개요
Terraform 기본 글에서 HCL 문법, Resource, State, Module을 다뤘습니다. 심화에서는 반복문, lifecycle, import, CI/CD 연동을 정리합니다.
“비슷한 리소스 10개를 어떻게 만들지?”, “이미 콘솔에서 만든 인프라를 Terraform으로 옮기려면?”, “팀에서 안전하게 apply 하려면?” 같은 질문에 대한 답입니다.
1. 반복문 (count, for_each)
정리 보기
count
같은 리소스를 여러 개 만들 때 사용합니다. 단순히 개수만 다를 때 적합합니다.
resource "aws_instance" "web" {
count = 3
ami = "ami-0abcdef1234567890"
instance_type = "t3.micro"
tags = {
Name = "web-${count.index}" # web-0, web-1, web-2
}
}count.index로 현재 인덱스 참조 (0부터 시작)- 중간 인스턴스를 삭제하면 인덱스가 밀려서 의도치 않은 재생성 발생
for_each
각 인스턴스마다 고유한 설정이 필요할 때 사용합니다. 키로 리소스를 식별하기 때문에 중간 항목을 지워도 나머지가 재생성되지 않습니다.
variable "instances" {
default = {
web = "t3.micro"
api = "t3.small"
worker = "t3.medium"
}
}
resource "aws_instance" "server" {
for_each = var.instances
ami = "ami-0abcdef1234567890"
instance_type = each.value
tags = {
Name = each.key # web, api, worker
}
}each.key: 맵의 키each.value: 맵의 값- 특정 인스턴스 삭제해도 다른 인스턴스에 영향 없음
count vs for_each
| 항목 | count | for_each |
|---|---|---|
| 식별 방식 | 인덱스 (0, 1, 2) | 키 (“web”, “api”) |
| 중간 삭제 시 | 뒤 인덱스 전부 재생성 | 해당 키만 삭제 |
| 적합한 경우 | 동일한 리소스 N개 | 각각 다른 설정 필요 |
| 참조 방식 | aws_instance.web[0] |
aws_instance.server["web"] |
for_each는 맵 또는 문자열 집합(set of strings)만 받고, 같은 블록에 count와 for_each를 함께 쓸 수 없습니다. 리스트를 쓰려면 toset()으로 변환해야 합니다.
count에서 for_each로 바꾸면 리소스 주소가 web[0]에서 server["web"] 형태로 바뀝니다. State에 있는 주소와 달라지므로 그대로 apply하면 삭제 후 재생성 계획이 잡히고, 이를 피하려면 moved 블록이나 terraform state mv로 주소를 옮겨야 합니다. State의 리소스 주소가 달라지면 Terraform은 다른 리소스로 인식합니다.
2. dynamic 블록
정리 보기
리소스 내부의 반복되는 중첩 블록을 동적으로 생성할 때 사용합니다. Security Group의 ingress 규칙처럼 여러 개 필요한 경우에 유용합니다.
dynamic 없이 (하드코딩)
resource "aws_security_group" "web" {
name = "web-sg"
ingress {
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
ingress {
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
}dynamic 사용
variable "ingress_rules" {
default = [
{ port = 80, cidr = "0.0.0.0/0" },
{ port = 443, cidr = "0.0.0.0/0" },
{ port = 8080, cidr = "10.0.0.0/16" },
]
}
resource "aws_security_group" "web" {
name = "web-sg"
dynamic "ingress" {
for_each = var.ingress_rules
content {
from_port = ingress.value.port
to_port = ingress.value.port
protocol = "tcp"
cidr_blocks = [ingress.value.cidr]
}
}
}구조
dynamic "<블록이름>" {
for_each = <컬렉션>
content {
# 블록 내용 (<블록이름>.value로 접근)
}
}iterator 규칙: 기본적으로 블록 이름이 iterator 이름이 됩니다. 위 예시에서 dynamic "ingress"면 ingress.value로 접근합니다. 이름을 바꾸고 싶으면 iterator 인자를 사용합니다:
dynamic "ingress" {
for_each = var.ingress_rules
iterator = rule # iterator 이름을 "rule"로 변경
content {
from_port = rule.value.port # rule.value로 접근
to_port = rule.value.port
protocol = "tcp"
cidr_blocks = [rule.value.cidr]
}
}제약
- dynamic 블록은
resource,data,provider,provisioner블록 안에서 쓸 수 있습니다. lifecycle,provisioner같은 메타 인자 블록은 dynamic으로 생성할 수 없습니다.- iterator 객체는
key와value두 속성을 가집니다.for_each에 집합(set)을 주면key가value와 같아집니다.
공식 문서에서는 dynamic 블록을 남용하지 말라고 권장합니다. 재사용 모듈의 인터페이스를 깔끔하게 만들 때만 쓰고, 가능하면 블록을 직접 쓰는 게 읽기 좋습니다.
3. 조건문과 함수
정리 보기
삼항 연산자
# 조건 ? true일 때 값 : false일 때 값
instance_type = var.environment == "prod" ? "t3.large" : "t3.micro"# 리소스를 조건부로 생성
resource "aws_cloudwatch_metric_alarm" "cpu" {
count = var.enable_alarm ? 1 : 0 # true면 1개 생성, false면 0개 (안 만듦)
# ...
}함수
| 함수 | 용도 | 예시 |
|---|---|---|
lookup |
맵에서 값 조회 (기본값 지정 가능) | lookup(var.amis, "ap-northeast-2", "ami-default") |
merge |
맵 합치기 | merge(var.default_tags, { Name = "web" }) |
concat |
리스트 합치기 | concat(var.public_subnets, var.private_subnets) |
cidrsubnet |
CIDR에서 서브넷 계산 | cidrsubnet("10.0.0.0/16", 8, 1) → 10.0.1.0/24 |
file |
파일 내용 읽기 | file("scripts/init.sh") |
templatefile |
템플릿에 변수 삽입 | templatefile("user_data.tpl", { name = "web" }) |
try |
에러 나면 기본값 | try(var.config.port, 8080) |
coalesce |
null도 빈 문자열도 아닌 첫 번째 값 | coalesce(var.custom_name, "default") |
coalesce는 null과 빈 문자열을 모두 건너뜁니다. 인자들의 타입이 다르면 Terraform이 공통 타입으로 자동 변환을 시도하고, 변환할 수 없으면 에러가 납니다.
for 표현식
리스트나 맵을 변환할 때 사용합니다.
# 리스트 → 리스트 (대문자 변환)
upper_names = [for name in var.names : upper(name)]
# 리스트 → 맵
instance_ids = { for inst in aws_instance.web : inst.tags.Name => inst.id }
# 필터링
prod_instances = [for inst in var.instances : inst if inst.env == "prod"]4. Lifecycle
정리 보기
리소스의 생성/수정/삭제 동작을 커스터마이징하는 블록입니다.
create_before_destroy
기존 리소스를 삭제하기 전에 새 리소스를 먼저 생성합니다. 다운타임을 줄일 때 사용합니다.
resource "aws_instance" "web" {
ami = var.ami_id
instance_type = "t3.micro"
lifecycle {
create_before_destroy = true
}
}prevent_destroy
plan에 해당 리소스의 삭제가 포함되면 Terraform이 에러를 반환합니다.
resource "aws_db_instance" "main" {
# ...
lifecycle {
prevent_destroy = true
}
}리소스 설정 자체를 코드에서 제거한 경우에는 삭제를 막지 않습니다.
ignore_changes
특정 속성의 변경을 무시합니다. 외부에서 수정되는 값(예: Auto Scaling이 바꾸는 태그)을 Terraform이 되돌리지 않게 합니다.
resource "aws_instance" "web" {
# ...
lifecycle {
ignore_changes = [tags, ami]
}
}ignore_changes = all로 해당 리소스의 모든 속성 변경을 무시할 수 있습니다.
replace_triggered_by
다른 리소스가 변경되면 이 리소스를 재생성합니다.
resource "aws_appautoscaling_target" "ecs" {
# ...
lifecycle {
replace_triggered_by = [aws_ecs_service.main.id]
}
}제약: replace_triggered_by에는 관리 리소스(managed resource)의 주소, 인스턴스, 인스턴스 속성만 넣을 수 있습니다. local이나 변수처럼 자체 계획(planned action)이 없는 값은 넣을 수 없습니다. 참조 대상이 리소스 인스턴스면 그 인스턴스의 수정 또는 교체 계획이 잡힐 때, 속성 하나를 참조하면 그 값이 바뀔 때 교체가 발생합니다.
또한 lifecycle 설정은 의존성 그래프 구성 단계에서 처리되므로 리터럴 값만 쓸 수 있습니다. create_before_destroy를 제외하면 lifecycle 규칙은 State에 기록되지 않습니다.
정리
| 옵션 | 용도 |
|---|---|
create_before_destroy |
무중단 교체 |
prevent_destroy |
삭제 계획이 잡히면 에러 반환 |
ignore_changes |
외부 변경 무시 |
replace_triggered_by |
연관 리소스 변경 시 재생성 |
5. Workspace
정리 보기
하나의 코드로 여러 배포를 관리할 때 사용합니다. Workspace마다 별도의 State를 가집니다. 단, 여러 Workspace를 쓰려면 backend가 이를 지원해야 합니다. 지원 backend는 AzureRM, Consul, COS, GCS, Kubernetes, Local, OSS, Postgres, Remote, S3입니다. 처음에는 default라는 Workspace 하나만 있고, 이 Workspace는 삭제할 수 없습니다.
참고로 Terraform CLI의 Workspace와 HCP Terraform의 Workspace는 다른 개념입니다.
기본 명령어
# Workspace 목록
terraform workspace list
# 새 Workspace 생성
terraform workspace new dev
terraform workspace new prod
# Workspace 전환
terraform workspace select prod
# 현재 Workspace 확인
terraform workspace show코드에서 Workspace 활용
resource "aws_instance" "web" {
instance_type = terraform.workspace == "prod" ? "t3.large" : "t3.micro"
tags = {
Environment = terraform.workspace
}
}
# S3 버킷 이름에 환경 포함
resource "aws_s3_bucket" "data" {
bucket = "myapp-${terraform.workspace}-data"
}Workspace vs 디렉터리 분리
| 방식 | 장점 | 단점 |
|---|---|---|
| Workspace | 코드 중복 없음 | 환경별 차이가 크면 복잡해짐 |
| 디렉터리 분리 | 환경별 독립적 관리 | 코드 중복 발생 |
공식 문서는 Workspace가 시스템 분해(system decomposition)나 서로 다른 인증 정보·접근 제어가 필요한 배포에는 적합하지 않다고 설명합니다.
6. 기존 인프라 가져오기 (Import)
정리 보기
이미 콘솔이나 CLI로 만든 리소스를 Terraform 관리 하에 두는 방법입니다.
방법 1: terraform import 명령어
리소스를 하나씩 가져옵니다.
# 형식: terraform import <리소스주소> <실제ID>
terraform import aws_instance.web i-0abc123def456
# VPC
terraform import aws_vpc.main vpc-0abc123
# S3 버킷
terraform import aws_s3_bucket.data my-bucket-nameimport는 State에만 추가하므로 .tf 파일에 리소스 블록이 있어야 합니다.
방법 2: import 블록 (Terraform 1.5+)
코드에 import 블록을 작성하고 terraform plan으로 확인 후 apply 합니다.
# import 블록 작성
import {
to = aws_instance.web
id = "i-0abc123def456"
}
# 리소스 블록도 작성 (또는 plan -generate-config-out으로 자동 생성)
resource "aws_instance" "web" {
ami = "ami-0abcdef1234567890"
instance_type = "t3.micro"
# ...
}# 코드 자동 생성 (리소스 블록을 자동으로 만들어줌)
terraform plan -generate-config-out=generated.tf-generate-config-out에는 새 파일 경로를 줘야 합니다. 이미 있는 파일을 지정하면 에러가 납니다. 그리고 이 설정 자동 생성 기능은 Terraform 1.5에 실험적(experimental) 기능으로 들어갔고, 이후 마이너 버전에서 생성 결과 형식이나 동작이 바뀔 수 있습니다. 실행하면 “Config generation is experimental” 경고가 함께 출력됩니다.
import 블록 방식의 장점:
- CI/CD 파이프라인에서 자동화 가능
- plan으로 미리 확인 가능
for_each로 여러 리소스를 한 번에 import 가능 (import 블록의for_each는 Terraform 1.7부터)
import 블록에서 id와 identity는 함께 쓸 수 없습니다. 둘 중 하나만 지정합니다.
방법 3: Terraformer (Google 오픈소스)
기존 인프라 전체를 한 번에 .tf 파일로 변환하는 도구입니다.
# AWS 전체 리소스를 Terraform 코드로 변환
terraformer import aws --resources=ec2_instance,vpc,subnet --regions=ap-northeast-2
# 특정 리소스만
terraformer import aws --resources=s3 --regions=ap-northeast-2Terraformer 레포지토리는 현재 아카이브 상태입니다.
7. Data Source
정리 보기
이미 존재하는 리소스의 정보를 읽어올 때 사용합니다. Resource는 “만들겠다"이고, Data Source는 “조회하겠다"입니다.
# 최신 Amazon Linux AMI 조회
data "aws_ami" "amazon_linux" {
most_recent = true
owners = ["amazon"]
filter {
name = "name"
values = ["al2023-ami-*-x86_64"]
}
}
# 조회한 AMI로 인스턴스 생성
resource "aws_instance" "web" {
ami = data.aws_ami.amazon_linux.id
instance_type = "t3.micro"
}Data Source
# 현재 AWS 계정 정보
data "aws_caller_identity" "current" {}
# 사용: data.aws_caller_identity.current.account_id
# 현재 리전
data "aws_region" "current" {}
# 사용: data.aws_region.current.name
# 기존 VPC 조회
data "aws_vpc" "existing" {
filter {
name = "tag:Name"
values = ["main-vpc"]
}
}
# 다른 State 참조 (Remote State)
data "terraform_remote_state" "network" {
backend = "s3"
config = {
bucket = "my-terraform-state"
key = "network/terraform.tfstate"
region = "ap-northeast-2"
}
}
# 사용
resource "aws_instance" "web" {
subnet_id = data.terraform_remote_state.network.outputs.public_subnet_id
}terraform_remote_state는 다른 State의 output 값을 읽어옵니다. 네트워크 구성의 State에 있는 VPC ID를 애플리케이션 구성에서 참조하는 식으로 사용합니다.
8. CI/CD 연동
정리 보기
PR에서 plan을 실행하고 merge 시 apply하는 구성이 많이 쓰입니다.
기본 구조
PR 생성 → terraform plan (결과를 PR 코멘트로) → 리뷰 → Merge → terraform applyGitHub Actions 예시
name: Terraform
on:
pull_request:
paths: ['infra/**']
push:
branches: [main]
paths: ['infra/**']
env:
TF_VERSION: "1.9.0"
AWS_REGION: "ap-northeast-2"
jobs:
plan:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
permissions:
id-token: write # OIDC 토큰 발급에 필요
contents: read
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
with:
terraform_version: ${{ env.TF_VERSION }}
- name: Configure AWS Credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
aws-region: ${{ env.AWS_REGION }}
- name: Terraform Init
run: terraform init
working-directory: infra
- name: Terraform Plan
run: terraform plan -no-color -out=tfplan
working-directory: infra
apply:
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
runs-on: ubuntu-latest
permissions:
id-token: write
contents: read
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
with:
terraform_version: ${{ env.TF_VERSION }}
- name: Configure AWS Credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
aws-region: ${{ env.AWS_REGION }}
- name: Terraform Init
run: terraform init
working-directory: infra
- name: Terraform Apply
run: terraform apply -auto-approve
working-directory: infra예시 워크플로의 구성 요소
| 항목 | 설명 |
|---|---|
| plan job | pull_request 이벤트에서만 실행 |
| apply job | main 브랜치 push에서만 실행 |
| OIDC 인증 | configure-aws-credentials의 role-to-assume으로 역할을 맡아 임시 자격 증명을 발급받음 |
permissions: id-token: write |
OIDC 토큰 발급에 필요한 권한. 없으면 인증 실패 |
| -auto-approve | 저장된 plan 파일 없이 apply할 때 대화형 승인을 건너뛰기 위해 사용 |
| State Lock | 동시 apply 방지 (S3 backend는 use_lockfile = true) |
OIDC로 역할을 맡으려면 워크플로우 또는 job에 permissions 설정으로 id-token: write를 줘야 합니다. 이게 없으면 토큰 발급 단계에서 실패합니다.
GitLab CI/CD 예시 (.gitlab-ci.yml)
stages:
- validate
- plan
- apply
variables:
TF_VERSION: "1.9.0"
TF_ROOT: "infra"
image:
name: hashicorp/terraform:$TF_VERSION
entrypoint: [""]
before_script:
- cd $TF_ROOT
- terraform init
validate:
stage: validate
script:
- terraform validate
- terraform fmt -check
plan:
stage: plan
script:
- terraform plan -out=tfplan
artifacts:
paths:
- $TF_ROOT/tfplan
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == "main"
apply:
stage: apply
script:
- terraform apply tfplan
dependencies:
- plan
rules:
- if: $CI_COMMIT_BRANCH == "main"
when: manual # 수동 승인 후 apply
environment:
name: production저장된 plan 파일을 terraform apply tfplan처럼 넘기면 Terraform은 확인을 묻지 않고 그대로 실행합니다. plan 파일을 넘기는 행위 자체를 승인으로 보기 때문에 이 경우 -auto-approve는 무시됩니다. 그래서 GitLab 예시에서는 붙이지 않았습니다. 반대로 위 GitHub Actions 예시처럼 plan 파일 없이 apply할 때는 -auto-approve가 필요합니다.
GitHub Actions vs GitLab CI/CD 비교
| 항목 | GitHub Actions | GitLab CI/CD |
|---|---|---|
| 설정 파일 | .github/workflows/*.yml |
.gitlab-ci.yml |
| 트리거 | on: push/pull_request |
rules: if |
| 단계 구분 | jobs (병렬 기본) |
stages (순차 기본) |
| 수동 승인 | Environment protection rules | when: manual |
| 비밀 관리 | Repository Secrets | CI/CD Variables |
| Runner | GitHub-hosted / self-hosted | GitLab shared / self-hosted |
둘 다 기본 흐름은 같습니다: plan → 확인 → apply. 차이는 문법과 설정 위치 정도입니다.