JOURNAL ENTRY Engineering

탈퇴와 삭제는 같은 lifecycle이 아니었다 — PhotoGram에서 계정·콘텐츠·GPS 보유기간을 분리한 이유

30일 보존 규칙 하나로 계정과 모든 콘텐츠를 처리하려고 하면 복구, 재가입, 개인정보, 미디어 정리가 서로 충돌한다. PhotoGram에서 계정 탈퇴와 비활성화, Photo/Story 휴지통, raw GPS 보존을 서로 다른 lifecycle로 분리한 이유를 정리한다.

서비스에 삭제 기능을 넣을 때 처음에는 하나의 숫자로 정리하고 싶어진다.

예를 들어:

삭제 요청
→ 30일 보관
→ 완전 삭제

처럼 말이다.

하지만 PhotoGram의 계정과 콘텐츠 lifecycle을 실제 코드와 함께 검토하면서 이 방식은 너무 거칠다는 결론이 나왔다.

계정 탈퇴, 계정 비활성화, 사진 삭제, 스토리 삭제, 댓글 삭제, 좋아요나 팔로우 같은 관계 제거, 그리고 GPS 같은 민감한 원본 metadata는 모두 같은 종류의 "삭제"가 아니었다.

결국 필요한 것은 하나의 retention 기간이 아니라 각 데이터가 어떤 제품 의미를 가지는지 먼저 분리하는 일이었다.

비활성화와 탈퇴는 다른 상태다

계정 lifecycle부터 나눴다.

ACTIVE
  |
  +-- deactivate --> DEACTIVATED
  |                    |
  |                    +-- reactivate --> ACTIVE
  |
  +-- delete ---------------------------> DELETED

DEACTIVATED는 사용자가 잠시 계정을 사용하지 않는 상태다.

계정 identity와 콘텐츠는 남아 있고, 나중에 같은 계정을 다시 활성화할 수 있다.

반면 DELETED는 탈퇴다.

DEACTIVATED
= reversible

DELETED
= irreversible

이 둘을 하나의 isDeleted 같은 flag 의미로 섞으면 복구 정책부터 다시 꼬인다.

"탈퇴했지만 30일 안에는 복구 가능"이라는 정책을 원한다면 그것은 별도의 제품 결정이어야 한다. 콘텐츠 휴지통의 30일을 계정 탈퇴에 자동으로 전파하면 안 된다.

PhotoGram에서는 계정 탈퇴를 irreversible lifecycle로 두기로 했다.

탈퇴 시에는 세션을 폐기하고, 프로필과 직접 개인정보를 제거하거나 익명화하며, 계정이 소유한 Photo/Story와 댓글, likes, saved, follows, blocks 같은 개인 상태를 정리한다.

같은 이메일로 다시 가입해도 같은 계정이 아니다

이 결정을 하고 나면 재가입 의미도 명확해진다.

old Member
→ DELETED

same email signup
→ new Member
→ new memberId

같은 이메일 주소를 다시 사용했다고 해서 예전 Member가 복원되는 것은 아니다.

새 계정은 자동으로 이전 콘텐츠나 관계, saved state, social-provider link, 기존 Member identity를 물려받지 않는다.

탈퇴한 계정을 내부적으로 계속 살려두고 이메일만 다시 연결하면 제품 표면에서는 삭제처럼 보여도 identity lifecycle은 실제로 끝나지 않은 셈이 된다.

30일 복구는 Photo와 Story에만 적용했다

반대로 사진과 스토리는 실수로 삭제하는 경우가 많다.

여기에는 휴지통 semantics가 유용하다.

ACTIVE CONTENT
    |
    | user delete
    v
SOFT-DELETED / HIDDEN
    |
    | 30 days
    |
    +-- restore ----> ACTIVE
    |
    +-- expiry -----> FINAL PURGE

삭제 직후 콘텐츠는 일반 화면에서 숨긴다.

하지만 30일 동안은 owner가 복구할 수 있다.

30일이 지나면 실제 content row와 media lifecycle을 최종 정리한다.

중요한 점은 이 규칙을 "PhotoGram의 모든 삭제는 30일"로 확장하지 않았다는 것이다.

PHOTO / STORY
→ 30-day recoverable trash

COMMENT
→ no 30-day recovery requirement

LIKE / SAVED / FOLLOW / BLOCK
→ relationship-state transition

ACCOUNT
→ separate irreversible lifecycle

같은 삭제 버튼처럼 보여도 데이터의 제품 의미가 다르기 때문이다.

계정 탈퇴가 콘텐츠 휴지통 때문에 되돌아오면 안 된다

사용자가 사진을 삭제해 휴지통에 넣은 뒤 계정 자체를 탈퇴했다고 하자.

사진은 원래 30일 동안 복구 가능하다.

하지만 계정까지 삭제된 뒤에도 그 사진이 복구 가능하다면 계정 탈퇴가 간접적으로 reversible해진다.

그래서 다음을 고정했다.

ACCOUNT = DELETED

=> content owned by that account
   cannot remain user-restorable

실제 purge 순서는 구현에서 정할 수 있다.

하지만 account deletion이 Photo/Story trash semantics에 종속되지는 않는다.

Lifecycle Tracker는 작은 역할만 가진다

30일이 지나면 누군가는 최종 purge를 실행해야 한다.

여기서 쉽게 범용 retention engine을 만들고 싶은 유혹이 생긴다.

하지만 현재 반복해서 필요한 구체적 lifecycle은 Photo와 Story의 recoverable delete뿐이었다.

그래서 tracker가 필요하다면 역할을 좁게 잡는다.

ContentDeletionLifecycle

resourceType
resourceId
ownerMemberId
requestedAt
purgeAfter
status
failureReason

이 tracker의 책임은 단순하다.

언제 irreversible purge를 실행할 수 있는가?

반대로 이것이 Photo나 Story의 제품 상태 authority가 되면 안 된다.

TRACKER
= scheduling / execution state

DOMAIN ENTITY
= product state authority

나중에 다른 domain에서도 같은 lifecycle이 실제로 반복되는 것이 확인되면 확장할 수 있다. 지금부터 generic governance engine을 만들 필요는 없다.

미디어 삭제 메커니즘도 새로 만들지 않는다

Photo/Story가 최종 purge될 때 이미지 파일도 정리해야 한다.

이미 PhotoGram에는 Attachment와 media cleanup lifecycle이 존재한다.

따라서 새로운 파일 삭제 시스템을 하나 더 만들기보다 기존 경계를 재사용한다.

Photo/Story soft delete
    ↓
30-day recovery
    ↓
expiry
    ↓
final content purge
    ↓
existing Attachment/media cleanup

반면 아직 게시되지 않은 staged upload의 TTL/orphan cleanup은 다른 문제다.

STAGED UPLOAD TTL
!=
PUBLISHED CONTENT TRASH

둘 다 파일을 지우지만 lifecycle은 다르다.

Raw GPS는 보관기간을 정하기 전에 저장하지 않는 것이 낫다

삭제와 retention을 검토하면서 GPS도 같이 봤다.

사진의 EXIF에는 정확한 촬영 좌표가 들어갈 수 있다.

Place 기능은 이 좌표를 사용해 주변 장소를 찾을 수 있지만, 그렇다고 exact capture coordinate가 PhotoGram의 영구 application state가 되어야 하는 것은 아니다.

그래서 의미를 나눴다.

raw EXIF / device coordinate
= transient resolve input

Place identity / representative coordinate
= durable Place domain data

즉 정확한 촬영 좌표는 client/request에서 Place resolve에 사용한 뒤 버리는 것이 기본 방향이다.

DB, durable cache, analytics, application log에 굳이 넣지 않는다.

반면 provider가 가진 Place 대표 좌표는 "장소" 자체의 공개 가능한 공간 정보이므로 별도 Place 데이터로 유지할 수 있다.

capture coordinate
!= Place representative coordinate

이 구분이 없으면 "카페 위치"를 저장한 것인지 "사용자가 실제로 서 있던 정확한 좌표"를 저장한 것인지 데이터 의미가 흐려진다.

코드 리뷰에서 발견한 실제 mismatch

기존 구현을 확인했을 때 request에 들어온 정확한 Place resolve 좌표는 DB에 저장되지 않고 있었다.

하지만 업로드된 원본 파일 bytes는 그대로 durable storage에 남을 수 있었기 때문에, 원본 EXIF 안의 GPS가 계속 보존될 가능성이 있었다.

NO GPS COLUMN
!=
NO DURABLE GPS

strict non-persistence 방향을 지키려면 media processing 단계에서 canonical persisted source/original의 EXIF 정책까지 봐야 한다.

데이터 lifecycle을 DB schema만 보고 판단하면 놓치기 쉬운 지점이었다.

모든 retention을 30일로 만들지 않는다

일부 데이터는 Photo/Story 정책만 보고 보존기간을 정할 수 없다.

예를 들어 moderation/report evidence, security/abuse records, access logs, IP security evidence, backups, notifications에는 각각 다른 목적이 있다.

이런 데이터까지 "사용자 콘텐츠는 30일"이라는 규칙에 넣으면 오히려 정책이 부정확해진다.

필요한 순서는 다음이다.

DATA PURPOSE
→ PRODUCT SEMANTICS
→ RETENTION / DELETION POLICY

삭제 정책은 데이터 모델의 일부다

이번 정리에서 가장 크게 바뀐 생각은 retention을 운영 설정으로만 보지 않게 된 것이다.

계정 복구 가능 여부는 Auth 모델을 바꾼다.

Photo/Story의 복구 기간은 콘텐츠 상태 모델을 바꾼다.

미디어 purge는 Attachment lifecycle과 연결된다.

GPS를 보존하느냐는 privacy architecture를 바꾼다.

RETENTION
!= cleanup detail

RETENTION
= product lifecycle
+ privacy contract
+ storage semantics

그래서 PhotoGram에서는 "30일 후 삭제" 하나로 끝내지 않았다.

어떤 데이터가 왜 존재하는지부터 나눈 뒤, 그 의미에 맞는 lifecycle을 각각 부여하는 쪽을 선택했다.