Skip to content
74 changes: 42 additions & 32 deletions src/features/rental/search/hook/useSearchPlacesHooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,22 +23,6 @@ const useDebounce = (value: string, delay: number) => {
return debouncedValue;
};

// 스로틀링 훅
const useThrottle = <T extends unknown[]>(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('');
Expand All @@ -48,9 +32,15 @@ export const useSearchPlaces = () => {
const [hasNext, setHasNext] = useState(true);
const [page, setPage] = useState(1);

// 같은 요청 (키워드, 페이지)이 중복으로 발생하는 것을 방지 가드
const lastReqRef = useRef<string | null>(null);

// 디바운스된 키워드 (500ms) - 공백 제거
const debouncedKeyword = useDebounce(keyword.trim(), 500);

// 이전 요청 취소용 컨트롤러
const controllerRef = useRef<AbortController | null>(null);

// 검색 실행 함수
const performSearch = useCallback(
async (searchKeyword: string, pageNum: number = 1, append: boolean = false) => {
Expand All @@ -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 {
Expand All @@ -71,6 +75,7 @@ export const useSearchPlaces = () => {
keyword: trimmedKeyword,
page: pageNum,
size: 15,
signal: controller.signal,
};

const results = await searchPlaces(params);
Expand All @@ -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);
Expand All @@ -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(() => {
Expand Down
63 changes: 35 additions & 28 deletions src/features/rental/search/hook/useSearchPosHooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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],
);

// 주소 이력 클릭 시 호출되는 함수
Expand Down Expand Up @@ -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 {
// 상태
Expand Down
10 changes: 8 additions & 2 deletions src/features/rental/search/utils/address/searchPlaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,12 @@ export interface SearchPlacesParams {
keyword: string;
page?: number;
size?: number;
signal?: AbortSignal;
}

// 키워드 검색 함수 (페이지네이션 지원)
export const searchPlaces = async (params: SearchPlacesParams): Promise<PlaceSearchResult[]> => {
const { keyword, page = 1, size = 15 } = params;
const { keyword, page = 1, size = 15, signal } = params;

if (!keyword.trim()) {
return [];
Expand All @@ -31,6 +32,7 @@ export const searchPlaces = async (params: SearchPlacesParams): Promise<PlaceSea
headers: {
Authorization: `KakaoAK ${process.env.NEXT_PUBLIC_KAKAO_MAP_REST_API_KEY}`,
},
signal, //진행 중 요청을 취소할 수 있도록 signal 추가
},
);

Expand Down Expand Up @@ -67,7 +69,11 @@ export const searchPlaces = async (params: SearchPlacesParams): Promise<PlaceSea

return [];
} catch (error) {
if ((error as Error)?.name === 'AbortError') {
// 요청이 취소된 경우 (AbortError)에는 아무 작업도 하지 않음
throw error;
}
console.error('키워드 검색 오류:', error);
return [];
throw error;
}
};