Files
sam-react-prod/claudedocs/architecture/[PLAN-2025-12-29] dynamic-menu-refresh.md
유병철 c1b63b850a feat(WEB): 자재/출고/생산/품질/단가 기능 대폭 개선 및 신규 페이지 추가
자재관리:
- 입고관리 재고조정 다이얼로그 신규 추가, 상세/목록 기능 확장
- 재고현황 컴포넌트 리팩토링

출고관리:
- 출하관리 생성/수정/목록/상세 개선
- 차량배차관리 상세/수정/목록 기능 보강

생산관리:
- 작업지시서 WIP 생산 모달 신규 추가
- 벤딩WIP/슬랫조인트바 검사 콘텐츠 신규 추가
- 작업자화면 기능 대폭 확장 (카드/목록 개선)
- 검사성적서 모달 개선

품질관리:
- 실적보고서 관리 페이지 신규 추가
- 검사관리 문서/타입/목데이터 개선

단가관리:
- 단가배포 페이지 및 컴포넌트 신규 추가
- 단가표 관리 페이지 및 컴포넌트 신규 추가

공통:
- 권한 시스템 추가 개선 (PermissionContext, usePermission, PermissionGuard)
- 메뉴 폴링 훅 개선, 레이아웃 수정
- 모바일 줌/패닝 CSS 수정
- locale 유틸 추가

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 12:46:19 +09:00

14 KiB

동적 메뉴 갱신 시스템

개요

관리자가 게시판/메뉴를 추가하면 사용자가 재로그인 없이 즉시 메뉴를 갱신받을 수 있는 시스템 구현.

현재 문제점

현재 흐름:
  로그인 → API 응답에서 메뉴 수신 → localStorage.user.menu 저장 → 세션 종료까지 고정

문제:
  - 관리자가 게시판 추가해도 사용자는 재로그인 전까지 새 메뉴 안 보임
  - 메뉴 전용 갱신 API 없음
  - 실시간 알림 메커니즘 없음

데이터 흐름 (현재)

┌─────────────────────────────────────────────────────────────┐
│                       로그인 시                              │
├─────────────────────────────────────────────────────────────┤
│  POST /api/v1/login                                         │
│       ↓                                                     │
│  응답: { user, tenant, roles, menus }                       │
│       ↓                                                     │
│  transformApiMenusToMenuItems(menus)                        │
│       ↓                                                     │
│  localStorage.setItem('user', { ...userData, menu })        │
└─────────────────────────────────────────────────────────────┘
                            ↓
┌─────────────────────────────────────────────────────────────┐
│                   페이지 로드 시                              │
├─────────────────────────────────────────────────────────────┤
│  AuthenticatedLayout.tsx                                    │
│       ↓                                                     │
│  localStorage.getItem('user') → userData.menu               │
│       ↓                                                     │
│  deserializeMenuItems(userData.menu)                        │
│       ↓                                                     │
│  menuStore.setMenuItems(deserializedMenus)                  │
│       ↓                                                     │
│  Sidebar 컴포넌트 렌더링                                     │
└─────────────────────────────────────────────────────────────┘

관련 파일

파일 역할
src/store/menuStore.ts Zustand 메뉴 상태 관리
src/lib/utils/menuTransform.ts API 메뉴 → UI 메뉴 변환
src/lib/utils/menuRefresh.ts 메뉴 갱신 유틸리티 (해시 비교, localStorage/Zustand 동시 업데이트)
src/hooks/useMenuPolling.ts 메뉴 폴링 훅 (30초 간격, 탭 가시성, 세션 만료 처리)
src/layouts/AuthenticatedLayout.tsx 메뉴 로드 및 스토어 설정
src/components/layout/Sidebar.tsx 메뉴 렌더링
src/contexts/AuthContext.tsx 사용자 인증 컨텍스트

구현 계획

1단계: 폴링 방식 (현재 구현 목표)

방식: 30초마다 메뉴 API 호출하여 변경사항 확인

┌─────────────────────────────────────────────────────────────┐
│                    폴링 방식 흐름                            │
├─────────────────────────────────────────────────────────────┤
│                                                              │
│  [30초마다]                                                  │
│       ↓                                                     │
│  GET /api/menus (메뉴 전용 API 필요)                         │
│       ↓                                                     │
│  현재 메뉴와 비교 (해시 또는 버전 비교)                        │
│       ↓                                                     │
│  변경 있으면 → refreshMenus() 호출                           │
│       ↓                                                     │
│  localStorage.user.menu 업데이트                             │
│  menuStore.setMenuItems() 호출                               │
│       ↓                                                     │
│  UI 즉시 반영                                                │
│                                                              │
└─────────────────────────────────────────────────────────────┘

장점:

  • 구현 단순
  • 백엔드 수정 최소화 (메뉴 조회 API만 추가)
  • 기존 인프라 그대로 사용

단점:

  • 최대 30초 지연
  • 불필요한 API 호출 발생

프론트엔드 구현 사항

  1. 메뉴 갱신 유틸리티 함수 (src/lib/utils/menuRefresh.ts)
  2. 폴링 훅 (src/hooks/useMenuPolling.ts)
  3. AuthenticatedLayout에 훅 적용

백엔드 요청 사항

항목 설명
엔드포인트 GET /api/v1/menus
인증 Bearer 토큰 필요
응답 현재 사용자의 메뉴 목록 (로그인 응답의 menus와 동일 구조)
선택사항 menu_version 또는 menu_hash 필드 추가 (변경 감지 최적화용)

2단계: SSE 고도화 (향후 계획)

방식: 서버에서 메뉴 변경 시 SSE로 클라이언트에 푸시

┌─────────────────────────────────────────────────────────────┐
│                      백엔드 (Laravel)                        │
├─────────────────────────────────────────────────────────────┤
│  1. 관리자가 메뉴 추가 → DB 저장                              │
│  2. MenuUpdatedEvent 발생                                    │
│  3. 해당 테넌트의 SSE 채널로 푸시                             │
└─────────────────────────────────────────────────────────────┘
                            ↓ SSE
┌─────────────────────────────────────────────────────────────┐
│                    프론트엔드 (Next.js)                       │
├─────────────────────────────────────────────────────────────┤
│  1. EventSource로 SSE 연결 유지                              │
│  2. 'menu-updated' 이벤트 수신                               │
│  3. refreshMenus() 호출 → UI 즉시 갱신                       │
└─────────────────────────────────────────────────────────────┘

장점:

  • 실시간 갱신 (지연 없음)
  • 효율적 (변경 시에만 통신)

단점:

  • 백엔드 SSE 인프라 구축 필요
  • 동시 접속자 관리 필요
  • 멀티테넌트 채널 분리 필요

백엔드 요구사항 (SSE)

항목 설명
SSE 엔드포인트 GET /api/v1/sse/menu-updates
인증 Bearer 토큰 또는 쿼리 파라미터
이벤트 타입 menu-updated
채널 분리 테넌트별로 분리 필요
구현 옵션 Laravel Broadcasting + Redis, 직접 구현 등

구현 체크리스트

1단계: 폴링 방식

프론트엔드 구현 완료 (2025-12-29)

  • src/lib/utils/menuRefresh.ts 생성
    • refreshMenus() 함수 구현
    • forceRefreshMenus() 강제 갱신 함수
    • localStorage + Zustand 동시 업데이트
    • 해시 기반 변경 감지
  • src/hooks/useMenuPolling.ts 생성
    • 30초 간격 폴링 로직
    • 탭 가시성 변경 시 자동 중지/재개
    • pause/resume 기능
    • 컴포넌트 언마운트 시 정리
  • src/app/api/menus/route.ts 생성 (Next.js 프록시)
    • 백엔드 메뉴 API 프록시
    • HttpOnly 쿠키 토큰 처리
    • { data: [...] } 응답 구조 처리
  • AuthenticatedLayout.tsx에 훅 적용
  • 테스트: 관리자 메뉴 추가 → 30초 내 사용자 메뉴 갱신 확인

백엔드 (이미 존재!)

  • GET /api/v1/menus API 존재 확인
  • MenuController::indexMenuService::index (사용자 권한 기반 필터링)
  • 응답 구조: { data: [...] } (ApiResponse::handle 표준)

2단계: SSE 고도화 (향후)

  • 백엔드 SSE 인프라 구축
  • 프론트엔드 EventSource 훅 구현
  • 폴링 → SSE 전환
  • 폴백: SSE 연결 실패 시 폴링으로 대체

코드 스니펫

refreshMenus 함수

// src/lib/utils/menuRefresh.ts
import { transformApiMenusToMenuItems, deserializeMenuItems } from './menuTransform';
import { useMenuStore } from '@/store/menuStore';

export async function refreshMenus(): Promise<boolean> {
  try {
    const response = await fetch('/api/menus');
    if (!response.ok) return false;

    const { menus } = await response.json();
    const transformedMenus = transformApiMenusToMenuItems(menus);

    // 1. localStorage 업데이트 (새로고침 대응)
    const userData = JSON.parse(localStorage.getItem('user') || '{}');
    userData.menu = transformedMenus;
    localStorage.setItem('user', JSON.stringify(userData));

    // 2. Zustand 스토어 업데이트 (UI 즉시 반영)
    const { setMenuItems } = useMenuStore.getState();
    setMenuItems(deserializeMenuItems(transformedMenus));

    console.log('[Menu] 메뉴 갱신 완료');
    return true;
  } catch (error) {
    console.error('[Menu] 메뉴 갱신 실패:', error);
    return false;
  }
}

useMenuPolling 훅

// src/hooks/useMenuPolling.ts
// 주요 기능: 30초 폴링, 탭 가시성 처리, 세션 만료 감지(3회 연속 401), 토큰 갱신 쿠키 감지

export function useMenuPolling(options: UseMenuPollingOptions = {}): UseMenuPollingReturn {
  // ⚠️ 콜백 안정화 패턴 (2026-02-03 버그 수정)
  // 부모 컴포넌트에서 인라인 콜백을 전달하면 매 렌더마다 새 참조가 생성되어
  // executeRefresh → useEffect 의존성이 변경 → setInterval이 매 렌더마다 리셋되는 버그 발생.
  // 해결: 콜백을 ref로 저장하여 executeRefresh 의존성에서 제거.
  const onMenuUpdatedRef = useRef(onMenuUpdated);
  const onErrorRef = useRef(onError);
  const onSessionExpiredRef = useRef(onSessionExpired);

  useEffect(() => {
    onMenuUpdatedRef.current = onMenuUpdated;
    onErrorRef.current = onError;
    onSessionExpiredRef.current = onSessionExpired;
  });

  // executeRefresh 의존성: [stopPolling] 만 — 안정적
  const executeRefresh = useCallback(async () => {
    // ref를 통해 최신 콜백 호출
    onMenuUpdatedRef.current?.();
    onSessionExpiredRef.current?.();
    onErrorRef.current?.(result.error);
  }, [stopPolling]);
}

Next.js API 프록시

// src/app/api/menus/route.ts
import { NextRequest, NextResponse } from 'next/server';

export async function GET(request: NextRequest) {
  const token = request.cookies.get('access_token')?.value;

  if (!token) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
  }

  const response = await fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}/api/v1/menus`, {
    headers: {
      'Authorization': `Bearer ${token}`,
      'X-API-KEY': process.env.NEXT_PUBLIC_API_KEY || '',
    },
  });

  const data = await response.json();
  return NextResponse.json(data);
}

참고 사항

메뉴 데이터 저장 위치

저장소 용도
localStorage user.menu 새로고침 시 복구용
Zustand menuStore.menuItems UI 렌더링용

갱신 시 동기화 필수

// 반드시 둘 다 업데이트!
localStorage.user.menu = newMenus;      // 새로고침 대응
menuStore.setMenuItems(newMenus);       // UI 즉시 반영

작성 정보

  • 작성일: 2025-12-29
  • 최종 수정: 2026-02-03
  • 상태: 1단계 구현 완료 + 폴링 버그 수정
  • 담당: 프론트엔드 팀
  • 백엔드: GET /api/v1/menus API 이미 존재

변경 이력

2026-02-03: 폴링 인터벌 리셋 버그 수정

문제: 메뉴 폴링이 실제로 실행되지 않아 백엔드에서 메뉴를 추가해도 재로그인 전까지 반영되지 않음.

원인: useMenuPolling 훅의 executeRefresh 콜백이 매 렌더마다 새 참조를 생성하여 setInterval이 리셋됨.

AuthenticatedLayout에서 인라인 콜백 전달:
  onMenuUpdated: () => { ... }     ← 매 렌더마다 새 함수
  onSessionExpired: () => { ... }  ← 매 렌더마다 새 함수
       ↓
  executeRefresh deps: [onMenuUpdated, onError, onSessionExpired, stopPolling]
       ↓ 매 렌더마다 변경
  useEffect deps: [executeRefresh] → clearInterval → setInterval 재설정
       ↓
  알림 폴링이 30초마다 state 업데이트 → 리렌더 → 메뉴 인터벌 리셋
       ↓
  메뉴 폴링이 30초에 도달하지 못하고 영원히 미실행

수정: 콜백을 useRef로 안정화하여 executeRefresh 의존성에서 제거.

수정 전: executeRefresh deps = [onMenuUpdated, onError, onSessionExpired, stopPolling]
수정 후: executeRefresh deps = [stopPolling]  ← 안정적, 인터벌 리셋 없음

수정 파일: src/hooks/useMenuPolling.ts