테라폼 S3 백엔드를 쓰는 팀이 잠금 때문에 정해야 할 값은 세 가지예요. 잠금을 무엇으로 걸지, 잠금을 못 잡았을 때 몇 분 기다릴지, 남은 잠금을 언제 강제로 풀지. 결론부터 말하면 새로 만드는 백엔드는 테라폼 1.11 이상에서 use_lockfile = true 만 켜고, DynamoDB 를 쓰던 곳은 둘을 함께 켠 기간을 거쳐 옮기며, CI 는 -lock-timeout 을 "가장 긴 apply × 앞에 설 수 있는 작업 수 + 30초" 정도로 주고, 사람이 치는 명령은 기본값 0 으로 두는 게 무난해요.
이 글은 동작 원리보다 값을 고르는 기준과 운영에서 볼 순서를 다뤄요. 근거는 Terraform S3 백엔드 문서, 테라폼 main 브랜치의 S3 백엔드 client.go와 statemgr/locker.go, Amazon S3 조건부 쓰기 문서이고, 시간·확률·비용은 조건을 밝힌 직접 계산이에요(2026-11-07 기준). plan 옵션과 상태 분리 기준은 테라폼 plan 옵션 고르는 법에서 따로 다뤘어요.
잠금 방식 판단표, use_lockfile·DynamoDB·이중 잠금 중 무엇을 켤까
S3 문서는 잠금이 선택 기능이고 DynamoDB 잠금은 지원 중단(deprecated)이라 향후 마이너 버전에서 빠진다고 적어요. 변경 기록을 보면 S3 자체 잠금은 1.10에 들어왔고 1.11에서 정식 기능이 되면서 DynamoDB 관련 인자가 지원 중단으로 바뀌었어요. 판단은 "지금 어디서 출발하느냐"로 갈려요.
| 지금 상황 | 설정 | 이유 |
|---|---|---|
| 새 백엔드, 모든 실행 환경이 1.11 이상 | use_lockfile = true 만 | 테이블·테이블 권한이 필요 없어요 |
| DynamoDB 잠금을 쓰는 중, 옛 버전 러너가 남음 | dynamodb_table 유지 + 러너 먼저 올리기 | 1.9 이하 러너는 use_lockfile 을 몰라요 |
| 모든 러너가 1.11 이상, 저장소·파이프라인이 여러 개 | use_lockfile + dynamodb_table 함께 | 반영이 덜 된 곳이 있어도 DynamoDB 에서 서로 막혀요 |
| 이중 잠금으로 몇 주 돌았고 DynamoDB 만 쓰는 설정이 없음 | dynamodb_table 제거 | 잠금 한 곳, 권한 한 곳으로 줄어요 |
| 잠금 설정이 아예 없음 | 오늘 use_lockfile = true 추가 | 지금은 동시 apply 를 아무것도 막지 않아요 |
| S3 호환 저장소(MinIO 등 자체 운영) | 조건부 쓰기 지원부터 시험 | If-None-Match 를 무시하는 저장소면 잠금이 겉으로만 돼요 |
S3 잠금은 .tflock 객체를 If-None-Match: * 조건으로 올리는 방식이라, 저장소가 이 헤더를 지키지 않으면 두 작업이 모두 잠금에 성공해요. 도입 전에 AWS CLI 의 put-object --if-none-match 옵션으로 같은 키에 두 번 올려 보고, 두 번째가 412(Precondition Failed)로 실패하는지 확인해요. 두 번째도 성공하면 그 저장소에서는 S3 잠금을 믿으면 안 돼요.
echo '{}' > /tmp/probe.json
aws s3api put-object --bucket mycorp-tfstate --key probe.tflock \
--body /tmp/probe.json --if-none-match '*' # 첫 번째: 성공해야 함
aws s3api put-object --bucket mycorp-tfstate --key probe.tflock \
--body /tmp/probe.json --if-none-match '*' # 두 번째: 412 가 나와야 함
aws s3api delete-object --bucket mycorp-tfstate --key probe.tflock
버킷 버전 관리를 켠 버킷에서도 잠금은 문제없어요. 조건부 쓰기 문서에 따르면 현재 버전이 삭제 마커면 "객체 없음"으로 보고 쓰기를 허용해요. 대신 잠금을 한 번 걸고 풀 때마다 .tflock 의 옛 버전과 삭제 마커가 하나씩 쌓여요. 하루 200번이면 1년에 7만 3천 개지만 잠금 JSON 이 200바이트 남짓이라 합쳐도 약 18MB 예요.
DynamoDB 에서 use_lockfile 로 옮기는 3단계
소스의 Lock 함수를 보면 이중 잠금에서는 .tflock 을 먼저 잡고 DynamoDB 를 나중에 잡으며, DynamoDB 쪽이 실패하면 방금 만든 .tflock 을 지우고 실패해요. 그래서 2단계 동안 잠금은 "둘 다 잡아야 성공"이에요. 단계별로 확인할 것을 정리하면 이래요.
- 러너 버전 확인: CI 이미지, 개발자 노트북, 스케줄 잡, 다른 저장소의 원격 상태 소비자까지
terraform version을 모아요.required_version = ">= 1.11"을 먼저 넣어 두면 옛 버전 러너가 init 단계에서 바로 실패해서 빠뜨린 곳을 찾기 쉬워요. - 권한 먼저, 설정 나중:
.tflock에s3:GetObject·s3:PutObject·s3:DeleteObject를 준 정책을 먼저 배포하고, 그다음use_lockfile = true를 넣어요. 순서가 바뀌면 첫 실행부터 잠금 단계에서 AccessDenied 가 나요. terraform init -reconfigure: 백엔드 인자가 바뀌면 다시 초기화해야 해요. 버킷과 키는 그대로라 상태를 옮길 필요는 없어요.- 2단계 유지 기간: 저장소·파이프라인 목록이 전부 새 설정으로 바뀐 것을 확인할 때까지예요. 2단계에서는 새 설정도 DynamoDB 에 쓰기 때문에 테이블 쓰기 지표로는 남은 곳을 구분할 수 없어요. 대신 저장소 전체에서
dynamodb_table이 있고use_lockfile이 없는 백엔드 블록을 찾아요. - 3단계 뒤 테이블 삭제:
dynamodb_table을 뺀 뒤 일정 기간 테이블 쓰기가 0 인지 보고 지워요. 테이블에는 잠금 항목 말고도 상태의 MD5 를 담은-md5항목이 남아 있지만, S3 잠금만 쓰면 더 읽지 않아요.
이중 잠금 해제는 S3 와 DynamoDB 를 둘 다 시도하고, 한쪽만 실패해도 오류를 돌려줘요. 2단계에서 .tflock Delete 권한이 빠졌다면 DynamoDB 잠금은 풀리는데 .tflock 만 남는 상황이 생기니, 이전 첫날 실행 로그에 failed to unlock S3 가 없는지 봐요.
-lock-timeout 값 고르는 법, 재시도 간격으로 계산하기
-lock-timeout 의 기본값은 0초라 잠금을 못 잡으면 바로 실패해요. 값을 주면 LockWithContext 가 첫 대기 1초에서 시작해 실패할 때마다 두 배로 늘리고 16초에서 멈춰요. 첫 시도부터 따지면 0·1·3·7·15·31·47·63초… 에 다시 시도해요. 재시도는 상대 잠금 정보가 담긴 잠금 오류일 때만 하고, 권한 오류는 기다리지 않고 끝나요.
이 간격 때문에 생기는 숫자가 하나 더 있어요. 앞 작업이 잠금을 푼 순간 바로 잡는 게 아니라 다음 재시도 때 잡아요. 대기가 31초를 넘긴 뒤라면 풀린 시점부터 최대 16초, 평균 8초쯤 더 기다려요. 로컬 백엔드(테라폼 1.9.8, 재시도 함수는 S3 와 같은 코드)로 40초 걸리는 apply 를 돌리고 3초 뒤 plan -lock-timeout=120s 를 띄웠더니, apply 는 40.4초에 끝났고 plan 은 50.3초에 끝났어요. 3초에 시작한 plan 의 재시도 시각이 3+47=50초라 계산과 맞아요.
그래서 값은 이렇게 잡아요.
| 실행 주체 | 권장 값 | 이유 |
|---|---|---|
| 사람이 직접 치는 plan·apply | 0(기본) | 누가 잡고 있는지 바로 보고 판단하는 게 나아요 |
| PR 마다 도는 plan | 2~5분 | 같은 상태의 apply 하나가 끝날 정도만 기다려요 |
| main 머지 후 apply | 가장 긴 apply × 앞에 설 수 있는 작업 수 + 30초 | 줄 선 작업이 다 끝나고 재시도 간격까지 넣은 값 |
| 야간 드리프트 점검 | 10~15분 | 급하지 않으니 여유 있게, 대신 CI 잡 시간 한도 안에서 |
예를 들어 가장 긴 apply 가 6분이고 동시에 두 작업이 앞에 설 수 있으면 6×2 = 12분에 30초를 더해 12분 30초예요. 이때 CI 잡의 시간 한도는 잠금 대기 + 자기 apply 시간보다 커야 해요. 잡 한도가 먼저 와서 러너가 강제 종료되면, 기다리던 작업은 잠금을 못 잡은 채 끝나니 괜찮지만 이미 잠금을 쥐고 apply 하던 작업이 잘리면 잠금이 남아요.
줄이 자주 길어지면 CI 에서 직렬화하는 게 깔끔해요. GitHub Actions 라면 concurrency 그룹을 상태 키 단위로 걸어 같은 상태를 쓰는 잡이 동시에 시작하지 않게 해요.
잠금 충돌 확률, 상태를 나눌 때인지 숫자로 보기
같은 상태 파일에 잠금을 거는 작업(plan 포함)이 시간당 λ번 무작위로 시작하고 한 번에 d분 걸린다면, 내 작업이 도는 동안 다른 작업이 들어와 막힐 확률은 대략 1 − e^(−λ × d ÷ 60)이에요.
시간당 6번, 한 번에 10분이면 셋 중 둘(63.2%)은 누군가를 기다려요. 이 정도면 타임아웃을 늘려 버티기보다 상태 파일을 쪼갤 신호예요. 잠금 단위가 상태 파일 하나라서, 워크스페이스나 키가 다르면 서로 막지 않아요. 네트워크처럼 거의 안 바뀌는 것과 하루에도 여러 번 바뀌는 앱 리소스를 나누면 λ 가 각각 줄어요. 작업 길이(d) 자체를 줄이는 방법은 테라폼 parallelism 값 고르는 법에서 다뤘어요.
비용과 한계, DynamoDB 를 빼도 돈은 거의 안 바뀌어요
AWS 가격 API 의 서울 리전(ap-northeast-2) 값(2026-11-04 게시)으로 실행 1만 번을 계산해 봤어요. S3 는 PUT 1,000건당 0.0045달러, GET 1만 건당 0.0035달러, DynamoDB 온디맨드는 쓰기 요청 단위 100만 개당 0.68달러, 읽기 100만 개당 0.1355달러예요. 실제 요금표는 Amazon S3 요금 페이지에서 볼 수 있어요.
S3 잠금 쪽이 조금 더 나오는 건 S3 PUT 단가가 DynamoDB 쓰기 단가보다 비싸서인데, 차이는 실행 1만 번에 3센트도 안 돼요. apply 가 상태를 여러 번 쓰거나 잠금 재시도가 늘어도 자릿수는 그대로예요. DynamoDB 를 빼서 얻는 건 돈이 아니라 테이블 하나, IAM 문장 하나, 계정마다 반복하던 부트스트랩 코드가 사라지는 거예요.
반대로 S3 잠금이 해 주지 않는 일도 알아 둬요.
- 만료 없음: 잠금 객체에는 TTL 이나 하트비트가 없어요. 레디스 락처럼 시간이 지나면 풀리는 구조가 아니라서, 레디스 분산 락 고르는 법에서 다룬 "TTL 이 끝났는데 작업은 계속 도는" 문제는 없는 대신, 죽은 작업의 잠금은 사람이 풀어야 해요.
- 펜싱 없음: 강제로 풀린 뒤 살아 돌아온 옛 작업이 상태를 쓰는 걸 막는 토큰은 없어요. 그래서
force-unlock전에 작업이 진짜 죽었는지 확인하는 절차가 대신 그 역할을 해요. - 저장된 plan 은 따로: 잠금은 명령이 도는 동안만 걸려요. plan 을 저장하고 리뷰하는 사이 상태가 바뀌면 apply 단계에서 "Saved plan is stale" 로 거부되는데, 이건 잠금이 아니라 상태 serial 비교가 막는 거예요.
Error acquiring the state lock 확인 순서
1. 오류에 Lock Info 가 있는지 봐요. ID·Path·Operation·Who·Created 가 찍혔다면 진짜 잠금 충돌이에요. 없이 AccessDenied 만 있다면 .tflock 경로에 Put 권한이 없거나, kms_key_id 를 쓰는 백엔드에서 KMS 권한이 빠진 경우예요. 소스를 보면 잠금 파일도 상태와 같은 KMS 키로 암호화해 올려서, 문서대로 kms:Encrypt·kms:Decrypt·kms:GenerateDataKey 가 필요해요.
2. Who 가 매번 나 자신이면 해제 실패예요. apply 는 성공했는데 다음 실행부터 같은 러너 이름의 잠금에 걸린다면 .tflock 의 s3:DeleteObject 권한을 의심해요. 상태 파일 ARN 만 허용하는 정책을 그대로 쓰면 이렇게 돼요. 해제는 .tflock 을 GetObject 로 읽어 ID 를 비교한 뒤 DeleteObject 로 지우는 순서라 Get 권한도 같이 필요해요.
3. Created 와 Who 로 작업이 살아 있는지 확인해요. Created 는 UTC 라서 한국 시각으로 볼 때는 9시간을 더해요. 헷갈리면 타임스탬프 변환기로 바꿔 보고, .tflock 내용 전체는 aws s3 cp s3://버킷/키.tflock - 로 받아 JSON 포매터로 펼쳐 봐요. CI 러너 이름이면 그 잡의 상태를, 사람 이름이면 당사자에게 물어요.
4. 살아 있으면 기다려요. -lock-timeout 을 주고 다시 돌리거나 CI 큐에 맡겨요. "오래 걸리는 것 같다"는 이유로 푸는 게 가장 흔한 사고예요.
5. 죽은 게 확실하면 terraform force-unlock <ID> 를 쓰고 plan 부터 돌려요. force-unlock 문서대로 인프라는 건드리지 않고 잠금만 지워요. Operation 이 Apply 였다면 상태를 쓰다 죽었을 수 있으니, 바로 apply 하지 말고 plan 결과로 상태와 실제가 맞는지 먼저 봐요. 버킷 버전 관리가 켜져 있으면 상태 파일의 직전 버전을 비교할 수 있어요.
6. lock ID ... does not match 가 나면 손을 멈춰요. 내가 가진 ID 와 저장된 잠금의 ID 가 다르다는 뜻이라, 내가 쥔 잠금을 누가 force-unlock 하고 다른 작업이 새로 잡은 경우가 많아요. 지금 상태를 두 작업이 다 썼을 수 있으니 양쪽 로그를 맞춰 봐요.
죽은 잠금을 미리 찾아 두고 싶다면 하루 한 번 .tflock 목록을 훑는 점검을 걸 수 있어요. aws s3api list-objects-v2 --bucket mycorp-tfstate --query "Contents[?ends_with(Key, '.tflock')].[Key,LastModified]" 로 뽑아서, 가장 긴 apply 시간보다 오래된 것만 알리면 돼요.
도입 전에 따질 것
- 잠금 설정 존재 여부: 저장소마다
backend "s3"블록에use_lockfile이나dynamodb_table이 있는지 봐요. 꺼져 있어도 아무 메시지가 없어요. - 버전·권한·암호화: 1.11 미만 러너가 남으면 3단계로 못 가요. IAM 을 상태 키마다 적는다면
.tflockARN 도 짝으로 적고,kms_key_id를 쓰면 같은 KMS 권한을 줘요. - S3 호환 저장소: 412 시험을 통과하지 못하면 S3 잠금을 쓰지 말아요.
- 줄 서는 정도: 충돌 확률이 30% 를 넘는 상태라면 타임아웃 값보다 상태 분리를 먼저 계획해요.
PR plan 용 읽기 전용 권한과 -lock=false
PR plan 역할을 읽기 전용으로 두고 싶어도, plan 도 잠금을 잡기 때문에 .tflock 에 Put·Delete 가 없으면 plan 이 잠금 단계에서 실패해요. 선택지는 둘이에요.
| 방법 | 장점 | 대가 |
|---|---|---|
plan 역할에 .tflock Get·Put·Delete 만 추가 | 상태 파일과 리소스는 계속 읽기 전용 | plan 이 apply 와 줄을 서서 PR 피드백이 느려질 수 있어요 |
plan 에 -lock=false | 잠금 권한이 아예 필요 없음 | apply 와 동시에 돌면 쓰는 중인 상태를 읽어 결과가 어긋날 수 있어요 |
PR plan 은 결과를 그대로 적용하지 않는 미리보기라면 -lock=false 도 받아들일 만해요. 다만 plan 결과를 저장해 apply 로 넘기는 파이프라인이라면 잠금을 켜요. apply 에 -lock=false 를 쓰는 건 어떤 경우에도 권하지 않아요.
자주 묻는 질문
use_lockfile 을 안 켜면 S3 백엔드는 잠금을 안 거나요?
네. use_lockfile 의 기본값은 false 이고, dynamodb_table 도 없으면 S3 백엔드의 Lock 은 아무것도 하지 않고 성공을 돌려줘요. 오류나 경고가 없으니 설정 파일을 직접 확인해야 해요.
DynamoDB 잠금은 언제까지 쓸 수 있나요?
문서는 지원 중단 상태이고 향후 마이너 버전에서 제거된다고만 적고 있어요. 제거 버전이 정해지면 변경 기록에 나오니, 그 전에 이중 잠금 단계를 거쳐 옮겨 두는 게 안전해요.
-lock-timeout 을 아주 길게 주면 문제가 있나요?
잠금이 죽은 작업의 것이면 그만큼 헛되이 기다려요. S3 잠금은 만료가 없어서 시간이 지나도 풀리지 않아요. CI 잡 시간 한도 안에서, 가장 긴 apply 와 줄 선 작업 수로 계산한 값만 주세요.
force-unlock 에 넣을 ID 는 어디서 보나요?
잠금 오류 메시지의 Lock Info 아래 ID 줄이에요. .tflock 객체 본문의 ID 값과 같아요. 확인 질문 없이 풀려면 -force 를 붙이지만, 사람이 칠 때는 빼는 편이 안전해요.
워크스페이스를 나누면 잠금도 나뉘나요?
네. 잠금 파일은 상태 경로 뒤에 .tflock 을 붙인 객체라 워크스페이스나 키가 다르면 서로 막지 않아요. 잠금 대기가 잦다면 상태를 나누는 게 근본적인 해결이에요.
정리
- 잠금 설정이 없는 S3 백엔드는 조용히 잠금 없이 돌아요. 새 백엔드는 1.11 이상에서
use_lockfile = true만 켜요. - DynamoDB 에서 옮길 때는 버전 통일 → 이중 잠금 → DynamoDB 제거 순서로, 권한을 설정보다 먼저 배포해요.
-lock-timeout은 사람 0, CI 는 "가장 긴 apply × 앞에 설 작업 수 + 30초". 재시도가 16초 간격이라 풀린 뒤에도 최대 16초를 더 기다려요.- 잠금 오류는 Lock Info 유무 → Who → 생존 여부 → force-unlock 후 plan 순서로 봐요.
.tflock에는 Get·Put·Delete 와 KMS 권한이 필요해요. - 요청 비용은 두 방식 모두 실행 1만 번에 10센트 안팎이라, 고르는 기준은 관리할 리소스와 줄 서는 정도예요.
더 깊이 읽기
이 글은 실무에서 판단할 때 필요한 것 위주로 정리했어요. 같은 주제의 내부 동작 원리는 devboi 기술 블로그의 테라폼 상태 잠금 원리 — S3 use_lockfile·조건부 쓰기에서 도식과 코드로 단계별로 풀었어요.