Terraform 모듈이란: Root Module과 Child Module의 관계
Terraform에서 모듈은 함께 관리되는 리소스의 집합입니다. 모든 Terraform 구성은 그 자체로 모듈이며, 작업 디렉터리의 .tf 파일들이 root module입니다. root module에서 module 블록으로 호출하는 모듈이 child module이고, child module은 다시 자체적으로 child module을 호출할 수 있는 계층 구조를 형성합니다.
# root module에서 child module 호출
module "vpc" {
source = "./modules/vpc"
cidr_block = "10.0.0.0/16"
azs = ["ap-northeast-2a", "ap-northeast-2c"]
}
module "eks" {
source = "./modules/eks"
vpc_id = module.vpc.vpc_id # 모듈 간 의존성
subnet_ids = module.vpc.private_subnet_ids
}
이 구조에서 핵심은 모듈 간 통신이 오직 input variables(variables.tf)와 output values(outputs.tf)로만 이루어진다는 점입니다. 모듈 내부의 리소스에 직접 접근할 수 없으며, 이것이 모듈의 캡슐화를 보장합니다.
모듈 디렉터리 구조: 표준 파일 레이아웃
Terraform 공식 문서에서 권장하는 모듈의 표준 구조는 다음과 같습니다:
modules/
└── vpc/
├── main.tf # 핵심 리소스 정의
├── variables.tf # input variables (모듈의 인터페이스)
├── outputs.tf # output values (다른 모듈에 노출)
├── versions.tf # required_providers, terraform 버전 제약
├── locals.tf # 내부 계산값 (선택)
└── README.md # 사용법 문서
| 파일 | 역할 | 필수 여부 |
|---|---|---|
main.tf |
리소스·data source 정의 | 필수 |
variables.tf |
모듈의 입력 인터페이스 | 필수 |
outputs.tf |
모듈의 출력 인터페이스 | 필수 |
versions.tf |
provider·Terraform 버전 제약 | 권장 |
README.md |
사용법·예제·input/output 설명 | 공개 모듈 필수 |
규칙: 모듈은 하나의 논리적 인프라 단위를 표현해야 합니다. “VPC”, “EKS 클러스터”, “RDS 인스턴스”처럼 명확한 경계가 있어야 하며, “전체 인프라”를 하나의 모듈에 넣는 것은 안티패턴입니다.
모듈 소스(source): 로컬·레지스트리·Git 참조
source 인수는 모듈을 어디서 가져올지 결정합니다. Terraform은 다양한 소스를 지원합니다:
| 소스 타입 | source 문법 | 버전 관리 | 적합한 상황 |
|---|---|---|---|
| 로컬 경로 | "./modules/vpc" |
없음 (항상 최신) | 같은 리포지터리 내 모듈 |
| Terraform Registry | "hashicorp/consul/aws" |
version = "~> 0.1" |
공개/사내 레지스트리 모듈 |
| GitHub | "github.com/org/repo//modules/vpc" |
?ref=v1.2.0 |
사내 Git 저장소 |
| Generic Git | "git::https://example.com/repo.git" |
?ref=tag |
자체 호스팅 Git |
| S3 Bucket | "s3::https://bucket.s3.amazonaws.com/module.zip" |
URL 경로로 관리 | 에어갭 환경 |
# Terraform Registry — 버전 제약 필수
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "~> 5.0" # 5.x 최신 (6.0 미만)
# ...
}
# GitHub — ref로 태그/브랜치/커밋 고정
module "vpc" {
source = "github.com/myorg/terraform-modules//modules/vpc?ref=v2.3.1"
# ...
}
# 로컬 경로 — 버전 인수 사용 불가
module "vpc" {
source = "./modules/vpc"
# version은 사용할 수 없음 — 항상 현재 파일 참조
# ...
}
주의: version 인수는 Terraform Registry 소스에서만 사용 가능합니다. Git 소스는 ?ref=로, 로컬 경로는 버전 관리 자체가 불가능합니다.
Input Variables 설계: 모듈의 공개 인터페이스
모듈의 variables.tf는 함수의 파라미터와 같습니다. 잘 설계된 input은 모듈의 유연성과 재사용성을 결정합니다.
# modules/vpc/variables.tf
variable "name" {
description = "VPC 이름 태그에 사용되는 프로젝트명"
type = string
validation {
condition = length(var.name) > 0 && length(var.name) = 2
error_message = "고가용성을 위해 최소 2개 AZ가 필요합니다."
}
}
variable "enable_nat_gateway" {
description = "NAT Gateway 생성 여부"
type = bool
default = true
}
variable "tags" {
description = "모든 리소스에 부착될 공통 태그"
type = map(string)
default = {}
}
Input 설계 원칙
- 필수 vs 선택:
default가 없으면 필수, 있으면 선택입니다. 합리적인 기본값이 있는 항목에만 default를 설정합니다. - validation 블록: Terraform 0.13+에서 지원합니다. plan 단계에서 잘못된 입력을 조기에 잡아줍니다.
- description 필수: Registry에 게시하면 자동으로 문서화됩니다. 로컬 모듈에서도
terraform-docs로 README를 자동 생성할 수 있습니다. - sensitive 마킹: 비밀번호·API 키는
sensitive = true를 설정하여 plan/apply 출력에서 마스킹합니다.
Output Values 설계: 모듈 간 데이터 전달
# modules/vpc/outputs.tf
output "vpc_id" {
description = "생성된 VPC의 ID"
value = aws_vpc.main.id
}
output "private_subnet_ids" {
description = "프라이빗 서브넷 ID 목록"
value = aws_subnet.private[*].id
}
output "public_subnet_ids" {
description = "퍼블릭 서브넷 ID 목록"
value = aws_subnet.public[*].id
}
output "nat_gateway_ips" {
description = "NAT Gateway의 Elastic IP 주소"
value = var.enable_nat_gateway ? aws_eip.nat[*].public_ip : []
}
# root module에서 child module의 output 참조
module "vpc" {
source = "./modules/vpc"
# ...
}
module "eks" {
source = "./modules/eks"
vpc_id = module.vpc.vpc_id # output 참조
subnet_ids = module.vpc.private_subnet_ids # output 참조
}
# root module의 output으로 노출 (다른 프로젝트에서 remote_state로 참조 가능)
output "vpc_id" {
value = module.vpc.vpc_id
}
설계 원칙: output은 “다른 모듈이 필요로 할 수 있는 값”만 노출합니다. 리소스의 모든 속성을 노출하는 것은 캡슐화를 깨뜨립니다. 변경 시 모든 소비자에 영향을 미치므로, output은 모듈의 공개 API로 취급하고 신중하게 관리해야 합니다.
모듈 버전 관리: 시맨틱 버저닝과 version 제약
| version 제약 | 의미 | 사용 시점 |
|---|---|---|
= 1.2.3 |
정확히 1.2.3 | 재현성이 최우선 (프로덕션 고정) |
~> 1.2 |
≥ 1.2.0, < 2.0.0 | 마이너·패치 자동 업데이트 허용 |
~> 1.2.0 |
≥ 1.2.0, < 1.3.0 | 패치만 자동 업데이트 |
>= 1.0, < 2.0 |
복합 조건 | 세밀한 범위 지정 |
# 프로덕션 권장: 패치만 자동 업데이트
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "~> 5.8.0" # 5.8.x만 허용
}
# 개발 환경: 마이너 업데이트 허용
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "~> 5.0" # 5.x 전체 허용
}
운영 규칙: terraform init은 .terraform.lock.hcl에 설치된 모듈의 정확한 버전을 기록합니다. 이 lock 파일을 반드시 Git에 커밋하여 팀원 간 동일한 버전이 사용되도록 합니다.
모듈 구성 패턴: Flat vs Nested vs Facade
패턴 1: Flat Modules (권장 시작점)
infrastructure/
├── main.tf # root module
├── modules/
│ ├── vpc/ # 독립 모듈
│ ├── eks/ # 독립 모듈
│ ├── rds/ # 독립 모듈
│ └── redis/ # 독립 모듈
└── environments/
├── dev.tfvars
├── staging.tfvars
└── prod.tfvars
root module에서 각 child module을 직접 호출하고, 모듈 간 의존성은 output → input으로 연결합니다.
패턴 2: Nested Modules (중형 이상 프로젝트)
modules/
└── eks-cluster/ # 상위 모듈
├── main.tf # 하위 모듈 호출
├── modules/
│ ├── node-group/ # 하위 모듈
│ └── addons/ # 하위 모듈
├── variables.tf
└── outputs.tf
상위 모듈이 하위 모듈들을 조합하여 하나의 논리 단위를 구성합니다. 이 패턴에서 주의할 점은 중첩 깊이를 2단계 이하로 유지하는 것입니다. 3단계 이상은 디버깅이 극도로 어려워집니다.
패턴 3: Facade Module (환경별 구성 캡슐화)
# environments/prod/main.tf — Facade 역할
module "platform" {
source = "../../modules/platform"
environment = "prod"
vpc_cidr = "10.0.0.0/16"
eks_node_count = 5
rds_instance_class = "db.r6g.xlarge"
enable_monitoring = true
}
# modules/platform/main.tf — 내부에서 여러 모듈 조합
module "vpc" {
source = "../vpc"
cidr_block = var.vpc_cidr
# ...
}
module "eks" {
source = "../eks"
vpc_id = module.vpc.vpc_id
node_count = var.eks_node_count
# ...
}
module "rds" {
source = "../rds"
subnet_ids = module.vpc.private_subnet_ids
instance_class = var.rds_instance_class
# ...
}
패턴 비교
| 패턴 | 장점 | 단점 | 적합한 규모 |
|---|---|---|---|
| Flat | 단순, 의존성 투명 | root module 비대화 | 소규모 (모듈 5개 이하) |
| Nested | 관련 리소스 응집, 재사용 단위 확대 | 깊은 중첩 시 디버깅 난이도 증가 | 중규모 |
| Facade | 환경별 차이를 변수 하나로 제어 | 추상화 층이 많아 plan 결과 추적 어려움 | 대규모·멀티 환경 |
모듈에서 Provider 설정: 전달 패턴과 함정
Terraform의 공식 권장 사항은 child module이 자체적으로 provider를 설정하지 않고, root module에서 전달받는 것입니다.
# root module: provider 설정
provider "aws" {
region = "ap-northeast-2"
alias = "seoul"
}
provider "aws" {
region = "us-east-1"
alias = "virginia"
}
# child module에 provider 전달
module "cdn" {
source = "./modules/cloudfront"
providers = {
aws = aws.virginia # CloudFront + ACM은 us-east-1 필수
}
}
# modules/cloudfront/versions.tf
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 5.0"
# configuration_aliases는 쓰지 않음 — 기본 aws provider만 수신
}
}
}
함정: child module 내부에서 provider 블록을 직접 정의하면, 해당 모듈을 count나 for_each로 호출할 수 없습니다. Terraform은 “provider를 자체 설정하는 모듈은 반복 호출이 불가능하다”는 제약을 명시하고 있습니다.
for_each로 모듈 반복 호출
Terraform 0.13+에서 for_each를 모듈 블록에 사용할 수 있습니다. 동일한 모듈을 여러 설정으로 반복 생성할 때 유용합니다.
locals {
databases = {
users = {
instance_class = "db.t3.medium"
storage_gb = 50
multi_az = false
}
orders = {
instance_class = "db.r6g.large"
storage_gb = 200
multi_az = true
}
analytics = {
instance_class = "db.r6g.xlarge"
storage_gb = 500
multi_az = true
}
}
}
module "rds" {
source = "./modules/rds"
for_each = local.databases
name = each.key
instance_class = each.value.instance_class
storage_gb = each.value.storage_gb
multi_az = each.value.multi_az
subnet_ids = module.vpc.private_subnet_ids
}
# 특정 인스턴스의 output 접근
output "orders_db_endpoint" {
value = module.rds["orders"].endpoint
}
# 전체 인스턴스의 endpoint 맵
output "all_db_endpoints" {
value = { for k, v in module.rds : k => v.endpoint }
}
주의: for_each의 키는 state에서 리소스 주소로 사용됩니다(module.rds["orders"]). 키를 변경하면 Terraform은 기존 리소스를 삭제하고 새로 생성합니다. moved 블록으로 마이그레이션하세요.
depends_on: 모듈 간 암묵적 의존성 명시
대부분의 모듈 간 의존성은 output → input 참조로 자동 해결됩니다. 하지만 데이터 흐름으로 표현할 수 없는 의존성이 있을 때 depends_on을 사용합니다.
# IAM 정책이 적용된 후에 EKS를 생성해야 하지만,
# IAM ARN을 EKS 모듈에 전달하는 것으로는 의존성이 부족한 경우
module "iam" {
source = "./modules/iam"
}
module "eks" {
source = "./modules/eks"
role_arn = module.iam.eks_role_arn # 데이터 의존성
depends_on = [module.iam] # 전체 모듈 완료 후 실행 보장
}
주의: depends_on을 모듈에 사용하면, 해당 모듈의 모든 리소스가 depends_on 대상 이후로 지연됩니다. 이는 plan/apply 시간을 크게 증가시킬 수 있으므로, 가능하면 output → input 참조로 해결하고 depends_on은 최후의 수단으로 사용합니다.
모듈 리팩터링: moved 블록으로 state 안전하게 이동
모듈 구조를 변경할 때 가장 위험한 것은 state에서 리소스가 “삭제 + 재생성”으로 처리되는 것입니다. Terraform 1.1+의 moved 블록으로 이를 방지합니다.
# 리소스를 모듈 안으로 이동
moved {
from = aws_vpc.main
to = module.vpc.aws_vpc.main
}
# 모듈 이름 변경
moved {
from = module.old_name
to = module.new_name
}
# for_each 키 변경
moved {
from = module.rds["user-db"]
to = module.rds["users"]
}
moved 블록은 terraform plan에서 “~ moved”로 표시되며, 실제 인프라 변경 없이 state만 업데이트됩니다. 마이그레이션이 완료되면 moved 블록을 제거해도 됩니다.
모듈 설계 Anti-Pattern 5가지
- “God Module”: VPC + EKS + RDS + Redis를 하나의 모듈에 넣으면 변경 영향 범위가 너무 넓어집니다. 모듈 하나가 10개 이상의 리소스를 포함하면 분할을 검토하세요.
- 모듈 안에서 provider 설정: for_each/count 사용 불가, 테스트 어려움. provider는 반드시 root에서 전달합니다.
- output 없는 모듈: 다른 모듈과 통합할 수 없어 재사용이 불가능해집니다. 최소한 생성된 리소스의 ID와 ARN은 output으로 노출하세요.
- version 미지정: Registry 모듈을 version 없이 사용하면
terraform init -upgrade시 예고 없이 최신 버전으로 올라갑니다. 프로덕션에서는 반드시 version을 고정합니다. - 깊은 중첩 (3단계 이상):
module.a.module.b.module.c형태는 plan 출력을 읽기 어렵게 만들고, state 구조가 복잡해져 디버깅이 극도로 어렵습니다.
모듈 설계 체크리스트
| 항목 | 확인 사항 |
|---|---|
| 경계 | 하나의 논리적 인프라 단위를 표현하는가? |
| variables.tf | description·type·validation이 모두 있는가? |
| outputs.tf | 소비자 모듈이 필요로 하는 값을 노출하는가? |
| versions.tf | required_providers와 terraform 버전 제약이 있는가? |
| Provider | child module이 자체 provider를 설정하지 않는가? |
| 버전 관리 | Registry 모듈에 version, Git에 ?ref가 고정되어 있는가? |
| 중첩 깊이 | 2단계 이하로 유지되는가? |
참고 자료