# 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 { // 네이티브 환경 체크 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 { 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 { 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 { 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 ( {children} ); } ``` --- ## 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 스킬로 생성되었습니다.*