SEM.ai
자동화

이미지 호스팅, 진짜 거의 공짜로 할 수 있다 — 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분이면 끝

  1. Cloudflare 대시보드 → R2 → 버킷 생성
  2. API 토큰 발급 (Object Read & Write 권한)
  3. 커스텀 도메인 연결 (선택사항이지만 강력 권장) — 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 서버리스 환경 최적화.

cloudflare-r2webp이미지최적화비용절감인디개발