본문으로 이동geul

TanStack Start에서 R2·Cloudflare Images로 이미지 갤러리를 만든 과정

geul조회 3

에디터에는 이미지를 문단 사이에 넣을 수 있었다. 문제는 글을 읽다가 이미지를 하나 눌렀을 때였다. 그 한 장만 크게 보는 것으로는 부족했다. 글에 첨부한 이미지 전체를 본문 순서대로 훑고, 원하는 사진으로 바로 이동할 수 있어야 했다.

이번에는 TanStack Start, Drizzle ORM, Cloudflare D1, R2, Cloudflare Images를 조합해 그 흐름을 만들었다. 목표는 새 갤러리 테이블을 늘리는 것이 아니라, 이미 글에 연결된 이미지 정보와 원본 파일을 그대로 활용하는 것이었다.

Tiptap 본문 이미지 ID와 alt
        │
        ├─ D1 post_images / 메타데이터
        ├─ R2 / 원본 파일
        └─ Cloudflare Images 변환 + Cache API
                         │
                   본문, 썸네일, 갤러리 뷰어

글 본문과 갤러리 목록을 같은 순서로 맞추기

이미지의 원본 파일은 R2에 둔다. D1의 post_images에는 이미지 ID, R2 key, MIME type, 크기, 첨부 상태 같은 메타데이터만 저장한다. Drizzle ORM은 이 관계를 읽는 역할이다.

글 본문은 Tiptap 문서 안에 이미지 ID와 alt 텍스트를 가진다. 상세 페이지를 만들 때 본문을 한 번 훑어 이미지 ID를 모은다. 이때 중복은 제거하고, 실제로 본문에 들어간 순서는 유지한다.

html: bodyToHtml({ body, tags, images, interactiveImages: true }),
images: bodyToImages({ body, images }),

그래서 이미지를 올려 놓고 글에는 넣지 않은 파일은 갤러리에 나타나지 않는다. 본문 이미지와 갤러리의 순서가 어긋날 일도 없다. 기존 글도 다시 저장하거나 DB 마이그레이션할 필요 없이 같은 방식으로 동작한다.

본문 이미지 클릭은 전체 첨부 이미지 갤러리로 이어진다

본문 이미지는 버튼으로 감싸 클릭 가능한 상태로 렌더링했다. 클릭한 이미지의 인덱스로 Dialog와 Carousel을 열고, 아래에는 글 전체 이미지의 썸네일 목록을 보여 준다.

  • 이전, 다음 버튼과 모바일 밀기

  • 현재 선택한 썸네일을 목록 안에서 자동으로 보이게 처리

  • 선택한 이미지와 이웃 이미지 정도만 큰 크기로 불러오기

  • Escape로 닫기, 닫은 뒤 원래 클릭한 이미지로 포커스 복귀

  • 큰 이미지와 썸네일 로딩 실패 시 다시 시도

이미지가 20장이어도 처음부터 원본 20장을 모두 받지 않는다. 갤러리를 열기 전에는 본문에 필요한 이미지, 연 뒤에는 작은 썸네일과 현재 보는 큰 이미지를 나눠 요청한다.

R2는 원본, Cloudflare Images는 화면용 변환

R2는 원본을 저장하는 곳이고, 작은 썸네일을 알아서 만들어 주지는 않는다. 그래서 /api/media/:id에 세 가지 변형을 뒀다.

const options =
  variant === "thumbnail"
    ? { width: 160, height: 160, fit: "cover", quality: 70 }
    : variant === "viewer"
      ? { width: 2048, height: 2048, fit: "scale-down", quality: 85 }
      : { width: 1200, fit: "scale-down", quality: 85 }
  • 본문, 최대 너비 1200px, 품질 85

  • 갤러리 썸네일, 160×160px, cover, 품질 70

  • 모달 뷰어, 최대 2048px, scale-down, 품질 85

Cloudflare Images binding이 R2 원본을 받아 WebP, AVIF, JPEG 중 브라우저가 받을 수 있는 형식으로 변환한다. GIF는 본문과 뷰어에서 원본 애니메이션을 유지하고, 썸네일에서는 정지 WebP로 바꾼다.

변환 결과는 이미지 ID, variant, 출력 형식을 키로 Cloudflare Cache API에 하루 동안 저장한다. 다만 캐시가 있다고 바로 돌려주지는 않는다. 먼저 이미지가 실제로 첨부된 상태인지, 연결된 글이 삭제되지 않았는지 확인한다. 업로드만 하고 아직 글에 연결하지 않은 이미지는 작성자만 볼 수 있다.

미래 비용은 어디서 줄고, 어디서 늘어날까

이 구조의 이득은 모든 원본에 대해 모든 크기의 파일을 미리 만들어 R2에 쌓지 않는 데 있다. 썸네일과 뷰어 이미지는 실제로 요청된 조합만 변환한다. 같은 원본, 같은 크기, 같은 형식의 변환은 한 달 안에 반복 요청돼도 Cloudflare Images의 과금 기준상 한 번의 unique transformation으로 계산된다.

반대로 비용이 완전히 사라지는 것은 아니다.

  • R2 Standard는 저장량, Class A 쓰기, Class B 읽기로 과금한다. 월 10GB 저장, Class A 100만 회, Class B 1,000만 회는 무료고 인터넷 egress는 무료다.

  • Cloudflare Images는 R2처럼 외부에 둔 원본을 변환할 때 월 5,000개의 unique transformation까지 무료다. 그 뒤는 1,000개당 $0.50이다.

  • 이미지 응답이 Cache API hit여도 Worker 요청 자체가 무료 요청으로 바뀌지는 않는다. 캐시는 Worker CPU와 R2 원본 읽기, 변환 대기를 줄이는 쪽의 이득이 더 크다.

  • D1은 원본 binary를 저장하지 않고 메타데이터만 저장하므로 DB 저장량은 작다. 다만 접근 상태를 확인하는 읽기는 계속 발생하므로 rows_read를 관측해야 한다.

예를 들어 원본 이미지 10,000장이 평균 3MB라면 R2 Standard 저장량은 약 30GB다. 무료 10GB를 뺀 20GB가 월 $0.015/GB 기준으로 약 $0.30이다. 이 숫자는 저장료만 계산한 예시다. 실제 전체 비용은 원본 수, 각 variant와 출력 형식이 처음 요청되는 수, 이미지 조회량, Worker 요금제에 따라 달라진다.

그래서 운영에서 볼 숫자는 세 가지다. R2 저장 GB, Images의 unique transformation 수, D1의 rows_read다. 갤러리가 많이 쓰여도 같은 이미지와 같은 변형을 반복 보는 패턴이라면 변환 비용은 천천히 늘어난다. 반대로 새 원본과 새 변형을 대량으로 한꺼번에 열면 무료 5,000개를 빨리 넘는다.

R2에 원본을 두고 Cloudflare Images로 필요한 크기만 만들면, 처음에는 단순하고 미래에는 비용을 예측할 수 있다. 다만 이것은 “무조건 싸다”는 선택이 아니라, 사용량을 세 가지 지표로 분해해 보고 조절할 수 있게 만드는 선택이다.

#dev

좋아요저장

댓글