dev.syw

콜드 스타트, 멱등성, 트랜잭션, 백그라운드 트리거와 태스크 큐로 신뢰성 있는 서버 로직을 작성한다.

Cloud Functions 심화 패턴

입문편에서 Cloud Functions의 기본 작성과 배포를 익혔다면, 이제는 "잘 동작하는" 함수를 넘어 "신뢰할 수 있는" 함수를 만드는 단계로 나아가야 합니다. 프로덕션 환경에서 함수는 예상치 못한 재시도, 동시 실행, 콜드 스타트 지연, 부분 실패 등 다양한 도전에 노출됩니다.

이 레슨에서는 콜드 스타트 원인과 완화 전략, Firestore 트리거의 at-least-once 보장에 따른 멱등성 설계, 트랜잭션과 배치 쓰기로 일관성을 유지하는 방법, 2세대 함수의 동시성 모델, Cloud Tasks와 Cloud Scheduler를 활용한 비동기 작업 분리, 그리고 시크릿 관리와 로컬 에뮬레이션까지 프로덕션 수준의 패턴을 집중적으로 다룹니다.

학습 목표

  • 콜드 스타트 원인을 이해하고, 전역 변수와 연결 풀 최적화로 인스턴스 재사용률을 높일 수 있다.
  • Firestore 트리거의 at-least-once 실행 보장을 이해하고, 멱등성 패턴으로 중복 실행을 안전하게 처리할 수 있다.
  • 트랜잭션배치 쓰기를 올바르게 사용해 데이터 일관성을 보장할 수 있다.
  • 2세대 Cloud Functions의 동시성(concurrency)과 최소 인스턴스 설정으로 성능을 튜닝할 수 있다.
  • Cloud TasksCloud Scheduler로 비동기·예약 작업을 함수 외부로 분리할 수 있다.

콜드 스타트와 인스턴스 재사용

Cloud Functions는 요청이 없을 때 인스턴스를 종료합니다. 새 요청이 들어오면 런타임 컨테이너를 새로 시작해야 하는데, 이 과정을 **콜드 스타트(cold start)**라고 합니다. Node.js 기준으로 수백 ms에서 수 초까지 걸릴 수 있으며, 사용자가 체감하는 지연으로 이어집니다.

콜드 스타트 발생 원인

상황설명
첫 배포 이후 첫 요청아직 워밍업된 인스턴스 없음
오랜 유휴 뒤 요청GCP가 인스턴스를 회수한 상태
트래픽 급증으로 스케일 아웃새 인스턴스가 추가로 시작
함수 재배포 직후이전 인스턴스는 폐기됨

전역 변수로 연결 재사용하기

함수 핸들러 에서 초기화한 변수는 동일 인스턴스의 후속 호출에서 재사용됩니다. 이를 활용하면 Firestore Admin SDK 초기화, 외부 API 클라이언트, DB 연결 풀 등 무거운 객체 생성 비용을 절감할 수 있습니다.

// functions/src/index.ts
import { initializeApp, getApps } from "firebase-admin/app";
import { getFirestore } from "firebase-admin/firestore";
import * as functions from "firebase-functions/v2/https";

// ✅ 전역 초기화 — 인스턴스가 살아있는 동안 재사용됨
if (getApps().length === 0) {
  initializeApp();
}
const db = getFirestore();

export const getUserProfile = functions.onRequest(async (req, res) => {
  // ✅ db는 이미 초기화된 상태 — 재연결 비용 없음
  const uid = req.query.uid as string;
  const snap = await db.collection("users").doc(uid).get();
  res.json(snap.data() ?? {});
});
// ❌ 잘못된 예: 호출마다 새 인스턴스를 만드는 패턴
export const badExample = functions.onRequest(async (req, res) => {
  const { initializeApp } = await import("firebase-admin/app"); // ❌ 함수 내부에서 매번 import
  const app = initializeApp(); // ❌ 호출마다 새 앱 생성 시도 → 오류 또는 낭비
  // ...
});

💡 TIP getApps().length === 0 가드를 사용하면 로컬 에뮬레이터와 실제 환경 모두에서 "already initialized" 오류 없이 안전하게 초기화할 수 있습니다.

무거운 의존성 지연 로딩

초기화 비용이 큰 라이브러리(예: sharp, pdf-lib, puppeteer)는 함수가 실제로 필요한 경우에만 로드되도록 동적 import를 사용할 수 있습니다.

let sharpInstance: typeof import("sharp") | null = null;

async function getSharp() {
  if (!sharpInstance) {
    // ✅ 첫 호출에만 로드, 이후에는 캐시된 참조 재사용
    sharpInstance = (await import("sharp")).default;
  }
  return sharpInstance;
}

Firestore 트리거의 at-least-once 보장과 멱등성 설계

Firestore 트리거를 포함한 백그라운드 함수는 at-least-once(최소 한 번) 실행을 보장합니다. 다만 2세대(v2) 이벤트 함수는 기본적으로 재시도가 비활성화되어 있어, 함수가 오류로 종료되면 기본 설정에서는 이벤트가 드롭되고 자동으로 재시도되지 않습니다. 재시도를 원한다면 함수 옵션에 retry: true를 명시적으로 설정해야 합니다. 그러나 재시도 설정과 무관하게 at-least-once 전달 모델의 특성상 같은 이벤트가 두 번 이상 전달되어 함수가 중복 호출될 수 있으므로, 멱등성 설계가 반드시 필요합니다.

이것이 문제가 되는 이유는 다음과 같습니다.

  • 이메일 발송 함수가 두 번 실행되면 사용자가 메일을 두 통 받는다.
  • 카운터 증가 로직이 두 번 실행되면 숫자가 의도보다 크게 된다.
  • 결제 처리 함수가 두 번 실행되면 이중 청구가 발생한다.

**멱등성(idempotency)**이란 같은 연산을 여러 번 실행해도 결과가 한 번 실행한 것과 동일한 성질을 말합니다.

이벤트 ID로 중복 실행 방지

Firestore 트리거 이벤트는 고유한 이벤트 ID를 가집니다. 이를 Firestore에 기록하여 "이미 처리했다면 건너뛰기" 패턴을 구현할 수 있습니다.

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

const db = getFirestore();

export const onOrderCreated = onDocumentCreated(
  "orders/{orderId}",
  async (event) => {
    const eventId = event.id; // 고유 이벤트 식별자
    const orderData = event.data?.data();
    if (!orderData) return;

    const processedRef = db.collection("_processedEvents").doc(eventId);

    // ✅ 트랜잭션으로 중복 체크와 처리를 원자적으로 수행
    await db.runTransaction(async (tx) => {
      const processedSnap = await tx.get(processedRef);
      if (processedSnap.exists) {
        console.log(`Event ${eventId} already processed. Skipping.`);
        return; // 이미 처리됨 — 조기 종료
      }

      // 실제 비즈니스 로직
      const userRef = db.collection("users").doc(orderData.userId);
      tx.update(userRef, {
        orderCount: FieldValue.increment(1),
        lastOrderAt: FieldValue.serverTimestamp(),
      });

      // 처리 완료 표시 (TTL 필드로 나중에 정리 가능)
      tx.set(processedRef, {
        processedAt: FieldValue.serverTimestamp(),
        eventType: "onOrderCreated",
      });
    });
  }
);

⚠️ 주의 _processedEvents 컬렉션은 시간이 지남에 따라 커질 수 있습니다. TTL 정책(Firestore TTL)이나 Cloud Scheduler로 오래된 문서를 주기적으로 정리하는 것을 권장합니다.

상태 기반 멱등성

이벤트 ID 기록 외에도, 문서 자체의 상태 필드를 활용하는 방식이 있습니다.

export const onPaymentPending = onDocumentUpdated(
  "payments/{paymentId}",
  async (event) => {
    const before = event.data?.before.data();
    const after = event.data?.after.data();

    // ✅ 상태가 pending → processing으로 변할 때만 처리
    if (before?.status !== "pending" || after?.status !== "processing") {
      return; // 멱등성 보장: 의도한 전환이 아니면 무시
    }

    // 결제 처리 로직...
    await processPayment(after);
  }
);

트랜잭션과 배치 쓰기로 일관성 유지하기

여러 문서를 함께 수정해야 할 때 개별 set/update 호출을 나열하면 중간에 실패했을 경우 데이터가 부분적으로만 반영되는 부분 실패 상태가 됩니다.

트랜잭션 (읽기 + 쓰기)

트랜잭션은 읽기 결과에 따라 쓰기를 결정해야 할 때 사용합니다. 트랜잭션 내에서 읽은 문서가 커밋 전에 다른 클라이언트에 의해 변경되면 Firestore가 자동으로 재시도합니다.

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

const db = getFirestore();

/**
 * 포인트를 sender에서 receiver로 이전하는 함수
 * 잔액 부족 시 전이 취소 (원자적)
 */
async function transferPoints(
  senderId: string,
  receiverId: string,
  amount: number
): Promise<void> {
  await db.runTransaction(async (tx) => {
    const senderRef = db.collection("wallets").doc(senderId);
    const receiverRef = db.collection("wallets").doc(receiverId);

    const [senderSnap, receiverSnap] = await Promise.all([
      tx.get(senderRef),
      tx.get(receiverRef),
    ]);

    const senderBalance = senderSnap.data()?.points ?? 0;
    if (senderBalance < amount) {
      // 트랜잭션 내에서 예외를 throw하면 자동 롤백
      throw new Error(`잔액 부족: 현재 ${senderBalance}포인트`);
    }

    tx.update(senderRef, { points: FieldValue.increment(-amount) });
    tx.update(receiverRef, { points: FieldValue.increment(amount) });

    // 이체 기록 남기기 (같은 트랜잭션 내 원자적 수행)
    const historyRef = db.collection("transferHistory").doc();
    tx.set(historyRef, {
      senderId,
      receiverId,
      amount,
      timestamp: FieldValue.serverTimestamp(),
    });
  });
}

배치 쓰기 (쓰기만)

읽기 없이 여러 문서를 원자적으로 쓰는 경우에는 트랜잭션보다 배치 쓰기가 더 효율적입니다. 재시도 로직이 없어 오버헤드가 적습니다.

async function archiveAndDeletePosts(postIds: string[]): Promise<void> {
  // 배치 하나당 최대 500개 문서 제한
  const BATCH_LIMIT = 400; // 여유를 두고 400으로 설정

  for (let i = 0; i < postIds.length; i += BATCH_LIMIT) {
    const chunk = postIds.slice(i, i + BATCH_LIMIT);
    const batch = db.batch();

    for (const postId of chunk) {
      const srcRef = db.collection("posts").doc(postId);
      const archiveRef = db.collection("archivedPosts").doc(postId);

      // ✅ 아카이브 컬렉션에 복사 + 원본 삭제 — 원자적으로 수행
      batch.set(archiveRef, { postId, archivedAt: FieldValue.serverTimestamp() });
      batch.delete(srcRef);
    }

    await batch.commit();
  }
}

⚠️ 주의 트랜잭션 내에서 읽기는 반드시 쓰기보다 먼저 수행해야 합니다. 쓰기 후 읽기는 Firestore 트랜잭션 규칙 위반으로 오류가 발생합니다.

구분트랜잭션배치 쓰기
읽기 포함 가능아니오
충돌 재시도자동없음
최대 문서 수500500
적합한 상황잔액 검사 후 차감 등대량 쓰기, 마이그레이션

2세대 함수, 동시성과 최소 인스턴스 설정

Cloud Functions 2세대(v2)는 Cloud Run 인프라 위에서 동작하며, 한 인스턴스가 여러 요청을 동시에 처리할 수 있습니다. 1세대는 인스턴스당 요청 1개였지만, v2는 기본 동시성이 80이고 최대 1000까지 설정 가능합니다.

v2 함수 기본 구조와 설정

import { onRequest } from "firebase-functions/v2/https";
import { onDocumentCreated } from "firebase-functions/v2/firestore";
import { defineSecret } from "firebase-functions/params";

const stripeKey = defineSecret("STRIPE_SECRET_KEY");

export const processWebhook = onRequest(
  {
    region: "asia-northeast3",   // 서울 리전
    concurrency: 50,              // 인스턴스당 최대 동시 요청 수
    minInstances: 1,              // ✅ 최소 인스턴스 — 콜드 스타트 완전 제거
    maxInstances: 20,             // 최대 스케일 아웃 수
    memory: "512MiB",             // 메모리 할당
    timeoutSeconds: 60,
    secrets: [stripeKey],         // Secret Manager 연동
  },
  async (req, res) => {
    const key = stripeKey.value(); // 런타임에 안전하게 접근
    // ...
    res.sendStatus(200);
  }
);

동시성 설정 가이드

동시성은 성능과 안전성의 균형이 중요합니다.

// ✅ I/O 바운드 작업 (DB 조회, 외부 API 호출) — 높은 동시성 적합
export const fetchUserData = onRequest(
  { concurrency: 80, memory: "256MiB" },
  async (req, res) => {
    const data = await db.collection("users").doc(req.query.uid as string).get();
    res.json(data.data());
  }
);

// ✅ CPU 바운드 작업 (이미지 처리, 암호화) — 낮은 동시성 또는 1로 설정
export const resizeImage = onRequest(
  { concurrency: 1, memory: "1GiB", cpu: 2 },
  async (req, res) => {
    // CPU 집중 작업: 동시성을 낮춰 인스턴스별 자원 확보
    const sharp = await getSharp();
    // ...
  }
);

최소 인스턴스(minInstances)의 비용 고려

minInstances: 1로 설정하면 콜드 스타트가 없어지지만 항상 인스턴스가 가동되므로 비용이 발생합니다. 사용자가 직접 호출하는 중요 API에는 적용하고, 백그라운드 트리거나 배치 작업에는 0으로 두는 것이 일반적입니다.

💡 TIP 트래픽이 예측 가능한 서비스라면 minInstances를 낮게 설정하고 Cloud Scheduler로 주기적으로 워밍업 요청을 보내는 방법도 비용 효율적입니다.

Cloud Tasks와 Cloud Scheduler로 비동기·예약 작업 분리

HTTP 요청 내에서 오래 걸리는 작업(이메일 발송, 대용량 처리 등)을 실행하면 응답 지연과 타임아웃 위험이 있습니다. Cloud Tasks는 이런 작업을 큐에 넣어 비동기로 처리하게 해주고, Cloud Scheduler는 정해진 시각에 자동으로 함수를 호출합니다.

Cloud Tasks로 지연 처리 구현

import { onRequest } from "firebase-functions/v2/https";
import { CloudTasksClient } from "@google-cloud/tasks";

const tasksClient = new CloudTasksClient(); // ✅ 전역 선언으로 재사용

const PROJECT = process.env.GCLOUD_PROJECT!;
const LOCATION = "asia-northeast3";
const QUEUE = "email-queue";

// 1️⃣ 주문 생성 시 이메일 태스크를 큐에 등록
export const createOrder = onRequest(async (req, res) => {
  const orderData = req.body;

  // 빠른 응답을 위해 DB 저장만 즉시 수행
  const orderRef = db.collection("orders").doc();
  await orderRef.set({ ...orderData, status: "pending" });

  // 이메일 발송은 태스크로 분리
  const queuePath = tasksClient.queuePath(PROJECT, LOCATION, QUEUE);
  await tasksClient.createTask({
    parent: queuePath,
    task: {
      httpRequest: {
        httpMethod: "POST",
        url: `https://${LOCATION}-${PROJECT}.cloudfunctions.net/sendOrderEmail`,
        headers: { "Content-Type": "application/json" },
        body: Buffer.from(JSON.stringify({ orderId: orderRef.id })).toString("base64"),
        oidcToken: {
          serviceAccountEmail: `${PROJECT}@appspot.gserviceaccount.com`,
        },
      },
      // ✅ 5분 후에 실행 (scheduleTime을 설정하면 지연 실행 가능)
      scheduleTime: {
        seconds: Math.floor(Date.now() / 1000) + 300,
      },
    },
  });

  res.json({ orderId: orderRef.id, message: "주문이 생성되었습니다." });
});

// 2️⃣ 태스크 핸들러 — Cloud Tasks가 호출하는 함수
export const sendOrderEmail = onRequest(
  { invoker: "private" }, // Cloud Tasks 서비스 계정만 호출 가능
  async (req, res) => {
    const { orderId } = req.body as { orderId: string };

    // ✅ 멱등성 보장: 이미 발송된 주문이면 건너뜀
    const orderRef = db.collection("orders").doc(orderId);
    const order = await orderRef.get();
    if (order.data()?.emailSent) {
      res.sendStatus(200);
      return;
    }

    await sendEmail(order.data()!);
    await orderRef.update({ emailSent: true });
    res.sendStatus(200);
  }
);

Cloud Scheduler로 정기 작업 등록

import { onSchedule } from "firebase-functions/v2/scheduler";
import { getFirestore, Timestamp } from "firebase-admin/firestore";

// ✅ 매일 자정(KST)에 만료된 세션 정리
export const cleanExpiredSessions = onSchedule(
  {
    schedule: "0 0 * * *", // timeZone이 Asia/Seoul이므로 KST 00:00(매일 자정)
    timeZone: "Asia/Seoul",
    region: "asia-northeast3",
  },
  async () => {
    const db = getFirestore();
    const cutoff = Timestamp.fromDate(
      new Date(Date.now() - 30 * 24 * 60 * 60 * 1000) // 30일 전
    );

    const expiredQuery = db
      .collection("sessions")
      .where("lastActiveAt", "<", cutoff)
      .limit(500);

    const snapshot = await expiredQuery.get();
    if (snapshot.empty) return;

    const batch = db.batch();
    snapshot.docs.forEach((doc) => batch.delete(doc.ref));
    await batch.commit();

    console.log(`${snapshot.size}개의 만료 세션 삭제 완료`);
  }
);

💡 TIP Cloud Scheduler 함수는 반환값이 없으며(void), 실패 시 Cloud Console에서 재시도 정책을 설정할 수 있습니다. 스케줄 문법은 unix-cron 형식을 따릅니다.

시크릿 관리와 로컬 에뮬레이션

Secret Manager로 시크릿 안전하게 관리

API 키와 같은 민감한 값을 functions.config()(v1 구문)나 환경변수 파일에 직접 넣으면 코드 저장소 노출 위험이 있습니다. v2에서는 Google Secret Manager와 통합된 defineSecret을 사용합니다.

# 시크릿 등록
echo -n "sk_live_xxxx" | gcloud secrets create STRIPE_SECRET_KEY \
  --data-file=- --replication-policy=automatic

# 함수에 접근 권한 부여
gcloud secrets add-iam-policy-binding STRIPE_SECRET_KEY \
  --member="serviceAccount:PROJECT_ID@appspot.gserviceaccount.com" \
  --role="roles/secretmanager.secretAccessor"
import { defineSecret } from "firebase-functions/params";
import { onRequest } from "firebase-functions/v2/https";

// ✅ 배포 시 자동으로 Secret Manager에서 값을 주입
const stripeSecretKey = defineSecret("STRIPE_SECRET_KEY");
const sendgridKey = defineSecret("SENDGRID_API_KEY");

export const chargeCustomer = onRequest(
  { secrets: [stripeSecretKey, sendgridKey] }, // ✅ 사용할 시크릿 명시
  async (req, res) => {
    const stripe = require("stripe")(stripeSecretKey.value()); // 런타임에 안전하게 접근
    // ...
  }
);

로컬 에뮬레이터로 함수 테스트

Firebase 에뮬레이터 스위트를 사용하면 실제 GCP 서비스를 호출하지 않고 로컬에서 Functions, Firestore, Auth를 함께 테스트할 수 있습니다.

# 에뮬레이터 시작 (Firestore + Functions + Auth 포함)
firebase emulators:start --only functions,firestore,auth

# 시크릿을 로컬에서 흉내낼 때는 .env.local 파일 사용
# functions/.env.local
# STRIPE_SECRET_KEY=sk_test_local_dummy
// functions/src/__tests__/transferPoints.test.ts
import { initializeApp } from "firebase-admin/app";
import { getFirestore } from "firebase-admin/firestore";
import { transferPoints } from "../wallets";

// 에뮬레이터 환경 변수 설정
process.env.FIRESTORE_EMULATOR_HOST = "localhost:8080";

const app = initializeApp({ projectId: "demo-test" });
const db = getFirestore(app);

describe("transferPoints", () => {
  beforeEach(async () => {
    // 테스트 데이터 준비
    await db.collection("wallets").doc("alice").set({ points: 100 });
    await db.collection("wallets").doc("bob").set({ points: 50 });
  });

  it("정상 이전 시 잔액이 업데이트된다", async () => {
    await transferPoints("alice", "bob", 30);

    const alice = await db.collection("wallets").doc("alice").get();
    const bob = await db.collection("wallets").doc("bob").get();

    expect(alice.data()?.points).toBe(70);
    expect(bob.data()?.points).toBe(80);
  });

  it("잔액 부족 시 예외를 throw하고 잔액이 변경되지 않는다", async () => {
    await expect(transferPoints("alice", "bob", 200)).rejects.toThrow("잔액 부족");

    const alice = await db.collection("wallets").doc("alice").get();
    expect(alice.data()?.points).toBe(100); // 변경 없음
  });
});

💡 TIP firebase.json"emulators" 설정을 추가하면 CI/CD 파이프라인에서도 firebase emulators:exec --only functions,firestore "npm test"로 에뮬레이터를 자동으로 시작하고 종료할 수 있습니다.

요약

  • 콜드 스타트는 전역 변수에 Admin SDK와 연결 객체를 초기화하고, v2의 minInstances를 설정하여 완화할 수 있다.
  • Firestore 트리거는 at-least-once 실행을 보장하므로, 이벤트 ID를 Firestore에 기록하거나 상태 전환 조건을 체크하여 멱등성을 반드시 구현해야 한다.
  • 트랜잭션은 읽기 결과에 따른 조건부 쓰기에, 배치 쓰기는 읽기 없이 여러 문서를 원자적으로 쓸 때 사용한다.
  • 2세대 함수는 인스턴스당 동시 요청 처리가 가능하며, I/O 바운드 작업에는 높은 concurrency, CPU 바운드에는 낮은 값을 설정한다.
  • Cloud Tasks로 오래 걸리는 작업을 비동기로 분리하고, Cloud Scheduler로 주기적 정리·집계 작업을 예약한다.
  • Secret Manager + defineSecret으로 API 키를 코드 밖에서 안전하게 관리하고, Firebase 에뮬레이터로 로컬에서 전체 스택을 테스트한다.

연습문제

  1. orders/{orderId} 문서가 생성될 때 실행되는 Firestore 트리거 함수가 있습니다. 이 함수가 동일한 이벤트에 대해 두 번 실행될 경우, users/{userId}.orderCount 필드가 두 번 증가하는 버그가 있습니다. 이벤트 ID를 활용해 멱등성을 보장하도록 수정하세요.

    힌트 event.id를 별도 컬렉션에 기록하고, 트랜잭션으로 중복 체크와 쓰기를 묶으세요.

  2. accounts 컬렉션의 두 문서 사이에서 balance 필드를 원자적으로 이전하는 transferBalance(fromId, toId, amount) 함수를 작성하세요. 잔액이 부족하면 이전을 중단하고 오류를 반환해야 합니다.

    힌트 db.runTransaction 내에서 두 문서를 먼저 읽은 뒤 잔액을 검증하고 FieldValue.increment로 업데이트하세요.

  3. v2 HTTP 함수 generateReport가 보고서 생성에 20~30초가 걸립니다. 사용자 요청에 즉시 응답하고 실제 생성은 Cloud Tasks로 처리하도록 구조를 변경하세요. 태스크 핸들러는 완료 후 reports/{reportId} 문서의 status"completed"로 업데이트해야 합니다.

    힌트 메인 함수에서는 Firestore에 status: "pending" 문서를 먼저 만들고 태스크를 큐에 등록한 뒤 즉시 응답하세요.

  4. 매주 월요일 오전 9시(KST)에 지난 7일간 가입한 신규 사용자 수를 집계해 stats/weekly 문서의 newUsersLastWeek 필드를 업데이트하는 Cloud Scheduler 함수를 작성하세요.

    힌트 timeZone: "Asia/Seoul"을 지정하면 cron 표현식이 KST 기준으로 해석되므로 schedule0 9 * * 1(월요일 09:00 KST)로 작성하고, users 컬렉션에서 createdAt 필드로 범위 쿼리를 수행하세요.

💡 연습문제 풀이

불러오는 중…

함께 보면 좋은 자료

댓글 0

Firebase 심화” 강좌에 대한 댓글입니다.

댓글을 작성하려면 로그인이 필요합니다.