Skip to content
ioob.dev
Go back

Terraform 9편 — 모듈

· 7분 읽기
Terraform 시리즈 (9/15)
  1. Terraform 1편 — Terraform이란
  2. Terraform 2편 — 설치와 첫 배포
  3. Terraform 3편 — HCL 문법
  4. Terraform 4편 — 변수와 출력
  5. Terraform 5편 — 프로바이더
  6. Terraform 6편 — 리소스와 의존성
  7. Terraform 7편 — 데이터 소스와 Import
  8. Terraform 8편 — State 관리
  9. Terraform 9편 — 모듈
  10. Terraform 10편 — 반복과 조건
  11. Terraform 11편 — 워크스페이스와 환경 분리
  12. Terraform 12편 — Kubernetes와 Helm 프로바이더
  13. Terraform 13편 — CI/CD 통합
  14. Terraform 14편 — 테스트와 정책
  15. Terraform 15편 — 실전 패턴과 함정
Table of contents

Table of contents

왜 모듈인가

VPC 하나, 서브넷 셋, 보안 그룹 몇 개, RDS 한 개. 개발 환경에 필요한 이 조합을 한 번 만들고 나면, 스테이징에도 똑같이 필요하고, 프로덕션에도 똑같이 필요하다. 각 환경마다 같은 HCL 코드를 복사해 붙여넣으면 뭐가 문제일까.

모듈은 이런 문제를 해결한다. 한 번 잘 만들어 둔 인프라 패턴을 함수처럼 불러 쓰는 개념이다. 프로그래밍의 함수와 똑같다. 입력을 받고, 내부 로직으로 리소스를 만들고, 출력을 돌려준다.

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 환경에서 자주 쓰이는 것들:

예를 들어 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 식으로 찍어두면 사용자가 안정적으로 버전을 고정할 수 있다.

6) 깊은 조건 분기 대신 별도 모듈을 고려한다

한 모듈에서 var.environment == "prod" ? X : Y 같은 분기가 여기저기 생긴다면, 아예 모듈을 분리하는 편이 나을 수 있다. 분기가 많아질수록 읽기 어렵고 테스트도 까다로워진다.

모듈 레지스트리 만들기

팀 내에서 모듈을 공유하는 방법은 여러 가지다.

규모가 작다면 첫 번째 방식(terraform-modules 단일 레포)이 시작하기 좋다. 회사가 커지면서 모듈 수가 많아지면 개별 레포로 분리하는 방향으로 진화하는 게 자연스럽다.


다음 편에서는 반복과 조건을 다룬다. countfor_each 중 언제 뭘 써야 하는지, dynamic 블록으로 복잡한 설정을 깔끔하게 정리하는 법까지 살펴본다.

10편: 반복과 조건


Related Posts

Share this post on:

Comments

Loading comments...


Previous Post
Terraform 8편 — State 관리
Next Post
Terraform 10편 — 반복과 조건