Terraform Network 모듈 설계 ㅡ VPC부터 SG까지 의존성 사슬 그리기

2026. 6. 13. 15:31카테고리 없음

 

지난 글에서 ECR 모듈로 테라폼의 기초를 익혔다면,

 

이번에는 좀 더 복잡한 네트워크(Network) 모듈을 설계할 차례다.

네트워크 모듈의 리소스는 아래와 같다.

  • VPC, Subnet, IGW, NAT, EIP, Route Table, Route Table Association, Security Group

의존성 사슬도 길어져 어디서부터 손대야 할지 막막한 단계였다.

이번 글은 모듈 코드 자체보다 의사결정에 비중을 둔다. (코드는 Terraform 문서가 더 잘 설명하기에)

내가 정리해두고 싶은 건 "왜 이렇게 골랐는가" 이다.

 

그림부터

Flicker가 필요로 하는 네트워크 구조는 이렇다.

                   Internet
                       |
                  Internet Gateway
                       |
        +--------------+--------------+
        |                             |
   Public Subnet AZ-a            Public Subnet AZ-c
   10.0.1.0/24                   10.0.2.0/24
   (ALB 존재)                    (ALB 존재)
        |
   NAT Gateway (단일)
        |
        +--------------+--------------+
        |                             |
   Private Subnet AZ-a           Private Subnet AZ-c
   10.0.11.0/24                  10.0.12.0/24
   (EC2, RDS, Redis)             (EC2, RDS, Redis)
 

세 가지가 핵심이다.

  1. Public/Private 분리 — ALB는 인터넷이 직접 닿는 Public. EC2(웹), DB는 Private 서브넷
  2. Multi-AZ — ALB가 최소 2개 AZ를 요구함
  3. NAT Gateway — Private 안의 EC2가 외부로 나갈 길 (yum update, ECR pull, API 호출 등)

 

여기서 첫 번째 비용 의사결정이 나온다.

NAT Gateway — 단일 vs Multi-AZ

NAT Gateway는 시간당 ~$0.045 + 데이터 전송 비용이다. 매월 약 $32. 운영 표준은 AZ마다 하나씩 두는 Multi-AZ인데, 그러면 $32 × 2 = $65가 돼버려서, 사이드 프로젝트에선 솔직히 부담이다.

 

단일 NAT의 트레이드오프는 명확하다. NAT가 위치한 AZ가 다운되면, 다른 AZ의 Private Subnet은 외부 통신이 끊긴다. Public Subnet의 ALB는 살아있지만 Private의 EC2가 ECR pull을 못 한다. 새 배포 막힘. 기존 트래픽은 ALB가 살아있는 AZ로 라우팅돼서 일단 돌아간다.

 

이걸 변수로 표현했다.

variable "enable_nat_gateway" {
  type    = bool
  default = true
}

variable "single_nat_gateway" {
  description = "Use single NAT Gateway across all AZs (cost saving, less HA)"
  type        = bool
  default     = true
}

 

dev 환경에선 enable_nat_gateway = false로 해서 통째로 끌 수도 있다. 외부 통신이 필요 없는 격리 환경이면 NAT 자체가 불필요하다. (변수 하나로 NAT GateWay 설정 가능)
 
운영 환경에선 Multi-AZ NAT 권장이지만, 비용 트레이드오프로 단일 AZ 선택했다.
 
 
 

count 패턴 — AZ 수만큼 반복

AZ 2개에 Subnet도 2개씩(public, private 합쳐서 4개). 손으로 4개 박지 않는다.

resource "aws_subnet" "public" {
  count = length(var.azs)

  vpc_id                  = aws_vpc.this.id
  cidr_block              = var.public_subnet_cidrs[count.index]
  availability_zone       = var.azs[count.index]
  map_public_ip_on_launch = true

  tags = merge(var.tags, {
    Name = "${var.project_name}-public-${var.azs[count.index]}"
    Tier = "public"
  })
}
 

count.index로 리스트에 인덱싱

  • AZ가 a, c 순서면 count.index 0은 a, 1은 c. AZ를 3개로 늘리고 싶으면 var.azs 값만 변경해주면 된다. (코드 변경X)
  • 이렇게 변수를 통해 동일 코드를 재사용할 수 있다. (개발 때 배웠던 OOP 원칙이랑 겹치는 것 같다)

map_public_ip_on_launch = true는 Public Subnet에만.

이 리소스(Public AZ) 에 들어오는 NAT나 ALB는 자동으로 공인 IP를 받는다. Private엔 false (기본값).

 

NAT count의 조건 체인

NAT 개수가 좀 복잡하다.

resource "aws_nat_gateway" "this" {
  count = var.enable_nat_gateway ? (var.single_nat_gateway ? 1 : length(var.azs)) : 0
  ...
}

 

세 가지 경우
  • enable_nat_gateway = false → 0개. NAT 안 만듦
  • enable_nat_gateway = true && single_nat_gateway = true → 1개. 비용 절약 모드
  • enable_nat_gateway = true && single_nat_gateway = false → AZ 수만큼. Multi-AZ 모드

EIP도 같은 식으로 NAT 수만큼 만든다. NAT 하나당 EIP 하나가 짝임

resource "aws_eip" "nat" {
  count  = var.enable_nat_gateway ? (var.single_nat_gateway ? 1 : length(var.azs)) : 0
  domain = "vpc"
  ...
}
 
 
Route Table 리소스 (dynamic 블록)
resource "aws_route_table" "private" {
  count = var.enable_nat_gateway ? (var.single_nat_gateway ? 1 : length(var.azs)) : 1

  vpc_id = aws_vpc.this.id

  dynamic "route" {
    for_each = var.enable_nat_gateway ? [1] : []
    content {
      cidr_block     = "0.0.0.0/0"
      nat_gateway_id = aws_nat_gateway.this[count.index].id
    }
  }
  ...
}

 

 

 

NAT Gateway는 Public Subnet에 위치하는 리소스이다.

그리고 외부 인터넷과 직접 통신할 수 없는 Private Subnet의 리소스(EC2 등)가 아웃바운드(예: 오픈 API 호출, 패키지 다운로드) 요청을 보낼 때, 중간에서 다리 역할을 해주는 녀석이다.

 

Route Table(라우팅 테이블)은 '이정표'다

VPC 안에서 모든 서브넷은 반드시 하나의 라우팅 테이블과 연결되어 있어야 한다. 라우팅 테이블이 없으면 서브넷 내부의 트래픽이 어디로 가야 할지 모르는 미아가 되기 때문

 

💡 NAT GW가 있을 때의 Private 라우팅 테이블 (이정표 2개)

NAT Gateway를 쓸 때는 Private 서브넷의 라우팅 테이블에 다음과 같이 2개의 이정표가 들어간다.

  1. 10.0.0.0/16 -> local: VPC 내부 리소스들끼리 통신할 때는 이 안에서 찾아라.
  2. 0.0.0.0/0 (모든 외부 인터넷) -> nat-xxxxxx: VPC 내부 주소가 아닌 완전 외부 인터넷으로 나가는 트래픽은 Public 서브넷에 있는 NAT Gateway로 던져라.

(코드 설명)

만약 개발(dev) 환경이라 비용을 아끼려고 NAT Gateway를 생성하지 않았다면 (var.enable_nat_gateway = false) 가 됨.

dynamic "route" 블록이 조절하는 것은 aws_route_table 리소스 자체는 아니고, 내부의 "특정 이정표 규칙(Route)"만 넣었다가 뺐다가 하는 것. -> 그것이 dynamic 문법

 

  • NAT가 있으면: 라우팅 테이블이 만들어지고, 그 안에 0.0.0.0/0 규칙(NAT 방향)이 포함됩니다.
  • NAT가 없으면: 라우팅 테이블은 똑같이 만들어지지만, 그 안에 0.0.0.0/0 규칙이 쏙 빠진 채로 (local 규칙만 가진 채) 생성됩니다.

 

 

Route Table Association 

Route Table을 Subnet에 붙이는 단계이다.

resource "aws_route_table_association" "private" {
  count = length(var.azs)

  subnet_id      = aws_subnet.private[count.index].id
  route_table_id = var.single_nat_gateway ? 
                   aws_route_table.private[0].id : 
                   aws_route_table.private[count.index].id
}

 

route_table_id가 조건부. 단일 NAT면 모든 Private Subnet이 같은 Route Table을 가리킨다(인덱스 0).

Multi-AZ면 각 Subnet이 자기 AZ의 Route Table을 가리킨다.

 

 

Security Group

CIDR을 박지 않고, 다른 SG를 source로 구성했다.

resource "aws_security_group" "app" {
  name   = "${var.project_name}-app-sg"
  vpc_id = aws_vpc.this.id

  ingress {
    description     = "Blue container (8080) from ALB"
    from_port       = 8080
    to_port         = 8080
    protocol        = "tcp"
    security_groups = [aws_security_group.alb.id]
  }
  ...
}

 

security_groups = [aws_security_group.alb.id]. SG ID를 source로 받는다.

ALB SG가 붙은 어떤 리소스든 app 컨테이너에 접근 가능. ALB 인스턴스가 늘어나거나 IP가 바뀌어도 SG 규칙은 그대로다.

 

CIDR로 박으면 어떤 일이 일어나는가?

첫째, ALB의 ENI IP가 바뀔 때마다 SG 갱신 필요

둘째, "10.0.0.0/16"로 Route Table 이 구성되어 있는데(앞부분 참조), 그럼 VPC 내부 어떤 리소스든 8080 접근 가능

-> 최소 권한 원칙 위배

 

이 패턴을 4개 SG에 일관되게 적용했다.

SG Source Destination Port
alb-sg 0.0.0.0/0 80, 443
app-sg alb-sg 8080, 8081
db-sg app-sg 3306
redis-sg app-sg 6379

 

0.0.0.0/0은 ALB만. 나머지는 다 SG → SG

 

description을 매번 작성한 이유

ingress {
  description = "Blue container (8080) from ALB"
  ...
}

description은 선택이지만, 나중을 생각해서 무조건 쓰기로 했다.

AWS 콘솔에서 SG rule 목록 열면 description이 그대로 보인다. "이 8080 inbound가 왜 있더라?" 같은 질문에 즉시 답이 된다

 

 

Network 모듈의 output — 모듈 합성을 위한 인터페이스

다른 모듈들이 Network 모듈의 결과를 받아 써야 한다. VPC ID, Subnet IDs, SG IDs.

output "vpc_id" {
  value = aws_vpc.this.id
}

output "public_subnet_ids" {
  value = aws_subnet.public[*].id
}

output "private_subnet_ids" {
  value = aws_subnet.private[*].id
}

output "alb_security_group_id" {
  value = aws_security_group.alb.id
}

 

 

 

aws_subnet.public[*].id. * splat 연산자로 count로 만든 모든 리소스의 ID를 리스트로 뽑는다.

ALB가 이 리스트를 그대로 받아 multi-AZ 배포된다.

module "alb" {
  source = "../../modules/alb"

  vpc_id            = module.network.vpc_id
  public_subnet_ids = module.network.public_subnet_ids
  security_group_id = module.network.alb_security_group_id
}

 

이게 모듈 합성의 정석이다. Network 모듈의 output → ALB 모듈의 input. Terraform이 의존성 자동 추적. Network가 먼저 생성, 그 다음 ALB. 명시적 depends_on 안 박아도 된다.
 
 
 

한 가지 미스가 있었다.

CORS configuration 리소스를 모듈 main.tf에 박는 걸 빠뜨려서 plan이 10 to add로 잡혔다. 의도는 11.

variables.tf에는 cors_allowed_origins 변수가 있고, environments에서도 값을 넘기는데, 정작 모듈 안에서 그 변수를 받아 리소스를 만드는 코드가 없었다.

 

plan 결과 숫자를 의도와 비교하는 습관이 이걸 잡아줬다. "분명 11개 나와야 하는데 10개네?" 한 줄 의심에서 시작해서 모듈 코드 다시 보고 누락 발견. 이런 검증 흐름이 Terraform에선 자주 일어난다고 한다.

plan 출력의 숫자를 머릿속 모델과 대조하는 습관을 가져야 한다.

 

plan 통과 ≠ 운영 가능

Network 모듈 plan이 통과했다고 인프라가 살아있는 건 아니다. terraform apply를 안 했으니까. 지금 콘솔에 있는 default VPC, ALB, RDS는 모두 손으로 만든 것들이고, Terraform 코드와 일치하지 않는다.

 

이걸 일치시키는 작업이 따로 남아있다. 통합 검증 때 콘솔 리소스 destroy → terraform apply → 동작 검증 사이클을 한 번에 돈다. 이때 의존성 사슬이 진짜로 작동하는지, 모듈 간 output 전달이 깨지지 않는지 확인할 예정이다.