목차
- 2시간 30분 — 상태 파일 충돌 한 건의 대가
- Terraform State Lock은 어떻게 동작하는가
- S3 백엔드 + DynamoDB 락 설정
- Terraform 1.10 이후 — DynamoDB 없는 네이티브 락
- force-unlock을 함부로 쓰면 안 되는 이유
- CI/CD에서 Terraform 상태 파일 충돌을 예방하는 구조
- State 파일이 꼬였을 때 복구 순서
- 어떤 환경에 어떤 백엔드 락을 쓸 것인가
2시간 30분 — 상태 파일 충돌 한 건의 대가
Terraform을 팀에서 쓰면 state 파일 충돌은 시간문제다. 누군가 EC2 인스턴스를 추가하는 terraform apply를 실행하는 동안 다른 쪽에서 보안 그룹을 수정하고, CI/CD의 자동 apply까지 겹치면 충돌이 발생한다.
Terraform 1.7 환경에서 뜬 에러 메시지는 이랬다:
Error: Error acquiring the state lock
Error message: ConditionalCheckFailedException: The conditional request failed
Lock Info:
ID: a1b2c3d4-e5f6-7890-abcd-ef1234567890
Path: s3://our-terraform-state/staging/terraform.tfstate
Operation: OperationTypeApply
Who: colleague@devmachine
Version: 1.7.4
Created: 2024-11-19 02:15:32.123456 +0000 UTC
여기까지는 흔한 잠금 충돌이다. 문제는 원인이 예상과 달랐다는 점이다. 셋이 동시에 눌러서 터진 게 아니라, 그 전주에 CI 파이프라인에서 apply가 중간에 죽으면서 DynamoDB에 고아 락(orphan lock)이 남아있었다. 누가 실제로 작업 중인지, 오래된 잔여 잠금인지 구분이 안 되면서 상황이 꼬이기 시작했다. 이 글은 그날 이후 정리한 내용이다.
Terraform State Lock은 어떻게 동작하는가
Terraform이 plan이나 apply를 실행할 때 가장 먼저 하는 일은 상태 파일에 대한 잠금(lock) 획득이다. 데이터베이스의 행 잠금과 비슷한 개념으로, 동시에 여러 프로세스가 같은 상태 파일을 수정하는 것을 방지한다.
로컬 백엔드를 쓸 때는 파일 시스템의 .terraform.tfstate.lock.info 파일로 잠금을 처리한다. 별도 설정이 필요 없고, 혼자 작업하면 이걸로 충분하다.
S3 백엔드에서는 상황이 다르다. S3 자체에는 원자적(atomic) 잠금 메커니즘이 원래 없었기 때문에, HashiCorp는 DynamoDB 테이블을 별도로 사용하는 방식을 택했다. 흐름을 순서대로 보면 이렇다:
- Terraform이 DynamoDB 테이블에 잠금 레코드를 쓴다 (PutItem + ConditionExpression)
- 이미 레코드가 존재하면
ConditionalCheckFailedException이 발생하며 요청이 거부된다 - 잠금을 획득한 프로세스만 S3의 상태 파일을 읽고 쓸 수 있다
- 작업이 끝나면 DynamoDB에서 잠금 레코드를 삭제한다
DynamoDB의 조건부 쓰기(Conditional Write)가 핵심이다. 동시에 두 요청이 들어와도 하나만 성공하고 나머지는 실패하도록 보장하는 원자적 연산이다. 분산 환경에서 잠금을 구현할 때 가장 검증된 패턴 중 하나로, Terraform이 이 방식을 선택한 건 합리적이었다고 본다.
오해하기 쉬운 점이 하나 있다. terraform plan은 읽기 전용 작업이라 잠금이 필요 없을 것 같지만, 실제로는 plan도 state를 읽기 위해 잠금을 건다. 차이는 점유 시간이다. plan은 state를 읽고 즉시 해제하지만, apply는 리소스 변경이 모두 끝날 때까지 잠금을 유지한다. EC2 인스턴스 하나 만드는 데 2~3분, RDS 클러스터면 써보면 15분 넘게 걸리는 경우도 있다. 그 시간 동안 다른 사람은 plan조차 실행할 수 없다.
잠금 레코드에는 Who(누가), Created(언제), Operation(어떤 작업)이 기록된다. 충돌이 발생했을 때 잠금을 잡고 있는 주체를 추적할 수 있게 해주는 정보다. 위 에러 메시지에서 이 필드들이 보였던 이유이기도 하다.
S3 백엔드 + DynamoDB 락 설정
설정 자체는 간단하다. 복잡한 건 설정이 아니라 운영이다.
# backend.tf — 실무 S3 + DynamoDB 백엔드 설정
terraform {
backend "s3" {
bucket = "myteam-terraform-state"
key = "staging/network/terraform.tfstate"
region = "ap-northeast-2"
encrypt = true
dynamodb_table = "terraform-lock"
# S3 버전닝은 콘솔 또는 별도 IaC에서 활성화 필수
}
}
몇 가지 포인트를 짚겠다.
key 경로 설계가 운영의 절반이다. 환경별로 분리하지 않으면 dev에서 날린 apply가 prod state를 잠글 수 있다. {환경}/{서비스}/terraform.tfstate 형태가 무난하다. 우리 팀은 staging/network/terraform.tfstate, prod/compute/terraform.tfstate처럼 2단계로 나눈다. 서비스가 많아지면 3단계까지 가는 팀도 있는데, 깊어질수록 관리 비용도 올라가니 2단계에서 시작하는 것을 권한다.
encrypt = true는 빼먹으면 안 된다. State 파일에는 DB 비밀번호, API 키 같은 민감 정보가 평문으로 들어간다. 이 옵션을 켜면 S3 서버 사이드 암호화(SSE-S3)가 적용된다. KMS를 쓰려면 kms_key_id를 추가하면 되고, 규정 준수가 필요한 환경이면 KMS 쪽이 감사(audit) 측면에서 유리하다.
DynamoDB 테이블은 Terraform이 자동 생성하지 않는다. 직접 만들어야 한다. 파티션 키는 반드시 LockID(String 타입)로 지정해야 하고, 이름이 다르면 잠금이 동작하지 않는다. 공식 문서를 빠르게 훑으면 놓치기 쉬운 부분이다 (출처: Terraform S3 Backend 공식 문서, 2026-04-03 기준). 과금 모드는 PAY_PER_REQUEST(온디맨드)면 충분하다. 잠금 연산은 하루에 수십~수백 건 수준이라 프로비저닝 모드를 쓸 이유가 없다.
S3 버전닝을 켜두지 않으면 복구가 불가능하다. 이건 Terraform 설정이 아니라 S3 버킷 자체의 속성이다. State가 꼬였을 때 이전 버전으로 돌릴 수 있는 유일한 수단이다. 우리 팀이 2시간 반 만에 복구를 끝낼 수 있었던 건 순전히 이 설정 덕이었다. 버전닝이 꺼져 있었다면 terraform import로 리소스를 하나씩 다시 등록해야 했을 것이고, 리소스가 수십 개면 반나절은 잡아야 한다.
Terraform 1.10 이후 — DynamoDB 없는 네이티브 락
결론부터 말하면, Terraform 1.10.0부터는 DynamoDB 없이 S3만으로 상태 잠금이 가능하다 (출처: Terraform v1.10.0 Release Notes). AWS가 2024년 8월에 공개한 S3 조건부 쓰기(Conditional Writes) 기능을 활용한 것이다.
# Terraform 1.10+ 네이티브 S3 락 설정
terraform {
backend "s3" {
bucket = "myteam-terraform-state"
key = "staging/network/terraform.tfstate"
region = "ap-northeast-2"
encrypt = true
use_lockfile = true
# dynamodb_table 불필요
}
}
원리는 DynamoDB 방식과 동일하다. S3에 .tflock 파일을 조건부 PUT으로 생성하고, 이미 존재하면 실패한다. ConditionalCheckFailedException 대신 S3의 PreconditionFailed(HTTP 412)가 발생하는 차이만 있다.
use_lockfile과 dynamodb_table을 동시에 설정하면 이중 잠금이 걸린다. 마이그레이션 과도기에는 이 구성이 오히려 안전할 수 있다. 다만 장기적으로는 하나로 통일해야 운영 부담이 줄어든다.
신규 프로젝트라면 네이티브 S3 락을 쓰는 게 맞다고 본다. DynamoDB 테이블을 별도로 관리할 필요가 없고, IAM 정책도 S3 쪽만 신경 쓰면 된다. 반대로, 기존에 DynamoDB로 잘 돌아가는 프로젝트를 굳이 마이그레이션할 이유는 없다. 네이티브 S3 락이 대규모 환경에서 장기간 운영된 사례가 아직 많지 않아서, 안정성 측면에서는 DynamoDB가 더 검증되어 있다는 판단이다.
force-unlock을 함부로 쓰면 안 되는 이유
terraform force-unlock은 DynamoDB(또는 S3)에 남아있는 잠금 레코드를 강제로 삭제하는 명령이다. 우리 팀에서 state 복구에 2시간 반이 걸린 직접적 원인이 이 명령이었다.
상황을 다시 정리하면 이렇다. CI 파이프라인이 중간에 죽으면서 남긴 고아 락이 있었고, 나는 그게 고아 락인 줄 알고 force-unlock을 실행했다. 그런데 동료가 실제로 apply 작업 중이었다. 잠금이 풀리면서 두 프로세스가 동시에 state 파일을 썼고, 파일이 깨졌다. 등에 식은땀이 흐르는 순간이었다.
force-unlock 전에 반드시 확인해야 할 것들:
- 에러 메시지의
Who필드를 보고 해당 사용자에게 직접 확인한다 Created타임스탬프가 수 시간 전이면 고아 락일 가능성이 높다- CI/CD 파이프라인 로그에서 실행 중인 작업이 없는지 점검한다
- DynamoDB를 직접 조회해서 잠금 레코드 상세를 본다
확신이 없으면 force-unlock을 쓰지 않는 게 맞다. 5분 기다려서 동료한테 슬랙 한 줄 보내는 게, state 복구에 2시간 쓰는 것보다 훨씬 경제적이다.
CI/CD에서 Terraform 상태 파일 충돌을 예방하는 구조
사람끼리는 "지금 apply 돌린다" 한마디면 해결되지만, CI/CD 파이프라인은 그런 소통을 하지 않는다. 구조적으로 막아야 한다.
plan과 apply를 단계적으로 분리하라. PR 단계에서는 terraform plan만 실행하고, 메인 브랜치 머지 이후에 terraform apply를 실행하는 것이 기본이다. plan도 잠금을 걸지만 점유 시간이 짧아 충돌 확률이 낮다. 문제가 되는 건 apply다. 리소스 생성/변경에 수 분에서 수십 분이 걸리기 때문에 잠금 점유 시간이 길어진다.
동시 실행을 직렬화하라. GitHub Actions라면 concurrency 그룹을 설정해서 같은 환경에 대한 워크플로우가 겹치지 않게 제한할 수 있다. GitLab CI는 resource_group이 같은 역할을 한다. 이 설정 하나면 CI/CD 환경에서 발생하는 대부분의 상태 파일 충돌을 예방할 수 있다. 실수하기 쉬운 부분이 하나 있는데, concurrency 그룹을 환경별로 분리하지 않는 것이다. concurrency: terraform으로 하나만 잡으면 dev apply가 돌아가는 동안 prod apply도 대기하게 된다. concurrency: terraform-staging, concurrency: terraform-prod처럼 환경별로 나눠야 불필요한 대기를 없앨 수 있다.
타임아웃과 실패 후처리를 넣어라. CI에서 apply가 중간에 죽는 원인은 십중팔구 타임아웃이다. 기본 타임아웃이 너무 짧거나, 반대로 무한 대기에 빠지는 경우 모두 고아 락을 남긴다. 우리 팀은 apply 스텝에 30분 타임아웃을 걸고, 실패 시 후처리 스텝에서 잠금 상태를 확인한 뒤 슬랙 알림을 보내도록 구성했다. 자동 force-unlock은 위험하니 알림만 보내는 것을 권한다.
수동 apply를 병행한다면 규칙을 정해라. "CI가 apply를 담당하는 환경에서는 로컬 apply 금지"가 가장 깔끔하다. 현실적으로 어렵다면 apply 전에 terraform state list로 현재 상태를 확인하는 습관이라도 들이는 게 낫다. 이 부분은 기술보다 팀 규칙의 문제라서 정답이라고 할 만한 건 아직 모르겠다.
State 파일이 꼬였을 때 복구 순서
S3 버전닝이 켜져 있다면 복구는 생각보다 단순하다.
# 1. 깨진 state라도 일단 로컬에 백업
aws s3 cp s3://myteam-terraform-state/staging/network/terraform.tfstate ./broken.tfstate
# 2. S3 버전 목록에서 정상이었던 마지막 버전 찾기
aws s3api list-object-versions \
--bucket myteam-terraform-state \
--prefix staging/network/terraform.tfstate \
--max-items 5
# 3. 정상 버전 다운로드
aws s3api get-object \
--bucket myteam-terraform-state \
--key staging/network/terraform.tfstate \
--version-id "TARGET_VERSION_ID" \
./recovered.tfstate
# 4. 복구한 state를 다시 업로드
aws s3 cp ./recovered.tfstate \
s3://myteam-terraform-state/staging/network/terraform.tfstate
# 5. DynamoDB 잔여 락 정리
aws dynamodb delete-item \
--table-name terraform-lock \
--key '{"LockID": {"S": "myteam-terraform-state/staging/network/terraform.tfstate"}}'
# 6. 실제 인프라와 state 정합성 확인
terraform plan
terraform plan 결과에서 변경 사항이 없거나 예상한 diff만 나오면 복구가 된 것이다. 예상 못 한 리소스 삭제나 생성이 보이면 버전을 하나 더 앞으로 돌려서 다시 확인해야 한다.
복구 중에 다른 사람이 apply를 실행하면 상황이 더 꼬인다. 팀 채널에 "state 복구 중이니 terraform 쓰지 마라"고 먼저 공지하는 게 첫 단계다. 기술적 복구보다 커뮤니케이션이 먼저라는 걸 그날 뼈저리게 느꼈다.
S3 버전닝이 꺼져 있었다면 상황이 훨씬 나빠진다. terraform import로 실제 인프라에 존재하는 리소스를 하나씩 state에 다시 등록해야 한다. 리소스가 수십 개면 반나절 작업이다. 그래서 S3 버전닝은 선택 사항이 아니라 전제 조건으로 보는 게 맞다.
어떤 환경에 어떤 백엔드 락을 쓸 것인가
모든 프로젝트에 S3 + DynamoDB 조합을 적용할 필요는 없다.
혼자 작업하는 사이드 프로젝트라면 로컬 백엔드로 충분하다. 잠금 충돌 자체가 발생하지 않는다. S3 백엔드를 설정하면 terraform init 때마다 AWS 인증을 챙겨야 해서 오히려 번거롭다.
2~5명 규모 팀이라면 Terraform 1.10 이상을 쓰고 있을 때 네이티브 S3 락(use_lockfile = true)이 가장 균형 잡힌 선택이다. DynamoDB를 별도로 관리하지 않아도 되고 설정이 단순하다. 1.10 미만이라면 DynamoDB 방식을 쓸 수밖에 없다.
대규모 팀이거나 거버넌스가 필요한 환경이라면 Terraform Cloud나 Spacelift 같은 관리형 서비스가 현실적이다. State 관리, 잠금, 접근 제어, 감사 로그가 한 곳에서 해결된다. 월 비용이 발생하지만 state 충돌로 인프라가 멈추는 비용을 생각하면 합리적인 트레이드오프다. 참고로 Terraform Cloud 무료 플랜도 state 관리와 잠금을 지원한다. 5명 이하 팀이면 비용 없이 쓸 수 있으니 검토해볼 만하다. 다만 state를 HashiCorp 서버에 저장하는 게 조직 보안 정책상 허용되는지는 별도로 확인해야 한다.
기존 DynamoDB 설정을 네이티브 S3 락으로 바꿀지 고민하고 있다면, 기준은 하나다. 지금 DynamoDB 때문에 문제가 생기고 있는가? 그렇지 않다면 건드리지 않는 게 낫다. Terraform 상태 파일 충돌 해결에서 가장 중요한 건 백엔드 종류가 아니라 팀의 작업 규칙이고, 그건 어떤 도구를 쓰든 달라지지 않는다.
관련 글
- Python pytest 테스트 자동화 — unittest에서 전환하며 깨달은 것들 – 커버리지 80%를 달성했는데 버그는 왜 안 줄었나. unittest에서 pytest로 전환하면서 겪은 시행착오와 fixture·mock·커…
- 쿠버네티스 비용 최적화
- GitHub Actions CI 비용 절감