Firestore Deep Dive - Part 2: 확장 한계와 워밍업
Wed 19 August 2026이 글에서 다루는 사내 사례와 데이터는 기밀 유지를 위해 일부 이름과 용어를 익명화했습니다. 컬렉션은
A~G, 프로젝트는P로 표기하며, 날짜는 상대 표기로 바꿨습니다. 수치는 전부 실측 그대로입니다.
1부: 역사, 구조, 그리고 Spanner에서 Firestore가 Spanner 위에 서 있고, 데이터가 문서 ID 사전순으로 정렬돼 split 단위로 서버에 나뉘며, split은 부하를 보고 생긴다는 것까지 확인했습니다.
이 글은 그 구조에 실제로 부하를 걸어 본 기록입니다.
미리 결론부터 적습니다.
읽기 확장(split) 구조다. 부하를 멈춰도 남는다. 약 24시간
쓰기 허용량 정책이다. 부하를 멈추면 되감긴다. 40분 정지에 5배 축소
이 프로젝트는 8일 동안 "split이 형성되는가" 만 물었습니다. 그 답은 참이었습니다. 그런데 워밍업의 절반은 그 질문 바깥에 있었습니다.
1. 한계 — 용량을 미리 잡아 둘 수 없다
Firestore의 가장 큰 장점이자 이 글의 문제 전부입니다.
Firestore에는 인스턴스 크기라는 개념이 없습니다. RDS나 Spanner라면 런칭 전에 노드를 늘려 두면 됩니다. Firestore에는 늘릴 대상이 없습니다. 1부 §2-1에서 본 대로 노드 수도, split 수도, CPU 사용률도 값 자체가 존재하지 않습니다.
대신 부하를 보고 따라옵니다. 평소에는 이게 압도적으로 편합니다. 문제는 딱 한 순간입니다.
평상시 운영 부하가 완만히 변한다 → 확장이 여유 있게 따라온다 ✅
런칭 순간 부하가 0에서 최대로 → 확장이 도착보다 늦는다 ❌
1-1. 늦으면 무슨 일이 일어나는가
429 RESOURCE_EXHAUSTED가 돌아옵니다. 📐 우리가 실제로 받은 메시지는 이랬습니다.
429 This database has exceeded their maximum
request_rate/bandwidth/document_rate for writes,
please retry with exponential backoff.
세 가지를 눈여겨볼 만합니다.
for writes— 읽기가 아니라 쓰기라고 명시합니다. 이 구분이 이 글 전체의 결론으로 이어집니다.request_rate / bandwidth / document_rate— 막히는 축이 하나가 아닙니다. QPS만 보고 있으면 대역폭에 걸린 걸 놓칩니다(후반부에서 실제 사례가 나옵니다).This database has exceeded— 데이터베이스 단위라고 말합니다. 🔴 그런데 실제 동작은 컬렉션 단위였습니다. 이것도 후반부에서 다룹니다.
1-2. 500/50/5 — 공식적으로 허용된 상승 속도
📘 Firestore 문서는 트래픽을 올리는 속도에 규칙을 둡니다.
"The best practice is to distribute operations across the key range, while ramping up traffic on a collection in a database with 500 operations per second. After this gradual ramp up, increase the traffic by up to 50% every five minutes."
짧지만 조건이 세 개나 들어 있습니다.
| 문구 | 뜻 |
|---|---|
operations |
쓰기가 아니라 읽기+쓰기 전체입니다 |
on a collection |
데이터베이스가 아니라 컬렉션 단위입니다 |
best practice |
서버가 강제하는 값이 아니라 클라이언트가 지켜야 하는 규칙입니다 |
마지막 항목이 중요합니다. 500/50/5는 Firestore가 걸어 놓은 스로틀이 아니라, split이 형성될 시간을 주기 위해 클라이언트가 자발적으로 지키는 상승 속도입니다. 어기면 막히는 게 아니라, 어기면 확장이 못 따라와서 429를 보게 되는 구조입니다.
🔴 여기서 계산을 해 보면 문제가 드러납니다. 500에서 시작해 5분마다 1.5배씩 올리면 100,000 ops/s에 도달하는 데 약 65분이 걸립니다. 런칭 트래픽은 65분을 기다려 주지 않습니다.
2. 핫스팟 — 처리량은 split 수가 정한다
1부 §10에서 본 구조를 다시 꺼냅니다.
키 공간 (문서 ID 사전순)
├─────────────┬─────────────┬─────────────┤
split A split B split C
서버 1 서버 2 서버 3
split 하나는 서버 하나가 담당합니다. 그래서 트래픽이 한 split에 몰리면 그 서버 한 대의 한계가 곧 전체의 한계가 됩니다. 이것이 핫스팟이고, Firestore에서 "느리다"의 대부분은 여기서 옵니다.
역으로 처리량은 split 수에 비례합니다. 그리고 split 수는 지금까지 받아 본 부하에 비례합니다. 부하를 받은 적 없는 콜드 DB는 split이 적습니다.
2-1. split이 고치지 못하는 것
여기서 중요한 구분이 하나 필요합니다. 핫스팟이라고 다 같은 게 아닙니다.
| 현상 | split이 고치는가 | 왜 |
|---|---|---|
| 넓은 키 범위에 고른 부하 | ✅ | 쪼개면 서버가 늘어난다 |
| 단조증가 키 | ❌ | 꼬리를 쪼개도 새 꼬리가 생긴다 (1부 §10-1) |
| 단일 문서 집중 | ❌ | 문서 하나보다 작게 쪼갤 수 없다 |
문서 락 경합 (ABORTED) |
❌ | 같은 행을 동시에 쓰는 문제 |
| 인덱스 팬아웃 | ❌ | participant 수를 늘리는 문제 (1부 §9-3) |
쓰기 rate 제한 (429) |
❌ | 별도의 규칙이다 (§1-2) |
🔴 이 표가 실험 설계를 지배합니다. 부하를 걸었는데 지연이 안 내려간다면, 그게 "split이 아직 안 생겼다" 인지 "split으로는 원래 안 풀리는 문제" 인지 구분해야 합니다. 구분하지 못하면 영원히 기다리게 됩니다.
그래서 실험에서는 에러의 종류가 다음 행동을 정합니다. 429면 더 밀어도 되고(정확히는, 밀어야 열립니다), ABORTED면 밀어 봐야 소용없습니다.
3. 공식 문서가 말하는 확장, 그리고 말하지 않는 것
부하를 걸기 전에 문서에 있는 숫자를 모았습니다.
| 📘 공식 | |
|---|---|
| split 형성 시간 | 높은 부하 수 분 후 |
| split 유지 시간 | "retained for around ~24 hours even if the traffic goes away" |
| 확장 속도 기대치 (Spanner) | "write throughput should double every few minutes" |
| 못 따라갈 때 | "a split can potentially use up its available compute and memory resources" |
| pushback 지속 | 최대 1분, 심하면 수 분 |
노드당 처리량도 공표돼 있습니다. Spanner 기준, SSD·1KB 행 가정입니다.
| Regional | Multi-region | |
|---|---|---|
| reads/s per node | 22,500 | 15,000 |
| writes/s per node | 3,500 | 2,700 |
| 권장 최대 CPU | 65% | 45% |
우리 DB는 멀티리전(nam5)이라 오른쪽 열입니다. 여기서 쓰기 1건 ≈ 읽기 5.5건(15,000 ÷ 2,700)이 나오고, 이 글에서 읽기와 쓰기가 섞인 부하를 하나의 단위로 비교할 때 쓰는 read-equiv 환산이 이것입니다.
3-1. 🔴 그런데 정작 필요한 숫자가 없다
문제는 이 표에 split 하나가 얼마나 받는지가 없다는 것입니다.
| 단위 | 위 수치는 전부 노드당입니다. 노드 하나는 split을 여러 개 담습니다 |
| 가정 | 1KB 행 기준입니다. 📐 우리 컬렉션 A의 문서는 9.4KB였습니다 |
| 적용 대상 | 프로비저닝해서 소유하는 인스턴스의 수치입니다. Firestore에는 노드라는 값이 없습니다(§1) |
그러니까 이 숫자들은 상한을 짐작하는 데는 쓸 수 있지만, "얼마를 걸면 split이 생기는가"라는 문턱으로는 쓸 수 없습니다.
그리고 1부 §2-1에서 본 대로 split 수는 조회할 방법이 없습니다. Cloud Monitoring에도 없습니다.
정리하면 이런 상황입니다. ① 확장은 부하를 보고 일어나는데 ② 얼마를 걸어야 하는지 공표된 문턱이 없고 ③ 일어났는지 확인할 계기판도 없다.
그래서 실험은 "행동으로 추론하기"가 될 수밖에 없었습니다.
4. 가설 — split은 바깥에서 관측될 수 있는가
시스템을 만들기 전에 전제부터 반증하려 했습니다. 워밍업 시스템을 다 만들어 놓고 "그런데 split이 생겼는지 알 방법이 없네"가 되면 전부 헛수고이기 때문입니다. 가장 싸게 계획 전체를 무효화할 수 있는 실험을 먼저 했습니다.
세운 가설은 이렇습니다.
가설 — 콜드 DB의 좁은 key range 하나에 고정 rate로 부하를 유지하면, 그 range의 읽기 p95가 상승한 뒤 하강해 안정화한다.
논리는 이렇습니다.
콜드 DB에 부하를 건다
→ split 하나가 자기 자원을 다 쓴다 → 지연 상승 📈
→ Spanner가 split을 추가한다 (수 분)
→ 부하가 여러 서버로 나뉜다 → 지연 하강 📉
→ 새 split 구성에서 안정화 → 평탄 ➡️
즉 p95 시계열에 혹(hump)이 생겼다 꺼지면 그게 split이 형성된 흔적이라는 것입니다.
4-1. 🔴 이 가설의 결정적 조건
여기에 함정이 하나 있습니다. "지연이 올랐다 내려왔다"만으로는 아무것도 증명하지 못합니다. 부하가 끝나서 내려온 것일 수도 있으니까요.
그래서 판정 조건을 이렇게 좁혔습니다.
부하를 줄이지 않았는데 지연이 회복되어야 한다. 더 강하게는, 부하가 늘어나는 중에 지연이 회복되면 대안 설명이 사라진다.
부하가 유지·증가하는 동안 지연이 baseline으로 돌아왔다면, 그 사이에 시스템이 처리 능력을 확보했다는 뜻이고, Firestore에서 실행 중에 처리 능력이 늘어나는 메커니즘은 load-based splitting밖에 없습니다.
5. 계측 설계 — 부하 생성기에서 지연을 재면 안 된다
가설을 검증하려면 지연을 재야 하는데, 어디서 재느냐가 결과를 바꿉니다.
📐 실측 한 장면입니다. 같은 순간, 같은 DB를 두 곳에서 쟀습니다.
| 재는 주체 | p95 |
|---|---|
| 부하 워커 | 235 ms |
| 별도 프로브 | 44.6 ms |
5배 차이입니다. 워커가 본 235ms는 Firestore의 지연이 아니라 자기 CPU가 포화해서 요청을 못 내보내고 있는 시간이 섞인 값입니다. 부하 생성기에서 지연을 재면 서버 포화와 클라이언트 포화를 구분할 수 없습니다.
그래서 계측을 부하에서 물리적으로 떼어냈습니다.
프로브의 설계 원칙은 다섯 가지였고, 전부 "계측기가 실험을 바꾸지 않게" 하려는 것입니다.
| 원칙 | 이유 |
|---|---|
| 부하와 다른 노드에 둔다 | 워커 CPU 포화가 지연에 섞이면 안 된다 |
| 상수 rate | 부하가 늘어도 프로브는 안 늘린다. 기준선이 흔들리면 비교가 불가능하다 |
| rate가 지연과 독립 | 지연이 늘 때 요청도 줄면 되먹임이 생긴다 |
| 키 풀에서 매번 랜덤 추출 | 순회하면 캐시 적중률만 오른다 |
| 자동 종료 없음 | 판정 규칙을 계측기에 넣으면 계측기가 실험을 바꾼다 |
5-1. 대조군이 핵심이다
그리고 프로브는 두 개의 키 범위를 동시에 잽니다.
- hot range — 부하를 거는 범위
- control range — 부하를 걸지 않는 범위
둘 다 같이 느려지면 DB 전체의 사정이고, hot만 느려지면 그 키 범위의 사정입니다. 이 구분이 없으면 "split이 생겼다" 와 "그냥 DB가 느려졌다 나아졌다" 를 가를 수 없습니다.
📐 부수적으로 얻은 검증: 부하를 걸기 전 서버측 read가 정확히 48/s 로 찍혔는데, 프로브 설정이
16 타깃 × 3 req/s = 48이었습니다. 소수점까지 일치했습니다. 프로브가 의도대로 동작한다는 독립 확인이 됐습니다.
6. 실험 A — 전제 검증
6-1. 먼저 네 번 실패했다
첫 성공까지 다섯 번 시도했고, 앞선 네 번은 전부 부하가 모자라서 아무 신호도 못 봤습니다. 실패의 내용이 오히려 정보였습니다.
| 시도 | 서버측 read-equiv | 결과 |
|---|---|---|
| 초기 시도들 | ~26,000 | ❌ 신호 없음 |
| 성공한 시도 | ~32,000 | ✅ 명확한 혹 + 회복 |
26,000에서는 아무 일도 없었고 32,000에서 일어났습니다. 문턱은 그 사이 어딘가에 있습니다. §3-1에서 본 대로 공표된 문턱이 없으니, 이렇게 좁히는 수밖에 없었습니다.
그리고 부수적으로 알게 된 것 — 서울에서는 이 실험을 돌릴 수 없었습니다. 📐 RTT가 244ms라 서버측 20~80ms 신호가 묻히고, in-flight ÷ RTT가 상한이라 처리량도 ~1,050 ops/s에 갇힙니다. 같은 코드가 us-central1에서는 프로세스당 1,230 ops/s를 냈습니다. RTT가 만든 천장이지 클라이언트 천장이 아니었습니다. 그래서 생성기와 프로브를 전부 DB와 같은 리전에 뒀습니다.
6-2. 왜 write-only가 아니라 3:1인가
워크로드를 읽기 75% / 쓰기 25%로 잡았습니다. 이유가 §2-1의 표에 있습니다.
write-only로 20,000 writes/s ÷ 문서 12,000건 = 1.6 writes/s/doc
→ 같은 문서를 초당 1.6번 쓴다
→ 문서 락 경합(ABORTED)이 오른다
→ 그런데 락 경합은 split으로 해결되지 않는 문제다
→ "split이 안 생겼다"와 구분이 안 된다 → 실험이 오염된다
3:1이면 문서당 0.4 writes/s로 떨어져 경합이 사라집니다. 그리고 이 비율은 라이브 타이틀에서 실측한 3.3:1과도 맞았습니다.
6-3. 결과 — 혹이 생겼고, 부하가 느는 중에 회복했다
📐 신규 DB에 좁은 range 두 개(hot / control, 각 12,000건)를 만들고 부하를 걸었습니다.
프로브 p95 시계열 (60초 창)
| t+(초) | hot p50 | hot p95 | control p95 | ratio |
|---|---|---|---|---|
| 540 | 9.0 | 15.8 ← baseline | 17.4 | 0.91 |
| 600 | 10.1 | 61.1 ← 부하 시작 | 49.2 | 1.24 |
| 720 | 16.3 | 299.0 | 303.5 | 0.99 |
| 840 | 21.0 | 319.7 ← 정점 | 295.2 | 1.08 |
| 900 | 12.0 | 21.4 ← 회복 | 22.2 | 0.97 |
| 1,200 | 9.6 | 14.0 | 15.1 | 0.93 |
| 2,160 | 9.5 | 18.1 ← 21분째 유지 | 18.5 | 0.98 |
baseline 15.8ms → 정점 319.7ms(20배) → 1분 만에 21.4ms로 회복 → 21분간 유지.
그런데 결정적인 것은 이 표가 아니라 같은 시각의 서버측 부하입니다.
| 시각 | read/s | write/s | read-equiv |
|---|---|---|---|
| 정점 무렵 | 11,342 | 3,749 | ~32,000 |
| 정착 이후 | 15,822 | 5,278 | ~44,800 |
정점 시점 부하 read 11,342/s + write 3,749/s
정착 시점 부하 read 15,822/s + write 5,278/s ← 39% 증가
↑ 그런데 p95는 319.7 → 21.4ms (93% 감소)
🔴 부하를 39% 늘렸는데 지연이 93% 줄었습니다. §4-1에서 정한 조건이 충족됐습니다. "부하가 끝나서 내려간 것"이라는 대안 설명이 여기서는 성립하지 않습니다.
6-4. ⚠️ 그런데 증명되지 않은 것이 있다
정직하게 적어야 하는 부분입니다. 위 표의 ratio 열을 보면 hot과 control이 전 구간 같이 움직였습니다(0.84~1.24).
| 증명된 것 | 증명되지 않은 것 | |
|---|---|---|
| 이 DB에서 split이 형성됐다 | 그것이 hot range에 국소적이었다 |
control range에도 부하를 걸지 않았는데 같이 느려졌다가 같이 회복했습니다. 즉 관측된 것은 DB 전체 수준의 현상이고, "내가 부하를 건 그 키 범위만 쪼개졌다" 는 확인하지 못했습니다.
🧩 콜드 DB는 애초에 split이 극소수라 hot과 control이 같은 split 안에 있었을 가능성이 높습니다. 그렇다면 동조는 당연한 결과입니다. 하지만 이건 추론이고, 실험으로 가르지는 못했습니다.
📌 이 실패에서 얻은 방법론이 하나 있습니다 — 판정 단위는 타깃 하나가 아니라 hot·control 조합이어야 한다. hot만 보면 "혹 있음"으로 끝나 국소성 미검증을 성공으로 오독하게 됩니다.
7. 실험 B — 콜드 DB에 단발로 때려 보기
실험 A는 "된다"까지만 보여 줬습니다. 다음 질문은 완전히 다른 조건에서도 재현되는가, 그리고 지연 회복 말고 처리량 증가를 직접 측정할 수 있는가였습니다.
7-1. 설계 — 램프 없이 직행
| 항목 | 값 |
|---|---|
| DB | 신규 빈 DB (split 1개 상태) |
| 타깃 | 112개 — 전체 키 공간 |
| 워커 | 13파드 × 6프로세스 = 78 — 🔴 미리 스케일해 둠 |
| 투입 | 램프 없이 25,000 ops/s 단발 |
| 서버 압력 | 3:1 기준 53,000 read-equiv = 노드당 공표 읽기치(15,000)의 3.5배 |
| 프로브 | 16 타깃 × 3 req/s |
왜 프리스케일인가 — 오토스케일에 맡기면 부하가 서서히 올라가서 "단발"이 아니게 됩니다. 워커가 먼저 준비돼 있어야 계단 입력이 만들어집니다.
그리고 이 부하는 프로덕션이라면 걸면 안 되는 수준입니다. 실제로 안전 게이트가 전 구간 홀드를 걸었고, 물리 현상을 보려고 의도적으로 무시했습니다.
7-2. 프로브 p95 — 100배 튀었다가 돌아왔다
📐 단일 타깃, 60초 창입니다.
| 구간 | p50 | p95 | p99 |
|---|---|---|---|
| baseline | 15.1 | 19.2 | 24.4 |
| 투입 | 15.5 | 631.5 | 2,565 |
| 정점 | 28.7 | 2,041.1 | 3,183 |
| 감쇠 | 23.4 | 158.7 | 290.5 |
| 회복 | 22.9 | 82.0 | 924.7 |
| 정착 | 16.6 | 27.6 | 38.1 |
정점 +6,448%, 회복 99%.
🔵 눈여겨볼 것은 p50입니다. p50은 15 → 29ms로 1.9배 오르는 데 그쳤는데 p95만 100배 튀었습니다. split에 걸린 꼬리만 길어지는 형태입니다. 신호가 안 잡혔던 시도들에서는 p50이 아예 안 움직였습니다.
7-3. 🔴 그리고 이번엔 처리량 증가를 직접 쟀다
setpoint는 25,000으로 고정한 채입니다.
| 경과 | p95 | 달성 | 달성률 | 에러 |
|---|---|---|---|---|
| 0분 | 828ms | 199 | 0.8% | 1,824 |
| 1분 | 1,222ms | 10,765 | 43.1% | 474 |
| 2분 | 109ms | 8,274 | 33.1% | 0 |
| 4분 | 48.3ms | 14,509 | 58.0% | 0 |
| 7분 | 49.5ms | 20,341 | 81.4% | 0 |
| 9분 | 29.0ms | 22,990 | 92.0% | 0 |
흡수량 199 → 22,990 ops/s (115배)
지연 2,041 → 27.6 ms (1/74)
에러 1,824 → 0
목표 rate를 한 번도 낮추지 않았습니다. 9분 동안 시스템이 흡수량을 115배 늘렸습니다. 실험 A가 "지연이 회복했다"까지였다면, 여기서는 용량이 실제로 늘어난 것을 직접 측정했습니다.
7-4. 서버측 교차검증
클라이언트가 보고한 값만 믿을 수는 없어서 Cloud Monitoring과 대조했습니다.
| 시각 | read/s | write/s | read-equiv |
|---|---|---|---|
| 부하 전 | 48 | 0 | 48 |
| 투입 직후 | 615 | 109 | 1,215 |
| 정점 시각 | 4,910 | 1,658 | 14,029 |
정점 시각의 14,029 read-equiv가 노드당 공표 읽기치 15,000과 거의 같습니다. §3의 표가 문턱이 아니라 상한이라고 적었지만, 적어도 그 근처에서 무언가 일어난다는 것은 관측과 맞물립니다.
7-5. 두 실험을 나란히 놓으면
| 실험 A | 실험 B | |
|---|---|---|
| 플랫폼 | VM + SSH fan-out | GKE 기반 워커 |
| 페이로드 | 합성 필드 | 실제 스키마 (leaf 438개) |
| 키 공간 | 좁은 range 2개 | 전체 112 타깃 |
| 자극 강도 | 임계 2.1배 | 3.5배 |
| 정점 | 319.7ms (20배) | 2,041ms (106배) |
| 회복 | 1분 / 93% | 약 3분 / 99% |
| 처리량 증거 | 미측정 | 115배 증가 직접 측정 |
플랫폼·페이로드·키 공간·자극 강도가 전부 다릅니다. 독립 재현입니다.
같은 패턴은 이후 프로덕션 워밍업에서도 반복해서 나타났습니다. rate를 한 단계 올릴 때마다 혹이 생겼다 꺼집니다.

타깃별 읽기 p95. 여러 키 범위를 동시에 재고 있어 선이 겹쳐 보입니다. 큰 혹 하나가 아니라 rate를 올릴 때마다 작은 혹이 반복되는 것이 워밍업이 진행되는 모습입니다. (범례에 컬렉션 이름이 드러나 잘라냈습니다.)
7-6. 그래서 "도달했다"를 무엇으로 판정하나
이 시스템에서 가장 어려운 부분이고, 아직 완전히 풀리지 않았습니다. 🔴 Firestore는 split 수를 노출하지 않으므로 전부 행동으로 추론해야 합니다.
| 신호 | 뜻 | 강도 |
|---|---|---|
| ① 고정 setpoint에서 p95가 올랐다 내려온다 | split 형성 → 완료 | 강함 |
| ② 고정 setpoint에서 흡수량이 는다 | 용량이 실제로 늘었다 | 강함 |
| ③ 같은 쓰기 rate에서 429가 사라진다 | 허용량이 열렸다 | 중간 |
| ④ 목표 rate에서 p95 예산 안 · 에러 0 유지 | 그 부하를 견딘다 | 가장 실용적 |
①과 ②는 반드시 같이 봐야 합니다. ①만 보면 "그냥 느려졌다 나아졌다" 와 구분되지 않습니다. ②가 붙어야 "rate를 줄이지 않았는데 회복했다" 가 됩니다.
정직한 현재 위치: "목표 rate를 실제로 견뎠다" 는 확인했습니다. "런칭 시점까지 그 용량이 남는다" 는 — 후반부에서 보겠지만 — 읽기만 참이었습니다.
8. 실제 목표
전제가 검증됐으니 진짜 목표를 세웠습니다.
| 항목 | 값 | 왜 |
|---|---|---|
| 목표 처리량 | 100,000 ops/s | 런칭 피크 추정치 |
| 읽기 : 쓰기 | 3 : 1 | 라이브 타이틀 실측 비율(3.3:1) |
| 컬렉션당 문서 | 100만 건 | 부하가 읽을 대상을 만드는 최소한 |
| 쓰기 방식 | update only (1필드 갱신) | 아래 참조 |
| 대상 | 프로덕션 프로젝트 P의 컬렉션 A~G 7개 |
쓰기를 update로 한 것이 중요한 선택이었습니다. 신규 문서를 생성하면 문서 수가 계속 늘어 키 공간이 변하고, 그러면 "split이 부하 때문에 생겼는지 데이터가 커져서 생겼는지"(1부 §10의 load-based vs size-based)를 구분할 수 없습니다. 미리 100만 건을 깔아 두고 그 위에서 갱신만 하면 키 공간이 고정된 채 부하만 변합니다.
그리고 1부 §5-2에서 본 이유로 단조증가 값은 절대 쓰지 않았습니다. 워밍업한다고 타임스탬프를 인덱스에 밀어 넣으면 index lasering을 유발해서, 데우는 게 아니라 핫스팟을 만들게 됩니다.
9. 시스템 — 두 개의 평면
시스템 자체는 이 글의 주제가 아니라서 뼈대만 적습니다. 핵심은 §5에서 말한 계측과 부하의 분리이고, 나머지는 그것을 굴리기 위한 부속입니다.
부하 생성기는 처음에 Locust를 썼다가 48,275 ops/s에서 막혔습니다. 원인은 DB가 아니라 중앙 master로 모이는 제어 연결이었습니다. 그래서 명령을 파일로 전달받고 워커가 각자 정해진 rate로 돌게 바꿔서 중앙 연결 구조를 없앴습니다.
9-1. 파드 하나의 천장은 GIL이 정한다
여기서 실수를 두 번 했습니다. 처음엔 파드당 처리량을 이렇게 계산했습니다.
틀림: 파드당 처리량 = in-flight ÷ 지연
맞음: 파드당 처리량 = 사용 가능한 CPU-ms ÷ (연산당 CPU-ms)
📐 in-flight를 두 배로 올렸더니 늘어난 것은 처리량이 아니라 지연이었습니다(123 → 191ms). Python 프로세스 하나는 GIL 때문에 코어 하나어치 CPU만 씁니다. 비동기라서 동시에 여러 요청을 띄울 수는 있어도, 직렬화·HTTP 처리에 드는 CPU는 한 코어를 넘지 못합니다.
즉 파드를 늘리는 것 외에는 방법이 없고, in-flight를 늘리는 건 지연만 늘립니다. §11에서 이 성질이 아주 반직관적인 형태로 다시 나옵니다.
9-2. 🔴 프로덕션에 부하를 거는 일의 안전장치
프로덕션 컬렉션에 더미 문서를 넣고 지우는 작업이라 실제 유저 데이터를 건드릴 위험이 가장 큰 리스크였습니다.
핵심 규칙은 하나였습니다.
더미 키는 그 컬렉션에서 실제 키가 될 수 없는 형태여야 하고, 동시에 실제 키 공간과 같은 정렬 위치에 놓여야 한다.
뒤쪽 조건이 있어야 워밍업이 의미가 있고(엉뚱한 키 범위를 데우면 소용없습니다), 앞쪽 조건이 있어야 안전합니다.
컬렉션마다 키 형식이 달라서 따로 설계했는데, 결국 쓴 방법은 실제 키가 이미 갖고 있는 불변식을 하나 골라서 깨뜨리는 것이었습니다.
| 유형 | 더미 키를 만드는 방법 |
|---|---|
| 길이 불변식 | 실제 키가 고정 길이라면, 더미는 그보다 길게 만든다 |
| 상한 불변식 | 서버가 길이 상한을 강제한다면, 더미는 그 상한을 넘긴다 |
| 문자 집합 불변식 | 실제 키에 절대 등장할 수 없는 문자를 더미에 섞는다 |
셋 다 키의 앞부분은 실제 키와 같은 분포를 유지해서 정렬 위치는 그대로 두고, 뒤쪽만 실제 키가 될 수 없게 만드는 방식입니다. 그래야 데우려는 키 범위를 정확히 겨냥하면서도 충돌이 원천적으로 불가능해집니다.
그리고 쓰기 직전에 "이 키가 워밍업 키가 맞는가"를 다시 확인하게 했습니다. 📐 실제로 워밍업이 끝난 뒤 더미 616만 건을 삭제했고(3시간 32분, 실패 0), 전수 검증 결과 잔존 0건이었습니다.
10. 결과 — 100K ops/s
📐 목표를 통과한 순간의 로그입니다.
done rate=100,000 p95=65.1ms achieved=100,183(100.2%) err=0
| 항목 | 값 |
|---|---|
| 달성 | 100,183 ops/s (100.2%) |
| 프로브 p95 | 65.1 ms (예산 150ms) |
| 에러 | 0 — ABORTED 전 구간 0건 |
| 함대 | 파드 334 / 노드 56 (448 vCPU) |

계단이 목표 rate(초록), 그것을 따라가는 선이 실제 달성(파랑)입니다. 두 선이 붙어 있으면 정상이고, 벌어지면 그 자체가 진단입니다 — 워커가 못 내고 있거나 DB가 막고 있거나 둘 중 하나입니다.
10-1. 램프의 두 가지 모습
📐 혹이 없는 경우 — 컬렉션 B는 1,000에서 100,000까지 62분 만에, 매 단계 2배씩 올라갔습니다.

계단이 매끄럽게 올라가고 지연도 흔들리지 않습니다. 이미 흡수할 수 있는 상태였다는 뜻입니다.
📐 429로 홀드하는 경우 — 목표를 100K로 올린 뒤 달성이 한참 못 따라오다가, Firestore가 허용량을 열어 주자 따라붙습니다.

주황색(dropped)이 올랐다 사라지는 구간이 재시도가 in-flight를 점유하던 시간입니다. 이 구간에서는 기다리는 것 외에 할 수 있는 게 없습니다.
10-2. 컬렉션 7개 성적
| 컬렉션 | 결과 |
|---|---|
| A | 96,000 도달 |
| B | ✅ 100,000 (62분) |
| C | ✅ 100,000 (72분, 전 구간 에러 0) |
| D | ✅ 완료 |
| E | ✅ 100,000 (100.2%, 에러 0) |
| F | ✅ 100,198 |
| G | ✅ 100,000 (98.4%) — 문서를 6.2KB → 626B로 줄인 뒤 |
마지막 줄의 각주가 §12-6에서 다룰 이야기입니다.
11. 🔴 가장 반직관적이었던 관측
429가 나기 시작했을 때 처음 한 일은 파드를 늘리는 것이었습니다. 처리량이 목표에 못 미치니 더 밀면 될 거라고 생각했습니다.
📐 결과는 반대였습니다.
| 파드 | 명령 총량 | 달성 | 쓰기 지연 |
|---|---|---|---|
| 63 | 25,200 | 6,001 | 2,314 ms |
| 30 | 12,000 | 11,555 | 120 ms |
파드를 절반으로 줄였더니 처리량이 두 배가 됐고 지연은 1/19이 됐습니다.
이유는 §9-1의 GIL 이야기와 이어집니다.
429를 맞은 쓰기는 클라이언트가 최대 60초 동안 재시도한다
→ 그동안 in-flight 슬롯을 붙잡고 있는다
→ 슬롯이 재시도로 가득 차면 정상 연산이 나갈 자리가 없다
→ 파드를 늘리면 재시도도 같이 늘어난다
→ 더 밀수록 더 막힌다
막히면 더 미는 것이 아니라 줄여야 합니다.
이건 429에 국한된 이야기가 아니라, 재시도가 있는 모든 클라이언트에 해당합니다. 백프레셔가 없는 부하 생성기는 자기 자신을 막는 방향으로 동작합니다.
12. 워밍업의 한계 — 여기가 진짜 발견이다
여기까지는 "된다"는 이야기였습니다. 이 절부터가 이 프로젝트에서 가장 값어치 있는 부분입니다.
12-1. 데운 컬렉션이 1/4도 못 버텼다
컬렉션 A는 오전에 96,000 ops/s까지 올라갔습니다. 같은 날 오후, 다른 컬렉션들을 돌고 와서 25,000을 다시 넣었더니 무너졌습니다.
hold rate=25,000 p95=26.5ms achieved=6,794(27.2%) err=1628
그 사이에 있었던 일은 두 가지입니다.
- 컬렉션 A에 쓰기 부하가 6시간 이상 없었다 (다른 컬렉션들을 도는 동안)
- 관측 스택 사고로 40분간 부하가 완전히 0이었다
📘 문서는 split이 ~24시간 유지된다고 했는데, 6시간 만에 워밍업이 통째로 날아간 것처럼 보였습니다.
12-2. 🔴 그런데 split은 살아 있었다
쓰기를 완전히 빼고 읽기만 같은 컬렉션에 넣어 봤습니다.
📐
| 읽기 부하 | 프로브 p95 |
|---|---|
| 28,635 ops/s | 28.8 ms |
| 57,301 ops/s | 26.0 ms |
| 85,512 ops/s | 23.7 ms |
| 91,757 ops/s | 26.2 ms |
부하를 3배로 올리는데 지연이 오히려 내려갔습니다.
콜드 상태였다면 §7-2처럼 치솟았어야 합니다. 키 범위가 이미 충분히 쪼개져 있다는 뜻이고, 오전에 만든 split이 그대로 남아 있다는 뜻입니다. 에러는 0이었습니다.
즉 무너진 것은 split이 아니었습니다.
12-3. 막고 있던 것은 전적으로 쓰기였다
같은 컬렉션, 같은 시각. 부하 구성만 바꿨습니다.
📐
| 쓰기 25% | 읽기 100% | |
|---|---|---|
| 창당 에러 | 1,606 | 2 |
| 달성률 | 65.1% | 93.3% |
| 실 처리량 | 19,522 ops/s | 27,988 ops/s |
총 부하를 바꾸지 않고 구성만 바꿨는데 처리량이 43% 늘었습니다. 에러는 전부 §1-1의 그 429였고, 메시지가 for writes라고 명시하고 있었습니다. 읽기는 단 한 건도 실패하지 않았습니다.
여기서 판별식이 나옵니다.
처리량이 오를 때 지연도 따라 오른다 → 포화다. split을 더 만들어야 한다
처리량만 거부되고 지연은 그대로다 → 정책이다. 시간을 주거나 부하를 낮춰야 한다
📐 막힌 상태에서 목표 rate를 25K → 10K → 20K → 30K로 흔들며 쓰기가 열리는 과정을 지켜봤습니다.
| 목표 rate | 쓰기/s | 에러/s | 프로브 p95 |
|---|---|---|---|
| 25,000 | 1,693 | 28.4 | 22.3 ms |
| 10,000 | 3,203 | 30.6 | 31.1 ms |
| 20,000 | 4,152 | 0.0 | 25.0 ms |
| 30,000 | 4,784 | 30.6 | 26.9 ms |
쓰기가 2.8배 오르는 동안 p95는 22~31ms 안에서 제자리입니다. 포화라면 있을 수 없는 일입니다.

앞쪽은 쓰기가 429로 막혀 있던 구간, 뒤쪽은 읽기만 92K를 흘린 구간입니다. 처리량이 20배 차이 나는데 지연은 같은 20~30ms대입니다. 저장 계층이 포화했다면 이렇게 될 수 없습니다.
🔴 429는 포화의 신호가 아니라 admission control의 신호입니다. 용량이 없어서 거절하는 게 아니라, 아직 열어 주지 않아서 거절하는 것입니다.
12-4. 허용량은 DB가 아니라 컬렉션 단위였다
§1-1에서 예고한 이야기입니다. 429 메시지는 "This database has exceeded…"라고 말합니다. 그런데 실제 동작은 달랐습니다.
📐
13:05 목표 100,000 쓰기 23,011/s 에러 0 ← 직전 컬렉션
13:20 목표 25,000 쓰기 1,693/s 에러 28.4 ← 다른 컬렉션으로 전환
5분 사이에 23,011에서 1,693으로 떨어졌습니다. DB 전체 한도였다면 불가능한 일입니다.

왼쪽 두 선(초록·노랑)이 컬렉션 G의 읽기·쓰기, 오른쪽 두 선(파랑·주황)이 컬렉션 A의 읽기·쓰기입니다. G가 읽기 70K + 쓰기 25K를 문제없이 처리하다가, A로 전환하자 쓰기가 바닥에 붙습니다. 그리고 쓰기를 빼자 읽기(파랑)가 92K로 튀어 오릅니다.
이 동작은 📘 500/50/5의 문구 "ramping up traffic on a collection" 과 정확히 일치합니다. 메시지가 database라고 말할 뿐, 실제 단위는 컬렉션입니다.
컬렉션마다 따로 데워야 합니다. 하나를 데운 것이 다음으로 이월되지 않습니다.
12-5. 그런데 유지하면 열린다
되감기기만 하는 건 아닙니다. 부하를 계속 유지하면 허용량이 단조 증가합니다.
📐 429를 맞아 가며 약 90분에 걸쳐 관측한 기록입니다.
| 경과 | 쓰기/s | 에러/s |
|---|---|---|
| 0분 | 1,517 | 55.7 🔴 |
| 10분 | 2,892 | 0 |
| 50분 | 5,011 | 0 |
| 60분 | 9,481 | 16.8 🔴 |
| 70분 | 12,531 | 0 |
| 80분 | 22,489 | 120.7 🔴 |
| 85분 | 25,042 | 0 |
429가 나타났다 사라지기를 반복하면서 허용량이 올라갑니다.
🔴 429는 정지 신호가 아니라 진행 중이라는 신호입니다. 나타났다 사라지는 것이 정상이고, 사라지지 않으면 그때 멈춰야 합니다.
12-6. 대역폭이 먼저 막히는 경우
§10-2 표의 각주입니다. 📐 컬렉션 G만 유독 40,000 ops/s에서 달성률 84%로 무너졌습니다. 다른 여섯 개가 100,000을 통과한 것과 대조적이었습니다.
원인은 문서 크기였습니다. 컬렉션 G의 문서는 6.2KB였습니다(외부 결제 영수증 payload가 5,429자).
60,000 QPS × 쓰기 25% × 6.2KB = 93 MB/s
다른 컬렉션은 문서가 ~200B → 같은 조건에서 5 MB/s (19배 차이)
§1-1의 429 메시지가 지목하는 세 축 request_rate / bandwidth / document_rate 중 bandwidth 에 걸린 것입니다. payload를 잘라 문서를 626B로 만들자 100,000에서 98.4%, 에러 0이 됐습니다.
문서가 큰 컬렉션은 QPS를 목표로 잡으면 안 됩니다. 대역폭이 먼저 막힙니다.
경계값은 모릅니다. 93 MB/s 실패와 9.4 MB/s 성공 사이 어디라는 것까지만 압니다.
12-7. ⚠️ 공식 문서는 틀리지 않았지만 혼란스럽게 합니다
📘 다시 그 문장입니다.
"Splits created to handle excess traffic are retained for around ~24 hours even if the traffic goes away."
📐 이 문장 자체는 우리 데이터로 확인됩니다. split은 실제로 남았습니다(§12-2). 틀린 것은 문장이 아니라 독자가 하게 되는 추론입니다.
| 문서가 말하는 것 | split이 24시간 유지된다 |
| 독자가 읽는 것 | 따라서 처리량이 24시간 유지된다 |
| 실제 | 읽기만 참. 쓰기는 되감긴다 |
| 문서가 말하지 않는 것 | 🔴 쓰기 admission control이 별도 상태로 존재한다 |
문서는 전 구간 traffic·operations로만 말하고 읽기와 쓰기를 한 번도 구분하지 않습니다. 500/50/5도 같은 페이지에 있는데, 그 둘이 다른 축이라는 설명이 없습니다. split은 구조를 만들고, 500/50/5는 정책을 엽니다.
"틀렸다"보다 "쓰기 경로에 대해 잘못된 결론을 유도할 만큼 불완전하다" 가 정확한 표현이고, 그렇게 읽어야 할 일이 나옵니다 — 두 경로를 따로 재라.
13. 그래서 워밍업이 막는 것은 무엇인가
여기까지 오니 처음 질문이 바뀌었습니다.
쓰기 워밍업이 막는 것 = 용량 부족 ❌
= 정책이 따라올 수 없는 속도의 도착 ✅
용량은 부족하지 않았습니다. §12-3에서 본 대로 저장 계층은 멀쩡했고, 거절한 것은 정책이었습니다. 그렇다면 런칭 계획의 진짜 변수는 목표 QPS가 아니라 도착 곡선입니다.
| 도착 속도 | 워밍업이 필요한가 |
|---|---|
| +50% / 5분 이하 (자연스러운 유저 유입) | 정책이 알아서 따라온다 — 필요 없다 |
| 그보다 빠름 (푸시 알림 · 마케팅 스파이크 · 대기열 일괄 개방) | 🔴 필요하다 |
13-1. 🧩 그리고 도착 속도를 제어하는 건 워밍업이 아니다
여기까지 오면 결론이 조금 김빠집니다.
도착 곡선이 문제라면, 그걸 제어하는 진짜 수단은 워밍업이 아니라 로그인 대기열입니다. 큐가 유입을 고르게 펴 주면 워밍업 없이도 정책이 따라오고, 반대로 큐를 한 번에 열어 버리면 아무리 데워 놔도 그 순간의 스파이크는 막지 못합니다.
📐 우리 목표였던 25K writes/s도 다시 봐야 합니다. 그건 피크 값이지 런칭 t=0의 도착률이 아닙니다. 다음 워밍업 전에 확인해야 할 것은 목표 QPS가 아니라 예상 도착 곡선입니다.
14. 아직 모르는 것
정직하게 남겨 둡니다.
| # | 질문 | 상태 |
|---|---|---|
| 1 | 되감김 곡선의 모양 | 40분 정지에 약 5배 축소는 봤지만, 몇 분부터 어떤 기울기로 줄어드는지는 모릅니다. 회복은 rate를 낮춰 재램프하면 30분에 두 배 정도였습니다 |
| 2 | 대역폭 한계의 절대값 | 93 MB/s 실패, 9.4 MB/s 성공. 그 사이 어디가 경계인지 미측정입니다 |
| 3 | 허용량의 진짜 단위 | 컬렉션 단위인 것까지는 봤습니다(§12-4). 그보다 잘게 키 범위 단위로도 나뉘는지는 모릅니다 |
| 4 | range 국소성 | §6-4 그대로 미해결입니다. "이 DB가 split됐다"까지만 증명됐습니다 |
| 5 | "도달했다"의 확실한 판정 | split 수가 안 보이는 한 §7-6의 간접 신호 조합이 최선입니다 |
15. 정리
- Firestore는 용량을 미리 잡아 둘 수 없습니다. 부하를 보고 따라오고, 그 지연이 런칭 순간에 문제가 됩니다.
- split은 부하를 보고 생기며, 바깥에서 관측할 수 있습니다. p95에 혹이 생겼다 꺼지고, 그동안 흡수량이 늘어나면 split이 형성된 것입니다. 둘을 같이 봐야 합니다.
- 계측은 부하 생성기에서 하면 안 됩니다. 같은 순간 워커는 235ms, 프로브는 44.6ms를 봤습니다.
- 429는 포화가 아니라 admission control입니다. 처리량이 거부되는데 지연이 그대로면 정책입니다. 그리고 429는 진행 중이라는 신호라서, 유지하면 열립니다.
- 막히면 줄여야 합니다. 파드를 절반으로 줄여 처리량이 두 배가 됐습니다.
- 🔴 읽기 확장과 쓰기 허용량은 다른 것이고 수명도 다릅니다. split은 구조라서 남고, 쓰기 허용량은 정책이라 되감깁니다. 공식 문서의 "~24시간"은 읽기에 대해서만 참입니다.
- 그리고 워밍업이 실제로 막아 주는 것은 용량 부족이 아니라 정책이 따라올 수 없는 속도의 도착입니다.
마지막 항목이 이 프로젝트에서 제일 오래 걸려 도달한 결론이자, 처음 질문을 바꿔 놓은 답이었습니다. 8일 동안 "split이 생기는가" 를 물었는데, 정작 중요한 질문은 "런칭 때 트래픽이 얼마나 빨리 도착하는가" 였습니다.
DB를 데우는 것보다 트래픽을 천천히 들여보내는 것이 더 확실한 대책입니다. 워밍업은 그게 불가능할 때의 보험입니다.
15-1. 그래서 Firestore를 쓸 것인가
이 결론을 기술 선택의 언어로 바꾸면 이렇게 됩니다.
Firestore를 계속 쓴다면, 급격한 유입은 그 자체로 장애 요인입니다. 용량을 미리 잡아 둘 수 없으니 트래픽이 계단으로 도착하면 확장이 따라오기 전에 429와 지연 폭증을 먼저 만납니다. 그래서 유입을 천천히 들여보내는 장치가 선택이 아니라 요구사항이 됩니다 — 로그인 대기열, 지역·플랫폼별 순차 오픈, 푸시 발송 분산 같은 것들입니다. 워밍업은 그 위에 얹는 보험이지 대체재가 아닙니다.
그런데 서비스가 아래 둘 중 하나에 해당한다면, Firestore는 맞는 선택이 아닙니다.
| 조건 | 왜 |
|---|---|
| 급격한 유입이 불가피하다 | 정해진 시각 동시 오픈, 방송·광고 스파이크처럼 도착 곡선을 우리가 정할 수 없는 경우입니다. 천천히 들여보낼 수단이 없으면 쓸 수 있는 카드가 남지 않습니다 |
| 용량을 미리 확정해야 한다 | 런칭 전에 용량을 잡아 두는 것이 요구사항이라면, Firestore에는 그 손잡이가 아예 없습니다(§1) |
이건 Firestore가 쓸수 없는 DB라는 이야기가 아닙니다. 용량 관리를 대신해 주는 대가로 용량 관리 권한을 가져간 DB이고, 그 거래가 맞지 않는 서비스가 있을 뿐입니다. 평상시 운영에서는 이 거래가 압도적으로 유리합니다 — 노드 수를 고민할 일이 없다는 건 큰 장점입니다.
다만 그 손잡이가 필요한 순간이 분명히 존재한다면, 손잡이가 있는 쪽을 고르는 게 맞습니다. 1부 §10-2에서 봤듯 같은 저장 엔진 위의 Spanner에는 런칭을 위한 pre-splitting API가 있습니다. 같은 Spanner 위에 있는데도 한쪽은 "미리 만들어 달라"고 말할 수 있고 한쪽은 말할 수 없다는 것 — 그 차이가 이 시리즈 전체의 이유였습니다.
1부에서 다룬 구조 이야기는 여기 → Firestore Deep Dive - Part 1: 역사, 구조, 그리고 Spanner
Isaac's Tech Blog