Firestore Deep Dive - Part 2: 확장 한계와 워밍업


이 글에서 다루는 사내 사례와 데이터는 기밀 유지를 위해 일부 이름과 용어를 익명화했습니다. 컬렉션은 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.

세 가지를 눈여겨볼 만합니다.

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가 포화해서 요청을 못 내보내고 있는 시간이 섞인 값입니다. 부하 생성기에서 지연을 재면 서버 포화와 클라이언트 포화를 구분할 수 없습니다.

그래서 계측을 부하에서 물리적으로 떼어냈습니다.

graph LR subgraph M["계측 평면 — 별도 노드"] P["프로브<br/><i>상수 rate · 랜덤 키 · 읽기 전용</i>"] end subgraph L["부하 평면"] W["비동기 워커 × N<br/><i>목표 rate 생성</i>"] end P -->|"p95 (권위)"| DB[(Firestore)] W -->|"부하"| DB

프로브의 설계 원칙은 다섯 가지였고, 전부 "계측기가 실험을 바꾸지 않게" 하려는 것입니다.

원칙 이유
부하와 다른 노드에 둔다 워커 CPU 포화가 지연에 섞이면 안 된다
상수 rate 부하가 늘어도 프로브는 안 늘린다. 기준선이 흔들리면 비교가 불가능하다
rate가 지연과 독립 지연이 늘 때 요청도 줄면 되먹임이 생긴다
키 풀에서 매번 랜덤 추출 순회하면 캐시 적중률만 오른다
자동 종료 없음 판정 규칙을 계측기에 넣으면 계측기가 실험을 바꾼다

5-1. 대조군이 핵심이다

그리고 프로브는 두 개의 키 범위를 동시에 잽니다.

둘 다 같이 느려지면 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에 혹이 생겼다 꺼지는 모습

타깃별 읽기 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에서 말한 계측과 부하의 분리이고, 나머지는 그것을 굴리기 위한 부속입니다.

graph LR O["오케스트레이터<br/><i>rate를 언제 올릴지 판정</i>"] -->|"목표 rate"| W subgraph LP["부하 평면"] W["비동기 워커 × N<br/><i>파드 하나 = 프로세스 하나 = 이벤트 루프 하나</i>"] end subgraph MP["계측 평면 — 물리적으로 분리"] P["프로브<br/><i>상수 rate · 읽기 전용</i>"] end W --> DB[(Firestore)] P --> DB P -.->|"p95 (판정 입력)"| O W -.->|"달성 · 에러"| O

부하 생성기는 처음에 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)
에러 0ABORTED 전 구간 0건
함대 파드 334 / 노드 56 (448 vCPU)

대시보드 — setpoint와 달성이 100K에서 붙어 있다

계단이 목표 rate(초록), 그것을 따라가는 선이 실제 달성(파랑)입니다. 두 선이 붙어 있으면 정상이고, 벌어지면 그 자체가 진단입니다 — 워커가 못 내고 있거나 DB가 막고 있거나 둘 중 하나입니다.

10-1. 램프의 두 가지 모습

📐 혹이 없는 경우 — 컬렉션 B는 1,000에서 100,000까지 62분 만에, 매 단계 2배씩 올라갔습니다.

혹 없이 올라가는 램프

계단이 매끄럽게 올라가고 지연도 흔들리지 않습니다. 이미 흡수할 수 있는 상태였다는 뜻입니다.

📐 429로 홀드하는 경우 — 목표를 100K로 올린 뒤 달성이 한참 못 따라오다가, Firestore가 허용량을 열어 주자 따라붙습니다.

429로 홀드했다가 따라붙는 램프

주황색(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

그 사이에 있었던 일은 두 가지입니다.

📘 문서는 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_ratebandwidth 에 걸린 것입니다. 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. 정리

마지막 항목이 이 프로젝트에서 제일 오래 걸려 도달한 결론이자, 처음 질문을 바꿔 놓은 답이었습니다. 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