Table of contents
- 도구를 아는 것과 잘 쓰는 것
- 디렉토리 구조 컨벤션
- 태깅 전략
- 흔한 실수 — state 유실
- 흔한 실수 — 순환 참조
- 흔한 실수 — destroy 사고
- 대규모 프로젝트 분할
- 마이그레이션 전략 — 기존 인프라를 Terraform으로
- 운영에서 꼭 기억할 것
도구를 아는 것과 잘 쓰는 것
시리즈를 따라오면서 Terraform의 기능은 대부분 훑어봤다. 문법, 리소스, 모듈, state, 워크스페이스, CI/CD, 테스트까지. 그런데 이것들을 안다고 해서 바로 큰 인프라를 잘 운영할 수 있는 건 아니다.
실전에서 중요한 건 따로 있다. 언제 어떤 구조를 선택할지, 어떤 실수를 피할지, 규모가 커졌을 때 어떻게 쪼갤지 같은 것들이다. 이번 편은 그간의 경험에서 반복적으로 마주한 패턴과 함정들을 한 번에 정리한다.
디렉토리 구조 컨벤션
정답은 없지만, 자주 쓰이는 몇 가지 패턴이 있다. 프로젝트 규모와 팀 구조에 맞춰 고른다.
패턴 1: 평면 구조 (소규모)
infra/
├── main.tf
├── variables.tf
├── outputs.tf
└── terraform.tfvars
리소스가 20개 이하, 환경 1개일 때. 단순 프로토타입이나 사이드 프로젝트에 적합하다. 관리가 편하지만 확장성은 낮다.
패턴 2: 환경별 분리 (중소규모)
infra/
├── modules/
│ ├── vpc/
│ ├── eks/
│ └── rds/
└── envs/
├── dev/
├── staging/
└── prod/
모듈화 + 환경 분리. 대부분의 실무 스타트업/중소 팀에서 이 정도면 충분하다.
패턴 3: 컴포넌트 분리 (중대규모)
infra/
├── modules/
│ ├── vpc/
│ ├── eks/
│ └── rds/
└── envs/
├── dev/
│ ├── network/ # VPC, 서브넷 등
│ ├── cluster/ # EKS
│ ├── database/ # RDS
│ └── application/ # 앱 배포
└── prod/
├── network/
├── cluster/
├── database/
└── application/
환경 안에서도 컴포넌트별로 분리한다. state가 컴포넌트별로 나뉘어 관리 단위가 작아진다. 장점은 분명하다. 네트워크를 건드리는 변경이 데이터베이스 state에 영향을 주지 않는다. 단점은 컴포넌트 간 참조(출력값 공유)가 번거로워진다. 이 경우 원격 state 데이터 소스나 Terragrunt의 dependency 블록으로 해결한다.
# envs/prod/application/main.tf
data "terraform_remote_state" "network" {
backend = "s3"
config = {
bucket = "my-company-tfstate"
key = "envs/prod/network/terraform.tfstate"
region = "ap-northeast-2"
}
}
resource "aws_instance" "app" {
subnet_id = data.terraform_remote_state.network.outputs.private_subnet_ids[0]
}
패턴 4: 라이브/모듈 분리 (대규모)
terraform-modules/ # 별도 레포
├── vpc/
├── eks/
└── rds/
infra-live/ # 별도 레포
└── envs/
├── dev/
└── prod/
모듈을 완전히 별도 레포로 분리한다. 모듈에 SemVer 태깅을 하고, infra-live에서 버전 핀으로 참조한다. 큰 조직에서 플랫폼 팀과 서비스 팀이 분리될 때 자연스러운 구조다.
flowchart TB
Size["팀/인프라 규모"]
Size -->|"작음"| Flat["평면 구조"]
Size -->|"중소"| Envs["환경 분리"]
Size -->|"중대"| Comp["컴포넌트 분리"]
Size -->|"대규모"| Repo["라이브/모듈 레포 분리"]
Flat -.-> Flat2["관리 단순,\n확장 제약"]
Envs -.-> Envs2["환경별 독립,\n컴포넌트 혼재"]
Comp -.-> Comp2["state 세분화,\n참조 복잡"]
Repo -.-> Repo2["팀 경계 명확,\n운영 비용 증가"]
어느 패턴에서 시작하든 나중에 쪼개기 쉽게 처음부터 모듈화하는 것이 가장 중요하다.
태깅 전략
태그는 사소해 보여도 운영에서 결정적이다. 비용 분석, 리소스 검색, 정책 적용, 소유자 추적 모두 태그에 기반한다.
공통 태그는 모든 리소스에 일관되게 붙인다. 흔한 최소 집합은 이렇다.
| 태그 | 의미 | 예시 |
|---|---|---|
Environment | 환경 | dev, staging, prod |
Service | 서비스 이름 | order-api, auth, platform |
Owner | 담당자 / 팀 | platform-team, alice@company.com |
CostCenter | 비용 센터 | engineering, marketing |
ManagedBy | 관리 주체 | terraform, manual |
Repo | 정의된 레포 | github.com/org/infra-repo |
프로바이더 수준에서 기본 태그를 걸어두면 모든 리소스에 자동 적용된다.
provider "aws" {
region = "ap-northeast-2"
default_tags {
tags = {
Environment = var.environment
Service = var.service_name
Owner = "platform-team"
ManagedBy = "terraform"
Repo = "github.com/my-org/infra-repo"
}
}
}
default_tags는 AWS 프로바이더 3.38부터 지원한다. 이걸 쓰면 리소스마다 태그를 일일이 쓸 필요가 없다. 리소스별로 추가 태그가 필요하면 tags 블록으로 더하면 된다.
태깅 정책은 자동 검증한다
앞 편에서 본 OPA로 “필수 태그 누락 시 fail”을 걸면 된다. 수동으로 매번 체크할 수 있는 일이 아니다.
흔한 실수 — state 유실
state가 사라지는 건 Terraform에서 가장 끔찍한 사고 중 하나다. state가 없으면 Terraform은 리소스를 다시 만들려고 한다. 이미 있는 RDS를 또 만들려다 이름 충돌로 실패하거나, 기존 리소스 옆에 쓸모없는 복제본이 생긴다.
원인
- 로컬 state를 노트북째로 분실
- 원격 백엔드 버킷을 실수로 삭제
- 여러 명이 동시에 apply해서 state가 꼬임
terraform state rm을 잘못 써서 필요한 리소스가 state에서 빠짐
예방
- 로컬 state 금지: 팀으로 작업한다면 무조건 원격 백엔드
- 버킷 버저닝 활성화: S3/GCS/Azure Blob 모두 버저닝 필수
- 버킷 삭제 방지:
prevent_destroy = true로 state 버킷 자체를 보호 - 잠금 활성화: DynamoDB, GCS 잠금, Azure lease
state 버킷 자체를 Terraform으로 관리하고 보호하는 코드다.
resource "aws_s3_bucket" "tfstate" {
bucket = "my-company-tfstate"
lifecycle {
prevent_destroy = true # 실수로 destroy해도 거부
}
}
resource "aws_s3_bucket_versioning" "tfstate" {
bucket = aws_s3_bucket.tfstate.id
versioning_configuration {
status = "Enabled"
}
}
resource "aws_s3_bucket_policy" "tfstate" {
bucket = aws_s3_bucket.tfstate.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Sid = "DenyDelete"
Effect = "Deny"
Principal = "*"
Action = ["s3:DeleteBucket", "s3:DeleteBucketPolicy"]
Resource = [aws_s3_bucket.tfstate.arn]
}]
})
}
복구
잃어버리면 어떻게 할까. 방법은 하나뿐이다 — import로 처음부터 다시 쌓는다.
import {
to = aws_vpc.main
id = "vpc-0abc123def456"
}
리소스 하나하나 import 블록을 넣고, terraform plan으로 차이를 확인해 코드를 맞춰간다. 수십 개면 고되고, 수백 개면 악몽이다. 그래서 예방이 전부다.
흔한 실수 — 순환 참조
모듈 A가 모듈 B의 출력을 쓰고, 모듈 B가 다시 모듈 A의 출력을 쓰려고 하면 순환 참조가 생긴다. Terraform이 DAG를 계산할 수 없어 실패한다.
Error: Cycle detected in configuration
전형적인 사례
# 잘못된 예
module "sg_web" {
source = "./modules/sg"
ingress_from_sg = module.sg_app.security_group_id # app 참조
}
module "sg_app" {
source = "./modules/sg"
ingress_from_sg = module.sg_web.security_group_id # web 참조 — 순환!
}
해법
보안 그룹처럼 서로 참조해야 하는 경우, 규칙을 별도 리소스로 빼낸다.
module "sg_web" {
source = "./modules/sg"
}
module "sg_app" {
source = "./modules/sg"
}
# 순환 없이 서로 참조
resource "aws_security_group_rule" "web_from_app" {
type = "ingress"
from_port = 80
to_port = 80
protocol = "tcp"
security_group_id = module.sg_web.security_group_id
source_security_group_id = module.sg_app.security_group_id
}
resource "aws_security_group_rule" "app_from_web" {
type = "ingress"
from_port = 8080
to_port = 8080
protocol = "tcp"
security_group_id = module.sg_app.security_group_id
source_security_group_id = module.sg_web.security_group_id
}
규칙을 리소스로 분리하면 SG 자체는 서로 몰라도 된다. Terraform이 SG부터 만든 뒤 규칙을 연결하는 순서로 처리한다.
흔한 실수 — destroy 사고
가장 가슴 떨리는 사고다. prod에서 terraform destroy를 쳐버리거나, 코드 리팩토링 중 prod 리소스가 삭제되는 경우다.
방어막
-
prod 환경에서는
terraform destroy를 쓸 수 없게 막는다CI에서 prod 디렉토리에는 destroy 커맨드 자체를 실행하지 않는다. 수동 destroy가 필요하면 여러 단계의 승인을 거친다.
-
중요 리소스에
prevent_destroyresource "aws_db_instance" "prod_db" { # ... lifecycle { prevent_destroy = true } }이 리소스가 destroy 대상이 되면
terraform plan단계에서 바로 실패한다. -
리소스 이름 변경 시 반드시
state mvresource "aws_instance" "old_name"을"new_name"으로 바꾸면, Terraform은 기존 리소스를 삭제하고 새 이름으로 다시 만들려 한다. 프로덕션 DB라면 치명적이다.terraform state mv aws_instance.old_name aws_instance.new_namestate에서 이름만 바꾸면 실제 리소스는 건드리지 않고 새 이름으로 인식된다.
-
plan 결과를 반드시 본다
plan에서
-destroy가 나오면 무조건 의심한다. 의도적인 destroy가 아니라면 어딘가 잘못된 것이다.
flowchart LR
Plan["terraform plan"] --> Check{"destroy가\n있는가?"}
Check -->|"있음"| Review["한 번 더 확인"]
Check -->|"없음"| OK["apply"]
Review --> Intended{"의도한\n destroy?"}
Intended -->|"아니요"| Stop["멈추고 원인 파악"]
Intended -->|"예"| Confirm["승인 후 apply"]
대규모 프로젝트 분할
인프라 레포가 커지면 몇 가지 문제가 터진다.
terraform plan이 5분, 10분씩 걸린다- state 파일이 수십 MB로 커져서 작업이 느려진다
- 여러 팀이 동시에 건드리며 잠금 경합이 빈번해진다
- 변경 영향 범위가 파악하기 어려워진다
해법은 상태 단위 분할이다. 하나의 거대한 state를 여러 개의 작은 state로 쪼갠다.
분할 기준
- 변경 빈도 — 자주 바뀌는 것과 거의 안 바뀌는 것 분리 (VPC는 거의 안 바뀜, 앱 배포는 자주 바뀜)
- 소유 팀 — 팀별로 관리 주체 분리
- 라이프사이클 — 함께 생성/삭제되는 단위로 묶음
- 블래스트 레이디어스 — 한 실수의 영향이 미치는 범위 최소화
예시:
envs/prod/
├── 01-network/ # VPC, 서브넷, 라우팅 (거의 안 바뀜)
├── 02-security/ # 공통 보안 그룹, KMS 키
├── 03-cluster/ # EKS 클러스터 (분기별 업그레이드)
├── 04-database/ # RDS, ElastiCache (드물게 변경)
├── 05-bootstrap/ # 클러스터 부트스트랩 (월 단위 변경)
└── 06-applications/ # 앱 배포 (일 단위 변경)
의존성 순서로 번호를 붙이면 생성/삭제 순서가 명확해진다.
분할 마이그레이션
이미 거대해진 state를 쪼개야 하는 상황이라면, terraform state mv의 원격 state 버전인 terraform state rm + state push나, Terragrunt run-all을 활용해야 한다. 작업이 크고 리스크도 크니 단계적으로 진행한다.
- 새 state 디렉토리의 백엔드 먼저 생성
- 옮길 리소스를 HCL에서 정의
terraform state mv -state-out=...로 state 간 이동, 또는 기존 state에서rm후 새 state에import- 양쪽에서
plan으로 차이 없음 확인
반드시 dev/staging에서 먼저 연습하고, 모든 state 백업을 확보한 뒤에 진행한다.
마이그레이션 전략 — 기존 인프라를 Terraform으로
새 프로젝트라면 처음부터 Terraform으로 시작하면 된다. 하지만 이미 콘솔이나 CloudFormation으로 돌아가는 인프라를 Terraform으로 옮겨야 한다면 어떨까.
접근 1: 점진적 import
가장 안전한 방법이다. 한 번에 모두 import하지 않고, 작은 단위부터 시작한다.
1단계: 네트워크 (VPC, 서브넷) — 가장 아래층, 거의 안 바뀜
2단계: 데이터 (RDS, S3) — 민감하지만 변경 드묾
3단계: 컴퓨트 (EC2, ECS, EKS)
4단계: 응용 레이어 (앱 배포, 헬름 차트)
각 단계에서 import 블록으로 HCL을 작성하고, plan으로 차이가 0이 되도록 코드를 맞춰간다.
import {
to = aws_vpc.main
id = "vpc-0abc123def456"
}
resource "aws_vpc" "main" {
cidr_block = "10.0.0.0/16"
# ... plan으로 차이 확인하면서 채워나감
}
Terraform 1.5부터는 import 블록을 쓴 뒤 -generate-config-out 플래그로 HCL 초안을 자동 생성할 수 있다.
terraform plan -generate-config-out=generated.tf
생성된 코드를 다듬어서 모듈화하면 된다.
접근 2: Terraformer 같은 도구
Google에서 만든 Terraformer는 기존 클라우드 리소스를 스캔해서 HCL과 state를 동시에 생성해준다. 빠르지만, 생성된 코드가 정돈된 상태가 아니라 후처리가 많이 필요하다.
terraformer import aws --resources=vpc,subnet --regions=ap-northeast-2
대량의 리소스를 한 번에 가져와야 할 때 유용하다.
접근 3: 병행 운영
기존 인프라는 그대로 두고, 새로운 리소스만 Terraform으로 만든다. 기존은 서서히 EOL로 밀어내면서 신규 구축은 Terraform으로 일관되게 한다. 시간은 오래 걸리지만 리스크가 가장 낮다.
어느 접근을 택하든 철저한 백업과 dry-run이 핵심이다. state를 여러 번 덤프해 두고, plan을 수십 번 돌려본 뒤에야 실제 적용한다.
운영에서 꼭 기억할 것
마지막으로, 지금까지 나온 내용을 관통하는 원칙 몇 가지로 정리한다.
1) 매번 plan을 본다
apply 전에 plan 결과를 읽는 건 협상 불가능하다. “아마 괜찮을 거야”는 사고의 씨앗이다.
2) 작게 자주 apply한다
한 번에 50개 리소스를 건드리는 PR보다, 5개씩 10번 apply하는 게 안전하다. 실패 시 원인 파악도 쉽고 롤백도 쉽다.
3) state는 가장 귀한 자산
state를 잃으면 복구가 어렵다. 백엔드 버저닝, 잠금, 접근 제어는 타협 불가.
4) 수동 변경은 금지
콘솔에서 직접 변경하는 건 drift를 만들고, 결국 Terraform과 현실의 괴리를 키운다. 긴급 상황에서 어쩔 수 없이 손댔다면 즉시 코드에 반영한다.
5) 모듈은 공개 API다
모듈의 입력/출력은 공개 API라고 생각하고 설계한다. 함부로 변경하면 사용자가 모두 영향받는다. SemVer로 버전을 관리한다.
6) 정책을 코드로
“PR 리뷰할 때 주의해서 보자”는 안 통한다. 태깅, 보안 그룹, 암호화 같은 정책은 OPA/conftest로 자동 검증한다.
7) 도구는 수단이다
Terraform은 만능이 아니다. 클러스터 내부 자주 바뀌는 리소스는 ArgoCD에, 비밀번호는 Secrets Manager에, 모니터링은 관찰 도구에 맡긴다. Terraform이 할 일과 안 할 일을 명확히 한다.
여기까지가 Terraform 시리즈의 마지막이다. main.tf를 처음 만드는 것에서 시작해, 변수와 state, 모듈, 환경 분리, CI/CD, 테스트, 그리고 실전 운영까지 한 바퀴를 돌았다.
Terraform은 인프라를 코드로 다루는 강력한 도구지만, 도구의 기능을 외우는 것만으로는 부족하다. “이 변경이 어떤 영향을 주는가”를 늘 생각하고, 작은 단위로 안전하게 변경하는 습관이 운영의 실력을 만든다. 코드를 아끼듯 인프라 코드도 아끼고, 무엇보다 state를 아낀다. 그게 전부다.
이 시리즈로 시작한 누군가가 자기 팀의 인프라를 한 뼘씩 견고하게 다져나가길 바란다. 여정에서 마주칠 모든 함정 앞에서, 잘 만들어진 코드 한 줄이 가장 든든한 안전장치가 될 것이다.


Loading comments...