# React FCM 푸시 알림 연동 계획

> **작성일**: 2025-12-30
> **목적**: Capacitor 앱 웹뷰가 dev.sam.kr (Next.js)을 로드할 때 FCM 푸시 알림 지원
> **기준 문서**: mng/public/js/fcm.js (포팅 대상), api/app/Swagger/v1/PushApi.php
> **상태**: ✅ 구현 완료 (Serena ID: react-fcm-state)

---

## 📍 현재 진행 상태

| 항목 | 내용 |
|------|------|
| **마지막 완료 작업** | Phase 4: 통합 완료 |
| **다음 작업** | 테스트 (Capacitor 앱에서 확인) |
| **진행률** | 4/4 (100%) ✅ |
| **마지막 업데이트** | 2025-12-30 |

---

## 1. 개요

### 1.1 현재 구조

```
Capacitor 앱 (웹뷰)
       │
       ▼
     mng (현재)
       │
       ├── fcm.js 로드
       │   ├── Capacitor PushNotifications 사용
       │   ├── 토큰 발급
       │   └── api에 토큰 등록
       │
       ▼
     api
       │
       └── /push/register-token
```

### 1.2 목표 구조

```
Capacitor 앱 (웹뷰)
       │
       ▼
  dev.sam.kr (react) ← 변경
       │
       ├── FCM 훅/유틸리티 (포팅)
       │   ├── Capacitor PushNotifications 사용 (동일)
       │   ├── 토큰 발급 (동일)
       │   └── api에 토큰 등록 (동일)
       │
       ▼
     api (변경 없음)
       │
       └── /push/register-token
```

### 1.3 핵심 포인트

```
┌─────────────────────────────────────────────────────────────────┐
│  🎯 핵심: mng/public/js/fcm.js를 react에 포팅                     │
├─────────────────────────────────────────────────────────────────┤
│  1. Capacitor PushNotifications 플러그인 사용 (동일)              │
│  2. 토큰 발급 → api 등록 로직 (동일)                              │
│  3. 포그라운드 알림 → sonner 토스트로 변경                        │
│  4. 백엔드 API 변경 없음                                          │
└─────────────────────────────────────────────────────────────────┘
```

### 1.4 변경 승인 정책

| 분류 | 예시 | 승인 |
|------|------|------|
| ✅ 즉시 가능 | Capacitor 플러그인 설치, 훅 생성, 유틸리티 추가 | 불필요 |
| ⚠️ 컨펌 필요 | 포그라운드 알림 UX (토스트 디자인) | **필수** |
| 🔴 금지 | API 엔드포인트 변경, DB 스키마 변경 | 별도 협의 |

---

## 2. 대상 범위

### 2.1 Phase 1: Capacitor 플러그인 설치 ✅

| # | 작업 항목 | 상태 | 비고 |
|---|----------|:----:|------|
| 1.1 | @capacitor/push-notifications 설치 | ✅ | ^8.0.0 |
| 1.2 | @capacitor/app 설치 | ✅ | ^8.0.0 |
| 1.3 | @capacitor/core 설치 | ✅ | ^8.0.0 |

### 2.2 Phase 2: FCM 유틸리티 포팅 ✅

| # | 작업 항목 | 상태 | 비고 |
|---|----------|:----:|------|
| 2.1 | lib/capacitor/fcm.ts 생성 | ✅ | 9.1KB |
| 2.2 | useFCM 훅 생성 | ✅ | 3.3KB |
| 2.3 | FCM Provider 생성 | ✅ | contexts/FCMProvider.tsx |

### 2.3 Phase 3: 포그라운드 알림 UI ✅

| # | 작업 항목 | 상태 | 비고 |
|---|----------|:----:|------|
| 3.1 | sonner 토스트 연동 | ✅ | useFCM에서 처리 |
| 3.2 | 알림 사운드 재생 | ✅ | public/sounds/ |
| 3.3 | 클릭 시 URL 이동 | ✅ | window.location.href |

### 2.4 Phase 4: 통합 ✅

| # | 작업 항목 | 상태 | 비고 |
|---|----------|:----:|------|
| 4.1 | layout.tsx에 FCMProvider 추가 | ✅ | (protected)/layout.tsx |
| 4.2 | 로그아웃 시 토큰 해제 | ✅ | logout.ts 수정 |
| 4.3 | 토큰 등록 테스트 | ⏳ | Capacitor 앱에서 확인 필요 |
| 4.4 | 포그라운드/백그라운드 알림 테스트 | ⏳ | Capacitor 앱에서 확인 필요 |

---

## 3. 기술 상세

### 3.1 기존 mng/public/js/fcm.js 분석

```javascript
// 핵심 기능 요약
1. Capacitor 네이티브 환경 체크 (ios/android)
2. PushNotifications.requestPermissions() - 권한 요청
3. PushNotifications.register() - 토큰 발급
4. registration 이벤트 → api에 토큰 등록
5. pushNotificationReceived → 포그라운드 알림 (토스트 + 사운드)
6. pushNotificationActionPerformed → 알림 클릭 시 URL 이동
```

### 3.2 FCM 유틸리티 (포팅)

```typescript
// src/lib/capacitor/fcm.ts
import { Capacitor } from '@capacitor/core';
import { PushNotifications } from '@capacitor/push-notifications';
import { App } from '@capacitor/app';

const CONFIG = {
  apiBaseUrl: process.env.NEXT_PUBLIC_API_URL || 'https://api.codebridge-x.com',
  fcmTokenKey: 'fcm_token',
  soundBasePath: '/sounds/',
  defaultSound: 'default',
};

let isAppForeground = true;

/**
 * FCM 초기화 (Capacitor 네이티브 환경에서만 동작)
 */
export async function initializeFCM(
  accessToken: string,
  onForegroundNotification?: (notification: PushNotification) => void
): Promise<boolean> {
  // 네이티브 환경 체크
  const platform = Capacitor.getPlatform();
  if (platform !== 'ios' && platform !== 'android') {
    console.log('[FCM] Not running in native app');
    return false;
  }

  if (!Capacitor.isPluginAvailable('PushNotifications')) {
    console.log('[FCM] PushNotifications plugin not available');
    return false;
  }

  try {
    // 앱 상태 리스너
    App.addListener('appStateChange', ({ isActive }) => {
      isAppForeground = isActive;
      console.log('[FCM] App state:', isActive ? 'foreground' : 'background');
    });

    // 기존 리스너 제거
    await PushNotifications.removeAllListeners();

    // 리스너 등록
    PushNotifications.addListener('registration', async (token) => {
      console.log('[FCM] Token received:', token.value?.substring(0, 20) + '...');
      await handleTokenRegistration(token.value, accessToken);
    });

    PushNotifications.addListener('registrationError', (err) => {
      console.error('[FCM] Registration error:', err);
    });

    PushNotifications.addListener('pushNotificationReceived', (notification) => {
      console.log('[FCM] Push received (foreground):', notification);
      if (onForegroundNotification) {
        onForegroundNotification(notification);
      }
      handleForegroundSound(notification);
    });

    PushNotifications.addListener('pushNotificationActionPerformed', (action) => {
      console.log('[FCM] Push action performed:', action);
      const url = action.notification?.data?.url;
      if (url) {
        window.location.href = url;
      }
    });

    // 권한 요청
    const perm = await PushNotifications.requestPermissions();
    console.log('[FCM] Push permission:', perm.receive);

    if (perm.receive !== 'granted') {
      console.log('[FCM] Push permission not granted');
      return false;
    }

    // 토큰 발급 요청
    await PushNotifications.register();
    return true;

  } catch (error) {
    console.error('[FCM] Initialization error:', error);
    return false;
  }
}

/**
 * 토큰 등록 처리
 */
async function handleTokenRegistration(newToken: string, accessToken: string): Promise<void> {
  const oldToken = sessionStorage.getItem(CONFIG.fcmTokenKey);

  if (oldToken === newToken) {
    console.log('[FCM] Token unchanged, skip');
    return;
  }

  const success = await registerTokenToServer(newToken, accessToken);

  if (success) {
    sessionStorage.setItem(CONFIG.fcmTokenKey, newToken);
    console.log('[FCM] Token saved to sessionStorage');
  }
}

/**
 * 서버에 토큰 등록
 */
async function registerTokenToServer(token: string, accessToken: string): Promise<boolean> {
  try {
    const response = await fetch(`${CONFIG.apiBaseUrl}/api/v1/push/register-token`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Accept': 'application/json',
        'Authorization': `Bearer ${accessToken}`,
      },
      body: JSON.stringify({
        token,
        platform: Capacitor.getPlatform(),
        device_name: navigator.userAgent?.substring(0, 100) || null,
        app_version: process.env.NEXT_PUBLIC_APP_VERSION || null,
      }),
    });

    if (response.ok) {
      console.log('[FCM] Token registered successfully');
      return true;
    }

    console.error('[FCM] Token registration failed:', response.status);
    return false;

  } catch (error) {
    console.error('[FCM] Failed to send token:', error);
    return false;
  }
}

/**
 * 토큰 해제 (로그아웃 시)
 */
export async function unregisterFCMToken(accessToken?: string): Promise<boolean> {
  const token = sessionStorage.getItem(CONFIG.fcmTokenKey);
  if (!token) return true;

  try {
    if (accessToken) {
      await fetch(`${CONFIG.apiBaseUrl}/api/v1/push/unregister-token`, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          'Authorization': `Bearer ${accessToken}`,
        },
        body: JSON.stringify({ token }),
      });
    }
  } catch (e) {
    console.warn('[FCM] Unregister failed');
  }

  sessionStorage.removeItem(CONFIG.fcmTokenKey);
  return true;
}

/**
 * 포그라운드 사운드 재생
 */
function handleForegroundSound(notification: any): void {
  if (!isAppForeground) return;

  const soundKey = notification.data?.sound_key;
  if (!soundKey) return;

  try {
    const audio = new Audio(`${CONFIG.soundBasePath}${soundKey}.wav`);
    audio.volume = 0.5;
    audio.play().catch(() => {
      // 기본 사운드 시도
      const defaultAudio = new Audio(`${CONFIG.soundBasePath}${CONFIG.defaultSound}.wav`);
      defaultAudio.volume = 0.5;
      defaultAudio.play().catch(() => {});
    });
  } catch (err) {
    console.warn('[FCM] Sound error:', err);
  }
}

/**
 * Capacitor 네이티브 환경인지 확인
 */
export function isCapacitorNative(): boolean {
  const platform = Capacitor.getPlatform();
  return platform === 'ios' || platform === 'android';
}

// 타입 정의
export interface PushNotification {
  title?: string;
  body?: string;
  data?: {
    type?: string;
    url?: string;
    sound_key?: string;
  };
}
```

### 3.3 useFCM 훅

```typescript
// src/hooks/useFCM.ts
'use client';

import { useEffect, useRef } from 'react';
import { useSession } from 'next-auth/react';
import { toast } from 'sonner';
import {
  initializeFCM,
  unregisterFCMToken,
  isCapacitorNative,
  PushNotification,
} from '@/lib/capacitor/fcm';

export function useFCM() {
  const { data: session } = useSession();
  const initialized = useRef(false);

  useEffect(() => {
    // 네이티브 환경이 아니면 무시
    if (!isCapacitorNative()) return;

    // 로그인 안 됐으면 무시
    if (!session?.accessToken) return;

    // 이미 초기화됐으면 무시
    if (initialized.current) return;

    initialized.current = true;

    // FCM 초기화
    initializeFCM(session.accessToken, handleForegroundNotification);

    // 클린업 (로그아웃 시)
    return () => {
      // 로그아웃 시 토큰 해제는 별도 처리
    };
  }, [session?.accessToken]);

  // 포그라운드 알림 핸들러
  function handleForegroundNotification(notification: PushNotification) {
    const { title, body, data } = notification;
    const type = data?.type || 'default';
    const url = data?.url;

    // 타입별 토스트 스타일
    const toastFn = getToastFunction(type);

    toastFn(title || '알림', {
      description: body,
      action: url ? {
        label: '보기',
        onClick: () => {
          window.location.href = url;
        },
      } : undefined,
      duration: 5000,
    });
  }

  // 타입별 토스트 함수
  function getToastFunction(type: string) {
    const errorTypes = ['invoice_failed', 'payment_failed', 'order_cancelled'];
    const warningTypes = ['approval_required', 'stock_low'];
    const successTypes = ['order_completed', 'payment_completed', 'approval_approved'];

    if (errorTypes.includes(type)) return toast.error;
    if (warningTypes.includes(type)) return toast.warning;
    if (successTypes.includes(type)) return toast.success;
    return toast.info;
  }

  // 로그아웃 시 호출
  async function cleanup(accessToken?: string) {
    await unregisterFCMToken(accessToken);
    initialized.current = false;
  }

  return { cleanup };
}
```

### 3.4 FCM Provider

```typescript
// src/providers/FCMProvider.tsx
'use client';

import { useFCM } from '@/hooks/useFCM';

export function FCMProvider({ children }: { children: React.ReactNode }) {
  // FCM 훅 실행 (초기화)
  useFCM();

  return <>{children}</>;
}
```

### 3.5 레이아웃에 Provider 추가

```typescript
// src/app/layout.tsx (또는 적절한 위치)
import { FCMProvider } from '@/providers/FCMProvider';

export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        <SessionProvider>
          <FCMProvider>
            {children}
          </FCMProvider>
        </SessionProvider>
      </body>
    </html>
  );
}
```

---

## 4. 파일 구조

```
react/
├── public/
│   └── sounds/                    ← 알림 사운드 (mng에서 복사)
│       ├── default.wav
│       └── *.wav
├── src/
│   ├── lib/
│   │   └── capacitor/
│   │       └── fcm.ts             ← 🆕 FCM 핵심 로직 (포팅)
│   ├── hooks/
│   │   └── useFCM.ts              ← 🆕 FCM 훅
│   └── providers/
│       └── FCMProvider.tsx        ← 🆕 FCM Provider
├── capacitor.config.ts            ← 확인/수정 필요
└── package.json                   ← Capacitor 플러그인 추가
```

---

## 5. 의존성

| 패키지 | 버전 | 용도 |
|--------|------|------|
| @capacitor/core | (기존) | Capacitor 코어 |
| @capacitor/push-notifications | ^6.0.0 | 푸시 알림 플러그인 |
| @capacitor/app | ^6.0.0 | 앱 상태 감지 |
| sonner | (기존) | 포그라운드 토스트 |

---

## 6. mng vs react 비교

| 항목 | mng (기존) | react (포팅) |
|------|-----------|--------------|
| **FCM 플러그인** | Capacitor PushNotifications | 동일 |
| **토큰 저장** | sessionStorage | 동일 |
| **API 호출** | fetch | 동일 |
| **포그라운드 알림** | showToast (커스텀) | sonner 토스트 |
| **사운드 재생** | Audio API | 동일 |
| **URL 이동** | window.location.href | 동일 (또는 router.push) |

---

## 7. 참고 문서

| 문서 | 용도 |
|------|------|
| `mng/public/js/fcm.js` | 포팅 원본 |
| `api/app/Swagger/v1/PushApi.php` | 백엔드 API 스펙 |
| [Capacitor Push Notifications](https://capacitorjs.com/docs/apis/push-notifications) | 공식 문서 |

---

## 8. 컨펌 대기 목록

| # | 항목 | 변경 내용 | 영향 범위 | 상태 |
|---|------|----------|----------|------|
| 1 | 포그라운드 알림 UX | sonner 토스트 디자인/위치 | UX | ⏳ |

---

## 9. 변경 이력

| 날짜 | 항목 | 변경 내용 | 파일 | 승인 |
|------|------|----------|------|------|
| 2025-12-30 | 계획 수립 | 계획 문서 작성 | - | - |

---

*이 문서는 /sc:plan 스킬로 생성되었습니다.*