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:
543
plans/react-fcm-push-notification-plan.md
Normal file
543
plans/react-fcm-push-notification-plan.md
Normal 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 스킬로 생성되었습니다.*
|
||||
Reference in New Issue
Block a user