LLM WikiAccess-protected knowledge portal
← 스터디 홈
3편 · 약 20분

모듈 설계와 코드 구조화

언제 모듈을 만들어야 하나

모듈은 비용이다. 추상화 계층이 하나 더 생기면 코드를 읽는 사람이 파일을 오가며 흐름을 추적해야 한다. 처음부터 모든 것을 모듈로 만들면 오히려 유지보수가 어려워진다.

모듈이 정당화되는 세 가지 상황이 있다.

  1. 반복: 같은 리소스 조합을 두 곳 이상에서 사용한다. 예) 개발·스테이징·프로덕션에 동일한 EC2 + ALB + 보안 그룹 패턴을 쓴다.
  2. 시스템 경계: 여러 리소스가 함께 의미 있는 하나의 시스템을 이룬다. 예) VPC + 서브넷 + 라우팅 테이블 → "네트워크 모듈".
  3. 팀 분리: 인프라 팀이 만든 검증된 모듈을 애플리케이션 팀이 가져다 쓴다. 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단계로 유지하고, 모듈 간 데이터 흐름을 루트 모듈의 localsoutput으로 명시한다.

# 루트 모듈 (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 조작도 복잡해진다.

루트 모듈
variables.tf
환경·설정 입력
main.tf
모듈 호출 + 연결
outputs.tf
최종 출력
modules/vpc VPC + 서브넷 + IGW
출력: vpc_id, subnet_ids
modules/eks 클러스터 + 노드그룹
입력: vpc_id, subnet_ids
modules/rds DB 인스턴스 + 파라미터
입력: vpc_id, allowed_sg_id
AWS Cloud
VPC / 서브넷 EKS 클러스터 RDS 인스턴스
모듈 컴포지션 아키텍처

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/