NestBook / Cloudflare + D1

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.keystring오브젝트 키(파일 경로)
object.sizenumber바이트 단위 크기
object.uploadedDate업로드 시각
object.etagstring내용 해시 (캐시 검증용)
object.httpMetadataobjectContent-Type 등 HTTP 메타데이터
object.customMetadataobjectput() 시 지정한 커스텀 메타데이터
object.bodyReadableStream파일 본문 스트림

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’

**다음 챕터 연결** — Workers·Pages·R2 모두 시크릿 키(R2_ACCESS_KEY_ID, API_SECRET 등)를 사용한다. 이 값들을 코드에 하드코딩하면 보안 사고로 이어진다. 다음 챕터에서는 **환경 변수 & Secrets** 관리 — `wrangler secret`, `.dev.vars`, 환경별(dev/prod) 분리 — 를 체계적으로 정리한다.