이미지 호스팅, 진짜 거의 공짜로 할 수 있다 — WebP + Cloudflare R2 실전 가이드
유저 업로드 이미지 때문에 서버 비용이 걱정이라면, 이 조합으로 해결할 수 있다. 클라이언트 사이드 WebP 변환과 Cloudflare R2로 월 몇 달러도 안 나오는 구조 만드는 법.
처음엔 그냥 URL 받으면 된다고 생각했다. "프로필 사진? SNS 링크 입력하면 되지." 근데 써보니 UX가 너무 별로였다. 링크가 죽거나, 이미지 비율이 다 제각각이거나, 아예 외부 도메인 이미지가 차단되는 경우도 생겼다.
직접 업로드를 받아야 했다. 근데 그러면 스토리지 비용이 문제다.
실제로 얼마나 나올까 — 계산부터
Photo by Nguyen Dang Hoang Nhu on Unsplash
보통 겁부터 먹는다. "이미지 직접 저장하면 비용 감당 안 되지 않아?" 계산해보면 생각보다 훨씬 적다.
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 서버리스 환경 최적화.
이 글이 도움됐다면 공유해주세요
블로그 추천
Claude Code vs Cursor 실전 비교
Claude Code와 Cursor를 직접 써보고 비교한 실전 리뷰. 1인 창업자 관점에서 어떤 AI 코딩 도구가 더 나은지 정리했다.
분석 툴 통합 전략 — 전환율 2.3배 만든 법
Amplitude-Statsig 인수 사례로 본 분석 툴 통합 전략. 마테크 스택을 줄여 전환율 2.3배를 만든 실전 과정과 재현법을 정리했다.
AI 도입 속도, 나는 완전히 틀렸다
AI 도입 속도가 빠를수록 성장한다고 믿었다가 오히려 역효과를 겪었다. 1인 창업자가 직접 데이터로 확인한 실수와 교정 방법을 공유한다.