AWS Private EC2 운영 가이드 2편: Terraform으로 VPC 인프라 구성하기 — VPC/Subnet/Route Table/SG/ALB/EC2를 단일 main.tf로

AWS Private EC2 운영 가이드 2편: Terraform으로 VPC 인프라 구성하기 — VPC/Subnet/Route Table/SG/ALB/EC2를 단일 main.tf로


서론

1편에서 “왜 Private Subnet인가”를 납득했다면, 2편은 그 아키텍처를 실제로 띄우는 편이다. 읽고 끝나는 글이 아니라, 이 글의 코드를 그대로 복붙해서 terraform apply만 하면 1편의 그림이 AWS 콘솔에 그대로 뜬다.

구성 방침은 하나다 — 단일 main.tf. 주니어에게 “파일 분리부터 하자”고 하면 변수가 어느 파일에 있는지 추적하느라 에너지가 샌다. 위에서 아래로 읽으면 의존 관계가 보이도록 하나의 파일에 주석 블록(# ===== VPC ===== 식)으로 구역만 나눈다. 프로덕션용 모듈 분리는 마지막 절에서 짚는다.

  • 1편 — 왜 Private Subnet인가?
  • 2편 — Terraform으로 VPC 인프라 구성하기 (이 글)
  • 3편 — SSM Session Manager로 Bastion 없이 접속하기
  • 4편 — GitHub Actions + SSM/CodeDeploy CI/CD 파이프라인
  • 5편 — 비용 분석과 최적화 전략

이 글의 대상은 Terraform은 hello_world 정도는 해봤지만 VPC 한 벌을 스스로 못 세워본 주니어다. 다 읽고 나면 “아 SG가 SG를 참조한다는 게 이런 거구나”와 “AZ마다 Route Table이 따로인 이유가 이거구나”가 남아야 한다.


TL;DR

  • 설계 확정: VPC 10.0.0.0/16 + /24 서브넷 4개(Public 2 + Private 2), 2AZ(ap-northeast-2a, 2c), NAT Gateway AZ별 1개.
  • 핵심 패턴: SG가 IP가 아니라 다른 SG를 참조한다. alb-sgec2-sgdb-sg로 체인이 이어져야 실무 SG 설계다.
  • Route Table은 AZ별로 분리해야 한다. 한 AZ의 NAT Gateway가 죽었을 때 다른 AZ 트래픽까지 말려드는 걸 막기 위해서다.
  • NACL은 기본값 그대로 둔다. SG는 stateful(돌아오는 트래픽 자동 허용), NACL은 stateless라 실무 99%는 SG만으로 충분하다.
  • 단일 main.tf로 시작한다. 공식 terraform-aws-modules/vpc/aws는 학습 단계에서는 추상화가 과해서 오히려 방해 — 프로덕션으로 가면서 모듈화한다.

1. 설계 결정 한눈에 보기

본론 들어가기 전에 이 글에서 만들 리소스를 한 장으로 정리한다. 각 결정의 는 해당 섹션에서 다룬다.

항목결정근거
VPC CIDR10.0.0.0/166만+ IP. 주니어가 머리로 계산 가능한 단위
Subnet/24 4개 (Public 2 + Private 2)각 254개 IP. AZ 식별이 CIDR만 봐도 됨
AZ 수2개 (ap-northeast-2a, 2c)1편의 Multi-AZ 기준 유지
IGW1개리전 하나에 1개면 충분
NAT GatewayAZ별 1개 (총 2개)AZ 장애 격리 — 이유는 3절에서
Route TablePublic 1개 + Private AZ별 1개AZ별 NAT을 가리키려면 분리 필요
Security Groupalb-sg, ec2-sg, db-sg 3개SG 참조 체인 패턴
NACL기본값 그대로Stateless의 번거로움 — 5절 참고
EC2t3.micro 2대 (Private Subnet에 분산)SSM 접속을 위해 IAM Role 부여
ALBInternet-facing, 양쪽 Public Subnet attach1편 “ALB는 AZ마다 만들지 않는다” 원칙

아키텍처 그림으로 한 번 더:

flowchart TB
    Internet([Internet])
    subgraph VPC["VPC 10.0.0.0/16"]
        IGW[Internet Gateway]
        ALB["ALB<br/>(public subnets 양쪽 attach)"]
        subgraph AZa["ap-northeast-2a"]
            PubA["Public Subnet<br/>10.0.1.0/24<br/>NAT GW A"]
            PriA["Private Subnet<br/>10.0.11.0/24<br/>EC2 app-a"]
        end
        subgraph AZc["ap-northeast-2c"]
            PubC["Public Subnet<br/>10.0.2.0/24<br/>NAT GW C"]
            PriC["Private Subnet<br/>10.0.12.0/24<br/>EC2 app-c"]
        end
    end
    Internet --> IGW
    IGW --> ALB
    ALB --> PriA
    ALB --> PriC
    PriA -.outbound.-> PubA -.-> IGW
    PriC -.outbound.-> PubC -.-> IGW

2. VPC와 Subnet — CIDR 설계와 2AZ 배치

2.1 왜 10.0.0.0/16 + /24 네 개인가

CIDR은 “IP 몇 개를 쓸 수 있는 대역인가”를 표시하는 표기법이다. /16은 IP 65,536개, /24는 256개(AWS는 그중 5개를 예약으로 쓰므로 실제 사용 가능 251개).

  • /16 VPC — 서비스 몇 개를 더 얹어도 IP가 마를 일이 없다. 172.16.0.0/16이나 192.168.0.0/16도 가능하지만, 10.0.0.0/16은 AWS 예제·사내망과 덜 겹쳐서 초보자 기준으로 가장 만만하다.
  • /24 Subnet — 각 서브넷에 IP 254개. EC2 수백 대를 한 서브넷에 넣을 일이 없다면 충분하고, 3번째 옥텟만 보면 어느 서브넷인지 한눈에 보인다.
  • AZ 식별이 쉬운 번호 체계 — Public은 10.0.1.x, 10.0.2.x(1자리), Private는 10.0.11.x, 10.0.12.x(2자리). CIDR만 보고도 “아 이건 Private AZ-c구나”가 바로 읽힌다.

참고: VPC CIDR은 생성 후 변경이 매우 까다롭다(Secondary CIDR 추가는 가능하지만 메인 대역 변경은 사실상 재구성). 실무에서는 처음에 넉넉하게 /16으로 잡고, 서브넷을 좁게 자른다.

2.2 Provider와 VPC 스켈레톤

# ===== Terraform & Provider =====
terraform {
  required_version = ">= 1.5.0"
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"  # 5.x: 이 글 작성 시점 최신 major
    }
  }
}

provider "aws" {
  region = "ap-northeast-2"  # 서울 리전 — 1편 기준 유지
}

# ===== VPC =====
resource "aws_vpc" "main" {
  cidr_block           = "10.0.0.0/16"   # 65K+ IP. 변경이 까다로워서 넉넉히 잡는다
  enable_dns_support   = true             # VPC 내부 DNS 해석 (기본 true)
  enable_dns_hostnames = true             # 기본 false — ALB DNS·3편 SSM Endpoint Private DNS에 필요
  tags = { Name = "private-ec2-vpc" }
}

enable_dns_hostnames = true는 뒤에서 ALB DNS 해석과 SSM 엔드포인트 Private DNS를 쓰려면 필수다. 기본값이 false라 깜빡하고 끄면 3편에서 SSM VPC Endpoint 붙일 때 문제가 생긴다.

2.3 Subnet 4개

# ===== Public Subnets =====
resource "aws_subnet" "public_a" {
  vpc_id                  = aws_vpc.main.id
  cidr_block              = "10.0.1.0/24"       # 3번째 옥텟 1자리 = Public 규약
  availability_zone       = "ap-northeast-2a"   # Subnet은 1개 AZ에 고정 — 여기가 AZ-a 축
  map_public_ip_on_launch = true                 # ★ 이 스위치가 "Public Subnet"의 본질 — EC2에 공인 IP 자동 부여
  tags = { Name = "private-ec2-public-a" }
}

resource "aws_subnet" "public_c" {
  vpc_id                  = aws_vpc.main.id
  cidr_block              = "10.0.2.0/24"
  availability_zone       = "ap-northeast-2c"   # Multi-AZ의 두 번째 축
  map_public_ip_on_launch = true
  tags = { Name = "private-ec2-public-c" }
}

# ===== Private Subnets =====
resource "aws_subnet" "private_a" {
  vpc_id            = aws_vpc.main.id
  cidr_block        = "10.0.11.0/24"            # 3번째 옥텟 2자리 = Private 규약
  availability_zone = "ap-northeast-2a"
  # map_public_ip_on_launch 생략 = 기본값 false → 공인 IP 없음 (= "Private"의 정체)
  tags              = { Name = "private-ec2-private-a" }
}

resource "aws_subnet" "private_c" {
  vpc_id            = aws_vpc.main.id
  cidr_block        = "10.0.12.0/24"
  availability_zone = "ap-northeast-2c"
  tags              = { Name = "private-ec2-private-c" }
}

map_public_ip_on_launch는 “이 서브넷에 EC2를 만들면 공인 IPv4를 자동으로 붙이느냐”를 정한다. Public Subnet만 true로 두고, Private Subnet은 기본값(false)으로 둔다. 이게 1편에서 말한 “Private EC2에는 공인 IP가 없다”의 실제 스위치다.

참고: 서울 리전에는 AZ가 2a, 2b, 2c, 2d 4개가 있다. 2b를 건너뛰고 2a2c를 쓴 건 특별한 이유는 없다 — AWS 가이드·블로그에서 관용적으로 많이 쓰이고, b는 구 계정에서 사용 제한이 있던 리전(us-east-1 등) 관례가 이어진 것 정도다. 어느 두 개를 고르든 실습에는 차이 없다.


3. 라우팅 — IGW, NAT Gateway, Route Table

3.1 Internet Gateway와 Public Route Table

Internet Gateway(IGW)는 VPC를 인터넷과 연결하는 관문이다. 하나의 VPC에 1개만 있으면 되고, 무료다.

# ===== Internet Gateway =====
# VPC를 인터넷과 잇는 관문. VPC당 1개, 무료.
resource "aws_internet_gateway" "main" {
  vpc_id = aws_vpc.main.id
  tags   = { Name = "private-ec2-igw" }
}

# ===== Public Route Table =====
# 이 RT가 붙은 Subnet은 "Public"이 된다 — 핵심은 아래 0.0.0.0/0 → IGW 한 줄
resource "aws_route_table" "public" {
  vpc_id = aws_vpc.main.id
  route {
    cidr_block = "0.0.0.0/0"                   # 모든 외부 대역을
    gateway_id = aws_internet_gateway.main.id  # IGW로 흘려보낸다 → 인터넷 접속 가능
  }
  tags = { Name = "private-ec2-public-rt" }
}

# Subnet ↔ RT 연결 — association이 걸려야 Subnet의 성격 부여가 완성된다
resource "aws_route_table_association" "public_a" {
  subnet_id      = aws_subnet.public_a.id
  route_table_id = aws_route_table.public.id
}

resource "aws_route_table_association" "public_c" {
  subnet_id      = aws_subnet.public_c.id
  route_table_id = aws_route_table.public.id
}

“Public Subnet이 Public인 이유는 라우트 테이블에 IGW 경로가 있기 때문” — 1편에서 말한 내용의 실제 코드가 이 4줄의 route { ... } 블록이다. Subnet 자체에는 Public/Private 속성이 없다. 라우트 테이블이 붙는 순간 성격이 정해진다.

3.2 NAT Gateway와 Private Route Table (AZ별 분리)

NAT Gateway는 Private Subnet의 EC2가 외부로 나가는 아웃바운드 전용 통로다. aws_eip 1개(Elastic IP)를 할당해 Public Subnet에 심는다.

# ===== NAT Gateways (AZ별 1개) =====
# NAT은 외부로 나갈 때 쓸 고정 공인 IP가 필요 — EIP를 먼저 발급해둔다
resource "aws_eip" "nat_a" {
  domain = "vpc"
  tags   = { Name = "private-ec2-nat-eip-a" }
}

resource "aws_eip" "nat_c" {
  domain = "vpc"
  tags   = { Name = "private-ec2-nat-eip-c" }
}

resource "aws_nat_gateway" "a" {
  allocation_id = aws_eip.nat_a.id
  subnet_id     = aws_subnet.public_a.id       # NAT 본체는 Public Subnet에 산다 (IGW로 외부에 나가야 하니까)
  tags          = { Name = "private-ec2-nat-a" }
  depends_on    = [aws_internet_gateway.main]  # IGW가 먼저 존재해야 NAT이 외부 경로를 쓸 수 있음
}

resource "aws_nat_gateway" "c" {
  allocation_id = aws_eip.nat_c.id
  subnet_id     = aws_subnet.public_c.id
  tags          = { Name = "private-ec2-nat-c" }
  depends_on    = [aws_internet_gateway.main]
}

# ===== Private Route Tables (AZ별 분리) =====
# ★ AZ 장애 격리의 핵심 — 각 AZ의 Private Subnet은 자기 AZ NAT만 바라본다
resource "aws_route_table" "private_a" {
  vpc_id = aws_vpc.main.id
  route {
    cidr_block     = "0.0.0.0/0"
    nat_gateway_id = aws_nat_gateway.a.id     # AZ-a Private → AZ-a NAT (교차 금지)
  }
  tags = { Name = "private-ec2-private-rt-a" }
}

resource "aws_route_table" "private_c" {
  vpc_id = aws_vpc.main.id
  route {
    cidr_block     = "0.0.0.0/0"
    nat_gateway_id = aws_nat_gateway.c.id     # AZ-c Private → AZ-c NAT
  }
  tags = { Name = "private-ec2-private-rt-c" }
}

resource "aws_route_table_association" "private_a" {
  subnet_id      = aws_subnet.private_a.id
  route_table_id = aws_route_table.private_a.id
}

resource "aws_route_table_association" "private_c" {
  subnet_id      = aws_subnet.private_c.id
  route_table_id = aws_route_table.private_c.id
}

3.3 참고: 왜 Private Route Table을 AZ별로 분리하나?

주니어가 가장 많이 실수하는 지점이다. “Public Route Table은 하나인데 Private는 왜 둘로?”

이유는 AZ 장애 격리다. NAT Gateway는 한 서브넷(=한 AZ)에 묶이는 리소스라, AZ-a의 NAT이 죽었을 때 AZ-c EC2가 AZ-a NAT을 쓰도록 route가 걸려 있으면 멀쩡한 AZ-c까지 같이 아웃바운드가 막힌다.

flowchart LR
    subgraph Bad["❌ Private RT 1개로 공유"]
        PriA1["Private AZ-a"] --> NatA1["NAT GW A"]
        PriC1["Private AZ-c"] --> NatA1
        NatA1 -.AZ-a 장애.-> X1((양쪽 다 끊김))
    end
    subgraph Good["✅ Private RT AZ별 분리"]
        PriA2["Private AZ-a"] --> NatA2["NAT GW A"]
        PriC2["Private AZ-c"] --> NatC2["NAT GW C"]
        NatA2 -.AZ-a 장애.-> X2((AZ-a만 끊김))
    end
더 자세히 — 비용이 아까우면 단일 NAT도 가능

개발·스테이징처럼 가용성 요건이 낮은 환경에서는 NAT Gateway 1개만 두고 Private Route Table 2개가 모두 그 하나를 가리키게 하기도 한다. 월 $43 → 프로덕션 대비 절반이다. 단 위 그림의 “Bad” 시나리오를 각오한 선택이라는 걸 문서화해두는 게 좋다. 5편에서 NAT Gateway 비용 최적화 전략을 상세히 다룬다.


4. Security Group — SG가 SG를 참조하는 패턴

4.1 핵심 아이디어

Security Group은 EC2/ALB 등에 붙는 인스턴스 단위 방화벽이다. 가장 기본적인 사용법은 “특정 포트를 IP CIDR로부터 허용”이지만, 실무에서 더 많이 쓰이는 패턴은 “SG가 SG를 참조”하는 것이다.

flowchart LR
    Internet([Internet])
    ALB[alb-sg]
    EC2[ec2-sg]
    DB[db-sg]
    Internet -->|80/443 from 0.0.0.0/0| ALB
    ALB -->|8080 from alb-sg| EC2
    EC2 -->|5432 from ec2-sg| DB
  • alb-sg: 외부에서 80/443만 받는다. 바깥 경계.
  • ec2-sg: “alb-sg에서 온 8080”만 받는다. IP를 쓰지 않는다.
  • db-sg: “ec2-sg에서 온 5432”만 받는다. 역시 IP를 쓰지 않는다.

4.2 왜 IP 대신 SG를 참조하나

IP 기반SG 참조
EC2가 늘어나면 IP 일일이 관리SG만 붙이면 자동 적용
EC2가 재시작되면 IP 바뀔 수 있음SG ID는 영속적
Auto Scaling과 궁합이 나쁨Auto Scaling과 자연스러움
ALB 내부 IP는 AWS가 알아서 바꿈 → 추적 불가alb-sg만 걸어두면 끝

EC2 10대를 운영하다 1대를 추가했다고 치자. IP 기반이면 DB SG를 열어주러 가야 한다. SG 참조면 새 EC2에 ec2-sg를 붙이는 순간 DB 접근이 자동으로 허용된다. 이게 1편에서 “Private Subnet은 Security Group으로 제어한다”고 했을 때의 실제 형태다.

4.3 코드

# ===== ALB SG — 바깥 경계 =====
# 인터넷과 마주하는 유일한 SG. 여기는 IP(0.0.0.0/0)로 열 수밖에 없다.
resource "aws_security_group" "alb" {
  name        = "private-ec2-alb-sg"
  description = "ALB: HTTP/HTTPS from the internet"
  vpc_id      = aws_vpc.main.id

  ingress {
    description = "HTTP"
    from_port   = 80
    to_port     = 80
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]            # 외부에서 오는 트래픽 = IP 기반 허용이 불가피
  }

  ingress {
    description = "HTTPS"
    from_port   = 443
    to_port     = 443
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"                      # -1 = 모든 프로토콜 허용
    cidr_blocks = ["0.0.0.0/0"]
  }

  tags = { Name = "private-ec2-alb-sg" }
}

# ===== EC2 SG — ALB에서만 받는다 =====
# ★ 여기서부터 "SG가 SG를 참조"하는 패턴 — IP를 쓰지 않는다
resource "aws_security_group" "ec2" {
  name        = "private-ec2-app-sg"
  description = "EC2: app port from ALB SG only"
  vpc_id      = aws_vpc.main.id

  ingress {
    description     = "App port from ALB"
    from_port       = 8080
    to_port         = 8080
    protocol        = "tcp"
    security_groups = [aws_security_group.alb.id]  # ★ cidr 대신 SG ID 참조 — ALB IP 변동에 영향 없음
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]                    # NAT Gateway 경유 아웃바운드 — dnf install 등에 필요
  }

  tags = { Name = "private-ec2-app-sg" }
}

# ===== DB SG — EC2에서만 받는다 (SG 체인 시각화용) =====
resource "aws_security_group" "db" {
  name        = "private-ec2-db-sg"
  description = "DB: Postgres from EC2 SG only"
  vpc_id      = aws_vpc.main.id

  ingress {
    description     = "Postgres from EC2"
    from_port       = 5432
    to_port         = 5432
    protocol        = "tcp"
    security_groups = [aws_security_group.ec2.id]  # ★ 3단 체인의 마지막 고리: alb-sg → ec2-sg → db-sg
  }

  tags = { Name = "private-ec2-db-sg" }
}

db-sg는 이 시리즈에서 실제로 DB를 띄우지는 않는다. 그래도 포함한 이유는 SG 참조 체인의 가장 또렷한 예시이기 때문이다. 2단 체인(ALB → EC2)만 보여주는 것보다, 3단 체인(ALB → EC2 → DB)이 “아 이게 얼마나 확장되는 패턴이구나”가 체감된다.


5. 참고: NACL은 왜 기본값 그대로 두나

5.1 Stateful(SG) vs Stateless(NACL)

  • SG는 stateful이다. “외부에서 80으로 들어오는 요청”을 허용하면, 그 요청에 대한 응답 트래픽(ephemeral port로 돌아오는 것)은 자동으로 허용된다.
  • NACL은 stateless다. 들어오는 규칙을 허용했더라도, 나가는 응답 트래픽을 따로 명시적으로 허용해야 한다. 실무에서는 1024-65535 ephemeral port 대역을 수동으로 열어야 해서 번거롭다.

5.2 실무 가이드

상황도구
99%의 일반 서비스SG만 — 인스턴스 단위 세밀 제어
금융·공공 등 Defense-in-Depth 요구SG + 커스텀 NACL (Subnet 레벨 추가 방어)
특정 IP 대역 전체 차단(봇넷 ASN 등)NACL deny 규칙 — SG는 deny가 없다

결론: 이 가이드에서는 기본 NACL(모두 허용)을 그대로 둔다. 방화벽 규칙은 전부 SG에 집중시키는 게 학습·운영 모두 단순하다. NACL이 필요해지는 순간은 “규제에 따라 Subnet 레벨 방어 레이어가 명시적으로 요구될 때”인데, 그런 환경은 이미 네트워크 전담자가 있다.


6. ALB + EC2 + IAM Role (SSM 준비)

6.1 IAM Role — 3편을 위한 밑작업

Private EC2는 공인 IP가 없으므로 SSH로 못 붙는다. 대신 SSM Session Manager로 접속할 예정인데(3편), 그러려면 EC2에 AmazonSSMManagedInstanceCore 정책을 가진 IAM Role을 미리 붙여놔야 한다. 2편에서 같이 심어둔다.

# ===== IAM Role for EC2 (SSM) =====
# EC2는 AWS API 호출 시 "인스턴스 프로파일"에 묶인 Role의 권한을 사용한다 — 사용자 자격이 아님
resource "aws_iam_role" "ec2_ssm" {
  name = "private-ec2-ssm-role"
  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect    = "Allow"
      Principal = { Service = "ec2.amazonaws.com" }  # "EC2 서비스가 이 Role을 assume할 수 있다"는 신뢰 정책
      Action    = "sts:AssumeRole"
    }]
  })
}

# AWS 관리형 정책 — SSM Session Manager에 필요한 최소 권한 세트 (3편에서 이 하나로 충분)
resource "aws_iam_role_policy_attachment" "ec2_ssm" {
  role       = aws_iam_role.ec2_ssm.name
  policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
}

# Role을 EC2에 꽂기 위한 래퍼 — aws_instance.iam_instance_profile에 이 이름을 넘긴다
resource "aws_iam_instance_profile" "ec2_ssm" {
  name = "private-ec2-ssm-profile"
  role = aws_iam_role.ec2_ssm.name
}

6.2 EC2 2대

# ===== AMI (Amazon Linux 2023) =====
# al2023 = SSM Agent 기본 탑재 + dnf 사용. AMI ID는 주기적으로 갱신되므로 data로 조회
data "aws_ami" "al2023" {
  most_recent = true
  owners      = ["amazon"]                         # Amazon 공식 계정 소유 이미지만
  filter {
    name   = "name"
    values = ["al2023-ami-*-x86_64"]
  }
}

# ===== EC2 Instances (Private Subnet, Multi-AZ) =====
resource "aws_instance" "app_a" {
  ami                    = data.aws_ami.al2023.id
  instance_type          = "t3.micro"              # 실습용 최소 사양 (프리티어 근처)
  subnet_id              = aws_subnet.private_a.id # ★ Private Subnet 배치 → 공인 IP 없음, SSH 불가
  vpc_security_group_ids = [aws_security_group.ec2.id]
  iam_instance_profile   = aws_iam_instance_profile.ec2_ssm.name  # ★ 3편 SSM 접속을 위한 Role 연결
  user_data = <<-EOT
    #!/bin/bash
    dnf install -y nginx
    # 데모용 — ALB Target Group이 8080을 쓰도록 Nginx listen 포트 변경
    sed -i 's/listen\s*80;/listen 8080;/' /etc/nginx/nginx.conf
    echo "Hello from AZ-a ($(hostname))" > /usr/share/nginx/html/index.html
    systemctl enable --now nginx
  EOT
  tags = { Name = "private-ec2-app-a" }
}

resource "aws_instance" "app_c" {
  ami                    = data.aws_ami.al2023.id
  instance_type          = "t3.micro"
  subnet_id              = aws_subnet.private_c.id # AZ-c 쪽 Private Subnet — Multi-AZ 분산
  vpc_security_group_ids = [aws_security_group.ec2.id]
  iam_instance_profile   = aws_iam_instance_profile.ec2_ssm.name
  user_data = <<-EOT
    #!/bin/bash
    dnf install -y nginx
    sed -i 's/listen\s*80;/listen 8080;/' /etc/nginx/nginx.conf
    echo "Hello from AZ-c ($(hostname))" > /usr/share/nginx/html/index.html
    systemctl enable --now nginx
  EOT
  tags = { Name = "private-ec2-app-c" }
}

user_data의 Nginx 설정 치환은 “ALB Target이 8080을 쓰는 걸 보여주기 위한 데모 수준”이다. 실제 앱 배포는 4편의 CI/CD에서 다룬다.

6.3 ALB + Target Group + Listener

# ===== ALB =====
resource "aws_lb" "app" {
  name               = "private-ec2-alb"
  internal           = false                                              # false = 인터넷 연결 (true면 VPC 내부 전용)
  load_balancer_type = "application"                                      # L7 — path/host 기반 라우팅 가능
  security_groups    = [aws_security_group.alb.id]
  subnets            = [aws_subnet.public_a.id, aws_subnet.public_c.id]   # ★ ALB는 AZ마다 만들지 않는다 — 하나에 양쪽 Public Subnet attach (1편 원칙)
  tags               = { Name = "private-ec2-alb" }
}

# Target Group: ALB가 요청을 전달할 백엔드 풀 + 헬스체크 정의
resource "aws_lb_target_group" "app" {
  name     = "private-ec2-tg"
  port     = 8080
  protocol = "HTTP"
  vpc_id   = aws_vpc.main.id

  health_check {
    path                = "/"
    matcher             = "200"                   # 200 OK만 healthy로 간주
    interval            = 30                      # 30초마다 체크
    timeout             = 5
    healthy_threshold   = 2                       # 연속 2회 성공 → healthy 전환
    unhealthy_threshold = 3                       # 연속 3회 실패 → out of rotation
  }
}

# 각 EC2를 Target Group에 수동 등록 (Auto Scaling을 쓰지 않으므로 정적 등록)
resource "aws_lb_target_group_attachment" "app_a" {
  target_group_arn = aws_lb_target_group.app.arn
  target_id        = aws_instance.app_a.id
  port             = 8080
}

resource "aws_lb_target_group_attachment" "app_c" {
  target_group_arn = aws_lb_target_group.app.arn
  target_id        = aws_instance.app_c.id
  port             = 8080
}

# Listener: ALB가 어느 포트에서 받아 어디로 보낼지. 학습용이라 80만 — 프로덕션은 443 + ACM 추가
resource "aws_lb_listener" "http" {
  load_balancer_arn = aws_lb.app.arn
  port              = 80
  protocol          = "HTTP"
  default_action {
    type             = "forward"
    target_group_arn = aws_lb_target_group.app.arn
  }
}

# ===== Outputs =====
# apply 끝난 뒤 터미널에 찍히는 값 — 다음 단계에서 바로 복붙해 쓴다
output "alb_dns_name" {
  value       = aws_lb.app.dns_name
  description = "ALB의 퍼블릭 DNS — 브라우저로 바로 열어볼 수 있다"
}

output "ec2_ids" {
  value = {
    app_a = aws_instance.app_a.id
    app_c = aws_instance.app_c.id
  }
  description = "3편 SSM Session Manager에서 쓸 인스턴스 ID"
}

참고: 학습용이라 HTTPS(443) Listener는 생략했다. 프로덕션에서는 ACM 인증서를 발급받아 443 Listener를 추가하고 80 → 443 리다이렉트를 거는 게 표준이다.


7. terraform apply와 콘솔에서 검증하기

7.1 적용

위 코드 블록을 순서대로 이어 붙여 main.tf 한 파일로 저장한 다음:

# 1. 프로바이더 플러그인 설치
terraform init

# 2. 무엇이 만들어질지 미리보기
terraform plan

# 3. 실제로 만들기 (Y 입력)
terraform apply

apply가 끝나면 출력 맨 아래에 alb_dns_name = "private-ec2-alb-xxxxxxxxxx.ap-northeast-2.elb.amazonaws.com"이 찍힌다. 이 DNS를 브라우저에 붙여넣으면 Hello from AZ-a 또는 Hello from AZ-c가 번갈아 나와야 성공이다.

7.2 콘솔 검증 체크리스트

코드가 문제없이 적용됐더라도, 1편 아키텍처가 그대로 올라왔는지 AWS 콘솔에서 한 번은 눈으로 확인하는 게 좋다.

서비스메뉴확인할 것
VPCYour VPCsprivate-ec2-vpc, CIDR 10.0.0.0/16
VPCSubnetsPublic 2개(10.0.1.0/24, 10.0.2.0/24) + Private 2개(10.0.11.0/24, 10.0.12.0/24)
VPCRoute TablesPublic 1개(IGW) + Private 2개(각 NAT) — 연결된 서브넷 확인
VPCNAT Gateways2개 상태 Available, EIP 연결 확인
VPCInternet Gateways1개, VPC attach 상태
EC2Security Groups3개(alb-sg, ec2-sg, db-sg), Inbound의 Source에 SG ID가 보이는지
EC2Instances2대, Public IP 없음, Private IP가 10.0.11.x / 10.0.12.x
EC2Load BalancersALB 1개, Availability Zones 2개 활성
EC2Target GroupsHealthy 2개 (초기엔 unhealthy로 뜨다 1~2분 후 전환)

Target Group이 계속 unhealthy이면 원인은 대부분 이 3개 중 하나다:

  1. Nginx가 8080에서 리슨하지 않음 → EC2에 SSM 접속해서 curl localhost:8080 확인(3편 예고).
  2. ec2-sg의 인바운드가 alb-sg가 아닌 다른 소스로 잘못 걸림.
  3. Private Subnet의 Route Table이 NAT을 안 가리켜서 dnf install nginx가 실패 → user_data 로그 /var/log/cloud-init-output.log.

8. 프로덕션은 어떻게 다를까 — 모듈화와 공식 모듈 평가

8.1 단일 main.tf의 한계

이 글의 코드는 학습용으로는 최적이지만 프로덕션에는 그대로 쓰지 않는다. 이유는:

  • 환경 복제가 번거롭다 — dev/staging/prod 각각의 main.tf를 복붙하게 되고, 변경사항이 서로 어긋난다.
  • 리뷰 단위가 커진다 — VPC 1줄 수정 PR에 400줄짜리 파일 전체가 diff로 뜬다.
  • State가 하나에 몰린다terraform apply 한 번에 VPC부터 EC2까지 전부 다룬다. 실수로 VPC를 날릴 여지가 있다.

8.2 실무 리팩토링 방향

infra/
├── modules/
│   ├── network/    # VPC, Subnet, IGW, NAT, Route Table
│   ├── security/   # SG 3종
│   └── compute/    # EC2, ALB, Target Group
└── envs/
    ├── dev/        # modules 호출 + dev 변수
    ├── staging/
    └── prod/       # prod는 NAT Gateway 2개, dev는 1개 식
  • terraform_remote_state로 네트워크 모듈 출력을 컴퓨팅 모듈이 참조.
  • S3 backend + DynamoDB lock으로 State 공유·경합 방지.
  • 환경별 변수(instance_type, az_count, enable_nat_ha 등)로 dev와 prod 차이 관리.

8.3 공식 모듈 terraform-aws-modules/vpc/aws는?

Terraform Registry에서 가장 많이 쓰이는 공식 커뮤니티 모듈이다. 프로덕션에서는 훌륭하지만 학습 단계에서는 권하지 않는다. 이유는:

측면공식 모듈단일 main.tf
리소스 이해aws_vpc/aws_subnet이 모듈 안으로 숨음모든 리소스가 눈앞에 있음
디버깅모듈 변수 이름만 보고 추론terraform state list로 바로 매칭
학습 효과”왜 NAT이 필요한지” 체감 약함한 줄 한 줄이 1편의 그림
프로덕션 편의수십 옵션 즉시 활용직접 옮겨 써야 함

정리: 리소스 하나하나를 직접 타이핑하며 VPC 구조를 몸으로 익힌 뒤 공식 모듈로 옮기는 게 순서다. 처음부터 공식 모듈로 시작하면 변수만 바꾸며 “뭔가 되긴 하는” 단계에 머무르기 쉽다.


정리

이 글에서 다룬 핵심:

  1. 설계는 단순하게, /16 VPC + /24 Subnet 4개 + 2AZ. 숫자 체계로 Public/Private과 AZ가 CIDR만 봐도 읽혀야 한다.
  2. Subnet은 라우트 테이블이 성격을 결정한다. IGW를 가리키면 Public, NAT을 가리키면 Private — Subnet 자체에는 Public/Private 속성이 없다.
  3. Private Route Table은 AZ별로 분리한다. 단일 Private RT는 AZ 장애 격리 원칙을 깬다. NAT Gateway가 AZ 리소스이기 때문이다.
  4. SG는 IP 대신 다른 SG를 참조한다. alb-sgec2-sgdb-sg 체인이 실무 SG 설계의 표준이고, Auto Scaling·IP 변동과도 자연스럽게 맞물린다.
  5. NACL은 기본값으로 둔다. Stateful한 SG에 방화벽 규칙을 집중시키는 게 학습·운영 모두 단순하다.
  6. EC2에 AmazonSSMManagedInstanceCore Role을 미리 붙인다. 공인 IP도 SSH도 없이 3편에서 SSM으로 접속하기 위한 사전 준비다.
  7. 학습은 단일 main.tf, 프로덕션은 모듈. 공식 모듈은 이 한 번을 직접 해본 뒤로 미룬다.

2편의 목표도 하나였다 — 1편의 그림을 실제로 띄우는 것. 이제 VPC, Subnet, Route Table, SG, ALB, EC2가 전부 당신 계정에 살아있고, alb_dns_name으로 응답까지 받는 상태다.

다음 편에서는 이 Private EC2에 Bastion 없이 SSM Session Manager로 접속한다. 22번 포트를 단 한 번도 열지 않고도 셸에 들어가고, 파일을 올리고, 로그를 뽑는 과정 — SSH와 뭐가 다른지, 443 아웃바운드만으로 어떻게 양방향 세션이 성립하는지까지 다룬다.


부록. 실습 비용 주의와 변수화 Tip

A. 실습 중 과금 유의

학습용이라도 NAT Gateway 2개 + ALB + EC2 2대는 시간당 약 $0.25, 하루 켜두면 약 $6이 나온다. 실습이 끝나면 반드시:

terraform destroy

를 실행한다. 특히 NAT Gateway와 EIP는 안 쓰는 동안에도 과금되는 대표 리소스라 잊고 방치하기 쉽다.

B. 최소 비용으로 실습을 이어가고 싶다면

aws_nat_gateway.c 리소스와 aws_eip.nat_c, aws_route_table.private_c를 주석 처리하고, private_c의 route table association이 aws_route_table.private_a를 가리키도록 바꾸면 NAT 1개로 실습 가능하다(월 $43 절감). 단 “AZ 장애 격리가 깨졌다”는 트레이드오프를 스스로 인지하고 쓴다.

C. 변수화 Tip

이 글은 일부러 하드코딩했지만, 바로 변수로 바꾸고 싶다면 최소 이 3개만 먼저 분리해도 재사용성이 크게 오른다:

variable "project"  { default = "private-ec2" }
variable "region"   { default = "ap-northeast-2" }
variable "vpc_cidr" { default = "10.0.0.0/16" }

그 다음 단계는 azs = ["ap-northeast-2a", "ap-northeast-2c"] 같은 리스트와 for_each로 Subnet·NAT·Route Table을 반복 생성하는 것 — 이쯤 가면 “단일 main.tf의 한계”에 자연스럽게 도달한다. 그때가 모듈로 쪼갤 타이밍이다.

이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.