diff --git a/apps/web/app/_components/SearchPage/SearchPage.tsx b/apps/web/app/_components/SearchPage/SearchPage.tsx index bd54d06..ba0a15b 100644 --- a/apps/web/app/_components/SearchPage/SearchPage.tsx +++ b/apps/web/app/_components/SearchPage/SearchPage.tsx @@ -4,15 +4,17 @@ import { Icon } from '@repo/ui/components/Icon' import { Flex, VerticalScrollArea } from '@repo/ui/components/Layout' import { SearchPlaceListItem } from './SearchPlaceListItem' import { HeaderBackButton } from '@/_components/HeaderBackButton' +import { useDebouncedFetch } from '@/_hooks/useDebouncedFetch' + +export type BasePlace = { + id: string + name: string + address: string +} export type Props = { placeholder?: string - places: { - id: string - name: string - address: string - }[] - searchFunc: (inputValue: string) => void + searchFunc: (inputValue: string) => Promise onSelectPlace: (id: string) => void useBackHandler?: boolean } @@ -26,7 +28,6 @@ export type Props = { * - useBackHandler가 true면 헤더에 뒤로가기 버튼, false면 검색 아이콘 표시 * * @param placeholder 검색 input의 placeholder - * @param places 검색 결과 장소 리스트 * @param searchFunc 검색 함수 (input 변경 시 호출) * @param onSelectPlace 리스트 아이템 선택 시 호출 * @param useBackHandler 헤더에 뒤로가기 버튼 사용 여부 @@ -34,33 +35,32 @@ export type Props = { * @example * console.log(id)} * useBackHandler={true} * /> */ export const SearchPage = ({ - placeholder, - places, + placeholder = '장소 또는 주소를 검색하세요', searchFunc, onSelectPlace, useBackHandler = false, }: Props) => { + const [places, setPlaces] = useDebouncedFetch(searchFunc) const [inputValue, setInputValue] = useState('') - const [isLoading, setIsLoading] = useState(false) + const [isSelecting, setIsSelecting] = useState(false) const handleInputChange = (e: React.ChangeEvent) => { const value = e.target.value setInputValue(value) if (value.length > 0) { - searchFunc(value) + setPlaces(value) } } return ( <> - {isLoading && ( + {isSelecting && ( )} @@ -73,7 +73,7 @@ export const SearchPage = ({ value={inputValue} onChange={handleInputChange} className={'w-full text-lg font-medium outline-none'} - placeholder={placeholder || '장소 또는 주소를 검색하세요'} + placeholder={placeholder} /> {inputValue && ( @@ -84,7 +84,7 @@ export const SearchPage = ({ inputValue={inputValue} place={place} onClick={() => { - setIsLoading(true) + setIsSelecting(true) onSelectPlace(place.id) }} /> diff --git a/apps/web/app/_hooks/useDebouncedFetch.ts b/apps/web/app/_hooks/useDebouncedFetch.ts new file mode 100644 index 0000000..69810b8 --- /dev/null +++ b/apps/web/app/_hooks/useDebouncedFetch.ts @@ -0,0 +1,54 @@ +import { useCallback, useEffect, useRef, useState } from 'react' + +/** + * 비동기 함수(fetcher)를 디바운싱하여 실행하고, 그 결과를 상태로 관리하는 훅 + * + * - 입력이 멈춘 후 일정 시간(delay) 뒤에 API를 호출합니다. + * - 검색어 자동완성, 필터링 등 잦은 요청을 방지해야 할 때 유용합니다. + * + * @template T 결과 데이터의 타입 + * @template P 파라미터의 타입 + * + * @param fetcher 데이터를 가져오는 비동기 함수 (Promise 반환) + * @param delay 디바운스 지연 시간 (ms, 기본값: 300ms) + * + * @returns data - 비동기 작업의 결과 데이터 (초기값: []) + * @returns trigger - 디바운스가 적용된 실행 함수 + * + * @example + * const { data: places, trigger: searchPlaces } = useDebouncedFetch(getSearchPlaceByKakao, 500); + * // searchPlaces('강남역') 호출 시 500ms 후 API 호출 -> places 업데이트 + */ +export const useDebouncedFetch = ( + fetcher: (params: P) => Promise, + delay: number = 300, +) => { + const [data, setData] = useState([]) + const timeoutRef = useRef | null>(null) + + const trigger = useCallback( + (params: P) => { + if (timeoutRef.current) clearTimeout(timeoutRef.current) + + timeoutRef.current = setTimeout(async () => { + try { + const result = await fetcher(params) + setData(result) + } catch (error) { + console.error('Debounced fetch failed:', error) + setData([]) + } + }, delay) + }, + [fetcher, delay], + ) + + // 언마운트 시 타이머 클리어 + useEffect(() => { + return () => { + if (timeoutRef.current) clearTimeout(timeoutRef.current) + } + }, []) + + return [data, trigger] as const +} diff --git a/apps/web/app/_hooks/useSearch.ts b/apps/web/app/_hooks/useSearch.ts deleted file mode 100644 index c2d1aa9..0000000 --- a/apps/web/app/_hooks/useSearch.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { useCallback, useEffect, useRef, useState } from 'react' - -/** - * - 입력된 query를 기준으로 장소 검색 API 호출 - * - 300ms 디바운싱 적용 (빠른 입력 시 불필요한 요청 방지) - * - API 결과를 상태로 관리하여 컴포넌트에서 바로 사용 가능 - * - * @example - * const { searchResult: cafeResult, searchFunc: cafeSearchFunc } = useSearch< - * SearchPlace, - * KakaoSearchFuncParams - * >(getSearchPlaceByKakao) - * - * - * @template T 검색 결과 아이템의 타입 (예: SearchPlace) - * @template P 검색 함수에 들어갈 파라미터 타입 (예: KakaoSearchFuncParams) - * @param fetcher Promise를 반환하는 비동기 검색 함수 - * - * @returns searchResult - 검색된 장소 리스트 - * @returns searchFunc - 검색을 수행하는 함수 - */ - -export const useSearch = (fetcher: (params: P) => Promise) => { - const [searchResult, setSearchResult] = useState([]) - const timeoutRef = useRef | null>(null) - - const searchFunc = useCallback( - (params: P) => { - if (timeoutRef.current) clearTimeout(timeoutRef.current) - - timeoutRef.current = setTimeout(async () => { - try { - // 주입받은 fetcher 실행 - const result = await fetcher(params) - setSearchResult(result) - } catch (error) { - console.error('Search failed:', error) - setSearchResult([]) - } - }, 300) - }, - [fetcher], // fetcher가 변경되면 함수 재생성 - ) - - // 언마운트 시 타이머 클리어 - useEffect(() => { - return () => { - if (timeoutRef.current) clearTimeout(timeoutRef.current) - } - }, []) - - return { searchResult, searchFunc } -} diff --git a/apps/web/app/places/new/_components/Step/PlaceSearch/PlaceSearch.tsx b/apps/web/app/places/new/_components/Step/PlaceSearch/PlaceSearch.tsx index df38405..4eaca47 100644 --- a/apps/web/app/places/new/_components/Step/PlaceSearch/PlaceSearch.tsx +++ b/apps/web/app/places/new/_components/Step/PlaceSearch/PlaceSearch.tsx @@ -1,6 +1,5 @@ import { useCallback } from 'react' import { SearchPage } from '@/_components/SearchPage' -import { useSearch } from '@/_hooks/useSearch' import type { UseFormSetValue } from 'react-hook-form' import type { NewPlaceRequest } from '@/_apis/schemas/place' import { type CampusType, CAMPUS_LOCATION } from '@/_constants/campus' @@ -13,27 +12,26 @@ type Props = { } export const PlaceSearch = ({ campus, setValue, nextStep }: Props) => { - const { searchResult, searchFunc } = useSearch(searchCafeAndRestaurant) - - const places = [...searchResult].map((item) => ({ - id: item.id, - name: item.place_name, - address: item.address_name, - })) - const handleSearch = useCallback( - (query: string) => { + async (query: string) => { const { longitude: x, latitude: y } = CAMPUS_LOCATION[campus] - searchFunc({ query, location: { x, y } }) + const result = await searchCafeAndRestaurant({ + query, + location: { x, y }, + }) + return result.map((item) => ({ + id: item.id, + name: item.place_name, + address: item.address_name, + })) }, - [campus, searchFunc], + [campus], ) return ( { + onSelectPlace={(id: string) => { setValue('kakaoPlaceId', id) nextStep() }} diff --git a/apps/web/app/places/search/page.tsx b/apps/web/app/places/search/page.tsx index fb07d96..d4349f3 100644 --- a/apps/web/app/places/search/page.tsx +++ b/apps/web/app/places/search/page.tsx @@ -3,29 +3,27 @@ import { useRouter } from 'next/navigation' import { SearchPage } from '@/_components/SearchPage' import { getPlacesBySearch } from '@/_apis/services/place' -import { useSearch } from '@/_hooks/useSearch' import { CLIENT_PATH } from '@/_constants/path' -import type { PlaceBySearch } from '@/_apis/schemas/place' const Page = () => { const { replace } = useRouter() - const { searchResult, searchFunc } = useSearch( - getPlacesBySearch, - ) - const newPlaces = searchResult.map((place) => ({ - id: place.placeId, - name: place.placeName, - address: place.address, - })) + const handleSearch = async (query: string) => { + const result = await getPlacesBySearch(query) + return result.map((place) => ({ + id: place.placeId, + name: place.placeName, + address: place.address, + })) + } return ( { replace(CLIENT_PATH.PLACE_DETAIL(id)) }} - places={newPlaces} /> ) }