Firestore Deep Dive - Part 1: 역사, 구조, 그리고 Spanner


이 글에서 다루는 사내 사례와 데이터는 기밀 유지를 위해 일부 이름과 용어를 익명화했습니다.

들어가며

게임 런칭을 앞두고 Firestore를 미리 데우는(warm-up) 과제를 맡은 적이 있습니다.

시작은 단순한 질문이었습니다. 런칭 순간에 트래픽이 0에서 최대로 뛰는데, 그때 DB가 버틸까? 공식 문서를 찾아보니 답이 이렇게 적혀 있었습니다.

Firestore는 트래픽에 맞춰 자동으로 확장됩니다.

맞는 말인데, 이 문장만으로는 아무것도 할 수 없었습니다. 얼마나 빨리 확장되는지, 확장된 상태가 얼마나 유지되는지, 확장이 트래픽보다 느리면 무슨 일이 일어나는지가 없었기 때문입니다. 실제로 부하를 걸어 보니 429가 돌아왔고, 그 시점부터는 "Firestore가 알아서 한다"는 설명으로는 한 발짝도 나아갈 수 없었습니다.

그래서 Firestore가 실제로 무엇 위에 서 있는지부터 파고들었습니다. 이 글은 그 조사 기록입니다.

1부는 실험 이야기가 없는 순수 구조 해설입니다. 그런데 2부에서 다루는 관측들이 전부 여기 나오는 구조에서 설명되기 때문에, 순서를 이렇게 잡았습니다.

근거 표기

분산 DB의 내부는 공식 문서가 다 말해 주지 않고, 검증되지 않은 추측이 인터넷에 많습니다. 그래서 문장마다 근거의 출처와 강도를 표시했습니다.

표기
📘 공식 문서 — Google Cloud / Firebase 문서
📄 피어리뷰 논문 — VLDB / OSDI 등
📐 직접 실측 — 사내 부하 실험 결과
🧩 추론 — 위 세 가지로부터 유도한 것. 검증되지 않았습니다

1. 역사 — Firestore는 Datastore의 후속이 아니다

Firestore를 이해하는 데 가장 도움이 됐던 사실은 이것이었습니다.

Firestore는 Datastore의 다음 버전이 아니라, 같은 데이터 모델을 다른 저장 엔진 위에 다시 구현한 것이다.

이 문장이 왜 중요한지는 조금 뒤에 드러납니다. 먼저 시간순으로 보겠습니다.

📄 2024년 VLDB에 Google 엔지니어들이 Datastore에서 Firestore로의 이관 과정을 다룬 논문을 발표했습니다. 이 글의 역사·저장구조 부분은 대부분 이 논문에 근거합니다.

시점 사건 저장 엔진
2008 Datastore 출시 — App Engine의 데이터베이스 Megastore (그 아래 Bigtable)
2013 Cloud Datastore API 추가 — App Engine 밖에서도 사용 가능 Megastore
2019 Firestore 출시 — Datastore API의 재구현 + 새 모바일/웹 API Spanner
2021 고객 데이터베이스 이관 시작 Megastore → Spanner
2024 100만 개가 넘는 DB 전부 이관 완료 Spanner
2025 Enterprise edition + MongoDB 호환 GA Spanner

📄 논문의 표현은 이렇습니다.

"Datastore launched in 2008 as the database for Google's early platform-as-a-service App Engine. Megastore was used as Datastore's storage layer. ... Firestore launched in 2019, combining a newer implementation of the Datastore API with a new mobile and web-friendly Firestore API, but used Spanner as its storage layer."

1-1. Megastore가 무엇이고 무엇이 문제였나

📄 Megastore는 entity group 단위로 Paxos를 돌려 데이터를 동기 복제했고, 각 복제본은 Bigtable에 저장됐습니다.

entity group은 키의 계층 구조로 정해집니다. Datastore의 키는 (kind, identifier) 쌍의 경로입니다.

/users/752                          ← entity group (users, 752)
/users/752/rooms/den                ← 같은 group
/users/752/rooms/kitchen            ← 같은 group
/users/752/backups/A/slice/23       ← 같은 group

첫 번째 (kind, identifier) 쌍을 공유하면 같은 entity group입니다. 그리고 여기에 Megastore의 두 가지 제약이 걸려 있었습니다.

📄 > "Megastore, and hence Datastore, provided full ACID semantics within each entity group but no consistency guarantees across them. Furthermore, each entity group had a limited maximum write rate (on the order of a few writes per second), forcing application developers to split their data across many entity groups, and then have to resort to eventually consistent queries to access this data."

이 제약이 만드는 악순환이 문제의 핵심입니다.

group 하나당 초당 몇 건밖에 못 쓴다
        ↓
데이터를 여러 entity group으로 잘게 쪼갠다
        ↓
그런데 group 사이에는 일관성 보장이 없다
        ↓
group을 걸치는 조회는 eventually consistent 쿼리로 해야 한다
        ↓
데이터 모델이 저장 엔진의 제약에 맞춰 뒤틀린다

📄 완화책으로 최대 25개 entity group을 걸치는 트랜잭션을 지원했지만, 근본 해결은 아니었습니다.

🔴 시제에 주의하세요. "entity group당 초당 몇 건"은 Megastore 시절 Datastore의 제약입니다. 지금의 Firestore에는 해당하지 않습니다. 인터넷에 남아 있는 오래된 Datastore 튜닝 글들이 이 제약을 전제로 쓰여 있어서, 지금 읽으면 오히려 잘못된 데이터 모델을 유도합니다.

1-2. 왜 Spanner였나

📄 논문은 Spanner를 고른 이유를 이렇게 적습니다.

"Spanner was chosen as the storage engine of Firestore in particular for technical reasons — it provides unrestricted transaction capabilities, strong consistency guarantees, and other improvements over Megastore."

그래서 Datastore API를 Spanner 위에 다시 구현했을 때 얻은 것이 두 가지입니다. 📄 모든 쿼리의 강한 일관성, 그리고 제약 없는 트랜잭션. 위 악순환이 통째로 사라진 셈입니다.

📄 그리고 두 API는 같은 데이터 모델을 쓰고, 같은 데이터에 접근하며, 가격도 동일합니다. Datastore API와 Firestore API는 같은 저장소를 보는 두 개의 문(門)입니다.

1-3. 100만 개의 DB를 무중단으로 옮긴 이야기

📄 이관은 2021년에 시작해 약 3년이 걸렸고, 100만 개가 넘는 모든 데이터베이스가 옮겨졌습니다. 이 글의 주제는 아니지만 원칙 하나는 인용할 만합니다.

"Migration causes no downtime or disruption to customer traffic during and after migration ... The migration is automatic; the customer does not need to take any action."

우리가 지금 쓰는 Firestore가 이 이관의 결과물이고, 뒤에 나올 저장 레이아웃이 Megastore 시절의 키 순서를 그대로 보존하도록 설계된 이유도 여기에 있습니다. 📄 "to preserve Datastore's performance profile after migration, both implementations must keep essentially the same storage layout and key order."


2. 레이아웃 — 요청 하나가 지나가는 길

Firestore에 문서 하나를 읽는 요청을 보내면, 그 요청은 일곱 개의 층을 지나갑니다.

graph TB SDK["① SDK · 클라이언트 라이브러리<br/><i>재시도 · 채널 · 베스트프랙티스</i>"] GFE["② Google Front End (GFE)<br/><i>전역 진입점 · DDoS 방어</i>"] FE["③ Firestore 프런트엔드 서버<br/><i>DB와 같은 리전으로 라우팅 · 장수명 연결</i>"] SVC["④ Firestore 서비스<br/><i>인증 · 인가 · 쿼터 · 보안 규칙</i>"] ST["⑤ Firestore 스토리지 층<br/><i>Entities 테이블 + Indexes 테이블</i>"] SP["⑥ Spanner<br/><i>split · Paxos · lock table · TrueTime</i>"] CO["⑦ Colossus<br/><i>WAL · B-tree 유사 데이터 파일</i>"] SDK --> GFE --> FE --> SVC --> ST --> SP --> CO
하는 일
① SDK 📘 API 사용을 단순화하고 베스트프랙티스를 구현하는 추상화 층
② GFE 📘 들어오는 요청을 받아 해당 Google 서비스로 넘긴다
③ 프런트엔드 📘 DB와 같은 리전으로 라우팅. 연결은 장수명이라 앱이 닫을 때까지 유지된다
④ 서비스 📘 인증·인가·쿼터 검사·보안 규칙
⑤ 스토리지 📄 관계형 테이블 두 개 — 문서 본문의 Entities, 정렬 조회용 Indexes
⑥ Spanner 📄 split 단위 분배, Paxos 동기 복제, 잠금, TrueTime
⑦ Colossus 📄 tablet의 write-ahead log와 데이터 파일이 놓이는 분산 파일시스템

🔴 출처를 분명히 해야 하는 지점입니다. Firestore 공식 문서(📘 Understand reads and writes at scale)는 ①~⑤층까지만 설명하고 Spanner와 Colossus를 이름으로 언급하지 않습니다. ⑥⑦층과 Firestore를 잇는 근거는 📄 VLDB 논문입니다. "Firestore는 Spanner 위에서 돈다"는 문장을 공식 문서에서 찾으려 하면 못 찾습니다.

2-1. 🔴 우리가 직접 볼 수 있는 층은 하나도 없다

이 글 전체에서 가장 실무적으로 중요한 사실이 이것입니다.

Spanner를 직접 쓰면 노드 수를 정하고, CPU 사용률을 보고, split 목록을 조회할 수 있습니다. Firestore 사용자에게는 그 어느 것도 노출되지 않습니다. Firestore는 프로비저닝하는 인스턴스가 아니기 때문에 노드 수라는 값 자체가 없습니다.

알고 싶은 것 볼 수 있는가
split이 몇 개인가
split당 CPU 사용률
어느 키 범위가 뜨거운가 ⚠️ Key Visualizer로 간접 관측
서버측 읽기/쓰기 rate ✅ Cloud Monitoring

그래서 2부의 실험은 전부 "안을 못 보는 상태에서 행동만으로 내부 상태를 추론하는" 형태가 됩니다. 이 제약이 실험 설계 전체를 지배합니다.


3. 에디션과 모드 — 헷갈리는 두 개의 축

Firestore를 만들 때 고르는 선택지가 두 축으로 나뉘는데, 이름이 비슷해서 자주 섞입니다.

graph LR subgraph ED["에디션 (성능·기능 등급)"] S["Standard"] E["Enterprise"] end subgraph MO["모드 (API 표면)"] N["Native"] D["Datastore"] M["MongoDB 호환"] end S --> N S --> D E --> N E --> M

📘 editions overview 기준으로 정리하면 이렇습니다.

Standard Enterprise
쿼리 엔진 "standard query engine" — 기본 비교와 매칭 "advanced query engine" — 180개가 넘는 stage/operator, 집계·산술·배열·집합·타입변환·조인
인덱스 모든 필드에 자동 인덱스, 그리고 모든 쿼리가 covered index를 요구 커스터마이즈 가능 — unique·dense·sparse. 인덱스 없이도 쿼리 가능
지원 모드 Native 또는 Datastore Native 또는 MongoDB 호환
기타 텍스트 검색, 지리 검색, 📘 최대 5배 성능

모드는 어떤 API로 이 저장소를 열 것인가입니다.

🔴 모드는 DB 생성 시점에 정해지고 나중에 바꿀 수 없습니다. 리전도 마찬가지입니다. 이 두 값은 되돌릴 수 없으니 생성 전에 확인해야 합니다.

이 글의 나머지와 2부는 전부 Standard edition · Native mode 기준입니다. 실무에서 가장 흔한 조합이고, 무엇보다 "모든 필드에 자동으로 인덱스가 생긴다"는 성질이 뒤에 나올 쓰기 비용 이야기의 전제이기 때문입니다.


4. 특성 — 문서 DB로서의 Firestore

4-1. 데이터 모델

Firestore는 컬렉션 → 문서 → (서브컬렉션 → 문서) 의 트리입니다.

users (컬렉션)
 └── user_8f3a... (문서)
      ├── name: "Isaac"
      ├── level: 42
      └── inventory (서브컬렉션)
           └── item_001 (문서)

4-2. 쿼리 — "느린 쿼리"가 아니라 "불가능한 쿼리"

RDBMS를 쓰다 오면 가장 크게 걸리는 지점입니다.

📘 Standard edition에서는 모든 쿼리가 인덱스를 요구합니다. 인덱스가 없으면 쿼리가 느려지는 게 아니라 실패합니다. 대신 에러 메시지에 그 인덱스를 만드는 콘솔 링크가 같이 옵니다.

FAILED_PRECONDITION: The query requires an index.
You can create it here: https://console.firebase.google.com/...

이 설계의 결과가 재미있습니다.

Firestore에는 "풀 스캔이 걸려서 느려지는" 상황이 존재하지 않습니다. 쿼리 성능은 스캔한 데이터가 아니라 반환한 결과의 크기에 비례합니다.

RDBMS에서는 인덱스를 안 타면 느려지기라도 하지만, Firestore는 아예 거부합니다. 성능 예측 가능성을 얻고 표현력을 내준 거래입니다. 그래서 JOIN이 없고, 정렬·범위 조건의 조합에도 제약이 있습니다.

4-3. PostgreSQL과의 빠른 비교

PostgreSQL Firestore (Standard · Native)
데이터 모델 테이블 · 행 · 고정 스키마 컬렉션 · 문서 · 스키마리스
인덱스 직접 만든다. 없으면 느려진다 단일 필드는 자동 생성. 없으면 실패한다
쿼리 비용 스캔한 행에 비례 반환한 문서에 비례
JOIN ❌ (Enterprise edition은 지원)
트랜잭션 단일 노드 ACID 분산 ACID, 📘 최대 270초
확장 수직 확장 + 읽기 복제본. 샤딩은 직접 자동 수평 분할
용량 산정 인스턴스 크기를 내가 정한다 정할 수 없다 — 부하를 보고 따라온다
쓰기 1건의 비용 인덱스 수에 비례 인덱스 수에 비례하되 분산 트랜잭션 비용이 추가

마지막 두 줄이 2부 전체의 주제입니다. 용량을 내가 정할 수 없다는 성질은 운영을 편하게 만들어 주지만, 런칭처럼 부하가 계단으로 뛰는 순간에는 정확히 반대로 작용합니다.


5. 키 공간 — 문서 ID가 곧 물리적 위치다

이제 저장 층으로 내려갑니다. 여기서부터가 2부의 실제 근거입니다.

5-1. 테이블은 두 개다

📄 논문이 보여 주는 저장 레이아웃입니다. 예제는 /users/752/rooms/den 같은 문서들이 homes라는 DB에 들어 있는 상황입니다.

TABLE Entities
  db_id | entity_group | path                     | kind  | ...
  ------+--------------+--------------------------+-------+----
  homes | (users, 752) | /users/752               | users | ...
  homes | (users, 752) | /users/752/rooms/den     | rooms | ...
  homes | (users, 752) | /users/752/rooms/kitchen | rooms | ...

TABLE Indexes
  db_id | index           | value | key → Entities
  ------+-----------------+-------+---------------------------
  homes | "size" on rooms | 100   | /users/752/rooms/den
  homes | "size" on rooms | 200   | /users/752/rooms/kitchen

키 순서가 핵심입니다. 📄

그래서 SELECT * FROM rooms WHERE size > 90 같은 쿼리는 이렇게 실행됩니다.

graph LR Q["쿼리<br/>size > 90"] --> I["① Indexes 테이블<br/><b>range scan</b><br/>정렬돼 있으니 연속 구간"] I --> K["일치하는 문서 키 목록"] K --> E["② Entities 테이블<br/><b>point lookup</b><br/>키로 직접 조회"] E --> R["결과 문서"]

인덱스를 스캔해서 키를 얻고, 그 키로 본문을 가져온다. 이 2단계가 §4-2에서 말한 "인덱스 없으면 쿼리가 아예 안 되는" 이유이고, 동시에 "비용이 반환 결과에 비례하는" 이유이기도 합니다. 인덱스가 이미 정렬돼 있으니 조건에 맞는 구간만 연속으로 읽으면 되고, 그 구간의 길이가 곧 결과의 크기니까요.

5-2. 그래서 문서 ID를 어떻게 짓느냐가 물리 배치를 정한다

📄 저장 층은 행들을 tablet으로 쪼개 서버에 나눠 주고, 각 tablet은 연속된 키 범위를 담습니다.

"Both Megastore (via Bigtable) and Spanner use sharding to split or merge an instance's rows into tablets which are then assigned to individual servers, for sharing and balancing load. Each tablet contains consecutive ranges of the instance's keyed rows; consequently, key ordering is important for locality and overall performance."

즉 이렇습니다.

키 공간 (문서 ID 사전순)
├─────────────┬─────────────┬─────────────┤
   구간 A         구간 B         구간 C
   서버 1         서버 2         서버 3

문서 ID는 단순한 식별자가 아니라 그 문서가 어느 서버에 놓일지를 정하는 좌표입니다.

여기서 두 가지가 따라 나옵니다. 하나는 좋은 소식이고 하나는 나쁜 소식입니다.

그리고 두 번째 문제는 Entities뿐 아니라 Indexes에도 똑같이 일어납니다. 문서 ID를 랜덤으로 잘 지었더라도 created_at 같은 필드에 인덱스가 걸려 있으면, 그 인덱스의 키 공간에서는 새 값이 항상 맨 끝에 붙습니다. 📘 Standard edition은 모든 필드에 자동으로 인덱스를 만들기 때문에(§3), 이건 의도하지 않아도 생깁니다.

📄 Google은 이 패턴에 index lasering이라는 이름을 붙였고, 논문에 이렇게 적었습니다.

"Index lasering is a traffic pattern in which monotonically increasing or decreasing values are written at a high rate to an index. ... Because the hotspot moves sequentially through a previously empty index key space, the load cannot be effectively split across multiple servers in a range-sharded system."

마지막 문장이 2부에서 반복해서 돌아옵니다. 핫스팟에는 서버를 더 나눠 줘서 풀리는 종류와, 아무리 나눠도 안 풀리는 종류가 있습니다. 단조증가 키는 후자입니다. 꼬리를 쪼개 봐야 새 꼬리가 생기니까요.



6. Spanner 구조 — 논리와 물리의 관계

여기서부터는 Firestore 문서에 없는 이야기입니다. 근거는 📄 Spanner: Google's Globally-Distributed Database (OSDI 2012) 논문과 📘 Cloud 문서입니다.

⚠️ OSDI 논문은 2012년 시점의 스냅샷입니다. 이후 구현이 바뀐 부분이 있을 수 있으므로, 구체적인 수치(특히 §7의 시계 오차)는 "당시 값"으로 읽어 주세요. 반면 구조와 용어는 지금 Cloud 문서와도 일관됩니다.

용어가 여럿 나오는데 논리 단위와 물리 단위가 섞여 있어서 헷갈립니다. 먼저 갈라 놓겠습니다.

구분 용어 정체
논리 split 연속된 키 범위 한 덩어리. 데이터 분배의 단위
논리 Paxos group 📄 "The set of replicas is collectively a Paxos group." 한 split의 복제본들이 이루는 합의 그룹
물리 replica 한 split의 사본 하나. 각각 다른 zone에 놓인다
물리 tablet 📄 replica가 실제로 사는 자료구조. (key, timestamp) → string
물리 spanserver 📄 tablet을 100~1000개 담아 서빙하는 서버 프로세스
물리 zone 📄 배포·물리 격리의 단위이자 복제 위치의 단위
물리 region / multi-region zone들의 묶음. nam5처럼 사용자가 고르는 로케이션

6-1. spanserver 안쪽

📄 spanserver 하나는 tablet을 100~1000개 들고 있고, tablet마다 Paxos state machine이 하나씩 얹혀 있습니다.

graph TB subgraph SS["spanserver 1대 (tablet 100~1000개)"] direction TB LT["lock table<br/><i>2PL 상태 · 리더에만 존재</i>"] TM["transaction manager<br/><i>2PC participant leader</i>"] PX["Paxos state machine<br/><i>tablet 하나당 1개</i>"] TB["tablet<br/><i>(key, timestamp) → string</i>"] LT --> PX TM --> PX PX --> TB end TB --> CO["Colossus<br/><i>B-tree 유사 파일 + write-ahead log</i>"]

몇 가지 중요한 성질이 여기서 나옵니다.

6-2. split 하나가 zone들에 퍼지는 모습

이제 전체 관계도입니다. split 3개가 zone 3개에 복제된 상황을 그리면 이렇습니다.

graph TB subgraph ZA["zone A"] A0["split 0 replica<br/><b>LEADER</b>"] A1["split 1 replica"] A2["split 2 replica"] end subgraph ZB["zone B"] B0["split 0 replica"] B1["split 1 replica<br/><b>LEADER</b>"] B2["split 2 replica"] end subgraph ZC["zone C"] C0["split 0 replica"] C1["split 1 replica"] C2["split 2 replica<br/><b>LEADER</b>"] end A0 -.->|"Paxos group 0"| B0 -.-> C0 A1 -.->|"Paxos group 1"| B1 -.-> C1 A2 -.->|"Paxos group 2"| B2 -.-> C2

읽는 법입니다.

마지막 항목이 실무적으로 중요합니다. "이 DB의 리더 서버"라는 것은 존재하지 않습니다. split마다 리더가 따로 있고 서로 다른 zone에 흩어져 있습니다. 그래서 쓰기가 여러 split에 걸치면 여러 zone을 가로지르는 조율이 필요해집니다(§9).

6-3. nam5는 실제로 어떻게 생겼나

📘 Firestore의 멀티리전 로케이션은 문서에 구성이 공개돼 있습니다.

로케이션 read-write 리전 witness 리전
nam5 (미국 중부) us-central1 (아이오와), us-central2 (오클라호마) us-east1 (사우스캐롤라이나)
eur3 (유럽) europe-west1 (벨기에), europe-west4 (네덜란드) europe-north1 (핀란드)

📘 "Each replica is either a read-write replica which contains all of the data in the database or a witness replica which does not maintain a full set of data but participates in replication."

witness replica는 데이터 전부를 들고 있지 않지만 복제(=합의 투표)에는 참여합니다. 과반을 만드는 데는 기여하되 스토리지 비용은 덜 드는 구성입니다. 리전 하나가 통째로 사라져도 서비스가 계속되는 근거가 이것입니다.

🧩 문서가 말하지 않는 것: 총 replica 개수와, 리더십이 어느 리전에 주로 놓이는지는 공식 문서에 명시돼 있지 않습니다. 인터넷에 "리더는 주 리전에 유지된다"는 설명이 돌아다니지만 저는 출처를 확인하지 못했습니다.


7. TrueTime — 시계에 돈을 쓰는 이유

분산 트랜잭션에서 가장 어려운 문제는 "어느 쪽이 먼저 일어났는가" 입니다. 서버마다 시계가 조금씩 다르기 때문에, 보통은 시계를 믿지 않는 방향으로 설계합니다.

Spanner는 반대로 갔습니다. 시계를 믿을 수 있게 만들어 버렸습니다.

📄 TrueTime API는 시각을 하나의 값이 아니라 구간으로 돌려줍니다.

TT.now()  →  TTinterval [earliest, latest]
              ← 실제 시각은 이 구간 안에 반드시 있다

📄 "TrueTime explicitly represents time as a TTinterval, which is an interval with bounded time uncertainty (unlike standard time interfaces that give clients no notion of uncertainty)."

이 구간의 폭 절반을 ε(엡실론)이라 부릅니다. 논문 시점의 값은 이랬습니다. 📄

7-1. commit wait — 쓰기에 고정으로 붙는 비용

TrueTime이 있으면 트랜잭션에 "실제 시각" 타임스탬프를 붙일 수 있습니다. 그런데 구간에는 폭이 있으므로, 커밋 타임스탬프가 정말로 과거가 될 때까지 기다려야 합니다.

📘 "the leader waits until it can be sure that the transaction's timestamp has passed in real time; this typically requires a few milliseconds ... This is what ensures strong consistency."

이것이 commit wait입니다. 📄 논문의 1-replica 실험에서 commit wait은 약 5ms, Paxos 지연은 약 9ms였습니다.

🔴 여기서 얻는 것과 잃는 것 얻는 것 — external consistency. 트랜잭션 A가 끝난 뒤 시작한 트랜잭션 B는 반드시 더 큰 타임스탬프를 받습니다. 두 트랜잭션이 지구 반대편 서버에서 실행돼도 그렇습니다. 잃는 것 — 모든 쓰기에 수 ms의 고정 비용이 붙습니다. 부하를 줄여도 사라지지 않습니다.

2부에서 "쓰기 지연이 왜 읽기보다 구조적으로 비싼가"를 이야기할 때, 그 바닥에 이 commit wait이 깔려 있습니다.


8. 읽기 경로 — 잠금이 없다

읽기 한 건이 지나가는 길입니다.

sequenceDiagram participant C as 클라이언트 participant API as API 층 participant R as split의 replica participant L as Paxos 리더 C->>API: 읽기 요청 API->>API: TrueTime으로 read timestamp 선택 API->>R: 읽기 요청 + read timestamp alt 충분히 최신이거나 자신이 리더 R-->>C: 바로 응답 else 최신 여부가 불확실 R->>L: 적용해야 할 마지막 트랜잭션 타임스탬프 문의 L-->>R: 응답 R-->>C: 그 트랜잭션 적용 후 응답 end

핵심은 두 문장입니다. 📘

"reads do not acquire any locks in read-only transactions. And because reads can potentially be served by any up-to-date replica of a given split, the read throughput of the system is potentially very high."

8-1. 낡은 읽기가 더 빠른 이유

📘 강한 읽기에서도 replica는 보통 리더에게 RPC를 한 번 날려 "내가 어디까지 적용해야 하느냐"를 묻습니다. 그런데 이 왕복을 없앨 수 있습니다.

"If the client is able to tolerate reads that are at least ten seconds stale, read throughput can be even higher. Because the leader typically updates the replicas with the latest safe timestamp every ten seconds, reads at a stale timestamp may avoid an extra RPC to the leader."

리더가 10초 주기로 안전 타임스탬프를 뿌려 주기 때문에, 10초보다 낡은 시점을 읽겠다고 하면 리더에게 물어볼 필요가 없습니다. 최신성을 조금 포기하는 대가로 왕복 하나를 통째로 아끼는 거래입니다.

랭킹 보드나 목록 화면처럼 몇 초 낡아도 되는 조회라면 검토할 가치가 있습니다.


9. 쓰기 경로 — participant 수가 지연을 정한다

읽기와 달리 쓰기는 리더를 거쳐야 하고, 합의를 해야 하고, 잠금을 잡아야 합니다.

9-1. split 하나 안에서 끝나는 쓰기

sequenceDiagram participant C as 클라이언트 participant API as API 층 participant L as split 리더 participant R as 나머지 replica C->>API: 쓰기 요청 API->>L: 키를 소유한 split의 리더에게 전달 L->>L: 해당 행에 쓰기 잠금 획득 L->>L: TrueTime으로 타임스탬프 부여 L->>R: Paxos - 변경을 복제 R-->>L: 과반이 안정 저장소에 기록 L->>L: commit wait (수 ms) L-->>C: 커밋 완료

📘 리더가 부여하는 타임스탬프는 "guaranteed to be greater than that of any previously committed transaction" 입니다.

9-2. 여러 split에 걸치는 쓰기 — 2PC

문제는 쓰기 한 건이 여러 split을 건드릴 때입니다. 📘

"All of the splits involved in a transaction become participants in the transaction."

이때는 2단계 커밋이 붙습니다. participant마다 잠금을 잡고, 📘 "each participant split records its set of locks by replicating them to (at least) a majority of split replicas" — 즉 잠금 자체를 과반 복제합니다. 그다음 coordinator가 결과를 모든 participant에 알리고, 그제서야 변경이 적용됩니다.

📘 "typically a few more than in the single split case because of the extra cross-split coordination."

그래서 공식 문서가 이렇게 못 박습니다. 📘

"Write/transaction latency increases as the number of splits/participants increases."

9-3. 🔴 그런데 문서 하나를 쓰면 participant가 몇 개가 되는가

여기가 Firestore 특유의 지점입니다. §3에서 본 것처럼 Standard edition은 모든 필드에 자동으로 인덱스를 만듭니다. 그리고 📄 인덱스 엔트리는 같은 트랜잭션 안에서 동기적으로 갱신됩니다.

📄 "in the case of a write to Spanner, all entities and index entries are updated synchronously, and therefore the latency of a write is at least as high as the latency of the slowest index update"

인덱스 엔트리는 §5에서 봤듯 Indexes 테이블의 전혀 다른 키 위치에 놓입니다. 인덱싱된 필드가 많을수록 건드리는 키 범위가 넓어지고, 넓어질수록 걸치는 split이 늘어납니다.

문서 1건 쓰기
   → 인덱싱된 필드 N개의 엔트리를 전부 갱신
   → 그 엔트리들이 흩어진 키 범위가 넓다
   → participant split이 많아진다
   → 2PC 참가자가 늘어난다
   → 지연이 오른다

📘 공식 문서의 표현은 이렇습니다.

"High index fanout for a document write increases the number of database rows to be mutated, which increases the number of participants."

⚠️ 인터넷에는 "인덱스 팬아웃이 쓰기 지연의 가장 큰 기여자"라는 표현이 자주 보이는데, 저는 그 문장을 공식 문서에서 확인하지 못했습니다. 확인되는 것은 "participant 수를 늘린다" 까지입니다. 방향은 같지만 강도가 다르므로 여기서는 확인된 데까지만 적습니다.

9-4. 읽기와 쓰기의 비용 구조 정리

읽기 쓰기
잠금 📘 없음 행 단위 잠금, 다중 split이면 잠금도 과반 복제
어느 replica 📘 최신인 아무 replica 리더만
합의 없음 Paxos, 다중 split이면 + 2PC
인덱스 읽기만 📄 엔트리 전부 동기 갱신
시계 비용 없음 commit wait (수 ms 고정)
낡은 읽기로 완화 📘 ✅

🧩 읽기는 replica 수만큼 늘어나고 쓰기는 리더 하나로 직렬화됩니다. 이 비대칭이 2부에서 실측으로 그대로 드러납니다.


10. split은 부하를 보고 생긴다

이제 §5의 키 공간과 §6의 구조를 합칩니다.

📘 Spanner는 split 경계를 두 가지 계기로 만듭니다.

종류 계기
size-based 데이터가 커지면
load-based split 하나가 자기 컴퓨트 자원을 다 쓰게 되면

📘 "Splitting and merging are dynamic, based on traffic. As a split receives more traffic, Spanner subdivides it into smaller ranges and redistributes the resulting splits over other available resources in the instance."

즉 처리량은 split 수에 비례하고, split 수는 지금까지 받아 본 부하에 비례합니다. 부하를 받은 적이 없는 DB는 split이 적고, split이 적으면 서버도 적게 씁니다.

📘 그리고 확장이 트래픽을 못 따라가면 이렇게 됩니다.

"Splitting is not instantaneous. If the splitting and rebalancing can't keep up with the traffic, a split can potentially use up its available compute and memory resources."

이 한 문장이 2부의 출발점입니다. 런칭은 트래픽이 0에서 최대로 뛰는 사건인데, split은 즉시 생기지 않습니다.

10-1. split으로 풀리는 핫스팟과 안 풀리는 핫스팟

📘 load-based splitting은 "자주 접근되는 행들 사이에 경계를 추가해 서로 다른 서버가 담당하게" 만듭니다. 그런데 이 방식이 통하려면 경계를 그을 만한 자리가 있어야 합니다.

현상 split이 고치는가
넓은 키 범위에 고른 부하 쪼개면 서버가 늘어난다
단조증가 키 (타임스탬프·시퀀스) 꼬리를 쪼개도 새 꼬리가 생긴다
단일 문서 집중 문서 하나보다 작게 쪼갤 수 없다
문서 락 경합 같은 행을 동시에 쓰는 문제라 분할과 무관
인덱스 팬아웃 participant 수를 늘리는 문제(§9-3)

두 번째 줄이 §5에서 예고한 index lasering입니다. 📄 논문의 문장을 다시 인용합니다.

"Because the hotspot moves sequentially through a previously empty index key space, the load cannot be effectively split across multiple servers in a range-sharded system."

핫스팟이 가만히 있지 않고 이동하기 때문에, 경계를 그어도 부하는 이미 다음 자리로 옮겨가 있습니다.

10-2. 🔴 Spanner에는 있고 Firestore에는 없는 것

여기서 2부로 이어지는 결정적인 비대칭이 나옵니다.

📘 Spanner에는 pre-splitting이 있습니다. 런칭 전에 split 경계를 미리 만들어 달라고 요청하는 API입니다.

"By pre-splitting the database, Spanner can be ready for a predictable increased traffic." 언제 쓰는가 — "You're expecting a traffic load increase in an existing Spanner database in the near future. For example, you might need to support a large traffic event like a product launch or a sales campaign."

pre-split 지점은 기본 10일(최대 30일) 유지됩니다.

그런데 Firestore에는 이 API가 없습니다. §2-1에서 본 대로 Firestore는 split을 노출하지도, 조작하게 하지도 않습니다.

Spanner    : "여기에 경계를 만들어 줘"  라고 말할 수 있다   → 선언적
Firestore  : 실제로 부하를 걸어서 만들게 하는 수밖에 없다  → 경험적

🧩 Firestore에서 워밍업이 "데이터를 미리 넣는 일"이 아니라 "목표 부하를 실제로 통과시키는 일"이 되는 이유가 이것입니다. 경계를 요청할 수단이 없으니, 경계가 생길 만한 상황을 만들어 주는 것 외에 방법이 없습니다.

2부는 정확히 이 지점에서 시작합니다.


11. 다른 합의 기반 DB와 비교하면

Spanner만 이렇게 생긴 것은 아닙니다. 같은 계열의 분산 DB들과 나란히 놓으면 Firestore의 성질이 이 제품의 결함인지, 아니면 이 구조를 택하면 따라오는 것인지가 분명해집니다.

샤딩 단위 합의 시계 문제 해법 부하 기반 분할
Spanner / Firestore split Paxos TrueTime (GPS + 원자시계)
CockroachDB range Raft HLC + 최대 시계 오차 가정
YugabyteDB tablet Raft HLC 주로 크기 기반
TiDB / TiKV region Raft TSO — PD가 중앙에서 발급 ⚠️ 분할이 아니라 재배치
etcd 없음 (단일 그룹) Raft
MongoDB chunk (shard key) Raft 유사 선출 ❌ (balancer가 chunk 이동)
Cassandra 토큰 범위 쓰기당 합의 없음 (quorum)

가장 흥미로운 축은 시계입니다. 셋 다 같은 문제를 푸는데 답이 다릅니다.

11-1. 단조증가 키 문제는 Firestore만의 것이 아니다

그리고 §10-1의 핫스팟 이야기가 여기서 확인됩니다. CockroachDB 문서똑같은 한계를 적고 있습니다.

"in the case of a sequential workload hitting one of the range boundaries, there is no suitable split point, which leads to a single range hotspot."

부하 기반 분할이 있는 시스템에서도, 순차 워크로드에는 그을 자리가 없습니다.

단조증가 키 핫스팟은 Firestore의 버그가 아니라 range-sharded 시스템 공통의 구조적 한계입니다.

키를 사전순으로 정렬해 연속 범위로 쪼개는 설계를 택하는 순간 따라오는 대가이고, 그래서 해법도 공통입니다 — 키를 흩뜨리는 것(해시 프리픽스, 샤딩된 인덱스)뿐입니다. 📄 실제로 Google도 이관 과정에서 lasering을 겪는 고객 DB를 위해 투명한 인덱스 샤딩 시스템을 만들었습니다.


12. 정리

1부에서 확인한 것을 압축하면 이렇습니다.

마지막 두 줄이 2부의 문제의식입니다. 확장은 부하를 따라오는 것인데, 런칭은 부하가 먼저 도착하는 사건입니다. 그 사이의 간격에서 무슨 일이 일어나는지, 그리고 그 간격을 미리 메울 수 있는지를 실제로 부하를 걸어 확인한 기록이 2부입니다.

거기서는 이런 것들을 다룹니다.

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


참고 문헌

# 문서
📄 Transparent Migration from Datastore to Firestore (PVLDB 17(12), 2024)
📄 Spanner: Google's Globally-Distributed Database (OSDI 2012)
📘 Understand reads and writes at scale (Firestore)
📘 Life of Spanner reads and writes
📘 Firestore editions overview
📘 Firestore locations
📘 Spanner pre-splitting overview
📘 Firestore with MongoDB compatibility GA
📘 CockroachDB Load-Based Splitting