From 0106f9c5971b210c0411f3086afa599250d6acd0 Mon Sep 17 00:00:00 2001 From: gahusb Date: Mon, 27 Apr 2026 08:08:03 +0900 Subject: [PATCH] =?UTF-8?q?docs:=20web=20=EC=97=94=EC=A7=84=20=EC=A0=84?= =?UTF-8?q?=ED=99=98=20+=20=EA=B4=91=EA=B3=A0=20=EC=88=98=EC=9D=B5?= =?UTF-8?q?=ED=99=94=20PWA=201=EC=B0=A8=20=EC=B6=9C=EC=8B=9C=20=EB=94=94?= =?UTF-8?q?=EC=9E=90=EC=9D=B8=20(JSA-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 토스 인앱 타겟에서 웹 PWA 단독 배포로 방향 전환. 4주 영업일 기준 1차 출시 스코프, 광고 어댑터 인터페이스, PWA + 푸시 전략, 분석· 배포 계획을 단일 디자인 문서로 정리. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../2026-04-27-web-engine-pivot-design.md | 544 ++++++++++++++++++ 1 file changed, 544 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-27-web-engine-pivot-design.md diff --git a/docs/superpowers/specs/2026-04-27-web-engine-pivot-design.md b/docs/superpowers/specs/2026-04-27-web-engine-pivot-design.md new file mode 100644 index 0000000..52e4ae7 --- /dev/null +++ b/docs/superpowers/specs/2026-04-27-web-engine-pivot-design.md @@ -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는 `
` + 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` | `` 하단 고정 배치 | +| 그 외 컴포넌트 | 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; + isReady(): boolean; + showBanner(slotId: AdSlotId, container: HTMLElement): Promise; + showInterstitial(slotId: AdSlotId): Promise; + showRewarded(slotId: AdSlotId): Promise; +} +``` + +### 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(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 +