Compare commits

..

7 Commits

Author SHA1 Message Date
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
16 changed files with 949 additions and 423 deletions

View File

@@ -4,8 +4,54 @@
--- ---
## 2026-06-16
### 🗺️ #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 ## 2026-06-15
### 🐛 캐치테이블 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) ### 📋 #358 RestaurantUpdateDTO + @Valid 표준화 (v0.1.45)
- dto/RestaurantUpdateDTO record 신규 (15 필드, 모두 nullable) - dto/RestaurantUpdateDTO record 신규 (15 필드, 모두 nullable)
- Bean Validation: @Size/@Pattern(URL or NONE)/@DecimalMin·Max/@Min·Max - Bean Validation: @Size/@Pattern(URL or NONE)/@DecimalMin·Max/@Min·Max

View File

@@ -62,6 +62,15 @@ public class AdminRestaurantController {
return Map.of("success", true, "id", id); 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 토글. * 어드민용 hidden 토글.
*/ */

View File

@@ -175,7 +175,7 @@ public class RestaurantController {
@PostMapping("/bulk-tabling") @PostMapping("/bulk-tabling")
public SseEmitter bulkTabling() { public SseEmitter bulkTabling() {
AuthUtil.requireAdmin(); AuthUtil.requireAdmin();
SseEmitter emitter = new SseEmitter(600_000L); SseEmitter emitter = new SseEmitter(10_800_000L); // 3h — 대량 백필 대응
executor.execute(() -> { executor.execute(() -> {
try { try {
@@ -252,8 +252,11 @@ public class RestaurantController {
if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND); if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND);
String url = body.get("tabling_url"); String url = body.get("tabling_url");
// #290 — javascript:/외부 악성 URL 차단. 빈 문자열은 매핑 해제로 허용. // #290 — javascript:/외부 악성 URL 차단. 빈 문자열은 매핑 해제로 허용.
if (url != null && !url.isBlank() && !url.startsWith("https://tabling.co.kr/")) { // Naver/DDG 결과가 www.tabling.co.kr 형태로도 옴.
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "테이블링 URL은 https://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 : "")); restaurantService.update(id, Map.of("tabling_url", url != null ? url : ""));
cache.flush(); cache.flush();
@@ -306,7 +309,7 @@ public class RestaurantController {
@PostMapping("/bulk-catchtable") @PostMapping("/bulk-catchtable")
public SseEmitter bulkCatchtable() { public SseEmitter bulkCatchtable() {
AuthUtil.requireAdmin(); AuthUtil.requireAdmin();
SseEmitter emitter = new SseEmitter(600_000L); SseEmitter emitter = new SseEmitter(10_800_000L); // 3h — 대량 백필 대응
executor.execute(() -> { executor.execute(() -> {
try { try {
@@ -420,9 +423,10 @@ public class RestaurantController {
} }
private List<Map<String, Object>> searchCatchtable(String restaurantName) { private List<Map<String, Object>> searchCatchtable(String restaurantName) {
// 실제 캐치테이블 URL은 /ct/shop/ 형식. 옛 /dining/ /shop/ 패턴은 매칭 실패.
return webSearch.search( return webSearch.search(
"site:app.catchtable.co.kr " + restaurantName, "site:app.catchtable.co.kr " + restaurantName,
"catchtable.co.kr/dining/", "catchtable.co.kr/shop/" "catchtable.co.kr/ct/shop/", "catchtable.co.kr/ct/dining/"
); );
} }

View File

@@ -39,6 +39,9 @@ public interface RestaurantMapper {
int countUnevaluatedLinks(); int countUnevaluatedLinks();
// #359 1단계 — google_place_id 중복 조회
List<Map<String, Object>> findDuplicatePlaceIdRows();
Restaurant findById(@Param("id") String id); Restaurant findById(@Param("id") String id);
List<Map<String, Object>> findVideoLinks(@Param("restaurantId") String restaurantId, List<Map<String, Object>> findVideoLinks(@Param("restaurantId") String restaurantId,

View File

@@ -111,6 +111,23 @@ public class RestaurantService {
return mapper.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) { public void update(String id, Map<String, Object> fields) {
mapper.updateFields(id, fields); mapper.updateFields(id, fields);
} }

View File

@@ -14,6 +14,7 @@ import java.net.http.HttpClient;
import java.net.http.HttpRequest; import java.net.http.HttpRequest;
import java.net.http.HttpResponse; import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.*; import java.util.*;
import java.util.regex.Matcher; import java.util.regex.Matcher;
import java.util.regex.Pattern; import java.util.regex.Pattern;
@@ -30,8 +31,10 @@ public class WebSearchService {
private static final Logger log = LoggerFactory.getLogger(WebSearchService.class); private static final Logger log = LoggerFactory.getLogger(WebSearchService.class);
private static final int MAX_RESULTS = 5; private static final int MAX_RESULTS = 5;
private static final Duration REQ_TIMEOUT = Duration.ofSeconds(15);
private static final HttpClient HTTP = HttpClient.newBuilder() private static final HttpClient HTTP = HttpClient.newBuilder()
.followRedirects(HttpClient.Redirect.NORMAL) .followRedirects(HttpClient.Redirect.NORMAL)
.connectTimeout(Duration.ofSeconds(5))
.build(); .build();
private static final Pattern DDG_RESULT = Pattern.compile( private static final Pattern DDG_RESULT = Pattern.compile(
@@ -74,6 +77,7 @@ public class WebSearchService {
String url = "https://openapi.naver.com/v1/search/webkr.json?query=" + encoded + "&display=30"; String url = "https://openapi.naver.com/v1/search/webkr.json?query=" + encoded + "&display=30";
HttpRequest req = HttpRequest.newBuilder() HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create(url)) .uri(URI.create(url))
.timeout(REQ_TIMEOUT)
.header("X-Naver-Client-Id", naverClientId) .header("X-Naver-Client-Id", naverClientId)
.header("X-Naver-Client-Secret", naverClientSecret) .header("X-Naver-Client-Secret", naverClientSecret)
.GET() .GET()
@@ -104,6 +108,7 @@ public class WebSearchService {
String url = "https://html.duckduckgo.com/html/?q=" + encoded; String url = "https://html.duckduckgo.com/html/?q=" + encoded;
HttpRequest req = HttpRequest.newBuilder() HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create(url)) .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("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", "text/html,application/xhtml+xml")
.header("Accept-Language", "ko-KR,ko;q=0.9") .header("Accept-Language", "ko-KR,ko;q=0.9")

View File

@@ -353,4 +353,21 @@
SELECT COUNT(*) FROM video_restaurants WHERE relevance_evaluated_at IS NULL SELECT COUNT(*) FROM video_restaurants WHERE relevance_evaluated_at IS NULL
</select> </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> </mapper>

View File

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

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,84 @@
# 설계서: 메인 지도 탭 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차 적용 후 결정.

View File

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

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

@@ -1,416 +1,37 @@
"use client"; "use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import GoogleMapView from "@/components/GoogleMapView";
import { import NaverMapView from "@/components/NaverMapView";
APIProvider, import { isKoreaCoord, type MapBounds, type FlyTo, type MapViewProps } from "@/components/MapView.types";
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 }; export type { MapBounds, FlyTo };
const API_KEY = process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY || "";
// Channel color palette const NAVER_KEY = process.env.NEXT_PUBLIC_NAVER_MAP_CLIENT_ID || "";
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>(); * #363 — 메인 지도 dispatcher.
restaurants.forEach((r) => r.channels?.forEach((ch) => channels.add(ch))); * - 키 미설정: GoogleMap (회귀 0)
const map: Record<string, typeof CHANNEL_COLORS[0]> = {}; * - flyTo 또는 selected 좌표가 KR bbox: NaverMap
let i = 0; * - 그 외 (해외 + 좌표 추정): GoogleMap
for (const ch of channels) { * - 초기 마운트 시 화면 중심을 추정할 수 없으면 식당 평균 좌표로.
map[ch] = CHANNEL_COLORS[i % CHANNEL_COLORS.length]; */
i++; 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 { function avgLat(rs: MapViewProps["restaurants"]): number | null {
north: number; if (!rs.length) return null;
south: number; return rs.reduce((s, r) => s + r.latitude, 0) / rs.length;
east: number;
west: number;
} }
function avgLng(rs: MapViewProps["restaurants"]): number | null {
export interface FlyTo { if (!rs.length) return null;
lat: number; return rs.reduce((s, r) => s + r.longitude, 0) / rs.length;
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>
);
} }

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

@@ -0,0 +1,225 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import Supercluster from "supercluster";
import type { Restaurant } from "@/lib/api";
import { getCuisineIcon } from "@/lib/cuisine-icons";
import Icon from "@/components/Icon";
import type { MapBounds, FlyTo, MapViewProps } from "@/components/MapView.types";
// ---- naver maps v3 타입 최소 정의 (full 타입은 @types/navermaps 없음) ----
declare global {
interface Window {
naver?: {
maps: NaverMaps;
};
}
}
type NaverMaps = {
LatLng: new (lat: number, lng: number) => unknown;
Map: new (el: HTMLElement, opts: Record<string, unknown>) => NaverMapInstance;
Event: { addListener: (target: unknown, type: string, fn: (...args: unknown[]) => void) => unknown };
Position?: unknown;
MapTypeControlStyle?: unknown;
ZoomControlStyle?: unknown;
};
type NaverMapInstance = {
setCenter: (latlng: unknown) => void;
setZoom: (zoom: number, useEffect?: boolean) => void;
getCenter: () => { lat: () => number; lng: () => number; x: number; y: number };
getZoom: () => number;
getBounds: () => { getNE: () => { lat: () => number; lng: () => number }; getSW: () => { lat: () => number; lng: () => number } };
getProjection: () => { fromCoordToOffset: (latlng: unknown) => { x: number; y: number } };
panTo: (latlng: unknown, opts?: Record<string, unknown>) => void;
destroy?: () => void;
};
const NAVER_CLIENT_ID = process.env.NEXT_PUBLIC_NAVER_MAP_CLIENT_ID || "";
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?ncpClientId=${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;
}
export default function NaverMapView({
restaurants,
selected,
onSelectRestaurant,
onBoundsChanged,
flyTo,
onMyLocation,
}: MapViewProps) {
const { ready, error } = useNaverMaps();
const divRef = useRef<HTMLDivElement | null>(null);
const mapRef = useRef<NaverMapInstance | null>(null);
const [bounds, setBounds] = useState<MapBounds | null>(null);
const [zoom, setZoom] = useState(13);
const { getClusters, getExpansionZoom } = useSupercluster(restaurants);
// 1) 지도 인스턴스 1회 생성
useEffect(() => {
if (!ready || !divRef.current || mapRef.current) return;
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;
const sync = () => {
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);
};
sync();
n.Event.addListener(m, "bounds_changed", sync);
n.Event.addListener(m, "zoom_changed", sync);
}, [ready, flyTo, selected, onBoundsChanged]);
// 2) 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]);
const clusters = useMemo(() => {
if (!bounds) return [];
return getClusters(bounds, zoom);
}, [bounds, zoom, getClusters]);
// 3) 좌표 → 화면 픽셀 변환 (네이버 projection)
const toScreen = useCallback((lat: number, lng: number) => {
const m = mapRef.current;
if (!m || !window.naver?.maps) return null;
const p = m.getProjection().fromCoordToOffset(new window.naver.maps.LatLng(lat, lng));
return p;
}, []);
if (error) return <div className="w-full h-full flex items-center justify-center text-xs text-red-500">{error} Google Map으로 .</div>;
if (!ready) return <div className="w-full h-full flex items-center justify-center text-xs text-gray-400"> </div>;
return (
<div className="relative w-full h-full">
<div ref={divRef} className="absolute inset-0" />
{/* 마커 overlay (좌표→픽셀 변환 + absolute positioned div) */}
{clusters.map((feature) => {
const [lng, lat] = feature.geometry.coordinates;
const pt = toScreen(lat, lng);
if (!pt) return null;
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 (
<button
key={`c-${cluster_id}`}
onClick={() => {
const z = Math.min(getExpansionZoom(cluster_id), 18);
const m = mapRef.current;
if (!m || !window.naver?.maps) return;
m.panTo(new window.naver.maps.LatLng(lat, lng));
m.setZoom(z, true);
}}
className="absolute -translate-x-1/2 -translate-y-1/2 rounded-full bg-brand-500/90 text-white font-semibold shadow-lg ring-2 ring-white"
style={{ left: pt.x, top: pt.y, width: size, height: size, fontSize: size > 44 ? 14 : 12 }}
>
{point_count}
</button>
);
}
const r = (feature.properties as RestaurantProps).restaurant;
const cuisineIcon = getCuisineIcon(r.cuisine_type);
const isSel = selected?.id === r.id;
return (
<button
key={r.id}
onClick={() => onSelectRestaurant?.(r)}
className={`absolute -translate-x-1/2 -translate-y-full rounded-full shadow-md ring-2 ring-white transition-transform ${isSel ? "scale-125 z-10" : ""}`}
style={{ left: pt.x, top: pt.y, width: 32, height: 32, background: "#f59e0b", color: "#78350f" }}
title={r.name}
>
<Icon name={cuisineIcon} size={18} />
</button>
);
})}
{/* 내 위치 버튼 */}
{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"
>
<Icon name="my-location" size={22} />
</button>
)}
</div>
);
}

View File

@@ -24,6 +24,15 @@ function buildSearchQuery(r: Restaurant): string {
return r.name; 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({ export default function RestaurantDetail({
restaurant, restaurant,
onClose, onClose,
@@ -138,6 +147,26 @@ export default function RestaurantDetail({
)} )}
{restaurant.google_place_id && ( {restaurant.google_place_id && (
<p className="flex gap-3"> <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 <a
href={`https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(buildSearchQuery(restaurant))}`} href={`https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(buildSearchQuery(restaurant))}`}
target="_blank" target="_blank"
@@ -146,15 +175,6 @@ export default function RestaurantDetail({
> >
Google Maps에서 Google Maps에서
</a> </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> </p>
)} )}