feat: 메뉴 폴링 및 문서 업데이트

- 메뉴 폴링 API 및 훅 추가 (useMenuPolling, menuRefresh)
- AuthenticatedLayout 메뉴 새로고침 연동
- 품질검사 체크리스트 문서 추가
- Vercel 배포 가이드 추가
- 동적 메뉴 리프레시 계획 문서 추가

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
byeongcheolryu
2025-12-29 14:54:27 +09:00
parent fb2be8651e
commit 69832b4c58
9 changed files with 1060 additions and 1 deletions

BIN
claudedocs/.DS_Store vendored

Binary file not shown.

View File

@@ -152,7 +152,8 @@ claudedocs/
| 파일 | 설명 |
|------|------|
| `[PLAN-2025-12-23] common-component-extraction-plan.md` | 🔴 **NEW** - 공통 컴포넌트 추출 계획서 (Phase 1-4, 체크리스트 포함, ~1,900줄 절감) |
| `[GUIDE-2025-12-29] vercel-deployment.md` | 🔴 **NEW** - Vercel 배포 가이드 (환경변수, CORS, 테스트 체크리스트) |
| `[PLAN-2025-12-23] common-component-extraction-plan.md` | 공통 컴포넌트 추출 계획서 (Phase 1-4, 체크리스트 포함, ~1,900줄 절감) |
| `[ANALYSIS-2025-12-23] common-component-extraction-candidates.md` | 📋 공통 컴포넌트 추출 후보 분석 (다이얼로그 102개 중복, ~2,370줄 절감 예상) |
| `[PLAN-2025-12-19] project-health-improvement.md` | ✅ **Phase 1 완료** - 프로젝트 헬스 개선 계획서 (타입에러 0개, API키 보안, SSR 수정) |
| `[PLAN-2025-12-19] page-layout-standardization.md` | 🔴 **NEW** - 페이지 레이아웃 표준화 계획 |
@@ -172,6 +173,7 @@ claudedocs/
| 파일 | 설명 |
|------|------|
| `[PLAN-2025-12-29] dynamic-menu-refresh.md` | 🔴 **NEW** - 동적 메뉴 갱신 시스템 (1단계: 폴링, 2단계: SSE) |
| `multi-tenancy-implementation.md` | 멀티테넌시 구현 |
| `multi-tenancy-test-guide.md` | 멀티테넌시 테스트 |
| `architecture-integration-risks.md` | 통합 리스크 |

View File

@@ -0,0 +1,308 @@
# 동적 메뉴 갱신 시스템
## 개요
관리자가 게시판/메뉴를 추가하면 사용자가 **재로그인 없이** 즉시 메뉴를 갱신받을 수 있는 시스템 구현.
## 현재 문제점
```
현재 흐름:
로그인 → 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/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)
- [x] `src/lib/utils/menuRefresh.ts` 생성
- [x] `refreshMenus()` 함수 구현
- [x] `forceRefreshMenus()` 강제 갱신 함수
- [x] localStorage + Zustand 동시 업데이트
- [x] 해시 기반 변경 감지
- [x] `src/hooks/useMenuPolling.ts` 생성
- [x] 30초 간격 폴링 로직
- [x] 탭 가시성 변경 시 자동 중지/재개
- [x] pause/resume 기능
- [x] 컴포넌트 언마운트 시 정리
- [x] `src/app/api/menus/route.ts` 생성 (Next.js 프록시)
- [x] 백엔드 메뉴 API 프록시
- [x] HttpOnly 쿠키 토큰 처리
- [x] `{ data: [...] }` 응답 구조 처리
- [x] `AuthenticatedLayout.tsx`에 훅 적용
- [ ] 테스트: 관리자 메뉴 추가 → 30초 내 사용자 메뉴 갱신 확인
#### 백엔드 (이미 존재!)
- [x] `GET /api/v1/menus` API 존재 확인 ✅
- [x] `MenuController::index``MenuService::index` (사용자 권한 기반 필터링)
- [x] 응답 구조: `{ data: [...] }` (ApiResponse::handle 표준)
### 2단계: SSE 고도화 (향후)
- [ ] 백엔드 SSE 인프라 구축
- [ ] 프론트엔드 EventSource 훅 구현
- [ ] 폴링 → SSE 전환
- [ ] 폴백: SSE 연결 실패 시 폴링으로 대체
---
## 코드 스니펫
### refreshMenus 함수
```typescript
// 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 훅
```typescript
// src/hooks/useMenuPolling.ts
import { useEffect, useRef } from 'react';
import { refreshMenus } from '@/lib/utils/menuRefresh';
const POLLING_INTERVAL = 30000; // 30초
export function useMenuPolling(enabled: boolean = true) {
const intervalRef = useRef<NodeJS.Timeout | null>(null);
useEffect(() => {
if (!enabled) return;
// 초기 실행은 하지 않음 (로그인 시 이미 받아옴)
intervalRef.current = setInterval(() => {
refreshMenus();
}, POLLING_INTERVAL);
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
};
}, [enabled]);
}
```
### Next.js API 프록시
```typescript
// 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 렌더링용 |
### 갱신 시 동기화 필수
```typescript
// 반드시 둘 다 업데이트!
localStorage.user.menu = newMenus; // 새로고침 대응
menuStore.setMenuItems(newMenus); // UI 즉시 반영
```
---
## 작성 정보
- **작성일**: 2025-12-29
- **상태**: ✅ 1단계 구현 완료 (테스트 대기)
- **담당**: 프론트엔드 팀
- **백엔드**: `GET /api/v1/menus` API 이미 존재 ✅

View File

@@ -0,0 +1,84 @@
# 품질인정심사 시스템 구현 체크리스트
> **경로**: `src/app/[locale]/(protected)/dev/quality-inspection/`
> **작업일**: 2025-12-29
> **담당**: 버디
> **상태**: ✅ 완료
---
## Phase 1: 상태 관리 구현 ✅
- [x] 1.1 page.tsx에 필터 상태 추가 (년도, 분기, 검색어)
- [x] 1.2 selectedReport 상태 추가
- [x] 1.3 selectedRoute 상태 추가
- [x] 1.4 필터링 로직 구현 (useMemo)
## Phase 2: 컴포넌트 Props 연동 ✅
- [x] 2.1 ReportList.tsx - onSelect 콜백 추가
- [x] 2.2 RouteList.tsx - reports 데이터 + onSelect 콜백 추가
- [x] 2.3 DocumentList.tsx - route 데이터 연동
- [x] 2.4 Filters.tsx - 상태 콜백 연동
## Phase 3: Mock 데이터 통합 ✅
- [x] 3.1 types.ts에 통합 데이터 구조 정의
- [x] 3.2 mockData.ts 생성 (계층 구조 데이터)
- [x] 3.3 Report → Route → Document 연결 구조
## Phase 4: 문서 모달 연동 ✅
### 기존 문서 컴포넌트 (재사용)
| 문서 종류 | 기존 컴포넌트 | 상태 |
|----------|--------------|------|
| 수주서 | `orders/documents/OrderDocumentModal.tsx` | ✅ 있음 |
| 작업일지 | `production/WorkerScreen/WorkLogModal.tsx` | ✅ 있음 |
| 납품확인서 | `outbound/ShipmentManagement/documents/DeliveryConfirmation.tsx` | ✅ 있음 |
| 출고증 | `outbound/ShipmentManagement/documents/ShippingSlip.tsx` | ✅ 있음 |
### 신규 문서 (양식 필요)
| 문서 종류 | 상태 | 비고 |
|----------|------|------|
| 수입검사 성적서 | ❌ 양식 필요 | 디자인 파일 대기 |
| 중간검사 성적서 | ❌ 양식 필요 | 디자인 파일 대기 |
| 제품검사 성적서 | ❌ 양식 필요 | 디자인 파일 대기 |
| 품질관리서 | ❌ 양식 필요 | 디자인 파일 대기 |
### 모달 연동 작업
- [x] 4.1 InspectionModal에서 문서 타입별 분기 처리
- [x] 4.2 기존 문서 컴포넌트 Placeholder 표시 (연동 예정 안내)
- [x] 4.3 신규 문서는 Placeholder 표시 (양식 대기)
## Phase 5: UI 개선 ✅
- [x] 5.1 PageLayout 적용 → N/A (전체 높이 대시보드 레이아웃으로 별도 처리)
- [x] 5.2 Filters.tsx 미사용 import 정리 → 미사용 import 없음 확인
- [x] 5.3 반응형 레이아웃 검증 → grid-cols-12 + lg: 반응형 적용됨
---
## 진행 현황
| Phase | 상태 | 완료일 |
|-------|------|--------|
| Phase 1 | ✅ 완료 | 2025-12-29 |
| Phase 2 | ✅ 완료 | 2025-12-29 |
| Phase 3 | ✅ 완료 | 2025-12-29 |
| Phase 4 | ✅ 완료 | 2025-12-29 |
| Phase 5 | ✅ 완료 | 2025-12-29 |
---
## 참고 파일
```
src/components/orders/documents/OrderDocumentModal.tsx
src/components/production/WorkerScreen/WorkLogModal.tsx
src/components/outbound/ShipmentManagement/documents/DeliveryConfirmation.tsx
src/components/outbound/ShipmentManagement/documents/ShippingSlip.tsx
src/components/process-management/ProcessWorkLogPreviewModal.tsx
```

View File

@@ -0,0 +1,204 @@
# Vercel 배포 가이드
> 작성일: 2025-12-29
> 상태: 🔄 진행 중
> 담당:
---
## 📋 배포 전 체크리스트
### 프로젝트 상태
- [x] 빌드 테스트 성공
- [x] Node.js v20.x 호환 확인
- [x] Next.js 15 + next-intl 설정 완료
- [x] 다국어 지원 (ko/en/ja)
### 배포 준비
- [ ] Vercel 계정 준비
- [ ] Git 레포지토리 연동
- [ ] 환경 변수 설정
- [ ] CORS 설정 요청 (백엔드)
- [ ] 배포 완료
- [ ] 테스트 완료
---
## 1단계: Vercel 프로젝트 생성
### 1.1 Vercel 접속
1. [vercel.com](https://vercel.com) 접속
2. GitHub/GitLab 계정으로 로그인
### 1.2 프로젝트 생성
1. Dashboard → **Add New****Project**
2. Git 레포지토리 선택: `sam-react-prod`
3. Framework Preset: **Next.js** (자동 감지)
4. Root Directory: `.` (기본값)
---
## 2단계: 환경 변수 설정
### 필수 환경 변수
| 변수명 | 값 | 환경 | 설명 |
|--------|-----|------|------|
| `NEXT_PUBLIC_API_URL` | `https://api.5130.co.kr` | All | 백엔드 API URL |
| `NEXT_PUBLIC_FRONTEND_URL` | `(배포 후 URL)` | Production | 프론트엔드 URL |
| `NEXT_PUBLIC_AUTH_MODE` | `sanctum` | All | 인증 모드 |
| `API_KEY` | `(실제 키)` | All | 서버사이드 API 키 |
### 설정 방법
1. Vercel Dashboard → Settings → Environment Variables
2. 각 변수 추가
3. Environment: Production / Preview / Development 선택
### 환경 변수 값 메모
```
NEXT_PUBLIC_API_URL =
NEXT_PUBLIC_FRONTEND_URL =
NEXT_PUBLIC_AUTH_MODE =
API_KEY =
```
---
## 3단계: 백엔드 CORS 설정
### 요청 내용
Vercel 배포 후 도메인을 백엔드팀에 전달하여 CORS 허용 요청
```
허용 요청 도메인:
- https://프로젝트명.vercel.app
- https://커스텀도메인.com (있는 경우)
```
### 백엔드 요청 메모
```
요청일:
요청 도메인:
처리 상태: [ ] 대기 / [ ] 완료
```
---
## 4단계: 배포 실행
### 4.1 첫 배포
1. 환경 변수 설정 완료 확인
2. **Deploy** 버튼 클릭
3. 빌드 로그 모니터링
### 4.2 배포 성공 확인
- [ ] 빌드 성공
- [ ] 배포 URL 생성
- [ ] 페이지 로딩 확인
### 배포 정보
```
배포 URL:
배포 시간:
빌드 시간:
```
---
## 5단계: 배포 후 테스트
### 5.1 기본 테스트
- [ ] 메인 페이지 로딩
- [ ] 로그인 페이지 접근
- [ ] 다국어 전환 (ko/en/ja)
### 5.2 인증 테스트
- [ ] 로그인 시도
- [ ] 토큰 발급 확인
- [ ] 로그아웃
### 5.3 API 연동 테스트
- [ ] API 호출 정상
- [ ] CORS 에러 없음
- [ ] 데이터 로딩 확인
### 5.4 주요 페이지 테스트
- [ ] 대시보드
- [ ] 품목기준관리
- [ ] 설정 페이지
### 테스트 결과 메모
```
테스트일:
발견된 이슈:
-
해결 필요 사항:
-
```
---
## 6단계: 커스텀 도메인 (선택)
### 6.1 도메인 연결
1. Vercel Dashboard → Settings → Domains
2. 도메인 추가: `your-domain.com`
3. DNS 설정 안내 확인
### 6.2 DNS 설정
```
Type: CNAME
Name: @ 또는 www
Value: cname.vercel-dns.com
```
### 도메인 정보
```
도메인:
SSL 상태: [ ] 대기 / [ ] 활성화
```
---
## 트러블슈팅
### 빌드 실패 시
```bash
# 로컬에서 빌드 테스트
npm run build
```
### CORS 에러 시
- 백엔드 CORS 설정 확인
- `NEXT_PUBLIC_FRONTEND_URL` 값 확인
### 환경 변수 미적용 시
- Vercel Dashboard에서 값 확인
- 재배포 필요 (환경 변수 변경 후)
### API 연결 실패 시
- `NEXT_PUBLIC_API_URL` 확인
- `API_KEY` 값 확인
- 네트워크 탭에서 요청/응답 확인
---
## 참고 자료
- [Vercel Next.js 배포 가이드](https://vercel.com/docs/frameworks/nextjs)
- [Next.js 환경 변수](https://nextjs.org/docs/app/building-your-application/configuring/environment-variables)
- [Vercel 커스텀 도메인](https://vercel.com/docs/projects/domains)
---
## 작업 로그
### 2025-12-29
- [ ] 가이드 문서 생성
- [ ] 배포 시작
### 추가 메모
```
(여기에 진행하면서 메모 추가)
```

View File

@@ -0,0 +1,92 @@
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
/**
* 🔵 Next.js 내부 API - 메뉴 조회 프록시 (PHP 백엔드로 전달)
*
* ⚡ 설계 목적:
* - 동적 메뉴 갱신: 재로그인 없이 메뉴 목록 갱신
* - 보안: HttpOnly 쿠키에서 토큰을 읽어 백엔드로 전달
*
* 🔄 동작 흐름:
* 1. 클라이언트 → Next.js /api/menus
* 2. Next.js: HttpOnly 쿠키에서 access_token 읽기
* 3. Next.js → PHP /api/v1/menus (메뉴 조회 요청)
* 4. Next.js → 클라이언트 (메뉴 목록 응답)
*
* 📌 백엔드 API 요청 사항:
* - 엔드포인트: GET /api/v1/menus
* - 인증: Bearer 토큰 필요
* - 응답: { menus: [...] } (로그인 응답의 menus와 동일 구조)
*
* @see claudedocs/architecture/[PLAN-2025-12-29] dynamic-menu-refresh.md
*/
export async function GET(request: NextRequest) {
try {
// HttpOnly 쿠키에서 access_token 읽기
const accessToken = request.cookies.get('access_token')?.value;
if (!accessToken) {
return NextResponse.json(
{ error: 'Unauthorized', message: '인증 토큰이 없습니다' },
{ status: 401 }
);
}
// PHP 백엔드 메뉴 API 호출
const backendUrl = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/menus`;
const response = await fetch(backendUrl, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'Authorization': `Bearer ${accessToken}`,
'X-API-KEY': process.env.API_KEY || '',
},
});
if (!response.ok) {
// 백엔드 에러 응답 전달
const errorData = await response.json().catch(() => ({}));
console.error('[Menu API] Backend error:', response.status, errorData);
return NextResponse.json(
{
error: 'Backend Error',
message: errorData.message || '메뉴 조회에 실패했습니다',
status: response.status
},
{ status: response.status }
);
}
const data = await response.json();
// 백엔드 응답 구조: { data: [...] } (ApiResponse::handle 표준)
// 또는 로그인 응답과 동일한 { menus: [...] } 형태일 수 있음
const menus = data.data || data.menus || (Array.isArray(data) ? data : null);
// 메뉴 데이터 검증
if (!menus || !Array.isArray(menus)) {
console.error('[Menu API] Invalid response format:', data);
return NextResponse.json(
{ error: 'Invalid Response', message: '메뉴 데이터 형식이 올바르지 않습니다' },
{ status: 500 }
);
}
// 응답 구조 통일: { menus: [...] }
return NextResponse.json({ menus }, { status: 200 });
} catch (error) {
console.error('[Menu API] Proxy error:', error);
return NextResponse.json(
{
error: 'Internal Server Error',
message: error instanceof Error ? error.message : '서버 오류가 발생했습니다'
},
{ status: 500 }
);
}
}

169
src/hooks/useMenuPolling.ts Normal file
View File

@@ -0,0 +1,169 @@
/**
* 메뉴 폴링 훅
*
* 일정 간격으로 메뉴 변경사항을 확인하고 자동 갱신합니다.
*
* @see claudedocs/architecture/[PLAN-2025-12-29] dynamic-menu-refresh.md
*/
import { useEffect, useRef, useCallback } from 'react';
import { refreshMenus } from '@/lib/utils/menuRefresh';
// 기본 폴링 간격: 30초
const DEFAULT_POLLING_INTERVAL = 30 * 1000;
// 최소 폴링 간격: 10초 (서버 부하 방지)
const MIN_POLLING_INTERVAL = 10 * 1000;
// 최대 폴링 간격: 5분
const MAX_POLLING_INTERVAL = 5 * 60 * 1000;
interface UseMenuPollingOptions {
/** 폴링 활성화 여부 (기본: true) */
enabled?: boolean;
/** 폴링 간격 (ms, 기본: 30초) */
interval?: number;
/** 메뉴 갱신 시 콜백 */
onMenuUpdated?: () => void;
/** 에러 발생 시 콜백 */
onError?: (error: string) => void;
}
interface UseMenuPollingReturn {
/** 수동으로 메뉴 갱신 실행 */
refresh: () => Promise<void>;
/** 폴링 일시 중지 */
pause: () => void;
/** 폴링 재개 */
resume: () => void;
/** 현재 폴링 상태 */
isPaused: boolean;
}
/**
* 메뉴 폴링 훅
*
* @example
* ```tsx
* // 기본 사용
* useMenuPolling();
*
* // 옵션과 함께 사용
* const { refresh, pause, resume } = useMenuPolling({
* interval: 60000, // 1분마다
* onMenuUpdated: () => console.log('메뉴 업데이트됨!'),
* });
*
* // 수동 갱신
* await refresh();
* ```
*/
export function useMenuPolling(options: UseMenuPollingOptions = {}): UseMenuPollingReturn {
const {
enabled = true,
interval = DEFAULT_POLLING_INTERVAL,
onMenuUpdated,
onError,
} = options;
// 폴링 간격 유효성 검사
const safeInterval = Math.max(MIN_POLLING_INTERVAL, Math.min(MAX_POLLING_INTERVAL, interval));
const intervalRef = useRef<NodeJS.Timeout | null>(null);
const isPausedRef = useRef(false);
// 메뉴 갱신 실행
const executeRefresh = useCallback(async () => {
if (isPausedRef.current) return;
const result = await refreshMenus();
if (result.success && result.updated) {
onMenuUpdated?.();
}
if (!result.success && result.error) {
onError?.(result.error);
}
}, [onMenuUpdated, onError]);
// 수동 갱신 함수
const refresh = useCallback(async () => {
await executeRefresh();
}, [executeRefresh]);
// 폴링 일시 중지
const pause = useCallback(() => {
isPausedRef.current = true;
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
}, []);
// 폴링 재개
const resume = useCallback(() => {
isPausedRef.current = false;
if (enabled && !intervalRef.current) {
intervalRef.current = setInterval(executeRefresh, safeInterval);
}
}, [enabled, safeInterval, executeRefresh]);
// 폴링 설정
useEffect(() => {
if (!enabled) {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
return;
}
// 페이지 로드 시 즉시 실행하지 않음 (로그인 시 이미 받아옴)
// 폴링 시작
intervalRef.current = setInterval(executeRefresh, safeInterval);
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
};
}, [enabled, safeInterval, executeRefresh]);
// 탭 가시성 변경 시 처리
useEffect(() => {
if (!enabled) return;
const handleVisibilityChange = () => {
if (document.hidden) {
// 탭이 숨겨지면 폴링 중지 (리소스 절약)
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
} else {
// 탭이 다시 보이면 즉시 갱신 후 폴링 재개
if (!isPausedRef.current) {
executeRefresh();
intervalRef.current = setInterval(executeRefresh, safeInterval);
}
}
};
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
}, [enabled, safeInterval, executeRefresh]);
return {
refresh,
pause,
resume,
isPaused: isPausedRef.current,
};
}
export default useMenuPolling;

View File

@@ -37,6 +37,7 @@ import Sidebar from '@/components/layout/Sidebar';
import { useTheme } from '@/contexts/ThemeContext';
import { useAuth } from '@/contexts/AuthContext';
import { deserializeMenuItems } from '@/lib/utils/menuTransform';
import { useMenuPolling } from '@/hooks/useMenuPolling';
interface AuthenticatedLayoutProps {
children: React.ReactNode;
@@ -60,6 +61,16 @@ export default function AuthenticatedLayout({ children }: AuthenticatedLayoutPro
const [userName, setUserName] = useState<string>("사용자");
const [userPosition, setUserPosition] = useState<string>("직책");
// 메뉴 폴링 (30초마다 메뉴 변경 확인)
// 백엔드 GET /api/v1/menus API 준비되면 자동 동작
useMenuPolling({
enabled: true,
interval: 30000, // 30초
onMenuUpdated: () => {
console.log('[Menu] 메뉴가 업데이트되었습니다');
},
});
// 모바일 감지
useEffect(() => {
const checkScreenSize = () => {

View File

@@ -0,0 +1,189 @@
/**
* 메뉴 동적 갱신 유틸리티
*
* 관리자가 게시판/메뉴 추가 시 재로그인 없이 메뉴를 갱신합니다.
*
* @see claudedocs/architecture/[PLAN-2025-12-29] dynamic-menu-refresh.md
*/
import { transformApiMenusToMenuItems, deserializeMenuItems } from './menuTransform';
import { useMenuStore } from '@/store/menuStore';
import type { SerializableMenuItem } from '@/store/menuStore';
/**
* 메뉴 해시 생성 (변경 감지용)
* 메뉴 ID들을 정렬하여 해시 생성
*/
function generateMenuHash(menus: SerializableMenuItem[]): string {
const collectIds = (items: SerializableMenuItem[]): string[] => {
return items.flatMap(item => [
item.id,
...(item.children ? collectIds(item.children) : [])
]);
};
return collectIds(menus).sort().join(',');
}
/**
* 현재 저장된 메뉴 해시 가져오기
*/
export function getCurrentMenuHash(): string {
if (typeof window === 'undefined') return '';
try {
const userData = localStorage.getItem('user');
if (!userData) return '';
const parsed = JSON.parse(userData);
if (!parsed.menu || !Array.isArray(parsed.menu)) return '';
return generateMenuHash(parsed.menu);
} catch {
return '';
}
}
/**
* 메뉴 갱신 결과 타입
*/
interface RefreshMenuResult {
success: boolean;
updated: boolean; // 실제로 메뉴가 변경되었는지
error?: string;
}
/**
* 메뉴 갱신 함수
*
* 1. API에서 새 메뉴 받아오기
* 2. 기존 메뉴와 비교 (해시)
* 3. 변경 시 localStorage + Zustand 업데이트
*
* @returns 갱신 결과
*/
export async function refreshMenus(): Promise<RefreshMenuResult> {
try {
// 1. 현재 메뉴 해시 저장
const currentHash = getCurrentMenuHash();
// 2. API에서 새 메뉴 받아오기
const response = await fetch('/api/menus', {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
});
if (!response.ok) {
// 401 등 인증 오류는 조용히 실패 (로그아웃 상태일 수 있음)
if (response.status === 401) {
return { success: false, updated: false };
}
return {
success: false,
updated: false,
error: `API 오류: ${response.status}`
};
}
const data = await response.json();
if (!data.menus || !Array.isArray(data.menus)) {
return {
success: false,
updated: false,
error: '메뉴 데이터 형식 오류'
};
}
// 3. 메뉴 변환
const transformedMenus = transformApiMenusToMenuItems(data.menus);
const newHash = generateMenuHash(transformedMenus);
// 4. 변경 없으면 업데이트 스킵
if (currentHash === newHash) {
return { success: true, updated: false };
}
// 5. localStorage 업데이트 (새로고침 대응)
const userData = localStorage.getItem('user');
if (userData) {
const parsed = JSON.parse(userData);
parsed.menu = transformedMenus;
localStorage.setItem('user', JSON.stringify(parsed));
}
// 6. Zustand 스토어 업데이트 (UI 즉시 반영)
const { setMenuItems } = useMenuStore.getState();
setMenuItems(deserializeMenuItems(transformedMenus));
console.log('[Menu] 메뉴 갱신 완료 - 변경 감지됨');
return { success: true, updated: true };
} catch (error) {
console.error('[Menu] 메뉴 갱신 실패:', error);
return {
success: false,
updated: false,
error: error instanceof Error ? error.message : '알 수 없는 오류'
};
}
}
/**
* 메뉴 강제 갱신 (비교 없이 무조건 갱신)
*/
export async function forceRefreshMenus(): Promise<RefreshMenuResult> {
try {
const response = await fetch('/api/menus', {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
});
if (!response.ok) {
return {
success: false,
updated: false,
error: `API 오류: ${response.status}`
};
}
const data = await response.json();
if (!data.menus || !Array.isArray(data.menus)) {
return {
success: false,
updated: false,
error: '메뉴 데이터 형식 오류'
};
}
const transformedMenus = transformApiMenusToMenuItems(data.menus);
// localStorage 업데이트
const userData = localStorage.getItem('user');
if (userData) {
const parsed = JSON.parse(userData);
parsed.menu = transformedMenus;
localStorage.setItem('user', JSON.stringify(parsed));
}
// Zustand 스토어 업데이트
const { setMenuItems } = useMenuStore.getState();
setMenuItems(deserializeMenuItems(transformedMenus));
console.log('[Menu] 메뉴 강제 갱신 완료');
return { success: true, updated: true };
} catch (error) {
console.error('[Menu] 메뉴 강제 갱신 실패:', error);
return {
success: false,
updated: false,
error: error instanceof Error ? error.message : '알 수 없는 오류'
};
}
}