Terraform 1.11에서 S3 backend에 use_lockfile = true 옵션이 GA로 들어온 뒤로, 팀에서도 슬슬 DynamoDB lock table을 걷어내자는 얘기가 나왔다. 이유는 단순했다. DynamoDB 테이블 하나 유지하려고 IAM 정책 관리, provisioned capacity 알람, TTL 스크립트까지 붙어 있는 게 웃긴 상황이었다. state 파일 하나 지키자고 별도 서비스를 하나 더 굴리는 셈이니까.
그런데 "S3 하나로 lock까지 다 처리한다"는 말이 처음엔 어색했다. S3는 오래도록 eventual consistency로 유명했던 스토리지고, DynamoDB의 conditional write처럼 원자적인 락을 걸 수 있는 프리미티브가 없다고 알고 있었기 때문이다. 이게 어떻게 동작하는지 궁금해서 문서와 소스 코드를 좀 뒤져봤다. 정리하다 보니 생각보다 깔끔한 설계였다.
S3 conditional write가 사실상의 락 프리미티브다
이게 핵심이다. 2024년 8월에 S3가 If-None-Match: * 헤더를 지원하기 시작했다. HTTP 스펙으로는 오래 있던 헤더인데, S3 PutObject가 이걸 받아들이게 된 게 얼마 안 됐다. 의미는 이렇다. "이 키에 오브젝트가 아직 없을 때만 쓰기를 수락하라." 이미 있으면 412 Precondition Failed를 돌려준다.
Terraform의 S3 backend는 이걸 그대로 활용한다. terraform plan이나 apply가 시작되면, backend는 state 파일 옆에 <state-key>.tflock이라는 오브젝트를 If-None-Match: *로 PUT한다. 두 CI 러너가 동시에 apply를 시도해도, S3는 두 요청 중 하나만 성공시키고 나머지는 412로 거절한다. 거절당한 쪽은 lock을 얻지 못한 걸로 판단해서 종료한다.
읽어보면 별거 아닌 것 같지만, 이 원자성 보장을 S3 오브젝트 스토리지에서 얻을 수 있게 된 게 이 기능의 전제 조건이다. 이전엔 DynamoDB의 ConditionExpression: attribute_not_exists(LockID)가 이 역할을 대신했다. 이제 그럴 필요가 없어진 거다.
Lock 오브젝트 안에는 뭐가 들어있나
궁금해서 실제로 apply 도중에 .tflock 오브젝트를 열어봤다. JSON 하나 들어있는데 대충 이런 모양이다.
{
"ID": "a3f8c2b1-4d5e-4f8a-9b2c-1e3d5f7a9b1c",
"Operation": "OperationTypeApply",
"Info": "",
"Who": "woogil@ci-runner-42",
"Version": "1.11.3",
"Created": "2026-07-08T14:22:17.482913Z",
"Path": "prod/vpc/terraform.tfstate"
}
DynamoDB에 넣던 아이템 구조랑 거의 같다. lock 획득에 실패한 쪽이 이 정보를 파싱해서 "누가, 언제, 어디서 락을 걸었는지" 사용자에게 보여줄 때 쓴다. terraform force-unlock <ID>의 그 ID가 여기 있는 값이다.
한 가지 눈여겨볼 점은 Path가 상대 경로로 들어간다는 거다. 여러 workspace를 쓰는 경우 lock 파일도 workspace 별로 분리되어 생성된다. env:/staging/prod/vpc/terraform.tfstate.tflock 이런 식으로. 그래서 dev, staging, prod 파이프라인이 병렬로 돌아도 서로 간섭이 없다.
Lock 해제는 그냥 DELETE다
Apply가 끝나면 backend는 그 .tflock 오브젝트를 지운다. 여기엔 아무 원자성 요구가 없다. 락을 가진 프로세스만 지우면 되니까. 특이한 건 삭제 시에도 If-Match: <etag> 조건을 붙인다는 점이다. 이건 상당히 방어적인 설계인데, 혹시 관리자가 콘솔에서 수동으로 lock을 지우고 다른 프로세스가 새 lock을 잡은 뒤에 원래 프로세스가 뒤늦게 자기 lock을 지우려 하는 케이스를 막는 목적이다. etag가 안 맞으면 삭제가 거절되고, 원래 프로세스는 "lock이 이미 내 것이 아니다"라는 걸 알게 된다.
이게 왜 중요하냐면, 이전 DynamoDB 방식에선 이런 안전장치가 없었다. DeleteItem에 ConditionExpression: LockID = :expected를 걸긴 했는데, 그게 걸린 상태로 잘못 쓰이는 사례를 몇 번 봤다. S3-native 방식은 etag가 자연스럽게 그 역할을 해준다.
Stale lock은 어떻게 처리하나
여기서부턴 좀 조심해야 하는 영역이다. CI 러너가 apply 도중에 죽어버리면 .tflock이 남는다. DynamoDB 시절에도 똑같은 문제였지만, 대응이 조금 다르다.
DynamoDB는 TTL을 걸어 오래된 락을 자동으로 청소하는 옵션이 있었다. S3에는 lock 파일용 TTL 같은 게 따로 없다. 대신 두 가지 방법이 있다.
첫째, terraform force-unlock <lock-id>를 실행한다. 이건 백엔드에 상관없이 동일하게 동작한다. Backend가 알아서 .tflock 오브젝트를 지운다.
둘째, 콘솔이나 CLI로 직접 aws s3 rm s3://bucket/path/state.tfstate.tflock을 실행한다. 정말 stale하다는 게 확실할 때만 써야 한다. Lock 파일 안의 timestamp를 반드시 확인해서, 정말 오래된 락인지 판단하고 지운다.
우리 팀에선 lifecycle 정책으로 .tflock 확장자 오브젝트만 12시간 지나면 자동 삭제되게 걸어뒀다. Apply가 12시간을 넘길 일이 사실상 없으니 안전 그물 정도로 생각하고 쓴다. 근데 이게 정답인지는 아직 확신이 없다. 팀원 중엔 "자동 삭제는 위험하다, 수동 개입이 낫다"는 의견도 있었다.
DynamoDB 대비 실제로 뭐가 좋아지나
세 가지가 눈에 띈다.
첫째, 인프라 구성이 단순해진다. state bucket 하나로 lock까지 끝난다. aws_dynamodb_table 리소스, IAM 정책의 dynamodb:PutItem, dynamodb:GetItem, dynamodb:DeleteItem 권한이 다 사라진다. 새 프로젝트 부트스트랩 시간이 눈에 띄게 짧아졌다.
둘째, 비용. DynamoDB pay-per-request로 써도 월 몇 달러 나오긴 했는데, 프로젝트가 많아지면 이게 쌓인다. S3는 어차피 state 저장용으로 쓰던 거라 lock으로 인한 추가 비용이 거의 없다. Lock 오브젝트가 작고 짧은 시간만 살아있어서 스토리지 비용은 무시할 수준이다.
셋째, 권한 모델 단순화. IAM에서 state bucket에 대한 s3:GetObject, s3:PutObject, s3:DeleteObject만 있으면 lock까지 다 처리된다. s3:PutObject에 이미 If-None-Match 조건이 포함되어 있어서 별도 권한도 필요 없다.
그래도 신경 써야 할 부분
이 방식이 만능은 아니다. 마이그레이션 시 몇 가지 걸리는 지점이 있었다.
우선 Terraform 버전 통일이 필수다. 팀원 A는 로컬에 1.10을 쓰고 있고, CI는 1.11로 올렸다면? A가 apply를 걸 때 DynamoDB만 보고 lock이 없다고 판단해서 밀어붙일 수 있다. 우리 팀은 .terraform-version을 강제하고 pre-commit 훅으로 체크한다.
또 하나. 기존 DynamoDB lock table을 즉시 지우면 안 된다. Backend config에서 dynamodb_table을 제거하고 use_lockfile = true로 바꾼 뒤에도, 아직 DynamoDB에 잡혀 있는 stale lock이 있을 수 있다. 우리는 2주 정도 병행 운영한 다음에 DynamoDB 테이블을 지웠다.
마지막으로, 여러 리전의 S3 bucket을 replicate하는 경우 lock 파일도 replicate되면 이상한 결과가 나올 수 있다. Cross-region replication 정책에서 .tflock 오브젝트는 제외하는 걸 권장한다. lock은 primary region에서만 의미가 있으니까.
정리하면, S3 conditional write라는 프리미티브 하나 생긴 걸로 Terraform 백엔드 구조가 상당히 깔끔해졌다. 마이그레이션 자체는 backend config 몇 줄 바꾸는 정도지만, 내부 동작을 이해하고 나니 stale lock 대응이나 lifecycle 정책 결정에서 훨씬 자신 있게 판단할 수 있게 됐다.
혹시 이미 마이그레이션 해보신 분들, lock lifecycle을 어떻게 관리하시는지 궁금하다.
'IT > IaC' 카테고리의 다른 글
| Terraform vs OpenTofu, 2026년 중반에 뭘 쓸까 (0) | 2026.07.06 |
|---|---|
| OpenTofu state 암호화, fallback 빼먹으면 일 난다 (0) | 2026.06.28 |
| Terraform S3 native lock, 안에서 무슨 일이 벌어지나 (0) | 2026.06.24 |
| Terraform 1.10 ephemeral resources로 시크릿 상태 노출 줄이기 (0) | 2026.06.19 |
| OpenTofu vs Terraform, 포크 1년 반 지난 시점에서 다시 본다 (0) | 2026.06.13 |