-
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
Conversation
…ch api 요청 관련 파일 분리
- 개별적으로 동작하던 카페/식당 검색 로직을 하나의 Fetcher 함수로 통합 - 두 번 발생하던 리렌더링을 한 번으로 줄여 UI 깜빡임 현상 최적화
- 장소 아이템 선택 시 시각적 피드백을 위해 `Spinner` 컴포넌트 추가 - handleInputChange 함수 생성
Walkthrough카카오 로컬 검색 관련 타입·서비스와 제네릭 디바운스 훅을 추가하고, 레거시 카카오 훅을 제거했으며 검색 페이지·유틸을 도입하고 Banner 컴포넌트의 안정성을 강화했습니다. 검색 흐름은 통합된 useSearch + searchCafeAndRestaurant로 변경되었습니다. Changes
Sequence Diagram(s)sequenceDiagram
participant User as 사용자
participant SearchPage as SearchPage
participant useSearch as useSearch 훅
participant searchUtil as searchCafeAndRestaurant
participant kakaoSvc as getSearchPlaceByKakao
participant KakaoAPI as Kakao API
User->>SearchPage: 검색어 입력
SearchPage->>useSearch: searchFunc({ query, location }) (디바운스 300ms)
activate useSearch
useSearch-->>useSearch: 이전 타이머 취소 & 새 타이머 설정
useSearch->>searchUtil: 호출 (동시 카테고리 요청)
activate searchUtil
searchUtil->>kakaoSvc: getSearchPlaceByKakao(query, category: cafe, location)
searchUtil->>kakaoSvc: getSearchPlaceByKakao(query, category: restaurant, location)
activate kakaoSvc
kakaoSvc->>KakaoAPI: GET /v2/local/search/keyword (Authorization: KakaoAK ...)
KakaoAPI-->>kakaoSvc: documents 응답
deactivate kakaoSvc
searchUtil-->>useSearch: 병합된 결과 반환
deactivate searchUtil
useSearch-->>SearchPage: searchResult 업데이트
deactivate useSearch
SearchPage-->>User: 장소 목록 표시
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes
Possibly related PRs
Suggested labels
Suggested reviewers
Poem
Pre-merge checks and finishing touches❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✨ Finishing touches
🧪 Generate unit tests (beta)
📜 Recent review detailsConfiguration used: CodeRabbit UI Review profile: CHILL Plan: Pro 📒 Files selected for processing (2)
🚧 Files skipped from review as they are similar to previous changes (2)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
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.
Actionable comments posted: 8
🧹 Nitpick comments (4)
apps/web/app/places/new/_utils/searchCafeAndRestaurant.ts (1)
3-14: 중복 제거 및 타입 안정성을 개선하세요.현재 구현에는 다음과 같은 개선 여지가 있습니다:
- 중복 가능성: 동일한 장소가 카페와 음식점 카테고리 모두에 속할 수 있어 검색 결과에 중복으로 나타날 수 있습니다.
- 타입 정의 부재: 반환 타입이 명시되지 않아 타입 안정성이 떨어집니다.
다음과 같이 개선하는 것을 권장합니다:
+import { SearchPlaceByKakao } from '@/_apis/schemas/kakaoSearch' import { getSearchPlaceByKakao } from '@/_apis/services/kakaoSearch' export const searchCafeAndRestaurant = async (params: { query: string location: { x: number; y: number } -}) => { - const [cafes, restaurants] = await Promise.all([ +}): Promise<SearchPlaceByKakao[]> => { + const [cafesResult, restaurantsResult] = await Promise.allSettled([ getSearchPlaceByKakao({ ...params, categoryCode: 'cafe' }), getSearchPlaceByKakao({ ...params, categoryCode: 'restaurant' }), ]) + + const cafes = cafesResult.status === 'fulfilled' ? cafesResult.value : [] + const restaurants = restaurantsResult.status === 'fulfilled' ? restaurantsResult.value : [] - // 두 결과를 합쳐서 반환 - return [...cafes, ...restaurants] + // 두 결과를 합치고 중복 제거 + const combined = [...cafes, ...restaurants] + const uniquePlaces = Array.from( + new Map(combined.map(place => [place.id, place])).values() + ) + + return uniquePlaces }apps/web/app/places/search/page.tsx (1)
16-20: 맵핑 로직을 useMemo로 최적화 고려
searchResult가 변경될 때만 재계산하도록 최적화할 수 있습니다.+import { useMemo } from 'react' const Page = () => { const { replace } = useRouter() const { searchResult, searchFunc } = useSearch<PlaceBySearch, string>( getPlacesBySearch, ) - const newPlaces = searchResult.map((place) => ({ + const newPlaces = useMemo(() => searchResult.map((place) => ({ id: place.placeId, name: place.placeName, address: place.address, - })) + })), [searchResult])apps/web/app/_hooks/useSearch.ts (1)
40-40: 디바운스 시간을 설정 가능하게 변경 고려300ms로 하드코딩되어 있어 다양한 사용 사례에 대응하기 어렵습니다.
-export const useSearch = <T, P>(fetcher: (params: P) => Promise<T[]>) => { +export const useSearch = <T, P>( + fetcher: (params: P) => Promise<T[]>, + debounceMs = 300 +) => { 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 { const result = await fetcher(params) setSearchResult(result) } catch (error) { console.error('Search failed:', error) setSearchResult([]) } - }, 300) + }, debounceMs) }, - [fetcher], + [fetcher, debounceMs], )apps/web/app/places/new/_components/Step/PlaceSearch/PlaceSearch.tsx (1)
18-22: 불필요한 스프레드 연산자 제거
searchResult는 이미 배열이므로 스프레드 연산자가 필요하지 않습니다. 얕은 복사가 특별히 필요한 경우가 아니라면 제거하는 것이 더 명확합니다.- const places = [...searchResult].map((item) => ({ + const places = searchResult.map((item) => ({ id: item.id, name: item.place_name, address: item.address_name, }))
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (14)
apps/web/app/_apis/schemas/kakaoSearch.ts(1 hunks)apps/web/app/_apis/schemas/place.ts(2 hunks)apps/web/app/_apis/services/kakaoSearch.ts(1 hunks)apps/web/app/_apis/services/place.ts(2 hunks)apps/web/app/_components/SearchPage/SearchPage.tsx(4 hunks)apps/web/app/_constants/path.ts(1 hunks)apps/web/app/_hooks/useSearch.ts(1 hunks)apps/web/app/_hooks/useSearchPlaceByKakao.ts(0 hunks)apps/web/app/places/[id]/PlaceDetailPage.tsx(1 hunks)apps/web/app/places/new/_components/Step/PlacePreview/PlacePreview.tsx(1 hunks)apps/web/app/places/new/_components/Step/PlaceSearch/PlaceSearch.tsx(2 hunks)apps/web/app/places/new/_utils/searchCafeAndRestaurant.ts(1 hunks)apps/web/app/places/search/page.tsx(1 hunks)packages/ui/src/components/Banner/Banner.tsx(3 hunks)
💤 Files with no reviewable changes (1)
- apps/web/app/_hooks/useSearchPlaceByKakao.ts
🧰 Additional context used
🧬 Code graph analysis (6)
apps/web/app/_apis/schemas/kakaoSearch.ts (1)
apps/web/app/_hooks/useSearchPlaceByKakao.ts (3)
KakaoSearchFuncParams(61-77)SearchPlace(9-16)result(64-76)
apps/web/app/places/[id]/PlaceDetailPage.tsx (2)
packages/ui/src/components/Banner/Banner.tsx (1)
Banner(37-129)apps/web/app/requests/[id]/RequestDetailPage.tsx (1)
photo(44-53)
apps/web/app/places/new/_utils/searchCafeAndRestaurant.ts (1)
apps/web/app/_apis/services/kakaoSearch.ts (1)
getSearchPlaceByKakao(8-25)
apps/web/app/places/search/page.tsx (4)
apps/web/app/page.tsx (1)
Page(25-59)apps/web/app/_hooks/useSearch.ts (1)
useSearch(23-53)apps/web/app/_apis/schemas/place.ts (1)
PlaceBySearch(84-84)apps/web/app/_apis/services/place.ts (1)
getPlacesBySearch(58-63)
apps/web/app/_apis/services/place.ts (2)
apps/web/app/_apis/schemas/place.ts (2)
PlaceBySearch(84-84)PlaceBySearchSchema(40-44)apps/web/app/_constants/path.ts (1)
API_PATH(4-50)
apps/web/app/places/new/_components/Step/PlaceSearch/PlaceSearch.tsx (3)
apps/web/app/_hooks/useSearch.ts (1)
useSearch(23-53)apps/web/app/places/new/_utils/searchCafeAndRestaurant.ts (1)
searchCafeAndRestaurant(3-14)apps/web/app/_constants/campus.ts (1)
CAMPUS_LOCATION(9-13)
🔇 Additional comments (9)
packages/ui/src/components/Banner/Banner.tsx (1)
68-70: 안정성 개선이 잘 적용되었습니다!다음과 같은 방어적 프로그래밍 개선사항이 적용되었습니다:
- 슬라이더 인스턴스 존재 여부 확인 후
next()호출- 컴포넌트 파괴 시 타임아웃 정리
- 빈 콘텐츠 배열에 대한 조기 반환
- 옵셔널 체이닝을 통한 안전한 슬라이드 개수 접근
이러한 변경사항들은 런타임 에러를 방지하고 컴포넌트의 안정성을 크게 향상시킵니다.
Also applies to: 88-88, 93-95, 124-124
apps/web/app/places/new/_components/Step/PlacePreview/PlacePreview.tsx (1)
63-63: 인디케이터 추가가 적절합니다.배너에 슬라이드 인디케이터를 표시하도록 설정하여 사용자 경험이 개선되었습니다. Banner 컴포넌트가 이 기능을 안전하게 지원합니다.
apps/web/app/_apis/schemas/kakaoSearch.ts (1)
1-21: 타입 정의가 잘 구조화되어 있습니다.카카오 검색 API를 위한 타입과 상수가 명확하게 정의되었습니다:
KAKAO_CATEGORY_CODE: 카테고리 코드 매핑KakaoSearchFuncParams: 검색 함수 매개변수 타입SearchPlaceByKakao: API 응답 타입KakaoCategoryCode: 타입 안전한 카테고리 코드타입 정의가 명확하고 재사용 가능하게 구성되었습니다.
apps/web/app/_apis/schemas/place.ts (1)
40-44: 검색 결과 스키마가 적절하게 정의되었습니다.
PlaceBySearchSchema는 Zod의pick메서드를 사용하여BasePlaceSchema에서 필요한 필드(placeId, placeName, address)만 선택합니다. 이는 검색 결과에 필요한 최소한의 정보만 포함하여 효율적이며, 타입 안정성도 보장됩니다.Also applies to: 84-84
apps/web/app/places/[id]/PlaceDetailPage.tsx (1)
26-39: 조건부 렌더링 제거가 적절하고 안전합니다.검증 결과,
photos필드는 Zod 스키마(PlaceDetailSchema)에서 필수 필드로 정의되어 있으며(z.array(...)),useSuspenseQuery의 스키마 검증을 통해 항상 배열로 보장됩니다. 컴포넌트에서photos를 조건 없이 직접 사용하고 있으므로, 외부 조건부 렌더링을 제거하고Banner컴포넌트가 빈 배열을 내부적으로 처리하도록 한 변경은 안전하고 코드를 더 간결하게 만듭니다.apps/web/app/places/search/page.tsx (1)
11-11:replace사용에 대한 검토 필요뒤로가기 버튼을 눌렀을 때 검색 페이지로 돌아갈 수 없게 됩니다. 사용자가 상세 페이지에서 뒤로가기를 눌렀을 때 검색 결과로 돌아와야 한다면
push를 사용하는 것이 더 적절할 수 있습니다.의도적으로 히스토리를 남기지 않으려는 것인지 확인이 필요합니다.
apps/web/app/_components/SearchPage/SearchPage.tsx (1)
73-75: Spinner 위치 스타일 확인 필요
absolute포지셔닝이 올바르게 작동하려면 부모 요소에position: relative가 필요합니다. 현재 최상위 Fragment를 사용하고 있어 Spinner가 의도한 위치에 표시되지 않을 수 있습니다.다음과 같이 수정을 고려해보세요:
return ( - <> + <div className="relative"> {isNavigating && ( <Spinner className='absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2' /> )} {/* rest of the content */} - </> + </div> )apps/web/app/places/new/_components/Step/PlaceSearch/PlaceSearch.tsx (1)
16-30: 리팩토링이 잘 되었습니다기존의 두 개의 카카오 검색 훅을 하나의 통합된
useSearch훅으로 개선하여 코드가 더 간결하고 유지보수하기 좋아졌습니다.handleSearch도 적절하게 메모이제이션되어 있습니다.apps/web/app/_apis/services/place.ts (1)
58-63: 새로운 검색 API 연동이 잘 구현되었습니다
getPlacesBySearch함수가 기존의 다른 API 함수들과 일관된 패턴을 따르고 있으며, Zod 스키마를 통한 응답 검증도 적절하게 적용되어 있습니다.
| export const getSearchPlaceByKakao = async ({ | ||
| query, | ||
| categoryCode, | ||
| location, | ||
| }: KakaoSearchFuncParams) => { | ||
| const KAKAO_API_KEY = process.env.NEXT_PUBLIC_KAKAO_API || '' | ||
| const { x, y } = location | ||
|
|
||
| const { data } = await axios.get( | ||
| API_PATH.KAKAO.SEARCH(query, KAKAO_CATEGORY_CODE[categoryCode], x, y), | ||
| { | ||
| headers: { | ||
| Authorization: `KakaoAK ${KAKAO_API_KEY}`, | ||
| }, | ||
| }, | ||
| ) | ||
| return data.documents |
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 오류에 대한 처리가 없습니다.
- 응답 데이터의 유효성 검증이 없습니다.
- 반환 타입이 명시되지 않았습니다.
다음과 같이 개선하는 것을 권장합니다:
+import { SearchPlaceByKakao } from '@/_apis/schemas/kakaoSearch'
+
-export const getSearchPlaceByKakao = async ({
+export const getSearchPlaceByKakao = async ({
query,
categoryCode,
location,
-}: KakaoSearchFuncParams) => {
+}: KakaoSearchFuncParams): Promise<SearchPlaceByKakao[]> => {
const KAKAO_API_KEY = process.env.NEXT_PUBLIC_KAKAO_API
+ if (!KAKAO_API_KEY) {
+ throw new Error('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}`,
},
},
)
- return data.documents
+
+ if (!data?.documents || !Array.isArray(data.documents)) {
+ return []
+ }
+
+ return data.documents as SearchPlaceByKakao[]
+ } catch (error) {
+ console.error('카카오 장소 검색 실패:', error)
+ throw error
+ }
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| export const getSearchPlaceByKakao = async ({ | |
| query, | |
| categoryCode, | |
| location, | |
| }: KakaoSearchFuncParams) => { | |
| const KAKAO_API_KEY = process.env.NEXT_PUBLIC_KAKAO_API || '' | |
| const { x, y } = location | |
| const { data } = await axios.get( | |
| API_PATH.KAKAO.SEARCH(query, KAKAO_CATEGORY_CODE[categoryCode], x, y), | |
| { | |
| headers: { | |
| Authorization: `KakaoAK ${KAKAO_API_KEY}`, | |
| }, | |
| }, | |
| ) | |
| return data.documents | |
| import { 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 | |
| if (!KAKAO_API_KEY) { | |
| throw new Error('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 as SearchPlaceByKakao[] | |
| } catch (error) { | |
| console.error('카카오 장소 검색 실패:', error) | |
| throw error | |
| } | |
| } |
| categoryCode, | ||
| location, | ||
| }: KakaoSearchFuncParams) => { | ||
| const KAKAO_API_KEY = process.env.NEXT_PUBLIC_KAKAO_API || '' |
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 요청이 인증 실패로 이어집니다. 이는 디버깅하기 어려운 런타임 오류를 발생시킬 수 있습니다.
다음과 같이 수정하여 명확한 에러 처리를 추가하세요:
- const KAKAO_API_KEY = process.env.NEXT_PUBLIC_KAKAO_API || ''
+ const KAKAO_API_KEY = process.env.NEXT_PUBLIC_KAKAO_API
+ if (!KAKAO_API_KEY) {
+ throw new Error('NEXT_PUBLIC_KAKAO_API 환경 변수가 설정되지 않았습니다.')
+ }🤖 Prompt for AI Agents
In apps/web/app/_apis/services/kakaoSearch.ts around line 13, the KAKAO_API_KEY
currently falls back to an empty string when the env var is missing which causes
opaque auth failures at runtime; replace the silent fallback with explicit
validation that throws a clear error (or logs and throws) when
process.env.NEXT_PUBLIC_KAKAO_API is not set so the app fails fast with a
descriptive message (e.g. "KAKAO API key missing: set NEXT_PUBLIC_KAKAO_API"),
or alternatively return a failure response from the service initializer when
called so downstream callers never attempt requests with an empty key.
| const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => { | ||
| const value = e.target.value | ||
| setInputValue(value) | ||
| if (value.length > 0) { | ||
| searchFunc(value) | ||
| } | ||
| }, [inputValue, searchFunc]) | ||
| } |
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.
검색어가 비워졌을 때 결과 초기화 누락
검색어를 지웠을 때 이전 검색 결과가 화면에 남아있을 수 있습니다. 사용자 경험 개선을 위해 빈 문자열일 때 결과를 초기화하는 것을 고려해보세요.
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value
setInputValue(value)
if (value.length > 0) {
searchFunc(value)
+ } else {
+ searchFunc('') // 또는 결과를 초기화하는 다른 방법
}
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => { | |
| const value = e.target.value | |
| setInputValue(value) | |
| if (value.length > 0) { | |
| searchFunc(value) | |
| } | |
| }, [inputValue, searchFunc]) | |
| } | |
| const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => { | |
| const value = e.target.value | |
| setInputValue(value) | |
| if (value.length > 0) { | |
| searchFunc(value) | |
| } else { | |
| searchFunc('') // 또는 결과를 초기화하는 다른 방법 | |
| } | |
| } |
🤖 Prompt for AI Agents
In apps/web/app/_components/SearchPage/SearchPage.tsx around lines 53 to 59, the
input handler only triggers search when the input has text, so when the user
clears the input the previous results remain; update the handler to detect an
empty string and explicitly clear the search results (e.g., call the existing
state setter or result-clearing function to set results to an empty array or
initial state) instead of leaving them unchanged, while keeping the existing
searchFunc call for non-empty values.
| const handleSelect = (id: string) => { | ||
| setIsNavigating(true) | ||
| try { | ||
| onSelectPlace(id) | ||
| } catch (error) { | ||
| console.error(error) | ||
| setIsNavigating(false) | ||
| } | ||
| } |
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.
동기 네비게이션에 대한 에러 처리 재검토 필요
onSelectPlace는 router.replace()를 호출하는 동기 함수이므로, try-catch로 에러를 잡을 수 없습니다. Next.js 라우터 네비게이션은 예외를 throw하지 않습니다. 또한 성공적인 네비게이션 후에도 isNavigating 상태가 false로 리셋되지 않아 스피너가 계속 표시될 수 있습니다.
다음과 같이 수정을 권장합니다:
const handleSelect = (id: string) => {
setIsNavigating(true)
- try {
- onSelectPlace(id)
- } catch (error) {
- console.error(error)
- setIsNavigating(false)
- }
+ onSelectPlace(id)
+ // 네비게이션이 실패하는 경우는 거의 없으므로,
+ // 타임아웃으로 안전장치를 추가하거나 isNavigating을 제거하는 것을 고려
}또는 isNavigating 상태를 사용하지 않고 간단하게 유지하는 것도 방법입니다.
🤖 Prompt for AI Agents
In apps/web/app/_components/SearchPage/SearchPage.tsx around lines 61-69, the
handler sets isNavigating=true and wraps a synchronous onSelectPlace (which
calls router.replace) in a try-catch that cannot catch Next.js navigation
failures and never resets isNavigating on success; fix by either making
onSelectPlace return a Promise and awaiting it (wrap call in async/await and set
isNavigating=false in finally), or remove isNavigating usage and rely on router
events; alternatively subscribe to Next router events (routeChangeComplete and
routeChangeError) and reset isNavigating to false in those handlers, ensuring
you clean up listeners on unmount.
| 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 comment
The 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:
Does axios automatically encode URL query parameters in GET requests?
💡 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:
- axios request-config / paramsSerializer docs. [1]
- buildURL/serialization behavior (default encoding + options). [2]
- GitHub issue showing default behavior for brackets/array notation and that you can override it. [3]
검색 키워드 URL 인코딩 추가 필요
현재 구현에서 키워드가 URL 문자열에 직접 삽입되고 있어 특수문자나 공백이 포함된 경우 문제가 발생할 수 있습니다. axios의 자동 인코딩은 params 옵션으로 전달될 때만 작동하므로, 문자열로 빌드된 URL에서는 수동으로 인코딩해야 합니다.
수정 위치: apps/web/app/_constants/path.ts 줄 29
SEARCH: (keyword: string) => `/places/search?keyword=${encodeURIComponent(keyword)}`,🤖 Prompt for AI Agents
In apps/web/app/_constants/path.ts around line 29, the SEARCH path inserts the
keyword directly into the query string which can break with spaces or special
characters; update the factory to call encodeURIComponent on the keyword so the
returned URL is safe (e.g., use encodeURIComponent(keyword) when building the
query param).
| 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가 변경되면 함수 재생성 | ||
| ) |
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.
🛠️ Refactor suggestion | 🟠 Major
fetcher 함수 안정성에 대한 문서화 필요
fetcher가 의존성 배열에 포함되어 있어, 호출하는 컴포넌트에서 fetcher가 매 렌더링마다 새로 생성되면 searchFunc도 매번 재생성됩니다.
다음 중 하나를 권장합니다:
- JSDoc에 fetcher는 반드시 안정적인 참조여야 함을 명시:
/**
+ * @important fetcher 함수는 useCallback으로 감싸거나 컴포넌트 외부에 선언하여
+ * 안정적인 참조를 유지해야 합니다. 그렇지 않으면 매 렌더링마다 searchFunc이 재생성됩니다.
+ *
* @template T 검색 결과 아이템의 타입 (예: SearchPlace)- 또는
fetcher를 의존성에서 제거하고 ref로 관리:
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],
+ [],
)Committable suggestion skipped: line range outside the PR's diff.
🤖 Prompt for AI Agents
In apps/web/app/_hooks/useSearch.ts around lines 27 to 43, the review warns that
including fetcher in the dependency array causes searchFunc to be recreated
whenever the parent provides a new fetcher reference; choose one fix: either
document that fetcher must be a stable (memoized) function by adding a
JSDoc/@param comment above the hook stating fetcher must be stable across
renders (e.g., memoized with useCallback) and leave the dependency as-is, or
remove fetcher from the dependency array and manage it via a ref by creating a
ref that is assigned the incoming fetcher on each render and then using
ref.current inside the timeout callback so searchFunc remains stable; apply one
of these approaches consistently and update tests/comments accordingly.
| timeoutRef.current = setTimeout(async () => { | ||
| try { | ||
| // 주입받은 fetcher 실행 | ||
| const result = await fetcher(params) | ||
| setSearchResult(result) | ||
| } catch (error) { | ||
| console.error('Search failed:', error) | ||
| setSearchResult([]) | ||
| } | ||
| }, 300) |
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.
진행 중인 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을 받을 수 있도록 시그니처 변경이 필요할 수 있습니다.
Committable suggestion skipped: line range outside the PR's diff.
🤖 Prompt for AI Agents
In apps/web/app/_hooks/useSearch.ts around lines 31 to 40, the timeout-based
fetch logic does not cancel previous in-flight requests, risking race conditions
when users type quickly; introduce an AbortController per scheduled fetch, call
controller.abort() before creating a new one (and when clearing the timeout),
pass controller.signal into the fetcher (update fetcher signature/type to accept
an optional AbortSignal), handle AbortError in the catch path to avoid
overwriting results, and ensure you abort/clear the timeout in the hook cleanup
(return) to prevent leaks on unmount.
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.
Actionable comments posted: 0
♻️ Duplicate comments (1)
apps/web/app/_apis/services/kakaoSearch.ts (1)
14-14: 환경 변수 누락 시 빈 문자열 폴백은 런타임 디버깅을 어렵게 합니다.
NEXT_PUBLIC_KAKAO_API가 설정되지 않으면 현재는 빈 문자열로 대체되어, 모든 요청이 조용히 인증 실패하고 원인 파악이 어려워집니다. 이전 리뷰에서도 같은 포인트가 지적되었으므로, 이번에 확실히 실패를 명시해 두는 편이 좋겠습니다.아래처럼 명시적으로 검사 후 에러를 던지는 방향을 권장합니다.
- const KAKAO_API_KEY = process.env.NEXT_PUBLIC_KAKAO_API || '' + const KAKAO_API_KEY = process.env.NEXT_PUBLIC_KAKAO_API + if (!KAKAO_API_KEY) { + throw new Error('NEXT_PUBLIC_KAKAO_API 환경 변수가 설정되지 않았습니다.') + }이렇게 하면 설정 누락 시 바로 의미 있는 에러를 보고할 수 있어, 배포/운영 단계에서 문제를 훨씬 빨리 찾을 수 있습니다.
🧹 Nitpick comments (1)
apps/web/app/_apis/services/kakaoSearch.ts (1)
26-30: axios 응답 타입을 명시하면 타입 안전성이 더 좋아집니다.현재
axios.get호출에 제네릭이 없어data가 사실상any로 취급됩니다. 함수 반환 타입으로SearchPlaceByKakao[]를 선언했기 때문에 컴파일은 되지만, Kakao 응답 스키마가 바뀌어도 타입 시스템이 잡아주지 못합니다.응답 스키마를 한 번 정의해서 axios 제네릭에 넘기는 걸 고려해 볼 수 있습니다.
-import axios from 'axios' +import axios from 'axios' +import type { AxiosResponse } from 'axios' @@ -export const getSearchPlaceByKakao = async ({ +type KakaoSearchResponse = { + documents: SearchPlaceByKakao[] +} + +export const getSearchPlaceByKakao = async ({ query, categoryCode, location, -}: KakaoSearchFuncParams): Promise<SearchPlaceByKakao[]> => { +}: KakaoSearchFuncParams): Promise<SearchPlaceByKakao[]> => { @@ - const { data } = await axios.get( + const { data } = await axios.get<KakaoSearchResponse>( API_PATH.KAKAO.SEARCH(query, KAKAO_CATEGORY_CODE[categoryCode], x, y), @@ - if (!data?.documents || !Array.isArray(data.documents)) { + if (!data?.documents || !Array.isArray(data.documents)) { return [] } - return data.documents + return data.documents이렇게 하면 Kakao 응답 구조가 바뀌었을 때 TS가 바로 오류를 내주기 때문에, 런타임 전에 이상을 감지하기 쉬워집니다.
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (1)
apps/web/app/_apis/services/kakaoSearch.ts(1 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
apps/web/app/_apis/services/kakaoSearch.ts (1)
apps/web/app/_apis/schemas/kakaoSearch.ts (3)
KakaoSearchFuncParams(6-10)SearchPlaceByKakao(12-19)KAKAO_CATEGORY_CODE(1-4)
🔇 Additional comments (1)
apps/web/app/_apis/services/kakaoSearch.ts (1)
9-35: 카카오 검색 서비스 함수 전반적인 구조는 잘 잡혀 있습니다.
- 명시적인 반환 타입(
Promise<SearchPlaceByKakao[]>)과KakaoSearchFuncParams사용으로 인터페이스가 깔끔합니다.try/catch로 네트워크 에러를 캡처하고,data?.documents배열 여부를 검사한 뒤 빈 배열을 반환하는 방어 로직도 적절합니다.KAKAO_CATEGORY_CODE[categoryCode]를 통해 카테고리 매핑하는 부분도 의도가 분명합니다.현재 구조 기준으로는 서비스 레이어로서 충분히 읽기 쉽고 재사용하기 좋은 형태입니다.
- 일부 요청이 실패 시 부분적인 결과라도 사용자에게 제공하기 위해 사용
#️⃣연관된 이슈
📝작업 내용
1. 키워드 기반 등록된 맛집 검색 API 연결
우리 서비스 DB에 등록된 맛집을 키워드로 검색하는 기능을 연동했습니다.
2. useSearch 커스텀 훅 구현 (리팩토링 & 기능 확장)
배경:
기존에는 디바운스(Debounce) 로직이 카카오 검색 함수 내부에 강하게 결합되어 있어, 다른 검색(등록된 맛집 검색 등)에 재사용하기 어려웠습니다.
작업 내용:
검색 로직과 디바운스 처리를 담당하는 useSearch 훅을 분리했습니다.
Fetcher 함수 주입: useSearch 훅이 fetcher 함수를 매개변수와 함께 generic type으로 주입받도록 설계하여, 카카오 검색과 등록된 맛집 검색 모두에서 유연하게 사용할 수 있도록 개선했습니다.
디바운스 적용: 검색어 입력 시 잦은 API 호출을 방지하기 위해 훅 내부에서 디바운싱 처리를 일원화했습니다.
스크린샷 (선택)
2025-11-24.3.02.24.mov
💬리뷰 요구사항(선택)
Summary by CodeRabbit
새로운 기능
개선 사항
✏️ Tip: You can customize this high-level summary in your review settings.