엣지에서의 지속성: Workers KV, R2 Object Storage 및 D1 SQLite
Cloudflare Workers에서 사용할 수 있는 세 가지 저장소 계층 간의 실제 비교: 글로벌 키-값 데이터용 KV, 송신 수수료 없는 객체 스토리지용 R2, D1 엣지에서의 관계형 SQL 쿼리용.
가장자리에서의 지속성 문제
V8은 Cloudflare Workers의 전원이 설계상 무상태임을 분리합니다. 마이크로초 내에 생성 및 소멸될 수 있으며 로컬 JavaScript 상태는 요청 간에 지속되거나 동일한 요청에 대한 동시성으로 인해 가끔씩만 지속됩니다. 절연). 실제 애플리케이션을 구축하려면 외부 지속성이 필요합니다.
Cloudflare는 각각 사용 사례에 맞게 설계된 세 가지 기본 스토리지 제품을 제공합니다. 구체적. 잘못된 것을 선택하면 쿼리 속도가 느려지고 비용이 높아질 수 있습니다. 또는 디버그하기 어려운 불일치. 이 기사에서는 이를 다음과 자세히 비교합니다. 실제 코드 예시.
무엇을 배울 것인가
- Workers KV: 최종적으로 일관된 아키텍처, API, 제한 및 이상적인 사용 사례
- Cloudflare R2: 송신 비용이 없는 S3 호환 개체 스토리지, 멀티파트 업로드
- D1 SQLite: 엣지의 관계형 데이터베이스, SQL 쿼리, Drizzle ORM을 사용한 마이그레이션
- 비용 비교: 다양한 사용량 규모의 KV, R2, D1
- 통합 패턴: 지능형 캐싱을 위해 KV + D1을 결합하는 방법
- 각 저장소 유형에 대해 wrangler.toml에서 바인딩 구성
Workers KV: 전역 키-값 및 최종 일관성
Workers KV는 전역적으로 분산된 키-값 데이터 저장소입니다. 당신이 쓸 때 값은 몇 초 내에 모든 Cloudflare PoP에 복제됩니다. 읽을 때, 가장 가까운 PoP에서 값 가져오기: 읽기 대기 시간은 일반적으로 1ms 미만입니다.
근본적인 절충점: KV는 결국 일관성. 글 전체적으로 표시되는 데 최대 60초가 걸릴 수 있습니다. 이것은 그것을 만든다 자주 변경되고 즉시 읽어야 하는 데이터에는 적합하지 않습니다. 글을 쓴 후.
wrangler.toml의 KV 구성
# wrangler.toml
[[kv_namespaces]]
binding = "CACHE" # Nome del binding nel codice TypeScript
id = "xxxxxxxxxxxxxxxxxx" # ID del namespace in produzione
preview_id = "yyyyyyyyyy" # ID del namespace per wrangler dev
[[kv_namespaces]]
binding = "SESSIONS"
id = "aaaaaaaaaaaaaaaaa"
preview_id = "bbbbbbbbbbbbb"
# Per creare i namespace:
# wrangler kv namespace create CACHE
# wrangler kv namespace create CACHE --preview
TypeScript의 작업자 KV API
// src/services/cache.service.ts
export interface Env {
CACHE: KVNamespace;
SESSIONS: KVNamespace;
}
// -------- OPERAZIONI BASE --------
// WRITE: put con TTL opzionale (in secondi)
await env.CACHE.put('user:123', JSON.stringify({ name: 'Mario', role: 'admin' }), {
expirationTtl: 3600, // scade tra 1 ora
});
// WRITE senza TTL (valore permanente)
await env.CACHE.put('config:features', JSON.stringify({ darkMode: true }));
// WRITE con expiration assoluta (Unix timestamp)
await env.CACHE.put('promo:summer', 'active', {
expiration: Math.floor(Date.now() / 1000) + 86400, // scade tra 24h
});
// READ: get con tipo di decodifica
const raw = await env.CACHE.get('user:123');
// raw: string | null
const parsed = await env.CACHE.get<{ name: string; role: string }>('user:123', 'json');
// parsed: { name: string; role: string } | null
// READ con metadata
const { value, metadata } = await env.CACHE.getWithMetadata('user:123', 'json');
// DELETE
await env.CACHE.delete('user:123');
// -------- LISTING (con limitazioni) --------
// KV supporta listing, ma e lento e con limite di 1000 chiavi per chiamata
const listing = await env.CACHE.list({ prefix: 'user:', limit: 100 });
for (const key of listing.keys) {
console.log(key.name, key.expiration, key.metadata);
}
// -------- PATTERN: cache-aside --------
async function getUserCached(userId: string, env: Env): Promise<User | null> {
const cacheKey = `user:${userId}`;
// 1. Controlla la cache KV
const cached = await env.CACHE.get<User>(cacheKey, 'json');
if (cached) {
return cached; // Cache hit: risposta da KV < 1ms
}
// 2. Cache miss: fetch dall'origine
const user = await fetchUserFromDatabase(userId);
if (!user) return null;
// 3. Popola la cache (eventually consistent, ok per profili utente)
await env.CACHE.put(cacheKey, JSON.stringify(user), {
expirationTtl: 300, // 5 minuti
});
return user;
}
| 특성 | KV 세부정보 |
|---|---|
| 일관성 모델 | 최종 일관성(최대 60초 전파) |
| 읽기 대기 시간 | < 1ms(웜 후 로컬 PoP에서) |
| 쓰기 대기 시간 | ~100ms(확인), 비동기 전역 복제 |
| 최대값 크기 | 값당 25MB |
| 최대 키 크기 | 512바이트 |
| 독서 비용 | 백만 달러당 $0.50(무료: 월 1,000만 달러) |
| 작성 비용 | 백만 달러당 5달러(무료: 월 100만 달러) |
| 다음에 이상적입니다. | 구성, 기능 플래그, 세션 저장소, API 캐싱 |
| 적합하지 않음 | 실시간 카운터, 매초마다 변경되는 데이터 |
Cloudflare R2: 송신 비용이 없는 S3 호환 개체 스토리지
R2는 2022년 출시 당시 가장 큰 화제를 모은 제품 : 객체 스토리지 AWS S3 API와 호환 가능 송신 비용 없음. S3에서는 1TB의 데이터를 인터넷으로 전송하는 데 드는 비용은 ~$90입니다. R2에서는 무료입니다.
R2는 사용자 파일, 정적 자산, 백업, 보관된 로그 업로드에 이상적입니다. 브라우저나 다른 작업자가 검색해야 하는 바이너리 객체입니다.
wrangler.toml의 R2 구성
# wrangler.toml
[[r2_buckets]]
binding = "ASSETS" # Nome del binding TypeScript
bucket_name = "my-assets" # Nome del bucket in Cloudflare
# Per creare il bucket:
# wrangler r2 bucket create my-assets
TypeScript의 R2 API
// src/services/storage.service.ts
export interface Env {
ASSETS: R2Bucket;
}
// -------- UPLOAD --------
// Upload di testo semplice
await env.ASSETS.put('documents/readme.txt', 'Contenuto del file', {
httpMetadata: { contentType: 'text/plain; charset=utf-8' },
customMetadata: { uploadedBy: 'user-123', version: '1' },
});
// Upload di JSON
const data = { users: [{ id: 1, name: 'Mario' }] };
await env.ASSETS.put('data/users.json', JSON.stringify(data), {
httpMetadata: { contentType: 'application/json' },
});
// Upload di un file binario da una Request (multipart form)
async function handleFileUpload(request: Request, env: Env): Promise<Response> {
const formData = await request.formData();
const file = formData.get('file') as File | null;
if (!file) {
return new Response('No file provided', { status: 400 });
}
// Genera un nome unico per evitare collisioni
const key = `uploads/${Date.now()}-${file.name}`;
await env.ASSETS.put(key, file.stream(), {
httpMetadata: {
contentType: file.type,
contentLength: file.size,
},
customMetadata: {
originalName: file.name,
uploadedAt: new Date().toISOString(),
},
});
return Response.json({ key, size: file.size, type: file.type });
}
// -------- DOWNLOAD --------
async function serveAsset(key: string, env: Env): Promise<Response> {
const object = await env.ASSETS.get(key);
if (!object) {
return new Response('Not Found', { status: 404 });
}
// Leggi i metadata HTTP
const headers = new Headers();
object.writeHttpMetadata(headers);
headers.set('etag', object.httpEtag);
// Aggiungi headers di caching appropriati
headers.set('Cache-Control', 'public, max-age=31536000, immutable');
return new Response(object.body, { headers });
}
// -------- LISTING --------
async function listUploads(prefix: string, env: Env) {
const listed = await env.ASSETS.list({
prefix: `uploads/${prefix}`,
limit: 100,
// cursor: paginationCursor // per paginare
});
return listed.objects.map(obj => ({
key: obj.key,
size: obj.size,
uploaded: obj.uploaded.toISOString(),
etag: obj.etag,
customMetadata: obj.customMetadata,
}));
}
// -------- DELETE --------
await env.ASSETS.delete('uploads/old-file.txt');
// Delete multiplo (fino a 1000 chiavi)
await env.ASSETS.delete(['file1.txt', 'file2.txt', 'file3.txt']);
// -------- HEAD: verifica esistenza senza download --------
const headResult = await env.ASSETS.head('uploads/documento.pdf');
if (headResult) {
console.log('Size:', headResult.size);
console.log('Uploaded:', headResult.uploaded);
}
공개 액세스 및 미리 서명된 URL을 갖춘 R2
// Pattern: servire file pubblicamente tramite Worker con access control
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
// URL pattern: /files/{key}
const key = url.pathname.replace('/files/', '');
if (!key) {
return new Response('Key required', { status: 400 });
}
// Implementa qui la tua logica di autenticazione/autorizzazione
const authorized = await checkAccess(request, key, env);
if (!authorized) {
return new Response('Forbidden', { status: 403 });
}
// Gestisci il conditional request (ETag/If-None-Match)
const etag = request.headers.get('If-None-Match');
const object = await env.ASSETS.get(key, {
onlyIf: etag ? { etagDoesNotMatch: etag } : undefined,
range: request.headers.get('Range') ?? undefined,
});
if (!object) {
// Potrebbe essere 404 o 304 Not Modified
const head = await env.ASSETS.head(key);
if (!head) return new Response('Not Found', { status: 404 });
return new Response(null, { status: 304, headers: { etag: head.httpEtag } });
}
const headers = new Headers();
object.writeHttpMetadata(headers);
headers.set('etag', object.httpEtag);
if (object.range) {
headers.set('Content-Range', `bytes ${object.range.offset}-${
(object.range.offset ?? 0) + (object.range.length ?? 0) - 1
}/${object.size}`);
return new Response(object.body, { status: 206, headers });
}
return new Response(object.body, { headers });
},
};
D1 SQLite: 엣지의 관계형 데이터베이스
D1은 가장 야심찬 최신 제품으로, 엣지에 기본 SQLite가 탑재되어 있습니다. JOIN, 트랜잭션, 복잡한 쿼리 등 모든 것을 갖춘 완전한 관계형 데이터베이스 콜드 스타트 연결 없이, 송신 수수료 없이 작업자가 액세스할 수 있습니다. 모든 PoP에 자동 복제됩니다.
D1은 내부적으로 SQLite를 사용하고 이를 Cloudflare 지역 데이터 센터에 복제합니다. 쓰기는 기본 노드로 이동하고(복제본의 일관성 가능) 읽기는 대기 시간을 최소화하기 위해 로컬 복제본 역할을 할 수 있습니다.
wrangler.toml의 D1 구성
# wrangler.toml
[[d1_databases]]
binding = "DB"
database_name = "mio-database"
database_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
# Per creare il database:
# wrangler d1 create mio-database
D1을 사용한 마이그레이션 및 스키마
# Crea il file di migration
wrangler d1 migrations create mio-database "create users table"
# Crea: migrations/0001_create_users_table.sql
# migrations/0001_create_users_table.sql
-- migrations/0001_create_users_table.sql
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'user',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
CREATE INDEX IF NOT EXISTS idx_users_role ON users(role);
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
expires_at DATETIME NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id);
CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at);
# Applica le migration in locale (wrangler dev)
wrangler d1 migrations apply mio-database --local
# Applica le migration in produzione
wrangler d1 migrations apply mio-database
TypeScript의 D1 API: 직접 쿼리
// src/services/user.service.ts
export interface Env {
DB: D1Database;
}
interface User {
id: number;
email: string;
name: string;
role: string;
created_at: string;
}
// -------- SELECT --------
// Query prepared statement (SEMPRE usare prepared statements: previene SQL injection)
async function getUserById(id: number, env: Env): Promise<User | null> {
const result = await env.DB
.prepare('SELECT * FROM users WHERE id = ?')
.bind(id)
.first<User>();
return result;
}
// SELECT multipli
async function getUsersByRole(role: string, env: Env): Promise<User[]> {
const { results } = await env.DB
.prepare('SELECT id, email, name, role, created_at FROM users WHERE role = ? ORDER BY created_at DESC')
.bind(role)
.all<User>();
return results;
}
// SELECT con pagination
async function getUsers(page: number, pageSize: number, env: Env) {
const offset = (page - 1) * pageSize;
const [{ results }, { total }] = await Promise.all([
env.DB
.prepare('SELECT * FROM users LIMIT ? OFFSET ?')
.bind(pageSize, offset)
.all<User>(),
env.DB
.prepare('SELECT COUNT(*) as total FROM users')
.first<{ total: number }>()
.then(r => r ?? { total: 0 }),
]);
return {
users: results,
pagination: { page, pageSize, total: total.total, totalPages: Math.ceil(total.total / pageSize) },
};
}
// -------- INSERT --------
async function createUser(
email: string,
name: string,
env: Env,
): Promise<User> {
const result = await env.DB
.prepare('INSERT INTO users (email, name) VALUES (?, ?) RETURNING *')
.bind(email, name)
.first<User>();
if (!result) {
throw new Error('Failed to create user');
}
return result;
}
// -------- UPDATE --------
async function updateUser(id: number, updates: Partial<Pick<User, 'name' | 'role'>>, env: Env) {
const setClauses: string[] = [];
const values: (string | number)[] = [];
if (updates.name !== undefined) {
setClauses.push('name = ?');
values.push(updates.name);
}
if (updates.role !== undefined) {
setClauses.push('role = ?');
values.push(updates.role);
}
if (setClauses.length === 0) return;
setClauses.push('updated_at = CURRENT_TIMESTAMP');
values.push(id);
await env.DB
.prepare(`UPDATE users SET ${setClauses.join(', ')} WHERE id = ?`)
.bind(...values)
.run();
}
// -------- TRANSAZIONI (batch) --------
async function createUserWithSession(
email: string,
name: string,
sessionId: string,
expiresAt: Date,
env: Env,
): Promise<User> {
// D1 supporta batch per eseguire piu statement in una singola round-trip
const [userResult] = await env.DB.batch([
env.DB
.prepare('INSERT INTO users (email, name) VALUES (?, ?) RETURNING *')
.bind(email, name),
env.DB
.prepare('INSERT INTO sessions (id, user_id, expires_at) VALUES (?, last_insert_rowid(), ?)')
.bind(sessionId, expiresAt.toISOString()),
]);
const user = userResult.results[0] as User;
return user;
}
Drizzle ORM이 포함된 D1: 유형 안전 완료
Drizzle ORM은 기본적으로 D1을 지원하며 뛰어난 개발자 경험을 제공합니다. 스키마에서 자동 유형 추론을 사용합니다.
// npm install drizzle-orm
// npm install -D drizzle-kit @types/better-sqlite3
// src/db/schema.ts
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';
import { sql } from 'drizzle-orm';
export const users = sqliteTable('users', {
id: integer('id').primaryKey({ autoIncrement: true }),
email: text('email').unique().notNull(),
name: text('name').notNull(),
role: text('role', { enum: ['user', 'admin', 'moderator'] }).default('user').notNull(),
createdAt: text('created_at').default(sql`CURRENT_TIMESTAMP`),
});
export const sessions = sqliteTable('sessions', {
id: text('id').primaryKey(),
userId: integer('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
expiresAt: text('expires_at').notNull(),
createdAt: text('created_at').default(sql`CURRENT_TIMESTAMP`),
});
// Tipi TypeScript inferiti dallo schema
export type User = typeof users.$inferSelect;
export type NewUser = typeof users.$inferInsert;
// src/db/index.ts
import { drizzle } from 'drizzle-orm/d1';
import * as schema from './schema';
export function createDb(d1: D1Database) {
return drizzle(d1, { schema });
}
// src/services/user.drizzle.service.ts
import { eq, desc, count } from 'drizzle-orm';
import { createDb } from '../db';
import { users, type User, type NewUser } from '../db/schema';
export async function getUserById(id: number, env: { DB: D1Database }): Promise<User | undefined> {
const db = createDb(env.DB);
return db.select().from(users).where(eq(users.id, id)).get();
}
export async function createUser(data: NewUser, env: { DB: D1Database }): Promise<User> {
const db = createDb(env.DB);
const [user] = await db.insert(users).values(data).returning();
return user;
}
export async function getAdminUsers(env: { DB: D1Database }): Promise<User[]> {
const db = createDb(env.DB);
return db.select()
.from(users)
.where(eq(users.role, 'admin'))
.orderBy(desc(users.createdAt))
.all();
}
비교: KV 대 R2 대 D1
| 표준 | 노동자 KV | R2 | D1 SQLite |
|---|---|---|---|
| 데이터 유형 | 키-값(문자열/BLOB) | 바이너리 객체(파일) | 관계형(SQL 테이블) |
| 일관성 | 결국 일관성 | 최종적(일꾼에게 강함) | 강력(기본), 최종(복제본) |
| 쿼리 기능 | 키로만 가져오기/넣기/삭제 | 키로만 가져오기/넣기/삭제 | 전체 SQL: JOIN, 집계, 인덱스 |
| 최대 크기 | 값당 25MB | 항목당 5TB | 10GB(베타: 2GB) |
| 보관 비용 | $0.50/GB/월 | $0.015/GB/월 | $0.75/GB/월 |
| 운영 비용 | $5/백만 쓰기, $0.50/백만 읽기 | $4.50/백만 쓰기, $0.36/백만 읽기 | $0.001/M행 쓰기, $0.001/M행 읽기 |
| 읽기 대기 시간 | < 1ms(PoP 캐시에서) | ~10-50ms(저장소에서) | ~1-5ms(간단한 쿼리) |
| 이상적인 사용 사례 | 세션, 구성, API 캐시 | 파일, 자산, 백업 업로드 | CRUD 앱, 카탈로그, 사용자 |
고급 패턴: D1의 캐시 레이어인 KV
효과적인 패턴은 D1(신뢰할 수 있는 데이터)과 KV(빠른 캐시)를 결합합니다.
// src/services/cached-user.service.ts
export interface Env {
DB: D1Database;
CACHE: KVNamespace;
}
const USER_CACHE_TTL = 300; // 5 minuti
export async function getUserCached(userId: number, env: Env) {
const cacheKey = `user:v2:${userId}`;
// 1. Controlla KV cache prima (sub-ms)
const cached = await env.CACHE.get<User>(cacheKey, 'json');
if (cached) return cached;
// 2. Miss: leggi da D1 (query SQL)
const user = await getUserById(userId, env);
if (!user) return null;
// 3. Popola la cache KV in background (non blocca la risposta)
// (usa ctx.waitUntil() nel fetch handler per non bloccare)
await env.CACHE.put(cacheKey, JSON.stringify(user), {
expirationTtl: USER_CACHE_TTL,
});
return user;
}
export async function invalidateUserCache(userId: number, env: Env): Promise<void> {
await env.CACHE.delete(`user:v2:${userId}`);
}
// Quando aggiorni un utente, invalida la cache
export async function updateUserAndInvalidate(
userId: number,
updates: Partial<User>,
env: Env,
): Promise<void> {
await updateUser(userId, updates, env);
await invalidateUserCache(userId, env);
}
결론 및 다음 단계
스토리지 계층 선택은 데이터 유형에 따라 다릅니다. 키-값의 경우 KV 높은 읽기 속도와 낮은 쓰기 속도, 바이너리 파일용 R2 및 복잡한 관계와 쿼리가 포함된 구조화된 데이터의 경우 D1 자산입니다. 많은 응용 프로그램에서 실제로는 세 가지를 모두 조합하여 사용하게 됩니다.
시리즈의 다음 기사
- 제4조: 지속성 있는 객체 — 강력한 일관성을 지닌 상태 및 WebSocket: KV가 충분하지 않고 일관성이 필요한 경우 강력하고 분산된 조정.
- 제5조: Workers AI — LLM 및 비전 모델 추론 Straight to the Edge: AI 바인딩을 사용하여 Workers에서 AI 모델을 실행하는 방법.
- 제10조: 엣지의 풀스택 아키텍처 - 사례 1개 Workers + D1 + R2 + CI/CD를 GitHub Actions와 결합한 완전한 연구입니다.







