/** * 메뉴 폴링 훅 * * 일정 간격으로 메뉴 변경사항을 확인하고 자동 갱신합니다. * 401 응답이 3회 연속 발생하면 세션 만료로 판단하고 폴링을 중지합니다. * 토큰 갱신 시 자동으로 폴링을 재시작합니다. * * @see claudedocs/architecture/[PLAN-2025-12-29] dynamic-menu-refresh.md */ import { useEffect, useRef, useCallback } from 'react'; import { refreshMenus } from '@/lib/utils/menuRefresh'; // 토큰 갱신 신호 쿠키 이름 const TOKEN_REFRESHED_COOKIE = 'token_refreshed_at'; /** * 쿠키에서 값 읽기 */ function getCookieValue(name: string): string | null { if (typeof document === 'undefined') return null; const match = document.cookie.match(new RegExp(`(^| )${name}=([^;]+)`)); return match ? match[2] : null; } /** * 쿠키 삭제 */ function deleteCookie(name: string): void { if (typeof document === 'undefined') return; document.cookie = `${name}=; Path=/; Max-Age=0`; } // 기본 폴링 간격: 30초 const DEFAULT_POLLING_INTERVAL = 30 * 1000; // 최소 폴링 간격: 10초 (서버 부하 방지) const MIN_POLLING_INTERVAL = 10 * 1000; // 최대 폴링 간격: 5분 const MAX_POLLING_INTERVAL = 5 * 60 * 1000; // 세션 만료 판단 기준: 연속 401 횟수 const MAX_SESSION_EXPIRED_COUNT = 3; interface UseMenuPollingOptions { /** 폴링 활성화 여부 (기본: true) */ enabled?: boolean; /** 폴링 간격 (ms, 기본: 30초) */ interval?: number; /** 메뉴 갱신 시 콜백 */ onMenuUpdated?: () => void; /** 에러 발생 시 콜백 */ onError?: (error: string) => void; /** 세션 만료로 폴링 중지 시 콜백 */ onSessionExpired?: () => void; } interface UseMenuPollingReturn { /** 수동으로 메뉴 갱신 실행 */ refresh: () => Promise; /** 폴링 일시 중지 */ pause: () => void; /** 폴링 재개 */ resume: () => void; /** 인증 성공 후 폴링 재시작 (401 카운트 리셋) */ restartAfterAuth: () => void; /** 현재 폴링 상태 */ isPaused: boolean; /** 세션 만료로 중지된 상태 */ isSessionExpired: 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, onSessionExpired, } = options; // 폴링 간격 유효성 검사 const safeInterval = Math.max(MIN_POLLING_INTERVAL, Math.min(MAX_POLLING_INTERVAL, interval)); const intervalRef = useRef(null); const isPausedRef = useRef(false); const sessionExpiredCountRef = useRef(0); // 연속 401 카운트 const isSessionExpiredRef = useRef(false); // 세션 만료로 중지된 상태 // 콜백을 ref로 저장하여 인터벌 리셋 방지 // (인라인 콜백이 매 렌더마다 새 참조를 생성해도 인터벌에 영향 없음) const onMenuUpdatedRef = useRef(onMenuUpdated); const onErrorRef = useRef(onError); const onSessionExpiredRef = useRef(onSessionExpired); // 콜백 ref를 최신 값으로 동기화 useEffect(() => { onMenuUpdatedRef.current = onMenuUpdated; onErrorRef.current = onError; onSessionExpiredRef.current = onSessionExpired; }); // 폴링 중지 (내부용) const stopPolling = useCallback(() => { if (intervalRef.current) { clearInterval(intervalRef.current); intervalRef.current = null; } }, []); // 메뉴 갱신 실행 (의존성: stopPolling만 — 안정적) const executeRefresh = useCallback(async () => { if (isPausedRef.current || isSessionExpiredRef.current) return; const result = await refreshMenus(); // 성공 시 401 카운트 리셋 if (result.success) { sessionExpiredCountRef.current = 0; if (result.updated) { onMenuUpdatedRef.current?.(); } return; } // 401 세션 만료 응답 if (result.sessionExpired) { sessionExpiredCountRef.current += 1; console.log(`[Menu] 401 응답 (${sessionExpiredCountRef.current}/${MAX_SESSION_EXPIRED_COUNT})`); // 3회 연속 401 → 폴링 중지 if (sessionExpiredCountRef.current >= MAX_SESSION_EXPIRED_COUNT) { console.log('[Menu] 세션 만료로 폴링 중지'); isSessionExpiredRef.current = true; stopPolling(); onSessionExpiredRef.current?.(); } return; } // 기타 에러 (네트워크 등) → 401 카운트 리셋하지 않음 if (result.error) { onErrorRef.current?.(result.error); } }, [stopPolling]); // 수동 갱신 함수 const refresh = useCallback(async () => { await executeRefresh(); }, [executeRefresh]); // 폴링 일시 중지 const pause = useCallback(() => { isPausedRef.current = true; stopPolling(); }, [stopPolling]); // 폴링 재개 const resume = useCallback(() => { // 세션 만료 상태면 재개 불가 (restartAfterAuth 사용) if (isSessionExpiredRef.current) { console.log('[Menu] 세션 만료 상태 - resume 불가, restartAfterAuth 사용 필요'); return; } isPausedRef.current = false; if (enabled && !intervalRef.current) { intervalRef.current = setInterval(executeRefresh, safeInterval); } }, [enabled, safeInterval, executeRefresh]); // 인증 성공 후 폴링 재시작 (401 카운트 리셋) const restartAfterAuth = useCallback(() => { console.log('[Menu] 인증 성공 - 폴링 재시작'); sessionExpiredCountRef.current = 0; isSessionExpiredRef.current = false; isPausedRef.current = false; if (enabled && !intervalRef.current) { // 즉시 한 번 실행 후 폴링 시작 executeRefresh(); intervalRef.current = setInterval(executeRefresh, safeInterval); } }, [enabled, safeInterval, executeRefresh]); // 폴링 설정 useEffect(() => { if (!enabled || isSessionExpiredRef.current) { stopPolling(); return; } // 페이지 로드 시 즉시 실행하지 않음 (로그인 시 이미 받아옴) // 폴링 시작 intervalRef.current = setInterval(executeRefresh, safeInterval); return () => { stopPolling(); }; }, [enabled, safeInterval, executeRefresh, stopPolling]); // 탭 가시성 변경 시 처리 useEffect(() => { if (!enabled) return; const handleVisibilityChange = () => { // 세션 만료 상태면 무시 if (isSessionExpiredRef.current) return; if (document.hidden) { // 탭이 숨겨지면 폴링 중지 (리소스 절약) stopPolling(); } else { // 탭이 다시 보이면 즉시 갱신 후 폴링 재개 if (!isPausedRef.current) { executeRefresh(); intervalRef.current = setInterval(executeRefresh, safeInterval); } } }; document.addEventListener('visibilitychange', handleVisibilityChange); return () => { document.removeEventListener('visibilitychange', handleVisibilityChange); }; }, [enabled, safeInterval, executeRefresh, stopPolling]); // 🔔 토큰 갱신 신호 쿠키 감지 → 폴링 자동 재시작 // 서버에서 토큰 갱신 시 설정한 쿠키를 감지하여 폴링을 재시작 useEffect(() => { if (!enabled) return; // 마지막으로 처리한 토큰 갱신 타임스탬프 추적 let lastProcessedTimestamp: string | null = null; const checkTokenRefresh = () => { const refreshedAt = getCookieValue(TOKEN_REFRESHED_COOKIE); // 새로운 토큰 갱신 감지 if (refreshedAt && refreshedAt !== lastProcessedTimestamp) { lastProcessedTimestamp = refreshedAt; // 세션 만료 상태였다면 폴링 재시작 if (isSessionExpiredRef.current) { console.log('[Menu] 🔔 토큰 갱신 감지 - 폴링 재시작'); sessionExpiredCountRef.current = 0; isSessionExpiredRef.current = false; isPausedRef.current = false; // 폴링 재시작 if (!intervalRef.current) { executeRefresh(); intervalRef.current = setInterval(executeRefresh, safeInterval); } } // 쿠키 삭제 (처리 완료) deleteCookie(TOKEN_REFRESHED_COOKIE); } }; // 1초마다 쿠키 확인 const checkInterval = setInterval(checkTokenRefresh, 1000); return () => { clearInterval(checkInterval); }; }, [enabled, safeInterval, executeRefresh]); return { refresh, pause, resume, restartAfterAuth, isPaused: isPausedRef.current, isSessionExpired: isSessionExpiredRef.current, }; } export default useMenuPolling;