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

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 : '알 수 없는 오류'
};
}
}