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 현재 구조
1.2 목표 구조
1.3 핵심 포인트
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 분석
3.2 FCM 유틸리티 (포팅)
// 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 훅
3.4 FCM Provider
3.5 레이아웃에 Provider 추가
4. 파일 구조
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. 참고 문서
8. 컨펌 대기 목록
| # |
항목 |
변경 내용 |
영향 범위 |
상태 |
| 1 |
포그라운드 알림 UX |
sonner 토스트 디자인/위치 |
UX |
⏳ |
9. 변경 이력
| 날짜 |
항목 |
변경 내용 |
파일 |
승인 |
| 2025-12-30 |
계획 수립 |
계획 문서 작성 |
- |
- |
이 문서는 /sc:plan 스킬로 생성되었습니다.