- 완료 문서의 상세 내용은 추후 docs/ 구조화 시 정식 문서에 반영 예정 - HISTORY.md는 요약 인덱스로 유지, 개별 파일은 상세 참조용 보관 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
543 lines
15 KiB
Markdown
543 lines
15 KiB
Markdown
# 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 스킬로 생성되었습니다.* |