iNote

생각을 기록하면 오래 남습니다.

AI 어시스턴트

게스트님, 안녕하세요

무엇을 도와드릴까요?

  • 대화 기록이 없어요.

AI 어시스턴트

게스트님, 안녕하세요

무엇을 도와드릴까요?

  • 대화 기록이 없어요.
iNote
iNote
홈할 일일지독서나의 글글쓰기dev-guide

iNote

생각을 기록하면 오래 남습니다.

사이드프로젝트 기록

이미지 업로드 "Draft 파일"과 R2 업로드 정리 전략 설계

서동찬 (sdc337dc@gmail.com)

생성일 : 2026.10.13 22:23

  • 업로드 API는 저장 없이 R2에 바로 파일을 저장해 공개 URL을 반환하지만, 독서 기록에 저장되지 않으면 고아 파일이 남아 용량과 보안 문제를 일으킨다.
  • 파일 정리 전략을 단일 규칙으로 정리하기 위해 “참조 여부 + 24시간 유예”를 적용하고, tmp/와 reading/ 폴더를 구분해 R2에서 정기적으로 미사용 파일을 삭제한다.
  • 테스트 환경에서는 Nest 11의 FileTypeValidator가 ESM 패키지를 사용하므로 `NODE_OPTIONS=--experimental-vm-modules` 플래그를 켜야 정상 통과하며, 실제 구현 시에도 동일한 설정이 필요하다.
  • 최종 구현 방향은 독서 표지에만 적용하며, 업로드 시 tmp 폴더에 저장하고 발행 시 복사·삭제를 통해 reading 폴더로 승격하고, 배치 스케줄(Asia/Seoul 03:00)로 정리한다.

2026.10.13 화요일 - 독서 > 책 표지 이미지 업로드 개발

개인 기록 앱(iNote)의 독서 기록에 책 표지 이미지 업로드를 붙이는 과정에서, 저장하지 않고 나간 이미지가 R2에 계속 쌓인다는 문제를 발견하고 정리 전략을 설계한 기록이다. 스택: Next.js(FE) · NestJS 11 + Prisma(BE) · Cloudflare R2(파일 저장) · PostgreSQL ⚠️ 이 글은 설계 단계의 기록이다. 정리 배치는 아직 구현하지 않았고, 구현하면 후속 글로 이어서 쓴다.


1. 이미 있던 업로드 API가 정말 되는지 확인하기

BE에는 이미 POST /uploads/image가 있었다.

  • 로그인 필요, 5MB 이하, jpeg/png/webp/gif만 허용

  • R2에 uploads/<userId>/<uuid>.<확장자> 키로 저장하고 공개 URL을 돌려줌

  • FE는 글 썸네일과 독서 표지에서 이 API를 쓴다

"구현돼 있다"와 "된다"는 다르니 먼저 확인했다. 로그인이 필요한 API라 바로 호출할 수 없어서, 층을 나눠서 확인했다.

1-1. 저장소(R2)만 따로 확인

실제 업로드를 맡은 R2Service를 .env 값으로 직접 호출했다.

  1. 1x1 PNG를 R2에 업로드

  2. 반환된 공개 URL로 GET → 200, image/png, 올린 내용과 동일

  3. 테스트 파일 삭제 → 삭제 후 같은 URL이 404

1-2. 컨트롤러 검증만 따로 확인

R2는 가짜로 바꾸고, 인증 가드는 테스트에서만 우회해서 케이스별 응답을 봤다.

케이스

결과

png, webp(확장자 대문자)

201

pdf, svg

400

5MB 초과

400

파일 없음 / 필드명이 file이 아님

400

내용은 텍스트인데 Content-Type만 image/png로 위장

400

1-3. 로그인한 상태로 실제 업로드

마지막으로 로컬에서 FE와 BE를 띄우고 로그인한 상태에서 독서 기록 화면으로 올려 봤고, R2에 파일이 올라간 것까지 확인했다.

배운 점: "로그인이 필요해서 테스트 못 한다"에서 멈출 필요가 없다. 인증이 걸린 층과 저장소 층을 나누면 각각 확인할 수 있다.


2. 함정: 정상 PNG가 Jest에서 400으로 거절된다

컨트롤러 검증을 Jest로 돌렸더니 정상 PNG와 WEBP도 400이었다.

Validation failed (current file type is image/png,

expected type is /^image\/(jpeg|png|webp|gif)$/)

형식이 맞는데 거절한다. 코드 버그를 의심했지만 원인은 다른 곳에 있었다.

  • Nest 11의 FileTypeValidator는 Content-Type 문자열이 아니라 파일 앞부분(매직 넘버)을 읽어서 형식을 판별한다.

  • 이때 쓰는 file-type 패키지가 ESM인데, Jest에서는 실험 플래그가 없으면 ESM을 불러오지 못해 항상 거절한다. 검증기 소스에도 이 상황의 경고 문구가 있었다.

  • NODE_OPTIONS=--experimental-vm-modules를 켜고 다시 돌리자 정상 통과했다.

덤으로 얻은 사실이 있다. 매직 넘버로 판별하기 때문에 확장자나 Content-Type만 위조한 파일은 이미 걸러진다(위 표의 "위장" 케이스가 400이었던 이유).

기억할 점: 이 컨트롤러의 단위 테스트를 쓸 때는 같은 플래그가 필요하다. 코드 문제가 아니라 테스트 환경의 한계였다.


3. 본론: 올리기만 하고 저장하지 않으면?

업로드 API의 동작을 다시 보면 이렇다.

  • 파일을 DB 기록 없이 R2에 바로 저장하고 URL만 돌려준다.

  • 이 URL을 독서 기록의 coverImageUrl에 넣어 저장하는 것은 그다음, 별개의 요청이다.

  • R2Service에는 upload만 있고 삭제가 없다.

그래서 아래 경우마다 파일이 R2에 그대로 남는다.

#

고아 파일이 생기는 경우

1

이미지를 올리고 저장하지 않고 나감

2

저장 후 다른 이미지로 교체

3

저장 후 이미지를 제거

4

독서 기록을 삭제 (DB 행만 지워짐)

5

회원 탈퇴 — DB의 onDelete: Cascade로 기록이 지워져서 앱 코드를 거치지 않는다

게다가 이미지 URL이 공개 주소라서, 비공개 글의 이미지를 지워도 URL을 아는 사람에게는 계속 열린다. 용량보다 이쪽이 더 신경 쓰였다.


4. 전략 후보

후보

내용

장단점

A

업로드 기록 테이블 + 정리

정확하지만 마이그레이션과 상태 관리(대기/사용 중)가 많다. 저장·교체·삭제마다 상태를 맞춰야 함

B

참조 기반 청소(스윕): R2의 파일 목록과 DB의 참조를 비교해서 안 쓰는 파일 삭제

DB 변경이 없고 위 1~5번을 한 번에 처리. 정기 실행 장치가 필요

C

임시 폴더 + R2 수명주기 규칙(자동 만료)

코드가 필요 없지만 파일 나이만 본다. (규칙 지원 여부는 공식 문서로 확인 필요)

D

저장 버튼을 누를 때만 업로드

1번만 해결. 저장 후 교체·삭제(2~5번)는 그대로

E

방치하고 수동 정리

양이 적으면 가능하지만 공개 URL 문제가 남는다


5. 정한 방향

대화로 다음을 정했다.

5-1. 범위

우선 독서 표지에만 적용한다. 글 썸네일은 기존 동작을 유지하고, 같은 구조를 다음 단계에서 확장한다.

5-2. 폴더를 둘로 나눈다

업로드     →  tmp/reading/<userId>/<uuid>.<확장자>   (임시)

발행할 때 → reading/<userId>/<uuid>.<확장자> (영구)

S3 호환 API에는 "이동"이 없어서, 승격은 복사 후 삭제 두 단계로 한다.

5-3. 배치 규칙은 하나로 통일

R2 파일 중, DB 어디에서도 참조하지 않고, 마지막 수정 후 24시간이 지난 것을 삭제한다. (tmp/와 reading/ 모두)

이 규칙 하나가 위 1~5번을 전부 정리한다. 24시간 유예는 아직 저장 전인 새 업로드를 보호하기 위한 것이다.

5-4. 표지 URL은 우리 R2 주소만 허용

지금 coverImageUrl은 문자열이면 무엇이든 저장된다(다른 사이트 이미지 주소도). 이를 우리 R2 주소로 제한하고, 본인 폴더인지도 검사한다. FE는 업로드 결과로만 값을 채우기 때문에 기존 흐름은 깨지지 않는다.

5-5. 임시저장(draft)은 내용과 함께 삭제

임시저장된 글·독서 같은 콘텐츠를 이미지와 함께 배치가 정리한다. 순서가 중요하다.

  1. 보관 기간이 지난 draft를 삭제

  2. 그 결과 참조가 사라진 이미지를 5-3 규칙으로 삭제

순서가 반대면 방금 지운 draft의 이미지가 다음 실행까지 남는다. 보관 기간은 아직 정하지 못했다(기준은 생성일이 아니라 마지막 수정일이어야 오래 쓰는 중인 draft를 지우지 않는다).

5-6. 배치 실행은 cron-job.org에 의존하지 않는다

작업 본체를 HTTP와 무관한 서비스 하나로 만들고, 두 가지 방식으로 붙인다.

  • 앱 안의 @Cron (시간대 Asia/Seoul, 새벽 3시) — Spring의 @Scheduled와 같은 개념

  • 독립 실행 스크립트 (웹 서버 없이 Nest 컨텍스트만 띄움) — OS cron·컨테이너 크론잡·수동 실행용


6. 왜 "파일 나이"가 아니라 "참조"인가 (이 글의 핵심)

처음에는 단순하게 생각했다. "tmp 폴더에서 24시간 지난 파일을 지우면 되지 않나?"

그런데 독서 작성에는 자동저장이 있다. 사용자가 표지를 올리고 임시저장해 둔 채 며칠 쉬었다가 다시 오면, 그 draft는 tmp 이미지를 참조한 채 살아 있다. 파일 나이만 보고 지우면 살아 있는 draft의 이미지가 깨진다.

그래서 규칙이 "참조 여부 + 유예 시간"이 되었다. R2 수명주기 규칙(C안)은 나이만 볼 수 있어서 이 문제를 모른다. 쓴다면 30일처럼 아주 넉넉한 안전망 용도다.

참조 판단은 모든 독서 기록 행(draft와 발행 모두)의 coverImageUrl을 기준으로 한다. 아래 의사코드는 설계 예시다(미구현).

1. 보관 기간이 지난 draft 삭제           (updatedAt 기준)

2. 참조된 키 집합 = 모든 ReadingLog의 coverImageUrl에서 추출한 키

3. R2에서 tmp/reading/, reading/ 아래 파일 목록 조회

4. 각 파일에 대해:

참조된 키가 아니고, LastModified가 24시간 이전이면 → 삭제

5. 삭제 개수·실패 건수를 로그에 남김


7. 놓치기 쉬운 점

  • 승격 시점: 자동저장 때마다 옮기지 말고 발행 때 옮기는 것이 단순하다. 대신 배치가 참조를 확인해야 한다.

  • 저장된 파일은 FE가 직접 지우지 못하게: 클릭 순간에 지우면, 수정하다 취소했을 때 DB는 옛 파일을 가리키는데 파일이 없어진다. 저장이 성공한 뒤 서버가 지운다.

  • 소유 검증: 키에 userId를 넣고 요청자와 같은지 확인하지 않으면 남의 tmp 파일을 자기 기록에 가져올 수 있다.

  • "표지 없음"은 빈 문자열: 표지 삭제 버튼이 ""를 보내고 BE가 그대로 저장한다. null과 둘 다 "참조 없음"으로 다뤄야 한다.

  • 확장자는 사용자 파일명이 아니라 검증된 타입에서: 지금은 파일명 끝을 그대로 쓴다. 실제로 확장자 없는 파일명은 키가 ...uuid.noext가 됐고, PNG 내용을 x.html로 보내면 .html 키로 저장됐다(R2에는 image/png로 저장돼서 당장 문제는 없지만 지저분하다).

  • 시간대: 서버가 UTC라면 새벽 3시(KST)는 전날 18:00 UTC다. 스케줄에 시간대를 명시한다.

  • 무료 호스팅의 슬립: 앱 안의 스케줄러는 서버가 깨어 있어야 돈다. 항상 켜진 호스팅에서는 문제가 없지만, 슬립이 있는 무료 호스팅에서는 외부에서 깨워 줘야 한다.

  • draft 삭제는 되돌릴 수 없다: 사용자의 작성 중인 내용을 영구 삭제하는 일이라 보관 기간을 넉넉히 잡고 삭제 건수를 로그에 남긴다.

  • 공개 URL: 개발용 공개 주소(r2.dev)는 운영에 권장되지 않는다고 알려져 있어(공식 문서 확인 필요) 커스텀 도메인을 검토 중이다. 도메인이 바뀔 수 있으니 DB에는 URL 전체가 아니라 경로(키)만 저장하는 안도 고민 중이다.


8. 아직 모르는 것, 다음 단계

  • draft 보관 기간(N일)

  • DB에 URL 대신 키(경로)만 저장할지

  • 커스텀 도메인을 쓸 수 있는지

  • R2 수명주기 규칙이 접두어별 자동 삭제를 지원하는지, 서명 URL이 커스텀 도메인에서 동작하는지 → 공식 문서로 확인

  • 구현은 API 계약 → FE(MSW·유닛 테스트) → BE(테스트 케이스는 직접 작성) → 통합 확인 순서로 진행한다.


정리

  1. API가 되는지는 층을 나눠서 확인할 수 있다(저장소 / 컨트롤러 검증 / 로그인 후 실제 호출).

  2. Nest 11의 파일 형식 검증은 매직 넘버로 판별해서 위조에 강하지만, Jest에서는 ESM 플래그가 필요하다.

  3. 업로드와 저장이 분리돼 있으면 고아 파일은 반드시 생긴다. 발생 경우를 먼저 목록으로 만들면 해법이 보인다.

  4. 파일 정리의 기준은 나이가 아니라 참조다. 나이는 유예 시간으로만 쓴다.

  5. 여러 경우(저장 안 함, 교체, 삭제, 탈퇴)를 하나의 규칙으로 정리하면 이벤트마다 삭제 코드를 흩뿌릴 필요가 없다.

목차

  • 1. 이미 있던 업로드 API가 정말 되는지 확인하기
  • 1-1. 저장소(R2)만 따로 확인
  • 1-2. 컨트롤러 검증만 따로 확인
  • 1-3. 로그인한 상태로 실제 업로드
  • 2. 함정: 정상 PNG가 Jest에서 400으로 거절된다
  • 3. 본론: 올리기만 하고 저장하지 않으면?
  • 4. 전략 후보
  • 5. 정한 방향
  • 5-1. 범위
  • 5-2. 폴더를 둘로 나눈다
  • 5-3. 배치 규칙은 하나로 통일
  • 5-4. 표지 URL은 우리 R2 주소만 허용
  • 5-5. 임시저장(draft)은 내용과 함께 삭제
  • 5-6. 배치 실행은 cron-job.org에 의존하지 않는다
  • 6. 왜 "파일 나이"가 아니라 "참조"인가 (이 글의 핵심)
  • 7. 놓치기 쉬운 점
  • 8. 아직 모르는 것, 다음 단계
  • 정리