🔥 버전 관리와 삭제 마커: 실수로부터 복구
강의 목차

콘솔에서 documents 버킷을 열고 Properties의 Bucket Versioning을 Enable로 한 번 눌러 본 적이 있다. 토글이 살짝 굴러가더니 상단에 작은 라벨 하나가 붙었다. 그 상태에서 내가 같은 키 contract.pdf를 두 번 연달아 업로드했다. 객체 목록은 여전히 한 줄로 보였는데, Show versions를 켰더니 같은 키 위에 두 줄이 더 늘어났다. 다음 달 청구서가 도착했을 때, 같은 파일이 1MB 두 번으로 잡혀 있었다. 한 줄짜리 토글이 DELETE라는 동사의 의미를 통째로 바꾼 셈이다.
S3란 무엇인가: 오브젝트 스토리지의 구조에서 11 9's durability를 물리적으로 보장한다고 한 줄을 두었고, 버킷과 키: 플랫 네임스페이스의 의미에서 슬래시는 디렉토리가 아니라 키 한 줄 안의 문자라는 명제를 두었다. 같은 평면 위에 시간 축을 한 겹 더 얹는 단계가 오늘이다. 객체가 사라지지 않고 쌓이고, DELETE가 지우는 동사에서 흐리는 동사로 바뀐다. 그 변화가 비용·복원·거버넌스 세 곳에서 어떤 결정을 강제하는지를 따라가 본다.
버전이 켜지면 PUT과 DELETE가 다르게 동작한다
버전 관리가 켜진 버킷에서 같은 키에 PUT을 두 번 보냈을 때, 공식 문서는 한 줄로 끝낸다.
"After versioning is enabled for a bucket, if Amazon S3 receives multiple write requests for the same object simultaneously, it stores all of those objects."
같은 키 위로 PUT이 들어올 때마다 S3가 고유한 VersionId를 새로 발급하고 그 버전을 한 줄로 쌓는다. 첫 PUT이 v1, 두 번째 PUT이 v2, 세 번째가 v3. 덮어쓰기가 사라지고 추가만 남는다는 뜻이다. 만약 v1을 다시 읽고 싶으면 GetObject에 VersionId=v1을 명시해서 부르고, 명시 없이 부르면 가장 최근의 current version이 돌아온다.

DELETE는 더 이상하게 동작한다. 버전 ID 없이 보낸 단순 DELETE Object는 객체를 지우는 게 아니라 delete marker라는 한 줄을 새 버전으로 추가한다. 공식 문서의 정의는 정확히 이렇다.
"If you delete an object, Amazon S3 inserts a delete marker instead of removing the object permanently. The delete marker becomes the current object version."
delete marker는 데이터가 없는 placeholder다. AWS 공식 문서가 명시하는 건 두 가지. 데이터가 없고 ACL이 매달리지 않는다는 점. 저장소 청구는 키 이름 UTF-8 바이트 분만 S3 Standard 요율로 따로 잡는다. 한 마커당 수십 바이트라 최소 요금이지만 0은 아니라는 뜻이다. 그리고 같은 키에 DELETE를 또 보내면 S3가 마커를 또 한 줄 더 쌓는다. 마커 위에 마커가 쌓이는 형태도 정상 동작이다.

이 두 가지가 같이 일어나면 한 키 위의 current version은 늘 가장 최근에 들어온 한 줄을 가리킨다. 그 한 줄이 marker면 GET 응답이 한 가지 형태로 굳는다.
GET이 404를 돌려주는데 데이터는 그대로 남아 있다
GET Object에 VersionId를 안 붙이면 S3는 늘 current version을 본다. 그 위치에 마커가 올라와 있으면 응답은 404 Not Found인데, 한 가지가 다르다. 응답 헤더 x-amz-delete-marker: true가 같이 돌아온다. 단순한 Not Found가 아니라 마커가 위에 있어서 가려진 상태라고 명시적으로 알려 주는 신호다. 만약 마커의 VersionId를 GET ?versionId=…로 명시해서 부르면 응답은 405 Method Not Allowed 가 돌아온다. 데이터가 없는 placeholder를 GET으로 읽을 수는 없다는 뜻.
복구가 가능한 이유는 데이터가 그대로 살아 있어서다. v1·v2·v3가 멀쩡하고 그 위에 마커 한 줄이 얹혀 있을 뿐이다. ListObjectVersions를 부르면 두 배열로 나누어 응답이 돌아온다.
<Version>
<Key>contract.pdf</Key>
<VersionId>QUpfdndhfd…</VersionId>
<IsLatest>false</IsLatest>
<Size>434234</Size>
</Version>
<DeleteMarker>
<Key>contract.pdf</Key>
<VersionId>03jpff543dhf…</VersionId>
<IsLatest>true</IsLatest>
</DeleteMarker><Version>
<Key>contract.pdf</Key>
<VersionId>QUpfdndhfd…</VersionId>
<IsLatest>false</IsLatest>
<Size>434234</Size>
</Version>
<DeleteMarker>
<Key>contract.pdf</Key>
<VersionId>03jpff543dhf…</VersionId>
<IsLatest>true</IsLatest>
</DeleteMarker>마커는 <DeleteMarker> 배열에, 데이터 버전은 <Version> 배열에 있고 IsLatest로 누가 현재인지 표시한다. 복구는 한 줄짜리 작업이다. 마커의 VersionId를 그대로 DeleteObject에 붙여서 부르면 마커가 사라지고, 바로 아래 깔려 있던 비-마커 버전이 다시 현재로 올라온다. 복구는 덮어쓰기가 아니라 마커 한 줄 제거 한 번에 끝난다.
여기서 한 가지를 짚고 가야 한다. 단순 DELETE로 마커를 만드는 동작은 보통 IAM 권한 s3:DeleteObject만으로 충분하지만, 마커 자체를 영구 삭제해서 복구하거나 특정 버전을 영구 삭제하려면 s3:DeleteObjectVersion 권한이 따로 필요하다. 두 권한을 분리해 둔 이유가 바로 이 지점이다. 일반 사용자는 복구 가능한 삭제만 할 수 있게 두고, 정말로 영구 삭제할 수 있는 사람은 따로 좁혀 두는 거버넌스의 경계.
한 번 켜면 못 끈다, 상태는 한 방향 문이다
"After you version-enable a bucket, it can never return to an unversioned state. But you can suspend versioning on that bucket."
버킷 상태는 세 가지다. Unversioned (기본값, 한 번도 켜지 않은 상태로 버전 관리 설정 자체가 없음), Versioning-enabled (켠 상태), Versioning-suspended (켰다가 멈춘 상태). 단, PutBucketVersioning API의 Status 필드는 Enabled 또는 Suspended 두 값만 받는다. Unversioned는 Status가 없는 상태이지 별도의 입력값이 아니다. 한 번 Enabled로 가면 Unversioned로는 절대 돌아가지 못 한다. Suspended로는 갈 수 있고, Suspended에서 Enabled로 다시 갈 수도 있다. 그러나 Unversioned는 한 방향 문 너머에 둔다.

Suspended 상태가 한 가지 흥미로운 동작을 한다. 새로 들어오는 PUT은 더 이상 고유 VersionId를 받지 않고 null이라는 특별한 ID 한 줄로 들어가는데, 같은 키 위의 기존 버전은 그대로 살아 있다. 멈춤이지 삭제가 아니라는 뜻이다. 청구서도 그대로 따라온다. 한 가지 공식 문서가 짚어 둔 미묘한 점이 따라온다. Suspended 상태에서 같은 키에 PUT이 또 들어오면, 이미 있는 null 버전을 덮어쓴다. 새 null이 한 줄 더 추가되는 게 아니라 기존 null이 사라지고 새 null이 그 위치를 차지한다. 한 번 켜기 전부터 있던 객체는 또 다른 위치를 차지하는데, 활성화 시점 이전부터 있었던 것이라 VersionId가 없는 상태로 남고, 공식 문서가 그 케이스를 null 버전 ID로 다룬다고 명시한다.
이 한 방향성이 결정의 무게를 만든다. Enable 토글은 오늘 한 번 누를 수 있는 결정이지만, 그 한 번이 그 버킷의 영구한 비용 모델을 정한다. 그래서 토글을 누르기 전에 청구서가 어떻게 바뀌는지를 미리 봐 두어야 한다.
청구서가 어떻게 자라는지, 그리고 막는 단 한 가지 길
"Normal Amazon S3 rates apply for every version of an object stored and transferred. Each version of an object is the entire object; it is not just a diff from the previous version. Thus, if you have three versions of an object stored, you are charged for three objects."
delta가 아니라 전체 객체라는 게 핵심 의미다. 1MB 파일을 같은 키에 100번 덮어썼다고 하자. 버전 관리가 꺼져 있으면 청구서는 1MB 한 줄로 끝난다. 켜져 있으면 100MB로 잡는다. 일주일에 한 번 자동 백업으로 같은 1GB 파일을 같은 키에 PUT 하는 잡이 1년 돌아가면 어떻게 될까. 1GB × 52주 = 52GB를 그 한 키 위에 차곡차곡 쌓아 둔다. 사용자는 늘 한 줄만 보고 있어도 청구서는 52배다.
이 폭주를 자동으로 막는 AWS의 built-in 길은 Lifecycle 정책의 NoncurrentVersionExpiration 액션이다. 오래된 버전을 시간·개수 기준으로 자동 청소한다. 정책에는 두 종류의 조건을 같이 걸 수 있다.
NoncurrentDays: 객체가 current가 아닌 상태로 머문 일수. 30일 지나면 영구 삭제, 같은 식.NewerNoncurrentVersions: 최근 N개의 noncurrent 버전만 남기고 나머지를 영구 삭제. 공식 문서에 따르면 1에서 100 사이의 정수만 허용한다.
매일 한 번 PUT 하는 자동 백업 잡에 NewerNoncurrentVersions: 30을 걸면 노후화된 백업이 30개를 넘는 시점부터 가장 오래된 한 줄을 매일 영구 삭제한다. 1GB × 30 = 30GB로 상한선을 둔다. NewerNoncurrentVersions가 없으면 같은 잡이 365개 버전을 1년 만에 쌓아 365GB로 잡는다. 두 자릿수 차이가 정책 한 줄에서 나뉜다.
마커 자체도 cleanup 대상이다. 같은 키 위의 모든 데이터 버전을 영구 삭제한 뒤 마커 한 줄만 남으면 그 마커는 expired object delete marker로 분류한다. Lifecycle의 Expiration 액션에 ExpiredObjectDeleteMarker: true 옵션을 켜면 그 외로운 마커들도 자동으로 청소한다. 청구는 미세하지만 List 응답에는 차이를 만든다.
여기서 한 가지 함정이 따라온다. 운영 환경에서 영구 삭제 자체를 막고 싶다는 요구가 들어오면, 예를 들어 회계·감사 목적으로 어떤 운영자도 데이터를 사라지게 만들지 못하게 잠가야 한다면 Lifecycle 정책으로 자동 정리하는 동작이 반대로 위협이 된다. 그래서 영구 삭제 자체를 잠그는 별도 도구가 필요하다.
MFA Delete, 영구 삭제를 root에 의존시키는 자물쇠
MFA Delete는 영구 삭제와 버전 관리 비활성화 두 동작 앞에 root user의 MFA 토큰을 추가 인증 게이트로 거는 기능이다. 공식 문서가 적용 범위를 두 줄로 명시한다.
- Changing the versioning state of your bucket
- Permanently deleting an object version
켤 때는 같은 PutBucketVersioning API를 부른다. 단 두 가지를 더한다. XML 본문의 <MfaDelete>Enabled</MfaDelete> 요소(AWS CLI에서는 MFADelete=Enabled로 받는다)와 요청 헤더 x-amz-mfa: <SerialNumber> <TokenCode>. CLI 호출은 이 형태다.
aws s3api put-bucket-versioning \
--bucket amzn-s3-demo-bucket1 \
--versioning-configuration Status=Enabled,MFADelete=Enabled \
--mfa "arn:aws:iam::111122223333:mfa/root-account-mfa-device 123789"aws s3api put-bucket-versioning \
--bucket amzn-s3-demo-bucket1 \
--versioning-configuration Status=Enabled,MFADelete=Enabled \
--mfa "arn:aws:iam::111122223333:mfa/root-account-mfa-device 123789"여기에 함정 두 개가 같이 따라온다.
첫째, MFA Delete는 root user만 켜고 끌 수 있다. IAM user나 role은 같은 API를 정확히 같은 권한으로 들고 있어도 바로 그 작업만 거부한다. 운영 환경에서 root 자격증명을 평소에 안 쓰는 게 IAM 모범사례인데, MFA Delete를 켜는 순간 그 버킷의 영구 삭제와 비활성화 작업이 오직 root user 한 경로만 허용한다. Assume Role: 임시 자격증명이 만들어지는 순간에서 STS 임시 자격증명이 어떻게 만들어지는지를 봤지만, 여기서는 그 단계에서 root 자격증명만 쓸 수 있다. 운영 거버넌스가 한 단계만큼 더 좁다.
둘째, 콘솔에서 못 켠다. AWS Management Console의 Versioning UI에는 MFA Delete 토글이 없다. CLI나 API로만 켜고 끌 수 있다. 그리고 Lifecycle 정책과 호환하지 않는다. MFA Delete가 켜진 버킷에서는 자동 정리가 동작하지 않는다는 뜻이다.
세 가지가 합쳐지면 운영 의미가 분명하다. MFA Delete는 audit·contract·legal hold 같은 영구 보존용 도구이지, 일반 운영 버킷용 도구가 아니다. 켜면 청구서 폭주를 막는 길(Lifecycle)이 닫히고, root 자격증명 의존이 추가되고, 콘솔에서는 보이지 않는 곳이 생긴다. 그래서 audit 버킷에는 강하게 권장하지만, 일반 application 버킷에는 거의 안 켠다.

그래서 어디에 켜고 어디에 안 켜나
| 버킷 용도 | Versioning | MFA Delete | Lifecycle 정책 |
|---|---|---|---|
| audit / contract 보관 | Enabled | Enabled (root only) | 비호환, 안 검 |
| 자동 백업 | Enabled | Disabled | NewerNoncurrentVersions = 7~30 |
| 운영 application 데이터 | Enabled | Disabled | NoncurrentDays = 30~90 + ExpiredObjectDeleteMarker |
| temp / cache / build artifact | Disabled | Disabled | n/a (켜면 손해) |
마지막 줄이 언제 이 서비스를 쓰지 말아야 하는가에 대한 답이다. 빌드 산출물 버킷이나 CI 캐시 버킷처럼 지운 게 진짜로 지워져야 하는 곳에 versioning을 켜면, 매일 N번 PUT이 일어나는 잡이 한 달이면 청구서 두 자릿수를 더 쌓는다. 복구 가능한 삭제가 항상 좋은 동작이 아니라는 점을 기억해 두어야 한다.
versioning을 켠다는 토글 한 번이 가벼워 보였지만, DELETE라는 동사의 의미가 한 줄에서 두 줄로 늘어난 결과는 비용·복원·거버넌스 세 곳에 동시에 닿는다. Suspended로만 끌 수 있고 Disabled로는 못 가는 한 방향 문 너머로 한 번 들어가면 그 결정은 그 버킷이 사라질 때까지 따라온다. 그래서 토글을 누르기 전에 이 버킷이 audit 용인지, 운영 용인지, temp 용인지 한 줄로 답할 수 있어야 한다.
참고 자료
- Retaining multiple versions of objects with S3 Versioning. 버전 관리의 상태·동작·청구 모델 전체 정의 (1차 출처)
- Working with delete markers. Delete Marker의 정의·GET 응답 동작·response 헤더
- Configuring MFA delete. root-only 제약, CLI 사용법, Lifecycle 비호환
- PutBucketVersioning API Reference. XML 스키마, 허용 값, x-amz-mfa 헤더 형식
- ListObjectVersions API Reference. Versions[]·DeleteMarkers[] 분리 응답 + IsLatest 의미
- NoncurrentVersionExpiration API Reference. NewerNoncurrentVersions 1–100 범위와 NoncurrentDays 조합
- Adding objects to versioning-suspended buckets. Suspended 상태의 null 버전 덮어쓰기 동작










