diff --git a/frontend/src/apis/chat/chat.queries.ts b/frontend/src/apis/chat/chat.queries.ts index 824dd9e..0c996dc 100644 --- a/frontend/src/apis/chat/chat.queries.ts +++ b/frontend/src/apis/chat/chat.queries.ts @@ -1,9 +1,17 @@ // 채팅 도메인의 조회(useQuery) 훅. import { useQuery } from '@tanstack/react-query' +import { ErrorCode, isApiError } from '@/apis/types' import { chatApi } from './chat.api' import { chatKeys } from './chat.keys' import { mapInit, mapMessage } from './chat.type' +// 재시도해도 결과가 바뀌지 않는 클라이언트 에러(권한없음/없는 세션) → 즉시 실패시켜 목록 복귀를 지연 없이 처리. +const NON_RETRYABLE = new Set([ErrorCode.NEGO_FORBIDDEN, ErrorCode.NEGO_NOT_FOUND]) +function retryQuery(failureCount: number, error: unknown): boolean { + if (isApiError(error) && NON_RETRYABLE.has(error.code)) return false + return failureCount < 3 +} + /** 채팅 진입 메타(상품·견적). 마감 시각은 거의 불변이라 오래 캐싱한다. */ export function useChatInitQuery(sessionId: string) { return useQuery({ @@ -11,6 +19,7 @@ export function useChatInitQuery(sessionId: string) { queryFn: () => chatApi.getInit(sessionId).then(mapInit), enabled: !!sessionId, staleTime: 5 * 60 * 1000, + retry: retryQuery, }) } @@ -25,5 +34,6 @@ export function useChatMessagesQuery(sessionId: string) { enabled: !!sessionId, staleTime: Infinity, refetchOnWindowFocus: false, + retry: retryQuery, }) } diff --git a/frontend/src/features/chat/hooks/useChatController.ts b/frontend/src/features/chat/hooks/useChatController.ts index 88a183a..fd18d01 100644 --- a/frontend/src/features/chat/hooks/useChatController.ts +++ b/frontend/src/features/chat/hooks/useChatController.ts @@ -1,4 +1,4 @@ -import { useEffect } from 'react' +import { useEffect, useRef } from 'react' import { useNavigate } from 'react-router' import { useQueryClient } from '@tanstack/react-query' import { @@ -23,6 +23,10 @@ const TERMINAL_CODES = new Set([ ErrorCode.NEGO_NOT_FOUND, ]) +// 진입 로드(init/messages) 시 '잘못된 접근'으로 볼 코드 → 권한 없음 / 없는(또는 남의) 세션. +// 토스트 안내 후 목록으로 복귀시킨다. (로그인은 유효하므로 로그아웃하지 않는다 — 인증≠인가) +const INVALID_ACCESS_CODES = new Set([ErrorCode.NEGO_FORBIDDEN, ErrorCode.NEGO_NOT_FOUND]) + // 화면이 보는 대화 상태가 서버와 어긋났을 수 있는 코드 → 서버 기준으로 메시지를 다시 불러와 리싱크. const RESYNC_CODES = new Set([ ErrorCode.CHAT_INPUT_MODE_MISMATCH, // 보낸 입력이 직전 봇이 요구한 모드와 불일치 @@ -64,6 +68,17 @@ export function useChatController(sessionId: string) { // staleTime:Infinity 인 메시지 쿼리는 재조회되지 않아 스토어가 빈 채로 남아 채팅이 사라진다. const { mutate: sendMutate } = useChatSendMutation(sessionId) + // 진입 로드 실패. 권한 없음/없는 세션(잘못된 접근)이면 토스트 후 목록으로 복귀시킨다. + const loadError = initQuery.error ?? messagesQuery.error + const isInvalidAccess = isApiError(loadError) && INVALID_ACCESS_CODES.has(loadError.code) + const redirectedRef = useRef(false) + useEffect(() => { + if (!isInvalidAccess || redirectedRef.current) return + redirectedRef.current = true + toast.error('잘못된 접근입니다.') + navigate('/list', { replace: true }) + }, [isInvalidAccess, navigate]) + // init 메타 → 스토어 useEffect(() => { if (initQuery.data) setInitData(initQuery.data) @@ -130,8 +145,10 @@ export function useChatController(sessionId: string) { }, [sessionId, sendMutate, navigate, queryClient]) return { - isInitLoading: initQuery.isLoading || messagesQuery.isLoading, - initError: initQuery.error ?? messagesQuery.error, + // 잘못된 접근이면 목록으로 리다이렉트되는 동안 스피너를 유지(ErrorPage 깜빡임 방지). + isInitLoading: initQuery.isLoading || messagesQuery.isLoading || isInvalidAccess, + // 잘못된 접근은 토스트+리다이렉트로 처리하므로 ErrorPage(재시도)로 노출하지 않는다. + initError: isInvalidAccess ? null : loadError, // 로드 실패(서버/네트워크) 시 init·messages 를 함께 재조회한다. refetchInit: () => { void initQuery.refetch()