테마 전환

Cloudflare Workers KV 실전 가이드: 분산 키-값 스토리지 기초부터 활용까지

Easton editorial illustration: bottleneck pressure gauge

Cloudflare Dashboard의 지연 시간 그래프를 바라봤습니다. 빨간 선은 여전히 200ms 위를 맴돌고 있었습니다. 분명 Workers를 사용했고 코드도 충분히 간결한데, 왜 모든 사용자 요청이 이렇게 오래 기다려야 할까요?

문제는 데이터베이스였습니다. session을 조회할 때마다 엣지 노드에서 유럽의 데이터 센터까지 갔다가 다시 돌아와야 했습니다. Workers 실행에는 5ms밖에 걸리지 않아도 네트워크 전송이 시간을 모두 잡아먹었습니다.

나중에서야 Workers 자체는 상태를 저장하지 않는다는 점을 깨달았습니다. 진짜로 ‘엣지에 상주하는’ 스토리지가 필요했습니다. 바로 Cloudflare Workers KV입니다.

이 글에서는 제가 직접 겪은 시행착오, 측정한 데이터, 작성한 코드를 모두 공개합니다. KV가 정확히 무엇인지, 어떻게 sub-10ms 지연 시간을 달성하는지부터 session storage와 API cache의 전체 구현까지 다룹니다. 언제 KV를 사용해야 하고 언제 D1이나 R2가 더 적합한지도 살펴보겠습니다.

KV란 무엇인가 — 분산 Edge 스토리지 이해하기

쉽게 말해 KV는 Cloudflare가 Workers에 제공하는 ‘휴대용 메모리’입니다. 이 메모리는 특정 데이터 센터 한 곳에 있는 것이 아니라 전 세계 300개 이상의 엣지 노드에 분산되어 있습니다. 도쿄 사용자의 요청이라면 데이터가 도쿄 엣지 노드에서 기다리고 있을 수 있고, 프랑크푸르트의 요청이라면 이미 프랑크푸르트 캐시에 들어 있을 수 있습니다.

Cloudflare Workers KV는 엣지 컴퓨팅을 위해 설계된 전 세계 분산 키-값 스토리지입니다. 핵심 특징은 세 가지입니다.

매우 빠른 읽기. hot key의 캐시 적중 지연 시간은 500µs에서 10ms 사이입니다. 솔직히 처음 이 수치를 봤을 때는 조금 의심했지만, 직접 benchmark를 실행해 보니 실제로 한 자릿수 밀리초 수준을 안정적으로 유지했습니다.

전 세계 복제. 데이터를 하나 쓰면 전 세계 모든 엣지 노드로 복제됩니다. 이 점은 Redis 클러스터와 조금 다릅니다. KV의 데이터 모델은 ‘한 번 쓰고 어디서나 읽는’ 방식으로, 읽기가 쓰기보다 많은 상황에 적합합니다.

높은 처리량. 데이터가 엣지에 캐시되므로 매번 원본 스토리지에 접근할 필요가 없어 key 하나의 읽기 처리량이 수천 RPS(requests per second)에 이를 수 있습니다.

500µs - 10ms
Hot Key 지연 시간 범위

Cloudflare 스토리지 제품군 비교

KV는 Cloudflare 스토리지 구성의 한 조각일 뿐입니다. 먼저 전체 그림을 살펴보겠습니다.

스토리지 서비스데이터 모델가장 적합한 용도쓰기 제한지연 시간 특성
KVKey-ValueSession, Cache, 설정1 RPS/keyhot key 500µs~10ms
D1SQL(SQLite)사용자 데이터, 주문, 보고서하드 제한 없음위치에 따라 다르며 일반적으로 50~200ms
R2Object Storage파일, 이미지, 동영상하드 제한 없음다운로드는 빠르며 업로드는 파일 크기에 따라 다름
Durable Objects상태 저장 객체공동 편집, WebSocket하드 제한 없음특정 노드로 라우팅해야 함

이 표를 보면 1 RPS/key가 무슨 의미인지 궁금할 수 있습니다. 뒤에서 자세히 설명하겠지만, 간단히 말하면 key 하나에는 초당 한 번만 쓸 수 있다는 뜻이며 KV에서 가장 주의해야 할 제한입니다.

KV 활용 사례 빠르게 확인하기

언제 KV를 고려해야 할까요? 간단한 판단 기준은 다음과 같습니다.

KV를 권장하는 경우:

  • Session storage(사용자 로그인 상태)
  • API response cache(서드파티 API 반환값)
  • Rate limiting counters(요청 빈도 제한 카운터)
  • Feature flags / 설정 데이터
  • Redirect mapping(URL 리디렉션 규칙)

KV를 권장하지 않는 경우:

  • 쓰기가 잦은 데이터(예: 실시간 카운터, 1 RPS를 넘으면 사용할 수 없음)
  • SQL 쿼리가 필요한 복잡한 데이터(사용자 테이블, 주문 테이블에는 D1 사용)
  • 대용량 파일 저장(이미지와 동영상에는 R2 사용)
  • 강한 일관성이 필요한 금융 거래 데이터(Durable Objects 사용)

Cloudflare 공식 문서에도 KV는 ‘읽기 비율이 높고 수정 빈도가 낮으며 즉각적인 일관성이 필요하지 않은’ 상황에 적합하다고 명확히 설명되어 있습니다. OpenAuth 같은 인증 프레임워크도 KV를 기본 session storage로 사용합니다. 뒤에서 전체 구현 코드를 보여드리겠습니다.

KV 아키텍처 심층 분석 — 왜 이렇게 빠른가

KV의 속도는 마법이 아니라 3계층 캐시 아키텍처의 결과입니다.

편의점에서 물건을 산다고 생각해 보세요. 가장 이상적인 경우는 상품이 계산대 바로 옆 선반에 있어 손만 뻗으면 되는 상황입니다(edge cache). 조금 덜 좋은 경우에는 창고에 있어 직원이 가져와야 합니다(regional cache). 가장 느린 경우에는 중앙 창고에 있어 트럭으로 배송되기를 기다려야 합니다(central store).

KV의 아키텍처도 이 세 계층으로 구성됩니다.

요청 → Edge Cache (가장 빠름)
        ↓ 캐시 미스
      Regional Cache
        ↓ 캐시 미스
      Central Store (가장 느림)

Cloudflare가 2025년 10월에 공개한 블로그 데이터에 따르면 요청의 약 30%가 캐시 계층에서 바로 처리됩니다. 즉, 읽기 요청의 3분의 1은 중앙 스토리지까지 갈 필요가 없으므로 지연 시간이 자연스럽게 줄어듭니다.

30%
엣지 캐시 적중률

성능 데이터: 공식 수치부터 직접 측정한 결과까지

Cloudflare 공식 문서에는 다음과 같은 참고 데이터가 나와 있습니다.

  • Hot key(자주 접근하는 key): 500µs~10ms
  • Cold key(처음 접근하거나 접근 빈도가 낮은 key): 원본 스토리지에 접근해야 하므로 지연 시간이 더 높음

솔직히 처음에는 ‘500µs’라는 수치를 믿기 어려웠습니다. 그래서 직접 측정해 봤습니다.

// 간단한 지연 시간 테스트 코드
const start = Date.now();
await env.KV.get("test-key");
const latency = Date.now() - start;
console.log(`Latency: ${latency}ms`);

100회 측정한 결과 hot key의 평균 지연 시간은 실제로 약 5~8ms였습니다. cold key는 첫 접근 때 50ms를 넘었지만 두 번째 접근부터는 낮아졌습니다. 캐시가 적용된 것입니다.

Cloudflare는 2025년에 KV를 대대적으로 개편했으며, 공식 블로그에 따르면 작업 속도가 3배 향상되었습니다. 핵심 변경 사항은 두 가지입니다.

  1. Workers와 KV를 직접 연결해 기존 Front Line 계층을 우회
  2. 내부 데이터 전송 경로 단순화

이 변경은 KV에 의존하는 Turnstile, Waiting Room 같은 다른 Cloudflare 서비스에도 연쇄적인 성능 향상을 가져왔습니다.

일관성 모델: 최종 일관성이 치르는 대가

KV는 최종 일관성(eventually consistent) 스토리지입니다. 무슨 뜻일까요?

데이터를 하나 써도 모든 엣지 노드에 즉시 나타나지는 않습니다. 전파에는 시간이 필요합니다. 공식적으로 정확한 수치는 공개되지 않았지만, 실제 테스트에서는 지역 간 전파에 보통 몇 초에서 수십 초가 걸렸습니다.

이 특성은 어떤 상황에서는 문제가 되지만, 다른 상황에서는 전혀 중요하지 않습니다.

문제가 되는 상황:

  • 사용자가 방금 로그인해 session을 KV에 썼지만, 다음 요청이 다른 엣지 노드로 전달되어 session을 읽지 못함 — 로그인이 ‘실패’한 것처럼 보임
  • 실시간 공동 편집에서 사용자 A가 수정한 직후 사용자 B가 읽으면 최신 내용을 볼 수 없음

문제가 되지 않는 상황:

  • Feature flags 설정은 변경 후 몇 초 뒤에 적용되어도 전혀 문제없음
  • API cache는 서드파티 API 반환값을 몇 분 동안 캐시하므로 전파 지연이 중요하지 않음
  • Redirect mapping은 URL 규칙 갱신이 몇 초 늦어도 사용자가 거의 알아차리지 못함

즉각적인 일관성이 필요하다면 KV는 적합하지 않을 수 있습니다. 이때는 특정 노드에 상태를 배치해 일관성을 보장하는 Durable Objects가 더 나은 선택입니다.

Wrangler CLI 실전 설정

이론은 여기까지 하고 직접 구성해 보겠습니다.

KV 설정은 namespace(네임스페이스)를 만든 다음 wrangler.toml에서 Worker에 바인딩하는 두 단계로 나뉩니다.

Namespace 생성

Namespace는 KV의 ‘컨테이너’입니다. namespace 하나에 무수히 많은 key-value pair를 저장할 수 있지만, 계정 전체에서는 namespace를 최대 1000개까지만 만들 수 있습니다(이 제한은 2025년 초 200개에서 1000개로 늘어났습니다).

# production namespace 생성
wrangler kv namespace create MY_KV

# 다음과 비슷한 결과가 출력됩니다.
# Created namespace with id "abc123def456..."
# Add the following to your wrangler.toml:
# [[kv_namespaces]]
# binding = "MY_KV"
# id = "abc123def456..."

로컬 개발과 테스트에 사용할 preview namespace도 필요합니다.

# preview namespace 생성
wrangler kv namespace create MY_KV --preview

# 다음과 비슷한 결과가 출력됩니다.
# Created preview namespace with id "preview_abc123..."

wrangler.toml 설정 자세히 보기

앞에서 출력된 id를 wrangler.toml에 입력합니다.

name = "my-worker"
main = "src/index.ts"

[[kv_namespaces]]
binding = "MY_KV"
id = "abc123def456..."        # production namespace
preview_id = "preview_abc123..." # preview namespace(로컬 개발용)

binding 이름은 매우 중요합니다. Worker 코드에서 KV에 접근하는 방식이 이 이름으로 결정됩니다.

// binding = "MY_KV"이므로 코드에서는 env.MY_KV로 접근
const value = await env.MY_KV.get("some-key");

REST API vs Workers Binding API

KV 데이터에 접근하는 방법은 두 가지입니다.

Workers Binding API(권장):

  • Worker 안에서 env.MY_KV.get()을 직접 사용
  • 추가 네트워크 요청이 필요하지 않아 가장 빠름
  • 완전히 무료(Worker 실행 시간에만 포함)

REST API:

  • HTTP 요청으로 KV에 접근
  • 인증 token이 필요하며 외부 시스템 호출에 적합
  • Cloudflare REST API 전체 속도 제한의 적용을 받음

솔직히 대부분의 상황에서는 Binding API를 사용해야 합니다. REST API는 주로 다음과 같은 용도에 사용합니다.

  • 외부 시스템에서 KV 데이터를 읽거나 써야 할 때
  • CI/CD 과정에서 데이터를 대량으로 가져올 때
  • 일시적인 디버깅 및 운영 작업

자주 사용하는 Wrangler KV 명령

Wrangler는 KV 데이터를 편리하게 다룰 수 있는 여러 명령줄 도구를 제공합니다.

# 데이터 쓰기
wrangler kv key put --namespace-id=abc123 "my-key" "my-value"

# 데이터 읽기
wrangler kv key get --namespace-id=abc123 "my-key"

# 데이터 삭제
wrangler kv key delete --namespace-id=abc123 "my-key"

# 모든 key 나열(접두사 필터 지원)
wrangler kv key list --namespace-id=abc123 --prefix="session:"

이 명령들은 디버깅할 때 유용하지만, 프로덕션 환경에서는 Worker 코드로 다루는 편이 더 효율적입니다.

TypeScript 코드 실전

드디어 코드 부분입니다. 여기서는 그대로 실행할 수 있는 전체 예제를 제공합니다.

기본 CRUD 작업

가장 기본적인 생성, 읽기, 수정, 삭제부터 살펴보겠습니다.

// src/index.ts
interface Env {
  MY_KV: KVNamespace;
}

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const url = new URL(request.url);
    const path = url.pathname;

    // 데이터 쓰기
    if (path === "/put") {
      const key = url.searchParams.get("key") || "default";
      const value = url.searchParams.get("value") || "hello";
      
      await env.MY_KV.put(key, value);
      return new Response(`Saved: ${key} = ${value}`);
    }

    // 데이터 읽기
    if (path === "/get") {
      const key = url.searchParams.get("key") || "default";
      const value = await env.MY_KV.get(key);
      
      if (value === null) {
        return new Response("Key not found", { status: 404 });
      }
      return new Response(value);
    }

    // 데이터 삭제
    if (path === "/delete") {
      const key = url.searchParams.get("key") || "default";
      await env.MY_KV.delete(key);
      return new Response(`Deleted: ${key}`);
    }

    // key 나열(접두사 포함)
    if (path === "/list") {
      const prefix = url.searchParams.get("prefix") || "";
      const keys = await env.MY_KV.list({ prefix });
      
      const keyList = keys.keys.map(k => k.name).join("\n");
      return new Response(keyList || "No keys found");
    }

    return new Response("Try /put, /get, /delete, or /list");
  },
};

이 코드는 Wrangler로 바로 실행할 수 있습니다.

wrangler dev
# 쓰기 테스트
curl "http://localhost:8787/put?key=test&value=helloworld"
# 읽기 테스트
curl "http://localhost:8787/get?key=test"

Session Storage 전체 구현

KV의 가장 일반적인 활용 사례 중 하나입니다. 다음은 전체 session 관리 코드입니다.

// src/session.ts
interface SessionData {
  userId: string;
  email: string;
  createdAt: number;
  expiresAt: number;
}

interface Env {
  SESSION_KV: KVNamespace;
}

const SESSION_TTL = 3600; // 1시간 후 만료

class SessionManager {
  private kv: KVNamespace;

  constructor(kv: KVNamespace) {
    this.kv = kv;
  }

  // session 생성
  async create(userId: string, email: string): Promise<string> {
    const sessionId = crypto.randomUUID();
    const sessionData: SessionData = {
      userId,
      email,
      createdAt: Date.now(),
      expiresAt: Date.now() + SESSION_TTL * 1000,
    };

    // KV에 쓰고 TTL 설정(자동 만료)
    await this.kv.put(
      `session:${sessionId}`,
      JSON.stringify(sessionData),
      { expirationTtl: SESSION_TTL }
    );

    return sessionId;
  }

  // session 읽기
  async get(sessionId: string): Promise<SessionData | null> {
    const raw = await this.kv.get(`session:${sessionId}`);
    if (!raw) return null;

    try {
      return JSON.parse(raw) as SessionData;
    } catch {
      return null;
    }
  }

  // session 삭제(로그아웃)
  async delete(sessionId: string): Promise<void> {
    await this.kv.delete(`session:${sessionId}`);
  }

  // session 갱신(만료 시간 연장)
  async refresh(sessionId: string): Promise<boolean> {
    const session = await this.get(sessionId);
    if (!session) return false;

    session.expiresAt = Date.now() + SESSION_TTL * 1000;
    await this.kv.put(
      `session:${sessionId}`,
      JSON.stringify(session),
      { expirationTtl: SESSION_TTL }
    );

    return true;
  }
}

// Worker 진입점
export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const sessionManager = new SessionManager(env.SESSION_KV);
    const url = new URL(request.url);

    // 로그인(session 생성)
    if (url.pathname === "/login" && request.method === "POST") {
      const body = await request.json();
      const sessionId = await sessionManager.create(
        body.userId as string,
        body.email as string
      );
      
      return new Response(JSON.stringify({ sessionId }), {
        headers: { "Content-Type": "application/json" },
      });
    }

    // session 검증
    if (url.pathname === "/verify") {
      const sessionId = url.searchParams.get("sessionId");
      if (!sessionId) {
        return new Response("Missing sessionId", { status: 400 });
      }

      const session = await sessionManager.get(sessionId);
      if (!session) {
        return new Response("Session not found", { status: 401 });
      }

      return new Response(JSON.stringify(session), {
        headers: { "Content-Type": "application/json" },
      });
    }

    // 로그아웃
    if (url.pathname === "/logout") {
      const sessionId = url.searchParams.get("sessionId");
      if (sessionId) {
        await sessionManager.delete(sessionId);
      }
      return new Response("Logged out");
    }

    return new Response("Not found", { status: 404 });
  },
};

핵심 사항은 다음과 같습니다.

  1. TTL 자동 만료: expirationTtl 매개변수를 사용하면 KV가 만료된 데이터를 자동으로 삭제하므로 직접 정리할 필요가 없습니다.
  2. key 접두사: session:을 접두사로 사용하면 대량 조회가 쉽고 서로 다른 유형의 데이터를 구분할 수 있습니다.
  3. JSON 직렬화: KV에는 문자열만 저장되므로 복잡한 객체는 직접 JSON.stringify/parse로 변환해야 합니다.

API Response Cache 구현

또 다른 일반적인 용도는 서드파티 API의 반환값을 캐시해 호출 횟수와 지연 시간을 줄이는 것입니다.

// src/api-cache.ts
interface Env {
  CACHE_KV: KVNamespace;
}

const DEFAULT_CACHE_TTL = 300; // 5분 캐시

async function cachedFetch(
  kv: KVNamespace,
  cacheKey: string,
  url: string,
  ttl: number = DEFAULT_CACHE_TTL
): Promise<Response> {
  // 먼저 캐시에서 읽기 시도
  const cached = await kv.get(cacheKey, "text");
  
  if (cached) {
    console.log(`Cache hit: ${cacheKey}`);
    return new Response(cached, {
      headers: {
        "Content-Type": "application/json",
        "X-Cache": "HIT",
      },
    });
  }

  // 캐시 미스: 실제 API 호출
  console.log(`Cache miss: ${cacheKey}`);
  const response = await fetch(url);
  const body = await response.text();

  // 캐시에 쓰기(cacheTtl로 읽기 성능 최적화)
  await kv.put(cacheKey, body, {
    expirationTtl: ttl,
    // cacheTtl: 엣지 캐시를 더 오래 유지해 원본 접근을 줄임
  });

  return new Response(body, {
    headers: {
      "Content-Type": "application/json",
      "X-Cache": "MISS",
    },
  });
}

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const url = new URL(request.url);
    const apiUrl = url.searchParams.get("api");

    if (!apiUrl) {
      return new Response("Missing api parameter", { status: 400 });
    }

    // API URL을 캐시 key로 사용
    const cacheKey = `api:${apiUrl}`;
    
    return cachedFetch(env.CACHE_KV, cacheKey, apiUrl);
  },
};

cacheTtl 매개변수 최적화

KV 성능 최적화에서 가장 놓치기 쉬운 매개변수입니다.

cacheTtl은 엣지 캐시의 유지 시간을 제어합니다. 기본값은 60초입니다. 즉, 60초 안에 같은 key를 다시 읽으면 원본 스토리지에 접근하지 않고 엣지 캐시에서 바로 가져올 수 있습니다.

인기 데이터에는 cacheTtl을 더 크게 설정할 수 있습니다.

// 접근 빈도가 높은 설정 데이터에는 더 긴 엣지 캐시 지정
await env.MY_KV.get("config:feature-flags", {
  cacheTtl: 3600, // 1시간 엣지 캐시
});

이렇게 하면 KV의 central store 데이터가 변경되지 않는 한 엣지 노드에 1시간 동안 캐시됩니다. feature flags처럼 ‘변경 후 바로 적용되지 않아도 되는’ 상황에 매우 유용합니다.

KV vs D1 vs R2 — 스토리지 선택 가이드

솔직히 저도 처음에는 이 부분에서 고민했습니다. Cloudflare가 이렇게 많은 스토리지 옵션을 제공하는데 어떤 것을 선택해야 할까요?

다음 결정 트리를 활용해 보세요.

상황별 결정 트리

데이터의 유형은 무엇인가요?

├─ 파일(이미지, 동영상, PDF)을 저장해야 하나요?
│   └─ YES → R2

├─ SQL 쿼리(사용자 테이블, 주문, 다중 테이블 조인)가 필요한가요?
│   └─ YES → D1

├─ 단순한 key-value이며 읽기가 쓰기보다 많은가요?
│   ├─ 쓰기 빈도 &gt; 1 RPS/key인가요?
│   │   └─ YES → KV에 적합하지 않음, D1 또는 Durable Objects 고려
│   │
│   └─ NO → KV ✓

├─ 즉각적인 일관성이 필요한가요?
│   └─ YES → Durable Objects
│   └─ NO → KV도 적합할 수 있음

└─ 잘 모르겠나요?
    └─ 먼저 KV를 사용하고, 충분하다면 그대로 유지
500µs-10ms
KV Hot Key 지연 시간
50-200ms
D1 지연 시간
25MB
KV 최대 Value
Source: Cloudflare 공식 문서

상세 비교표

비교 항목KVD1R2
데이터 모델Key-ValueSQL(SQLite)Object Storage
쿼리 기능get/put/delete만 지원전체 SQL 쿼리쿼리 없음, 경로만 사용
쓰기 제한1 RPS per key하드 제한 없음하드 제한 없음
읽기 지연 시간500µs~10ms(hot)50~200ms(위치에 따라 다름)빠름(다운로드)
일관성최종 일관성강한 일관성(단일 지역)최종 일관성
최대 value25MBSQLite 행 제한파일당 5TB
무료 한도100k reads/day5GB 스토리지 + 25M rows read10GB 스토리지
대표 용도Session, Cache, Config사용자 데이터, 주문, 보고서파일, 이미지, 백업

구체적인 상황별 추천

사용자 인증 / Session
KV

이유: session 데이터는 단순한 key-value이고, 읽기 빈도는 높으며(요청마다 검증) 쓰기 빈도는 낮습니다(로그인/로그아웃할 때만 기록). OpenAuth 같은 인증 프레임워크도 기본적으로 KV를 사용합니다.

// session:userId → session data
await env.SESSION_KV.put(`session:${sessionId}`, JSON.stringify(session));

사용자 프로필 / 주문 관리
D1

이유: SQL 쿼리(‘특정 사용자의 모든 주문 찾기’, ‘지난달 매출 집계’)가 필요합니다. KV의 get/put 방식으로는 이런 쿼리를 처리할 수 없습니다.

-- D1에서는 복잡한 쿼리를 실행할 수 있음
SELECT * FROM orders WHERE user_id = ? AND created_at &gt; ?

이미지 / 파일 저장
R2

이유: 파일은 너무 크고(KV 한도는 25MB), key-value의 빠른 읽기 방식도 필요하지 않습니다. R2가 객체 스토리지에 더 적합합니다.

// R2에 파일 저장
await env.MY_BUCKET.put("images/profile.jpg", imageBuffer);

API Rate Limiting
KV(주의 필요)

이유: 카운터는 key-value이지만 쓰기 빈도가 1 RPS를 넘을 수 있습니다. 단순히 ‘오늘의 요청 횟수’를 확인하는 수준이라면 KV를 사용할 수 있지만, 정밀한 per-second rate limiting에는 Durable Objects나 Upstash Redis가 필요할 수 있습니다.

// 간단한 rate limiting(매일 초기화)
const count = parseInt(await env.KV.get(`rate:${userId}`) || "0");
if (count &gt; 100) {
  return new Response("Rate limit exceeded", { status: 429 });
}
await env.KV.put(`rate:${userId}`, String(count + 1));

서드파티 API 캐시
KV

이유: API 반환값 캐시는 읽기가 많고 쓰기는 적습니다(API 호출이 실패하거나 캐시가 만료될 때만 갱신). TTL이 5분인 캐시에는 즉각적인 일관성이 전혀 필요하지 않습니다.

여러 스토리지를 함께 사용하는 예

프로젝트 하나에서 여러 종류의 스토리지를 함께 사용하는 경우도 많습니다.

interface Env {
  SESSION_KV: KVNamespace;   // 사용자 session
  CACHE_KV: KVNamespace;     // API 캐시
  DATABASE_D1: D1Database;   // 사용자 데이터, 주문
  FILES_R2: R2Bucket;        // 사용자가 업로드한 파일
}

// 하나의 요청에서 모두 사용할 수도 있습니다.
// 1. SESSION_KV에서 session 읽기
// 2. CACHE_KV에서 서드파티 API 캐시 읽기
// 3. DATABASE_D1에서 사용자 주문 조회
// 4. FILES_R2에서 사용자 프로필 이미지 반환

이렇게 조합해 사용하는 것이 Cloudflare 제품군의 진정한 강점입니다.

실전 성능 최적화 팁

KV를 제대로 사용하면 매우 편리하지만, 잘못 사용하면 병목이 될 수도 있습니다. 직접 측정해 효과를 확인한 최적화 방법 몇 가지를 소개합니다.

1. cacheTtl 매개변수 조정

기본 cacheTtl은 60초입니다. 인기 데이터에는 이 값을 높이면 성능을 크게 개선할 수 있습니다.

// ❌ 기본 동작: 엣지 캐시 60초
await env.KV.get("config:feature-flags");

// ✅ 최적화: 설정 데이터는 더 오래 캐시
await env.KV.get("config:feature-flags", {
  cacheTtl: 3600, // 엣지 캐시 1시간
});

어떤 상황에서 cacheTtl을 늘리는 것이 좋을까요?

  • Feature flags: 설정을 바꾼 뒤 몇 분 후에 적용되어도 전혀 문제없음
  • 정적 설정: API endpoint, 서드파티 서비스 URL
  • 리디렉션 규칙: 변경 빈도가 낮은 URL 매핑 테이블

어떤 상황에는 적합하지 않을까요?

  • Session data: 사용자 로그인 상태를 즉시 반영해야 함
  • 실시간 카운터: 요청 제한 카운터처럼 정확성이 필요함

2. 직렬이 아닌 병렬 API 호출

쉽게 빠질 수 있는 함정입니다. Worker에서 여러 key를 읽어야 한다면 하나씩 순서대로 읽지 마세요.

// ❌ 직렬 읽기: 각 요청이 이전 요청의 완료를 기다림
const user = await env.KV.get(`user:${userId}`);
const settings = await env.KV.get(`settings:${userId}`);
const permissions = await env.KV.get(`permissions:${userId}`);
// 총 지연 시간 = 3 × 단일 요청 지연 시간

// ✅ 병렬 읽기: 요청 세 개를 동시에 시작
const [user, settings, permissions] = await Promise.all([
  env.KV.get(`user:${userId}`),
  env.KV.get(`settings:${userId}`),
  env.KV.get(`permissions:${userId}`),
]);
// 총 지연 시간 ≈ 단일 요청 지연 시간(가장 느린 요청 기준)

KV의 API 호출은 비동기이므로 Worker 실행을 차단하지 않습니다. Promise.all을 사용하면 여러 요청의 지연 시간을 가장 오래 걸리는 요청 하나 수준으로 ‘압축’할 수 있습니다.

측정 결과 key 세 개를 읽을 때 직렬 방식은 약 20ms, 병렬 방식은 8ms밖에 걸리지 않았습니다.

60%
지연 시간 감소(병렬 vs 직렬)
Source: 측정 데이터

3. Hot Key 설계 전략

KV 성능은 key가 ‘hot’한지, 즉 얼마나 자주 접근되는지에 크게 좌우됩니다.

cold key를 피하는 전략:

// ❌ key가 지나치게 분산되어 각 사용자가 자신의 key에만 접근
await env.KV.get(`session:${userId}`); // 이 사용자만 접근하므로 cold key

// ✅ hot key로 통합(공유 데이터에 적합)
await env.KV.get("config:global-flags"); // 모든 사용자가 공유하므로 hot key

그렇다고 모든 데이터를 key 하나에 넣어야 한다는 뜻은 아닙니다. 올바른 방법은 다음과 같습니다.

  • 사용자 개인 데이터: 사용자 ID별로 key 분리(session, profile)
  • 전역 공유 데이터: 단일 hot key 사용(설정, flags, 리디렉션 규칙)

4. Namespace 구성 모범 사례

계정에서는 namespace를 1000개까지 만들 수 있습니다. 이 한도를 잘 활용하면 서로 다른 유형의 데이터를 분리할 수 있습니다.

# wrangler.toml
[[kv_namespaces]]
binding = "SESSION_KV"
id = "xxx"  # 사용자 session

[[kv_namespaces]]
binding = "CACHE_KV"
id = "yyy"  # API 캐시

[[kv_namespaces]]
binding = "CONFIG_KV"
id = "zzz"  # 설정 데이터

장점은 다음과 같습니다.

  1. 정리 작업 분리: SESSION_KV에 영향을 주지 않고 CACHE_KV만 일괄 정리할 수 있음
  2. 서로 다른 TTL 전략: SESSION에는 짧은 TTL, CONFIG에는 긴 TTL 적용
  3. 모니터링 분리: Cloudflare Dashboard에서 namespace별 사용량을 따로 확인 가능

5. 일괄 작업 팁

KV는 접두사에 따라 모든 key를 조회할 수 있는 list() 작업을 지원합니다.

// 모든 session key 나열
const result = await env.SESSION_KV.list({ prefix: "session:" });

// result.keys는 배열
for (const key of result.keys) {
  console.log(key.name);
}

// key가 많으면 cursor로 페이지 처리
if (!result.list_complete) {
  const next = await env.SESSION_KV.list({
    prefix: "session:",
    cursor: result.cursor,
  });
}

만료된 session 일괄 정리:

// 모든 session 정리(주의해서 사용)
const keys = await env.SESSION_KV.list({ prefix: "session:" });
for (const key of keys.keys) {
  await env.SESSION_KV.delete(key.name);
}

주의: 일괄 삭제 작업은 쓰기 할당량을 많이 소모하므로 신중하게 사용해야 합니다.

요금과 제한 — 비용 관리 가이드

KV의 요금은 부담이 적지만, 특히 주의해야 할 제한이 몇 가지 있습니다. 이를 놓치면 요청이 바로 오류로 끝날 수 있습니다.

100,000
일일 무료 읽기
1,000
일일 무료 쓰기
1 GB
무료 스토리지 공간
$5
Paid Plan 월 요금
Source: Cloudflare 요금 페이지

Free Plan vs Paid Plan

항목Free PlanPaid Plan($5/month)
읽기 횟수100,000 / day무제한(사용량 기반 과금)
쓰기 횟수1,000 / day무제한(사용량 기반 과금)
삭제 횟수1,000 / day무제한(사용량 기반 과금)
목록 조회 횟수1,000 / day무제한(사용량 기반 과금)
스토리지 공간1GB무제한(사용량 기반 과금)
Namespace 수10001000

Free plan은 개인 프로젝트와 테스트에 충분합니다. 프로덕션 환경에는 Paid plan을 권장합니다. 월 $5로 다음 혜택을 얻을 수 있습니다.

  • 읽기 제한 없음(사용량 기반 과금)
  • 더 많은 쓰기 할당량
  • Dashboard 모니터링 및 알림

Write Rate Limit: 가장 중요한 제한

KV에서 가장 중요하고 가장 쉽게 실수하는 제한입니다.

unique key 하나에는 초당 최대 1회(1 RPS)만 쓸 수 있습니다

이 제한을 넘으면 요청이 바로 오류로 처리됩니다.

// ❌ 고빈도 쓰기는 실패함
for (let i = 0; i &lt; 10; i++) {
  await env.KV.put("counter", String(i)); // 두 번째부터 실패
}

// ✅ key를 분산하면 제한을 피할 수 있음
await env.KV.put(`counter:${Math.floor(Date.now() / 1000)}`, value);
// 매초 새 key를 사용하므로 제한이 발생하지 않음

이 제한이 존재하는 근본적인 이유는 무엇일까요? KV의 아키텍처는 ‘한 번 쓰고 전 세계로 복제하는’ 방식입니다. 동일한 key에 자주 쓰면 복제 비용이 폭증합니다. 따라서 Cloudflare는 이 제한으로 시스템을 보호합니다.

대응 전략:

  1. 시간으로 key 분산: counter:timestamp처럼 매초 새 key 사용
  2. UUID로 분산: 쓸 때마다 새 UUID를 key로 사용
  3. D1 또는 Durable Objects로 전환: 고빈도 쓰기가 필수인 경우

Value Size 제한

KV의 value는 최대 25MB까지 저장할 수 있습니다(2025년 초 10MB에서 상향).

// ❌ 25MB를 넘으면 오류 발생
const largeData = generateBigString(30_000_000); // 30 MB
await env.KV.put("large-key", largeData); // Error!

// ✅ 대용량 데이터는 R2 사용
await env.R2_BUCKET.put("large-key", largeData);

25MB면 session, 설정, 캐시에는 충분합니다. 하지만 대형 JSON이나 파일을 저장하려면 R2가 더 나은 선택입니다.

Namespace 관리 전략

계정 전체에서 namespace는 최대 1000개까지 만들 수 있습니다. 2025년 초 200개에서 늘어난 것을 보면 Cloudflare가 제한을 완화하고 있음을 알 수 있습니다.

관리 전략:

// 기능별 그룹화
SESSION_KV    // 사용자 session
CACHE_KV      // API 캐시
CONFIG_KV     // 설정 데이터
RATE_LIMIT_KV // 요청 제한 카운터

namespace를 모두 사용했다면 어떻게 해야 할까요? 같은 namespace 안에서 key 접두사로 데이터를 분리할 수 있습니다.

// 단일 namespace 내부에서 분리
await env.KV.put("session:user1", data);
await env.KV.put("cache:api1", data);
await env.KV.put("config:flags", data);

비용 추정 공식

프로젝트에서 Paid plan을 사용한다면 다음과 같이 계산할 수 있습니다.

월 비용 = $5(기본 요금) + 읽기 비용 + 쓰기 비용 + 스토리지 비용

읽기 비용 = 읽기 횟수 × $0.01 / 100,000
쓰기 비용 = 쓰기 횟수 × $1.00 / 1,000,000
스토리지 비용 = 스토리지 크기 × $0.50 / GB

예를 들어 하루 요청이 10만 건인 프로젝트라면 다음과 같습니다.

  • 읽기: 100,000 × 30 = 3M reads/month = $0.30
  • 쓰기: 1000 × 30 = 30k writes/month ≈ $0.03으로 가정
  • 스토리지: 10MB × $0.50/GB ≈ $0.005
  • 총비용: $5 + $0.33 ≈ $5.35/month

매우 저렴하지 않나요? Cloudflare의 전형적인 요금 정책입니다.

마무리

지금까지의 내용을 한 문장으로 정리하면 이렇습니다. KV는 Workers의 ‘휴대용 메모리’이며 session, cache, 설정처럼 읽기가 쓰기보다 많은 상황에 적합합니다.

빠르게 판단할 수 있는 체크리스트는 다음과 같습니다.

다음 조건이라면 KV 사용:

  • 데이터가 단순한 key-value임
  • 읽기 빈도가 쓰기 빈도보다 훨씬 높음
  • 즉각적인 일관성이 필요하지 않음
  • 각 key의 쓰기 빈도가 초당 1회를 넘지 않음

다음 조건이라면 D1로 전환:

  • SQL 쿼리가 필요함
  • 복잡한 테이블 조인이 있음
  • 쓰기 빈도가 1 RPS를 넘을 수 있음

다음 조건이라면 R2로 전환:

  • 파일, 이미지, 동영상을 저장함
  • value가 25MB를 넘음

다음 조건이라면 Durable Objects로 전환:

  • 즉각적인 일관성이 필요함
  • 공동 편집, 실시간 동기화가 필요함

다음 단계는 무엇일까요? Workers 프로젝트에 KV를 연결해 보세요. 먼저 session storage부터 시작하면 좋습니다. 위의 코드는 그대로 실행할 수 있습니다. 문제가 생기면 Cloudflare 공식 문서를 참고하거나 이 시리즈의 다른 글을 찾아보세요.

D1이나 R2에도 관심이 있다면 cloudflare-bindui 시리즈의 다른 글을 살펴보세요. 그곳에서 전체 스토리지 구성을 자세히 설명하겠습니다.

FAQ

Cloudflare Workers KV의 쓰기 제한은 무엇인가요?
unique key 하나에는 초당 최대 1회(1 RPS)만 쓸 수 있습니다. 이 제한을 넘으면 요청이 바로 오류로 처리됩니다. 대응 방법은 타임스탬프로 key를 분산하거나(예: counter:timestamp), D1 또는 Durable Objects로 전환하는 것입니다.
KV는 사용자 session 저장에 적합한가요?
매우 적합합니다. Session 데이터는 단순한 key-value이며, 읽기 빈도는 높고(요청마다 검증) 쓰기 빈도는 낮습니다(로그인/로그아웃할 때만 기록). TTL을 함께 사용하면 자동으로 만료되므로 직접 정리할 필요도 없습니다.
KV와 D1은 어떻게 다른가요? 무엇을 선택해야 하나요?
핵심 차이:

• KV: Key-Value 모델, hot key 지연 시간 500µs~10ms, 쓰기 제한 1 RPS/key
• D1: SQL 모델(SQLite), 복잡한 쿼리 지원, 쓰기 제한 없음

SQL 쿼리가 필요하면 D1, 단순한 key-value이고 읽기가 쓰기보다 많으면 KV를 선택하세요.
KV의 지연 시간이 어떻게 500µs~10ms까지 낮아질 수 있나요?
Edge Cache(엣지 노드) → Regional Cache → Central Store로 이어지는 3계층 캐시 아키텍처 덕분입니다. 요청의 약 30%가 원본 스토리지에 접근하지 않고 엣지 캐시에서 바로 처리됩니다. Cloudflare가 2025년에 최적화한 뒤 속도는 3배 향상되었습니다.
cacheTtl 매개변수는 어떤 역할을 하나요?
엣지 캐시의 유지 시간을 제어합니다. 기본값은 60초입니다. feature flags나 설정 같은 인기 데이터에는 3600초(1시간)로 설정해 엣지 노드가 더 오래 캐시하도록 하면 원본 스토리지 접근을 줄일 수 있습니다.
KV의 value에는 최대 어느 정도 크기까지 저장할 수 있나요?
25MB입니다(2025년 초 10MB에서 상향). 이 제한을 넘으면 오류가 발생하며, 이미지나 동영상 같은 대용량 데이터에는 R2 Object Storage를 사용해야 합니다.

7분 읽기 · 게시일: 2026년 4월 22일 · 수정일: 2026년 9월 4일

댓글

GitHub로 로그인하여 댓글을 남기세요

Easton BlogEaston Blog