자재관리: - 입고관리 재고조정 다이얼로그 신규 추가, 상세/목록 기능 확장 - 재고현황 컴포넌트 리팩토링 출고관리: - 출하관리 생성/수정/목록/상세 개선 - 차량배차관리 상세/수정/목록 기능 보강 생산관리: - 작업지시서 WIP 생산 모달 신규 추가 - 벤딩WIP/슬랫조인트바 검사 콘텐츠 신규 추가 - 작업자화면 기능 대폭 확장 (카드/목록 개선) - 검사성적서 모달 개선 품질관리: - 실적보고서 관리 페이지 신규 추가 - 검사관리 문서/타입/목데이터 개선 단가관리: - 단가배포 페이지 및 컴포넌트 신규 추가 - 단가표 관리 페이지 및 컴포넌트 신규 추가 공통: - 권한 시스템 추가 개선 (PermissionContext, usePermission, PermissionGuard) - 메뉴 폴링 훅 개선, 레이아웃 수정 - 모바일 줌/패닝 CSS 수정 - locale 유틸 추가 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
14 KiB
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 호출 발생
프론트엔드 구현 사항
- 메뉴 갱신 유틸리티 함수 (
src/lib/utils/menuRefresh.ts) - 폴링 훅 (
src/hooks/useMenuPolling.ts) - 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/menusAPI 존재 확인 ✅MenuController::index→MenuService::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/menusAPI 이미 존재 ✅
변경 이력
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