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 인가 #
콘솔만 쓰던 운영에서 만나는 통증은 네 가지입니다.
- 재현 불가 — staging을 prod와 똑같이 띄워라? 사람의 기억으로는 미세한 차이가 늘 남습니다.
- 변경 추적 불가 — “지난주에 누가 SG를 바꿨지?” 라면 CloudTrail을 뒤져야 합니다. 코드라면 git log입니다.
- 리뷰 불가 — 운영 클러스터의 SG 인바운드 한 줄 수정에 동료의 눈이 들어오지 않습니다.
- 삭제 / 재생성 부담 — 하나만 잘못 만들어도 고치기가 두렵습니다.
IaC (Infrastructure as Code)는 인프라를 선언적 코드로 표현해 위 네 가지를 한 번에 잡습니다.
| 도구 | 역할 |
|---|---|
| Terraform | 멀티 클라우드, 가장 표준. 본 챕터의 주인공 |
| Pulumi | TypeScript / Python / Go로 작성. 동적 로직에 강함 |
| AWS CDK | TypeScript / Python → CloudFormation 트랜스파일 |
| CloudFormation | AWS 네이티브 YAML/JSON. 동적 표현 약함 |
| OpenTofu | Terraform의 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만
terraform→tofu로 바뀝니다 (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의 다섯 구성 요소 #
# 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 init # provider 다운로드, backend 초기화
terraform plan # 무엇이 만들어지고/바뀌고/삭제되는지 미리 보기
terraform apply # 적용
terraform destroy # 삭제plan의 출력이 Terraform의 가장 큰 가치입니다. 사고를 코드 머지 전에 막습니다.
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이 차이를 계산 할 수 있습니다.
실제 AWS 의 인프라 ←────── Terraform code
│
▼
state (마지막 apply 의 결과)Terraform은 code ↔ state ↔ AWS의 3자 정합성을 본 다음 변경 계획을 짭니다.
state가 깨지면 어떻게 되는가 #
| 상황 | 결과 |
|---|---|
| state 분실 | Terraform은 “아무것도 만든 적 없음"으로 인식 → 이미 있는 리소스를 또 만들려 함 |
| 두 사람이 동시에 apply | state가 깨지거나 한쪽이 다른 쪽 변경을 덮어씀 |
| state 파일이 git에 평문 | 비밀번호 / 키 노출 (state 안에 secret이 있는 자원 다수) |
따라서 로컬 .tfstate는 학습용만입니다. 운영은 원격 backend가 필수입니다.
S3 + DynamoDB Backend #
가장 흔한 운영 패턴입니다.
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 bucket | state 파일 저장 (버전 관리 + 암호화 활성) |
| DynamoDB table | 동시 apply 차단 — 락 테이블 |
| bucket key의 prefix | <프로젝트>/<환경>/terraform.tfstate 패턴으로 환경 분리 |
| encrypt = true | KMS로 자동 암호화 |
Backend를 셋업하는 한 번의 부트스트랩 #
S3와 DynamoDB 자체는 누군가 먼저 만들어야 합니다. 닭과 달걀 문제입니다. 두 가지 흐름이 있습니다.
- 콘솔 / CLI로 한 번 수동 생성 (본 챕터의 가정)
- 별도 “bootstrap” 폴더에서 local backend로 만들고, 그 다음 backend를 S3로 마이그레이션
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를 분리합니다.
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에서 반복하지 않도록 모듈로 묶습니다.
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 }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장의 콘솔 작업이 여기 한 파일로 모였습니다.
모듈 사용 #
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 코멘트로 #
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이 차이를 보여주면서 “원복할까?“를 묻습니다.
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_instance의 password, aws_secretsmanager_secret_version의 secret_string은 state에 평문으로 들어갑니다. 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_providers에 version을 미지정하면 다음 init에서 깨질 수 있습니다. 항상 ~> 5.0 같은 패턴을 씁니다.
6) terraform destroy 사고
#
prod 환경에서 실수로 destroy 하는 경우입니다. 보호 장치를 둡니다.
resource "aws_db_instance" "blog" {
# ...
lifecycle {
prevent_destroy = true
}
}prevent_destroy = true가 있는 자원은 destroy / replace가 plan 단계에서 막힙니다.
연습문제 #
- 콘솔만 쓰던 운영의 네 가지 통증(§“왜 IaC 인가”)을 보지 않고 적고, 각 통증을 Terraform의 어떤 기능(plan / git 이력 / PR 리뷰 / state)이 해결하는지 한 줄씩 연결해 보세요.
- 로컬
.tfstate가 운영에서 위험한 이유 세 가지를 §“state가 깨지면 어떻게 되는가” 표에서 정리하고, S3 + DynamoDB backend가 각각 어느 위험을 막는지 짝지어 보세요. terraform plan출력에서-/+가 보일 때 왜 멈춰서 자세히 봐야 하는지, 23장 RDS 연동의 데이터와 연결해 한 단락으로 설명해 보세요.prevent_destroy와deletion_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초 걸렸지?“를 한 줄로 잡는 흐름까지 다룹니다.