diff --git a/src/features/rental/search/hook/useSearchPlacesHooks.ts b/src/features/rental/search/hook/useSearchPlacesHooks.ts index 7ebd0944..2cd9e0a2 100644 --- a/src/features/rental/search/hook/useSearchPlacesHooks.ts +++ b/src/features/rental/search/hook/useSearchPlacesHooks.ts @@ -23,22 +23,6 @@ const useDebounce = (value: string, delay: number) => { return debouncedValue; }; -// 스로틀링 훅 -const useThrottle = (callback: (...args: T) => void, delay: number) => { - const lastRunRef = useRef(0); - - return useCallback( - (...args: T) => { - const now = Date.now(); - if (now - lastRunRef.current >= delay) { - lastRunRef.current = now; - callback(...args); - } - }, - [callback, delay], - ); -}; - // 키워드 검색 훅 export const useSearchPlaces = () => { const [keyword, setKeyword] = useState(''); @@ -48,9 +32,15 @@ export const useSearchPlaces = () => { const [hasNext, setHasNext] = useState(true); const [page, setPage] = useState(1); + // 같은 요청 (키워드, 페이지)이 중복으로 발생하는 것을 방지 가드 + const lastReqRef = useRef(null); + // 디바운스된 키워드 (500ms) - 공백 제거 const debouncedKeyword = useDebounce(keyword.trim(), 500); + // 이전 요청 취소용 컨트롤러 + const controllerRef = useRef(null); + // 검색 실행 함수 const performSearch = useCallback( async (searchKeyword: string, pageNum: number = 1, append: boolean = false) => { @@ -60,6 +50,20 @@ export const useSearchPlaces = () => { return; } + // StrictMode 재마운트/이펙트 재실행 시 같은 요청 스킵 + const reqKey = `${trimmedKeyword}::${pageNum}::${append ? 'append' : 'replace'}`; + if (lastReqRef.current === reqKey) { + return; + } + lastReqRef.current = reqKey; + + // 이전 요청 취소 + controllerRef.current?.abort(); + // 새 컨트롤러 생성 + const controller = new AbortController(); + controllerRef.current = controller; + + // API 호출 if (pageNum === 1) { setIsLoading(true); } else { @@ -71,6 +75,7 @@ export const useSearchPlaces = () => { keyword: trimmedKeyword, page: pageNum, size: 15, + signal: controller.signal, }; const results = await searchPlaces(params); @@ -83,13 +88,15 @@ export const useSearchPlaces = () => { setHasNext(results.length === 15 && pageNum < 3); } catch (error) { - console.error('검색 오류:', error); - if (!append) { - setSearchResults([]); - } - // API 호출 제한 에러인 경우 사용자에게 알림 - if (error instanceof Error && error.message.includes('API 호출 제한')) { - console.warn('API 호출 제한으로 인해 검색이 일시적으로 중단되었습니다.'); + if ((error as Error)?.name !== 'AbortError') { + console.error('검색 오류:', error); + if (!append) { + setSearchResults([]); + } + // API 호출 제한 에러인 경우 사용자에게 알림 + if (error instanceof Error && error.message.includes('API 호출 제한')) { + console.warn('API 호출 제한으로 인해 검색이 일시적으로 중단되었습니다.'); + } } } finally { setIsLoading(false); @@ -99,19 +106,22 @@ export const useSearchPlaces = () => { [], ); - // 스로틀링된 검색 함수 (300ms) - const throttledSearch = useThrottle(performSearch, 300); - // 디바운스된 키워드가 변경될 때 검색 실행 useEffect(() => { - if (debouncedKeyword) { - setPage(1); - setHasNext(true); - throttledSearch(debouncedKeyword, 1, false); - } else { + if (!debouncedKeyword) { setSearchResults([]); + // 현재 진행 중인 요청이 있으면 취소 + controllerRef.current?.abort(); + return; } - }, [debouncedKeyword, throttledSearch]); + setPage(1); + setHasNext(true); + performSearch(debouncedKeyword, 1, false); + // 이 이펙트가 재실행/언마운트 될 때 진행 중 요청 취소 + return () => { + controllerRef.current?.abort(); + }; + }, [debouncedKeyword, performSearch]); // 다음 페이지 로드 함수 const loadNextPage = useCallback(() => { diff --git a/src/features/rental/search/hook/useSearchPosHooks.ts b/src/features/rental/search/hook/useSearchPosHooks.ts index 90caecae..2037eb1f 100644 --- a/src/features/rental/search/hook/useSearchPosHooks.ts +++ b/src/features/rental/search/hook/useSearchPosHooks.ts @@ -49,28 +49,38 @@ export const useSearchPos = () => { loadNextPage, } = useSearchPlaces(); - // 검색 결과 선택 시 호출되는 함수 - const handleSelectPlace = useCallback( - (place: PlaceSearchResult) => { - createAddressMutation.mutate(place, { - onSuccess: () => { - setTimeout(() => { - queryClient.invalidateQueries({ queryKey: ['addressHistory', 5, sort] }); - refetch(); - }, 500); - }, - }); + // 주소 이력 쿼리 키 + const addressHistoryKey = (limit: number, sort: string /*, userId?: string */) => + ['addressHistory', limit, sort /*, userId */] as const; - const searchParams = new URLSearchParams({ - lat: place.y.toString(), - lng: place.x.toString(), - address: place.road_address_name || place.address_name, - placeName: place.place_name, - }); + const LIMIT = 5; - router.push(`/rental?${searchParams.toString()}`); + // 장소 선택 시 호출되는 함수 + const handleSelectPlace = useCallback( + async (place: PlaceSearchResult) => { + try { + // 1) 주소 생성(저장) + await createAddressMutation.mutateAsync(place); + + // 2) 캐시 무효화 + 즉시 재패치 (타임아웃 제거) + const key = addressHistoryKey(LIMIT, sort); + await queryClient.invalidateQueries({ queryKey: key }); + await queryClient.refetchQueries({ queryKey: key }); + + // 3) 라우팅 + const searchParams = new URLSearchParams({ + lat: String(place.y), // Kakao: y=lat, x=lng + lng: String(place.x), + address: place.road_address_name || place.address_name, + placeName: place.place_name, + }); + router.push(`/rental?${searchParams.toString()}`); + } catch (e) { + // 필요 시 토스트/로그 + console.error(e); + } }, - [createAddressMutation, refetch, sort, queryClient, router], + [createAddressMutation, queryClient, sort, router], ); // 주소 이력 클릭 시 호출되는 함수 @@ -182,17 +192,14 @@ export const useSearchPos = () => { } }, [throttledHandleScroll]); - // 주소 이력 존재 여부 + // 주소 이력 존재 여부 (실제 내용이 바뀔 때만 재계산) const hasAddressHistory = useMemo(() => { - return ( - Array.isArray(addressHistoryInfinite?.pages) && - addressHistoryInfinite.pages.some( - (page) => - Array.isArray(page?.content?.getAddressResponses) && - page.content.getAddressResponses.length > 0, - ) + if (!addressHistoryInfinite?.pages) return false; + + return addressHistoryInfinite.pages.some( + (page) => page?.content?.getAddressResponses?.length > 0, ); - }, [addressHistoryInfinite?.pages]); + }, [addressHistoryInfinite]); return { // 상태 diff --git a/src/features/rental/search/utils/address/searchPlaces.ts b/src/features/rental/search/utils/address/searchPlaces.ts index 2322f19f..f8ed9dd2 100644 --- a/src/features/rental/search/utils/address/searchPlaces.ts +++ b/src/features/rental/search/utils/address/searchPlaces.ts @@ -14,11 +14,12 @@ export interface SearchPlacesParams { keyword: string; page?: number; size?: number; + signal?: AbortSignal; } // 키워드 검색 함수 (페이지네이션 지원) export const searchPlaces = async (params: SearchPlacesParams): Promise => { - const { keyword, page = 1, size = 15 } = params; + const { keyword, page = 1, size = 15, signal } = params; if (!keyword.trim()) { return []; @@ -31,6 +32,7 @@ export const searchPlaces = async (params: SearchPlacesParams): Promise