Firebase Cloud Functions는 Google Cloud Platform에서 실행되는 서버리스 백엔드 솔루션입니다. 서버 인프라를 관리할 필요 없이 Node.js 코드를 실행하고, 자동으로 확장되며, 사용한 만큼만 비용을 지불합니다. 이 튜토리얼에서는 실전 프로젝트를 통해 Firebase Cloud Functions의 핵심 개념과 활용법을 배웁니다.
1. Firebase Cloud Functions 소개
1.1 서버리스 아키텍처란?
서버리스는 서버가 없다는 의미가 아닙니다. 개발자가 서버를 직접 관리하지 않고, 클라우드 제공자가 인프라를 관리한다는 의미입니다.
1.2 Cloud Functions의 장점
- 자동 확장: 트래픽에 따라 자동으로 인스턴스 증감
- 비용 효율적: 실행 시간만큼만 과금
- 빠른 개발: 인프라 설정 없이 코드만 작성
- Firebase 통합: Firestore, Auth, Storage 등과 완벽 통합
- 이벤트 기반: 다양한 트리거 지원
1.3 지원되는 트리거 유형
- HTTP Functions: REST API 엔드포인트
- Firestore Triggers: 데이터베이스 변경 이벤트
- Authentication Triggers: 사용자 생성/삭제 이벤트
- Storage Triggers: 파일 업로드/삭제 이벤트
- Pub/Sub Triggers: 메시지 큐 이벤트
- Scheduled Functions: Cron 작업
2. 환경 설정
2.1 필수 도구 설치
Step 1: Node.js 설치
Node.js 18 이상이 필요합니다. nodejs.org에서 다운로드하세요.
# 버전 확인
node --version # v18.0.0 이상
npm --version # 9.0.0 이상
Step 2: Firebase CLI 설치
npm install -g firebase-tools
Step 3: Firebase 로그인
firebase login
2.2 Firebase 프로젝트 생성
Step 1: Firebase Console에서 프로젝트 생성
- Firebase Console 접속
- "프로젝트 추가" 클릭
- 프로젝트 이름 입력 (예: "my-serverless-app")
- Google Analytics 설정 (선택사항)
- 프로젝트 생성 완료
Step 2: Blaze 요금제로 업그레이드
Cloud Functions를 사용하려면 Blaze (종량제) 요금제가 필요합니다. 무료 할당량이 충분하므로 개발/테스트 단계에서는 비용이 거의 발생하지 않습니다.
2.3 로컬 프로젝트 초기화
# 프로젝트 디렉토리 생성
mkdir my-firebase-functions
cd my-firebase-functions
# Firebase 초기화
firebase init functions
초기화 설정:
- 언어 선택: TypeScript (권장)
- ESLint 사용: Yes
- 의존성 설치: Yes
생성된 프로젝트 구조:
my-firebase-functions/
├── .firebaserc
├── firebase.json
└── functions/
├── src/
│ └── index.ts
├── package.json
├── tsconfig.json
└── .eslintrc.js
3. 첫 번째 Function 만들기
3.1 Hello World HTTP Function
functions/src/index.ts 수정:
import * as functions from 'firebase-functions';
export const helloWorld = functions.https.onRequest((request, response) => {
response.json({
message: "Hello from Firebase!",
timestamp: new Date().toISOString()
});
});
3.2 로컬 테스트
# 로컬 에뮬레이터 실행
npm --prefix functions run serve
# 또는
firebase emulators:start --only functions
브라우저에서 http://localhost:5001/[PROJECT_ID]/us-central1/helloWorld 접속
3.3 배포
firebase deploy --only functions
배포 완료 후 제공되는 URL로 접근 가능합니다.
4. 실전 프로젝트: 블로그 API 구축
이제 실제 사용 가능한 블로그 API를 만들어봅시다. CRUD 기능, 인증, 파일 업로드를 포함하는 완전한 백엔드를 구현합니다.
4.1 프로젝트 구조 설계
functions/
├── src/
│ ├── index.ts # 진입점
│ ├── api/
│ │ ├── posts.ts # 포스트 관련 함수
│ │ ├── users.ts # 사용자 관련 함수
│ │ └── comments.ts # 댓글 관련 함수
│ ├── middleware/
│ │ ├── auth.ts # 인증 미들웨어
│ │ └── validation.ts # 유효성 검사
│ ├── utils/
│ │ ├── response.ts # 응답 포맷터
│ │ └── errors.ts # 에러 핸들러
│ └── types/
│ └── index.ts # TypeScript 타입 정의
└── package.json
4.2 의존성 설치
cd functions
npm install express cors
npm install -D @types/express @types/cors
4.3 타입 정의
functions/src/types/index.ts:
export interface Post {
id: string;
title: string;
content: string;
authorId: string;
authorName: string;
createdAt: FirebaseFirestore.Timestamp;
updatedAt: FirebaseFirestore.Timestamp;
published: boolean;
tags: string[];
viewCount: number;
}
export interface Comment {
id: string;
postId: string;
userId: string;
userName: string;
content: string;
createdAt: FirebaseFirestore.Timestamp;
}
export interface ApiResponse {
success: boolean;
data?: T;
error?: string;
message?: string;
}
4.4 유틸리티 함수
functions/src/utils/response.ts:
import { Response } from 'express';
import { ApiResponse } from '../types';
export const sendSuccess = (
res: Response,
data: T,
message?: string
): void => {
const response: ApiResponse = {
success: true,
data,
message
};
res.status(200).json(response);
};
export const sendError = (
res: Response,
error: string,
statusCode: number = 400
): void => {
const response: ApiResponse = {
success: false,
error
};
res.status(statusCode).json(response);
};
export const sendNotFound = (res: Response, message: string): void => {
sendError(res, message, 404);
};
export const sendUnauthorized = (res: Response): void => {
sendError(res, 'Unauthorized', 401);
};
4.5 인증 미들웨어
functions/src/middleware/auth.ts:
import { Request, Response, NextFunction } from 'express';
import * as admin from 'firebase-admin';
import { sendUnauthorized } from '../utils/response';
export interface AuthRequest extends Request {
user?: admin.auth.DecodedIdToken;
}
export const authenticate = async (
req: AuthRequest,
res: Response,
next: NextFunction
): Promise => {
try {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
sendUnauthorized(res);
return;
}
const token = authHeader.split('Bearer ')[1];
const decodedToken = await admin.auth().verifyIdToken(token);
req.user = decodedToken;
next();
} catch (error) {
console.error('Authentication error:', error);
sendUnauthorized(res);
}
};
4.6 Posts API 구현
functions/src/api/posts.ts:
import * as admin from 'firebase-admin';
import { Request, Response } from 'express';
import { AuthRequest } from '../middleware/auth';
import { Post } from '../types';
import { sendSuccess, sendError, sendNotFound } from '../utils/response';
const db = admin.firestore();
const postsCollection = db.collection('posts');
// 모든 포스트 조회
export const getAllPosts = async (req: Request, res: Response) => {
try {
const { limit = 10, published = 'true' } = req.query;
let query = postsCollection
.orderBy('createdAt', 'desc')
.limit(Number(limit));
if (published === 'true') {
query = query.where('published', '==', true) as any;
}
const snapshot = await query.get();
const posts = snapshot.docs.map(doc => ({
id: doc.id,
...doc.data()
}));
sendSuccess(res, posts);
} catch (error) {
console.error('Error fetching posts:', error);
sendError(res, 'Failed to fetch posts', 500);
}
};
// 특정 포스트 조회
export const getPost = async (req: Request, res: Response) => {
try {
const { id } = req.params;
const doc = await postsCollection.doc(id).get();
if (!doc.exists) {
sendNotFound(res, 'Post not found');
return;
}
// 조회수 증가
await postsCollection.doc(id).update({
viewCount: admin.firestore.FieldValue.increment(1)
});
const post = { id: doc.id, ...doc.data() };
sendSuccess(res, post);
} catch (error) {
console.error('Error fetching post:', error);
sendError(res, 'Failed to fetch post', 500);
}
};
// 포스트 생성 (인증 필요)
export const createPost = async (req: AuthRequest, res: Response) => {
try {
const { title, content, tags = [], published = false } = req.body;
if (!title || !content) {
sendError(res, 'Title and content are required');
return;
}
const newPost: Omit = {
title,
content,
authorId: req.user!.uid,
authorName: req.user!.name || req.user!.email || 'Anonymous',
createdAt: admin.firestore.Timestamp.now(),
updatedAt: admin.firestore.Timestamp.now(),
published,
tags,
viewCount: 0
};
const docRef = await postsCollection.add(newPost);
const post = { id: docRef.id, ...newPost };
sendSuccess(res, post, 'Post created successfully');
} catch (error) {
console.error('Error creating post:', error);
sendError(res, 'Failed to create post', 500);
}
};
// 포스트 수정 (인증 필요)
export const updatePost = async (req: AuthRequest, res: Response) => {
try {
const { id } = req.params;
const { title, content, tags, published } = req.body;
const doc = await postsCollection.doc(id).get();
if (!doc.exists) {
sendNotFound(res, 'Post not found');
return;
}
const postData = doc.data() as Post;
// 작성자 확인
if (postData.authorId !== req.user!.uid) {
sendError(res, 'Unauthorized to update this post', 403);
return;
}
const updates: Partial = {
updatedAt: admin.firestore.Timestamp.now()
};
if (title) updates.title = title;
if (content) updates.content = content;
if (tags) updates.tags = tags;
if (typeof published === 'boolean') updates.published = published;
await postsCollection.doc(id).update(updates);
sendSuccess(res, { id, ...updates }, 'Post updated successfully');
} catch (error) {
console.error('Error updating post:', error);
sendError(res, 'Failed to update post', 500);
}
};
// 포스트 삭제 (인증 필요)
export const deletePost = async (req: AuthRequest, res: Response) => {
try {
const { id } = req.params;
const doc = await postsCollection.doc(id).get();
if (!doc.exists) {
sendNotFound(res, 'Post not found');
return;
}
const postData = doc.data() as Post;
// 작성자 확인
if (postData.authorId !== req.user!.uid) {
sendError(res, 'Unauthorized to delete this post', 403);
return;
}
await postsCollection.doc(id).delete();
sendSuccess(res, null, 'Post deleted successfully');
} catch (error) {
console.error('Error deleting post:', error);
sendError(res, 'Failed to delete post', 500);
}
};
4.7 Express 앱 설정
functions/src/index.ts:
import * as functions from 'firebase-functions';
import * as admin from 'firebase-admin';
import express from 'express';
import cors from 'cors';
import { authenticate } from './middleware/auth';
import {
getAllPosts,
getPost,
createPost,
updatePost,
deletePost
} from './api/posts';
// Firebase Admin 초기화
admin.initializeApp();
// Express 앱 생성
const app = express();
// 미들웨어
app.use(cors({ origin: true }));
app.use(express.json());
// 공개 엔드포인트
app.get('/posts', getAllPosts);
app.get('/posts/:id', getPost);
// 인증 필요 엔드포인트
app.post('/posts', authenticate, createPost);
app.put('/posts/:id', authenticate, updatePost);
app.delete('/posts/:id', authenticate, deletePost);
// Health check
app.get('/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
// API를 Cloud Function으로 내보내기
export const api = functions.https.onRequest(app);
5. Firestore 트리거 함수
5.1 포스트 생성 시 알림 발송
functions/src/index.ts에 추가:
// 새 포스트 생성 시 트리거
export const onPostCreated = functions.firestore
.document('posts/{postId}')
.onCreate(async (snapshot, context) => {
const post = snapshot.data() as Post;
const postId = context.params.postId;
console.log(`New post created: ${postId}`);
console.log(`Title: ${post.title}`);
console.log(`Author: ${post.authorName}`);
// 여기에 알림 로직 추가 가능
// 예: 이메일 발송, 푸시 알림, Slack 메시지 등
// 통계 업데이트
const statsRef = db.collection('stats').doc('posts');
await statsRef.set({
totalPosts: admin.firestore.FieldValue.increment(1),
lastUpdated: admin.firestore.Timestamp.now()
}, { merge: true });
return null;
});
// 포스트 삭제 시 연관 데이터 정리
export const onPostDeleted = functions.firestore
.document('posts/{postId}')
.onDelete(async (snapshot, context) => {
const postId = context.params.postId;
// 해당 포스트의 모든 댓글 삭제
const commentsSnapshot = await db
.collection('comments')
.where('postId', '==', postId)
.get();
const batch = db.batch();
commentsSnapshot.docs.forEach(doc => {
batch.delete(doc.ref);
});
await batch.commit();
console.log(`Deleted ${commentsSnapshot.size} comments for post ${postId}`);
return null;
});
5.2 검색 인덱스 자동 업데이트
export const updateSearchIndex = functions.firestore
.document('posts/{postId}')
.onWrite(async (change, context) => {
const postId = context.params.postId;
// 삭제된 경우
if (!change.after.exists) {
await db.collection('searchIndex').doc(postId).delete();
return null;
}
const post = change.after.data() as Post;
// 검색 가능한 텍스트 생성
const searchText = `${post.title} ${post.content} ${post.tags.join(' ')}`
.toLowerCase();
// 검색 인덱스 업데이트
await db.collection('searchIndex').doc(postId).set({
postId,
title: post.title,
searchText,
tags: post.tags,
createdAt: post.createdAt
});
return null;
});
6. 예약 작업 (Cron Jobs)
6.1 매일 통계 집계
export const dailyStats = functions.pubsub
.schedule('0 0 * * *') // 매일 자정 실행
.timeZone('Asia/Seoul')
.onRun(async (context) => {
console.log('Running daily stats aggregation...');
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
yesterday.setHours(0, 0, 0, 0);
const today = new Date();
today.setHours(0, 0, 0, 0);
// 어제 생성된 포스트 수
const postsSnapshot = await db.collection('posts')
.where('createdAt', '>=', admin.firestore.Timestamp.fromDate(yesterday))
.where('createdAt', '<', admin.firestore.Timestamp.fromDate(today))
.get();
// 통계 저장
await db.collection('dailyStats').add({
date: admin.firestore.Timestamp.fromDate(yesterday),
postsCreated: postsSnapshot.size,
timestamp: admin.firestore.Timestamp.now()
});
console.log(`Stats saved: ${postsSnapshot.size} posts created yesterday`);
return null;
});
6.2 비활성 데이터 정리
export const cleanupOldData = functions.pubsub
.schedule('0 2 * * 0') // 매주 일요일 새벽 2시
.timeZone('Asia/Seoul')
.onRun(async (context) => {
console.log('Cleaning up old data...');
const sixMonthsAgo = new Date();
sixMonthsAgo.setMonth(sixMonthsAgo.getMonth() - 6);
// 6개월 이상 된 미발행 포스트 삭제
const oldPostsSnapshot = await db.collection('posts')
.where('published', '==', false)
.where('createdAt', '<', admin.firestore.Timestamp.fromDate(sixMonthsAgo))
.get();
const batch = db.batch();
oldPostsSnapshot.docs.forEach(doc => {
batch.delete(doc.ref);
});
await batch.commit();
console.log(`Deleted ${oldPostsSnapshot.size} old unpublished posts`);
return null;
});
7. Storage 트리거: 이미지 최적화
import * as path from 'path';
import * as os from 'os';
import * as fs from 'fs';
import { spawn } from 'child-process-promise';
export const generateThumbnail = functions.storage
.object()
.onFinalize(async (object) => {
const filePath = object.name;
const contentType = object.contentType;
// 이미지 파일만 처리
if (!contentType || !contentType.startsWith('image/')) {
console.log('Not an image.');
return null;
}
// 이미 썸네일인 경우 무시
if (filePath?.includes('thumb_')) {
console.log('Already a thumbnail.');
return null;
}
const bucket = admin.storage().bucket(object.bucket);
const fileName = path.basename(filePath!);
const thumbFileName = `thumb_${fileName}`;
const thumbFilePath = path.join(path.dirname(filePath!), thumbFileName);
// 임시 파일 경로
const tempFilePath = path.join(os.tmpdir(), fileName);
const tempThumbPath = path.join(os.tmpdir(), thumbFileName);
// 파일 다운로드
await bucket.file(filePath!).download({ destination: tempFilePath });
console.log('Image downloaded to', tempFilePath);
// ImageMagick으로 썸네일 생성 (200x200)
await spawn('convert', [
tempFilePath,
'-thumbnail', '200x200>',
tempThumbPath
]);
console.log('Thumbnail created at', tempThumbPath);
// 썸네일 업로드
await bucket.upload(tempThumbPath, {
destination: thumbFilePath,
metadata: {
contentType: contentType
}
});
console.log('Thumbnail uploaded to', thumbFilePath);
// 임시 파일 삭제
fs.unlinkSync(tempFilePath);
fs.unlinkSync(tempThumbPath);
return null;
});
8. 로컬 개발 및 테스트
8.1 Firebase Emulator Suite
firebase.json 설정:
{
"functions": {
"source": "functions"
},
"emulators": {
"functions": {
"port": 5001
},
"firestore": {
"port": 8080
},
"ui": {
"enabled": true,
"port": 4000
}
}
}
에뮬레이터 시작:
firebase emulators:start
Emulator UI: http://localhost:4000
8.2 API 테스트
curl로 테스트:
# Health check
curl http://localhost:5001/[PROJECT_ID]/us-central1/api/health
# 포스트 목록 조회
curl http://localhost:5001/[PROJECT_ID]/us-central1/api/posts
# 포스트 생성 (인증 토큰 필요)
curl -X POST http://localhost:5001/[PROJECT_ID]/us-central1/api/posts \
-H "Authorization: Bearer YOUR_ID_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"title": "Test Post",
"content": "This is a test post",
"tags": ["test", "firebase"],
"published": true
}'
8.3 유닛 테스트
설치:
npm install -D @firebase/testing mocha chai
functions/src/test/posts.test.ts:
import { expect } from 'chai';
import * as admin from 'firebase-admin';
describe('Posts API', () => {
before(() => {
// 테스트 환경 설정
process.env.FIRESTORE_EMULATOR_HOST = 'localhost:8080';
});
it('should create a new post', async () => {
const db = admin.firestore();
const postData = {
title: 'Test Post',
content: 'Test content',
authorId: 'test-user',
authorName: 'Test User',
createdAt: admin.firestore.Timestamp.now(),
updatedAt: admin.firestore.Timestamp.now(),
published: true,
tags: ['test'],
viewCount: 0
};
const docRef = await db.collection('posts').add(postData);
const doc = await docRef.get();
expect(doc.exists).to.be.true;
expect(doc.data()?.title).to.equal('Test Post');
});
});
9. 배포 및 모니터링
9.1 배포 전 체크리스트
- [ ] 모든 환경 변수 설정 완료
- [ ] 로컬 테스트 통과
- [ ] 에러 핸들링 구현
- [ ] 보안 규칙 검토
- [ ] CORS 설정 확인
- [ ] 로깅 설정
9.2 환경 변수 설정
# 환경 변수 설정
firebase functions:config:set service.api_key="YOUR_API_KEY"
# 확인
firebase functions:config:get
# 코드에서 사용
const apiKey = functions.config().service.api_key;
9.3 배포
# 전체 배포
firebase deploy
# 함수만 배포
firebase deploy --only functions
# 특정 함수만 배포
firebase deploy --only functions:api,functions:onPostCreated
9.4 모니터링
Firebase Console에서 확인:
- Functions > 대시보드: 호출 횟수, 실행 시간, 에러율
- Functions > 로그: 실시간 로그 확인
- Functions > 사용량: 비용 추정
로그 확인:
# 실시간 로그
firebase functions:log --only api
# 특정 함수의 로그
firebase functions:log --only onPostCreated --lines 50
10. 성능 최적화
10.1 Cold Start 최소화
// 함수 외부에서 초기화 (재사용됨)
import * as admin from 'firebase-admin';
admin.initializeApp();
const db = admin.firestore();
// 함수 내부에서는 초기화된 인스턴스 사용
export const myFunction = functions.https.onRequest(async (req, res) => {
// db 사용
const data = await db.collection('posts').get();
res.json(data);
});
10.2 메모리 및 타임아웃 설정
export const heavyTask = functions
.runWith({
memory: '2GB',
timeoutSeconds: 300
})
.https.onRequest(async (req, res) => {
// 무거운 작업
});
10.3 병렬 처리
export const processMultiplePosts = functions.https.onRequest(
async (req, res) => {
const postIds = req.body.postIds;
// 순차 처리 (느림)
// for (const id of postIds) {
// await processPost(id);
// }
// 병렬 처리 (빠름)
await Promise.all(
postIds.map(id => processPost(id))
);
res.json({ success: true });
}
);
11. 보안 모범 사례
11.1 CORS 설정
import cors from 'cors';
// 특정 도메인만 허용
const corsOptions = {
origin: ['https://yourdomain.com', 'https://app.yourdomain.com'],
credentials: true
};
app.use(cors(corsOptions));
11.2 Rate Limiting
import rateLimit from 'express-rate-limit';
const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15분
max: 100 // 최대 100 요청
});
app.use('/api/', limiter);
11.3 입력 검증
import { body, validationResult } from 'express-validator';
app.post('/posts',
authenticate,
[
body('title').isLength({ min: 5, max: 200 }),
body('content').isLength({ min: 10 }),
body('tags').isArray().optional()
],
(req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return sendError(res, 'Validation failed', 400);
}
// 포스트 생성
createPost(req, res);
}
);
12. 비용 최적화
12.1 무료 할당량
- 호출 횟수: 200만 회/월
- 컴퓨팅 시간: 40만 GB-초/월
- 네트워크: 5GB/월
12.2 비용 절감 팁
- 불필요한 함수 호출 줄이기
- 적절한 메모리 크기 선택 (과도한 할당 금지)
- 캐싱 활용
- 배치 처리로 호출 횟수 감소
- 예산 알림 설정
"서버리스는 인프라 관리의 부담을 없애고, 비즈니스 로직에 집중할 수 있게 해줍니다."
마치며
Firebase Cloud Functions로 서버리스 백엔드를 구축하는 방법을 배웠습니다. 이제 서버 관리 없이도 확장 가능한 애플리케이션을 만들 수 있습니다. 작게 시작해서 필요에 따라 기능을 확장해 나가세요.
참고 자료
- Firebase Functions Documentation - 공식 문서
- Firebase Functions Samples - 예제 코드 모음
- Firebase Emulator Suite - 로컬 개발 가이드
- Cloud Functions Best Practices - 모범 사례
- Firebase Pricing - 가격 정책