Skip to content

Conversation

@leeleeleeleejun
Copy link
Member

@leeleeleeleejun leeleeleeleejun commented Nov 24, 2025

#️⃣연관된 이슈

📝작업 내용

1. 키워드 기반 등록된 맛집 검색 API 연결

우리 서비스 DB에 등록된 맛집을 키워드로 검색하는 기능을 연동했습니다.

2. useSearch 커스텀 훅 구현 (리팩토링 & 기능 확장)

배경:
기존에는 디바운스(Debounce) 로직이 카카오 검색 함수 내부에 강하게 결합되어 있어, 다른 검색(등록된 맛집 검색 등)에 재사용하기 어려웠습니다.

작업 내용:
검색 로직과 디바운스 처리를 담당하는 useSearch 훅을 분리했습니다.
Fetcher 함수 주입: useSearch 훅이 fetcher 함수를 매개변수와 함께 generic type으로 주입받도록 설계하여, 카카오 검색과 등록된 맛집 검색 모두에서 유연하게 사용할 수 있도록 개선했습니다.
디바운스 적용: 검색어 입력 시 잦은 API 호출을 방지하기 위해 훅 내부에서 디바운싱 처리를 일원화했습니다.

스크린샷 (선택)

2025-11-24.3.02.24.mov

💬리뷰 요구사항(선택)

리뷰어가 특별히 봐주었으면 하는 부분이 있다면 작성해주세요

ex) 메서드 XXX의 이름을 더 잘 짓고 싶은데 혹시 좋은 명칭이 있을까요?

Summary by CodeRabbit

  • 새로운 기능

    • 장소 검색 전용 페이지 추가 — 키워드로 장소 검색 가능(카페·레스토랑 통합)
    • 장소 검색 API 연동 및 통합 검색 유틸 추가
  • 개선 사항

    • 통합 검색 훅 도입으로 카페/레스토랑 결과를 한 번에 표시
    • 입력 기반 디바운스 검색 도입으로 응답 안정화
    • 장소 선택 시 로딩 스피너 표시
    • 사진 배너 안정성 강화 및 인디케이터 표시 추가

✏️ Tip: You can customize this high-level summary in your review settings.

@leeleeleeleejun leeleeleeleejun self-assigned this Nov 24, 2025
@leeleeleeleejun leeleeleeleejun linked an issue Nov 24, 2025 that may be closed by this pull request
1 task
@coderabbitai
Copy link

coderabbitai bot commented Nov 24, 2025

Walkthrough

카카오 로컬 검색 관련 타입·서비스와 제네릭 디바운스 훅을 추가하고, 레거시 카카오 훅을 제거했으며 검색 페이지·유틸을 도입하고 Banner 컴포넌트의 안정성을 강화했습니다. 검색 흐름은 통합된 useSearch + searchCafeAndRestaurant로 변경되었습니다.

Changes

Cohort / File(s) 변경 요약
카카오 검색 스키마 및 타입
apps/web/app/_apis/schemas/kakaoSearch.ts
카카오 검색 상수 및 타입(KAKAO_CATEGORY_CODE, KakaoSearchFuncParams, SearchPlaceByKakao, KakaoCategoryCode) 추가.
장소 검색 스키마
apps/web/app/_apis/schemas/place.ts
PlaceBySearchSchema 및 타입 PlaceBySearch 추가(기존 BasePlaceSchema에서 pick).
카카오 검색 서비스
apps/web/app/_apis/services/kakaoSearch.ts
getSearchPlaceByKakao(params) 추가 — Kakao Local API 호출, 응답 검증, 오류 로깅 및 재던짐 처리.
장소 서비스 리팩터링
apps/web/app/_apis/services/place.ts
레거시 카카오 전용 검색 제거, getPlacesBySearch(keyword) 추가(PlaceBySearchSchema 사용).
API 경로 상수
apps/web/app/_constants/path.ts
API_PATH.PLACES.LIKE.SEARCH 엔드포인트 추가 (/places/search?keyword=...).
제네릭 디바운스 훅
apps/web/app/_hooks/useSearch.ts
제네릭 훅 useSearch<T,P>(fetcher) 추가 — 300ms 디바운스, searchResult, searchFunc 제공.
레거시 훅 제거
apps/web/app/_hooks/useSearchPlaceByKakao.ts
카카오 전용 훅 및 관련 타입/상수 전부 삭제.
검색 유틸리티
apps/web/app/places/new/_utils/searchCafeAndRestaurant.ts
카페·레스토랑 동시 호출 후 결과를 병합하여 반환하는 searchCafeAndRestaurant 추가.
검색 컴포넌트 리팩터링
apps/web/app/places/new/_components/Step/PlaceSearch/PlaceSearch.tsx
useSearchPlaceByKakao 제거, useSearch + searchCafeAndRestaurant로 통합하여 검색 흐름 단일화.
검색 페이지 추가
apps/web/app/places/search/page.tsx
키워드 검색 클라이언트 페이지 추가 — getPlacesBySearch 사용해 결과 매핑 후 SearchPage 렌더.
SearchPage 컴포넌트 변경
apps/web/app/_components/SearchPage/SearchPage.tsx
입력 즉시 검색으로 변경(기존 useEffect 제거), isLoading 상태 및 Spinner 표시 추가, 선택 시 로딩 처리.
Banner 컴포넌트 개선
packages/ui/src/components/Banner/Banner.tsx, apps/web/app/places/[id]/PlaceDetailPage.tsx, apps/web/app/places/new/_components/Step/PlacePreview/PlacePreview.tsx
자동 슬라이드 안전 검사·타임아웃 정리 강화, contents가 비어있으면 조기 반환, PlaceDetailPage에서 Banner 항상 렌더, PlacePreview에 showIndicator={true} 추가.

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: 장소 목록 표시
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

  • 주의할 파일/영역:
    • useSearch의 디바운스 및 언마운트 타이머 정리.
    • searchCafeAndRestaurant의 Promise.allSettled 처리 및 실패 정규화.
    • getSearchPlaceByKakao의 응답 검증, 환경변수 사용, 예외 재던짐 로직.
    • SearchPage의 로딩 플로우(Spinner 표시/해제)와 선택 시 상태 전이.

Possibly related PRs

Suggested labels

✨ Feature, 🔨 Refactor

Suggested reviewers

  • jcw1031
  • gihhyeon

Poem

🐰 새 검색이 폴짝와 빛을 뿌리네,
디바운스 발자국으로 결과를 모아,
카페와 밥집, 두 길을 한 번에,
배너는 단단히, 페이지는 반짝.
함께 뛰는 토끼, 오늘도 찾는다 🥕✨

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed PR 제목은 주요 변경사항을 명확하게 요약하고 있습니다. 등록된 맛집 검색 API 연결과 useSearch 커스텀 훅 구현이라는 핵심 내용이 잘 전달되고 있습니다.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/#73

📜 Recent review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 68b71af and c5eac60.

📒 Files selected for processing (2)
  • apps/web/app/_components/SearchPage/SearchPage.tsx (4 hunks)
  • apps/web/app/places/new/_utils/searchCafeAndRestaurant.ts (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (2)
  • apps/web/app/_components/SearchPage/SearchPage.tsx
  • apps/web/app/places/new/_utils/searchCafeAndRestaurant.ts

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a 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: 중복 제거 및 타입 안정성을 개선하세요.

현재 구현에는 다음과 같은 개선 여지가 있습니다:

  1. 중복 가능성: 동일한 장소가 카페와 음식점 카테고리 모두에 속할 수 있어 검색 결과에 중복으로 나타날 수 있습니다.
  2. 타입 정의 부재: 반환 타입이 명시되지 않아 타입 안정성이 떨어집니다.

다음과 같이 개선하는 것을 권장합니다:

+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

📥 Commits

Reviewing files that changed from the base of the PR and between cd70a92 and c628c5d.

📒 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 스키마를 통한 응답 검증도 적절하게 적용되어 있습니다.

Comment on lines 8 to 24
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
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

에러 처리 및 타입 안정성을 개선해주세요.

현재 구현에는 다음과 같은 문제점이 있습니다:

  1. 네트워크 오류나 API 오류에 대한 처리가 없습니다.
  2. 응답 데이터의 유효성 검증이 없습니다.
  3. 반환 타입이 명시되지 않았습니다.

다음과 같이 개선하는 것을 권장합니다:

+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.

Suggested change
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 || ''
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

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.

Comment on lines +53 to +59
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value
setInputValue(value)
if (value.length > 0) {
searchFunc(value)
}
}, [inputValue, searchFunc])
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

검색어가 비워졌을 때 결과 초기화 누락

검색어를 지웠을 때 이전 검색 결과가 화면에 남아있을 수 있습니다. 사용자 경험 개선을 위해 빈 문자열일 때 결과를 초기화하는 것을 고려해보세요.

 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.

Suggested change
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.

Comment on lines 61 to 69
const handleSelect = (id: string) => {
setIsNavigating(true)
try {
onSelectPlace(id)
} catch (error) {
console.error(error)
setIsNavigating(false)
}
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

동기 네비게이션에 대한 에러 처리 재검토 필요

onSelectPlacerouter.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}`,
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 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 2

Length 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 -10

Length 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).

Comment on lines +27 to +43
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가 변경되면 함수 재생성
)
Copy link

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도 매번 재생성됩니다.

다음 중 하나를 권장합니다:

  1. JSDoc에 fetcher는 반드시 안정적인 참조여야 함을 명시:
 /**
+ * @important fetcher 함수는 useCallback으로 감싸거나 컴포넌트 외부에 선언하여
+ * 안정적인 참조를 유지해야 합니다. 그렇지 않으면 매 렌더링마다 searchFunc이 재생성됩니다.
+ *
  * @template T 검색 결과 아이템의 타입 (예: SearchPlace)
  1. 또는 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.

Comment on lines +31 to +40
timeoutRef.current = setTimeout(async () => {
try {
// 주입받은 fetcher 실행
const result = await fetcher(params)
setSearchResult(result)
} catch (error) {
console.error('Search failed:', error)
setSearchResult([])
}
}, 300)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

진행 중인 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.

Copy link

@coderabbitai coderabbitai bot left a 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

📥 Commits

Reviewing files that changed from the base of the PR and between c628c5d and 68b71af.

📒 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]를 통해 카테고리 매핑하는 부분도 의도가 분명합니다.

현재 구조 기준으로는 서비스 레이어로서 충분히 읽기 쉽고 재사용하기 좋은 형태입니다.

- 일부 요청이 실패 시 부분적인 결과라도 사용자에게 제공하기 위해 사용
@leeleeleeleejun leeleeleeleejun merged commit 1291fc7 into develop Nov 24, 2025
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature] 등록된 가게 검색 api 구현

2 participants