이미지 호스팅, 진짜 거의 공짜로 할 수 있다 — WebP + Cloudflare R2 실전 가이드
유저 업로드 이미지 때문에 서버 비용이 걱정이라면, 이 조합으로 해결할 수 있다. 클라이언트 사이드 WebP 변환과 Cloudflare R2로 월 몇 달러도 안 나오는 구조 만드는 법.
처음엔 그냥 URL 받으면 된다고 생각했다. "프로필 사진? SNS 링크 입력하면 되지." 근데 써보니 UX가 너무 별로였다. 링크가 죽거나, 이미지 비율이 다 제각각이거나, 아예 외부 도메인 이미지가 차단되는 경우도 생겼다.
직접 업로드를 받아야 했다. 근데 그러면 스토리지 비용이 문제다.
실제로 얼마나 나올까 — 계산부터
보통 겁부터 먹는다. "이미지 직접 저장하면 비용 감당 안 되지 않아?" 계산해보면 생각보다 훨씬 적다.
Cloudflare R2 기준:
- 저장: $0.015 / GB / 월
- 읽기(Class B): 100만 건 당 $0.36 (첫 1,000만 건/월은 무료)
- Egress 비용: 없음 (이게 핵심이다)
AWS S3와 결정적으로 다른 점이 egress다. S3는 데이터 꺼낼 때마다 과금된다. R2는 Cloudflare 네트워크에서 직접 서빙하므로 외부 전송 비용이 없다.
유저 1,000명, 1인당 평균 사진 5장, 장당 200KB 가정:
- 저장 용량: 1GB = $0.015/월
- 읽기 요청: 월 50만 건 → 무료 구간
합계: 한 달에 2센트도 안 나온다.
핵심 1: 클라이언트 사이드 WebP 변환
업로드 전에 브라우저에서 먼저 변환하는 게 포인트다. 서버까지 원본을 보내지 않는다.
async function compressToWebP(file, maxWidthPx = 1200, quality = 0.82) {
return new Promise((resolve) => {
const img = new Image();
const url = URL.createObjectURL(file);
img.onload = () => {
const canvas = document.createElement("canvas");
const ratio = Math.min(1, maxWidthPx / img.width);
canvas.width = img.width * ratio;
canvas.height = img.height * ratio;
canvas.getContext("2d").drawImage(img, 0, 0, canvas.width, canvas.height);
canvas.toBlob(resolve, "image/webp", quality);
URL.revokeObjectURL(url);
};
img.src = url;
});
}
실제 결과: 스마트폰으로 찍은 4MB HEIC 이미지가 80~150KB WebP로 줄어든다. 서버로 가는 트래픽이 20-30배 감소한다. 저장 비용이 줄어드는 건 덤이고, 로딩 속도가 체감될 정도로 빨라진다.
핵심 2: R2 + Presigned URL 패턴
서버에서 파일 내용을 받지 않는다. 서버는 서명된 업로드 URL만 발급하고, 실제 업로드는 브라우저 → R2 직접 연결로 처리한다.
// 서버 (Next.js API Route)
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
const r2 = new S3Client({
region: "auto",
endpoint: `https://${process.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,
credentials: {
accessKeyId: process.env.R2_ACCESS_KEY_ID!,
secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!,
},
});
export async function POST(req: Request) {
const { filename, contentType } = await req.json();
const key = `uploads/${Date.now()}-${filename}`;
const signedUrl = await getSignedUrl(
r2,
new PutObjectCommand({ Bucket: process.env.R2_BUCKET!, Key: key, ContentType: contentType }),
{ expiresIn: 300 }
);
return Response.json({ uploadUrl: signedUrl, key });
}
// 클라이언트
const webpBlob = await compressToWebP(file);
// 1. 서버에서 업로드 URL 발급
const { uploadUrl, key } = await fetch("/api/upload", {
method: "POST",
body: JSON.stringify({ filename: "photo.webp", contentType: "image/webp" }),
headers: { "Content-Type": "application/json" },
}).then(r => r.json());
// 2. R2에 직접 업로드 (서버 거치지 않음)
await fetch(uploadUrl, {
method: "PUT",
body: webpBlob,
headers: { "Content-Type": "image/webp" },
});
// 3. 최종 URL 저장
const publicUrl = `https://cdn.yourdomain.com/${key}`;
이 패턴의 장점: 서버 메모리를 파일이 통과하지 않으므로, Vercel 같은 서버리스 환경에서도 문제없이 작동한다. 4MB 파일도 서버 함수 메모리 제한에 걸리지 않는다.
R2 설정 — 5분이면 끝
- Cloudflare 대시보드 → R2 → 버킷 생성
- API 토큰 발급 (Object Read & Write 권한)
- 커스텀 도메인 연결 (선택사항이지만 강력 권장) — R2 버킷 → Settings → Custom Domains
커스텀 도메인 연결하면 cdn.yourdomain.com으로 이미지를 서빙할 수 있고, Cloudflare 캐시가 자동으로 붙는다. 같은 이미지 두 번째 요청부터는 R2 읽기 요청도 발생하지 않는다.
실제로 써보니
처음에 이 구조로 전환했을 때 가장 놀랐던 건 비용이 아니라 속도였다. WebP + Cloudflare CDN 조합이 원본 JPEG + S3 조합보다 로딩이 훨씬 빨랐다. LCP(Largest Contentful Paint) 수치가 눈에 띄게 개선됐다.
비용은 진짜 거의 안 나온다. 서비스 초반에는 R2 무료 구간(10GB 저장, 100만 Class A 요청, 1,000만 Class B 요청)에서 벗어나지도 않는다.
Wrap-up
이미지 호스팅 비용이 무섭다면 계산을 먼저 해보라. 막연한 걱정보다 숫자가 훨씬 작을 것이다. 클라이언트 사이드 WebP 변환과 R2 presigned URL 패턴을 조합하면, 인디 규모에서는 사실상 공짜에 가까운 이미지 인프라를 만들 수 있다.
실제 프로젝트 구현 과정에서 정리한 내용입니다. 참고: 커뮤니티 플랫폼 이미지 업로드 기능 개발, Vercel 서버리스 환경 최적화.