모듈 설계와 코드 구조화
언제 모듈을 만들어야 하나
모듈은 비용이다. 추상화 계층이 하나 더 생기면 코드를 읽는 사람이 파일을 오가며 흐름을 추적해야 한다. 처음부터 모든 것을 모듈로 만들면 오히려 유지보수가 어려워진다.
모듈이 정당화되는 세 가지 상황이 있다.
- 반복: 같은 리소스 조합을 두 곳 이상에서 사용한다. 예) 개발·스테이징·프로덕션에 동일한 EC2 + ALB + 보안 그룹 패턴을 쓴다.
- 시스템 경계: 여러 리소스가 함께 의미 있는 하나의 시스템을 이룬다. 예) VPC + 서브넷 + 라우팅 테이블 → "네트워크 모듈".
- 팀 분리: 인프라 팀이 만든 검증된 모듈을 애플리케이션 팀이 가져다 쓴다. Provider 버전, 보안 설정, 태그 정책 등을 중앙에서 관리한다.
반대로, 한 번만 쓰이거나 3~4개 리소스뿐인 단순한 구성은 모듈로 만들지 않는 게 낫다. 코드 파일 분리(리소스를 compute.tf, network.tf로 나누기)로 충분한 경우가 많다.
표준 모듈 파일 구조
HashiCorp 공식 권고 구조다.
modules/
└── web-server/
├── main.tf # 리소스 블록 (핵심)
├── variables.tf # 입력 변수 선언
├── outputs.tf # 출력 값 선언
├── versions.tf # required_providers, required_version
├── locals.tf # 내부에서만 쓰는 로컬 값 (선택)
└── README.md # 사용법, 입출력 목록각 파일의 역할을 명확히 나누면 협업 시 어디를 보면 되는지 예측 가능하다.
versions.tf 에는 모듈이 필요로 하는 Provider와 Terraform 버전을 고정한다.
terraform {
required_version = ">= 1.6"
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 5.0"
}
}
}모듈에서 버전을 너무 좁게 고정하면 호출 측에서 버전 충돌이 생기므로 >= 또는 ~>로 유연하게 설정한다.
Variable 설계: 입력 변수의 원칙
변수는 모듈의 공개 인터페이스다. 잘 설계된 변수는 모듈을 올바르게 쓰게 유도하고, 잘못 설계된 변수는 소비자가 내부 구조를 파악하도록 강요한다.
variable "environment" {
description = "배포 환경: dev, staging, prod"
type = string
validation {
condition = contains(["dev", "staging", "prod"], var.environment)
error_message = "environment는 dev, staging, prod 중 하나여야 합니다."
}
}
variable "instance_config" {
description = "EC2 인스턴스 설정"
type = object({
instance_type = string
ami_id = string
disk_size_gb = optional(number, 20)
})
}
variable "tags" {
description = "리소스에 붙일 추가 태그"
type = map(string)
default = {}
}설계 원칙:
description은 생략하지 않는다. 모듈 소비자가 문서를 읽지 않아도 변수 의미를 알 수 있어야 한다.validation블록으로 잘못된 입력을apply전에 잡는다.object타입으로 연관된 변수들을 묶는다. 매개변수가 5개가 넘으면 묶는 것을 고려한다.- 민감한 입력(비밀번호, API 키)에는
sensitive = true를 붙인다.plan출력에서 가려진다.
Output 설계: 출력 값의 원칙
출력은 모듈이 외부에 공개하는 데이터다. 호출 측 코드가 모듈 내부를 참조(module.vpc.aws_vpc.main.id)하지 않고 출력(module.vpc.vpc_id)만 쓰게 한다.
output "vpc_id" {
description = "생성된 VPC ID"
value = aws_vpc.main.id
}
output "public_subnet_ids" {
description = "퍼블릭 서브넷 ID 목록"
value = aws_subnet.public[*].id
}
output "security_group_id" {
description = "웹 서버용 보안 그룹 ID"
value = aws_security_group.web.id
sensitive = false
}내부 구현 상세(임시 리소스 이름, 중간 계산 값)는 출력하지 않는다. 출력이 너무 많으면 모듈의 경계가 모호하다는 신호다.
모듈 조합 패턴
플랫 컴포지션(Flat Composition)
HashiCorp가 권장하는 방식이다. 모듈 트리를 가능하면 1단계로 유지하고, 모듈 간 데이터 흐름을 루트 모듈의 locals와 output으로 명시한다.
# 루트 모듈 (main.tf)
module "vpc" {
source = "./modules/vpc"
cidr = "10.0.0.0/16"
environment = var.environment
}
module "eks" {
source = "./modules/eks"
vpc_id = module.vpc.vpc_id # vpc 모듈 출력 참조
subnet_ids = module.vpc.private_subnet_ids
cluster_version = "1.30"
environment = var.environment
}
module "rds" {
source = "./modules/rds"
vpc_id = module.vpc.vpc_id
subnet_ids = module.vpc.private_subnet_ids
allowed_sg_id = module.eks.node_security_group_id # eks 모듈 출력 참조
environment = var.environment
}모듈 안에서 다른 모듈을 호출하는 중첩 모듈은 피한다. 중첩이 깊어질수록 의존 관계를 파악하기 어렵고, state mv 같은 State 조작도 복잡해진다.
환경·설정 입력 main.tf
모듈 호출 + 연결 outputs.tf
최종 출력
출력: vpc_id, subnet_ids
입력: vpc_id, subnet_ids
입력: vpc_id, allowed_sg_id
for_each로 다수 인스턴스 생성
같은 모듈을 여러 번 호출할 때 for_each를 쓰면 State 주소가 안정적으로 유지된다.
locals {
services = {
api = { instance_type = "t3.medium", port = 8080 }
worker = { instance_type = "t3.large", port = 8081 }
cron = { instance_type = "t3.micro", port = 8082 }
}
}
module "service" {
for_each = local.services
source = "./modules/service"
name = each.key
instance_type = each.value.instance_type
port = each.value.port
environment = var.environment
}for_each를 쓰면 State 주소가 module.service["api"], module.service["worker"]처럼 키 기반으로 고정된다. count를 쓰면 인덱스(module.service[0])로 주소가 잡혀, 중간 항목을 삭제하면 뒤에 있는 모든 인스턴스가 재생성된다.
for_each가 있는 모듈 블록에provider블록을 정의하면 오류가 난다.for_each,count,depends_on은 모듈 내부 provider와 호환되지 않는다.
모듈 버전 관리
로컬 모듈
source = "./modules/vpc"처럼 상대 경로로 참조한다. 단일 리포지터리(모노레포)에서 내부 모듈을 빠르게 순환 개발할 때 유용하다.
Git 태그 버전
module "vpc" {
source = "git::https://github.com/myorg/terraform-modules.git//modules/vpc?ref=v2.3.0"
}//modules/vpc는 리포지터리 내 서브디렉터리를 가리키는 Git URL 문법이다. ref=v2.3.0처럼 태그를 고정하면 모듈이 예고 없이 바뀌지 않는다.
시맨틱 버저닝 원칙:
| 버전 변경 | 상황 |
|---|---|
MAJOR (3.0.0) | 기존 variable 삭제·이름 변경, output 형태 변경 등 호환성이 깨지는 변경 |
MINOR (2.4.0) | 새 optional variable 추가, 새 output 추가 (하위 호환) |
PATCH (2.3.1) | 버그 수정, 문서 업데이트 |
Terraform Registry 모듈
registry.terraform.io의 공개 모듈을 쓸 때 가장 간결한 source 문법을 사용한다.
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "~> 5.8" # 5.x 허용, 6.0은 허용 안 함
name = "production-vpc"
cidr = "10.0.0.0/16"
azs = ["ap-northeast-2a", "ap-northeast-2b", "ap-northeast-2c"]
private_subnets = ["10.0.1.0/24", "10.0.2.0/24", "10.0.3.0/24"]
public_subnets = ["10.0.101.0/24", "10.0.102.0/24", "10.0.103.0/24"]
enable_nat_gateway = true
}terraform-aws-modules/vpc/aws는 가장 널리 쓰이는 공개 모듈 중 하나다. 프로덕션 도입 전에 반드시 코드를 직접 읽고 신뢰도를 확인한다.
모듈 테스트: terraform test
Terraform 1.6부터 terraform test 명령어가 내장됐다. 별도 도구 없이 모듈 동작을 검증할 수 있다.
modules/vpc/
└── tests/
├── defaults.tftest.hcl # 기본값으로 모듈이 정상 동작하는지
└── custom_cidr.tftest.hcl # 사용자 지정 CIDR 입력 테스트# tests/defaults.tftest.hcl
run "creates_vpc_with_defaults" {
command = plan # apply 없이 plan만으로 검증 (빠름)
module {
source = ".."
}
variables {
environment = "test"
cidr = "10.99.0.0/16"
}
assert {
condition = aws_vpc.main.cidr_block == "10.99.0.0/16"
error_message = "VPC CIDR 블록이 입력값과 다릅니다."
}
assert {
condition = length(aws_subnet.public) == 2
error_message = "퍼블릭 서브넷이 2개여야 합니다."
}
}command = plan은 실제 리소스를 만들지 않고 plan 결과만 검증해 테스트가 빠르다. command = apply는 실제로 리소스를 만들고 테스트가 끝나면 destroy한다. apply 테스트는 더 정확하지만 비용이 발생한다.
CI 파이프라인에서 terraform test ./modules/vpc/... 명령으로 전체 테스트를 실행한다.
디렉터리 구조: 실제 프로젝트 레이아웃
소규모 프로젝트가 성장하면 어떤 디렉터리 구조를 가지게 되는지 전형적인 예다.
infrastructure/
├── modules/ # 내부 재사용 모듈
│ ├── vpc/
│ ├── eks/
│ ├── rds/
│ └── service/
├── envs/ # 환경별 루트 모듈
│ ├── dev/
│ │ ├── main.tf
│ │ ├── variables.tf
│ │ ├── terraform.tfvars # 개발 환경 값
│ │ └── backend.tf
│ ├── staging/
│ └── prod/
└── scripts/
└── plan-all.sh # 전체 환경 plan 스크립트모노레포 vs 멀티레포: 팀 규모가 작을 때는 infrastructure/ 하나에 모듈과 환경 설정을 모두 두는 모노레포가 편하다. 팀이 커지면 terraform-modules 레포와 infra-prod 레포처럼 분리해 변경 주기와 권한을 다르게 관리한다.
실무 체크리스트
| 항목 | 확인 내용 |
|---|---|
| 모듈 범위 | 2곳 이상에서 사용하거나 의미 있는 시스템 경계일 때만 모듈화 |
| 표준 파일 구조 | main.tf, variables.tf, outputs.tf, versions.tf, README.md |
| variable description | 모든 variable에 description 필수; 입력 검증은 validation 블록 |
| 모듈 트리 깊이 | 중첩 모듈보다 1단계 플랫 컴포지션 유지 |
| for_each vs count | 모듈 다중 인스턴스는 for_each 사용; count는 순서 변경 시 위험 |
| 버전 고정 | 외부 모듈 version = "~> X.Y" 고정; Git 태그 참조는 ?ref=vX.Y.Z |
| 테스트 | Terraform 1.6+ terraform test로 plan/apply 레벨 검증 |
한 줄 정리
모듈은 반복되거나 의미 있는 시스템 경계에서만 만들고, 표준 파일 구조(main/variables/outputs/versions)와 플랫 컴포지션 패턴으로 설계하며, for_each로 다수 인스턴스를, Git 태그 버전으로 안정적인 재사용을 확보한다.
References
- https://developer.hashicorp.com/terraform/language/modules/develop/structure
- https://developer.hashicorp.com/terraform/language/modules/develop/composition
- https://developer.hashicorp.com/terraform/language/meta-arguments/for_each
- https://developer.hashicorp.com/terraform/language/tests
- https://oneuptime.com/blog/post/2026-02-23-how-to-understand-terraform-module-structure/view
- https://oneuptime.com/blog/post/2026-02-23-how-to-use-module-composition-patterns-in-terraform/view
- https://oneuptime.com/blog/post/2026-02-23-how-to-use-module-for-each-in-terraform/view
- https://spacelift.io/blog/terraform-module-versioning
- https://blogs.businesscompassllc.com/2026/03/advanced-terraform-module-design.html
- https://blogs.businesscompassllc.com/2026/04/scaling-terraform-with-modules.html
- https://devopscube.com/terraform-module-best-practices/