목차
25 장

IaC — Terraform 입문

왜 IaC 인가, Terraform의 provider / resource / state 모양, S3 + DynamoDB backend로 팀 협업, 모듈로 환경 분리, 그리고 앞 챕터의 인프라를 한 줄씩 코드화하는 흐름까지 정리합니다.

22장 ~ 24장에서 만든 인프라는 여전히 콘솔과 CLI로 직접 만지고 있습니다. 한 번 더 같은 구성을 띄워보라면 — 기억으로? 메모로? — 흔들립니다. 그 작업을 Terraform으로 옮기는 것이 본 챕터입니다.

4부의 네 번째 챕터로, 다룰 내용은 다음과 같습니다.

  • 왜 IaC 인가 — 반복성 / 코드 리뷰 / drift 추적
  • Terraform의 구조 — provider, resource, data, variable, output, state
  • state가 진짜 핵심 — S3 + DynamoDB lock backend
  • 모듈 — 재사용 단위, 환경별 분기
  • 22장의 ECS 인프라를 한 줄씩 코드화

왜 IaC 인가 #

콘솔만 쓰던 운영에서 만나는 통증은 네 가지입니다.

  1. 재현 불가 — staging을 prod와 똑같이 띄워라? 사람의 기억으로는 미세한 차이가 늘 남습니다.
  2. 변경 추적 불가 — “지난주에 누가 SG를 바꿨지?” 라면 CloudTrail을 뒤져야 합니다. 코드라면 git log입니다.
  3. 리뷰 불가 — 운영 클러스터의 SG 인바운드 한 줄 수정에 동료의 눈이 들어오지 않습니다.
  4. 삭제 / 재생성 부담 — 하나만 잘못 만들어도 고치기가 두렵습니다.

IaC (Infrastructure as Code)는 인프라를 선언적 코드로 표현해 위 네 가지를 한 번에 잡습니다.

도구역할
Terraform멀티 클라우드, 가장 표준. 본 챕터의 주인공
PulumiTypeScript / Python / Go로 작성. 동적 로직에 강함
AWS CDKTypeScript / Python → CloudFormation 트랜스파일
CloudFormationAWS 네이티브 YAML/JSON. 동적 표현 약함
OpenTofuTerraform의 OSS 포크 (라이선스 분쟁 후)

이 책은 Terraform으로 통일합니다. 다만 2026 시점에는 Terraform의 라이선스 변화와 OpenTofu라는 선택지를 알고 가야 하므로, 도구를 본격적으로 쓰기 전에 한 번 짚습니다.

Terraform vs OpenTofu — 2026의 갈림 #

2023년 HashiCorp가 Terraform의 라이선스를 오픈소스(MPL 2.0)에서 BSL(Business Source License) 1.1로 바꿨습니다. 개인과 대부분의 회사는 그대로 쓸 수 있지만, Terraform으로 경쟁 제품을 만드는 것은 제한됩니다. 이에 커뮤니티가 마지막 MPL 버전을 포크해 만든 것이 OpenTofu로, Linux Foundation 산하에서 오픈소스로 관리됩니다. 2025년에는 IBM이 HashiCorp를 인수했습니다.

핵심은 둘이 사실상 호환 된다는 점입니다.

  • 같은 HCL 문법, 같은 프로바이더 생태계, 호환되는 state.
  • CLI만 terraformtofu로 바뀝니다 (tofu init / tofu plan / tofu apply).
  • 본 책의 모든 .tf 코드는 Terraform과 OpenTofu 양쪽에서 그대로 동작합니다.
고를 때선택
완전한 오픈소스 / 커뮤니티 거버넌스 / BSL 회피가 중요OpenTofu
HCP Terraform (클라우드 state · 정책 · 팀 기능)이나 상용 지원이 필요Terraform
학습 / 사이드 프로젝트둘 다 무방 (문법 동일)

2026 현재 OpenTofu는 충분히 성숙해 production 도입 사례(Boeing · Capital One 등)가 늘고 있습니다. 본 책은 설명과 명령을 terraform 기준으로 적지만, 회사가 OpenTofu를 쓴다면 명령어만 tofu로 바꿔 읽으시면 그대로 통합니다.

1) Terraform의 다섯 구성 요소 #

main.tf — 가장 작은 구조
# 1) Provider — AWS 와 어떻게 통신
provider "aws" {
  region = "ap-northeast-2"
}

# 2) Resource — 실제 만들 인프라
resource "aws_ecr_repository" "blog_api" {
  name                 = "blog-api"
  image_tag_mutability = "MUTABLE"

  image_scanning_configuration {
    scan_on_push = true
  }
}

# 3) Data — 이미 있는 리소스 조회
data "aws_caller_identity" "current" {}

# 4) Variable — 외부 입력
variable "environment" {
  type    = string
  default = "dev"
}

# 5) Output — 만든 결과를 노출
output "ecr_url" {
  value = aws_ecr_repository.blog_api.repository_url
}

다섯 구성 요소가 한 파일에 모이면 한 인프라 단위가 됩니다.

워크플로우 4단계 #

Terraform의 4단계
terraform init      # provider 다운로드, backend 초기화
terraform plan      # 무엇이 만들어지고/바뀌고/삭제되는지 미리 보기
terraform apply     # 적용
terraform destroy   # 삭제

plan의 출력이 Terraform의 가장 큰 가치입니다. 사고를 코드 머지 전에 막습니다.

plan 출력 예시
Terraform will perform the following actions:

  # aws_security_group.fargate will be created
  + resource "aws_security_group" "fargate" {
      + arn                    = (known after apply)
      + name                   = "sg-fargate"
      + ingress = [
          + {
              + from_port = 8000
              + to_port   = 8000
              + protocol  = "tcp"
              + ...
            },
        ]
    }

Plan: 1 to add, 0 to change, 0 to destroy.

+ 추가 / ~ 변경 / - 삭제 / -/+ 재생성 (ID가 바뀌면 끔찍하니 항상 의식)입니다.

2) State — 진짜 핵심 #

Terraform은 state (.tfstate 파일)에 “지금까지 만든 인프라의 상태"를 저장합니다. 이 파일이 있어야 다음 plan이 차이를 계산 할 수 있습니다.

state의 역할
실제 AWS 의 인프라     ←──────  Terraform code
                           state (마지막 apply 의 결과)

Terraform은 code ↔ state ↔ AWS의 3자 정합성을 본 다음 변경 계획을 짭니다.

state가 깨지면 어떻게 되는가 #

상황결과
state 분실Terraform은 “아무것도 만든 적 없음"으로 인식 → 이미 있는 리소스를 또 만들려 함
두 사람이 동시에 applystate가 깨지거나 한쪽이 다른 쪽 변경을 덮어씀
state 파일이 git에 평문비밀번호 / 키 노출 (state 안에 secret이 있는 자원 다수)

따라서 로컬 .tfstate는 학습용만입니다. 운영은 원격 backend가 필수입니다.

S3 + DynamoDB Backend #

가장 흔한 운영 패턴입니다.

backend.tf
terraform {
  required_version = ">= 1.7"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }

  backend "s3" {
    bucket         = "myorg-terraform-state"
    key            = "blog-api/prod/terraform.tfstate"
    region         = "ap-northeast-2"
    dynamodb_table = "terraform-state-lock"
    encrypt        = true
  }
}

구성을 정리하면 다음과 같습니다.

역할
S3 bucketstate 파일 저장 (버전 관리 + 암호화 활성)
DynamoDB table동시 apply 차단 — 락 테이블
bucket key의 prefix<프로젝트>/<환경>/terraform.tfstate 패턴으로 환경 분리
encrypt = trueKMS로 자동 암호화

Backend를 셋업하는 한 번의 부트스트랩 #

S3와 DynamoDB 자체는 누군가 먼저 만들어야 합니다. 닭과 달걀 문제입니다. 두 가지 흐름이 있습니다.

  1. 콘솔 / CLI로 한 번 수동 생성 (본 챕터의 가정)
  2. 별도 “bootstrap” 폴더에서 local backend로 만들고, 그 다음 backend를 S3로 마이그레이션
bootstrap
aws s3api create-bucket \
  --bucket myorg-terraform-state \
  --region ap-northeast-2 \
  --create-bucket-configuration LocationConstraint=ap-northeast-2

aws s3api put-bucket-versioning \
  --bucket myorg-terraform-state \
  --versioning-configuration Status=Enabled

aws dynamodb create-table \
  --table-name terraform-state-lock \
  --attribute-definitions AttributeName=LockID,AttributeType=S \
  --key-schema AttributeName=LockID,KeyType=HASH \
  --billing-mode PAY_PER_REQUEST \
  --region ap-northeast-2

이 두 자원은 절대 Terraform으로 destroy 하지 않습니다. state가 그 안에 살고 있습니다.

3) 디렉터리 구조 — 환경별 분리 #

실전 모양
infra/
├─ modules/
│   ├─ network/        ← VPC, Subnets, SGs
│   ├─ ecs-service/    ← ALB + Service + Auto Scaling
│   └─ rds/            ← DB
├─ envs/
│   ├─ dev/
│   │   ├─ main.tf
│   │   ├─ backend.tf
│   │   ├─ variables.tf
│   │   └─ terraform.tfvars
│   └─ prod/
│       ├─ main.tf
│       ├─ backend.tf
│       ├─ variables.tf
│       └─ terraform.tfvars
└─ bootstrap/          ← S3 / DynamoDB (한 번만)

환경별 backend key를 다르게 두어 state를 분리합니다.

envs/dev/backend.tf
terraform { backend "s3" {
  bucket         = "myorg-terraform-state"
  key            = "blog-api/dev/terraform.tfstate"
  region         = "ap-northeast-2"
  dynamodb_table = "terraform-state-lock"
}}

이러면 dev와 prod가 완전히 분리됩니다. dev의 apply가 prod state를 건드릴 일이 없습니다.

4) 모듈 — 재사용 단위 #

같은 인프라 패턴을 dev / prod에서 반복하지 않도록 모듈로 묶습니다.

modules/ecs-service/variables.tf
variable "name"          { type = string }
variable "cluster_arn"   { type = string }
variable "image"         { type = string }
variable "vpc_id"        { type = string }
variable "subnet_ids"    { type = list(string) }
variable "alb_sg_id"     { type = string }
variable "desired_count" { type = number, default = 2 }
variable "cpu"           { type = string, default = "512" }
variable "memory"        { type = string, default = "1024" }
variable "container_port" { type = number, default = 8000 }
modules/ecs-service/main.tf (발췌)
resource "aws_security_group" "fargate" {
  name        = "sg-${var.name}-fargate"
  description = "Fargate task SG"
  vpc_id      = var.vpc_id

  ingress {
    from_port       = var.container_port
    to_port         = var.container_port
    protocol        = "tcp"
    security_groups = [var.alb_sg_id]
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }
}

resource "aws_lb_target_group" "this" {
  name        = "tg-${var.name}"
  port        = var.container_port
  protocol    = "HTTP"
  target_type = "ip"
  vpc_id      = var.vpc_id

  health_check {
    path                = "/health"
    healthy_threshold   = 2
    interval            = 15
  }
}

resource "aws_ecs_task_definition" "this" {
  family                   = var.name
  network_mode             = "awsvpc"
  requires_compatibilities = ["FARGATE"]
  cpu                      = var.cpu
  memory                   = var.memory
  execution_role_arn       = aws_iam_role.execution.arn
  task_role_arn            = aws_iam_role.task.arn

  container_definitions = jsonencode([{
    name  = "api"
    image = var.image
    portMappings = [{ containerPort = var.container_port, protocol = "tcp" }]
    logConfiguration = {
      logDriver = "awslogs"
      options = {
        "awslogs-group"         = aws_cloudwatch_log_group.this.name
        "awslogs-region"        = data.aws_region.current.name
        "awslogs-stream-prefix" = "api"
      }
    }
  }])
}

resource "aws_ecs_service" "this" {
  name            = var.name
  cluster         = var.cluster_arn
  task_definition = aws_ecs_task_definition.this.arn
  desired_count   = var.desired_count
  launch_type     = "FARGATE"

  network_configuration {
    subnets          = var.subnet_ids
    security_groups  = [aws_security_group.fargate.id]
    assign_public_ip = true
  }

  load_balancer {
    target_group_arn = aws_lb_target_group.this.arn
    container_name   = "api"
    container_port   = var.container_port
  }

  deployment_circuit_breaker {
    enable   = true
    rollback = true
  }
}

output "target_group_arn" { value = aws_lb_target_group.this.arn }
output "service_name"     { value = aws_ecs_service.this.name }

22장의 콘솔 작업이 여기 한 파일로 모였습니다.

모듈 사용 #

envs/prod/main.tf
module "network" {
  source       = "../../modules/network"
  name         = "blog-prod"
  cidr         = "10.0.0.0/16"
  azs          = ["ap-northeast-2a", "ap-northeast-2c"]
}

module "rds" {
  source            = "../../modules/rds"
  name              = "blog-prod"
  vpc_id            = module.network.vpc_id
  db_subnet_ids     = module.network.db_subnet_ids
  fargate_sg_id     = module.api.fargate_sg_id
  multi_az          = true
  instance_class    = "db.t4g.small"
  deletion_protection = true
}

module "api" {
  source         = "../../modules/ecs-service"
  name           = "blog-prod"
  cluster_arn    = aws_ecs_cluster.blog.arn
  image          = var.image  # CI 가 주입
  vpc_id         = module.network.vpc_id
  subnet_ids     = module.network.private_subnet_ids
  alb_sg_id      = module.network.alb_sg_id
  desired_count  = 4
  cpu            = "1024"
  memory         = "2048"
}

dev 환경은 desired_count = 1, multi_az = false, instance_class = "db.t4g.micro"로 가벼운 모양입니다. 같은 모듈 + 다른 변수가 핵심입니다.

5) Terraform ↔ CI/CD 통합 #

24장 CI/CD의 GitHub Actions와 어떻게 묶을 것인가입니다.

두 가지 흐름 #

역할
A. 인프라 / 앱 분리인프라 변경은 별도 PR + apply, 앱 배포는 image만 갱신
B. 한 워크플로우로 묶음image 빌드 → terraform apply가 새 image를 service에

처음엔 A를 권장 합니다. 인프라 변경은 무겁고, 앱 배포는 자주 있습니다. 두 흐름의 위험도가 다릅니다.

Plan을 PR 코멘트로 #

.github/workflows/terraform-plan.yml
name: Terraform Plan
on:
  pull_request:
    paths: ['infra/**']

permissions:
  id-token: write
  contents: read
  pull-requests: write

jobs:
  plan:
    runs-on: ubuntu-latest
    defaults:
      run: { working-directory: infra/envs/prod }
    steps:
      - uses: actions/checkout@v4
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/terraform-plan
          aws-region: ap-northeast-2
      - uses: hashicorp/setup-terraform@v3
        with: { terraform_version: 1.9.0 }
      - run: terraform init
      - run: terraform plan -no-color -out=tfplan
      - name: Comment Plan
        uses: actions/github-script@v7
        with:
          script: |
            const fs = require('fs');
            const out = require('child_process')
              .execSync('terraform show -no-color tfplan', { cwd: 'infra/envs/prod' });
            github.rest.issues.createComment({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo,
              body: '```\n' + out + '\n```'
            });

PR 검토 단계에서 무엇이 바뀌는지 한곳에서 확인 하는 것이, 운영 사고를 코드 머지 전에 막는 가장 효과적인 방법입니다.

terraform-plan 역할은 read-only 권한으로 충분합니다. apply 권한은 별도 역할로 둡니다.

6) Drift 추적 #

콘솔에서 손으로 바꾼 자원은 state와 어긋납니다 (drift). terraform plan차이를 보여주면서 “원복할까?“를 묻습니다.

정기 drift 점검
terraform plan -detailed-exitcode
# exit 0 = 차이 없음
# exit 2 = 차이 있음 (실패가 아님)

CI에서 매일 한 번 돌리고, exit 2 면 슬랙으로 알립니다.

함정 — Terraform 운영의 함정 #

1) state 잠김 해제 안 됨 #

apply가 ctrl-c로 중단되면 DynamoDB 락이 그대로 남습니다. 다음 apply가 “Resource locked"로 실패합니다.

강제 해제 (조심)
terraform force-unlock <LOCK_ID>

LOCK_ID는 에러 메시지에 표시됩니다. 다른 사람이 진짜 작업 중이 아닌지 반드시 확인 후에 합니다.

2) state 수동 편집 #

.tfstate를 vim으로 열어 편집하면 거의 항상 후회합니다. 대신 state 명령을 씁니다.

state 명령
terraform state list                       # 자원 목록
terraform state show aws_ecr_repository.x  # 한 자원 상세
terraform state rm aws_ecr_repository.x    # state 에서 제거 (실제 자원은 안 지움)
terraform state mv module.a.x module.b.x   # 자원 이동
terraform import aws_ecr_repository.x my-repo  # 기존 자원을 state 에 등록

3) 비밀번호가 state에 평문 #

aws_db_instancepassword, aws_secretsmanager_secret_versionsecret_string은 state에 평문으로 들어갑니다. state bucket 암호화 + 접근 제한이 필수입니다.

state bucket 정책 (예시)
data "aws_iam_policy_document" "state_bucket" {
  statement {
    effect    = "Deny"
    actions   = ["s3:*"]
    resources = ["arn:aws:s3:::myorg-terraform-state/*"]
    condition {
      test     = "Bool"
      variable = "aws:SecureTransport"
      values   = ["false"]
    }
  }
}

4) -/+ destroy/create #

plan에서 -/+가 보이면 자원 ID가 바뀝니다. RDS 라면 데이터 손실입니다. 자세히 봐야 할 부분입니다.

  # aws_db_instance.blog must be replaced
-/+ resource "aws_db_instance" "blog" {
      ~ engine_version = "16.3" -> "17.0"  # forces replacement
    }

이런 변경은 별도 마이그레이션 절차로 합니다. RDS는 in-place 업그레이드 옵션이 따로 있습니다.

5) Provider 버전 고정 안 함 #

required_providersversion을 미지정하면 다음 init에서 깨질 수 있습니다. 항상 ~> 5.0 같은 패턴을 씁니다.

6) terraform destroy 사고 #

prod 환경에서 실수로 destroy 하는 경우입니다. 보호 장치를 둡니다.

중요한 자원 보호
resource "aws_db_instance" "blog" {
  # ...
  lifecycle {
    prevent_destroy = true
  }
}

prevent_destroy = true가 있는 자원은 destroy / replace가 plan 단계에서 막힙니다.

연습문제 #

  1. 콘솔만 쓰던 운영의 네 가지 통증(§“왜 IaC 인가”)을 보지 않고 적고, 각 통증을 Terraform의 어떤 기능(plan / git 이력 / PR 리뷰 / state)이 해결하는지 한 줄씩 연결해 보세요.
  2. 로컬 .tfstate가 운영에서 위험한 이유 세 가지를 §“state가 깨지면 어떻게 되는가” 표에서 정리하고, S3 + DynamoDB backend가 각각 어느 위험을 막는지 짝지어 보세요.
  3. terraform plan 출력에서 -/+가 보일 때 왜 멈춰서 자세히 봐야 하는지, 23장 RDS 연동의 데이터와 연결해 한 단락으로 설명해 보세요. prevent_destroydeletion_protection이 각각 무엇을 막는지도 구분해 적어 보세요.

한 줄 요약: IaC는 인프라를 선언적 코드로 만들어 재현·추적·리뷰·안전한 삭제를 한 번에 잡는다. Terraform은 provider / resource / data / variable / output 다섯 요소로 짜이고, init → plan → apply → destroy로 돈다. 핵심은 state이며 운영은 S3 + DynamoDB backend가 필수다. 같은 모듈에 다른 변수를 주어 dev/prod를 분리하고, plan을 PR 코멘트로 띄워 머지 전에 사고를 막으며, -/+ 재생성과 terraform destroy는 lifecycle로 보호한다.

다음 챕터 #

인프라가 코드가 됐고 배포가 자동입니다. 이제 돌고 있는지, 잘 돌고 있는지를 본격적으로 봐야 할 차례입니다. 다음 26장 모니터링 — CloudWatch 알람과 X-Ray에서는 ECS / RDS / ALB의 핵심 메트릭, Logs Insights 운영 쿼리, 알람을 슬랙으로 보내는 흐름, 그리고 X-Ray 분산 추적으로 “왜 이 요청만 5초 걸렸지?“를 한 줄로 잡는 흐름까지 다룹니다.

X