멀티 환경 구성, 비용·성능 모니터링, 핫스팟 회피와 점진적 마이그레이션으로 프로덕션을 운영한다.
운영, 모니터링, 확장 전략
입문편에서 Hosting 배포와 Cloud Functions의 기본 흐름을 익혔다면, 이제는 그 서비스를 실제로 지속 운영하는 문제를 마주하게 됩니다. 트래픽이 늘어날수록 핫스팟, 예상치 못한 청구서, 데이터 구조 변경에 따른 다운타임 위험이 현실적인 과제가 됩니다. 이 레슨에서는 프로덕션 환경을 안전하게 유지하고 비용을 통제하며 스케일 아웃까지 감당하는 전략들을 다룹니다.
단순히 firebase deploy를 실행하는 수준을 넘어, dev/staging/prod 환경을 분리하고, 모니터링 대시보드와 예산 알림을 설정하고, 스키마 마이그레이션을 무중단으로 수행하고, 장애 발생 시 복구하는 구체적인 방법을 살펴봅니다.
학습 목표
- 멀티 프로젝트 전략으로 dev/staging/prod 환경을 분리하고 환경별 배포를 자동화하는 방법을 익힌다.
- 사용량·비용 모니터링과 예산 알림을 설정해 과금 폭탄을 방지한다.
- 핫스팟·순차 키 문제를 이해하고 샤딩으로 Firestore 쓰기 처리량을 확장한다.
- App Check로 백엔드 리소스 남용을 방지하는 방법을 이해한다.
- 무중단 마이그레이션과 백필(backfill) 전략으로 스키마를 안전하게 변경한다.
멀티 프로젝트: 환경 분리와 배포 자동화
Firebase 프로젝트를 하나만 사용하면 개발 중인 코드가 프로덕션 데이터에 영향을 줄 수 있습니다. 프로젝트를 myapp-dev, myapp-staging, myapp-prod 세 개로 분리하는 것이 업계 표준입니다.
프로젝트 별칭(alias) 등록
# 현재 디렉터리에 별칭 등록
firebase use --add
# 또는 .firebaserc에 직접 기재
.firebaserc 파일에 별칭을 정의합니다.
{
"projects": {
"default": "myapp-dev",
"staging": "myapp-staging",
"production": "myapp-prod"
}
}
이후 배포 시 --project 플래그 대신 별칭을 사용합니다.
# 스테이징 배포
firebase use staging
firebase deploy --only functions,firestore
# 프로덕션 배포
firebase use production
firebase deploy --only functions,firestore
환경 변수 분리
Cloud Functions에서 환경별 설정을 분리할 때는 Firebase의 defineString / defineInt(Firebase Functions v2 파라미터 방식)를 활용합니다.
// functions/src/config.ts
import { defineString, defineInt } from 'firebase-functions/params';
export const stripeKey = defineString('STRIPE_KEY');
export const maxRetries = defineInt('MAX_RETRIES', { default: 3 });
각 환경의 .env.<project-id> 파일에 값을 넣으면 배포 시 자동으로 주입됩니다.
# .env.myapp-dev
STRIPE_KEY=sk_test_xxx
MAX_RETRIES=1
# .env.myapp-prod
STRIPE_KEY=sk_live_xxx
MAX_RETRIES=5
💡 TIP
.env.myapp-prod는 반드시.gitignore에 추가하세요. 실수로 커밋하면 라이브 시크릿이 노출됩니다.
GitHub Actions로 배포 자동화
# .github/workflows/deploy.yml
name: Firebase Deploy
on:
push:
branches:
- develop # dev 환경
- staging # staging 환경
- main # production 환경
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install dependencies
run: npm ci --prefix functions
- name: Determine target project
id: project
run: |
if [ "${{ github.ref_name }}" = "main" ]; then
echo "alias=production" >> $GITHUB_OUTPUT
elif [ "${{ github.ref_name }}" = "staging" ]; then
echo "alias=staging" >> $GITHUB_OUTPUT
else
echo "alias=default" >> $GITHUB_OUTPUT
fi
- name: Deploy to Firebase
uses: FirebaseExtended/action-hosting-deploy@v0
with:
repoToken: ${{ secrets.GITHUB_TOKEN }}
firebaseServiceAccount: ${{ secrets.FIREBASE_SERVICE_ACCOUNT }}
projectId: ${{ steps.project.outputs.alias }}
⚠️ 주의 Service Account 키를 GitHub Secrets에 저장할 때는 최소 권한(Deployer 역할)만 부여하세요.
Editor권한을 통째로 주면 보안 사고 시 피해 범위가 커집니다.
사용량·비용 모니터링과 과금 폭탄 방지
Firebase는 읽기/쓰기 횟수, 저장 용량, 함수 호출 건수 단위로 과금됩니다. 모니터링 없이 운영하면 무한 루프 함수나 보안 규칙 오작동 하나로 수십만 원 청구서를 받을 수 있습니다.
Google Cloud Budget Alert 설정
Firebase 프로젝트는 내부적으로 Google Cloud 프로젝트입니다. Cloud Console의 Billing → Budgets & alerts 에서 예산을 설정합니다.
# gcloud CLI로 예산 알림 생성 (월 50,000원 = 약 38 USD 기준)
gcloud billing budgets create \
--billing-account=BILLING_ACCOUNT_ID \
--display-name="Firebase Monthly Budget" \
--budget-amount=38USD \
--threshold-rule=percent=0.5,basis=CURRENT_SPEND \
--threshold-rule=percent=0.9,basis=CURRENT_SPEND \
--threshold-rule=percent=1.0,basis=CURRENT_SPEND \
--all-updates-rule-pubsub-topic=projects/myapp-prod/topics/billing-alerts
50%, 90%, 100% 도달 시 Pub/Sub 토픽으로 알림을 발행하고, 이를 Cloud Function으로 받아 Slack이나 이메일로 전달할 수 있습니다.
Firestore 읽기 급증 자동 차단
보안 규칙 오작동으로 Firestore 읽기가 폭발할 경우를 대비해 함수 레벨에서 간단한 Rate Limiter를 구현할 수 있습니다.
// functions/src/rateLimiter.ts
import { getFirestore, FieldValue } from 'firebase-admin/firestore';
const db = getFirestore();
/**
* 특정 사용자의 분당 API 호출 횟수를 제한합니다.
* Firestore 카운터 문서를 활용한 슬라이딩 윈도우 방식.
*/
export async function checkRateLimit(
uid: string,
limitPerMinute = 60
): Promise<boolean> {
const minute = Math.floor(Date.now() / 60000);
const ref = db.doc(`rateLimits/${uid}/windows/${minute}`);
const result = await db.runTransaction(async (tx) => {
const snap = await tx.get(ref);
const count: number = snap.exists ? (snap.data()!.count as number) : 0;
if (count >= limitPerMinute) return false;
tx.set(
ref,
{ count: FieldValue.increment(1), expireAt: new Date((minute + 2) * 60000) },
{ merge: true }
);
return true;
});
return result;
}
💡 TIP
expireAt필드에 TTL 정책을 걸어두면(gcloud firestore fields ttls update expireAt --collection-group=windows --enable-ttl또는firestore.indexes.json의fieldOverrides에"ttl": true를 추가해 배포) 오래된 카운터 문서가 자동 삭제되어 저장 비용이 누적되지 않습니다.
Cloud Functions 비용 제어
// functions/src/index.ts
import { onRequest } from 'firebase-functions/v2/https';
export const api = onRequest(
{
// ✅ 동시 실행 인스턴스 상한을 명시해 비용 폭주를 방지
maxInstances: 10,
// ✅ 최소 인스턴스를 0으로 유지 (콜드스타트 허용 대신 비용 절감)
minInstances: 0,
memory: '256MiB',
timeoutSeconds: 30,
},
async (req, res) => {
// 핸들러 구현
}
);
핫스팟·순차 키 문제와 샤딩
Firestore는 내부적으로 데이터를 키 범위로 분산 저장하는 분산 데이터베이스입니다. 특정 키 범위에 쓰기가 집중되면 해당 태블릿(shard) 하나에 부하가 몰려 단일 문서에 대한 지속 쓰기 약 1회/초(soft limit)에 걸립니다. 이를 핫스팟(hotspot) 이라 합니다.
순차 키가 핫스팟을 만드는 이유
// ❌ 타임스탬프를 문서 ID로 사용하면 최신 쓰기가 항상 같은 샤드에 집중됨
const docId = Date.now().toString(); // "1749340800000", "1749340800001", ...
await db.collection('events').doc(docId).set(data);
// ❌ 순차 번호도 마찬가지
const docId = String(counter).padStart(10, '0'); // "0000000001", ...
연속된 숫자 ID는 Firestore 내부의 같은 키 범위 서버로 라우팅되어 병목이 생깁니다.
해결책 1: 자동 생성 ID 사용
// ✅ add() 또는 doc()로 생성되는 랜덤 ID는 자동으로 분산됨
const ref = await db.collection('events').add(data);
해결책 2: 카운터 샤딩
글로벌 카운터처럼 단일 문서에 쓰기가 집중되는 경우, 샤딩을 적용합니다.
// utils/distributedCounter.ts
import { getFirestore, FieldValue } from 'firebase-admin/firestore';
const db = getFirestore();
const NUM_SHARDS = 10;
/**
* 분산 카운터: 10개의 샤드 문서에 균등 분산하여 쓰기 처리량을 10배 확장.
*/
export async function incrementCounter(counterName: string): Promise<void> {
const shardId = Math.floor(Math.random() * NUM_SHARDS);
const shardRef = db.doc(`counters/${counterName}/shards/${shardId}`);
await shardRef.set({ count: FieldValue.increment(1) }, { merge: true });
}
export async function getCount(counterName: string): Promise<number> {
const shardsSnap = await db
.collection(`counters/${counterName}/shards`)
.get();
return shardsSnap.docs.reduce((sum, doc) => sum + (doc.data().count ?? 0), 0);
}
| 방식 | 단일 문서 카운터 | 샤딩 카운터 (10 shards) |
|---|---|---|
| 초당 쓰기 한계 | ~1회 | ~10회 |
| 읽기 비용 | 1 read | 10 reads |
| 적합한 상황 | 저빈도 갱신 | 실시간 집계, 좋아요 수 등 |
⚠️ 주의 샤드 수를 무작정 늘리면 읽기 비용이 선형으로 증가합니다. 예상 쓰기 TPS의 1.5배 정도로 설정하는 것이 적절합니다.
App Check로 백엔드 리소스 남용 방지
앱 클라이언트 없이 Firestore나 Cloud Functions에 직접 HTTP 요청을 날리는 스크레이퍼·봇으로부터 리소스를 보호하려면 App Check를 활성화합니다. App Check는 각 요청이 신뢰할 수 있는 앱 인스턴스에서 왔는지 검증합니다.
클라이언트 설정 (웹, reCAPTCHA v3)
// src/firebase.ts
import { initializeApp } from 'firebase/app';
import { initializeAppCheck, ReCaptchaV3Provider } from 'firebase/app-check';
const app = initializeApp(firebaseConfig);
// 개발 환경에서는 디버그 토큰 사용
if (import.meta.env.DEV) {
// @ts-ignore
self.FIREBASE_APPCHECK_DEBUG_TOKEN = true;
}
initializeAppCheck(app, {
provider: new ReCaptchaV3Provider('YOUR_RECAPTCHA_V3_SITE_KEY'),
isTokenAutoRefreshEnabled: true,
});
Cloud Functions에서 App Check 토큰 강제 검증
// functions/src/index.ts
import { onCall, HttpsError } from 'firebase-functions/v2/https';
export const sensitiveAction = onCall(
{ enforceAppCheck: true }, // ✅ App Check 토큰이 없으면 자동으로 403 반환
async (request) => {
// request.app이 undefined이면 App Check 실패 (enforceAppCheck: false일 때만 체크)
const uid = request.auth?.uid;
if (!uid) throw new HttpsError('unauthenticated', '로그인이 필요합니다.');
return { success: true };
}
);
💡 TIP Firestore Security Rules에서도
request.app != null조건으로 App Check를 강제할 수 있습니다. Functions와 Rules 모두 적용하면 이중으로 보호됩니다.
// Firestore Security Rules
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
match /sensitiveData/{docId} {
allow read, write: if request.auth != null && request.app != null;
}
}
}
무중단 스키마 마이그레이션과 백필 전략
Firestore는 스키마리스(schemaless) 데이터베이스지만, 필드명 변경이나 데이터 구조 개편이 필요한 시점은 반드시 옵니다. 잘못 처리하면 구버전 클라이언트와 신버전 서버가 충돌하는 브레이킹 체인지가 발생합니다.
확장 우선(Expand-then-Contract) 패턴
스키마 변경은 세 단계로 나눕니다.
1단계 — Expand (확장): 새 필드를 추가하되 기존 필드는 유지합니다.
// 기존: { name: "홍길동" }
// 목표: { firstName: "홍", lastName: "길동" } 으로 분리
// 1단계: 두 필드 모두 쓰기 (하위 호환)
await userRef.update({
name: '홍길동', // ✅ 기존 클라이언트를 위해 유지
firstName: '홍', // ✅ 신규 필드 추가
lastName: '길동',
});
2단계 — Backfill (백필): Cloud Function 또는 Admin SDK 스크립트로 기존 문서를 일괄 변환합니다.
// scripts/backfill-name-split.ts
import { getFirestore } from 'firebase-admin/firestore';
import { initializeApp, cert } from 'firebase-admin/app';
initializeApp({ credential: cert('./service-account.json') });
const db = getFirestore();
const BATCH_SIZE = 400; // Firestore 배치 한도 500보다 여유 있게
async function backfill() {
let lastDoc: FirebaseFirestore.DocumentSnapshot | undefined;
let processed = 0;
while (true) {
let query = db.collection('users').limit(BATCH_SIZE);
if (lastDoc) query = query.startAfter(lastDoc);
const snap = await query.get();
if (snap.empty) break;
const batch = db.batch();
for (const doc of snap.docs) {
const { name } = doc.data();
if (!name || doc.data().firstName) continue; // 이미 변환된 문서 건너뜀
const parts = name.split(' ');
batch.update(doc.ref, {
firstName: parts[0] ?? '',
lastName: parts.slice(1).join(' ') ?? '',
});
}
await batch.commit();
processed += snap.size;
lastDoc = snap.docs[snap.docs.length - 1];
console.log(`Processed: ${processed}`);
// Firestore 쓰기 제한 회피를 위한 짧은 대기
await new Promise((r) => setTimeout(r, 200));
}
console.log('Backfill complete.');
}
backfill().catch(console.error);
3단계 — Contract (수축): 모든 클라이언트가 신규 필드만 읽는 버전으로 업데이트된 것이 확인되면 기존 name 필드를 제거합니다.
// 기존 필드 제거 (FieldValue.delete())
import { FieldValue } from 'firebase-admin/firestore';
await userRef.update({ name: FieldValue.delete() });
⚠️ 주의 백필 스크립트는 프로덕션 트래픽이 낮은 시간대(새벽)에 실행하고, 진행 상황을 로그로 기록하세요. 중간에 실패해도
continue로직 덕분에 재실행이 가능합니다.
점진적 롤아웃(Gradual Rollout)
Firebase Remote Config를 이용해 신규 로직을 일부 사용자에게만 먼저 적용합니다.
// src/featureFlags.ts
import { getRemoteConfig, fetchAndActivate, getValue } from 'firebase/remote-config';
const remoteConfig = getRemoteConfig(app);
remoteConfig.defaultConfig = { use_new_profile_schema: false };
await fetchAndActivate(remoteConfig);
export function useNewProfileSchema(): boolean {
return getValue(remoteConfig, 'use_new_profile_schema').asBoolean();
}
Remote Config 콘솔에서 조건을 설정해 특정 사용자 퍼센트(예: 5% → 20% → 100%)에 순서대로 배포하면 스키마 변경의 영향을 단계적으로 확인할 수 있습니다.
장애 대응: 백업·복구·롤백
프로덕션 장애는 언제든 발생합니다. 미리 준비된 절차가 없으면 복구 시간이 수십 배 늘어납니다.
Firestore 정기 백업 (Managed Export)
# Cloud Scheduler + gcloud로 매일 자동 내보내기
gcloud firestore export gs://myapp-backups/$(date +%Y-%m-%d) \
--project=myapp-prod
# 특정 컬렉션만 백업
gcloud firestore export gs://myapp-backups/$(date +%Y-%m-%d) \
--collection-ids=users,orders \
--project=myapp-prod
Cloud Functions로 스케줄 기반 자동 백업을 구현할 수도 있습니다.
// functions/src/backup.ts
import { onSchedule } from 'firebase-functions/v2/scheduler';
import { GoogleAuth } from 'google-auth-library';
export const scheduledBackup = onSchedule('every 24 hours', async () => {
const auth = new GoogleAuth({
scopes: ['https://www.googleapis.com/auth/datastore'],
});
const client = await auth.getClient();
const projectId = process.env.GCLOUD_PROJECT;
const bucket = `gs://${projectId}-backups/${new Date().toISOString().slice(0, 10)}`;
await client.request({
url: `https://firestore.googleapis.com/v1/projects/${projectId}/databases/(default):exportDocuments`,
method: 'POST',
data: { outputUriPrefix: bucket },
});
console.log(`Backup triggered to ${bucket}`);
});
백업에서 복원
# 특정 날짜 백업으로 전체 복원
gcloud firestore import gs://myapp-backups/2026-06-07 \
--project=myapp-prod
# 특정 컬렉션만 복원
gcloud firestore import gs://myapp-backups/2026-06-07 \
--collection-ids=orders \
--project=myapp-prod
⚠️ 주의
import는 백업에 포함된 문서를 동일 ID 기준으로 통째로 덮어씁니다(필드 단위 병합이 아닙니다). 단, 백업에 없던(import에 영향받지 않은) 문서는 삭제되지 않고 그대로 남습니다. 완전 초기화가 필요하면 대상 컬렉션을 먼저 삭제한 뒤 import하세요.
Cloud Functions 롤백
Functions는 배포 시 이전 버전이 삭제되므로 코드 자체를 이전 커밋으로 되돌려 재배포해야 합니다.
# 이전 커밋으로 롤백
git revert HEAD --no-edit
git push origin main
# 또는 특정 커밋으로 되돌리기
git checkout <이전-커밋-해시> -- functions/src/
git commit -m "hotfix: revert to stable functions"
git push origin main
CI/CD 파이프라인이 자동으로 재배포를 처리하도록 구성되어 있다면 push만으로 롤백이 완료됩니다.
장애 대응 체크리스트
| 단계 | 작업 |
|---|---|
| 감지 | Budget Alert 또는 Cloud Monitoring 알림 확인 |
| 격리 | Security Rules를 임시로 전체 차단 (allow read, write: if false) |
| 진단 | Cloud Logging에서 오류 패턴 확인 |
| 복구 | 백업 복원 또는 코드 롤백 |
| 검증 | Firestore 에뮬레이터 + 테스트 스위트로 정상 동작 확인 |
| 재개 | Security Rules 원복, 트래픽 점진적 복구 |
요약
- 멀티 프로젝트 분리(
dev/staging/prod)와.firebaserc별칭, GitHub Actions로 환경별 배포를 자동화하면 사고 범위를 격리할 수 있습니다. - 예산 알림과
maxInstances설정으로 과금 폭탄을 예방하고, Rate Limiter로 Firestore 읽기 급증을 차단합니다. - 순차 키는 핫스팟을 만듭니다. 자동 생성 ID를 사용하거나 카운터 샤딩으로 쓰기 처리량을 확장하세요.
- App Check는 신뢰할 수 없는 클라이언트의 API 호출을 차단해 스크레이퍼와 봇으로부터 비용을 보호합니다.
- Expand-then-Contract 패턴과 백필 스크립트로 무중단 스키마 마이그레이션을 수행하고, Remote Config로 점진적 롤아웃을 제어합니다.
- 정기 백업(Managed Export)과 롤백 절차를 사전에 준비하면 장애 복구 시간을 대폭 줄일 수 있습니다.
연습문제
- 현재 Firebase 프로젝트를
dev와prod두 개로 분리하고,develop브랜치에 push하면 dev 프로젝트에,main브랜치에 push하면 prod 프로젝트에 자동 배포되도록 GitHub Actions 워크플로우를 작성하세요.
힌트
.firebaserc에 별칭을 등록하고,github.ref_name으로 브랜치를 구분합니다.
- 사용자별 분당 API 호출 횟수를 30회로 제한하는 Cloud Function 미들웨어를 작성하세요. Firestore를 카운터 저장소로 사용하며, 제한 초과 시 HTTP 429를 반환해야 합니다.
힌트
runTransaction으로 원자적 읽기-쓰기를 구현하고,expireAt필드로 TTL을 설정합니다.
products컬렉션의 기존price필드(숫자)를{ amount: number, currency: string }객체로 변경하는 백필 스크립트를 작성하세요. 이미 변환된 문서는 건너뛰어야 합니다.
힌트
typeof doc.data().price === 'number'조건으로 미변환 문서를 식별합니다.
pageViews라는 글로벌 카운터가 초당 50회 이상 증가할 때 핫스팟 없이 정확한 합계를 유지하는 분산 카운터를 설계하고, 합계를 읽는 함수를 작성하세요.
힌트 샤드 수는 예상 TPS의 1.5배 이상으로 설정하고,
collection().get()으로 모든 샤드를 합산합니다.
💡 연습문제 풀이
불러오는 중…
댓글 0
“Firebase 심화” 강좌에 대한 댓글입니다.