목록으로 가기

Terraform 반복문, Lifecycle, Import, CI/CD 자동화 — 심화 패턴

for_each로 리소스 반복 생성, lifecycle로 삭제 방지, 기존 인프라 import, GitHub Actions/GitLab CI/CD 연동까지 정리했습니다.

Terraform 반복문, Lifecycle, Import, CI/CD 자동화 — 심화 패턴 ko posts terraform for_each로 리소스 반복 생성, lifecycle로 삭제 방지, 기존 인프라 import, GitHub Actions/GitLab CI/CD 연동까지 정리했습니다.

개요

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)만 받고, 같은 블록에 countfor_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 객체는 keyvalue 두 속성을 가집니다. for_each에 집합(set)을 주면 keyvalue와 같아집니다.

공식 문서에서는 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-name

import는 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 블록에서 ididentity는 함께 쓸 수 없습니다. 둘 중 하나만 지정합니다.

방법 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-2

Terraformer 레포지토리는 현재 아카이브 상태입니다.

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 apply

GitHub 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-credentialsrole-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. 차이는 문법과 설정 위치 정도입니다.

관련 포스트

Terraform으로 AWS 인프라 관리하기 — HCL 문법부터 Module까지 IaC가 뭔지, HCL로 코드를 어떻게 작성하는지, …