왜 상태 잠금 경합이 문제가 되는가
Terraform은 apply나 plan 시점에 상태 파일을 보호하기 위해 잠금(lock)을 건다. 문제는 팀 규모가 커지고 CI 파이프라인이 병렬로 돌기 시작하면서 발생한다. 여러 파이프라인이 동일한 상태에 접근하려 하면 뒤에 온 작업은 Error acquiring the state lock를 만나고 대기하거나 실패한다. 특히 모노레포에서 하나의 백엔드에 여러 워크스페이스를 몰아넣었거나, 상태 파일을 지나치게 크게 쪼개지 않은 경우 경합이 심해진다.
더 나쁜 경우는 잠금이 정상적으로 해제되지 않는 것이다. Runner가 OOM으로 죽거나 타임아웃으로 강제 종료되면 잠금 레코드가 남아 후속 작업이 전부 막힌다. 이때 force-unlock을 남발하면 두 apply가 동시에 상태를 덮어써 상태 손상으로 이어질 수 있다.
잠금 메커니즘의 내부 동작
S3 백엔드를 쓴다면 상태는 S3에, 잠금은 DynamoDB 테이블에 저장된다(Terraform 1.10 이전 기준). 잠금은 LockID를 파티션 키로 하는 조건부 쓰기(conditional put)로 구현되어 있어, 이미 레코드가 있으면 획득에 실패한다. GCS 백엔드는 객체 메타데이터 기반 원자적 생성으로 잠금을 처리한다. 핵심은 잠금 획득이 원자적 조건부 연산이라는 점이며, 이 연산의 스로틀링이나 지연이 경합 체감의 상당 부분을 차지한다.
백엔드 설정과 재시도 튜닝
가장 먼저 손댈 것은 백엔드 설정의 재시도와 잠금 대기다. Terraform은 -lock-timeout으로 잠금 획득을 폴링하며 대기한다. CI에서 짧은 apply가 줄지어 있다면 실패 대신 몇 분 대기하는 편이 안정적이다.
terraform {
backend "s3" {
bucket = "acme-tfstate"
key = "services/payments/terraform.tfstate"
region = "ap-northeast-2"
dynamodb_table = "tf-locks"
encrypt = true
# API 스로틀링 시 백엔드 자체 재시도 횟수
max_retries = 10
}
}
# apply 시 최대 5분까지 잠금 해제를 폴링하며 대기
terraform apply \
-lock-timeout=300s \
-input=false \
-auto-approve
-lock-timeout은 실패를 지연으로 바꿔 순간적 경합을 흡수한다. 다만 근본 원인이 상태 분리 부족이라면 대기만 늘어날 뿐이므로, 아래 상태 쪼개기와 병행해야 한다.
상태를 쪼개 경합 자체를 줄이기
튜닝보다 효과가 큰 것은 경합 표면을 줄이는 것이다. 배포 단위(서비스, 환경)별로 상태 키를 분리하면 서로 다른 파이프라인이 서로 다른 잠금을 잡으므로 경합이 사라진다. 아래는 상태 키 설계의 좋은 예와 나쁜 예다.
| 구분 | 상태 키 구조 | 경합 |
|---|---|---|
| 나쁨 | infra/terraform.tfstate (전 서비스 단일 상태) | 높음, 직렬화됨 |
| 좋음 | env/prod/payments/terraform.tfstate 처럼 환경·서비스별 분리 | 낮음, 독립 병렬 |
분리 후에도 공통 네트워크 같은 자원은 별도 상태로 두고 terraform_remote_state 데이터 소스로 읽기 전용 참조한다. 참조 상태는 잠금을 잡지 않으므로 경합에 기여하지 않는다.
CI 파이프라인에서의 동시성 제어
백엔드 잠금에만 의존하면 실패-재시도 루프가 생긴다. 파이프라인 레벨에서 같은 상태를 건드리는 잡을 직렬화하는 것이 깔끔하다. GitHub Actions라면 concurrency 그룹을 상태 키 단위로 잡는다.
concurrency:
group: tf-${{ github.workflow }}-${{ github.ref }}-payments
cancel-in-progress: false # apply 중 취소는 상태 손상 위험, 반드시 false
jobs:
apply:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: terraform init -input=false
- run: terraform apply -lock-timeout=300s -auto-approve -input=false
cancel-in-progress는 반드시 false로 둔다. apply 도중 취소되면 잠금이 남거나 상태가 반영 중간에 끊길 수 있다.
고아 잠금 진단과 안전한 해제
잠금이 남았을 때 무작정 force-unlock하지 말고 먼저 누가 잡았는지 확인한다. 에러 메시지에 ID, Who, Created가 표시된다. 실제 실행 중인 CI 잡이 없음을 확인한 뒤에만 해제한다.
# DynamoDB에 남은 잠금 레코드 직접 조회 (상태 키 확인용)
aws dynamodb get-item \
--table-name tf-locks \
--key '{"LockID":{"S":"acme-tfstate/services/payments/terraform.tfstate"}}'
# 해당 실행이 죽은 것이 확실할 때만 해제 (LOCK_ID는 에러 메시지의 ID)
terraform force-unlock -force "$LOCK_ID"
운영 시 주의점
첫째, -lock-timeout을 너무 길게 잡으면 진짜 데드락을 지연으로 감춘다. 경합 로그를 수집해 대기 시간 분포를 보고 조정한다. 둘째, DynamoDB 잠금 테이블은 온디맨드 과금으로 두면 트래픽 급증에도 스로틀링이 줄어 잠금 획득 실패가 감소한다. 셋째, Terraform 1.10+의 S3 네이티브 잠금(use_lockfile)을 검토하면 DynamoDB 의존을 없앨 수 있으나, 기존 파이프라인과의 호환성을 먼저 확인해야 한다. 마지막으로 상태 손상은 복구가 어렵다. S3 버전 관리를 켜두고, apply 전 상태 백업을 남기는 습관이 잠금 튜닝보다 더 큰 안전망이 된다.