본문 바로가기
Infra2026년 9월 10일21분 읽기

Terraform 상태 관리와 모듈 설계 — 실무에서 안 터지는 IaC 패턴

YS
김영삼
조회 8
Terraform 상태 관리와 모듈 설계 — 실무에서 안 터지는 IaC 패턴

Terraform에서 사고의 90%는 코드가 아니라 상태(state)에서 난다. 원격 백엔드+잠금으로 상태를 지키고, 환경은 워크스페이스가 아니라 디렉토리로 나누고, 모듈은 얇게 설계하라.

이 글은 상태 파일을 어디에 어떻게 둘지, 잠금은 왜 필수인지, 모듈은 얼마나 쪼갤지, plan/apply를 CI에서 어떻게 굴릴지, 시크릿과 드리프트는 어떻게 다룰지를 실무 관점에서 정리한다. 마지막에 OpenTofu로의 이주 판단 기준도 짚는다.

인프라를 코드로 관리한 지 몇 년 됐는데, 팀에 새로 들어온 사람에게 매번 똑같은 말을 하게 된다. "코드는 나중에 고치면 되는데, state 깨지면 하루가 날아간다." 실제로 나도 예전에 로컬 terraform.tfstate를 그대로 두고 두 명이 동시에 apply 했다가 RDS 인스턴스 하나를 유령으로 만든 적이 있다. 그날 이후로 상태 관리를 제일 먼저 잡는다.

이 글은 Terraform 1.x(및 호환되는 OpenTofu 1.x)를 기준으로, 소규모 스타트업부터 여러 팀이 같은 인프라를 건드리는 조직까지 두루 쓸 수 있는 패턴을 다룬다. 문법 소개가 아니라 "이렇게 안 하면 언젠가 데인다"에 가깝다.

핵심 요소실무 기본값안 하면 생기는 일
상태 저장 위치원격 백엔드(S3+DynamoDB, GCS, Azure Blob 등)state 유실·충돌, 시크릿 평문 노출
잠금(lock)DynamoDB/네이티브 락 활성화동시 apply로 상태 손상
환경 분리디렉토리 분리(dev/stg/prod)워크스페이스 혼동으로 prod에 오적용
모듈 입도얇은 재사용 단위, 강한 인터페이스거대 모듈로 변경 반경 폭발
배포 흐름plan 산출물 저장 후 applyplan과 다른 결과가 실제로 적용

Terraform 상태(state)는 정확히 무엇을 하나?

상태 파일은 Terraform이 관리하는 실제 리소스와 코드 사이의 매핑 장부다. HCL에 aws_instance.web이라고 쓰면, 그게 실제 어떤 인스턴스 ID(i-0abc...)에 대응하는지를 state가 기억한다. 이 장부가 없으면 Terraform은 다음 plan 때 "이거 새로 만들어야 하나?"를 판단할 수 없다.

그래서 상태가 리소스의 단일 진실 공급원(source of truth)이 된다. 코드가 아니다. 이게 초보자가 가장 자주 놓치는 지점이다. 콘솔에서 손으로 리소스를 지워도 state에는 남아 있고, 반대로 state에서 지우면 Terraform은 그 리소스를 잊는다(실물은 남는다). terraform state rmterraform import가 이래서 존재한다.

기본값이 함정인 부분: 아무 설정 없이 terraform apply를 하면 상태가 현재 디렉토리의 terraform.tfstate에 평문 JSON으로 저장된다. 여기에는 DB 비밀번호, 프라이빗 키 같은 게 그대로 들어간다. 이걸 Git에 올리는 사고가 아직도 흔하다.

참고 .gitignore*.tfstate, *.tfstate.*, .terraform/, *.tfvars(시크릿 포함 시)를 넣는 건 기본 중 기본이다. 하지만 그건 방어일 뿐, 근본 해결은 상태를 애초에 로컬에 두지 않는 것이다.

원격 상태와 잠금 — 왜 로컬 state는 팀에서 금지인가

결론부터: 두 명 이상이 같은 인프라를 만지는 순간 원격 백엔드와 잠금은 선택이 아니라 필수다. 로컬 상태는 공유가 안 되고, 동시 실행 시 서로의 변경을 덮어쓴다.

AWS라면 S3(상태 저장)+DynamoDB(잠금) 조합이 여전히 가장 검증된 형태다. S3 버킷은 버전 관리를 켜두면 실수로 깨진 state도 이전 버전으로 복구할 수 있다. 이건 실제로 나를 두 번 살렸다.

terraform {
  required_version = ">= 1.6.0"

  backend "s3" {
    bucket         = "acme-tfstate-prod"
    key            = "network/terraform.tfstate"  # 상태 파일 경로(구성요소별로 분리)
    region         = "ap-northeast-2"
    dynamodb_table = "acme-tf-locks"               # 잠금 테이블
    encrypt        = true                          # SSE 암호화 필수
  }

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

DynamoDB 잠금 테이블은 파티션 키를 LockID(문자열)로 만들면 된다. 이 테이블 하나를 계정 전체가 공유해도 무방하다. 잠금이 걸린 상태에서 다른 사람이 apply를 시도하면 이런 에러가 뜬다:

Error: Error acquiring the state lock

Lock Info:
  ID:        7f3c1a2e-...
  Operation: OperationTypeApply
  Who:       kim@macbook
  Created:   2026-09-13 04:12:33 UTC

# 정말로 유령 락이라고 확신할 때만(상대가 실행 중이 아님을 확인 후):
terraform force-unlock 7f3c1a2e-...
주의 force-unlock은 최후의 수단이다. 상대가 실제로 apply 중인데 강제 해제하면 두 프로세스가 같은 state를 동시에 쓰면서 진짜로 장부가 깨진다. 나는 팀 슬랙에 "unlock 합니다" 한 마디 남기고 나서만 실행한다.

참고로 2023년 이후 backend "s3"use_lockfile = true 옵션이 생겨서 S3 조건부 쓰기 기반 잠금을 쓸 수 있게 됐다. DynamoDB 없이도 잠금이 되지만, 기존 프로젝트라면 검증된 DynamoDB 방식을 굳이 급하게 바꿀 이유는 없다. 신규라면 락 파일 방식이 인프라가 하나 줄어서 깔끔하다.

워크스페이스 vs 디렉토리 분리 — 환경은 어떻게 나눌까?

실무 정답에 가까운 건 디렉토리 분리다. terraform workspace는 편해 보이지만 prod와 dev가 같은 코드·같은 백엔드 키 접두사를 공유하기 때문에, 지금 어느 워크스페이스에 있는지 착각하면 dev인 줄 알고 prod를 밀어버린다. 이 사고는 생각보다 자주 난다.

기준워크스페이스디렉토리 분리
환경 격리 강도약함(같은 코드 공유)강함(백엔드·변수 분리)
환경별 차이 표현count/조건문 남발tfvars로 깔끔
실수 위험높음(현재 ws 착각)낮음(cd 위치가 곧 환경)
적합한 용도단명 리뷰 환경·PR별 미리보기장기 운영 환경(dev/stg/prod)

내가 선호하는 레이아웃은 이렇다. 환경별로 폴더를 두고, 공통 로직은 모듈로 뺀다.

.
├── modules/                # 재사용 모듈(환경 무관)
│   ├── network/
│   ├── eks/
│   └── rds/
└── live/                   # 실제 배포 단위(환경별)
    ├── prod/
    │   ├── network/
    │   │   ├── main.tf     # module "network" 호출
    │   │   ├── backend.tf  # prod 전용 state 키
    │   │   └── prod.tfvars
    │   └── rds/
    ├── stg/
    └── dev/

이렇게 하면 "prod를 건드리려면 cd live/prod/rds를 해야 한다"는 물리적 장벽이 생긴다. CI에서도 변경된 디렉토리만 plan을 돌리기 쉽다. 워크스페이스는 나도 쓴다 — 단, PR마다 임시로 뜨는 리뷰 환경처럼 수명이 짧고 서로 완전히 동일한 환경에만.

참고 상태 파일을 구성요소(network/rds/eks)별로도 쪼개면 blast radius(변경 반경)가 준다. rds만 apply할 때 network state를 건드릴 일이 없어지고, plan/apply 속도도 빨라진다. 대신 구성요소 간 참조는 terraform_remote_state 데이터 소스나 SSM 파라미터로 연결한다.

모듈 설계 — 얼마나 쪼개고, 인터페이스는 어떻게?

좋은 모듈은 하나의 논리적 단위를 캡슐화하고, 입력과 출력이 명확한 얇은 것이다. "우리 회사 인프라 전체"를 한 모듈에 담으려는 유혹을 이겨야 한다. 거대 모듈은 변수가 80개씩 되고, 한 줄 고치면 전 팀이 영향받는다.

내 기준은 이렇다. (1) 재사용될 가능성이 있는가, (2) 명확한 경계(예: VPC 하나, RDS 하나)가 있는가, (3) 이 모듈만 따로 plan해도 말이 되는가. 세 개 다 예면 모듈로 뺀다. 아니면 그냥 루트에 둔다. 과도한 추상화는 재사용 안 되는 모듈만 양산한다 — 이게 실무에서 훨씬 흔한 실패다.

# modules/rds/variables.tf — 인터페이스는 최소·명확하게
variable "identifier" {
  description = "RDS 인스턴스 식별자"
  type        = string
}

variable "instance_class" {
  description = "인스턴스 클래스"
  type        = string
  default     = "db.t4g.medium"
}

variable "allocated_storage" {
  type    = number
  default = 50

  validation {
    condition     = var.allocated_storage >= 20
    error_message = "스토리지는 최소 20GB 이상이어야 합니다."
  }
}

variable "tags" {
  type    = map(string)
  default = {}
}

# modules/rds/outputs.tf — 소비자가 필요한 것만 노출
output "endpoint" {
  value = aws_db_instance.this.endpoint
}

output "security_group_id" {
  value = aws_security_group.rds.id
}

호출부는 이렇게 깔끔해진다. 버전 고정을 잊지 말자 — 모듈 소스가 Git이나 레지스트리면 ref/version으로 반드시 핀을 박는다. 안 그러면 어제 되던 게 오늘 남의 커밋 때문에 깨진다.

# live/prod/rds/main.tf
module "orders_db" {
  source  = "git::https://github.com/acme/tf-modules.git//rds?ref=v2.3.1"

  identifier        = "orders-prod"
  instance_class    = "db.r6g.large"
  allocated_storage = 200

  tags = {
    Environment = "prod"
    Team        = "payments"
  }
}

몇 가지 현장 규칙: 모듈 안에서는 provider 블록을 정의하지 말고 호출부에서 주입받는다(모듈이 provider를 고정하면 여러 리전·계정 배포가 지옥이 된다). 모듈 안에서 backend도 선언하지 않는다. 그리고 변수엔 descriptionvalidation을 넣어라 — 이게 곧 문서고, 잘못된 입력을 apply 전에 막아준다.

plan/apply 워크플로 — CI에서 안전하게 굴리기

핵심 원칙 하나: plan한 그 결과를 그대로 apply하라. terraform planterraform apply를 따로 실행하면, 그 사이에 인프라가 바뀌어서 apply가 plan과 다른 짓을 할 수 있다. 그래서 plan을 파일로 저장하고 그 파일을 apply에 넘긴다.

# 1) 계획을 산출물로 저장
terraform plan -out=tfplan -var-file=prod.tfvars

# 2) 저장된 계획을 그대로 적용(중간에 상태 바뀌면 apply가 실패해 안전)
terraform apply tfplan

# CI에서 사람 확인 없이 돌릴 땐 -input=false를 붙여 프롬프트 방지
terraform apply -input=false tfplan

CI 파이프라인은 보통 이렇게 짠다: PR이 열리면 fmt -checkvalidateplan을 돌리고, plan 결과를 PR 코멘트로 붙인다. 리뷰어가 diff를 눈으로 확인하고 머지하면, main에서 apply tfplan이 돈다. plan 산출물을 아티팩트로 넘겨서 "리뷰한 것과 적용한 것"이 물리적으로 동일함을 보장하는 게 포인트다.

# GitHub Actions 발췌
- name: Terraform Plan
  run: |
    terraform init -input=false
    terraform plan -input=false -out=tfplan
    terraform show -no-color tfplan > plan.txt

- name: Upload plan
  uses: actions/upload-artifact@v4
  with:
    name: tfplan
    path: tfplan          # apply 잡에서 이 아티팩트를 그대로 사용
참고 terraform plan의 종료 코드는 옵션에 따라 다르다. -detailed-exitcode를 주면 0=변경 없음, 1=오류, 2=변경 있음으로 갈린다. "drift가 있으면 CI를 빨갛게" 같은 정책을 만들 때 이 종료 코드를 쓴다.

시크릿은 어디에 두나 — state에 평문으로 남는 문제

불편한 진실: Terraform은 민감한 값도 state에 평문으로 저장한다. sensitive = true는 CLI 출력에서 값을 가려줄 뿐, state 파일 안에는 그대로 들어간다. 그래서 state 자체를 암호화하고(S3 SSE, 백엔드 암호화) 접근 권한을 조이는 게 1차 방어다.

근본 대책은 시크릿을 Terraform 변수로 넘기지 않는 것이다. 나는 이렇게 나눈다:

  • DB 비밀번호처럼 리소스 생성에 꼭 필요한 값 → 생성 시 랜덤 발급 후 Secrets Manager/SSM에 저장, 애플리케이션은 런타임에 거기서 읽음. Terraform은 값을 몰라도 된다.
  • Provider 인증(AWS 키 등) → 코드/tfvars에 절대 안 넣고 CI의 OIDC 연동이나 환경변수·IAM 역할로 주입.
  • 불가피하게 외부에서 받아야 하는 시크릿 → TF_VAR_ 환경변수로 주입하고 tfvars에는 안 남김.
# 비밀번호를 코드에 두지 않는 패턴
resource "random_password" "db" {
  length  = 24
  special = true
}

resource "aws_secretsmanager_secret_version" "db" {
  secret_id     = aws_secretsmanager_secret.db.id
  secret_string = random_password.db.result
}

resource "aws_db_instance" "this" {
  # ... 생략
  password = random_password.db.result  # state엔 남지만 사람 손엔 안 거침
}

이렇게 해도 random_password.db.result는 state에 남는다. 완전히 없앨 수는 없다는 걸 인정하고, state 접근 통제를 보안 경계로 삼는 게 현실적인 답이다. "state 파일을 읽을 수 있는 사람 = 시크릿을 아는 사람"이라고 생각하고 IAM을 설계하라.

드리프트(drift) — 코드와 실제가 어긋날 때

드리프트는 누군가 콘솔에서 손으로 리소스를 바꿔서 코드·state와 실제가 달라진 상태다. 완전히 막기는 어렵고(긴급 대응 때 사람이 손대는 건 현실이다), 주기적으로 감지하는 게 관리의 핵심이다.

가장 간단한 감지는 terraform plan -detailed-exitcode를 정기적으로(예: 매일 새벽 CronJob) 돌려서 종료 코드 2가 나오면 알림을 보내는 것이다. 변경 사항 없이 깨끗하면 0이니까, 2가 뜨면 누군가 손댔다는 신호다.

# 드리프트 감지 스크립트(CI cron)
terraform plan -detailed-exitcode -input=false -lock=false > /dev/null
case $? in
  0) echo "드리프트 없음" ;;
  2) echo "드리프트 감지! 슬랙 알림 발송"; notify_slack ;;
  *) echo "plan 실패"; exit 1 ;;
esac

드리프트를 발견하면 두 갈래다. 손댄 변경이 정당하면 코드에 반영하고(코드가 실물을 따라감), 실수면 terraform apply로 코드 상태로 되돌린다(실물이 코드를 따라감). 어느 쪽이든 "코드가 진실"이라는 원칙을 지키려는 행위다. terraform apply -refresh-only는 실제 상태를 state에만 반영하고 리소스는 안 바꾸는데, 드리프트 조사할 때 유용하다.

주의 드리프트 감지 plan에 -lock=false를 쓰는 건 조회 목적이라 락을 안 잡으려는 것인데, 이건 읽기 전용 plan일 때만 안전하다. 절대 -lock=false로 apply하지 마라. 잠금을 우회한 apply는 동시 실행 사고의 지름길이다.

OpenTofu — 이주할까 말까

OpenTofu는 2023년 HashiCorp가 Terraform 라이선스를 BSL(비오픈소스)로 바꾸자, 리눅스 재단 산하로 포크된 오픈소스 대안이다. 현재 Terraform 1.5까지와는 사실상 드롭인 호환이고, 명령·HCL 문법·state 포맷이 거의 동일하다. terraformtofu로 바꿔도 대부분 그대로 돈다.

내 판단 기준은 이렇다. 라이선스(BSL)가 조직 정책상 문제가 되거나, state 암호화 같은 OpenTofu 고유 기능(클라이언트 사이드 state 암호화는 OpenTofu가 먼저 넣었다)이 필요하면 이주를 검토한다. 반대로 HCP Terraform(구 Terraform Cloud) 같은 HashiCorp 상용 생태계에 깊게 묶여 있으면 그냥 Terraform을 쓰는 게 편하다.

주의할 점: 두 도구가 1.6 이후로 기능이 갈리기 시작했다. "드롭인"이라는 말만 믿고 최신 기능을 섞어 쓰면 미묘하게 어긋난다. 이주한다면 버전을 한 번에 정하고, provider·모듈 호환성을 stg에서 충분히 검증한 뒤 넘어가라. 나는 신규 프로젝트에는 OpenTofu를, 기존 대규모 레거시는 급하지 않으면 그대로 두는 편이다.

자주 묻는 질문

terraform.tfstate 파일을 Git에 올려도 되나요?

안 됩니다. 시크릿이 평문으로 들어 있고, 팀 협업 시 충돌·유실 위험이 큽니다. 원격 백엔드(S3+DynamoDB 등)에 두고 .gitignore로 로컬 state를 제외하세요.

워크스페이스와 디렉토리 분리 중 뭘 써야 하나요?

장기 운영 환경(dev/stg/prod)은 디렉토리 분리가 안전합니다. 워크스페이스는 PR별 리뷰 환경처럼 수명이 짧고 구조가 동일한 임시 환경에 적합합니다.

terraform force-unlock은 언제 써도 되나요?

실행 중인 프로세스가 없음을 확실히 확인한 유령 락일 때만입니다. 실제 실행 중인 락을 강제 해제하면 동시 쓰기로 state가 손상됩니다. 팀에 먼저 알리고 실행하세요.

plan과 apply 사이에 인프라가 바뀌면 어떻게 되나요?

terraform plan -out=tfplan으로 계획을 저장하고 apply tfplan으로 그 계획만 적용하세요. 저장된 계획과 실제 state가 어긋나면 apply가 실패하므로, 예상 밖 변경이 조용히 적용되는 일을 막아줍니다.

시크릿을 state에서 완전히 없앨 수 있나요?

완전히는 어렵습니다. Terraform은 민감한 값도 state에 평문 저장하므로, state 암호화와 접근 권한 통제가 현실적인 방어입니다. 가능하면 시크릿은 Secrets Manager/SSM에서 런타임에 읽게 해 Terraform이 값을 모르게 설계하세요.

모듈은 얼마나 잘게 쪼개야 하나요?

명확한 경계(VPC, RDS 등)가 있고 재사용 가능성이 있으며 단독으로 plan해도 말이 되는 단위로 나누세요. "인프라 전체"를 한 모듈에 담으면 변수가 폭증하고 변경 반경이 커집니다. 과도한 추상화는 오히려 재사용 안 되는 모듈만 늘립니다.

OpenTofu로 지금 이주해야 하나요?

급하지 않습니다. 라이선스(BSL)가 정책상 걸리거나 클라이언트 사이드 state 암호화 같은 고유 기능이 필요하면 검토하세요. HashiCorp 상용 생태계에 묶여 있다면 Terraform을 유지하는 편이 편합니다. 이주 시 버전을 고정하고 stg에서 호환성을 검증하세요.

댓글 0

아직 댓글이 없습니다.
Ctrl+Enter로 등록