Terraform state 파일, 어떻게 나눠서 관리할까

Terraform을 몇 년 굴려본 팀이면 한 번쯤은 겪는 상황이 있다. state 파일 하나에 리소스가 500개, 800개 넘게 쌓이면 plan 한 번 돌리는 데 몇 분씩 걸리고, 누구 하나가 apply 걸어놓으면 다른 사람은 하염없이 기다려야 한다. 그러다 어느 날 실수로 리소스 하나 잘못 지우면 blast radius가 너무 커서 롤백도 쉽지 않다.
우리 팀도 초기에는 그냥 terraform/ 폴더 하나에 다 몰아넣고 시작했다. 편하긴 한데, 팀 규모가 커지고 환경이 dev/stage/prod로 늘어나면서 이 구조가 점점 부담스러워졌다. 이 글은 state 파일을 어떻게 쪼개고, 원격 백엔드는 어떻게 설정하고, drift는 어떻게 감지하는지 실무 관점에서 정리한 가이드다.
최근에 OpenTofu 1.10이 나오면서 state encryption에 external key provider가 정식으로 지원되기 시작했다. Terraform 진영도 마찬가지로 remote backend + 잠금은 이제 기본이 됐다. 이번 글은 이 흐름을 반영해서 2026년 기준으로 다시 정리한 실무 노트다.
왜 state를 나눠야 하는가
state 파일 하나에 모든 걸 다 넣으면 세 가지 문제가 생긴다.
첫째, plan / apply 시간이 리소스 수에 비례해서 늘어난다. 리소스 300개 정도까지는 견딜 만한데, 500개 넘어가면 refresh 단계에서만 몇 분씩 걸린다. AWS provider 기준으로 리소스 하나당 최소 1-2번씩 API 호출이 들어가니 rate limit도 신경 써야 한다.
둘째, 잠금(lock) 경합이 심해진다. DynamoDB로 state lock을 잡는 경우, 팀원 A가 apply 중이면 팀원 B는 그동안 아무것도 못 한다. VPC 하나 고치려는데 EKS 배포 끝날 때까지 기다려야 하는 상황이 반복되면 개발 속도가 확 떨어진다.
셋째, blast radius. 실수로 terraform destroy를 잘못 걸면 프로덕션 데이터베이스까지 같이 날아갈 수 있다. 우리 팀에서도 예전에 dev 환경 정리한다고 명령 잘못 쳐서 stage RDS까지 지워질 뻔한 적이 있다. 그 이후로 state를 확실히 분리했다.
어떻게 쪼갤까: 세 가지 축
state 분리 기준은 크게 세 가지 축이 있다. 이 세 축을 조합해서 자기 팀 상황에 맞게 잡으면 된다.
축 1 — 환경(environment): dev / stage / prod. 이건 거의 무조건이다. 프로덕션 state를 실수로 건드리는 일을 원천 차단하려면 환경별로 완전히 분리해야 한다.
축 2 — 리소스 수명주기(lifecycle): 자주 바뀌는 리소스와 거의 안 바뀌는 리소스를 나눈다. VPC, IAM, KMS 같은 foundation 리소스는 한 번 만들면 몇 달에 한 번 건드릴까 말까다. 반면 EKS 워크로드나 Lambda 함수는 하루에도 몇 번씩 배포된다. 이걸 같은 state에 두면 자주 바뀌는 쪽 때문에 안 바뀌는 쪽까지 매번 refresh를 해야 한다.
축 3 — 소유 팀(ownership): 네트워크 팀이 관리하는 리소스와 애플리케이션 팀이 관리하는 리소스를 분리한다. 팀 간 의존성이 있는 경우 terraform_remote_state나 SSM Parameter Store로 값을 넘기면 된다.
우리 팀은 대략 이런 식으로 정리했다.
terraform/
├── prod/
│ ├── foundation/ # VPC, IAM, KMS, Route53
│ │ └── backend.tf
│ ├── data/ # RDS, ElastiCache, S3 (수명주기 김)
│ │ └── backend.tf
│ ├── platform/ # EKS 클러스터, ArgoCD, 모니터링
│ │ └── backend.tf
│ └── apps/ # 각 애플리케이션 (자주 바뀜)
│ ├── api-server/
│ └── worker/
├── stage/
│ └── ...
└── dev/
└── ...
이렇게 나누면 애플리케이션 팀이 apps/api-server state만 갖고 자유롭게 배포할 수 있고, 네트워크 팀이 foundation을 건드리는 것과 아예 독립적으로 돌아간다.
Remote backend 설정: S3 + DynamoDB 기준
로컬 state는 팀 작업할 때 절대 쓰면 안 된다. 원격 백엔드가 기본이다. AWS라면 S3 + DynamoDB 조합이 가장 흔하고 검증됐다.
# prod/platform/backend.tf
terraform {
backend "s3" {
bucket = "mycompany-tfstate-prod"
key = "platform/terraform.tfstate"
region = "ap-northeast-2"
dynamodb_table = "terraform-locks"
encrypt = true
kms_key_id = "arn:aws:kms:ap-northeast-2:111122223333:key/xxxx"
}
}
몇 가지 짚고 넘어갈 포인트가 있다.
encrypt = true + kms_key_id는 반드시 넣는다. state 파일에는 리소스 ID뿐 아니라 provider가 반환한 raw 값이 다 들어간다. RDS master password를 output으로 안 뽑아도, provider가 응답에 담아 보낸 값이 state에 그대로 남는 경우가 있다. KMS로 암호화해두면 최소한 S3 bucket이 실수로 public이 돼도 내용을 못 읽는다.
DynamoDB lock 테이블은 환경마다 따로 두는 걸 추천한다. dev와 prod가 같은 lock 테이블을 공유하면 쓸데없이 lock 경합이 생긴다. 테이블 자체는 저렴하니(월 1달러도 안 나온다) 아끼지 말자.
S3 버킷에는 버전 관리(versioning)를 반드시 켠다. state가 깨졌을 때 이전 버전으로 롤백할 수 있는 유일한 방법이다. 그리고 lifecycle rule로 90일 이상 된 non-current version은 자동 삭제하도록 걸어둔다.
OpenTofu 쓸 거면 state encryption도 걸어라
OpenTofu 1.7부터 built-in state encryption이 들어왔고, 1.10에서 external key provider 지원이 안정화됐다. Terraform은 아직 이 기능이 없다. OpenTofu로 마이그레이션을 고려 중이라면 이건 꽤 강력한 이유다.
terraform {
encryption {
key_provider "aws_kms" "state_key" {
kms_key_id = "arn:aws:kms:ap-northeast-2:111122223333:key/xxxx"
region = "ap-northeast-2"
key_spec = "AES_256"
}
method "aes_gcm" "state_method" {
keys = key_provider.aws_kms.state_key
}
state {
method = method.aes_gcm.state_method
enforced = true
}
plan {
method = method.aes_gcm.state_method
enforced = true
}
}
}
S3 서버 사이드 암호화가 이미 있는데 왜 이걸 또 하냐고 물을 수 있다. 이유는 이렇다. S3 SSE는 저장 시점 암호화라서, 누군가 IAM 권한으로 S3에서 파일을 받으면 평문으로 내려온다. OpenTofu의 state encryption은 애플리케이션 레이어 암호화라서 KMS 키에 접근 못 하면 파일을 받아도 못 읽는다. 두 겹으로 보호하는 셈이다.
enforced = true는 실수로 암호화 없이 state가 저장되는 걸 막아준다. 처음 도입할 때는 false로 시작해서 점진적으로 마이그레이션한 뒤 켜는 걸 추천한다.
팀 간 state 값 공유: remote_state vs SSM
state를 쪼개면 팀 간 값 공유가 문제가 된다. platform state에서 만든 EKS 클러스터 이름을 apps state에서 참조해야 한다면?
방법 1은 terraform_remote_state.
data "terraform_remote_state" "platform" {
backend = "s3"
config = {
bucket = "mycompany-tfstate-prod"
key = "platform/terraform.tfstate"
region = "ap-northeast-2"
}
}
resource "aws_iam_role" "app_role" {
# ...
assume_role_policy = jsonencode({
# ...
Condition = {
StringEquals = {
"oidc.eks.ap-northeast-2.amazonaws.com/id/${data.terraform_remote_state.platform.outputs.eks_oidc_provider}:sub" = "system:serviceaccount:default:app"
}
}
})
}
편하긴 한데 단점이 있다. remote_state를 참조하는 쪽은 참조되는 쪽 state 전체를 읽을 권한이 필요하다. 즉 apps 팀 IAM 롤에 platform state를 읽을 권한을 줘야 한다. 팀 격리 관점에서 좀 애매하다.
방법 2는 SSM Parameter Store나 AWS Secrets Manager로 값을 넘기는 것.
# platform 쪽
resource "aws_ssm_parameter" "eks_oidc_provider" {
name = "/platform/eks/oidc_provider"
type = "String"
value = replace(aws_eks_cluster.this.identity[0].oidc[0].issuer, "https://", "")
}
# apps 쪽
data "aws_ssm_parameter" "eks_oidc_provider" {
name = "/platform/eks/oidc_provider"
}
이 방식은 apps 팀이 SSM 특정 path만 읽으면 되니 권한 분리가 깔끔하다. 단점은 값이 바뀌었을 때 apps 쪽에서 자동으로 refresh되지 않는다는 것 정도. 우리 팀은 대부분 이 방식을 쓴다.
Drift 감지: 정기 plan을 자동화한다
state를 아무리 잘 쪼개놔도 콘솔에서 누가 수동으로 리소스를 건드리면 drift가 생긴다. AWS 콘솔에서 보안 그룹 룰 하나 추가하는 게 얼마나 흔한 일인지 다들 알 것이다.
drift 감지는 GitHub Actions로 매일 새벽에 plan만 돌려서 변경사항이 있으면 Slack으로 알림 보내는 게 가장 간단하다.
# .github/workflows/drift-check.yml
name: Drift Detection
on:
schedule:
- cron: '0 19 * * *' # 매일 KST 04:00
workflow_dispatch:
jobs:
drift:
strategy:
matrix:
stack:
- prod/foundation
- prod/data
- prod/platform
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
- name: Init
run: terraform -chdir=terraform/${{ matrix.stack }} init
- name: Plan
id: plan
run: |
terraform -chdir=terraform/${{ matrix.stack }} plan -detailed-exitcode
continue-on-error: true
- name: Notify Slack on drift
if: steps.plan.outputs.exitcode == '2'
run: |
curl -X POST ${{ secrets.SLACK_WEBHOOK }} \
-H 'Content-Type: application/json' \
-d '{"text":"⚠️ Drift detected in ${{ matrix.stack }}"}'
-detailed-exitcode가 핵심이다. 이 옵션을 주면 변경사항이 있을 때 exit code 2를 반환한다. 그걸로 Slack 알림을 트리거한다. apps처럼 자주 바뀌는 스택은 drift 감지가 의미 없으니 foundation, data, platform 같은 안정된 스택만 대상으로 하는 게 좋다.
실수 방지 장치 몇 가지
마지막으로 우리 팀이 몇 번 실수한 뒤에 걸어둔 안전장치 몇 개를 공유한다.
Prod 리소스에 prevent_destroy: RDS, KMS 키처럼 지워지면 안 되는 것들은 무조건 lifecycle에 걸어둔다.
resource "aws_db_instance" "prod" {
# ...
lifecycle {
prevent_destroy = true
}
}
이거 걸어두면 terraform destroy나 리소스 이름을 바꿔서 재생성하려 할 때 apply 자체가 실패한다. 성가시지만 안전하다.
S3 backend에 versioning + MFA delete: state 파일 자체가 실수로 지워지는 걸 막는다. MFA delete는 처음 설정할 때 좀 번거로운데, 프로덕션 state는 이걸 켜두면 마음이 편하다.
Atlantis 또는 Terraform Cloud로 PR 기반 apply: 로컬에서 apply 치는 걸 원천 차단한다. GitHub PR을 통해서만 plan/apply가 되게 하면 리뷰 없이 프로덕션에 뭐가 반영되는 일이 사라진다. 도입 비용은 있지만 팀 규모가 5명 넘어가면 거의 필수라고 본다.
마무리
state 분리는 처음부터 완벽하게 할 수 없다. 우리 팀도 몇 번 리팩터링을 거쳐서 지금 구조에 정착했다. terraform state mv 명령으로 리소스를 이동시키는 작업이 생각보다 손이 많이 가서, 한 번 잘 나눠두면 그 뒤가 편하다.
핵심만 다시 정리하면: 환경별로 완전히 분리하고, 수명주기와 소유 팀을 축으로 잘게 쪼갠다. 원격 백엔드는 필수고 KMS 암호화는 기본이다. OpenTofu 쓴다면 state encryption도 걸어라. drift는 자동으로 매일 감지한다.
혹시 다른 방식으로 state를 관리하고 계신 분들 있으면 어떻게 하시는지 궁금하다. 특히 Terragrunt로 관리하는 팀들 이야기를 듣고 싶다. 우리 팀도 한동안 검토했다가 러닝 커브 때문에 미뤄뒀는데, 최근에 다시 고민 중이다.