R2 오브젝트 스토리지
R2란 — S3와 무엇이 다른가
Cloudflare R2는 오브젝트 스토리지 서비스다. AWS S3와 API가 거의 같아서 S3 SDK를 그대로 사용할 수 있다. 결정적 차이는 이그레스(egress) 요금이 0원이라는 점이다.
AWS S3는 데이터를 꺼낼 때마다 GB당 약 $0.09를 청구한다. 이미지 100 GB를 월 10만 회 서빙하면 상당한 비용이 발생한다. R2는 꺼내는 요금이 없다.
| 항목 | R2 무료 플랜 | R2 유료 | AWS S3 (서울 리전) |
|---|---|---|---|
| 저장 | 10 GB/월 | $0.015/GB | $0.025/GB |
| 클래스 A (쓰기) | 100만 회/월 | $4.50/100만 | $0.005/1000 |
| 클래스 B (읽기) | 1000만 회/월 | $0.36/100만 | $0.0004/1000 |
| 이그레스 | 무료 | 무료 | $0.09/GB |
버킷 생성
Dashboard에서
Dashboard → R2 Object Storage → Create bucket
→ 버킷 이름 입력 (전 세계 고유 필요 없음, 계정 내 고유)
→ 위치: Automatic (권장) 또는 특정 리전 선택
→ Create bucket
CLI에서
npx wrangler r2 bucket create my-uploads
npx wrangler r2 bucket list # 버킷 목록 확인
npx wrangler r2 bucket delete my-old-bucket
Workers 바인딩 설정
# wrangler.toml
[[r2_buckets]]
binding = "BUCKET"
bucket_name = "my-uploads"
TypeScript 타입:
type Bindings = {
BUCKET: R2Bucket
DB: D1Database
}
기본 파일 업로드·다운로드·삭제
import { Hono } from 'hono'
type Bindings = { BUCKET: R2Bucket }
const app = new Hono<{ Bindings: Bindings }>()
// 업로드 — PUT /files/:key
app.put('/files/:key', async (c) => {
const key = c.req.param('key')
const body = await c.req.arrayBuffer()
await c.env.BUCKET.put(key, body, {
httpMetadata: {
contentType: c.req.header('Content-Type') ?? 'application/octet-stream',
},
// 커스텀 메타데이터
customMetadata: {
uploadedBy: 'worker',
originalName: key,
},
})
return c.json({ uploaded: key }, 201)
})
// 다운로드 — GET /files/:key
app.get('/files/:key', async (c) => {
const key = c.req.param('key')
const object = await c.env.BUCKET.get(key)
if (!object) return c.json({ error: 'Not Found' }, 404)
// R2Object의 헤더를 그대로 복사해 캐시·ETag 처리
const headers = new Headers()
object.writeHttpMetadata(headers)
headers.set('ETag', object.httpEtag)
return new Response(object.body, { headers })
})
// 삭제 — DELETE /files/:key
app.delete('/files/:key', async (c) => {
await c.env.BUCKET.delete(c.req.param('key'))
return c.json({ deleted: true })
})
// 목록 조회 — GET /files
app.get('/files', async (c) => {
const listed = await c.env.BUCKET.list({
prefix: c.req.query('prefix') ?? '',
limit: 50,
})
return c.json({
objects: listed.objects.map((o) => ({
key: o.key,
size: o.size,
uploaded: o.uploaded,
})),
truncated: listed.truncated,
cursor: listed.truncated ? listed.cursor : undefined,
})
})
export default app
R2Object 주요 속성
| 속성 | 타입 | 설명 |
|---|---|---|
object.key | string | 오브젝트 키(파일 경로) |
object.size | number | 바이트 단위 크기 |
object.uploaded | Date | 업로드 시각 |
object.etag | string | 내용 해시 (캐시 검증용) |
object.httpMetadata | object | Content-Type 등 HTTP 메타데이터 |
object.customMetadata | object | put() 시 지정한 커스텀 메타데이터 |
object.body | ReadableStream | 파일 본문 스트림 |
Presigned URL — 클라이언트 직접 업로드
Presigned URL은 특정 시간 동안만 유효한 서명된 URL이다. 클라이언트가 서버를 거치지 않고 R2에 직접 업로드할 수 있어 Workers의 메모리 한도(128 MB)를 우회할 수 있다.
클라이언트 Workers R2
│ │ │
│ GET /upload-url?file=... │ │
│ ──────────────────────────▶│ │
│ │ createPresignedUrl() │
│ │ ─────────────────────────▶│
│ │◀─────────────────────────│
│ { url, key } │ │
│◀──────────────────────────│ │
│ │ │
│ PUT {url} (파일 직접 전송) │ │
│ ──────────────────────────────────────────────────────▶│
│ │ │
Workers에서 Presigned URL을 생성하려면 S3 호환 API(aws4fetch 라이브러리)를 사용한다.
npm install aws4fetch
// R2 Presigned URL 생성 — Workers + S3 호환 API
import { AwsClient } from 'aws4fetch'
type Bindings = {
R2_ACCOUNT_ID: string
R2_ACCESS_KEY_ID: string
R2_SECRET_ACCESS_KEY: string
R2_BUCKET_NAME: string
}
// Presigned URL 발급 엔드포인트
app.get('/upload-url', async (c) => {
const filename = c.req.query('filename')
if (!filename) return c.json({ error: 'filename required' }, 400)
const key = `uploads/${Date.now()}-${filename}`
const expiresIn = 3600 // 1시간 유효
const r2 = new AwsClient({
accessKeyId: c.env.R2_ACCESS_KEY_ID,
secretAccessKey: c.env.R2_SECRET_ACCESS_KEY,
})
const endpoint = `https://${c.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`
const url = new URL(`/${c.env.R2_BUCKET_NAME}/${key}`, endpoint)
url.searchParams.set('X-Amz-Expires', String(expiresIn))
const signed = await r2.sign(
new Request(url, { method: 'PUT' }),
{ aws: { signQuery: true } }
)
return c.json({ url: signed.url, key })
})
클라이언트(React/Vue 등) 사용 예:
// 1. Workers에서 Presigned URL 발급 요청
const { url, key } = await fetch('/upload-url?filename=photo.jpg').then(r => r.json())
// 2. R2에 직접 업로드 (Workers를 경유하지 않음)
await fetch(url, {
method: 'PUT',
body: file, // File 또는 Blob
headers: { 'Content-Type': file.type },
})
// 3. 업로드 완료 후 키를 DB에 저장
await fetch('/api/posts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title, imageKey: key }),
})
공개 접근 설정
기본적으로 R2 버킷은 비공개다. 이미지를 URL로 공개 제공하려면 두 가지 방법이 있다.
방법 1 — R2 Public Bucket
Dashboard → R2 → [버킷] → Settings → Public access → Allow Access
활성화하면 https://pub-<hash>.r2.dev/<key> URL로 직접 접근 가능하다.
방법 2 — Workers를 통한 프록시 (권장)
Workers가 R2에서 파일을 읽어 클라이언트에 전달한다. 접근 제어·캐시 헤더·이미지 리사이징 등을 미들웨어로 추가할 수 있다.
// GET /assets/:key — Workers 프록시
app.get('/assets/:key{.+}', async (c) => {
const key = c.req.param('key')
const object = await c.env.BUCKET.get(key)
if (!object) return c.notFound()
const headers = new Headers()
object.writeHttpMetadata(headers)
headers.set('ETag', object.httpEtag)
// 브라우저 1시간 캐시 + CDN 1일 캐시
headers.set('Cache-Control', 'public, max-age=3600, s-maxage=86400')
return new Response(object.body, { headers })
})
흔한 실수 —
Cache-Control헤더를 설정하지 않으면 Workers를 거치는 모든 요청이 매번 R2를 읽는다. 정적 에셋은 반드시 캐시 헤더를 추가해야 비용과 지연시간을 줄일 수 있다.
실전 패턴 — 이미지 업로드 전체 흐름
// D1 테이블: posts (id, title, image_key, created_at)
// POST /posts — 제목 + 이미지 키 저장
app.post('/posts', async (c) => {
const { title, imageKey } = await c.req.json()
const post = await c.env.DB
.prepare('INSERT INTO posts (title, image_key) VALUES (?, ?) RETURNING *')
.bind(title, imageKey)
.first()
return c.json(post, 201)
})
// GET /posts/:id — 게시글 + 이미지 URL 반환
app.get('/posts/:id', async (c) => {
const post = await c.env.DB
.prepare('SELECT * FROM posts WHERE id = ?')
.bind(c.req.param('id'))
.first<{ id: number; title: string; image_key: string }>()
if (!post) return c.json({ error: 'Not Found' }, 404)
return c.json({
...post,
imageUrl: post.image_key
? `https://my-site.pages.dev/assets/${post.image_key}`
: null,
})
})
핵심 정리
import KeyPoints from ’../../components/KeyPoints.astro’
<KeyPoints points={[ “R2는 S3 호환 오브젝트 스토리지 — 이그레스(다운로드) 요금이 0원이 핵심 장점”, “BUCKET.put(key, body) / BUCKET.get(key) / BUCKET.delete(key) 세 메서드가 기본”, “Presigned URL로 클라이언트 직접 업로드 — Workers 128 MB 메모리 한도 우회”, “Workers 프록시 패턴 + Cache-Control 헤더로 R2 읽기 비용과 응답 지연 동시에 절감”, “파일 경로(key)를 D1에 저장하고, 응답 시 URL로 변환해 반환하는 패턴이 표준” ]} />
import Callout from ’../../components/Callout.astro’