에디터 이미지 업로드와 고아 객체 라이프사이클
문제
에디터에 이미지를 붙여넣으면 업로드 API가 호출되고, 반환된 URL을 본문에 넣는다. 흔한 기능이지만 함정이 있다. 이미지의 수명이 글의 수명과 어긋난다.
고아(orphan) 객체가 생기는 경로는 둘:
- 올렸지만 저장 안 함 — 붙여넣는 순간 이미 버킷에 올라간다. 글을 저장하지 않고 나가면 아무 글도 참조하지 않는 객체가 남는다.
- 본문에서 지워진 이미지 — 저장된 글을 수정하며 이미지를 지운다. 본문에서 URL은 사라졌지만 버킷 객체는 그대로다.
둘 다 "버킷에는 있는데 아무도 안 쓰는" 상태다.
순진한 해법이 깨지는 이유
"에디터에서 이미지 지울 때 삭제 API 호출하면 되잖아?"
동작은 하지만 실무에선 깨진다. 클라이언트를 신뢰할 수 없기 때문이다.
- undo: 지웠다가 Ctrl+Z로 되살림 → 삭제 API는 이미 나감 → 깨진 이미지
- 네트워크 실패: 삭제 요청 유실 → 고아 잔존
- 재사용: 같은 이미지를 두 글이 참조 → 한쪽에서 지우면 다른 쪽이 깨짐
- 창 닫기: 저장도 삭제도 호출 안 됨 → 1번은 애초에 못 잡음
파괴적이고 되돌릴 수 없는 삭제를, 신뢰할 수 없는 실시간 신호에 맡기면 안 된다.
원칙
- 클라이언트를 신뢰하지 않는다. 삭제 신호로 즉시 지우지 않는다.
- DB가 진실의 원천. "어느 글이 이 이미지를 참조하는가"를 스토리지가 아닌 DB가 안다.
- 삭제는 지연·멱등·유예기간. 실시간이 아니라 비동기로, 미참조가 된 지 N시간 뒤에.
사이클: 업로드 · 리컨실 · GC
1. 업로드(스테이징) — R2에 올리고 URL 반환. DB에 기록하되 미참조 상태로. 태어난 순간부터 GC 시계가 돈다. → 1번(저장 안 함) 해결.
2. 리컨실(reconcile) — 글 저장 시 본문·썸네일·og_image에서 우리 버킷 URL을 스캔해 "실제 참조 집합"을 뽑고, DB 참조 관계를 이 집합에 맞춘다. 기준점은 클라이언트의 "지웠어" 신호가 아니라 저장된 최종 본문. → 2번(본문 제거) 해결.
3. GC — 스케줄러가 "미참조 + 유예 초과" 객체를 R2·DB에서 삭제. 1번이든 2번이든 "참조 0 + 유예 초과"라는 한 규칙으로 수렴한다.
업로드 → [미참조, 시계 시작]
글 저장(참조 생김) → 시계 정지 → [참조됨]
본문에서 제거(참조 0) → 시계 재시작 → [미참조]
유예 초과 → R2 + DB 삭제
참조는 N:M으로
image.post_id 컬럼 하나로 두고 싶지만 베스트 프랙티스가 아니다. 같은 이미지를 여러 글에 붙이면 오삭제가 난다(A에서 지우면 B가 깨짐).
N:M(글 ↔ 이미지) 으로 두면 "참조 개수 == 0"이 곧 "고아"가 된다. 업로드 직후(0), 참조됨(≥1), 마지막 참조 빠짐(0) — 세 상태가 참조 카운트 하나로 통일된다.
구현 노트
- 미참조 시각 컬럼(
unreferenced_since): 참조되면NULL, 참조 0되면now. GC는unreferenced_since < now - grace한 줄. 상태 enum 없이 시각 하나로 판정. - 유예기간(24h) 이 undo와 저장 지연을 흡수한다. 너무 짧으면 작성 중 이미지가 지워지고, 너무 길면 청소가 밀린다.
- GC 순서: R2 먼저 삭제 → DB 삭제. R2 삭제는 멱등(없는 key 무시)이라, 중간에 실패해도 DB row가 남아 다음 회차에 재시도된다. 반대 순서면 DB만 지워지고 버킷에 추적 불가능한 진짜 고아가 남는다.
- 리컨실은 글 저장 트랜잭션 안에서. 글은 저장됐는데 참조 갱신이 누락되는 불일치를 막는다.
- URL 스캔 범위는 우리 공개 URL로 시작하는 것만. 외부 이미지·오탐을 원천 차단.
DELETE API를 안 만든 이유
정상 흐름에선 쓸 일이 없다. 리컨실+GC가 라이프사이클 전체를 처리하므로, 본문에서 URL을 지우고 저장하면 알아서 회수된다. 남는 용도는 "즉시 내리기"(실수로 올린 민감 이미지를 유예 없이 제거)뿐이고, 개인 블로그에선 드물어 필요할 때 추가하면 된다. 안 만들어도 되는 엔드포인트는 안 만든다.
요약
- 이미지 수명 ≠ 글 수명. 이 불일치가 고아를 만든다.
- 삭제를 클라이언트 실시간 신호에 맡기지 않는다.
- DB를 진실의 원천으로, 삭제는 지연·멱등·유예.
- 업로드(스테이징) → 저장 시 리컨실 → 유예 후 GC. 서로 다른 고아 경로를 "참조 0 + 유예 초과" 한 규칙으로 수렴.
- 참조는 N:M. 모델이 정확하면 판정이 단순해진다.
결국 스토리지와 DB라는 두 저장소의 상태를 일관되게 유지하는 문제다. 한쪽(DB)을 진실로 정하고, 나머지를 거기에 안전하게 수렴시킨다.