Claude Code로 Firestore 스키마 설계하기: GCP/Firebase SaaS 가이드
Claude Code로 Firestore collection, rules, index, SaaS schema를 안전하게 설계하는 실전 가이드.
Firestore 설계는 collection 이름보다 읽기 방식이 먼저입니다
claudecode-lab.com을 운영하는 Masa입니다.
처음 Firestore를 쓸 때 저는 users, projects, events, subscriptions 같은 collection 이름부터 만들었습니다. 보기에는 깔끔했습니다. 하지만 SaaS 화면이 늘어나자 문제가 생겼습니다. 현재 사용자의 프로젝트 목록, 프로젝트별 멤버 권한, 최근 이벤트 50개, trial 종료 예정 사용자, 결제 상태, 관리자 화면을 모두 처리하려니 schema가 실제 query와 맞지 않았습니다.
Firestore는 나중에 JOIN으로 해결하는 관계형 DB가 아닙니다. 공식 데이터 모델 문서는 Firestore가 collection 안의 document로 데이터를 저장한다고 설명합니다. document에는 field, 객체, subcollection을 둘 수 있습니다. 쉽게 말하면 collection은 책장, document는 파일, subcollection은 그 파일 안의 작은 폴더입니다.
그래서 좋은 순서는 화면, query, schema, Security Rules, index입니다. Claude Code는 이 흐름에서 코드 생성기보다 로컬 설계 리뷰어로 쓸 때 효과가 큽니다. firestore.rules, firestore.indexes.json, TypeScript 타입, query 함수를 함께 보여주고 모순을 찾게 만드는 방식입니다.
SaaS에서 collection, document, subcollection 나누기
작은 B2B SaaS라면 다음 구조로 시작할 수 있습니다.
users/{uid}
projects/{projectId}
projects/{projectId}/members/{uid}
projects/{projectId}/events/{eventId}
subscriptions/{uid}
billingCustomers/{uid}
| 경로 | 역할 | 대표 읽기 |
|---|---|---|
users/{uid} | 사용자 프로필 | 내 프로필 |
projects/{projectId} | 워크스페이스 또는 고객 프로젝트 | 프로젝트 상세 |
projects/{projectId}/members/{uid} | 프로젝트별 권한 | 멤버 목록과 권한 체크 |
projects/{projectId}/events/{eventId} | 활동 로그, 감사 로그 | 프로젝트 최근 이벤트 |
subscriptions/{uid} | plan과 결제 상태 | 기능 제한 |
billingCustomers/{uid} | Stripe 같은 결제 시스템 ID | 서버 작업 전용 |
subcollection은 멋있어 보이려고 쓰는 것이 아니라 자주 읽는 화면에 맞춰야 합니다. 어떤 프로젝트의 최근 이벤트를 보여주는 화면이 많다면 projects/{projectId}/events가 자연스럽습니다. 여러 프로젝트의 이벤트를 가로질러 읽어야 한다면 collection group query를 검토하지만, 그 순간 rules와 index도 같이 바뀝니다.
Claude Code에는 이렇게 요청합니다.
claude -p "
B2B SaaS의 Firestore 설계를 리뷰해주세요.
collection을 제안하기 전에 화면별 query 목록을 먼저 만들어주세요.
화면:
- 현재 사용자가 속한 프로젝트 목록
- 프로젝트 상세
- 프로젝트 안의 최근 이벤트 50개
- 관리자용 subscription 상태 목록
- trial 종료 예정 사용자
각 화면마다 where/orderBy/limit, 필요한 Composite index,
Security Rules 조건을 표로 작성해주세요.
"
이렇게 하면 구조가 아니라 실제 제품 동작을 기준으로 설계를 보게 됩니다.
사용자, 프로젝트, 이벤트, 결제 상태 schema 예시
다음은 Firebase Admin SDK나 Cloud Functions에서 쓰기 좋은 TypeScript 모델입니다.
import type { Timestamp } from "firebase-admin/firestore";
export type ProjectRole = "owner" | "admin" | "member" | "viewer";
export type SubscriptionStatus =
| "trialing"
| "active"
| "past_due"
| "canceled";
export interface ProjectDoc {
id: string;
name: string;
ownerUid: string;
plan: "free" | "starter" | "pro";
memberCount: number;
lastEventAt: Timestamp | null;
createdAt: Timestamp;
updatedAt: Timestamp;
}
export interface ProjectMemberDoc {
uid: string;
role: ProjectRole;
displayName: string;
email: string;
joinedAt: Timestamp;
}
export interface ProjectEventDoc {
id: string;
projectId: string;
actorUid: string;
actorName: string;
type: "created" | "updated" | "commented" | "exported";
message: string;
createdAt: Timestamp;
}
export interface SubscriptionDoc {
uid: string;
status: SubscriptionStatus;
plan: "free" | "starter" | "pro";
trialEndsAt: Timestamp | null;
updatedAt: Timestamp;
}
ProjectMemberDoc에 displayName과 email을 넣는 것은 의도적인 비정규화입니다. 비정규화는 작은 표시용 데이터를 복사해서 읽기 횟수를 줄이는 방식입니다. 멤버 50명을 표시할 때 users/{uid} 50개를 추가로 읽는 것보다, 멤버 document에 이름을 함께 두는 편이 Firestore에서는 실용적입니다. 대신 프로필이 바뀔 때 동기화하는 처리가 필요합니다.
실제 예 1은 홈 대시보드입니다. 로그인 직후 사용자의 프로젝트를 빠르게 보여주려면 다음처럼 사용자 하위에 참조를 둘 수 있습니다.
users/{uid}/projectRefs/{projectId}
projectId: string
projectName: string
role: "owner" | "admin" | "member" | "viewer"
lastEventAt: Timestamp | null
완벽하게 정규화된 모델은 아니지만, 첫 화면이 한 번의 예측 가능한 읽기로 끝납니다.
Security Rules는 필터가 아닙니다
가장 위험한 오해입니다. 공식 보안 query 문서는 Security Rules가 결과를 필터링하지 않는다고 설명합니다. query는 전체가 허용되거나 전체가 거부됩니다. 허용되지 않은 document를 반환할 가능성이 있으면 실패합니다.
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
match /projects/{projectId}/events/{eventId} {
allow list: if request.auth != null
&& exists(/databases/$(database)/documents/projects/$(projectId)/members/$(request.auth.uid))
&& request.query.limit <= 50;
}
}
}
아래 query는 limit이 없어서 rules 조건을 만족하지 못합니다.
import { collection, getDocs } from "firebase/firestore";
await getDocs(collection(db, "projects", projectId, "events"));
query도 rules와 같은 제한을 가져야 합니다.
import {
collection,
getDocs,
limit,
orderBy,
query,
} from "firebase/firestore";
export async function listProjectEvents(projectId: string) {
const eventsQuery = query(
collection(db, "projects", projectId, "events"),
orderBy("createdAt", "desc"),
limit(50),
);
const snap = await getDocs(eventsQuery);
return snap.docs.map((doc) => ({ id: doc.id, ...doc.data() }));
}
실례 2는 공개 데이터입니다. rules가 visibility == "public"만 허용한다면 query에도 where("visibility", "==", "public")가 있어야 합니다. Firestore가 자동으로 볼 수 있는 것만 골라주는 것이 아닙니다.
Composite index와 collection group query
Firestore는 기본 index를 자동으로 만들지만 여러 조건과 정렬을 섞으면 composite index가 필요합니다. 공식 index 문서는 index가 없을 때 생성 링크가 있는 오류를 반환한다고 설명합니다. 팀 프로젝트라면 firestore.indexes.json을 Git에 넣는 편이 안전합니다.
{
"indexes": [
{
"collectionGroup": "events",
"queryScope": "COLLECTION_GROUP",
"fields": [
{ "fieldPath": "projectId", "order": "ASCENDING" },
{ "fieldPath": "createdAt", "order": "DESCENDING" }
]
},
{
"collectionGroup": "subscriptions",
"queryScope": "COLLECTION",
"fields": [
{ "fieldPath": "status", "order": "ASCENDING" },
{ "fieldPath": "trialEndsAt", "order": "ASCENDING" }
]
}
],
"fieldOverrides": []
}
collection group query는 같은 ID를 가진 모든 subcollection을 가로질러 읽습니다.
import {
collectionGroup,
getDocs,
limit,
orderBy,
query,
where,
} from "firebase/firestore";
export async function listRecentEventsAcrossProjects(projectId: string) {
const eventsQuery = query(
collectionGroup(db, "events"),
where("projectId", "==", projectId),
orderBy("createdAt", "desc"),
limit(50),
);
const snap = await getDocs(eventsQuery);
return snap.docs.map((doc) => ({ id: doc.id, ...doc.data() }));
}
rules도 맞춰야 합니다. 공식 Rules 구조 문서는 match가 collection이 아니라 document path를 가리킨다고 설명합니다. collection group query에는 rules version 2와 재귀 wildcard가 필요합니다.
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
function signedIn() {
return request.auth != null;
}
function isProjectMember(projectId) {
return signedIn()
&& exists(/databases/$(database)/documents/projects/$(projectId)/members/$(request.auth.uid));
}
match /{path=**}/events/{eventId} {
allow list: if signedIn()
&& request.query.limit <= 50
&& resource.data.projectId is string
&& isProjectMember(resource.data.projectId);
}
}
}
Emulator에서 반드시 테스트하세요. 나중에 이메일 로그나 결제 로그도 events라고 만들면 같은 collection group query에 포함됩니다. 권한이 다르면 projectEvents, billingEvents처럼 이름을 분리하는 편이 좋습니다.
Claude Code로 로컬 설계 리뷰하기
저는 docs/firestore-schema.md, firestore.rules, firestore.indexes.json, src/lib/firestore/queries.ts를 준비하고 다음처럼 요청합니다.
claude -p "
이 Firestore 설계를 로컬에서 리뷰해주세요.
파일:
- docs/firestore-schema.md
- firestore.rules
- firestore.indexes.json
- src/lib/firestore/queries.ts
검토 관점:
1. 화면별 query가 schema와 맞는가
2. Security Rules를 필터처럼 오해하고 있지 않은가
3. list query에 필요한 where/orderBy/limit이 있는가
4. Composite index가 부족하거나 과도하지 않은가
5. collection group query가 너무 넓지 않은가
6. 클라이언트가 subscription status를 바꿀 수 없는가
7. 읽기 횟수가 많은 화면은 무엇인가
문제, 이유, 수정 코드를 출력해주세요.
"
실례 3은 구독 상태입니다. subscriptions/{uid}는 클라이언트가 쓰면 안 됩니다. Stripe webhook이나 Cloud Functions가 서버에서 작성해야 합니다.
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
match /subscriptions/{uid} {
allow get: if request.auth != null && request.auth.uid == uid;
allow list: if false;
allow create, update, delete: if false;
}
}
}
서버에서도 plan을 확인하세요. UI에서 버튼을 숨기는 것은 권한 검사가 아닙니다.
import { getFirestore } from "firebase-admin/firestore";
const db = getFirestore();
export async function assertActiveSubscription(uid: string) {
const snap = await db.collection("subscriptions").doc(uid).get();
const data = snap.data();
if (!data || !["trialing", "active"].includes(data.status)) {
throw new Error("Active subscription required");
}
return data;
}
자주 본 실패는 연속 document ID, rules와 query를 따로 리뷰하는 것, 결제 상태를 users/{uid}에 섞는 것, 모든 로그를 events로 부르는 것입니다. 문의 관리, 콘텐츠 관리, 작은 SaaS 데모에서 이 흐름을 시험했을 때, 처음에 query 표를 만든 쪽이 수정량이 훨씬 적었습니다.
GCP 연동을 더 보려면 Claude Code x GCP Cloud Functions와 Claude Code x GCP Cloud Run을 함께 보세요. API 경계가 애매하다면 Claude Code로 REST API 설계하기가 이어지는 글입니다. ClaudeCodeLab에서는 이런 체크리스트를 무료 PDF, 학습자료, 상담 흐름으로 정리하고 있습니다. schema, rules, query 목록을 가져오면 상담에서 바로 구체적인 리뷰로 들어갈 수 있습니다.
무료 PDF: Claude Code 치트시트
이메일을 입력하면 명령, 리뷰 습관, 안전한 워크플로를 정리한 PDF를 받을 수 있습니다.
개인정보를 안전하게 관리하며 스팸을 보내지 않습니다.
작성자 소개
Masa
Claude Code 실무 워크플로와 팀 도입을 검증하는 엔지니어입니다.
관련 글
Obsidian 메모를 CLAUDE.md로 바꾸는 Claude Code 워크플로
Obsidian 작업 메모를 CLAUDE.md 운영 노트로 정리해 Claude Code 세션의 문맥 반복을 줄입니다.
Claude Code Revenue CTA Routing: 글에서 PDF, Gumroad, 상담으로 보내기
독자 의도에 따라 무료 PDF, Gumroad 상품, 상담으로 나누는 Claude Code CTA 설계입니다.
Claude Code 팀 인계 규칙: 리뷰 증거, 권한, 롤백, 수익 경로까지 넘기는 법
Claude Code 작업을 팀에 넘길 때 필요한 증거, 권한 규칙, 롤백, 무료 PDF, Gumroad, 상담 경로 체크리스트.