Files
sam-docs/plans/archive/react-fcm-push-notification-plan.md
권혁성 00023b2d69 chore: 계획 문서 정리 및 아카이브 이동
- 완료된 계획 문서 12개 → plans/archive/ 이동
- 완료된 하위 계획 2개 → plans/sub/archive/ 이동
- 새 계획 문서 추가:
  - 5130-bom-migration-plan.md (완료)
  - 5130-sam-data-migration-plan.md (완료)
  - bidding-api-implementation-plan.md (완료)
  - dashboard-api-integration-plan.md
  - order-workorder-shipment-integration-plan.md
  - dev-toolbar-plan.md
- AI 리포트 키워드 색상체계 가이드 v1.4 추가
- index_plans.md 업데이트
2026-01-20 19:05:43 +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 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 분석

// 핵심 기능 요약
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 스킬로 생성되었습니다.