DynamoDB 없이 S3 하나로 — OpenTofu 1.10 네이티브 락이 state 동시성을 지키는 방식
Terraform이나 OpenTofu로 인프라를 관리하다 보면 한 번쯤은 마주치는 장면이 있습니다. CI/CD 파이프라인에서 두 개의 잡이 거의 동시에 apply를 실행했는데, DynamoDB 락 테이블이 멀쩡한데도 어딘가 꼬이는 느낌. 또는 반대로, 단순한 개인 프로젝트인데 DynamoDB 테이블을 관리하고, IAM 정책을 두 서비스에 나눠 작성하고, 백업 설정까지 신경 써야 한다는 부담감. 솔직히 "state 잠금 하나를 위해 이렇게까지 해야 하나?" 싶을 때가 있습니다.
OpenTofu 1.10부터는 이 구조가 바뀌었습니다. AWS S3가 2024년 8월에 조건부 쓰기(conditional write) 기능을 출시하면서 S3 단독으로 원자적 락을 구현할 수 있는 기술적 토대가 마련됐고, OpenTofu 팀은 2025년 2월 RFC를 채택해 use_lockfile = true 파라미터를 1.10에서 정식 지원했습니다. 더 이상 DynamoDB 테이블이 필요하지 않습니다.
이 글에서는 왜 이 변화가 가능해졌는지 원리를 짚고, 실제로 기존 DynamoDB 락을 S3 네이티브 락으로 이전하는 과정을 살펴봅니다. 버전 혼재 환경에서 실제로 어떤 순서로 레이스 컨디션이 터지는지, 그리고 스테일 락을 어떻게 식별하고 해제하는지 같은 현실적인 함정도 함께 다룹니다.
DynamoDB가 필요했던 진짜 이유, 그리고 S3가 바꾼 것
S3만으로는 왜 안 됐을까
과거에 DynamoDB를 락 조율자로 사용한 건 설계 결함이 아니라 당시 S3의 한계 때문이었습니다. S3는 오브젝트 스토리지로서 "이 파일이 존재하지 않을 때만 쓰기를 허용해줘"라는 원자적 조건부 쓰기를 지원하지 않았습니다. 두 프로세스가 동시에 락 파일 존재 여부를 확인하고 쓰기를 시도하면 둘 다 성공해버리는 TOCTOU(Time-of-Check-Time-of-Use) 문제가 발생합니다.
DynamoDB는 이 문제를 ConditionalExpression으로 해결했습니다. attribute_not_exists(LockID) 조건을 붙인 PutItem은 원자적으로 실행되기 때문에 두 프로세스 중 하나만 성공합니다. S3의 저장 기능과 DynamoDB의 원자성을 조합한 구조였던 셈입니다.
2024년 8월, S3가 달라졌다
AWS는 2024년 8월 S3에 조건부 쓰기 기능을 출시했습니다. HTTP 헤더 If-None-Match: *를 포함한 PUT 요청은 해당 오브젝트가 존재하지 않을 때만 성공하고, 이미 존재하면 412 Precondition Failed를 반환합니다. 이 동작이 원자적으로 보장되기 때문에 이제 S3 혼자서 락의 역할을 수행할 수 있습니다.
OpenTofu가 tofu apply를 시작하면 state 파일과 같은 경로에 .tflock 확장자 파일을 조건부 쓰기로 생성하려 시도합니다. production/terraform.tfstate라면 락 파일은 production/terraform.tfstate.tflock입니다. 동시에 다른 프로세스가 같은 시도를 하면 S3가 두 번째 쓰기를 거부하고, OpenTofu는 "Error: state already locked"를 반환합니다.
이전 작업 전에 확인해야 할 것들
버전 요구사항
use_lockfile 파라미터는 OpenTofu 1.10 이상에서만 지원됩니다(2026년 9월 기준). HashiCorp Terraform에도 동일한 파라미터가 도입되었다는 이야기가 커뮤니티 글에 등장하지만, OpenTofu와 Terraform은 라이선스 분기 이후 독립적으로 발전해 왔으므로 Terraform을 병행하는 팀이라면 반드시 HashiCorp 공식 changelog에서 직접 확인하는 것이 좋습니다. 이 글의 설명은 OpenTofu 1.10 기준입니다.
팀 전체, 그리고 CI/CD 파이프라인의 모든 실행 환경이 1.10 이상인지 먼저 확인하는 것이 중요합니다. 이걸 빠뜨리고 전환하면 구버전 클라이언트는 S3 락 파일을 인식하지 못해서 동시 실행 보호가 절반만 동작합니다.
S3 버킷 정책 점검
조건부 쓰기는 별도의 API가 아니라 기존 s3:PutObject 권한 안에서 동작합니다. 다만 .tflock 파일 경로에 대해 s3:PutObject와 s3:DeleteObject가 허용되어 있어야 합니다. MinIO나 OVHcloud 같은 S3 호환 스토리지를 사용 중이라면 해당 구현이 조건부 쓰기(If-None-Match)를 지원하는 버전인지 확인하는 것이 좋습니다.
{
"Effect": "Allow",
"Action": [
"s3:GetObject",
"s3:PutObject",
"s3:DeleteObject",
"s3:ListBucket"
],
"Resource": [
"arn:aws:s3:::my-state-bucket",
"arn:aws:s3:::my-state-bucket/*"
]
}기존에 DynamoDB 방식을 사용하고 있었다면 IAM 정책에서 dynamodb:GetItem, dynamodb:PutItem, dynamodb:DeleteItem 같은 권한 블록 전체를 제거할 수 있습니다.
이전 경로 — 팀 규모에 따라 다른 접근
이전 경로는 환경에 따라 두 가지로 나뉩니다.
이중 락 전환이 왜 안전한가
여러 개발자나 CI/CD 러너가 서로 다른 버전의 설정을 동시에 사용할 수 있는 환경이라면, 두 파라미터를 잠시 함께 켜두는 중간 단계를 거치는 것이 안전합니다. 여기서 핵심은 OpenTofu 1.10 클라이언트가 use_lockfile = true와 dynamodb_table이 함께 설정된 백엔드를 보면 두 락을 모두 획득한다는 점입니다.
즉, 신버전 클라이언트가 DynamoDB 락도 함께 점유하기 때문에 구버전 클라이언트가 그 상황을 감지할 수 있습니다. 그림으로 보면 이렇습니다.
만약 이중 락 단계를 건너뛰고 곧바로 DynamoDB를 제거해버리면, 아직 업그레이드되지 않은 1.9 클라이언트는 use_lockfile이라는 파라미터 자체를 모르기 때문에 S3 락 파일 존재 여부와 상관없이 그냥 state를 읽고 씁니다. 반대편의 1.10 클라이언트는 .tflock을 잘 만들어놓고 안심하지만, 두 클라이언트 사이에는 어떤 조율도 존재하지 않아 그대로 레이스 컨디션이 성립합니다. 양쪽이 공통으로 알아보는 락 매체가 사라지는 것이 이 시나리오의 본질입니다.
1단계 — 이중 락 활성화
terraform {
backend "s3" {
bucket = "my-state-bucket"
key = "prod/tofu.tfstate"
region = "us-east-1"
use_lockfile = true
dynamodb_table = "tf-lock-table"
}
}이 상태에서 팀의 모든 로컬 환경과 CI/CD 러너의 OpenTofu 버전을 1.10 이상으로 업데이트합니다. 실행 로그에서 두 락이 모두 획득되는지 확인해두면 이후 단계가 안전해집니다.
2단계 — DynamoDB 파라미터 제거
terraform {
backend "s3" {
bucket = "my-state-bucket"
key = "prod/tofu.tfstate"
region = "us-east-1"
use_lockfile = true
}
}설정 변경 후 백엔드를 재초기화합니다.
tofu init -reconfigure3단계 — DynamoDB 테이블 삭제 전 최종 확인
테이블을 바로 삭제하기 전에 state가 정상적으로 접근되는지 한 번 더 확인해보면 좋습니다.
tofu state list
tofu plan이상이 없다면 DynamoDB 테이블 리소스를 제거합니다. Terraform/OpenTofu로 테이블을 관리하고 있었다면 state에서 해당 리소스를 제거하고 실제 테이블을 삭제합니다.
스테일 락을 어떻게 찾고 해제할 것인가
프로세스가 비정상 종료되면 .tflock 파일이 S3에 남아있을 수 있습니다. 다음 실행에서 에러 메시지에 락 ID가 함께 출력되면 그 값을 그대로 사용하면 됩니다.
tofu force-unlock <lock-id>문제는 프로세스가 조용히 죽었거나, 오래된 잡이 남긴 락을 정리해야 하는 상황입니다. 이럴 때는 S3에서 락 파일을 직접 찾아서 내용을 열어보는 것이 빠릅니다.
aws s3 ls s3://my-state-bucket/ --recursive | grep '\.tflock$'
aws s3 cp s3://my-state-bucket/prod/tofu.tfstate.tflock - | jq.tflock 파일 내용은 JSON 형태로 ID, Who, Created 같은 필드를 포함합니다. 여기서 락 ID를 확인한 뒤 force-unlock으로 해제하거나, 락 소유자가 확실히 사라진 상황이라면 파일 자체를 삭제해도 됩니다. 어느 쪽이든 실행 전에 정말로 활성 프로세스가 없는지 확인하는 것이 중요합니다.
트레이드오프 — 기대할 것과 주의할 것
| 항목 | 내용 |
|---|---|
| 인프라 단순화 | S3 하나로 저장과 락을 모두 처리, 멀티 서비스 의존 제거 |
| 비용 | DynamoDB On-Demand 요금은 팀 규모와 apply 빈도에 따라 다르지만 락 테이블 자체는 대개 미미한 수준. 극적인 절감보다는 관리 대상 축소가 실질적 이득 |
| IAM 단순화 | DynamoDB 관련 권한 블록 전체 제거 가능 |
| 관리 오버헤드 감소 | DynamoDB 테이블 모니터링, 백업, 용량 관리 불필요 |
| 레이턴시 | 단일 서비스 호출로 크로스 서비스 호출 대비 지연 감소 |
주의해야 할 부분은 다음과 같습니다.
이중 락 전환 기간 관리 — 앞서 설명한 대로 1.9 클라이언트가 남아 있는 상태에서 DynamoDB를 먼저 제거하면 조율 매체가 사라집니다. 전환 기간을 가능한 짧게 유지하고, 업그레이드 완료를 어떻게 검증할지 미리 정해두는 것이 좋습니다.
S3 호환 스토리지 주의 — MinIO, OVHcloud 같은 S3 호환 스토리지는 조건부 쓰기 지원 여부를 따로 확인해야 합니다. AWS S3 자체는 2024년 8월 이후 모든 리전에서 지원됩니다.
S3 Object Lock과 혼동 금지 — 이름이 비슷해서 헷갈리기 쉬운데, S3 Object Lock은 WORM(Write Once Read Many) 규정 준수를 위한 기능입니다. state locking과는 목적이 완전히 다릅니다. 여기서 다루는 네이티브 락은 S3 Conditional Write 기반입니다.
Terragrunt와 Atlantis에서의 동작
Terragrunt를 사용 중이라면 remote_state 블록의 config에서 use_lockfile을 여느 백엔드 파라미터처럼 지정할 수 있습니다. Terragrunt 자체가 S3 백엔드를 프록시할 뿐이므로, 렌더링된 백엔드 설정이 그대로 OpenTofu에 전달됩니다. 다만 Terragrunt 버전에 따라 특정 파라미터를 필터링하는 로직이 있을 수 있으므로 실행 로그에서 실제로 전달되는지 확인하는 것이 안전합니다.
Atlantis는 별도의 특별한 지원 없이 자연스럽게 동작합니다. Atlantis가 락을 관리하는 것이 아니라, Atlantis가 실행하는 OpenTofu 1.10 이상 바이너리가 백엔드 설정의 use_lockfile = true를 보고 스스로 S3 락을 사용하는 구조입니다. 따라서 "Atlantis가 자동으로 활용한다"기보다는 "백엔드 설정만 잘 되어 있으면 Atlantis 실행 흐름과 충돌하지 않는다"가 더 정확한 표현입니다.
지금 시점에서의 판단
2026년 현재 커뮤니티의 합의는 인프라 단순화 방향으로 형성되고 있고, 관리형 플랫폼 중에도 자사 고객 인프라의 DynamoDB 락 테이블을 공식적으로 제거하기 시작한 곳이 나오고 있습니다. S3 하나로 저장과 락을 모두 처리하는 구조가 새로운 기본값이 되어가는 흐름입니다.
물론 레거시 환경이나 S3 호환 스토리지를 사용하는 경우, 또는 팀 내 OpenTofu 버전 업그레이드가 아직 진행 중이라면 당장 전환을 서두를 필요는 없습니다. 하지만 신규 프로젝트라면 처음부터 use_lockfile = true로 시작하는 것이 자연스러운 선택입니다.
DynamoDB 테이블 하나를 지운다고 세상이 바뀌지는 않지만, "state 잠금 하나를 위해 서비스 두 개를 관리해야 하는" 그 미묘한 부담이 사라지는 건 생각보다 꽤 후련한 일입니다.