Compare commits

..

41 Commits

Author SHA1 Message Date
joungmin
250b067d87 fix(map): NaverMapView selected 변경 시 자동 panTo + zoom
- GoogleMapView에는 있던 useEffect [selected] 패턴이 NaverMapView에 누락
- 마커/클러스터/리스트 어디서 선택해도 그 식당이 중앙 + zoom 16

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-17 09:42:37 +09:00
joungmin
6a885c5203 docs(changelog): v0.1.63 미커밋 잡변경 정리 5건
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-16 19:35:24 +09:00
joungmin
a9dc1dad6a chore(dev): PM2 frontend PORT=3001 env + reviews/screenshots gitignore
- tasteby-web: PORT=3001 명시 (env 누락 → 기본 3000으로 충돌 위험)
- reviews/, screenshots/ 로컬 작업 산출물 무시

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-16 19:34:58 +09:00
joungmin
21eb1e9562 docs(deploy): OKE 배포 가이드 추가
- Colima(로컬 ARM64 Docker) → OCIR → OKE 파이프라인 전체 절차
- deploy.sh 사용법, kubectl context, secret 등록 흐름

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-16 19:34:58 +09:00
joungmin
94be5a81e6 feat(frontend): 아이콘 시스템 일관성 + Phosphor 마이그레이션
- Icon.tsx: 크기 고정 + flex center로 정렬 일관성
- cuisine-icons.ts: Tabler → Phosphor 매핑(BowlFood/FishSimple/Pizza 등)
- FoodIcon.tsx 신규: 한식 specific 자체 SVG (찌개/전골/탕)
- frontend/docs/brand-guide.md 신규: 브랜드 아이덴티티 + 디자인 시스템 정책

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-16 19:34:58 +09:00
joungmin
1d767bee37 feat(util): JsonUtil.normalizeEvaluation — 평문→JSON 래핑 + 300자 제한
- evaluation 컬럼이 IS JSON 제약이라 평문은 {"text":"..."}로 정규화
- parseMap이 잘못된 JSON 받았을 때 빈 Map 대신 {"text":원문}으로 보존
- PipelineService/RestaurantService에서 이미 호출 중인 유틸 — 미커밋 상태였음

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-16 19:34:58 +09:00
joungmin
0676a31cfd feat(admin): 사용자 어드민 권한 토글 backend 연결
- AdminUserController.updateAdmin이 호출하던 service/mapper 미커밋 상태였음
- CORS allowedMethods에 PATCH 추가 (PATCH /api/admin/users/{id}/admin)
- UserMapper.updateAdmin + XML (is_admin 0/1)
- UserService.updateAdmin (트랜잭션 + 404 가드)
- findAllWithCounts에 is_admin 컬럼 SELECT 추가

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-16 19:34:58 +09:00
joungmin
1164139312 feat(map): 식당 선택 시 지도 자동 줌인/이동
- 리스트/검색결과에서 식당 클릭 → setRegionFlyTo로 그 좌표 + zoom 16
- NaverMap/GoogleMap dispatcher 둘 다 panTo + setZoom
- 마커 selected 강조(1.15× 파란 박스)와 함께 동작

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-16 17:29:28 +09:00
joungmin
78f7e83a0e feat(map): NaverMapView 마커에 식당명 박스 (GoogleMap 동일)
- 단순 동그라미 → 박스+화살표+식당명+cuisine 아이콘 핀
- 채널별 bg/text/border/arrow 색상
- 폐업(CLOSED_*) 회색 + 취소선, selected 강조 (1.15× + 파란박스)
- InfoWindow 제거 (식당명이 박스로 보임)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-16 17:13:37 +09:00
joungmin
247547c516 feat(map): NaverMapView 채널별 마커 색상
- GoogleMapView와 동일 팔레트 (8 색)
- 식당 첫 채널 기준 색상, activeChannel 있으면 우선
- getChannelColorMap 재사용 패턴 동일

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-16 17:09:02 +09:00
joungmin
8de8696424 perf(map): NaverMapView SDK 네이티브 마커 + InfoWindow
- React absolute div overlay → naver.maps.Marker 네이티브 교체
- 줌/팬 시 SDK GPU 최적화로 랙 해소
- 식당명 InfoWindow (마커 클릭 시 표시)
- bounds_changed → idle 이벤트로 sync 빈도 감소
- 클러스터도 네이티브 마커 (HTML 콘텐츠 숫자)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-16 16:58:03 +09:00
joungmin
a4de9ba87b feat(admin): #356 후속 — verifyAllAsync로 즉시 응답
- 1244 링크 백필 동기 호출 시 HTTP 25~30분 hang (ingress 5분 timeout 초과)
- @Async verifyAllAsync 추가, controller는 started/pending 즉시 응답
- 진행은 /api/admin/video-relevance/pending 폴링으로 확인

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-16 15:15:48 +09:00
joungmin
cf37e496d4 docs(design): #363 실 운영 fix 기록 (1~2단계 + v0.1.57 안정화)
- 배포 흐름 v0.1.51~v0.1.57 정리
- 운영 진단 확인사항 (NCLOUD 도메인 형식, Search/Maps 별개 시스템)
- NaverMapView 안정화 핵심 결정사항 (divRef 항상 마운트, ResizeObserver, try/catch)
- 후속 분리 항목 명시

Refs: #363

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-16 10:56:51 +09:00
joungmin
ce3e34938c fix(map): NaverMapView 안정화 — divRef 항상 마운트 + ResizeObserver
- 이전: ready 가드로 첫 렌더 시 ref 누락 가능 → SDK가 div 못 잡음
- 명시적 width/height + 회색 배경(시각적 로딩 표시)
- ResizeObserver + rAF로 컨테이너 0×0 → 정상 크기 시 m.refresh
- try/catch + initError로 init 실패 가시화

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-16 10:38:57 +09:00
joungmin
5199475d67 revert(map): NaverMap 임시 비활성, 한국도 GoogleMap fallback
- NaverMapView 골격이 실 운영에서 지도/마커 렌더 실패
- 환경변수 비워 dispatcher가 GoogleMap fallback (회귀 0)
- NaverMapView 코드는 유지 — 후속 안정화 작업 후 재활성

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-16 10:32:54 +09:00
joungmin
bd8d82dd5d fix(stats): /api/stats/visits 500 — Mapper resultType int→long
- StatsMapper interface는 long 반환인데 XML resultType이 int
- Integer를 primitive long으로 cast 못 함 → ClassCastException → 500

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-16 10:26:15 +09:00
joungmin
bc83923261 fix(map): NaverMap 인증 파라미터 ncpClientId → ncpKeyId
- NCLOUD 신 정책: ncpKeyId 사용 (navermaps/maps.js.ncp 공식)
- 인증 200/Failed 진짜 원인 — 도메인 등록 정확했으나 파라미터 차이
- Refs: navermaps/maps.js.ncp

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-16 10:03:09 +09:00
joungmin
f17ba9e37a feat(map): #363 메인 지도 SDK 국내(네이버)/해외(구글) 분기
- MapView dispatcher: NAVER 키 + KR bbox 좌표 → NaverMapView
- NaverMapView 신규 (네이버 v3 직접 wrapper, Supercluster 재사용)
- GoogleMapView 신규 (기존 MapView 내용 rename)
- MapView.types.ts 공용 타입 + isKoreaCoord 헬퍼
- Dockerfile/deploy.sh: NEXT_PUBLIC_NAVER_MAP_CLIENT_ID build-arg
- 키 미설정 시 GoogleMap fallback (회귀 0)

Refs: #363

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-16 06:25:47 +09:00
joungmin
7789671fbc feat(map): 식당 상세 지도 링크 국내/해외 분기 (1단계)
- 좌표 기반 한국 판정 (KR bbox 33~38.7°N, 124~132°E)
- 국내: 네이버 지도(/p/search/) primary + Google Maps 보조
- 해외: Google Maps 단독
- 좌표 없으면 region 첫 토큰 fallback

2단계(메인 지도 탭 SDK 분기)는 별도 후속.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-16 05:59:24 +09:00
joungmin
c5b0216a37 fix(catchtable): URL 패턴을 /ct/shop/, /ct/dining/으로 교정
- 실제 캐치테이블은 app.catchtable.co.kr/ct/shop/... 형식
- 옛 /shop/, /dining/ 패턴은 contains 매칭 실패 → 첫 회차 1044건 전부 미발견
- 패턴 교정 후 NONE 해제 + 재실행 필요

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-16 01:35:18 +09:00
joungmin
40e448fe95 fix(search): WebSearchService HTTP timeout 추가 (connect 5s, request 15s)
- 특정 검색에서 무한 hang → backend virtual thread 점유로 후속 벌크 작업 중단
- Naver/DDG 둘 다 timeout 적용
- 타임아웃 시 HttpTimeoutException → 호출자(bulk)에서 notfound 안전 처리

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-15 21:52:31 +09:00
joungmin
8a21646031 fix(admin): bulk-tabling/catchtable SSE timeout 10분 → 3시간
- 대량 백필(700+건 ≈ 100분) 시 10분 SSE timeout으로 중간 끊김
- emit() 실패 시 마지막 cache.flush + complete 누락 → 3시간으로 확장

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-15 21:08:55 +09:00
joungmin
52090057de fix(restaurant): #357 후속 — tabling-url validation에 www. 호스트 허용
- Naver/DDG 결과가 www.tabling.co.kr 형태인데 PUT validation에서 거부됨
- bulk-tabling SSE는 validation 없이 통과 — 불일치 해소
- catchtable은 이미 app/www 둘 다 허용 (기존)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-15 21:02:32 +09:00
joungmin
d73947444f feat(backend): #359 1단계 — google_place_id 중복 조회 API
- GET /api/admin/restaurants/duplicates/place-id (어드민 전용)
- 그룹별 식당 목록 + video/review/memo 카운트 동봉
- Mapper: findDuplicatePlaceIdRows + Service 그룹핑
- 정리/병합 + UNIQUE 제약은 데이터 위험 분리 위해 후속 PR로

Refs: #359 (조회 단계 완료)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-15 20:32:40 +09:00
joungmin
c1050f3abd feat(backend): #358 RestaurantUpdateDTO + @Valid 표준화
- dto/RestaurantUpdateDTO record 신규 (15 필드, 모두 nullable)
- @Size/@Pattern(URL or NONE)/@DecimalMin·Max/@Min·Max
- RestaurantController.update 시그니처 Map → @Valid DTO 교체
- toFieldMap()으로 null 제외 후 기존 Service.update 호출 (회귀 0)
- #332 ALLOWED_UPDATE_FIELDS Set 제거 (DTO 필드 자체가 화이트리스트)

Refs: #358 (close)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-15 20:20:51 +09:00
joungmin
a504bf8ee5 feat(backend): #357 DDG → Naver Search 정식 API + DDG 폴백
- WebSearchService 신규 (Naver webkr.json 우선, 키 미설정/실패 시 DDG)
- RestaurantController.searchTabling/searchCatchtable 내부 호출 교체
- 인라인 DDG 80줄 제거, 미사용 import 정리
- app.naver.client-id/secret 추가 (env: NAVER_CLIENT_ID/SECRET)
- k8s secrets template에 NAVER 키 항목

Refs: #357 (close)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-15 20:16:14 +09:00
joungmin
f1164b63c5 docs(design): #357 정식 검색 API 전환 설계서 (Architect)
- WebSearchService 신규: Naver Search webkr.json 우선, DDG 폴백
- searchTabling/searchCatchtable 내부 호출만 교체 (시그니처 유지)
- application.yml + k8s secrets에 NAVER_CLIENT_ID/SECRET 추가

Refs: #357

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-15 20:11:52 +09:00
joungmin
47020fd649 feat(backend): #356 영상-식당 관련도 LLM 평가
- DB V20260615b: video_restaurants.{relevance, relevance_reason, relevance_evaluated_at} + idx_vr_relevance
- VideoRelevanceService (#322 패턴): @Async verifyAsync + verify + verifyAll(batchSize)
- PipelineService.processExtract → linkVideoRestaurant 후 verifyAsync(linkId) 자동 트리거
- GET /api/restaurants/{id}/videos: 기본 strong/unknown만, ?include_weak=true 시 모두 + relevance/reason
- AdminVideoRelevanceController: GET pending / POST all / POST {id}/evaluate / PATCH {id}
- 캐시 키 strong|all 분리, LLM 실패 시 unknown 안전 기본값(표시 유지)

Refs: #356 (close)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-15 19:38:07 +09:00
joungmin
88bbf3ca25 docs(design): #356 영상-식당 관련도 LLM 평가 설계서 (Architect)
video_restaurants.relevance(strong/weak/incidental/unknown) 컬럼 + VideoRelevanceService.
findVideoLinks에 includeWeak 파라미터. 어드민 4개 API.
#322 식당 LLM 검증과 동일 패턴.

설계서: docs/design/356-video-relevance-llm/README.md (Approved)
Refs: #356 (Architect)
2026-06-15 19:24:19 +09:00
joungmin
8152b71119 docs(changelog): v0.1.42 #351 SSE 통일 기록 2026-06-15 17:17:14 +09:00
joungmin
d6ee62230e refactor(admin): #351 SSE 6곳 consumeSseStream으로 통일
VideosPanel:
- bulkTranscript/bulkExtract: 단일 SSE 핸들러 → consumeSseStream
- rebuildVectors: consumeSseStream
- remapCuisine / remapFoods: consumeSseStream

RestaurantsPanel:
- bulkTabling / bulkCatchtable: consumeSseStream

이전: 각 호출이 자체적으로 reader+decoder+buf.split+match 6곳 복제.
이제: lib/admin-utils.ts의 consumeSseStream(resp, onEvent)으로 일관 처리.

빌드 + npm test 13/13 통과. 회귀 없음.

Refs: #351
2026-06-15 17:15:35 +09:00
joungmin
cf1055bdf9 docs(changelog): v0.1.40 #343 테스트 인프라 기록 2026-06-15 16:29:22 +09:00
joungmin
2580414790 build(npm): #343 lock 재생성 (Jest 30 + @testing-library/* 동기화) 2026-06-15 16:26:52 +09:00
joungmin
730727a7a6 test(frontend): #343 Jest+RTL 인프라 + ARIA Tabs + remotePatterns
테스트 인프라:
- Jest 30 + jest-environment-jsdom + RTL + jest-dom matchers
- next/jest로 SWC/Next.js 자동 통합
- jest.config.ts (setupFilesAfterEnv) + jest.setup.ts
- npm scripts: test, test:watch
- 샘플 테스트 3개, 13/13 통과:
  - i18n/config: isLocale + detectBrowserLocale (5 케이스)
  - Stars 컴포넌트: 별점/aria/clamp/showNumber (5 케이스)
  - admin-utils: getAdminToken + authHeaders (4 케이스)

ARIA Tabs (MyReviewsList):
- role=tablist + tab + aria-selected + aria-controls + tabIndex
- panel에 role=tabpanel + aria-labelledby

next/image:
- next.config.ts remotePatterns: lh3.googleusercontent.com / i.ytimg.com / yt3.ggpht.com
- ReviewSection의 user_avatar_url에 명시적 eslint-disable + 사유

후속(별도): 전체 컴포넌트 테스트 점진 추가, 백엔드 JUnit 인프라, E2E (Playwright), CI 통합

설계서: docs/design/343-frontend-test-infra/README.md

Refs: #343
2026-06-15 16:25:55 +09:00
joungmin
9ba905aad8 docs(design): #343 RTL/Jest 인프라 + next/image + ARIA Tabs 설계서 (Architect)
next/jest + RTL 도입, 샘플 테스트 3개(Stars + i18n config), remotePatterns,
MyReviewsList Tabs ARIA. 백엔드 JUnit/E2E/CI는 후속.

설계서: docs/design/343-frontend-test-infra/README.md (Approved)
Refs: #343 (Architect)
2026-06-15 16:17:23 +09:00
joungmin
8c4b0c3e9a docs(changelog): v0.1.38 #348 isNameSimilar 한국어 기록 2026-06-15 16:12:46 +09:00
joungmin
3815221535 feat(util): #348 isNameSimilar 한국어 자모 + Sørensen-Dice
- HangulSimilarity 유틸 신규
  - decompose: Unicode NFD 분해 (한글 음절 → 초성/중성/종성)
  - 공백·구두점 제거 + 소문자화
  - bigram multiset 기반 Sørensen-Dice 계수
  - 빈 입력/포함 관계 가드
- RestaurantController.isNameSimilar 임계값 0.45 (이전 Jaccard 0.4와 유사 보수성)
- 기존 normalize 헬퍼 제거 (HangulSimilarity 내부로 이동)

DDG/DTO/UNIQUE는 별도 후속:
- 외부 검색 API 선정 (Naver/Kakao/Google CSE)
- RestaurantUpdateDTO + @Valid
- google_place_id 중복 정리 후 UNIQUE 제약

설계서: docs/design/348-name-similarity/README.md

Refs: #348 (Developer 단계)
2026-06-15 16:10:44 +09:00
joungmin
49ef0322ac docs(design): #348 isNameSimilar 자모 + Sørensen-Dice (Architect)
NFD 자모 분해 + bigram 기반 Sørensen-Dice 계수로 한국어 정확도 향상.
DDG/DTO/UNIQUE는 별도 후속(외부 API/큰 리팩터링/DB 데이터 정리 필요).

설계서: docs/design/348-name-similarity/README.md (Approved)
Refs: #348 (Architect)
2026-06-15 16:09:19 +09:00
joungmin
cc4bc0b7e4 docs(changelog): #352 i18n 뼈대 + v0.1.37 기록 2026-06-15 16:02:40 +09:00
joungmin
515f5c1d1a build(npm): #352 package-lock 재생성 (next-intl + @swc/helpers 동기화) 2026-06-15 15:59:44 +09:00
joungmin
6cbf7feaf5 feat(i18n): #352 다국어 뼈대 ko/en/ja/es
- next-intl 5.x 도입
- src/i18n/config.ts: LOCALES 상수, detectBrowserLocale, LOCALE_LABELS(국기/네이티브명)
- src/i18n/LocaleProvider.tsx: NextIntlClientProvider wrap + localStorage tasteby_locale 저장
- src/messages/{ko,en,ja,es}.json: 초기 30개 키 (header/actions/filter/restaurant/review 5 카테고리)
- src/components/LanguageSwitcher.tsx: 헤더용 드롭다운 (국기 + native, ARIA listbox, 44px 터치)
- providers.tsx: LocaleProvider로 AuthProvider 감싸기
- page.tsx 헤더에 LanguageSwitcher 배치

설계서: docs/design/352-i18n-skeleton/README.md (Approved)

언어 선택 근거:
- ko: 기본
- en: 글로벌 1순위
- ja: 일본 사용자 + 한국 음식 관광
- es: 5억 화자, 라틴아메리카 + 스페인 확장

미번역 키는 ko fallback. URL 라우팅(/en/)/SEO meta/사용자 콘텐츠 번역은 후속.

Refs: #352
2026-06-15 15:58:21 +09:00
64 changed files with 10183 additions and 1449 deletions

4
.gitignore vendored
View File

@@ -20,3 +20,7 @@ k8s/secrets.yaml
backend/cookies.txt
backend-java/cookies.txt
**/cookies.txt
# 작업 산출물 (로컬 전용)
reviews/
screenshots/

View File

@@ -4,9 +4,176 @@
---
## 2026-06-17
### 🎯 NaverMapView selected 자동 panTo + zoom (v0.1.64)
- 마커/클러스터/리스트 어디서 선택해도 그 식당이 화면 중앙으로 + zoom 16
- GoogleMapView에는 이미 있던 useEffect [selected] 패턴을 동일하게 추가
## 2026-06-16
### 🧹 미커밋 잡변경 정리 + 5개 분리 commit (v0.1.63)
- backend: 어드민 권한 토글 service/mapper/CORS PATCH 미커밋분 적용
- backend: JsonUtil.normalizeEvaluation 정식 commit (PipelineService/RestaurantService 이미 호출 중)
- frontend: Phosphor 아이콘 마이그레이션 + FoodIcon 신규 + brand-guide.md
- docs: oke-deploy-howto.md 신규
- chore: ecosystem PORT=3001 + reviews/screenshots gitignore
### 🎯 식당 선택 시 지도 자동 줌인/이동 (v0.1.62)
- 리스트 / 검색결과에서 식당 클릭 → setRegionFlyTo로 그 식당 좌표 + zoom 16
- 지도가 선택 식당으로 panTo + zoom — NaverMap/GoogleMap 둘 다
- 마커의 selected 강조(1.15× + 파란 박스)와 함께 동작
### 🏷️ NaverMapView 마커에 식당명 박스 (v0.1.61)
- 단순 동그라미 → GoogleMapView와 동일 핀 디자인(박스+화살표+식당명+cuisine 아이콘)
- 채널별 배경/테두리/화살표 색상, 폐업(business_status CLOSED_*) 표시 회색 + 취소선
- selected 식당 강조 (1.15× scale + 파란 박스), zIndex 1000
- InfoWindow 제거 (식당명 자체가 박스로 보이므로 불필요)
### 🎨 NaverMapView 채널별 마커 색상 (v0.1.60)
- GoogleMapView와 동일 팔레트 (amber/blue/green/pink/purple/red/teal/yellow)
- 식당의 첫 채널 기준 색상, activeChannel 있으면 그 채널 우선
### ⚡ NaverMapView SDK 네이티브 마커 + InfoWindow (v0.1.59)
- 마커를 React `absolute div` overlay → `naver.maps.Marker` 네이티브로 교체
- 줌/팬 시 SDK가 GPU 최적화, 매 frame React 리렌더링 없음 → 랙 해소
- 식당명 InfoWindow 추가 (마커 클릭 시 표시)
- bounds_changed → idle 이벤트로 sync (줌/팬 중 발화 빈도 ↓)
- 클러스터도 네이티브 마커 (HTML 콘텐츠로 숫자 표시)
### 🗺️ NaverMapView 안정화 + 재활성 (v0.1.57)
- divRef 항상 마운트 (이전: ready 가드로 첫 렌더 ref 누락 가능)
- 명시적 width/height + 회색 배경(시각적 로딩 표시)
- ResizeObserver + requestAnimationFrame으로 컨테이너 0×0 → 정상 크기 시 refresh
- try/catch + initError state로 init 실패 가시화
- Naver 키 재활성
### ⏪ NaverMap 임시 비활성, 한국도 GoogleMap fallback (v0.1.55)
- NaverMapView 골격이 실 운영에서 지도/마커 렌더 실패 (정확한 원인 추후 진단)
- NEXT_PUBLIC_NAVER_MAP_CLIENT_ID 빈 값으로 dispatcher가 GoogleMap fallback (회귀 0)
- NaverMapView 코드는 유지 — 안정화 후 환경변수 채우면 재활성
### 🐛 /api/stats/visits 500 — StatsMapper resultType int → long (v0.1.54)
- StatsMapper interface는 `long` 반환인데 XML resultType이 `int` → Integer를 long에 cast 실패
- ClassCastException: Integer → Long. resultType만 long으로 교정
### 🐛 NaverMap 인증 파라미터 ncpClientId → ncpKeyId (v0.1.53)
- NCLOUD 신 정책: `ncpKeyId` 사용 (옛 `ncpClientId`는 NAVER Developers용)
- 인증 200/Failed의 진짜 원인 — 도메인 등록은 정확했으나 파라미터 이름 차이로 키 인식 실패
- 새 NCLOUD Maps Client ID(`fg01bipxbo`)로 prod 재빌드
- 참고: https://github.com/navermaps/maps.js.ncp/blob/master/index.html
### 🗺️ #363 메인 지도 SDK 국내(네이버)/해외(구글) 분기 (v0.1.52)
- MapView를 dispatcher로 전환: 좌표가 KR bbox + NAVER_MAP_CLIENT_ID 설정 시 NaverMapView, 그 외 GoogleMapView
- NaverMapView 신규 (네이버 v3 직접 wrapper, Supercluster 재사용, 마커/클러스터/flyTo)
- GoogleMapView 신규 (기존 MapView 내용 rename)
- MapView.types.ts 공용 (MapBounds/FlyTo/MapViewProps + isKoreaCoord)
- Dockerfile + deploy.sh: NEXT_PUBLIC_NAVER_MAP_CLIENT_ID build-arg 추가
- 키 미설정 시 GoogleMap fallback (회귀 0)
- 설계서: docs/design/363-map-sdk-branch/README.md
- Refs: #363
### 🗺️ 식당 상세 지도 링크 국내/해외 분기 (v0.1.51)
- 좌표 기반 한국 판정 (WGS84 KR bbox 33~38.7°N, 124~132°E)
- 국내: 네이버 지도 primary + Google Maps 보조 (네이버 URL은 신 도메인 /p/search/)
- 해외: Google Maps 단독
- 좌표 없으면 region 첫 토큰 fallback (구 데이터 호환)
- frontend-only 배포
## 2026-06-15
### 🧹 #329 admin/page.tsx 분리 (v0.1.35)
### 🐛 캐치테이블 URL 패턴 수정 (v0.1.50)
- 실제 catchtable URL은 `app.catchtable.co.kr/ct/shop/...` 형식 (옛 `/shop/`, `/dining/`은 매칭 실패)
- 첫 회차(v0.1.49) 캐치테이블 벌크 결과 1044건 전부 미발견(매핑 0%)의 원인
- 패턴을 `catchtable.co.kr/ct/shop/`, `catchtable.co.kr/ct/dining/`로 교정 후 NONE 해제 + 재실행
### 🐛 WebSearchService HTTP timeout 추가 (v0.1.49)
- 벌크 백필 중 특정 검색에서 무한 hang → backend executor virtual thread 점유로 후속 작업 중단 (90건 처리 후 멈춤)
- connectTimeout=5s + request timeout=15s (Naver/DDG 둘 다)
- 해당 식당은 HttpTimeoutException → notfound로 안전 처리
### ⏱️ bulk-tabling/catchtable SSE timeout 10분 → 3시간 (v0.1.48)
- 대량 백필(724건 ≈ 100분) 시 10분 SSE timeout으로 중간 끊김 → 3시간으로 확장
- 백엔드 작업은 virtual thread로 별도 진행됐지만 emit() 예외로 마지막 cache.flush + complete 누락이슈 해소
### 🐛 #357 후속 — tabling-url validation에 www. 호스트 허용 (v0.1.47)
- Naver/DDG 결과가 `https://www.tabling.co.kr/...` 형태인데 #290 validation은 `tabling.co.kr/`만 허용 → 단건 매핑 PUT 거부
- bulk-tabling SSE는 validation 없이 service.update 직접 호출이라 통과 → 단일/벌크 불일치
- `www.tabling.co.kr` prefix도 허용 (catchtable은 이미 app/www 둘 다 허용)
- 시연 등록: bbq 부천은하마을점 → BBQ 치킨 부천은하마을점
### 🔍 #359 1단계 — google_place_id 중복 조회 API (v0.1.46)
- GET /api/admin/restaurants/duplicates/place-id (어드민 전용)
- 응답: 그룹별 식당 + video/review/memo 카운트 (병합 의사결정 자료)
- 정리/병합 + UNIQUE 제약은 별도 PR (데이터 위험 분리)
- 설계서: docs/design/359a-duplicate-place-id-view/README.md
- Refs: #359 (조회 단계 완료, 후속 분리 유지)
### 📋 #358 RestaurantUpdateDTO + @Valid 표준화 (v0.1.45)
- dto/RestaurantUpdateDTO record 신규 (15 필드, 모두 nullable)
- Bean Validation: @Size/@Pattern(URL or NONE)/@DecimalMin·Max/@Min·Max
- RestaurantController.update 시그니처 Map → @Valid DTO 교체
- toFieldMap()으로 null 제외 후 기존 Service.update 호출 (회귀 0)
- #332 ALLOWED_UPDATE_FIELDS Set 제거 (DTO 필드 자체가 화이트리스트)
- 설계서: docs/design/358-restaurant-update-dto/README.md
- Refs: #358 (close)
### 🔎 #357 DDG → Naver Search 정식 API + DDG 폴백 (v0.1.44)
- WebSearchService 신규 (Naver webkr.json 우선, 키 미설정/실패 시 DDG 폴백)
- RestaurantController.searchTabling/searchCatchtable 내부 호출 교체, DDG 인라인 80줄 제거
- application.yml: app.naver.client-id/secret (NAVER_CLIENT_ID/SECRET 환경변수)
- k8s/secrets.yaml.template에 NAVER_CLIENT_ID/SECRET 항목 추가
- 미사용 import 정리 (HttpClient/URI/URLEncoder/Pattern 등 RestaurantController에서)
- 설계서: docs/design/357-web-search-api/README.md
- Refs: #357 (close)
### 🎯 #356 영상-식당 관련도 LLM 평가 (v0.1.43)
- DB: video_restaurants 컬럼 추가 (relevance/relevance_reason/relevance_evaluated_at) + idx_vr_relevance
- VideoRelevanceService 신규 (#322 RestaurantVerifyService 패턴 모방, @Async verifyAsync/verify/verifyAll)
- PipelineService.processExtract — linkVideoRestaurant 후 verifyAsync(linkId) 자동 트리거
- GET /api/restaurants/{id}/videos: 기본 strong/unknown만 응답 (안전 기본값), ?include_weak=true 시 모두
- AdminVideoRelevanceController 신규 (pending/all/{id}/evaluate/{id} PATCH)
- 응답 매핑: relevance, relevance_reason 필드 동봉
- 기존 1244 링크는 'unknown' 시작 → 어드민 백필로 점진 평가
- 설계서: docs/design/356-video-relevance-llm/README.md
- Refs: #356 (close)
### 🧹 #351 admin SSE 6곳 consumeSseStream 통일 (v0.1.42)
- VideosPanel 4곳(bulkTranscript/Extract, rebuildVectors, remapCuisine, remapFoods)
- RestaurantsPanel 2곳(bulkTabling, bulkCatchtable)
- response.body?.getReader 직접 호출 0건 (lib/admin-utils.ts의 consumeSseStream 활용)
- 149줄 삭제 → 74줄 압축, npm test 13/13 통과
- Refs: #351 (close)
### 🧪 #343 Jest+RTL 인프라 + ARIA Tabs + remotePatterns (v0.1.40)
- Jest 30 + jest-environment-jsdom + RTL + jest-dom matchers 도입
- next/jest 자동 SWC 통합, jest.config.ts + jest.setup.ts (setupFilesAfterEnv)
- npm scripts: test, test:watch
- 샘플 테스트 3개 13/13 통과: i18n/config(5), Stars(5), admin-utils(4)
- MyReviewsList: role=tablist/tab/aria-selected/aria-controls/tabIndex + tabpanel
- next.config.ts remotePatterns: Google avatar + YouTube thumbnail/avatar
- 후속: 전체 컴포넌트 테스트 확장, 백엔드 JUnit, E2E(Playwright), CI 통합
- 설계서: docs/design/343-frontend-test-infra/README.md
- Refs: #343 (close)
### 🔤 #348 isNameSimilar 한국어 자모 + Sørensen-Dice (v0.1.38)
- HangulSimilarity 유틸 신규 (Unicode NFD 분해 + bigram Sørensen-Dice)
- RestaurantController.isNameSimilar 교체, 임계값 0.45
- 짧은 한국어 이름 매칭 정확도 향상 (예: "스타벅스 강남" vs "스타벅스 강남점")
- 후속 분리: #357(DDG→정식 API), #358(DTO+@Valid), #359(UNIQUE+데이터 정리)
- 설계서: docs/design/348-name-similarity/README.md
- Refs: #348 (close)
### 🌐 #352 i18n 뼈대 ko/en/ja/es (v0.1.37)
- next-intl 5.x 도입
- src/i18n/{config,LocaleProvider} + src/messages/{ko,en,ja,es}.json (30 키)
- LanguageSwitcher 컴포넌트 (헤더, ARIA listbox, 44px, 국기+네이티브명)
- localStorage tasteby_locale + 브라우저 언어 감지 + ko fallback
- 설계서: docs/design/352-i18n-skeleton/README.md
- 미적용: URL 라우팅 i18n, SEO meta, 사용자 콘텐츠 번역, 어드민(한국어 유지)
- Refs: #352 (close)
### 🧹 #329 admin/page.tsx 분리 (v0.1.35→v0.1.36 운영 반영)
- page.tsx 2817 → 107 LOC (탭 라우팅 + 헤더만)
- _panels/{Channels,Videos,Restaurants,Users,Daemon}Panel.tsx 5개 분리
- localStorage.getItem 10곳 → getAdminToken() (admin-utils.ts)

View File

@@ -21,7 +21,7 @@ public class WebConfig implements WebMvcConfigurer {
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOrigins(Arrays.asList(allowedOrigins.split(",")));
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
config.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
config.setAllowedHeaders(List.of("*"));
config.setAllowCredentials(true);

View File

@@ -62,6 +62,15 @@ public class AdminRestaurantController {
return Map.of("success", true, "id", id);
}
// #359 1단계 — google_place_id 중복 조회 (정리/UNIQUE는 후속).
@GetMapping("/duplicates/place-id")
public Map<String, Object> duplicatePlaceIds() {
var admin = AuthUtil.requireAdmin();
var groups = restaurantService.findDuplicatePlaceIdGroups();
log.info("[ADMIN] {} duplicate place_id groups: {}", admin.getSubject(), groups.size());
return Map.of("groups", groups, "group_count", groups.size());
}
/**
* 어드민용 hidden 토글.
*/

View File

@@ -0,0 +1,70 @@
package com.tasteby.controller;
import com.tasteby.security.AuthUtil;
import com.tasteby.service.RestaurantService;
import com.tasteby.service.VideoRelevanceService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
import java.util.Set;
/**
* #356 영상-식당 관련도 LLM 평가 어드민 API.
* - 미평가 카운트 / 일괄 백필 / 단건 재평가 / 수동 토글
*/
@RestController
@RequestMapping("/api/admin/video-relevance")
public class AdminVideoRelevanceController {
private static final Logger log = LoggerFactory.getLogger(AdminVideoRelevanceController.class);
private static final Set<String> VALID = Set.of("strong", "weak", "incidental", "unknown");
private final RestaurantService restaurantService;
private final VideoRelevanceService relevanceService;
public AdminVideoRelevanceController(RestaurantService restaurantService, VideoRelevanceService relevanceService) {
this.restaurantService = restaurantService;
this.relevanceService = relevanceService;
}
@GetMapping("/pending")
public Map<String, Object> pendingCount() {
var admin = AuthUtil.requireAdmin();
int n = restaurantService.countUnevaluatedLinks();
log.info("[ADMIN] {} video-relevance pending: {}", admin.getSubject(), n);
return Map.of("pending", n);
}
@PostMapping("/all")
public Map<String, Object> verifyAll(@RequestParam(defaultValue = "10") int batchSize) {
var admin = AuthUtil.requireAdmin();
int pending = restaurantService.countUnevaluatedLinks();
log.info("[ADMIN] {} triggered video-relevance verifyAllAsync (batchSize={}, pending={})", admin.getSubject(), batchSize, pending);
// 비동기 트리거 — HTTP request는 즉시 응답. 진행은 /pending 폴링으로 확인.
relevanceService.verifyAllAsync(batchSize);
return Map.of("started", true, "pending", pending);
}
@PostMapping("/{linkId}/evaluate")
public Map<String, Object> evaluateOne(@PathVariable String linkId) {
var admin = AuthUtil.requireAdmin();
log.info("[ADMIN] {} video-relevance evaluate({})", admin.getSubject(), linkId);
relevanceService.verify(linkId);
return Map.of("success", true, "linkId", linkId);
}
@PatchMapping("/{linkId}")
public Map<String, Object> setRelevance(@PathVariable String linkId, @RequestBody Map<String, Object> body) {
var admin = AuthUtil.requireAdmin();
Object relObj = body.get("relevance");
if (!(relObj instanceof String relevance) || !VALID.contains(relevance)) {
return Map.of("success", false, "error", "relevance must be one of strong|weak|incidental|unknown");
}
String reason = body.get("reason") instanceof String s ? s : "manual";
restaurantService.updateLinkRelevance(linkId, relevance, reason);
log.info("[ADMIN] {} manual relevance={} for link {}", admin.getSubject(), relevance, linkId);
return Map.of("success", true, "linkId", linkId, "relevance", relevance);
}
}

View File

@@ -4,9 +4,12 @@ import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.tasteby.domain.Restaurant;
import com.tasteby.security.AuthUtil;
import com.tasteby.dto.RestaurantUpdateDTO;
import com.tasteby.service.CacheService;
import com.tasteby.service.GeocodingService;
import com.tasteby.service.RestaurantService;
import com.tasteby.service.WebSearchService;
import jakarta.validation.Valid;
import jakarta.annotation.PreDestroy;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -15,19 +18,10 @@ import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.net.URI;
import java.net.URLDecoder;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.util.*;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ThreadLocalRandom;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@RestController
@RequestMapping("/api/restaurants")
@@ -39,13 +33,15 @@ public class RestaurantController {
private final GeocodingService geocodingService;
private final CacheService cache;
private final ObjectMapper objectMapper;
private final WebSearchService webSearch;
private final ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
public RestaurantController(RestaurantService restaurantService, GeocodingService geocodingService, CacheService cache, ObjectMapper objectMapper) {
public RestaurantController(RestaurantService restaurantService, GeocodingService geocodingService, CacheService cache, ObjectMapper objectMapper, WebSearchService webSearch) {
this.restaurantService = restaurantService;
this.geocodingService = geocodingService;
this.cache = cache;
this.objectMapper = objectMapper;
this.webSearch = webSearch;
}
// #290 — Bean 종료 시 virtual thread executor를 정리하여 리소스 누수 방지.
@@ -90,30 +86,14 @@ public class RestaurantController {
return r;
}
// #332 — Restaurant 업데이트 화이트리스트 (SQL updateFields의 컬럼 가드와 1:1).
// 허용되지 않은 키는 무시(silent drop). DTO 도입은 후속 작업.
private static final java.util.Set<String> ALLOWED_UPDATE_FIELDS = java.util.Set.of(
"name", "address", "region", "cuisine_type", "price_range",
"phone", "website", "tabling_url", "catchtable_url",
"latitude", "longitude", "google_place_id",
"business_status", "rating", "rating_count"
);
@PutMapping("/{id}")
public Map<String, Object> update(@PathVariable String id, @RequestBody Map<String, Object> body) {
public Map<String, Object> update(@PathVariable String id, @Valid @RequestBody RestaurantUpdateDTO dto) {
AuthUtil.requireAdmin();
var r = restaurantService.findById(id);
if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Restaurant not found");
// #332입력 body를 허용 키만 통과시킨 가변 Map으로 정규화
Map<String, Object> sanitized = new java.util.LinkedHashMap<>();
for (var e : body.entrySet()) {
if (ALLOWED_UPDATE_FIELDS.contains(e.getKey())) {
sanitized.put(e.getKey(), e.getValue());
} else {
log.debug("Ignoring non-whitelisted update field: {}", e.getKey());
}
}
// #358DTO → Map (null 제외). 화이트리스트는 DTO 필드 자체로 표현.
Map<String, Object> sanitized = dto.toFieldMap();
// Re-geocode if name or address changed
String newName = (String) sanitized.get("name");
@@ -195,7 +175,7 @@ public class RestaurantController {
@PostMapping("/bulk-tabling")
public SseEmitter bulkTabling() {
AuthUtil.requireAdmin();
SseEmitter emitter = new SseEmitter(600_000L);
SseEmitter emitter = new SseEmitter(10_800_000L); // 3h — 대량 백필 대응
executor.execute(() -> {
try {
@@ -272,8 +252,11 @@ public class RestaurantController {
if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND);
String url = body.get("tabling_url");
// #290 — javascript:/외부 악성 URL 차단. 빈 문자열은 매핑 해제로 허용.
if (url != null && !url.isBlank() && !url.startsWith("https://tabling.co.kr/")) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "테이블링 URL은 https://tabling.co.kr/ 만 허용");
// Naver/DDG 결과가 www.tabling.co.kr 형태로도 옴.
if (url != null && !url.isBlank()
&& !url.startsWith("https://tabling.co.kr/")
&& !url.startsWith("https://www.tabling.co.kr/")) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "테이블링 URL은 https://(www.)tabling.co.kr/ 만 허용");
}
restaurantService.update(id, Map.of("tabling_url", url != null ? url : ""));
cache.flush();
@@ -326,7 +309,7 @@ public class RestaurantController {
@PostMapping("/bulk-catchtable")
public SseEmitter bulkCatchtable() {
AuthUtil.requireAdmin();
SseEmitter emitter = new SseEmitter(600_000L);
SseEmitter emitter = new SseEmitter(10_800_000L); // 3h — 대량 백필 대응
executor.execute(() -> {
try {
@@ -413,8 +396,10 @@ public class RestaurantController {
}
@GetMapping("/{id}/videos")
public List<Map<String, Object>> videos(@PathVariable String id) {
String key = cache.makeKey("restaurant_videos", id);
public List<Map<String, Object>> videos(
@PathVariable String id,
@RequestParam(name = "include_weak", defaultValue = "false") boolean includeWeak) {
String key = cache.makeKey("restaurant_videos", id, includeWeak ? "all" : "strong");
String cached = cache.getRaw(key);
if (cached != null) {
try {
@@ -423,100 +408,25 @@ public class RestaurantController {
}
var r = restaurantService.findById(id);
if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Restaurant not found");
var result = restaurantService.findVideoLinks(id);
var result = restaurantService.findVideoLinks(id, includeWeak);
cache.set(key, result);
return result;
}
// ─── DuckDuckGo HTML search helpers ─────────────────────────────────
// ─── 예약 사이트 URL 검색 (#357 WebSearchService: Naver primary + DDG fallback) ───
private static final HttpClient httpClient = HttpClient.newBuilder()
.followRedirects(HttpClient.Redirect.NORMAL)
.build();
private static final Pattern DDG_RESULT_PATTERN = Pattern.compile(
"<a[^>]+class=\"result__a\"[^>]+href=\"([^\"]+)\"[^>]*>(.*?)</a>",
Pattern.DOTALL
);
/**
* DuckDuckGo HTML 검색을 통해 특정 사이트의 URL을 찾는다.
* html.duckduckgo.com은 서버사이드 렌더링이라 봇 판정 없이 HTTP 요청만으로 결과를 파싱할 수 있다.
*/
private List<Map<String, Object>> searchDuckDuckGo(String query, String... urlPatterns) throws Exception {
String encoded = URLEncoder.encode(query, StandardCharsets.UTF_8);
String searchUrl = "https://html.duckduckgo.com/html/?q=" + encoded;
log.info("[DDG] Searching: {}", query);
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(searchUrl))
.header("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36")
.header("Accept", "text/html,application/xhtml+xml")
.header("Accept-Language", "ko-KR,ko;q=0.9")
.GET()
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
String html = response.body();
List<Map<String, Object>> results = new ArrayList<>();
Set<String> seen = new HashSet<>();
Matcher matcher = DDG_RESULT_PATTERN.matcher(html);
while (matcher.find() && results.size() < 5) {
String href = matcher.group(1);
String title = matcher.group(2).replaceAll("<[^>]+>", "").trim();
// DDG 링크에서 실제 URL 추출 (uddg 파라미터)
String actualUrl = extractDdgUrl(href);
if (actualUrl == null) continue;
boolean matches = false;
for (String pattern : urlPatterns) {
if (actualUrl.contains(pattern)) {
matches = true;
break;
}
}
if (matches && !seen.contains(actualUrl)) {
seen.add(actualUrl);
results.add(Map.of("title", title, "url", actualUrl));
}
}
log.info("[DDG] Found {} results for '{}'", results.size(), query);
return results;
}
/** DDG 리다이렉트 URL에서 실제 URL 추출 */
private String extractDdgUrl(String ddgHref) {
try {
// //duckduckgo.com/l/?uddg=ENCODED_URL&rut=...
if (ddgHref.contains("uddg=")) {
String uddgParam = ddgHref.substring(ddgHref.indexOf("uddg=") + 5);
int ampIdx = uddgParam.indexOf('&');
if (ampIdx > 0) uddgParam = uddgParam.substring(0, ampIdx);
return URLDecoder.decode(uddgParam, StandardCharsets.UTF_8);
}
// 직접 URL인 경우
if (ddgHref.startsWith("http")) return ddgHref;
} catch (Exception e) {
log.debug("[DDG] Failed to extract URL from: {}", ddgHref);
}
return null;
}
private List<Map<String, Object>> searchTabling(String restaurantName) throws Exception {
return searchDuckDuckGo(
private List<Map<String, Object>> searchTabling(String restaurantName) {
return webSearch.search(
"site:tabling.co.kr " + restaurantName,
"tabling.co.kr/restaurant/", "tabling.co.kr/place/"
);
}
private List<Map<String, Object>> searchCatchtable(String restaurantName) throws Exception {
return searchDuckDuckGo(
private List<Map<String, Object>> searchCatchtable(String restaurantName) {
// 실제 캐치테이블 URL은 /ct/shop/ 형식. 옛 /dining/ /shop/ 패턴은 매칭 실패.
return webSearch.search(
"site:app.catchtable.co.kr " + restaurantName,
"catchtable.co.kr/dining/", "catchtable.co.kr/shop/"
"catchtable.co.kr/ct/shop/", "catchtable.co.kr/ct/dining/"
);
}
@@ -524,25 +434,12 @@ public class RestaurantController {
* 식당 이름과 검색 결과 제목의 유사도 검사.
* 한쪽 이름이 다른쪽에 포함되거나, 공통 글자 비율이 40% 이상이면 유사하다고 판단.
*/
/**
* #348 — 한국어 자모 분해 + Sørensen-Dice bigram 유사도(임계값 0.45).
* 짧은 한국어 이름에서 이전 Jaccard-like(set 비율) 방식보다 정확.
*/
private boolean isNameSimilar(String restaurantName, String resultTitle) {
String a = normalize(restaurantName);
String b = normalize(resultTitle);
if (a.isEmpty() || b.isEmpty()) return false;
// 포함 관계 체크
if (a.contains(b) || b.contains(a)) return true;
// 공통 문자 비율 (Jaccard-like)
var setA = a.chars().boxed().collect(java.util.stream.Collectors.toSet());
var setB = b.chars().boxed().collect(java.util.stream.Collectors.toSet());
long common = setA.stream().filter(setB::contains).count();
double ratio = (double) common / Math.max(setA.size(), setB.size());
return ratio >= 0.4;
}
private String normalize(String s) {
if (s == null) return "";
return s.replaceAll("[\\\\-_()\\[\\]【】]", "").toLowerCase();
return com.tasteby.util.HangulSimilarity.similarity(restaurantName, resultTitle) >= 0.45;
}
private void emit(SseEmitter emitter, Map<String, Object> data) {

View File

@@ -0,0 +1,94 @@
package com.tasteby.dto;
import com.fasterxml.jackson.annotation.JsonProperty;
import jakarta.validation.constraints.DecimalMax;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import java.math.BigDecimal;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* #358 식당 부분 업데이트 DTO.
* - null = 변경 없음 (toFieldMap에서 제외).
* - 화이트리스트는 record 필드로 표현 — Jackson SNAKE_CASE 매핑 유지.
* - URL: http(s) / "NONE" / 빈 문자열만 허용 ("NONE"은 DDG/Naver 매칭 실패 마킹).
*/
public record RestaurantUpdateDTO(
@Size(min = 1, max = 200)
String name,
@Size(max = 500)
String address,
@Size(max = 100)
String region,
@JsonProperty("cuisine_type")
@Size(max = 50)
String cuisineType,
@JsonProperty("price_range")
@Min(1) @Max(5)
Integer priceRange,
@Size(max = 50)
String phone,
@Pattern(regexp = "^(https?://.*|NONE|)$")
String website,
@JsonProperty("tabling_url")
@Pattern(regexp = "^(https?://.*|NONE|)$")
String tablingUrl,
@JsonProperty("catchtable_url")
@Pattern(regexp = "^(https?://.*|NONE|)$")
String catchtableUrl,
@DecimalMin("-90.0") @DecimalMax("90.0")
BigDecimal latitude,
@DecimalMin("-180.0") @DecimalMax("180.0")
BigDecimal longitude,
@JsonProperty("google_place_id")
@Size(max = 200)
String googlePlaceId,
@JsonProperty("business_status")
@Size(max = 50)
String businessStatus,
@DecimalMin("0.0") @DecimalMax("5.0")
BigDecimal rating,
@JsonProperty("rating_count")
@Min(0)
Integer ratingCount
) {
/** null이 아닌 필드만 DB 컬럼명 키로 변환. */
public Map<String, Object> toFieldMap() {
Map<String, Object> m = new LinkedHashMap<>();
if (name != null) m.put("name", name);
if (address != null) m.put("address", address);
if (region != null) m.put("region", region);
if (cuisineType != null) m.put("cuisine_type", cuisineType);
if (priceRange != null) m.put("price_range", priceRange);
if (phone != null) m.put("phone", phone);
if (website != null) m.put("website", website);
if (tablingUrl != null) m.put("tabling_url", tablingUrl);
if (catchtableUrl != null) m.put("catchtable_url", catchtableUrl);
if (latitude != null) m.put("latitude", latitude);
if (longitude != null) m.put("longitude", longitude);
if (googlePlaceId != null) m.put("google_place_id", googlePlaceId);
if (businessStatus != null) m.put("business_status", businessStatus);
if (rating != null) m.put("rating", rating);
if (ratingCount != null) m.put("rating_count", ratingCount);
return m;
}
}

View File

@@ -28,9 +28,24 @@ public interface RestaurantMapper {
int countUnverified();
// #356 영상-식당 관련도
void updateLinkRelevance(@Param("linkId") String linkId,
@Param("relevance") String relevance,
@Param("reason") String reason);
Map<String, Object> findLinkContext(@Param("linkId") String linkId);
List<Map<String, Object>> findUnevaluatedLinks(@Param("limit") int limit);
int countUnevaluatedLinks();
// #359 1단계 — google_place_id 중복 조회
List<Map<String, Object>> findDuplicatePlaceIdRows();
Restaurant findById(@Param("id") String id);
List<Map<String, Object>> findVideoLinks(@Param("restaurantId") String restaurantId);
List<Map<String, Object>> findVideoLinks(@Param("restaurantId") String restaurantId,
@Param("includeWeak") boolean includeWeak);
void insertRestaurant(Restaurant r);

View File

@@ -21,4 +21,6 @@ public interface UserMapper {
List<UserInfo> findAllWithCounts(@Param("limit") int limit, @Param("offset") int offset);
int countAll();
int updateAdmin(@Param("id") String id, @Param("admin") int admin);
}

View File

@@ -29,6 +29,7 @@ public class PipelineService {
private final VectorService vectorService;
private final CacheService cacheService;
private final RestaurantVerifyService verifyService;
private final VideoRelevanceService relevanceService;
public PipelineService(YouTubeService youTubeService,
ExtractorService extractorService,
@@ -37,7 +38,8 @@ public class PipelineService {
VideoService videoService,
VectorService vectorService,
CacheService cacheService,
RestaurantVerifyService verifyService) {
RestaurantVerifyService verifyService,
VideoRelevanceService relevanceService) {
this.youTubeService = youTubeService;
this.extractorService = extractorService;
this.geocodingService = geocodingService;
@@ -46,6 +48,7 @@ public class PipelineService {
this.vectorService = vectorService;
this.cacheService = cacheService;
this.verifyService = verifyService;
this.relevanceService = relevanceService;
}
/**
@@ -145,13 +148,16 @@ public class PipelineService {
evaluationJson = JsonUtil.toJson(s);
}
restaurantService.linkVideoRestaurant(
String linkId = restaurantService.linkVideoRestaurant(
videoDbId, restId,
foods instanceof List<?> ? (List<String>) foods : null,
evaluationJson,
guests instanceof List<?> ? (List<String>) guests : null
);
// #356 — 신규 등록 직후 비동기 관련도 평가
relevanceService.verifyAsync(linkId);
// Vector embeddings
var chunks = VectorService.buildChunks(name, restData, title);
if (!chunks.isEmpty()) {

View File

@@ -77,7 +77,11 @@ public class RestaurantService {
}
public List<Map<String, Object>> findVideoLinks(String restaurantId) {
var rows = mapper.findVideoLinks(restaurantId);
return findVideoLinks(restaurantId, false);
}
public List<Map<String, Object>> findVideoLinks(String restaurantId, boolean includeWeak) {
var rows = mapper.findVideoLinks(restaurantId, includeWeak);
return rows.stream().map(row -> {
var m = JsonUtil.lowerKeys(row);
m.put("foods_mentioned", JsonUtil.parseStringList(m.get("foods_mentioned")));
@@ -87,6 +91,43 @@ public class RestaurantService {
}).toList();
}
// #356 영상-식당 관련도
public void updateLinkRelevance(String linkId, String relevance, String reason) {
mapper.updateLinkRelevance(linkId, relevance, reason);
}
public Map<String, Object> findLinkContext(String linkId) {
var row = mapper.findLinkContext(linkId);
return row != null ? JsonUtil.lowerKeys(row) : null;
}
public List<Map<String, Object>> findUnevaluatedLinks(int limit) {
return mapper.findUnevaluatedLinks(limit).stream()
.map(JsonUtil::lowerKeys)
.toList();
}
public int countUnevaluatedLinks() {
return mapper.countUnevaluatedLinks();
}
// #359 1단계 — google_place_id 중복 그룹 (참조 카운트 동봉)
public List<Map<String, Object>> findDuplicatePlaceIdGroups() {
var rows = mapper.findDuplicatePlaceIdRows().stream()
.map(JsonUtil::lowerKeys)
.toList();
Map<String, List<Map<String, Object>>> grouped = new LinkedHashMap<>();
for (var r : rows) {
String key = (String) r.get("google_place_id");
grouped.computeIfAbsent(key, k -> new ArrayList<>()).add(r);
}
List<Map<String, Object>> out = new ArrayList<>(grouped.size());
for (var e : grouped.entrySet()) {
out.add(Map.of("google_place_id", e.getKey(), "items", e.getValue()));
}
return out;
}
public void update(String id, Map<String, Object> fields) {
mapper.updateFields(id, fields);
}
@@ -138,12 +179,13 @@ public class RestaurantService {
}
}
public void linkVideoRestaurant(String videoId, String restaurantId, List<String> foods, String evaluation, List<String> guests) {
public String linkVideoRestaurant(String videoId, String restaurantId, List<String> foods, String evaluation, List<String> guests) {
String id = IdGenerator.newId();
String foodsJson = foods != null ? JsonUtil.toJson(foods) : null;
String guestsJson = guests != null ? JsonUtil.toJson(guests) : null;
String evalJson = JsonUtil.normalizeEvaluation(evaluation);
mapper.linkVideoRestaurant(id, videoId, restaurantId, foodsJson, evalJson, guestsJson);
return id;
}
public void updateCuisineType(String id, String cuisineType) {

View File

@@ -3,8 +3,10 @@ package com.tasteby.service;
import com.tasteby.domain.UserInfo;
import com.tasteby.mapper.UserMapper;
import com.tasteby.util.IdGenerator;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
@@ -47,4 +49,12 @@ public class UserService {
public int countAll() {
return mapper.countAll();
}
@Transactional
public void updateAdmin(String userId, boolean admin) {
int rows = mapper.updateAdmin(userId, admin ? 1 : 0);
if (rows == 0) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "User not found");
}
}
}

View File

@@ -0,0 +1,154 @@
package com.tasteby.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* #356 영상-식당 관련도 LLM 평가.
* 설계서: docs/design/356-video-relevance-llm/README.md
*
* 신규 등록 시 자동 평가 + 어드민 백필. 결과는 video_restaurants.relevance에 저장.
* - strong: 본격 다룸 (방문 리뷰, 메뉴 평가)
* - weak: 잠깐 언급, 비교 대상
* - incidental: 일반 토픽 중 단순 언급, 입점 전
* - unknown: 미평가 or LLM 실패 (안전 기본값으로 표시 유지)
*/
@Service
public class VideoRelevanceService {
private static final Logger log = LoggerFactory.getLogger(VideoRelevanceService.class);
private static final Set<String> VALID = Set.of("strong", "weak", "incidental", "unknown");
private static final long BACKFILL_SLEEP_MS = 200;
private final RestaurantService restaurantService;
private final OciGenAiService genAi;
private final ObjectMapper jsonMapper = new ObjectMapper();
public VideoRelevanceService(RestaurantService restaurantService, OciGenAiService genAi) {
this.restaurantService = restaurantService;
this.genAi = genAi;
}
@Async
public void verifyAsync(String linkId) {
try {
verify(linkId);
} catch (Exception e) {
log.warn("verifyAsync failed for link {}: {}", linkId, e.getMessage());
}
}
public void verify(String linkId) {
Map<String, Object> ctx = restaurantService.findLinkContext(linkId);
if (ctx == null) return;
VerifyResult result;
try {
String prompt = buildPrompt(ctx);
String response = genAi.chat(prompt, 120);
result = parseRelevance(response);
} catch (Exception e) {
log.warn("verify({}) LLM failed: {} — keeping unknown", linkId, e.getMessage());
return;
}
restaurantService.updateLinkRelevance(linkId, result.relevance(), truncate(result.reason(), 120));
}
@Async
public void verifyAllAsync(int batchSize) {
try {
int n = verifyAll(batchSize);
log.info("[VideoRelevance] backfill done: {} processed", n);
} catch (Exception e) {
log.warn("verifyAllAsync failed: {}", e.getMessage());
}
}
public int verifyAll(int batchSize) {
int total = 0;
List<Map<String, Object>> batch;
while (!(batch = restaurantService.findUnevaluatedLinks(batchSize)).isEmpty()) {
for (Map<String, Object> row : batch) {
String linkId = (String) row.get("link_id");
if (linkId == null) continue;
try {
verify(linkId);
} catch (Exception e) {
log.warn("verifyAll({}) failed: {}", linkId, e.getMessage());
}
total++;
try { Thread.sleep(BACKFILL_SLEEP_MS); } catch (InterruptedException ie) {
Thread.currentThread().interrupt();
return total;
}
}
if (batch.size() < batchSize) break;
}
return total;
}
// ---- pure helpers ----
String buildPrompt(Map<String, Object> ctx) {
String foods = safeStr(ctx.get("foods_mentioned"));
String evaluation = safeStr(ctx.get("evaluation"));
return "다음 YouTube 영상이 이 식당을 어떻게 다루는지 판정하라.\n\n" +
"식당명: " + safeStr(ctx.get("restaurant_name")) + "\n" +
"주소: " + safeStr(ctx.get("address")) + "\n" +
"음식 분류: " + safeStr(ctx.get("cuisine_type")) + "\n" +
"언급된 음식: " + (foods.isEmpty() ? "(없음)" : foods) + "\n\n" +
"영상 제목: " + safeStr(ctx.get("video_title")) + "\n" +
"영상 채널: " + safeStr(ctx.get("channel_name")) + "\n" +
"영상에 등장한 평가: " + (evaluation.isEmpty() ? "(없음)" : evaluation) + "\n\n" +
"응답 형식(JSON만, 다른 텍스트 없이):\n" +
"{\"relevance\": \"strong\"|\"weak\"|\"incidental\", \"reason\": \"20자 이내 한국어\"}\n\n" +
"가이드:\n" +
"- strong: 영상이 이 식당을 본격 다룸 (방문 리뷰, 메뉴 평가).\n" +
"- weak: 잠깐 언급, 다른 식당과 비교 대상으로 등장.\n" +
"- incidental: 일반 토픽 중 단순 언급, 식당 입점 전 영상.\n" +
"- 판단 모호 시 strong (보수적 — 사용자에게 표시 유지).";
}
private static final Pattern JSON_BLOCK = Pattern.compile("\\{[^{}]*\\}", Pattern.DOTALL);
VerifyResult parseRelevance(String raw) {
if (raw == null) return VerifyResult.unknown();
String trimmed = raw.trim();
String json = (trimmed.startsWith("{") && trimmed.endsWith("}")) ? trimmed : null;
if (json == null) {
Matcher m = JSON_BLOCK.matcher(raw);
if (m.find()) json = m.group();
}
if (json == null) return VerifyResult.unknown();
try {
JsonNode node = jsonMapper.readTree(json);
String rel = node.path("relevance").asText("unknown").toLowerCase();
if (!VALID.contains(rel)) rel = "unknown";
String reason = node.path("reason").asText("");
return new VerifyResult(rel, reason);
} catch (Exception e) {
return VerifyResult.unknown();
}
}
private static String safeStr(Object o) {
return o == null ? "" : o.toString();
}
private static String truncate(String s, int max) {
return s == null ? null : (s.length() <= max ? s : s.substring(0, max));
}
public record VerifyResult(String relevance, String reason) {
public static VerifyResult unknown() { return new VerifyResult("unknown", "parse_failed"); }
}
}

View File

@@ -0,0 +1,159 @@
package com.tasteby.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.net.URI;
import java.net.URLDecoder;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.*;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* #357 웹 검색 추상화.
* - Naver Search webkr.json 우선 (한국 식당 정확도 높음, 무료 일 25k).
* - 키 미설정 또는 5xx/timeout 시 DDG HTML 파싱으로 폴백.
* - 결과는 urlPatterns로 필터링 (기존 searchDuckDuckGo와 동일 인터페이스).
*/
@Service
public class WebSearchService {
private static final Logger log = LoggerFactory.getLogger(WebSearchService.class);
private static final int MAX_RESULTS = 5;
private static final Duration REQ_TIMEOUT = Duration.ofSeconds(15);
private static final HttpClient HTTP = HttpClient.newBuilder()
.followRedirects(HttpClient.Redirect.NORMAL)
.connectTimeout(Duration.ofSeconds(5))
.build();
private static final Pattern DDG_RESULT = Pattern.compile(
"<a[^>]+class=\"result__a\"[^>]+href=\"([^\"]+)\"[^>]*>(.*?)</a>",
Pattern.DOTALL);
private final ObjectMapper json = new ObjectMapper();
private final String naverClientId;
private final String naverClientSecret;
public WebSearchService(
@Value("${app.naver.client-id:}") String naverClientId,
@Value("${app.naver.client-secret:}") String naverClientSecret) {
this.naverClientId = naverClientId == null ? "" : naverClientId.trim();
this.naverClientSecret = naverClientSecret == null ? "" : naverClientSecret.trim();
log.info("WebSearchService init — Naver={}", naverClientId.isEmpty() ? "off" : "on");
}
public List<Map<String, Object>> search(String query, String... urlPatterns) {
if (!naverClientId.isEmpty() && !naverClientSecret.isEmpty()) {
try {
List<Map<String, Object>> n = searchNaver(query, urlPatterns);
if (!n.isEmpty()) return n;
} catch (Exception e) {
log.warn("[NaverSearch] failed, falling back to DDG: {}", e.getMessage());
}
}
try {
return searchDdg(query, urlPatterns);
} catch (Exception e) {
log.warn("[DDG] failed: {}", e.getMessage());
return List.of();
}
}
// ─── Naver ───
List<Map<String, Object>> searchNaver(String query, String... urlPatterns) throws Exception {
String encoded = URLEncoder.encode(query, StandardCharsets.UTF_8);
String url = "https://openapi.naver.com/v1/search/webkr.json?query=" + encoded + "&display=30";
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create(url))
.timeout(REQ_TIMEOUT)
.header("X-Naver-Client-Id", naverClientId)
.header("X-Naver-Client-Secret", naverClientSecret)
.GET()
.build();
HttpResponse<String> resp = HTTP.send(req, HttpResponse.BodyHandlers.ofString());
if (resp.statusCode() >= 400) {
throw new RuntimeException("Naver " + resp.statusCode());
}
JsonNode root = json.readTree(resp.body());
JsonNode items = root.path("items");
List<Map<String, Object>> out = new ArrayList<>();
Set<String> seen = new HashSet<>();
for (JsonNode it : items) {
if (out.size() >= MAX_RESULTS) break;
String link = it.path("link").asText("");
String title = stripTags(it.path("title").asText(""));
if (link.isEmpty() || !matchesPattern(link, urlPatterns)) continue;
if (seen.add(link)) out.add(Map.of("title", title, "url", link));
}
log.info("[NaverSearch] '{}' → {}", query, out.size());
return out;
}
// ─── DDG ───
List<Map<String, Object>> searchDdg(String query, String... urlPatterns) throws Exception {
String encoded = URLEncoder.encode(query, StandardCharsets.UTF_8);
String url = "https://html.duckduckgo.com/html/?q=" + encoded;
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create(url))
.timeout(REQ_TIMEOUT)
.header("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36")
.header("Accept", "text/html,application/xhtml+xml")
.header("Accept-Language", "ko-KR,ko;q=0.9")
.GET()
.build();
HttpResponse<String> resp = HTTP.send(req, HttpResponse.BodyHandlers.ofString());
String html = resp.body();
Matcher m = DDG_RESULT.matcher(html);
List<Map<String, Object>> out = new ArrayList<>();
Set<String> seen = new HashSet<>();
while (m.find() && out.size() < MAX_RESULTS) {
String href = m.group(1);
String title = m.group(2).replaceAll("<[^>]+>", "").trim();
String actual = extractDdgUrl(href);
if (actual == null || !matchesPattern(actual, urlPatterns)) continue;
if (seen.add(actual)) out.add(Map.of("title", title, "url", actual));
}
log.info("[DDG] '{}' → {}", query, out.size());
return out;
}
private String extractDdgUrl(String ddgHref) {
try {
if (ddgHref.contains("uddg=")) {
String p = ddgHref.substring(ddgHref.indexOf("uddg=") + 5);
int amp = p.indexOf('&');
if (amp > 0) p = p.substring(0, amp);
return URLDecoder.decode(p, StandardCharsets.UTF_8);
}
if (ddgHref.startsWith("http")) return ddgHref;
} catch (Exception e) {
log.debug("[DDG] url extract failed: {}", ddgHref);
}
return null;
}
static String stripTags(String s) {
return s == null ? "" : s.replaceAll("<[^>]+>", "").trim();
}
static boolean matchesPattern(String url, String[] patterns) {
if (patterns == null || patterns.length == 0) return true;
for (String p : patterns) {
if (url.contains(p)) return true;
}
return false;
}
}

View File

@@ -0,0 +1,67 @@
package com.tasteby.util;
import java.text.Normalizer;
import java.util.HashMap;
import java.util.Map;
/**
* #348 — 한국어 자모 분해(Unicode NFD) + Sørensen-Dice bigram 유사도.
*
* 음절 단위 Jaccard보다 짧은 한국어 이름에 정확. 예:
* similarity("스타벅스 강남", "스타벅스 강남점") ≈ 0.85+
* similarity("스타벅스 강남", "스타벅스 종로") ≈ 0.55~0.85
* similarity("스타벅스", "맥도날드") < 0.20
*
* 공백/구두점은 제거하고 소문자화한 뒤 NFD 분해.
*/
public final class HangulSimilarity {
private HangulSimilarity() {}
/** 공백/구두점 제거 + 소문자화 + NFD 분해(한글 음절 → 자모). */
public static String decompose(String s) {
if (s == null || s.isEmpty()) return "";
String stripped = s.replaceAll("[\\\\-_()\\[\\]【】]", "").toLowerCase();
return Normalizer.normalize(stripped, Normalizer.Form.NFD);
}
/**
* Sørensen-Dice 계수 (bigram multiset 기반). 0.0~1.0.
* 동일 문자열 → 1.0. 빈 입력 → 0.0.
*/
public static double similarity(String a, String b) {
String da = decompose(a);
String db = decompose(b);
if (da.isEmpty() || db.isEmpty()) return 0.0;
if (da.equals(db)) return 1.0;
// 포함 관계는 강한 신호로 1.0 처리 (기존 동작과 일관)
if (da.contains(db) || db.contains(da)) return 1.0;
if (da.length() < 2 || db.length() < 2) {
return 0.0;
}
Map<String, Integer> bigramsA = bigrams(da);
Map<String, Integer> bigramsB = bigrams(db);
int common = 0;
for (var e : bigramsA.entrySet()) {
Integer countB = bigramsB.get(e.getKey());
if (countB != null) {
common += Math.min(e.getValue(), countB);
}
}
int sizeA = da.length() - 1;
int sizeB = db.length() - 1;
return (2.0 * common) / (sizeA + sizeB);
}
private static Map<String, Integer> bigrams(String s) {
Map<String, Integer> map = new HashMap<>();
for (int i = 0; i < s.length() - 1; i++) {
String gram = s.substring(i, i + 2);
map.merge(gram, 1, Integer::sum);
}
return map;
}
}

View File

@@ -53,7 +53,8 @@ public final class JsonUtil {
try {
return MAPPER.readValue(json, new TypeReference<>() {});
} catch (Exception e) {
return Collections.emptyMap();
// Plain text or malformed JSON (e.g. Python-style single quotes) → wrap as {"text": "..."}
return Map.of("text", json.trim());
}
}
@@ -74,6 +75,24 @@ public final class JsonUtil {
return rows.stream().map(JsonUtil::lowerKeys).collect(Collectors.toList());
}
/**
* Normalize evaluation to a valid JSON object string (e.g. {"text":"..."}).
* Plain text is wrapped, already-valid JSON is returned as-is, and text is truncated to maxLen.
*/
public static String normalizeEvaluation(String eval, int maxLen) {
if (eval == null || eval.isBlank()) return null;
String trimmed = eval.trim();
if (trimmed.startsWith("{")) return trimmed;
if (trimmed.length() > maxLen) {
trimmed = trimmed.substring(0, maxLen);
}
return toJson(Map.of("text", trimmed));
}
public static String normalizeEvaluation(String eval) {
return normalizeEvaluation(eval, 300);
}
public static String toJson(Object value) {
try {
return MAPPER.writeValueAsString(value);

View File

@@ -56,6 +56,11 @@ app:
youtube-api-key: ${YOUTUBE_DATA_API_KEY}
client-id: ${GOOGLE_CLIENT_ID:635551099330-2l003d3ernjmkqavd4f6s78r8r405iml.apps.googleusercontent.com}
# #357 — Naver Search API (Tabling/Catchtable URL 매칭). 미설정 시 DDG 폴백.
naver:
client-id: ${NAVER_CLIENT_ID:}
client-secret: ${NAVER_CLIENT_SECRET:}
cache:
ttl-seconds: 600

View File

@@ -0,0 +1,7 @@
-- #356 영상-식당 관련도 LLM 평가
ALTER TABLE video_restaurants ADD (
relevance VARCHAR2(16) DEFAULT 'unknown' NOT NULL,
relevance_reason VARCHAR2(120),
relevance_evaluated_at TIMESTAMP
);
CREATE INDEX idx_vr_relevance ON video_restaurants(relevance);

View File

@@ -69,14 +69,20 @@
</select>
<select id="findVideoLinks" resultType="map">
SELECT v.video_id, v.title, v.url,
<!-- #356 — relevance 컬럼 SELECT + includeWeak 가드 -->
SELECT vr.id AS link_id,
v.video_id, v.title, v.url,
TO_CHAR(v.published_at, 'YYYY-MM-DD"T"HH24:MI:SS') AS published_at,
vr.foods_mentioned, vr.evaluation, vr.guests,
vr.relevance, vr.relevance_reason,
c.channel_name, c.channel_id
FROM video_restaurants vr
JOIN videos v ON v.id = vr.video_id
JOIN channels c ON c.id = v.channel_id
WHERE vr.restaurant_id = #{restaurantId}
<if test="includeWeak == null or !includeWeak">
AND vr.relevance IN ('strong', 'unknown')
</if>
ORDER BY v.published_at DESC
</select>
@@ -315,4 +321,53 @@
SELECT COUNT(*) FROM restaurants WHERE verified_at IS NULL
</select>
<!-- ===== #356 영상-식당 관련도 ===== -->
<update id="updateLinkRelevance">
UPDATE video_restaurants
SET relevance = #{relevance},
relevance_reason = #{reason,jdbcType=VARCHAR},
relevance_evaluated_at = CURRENT_TIMESTAMP
WHERE id = #{linkId}
</update>
<select id="findLinkContext" resultType="map">
<!-- LLM 평가에 필요한 정보 -->
SELECT vr.id AS link_id, vr.foods_mentioned, vr.evaluation,
r.id AS restaurant_id, r.name AS restaurant_name, r.address, r.cuisine_type,
v.title AS video_title, c.channel_name
FROM video_restaurants vr
JOIN restaurants r ON r.id = vr.restaurant_id
JOIN videos v ON v.id = vr.video_id
JOIN channels c ON c.id = v.channel_id
WHERE vr.id = #{linkId}
</select>
<select id="findUnevaluatedLinks" resultType="map">
SELECT id AS link_id FROM video_restaurants
WHERE relevance_evaluated_at IS NULL
FETCH FIRST #{limit} ROWS ONLY
</select>
<select id="countUnevaluatedLinks" resultType="int">
SELECT COUNT(*) FROM video_restaurants WHERE relevance_evaluated_at IS NULL
</select>
<!-- #359 1단계 — google_place_id 중복 조회 (그룹 식당 + 참조 카운트) -->
<select id="findDuplicatePlaceIdRows" resultType="map">
SELECT r.id, r.google_place_id, r.name, r.address,
TO_CHAR(r.created_at, 'YYYY-MM-DD"T"HH24:MI:SS') AS created_at,
r.hidden,
(SELECT COUNT(*) FROM video_restaurants vr WHERE vr.restaurant_id = r.id) AS video_count,
(SELECT COUNT(*) FROM user_reviews rv WHERE rv.restaurant_id = r.id) AS review_count,
(SELECT COUNT(*) FROM user_memos mm WHERE mm.restaurant_id = r.id) AS memo_count
FROM restaurants r
WHERE r.google_place_id IN (
SELECT google_place_id FROM restaurants
WHERE google_place_id IS NOT NULL
GROUP BY google_place_id HAVING COUNT(*) > 1
)
ORDER BY r.google_place_id, r.created_at
</select>
</mapper>

View File

@@ -10,13 +10,13 @@
WHEN NOT MATCHED THEN INSERT (visit_date, visit_count) VALUES (src.d, 1)
</update>
<select id="getTodayVisits" resultType="int">
<select id="getTodayVisits" resultType="long">
SELECT NVL(visit_count, 0)
FROM site_visits
WHERE visit_date = TRUNC(SYSDATE)
</select>
<select id="getTotalVisits" resultType="int">
<select id="getTotalVisits" resultType="long">
SELECT NVL(SUM(visit_count), 0)
FROM site_visits
</select>

View File

@@ -37,7 +37,7 @@
</select>
<select id="findAllWithCounts" resultMap="userResultMap">
SELECT u.id, u.email, u.nickname, u.avatar_url, u.provider, u.created_at,
SELECT u.id, u.email, u.nickname, u.avatar_url, u.is_admin, u.provider, u.created_at,
NVL(fav.cnt, 0) AS favorite_count,
NVL(rev.cnt, 0) AS review_count,
NVL(memo.cnt, 0) AS memo_count
@@ -53,4 +53,8 @@
SELECT COUNT(*) FROM tasteby_users
</select>
<update id="updateAdmin">
UPDATE tasteby_users SET is_admin = #{admin,jdbcType=NUMERIC} WHERE id = #{id}
</update>
</mapper>

View File

@@ -62,15 +62,18 @@ if [[ "$TARGET" == "all" || "$TARGET" == "frontend" ]]; then
# Read build args from env or .env file
MAPS_KEY="${NEXT_PUBLIC_GOOGLE_MAPS_API_KEY:-}"
CLIENT_ID="${NEXT_PUBLIC_GOOGLE_CLIENT_ID:-}"
NAVER_MAP_ID="${NEXT_PUBLIC_NAVER_MAP_CLIENT_ID:-}"
if [[ -f frontend/.env.local ]]; then
MAPS_KEY="${MAPS_KEY:-$(grep NEXT_PUBLIC_GOOGLE_MAPS_API_KEY frontend/.env.local 2>/dev/null | cut -d= -f2)}"
CLIENT_ID="${CLIENT_ID:-$(grep NEXT_PUBLIC_GOOGLE_CLIENT_ID frontend/.env.local 2>/dev/null | cut -d= -f2)}"
NAVER_MAP_ID="${NAVER_MAP_ID:-$(grep NEXT_PUBLIC_NAVER_MAP_CLIENT_ID frontend/.env.local 2>/dev/null | cut -d= -f2)}"
fi
docker build --platform "$PLATFORM" \
--build-arg NEXT_PUBLIC_GOOGLE_MAPS_API_KEY="$MAPS_KEY" \
--build-arg NEXT_PUBLIC_GOOGLE_CLIENT_ID="$CLIENT_ID" \
--build-arg NEXT_PUBLIC_NAVER_MAP_CLIENT_ID="$NAVER_MAP_ID" \
-t "$REGISTRY/frontend:$TAG" \
-t "$REGISTRY/frontend:latest" \
frontend/

View File

@@ -0,0 +1,117 @@
# 설계서: RTL/Jest 인프라 + next/image + ARIA Tabs (#343)
> **상태**: Approved
> **작성**: [AI] Architect · **최종수정**: 2026-06-15
> **추적성** — Redmine: #343 · 부모: #281 (현행화 frontend-review-memo, 09-Done)
> · 구현 파일: `frontend/package.json`, `frontend/jest.config.ts` (신규), `frontend/jest.setup.ts` (신규), `frontend/next.config.ts`, `frontend/__tests__/*` (신규), `frontend/src/components/MyReviewsList.tsx`, `frontend/src/components/ReviewSection.tsx`
> · 테스트: 본 이슈가 테스트 인프라 자체를 도입
## 1. 목적 (Why)
본 프로젝트는 지금까지 자동화된 단위 테스트가 0건. 누적된 "후속 테스트" 항목이 12+개(StatsService/CacheService/SearchService/Stars/HangulSimilarity 등)이며, 그동안 분리된 후속 이슈를 처리할 인프라가 없어 모두 보류 상태. 본 이슈에서 Jest + RTL 인프라 도입 + next/image 적용 + ARIA Tabs 보강.
## 2. 범위 (Scope)
- **포함**
- Jest 30 + `next/jest` 자동 설정 + `@testing-library/react` + `@testing-library/jest-dom`.
- `jest.config.ts`, `jest.setup.ts`, `package.json scripts: test, test:watch`.
- 샘플 테스트 3개 — 가장 안전한 순수 함수/단순 컴포넌트로 인프라 검증:
- `Stars` 컴포넌트 렌더 + 별점 표시
- 기존 `HangulSimilarity` (#348) — 자모/유사도
- `BotDetector` (#337) — 봇 UA 패턴
- `next.config.ts` `images.remotePatterns`에 Google avatar 도메인(`lh3.googleusercontent.com`) + YouTube thumbnail(`i.ytimg.com`).
- `ReviewSection`/`MyReviewsList``<img>` 일부를 `next/image` 또는 명시적 eslint-disable로 정리.
- `MyReviewsList` 탭에 WAI-ARIA Tabs 패턴(role=tablist/tab/aria-selected/aria-controls).
- **제외 (후속)**
- 백엔드 JUnit 테스트 인프라 (별도 큰 작업).
- E2E (Playwright) 도입.
- CI 통합 (GitHub Actions 또는 OCI DevOps).
- 모든 컴포넌트 테스트 — 점진적으로 추가.
- 모든 `<img>``next/image` 전수 교체 — 점진적.
## 3. 인수조건
- [ ] `npm test`가 단일 명령으로 동작 (0건 → 샘플 3개 통과).
- [ ] `npm run build`가 회귀 없이 통과.
- [ ] `next.config.ts``remotePatterns` 설정.
- [ ] `ReviewSection`의 user_avatar_url `<img>``next/image` 또는 eslint-disable 주석.
- [ ] `MyReviewsList` 탭이 `role="tablist"`/`role="tab"`/`aria-selected`/`aria-controls`/`tabIndex` 설정.
## 4. 컨텍스트 & 제약
- Next.js 16.1.6 + Turbopack.
- `next/jest`는 SWC/Babel 자동 통합. Turbopack 빌드와는 분리(테스트만 Jest 별도).
- Pretendard/Geist 폰트는 `next/font/local` 사용 → 테스트에선 mock 불필요.
- 패널 분리(#329)로 admin 영역은 단위 테스트 도입 더 쉬워짐.
## 5. 아키텍처 개요
```
frontend/
├── package.json
│ └── scripts: test, test:watch
├── jest.config.ts (next/jest createNextJestConfig 사용)
├── jest.setup.ts (@testing-library/jest-dom 확장 matchers)
├── __tests__/
│ ├── Stars.test.tsx
│ ├── HangulSimilarity.test.ts (자체 구현은 backend Java, TS 포팅은 미적용 → 다른 순수 함수로 대체)
│ └── BotDetector.test.ts (마찬가지 — backend → TS 동등 포팅 불가)
└── (대안) 프론트 측 순수 함수:
├── lib/cuisine-icons.ts 의 getPhosphorCuisineIcon
├── components/Stars 의 0.5 단위 렌더
└── i18n/config.ts 의 isLocale/detectBrowserLocale
```
→ 백엔드 Java 코드는 TS 테스트로 검증 불가. 프론트 측 순수 함수 3개로 대체:
- `Stars` 렌더 (RTL component test)
- `i18n/config.ts` `isLocale` (pure)
- `i18n/config.ts` `detectBrowserLocale` (navigator mock)
## 6. 데이터 모델
`__tests__/*.test.{tsx,ts}` — Jest 표준 컨벤션.
## 7. 함수 명세
| 단위 | 책임 | 비고 |
|------|------|------|
| `jest.config.ts` | `createJestConfig(customConfig)` + moduleNameMapper `@/*` | next/jest |
| `jest.setup.ts` | `import "@testing-library/jest-dom"` | 확장 matchers |
| `Stars.test.tsx` | 별점 0/2.5/5 렌더, aria-label 확인 | RTL |
| `i18n/config.test.ts` | isLocale/detectBrowserLocale | navigator mock |
| `MyReviewsList` Tabs 패치 | tablist/tab/aria-selected | role + aria |
| `ReviewSection` img → eslint-disable | 최소 변경 | next/image는 후속 |
## 8. 흐름
1. `npm i -D jest jest-environment-jsdom @testing-library/react @testing-library/jest-dom @testing-library/user-event @types/jest`.
2. `jest.config.ts` + `jest.setup.ts` 작성.
3. `package.json``"test": "jest"` + `"test:watch": "jest --watch"` 추가.
4. `__tests__/`에 3개 샘플 테스트.
5. `next.config.ts``remotePatterns` 추가.
6. `MyReviewsList` Tabs ARIA 보강.
7. `ReviewSection``<img>` 라인에 `// eslint-disable-next-line @next/next/no-img-element` (next/image 전환은 후속).
8. `npm test` 통과 → `npm run build` 통과.
## 9. 엣지케이스
- **Turbopack vs Jest**: 무관 (테스트는 별도 SWC 컴파일).
- **CSS modules / globals.css import**: jest.config.ts의 moduleNameMapper로 `\\.(css|scss)$``identity-obj-proxy` 대신 next/jest가 자동 처리.
- **Next.js Server Components**: 본 프로젝트는 모두 `"use client"` 컴포넌트라 RTL이 통상 동작.
## 10. 테스트
자기 자신 — `npm test`가 통과해야 본 이슈 완료.
## 11. 리스크 & 대안
- **선택**: `next/jest` + RTL. Next.js 공식 권장.
- **대안 A**: Vitest — 더 빠르지만 Next.js 공식 가이드 부재, 본 프로젝트 규모에서 차이 작음.
- **대안 B**: Playwright Component Testing — 더 무겁고 E2E 통합 안 됨.
- **트레이드오프**: Jest 30 + RTL은 React 19에 호환. 의존성 부담은 dev-only.
## 12. 미해결 질문
- CI(테스트 자동 실행) — 본 이슈 범위 밖. OCI DevOps Build Pipeline은 ARM64 미지원 → GitHub Actions 또는 Gitea Actions 후속.
- 백엔드 JUnit 테스트 인프라 — 별도 큰 이슈.
- E2E (Playwright) — 별도.

View File

@@ -0,0 +1,80 @@
# 설계서: isNameSimilar 한국어 자모 분해 + Sørensen-Dice (#348)
> **상태**: Approved
> **작성**: [AI] Architect · **최종수정**: 2026-06-15
> **추적성** — Redmine: #348 · 부모: #332 (이미 close)
> · 구현 파일: `backend-java/src/main/java/com/tasteby/util/HangulSimilarity.java` (신규), `backend-java/src/main/java/com/tasteby/controller/RestaurantController.java`
## 1. 목적
기존 `isNameSimilar`가 Jaccard-like(문자 set 교집합 비율 ≥ 0.4)로 짧은 한국어 이름에서 오탐 가능. 자모 분해 + Sørensen-Dice bigram으로 정확도 향상.
## 2. 범위
- **포함**
- `HangulSimilarity.similarity(a, b)` 유틸 신규
- `RestaurantController.isNameSimilar` 호출부를 새 유틸로 교체
- **제외 (별도 후속으로 분리)**
- DDG → 정식 검색 API 전환 (외부 API 결정 + 비용/계약 필요)
- DTO RestaurantUpdateDTO + @Valid 표준화 (#332 화이트리스트 set으로 SQL 측 가드 확보)
- UNIQUE(google_place_id) 제약 강화 — DB 중복 정리 선행 필요(현재 10+건 중복 확인)
## 3. 인수조건
- [ ] `HangulSimilarity.similarity(a, b)` 0.0~1.0 반환 (1.0=동일)
- [ ] 한국어 음절을 Unicode NFD로 자모 분해(초성·중성·종성)
- [ ] 분해 후 bigram 기반 Sørensen-Dice 계수 계산
- [ ] 빈 문자열 안전 처리 (둘 다 비면 0.0, 한쪽만 비면 0.0)
- [ ] `RestaurantController.isNameSimilar` 임계값 0.45로 호출 (Jaccard 0.4와 유사 보수성)
- [ ] 회귀 없음 — 기존 정상 매칭 시나리오 통과
## 4. 컨텍스트 & 제약
- Java 21 `Normalizer.normalize(Form.NFD)` 활용.
- 한글 음절(가-힣) NFD → 초성(ㄱ-ㅎ 호환자모 또는 조합자모) + 중성 + 종성.
- 영문/숫자는 그대로 통과.
- Sørensen-Dice: `2 * |A ∩ B| / (|A| + |B|)` — bigram 다중집합(multiset) 기준.
## 5. 함수 명세
| 함수 | 책임 | 시그니처 |
|------|------|---------|
| `decomposeHangul(s)` | NFD 자모 분해 + 공백/구두점 제거 + 소문자화 | `static String decompose(String)` |
| `bigrams(s)` | 2글자 bigram 리스트 | `static List<String> bigrams(String)` |
| `similarity(a, b)` | Sørensen-Dice 0.0~1.0 | `static double similarity(String, String)` |
## 6. 흐름
1. 두 이름을 `decompose`로 자모 분해 + 정규화.
2. 각 분해 결과를 `bigrams`로 분해.
3. multiset 교집합 크기 카운트.
4. `2 * common / (sizeA + sizeB)`.
## 7. 엣지케이스
- **둘 다 빈 문자열**: 0.0 반환.
- **bigram 1개 이하**: 두 문자열 같으면 1.0, 아니면 0.0.
- **포함 관계**: 기존 코드의 `a.contains(b) || b.contains(a)` 단축 평가 유지 (1.0 반환).
- **혼합(한영)**: NFD가 한글만 분해 → 영문은 그대로. bigram 계산은 동일하게 동작.
## 8. 테스트 (수동)
```
similarity("스타벅스 강남", "스타벅스 강남점") → ≥ 0.85
similarity("스타벅스 강남", "스타벅스 종로") → ≥ 0.55, < 0.85
similarity("스타벅스", "맥도날드") → < 0.20
similarity("PIZZAHUT", "피자헛") → 한글 + 영문 혼재 가드 통과
```
## 9. 리스크 & 대안
- **선택**: NFD 분해 + bigram Sørensen-Dice. Java 표준 라이브러리만 사용.
- **대안 A**: Apache Commons Text `JaroWinklerSimilarity` — 라이브러리 추가 부담.
- **대안 B**: Hangul.js류 라이브러리 — Java 포팅 없음.
- **대안 C**: Levenshtein 거리 — 자모 분해와 결합 시 좋으나 구현 복잡.
## 10. 미해결 질문 / 분리된 후속
- DDG → 정식 검색 API: Naver Search API 또는 Google Custom Search (외부 API 결정 + 비용 검토 필요) — 별도 신규 이슈
- DTO RestaurantUpdateDTO + @Valid: #332 set 화이트리스트로 1차 가드. 본격 DTO는 큰 변경 — 별도 신규 이슈
- UNIQUE(google_place_id) 제약: 현재 10+건 중복. 데이터 정리(병합/삭제) 선행 → 별도 신규 이슈

View File

@@ -0,0 +1,141 @@
# 설계서: i18n 뼈대 — ko/en/ja/es (#352)
> **상태**: Approved
> **작성**: [AI] Architect · **최종수정**: 2026-06-15
> **추적성** — Redmine: #352
> · 구현 파일: `frontend/package.json`, `frontend/next.config.ts`, `frontend/src/i18n/*` (신규), `frontend/src/messages/{ko,en,ja,es}.json` (신규), `frontend/src/components/LanguageSwitcher.tsx` (신규)
> · 테스트: 수동 (언어 전환 + ko fallback)
## 1. 목적
장기적으로 영어/일본어/스페인어 시장으로 확장 가능하도록 i18n 뼈대 구축. 본 이슈는 *뼈대*만 — 약 30개 키 초기 번역.
## 2. 범위
- **포함**
- `next-intl` 라이브러리 도입 (Next.js 16 App Router 권장).
- 4개 로케일: `ko, en, ja, es`. 기본 `ko`, fallback `ko`.
- 메시지 디렉토리 `src/messages/{ko,en,ja,es}.json`.
- Provider (`<NextIntlClientProvider>`) 루트 layout 적용.
- `LanguageSwitcher` 컴포넌트 (헤더 우측).
- 로케일 저장: localStorage `tasteby_locale`.
- 초기 번역 키 ~30개: 헤더(로그인/검색/메뉴) + 주요 액션(저장/취소/삭제/확인) + 페이지 제목.
- **제외**
- URL 라우팅 i18n (`/ko/...`).
- SEO meta tags i18n.
- 식당명/리뷰 등 사용자 콘텐츠 번역.
- 어드민 페이지(운영자만 사용, 한국어 유지).
- 식당 카드/상세 시트 전체 키 추출 (점진).
## 3. 인수조건
- [ ] `npm i next-intl` + 4개 메시지 파일 생성.
- [ ] `tasteby_locale`이 localStorage에 있으면 사용, 없으면 브라우저 언어 감지(`navigator.language`) → 매칭 안되면 `ko`.
- [ ] 헤더 우측 LanguageSwitcher 드롭다운(국기 + 코드).
- [ ] 초기 번역 키 약 30개 — 4개 언어 모두 채움.
- [ ] 미번역 키는 `ko` fallback (에러 없이).
- [ ] 빌드/배포 회귀 없음.
## 4. 컨텍스트 & 제약
- Next.js 16 + App Router + `"use client"` 컴포넌트 다수.
- 기존 `auth-context`처럼 i18n도 React Context 패턴.
- `next-intl`은 server/client 모두 지원, 본 프로젝트는 client-side switching이라 `NextIntlClientProvider` 중심.
- URL 라우팅 변경 없이 단순 메시지만 교체(낮은 비용).
- 폰트: Pretendard Variable이 한국어/영어 잘 표시, 일본어는 시스템 폰트 fallback OK, 스페인어는 라틴 문자라 OK.
## 5. 아키텍처 개요
```
frontend/
├── src/
│ ├── i18n/
│ │ ├── config.ts ← 로케일 목록/기본값 상수
│ │ ├── LocaleProvider.tsx ← NextIntlClientProvider wrap + localStorage 저장
│ │ └── useTranslations.ts ← (next-intl 재export)
│ ├── messages/
│ │ ├── ko.json ← 기본
│ │ ├── en.json
│ │ ├── ja.json
│ │ └── es.json
│ ├── components/
│ │ └── LanguageSwitcher.tsx ← 헤더용
│ └── app/
│ └── layout.tsx ← LocaleProvider로 감싸기
```
## 6. 데이터 모델
### 메시지 키 (초기 ~30)
```json
{
"header": {
"search": "검색", "login": "로그인", "logout": "로그아웃",
"menu": "메뉴", "myReviews": "내 리뷰", "favorites": "즐겨찾기"
},
"actions": {
"save": "저장", "cancel": "취소", "delete": "삭제",
"edit": "수정", "confirm": "확인", "close": "닫기",
"loading": "로딩 중...", "submit": "제출"
},
"filter": {
"title": "필터", "cuisine": "음식 종류", "price": "가격대",
"region": "지역", "channel": "채널", "reset": "초기화"
},
"restaurant": {
"rating": "평점", "address": "주소", "phone": "전화",
"website": "웹사이트", "closed": "폐업", "tempClosed": "임시휴업"
},
"review": {
"title": "리뷰", "write": "리뷰 작성", "noReviews": "아직 리뷰가 없습니다"
}
}
```
총 5개 카테고리 × 평균 6개 = 30개 키.
## 7. 함수 명세
| 함수/컴포넌트 | 책임 | 비고 |
|---|---|---|
| `i18n/config.ts` | LOCALES, DEFAULT_LOCALE 상수 | 단순 |
| `LocaleProvider.tsx` | NextIntlClientProvider wrap + 메시지 동적 로딩 + localStorage 동기화 | client |
| `LanguageSwitcher.tsx` | 헤더 드롭다운 (국기 + 코드) | client, 44px 터치 |
| `messages/<lang>.json` | 키 → 텍스트 | flat or nested |
## 8. 흐름
1. 사용자 첫 방문 → `tasteby_locale` 없음 → `navigator.language.split('-')[0]`이 LOCALES에 있으면 사용, 아니면 `ko`.
2. LocaleProvider가 해당 로케일 메시지 파일 import → NextIntlClientProvider에 전달.
3. 컴포넌트는 `useTranslations('header')` 등으로 호출.
4. LanguageSwitcher에서 변경 → localStorage 저장 → 페이지 새로고침 또는 state 업데이트.
## 9. 엣지케이스
- **메시지 파일 누락 키**: next-intl 기본 동작은 키 자체 표시 + 콘솔 경고. fallback 처리는 messages 명시.
- **localStorage 비활성/SSR**: typeof window 체크.
- **로케일 코드 대소문자**: 항상 소문자 정규화.
- **placeholder 변수**: next-intl ICU 메시지 형식 지원 (`{name}` 등). 초기 키에는 미적용.
## 10. 테스트
- 수동:
- 한국어 첫 방문 → "검색" 표시.
- LanguageSwitcher에서 English → "Search" 표시.
- 새로고침 후 영어 유지.
- 메시지 누락 키 → 콘솔 경고 + 키 표시.
## 11. 리스크 & 대안
- **선택**: `next-intl` (Next.js 16 App Router 권장, ICU 메시지, 활발 유지보수).
- **대안 A**: `react-i18next` — 더 일반적이지만 App Router 통합 next-intl이 더 매끄러움.
- **대안 B**: 자체 구현 + Context — 의존성 ↓ but 기능/표준화 ↓.
- **트레이드오프**: 30개 키는 단순하지만 ICU 메시지(복수형, 성별 등) 필요 시 next-intl 가치 큼.
## 12. 미해결 질문
- 식당명/리뷰 콘텐츠 번역 — 사용자 작성이라 자동 번역(LLM)? 별도 정책 결정.
- URL 라우팅 i18n (`/en/`) — 후속.
- SEO meta tags i18n — 후속.
- 어드민 페이지는 운영자 한국어 유지 — 확정.
- 통화/날짜 포맷(Intl API) — 후속.

View File

@@ -0,0 +1,172 @@
# 설계서: 영상-식당 관련도 LLM 평가로 약한 매칭 자동 숨김 (#356)
> **상태**: Approved
> **작성**: [AI] Architect · **최종수정**: 2026-06-15
> **추적성** — Redmine: #356 · 유사 패턴: #322(식당 LLM 검증, 09-Done) · 부모 영역: #270(영상→식당 추출 파이프라인 현행화, 09-Done)
> · 구현 파일: `backend-java/src/main/java/com/tasteby/service/VideoRelevanceService.java`(신규), `backend-java/src/main/resources/mybatis/mapper/RestaurantMapper.xml`, `backend-java/src/main/java/com/tasteby/service/RestaurantService.java`, `backend-java/src/main/java/com/tasteby/controller/AdminVideoRelevanceController.java`(신규), DB 마이그레이션 SQL
> · 테스트: 본 범위 밖 (테스트 인프라 #343 도입됨, 후속에서 점진 확장)
## 1. 목적 (Why)
식당 상세에 연결된 영상 중 식당과 **본격적으로 관련 없는 약한 언급**(비교 대상, 일반 토픽 중 잠깐 언급, 식당 입점 전 영상 등)이 노이즈로 표시. 실제 케이스 — 파이브가이즈 강남의 영상 7개 중 3건이 약한 매칭(쉐이크쉑 비교 / 미국 비만율 일반 토픽 / 한국 입점 전 미국 여행). LLM 평가로 약한 매칭 자동 숨김.
## 2. 범위 (Scope)
- **포함**
- `video_restaurants` 테이블에 `relevance`, `relevance_reason`, `relevance_evaluated_at` 컬럼 추가.
- `VideoRelevanceService` 신규 — LLM 판정 + DB 반영 (`#322` 패턴 모방).
- `PipelineService.processExtract` 완료 후 `verifyAsync(linkId)` 호출 — 신규 등록 자동 평가.
- `GET /api/restaurants/{id}/videos`: 기본 `relevance = 'strong'`만 응답. `?include_weak=true` 시 모두 포함.
- 어드민 API: 단건 재평가 / 일괄 백필 / 수동 토글.
- **제외 (별도 후속)**
- 어드민 UI(검증 칼럼 / 토글) — `#322`의 RestaurantsPanel UI와 같은 패턴으로 별도 후속.
- 프론트 사용자 옵션 UI("약한 매칭도 보기" 토글) — 별도 후속.
- LLM 비용 모니터링/메트릭 — 별도.
## 3. 인수조건
- [ ] `video_restaurants` 테이블에 `relevance VARCHAR2(16) DEFAULT 'unknown'`, `relevance_reason VARCHAR2(120)`, `relevance_evaluated_at TIMESTAMP` 컬럼 + `idx_vr_relevance` 인덱스.
- [ ] 가능한 값: `strong | weak | incidental | unknown` (unknown = 미평가).
- [ ] 신규 등록 시 60초 안에 `relevance_evaluated_at` 설정.
- [ ] `GET /api/restaurants/{id}/videos` 기본 응답: `relevance IN ('strong','unknown')` (안전한 기본값 = 평가 실패 시 표시).
- [ ] `?include_weak=true`: 모두 포함 + `relevance`, `relevance_reason` 필드 동봉.
- [ ] 어드민 API:
- `GET /api/admin/video-relevance/pending` → 미평가(unknown) 카운트
- `POST /api/admin/video-relevance/all?batchSize=10` → 백필
- `POST /api/admin/video-relevance/{linkId}/evaluate` → 단건 재평가
- `PATCH /api/admin/video-relevance/{linkId}` → 수동 강제 토글 `{relevance, reason}`
- [ ] LLM 호출 실패 시 `unknown` 유지 + 로그 (`#322`와 같은 안전 기본값).
- [ ] 빌드/배포 회귀 없음.
## 4. 컨텍스트 & 제약
- 기존 `OciGenAiService.chat(prompt, maxTokens)` 재사용.
- LLM 비용: 영상-식당 페어당 1회 단발. 현재 1,244건 → 백필 시 약 1,244 호출.
- `video_restaurants`는 한 영상에 여러 식당, 한 식당에 여러 영상이 m:n 관계.
- 같은 페어는 `relevance_evaluated_at`이 NULL 아니면 재평가 안 함 (캐시).
## 5. 아키텍처 개요
```
PipelineService.processExtract (기존)
RestaurantService.linkVideoRestaurant (video_restaurants INSERT)
VideoRelevanceService.verifyAsync(linkId) ← #356 신규
│ (비동기)
OciGenAiService.chat(prompt, 120)
parseRelevance → { relevance: strong|weak|incidental, reason: string }
RestaurantMapper.updateRelevance(linkId, relevance, reason)
▼ (조회 시)
RestaurantMapper.findVideoLinks(restaurantId, includeWeak)
├ includeWeak=false (기본): WHERE relevance IN ('strong','unknown')
└ includeWeak=true: 모두 + relevance/reason 필드 노출
```
## 6. 데이터 모델
### DB 마이그레이션
```sql
ALTER TABLE video_restaurants ADD (
relevance VARCHAR2(16) DEFAULT 'unknown' NOT NULL,
relevance_reason VARCHAR2(120),
relevance_evaluated_at TIMESTAMP
);
CREATE INDEX idx_vr_relevance ON video_restaurants(relevance);
```
### 도메인 (`VideoRestaurantLink` 확장은 본 범위 밖 — findVideoLinks는 `resultType="map"`)
응답 Map에 키 추가:
- `relevance`: `"strong" | "weak" | "incidental" | "unknown"`
- `relevance_reason`: `string | null`
### LLM 응답 스키마
```json
{
"relevance": "strong" | "weak" | "incidental",
"reason": "20자 이내"
}
```
## 7. 함수 명세
| 함수 | 책임 | 비고 |
|---|---|---|
| `VideoRelevanceService.verifyAsync(linkId)` | 비동기 트리거 | `#322``RestaurantVerifyService.verifyAsync` 유사 |
| `VideoRelevanceService.verify(linkId)` | 단건 검증 + DB 반영 | LLM 실패 시 unknown 유지 |
| `VideoRelevanceService.verifyAll(batchSize)` | 백필 (식당당 200ms sleep) | |
| `VideoRelevanceService.buildPrompt(...)` | 프롬프트 생성 | 식당명·주소·음식·영상 제목·평가 |
| `VideoRelevanceService.parseRelevance(raw)` | LLM 응답 → DTO | 파싱 실패 시 unknown 안전 기본값 |
| `RestaurantMapper.updateRelevance(linkId, rel, reason)` | DB 갱신 | |
| `RestaurantMapper.findVideoLinks(restaurantId, includeWeak)` | 기존 SQL에 WHERE 조건 추가 | |
| `AdminVideoRelevanceController` 신규 | 4개 admin endpoint | requireAdmin |
## 8. 흐름
### 신규 등록 자동 평가
1. `PipelineService.processExtract``linkVideoRestaurant` → linkId 획득.
2. `VideoRelevanceService.verifyAsync(linkId)` 호출(@Async).
3. 별도 스레드: 영상/식당/평가 데이터 조회 → buildPrompt → LLM → parse → DB.
### 백필
1. 어드민 `POST /api/admin/video-relevance/all` 호출.
2. `WHERE relevance_evaluated_at IS NULL` 인 link 10개씩 조회 → 순차 검증.
3. 식당당 200ms sleep (LLM rate 보호).
### 프롬프트 예시
```
다음 YouTube 영상이 이 식당을 어떻게 다루는지 판정하라.
식당명: {restaurantName}
주소: {address}
음식: {foodsMentioned}
영상 제목: {videoTitle}
영상 채널: {channelName}
영상에 등장한 평가 내용: {evaluation}
응답 형식(JSON만, 다른 텍스트 없이):
{"relevance": "strong"|"weak"|"incidental", "reason": "20자 이내 한국어"}
가이드:
- strong: 영상 본편이 이 식당을 본격 다룸. 방문 리뷰, 메뉴 평가 등.
- weak: 영상에서 잠깐 언급, 비교 대상으로만 등장, 다른 식당의 일부로.
- incidental: 식당 입점 전 영상에서 단순 언급, 일반 토픽(미국 비만, 환율 등)에서 잠깐.
- 판단 모호 시 strong (보수적 — 사용자에게 표시 유지).
```
## 9. 엣지케이스
- **LLM 응답 비-JSON**: parseRelevance → unknown 기본값.
- **LLM 호출 실패**: unknown 유지 → 다음 백필 재시도.
- **영상 데이터 누락(transcript 없음, evaluation 비어있음)**: 프롬프트에 "(미상)" 표기. LLM이 판정 어려우면 strong 보수적.
- **동시성**: 같은 linkId verifyAsync 두 번 호출 → idempotent.
- **삭제된 영상**: linkId 조회 결과 없으면 no-op.
## 10. 테스트 (수동)
- 파이브가이즈 강남 케이스 백필 → 7건 중 3건이 weak/incidental로 마킹되는지 확인.
- 공개 API `/api/restaurants/{id}/videos` → 약한 매칭 제외 확인.
- `?include_weak=true` → 모두 포함 확인.
## 11. 리스크 & 대안
- **선택**: `#322` 동일 패턴 + DB 마이그레이션.
- **대안 A**: 사용자가 직접 "약한 매칭도 보기" 토글 → 사용자 결정 부담.
- **대안 B**: 추출 단계에서 한 번에 판정 → 비용 ↓이지만 ExtractorService 비대.
- **트레이드오프**: 단발 LLM 평가는 비용 합리적. false positive는 어드민 수동 토글 + `unknown` 안전 기본값으로 보완.
## 12. 미해결 질문
- 임계값(weak/incidental 둘 다 숨김 vs incidental만 숨김) — 현재는 둘 다 숨김.
- 영상 자막 전체를 LLM에 보낼지 vs 평가 텍스트만 → 비용/정확도 트레이드오프. 현재는 evaluation만(짧음).
- 사용자에게 "약한 매칭도 보기" UI → 별도 후속.
- 어드민 UI — 별도 후속 (#322 패턴 모방).

View File

@@ -0,0 +1,114 @@
# 설계서: DDG HTML 파싱 → 정식 검색 API 전환 (#357)
> **상태**: Approved
> **작성**: [AI] Architect · **최종수정**: 2026-06-15
> **추적성** — Redmine: #357 · 부모: #348(이름 유사도, 09-Done) · 관련: searchTabling/searchCatchtable
> · 구현 파일: `backend-java/src/main/java/com/tasteby/service/WebSearchService.java`(신규), `backend-java/src/main/java/com/tasteby/controller/RestaurantController.java`, `backend-java/src/main/resources/application.yml`, `k8s/secrets.yaml.template`
> · 테스트: 본 범위 밖 (수동 — Tabling/Catchtable 매핑 SSE에서 확인)
## 1. 목적 (Why)
`searchDuckDuckGo`는 html.duckduckgo.com을 정규식으로 긁어 Tabling/Catchtable URL을 추출. 봇 차단/HTML 구조 변경 시 대량 `NONE` 마킹 위험. 정식 검색 API로 교체해 안정성/정확도 확보.
## 2. 범위 (Scope)
- **포함**
- `WebSearchService` 신규 — Naver Search webkr.json 우선, 미설정/실패 시 DDG 폴백.
- `RestaurantController.searchTabling/searchCatchtable` 내부 호출을 새 서비스로 교체.
- `application.yml` + `k8s/secrets.yaml.template``NAVER_CLIENT_ID/SECRET` 항목 추가.
- **제외 (별도 후속)**
- Kakao Local API 검색 (식당 검색 전용이라 사이트 URL 매칭 부적합).
- Google Custom Search Engine (비용 + 무료 100/day 제약).
- 결과 캐시/메트릭 — 후속.
## 3. 인수조건
- [ ] `NAVER_CLIENT_ID/SECRET` 환경변수 등록 시 Naver Search webkr.json 호출.
- [ ] 미설정 또는 5xx/timeout 시 DDG로 자동 폴백 — 회귀 없음.
- [ ] 응답 형식: 기존 `List<Map<String, Object>>` 유지 — `{title, url}` 키.
- [ ] URL 패턴 필터 동일 동작 (예: `tabling.co.kr/restaurant/`, `tabling.co.kr/place/`).
- [ ] 빌드/배포 회귀 없음.
## 4. 컨텍스트 & 제약
- Naver Search 무료 한도: 일 25,000건. Tabling/Catchtable 매핑 백필이 1,200 식당 × 2회 ≈ 2,400건 1회성 → 한도 내.
- Naver는 `site:` 연산자 미지원 — 결과 URL 패턴 필터링으로 대체.
- `WebClient` 또는 `HttpClient` — 기존 `HttpClient`(static) 재사용.
- 키 미설정 환경(dev local 일부)에서도 DDG 폴백으로 동작 보장.
## 5. 아키텍처 개요
```
RestaurantController.searchTabling(name)
WebSearchService.search(query, urlPatterns)
├─ NAVER_CLIENT_ID 설정 시
│ └─ Naver webkr.json → URL 패턴 필터 → 결과
│ (실패/0건 시 ▼)
└─ DDG html.duckduckgo.com → 정규식 파싱 → URL 패턴 필터
```
## 6. 데이터 모델
### 응답 (기존 유지)
```json
[ { "title": "스타벅스 강남대로점", "url": "https://app.catchtable.co.kr/dining/12345" } ]
```
### Naver 응답 → 변환
```json
{ "items": [ { "title": "<b>스타벅스</b> 강남점", "link": "https://..." } ] }
```
- `title``<b>` 태그 제거 후 사용.
- `link`가 urlPatterns 중 하나에 매칭되면 결과에 포함.
## 7. 함수 명세
| 함수 | 책임 | 비고 |
|---|---|---|
| `WebSearchService.search(query, urlPatterns)` | 외부 진입점 | Naver 우선 → DDG 폴백 |
| `WebSearchService.searchNaver(...)` | Naver Search webkr.json 호출 + 필터 | 키 미설정 시 즉시 빈 결과 |
| `WebSearchService.searchDdg(...)` | 기존 DDG 로직 이관 | RestaurantController에서 옮김 |
| `WebSearchService.stripTags(s)` | `<b>...</b>` 제거 | |
| `WebSearchService.matchesPattern(url, patterns)` | URL 패턴 매칭 | `urlPatterns.length == 0` 면 모두 통과 |
## 8. 흐름
### 정상
1. `searchTabling(name)``webSearchService.search("스타벅스", ["tabling.co.kr/restaurant/", "tabling.co.kr/place/"])`.
2. Naver 호출 → 200, items 30 → URL 패턴 매칭 0~N건 추출.
3. 0건이면 DDG 폴백, 그래도 0건이면 빈 리스트.
### 폴백
- Naver 5xx / IOException / 키 미설정 → DDG.
### 키 등록 (운영자 작업)
1. https://developers.naver.com/apps/#/list 검색 앱 등록.
2. `NAVER_CLIENT_ID`, `NAVER_CLIENT_SECRET` 발급.
3. dev: `.env`에 추가, prod: `k8s/secrets.yaml`에 추가 + `kubectl apply`.
## 9. 엣지케이스
- **키 미설정**: searchNaver 즉시 빈 리스트 → DDG 폴백 (회귀 없음).
- **Naver rate limit (429)**: DDG 폴백.
- **DDG도 막힘**: 빈 리스트 반환 → 호출자(매핑 SSE)에서 `NONE` 처리 — 기존 동작 동일.
- **URL 패턴 빈 배열**: 패턴 매칭 스킵, 모든 결과 반환 (API 일반화 대비).
## 10. 테스트 (수동)
- Dev: `NAVER_CLIENT_ID/SECRET` 등록 → 어드민 `bulkTabling` SSE 실행 → 매칭율 비교 (이전 DDG 대비 ≥).
- 키 일부 제거 → DDG 폴백 동작 확인.
## 11. 리스크 & 대안
- **선택**: Naver primary + DDG fallback. 한국 식당 정확도 + 무료 한도 + 폴백 안정성.
- **대안 A**: Kakao Local Search — 식당 정보 직접 검색은 가능하지만 Tabling/Catchtable URL 매핑 부적합.
- **대안 B**: Google CSE — 비용/한도 제약.
- **트레이드오프**: Naver 응답 정확도가 압도적이지만 키 발급 운영자 작업 필요. 폴백으로 회귀 0 보장.
## 12. 미해결 질문
- Naver의 인덱스 신선도 vs Tabling/Catchtable 최신 입점 식당 미반영 가능성 — 백필 주기 후속.
- 결과 캐시(같은 식당 재호출) — 후속.

View File

@@ -0,0 +1,75 @@
# 설계서: RestaurantUpdateDTO + @Valid 표준화 (#358)
> **상태**: Approved
> **작성**: [AI] Architect · **최종수정**: 2026-06-15
> **추적성** — Redmine: #358 · 부모: #348(09-Done) · 관련: #332(화이트리스트 1차)
> · 구현 파일: `backend-java/src/main/java/com/tasteby/dto/RestaurantUpdateDTO.java`(신규), `backend-java/src/main/java/com/tasteby/controller/RestaurantController.java`
> · 테스트: 본 범위 밖 (수동 — 어드민 식당 편집 동작 확인)
## 1. 목적 (Why)
`#332`에서 Set 화이트리스트로 1차 가드 적용했지만, 타입 안전성·validation·API 명세는 여전히 `Map<String, Object>`로 흐릿함. 본격 DTO 표준화로 잘못된 입력 자동 거부 + 명세 명확화.
## 2. 범위 (Scope)
- **포함**
- `RestaurantUpdateDTO` record — 화이트리스트 14필드 모두 Optional(null 시 미변경).
- `@Valid` + Bean Validation 어노테이션 적용 (`@Size`, `@Pattern`, `@DecimalMin/Max`, `@Min`).
- Controller `PUT /api/restaurants/{id}` 시그니처: `Map → RestaurantUpdateDTO`.
- DTO → Map 변환(`toFieldMap()`) — Service 계층은 그대로 (재작업 0).
- 잘못된 입력 시 400 자동 응답 (Spring 기본 `MethodArgumentNotValidException`).
- **제외 (별도 후속)**
- `tabling-url` / `catchtable-url` PUT 엔드포인트 — 단일 필드라 현행 유지.
- PATCH 시멘틱 (부분 업데이트) — 현재 PUT이 부분 업데이트 의미로 사용 중.
## 3. 인수조건
- [ ] 모든 화이트리스트 필드 record에 등재 + null 가능.
- [ ] `name`: `@Size(min=1, max=200)`.
- [ ] `website`/`tabling_url`/`catchtable_url`: `@Pattern(http(s)://... | "NONE" | "")`.
- [ ] `latitude`: `@DecimalMin("-90.0") @DecimalMax("90.0")`.
- [ ] `longitude`: `@DecimalMin("-180.0") @DecimalMax("180.0")`.
- [ ] `rating`: `@DecimalMin("0.0") @DecimalMax("5.0")`.
- [ ] `rating_count`: `@Min(0)`.
- [ ] `price_range`: `@Min(1) @Max(5)`.
- [ ] 잘못된 입력 → HTTP 400 자동 응답.
- [ ] 기존 동작 회귀 없음 (geocode/cache flush 흐름 동일).
## 4. 컨텍스트 & 제약
- `spring-boot-starter-validation` 이미 의존성 등록됨.
- record + Bean Validation: 컴파일 시 어노테이션 인식 OK.
- Jackson SNAKE_CASE 매핑 유지: `cuisine_type`, `tabling_url` 등.
- `null`은 "변경 없음" 시그널 — `toFieldMap()`에서 제외.
## 5. 함수 명세
| 함수 | 책임 | 비고 |
|---|---|---|
| `RestaurantUpdateDTO` (record) | 입력 표면 | 14 필드, 모두 nullable |
| `RestaurantUpdateDTO.toFieldMap()` | null 제외 Map 변환 | Service `update` 시그니처 유지 |
| `RestaurantController.update(...)` | DTO 받음 + geocode 분기 | `@Valid @RequestBody RestaurantUpdateDTO` |
## 6. 흐름
1. 클라이언트 → `PUT /api/restaurants/{id}` JSON.
2. Spring 역직렬화 + Bean Validation. 실패 시 400 자동.
3. `dto.toFieldMap()` → null 제외.
4. 기존 geocode 분기 + `restaurantService.update(id, fieldMap)`.
## 7. 엣지케이스
- **모든 필드 null**: `toFieldMap()` 빈 Map → no-op (현행 유지).
- **`tabling_url = "NONE"` / 빈 문자열**: Pattern에 포함 → 통과.
- **숫자 범위 위반**: 400.
- **알 수 없는 필드 (예: `xxx`)**: Jackson 기본은 무시 (mapper 설정 유지) → 안전.
## 8. 리스크 & 대안
- **선택**: record + Bean Validation. 코드 최소.
- **대안 A**: class + setter. 보일러플레이트 다수.
- **대안 B**: 개별 PATCH endpoint per 필드. 표면 폭증.
## 9. 미해결 질문
- bulkUpdate (batch) 도입 시 별도 DTO — 후속.

View File

@@ -0,0 +1,47 @@
# 설계서: google_place_id 중복 조회 API (#359 1단계)
> **상태**: Approved
> **작성**: [AI] Architect · **최종수정**: 2026-06-15
> **추적성** — Redmine: #359 · 1단계(조회 전용, 위험 0). 2단계(자동 병합) / 3단계(UNIQUE)는 별도 PR.
> · 구현 파일: `backend-java/src/main/resources/mybatis/mapper/RestaurantMapper.xml`, `backend-java/src/main/java/com/tasteby/mapper/RestaurantMapper.java`, `backend-java/src/main/java/com/tasteby/service/RestaurantService.java`, `backend-java/src/main/java/com/tasteby/controller/AdminRestaurantController.java`
> · 테스트: 본 범위 밖 (수동 — admin token으로 호출).
## 1. 목적 (Why)
같은 `google_place_id`에 다중 식당이 매핑된 경우 운영자가 어떤 것을 유지/병합할지 결정 필요. 본 단계는 **조회만** — 그룹과 후보 식당을 메타데이터(연결된 영상/리뷰/메모 수)와 함께 보여줘 의사결정 자료 제공.
## 2. 범위
- 포함: `GET /api/admin/restaurants/duplicates/place-id` — 운영자만, 그룹별 식당 + 카운트 동봉.
- 제외 (별도 PR): 병합/삭제, UNIQUE constraint.
## 3. 인수조건
- [ ] requireAdmin 보호.
- [ ] 응답 구조: `[{ google_place_id, items: [{ id, name, address, created_at, video_count, review_count, memo_count, hidden }] }]`.
- [ ] 그룹은 `COUNT(*) > 1` 만 반환.
## 4. SQL
```sql
SELECT r.id, r.google_place_id, r.name, r.address,
TO_CHAR(r.created_at, 'YYYY-MM-DD"T"HH24:MI:SS') AS created_at,
r.hidden,
(SELECT COUNT(*) FROM video_restaurants vr WHERE vr.restaurant_id = r.id) AS video_count,
(SELECT COUNT(*) FROM reviews rv WHERE rv.restaurant_id = r.id) AS review_count,
(SELECT COUNT(*) FROM memos mm WHERE mm.restaurant_id = r.id) AS memo_count
FROM restaurants r
WHERE r.google_place_id IN (
SELECT google_place_id FROM restaurants
WHERE google_place_id IS NOT NULL
GROUP BY google_place_id HAVING COUNT(*) > 1
)
ORDER BY r.google_place_id, r.created_at
```
Service 계층에서 google_place_id로 그룹핑하여 응답 구조 변환.
## 5. 엣지케이스
- 중복 0건 → 빈 배열.
- 누군가가 google_place_id를 동시에 변경 중 → 다음 호출에서 반영 (캐시 X).

View File

@@ -0,0 +1,112 @@
# 설계서: 메인 지도 탭 SDK 국내/해외 분기 (#363)
> **상태**: Approved
> **작성**: [AI] Architect · **최종수정**: 2026-06-16
> **추적성** — Redmine: #363 · 부모: v0.1.51 1단계(외부 링크 분기) · 관련: MapView.tsx, mobile nearby
> · 구현 파일: `frontend/src/components/MapView.tsx`(dispatcher), `frontend/src/components/GoogleMapView.tsx`(rename from 기존 MapView 내용), `frontend/src/components/NaverMapView.tsx`(신규), `frontend/src/lib/map-utils.ts`(공용 헬퍼)
> · 테스트: 본 범위 밖 (수동 — dev 브라우저 검증)
## 1. 목적 (Why)
현재 MapView는 `@vis.gl/react-google-maps` 단일 사용. 한국 식당은 네이버 지도가 지번/도로명/상호/길찾기에서 압도적으로 정확. 메인 지도 탭 자체를 국내/해외 분기.
## 2. 범위
- 포함: MapView를 dispatcher로 전환, 좌표 기반 자동 분기(KR bbox), 네이버 키 미설정 시 GoogleMap fallback.
- 제외 (별도 후속): 사용자 강제 토글 UI, mixed 화면(한국+해외 동시) 최적화, 모바일 nearby도 동일 분기는 1차 적용 후 검토.
## 3. 인수조건
- [ ] `NEXT_PUBLIC_NAVER_MAP_CLIENT_ID` 환경변수 설정 + 화면 중심이 KR bbox 안이면 NaverMap 렌더.
- [ ] 키 미설정 또는 화면이 KR 밖이면 GoogleMap 렌더 (현행 동일).
- [ ] Supercluster + 클러스터/단일 마커 표시, 클릭 → onSelectRestaurant 콜백 동일.
- [ ] flyTo, onBoundsChanged, 내 위치, 채널 색상 동일하게 동작.
- [ ] 빌드/타입 회귀 없음.
## 4. 컨텍스트 & 제약
- 네이버 지도 v3: `https://oapi.map.naver.com/openapi/v3/maps.js?ncpClientId=<ID>` 스크립트 로드.
- 네이버 좌표계: 기본 WGS84 (`naver.maps.LatLng(lat, lng)`).
- 직접 wrapper 채택 (react-naver-maps 의존성 제거 — 메인터넌스 리스크).
- Supercluster는 SDK 독립이라 재사용.
- KR bbox: 위도 33~38.7, 경도 124~132. 화면 중심좌표가 안에 있으면 한국.
## 5. 아키텍처 개요
```
MapView (dispatcher)
├─ 화면 중심 좌표가 KR bbox AND 네이버 키 있음 → NaverMapView
│ ├─ <script src=naver maps v3> 동적 로드
│ ├─ useEffect: new naver.maps.Map(div, ...)
│ ├─ Supercluster로 cluster 계산 → markers div overlay
│ └─ flyTo: map.setCenter + setZoom
└─ 그 외 → GoogleMapView (기존 MapView 내용 그대로 이전)
```
## 6. 함수 명세
| 함수 | 책임 | 비고 |
|---|---|---|
| `MapView` (dispatcher) | 좌표 기반 분기 | flyTo 또는 첫 마운트 좌표로 판정 |
| `GoogleMapView` | 기존 MapView 내용 | rename만, 로직 변경 X |
| `NaverMapView` | 신규 — 네이버 지도 + Supercluster + markers | wrapper 직접 |
| `useNaverMaps(clientId)` | 스크립트 로드 + ready boolean | 한 번만 로드 |
| `isKoreaBounds(lat, lng)` | KR bbox 판정 | map-utils 공용 |
## 7. 흐름
1. MapView 마운트 → flyTo or 첫 식당 평균 좌표로 초기 중심 계산.
2. KR bbox + 키 있음 → NaverMapView 마운트.
3. NaverMapView: `useNaverMaps` 훅으로 v3 스크립트 로드, ready되면 `new naver.maps.Map(divRef, options)` 생성.
4. Supercluster로 cluster 계산 → 마커는 absolute positioned div overlay (네이버 OverlayView 또는 자체 좌표 변환).
5. 사용자 줌/팬 → bounds_changed 이벤트 → 클러스터 재계산 + onBoundsChanged 콜백.
## 8. 엣지케이스
- **네이버 스크립트 로드 실패**: ready=false 유지, dispatcher가 다음 렌더 사이클에서 GoogleMap fallback.
- **flyTo가 해외 좌표인데 현재 NaverMap 중**: dispatcher 재판정 → GoogleMap로 교체 (remount).
- **mixed 화면(한국+해외 식당)**: 화면 중심 기준 SDK 선택 → 다른 나라 식당은 화면 밖에 있어 무관.
- **키 미설정**: 항상 GoogleMap (회귀 0).
## 9. 리스크 & 대안
- **선택**: 직접 wrapper. 의존성 최소, 유지보수 자유.
- **대안 A**: `react-naver-maps` npm — 빠른 시작이지만 메인터넌스 상태 불확실.
- **대안 B**: 단일 SDK(Maplibre + 네이버 타일) — 타일 권리 이슈.
- **트레이드오프**: 직접 wrapper는 초기 코드 양 ↑이지만 한 번 만들면 안정.
## 10. 미해결 질문
- 한 화면 mixed(국가 경계 근처) 동시 마커 — 후속.
- 사용자 토글 UI — 후속.
- 모바일 nearby 동일 분기 — 1차 적용 후 결정.
## 11. 실제 구현 기록 (2026-06-16)
### 배포 흐름
| 버전 | 내용 |
|---|---|
| v0.1.51 | **1단계** — 식당 상세 외부 링크 좌표 기반 분기 (`RestaurantDetail.tsx`) |
| v0.1.52 | **2단계** — MapView dispatcher + NaverMapView/GoogleMapView 분리 + Dockerfile/deploy.sh build-arg |
| v0.1.53 | **fix**: 인증 파라미터 `ncpClientId``ncpKeyId` (NCLOUD 신 정책, 옛 NAVER Developers와 다름) |
| v0.1.5556 | 임시 fallback (운영 일시 GoogleMap, 디버그) |
| v0.1.57 | **안정화 + 재활성** — divRef 첫 렌더 누락 fix, ResizeObserver/rAF, try/catch |
### 운영 진단에서 확인된 사항
- NCLOUD Maps Application의 Web 서비스 URL은 **스킴 포함**(`https://...`).
- 옛 NAVER Developers와 NCLOUD는 다른 시스템 — Search Application과 Maps Application은 도메인 중복 충돌 없음.
- NCLOUD 콘솔의 신규 경로: `Services > Application Services > Maps > Application`.
### NaverMapView 안정화 핵심 결정사항
- **`divRef` 항상 마운트** (early return 제거) — `ready=false` 동안에도 div를 두고 로딩 메시지는 overlay로 표시.
- **명시적 `width:100%; height:100%`** + 회색 배경 — 컨테이너 영역이 시각적으로 확인 가능.
- **ResizeObserver + requestAnimationFrame**으로 컨테이너 0×0 → 정상 크기 변경 시 `m.refresh(true)`.
- **try/catch + `initError` state** — init 실패 시 화면 가시화.
### 후속 (별도 PR)
- 사용자 토글 (네이버/구글 강제 선택) UI.
- mixed 화면(국경 근처) 동시 마커.
- 모바일 nearby 탭 동일 분기 검토.
- 채널 색상/InfoWindow 등 GoogleMapView 수준의 디테일을 NaverMapView에 도입.

766
docs/oke-deploy-howto.md Normal file
View File

@@ -0,0 +1,766 @@
# OKE(Oracle Kubernetes Engine) 배포 가이드
> Spring Boot + Next.js 앱을 OKE에 배포하는 전체 과정을 정리한 문서.
> Colima(로컬 ARM64 Docker) → OCIR(이미지 레지스트리) → OKE(K8s 클러스터) 파이프라인 기준.
---
## 목차
1. [사전 준비](#1-사전-준비)
2. [인프라 아키텍처](#2-인프라-아키텍처)
3. [최초 클러스터 설정 (1회성)](#3-최초-클러스터-설정-1회성)
4. [K8s 매니페스트 구조](#4-k8s-매니페스트-구조)
5. [Dockerfile 작성](#5-dockerfile-작성)
6. [배포 스크립트 (deploy.sh)](#6-배포-스크립트-deploysh)
7. [일상적인 배포 절차](#7-일상적인-배포-절차)
8. [환경별 설정 관리](#8-환경별-설정-관리)
9. [도메인 및 SSL 설정](#9-도메인-및-ssl-설정)
10. [트러블슈팅](#10-트러블슈팅)
11. [유용한 kubectl 명령어](#11-유용한-kubectl-명령어)
---
## 1. 사전 준비
### 필요한 도구
| 도구 | 용도 | 설치 |
|------|------|------|
| **OCI CLI** | OKE 인증, kubeconfig 설정 | `brew install oci-cli` |
| **kubectl** | K8s 클러스터 관리 | `brew install kubectl` |
| **Colima** | 로컬 ARM64 Docker 빌드 (Docker Desktop 대체) | `brew install colima` |
| **Docker CLI** | 이미지 빌드/푸시 | `brew install docker` |
| **Helm** | Nginx Ingress, cert-manager 설치 | `brew install helm` |
### OCI 설정
```bash
# OCI CLI 프로파일 설정 (~/.oci/config)
[DEFAULT]
user=ocid1.user.oc1..xxxx
fingerprint=xx:xx:xx:...
tenancy=ocid1.tenancy.oc1..xxxx
region=ap-seoul-1
key_file=~/.oci/oci_api_key.pem
```
### kubeconfig 설정
```bash
# OKE 클러스터 접근 설정
oci ce cluster create-kubeconfig \
--cluster-id ocid1.cluster.oc1.<region>.<cluster-id> \
--file $HOME/.kube/config \
--region <region> \
--token-version 2.0.0 \
--kube-endpoint PUBLIC_ENDPOINT
# 연결 확인
kubectl get nodes
```
### OCIR(Oracle Container Image Registry) 로그인
```bash
# OCIR 로그인
# 사용자명 형식: <namespace>/oracleidentitycloudservice/<email>
# 비밀번호: OCI Console → User Settings → Auth Tokens에서 발급
docker login <region-code>.ocir.io
# 예) docker login icn.ocir.io
```
### Colima 시작 (ARM64 빌드용)
```bash
# OKE 노드가 ARM64인 경우 반드시 ARM64로 빌드해야 함
colima start --arch aarch64 --cpu 4 --memory 4
# ⚠️ Colima 시작 시 kubectl 컨텍스트가 'colima'로 바뀜
# OKE 컨텍스트로 복원 필수
kubectl config use-context <oke-context-name>
```
---
## 2. 인프라 아키텍처
```
┌─────────────────────────────────────────────────────┐
│ Internet (사용자) │
│ https://www.example.com │
└────────────────┬────────────────────────────────────┘
┌───────▼────────┐
│ DNS Provider │ ← A 레코드 → NLB Public IP
└───────┬────────┘
┌────────────▼──────────────┐
│ OCI Network Load Balancer │ ← Nginx Ingress가 자동 생성
└────────────┬──────────────┘
┌────────────▼──────────────────────────────────┐
│ OKE Cluster │
│ │
│ ┌─────────────────────────────┐ │
│ │ Nginx Ingress Controller │ │
│ │ + cert-manager (Let's Encrypt)│ │
│ └───┬─────────────┬───────────┘ │
│ │ │ │
│ /api/* /* │
│ │ │ │
│ ┌───▼──────┐ ┌──▼───────┐ ┌──────────┐ │
│ │ Backend │ │ Frontend │ │ Redis │ │
│ │ :8000 │ │ :3001 │ │ :6379 │ │
│ └──────────┘ └──────────┘ └──────────┘ │
└───────────────────────────────────────────────┘
┌────────────▼──────────────────────┐
│ External Services │
│ ├─ Oracle ADB (mTLS + Wallet) │
│ ├─ OCI GenAI │
│ └─ 기타 외부 API │
└────────────────────────────────────┘
```
### 리소스 할당 예시 (ARM64 × 2노드, 2CPU/8GB 각)
| 컴포넌트 | CPU request/limit | Memory request/limit |
|----------|-------------------|----------------------|
| Backend | 500m / 1 | 768Mi / 1536Mi |
| Frontend | 200m / 500m | 256Mi / 512Mi |
| Redis | 100m / 200m | 128Mi / 256Mi |
| **합계** | **~800m** | **~1.2GB** |
---
## 3. 최초 클러스터 설정 (1회성)
### 3.1 Nginx Ingress Controller 설치
```bash
helm repo add ingress-nginx https://kubernetes.github.io/ingress-nginx
helm repo update
helm install ingress-nginx ingress-nginx/ingress-nginx \
--namespace ingress-nginx \
--create-namespace \
--set controller.service.type=LoadBalancer \
--set controller.service.annotations."oci\.oraclecloud\.com/load-balancer-type"="nlb" \
--set controller.service.externalTrafficPolicy=Local
# NLB 외부 IP 확인 (DNS에 연결할 IP)
kubectl get svc -n ingress-nginx ingress-nginx-controller
```
> **OKE 네트워크 설정**: VCN Security List에서 NodePort 범위(30000~32767)를 NLB IP 대역에서 허용해야 함.
### 3.2 cert-manager 설치 (Let's Encrypt 자동 인증서)
```bash
helm repo add jetstack https://charts.jetstack.io
helm repo update
helm install cert-manager jetstack/cert-manager \
--namespace cert-manager \
--create-namespace \
--set crds.enabled=true
```
### 3.3 ClusterIssuer 생성
```yaml
# k8s/cert-manager/cluster-issuer.yaml
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt-prod
spec:
acme:
server: https://acme-v02.api.letsencrypt.org/directory
email: your-email@example.com
privateKeySecretRef:
name: letsencrypt-prod
solvers:
- http01:
ingress:
class: nginx
```
```bash
kubectl apply -f k8s/cert-manager/cluster-issuer.yaml
```
### 3.4 앱 네임스페이스 및 시크릿 생성
```bash
# 네임스페이스
kubectl apply -f k8s/namespace.yaml
# OCIR pull secret (이미지 다운로드용)
kubectl create secret docker-registry ocir-secret \
--namespace <app-namespace> \
--docker-server=<region>.ocir.io \
--docker-username='<namespace>/oracleidentitycloudservice/<email>' \
--docker-password='<auth-token>'
# Oracle Wallet (DB mTLS 인증)
kubectl create secret generic oracle-wallet \
--namespace <app-namespace> \
--from-file=cwallet.sso \
--from-file=ewallet.p12 \
--from-file=tnsnames.ora \
--from-file=sqlnet.ora \
--from-file=ojdbc.properties \
--from-file=keystore.jks \
--from-file=truststore.jks
# OCI 설정 (GenAI 등 OCI SDK 인증)
kubectl create secret generic oci-config \
--namespace <app-namespace> \
--from-file=config=~/.oci/config \
--from-file=oci_api_key.pem=~/.oci/oci_api_key.pem
# 앱 시크릿/설정
kubectl apply -f k8s/secrets.yaml
kubectl apply -f k8s/configmap.yaml
```
### 3.5 앱 배포 (최초)
```bash
kubectl apply -f k8s/redis-deployment.yaml
kubectl apply -f k8s/backend-deployment.yaml
kubectl apply -f k8s/frontend-deployment.yaml
kubectl apply -f k8s/ingress.yaml
# 롤아웃 확인
kubectl rollout status deployment/backend -n <app-namespace>
kubectl rollout status deployment/frontend -n <app-namespace>
```
---
## 4. K8s 매니페스트 구조
```
k8s/
├── namespace.yaml # 네임스페이스 정의
├── configmap.yaml # 비민감 설정 (Redis 호스트, API 엔드포인트 등)
├── secrets.yaml # 민감 정보 (DB 비밀번호, API 키 등) ← .gitignore
├── secrets.yaml.template # 시크릿 템플릿 (Git에 포함)
├── backend-deployment.yaml # Backend Deployment + Service
├── frontend-deployment.yaml # Frontend Deployment + Service
├── redis-deployment.yaml # Redis Deployment + Service
├── ingress.yaml # Ingress (라우팅 + TLS)
└── cert-manager/
└── cluster-issuer.yaml # Let's Encrypt ClusterIssuer
```
### Backend Deployment 예시
```yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: backend
namespace: <app-namespace>
spec:
replicas: 1
selector:
matchLabels:
app: backend
template:
metadata:
labels:
app: backend
spec:
imagePullSecrets:
- name: ocir-secret
containers:
- name: backend
image: <region>.ocir.io/<namespace>/<repo>/backend:latest
ports:
- containerPort: 8000
envFrom:
- configMapRef:
name: <app>-config
- secretRef:
name: <app>-secrets
volumeMounts:
- name: oracle-wallet
mountPath: /etc/oracle/wallet
readOnly: true
- name: oci-config
mountPath: /root/.oci
readOnly: true
resources:
requests:
cpu: "500m"
memory: "768Mi"
limits:
cpu: "1"
memory: "1536Mi"
readinessProbe:
tcpSocket:
port: 8000
initialDelaySeconds: 30
periodSeconds: 10
livenessProbe:
tcpSocket:
port: 8000
initialDelaySeconds: 60
periodSeconds: 30
volumes:
- name: oracle-wallet
secret:
secretName: oracle-wallet
- name: oci-config
secret:
secretName: oci-config
---
apiVersion: v1
kind: Service
metadata:
name: backend
namespace: <app-namespace>
spec:
selector:
app: backend
ports:
- port: 8000
targetPort: 8000
```
### Ingress 예시
```yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: <app>-ingress
namespace: <app-namespace>
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
nginx.ingress.kubernetes.io/ssl-redirect: "true"
nginx.ingress.kubernetes.io/proxy-read-timeout: "300"
nginx.ingress.kubernetes.io/proxy-send-timeout: "300"
nginx.ingress.kubernetes.io/proxy-body-size: "10m"
spec:
ingressClassName: nginx
tls:
- hosts:
- www.example.com
secretName: <app>-tls
rules:
- host: www.example.com
http:
paths:
- path: /api
pathType: Prefix
backend:
service:
name: backend
port:
number: 8000
- path: /
pathType: Prefix
backend:
service:
name: frontend
port:
number: 3001
```
---
## 5. Dockerfile 작성
### Backend (Spring Boot 멀티스테이지)
```dockerfile
# ── Build Stage ──
FROM eclipse-temurin:21-jdk AS build
WORKDIR /app
# 의존성 캐싱 (소스 변경 시에도 재사용)
COPY gradlew settings.gradle build.gradle ./
COPY gradle/ gradle/
RUN chmod +x gradlew && ./gradlew dependencies --no-daemon || true
# 소스 복사 & 빌드
COPY src/ src/
RUN ./gradlew bootJar -x test --no-daemon
# ── Runtime Stage ──
FROM eclipse-temurin:21-jre
WORKDIR /app
COPY --from=build /app/build/libs/*.jar app.jar
EXPOSE 8000
ENV JAVA_OPTS="-XX:MaxRAMPercentage=75.0 -XX:+UseG1GC"
ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"]
```
**포인트:**
- Gradle 의존성을 먼저 복사/다운로드 → Docker 레이어 캐시 활용
- `-XX:MaxRAMPercentage=75.0` → 컨테이너 메모리 제한의 75%를 JVM 힙으로 사용
- 최종 이미지에 JDK 대신 JRE만 포함 → 이미지 크기 절감
### Frontend (Next.js standalone 멀티스테이지)
```dockerfile
# ── Build Stage ──
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
# 빌드 시점 환경변수 주입 (NEXT_PUBLIC_* 변수)
ARG NEXT_PUBLIC_GOOGLE_MAPS_API_KEY
ARG NEXT_PUBLIC_GOOGLE_CLIENT_ID
RUN npm run build
# ── Runtime Stage ──
FROM node:22-alpine
WORKDIR /app
# standalone 출력 + 정적 파일 + public 복사
COPY --from=build /app/.next/standalone ./
COPY --from=build /app/.next/static ./.next/static
COPY --from=build /app/public ./public
EXPOSE 3001
ENV PORT=3001 HOSTNAME=0.0.0.0
CMD ["node", "server.js"]
```
**포인트:**
- `next.config.ts``output: "standalone"` 필수
- `NEXT_PUBLIC_*` 환경변수는 **빌드 시점**에만 주입 가능 (런타임 X)
- `.next/static``public/`은 standalone에 포함되지 않으므로 수동 복사
---
## 6. 배포 스크립트 (deploy.sh)
```bash
#!/bin/bash
set -euo pipefail
# ── Configuration ──
REGISTRY="<region>.ocir.io/<namespace>/<repo>"
APP_NAMESPACE="<app-namespace>"
PLATFORM="linux/arm64" # OKE 노드 아키텍처에 맞춤
# ── Parse arguments ──
TARGET="all" # all | backend | frontend
MESSAGE=""
while [[ $# -gt 0 ]]; do
case $1 in
--backend-only) TARGET="backend"; shift ;;
--frontend-only) TARGET="frontend"; shift ;;
--dry-run) echo "[DRY RUN]"; exit 0 ;;
-m) MESSAGE="$2"; shift 2 ;;
*) MESSAGE="$1"; shift ;;
esac
done
# ── Auto-increment version ──
LATEST_TAG=$(git tag --sort=-v:refname | grep '^v' | head -1 2>/dev/null || echo "v0.1.0")
MAJOR=$(echo "$LATEST_TAG" | cut -d. -f1)
MINOR=$(echo "$LATEST_TAG" | cut -d. -f2)
PATCH=$(echo "$LATEST_TAG" | cut -d. -f3)
TAG="${MAJOR}.${MINOR}.$((PATCH + 1))"
echo "━━━ Deploying ${TAG} (${TARGET}) ━━━"
cd "$(git rev-parse --show-toplevel)"
# ── Build & Push ──
if [[ "$TARGET" == "all" || "$TARGET" == "backend" ]]; then
echo "▶ Building backend..."
docker build --platform "$PLATFORM" \
-t "$REGISTRY/backend:$TAG" \
-t "$REGISTRY/backend:latest" \
backend-java/
docker push "$REGISTRY/backend:$TAG"
docker push "$REGISTRY/backend:latest"
fi
if [[ "$TARGET" == "all" || "$TARGET" == "frontend" ]]; then
echo "▶ Building frontend..."
# .env.local에서 빌드 인자 읽기
MAPS_KEY=$(grep NEXT_PUBLIC_GOOGLE_MAPS_API_KEY frontend/.env.local 2>/dev/null | cut -d= -f2)
CLIENT_ID=$(grep NEXT_PUBLIC_GOOGLE_CLIENT_ID frontend/.env.local 2>/dev/null | cut -d= -f2)
docker build --platform "$PLATFORM" \
--build-arg NEXT_PUBLIC_GOOGLE_MAPS_API_KEY="$MAPS_KEY" \
--build-arg NEXT_PUBLIC_GOOGLE_CLIENT_ID="$CLIENT_ID" \
-t "$REGISTRY/frontend:$TAG" \
-t "$REGISTRY/frontend:latest" \
frontend/
docker push "$REGISTRY/frontend:$TAG"
docker push "$REGISTRY/frontend:latest"
fi
# ── Rolling Update ──
# ⚠️ kubectl 컨텍스트가 OKE를 가리키는지 반드시 확인
if [[ "$TARGET" == "all" || "$TARGET" == "backend" ]]; then
kubectl set image deployment/backend \
backend="$REGISTRY/backend:$TAG" -n "$APP_NAMESPACE"
kubectl rollout status deployment/backend -n "$APP_NAMESPACE" --timeout=180s
fi
if [[ "$TARGET" == "all" || "$TARGET" == "frontend" ]]; then
kubectl set image deployment/frontend \
frontend="$REGISTRY/frontend:$TAG" -n "$APP_NAMESPACE"
kubectl rollout status deployment/frontend -n "$APP_NAMESPACE" --timeout=120s
fi
# ── Git Tag ──
git tag -a "$TAG" -m "Deploy ${TAG}: ${MESSAGE}"
git push origin "$TAG"
echo "━━━ ✅ Deploy complete: ${TAG} ━━━"
kubectl get pods -n "$APP_NAMESPACE"
```
---
## 7. 일상적인 배포 절차
```bash
# 1. Colima 시작 (ARM64 Docker 빌드용, 꺼져있을 때만)
colima start --arch aarch64 --cpu 4 --memory 4
# 2. kubectl 컨텍스트를 OKE로 복원 (Colima가 바꿔버림)
kubectl config use-context <oke-context-name>
# 3. 배포 실행
./deploy.sh "변경 내용 설명" # 전체 배포
./deploy.sh --backend-only "API 수정" # 백엔드만
./deploy.sh --frontend-only "UI 수정" # 프론트엔드만
# 4. 확인
kubectl get pods -n <app-namespace>
kubectl logs -f deployment/backend -n <app-namespace>
# 5. (선택) Colima 중지 (리소스 절약)
colima stop
```
### 시크릿/설정만 변경할 때
```bash
# secrets.yaml 수정 후
kubectl apply -f k8s/secrets.yaml
# 시크릿 변경은 Pod를 자동 재시작하지 않음 → 수동 재시작 필요
kubectl rollout restart deployment/backend -n <app-namespace>
```
### 롤백
```bash
# 이전 버전으로 롤백
kubectl rollout undo deployment/backend -n <app-namespace>
kubectl rollout undo deployment/frontend -n <app-namespace>
# 특정 리비전으로 롤백
kubectl rollout history deployment/backend -n <app-namespace>
kubectl rollout undo deployment/backend -n <app-namespace> --to-revision=3
```
---
## 8. 환경별 설정 관리
### 개발(Local) vs 운영(OKE) 비교
| 항목 | 개발 (Local) | 운영 (OKE) |
|------|-------------|-----------|
| DB 설정 | `backend/.env` | K8s Secret (`secrets.yaml`) |
| 환경변수 | 파일 기반 (`.env`, `.env.local`) | ConfigMap + Secret |
| Oracle Wallet | 로컬 디렉토리 | K8s Secret → Volume 마운트 |
| OCI 인증 | `~/.oci/config` | K8s Secret → Volume 마운트 |
| Redis | 로컬 IP (`192.168.x.x`) | K8s Service DNS (`redis`) |
| 프로세스 관리 | PM2 | K8s Deployment |
| 프론트엔드 모드 | `npm run dev` | `node server.js` (standalone) |
| DB 프로파일 | `_low` (리소스 절약) | `_medium` (적정 성능) |
### Oracle ADB 프로파일 선택
| 프로파일 | 병렬 처리 | 용도 |
|---------|----------|------|
| `_high` | 최대 | 대규모 배치/분석 |
| `_medium` | 중간 | **운영 권장** |
| `_low` | 최소 | **개발/테스트** (OCPU 절약) |
| `_tp` | 트랜잭션 | OLTP 워크로드 |
---
## 9. 도메인 및 SSL 설정
### DNS 설정
```
# DNS Provider (예: Namecheap)에서 설정
Type Host Value TTL
A @ <NLB Public IP> 300
A www <NLB Public IP> 300
```
NLB Public IP 확인:
```bash
kubectl get svc -n ingress-nginx ingress-nginx-controller \
-o jsonpath='{.status.loadBalancer.ingress[0].ip}'
```
### SSL 인증서
cert-manager + ClusterIssuer가 설정되어 있으면 Ingress에 annotation만 추가하면 자동 발급/갱신됨.
```yaml
# Ingress에 추가
metadata:
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
spec:
tls:
- hosts:
- www.example.com
secretName: <app>-tls # cert-manager가 자동 생성
```
인증서 상태 확인:
```bash
kubectl get certificate -n <app-namespace>
kubectl describe certificate <app>-tls -n <app-namespace>
```
---
## 10. 트러블슈팅
### 이미지 Pull 실패 (ImagePullBackOff)
```bash
kubectl describe pod <pod-name> -n <app-namespace>
# Events 섹션에서 에러 확인
# 원인 1: OCIR 인증 실패 → pull secret 재생성
kubectl delete secret ocir-secret -n <app-namespace>
kubectl create secret docker-registry ocir-secret \
--namespace <app-namespace> \
--docker-server=<region>.ocir.io \
--docker-username='<namespace>/oracleidentitycloudservice/<email>' \
--docker-password='<auth-token>'
# 원인 2: 이미지가 존재하지 않음 → OCIR 콘솔에서 확인
```
### OKE CRI-O: short image name 오류
```
# ❌ 틀림
image: redis:7-alpine
# ✅ 맞음 (docker.io/library/ 접두사 필수)
image: docker.io/library/redis:7-alpine
```
OKE는 CRI-O 런타임을 사용하며, Docker Hub의 `library/` 이미지도 전체 경로를 명시해야 함.
### DB 연결 실패 (HikariPool timeout)
```bash
# Pod 로그 확인
kubectl logs deployment/backend -n <app-namespace> | grep -i "hikari\|oracle\|connection"
# 원인 1: Wallet이 마운트되지 않음
kubectl exec deployment/backend -n <app-namespace> -- ls /etc/oracle/wallet/
# 원인 2: ORACLE_DSN에 TNS_ADMIN 경로가 잘못됨
# 형식: <tns-alias>_medium?TNS_ADMIN=/etc/oracle/wallet
```
### Let's Encrypt 인증서 발급 실패
```bash
# Challenge 상태 확인
kubectl get challenges -n <app-namespace>
kubectl describe challenge <name> -n <app-namespace>
# 원인: VCN Security List에서 80번 포트가 막혀있음
# OCI Console → VCN → Security Lists → Ingress Rules에 HTTP 80 허용 추가
```
### kubectl 인증 실패
```bash
# kubeconfig 재생성
oci ce cluster create-kubeconfig \
--cluster-id <cluster-ocid> \
--file $HOME/.kube/config \
--region <region> \
--token-version 2.0.0 \
--kube-endpoint PUBLIC_ENDPOINT
# OCI CLI 프로파일 지정 (여러 프로파일 사용 시)
# kubeconfig의 args에 --profile <PROFILE_NAME> 추가
```
### Colima 시작 후 kubectl 안 됨
```bash
# Colima가 kubectl 컨텍스트를 가져감
kubectl config get-contexts
kubectl config use-context <oke-context-name> # OKE로 복원
```
---
## 11. 유용한 kubectl 명령어
```bash
# ── 상태 확인 ──
kubectl get pods -n <ns> # Pod 목록
kubectl get pods -n <ns> -w # 실시간 감시
kubectl get svc -n <ns> # Service 목록
kubectl get ingress -n <ns> # Ingress 확인
kubectl top pods -n <ns> # 리소스 사용량
# ── 로그 ──
kubectl logs deployment/backend -n <ns> # 최근 로그
kubectl logs deployment/backend -n <ns> -f # 실시간 로그
kubectl logs deployment/backend -n <ns> --previous # 이전 Pod 로그 (크래시 시)
# ── 디버깅 ──
kubectl describe pod <pod> -n <ns> # Pod 상세 (이벤트 포함)
kubectl exec -it deployment/backend -n <ns> -- sh # Pod 접속
kubectl port-forward svc/backend 8000:8000 -n <ns> # 로컬 포트 포워딩
# ── 배포 관리 ──
kubectl rollout status deployment/backend -n <ns> # 롤아웃 상태
kubectl rollout restart deployment/backend -n <ns> # 재시작 (설정 변경 반영)
kubectl rollout undo deployment/backend -n <ns> # 이전 버전 롤백
kubectl rollout history deployment/backend -n <ns> # 배포 이력
# ── 설정 변경 ──
kubectl apply -f k8s/configmap.yaml # ConfigMap 업데이트
kubectl apply -f k8s/secrets.yaml # Secret 업데이트
kubectl edit deployment backend -n <ns> # 직접 수정 (비추천)
# ── 정리 ──
kubectl delete pod <pod> -n <ns> # Pod 강제 재시작
kubectl scale deployment/backend --replicas=0 -n <ns> # 일시 중지
kubectl scale deployment/backend --replicas=1 -n <ns> # 복원
```

View File

@@ -26,6 +26,7 @@ module.exports = {
cwd: "/Users/joungmin/workspaces/tasteby/frontend",
script: "npm",
args: "run dev",
env: { PORT: 3001 },
},
],
};

View File

@@ -6,6 +6,7 @@ RUN npm ci
COPY . .
ARG NEXT_PUBLIC_GOOGLE_MAPS_API_KEY
ARG NEXT_PUBLIC_GOOGLE_CLIENT_ID
ARG NEXT_PUBLIC_NAVER_MAP_CLIENT_ID
RUN npm run build
# ── Runtime stage ──

View File

@@ -0,0 +1,36 @@
/**
* #343 — Stars 컴포넌트 렌더 테스트.
*/
import { render, screen } from "@testing-library/react";
import Stars from "@/components/Stars";
describe("Stars", () => {
it("renders 5 star slots", () => {
const { container } = render(<Stars rating={3} />);
// 빈 별 5개 (text-gray-300 클래스 갖는 span)
const emptyStars = container.querySelectorAll("span.text-gray-300");
expect(emptyStars.length).toBe(5);
});
it("shows aria-label with rating", () => {
render(<Stars rating={4.5} />);
expect(screen.getByLabelText("4.5점")).toBeInTheDocument();
});
it("clamps rating to 0~5", () => {
render(<Stars rating={-1} />);
expect(screen.getByLabelText("0점")).toBeInTheDocument();
render(<Stars rating={10} />);
expect(screen.getByLabelText("5점")).toBeInTheDocument();
});
it("shows number when showNumber + rating > 0", () => {
const { container } = render(<Stars rating={3.5} showNumber />);
expect(container.textContent).toContain("3.5");
});
it("does not show number when rating is 0 even with showNumber", () => {
const { container } = render(<Stars rating={0} showNumber />);
expect(container.textContent).not.toContain("0");
});
});

View File

@@ -0,0 +1,28 @@
/**
* #343 — admin-utils 순수 함수 단위 테스트.
*/
import { getAdminToken, authHeaders } from "@/lib/admin-utils";
describe("admin-utils", () => {
beforeEach(() => {
localStorage.clear();
});
it("getAdminToken returns null when not set", () => {
expect(getAdminToken()).toBeNull();
});
it("getAdminToken returns stored token", () => {
localStorage.setItem("tasteby_token", "abc123");
expect(getAdminToken()).toBe("abc123");
});
it("authHeaders is empty when no token", () => {
expect(authHeaders()).toEqual({});
});
it("authHeaders includes Bearer when token set", () => {
localStorage.setItem("tasteby_token", "xyz");
expect(authHeaders()).toEqual({ Authorization: "Bearer xyz" });
});
});

View File

@@ -0,0 +1,42 @@
/**
* #343 — i18n/config 순수 함수 단위 테스트.
*/
import { isLocale, detectBrowserLocale, DEFAULT_LOCALE } from "@/i18n/config";
describe("i18n/config.isLocale", () => {
it("returns true for supported locales", () => {
expect(isLocale("ko")).toBe(true);
expect(isLocale("en")).toBe(true);
expect(isLocale("ja")).toBe(true);
expect(isLocale("es")).toBe(true);
});
it("returns false for unsupported / null / undefined", () => {
expect(isLocale("fr")).toBe(false);
expect(isLocale("zh")).toBe(false);
expect(isLocale(null)).toBe(false);
expect(isLocale(undefined)).toBe(false);
expect(isLocale("")).toBe(false);
});
});
describe("i18n/config.detectBrowserLocale", () => {
// jsdom의 navigator.language는 기본 'en-US'
it("returns supported locale from navigator.language", () => {
Object.defineProperty(navigator, "language", { value: "en-US", configurable: true });
expect(detectBrowserLocale()).toBe("en");
Object.defineProperty(navigator, "language", { value: "ko-KR", configurable: true });
expect(detectBrowserLocale()).toBe("ko");
Object.defineProperty(navigator, "language", { value: "ja", configurable: true });
expect(detectBrowserLocale()).toBe("ja");
Object.defineProperty(navigator, "language", { value: "es-MX", configurable: true });
expect(detectBrowserLocale()).toBe("es");
});
it("falls back to DEFAULT_LOCALE for unsupported", () => {
Object.defineProperty(navigator, "language", { value: "fr-FR", configurable: true });
expect(detectBrowserLocale()).toBe(DEFAULT_LOCALE);
Object.defineProperty(navigator, "language", { value: "zh-CN", configurable: true });
expect(detectBrowserLocale()).toBe(DEFAULT_LOCALE);
});
});

View File

@@ -0,0 +1,341 @@
# Tasteby Brand & Design Guide
> Tasteby의 브랜드 아이덴티티, 디자인 시스템, 아이콘 정책을 정의하는 가이드 문서.
> 최종 수정: 2026-03-14
---
## 1. 브랜드 개요
### 1.1 서비스 정의
**Tasteby**는 유튜브 맛집 영상에서 레스토랑 정보를 추출하여 지도 위에 큐레이션하는 서비스.
"유튜버의 입맛으로(Taste by)" 찾는 맛집이라는 의미를 담고 있다.
### 1.2 브랜드 키워드
- **미식 큐레이션** — 단순 검색이 아닌, 유튜버가 직접 방문한 맛집을 큐레이팅
- **신뢰** — 실제 영상 기반, 출처가 명확한 정보
- **따뜻함** — 음식과 사람을 연결하는 따뜻한 경험
- **프리미엄 캐주얼** — 고급스럽지만 접근하기 쉬운 톤
### 1.3 브랜드 보이스
| Do | Don't |
|----|-------|
| 친근하고 자연스러운 한국어 | 과도한 존댓말·마케팅 투 |
| 간결하고 핵심적인 정보 전달 | 불필요한 수식어 남발 |
| 음식에 대한 애정이 묻어나는 톤 | 딱딱한 기술 용어 노출 |
---
## 2. 로고
### 2.1 로고 구성
Tasteby 로고는 **워드마크(Wordmark)** 형태로, 글자 "e" 안에 **위치 핀 마커**와 **스마일 곡선**이 결합되어 있다.
- 핀 마커 = 지도 기반 서비스
- 스마일 = 맛있는 음식의 만족감
- 색상: 본문 다크 그레이(`#3C3C3C`) + 핀/스마일 레드(`#E8534A`)
### 2.2 로고 파일
| 파일 | 용도 |
|------|------|
| `logo.png` | 원본 (고해상도) |
| `logo-200h.png` | 히어로/소개 영역 |
| `logo-120h.png` | 헤더/네비게이션 |
| `logo-80h.png` | 파비콘/소형 UI |
| `logo-dark.png` | 다크 배경용 (현재 미사용) |
| `logo-dark-120h.png` | 다크 배경 헤더용 (현재 미사용) |
| `logo-dark-80h.png` | 다크 배경 소형 (현재 미사용) |
### 2.3 로고 사용 규칙
- 최소 여백: 로고 높이의 50% 이상
- 배경: 크림 화이트(`#FFFAF5`) 또는 순백(`#FFFFFF`) 위에 사용
- 변형 금지: 회전, 기울임, 색상 임의 변경 불가
- 현재 라이트 모드 전용 — 다크 모드 미지원
---
## 3. 컬러 시스템
### 3.1 디자인 테마: Saffron (사프란)
따뜻한 금빛 오렌지 톤. 고급스러운 미식 큐레이션 느낌을 전달한다.
### 3.2 브랜드 팔레트
CSS 변수로 `globals.css``@theme inline`에 등록되어 있다.
| 토큰 | Hex | 용도 |
|------|-----|------|
| `brand-50` | `#FFF8F0` | 선택 상태 배경, 카드 hover |
| `brand-100` | `#FFEDD5` | 연한 배경, 뱃지 배경 |
| `brand-200` | `#FFD6A5` | 보조 배경 |
| `brand-300` | `#FFBC72` | 호버 보더, 보조 강조 |
| `brand-400` | `#F5A623` | 스피너, 중간 강조 |
| `brand-500` | `#F59E3F` | Primary 라이트 |
| `brand-600` | `#E8720C` | **Primary** — 아이콘, 버튼, 핵심 강조색 |
| `brand-700` | `#C45A00` | Primary 다크 — 텍스트 강조 |
| `brand-800` | `#9A4500` | 진한 강조 |
| `brand-900` | `#6B3000` | 다크 상태 |
| `brand-950` | `#3D1A00` | 최진한 강조 |
### 3.3 시맨틱 토큰
| 토큰 | Hex | 용도 |
|------|-----|------|
| `background` | `#FFFAF5` | 페이지 배경 (크림 화이트) |
| `foreground` | `#171717` | 본문 텍스트 |
| `surface` | `#FFFFFF` | 카드/패널 배경 |
### 3.4 컬러 사용 원칙
- 인터랙티브/강조 요소에는 반드시 `brand-*` 토큰 사용 (`orange-*` 금지)
- Tailwind 기본 `gray` 팔레트는 변경하지 않음 (다크 모드 호환 문제)
- 라이트 모드 전용 (`color-scheme: only light`)
- 다크 모드 CSS는 코멘트 처리하여 보존 중 (향후 지원 가능성)
---
## 4. 타이포그래피
### 4.1 서체 스택
```
Pretendard Variable → Geist → system-ui → sans-serif
```
| 서체 | 역할 | 로딩 방식 |
|------|------|----------|
| **Pretendard Variable** | 1순위 본문 서체 | 로컬 woff2 (`src/fonts/`) |
| **Geist** | 폴백 서체 | Google Fonts (`next/font/google`) |
### 4.2 선택 이유
- **Pretendard**: 한국어 가독성 최적화, 다양한 웨이트 지원, 깔끔하고 현대적
- **Geist**: 영문/숫자 렌더링 우수, Next.js 기본 서체로 호환성 확보
### 4.3 사용 원칙
- 웨이트: Regular(400), Medium(500), SemiBold(600), Bold(700) 주로 사용
- 한글 최소 본문 크기: 14px (가독성 확보)
- 레이블/캡션: 12px까지 허용
- HTML lang: `ko` (한국어 기본)
---
## 5. 아이콘 시스템
Tasteby는 **3개의 아이콘 레이어**를 사용한다.
### 5.1 Material Symbols Rounded — UI 아이콘
**용도**: 범용 UI (검색, 재생, 네비게이션, 상태 표시 등)
| 항목 | 값 |
|------|-----|
| 폰트 | Material Symbols Rounded (Google Fonts) |
| 기본 설정 | FILL 0, wght 400, GRAD 0, opsz 24 |
| 컴포넌트 | `<Icon name="search" size={20} />` |
| 파일 | `src/components/Icon.tsx` |
**사용 위치**:
- RestaurantList 음식 카테고리 아이콘
- MapView 마커/InfoWindow 카테고리 아이콘
- SearchBar, 네비게이션, 재생 버튼 등 모든 UI 아이콘
- RestaurantDetail 상세 정보 아이콘
**주요 아이콘 매핑** (`getCuisineIcon()`):
- 한식 → `rice_bowl`, 일식 → `set_meal`, 중식 → `skillet`
- 양식 → `dinner_dining`, 아시아 → `restaurant`, 기타 → `flatware`
- 소분류별 세부 매핑 (67개 규칙) — `cuisine-icons.ts` 참조
**스타일 규칙**:
- 아이콘 크기와 `width`/`height`를 동일하게 설정하여 종횡비 고정
- `overflow: hidden`으로 비정형 아이콘(예: sake) 크기 넘침 방지
### 5.2 Phosphor Icons — 장르 카드 픽토그램
**용도**: 홈 탭 장르 필터 카드의 카테고리 아이콘
| 항목 | 값 |
|------|-----|
| 패키지 | `@phosphor-icons/react` (^2.1.10) |
| 라이선스 | MIT |
| 스타일 | Regular (outline) 기본 |
| 파일 | `src/lib/cuisine-icons.ts``getPhosphorCuisineIcon()` |
**Phosphor 아이콘 매핑**:
| 카테고리 | 아이콘 | Phosphor 컴포넌트명 |
|----------|--------|---------------------|
| 한식 | 밥그릇 | `BowlFood` |
| 일식 | 물고기 | `FishSimple` |
| 중식 | 불꽃 | `Fire` |
| 양식 | 피자 | `Pizza` |
| 아시아 | 김 나는 그릇 | `BowlSteam` |
| 기타 | 쿠키 | `Cookie` |
**소분류 Phosphor 매핑** (주요 항목):
| 소분류 | 아이콘 | 비고 |
|--------|--------|------|
| 국밥/해장국 | `BowlSteam` | |
| 소고기/한우구이 | `Cow` | |
| 곱창/막창 | `Flame` | |
| 닭/오리구이 | `Bird` | |
| 회/횟집 | `Fish` | |
| 해산물 | `Shrimp` | |
| 주점/포차 | `BeerStein` | |
| 이자카야 | `Martini` | 술잔 모양 |
| 파인다이닝/코스 | `Champagne` | |
| 스시/오마카세 | `Fish` | |
| 스테이크 | `Knife` | |
| 햄버거 | `Hamburger` | |
| 피자 | `Pizza` | |
| 프렌치 | `Champagne` | |
| 마라/훠궈 | `Pepper` | |
| 딤섬/만두 | `Egg` | |
| 비건/샐러드 | `Leaf` | |
| 카페/디저트 | `Coffee` | |
| 베이커리 | `Bread` | |
### 5.3 Custom SVG Food Icons — 커스텀 음식 픽토그램
**용도**: Phosphor에 적합한 아이콘이 없는 특수 음식 카테고리
| 항목 | 값 |
|------|-----|
| 컴포넌트 | `<FoodIcon name="noodle" size={22} />` |
| 파일 | `src/components/FoodIcon.tsx` |
| 접두어 | `food:` (예: `food:noodle`) |
| 렌더링 | SVG inline, `fill` 또는 `stroke` 모드 |
**등록된 커스텀 아이콘**:
| 이름 | 대상 소분류 | viewBox | 렌더링 모드 | 출처 |
|------|------------|---------|------------|------|
| `jjigae` | 찌개/전골/탕 | 0 0 24 24 | stroke | 자체 제작 (뚝배기+김) |
| `tteok` | 분식 | 0 0 24 24 | stroke | 자체 제작 (가래떡) |
| `noodle` | 면, 라멘, 소바/우동, 베트남 | 0 0 363.674 363.674 | fill | 외부 SVG (그릇+면+젓가락) |
| `tempura` | 텐동/튀김 | 0 0 64 64 | fill | Flaticon (텐푸라) |
| `pig` | 삼겹살/돼지구이, 족발/보쌈, 돈카츠 | 0 0 90 90 | fill | 외부 SVG (돼지 전신) |
### 5.4 아이콘 선택 기준
```
1단계: Phosphor Icons에 적합한 아이콘이 있는가?
→ 있으면 Phosphor 사용
2단계: 없으면 커스텀 SVG(FoodIcon)로 제작/수급
→ food: 접두어로 매핑
3단계: UI 범용 아이콘은 Material Symbols
→ 검색, 네비, 상태 표시 등
```
### 5.5 아이콘 제작/추가 가이드
**커스텀 SVG 추가 시:**
1. `FoodIcon.tsx``FOOD_ICONS` Record에 `IconDef` 추가
2. `viewBox`는 원본 SVG에 맞게 설정 (24×24가 아닐 수 있음)
3. `fill: true`면 fill 모드, 생략/false면 stroke 모드
4. `cuisine-icons.ts``PHOSPHOR_SUB_RULES`에서 `food:이름`으로 매핑
5. SVG 경로는 JSX로 변환 (`clip-rule``clipRule` 등)
**라이선스 확인:**
- Phosphor Icons: MIT ✅
- Material Symbols: Apache 2.0 ✅
- 외부 SVG: 출처별 라이선스 확인 필수 (Flaticon은 Attribution 필요할 수 있음)
---
## 6. 레이아웃 & 컴포넌트 스타일
### 6.1 홈 탭 구성
```
┌─────────────────────────────────────┐
│ [로고] [검색] [유튜버▼] │ ← 헤더
├─────────────────────────────────────┤
│ [한식][일식][중식][양식]... →scroll │ ← 장르 카드 (가로 스크롤+드래그)
│ [가격▼][지역▼][내위치][N개 결과] │ ← 필터 바 (flex-wrap)
├─────────────────────────────────────┤
│ 지도 / 리스트 / 근처 탭 │ ← 메인 콘텐츠
└─────────────────────────────────────┘
```
### 6.2 장르 카드 스타일
- 칩(chip) 형태, 가로 스크롤 + 마우스 드래그 지원 (`useDragScroll`)
- 아이콘(22px) + 텍스트 라벨
- 선택 시: `bg-brand-50 border-brand-300 text-brand-700`
- 비선택 시: `bg-white border-gray-200 text-gray-600`
- 아이콘 색상: 선택 시 `brand-600`, 비선택 시 `gray-400`
### 6.3 카드/서피스
- 배경: `surface` (#FFFFFF)
- 보더: `border-gray-200` (1px)
- 라운딩: `rounded-lg` (8px) 기본
- 그림자: 최소한 사용 (flat 디자인 지향)
### 6.4 버튼/인터랙티브
- Primary: `bg-brand-600 text-white hover:bg-brand-700`
- Secondary: `bg-brand-50 text-brand-700 border-brand-300`
- 포커스 링: `ring-brand-400`
---
## 7. 지도 마커 스타일
### 7.1 마커 디자인
텍스트 라벨 마커 (핀이 아닌 말풍선 형태):
- 배경: 흰색 (폐업 시 회색)
- 보더: 카테고리별 색상
- 텍스트: 레스토랑 이름 + 카테고리 아이콘 (Material Symbols)
- 하단 꼬리(arrow): 삼각형
- 선택 시: 파란색(`#1d4ed8`) 보더+꼬리
### 7.2 마커 카테고리 색상
| 카테고리 | 보더 | 배경 | 화살표 |
|----------|------|------|--------|
| 한식 | `#E8720C` | `#FFF8F0` | `#E8720C` |
| 일식 | `#D94F5A` | `#FFF5F5` | `#D94F5A` |
| 기타 | 기본 그레이 | 기본 화이트 | 기본 그레이 |
### 7.3 클러스터링
- 라이브러리: supercluster
- 클러스터 마커: 원형 + 개수 텍스트
---
## 8. 향후 방향
### 8.1 다크 모드
- 현재 라이트 모드 전용
- 다크 모드 CSS 토큰은 정의되어 있으나 비활성 상태
- 다크 배경 로고 에셋 준비 완료 (`logo-dark-*.png`)
- 활성화 시 Tailwind 기본 gray 팔레트 유지 (커스텀 gray 금지)
### 8.2 아이콘 정리
- `@tabler/icons-react` 의존성 제거 가능 (현재 미사용)
- 커스텀 SVG 아이콘 추가 확장 가능 (소고기/해산물 등)
- Flaticon 에셋 사용 시 Attribution 라이선스 확인 필요
### 8.3 디자인 토큰 확장
- 현재: 색상 + 폰트 토큰만 정의
- 향후: spacing, radius, shadow, motion 토큰 추가 가능
- Tailwind v4 `@theme` 기반으로 확장
---
## 부록: 파일 구조
```
frontend/
├── docs/
│ ├── brand-guide.md ← 이 문서
│ └── design-concepts.md ← 초기 컨셉 후보안
├── public/
│ ├── logo.png ← 로고 에셋들
│ ├── logo-{80h,120h,200h}.png
│ ├── logo-dark.png
│ └── logo-dark-{80h,120h}.png
├── src/
│ ├── app/
│ │ ├── globals.css ← 디자인 토큰 (@theme inline)
│ │ ├── layout.tsx ← 폰트, 메타 설정
│ │ └── page.tsx ← 홈 탭 (장르 카드 렌더링)
│ ├── components/
│ │ ├── Icon.tsx ← Material Symbols 래퍼
│ │ └── FoodIcon.tsx ← 커스텀 SVG 아이콘
│ ├── fonts/
│ │ └── PretendardVariable.woff2
│ └── lib/
│ └── cuisine-icons.ts ← 아이콘 매핑 (Material + Phosphor + Food)
```

21
frontend/jest.config.ts Normal file
View File

@@ -0,0 +1,21 @@
// #343 — Jest 설정. next/jest로 SWC 자동 통합.
import type { Config } from "jest";
import nextJest from "next/jest.js";
const createJestConfig = nextJest({
// 테스트 환경의 Next.js 앱 루트
dir: "./",
});
const customConfig: Config = {
// jest-dom matchers는 setupFilesAfterEnv로 등록 (Jest framework 로드 후)
setupFilesAfterEnv: ["<rootDir>/jest.setup.ts"],
testEnvironment: "jsdom",
moduleNameMapper: {
"^@/(.*)$": "<rootDir>/src/$1",
},
testPathIgnorePatterns: ["<rootDir>/.next/", "<rootDir>/node_modules/"],
};
export default createJestConfig(customConfig);

2
frontend/jest.setup.ts Normal file
View File

@@ -0,0 +1,2 @@
// #343 — Jest setup. @testing-library/jest-dom matchers 확장.
import "@testing-library/jest-dom";

View File

@@ -2,6 +2,14 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
// #343 — 외부 이미지 도메인 허용 (next/image)
images: {
remotePatterns: [
{ protocol: "https", hostname: "lh3.googleusercontent.com" }, // Google avatar
{ protocol: "https", hostname: "i.ytimg.com" }, // YouTube thumbnail
{ protocol: "https", hostname: "yt3.ggpht.com" }, // YouTube channel avatar
],
},
};
export default nextConfig;

File diff suppressed because it is too large Load Diff

View File

@@ -6,25 +6,35 @@
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
"lint": "eslint",
"test": "jest",
"test:watch": "jest --watch"
},
"dependencies": {
"@phosphor-icons/react": "^2.1.10",
"@react-oauth/google": "^0.13.4",
"@tabler/icons-react": "^3.40.0",
"@types/supercluster": "^7.1.3",
"@vis.gl/react-google-maps": "^1.7.1",
"next": "16.1.6",
"next-intl": "^4.13.0",
"react": "19.2.3",
"react-dom": "19.2.3",
"supercluster": "^8.0.1"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/jest": "^30.0.0",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.1.6",
"jest": "^30.4.2",
"jest-environment-jsdom": "^30.4.1",
"tailwindcss": "^4",
"typescript": "^5"
}

View File

@@ -1,5 +1,5 @@
"use client";
import { getAdminToken } from "@/lib/admin-utils";
import { getAdminToken, consumeSseStream } from "@/lib/admin-utils";
import { useCallback, useEffect, useState } from "react";
import { api } from "@/lib/api";
@@ -209,30 +209,19 @@ export function RestaurantsPanel({ isAdmin }: { isAdmin: boolean }) {
method: "POST",
headers: { Authorization: `Bearer ${getAdminToken()}` },
});
const reader = res.body!.getReader();
const decoder = new TextDecoder();
let buf = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
const lines = buf.split("\n");
buf = lines.pop() || "";
for (const line of lines) {
const m = line.match(/^data:(.+)$/);
if (!m) continue;
const evt = JSON.parse(m[1]);
// #351 — consumeSseStream으로 통일
await consumeSseStream(res, (raw) => {
const evt = raw as { type: string; [k: string]: unknown };
if (evt.type === "processing" || evt.type === "done" || evt.type === "notfound" || evt.type === "error") {
setBulkTablingProgress(p => ({
...p, current: evt.current, total: evt.total || p.total, name: evt.name,
...p, current: evt.current as number, total: (evt.total as number) || p.total, name: evt.name as string,
linked: evt.type === "done" ? p.linked + 1 : p.linked,
notFound: (evt.type === "notfound" || evt.type === "error") ? p.notFound + 1 : p.notFound,
}));
} else if (evt.type === "complete") {
alert(`완료! 연결: ${evt.linked}개, 미발견: ${evt.notFound}`);
}
}
}
});
} catch (e) { alert("벌크 테이블링 실패: " + (e instanceof Error ? e.message : String(e))); }
finally { setBulkTabling(false); load(); }
}}
@@ -287,30 +276,19 @@ export function RestaurantsPanel({ isAdmin }: { isAdmin: boolean }) {
method: "POST",
headers: { Authorization: `Bearer ${getAdminToken()}` },
});
const reader = res.body!.getReader();
const decoder = new TextDecoder();
let buf = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
const lines = buf.split("\n");
buf = lines.pop() || "";
for (const line of lines) {
const m = line.match(/^data:(.+)$/);
if (!m) continue;
const evt = JSON.parse(m[1]);
// #351 — consumeSseStream으로 통일
await consumeSseStream(res, (raw) => {
const evt = raw as { type: string; [k: string]: unknown };
if (evt.type === "processing" || evt.type === "done" || evt.type === "notfound" || evt.type === "error") {
setBulkCatchtableProgress(p => ({
...p, current: evt.current, total: evt.total || p.total, name: evt.name,
...p, current: evt.current as number, total: (evt.total as number) || p.total, name: evt.name as string,
linked: evt.type === "done" ? p.linked + 1 : p.linked,
notFound: (evt.type === "notfound" || evt.type === "error") ? p.notFound + 1 : p.notFound,
}));
} else if (evt.type === "complete") {
alert(`완료! 연결: ${evt.linked}개, 미발견: ${evt.notFound}`);
}
}
}
});
} catch (e) { alert("벌크 캐치테이블 실패: " + (e instanceof Error ? e.message : String(e))); }
finally { setBulkCatchtable(false); load(); }
}}

View File

@@ -1,5 +1,5 @@
"use client";
import { getAdminToken } from "@/lib/admin-utils";
import { getAdminToken, consumeSseStream } from "@/lib/admin-utils";
import { useCallback, useEffect, useState } from "react";
import { api } from "@/lib/api";
@@ -209,39 +209,25 @@ export function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
setBulkProgress(null);
return;
}
const reader = resp.body?.getReader();
const decoder = new TextDecoder();
if (!reader) { setRunning(false); return; }
let buf = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
const lines = buf.split("\n");
buf = lines.pop() || "";
for (const line of lines) {
if (!line.startsWith("data: ")) continue;
try {
const ev = JSON.parse(line.slice(6));
// #351 — consumeSseStream으로 통일
await consumeSseStream(resp, (raw) => {
const ev = raw as { type: string; [k: string]: unknown };
if (ev.type === "processing") {
setBulkProgress((p) => p ? { ...p, current: ev.index + 1, currentTitle: ev.title, waiting: undefined } : p);
setBulkProgress((p) => p ? { ...p, current: (ev.index as number) + 1, currentTitle: ev.title as string, waiting: undefined } : p);
} else if (ev.type === "wait") {
setBulkProgress((p) => p ? { ...p, waiting: ev.delay } : p);
setBulkProgress((p) => p ? { ...p, waiting: ev.delay as number } : p);
} else if (ev.type === "done") {
const detail = isTranscript
? `${ev.source} / ${ev.length?.toLocaleString()}`
? `${ev.source} / ${(ev.length as number)?.toLocaleString()}`
: `${ev.restaurants}개 식당`;
setBulkProgress((p) => p ? { ...p, results: [...p.results, { title: ev.title, detail }] } : p);
setBulkProgress((p) => p ? { ...p, results: [...p.results, { title: ev.title as string, detail }] } : p);
} else if (ev.type === "error") {
setBulkProgress((p) => p ? { ...p, results: [...p.results, { title: ev.title, detail: ev.message, error: true }] } : p);
setBulkProgress((p) => p ? { ...p, results: [...p.results, { title: ev.title as string, detail: ev.message as string, error: true }] } : p);
} else if (ev.type === "complete") {
setRunning(false);
load();
}
} catch { /* ignore */ }
}
}
});
setRunning(false);
load();
} catch {
@@ -264,30 +250,17 @@ export function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
setRebuildingVectors(false);
return;
}
const reader = resp.body?.getReader();
const decoder = new TextDecoder();
if (!reader) { setRebuildingVectors(false); return; }
let buf = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
const lines = buf.split("\n");
buf = lines.pop() || "";
for (const line of lines) {
if (!line.startsWith("data: ")) continue;
try {
const ev = JSON.parse(line.slice(6));
// #351 — consumeSseStream으로 통일
await consumeSseStream(resp, (raw) => {
const ev = raw as { status?: string; type?: string; [k: string]: unknown };
if (ev.status === "progress" || ev.type === "progress") {
setVectorProgress({ phase: ev.phase, current: ev.current, total: ev.total, name: ev.name });
setVectorProgress({ phase: ev.phase as string, current: ev.current as number, total: ev.total as number, name: ev.name as string });
} else if (ev.status === "done" || ev.type === "done") {
setVectorProgress({ phase: "done", current: ev.total, total: ev.total });
setVectorProgress({ phase: "done", current: ev.total as number, total: ev.total as number });
} else if (ev.type === "error") {
alert(`벡터 재생성 오류: ${ev.message}`);
}
} catch { /* ignore */ }
}
}
});
setRebuildingVectors(false);
} catch {
setRebuildingVectors(false);
@@ -309,30 +282,17 @@ export function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
setRemappingCuisine(false);
return;
}
const reader = resp.body?.getReader();
const decoder = new TextDecoder();
if (!reader) { setRemappingCuisine(false); return; }
let buf = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
const lines = buf.split("\n");
buf = lines.pop() || "";
for (const line of lines) {
if (!line.startsWith("data: ")) continue;
try {
const ev = JSON.parse(line.slice(6));
// #351 — consumeSseStream으로 통일
await consumeSseStream(resp, (raw) => {
const ev = raw as { type: string; [k: string]: unknown };
if (ev.type === "processing" || ev.type === "batch_done") {
setRemapProgress({ current: ev.current, total: ev.total, updated: ev.updated || 0 });
setRemapProgress({ current: ev.current as number, total: ev.total as number, updated: (ev.updated as number) || 0 });
} else if (ev.type === "complete") {
setRemapProgress({ current: ev.total, total: ev.total, updated: ev.updated });
setRemapProgress({ current: ev.total as number, total: ev.total as number, updated: ev.updated as number });
} else if (ev.type === "error") {
alert(`재분류 오류: ${ev.message}`);
}
} catch { /* ignore */ }
}
}
});
setRemappingCuisine(false);
} catch {
setRemappingCuisine(false);
@@ -354,30 +314,17 @@ export function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
setRemappingFoods(false);
return;
}
const reader = resp.body?.getReader();
const decoder = new TextDecoder();
if (!reader) { setRemappingFoods(false); return; }
let buf = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
const lines = buf.split("\n");
buf = lines.pop() || "";
for (const line of lines) {
if (!line.startsWith("data: ")) continue;
try {
const ev = JSON.parse(line.slice(6));
// #351 — consumeSseStream으로 통일
await consumeSseStream(resp, (raw) => {
const ev = raw as { type: string; [k: string]: unknown };
if (ev.type === "processing" || ev.type === "batch_done") {
setFoodsProgress({ current: ev.current, total: ev.total, updated: ev.updated || 0 });
setFoodsProgress({ current: ev.current as number, total: ev.total as number, updated: (ev.updated as number) || 0 });
} else if (ev.type === "complete") {
setFoodsProgress({ current: ev.total, total: ev.total, updated: ev.updated });
setFoodsProgress({ current: ev.total as number, total: ev.total as number, updated: ev.updated as number });
} else if (ev.type === "error") {
alert(`메뉴 태그 재생성 오류: ${ev.message}`);
}
} catch { /* ignore */ }
}
}
});
setRemappingFoods(false);
} catch {
setRemappingFoods(false);

View File

@@ -3,6 +3,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { GoogleLogin } from "@react-oauth/google";
import LoginMenu from "@/components/LoginMenu";
import LanguageSwitcher from "@/components/LanguageSwitcher";
import { api } from "@/lib/api";
import type { Restaurant, Channel, Review, Memo } from "@/lib/api";
import { useAuth } from "@/lib/auth-context";
@@ -384,6 +385,10 @@ export default function Home() {
const handleSelectRestaurant = useCallback((r: Restaurant) => {
setSelected(r);
setShowDetail(true);
// 지도가 선택 식당으로 이동/줌인 — 객체 새로 만들어 flyTo effect 매번 트리거
if (r.latitude != null && r.longitude != null) {
setRegionFlyTo({ lat: r.latitude, lng: r.longitude, zoom: 16 });
}
}, []);
const handleCloseDetail = useCallback(() => {
@@ -719,6 +724,8 @@ export default function Home() {
</>
)}
<div className="flex-1" />
{/* #352 — Language switcher */}
<LanguageSwitcher />
{/* Desktop user area */}
{authLoading ? null : user ? (
<div className="flex items-center gap-2 shrink-0">

View File

@@ -2,6 +2,7 @@
import { GoogleOAuthProvider } from "@react-oauth/google";
import { AuthProvider } from "@/lib/auth-context";
import { LocaleProvider } from "@/i18n/LocaleProvider";
import type { ReactNode } from "react";
const GOOGLE_CLIENT_ID = process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID || "";
@@ -9,7 +10,9 @@ const GOOGLE_CLIENT_ID = process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID || "";
export function Providers({ children }: { children: ReactNode }) {
return (
<GoogleOAuthProvider clientId={GOOGLE_CLIENT_ID}>
<LocaleProvider>
<AuthProvider>{children}</AuthProvider>
</LocaleProvider>
</GoogleOAuthProvider>
);
}

View File

@@ -0,0 +1,93 @@
"use client";
interface FoodIconProps {
name: string;
size?: number;
className?: string;
}
interface IconDef {
viewBox: string;
fill?: boolean; // true = fill icon (no stroke), false = stroke icon (default)
paths: React.ReactNode;
}
const FOOD_ICONS: Record<string, IconDef> = {
// 찌개/전골/탕: 뚝배기 + 김
"jjigae": {
viewBox: "0 0 24 24",
paths: (
<>
<path d="M5 11h14v1c0 4-2.5 7-7 7s-7-3-7-7v-1z" strokeLinecap="round" strokeLinejoin="round" />
<path d="M4 11h16" strokeLinecap="round" />
<path d="M3 11.5h1M20 11.5h1" strokeLinecap="round" strokeWidth="1.5" />
<path d="M9 8c0-1.5 1-2 0-3M12 7c0-1.5 1-2 0-3M15 8c0-1.5 1-2 0-3" strokeLinecap="round" fill="none" />
</>
),
},
// 분식: 가래떡 1개 사선
"tteok": {
viewBox: "0 0 24 24",
paths: (
<rect x="2" y="10" width="20" height="4" rx="2" transform="rotate(-30 12 12)" strokeLinecap="round" strokeLinejoin="round" />
),
},
// 면: 그릇에 면+젓가락 (noodles-in-a-bowl)
"noodle": {
viewBox: "0 0 363.674 363.674",
fill: true,
paths: (
<>
<path d="M79.496,292.615c-16.686-10.313-28.229-27.017-33.383-48.308c-0.975-4.025-5.027-6.502-9.054-5.524c-4.026,0.975-6.499,5.027-5.524,9.054c6.102,25.21,19.959,45.105,40.074,57.538c1.229,0.759,2.59,1.121,3.936,1.121c2.514,0,4.969-1.263,6.387-3.558C84.109,299.415,83.02,294.793,79.496,292.615z" />
<path d="M363.465,70.469c-0.967-4.027-5.016-6.5-9.045-5.542l-179.247,43.032V85.908l163.056-69.827c3.807-1.631,5.572-6.039,3.941-9.847c-1.631-3.809-6.039-5.574-9.848-3.942L174.755,69.768c-1.017-2.937-3.8-5.049-7.082-5.049c-4.142,0-7.5,3.357-7.5,7.5v3.794l-4.55,1.948c-0.631-3.505-3.689-6.166-7.375-6.166c-4.143,0-7.5,3.357-7.5,7.5v5.036l-4.543,1.945c-0.611-3.527-3.68-6.213-7.382-6.213c-4.142,0-7.5,3.357-7.5,7.5v5.086l-4.497,1.926c-0.482-3.677-3.621-6.517-7.428-6.517c-4.143,0-7.5,3.357-7.5,7.5v5.409l-4.631,1.983c-0.779-3.313-3.745-5.78-7.295-5.78c-4.142,0-7.5,3.357-7.5,7.5v4.615l-20.778,8.898c-3.808,1.631-5.572,6.039-3.942,9.847c1.219,2.846,3.988,4.55,6.898,4.55c0.984,0,1.986-0.195,2.949-0.607l14.873-6.369v4.608l-14.318,3.438c-4.027,0.967-6.509,5.016-5.542,9.044c0.825,3.439,3.899,5.751,7.286,5.751c0.58,0,1.17-0.068,1.758-0.209l10.816-2.597v64.284H7.5c-4.142,0-7.5,3.357-7.5,7.5c0,28.225,6.892,54.242,19.93,75.239c12.92,20.807,31.711,36.318,54.55,45.096v16.73c0,4.143,3.358,7.5,7.5,7.5h97.493c4.143,0,7.5-3.357,7.5-7.5v-16.73c22.838-8.777,41.631-24.29,54.549-45.096c13.039-20.997,19.932-47.015,19.932-75.239c0-4.143-3.358-7.5-7.5-7.5H116.898v-72.549l4.719-1.133c0.898,3.138,3.781,5.436,7.206,5.436c4.142,0,7.5-3.357,7.5-7.5v-1.466l4.471-1.074c0.405,3.76,3.587,6.688,7.454,6.688c4.143,0,7.5-3.357,7.5-7.5v-2.777l4.425-1.063v14.33c0,4.143,3.358,7.5,7.5,7.5s7.5-3.357,7.5-7.5v-17.931l182.749-43.873C361.949,78.546,364.432,74.497,363.465,70.469z M97.473,119.181l4.426-1.895v8.264l-4.426,1.063V119.181z M89.479,346.99v-7.19h82.493v7.19H89.479z M178.76,324.8H82.693c-40.367-14.153-65.217-51.11-67.519-99.875h231.103C243.975,273.689,219.127,310.646,178.76,324.8z M101.898,209.925h-4.426v-67.886l4.426-1.063V209.925z M116.898,110.863l4.425-1.895v11.918l-4.425,1.063V110.863z M136.323,102.545l4.425-1.896v15.574l-4.425,1.063V102.545z M155.748,112.623V94.226l4.425-1.896v19.229L155.748,112.623z" />
</>
),
},
// 텐동/튀김: 텐푸라 (flaticon tempura)
"tempura": {
viewBox: "0 0 64 64",
fill: true,
paths: (
<>
<path d="M59.96092,38.28982c-.58769-2.348-4.12861-1.72934-6.10276-1.50418a48.12688,48.12688,0,0,0,2.50872-4.73925A1.91121,1.91121,0,0,0,54.08314,29.416c-3.94455,1.2-6.79557,4.77253-8.49153,7.55764a8.05234,8.05234,0,0,1-1.77036,2.03025c-.05638-.04614-.115-.097-.16482-.13169a5.09683,5.09683,0,0,0-5.11816-.48233,4.7649,4.7649,0,0,0-5.21482-.55654,5.28288,5.28288,0,0,0-3.90841-.05207,4.9359,4.9359,0,0,0-4.18868-1.25476A4.63263,4.63263,0,0,0,21.0592,34.644a4.73344,4.73344,0,0,0-8.3529-1.07386c-2.98316-.34766-6.15924,2.35525-5.34459,5.723A5.18758,5.18758,0,0,0,5.5691,43.06339a4.84285,4.84285,0,0,0,3.72,4.64886,4.72272,4.72272,0,0,0,5.13667,4.69644A4.74936,4.74936,0,0,0,20.736,54.771a4.73286,4.73286,0,0,0,6.6833,1.47475.99956.99956,0,1,0-1.1287-1.65,2.71212,2.71212,0,0,1-3.84593-.86364,2.05527,2.05527,0,0,0-2.603-.75617,2.5586,2.5586,0,0,1-1.09452.24556,2.73778,2.73778,0,0,1-2.67039-2.17292,1.00314,1.00314,0,0,0-.46182-.65271c-.46123-.33887-1.17682.06863-1.60712.03614a2.72229,2.72229,0,0,1-2.7192-2.71971c-.00244-.45741.32815-1.1176-.06741-1.54852a1.00725,1.00725,0,0,0-.93241-.38128,2.72268,2.72268,0,0,1-1.6968-4.8248,2.11365,2.11365,0,0,0,.72141-2.09722c-.55724-1.9936,1.76582-3.57014,2.96729-3.2786a2.07918,2.07918,0,0,0,2.08545-.89638,2.709,2.709,0,0,1,4.94632,1.24537,1.00583,1.00583,0,0,0,1.30933.8426,2.64386,2.64386,0,0,1,3.00723.9549,2.065,2.065,0,0,0,2.04257.78843,2.793,2.793,0,0,1,2.357.70543,2.12238,2.12238,0,0,0,2.32378.41545,2.67127,2.67127,0,0,1,2.48878.1982.99706.99706,0,0,0,1.10819-.0415,2.72809,2.72809,0,0,1,3.29624.12156,2.05987,2.05987,0,0,0,2.27495.27241,2.70644,2.70644,0,0,1,3.72479,3.33392c-.38932.81316.21723,1.64631.15282,2.47613A2.71632,2.71632,0,0,1,40.1902,48.528a2.00549,2.00549,0,0,0-2.17527,1.16787,2.71478,2.71478,0,0,1-2.49171,1.64568,2.592,2.592,0,0,1-.949-.17282,2.03329,2.03329,0,0,0-2.42337.79575,2.73019,2.73019,0,0,1-1.58075,1.17262.99911.99911,0,0,0-.69908,1.22877c.77166,1.84716,3.33875-.273,3.98066-1.33265A4.69753,4.69753,0,0,0,39.828,50.49447,4.90789,4.90789,0,0,0,44.20519,49.091c1.07331-1.28142.69028-2.47595,2.314-3.18146a7.17839,7.17839,0,0,1,2.02786-.83393,32.47063,32.47063,0,0,0,10.64445-4.85112A1.88349,1.88349,0,0,0,59.96092,38.28982ZM48.05305,43.138a8.88032,8.88032,0,0,0-2.879,1.26343c.07057-1.027.56233-2.1911-.21569-3.75184,3.30834-2.7934,4.28439-7.41256,9.50762-9.25727A41.37828,41.37828,0,0,1,50.91767,37.626c-1.32533.664-3.54,1.03493-2.8774,2.52866a.99875.99875,0,0,0,1.36213.37843c2.1403-1.32937,6.2594-2.42108,8.52181-1.86132A30.48308,30.48308,0,0,1,48.05305,43.138Z" />
<path d="M20.71747,49.98436A3.136,3.136,0,0,0,24.414,47.5605a1.00042,1.00042,0,0,0-1.95861-.40708,1.12553,1.12553,0,0,1-2.20366-.45852,1.00043,1.00043,0,0,0-1.95863-.407A3.12939,3.12939,0,0,0,20.71747,49.98436Z" />
<path d="M33.96005,46.60805a1.13194,1.13194,0,0,1-1.5456-.38031,1.00012,1.00012,0,0,0-1.71058,1.036,3.126,3.126,0,0,0,5.34752-3.23828,1.0001,1.0001,0,0,0-1.71052,1.03606A1.13019,1.13019,0,0,1,33.96005,46.60805Z" />
<path d="M14.89436,39.24667A1.131,1.131,0,0,1,16.44,39.627a1.00011,1.00011,0,0,0,1.71058-1.036A3.12612,3.12612,0,0,0,12.803,41.82975a1.00009,1.00009,0,0,0,1.71052-1.03606A1.13084,1.13084,0,0,1,14.89436,39.24667Z" />
<path d="M6.12651,33.96448a2.694,2.694,0,0,1,.36907-2.33061c1.05665-1.50674-.70244-2.64347-.19038-4.31314C7.64257,24.515,9.289,26.224,10.16584,23.95766a2.71747,2.71747,0,0,1,5.09174-.25918,1.00613,1.00613,0,0,0,1.51436.41191,2.23774,2.23774,0,0,1,.74692-.37979,2.64262,2.64262,0,0,1,2.39017.41545,2.05437,2.05437,0,0,0,2.192.15622,2.71372,2.71372,0,0,1,2.47706-.00635,2.09934,2.09934,0,0,0,2.31206-.28413,2.64664,2.64664,0,0,1,2.44094-.54725,1.00237,1.00237,0,0,0,1.04765-.35882,2.68307,2.68307,0,0,1,3.20154-.85528,2.04549,2.04549,0,0,0,2.23589-.40278c.466-.57119,2.41006-1.24944,3.47261-.19757a2.77124,2.77124,0,0,1,1.07535,2.30556,2.12056,2.12056,0,0,0,.42765,1.36351,2.71658,2.71658,0,0,1-1.84936,4.30037,2.00726,2.00726,0,0,0-1.774,1.76536,2.64106,2.64106,0,0,1-1.3718,2.06064,1.00024,1.00024,0,0,0,.953,1.75842,4.56245,4.56245,0,0,0,2.38621-3.594,4.8526,4.8526,0,0,0,3.78058-2.63716,5.43994,5.43994,0,0,0,.35986-2.5729,8.82313,8.82313,0,0,1,2.61707-2.53306A32.49913,32.49913,0,0,0,54.64846,16.108a1.88292,1.88292,0,0,0,.167-2.07578c-1.24835-2.07129-4.45549-.44274-6.275.35141a48.01336,48.01336,0,0,0,1.00846-5.265,1.91029,1.91029,0,0,0-2.95353-1.84583c-3.42122,2.30473-5.09863,6.55538-5.90317,9.71591-.21291.58392-.67229,2.46176-1.29549,2.3801a5.14553,5.14553,0,0,0-5.03435,1.04112,4.69673,4.69673,0,0,0-5.14842.99789,5.16687,5.16687,0,0,0-3.75118,1.09557,4.88152,4.88152,0,0,0-4.37323.02872,4.60545,4.60545,0,0,0-4.14375-.71718,3.74334,3.74334,0,0,0-.39739.1362A4.70187,4.70187,0,0,0,8.256,23.365a5.23681,5.23681,0,0,0-3.69162,2.97413,4.56732,4.56732,0,0,0,.26544,4.08018,5.13094,5.13094,0,0,0-.62272,4.1052A1.01478,1.01478,0,1,0,6.12651,33.96448Zm41.413-24.91369a41.36518,41.36518,0,0,1-1.56268,6.99793,18.57825,18.57825,0,0,0-2.04893,1.8495.99993.99993,0,0,0,1.45289,1.37371c1.65257-1.89613,5.27564-4.14923,7.60011-4.27732a30.49647,30.49647,0,0,1-8.12739,7.16451A8.87242,8.87242,0,0,0,42.465,24.21738a5.82025,5.82025,0,0,0-1.30541-3.52356C43.51355,17.05538,43.06651,12.37112,47.53947,9.05079Z" />
<path d="M31.62065,29.98282a1.14811,1.14811,0,0,1-.81332-.28461,1.00012,1.00012,0,0,0-1.33173,1.49194,3.10428,3.10428,0,0,0,2.07768.79424,3.14507,3.14507,0,0,0,2.08541-5.458,1.00008,1.00008,0,0,0-1.3316,1.492A1.13309,1.13309,0,0,1,31.62065,29.98282Z" />
<path d="M12.78637,27.78646a1.13979,1.13979,0,0,1,.81332.28462,1.00013,1.00013,0,0,0,1.33173-1.49195,3.12577,3.12577,0,0,0-4.16309,4.66374,1.00009,1.00009,0,0,0,1.3316-1.492A1.13318,1.13318,0,0,1,12.78637,27.78646Z" />
</>
),
},
// 돼지 (삼겹살/돼지구이, 족발/보쌈, 돈카츠)
"pig": {
viewBox: "0 0 90 90",
fill: true,
paths: (
<>
<path d="m66.005 57.841c-.76 3.428-2.077 6.355-3.24 8.521h-2.301c.645-2.74 1.151-5.421 1.312-7.973 1.428-.158 2.844-.334 4.229-.548zm-27.672-.004c.745.129 1.505.239 2.265.348.147 2.609.667 5.365 1.324 8.177h-2.344c-.729-1.359-1.505-2.968-2.172-4.901.355-1.156.672-2.37.927-3.624zm27.09-33.334c8.703-.376 14.869 5.025 17.547 10.541 2.765 5.713.796 11.839-3.765 16.443-.209.208-.328.489-.339.781-.224 6.339-2.589 12.077-4.584 15.739h-3.864c.765-3.176 1.396-6.301 1.572-9.244.032-.547-.323-1.041-.853-1.188-3.975-1.072-5.729-3.661-6.443-6.213-.407-1.511-2.667-.88-2.24.62.349 1.245 1.161 2.469 2.041 3.645-9.041 1.261-17.015 1.464-25.776-.088.219-1.615.328-3.287.276-4.989.011-1.604-2.421-1.532-2.317.068.199 6.905-2.432 13.395-4.609 17.389h-3.844c1.532-6.337 2.12-12.307 1.089-17.645-.1-.557-.589-.964-1.152-.959-.739 0-1.285.677-1.135 1.396.183.937.296 1.912.38 2.901-4.984-.828-8.287-2.407-11.203-4.027-3.157-1.755-5.933-3.624-9.776-4.385-.048-.009-.183-.067-.355-.395-.172-.329-.312-.855-.344-1.396-.025-.543.057-1.095.199-1.469.145-.369.307-.505.411-.541 4.541-1.765 7.147-3.251 11.161-7.683.396-.443.401-1.109.005-1.552-2.077-2.349-2.219-4.749-1.869-7.177l8.244 6.371c1.224.948 2.647-.891 1.423-1.839l-1-.771c1.296-1.172 2.421-2.256 6.073-3.172 4.364-1.089 8.124-.729 13.389-.376 5.272.349 9.917.663 19.887-.629.599-.079 1.192-.131 1.771-.156zm14.598-4.849c-1.547.073-1.437 2.396.115 2.323 1.817 0 2.536 1.063 2.599 2.344.057 1.244-.625 2.64-2.583 3.24-4.048-3.667-9.797-6.109-16.803-5.199-9.787 1.265-14.197.953-19.427.604-5.235-.348-9.324-.749-14.104.448-4.167 1.041-5.891 2.489-7.443 3.932l-6.964-5.385c-.219-.167-.484-.255-.756-.244-.509.02-.943.375-1.077.864-.823 3.109-.667 6.781 1.683 10.136-3.491 3.739-5.568 4.963-9.772 6.599-.891.344-1.432 1.115-1.728 1.88-.297.771-.396 1.599-.349 2.423.041.823.224 1.629.604 2.353.374.718 1.026 1.416 1.964 1.598 3.339.661 5.833 2.319 9.093 4.131 3.099 1.724 6.875 3.505 12.443 4.364.041 3.948-.584 8.235-1.797 12.803-.197.74.36 1.464 1.125 1.459h5.989c.423 0 .808-.224 1.016-.589.765-1.359 1.588-3.052 2.36-4.952.536 1.244 1.088 2.369 1.609 3.301.203.369.593.599 1.009.599h4.448c.767.005 1.323-.719 1.131-1.457-.808-3.079-1.303-5.996-1.464-8.745 6.043.672 10.36.672 16.495.14-.172 2.709-.661 5.579-1.452 8.605-.199.739.364 1.463 1.124 1.457h4.448c.423 0 .813-.229 1.016-.599 1.281-2.297 2.765-5.631 3.645-9.489.412.244.849.473 1.344.661-.229 3.041-.744 6.235-1.64 9.609-.199.735.359 1.459 1.12 1.459h5.995c.416 0 .801-.224 1.005-.589 2.145-3.796 4.771-10.063 5.088-17.093 4.792-5.088 7.167-12.131 3.937-18.615-.891-1.776-2.052-3.427-3.156-4.692 2.135-1.1 3.24-3.167 3.145-5.131-.109-2.333-2.025-4.552-4.921-4.552-.04-.001-.078-.001-.114-.001z" />
<path clipRule="evenodd" d="m18.354 36.679c1.177 0 2.135.959 2.135 2.141 0 1.183-.957 2.14-2.135 2.14-1.183 0-2.141-.957-2.141-2.14 0-1.182.959-2.141 2.141-2.141z" fillRule="evenodd" />
</>
),
},
};
export default function FoodIcon({ name, size = 24, className = "" }: FoodIconProps) {
const icon = FOOD_ICONS[name];
if (!icon) return null;
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox={icon.viewBox}
fill={icon.fill ? "currentColor" : "none"}
stroke={icon.fill ? "none" : "currentColor"}
strokeWidth={icon.fill ? undefined : 1.5}
className={className}
>
{icon.paths}
</svg>
);
}

View File

@@ -0,0 +1,395 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
APIProvider,
Map,
AdvancedMarker,
InfoWindow,
useMap,
} from "@vis.gl/react-google-maps";
import Supercluster from "supercluster";
import type { Restaurant } from "@/lib/api";
import { getCuisineIcon } from "@/lib/cuisine-icons";
import Icon from "@/components/Icon";
const SEOUL_CENTER = { lat: 37.5665, lng: 126.978 };
const API_KEY = process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY || "";
// Channel color palette
const CHANNEL_COLORS = [
{ bg: "#fff7ed", text: "#78350f", border: "#f59e0b", arrow: "#f59e0b" }, // amber (default)
{ bg: "#eff6ff", text: "#1e3a5f", border: "#3b82f6", arrow: "#3b82f6" }, // blue
{ bg: "#f0fdf4", text: "#14532d", border: "#22c55e", arrow: "#22c55e" }, // green
{ bg: "#fdf2f8", text: "#831843", border: "#ec4899", arrow: "#ec4899" }, // pink
{ bg: "#faf5ff", text: "#581c87", border: "#a855f7", arrow: "#a855f7" }, // purple
{ bg: "#fff1f2", text: "#7f1d1d", border: "#ef4444", arrow: "#ef4444" }, // red
{ bg: "#f0fdfa", text: "#134e4a", border: "#14b8a6", arrow: "#14b8a6" }, // teal
{ bg: "#fefce8", text: "#713f12", border: "#eab308", arrow: "#eab308" }, // yellow
];
function getChannelColorMap(restaurants: Restaurant[]) {
const channels = new Set<string>();
restaurants.forEach((r) => r.channels?.forEach((ch) => channels.add(ch)));
const map: Record<string, typeof CHANNEL_COLORS[0]> = {};
let i = 0;
for (const ch of channels) {
map[ch] = CHANNEL_COLORS[i % CHANNEL_COLORS.length];
i++;
}
return map;
}
import type { MapBounds, FlyTo, MapViewProps } from "@/components/MapView.types";
type RestaurantProps = { restaurant: Restaurant };
type RestaurantFeature = Supercluster.PointFeature<RestaurantProps>;
function useSupercluster(restaurants: Restaurant[]) {
// #278 — indexRef 제거 (set만 되고 read 없는 dead code)
const points: RestaurantFeature[] = useMemo(
() =>
restaurants.map((r) => ({
type: "Feature" as const,
geometry: { type: "Point" as const, coordinates: [r.longitude, r.latitude] },
properties: { restaurant: r },
})),
[restaurants]
);
const index = useMemo(() => {
const sc = new Supercluster<{ restaurant: Restaurant }>({
radius: 60,
maxZoom: 16,
minPoints: 2,
});
sc.load(points);
return sc;
}, [points]);
const getClusters = useCallback(
(bounds: MapBounds, zoom: number) => {
return index.getClusters(
[bounds.west, bounds.south, bounds.east, bounds.north],
Math.floor(zoom)
);
},
[index]
);
const getExpansionZoom = useCallback(
(clusterId: number): number => {
try {
return index.getClusterExpansionZoom(clusterId);
} catch {
return 17;
}
},
[index]
);
return { getClusters, getExpansionZoom, index };
}
function getClusterSize(count: number): number {
if (count < 10) return 36;
if (count < 50) return 42;
if (count < 100) return 48;
return 54;
}
function MapContent({ restaurants, selected, onSelectRestaurant, flyTo, activeChannel }: Omit<MapViewProps, "onMyLocation" | "onBoundsChanged">) {
const map = useMap();
const [infoTarget, setInfoTarget] = useState<Restaurant | null>(null);
const [zoom, setZoom] = useState(13);
const [bounds, setBounds] = useState<MapBounds | null>(null);
const channelColors = useMemo(() => getChannelColorMap(restaurants), [restaurants]);
const { getClusters, getExpansionZoom } = useSupercluster(restaurants);
// #278 — restaurantMap 제거 (빌드만 되고 렌더에서 사용 안 됨, dead code)
const clusters = useMemo(() => {
if (!bounds) return [];
return getClusters(bounds, zoom);
}, [bounds, zoom, getClusters]);
const handleMarkerClick = useCallback(
(r: Restaurant) => {
setInfoTarget(r);
onSelectRestaurant?.(r);
},
[onSelectRestaurant]
);
const handleClusterClick = useCallback(
(clusterId: number, lng: number, lat: number) => {
if (!map) return;
const expansionZoom = Math.min(getExpansionZoom(clusterId), 18);
map.panTo({ lat, lng });
map.setZoom(expansionZoom);
},
[map, getExpansionZoom]
);
// Track camera changes for clustering
useEffect(() => {
if (!map) return;
const listener = map.addListener("idle", () => {
const b = map.getBounds();
const z = map.getZoom();
if (b && z != null) {
const ne = b.getNorthEast();
const sw = b.getSouthWest();
setBounds({ north: ne.lat(), south: sw.lat(), east: ne.lng(), west: sw.lng() });
setZoom(z);
}
});
// Trigger initial bounds
const b = map.getBounds();
const z = map.getZoom();
if (b && z != null) {
const ne = b.getNorthEast();
const sw = b.getSouthWest();
setBounds({ north: ne.lat(), south: sw.lat(), east: ne.lng(), west: sw.lng() });
setZoom(z);
}
return () => google.maps.event.removeListener(listener);
}, [map]);
// Fly to a specific location (region filter)
useEffect(() => {
if (!map || !flyTo) return;
map.panTo({ lat: flyTo.lat, lng: flyTo.lng });
if (flyTo.zoom) map.setZoom(flyTo.zoom);
}, [map, flyTo]);
// Pan and zoom to selected restaurant
useEffect(() => {
if (!map || !selected) return;
map.panTo({ lat: selected.latitude, lng: selected.longitude });
map.setZoom(16);
setInfoTarget(selected);
}, [map, selected]);
return (
<>
{clusters.map((feature) => {
const [lng, lat] = feature.geometry.coordinates;
const isCluster = feature.properties && "cluster" in feature.properties && feature.properties.cluster;
if (isCluster) {
const { cluster_id, point_count } = feature.properties as Supercluster.ClusterProperties;
const size = getClusterSize(point_count);
return (
<AdvancedMarker
key={`cluster-${cluster_id}`}
position={{ lat, lng }}
onClick={() => handleClusterClick(cluster_id, lng, lat)}
zIndex={100}
>
<div
role="button"
aria-label={`${point_count}개 식당이 모인 클러스터, 클릭하면 확대됩니다`}
style={{
width: size,
height: size,
borderRadius: "50%",
background: "linear-gradient(135deg, #E8720C 0%, #f59e0b 100%)",
border: "3px solid #fff",
boxShadow: "0 2px 8px rgba(0,0,0,0.25)",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "#fff",
fontSize: size > 42 ? 15 : 13,
fontWeight: 700,
cursor: "pointer",
transition: "transform 0.2s ease",
}}
>
{point_count}
</div>
</AdvancedMarker>
);
}
// Individual marker
const r = (feature.properties as { restaurant: Restaurant }).restaurant;
const isSelected = selected?.id === r.id;
const isClosed = r.business_status === "CLOSED_PERMANENTLY";
const chKey = activeChannel && r.channels?.includes(activeChannel) ? activeChannel : r.channels?.[0];
const chColor = chKey ? channelColors[chKey] : CHANNEL_COLORS[0];
const c = chColor || CHANNEL_COLORS[0];
return (
<AdvancedMarker
key={r.id}
position={{ lat: r.latitude, lng: r.longitude }}
onClick={() => handleMarkerClick(r)}
zIndex={isSelected ? 1000 : 1}
>
<div
role="button"
aria-label={`${r.name}${isClosed ? ' (폐업)' : ''}, 클릭하면 상세 정보가 표시됩니다`}
style={{ display: "flex", flexDirection: "column", alignItems: "center", transition: "transform 0.2s ease", transform: isSelected ? "scale(1.15)" : "scale(1)", opacity: isClosed ? 0.5 : 1 }}>
<div
style={{
padding: "4px 8px",
backgroundColor: isSelected ? "#2563eb" : isClosed ? "#f3f4f6" : c.bg,
color: isSelected ? "#fff" : isClosed ? "#9ca3af" : c.text,
fontSize: 12,
fontWeight: 600,
borderRadius: 6,
border: isSelected ? "2px solid #1d4ed8" : `1.5px solid ${c.border}`,
boxShadow: isSelected
? "0 2px 8px rgba(37,99,235,0.4)"
: `0 1px 4px ${c.border}40`,
whiteSpace: "nowrap",
maxWidth: 120,
overflow: "hidden",
textOverflow: "ellipsis",
textDecoration: isClosed ? "line-through" : "none",
}}
>
<span className="material-symbols-rounded" style={{ fontSize: 14, width: 14, height: 14, overflow: "hidden", display: "inline-flex", alignItems: "center", justifyContent: "center", marginRight: 3, verticalAlign: "middle", color: "#E8720C" }}>{getCuisineIcon(r.cuisine_type)}</span>
{r.name}
</div>
<div
style={{
width: 0,
height: 0,
borderLeft: "6px solid transparent",
borderRight: "6px solid transparent",
borderTop: isSelected ? "6px solid #1d4ed8" : `6px solid ${c.arrow}`,
marginTop: -1,
}}
/>
</div>
</AdvancedMarker>
);
})}
{infoTarget && (
<InfoWindow
position={{ lat: infoTarget.latitude, lng: infoTarget.longitude }}
onCloseClick={() => setInfoTarget(null)}
>
<div style={{ backgroundColor: "#ffffff", color: "#171717", colorScheme: "light" }} className="max-w-xs p-1">
<div className="flex items-center gap-2">
<h3 className="font-bold text-base" style={{ color: "#171717" }}><span className="material-symbols-rounded" style={{ fontSize: 18, width: 18, height: 18, overflow: "hidden", display: "inline-flex", alignItems: "center", justifyContent: "center", verticalAlign: "middle", color: "#E8720C", marginRight: 4 }}>{getCuisineIcon(infoTarget.cuisine_type)}</span>{infoTarget.name}</h3>
{infoTarget.business_status === "CLOSED_PERMANENTLY" && (
<span className="px-1.5 py-0.5 bg-red-100 text-red-700 rounded text-[10px] font-semibold"></span>
)}
{infoTarget.business_status === "CLOSED_TEMPORARILY" && (
<span className="px-1.5 py-0.5 bg-yellow-100 text-yellow-700 rounded text-[10px] font-semibold"></span>
)}
</div>
{infoTarget.rating && (
<p className="text-xs mt-0.5">
<span className="text-yellow-500"></span> {infoTarget.rating}
{infoTarget.rating_count && (
<span className="text-gray-400 ml-1">({infoTarget.rating_count.toLocaleString()})</span>
)}
</p>
)}
{infoTarget.cuisine_type && (
<p className="text-xs text-gray-500">{infoTarget.cuisine_type}</p>
)}
{infoTarget.address && (
<p className="text-[11px] text-gray-400 mt-1">{infoTarget.address}</p>
)}
{infoTarget.price_range && (
<p className="text-[11px] text-gray-400">{infoTarget.price_range}</p>
)}
{infoTarget.phone && (
<p className="text-[11px] text-gray-400">{infoTarget.phone}</p>
)}
<button
onClick={() => onSelectRestaurant?.(infoTarget)}
className="mt-2 text-sm text-blue-600 hover:underline"
>
</button>
</div>
</InfoWindow>
)}
</>
);
}
export default function GoogleMapView({ restaurants, selected, onSelectRestaurant, onBoundsChanged, flyTo, onMyLocation, activeChannel }: MapViewProps) {
const channelColors = useMemo(() => getChannelColorMap(restaurants), [restaurants]);
const channelNames = useMemo(() => {
const names = Object.keys(channelColors);
if (activeChannel) return names.filter((n) => n === activeChannel);
return names;
}, [channelColors, activeChannel]);
const boundsTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const handleCameraChanged = useCallback((ev: { detail: { bounds: { north: number; south: number; east: number; west: number } } }) => {
if (!onBoundsChanged) return;
if (boundsTimerRef.current) clearTimeout(boundsTimerRef.current);
boundsTimerRef.current = setTimeout(() => {
const { north, south, east, west } = ev.detail.bounds;
onBoundsChanged({ north, south, east, west });
}, 150);
}, [onBoundsChanged]);
// #278 — 언마운트 시 디바운스 타이머 정리 (메모리 누수 + unmounted setState 경고 방지)
useEffect(() => {
return () => {
if (boundsTimerRef.current) clearTimeout(boundsTimerRef.current);
};
}, []);
return (
<APIProvider apiKey={API_KEY}>
<Map
defaultCenter={SEOUL_CENTER}
defaultZoom={13}
mapId="tasteby-map"
className="h-full w-full"
colorScheme="LIGHT"
mapTypeControl={false}
fullscreenControl={false}
onCameraChanged={handleCameraChanged}
>
<MapContent
restaurants={restaurants}
selected={selected}
onSelectRestaurant={onSelectRestaurant}
flyTo={flyTo}
activeChannel={activeChannel}
/>
</Map>
{onMyLocation && (
<button
onClick={onMyLocation}
aria-label="내 위치로 이동"
// #278 — 44×44px 터치 영역 확보 (이전 36px)
className="absolute top-2 right-2 w-11 h-11 bg-surface rounded-lg shadow-md flex items-center justify-center text-gray-600 dark:text-gray-300 hover:text-brand-500 dark:hover:text-brand-400 transition-colors z-10 touch-manipulation"
title="내 위치"
>
<Icon name="my_location" size={22} />
</button>
)}
{channelNames.length > 0 && (
<div
role="region"
aria-label="채널 범례"
className="absolute bottom-2 left-2 bg-surface/90 backdrop-blur-sm rounded-lg shadow px-2.5 py-1.5 flex flex-wrap gap-x-3 gap-y-1 text-[11px] z-10"
>
{channelNames.map((ch) => (
<div key={ch} className="flex items-center gap-1">
<span
aria-hidden="true"
className="inline-block w-2.5 h-2.5 rounded-full border"
style={{ backgroundColor: channelColors[ch].border, borderColor: channelColors[ch].border }}
/>
<span className="text-gray-700 dark:text-gray-300">{ch}</span>
</div>
))}
</div>
)}
</APIProvider>
);
}

View File

@@ -15,7 +15,7 @@ export default function Icon({ name, size = 20, filled, className = "" }: IconPr
return (
<span
className={`material-symbols-rounded ${filled ? "filled" : ""} ${className}`}
style={{ fontSize: size }}
style={{ fontSize: size, width: size, height: size, overflow: "hidden", display: "inline-flex", alignItems: "center", justifyContent: "center" }}
>
{name}
</span>

View File

@@ -0,0 +1,57 @@
"use client";
import { useState } from "react";
import { useLocale } from "@/i18n/LocaleProvider";
import { LOCALES, LOCALE_LABELS } from "@/i18n/config";
// #352 — 헤더용 언어 전환 드롭다운. 44px 터치 영역 + ARIA listbox 패턴.
export default function LanguageSwitcher() {
const { locale, setLocale } = useLocale();
const [open, setOpen] = useState(false);
return (
<div className="relative">
<button
onClick={() => setOpen((v) => !v)}
aria-haspopup="listbox"
aria-expanded={open}
aria-label="언어 선택 / Select language"
className="min-h-[44px] min-w-[44px] px-2 py-1.5 flex items-center gap-1 text-sm rounded-lg hover:bg-brand-50 touch-manipulation"
>
<span aria-hidden="true">{LOCALE_LABELS[locale].flag}</span>
<span className="text-xs text-gray-500 uppercase">{locale}</span>
</button>
{open && (
<>
<button
aria-hidden="true"
tabIndex={-1}
className="fixed inset-0 z-40 cursor-default"
onClick={() => setOpen(false)}
/>
<ul
role="listbox"
aria-label="언어 목록"
className="absolute right-0 mt-1 bg-surface rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 py-1 z-50 min-w-[160px]"
>
{LOCALES.map((l) => (
<li key={l}>
<button
role="option"
aria-selected={l === locale}
onClick={() => { setLocale(l); setOpen(false); }}
className={`w-full text-left px-3 py-2 text-sm flex items-center gap-2 hover:bg-brand-50 ${
l === locale ? "font-semibold text-brand-700" : "text-gray-700"
}`}
>
<span aria-hidden="true">{LOCALE_LABELS[l].flag}</span>
<span>{LOCALE_LABELS[l].native}</span>
</button>
</li>
))}
</ul>
</>
)}
</div>
);
}

View File

@@ -1,416 +1,37 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
APIProvider,
Map,
AdvancedMarker,
InfoWindow,
useMap,
} from "@vis.gl/react-google-maps";
import Supercluster from "supercluster";
import type { Restaurant } from "@/lib/api";
import { getCuisineIcon } from "@/lib/cuisine-icons";
import Icon from "@/components/Icon";
import GoogleMapView from "@/components/GoogleMapView";
import NaverMapView from "@/components/NaverMapView";
import { isKoreaCoord, type MapBounds, type FlyTo, type MapViewProps } from "@/components/MapView.types";
const SEOUL_CENTER = { lat: 37.5665, lng: 126.978 };
const API_KEY = process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY || "";
export type { MapBounds, FlyTo };
// Channel color palette
const CHANNEL_COLORS = [
{ bg: "#fff7ed", text: "#78350f", border: "#f59e0b", arrow: "#f59e0b" }, // amber (default)
{ bg: "#eff6ff", text: "#1e3a5f", border: "#3b82f6", arrow: "#3b82f6" }, // blue
{ bg: "#f0fdf4", text: "#14532d", border: "#22c55e", arrow: "#22c55e" }, // green
{ bg: "#fdf2f8", text: "#831843", border: "#ec4899", arrow: "#ec4899" }, // pink
{ bg: "#faf5ff", text: "#581c87", border: "#a855f7", arrow: "#a855f7" }, // purple
{ bg: "#fff1f2", text: "#7f1d1d", border: "#ef4444", arrow: "#ef4444" }, // red
{ bg: "#f0fdfa", text: "#134e4a", border: "#14b8a6", arrow: "#14b8a6" }, // teal
{ bg: "#fefce8", text: "#713f12", border: "#eab308", arrow: "#eab308" }, // yellow
];
const NAVER_KEY = process.env.NEXT_PUBLIC_NAVER_MAP_CLIENT_ID || "";
function getChannelColorMap(restaurants: Restaurant[]) {
const channels = new Set<string>();
restaurants.forEach((r) => r.channels?.forEach((ch) => channels.add(ch)));
const map: Record<string, typeof CHANNEL_COLORS[0]> = {};
let i = 0;
for (const ch of channels) {
map[ch] = CHANNEL_COLORS[i % CHANNEL_COLORS.length];
i++;
/**
* #363 — 메인 지도 dispatcher.
* - 키 미설정: GoogleMap (회귀 0)
* - flyTo 또는 selected 좌표가 KR bbox: NaverMap
* - 그 외 (해외 + 좌표 추정): GoogleMap
* - 초기 마운트 시 화면 중심을 추정할 수 없으면 식당 평균 좌표로.
*/
export default function MapView(props: MapViewProps) {
if (!NAVER_KEY) return <GoogleMapView {...props} />;
const targetLat = props.flyTo?.lat ?? props.selected?.latitude ?? avgLat(props.restaurants);
const targetLng = props.flyTo?.lng ?? props.selected?.longitude ?? avgLng(props.restaurants);
if (targetLat != null && targetLng != null && isKoreaCoord(targetLat, targetLng)) {
return <NaverMapView {...props} />;
}
return map;
return <GoogleMapView {...props} />;
}
export interface MapBounds {
north: number;
south: number;
east: number;
west: number;
function avgLat(rs: MapViewProps["restaurants"]): number | null {
if (!rs.length) return null;
return rs.reduce((s, r) => s + r.latitude, 0) / rs.length;
}
export interface FlyTo {
lat: number;
lng: number;
zoom?: number;
}
interface MapViewProps {
restaurants: Restaurant[];
selected?: Restaurant | null;
onSelectRestaurant?: (r: Restaurant) => void;
onBoundsChanged?: (bounds: MapBounds) => void;
flyTo?: FlyTo | null;
onMyLocation?: () => void;
activeChannel?: string;
}
type RestaurantProps = { restaurant: Restaurant };
type RestaurantFeature = Supercluster.PointFeature<RestaurantProps>;
function useSupercluster(restaurants: Restaurant[]) {
// #278 — indexRef 제거 (set만 되고 read 없는 dead code)
const points: RestaurantFeature[] = useMemo(
() =>
restaurants.map((r) => ({
type: "Feature" as const,
geometry: { type: "Point" as const, coordinates: [r.longitude, r.latitude] },
properties: { restaurant: r },
})),
[restaurants]
);
const index = useMemo(() => {
const sc = new Supercluster<{ restaurant: Restaurant }>({
radius: 60,
maxZoom: 16,
minPoints: 2,
});
sc.load(points);
return sc;
}, [points]);
const getClusters = useCallback(
(bounds: MapBounds, zoom: number) => {
return index.getClusters(
[bounds.west, bounds.south, bounds.east, bounds.north],
Math.floor(zoom)
);
},
[index]
);
const getExpansionZoom = useCallback(
(clusterId: number): number => {
try {
return index.getClusterExpansionZoom(clusterId);
} catch {
return 17;
}
},
[index]
);
return { getClusters, getExpansionZoom, index };
}
function getClusterSize(count: number): number {
if (count < 10) return 36;
if (count < 50) return 42;
if (count < 100) return 48;
return 54;
}
function MapContent({ restaurants, selected, onSelectRestaurant, flyTo, activeChannel }: Omit<MapViewProps, "onMyLocation" | "onBoundsChanged">) {
const map = useMap();
const [infoTarget, setInfoTarget] = useState<Restaurant | null>(null);
const [zoom, setZoom] = useState(13);
const [bounds, setBounds] = useState<MapBounds | null>(null);
const channelColors = useMemo(() => getChannelColorMap(restaurants), [restaurants]);
const { getClusters, getExpansionZoom } = useSupercluster(restaurants);
// #278 — restaurantMap 제거 (빌드만 되고 렌더에서 사용 안 됨, dead code)
const clusters = useMemo(() => {
if (!bounds) return [];
return getClusters(bounds, zoom);
}, [bounds, zoom, getClusters]);
const handleMarkerClick = useCallback(
(r: Restaurant) => {
setInfoTarget(r);
onSelectRestaurant?.(r);
},
[onSelectRestaurant]
);
const handleClusterClick = useCallback(
(clusterId: number, lng: number, lat: number) => {
if (!map) return;
const expansionZoom = Math.min(getExpansionZoom(clusterId), 18);
map.panTo({ lat, lng });
map.setZoom(expansionZoom);
},
[map, getExpansionZoom]
);
// Track camera changes for clustering
useEffect(() => {
if (!map) return;
const listener = map.addListener("idle", () => {
const b = map.getBounds();
const z = map.getZoom();
if (b && z != null) {
const ne = b.getNorthEast();
const sw = b.getSouthWest();
setBounds({ north: ne.lat(), south: sw.lat(), east: ne.lng(), west: sw.lng() });
setZoom(z);
}
});
// Trigger initial bounds
const b = map.getBounds();
const z = map.getZoom();
if (b && z != null) {
const ne = b.getNorthEast();
const sw = b.getSouthWest();
setBounds({ north: ne.lat(), south: sw.lat(), east: ne.lng(), west: sw.lng() });
setZoom(z);
}
return () => google.maps.event.removeListener(listener);
}, [map]);
// Fly to a specific location (region filter)
useEffect(() => {
if (!map || !flyTo) return;
map.panTo({ lat: flyTo.lat, lng: flyTo.lng });
if (flyTo.zoom) map.setZoom(flyTo.zoom);
}, [map, flyTo]);
// Pan and zoom to selected restaurant
useEffect(() => {
if (!map || !selected) return;
map.panTo({ lat: selected.latitude, lng: selected.longitude });
map.setZoom(16);
setInfoTarget(selected);
}, [map, selected]);
return (
<>
{clusters.map((feature) => {
const [lng, lat] = feature.geometry.coordinates;
const isCluster = feature.properties && "cluster" in feature.properties && feature.properties.cluster;
if (isCluster) {
const { cluster_id, point_count } = feature.properties as Supercluster.ClusterProperties;
const size = getClusterSize(point_count);
return (
<AdvancedMarker
key={`cluster-${cluster_id}`}
position={{ lat, lng }}
onClick={() => handleClusterClick(cluster_id, lng, lat)}
zIndex={100}
>
<div
role="button"
aria-label={`${point_count}개 식당이 모인 클러스터, 클릭하면 확대됩니다`}
style={{
width: size,
height: size,
borderRadius: "50%",
background: "linear-gradient(135deg, #E8720C 0%, #f59e0b 100%)",
border: "3px solid #fff",
boxShadow: "0 2px 8px rgba(0,0,0,0.25)",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "#fff",
fontSize: size > 42 ? 15 : 13,
fontWeight: 700,
cursor: "pointer",
transition: "transform 0.2s ease",
}}
>
{point_count}
</div>
</AdvancedMarker>
);
}
// Individual marker
const r = (feature.properties as { restaurant: Restaurant }).restaurant;
const isSelected = selected?.id === r.id;
const isClosed = r.business_status === "CLOSED_PERMANENTLY";
const chKey = activeChannel && r.channels?.includes(activeChannel) ? activeChannel : r.channels?.[0];
const chColor = chKey ? channelColors[chKey] : CHANNEL_COLORS[0];
const c = chColor || CHANNEL_COLORS[0];
return (
<AdvancedMarker
key={r.id}
position={{ lat: r.latitude, lng: r.longitude }}
onClick={() => handleMarkerClick(r)}
zIndex={isSelected ? 1000 : 1}
>
<div
role="button"
aria-label={`${r.name}${isClosed ? ' (폐업)' : ''}, 클릭하면 상세 정보가 표시됩니다`}
style={{ display: "flex", flexDirection: "column", alignItems: "center", transition: "transform 0.2s ease", transform: isSelected ? "scale(1.15)" : "scale(1)", opacity: isClosed ? 0.5 : 1 }}>
<div
style={{
padding: "4px 8px",
backgroundColor: isSelected ? "#2563eb" : isClosed ? "#f3f4f6" : c.bg,
color: isSelected ? "#fff" : isClosed ? "#9ca3af" : c.text,
fontSize: 12,
fontWeight: 600,
borderRadius: 6,
border: isSelected ? "2px solid #1d4ed8" : `1.5px solid ${c.border}`,
boxShadow: isSelected
? "0 2px 8px rgba(37,99,235,0.4)"
: `0 1px 4px ${c.border}40`,
whiteSpace: "nowrap",
maxWidth: 120,
overflow: "hidden",
textOverflow: "ellipsis",
textDecoration: isClosed ? "line-through" : "none",
}}
>
<span className="material-symbols-rounded" style={{ fontSize: 14, width: 14, height: 14, overflow: "hidden", display: "inline-flex", alignItems: "center", justifyContent: "center", marginRight: 3, verticalAlign: "middle", color: "#E8720C" }}>{getCuisineIcon(r.cuisine_type)}</span>
{r.name}
</div>
<div
style={{
width: 0,
height: 0,
borderLeft: "6px solid transparent",
borderRight: "6px solid transparent",
borderTop: isSelected ? "6px solid #1d4ed8" : `6px solid ${c.arrow}`,
marginTop: -1,
}}
/>
</div>
</AdvancedMarker>
);
})}
{infoTarget && (
<InfoWindow
position={{ lat: infoTarget.latitude, lng: infoTarget.longitude }}
onCloseClick={() => setInfoTarget(null)}
>
<div style={{ backgroundColor: "#ffffff", color: "#171717", colorScheme: "light" }} className="max-w-xs p-1">
<div className="flex items-center gap-2">
<h3 className="font-bold text-base" style={{ color: "#171717" }}><span className="material-symbols-rounded" style={{ fontSize: 18, width: 18, height: 18, overflow: "hidden", display: "inline-flex", alignItems: "center", justifyContent: "center", verticalAlign: "middle", color: "#E8720C", marginRight: 4 }}>{getCuisineIcon(infoTarget.cuisine_type)}</span>{infoTarget.name}</h3>
{infoTarget.business_status === "CLOSED_PERMANENTLY" && (
<span className="px-1.5 py-0.5 bg-red-100 text-red-700 rounded text-[10px] font-semibold"></span>
)}
{infoTarget.business_status === "CLOSED_TEMPORARILY" && (
<span className="px-1.5 py-0.5 bg-yellow-100 text-yellow-700 rounded text-[10px] font-semibold"></span>
)}
</div>
{infoTarget.rating && (
<p className="text-xs mt-0.5">
<span className="text-yellow-500"></span> {infoTarget.rating}
{infoTarget.rating_count && (
<span className="text-gray-400 ml-1">({infoTarget.rating_count.toLocaleString()})</span>
)}
</p>
)}
{infoTarget.cuisine_type && (
<p className="text-xs text-gray-500">{infoTarget.cuisine_type}</p>
)}
{infoTarget.address && (
<p className="text-[11px] text-gray-400 mt-1">{infoTarget.address}</p>
)}
{infoTarget.price_range && (
<p className="text-[11px] text-gray-400">{infoTarget.price_range}</p>
)}
{infoTarget.phone && (
<p className="text-[11px] text-gray-400">{infoTarget.phone}</p>
)}
<button
onClick={() => onSelectRestaurant?.(infoTarget)}
className="mt-2 text-sm text-blue-600 hover:underline"
>
</button>
</div>
</InfoWindow>
)}
</>
);
}
export default function MapView({ restaurants, selected, onSelectRestaurant, onBoundsChanged, flyTo, onMyLocation, activeChannel }: MapViewProps) {
const channelColors = useMemo(() => getChannelColorMap(restaurants), [restaurants]);
const channelNames = useMemo(() => {
const names = Object.keys(channelColors);
if (activeChannel) return names.filter((n) => n === activeChannel);
return names;
}, [channelColors, activeChannel]);
const boundsTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const handleCameraChanged = useCallback((ev: { detail: { bounds: { north: number; south: number; east: number; west: number } } }) => {
if (!onBoundsChanged) return;
if (boundsTimerRef.current) clearTimeout(boundsTimerRef.current);
boundsTimerRef.current = setTimeout(() => {
const { north, south, east, west } = ev.detail.bounds;
onBoundsChanged({ north, south, east, west });
}, 150);
}, [onBoundsChanged]);
// #278 — 언마운트 시 디바운스 타이머 정리 (메모리 누수 + unmounted setState 경고 방지)
useEffect(() => {
return () => {
if (boundsTimerRef.current) clearTimeout(boundsTimerRef.current);
};
}, []);
return (
<APIProvider apiKey={API_KEY}>
<Map
defaultCenter={SEOUL_CENTER}
defaultZoom={13}
mapId="tasteby-map"
className="h-full w-full"
colorScheme="LIGHT"
mapTypeControl={false}
fullscreenControl={false}
onCameraChanged={handleCameraChanged}
>
<MapContent
restaurants={restaurants}
selected={selected}
onSelectRestaurant={onSelectRestaurant}
flyTo={flyTo}
activeChannel={activeChannel}
/>
</Map>
{onMyLocation && (
<button
onClick={onMyLocation}
aria-label="내 위치로 이동"
// #278 — 44×44px 터치 영역 확보 (이전 36px)
className="absolute top-2 right-2 w-11 h-11 bg-surface rounded-lg shadow-md flex items-center justify-center text-gray-600 dark:text-gray-300 hover:text-brand-500 dark:hover:text-brand-400 transition-colors z-10 touch-manipulation"
title="내 위치"
>
<Icon name="my_location" size={22} />
</button>
)}
{channelNames.length > 0 && (
<div
role="region"
aria-label="채널 범례"
className="absolute bottom-2 left-2 bg-surface/90 backdrop-blur-sm rounded-lg shadow px-2.5 py-1.5 flex flex-wrap gap-x-3 gap-y-1 text-[11px] z-10"
>
{channelNames.map((ch) => (
<div key={ch} className="flex items-center gap-1">
<span
aria-hidden="true"
className="inline-block w-2.5 h-2.5 rounded-full border"
style={{ backgroundColor: channelColors[ch].border, borderColor: channelColors[ch].border }}
/>
<span className="text-gray-700 dark:text-gray-300">{ch}</span>
</div>
))}
</div>
)}
</APIProvider>
);
function avgLng(rs: MapViewProps["restaurants"]): number | null {
if (!rs.length) return null;
return rs.reduce((s, r) => s + r.longitude, 0) / rs.length;
}

View File

@@ -0,0 +1,29 @@
import type { Restaurant } from "@/lib/api";
export interface MapBounds {
north: number;
south: number;
east: number;
west: number;
}
export interface FlyTo {
lat: number;
lng: number;
zoom?: number;
}
export interface MapViewProps {
restaurants: Restaurant[];
selected?: Restaurant | null;
onSelectRestaurant?: (r: Restaurant) => void;
onBoundsChanged?: (bounds: MapBounds) => void;
flyTo?: FlyTo | null;
onMyLocation?: () => void;
activeChannel?: string;
}
// 좌표가 한국 영토 bbox 안인지 (WGS84).
export function isKoreaCoord(lat: number, lng: number): boolean {
return lat >= 33 && lat <= 38.7 && lng >= 124 && lng <= 132;
}

View File

@@ -41,8 +41,14 @@ export default function MyReviewsList({
</button>
</div>
<div className="flex gap-1 border-b">
{/* #343 — WAI-ARIA Tabs 패턴 */}
<div role="tablist" aria-label="내 활동" className="flex gap-1 border-b">
<button
role="tab"
id="tab-reviews"
aria-selected={tab === "reviews"}
aria-controls="panel-reviews"
tabIndex={tab === "reviews" ? 0 : -1}
onClick={() => setTab("reviews")}
className={`px-3 py-1.5 text-sm font-medium border-b-2 transition-colors ${
tab === "reviews"
@@ -54,6 +60,11 @@ export default function MyReviewsList({
({reviews.length})
</button>
<button
role="tab"
id="tab-memos"
aria-selected={tab === "memos"}
aria-controls="panel-memos"
tabIndex={tab === "memos" ? 0 : -1}
onClick={() => setTab("memos")}
className={`px-3 py-1.5 text-sm font-medium border-b-2 transition-colors ${
tab === "memos"
@@ -67,7 +78,8 @@ export default function MyReviewsList({
</div>
{tab === "reviews" ? (
reviews.length === 0 ? (
<div role="tabpanel" id="panel-reviews" aria-labelledby="tab-reviews">
{reviews.length === 0 ? (
<p className="text-sm text-gray-500 py-8 text-center">
.
</p>
@@ -100,9 +112,11 @@ export default function MyReviewsList({
</button>
))}
</div>
)
)}
</div>
) : (
memos.length === 0 ? (
<div role="tabpanel" id="panel-memos" aria-labelledby="tab-memos">
{memos.length === 0 ? (
<p className="text-sm text-gray-500 py-8 text-center">
.
</p>
@@ -137,7 +151,8 @@ export default function MyReviewsList({
</button>
))}
</div>
)
)}
</div>
)}
</div>
);

View File

@@ -0,0 +1,355 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import Supercluster from "supercluster";
import type { Restaurant } from "@/lib/api";
import Icon from "@/components/Icon";
import { getCuisineIcon } from "@/lib/cuisine-icons";
import type { MapBounds, MapViewProps } from "@/components/MapView.types";
declare global {
interface Window {
naver?: { maps: NaverMaps };
}
}
type LatLng = { lat: () => number; lng: () => number };
type NaverMaps = {
LatLng: new (lat: number, lng: number) => LatLng;
Map: new (el: HTMLElement, opts: Record<string, unknown>) => NaverMapInstance;
Marker: new (opts: Record<string, unknown>) => NaverMarker;
InfoWindow: new (opts: Record<string, unknown>) => NaverInfoWindow;
Event: { addListener: (target: unknown, type: string, fn: (...args: unknown[]) => void) => unknown; removeListener: (handler: unknown) => void };
Size: new (w: number, h: number) => unknown;
Point: new (x: number, y: number) => unknown;
};
type NaverMapInstance = {
setCenter: (latlng: unknown) => void;
setZoom: (zoom: number, useEffect?: boolean) => void;
getZoom: () => number;
getBounds: () => { getNE: () => LatLng; getSW: () => LatLng };
panTo: (latlng: unknown, opts?: Record<string, unknown>) => void;
refresh: (noEffect?: boolean) => void;
};
type NaverMarker = {
setMap: (map: NaverMapInstance | null) => void;
setIcon: (icon: Record<string, unknown>) => void;
setPosition: (latlng: unknown) => void;
getPosition: () => LatLng;
};
type NaverInfoWindow = {
open: (map: NaverMapInstance, marker: NaverMarker) => void;
close: () => void;
setContent: (content: string) => void;
getMap: () => NaverMapInstance | null;
};
const NAVER_CLIENT_ID = process.env.NEXT_PUBLIC_NAVER_MAP_CLIENT_ID || "";
// Channel color palette — GoogleMapView와 동일
const CHANNEL_COLORS = [
{ bg: "#fff7ed", text: "#78350f", border: "#f59e0b", arrow: "#f59e0b" }, // amber (default)
{ bg: "#eff6ff", text: "#1e3a5f", border: "#3b82f6", arrow: "#3b82f6" }, // blue
{ bg: "#f0fdf4", text: "#14532d", border: "#22c55e", arrow: "#22c55e" }, // green
{ bg: "#fdf2f8", text: "#831843", border: "#ec4899", arrow: "#ec4899" }, // pink
{ bg: "#faf5ff", text: "#581c87", border: "#a855f7", arrow: "#a855f7" }, // purple
{ bg: "#fff1f2", text: "#7f1d1d", border: "#ef4444", arrow: "#ef4444" }, // red
{ bg: "#f0fdfa", text: "#134e4a", border: "#14b8a6", arrow: "#14b8a6" }, // teal
{ bg: "#fefce8", text: "#713f12", border: "#eab308", arrow: "#eab308" }, // yellow
];
function getChannelColorMap(restaurants: Restaurant[]) {
const channels = new Set<string>();
restaurants.forEach((r) => r.channels?.forEach((ch) => channels.add(ch)));
const map: Record<string, typeof CHANNEL_COLORS[0]> = {};
let i = 0;
for (const ch of channels) {
map[ch] = CHANNEL_COLORS[i % CHANNEL_COLORS.length];
i++;
}
return map;
}
function useNaverMaps(): { ready: boolean; error: string | null } {
const [ready, setReady] = useState(typeof window !== "undefined" && !!window.naver?.maps);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!NAVER_CLIENT_ID) { setError("NEXT_PUBLIC_NAVER_MAP_CLIENT_ID 미설정"); return; }
if (window.naver?.maps) { setReady(true); return; }
const existing = document.querySelector<HTMLScriptElement>(`script[data-naver-maps]`);
if (existing) {
existing.addEventListener("load", () => setReady(true), { once: true });
return;
}
const s = document.createElement("script");
s.src = `https://oapi.map.naver.com/openapi/v3/maps.js?ncpKeyId=${NAVER_CLIENT_ID}`;
s.async = true;
s.dataset.naverMaps = "1";
s.onload = () => setReady(true);
s.onerror = () => setError("naver maps v3 스크립트 로드 실패");
document.head.appendChild(s);
}, []);
return { ready, error };
}
type RestaurantProps = { restaurant: Restaurant };
type RestaurantFeature = Supercluster.PointFeature<RestaurantProps>;
function useSupercluster(restaurants: Restaurant[]) {
const points: RestaurantFeature[] = useMemo(
() => restaurants.map((r) => ({
type: "Feature" as const,
geometry: { type: "Point" as const, coordinates: [r.longitude, r.latitude] },
properties: { restaurant: r },
})),
[restaurants]
);
const index = useMemo(() => {
const sc = new Supercluster<{ restaurant: Restaurant }>({ radius: 60, maxZoom: 16, minPoints: 2 });
sc.load(points);
return sc;
}, [points]);
const getClusters = useCallback((bounds: MapBounds, zoom: number) =>
index.getClusters([bounds.west, bounds.south, bounds.east, bounds.north], Math.floor(zoom))
, [index]);
const getExpansionZoom = useCallback((clusterId: number) => {
try { return index.getClusterExpansionZoom(clusterId); } catch { return 17; }
}, [index]);
return { getClusters, getExpansionZoom };
}
function getClusterSize(count: number): number {
if (count < 10) return 36;
if (count < 50) return 42;
if (count < 100) return 48;
return 54;
}
// 단일 마커 — 식당명 박스 + 화살표 핀 (GoogleMapView와 동일 디자인)
function markerIconHtml(
name: string,
cuisineIcon: string,
c: typeof CHANNEL_COLORS[0],
opts: { isSelected: boolean; isClosed: boolean }
): string {
const { isSelected, isClosed } = opts;
const bg = isSelected ? "#2563eb" : isClosed ? "#f3f4f6" : c.bg;
const text = isSelected ? "#fff" : isClosed ? "#9ca3af" : c.text;
const border = isSelected ? "2px solid #1d4ed8" : `1.5px solid ${c.border}`;
const shadow = isSelected ? "0 2px 8px rgba(37,99,235,0.4)" : `0 1px 4px ${c.border}40`;
const arrowColor = isSelected ? "#1d4ed8" : c.arrow;
const opacity = isClosed ? 0.5 : 1;
const deco = isClosed ? "line-through" : "none";
return `
<div style="display:flex;flex-direction:column;align-items:center;transition:transform .2s ease;transform:scale(${isSelected ? 1.15 : 1});opacity:${opacity};">
<div style="padding:4px 8px;background:${bg};color:${text};font-size:12px;font-weight:600;border-radius:6px;border:${border};box-shadow:${shadow};white-space:nowrap;max-width:120px;overflow:hidden;text-overflow:ellipsis;text-decoration:${deco};">
<span class="material-symbols-rounded" style="font-size:14px;width:14px;height:14px;overflow:hidden;display:inline-flex;align-items:center;justify-content:center;margin-right:3px;vertical-align:middle;color:#E8720C;">${escapeHtml(cuisineIcon)}</span>${escapeHtml(name)}
</div>
<div style="width:0;height:0;border-left:6px solid transparent;border-right:6px solid transparent;border-top:6px solid ${arrowColor};margin-top:-1px;"></div>
</div>
`;
}
// SVG data URL — 클러스터(숫자)
function clusterIconHtml(count: number, size: number): string {
return `<div style="width:${size}px;height:${size}px;border-radius:9999px;background:rgba(245,158,11,.92);color:#fff;display:flex;align-items:center;justify-content:center;font-weight:700;font-size:${size > 44 ? 14 : 12}px;border:2px solid #fff;box-shadow:0 2px 8px rgba(0,0,0,.3);">${count}</div>`;
}
export default function NaverMapView({
restaurants,
selected,
onSelectRestaurant,
onBoundsChanged,
flyTo,
onMyLocation,
activeChannel,
}: MapViewProps) {
const channelColors = useMemo(() => getChannelColorMap(restaurants), [restaurants]);
const { ready, error } = useNaverMaps();
const divRef = useRef<HTMLDivElement | null>(null);
const mapRef = useRef<NaverMapInstance | null>(null);
const markersRef = useRef<NaverMarker[]>([]);
const infoWindowRef = useRef<NaverInfoWindow | null>(null);
const [bounds, setBounds] = useState<MapBounds | null>(null);
const [zoom, setZoom] = useState(13);
const [initError, setInitError] = useState<string | null>(null);
const { getClusters, getExpansionZoom } = useSupercluster(restaurants);
// 지도 1회 생성
useEffect(() => {
if (!ready || !divRef.current || mapRef.current) return;
try {
const n = window.naver!.maps;
const initLat = flyTo?.lat ?? selected?.latitude ?? 37.5665;
const initLng = flyTo?.lng ?? selected?.longitude ?? 126.978;
const initZoom = flyTo?.zoom ?? 13;
const m = new n.Map(divRef.current, {
center: new n.LatLng(initLat, initLng),
zoom: initZoom,
logoControl: false,
mapDataControl: false,
scaleControl: false,
zoomControl: false,
});
mapRef.current = m;
infoWindowRef.current = new n.InfoWindow({
borderWidth: 0,
anchorSize: new n.Size(10, 10),
pixelOffset: new n.Point(0, -8),
backgroundColor: "transparent",
disableAnchor: false,
});
const ro = new ResizeObserver(() => {
try { m.refresh(true); } catch { /* noop */ }
});
ro.observe(divRef.current);
// bounds_changed가 줌/팬 끝나는 시점에 한 번만 emit (SDK가 throttle)
const sync = () => {
try {
const b = m.getBounds();
const ne = b.getNE(), sw = b.getSW();
const nb: MapBounds = { north: ne.lat(), south: sw.lat(), east: ne.lng(), west: sw.lng() };
setBounds(nb);
setZoom(m.getZoom());
onBoundsChanged?.(nb);
} catch (e) {
console.warn("[NaverMap] sync failed", e);
}
};
requestAnimationFrame(() => {
try { m.refresh(true); } catch { /* noop */ }
sync();
});
// idle = 줌/팬 끝났을 때 한 번 (bounds_changed보다 적게 발화 → 성능)
n.Event.addListener(m, "idle", sync);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
console.error("[NaverMap] init failed", e);
setInitError(msg);
}
}, [ready, flyTo, selected, onBoundsChanged]);
// flyTo 변경 반영
useEffect(() => {
const m = mapRef.current;
if (!m || !flyTo || !window.naver?.maps) return;
m.panTo(new window.naver.maps.LatLng(flyTo.lat, flyTo.lng));
if (flyTo.zoom) m.setZoom(flyTo.zoom, true);
}, [flyTo]);
// selected 변경 시 자동 panTo + zoom (GoogleMapView와 동일 동작)
useEffect(() => {
const m = mapRef.current;
if (!m || !selected || !window.naver?.maps) return;
if (selected.latitude == null || selected.longitude == null) return;
m.panTo(new window.naver.maps.LatLng(selected.latitude, selected.longitude));
m.setZoom(16, true);
}, [selected]);
// 클러스터 계산 (bounds/zoom 변경 시)
const clusters = useMemo(() => {
if (!bounds) return [];
return getClusters(bounds, zoom);
}, [bounds, zoom, getClusters]);
// 마커를 SDK 네이티브로 그림 — clusters 바뀌면 기존 마커 모두 제거 후 새로 생성
useEffect(() => {
const m = mapRef.current;
const naver = window.naver?.maps;
if (!m || !naver) return;
// 기존 마커 제거
for (const mk of markersRef.current) mk.setMap(null);
markersRef.current = [];
for (const feature of clusters) {
const [lng, lat] = feature.geometry.coordinates;
const isCluster = feature.properties && "cluster" in feature.properties && feature.properties.cluster;
if (isCluster) {
const { cluster_id, point_count } = feature.properties as Supercluster.ClusterProperties;
const size = getClusterSize(point_count);
const marker = new naver.Marker({
position: new naver.LatLng(lat, lng),
map: m,
icon: {
content: clusterIconHtml(point_count, size),
anchor: new naver.Point(size / 2, size / 2),
},
});
naver.Event.addListener(marker, "click", () => {
const z = Math.min(getExpansionZoom(cluster_id), 18);
m.panTo(new naver.LatLng(lat, lng));
m.setZoom(z, true);
});
markersRef.current.push(marker);
} else {
const r = (feature.properties as RestaurantProps).restaurant;
const chKey = activeChannel && r.channels?.includes(activeChannel) ? activeChannel : r.channels?.[0];
const chColor = chKey ? channelColors[chKey] : CHANNEL_COLORS[0];
const isSelected = selected?.id === r.id;
const isClosed = r.business_status === "CLOSED_PERMANENTLY" || r.business_status === "CLOSED_TEMPORARILY";
const cuisineIcon = getCuisineIcon(r.cuisine_type);
const marker = new naver.Marker({
position: new naver.LatLng(lat, lng),
map: m,
title: r.name,
zIndex: isSelected ? 1000 : 1,
icon: {
content: markerIconHtml(r.name, cuisineIcon, chColor ?? CHANNEL_COLORS[0], { isSelected, isClosed }),
// 박스 폭 가변 — 화살표 끝(하단 중앙)이 좌표에 위치하도록 추정 anchor
// approxWidth = textLen * 7 + 30 (icon+padding), height = box 24 + arrow 6 = 30
anchor: new naver.Point(Math.min(r.name.length * 4 + 18, 64), 30),
},
});
naver.Event.addListener(marker, "click", () => {
onSelectRestaurant?.(r);
});
markersRef.current.push(marker);
}
}
}, [clusters, getExpansionZoom, onSelectRestaurant, channelColors, activeChannel, selected]);
// 컴포넌트 unmount 시 마커 정리
useEffect(() => {
return () => {
for (const mk of markersRef.current) mk.setMap(null);
markersRef.current = [];
infoWindowRef.current?.close();
};
}, []);
return (
<div className="relative w-full h-full">
<div
ref={divRef}
className="absolute inset-0"
style={{ width: "100%", height: "100%", backgroundColor: "#e5e7eb" }}
/>
{(error || initError) && (
<div className="absolute inset-0 flex items-center justify-center text-xs text-red-600 bg-white/80 pointer-events-none">
{error ?? initError}
</div>
)}
{!ready && !error && (
<div className="absolute inset-0 flex items-center justify-center text-xs text-gray-500 bg-white/80 pointer-events-none">
</div>
)}
{onMyLocation && (
<button
onClick={onMyLocation}
aria-label="내 위치"
className="absolute right-3 bottom-3 size-11 rounded-full bg-white shadow-lg flex items-center justify-center z-10"
>
<Icon name="my-location" size={22} />
</button>
)}
</div>
);
}
function escapeHtml(s: string): string {
return s.replace(/[&<>"']/g, (ch) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[ch] as string));
}

View File

@@ -24,6 +24,15 @@ function buildSearchQuery(r: Restaurant): string {
return r.name;
}
// 좌표 기반 한국 판정 (WGS84). KR bbox 대략 33~38.7°N, 124~132°E.
// 좌표 없으면 region 첫 토큰으로 fallback (구 데이터 호환).
function isKoreaRestaurant(r: Restaurant): boolean {
if (r.latitude != null && r.longitude != null) {
return r.latitude >= 33 && r.latitude <= 38.7 && r.longitude >= 124 && r.longitude <= 132;
}
return !r.region || r.region.split("|")[0] === "한국";
}
export default function RestaurantDetail({
restaurant,
onClose,
@@ -138,6 +147,26 @@ export default function RestaurantDetail({
)}
{restaurant.google_place_id && (
<p className="flex gap-3">
{isKoreaRestaurant(restaurant) ? (
<>
<a
href={`https://map.naver.com/p/search/${encodeURIComponent(buildSearchQuery(restaurant))}`}
target="_blank"
rel="noopener noreferrer"
className="text-green-600 dark:text-green-400 hover:underline text-xs"
>
</a>
<a
href={`https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(buildSearchQuery(restaurant))}`}
target="_blank"
rel="noopener noreferrer"
className="text-gray-500 dark:text-gray-400 hover:underline text-xs"
>
Google Maps
</a>
</>
) : (
<a
href={`https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(buildSearchQuery(restaurant))}`}
target="_blank"
@@ -146,15 +175,6 @@ export default function RestaurantDetail({
>
Google Maps에서
</a>
{(!restaurant.region || restaurant.region.split("|")[0] === "한국") && (
<a
href={`https://map.naver.com/v5/search/${encodeURIComponent(restaurant.name)}`}
target="_blank"
rel="noopener noreferrer"
className="text-green-600 dark:text-green-400 hover:underline text-xs"
>
</a>
)}
</p>
)}

View File

@@ -257,6 +257,9 @@ export default function ReviewSection({ restaurantId }: ReviewSectionProps) {
<>
<div className="flex items-center gap-2 mb-1">
{review.user_avatar_url && (
// eslint-disable-next-line @next/next/no-img-element
// #343 — Google avatar URL은 remotePatterns에 추가됨.
// next/image 전환은 SSR/lazy 효과 미미한 5x5 아바타라 후속에서 일괄 적용.
<img
src={review.user_avatar_url}
alt=""

View File

@@ -0,0 +1,56 @@
"use client";
import { NextIntlClientProvider } from "next-intl";
import { useEffect, useState } from "react";
import { DEFAULT_LOCALE, LOCALE_STORAGE_KEY, Locale, detectBrowserLocale, isLocale } from "./config";
// #352 — 메시지 파일을 정적 import (Tree-shaking 가능, 클라이언트 번들에 4언어 모두 포함되지만
// 30개 키 수준이라 부담 미미. 키가 늘어나면 동적 import로 분할 검토)
import ko from "@/messages/ko.json";
import en from "@/messages/en.json";
import ja from "@/messages/ja.json";
import es from "@/messages/es.json";
const MESSAGES: Record<Locale, typeof ko> = { ko, en, ja, es };
interface LocaleContextValue {
locale: Locale;
setLocale: (l: Locale) => void;
}
import { createContext, useContext } from "react";
const LocaleContext = createContext<LocaleContextValue | null>(null);
export function useLocale() {
const ctx = useContext(LocaleContext);
if (!ctx) throw new Error("useLocale must be used within LocaleProvider");
return ctx;
}
export function LocaleProvider({ children }: { children: React.ReactNode }) {
// SSR 단계는 기본 로케일로 시작 (hydration mismatch 방지)
const [locale, setLocaleState] = useState<Locale>(DEFAULT_LOCALE);
useEffect(() => {
if (typeof window === "undefined") return;
const saved = localStorage.getItem(LOCALE_STORAGE_KEY);
const initial: Locale = isLocale(saved) ? saved : detectBrowserLocale();
if (initial !== locale) setLocaleState(initial);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const setLocale = (l: Locale) => {
setLocaleState(l);
if (typeof window !== "undefined") {
localStorage.setItem(LOCALE_STORAGE_KEY, l);
}
};
return (
<LocaleContext.Provider value={{ locale, setLocale }}>
<NextIntlClientProvider locale={locale} messages={MESSAGES[locale]}>
{children}
</NextIntlClientProvider>
</LocaleContext.Provider>
);
}

View File

@@ -0,0 +1,28 @@
// #352 i18n 뼈대 — 로케일 목록/기본값
export const LOCALES = ["ko", "en", "ja", "es"] as const;
export type Locale = (typeof LOCALES)[number];
export const DEFAULT_LOCALE: Locale = "ko";
export const LOCALE_STORAGE_KEY = "tasteby_locale";
export const LOCALE_LABELS: Record<Locale, { flag: string; label: string; native: string }> = {
ko: { flag: "🇰🇷", label: "Korean", native: "한국어" },
en: { flag: "🇺🇸", label: "English", native: "English" },
ja: { flag: "🇯🇵", label: "Japanese", native: "日本語" },
es: { flag: "🇪🇸", label: "Spanish", native: "Español" },
};
export function isLocale(value: string | null | undefined): value is Locale {
return value != null && (LOCALES as readonly string[]).includes(value);
}
/**
* 브라우저 언어 감지 → 지원 로케일이면 그것, 아니면 기본값.
* SSR-safe (typeof window 체크 호출자).
*/
export function detectBrowserLocale(): Locale {
if (typeof navigator === "undefined") return DEFAULT_LOCALE;
const code = navigator.language.split("-")[0].toLowerCase();
return isLocale(code) ? code : DEFAULT_LOCALE;
}

View File

@@ -39,7 +39,7 @@ const SUB_ICON_RULES: { keyword: string; icon: string }[] = [
{ keyword: "라멘", icon: "ramen_dining" },
{ keyword: "돈카츠", icon: "lunch_dining" },
{ keyword: "텐동/튀김", icon: "tapas" },
{ keyword: "이자카야", icon: "sake" },
{ keyword: "이자카야", icon: "local_bar" },
{ keyword: "야키니쿠", icon: "kebab_dining" },
{ keyword: "카레", icon: "skillet" },
{ keyword: "소바/우동", icon: "ramen_dining" },
@@ -77,78 +77,78 @@ export function getCuisineIcon(cuisineType: string | null | undefined): string {
return CUISINE_ICON_MAP[main] || DEFAULT_ICON;
}
// ── Tabler Icons (for genre card chips) ──
// Returns Tabler icon component name (PascalCase without "Icon" prefix)
// ── Phosphor Icons (for genre card chips) ──
// Returns Phosphor icon component name (PascalCase)
const TABLER_CUISINE_MAP: Record<string, string> = {
"한식": "BowlChopsticks",
"일식": "Fish",
"중식": "Soup",
const PHOSPHOR_CUISINE_MAP: Record<string, string> = {
"한식": "BowlFood",
"일식": "FishSimple",
"중식": "Fire",
"양식": "Pizza",
"아시아": "BowlSpoon",
"아시아": "BowlSteam",
"기타": "Cookie",
};
const TABLER_SUB_RULES: { keyword: string; icon: string }[] = [
const PHOSPHOR_SUB_RULES: { keyword: string; icon: string }[] = [
// 한식
{ keyword: "백반/한정식", icon: "BowlChopsticks" },
{ keyword: "국밥/해장국", icon: "Soup" },
{ keyword: "찌개/전골/탕", icon: "Cooker" },
{ keyword: "삼겹살/돼지구이", icon: "Meat" },
{ keyword: "소고기/한우구이", icon: "Grill" },
{ keyword: "곱창/막창", icon: "GrillFork" },
{ keyword: "닭/오리구이", icon: "Meat" },
{ keyword: "족발/보쌈", icon: "Meat" },
{ keyword: "백반/한정식", icon: "BowlFood" },
{ keyword: "국밥/해장국", icon: "BowlSteam" },
{ keyword: "찌개/전골/탕", icon: "CookingPot" },
{ keyword: "삼겹살/돼지구이", icon: "food:pig" },
{ keyword: "소고기/한우구이", icon: "Cow" },
{ keyword: "곱창/막창", icon: "Flame" },
{ keyword: "닭/오리구이", icon: "Bird" },
{ keyword: "족발/보쌈", icon: "food:pig" },
{ keyword: "회/횟집", icon: "Fish" },
{ keyword: "해산물", icon: "Fish" },
{ keyword: "분식", icon: "EggFried" },
{ keyword: "면", icon: "BowlChopsticks" },
{ keyword: "죽/죽집", icon: "BowlSpoon" },
{ keyword: "순대/순대국", icon: "Soup" },
{ keyword: "장어/민물", icon: "Fish" },
{ keyword: "주점/포차", icon: "Beer" },
{ keyword: "파인다이닝/코스", icon: "GlassChampagne" },
{ keyword: "해산물", icon: "Shrimp" },
{ keyword: "분식", icon: "food:tteok" },
{ keyword: "면", icon: "food:noodle" },
{ keyword: "죽/죽집", icon: "BowlFood" },
{ keyword: "순대/순대국", icon: "BowlSteam" },
{ keyword: "장어/민물", icon: "FishSimple" },
{ keyword: "주점/포차", icon: "BeerStein" },
{ keyword: "파인다이닝/코스", icon: "Champagne" },
// 일식
{ keyword: "스시/오마카세", icon: "Fish" },
{ keyword: "라멘", icon: "Soup" },
{ keyword: "돈카츠", icon: "Meat" },
{ keyword: "텐동/튀김", icon: "EggFried" },
{ keyword: "이자카야", icon: "GlassCocktail" },
{ keyword: "야키니쿠", icon: "Grill" },
{ keyword: "카레", icon: "BowlSpoon" },
{ keyword: "소바/우동", icon: "BowlChopsticks" },
{ keyword: "라멘", icon: "food:noodle" },
{ keyword: "돈카츠", icon: "food:pig" },
{ keyword: "텐동/튀김", icon: "food:tempura" },
{ keyword: "이자카야", icon: "Martini" },
{ keyword: "야키니쿠", icon: "Flame" },
{ keyword: "카레", icon: "BowlFood" },
{ keyword: "소바/우동", icon: "food:noodle" },
// 중식
{ keyword: "중화요리", icon: "Soup" },
{ keyword: "중화요리", icon: "Fire" },
{ keyword: "마라/훠궈", icon: "Pepper" },
{ keyword: "딤섬/만두", icon: "Egg" },
{ keyword: "양꼬치", icon: "Grill" },
{ keyword: "양꼬치", icon: "Flame" },
// 양식
{ keyword: "파스타/이탈리안", icon: "BowlSpoon" },
{ keyword: "스테이크", icon: "Meat" },
{ keyword: "햄버거", icon: "Burger" },
{ keyword: "파스타/이탈리안", icon: "ForkKnife" },
{ keyword: "스테이크", icon: "Knife" },
{ keyword: "햄버거", icon: "Hamburger" },
{ keyword: "피자", icon: "Pizza" },
{ keyword: "프렌치", icon: "GlassChampagne" },
{ keyword: "바베큐", icon: "GrillSpatula" },
{ keyword: "브런치", icon: "EggFried" },
{ keyword: "비건/샐러드", icon: "Salad" },
{ keyword: "프렌치", icon: "Champagne" },
{ keyword: "바베큐", icon: "Flame" },
{ keyword: "브런치", icon: "Coffee" },
{ keyword: "비건/샐러드", icon: "Leaf" },
// 아시아
{ keyword: "베트남", icon: "BowlChopsticks" },
{ keyword: "베트남", icon: "food:noodle" },
{ keyword: "태국", icon: "Pepper" },
{ keyword: "인도/중동", icon: "BowlSpoon" },
{ keyword: "동남아기타", icon: "BowlSpoon" },
{ keyword: "인도/중동", icon: "BowlFood" },
{ keyword: "동남아기타", icon: "BowlFood" },
// 기타
{ keyword: "치킨", icon: "Meat" },
{ keyword: "치킨", icon: "Egg" },
{ keyword: "카페/디저트", icon: "Coffee" },
{ keyword: "베이커리", icon: "Bread" },
{ keyword: "뷔페", icon: "Cheese" },
{ keyword: "뷔페", icon: "ForkKnife" },
{ keyword: "퓨전", icon: "Cookie" },
];
export function getTablerCuisineIcon(cuisineType: string | null | undefined): string {
if (!cuisineType) return "Bowl";
for (const rule of TABLER_SUB_RULES) {
export function getPhosphorCuisineIcon(cuisineType: string | null | undefined): string {
if (!cuisineType) return "ForkKnife";
for (const rule of PHOSPHOR_SUB_RULES) {
if (cuisineType.includes(rule.keyword)) return rule.icon;
}
const main = cuisineType.split("|")[0];
return TABLER_CUISINE_MAP[main] || "Bowl";
return PHOSPHOR_CUISINE_MAP[main] || "ForkKnife";
}

View File

@@ -0,0 +1,41 @@
{
"header": {
"search": "Search",
"login": "Sign In",
"logout": "Sign Out",
"menu": "Menu",
"myReviews": "My Reviews",
"favorites": "Favorites"
},
"actions": {
"save": "Save",
"cancel": "Cancel",
"delete": "Delete",
"edit": "Edit",
"confirm": "OK",
"close": "Close",
"loading": "Loading...",
"submit": "Submit"
},
"filter": {
"title": "Filter",
"cuisine": "Cuisine",
"price": "Price Range",
"region": "Region",
"channel": "Channel",
"reset": "Reset"
},
"restaurant": {
"rating": "Rating",
"address": "Address",
"phone": "Phone",
"website": "Website",
"closed": "Permanently Closed",
"tempClosed": "Temporarily Closed"
},
"review": {
"title": "Reviews",
"write": "Write a Review",
"noReviews": "No reviews yet"
}
}

View File

@@ -0,0 +1,41 @@
{
"header": {
"search": "Buscar",
"login": "Iniciar sesión",
"logout": "Cerrar sesión",
"menu": "Menú",
"myReviews": "Mis reseñas",
"favorites": "Favoritos"
},
"actions": {
"save": "Guardar",
"cancel": "Cancelar",
"delete": "Eliminar",
"edit": "Editar",
"confirm": "Aceptar",
"close": "Cerrar",
"loading": "Cargando...",
"submit": "Enviar"
},
"filter": {
"title": "Filtro",
"cuisine": "Tipo de cocina",
"price": "Rango de precios",
"region": "Región",
"channel": "Canal",
"reset": "Restablecer"
},
"restaurant": {
"rating": "Calificación",
"address": "Dirección",
"phone": "Teléfono",
"website": "Sitio web",
"closed": "Cerrado permanentemente",
"tempClosed": "Cerrado temporalmente"
},
"review": {
"title": "Reseñas",
"write": "Escribir una reseña",
"noReviews": "Aún no hay reseñas"
}
}

View File

@@ -0,0 +1,41 @@
{
"header": {
"search": "検索",
"login": "ログイン",
"logout": "ログアウト",
"menu": "メニュー",
"myReviews": "マイレビュー",
"favorites": "お気に入り"
},
"actions": {
"save": "保存",
"cancel": "キャンセル",
"delete": "削除",
"edit": "編集",
"confirm": "確認",
"close": "閉じる",
"loading": "読み込み中...",
"submit": "送信"
},
"filter": {
"title": "フィルター",
"cuisine": "料理ジャンル",
"price": "価格帯",
"region": "地域",
"channel": "チャンネル",
"reset": "リセット"
},
"restaurant": {
"rating": "評価",
"address": "住所",
"phone": "電話",
"website": "ウェブサイト",
"closed": "閉店",
"tempClosed": "一時休業"
},
"review": {
"title": "レビュー",
"write": "レビューを書く",
"noReviews": "まだレビューがありません"
}
}

View File

@@ -0,0 +1,41 @@
{
"header": {
"search": "검색",
"login": "로그인",
"logout": "로그아웃",
"menu": "메뉴",
"myReviews": "내 리뷰",
"favorites": "즐겨찾기"
},
"actions": {
"save": "저장",
"cancel": "취소",
"delete": "삭제",
"edit": "수정",
"confirm": "확인",
"close": "닫기",
"loading": "로딩 중...",
"submit": "제출"
},
"filter": {
"title": "필터",
"cuisine": "음식 종류",
"price": "가격대",
"region": "지역",
"channel": "채널",
"reset": "초기화"
},
"restaurant": {
"rating": "평점",
"address": "주소",
"phone": "전화",
"website": "웹사이트",
"closed": "폐업",
"tempClosed": "임시휴업"
},
"review": {
"title": "리뷰",
"write": "리뷰 작성",
"noReviews": "아직 리뷰가 없습니다"
}
}

View File

@@ -9,9 +9,12 @@ type: Opaque
stringData:
ORACLE_USER: "<oracle-username>"
ORACLE_PASSWORD: "<oracle-password>"
ORACLE_DSN: "<tns-alias>_high?TNS_ADMIN=/etc/oracle/wallet"
ORACLE_DSN: "<tns-alias>_medium?TNS_ADMIN=/etc/oracle/wallet"
JWT_SECRET: "<jwt-secret>"
OCI_COMPARTMENT_ID: "<oci-compartment-id>"
OCI_CHAT_MODEL_ID: "<oci-chat-model-id>"
GOOGLE_MAPS_API_KEY: "<google-maps-api-key>"
YOUTUBE_DATA_API_KEY: "<youtube-data-api-key>"
# #357 — Naver Search API (선택). 미설정 시 DDG 폴백.
NAVER_CLIENT_ID: "<naver-client-id>"
NAVER_CLIENT_SECRET: "<naver-client-secret>"