Use Cases (업데이트: 2026. 6. 1.)

Claude Code로 Firebase 개발하기: 인증, 규칙, 함수, 배포 실전 가이드

Claude Code로 Firebase 앱을 개발하는 방법을 Auth, Firestore 규칙, Functions, Hosting, Emulator, 환경 분리와 보안까지 설명합니다.

Claude Code로 Firebase 개발하기: 인증, 규칙, 함수, 배포 실전 가이드

Claude Code에 Firebase 개발을 맡기기 전에 정할 것

Firebase는 인증, 데이터베이스, 서버리스 함수, 정적 호스팅을 빠르게 묶을 수 있는 BaaS입니다. 여기서 BaaS는 Backend as a Service, 즉 백엔드의 공통 기능을 서비스로 사용하는 방식입니다. 작은 팀도 Firebase Authentication, Cloud Firestore, Cloud Functions for Firebase, Firebase Hosting, Local Emulator Suite를 조합하면 로그인 기능이 있는 웹 앱을 빠르게 만들 수 있습니다.

하지만 Firebase는 “동작한다”와 “안전하게 운영된다” 사이의 차이가 큽니다. Firestore Security Rules가 느슨하면 다른 사용자의 문서가 노출될 수 있습니다. Cloud Functions에서 쓰는 Admin SDK는 Security Rules를 우회하므로 함수 내부 검증을 따로 리뷰해야 합니다. 개발 프로젝트와 운영 프로젝트가 섞이면 로컬 테스트가 실제 데이터와 실제 비용으로 이어질 수 있습니다.

Claude Code에는 화면 하나가 아니라 세로로 완성된 기능 단위를 맡기는 편이 좋습니다. 예를 들어 “문의 티켓 기능”이라면 UI, 로그인 상태, Firestore 쓰기, Security Rules, 규칙 테스트, 권한이 필요한 Cloud Function, Hosting 설정까지 같은 작업 범위에 넣습니다.

기초 설계는 인증 구현 가이드, 배포 자동화는 CI/CD 설정 가이드, 다른 BaaS와의 비교는 Supabase 통합 가이드도 함께 참고할 수 있습니다.

flowchart LR
  A["User"] --> B["Firebase Authentication"]
  B --> C["React or Astro UI"]
  C --> D["Cloud Firestore"]
  D --> E["Security Rules"]
  D --> F["Cloud Functions v2"]
  C --> G["Firebase Hosting"]
  H["Emulator Suite"] --> B
  H --> D
  H --> F

이 글의 코드는 Vite + React + TypeScript 기준입니다. Next.js나 Astro에서도 원리는 같지만 환경 변수명과 라우팅은 프로젝트에 맞게 바꾸면 됩니다.

기본 구성과 공식 문서

Firebase 개발에서는 공식 문서를 기준으로 삼아야 합니다. 특히 Security Rules와 Emulator Suite는 오래된 예제를 그대로 복사하면 권한 모델이 어긋날 수 있습니다.

영역공식 링크Claude Code에 맡길 수 있는 작업
AuthenticationFirebase Authentication로그인 UI, 사용자 프로필 생성, 로그인 상태 관리
FirestoreCloud Firestore컬렉션 설계, 쿼리 함수, 인덱스 전제 확인
Security RulesFirestore Security Rules규칙, 실패 테스트, 소유자 검증
Cloud FunctionsCloud Functions for Firebase서버 검증, 알림, 집계, 외부 API 연동
HostingFirebase HostingSPA 배포, 캐시, 프리뷰 채널
EmulatorLocal Emulator Suite로컬 검증, 규칙 커버리지, CI 테스트
가격Firebase pricing읽기 수, 함수 호출, 로그량 추정
Claude CodeClaude Code docs작업 분할, 리뷰 루프, 테스트 실행

다음 구조를 먼저 읽게 하면 Claude Code의 변경 범위를 통제하기 쉽습니다.

.
├─ firebase.json
├─ firestore.rules
├─ firestore.indexes.json
├─ .firebaserc
├─ functions/
│  ├─ package.json
│  └─ src/index.ts
└─ src/
   ├─ lib/firebase.ts
   ├─ lib/tickets.ts
   └─ lib/useAuth.tsx

환경 분리를 먼저 고정하기

Firebase 사고의 상당수는 복잡한 코드가 아니라 프로젝트 혼동에서 시작합니다. dev, stg, prod를 분리하고 Claude Code가 운영 배포를 실행하지 못하게 명확히 적어 둡니다.

{
  "projects": {
    "dev": "claudecodelab-firebase-dev",
    "stg": "claudecodelab-firebase-stg",
    "prod": "claudecodelab-firebase-prod"
  }
}

firebase.json에는 Firestore 규칙, 인덱스, Functions, Hosting, Emulator 설정을 함께 둡니다.

{
  "firestore": {
    "rules": "firestore.rules",
    "indexes": "firestore.indexes.json"
  },
  "functions": [
    {
      "source": "functions",
      "codebase": "default",
      "runtime": "nodejs20"
    }
  ],
  "hosting": {
    "public": "dist",
    "ignore": ["firebase.json", "**/.*", "**/node_modules/**"],
    "headers": [
      {
        "source": "/assets/**",
        "headers": [
          {
            "key": "Cache-Control",
            "value": "public, max-age=31536000, immutable"
          }
        ]
      }
    ],
    "rewrites": [
      {
        "source": "**",
        "destination": "/index.html"
      }
    ]
  },
  "emulators": {
    "auth": {
      "port": 9099
    },
    "functions": {
      "port": 5001
    },
    "firestore": {
      "port": 8080
    },
    "hosting": {
      "port": 5000
    },
    "ui": {
      "enabled": true,
      "port": 4000
    },
    "singleProjectMode": true
  }
}

Vite의 .env.local 예시는 다음과 같습니다. Firebase Web API key는 서버 비밀키가 아니지만 서비스 계정 JSON과 Admin SDK 자격 증명은 프론트엔드에 두면 안 됩니다.

VITE_FIREBASE_API_KEY=replace-me
VITE_FIREBASE_AUTH_DOMAIN=claudecodelab-firebase-dev.firebaseapp.com
VITE_FIREBASE_PROJECT_ID=claudecodelab-firebase-dev
VITE_FIREBASE_STORAGE_BUCKET=claudecodelab-firebase-dev.appspot.com
VITE_FIREBASE_APP_ID=replace-me
VITE_USE_FIREBASE_EMULATORS=true

Authentication과 클라이언트 초기화

아래 코드는 Firebase App, Auth, Firestore, Functions를 초기화하고 개발 환경에서 Emulator Suite에 연결합니다.

// src/lib/firebase.ts
import { initializeApp, getApp, getApps } from "firebase/app";
import {
  connectAuthEmulator,
  getAuth,
  GoogleAuthProvider,
} from "firebase/auth";
import { connectFirestoreEmulator, getFirestore } from "firebase/firestore";
import { connectFunctionsEmulator, getFunctions } from "firebase/functions";

const firebaseConfig = {
  apiKey: import.meta.env.VITE_FIREBASE_API_KEY,
  authDomain: import.meta.env.VITE_FIREBASE_AUTH_DOMAIN,
  projectId: import.meta.env.VITE_FIREBASE_PROJECT_ID,
  storageBucket: import.meta.env.VITE_FIREBASE_STORAGE_BUCKET,
  appId: import.meta.env.VITE_FIREBASE_APP_ID,
};

const app = getApps().length > 0 ? getApp() : initializeApp(firebaseConfig);

export const auth = getAuth(app);
export const db = getFirestore(app);
export const functions = getFunctions(app, "asia-northeast1");
export const googleProvider = new GoogleAuthProvider();

const shouldUseEmulators =
  import.meta.env.DEV && import.meta.env.VITE_USE_FIREBASE_EMULATORS === "true";

const globalState = globalThis as typeof globalThis & {
  __firebaseEmulatorsConnected?: boolean;
};

if (shouldUseEmulators && !globalState.__firebaseEmulatorsConnected) {
  connectAuthEmulator(auth, "http://127.0.0.1:9099", {
    disableWarnings: true,
  });
  connectFirestoreEmulator(db, "127.0.0.1", 8080);
  connectFunctionsEmulator(functions, "127.0.0.1", 5001);
  globalState.__firebaseEmulatorsConnected = true;
}

로그인 상태는 Hook으로 분리하고, 첫 로그인 때 users/{uid} 문서를 merge로 저장합니다.

// src/lib/useAuth.tsx
import { useEffect, useState } from "react";
import {
  onAuthStateChanged,
  signInWithPopup,
  signOut,
  type User,
} from "firebase/auth";
import { doc, serverTimestamp, setDoc } from "firebase/firestore";
import { auth, db, googleProvider } from "./firebase";

type AuthState = {
  user: User | null;
  loading: boolean;
  signInWithGoogle: () => Promise<void>;
  logout: () => Promise<void>;
};

export function useAuth(): AuthState {
  const [user, setUser] = useState<User | null>(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    return onAuthStateChanged(auth, (currentUser) => {
      setUser(currentUser);
      setLoading(false);
    });
  }, []);

  async function signInWithGoogle() {
    const result = await signInWithPopup(auth, googleProvider);
    await setDoc(
      doc(db, "users", result.user.uid),
      {
        uid: result.user.uid,
        email: result.user.email,
        displayName: result.user.displayName,
        photoURL: result.user.photoURL,
        updatedAt: serverTimestamp(),
      },
      { merge: true },
    );
  }

  return {
    user,
    loading,
    signInWithGoogle,
    logout: () => signOut(auth),
  };
}

Claude Code에는 다음처럼 요청합니다.

Firebase Auth의 Google 로그인을 구현해 주세요.
- 대상은 Vite + React + TypeScript
- 기존 src/lib/firebase.ts 초기화를 재사용
- 첫 로그인 시 users/{uid}를 merge로 생성
- logout 함수도 반환
- 서비스 계정이나 비밀키는 만들거나 표시하지 않음
- 구현 후 타입 오류, Firestore Rules 영향, 수동 확인 항목을 설명

Firestore 데이터 설계와 CRUD

예시는 “문의 티켓”입니다. 실제 유스케이스는 세 가지입니다. 사용자가 자신의 문의를 확인하는 회원 포털, 상담 담당자가 Cloud Functions를 통해 티켓을 닫는 운영 흐름, 새 티켓 생성 시 알림과 통계를 만드는 관리자 대시보드입니다.

// src/lib/tickets.ts
import {
  addDoc,
  collection,
  getDocs,
  limit,
  orderBy,
  query,
  serverTimestamp,
  where,
  type Timestamp,
} from "firebase/firestore";
import { db } from "./firebase";

export type TicketStatus = "open" | "closed";

export type Ticket = {
  id: string;
  userId: string;
  title: string;
  body: string;
  status: TicketStatus;
  createdAt: Timestamp;
  updatedAt: Timestamp;
};

type CreateTicketInput = {
  userId: string;
  title: string;
  body: string;
};

export async function createTicket(input: CreateTicketInput): Promise<string> {
  const title = input.title.trim();
  const body = input.body.trim();

  if (title.length === 0 || title.length > 120) {
    throw new Error("Title must be between 1 and 120 characters.");
  }

  if (body.length === 0 || body.length > 4000) {
    throw new Error("Body must be between 1 and 4000 characters.");
  }

  const docRef = await addDoc(collection(db, "tickets"), {
    userId: input.userId,
    title,
    body,
    status: "open",
    createdAt: serverTimestamp(),
    updatedAt: serverTimestamp(),
  });

  return docRef.id;
}

export async function listMyTickets(userId: string): Promise<Ticket[]> {
  const ticketsQuery = query(
    collection(db, "tickets"),
    where("userId", "==", userId),
    orderBy("createdAt", "desc"),
    limit(20),
  );

  const snapshot = await getDocs(ticketsQuery);

  return snapshot.docs.map((ticketDoc) => ({
    id: ticketDoc.id,
    ...(ticketDoc.data() as Omit<Ticket, "id">),
  }));
}

whereorderBy 조합은 복합 인덱스가 필요할 수 있으므로 firestore.indexes.json에 남깁니다.

{
  "indexes": [
    {
      "collectionGroup": "tickets",
      "queryScope": "COLLECTION",
      "fields": [
        {
          "fieldPath": "userId",
          "order": "ASCENDING"
        },
        {
          "fieldPath": "createdAt",
          "order": "DESCENDING"
        }
      ]
    }
  ],
  "fieldOverrides": []
}

Firestore Security Rules는 필터가 아니다

Security Rules는 넓은 쿼리 결과를 안전한 문서만 남기도록 필터링하지 않습니다. 쿼리 자체가 허용 가능한 문서만 반환하도록 설계되어야 합니다.

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

    function isOwner(userId) {
      return signedIn() && request.auth.uid == userId;
    }

    function ticketFieldsAreValid() {
      return request.resource.data.keys().hasOnly([
        "userId",
        "title",
        "body",
        "status",
        "createdAt",
        "updatedAt"
      ])
      && request.resource.data.title is string
      && request.resource.data.title.size() > 0
      && request.resource.data.title.size() <= 120
      && request.resource.data.body is string
      && request.resource.data.body.size() > 0
      && request.resource.data.body.size() <= 4000;
    }

    match /users/{userId} {
      allow create, read, update: if isOwner(userId);
      allow delete: if false;
    }

    match /tickets/{ticketId} {
      allow create: if signedIn()
        && request.resource.data.userId == request.auth.uid
        && request.resource.data.status == "open"
        && ticketFieldsAreValid();

      allow read: if signedIn()
        && resource.data.userId == request.auth.uid;

      allow update: if signedIn()
        && resource.data.userId == request.auth.uid
        && request.resource.data.userId == resource.data.userId
        && request.resource.data.status == resource.data.status
        && request.resource.data.diff(resource.data).affectedKeys()
          .hasOnly(["title", "body", "updatedAt"])
        && ticketFieldsAreValid();

      allow delete: if false;
    }

    match /adminStats/{docId} {
      allow read, write: if false;
    }
  }
}

리뷰할 부분은 request.auth.uid와 소유자 비교, 허용 필드 제한, 삭제 금지, 관리자 데이터가 클라이언트에 열려 있지 않은지입니다.

Emulator Suite로 규칙 테스트하기

npm install -D vitest @firebase/rules-unit-testing firebase
firebase setup:emulators:firestore
// tests/firestore.rules.test.ts
import { readFileSync } from "node:fs";
import {
  assertFails,
  assertSucceeds,
  initializeTestEnvironment,
  type RulesTestEnvironment,
} from "@firebase/rules-unit-testing";
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
import { doc, getDoc, setDoc, updateDoc } from "firebase/firestore";

let testEnv: RulesTestEnvironment;

beforeAll(async () => {
  testEnv = await initializeTestEnvironment({
    projectId: "claudecodelab-firestore-rules",
    firestore: {
      rules: readFileSync("firestore.rules", "utf8"),
    },
  });
});

beforeEach(async () => {
  await testEnv.clearFirestore();
});

afterAll(async () => {
  await testEnv.cleanup();
});

describe("tickets security rules", () => {
  it("allows the owner to create and read a ticket", async () => {
    const aliceDb = testEnv.authenticatedContext("alice").firestore();
    const ticketRef = doc(aliceDb, "tickets/ticket-1");

    await assertSucceeds(
      setDoc(ticketRef, {
        userId: "alice",
        title: "Please resend my invoice",
        body: "I cannot find the April invoice.",
        status: "open",
        createdAt: new Date(),
        updatedAt: new Date(),
      }),
    );

    await assertSucceeds(getDoc(ticketRef));
  });

  it("blocks another user from reading the ticket", async () => {
    await testEnv.withSecurityRulesDisabled(async (context) => {
      await setDoc(doc(context.firestore(), "tickets/ticket-2"), {
        userId: "alice",
        title: "Plan question",
        body: "I want to confirm my current plan.",
        status: "open",
        createdAt: new Date(),
        updatedAt: new Date(),
      });
    });

    const bobDb = testEnv.authenticatedContext("bob").firestore();
    await assertFails(getDoc(doc(bobDb, "tickets/ticket-2")));
  });

  it("blocks status changes from the web client", async () => {
    await testEnv.withSecurityRulesDisabled(async (context) => {
      await setDoc(doc(context.firestore(), "tickets/ticket-3"), {
        userId: "alice",
        title: "Cannot sign in",
        body: "Google sign-in returns an error.",
        status: "open",
        createdAt: new Date(),
        updatedAt: new Date(),
      });
    });

    const aliceDb = testEnv.authenticatedContext("alice").firestore();
    await assertFails(
      updateDoc(doc(aliceDb, "tickets/ticket-3"), {
        status: "closed",
        updatedAt: new Date(),
      }),
    );
  });

  it("keeps the test runner alive", () => {
    expect(testEnv).toBeDefined();
  });
});
{
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "firebase:use:dev": "firebase use dev",
    "firebase:emulators": "firebase emulators:start --only auth,firestore,functions,hosting",
    "test:rules": "firebase emulators:exec --only firestore \"vitest run tests/firestore.rules.test.ts\"",
    "deploy:stg": "firebase use stg && npm run build && firebase deploy --only hosting,firestore:rules,firestore:indexes,functions",
    "deploy:prod": "firebase use prod && npm run build && firebase deploy --only hosting,firestore:rules,firestore:indexes,functions"
  }
}

Cloud Functions에 권한 작업을 모으기

상태 변경, 외부 API 키, 알림, 집계처럼 브라우저를 신뢰하면 안 되는 처리는 Cloud Functions로 옮깁니다.

// functions/src/index.ts
import { initializeApp } from "firebase-admin/app";
import { FieldValue, getFirestore } from "firebase-admin/firestore";
import { onDocumentCreated } from "firebase-functions/v2/firestore";
import { HttpsError, onCall } from "firebase-functions/v2/https";

initializeApp();

const db = getFirestore();

export const closeTicket = onCall(
  {
    region: "asia-northeast1",
  },
  async (request) => {
    if (!request.auth) {
      throw new HttpsError("unauthenticated", "Sign-in is required.");
    }

    const ticketId = request.data?.ticketId;
    if (typeof ticketId !== "string" || ticketId.length > 100) {
      throw new HttpsError("invalid-argument", "ticketId is invalid.");
    }

    const ticketRef = db.doc(`tickets/${ticketId}`);
    const ticketSnap = await ticketRef.get();

    if (!ticketSnap.exists) {
      throw new HttpsError("not-found", "Ticket was not found.");
    }

    const ticket = ticketSnap.data();
    if (ticket?.userId !== request.auth.uid) {
      throw new HttpsError("permission-denied", "You cannot close this ticket.");
    }

    await ticketRef.update({
      status: "closed",
      closedAt: FieldValue.serverTimestamp(),
      updatedAt: FieldValue.serverTimestamp(),
    });

    return { ok: true };
  },
);

export const notifyTicketCreated = onDocumentCreated(
  {
    document: "tickets/{ticketId}",
    region: "asia-northeast1",
  },
  async (event) => {
    const ticket = event.data?.data();
    if (!ticket) return;

    await db.collection("adminNotifications").add({
      type: "ticket_created",
      ticketId: event.params.ticketId,
      title: ticket.title,
      userId: ticket.userId,
      createdAt: FieldValue.serverTimestamp(),
      read: false,
    });
  },
);

중요한 함정은 Admin SDK가 Security Rules를 우회한다는 점입니다. 함수 내부에서 request.auth, 입력 타입, 문서 소유자를 매번 확인해야 합니다.

Hosting, 비용, 보안 리뷰

firebase login
firebase use dev
npm run build
firebase emulators:start --only auth,firestore,functions,hosting
firebase hosting:channel:deploy preview-firebase-ticket
firebase use stg
firebase deploy --only hosting,firestore:rules,firestore:indexes,functions

실패 사례는 구체적입니다. 로그인한 모든 사용자에게 읽기와 쓰기를 허용하는 규칙, 전체 컬렉션을 읽고 화면에서 필터링하는 구현, Functions에서 소유자 검증을 생략하는 코드, Emulator가 실제 규칙 파일을 읽지 않는 설정, .env.localfirebase use가 다른 프로젝트를 가리키는 상태입니다.

비용은 Firebase pricing에서 최신 정보를 확인합니다. Firestore 읽기 수, limit, 실시간 리스너 해제, Functions 리전과 타임아웃, 로그량, Hosting 캐시를 함께 봐야 합니다. 보안 측면에서는 서비스 계정 JSON, CI 토큰, 운영 Owner 권한을 Claude Code 프롬프트에 넣지 않습니다.

Claude Code 요청 템플릿

Firebase 문의 티켓 기능을 구현해 주세요.

범위:
- Vite + React + TypeScript
- Firebase Auth, Firestore, Cloud Functions v2, Hosting
- src/lib/firebase.ts, src/lib/useAuth.tsx, src/lib/tickets.ts, firestore.rules, functions/src/index.ts, firebase.json만 수정

요구사항:
- Google 로그인 사용자는 tickets를 생성할 수 있음
- 사용자는 자기 tickets만 읽을 수 있음
- Web 클라이언트는 status를 변경할 수 없음
- Callable Function이 소유자 검증 후 ticket을 닫음
- Emulator Suite로 허용/거부 테스트를 추가
- dev/stg/prod 프로젝트를 혼동하지 않음

금지:
- 서비스 계정 JSON 생성, 출력, 저장 금지
- 운영 배포 실행 금지
- allow read, write: if true 사용 금지

보고:
- 변경 파일
- 실행한 테스트
- Security Rules의 권한 경계
- 남은 수동 확인

정리

Claude Code와 Firebase의 조합은 실용적입니다. rules, functions, indexes, hosting 설정, 클라이언트 SDK 호출이 모두 리뷰 가능한 diff로 남기 때문입니다. 다만 권한, 운영 배포, 데이터 소유권, 비용 경계는 사람이 마지막으로 판단해야 합니다.

실무에서는 Emulator Suite에서 작은 기능 하나를 세로로 만들고, Security Rules 실패 테스트를 넣고, Cloud Functions의 권한 검증을 리뷰한 뒤, Hosting preview와 staging을 거쳐 production으로 올리는 흐름이 안전합니다. ClaudeCodeLab은 Firebase 구현, Security Rules 리뷰, Emulator Suite 교육, Claude Code 팀 운영 규칙 정비를 지원합니다. 상담이나 교육이 필요하다면 현재 Firebase 구성, 출시하려는 기능, 배포 방식을 함께 정리해 주세요.

이 글의 내용을 실제로 시도할 때는 개발용 Firebase 프로젝트를 쓰는지, firebase use.env.local이 일치하는지, 성공/실패 Security Rules 테스트가 모두 통과하는지, Cloud Functions 내부에서 소유자 검증을 하는지, Claude Code가 운영 배포를 자동 실행하지 못하게 되어 있는지 확인하세요.

#Claude Code #Firebase #Firestore #Cloud Functions #BaaS
무료

무료 PDF: Claude Code 치트시트

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

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

Masa

작성자 소개

Masa

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