Cloudflare Image Transformations로 이미지 최적화하기
왜 이미지 최적화가 필요했나
블로그에 이미지가 쌓이면서 페이지 로딩이 눈에 띄게 느려졌어요. 삿포로 여행기에 사진을 잔뜩 넣었는데 모바일에서 열면 원본 이미지를 그대로 내려받느라 로딩이 한참 걸리더라고요. Lighthouse를 돌려보니 이미지가 성능 저하의 가장 큰 원인이었어요.
문제는 크게 두 가지였어요.
- 불필요하게 큰 해상도: 모바일 화면에서 굳이 1600px 원본을 받을 필요가 없어요
- 포맷 최적화 X: PNG나 JPEG 원본을 그대로 서빙하고 있었는데 WebP나 AVIF로 바꾸면 용량이 절반 이하로 줄어요
이미지를 미리 여러 사이즈로 만들어두는 방법도 있지만 사이즈별로 파일을 관리하는 게 비효율적이에요. R2에는 원본 하나만 올려두고 요청 시점에 필요한 크기와 포맷으로 변환해주는 게 이상적이었어요.
마침 Cloudflare에 Image Transformations라는 기능이 있었어요.
Cloudflare Image Transformations가 뭔데
Cloudflare Edge에서 이미지를 실시간으로 리사이즈·포맷 변환·품질 조정해주는 기능이에요. 원본 URL 앞에 /cdn-cgi/image/ 경로와 파라미터를 붙이면 변환된 이미지가 응답으로 내려와요.
원본:
https://www.seung-woo.me/api/media?key=1234-photo.png
변환:
https://www.seung-woo.me/cdn-cgi/image/width=800,format=avif,quality=85/api/media?key=1234-photo.png
format=avif로 설정하면 원본 포맷과 관계없이 AVIF로 변환돼요. AVIF는 WebP보다 압축률이 20-30% 좋아서 같은 화질에서 용량이 더 작아요.
변환된 이미지는 Cloudflare CDN에 캐싱돼서 같은 파라미터로 다시 요청하면 변환 없이 캐시에서 바로 내려줘요.
활성화
Cloudflare 미디어 > Images > 변환 메뉴에서 해당 Zone의 Image Transformations를 켜면 끝이에요. 별도 SDK 설치나 API 키 설정 같은 건 없어요.
이미지가 전부 같은 도메인에서 서빙되기 때문에 This zone only로 설정했어요. 외부 도메인 이미지까지 변환할 필요가 없으니까요.
이게 가능한 건 R2를 Pages 바인딩으로 연결하고 API 라우트를 직접 만들었기 때문이에요. 자세한 내용은 아래 게시글을 참고해주세요.

전체 구조
MDX 본문의 <Img src="/api/media?key=..." />
→ MDXComponents.tsx의 MdxImage 컴포넌트
→ image.ts 유틸로 변환 URL 생성
→ src: /cdn-cgi/image/width=800/.../api/media?key=...
→ srcSet: 400w, 640w, 800w, 1200w, 1600w, 2000w
→ data-zoom-src: /cdn-cgi/image/width=2000/...
→ 브라우저가 화면 크기에 맞는 srcSet 선택
→ Cloudflare Edge에서 실시간 변환 + CDN 캐시
핵심은 MDX에서 <Img src="/api/media?key=..." />만 쓰면 나머지는 자동으로 처리된다는 거예요.
변환 URL 생성 유틸
이미지 변환 URL을 만들어주는 유틸 함수들을 src/lib/image.ts에 모아뒀어요.
기본 변환
// src/lib/image.ts
const SITE_ORIGIN = "https://www.seung-woo.me";
const WIDTHS = [400, 640, 800, 1200, 1600, 2000] as const;
const FORMAT = "avif";
export function getOptimizedImageUrl(
src: string,
width: number,
quality = 85,
): string {
if (!isOptimizable(src)) return src;
const path = toRelativePath(src);
return `${SITE_ORIGIN}/cdn-cgi/image/width=${width},format=${FORMAT},quality=${quality}/${path.replace(/^\//, "")}`;
}width와 quality를 받아서 Cloudflare 변환 URL을 만들어요. quality는 85를 기본값으로 줬어요. 80 이하로 내리면 눈에 보일 정도로 화질이 떨어지고 90 이상은 용량 대비 차이가 미미해서 85가 적당하다고 판단했어요.
반응형 srcSet
export function getImageSrcSet(src: string, quality = 85): string | undefined {
if (!isOptimizable(src)) return undefined;
return WIDTHS.map(
(w) => `${getOptimizedImageUrl(src, w, quality)} ${w}w`,
).join(", ");
}WIDTHS 배열에 정의된 6개 브레이크포인트(400/640/800/1200/1600/2000)로 srcSet을 만들어요. 브라우저가 화면 크기와 DPR(Device Pixel Ratio)에 맞는 이미지를 알아서 골라요.
브레이크포인트를 6개로 잡은 이유가 있어요. 블로그 본문 컨테이너가 800px이라서 1x에 800, 2x(레티나)에 1600이 필수예요. grid 이미지(sizes: 400px)용 400, 모바일 2x grid용 640, 태블릿용 1200을 추가하고 줌 모달용 2000까지 넣었어요. format=avif 고정으로 포맷별 분리가 없어서 6개로 늘려도 변환 카운트 부담이 적어요.
이중 변환 방지
function isOptimizable(src: string): boolean {
if (!src) return false;
if (src.includes("/cdn-cgi/image/")) return false;
if (src.endsWith(".gif") || src.includes(".gif?")) return false;
return src.startsWith("/api/media") || src.includes("/api/media?");
}이미 변환된 URL이 다시 들어오면 이중 변환이 걸려요. /cdn-cgi/image/가 포함된 URL은 건너뛰도록 했어요. GIF도 제외했어요. 애니메이션 GIF는 변환하면 첫 프레임만 남거든요.
그리고 /api/media 경로의 이미지만 변환 대상으로 잡았어요. 외부 URL이나 다른 경로의 이미지에 Cloudflare 변환을 걸면 오류가 나서 변환을 막아요.
MDX 이미지 컴포넌트
유틸 함수를 실제로 적용하는 건 MDX 이미지 컴포넌트예요.
// src/components/mdx/MDXComponents.tsx
function MdxImage({ className, src, sizes: sizesProp, ...props }: MdxImageProps) {
const cls = typeof className === "string" ? className : "";
const isHero = cls.includes("aspect-[21/9]");
const isGridItem = cls.includes("aspect-square");
const optimizable = srcStr ? canOptimize(srcStr) : false;
const fallbackWidth = isGridItem ? 400 : 800;
const optimizedSrc = optimizable && srcStr
? getOptimizedImageUrl(srcStr, defaultWidth) : srcStr;
const srcSet = optimizable && srcStr
? getImageSrcSet(srcStr) : undefined;
const zoomSrc = optimizable && srcStr
? getZoomImageUrl(srcStr) : undefined;
return (
<Img
{...props}
src={optimizedSrc}
srcSet={srcSet}
sizes={resolvedSizes}
data-zoom-src={zoomSrc}
className={className}
{...(isHero
? { fetchPriority: "high", loading: "eager", decoding: "async" }
: {})}
/>
);
}className을 보고 이미지 용도를 판단해요. aspect-[21/9]는 히어로 이미지용이라 fetchPriority: "high"로 우선 로딩하고 나머지는 lazy loading이에요.
sizes 자동 계산
MDX에서 이미지를 쓸 때 sizes를 따로 지정하지 않으면 자동으로 계산돼요.
- 히어로/일반 이미지:
(min-width: 1024px) 800px, 100vw - 그리드 아이템:
(min-width: 1024px) 400px, 50vw
수동으로 지정하고 싶으면 배열 형태로 넘길 수도 있어요.
<Img src="/api/media?key=..." sizes={["33vw", 267]} />이러면 (min-width: 1024px) 267px, 33vw로 변환돼요. [모바일, 데스크탑] 순서예요.
img 태그 이름 문제
MDX에서 소문자 <img>는 next-mdx-remote의 components map을 안 타요. 그래서 MDX 파싱 단계에서 <img>를 <Img>로 치환하는 로직을 넣었어요.
// src/lib/mdx.ts
content = content.replace(/<img\s/g, "<Img ");components map에 img와 Img 모두 같은 MdxImage를 등록해두면 어느 쪽으로 써도 동일하게 처리돼요.
줌 모달과 캐시 공유
이미지를 클릭하면 전체 화면 줌 모달이 뜨는데 이때 2000px 해상도 이미지를 보여줘요.
export function getZoomImageUrl(src: string): string {
return getOptimizedImageUrl(src, ZOOM_WIDTH); // ZOOM_WIDTH = 2000
}여기서 포인트는 줌 이미지의 파라미터가 srcSet의 가장 큰 변형(2000w)과 완전히 동일하다는 거예요. Cloudflare CDN은 URL 기반으로 캐싱하니까 같은 URL이면 캐시 엔트리를 공유해요. 줌용으로 별도 변환이 발생하지 않아요.
줌 모달에서는 인접 이미지 4개를 미리 prefetch해서 좌우 넘길 때 즉시 보이도록 했어요.
format을 avif로 고정한 이유
처음에는 format=auto로 설정했어요. Cloudflare가 브라우저 Accept 헤더를 보고 AVIF/WebP/JPEG 중 최적 포맷을 골라주는 방식이에요. 호환성 걱정이 없다는 게 장점이었지만 단점도 있었어요. 포맷별로 캐시 엔트리가 따로 생겨서 변환 카운트가 2-3배로 불어나는 거예요.
AVIF는 Safari 16부터 지원해요. 2022년 9월에 출시된 버전이라 2026년 기준으로 거의 4년 전이에요. iOS 자동 업데이트 특성상 Safari 16 미만에 남아있는 사용자는 극소수라고 판단했어요. browserslist도 safari >= 16으로 올리면서 AVIF 미지원 브라우저는 타겟에서 제외했어요.
format=avif로 고정하면서 얻은 것들이에요.
- WebP 대비 압축률 20-30% 향상으로 이미지 용량 추가 절감
- 포맷별 캐시 분리가 없어져서 변환 카운트가 1/2-1/3로 줄어듦
- browserslist 상향으로 불필요한 폴리필 약 11KB 제거
시행착오
next-mdx-remote의 blockJS
제일 난관이었던 이슈예요. MDX에서 <Img sizes={["33vw", 267]} />처럼 JSX expression을 쓰면 빌드는 되는데 런타임에서 sizes 값이 사라지는 거예요.
원인은 next-mdx-remote v6부터 추가된 blockJS: true 기본값이었어요. 보안을 위해 JSX attribute expression을 전부 지워버리는 플러그인이 내부적으로 돌고 있었어요.
// src/app/blog/[slug]/page.tsx
<MDXRemote
source={post.content}
options={{
blockJS: false, // JSX expression 허용
mdxOptions: { ... },
}}
/>blockJS: false로 꺼서 해결했어요. 블로그 콘텐츠는 전부 제가 직접 작성하는 MDX라서 보안 위험은 없어요. blockDangerousJS는 그대로 켜둬서 eval이나 Function 같은 건 여전히 차단돼요.
어떤 이미지가 최적화 대상인지 구분
처음에는 모든 이미지에 변환을 걸었어요. 외부 URL이든 내부 이미지든 상관없이요. 당연히 외부 URL에 /cdn-cgi/image/를 붙이면 에러가 났어요.
그래서 /api/media 경로의 이미지만 변환 대상으로 잡도록 isOptimizable 함수를 만들었어요. R2에 올린 이미지만 Cloudflare 변환을 타고 나머지는 원본 그대로 나가요.
비용
Image Transformations 무료 한도예요.
| 항목 | 무료 한도 |
|---|---|
| 고유 변환 | 월 5,000건 |
"고유 변환"이란 새로운 파라미터 조합으로 처음 변환하는 것을 말해요. 같은 URL + 같은 파라미터로 다시 요청하면 CDN 캐시에서 나가니까 카운트에 안 잡혀요.
format=avif로 고정해서 포맷별 캐시 분리가 없어요. 이미지 1장당 srcSet 6개 변형(400/640/800/1200/1600/2000)이 각각 1건이라 이미지 1장에 6건이에요. 블로그에 월 50-60장 이미지를 올린다고 쳐도 360건 정도라 무료 한도 안에서 넉넉해요.
적용 전후 비교
실제로 삿포로 여행기 게시글에서 측정해봤어요.
- 원본 PNG: 한 장당 평균 1.5MB
- 변환 후 (800px, WebP): 한 장당 평균 80-120KB
한 게시글에 이미지가 10장이면 15MB → 1MB 수준으로 줄어들어요. 모바일에서 체감이 확 달라졌어요.
Lighthouse 퍼포먼스 점수도 50점에서 70점대로 올라갔고 관련 경고도 눈에 띄게 줄었어요.
마무리
R2에 원본을 올려두고 Cloudflare Image Transformations로 실시간 변환하는 구조는 제가 생각하는 최적화 방식에 딱 들어맞았어요. 이미지 사이즈별 파일 관리가 필요 없고 CDN 캐시까지 알아서 되니까요.
이미지는 페이지 로드, LCP 등 많은 부분에서 영향을 주는 요소기 때문에 최적화가 필요해요. 이미지 최적화를 고민하시는 분이라면 Cloudflare Image Transformations 나쁘지 않은 선택이 될거라고 생각해요.


