Delete Markers and Noncurrent Versions
한국어 원문으로 표시합니다.
한 줄 요약
버저닝을 켜면 삭제는 지우는 것이 아니라 "지웠다는 표시(delete marker)" 를 하나 더 올리는 것이 됩니다. 원본은 그대로 남아 있고 요금도 계속 나옵니다.
왜 이게 필요했나
S3 는 오랫동안 최종 일관성이었습니다. 객체를 덮어쓰고 바로 읽으면 옛 값이 올 수 있었고, 이 때문에 많은 애플리케이션이 재시도 로직을 넣었습니다. 2020년 12월부터 AWS S3 는 강한 읽기-쓰기 일관성을 제공합니다. PUT 직후의 GET 이 새 값을 봅니다.
다만 이 보장의 범위를 정확히 알아야 합니다.
| 연산 | 지금의 보장 | 주의 |
|---|---|---|
| PUT 뒤 GET (새 키) | 강한 일관성 | — |
| PUT 뒤 GET (덮어쓰기) | 강한 일관성 | — |
| DELETE 뒤 GET | 강한 일관성 | 버저닝을 켜면 404 뒤에 데이터가 남아 있다 |
| LIST | 강한 일관성 | 다른 구현은 대개 여기서 약하다 |
| 다중 리전 복제 | 비동기 | 복제본은 언제든 뒤처질 수 있다 |
"S3 는 이제 강한 일관성" 이라는 문장을 모든 S3 호환 스토리지에 그대로 적용하면 안 됩니다. MinIO 는 강한 일관성이지만, Ceph RGW 는 설정에 따라 다르고, SeaweedFS 는 filer 구성에 따라 목록 조회가 뒤처질 수 있습니다. 이 랩의 저장소도 마찬가지입니다. 쓰는 코드가 어떤 보장 위에 서 있는지는 문서가 아니라 실측으로 확인합니다.
조건부 요청으로 경쟁을 막는다
강한 일관성이 있어도 두 프로세스가 같은 키에 동시에 쓰는 문제 는 남습니다. 나중에 쓴 것이 이기므로 앞의 갱신이 조용히 사라집니다. S3 는 이것을 조건부 헤더로 막습니다.
# 없을 때만 만든다 — 있으면 412 Precondition Failed
s3.put_object(Bucket=b, Key=k, Body=data, IfNoneMatch="*")
# 내가 읽은 그 버전일 때만 덮어쓴다(낙관적 잠금)
head = s3.head_object(Bucket=b, Key=k)
s3.put_object(Bucket=b, Key=k, Body=new, IfMatch=head["ETag"])
IfMatch 가 412 를 내면 그 사이에 누가 바꾼 것입니다. 다시 읽어 병합하고 재시도
합니다. 이 패턴이 없으면 "동시에 저장 눌렀더니 한 사람 것만 남는" 사고가 납니다.
버저닝은 다른 문제를 푼다
버저닝이 푸는 것은 일관성이 아니라 실수로 덮어쓰거나 지운 것을 되돌리는 문제 입니다.
버저닝을 켜면 같은 키에 쓸 때마다 새 버전 ID 가 생깁니다. 최신 것이 현행 버전이고 나머지는 비현행 버전입니다. 옛 버전은 버전 ID 로 언제든 읽을 수 있습니다.
삭제가 흥미롭습니다. 버전 ID 없이 DELETE 하면 실제로 지워지지 않고 delete marker 라는 특수한 버전이 맨 위에 올라갑니다. 그래서 보통 GET 은 404 를 받지만, 데이터는 그 아래 그대로 있습니다.
키 report.csv 의 버전 스택 (위가 최신)
delete marker (v4) ← 지금 GET 하면 404
────────────────────
2026-09-05 판 (v3) ← v4 를 지우면 이것이 다시 현행이 된다
2026-09-04 판 (v2)
2026-09-03 판 (v1)
delete marker 를 지우면 바로 아래 버전이 다시 현행이 됩니다. 이것이 실수 복구의 메커니즘입니다. 진짜로 지우려면 버전 ID 를 명시해 DELETE 해야 하고, 이 동작은 되돌릴 수 없습니다.
한 가지 더. 버저닝은 켤 수는 있어도 끌 수는 없습니다. Suspended 상태로만
바꿀 수 있고, 이미 쌓인 버전은 그대로 남습니다. Suspended 상태에서 쓴 객체는
버전 ID 가 null 인 특수 버전이 되어, 다음에 또 쓰면 그것을 덮어씁니다. 켜기 전에
수명주기 규칙을 먼저 정하는 이유입니다.
현장에서 만나는 모습
버저닝의 비용을 자주 놓칩니다. 로그 파일을 매일 같은 키에 덮어쓰는 코드에 버저닝을 켜면, 1년 뒤 그 키 하나에 365개 버전이 쌓여 있습니다. 목록에는 객체 하나로 보이는데 요금은 365배입니다. 그리고 "다 지웠는데 요금이 그대로" 인 상황도 delete marker 때문입니다.
숨은 비용이 하나 더 있습니다. 실패한 멀티파트 업로드입니다. 5GB 파일을 올리다
80% 에서 끊기면, 이미 올라간 조각들이 어디에도 보이지 않은 채 과금됩니다. 목록에도
안 나오고 버전 목록에도 안 나옵니다. list-multipart-uploads 로만 보입니다.
그래서 버저닝을 켤 때는 반드시 수명주기 규칙을 함께 겁니다. 세 규칙이 기본 세트 입니다.
{"Rules": [
{"ID": "expire-noncurrent", "Status": "Enabled", "Filter": {},
"NoncurrentVersionExpiration": {"NoncurrentDays": 30}},
{"ID": "clean-delete-markers", "Status": "Enabled", "Filter": {},
"Expiration": {"ExpiredObjectDeleteMarker": true}},
{"ID": "abort-incomplete-mpu", "Status": "Enabled", "Filter": {},
"AbortIncompleteMultipartUpload": {"DaysAfterInitiation": 7}}
]}
규칙은 하루 한 번 비동기로 도므로 즉시 반영되지 않습니다. 적용 다음 날 용량이 줄기 시작하면 정상입니다.
되돌릴 수 없는 삭제를 아예 막아야 하는 데이터라면 Object Lock 을 씁니다.
GOVERNANCE 모드는 특별 권한이 있으면 풀 수 있고, COMPLIANCE 모드는 루트
계정도 기간 안에는 못 지웁니다. 감사 대상 로그에는 후자를 씁니다.
다음 실습에서 할 것
버저닝을 켜고 같은 키에 세 번 써서 버전을 쌓고, 옛 버전을 읽고, 삭제 후 delete marker 를 확인하고, 그것을 제거해 복구하고, 비현행 버전이 차지하는 용량을 계산한 뒤, 수명주기 규칙을 적용합니다. 마지막으로 조건부 PUT 이 412 를 내는 것을 직접 확인합니다.