docs: React FCM 푸시 알림 연동 계획 문서 추가

- 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 <noreply@anthropic.com>
This commit is contained in:
2025-12-30 13:59:04 +09:00
parent 46369be515
commit b206875a8c

View File

@@ -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<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 스킬로 생성되었습니다.*