Wednesday, July 19, 2023

서버리스 아키텍처와 Firebase Functions 심층 분석

서문: 서버리스 혁명의 서막

클라우드 컴퓨팅의 등장은 소프트웨어 개발 및 배포의 패러다임을 근본적으로 바꾸어 놓았습니다. 초기에는 물리적 서버를 가상 머신(VM)으로 대체하는 수준에서 시작하여, 컨테이너 기술을 통해 애플리케이션을 격리하고 이식성을 높이는 단계로 발전했습니다. 그리고 이제, 우리는 '서버리스(Serverless)'라는 또 다른 거대한 변화의 물결 위에 서 있습니다. 이름만 들으면 마치 서버가 완전히 사라진 것처럼 느껴지지만, 실제로는 개발자가 서버의 존재를 의식하거나 직접 관리할 필요가 없다는 의미에 더 가깝습니다. 인프라 구축, 운영 체제 관리, 패치, 스케일링, 부하 분산 등 기존의 백엔드 개발자가 짊어져야 했던 수많은 짐을 클라우드 제공업체에게 위임하는 것입니다.

이러한 서버리스 아키텍처의 핵심에는 'FaaS(Function as a Service)'가 자리 잡고 있습니다. FaaS는 특정 이벤트에 의해 트리거될 때만 실행되는 작은 코드 조각, 즉 '함수(Function)' 단위로 백엔드 로직을 배포하는 모델입니다. 함수는 독립적으로 실행되고, 사용된 만큼만 비용을 지불하며, 트래픽이 급증하면 자동으로 확장됩니다. 개발자는 오로지 비즈니스 로직 구현에만 집중할 수 있게 되어, 개발 속도를 획기적으로 높이고 운영 비용을 절감할 수 있습니다.

Google의 Firebase 플랫폼이 제공하는 FaaS 솔루션이 바로 Firebase Functions입니다. Firebase Functions는 Firebase 생태계의 다른 서비스(Firestore, Realtime Database, Authentication, Cloud Storage 등)와 완벽하게 통합되어, 이벤트 기반의 반응형 애플리케이션을 매우 쉽고 강력하게 구축할 수 있도록 지원합니다. 본 문서는 Firebase Functions의 기본 개념부터 시작하여, 실무에서 마주할 수 있는 다양한 시나리오와 고급 활용법, 그리고 최적화 전략까지 심도 있게 다룰 것입니다. 서버리스 아키텍처에 첫발을 내딛는 개발자부터, 이미 Firebase를 사용하고 있지만 Functions를 더욱 깊이 있게 활용하고 싶은 개발자 모두에게 유용한 지침이 될 것입니다.

1장: 개발 환경 구축 및 첫걸음

Firebase Functions를 사용하기 위한 여정은 개발 환경을 올바르게 설정하는 것에서부터 시작됩니다. 이 과정은 몇 가지 필수 도구를 설치하고 Firebase 프로젝트와 로컬 개발 환경을 연결하는 작업을 포함합니다.

1.1. Node.js와 npm: Functions의 실행 환경

Firebase Functions는 기본적으로 Node.js 런타임 환경에서 실행됩니다. (현재는 Python, Go, Java, .NET, Ruby, PHP 등 다양한 언어를 지원하지만, 가장 널리 사용되고 문서화가 잘 되어 있는 것은 Node.js 기반의 TypeScript와 JavaScript입니다.) 따라서 컴퓨터에 Node.js와 그 패키지 매니저인 npm(Node Package Manager)이 설치되어 있어야 합니다. Node.js는 서버 사이드에서 JavaScript 코드를 실행할 수 있게 해주는 런타임이며, npm은 Firebase Functions SDK를 포함한 다양한 라이브러리와 도구를 설치하고 관리하는 데 사용됩니다.

터미널 또는 명령 프롬프트에서 아래 명령어를 실행하여 Node.js와 npm이 이미 설치되어 있는지, 버전은 무엇인지 확인할 수 있습니다. Firebase Functions는 특정 Node.js 버전을 요구하므로, 공식 문서를 통해 지원되는 버전을 확인하고 설치하는 것이 중요합니다 (일반적으로 LTS - Long Term Support 버전을 권장합니다).


# Node.js 버전 확인
$ node -v
v18.17.0

# npm 버전 확인
$ npm -v
9.6.7

만약 설치되어 있지 않다면, Node.js 공식 웹사이트에서 LTS 버전을 다운로드하여 설치하세요. Node.js를 설치하면 npm은 자동으로 함께 설치됩니다.

1.2. Firebase CLI: 강력한 커맨드 라인 인터페이스

Firebase CLI(Command Line Interface)는 터미널에서 Firebase 프로젝트를 관리하고, Functions를 비롯한 다양한 Firebase 서비스를 배포하고 테스트할 수 있게 해주는 필수 도구입니다. npm을 사용하여 Firebase CLI를 전역(global)으로 설치합니다. `-g` 플래그는 시스템의 어느 위치에서나 `firebase` 명령어를 사용할 수 있게 해줍니다.


# npm을 사용하여 Firebase CLI 전역 설치
$ npm install -g firebase-tools

설치가 완료되면, Google 계정을 사용하여 Firebase CLI에 로그인해야 합니다. 이 과정은 웹 브라우저를 통해 진행되며, 한 번 로그인하면 로컬 컴퓨터에 인증 정보가 저장되어 이후에는 다시 로그인할 필요가 없습니다.


# Firebase에 로그인
$ firebase login

1.3. 프로젝트 초기화: 로컬과 클라우드의 연결

이제 로컬 프로젝트 폴더를 만들고 그 안에서 Firebase 프로젝트를 초기화할 차례입니다. `firebase init` 명령어는 현재 디렉터리를 Firebase 프로젝트와 연결하고 필요한 설정 파일과 폴더 구조를 생성하는 역할을 합니다.


# 프로젝트를 위한 새 디렉터리 생성 및 이동
$ mkdir my-functions-project
$ cd my-functions-project

# Firebase 프로젝트 초기화
$ firebase init

firebase init을 실행하면 CLI는 어떤 Firebase 서비스를 사용할 것인지 묻는 대화형 프롬프트를 표시합니다. 키보드 화살표 키로 'Functions'를 선택하고 스페이스바를 눌러 체크한 후 엔터를 누릅니다. 이후의 과정은 다음과 같습니다.

  1. 프로젝트 선택: 기존 Firebase 프로젝트에 연결할지, 아니면 새로운 프로젝트를 생성할지 선택합니다. 보통은 Firebase Console에서 미리 생성해 둔 프로젝트를 선택합니다.
  2. 언어 선택: 함수를 작성할 언어를 선택합니다. JavaScript와 TypeScript 중에 선택할 수 있습니다. TypeScript는 정적 타이핑을 지원하여 대규모 프로젝트에서 코드의 안정성과 유지보수성을 높여주므로 강력히 권장됩니다.
  3. ESLint 사용 여부: 코드 스타일을 검사하고 잠재적인 오류를 찾아주는 도구인 ESLint를 사용할지 묻습니다. 사용하는 것이 좋습니다.
  4. 의존성 설치: 필요한 npm 모듈을 지금 바로 설치할지 묻습니다. 'y'를 선택하면 `package.json` 파일에 명시된 `firebase-functions`와 `firebase-admin` SDK가 설치됩니다.

초기화가 완료되면 프로젝트 폴더 내에 `functions`라는 하위 디렉터리가 생성됩니다. 이 디렉터리가 바로 우리가 클라우드 함수 코드를 작성하고 관리할 공간입니다. 내부 구조는 다음과 같습니다.

  • node_modules/: 프로젝트 의존성(라이브러리)이 설치되는 폴더
  • src/ (TypeScript 선택 시) 또는 index.js (JavaScript 선택 시): 실제 함수 코드를 작성하는 파일
  • package.json: 프로젝트의 정보와 의존성 목록을 정의하는 파일
  • .eslintrc.js: ESLint 설정 파일
  • tsconfig.json (TypeScript 선택 시): TypeScript 컴파일러 설정 파일

이로써 Firebase Functions 개발을 위한 모든 준비가 끝났습니다. 이제 첫 번째 함수를 작성하고 배포해볼 시간입니다.

2장: 첫 번째 함수 작성, 테스트, 그리고 배포

환경 설정이 완료되었으니, 이제 가장 간단한 형태의 클라우드 함수인 HTTP 함수를 만들어 보겠습니다. 이 함수는 특정 URL로 HTTP 요청을 받으면 "Hello, World!"라는 응답을 보내는 역할을 합니다.

2.1. "Hello World" 함수 작성하기

`functions` 디렉터리 안의 `index.js` (또는 `src/index.ts`) 파일을 열고 기본으로 생성된 주석 처리된 코드를 지운 후, 아래와 같이 작성합니다.


// functions/index.js

// Firebase Functions SDK를 가져옵니다.
const functions = require("firebase-functions");

// "helloWorld"라는 이름으로 HTTP 함수를 내보냅니다(export).
// 이 함수는 HTTP 요청(request)을 수신하고 응답(response)을 보냅니다.
exports.helloWorld = functions.https.onRequest((request, response) => {
  // 함수가 호출되었을 때 로그를 남깁니다. 이 로그는 Firebase Console에서 확인할 수 있습니다.
  functions.logger.info("Hello logs!", {structuredData: true});
  
  // 클라이언트에게 "Hello from Firebase!"라는 텍스트를 응답으로 보냅니다.
  response.send("Hello from Firebase!");
});

코드를 한 줄씩 분석해 보겠습니다.

  • const functions = require("firebase-functions");: Firebase Functions를 작성하는 데 필요한 모든 도구와 트리거가 포함된 `firebase-functions` SDK를 불러옵니다.
  • exports.helloWorld = ...: Node.js의 모듈 시스템 문법입니다. `exports` 객체에 속성을 추가하면 해당 속성이 클라우드 함수로 배포됩니다. 즉, `helloWorld`가 우리가 배포할 함수의 이름이 됩니다.
  • functions.https.onRequest(...): 이것이 바로 '트리거(trigger)'입니다. `https.onRequest`는 HTTP 요청이 들어올 때마다 이 함수를 실행하라고 Firebase에 알리는 역할을 합니다.
  • (request, response) => { ... }: 콜백 함수입니다. 실제 로직이 이 안에 담깁니다. `request` 객체에는 요청 헤더, 본문, 쿼리 파라미터 등 클라이언트가 보낸 정보가 담겨 있고, `response` 객체는 클라이언트에게 응답을 보내는 데 사용됩니다. 이 구조는 Node.js의 인기 웹 프레임워크인 Express.js와 매우 유사하여 익숙한 개발자가 많을 것입니다.

2.2. 로컬에서 테스트하기: Firebase Emulator Suite

함수를 작성한 후 매번 클라우드에 배포하여 테스트하는 것은 매우 비효율적입니다. 배포에는 수십 초에서 수 분이 소요될 수 있기 때문입니다. Firebase는 이러한 불편함을 해소하기 위해 Emulator Suite라는 강력한 로컬 테스트 도구를 제공합니다.

프로젝트 루트 디렉터리에서 다음 명령어를 실행하여 에뮬레이터를 시작합니다.


# Firebase Emulator Suite 시작
$ firebase emulators:start

명령어를 실행하면 CLI가 현재 프로젝트에 설정된 서비스를 감지하고(이 경우 Functions), 로컬에서 해당 서비스들을 시뮬레이션하기 시작합니다. 터미널에는 각 함수가 로컬에서 실행되는 URL이 표시됩니다.


...
✔  functions: Emulator started at http://127.0.0.1:5001
i  functions: Watching "/path/to/my-functions-project/functions" for Cloud Functions...
✔  functions[us-central1-helloWorld]: http function initialized (http://127.0.0.1:5001/your-project-id/us-central1/helloWorld).
...

이제 웹 브라우저나 `curl` 같은 도구를 사용하여 출력된 URL(`http://127.0.0.1:5001/.../helloWorld`)로 접속하면, "Hello from Firebase!"라는 응답을 즉시 확인할 수 있습니다. 코드를 수정한 후 저장하면 에뮬레이터가 자동으로 변경 사항을 감지하고 함수를 다시 로드해주므로, 매우 빠르고 효율적인 개발-테스트 사이클을 경험할 수 있습니다.

2.3. 클라우드에 배포하기

로컬 테스트를 통해 함수의 동작을 확인했다면, 이제 전 세계 어디서든 접근할 수 있도록 실제 Firebase 클라우드 환경에 배포할 차례입니다. 배포는 `firebase deploy` 명령어를 사용합니다.


# functions 서비스만 배포
$ firebase deploy --only functions

# 만약 여러 함수 중 특정 함수만 배포하고 싶다면:
$ firebase deploy --only functions:helloWorld

--only functions 플래그는 다른 Firebase 서비스(Hosting, Firestore Rules 등)는 제외하고 Functions만 배포하겠다는 의미입니다. 배포가 시작되면 CLI는 코드를 압축하여 클라우드에 업로드하고, 필요한 인프라를 프로비저닝합니다. 몇 분 후 배포가 성공적으로 완료되면, 터미널에 해당 함수의 공개 URL이 표시됩니다. 이 URL은 이제 로컬 에뮬레이터 URL이 아닌, 실제 인터넷을 통해 접근 가능한 주소입니다.

축하합니다! 당신은 방금 첫 번째 서버리스 함수를 성공적으로 만들고 배포했습니다. 이 간단한 과정은 Firebase Functions가 가진 강력함과 편리함의 시작에 불과합니다.

3장: 다양한 트리거의 세계: 이벤트 기반 아키텍처의 핵심

Firebase Functions의 진정한 힘은 HTTP 요청뿐만 아니라 Firebase 생태계 내에서 발생하는 거의 모든 이벤트에 응답할 수 있다는 점에서 나옵니다. 이러한 이벤트 소스를 '트리거(Trigger)'라고 부릅니다. 트리거를 사용하면 각 서비스가 독립적으로 동작하면서도 서로 유기적으로 연결된 정교한 백엔드 시스템을 구축할 수 있습니다.

3.1. HTTP 트리거 심화

앞서 살펴본 `onRequest` 외에, 클라이언트 앱(웹, iOS, Android)에서 직접 호출하기 위해 특별히 설계된 `onCall`이라는 또 다른 HTTP 트리거가 있습니다.

Callable Functions (`onCall`)

`onCall` 트리거는 클라이언트 앱에서 Firebase SDK를 통해 직접 함수를 호출할 때 사용됩니다. `onRequest`와 비교했을 때 몇 가지 중요한 장점이 있습니다.

  • 자동 인증 처리: 클라이언트가 로그인 상태라면 Firebase Authentication ID 토큰이 자동으로 함수에 전달되고 서버에서 검증됩니다. 개발자가 직접 토큰을 파싱하고 검증하는 번거로운 코드를 작성할 필요가 없습니다.
  • 데이터 직렬화/역직렬화 간소화: 클라이언트에서 JavaScript 객체를 보내면 함수에서 그대로 받을 수 있고, 함수에서 객체를 반환하면 클라이언트 SDK가 알아서 파싱해줍니다. JSON을 직접 다룰 필요가 없습니다.
  • CORS 문제 없음: 클라이언트 SDK를 통해 호출되므로 복잡한 CORS(Cross-Origin Resource Sharing) 정책을 설정할 필요가 없습니다.

서버 측 코드 (`index.js`):


exports.addMessage = functions.https.onCall((data, context) => {
  // context.auth 객체를 통해 사용자의 인증 정보를 확인합니다.
  if (!context.auth) {
    // 인증되지 않은 사용자의 요청을 거부합니다.
    throw new functions.https.HttpsError('unauthenticated', 'The function must be called while authenticated.');
  }

  // 전달된 데이터(data 객체)를 사용합니다.
  const text = data.text;
  
  // 여기에서 데이터베이스에 데이터를 쓰는 등의 로직을 수행합니다.
  // ...

  // 클라이언트에 결과를 반환합니다.
  return { result: `Message with text '${text}' added.` };
});

클라이언트 측 코드 (웹 JavaScript):


import { getFunctions, httpsCallable } from "firebase/functions";

const functions = getFunctions();
const addMessage = httpsCallable(functions, 'addMessage');

addMessage({ text: 'Hello, callable function!' })
  .then((result) => {
    console.log(result.data.result);
  })
  .catch((error) => {
    console.error(error);
  });

클라이언트 앱과 직접 상호작용하는 API를 만들 때는 보안과 편의성을 위해 `onRequest`보다 `onCall`을 우선적으로 고려하는 것이 좋습니다.

3.2. Firestore 트리거: 데이터 변경에 실시간으로 반응하기

Cloud Firestore는 Firebase의 주력 NoSQL 문서 데이터베이스입니다. Firestore 트리거를 사용하면 컬렉션의 특정 문서에 데이터가 생성, 수정, 또는 삭제될 때마다 함수를 실행할 수 있습니다. 이는 데이터 일관성을 유지하거나, 데이터 변경에 따른 후속 작업을 자동화하는 데 매우 유용합니다.

  • onCreate(snapshot, context): 새 문서가 생성될 때 실행됩니다.
  • onUpdate(change, context): 기존 문서가 수정될 때 실행됩니다. `change` 객체는 `change.before.data()`와 `change.after.data()`를 통해 변경 전후의 데이터를 모두 포함합니다.
  • onDelete(snapshot, context): 문서가 삭제될 때 실행됩니다.
  • onWrite(change, context): 생성, 수정, 삭제 중 어느 것이든 발생할 때 실행됩니다.

사용 예시: 사용자 프로필 생성 자동화

Firebase Authentication을 통해 새로운 사용자가 가입하면, 해당 사용자의 정보를 담은 프로필 문서를 `users` 컬렉션에 자동으로 생성하는 시나리오를 생각해 보겠습니다.


const functions = require("firebase-functions");
const admin = require("firebase-admin");
admin.initializeApp();

// Authentication에서 새로운 사용자가 생성될 때마다 이 함수가 트리거됩니다.
exports.createProfile = functions.auth.user().onCreate((user) => {
  // user 객체에는 새로 생성된 사용자의 uid, email 등의 정보가 담겨 있습니다.
  const { uid, email, displayName, photoURL } = user;

  // Firestore의 'users' 컬렉션에 사용자 uid를 문서 ID로 하여 새 문서를 생성합니다.
  return admin.firestore().collection('users').doc(uid).set({
    email: email,
    displayName: displayName || null,
    photoURL: photoURL || null,
    createdAt: admin.firestore.FieldValue.serverTimestamp(), // 생성 시각 기록
    level: 1, // 기본 레벨 설정
  });
});

위 예시는 Authentication 트리거(functions.auth.user().onCreate)를 사용했지만, Firestore 트리거의 강력함을 보여주는 또 다른 예시는 집계(aggregation) 작업입니다. 예를 들어, `posts/{postId}/likes/{userId}` 문서가 생성될 때마다 `posts/{postId}` 문서의 `likeCount` 필드를 1씩 증가시키는 함수를 만들 수 있습니다. 이는 클라이언트의 부담을 줄이고 데이터의 정합성을 보장하는 훌륭한 패턴입니다.


// 'likes' 컬렉션에 새 문서가 추가될 때마다 실행
exports.incrementLikeCount = functions.firestore
  .document('posts/{postId}/likes/{userId}')
  .onCreate((snap, context) => {
    const postId = context.params.postId;
    const postRef = admin.firestore().collection('posts').doc(postId);

    // 트랜잭션을 사용하여 원자적으로 카운터를 증가시킵니다.
    return admin.firestore().runTransaction(async (transaction) => {
      const postDoc = await transaction.get(postRef);
      if (!postDoc.exists) {
        throw "Document does not exist!";
      }
      const newCount = (postDoc.data().likeCount || 0) + 1;
      transaction.update(postRef, { likeCount: newCount });
    });
  });

3.3. Cloud Storage 트리거: 파일 처리를 위한 자동화 파이프라인

Cloud Storage는 이미지, 동영상 등 사용자의 파일을 저장하는 서비스입니다. Storage 트리거를 사용하면 특정 버킷에 파일이 업로드되거나 삭제될 때 함수를 실행할 수 있습니다.

  • onFinalize(object): 버킷에 새 파일 업로드가 성공적으로 완료되었을 때 실행됩니다.
  • onDelete(object): 파일이 삭제되었을 때 실행됩니다.
  • onArchive(object), onMetadataUpdate(object): 파일이 아카이브되거나 메타데이터가 업데이트될 때 실행됩니다.

사용 예시: 이미지 썸네일 자동 생성

사용자가 프로필 사진을 업로드하면, 서버에서 자동으로 작은 크기의 썸네일 이미지를 생성하여 별도로 저장하는 것은 매우 흔한 요구사항입니다. 이 작업을 Firebase Functions와 Storage 트리거를 사용하면 손쉽게 자동화할 수 있습니다. 이 예제는 외부 라이브러리(sharp)와 OS에 설치된 도구(ImageMagick)를 필요로 할 수 있습니다.


const functions = require("firebase-functions");
const admin = require("firebase-admin");
const path = require("path");
const os = require("os");
const fs = require("fs");
const sharp = require("sharp"); // 이미지 처리를 위한 라이브러리

admin.initializeApp();

exports.generateThumbnail = functions.storage.object().onFinalize(async (object) => {
  const fileBucket = object.bucket; // 파일이 포함된 버킷
  const filePath = object.name; // 버킷 내 파일 경로
  const contentType = object.contentType; // 파일의 MIME 타입

  // 썸네일이 이미 생성된 경우 또는 이미지 파일이 아닌 경우 함수를 종료합니다.
  if (!contentType.startsWith('image/')) {
    return functions.logger.log('This is not an image.');
  }
  if (path.basename(filePath).startsWith('thumb_')) {
    return functions.logger.log('Already a Thumbnail.');
  }

  const bucket = admin.storage().bucket(fileBucket);
  const tempFilePath = path.join(os.tmpdir(), path.basename(filePath));
  const metadata = { contentType: contentType };

  // 파일을 Functions의 임시 디렉터리에 다운로드합니다.
  await bucket.file(filePath).download({ destination: tempFilePath });
  functions.logger.log('Image downloaded locally to', tempFilePath);

  // 'sharp'를 사용하여 썸네일을 생성합니다.
  const thumbFileName = `thumb_${path.basename(filePath)}`;
  const thumbFilePath = path.join(os.tmpdir(), thumbFileName);
  await sharp(tempFilePath).resize(200, 200).toFile(thumbFilePath);

  // 썸네일을 다시 Storage 버킷에 업로드합니다.
  await bucket.upload(thumbFilePath, {
    destination: path.join(path.dirname(filePath), thumbFileName),
    metadata: metadata,
  });

  // 임시 파일을 삭제하여 리소스를 정리합니다.
  return fs.unlinkSync(tempFilePath);
});

3.4. 기타 주요 트리거

  • Authentication 트리거: 위에서 잠깐 살펴봤듯이 auth.user().onCreate()onDelete()를 통해 사용자 생성 및 삭제 이벤트를 감지할 수 있습니다. 환영 이메일 발송, 관련 데이터 정리 등에 활용됩니다.
  • Pub/Sub 트리거: pubsub.topic('topic-name').onPublish()는 Google Cloud Pub/Sub의 특정 토픽에 메시지가 게시될 때 함수를 실행합니다. 이는 여러 서비스 간의 비동기적이고 분리된 통신이 필요할 때 유용하며, 복잡한 마이크로서비스 아키텍처를 구축하는 데 사용될 수 있습니다.
  • Cloud Scheduler 트리거: pubsub.schedule('every 5 minutes').onRun()을 사용하면 cron 작업처럼 특정 시간 간격이나 정해진 시간에 주기적으로 함수를 실행할 수 있습니다. 매일 자정에 데이터를 정리하거나, 매시간 리포트를 생성하는 등의 작업에 적합합니다.

이처럼 다양한 트리거를 조합함으로써, 개발자는 단일 기능에 집중된 작고 독립적인 함수들을 만들고, 이들을 이벤트로 연결하여 복잡하고 확장 가능한 시스템을 구축할 수 있습니다. 이것이 바로 Firebase Functions가 제공하는 이벤트 기반 서버리스 아키텍처의 핵심 철학입니다.

4장: 안정적인 함수 운영을 위한 고급 기법

함수를 개발하고 배포하는 것을 넘어, 실제 프로덕션 환경에서 안정적으로 운영하기 위해서는 예외 처리, 로깅, 보안, 성능 최적화 등 여러 가지 고급 주제를 고려해야 합니다.

4.1. 예외 처리와 에러 관리

잘못된 입력, 외부 API 호출 실패, 데이터베이스 접근 오류 등 함수 실행 중에는 다양한 예외 상황이 발생할 수 있습니다. 이러한 예외를 적절히 처리하지 않으면 함수가 비정상적으로 종료되거나 클라이언트에게 혼란스러운 오류를 반환하게 됩니다.

HTTP 함수에서의 에러 처리

HTTP 트리거 함수에서는 `try...catch` 구문을 사용하여 오류를 잡고, `response.status().send()`를 통해 클라이언트에게 명확한 HTTP 상태 코드와 에러 메시지를 전달하는 것이 중요합니다.


exports.handleErrors = functions.https.onRequest(async (request, response) => {
  try {
    // 잠재적으로 오류를 발생시킬 수 있는 비동기 작업
    const data = await fetchDataFromRiskyAPI(); 
    response.status(200).send(data);
  } catch (error) {
    // 오류를 로그에 기록하여 추적
    functions.logger.error("API call failed:", error); 
    
    // 클라이언트에게는 내부 서버 오류임을 알림
    response.status(500).send({ error: 'Internal Server Error' });
  }
});

배경 함수에서의 재시도 정책

Firestore나 Storage 같은 배경 함수(background functions)는 일시적인 네트워크 문제나 외부 서비스 장애로 인해 실패할 수 있습니다. 이런 경우를 대비해 Firebase는 함수에 재시도 정책을 설정하는 기능을 제공합니다.

함수를 정의할 때 `.runWith()`를 사용하여 재시도 옵션을 활성화하면, 함수 실행이 실패했을 때 Firebase가 자동으로 몇 차례 더 실행을 시도합니다. 이는 함수의 안정성을 크게 높여줍니다.


exports.retriableBackgroundFunction = functions
  .runWith({
    // 실패 시 재시도를 활성화합니다.
    failurePolicy: {
      retry: {}, // 빈 객체로 설정하면 기본 정책이 적용됩니다.
    },
  })
  .firestore.document('some/doc')
  .onCreate(async (snap, context) => {
    // 이 함수는 실패 시 자동으로 재시도됩니다.
    // 단, 재시도 시 동일한 작업이 여러 번 수행될 수 있으므로
    // 함수 로직은 '멱등성(idempotent)'을 가지도록 설계해야 합니다.
    // (여러 번 실행되어도 결과가 같은 성질)
    await callFlakyThirdPartyService();
  });

주의할 점은 재시도 정책을 사용하는 함수는 멱등성(Idempotency)을 고려하여 설계해야 한다는 것입니다. 즉, 함수가 여러 번 실행되더라도 최종 결과는 한 번 실행된 것과 같아야 합니다. 예를 들어, 카운터를 1 증가시키는 대신 특정 값으로 설정하거나, 이미 처리된 이벤트인지 확인하는 로직을 추가하는 등의 방법이 있습니다.

4.2. 로깅과 모니터링

함수가 어떻게 실행되고 있는지, 어떤 오류가 발생하는지 파악하기 위해 로깅은 필수적입니다. Firebase Functions는 Google Cloud의 강력한 로깅 및 모니터링 도구인 Cloud Logging과 통합되어 있습니다.

functions.logger 객체를 사용하면 다양한 수준의 로그를 남길 수 있습니다.

  • functions.logger.log(): 일반 정보
  • functions.logger.info(): 정보성 메시지
  • functions.logger.warn(): 경고
  • functions.logger.error(): 오류

이러한 로그들은 Firebase Console의 Functions 탭이나 Google Cloud Console의 Logging 섹션에서 실시간으로 확인하고, 필터링하며, 검색할 수 있습니다. 특히 JSON 객체를 로그에 포함시키면 구조화된 로깅이 가능해져 나중에 데이터를 분석하거나 특정 조건의 로그를 찾는 데 매우 유용합니다.


exports.loggingExample = functions.firestore.document('users/{userId}')
  .onUpdate((change, context) => {
    const userId = context.params.userId;
    const beforeData = change.before.data();
    const afterData = change.after.data();

    functions.logger.info(`User ${userId} updated`, {
      before: { email: beforeData.email, level: beforeData.level },
      after: { email: afterData.email, level: afterData.level },
      updatedBy: 'system',
    });
  });

4.3. 보안: 환경 변수와 인증

API 키, 데이터베이스 자격 증명 등 민감한 정보를 코드에 직접 하드코딩하는 것은 매우 위험한 관행입니다. 이러한 정보는 소스 코드 저장소에 노출될 수 있기 때문입니다. Firebase는 이러한 민감한 데이터를 안전하게 관리하기 위해 환경 구성(Environment Configuration) 기능을 제공합니다.

Firebase CLI를 사용하여 환경 변수를 설정할 수 있습니다.


# API 키 설정 (key.name은 원하는 이름으로 지정)
$ firebase functions:config:set third_party.api_key="YOUR_API_KEY"
$ firebase functions:config:set mailer.user="user@example.com"
$ firebase functions:config:set mailer.password="supersecret"

이렇게 설정된 값들은 암호화되어 저장되며, 함수 코드 내에서는 `functions.config()` 객체를 통해 접근할 수 있습니다.


const functions = require("firebase-functions");
const apiKey = functions.config().third_party.api_key;
// 이제 apiKey 변수를 사용하여 안전하게 외부 API를 호출할 수 있습니다.

또한, HTTP 함수를 보호하기 위해서는 반드시 인증 및 인가 로직을 구현해야 합니다. 앞서 다룬 `onCall` 트리거는 이를 자동으로 처리해주지만, `onRequest` 트리거를 사용한다면 클라이언트가 요청 헤더에 보낸 Firebase ID 토큰을 `firebase-admin` SDK를 사용하여 직접 검증해야 합니다.


// onRequest 함수 내에서 토큰을 검증하는 미들웨어 패턴
const admin = require("firebase-admin");

exports.authenticatedEndpoint = functions.https.onRequest(async (req, res) => {
  const authorization = req.headers.authorization;
  if (!authorization || !authorization.startsWith('Bearer ')) {
    res.status(403).send('Unauthorized');
    return;
  }
  
  const idToken = authorization.split('Bearer ')[1];
  try {
    const decodedToken = await admin.auth().verifyIdToken(idToken);
    req.user = decodedToken; // 검증된 사용자 정보를 request 객체에 추가
    
    // 이후 로직 수행
    res.send({ message: `Hello, ${req.user.email}`});
  } catch (error) {
    res.status(403).send('Unauthorized');
  }
});

4.4. 성능 최적화와 비용 관리

서버리스 함수는 호출될 때마다 새로운 실행 환경을 준비해야 할 수 있습니다. 이 과정을 콜드 스타트(Cold Start)라고 하며, 이로 인해 첫 번째 요청에 대한 응답 시간이 길어질 수 있습니다. 콜드 스타트의 영향을 줄이기 위한 몇 가지 전략이 있습니다.

  • 최소 인스턴스 설정: 함수 설정에서 `minInstances`를 1 이상으로 설정하면, 항상 지정된 수의 함수 인스턴스가 대기 상태(warm)로 유지되어 콜드 스타트를 피할 수 있습니다. 다만, 유휴 상태에서도 비용이 발생하므로 트래픽 패턴을 고려하여 신중하게 결정해야 합니다.
  • 전역 변수 활용: 데이터베이스 연결이나 무거운 라이브러리 초기화 같은 작업은 함수 핸들러 외부, 즉 전역 스코프에서 수행하세요. 이렇게 하면 콜드 스타트 시 한 번만 실행되고, 이후의 '웜' 호출에서는 재사용되어 실행 시간을 단축할 수 있습니다.
  • 의존성 최소화: `package.json`에 꼭 필요한 라이브러리만 포함하여 배포 패키지의 크기를 줄이면 초기화 시간을 단축하는 데 도움이 됩니다.

또한, 함수의 메모리 및 타임아웃 설정도 성능과 비용에 직접적인 영향을 미칩니다. `.runWith({ memory: '512MB', timeoutSeconds: 60 })` 와 같이 함수마다 적절한 리소스를 할당할 수 있습니다. 메모리를 많이 필요로 하는 작업(예: 이미지 처리)에는 더 많은 메모리를 할당하고, 단순한 작업에는 기본값을 사용하여 비용을 절약할 수 있습니다.

마지막으로, 함수가 실행되는 리전(Region)을 데이터베이스나 사용자와 가까운 곳으로 선택하는 것이 네트워크 지연 시간을 줄이는 데 중요합니다. 예를 들어, Firestore 데이터베이스가 `asia-northeast3`(서울)에 있다면, Functions도 동일한 리전에 배포하는 것이 최상의 성능을 보장합니다.


// 서울 리전에 함수를 배포하고 메모리, 타임아웃, 최소 인스턴스를 설정
exports.optimizedFunction = functions
  .region('asia-northeast3') // 리전 지정
  .runWith({
    memory: '1GB', // 메모리 할당
    timeoutSeconds: 120, // 타임아웃 설정
    minInstances: 1, // 최소 인스턴스 설정
  })
  .https.onRequest((req, res) => {
    // ... 고성능이 요구되는 로직
    res.send("Optimized function executed!");
  });

이러한 고급 기법들을 잘 활용하면 Firebase Functions를 단지 간단한 스크립트를 실행하는 도구를 넘어, 안정적이고 안전하며 고성능을 자랑하는 프로덕션급 백엔드 서비스로 운영할 수 있습니다.

결론: 무한한 가능성을 여는 서버리스 백엔드

지금까지 우리는 서버리스 아키텍처의 개념을 시작으로 Firebase Functions를 사용하여 백엔드 로직을 개발, 테스트, 배포하고 운영하는 전반적인 과정을 심도 있게 살펴보았습니다. 간단한 HTTP 엔드포인트 생성부터 Firestore, Storage, Authentication 등 Firebase의 다른 서비스들과 연동하여 강력한 이벤트 기반 시스템을 구축하는 방법, 그리고 프로덕션 환경에서 필수적인 오류 처리, 보안, 성능 최적화 기법에 이르기까지 Firebase Functions가 제공하는 다채로운 기능들을 탐험했습니다.

Firebase Functions의 가장 큰 매력은 개발자가 인프라의 복잡성에서 해방되어 오롯이 비즈니스 가치를 창출하는 코드에만 집중할 수 있게 해준다는 점입니다. 자동 스케일링, 종량제 과금 모델, 그리고 Firebase 생태계와의 긴밀한 통합은 스타트업의 빠른 프로토타이핑부터 대규모 서비스의 마이크로서비스 아키텍처 구축에 이르기까지 폭넓은 스펙트럼의 요구사항을 만족시킬 수 있는 유연성과 확장성을 제공합니다.

본 문서에서 다룬 내용들은 Firebase Functions가 가진 잠재력의 일부에 불과합니다. 이제 여러분의 차례입니다. 직접 아이디어를 코드로 구현하고, 다양한 트리거를 조합하여 새로운 자동화 파이프라인을 만들어보세요. 로컬 에뮬레이터를 통해 빠르게 실험하고, Cloud Logging을 통해 함수의 동작을 관찰하며 서버리스 개발의 즐거움을 만끽하시길 바랍니다. Firebase Functions와 함께라면, 복잡한 백엔드 인프라에 대한 걱정 없이 여러분의 상상력을 현실로 만드는 데 한 걸음 더 다가갈 수 있을 것입니다.


0 개의 댓글:

Post a Comment