feat(WEB): 헤더 바로가기 버튼 추가 및 종합분석 목데이터 적용
- 공용 헤더에 종합분석/품질인정심사 바로가기 버튼 추가 (데스크톱/모바일) - 종합분석 페이지 목데이터 적용 (API 호출 비활성화) - 로그인 페이지 기본 계정 설정 - QMS 필터/모달 컴포넌트 개선 - 메뉴 폴링 및 fetch-wrapper 유틸리티 개선 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,8 @@
|
||||
* 메뉴 폴링 훅
|
||||
*
|
||||
* 일정 간격으로 메뉴 변경사항을 확인하고 자동 갱신합니다.
|
||||
* 401 응답이 3회 연속 발생하면 세션 만료로 판단하고 폴링을 중지합니다.
|
||||
* 토큰 갱신 시 자동으로 폴링을 재시작합니다.
|
||||
*
|
||||
* @see claudedocs/architecture/[PLAN-2025-12-29] dynamic-menu-refresh.md
|
||||
*/
|
||||
@@ -9,6 +11,26 @@
|
||||
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;
|
||||
|
||||
@@ -18,6 +40,9 @@ 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;
|
||||
@@ -27,6 +52,8 @@ interface UseMenuPollingOptions {
|
||||
onMenuUpdated?: () => void;
|
||||
/** 에러 발생 시 콜백 */
|
||||
onError?: (error: string) => void;
|
||||
/** 세션 만료로 폴링 중지 시 콜백 */
|
||||
onSessionExpired?: () => void;
|
||||
}
|
||||
|
||||
interface UseMenuPollingReturn {
|
||||
@@ -36,8 +63,12 @@ interface UseMenuPollingReturn {
|
||||
pause: () => void;
|
||||
/** 폴링 재개 */
|
||||
resume: () => void;
|
||||
/** 인증 성공 후 폴링 재시작 (401 카운트 리셋) */
|
||||
restartAfterAuth: () => void;
|
||||
/** 현재 폴링 상태 */
|
||||
isPaused: boolean;
|
||||
/** 세션 만료로 중지된 상태 */
|
||||
isSessionExpired: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -64,6 +95,7 @@ export function useMenuPolling(options: UseMenuPollingOptions = {}): UseMenuPoll
|
||||
interval = DEFAULT_POLLING_INTERVAL,
|
||||
onMenuUpdated,
|
||||
onError,
|
||||
onSessionExpired,
|
||||
} = options;
|
||||
|
||||
// 폴링 간격 유효성 검사
|
||||
@@ -71,21 +103,53 @@ export function useMenuPolling(options: UseMenuPollingOptions = {}): UseMenuPoll
|
||||
|
||||
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const isPausedRef = useRef(false);
|
||||
const sessionExpiredCountRef = useRef(0); // 연속 401 카운트
|
||||
const isSessionExpiredRef = useRef(false); // 세션 만료로 중지된 상태
|
||||
|
||||
// 폴링 중지 (내부용)
|
||||
const stopPolling = useCallback(() => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 메뉴 갱신 실행
|
||||
const executeRefresh = useCallback(async () => {
|
||||
if (isPausedRef.current) return;
|
||||
if (isPausedRef.current || isSessionExpiredRef.current) return;
|
||||
|
||||
const result = await refreshMenus();
|
||||
|
||||
if (result.success && result.updated) {
|
||||
onMenuUpdated?.();
|
||||
// 성공 시 401 카운트 리셋
|
||||
if (result.success) {
|
||||
sessionExpiredCountRef.current = 0;
|
||||
|
||||
if (result.updated) {
|
||||
onMenuUpdated?.();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!result.success && result.error) {
|
||||
// 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();
|
||||
onSessionExpired?.();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 기타 에러 (네트워크 등) → 401 카운트 리셋하지 않음
|
||||
if (result.error) {
|
||||
onError?.(result.error);
|
||||
}
|
||||
}, [onMenuUpdated, onError]);
|
||||
}, [onMenuUpdated, onError, onSessionExpired, stopPolling]);
|
||||
|
||||
// 수동 갱신 함수
|
||||
const refresh = useCallback(async () => {
|
||||
@@ -95,27 +159,41 @@ export function useMenuPolling(options: UseMenuPollingOptions = {}): UseMenuPoll
|
||||
// 폴링 일시 중지
|
||||
const pause = useCallback(() => {
|
||||
isPausedRef.current = true;
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
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) {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
if (!enabled || isSessionExpiredRef.current) {
|
||||
stopPolling();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -124,24 +202,21 @@ export function useMenuPolling(options: UseMenuPollingOptions = {}): UseMenuPoll
|
||||
intervalRef.current = setInterval(executeRefresh, safeInterval);
|
||||
|
||||
return () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
stopPolling();
|
||||
};
|
||||
}, [enabled, safeInterval, executeRefresh]);
|
||||
}, [enabled, safeInterval, executeRefresh, stopPolling]);
|
||||
|
||||
// 탭 가시성 변경 시 처리
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
|
||||
const handleVisibilityChange = () => {
|
||||
// 세션 만료 상태면 무시
|
||||
if (isSessionExpiredRef.current) return;
|
||||
|
||||
if (document.hidden) {
|
||||
// 탭이 숨겨지면 폴링 중지 (리소스 절약)
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
stopPolling();
|
||||
} else {
|
||||
// 탭이 다시 보이면 즉시 갱신 후 폴링 재개
|
||||
if (!isPausedRef.current) {
|
||||
@@ -156,13 +231,57 @@ export function useMenuPolling(options: UseMenuPollingOptions = {}): UseMenuPoll
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user