From 14186d98c05058b2ce3b6984432c62e873f802f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Thu, 22 Jan 2026 19:51:24 +0900 Subject: [PATCH] =?UTF-8?q?fix(WEB):=20=EC=95=8C=EB=A6=BC=20=ED=8F=B4?= =?UTF-8?q?=EB=A7=81=20=EC=8B=9C=20=EC=9D=B8=EC=A6=9D=20=EC=97=90=EB=9F=AC?= =?UTF-8?q?=20=EC=B2=98=EB=A6=AC=20=EB=B0=8F=20=ED=86=A0=ED=81=B0=20?= =?UTF-8?q?=EC=9E=90=EB=8F=99=20=EA=B0=B1=EC=8B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ApiResponse 타입에 authError 플래그 추가 - today-issue.ts에 인증 에러 감지 로직 추가 - AuthenticatedLayout에서 authError 시 토큰 갱신 후 재시도 - 토큰 갱신 실패 시 폴링 중지하여 반복 에러 방지 --- src/layouts/AuthenticatedLayout.tsx | 78 +++++++++++++++++++++++++++-- src/lib/api/today-issue.ts | 28 ++++++++++- src/types/today-issue.ts | 2 + 3 files changed, 102 insertions(+), 6 deletions(-) diff --git a/src/layouts/AuthenticatedLayout.tsx b/src/layouts/AuthenticatedLayout.tsx index 29d8623f..f4df1dbb 100644 --- a/src/layouts/AuthenticatedLayout.tsx +++ b/src/layouts/AuthenticatedLayout.tsx @@ -101,6 +101,7 @@ export default function AuthenticatedLayout({ children }: AuthenticatedLayoutPro const [notificationEnabled, setNotificationEnabled] = useState(true); const [notifications, setNotifications] = useState([]); const [unreadCount, setUnreadCount] = useState(0); + const [isPollingPaused, setIsPollingPaused] = useState(false); // 토큰 갱신 중 폴링 일시 중지 // 알림 벨 애니메이션 (알림 켜져 있고 읽지 않은 알림이 있을 때만) const bellAnimating = useMemo(() => notificationEnabled && unreadCount > 0, [notificationEnabled, unreadCount]); @@ -113,18 +114,81 @@ export default function AuthenticatedLayout({ children }: AuthenticatedLayoutPro setIsMounted(true); }, []); + // 토큰 갱신 함수 + const refreshToken = useCallback(async (): Promise => { + try { + console.log('[Notification] 토큰 갱신 시도...'); + const response = await fetch('/api/auth/refresh', { + method: 'POST', + credentials: 'include', + }); + + if (response.ok) { + console.log('[Notification] 토큰 갱신 성공'); + return true; + } else { + console.warn('[Notification] 토큰 갱신 실패:', response.status); + return false; + } + } catch (error) { + console.error('[Notification] 토큰 갱신 오류:', error); + return false; + } + }, []); + // 알림 데이터 가져오기 함수 - const fetchNotifications = useCallback(async () => { + const fetchNotifications = useCallback(async (isRetry: boolean = false) => { + // 폴링이 일시 중지 상태면 건너뛰기 + if (isPollingPaused && !isRetry) { + return; + } + try { const response = await getUnreadTodayIssues(10); + + // 인증 에러인 경우 + if (response.authError) { + console.warn('[Notification] 인증 에러 감지 - 토큰 갱신 시도'); + + // 이미 재시도 중이면 무한 루프 방지 + if (isRetry) { + console.error('[Notification] 토큰 갱신 후에도 인증 에러 - 폴링 중지'); + setIsPollingPaused(true); + return; + } + + // 폴링 일시 중지 + setIsPollingPaused(true); + + // 토큰 갱신 시도 + const refreshSuccess = await refreshToken(); + + if (refreshSuccess) { + // 토큰 갱신 성공 - 폴링 재개 및 재시도 + setIsPollingPaused(false); + await fetchNotifications(true); // 재시도 플래그로 호출 + } else { + // 토큰 갱신 실패 - 폴링 유지 중지 (로그인 필요) + console.error('[Notification] 토큰 갱신 실패 - 폴링 중지, 재로그인 필요'); + // 여기서 로그아웃하거나 알림을 보여줄 수 있음 + } + return; + } + + // 정상 응답 if (response.success && response.data) { setNotifications(response.data.items); setUnreadCount(response.data.total); + + // 혹시 이전에 폴링이 중지되어 있었다면 재개 + if (isPollingPaused) { + setIsPollingPaused(false); + } } } catch (error) { console.error('[Notification] 알림 조회 실패:', error); } - }, []); + }, [isPollingPaused, refreshToken]); // 알림 폴링 (30초마다) useEffect(() => { @@ -133,13 +197,17 @@ export default function AuthenticatedLayout({ children }: AuthenticatedLayoutPro // 초기 로드 fetchNotifications(); - // 폴링 시작 - const intervalId = setInterval(fetchNotifications, NOTIFICATION_POLLING_INTERVAL); + // 폴링 시작 (폴링이 중지되지 않은 경우에만) + const intervalId = setInterval(() => { + if (!isPollingPaused) { + fetchNotifications(); + } + }, NOTIFICATION_POLLING_INTERVAL); return () => { clearInterval(intervalId); }; - }, [isMounted, fetchNotifications]); + }, [isMounted, fetchNotifications, isPollingPaused]); // 알림 클릭 핸들러 (읽음 처리 + 페이지 이동) const handleNotificationClick = useCallback(async (notification: TodayIssueUnreadItem) => { diff --git a/src/lib/api/today-issue.ts b/src/lib/api/today-issue.ts index d6e781dd..b50ec98f 100644 --- a/src/lib/api/today-issue.ts +++ b/src/lib/api/today-issue.ts @@ -6,6 +6,7 @@ */ import { apiClient } from './index'; +import { AuthError } from './errors'; import type { ApiResponse, TodayIssueUnreadResponse, @@ -13,6 +14,20 @@ import type { TodayIssueMarkAllReadResponse, } from '@/types/today-issue'; +/** + * 인증 에러인지 확인 + */ +function isAuthenticationError(error: unknown): boolean { + if (error instanceof AuthError) return true; + if (error && typeof error === 'object') { + const err = error as { status?: number; code?: string; message?: string }; + if (err.status === 401) return true; + if (err.code === 'AUTH_ERROR') return true; + if (err.message?.includes('회원정보') || err.message?.includes('인증')) return true; + } + return false; +} + /** * 읽지 않은 이슈 목록 조회 (헤더 알림용) * @param limit 조회할 최대 항목 수 (기본 10) @@ -26,7 +41,18 @@ export async function getUnreadTodayIssues(limit: number = 10): Promise { success: boolean; message: string; data: T; + /** 인증 에러 여부 (토큰 만료 등) */ + authError?: boolean; } \ No newline at end of file