NoSQL 비정규화, 컬렉션 구조 설계, 집계와 팬아웃 패턴으로 확장 가능한 데이터 모델을 설계한다.
데이터 모델링과 아키텍처 설계
입문편에서 Firestore 문서와 컬렉션을 읽고 쓰는 기본 API를 익혔다면, 이제 진짜 질문이 시작됩니다. "어떤 구조로 저장해야 하는가?" Firestore는 SQL 데이터베이스와 달리 JOIN이 없고, 쿼리는 단일 컬렉션(또는 컬렉션 그룹) 범위 내에서만 동작합니다. 이 제약은 단점이 아니라 설계 원칙의 전환을 요구합니다. 읽기 패턴 중심으로 데이터를 배치하면 수백만 사용자가 붙어도 선형으로 확장됩니다.
이 레슨은 실무에서 자주 마주치는 의사결정 지점들—정규화와 비정규화의 경계, 서브컬렉션 vs 루트 컬렉션, 대용량 문서 분할, 집계 카운터, 팬아웃 패턴—을 다루며, 관계형 사고 방식에서 벗어나 NoSQL 설계 감각을 익히는 데 집중합니다.
학습 목표
- 정규화 vs 비정규화 트레이드오프를 읽기 비용 관점에서 판단할 수 있다.
- 서브컬렉션과 루트 컬렉션 구조를 적합한 기준으로 선택할 수 있다.
- 문서 1MB 한계와 배열·맵의 제약을 고려하여 데이터를 올바르게 분할할 수 있다.
- 집계 카운터와 요약 문서 패턴으로 비용 효율적인 읽기 구조를 설계할 수 있다.
- 팬아웃 쓰기 패턴으로 피드·타임라인을 모델링할 수 있다.
정규화 대 비정규화: 읽기 비용을 기준으로 한 모델 선택
관계형 데이터베이스에서는 중복을 최소화하는 정규화가 기본 원칙입니다. 하지만 Firestore에서는 읽기 요청 횟수가 곧 비용이고 성능입니다. 화면 한 번 렌더링에 문서를 10개 읽어야 한다면, 그 비용은 1개를 읽는 것보다 10배 높습니다.
비정규화의 핵심 원칙은 "화면 하나 = 문서 하나(또는 최소한의 쿼리)" 입니다. 자주 함께 조회되는 데이터는 같은 문서 또는 같은 컬렉션에 두세요.
// ❌ 정규화된 구조 — 게시글 목록 렌더링에 N+1 읽기 발생
// posts/{postId} 문서에는 authorId만 있고, 작성자 이름을 별도 조회해야 함
const post = await getDoc(doc(db, 'posts', postId));
const author = await getDoc(doc(db, 'users', post.data().authorId)); // 게시글마다 반복
// ✅ 비정규화 — 작성자 정보를 posts 문서에 내장
// posts/{postId}
{
title: "Firestore 설계 전략",
content: "...",
authorId: "uid_abc",
authorName: "김개발", // users 문서에서 복사
authorPhotoURL: "https://...",
createdAt: Timestamp,
likeCount: 0
}
물론 비정규화에는 비용이 따릅니다. 사용자가 이름을 변경하면 해당 사용자의 모든 게시글의 authorName을 업데이트해야 합니다. 이 트레이드오프를 판단하는 기준은 다음과 같습니다.
| 기준 | 정규화 선호 | 비정규화 선호 |
|---|---|---|
| 데이터 변경 빈도 | 자주 바뀜 | 거의 바뀌지 않음 |
| 읽기 빈도 | 낮음 | 높음 |
| 일관성 요구 수준 | 즉각적 일관성 필요 | 최종 일관성 허용 |
| 연관 문서 수 | 소수 | 수천~수백만 |
💡 TIP 사용자의
displayName처럼 드물게 바뀌지만 여러 곳에서 읽히는 필드는 비정규화의 좋은 후보입니다. 반면 재고 수량처럼 실시간으로 바뀌고 정확성이 중요한 필드는 정규화를 유지하세요.
중첩 vs 루트 컬렉션과 서브컬렉션 구조 결정 기준
Firestore에서 컬렉션 구조를 결정하는 가장 핵심적인 질문은 "이 데이터를 부모 없이도 독립적으로 쿼리할 필요가 있는가?"입니다.
서브컬렉션을 선택해야 할 때
부모 문서에 종속되며 부모 없이는 의미가 없는 데이터는 서브컬렉션에 둡니다. 대화방의 메시지, 주문의 아이템 목록, 게시글의 댓글이 대표적입니다.
// ✅ 서브컬렉션 — 댓글은 게시글에 종속
// posts/{postId}/comments/{commentId}
await addDoc(collection(db, 'posts', postId, 'comments'), {
text: "좋은 글 감사합니다.",
authorId: "uid_xyz",
authorName: "이댓글",
createdAt: serverTimestamp()
});
// 특정 게시글의 댓글만 쿼리
const commentsRef = collection(db, 'posts', postId, 'comments');
const q = query(commentsRef, orderBy('createdAt', 'desc'), limit(20));
루트 컬렉션을 선택해야 할 때
여러 부모에 걸쳐 쿼리해야 하거나 독립적인 생명주기를 가진 데이터는 루트 컬렉션에 둡니다. 예를 들어 "특정 사용자가 작성한 모든 댓글"을 조회해야 한다면 서브컬렉션 구조로는 모든 게시글을 순회해야 합니다.
// ✅ 루트 컬렉션 — 여러 부모에 걸친 쿼리가 필요한 경우
// comments/{commentId}
{
postId: "post_abc",
text: "좋은 글 감사합니다.",
authorId: "uid_xyz",
createdAt: Timestamp
}
// 특정 사용자의 전체 댓글을 한 번에 조회 가능
const q = query(
collection(db, 'comments'),
where('authorId', '==', currentUserId),
orderBy('createdAt', 'desc')
);
컬렉션 그룹 쿼리: 두 방식을 절충하기
서브컬렉션에 두면서도 전체를 쿼리해야 한다면 collectionGroup을 활용하세요. 단, 이를 위해서는 Cloud Firestore 콘솔에서 해당 컬렉션 ID에 대한 컬렉션 그룹 인덱스를 생성해야 합니다.
import { collectionGroup, query, where, getDocs } from 'firebase/firestore';
// posts/{postId}/comments 서브컬렉션 전체를 한 번에 검색
const q = query(
collectionGroup(db, 'comments'),
where('authorId', '==', currentUserId)
);
const snapshot = await getDocs(q);
⚠️ 주의
collectionGroup쿼리는 동일한 컬렉션 ID를 가진 모든 서브컬렉션을 포함합니다. 보안 규칙에서match /{path=**}/comments/{commentId}패턴으로 접근을 반드시 제어하세요.
문서 1MB 한계와 배열·맵의 한계를 고려한 데이터 분할
Firestore의 문서 하나는 1MB를 초과할 수 없습니다. 또한 배열 필드는 요소 수에 공식 제한은 없지만, 배열을 통째로 읽고 써야 한다는 특성상 수백 개를 넘기면 성능이 저하됩니다. 맵 필드의 중첩 깊이는 20단계까지 허용되지만, 깊이 중첩된 구조는 쿼리 불가 필드가 늘어나 실용성이 떨어집니다.
언제 분할이 필요한가
채팅 메시지를 배열에 누적하거나, 팔로워 목록을 하나의 문서에 저장하는 구조는 빠르게 한계에 도달합니다.
// ❌ 배열에 무한정 누적 — 1MB 한도와 동시 쓰기 충돌 위험
// users/{userId}
{
followers: ["uid_1", "uid_2", "uid_3", ...] // 팔로워가 늘수록 문서 크기 증가
}
// ✅ 서브컬렉션으로 분리 — 무제한 확장 가능
// users/{userId}/followers/{followerId}
{
followedAt: Timestamp,
displayName: "팔로워이름" // 비정규화로 목록 렌더링 최적화
}
청크 패턴: 필드가 동적으로 증가하는 경우
맵 필드에 동적 키를 추가하는 패턴(예: { [userId]: true } 형태의 좋아요 목록)도 문서 크기 한계에 부딪힙니다. 이 경우 청크 문서로 분산합니다.
// ❌ 단일 문서에 동적 키 누적
// posts/{postId}
{
likes: {
"uid_001": true,
"uid_002": true,
// ... 수만 명이 좋아요를 누르면 1MB 초과
}
}
// ✅ 서브컬렉션으로 분산
// posts/{postId}/likes/{userId}
{
likedAt: Timestamp
}
// 좋아요 여부 확인
const likeRef = doc(db, 'posts', postId, 'likes', currentUserId);
const likeSnap = await getDoc(likeRef);
const isLiked = likeSnap.exists();
💡 TIP 숫자형 집계(총 좋아요 수)는 서브컬렉션 문서 수를 세는 대신 별도 카운터 필드를 유지하세요. 서브컬렉션 문서 수를
count()쿼리로 세는 것은 가능하지만, 자주 조회되는 집계 값은 다음 섹션의 집계 카운터 패턴이 훨씬 저렴합니다.
집계 카운터와 요약 문서로 읽기 비용 줄이기
"게시글의 좋아요 수가 몇 개인가?", "상품의 리뷰 평균 점수는?" 같은 집계 값을 매번 서브컬렉션을 전부 읽어서 계산하면 비용이 폭증합니다. 집계 카운터 패턴은 쓰기 시점에 미리 집계 값을 계산해 두는 방식입니다.
원자적 카운터 업데이트
Firestore의 increment() 함수를 사용하면 동시성 문제 없이 카운터를 업데이트할 수 있습니다.
import { doc, runTransaction, increment, serverTimestamp } from 'firebase/firestore';
// 좋아요 추가 + 카운터 동기화를 트랜잭션으로 묶기
async function addLike(postId, userId) {
const postRef = doc(db, 'posts', postId);
const likeRef = doc(db, 'posts', postId, 'likes', userId);
await runTransaction(db, async (transaction) => {
const likeSnap = await transaction.get(likeRef);
if (likeSnap.exists()) {
throw new Error('이미 좋아요를 눌렀습니다.');
}
transaction.set(likeRef, { likedAt: serverTimestamp() });
transaction.update(postRef, { likeCount: increment(1) });
});
}
// 좋아요 취소
async function removeLike(postId, userId) {
const postRef = doc(db, 'posts', postId);
const likeRef = doc(db, 'posts', postId, 'likes', userId);
await runTransaction(db, async (transaction) => {
const likeSnap = await transaction.get(likeRef);
if (!likeSnap.exists()) return;
transaction.delete(likeRef);
transaction.update(postRef, { likeCount: increment(-1) });
});
}
분산 카운터: 초당 1회 제한 우회하기
Firestore 단일 문서는 초당 최대 1회 쓰기 제한이 있습니다. 좋아요가 초당 수십 번 발생하는 인기 게시글이라면 단순 카운터가 병목이 됩니다. 이때 분산 카운터(Sharded Counter) 패턴을 사용합니다.
const SHARD_COUNT = 10;
// 쓰기: 랜덤 샤드에 분산
// updateDoc()은 대상 문서가 없으면 'NOT_FOUND' 오류를 던지므로,
// setDoc(..., { merge: true })로 샤드가 없으면 생성하고 있으면 누적한다.
async function incrementShardedCounter(docRef) {
const shardId = Math.floor(Math.random() * SHARD_COUNT).toString();
const shardRef = doc(docRef, 'shards', shardId);
await setDoc(shardRef, { count: increment(1) }, { merge: true });
}
// 읽기: 모든 샤드 합산
async function getShardedCount(docRef) {
const shardsRef = collection(docRef, 'shards');
const snapshot = await getDocs(shardsRef);
return snapshot.docs.reduce((total, doc) => total + (doc.data().count || 0), 0);
}
⚠️ 주의 분산 카운터의 읽기는 샤드 수만큼 문서를 읽으므로 읽기 비용이 높아집니다. 읽기보다 쓰기가 훨씬 빈번한 경우에만 사용하세요. 대부분의 서비스에서는 단순 카운터로 충분합니다.
요약 문서 패턴
집계가 복잡하거나(평균 점수, 분포 통계 등) 여러 컬렉션에 걸쳐 있다면 Cloud Functions로 요약 문서를 별도 생성합니다. 예를 들어 제품 리뷰 통계를 별도 문서로 분리합니다.
// Cloud Functions (v2) — 리뷰 추가 시 요약 문서 업데이트
import { onDocumentCreated } from 'firebase-functions/v2/firestore';
import { getFirestore, increment } from 'firebase-admin/firestore';
export const onReviewCreated = onDocumentCreated(
'products/{productId}/reviews/{reviewId}',
async (event) => {
const review = event.data?.data();
if (!review) return;
const db = getFirestore();
const statsRef = db.doc(`products/${event.params.productId}/meta/stats`);
await statsRef.set(
{
reviewCount: increment(1),
ratingSum: increment(review.rating),
// 클라이언트에서 ratingSum / reviewCount 로 평균 계산
},
{ merge: true }
);
}
);
팬아웃(fan-out) 쓰기 패턴으로 피드·타임라인 모델링
SNS 피드나 알림 타임라인은 Firestore 설계에서 가장 까다로운 주제입니다. "내가 팔로우하는 사람들의 게시글을 최신순으로 보여줘"라는 요구사항을 어떻게 구현할까요?
풀(Pull) 모델의 문제점
팔로잉 목록을 가져온 후 각각의 게시글을 쿼리하는 방식은 Firestore에서 동작하지 않습니다. where('authorId', 'in', followingList) 쿼리는 in 배열 최대 30개 제한이 있고, 팔로잉이 수백 명이면 여러 번의 쿼리가 필요합니다.
// ❌ 풀 모델 — in 쿼리 30개 제한, 팔로잉이 많으면 다수의 쿼리 필요
const followingIds = ['uid_1', 'uid_2', ..., 'uid_200']; // 200명
// 30개씩 청크로 나눠 7번 쿼리, 결과를 메모리에서 병합해야 함
팬아웃(Push) 모델
팬아웃은 게시글이 작성될 때 팔로워 각각의 피드 컬렉션에 해당 게시글 정보를 복사(또는 참조)하는 패턴입니다. 쓰기 비용이 증가하지만 읽기가 극적으로 단순해집니다.
// Cloud Functions — 게시글 생성 시 팔로워 피드에 팬아웃
import { onDocumentCreated } from 'firebase-functions/v2/firestore';
import { getFirestore } from 'firebase-admin/firestore';
export const onPostCreated = onDocumentCreated(
'posts/{postId}',
async (event) => {
const post = event.data?.data();
if (!post) return;
const db = getFirestore();
const postId = event.params.postId;
// 작성자의 팔로워 목록 조회
const followersSnap = await db
.collection(`users/${post.authorId}/followers`)
.get();
// 배치 쓰기로 각 팔로워의 feed에 복사
const batch = db.batch();
followersSnap.docs.forEach((followerDoc) => {
const feedRef = db.doc(`users/${followerDoc.id}/feed/${postId}`);
batch.set(feedRef, {
postId,
authorId: post.authorId,
authorName: post.authorName,
title: post.title,
createdAt: post.createdAt,
likeCount: 0
});
});
// 자신의 피드에도 추가
const ownFeedRef = db.doc(`users/${post.authorId}/feed/${postId}`);
batch.set(ownFeedRef, {
postId,
authorId: post.authorId,
authorName: post.authorName,
title: post.title,
createdAt: post.createdAt,
likeCount: 0
});
await batch.commit();
}
);
// 클라이언트 — 피드 조회가 단일 컬렉션 쿼리로 단순화됨
import { collection, query, orderBy, limit, onSnapshot } from 'firebase/firestore';
const feedRef = collection(db, 'users', currentUserId, 'feed');
const q = query(feedRef, orderBy('createdAt', 'desc'), limit(20));
const unsubscribe = onSnapshot(q, (snapshot) => {
const feed = snapshot.docs.map(doc => ({ id: doc.id, ...doc.data() }));
renderFeed(feed);
});
⚠️ 주의 팔로워가 수십만 명인 인플루언서 계정은 팬아웃 쓰기가 Cloud Functions 실행 시간 제한(9분)을 초과할 수 있습니다. 이 경우 팔로워를 페이지네이션하여 여러 함수 인스턴스에 분산하거나, 인플루언서 계정은 팬아웃을 적용하지 않고 클라이언트에서 별도 쿼리를 혼합하는 하이브리드 모델을 고려하세요.
피드 데이터 동기화 유지
팬아웃된 피드 항목의 likeCount 같은 집계 필드는 원본 게시글이 업데이트될 때 동기화해야 합니다. 모든 피드 항목을 즉시 업데이트하는 것은 비용이 크므로, 피드 항목에는 postId만 저장하고 렌더링 시 원본 문서를 참조하는 하이브리드 접근도 유효합니다.
관계형 데이터를 NoSQL로 옮길 때의 안티패턴
관계형 모델에서 Firestore로 이전할 때 자주 저지르는 실수들을 살펴봅니다.
안티패턴 1: JOIN을 코드로 구현하기
SQL의 JOIN을 애플리케이션 코드에서 루프로 구현하면 N+1 읽기 문제가 발생합니다.
// ❌ 코드 레벨 JOIN — 게시글 100개라면 101번 읽기 발생
const postsSnap = await getDocs(collection(db, 'posts'));
const postsWithAuthors = await Promise.all(
postsSnap.docs.map(async (postDoc) => {
const post = postDoc.data();
const author = await getDoc(doc(db, 'users', post.authorId)); // 매번 읽기
return { ...post, author: author.data() };
})
);
// ✅ 비정규화로 해결 — 작성자 정보를 posts 문서에 미리 포함
const postsSnap = await getDocs(collection(db, 'posts'));
const posts = postsSnap.docs.map(doc => ({ id: doc.id, ...doc.data() }));
// authorName, authorPhotoURL이 이미 posts 문서에 있으므로 추가 읽기 불필요
안티패턴 2: 깊은 중첩 경로 남용
3단계 이상 서브컬렉션을 중첩하면 경로가 복잡해지고 보안 규칙 작성도 어려워집니다.
// ❌ 과도한 중첩 — 보안 규칙 작성이 복잡해지고 쿼리 유연성 저하
// companies/{companyId}/teams/{teamId}/projects/{projectId}/tasks/{taskId}/comments/{commentId}
// ✅ 2단계 이하로 유지, 깊은 데이터는 루트 컬렉션 + 참조 필드로 연결
// comments/{commentId}
{
taskId: "task_abc",
projectId: "proj_xyz",
text: "완료 처리 부탁드립니다.",
authorId: "uid_123"
}
안티패턴 3: 쿼리 불가능한 구조로 저장하기
맵 필드 내부의 값은 where 쿼리 대상이 될 수 없습니다.
// ❌ 맵 필드 내부는 쿼리 불가
// orders/{orderId}
{
items: {
"item_001": { name: "노트북", price: 1500000 },
"item_002": { name: "마우스", price: 50000 }
}
}
// where('items.item_001.price', '>', 1000000) 은 동작하지 않음
// ✅ 배열 또는 서브컬렉션으로 변환
// orders/{orderId}/items/{itemId}
{
name: "노트북",
price: 1500000,
category: "electronics"
}
// 이제 category로 필터링하는 쿼리가 가능해짐
안티패턴 4: 모든 것을 하나의 컬렉션에 저장하기
다형 문서(서로 다른 스키마를 가진 문서들이 같은 컬렉션에 혼재)는 인덱스 낭비와 쿼리 복잡성을 유발합니다.
// ❌ 타입이 다른 이벤트를 하나의 컬렉션에 혼재
// events/{eventId}
// { type: 'click', elementId: '...' }
// { type: 'purchase', productId: '...', amount: 50000 }
// { type: 'signup', email: '...' }
// ✅ 이벤트 타입별로 별도 컬렉션 또는 서브컬렉션으로 분리
// clickEvents/{eventId}, purchaseEvents/{eventId}, signupEvents/{eventId}
// 또는 analytics/{type}/events/{eventId}
요약
- 읽기 패턴 중심 설계: 화면 하나에 필요한 데이터를 최소한의 읽기로 얻을 수 있도록 비정규화하되, 변경 빈도와 일관성 요구 수준을 함께 고려한다.
- 서브컬렉션 vs 루트 컬렉션: 부모 종속성과 독립 쿼리 필요 여부로 판단한다. 양쪽이 모두 필요하면
collectionGroup으로 절충한다. - 문서 크기 관리: 배열·맵에 무한정 데이터를 쌓지 않는다. 팔로워, 좋아요 목록 등은 반드시 서브컬렉션으로 분리한다.
- 집계 카운터:
increment()와 트랜잭션으로 카운터를 원자적으로 유지하고, 초당 1회 제한이 문제라면 분산 카운터를 사용한다. - 팬아웃 패턴: 쓰기 시점에 팔로워 피드를 복사해 두면 읽기가 단일 컬렉션 쿼리로 단순화된다. 팔로워가 매우 많은 계정은 하이브리드 전략을 고려한다.
- 관계형 사고 탈피: 코드 레벨 JOIN, 깊은 중첩, 쿼리 불가능한 맵 구조, 다형 컬렉션은 Firestore에서 피해야 할 대표적인 안티패턴이다.
연습문제
-
전자상거래 앱에서
orders컬렉션에는 주문 정보가,products컬렉션에는 상품 정보가 있습니다. 주문 목록 화면에서 각 주문의 상품 이름과 대표 이미지를 표시해야 합니다. N+1 읽기 문제를 피하기 위한 데이터 구조를 설계하세요.힌트 주문 생성 시점에 상품 정보가 자주 바뀌는지, 주문 목록에서 항상 최신 상품 정보가 필요한지를 생각해 보세요.
-
블로그 서비스에서 게시글의 태그 목록(
tags: ["firebase", "nosql", "backend"])을 저장하고, "firebase" 태그가 달린 모든 게시글을 최신순으로 쿼리해야 합니다. 현재 구조의 문제점과 올바른 Firestore 쿼리 방법을 작성하세요.힌트 Firestore는 배열 내 특정 값 포함 여부를
array-contains로 쿼리할 수 있습니다. -
사용자가 게시글에 댓글을 달 수 있는 서비스를 만들고 있습니다. 댓글 수가 많아도 게시글 문서 크기에 영향이 없어야 하고, "특정 사용자가 작성한 모든 댓글"도 조회할 수 있어야 합니다. 서브컬렉션과 루트 컬렉션 중 어떤 구조를 선택할지, 또는 두 구조를 어떻게 조합할지 설계하세요.
힌트
collectionGroup을 활용하면 서브컬렉션 구조를 유지하면서 교차 컬렉션 쿼리가 가능합니다. -
인스타그램 스타일의 피드를 구현합니다. 팔로잉 수가 최대 1,000명인 일반 사용자와 팔로워가 100만 명인 인플루언서 계정이 공존합니다. 팬아웃 패턴을 기본으로 하되, 인플루언서 계정을 어떻게 처리할지 설계하세요.
힌트 팬아웃 대상 계정에
isInfluencer플래그를 두고, 클라이언트에서 일반 피드와 인플루언서 피드를 별도 쿼리로 병합하는 하이브리드 접근을 고려하세요.
💡 연습문제 풀이
불러오는 중…
댓글 0
“Firebase 심화” 강좌에 대한 댓글입니다.