Skip to content

[Feat] : 카카오 지도 기반 노래방 위치 검색 및 즐겨찾기 기능 추가 (#224)#225

Open
GulSam00 wants to merge 1 commit into
developfrom
feat/224-karaokeMapAndFavorites
Open

[Feat] : 카카오 지도 기반 노래방 위치 검색 및 즐겨찾기 기능 추가 (#224)#225
GulSam00 wants to merge 1 commit into
developfrom
feat/224-karaokeMapAndFavorites

Conversation

@GulSam00
Copy link
Copy Markdown
Owner

@GulSam00 GulSam00 commented May 7, 2026

User description

📌 PR 제목

[Feat] : 카카오 지도 기반 노래방 위치 검색 및 즐겨찾기 기능 추가

📌 변경 사항

  • 카카오 지도 JS SDK 연동 (useKakaoMap 커스텀 훅으로 초기화 로직 캡슐화)
  • /map 페이지 추가 — 현재 위치 기반 반경 1km 내 노래방 마커 표시
  • 마커 클릭 시 노래방 정보(이름·주소·전화) + 즐겨찾기 토글 UI 표시
  • 즐겨찾기 저장/삭제 API 라우트 추가 (GET/POST/DELETE /api/karaoke/favorites)
  • Supabase karaoke_favorites 테이블 연동 (CRUD)
  • 하단 네비게이션 바에 지도 탭 추가, FooterKeyMAP 추가
  • /map 공개 경로 추가 (비로그인 시 지도 조회 가능, 즐겨찾기는 401 처리)

💬 추가 참고 사항


PR Type

Enhancement


Description

  • Add Kakao Map integration for karaoke location search within 1km radius

  • Implement favorites management system with Supabase backend

  • Create /map public page with geolocation and marker display

  • Add map navigation tab to footer with authentication-based access control


Diagram Walkthrough

flowchart LR
  A["User Location"] -->|"Geolocation API"| B["Kakao Map"]
  B -->|"Search 노래방"| C["Display Markers"]
  C -->|"Click Marker"| D["Show Place Info"]
  D -->|"Toggle Favorite"| E["Supabase DB"]
  E -->|"Sync"| F["Favorites List"]
  G["Auth Check"] -->|"401 if not logged in"| E
Loading

File Walkthrough

Relevant files
Enhancement
9 files
route.ts
Karaoke favorites API endpoints with Supabase integration
+91/-0   
useKakaoMap.ts
Custom hook for Kakao Map initialization and karaoke search
+87/-0   
karaokeMap.ts
API client functions for favorites CRUD operations             
+25/-0   
karaokeQuery.ts
React Query hooks for karaoke favorites data management   
+47/-0   
karaoke.ts
TypeScript interfaces for karaoke and Kakao Place data     
+21/-0   
KakaoMap.tsx
Map component with place selection and favorites UI           
+108/-0 
page.tsx
New map page route for karaoke location discovery               
+12/-0   
useFooterAnimateStore.ts
Add MAP footer key type to navigation store                           
+1/-1     
Footer.tsx
Add map navigation tab to footer menu                                       
+1/-3     
Configuration changes
1 files
auth.tsx
Add /map to public allowed paths list                                       
+1/-0     

@vercel
Copy link
Copy Markdown
Contributor

vercel Bot commented May 7, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
singcode Ready Ready Preview, Comment May 7, 2026 4:11am

@GulSam00
Copy link
Copy Markdown
Owner Author

GulSam00 commented May 7, 2026

/describe

@GulSam00
Copy link
Copy Markdown
Owner Author

GulSam00 commented May 7, 2026

/review

@GulSam00
Copy link
Copy Markdown
Owner Author

GulSam00 commented May 7, 2026

/improve

@qodo-code-review
Copy link
Copy Markdown

qodo-code-review Bot commented May 7, 2026

Code Review by Qodo

🐞 Bugs (9) 📘 Rule violations (2) 📎 Requirement gaps (3)

Context used

Grey Divider


Action required

1. No place search input 📎 Requirement gap ≡ Correctness ⭐ New
Description
The new /map UI only performs a fixed keyword search ('노래방') around the current/fallback
location and does not provide a user-driven place search input/results UI. This fails the
requirement for a location search page that includes place search functionality.
Code

apps/web/src/hooks/useKakaoMap.ts[R20-49]

+  const searchNearbyKaraoke = useCallback((map: any, lat: number, lng: number) => {
+    const ps = new window.kakao.maps.services.Places();
+    const center = new window.kakao.maps.LatLng(lat, lng);
+    const infowindow = new window.kakao.maps.InfoWindow({ zIndex: 1 });
+
+    ps.keywordSearch(
+      '노래방',
+      (data: KakaoPlace[], status: string) => {
+        if (status !== window.kakao.maps.services.Status.OK) return;
+
+        data.forEach(place => {
+          const position = new window.kakao.maps.LatLng(Number(place.y), Number(place.x));
+          const marker = new window.kakao.maps.Marker({ map, position });
+
+          window.kakao.maps.event.addListener(marker, 'click', () => {
+            infowindow.close();
+            setSelectedPlace(place);
+            infowindow.setContent(
+              `<div style="padding:6px 10px;font-size:13px;font-weight:bold;">${place.place_name}</div>`,
+            );
+            infowindow.open(map, marker);
+          });
+        });
+      },
+      {
+        location: center,
+        radius: 1000,
+        sort: window.kakao.maps.services.SortBy.DISTANCE,
+      },
+    );
Evidence
PR Compliance ID 2 requires a place search UI/logic (search input/results). In useKakaoMap, the
search is hardcoded to ps.keywordSearch('노래방', ...) and the rendered map component includes no
search input element—only the map container—so users cannot perform arbitrary place searches.

노래방 위치 검색 페이지(지도 표시 + 장소 검색) 구현
apps/web/src/hooks/useKakaoMap.ts[20-49]
apps/web/src/components/KakaoMap.tsx[58-60]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`/map` currently has no user-driven place search UI (input/results); it only runs a fixed keyword search (`'노래방'`).

## Issue Context
Compliance requires a location search page with map display + place search UI/logic (search input and results display).

## Fix Focus Areas
- apps/web/src/components/KakaoMap.tsx[51-60]
- apps/web/src/hooks/useKakaoMap.ts[20-49]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Karaoke favorites page missing 📎 Requirement gap ≡ Correctness ⭐ New
Description
There is no dedicated page/route that displays the user's saved karaoke favorites; favorites are
only shown inline within the map component. This does not meet the requirement for a standalone
favorites list page.
Code

apps/web/src/components/KakaoMap.tsx[R81-104]

+        {favorites.length > 0 && (
+          <div className="bg-card rounded-lg border p-4">
+            <p className="mb-2 font-semibold">즐겨찾기 ({favorites.length})</p>
+            <ul className="flex flex-col gap-2">
+              {favorites.map(fav => (
+                <li key={fav.place_id} className="flex items-center justify-between text-sm">
+                  <span>{fav.place_name}</span>
+                  <Button
+                    size="sm"
+                    variant="ghost"
+                    className="text-muted-foreground h-auto px-2 py-1 text-xs"
+                    onClick={() =>
+                      deleteFavorite.mutate(fav.place_id, {
+                        onSuccess: () => toast.success(`${fav.place_name} 삭제`),
+                      })
+                    }
+                  >
+                    삭제
+                  </Button>
+                </li>
+              ))}
+            </ul>
+          </div>
+        )}
Evidence
PR Compliance ID 5 requires a dedicated favorites list page. The added /map page only renders the
map component, and the favorites list rendering is embedded inside KakaoMap rather than being
implemented as its own page/route.

즐겨찾기 목록 페이지 구현
apps/web/src/app/map/page.tsx[1-12]
apps/web/src/components/KakaoMap.tsx[81-104]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
A dedicated karaoke favorites list page/route is missing; favorites are only shown within the map UI.

## Issue Context
Compliance requires a standalone page that fetches and displays favorites stored in Supabase (via the internal `/api/karaoke/favorites` route).

## Fix Focus Areas
- apps/web/src/app/map/page.tsx[1-12]
- apps/web/src/components/KakaoMap.tsx[81-104]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. Kakao InfoWindow XSS 🐞 Bug ⛨ Security ⭐ New
Description
useKakaoMap()에서 외부(Kakao Places API)로부터 온 place_name을 HTML 문자열에 그대로 삽입해 InfoWindow.setContent()에 넘겨
DOM 기반 XSS가 가능합니다. 악성 place_name이 포함되면 스크립트 실행/세션 탈취 등으로 이어질 수 있습니다.
Code

apps/web/src/hooks/useKakaoMap.ts[R34-41]

+          window.kakao.maps.event.addListener(marker, 'click', () => {
+            infowindow.close();
+            setSelectedPlace(place);
+            infowindow.setContent(
+              `<div style="padding:6px 10px;font-size:13px;font-weight:bold;">${place.place_name}</div>`,
+            );
+            infowindow.open(map, marker);
+          });
Evidence
Kakao Places 검색 결과(place)는 외부 데이터이며, 현재 코드는 place.place_name을 escape/sanitize 없이 HTML로 구성해
InfoWindow에 주입합니다. InfoWindow는 HTML을 렌더링하므로 place_name에 태그/이벤트 핸들러가 들어오면 실행될 수 있습니다.

apps/web/src/hooks/useKakaoMap.ts[25-41]
apps/web/src/hooks/useKakaoMap.ts[37-40]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`InfoWindow.setContent()`에 전달하는 문자열에 `place.place_name`을 그대로 삽입하고 있어, 외부 데이터 기반 XSS가 가능합니다.

### Issue Context
Kakao Places API 응답은 신뢰할 수 없는 입력으로 취급해야 합니다. `setContent()`에는 HTML 문자열 대신 DOM 노드를 사용하거나, 반드시 HTML escaping/sanitization을 적용해야 합니다.

### Fix Focus Areas
- apps/web/src/hooks/useKakaoMap.ts[25-41]

### Suggested fix
- 가능하면 DOM 엘리먼트를 만들어 `textContent`로 값을 세팅한 뒤 `setContent(element)`로 전달하세요.
 - 예: `const el = document.createElement('div'); el.style...; el.textContent = place.place_name; infowindow.setContent(el);`
- HTML 문자열을 유지해야 한다면 최소한 `place_name`을 HTML escape 처리(예: `& < > " '` 변환)하거나, 프로젝트 정책에 맞는 sanitizer(예: DOMPurify)를 적용하세요.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


View more (3)
4. Kakao Places called in browser 📘 Rule violation ⛨ Security
Description
Client code directly loads the Kakao Maps SDK and calls Places().keywordSearch() in the browser,
which is an external API interaction outside /api/*. This violates the BFF requirement and
bypasses centralized auth/validation and request controls.
Code

apps/web/src/hooks/useKakaoMap.ts[R20-49]

+  const searchNearbyKaraoke = useCallback((map: any, lat: number, lng: number) => {
+    const ps = new window.kakao.maps.services.Places();
+    const center = new window.kakao.maps.LatLng(lat, lng);
+    const infowindow = new window.kakao.maps.InfoWindow({ zIndex: 1 });
+
+    ps.keywordSearch(
+      '노래방',
+      (data: KakaoPlace[], status: string) => {
+        if (status !== window.kakao.maps.services.Status.OK) return;
+
+        data.forEach(place => {
+          const position = new window.kakao.maps.LatLng(Number(place.y), Number(place.x));
+          const marker = new window.kakao.maps.Marker({ map, position });
+
+          window.kakao.maps.event.addListener(marker, 'click', () => {
+            infowindow.close();
+            setSelectedPlace(place);
+            infowindow.setContent(
+              `<div style="padding:6px 10px;font-size:13px;font-weight:bold;">${place.place_name}</div>`,
+            );
+            infowindow.open(map, marker);
+          });
+        });
+      },
+      {
+        location: center,
+        radius: 1000,
+        sort: window.kakao.maps.services.SortBy.DISTANCE,
+      },
+    );
Evidence
PR Compliance ID 8 requires browser/client code to call only internal Next.js API routes (/api/*)
and not external APIs directly. The added client hook uses Kakao's Places service (keywordSearch)
and the component loads the external Kakao SDK script URL directly in the browser.

CLAUDE.md
apps/web/src/hooks/useKakaoMap.ts[20-49]
apps/web/src/components/KakaoMap.tsx[53-56]
CLAUDE.md[50-58]
apps/web/CLAUDE.md[43-47]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Browser/client code is directly calling an external API (Kakao Places via JS SDK) instead of routing through internal `/api/*` endpoints (BFF pattern).
## Issue Context
`useKakaoMap` uses `new window.kakao.maps.services.Places()` and `keywordSearch()`, and `KakaoMap` loads Kakao's SDK script in the client.
## Fix Focus Areas
- apps/web/src/hooks/useKakaoMap.ts[20-49]
- apps/web/src/components/KakaoMap.tsx[53-56]
- apps/web/src/app/api/[...]/route.ts[1-200]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


5. CLAUDE.md missing map docs 📘 Rule violation ⚙ Maintainability
Description
The PR introduces a new public route (/map) and a required env var (NEXT_PUBLIC_KAKAO_MAP_KEY)
but does not update apps/web/CLAUDE.md to reflect these changes. This creates onboarding/setup
drift and can cause local runs to fail or behave unexpectedly.
Code

apps/web/src/components/KakaoMap.tsx[R53-56]

+      <Script
+        src={`//dapi.kakao.com/v2/maps/sdk.js?appkey=${process.env.NEXT_PUBLIC_KAKAO_MAP_KEY}&libraries=services&autoload=false`}
+        onLoad={() => setIsScriptLoaded(true)}
+      />
Evidence
PR Compliance ID 10 requires updating CLAUDE.md when repo rules/structure/architecture or
build/setup requirements change. The PR adds a new public route (/map) and references a new
required env var, but the web CLAUDE doc still lists public routes and required env vars without
these additions.

CLAUDE.md
apps/web/src/auth.tsx[13-16]
apps/web/src/components/KakaoMap.tsx[53-56]
apps/web/CLAUDE.md[59-62]
apps/web/CLAUDE.md[89-98]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`apps/web/CLAUDE.md` is outdated relative to the PR changes (new public route and new required env var).
## Issue Context
- `/map` was added to public allow paths.
- `NEXT_PUBLIC_KAKAO_MAP_KEY` is now required for Kakao map loading.
## Fix Focus Areas
- apps/web/CLAUDE.md[59-62]
- apps/web/CLAUDE.md[89-98]
- apps/web/src/auth.tsx[13-16]
- apps/web/src/components/KakaoMap.tsx[53-56]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


6. InfoWindow XSS risk 🐞 Bug ⛨ Security
Description
useKakaoMap()이 Kakao InfoWindow content를 HTML 문자열로 만들면서 외부 데이터인 place.place_name을 그대로 보간해 DOM에
주입합니다. 악의적인 place_name 값이 들어오면 InfoWindow 영역에서 스크립트/HTML 실행(XSS)로 이어질 수 있습니다.
Code

apps/web/src/hooks/useKakaoMap.ts[R34-41]

+          window.kakao.maps.event.addListener(marker, 'click', () => {
+            infowindow.close();
+            setSelectedPlace(place);
+            infowindow.setContent(
+              `<div style="padding:6px 10px;font-size:13px;font-weight:bold;">${place.place_name}</div>`,
+            );
+            infowindow.open(map, marker);
+          });
Evidence
InfoWindow의 setContent에 HTML string을 사용하고, 그 안에 외부 API 응답 필드(place_name)를 그대로 삽입하고 있습니다. KakaoPlace는
지도 API 응답을 그대로 나타내는 타입이므로 신뢰할 수 없는 입력으로 취급해야 합니다.

apps/web/src/hooks/useKakaoMap.ts[25-41]
apps/web/src/types/karaoke.ts[12-21]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`infowindow.setContent()`에 외부 데이터(`place.place_name`)를 HTML 문자열로 직접 삽입해 XSS가 가능해집니다.
### Issue Context
Kakao Maps InfoWindow는 문자열 content를 HTML로 렌더링합니다. 따라서 place_name을 HTML-escape 하거나, 문자열 대신 DOM 노드를 만들어 `textContent`로 넣어야 합니다.
### Fix Focus Areas
- apps/web/src/hooks/useKakaoMap.ts[34-41]
### Suggested approach
- `const div = document.createElement('div'); div.style...; div.textContent = place.place_name; infowindow.setContent(div);` 처럼 DOM node 기반으로 설정
- 또는 최소한 HTML escape 유틸을 만들어 `${escapeHtml(place.place_name)}` 적용

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

7. Favorites body unvalidated 🐞 Bug ☼ Reliability ⭐ New
Description
즐겨찾기 API에서 POST/DELETE가 request.json() 결과를 검증 없이 DB에 전달해, 필드 누락/타입 오류/좌표 범위 오류 등이 발생하면 500으로 처리되며
데이터 무결성이 깨질 수 있습니다. 특히 lat/lng가 NaN/Infinity이거나 placeId가 비어도 그대로 insert/delete가 시도됩니다.
Code

apps/web/src/app/api/karaoke/favorites/route.ts[R48-57]

+    const { placeId, placeName, address, lat, lng } = await request.json();
+
+    const { error } = await supabase.from('karaoke_favorites').insert({
+      user_id: userId,
+      place_id: placeId,
+      place_name: placeName,
+      address,
+      lat,
+      lng,
+    });
Evidence
POST는 placeId/placeName/address/lat/lng를 구조분해한 뒤 타입/범위 검증 없이 그대로 insert합니다. DELETE도 placeId 검증 없이
match 조건으로 사용하며, JSON 파싱 실패/누락 시 현재 로직에서는 400이 아니라 catch로 떨어져 500 응답이 됩니다.

apps/web/src/app/api/karaoke/favorites/route.ts[43-67]
apps/web/src/app/api/karaoke/favorites/route.ts[70-90]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`POST/DELETE /api/karaoke/favorites`가 요청 바디를 검증하지 않고 DB에 전달하여, 잘못된 입력이 들어오면 내부 에러(500)로 처리되거나 잘못된 좌표/값이 저장될 수 있습니다.

### Issue Context
현재 `request.json()` 결과를 바로 구조분해하여 사용합니다. 클라이언트 버그/세션 만료/프록시 등으로 잘못된 요청이 들어올 때 400 계열로 명확히 처리하는 것이 안정적입니다.

### Fix Focus Areas
- apps/web/src/app/api/karaoke/favorites/route.ts[43-67]
- apps/web/src/app/api/karaoke/favorites/route.ts[70-90]

### Suggested fix
- `request.json()` 파싱 자체를 try/catch로 분리하고, 유효성 실패 시 400 반환.
- 스키마 검증(예: zod) 추가:
 - `placeId/placeName/address`: non-empty string, length 제한
 - `lat/lng`: number인지 확인 + 범위 체크(lat -90~90, lng -180~180) + `Number.isFinite`
- 중복 즐겨찾기(UNIQUE 충돌) 등은 가능하면 409로 분리하여 응답을 명확히 하세요.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


8. Mutation errors ignored 🐞 Bug ◔ Observability ⭐ New
Description
즐겨찾기 추가/삭제 mutation이 onSuccess만 정의되어 실패(401/500/중복 추가 등) 시 사용자에게 오류가 노출되지 않습니다. 결과적으로 요청이 실패해도 UI는
성공/실패를 구분하지 못하고 조용히 무시됩니다.
Code

apps/web/src/queries/karaokeQuery.ts[R24-46]

+  return useMutation({
+    mutationFn: (body: {
+      placeId: string;
+      placeName: string;
+      address: string;
+      lat: number;
+      lng: number;
+    }) => postKaraokeFavorite(body),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['karaokeFavorites'] });
+    },
+  });
+}
+
+export function useDeleteKaraokeFavoriteMutation() {
+  const queryClient = useQueryClient();
+
+  return useMutation({
+    mutationFn: (placeId: string) => deleteKaraokeFavorite({ placeId }),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['karaokeFavorites'] });
+    },
+  });
Evidence
react-query mutation 정의에 onError가 없고, KakaoMap 컴포넌트에서도 mutate 호출 시 성공 toast만 등록되어 있습니다. 따라서
네트워크/인증/DB 오류가 발생해도 사용자 피드백 및 로그가 부족합니다.

apps/web/src/queries/karaokeQuery.ts[21-46]
apps/web/src/components/KakaoMap.tsx[32-47]
apps/web/src/components/KakaoMap.tsx[92-96]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
즐겨찾기 추가/삭제 요청 실패 시 사용자에게 아무런 안내가 없어, 실패가 조용히 묻히고 디버깅이 어렵습니다.

### Issue Context
기존 다른 기능(예: likeSong mutation)에서는 onError에서 사용자 알림/로그를 남깁니다. 본 기능도 동일한 수준의 오류 처리가 필요합니다.

### Fix Focus Areas
- apps/web/src/queries/karaokeQuery.ts[21-46]
- apps/web/src/components/KakaoMap.tsx[25-49]

### Suggested fix
- `useAddKaraokeFavoriteMutation`, `useDeleteKaraokeFavoriteMutation`에 `onError: (err) => { console.error(...); toast.error(...) }` 추가.
- 필요 시 401이면 `useAuthStore.logout()`/재인증 유도 등 후속 처리.
- KakaoMap에서 mutate 호출 시에도 `onError`를 전달하거나, mutation 훅 내부에서 공통 처리하도록 정리하세요.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


9. Favorites API missing update 📎 Requirement gap ⚙ Maintainability
Description
The new favorites API route implements GET, POST, and DELETE but no update endpoint (e.g.,
PUT/PATCH). If the contract requires full CRUD, clients cannot modify existing favorites via the
API.
Code

apps/web/src/app/api/karaoke/favorites/route.ts[R21-91]

+export async function GET(): Promise<NextResponse<ApiResponse<KaraokeFavorite[]>>> {
+  try {
+    const supabase = await createClient();
+    const userId = await getAuthenticatedUser(supabase);
+
+    const { data, error } = await supabase
+      .from('karaoke_favorites')
+      .select('*')
+      .eq('user_id', userId)
+      .order('created_at', { ascending: false });
+
+    if (error) throw error;
+
+    return NextResponse.json({ success: true, data: data ?? [] });
+  } catch (error) {
+    if (error instanceof Error && error.cause === 'auth') {
+      return NextResponse.json({ success: false, error: 'User not authenticated' }, { status: 401 });
+    }
+    return NextResponse.json({ success: false, error: 'Failed to get favorites' }, { status: 500 });
+  }
+}
+
+export async function POST(request: Request): Promise<NextResponse<ApiResponse<void>>> {
+  try {
+    const supabase = await createClient();
+    const userId = await getAuthenticatedUser(supabase);
+
+    const { placeId, placeName, address, lat, lng } = await request.json();
+
+    const { error } = await supabase.from('karaoke_favorites').insert({
+      user_id: userId,
+      place_id: placeId,
+      place_name: placeName,
+      address,
+      lat,
+      lng,
+    });
+
+    if (error) throw error;
+
+    return NextResponse.json({ success: true });
+  } catch (error) {
+    if (error instanceof Error && error.cause === 'auth') {
+      return NextResponse.json({ success: false, error: 'User not authenticated' }, { status: 401 });
+    }
+    return NextResponse.json({ success: false, error: 'Failed to add favorite' }, { status: 500 });
+  }
+}
+
+export async function DELETE(request: Request): Promise<NextResponse<ApiResponse<void>>> {
+  try {
+    const supabase = await createClient();
+    const userId = await getAuthenticatedUser(supabase);
+
+    const { placeId } = await request.json();
+
+    const { error } = await supabase
+      .from('karaoke_favorites')
+      .delete()
+      .match({ user_id: userId, place_id: placeId });
+
+    if (error) throw error;
+
+    return NextResponse.json({ success: true });
+  } catch (error) {
+    if (error instanceof Error && error.cause === 'auth') {
+      return NextResponse.json({ success: false, error: 'User not authenticated' }, { status: 401 });
+    }
+    return NextResponse.json({ success: false, error: 'Failed to delete favorite' }, { status: 500 });
+  }
+}
Evidence
PR Compliance ID 7 requires API routes for favorites CRUD; the added route defines only GET,
POST, and DELETE exports and provides no PUT/PATCH handler for updates.

즐겨찾기 CRUD를 위한 API 라우트 구현
apps/web/src/app/api/karaoke/favorites/route.ts[21-91]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The favorites API route lacks an update method (`PUT`/`PATCH`), so it does not meet a strict CRUD requirement.
## Issue Context
Current route exports: `GET`, `POST`, `DELETE` only.
## Fix Focus Areas
- apps/web/src/app/api/karaoke/favorites/route.ts[21-91]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


View more (3)
10. Kakao script silent failure 🐞 Bug ☼ Reliability
Description
KakaoMap에서 Script 로딩 성공(onLoad)만 처리하고 실패(onError) 및 API 키 누락을 처리하지 않아, SDK 로드 실패 시 지도 초기화가 영구히 진행되지
않습니다. 사용자는 원인 안내 없이 빈 지도 영역만 보게 됩니다.
Code

apps/web/src/components/KakaoMap.tsx[R53-56]

+      <Script
+        src={`//dapi.kakao.com/v2/maps/sdk.js?appkey=${process.env.NEXT_PUBLIC_KAKAO_MAP_KEY}&libraries=services&autoload=false`}
+        onLoad={() => setIsScriptLoaded(true)}
+      />
Evidence
KakaoMap은 Script onLoad에서만 isScriptLoaded를 true로 바꾸고, useKakaoMap은 isScriptLoaded가 true일 때만 initMap을
호출합니다. 즉 Script 로드 실패/키 누락 등으로 onLoad가 실행되지 않으면 initMap이 영원히 호출되지 않습니다.

apps/web/src/components/KakaoMap.tsx[53-56]
apps/web/src/hooks/useKakaoMap.ts[80-84]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Kakao 지도 SDK 스크립트 로드 실패 시 사용자에게 아무 안내가 없고, 지도 초기화도 수행되지 않습니다.
### Issue Context
`isScriptLoaded`가 true일 때만 `initMap()`이 호출되므로, 스크립트가 실패하면 UI가 정지 상태가 됩니다.
### Fix Focus Areas
- apps/web/src/components/KakaoMap.tsx[53-56]
- apps/web/src/hooks/useKakaoMap.ts[80-84]
### Suggested approach
- `NEXT_PUBLIC_KAKAO_MAP_KEY`가 없으면 렌더 전에 안내 문구/에러 토스트 표시
- `<Script onError={() => ...}>` 추가하여 토스트 + 에러 상태를 세팅하고 대체 UI 렌더
- 필요하면 `setIsScriptLoaded(false)`와 별도의 `scriptError` state를 도입

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


11. Favorites API no validation 🐞 Bug ☼ Reliability
Description
즐겨찾기 POST/DELETE가 request.json()으로 받은 값을 검증 없이 바로 DB insert/delete에 사용해, 필수 필드 누락/잘못된 JSON 요청 시 400이
아닌 500으로 처리됩니다. 클라이언트 오류가 서버 오류로 기록되어 디버깅/운영에 혼선을 줍니다.
Code

apps/web/src/app/api/karaoke/favorites/route.ts[R48-57]

+    const { placeId, placeName, address, lat, lng } = await request.json();
+
+    const { error } = await supabase.from('karaoke_favorites').insert({
+      user_id: userId,
+      place_id: placeId,
+      place_name: placeName,
+      address,
+      lat,
+      lng,
+    });
Evidence
POST/DELETE에서 body를 구조분해해 바로 사용하고, 누락/타입 불일치/JSON 파싱 실패에 대한 분기(400)가 없습니다. 현재는 예외가 catch로 떨어져 일괄 500
응답을 반환합니다.

apps/web/src/app/api/karaoke/favorites/route.ts[43-67]
apps/web/src/app/api/karaoke/favorites/route.ts[70-90]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
즐겨찾기 API가 입력값을 검증하지 않아 클라이언트 입력 문제도 500으로 처리됩니다.
### Issue Context
`request.json()` 파싱 실패/필드 누락(`placeId` 등)/타입 불일치 시 DB 오류 또는 런타임 오류가 발생할 수 있습니다.
### Fix Focus Areas
- apps/web/src/app/api/karaoke/favorites/route.ts[43-67]
- apps/web/src/app/api/karaoke/favorites/route.ts[70-90]
### Suggested approach
- Zod 등으로 body 스키마 검증 후 실패 시 400 반환
- `try { await request.json() } catch { return 400 }`
- UNIQUE 위반 등 예상 가능한 DB 에러는 409 등으로 매핑(선택)

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


12. Favorites request errors bubble 🐞 Bug ☼ Reliability
Description
즐겨찾기 query/mutation 경로가 네트워크/HTTP 오류(예: 401, timeout 등)를 ApiResponse로 정규화하지 않아 예외가 그대로 react-query
에러 상태로 전파됩니다. 결과적으로 지도 페이지에서 즐겨찾기 영역이 에러 상태가 되거나 사용자 피드백 없이 동작이 실패할 수 있습니다.
Code

apps/web/src/queries/karaokeQuery.ts[R10-18]

+  return useQuery({
+    queryKey: ['karaokeFavorites'],
+    queryFn: async () => {
+      const response = await getKaraokeFavorites();
+      if (!response.success) return [];
+      return response.data ?? [];
+    },
+    enabled: isAuthenticated,
+  });
Evidence
API 래퍼(getKaraokeFavorites/post/delete)가 axios 호출 결과만 그대로 반환하며 예외를 ApiResponse 형태로 변환하지 않습니다. 또한
queryFn 내부에도 try/catch가 없어 실패 시 if (!response.success) 분기 자체가 실행되지 못하고 예외가 전파됩니다.

apps/web/src/lib/api/karaokeMap.ts[6-25]
apps/web/src/queries/karaokeQuery.ts[9-18]
apps/web/src/lib/api/client.ts[1-10]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
즐겨찾기 API 호출 실패가 ApiResponse(success:false)로 내려오지 않고 예외로 던져질 수 있어, react-query 에러 상태로 전파됩니다.
### Issue Context
현재 `karaokeQuery`는 `response.success`만 보고 분기하지만, `getKaraokeFavorites()`가 throw하면 해당 분기까지 도달하지 못합니다.
### Fix Focus Areas
- apps/web/src/lib/api/client.ts[1-10]
- apps/web/src/lib/api/karaokeMap.ts[6-25]
- apps/web/src/queries/karaokeQuery.ts[9-18]
### Suggested approach
- (권장) axios response interceptor를 추가해 non-2xx를 `{success:false,error}` 형태로 변환하거나
- queryFn/mutationFn에서 try/catch로 감싸서 `{success:false}`로 변환하고, UI에서 toast/onError 처리
- 특히 401은 사용자 메시지(로그인 필요)로 명확히 처리

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Advisory comments

13. Script load failure silent 🐞 Bug ☼ Reliability ⭐ New
Description
Kakao SDK Script가 로딩 실패(키 누락/차단/네트워크 오류)해도 onError 처리나 사용자 안내가 없어 지도 영역이 빈 화면으로 남습니다. 또한 onLoad만으로
상태를 전환해 실패 시 initMap이 영원히 호출되지 않습니다.
Code

apps/web/src/components/KakaoMap.tsx[R53-56]

+      <Script
+        src={`//dapi.kakao.com/v2/maps/sdk.js?appkey=${process.env.NEXT_PUBLIC_KAKAO_MAP_KEY}&libraries=services&autoload=false`}
+        onLoad={() => setIsScriptLoaded(true)}
+      />
Evidence
현재 Script에는 onLoad만 있고 onError가 없으며, isScriptLoaded는 onLoad에서만 true가 됩니다. 따라서 스크립트 로드 실패 시 initMap
트리거가 발생하지 않아 지도 실패 원인을 파악하기 어렵습니다.

apps/web/src/components/KakaoMap.tsx[53-56]
apps/web/src/hooks/useKakaoMap.ts[80-84]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
Kakao SDK 스크립트 로드 실패 시 사용자/개발자에게 알려줄 경로가 없습니다.

### Issue Context
지도 기능은 외부 스크립트에 의존하므로 실패 케이스(광고차단/네트워크/키 설정 오류)를 대비한 fallback이 필요합니다.

### Fix Focus Areas
- apps/web/src/components/KakaoMap.tsx[53-56]
- apps/web/src/hooks/useKakaoMap.ts[80-84]

### Suggested fix
- `<Script ... onError={() => { toast.error('지도 로딩에 실패했습니다'); /* 로그 */ }} />` 추가.
- `NEXT_PUBLIC_KAKAO_MAP_KEY`가 falsy인 경우 Script를 렌더링하지 않고 명확한 안내 UI를 표시.
- 필요하면 로딩/에러 상태를 `useKakaoMap`에서 함께 관리해 화면에 표시하세요.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


14. POPULAR animation unreachable 🐞 Bug ⚙ Maintainability
Description
Footer 네비게이션에서 POPULAR 탭이 제거되었지만, 기존 코드가 여전히 setFooterAnimateKey('POPULAR')를 호출하고 있어 해당 애니메이션은 더 이상
표시되지 않습니다. 사용자에게 의도한 피드백이 사라지고 키 관리가 불명확해집니다.
Code

apps/web/src/Footer.tsx[R17-23]

const navigation: Navigation[] = [
 { name: '최신 곡', href: '/recent', key: 'RECENT' },
-
 { name: '부를 곡', href: '/tosing', key: 'TOSING' },
 { name: '검색', href: '/', key: 'SEARCH' },
-
-  { name: '인기곡', href: '/popular', key: 'POPULAR' },
+  { name: '지도', href: '/map', key: 'MAP' },
 { name: '정보', href: '/info', key: 'INFO' },
];
Evidence
Footer navigation 배열에는 MAP이 추가되고 POPULAR 항목은 존재하지 않습니다. 반면 다른 컴포넌트는 POPULAR 키로 애니메이션을 트리거하고 있어, 트리거된
애니메이션이 렌더링될 대상이 없습니다.

apps/web/src/Footer.tsx[17-23]
apps/web/src/components/ThumbUpModal.tsx[43-51]
apps/web/src/stores/useFooterAnimateStore.ts[3-4]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Footer에서 POPULAR 탭이 제거되어도 POPULAR 애니메이션 키가 여전히 사용되고 있어 효과가 사라집니다.
### Issue Context
키 타입에는 POPULAR이 남아 있고, 실제 탭 목록에는 POPULAR이 없습니다.
### Fix Focus Areas
- apps/web/src/Footer.tsx[17-23]
- apps/web/src/components/ThumbUpModal.tsx[43-51]
- apps/web/src/stores/useFooterAnimateStore.ts[3-4]
### Suggested approach
- POPULAR 탭 제거가 의도라면: ThumbUpModal 등에서 트리거 키를 실제 존재하는 탭 키로 변경하거나, 애니메이션 트리거 자체 제거
- POPULAR 탭 유지가 의도라면: Footer navigation에 POPULAR을 복구하고 MAP 추가로 인한 탭 구성 재조정

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Qodo Logo

@qodo-code-review
Copy link
Copy Markdown

qodo-code-review Bot commented May 7, 2026

Code Review by Qodo

Grey Divider

New Review Started

This review has been superseded by a new analysis

Grey Divider

Qodo Logo

@qodo-code-review
Copy link
Copy Markdown

Review Summary by Qodo

Add Kakao Map-based karaoke location search and favorites feature

✨ Enhancement

Grey Divider

Walkthroughs

Description
• Add Kakao Map integration for karaoke location search within 1km radius
• Implement favorites management system with Supabase backend
• Create /map public page with geolocation and marker display
• Add Map tab to footer navigation with authentication-aware favorites
Diagram
flowchart LR
  A["Kakao Map SDK"] -->|"Initialize & Search"| B["useKakaoMap Hook"]
  B -->|"Display Markers"| C["KakaoMap Component"]
  C -->|"Toggle Favorite"| D["Favorites API Route"]
  D -->|"CRUD Operations"| E["Supabase karaoke_favorites"]
  C -->|"Query Favorites"| F["karaokeQuery Hooks"]
  F -->|"Cache Management"| G["React Query"]
  H["Map Page"] -->|"Render"| C
  I["Footer Navigation"] -->|"Link to"| H
Loading

Grey Divider

File Changes

1. apps/web/src/app/api/karaoke/favorites/route.ts ✨ Enhancement +91/-0

API endpoints for favorites CRUD operations

apps/web/src/app/api/karaoke/favorites/route.ts


2. apps/web/src/hooks/useKakaoMap.ts ✨ Enhancement +87/-0

Custom hook for Kakao Map initialization and search

apps/web/src/hooks/useKakaoMap.ts


3. apps/web/src/lib/api/karaokeMap.ts ✨ Enhancement +25/-0

API client functions for karaoke favorites

apps/web/src/lib/api/karaokeMap.ts


View more (7)
4. apps/web/src/queries/karaokeQuery.ts ✨ Enhancement +47/-0

React Query hooks for favorites data management

apps/web/src/queries/karaokeQuery.ts


5. apps/web/src/types/karaoke.ts ✨ Enhancement +21/-0

Type definitions for karaoke and Kakao Place

apps/web/src/types/karaoke.ts


6. apps/web/src/components/KakaoMap.tsx ✨ Enhancement +108/-0

Map component with favorites toggle and display

apps/web/src/components/KakaoMap.tsx


7. apps/web/src/app/map/page.tsx ✨ Enhancement +12/-0

New map page for karaoke location search

apps/web/src/app/map/page.tsx


8. apps/web/src/Footer.tsx ✨ Enhancement +1/-3

Add Map navigation tab to footer

apps/web/src/Footer.tsx


9. apps/web/src/stores/useFooterAnimateStore.ts ✨ Enhancement +1/-1

Add MAP key to FooterKey type union

apps/web/src/stores/useFooterAnimateStore.ts


10. apps/web/src/auth.tsx ⚙️ Configuration changes +1/-0

Add /map to public allowed paths

apps/web/src/auth.tsx


Grey Divider

Qodo Logo

@qodo-code-review
Copy link
Copy Markdown

qodo-code-review Bot commented May 7, 2026

Code Review by Qodo

🐞 Bugs (5) 📘 Rule violations (2) 📎 Requirement gaps (1)

Context used

Grey Divider


Action required

1. Kakao Places called in browser 📘 Rule violation ⛨ Security
Description
Client code directly loads the Kakao Maps SDK and calls Places().keywordSearch() in the browser,
which is an external API interaction outside /api/*. This violates the BFF requirement and
bypasses centralized auth/validation and request controls.
Code

apps/web/src/hooks/useKakaoMap.ts[R20-49]

+  const searchNearbyKaraoke = useCallback((map: any, lat: number, lng: number) => {
+    const ps = new window.kakao.maps.services.Places();
+    const center = new window.kakao.maps.LatLng(lat, lng);
+    const infowindow = new window.kakao.maps.InfoWindow({ zIndex: 1 });
+
+    ps.keywordSearch(
+      '노래방',
+      (data: KakaoPlace[], status: string) => {
+        if (status !== window.kakao.maps.services.Status.OK) return;
+
+        data.forEach(place => {
+          const position = new window.kakao.maps.LatLng(Number(place.y), Number(place.x));
+          const marker = new window.kakao.maps.Marker({ map, position });
+
+          window.kakao.maps.event.addListener(marker, 'click', () => {
+            infowindow.close();
+            setSelectedPlace(place);
+            infowindow.setContent(
+              `<div style="padding:6px 10px;font-size:13px;font-weight:bold;">${place.place_name}</div>`,
+            );
+            infowindow.open(map, marker);
+          });
+        });
+      },
+      {
+        location: center,
+        radius: 1000,
+        sort: window.kakao.maps.services.SortBy.DISTANCE,
+      },
+    );
Evidence
PR Compliance ID 8 requires browser/client code to call only internal Next.js API routes (/api/*)
and not external APIs directly. The added client hook uses Kakao's Places service (keywordSearch)
and the component loads the external Kakao SDK script URL directly in the browser.

CLAUDE.md
apps/web/src/hooks/useKakaoMap.ts[20-49]
apps/web/src/components/KakaoMap.tsx[53-56]
CLAUDE.md[50-58]
apps/web/CLAUDE.md[43-47]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Browser/client code is directly calling an external API (Kakao Places via JS SDK) instead of routing through internal `/api/*` endpoints (BFF pattern).

## Issue Context
`useKakaoMap` uses `new window.kakao.maps.services.Places()` and `keywordSearch()`, and `KakaoMap` loads Kakao's SDK script in the client.

## Fix Focus Areas
- apps/web/src/hooks/useKakaoMap.ts[20-49]
- apps/web/src/components/KakaoMap.tsx[53-56]
- apps/web/src/app/api/[...]/route.ts[1-200]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. CLAUDE.md missing map docs 📘 Rule violation ⚙ Maintainability
Description
The PR introduces a new public route (/map) and a required env var (NEXT_PUBLIC_KAKAO_MAP_KEY)
but does not update apps/web/CLAUDE.md to reflect these changes. This creates onboarding/setup
drift and can cause local runs to fail or behave unexpectedly.
Code

apps/web/src/components/KakaoMap.tsx[R53-56]

+      <Script
+        src={`//dapi.kakao.com/v2/maps/sdk.js?appkey=${process.env.NEXT_PUBLIC_KAKAO_MAP_KEY}&libraries=services&autoload=false`}
+        onLoad={() => setIsScriptLoaded(true)}
+      />
Evidence
PR Compliance ID 10 requires updating CLAUDE.md when repo rules/structure/architecture or
build/setup requirements change. The PR adds a new public route (/map) and references a new
required env var, but the web CLAUDE doc still lists public routes and required env vars without
these additions.

CLAUDE.md
apps/web/src/auth.tsx[13-16]
apps/web/src/components/KakaoMap.tsx[53-56]
apps/web/CLAUDE.md[59-62]
apps/web/CLAUDE.md[89-98]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`apps/web/CLAUDE.md` is outdated relative to the PR changes (new public route and new required env var).

## Issue Context
- `/map` was added to public allow paths.
- `NEXT_PUBLIC_KAKAO_MAP_KEY` is now required for Kakao map loading.

## Fix Focus Areas
- apps/web/CLAUDE.md[59-62]
- apps/web/CLAUDE.md[89-98]
- apps/web/src/auth.tsx[13-16]
- apps/web/src/components/KakaoMap.tsx[53-56]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. InfoWindow XSS risk 🐞 Bug ⛨ Security
Description
useKakaoMap()이 Kakao InfoWindow content를 HTML 문자열로 만들면서 외부 데이터인 place.place_name을 그대로 보간해 DOM에
주입합니다. 악의적인 place_name 값이 들어오면 InfoWindow 영역에서 스크립트/HTML 실행(XSS)로 이어질 수 있습니다.
Code

apps/web/src/hooks/useKakaoMap.ts[R34-41]

+          window.kakao.maps.event.addListener(marker, 'click', () => {
+            infowindow.close();
+            setSelectedPlace(place);
+            infowindow.setContent(
+              `<div style="padding:6px 10px;font-size:13px;font-weight:bold;">${place.place_name}</div>`,
+            );
+            infowindow.open(map, marker);
+          });
Evidence
InfoWindow의 setContent에 HTML string을 사용하고, 그 안에 외부 API 응답 필드(place_name)를 그대로 삽입하고 있습니다. KakaoPlace는
지도 API 응답을 그대로 나타내는 타입이므로 신뢰할 수 없는 입력으로 취급해야 합니다.

apps/web/src/hooks/useKakaoMap.ts[25-41]
apps/web/src/types/karaoke.ts[12-21]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`infowindow.setContent()`에 외부 데이터(`place.place_name`)를 HTML 문자열로 직접 삽입해 XSS가 가능해집니다.

### Issue Context
Kakao Maps InfoWindow는 문자열 content를 HTML로 렌더링합니다. 따라서 place_name을 HTML-escape 하거나, 문자열 대신 DOM 노드를 만들어 `textContent`로 넣어야 합니다.

### Fix Focus Areas
- apps/web/src/hooks/useKakaoMap.ts[34-41]

### Suggested approach
- `const div = document.createElement('div'); div.style...; div.textContent = place.place_name; infowindow.setContent(div);` 처럼 DOM node 기반으로 설정
- 또는 최소한 HTML escape 유틸을 만들어 `${escapeHtml(place.place_name)}` 적용

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

4. Favorites API missing update 📎 Requirement gap ⚙ Maintainability
Description
The new favorites API route implements GET, POST, and DELETE but no update endpoint (e.g.,
PUT/PATCH). If the contract requires full CRUD, clients cannot modify existing favorites via the
API.
Code

apps/web/src/app/api/karaoke/favorites/route.ts[R21-91]

+export async function GET(): Promise<NextResponse<ApiResponse<KaraokeFavorite[]>>> {
+  try {
+    const supabase = await createClient();
+    const userId = await getAuthenticatedUser(supabase);
+
+    const { data, error } = await supabase
+      .from('karaoke_favorites')
+      .select('*')
+      .eq('user_id', userId)
+      .order('created_at', { ascending: false });
+
+    if (error) throw error;
+
+    return NextResponse.json({ success: true, data: data ?? [] });
+  } catch (error) {
+    if (error instanceof Error && error.cause === 'auth') {
+      return NextResponse.json({ success: false, error: 'User not authenticated' }, { status: 401 });
+    }
+    return NextResponse.json({ success: false, error: 'Failed to get favorites' }, { status: 500 });
+  }
+}
+
+export async function POST(request: Request): Promise<NextResponse<ApiResponse<void>>> {
+  try {
+    const supabase = await createClient();
+    const userId = await getAuthenticatedUser(supabase);
+
+    const { placeId, placeName, address, lat, lng } = await request.json();
+
+    const { error } = await supabase.from('karaoke_favorites').insert({
+      user_id: userId,
+      place_id: placeId,
+      place_name: placeName,
+      address,
+      lat,
+      lng,
+    });
+
+    if (error) throw error;
+
+    return NextResponse.json({ success: true });
+  } catch (error) {
+    if (error instanceof Error && error.cause === 'auth') {
+      return NextResponse.json({ success: false, error: 'User not authenticated' }, { status: 401 });
+    }
+    return NextResponse.json({ success: false, error: 'Failed to add favorite' }, { status: 500 });
+  }
+}
+
+export async function DELETE(request: Request): Promise<NextResponse<ApiResponse<void>>> {
+  try {
+    const supabase = await createClient();
+    const userId = await getAuthenticatedUser(supabase);
+
+    const { placeId } = await request.json();
+
+    const { error } = await supabase
+      .from('karaoke_favorites')
+      .delete()
+      .match({ user_id: userId, place_id: placeId });
+
+    if (error) throw error;
+
+    return NextResponse.json({ success: true });
+  } catch (error) {
+    if (error instanceof Error && error.cause === 'auth') {
+      return NextResponse.json({ success: false, error: 'User not authenticated' }, { status: 401 });
+    }
+    return NextResponse.json({ success: false, error: 'Failed to delete favorite' }, { status: 500 });
+  }
+}
Evidence
PR Compliance ID 7 requires API routes for favorites CRUD; the added route defines only GET,
POST, and DELETE exports and provides no PUT/PATCH handler for updates.

즐겨찾기 CRUD를 위한 API 라우트 구현
apps/web/src/app/api/karaoke/favorites/route.ts[21-91]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The favorites API route lacks an update method (`PUT`/`PATCH`), so it does not meet a strict CRUD requirement.

## Issue Context
Current route exports: `GET`, `POST`, `DELETE` only.

## Fix Focus Areas
- apps/web/src/app/api/karaoke/favorites/route.ts[21-91]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


5. Kakao script silent failure 🐞 Bug ☼ Reliability
Description
KakaoMap에서 Script 로딩 성공(onLoad)만 처리하고 실패(onError) 및 API 키 누락을 처리하지 않아, SDK 로드 실패 시 지도 초기화가 영구히 진행되지
않습니다. 사용자는 원인 안내 없이 빈 지도 영역만 보게 됩니다.
Code

apps/web/src/components/KakaoMap.tsx[R53-56]

+      <Script
+        src={`//dapi.kakao.com/v2/maps/sdk.js?appkey=${process.env.NEXT_PUBLIC_KAKAO_MAP_KEY}&libraries=services&autoload=false`}
+        onLoad={() => setIsScriptLoaded(true)}
+      />
Evidence
KakaoMap은 Script onLoad에서만 isScriptLoaded를 true로 바꾸고, useKakaoMap은 isScriptLoaded가 true일 때만 initMap을
호출합니다. 즉 Script 로드 실패/키 누락 등으로 onLoad가 실행되지 않으면 initMap이 영원히 호출되지 않습니다.

apps/web/src/components/KakaoMap.tsx[53-56]
apps/web/src/hooks/useKakaoMap.ts[80-84]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
Kakao 지도 SDK 스크립트 로드 실패 시 사용자에게 아무 안내가 없고, 지도 초기화도 수행되지 않습니다.

### Issue Context
`isScriptLoaded`가 true일 때만 `initMap()`이 호출되므로, 스크립트가 실패하면 UI가 정지 상태가 됩니다.

### Fix Focus Areas
- apps/web/src/components/KakaoMap.tsx[53-56]
- apps/web/src/hooks/useKakaoMap.ts[80-84]

### Suggested approach
- `NEXT_PUBLIC_KAKAO_MAP_KEY`가 없으면 렌더 전에 안내 문구/에러 토스트 표시
- `<Script onError={() => ...}>` 추가하여 토스트 + 에러 상태를 세팅하고 대체 UI 렌더
- 필요하면 `setIsScriptLoaded(false)`와 별도의 `scriptError` state를 도입

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


6. Favorites API no validation 🐞 Bug ☼ Reliability
Description
즐겨찾기 POST/DELETE가 request.json()으로 받은 값을 검증 없이 바로 DB insert/delete에 사용해, 필수 필드 누락/잘못된 JSON 요청 시 400이
아닌 500으로 처리됩니다. 클라이언트 오류가 서버 오류로 기록되어 디버깅/운영에 혼선을 줍니다.
Code

apps/web/src/app/api/karaoke/favorites/route.ts[R48-57]

+    const { placeId, placeName, address, lat, lng } = await request.json();
+
+    const { error } = await supabase.from('karaoke_favorites').insert({
+      user_id: userId,
+      place_id: placeId,
+      place_name: placeName,
+      address,
+      lat,
+      lng,
+    });
Evidence
POST/DELETE에서 body를 구조분해해 바로 사용하고, 누락/타입 불일치/JSON 파싱 실패에 대한 분기(400)가 없습니다. 현재는 예외가 catch로 떨어져 일괄 500
응답을 반환합니다.

apps/web/src/app/api/karaoke/favorites/route.ts[43-67]
apps/web/src/app/api/karaoke/favorites/route.ts[70-90]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
즐겨찾기 API가 입력값을 검증하지 않아 클라이언트 입력 문제도 500으로 처리됩니다.

### Issue Context
`request.json()` 파싱 실패/필드 누락(`placeId` 등)/타입 불일치 시 DB 오류 또는 런타임 오류가 발생할 수 있습니다.

### Fix Focus Areas
- apps/web/src/app/api/karaoke/favorites/route.ts[43-67]
- apps/web/src/app/api/karaoke/favorites/route.ts[70-90]

### Suggested approach
- Zod 등으로 body 스키마 검증 후 실패 시 400 반환
- `try { await request.json() } catch { return 400 }`
- UNIQUE 위반 등 예상 가능한 DB 에러는 409 등으로 매핑(선택)

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


View more (1)
7. Favorites request errors bubble 🐞 Bug ☼ Reliability
Description
즐겨찾기 query/mutation 경로가 네트워크/HTTP 오류(예: 401, timeout 등)를 ApiResponse로 정규화하지 않아 예외가 그대로 react-query
에러 상태로 전파됩니다. 결과적으로 지도 페이지에서 즐겨찾기 영역이 에러 상태가 되거나 사용자 피드백 없이 동작이 실패할 수 있습니다.
Code

apps/web/src/queries/karaokeQuery.ts[R10-18]

+  return useQuery({
+    queryKey: ['karaokeFavorites'],
+    queryFn: async () => {
+      const response = await getKaraokeFavorites();
+      if (!response.success) return [];
+      return response.data ?? [];
+    },
+    enabled: isAuthenticated,
+  });
Evidence
API 래퍼(getKaraokeFavorites/post/delete)가 axios 호출 결과만 그대로 반환하며 예외를 ApiResponse 형태로 변환하지 않습니다. 또한
queryFn 내부에도 try/catch가 없어 실패 시 if (!response.success) 분기 자체가 실행되지 못하고 예외가 전파됩니다.

apps/web/src/lib/api/karaokeMap.ts[6-25]
apps/web/src/queries/karaokeQuery.ts[9-18]
apps/web/src/lib/api/client.ts[1-10]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
즐겨찾기 API 호출 실패가 ApiResponse(success:false)로 내려오지 않고 예외로 던져질 수 있어, react-query 에러 상태로 전파됩니다.

### Issue Context
현재 `karaokeQuery`는 `response.success`만 보고 분기하지만, `getKaraokeFavorites()`가 throw하면 해당 분기까지 도달하지 못합니다.

### Fix Focus Areas
- apps/web/src/lib/api/client.ts[1-10]
- apps/web/src/lib/api/karaokeMap.ts[6-25]
- apps/web/src/queries/karaokeQuery.ts[9-18]

### Suggested approach
- (권장) axios response interceptor를 추가해 non-2xx를 `{success:false,error}` 형태로 변환하거나
- queryFn/mutationFn에서 try/catch로 감싸서 `{success:false}`로 변환하고, UI에서 toast/onError 처리
- 특히 401은 사용자 메시지(로그인 필요)로 명확히 처리

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Advisory comments

8. POPULAR animation unreachable 🐞 Bug ⚙ Maintainability
Description
Footer 네비게이션에서 POPULAR 탭이 제거되었지만, 기존 코드가 여전히 setFooterAnimateKey('POPULAR')를 호출하고 있어 해당 애니메이션은 더 이상
표시되지 않습니다. 사용자에게 의도한 피드백이 사라지고 키 관리가 불명확해집니다.
Code

apps/web/src/Footer.tsx[R17-23]

const navigation: Navigation[] = [
  { name: '최신 곡', href: '/recent', key: 'RECENT' },
-
  { name: '부를 곡', href: '/tosing', key: 'TOSING' },
  { name: '검색', href: '/', key: 'SEARCH' },
-
-  { name: '인기곡', href: '/popular', key: 'POPULAR' },
+  { name: '지도', href: '/map', key: 'MAP' },
  { name: '정보', href: '/info', key: 'INFO' },
];
Evidence
Footer navigation 배열에는 MAP이 추가되고 POPULAR 항목은 존재하지 않습니다. 반면 다른 컴포넌트는 POPULAR 키로 애니메이션을 트리거하고 있어, 트리거된
애니메이션이 렌더링될 대상이 없습니다.

apps/web/src/Footer.tsx[17-23]
apps/web/src/components/ThumbUpModal.tsx[43-51]
apps/web/src/stores/useFooterAnimateStore.ts[3-4]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
Footer에서 POPULAR 탭이 제거되어도 POPULAR 애니메이션 키가 여전히 사용되고 있어 효과가 사라집니다.

### Issue Context
키 타입에는 POPULAR이 남아 있고, 실제 탭 목록에는 POPULAR이 없습니다.

### Fix Focus Areas
- apps/web/src/Footer.tsx[17-23]
- apps/web/src/components/ThumbUpModal.tsx[43-51]
- apps/web/src/stores/useFooterAnimateStore.ts[3-4]

### Suggested approach
- POPULAR 탭 제거가 의도라면: ThumbUpModal 등에서 트리거 키를 실제 존재하는 탭 키로 변경하거나, 애니메이션 트리거 자체 제거
- POPULAR 탭 유지가 의도라면: Footer navigation에 POPULAR을 복구하고 MAP 추가로 인한 탭 구성 재조정

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Qodo Logo

Comment on lines +20 to +49
const searchNearbyKaraoke = useCallback((map: any, lat: number, lng: number) => {
const ps = new window.kakao.maps.services.Places();
const center = new window.kakao.maps.LatLng(lat, lng);
const infowindow = new window.kakao.maps.InfoWindow({ zIndex: 1 });

ps.keywordSearch(
'노래방',
(data: KakaoPlace[], status: string) => {
if (status !== window.kakao.maps.services.Status.OK) return;

data.forEach(place => {
const position = new window.kakao.maps.LatLng(Number(place.y), Number(place.x));
const marker = new window.kakao.maps.Marker({ map, position });

window.kakao.maps.event.addListener(marker, 'click', () => {
infowindow.close();
setSelectedPlace(place);
infowindow.setContent(
`<div style="padding:6px 10px;font-size:13px;font-weight:bold;">${place.place_name}</div>`,
);
infowindow.open(map, marker);
});
});
},
{
location: center,
radius: 1000,
sort: window.kakao.maps.services.SortBy.DISTANCE,
},
);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Action required

1. Kakao places called in browser 📘 Rule violation ⛨ Security

Client code directly loads the Kakao Maps SDK and calls Places().keywordSearch() in the browser,
which is an external API interaction outside /api/*. This violates the BFF requirement and
bypasses centralized auth/validation and request controls.
Agent Prompt
## Issue description
Browser/client code is directly calling an external API (Kakao Places via JS SDK) instead of routing through internal `/api/*` endpoints (BFF pattern).

## Issue Context
`useKakaoMap` uses `new window.kakao.maps.services.Places()` and `keywordSearch()`, and `KakaoMap` loads Kakao's SDK script in the client.

## Fix Focus Areas
- apps/web/src/hooks/useKakaoMap.ts[20-49]
- apps/web/src/components/KakaoMap.tsx[53-56]
- apps/web/src/app/api/[...]/route.ts[1-200]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment on lines +53 to +56
<Script
src={`//dapi.kakao.com/v2/maps/sdk.js?appkey=${process.env.NEXT_PUBLIC_KAKAO_MAP_KEY}&libraries=services&autoload=false`}
onLoad={() => setIsScriptLoaded(true)}
/>
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Action required

2. Claude.md missing map docs 📘 Rule violation ⚙ Maintainability

The PR introduces a new public route (/map) and a required env var (NEXT_PUBLIC_KAKAO_MAP_KEY)
but does not update apps/web/CLAUDE.md to reflect these changes. This creates onboarding/setup
drift and can cause local runs to fail or behave unexpectedly.
Agent Prompt
## Issue description
`apps/web/CLAUDE.md` is outdated relative to the PR changes (new public route and new required env var).

## Issue Context
- `/map` was added to public allow paths.
- `NEXT_PUBLIC_KAKAO_MAP_KEY` is now required for Kakao map loading.

## Fix Focus Areas
- apps/web/CLAUDE.md[59-62]
- apps/web/CLAUDE.md[89-98]
- apps/web/src/auth.tsx[13-16]
- apps/web/src/components/KakaoMap.tsx[53-56]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment on lines +34 to +41
window.kakao.maps.event.addListener(marker, 'click', () => {
infowindow.close();
setSelectedPlace(place);
infowindow.setContent(
`<div style="padding:6px 10px;font-size:13px;font-weight:bold;">${place.place_name}</div>`,
);
infowindow.open(map, marker);
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Action required

3. Infowindow xss risk 🐞 Bug ⛨ Security

useKakaoMap()이 Kakao InfoWindow content를 HTML 문자열로 만들면서 외부 데이터인 place.place_name을 그대로 보간해 DOM에
주입합니다. 악의적인 place_name 값이 들어오면 InfoWindow 영역에서 스크립트/HTML 실행(XSS)로 이어질 수 있습니다.
Agent Prompt
### Issue description
`infowindow.setContent()`에 외부 데이터(`place.place_name`)를 HTML 문자열로 직접 삽입해 XSS가 가능해집니다.

### Issue Context
Kakao Maps InfoWindow는 문자열 content를 HTML로 렌더링합니다. 따라서 place_name을 HTML-escape 하거나, 문자열 대신 DOM 노드를 만들어 `textContent`로 넣어야 합니다.

### Fix Focus Areas
- apps/web/src/hooks/useKakaoMap.ts[34-41]

### Suggested approach
- `const div = document.createElement('div'); div.style...; div.textContent = place.place_name; infowindow.setContent(div);` 처럼 DOM node 기반으로 설정
- 또는 최소한 HTML escape 유틸을 만들어 `${escapeHtml(place.place_name)}` 적용

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment on lines +20 to +49
const searchNearbyKaraoke = useCallback((map: any, lat: number, lng: number) => {
const ps = new window.kakao.maps.services.Places();
const center = new window.kakao.maps.LatLng(lat, lng);
const infowindow = new window.kakao.maps.InfoWindow({ zIndex: 1 });

ps.keywordSearch(
'노래방',
(data: KakaoPlace[], status: string) => {
if (status !== window.kakao.maps.services.Status.OK) return;

data.forEach(place => {
const position = new window.kakao.maps.LatLng(Number(place.y), Number(place.x));
const marker = new window.kakao.maps.Marker({ map, position });

window.kakao.maps.event.addListener(marker, 'click', () => {
infowindow.close();
setSelectedPlace(place);
infowindow.setContent(
`<div style="padding:6px 10px;font-size:13px;font-weight:bold;">${place.place_name}</div>`,
);
infowindow.open(map, marker);
});
});
},
{
location: center,
radius: 1000,
sort: window.kakao.maps.services.SortBy.DISTANCE,
},
);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Action required

1. No place search input 📎 Requirement gap ≡ Correctness

The new /map UI only performs a fixed keyword search ('노래방') around the current/fallback
location and does not provide a user-driven place search input/results UI. This fails the
requirement for a location search page that includes place search functionality.
Agent Prompt
## Issue description
`/map` currently has no user-driven place search UI (input/results); it only runs a fixed keyword search (`'노래방'`).

## Issue Context
Compliance requires a location search page with map display + place search UI/logic (search input and results display).

## Fix Focus Areas
- apps/web/src/components/KakaoMap.tsx[51-60]
- apps/web/src/hooks/useKakaoMap.ts[20-49]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment on lines +81 to +104
{favorites.length > 0 && (
<div className="bg-card rounded-lg border p-4">
<p className="mb-2 font-semibold">즐겨찾기 ({favorites.length})</p>
<ul className="flex flex-col gap-2">
{favorites.map(fav => (
<li key={fav.place_id} className="flex items-center justify-between text-sm">
<span>{fav.place_name}</span>
<Button
size="sm"
variant="ghost"
className="text-muted-foreground h-auto px-2 py-1 text-xs"
onClick={() =>
deleteFavorite.mutate(fav.place_id, {
onSuccess: () => toast.success(`${fav.place_name} 삭제`),
})
}
>
삭제
</Button>
</li>
))}
</ul>
</div>
)}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Action required

2. Karaoke favorites page missing 📎 Requirement gap ≡ Correctness

There is no dedicated page/route that displays the user's saved karaoke favorites; favorites are
only shown inline within the map component. This does not meet the requirement for a standalone
favorites list page.
Agent Prompt
## Issue description
A dedicated karaoke favorites list page/route is missing; favorites are only shown within the map UI.

## Issue Context
Compliance requires a standalone page that fetches and displays favorites stored in Supabase (via the internal `/api/karaoke/favorites` route).

## Fix Focus Areas
- apps/web/src/app/map/page.tsx[1-12]
- apps/web/src/components/KakaoMap.tsx[81-104]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment on lines +34 to +41
window.kakao.maps.event.addListener(marker, 'click', () => {
infowindow.close();
setSelectedPlace(place);
infowindow.setContent(
`<div style="padding:6px 10px;font-size:13px;font-weight:bold;">${place.place_name}</div>`,
);
infowindow.open(map, marker);
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Action required

3. Kakao infowindow xss 🐞 Bug ⛨ Security

useKakaoMap()에서 외부(Kakao Places API)로부터 온 place_name을 HTML 문자열에 그대로 삽입해 InfoWindow.setContent()에 넘겨
DOM 기반 XSS가 가능합니다. 악성 place_name이 포함되면 스크립트 실행/세션 탈취 등으로 이어질 수 있습니다.
Agent Prompt
### Issue description
`InfoWindow.setContent()`에 전달하는 문자열에 `place.place_name`을 그대로 삽입하고 있어, 외부 데이터 기반 XSS가 가능합니다.

### Issue Context
Kakao Places API 응답은 신뢰할 수 없는 입력으로 취급해야 합니다. `setContent()`에는 HTML 문자열 대신 DOM 노드를 사용하거나, 반드시 HTML escaping/sanitization을 적용해야 합니다.

### Fix Focus Areas
- apps/web/src/hooks/useKakaoMap.ts[25-41]

### Suggested fix
- 가능하면 DOM 엘리먼트를 만들어 `textContent`로 값을 세팅한 뒤 `setContent(element)`로 전달하세요.
  - 예: `const el = document.createElement('div'); el.style...; el.textContent = place.place_name; infowindow.setContent(el);`
- HTML 문자열을 유지해야 한다면 최소한 `place_name`을 HTML escape 처리(예: `& < > " '` 변환)하거나, 프로젝트 정책에 맞는 sanitizer(예: DOMPurify)를 적용하세요.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

@qodo-code-review
Copy link
Copy Markdown

PR Description updated to latest commit (cdebcc6)

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.

지도 API를 사용한 노래방 위치 검색 및 즐겨찾기 기능 추가

1 participant