이미지 업로드 & 고아 파일 정리 설계

브라우저가 이미지 파일을 API 서버에 보내고 서버가 R2에 올리는 대신, API가 임시 업로드 주소를 발급하고 브라우저가 R2에 직접 업로드한다. API 서버는 파일을 중계하지 않고 업로드 권한 발급, 사용자와 페이지 확인, 업로드 결과 검사, DB 저장을 맡는다.

업로드 흐름

  1. 브라우저가 API에 파일 형식, 크기, 업로드 대상을 보내고 임시 주소(presigned URL)를 요청한다.
  2. 서버가 로그인 사용자와 페이지 소유 관계를 확인하고, 객체 키와 해당 키로 업로드할 수 있는 서명 URL을 발급한다. 객체 키는 CDN 주소를 제외한 users/.../pages/... 같은 문자열이다.
  3. 브라우저가 서명 URL을 사용해 이미지 파일을 R2에 직접 업로드한다.
  4. 업로드가 끝나면 브라우저가 POST /items/upload/complete에 객체 키를 보낸다.
  5. 서버가 사용자와 페이지, 키의 관계와 R2 객체의 존재 여부, 크기, 허용 형식을 확인한다.
  6. 검증이 끝나면 객체 키를 저장하거나 기존 값을 갱신한다.

객체 키에 사용자 경로가 있어도 그것만으로 소유권이 확인되는 것은 아니다. 서버가 로그인 사용자와 페이지 소유 관계를 따로 확인해야 한다.

Bento 미디어의 경우 완료 요청은 업로드 파일을 검증하지만 페이지 DB 저장까지 마치지는 않는다. 이미지는 독립된 레코드가 아니라 Bento 아이템 안에 들어가기 때문에, 아이템 내용과 이미지 키는 페이지 저장 과정에서 함께 기록한다.

DB에 URL 대신 객체 키를 저장하는 이유

DB에는 users/.../pages/... 같은 객체 키를 저장하고, 화면에 전달할 때 현재 이미지 주소를 객체 키에 붙여 URL을 만든다.

업로드 방식 비교

브라우저 → 서버 → R2 방식은 서버에서 파일 흐름을 한곳에서 처리하기 쉽고, 업로드 중 검사나 이미지 변환을 넣기 쉽다. 하지만 파일이 서버를 거치므로 네트워크 사용량과 서버 처리 부담이 늘어난다. 업로드 사용자가 많아지면 서버가 병목이 될 수 있다.

브라우저 → R2 직접 업로드 방식은 파일이 API 서버를 거치지 않아 서버의 전송 부담이 줄어든다. 대신 임시 주소 발급, 완료 확인, 실패한 업로드 정리까지 구현해야 한다.

고아 파일은 언제 생길까?

업로드를 시작하고 끝내지 않은 경우

사용자가 업로드를 시작하면 서버는 R2에 파일을 올릴 주소를 만든다. 그 뒤 브라우저가 해당 주소를 사용해 파일을 R2로 직접 보낸다.

사용자가 창을 닫거나 인터넷이 끊기거나 업로드가 실패하면 파일이 끝까지 올라가지 않을 수 있다. 반대로 파일은 올라갔지만 페이지에 저장하는 단계가 끝나지 않을 수도 있다. 이 경우 R2에는 파일이 남아도 페이지에는 연결되지 않는다.

업로드는 끝났지만 페이지 저장이 안 된 경우

브라우저는 파일을 R2에 보낸 뒤 서버에 업로드 완료를 알리고 페이지에 파일을 연결한다. 그 사이 창이 닫히거나 요청이 실패하면 R2에는 파일이 남아도 페이지에는 기록되지 않는다.

기존 파일을 바꾸거나 지운 경우

프로필 사진을 새 사진으로 바꾸거나 페이지에서 미디어 카드를 지우면 예전 파일은 더 이상 사용되지 않는다. 페이지 내용이 바뀌어도 R2의 파일은 저절로 사라지지 않는다. 페이지 자체를 삭제할 때도 페이지에 연결된 파일을 함께 정리해야 한다.

고아 파일이 왜 문제가 될까?

페이지에서 쓰지 않는 파일이 R2에 계속 쌓이면 저장 공간을 차지하고 비용이 늘 수 있다. 파일을 바꾸거나 페이지를 지운 뒤에도 예전 파일이 남아 있으면 페이지 내용과 실제 파일 저장소의 상태도 달라진다.

어떻게 정리할까?

업로드한 파일마다 상태를 기록한다

업로드 파일의 상태를 저장하는 테이블을 따로 둔다. 파일 키, 페이지, 소유자, 상태를 기록하며 상태는 세 가지로 나눈다.

업로드를 시작하면 pending으로 기록하고 페이지 저장이 성공하면 attached로 바꾼다. 사용자가 업로드를 취소하거나 파일을 교체하거나 삭제하면 pending_delete로 바꾼다. 이 기록으로 정리 작업은 사용 중인 파일과 남은 파일을 구분할 수 있다.

페이지에 연결되지 않은 파일은 기다렸다가 지운다

업로드 주소가 만들어졌지만 페이지 저장까지 이어지지 않으면 파일 기록은 pending으로 남는다. 이 상태가 7일 동안 유지되면 페이지에 연결되지 않은 파일로 보고 정리 대상으로 삼는다.

업로드 주소의 유효 시간은 15분이다. 정리까지 기다리는 시간을 더 길게 두어 업로드가 진행 중인데 파일을 너무 일찍 지우는 일을 줄인다. 사용자가 직접 취소한 파일도 업로드 주소를 더는 사용할 수 없게 된 뒤 삭제한다.

교체하거나 삭제한 파일은 잠시 뒤 지운다

예전 이미지를 새 이미지로 바꾸거나 미디어 카드를 지우면 페이지 내용을 저장하는 과정에서 예전 파일을 pending_delete로 표시한다. 파일 자체는 잠시 뒤 정기 정리 작업에서 지운다.

페이지를 삭제할 때도 같은 기록을 사용해 해당 페이지에 연결된 미디어를 삭제 대상으로 표시하고, 정기 작업이 처리하게 한다.

매일 정리 작업을 실행한다

정기 작업은 삭제할 때가 된 파일의 키를 모아 R2에서 지우고, 삭제가 끝난 기록도 DB에서 지운다. 한 번에 처리하는 파일 수는 최대 1,000개로 제한한다.

R2에서 지우는 도중 오류가 발생하면 DB의 삭제 기록은 그대로 남을 수 있다. 다음 정리 작업이 남은 기록을 다시 처리한다.

파일을 올리는 순간부터 페이지에 연결될 때까지 상태를 기록하고, 사용하지 않게 된 파일은 정기 작업으로 지운다. 이렇게 하면 업로드가 중간에 끊기거나 페이지 내용이 바뀌어도 R2에 남은 파일을 찾아 정리할 수 있다.