Workers KV
Workers KV란?
Workers KV(Key-Value)는 Cloudflare의 글로벌 분산 키-값 스토리지다. 전 세계 엣지 노드에 데이터를 복제해두어, 어디서 읽더라도 가장 가까운 노드에서 빠르게 응답한다.
쉽게 말해 거대한 딕셔너리(객체) 를 전 세계에 배포해둔 것이다:
- 키(key): 문자열 (최대 512바이트)
- 값(value): 문자열, JSON, 바이너리 (최대 25MB)
KV 네임스페이스 "MY_CACHE"
├── "user:1001" → '{"name":"홍길동","plan":"free"}'
├── "session:abc" → '{"userId":1001,"expires":1735689600}'
├── "config:rate" → '100'
└── "html:home" → '<html>...</html>' (정적 HTML 캐시)
KV의 특성 — 장점과 한계를 알아야 제대로 쓴다
Eventual Consistency (최종 일관성)
KV에 값을 쓰면(put) 전 세계 엣지에 복제되기까지 최대 60초가 걸릴 수 있다. 서울에서 쓴 값을 도쿄에서 바로 읽으면 이전 값이 나올 수 있다.
- 사용자가 비밀번호를 변경했는데, 일부 엣지에서 잠깐 동안 이전 비밀번호로 인증이 통과되는 상황
- 재고 수량처럼 정확한 동시 업데이트가 필요한 경우
- → 이런 경우에는 D1(다음 챕터)을 사용한다
읽기는 빠르고, 쓰기는 느리다
| 작업 | 속도 | 이유 |
|---|---|---|
| 읽기(get) | 매우 빠름 (~1ms) | 가장 가까운 엣지 캐시에서 반환 |
| 쓰기(put) | 상대적으로 느림 (~수십ms) | 전 세계 복제 시작 |
| 삭제(delete) | 느림 | 모든 엣지에서 제거 필요 |
무료 플랜 한도
| 항목 | 무료 | 유료 |
|---|---|---|
| 읽기 | 10만 건/일 | 1,000만 건/월 이후 $0.5/백만 건 |
| 쓰기 | 1,000건/일 | 100만 건/월 이후 $5/백만 건 |
| 스토리지 | 1GB | 이후 $0.5/GB/월 |
| 네임스페이스 | 최대 100개 | 동일 |
KV 네임스페이스 생성
KV는 “네임스페이스” 단위로 관리된다. 하나의 프로젝트에서 여러 네임스페이스를 사용할 수 있다 (예: SESSIONS, CACHE, CONFIG).
1. 네임스페이스 생성
# 프로덕션용 네임스페이스 생성
wrangler kv namespace create "MY_KV"
# 출력:
# ⛅️ wrangler 3.x.x
# -------------------
# 🌀 Creating namespace with title "my-worker-MY_KV"
# ✅ Success!
# Add the following to your configuration file in your kv_namespaces array:
# {
# binding = "MY_KV",
# id = "a1b2c3d4e5f6789012345678abcdef01" ← 이 ID를 복사한다
# }
# 로컬 개발용 프리뷰 네임스페이스도 만든다
wrangler kv namespace create "MY_KV" --preview
# 출력:
# Add the following to your wrangler.toml:
# {
# binding = "MY_KV",
# preview_id = "b2c3d4e5f6789012345678abcdef0123" ← preview_id
# }
2. wrangler.toml에 바인딩 추가
name = "my-worker"
main = "src/index.ts"
compatibility_date = "2024-01-01"
# KV 바인딩 — 생성된 ID를 여기에 붙여넣는다
[[kv_namespaces]]
binding = "MY_KV" # 코드에서 env.MY_KV 로 접근
id = "a1b2c3d4e5f6789012345678abcdef01"
preview_id = "b2c3d4e5f6789012345678abcdef0123" # wrangler dev 시 사용
3. TypeScript 타입 추가
src/index.ts 상단 또는 별도 worker-configuration.d.ts 파일:
// worker-configuration.d.ts
interface Env {
MY_KV: KVNamespace; // KVNamespace 타입을 등록해야 env.MY_KV 접근 시 자동완성이 된다
}
또는 wrangler types 명령어로 자동 생성:
wrangler types # worker-configuration.d.ts 파일을 자동 생성해준다
KV CRUD — 기본 조작
get — 읽기
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
// 기본 읽기 — 문자열 반환 (없으면 null)
const value = await env.MY_KV.get('user:1001');
// → '{"name":"홍길동","plan":"free"}' 또는 null
if (value === null) {
return Response.json({ error: '데이터 없음' }, { status: 404 });
}
// JSON으로 파싱이 필요한 경우
const user = JSON.parse(value);
// 또는 type 옵션으로 한 번에:
const user2 = await env.MY_KV.get('user:1001', { type: 'json' });
// → { name: '홍길동', plan: 'free' } (자동 파싱)
// ArrayBuffer로 읽기 (이미지 등 바이너리)
const imgBuffer = await env.MY_KV.get('img:logo', { type: 'arrayBuffer' });
// ReadableStream으로 읽기 (대용량 데이터 스트리밍)
const stream = await env.MY_KV.get('data:large', { type: 'stream' });
return Response.json({ user: user2 });
},
};
put — 쓰기
// 기본 쓰기
await env.MY_KV.put('user:1001', '{"name":"홍길동","plan":"free"}');
// JSON 객체를 직접 저장 (JSON.stringify 필요)
const userData = { name: '홍길동', plan: 'free', createdAt: Date.now() };
await env.MY_KV.put('user:1001', JSON.stringify(userData));
// TTL 설정 — N초 후 자동 삭제
await env.MY_KV.put('session:abc', JSON.stringify({ userId: 1001 }), {
expirationTtl: 3600, // 1시간 후 자동 삭제
});
// 특정 Unix 타임스탬프에 만료
const expiresAt = Math.floor(Date.now() / 1000) + 86400; // 24시간 후
await env.MY_KV.put('cache:home', htmlContent, {
expiration: expiresAt,
});
// 메타데이터 함께 저장 (최대 1024바이트, 별도 조회 가능)
await env.MY_KV.put('file:image.jpg', binaryData, {
metadata: { contentType: 'image/jpeg', size: 45230, uploadedBy: 'admin' },
});
delete — 삭제
// 단순 삭제
await env.MY_KV.delete('session:abc');
// 없는 키를 삭제해도 에러가 나지 않는다 (idempotent)
await env.MY_KV.delete('key:doesnt-exist'); // 정상 처리
getWithMetadata — 값과 메타데이터 함께 읽기
const result = await env.MY_KV.getWithMetadata<string, { contentType: string; size: number }>(
'file:image.jpg',
{ type: 'arrayBuffer' }
);
// result.value → ArrayBuffer (파일 데이터)
// result.metadata → { contentType: 'image/jpeg', size: 45230, uploadedBy: 'admin' }
if (result.value === null) {
return new Response('파일 없음', { status: 404 });
}
return new Response(result.value, {
headers: {
'Content-Type': result.metadata?.contentType ?? 'application/octet-stream',
},
});
list — 키 목록 조회
// 전체 키 목록 (최대 1,000개)
const listResult = await env.MY_KV.list();
// {
// keys: [
// { name: 'session:abc', expiration: 1735689600, metadata: null },
// { name: 'user:1001', expiration: undefined, metadata: null },
// ],
// list_complete: true, // false면 cursor로 다음 페이지 조회
// cursor: undefined
// }
// 특정 접두사로 필터링
const userKeys = await env.MY_KV.list({ prefix: 'user:' });
// → user:1001, user:1002, user:1003 ...
// 키 개수 제한
const limited = await env.MY_KV.list({ prefix: 'session:', limit: 100 });
// 페이지네이션 (cursor 방식)
let cursor: string | undefined = undefined;
const allKeys: string[] = [];
do {
const result = await env.MY_KV.list({ prefix: 'user:', cursor });
allKeys.push(...result.keys.map(k => k.name));
cursor = result.list_complete ? undefined : result.cursor;
} while (cursor);
console.log(`전체 사용자 키 수: ${allKeys.length}`);
"user:index" 키에 사용자 ID 배열을 저장.
실전 패턴: 세션 관리
KV의 가장 전형적인 사용 사례는 사용자 세션 저장이다. 로그인 시 세션 토큰을 KV에 저장하고, 이후 요청마다 토큰을 검증한다.
// 세션 데이터 타입 정의
interface Session {
userId: number;
email: string;
createdAt: number;
expiresAt: number;
}
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
const url = new URL(request.url);
// 로그인 API
if (url.pathname === '/api/login' && request.method === 'POST') {
const body = await request.json<{ email: string; password: string }>();
// 실제 서비스에서는 D1에서 사용자 조회 후 비밀번호 검증
if (body.email !== 'test@example.com' || body.password !== '1234') {
return Response.json({ error: '이메일 또는 비밀번호가 틀렸습니다' }, { status: 401 });
}
// 세션 토큰 생성
const sessionToken = crypto.randomUUID();
const session: Session = {
userId: 1001,
email: body.email,
createdAt: Date.now(),
expiresAt: Date.now() + 7 * 24 * 60 * 60 * 1000, // 7일
};
// KV에 세션 저장 (7일 후 자동 삭제)
await env.MY_KV.put(
`session:${sessionToken}`,
JSON.stringify(session),
{ expirationTtl: 7 * 24 * 60 * 60 }
);
return Response.json({
ok: true,
sessionToken, // 클라이언트에 토큰 전달
expiresAt: session.expiresAt,
});
}
// 인증이 필요한 API — 세션 검증
if (url.pathname === '/api/me') {
const authHeader = request.headers.get('Authorization');
if (!authHeader?.startsWith('Bearer ')) {
return Response.json({ error: '인증이 필요합니다' }, { status: 401 });
}
const token = authHeader.slice(7); // "Bearer " 이후 토큰 추출
const sessionData = await env.MY_KV.get<Session>(`session:${token}`, { type: 'json' });
if (!sessionData) {
return Response.json({ error: '유효하지 않거나 만료된 세션입니다' }, { status: 401 });
}
return Response.json({
ok: true,
user: { id: sessionData.userId, email: sessionData.email },
});
}
// 로그아웃 API
if (url.pathname === '/api/logout' && request.method === 'POST') {
const authHeader = request.headers.get('Authorization');
if (authHeader?.startsWith('Bearer ')) {
const token = authHeader.slice(7);
await env.MY_KV.delete(`session:${token}`); // 세션 즉시 삭제
}
return Response.json({ ok: true });
}
return Response.json({ error: 'Not Found' }, { status: 404 });
},
};
실전 패턴: HTML/API 응답 캐싱
외부 API 호출 결과나 연산 비용이 높은 데이터를 KV에 캐시하면 응답 속도를 크게 높일 수 있다.
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
const url = new URL(request.url);
if (url.pathname === '/api/exchange-rate') {
const cacheKey = 'cache:exchange-rate:KRW-USD';
// 캐시 먼저 확인
const cached = await env.MY_KV.get(cacheKey);
if (cached) {
return Response.json(JSON.parse(cached), {
headers: { 'X-Cache': 'HIT' },
});
}
// 캐시 미스 — 외부 API 호출
const apiRes = await fetch('https://api.exchangerate-api.com/v4/latest/USD');
const data = await apiRes.json<{ rates: Record<string, number> }>();
const rate = data.rates.KRW;
const result = {
currency: 'KRW',
rate,
updatedAt: new Date().toISOString(),
};
// KV에 1시간 캐시
// ctx.waitUntil을 사용하면 응답을 먼저 보내고 KV 저장은 백그라운드에서 처리
ctx.waitUntil(
env.MY_KV.put(cacheKey, JSON.stringify(result), { expirationTtl: 3600 })
);
return Response.json(result, {
headers: { 'X-Cache': 'MISS' },
});
}
return Response.json({ error: 'Not Found' }, { status: 404 });
},
};
ctx.waitUntil(promise)는 응답을 클라이언트에 먼저 보내고, 그 이후에도 비동기 작업(KV 저장, 로그 전송 등)을 완료될 때까지 Worker를 유지시킨다. 응답 속도에 영향을 주지 않으면서 사이드 이펙트를 처리할 때 매우 유용하다.
KV vs D1 — 무엇을 언제 사용할까
이 두 저장소는 자주 혼동된다. 목적과 특성이 완전히 다르다.
| 비교 항목 | Workers KV | D1 (다음 챕터) |
|---|---|---|
| 데이터 모델 | Key-Value (키로만 조회) | 관계형 SQL (SELECT, JOIN 가능) |
| 읽기 속도 | 매우 빠름 (엣지 캐시) | 보통 (지역 DB에서 읽기) |
| 쓰기 일관성 | Eventual (최대 60초 지연) | 강한 일관성 (즉시 반영) |
| 복잡한 쿼리 | 불가능 | 가능 (SQL) |
| 적합한 용도 | 세션, 캐시, 설정, 정적 데이터 | 사용자 데이터, 트랜잭션, 복잡한 관계 |
선택 가이드
KV를 선택해야 할 때:
- “이 데이터를 키 하나로 조회한다” → KV
- 캐시가 목적이다 (TTL 자동 만료) → KV
- 읽기 빈도가 쓰기보다 훨씬 높다 → KV
- 세션, 인증 토큰 저장 → KV
D1을 선택해야 할 때:
- “이 데이터를 WHERE 조건으로 검색한다” → D1
- 여러 테이블을 JOIN해서 조회한다 → D1
- 데이터 변경이 즉시 모든 사용자에게 반영되어야 한다 → D1
- 재고, 잔액 같은 정확한 동시성 제어가 필요하다 → D1
CLI로 KV 직접 조작 (개발·디버깅)
코드 없이 CLI에서 KV를 직접 읽고 쓸 수 있다.
# 값 쓰기
wrangler kv key put --namespace-id=a1b2c3... "user:1001" '{"name":"홍길동"}'
# 값 읽기
wrangler kv key get --namespace-id=a1b2c3... "user:1001"
# → {"name":"홍길동"}
# TTL과 함께 쓰기 (3600초 후 만료)
wrangler kv key put --namespace-id=a1b2c3... "temp:key" "임시값" --ttl=3600
# 키 삭제
wrangler kv key delete --namespace-id=a1b2c3... "user:1001"
# 키 목록 조회
wrangler kv key list --namespace-id=a1b2c3...
# 특정 접두사로 필터링
wrangler kv key list --namespace-id=a1b2c3... --prefix="user:"
초보자 흔한 오해와 주의사항
오해 1: “KV는 DB처럼 쓸 수 있다”
KV는 단순 키-값 저장소다. user:1001이라는 키를 알아야 조회할 수 있다. “나이가 20~30세인 사용자 전체” 같은 조건 조회는 불가능하다. 이런 쿼리가 필요하면 D1을 써야 한다.
오해 2: “put하면 즉시 모든 곳에서 보인다” Eventual Consistency 때문에 put 직후 같은 Worker의 다른 요청에서도 이전 값이 보일 수 있다. 같은 요청 내에서는 put 직후 get하면 새 값이 보이지만, 다른 엣지 노드에서는 최대 60초 지연이 있다.
오해 3: “무료 쓰기 한도 1,000건/일이면 충분하다” API가 요청마다 KV에 쓰기(카운터 증가, 로그 기록 등)를 한다면 빠르게 한도를 소진한다. 쓰기는 배치로 묶거나 꼭 필요한 경우만 사용한다.
주의: 키 네이밍 전략
KV는 모든 키가 플랫(flat)하다. 계층 구조가 없다. user:1001:profile, user:1001:settings 처럼 콜론(:)으로 네임스페이스를 표현하는 컨벤션을 처음부터 정해두면 list({ prefix: 'user:1001:' })로 특정 사용자 데이터를 한번에 조회할 수 있다.
다음 챕터에서는 D1 — Cloudflare의 SQLite 기반 엣지 데이터베이스를 다룬다. KV로는 할 수 없는 SQL 쿼리, JOIN, 트랜잭션이 필요한 순간 D1이 필요해진다. wrangler d1 create부터 Workers 바인딩까지 처음부터 설정한다.