-
Notifications
You must be signed in to change notification settings - Fork 1
Feat/#73 키워드 기반 등록된 맛집 검색 API 연결 및 useSearch 커스텀 훅 구현 #75
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
55a290c
4be3dbc
70a4d00
5b4c56c
49d4650
1e12a95
02bb124
b597cab
c628c5d
68b71af
564870e
c5eac60
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| export const KAKAO_CATEGORY_CODE = { | ||
| restaurant: 'FD6', | ||
| cafe: 'CE7', | ||
| } | ||
|
|
||
| export type KakaoSearchFuncParams = { | ||
| query: string | ||
| categoryCode: KakaoCategoryCode | ||
| location: { x: number; y: number } | ||
| } | ||
|
|
||
| export type SearchPlaceByKakao = { | ||
| id: string | ||
| place_name: string | ||
| x: string | ||
| y: string | ||
| road_address_name?: string | ||
| address_name: string | ||
| } | ||
|
|
||
| export type KakaoCategoryCode = keyof typeof KAKAO_CATEGORY_CODE |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,35 @@ | ||
| import axios from 'axios' | ||
| import { API_PATH } from '@/_constants/path' | ||
| import { | ||
| KAKAO_CATEGORY_CODE, | ||
| type KakaoSearchFuncParams, | ||
| type SearchPlaceByKakao, | ||
| } from '@/_apis/schemas/kakaoSearch' | ||
|
|
||
| export const getSearchPlaceByKakao = async ({ | ||
| query, | ||
| categoryCode, | ||
| location, | ||
| }: KakaoSearchFuncParams): Promise<SearchPlaceByKakao[]> => { | ||
| const KAKAO_API_KEY = process.env.NEXT_PUBLIC_KAKAO_API || '' | ||
| const { x, y } = location | ||
| try { | ||
| const { data } = await axios.get( | ||
| API_PATH.KAKAO.SEARCH(query, KAKAO_CATEGORY_CODE[categoryCode], x, y), | ||
| { | ||
| headers: { | ||
| Authorization: `KakaoAK ${KAKAO_API_KEY}`, | ||
| }, | ||
| }, | ||
| ) | ||
|
|
||
| if (!data?.documents || !Array.isArray(data.documents)) { | ||
| return [] | ||
| } | ||
|
|
||
| return data.documents | ||
| } catch (error) { | ||
| console.error('카카오 장소 검색 실패:', error) | ||
| throw error | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -1,4 +1,5 @@ | ||||||||||||||||||||||||||||||||||||
| import { useEffect, useState } from 'react' | ||||||||||||||||||||||||||||||||||||
| import { useState } from 'react' | ||||||||||||||||||||||||||||||||||||
| import { Spinner } from '@heroui/react' | ||||||||||||||||||||||||||||||||||||
| import { Icon } from '@repo/ui/components/Icon' | ||||||||||||||||||||||||||||||||||||
| import { Flex, VerticalScrollArea } from '@repo/ui/components/Layout' | ||||||||||||||||||||||||||||||||||||
| import { SearchPlaceListItem } from './SearchPlaceListItem' | ||||||||||||||||||||||||||||||||||||
|
|
@@ -47,15 +48,21 @@ export const SearchPage = ({ | |||||||||||||||||||||||||||||||||||
| useBackHandler = false, | ||||||||||||||||||||||||||||||||||||
| }: Props) => { | ||||||||||||||||||||||||||||||||||||
| const [inputValue, setInputValue] = useState('') | ||||||||||||||||||||||||||||||||||||
| const [isLoading, setIsLoading] = useState(false) | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| useEffect(() => { | ||||||||||||||||||||||||||||||||||||
| if (inputValue.length > 0) { | ||||||||||||||||||||||||||||||||||||
| searchFunc(inputValue) | ||||||||||||||||||||||||||||||||||||
| const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => { | ||||||||||||||||||||||||||||||||||||
| const value = e.target.value | ||||||||||||||||||||||||||||||||||||
| setInputValue(value) | ||||||||||||||||||||||||||||||||||||
| if (value.length > 0) { | ||||||||||||||||||||||||||||||||||||
| searchFunc(value) | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
| }, [inputValue, searchFunc]) | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
|
Comment on lines
+53
to
+59
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 검색어가 비워졌을 때 결과 초기화 누락 검색어를 지웠을 때 이전 검색 결과가 화면에 남아있을 수 있습니다. 사용자 경험 개선을 위해 빈 문자열일 때 결과를 초기화하는 것을 고려해보세요. const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value
setInputValue(value)
if (value.length > 0) {
searchFunc(value)
+ } else {
+ searchFunc('') // 또는 결과를 초기화하는 다른 방법
}
}📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| return ( | ||||||||||||||||||||||||||||||||||||
| <> | ||||||||||||||||||||||||||||||||||||
| {isLoading && ( | ||||||||||||||||||||||||||||||||||||
| <Spinner className='absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2' /> | ||||||||||||||||||||||||||||||||||||
| )} | ||||||||||||||||||||||||||||||||||||
| <Flex className={'border-b-1 gap-2.5 border-gray-100 p-3.5'}> | ||||||||||||||||||||||||||||||||||||
| {useBackHandler ? ( | ||||||||||||||||||||||||||||||||||||
| <HeaderBackButton /> | ||||||||||||||||||||||||||||||||||||
|
|
@@ -64,7 +71,7 @@ export const SearchPage = ({ | |||||||||||||||||||||||||||||||||||
| )} | ||||||||||||||||||||||||||||||||||||
| <input | ||||||||||||||||||||||||||||||||||||
| value={inputValue} | ||||||||||||||||||||||||||||||||||||
| onChange={(e) => setInputValue(e.target.value)} | ||||||||||||||||||||||||||||||||||||
| onChange={handleInputChange} | ||||||||||||||||||||||||||||||||||||
| className={'w-full text-lg font-medium outline-none'} | ||||||||||||||||||||||||||||||||||||
| placeholder={placeholder || '장소 또는 주소를 검색하세요'} | ||||||||||||||||||||||||||||||||||||
| /> | ||||||||||||||||||||||||||||||||||||
|
|
@@ -76,7 +83,10 @@ export const SearchPage = ({ | |||||||||||||||||||||||||||||||||||
| key={place.id} | ||||||||||||||||||||||||||||||||||||
| inputValue={inputValue} | ||||||||||||||||||||||||||||||||||||
| place={place} | ||||||||||||||||||||||||||||||||||||
| onClick={() => onSelectPlace(place.id)} | ||||||||||||||||||||||||||||||||||||
| onClick={() => { | ||||||||||||||||||||||||||||||||||||
| setIsLoading(true) | ||||||||||||||||||||||||||||||||||||
| onSelectPlace(place.id) | ||||||||||||||||||||||||||||||||||||
| }} | ||||||||||||||||||||||||||||||||||||
| /> | ||||||||||||||||||||||||||||||||||||
| ))} | ||||||||||||||||||||||||||||||||||||
| </VerticalScrollArea> | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -26,6 +26,7 @@ export const API_PATH = { | |
| POST: (id: string) => `/places/${id}/like`, | ||
| DELETE: (id: string) => `/places/${id}/like`, | ||
| }, | ||
| SEARCH: (keyword: string) => `/places/search?keyword=${keyword}`, | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain검색 키워드의 URL 인코딩을 확인해주세요. 검색 키워드가 URL 쿼리 파라미터로 직접 전달되고 있습니다. 특수문자나 공백이 포함된 키워드가 올바르게 인코딩되는지 확인이 필요합니다. 다음 스크립트를 실행하여 이 엔드포인트를 사용하는 모든 곳에서 적절한 인코딩이 적용되는지 확인하세요: 🏁 Script executed: #!/bin/bash
# Description: Search for usages of API_PATH.PLACES.SEARCH and verify URL encoding
# Search for direct usage of the SEARCH endpoint
rg -n "API_PATH\.PLACES\.SEARCH" --type ts -A 3 -B 1
# Look for potential encodeURIComponent usage
rg -n "encodeURIComponent" --type ts -A 2 -B 2Length of output: 360 🏁 Script executed: #!/bin/bash
# Check for tests related to search functionality
fd -e test.ts -e spec.ts -e test.tsx | head -20 | xargs grep -l "SEARCH\|searchPlace\|keyword" 2>/dev/null | head -5
# Look at the actual place.ts implementation
cat apps/web/app/_apis/services/place.ts
# Check if there are any utility functions for URL encoding
fd -name "*.ts" -type f | xargs grep -l "encodeURI\|URLSearchParams" 2>/dev/null | head -10Length of output: 2746 🌐 Web query: 💡 Result: Short answer: Yes — when you pass params to axios (e.g. axios.get(url, { params })) it serializes and URL-encodes the query string for you by default. You can customize serialization (array formats, custom encoders) with paramsSerializer or by supplying a URLSearchParams/qs-based serializer. [1][2][3] Sources:
검색 키워드 URL 인코딩 추가 필요 현재 구현에서 키워드가 URL 문자열에 직접 삽입되고 있어 특수문자나 공백이 포함된 경우 문제가 발생할 수 있습니다. axios의 자동 인코딩은 수정 위치: SEARCH: (keyword: string) => `/places/search?keyword=${encodeURIComponent(keyword)}`,🤖 Prompt for AI Agents |
||
| }, | ||
| KAKAO: { | ||
| SEARCH: (query: string, categoryCode: string, x: number, y: number) => | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,53 @@ | ||
| 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<T[]>를 반환하는 비동기 검색 함수 | ||
| * | ||
| * @returns searchResult - 검색된 장소 리스트 | ||
| * @returns searchFunc - 검색을 수행하는 함수 | ||
| */ | ||
|
|
||
| export const useSearch = <T, P>(fetcher: (params: P) => Promise<T[]>) => { | ||
| const [searchResult, setSearchResult] = useState<T[]>([]) | ||
| const timeoutRef = useRef<ReturnType<typeof setTimeout> | 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) | ||
|
Comment on lines
+31
to
+40
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 진행 중인 fetch 취소 로직 누락 사용자가 빠르게 검색어를 변경할 때, 이전 요청이 완료되기 전에 새 요청이 시작되면 race condition이 발생할 수 있습니다. 늦게 도착한 응답이 최신 검색 결과를 덮어쓸 수 있습니다. AbortController를 사용하여 이전 요청을 취소하는 것을 권장합니다: export const useSearch = <T, P>(fetcher: (params: P) => Promise<T[]>) => {
const [searchResult, setSearchResult] = useState<T[]>([])
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
+ const abortControllerRef = useRef<AbortController | null>(null)
const searchFunc = useCallback(
(params: P) => {
if (timeoutRef.current) clearTimeout(timeoutRef.current)
+ if (abortControllerRef.current) abortControllerRef.current.abort()
timeoutRef.current = setTimeout(async () => {
+ abortControllerRef.current = new AbortController()
try {
const result = await fetcher(params)
+ if (!abortControllerRef.current.signal.aborted) {
setSearchResult(result)
+ }
} catch (error) {
+ if (error.name !== 'AbortError') {
console.error('Search failed:', error)
setSearchResult([])
+ }
}
}, 300)
},
[fetcher],
)
useEffect(() => {
return () => {
if (timeoutRef.current) clearTimeout(timeoutRef.current)
+ if (abortControllerRef.current) abortControllerRef.current.abort()
}
}, [])참고: fetcher 함수도 AbortSignal을 받을 수 있도록 시그니처 변경이 필요할 수 있습니다.
🤖 Prompt for AI Agents |
||
| }, | ||
| [fetcher], // fetcher가 변경되면 함수 재생성 | ||
| ) | ||
|
Comment on lines
+27
to
+43
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🛠️ Refactor suggestion | 🟠 Major fetcher 함수 안정성에 대한 문서화 필요
다음 중 하나를 권장합니다:
/**
+ * @important fetcher 함수는 useCallback으로 감싸거나 컴포넌트 외부에 선언하여
+ * 안정적인 참조를 유지해야 합니다. 그렇지 않으면 매 렌더링마다 searchFunc이 재생성됩니다.
+ *
* @template T 검색 결과 아이템의 타입 (예: SearchPlace)
export const useSearch = <T, P>(fetcher: (params: P) => Promise<T[]>) => {
const [searchResult, setSearchResult] = useState<T[]>([])
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
+ const fetcherRef = useRef(fetcher)
+
+ useEffect(() => {
+ fetcherRef.current = fetcher
+ }, [fetcher])
const searchFunc = useCallback(
(params: P) => {
if (timeoutRef.current) clearTimeout(timeoutRef.current)
timeoutRef.current = setTimeout(async () => {
try {
- const result = await fetcher(params)
+ const result = await fetcherRef.current(params)
setSearchResult(result)
} catch (error) {
console.error('Search failed:', error)
setSearchResult([])
}
}, 300)
},
- [fetcher],
+ [],
)
🤖 Prompt for AI Agents |
||
|
|
||
| // 언마운트 시 타이머 클리어 | ||
| useEffect(() => { | ||
| return () => { | ||
| if (timeoutRef.current) clearTimeout(timeoutRef.current) | ||
| } | ||
| }, []) | ||
|
|
||
| return { searchResult, searchFunc } | ||
| } | ||
This file was deleted.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
API 키가 없을 때의 처리가 필요합니다.
환경 변수가 설정되지 않은 경우 빈 문자열로 폴백되어 모든 API 요청이 인증 실패로 이어집니다. 이는 디버깅하기 어려운 런타임 오류를 발생시킬 수 있습니다.
다음과 같이 수정하여 명확한 에러 처리를 추가하세요:
🤖 Prompt for AI Agents