Table of contents
- 같은 리소스를 여러 번 만들기
- count — 개수로 반복
- for_each — 키로 반복
- count vs for_each — 선택 기준
- dynamic 블록 — 중첩 블록의 반복
- 조건부 표현식
- for 표현식 — 리스트/맵 변환
- 조건부 리소스 생성 패턴
- 실전 패턴 몇 가지
같은 리소스를 여러 번 만들기
서브넷 세 개, 보안 그룹 규칙 다섯 개, IAM 사용자 열 명. 인프라 코드를 쓰다 보면 반복해서 만드는 리소스가 수두룩하다. 복사-붙여넣기로도 할 수 있지만, Terraform은 더 우아한 방법을 제공한다.
반복을 위한 두 가지 주요 메타 인자가 있다. count와 for_each다. 어떤 상황에 뭘 써야 하는지가 이 편의 핵심이다.
flowchart LR
Need["반복이 필요"] --> Q{"항목을\n식별할 수 있는\n고유 키가 있나?"}
Q -->|"없음 (단순 개수)"| Count["count 사용"]
Q -->|"있음 (이름, ID 등)"| ForEach["for_each 사용"]
Count -.-> CountCase["예: 같은 설정의 EC2 3대"]
ForEach -.-> ForEachCase["예: 사용자별 IAM,\n지역별 S3 버킷"]
count — 개수로 반복
count는 가장 단순한 반복이다. 숫자를 주면 그 수만큼 리소스를 만든다.
resource "aws_instance" "worker" {
count = 3
ami = "ami-0c9c942bd7bf113a2"
instance_type = "t3.micro"
tags = {
Name = "worker-${count.index}"
}
}
count.index로 현재 몇 번째인지(0부터 시작) 참조할 수 있다. 위 코드는 worker-0, worker-1, worker-2 세 대의 인스턴스를 만든다.
참조도 인덱스로 한다.
resource "aws_eip" "worker_ip" {
count = 3
instance = aws_instance.worker[count.index].id
}
# 다른 곳에서 첫 번째 인스턴스 참조
output "first_worker_ip" {
value = aws_instance.worker[0].private_ip
}
# 전체 참조 (splat)
output "all_worker_ips" {
value = aws_instance.worker[*].private_ip
}
[*]는 splat 표현식이다. 전체 인스턴스의 특정 속성을 리스트로 뽑아낸다.
count의 함정
count는 편하지만 치명적인 함정이 하나 있다. 가운데 항목을 지우면 뒤의 것들이 재생성된다.
# 처음: 서브넷 3개
resource "aws_subnet" "app" {
count = 3
cidr_block = ["10.0.1.0/24", "10.0.2.0/24", "10.0.3.0/24"][count.index]
}
# 가운데 CIDR을 빼고 싶다
# cidr_block = ["10.0.1.0/24", "10.0.3.0/24"]
# count = 2
이렇게 바꾸면 Terraform 입장에서는 인덱스 1(기존 10.0.2.0/24)이 10.0.3.0/24로 바뀌고, 인덱스 2가 사라진 것으로 본다. 실제 의도는 “가운데를 지우고 싶다”였지만, Terraform은 두 개를 재생성한다. 프로덕션 서브넷이라면 대참사다.
count는 모든 인스턴스가 완전히 동일하고, 가운데를 뺄 일이 없을 때만 쓰는 게 안전하다. 인스턴스를 늘리기만 하고 줄이지 않는 경우라면 괜찮다.
for_each — 키로 반복
이런 한계 때문에 대부분의 실전 코드는 for_each를 쓴다. 각 반복 인스턴스에 고유한 키를 부여해서, 키가 같은 항목은 같은 인스턴스로 인식된다.
for_each는 map 또는 set을 받는다.
map으로 반복
resource "aws_subnet" "app" {
for_each = {
a = "10.0.1.0/24"
b = "10.0.2.0/24"
c = "10.0.3.0/24"
}
vpc_id = aws_vpc.main.id
cidr_block = each.value
availability_zone = "ap-northeast-2${each.key}"
tags = {
Name = "subnet-${each.key}"
}
}
each.key는 맵의 키(“a”, “b”, “c”), each.value는 값(“10.0.1.0/24” 등)이다.
가운데에서 b를 빼더라도 a와 c는 건드리지 않는다. Terraform이 aws_subnet.app["a"], aws_subnet.app["b"], aws_subnet.app["c"]로 관리하고, b가 빠지면 그것만 제거한다.
# b를 뺀다
for_each = {
a = "10.0.1.0/24"
c = "10.0.3.0/24" # c는 키 그대로
}
# → b만 삭제되고 a, c는 건드리지 않음
set으로 반복
값이 각자 다르지 않고 단순히 이름 목록일 때는 set이 편하다.
variable "user_names" {
default = ["alice", "bob", "carol"]
}
resource "aws_iam_user" "developer" {
for_each = toset(var.user_names)
name = each.value
}
리스트를 toset()으로 감싸면 set이 된다. set은 중복을 허용하지 않고, 각 항목이 그 자체로 키가 된다. each.key와 each.value가 같은 값이다.
참조 방식
count는 인덱스로 참조하지만, for_each는 키로 참조한다.
# for_each로 만든 것 참조
resource "aws_route_table_association" "app" {
for_each = aws_subnet.app
subnet_id = each.value.id
route_table_id = aws_route_table.main.id
}
# 특정 키로 접근
output "zone_a_subnet_id" {
value = aws_subnet.app["a"].id
}
# 전체 맵으로 접근
output "all_subnet_ids" {
value = { for k, v in aws_subnet.app : k => v.id }
}
count vs for_each — 선택 기준
두 메타 인자의 선택은 “나중에 항목을 빼거나 추가할 일이 있는가”로 정리하면 된다.
| 상황 | 권장 |
|---|---|
| 항목이 완전히 동일하고 개수만 중요 (예: 완전히 같은 워커 N개) | count |
| 항목별로 설정이 다름 (예: 환경별, 사용자별) | for_each |
| 가운데 항목을 뺄 가능성이 있음 | for_each |
| 조건부로 0개 또는 1개만 만들고 싶음 | count = var.enabled ? 1 : 0 |
조건부 생성에 count를 쓰는 건 관용적인 패턴이다.
resource "aws_instance" "bastion" {
count = var.create_bastion ? 1 : 0
ami = "ami-xxx"
instance_type = "t3.nano"
}
output "bastion_ip" {
value = var.create_bastion ? aws_instance.bastion[0].public_ip : null
}
이런 경우는 for_each보다 count가 자연스럽다.
dynamic 블록 — 중첩 블록의 반복
리소스 안의 중첩 블록을 반복해서 만들어야 할 때가 있다. 보안 그룹의 ingress 규칙이 대표적이다.
# 정적으로 쓸 때
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"]
}
ingress {
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = ["10.0.0.0/8"]
}
}
규칙이 많아지면 반복된다. dynamic 블록을 쓰면 데이터 기반으로 규칙을 동적으로 만들 수 있다.
variable "ingress_rules" {
type = list(object({
from_port = number
to_port = number
protocol = string
cidr_blocks = list(string)
}))
default = [
{ from_port = 80, to_port = 80, protocol = "tcp", cidr_blocks = ["0.0.0.0/0"] },
{ from_port = 443, to_port = 443, protocol = "tcp", cidr_blocks = ["0.0.0.0/0"] },
{ from_port = 22, to_port = 22, protocol = "tcp", cidr_blocks = ["10.0.0.0/8"] },
]
}
resource "aws_security_group" "web" {
name = "web-sg"
dynamic "ingress" {
for_each = var.ingress_rules
content {
from_port = ingress.value.from_port
to_port = ingress.value.to_port
protocol = ingress.value.protocol
cidr_blocks = ingress.value.cidr_blocks
}
}
}
dynamic "ingress"는 “ingress라는 중첩 블록을 동적으로 생성한다”는 뜻이다. content 안에 한 블록의 내용을 적는데, ingress.value로 현재 반복 항목을 참조한다.
dynamic의 남용 주의
dynamic은 강력하지만, 필요 이상으로 쓰면 가독성이 나빠진다. 규칙이 3개 정도라면 그냥 명시적으로 쓰는 게 더 읽기 편하다. 규칙이 동적으로 바뀌거나, 개수가 많을 때만 쓰는 게 좋다.
flowchart TB
Start["중첩 블록 반복?"] --> Few{"개수가 적고\n고정적인가?"}
Few -->|"예 (2~3개)"| Plain["그냥 반복 작성"]
Few -->|"아니요"| Dyn["dynamic 사용"]
Plain -.-> Reason1["읽기 쉬움"]
Dyn -.-> Reason2["데이터 기반으로\n유연하게 관리"]
조건부 표현식
Terraform의 조건부 표현식은 삼항 연산자 문법을 쓴다.
locals {
instance_type = var.environment == "prod" ? "m5.large" : "t3.micro"
# 중첩도 가능 (가독성은 떨어짐)
size = var.env == "prod" ? "large" : var.env == "staging" ? "medium" : "small"
}
중첩이 깊어지면 lookup()이나 맵을 쓰는 편이 낫다.
locals {
instance_sizes = {
dev = "t3.micro"
staging = "t3.medium"
prod = "m5.large"
}
instance_type = local.instance_sizes[var.environment]
}
for 표현식 — 리스트/맵 변환
가장 자주 쓰이는 강력한 기능이다. 리스트를 맵으로, 맵을 리스트로, 필터링까지 한 줄로 처리한다.
리스트 → 리스트 (변환)
variable "names" {
default = ["alice", "bob", "carol"]
}
locals {
upper_names = [for name in var.names : upper(name)]
# ["ALICE", "BOB", "CAROL"]
}
리스트 → 맵
locals {
name_map = { for name in var.names : name => length(name) }
# { alice = 5, bob = 3, carol = 5 }
}
필터링
locals {
short_names = [for name in var.names : name if length(name) < 5]
# ["bob"]
}
객체 리스트 → 맵 (for_each용으로 변환)
variable "users" {
default = [
{ name = "alice", role = "admin" },
{ name = "bob", role = "dev" },
]
}
locals {
users_map = { for u in var.users : u.name => u }
}
resource "aws_iam_user" "dev" {
for_each = local.users_map
name = each.value.name
tags = {
Role = each.value.role
}
}
입력이 리스트 형태인데 for_each에 넘기고 싶을 때 이 패턴이 유용하다. 리스트는 순서가 있지만, for_each는 고유 키가 필요하기 때문에 맵으로 변환한다.
조건부 리소스 생성 패턴
프로덕션에서만 백업을 켜거나, 선택적 기능을 토글하고 싶을 때 쓰는 패턴이다.
count로 0 또는 1
variable "enable_monitoring" {
type = bool
default = false
}
resource "aws_cloudwatch_metric_alarm" "cpu" {
count = var.enable_monitoring ? 1 : 0
alarm_name = "cpu-high"
metric_name = "CPUUtilization"
# ...
}
for_each로 선택적 맵
locals {
monitoring_targets = var.enable_monitoring ? {
cpu = "CPUUtilization"
memory = "MemoryUtilization"
} : {}
}
resource "aws_cloudwatch_metric_alarm" "metrics" {
for_each = local.monitoring_targets
alarm_name = "${each.key}-high"
metric_name = each.value
# ...
}
여러 개를 조건부로 만들 때는 이쪽이 깔끔하다.
실전 패턴 몇 가지
패턴 1: 환경별로 다른 설정 적용
locals {
env_config = {
dev = {
instance_count = 1
instance_type = "t3.micro"
multi_az = false
}
staging = {
instance_count = 2
instance_type = "t3.medium"
multi_az = false
}
prod = {
instance_count = 3
instance_type = "m5.large"
multi_az = true
}
}
current = local.env_config[var.environment]
}
resource "aws_instance" "app" {
count = local.current.instance_count
instance_type = local.current.instance_type
}
환경별 차이를 한군데에 모아두고 참조한다.
패턴 2: 중첩 for로 조합 만들기
variable "regions" {
default = ["us-east-1", "us-west-2"]
}
variable "environments" {
default = ["dev", "prod"]
}
locals {
region_env_pairs = {
for pair in setproduct(var.regions, var.environments) :
"${pair[0]}-${pair[1]}" => {
region = pair[0]
environment = pair[1]
}
}
}
# 4개 조합: us-east-1-dev, us-east-1-prod, us-west-2-dev, us-west-2-prod
setproduct로 두 리스트의 모든 조합을 만든 뒤, 고유 키를 붙여 맵으로 변환한다. 리전별 환경별 버킷을 만드는 경우에 유용하다.
반복과 조건은 Terraform 코드의 표현력을 확 넓혀준다. 핵심 원칙만 기억하면 된다. 완전히 동일한 것의 개수만 다를 때는 count, 항목별로 정체성이 있으면 for_each, 중첩 블록 반복은 dynamic. 그리고 for 표현식은 데이터 구조 변환의 스위스 아미 나이프다.
다음 편에서는 워크스페이스와 환경 분리를 다룬다. dev/staging/prod를 어떻게 나눌지, Terragrunt는 왜 필요한지 살펴본다.


Loading comments...