ADR‐011: 이미지 변환은 자체 서버가 아니라 Cloudflare가 한다 - Moadong/moadong GitHub Wiki

작성일: 2026-09-25 | 상태: Accepted
결정일자: 2026-09-25

⚠️ 변환 소스를 .webp로 둔 부분과 모달 본 이미지를 원본 그대로 보여주는 부분은 ADR‐013: 이미지 변환은 Cloudflare가 원본에서 한다으로 대체됐습니다. Go 서버가 만든 .webp가 품질 약 1로 인코딩되고 있었고, 모달도 이제 Cloudflare가 원본에서 긴 변 2400px로 줄입니다.

관련: ADR‐007: 이미지 업로드 방식 결정 · ADR‐009: 웹뷰 이미지 업로드는 브리지를 경유하지 않는다

요약

  • 문제: Go 변환 서버는 포맷만 WebP로 바꾸고 크기는 그대로 둬서, 186px 칸에 4000px 원본이 내려가고 있었습니다.
  • 결정: 크기별 변형은 업로드 때 만들지 않고 요청 시점에 Cloudflare Image Transformations로 만듭니다. 원본은 R2에 그대로 둡니다.
  • 결과: 배포 후 프로덕션 66개 동아리 286장 기준으로 상세 진입 이미지 합계가 40.6MB에서 8.5MB로 79% 줄었습니다. 동아리별 중앙값은 −34%(활동사진이 있는 32곳)이고, 이미 작은 이미지는 오히려 커지기도 했습니다(배포 후 프로덕션 실측).

왜 이 결정이 필요했나요?

지금까지 이미지 WebP 변환은 별도 Go 서버가 했습니다. R2 버킷을 하루 한 번 스캔해 WebP가 아닌 객체를 변환하고, 같은 경로에 .webp로 새로 저장한 뒤 백엔드에 웹훅을 보내 DB의 URL을 바꾸는 구조입니다. 프로덕션 이미지 54장을 확인했더니 전부 image/webp로, 서버는 제대로 동작하고 있었습니다.

ADR-011-전-Go변환서버

그런데도 상세 페이지가 무거웠습니다. 변환이 포맷만 바꾸고 크기는 그대로 두고 있었기 때문입니다. 활동사진 원본은 4000×3000급(표본 12장 너비 중앙값 4000px)인데, 데스크톱 상세에서 활동사진 한 칸은 186px입니다(오른쪽 영역 756px을 4열, gap 4px로 나눈 값). 선형으로 21.5배, 픽셀 수로는 약 462배를 더 보내고 있었습니다.

이 문제는 크론에 리사이즈를 빠뜨려서가 아니라, 지금 구조로는 붙일 수 없어서 생겼습니다. 변환 결과가 원본과 같은 크기의 WebP 한 벌뿐이라, 그리드용 작은 버전과 전체보기용 큰 버전을 함께 가지려면 크기별로 파일을 여러 벌 저장하고, 프론트에 srcset을 깔고, DB 스키마에 변형 목록을 둬야 합니다.


어떤 결정을 내렸나요?

Go 변환 서버를 걷어내고 이미지 변환을 Cloudflare Image Transformations에 맡깁니다.

원본은 R2에 그대로 두고, 전달할 때 URL에 변환 파라미터를 붙입니다. ADR-011-후-Cloudflare

https://cdn.moadong.com/cdn-cgi/image/format=auto,width=480,height=600,fit=crop/<원본 경로>

format=auto는 브라우저의 Accept 헤더를 보고 AVIF, WebP, 원본 순으로 지원되는 포맷을 고르고, width·height·fit으로 표시 크기에 맞춰 줄입니다. 필요한 크기를 요청 시점에 만들기 때문에 파일을 여러 벌 저장할 필요가 없고, 프론트는 URL 접두사만 붙이면 됩니다. 변환 소스는 DB가 가리키는 .webp를 그대로 씁니다. 손실 인코딩이 두 번 들어가지만, 그리드처럼 크게 줄이는 용도에서는 눈으로 비교해 차이를 찾지 못했습니다.

용도별 값은 다음과 같습니다(#2110).

용도 옵션 근거 (칸이 가장 커지는 화면 × DPR)
활동사진 그리드 width=480,height=600,fit=crop 태블릿 3열 232×290 ×2, 폰 165×207 ×3
커버 width=1500,height=1500,fit=scale-down 태블릿 700×220 ×2, 폰 500×220 ×3
로고 width=192,height=192,fit=crop 64 ×2, 57 ×3
모달 썸네일 width=88,height=88,fit=crop 40 ×2, 28 ×3
모달 본 이미지 변환 안 함 (원본) 아래 참고
  • 칸 크기는 화면 너비 8단계(390~1440)에서 실제 렌더 크기를 재서 정했습니다. 그리드 칸은 데스크톱(186px)보다 태블릿(232px)에서 더 큽니다.
  • 그리드·로고·썸네일은 칸 비율이 고정이라 crop으로 가운데를 미리 자릅니다. cover와 달리 원본이 요청보다 작을 때 키우지 않습니다.
  • 커버는 칸 비율이 화면마다 1.8:1~3.2:1로 달라서, scale-down으로 비율만 유지한 채 줄이고 자르기는 CSS에 맡깁니다.
  • 출력 면적은 모두 2,560,000px(1600×1600) 이하로 둡니다. 이 값을 넘으면 format=auto가 AVIF 대신 WebP를 내보내 원본보다 커질 수 있습니다(실측).

모달 본 이미지는 원본을 씁니다. q85로 이미 압축된 4000px webp는 다시 인코딩하면 작아지지 않습니다.

모달 옵션 원본 575KB 사진 원본 313KB 사진
width=2400 1,511KB (+163%) —
width=2000,quality=75 824KB (+43%) 440KB (+41%)
width=1600,quality=75 471KB (−18%) 263KB (−16%)

가장 나은 경우도 해상도(레티나 1200px 모달에는 2400px 필요)와 화질을 포기하고 16~18% 줄이는 데 그칩니다. 대신 그리드와 모달의 URL이 달라져, 모달을 열면 원본을 새로 받습니다.

다른 대안들:

  • ❌ Go 서버에 리사이즈 추가: 크기별로 파일을 여러 벌 만들어야 해서 스토리지가 배수로 늘고, 프론트에 srcset, DB에 변형 목록이 따라붙습니다.
  • ❌ 업로드 전에 프론트에서 축소: 한 번 줄이면 원본 화질을 영구히 잃습니다. 같은 사진이 그리드·썸네일·모달 세 크기로 쓰이는데 업로드 때는 한 크기만 남고, 이미 올라간 705개에는 적용되지 않으며, 칸 크기가 바뀌면 다시 올려야 합니다. 업로드 바이트를 줄이는 건 별개 문제라 이 결정과 함께 쓸 수 있고, ADR‐009에서 다룹니다.
  • ❌ Cloudflare Images에 저장까지 맡기기: 원본을 R2 밖으로 옮기면 ADR‐007·ADR‐008의 키 설계와 검증 경계가 흔들립니다.
  • ❌ 그대로 두기: 활동사진이 많은 동아리는 상세에 들어가기만 해도 수 MB를 받습니다.

어떤 효과가 있나요?

배포 후 프로덕션 실측 (2026-09-27)

공개 API로 동아리 69곳의 활동사진·커버·로고 URL을 모으고, 이미지마다 원본과 변환 URL을 각각 받아 바이트를 비교했습니다(브라우저와 같게 Accept에 AVIF 포함). 이미지가 있는 66곳, 286장입니다. 동아리마다 상세를 한 번 열 때 받는 양을 합친 값이라 방문 수 가중치는 없습니다.

용도 전 후 절감
활동사진 그리드 (207장) 38.84 MB 7.36 MB −81.1%
커버 (14장) 1.00 MB 0.86 MB −13.5%
로고 (65장) 0.72 MB 0.29 MB −59.5%
합계 (286장) 40.6 MB 8.5 MB −79.0%

합계는 큰 원본이 좌우합니다. 동아리별 중앙값은 −34%(활동사진이 있는 32곳)이고, JPEG 등 webp가 아닌 원본 9장을 빼면 −54%입니다.

286장 중 123장은 오히려 커졌습니다(합계 +1.1MB). 이미 작은 webp(그리드 원본 중앙값 18KB)나 1500px보다 작은 커버를 Cloudflare가 기본 품질로 다시 인코딩해서 생깁니다. 전체로는 32MB가 줄어 되돌리지 않았고, quality=로 해결되는지는 확인하지 않았습니다.

화면에서도 확인했습니다. 산악부·송웨이브·WAP 상세를 데스크톱과 모바일에서 열었을 때 변환 요청은 전부 image/avif로 왔고 깨진 이미지는 없었습니다.

모달은 원본을 한꺼번에 받습니다. Swiper가 슬라이드를 전부 마운트해서, 모달을 여는 순간 그 동아리의 원본 사진을 모두 받습니다(송웨이브 약 22MB). 모달을 여는 사람이 받는 총량은 전과 비슷하고, 슬라이드를 볼 때만 받게 하는 건 후속 과제입니다.

장점:

  • 표시 크기별 변형을 파일 저장 없이 씁니다.
  • AVIF가 추가 작업 없이 따라옵니다.
  • 업로드 후 최대 24시간 걸리던 변환 대기가 사라집니다.
  • Go 서버, 크론, 웹훅, DB URL 치환을 운영하지 않아도 됩니다.
  • 비용이 들지 않습니다. Free 플랜 한도가 월 5,000 unique transformations이고, 이미지 705개에 변형 2종을 잡아도 월 1,410건(28%)입니다.

단점/트레이드오프:

  • 처음 요청되는 변형은 만드는 데 1~3초가 걸립니다. 이후에는 엣지 캐시에서 나갑니다.
  • 모달을 열 때 원본을 새로 받습니다(위 참고).
  • 월 한도를 넘기면 과금이 아니라 9422 에러로 이미지가 안 나옵니다. 변형 종류를 늘릴 때는 이미지 수 × 변형 수를 다시 계산해야 합니다.
  • 전달 경로까지 Cloudflare에 묶입니다. 원본이 R2에 그대로 있어서 되돌릴 때 잃는 건 URL 접두사뿐입니다.

다시 볼 조건: 이미지가 약 4,900장으로 늘거나 변형을 6~7종까지 쓰게 되면 한도를 다시 계산합니다. 첫 요청 지연이 문제가 되면 업로드 직후 주요 변형을 미리 요청해 캐시를 채우는 방법을 먼저 검토합니다.


적용 순서

  1. 존에 Transformations 활성화 (완료)
  2. 프론트에 변환 URL 적용: #2110, 2026-09-27 프로덕션 배포 (완료)
  3. Go 서버 크론 중단: 코드는 남겨 둔 채 스케줄만 끕니다. 이후 새 이미지는 원본 JPEG로 남아 모달이 수 MB를 받게 되므로, 그 전에 모달 본 이미지 변환 여부를 다시 정합니다.
  4. 관측 후 백엔드 변환 연동 코드 제거: ImageConversionCompletedWebhookController, ImageConversionCompletedWebhookService, WebpMigrationService, ConversionBatchAdminController와 관련 DTO
  5. Go 서버 레포 아카이브

2번보다 3번을 먼저 하면 새 이미지가 변환 없이 원본 그대로 나가고, 3번보다 4번을 먼저 하면 Go 서버의 웹훅이 실패해 재전송 큐에 쌓입니다.

아직 재지 않은 것은 시간 지표(커버 도착 시각, 모바일 네트워크에서 모달 첫 장)입니다. LCP 요소는 텍스트라 이 변경과 관계가 없습니다.

범위 밖에서 발견한 결함도 하나 있습니다. 활동사진 탭을 열지 않아도 피드 이미지가 최대 15장 즉시 로드됩니다(display: none + loading="eager"). Cloudflare와 무관한 프론트 수정이라 #2105에서 따로 다룹니다.


참고 자료

⚠️ **GitHub.com Fallback** ⚠️