Skip to content
ioob.dev
Go back

Terraform 10편 — 반복과 조건

· 5분 읽기
Terraform 시리즈 (10/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

같은 리소스를 여러 번 만들기

서브넷 세 개, 보안 그룹 규칙 다섯 개, IAM 사용자 열 명. 인프라 코드를 쓰다 보면 반복해서 만드는 리소스가 수두룩하다. 복사-붙여넣기로도 할 수 있지만, Terraform은 더 우아한 방법을 제공한다.

반복을 위한 두 가지 주요 메타 인자가 있다. countfor_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를 빼더라도 ac는 건드리지 않는다. 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.keyeach.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는 왜 필요한지 살펴본다.

11편: 워크스페이스와 환경 분리


Related Posts

Share this post on:

Comments

Loading comments...


Previous Post
Terraform 9편 — 모듈
Next Post
Terraform 11편 — 워크스페이스와 환경 분리