NestBook / Cloudflare + D1

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초가 걸릴 수 있다. 서울에서 쓴 값을 도쿄에서 바로 읽으면 이전 값이 나올 수 있다.

⚠️ Eventual Consistency — 이럴 때 문제가 된다
  • 사용자가 비밀번호를 변경했는데, 일부 엣지에서 잠깐 동안 이전 비밀번호로 인증이 통과되는 상황
  • 재고 수량처럼 정확한 동시 업데이트가 필요한 경우
  • → 이런 경우에는 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}`);
⚠️ list()는 느리고 비싸다
KV의 list()는 전체 키를 스캔하므로 성능이 좋지 않다. 키 목록이 자주 필요하다면 별도의 인덱스 키를 만들어 관리하는 패턴을 사용한다. 예: "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() 활용법
ctx.waitUntil(promise)는 응답을 클라이언트에 먼저 보내고, 그 이후에도 비동기 작업(KV 저장, 로그 전송 등)을 완료될 때까지 Worker를 유지시킨다. 응답 속도에 영향을 주지 않으면서 사이드 이펙트를 처리할 때 매우 유용하다.

KV vs D1 — 무엇을 언제 사용할까

이 두 저장소는 자주 혼동된다. 목적과 특성이 완전히 다르다.

비교 항목Workers KVD1 (다음 챕터)
데이터 모델Key-Value (키로만 조회)관계형 SQL (SELECT, JOIN 가능)
읽기 속도매우 빠름 (엣지 캐시)보통 (지역 DB에서 읽기)
쓰기 일관성Eventual (최대 60초 지연)강한 일관성 (즉시 반영)
복잡한 쿼리불가능가능 (SQL)
적합한 용도세션, 캐시, 설정, 정적 데이터사용자 데이터, 트랜잭션, 복잡한 관계

선택 가이드

KV를 선택해야 할 때:

  • “이 데이터를 키 하나로 조회한다” → KV
  • 캐시가 목적이다 (TTL 자동 만료) → KV
  • 읽기 빈도가 쓰기보다 훨씬 높다 → KV
  • 세션, 인증 토큰 저장 → KV

D1을 선택해야 할 때:

  • “이 데이터를 WHERE 조건으로 검색한다” → D1
  • 여러 테이블을 JOIN해서 조회한다 → D1
  • 데이터 변경이 즉시 모든 사용자에게 반영되어야 한다 → D1
  • 재고, 잔액 같은 정확한 동시성 제어가 필요하다 → D1
📌 실전에서는 KV + D1을 함께 쓴다
전형적인 패턴: D1에 실제 데이터를 저장하고, KV를 캐시 레이어로 앞에 둔다. D1 조회 결과를 KV에 5분간 캐시하면 DB 부하를 크게 줄이면서 응답 속도도 높일 수 있다.

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 바인딩까지 처음부터 설정한다.