Use Cases (업데이트: 2026. 7. 22.)

Claude Code로 Firestore 스키마 설계하기: GCP/Firebase SaaS 실전 가이드

화면별 쿼리부터 Firestore 컬렉션, 보안 규칙, 복합 인덱스를 설계하고 검증하는 실전 가이드입니다.

Claude Code로 Firestore 스키마 설계하기: GCP/Firebase SaaS 실전 가이드

Firestore 설계는 깔끔한 컬렉션 이름이 아니라 읽기 방식에서 시작합니다

claudecode-lab.com을 운영하는 Masa입니다.

처음 Firestore를 쓸 때 저도 빈 프로젝트를 열고 users, projects, events, subscriptions 같은 컬렉션 이름부터 정했습니다. 구조는 단정해 보였습니다. 하지만 프로젝트 대시보드, 멤버 목록, 활동 기록, 체험판 종료 알림, 관리자용 결제 화면을 붙이자 쿼리가 꼬이기 시작했습니다. 저장 구조는 깔끔했지만 실제 화면이 요구하는 읽기 방식과 맞지 않았던 것입니다.

Firestore는 나중에 JOIN으로 모든 모델링 문제를 해결하는 관계형 데이터베이스가 아닙니다. 공식 Firestore 데이터 모델 문서는 데이터를 컬렉션 안의 문서로 저장하는 NoSQL 문서 데이터베이스라고 설명합니다. 문서는 필드, 중첩 객체, 하위 컬렉션을 가질 수 있습니다. 컬렉션을 서류함, 문서를 서류 한 장, 하위 컬렉션을 그 서류에 딸린 별도 파일철이라고 생각하면 이해하기 쉽습니다.

따라서 다음 질문이 컬렉션 이름보다 먼저 나와야 합니다.

  • 어느 화면에서 목록을 읽는가?
  • 어떤 필드로 범위를 좁히고 어떤 순서로 정렬하는가?
  • 한 번에 몇 건만 가져와야 하는가?
  • 누가 그 결과를 볼 수 있는가?
  • 그 쿼리에 어떤 복합 인덱스가 필요한가?

이 글에서는 Claude Code를 GCP/Firebase SaaS의 로컬 설계 검토자로 사용하는 방법을 다룹니다. 사용자, 프로젝트, 프로젝트 활동 기록, 구독 상태, Firestore 보안 규칙, 복합 인덱스, 컬렉션 그룹 쿼리, TypeScript 코드를 한 흐름으로 점검합니다. Claude Code에 아키텍처 결정을 맡기는 것이 아니라, 사람이 정한 요구사항 사이의 모순을 구현 전에 찾는 것이 목표입니다.


컬렉션, 문서, 하위 컬렉션을 제품 화면에 맞춰 나눕니다

Firestore의 데이터는 문서에 저장되고, 모든 문서는 컬렉션에 속합니다. users/{uid}users 컬렉션 안에 사용자 ID를 문서 ID로 쓰는 문서가 있다는 뜻입니다. projects/{projectId}/events/{eventId}는 각 프로젝트 문서 아래에 events 하위 컬렉션이 있고, 그 안에 활동 기록 문서가 있다는 뜻입니다.

작은 B2B SaaS라면 다음 구조를 출발점으로 삼을 수 있습니다. 아래 이름과 ID는 모두 예시이며 실제 고객 정보가 아닙니다.

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}요금제와 결제 상태기능 사용 가능 여부 판단
billingCustomers/{uid}결제 서비스의 고객 ID서버의 결제 처리

문서를 최상위에 둘지 특정 문서 아래에 둘지는 가장 자주 쓰는 화면으로 판단합니다. 대부분의 사용자가 “이 프로젝트의 최근 활동”을 본다면 projects/{projectId}/events가 자연스럽습니다. 반대로 여러 프로젝트의 활동을 한 화면에서 찾아야 한다면 컬렉션 그룹 쿼리를 검토합니다. 이 결정을 바꾸면 쿼리뿐 아니라 인덱스와 보안 규칙도 함께 바뀝니다.

Claude Code에 단순히 “Firestore를 설계해 줘”라고 요청하지 말고 읽기 목록부터 만들게 합니다.

claude -p "
B2B SaaS의 Firestore 설계를 검토해 주세요.
컬렉션을 제안하기 전에 화면별 쿼리 목록부터 작성해 주세요.

화면:
- 현재 사용자가 속한 프로젝트 목록
- 프로젝트 상세
- 한 프로젝트의 최근 활동 50건
- 관리자용 구독 상태 목록
- 체험 기간 종료가 임박한 사용자 목록

각 화면에 대해 where/orderBy/limit 조건, 필요한 복합 인덱스,
쿼리와 일치해야 하는 Firestore 보안 규칙 조건을 표로 작성해 주세요.
확인할 수 없는 제품 정책은 추측하지 말고 질문으로 남겨 주세요.
"

이렇게 요청하면 보기 좋은 구조도가 아니라 실제 제품 동작을 기준으로 설계를 검토할 수 있습니다. Claude Code는 후보와 모순을 찾고, 사람은 화면 요구사항과 데이터 접근 권한을 확정합니다.


실제로 구현할 수 있는 SaaS 스키마를 만듭니다

다음은 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 UserDoc {
  uid: string;
  email: string;
  displayName: string;
  createdAt: Timestamp;
  updatedAt: Timestamp;
}

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";
  currentPeriodEnd: Timestamp | null;
  trialEndsAt: Timestamp | null;
  updatedAt: Timestamp;
}

ProjectMemberDocdisplayNameemail을 다시 저장한 것은 의도적인 비정규화입니다. 비정규화는 자주 표시하는 작은 데이터를 복사해 별도 문서를 추가로 읽지 않도록 만드는 방식입니다. 멤버 50명의 이름을 표시할 때 users/{uid} 문서 50개를 각각 읽는 대신, 멤버 쿼리 결과에 표시용 필드를 함께 담을 수 있습니다. 대신 프로필이 바뀌었을 때 복사본을 동기화하는 절차가 필요합니다.

유스케이스 1: 로그인 직후 프로젝트 목록

첫 화면에서 현재 사용자가 참여한 프로젝트를 바로 보여줘야 한다면 사용자별 참조 컬렉션을 둘 수 있습니다.

users/{uid}/projectRefs/{projectId}
  projectId: string
  projectName: string
  role: "owner" | "admin" | "member" | "viewer"
  lastEventAt: Timestamp | null

데이터가 일부 중복되지만 홈 화면은 예측 가능한 쿼리 한 번으로 끝납니다. 다만 프로젝트 이름이나 최근 활동 시각을 어디서 갱신할지 먼저 정해야 합니다. 복사본의 동기화 책임이 불분명하다면 비정규화의 이점보다 운영 오류가 커집니다.

다음 독립 실행 코드는 실제 서비스나 계정에 접속하지 않고, 가상 프로젝트 문서가 최소 조건을 만족하는지 확인합니다.

const allowedPlans = new Set(["free", "starter", "pro"]);

function validateProject(project) {
  const errors = [];
  if (!project.id || typeof project.id !== "string") errors.push("id가 필요합니다");
  if (!project.name || typeof project.name !== "string") errors.push("name이 필요합니다");
  if (!project.ownerUid || typeof project.ownerUid !== "string") errors.push("ownerUid가 필요합니다");
  if (!allowedPlans.has(project.plan)) errors.push("허용되지 않은 plan입니다");
  if (!Number.isInteger(project.memberCount) || project.memberCount < 1) {
    errors.push("memberCount는 1 이상의 정수여야 합니다");
  }
  return errors;
}

const sampleProject = {
  id: "project_demo_001",
  name: "가상 문서 관리 데모",
  ownerUid: "user_demo_001",
  plan: "starter",
  memberCount: 3,
  lastEventAt: null,
};

const errors = validateProject(sampleProject);
if (errors.length > 0) throw new Error(errors.join(", "));
console.log("가상 프로젝트 검증 통과:", sampleProject.id);

이 검사는 Firestore 보안 규칙을 대신하지 않습니다. 문서 형태를 빠르게 확인하는 로컬 검사일 뿐이며, 실제 읽기·쓰기 권한은 Emulator Suite에서 별도로 시험해야 합니다.


Firestore 보안 규칙은 결과를 걸러 주는 필터가 아닙니다

Firestore에서 가장 자주 생기는 오해입니다. 공식 보안 쿼리 문서는 보안 규칙이 필터처럼 작동하지 않는다고 명시합니다. Firestore는 쿼리가 반환할 가능성이 있는 결과 집합을 기준으로 요청 전체를 허용하거나 거부합니다. 권한이 없는 문서가 결과에 포함될 가능성이 있으면 해당 쿼리 전체가 실패합니다.

다음 규칙은 로그인한 프로젝트 멤버에게 활동 목록을 최대 50건까지 허용합니다.

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;
    }
  }
}

아래 클라이언트 쿼리에는 규칙이 요구하는 limit가 없습니다. 공식 문서의 request.query.limit 예시처럼, 제한이 없거나 허용 범위를 넘는 목록 요청은 거부됩니다.

import { collection, getDocs } from "firebase/firestore";

// 잘못된 예: 보안 규칙은 최대 50건의 limit를 요구합니다.
await getDocs(collection(db, "projects", projectId, "events"));

쿼리에도 같은 상한을 넣습니다.

import {
  collection,
  getDocs,
  limit,
  orderBy,
  query,
} from "firebase/firestore";

export async function listProjectEvents(projectId: string) {
  const eventsRef = collection(db, "projects", projectId, "events");
  const eventsQuery = query(
    eventsRef,
    orderBy("createdAt", "desc"),
    limit(50),
  );

  const snap = await getDocs(eventsQuery);
  return snap.docs.map((doc) => ({ id: doc.id, ...doc.data() }));
}

유스케이스 2: 공개 콘텐츠만 조회

규칙이 resource.data.visibility == "public"인 문서만 읽게 한다면 쿼리에도 where("visibility", "==", "public")가 필요합니다. Firestore가 전체 컬렉션을 읽은 다음 보이는 문서만 조용히 돌려주는 것이 아닙니다. 쿼리가 규칙의 조건을 만족한다고 증명할 수 있어야 합니다.

그래서 저는 Claude Code에 보안 규칙과 쿼리를 한 번에 검토하게 합니다. 규칙 파일만 보면 안전해 보이고, 쿼리 파일만 보면 정상처럼 보일 수 있습니다. 실제 오류는 두 파일의 조건이 어긋나는 지점에서 생깁니다.


배포 전에 복합 인덱스와 컬렉션 그룹 쿼리를 설계합니다

Firestore는 기본 쿼리에 필요한 인덱스를 자동으로 만들지만, 여러 필터와 정렬을 조합한 쿼리에는 추가 인덱스가 필요할 수 있습니다. 공식 인덱스 관리 문서에 따르면 필요한 인덱스가 없으면 생성 화면으로 이동하는 링크가 오류 메시지에 포함됩니다. 개발 중에는 편리하지만, 팀에서는 firestore.indexes.json을 Git에 넣어 변경 이유를 함께 검토하는 편이 안전합니다.

{
  "indexes": [
    {
      "collectionGroup": "projectRefs",
      "queryScope": "COLLECTION",
      "fields": [
        { "fieldPath": "role", "order": "ASCENDING" },
        { "fieldPath": "lastEventAt", "order": "DESCENDING" }
      ]
    },
    {
      "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": []
}

컬렉션 그룹 쿼리는 데이터베이스 전체에서 같은 컬렉션 ID를 가진 하위 컬렉션을 대상으로 합니다. 다음 예시는 모든 events 하위 컬렉션 중 가상 프로젝트 ID가 일치하는 최근 활동만 읽습니다.

import {
  collectionGroup,
  getDocs,
  limit,
  orderBy,
  query,
  where,
} from "firebase/firestore";

export async function listProjectEventsFromGroup(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() }));
}

컬렉션 그룹 쿼리에는 별도의 규칙 설계가 필요합니다. 공식 보안 규칙 구조 문서는 컬렉션 그룹 쿼리에 보안 규칙 버전 2와 재귀 와일드카드가 필요하다고 설명합니다.

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);

      allow get: if signedIn()
        && resource.data.projectId is string
        && isProjectMember(resource.data.projectId);
    }
  }
}

이 규칙과 쿼리는 Firebase Emulator Suite에서 함께 시험해야 합니다. 나중에 결제 웹훅이나 이메일 기록도 events라는 이름으로 만들면 같은 컬렉션 그룹에 포함됩니다. 접근 권한이 다른 기록이라면 projectEvents, auditEvents, billingEvents처럼 컬렉션 ID를 나누는 편이 명확합니다.


Claude Code로 스키마, 규칙, 인덱스, 쿼리를 함께 검토합니다

Claude Code는 처음부터 구조를 생성하게 하기보다 이미 정한 설계를 검토하게 할 때 더 유용합니다. docs/firestore-schema.md, firestore.rules, firestore.indexes.json, 쿼리 함수를 같은 저장소에 두고 서로 모순되는 부분을 찾게 합니다.

claude -p "
이 저장소의 Firestore 설계를 로컬에서 검토해 주세요.
대상 파일:
- docs/firestore-schema.md
- firestore.rules
- firestore.indexes.json
- src/lib/firestore/queries.ts

검토 항목:
1. 모든 화면의 쿼리가 스키마와 일치하는가
2. 보안 규칙을 결과 필터처럼 잘못 사용하고 있지 않은가
3. 목록 쿼리에 필요한 where/orderBy/limit가 있는가
4. 복합 인덱스가 부족하거나 불필요하게 많지 않은가
5. 컬렉션 그룹 쿼리의 범위가 지나치게 넓지 않은가
6. 클라이언트가 구독 상태를 바꿀 수 있는가
7. 문서 읽기가 과도한 화면은 어디인가

각 문제에 대해 근거가 있는 파일과 줄, 위험, 수정안을 출력해 주세요.
제품 정책과 권한 조건을 추측하지 말고 확인 질문으로 남겨 주세요.
"

유스케이스 3: 구독 상태는 클라이언트가 쓰지 못하게 합니다

subscriptions/{uid}는 결제 웹훅이나 Cloud Functions 같은 신뢰할 수 있는 서버 경로가 갱신해야 합니다. 모바일·웹 클라이언트에는 자신의 상태를 읽는 권한만 주고 쓰기 권한은 주지 않습니다.

rules_version = '2';

service cloud.firestore {
  match /databases/{database}/documents {
    function signedIn() {
      return request.auth != null;
    }

    match /subscriptions/{uid} {
      allow get: if signedIn() && request.auth.uid == uid;
      allow list: if false;
      allow create, update, delete: if false;
    }
  }
}

여기서 중요한 경계가 하나 더 있습니다. 공식 보안 규칙 조건 문서는 서버 클라이언트 라이브러리가 Firestore 보안 규칙을 우회하고 Google Application Default Credentials와 IAM으로 인증한다고 설명합니다. 따라서 위 규칙은 모바일·웹 클라이언트의 쓰기를 막지만, Admin SDK를 실행하는 서버 권한까지 제한하지는 않습니다. 서버 서비스 계정에는 필요한 최소 IAM 권한만 주고, 결제 웹훅의 서명 검증과 감사 로그도 별도로 구현해야 합니다.

화면에서 유료 기능 버튼을 숨기는 것 역시 권한 검사가 아닙니다. 실제 기능을 실행하는 서버에서도 구독 상태를 확인합니다.

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("활성 구독이 필요합니다");
  }

  return data;
}

Claude Code는 클라이언트 쓰기 경로, 서버 쓰기 경로, IAM 설정 파일, 테스트를 찾아 목록으로 만들 수 있습니다. 최종 권한과 결제 정책은 담당자가 확인해야 합니다.


매번 확인하는 실패 사례와 수정 방법

실패 1: 문서 ID를 연속 번호로 만듭니다

Customer1, Customer2처럼 계속 증가하는 문서 ID는 피합니다. 공식 Firestore 권장사항은 단조롭게 증가하는 ID가 지연 시간에 영향을 주는 핫스폿을 만들 수 있다고 설명합니다. 자동 ID를 사용하고, 사람이 읽을 URL이 필요하면 별도의 slug 필드를 둡니다.

프로젝트 문서와 첫 번째 소유자 문서를 함께 만들 때는 둘 중 하나만 저장되는 상태를 피하도록 배치 쓰기를 사용할 수 있습니다.

import { FieldValue, getFirestore } from "firebase-admin/firestore";

const db = getFirestore();

export async function createProject(name: string, ownerUid: string) {
  const projectRef = db.collection("projects").doc();
  const ownerRef = projectRef.collection("members").doc(ownerUid);
  const batch = db.batch();

  batch.set(projectRef, {
    id: projectRef.id,
    name,
    ownerUid,
    plan: "free",
    memberCount: 1,
    lastEventAt: null,
    createdAt: FieldValue.serverTimestamp(),
    updatedAt: FieldValue.serverTimestamp(),
  });

  batch.set(ownerRef, {
    uid: ownerUid,
    role: "owner",
    joinedAt: FieldValue.serverTimestamp(),
  });

  await batch.commit();
  return projectRef.id;
}

실패 2: 보안 규칙과 쿼리를 따로 검토합니다

규칙이 limit <= 50을 요구하면 쿼리에는 limit(50)가 있어야 합니다. 규칙이 공개 상태를 요구하면 쿼리에는 같은 상태를 제한하는 where 조건이 필요합니다. 규칙, 쿼리, Emulator 테스트를 하나의 변경 단위로 검토합니다.

실패 3: 결제 상태를 사용자 프로필에 섞습니다

users/{uid}.plan = "pro"는 간단해 보이지만 프로필 수정 권한과 구독 상태 변경 권한을 섞습니다. 클라이언트가 수정하는 프로필과 서버가 관리하는 subscriptions/{uid}를 분리하면 권한 검토가 쉬워집니다. 다만 서버 SDK에는 보안 규칙이 적용되지 않으므로 IAM과 웹훅 검증이 별도로 필요합니다.

실패 4: 모든 기록에 events라는 이름을 재사용합니다

컬렉션 그룹 쿼리는 데이터베이스 전체에서 같은 컬렉션 ID를 대상으로 합니다. 프로젝트 활동, 감사 기록, 결제 기록의 접근 정책이 다르다면 이름도 분리합니다. 이미 같은 이름을 사용했다면 쿼리 범위와 보안 규칙을 Emulator에서 먼저 확인한 뒤 마이그레이션합니다.


실제로 시험한 결과

이 글을 갱신하면서 가상 ID와 가상 프로젝트 이름만 사용한 독립 실행 검증 코드를 Node.js에서 실행했습니다. 정상 샘플은 가상 프로젝트 검증 통과: project_demo_001을 출력했고, 필수 ID 누락, 허용되지 않은 요금제, 0명 멤버 입력은 오류 목록으로 잡히는지 확인했습니다. JSON 인덱스 예제는 JSON 파서로 읽었고, TypeScript 예제는 구문 검사를 진행했습니다. 실제 Firebase 프로젝트, 고객 데이터, 결제 정보에는 연결하지 않았습니다.

다음 단계는 자신의 화면을 기준으로 쿼리 목록을 한 장 작성하는 것입니다. 그 목록과 firestore.rules, firestore.indexes.json, 쿼리 함수를 함께 검토하면 스키마만 따로 고칠 때보다 누락을 찾기 쉽습니다.

GCP 연동을 더 살펴보려면 Claude Code와 GCP Cloud Functions 연동Claude Code와 GCP Cloud Run 연동을 이어서 읽어 보세요. API 경계부터 정리해야 한다면 Claude Code로 REST API 설계하기가 도움이 됩니다.

팀의 실제 스키마, 보안 규칙, IAM, 쿼리 목록을 한 번에 검토해야 한다면 Claude Code 교육 및 도입 상담에서 현재 구조를 기준으로 정리할 수 있습니다. 상담 전에 실제 고객 데이터와 비밀값을 제거한 샘플 파일을 준비해 주세요.

#claude-code #gcp #firestore #database #typescript #query-design
무료

무료 PDF: Claude Code 치트시트

이메일을 입력하면 명령, 리뷰 습관, 안전한 워크플로를 정리한 PDF를 받을 수 있습니다.

개인정보를 안전하게 관리하며 스팸을 보내지 않습니다.

Masa

작성자 소개

Masa

Claude Code 실무 워크플로와 팀 도입을 검증하는 엔지니어입니다.