Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions apps/web/app/_apis/schemas/kakaoSearch.ts
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
7 changes: 7 additions & 0 deletions apps/web/app/_apis/schemas/place.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,12 @@ export const PlaceByMapSchema = BasePlaceSchema.extend({
photos,
})

export const PlaceBySearchSchema = BasePlaceSchema.pick({
placeId: true,
placeName: true,
address: true,
})

export const PlaceDetailSchema = BasePlaceSchema.extend({
location,
isLiked: z.boolean(),
Expand Down Expand Up @@ -75,6 +81,7 @@ export type MapBounds = {

export type BasePlace = z.infer<typeof BasePlaceSchema>
export type PlaceByMap = z.infer<typeof PlaceByMapSchema>
export type PlaceBySearch = z.infer<typeof PlaceBySearchSchema>
export type PlaceDetail = z.infer<typeof PlaceDetailSchema>
export type PlaceByPreview = z.infer<typeof PlaceByPreviewSchema>
export type NewPlaceRequest = z.infer<typeof NewPlaceRequestSchema>
Expand Down
35 changes: 35 additions & 0 deletions apps/web/app/_apis/services/kakaoSearch.ts
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 || ''
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.

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
}
}
33 changes: 9 additions & 24 deletions apps/web/app/_apis/services/place.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import axios from 'axios'
import axiosInstance from '@/_lib/axiosInstance'
import { API_PATH } from '@/_constants/path'
import type { CampusType } from '@/_constants/campus'
Expand All @@ -8,18 +7,16 @@ import {
type PlaceDetail,
type MapBounds,
type PlaceByMap,
type PlaceBySearch,
type PlaceByPreview,
type NewPlaceRequest,
type NewPlaceResponse,
BasePlaceSchema,
PlaceByMapSchema,
PlaceBySearchSchema,
PlaceDetailSchema,
PlaceByPreviewSchema,
} from '../schemas/place'
import {
type KakaoSearchFuncParams,
KAKAO_CATEGORY_CODE,
} from '@/_hooks/useSearchPlaceByKakao'

export const getPlacesByRanking = async (
sort: RankingPlaceSort,
Expand Down Expand Up @@ -58,30 +55,18 @@ export const getPlacesByMap = async ({
return PlaceByMapSchema.array().parse(data)
}

export const getPlacesBySearch = async (
keyword: string,
): Promise<PlaceBySearch[]> => {
const { data } = await axiosInstance.get(API_PATH.PLACES.SEARCH(keyword))
return PlaceBySearchSchema.array().parse(data)
}

export const getPlaceDetail = async (id: string): Promise<PlaceDetail> => {
const { data } = await axiosInstance.get(API_PATH.PLACES.DETAIL(id))
return PlaceDetailSchema.parse(data)
}

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
}

export const getPlaceByPreview = async (
kakaoPlaceId: string,
): Promise<PlaceByPreview> => {
Expand Down
24 changes: 17 additions & 7 deletions apps/web/app/_components/SearchPage/SearchPage.tsx
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'
Expand Down Expand Up @@ -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
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.


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 />
Expand All @@ -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 || '장소 또는 주소를 검색하세요'}
/>
Expand All @@ -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>
Expand Down
1 change: 1 addition & 0 deletions apps/web/app/_constants/path.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`,
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).

},
KAKAO: {
SEARCH: (query: string, categoryCode: string, x: number, y: number) =>
Expand Down
53 changes: 53 additions & 0 deletions apps/web/app/_hooks/useSearch.ts
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
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.

},
[fetcher], // fetcher가 변경되면 함수 재생성
)
Comment on lines +27 to +43
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.


// 언마운트 시 타이머 클리어
useEffect(() => {
return () => {
if (timeoutRef.current) clearTimeout(timeoutRef.current)
}
}, [])

return { searchResult, searchFunc }
}
89 changes: 0 additions & 89 deletions apps/web/app/_hooks/useSearchPlaceByKakao.ts

This file was deleted.

31 changes: 14 additions & 17 deletions apps/web/app/places/[id]/PlaceDetailPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,23 +23,20 @@ export const PlaceDetailPage = ({ id }: { id: string }) => {
right={<LikeButton placeId={placeId} initIsLiked={isLiked} />}
/>
<VerticalScrollArea className={'flex-1'}>
{photos.length > 0 && (
<Banner
contents={photos.map((photo) => (
<Image
key={photo.displayOrder}
src={photo.photoUrl}
alt='place-photo'
width={450}
height={180}
className={'max-h-[180px] object-contain'}
/>
))}
minHeight={180}
showIndicator={true}
/>
)}

<Banner
contents={photos.map((photo) => (
<Image
key={photo.displayOrder}
src={photo.photoUrl}
alt='place-photo'
width={450}
height={180}
className={'max-h-[180px] object-contain'}
/>
))}
minHeight={180}
showIndicator={true}
/>
<Column className={'flex-1 justify-around gap-4 p-5'}>
<Location location={location} />
<Menus menus={menus} />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ export const PlacePreview = ({ getValues, setValue, nextStep }: Props) => {
className={'max-h-[180px] object-contain'}
/>
))}
showIndicator={true}
minHeight={180}
/>
<Column className={'flex-1 justify-around gap-4'}>
Expand Down
Loading