# 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 추가 검토 (스코프 외) |
