Compare commits

..

4 Commits

Author SHA1 Message Date
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
5 changed files with 134 additions and 53 deletions

View File

@@ -6,6 +6,18 @@
## 2026-06-16
### 🗺️ 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으로 교정

View File

@@ -40,9 +40,11 @@ public class AdminVideoRelevanceController {
@PostMapping("/all")
public Map<String, Object> verifyAll(@RequestParam(defaultValue = "10") int batchSize) {
var admin = AuthUtil.requireAdmin();
log.info("[ADMIN] {} triggered video-relevance verifyAll(batchSize={})", admin.getSubject(), batchSize);
int processed = relevanceService.verifyAll(batchSize);
return Map.of("processed", processed);
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")

View File

@@ -63,6 +63,16 @@ public class VideoRelevanceService {
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;

View File

@@ -82,3 +82,31 @@ MapView (dispatcher)
- 한 화면 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에 도입.

View File

@@ -5,33 +5,29 @@ 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";
import type { MapBounds, MapViewProps } from "@/components/MapView.types";
// ---- naver maps v3 타입 최소 정의 (full 타입은 @types/navermaps 없음) ----
declare global {
interface Window {
naver?: {
maps: NaverMaps;
};
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;
Size: new (w: number, h: number) => unknown;
};
type NaverMapInstance = {
setCenter: (latlng: unknown) => void;
setZoom: (zoom: number, useEffect?: boolean) => void;
getCenter: () => { lat: () => number; lng: () => number; x: number; y: number };
getCenter: () => { lat: () => number; lng: () => 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;
refresh: (noEffect?: boolean) => void;
setSize?: (size: unknown) => void;
};
const NAVER_CLIENT_ID = process.env.NEXT_PUBLIC_NAVER_MAP_CLIENT_ID || "";
@@ -41,10 +37,7 @@ function useNaverMaps(): { ready: boolean; error: string | null } {
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!NAVER_CLIENT_ID) {
setError("NEXT_PUBLIC_NAVER_MAP_CLIENT_ID 미설정");
return;
}
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) {
@@ -52,7 +45,6 @@ function useNaverMaps(): { ready: boolean; error: string | null } {
return;
}
const s = document.createElement("script");
// NCLOUD 신 정책: 파라미터는 ncpKeyId (옛 ncpClientId는 NAVER Developers용).
s.src = `https://oapi.map.naver.com/openapi/v3/maps.js?ncpKeyId=${NAVER_CLIENT_ID}`;
s.async = true;
s.dataset.naverMaps = "1";
@@ -110,38 +102,60 @@ export default function NaverMapView({
const mapRef = useRef<NaverMapInstance | 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) 지도 인스턴스 1회 생성
// 지도 1회 생성 — divRef 항상 마운트되므로 ready 시점에 안정적으로 잡힘
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);
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;
// 컨테이너 크기 변경(예: flex layout 안에서 첫 마운트 시 0×0 → 실제 크기) → refresh
const ro = new ResizeObserver(() => {
try { m.refresh(true); } catch { /* noop */ }
});
ro.observe(divRef.current);
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);
}
};
// 컨테이너 크기가 정해진 다음 sync (rAF로 다음 프레임)
requestAnimationFrame(() => {
try { m.refresh(true); } catch { /* noop */ }
sync();
});
n.Event.addListener(m, "bounds_changed", sync);
n.Event.addListener(m, "zoom_changed", sync);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
console.error("[NaverMap] init failed", e);
setInitError(msg);
}
}, [ready, flyTo, selected, onBoundsChanged]);
// 2) flyTo 변경 반영
// flyTo 변경 반영
useEffect(() => {
const m = mapRef.current;
if (!m || !flyTo || !window.naver?.maps) return;
@@ -154,22 +168,38 @@ export default function NaverMapView({
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;
try {
return m.getProjection().fromCoordToOffset(new window.naver.maps.LatLng(lat, lng));
} catch {
return null;
}
}, []);
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" />
{/* 지도 컨테이너 — 항상 마운트, 명시적 크기 보장 */}
<div
ref={divRef}
className="absolute inset-0"
style={{ width: "100%", height: "100%", backgroundColor: "#e5e7eb" }}
/>
{/* 마커 overlay (좌표→픽셀 변환 + absolute positioned div) */}
{/* 로딩/에러 overlay */}
{(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} GoogleMap로 fallback
</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>
)}
{/* 마커 overlay */}
{clusters.map((feature) => {
const [lng, lat] = feature.geometry.coordinates;
const pt = toScreen(lat, lng);
@@ -211,7 +241,6 @@ export default function NaverMapView({
);
})}
{/* 내 위치 버튼 */}
{onMyLocation && (
<button
onClick={onMyLocation}