Files
sam-docs/plans/react-fcm-push-notification-plan.md
kent b206875a8c 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>
2025-12-30 13:59:04 +09:00

15 KiB

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 분석

// 핵심 기능 요약
1. Capacitor 네이티브 환경 체크 (ios/android)
2. PushNotifications.requestPermissions() - 권한 요청
3. PushNotifications.register() - 토큰 발급
4. registration 이벤트  api에 토큰 등록
5. pushNotificationReceived  포그라운드 알림 (토스트 + 사운드)
6. pushNotificationActionPerformed  알림 클릭  URL 이동

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 훅

// 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

// 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 추가

// 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 공식 문서

8. 컨펌 대기 목록

# 항목 변경 내용 영향 범위 상태
1 포그라운드 알림 UX sonner 토스트 디자인/위치 UX

9. 변경 이력

날짜 항목 변경 내용 파일 승인
2025-12-30 계획 수립 계획 문서 작성 - -

이 문서는 /sc:plan 스킬로 생성되었습니다.