Cloudflare R2 이미지 최적화 삽질기: 캐시 설정부터 Pro $25 낭비까지


GCS에서 Cloudflare R2로 이미지 스토리지를 옮긴 뒤, 한동안 만족하고 있었습니다. egress 비용 0원, CDN 자동 적용, 커스텀 도메인까지. 그런데 어느 날 도서 페이지를 열어보니 이미지가 눈에 띄게 느립니다.
개발자 도구를 열어 응답 헤더를 확인해 봤습니다.
cf-cache-status: MISS cache-control: (없음)
첫 요청이 아닌데도 MISS. 새로고침해도 MISS. 분명 CDN을 쓰고 있는데, 매번 R2 원본에서 가져오고 있었습니다.
문제: 캐시가 있는데 캐시가 안 된다
FullStackFamily의 이미지 서빙 구조를 간단히 그리면 이렇습니다.
┌─────────────────┐ 사용자 ──────────→ │ Cloudflare Edge │ │ (전세계 POP) │ └────────┬────────┘ │ MISS일 때만 ┌────────▼────────┐ │ Cloudflare R2 │ │ (원본 스토리지) │ └─────────────────┘ storage.fullstackfamily.com → R2 커스텀 도메인 image.fullstackfamily.com → Cloudflare Worker (리사이징)
R2 커스텀 도메인을 쓰면 Cloudflare CDN이 자동으로 앞단에 붙습니다. 한 번 요청하면 Edge에 캐시되고, 다음 요청은 Edge에서 바로 응답하는 구조죠.
문제는 Edge가 얼마나 오래 캐시를 유지하느냐입니다.
R2 오브젝트에 Cache-Control이 없으면?
Cloudflare의 기본 동작은 단순합니다.
원본에 Cache-Control 헤더가 있으면 → 해당 설정값을 우선 적용 원본에 Cache-Control 헤더가 없으면 → 파일 확장자별 기본 TTL 적용
이 "기본 TTL"이 생각보다 짧습니다. 이미지 파일의 경우 약 4시간(14,400초). Edge에 캐시된 이미지가 4시간마다 만료되고, 다시 R2에서 가져오는 겁니다.
오전에 본 이미지를 오후에 다시 열면 또 로딩을 기다려야 합니다.
┌──────────────────────────────────────────────────┐ │ Edge 캐시 타임라인 (설정 전) │ ├──────────────────────────────────────────────────┤ │ │ │ 0h 4h 8h 12h 16h 20h │ │ ├───HIT──┤ MISS ├──HIT──┤ MISS ├──HIT──┤ MISS │ │ ↑ ↑ ↑ │ │ 만료→재요청 만료→재요청 만료→재요청 │ │ │ │ 하루에 원본 요청 6회, 사용자 입장에서는 자꾸 느림 │ └──────────────────────────────────────────────────┘
도서 페이지는 한 장에 이미지가 수십 개씩 들어갑니다. 이게 전부 MISS면 페이지 로딩이 수 초까지 걸릴 수 있고요.
해결: 코드 한 줄 + 대시보드 설정 3개
1. R2 업로드 시 Cache-Control 헤더 추가
원인을 알았으니 수정은 간단합니다. 파일을 R2에 올릴 때 메타데이터에 Cache-Control을 넣어줍니다.
// R2StorageService.java PutObjectRequest putRequest = PutObjectRequest.builder() .bucket(bucketName) .key(objectKey) .contentType(file.getContentType()) .cacheControl("public, max-age=31536000, immutable") .build();
max-age=31536000은 1년, immutable은 "이 파일은 절대 변하지 않는다"는 선언입니다.
이렇게 잡아도 되는 이유가 있습니다. 파일명에 UUID를 쓰거든요.
books/1/pages/a3f7b2c1-9e4d-4a8b-b6f0-3d2e1c0a9f8b.jpg
파일을 수정하면 새 UUID로 새 파일이 올라갑니다. 기존 URL은 영원히 같은 내용을 가리키니까 immutable이 안전합니다.
다만 이 변경은 새로 업로드되는 파일부터 적용됩니다. 이미 R2에 올라가 있는 수천 개의 기존 파일에는 여전히 Cache-Control이 없습니다.
2. Cloudflare Cache Rules
기존 파일까지 커버하려면 Cloudflare Edge에서 원본 헤더와 상관없이 TTL을 강제해야 합니다.
Cloudflare Dashboard → Caching → Cache Rules에서 규칙을 하나 추가합니다.
Rule: Force Cache - Storage Images When: (http.host eq "storage.fullstackfamily.com") Then: ┌─────────────────────────────────────┐ │ Edge TTL: 30일 (원본 헤더 무시) │ │ Browser TTL: 1년 │ │ Query String: Ignore │ └─────────────────────────────────────┘
여기서 중요한 건 "Ignore cache-control header and use this TTL" 옵션입니다. 원본(R2)에 Cache-Control이 있든 없든, Edge에서 30일간 유지하게 됩니다.
Query String Ignore도 빠뜨리면 안 됩니다. ?v=1 같은 파라미터가 붙었을 때 캐시가 분산되는 걸 막아줍니다.
3. Tiered Cache 활성화
Cloudflare에는 전 세계 수백 개의 POP(엣지 서버)이 있는데, 기본적으로 각 POP이 독립적으로 캐시를 관리합니다.
Tiered Cache OFF: 서울 POP → MISS → R2 원본 도쿄 POP → MISS → R2 원본 ← 같은 파일인데 각각 원본 요청 오사카 POP → MISS → R2 원본 Tiered Cache ON: 서울 POP → MISS → 상위 POP → R2 원본 도쿄 POP → MISS → 상위 POP → HIT! ← 상위 POP에서 바로 응답 오사카 POP → MISS → 상위 POP → HIT!
Tiered Cache를 켜면 POP끼리 캐시를 공유합니다. 서울에서 한 번 요청한 파일은 도쿄나 오사카에서 원본 서버를 다시 호출하지 않습니다. Free 플랜에서도 쓸 수 있습니다.
적용 후 검증
설정을 적용하고 curl로 확인해 봤습니다.
# 첫 번째 요청 $ curl -sI "https://storage.fullstackfamily.com/books/1/pages/page_001.jpg" \ | grep -i "cf-cache-status\|cache-control\|age" cf-cache-status: MISS ← 첫 요청은 당연히 MISS cache-control: max-age=31536000 # 두 번째 요청 (15초 후) $ curl -sI "https://storage.fullstackfamily.com/books/1/pages/page_001.jpg" \ | grep -i "cf-cache-status\|cache-control\|age" cf-cache-status: HIT ← Edge에서 바로 응답 cache-control: max-age=31536000 age: 15 ← 15초째 캐시 중
cf-cache-status: HIT와 age 값이 보이면 된 겁니다. 이 파일은 앞으로 30일간 Edge에서 바로 응답합니다.
┌──────────────────────────────────────────────────┐ │ Edge 캐시 타임라인 (설정 후) │ ├──────────────────────────────────────────────────┤ │ │ │ Day 1 Day 10 Day 20 Day 30 │ │ ├────────────── HIT ──────────────────────┤MISS │ │ ↑ ↑ │ │ 최초 1회만 원본 요청 30일 후 갱신 │ │ │ │ 하루 원본 요청: 0회 (첫날 제외) │ └──────────────────────────────────────────────────┘
비용 영향
R2는 egress가 무료이지만, 읽기 요청(Class B)에는 비용이 붙습니다.
| 항목 | 설정 전 | 설정 후 |
|---|---|---|
| Edge TTL | ~4시간 | 30일 |
| 하루 원본 요청 (1파일) | ~6회 | ~0회 |
| R2 Class B 요청 | 많음 | 크게 감소 |
| 사용자 체감 | 가끔 느림 | 일관되게 빠름 |
Free 플랜 기준 R2 Class B 요청은 월 1,000만 건까지 무료입니다. 캐시 HIT 비율이 올라가면 이 한도 안에 넉넉히 들어옵니다.
전체 적용 내역
| 구분 | 변경 내용 | 적용 대상 |
|---|---|---|
| 코드 | R2StorageService에 cacheControl 추가 | 새로 업로드되는 파일 |
| Cache Rules | Edge TTL 30일 강제, Query String Ignore | 기존+신규 모든 파일 |
| Tiered Cache | POP 간 캐시 공유 활성화 | 전체 도메인 |
| Browser TTL | 1년 (Cache Rules에서 오버라이드) | 모든 파일 |
코드 변경은 새 파일에만 적용되지만, Cache Rules가 기존 파일을 포함한 전체를 커버합니다. 두 가지 설정을 병행해야 누락 없이 확실하게 적용됩니다.
그런데 이미지가 아직 크다
캐시가 잘 되니까 두 번째 요청부터는 빨라졌습니다. 그런데 첫 번째 요청은 여전히 느립니다. 2.7MB짜리 PNG를 그대로 서빙하고 있으니까요.
Cloudflare에는 Polish라는 기능이 있습니다. Edge에서 JPEG/PNG를 자동 압축해주고, WebP 변환도 해줍니다. 문제는 이게 Pro 플랜($25/월) 부터 쓸 수 있다는 겁니다.
고민하다 결제했습니다. Polish Lossy를 켜고 WebP도 활성화했습니다.
Polish는 됐는데 WebP는 안 된다
Polish 자체는 동작했습니다. 2.7MB PNG가 2.0MB로 줄었고, 응답 헤더에 cf-polished: ok가 찍혔습니다. 26% 감소.
그런데 WebP 변환이 안 됩니다. content-type이 계속 image/png입니다.
cf-polished: ok ← Polish는 동작 content-type: image/png ← WebP 변환은 안 됨
Configuration Rules에서 호스트별로 WebP를 강제할 수 있나 봤더니, Polish(Lossy) 옵션만 있고 WebP 옵션 자체가 없습니다. R2 커스텀 도메인에서는 Cloudflare의 자동 WebP 변환을 쓸 수가 없었습니다.
$25를 내고 얻은 건 26% 압축뿐입니다.
직접 변환하기로 했다
Cloudflare에 맡기는 건 포기하고, 업로드 시점에 백엔드에서 직접 WebP로 변환하기로 했습니다.
┌──────────────────────────────────────────────────────┐ │ 이미지 업로드 파이프라인 │ ├──────────────────────────────────────────────────────┤ │ │ │ 사용자 업로드 (PNG/JPEG, 최대 10MB) │ │ ↓ │ │ width > 1600px? → 1600px로 리사이즈 │ │ ↓ │ │ cwebp -q 85 → WebP 변환 │ │ ↓ │ │ R2에 .webp로 저장 (Cache-Control: 1년) │ │ │ └──────────────────────────────────────────────────────┘
Google의 cwebp CLI를 사용합니다. Java에는 쓸 만한 WebP 라이브러리가 없어서 ProcessBuilder로 외부 프로세스를 호출하는 방식입니다. Dockerfile에 webp 패키지를 추가하면 됩니다.
// ImageOptimizer.java (핵심 부분) Process process = new ProcessBuilder( "cwebp", "-q", "85", tmpIn.toString(), "-o", tmpOut.toString()) .redirectErrorStream(true).start();
cwebp가 설치되어 있지 않은 환경에서는 JPEG로 폴백합니다. 로컬 개발 환경에서 cwebp를 안 깔아도 테스트가 깨지지 않습니다.
결과
같은 이미지를 다시 올려봤습니다.
| 항목 | Polish (Pro $25) | 백엔드 WebP 변환 |
|---|---|---|
| 원본 | 2.7MB PNG | 2.7MB PNG |
| 변환 후 | 2.0MB PNG (26%↓) | 166KB WebP (94%↓) |
| 비용 | $25/월 | $0 |
2.7MB가 166KB가 됐습니다. Polish의 26% 감소와는 비교가 안 됩니다.
Pro 플랜의 다른 기능(WAF, 봇 탐지, WordPress 캐싱 등)도 전부 확인해 봤는데, Spring Security + nginx로 이미 처리하고 있거나 쓸 일이 없는 것들이었습니다. 하루 만에 Free로 다운그레이드했고, $25는 환불이 안 됩니다. Cloudflare는 선불 결제에 환불 불가 정책이거든요.
마무리
CDN을 달았으니 빠르겠지, 하고 넘어갔던 게 실수였습니다. R2 커스텀 도메인에 Cloudflare Edge가 붙어 있어도, 캐시 정책을 안 잡아주면 Edge는 보수적으로 동작합니다. 이미지에 4시간짜리 TTL은 짧습니다.
그리고 Cloudflare의 이미지 최적화 기능(Polish, WebP)은 R2 커스텀 도메인에서 제대로 동작하지 않습니다. $25/월을 내기 전에 이걸 먼저 확인했어야 했습니다.
결국 가장 확실한 방법은 업로드 시점에 직접 변환하는 거였습니다. Cache Rules로 Edge TTL을 잡고, 이미지는 WebP로 변환해서 올리면, Cloudflare Free 플랜으로 충분합니다.
| 최종 구성 | 상세 |
|---|---|
| 이미지 변환 | 백엔드 cwebp (업로드 시 WebP 변환 + 리사이즈) |
| 캐시 | Cache Rules (Edge 30일) + Tiered Cache |
| Cloudflare 플랜 | Free ($0/월) |
| 연 절감 | $300 |






댓글
댓글을 작성하려면 이 필요합니다.