AWS 실전 #4 IaC: Terraform 입문

8 분 소요

#1 ~ #3에서 만든 인프라는 콘솔 / CLI로 직접 만지고 있습니다. 한 번 더 같은 구성을 띄워보라면, 기억으로? 메모로? 흔들립니다. 그 작업을 Terraform으로 옮기는 게 이번 글입니다.

다룰 내용:

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

왜 IaC #

콘솔만 쓰던 방식에서 만나는 통증 4가지:

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

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

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

이 시리즈는 Terraform으로 통일합니다. 회사 정책상 OpenTofu를 쓰더라도 문법은 동일합니다.

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 }

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

모듈 사용 #

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 통합 #

#3의 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 명령
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 단계에서 막힘.

정리 #

이번 글에서 잡은 것:

  • 왜 IaC. 재현 / 추적 / 리뷰 / 안전한 destroy
  • 다섯 블록. provider, resource, data, variable, output
  • 워크플로우. init → plan → apply → destroy. plan이 가장 큰 가치
  • state. Terraform의 핵심. 로컬 state는 학습용
  • S3 + DynamoDB Backend. 운영 표준, encrypt, versioning
  • Bootstrap. backend 자체는 콘솔 / 별도 모양
  • 디렉터리 구조. modules/ + envs/{dev,prod}/, 환경별 backend key 분리
  • 모듈. 같은 패턴을 변수로 차별화. dev = 가볍게, prod = 풀 옵션
  • CI/CD 통합. Plan을 PR 코멘트로, plan/apply 권한 분리
  • Drift 추적. plan -detailed-exitcode로 정기
  • 함정. 락 해제, state 편집, 평문 비밀번호, -/+, provider 버전, destroy 보호

다음: 모니터링 #

인프라가 코드가 됐고 배포가 자동입니다. 이제 돌고 있는지 / 잘 돌고 있는지를 본격적으로 봐야 할 차례.

#5 모니터링: CloudWatch 알람과 X-Ray에서는 ECS / RDS / ALB의 핵심 메트릭, Logs Insights 운영 쿼리, 알람을 슬랙으로 보내는 흐름, 그리고 X-Ray 분산 추적으로 “왜 이 요청만 5초 걸렸지?” 를 한 줄로 잡는 흐름까지.

X