ADR‐013: 이미지 변환은 Cloudflare가 원본에서 한다 - Moadong/moadong GitHub Wiki

작성일: 2026-09-28 | 수정일: 2026-10-01 | 상태: Proposed

관련: ADR‐011: 이미지 변환은 자체 서버가 아니라 Cloudflare가 한다 · ADR‐009: 웹뷰 이미지 업로드는 브리지를 경유하지 않는다

요약

  • 문제: Go 변환 서버가 2026-02-22부터 WebP를 품질 85가 아니라 약 1로 인코딩했고, 2026-09-29에 고친 뒤 원본에서 손실 품질 100으로 다시 만들었습니다. 화질은 대체로 돌아왔지만 .webp 합계가 원본의 약 2배가 됐고, 12MP 사진은 원본보다 2배 크면서 화질은 품질 85보다 낮습니다. ADR‐011은 이 .webp를 Cloudflare 변환의 소스로 삼았고, 모달과 홈·지도 로고는 이 파일을 그대로 받습니다. 소스 품질과 용량이 Go 서버 설정 하나에 매여 있습니다.
  • 결정: 이미지 변환의 주체는 Cloudflare입니다. 업로드는 원본 그대로 하고, Cloudflare가 R2의 원본에서 화면별 크기(그리드·커버·로고·썸네일)와 모달(긴 변 2400px)을 만듭니다. 동아리 상세뿐 아니라 홈 목록·지도 카드 로고처럼 사용자 화면에 나오는 동아리 이미지 전부에 적용합니다. Go 서버는 걷어냅니다. 클라이언트 리사이즈는 하지 않는 게 아니라 보류합니다. 업로드가 불편하다는 근거가 생기면 이 구조 위에 덧붙입니다.
  • 근거: 조회가 업로드보다 약 820배 많습니다. 보는 쪽 결과는 Cloudflare가 전부 하든 클라이언트가 먼저 줄이든 거의 같고, 클라이언트가 이기는 건 1년에 약 200번 일어나는 업로드 시간뿐입니다. 업로드가 실제로 불편한지는 아직 계측하지 않았습니다.

왜 이 결정이 필요했나요?

Cloudflare 변환을 붙이기 전(#2110 이전)의 흐름입니다. cdn.moadong.com은 처음부터 Cloudflare CDN을 거쳤지만 캐시만 했고, 모든 화면이 원본 해상도 파일을 그대로 받았습니다. #2110 이후로는 동아리 상세에서만 CDN이 이 파일을 소스로 크기를 줄입니다. 크론이 돈 뒤에는 그 소스가 Go 서버가 만든 .webp입니다.

Go 변환 서버만 쓰던 흐름

Go 서버가 만든 WebP는 품질 약 1이었습니다

Go 변환 서버는 2026-02-22 커밋 296f64c("window 호환성 추가")에서 인코더를 chai2010/webp에서 skrashevich/go-libwebp로 바꿨습니다. 새 라이브러리 문서에 품질 범위가 (0, 1]이라고 적혀 있어서 설정값 85를 100으로 나눠 0.85를 넘겼는데, 라이브러리는 이 값을 0~100 범위를 기대하는 libwebp WebPConfigPreset에 그대로 전달합니다. libwebp는 여기서 한 번 더 100으로 나눠 양자화 강도를 정하므로, 의도한 양자화값 14(0이 최고 화질, 127이 최저) 대신 104가 됩니다.

같은 12MP 사진을 이 라이브러리로 인코딩하면 넣은 값이 0.01이든 0.99든 124156KB로 거의 같고(libwebp 품질 01 구간), 1.0을 넣으면 무손실로 빠져 약 8MB가 됩니다. 이 라이브러리로는 손실 압축 품질 85를 낼 방법이 없습니다. 2026-09-28에 잰 프로덕션 활동사진 16장도 전부 libwebp 품질 1로 인코딩한 크기와 ±2% 안에서 일치했습니다. 그동안 "WebP 전환으로 활동사진 −95%"라고 알고 있던 절감도 대부분 이 화질 저하에서 나온 숫자입니다.

다행히 Go 서버는 원본을 지우지 않아서, DB가 가리키는 .webp 277개 중 274개의 원본이 같은 경로에 남아 있습니다.

2026-09-29에 고쳤지만, 이번에는 원본보다 커졌습니다

Go 서버 커밋 32c89e6(2026-09-29)이 인코더를 순수 Go 라이브러리 KarpelesLab/gowebp로 바꾸고 설정값을 0100 그대로 넘기도록 고쳤습니다. 같은 날 02:03(KST)에 원본이 있는 .webp 271개를 원본에서 다시 인코딩해 같은 키에 덮어썼고, DB URL은 그대로입니다. 파일 헤더는 전부 손실(VP8)이고 무손실(VP8L)은 없습니다. 같은 원본을 gowebp로 품질 75100에서 인코딩해 보니 품질 100 결과와 바이트 단위로 일치해서, 운영 설정은 손실 품질 100입니다.

샘플(활동사진 8장) 지금 .webp (gowebp 품질 100) libwebp 품질 85
1~1.6MP 6장 4431,305KB, PSNR 3746dB 78286KB, 3842dB
12MP 2장 8.4MB, 31dB 1.6~1.9MB, 39.5dB

.webp 합계는 약 650MB로 원본(약 350MB)의 2배입니다. 작은 사진은 화질이 품질 85와 비슷한데 용량이 4~6배이고, 12MP 사진은 원본보다 2배 크면서 화질은 품질 85보다 낮습니다. 12MP에서 화질이 떨어지는 원인은 확인하지 못했습니다.

변환은 동아리 상세에만 걸려 있었습니다

#2110 이후 Cloudflare 변환(cdnImage())을 쓰는 곳은 동아리 상세의 활동사진 그리드·커버·로고·모달 썸네일뿐입니다. 홈 동아리 목록(ClubCard)과 지도 카드(MapClubInfoCard)는 DB의 로고 URL을 그대로 그립니다. 홈 목록 로고 65개는 지금 DB가 가리키는 파일 합계가 11.35MB(가장 큰 파일 1.35MB)이고, 원본으로 되돌리면 7.10MB, 상세 페이지처럼 192px로 변환하면 약 0.3MB입니다. 1MB 안팎의 큰 로고는 09-29 품질 100 재변환으로 만들어진 파일입니다.

"누가 변환할지"를 다시 정해야 했습니다

2026-03-07 전체 45차 회의에서는 "클라이언트에서 WebP로 바꾸면 원본보다 업로드가 느리다"는 측정으로 서버 변환을 택했습니다. 그런데 그 측정은 업로드가 81~107Mbps로 빠른 회선 한 곳에서 조건마다 한 번씩만 잰 값이었고, 그 무렵 Go 서버는 이미 배포(2026-02-16)된 뒤였습니다. 그래서 클라이언트 변환, 클라이언트 리사이즈, Cloudflare 변환을 같은 사진으로 다시 쟀습니다.


어떤 결정을 내렸나요?

이미지 변환은 Cloudflare가 R2의 원본에서 합니다.

  • 클라이언트는 사진을 가공하지 않고 presigned URL로 R2에 올립니다(ADR‐007 흐름 그대로).
  • DB는 원본 URL을 가리킵니다. Go 서버가 .webp로 바꿔 둔 URL은 원본 확장자로 되돌립니다.
  • 화면별 크기는 지금처럼 cdnImage()가 /cdn-cgi/image/... 접두사로 만듭니다(그리드 480×600, 커버 1500, 로고 192, 썸네일 88). 홈 목록·지도 카드 로고에도 같은 로고 변환을 겁니다.
  • 모달 본 이미지도 Cloudflare가 긴 변 2400px로 줄여 내려줍니다(레티나 1200px 모달에 필요한 해상도). 슬라이드는 보이는 사진과 양옆만 불러옵니다.
  • Go 변환 서버, 크론, 웹훅, DB URL 치환은 걷어냅니다.

업로드 단계는 그대로이고, 업로드 뒤에 DB URL이 바뀌는 일이 없어집니다. 그림의 a.jpg는 관리자가 올린 원본(jpg·jpeg·png)을 줄여 쓴 것입니다. 원본 그림은 images/ADR-013-flow.excalidraw를 excalidraw.com에서 열어 고칠 수 있습니다.

Cloudflare가 원본에서 변환하는 흐름

다른 대안들:

  • ⏸ 클라이언트에서 긴 변 2400px로 리사이즈한 뒤 업로드(+ Cloudflare): 업로드가 크게 빨라지지만(아래 측정), 보는 쪽 결과는 이 결정과 거의 같습니다. 원본 해상도는 보존하지 않아도 되므로 그 점은 걸림돌이 아닙니다. 넣을 자리도 동아리 이미지 업로드 훅 3개(useUploadFeed·useUploadLogo·useUploadCover, 화면 4곳)에 모여 있어 비용이 크지 않습니다. 로고는 투명 PNG가 많아 포맷은 유지하고 크기만 줄여야 합니다. 보류하는 이유는 하나, 업로드가 느려서 관리자가 사진을 덜 올리거나 중간에 포기한다는 근거가 아직 없다는 것입니다. 근거가 생기면 이 결정 위에 업로드 단계만 덧붙입니다(보는 쪽 코드는 바뀌지 않습니다).
  • ❌ 클라이언트가 원본 크기 그대로 WebP로 변환: iOS는 canvas로 WebP를 만들 수 없어서(요청하면 PNG가 나옵니다) WASM 인코더가 필요하고, 15장에 19.2초가 걸려 업로드가 20Mbps보다 빠르면 원본을 그냥 올리는 쪽이 빠릅니다.
  • ❌ 클라이언트가 화면별 크기를 전부 만들기: 크기별로 파일을 여러 벌 저장하고, DB에 변형 목록을, 프론트에 srcset을 둬야 합니다(ADR‐011에서 기각).
  • ❌ Go 서버 품질을 고쳐 계속 쓰기(2026-09-29에 실제로 고쳤습니다): 화질은 대체로 돌아왔지만 원본의 2배 용량이 나왔고, 화면별 크기는 여전히 못 만듭니다. 크기 맞춤은 어차피 Cloudflare가 하므로 Go 서버가 하는 일은 크론이 돈 뒤 DB URL을 .webp로 바꾸는 것뿐이고, 그만큼 장애 지점과 소스 품질 위험만 남습니다.
  • ❌ 그대로 두기: 모달과 홈·지도 로고가 원본보다 큰 .webp(12MP 한 장 8.4MB)를 받고, 소스 품질이 Go 서버 설정 하나에 매여 있습니다.

왜 이게 가장 효율적인가요?

측정값은 "한 번 할 때 얼마나 걸리나"이고, 효율은 그 일이 얼마나 자주, 누구에게 일어나느냐로 정해집니다.

조회가 업로드보다 약 820배 많고, 같은 달에 몰립니다

Mixpanel 2025-10 ~ 2026-09 기준입니다.

12개월 2026-02 (정점)
상세 페이지 방문 162,414회 50,408회
활동사진 업로드 버튼 클릭 198회 (관리자 44명) 87회 (25명)

업로드 버튼 클릭은 파일 선택창을 여는 횟수라 실제 업로드는 이보다 적을 수 있습니다. 관리자는 모집 직전(2월)에 사진을 올리고 조회도 그 달에 가장 많습니다. 업로드 기기는 데스크톱 58%, 아이폰 33%, 안드로이드 9%였습니다.

보는 쪽 결과는 누가 변환하든 거의 같습니다

"Cloudflare가 전부"와 "클라이언트가 2400px로 줄여 올린 뒤 Cloudflare"를 이미지 경로별로 비교했습니다.

경로 빈도 Cloudflare가 전부 (이 결정) 클라이언트 리사이즈 + Cloudflare
그리드·커버 캐시 이후 조회마다 0.30~0.35s 같음
그리드·커버 첫 요청 이미지·변형당 1회 원본 소스 1.14~1.36s 소스가 작아 비슷하거나 조금 빠를 것(미측정)
모달 한 장 모달 열 때 약 0.64MB, 첫 요청 1.8s 0.5~0.7MB, 첫 요청 없음
로고(투명 PNG) 조회마다 문제없음 JPEG로 바꾸면 투명 배경이 깨져 예외 처리 필요

그리드·커버는 두 방식 모두 Cloudflare가 같은 옵션으로 만들어서 캐시 이후 결과가 같습니다. 모달은 프로덕션 12MP 원본 8장(25.85MB)을 Cloudflare가 2400px로 줄였을 때 5.09MB였고, 같은 사진을 로컬에서 2400px로 줄이면 JPEG q82 5.69MB, WebP q82 4.13MB였습니다. 2400×1800은 AVIF 면적 한도(2,560,000px)를 넘어서 Cloudflare 출력은 WebP로 나옵니다.

클라이언트가 이기는 건 업로드 시간뿐입니다

아이폰(iOS 26.6.1)에서 갤러리로 고른 사진 15장(47.6MB, 24MP 포함)을 관리자 화면과 같은 방식으로 올렸습니다. 업로드 대역을 제한한 로컬 서버로 올렸고, 조건마다 3회 중앙값입니다.

방식 변환(15장) 올리는 용량 5Mbps 약 15Mbps
원본 그대로 (이 결정) 없음 47.6MB 76.2s 25.9s
2400px 리사이즈 + WASM WebP q82 6.8s 4.94MB 8.4s 6.9s
2400px 리사이즈 + canvas JPEG q0.82 2.8s 13.42MB 21.8s 7.6s
원본 크기 WASM WebP q82 19.2s 13.1MB 22.7s —

리사이즈를 하면 원본을 그냥 올리는 것보다 대부분의 회선에서 빠릅니다. 이 차이는 1년에 약 200번 일어나고 업로더의 절반 이상은 데스크톱이지만, 빈도가 낮다고 불편이 작다는 뜻은 아닙니다. 업로드가 느려서 사진을 덜 올리는지는 계측한 적이 없어서, 지금은 "업로드 시간은 덜 중요하다"고 결론 내리지 않고 보류합니다. 보는 쪽 결과가 같으니, 근거 없이 업로드 단계에 코드를 더할 이유도 없습니다.


어떤 효과가 있나요?

장점:

  • 그리드·커버·로고·모달이 모두 정상 화질의 원본에서 만들어집니다.
  • Go 서버 설정 하나로 모든 이미지의 화질과 용량이 바뀌는 일이 없어집니다(2월~9월 품질 약 1, 9월 29일 이후 품질 100).
  • 모달 한 장이 지금(품질 100 .webp, 12MP 8.4MB)보다 약 0.6MB로 작아집니다.
  • 홈 목록 로고가 11.35MB에서 약 0.3MB로 줄어듭니다.
  • 업로드 흐름과 관리자 화면 코드는 그대로입니다.
  • Go 서버, 크론, 웹훅, DB URL 치환을 운영하지 않습니다. 서버 다운(64차 회의)과 웹훅 유실(65차 회의) 같은 장애 지점이 사라집니다.
  • 원본이 R2에 남아 있어서, 나중에 표시 크기가 바뀌어도 옵션만 고치면 됩니다.

단점/트레이드오프:

  • 이미지·변형마다 첫 요청이 1~2초 걸립니다(12MP 기준 그리드 1.14s, 커버 1.36s, 모달 1.80s 중앙값).
  • 품질 약 1짜리 소스와 비교하면 출력이 커집니다(그리드 +18%, 커버 +58%, 2026-09-28 측정). 그때 크기가 작았던 건 화질이 무너져서였습니다. 09-29 이후의 품질 100 소스와는 비슷할 것으로 보지만 다시 재지 않았습니다.
  • 느린 회선에서 올리는 관리자는 계속 오래 기다립니다(15장, 5Mbps 기준 약 76초).
  • 변형이 하나(모달) 늘어서 월 한도(5,000 unique transformations)를 더 씁니다. 활동사진이 약 300장이면 그리드·썸네일·모달 3종에 커버·로고를 더해 월 1,000건 안팎(추정)입니다.

다시 볼 조건:

  • 업로드 소요 시간·기기·회선과 업로드 도중 이탈을 계측합니다. 느린 업로드가 잦거나 이탈이 확인되면 업로드 단계에 클라이언트 리사이즈(긴 변 2400px, 로고는 포맷 유지)를 덧붙입니다.
  • 아이폰 최신 기종의 24MP 원본이 소스일 때 첫 요청이 3초를 자주 넘으면, 업로드 직후 주요 변형을 미리 요청해 캐시를 채우거나 클라이언트 리사이즈를 봅니다.
  • 이미지나 변형 종류가 늘어 월 한도에 가까워지면 다시 계산합니다. 한도를 넘기면 과금이 아니라 9422 에러로 이미지가 안 나옵니다.

적용 순서

  1. 모달 슬라이드를 지연 로딩으로 바꿉니다(프론트, #2119).
  2. 홈 목록·지도 카드 로고에 Cloudflare 로고 변환을 겁니다(프론트, #2124).
  3. 모달 본 이미지에 Cloudflare 2400px 변환을 겁니다(프론트).
  4. Go 서버 크론을 멈춥니다.
  5. DB의 .webp URL을 원본 확장자로 되돌립니다(백엔드). R2에서 같은 키의 원본을 찾아 그 확장자(jpg·jpeg·png, 대소문자 그대로)로 바꾸고, 원본이 없는 3개는 .webp로 둡니다. 이미 원본 URL인 9개(크론은 변환했지만 웹훅이 DB를 바꾸지 못한 것)는 그대로 둡니다. 활동사진 저장(POST /feeds)은 목록의 URL을 전부 R2에 HEAD로 확인해서 하나라도 없으면 요청 전체를 FILE_NOT_FOUND로 거절하므로, 없는 파일을 가리키게 하면 그 동아리는 활동사진을 고칠 수 없게 됩니다.
  6. 백엔드 변환 연동 코드를 제거합니다(ImageConversionCompletedWebhookController, ImageConversionCompletedWebhookService, WebpMigrationService, ConversionBatchAdminController와 관련 DTO, static/dev/index.html의 변환 배치 UI).
  7. Go 서버 레포를 아카이브합니다.

13번은 소스와 관계없이 효과가 나서 먼저 합니다. 지금 모달은 품질 100 .webp(12MP 한 장 8.4MB)를 그대로 받으므로 1번이 한 번에 받는 장수를 13장으로 줄이고, 3번이 한 장을 약 0.6MB로 줄입니다. 2번은 홈 로고 합계를 11.35MB에서 약 0.3MB로 줄입니다. 09-29 이전에는 3번을 DB 되돌리기 뒤에 해야 했지만(품질 약 1짜리 소스를 2400px로 다시 인코딩하면 커지기만 합니다, ADR‐011 실측 +163%), 지금 소스는 품질 100이라 순서 제약이 없습니다.

4번은 5번보다 먼저 합니다. 크론은 마지막 실행 이후 바뀐 R2 객체만 훑어서 평소에는 되돌린 URL을 건드리지 않지만, 상태 파일이 사라지면 처음부터 다시 훑어 웹훅으로 DB를 다시 .webp로 바꿉니다. 크론이 살아 있는 동안 새로 올린 이미지도 계속 품질 100 .webp로 바뀝니다. 크론을 먼저 멈추면 새 이미지는 원본 URL, 기존 이미지는 .webp URL로 섞이지만 둘 다 Cloudflare가 변환하므로 문제없습니다.

5번은 화질이 떨어지는 12MP 사진과 원본의 2배인 소스를 정리하는 일이라, 1~3번이 끝나면 급하지 않습니다. 6번을 4번보다 먼저 하면 Go 서버의 웹훅이 실패해 재전송 큐에 쌓입니다.


측정 조건과 한계

  • 클라이언트 업로드: 아이폰 1대(iOS 26.6.1, WebKit), 업로드 대역을 제한한 로컬 서버(R2 아님), 조건당 3회. 안드로이드는 재지 않았습니다.
  • Go 변환: M 시리즈 맥에서 측정했습니다.
  • 09-29 재변환 확인(2026-10-01): CDN 캐시를 우회해 .webp 277개를 받아 헤더와 last-modified를 확인했고, 활동사진 8장으로 PSNR을, 3장으로 gowebp 품질 재현을 했습니다. 동아리 목록은 공개 API 기준(69곳)이라 숨김 동아리는 빠졌을 수 있습니다.
  • Cloudflare: 측정 회선에서 요청이 홍콩·도쿄·오사카 엣지로 갔습니다. 국내 사용자는 인천 엣지로 가면 더 빠를 수 있습니다. 24MP 원본은 프로덕션에 없어서 12MP까지만 쟀습니다. 클라이언트가 줄인 2400px 파일을 소스로 한 첫 요청은 재지 않았습니다.
  • 모달의 클라이언트 리사이즈 용량은 맥의 Pillow로 계산한 근사치입니다.
  • 홍보·배너 이미지는 이번 비교에 넣지 않았습니다(업로드 경로가 다릅니다, ADR‐008). Go 웹훅이 DB URL을 바꾸는 대상은 동아리 로고·커버·활동사진뿐이라, 홍보 이미지는 지금도 원본이 변환 없이 나가고 있을 가능성이 큽니다. 같은 원칙(변환 주체는 Cloudflare)을 적용할지는 현재 상태를 확인한 뒤 따로 정합니다.
  • Mixpanel: 업로드는 버튼 클릭 기준이고, 2026-06의 Club Feed Tab Clicked(23,215회)가 같은 달 상세 방문(4,859회)보다 많은 이상치가 있어 판단에 쓰지 않았습니다.

참고 자료

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