🎯 Terraform 모듈 구조 마이그레이션 가이드

📑 목차


1. 왜 모듈 구조인가

핵심 개념

모듈 = Terraform의 함수. 모듈 없는 Terraform은 main() 하나에 전부 넣은 코드와 같다.

💡 GCP 챌린지 랩에서 모듈을 강제하는 이유

GCP Associate Cloud Engineer 시험의 챌린지 랩은 처음부터 모듈 구조로 테스트를 돌린다. 이유:

  1. 재사용성: 동일한 VPC 패턴을 dev/staging/prod에 변수만 바꿔서 재사용
  2. 팀 협업: 네트워크 담당은 vpc 모듈만, 보안 담당은 security 모듈만 수정
  3. Blast Radius 축소: 모듈 단위로 격리 → 변경 영향 범위 제한
  4. 테스트 가능: 모듈 단위로 terraform plan 검증 가능
  5. 표준화: GCP/AWS/Azure 어디서든 동일한 패턴 → 클라우드 엔지니어 “범용성”

📊 언제 모듈로 전환하는가

기준플랫 구조 OK모듈 필요
리소스 수~15개 이하20개 이상
환경단일 환경dev/staging/prod 분리
팀 규모혼자여러 명
패턴 반복없음같은 패턴 2번 이상
면접/시험-모듈 필수

실무 판단 기준

리소스가 20개를 넘거나, 비슷한 패턴이 반복되기 시작하면 모듈화 시점이다. 단, 학습 목적이라면 처음부터 모듈로 시작하는 것이 좋다 (GCP 챌린지 랩 방식).


2. 플랫 vs 모듈 구조 비교

📋 플랫 구조 (마이그레이션 전)

terraform-aws-infra/
├── providers.tf          # provider 설정
├── variables.tf          # 모든 변수 (7개)
├── terraform.tfvars      # 변수 값
├── vpc.tf                # VPC + IGW + Subnet + NAT + Route Table
├── security_groups.tf    # SG 4개
├── ec2.tf                # AMI + Key Pair + Bastion + EIP
├── outputs.tf            # 출력값
└── terraform.tfstate     # 20개 리소스

문제점:

  • 파일이 커질수록 찾기 어려움
  • VPC 변경이 EC2에 영향주는지 한눈에 파악 불가
  • 다른 프로젝트에 VPC만 재사용 불가

📋 모듈 구조 (마이그레이션 후)

terraform-aws-infra/
├── main.tf               # 모듈 호출 (진입점)
├── variables.tf          # 루트 변수
├── outputs.tf            # 루트 출력 (module.xxx.output 참조)
├── providers.tf          # provider 설정
├── terraform.tfvars      # 변수 값
└── modules/
    ├── vpc/              # 네트워크 계층
    │   ├── main.tf       # VPC, IGW, Subnet, NAT, Route Table
    │   ├── variables.tf  # vpc_cidr, subnets, AZs
    │   └── outputs.tf    # vpc_id, subnet_ids
    ├── security/         # 보안 계층
    │   ├── main.tf       # 4개 Security Group
    │   ├── variables.tf  # vpc_id, project_name
    │   └── outputs.tf    # sg_ids
    └── ec2/              # 컴퓨팅 계층
        ├── main.tf       # AMI, Key Pair, Bastion, EIP
        ├── variables.tf  # subnet_id, sg_id
        └── outputs.tf    # instance_id, eip

💡 모듈 간 의존성 흐름

variables.tf (루트)
     │
     ▼
 module.vpc ──────► vpc_id, subnet_ids
     │                    │
     ▼                    ▼
 module.security ◄── vpc_id  ──────► sg_ids
     │                                  │
     ▼                                  ▼
 module.ec2 ◄── subnet_id, sg_id ──► instance_id, eip

모듈 = 입력(variables) → 처리(resources) → 출력(outputs)

함수의 매개변수 → 로직 → 리턴값과 완전히 같은 구조다.


3. 실전 마이그레이션 (AWS 프로젝트)

주의

이미 terraform apply로 생성된 리소스가 있는 상태에서 모듈로 옮기면, Terraform이 **“기존 삭제 + 새로 생성”**으로 인식할 수 있다. 반드시 terraform state mv로 state 주소를 먼저 옮겨야 한다.

📋 마이그레이션 6단계

Step 1. State 백업

cp terraform.tfstate terraform.tfstate.pre-module-backup

필수

state mv가 잘못되면 모든 리소스가 삭제될 수 있다. 백업 없이 진행 금지.

Step 2. 모듈 디렉토리 생성

mkdir -p modules/{vpc,security,ec2}

Step 3. 코드 분리

각 모듈에 main.tf, variables.tf, outputs.tf 3개 파일을 생성한다.

핵심 규칙:

  • 리소스 이름은 절대 변경 금지 (aws_vpc.main → 모듈 안에서도 aws_vpc.main)
  • var.xxx 참조를 모듈의 variables.tf에서 선언
  • 직접 참조(aws_vpc.main.id)를 모듈 변수(var.vpc_id)로 교체

📊 모듈별 코드 매핑

기존 파일모듈리소스 수
vpc.tfmodules/vpc/12개
security_groups.tfmodules/security/4개
ec2.tfmodules/ec2/4개

Step 4. 루트 main.tf 작성

# ============================================================
# VPC 모듈
# ============================================================
module "vpc" {
  source = "./modules/vpc"
 
  project_name       = var.project_name
  vpc_cidr           = var.vpc_cidr
  public_subnets     = var.public_subnets
  private_subnets    = var.private_subnets
  availability_zones = var.availability_zones
  enable_nat_gateway = var.enable_nat_gateway
}
 
# ============================================================
# Security Group 모듈
# ============================================================
module "security" {
  source = "./modules/security"
 
  project_name = var.project_name
  vpc_id       = module.vpc.vpc_id    # vpc 모듈의 output 참조
}
 
# ============================================================
# EC2 모듈
# ============================================================
module "ec2" {
  source = "./modules/ec2"
 
  project_name      = var.project_name
  subnet_id         = module.vpc.public_subnet_ids[0]    # vpc output
  security_group_id = module.security.bastion_sg_id       # security output
}

핵심 패턴

모듈 간 데이터 전달은 반드시 output → variable 경로를 거친다.

  • module.vpcoutput "vpc_id"module.securityvariable "vpc_id"
  • 직접 module.vpc.aws_vpc.main.id 같은 참조는 불가능

Step 5. 루트 outputs.tf 수정

# 기존 (직접 참조)
output "vpc_id" {
  value = aws_vpc.main.id              # ❌ 리소스가 루트에 없음
}
 
# 변경 (모듈 output 참조)
output "vpc_id" {
  value = module.vpc.vpc_id            # ✅ 모듈 output 경유
}
 
output "bastion_public_ip" {
  value = module.ec2.public_ip         # ✅
}
 
output "security_group_ids" {
  value = {
    bastion = module.security.bastion_sg_id
    web     = module.security.web_sg_id
    app     = module.security.app_sg_id
    db      = module.security.db_sg_id
  }
}

Step 6. 기존 플랫 파일 삭제

rm vpc.tf security_groups.tf ec2.tf

4. state mv 완전 정복

💡 왜 state mv가 필요한가

🤔 질문: “코드만 모듈로 옮기면 되는 거 아닌가?”

아니다

Terraform은 리소스를 state 파일의 주소로 추적한다.

  • 기존 주소: aws_vpc.main
  • 모듈 주소: module.vpc.aws_vpc.main

주소가 다르면 Terraform은 “기존 것 삭제 + 새 것 생성”으로 판단한다. VPC가 삭제되면 안의 모든 리소스가 함께 날아간다.

📋 state mv 실행

# 1) terraform init (모듈 로딩)
terraform init
 
# 2) VPC 모듈 (12개 리소스)
terraform state mv aws_vpc.main module.vpc.aws_vpc.main
terraform state mv aws_internet_gateway.main module.vpc.aws_internet_gateway.main
terraform state mv 'aws_subnet.public[0]' 'module.vpc.aws_subnet.public[0]'
terraform state mv 'aws_subnet.public[1]' 'module.vpc.aws_subnet.public[1]'
terraform state mv 'aws_subnet.private[0]' 'module.vpc.aws_subnet.private[0]'
terraform state mv 'aws_subnet.private[1]' 'module.vpc.aws_subnet.private[1]'
terraform state mv aws_route_table.public module.vpc.aws_route_table.public
terraform state mv aws_route_table.private module.vpc.aws_route_table.private
terraform state mv 'aws_route_table_association.public[0]' 'module.vpc.aws_route_table_association.public[0]'
terraform state mv 'aws_route_table_association.public[1]' 'module.vpc.aws_route_table_association.public[1]'
terraform state mv 'aws_route_table_association.private[0]' 'module.vpc.aws_route_table_association.private[0]'
terraform state mv 'aws_route_table_association.private[1]' 'module.vpc.aws_route_table_association.private[1]'
 
# 3) Security 모듈 (4개)
terraform state mv aws_security_group.bastion module.security.aws_security_group.bastion
terraform state mv aws_security_group.web module.security.aws_security_group.web
terraform state mv aws_security_group.app module.security.aws_security_group.app
terraform state mv aws_security_group.db module.security.aws_security_group.db
 
# 4) EC2 모듈 (4개)
terraform state mv aws_key_pair.main module.ec2.aws_key_pair.main
terraform state mv aws_instance.bastion module.ec2.aws_instance.bastion
terraform state mv aws_eip.bastion module.ec2.aws_eip.bastion
terraform state mv aws_eip_association.bastion module.ec2.aws_eip_association.bastion

count 사용 리소스 주의

aws_subnet.public[0] 같은 인덱스 리소스는 반드시 따옴표로 감싸야 한다.

# ✅ 올바른 방법
terraform state mv 'aws_subnet.public[0]' 'module.vpc.aws_subnet.public[0]'
 
# ❌ 쉘이 대괄호를 해석해서 오류
terraform state mv aws_subnet.public[0] module.vpc.aws_subnet.public[0]

📋 검증

# 1) state 주소 확인 - 전부 module.xxx. 접두사
terraform state list
 
# 2) plan 확인 - 반드시 "No changes" 나와야 함
terraform plan
 
# 3) SSH 접속 확인 - EIP 유지 확인
ssh -i ~/.ssh/id_ed25519 ec2-user@43.201.xxx.xxx

📊 state mv 전후 비교

전 (플랫)후 (모듈)
aws_vpc.mainmodule.vpc.aws_vpc.main
aws_subnet.public[0]module.vpc.aws_subnet.public[0]
aws_security_group.bastionmodule.security.aws_security_group.bastion
aws_instance.bastionmodule.ec2.aws_instance.bastion
aws_eip.bastionmodule.ec2.aws_eip.bastion

data source는 state mv 불필요

data.aws_ami.amazon_linux 같은 data source는 매번 새로 조회하므로 state mv 대상이 아니다. 루트에 남은 stale 항목은 terraform state rm으로 정리하면 된다.


5. 트러블슈팅

🚨 “plan에서 destroy가 뜬다”

원인: state mv를 빠뜨린 리소스가 있음

# 어떤 리소스가 누락됐는지 확인
terraform plan | grep "will be destroyed"
 
# 누락된 리소스 state mv 추가 실행
terraform state mv aws_xxx.yyy module.xxx.aws_xxx.yyy

🚨 “state mv 중 오류 발생”

원인: 리소스 이름이 모듈 코드와 불일치

# 롤백
cp terraform.tfstate.pre-module-backup terraform.tfstate
 
# 모듈 코드의 리소스 이름 확인 후 재시도

🚨 “module.xxx.output이 없다는 오류”

원인: 모듈의 outputs.tf에 해당 output을 선언하지 않음

# modules/vpc/outputs.tf에 추가
output "vpc_id" {
  value = aws_vpc.main.id    # 이게 없으면 module.vpc.vpc_id 참조 불가
}

🚨 “terraform init 후 plan이 안 된다”

원인: 모듈 코드에서 var.xxx를 선언했는데 루트 main.tf에서 전달 안 함

# 루트 main.tf - 모듈에 선언된 모든 required variable 전달 필수
module "vpc" {
  source       = "./modules/vpc"
  project_name = var.project_name    # ← 빠뜨리면 오류
  vpc_cidr     = var.vpc_cidr        # ← 빠뜨리면 오류
}

🎯 핵심 정리

마이그레이션 체크리스트

  1. terraform.tfstate 백업 (필수)
  2. modules/ 디렉토리 + 3개 파일(main/variables/outputs) 생성
  3. 리소스 이름 변경 금지 (state mv 호환)
  4. 루트 main.tf에서 모듈 호출 + output → variable 연결
  5. terraform init (모듈 소스 로딩)
  6. terraform state mv (리소스 주소 이동)
  7. terraform plan“No changes” 확인
  8. 기존 플랫 파일 삭제

📊 실전 결과 (2026-01-27 실습)

항목결과
마이그레이션 대상20개 리소스 (VPC 12 + SG 4 + EC2 4)
state mv20/20 성공
terraform planNo changes
EIP43.201.xxx.xxx 유지
Bastion SSH정상 접속 확인
리소스 재생성0건

🔧 향후 확장 패턴

modules/
├── vpc/           ← 완료
├── security/      ← 완료
├── ec2/           ← 완료
├── alb/           ← 다음 단계: 로드밸런서
├── rds/           ← 다음 단계: 데이터베이스
└── ecs/           ← 다음 단계: 컨테이너

기억할 것

모듈 없는 Terraform = main() 하나에 1000줄 코드. 처음부터 모듈로 시작하면 나중에 마이그레이션 고통이 없다.