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로, 서버는 제대로 동작하고 있었습니다.
그런데도 상세 페이지가 무거웠습니다. 변환이 포맷만 바꾸고 크기는 그대로 두고 있었기 때문입니다. 활동사진 원본은 4000×3000급(표본 12장 너비 중앙값 4000px)인데, 데스크톱 상세에서 활동사진 한 칸은 186px입니다(오른쪽 영역 756px을 4열, gap 4px로 나눈 값). 선형으로 21.5배, 픽셀 수로는 약 462배를 더 보내고 있었습니다.
이 문제는 크론에 리사이즈를 빠뜨려서가 아니라, 지금 구조로는 붙일 수 없어서 생겼습니다. 변환 결과가 원본과 같은 크기의 WebP 한 벌뿐이라, 그리드용 작은 버전과 전체보기용 큰 버전을 함께 가지려면 크기별로 파일을 여러 벌 저장하고, 프론트에 srcset을 깔고, DB 스키마에 변형 목록을 둬야 합니다.
Go 변환 서버를 걷어내고 이미지 변환을 Cloudflare Image Transformations에 맡깁니다.
원본은 R2에 그대로 두고, 전달할 때 URL에 변환 파라미터를 붙입니다.

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를 받습니다.
공개 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종까지 쓰게 되면 한도를 다시 계산합니다. 첫 요청 지연이 문제가 되면 업로드 직후 주요 변형을 미리 요청해 캐시를 채우는 방법을 먼저 검토합니다.
- 존에 Transformations 활성화 (완료)
- 프론트에 변환 URL 적용: #2110, 2026-09-27 프로덕션 배포 (완료)
- Go 서버 크론 중단: 코드는 남겨 둔 채 스케줄만 끕니다. 이후 새 이미지는 원본 JPEG로 남아 모달이 수 MB를 받게 되므로, 그 전에 모달 본 이미지 변환 여부를 다시 정합니다.
- 관측 후 백엔드 변환 연동 코드 제거:
ImageConversionCompletedWebhookController,ImageConversionCompletedWebhookService,WebpMigrationService,ConversionBatchAdminController와 관련 DTO - Go 서버 레포 아카이브
2번보다 3번을 먼저 하면 새 이미지가 변환 없이 원본 그대로 나가고, 3번보다 4번을 먼저 하면 Go 서버의 웹훅이 실패해 재전송 큐에 쌓입니다.
아직 재지 않은 것은 시간 지표(커버 도착 시각, 모바일 네트워크에서 모달 첫 장)입니다. LCP 요소는 텍스트라 이 변경과 관계가 없습니다.
범위 밖에서 발견한 결함도 하나 있습니다. 활동사진 탭을 열지 않아도 피드 이미지가 최대 15장 즉시 로드됩니다(display: none + loading="eager"). Cloudflare와 무관한 프론트 수정이라 #2105에서 따로 다룹니다.