fix(WEB): 알림 폴링 시 인증 에러 처리 및 토큰 자동 갱신
- ApiResponse 타입에 authError 플래그 추가 - today-issue.ts에 인증 에러 감지 로직 추가 - AuthenticatedLayout에서 authError 시 토큰 갱신 후 재시도 - 토큰 갱신 실패 시 폴링 중지하여 반복 에러 방지
This commit is contained in:
@@ -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) => {
|
||||
|
||||
@@ -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: '알림을 불러오는데 실패했습니다.',
|
||||
|
||||
@@ -68,4 +68,6 @@ export interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
message: string;
|
||||
data: T;
|
||||
/** 인증 에러 여부 (토큰 만료 등) */
|
||||
authError?: boolean;
|
||||
}
|
||||
Reference in New Issue
Block a user