[코드가 환경을 모르는 구조 3/7] IaC에도 헥사고날이 관통한다

HCL이 커지면 생기는 일
오타 하나를 잡으려 terraform plan을 돌리며 수십 초를 기다려 본 사람은, HCL의 한계를 이미 몸으로 압니다. Terraform의 HCL은 IaC가 선언된 이래 사실상의 표준 언어가 되었고, 초창기에 해결한 문제는 분명했습니다. GUI 클릭 대신 파일에 구성을 적어 두면 그 파일이 곧 현재 상태의 정의가 되고, Git이 이력을 보관하며, 팀원 전체가 같은 것을 봅니다.
문제는 프로젝트가 커지면서 시작됩니다. 모듈이 모듈을 부르고, 변수가 변수를 엮고, 조건 분기가 중첩된 삼항 연산자로 변합니다. 타입 시스템이 약한 HCL에서는 많은 오류가 terraform plan을 돌려야 드러나는데, plan은 네트워크를 타고 상태를 가져오므로 빠르게 루프를 돌릴 도구가 아닙니다. 실행해서야 "module X expects variable Y"를 만나는 일이 반복됩니다.
더 근본적인 문제는 "무엇을"과 "어디서"의 경계가 HCL 안에서 흐릿해진다는 점입니다. 모듈 하나가 "VPC를 만든다"는 의미와 "AWS에서 VPC를 만든다"는 의미를 동시에 지고, NCP나 Azure를 추가하려는 순간 별도의 모듈 체계를 따로 세워야 합니다. 같은 "가상 네트워크"인데 클라우드마다 추상이 갈라지고, 클라우드 두세 개를 다루는 코드는 서로 다른 언어 두세 개를 동시에 쓰는 느낌을 줍니다.
flex는 이 문제를 Kotlin + Pulumi 기반의 멀티모듈 구조로 다시 풀었습니다. 중심에 있는 아이디어는 본편 5화의 그 문장입니다. "IaC에도 헥사고날이 관통한다."
flex-terraform의 모듈 지도
flex의 인프라 코드는 Gradle 멀티모듈 프로젝트입니다. 각 모듈의 역할을 한 줄로 정리하면 이렇습니다.
spec모듈: 인프라 개념의 계약을 담은 인터페이스. VirtualNetwork, KubernetesCluster, WorkloadIdentity 같은 타입을 여기에 정의합니다.- 클라우드별 모듈(
aws,ncp,azure,gcp):spec의 인터페이스를 각 클라우드에서 구현합니다. Pulumi 리소스가 실제로 태어나는 곳입니다. - 제품/도메인 모듈(
flex,leaf): spec 인터페이스 위에서 "우리 제품에 필요한 네트워크는 이런 모양이다"라는 구성을 조립합니다. - 환경 스택 디렉토리(
dev,qa,prod등): 어느 모듈을 어떤 파라미터로 인스턴스화할지 결정합니다.

이 지도에서 중요한 것은 의존성의 방향입니다. spec 모듈은 어떤 클라우드도 모릅니다. 클라우드 모듈은 spec만 바라보며 서로를 모릅니다. 제품 모듈은 spec에만 의존하고, 구체 클라우드는 주입받을 뿐 직접 import 하지 않습니다. 가장 바깥의 환경 스택만이 "이 제품은 dev에서 AWS를 쓴다" 같은 조립을 수행합니다.
덕분에 같은 개념을 한 곳에만 정의합니다. VirtualNetwork는 spec에 한 번 선언되고, AWS 구현체가 VPC를, NCP 구현체가 그에 대응하는 개념을 각각 만듭니다. 제품 모듈은 VirtualNetwork 인터페이스 하나만 알면 되고, 어느 클라우드에서 실행되든 같은 코드가 그대로 돕니다.
spec 모듈은 Port, 클라우드 모듈은 Adapter
여기까지 오면 눈치챈 독자도 있을 겁니다. 이 구조는 애플리케이션 코드에서 자주 말하는 헥사고날 아키텍처와 같은 모양입니다. spec이 Port, 클라우드 모듈이 Adapter, 제품 모듈이 도메인이며, Port만 아는 도메인 아래에서 Adapter는 갈아 끼울 수 있습니다.
핵심 계약은 Kotlin 인터페이스로 씁니다. 가상 네트워크 Port는 이렇게 생겼습니다.
interface VirtualNetwork {
val vpcId: Output<String>
val privateSubnetIds: Output<List<String>>
val publicSubnetIds: Output<List<String>>
val cidrBlock: Output<String>
}선언이 말하는 바는 간단합니다. "가상 네트워크라면 VPC ID와 서브넷 목록과 CIDR 블록을 Output 형태로 내놓아라." AWS VPC냐, NCP VPC냐, Azure VNet이냐는 한 줄도 등장하지 않습니다. 이것이 Port입니다.

Adapter는 각 클라우드 모듈이 맡습니다. AWS Adapter는 aws.Vpc, aws.Subnet 리소스를 Pulumi로 만들어 VirtualNetwork 인터페이스로 노출하고, NCP Adapter는 NCP의 VPC API를 호출하며, Azure Adapter는 VNet과 Subnet을 엮습니다. 각 Adapter가 자기 클라우드의 관용과 특수 사항을 안쪽에서 처리할 뿐, 바깥에서는 모두 같은 VirtualNetwork로 보입니다.
제품 모듈의 코드는 한결같이 VirtualNetwork에만 의존합니다. EKS 클러스터 모듈은 "클러스터는 VirtualNetwork 위에 얹힌다"만 알고, 그 VirtualNetwork가 AWS인지 NCP인지는 주입하는 쪽이 정합니다. 환경 스택이 dev에는 AWS Adapter, 다른 환경에는 NCP Adapter를 끼워 주면, 같은 클러스터 코드가 양쪽에서 그대로 작동합니다.
컴파일 타임 검증이라는 보상
Kotlin 위에서 돌아간다는 점은 단순한 문법 선호의 문제가 아닙니다. 컴파일러가 경계를 대신 검사해 준다는 것, 이것이 실질적인 보상입니다.
Port와 Adapter가 인터페이스와 구현의 관계이므로, 어느 Adapter가 Port 계약을 어기는 순간 컴파일이 실패합니다. Output<String>을 요구한 자리에 String?을 끼우면 IDE가 즉시 빨간 줄을 긋습니다. HCL에서는 terraform plan을 돌려야 알 수 있던 실수를, Kotlin에서는 코드를 입력하는 도중에 잡습니다.
물론 타입이 커버하지 못하는 영역도 있습니다. "리소스 이름 길이 제한이 클라우드마다 다르다" 같은 제약은 여전히 Adapter 내부의 유효성 검증이 막아야 합니다. 그래도 많은 실수는 타입 수준에서 차단되어 리뷰 부담과 CI 시간을 줄여 줍니다. "계약은 인터페이스가 붙잡고, 특수 사항은 Adapter가 안쪽에서 처리한다"는 원칙이 여기서도 그대로 작동합니다.
또 하나의 효과는 새 클라우드를 붙이는 비용입니다. GCP를 도입할 때 해야 할 일은 새 Adapter 모듈을 만들고 spec 인터페이스를 구현하는 것뿐이고, 기존 제품 모듈과 환경 스택은 건드리지 않아도 됩니 다. 헥사고날이 "새로운 저장소를 붙일 때 도메인 로직을 건드리지 않아도 되게 한다"며 주는 이득과 같은 모양의 이득입니다.
라이프사이클 기반 스택 분리
헥사고날이 공간축의 분리라면, 다음 문제는 시간축입니다. flex는 라이프사이클 기반으로 Pulumi 스택을 쪼갰습니다.
인프라 리소스는 변경 주기가 제각각입니다. VPC와 서브넷 같은 네트워크는 한 번 만들어지면 수 년 동안 그대로이고, EKS 클러스터는 분기에 한 번 버전을 올리거나 노드 그룹을 갱신하며, ServiceAccount와 IRSA는 주 단위로 새 서비스가 추가될 때마다 늘어납니다. 이 셋을 하나의 거대한 스택에 몰아넣으면, 서비스 계정 하나를 추가하려 해도 네트워크와 클러스터 전체의 plan이 돕니다. plan은 길어지고, 블라스트 반경은 넓어지며, 작은 변경에 대한 신뢰도는 떨어집니다.
그래서 flex는 네트워크 스택, 클러스터 스택, 아이덴티티 스택을 분리합니다. 각 스택은 자기 라이프사이클에 맞는 속도로 갱신되고, 스택 사이는 Pulumi의 StackReference가 이어 줍니다. 클러스터 스택은 네트워크가 어느 클라우드에서 태어났는지 알 필요가 없습니다. VirtualNetwork 인터페이스를 구현하는 StackReference 래퍼 하나만 주입받으면 됩니다.

물론 StackReference가 모든 문제를 자동으로 풀어 주지는 않습니다. 스택을 잘게 쪼갠 직후에는 의도치 않은 순환참조를 몇 번 마주했습니다. 아이덴티티 스택이 클러스터의 OIDC issuer를 참조하고, 클러스터 스택은 IRSA 역할의 ARN을 받으려 아이덴티티 스택을 참조하는 식으로 A↔B가 얽히면, Pulumi는 어느 쪽부터 업데이트해야 할지 결정하지 못하고 멈춥니다. 결국 "출력은 위에서 아래로만 흐른다"는 규칙을 더하고, 양방향 참조를 끊고, 필요한 값을 얇은 "공통 아웃풋 스택"에 한 번 모아 내려보내는 식으로 정리했습니다.
또 한 가지 숨어 있던 함정은 출력 계약의 버전 불일치입니다. 네트워크 스택이 privateSubnetIds 타입을 List<String>에서 Map<AZ, String>으로 바꾸는 순간, 아직 업데이트되지 않은 클러스터 스택이 기존 형태를 기대한 채 pulumi up을 돌리면 런타임에서야 캐스팅 실패로 터집니다. 지금은 spec 인터페이스를 바꿀 때 기존 필드를 @Deprecated로 남기고, 한 사이클은 양쪽을 모두 채워 내려보낸 뒤 제거하는 전이 절차를 규율로 둡니다. 컴파일 타임 검증은 스택 경계를 넘는 순간 약해진다 — 이 사실이 인터페이스를 바꿀 때 한 번 더 멈춰 생각하는 습관을 만들어 주었습니다.
이 분리는 애플리케이션 코드의 모듈 분리와 같은 결입니다. 변경 빈도가 다른 것을 한데 엮지 않는다. 경계는 변경 주기를 따라 그어지고, 경계를 넘는 의존은 인터페이스로만 통과합니다. 결과적으로 "VPC 설정을 바꾸다 서비스 계정이 말려 들어갔다"는 식의 사고가 줄고, 작은 변경의 plan은 작게 유지됩니다.
조직도 같은 사고방식으로 코드화된다
같은 원리는 서비스 인프라를 넘어 조직 운영 코드에서도 반복됩니다.
GitHub 리포지토리 설정은 github-terraform이 선언적으로 관리합니다. repositories.yaml에 리포지토리 이름과 사용할 정책 타입(trunk-based, gitflow 등)을 적어 두면, 타입별 모듈이 브랜치 보호 규칙과 웹훅을 적용합니다. 여기서도 모듈은 "무엇을"을 정의하고, yaml은 "어디에 적용할지"를 말합니다. 새 리포가 추가될 때 일어나는 일은 yaml에 한 줄이 늘어나는 것뿐입니다.
Okta SSO 설정도 마찬가지입니다. Jenkins를 SSO에 연결하는 모듈은 도메인 파라미터 하나만 받습니다. dev Jenkins와 prod Jenkins에 각각 인스턴스화되며, 모듈 코드에는 "dev"도 "prod"도 등장하지 않습니다. 한 모듈, 여러 인스턴스, 구분은 파라미터로만.
본편 5화가 조직 운영의 코드화까지 언급한 이유가 여기 있습니다. 같은 원칙이 서비스 인프라에 머물지 않고 개발 조직의 권한, 계정, 리포 설정까지 관통합니다. 그 관통이 가능하려면 각 레이어가 "무엇을"과 "어디서"를 가르는 기준선을 공유해야 합니다.
다시, 헥사고날의 이득
이 편을 한 문장으로 요약하면 이렇습니다. 인프라 코드에서 Port는 spec 모듈, Adapter는 클라우드 모듈, 도메인은 제품 모듈이다. 그 사이의 의존은 인터페이스만 통과한다. 이 구조는 Kotlin의 타입 시스템으로 경계를 컴파일 타임에 검사하고, 라이프사이클이 다른 리소스를 스택 단위로 분리해 블라스트 반경을 줄이며, 새 클라우드를 붙이는 비용을 인터페이스 구현 한 번으로 제한합니다.
본편 5화의 문장은 여기서도 유효합니다. 인프라 코드는 "무엇을" 정의하고, "어디서"는 환경 스택이 주입합니다. 다음 편에서는 이 원리가 애플리케이션 코드의 가장 깊은 곳, 시간이라는 비즈니스 로직의 핵심 변수에 어떻게 적용되는지를 봅니다.
다음 화에서는,
"타임머신. 시간 축을 교체한다. Clock을 주입받는 규율 하나로 어떻게 미래를 앞당겨 검증하는가." 에 대해서 이야기 합니다.

