Table of contents
- 왜 모듈인가
- 모듈의 기본 구조
- 모듈 호출하기
- source — 모듈을 어디서 가져올까
- 공식 레지스트리 잘 활용하기
- 모듈 내부에서 또 모듈 부르기
- 모듈 작성 모범 사례
- 모듈 레지스트리 만들기
왜 모듈인가
VPC 하나, 서브넷 셋, 보안 그룹 몇 개, RDS 한 개. 개발 환경에 필요한 이 조합을 한 번 만들고 나면, 스테이징에도 똑같이 필요하고, 프로덕션에도 똑같이 필요하다. 각 환경마다 같은 HCL 코드를 복사해 붙여넣으면 뭐가 문제일까.
- 수정을 세 번 해야 한다: 보안 그룹 규칙 하나 바꾸려면 세 군데를 동시에 건드려야 한다
- 드리프트(divergence)가 생긴다: 한 군데만 고치고 다른 데는 잊어버리는 일이 반드시 벌어진다
- 리뷰가 어렵다: PR에 같은 변경이 세 번 올라오면 사람 눈으로 체크하기 힘들다
모듈은 이런 문제를 해결한다. 한 번 잘 만들어 둔 인프라 패턴을 함수처럼 불러 쓰는 개념이다. 프로그래밍의 함수와 똑같다. 입력을 받고, 내부 로직으로 리소스를 만들고, 출력을 돌려준다.
flowchart LR
Input["입력 변수\n(variables)"] --> Module["모듈\n(내부 리소스 생성)"]
Module --> Output["출력 값\n(outputs)"]
Input -.->|"cidr_block\nenvironment\ntags"| Module
Module -.->|"vpc_id\nsubnet_ids\nsecurity_group_id"| Output
모듈의 기본 구조
모듈은 사실 특별한 게 아니다. .tf 파일들이 모여 있는 디렉토리면 그게 바로 모듈이다. 관례적으로 세 개의 파일로 나누는 편이다.
modules/vpc/
├── main.tf # 실제 리소스 정의
├── variables.tf # 입력 변수
└── outputs.tf # 출력 값
간단한 VPC 모듈을 만들어보자.
# modules/vpc/variables.tf
variable "cidr_block" {
description = "VPC CIDR 블록"
type = string
}
variable "environment" {
description = "환경 이름 (dev, staging, prod)"
type = string
}
variable "azs" {
description = "사용할 가용 영역 목록"
type = list(string)
default = ["ap-northeast-2a", "ap-northeast-2c"]
}
variable "tags" {
description = "공통 태그"
type = map(string)
default = {}
}
입력은 variable 블록으로 선언한다. 타입 명시, 설명, 기본값까지 갖춰두면 나중에 재사용할 때 헷갈리지 않는다.
# modules/vpc/main.tf
resource "aws_vpc" "this" {
cidr_block = var.cidr_block
enable_dns_hostnames = true
enable_dns_support = true
tags = merge(
var.tags,
{
Name = "${var.environment}-vpc"
Environment = var.environment
}
)
}
resource "aws_subnet" "public" {
count = length(var.azs)
vpc_id = aws_vpc.this.id
cidr_block = cidrsubnet(var.cidr_block, 8, count.index)
availability_zone = var.azs[count.index]
tags = merge(
var.tags,
{
Name = "${var.environment}-public-${count.index + 1}"
Tier = "public"
}
)
}
리소스 이름을 this 또는 main으로 두는 관례가 있다. 모듈 외부에서는 module.vpc.aws_vpc.this처럼 불리는데, 모듈 이름 자체가 맥락을 주니까 리소스 이름은 간결하게 둔다.
# modules/vpc/outputs.tf
output "vpc_id" {
description = "생성된 VPC ID"
value = aws_vpc.this.id
}
output "public_subnet_ids" {
description = "퍼블릭 서브넷 ID 목록"
value = aws_subnet.public[*].id
}
output "cidr_block" {
description = "VPC CIDR"
value = aws_vpc.this.cidr_block
}
출력은 모듈이 만든 것들 중에서 외부에서 쓸 값들만 선별해서 공개한다. 모듈의 “공개 API”다.
모듈 호출하기
이제 이 모듈을 불러 쓴다.
# envs/dev/main.tf
module "vpc" {
source = "../../modules/vpc"
cidr_block = "10.0.0.0/16"
environment = "dev"
azs = ["ap-northeast-2a", "ap-northeast-2c"]
tags = {
Project = "my-service"
Owner = "platform-team"
}
}
resource "aws_instance" "app" {
ami = "ami-0c9c942bd7bf113a2"
instance_type = "t3.micro"
subnet_id = module.vpc.public_subnet_ids[0] # 모듈 출력 참조
}
module 블록으로 호출하고, source로 모듈 위치를 지정한다. 모듈이 노출한 출력값은 module.<이름>.<출력명>으로 접근한다.
모듈을 처음 쓰거나 source가 바뀌면 terraform init을 다시 실행해야 한다. Terraform이 모듈을 다운로드하거나 링크를 설정해야 하기 때문이다.
source — 모듈을 어디서 가져올까
모듈은 여러 곳에서 가져올 수 있다. 로컬 디렉토리, Git 레포지토리, Terraform 레지스트리, S3, HTTP 등등. 각각의 용도가 다르다.
flowchart TB
Src["source 지정"]
Src --> Local["로컬 경로\n'../../modules/vpc'"]
Src --> Git["Git 레포\n'git::https://...'"]
Src --> Reg["레지스트리\n'terraform-aws-modules/vpc/aws'"]
Src --> S3["S3/GCS\n's3::https://...'"]
Local -.장점.-> LocalPro["빠른 수정,\n같은 레포 내"]
Git -.장점.-> GitPro["버전 태그,\n팀/외부 공유"]
Reg -.장점.-> RegPro["공식/검증된 모듈,\nSemVer"]
로컬 경로 — 같은 레포 안의 모듈을 쓸 때
module "vpc" {
source = "../../modules/vpc"
}
가장 단순하다. 개발 중이거나 단일 레포로 관리하는 팀에 적합하다.
Git 레포 — 사내 모듈을 공유할 때
module "vpc" {
source = "git::https://github.com/my-company/terraform-modules.git//vpc?ref=v1.2.0"
}
// 뒤에는 레포 안의 서브디렉토리를 지정한다. ref는 태그, 브랜치, 커밋 해시 모두 가능하다. 버전은 반드시 태그로 고정하는 걸 권장한다. ref=main으로 두면 모듈이 업데이트될 때마다 예고 없이 인프라가 바뀐다.
SSH 접근이라면 이렇게 쓴다.
module "vpc" {
source = "git::ssh://git@github.com/my-company/terraform-modules.git//vpc?ref=v1.2.0"
}
Terraform 레지스트리 — 공식 또는 공개 모듈
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "5.5.0"
name = "my-vpc"
cidr = "10.0.0.0/16"
# ...
}
registry.terraform.io에 가면 검증된 공개 모듈들이 있다. 특히 terraform-aws-modules 계열은 AWS 팀이 유지보수하는 준공식 모듈이라 믿고 쓸 만하다. version으로 SemVer 제약을 걸 수 있다.
| 버전 표기 | 의미 |
|---|---|
"5.5.0" | 정확히 이 버전 |
"~> 5.5" | 5.5.x 중 최신 (5.6.0은 안 됨) |
"~> 5.5.0" | 5.5.x 중 최신 (5.5.1 OK, 5.6.0 안 됨) |
">= 5.0, < 6.0" | 5.x 전체 |
공식 레지스트리 잘 활용하기
바퀴를 처음부터 새로 발명할 필요 없다. 레지스트리에는 이미 잘 만들어진 모듈이 넘친다.
AWS 환경에서 자주 쓰이는 것들:
terraform-aws-modules/vpc/aws— VPC, 서브넷, NAT, 라우팅 전부 처리terraform-aws-modules/eks/aws— EKS 클러스터 + 노드 그룹terraform-aws-modules/rds/aws— RDS 인스턴스, 서브넷 그룹, 파라미터 그룹terraform-aws-modules/security-group/aws— 보안 그룹 규칙 관리
예를 들어 VPC를 공식 모듈로 만들면 이 정도 코드로 끝난다.
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "~> 5.5"
name = "prod-vpc"
cidr = "10.0.0.0/16"
azs = ["ap-northeast-2a", "ap-northeast-2c"]
private_subnets = ["10.0.1.0/24", "10.0.2.0/24"]
public_subnets = ["10.0.101.0/24", "10.0.102.0/24"]
enable_nat_gateway = true
single_nat_gateway = true
tags = {
Environment = "prod"
}
}
직접 짰다면 200줄 넘었을 것이다. 모듈을 쓰면 필요한 입력만 넘기면 된다.
다만 공식 모듈을 무작정 쓰는 것도 능사는 아니다. 공식 모듈은 범용성을 위해 옵션이 많고 복잡하다. 요구사항이 단순하다면 직접 짜는 게 더 명료할 수 있다. 또 공식 모듈이 내부 구조를 바꾸면 상태 마이그레이션이 필요해지기도 한다. 프로젝트 규모와 팀 성숙도에 따라 선택하면 된다.
모듈 내부에서 또 모듈 부르기
모듈 안에서 다른 모듈을 부를 수 있다. 중첩이 가능하다는 뜻이다.
modules/
├── network/
│ ├── main.tf # module "vpc" 사용
│ └── ...
└── vpc/
└── ...
# modules/network/main.tf
module "vpc" {
source = "../vpc"
cidr_block = var.cidr_block
environment = var.environment
}
resource "aws_internet_gateway" "main" {
vpc_id = module.vpc.vpc_id
}
과하게 중첩하면 디버깅이 어려워진다. 2단계 정도까지가 일반적으로 관리할 만한 선이다.
모듈 작성 모범 사례
실무에서 쓰면서 정리된 원칙들이다.
1) 하나의 모듈은 하나의 논리적 단위를 담는다
VPC 모듈이라면 VPC, 서브넷, 라우팅 테이블, NAT 등 “VPC를 구성하는 것들”을 담는다. 거기에 RDS까지 넣으면 모듈의 책임이 불명확해진다.
2) 입력 변수에 타입과 설명을 꼭 붙인다
variable "instance_type" {
description = "EC2 인스턴스 타입 (예: t3.micro, m5.large)"
type = string
default = "t3.micro"
validation {
condition = can(regex("^(t3|t4g|m5|m6i)\\.", var.instance_type))
error_message = "허용된 인스턴스 패밀리: t3, t4g, m5, m6i"
}
}
validation 블록으로 입력값을 검증하면 모듈 사용자가 실수하는 걸 조기에 막을 수 있다.
3) 내부 리소스 이름을 외부에 노출하지 않는다
모듈 사용자는 module.vpc.vpc_id로 VPC ID를 받으면 충분하다. 내부에서 리소스를 어떻게 조합했는지는 알 필요가 없다. 출력은 꼭 필요한 것만 공개한다.
4) README.md와 예제를 함께 둔다
modules/vpc/
├── main.tf
├── variables.tf
├── outputs.tf
├── README.md # 사용법, 입력/출력 설명
└── examples/
├── basic/
│ └── main.tf # 최소한의 사용 예
└── complete/
└── main.tf # 모든 옵션 사용 예
모듈을 다른 사람이 쓰려면 문서가 필수다. terraform-docs 같은 도구로 README를 자동 생성할 수도 있다.
terraform-docs markdown table ./modules/vpc > ./modules/vpc/README.md
5) 버전 관리를 제대로 한다
모듈에 SemVer를 적용한다. Git 태그로 v1.0.0, v1.1.0 식으로 찍어두면 사용자가 안정적으로 버전을 고정할 수 있다.
- MAJOR: 호환성 깨지는 변경 (출력 제거, 필수 입력 추가 등)
- MINOR: 호환성 유지 기능 추가 (기본값 있는 입력 추가 등)
- PATCH: 버그 수정
6) 깊은 조건 분기 대신 별도 모듈을 고려한다
한 모듈에서 var.environment == "prod" ? X : Y 같은 분기가 여기저기 생긴다면, 아예 모듈을 분리하는 편이 나을 수 있다. 분기가 많아질수록 읽기 어렵고 테스트도 까다로워진다.
모듈 레지스트리 만들기
팀 내에서 모듈을 공유하는 방법은 여러 가지다.
- Git 레포 하나에 모듈 모음:
terraform-modules같은 레포에vpc/,eks/,rds/서브디렉토리로 분리 - 모듈별 레포 분리:
terraform-aws-vpc,terraform-aws-eks등 각자 레포로. 버전 관리가 더 독립적 - Private Terraform Registry: Terraform Cloud 또는 자체 호스팅(S3 + HTTP 서버). 회사 모듈을 공식 모듈처럼 버전 검색 가능
규모가 작다면 첫 번째 방식(terraform-modules 단일 레포)이 시작하기 좋다. 회사가 커지면서 모듈 수가 많아지면 개별 레포로 분리하는 방향으로 진화하는 게 자연스럽다.
다음 편에서는 반복과 조건을 다룬다. count와 for_each 중 언제 뭘 써야 하는지, dynamic 블록으로 복잡한 설정을 깔끔하게 정리하는 법까지 살펴본다.


Loading comments...