에디터 이미지 업로드와 고아 객체 라이프사이클
Backend

에디터 이미지 업로드와 고아 객체 라이프사이클

bangcr·2026년 7월 19일·4·조회 2
수정

문제

에디터에 이미지를 붙여넣으면 업로드 API가 호출되고, 반환된 URL을 본문에 넣는다. 흔한 기능이지만 함정이 있다. 이미지의 수명이 글의 수명과 어긋난다.

고아(orphan) 객체가 생기는 경로는 둘:

  1. 올렸지만 저장 안 함 — 붙여넣는 순간 이미 버킷에 올라간다. 글을 저장하지 않고 나가면 아무 글도 참조하지 않는 객체가 남는다.
  2. 본문에서 지워진 이미지 — 저장된 글을 수정하며 이미지를 지운다. 본문에서 URL은 사라졌지만 버킷 객체는 그대로다.

둘 다 "버킷에는 있는데 아무도 안 쓰는" 상태다.

순진한 해법이 깨지는 이유

"에디터에서 이미지 지울 때 삭제 API 호출하면 되잖아?"

동작은 하지만 실무에선 깨진다. 클라이언트를 신뢰할 수 없기 때문이다.

  • undo: 지웠다가 Ctrl+Z로 되살림 → 삭제 API는 이미 나감 → 깨진 이미지
  • 네트워크 실패: 삭제 요청 유실 → 고아 잔존
  • 재사용: 같은 이미지를 두 글이 참조 → 한쪽에서 지우면 다른 쪽이 깨짐
  • 창 닫기: 저장도 삭제도 호출 안 됨 → 1번은 애초에 못 잡음

파괴적이고 되돌릴 수 없는 삭제를, 신뢰할 수 없는 실시간 신호에 맡기면 안 된다.

원칙

  1. 클라이언트를 신뢰하지 않는다. 삭제 신호로 즉시 지우지 않는다.
  2. DB가 진실의 원천. "어느 글이 이 이미지를 참조하는가"를 스토리지가 아닌 DB가 안다.
  3. 삭제는 지연·멱등·유예기간. 실시간이 아니라 비동기로, 미참조가 된 지 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)을 진실로 정하고, 나머지를 거기에 안전하게 수렴시킨다.