한 줄 정의

스택을 모듈과 라이브러리로 나눌 때, 그 컴포넌트가 감춰 주는 복잡성이 한 겹을 더 얹는 비용보다 큰지를 기준으로 패턴과 안티패턴을 가려내는 방법입니다.

쉽게 말하면

카페 키오스크의 ‘아메리카노’ 버튼은 원두 몇 그램, 물 몇 밀리리터, 몇 초 추출인지를 감춰 주고 손님은 사이즈만 고르면 됩니다. 그런데 버튼을 눌렀더니 원두 그램수와 물 온도를 그대로 다시 물어본다면, 그 버튼은 메뉴판을 한 겹 더 얹었을 뿐 아무것도 대신해 주지 않습니다. 반대로 같은 버튼이 옵션에 따라 커피가 나오기도 하고 피자가 나오기도 한다면, 이번엔 감춘 게 너무 많아서 아무도 결과를 예측하지 못합니다.

이 장의 패턴과 안티패턴은 전부 이 하나의 질문으로 수렴합니다. 이 컴포넌트가 감춰 주는 것이, 계층을 하나 더 만드는 비용을 상쇄하는가.

왜 중요한가?

모듈화를 하는 이유는 코드가 깔끔해 보여서가 아니라 재사용, 구성(구현 교체 가능성), 테스트 가능성, 팀 간 공유 중 하나를 얻기 위해서입니다. 이 목적 없이 나누면 남는 것은 오버헤드뿐입니다.

더 나쁜 것은 스택을 모듈과 라이브러리로 쪼개도 스택 인스턴스 자체는 작아지거나 단순해지지 않는다는 점입니다. 오히려 컴포넌트는 스택에 실제로 추가되는 인프라 리소스의 수와 복잡성을 시야에서 가려 상황을 악화시킬 수 있습니다. 그래서 추상화와 라이브러리를 쓰더라도 그 아래에 무엇이 있는지는 끝까지 이해하고 있어야 합니다.

핵심 내용

언어 유형이 컴포넌트 종류를 결정한다

언어 유형을 잘못 고르면 코드가 스택 컴포넌트와 충돌하고 선언형과 명령형이 뒤섞입니다. 그래서 “어떤 컴포넌트를 만들까”보다 “어떤 언어를 쓰고 있는가”를 먼저 봐야 합니다.

선언형 모듈명령형 라이브러리
도구CloudFormation 중첩 스택, Terraform 모듈Pulumi, AWS CDK
능력HCL 표현식 하위 언어 수준의 최소한의 프로그래밍만 가능범용 언어이므로 조건·반복·객체 구성이 자유로움
잘 맞는 일비슷한 인프라 컴포넌트를 정의하고, 플랫폼 리소스를 래핑·단순화사용 방식에 따라 리소스를 동적으로 프로비저닝하는 복잡한 논리
테스트적용 결과가 크게 다르지 않아 포괄적 커버리지는 불필요. 단 여러 선언을 결합해 복잡한 엔티티를 만든다면 요구사항 충족 여부를 테스트동적 논리 자체가 테스트 대상

ShopSpinner의 애플리케이션 서버 인프라가 좋은 예입니다. 어떤 애플리케이션은 공용이고 어떤 것은 내부용인데, 두 경우 모두 IP 주소와 DNS 이름을 할당하고 게이트웨이에 경로를 만들어야 하지만 값은 서로 다르고 공용 쪽에만 방화벽 규칙이 필요합니다. 이 분기를 스택 코드에서 걷어내려면 라이브러리가 필요합니다.

application_networking = new ApplicationServerNetwork(PUBLIC_FACING, "checkout")

virtual_machine:
  name: appserver-checkout
  vlan: $(application_networking.address_block)
  ip_address: $(application_networking.private_ip_address)

ApplicationServerNetwork 안에서는 접근 유형에 따라 공용 VLAN을 잡고 공용 IP를 할당하고 DNS 레코드를 만들고 443 방화벽 규칙을 여는 일이 벌어집니다. 스택 코드에는 그 분기가 전혀 보이지 않는다는 점이 핵심입니다.

컴포넌트 패턴 세 가지

패턴감싸는 대상적합한 언어판별 기준
퍼사드 모듈단일 인프라 리소스선언형하드코딩된 값이 다수, 노출 파라미터는 소수
번들 모듈응집력 있는 여러 리소스(서버 클러스터 + 로드 밸런서 + DNS 항목)선언형리소스 집합이 고정적, 핵심 리소스를 중심으로 상향식 구성
인프라 도메인 엔티티상위 레벨 개념(애플리케이션을 실행하는 데 필요한 인프라 전체)명령형파라미터에 따라 리소스를 동적으로 생성, 사용 사례에서 출발하는 하향식 구성
퍼사드 모듈

기본 인프라 리소스의 일반적인 사용 사례를 단순화하고 표준화합니다. 래퍼 모듈이라고도 부릅니다.

use module: shopspinner-server
  name: checkout-appserver
  memory: 8GB

모듈 안에는 source_image: hardened-linux-base, 프로비저닝 도구, 역할, VLAN이 하드코딩되어 있습니다. 사용자는 이름과 메모리만 정하면 되고, 이 모듈로 만든 모든 서버는 자동으로 하드닝된 이미지를 씁니다. 모듈 코드의 품질 개선이 그것을 쓰는 모든 스택에 즉시 반영된다는 것이 가장 큰 이점입니다.

대가는 유연성입니다. 사용 방법을 제한하기 때문에 모든 사례에 쓸 수 없고, 인프라 리소스와 스택 코드 사이에 계층이 하나 늘어나 유지보수·디버깅·이해에 약간의 오버헤드가 붙습니다.

번들 모듈

여러 관련 리소스를 간소화된 인터페이스 하나로 선언합니다. 아래 호출 하나가 서버 클러스터, 로드 밸런서, DNS 항목을 함께 만듭니다.

use module: application_server
  service_name: checkout_service
  application_name: checkout_application
  application_version: 1.23
  min_cluster: 1
  max_cluster: 3
  ram_required: 4GB

여기서 진짜 가치는 리소스를 묶었다는 사실이 아니라 필수 요소들을 공통의 목적을 위해 어떻게 연결하는지에 대한 지식 을 코드에 담았다는 데 있습니다. DNS 항목의 IP가 로드 밸런서의 IP를 가리키고 로드 밸런서의 타깃이 서버 클러스터를 가리키는 배선을 매번 다시 쓰지 않아도 됩니다.

주의할 점은 상황에 따라 필요 이상의 리소스를 프로비저닝한다는 것입니다. 사용 사례가 지나치게 다양해지면 번들 모듈은 답이 아닙니다.

인프라 도메인 엔티티

여러 하위 레벨 리소스를 결합해 상위 레벨 개념 하나를 구현합니다. 호출부만 보면 번들 모듈과 닮았지만, 파라미터가 인프라 용어가 아니라는 점이 결정적으로 다릅니다.

use module: application_server
  service_name: checkout_service
  application_name: checkout_application
  application_version: 1.23
  traffic_level: medium

min_cluster: 1 대신 traffic_level: medium입니다. 라이브러리 안에서 트래픽 수준을 클러스터 크기로 번역합니다.

switch (${traffic_level}) {
  case ("high")   { $appserver_cluster.min_size = 3; $appserver_cluster.max_size = 9 }
  case ("medium") { $appserver_cluster.min_size = 2; $appserver_cluster.max_size = 5 }
  case ("low")    { $appserver_cluster.min_size = 1; $appserver_cluster.max_size = 2 }
}

이 패턴은 인프라 플랫폼팀이 다른 팀에게 조립 재료를 제공하는 구조에서 특히 의미가 있습니다. 소비자 팀은 인프라 리소스가 아니라 자기 서비스의 요구사항으로 말하면 됩니다.

구현의 관건은 코드가 아니라 설계입니다. 도메인 주도 설계(DDD)에서 파생된 패턴이므로, 소프트웨어로 설계하고 구축한 인프라 그 자체를 하나의 도메인으로 보고 개념 모델을 먼저 그리는 접근이 필요합니다.

안티패턴 세 가지

안티패턴정체신호처방
난독화 모듈감싸기만 하고 단순화하지 않는 퍼사드모듈 파라미터가 기본 리소스 속성과 사실상 1:1스택 언어를 직접 쓰는 코드로 리팩터링
비공유 모듈코드베이스에서 딱 한 번만 사용되는 모듈재사용처가 0곳다른 파일·폴더로 분리하거나, 스택 자체를 여러 스택으로 분할
스파게티 모듈선언형 언어로 도메인 엔티티를 흉내 낸 번들파라미터에 따라 결과가 눈에 띄게 달라지고 조건문이 겹겹이 쌓임세분화된 여러 모듈로 분할하거나 명령형 도메인 엔티티로 전환
난독화 모듈 — 퍼사드와 나란히 놓으면 차이가 보입니다
use module: any_server
  server_name: checkout-appserver
  ram: 8GB
  source_image: base_linux_image
  provisioning_tool: servermaker
  server_role: application_server
  vlan: application_zone_vlan

모듈 정의는 이 파라미터를 하나도 빠짐없이 스택 도구 코드에 그대로 전달할 뿐입니다. 앞의 퍼사드 모듈이 두 줄만 받았던 것과 비교하면, 이 모듈은 감춰 주는 것 없이 이름만 바꿔 놓은 별칭입니다.

의도적으로 이렇게 쓰는 사람은 없습니다. 보통은 DRY를 목표로 공통 리소스 유형을 한 번 선언해 모든 곳에서 쓰려다가, 각 사용처가 조금씩 다르기 때문에 결국 모든 속성을 파라미터로 노출하게 되면서 생깁니다. 스택 도구의 언어가 마음에 들지 않아 자기 언어로 ‘개선’하려는 시도도 같은 결과를 냅니다.

Quote

컴포넌트는 오버헤드로 인한 비용을 상쇄할 수 있을 만큼의 충분한 가치를 창출해야 한다.

여기서 오버헤드란 관리해야 하는 코드, 학습에 필요한 시간, 빌드·딜리버리 파이프라인을 추가로 하나 더 갖는 부담 전부를 말합니다. 주어진 모듈이 난독화인지 퍼사드인지 팀에서 논쟁하는 것 자체가 유용합니다.

비공유 모듈 — 재사용 없는 재사용 컴포넌트

스택 프로젝트 코드가 길어지면 모듈로 나누고 싶어지는데, 코드 구성이 목적이라면 모듈은 과한 도구입니다. 버전 관리와 여러 개의 작은 부품이 딸려 와서 코드베이스에 오버헤드만 늘어납니다. 재사용할 필요가 없을 때 재사용 가능한 모듈을 만드는 것은 전형적인 YAGNI 위반입니다.

스택이 너무 커진 게 문제라면 스택 구조 패턴으로 여러 스택으로 쪼개는 것이 먼저이고, 스택이 충분히 응집력 있다면 그냥 파일이나 폴더를 나누는 것으로 충분합니다. 판단이 서지 않을 때는 3의 법칙 이 기준이 됩니다. 쓸 곳이 세 군데가 되었을 때 재사용 가능한 컴포넌트로 전환합니다.

스파게티 모듈 — 선언형의 한계가 드러나는 지점
declare module: application-server-infrastructure
  variable: network_segment = {
    if ${parameter.network_access} = "public"
      id: public_subnet
    else if ${parameter.network_access} = "customer"
      id: customer_subnet
    else
      id: internal_subnet
    end
  }

  switch ${parameter.application_type}:
    "java": ...
    "NET":  ...
    "php":  ...

  switch ${parameter.database}: ...

서버를 세 네트워크 세그먼트 중 하나에 할당하고, 선택적으로 데이터베이스 클러스터를 만들고, 경우에 따라 가상 서버 대신 컨테이너 인스턴스 그룹을 만듭니다. 계획적이지 않고 직관에 기대어 늘어난 결과입니다.

이런 모듈은 조금만 바꿔도 무엇이 깨질지 알 수 없어 유지보수가 어렵고, 무엇보다 테스트하기 어렵습니다. 자동화 테스트와 파이프라인을 만들기 어렵다는 것 자체가 스파게티 모듈이 있다는 신호입니다.

해법은 세분화된 임무를 가진 모듈로 분할하는 것입니다.

use module: java-application-servers
  name: checkout_appserver
  application: "shopping_app"
  application_version: "4.20"
  network_segment: customer_subnet
  server_configuration:
    database_connection: ${module.mysql-database.outputs.connection_string}

use module: mysql-database
  cluster_minimum: 1
  cluster_maximum: 3
  allow_connections_from: customer_subnet

조건문으로 감췄던 선택이 호출부에 드러나면서, 각 모듈은 작고 단순해지고 개별 테스트가 가능해집니다.

추상화 계층

재사용 가능하고 조립 가능한 컴포넌트 집합은 그 자체로 인프라 리소스에 대한 추상화 계층 역할을 합니다. 애플리케이션팀이 애플리케이션 서버, 데이터베이스 인스턴스, 메시지 큐 접근을 포함하는 환경을 정의할 때, 라우팅과 리소스 권한의 조합 규칙 같은 세부사항은 컴포넌트가 흡수합니다.

이 계층은 하위 레벨 기술을 모르는 팀만을 위한 것이 아닙니다. 충분히 잘 아는 팀에게도 지금 이 순간 집중해야 할 레벨의 문제에만 집중하게 해 준다는 점에서 유용합니다. 대신 필요할 때 기본 컴포넌트까지 드릴다운할 수 있어야 한다 는 단서가 붙습니다.

계층은 라이브러리와 여러 컴포넌트를 만들다 보면 유기적으로 생겨나지만, 그렇게 생긴 계층이 저절로 잘 맞물리지는 않습니다. 컴포넌트들이 함께 잘 작동하도록 응집력 있는 상위 수준의 설계와 표준을 갖는 편이 낫습니다.

구조적으로는 상위 레벨의 선언형 언어로 애플리케이션 런타임 환경의 요구사항을 지정하고, 그것이 하위 레벨의 명령형 언어로 작성된 동적 컴포넌트를 호출하는 형태가 자주 나옵니다. 정적인 퍼사드·번들 모듈로도 계층을 만들 수는 있지만, 계층의 컴포넌트에는 더 높은 유연성이 요구되므로 도메인 엔티티 같은 동적 컴포넌트가 더 잘 맞습니다.

비교 / 트레이드오프

패턴과 안티패턴은 별개의 목록이 아니라 같은 축의 양 끝입니다. 이렇게 보면 지금 만들고 있는 모듈이 어느 쪽으로 기울고 있는지 판단하기 쉬워집니다.

축좋은 쪽나쁜 쪽갈림길
단일 리소스를 감쌀 때퍼사드 모듈난독화 모듈감춘 만큼 사용자가 정할 게 줄었는가
여러 리소스를 묶을 때번들 모듈스파게티 모듈만들어지는 리소스 집합이 파라미터와 무관하게 고정적인가
동적 구성이 필요할 때인프라 도메인 엔티티스파게티 모듈명령형 언어로 정면 돌파했는가, 선언형에서 조건문으로 버텼는가
모듈을 만들지 말지3의 법칙 이후 추출비공유 모듈지금 쓸 곳이 세 군데 있는가

번들 모듈과 도메인 엔티티는 결과물이 비슷해 보여서 가장 헷갈리는 쌍인데, 출발점이 반대입니다.

번들 모듈인프라 도메인 엔티티
접근 방향상향식 — 생성할 인프라 리소스에서 시작하향식 — 사용 사례에 필요한 것에서 시작
결과물큰 변형 없이 상당히 정적인 리소스 집합파라미터에 따라 달라지는 동적 리소스 집합
파라미터 언어인프라 용어(min_cluster, ram_required)도메인 용어(traffic_level)
응집력이 낮아지면—스파게티 모듈이 됨

내 생각

  • 실무에서 마주치는 Terraform 모듈의 상당수는 난독화 모듈입니다. variable이 resource 속성과 1:1로 대응하고 있다면 그건 모듈이 아니라 별칭이고, 파이프라인과 버전 태그만 하나 더 늘린 셈입니다.
  • 비공유 모듈은 modules/ 디렉터리를 먼저 만들고 시작하는 관성에서 나옵니다. 코드 구성이 목적이라면 network.tf, database.tf로 파일을 나누는 것으로 끝나는 일입니다.
  • “테스트 파이프라인을 못 만들겠다”가 스파게티 모듈의 신호라는 진단이 정확합니다. 15장의 “테스트 가능성이 설계를 강제한다”가 모듈 레벨에서 다시 나온 것으로, 어떤 조합을 검증해야 할지 나열조차 안 된다면 이미 분할 시점입니다.
  • 도메인 엔티티는 지금의 플랫폼 엔지니어링과 정확히 같은 이야기입니다. 서비스 팀이 traffic_level: medium으로 말하고 플랫폼팀이 그것을 클러스터 크기로 번역하는 구조가 곧 IDP이고, 이 책이 2020년에 쓰였다는 점을 감안하면 방향 예측이 잘 맞았습니다.
  • 드릴다운 가능해야 한다는 단서를 가볍게 넘기면 안 됩니다. 추상화 계층이 완전한 블랙박스가 되면 평시에는 편하지만 장애 때 아무도 손을 못 대고, 그때 플랫폼팀은 병목이 됩니다.

관련 개념