| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | |||
| 5 | 6 | 7 | 8 | 9 | 10 | 11 |
| 12 | 13 | 14 | 15 | 16 | 17 | 18 |
| 19 | 20 | 21 | 22 | 23 | 24 | 25 |
| 26 | 27 | 28 | 29 | 30 | 31 |
- 배포
- php
- FastAPI
- next.js
- javascript
- REST API
- 아키텍처
- 인증
- 보안
- Android
- 소셜로그인
- 도메인설계
- React
- 프론트엔드
- typescript
- electron
- sqlite
- 메일자동화
- github actions
- playwright
- 마이그레이션
- 크롤링
- Vite
- JWT
- 상태관리
- Kotlin
- python
- contextBridge
- 백엔드
- 온디바이스AI
- Today
- Total
HM소프트
팬 응원 플랫폼 개발기 (1) — Firebase 없이도 도는 데모 모드와 정적 배포 본문

팬 응원 플랫폼을 React Native·Expo로 만들며 Firebase 없이도 도는 데모 모드를 구현한 기록. 더미 기본값으로 죽지 않는 초기화, 계층별 폴백, baseUrl로 정적 호스팅 하위 경로 404 해결, 프로덕션에서만 죽던 데모 모드 버그를 환경값 유효성 판정으로 수정, FPI 트랜잭션과 SVG 행성·카운터 애니메이션까지 다룹니다.
무엇을 만들었나
좋아하는 아티스트를 응원하는 모바일 팬 응원 플랫폼을 만들었다. 컨셉은 단순하다. 사용자가 걷거나, 출석하거나, 짧은 영상을 끝까지 보면 POP이라는 인앱 재화가 쌓이고, 그 POP을 아티스트의 행성(Planet)에 쏟아부으면 행성이 커지고 빛난다. 응원이 모일수록 행성의 지수(FPI)가 오르고, 우주 화면 속에서 행성 랭킹이 실시간으로 출렁인다. 가끔은 한 행성에 "이클립스(Eclipse)"라는 위기 이벤트가 떨어지고, 팬들이 POP을 모아 함께 방어한다.
기술 스택은 React Native + Expo(라우팅은 expo-router), 상태는 Zustand, 애니메이션은 Reanimated, 백엔드는 Firebase — 인증·Firestore·Cloud Functions를 쓴다. 모바일 앱이지만 Expo의 웹 타깃으로 빌드해 브라우저에서도 그대로 도는 정적 사이트로 배포했다.
그런데 이번 1편의 주제는 역설적으로 "Firebase 없이도 돌아가게 만들기"다. 포트폴리오 성격이 있는 프로젝트라, 백엔드 키 한 줄 없이 링크만 열어도 누구나 앱을 둘러볼 수 있어야 했다. 초기 릴리스 직후 하루 동안 이 "데모 모드"를 만들고, 정적 호스팅으로 올리고, 프로덕션 빌드에서만 데모 모드가 죽는 버그를 잡고, 마지막으로 우주 비주얼과 카운터 애니메이션을 다듬은 — 그 하루치 작업을 순서대로 기록한다.
시스템을 한눈에 — 세 겹의 계층
먼저 전체 구조부터 짚자. 클라이언트(앱)는 화면 → 스토어(Zustand) → 서비스(Firebase 래퍼)의 세 겹으로 나뉘고, 쓰기 작업(POP 차감, 부스팅처럼 부정행위가 가능한 연산)은 클라이언트가 직접 Firestore를 건드리지 않고 Cloud Functions를 호출한다. 읽기는 onSnapshot 실시간 구독으로 받는다.

여기서 이번 편의 핵심은 그림 오른쪽 아래의 점선 — 설정이 없으면 데모 모드로 강등되는 경로다. 이 경로가 없으면, Firebase 키가 주입되지 않은 환경(예: 누가 처음 열어본 배포 링크)에서는 앱이 그냥 흰 화면으로 죽는다. 읽기 계층(onSnapshot)과 쓰기 계층(Functions 호출)을 깔끔하게 나눠둔 덕분에, "백엔드가 없을 때"의 처리도 한 곳이 아니라 계층별로 일관되게 끼워 넣을 수 있었다.
데모 모드 (1) — 초기화는 절대 죽지 않는다
처음 릴리스를 웹으로 올리자마자 부딪힌 문제. 이 앱은 Firebase 설정값(API 키 등)이 환경변수로 주입돼야 정상 동작하는데, 설정 없이 열면 흰 화면만 떴다. Firebase 초기화나 첫 인증 콜백에서 예외가 터지면 React 트리 전체가 마운트에 실패하기 때문이다.
가장 먼저 손본 건 Firebase 설정 진입점이다. 핵심 결정은 두 가지였다. 첫째, firebaseConfig의 모든 값에 더미 기본값을 깔아서 initializeApp 자체는 무조건 성공하게 했다. 둘째, 플랫폼(웹/네이티브)에 따라 인증 지속성(persistence) 방식이 다른데, 이 분기와 "이미 초기화됨" 케이스를 try/catch로 감싸 어느 쪽이든 인스턴스를 돌려주게 했다.

getApps().length === 0 체크는 Fast Refresh나 모듈 재평가로 같은 모듈이 두 번 실행될 때 "이미 앱이 있는데 또 초기화"하다 터지는 사고를 막는다. 그리고 initializeAuth가 두 번째 호출에서 던지는 예외도 catch로 받아 getAuth(app)으로 폴백한다. 즉 이 파일의 설계 목표는 단 하나다 — 무슨 일이 있어도 모듈 로딩 단계에서 죽지 않는다. 죽지 않아야 그 위에서 "데모냐 실서버냐"를 판단할 기회라도 생긴다.
데모 모드 (2) — 화면과 스토어의 우아한 강등
초기화가 안 죽는 건 절반이다. 나머지 절반은 "그래서 데이터가 없을 때 화면에 뭘 보여줄 거냐"다. 여기서 택한 원칙은 계층마다 폴백을 둔다였다.
스토어 레벨에서는 모든 실시간 구독에 onSnapshot의 에러 콜백을 달았다. Firestore에 붙지 못하면 에러 콜백이 호출되는데, 거기서 던지지 않고 그냥 로딩만 끄고 빈 상태를 유지한다. 화면 레벨에서는 스토어가 비어 있으면 인메모리 더미 데이터로 대체한다.

onSnapshot은 인자를 두 가지 형태로 받는다 — 데이터 콜백만 줄 수도, 데이터 콜백 + 에러 콜백을 줄 수도 있다. 초기 버전은 데이터 콜백만 넘겨서, 권한 거부나 미연결 시 에러가 어디에도 잡히지 않고 콘솔로 새어 나가거나 구독이 조용히 죽었다. 에러 콜백을 명시하자 "못 붙었네, 로딩만 끄고 넘어가자"가 한 줄로 표현됐다. 그리고 바깥의 try/catch는 구독을 *거는 행위 자체*가 실패하는 경우(예: db가 비정상)를 막는 이중 안전망이다.
화면에서는 이렇게 받는다. 스토어의 planets가 비어 있으면 손으로 만든 5개의 더미 행성으로 대체한다.

지갑 스토어(walletStore)도 같은 철학이다. 기본 잔액을 DEMO_BALANCE = 1500으로 깔아두고, 실서버에 붙으면 onSnapshot이 진짜 잔액으로 덮어쓴다. 못 붙으면? 1500 POP을 가진 척하면서 데모를 굴린다. 이렇게 계층마다 "없을 때의 모습"을 정의해 두니, 어느 화면을 열어도 비어 보이지 않고 "체험판"처럼 동작한다. 데모 모드의 본질은 가짜 데이터가 아니라, 모든 데이터 경로가 결손을 견디도록 설계하는 것이었다.
정적 배포 — 모바일 앱을 브라우저에 올리다
배포는 서버 없이 정적 호스팅(Git 기반 정적 페이지 호스팅)으로 했다. 모바일 앱이지만 Expo는 웹 타깃 빌드를 지원하고(번들러는 Metro), 백엔드는 Firebase 또는 데모 모드라 정적 파일만 올리면 끝이다.
문제는 정적 호스팅이 사이트를 도메인 루트(/)가 아니라 /저장소이름/ 하위 경로에 서빙한다는 점이었다. 로컬(/)에서 멀쩡하던 앱이 배포되자 JS·이미지·라우트 경로가 전부 404였다. expo-router는 내부적으로 base 경로를 알아야 정확한 링크를 만들 수 있는데, 그 값을 앱 설정의 experiments.baseUrl로 잡아준다.

baseUrl을 저장소 하위 경로로 맞추자 빌드 결과물의 에셋·라우트가 전부 그 경로 기준으로 생성돼 404가 사라졌다. 정적 배포는 공짜고 간단하지만 대가가 있다 — "우리 앱이 루트가 아닐 수 있다"는 가정을 앱이 처음부터 갖고 있어야 한다는 것. 라우팅 라이브러리(expo-router)를 쓴 덕에 경로를 절대값으로 하드코딩하지 않았고, base 한 줄로 정리됐다. 만약 화면 곳곳에서 /galaxy 같은 경로를 손으로 박아 썼다면 여기서 전부 깨졌을 것이다.
프로덕션 빌드에서만 데모 모드가 죽었다 — 진짜 버그
데모 모드를 만들고 base 경로도 잡았는데, 배포된 사이트에서 또 흰 화면이 나왔다. 그것도 묘하게 — 개발 서버(expo start)에서는 데모 모드가 멀쩡히 도는데 프로덕션 빌드에서만 죽었다. 로컬에서 재현이 안 되는 그 흔한 악몽이다.
원인은 두 가지가 겹쳐 있었다. 첫째, 데모 여부를 판정하는 초기 코드가 개발용 플래그(__DEV__)에 기대고 있었다. 개발 모드면 데모, 아니면 실서버 — 라는 발상인데, 정작 "키 없이 배포된 프로덕션"이 이 분기의 사각지대였다. 프로덕션이면서 키가 없는 상태가 곧 흰 화면이었다.
둘째, 앞서 깔아둔 더미 기본값이 역으로 발목을 잡았다. apiKey가 빈 문자열이어도 || 'demo-api-key'로 채워지니 initializeApp은 늘 성공한다. 즉 "예외가 났는가"로 데모를 판정하려 해도, 초기화는 절대 예외를 안 내므로 그 판정은 항상 "실서버"로 기울었다. 객체는 만들어지는데 실제 인증/쿼리 호출 단계에서야 비동기로 터지니, 동기 try/catch의 바깥에서 죽은 것이다.
고친 방향은 명확했다. 데모 판정을 "예외"나 "개발 플래그"가 아니라, 환경변수 값 자체의 유효성으로 옮겼다.

같은 판정을 로그인 화면에도 넣었다. 데모 모드에서 구글/애플 버튼을 누르면 "준비 중" 알림 대신 곧장 우주 화면으로 보낸다 — 데모를 둘러보는 사람을 막다른 로그인 폼에 가두지 않기 위해서다. 흐름으로 그리면 이렇다.

교훈은 분명하다. 폴백 조건은 예외나 빌드 플래그에 기대지 말고, "데이터가 유효한가"를 명시적으로 판정하라. 개발 빌드와 프로덕션 빌드는 환경변수 치환·트리 셰이킹·예외 시점까지 생각보다 다르게 동작한다. "터지면 catch가 잡겠지"는 빌드 모드가 바뀌는 순간 배신한다. 그리고 더 뼈아픈 메타 교훈 — 죽지 않게 만든 더미 기본값이, 죽음을 감지하는 신호까지 지워버릴 수 있다. 견고함과 관측 가능성은 가끔 충돌한다.
잠깐, FPI는 어떻게 계산되나 — 부스팅 트랜잭션
데모 모드 이야기에서 잠시 벗어나, 이 우주가 *실제로* 어떻게 움직이는지 한 번 짚고 가자. 화면의 행성 크기·색·랭킹을 정하는 건 FPI라는 단일 지표다. FPI는 그 행성에 누적된 부스팅 POP의 로그 함수로 정해진다.

log2를 쓴 건 의도적이다. 초반엔 조금만 응원해도 행성이 쑥쑥 자라 성취감을 주고, 규모가 커질수록 한 단계 올리기가 점점 어려워진다 — 고래 한 명이 판을 끝내버리지 못하게 하는 완만한 곡선이다. 텍스처(rock → metal → crystal → gem)도 FPI 구간으로 갈려서, 숫자 하나가 곧 행성의 외형이 된다.
진짜 부스팅은 클라이언트가 아니라 Cloud Functions의 트랜잭션 안에서 일어난다. POP 잔액 차감과 행성 FPI 증가가 원자적으로 묶여야 하기 때문이다. 클라이언트가 직접 Firestore를 쓰게 두면 잔액을 위조할 수 있으니, 쓰기는 전부 서버로 올렸다.
const result = await db.runTransaction(async (tx) => {
const [userDoc, planetDoc] = await Promise.all([tx.get(userRef), tx.get(planetRef)]);
const userData = userDoc.data()!;
const planetData = planetDoc.data()!;
if ((userData.popBalance || 0) < amount) {
throw new HttpsError('failed-precondition', 'POP이 부족합니다.');
}
tx.update(userRef, {
popBalance: (userData.popBalance || 0) - amount,
totalPopSpent: (userData.totalPopSpent || 0) + amount,
});
const newTotalBoostPop = (planetData.totalBoostPop || 0) + amount;
const newFpi = calculateFpi(newTotalBoostPop);
tx.update(planetRef, {
totalBoostPop: newTotalBoostPop,
fpi: newFpi,
size: getSize(newFpi),
texture: getTexture(newFpi),
activeBoosterCount: admin.firestore.FieldValue.increment(1),
});
return { newFpi, newBalance: (userData.popBalance || 0) - amount };
});
여기서 FPI 계산식이 클라이언트(gameConfig)와 서버(functions) 양쪽에 똑같이 들어 있다는 점이 눈에 띈다. 서버는 진실의 원천으로 실제 값을 확정하고, 클라이언트는 낙관적 UI(곧바로 화면에 반영)를 위해 같은 식을 미리 돌린다. 규칙이 두 곳에 있으니 언젠가 한쪽만 바뀌면 어긋날 위험이 있다 — 다음 편에서 이 공유 규칙을 한 군데로 모으는 작업을 다룰 생각이다.
우주를 그리다 — SVG 행성과 반짝이는 별
기능이 안정된 뒤에는 비주얼을 다듬었다. 이 앱은 "데이터 시각화"보다 "감성 비주얼"이 곧 핵심 UX라, 행성이 행성처럼 보이는 게 중요했다.
초기 행성은 단색 원에 가까웠다. 이걸 react-native-svg의 방사형 그라디언트(RadialGradient)로 다시 그렸다. 빛이 왼쪽 위에서 들어오는 것처럼 중심을 30% 지점으로 밀고(cx/cy = 30%), 구체용 그라디언트 위에 흰색 광택 레이어를 한 겹 더 얹어 입체감을 줬다. 이클립스 상태일 땐 색을 붉게 바꿔 위기를 직관적으로 표시한다.
<Svg height={size} width={size} viewBox={`0 0 ${size} ${size}`}>
<Defs>
<RadialGradient id="sphereGrad" cx="30%" cy="30%" rx="70%" ry="70%">
<Stop offset="0%" stopColor={secondaryColor} stopOpacity="1" />
<Stop offset="100%" stopColor={mainColor} stopOpacity="1" />
</RadialGradient>
<RadialGradient id="highlightGrad" cx="35%" cy="35%" rx="40%" ry="40%">
<Stop offset="0%" stopColor="white" stopOpacity="0.4" />
<Stop offset="100%" stopColor="white" stopOpacity="0" />
</RadialGradient>
</Defs>
{/* 바깥 글로우 → 메인 구체 → 광택, 세 겹을 겹쳐 입체감 */}
<Circle cx={size/2} cy={size/2} r={size/2} fill={mainColor} opacity={0.2} />
<Circle cx={size/2} cy={size/2} r={size*0.45} fill="url(#sphereGrad)" />
<Circle cx={size/2} cy={size/2} r={size*0.45} fill="url(#highlightGrad)" />
</Svg>
배경의 별도 손봤다. 40개의 별을 고정 좌표로 흩뿌리되, 각 별의 깜빡임을 Reanimated의 withRepeat + withSequence로 무한 반복시키고, withDelay로 별마다 시작 시점을 어긋나게 했다. 모두 동시에 깜빡이면 디스코장이 되니까, 지연을 줘서 자연스러운 "밤하늘"을 만들었다. 행성 자체도 withTiming(360deg)로 아주 천천히 자전시키고, 이클립스 중인 행성만 1.12 ↔ 0.92 스케일로 맥동시켜 시선을 끈다. 이런 디테일은 기능 목록엔 안 잡히지만, 이 앱에선 이게 곧 제품의 정체성이다.
POP 카운터 — 숫자가 차오르는 맛
마지막은 화면 곳곳에 박히는 POP 잔액 카운터다. 응원 수가 올라갈 때 숫자가 뚝 바뀌면 재미가 없다. 그래서 잔액이 변하면 이전 값에서 새 값까지 스프링으로 보간해 숫자가 스르륵 차오르게 했고, 컨테이너를 살짝 튕겨(1.15 → 1 바운스) 변화가 눈에 들어오게 했다.
React Native에서 매 프레임 텍스트를 다시 렌더하면 비싸다. 그래서 일반 <Text> 대신 애니메이션 가능한 TextInput을 써서, JS 스레드를 거치지 않고 UI 스레드에서 text prop을 직접 갱신한다.
const animatedBalance = useSharedValue(popBalance);
const scale = useSharedValue(1);
useEffect(() => {
if (lastChange > 0) {
animatedBalance.value = withSpring(popBalance, { damping: 20, stiffness: 90 });
scale.value = withSequence(
withSpring(1.15, { duration: 150 }),
withSpring(1, { duration: 200 })
);
} else {
animatedBalance.value = popBalance; // 첫 표시는 애니메이션 없이
}
}, [popBalance, lastChange]);
// 매 프레임 숫자를 콤마 포맷해서 TextInput의 text에 직접 주입
const animatedProps = useAnimatedProps(() => ({
text: Math.floor(animatedBalance.value).toLocaleString(),
} as any));
여기서 작지만 중요한 장치가 lastChange다. 스토어는 잔액이 바뀔 때마다 lastChange: Date.now()를 같이 갱신하는데, 카운터는 이 값이 > 0일 때만 보간 애니메이션을 건다. 그래서 첫 마운트나 데모 잔액 초기 표시는 애니메이션 없이 즉시 뜨고, *실제 변화가 있었을 때만* 차오르는 연출이 나온다. 낙관적 업데이트(optimisticEarn/optimisticSpend)로 화면이 먼저 반응하고, 잠시 뒤 서버 onSnapshot이 진짜 값으로 덮어도 같은 보간을 타니 흐름이 끊기지 않는다.
정리하며
- 외부 서비스(Firebase) 의존은 계층마다 폴백을 둔 데모 모드로 풀었다. 초기화는 더미 기본값으로 절대 죽지 않게 하고(config), 구독은 에러 콜백으로 강등하고(store), 화면은 더미 데이터로 채운다(screen). "데모 모드"의 본질은 가짜 데이터가 아니라 결손을 견디는 데이터 경로 설계다.
- 모바일 앱을 정적 호스팅에 올릴 땐 "루트가 아닐 수 있다"를 가정해야 한다. expo-router의
baseUrl한 줄로 하위 경로 404를 정리했다 — 경로를 하드코딩하지 않은 덕분이다. - 가장 값진 버그였던 "프로덕션에서만 죽는 데모 모드"의 교훈: 폴백 판정은 예외나 개발 플래그가 아니라 환경값의 유효성으로. 그리고 죽지 않게 만든 더미 기본값이 죽음의 신호까지 지울 수 있으니, 견고함과 관측 가능성의 충돌을 의식해야 한다.
- FPI는
log2곡선으로 초반 성취감과 후반 난이도를 동시에 잡고, 잔액·FPI 변경은 Cloud Functions 트랜잭션으로 원자화했다. 단, 같은 계산식이 클라이언트/서버에 중복돼 있어 정리가 필요하다. - 비주얼(SVG 그라디언트 행성, 지연을 준 별 깜빡임, 스프링 보간 카운터)은 기능 목록엔 없지만 이 앱의 정체성을 완성한다.
응원 데이터 모델과 실시간 동기화, 그리고 클라이언트/서버에 흩어진 게임 규칙을 한 군데로 모으는 작업은 커밋이 더 쌓이면 다음 편에서 다룰 예정이다.
'외주 개발일지' 카테고리의 다른 글
| 온프레미스 음성 AI 상담 시스템 개발기 (2) — 예약 tool-call과 DB 기반 RAG (0) | 2026.06.18 |
|---|---|
| 온프레미스 음성 AI 상담 시스템 개발기 (1) — 상담 런타임과 오디오 파이프라인 (0) | 2026.06.18 |
| 부품 유통 관리 ERP 개발기 (1) — 6개 모듈 프로토타입과 파일 하나로 끝낸 배포 (0) | 2026.06.15 |
| 수출 영업 메일 자동화 개발기 (1) — AI 분류·초안과 '사람이 승인하는' 발송 파이프라인 (0) | 2026.06.15 |
| 데이터 보호 시스템 개발기 (1) — 블록체인 구조를 빌린 DB 암호화와 키 관리 (0) | 2026.06.15 |