Cloudflare D1 샤딩 실전 가이드: 10GB 한계와 단일 Writer 병목 돌파하기

멀티테넌트 SaaS나 이벤트 로깅처럼 쓰기가 몰리는 서비스를 Cloudflare D1 위에 올려본 적이 있다면 언젠가 이 벽에 부딪힌다. 데이터베이스 하나당 최대 10GB, 그리고 모든 쓰기는 단일 writer를 거쳐 순차 처리된다는 구조적 제약이다. Postgres나 MySQL이었다면 read replica를 늘리거나 파티셔닝 익스텐션으로 눙치면 되는 문제인데, D1은 애초에 “SQLite를 엣지에 여러 개 분산 배치한 것”이라 접근 방식 자체가 다르다.
이 글은 인덱싱 최적화만으로 충분한 경우와 실제로 샤딩이 필요한 경우를 구분하는 기준부터, 테넌트 ID 기반 라우팅을 Worker + KV로 구현하는 코드, 다운타임 없이 샤드를 리밸런싱하는 절차, 크로스샤드 집계를 우회하는 전략까지 실제로 운영에 옮길 수 있는 수준으로 다룬다.
핵심 요약
- D1은 데이터베이스당 최대 10GB(Paid) / 500MB(Free), 계정 전체로는 1TB(Paid) / 5GB(Free) 상한이 있고, 각 데이터베이스는 단일 writer가 쓰기를 순차 처리한다.
- 읽기 위주 병목은 샤딩이 아니라 Sessions API 기반 read replica로 먼저 풀어야 한다 — read replica는 쓰기 처리량은 전혀 늘려주지 않는다.
- 샤딩은 (1) DB 크기가 10GB에 근접하거나 (2) 동시 쓰기 경합으로 지연이 커질 때만 고려한다. 그 전에 인덱스, 배치 쓰기, 콜드 데이터 아카이빙부터 점검해야 한다.
- 샤드 매핑은 해시 기반보다 KV + 제어 DB 기반 디렉터리 매핑이 리밸런싱에 훨씬 유리하다.
- 크로스샤드 JOIN·트랜잭션은 지원되지 않으므로 집계는 팬아웃 쿼리나 별도 롤업 저장소로 우회해야 한다.
D1의 10GB 하드 리밋과 단일 writer 아키텍처가 만드는 실제 병목 지점
D1의 제약은 크게 두 층위로 나뉜다. 개별 데이터베이스 크기 한계와 계정 전체 저장 용량 한계다. Cloudflare 공식 문서 기준 수치는 다음과 같다.
| 항목 | Free 플랜 | Paid 플랜 |
|---|---|---|
| 데이터베이스 하나 최대 크기 | 500MB | 10GB |
| 계정 전체 저장 용량 | 5GB | 1TB |
| Worker 호출당 쿼리 수 | 50 | 1,000 |
| SQL 문 최대 길이 | 100,000 bytes (100KB) | 동일 |
| 쿼리당 바인딩 파라미터 | 100개 | 동일 |
| 쿼리 최대 실행 시간 | 30초 | 동일 |
| 행(row) 최대 크기 | 2,000,000 bytes (2MB) | 동일 |
| 테이블당 최대 컬럼 수 | 100개 | 동일 |
| 파일 임포트 최대 크기 | 5GB | 동일 |
여기서 중요한 건 **“계정 전체 용량은 넉넉한데 개별 DB는 금방 찬다”**는 비대칭이다. 예를 들어 Paid 플랜에서 계정 전체로는 1TB까지 쓸 수 있지만, 테넌트 100개를 하나의 DB에 몰아넣었다면 그 DB 자체는 여전히 10GB에서 멈춘다. 즉 계정 한도가 아니라 “DB 한 개당 10GB”라는 벽이 실제 병목이다. 이벤트 로그나 감사(audit) 테이블처럼 계속 쌓이기만 하는 테이블이 있는 서비스는 이 벽에 생각보다 빨리 도달한다.
두 번째는 단일 writer 구조다. D1은 SQLite 엔진 위에서 동작하며, 문서에도 명시되어 있듯 “각 데이터베이스는 단일 스레드로 쿼리를 순차 처리”한다. 1ms 짜리 짧은 쿼리 기준으로 초당 약 1,000 쿼리 정도가 이론적 상한으로 언급되는데, 실제 프로덕션 쿼리는 인덱스 스캔·조인·쓰기 트랜잭션이 섞여 있어 이보다 훨씬 낮은 지점에서 지연이 튀기 시작한다. 문제는 이게 동시성(concurrency) 문제이지 처리량(throughput) 문제만은 아니라는 점이다. 테넌트 A의 무거운 쓰기 트랜잭션이 실행되는 동안, 같은 DB를 쓰는 테넌트 B의 요청은 큐에서 대기한다. 멀티테넌트 구조에서 이건 한 테넌트의 트래픽 스파이크가 다른 모든 테넌트의 레이턴시를 끌어올리는 노이지 네이버(noisy neighbor) 문제로 직결된다.
실전에서 이 병목이 드러나는 전형적인 신호는 다음과 같다.
- 피크 시간대에
INSERT/UPDATEp95 레이턴시가 평소 대비 3~5배 이상 튀는데, 인덱스는 이미 다 걸려 있다. - 특정 테넌트(대형 고객)의 배치 작업이 실행되는 동안 다른 테넌트의 API 응답이 함께 느려진다.
wrangler d1 info로 확인한 DB 크기가 8~9GB를 넘어가며 계속 증가 추세다.- 대량 삽입 작업에서
too many SQL variables또는 쿼리 실행 시간 초과 에러가 발생한다.
샤딩이 필요한 시점 판단 기준: 읽기 위주 vs 쓰기 위주 워크로드 구분법
샤딩은 비용이 크다. 라우팅 레이어, 리밸런싱 절차, 크로스샤드 집계 우회 로직까지 다 만들어야 하는 작업이라 “일단 나눠두면 안전하다”는 식으로 접근하면 오버엔지니어링이 된다. 실무에서는 먼저 워크로드 성격부터 진단해야 한다.
읽기 위주 병목이라면 샤딩이 답이 아니다
읽기 트래픽이 몰려서 느려지는 경우, D1은 Sessions API 기반 read replica 기능을 제공한다. 요청을 지리적으로 가까운 읽기 복제본으로 분산시켜 지연을 줄이고, 여러 복제본이 동시에 읽기를 처리하므로 읽기 처리량도 늘어난다. 다만 공식 문서에 명시된 대로 **“모든 쓰기 쿼리는 여전히 primary 데이터베이스로만 전달”**되며, read replica는 쓰기 처리량에는 어떤 개선도 주지 않는다. 즉 읽기 위주 워크로드에서 샤딩부터 검토하는 건 순서가 틀렸다 — read replica, KV/Cache API 캐싱, 쿼리 최적화를 먼저 소진해야 한다.
쓰기 위주 병목이라면 샤딩 외에 대안이 별로 없다
반대로 문제가 동시 쓰기 경합이거나 단일 DB 용량이 10GB에 근접하는 경우엔 얘기가 다르다. read replica는 이 두 문제 어느 쪽에도 도움이 되지 않는다 — 여전히 하나의 primary가 모든 쓰기를 순차 처리하고, 여전히 하나의 파일이 10GB 벽에 부딪힌다. 이 경우엔 실질적으로 테넌트 또는 엔티티 기준 수동 샤딩이 유일한 수평 확장 경로다.
판단을 위한 체크리스트:
- 가장 큰 DB의 크기가 10GB의 70% 이상이고 증가 추세가 꺾이지 않는가?
- 피크 시간대 쓰기 p95 레이턴시가 평상시 대비 2배 이상 벌어지는가? (읽기 레이턴시가 아니라 쓰기 레이턴시인지 반드시 구분할 것)
- 인덱스·배치 쓰기·콜드 데이터 아카이빙을 이미 적용했는데도 위 두 증상이 남아있는가?
- 테넌트별 데이터 볼륨 편차가 커서(예: 상위 5%가 전체 용량의 60% 차지) 특정 테넌트만 격리해도 효과가 있는 구조인가?
네 개 중 세 개 이상에 해당하면 샤딩을 설계 단계로 올릴 시점이다. 반대로 대부분 “아니오”라면, 아래 마지막 섹션의 인덱싱 체크리스트만으로 몇 달은 더 버틸 수 있다.
테넌트 ID 기반 라우팅 설계: Worker + KV 샤드 매핑 테이블 구현
샤드 키는 해시 기반과 디렉터리(매핑 테이블) 기반 두 가지 방식이 있다. 해시 기반(shard = hash(tenant_id) % N)은 구현이 단순하지만, 샤드 개수를 바꾸거나 특정 테넌트 하나만 격리 이전하려면 대량의 재해싱이 필요하다. 반대로 디렉터리 기반 매핑은 “이 테넌트는 지금 shard-3에 있다”는 사실을 별도 테이블에 기록해두므로, 테넌트 하나만 콕 집어 다른 샤드로 옮기는 부분 리밸런싱이 가능하다. 멀티테넌트 환경에서는 거의 항상 디렉터리 기반을 권장한다.
아키텍처는 세 겹으로 구성한다.
- 제어 DB(control DB): 샤드 매핑의 단일 진실 공급원(source of truth). 별도의 작은 D1 데이터베이스에
shard_map테이블 하나만 둔다. - KV 캐시: 매 요청마다 제어 DB를 조회하면 D1 자체가 병목이 되므로, 매핑 결과를 Workers KV에 read-through 캐시로 둔다.
- 샤드 DB들: 실제 테넌트 데이터가 들어있는 D1 데이터베이스 N개.
# wrangler.toml
name = "multitenant-api"
main = "src/index.ts"
compatibility_date = "2025-01-01"
[[d1_databases]]
binding = "CONTROL_DB"
database_name = "control-db"
database_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
[[d1_databases]]
binding = "SHARD_0"
database_name = "tenant-shard-0"
database_id = "xxxxxxxx-0000-xxxx-xxxx-xxxxxxxxxxxx"
[[d1_databases]]
binding = "SHARD_1"
database_name = "tenant-shard-1"
database_id = "xxxxxxxx-1111-xxxx-xxxx-xxxxxxxxxxxx"
[[kv_namespaces]]
binding = "SHARD_MAP_KV"
id = "yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy"
제어 DB 스키마:
CREATE TABLE shard_map (
tenant_id TEXT PRIMARY KEY,
shard_id TEXT NOT NULL, -- 'SHARD_0', 'SHARD_1' ...
status TEXT NOT NULL DEFAULT 'ACTIVE', -- ACTIVE | MIGRATING | DONE
target_shard_id TEXT, -- 리밸런싱 중일 때만 채워짐
updated_at INTEGER NOT NULL
);
CREATE INDEX idx_shard_map_status ON shard_map(status);
라우팅 로직은 다음과 같이 KV를 먼저 확인하고, 캐시 미스일 때만 제어 DB를 조회한 뒤 write-through로 캐시한다.
// src/shard-router.ts
type Env = {
CONTROL_DB: D1Database;
SHARD_MAP_KV: KVNamespace;
SHARD_0: D1Database;
SHARD_1: D1Database;
[key: string]: any;
};
interface ShardEntry {
shardId: string;
status: "ACTIVE" | "MIGRATING" | "DONE";
targetShardId?: string;
}
export async function resolveShard(
tenantId: string,
env: Env
): Promise<D1Database> {
const cacheKey = `shard:${tenantId}`;
const cached = await env.SHARD_MAP_KV.get<ShardEntry>(cacheKey, "json");
let entry: ShardEntry | null = cached;
if (!entry) {
const row = await env.CONTROL_DB
.prepare(
"SELECT shard_id, status, target_shard_id FROM shard_map WHERE tenant_id = ?"
)
.bind(tenantId)
.first<{ shard_id: string; status: string; target_shard_id: string | null }>();
if (!row) {
throw new Error(`Unknown tenant: ${tenantId}`);
}
entry = {
shardId: row.shard_id,
status: row.status as ShardEntry["status"],
targetShardId: row.target_shard_id ?? undefined,
};
// 읽기 전용 캐시. 리밸런싱 중에는 TTL을 짧게 둔다.
const ttl = entry.status === "MIGRATING" ? 30 : 300;
await env.SHARD_MAP_KV.put(cacheKey, JSON.stringify(entry), {
expirationTtl: ttl,
});
}
// MIGRATING 상태면 라이팅은 아직 원본 샤드로 보낸다 (다음 섹션 참고).
const activeShardId = entry.status === "DONE" && entry.targetShardId
? entry.targetShardId
: entry.shardId;
const db = env[activeShardId] as D1Database | undefined;
if (!db) throw new Error(`Shard binding not found: ${activeShardId}`);
return db;
}
새 테넌트를 온보딩할 때는 현재 각 샤드의 크기와 부하를 기준으로 가장 여유 있는 샤드를 선택하는 로직을 넣는다. 단순히 라운드로빈으로 배정하면 특정 샤드에 대형 테넌트가 몰릴 수 있으므로, 온보딩 시점에 예상 데이터 볼륨(플랜 등급 등)을 참고해 배정하는 편이 리밸런싱 빈도를 줄인다.
다운타임 없이 샤드 간 데이터를 이동·리밸런싱하는 절차
D1은 데이터베이스 간 네이티브 복제나 온라인 마이그레이션 API를 제공하지 않는다. 따라서 리밸런싱은 애플리케이션 레이어에서 직접 상태 머신을 돌려야 한다. shard_map.status 컬럼(ACTIVE → MIGRATING → DONE)을 이용한 절차는 다음과 같다.
- 마이그레이션 대상 선정: 크기·부하 모니터링 결과 특정 샤드가 임계치(예: 8GB, 또는 특정 테넌트가 해당 샤드 쓰기의 40% 이상 차지)를 넘으면, 이전할 테넌트를 고른다. 여러 테넌트 중 하나만 골라도 되는 게 디렉터리 매핑 방식의 장점이다.
- 상태를 MIGRATING으로 전환:
shard_map에서 해당 테넌트의status를MIGRATING,target_shard_id를 목적지 샤드로 갱신한다. 이 시점부터 애플리케이션은 쓰기는 여전히 원본 샤드로, 동시에 Cloudflare Queues로 같은 쓰기 이벤트를 비동기 발행한다(듀얼 라이트). - 벌크 복사: 원본 샤드에서 해당 테넌트 소유 행을
SELECT로 페이지네이션하며 읽고, 목적지 샤드에db.batch()로 밀어넣는다. Worker 호출당 쿼리 수 제한(Free 50 / Paid 1,000)과 바인딩 파라미터 100개 제한을 고려해 배치 크기를 잡아야 한다. 데이터가 많고 지연에 덜 민감하다면wrangler d1 export --output=tenant.sql로 덤프한 뒤 필터링해서wrangler d1 execute로 목적지에 임포트하는 오프라인 방식이 더 간단하다(단, 파일 임포트는 5GB 상한). - 큐 재생(replay): 벌크 복사가 끝난 시점 이후 큐에 쌓인 이벤트(마이그레이션 도중 발생한 쓰기)를 목적지 샤드에 순서대로 재생해 따라잡는다.
- 정합성 검증: 원본과 목적지의 행 수, 그리고 핵심 테이블의 체크섬(예:
SELECT COUNT(*), SUM(amount) FROM orders WHERE tenant_id = ?)을 비교한다. 불일치하면 3~4단계를 재시도한다. - 컷오버: 검증 통과 후
shard_map.status를DONE으로,shard_id를 목적지로 갱신하는 트랜잭션 하나로 전환한다. 이 순간부터 신규 쓰기는 목적지 샤드로만 간다. - 캐시 만료 대기: KV에 캐시된 라우팅 엔트리가 만료될 때까지(위 코드에서 MIGRATING 상태는 TTL 30초로 짧게 잡아둔 이유) 원본 샤드를 읽기 전용 폴백으로 살려둔다. 전역 KV 전파 지연을 감안해 최소 1~2분의 유예 시간을 둔다.
- 정리: 유예 시간이 지나면 원본 샤드에서 해당 테넌트 데이터를 삭제해 용량을 회수한다.
이 절차의 핵심은 **“완전히 멈추고 옮기기”가 아니라 “듀얼 라이트 + 캐시 TTL을 이용한 점진적 컷오버”**라는 점이다. 다운타임 없이 리밸런싱하려면 결국 일시적으로 쓰기를 두 곳에 중복 기록하는 비용을 감수해야 한다.
크로스샤드 쿼리의 한계와 집계(aggregation) 우회 전략
샤딩 이후 가장 자주 부딪히는 문제는 “전체 테넌트 합계” 같은 크로스샤드 집계다. D1은 데이터베이스 바인딩이 물리적으로 분리되어 있어 샤드 간 JOIN도, 샤드 간 트랜잭션도 지원하지 않는다. 이건 우회할 방법이 없는 하드 제약이므로 설계 단계에서부터 전제로 깔아야 한다.
팬아웃(fan-out) 쿼리
실시간성이 필요하고 샤드 개수가 적다면(수십 개 이하) 가장 단순한 방법은 모든 샤드에 병렬로 쿼리를 날리고 Worker에서 합산하는 것이다.
async function totalOrdersAcrossShards(env: Env): Promise<number> {
const shardBindings = ["SHARD_0", "SHARD_1", "SHARD_2"] as const;
const results = await Promise.all(
shardBindings.map((key) =>
(env[key] as D1Database)
.prepare("SELECT COUNT(*) AS cnt FROM orders")
.first<{ cnt: number }>()
)
);
return results.reduce((sum, r) => sum + (r?.cnt ?? 0), 0);
}
이 방식은 샤드 수가 늘어날수록 Worker 호출당 쿼리 수 제한과 요청 전체 지연 시간(가장 느린 샤드가 전체 응답 시간을 결정)에 영향을 받는다. 샤드가 수백 개 규모로 커지면 팬아웃은 더 이상 실시간 경로에 적합하지 않다.
롤업(rollup) 테이블 + 비동기 ETL
대시보드·리포팅처럼 초 단위 실시간성이 필요 없는 집계는 요청 시점에 팬아웃하지 않고, 쓰기 시점에 미리 집계해두는 편이 훨씬 안정적이다. 각 샤드의 쓰기 트랜잭션이 끝난 뒤 Cloudflare Queues로 “집계 갱신 필요” 이벤트를 발행하고, 별도의 컨슈머 Worker가 그 이벤트를 소비해 **전용 analytics D1 데이터베이스(또는 시계열 성격이 강하면 Analytics Engine)**의 롤업 행을 갱신하는 구조다. 이렇게 하면 관리자 대시보드는 샤드를 전혀 건드리지 않고 롤업 DB 하나만 조회하면 된다.
샤드를 넘나드는 트랜잭션은 사가(saga) 패턴으로
테넌트 간 자원 이전처럼 두 샤드에 걸친 원자적 트랜잭션이 필요한 경우(흔치는 않지만 발생한다), D1은 분산 트랜잭션을 지원하지 않으므로 보상 트랜잭션(compensating transaction) 기반 사가 패턴으로 풀어야 한다. 즉 “샤드 A에서 차감 → 성공하면 샤드 B에서 가산 → 실패하면 A에서 되돌리는” 멱등(idempotent) 단계로 쪼개고, 각 단계에 재시도 가능한 고유 작업 ID를 부여해 중복 실행을 막는다.
샤딩 전에 먼저 시도해야 할 인덱싱·쿼리 최적화 체크리스트
샤딩 아키텍처를 다 설계해놓고 보면 “사실 이 정도는 인덱스만 고쳐도 됐다”는 경우가 실무에서 꽤 많다. 아래 체크리스트를 먼저 소진하는 걸 권한다.
EXPLAIN QUERY PLAN로 실제 인덱스 사용 여부부터 확인한다.SCAN TABLE이 보이면 풀스캔이 일어나고 있다는 뜻이다.WHERE와ORDER BY에 함께 걸리는 컬럼 조합으로 커버링 인덱스를 만든다.SELECT *대신 필요한 컬럼만 조회한다. 행 최대 크기가 2MB까지 허용되긴 하지만, 불필요하게 큰 컬럼(JSON blob 등)을 매번 읽어오면 그만큼 쿼리 시간과 네트워크 페이로드가 늘어난다.- 다건 쓰기는 개별
INSERT대신db.batch()로 묶는다. 네트워크 왕복 횟수를 줄이는 것만으로도 단일 writer 구조에서 체감 처리량이 크게 개선된다. 단, 바인딩 파라미터 100개·SQL 문 100KB 제한을 넘지 않도록 배치 크기를 나눠야 한다. - 고빈도 단일 행 카운터/레이트리밋은 D1에서 빼낸다. 초당 여러 번 갱신되는 카운터를 D1에 직접
UPDATE하면 그 자체가 단일 writer 병목의 원인이 된다. Durable Objects나 KV로 옮기는 편이 D1의 트랜잭션 큐를 비워준다. - 콜드 데이터를 아카이빙해 DB 크기를 관리한다. 오래된 로그·완료된 주문처럼 조회 빈도가 낮은 데이터는 주기적으로 R2 등 별도 스토리지로 내보내고 D1에서는 삭제해 10GB 한계로부터 여유를 만든다.
- 읽기 병목이라면 샤딩보다 read replica(Sessions API)와 KV/Cache API 캐싱을 먼저 적용한다. 앞서 설명했듯 read replica는 읽기 처리량만 늘려주지만, 애초에 읽기가 문제라면 이걸로 충분한 경우가 많다.
- N+1 쿼리 패턴을 점검한다. 반복문 안에서 개별 쿼리를 날리고 있다면 Worker 호출당 쿼리 수 제한(Free 50 / Paid 1,000)에 먼저 걸릴 수 있다.
JOIN또는IN (...)절로 합치는 게 우선이다.
이 체크리스트를 다 적용했는데도 앞서 언급한 판단 기준(DB 크기 10GB 근접, 쓰기 p95 레이턴시 급등)이 여전히 남아있다면, 그때 비로소 이 글에서 다룬 샤딩 아키텍처를 실제로 옮길 시점이다.