Cloudflare R2 vs S3 vs Firebase Storage: 월 1TB 다운로드하면 $0 vs $90 vs $108, 이그레스 비용으로 스토리지 고르기

Flutter 앱에 이미지 업로드 기능을 붙일 때 대부분 이렇게 검색한다. “S3 vs Firebase Storage 요금 비교”, “R2 gb당 저장 비용”. 그리고 표에 나온 GB당 저장 요금이 다 거기서 거기라서 “그냥 익숙한 거 쓰자”고 결론 내린다. 그런데 서비스가 커지고 사용자가 프로필 사진과 피드 이미지를 매일 들여다보기 시작하면, 청구서에서 가장 큰 줄은 저장 비용이 아니라 다운로드(egress) 비용이다. 이 글은 그 숫자를 실제 단가로 증명한다.
핵심 요약: 저장 비용은 GB당 몇 센트 수준이라 세 서비스가 큰 차이가 없다. 하지만 이그레스(다운로드) 비용은 자릿수가 다르다. Cloudflare R2는 이그레스가 구조적으로 $0, AWS S3는 GB당 $0.09, Firebase Storage는 월 100GB 무료 이후 GB당 약 $0.12를 청구한다. 월 다운로드 트래픽이 1TB만 되어도 R2는 $0, S3는 약 $90, Firebase Storage는 약 $108이 된다. 이미지·동영상을 다루는 앱일수록 이 격차는 트래픽에 비례해 선형으로, 그리고 심리적으로는 기하급수적으로 벌어진다.
왜 저장 비용이 아니라 이그레스가 문제인가
Flutter 앱에서 이미지/동영상 스토리지에 발생하는 트래픽 패턴을 생각해보면 답이 나온다. 이미지는 한 번 업로드되고 수백~수천 번 조회된다. 프로필 사진 하나는 그 사람의 팔로워 수만큼 로드되고, 피드에 올라간 사진 한 장은 스크롤할 때마다, 앱을 재실행할 때마다 다시 받아진다. 즉 쓰기(Put/Upload)는 1회성이지만 읽기(Get/Download)는 트래픽의 대부분을 차지한다. 저장 요금은 “몇 GB를 얼마 동안 들고 있느냐”에 대한 요금이라 스토리지 총량에 선형으로 붙지만, 이그레스 요금은 “몇 GB가 얼마나 자주 나가느냐”에 붙기 때문에 실사용 트래픽에 따라 훨씬 빠르게 불어난다.
3사 요금 구조 한눈에 비교
먼저 각 서비스의 공식 요금 페이지 기준(2026년 7월 확인) 저장 요금과 이그레스 요금을 정리한다.
| 구분 | Cloudflare R2 | AWS S3 (Standard, us-east-1) | Firebase Storage (Blaze) |
|---|---|---|---|
| 저장 요금 | $0.015/GB-month | $0.023/GB-month (첫 50TB) | 레거시 버킷 기준 5GB 초과 시 $0.026/GB-month, 신규 버킷은 리전별 상이 |
| 쓰기/변경 요청 | Class A: $4.50/백만 건 | PUT 등: $0.005/1,000건 | Firestore와 별개, Storage 자체 요청 과금은 상대적으로 미미 |
| 읽기 요청 | Class B: $0.36/백만 건 | GET 등: $0.0004/1,000건 | 위와 동일 |
| 다운로드(이그레스) | $0 (전부 무료) | $0.09/GB (첫 10TB/월) | 월 100GB 초과분 GB당 약 $0.12 |
R2와 S3의 요청(오퍼레이션) 단가는 R2가 얼핏 비싸 보이지만, 이미지 뷰어 앱처럼 읽기가 압도적으로 많은 워크로드에서는 이그레스 한 줄이 나머지 항목을 전부 뒤집는다. 뒤에서 실제 숫자로 확인한다.
무료 티어 정리: 진짜 무료인가
세 서비스 모두 “무료 티어”를 내세우지만 성격이 완전히 다르다.
- Cloudflare R2: Standard 스토리지 기준 10GB-month 저장 무료, Class A(쓰기) 요청 월 100만 건 무료, Class B(읽기) 요청 월 1,000만 건 무료. 그리고 이 무료 티어와 별개로 이그레스는 스토리지 클래스, 트래픽량과 무관하게 항상 무료다. R2에서 직접 나가는 다운로드 트래픽 자체에는 애초에 과금 항목이 없다.
- Firebase Storage (Spark/Blaze 공통): 신규 버킷(
*.firebasestorage.app) 기준 다운로드 월 100GB까지 무료, 저장은 5GB-month까지 무료. 유료 플랜(Blaze)으로 전환해도 이 무료 할당량 자체는 계속 유지된다. 다만 이 무료 할당량은 us-central1, us-west1, us-east1 세 리전에만 적용된다는 단서가 붙는다. - AWS S3: “인터넷으로 나가는 데이터 전송 월 100GB까지 무료”가 존재하긴 한다. 그런데 공식 문서 문구를 그대로 옮기면 이 100GB는 “aggregated across all AWS Services and Regions”, 즉 계정 전체·전 리전·전 서비스를 통틀어 합산되는 하나의 풀이다. EC2 인스턴스가 API 응답을 내보내든, CloudFront가 캐시 미스로 오리진(S3)에서 데이터를 끌어오든, RDS 백업을 다운로드하든 전부 같은 100GB 풀을 소진한다. 스토리지 하나만 운영하는 실습 계정이라면 이 무료 티어가 의미 있지만, 이미 다른 워크로드가 돌고 있는 실서비스 AWS 계정에서는 사실상 항상 소진된 상태라고 보는 편이 안전하다.
이 차이가 뒤에 나올 시뮬레이션 표에서 S3와 Firebase Storage의 저비용 구간 숫자가 서로 다르게 나오는 이유이기도 하다.
실제 청구서 시뮬레이션: 월 다운로드 100GB / 1TB / 10TB
아래 표는 이그레스 트래픽만 놓고 계산한 월 청구액이다. 계산 조건은 다음과 같이 명시적으로 잡았다.
- 1TB = 1,000GB로 단순화(청구서 상 실제 단위는 GiB/TiB에 가깝지만 자릿수 비교 목적상 십진 단위로 계산).
- R2: 이그레스는 항상 $0. (저장·오퍼레이션 비용은 별도 절에서 다룸)
- S3: 계정 전체 공유 무료 티어(100GB)는 다른 AWS 트래픽으로 이미 소진되었다고 가정하고, 첫 10TB/월 구간 단가 $0.09/GB를 그대로 적용.
- Firebase Storage: Storage 전용 무료 할당량(100GB/월)은 이 앱만을 위해 매달 새로 채워진다고 가정하고, 초과분에 GB당 $0.12 적용.
| 월 다운로드 트래픽 | Cloudflare R2 | AWS S3 | Firebase Storage |
|---|---|---|---|
| 100GB | $0 | 100GB × $0.09 = $9 | 무료 한도 이내 → $0 |
| 1TB (1,000GB) | $0 | 1,000GB × $0.09 = $90 | (1,000-100)GB × $0.12 = $108 |
| 10TB (10,000GB) | $0 | 10,000GB × $0.09 = $900 | (10,000-100)GB × $0.12 = $1,188 |
숫자를 그대로 읽으면, 월 1TB 다운로드에서 R2 대비 S3는 90배, Firebase Storage는 108배 비싸다. 그리고 이 격차는 트래픽이 늘어날수록 절대 금액으로 더 벌어진다. 10TB 구간에서는 R2와의 차이가 S3 기준 연 $10,800, Firebase Storage 기준 연 $14,256에 달한다.
참고로 S3는 10TB를 넘어가면 $0.085/GB(1050TB), $0.070/GB(50150TB)로 단가가 완만하게 낮아지는 구간별(tiered) 요금이다. 그래도 R2의 $0과 비교하면 여전히 의미가 없는 수준의 “할인”이다.
저장 비용과 요청 비용은 상대적으로 작다
같은 앱이 이미지 500GB를 상시 저장한다고 가정하면:
- R2: 500GB × $0.015 = 월 $7.5
- S3: 500GB × $0.023 = 월 $11.5
- Firebase Storage: 레거시 단가 기준 500GB × $0.026 ≈ 월 $13 (신규 버킷은 리전별로 달라 공식 페이지에 단일 숫자가 명시돼 있지 않다 — 확인이 필요하면 반드시 콘솔 견적을 봐야 한다)
오퍼레이션 비용도 확인해보면, 월 10만 건 업로드(Class A)·5,000만 건 조회(Class B)를 하는 제법 트래픽이 있는 앱이라도 R2 기준 Class A는 무료 티어(100만 건) 이내라 $0, Class B는 무료 티어(1,000만 건)를 넘긴 4,000만 건에 $0.36/백만 건을 곱해 월 $14.4에 그친다. 즉 저장 비용도, 요청 비용도 월 수십 달러 단위에서 논다. 반면 이그레스는 트래픽이 늘어나는 순간 세 자리, 네 자리 달러로 뛴다. 비교 우위를 가르는 변수는 결국 이그레스 하나다.
Flutter 앱에서 흔한 실수: 원본 이미지를 그대로 서빙
Firebase Storage + Flutter 조합에서 가장 자주 보이는 패턴은 이렇다.
final ref = FirebaseStorage.instance.ref('posts/$postId/original.jpg');
final url = await ref.getDownloadURL();
// ListView 안에서 그대로 로드
Image.network(url);
카메라로 찍은 원본 사진은 보통 3,000×4,000px, 파일 크기 3~8MB에 달한다. 이걸 리사이즈 없이 피드의 썸네일(실제 화면에 보이는 크기는 100×100px 수준)로 그대로 내려주면, 사용자가 피드를 스크롤할 때마다 필요 이상의 바이트가 이그레스로 나간다. 사용자 1만 명이 하루 3번 앱을 켜서 각각 30장의 이미지를 로드한다고 잡으면 하루 90만 회 다운로드, 이미지 하나당 3MB라면 하루 약 2.7TB, 월 약 81TB가 순식간에 나온다. 이 수치를 Firebase Storage 단가(월 100GB 무료 이후 GB당 약 $0.12)에 그대로 대입하면 월 1만 달러 수준의 청구서가 된다.
여기에 캐싱까지 고려하면 문제가 하나 더 있다. Firebase Storage 객체는 커스텀 캐시 설정을 하지 않으면 기본 Cache-Control: public, max-age=3600이 붙는다. 즉 1시간이 지나면 브라우저/클라이언트 캐시가 만료되고, 앱을 다시 열거나 1시간 뒤 같은 화면을 다시 방문하면 원본 파일을 처음부터 다시 받는다. cached_network_image 같은 패키지로 클라이언트 측 디스크 캐시를 걸어도, 첫 진입이나 캐시 삭제 후에는 여전히 원본을 통째로 받아야 하는 구조는 그대로다.
실전 개선 순서
- 업로드 시점에 리사이즈: Firebase Extensions의 “Resize Images” 확장 기능을 붙이거나, 서버(Cloud Functions)에서 업로드 트리거로 여러 사이즈(썸네일 200px, 중간 800px, 원본)를 만들어 각각 저장한다. 목록 화면은 썸네일만 부르고, 상세 화면 진입 시에만 원본을 요청한다.
- Cache-Control을 길게 재설정: 이미지 경로에 콘텐츠 해시를 넣어 불변(immutable) 파일로 만들고
Cache-Control: public, max-age=31536000, immutable메타데이터를 명시적으로 설정한다. 파일명이 바뀌지 않는 한 영구 캐시가 가능해진다. - Flutter 쪽에서 표시 크기와 요청 크기를 맞춘다:
Image.network에cacheWidth/cacheHeight를 지정해 디코딩 비용은 줄일 수 있지만, 네트워크로 내려받는 바이트 자체를 줄이려면 결국 서버가 애초에 작은 파일을 내려줘야 한다. 클라이언트 디코딩 최적화와 이그레스 절감은 별개 문제라는 점을 헷갈리면 안 된다. - CDN을 앞단에 둔다: Firebase Hosting rewrite나 별도 CDN으로 이미지 요청을 캐싱하면 같은 이미지에 대한 반복 요청이 오리진(Storage)까지 가지 않아 이그레스가 줄어든다. 다만 이 경우 CDN 자체의 대역폭 요금이 별도로 붙을 수 있다는 점은 감안해야 한다.
R2로 마이그레이션 체크리스트
R2가 S3 호환 API를 제공하기 때문에 기존 S3 기반 코드베이스는 생각보다 적은 변경으로 옮길 수 있다. 다만 100% 동일하지는 않다.
1) 지원되지 않는 기능부터 확인한다. R2의 S3 호환 API는 PutObject, GetObject, HeadObject, DeleteObject, ListObjectsV2, 멀티파트 업로드(CreateMultipartUpload/UploadPart/CompleteMultipartUpload) 등 핵심 오브젝트 오퍼레이션은 대부분 지원한다. 반면 ACL(x-amz-acl), Object Lock/버저닝, 객체·버킷 태깅, 버킷 정책·알림·로깅·리플리케이션, SSE-KMS 서버 측 암호화는 지원하지 않는다. 세밀한 IAM 정책이나 S3 버킷 정책으로 접근 제어를 짜놓은 프로젝트라면 이 부분을 Cloudflare API 토큰 기반 권한 모델로 다시 설계해야 한다.
2) 데이터 마이그레이션은 Super Slurper를 쓴다. Cloudflare가 제공하는 Super Slurper는 S3 및 S3 호환 스토리지(백업으로 Backblaze B2, Wasabi, MinIO, DigitalOcean Spaces 등)에서 R2로 대량 마이그레이션을 무료로 수행해준다. 과금되는 부분은 마이그레이션 중 R2에 발생하는 Class A 오퍼레이션 비용뿐이다. 다만 마이그레이션 과정에서 오브젝트를 여러 파트로 나눠 전송할 수 있어 ETag가 원본과 100% 동일하지 않을 수 있다는 점은 체크리스트에 넣어야 한다(ETag를 무결성 검증에 쓰는 코드가 있다면 별도 해시 검증으로 대체).
3) Presigned URL 발급 방식이 다르다. R2도 AWS SDK로 presigned URL을 그대로 만들 수 있다.
npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
const S3 = new S3Client({
region: "auto", // R2는 리전 개념이 없어 반드시 "auto"
endpoint: `https://<ACCOUNT_ID>.r2.cloudflarestorage.com`,
credentials: {
accessKeyId: "<ACCESS_KEY_ID>",
secretAccessKey: "<SECRET_ACCESS_KEY>",
},
});
const putUrl = await getSignedUrl(
S3,
new PutObjectCommand({
Bucket: "my-bucket",
Key: "posts/123/original.jpg",
ContentType: "image/jpeg",
}),
{ expiresIn: 3600 }, // 1초~7일(604,800초) 범위에서 설정 가능
);
Flutter 클라이언트는 이 URL을 백엔드에서 받아 그대로 PUT하면 된다.
final res = await http.put(
Uri.parse(presignedUrl),
headers: {'Content-Type': 'image/jpeg'},
body: imageBytes,
);
주의할 점은 presigned URL은 R2의 기본 r2.cloudflarestorage.com 엔드포인트에서만 동작하고, 커스텀 도메인에서는 작동하지 않는다는 것이다. 업로드는 presigned URL로, 공개 다운로드는 커스텀 도메인으로 분리하는 구조가 일반적이다.
4) Workers 바인딩으로 아예 오리진을 없앤다. 백엔드가 Cloudflare Workers라면 S3 호환 API조차 거치지 않고 네이티브 바인딩으로 직접 접근할 수 있다.
# wrangler.toml
[[r2_buckets]]
binding = "MY_BUCKET"
bucket_name = "my-app-media"
export default {
async fetch(request, env) {
const url = new URL(request.url);
const key = url.pathname.slice(1);
if (request.method === "GET") {
const obj = await env.MY_BUCKET.get(key);
return obj ? new Response(obj.body) : new Response("Not found", { status: 404 });
}
if (request.method === "PUT") {
await env.MY_BUCKET.put(key, request.body);
return new Response(`Put ${key} successfully!`);
}
return new Response("Method not allowed", { status: 405 });
},
};
이 방식은 인증·리사이즈·워터마킹 같은 로직을 Worker 안에서 처리하면서도 이그레스는 여전히 $0이라는 이점을 유지한다.
5) 공개 도메인을 연결한다. R2 버킷은 기본적으로 비공개다. 프로덕션에서는 개발용 r2.dev 서브도메인(레이트 리밋이 있어 운영에는 권장되지 않음) 대신 커스텀 도메인을 Cloudflare DNS에 연결해 공개 읽기 접근을 구성하는 것이 표준적인 방법이다.
그럼에도 S3·Firebase Storage가 나은 경우
이그레스 비용만 보면 R2가 압도적이지만, 다음 상황에서는 굳이 옮기지 않는 편이 합리적이다.
- 이미 견고한 IAM/보안 체계가 잡혀 있는 AWS 조직: 버킷 정책, VPC 엔드포인트, Cross-account 접근, Object Lock을 이용한 컴플라이언스 요건이 걸려 있다면 R2가 지원하지 않는 기능 때문에 마이그레이션 자체가 어렵거나 재설계 비용이 이그레스 절감분을 넘어설 수 있다.
- Firestore/Auth와 강하게 결합된 Firebase 생태계: Firebase Storage Security Rules로
request.auth.uid기반 세밀한 접근 제어를 이미 짜놓았거나, Storage 업로드 트리거로 Cloud Functions/Extensions(썸네일 생성, 동영상 트랜스코딩)를 연결해 쓰고 있다면 이 통합을 그대로 유지하는 게 개발 속도 면에서 유리하다. 특히 MVP·초기 스타트업 단계처럼 다운로드 트래픽이 아직 월 100GB 근처라면, 이그레스 비용 차이 자체가 미미해서 마이그레이션 비용을 정당화하기 어렵다. - CDN 통합의 완성도: S3+CloudFront 조합은 서명된 쿠키, 지역별 접근 제어, Lambda@Edge 등 오랜 기간 다져진 기능이 많다. R2도 Cloudflare CDN과 통합되지만, 이미 CloudFront 중심으로 설계된 캐시 무효화·엣지 로직이 많다면 전환 비용을 별도로 계산해야 한다.
- 팀 내 운영 지식과 도구 체인: Terraform 모듈, 모니터링 대시보드, 알람 규칙이 이미 AWS/GCP 기준으로 갖춰져 있다면, 이그레스 비용 절감액이 이 운영 자산을 다시 구축하는 인건비보다 작을 수 있다. 이 경우는 R2를 새로 만드는 미디어 파이프라인에만 우선 도입하고, 기존 버킷은 그대로 두는 점진적 전환이 현실적이다.
결론: 판단 기준은 트래픽 프로파일이다
세 서비스의 저장 요금은 GB당 1~3센트 사이로 대동소이하다. 승부는 결국 다운로드 트래픽이 얼마나 나가는지, 그리고 그 트래픽에 얼마를 청구받는지에서 갈린다.
- 월 다운로드가 100GB 이하로 작다면(초기 MVP, 내부 도구) 세 서비스 어디를 써도 비용 차이는 미미하다. 이 경우 기존 생태계 통합(Firebase Auth, AWS IAM)을 우선 고려하면 된다.
- 월 다운로드가 1TB를 넘어서는 순간부터는 R2와 나머지 두 서비스의 격차가 두 자릿수 배율로 벌어진다. 이미지·동영상이 핵심 콘텐츠인 소셜/커머스형 Flutter 앱이라면 이 지점에서 R2 전환을 진지하게 검토해야 한다.
- 10TB 이상 다운로드가 나오는 서비스라면 이그레스 비용 차이가 연간 수만 달러 단위로 벌어지므로, 앞서 정리한 마이그레이션 체크리스트(S3 호환 API 제약, Super Slurper, presigned URL, Workers 바인딩)를 기준으로 실제 견적을 뽑아보는 것이 좋다.
숫자는 거짓말을 하지 않는다. 트래픽이 커질수록 “이그레스가 무료”라는 한 문장이 청구서 전체를 결정한다.