Cloudflare R2로 이그레스 비용 없는 오브젝트 스토리지 사용하기
클라우드에 파일을 저장하려면 보통 AWS S3를 떠올립니다. 문제는 비용이에요. S3에 파일을 올리는 건 저렴한데 사용자가 그 파일을 다운로드할 때마다 이그레스(egress) 비용이 붙습니다. 트래픽이 늘어나면 이 비용이 생각보다 빠르게 커지죠.
Cloudflare R2는 이 문제를 정면으로 해결합니다. S3 호환 API를 제공하면서 이그레스 비용이 아예 없어요. 데이터를 얼마나 자주 내려받든 추가 비용이 발생하지 않습니다. 게다가 Cloudflare Workers에서 바인딩으로 직접 접근할 수 있어서 파일 업로드 API나 이미지 서빙 같은 걸 빠르게 만들 수 있죠.
참고로 이그레스(egress)는 클라우드 사업자의 네트워크 밖으로 데이터가 나가는 것을 말합니다. 반대로 외부에서 안으로 들어오는 트래픽은 인그레스(ingress)라고 부르는데요, 대부분의 클라우드에서는 인그레스는 무료지만 이그레스에는 GB당 요금이 붙습니다. 한 번 데이터를 올려두면 꺼낼 때마다 돈이 빠져나가는 구조라, 이미지 CDN이나 동영상 스트리밍처럼 다운로드가 많은 서비스에서는 저장 비용보다 이그레스 비용이 훨씬 커지는 경우가 흔해요. 이런 요금 체계는 사용자를 특정 클라우드에 묶어두는 이른바 벤더 종속(vendor lock-in)의 원인으로도 자주 지목됩니다.
R2란
R2는 Cloudflare의 오브젝트 스토리지 서비스입니다. AWS S3와 동일한 API를 사용하기 때문에 기존 S3용 도구와 라이브러리를 그대로 쓸 수 있어요.
가장 큰 차별점은 이그레스 비용이 없다는 겁니다. S3는 데이터를 외부로 전송할 때 GB당 요금을 부과하는데 R2는 이 비용이 완전히 0이에요. 이미지, 동영상, 파일 다운로드처럼 아웃바운드 트래픽이 많은 서비스에서 비용 차이가 크게 납니다.
저장 비용은 S3 Standard와 비슷한 수준이고 무료 플랜에서도 넉넉한 저장 용량과 요청 한도를 제공합니다. 최신 가격은 R2 공식 가격 페이지에서 확인할 수 있어요.
버킷 만들기
R2에서 파일을 저장하려면 먼저 버킷을 만들어야 합니다.
npx wrangler r2 bucket create my-files
그리고 wrangler.jsonc에 바인딩을 추가합니다.
{
"name": "my-worker",
"main": "src/index.ts",
"compatibility_date": "2025-01-01",
"r2_buckets": [
{
"binding": "FILES",
"bucket_name": "my-files",
},
],
}
이제 Worker 코드에서 env.FILES로 버킷에 접근할 수 있습니다.
Workers에서 R2 사용하기
바인딩이 설정되면 Worker에서 파일을 업로드하고 다운로드하는 API를 만들 수 있습니다.
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
const key = url.pathname.slice(1); // URL 경로를 키로 사용
switch (request.method) {
case "PUT": {
await env.FILES.put(key, request.body, {
httpMetadata: {
contentType:
request.headers.get("content-type") || "application/octet-stream",
},
});
return new Response(`${key} 업로드 완료`);
}
case "GET": {
const object = await env.FILES.get(key);
if (object === null) {
return new Response("파일을 찾을 수 없습니다", { status: 404 });
}
const headers = new Headers();
object.writeHttpMetadata(headers);
headers.set("etag", object.httpEtag);
return new Response(object.body, { headers });
}
case "DELETE": {
await env.FILES.delete(key);
return new Response("삭제 완료");
}
default:
return new Response("Method Not Allowed", { status: 405 });
}
},
};
URL 경로를 오브젝트 키로 사용하는 간단한 파일 서버입니다. PUT으로 업로드하고 GET으로 다운로드하고 DELETE로 삭제하는 REST 패턴이에요.
put()에 httpMetadata를 설정하면 get()에서 writeHttpMetadata()로 Content-Type 같은 헤더를 자동으로 복원할 수 있습니다. 이미지를 업로드할 때 Content-Type을 image/png로 설정해두면 다운로드할 때도 브라우저가 이미지로 인식하죠.
오브젝트 다루기
R2의 핵심 API를 좀 더 자세히 살펴보겠습니다.
// 업로드 (문자열, ArrayBuffer, ReadableStream 가능)
await env.FILES.put("docs/readme.txt", "Hello, R2!");
// 메타데이터와 함께 업로드
await env.FILES.put("images/photo.jpg", imageBuffer, {
httpMetadata: {
contentType: "image/jpeg",
cacheControl: "public, max-age=31536000",
},
customMetadata: {
uploadedBy: "dale",
originalName: "vacation.jpg",
},
});
// 다운로드
const object = await env.FILES.get("images/photo.jpg");
if (object) {
console.log(object.key); // "images/photo.jpg"
console.log(object.size); // 파일 크기 (바이트)
console.log(object.uploaded); // 업로드 시각
console.log(object.customMetadata); // { uploadedBy: "dale", ... }
// object.body는 ReadableStream
}
// 헤드 (메타데이터만, 본문 없이)
const head = await env.FILES.head("images/photo.jpg");
// 삭제
await env.FILES.delete("images/photo.jpg");
// 여러 파일 한 번에 삭제
await env.FILES.delete(["temp/a.txt", "temp/b.txt", "temp/c.txt"]);
head()는 get()과 비슷하지만 파일 본문을 다운로드하지 않고 메타데이터만 가져옵니다. 파일이 존재하는지 확인하거나 크기를 알고 싶을 때 유용해요.
delete()에 배열을 넘기면 여러 파일을 한 번에 삭제할 수 있습니다. 임시 파일 정리 같은 배치 작업에 편하죠.
목록 조회
버킷의 오브젝트 목록을 조회하려면 list()를 사용합니다.
// 기본 조회
const listing = await env.FILES.list();
for (const object of listing.objects) {
console.log(`${object.key} (${object.size} bytes)`);
}
// 접두사로 필터링 (폴더처럼 사용)
const images = await env.FILES.list({
prefix: "images/",
limit: 50,
});
// 구분자로 폴더 구조 표현
const folders = await env.FILES.list({
prefix: "uploads/",
delimiter: "/",
});
// folders.delimitedPrefixes → ["uploads/2025/", "uploads/2024/"]
// folders.objects → 직접 포함된 파일만
delimiter를 /로 설정하면 S3의 “폴더” 개념처럼 동작합니다. delimitedPrefixes에 하위 “폴더” 목록이 들어오고 objects에는 현재 레벨의 파일만 들어와요. 파일 탐색기 같은 UI를 만들 때 이 패턴이 필요합니다.
한 번에 최대 1,000개를 반환하고 더 있으면 truncated가 true로 오니까 cursor로 다음 페이지를 요청하면 됩니다.
퍼블릭 액세스
R2 버킷은 기본적으로 비공개입니다. 파일을 누구나 접근할 수 있게 하려면 퍼블릭 액세스를 활성화해야 해요.
Cloudflare 대시보드에서 R2 > 버킷 설정 > “Public access”를 활성화하면 pub-{account-hash}.r2.dev 형태의 공개 URL이 생깁니다. 이 URL로 누구나 파일에 접근할 수 있어요.
커스텀 도메인을 연결하는 것도 가능합니다. 대시보드에서 “Connect domain”으로 자신의 도메인을 연결하면 files.example.com/images/photo.jpg 같은 깔끔한 URL로 파일을 서빙할 수 있어요. Cloudflare CDN이 자동으로 앞단에서 캐싱해주니까 성능도 좋습니다.
이렇게 커스텀 도메인을 붙이면 R2 위에 Cloudflare Images Transformations를 얹어 쓰기도 좋습니다. 원본은 R2에 한 벌만 올려두고 URL 경로에 /cdn-cgi/image/width=400,format=auto/ 같은 옵션을 끼워 넣으면 엣지에서 썸네일이나 WebP/AVIF 변환본을 즉석에서 만들어 돌려주거든요. 이그레스가 공짜인 R2와 조합하면 이미지 CDN을 따로 두지 않아도 충분한 성능과 비용 구조를 얻을 수 있어요.
Workers에서 접근을 제어하는 방식도 있습니다. 퍼블릭 액세스 대신 Worker가 인증을 검사한 뒤 R2에서 파일을 가져다 응답하는 패턴이에요. 유료 콘텐츠나 권한별 파일 다운로드에 적합하죠.
Presigned URL
클라이언트에서 R2로 직접 파일을 업로드하게 하려면 presigned URL을 사용합니다. 서버가 미리 서명된 URL을 발급하면 클라이언트가 그 URL로 직접 R2에 파일을 올리는 방식이에요. 서버를 거치지 않으니까 대용량 파일 업로드에 효율적입니다.
import { AwsClient } from "aws4fetch";
const r2 = new AwsClient({
accessKeyId: R2_ACCESS_KEY_ID,
secretAccessKey: R2_SECRET_ACCESS_KEY,
});
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
if (url.pathname === "/api/upload-url") {
const filename = url.searchParams.get("filename");
const r2Url = new URL(
`https://${BUCKET_NAME}.${ACCOUNT_ID}.r2.cloudflarestorage.com/${filename}`,
);
r2Url.searchParams.set("X-Amz-Expires", "3600");
const signed = await r2.sign(new Request(r2Url, { method: "PUT" }), {
aws: { signQuery: true },
});
return new Response(signed.url);
}
return new Response("Not Found", { status: 404 });
},
};
클라이언트는 이 URL을 받아서 PUT 요청으로 직접 파일을 올립니다.
// 클라이언트 코드
const res = await fetch("/api/upload-url?filename=photo.jpg");
const uploadUrl = await res.text();
await fetch(uploadUrl, {
method: "PUT",
body: file,
});
presigned URL에는 만료 시간을 설정할 수 있어서 보안상 안전합니다. 위 예제에서는 1시간(3600초)으로 설정했어요.
S3 호환 API
R2는 S3 호환 API를 제공하기 때문에 기존 S3 클라이언트 라이브러리를 그대로 쓸 수 있습니다. AWS SDK를 사용하는 기존 코드가 있다면 엔드포인트만 바꾸면 됩니다.
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
const s3 = new S3Client({
region: "auto",
endpoint: `https://${ACCOUNT_ID}.r2.cloudflarestorage.com`,
credentials: {
accessKeyId: R2_ACCESS_KEY_ID,
secretAccessKey: R2_SECRET_ACCESS_KEY,
},
});
await s3.send(
new PutObjectCommand({
Bucket: "my-files",
Key: "docs/report.pdf",
Body: fileBuffer,
ContentType: "application/pdf",
}),
);
이 방식은 Workers 밖에서(예: Node.js 서버, CI/CD 파이프라인) R2에 접근할 때 유용합니다. Workers 안에서는 바인딩(env.FILES)을 쓰는 게 더 간단하고 빠릅니다.
로컬 개발
wrangler dev를 실행하면 R2도 로컬에서 시뮬레이션됩니다.
npx wrangler dev
Miniflare가 로컬 파일 시스템에 R2 데이터를 저장해서 프로덕션과 동일한 API로 동작해요. Wrangler CLI로 로컬 버킷에 파일을 직접 넣어볼 수도 있습니다.
npx wrangler r2 object put my-files/test.txt --file ./test.txt --local
npx wrangler r2 object get my-files/test.txt --file ./downloaded.txt --local
용량과 제한
R2의 무료 플랜은 꽤 넉넉합니다. 저장 용량과 요청 한도 모두 개인 프로젝트에 충분한 수준이고 이그레스는 항상 무료입니다.
일반 업로드와 멀티파트 업로드의 오브젝트 크기 상한이 다르고 버킷당 오브젝트 개수에는 제한이 없어요. 최신 한도는 R2 공식 문서에서 확인할 수 있습니다.
멀티파트 업로드는 Workers 바인딩에서도 지원합니다. createMultipartUpload()로 시작하고 uploadPart()로 파트를 올린 뒤 complete()로 마무리하는 흐름이에요. 대용량 파일을 안정적으로 올려야 할 때 필요합니다.
KV, D1과 비교하면
Cloudflare의 세 가지 저장소는 각각 다른 용도에 최적화되어 있습니다.
KV는 설정값이나 캐시처럼 작은 텍스트 데이터를 빠르게 읽어오는 데 적합합니다. D1은 사용자 정보나 주문 내역처럼 구조화된 데이터를 SQL로 쿼리해야 할 때 맞고요. R2는 이미지, 동영상, PDF 같은 파일을 저장하고 서빙하는 데 최적이에요.
실전에서는 이 셋을 조합해서 씁니다. 사용자 프로필 이미지를 R2에 저장하고 프로필 정보는 D1에 넣고 자주 조회하는 데이터는 KV에 캐싱하는 식이죠. 하나의 Worker에서 세 가지 바인딩을 동시에 사용할 수 있으니까 아키텍처가 깔끔합니다.
마치며
R2는 S3의 익숙함과 Cloudflare의 비용 구조를 결합한 오브젝트 스토리지입니다. 이그레스 비용이 없다는 건 트래픽을 예측하기 어려운 프로젝트에서 특히 큰 장점이에요. “사용자가 많아지면 비용이 폭발하지 않을까”라는 걱정을 한 가지 덜 수 있으니까요.
Workers 바인딩으로 접근하면 파일 업로드 API를 몇 줄의 코드로 만들 수 있고 퍼블릭 액세스나 커스텀 도메인으로 정적 파일 서빙도 간단합니다. S3 호환 API 덕분에 기존 도구와의 호환성도 확보되고요.
더 자세한 내용은 Cloudflare R2 공식 문서를 참고하세요.
This work is licensed under
CC BY 4.0