docs: web 엔진 전환 + 광고 수익화 PWA 1차 출시 디자인 (JSA-2)
토스 인앱 타겟에서 웹 PWA 단독 배포로 방향 전환. 4주 영업일 기준 1차 출시 스코프, 광고 어댑터 인터페이스, PWA + 푸시 전략, 분석· 배포 계획을 단일 디자인 문서로 정리. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
544
docs/superpowers/specs/2026-04-27-web-engine-pivot-design.md
Normal file
544
docs/superpowers/specs/2026-04-27-web-engine-pivot-design.md
Normal file
@@ -0,0 +1,544 @@
|
||||
# Web 엔진 전환 + 광고 수익화 PWA 1차 출시 — 디자인
|
||||
|
||||
- **작성일**: 2026-04-27
|
||||
- **타겟 출시일**: 2026-05-25 (4주, 영업일 기준)
|
||||
- **상태**: Draft, 사용자 리뷰 대기
|
||||
|
||||
---
|
||||
|
||||
## 1. 목표·비목표·성공 지표
|
||||
|
||||
### 1.1 배경
|
||||
|
||||
`Archetype-FirstSpark`는 본래 앱인토스(Toss 인앱) 타겟으로 시작되었으나, 현실적인 1차 출시·수익화 검증을 위해 **웹 PWA 단독 배포**로 방향을 전환한다.
|
||||
|
||||
게임 코드는 이미 95% 웹 친화적이다. `useGameStore`는 zustand + localStorage 위에서 동작하고, UI는 `<div>` + emotion css이며, 합성·강화·방치·프레스티지·업적·튜토리얼·오프라인보상이 전부 구현되어 동작한다. Toss 종속성은 빌드 도구(Granite), UI 키트(TDS), Analytics SDK, `AppsInToss.registerApp` 진입점 4곳에 한정된다. 따라서 본 작업의 본질은 **신규 게임 기능 개발이 아니라 어댑터·인프라 교체**다.
|
||||
|
||||
### 1.2 v1에 포함
|
||||
|
||||
1. **엔진 교체**: Granite/Apps-in-Toss/TDS 제거 → Vite + React 단독 빌드. 게임 로직(`useGameStore`, JSON 데이터, 컴포넌트)은 거의 그대로 유지.
|
||||
2. **PWA**: 설치 가능 + 오프라인 셸 캐싱 + 푸시 알림(Periodic Background Sync 기반).
|
||||
3. **광고 어댑터 (티어2)**: vendor 독립 인터페이스 + `shop`의 부스트와 결합된 보상광고 + 프레스티지 전면광고 + 하단 고정 배너. v1 기본은 `DummyAdapter`로 UI 흐름만 완성.
|
||||
4. **분석**: GA4 어댑터. 기존 `trackGameEvent` 어댑터를 통해 재배선.
|
||||
5. **도메인-독립 빌드**: 모든 절대 URL 환경변수화. NAS / Cloudflare Pages / Vercel 어디든 같은 결과물 그대로 배포 가능.
|
||||
|
||||
### 1.3 v1 비목표 (의도적으로 미루는 것)
|
||||
|
||||
- 영어 번역 (i18n 토글 store는 유지하되 번역 파일은 v2)
|
||||
- 실 광고 SDK 통합 — 도메인 확정 후 AdSense·Pangle 신청 → 단위 발급 후 어댑터 교체 (v1.x)
|
||||
- TWA / Play 스토어 등록
|
||||
- 클라우드 저장·계정 시스템 (모든 진행도는 localStorage)
|
||||
- 신규 게임 컨텐츠 (현재 티어 그대로)
|
||||
- 리더보드·소셜·시즌
|
||||
- 자가호스팅 분석 (NAS 노출 제약 때문에 v1은 외부 분석)
|
||||
- Web Push protocol 기반 서버 푸시 (Periodic Background Sync로 v1 커버)
|
||||
|
||||
### 1.4 성공 지표
|
||||
|
||||
| 단계 | 지표 |
|
||||
|------|------|
|
||||
| 1차 (출시 후 4주) | 누적 고유 방문 500, D1 retention 30%, PWA 설치 30건 |
|
||||
| 2차 (도메인·광고 SDK 확정 후) | 광고 첫 수익 발생, 주간 광고 수익 측정 시작 — 절대값 KPI 없음, 데이터 수집 자체가 목적 |
|
||||
|
||||
v1은 본질적으로 "수익 내는 단계"가 아니라 "수익 낼 자격이 있는 시스템을 구축하는 단계"이므로, 광고 SDK 미확정인 상태에서 매출 KPI는 무의미하다.
|
||||
|
||||
### 1.5 핵심 제약
|
||||
|
||||
- **NAS 도메인 외부 노출 금지** — 모든 배포 경로·분석 엔드포인트는 NAS IP를 노출하지 않아야 한다. NAS는 스테이징 보조 환경으로만 사용.
|
||||
- **도메인 미확정 상태에서 출시 가능해야 한다** — 도메인은 v1 완성 직전 단계로 미룸. 코드는 도메인-독립적으로 작성.
|
||||
|
||||
---
|
||||
|
||||
## 2. 시스템 아키텍처와 모듈 경계
|
||||
|
||||
### 2.1 핵심 설계 원칙
|
||||
|
||||
**모든 외부 종속성은 `src/platform/`을 통해서만 접근**한다. 게임 로직(스토어·컴포넌트)은 vendor 이름을 절대 알지 못한다.
|
||||
|
||||
이게 현재 코드가 Toss와 강하게 결합되어 마이그레이션을 어렵게 만든 근본 원인이고, 같은 실수를 반복하지 않기 위한 단일 규칙이다. AdSense → Pangle 등 SDK 교체가 어댑터 파일 한 개 교체로 끝나야 한다.
|
||||
|
||||
### 2.2 모듈 맵
|
||||
|
||||
```
|
||||
Archetype-FirstSpark/
|
||||
├── index.html (NEW: Vite 진입, PWA 메타태그)
|
||||
├── vite.config.ts (NEW)
|
||||
├── public/
|
||||
│ ├── manifest.webmanifest (NEW: PWA 매니페스트)
|
||||
│ └── icons/ (NEW: 192·512·maskable PWA 아이콘)
|
||||
└── src/
|
||||
├── main.tsx (NEW: ReactDOM root, was AppsInToss.registerApp)
|
||||
├── App.tsx (MOVED: ex pages/index.tsx, Provider만 변경)
|
||||
│
|
||||
├── config/
|
||||
│ └── env.ts (NEW: VITE_PUBLIC_URL 등 환경변수 단일 진입점)
|
||||
│
|
||||
├── platform/ (NEW 디렉토리 — 모든 vendor 격리)
|
||||
│ ├── ads/
|
||||
│ │ ├── types.ts ← AdAdapter 인터페이스
|
||||
│ │ ├── adapter.ts ← 활성 어댑터 export (env 기반 선택)
|
||||
│ │ ├── dummyAdapter.ts ← v1 기본 (UI 흐름만, 실 노출 없음)
|
||||
│ │ └── adsenseAdapter.ts ← v1.x 예정 (도메인·승인 후 활성)
|
||||
│ ├── push/
|
||||
│ │ ├── types.ts ← PushAdapter 인터페이스
|
||||
│ │ └── webPushAdapter.ts ← Notification API + ServiceWorker + PeriodicBackgroundSync
|
||||
│ ├── analytics/
|
||||
│ │ ├── types.ts ← AnalyticsAdapter 인터페이스
|
||||
│ │ ├── ga4Adapter.ts ← v1 기본
|
||||
│ │ └── consoleAdapter.ts ← 개발용
|
||||
│ └── pwa/
|
||||
│ ├── installPrompt.tsx ← beforeinstallprompt 캡처 + 설치 유도 UI
|
||||
│ └── registerSW.ts ← vite-plugin-pwa 진입점
|
||||
│
|
||||
├── store/
|
||||
│ └── useGameStore.ts ← 거의 그대로 유지
|
||||
│
|
||||
├── data/ ← 변경 없음 (recipes, elements, achievements, prestige)
|
||||
├── hooks/ ← 변경 없음 + useRewardedBoost.ts 추가
|
||||
└── components/ ← Provider/SDK import 제거 외 변경 없음
|
||||
+ ShopScreen에 광고 버튼, BannerSlot 추가
|
||||
```
|
||||
|
||||
### 2.3 데이터 흐름 (변경되는 두 흐름)
|
||||
|
||||
**광고 시청 → 부스트 활성화** (보상광고 결합):
|
||||
|
||||
```
|
||||
[ShopScreen "광고 보고 강화석 부스트"]
|
||||
→ useRewardedBoost.watch()
|
||||
→ ads.showRewarded(slotId)
|
||||
→ (success) → useGameStore.activateBoost(boostId, durSec)
|
||||
→ analytics.track('rewarded_ad_completed', { slot: slotId })
|
||||
```
|
||||
|
||||
**오프라인 보상 가득 → 푸시 알림**:
|
||||
|
||||
```
|
||||
[탭 비활성 / visibilitychange to hidden]
|
||||
→ predictedFullAt = lastTickAt + MAX_OFFLINE_SECONDS * 1000
|
||||
→ IndexedDB.set('nextNotifyAt', predictedFullAt)
|
||||
→ registerPeriodicSync('check-rewards', { minInterval: 12h })
|
||||
|
||||
[Chrome/Edge가 SW를 깨움 (수시간 후)]
|
||||
→ SW: periodicSync 이벤트
|
||||
→ IndexedDB.get('nextNotifyAt') < Date.now() ?
|
||||
→ self.registration.showNotification('보상이 가득 찼어요!', ...)
|
||||
→ IndexedDB.delete('nextNotifyAt')
|
||||
→ analytics.track('push_fired')
|
||||
|
||||
[유저가 알림 클릭]
|
||||
→ SW: notificationclick → clients.openWindow('./')
|
||||
→ 게임 진입 → initOffline() (기존 로직)
|
||||
→ analytics.track('push_clicked')
|
||||
```
|
||||
|
||||
### 2.4 컴포넌트 변경 매트릭스
|
||||
|
||||
| 파일 | 변경 |
|
||||
|------|------|
|
||||
| `src/_app.tsx` | 삭제. `TDSMobileAITProvider`/`AppsInToss.registerApp` 제거 |
|
||||
| `pages/index.tsx` | `src/App.tsx`로 이동, import 경로 수정 |
|
||||
| `src/store/useGameStore.ts` | 변경 최소화 — `window.dispatchEvent('newAchievement', ...)`는 그대로 유지하고 토스트 컴포넌트가 직접 listen (변경 없음) |
|
||||
| `src/analytics.ts` | `src/platform/analytics/`로 이동, `@apps-in-toss/analytics` 제거, 어댑터 인터페이스로 재설계 |
|
||||
| `src/components/screens/ShopScreen.tsx` | 각 부스트 카드에 "광고 보고 받기" 버튼 추가 (티어2) |
|
||||
| `src/components/PrestigeModal.tsx` | 프레스티지 성공 후 interstitial 호출 (2회차부터) |
|
||||
| `src/App.tsx` | `<BannerSlot>` 하단 고정 배치 |
|
||||
| 그 외 컴포넌트 | TDS import만 plain HTML/emotion으로 대체 |
|
||||
|
||||
### 2.5 의존성 변경
|
||||
|
||||
**제거**:
|
||||
- `@apps-in-toss/analytics`, `@apps-in-toss/web-framework`, `@granite-js/react-native`
|
||||
- `@toss/tds-mobile`, `@toss/tds-mobile-ait`, `@toss/tds-react-native`, `@toss/tds-colors`
|
||||
- `react-native` 및 RN 관련 deps
|
||||
|
||||
**추가**:
|
||||
- `vite`, `@vitejs/plugin-react`, `vite-plugin-pwa`, `workbox-window`
|
||||
|
||||
**유지**:
|
||||
- `react`, `react-dom`, `@emotion/react`, `zustand`
|
||||
|
||||
---
|
||||
|
||||
## 3. 광고 어댑터 인터페이스
|
||||
|
||||
이 섹션은 v1의 유일한 신규 설계 표면이다.
|
||||
|
||||
### 3.1 인터페이스
|
||||
|
||||
```ts
|
||||
// src/platform/ads/types.ts
|
||||
export type AdSlotId = string; // 'bottom_banner' | 'prestige_interstitial' | 'shop_boost_fire' ...
|
||||
|
||||
export interface RewardedAdResult {
|
||||
completed: boolean;
|
||||
reason?: 'closed' | 'error' | 'no_fill';
|
||||
}
|
||||
|
||||
export interface BannerHandle {
|
||||
destroy(): void;
|
||||
}
|
||||
|
||||
export interface AdAdapter {
|
||||
init(): Promise<void>;
|
||||
isReady(): boolean;
|
||||
showBanner(slotId: AdSlotId, container: HTMLElement): Promise<BannerHandle>;
|
||||
showInterstitial(slotId: AdSlotId): Promise<void>;
|
||||
showRewarded(slotId: AdSlotId): Promise<RewardedAdResult>;
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 v1 기본 어댑터: `DummyAdapter`
|
||||
|
||||
도메인·SDK 미확정 상태에서 출시 가능하게 하는 핵심 장치.
|
||||
|
||||
- `showBanner`: 컨테이너에 "광고 영역" 플레이스홀더 div 렌더 (실 노출 0)
|
||||
- `showInterstitial`: 자체 모달 + 3초 타이머 (스킵 가능). UX 흐름 검증용
|
||||
- `showRewarded`: 자체 모달 + 5초 카운트다운 + 닫기 버튼. 카운트 끝까지 보면 `completed: true` 반환
|
||||
|
||||
이 어댑터의 가치: SDK 승인을 기다리지 않고 v1 출시 가능, 출시 직후 유저 트래픽으로 보상광고 결합 UX의 적정값(쿨다운·노출빈도·완료율)을 측정.
|
||||
|
||||
### 3.3 React 통합
|
||||
|
||||
```tsx
|
||||
// src/platform/ads/AdProvider.tsx
|
||||
const AdContext = createContext<AdAdapter>(null!);
|
||||
export const useAds = () => useContext(AdContext);
|
||||
```
|
||||
|
||||
승인 후 `dummyAdapter` 한 줄을 `adsenseAdapter` 또는 `pangleAdapter`로 교체. 게임 코드 변경 0.
|
||||
|
||||
### 3.4 게임 경제 결합점 (티어2)
|
||||
|
||||
#### 결합점 1 — 보상광고 ⇄ 부스트 (`ShopScreen`)
|
||||
|
||||
각 부스트 상품 카드에 두 가지 구매 경로:
|
||||
|
||||
- 기존: 골드 N 소비 → `activateBoost(id, dur)`
|
||||
- 신규: 광고 시청 → `activateBoost(id, dur)`
|
||||
|
||||
```ts
|
||||
// src/hooks/useRewardedBoost.ts (신규)
|
||||
export function useRewardedBoost(slotId: string, boostId: string, durSec: number) {
|
||||
const ads = useAds();
|
||||
const activateBoost = useGameStore(s => s.activateBoost);
|
||||
const [cooldown, setCooldown] = useState(0);
|
||||
|
||||
const watch = async () => {
|
||||
if (cooldown > 0) return { completed: false, reason: 'rate_limited' as const };
|
||||
const res = await ads.showRewarded(slotId);
|
||||
if (res.completed) {
|
||||
activateBoost(boostId, durSec);
|
||||
track('rewarded_ad_completed', { slot: slotId });
|
||||
setCooldown(REWARDED_COOLDOWN_MS); // 슬롯당 60분 (게임 정책)
|
||||
} else {
|
||||
track('rewarded_ad_dropped', { slot: slotId, reason: res.reason });
|
||||
}
|
||||
return res;
|
||||
};
|
||||
|
||||
return { watch, cooldown };
|
||||
}
|
||||
```
|
||||
|
||||
쿨다운 같은 게임 경제 룰은 어댑터에 두지 않고 훅 레이어에 둔다. 광고 SDK가 바뀌어도 게임 경제는 그대로여야 하기 때문.
|
||||
|
||||
#### 결합점 2 — 프레스티지 전면광고 (`PrestigeModal`)
|
||||
|
||||
```ts
|
||||
if (newPrestigeCount > 1 && ads.isReady()) {
|
||||
await ads.showInterstitial('prestige_interstitial');
|
||||
}
|
||||
```
|
||||
|
||||
첫 프레스티지에는 광고 없음 — 튜토리얼·신뢰 형성 구간 보호.
|
||||
|
||||
#### 결합점 3 — 하단 고정 배너 (`App.tsx` 루트)
|
||||
|
||||
```tsx
|
||||
<BannerSlot
|
||||
slotId="bottom_banner"
|
||||
hidden={!tutorialCompleted || sessionAgeSec < 300}
|
||||
/>
|
||||
```
|
||||
|
||||
조건부 숨김 (`useGameStore`의 `tutorialCompleted` 사용 — 기존 store 필드):
|
||||
- `tutorialCompleted === false`: hidden
|
||||
- `sessionAgeSec < 300` (첫 세션 5분): hidden — 첫 인상 보호
|
||||
- BottomTabBar 위, 50px 높이 고정
|
||||
|
||||
### 3.5 광고 정책 (명문화)
|
||||
|
||||
| 슬롯 | 빈도 제한 | 첫 노출 시점 |
|
||||
|------|----------|--------------|
|
||||
| `bottom_banner` | 항시 (조건부 숨김) | 튜토리얼 완료 + 세션 5분 후 |
|
||||
| `prestige_interstitial` | 프레스티지 1회당 1회 | 2회차 프레스티지부터 |
|
||||
| 보상광고 (슬롯별) | 슬롯당 60분 쿨다운 (`REWARDED_COOLDOWN_MS = 3_600_000`) | 튜토리얼 완료 후 |
|
||||
|
||||
### 3.6 페일세이프
|
||||
|
||||
- `isReady() === false` → 모든 광고 슬롯 자동 숨김. 게임 진행 정상.
|
||||
- `showRewarded()` 거부/실패 → 보상 미지급, "광고를 불러오지 못했습니다" 토스트 + 1분 후 재시도 가능.
|
||||
- 광고 실패가 게임 진행을 절대 블록하지 않는다.
|
||||
|
||||
---
|
||||
|
||||
## 4. PWA + 푸시 알림
|
||||
|
||||
### 4.1 Manifest
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Archetype: First Spark",
|
||||
"short_name": "FirstSpark",
|
||||
"start_url": "./?source=pwa",
|
||||
"scope": "./",
|
||||
"display": "standalone",
|
||||
"orientation": "portrait",
|
||||
"background_color": "#f7f8fa",
|
||||
"theme_color": "#FF6B35",
|
||||
"icons": [
|
||||
{ "src": "./icons/192.png", "sizes": "192x192", "type": "image/png" },
|
||||
{ "src": "./icons/512.png", "sizes": "512x512", "type": "image/png" },
|
||||
{ "src": "./icons/512-maskable.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`start_url`이 `.` 기준이라 어느 도메인에 배포해도 그대로 동작.
|
||||
|
||||
### 4.2 Service Worker 캐싱 전략
|
||||
|
||||
`vite-plugin-pwa` + Workbox 조합.
|
||||
|
||||
| 자산 | 전략 | 비고 |
|
||||
|------|------|------|
|
||||
| HTML, JS 번들, CSS | NetworkFirst (5초 timeout → cache fallback) | 신규 빌드 빠르게 받기 + 오프라인 동작 |
|
||||
| 아이콘·이미지 (해시된 파일명) | CacheFirst, 30일 만료 | Vite가 해시를 박으므로 안전 |
|
||||
| 폰트 (Pretendard CDN, 사용 시) | CacheFirst, 1년 만료 | |
|
||||
| `data/*.json` | 번들에 포함 (현재 import 방식) | 별도 캐싱 불필요 |
|
||||
| 외부 분석 스크립트 (gtag) | NetworkOnly | 캐시 안 함 |
|
||||
|
||||
**SW 업데이트**: 새 빌드 감지 → "새 버전이 준비되었어요. 새로고침" 토스트. 즉시 강제 reload는 데이터 손실 위험 있어 항상 사용자 액션으로.
|
||||
|
||||
### 4.3 설치 유도
|
||||
|
||||
| 플랫폼 | 동작 |
|
||||
|--------|------|
|
||||
| Android Chrome / Edge | `beforeinstallprompt` 캡처 → 첫 프레스티지 직후 "홈화면에 추가" CTA |
|
||||
| iOS Safari (16.4+) | `beforeinstallprompt` 미발화. 설정 메뉴에 "홈화면에 추가하는 법" 안내만, 자동 팝업 없음 |
|
||||
| Desktop | `beforeinstallprompt` 사용. CTA 표시하되 우선순위 낮음 |
|
||||
|
||||
CTA 시점이 첫 프레스티지 직후인 이유: "내가 이 게임에 시간을 투자했고 진행도가 가치 있다"는 인식이 만들어지는 순간 — 설치 전환율이 가장 높은 자연 타이밍.
|
||||
|
||||
### 4.4 푸시 알림 — 웹의 현실
|
||||
|
||||
**제약 명시**:
|
||||
- 브라우저는 순수 클라이언트만으로 미래 시각의 알림을 예약할 수 없다. `setTimeout`은 탭이 닫히면 죽는다.
|
||||
- 진짜 푸시는 두 가지 길:
|
||||
- **(A) Periodic Background Sync**: SW가 OS 판단으로 주기적 깨어남. 설치된 PWA + Chrome/Edge + Android 한정. iOS 미지원.
|
||||
- **(B) Web Push protocol**: VAPID 키 + 푸시 서버 + SW. 모든 모던 브라우저(iOS 16.4+ 포함). 푸시 서버 필요.
|
||||
|
||||
**v1 결정: (A)만 사용. (B)는 v2.**
|
||||
|
||||
이유:
|
||||
- (B)는 백엔드 인프라 추가 필요 (Cloudflare Worker / NAS 컨테이너)
|
||||
- v1 원칙: 게임 자체에 집중하고 추가 인프라 늘리지 않는다
|
||||
- (A)만으로도 안드로이드 + 데스크톱 사용자에게는 동작 — 한국 시장 안드로이드 비중 고려 시 v1 가치 충분
|
||||
- iOS 사용자에게 푸시 안 옴 — 정직하게 인정. v2에서 (B) 추가 시 모든 플랫폼 커버
|
||||
|
||||
### 4.5 PushAdapter
|
||||
|
||||
```ts
|
||||
export interface PushAdapter {
|
||||
isSupported(): boolean;
|
||||
hasPermission(): boolean;
|
||||
requestPermission(): Promise<boolean>;
|
||||
scheduleOfflineRewardReady(predictedAt: number): Promise<void>;
|
||||
cancelOfflineRewardReady(): Promise<void>;
|
||||
}
|
||||
```
|
||||
|
||||
### 4.6 권한 요청 타이밍
|
||||
|
||||
권한 다이얼로그는 한 번 거부당하면 회복 어려움.
|
||||
|
||||
규칙:
|
||||
- 첫 방문: 자동 요청 안 함
|
||||
- **첫 오프라인 보상 클레임 직후** (방치형 가치 체감 순간) → in-page CTA "다음에 보상이 가득 차면 알려드릴까요?" → Yes → 권한 요청
|
||||
- 거부 또는 dismiss: 다음 오프라인 보상 클레임에서 1회 더 시도 → 그 후로는 설정 메뉴에서만 활성화
|
||||
|
||||
### 4.7 한계 인정
|
||||
|
||||
Periodic Background Sync는:
|
||||
- 발화 시각이 OS 판단 (정확히 24h 아님)
|
||||
- 유저가 PWA 미설치면 발화 안 됨
|
||||
- iOS 미지원
|
||||
|
||||
→ 푸시는 "PWA를 설치한 진성 유저 retention 보너스"로 위치 짓고, 설치 안 한 유저는 OG 공유·북마크에 의존.
|
||||
|
||||
---
|
||||
|
||||
## 5. 분석·빌드·배포
|
||||
|
||||
### 5.1 분석 도구
|
||||
|
||||
NAS 도메인 노출 금지 제약 때문에 자가호스팅 분석은 v2로 미루고, v1은 **GA4 + 쿠키 동의 간이 배너**.
|
||||
|
||||
어댑터 인터페이스 뒤에 두므로 v2에서 자가호스팅 Plausible로 교체 시 1줄 수정.
|
||||
|
||||
### 5.2 이벤트 taxonomy (v1)
|
||||
|
||||
```ts
|
||||
type GameEvent =
|
||||
// 라이프사이클
|
||||
| { name: 'app_open'; params: { source?: 'pwa' | 'web' } }
|
||||
| { name: 'session_start' }
|
||||
|
||||
// PWA / 푸시
|
||||
| { name: 'pwa_install_prompt_shown' }
|
||||
| { name: 'pwa_install_accepted' }
|
||||
| { name: 'pwa_install_dismissed' }
|
||||
| { name: 'push_permission_requested' }
|
||||
| { name: 'push_permission_result'; params: { granted: boolean } }
|
||||
| { name: 'push_scheduled'; params: { delay_hours: number } }
|
||||
| { name: 'push_fired' }
|
||||
| { name: 'push_clicked' }
|
||||
|
||||
// 온보딩
|
||||
| { name: 'tutorial_step_completed'; params: { step: number } }
|
||||
| { name: 'tutorial_finished' }
|
||||
| { name: 'tutorial_skipped'; params: { at_step: number } }
|
||||
|
||||
// 코어 게임플레이
|
||||
| { name: 'fuse_success'; params: { recipe_id: string; tier: number } }
|
||||
| { name: 'enhance_success'; params: { element_id: string; new_level: number } }
|
||||
| { name: 'tier_unlocked'; params: { tier: number } }
|
||||
| { name: 'prestige_completed'; params: { prestige_count: number } }
|
||||
| { name: 'offline_reward_claimed'; params: { offline_sec: number; gold: number } }
|
||||
| { name: 'achievement_unlocked'; params: { achievement_id: string } }
|
||||
|
||||
// 광고 (수익 진단의 근거)
|
||||
| { name: 'ad_banner_visible'; params: { slot: string } }
|
||||
| { name: 'ad_interstitial_shown'; params: { slot: string } }
|
||||
| { name: 'ad_interstitial_completed'; params: { slot: string } }
|
||||
| { name: 'rewarded_ad_started'; params: { slot: string } }
|
||||
| { name: 'rewarded_ad_completed'; params: { slot: string } }
|
||||
| { name: 'rewarded_ad_dropped'; params: { slot: string; reason: string } }
|
||||
|
||||
// 에러
|
||||
| { name: 'error'; params: { type: string; message: string } };
|
||||
```
|
||||
|
||||
모든 이벤트에 자동 첨부:
|
||||
- `app_version` (package.json 자동 주입)
|
||||
- `platform` (web | pwa | desktop 자동 감지)
|
||||
- `prestige_count` (현재 진행도)
|
||||
|
||||
### 5.3 Vite 셋업
|
||||
|
||||
```ts
|
||||
// vite.config.ts (요지)
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
react({ jsxImportSource: '@emotion/react' }),
|
||||
VitePWA({
|
||||
registerType: 'prompt',
|
||||
manifest: { /* 4.1 */ },
|
||||
workbox: { /* 4.2 */ },
|
||||
}),
|
||||
],
|
||||
base: './',
|
||||
build: {
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks: {
|
||||
'react-vendor': ['react', 'react-dom'],
|
||||
'state': ['zustand'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
define: {
|
||||
__APP_VERSION__: JSON.stringify(process.env.npm_package_version),
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### 5.4 환경변수
|
||||
|
||||
```bash
|
||||
# .env.production
|
||||
VITE_PUBLIC_URL=https://__TBD_DOMAIN__/
|
||||
VITE_GA_MEASUREMENT_ID=G-XXXXXXXXXX
|
||||
VITE_AD_ADAPTER=dummy
|
||||
VITE_ENABLE_PUSH=true
|
||||
```
|
||||
|
||||
도메인 미확정 시 `VITE_PUBLIC_URL` placeholder. 사용처는 OG 메타태그 한 곳뿐 — 도메인 확정 즉시 1라인 변경.
|
||||
|
||||
### 5.5 배포 타겟 (3종 호환)
|
||||
|
||||
같은 `dist/` 결과물이 어디든 동작.
|
||||
|
||||
| 타겟 | 절차 | NAS 노출 |
|
||||
|------|------|----------|
|
||||
| NAS nginx | `robocopy dist Z:\docker\webpage\frontend-game /MIR` + nginx 서버블록 | 직접 노출 |
|
||||
| **Cloudflare Pages** (추천) | `wrangler pages deploy dist` (무료, 무제한 대역폭, 커스텀 도메인) | 0 |
|
||||
| Vercel | `vercel deploy --prod` (정적 사이트 무료) | 0 |
|
||||
|
||||
**v1 prod 타겟: Cloudflare Pages**. NAS는 스테이징 보조 환경.
|
||||
|
||||
### 5.6 4주 타임라인 (영업일)
|
||||
|
||||
#### Week 1 — 엔진 교체
|
||||
|
||||
- D1~2: Vite 셋업, Granite·Toss SDK 제거, dev server 부팅
|
||||
- D3: TDS Provider 제거, 컴포넌트 import 정리, 빌드 그린
|
||||
- D4: `_app.tsx`/`pages/index.tsx` → `main.tsx`/`App.tsx` 이전, 기능 회귀 테스트 (모든 화면)
|
||||
- D5: `env.ts`, 도메인-독립 빌드 셋업, vite.config 마무리
|
||||
|
||||
#### Week 2 — PWA + 푸시
|
||||
|
||||
- D1~2: `vite-plugin-pwa` 통합, manifest 검증, 아이콘 192/512/maskable 생성
|
||||
- D3: SW 캐싱 전략 구현, "새 버전 준비됨" 토스트
|
||||
- D4~5: `PushAdapter` 구현, 첫 오프라인보상 후 권한 요청 UX, periodicSync + SW notificationclick 핸들러
|
||||
|
||||
#### Week 3 — 광고 어댑터 + 결합
|
||||
|
||||
- D1: `AdAdapter` 인터페이스, `DummyAdapter` 구현
|
||||
- D2: `AdProvider`, `useAds`, `useRewardedBoost` 훅
|
||||
- D3: `ShopScreen` 보상광고 버튼, 슬롯별 쿨다운 UI
|
||||
- D4: `PrestigeModal` interstitial, `BannerSlot` 컴포넌트, 튜토리얼/첫 5분 숨김
|
||||
- D5: 광고 이벤트 추적, 어댑터 페일세이프 검증
|
||||
|
||||
#### Week 4 — 분석 + QA + 출시
|
||||
|
||||
- D1: `AnalyticsAdapter` 구현, GA4 통합, 기존 `trackGameEvent` 재배선, 쿠키 동의 배너
|
||||
- D2: 모든 이벤트 발화 지점 점검, taxonomy 검증, 디버그 모드 콘솔 검수
|
||||
- D3~4: 크로스브라우징 (Chrome Android · iOS Safari 16+ · Edge · Desktop Chrome)
|
||||
- D5: 버그픽스, OG 메타태그·소셜 카드 이미지, 첫 Cloudflare Pages 배포 (스테이징 도메인) — 도메인 확정 후 prod 도메인 swap 1회만
|
||||
|
||||
### 5.7 Definition of Done
|
||||
|
||||
- [ ] 모든 게임 기능이 Vite 빌드에서 회귀 없이 동작
|
||||
- [ ] PWA 설치 가능, manifest·아이콘 검증 (Lighthouse PWA 점수 ≥ 90)
|
||||
- [ ] 안드로이드 Chrome에서 푸시 권한 요청 → 알림 발화 1회 이상 수동 검증
|
||||
- [ ] DummyAdapter로 보상광고·전면광고·배너 UX 흐름 완주 가능
|
||||
- [ ] GA4에 정의된 모든 이벤트가 실시간 디버그뷰에서 관측됨
|
||||
- [ ] 도메인 미확정 상태에서도 스테이징 URL로 배포 가능
|
||||
- [ ] Lighthouse 모바일 성능 ≥ 80, 접근성 ≥ 90
|
||||
- [ ] 첫 5분 게임 플레이에서 광고·푸시 권한·설치 프롬프트 모두 미발생 (첫 인상 보호)
|
||||
|
||||
---
|
||||
|
||||
## 6. 위험 요소 및 완화
|
||||
|
||||
| 위험 | 영향 | 완화 |
|
||||
|------|------|------|
|
||||
| AdSense 사이트 승인 거절 | 광고 수익 0 | DummyAdapter로 출시 가능, Pangle/CrazyGames 등 대체 SDK 어댑터 추가 |
|
||||
| iOS Safari 푸시 미동작 | iOS 유저 retention↓ | 정직하게 인정. v2에서 Web Push protocol 도입 |
|
||||
| Periodic Background Sync 발화 빈도 OS 의존 | 푸시 누락 가능 | "PWA 설치 유저 보너스"로 포지셔닝, 미설치 유저는 북마크/공유 의존 |
|
||||
| 도메인 확정 지연 | prod 출시 지연 | 스테이징 도메인으로 베타 시작, 도메인 확정 후 swap |
|
||||
| TDS Provider 제거 시 컴포넌트 스타일 깨짐 | UI 회귀 | Week 1 D3에 집중 검수, 깨지는 부분은 emotion으로 직접 스타일 |
|
||||
| localStorage 단독 의존 (계정 없음) | 기기 변경 시 진행도 손실 | v1에서 명시적으로 인정. Settings에 "데이터 백업" JSON export/import 추가 검토 (스코프 외) |
|
||||
Reference in New Issue
Block a user