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

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

bangcr·2026년 7월 19일·약 4분·조회 23
수정

문제

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

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

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

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

순진한 해법이 깨지는 이유

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

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

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

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

원칙

  1. 클라이언트를 신뢰하지 않는다. 삭제 신호로 즉시 지우지 않는다.
  2. DB가 진실의 원천. "어느 글이 이 이미지를 참조하는가"를 스토리지가 아닌 DB가 안다.
  3. 삭제는 지연·멱등·유예기간. 실시간이 아니라 비동기로, 미참조가 된 지 N시간 뒤에.

사이클: 업로드 · 리컨실 · GC

ChatGPT Image 2026년 7월 24일 오전 10_40_48 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)을 진실로 정하고, 나머지를 거기에 안전하게 수렴시킨다.