Firebase로 웹게임 랭킹 시스템 구현하기
웹게임 랭킹은 점수를 저장하고 정렬하는 기능처럼 보이지만, 실제로는 입력값 검증, 중복 제출, 비용, 배포 환경변수, 치팅 가능성까지 함께 고려해야 합니다.
이 글의 목표
Vite + TypeScript 웹게임에 로그인 없는 닉네임 기반 글로벌 랭킹을 추가합니다. 개인 최고 기록은 localStorage에 저장하고, 글로벌 기록은 Cloud Firestore의 rankings 컬렉션에 저장합니다.
목차
- 구현 범위
- Firebase 프로젝트 준비
- 환경변수 설정
- TypeScript 설정
- Firestore 데이터 구조
- RankingService 구현
- Firestore Security Rules
- Composite Index
- UI와 개인 최고 기록
- 배포와 Vercel 설정
- 비용과 성능
- 치팅과 보안 한계
- 테스트 체크리스트
1. 구현 범위
이번 단계에서 구현할 기능
- 로그인 없이 닉네임으로 점수 제출
- 개인 최고 기록은
localStorage에 저장 - 글로벌 기록은 Firestore
rankings컬렉션에 저장 - 랭킹은 최대 100개만 조회
- Firebase 설정이 없으면 랭킹 기능만 비활성화
- Firestore 오류가 발생해도 게임 플레이는 계속 진행
전체 흐름
게임 종료
↓
플레이 결과 생성
↓
닉네임 입력
↓
클라이언트 검증
↓
Cloud Firestore에 기록 저장
↓
Top 100 조회
↓
랭킹 패널 표시이 구조는 초기 웹게임이나 커뮤니티용 랭킹에 적합합니다. 공식 경쟁, 시즌 보상, 현금성 보상에 사용하려면 서버 검증 구조가 추가로 필요합니다.
2. Firebase 프로젝트 준비
2.1 Firebase 프로젝트 생성
- Firebase Console에서 프로젝트를 생성합니다.
- Web App을 등록합니다.
- Firebase Hosting은 선택하지 않아도 됩니다.
- Firestore Database를 생성합니다.
- Firestore는 Standard 버전을 선택합니다.
- Production mode로 시작합니다.
- 서비스 지역은 주 사용자와 가까운 리전을 선택합니다.
이미 Vercel, Netlify, Cloudflare Pages 등으로 사이트를 배포하고 있다면 Firebase Hosting은 필요하지 않습니다.
2.2 Firebase Web App 구성값 확인
Firebase Console의 프로젝트 설정에서 Web App 구성값을 확인합니다.
const firebaseConfig = {
apiKey: '...',
authDomain: '...',
projectId: '...',
storageBucket: '...',
messagingSenderId: '...',
appId: '...',
measurementId: '...'
};이 값들은 Firebase Web SDK가 브라우저에서 사용하는 공개 설정값입니다. Admin SDK private key나 service account JSON 같은 서버 비밀키와는 다릅니다.
2.3 Firebase SDK 설치
npm install firebase3. 환경변수 설정
3.1 로컬 환경변수
프로젝트 루트에 .env.local 파일을 생성합니다.
VITE_FIREBASE_API_KEY=your-api-key
VITE_FIREBASE_AUTH_DOMAIN=your-project.firebaseapp.com
VITE_FIREBASE_PROJECT_ID=your-project-id
VITE_FIREBASE_STORAGE_BUCKET=your-project.firebasestorage.app
VITE_FIREBASE_MESSAGING_SENDER_ID=your-sender-id
VITE_FIREBASE_APP_ID=your-app-id
VITE_FIREBASE_MEASUREMENT_ID=your-measurement-idVITE_FIREBASE_MEASUREMENT_ID는 Analytics를 사용하지 않으면 생략할 수 있습니다.
3.2 Git 제외 설정
.env
.env.*
!.env.example실제 값이 없는 .env.example을 별도로 두면 환경변수 목록을 문서화하기 편합니다.
VITE_FIREBASE_API_KEY=
VITE_FIREBASE_AUTH_DOMAIN=
VITE_FIREBASE_PROJECT_ID=
VITE_FIREBASE_STORAGE_BUCKET=
VITE_FIREBASE_MESSAGING_SENDER_ID=
VITE_FIREBASE_APP_ID=
VITE_FIREBASE_MEASUREMENT_ID=4. TypeScript 설정
4.1 Vite 환경변수 타입 선언
src/vite-env.d.ts 또는 별도 타입 선언 파일에 다음 내용을 추가합니다.
interface ImportMetaEnv {
readonly VITE_FIREBASE_API_KEY?: string;
readonly VITE_FIREBASE_AUTH_DOMAIN?: string;
readonly VITE_FIREBASE_PROJECT_ID?: string;
readonly VITE_FIREBASE_STORAGE_BUCKET?: string;
readonly VITE_FIREBASE_MESSAGING_SENDER_ID?: string;
readonly VITE_FIREBASE_APP_ID?: string;
readonly VITE_FIREBASE_MEASUREMENT_ID?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}프로젝트의 allowJs가 false라면 Firebase 관련 파일은 반드시 .ts로 작성합니다.
4.2 Firebase 초기화 모듈
src/services/firebaseApp.ts
import { initializeApp, type FirebaseApp } from 'firebase/app';
import { getFirestore, type Firestore } from 'firebase/firestore';
const env = import.meta.env;
function readEnv(name: keyof ImportMetaEnv): string {
return env[name]?.trim() ?? '';
}
export function isFirebaseConfigured(): boolean {
return (
readEnv('VITE_FIREBASE_API_KEY').length > 0 &&
readEnv('VITE_FIREBASE_AUTH_DOMAIN').length > 0 &&
readEnv('VITE_FIREBASE_PROJECT_ID').length > 0 &&
readEnv('VITE_FIREBASE_STORAGE_BUCKET').length > 0 &&
readEnv('VITE_FIREBASE_MESSAGING_SENDER_ID').length > 0 &&
readEnv('VITE_FIREBASE_APP_ID').length > 0
);
}
let app: FirebaseApp | null = null;
let db: Firestore | null = null;
export function getFirebaseApp(): FirebaseApp {
if (!isFirebaseConfigured()) {
throw new Error('Firebase is not configured.');
}
if (!app) {
app = initializeApp({
apiKey: readEnv('VITE_FIREBASE_API_KEY'),
authDomain: readEnv('VITE_FIREBASE_AUTH_DOMAIN'),
projectId: readEnv('VITE_FIREBASE_PROJECT_ID'),
storageBucket: readEnv('VITE_FIREBASE_STORAGE_BUCKET'),
messagingSenderId: readEnv('VITE_FIREBASE_MESSAGING_SENDER_ID'),
appId: readEnv('VITE_FIREBASE_APP_ID')
});
}
return app;
}
export function getFirebaseDb(): Firestore {
if (!db) {
db = getFirestore(getFirebaseApp());
}
return db;
}지연 초기화를 사용하면 Firebase 환경변수가 없는 개발 환경에서도 게임 전체가 즉시 중단되는 문제를 피할 수 있습니다.
5. Firestore 데이터 구조
컬렉션 이름은 rankings로 사용합니다.
문서 필드
- 타입: string - 설명: 화면에 표시할 닉네임
nickname
- 타입: number - 설명: 도달 웨이브
wave
- 타입: number - 설명: 최종 점수
score
- 타입: number - 설명: 처치 수
kills
- 타입: number - 설명: 생존 시간(초)
survivalTime
- 타입: string - 설명: 사용 캐릭터
characterId
- 타입: string - 설명: 게임 버전
gameVersion
- 타입: string - 설명: 브라우저 단위 임의 식별자
clientId
- 타입: timestamp - 설명: 서버 기준 제출 시간
createdAt
권장 정렬 기준
wave내림차순score내림차순survivalTime내림차순
게임 규칙에 따라 생존 시간이 짧을수록 좋은 기록이라면 survivalTime 정렬 방향을 오름차순으로 변경해야 합니다.
6. RankingService 구현
src/types/ranking.ts
import type { Timestamp } from 'firebase/firestore';
export interface RankingSubmitPayload {
nickname: string;
wave: number;
score: number;
kills: number;
survivalTime: number;
characterId: string;
gameVersion: string;
}
export interface RankingRecord {
id: string;
rank: number;
nickname: string;
wave: number;
score: number;
kills: number;
survivalTime: number;
characterId: string;
gameVersion: string;
clientId: string;
createdAt?: Timestamp;
}src/services/RankingService.ts
import {
addDoc,
collection,
getDocs,
limit,
orderBy,
query,
serverTimestamp,
type DocumentData,
type QueryDocumentSnapshot
} from 'firebase/firestore';
import { getFirebaseDb, isFirebaseConfigured } from './firebaseApp';
import type {
RankingRecord,
RankingSubmitPayload
} from '../types/ranking';
const COLLECTION_NAME = 'rankings';
const CLIENT_ID_KEY = 'gemslasher.ranking.clientId';
const MAX_RANKING_COUNT = 100;
const MIN_NICKNAME_LENGTH = 2;
const MAX_NICKNAME_LENGTH = 12;
const MAX_WAVE = 999;
const MAX_SCORE = 999_999_999;
const MAX_KILLS = 999_999;
const MAX_SURVIVAL_TIME = 21_600;
function normalizeString(value: unknown, maxLength: number): string {
return String(value ?? '')
.replace(/[\u0000-\u001F\u007F]/g, '')
.trim()
.replace(/\s+/g, ' ')
.slice(0, maxLength);
}
function getOrCreateClientId(): string {
const existing = localStorage.getItem(CLIENT_ID_KEY);
if (existing) {
return existing;
}
const clientId =
typeof crypto.randomUUID === 'function'
? crypto.randomUUID()
: `${Date.now()}-${Math.random().toString(36).slice(2)}`;
localStorage.setItem(CLIENT_ID_KEY, clientId);
return clientId;
}
function assertIntegerInRange(
value: number,
min: number,
max: number,
fieldName: string
): void {
if (!Number.isInteger(value) || value < min || value > max) {
throw new Error(`Invalid ${fieldName}.`);
}
}
function validatePayload(
input: RankingSubmitPayload
): RankingSubmitPayload {
const nickname = normalizeString(input.nickname, MAX_NICKNAME_LENGTH);
const characterId = normalizeString(input.characterId, 32);
const gameVersion = normalizeString(input.gameVersion, 32);
const wave = Math.floor(Number(input.wave));
const score = Math.floor(Number(input.score));
const kills = Math.floor(Number(input.kills));
const survivalTime = Math.floor(Number(input.survivalTime));
if (nickname.length < MIN_NICKNAME_LENGTH) {
throw new Error('Nickname is too short.');
}
assertIntegerInRange(wave, 1, MAX_WAVE, 'wave');
assertIntegerInRange(score, 0, MAX_SCORE, 'score');
assertIntegerInRange(kills, 0, MAX_KILLS, 'kills');
assertIntegerInRange(
survivalTime,
1,
MAX_SURVIVAL_TIME,
'survivalTime'
);
if (!characterId) {
throw new Error('Invalid characterId.');
}
if (!gameVersion) {
throw new Error('Invalid gameVersion.');
}
// 정상 UI 제출을 위한 1차 방어선입니다.
// 서버 권한 검증이나 완전한 안티치트가 아닙니다.
const generousScoreLimit =
kills * 1_200 +
wave * 50_000 +
survivalTime * 500 +
100_000;
if (score > generousScoreLimit) {
throw new Error('Score is outside the expected range.');
}
return {
nickname,
wave,
score,
kills,
survivalTime,
characterId,
gameVersion
};
}
function mapRankingDoc(
snapshot: QueryDocumentSnapshot<DocumentData>,
index: number
): RankingRecord {
const data = snapshot.data();
return {
id: snapshot.id,
rank: index + 1,
nickname: String(data.nickname ?? 'Unknown'),
wave: Number(data.wave ?? 0),
score: Number(data.score ?? 0),
kills: Number(data.kills ?? 0),
survivalTime: Number(data.survivalTime ?? 0),
characterId: String(data.characterId ?? ''),
gameVersion: String(data.gameVersion ?? ''),
clientId: String(data.clientId ?? ''),
createdAt: data.createdAt
};
}
export async function submitRanking(
input: RankingSubmitPayload
): Promise<string> {
if (!isFirebaseConfigured()) {
throw new Error('Firebase ranking is unavailable.');
}
const payload = validatePayload(input);
const db = getFirebaseDb();
const docRef = await addDoc(collection(db, COLLECTION_NAME), {
...payload,
clientId: getOrCreateClientId(),
createdAt: serverTimestamp()
});
return docRef.id;
}
export async function getTopRankings(
count = MAX_RANKING_COUNT
): Promise<RankingRecord[]> {
if (!isFirebaseConfigured()) {
return [];
}
const safeCount = Math.max(1, Math.min(count, MAX_RANKING_COUNT));
const db = getFirebaseDb();
const rankingQuery = query(
collection(db, COLLECTION_NAME),
orderBy('wave', 'desc'),
orderBy('score', 'desc'),
orderBy('survivalTime', 'desc'),
limit(safeCount)
);
const snapshot = await getDocs(rankingQuery);
return snapshot.docs.map(mapRankingDoc);
}클라이언트 검증은 정상 UI에서 잘못된 값을 줄여주는 장치입니다. 브라우저 개발자 도구나 Firestore REST API를 이용하면 우회할 수 있으므로, 완전한 안티치트로 취급하면 안 됩니다.
7. Firestore Security Rules
Firebase Console의 Firestore Database → Rules에 다음 규칙을 적용합니다.
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
match /rankings/{rankingId} {
allow read: if true;
allow create: if
request.resource.data.keys().hasOnly([
'nickname',
'wave',
'score',
'kills',
'survivalTime',
'characterId',
'gameVersion',
'clientId',
'createdAt'
]) &&
request.resource.data.nickname is string &&
request.resource.data.nickname.size() >= 2 &&
request.resource.data.nickname.size() <= 12 &&
request.resource.data.wave is int &&
request.resource.data.wave >= 1 &&
request.resource.data.wave <= 999 &&
request.resource.data.score is int &&
request.resource.data.score >= 0 &&
request.resource.data.score <= 999999999 &&
request.resource.data.kills is int &&
request.resource.data.kills >= 0 &&
request.resource.data.kills <= 999999 &&
request.resource.data.survivalTime is int &&
request.resource.data.survivalTime >= 1 &&
request.resource.data.survivalTime <= 21600 &&
request.resource.data.characterId is string &&
request.resource.data.characterId.size() >= 1 &&
request.resource.data.characterId.size() <= 32 &&
request.resource.data.gameVersion is string &&
request.resource.data.gameVersion.size() >= 1 &&
request.resource.data.gameVersion.size() <= 32 &&
request.resource.data.clientId is string &&
request.resource.data.clientId.size() >= 1 &&
request.resource.data.clientId.size() <= 64 &&
request.resource.data.createdAt == request.time;
allow update, delete: if false;
}
match /{document=**} {
allow read, write: if false;
}
}
}createdAt == request.time 주의
클라이언트의 serverTimestamp()와 Rules의 request.time 비교는 Rules Playground 또는 Emulator에서 테스트한 뒤 배포하는 것이 안전합니다.
해당 조건이 거부된다면 다음 중 하나를 선택합니다.
- 지원되는 timestamp 검증 패턴으로 수정
- 초기 버전에서
createdAt is timestamp만 검증 - 향후 Cloud Functions에서 서버가 직접 작성
8. Composite Index
다음 복합 정렬 쿼리는 Composite Index가 필요할 수 있습니다.
orderBy('wave', 'desc'),
orderBy('score', 'desc'),
orderBy('survivalTime', 'desc')필요한 인덱스:
필요한 인덱스
Collection: rankings
wave Descending
score Descending
survivalTime Descending브라우저 콘솔에 The query requires an index 오류가 표시되면, 오류에 포함된 Firebase Console 링크를 열어 인덱스를 생성합니다.
9. UI와 개인 최고 기록
9.1 개인 최고 기록
const LOCAL_BEST_KEY = 'gemslasher.ranking.bestScore';
interface LocalBestRecord {
wave: number;
score: number;
kills: number;
survivalTime: number;
updatedAt: number;
}localStorage는 사용자가 직접 수정할 수 있으므로 보안 수단이 아닙니다. 최고 기록 안내, 닉네임 기억, 제출 UI 표시 여부 같은 UX 용도로만 사용합니다.
9.2 닉네임 출력 보안
사용자 입력은 innerHTML에 직접 넣지 않습니다.
권장
const nicknameCell = document.createElement('span');
nicknameCell.textContent = record.nickname;피해야 할 방식
row.innerHTML = `<span>${record.nickname}</span>`;9.3 UI 권장사항
- 제출 중 버튼 비활성화
- 오류 발생 시 재시도 가능
- 랭킹 통신 실패가 게임 루프를 중단하지 않도록 처리
- 새로고침 버튼에 10~30초 쿨타임 적용
- 모바일에서는 열 축약 또는 가로 스크롤 제공
10. 배포와 Vercel 설정
10.1 Vercel 환경변수 등록
Vercel Project Settings → Environment Variables에 로컬과 동일한 VITE_FIREBASE_* 변수를 등록합니다.
- Production: 필수
- Preview: Preview 배포에서도 테스트할 경우 추가
- Development: Vercel CLI를 사용할 경우 추가
10.2 환경변수 경고
Vercel은 VITE_ 접두사가 붙은 변수가 브라우저에 공개될 수 있다고 경고합니다. Firebase Web App 구성값은 브라우저 노출을 전제로 하므로 Mark as Safe로 처리할 수 있습니다.
Admin SDK private key, service account JSON, 서버 API secret에는 절대 VITE_ 접두사를 붙이면 안 됩니다.
10.3 Redeploy
- 환경변수를 저장합니다.
- Vercel의 Deployments 메뉴로 이동합니다.
- 최신 Production 배포의 메뉴를 엽니다.
- Redeploy를 선택합니다.
- 배포 완료 후 실제 도메인에서 다시 테스트합니다.
11. 비용과 성능
11.1 읽기 횟수 관리
- Top 50 또는 Top 100만 조회
- 게임 시작 시 자동 조회하지 않음
- 랭킹 패널을 열 때만 조회
- 초기 버전에서는
onSnapshot()실시간 리스너를 사용하지 않음 - 새로고침 버튼에 쿨타임 적용
- 짧은 시간 동안 결과를 메모리 캐시
Top 100을 조회하면 최대 100개 문서를 읽습니다. 사용자가 늘면 Firebase Console의 Usage에서 읽기·쓰기 횟수를 확인해야 합니다.
11.2 Firebase 모듈 지연 로딩
async function submitScore(
payload: RankingSubmitPayload
): Promise<void> {
const { submitRanking } = await import(
'../services/RankingService'
);
await submitRanking(payload);
}랭킹 패널을 열거나 점수를 제출할 때만 Firebase 모듈을 불러오면 초기 번들 크기를 줄이는 데 도움이 됩니다.
11.3 상위 퍼센트 표시
Cloud Functions 없이 구현한다면 전체 플레이어 기준 정확한 percentile보다 Top 100 범위를 명확하게 표시하는 편이 안전합니다.
글로벌 순위 #17
Top 100 기준 상위 17%const displayedPercent = Math.ceil(
(rank / rankings.length) * 100
);이 값은 전체 플레이어 기준 percentile이 아니므로, 반드시 Top 100 기준 또는 표시 랭킹 기준이라고 표기합니다.
12. 치팅과 보안 한계
현재 구조에서 가능한 조작
- 개발자 도구에서 제출 함수 직접 호출
- Firestore SDK 또는 REST API로 직접 문서 생성
- 허용 범위 내에서 점수·웨이브·처치 수 조작
clientId변경- 동일 기록 반복 제출
localStorage최고 기록 수정
Security Rules가 막을 수 있는 것
- 허용되지 않은 필드 추가
- 잘못된 데이터 타입
- 범위를 벗어난 수치
- 기존 기록 수정 및 삭제
rankings외 컬렉션 접근
Security Rules가 증명할 수 없는 것
- 사용자가 실제 게임을 플레이했는지
- 점수가 실제 전투에서 생성됐는지
- 브라우저 데이터가 조작되지 않았는지
- 동일 사용자가 새로운
clientId를 만들었는지
단계별 보안 강화
단계별 보안 강화
- 목적: 기본적인 잘못된 입력 차단
- 1단계 — Security Rules + 클라이언트 검증
- 목적: uid 기반 식별
- 2단계 — Anonymous Auth
- 목적: 외부 자동화 요청 억제
- 3단계 — App Check
- 목적: 서버 검증, rate limit, 서버 쓰기
- 4단계 — Cloud Functions
랭킹이 게임 내 보상, 시즌 경쟁, 공식 토너먼트와 연결되기 전에는 Cloud Functions 기반 제출 구조를 검토하는 것이 좋습니다.
13. 테스트 체크리스트
로컬 테스트
- [ ]
.env.local을 작성했다. - [ ] 개발 서버를 재시작했다.
- [ ] 게임오버 후 점수를 제출할 수 있다.
- [ ] Firestore
rankings에 문서가 생성된다. - [ ] 랭킹 정렬이 의도한 순서로 표시된다.
- [ ] 닉네임 1자가 거부된다.
- [ ] 닉네임 12자 초과가 제한된다.
- [ ] 제출 버튼 연속 클릭이 차단된다.
- [ ] 네트워크 오류가 게임을 중단시키지 않는다.
배포 테스트
- [ ] Vercel Production 환경변수를 등록했다.
- [ ] 환경변수 등록 후 Redeploy 했다.
- [ ] 실제 도메인에서 점수 제출이 된다.
- [ ] 실제 도메인에서 랭킹 조회가 된다.
- [ ] Firestore Rules가 게시되어 있다.
- [ ] Composite Index가 활성 상태다.
보안 테스트
- [ ]
update와delete요청이 거부된다. - [ ] 허용되지 않은 필드가 포함된 create가 거부된다.
- [ ] 범위를 벗어난 점수가 거부된다.
- [ ] HTML 닉네임이 코드로 실행되지 않는다.
- [ ]
.env.local이 Git에 포함되지 않았다. - [ ] 서비스 계정 키가 클라이언트 저장소에 없다.
결론
초기 웹게임 랭킹은 다음 요소만 갖춰도 충분히 운영할 수 있습니다.
- 닉네임 및 점수 검증
- Firestore Security Rules
- Top 100 조회 제한
- 안전한 DOM 출력
- Firebase 실패 시 게임 fallback
- 비정상 기록 수동 관리
다만 클라이언트가 점수를 생성하고 Firestore에 직접 쓰는 구조에서는 실제 플레이 여부를 증명할 수 없습니다. 랭킹이 핵심 경쟁 요소가 되거나 보상과 연결된다면 Anonymous Auth, App Check, Cloud Functions 기반 구조로 단계적으로 확장해야 합니다.