From b206875a8c2fcf214981db429781af78abd28944 Mon Sep 17 00:00:00 2001 From: kent Date: Tue, 30 Dec 2025 13:59:04 +0900 Subject: [PATCH] =?UTF-8?q?docs:=20React=20FCM=20=ED=91=B8=EC=8B=9C=20?= =?UTF-8?q?=EC=95=8C=EB=A6=BC=20=EC=97=B0=EB=8F=99=20=EA=B3=84=ED=9A=8D=20?= =?UTF-8?q?=EB=AC=B8=EC=84=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Capacitor 앱 웹뷰가 dev.sam.kr을 로드할 때 FCM 푸시 알림 지원 - mng/public/js/fcm.js를 react에 포팅하는 계획 - 4단계 Phase: 플러그인 설치 → 유틸리티 포팅 → UI → 테스트 - 백엔드 API 변경 없음 (기존 /push/* 재사용) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- plans/react-fcm-push-notification-plan.md | 543 ++++++++++++++++++++++ 1 file changed, 543 insertions(+) create mode 100644 plans/react-fcm-push-notification-plan.md diff --git a/plans/react-fcm-push-notification-plan.md b/plans/react-fcm-push-notification-plan.md new file mode 100644 index 0000000..70a2bb8 --- /dev/null +++ b/plans/react-fcm-push-notification-plan.md @@ -0,0 +1,543 @@ +# 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 1: Capacitor 플러그인 설치 | +| **진행률** | 0/4 (0%) | +| **마지막 업데이트** | 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 설치 | ⏳ | npm install | +| 1.2 | @capacitor/app 설치 | ⏳ | 앱 상태 감지용 | +| 1.3 | capacitor.config.ts 확인/수정 | ⏳ | 플러그인 설정 | + +### 2.2 Phase 2: FCM 유틸리티 포팅 + +| # | 작업 항목 | 상태 | 비고 | +|---|----------|:----:|------| +| 2.1 | lib/capacitor/fcm.ts 생성 | ⏳ | 핵심 FCM 로직 | +| 2.2 | useFCM 훅 생성 | ⏳ | React 훅 래퍼 | +| 2.3 | FCM Provider 생성 | ⏳ | 앱 전역 초기화 | + +### 2.3 Phase 3: 포그라운드 알림 UI + +| # | 작업 항목 | 상태 | 비고 | +|---|----------|:----:|------| +| 3.1 | sonner 토스트 연동 | ⏳ | 포그라운드 알림 표시 | +| 3.2 | 알림 사운드 재생 | ⏳ | /sounds/*.wav | +| 3.3 | 클릭 시 URL 이동 | ⏳ | router.push 사용 | + +### 2.4 Phase 4: 통합 테스트 + +| # | 작업 항목 | 상태 | 비고 | +|---|----------|:----:|------| +| 4.1 | 토큰 등록 테스트 | ⏳ | API 호출 확인 | +| 4.2 | 포그라운드 알림 테스트 | ⏳ | 토스트 + 사운드 | +| 4.3 | 백그라운드 알림 테스트 | ⏳ | 시스템 알림 | +| 4.4 | 알림 클릭 테스트 | ⏳ | URL 이동 | + +--- + +## 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 스킬로 생성되었습니다.* \ No newline at end of file