이미지 로딩 중...
AI Generated
2025. 11. 6. · 5 Views
Firebase 테스트 전략 완벽 가이드
Firebase 애플리케이션의 품질을 보장하는 실전 테스트 전략을 알아봅니다. 단위 테스트부터 통합 테스트, 보안 규칙 테스트까지 실무에서 바로 활용할 수 있는 테스트 기법을 다룹니다.
목차
- Firebase Emulator Suite - 로컬 테스트 환경 구축
- Firestore 보안 규칙 테스트 - 데이터 접근 제어 검증
- Cloud Functions 단위 테스트 - 서버리스 함수 검증
- Firestore 트랜잭션 테스트 - 데이터 일관성 검증
- Firebase Authentication 통합 테스트 - 인증 플로우 검증
- Firebase Storage 업로드 테스트 - 파일 처리 검증
- Firestore 쿼리 성능 테스트 - 인덱스 최적화 검증
- Firebase Functions 에러 처리 테스트 - 장애 복구 검증
1. Firebase Emulator Suite - 로컬 테스트 환경 구축
시작하며
여러분이 Firebase 프로젝트를 개발할 때 실제 프로덕션 데이터베이스에 테스트 데이터가 섞여서 곤란했던 적 있나요? 또는 테스트를 실행할 때마다 실제 Firebase 비용이 청구되어 당황한 경험이 있으신가요?
이런 문제는 실제 개발 현장에서 매우 자주 발생합니다. 특히 팀 단위로 개발할 때 각자의 테스트가 서로의 데이터를 방해하거나, CI/CD 파이프라인에서 수백 번의 테스트가 실행되면서 예상치 못한 비용이 발생하기도 합니다.
바로 이럴 때 필요한 것이 Firebase Emulator Suite입니다. 로컬 환경에서 Firebase의 모든 서비스를 시뮬레이션하여 안전하고 빠르게 테스트할 수 있습니다.
개요
간단히 말해서, Firebase Emulator Suite는 여러분의 컴퓨터에서 실제 Firebase 환경을 완벽하게 재현하는 도구입니다. 실제 Firebase에 연결하지 않고도 Firestore, Authentication, Functions, Storage 등 모든 Firebase 서비스를 로컬에서 테스트할 수 있습니다.
예를 들어, 사용자 인증 플로우를 수백 번 테스트해도 비용이 전혀 발생하지 않으며, 데이터베이스를 초기화하고 다시 시작하는 것도 몇 초면 가능합니다. 기존에는 테스트용 Firebase 프로젝트를 별도로 만들어야 했다면, 이제는 로컬 에뮬레이터로 완전히 격리된 환경에서 테스트할 수 있습니다.
에뮬레이터는 실시간 데이터 동기화, 보안 규칙 검증, 트리거 함수 실행 등 실제 Firebase의 모든 동작을 재현합니다. 이를 통해 프로덕션과 동일한 환경에서 안전하게 테스트하고, 버그를 조기에 발견할 수 있습니다.
코드 예제
// firebase.json - 에뮬레이터 설정
{
"emulators": {
"auth": {
"port": 9099
},
"firestore": {
"port": 8080
},
"functions": {
"port": 5001
},
"storage": {
"port": 9199
},
"ui": {
"enabled": true,
"port": 4000
}
}
}
// test-setup.ts - 테스트 환경 초기화
import { initializeTestEnvironment } from '@firebase/rules-unit-testing';
const testEnv = await initializeTestEnvironment({
projectId: 'demo-test-project',
firestore: {
host: 'localhost',
port: 8080
}
});
// 각 테스트 전 데이터 초기화
await testEnv.clearFirestore();
설명
이것이 하는 일: Firebase Emulator Suite는 여러분의 로컬 머신에서 Firebase 서비스들을 시뮬레이션하여 실제 클라우드에 연결하지 않고도 완전한 테스트 환경을 제공합니다. 첫 번째로, firebase.json 파일에서 각 서비스의 포트를 설정합니다.
Auth는 9099번, Firestore는 8080번 포트에서 실행되며, UI는 4000번 포트에서 웹 인터페이스를 제공합니다. 이렇게 각 서비스를 독립적인 포트에서 실행하여 충돌을 방지하고 디버깅을 쉽게 만듭니다.
그 다음으로, initializeTestEnvironment를 사용하여 테스트 환경을 초기화합니다. 이때 'demo-test-project'라는 프로젝트 ID를 사용하는데, 이는 Firebase가 자동으로 인식하는 특수한 ID로 실제 프로젝트에 연결되지 않습니다.
localhost:8080으로 Firestore 에뮬레이터에 연결하여 모든 데이터베이스 작업이 로컬에서만 이루어집니다. 마지막으로, clearFirestore()를 호출하여 각 테스트 전에 데이터베이스를 깨끗하게 초기화합니다.
이를 통해 이전 테스트의 잔여 데이터가 다음 테스트에 영향을 주지 않도록 보장하며, 테스트의 독립성과 재현성을 확보합니다. 여러분이 이 환경을 사용하면 실제 Firebase 비용 없이 무제한으로 테스트할 수 있고, 팀원들과 동일한 테스트 환경을 공유할 수 있으며, CI/CD 파이프라인에서도 안정적으로 테스트를 실행할 수 있습니다.
에뮬레이터 UI(localhost:4000)를 통해 실시간으로 데이터베이스 상태, 인증된 사용자 목록, 실행된 함수 로그 등을 시각적으로 확인할 수 있어 디버깅이 훨씬 쉬워집니다.
실전 팁
💡 firebase emulators:start 명령어 실행 전에 firebase.json과 firestore.rules 파일이 프로젝트 루트에 있는지 확인하세요. 없으면 에뮬레이터가 제대로 시작되지 않습니다.
💡 테스트 중 "Connection refused" 오류가 발생하면 connectFirestoreEmulator()를 Firebase 초기화 직후에 호출했는지 확인하세요. SDK 초기화 이후에 호출하면 에뮬레이터에 연결되지 않습니다.
💡 CI/CD 환경에서는 firebase emulators:exec "npm test" 명령어를 사용하세요. 테스트가 끝나면 에뮬레이터가 자동으로 종료되어 리소스 낭비를 방지합니다.
💡 에뮬레이터 UI에서 "Export data" 기능을 활용하여 특정 테스트 시나리오의 데이터를 저장하고, 나중에 동일한 상태로 빠르게 복원할 수 있습니다.
💡 프로덕션과 다른 동작을 방지하려면 firebase.json의 에뮬레이터 설정을 실제 Firebase 프로젝트 설정과 동일하게 유지하세요. 특히 보안 규칙은 완전히 일치시켜야 합니다.
2. Firestore 보안 규칙 테스트 - 데이터 접근 제어 검증
시작하며
여러분의 앱이 출시된 후 권한이 없는 사용자가 다른 사람의 개인정보를 읽을 수 있다는 보안 취약점이 발견되면 어떻게 될까요? 실제로 많은 Firebase 앱에서 보안 규칙 설정 실수로 인한 데이터 유출 사고가 발생했습니다.
이런 문제는 보안 규칙을 작성할 때 모든 엣지 케이스를 고려하지 못해서 발생합니다. "인증된 사용자만 접근 가능"이라고 생각했는데 실제로는 누구나 읽을 수 있거나, 반대로 정상적인 사용자도 자신의 데이터에 접근하지 못하는 경우가 있습니다.
바로 이럴 때 필요한 것이 Firestore 보안 규칙 테스트입니다. 자동화된 테스트로 모든 권한 시나리오를 검증하여 보안 취약점을 사전에 차단할 수 있습니다.
개요
간단히 말해서, 보안 규칙 테스트는 여러분이 작성한 Firestore 보안 규칙이 의도대로 동작하는지 자동으로 검증하는 테스트입니다. 실제 사용자가 데이터에 접근하기 전에, 다양한 권한 시나리오를 시뮬레이션하여 허용되어야 할 접근은 허용되고 차단되어야 할 접근은 확실히 차단되는지 확인합니다.
예를 들어, 사용자 A가 사용자 B의 프라이빗 문서를 읽으려 할 때 정확히 거부되는지, 또는 문서 소유자는 자신의 문서를 수정할 수 있는지 등을 테스트합니다. 기존에는 보안 규칙을 배포한 후 실제 앱에서 수동으로 테스트했다면, 이제는 배포 전에 자동화된 테스트로 모든 시나리오를 검증할 수 있습니다.
@firebase/rules-unit-testing 패키지는 다양한 인증 상태(비로그인, 로그인, 관리자 등)를 시뮬레이션하고, 각 상태에서 데이터 접근을 시도하여 보안 규칙이 올바르게 적용되는지 검증합니다. 이를 통해 보안 취약점을 프로덕션 배포 전에 발견하고 수정할 수 있습니다.
코드 예제
import { assertFails, assertSucceeds } from '@firebase/rules-unit-testing';
// 비인증 사용자는 읽기 실패해야 함
test('비인증 사용자는 개인 문서를 읽을 수 없음', async () => {
const unauthedDb = testEnv.unauthenticatedContext().firestore();
const docRef = unauthedDb.collection('users').doc('user123');
await assertFails(docRef.get());
});
// 문서 소유자는 읽기 성공해야 함
test('소유자는 자신의 문서를 읽을 수 있음', async () => {
const ownerDb = testEnv.authenticatedContext('user123').firestore();
const docRef = ownerDb.collection('users').doc('user123');
// 테스트 데이터 준비
await testEnv.withSecurityRulesDisabled(async (context) => {
await context.firestore().collection('users').doc('user123').set({ name: 'Alice' });
});
await assertSucceeds(docRef.get());
});
// 다른 사용자는 읽기 실패해야 함
test('다른 사용자는 타인의 문서를 읽을 수 없음', async () => {
const otherUserDb = testEnv.authenticatedContext('user456').firestore();
const docRef = otherUserDb.collection('users').doc('user123');
await assertFails(docRef.get());
});
설명
이것이 하는 일: Firestore 보안 규칙 테스트는 다양한 사용자 권한 시나리오를 시뮬레이션하여 데이터 접근 제어가 정확히 작동하는지 검증합니다. 첫 번째 테스트에서, unauthenticatedContext()로 로그인하지 않은 사용자를 시뮬레이션합니다.
이 상태에서 users 컬렉션의 문서를 읽으려 시도하면 assertFails()를 통해 접근이 거부되어야 함을 검증합니다. 만약 보안 규칙에 구멍이 있어서 실제로 읽기가 성공하면 테스트가 실패하여 취약점을 즉시 발견할 수 있습니다.
두 번째 테스트에서는, authenticatedContext('user123')로 특정 사용자로 로그인한 상태를 시뮬레이션합니다. 이 사용자가 자신의 문서(doc ID가 user123)를 읽으려 할 때는 assertSucceeds()로 성공해야 함을 검증합니다.
withSecurityRulesDisabled()를 사용하여 테스트 데이터를 미리 준비하는데, 이는 보안 규칙을 일시적으로 비활성화하여 초기 데이터 설정을 가능하게 합니다. 세 번째 테스트에서는, 다른 사용자(user456)가 user123의 문서를 읽으려 시도합니다.
이는 명백히 권한이 없는 접근이므로 assertFails()로 거부되어야 합니다. 이 테스트는 "소유자만 접근 가능" 규칙이 제대로 작동하는지 확인합니다.
여러분이 이런 테스트를 작성하면 보안 규칙을 수정할 때마다 모든 권한 시나리오가 여전히 올바르게 작동하는지 자동으로 확인할 수 있습니다. 또한 새로운 기능을 추가할 때 기존 보안 정책이 깨지지 않았는지 보장할 수 있습니다.
특히 복잡한 보안 규칙(예: 친구 관계, 그룹 멤버십, 역할 기반 권한 등)을 구현할 때 이런 테스트가 없으면 모든 경우의 수를 수동으로 검증하기 거의 불가능합니다. 자동화된 테스트는 수백 개의 시나리오를 몇 초 만에 검증하여 개발 속도와 보안을 동시에 향상시킵니다.
실전 팁
💡 보안 규칙을 변경할 때마다 반드시 테스트를 실행하세요. 작은 규칙 수정이 예상치 못한 부작용을 일으킬 수 있으며, 테스트 없이는 발견하기 어렵습니다.
💡 assertFails()가 실패하면 오류 메시지에서 "PERMISSION_DENIED"를 확인하세요. 다른 오류(예: NOT_FOUND)가 나오면 보안 규칙이 아닌 다른 문제일 수 있습니다.
💡 복잡한 보안 규칙은 단위별로 쪼개서 테스트하세요. 예를 들어, "친구만 게시물 읽기 가능" 규칙은 (1) 친구 관계 확인, (2) 게시물 읽기 권한을 별도로 테스트하면 디버깅이 쉽습니다.
💡 withSecurityRulesDisabled()는 테스트 데이터 준비에만 사용하고, 실제 테스트 시나리오에는 사용하지 마세요. 보안 규칙을 우회하면 테스트의 의미가 없어집니다.
💡 실제 프로덕션의 firestore.rules 파일을 에뮬레이터에 로드하여 테스트하세요. 별도의 테스트용 규칙을 사용하면 실제 환경과 차이가 생겨 테스트가 무의미해질 수 있습니다.
3. Cloud Functions 단위 테스트 - 서버리스 함수 검증
시작하며
여러분이 작성한 Cloud Function이 프로덕션에 배포된 후 특정 조건에서만 에러가 발생한다면 어떻게 디버깅하시겠어요? 실제 환경에서는 로그를 보는 것조차 시간이 걸리고, 재현하기도 어렵습니다.
이런 문제는 Functions가 이벤트 기반으로 동작하고 외부 서비스와 연동되기 때문에 발생합니다. Firestore 문서가 생성될 때, 사용자가 회원가입할 때, 예약된 시간에 등 다양한 트리거 조건과 외부 API 호출, 데이터베이스 쿼리 등이 복잡하게 얽혀 있어 수동 테스트로는 모든 경우를 커버하기 어렵습니다.
바로 이럴 때 필요한 것이 Cloud Functions 단위 테스트입니다. 함수를 격리된 환경에서 실행하고 다양한 입력과 상황을 시뮬레이션하여 모든 로직을 검증할 수 있습니다.
개요
간단히 말해서, Cloud Functions 단위 테스트는 여러분의 함수를 실제 트리거 없이 직접 호출하여 입력과 출력, 부작용을 검증하는 테스트입니다. 실제로 Firestore 문서를 생성하거나 사용자를 가입시키지 않고도, 해당 이벤트가 발생했을 때의 상황을 시뮬레이션할 수 있습니다.
예를 들어, "사용자 생성 시 환영 이메일 발송" 함수를 테스트할 때 실제로 이메일을 보내지 않고도 올바른 내용으로 발송 함수가 호출되었는지 확인할 수 있습니다. 기존에는 Functions를 배포한 후 실제 이벤트를 발생시켜 테스트했다면, 이제는 로컬에서 수백 번 실행하며 모든 엣지 케이스를 검증할 수 있습니다.
firebase-functions-test 라이브러리는 Functions 런타임 환경을 시뮬레이션하고, 이벤트 데이터를 생성하며, 외부 의존성을 모킹할 수 있는 도구를 제공합니다. 이를 통해 함수의 비즈니스 로직이 정확한지, 에러 처리가 제대로 되는지, 성능이 기준을 충족하는지 등을 빠르게 검증할 수 있습니다.
코드 예제
import * as admin from 'firebase-admin';
import * as functions from 'firebase-functions';
import * as test from 'firebase-functions-test';
// Functions 테스트 환경 초기화
const testEnv = test();
// 테스트할 함수: 사용자 생성 시 프로필 초기화
export const onUserCreate = functions.auth.user().onCreate(async (user) => {
const { uid, email, displayName } = user;
await admin.firestore().collection('userProfiles').doc(uid).set({
email,
displayName: displayName || 'Anonymous',
createdAt: admin.firestore.FieldValue.serverTimestamp(),
role: 'user'
});
});
// 단위 테스트
describe('onUserCreate', () => {
test('사용자 생성 시 프로필 문서가 생성됨', async () => {
// 테스트용 사용자 데이터
const userRecord = {
uid: 'test-user-123',
email: 'test@example.com',
displayName: 'Test User'
};
// 함수 실행
const wrapped = testEnv.wrap(onUserCreate);
await wrapped(userRecord);
// 검증: Firestore에 프로필 생성되었는지 확인
const profileDoc = await admin.firestore()
.collection('userProfiles')
.doc('test-user-123')
.get();
expect(profileDoc.exists).toBe(true);
expect(profileDoc.data()?.email).toBe('test@example.com');
expect(profileDoc.data()?.role).toBe('user');
});
test('displayName이 없으면 Anonymous로 설정됨', async () => {
const userRecord = {
uid: 'test-user-456',
email: 'noname@example.com',
displayName: undefined
};
const wrapped = testEnv.wrap(onUserCreate);
await wrapped(userRecord);
const profileDoc = await admin.firestore()
.collection('userProfiles')
.doc('test-user-456')
.get();
expect(profileDoc.data()?.displayName).toBe('Anonymous');
});
});
설명
이것이 하는 일: Cloud Functions 단위 테스트는 서버리스 함수를 격리된 환경에서 실행하여 다양한 시나리오에서 올바르게 동작하는지 검증합니다. 첫 번째로, firebase-functions-test로 테스트 환경을 초기화합니다.
이는 Firebase Functions 런타임을 시뮬레이션하여 환경 변수, 설정, 컨텍스트 등을 제공합니다. onUserCreate 함수는 실제 프로덕션 코드로, 사용자가 생성될 때 자동으로 프로필 문서를 Firestore에 저장하는 로직을 담고 있습니다.
그 다음으로, 첫 번째 테스트에서 testEnv.wrap()으로 함수를 감싸서 테스트 가능하게 만듭니다. 실제 사용자 생성 이벤트 없이 직접 userRecord 객체를 전달하여 함수를 호출합니다.
함수 실행 후 Firestore를 직접 쿼리하여 프로필 문서가 올바르게 생성되었는지, 이메일과 역할이 정확한지 검증합니다. 세 번째로, 두 번째 테스트에서는 엣지 케이스를 검증합니다.
displayName이 없는 사용자가 가입했을 때 함수가 에러를 발생시키지 않고 'Anonymous'로 기본값을 설정하는지 확인합니다. 이런 엣지 케이스 테스트가 없으면 프로덕션에서 예상치 못한 에러가 발생할 수 있습니다.
여러분이 이런 테스트를 작성하면 함수를 배포하기 전에 모든 로직이 정확한지 확인할 수 있습니다. 또한 함수를 수정할 때 기존 기능이 깨지지 않았는지(회귀 테스트) 자동으로 검증할 수 있어 안전하게 리팩토링할 수 있습니다.
특히 외부 API 호출, 이메일 발송, 결제 처리 등이 포함된 함수는 테스트에서 모킹하여 실제 비용이나 부작용 없이 로직만 검증할 수 있습니다. Jest의 jest.mock()이나 Sinon 같은 라이브러리를 사용하여 외부 의존성을 가짜 구현으로 대체하면 빠르고 안정적인 테스트가 가능합니다.
실전 팁
💡 testEnv.cleanup()을 afterAll()에서 호출하여 테스트 환경을 정리하세요. 그렇지 않으면 메모리 누수나 열린 소켓으로 인해 Jest가 종료되지 않을 수 있습니다.
💡 외부 API 호출이 있는 함수는 반드시 모킹하세요. 실제 API를 호출하면 테스트가 느려지고 불안정해지며, API 제한이나 비용 문제가 발생할 수 있습니다.
💡 Firestore 타임스탬프(serverTimestamp())를 테스트할 때는 정확한 값을 비교하지 말고 존재 여부만 확인하세요. 서버 시간은 예측할 수 없으므로 expect(data.createdAt).toBeDefined()를 사용합니다.
💡 비동기 함수는 반드시 await를 사용하거나 return promise를 하세요. 그렇지 않으면 테스트가 함수 실행이 끝나기 전에 통과하여 실제 에러를 놓칠 수 있습니다.
💡 환경 변수나 설정이 필요한 함수는 testEnv.mockConfig()로 모킹하세요. 예: testEnv.mockConfig({ stripe: { key: 'test_key' } })를 사용하면 실제 Stripe 키 없이 테스트할 수 있습니다.
4. Firestore 트랜잭션 테스트 - 데이터 일관성 검증
시작하며
여러분이 전자상거래 앱을 개발하는데, 두 사용자가 동시에 마지막 남은 상품 1개를 구매하려 할 때 어떻게 될까요? 둘 다 구매에 성공하면 재고는 -1이 되고, 둘 다 실패하면 상품은 영원히 팔리지 않을 수 있습니다.
이런 문제는 동시성 상황에서 데이터 일관성을 보장하지 못해서 발생합니다. 재고 확인과 차감, 주문 생성이 원자적으로(atomic) 실행되지 않으면 중간에 다른 요청이 끼어들어 데이터가 불일치하게 됩니다.
실제 프로덕션 환경에서는 수백 명의 사용자가 동시에 같은 데이터를 읽고 쓰기 때문에 이런 문제가 빈번히 발생합니다. 바로 이럴 때 필요한 것이 Firestore 트랜잭션 테스트입니다.
동시 접근 상황을 시뮬레이션하여 트랜잭션이 데이터 일관성을 올바르게 보장하는지 검증할 수 있습니다.
개요
간단히 말해서, 트랜잭션 테스트는 여러 작업이 하나의 원자적 단위로 실행되어 데이터 일관성이 보장되는지 검증하는 테스트입니다. Firestore 트랜잭션은 여러 문서를 읽고 쓰는 작업을 하나의 단위로 묶어서, 모두 성공하거나 모두 실패하도록 보장합니다.
예를 들어, 계좌 이체에서 출금과 입금이 모두 성공해야 하고 하나라도 실패하면 둘 다 취소되어야 합니다. 또한 트랜잭션 중간에 다른 요청이 데이터를 변경하면 자동으로 재시도하여 최종적으로 올바른 결과를 보장합니다.
기존에는 "아마도 잘 되겠지"라고 희망하며 프로덕션에 배포했다면, 이제는 동시성 시나리오를 직접 테스트하여 확신을 가질 수 있습니다. 트랜잭션 테스트는 특히 재고 관리, 좌석 예약, 포인트 적립/차감, 카운터 증가 등 경쟁 조건(race condition)이 발생할 수 있는 모든 상황에서 필수적입니다.
테스트를 통해 낙관적 동시성 제어가 올바르게 작동하고, 충돌 시 적절히 재시도하며, 최종적으로 정확한 값이 저장되는지 확인할 수 있습니다.
코드 예제
import { getFirestore } from 'firebase-admin/firestore';
// 재고 차감 함수 (트랜잭션 사용)
async function decreaseStock(productId: string, quantity: number) {
const db = getFirestore();
const productRef = db.collection('products').doc(productId);
return db.runTransaction(async (transaction) => {
const productDoc = await transaction.get(productRef);
if (!productDoc.exists) {
throw new Error('Product not found');
}
const currentStock = productDoc.data()!.stock;
if (currentStock < quantity) {
throw new Error('Insufficient stock');
}
// 재고 차감
transaction.update(productRef, {
stock: currentStock - quantity,
soldCount: (productDoc.data()!.soldCount || 0) + quantity
});
});
}
// 트랜잭션 테스트
describe('decreaseStock', () => {
test('재고가 충분하면 차감 성공', async () => {
// 초기 재고 설정
await admin.firestore().collection('products').doc('product-1').set({
name: 'Test Product',
stock: 10,
soldCount: 0
});
// 재고 차감
await decreaseStock('product-1', 3);
// 검증
const product = await admin.firestore()
.collection('products')
.doc('product-1')
.get();
expect(product.data()?.stock).toBe(7);
expect(product.data()?.soldCount).toBe(3);
});
test('재고가 부족하면 에러 발생', async () => {
await admin.firestore().collection('products').doc('product-2').set({
name: 'Limited Product',
stock: 2,
soldCount: 0
});
// 재고보다 많이 요청하면 에러
await expect(decreaseStock('product-2', 5))
.rejects.toThrow('Insufficient stock');
// 재고는 변경되지 않아야 함
const product = await admin.firestore()
.collection('products')
.doc('product-2')
.get();
expect(product.data()?.stock).toBe(2);
});
test('동시 요청 시 정확한 재고 차감', async () => {
await admin.firestore().collection('products').doc('product-3').set({
stock: 10
});
// 10개의 동시 요청 (각각 1개씩 차감)
await Promise.all(
Array.from({ length: 10 }, () => decreaseStock('product-3', 1))
);
const product = await admin.firestore()
.collection('products')
.doc('product-3')
.get();
// 정확히 0이어야 함 (race condition이 없다면)
expect(product.data()?.stock).toBe(0);
});
});
설명
이것이 하는 일: Firestore 트랜잭션 테스트는 여러 작업이 원자적으로 실행되고 동시성 문제 없이 데이터 일관성이 유지되는지 검증합니다. 첫 번째로, decreaseStock 함수는 runTransaction()을 사용하여 재고 확인과 차감을 하나의 원자적 작업으로 묶습니다.
transaction.get()으로 현재 재고를 읽고, 충분한지 확인한 후, transaction.update()로 재고를 차감합니다. 이 모든 작업이 중간에 다른 요청의 간섭 없이 하나의 단위로 실행됩니다.
그 다음으로, 첫 번째 테스트에서 정상적인 재고 차감 시나리오를 검증합니다. 초기 재고 10개에서 3개를 차감했을 때 최종 재고가 정확히 7개인지, 그리고 soldCount가 올바르게 증가했는지 확인합니다.
두 필드가 동시에 정확하게 업데이트되었다는 것은 트랜잭션이 원자성을 보장한다는 의미입니다. 두 번째 테스트에서는 재고가 부족한 상황을 검증합니다.
재고 2개에 5개를 요청하면 에러가 발생해야 하고, 중요한 점은 재고가 전혀 변경되지 않아야 한다는 것입니다. 트랜잭션이 실패하면 모든 변경사항이 롤백되어 데이터 일관성이 유지됩니다.
세 번째 테스트는 가장 중요한데, 10개의 요청을 Promise.all()로 동시에 실행하여 경쟁 조건을 시뮬레이션합니다. 각 요청이 1개씩 차감하므로 최종 재고는 정확히 0이어야 합니다.
만약 트랜잭션 없이 일반적인 read-modify-write 패턴을 사용했다면 여러 요청이 같은 초기값(10)을 읽어서 최종 재고가 9나 8이 될 수 있습니다. 트랜잭션은 이런 문제를 자동으로 해결합니다.
여러분이 이런 테스트를 작성하면 프로덕션 환경에서 발생할 수 있는 동시성 버그를 사전에 발견하고 수정할 수 있습니다. 특히 블랙 프라이데이 같은 트래픽 폭증 상황에서 데이터 일관성이 깨지지 않는다는 확신을 가질 수 있습니다.
Firestore 트랜잭션은 자동으로 충돌을 감지하고 재시도하므로, 테스트에서 동시 요청을 여러 번 실행해도 최종 결과는 항상 일관되어야 합니다. 만약 테스트가 간헐적으로 실패한다면 트랜잭션 로직에 문제가 있다는 신호입니다.
실전 팁
💡 트랜잭션 내에서 외부 API를 호출하지 마세요. 트랜잭션이 재시도되면 API가 여러 번 호출되어 중복 결제나 중복 이메일 발송이 발생할 수 있습니다. 트랜잭션 성공 후 외부 API를 호출하세요.
💡 동시성 테스트는 Promise.all()로 여러 요청을 병렬 실행하되, 충분한 횟수(최소 10회 이상)를 실행하여 경쟁 조건을 확실히 검증하세요. 1-2회는 운 좋게 통과할 수 있습니다.
💡 트랜잭션에서 읽은 문서만 수정할 수 있습니다. 읽지 않은 문서를 update하면 에러가 발생하므로, 모든 관련 문서를 transaction.get()으로 먼저 읽으세요.
💡 트랜잭션은 최대 500개 문서까지만 읽을 수 있습니다. 대량의 문서를 처리해야 한다면 배치(batch) 작업이나 여러 트랜잭션으로 나누세요.
💡 테스트 환경에서 트랜잭션 재시도를 시뮬레이션하려면 의도적으로 충돌을 일으키세요. 두 트랜잭션이 같은 문서를 동시에 수정하도록 하면 Firestore가 자동으로 재시도하는 것을 확인할 수 있습니다.
5. Firebase Authentication 통합 테스트 - 인증 플로우 검증
시작하며
여러분의 앱에서 사용자가 로그인하면 프로필을 자동으로 생성하고, 환영 이메일을 보내고, 분석 이벤트를 기록해야 한다면 이 모든 것이 올바르게 연결되어 있는지 어떻게 확인하시나요? 하나라도 빠지면 사용자 경험이 망가질 수 있습니다.
이런 문제는 인증이 단순히 로그인만 처리하는 것이 아니라 여러 시스템과 연결되어 있기 때문에 발생합니다. Authentication 트리거 함수, Firestore 보안 규칙, 사용자 데이터 초기화, 권한 설정 등이 복잡하게 얽혀 있어 하나의 단위 테스트로는 전체 플로우를 검증할 수 없습니다.
바로 이럴 때 필요한 것이 Firebase Authentication 통합 테스트입니다. 실제 사용자 가입부터 로그인, 권한 확인까지 전체 플로우를 자동으로 검증할 수 있습니다.
개요
간단히 말해서, Authentication 통합 테스트는 사용자 인증 관련 모든 컴포넌트가 올바르게 연동되어 작동하는지 end-to-end로 검증하는 테스트입니다. 단위 테스트가 개별 함수나 모듈을 검증한다면, 통합 테스트는 실제 사용자 시나리오(회원가입 → 프로필 생성 → 로그인 → 데이터 접근)를 처음부터 끝까지 실행하여 모든 부분이 함께 작동하는지 확인합니다.
예를 들어, 사용자가 이메일로 가입하면 Auth에 사용자가 생성되고, onUserCreate 함수가 트리거되어 Firestore에 프로필이 생성되고, 보안 규칙에 따라 해당 사용자만 자신의 프로필에 접근할 수 있는지를 한 번에 검증합니다. 기존에는 각 부분을 따로 테스트하고 "아마도 함께 잘 작동하겠지"라고 가정했다면, 이제는 실제로 함께 작동하는지 자동으로 확인할 수 있습니다.
통합 테스트는 특히 인증 관련 버그를 조기에 발견하는 데 효과적입니다. 프로필 생성 함수가 잘 작동하지만 보안 규칙 때문에 사용자가 자신의 프로필을 읽지 못한다거나, 로그인은 성공하지만 커스텀 클레임이 설정되지 않아 관리자 기능에 접근하지 못하는 등의 통합 문제를 발견할 수 있습니다.
코드 예제
import { initializeTestEnvironment, assertSucceeds, assertFails } from '@firebase/rules-unit-testing';
import { getAuth } from 'firebase/auth';
describe('Authentication 통합 테스트', () => {
let testEnv;
beforeAll(async () => {
testEnv = await initializeTestEnvironment({
projectId: 'demo-test-project',
auth: {
host: 'localhost',
port: 9099
},
firestore: {
host: 'localhost',
port: 8080
}
});
});
test('회원가입 후 프로필 생성 및 접근 권한 검증', async () => {
// 1. 사용자 생성 (Authentication)
const user = testEnv.authenticatedContext('user-123', {
email: 'newuser@example.com',
displayName: 'New User'
});
// 2. onUserCreate 함수가 프로필 생성했다고 가정 (실제로는 Functions 트리거)
await testEnv.withSecurityRulesDisabled(async (context) => {
await context.firestore()
.collection('userProfiles')
.doc('user-123')
.set({
email: 'newuser@example.com',
displayName: 'New User',
role: 'user',
createdAt: new Date()
});
});
// 3. 사용자는 자신의 프로필을 읽을 수 있어야 함
const profileRef = user.firestore()
.collection('userProfiles')
.doc('user-123');
await assertSucceeds(profileRef.get());
// 4. 사용자는 자신의 프로필을 수정할 수 있어야 함
await assertSucceeds(profileRef.update({ bio: 'Hello world' }));
// 5. 다른 사용자의 프로필은 읽을 수 없어야 함
const otherUser = testEnv.authenticatedContext('user-456');
const otherProfileRef = otherUser.firestore()
.collection('userProfiles')
.doc('user-123');
await assertFails(otherProfileRef.get());
});
test('비인증 사용자는 프로필에 접근 불가', async () => {
const unauthedContext = testEnv.unauthenticatedContext();
const profileRef = unauthedContext.firestore()
.collection('userProfiles')
.doc('user-123');
await assertFails(profileRef.get());
});
test('관리자는 모든 프로필에 접근 가능', async () => {
// 관리자 커스텀 클레임 설정
const admin = testEnv.authenticatedContext('admin-001', {
email: 'admin@example.com',
admin: true // 커스텀 클레임
});
const profileRef = admin.firestore()
.collection('userProfiles')
.doc('user-123');
// 관리자는 다른 사용자 프로필도 읽을 수 있어야 함
await assertSucceeds(profileRef.get());
});
});
설명
이것이 하는 일: Authentication 통합 테스트는 사용자 인증과 관련된 모든 시스템(Auth, Firestore, Functions, 보안 규칙)이 올바르게 연동되는지 end-to-end로 검증합니다. 첫 번째로, initializeTestEnvironment()로 Auth와 Firestore 에뮬레이터를 모두 연결한 통합 테스트 환경을 구성합니다.
이를 통해 실제 프로덕션 환경과 유사하게 여러 서비스가 함께 작동하는 상황을 시뮬레이션할 수 있습니다. 그 다음으로, 첫 번째 테스트에서 전체 사용자 라이프사이클을 검증합니다.
authenticatedContext()로 새 사용자를 생성하고, 실제 프로덕션에서는 onUserCreate 함수가 자동으로 프로필을 생성하므로 테스트에서는 이를 시뮬레이션합니다. 그 후 사용자가 자신의 프로필을 읽고 수정할 수 있는지, 그리고 다른 사용자의 프로필은 접근할 수 없는지 연속적으로 검증합니다.
세 번째로, 각 테스트는 실제 사용자 시나리오를 반영합니다. 비인증 사용자 테스트는 로그아웃 상태에서 프로필 접근을 시도하는 상황을, 관리자 테스트는 커스텀 클레임을 통한 역할 기반 접근 제어(RBAC)를 검증합니다.
admin: true 클레임을 가진 사용자는 보안 규칙에서 특별한 권한을 부여받아 다른 사용자의 데이터에도 접근할 수 있습니다. 여러분이 이런 통합 테스트를 작성하면 개별 컴포넌트는 잘 작동하지만 함께 작동하지 않는 통합 버그를 발견할 수 있습니다.
예를 들어, Functions는 프로필을 생성하지만 보안 규칙이 너무 엄격해서 사용자가 접근하지 못하거나, 커스텀 클레임이 설정되지 않아 관리자 기능이 작동하지 않는 등의 문제를 조기에 발견합니다. 실제 프로덕션 배포 전에 이런 통합 테스트를 실행하면 사용자가 회원가입 후 "프로필을 불러올 수 없습니다" 같은 에러를 보는 최악의 상황을 방지할 수 있습니다.
또한 인증 관련 코드를 수정할 때 기존 플로우가 깨지지 않았는지 자동으로 확인하여 안전한 리팩토링이 가능합니다.
실전 팁
💡 통합 테스트는 단위 테스트보다 느리므로, 핵심 사용자 플로우만 선별하여 테스트하세요. 모든 세부 로직은 단위 테스트로 커버하고, 통합 테스트는 주요 시나리오에 집중합니다.
💡 커스텀 클레임을 테스트할 때는 authenticatedContext의 두 번째 인자로 클레임을 전달하세요. 실제 프로덕션에서는 Admin SDK로 설정하지만, 테스트에서는 간편하게 시뮬레이션할 수 있습니다.
💡 beforeEach에서 clearFirestore()와 clearAuth()를 호출하여 각 테스트를 완전히 격리하세요. 이전 테스트의 사용자나 데이터가 남아있으면 다음 테스트에 영향을 줄 수 있습니다.
💡 실제 Functions 트리거를 테스트하려면 firebase-functions-test와 통합하세요. withSecurityRulesDisabled로 데이터를 설정하는 대신, 실제 onUserCreate 함수를 호출하여 end-to-end 플로우를 완전히 검증할 수 있습니다.
💡 이메일 인증, 전화번호 인증 등 실제 외부 통신이 필요한 부분은 에뮬레이터에서 자동으로 승인됩니다. 실제 이메일을 받지 않아도 테스트가 가능하므로 걱정하지 마세요.
6. Firebase Storage 업로드 테스트 - 파일 처리 검증
시작하며
여러분이 사용자 프로필 사진 업로드 기능을 만들 때, 이미지 크기 제한, 파일 형식 검증, 보안 규칙 적용이 모두 올바르게 작동하는지 확인해야 합니다. 만약 10MB 동영상을 프로필 사진으로 업로드할 수 있다면 문제가 되겠죠?
이런 문제는 Storage가 단순히 파일을 저장하는 것 이상의 복잡한 로직을 포함하기 때문에 발생합니다. 파일 크기 검증, 확장자 체크, 사용자 권한 확인, 업로드 후 이미지 리사이징 트리거 등 여러 단계가 연결되어 있어 하나라도 빠지면 보안 취약점이나 성능 문제가 발생할 수 있습니다.
바로 이럴 때 필요한 것이 Firebase Storage 업로드 테스트입니다. 파일 업로드부터 보안 규칙, 메타데이터 검증까지 모든 단계를 자동으로 테스트할 수 있습니다.
개요
간단히 말해서, Storage 업로드 테스트는 파일 저장과 관련된 모든 로직(업로드, 다운로드, 삭제, 권한 검증)이 올바르게 작동하는지 확인하는 테스트입니다. 실제 파일을 업로드하지 않고도 Buffer나 Blob을 사용하여 다양한 크기와 형식의 파일을 시뮬레이션할 수 있습니다.
예를 들어, 100KB JPG 파일, 10MB PNG 파일, 허용되지 않는 EXE 파일 등을 생성하여 각각의 경우에 시스템이 올바르게 반응하는지 검증합니다. 기존에는 실제로 파일을 업로드해보고 수동으로 확인했다면, 이제는 수백 개의 테스트 케이스를 몇 초 만에 자동으로 검증할 수 있습니다.
Storage 보안 규칙도 Firestore와 마찬가지로 테스트할 수 있습니다. "users/{userId}/profile.jpg는 해당 사용자만 업로드 가능"이라는 규칙이 정확히 작동하는지, 다른 사용자는 업로드할 수 없는지, 파일 크기나 형식 제한이 적용되는지 등을 자동으로 검증하여 보안 취약점을 사전에 차단합니다.
코드 예제
import { assertFails, assertSucceeds } from '@firebase/rules-unit-testing';
import { getStorage, ref, uploadBytes, getDownloadURL } from 'firebase/storage';
describe('Storage 업로드 테스트', () => {
test('사용자는 자신의 프로필 사진을 업로드할 수 있음', async () => {
const userContext = testEnv.authenticatedContext('user-123');
const storage = userContext.storage();
// 가짜 이미지 파일 생성 (1KB)
const fakeImage = Buffer.from('fake-image-data');
const storageRef = ref(storage, 'users/user-123/profile.jpg');
// 업로드 성공해야 함
await assertSucceeds(uploadBytes(storageRef, fakeImage, {
contentType: 'image/jpeg',
customMetadata: {
uploadedBy: 'user-123'
}
}));
});
test('다른 사용자의 폴더에는 업로드 불가', async () => {
const userContext = testEnv.authenticatedContext('user-456');
const storage = userContext.storage();
const fakeImage = Buffer.from('fake-image-data');
const storageRef = ref(storage, 'users/user-123/profile.jpg');
// 다른 사용자 폴더에 업로드 시도 - 실패해야 함
await assertFails(uploadBytes(storageRef, fakeImage, {
contentType: 'image/jpeg'
}));
});
test('파일 크기 제한 검증', async () => {
const userContext = testEnv.authenticatedContext('user-123');
const storage = userContext.storage();
// 5MB 초과 파일 생성 (가정: 5MB 제한)
const largeFile = Buffer.alloc(6 * 1024 * 1024);
const storageRef = ref(storage, 'users/user-123/large.jpg');
// 크기 제한 초과 - 실패해야 함
await assertFails(uploadBytes(storageRef, largeFile, {
contentType: 'image/jpeg'
}));
});
test('허용되지 않는 파일 형식 거부', async () => {
const userContext = testEnv.authenticatedContext('user-123');
const storage = userContext.storage();
const exeFile = Buffer.from('MZ...'); // EXE 파일 시그니처
const storageRef = ref(storage, 'users/user-123/malware.exe');
// 이미지가 아닌 파일 - 실패해야 함
await assertFails(uploadBytes(storageRef, exeFile, {
contentType: 'application/x-msdownload'
}));
});
test('업로드 후 다운로드 URL 생성', async () => {
const userContext = testEnv.authenticatedContext('user-123');
const storage = userContext.storage();
const fakeImage = Buffer.from('image-data');
const storageRef = ref(storage, 'users/user-123/avatar.jpg');
// 업로드
await uploadBytes(storageRef, fakeImage, {
contentType: 'image/jpeg'
});
// 다운로드 URL 생성 가능해야 함
const downloadURL = await getDownloadURL(storageRef);
expect(downloadURL).toMatch(/http/);
});
});
설명
이것이 하는 일: Storage 업로드 테스트는 파일 저장과 관련된 모든 로직(권한, 크기 제한, 파일 형식, 메타데이터)을 자동으로 검증합니다. 첫 번째로, 정상적인 업로드 시나리오를 테스트합니다.
authenticatedContext로 특정 사용자로 로그인하고, Buffer.from()으로 가짜 이미지 데이터를 생성하여 실제 파일 없이도 업로드를 시뮬레이션합니다. 'users/user-123/profile.jpg' 경로는 사용자 ID와 일치하므로 보안 규칙에 따라 업로드가 허용되어야 합니다.
contentType과 customMetadata를 함께 전달하여 메타데이터도 올바르게 저장되는지 확인합니다. 그 다음으로, 두 번째 테스트에서 권한 없는 접근을 검증합니다.
user-456이 user-123의 폴더에 파일을 업로드하려 시도하면 Storage 보안 규칙에서 거부되어야 합니다. assertFails()가 성공하면 보안 규칙이 올바르게 작동한다는 의미입니다.
이는 실제 프로덕션에서 사용자가 다른 사람의 프로필 사진을 덮어쓰는 것을 방지합니다. 세 번째와 네 번째 테스트에서는 파일 크기와 형식 제한을 검증합니다.
Buffer.alloc()으로 대용량 파일을 시뮬레이션하여 5MB 제한이 작동하는지 확인하고, EXE 파일의 매직 넘버(MZ)를 가진 파일을 업로드하려 할 때 거부되는지 테스트합니다. 이런 검증이 없으면 악성 파일이 업로드되거나 Storage 비용이 폭증할 수 있습니다.
마지막 테스트에서는 업로드 후 다운로드 URL이 정상적으로 생성되는지 확인합니다. 실제 애플리케이션에서는 업로드 후 이 URL을 Firestore에 저장하여 프로필 사진을 표시하므로, URL 생성이 실패하면 사용자 경험이 망가집니다.
여러분이 이런 테스트를 작성하면 파일 업로드 기능을 배포하기 전에 모든 엣지 케이스를 검증할 수 있습니다. 특히 보안 규칙은 문법이 복잡하고 디버깅이 어려우므로, 자동화된 테스트로 모든 시나리오를 커버하는 것이 필수적입니다.
Storage 에뮬레이터는 실제 GCS(Google Cloud Storage)와 거의 동일하게 작동하므로, 테스트를 통과한 코드는 프로덕션에서도 안정적으로 작동할 것이라고 확신할 수 있습니다.
실전 팁
💡 Buffer.alloc() 대신 실제 이미지 파일을 읽어서 테스트하면 더 현실적입니다. fs.readFileSync('test-image.jpg')로 작은 샘플 이미지를 프로젝트에 포함시켜 사용하세요.
💡 Storage 보안 규칙에서 request.resource.size로 파일 크기를 제한할 수 있습니다. 규칙: allow write: if request.resource.size < 5 * 1024 * 1024; (5MB 제한)
💡 contentType 검증은 보안 규칙에서 request.resource.contentType.matches('image/.*')로 구현하세요. 클라이언트에서 전달한 contentType만 믿으면 위조가 가능합니다.
💡 파일 업로드 후 Functions를 트리거하여 이미지 리사이징을 한다면, 통합 테스트에서 함께 검증하세요. 업로드는 성공했지만 리사이징이 실패하면 사용자는 썸네일을 볼 수 없습니다.
💡 에뮬레이터에서 업로드한 파일은 실제로 저장되지 않고 메모리에만 존재합니다. 테스트 종료 후 자동으로 사라지므로 정리 작업이 필요 없어 편리합니다.
7. Firestore 쿼리 성능 테스트 - 인덱스 최적화 검증
시작하며
여러분의 앱에 사용자가 100명일 때는 쿼리가 빠르게 작동하는데, 10만 명이 되니 타임아웃이 발생한다면 어떻게 하시겠어요? 프로덕션에서 사용자가 늘어난 후 발견하면 이미 늦습니다.
이런 문제는 Firestore 쿼리에 적절한 인덱스가 없거나, 비효율적인 쿼리를 사용해서 발생합니다. 복합 필터(where)와 정렬(orderBy)을 함께 사용하는 쿼리는 반드시 복합 인덱스가 필요한데, 이를 미리 확인하지 않으면 프로덕션에서 에러가 발생합니다.
또한 limit 없이 모든 문서를 읽는 쿼리는 데이터가 적을 때는 괜찮지만 많아지면 성능이 급격히 저하됩니다. 바로 이럴 때 필요한 것이 Firestore 쿼리 성능 테스트입니다.
대량의 데이터를 시뮬레이션하여 쿼리가 효율적으로 작동하는지, 필요한 인덱스가 모두 있는지 사전에 검증할 수 있습니다.
개요
간단히 말해서, 쿼리 성능 테스트는 Firestore 쿼리가 대량의 데이터에서도 빠르고 정확하게 작동하는지 검증하는 테스트입니다. 실제 프로덕션과 유사한 데이터 볼륨을 에뮬레이터에 생성하여 쿼리 속도, 인덱스 사용 여부, 읽기 작업 수 등을 측정합니다.
예를 들어, 10만 개의 게시물 중에서 특정 카테고리의 최신 10개를 가져오는 쿼리가 1초 이내에 완료되는지, 그리고 인덱스를 사용하여 효율적으로 실행되는지 확인합니다. 기존에는 "일단 배포하고 문제 생기면 인덱스 추가"했다면, 이제는 배포 전에 필요한 모든 인덱스를 firestore.indexes.json에 정의하고 테스트로 검증할 수 있습니다.
쿼리 성능 테스트는 특히 복잡한 필터링, 정렬, 페이지네이션이 있는 쿼리에서 필수적입니다. "카테고리가 'tech'이고, 생성일 기준 내림차순, 10개씩 페이지네이션" 같은 쿼리는 (category, createdAt) 복합 인덱스가 필요한데, 테스트에서 이를 자동으로 검증하여 프로덕션 에러를 방지합니다.
코드 예제
import { getFirestore, collection, query, where, orderBy, limit, getDocs } from 'firebase/firestore';
describe('Firestore 쿼리 성능 테스트', () => {
beforeAll(async () => {
// 대량의 테스트 데이터 생성 (1000개 게시물)
const db = testEnv.authenticatedContext('admin').firestore();
const batch = db.batch();
for (let i = 0; i < 1000; i++) {
const docRef = db.collection('posts').doc(`post-${i}`);
batch.set(docRef, {
title: `Post ${i}`,
category: i % 5 === 0 ? 'tech' : 'general',
createdAt: new Date(2024, 0, i + 1),
views: Math.floor(Math.random() * 1000),
published: i % 10 !== 0 // 90%는 published
});
}
await batch.commit();
});
test('복합 쿼리가 인덱스를 사용하여 빠르게 실행됨', async () => {
const db = testEnv.authenticatedContext('user-123').firestore();
const startTime = Date.now();
// 복합 쿼리: category + orderBy + limit
const q = query(
collection(db, 'posts'),
where('category', '==', 'tech'),
where('published', '==', true),
orderBy('createdAt', 'desc'),
limit(10)
);
const snapshot = await getDocs(q);
const executionTime = Date.now() - startTime;
// 검증: 1초 이내 실행
expect(executionTime).toBeLessThan(1000);
// 검증: 정확히 10개 반환
expect(snapshot.size).toBeLessThanOrEqual(10);
// 검증: 모두 'tech' 카테고리
snapshot.forEach(doc => {
expect(doc.data().category).toBe('tech');
expect(doc.data().published).toBe(true);
});
// 검증: 날짜순 내림차순 정렬
const dates = snapshot.docs.map(doc => doc.data().createdAt.getTime());
const sortedDates = [...dates].sort((a, b) => b - a);
expect(dates).toEqual(sortedDates);
});
test('페이지네이션이 올바르게 작동함', async () => {
const db = testEnv.authenticatedContext('user-123').firestore();
// 첫 페이지
const firstPageQuery = query(
collection(db, 'posts'),
where('category', '==', 'tech'),
orderBy('createdAt', 'desc'),
limit(10)
);
const firstPage = await getDocs(firstPageQuery);
expect(firstPage.size).toBe(10);
// 두 번째 페이지 (startAfter 사용)
const lastDoc = firstPage.docs[firstPage.docs.length - 1];
const secondPageQuery = query(
collection(db, 'posts'),
where('category', '==', 'tech'),
orderBy('createdAt', 'desc'),
startAfter(lastDoc),
limit(10)
);
const secondPage = await getDocs(secondPageQuery);
// 두 페이지의 문서가 중복되지 않아야 함
const firstIds = firstPage.docs.map(doc => doc.id);
const secondIds = secondPage.docs.map(doc => doc.id);
const intersection = firstIds.filter(id => secondIds.includes(id));
expect(intersection.length).toBe(0);
});
test('비효율적인 쿼리 감지', async () => {
const db = testEnv.authenticatedContext('user-123').firestore();
// 나쁜 예: limit 없이 모든 문서 읽기
const inefficientQuery = query(
collection(db, 'posts'),
where('category', '==', 'tech')
);
const startTime = Date.now();
const snapshot = await getDocs(inefficientQuery);
const executionTime = Date.now() - startTime;
// 경고: 너무 많은 문서를 읽음
if (snapshot.size > 100) {
console.warn(`비효율적인 쿼리: ${snapshot.size}개 문서를 읽었습니다. limit을 추가하세요.`);
}
// 읽은 문서 수 기록 (비용 계산용)
console.log(`읽기 작업: ${snapshot.size}회`);
});
});
설명
이것이 하는 일: 쿼리 성능 테스트는 실제 프로덕션 수준의 데이터 볼륨에서 Firestore 쿼리가 효율적으로 작동하는지 검증합니다. 첫 번째로, beforeAll()에서 1000개의 게시물을 batch 작업으로 생성합니다.
실제로 하나씩 set()을 호출하면 1000번의 네트워크 요청이 발생하지만, batch를 사용하면 한 번의 commit()으로 모두 저장하여 테스트 시간을 크게 단축합니다. 카테고리, 날짜, 조회수 등 다양한 필드를 랜덤하게 생성하여 실제 데이터와 유사한 환경을 만듭니다.
그 다음으로, 복합 쿼리 테스트에서 실행 시간을 측정합니다. Date.now()로 시작과 끝 시간을 기록하여 쿼리가 1초 이내에 완료되는지 확인합니다.
만약 1초를 초과하면 인덱스가 없거나 쿼리가 비효율적이라는 신호입니다. 또한 반환된 데이터가 정확한지(category가 'tech', 날짜순 정렬) 검증하여 쿼리 로직이 올바른지 확인합니다.
세 번째로, 페이지네이션 테스트에서 startAfter()를 사용한 커서 기반 페이지네이션이 올바르게 작동하는지 검증합니다. 첫 페이지의 마지막 문서를 가져와서 두 번째 페이지의 시작점으로 사용하고, 두 페이지의 문서 ID가 중복되지 않는지 확인합니다.
만약 중복이 있으면 사용자가 같은 게시물을 두 번 보게 되어 UX가 망가집니다. 마지막으로, 비효율적인 쿼리를 감지하는 테스트에서는 limit 없이 모든 문서를 읽는 나쁜 패턴을 시뮬레이션합니다.
100개 이상의 문서를 읽으면 경고를 출력하여 개발자에게 알립니다. 실제 프로덕션에서는 수십만 개의 문서를 읽으면 응답 시간이 길어지고 비용도 크게 증가하므로, 이런 패턴을 조기에 발견하는 것이 중요합니다.
여러분이 이런 테스트를 작성하면 프로덕션 배포 전에 필요한 인덱스를 모두 파악할 수 있습니다. 에뮬레이터에서 쿼리를 실행하면 "Index needed" 경고가 나오는데, 이를 기반으로 firestore.indexes.json 파일을 작성하여 배포와 함께 인덱스도 생성할 수 있습니다.
또한 쿼리 성능 문제를 조기에 발견하여 리팩토링할 수 있습니다. 예를 들어, "모든 게시물 가져오기 → 클라이언트에서 필터링"보다 "Firestore 쿼리로 필터링 → 필요한 것만 가져오기"가 훨씬 효율적이며, 성능 테스트가 이런 개선 기회를 알려줍니다.
실전 팁
💡 에뮬레이터는 복합 인덱스 없이도 쿼리를 실행하지만, 프로덕션에서는 에러가 발생합니다. 테스트 중 콘솔에 나오는 "Index needed" 경고를 반드시 확인하고 firestore.indexes.json에 추가하세요.
💡 대량의 테스트 데이터를 매번 생성하면 테스트가 느려집니다. beforeAll()에서 한 번만 생성하고, 각 테스트는 데이터를 읽기만 하도록 하여 속도를 높이세요.
💡 성능 임계값(예: 1초)은 실제 프로덕션 환경을 고려하여 설정하세요. 에뮬레이터는 로컬이므로 실제 Firebase보다 빠를 수 있습니다. 여유를 두고 설정하는 것이 좋습니다.
💡 쿼리 최적화 팁: where() 필터는 앞쪽에, orderBy()는 뒤쪽에 배치하세요. 순서가 바뀌면 다른 인덱스가 필요할 수 있습니다.
💡 snapshot.size로 읽은 문서 수를 기록하세요. Firestore는 읽기 작업당 과금하므로, 테스트에서 읽기 수를 최소화하는 쿼리를 찾아내면 비용 절감에 도움이 됩니다.
8. Firebase Functions 에러 처리 테스트 - 장애 복구 검증
시작하며
여러분의 Cloud Function이 외부 API를 호출하는데 네트워크가 일시적으로 끊기면 어떻게 되나요? 에러를 무시하고 지나가면 데이터 불일치가 발생하고, 무한 재시도하면 비용이 폭증할 수 있습니다.
이런 문제는 서버리스 환경에서 예측 불가능한 에러가 자주 발생하기 때문에 일어납니다. 외부 API 타임아웃, 데이터베이스 연결 실패, 메모리 부족, 권한 에러 등 다양한 장애 상황에서 함수가 올바르게 대응하지 못하면 사용자 경험이 망가지고 데이터가 손실될 수 있습니다.
바로 이럴 때 필요한 것이 Functions 에러 처리 테스트입니다. 다양한 장애 시나리오를 시뮬레이션하여 함수가 적절히 에러를 처리하고 복구하는지 검증할 수 있습니다.
개요
간단히 말해서, 에러 처리 테스트는 예상 가능한 모든 에러 상황에서 함수가 안전하게 실패하거나 자동으로 복구하는지 검증하는 테스트입니다. 외부 서비스의 에러, 잘못된 입력 데이터, 리소스 부족 등을 일부러 발생시켜서 함수가 crash하지 않고 적절한 에러 메시지를 반환하는지, 트랜잭션을 롤백하는지, 재시도 로직이 작동하는지 확인합니다.
예를 들어, 결제 API가 500 에러를 반환하면 사용자에게 "일시적인 오류"를 알리고, 주문 상태를 "pending"으로 유지하며, 나중에 재시도할 수 있도록 큐에 저장하는지 테스트합니다. 기존에는 "에러가 발생하지 않기를 바라며" 배포했다면, 이제는 모든 에러 시나리오를 테스트하여 확신을 가질 수 있습니다.
에러 처리 테스트는 특히 금전 거래, 데이터 동기화, 이메일 발송 같은 중요한 작업에서 필수적입니다. 결제는 성공했는데 주문 생성이 실패하면 큰 문제가 되므로, 이런 상황에서 적절히 롤백하거나 수동 복구 가능한 상태로 만드는지 검증해야 합니다.
코드 예제
import axios from 'axios';
import * as functions from 'firebase-functions';
import * as admin from 'firebase-admin';
// 외부 API 호출하는 함수
export const sendWelcomeEmail = functions.auth.user().onCreate(async (user) => {
const { email, displayName } = user;
try {
// 이메일 발송 API 호출
await axios.post('https://api.emailservice.com/send', {
to: email,
subject: 'Welcome!',
body: `Hello ${displayName || 'there'}!`
}, {
timeout: 5000 // 5초 타임아웃
});
// 성공 로그
console.log(`Welcome email sent to ${email}`);
// Firestore에 발송 기록
await admin.firestore().collection('emailLogs').add({
userId: user.uid,
email,
type: 'welcome',
status: 'sent',
sentAt: admin.firestore.FieldValue.serverTimestamp()
});
} catch (error) {
console.error('Email sending failed:', error);
// 실패 기록 (나중에 재시도 가능)
await admin.firestore().collection('emailLogs').add({
userId: user.uid,
email,
type: 'welcome',
status: 'failed',
error: error.message,
failedAt: admin.firestore.FieldValue.serverTimestamp()
});
// 에러를 다시 던지지 않음 (사용자 생성은 성공해야 함)
// 이메일 발송 실패는 치명적이지 않음
}
});
// 에러 처리 테스트
describe('sendWelcomeEmail 에러 처리', () => {
test('외부 API 타임아웃 시 적절히 처리', async () => {
// axios 모킹: 타임아웃 에러 발생
jest.spyOn(axios, 'post').mockRejectedValue(
new Error('timeout of 5000ms exceeded')
);
const userRecord = {
uid: 'user-123',
email: 'test@example.com',
displayName: 'Test User'
};
const wrapped = testEnv.wrap(sendWelcomeEmail);
// 함수가 에러를 던지지 않아야 함 (사용자 생성은 성공)
await expect(wrapped(userRecord)).resolves.not.toThrow();
// 실패 로그가 Firestore에 기록되어야 함
const failedLogs = await admin.firestore()
.collection('emailLogs')
.where('userId', '==', 'user-123')
.where('status', '==', 'failed')
.get();
expect(failedLogs.size).toBe(1);
expect(failedLogs.docs[0].data().error).toContain('timeout');
});
test('네트워크 에러 시 재시도 로직 작동', async () => {
let callCount = 0;
// 처음 2번은 실패, 3번째는 성공
jest.spyOn(axios, 'post').mockImplementation(() => {
callCount++;
if (callCount < 3) {
return Promise.reject(new Error('Network error'));
}
return Promise.resolve({ data: { success: true } });
});
// 재시도 로직이 있는 함수 (3번 재시도)
const sendEmailWithRetry = async (email: string) => {
let attempts = 0;
const maxAttempts = 3;
while (attempts < maxAttempts) {
try {
await axios.post('https://api.emailservice.com/send', { to: email });
return 'success';
} catch (error) {
attempts++;
if (attempts >= maxAttempts) throw error;
await new Promise(resolve => setTimeout(resolve, 1000)); // 1초 대기
}
}
};
const result = await sendEmailWithRetry('test@example.com');
expect(result).toBe('success');
expect(callCount).toBe(3); // 2번 실패 + 1번 성공
});
test('잘못된 입력 데이터 검증', async () => {
const invalidUserRecord = {
uid: 'user-456',
email: 'invalid-email', // 잘못된 이메일 형식
displayName: undefined
};
const wrapped = testEnv.wrap(sendWelcomeEmail);
// 함수가 crash하지 않아야 함
await expect(wrapped(invalidUserRecord)).resolves.not.toThrow();
// 실패 로그 확인
const logs = await admin.firestore()
.collection('emailLogs')
.where('userId', '==', 'user-456')
.get();
expect(logs.size).toBe(1);
expect(logs.docs[0].data().status).toBe('failed');
});
});
설명
이것이 하는 일: 에러 처리 테스트는 예상 가능한 모든 장애 상황에서 함수가 적절히 대응하여 데이터 일관성을 유지하고 사용자에게 명확한 피드백을 제공하는지 검증합니다. 첫 번째로, sendWelcomeEmail 함수는 try-catch로 외부 API 호출을 감싸서 에러가 발생해도 사용자 생성 프로세스가 중단되지 않도록 합니다.
이메일 발송은 중요하지만 치명적이지 않은 작업이므로, 실패하더라도 사용자는 가입할 수 있어야 합니다. 에러가 발생하면 Firestore에 실패 로그를 남겨서 나중에 수동으로 재발송하거나 모니터링할 수 있습니다.
그 다음으로, 첫 번째 테스트에서 jest.spyOn()으로 axios.post를 모킹하여 타임아웃 에러를 강제로 발생시킵니다. 실제로 API를 호출하지 않고도 네트워크 장애 상황을 시뮬레이션할 수 있습니다.
함수가 에러를 던지지 않고(resolves.not.toThrow) 실패 로그를 Firestore에 기록하는지 검증하여, 에러 처리 로직이 올바르게 작동함을 확인합니다. 세 번째로, 재시도 로직 테스트에서는 callCount 변수로 호출 횟수를 추적합니다.
처음 2번은 실패하고 3번째에 성공하도록 모킹하여, 재시도 로직이 올바르게 작동하는지 검증합니다. 실제 프로덕션에서는 일시적인 네트워크 문제가 자주 발생하므로, 지수 백오프(exponential backoff)를 사용한 재시도 로직이 필수적입니다.
마지막으로, 잘못된 입력 데이터 테스트에서는 유효하지 않은 이메일 주소를 전달하여 함수가 crash하지 않는지 확인합니다. 프로덕션에서는 예상치 못한 데이터가 들어올 수 있으므로, 모든 입력을 검증하고 에러를 우아하게 처리해야 합니다.
여러분이 이런 테스트를 작성하면 프로덕션에서 발생할 수 있는 거의 모든 에러 상황에 대비할 수 있습니다. 특히 외부 서비스와 연동하는 함수는 외부 시스템의 장애를 제어할 수 없으므로, 에러 처리가 매우 중요합니다.
또한 테스트를 통해 에러 메시지가 명확한지, 로그가 충분한지, 재시도 로직이 무한 루프에 빠지지 않는지 등을 검증할 수 있습니다. 이런 세부 사항이 실제 장애 상황에서 빠른 복구를 가능하게 합니다.
실전 팁
💡 외부 API 모킹 시 다양한 HTTP 상태 코드를 테스트하세요. 400(잘못된 요청), 401(인증 실패), 500(서버 에러), 503(서비스 불가) 등 각각에 대해 다르게 대응해야 할 수 있습니다.
💡 재시도 로직에는 반드시 최대 횟수와 지수 백오프를 구현하세요. 무한 재시도는 비용 폭증으로 이어지며, 즉시 재시도는 서버에 부담을 줍니다. 1초, 2초, 4초 식으로 대기 시간을 늘리세요.
💡 치명적인 에러와 복구 가능한 에러를 구분하세요. 네트워크 타임아웃은 재시도 가능하지만, 잘못된 API 키는 재시도해도 실패하므로 즉시 에러를 기록하고 알림을 보내야 합니다.
💡 에러 로그에는 문제 해결에 필요한 모든 정보를 포함하세요. 타임스탬프, 사용자 ID, 요청 데이터, 에러 메시지, 스택 트레이스 등이 있으면 디버깅이 훨씬 쉬워집니다.
💡 Functions에서 에러를 던지면 Firebase가 자동으로 재시도하지만, 비멱등성(non-idempotent) 작업(결제, 이메일 발송 등)은 중복 실행을 방지하는 로직이 필요합니다. 고유 ID로 중복 요청을 감지하세요.