fix(WEB): 알림 폴링 시 인증 에러 처리 및 토큰 자동 갱신

- ApiResponse 타입에 authError 플래그 추가
- today-issue.ts에 인증 에러 감지 로직 추가
- AuthenticatedLayout에서 authError 시 토큰 갱신 후 재시도
- 토큰 갱신 실패 시 폴링 중지하여 반복 에러 방지
This commit is contained in:
2026-01-22 19:51:24 +09:00
parent 390c1a8450
commit 14186d98c0
3 changed files with 102 additions and 6 deletions

View File

@@ -101,6 +101,7 @@ export default function AuthenticatedLayout({ children }: AuthenticatedLayoutPro
const [notificationEnabled, setNotificationEnabled] = useState(true);
const [notifications, setNotifications] = useState<TodayIssueUnreadItem[]>([]);
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<boolean> => {
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) => {

View File

@@ -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<ApiRespo
return response;
} catch (error) {
console.error('[TodayIssue] getUnreadTodayIssues error:', error);
// 에러 시 빈 응답 반환 (UI에서 처리)
// 인증 에러인 경우 authError 플래그 설정
if (isAuthenticationError(error)) {
return {
success: false,
message: '인증이 만료되었습니다.',
data: { items: [], total: 0 },
authError: true,
};
}
// 일반 에러
return {
success: false,
message: '알림을 불러오는데 실패했습니다.',

View File

@@ -68,4 +68,6 @@ export interface ApiResponse<T> {
success: boolean;
message: string;
data: T;
/** 인증 에러 여부 (토큰 만료 등) */
authError?: boolean;
}