인덱스 동작 원리, 비용 모델, 컬렉션 그룹 쿼리와 캐싱으로 읽기 성능과 요금을 최적화한다.
쿼리 성능과 인덱스 최적화
입문편에서 where, orderBy, limit, startAfter 같은 쿼리 API를 익히고, 콘솔에 뜨는 에러 링크를 클릭해 인덱스를 생성하는 방법까지 살펴봤습니다. 이번 레슨에서는 한 발 더 나아가 "왜 그 인덱스가 필요한가", "인덱스가 쿼리를 어떻게 풀어내는가", 그리고 "인덱스를 늘릴수록 저장 비용과 쓰기 지연이 어떻게 달라지는가"를 파헤칩니다.
Firestore는 서버리스 분산 데이터베이스이기 때문에 쿼리 성능이 전통적인 RDBMS와 다르게 동작합니다. 인덱스 구조를 이해하면 과도한 복합 인덱스를 제거해 인덱스 저장 비용과 쓰기 지연을 줄이고, 오프라인 캐시와 페이지네이션 전략으로 읽기 요금도 함께 절감할 수 있습니다.
학습 목표
- 단일 필드 인덱스와 복합 인덱스가 내부에서 어떤 구조로 저장되고 쿼리를 푸는지 설명할 수 있다.
- 인덱스 머지(merge) 동작 원리를 이해하고, 불필요한 복합 인덱스를 제거하는 기준을 세울 수 있다.
- Firestore의 읽기·쓰기·삭제 과금 모델을 이해하고, 인덱스가 저장 비용·쓰기 지연에 미치는 트레이드오프를 설명할 수 있다.
- 컬렉션 그룹 쿼리로 서브컬렉션을 가로지르는 패턴을 구현할 수 있다.
- 오프라인 영속성, 로컬 캐시, 커서 기반 페이지네이션으로 읽기 요청 수를 실질적으로 줄일 수 있다.
인덱스 내부 구조: 단일 필드 인덱스와 복합 인덱스
단일 필드 인덱스
Firestore는 모든 문서를 저장할 때 각 필드에 대해 **자동으로 단일 필드 인덱스(Single-field index)**를 생성합니다. 인덱스 엔트리는 개념적으로 다음과 같이 정렬된 키-값 쌍으로 볼 수 있습니다.
(collection, fieldName, fieldValue ASC, docId)
(collection, fieldName, fieldValue DESC, docId)
예를 들어 posts 컬렉션에 { author: "alice", createdAt: 1700000000 } 문서가 있다면:
(posts, author, "alice", doc_A)
(posts, createdAt, 1700000000, doc_A)
두 개의 인덱스 레코드가 자동 생성됩니다. 따라서 where("author", "==", "alice") 단독 쿼리는 이 인덱스 하나만으로 해결됩니다.
복합 인덱스
where("author", "==", "alice").orderBy("createdAt", "desc") 처럼 여러 필드를 조합하는 쿼리는 **복합 인덱스(Composite index)**가 필요합니다. 복합 인덱스 엔트리는 모든 지정 필드의 값이 결합된 정렬 키를 갖습니다.
(posts, author ASC + createdAt DESC, "alice" + 1700000001, doc_B)
(posts, author ASC + createdAt DESC, "alice" + 1700000000, doc_A)
Firestore는 이 결합 키를 단순 범위 스캔 하나로 해결하기 때문에 N개 결과를 찾는 데 O(N) 읽기만 발생합니다. 반면 단일 필드 인덱스 두 개를 각각 스캔한 뒤 교집합을 구하는 방식(인덱스 머지)은 중간 결과 집합이 클 경우 훨씬 비쌉니다.
💡 TIP Firestore 콘솔 → 인덱스 탭에서 복합 인덱스를 확인하고
firestore.indexes.json으로 버전 관리하세요. 배포 시firebase deploy --only firestore:indexes명령으로 동기화할 수 있습니다.
인덱스 머지와 불필요한 복합 인덱스 제거
인덱스 머지 동작 원리
Firestore는 단일 쿼리 안에서 여러 등호(==) where 조건이 AND로 결합될 때, 조건별로 단일 필드 인덱스를 각각 스캔한 뒤 결과 문서 ID를 교집합(AND)하는 **인덱스 머지(zigzag merge join)**를 수행합니다. 본 레슨에서 다루는 인덱스 머지는 이 AND 교집합 메커니즘을 가리킵니다. (합집합 OR 조건은 별도의 or() 복합 필터 기능으로 처리되며, 위의 단일 필드 인덱스 머지와는 다른 메커니즘입니다.)
예를 들어 아래 쿼리는 복합 인덱스 없이도 실행될 수 있습니다.
// ✅ 두 단일 필드 인덱스를 머지 — 복합 인덱스 불필요
db.collection("products")
.where("inStock", "==", true)
.where("category", "==", "electronics")
.get();
그러나 머지는 비교 연산(<, >, <=, >=, !=, array-contains-any 등)이 개입하거나 orderBy가 붙는 순간 작동하지 않습니다. 이때는 반드시 복합 인덱스가 있어야 합니다.
// ❌ 복합 인덱스 없으면 실패 — orderBy 필드가 where 필드와 다름
db.collection("products")
.where("category", "==", "electronics")
.orderBy("price", "asc")
.get();
불필요한 복합 인덱스 제거 기준
| 상황 | 인덱스 필요 여부 |
|---|---|
등호(==) 단독, 단일 필드 | 자동 생성됨 — 직접 생성 불필요 |
등호 두 개, orderBy 없음 | 인덱스 머지로 처리 가능 |
범위 조건 + orderBy | 복합 인덱스 필요 |
orderBy 두 필드 이상 | 복합 인덱스 필요 |
array-contains + orderBy | 복합 인덱스 필요 |
복합 인덱스를 무분별하게 늘려도 과금되는 쓰기 작업 수는 문서당 1회로 변하지 않지만, 인덱스 저장 용량 비용과 쓰기 지연(latency)이 인덱스 수에 따라 선형으로 증가합니다. 사용하지 않는 복합 인덱스는 콘솔이나 firestore.indexes.json에서 즉시 삭제하세요.
// firestore.indexes.json — 인덱스 버전 관리 예시
{
"indexes": [
{
"collectionGroup": "posts",
"queryScope": "COLLECTION",
"fields": [
{ "fieldPath": "author", "order": "ASCENDING" },
{ "fieldPath": "createdAt", "order": "DESCENDING" }
]
}
],
"fieldOverrides": []
}
⚠️ 주의 복합 인덱스를 삭제하면 해당 인덱스를 사용하는 쿼리가 즉시 실패합니다. 프로덕션에서 삭제 전 반드시 쿼리 사용 현황을 Cloud Logging으로 확인하세요.
읽기·쓰기·삭제 과금 모델과 인덱스 비용 트레이드오프
Firestore 과금 단위
Firestore는 작업 단위로 과금됩니다. 데이터 크기에 따른 과금은 저장 용량과 네트워크 이그레스뿐이며, 쿼리 실행은 반환된 문서 읽기 횟수로만 계산됩니다.
| 작업 | 과금 단위 |
|---|---|
| 문서 읽기 | 반환된 문서 1개당 1 읽기 |
| 문서 쓰기(생성/업데이트) | 인덱스 개수와 무관하게 작업당 1 쓰기 |
| 문서 삭제 | 인덱스 개수와 무관하게 작업당 1 삭제 |
| 트랜잭션 내 읽기 | 동일하게 문서당 1 읽기 |
인덱스가 쓰기에 미치는 영향: 저장 용량과 지연 트레이드오프
문서 쓰기/삭제는 인덱스 엔트리 수와 무관하게 작업당 1 write / 1 delete로만 과금됩니다. 인덱스 업데이트는 별도의 과금 쓰기 작업으로 청구되지 않습니다. 즉 인덱스를 늘려도 과금되는 쓰기 작업 수는 문서당 1회로 변하지 않습니다.
인덱스가 늘어날 때 실제로 증가하는 것은 다음 두 가지입니다.
인덱스 증가 시 늘어나는 것
(1) 인덱스 저장 용량 비용 (저장 용량 과금에 반영)
(2) 쓰기 지연(latency) / 처리량 — 더 많은 인덱스 엔트리를 갱신해야 하므로
※ 과금되는 쓰기 작업 수(write count)는 인덱스 개수와 무관하게 문서당 1회로 동일
아래 예시를 살펴보겠습니다.
// posts 문서: { author, category, status, createdAt, score }
// 단일 필드 인덱스 / 복합 인덱스가 몇 개든
// 이 add() 호출은 과금 기준 1 write 입니다.
// (다만 인덱스가 많을수록 저장 용량과 쓰기 지연은 늘어납니다)
await db.collection("posts").add({
author: "alice",
category: "tech",
status: "published",
createdAt: serverTimestamp(),
score: 98,
});
복합 인덱스가 10개로 늘어나도 동일 문서 쓰기는 여전히 1 write로 과금됩니다. 다만 인덱스 엔트리가 많아질수록 인덱스 저장 용량 비용과 쓰기 지연이 함께 증가하므로, 사용하지 않는 복합 인덱스는 저장 비용과 지연을 줄이기 위해 제거하는 것이 좋습니다.
단일 필드 인덱스 면제(Exemption)
쿼리 조건으로 절대 사용되지 않는 필드(예: 긴 description 텍스트, 플래그 배열 등)는 **인덱스 면제(Field exemption)**를 설정해 불필요한 인덱스 레코드를 생략할 수 있습니다.
# Firebase CLI로 필드 면제 배포
firebase deploy --only firestore:indexes
// firestore.indexes.json — description 필드 인덱스 면제
{
"indexes": [],
"fieldOverrides": [
{
"collectionGroup": "posts",
"fieldPath": "description",
"indexes": []
}
]
}
⚠️ 주의 면제된 필드는 단독
where조건이나orderBy에 사용할 수 없습니다. 전문 검색이 필요하다면 Algolia나 Typesense 같은 외부 검색 엔진을 연동하세요.
컬렉션 그룹 쿼리로 서브컬렉션 가로지르기
컬렉션 그룹 쿼리란
Firestore는 트리 구조로 중첩 데이터를 저장합니다. 예를 들어 users/{uid}/posts 서브컬렉션이 있다면, 특정 사용자의 게시물은 쉽게 조회할 수 있지만 모든 사용자의 게시물 중 status == "published"인 것만 뽑으려면 단순 컬렉션 쿼리로는 불가능합니다. 이때 사용하는 것이 collectionGroup 쿼리입니다.
// ✅ 모든 users/{uid}/posts 서브컬렉션을 한 번에 쿼리
const snapshot = await db
.collectionGroup("posts")
.where("status", "==", "published")
.orderBy("createdAt", "desc")
.limit(20)
.get();
snapshot.forEach((doc) => {
console.log(doc.ref.path, doc.data());
});
컬렉션 그룹 인덱스 설정
collectionGroup 쿼리는 반드시 queryScope가 COLLECTION_GROUP인 인덱스가 필요합니다.
{
"indexes": [
{
"collectionGroup": "posts",
"queryScope": "COLLECTION_GROUP",
"fields": [
{ "fieldPath": "status", "order": "ASCENDING" },
{ "fieldPath": "createdAt", "order": "DESCENDING" }
]
}
]
}
💡 TIP
collectionGroup쿼리는 동일 이름의 서브컬렉션이라면 깊이에 상관없이 모두 포함합니다.users/{uid}/posts뿐만 아니라teams/{tid}/posts도 함께 반환되므로, 보안 규칙에서resource.data.ownerId등으로 접근 제어를 명시적으로 걸어야 합니다.
보안 규칙 연동 예시
// firestore.rules
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
// 컬렉션 그룹 규칙: 경로 와일드카드 사용
match /{path=**}/posts/{postId} {
allow read: if resource.data.status == "published"
|| request.auth.uid == resource.data.authorId;
allow write: if request.auth.uid == resource.data.authorId;
}
}
}
오프라인 영속성과 로컬 캐시를 활용한 읽기 절감
오프라인 영속성 활성화
Firestore Web SDK v9 이상에서는 명시적으로 IndexedDB 기반 영속 캐시를 초기화할 수 있습니다.
import { initializeApp } from "firebase/app";
import {
initializeFirestore,
persistentLocalCache,
persistentMultipleTabManager,
} from "firebase/firestore";
const app = initializeApp(firebaseConfig);
// ✅ 멀티 탭 지원 영속 캐시 활성화
const db = initializeFirestore(app, {
localCache: persistentLocalCache({
tabManager: persistentMultipleTabManager(),
}),
});
영속 캐시가 활성화되면 앱을 재시작해도 캐시된 데이터를 먼저 반환하고, 서버 업데이트가 있을 때만 추가 읽기가 발생합니다. 이는 초기 렌더링 속도와 요금 모두에 유리합니다.
캐시 우선 전략과 Source 지정
특정 쿼리에서 서버 요청 없이 캐시만 읽으려면 source 옵션을 명시합니다.
import { getDocsFromCache, getDocsFromServer, collection, query, where } from "firebase/firestore";
const q = query(
collection(db, "products"),
where("category", "==", "electronics")
);
// ✅ 캐시에서만 읽기 — 읽기 과금 없음
try {
const snapshot = await getDocsFromCache(q);
renderProducts(snapshot.docs);
} catch (e) {
// 캐시 miss → 서버 폴백
const snapshot = await getDocsFromServer(q);
renderProducts(snapshot.docs);
}
⚠️ 주의
getDocsFromCache는 캐시에 결과가 없으면 예외를 던집니다. 반드시try/catch로 서버 폴백 경로를 마련하세요. 캐시가 오래됐을 때의 정합성 문제도 비즈니스 로직에서 고려해야 합니다.
실시간 구독에서 캐시 활용
onSnapshot은 기본적으로 캐시 데이터를 먼저 발행한 뒤 서버 업데이트를 발행합니다. fromCache 플래그로 구분할 수 있습니다.
import { onSnapshot, collection } from "firebase/firestore";
const unsubscribe = onSnapshot(
collection(db, "notifications"),
{ includeMetadataChanges: true },
(snapshot) => {
snapshot.docChanges().forEach((change) => {
if (change.doc.metadata.fromCache) {
// 캐시에서 온 데이터 — UI에 "로딩 중" 표시 불필요
renderFromCache(change.doc.data());
} else {
// 서버에서 온 최신 데이터
renderFresh(change.doc.data());
}
});
}
);
쿼리 폭증 방지: 페이지 크기, 커서 전략, 인덱스 예외
커서 기반 페이지네이션의 비용 구조
offset을 사용하면 스킵한 문서도 모두 읽기로 계산됩니다. Firestore에서 offset이 존재하지 않는 이유가 바로 이 때문입니다. 커서(startAfter, startAt)는 마지막으로 읽은 문서 스냅샷을 기준으로 다음 페이지를 시작하므로, 불필요한 읽기가 발생하지 않습니다.
// ✅ 커서 기반 페이지네이션 — 읽기 효율 최적
let lastVisible = null;
async function loadNextPage() {
let q = query(
collection(db, "posts"),
where("status", "==", "published"),
orderBy("createdAt", "desc"),
limit(10)
);
if (lastVisible) {
q = query(q, startAfter(lastVisible));
}
const snapshot = await getDocs(q);
lastVisible = snapshot.docs[snapshot.docs.length - 1];
return snapshot.docs.map((d) => d.data());
}
적절한 페이지 크기 선택
| 페이지 크기 | 읽기 횟수 | 트레이드오프 |
|---|---|---|
| 10 | 10 | 네트워크 왕복 잦음, 캐시 히트율 낮음 |
| 25~50 | 25~50 | 대부분 앱에 적절한 균형점 |
| 100 이상 | 100+ | 첫 로드 비용 높음, 초과 데이터 캐시 낭비 |
💡 TIP 무한 스크롤 UI에서는
limit(25)를 기본값으로 삼고, 사용자가 스크롤 끝에 도달할 때만 다음 페이지를 로드하세요. 첫 화면에서 전체 목록을 한 번에 가져오는 것은 대표적인 과금 낭비 패턴입니다.
인덱스 예외(Index Exemption)로 쿼리 범위 좁히기
특정 컬렉션에서 일부 필드를 인덱스에서 완전히 제외하면 그 필드에 대한 쿼리 자체가 불가능해지지만, 해당 필드의 인덱스 엔트리가 생성되지 않아 인덱스 저장 용량과 쓰기 지연이 줄어듭니다(과금되는 쓰기 작업 수는 문서당 1회로 동일합니다). 로그성 데이터나 이벤트 스트림처럼 쿼리가 불필요한 컬렉션에 유용합니다.
// firestore.indexes.json — 로그 컬렉션의 payload 필드 전체 인덱스 면제
{
"indexes": [],
"fieldOverrides": [
{
"collectionGroup": "eventLogs",
"fieldPath": "payload",
"indexes": []
},
{
"collectionGroup": "eventLogs",
"fieldPath": "rawData",
"indexes": []
}
]
}
이 설정으로 eventLogs 컬렉션에 문서를 쓸 때 payload와 rawData 필드에 대한 인덱스 레코드가 생성되지 않아 인덱스 저장 용량과 쓰기 지연이 감소합니다(과금되는 쓰기 작업 수는 문서당 1회로 동일합니다).
쿼리 폭증 방지를 위한 실무 체크리스트
다음은 프로덕션 배포 전 점검해야 할 항목들입니다.
// ❌ 안티패턴 1: 조건 없는 전체 컬렉션 읽기
const all = await getDocs(collection(db, "users")); // 사용자 수만큼 읽기 발생
// ✅ 개선: 필요한 범위만 한정
const active = await getDocs(
query(collection(db, "users"), where("isActive", "==", true), limit(50))
);
// ❌ 안티패턴 2: 컴포넌트 마운트마다 구독 재설정
useEffect(() => {
const unsub = onSnapshot(doc(db, "settings", "global"), handler);
return unsub; // 해제는 하지만 마운트마다 서버 읽기 1회 발생
}, [userId]); // userId가 자주 바뀌면 그때마다 읽기
// ✅ 개선: 상위 컴포넌트에서 1회 구독 후 Context로 공유
요약
- Firestore 복합 인덱스는 여러 필드의 값을 합친 정렬 키로 저장되어 범위 스캔 하나로 쿼리를 해결하므로, 등호 조건만 있는 쿼리에는 인덱스 머지로 충분하고 복합 인덱스가 불필요할 수 있다.
- 문서 쓰기/삭제는 인덱스 개수와 무관하게 작업당 1회로 과금되며, 복합 인덱스 수가 증가하면 과금되는 쓰기 작업 수가 아니라 인덱스 저장 비용과 쓰기 지연(latency)이 선형으로 증가한다. 사용하지 않는 복합 인덱스와 불필요한 단일 필드 인덱스는 면제(exemption)로 제거해 저장 비용과 지연을 줄여야 한다.
collectionGroup쿼리는 동일 이름의 서브컬렉션을 모두 가로지르며, 반드시COLLECTION_GROUP스코프의 복합 인덱스와 보안 규칙을 함께 설정해야 한다.- 오프라인 영속 캐시를 활성화하면 앱 재시작 시 캐시 데이터를 먼저 반환해 초기 읽기를 줄일 수 있으며,
getDocsFromCache로 명시적 캐시 우선 전략을 구현할 수 있다. - 커서 기반 페이지네이션(
startAfter)은 스킵한 문서에 과금이 없어 오프셋 방식보다 비용 효율이 높다. 페이지 크기는 25~50이 대부분의 앱에 적절하다. firestore.indexes.json으로 인덱스를 버전 관리하고firebase deploy --only firestore:indexes로 배포해 팀 전체가 동일한 인덱스 상태를 유지해야 한다.
연습문제
-
orders컬렉션에{ userId, status, amount, createdAt }필드가 있습니다.userId == "u1"이고status == "pending"인 주문을createdAt내림차순으로 조회하려 할 때, 필요한 인덱스 구성과 그 이유를 설명하고firestore.indexes.json항목을 작성하세요.힌트 등호 조건 두 개와
orderBy가 함께 있을 때 인덱스 머지가 적용되는지 확인하세요. -
companies/{companyId}/invoices서브컬렉션이 여러 회사에 걸쳐 존재합니다. 모든 회사의dueDate < today인 미납 인보이스를 한 번에 조회하는 코드와 필요한 인덱스를 작성하세요.힌트
collectionGroup과 범위 조건을 조합할 때queryScope를 어떻게 설정해야 하는지 확인하세요. -
사용자가 앱을 열 때마다 동일한
products쿼리가 서버에서 실행되어 불필요한 읽기가 발생하고 있습니다. 영속 캐시를 활성화하고 캐시 우선 전략을 적용하는 코드를 작성하되, 캐시 miss 시 서버 폴백을 포함하세요.힌트
initializeFirestore의localCache옵션과getDocsFromCache를 함께 사용하세요. -
현재 앱에서
eventLogs컬렉션에{ eventType, payload, rawData, timestamp }필드를 가진 문서를 분당 500건씩 씁니다.payload와rawData는 검색 조건으로 절대 사용하지 않습니다. 인덱스 저장 비용과 쓰기 지연을 줄이기 위한fieldOverrides설정을 작성하고, 이 면제가 과금되는 쓰기 작업 수에 영향을 주는지, 실제로 어떤 비용이 절감되는지 설명하세요.힌트 문서 쓰기는 인덱스 개수와 무관하게 작업당 1 write로 과금됩니다. 면제로 줄어드는 것이 과금 쓰기 작업 수인지, 인덱스 저장 용량과 쓰기 지연인지 구분해 보세요.
💡 연습문제 풀이
불러오는 중…
댓글 0
“Firebase 심화” 강좌에 대한 댓글입니다.