diff --git a/apps/web/src/features/ai-route/api/use-walk-paths-query.test.tsx b/apps/web/src/features/ai-route/api/use-walk-paths-query.test.tsx new file mode 100644 index 0000000..873220f --- /dev/null +++ b/apps/web/src/features/ai-route/api/use-walk-paths-query.test.tsx @@ -0,0 +1,177 @@ +import type { ReactNode } from "react"; +import { + hashKey, + QueryClient, + QueryClientProvider, +} from "@tanstack/react-query"; +import { renderHook, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { envelopeResponse, errorEnvelope } from "@/test/envelope-response"; +import { queryWrapper } from "@/test/query-wrapper"; +import { ROUTE_POINTS, routePointOf } from "@/test/route-points"; +import type { ReceivedRequest } from "@/test/stub-fetch"; +import { stubFetch } from "@/test/stub-fetch"; +import { useWalkPathsQuery, walkPathsQueryKey } from "./use-walk-paths-query"; + +/** 서면 좌표쌍 — 키 결정성 단정용 (MVP 지역 부산 서면) */ +const SEGMENT = { + startLat: 35.1601, + startLng: 129.0621, + endLat: 35.1633, + endLng: 129.0668, +}; + +const walkResponse = (resolved: boolean[]) => + envelopeResponse({ + segments: resolved.map((isResolved) => ({ + resolved: isResolved, + path: isResolved ? [{ lat: 35.1601, lng: 129.0621 }] : null, + distanceMeters: isResolved ? 604 : null, + })), + }); + +/** 수신 요청이 실은 대조군(활성 쿼리)의 것인지 세그먼트 개수로 식별한다 */ +const sentSegmentCount = (received: ReceivedRequest) => + (received.body as { segments: unknown[] }).segments.length; + +/** + * 재시도 백오프를 0으로 눌러, 재시도가 일어났다면 **에러 확정 전에 요청 수로 잡히게** 한다. + * 전역 기본(QueryProvider)은 5xx를 2회 더 두드리므로 `retry: false`가 사라지면 + * 이 클라이언트에서 요청이 4회 기록된다 (L15, Q7). + */ +const retryObservableWrapper = () => { + const client = new QueryClient({ + defaultOptions: { queries: { retryDelay: 0 } }, + }); + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + /** 재시도가 모두 소진돼 실패가 **최종 상태**가 된 시점 */ + const settledAsError = () => + client.getQueryCache().getAll()[0]?.state.status === "error"; + return { wrapper, settledAsError }; +}; + +describe("walkPathsQueryKey — 세그먼트 좌표가 곧 결과 식별자 (L13)", () => { + it("좌표가 같은 세그먼트 목록은 같은 키를 만든다 (L13)", () => { + expect(hashKey(walkPathsQueryKey([SEGMENT]))).toBe( + hashKey(walkPathsQueryKey([{ ...SEGMENT }])), + ); + }); + + it("좌표가 다른 새 추천 결과는 다른 키를 만든다 — 이전 응답이 화면에 적용되지 않는다 (L13)", () => { + expect(hashKey(walkPathsQueryKey([SEGMENT]))).not.toBe( + hashKey(walkPathsQueryKey([{ ...SEGMENT, endLat: 35.17 }])), + ); + }); +}); + +describe("useWalkPathsQuery — walk-paths 조회 계약 (L14~L16, R6)", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("성공하면 봉투를 벗겨 세그먼트 목록을 그대로 반환한다 (L16)", async () => { + stubFetch(() => walkResponse([true, false])); + + const { result } = renderHook(() => useWalkPathsQuery(ROUTE_POINTS), { + wrapper: queryWrapper, + }); + + await waitFor(() => expect(result.current.segments).toBeDefined()); + expect(result.current.segments?.map((s) => s.resolved)).toEqual([ + true, + false, + ]); + expect(result.current.segments?.[0].distanceMeters).toBe(604); + }); + + it("지점이 1개 이하라 세그먼트가 없으면 요청이 나가지 않는다 (L14)", async () => { + const received = stubFetch(() => walkResponse([true, true])); + + // 같은 렌더에 활성 쿼리(대조군)를 함께 띄운다 — 대조군이 응답을 받은 시점이면 + // 비활성 쿼리의 요청도 나갔다면 이미 기록됐을 시점이다 (waitFor 즉시 통과 회피) + const { result } = renderHook( + () => ({ + target: useWalkPathsQuery([ROUTE_POINTS[0]]), + control: useWalkPathsQuery(ROUTE_POINTS), + }), + { wrapper: queryWrapper }, + ); + + await waitFor(() => expect(result.current.control.segments).toBeDefined()); + + expect(received).toHaveLength(1); + expect(sentSegmentCount(received[0])).toBe(2); // 대조군(지점 3개)의 요청뿐 + expect(result.current.target.segments).toBeUndefined(); + }); + + it("좌표가 한국 서비스 범위 밖이면 요청이 나가지 않는다 (L14, Q3)", async () => { + const received = stubFetch(() => walkResponse([true, true])); + const outside = [ + ROUTE_POINTS[0], + routePointOf(2, { lat: 41.2, lng: 129.05 }), + ]; + + const { result } = renderHook( + () => ({ + target: useWalkPathsQuery(outside), + control: useWalkPathsQuery(ROUTE_POINTS), + }), + { wrapper: queryWrapper }, + ); + + await waitFor(() => expect(result.current.control.segments).toBeDefined()); + + expect(received).toHaveLength(1); + expect(sentSegmentCount(received[0])).toBe(2); // 범위 밖 쿼리(세그먼트 1개)는 없다 + expect(result.current.target.segments).toBeUndefined(); + }); + + it("400(14402) 실패는 세그먼트 없음으로 조용히 흡수되고 재시도하지 않는다 (L15)", async () => { + const received = stubFetch(() => + errorEnvelope(14402, "세그먼트 좌표가 서비스 범위 밖입니다", 400), + ); + const { wrapper, settledAsError } = retryObservableWrapper(); + + const { result } = renderHook(() => useWalkPathsQuery(ROUTE_POINTS), { + wrapper, + }); + + // 실패가 최종 상태가 된 뒤에 센다 — 재시도가 켜져 있었다면 그 요청들도 이미 기록됐다 + await waitFor(() => expect(settledAsError()).toBe(true)); + expect(received).toHaveLength(1); + expect(result.current.segments).toBeUndefined(); + }); + + it("503(14504) 실패도 재시도 없이 세그먼트 없음으로 끝난다 (L15, Q7)", async () => { + const received = stubFetch(() => + errorEnvelope(14504, "보행 경로 기능이 꺼져 있습니다", 503), + ); + const { wrapper, settledAsError } = retryObservableWrapper(); + + const { result } = renderHook(() => useWalkPathsQuery(ROUTE_POINTS), { + wrapper, + }); + + await waitFor(() => expect(settledAsError()).toBe(true)); + expect(received).toHaveLength(1); + expect(result.current.segments).toBeUndefined(); + }); + + it("리렌더가 반복돼도 세그먼트 참조가 유지된다 — 오버레이 재게시가 연쇄하지 않는다 (R6)", async () => { + stubFetch(() => walkResponse([true, true])); + + const { result, rerender } = renderHook( + () => useWalkPathsQuery(ROUTE_POINTS), + { wrapper: queryWrapper }, + ); + + await waitFor(() => expect(result.current.segments).toBeDefined()); + const first = result.current.segments; + rerender(); + rerender(); + + expect(result.current.segments).toBe(first); + }); +}); diff --git a/apps/web/src/features/ai-route/api/use-walk-paths-query.ts b/apps/web/src/features/ai-route/api/use-walk-paths-query.ts new file mode 100644 index 0000000..c512610 --- /dev/null +++ b/apps/web/src/features/ai-route/api/use-walk-paths-query.ts @@ -0,0 +1,61 @@ +import { useMemo } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { unwrapEnvelope } from "@/shared/api/envelope"; +import { walkPaths } from "@/shared/api/generated/sdk.gen"; +import type { SegmentDto, WalkSegmentDto } from "@/shared/api/generated"; +import { + buildWalkSegments, + isWithinKoreaRange, + type RouteStopGeo, +} from "../model/route-legs"; + +/** + * 세그먼트 보행 경로 조회 (MSG-490 §4-2) — `POST /api/routes/walk-paths`. + * body가 필요해 POST일 뿐 **조회**라 mutation이 아니라 useQuery다: 결과가 바뀌면 키가 바뀌고 + * 이전 응답이 버려지며(경합 차단) 세션 동안 보관되는 계약이 필요하다. + * + * - **queryKey = 세그먼트 좌표 자체**: 서버 응답에 결과 id가 없어 좌표가 곧 결과 식별자다. + * 새 추천 결과는 다른 키를 만들고, 늦게 온 이전 응답은 그 키의 캐시에만 앉는다 (L13·S5) + * - **retry: false**(Q7): 조용한 폴백이 계약이라 503(14504)을 두 번 더 두드릴 이유가 없다. + * 실패는 던지지 않고 `segments: undefined`로 끝나며 소비자는 직선을 유지한다 (L15) + * - **staleTime·gcTime Infinity**(Q6): 섹션 왕복에도 재요청 0회. 로그아웃 시 QueryProvider가 + * 전량 clear하므로 보관 수명이 세션 경계와 일치한다 (S6) + * + * 지도 SDK를 import하지 않는다(RN 경계) — 좌표열은 순수 모델(route-legs·route-overlay)이 쓴다. + */ + +export const walkPathsQueryKey = (segments: SegmentDto[]) => + ["ai-route", "walk-paths", segments] as const; + +export const useWalkPathsQuery = ( + points: RouteStopGeo[], +): { segments: WalkSegmentDto[] | undefined } => { + // 방문 순서 정렬은 route-legs·route-overlay와 같은 규칙이라야 인덱스 대응이 맞는다 + const stops = useMemo( + () => + [...points] + .sort((a, b) => a.order - b.order) + .map(({ lat, lng }) => ({ lat, lng })), + [points], + ); + const segments = useMemo(() => buildWalkSegments(stops), [stops]); + + const { data } = useQuery({ + queryKey: walkPathsQueryKey(segments), + queryFn: async ({ signal }) => { + const response = await walkPaths({ + body: { segments }, + signal, + throwOnError: true, + }); + return unwrapEnvelope(response.data); + }, + // 좌표가 하나라도 한국 범위 밖이면 서버가 요청 전체를 400으로 거절한다 — 왕복 자체를 생략 (L14, Q3) + enabled: segments.length > 0 && isWithinKoreaRange(stops), + retry: false, + staleTime: Infinity, + gcTime: Infinity, + }); + + return { segments: data?.segments }; +}; diff --git a/apps/web/src/features/ai-route/model/route-legs.test.ts b/apps/web/src/features/ai-route/model/route-legs.test.ts index 7f96dce..ec03485 100644 --- a/apps/web/src/features/ai-route/model/route-legs.test.ts +++ b/apps/web/src/features/ai-route/model/route-legs.test.ts @@ -1,5 +1,11 @@ import { describe, expect, it } from "vitest"; -import { buildRouteLegs, formatWalkDistance } from "./route-legs"; +import type { WalkSegmentDto } from "@/shared/api/generated"; +import { + buildRouteLegs, + buildWalkSegments, + formatWalkDistance, + isWithinKoreaRange, +} from "./route-legs"; /** 서면 일대 실좌표 근사 — 이웃 간 수백 m 간격 (MVP 지역 부산 서면) */ const STOPS = [ @@ -47,3 +53,152 @@ describe("buildRouteLegs — 이웃 지점 직선 거리 구간 (L3)", () => { expect(buildRouteLegs([])).toEqual([]); }); }); + +/** 서면 일대 좌표만 쓰는 세그먼트 픽스처 헬퍼 (MVP 지역 부산 서면) */ +const coord = (index: number) => ({ + lat: 35.1568 + index * 0.0033, + lng: 129.0594 + index * 0.0027, +}); +const coords = (count: number) => + Array.from({ length: count }, (_, index) => coord(index)); + +/** walk-paths 응답 세그먼트 픽스처 — 해결/미해결만 구분하면 되는 자리 */ +const resolvedSegment = ( + distance: number, + path: { lat: number; lng: number }[] = [], +): WalkSegmentDto => ({ + resolved: true, + path, + distanceMeters: distance, +}); +const unresolvedSegment = (): WalkSegmentDto => ({ + resolved: false, + path: null, + distanceMeters: null, +}); + +describe("buildWalkSegments — walk-paths 요청 DTO 변환 (L1·L2)", () => { + it("이웃 좌표쌍마다 세그먼트 하나를 순서대로 만든다 (L1)", () => { + const segments = buildWalkSegments(coords(3)); + + expect(segments).toHaveLength(2); + expect(segments[0]).toEqual({ + startLat: coord(0).lat, + startLng: coord(0).lng, + endLat: coord(1).lat, + endLng: coord(1).lng, + }); + expect(segments[1].startLat).toBe(coord(1).lat); + expect(segments[1].endLat).toBe(coord(2).lat); + }); + + it("좌표가 1개 이하면 세그먼트를 만들지 않는다 (L1, 경계)", () => { + expect(buildWalkSegments(coords(1))).toEqual([]); + expect(buildWalkSegments([])).toEqual([]); + }); + + it("좌표가 9개(출발지 1 + 지점 8)면 세그먼트가 8개이고 첫 세그먼트가 첫 좌표에서 출발한다 (L2)", () => { + const segments = buildWalkSegments(coords(9)); + + expect(segments).toHaveLength(8); + expect(segments[0].startLat).toBe(coord(0).lat); + expect(segments[0].endLat).toBe(coord(1).lat); + }); + + it("좌표가 9개를 넘으면 앞에서 8개 세그먼트만 만든다 — 서버 400 방어 (L2, Q10)", () => { + const segments = buildWalkSegments(coords(12)); + + expect(segments).toHaveLength(8); + expect(segments[0].startLat).toBe(coord(0).lat); + expect(segments[7].endLat).toBe(coord(8).lat); + }); +}); + +describe("isWithinKoreaRange — 확정 실패 요청 사전 차단 (L3, Q3)", () => { + it("모든 좌표가 위도 33~39·경도 124~132 안이면 true다 (L3)", () => { + expect(isWithinKoreaRange(coords(3))).toBe(true); + }); + + it("좌표가 하나라도 범위 밖이면 false다 (L3)", () => { + expect(isWithinKoreaRange([coord(0), { lat: 41.2, lng: 129.0 }])).toBe( + false, + ); + expect(isWithinKoreaRange([coord(0), { lat: 35.15, lng: 139.7 }])).toBe( + false, + ); + }); +}); + +describe("buildRouteLegs — walk-paths 실보행 거리 반영 (L4~L8·L11)", () => { + it("walk를 주지 않으면 직선 거리 결과에 resolved:false만 실린다 (L4, 회귀 고정)", () => { + const legs = buildRouteLegs(STOPS); + + expect(legs.map((leg) => leg.resolved)).toEqual([false, false]); + expect(Math.round(legs[0].meters)).toBe(441); + expect(legs[0].label).toBe("도보 약 440m"); + }); + + it("resolved:true 구간은 서버 실거리와 그 표기, resolved:true를 싣는다 (L5)", () => { + const legs = buildRouteLegs(STOPS, { + segments: [resolvedSegment(604), resolvedSegment(1234)], + }); + + expect(legs[0].meters).toBe(604); + expect(legs[0].label).toBe("도보 약 600m"); + expect(legs[0].resolved).toBe(true); + }); + + it("실거리도 직선과 같은 formatWalkDistance 규칙으로 적는다 (L8, Q1)", () => { + const legs = buildRouteLegs(STOPS, { + segments: [resolvedSegment(604), resolvedSegment(1234)], + }); + + expect(legs[1].label).toBe("도보 약 1.2km"); + }); + + it("resolved:false·distanceMeters null·대응 세그먼트 없음은 직선 거리와 resolved:false다 (L5)", () => { + const nullDistance: WalkSegmentDto = { + resolved: true, + path: [], + distanceMeters: null, + }; + + const legs = buildRouteLegs(STOPS, { + segments: [unresolvedSegment(), nullDistance], + }); + + expect(legs.map((leg) => leg.resolved)).toEqual([false, false]); + expect(Math.round(legs[0].meters)).toBe(441); + expect(legs[0].label).toBe("도보 약 440m"); + }); + + it("부분 해결이면 실거리 구간과 직선 구간이 섞여 나온다 (L6)", () => { + const legs = buildRouteLegs(STOPS, { + segments: [resolvedSegment(604), unresolvedSegment()], + }); + + expect(legs.map((leg) => leg.resolved)).toEqual([true, false]); + expect(legs[0].meters).toBe(604); + expect(Math.round(legs[1].meters)).not.toBe(604); + }); + + it("originOffset이 1이면 leg i가 segments[i+1]에 대응한다 — 출발지 구간을 건너뛴다 (L7)", () => { + const legs = buildRouteLegs(STOPS, { + segments: [ + resolvedSegment(111), + resolvedSegment(604), + resolvedSegment(1234), + ], + originOffset: 1, + }); + + expect(legs.map((leg) => leg.meters)).toEqual([604, 1234]); + }); + + it("응답 세그먼트 개수가 요청 개수와 다르면 walk 결과를 통째로 버린다 (L11, Q9)", () => { + const legs = buildRouteLegs(STOPS, { segments: [resolvedSegment(604)] }); + + expect(legs.map((leg) => leg.resolved)).toEqual([false, false]); + expect(Math.round(legs[0].meters)).toBe(441); + }); +}); diff --git a/apps/web/src/features/ai-route/model/route-legs.ts b/apps/web/src/features/ai-route/model/route-legs.ts index 9124f20..80297d6 100644 --- a/apps/web/src/features/ai-route/model/route-legs.ts +++ b/apps/web/src/features/ai-route/model/route-legs.ts @@ -1,29 +1,58 @@ -import { distanceMeters } from "@/entities/cell"; -import type { RoutePointDto } from "@/shared/api/generated"; +import { distanceMeters, type LatLng } from "@/entities/cell"; +import type { + RoutePointDto, + SegmentDto, + WalkSegmentDto, +} from "@/shared/api/generated"; /** - * 구간(이웃 지점 쌍) 거리 파생 (MSG-488 L3). + * 구간(이웃 지점 쌍) 거리 파생 (MSG-488 L3 · MSG-490 L1~L8·L11). * 순수 함수 — 지도 SDK·플랫폼에 의존하지 않는다(RN 재사용 대상). * - * 이 티켓의 거리는 **이웃 좌표 직선(하버사인)** 근사다. 실보행 경로·거리는 MSG-490이 - * `POST /api/routes/walk-paths`로 교체하며, 직선은 그때 폴백으로 남는다. + * 거리 원본은 두 층이다: walk-paths(`POST /api/routes/walk-paths`)가 준 **실보행 거리**가 + * 있으면 그것을, 없거나 미해결이면 **이웃 좌표 직선(하버사인)** 근사를 쓴다. 응답이 + * 오기 전·요청 실패는 후자로 조용히 남는다(MSG-490 §1-2 — 에러 UI 없음). + * + * Hermes 미구현 API 금지 구역이다 — `toSorted`·`toReversed`·`toSpliced`·`Object.groupBy`· + * `structuredClone`을 쓰지 않는다(`[...arr].sort()` 유지, MSG-427 실기 크래시). */ /** 구간 거리 계산 입력 — 방문 순서와 좌표만 쓴다 */ -type RouteStopGeo = Pick; +export type RouteStopGeo = Pick; + +/** 서버가 거절하는 세그먼트 상한 (9개 이상이면 400 + 14402) */ +const MAX_WALK_SEGMENTS = 8; + +/** 한국 서비스 범위 — 이 밖이면 서버가 요청 전체를 400(14402)으로 거절한다 */ +const KOREA_LAT_RANGE = [33, 39] as const; +const KOREA_LNG_RANGE = [124, 132] as const; export interface RouteLeg { fromOrder: number; toOrder: number; - /** 직선 거리(m) — 표기 반올림 전 원값 */ + /** 구간 거리(m) — 표기 반올림 전 원값 (실보행 거리 또는 직선 근사) */ meters: number; /** 커넥터 행 문구 */ label: string; + /** 실보행 거리로 채워졌는지 — false면 직선 근사 폴백 (표기 구분은 아직 없다, Q2) */ + resolved: boolean; +} + +/** walk-paths 응답 주입 — 세그먼트는 요청과 같은 개수·같은 순서다 */ +export interface WalkPathInput { + segments: WalkSegmentDto[]; + /** + * 카드 사이 구간보다 앞서는 세그먼트 수 — 출발지→1번 구간이 있으면 1이다. + * [MSG-489 확장점] 출발지가 스토어에 생기면 소비 훅 2곳이 이 값을 넘긴다(§8 R2). + */ + originOffset?: number; } /** - * 도보 거리 표기 (승인 Q6) — 1000m 미만은 10m 반올림 m, 1000m 이상은 소수 1자리 km. + * 도보 거리 표기 (승인 Q6·MSG-490 Q1) — 1000m 미만은 10m 반올림 m, 1000m 이상은 소수 1자리 km. * 반올림을 먼저 하므로 999.6m는 "1000m"가 아니라 "1.0km"로 넘어간다. + * 실보행 거리와 직선 근사가 **같은 규칙**을 쓴다 — 같은 자리에 번갈아 뜨는 값이라 + * 표기가 갈리면 사용자가 값 변화를 폴백으로 오해한다. */ export const formatWalkDistance = (meters: number): string => { const rounded = Math.round(meters / 10) * 10; @@ -32,20 +61,75 @@ export const formatWalkDistance = (meters: number): string => { : `도보 약 ${(rounded / 1000).toFixed(1)}km`; }; -/** 방문 순서대로 이웃 쌍마다 구간 하나 — 지점이 1개 이하면 구간이 없다 (L3) */ -export const buildRouteLegs = (points: RouteStopGeo[]): RouteLeg[] => { +/** + * walk-paths 요청 세그먼트 — 방문 순서대로 이웃 좌표쌍 하나씩 (L1·L2). + * 좌표가 1개 이하면 빈 배열이고, 세그먼트가 상한을 넘으면 앞에서 8개만 남긴다 + * (서버 400을 부르는 입력을 FE가 만들지 않는다, Q10). + */ +export const buildWalkSegments = (stops: LatLng[]): SegmentDto[] => + stops.slice(1, MAX_WALK_SEGMENTS + 1).map((to, index) => ({ + startLat: stops[index].lat, + startLng: stops[index].lng, + endLat: to.lat, + endLng: to.lng, + })); + +/** 한국 서비스 범위 판정 — 하나라도 벗어나면 요청 자체를 스킵한다 (L3, Q3) */ +export const isWithinKoreaRange = (stops: LatLng[]): boolean => + stops.every( + ({ lat, lng }) => + lat >= KOREA_LAT_RANGE[0] && + lat <= KOREA_LAT_RANGE[1] && + lng >= KOREA_LNG_RANGE[0] && + lng <= KOREA_LNG_RANGE[1], + ); + +/** + * 구간별 walk 세그먼트 정렬 — 반환 배열의 index i가 구간 i에 대응한다 (L7·L11). + * 응답 개수가 요청 개수와 다르면 **통째로 버린다**(null) — 부분 대응은 어느 인덱스가 + * 밀렸는지 알 수 없어 엉뚱한 구간에 남의 경로를 그린다(Q9). + */ +export const alignWalkSegments = ( + legCount: number, + walk?: WalkPathInput, +): WalkSegmentDto[] | null => { + if (!walk) return null; + const offset = walk.originOffset ?? 0; + const requested = Math.min(legCount + offset, MAX_WALK_SEGMENTS); + if (walk.segments.length !== requested) return null; + return walk.segments.slice(offset); +}; + +/** + * 방문 순서대로 이웃 쌍마다 구간 하나 — 지점이 1개 이하면 구간이 없다 (L3). + * `walk`가 없으면 MSG-488과 같은 직선 결과다 (L4 회귀 고정). + */ +export const buildRouteLegs = ( + points: RouteStopGeo[], + walk?: WalkPathInput, +): RouteLeg[] => { const ordered = [...points].sort((a, b) => a.order - b.order); + const segments = alignWalkSegments(ordered.length - 1, walk); + return ordered.slice(1).map((to, index) => { const from = ordered[index]; - const meters = distanceMeters( - { lat: from.lat, lng: from.lng }, - { lat: to.lat, lng: to.lng }, - ); + const segment = segments?.[index]; + const walked = + segment?.resolved === true && segment.distanceMeters !== null + ? segment.distanceMeters + : null; + const meters = + walked ?? + distanceMeters( + { lat: from.lat, lng: from.lng }, + { lat: to.lat, lng: to.lng }, + ); return { fromOrder: from.order, toOrder: to.order, meters, label: formatWalkDistance(meters), + resolved: walked !== null, }; }); }; diff --git a/apps/web/src/features/ai-route/model/route-overlay.test.ts b/apps/web/src/features/ai-route/model/route-overlay.test.ts index d9da358..4c7f75c 100644 --- a/apps/web/src/features/ai-route/model/route-overlay.test.ts +++ b/apps/web/src/features/ai-route/model/route-overlay.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { palette } from "@fillmap/design-tokens"; +import type { WalkSegmentDto } from "@/shared/api/generated"; import { ROUTE_POINTS, routePointOf } from "@/test/route-points"; import { AI_ROUTE_OVERLAY_ID, buildAiRouteOverlay } from "./route-overlay"; @@ -67,3 +68,111 @@ describe("buildAiRouteOverlay — 지도 게시 오버레이 파생 (L6)", () => }); }); }); + +/** 서면 일대 보행 좌표열 — 두 지점 사이를 도로 따라 굽어 가는 형태의 근사 */ +const walkPath = (from: { lat: number; lng: number }, to: typeof from) => [ + { lat: from.lat, lng: from.lng }, + { lat: from.lat + 0.0005, lng: from.lng + 0.0018 }, + { lat: to.lat, lng: to.lng }, +]; +const resolvedSegment = ( + from: { lat: number; lng: number }, + to: { lat: number; lng: number }, +): WalkSegmentDto => ({ + resolved: true, + path: walkPath(from, to), + distanceMeters: 604, +}); +/** 미해결 세그먼트 — 그 구간만 직선으로 남는다 (사양, §8 오탐 9) */ +const UNRESOLVED: WalkSegmentDto = { + resolved: false, + path: null, + distanceMeters: null, +}; +const straightPath = ROUTE_POINTS.map(({ lat, lng }) => ({ lat, lng })); + +describe("buildAiRouteOverlay — walk-paths 실보행 폴리라인 합성 (L9~L12)", () => { + it("walk를 주지 않으면 order 순 직선 path 그대로다 (L10, 회귀 고정)", () => { + const { routes } = buildAiRouteOverlay(ROUTE_POINTS, []); + + expect(routes[0].path).toEqual(straightPath); + }); + + it("전 세그먼트가 미해결이면 path가 order 순 직선과 같다 (L10)", () => { + const { routes } = buildAiRouteOverlay(ROUTE_POINTS, [], null, { + segments: [UNRESOLVED, UNRESOLVED], + }); + + expect(routes[0].path).toEqual(straightPath); + }); + + it("resolved 세그먼트의 보행 좌표열을 이어 붙이고 접점 중복 좌표는 하나로 합친다 (L9, Q8)", () => { + const first = resolvedSegment(ROUTE_POINTS[0], ROUTE_POINTS[1]); + const second = resolvedSegment(ROUTE_POINTS[1], ROUTE_POINTS[2]); + + const { routes } = buildAiRouteOverlay(ROUTE_POINTS, [], null, { + segments: [first, second], + }); + + expect(routes[0].path).toEqual([ + ...walkPath(ROUTE_POINTS[0], ROUTE_POINTS[1]), + ...walkPath(ROUTE_POINTS[1], ROUTE_POINTS[2]).slice(1), + ]); + }); + + it("부분 해결이면 해결 구간은 보행 좌표열, 미해결 구간은 두 끝점 직선으로 한 줄에 이어진다 (L9, S3)", () => { + const { routes } = buildAiRouteOverlay(ROUTE_POINTS, [], null, { + segments: [resolvedSegment(ROUTE_POINTS[0], ROUTE_POINTS[1]), UNRESOLVED], + }); + + expect(routes[0].path).toEqual([ + ...walkPath(ROUTE_POINTS[0], ROUTE_POINTS[1]), + { lat: ROUTE_POINTS[2].lat, lng: ROUTE_POINTS[2].lng }, + ]); + }); + + it("resolved:true인데 path가 빈 배열이면 그 구간은 두 끝점 직선으로 폴백한다 (L9, PR #106 리뷰)", () => { + const emptyPath: WalkSegmentDto = { + resolved: true, + path: [], + distanceMeters: 604, + }; + + const { routes } = buildAiRouteOverlay(ROUTE_POINTS, [], null, { + segments: [resolvedSegment(ROUTE_POINTS[0], ROUTE_POINTS[1]), emptyPath], + }); + + // 마지막 구간이 통째로 빠지면 선이 3번 마커에 닿지 않는다 — 끝점까지 반드시 그린다 + expect(routes[0].path).toEqual([ + ...walkPath(ROUTE_POINTS[0], ROUTE_POINTS[1]), + { lat: ROUTE_POINTS[2].lat, lng: ROUTE_POINTS[2].lng }, + ]); + }); + + it("응답 세그먼트 개수가 요청 개수와 다르면 walk 결과를 통째로 버리고 직선으로 되돌린다 (L11, Q9)", () => { + const { routes } = buildAiRouteOverlay(ROUTE_POINTS, [], null, { + segments: [resolvedSegment(ROUTE_POINTS[0], ROUTE_POINTS[1])], + }); + + expect(routes[0].path).toEqual(straightPath); + }); + + it("부분 해결이어도 번호 경유지와 격자 셀은 MSG-488과 동일하다 (L12)", () => { + const base = buildAiRouteOverlay(ROUTE_POINTS, [ROUTE_POINTS[1].gridId], 2); + + const withWalk = buildAiRouteOverlay( + ROUTE_POINTS, + [ROUTE_POINTS[1].gridId], + 2, + { + segments: [ + resolvedSegment(ROUTE_POINTS[0], ROUTE_POINTS[1]), + UNRESOLVED, + ], + }, + ); + + expect(withWalk.routes[0].waypoints).toEqual(base.routes[0].waypoints); + expect(withWalk.cells).toEqual(base.cells); + }); +}); diff --git a/apps/web/src/features/ai-route/model/route-overlay.ts b/apps/web/src/features/ai-route/model/route-overlay.ts index 8b2f7e6..4b18462 100644 --- a/apps/web/src/features/ai-route/model/route-overlay.ts +++ b/apps/web/src/features/ai-route/model/route-overlay.ts @@ -1,17 +1,22 @@ import { palette } from "@fillmap/design-tokens"; -import { decodeGridCorners } from "@/entities/cell"; +import { decodeGridCorners, type LatLng } from "@/entities/cell"; import type { RouteOverlay, StyledCellOverlay, } from "@/features/map-home/model/theme-overlay"; import type { RoutePointDto } from "@/shared/api/generated"; +import { alignWalkSegments, type WalkPathInput } from "./route-legs"; /** * 추천 지점 → 지도 게시 오버레이 파생 (MSG-488 L6). * 순수 함수 — 렌더(naver Polyline·Marker·Polygon)는 MapCanvas 경계 안에서만 한다(RN 경계, R7). * 오버레이 타입 3종은 `map-overlay-store`와 같은 계약(theme-overlay)을 type-only로 쓴다(Q4). * - * [MSG-490 확장점] `path`가 walk-paths 실보행 폴리라인으로 교체된다 — 직선은 폴백으로 남는다. + * `path`는 walk-paths 실보행 좌표열(MSG-490 L9)과 직선 폴백이 섞인 **한 줄**이다 — 해결된 + * 세그먼트만 도로를 따라 굽고 나머지는 두 끝점 직선으로 남는다. 응답 전·요청 실패는 + * MSG-488과 같은 order 순 직선이다(L10 회귀 고정). + * + * Hermes 미구현 API 금지 구역이다(RN 이식 대상 — `[...arr].sort()` 유지, MSG-427). */ /** 게시 경로 오버레이 id — AI 추천은 항상 한 줄이라 고정 id다 (코스 목록과 달리 다중 아님) */ @@ -28,6 +33,46 @@ export interface AiRouteOverlay { cells: StyledCellOverlay[]; } +/** 마지막 점과 같은 좌표는 싣지 않는다 — 세그먼트 접점 중복 1개를 합치는 자리 (Q8) */ +const pushPoint = (path: LatLng[], point: LatLng) => { + const last = path[path.length - 1]; + if (last && last.lat === point.lat && last.lng === point.lng) return; + path.push(point); +}; + +/** + * 경로선 정점 목록 (L9·L10) — 해결된 세그먼트는 보행 좌표열, 나머지는 두 끝점 직선. + * walk가 없거나 개수 계약이 깨졌으면 order 순 직선 그대로다. + */ +const buildRoutePath = ( + ordered: RouteStopGeometry[], + walk?: WalkPathInput, +): LatLng[] => { + const segments = alignWalkSegments(ordered.length - 1, walk); + if (segments === null || ordered.length < 2) { + return ordered.map(({ lat, lng }) => ({ lat, lng })); + } + + const path: LatLng[] = []; + ordered.slice(1).forEach((to, index) => { + const from = ordered[index]; + const segment = segments[index]; + // 빈 path는 계약 위반이지만 그 구간이 통째로 빠지면 끝점 마커에 선이 안 닿는다 — + // route-legs의 distanceMeters null 방어와 같은 수준으로 직선 폴백 (PR #106 리뷰) + if ( + segment?.resolved === true && + segment.path !== null && + segment.path.length > 0 + ) { + for (const { lat, lng } of segment.path) pushPoint(path, { lat, lng }); + return; + } + pushPoint(path, { lat: from.lat, lng: from.lng }); + pushPoint(path, { lat: to.lat, lng: to.lng }); + }); + return path; +}; + /** * 경로선 + 번호 경유지 + 지점 격자 초록 틴트 (L6). * - 지점이 없으면 둘 다 빈 배열이다 — 이전 표시가 걷힌다 (Q10) @@ -38,6 +83,7 @@ export const buildAiRouteOverlay = ( points: RouteStopGeometry[], occupiedGridIds: string[], selectedOrder: number | null = null, + walk?: WalkPathInput, ): AiRouteOverlay => { if (points.length === 0) return { routes: [], cells: [] }; @@ -61,7 +107,7 @@ export const buildAiRouteOverlay = ( routes: [ { id: AI_ROUTE_OVERLAY_ID, - path: ordered.map(({ lat, lng }) => ({ lat, lng })), + path: buildRoutePath(ordered, walk), waypoints: ordered.map(({ order, lat, lng }) => ({ seq: order, position: { lat, lng }, diff --git a/apps/web/src/pages/ai-route/ui/use-ai-route-overlay-publish.ts b/apps/web/src/pages/ai-route/ui/use-ai-route-overlay-publish.ts index cfd9425..ccffd4a 100644 --- a/apps/web/src/pages/ai-route/ui/use-ai-route-overlay-publish.ts +++ b/apps/web/src/pages/ai-route/ui/use-ai-route-overlay-publish.ts @@ -1,4 +1,5 @@ import { useEffect, useMemo } from "react"; +import { useWalkPathsQuery } from "@/features/ai-route/api/use-walk-paths-query"; import { buildAiRouteOverlay } from "@/features/ai-route/model/route-overlay"; import { useMapOverlayStore } from "@/widgets/map-shell/map-overlay-store"; import type { RoutePointDto } from "@/shared/api/generated"; @@ -8,6 +9,10 @@ import type { RoutePointDto } from "@/shared/api/generated"; * **뷰-레이어 훅** — 게시 스토어에 바로 배선하므로 RN 재사용 대상이 아니다. * 파생은 순수 함수(route-overlay)가 하고, 렌더는 MapCanvas 경계 안에서만 한다(R7). * 언마운트(섹션 이탈) 시 clear로 걷고, 재진입 시 같은 게시가 재실행돼 표시가 복원된다 (S11). + * + * walk-paths 실보행 좌표열도 여기서 합성한다 (MSG-490 §7 Q4) — `buildAiRouteOverlay`를 부르는 + * 곳이 이 훅 하나이고 순수 함수는 스스로 쿼리를 부를 수 없다. 응답 전에는 직선이 그려지고 + * 응답이 오면 같은 오버레이 id로 정점 목록만 바뀐다(재마운트 없는 교체, S1). */ interface AiRouteOverlayPublishInput { points: RoutePointDto[]; @@ -31,9 +36,16 @@ export const useAiRouteOverlayPublish = ({ ); const clearOverlays = useMapOverlayStore((s) => s.clear); + const { segments } = useWalkPathsQuery(points); + + // 래퍼 객체를 매 렌더 새로 만들면 게시 useEffect까지 연쇄로 재실행돼 오버레이가 + // clear→재게시로 깜빡인다 (§8 R6 — PR #104가 잡은 자리) + // [MSG-489 확장점] origin이 스토어에 생기면 `originOffset: 1`을 함께 넘긴다 (§8 R2) + const walk = useMemo(() => (segments ? { segments } : undefined), [segments]); + const overlay = useMemo( - () => buildAiRouteOverlay(points, occupiedGridIds, selectedOrder), - [points, occupiedGridIds, selectedOrder], + () => buildAiRouteOverlay(points, occupiedGridIds, selectedOrder, walk), + [points, occupiedGridIds, selectedOrder, walk], ); useEffect(() => { diff --git a/apps/web/src/pages/ai-route/ui/use-route-legs.ts b/apps/web/src/pages/ai-route/ui/use-route-legs.ts index c583834..1d7ef7a 100644 --- a/apps/web/src/pages/ai-route/ui/use-route-legs.ts +++ b/apps/web/src/pages/ai-route/ui/use-route-legs.ts @@ -1,4 +1,5 @@ import { useMemo } from "react"; +import { useWalkPathsQuery } from "@/features/ai-route/api/use-walk-paths-query"; import { buildRouteLegs, type RouteLeg, @@ -6,9 +7,18 @@ import { import type { RoutePointDto } from "@/shared/api/generated"; /** - * 구간 목록 훅 — 카드 사이 "도보 약 Nm" 커넥터의 데이터원. + * 구간 목록 훅 — 카드 사이 "도보 약 Nm" 커넥터의 데이터원 (MSG-490 §4-2). + * 실보행 거리가 오기 전·요청 실패에는 `segments`가 undefined라 직선 근사가 그대로 남는다 + * (점진 렌더가 공짜인 이유 — 별도 상태 전이·타이머가 없다). * - * [MSG-490 확장점] 구간 거리 소스 — 지금은 직선(route-legs), 실보행 경로로 교체된다. + * 같은 `points`로 오버레이 게시 훅도 같은 쿼리를 부르지만 queryKey가 같아 실요청은 1회다(Q5). */ -export const useRouteLegs = (points: RoutePointDto[]): RouteLeg[] => - useMemo(() => buildRouteLegs(points), [points]); +export const useRouteLegs = (points: RoutePointDto[]): RouteLeg[] => { + const { segments } = useWalkPathsQuery(points); + + // 래퍼 객체를 매 렌더 새로 만들면 아래 useMemo가 매번 깨진다 (§8 R6 — PR #104가 잡은 자리) + // [MSG-489 확장점] origin이 스토어에 생기면 `originOffset: 1`을 함께 넘긴다 (§8 R2) + const walk = useMemo(() => (segments ? { segments } : undefined), [segments]); + + return useMemo(() => buildRouteLegs(points, walk), [points, walk]); +}; diff --git a/docs/STATUS.md b/docs/STATUS.md index 1eb8181..28b9820 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -53,7 +53,7 @@ - **notifications** (MSG-408 신설) — config: `firebase`(Firebase config·VAPID 공개키 상수 정본 — VITE_ env 예외, 사용자 승인 2026-08-17) · model: `push-support`(지원 판별 순수), `push-sync`(재등록/로테이션 전이 + SW URL 조립 순수), `push-toggle`(토글 표시 파생 순수 — granted && 보관 토큰), `push-token-store`(보관 토큰 반응형, 저장 계층 `shared/storage.fcmTokenStorage`), `push-notice-store`(토글 denied/error 안내 단일 슬롯 — PR #60 리뷰 3) · api: `messaging`(firebase 동적 import 격리 경계 — 테스트 모킹 지점), `use-push-token-sync`(셸 상주 자동 동기화 — 기존 등록자 한정), `use-foreground-messages`(onMessage → 통지) · **MSG-477 ①에서 삭제** — 알림 설정 화면(MSG-409)이 걷히며 `preference-catalog`·`preference-cache`·`permission-banner`·`use-preferences-query`·`use-preference-mutation`·`use-push-toggle`이 소비처 0으로 함께 제거됐다(웹 푸시 **신규 등록 진입점 소멸** — 기존 등록자 동기화·수신 표시는 유지). `model/push-toggle`(표시 파생 순수)은 모바일 parity가 동적 import하므로 존치 · ui: `PushNoticeHost`(AppLayout 셸 분기 상주 — 동기화 배선 + 포그라운드·토글 안내 우하단 단일 스택). SW: `public/firebase-messaging-sw.js`(config는 쿼리스트링 전달, gstatic CDN compat) - **profile** — model: `profile-edit`, `profile-format`, `profile-modal-store`(모달 열림 — 사이드레일 초기화가 읽는다), `profile-image`(업로드 순수 로직), `upload-profile-image`(오케스트레이션 포트), `use-profile-image-upload`(웹 포트 훅), `use-profile-query`, `location-consent`(게이트 판정 순수 — RN 재사용 대상)·`use-location-consent-gate`(MSG-407) · api: `use-profile-mutations`(닉네임·위치동의 PUT·이미지 DELETE) · ui: `ProfileEditModal`, `DeleteAccountModal`, `LocationConsentScreen`(전면 동의 화면 — AppLayout 조건 렌더, MSG-407) - **video-actions** (MSG-411 신설) — model: `video-menu`(공개 옵션 2종·shouldPatchVisibility 같은 값 미발사·삭제 카드 문구 파생 순수 — RN 재사용 대상), `use-auto-dismiss-toast`(3초 자동 소멸 토스트 공유 훅 — PR #62 리뷰로 3곳 중복 추출, 동일 문구 연속 설정 타이머 재시작 보장), `report`(widgets/cell-detail에서 이동 — REPORT_REASONS·canSubmitReport + `toServerReportReason` FE 3종→서버 enum·`reportFailureNotice` 11409/409 중복 분기) · api: `use-video-mutations`(`useDeleteVideo` 도감·격자 무효화 + upload `invalidateGridQueries` cross-feature 재사용, `useSetVideoVisibility`, `useReportVideo`) · ui: `VideoMoreMenu`(Radix DropdownMenu 복합 소유 — 삭제 확인 모달·신고 모달·실패 토스트 동봉, mine 분기 + "영상 교체" 항목 → upload `openReplaceModal`, MSG-415), `VideoDeleteConfirmDialog`(danger confirm — 대상 카드 실데이터), `ReportDialog`·`ReportReasonSelect`(이동 + 실 POST). 진입점: 도감 `GalleryVideoCard` ⋯ + 미니 패널 헤더 ⋯ -- **ai-route** (MSG-488 신설) — model: `ai-route-store`(요청·결과·선택 상태, 플랫폼 중립 — 지도 SDK 미import), `route-request`(뷰포트 DTO 변환·trim 1~500 제출 판정·`needsZoomNormalize`/`reachedTargetViewport`/`exceedsViewportSpan` — 서버 뷰포트 상한 0.5도 최종 가드는 MSG-489 §12에서 부활), `route-point-view`(표시명 `[zoneName+zoneCell] · [regionName]` non-null join · kind 태그 매핑 — 미지 kind는 태그 없음, `EVENT`·`MISSION_FESTIVAL` 둘 다 `theme-festival`), `route-legs`(이웃 좌표 **직선** 구간 거리 — 실보행 경로는 MSG-490), `route-error`(developCode 14400/14401/14429/14502/14503 + 401(2403) → UI 반응 7행), `route-overlay`(points → `RouteOverlay` + `StyledCellOverlay[]` 순수 파생), **MSG-489 추가**: `route-origin`(`resolveRouteOrigin` — 현위치 ∈ bounds면 origin, 밖/미확보/bounds null이면 null), `route-mentioned-area`(`resolveAutoMove` — `mentionedArea` non-null이면 `kind` 무관 이동, `alreadyMoved`면 차단(2차 무시) · `movedToastTitle` 받침 판정 조사), `route-request` 확장(`origin` 병합·`needsZoomNormalize`(항상 1km — 목표 줌 단은 뷰-레이어가 주입, `features/map-home` 미import)·`reachedTargetViewport`(2차 발사 게이트)·`VIEWPORT_SETTLE_TIMEOUT_MS`·`submitLabel`·`secondaryDelayMs`/`SECONDARY_MIN_INTERVAL_MS`=10.5s — **§11에서 0.5도 판정 `needsSpanNormalize`·`MAX_VIEWPORT_SPAN_DEG` 폐기**), `ai-route-store` 사이클 플래그 6종(`autoMoved`·`originSent`·`movedAreaName`·`secondaryPending`·`normalizePending`·`requestedAt`) · api: `use-route-recommend`(`recommendMutation` 래핑 + `unwrapEnvelope`; MSG-489 — `onSuccess`가 1차/2차를 갈라 이동 필요 시 결과 미게시 + 2차 예약, `secondary` 옵션) · ui/ 없음(UI는 pages/ai-route). **MSG-489·490 확장점 주석이 심겨 있다** — 소유권 경계는 `docs/spec/MSG-488.md` §4-2 +- **ai-route** (MSG-488 신설) — model: `ai-route-store`(요청·결과·선택 상태, 플랫폼 중립 — 지도 SDK 미import), `route-request`(뷰포트 DTO 변환·trim 1~500 제출 판정·`needsZoomNormalize`/`reachedTargetViewport`/`exceedsViewportSpan` — 서버 뷰포트 상한 0.5도 최종 가드는 MSG-489 §12에서 부활), `route-point-view`(표시명 `[zoneName+zoneCell] · [regionName]` non-null join · kind 태그 매핑 — 미지 kind는 태그 없음, `EVENT`·`MISSION_FESTIVAL` 둘 다 `theme-festival`), `route-legs`(이웃 좌표 직선 구간 거리 + **walk-paths 실보행 거리 우선·직선 폴백** — `buildWalkSegments`·`isWithinKoreaRange`·`alignWalkSegments`, MSG-490), `route-error`(developCode 14400/14401/14429/14502/14503 + 401(2403) → UI 반응 7행), `route-overlay`(points → `RouteOverlay` + `StyledCellOverlay[]` 순수 파생), **MSG-489 추가**: `route-origin`(`resolveRouteOrigin` — 현위치 ∈ bounds면 origin, 밖/미확보/bounds null이면 null), `route-mentioned-area`(`resolveAutoMove` — `mentionedArea` non-null이면 `kind` 무관 이동, `alreadyMoved`면 차단(2차 무시) · `movedToastTitle` 받침 판정 조사), `route-request` 확장(`origin` 병합·`needsZoomNormalize`(항상 1km — 목표 줌 단은 뷰-레이어가 주입, `features/map-home` 미import)·`reachedTargetViewport`(2차 발사 게이트)·`VIEWPORT_SETTLE_TIMEOUT_MS`·`submitLabel`·`secondaryDelayMs`/`SECONDARY_MIN_INTERVAL_MS`=10.5s — **§11에서 0.5도 판정 `needsSpanNormalize`·`MAX_VIEWPORT_SPAN_DEG` 폐기**), `ai-route-store` 사이클 플래그 6종(`autoMoved`·`originSent`·`movedAreaName`·`secondaryPending`·`normalizePending`·`requestedAt`) · api: `use-route-recommend`(`recommendMutation` 래핑 + `unwrapEnvelope`; MSG-489 — `onSuccess`가 1차/2차를 갈라 이동 필요 시 결과 미게시 + 2차 예약, `secondary` 옵션) · `use-walk-paths-query`(MSG-490 — 세그먼트 좌표가 queryKey, `staleTime/gcTime: Infinity`·`retry: false`, 실패는 `segments: undefined` 조용한 폴백) · ui/ 없음(UI는 pages/ai-route). **MSG-489·490 확장점 주석이 심겨 있다** — 소유권 경계는 `docs/spec/MSG-488.md` §4-2 - **upload** — model: `upload-wizard`(스텝 전이), `upload-orchestration`(presign→S3 PUT→확정 상태머신), `upload-validation`, `highlight-selection`(+훅), `video-trim`, `ready-poll`(READY 반영 폴링 — 통지 아님), `upload-modal-store`(+`replaceTarget`·`openReplaceModal` — 교체 모드, MSG-415), `use-upload-location`, `presign-purpose`, `wizard-mode`(모드별 문구·격자 라벨 순수, MSG-415) · api: `use-upload-mutations`(`useConfirmUpload` + `useReplaceVideo` — finalize만 `PUT /api/videos/{videoId}`·lat/lng 미전송 미러, MSG-415), `s3-upload`, `ffmpeg-trim`(ffmpeg.wasm), `invalidate-grid-queries`, `invalidate-upload-surfaces`(확정·교체·READY 공용 무효화 집합 — 재생·격자·도감·잔디), `start-ready-refresh`(확정 후 READY 전이에서 같은 집합 재무효화) · ui: `UploadModal`+`use-upload-wizard`, `SelectStep`/`HighlightStep`/`PreviewStep`, `SegmentList`/`SegmentRow`/`SegmentTrimmer`, `UploadDropzone`, `VideoPreview`, `AnalyzingModal`. **블러 경로(폴링·토스트·확인 모달) 전량 삭제 — 서버 블러 중단, MSG-476.** `PreviewStep`에 공개 범위 라디오(전체 공개/나만 보기 — video-actions `VISIBILITY_OPTIONS` 재사용, radix-ui RadioGroup) → 확정 body `visibility` 전송 ## entities/ (5) @@ -83,9 +83,9 @@ 전 컴포넌트 스토리 존재(RN Storybook은 `apps/mobile/.rnstorybook/main.ts` glob 자동 등록 — 파일 추가만으로 등재). `index.ts`는 MSG-420이 독점 소유했으며, 이후 모바일 화면 티켓은 export를 추가하지 않는 것이 합의다. MSG-421이 이 합의를 지킨 채 `VideoCard`에 optional `thumbnailClassName`·`overlay`(미지정 시 렌더 불변)를, `SegmentedProgress`에 `accessible`(Android는 이게 없으면 role만으로 접근성 노드를 만들지 않아 진행바가 아예 낭독되지 않는다)을 추가했다 — **컴포넌트 파일 비파괴 확장은 열려 있고 `index.ts` 추가만 닫혀 있다**. -## 테스트 자산 (apps/web/src — 196개, smoke 32개) + e2e 3스펙(apps/web/e2e — 로그인 시딩 `auth-session-stub`(getMe `locationConsent: true` 스텁 포함), `consent-gate-popstate.spec.ts`는 실브라우저 히스토리 방향 판별 고정 — MSG-407) +## 테스트 자산 (apps/web/src — 197개, smoke 32개) + e2e 3스펙(apps/web/e2e — 로그인 시딩 `auth-session-stub`(getMe `locationConsent: true` 스텁 포함), `consent-gate-popstate.spec.ts`는 실브라우저 히스토리 방향 판별 고정 — MSG-407) -레이어별 분포: app 8 · entities 6 · features/ai-route 9 · features/auth 6 · features/dex 15 · features/map-home 58 · features/notifications 6 · features/profile 12 · features/region 7 · features/search 3 · features/upload 16 · features/video-actions 6 · pages 24 · shared 12 · widgets 8. 목록은 `**/*.test.*`·`**/*.smoke.test.tsx` glob으로 확인 (2026-08-28 MSG-488에서 전수 재계수 — 직전 기재값 176은 MSG-473~478 웨이브 증가분이 반영되지 않은 스테일이었다. MSG-488 신규는 8개(features/ai-route 7 + pages/ai-route smoke 1)). +레이어별 분포: app 8 · entities 6 · features/ai-route 10 · features/auth 6 · features/dex 15 · features/map-home 58 · features/notifications 6 · features/profile 12 · features/region 7 · features/search 3 · features/upload 16 · features/video-actions 6 · pages 24 · shared 12 · widgets 8. 목록은 `**/*.test.*`·`**/*.smoke.test.tsx` glob으로 확인 (2026-08-28 MSG-488에서 전수 재계수 — 직전 기재값 176은 MSG-473~478 웨이브 증가분이 반영되지 않은 스테일이었다. MSG-488 신규는 8개(features/ai-route 7 + pages/ai-route smoke 1)). 커버리지 공백(테스트 없는 로직 파일): `dex/use-collection-query`, `map-home/map-query-policy`, `profile/use-profile-image-upload`, `upload/presign-purpose`·`use-highlight-selection`·`use-upload-wizard`·`use-video-duration`, `map-shell/sidebar-store`·`use-map-shell`, `map-home/use-escape-close`, `shared/error-interceptor`·`http-client`·`navigation` @@ -163,3 +163,4 @@ - MSG-474: 비로그인 지도 홈 개방 — QA 3건(최초 진입 로그인 안내만 뜸 / "장소 불러오기" 미작동 / 칩 최근접 이동 안 됨)의 뿌리는 하나였다: 역지오코딩이 비로그인에서 게이트돼 **확정(commit)이 영영 생기지 않음** → `committedBounds`가 부트스트랩 초기 뷰포트에 고정 → 칩 bbox가 좁고 `currentRegion` null이라 불러오기 버튼 미생성. 티켓이 지목한 `use-nearest-entry`는 무관했다(그 훅의 center는 `viewportCenter`라 비로그인에도 null 아님). **게이트 해제 3곳**(`MapHomePage`·`RegionPanel`·`use-committed-region` — 마지막은 티켓 미기재였으나 최초 확정의 실제 소스)으로 3건이 함께 풀렸고 `use-chip-entry`·`use-nearest-entry`·`region-reload`는 **무수정** 회귀 통과. ① `RegionPanel` 로그인 유도 분기 통삭제 → 헤더·격자 카드·"전체 보기"·전체 지역 무한 스크롤이 비로그인에도 열린다(**MSG-463 확정 1을 뒤집음** — 당시 근거 "데이터원이 로그인 전용"이 explore 익명 200으로 소멸, 사용자 승인). ② 비로그인 격자 상세는 `grids/{gridId}`(응답이 occupied·내 영상 수 = 사용자별이라 익명 401이 **설계상 정상**)를 부르지 않고 **진입점 응답의 이름 재료로 조립** — `deriveHomeCellDetail`을 `viewerAuthenticated` 판별식 유니언(로그인=cell 필수 / 비로그인=entryNaming 필수)으로 확장, 비로그인은 subtitle null·"내 점령" 배지 없음. ③ 인증 **true→false 전이**에서 `QueryProvider` 한 곳이 `queryClient.clear()` — 티켓의 "쿼리 키에 인증 반영"은 생성 키에 인증 축이 없고 로그인은 이미 clear 중(로그아웃만 누락)이라 키 확장 10곳+ 대신 전이 구독 1곳을 택했다. ④ **스펙 인벤토리 밖 401 경로 2건을 구현 중 발견·차단**: `use-grid-names-query`(코스 스팟 이름 = `grids/{id}` N회)·dex `use-region-videos-query`(`collections/videos` — 비로그인 확정 지역이 생기며 처음 활성화). **실측이 범위를 두 번 바꿨다**: 티켓의 "BE 선행 전부 배포 확인"과 달리 1차 익명 curl에서 `regions/{code}/grids`·`videos/{id}`·`explore`가 401(요구 2·5·재생 블로커) → 사용자가 권한 재배포 → 2차 실측 200으로 요구 5건 전부 성립. **`api:generate` 미실행** — 라이브 `/v3/api-docs`와 레포 스냅샷이 **바이트 동일**(권한만 열리고 계약 무변경)이라 생성물 4파일 diff 0, MSG-475와의 충돌 우려도 소멸(구현 중 발견된 생성물 재생성분은 `FriendPreviewResponseDto.relation`=MSG-391 친구 기능으로 무관 → HEAD 복원). **AC 14(최종 방어선) 통과**: 비로그인 전 과정(진입→전체 보기→이동→불러오기→칩 3종→격자 상세→코스 상세→재생) `/api/` 실호출 전건 200·**401 0건·reissue 0건**·금지 7종 0건. **재작업 1회차**: ① 모바일 `home-cell-detail.parity.test.ts`가 웹 파생 함수를 구 시그니처로 동적 import해 깨짐 — 빌더가 `--filter web`만 돌려 놓쳤고 루트 게이트가 잡았다(**MSG-476과 동일 재발** — 웹 파생 함수를 바꾸는 티켓은 모바일 패리티 앵커를 먼저 조회할 것), 타입까지 새 계약에 묶어 다음 드리프트를 typecheck가 잡게 함 ② e2e `consent-gate-popstate`가 익명 렌더 증거로 삭제 대상인 "로그인" 버튼을 단정 → 확정 행정동 heading으로 교체(게이트 본래 단정 무변경) ③ nose 신규 6패밀리 개별 검토 후 추출 대상 0건 판정·베이스라인 재등재. **BE 환류 2건**: 코스 스팟(`CourseSpotDto`)이 좌표만 줘 비로그인 코스 스팟 상세에 행정동 줄 없음(AC 11 부분, FE로 메울 수 없음) · dev 재생 미디어가 S3 키를 `https%3A//dev.local/seed/….m3u8`로 저장해 403(AC 12 프레임 미표시 — FE 계약은 성립, 시드 데이터 결함). 확인불가 2건은 로그인 상태 브라우저 회귀(카카오 세션 부재, MSG-463 선례) - MSG-488: [웹] AI 경로추천 페이지 신설 (`/ai-route`) — 자연어 문장 → `POST /api/routes/recommend` → 좌측 388px 패널 카드 리스트 + 지도 오버레이(번호 마커·격자 초록 틴트·이웃 직선). **라우트명은 티켓 가칭 `/route`에서 `/ai-route`로 변경**(사용자 승인) — 코드베이스에 `route`가 이미 3중 의미(`ROUTES` react-router 상수 · `ThemeId "route"` = 기존 코스 칩 "경로추천"으로 같은 초록 `theme-route`까지 공유 · `map-overlay-store.routes`)라 동음이의를 피했다. **로그인 전용** — 2026-08-28 익명 POST 실측이 `401 {developCode:2403}`이라 `RequireAuth` + 레일 항목 비로그인 클릭 시 `login-modal-store` 게이트. **결과 도착 시 지도를 이동·확대하지 않는다**(카드 클릭만 `moveTo`, 줌 불변) — 사용자가 보던 범위를 뺏지 않는 것이 이 티켓의 계약이고, 자동 이동은 MSG-489 몫. **재사용 성과: 신규 렌더 코드가 거의 없다** — `map-overlay-store`에 이미 `routes`(폴리라인 + 번호 경유지)·`cells`(색·빗금) 슬롯이 있어 MapShell→MapCanvas 렌더 경로를 통째로 썼고, MapCanvas 추가분은 경유지 `onClick` 슬롯 + `active` 강조뿐(`onRouteWaypointClick` 미제공이면 기존 코스 마커는 비클릭 그대로). 오버레이 파생은 순수 함수 `route-overlay.ts`에 두어 RN 경계 유지(`features/`·`model/`에 `naver` import 0건). 승격 2건: ui-web **`Skeleton`** 신설(레포 첫 스켈레톤 — 착수 전제였던 "GalleryTabBody가 첫 사용처"는 실측 오류였다. `GallerySkeleton`은 `DotsLoader` 래퍼이고 MSG-403이 의도적으로 도트로 통일한 자리라 미접촉), **`RetryNotice`**를 `pages/map-home/ui/` → ui-web 이동(재사용 시 pages→pages import가 되므로 — 소비 8파일은 import 한 줄씩만 변경). 마커 스타일은 Figma 정본(28px `size-7` + `border-2 border-background` + `shadow-raised`)으로 갱신했고 `routeMarkerContent` 공유상 **기존 코스 칩 경유지 마커도 함께 바뀌는 것을 사용자가 승인**(두 마커가 같은 초록이라 통일이 낫다는 판단 — `markerStyle` 분기 미생성). 결과 부족 배너는 **FE 고정 문구**이고 서버 `notice`는 null 여부 신호로만 쓴다(문자열 미노출을 `not.toContain`으로 고정), 0곳이면 카드·오버레이 없이 배너만. **웨이브 2 병렬 머지를 위해 파일 소유권을 설계 산출물로 명시**(정본 `docs/spec/MSG-488.md` §4-2): 489(상태·요청·입력카드 계열)와 490(구간거리·오버레이 기하·walk-paths 계열)의 **교차 파일 0건**, 공유 파일 `AiRoutePage.tsx`도 489만 2줄·490은 0줄, 셸·렌더·라우트·레일·ui-web은 488이 완결(둘 다 0줄). 확장점 주석 3종을 지정 위치에 심었고 489·490 신규 예정 파일은 하나도 만들지 않았다. **검증 실측**: 남아 있던 리프레시 쿠키로 실계정 자동 재발급이 돼 **실 API 8곳 응답**으로 브라우저 검증 — 지도 미이동(축척바 100m 불변) · 8→3→2→0곳 4회 연속 갱신 잔상 0 · 도감 왕복 후 복원 + **재요청 0회** · 실패 7경로 전건 · Figma 4프레임 오탐 목록 밖 편차 0 · 콘솔 에러 0. 확인불가 1건 — 빗금(점령 격자 교집합)이 실데이터에 발생하지 않아 육안 불가, 단위 테스트로 대체. a11y 지적 1건 즉시 반영: `RouteResultHeader`(`role="status"`)가 자체 `role="status"`를 가진 `DotsLoader`를 감싸 낭독이 실제로 중복돼(`"동선 찾는 중 동선 찾는 중"`) 도트를 `aria-hidden`으로 감쌌다(ui-web 무수정). **nose 중복 게이트는 착수 전부터 red**였고 근원이 이 티켓 자신의 웨이브 0 openapi 커밋(`107aa59`)이라 재등재했다 — 검증자가 `git archive HEAD`로 워크트리 밖에 풀어 재현해 HEAD 시점 3패밀리가 빌더 지목 id와 일치함을 독립 확인(숨어든 진짜 중복 0). **알려진 한계**: `gateFillCells`가 `zoom < 16`에서 채움 셀을 버려 넓게 본 상태에서 요청하면 격자 초록 틴트가 안 보인다(마커·선은 남음) — MSG-489의 1km 축척 고정이 해소한다. **489 선행 경고**: "지도 위 현재 위치 점"을 그릴 슬롯이 `MapCanvasProps`에 없어(`MapLabelOverlay`는 텍스트 pill, `waypoints`는 번호 뱃지) 489가 "488 완결·489 0줄"로 선언된 `MapCanvas.tsx`를 다시 열 공산이 크다 — 489∥490 병렬성 자체는 안 깨진다. **스펙 각주 누락 1건**: Figma 결과·결과 부족 프레임 양쪽에 "축제, 팝업, 코스, 행사 정보와…" 각주가 있는데 스펙은 로딩 화면에만 기재 — 디자인 정본을 따라 양쪽에 렌더. - MSG-489: [웹] AI 경로추천 — `mentionedArea` 자동 이동·1km 축척 고정·2차 자동 재요청·출발지(origin) 자동 판정. **웨이브 2 = 490(walk-paths)과 동시 진행** — `docs/spec/MSG-488.md` §4-2 소유권 표의 489열만 접촉, 접촉 금지 15경로 diff 0줄(검증자 감사), `MapCanvas.tsx` 0줄(현재 위치 점은 **Figma 12402·13139에 노드가 없어 제외** — 488의 "489가 MapCanvas를 다시 열 공산" 경고는 디자인 근거가 없었다). **착수 전 실측 2건이 설계를 결정했다**: ① 2차 자동 재요청은 서버 10초 rate limit **예외가 아니다**(로그인 세션 연속 호출 → 두 번째 `429/14429`, 1차 요청 시작 +11s는 200, `Retry-After` 없음) → 1차 `requestedAt` 기준 `10.5s` 잔여 대기 후 발사, 대기·2차 중 패널은 로딩·1차 결과 미게시, 2차 mentionedArea 무시(1회 한정), 2차 14429면 기존 에러 매핑. 브라우저 실측 간격 11,174ms·recommend 정확히 2회. ② `shared/geolocation.getCurrentPosition()`이 **권한 거부를 삼키고 서면 좌표를 돌려줘** 거부 사용자에게도 origin이 전송되는 오판정 → 가산 export `getCurrentPositionOrNull`(사용자 승인, 기존 소비 3곳 무수정). **488 "알려진 한계" 정정**: `gateFillCells`는 `zoom<16`에서 *색 있는 셀을 남기는* 필터이고 AI 경로 셀은 전부 `theme-route` 색이라 게이트를 **통과한다**(zoom 13에서 폴리곤 8개 DOM 실측, `fill=#34C759`) — 다만 1km 축척에선 셀이 6~7px이라 28px 마커에 가려 육안으로는 안 보인다. "1km 고정이 해소한다"는 서술은 성립하지 않으며 이는 홈과 동일한 저줌 공통 동작으로 수용(`MAP_SCALE_1KM_ZOOM`=13 < `GRID_MIN_ZOOM`=16). **설계 결정**: 제출·정규화·측위·이동·2차 예약을 훅 하나(`use-ai-route-auto-move`)가 소유해 `AiRoutePage.tsx`가 스펙 "2줄" 상한을 -w 기준 +14/−12로 넘겼다(제출 로직 7줄이 페이지에서 빠짐 — 490은 이 파일 0줄이라 병렬성 불변); 2차 발사는 `bounds` **참조 변경** AND 시간 경과 두 조건(이동 직후 즉시 쏘면 옛 bounds, `ZOOM_OUT`은 중심이 옛 뷰포트 안이라 중심 판정만으론 못 막음); 예약 플래그를 스토어에 둬 대기 중 섹션 이탈·StrictMode 2회 실행에서도 영구 로딩·중복 발사 없음; 토스트 조사는 받침 판정 순수 함수("서면으로", ㄹ 받침 예외 포함). 0.5도 예방(뷰포트 한 변 >0.5°면 중심 기준 1km로 먼저 맞추고 요청, 토스트 없음) 실측: span 0.7° 제출 → 요청 1회·14401 없음 — **이 규칙은 아래 §11 후속 변경에서 폐기됐다(현재는 항상 1km 정규화)**. ui-web `Toast` dark가 Figma 15675:3267과 1:1이라 승격 0건, Figma 편차 0(computed style 실측). **검증 실측**: 수용 기준 28개(L19+S9) 전건 통과·확인불가 0, Chrome 위치 권한 granted + 실좌표가 서면 안이라 S1 "안 → 표시"를 브라우저에서 직접 판정, 토스트 3초 소멸, 2차 로딩 전 구간 1차 잔상 0, 콘솔 0. 중복 게이트 재등재 +11/−6 전부 489 변경 파일 포함(무관 흡수 0), `setGeolocation` 3복제는 `src/test/geolocation.ts`로 추출. **후속 변경(2026-08-29, 승인 — 스펙 §11)**: 사용자 지시로 **제출 시 항상 1km 정규화**로 바꾸고 0.5도 규칙(A2·`needsSpanNormalize`·`MAX_VIEWPORT_SPAN_DEG`·L12)을 **폐기**했다 — 항상 1km면 서버 상한 14401이 구조적으로 불가능해 0.5도 판정이 죽은 조건이 된다. 이미 1km면 줌 명령을 내지 않는다(같은 값으로 `zoomTo`하면 `idle`이 안 와 요청이 영영 안 나간다). 정규화 대기에도 로딩을 켜되(`startNormalize`) `requestedAt`은 **mutate 시점**에만 찍는다(대기가 10초 창을 먹으면 2차가 조기 발사돼 14429). 2차 발사 조건은 `bounds` 참조 변경 → **목표 뷰포트 도달**(`reachedTargetViewport` — 참조 변경 AND 줌이 목표 단)로 좁혀 진행 중 지도 조작이 2차를 오염시키지 않게 했고, **지도 잠금은 하지 않는다**(사용자 결정 — ui-web·MapShell·MapCanvas 계속 0줄). 좁힌 대가인 "목표를 영영 벗어나는" 경로는 두 대기 모두 `VIEWPORT_SETTLE_TIMEOUT_MS`=3s 상한으로 종결시켜 **영구 로딩을 테스트로 금지**(L22·L24). 결과 상태에서는 뷰포트가 바뀌어도 recommend가 안 나가 자유 확대 가능(L25 회귀 고정 — 1km에서 셀 6~7px이라 육안 미식별이던 지점이 여기서 해소). 접촉 파일 7개(+`nose.baseline.json`), `AiRoutePage.tsx` 0줄, 490 소유 파일 0줄. **재작업 1회차(2026-08-29 §12)**: 검증이 백그라운드 탭 경로에서 **14401 재현**을 잡았다 — 숨은 탭은 rAF가 멈춰 `idle`이 안 오는데 3s 상한이 옛 뷰포트(1.94°×4.75°)로 발사했다. §11이 지웠던 서버 상한 판정을 **발사 직전 최종 가드**(`exceedsViewportSpan`)로 되살려 `send()` 한 곳에서 막고, 못 보내면 `abortPending`으로 **에러 안내 종결**(영구 로딩·확정 400 둘 다 금지), 숨은 탭에서는 상한 타이머를 걸지 않는다. §11 D10의 "항상 1km면 14401 구조적 불가능" 서술은 정정됐다(정상 경로 한정). 상한 3s는 실측(줌 전이 269~729ms) 근거로 유지. **같은 회차에 사용자 지시로 `/ai-route`에서 다른 섹션 집계 클러스터 마커를 숨겼다** — `MapShell.tsx`는 MSG-488이 완결 선언한 파일이나 사용자 명시 지시이고 490도 0줄이라 병렬 충돌 없음(예외 기록). **codex 리뷰 반영(2026-08-29 §13, push 전 게이트)**: ①2차 인스턴스에 `onLoginRequired`를 붙였다 — 1차 성공과 지연된 2차(서버 10초 창) 사이에 세션이 만료되면 401을 받는데 모달이 뜨지 않았다(종전 "후속 후보" 서술은 한계가 아니라 결함이었다). ②**정착 상한이 무한히 밀리는 D13 위반을 고쳤다** — 두 대기 이펙트가 `bounds`·`zoom` 갱신마다 타이머를 처음부터 다시 걸어, 사용자가 계속 패닝하면 종결이 영영 연기됐다(정규화 대기도 동일 구조였다). 상한을 **대기 사이클당 절대 마감 1회**(`advanceSettleDeadline` 순수 함수)로 바꿔 재실행은 **남은 시간만** 스케줄하고 마감이 지났으면 즉시 종결한다. 마감은 **가시 구간만 소모**하고 숨은 구간만큼 뒤로 밀려 §12 가시성 결정과 공존한다. 패닝 반복 시나리오 3건 + 마감 산술 5건을 테스트로 고정(기존 L1~L25 회귀 0). **브라우저 실측(2026-08-29, 전제 충족 후 재계측)**: 정규화 315ms(8km→1km)·복구 851ms·2차 간격 10,492ms·요청 span 전건 ≤0.5°·14429 문구 노출 확인, 계속 방해 시 3,088ms 종결(요청 0건, 안내+다시 시도). +- MSG-490: [웹] AI 경로추천 실보행 경로 — 결과 도착 시 직선을 먼저 그린 채 이웃 좌표쌍(≤8)을 `POST /api/routes/walk-paths`로 1회 요청, `resolved:true` 세그먼트만 `path` 좌표열로 교체·`distanceMeters`로 "도보 약 Nm" 갱신(같은 `formatWalkDistance`), `false`·실패(400/503/네트워크/401)는 직선·직선 거리 유지의 **조용한 폴백**(에러 UI 0·`retry: false`). **렌더 0줄** — Figma `route-line-walkpath` SVG 실측이 `#34C759` 4px 실선으로 현재 직선 스타일과 동일해 `MapCanvas`·`map-overlay-store`·`MapShell`·`RouteWalkConnector`(실거리/추정 구분 변형 노드 없음, Q2) 전부 미접촉. **경합 차단은 세그먼트 좌표 자체를 queryKey**로 — 서버 응답에 결과 id가 없고 스토어는 489 소유라 좌표가 유일한 식별자, 새 결과=다른 키라 늦은 응답이 화면에 앉을 길이 없고 `staleTime/gcTime: Infinity`가 "세션 유지 + 섹션 왕복 재요청 0회"를 겸한다(로그아웃 시 `QueryProvider.clear()`로 세션 경계 일치). 소유권 표(`MSG-488.md` §4-2) 이탈 1건 승인 — `use-ai-route-overlay-publish.ts`(`buildAiRouteOverlay` 유일 호출처, 489도 미접촉이라 교차 파일 0 유지). 소비 훅 2곳이 각자 `useWalkPathsQuery(points)`를 부르되 키가 같아 실요청 1회. `alignWalkSegments`(개수 불일치 시 통째 폐기 + `originOffset`)를 legs·overlay가 공유해 "거리는 실거리·선은 직선" 불일치 차단. 검증에서 **뮤테이션으로 L14·L15 테스트가 회귀를 못 잡는 것을 발견**(`enabled: true`·`retry: 3`에도 통과) → 테스트만 교체(프로덕션 0줄). **origin 배선은 489 머지 후 실측으로 "현재 불필요" 정정** — 489 실구현이 출발지 구간을 UI에 그리지 않아(요청 필드·상태 행·버튼 라벨뿐, 좌표 미보관) `originOffset: 1` 대상이 없다, 파라미터는 예비 기반으로 존치(`MSG-490.md` 후속 권장 참조). 브라우저 실동작은 489 워크트리 dev 서버의 5173 점유(19시간)로 착수 시 확인불가였다가 해제 후 3-A **S1~S8 전건 통과** — 점진 렌더 중간 빈 상태 0회·섹션 왕복 재요청 0회·결과당 요청 1회(body N−1)·늦은 이전 응답 무시 실측(작업 로그 참조). diff --git a/docs/decisions/DECISIONS.md b/docs/decisions/DECISIONS.md index 4d312b8..2823415 100644 --- a/docs/decisions/DECISIONS.md +++ b/docs/decisions/DECISIONS.md @@ -436,4 +436,6 @@ | 2026-08-29 | MSG-489 | 결정: 14429(요청 과다) 안내를 서버와 같은 문장 `"요청이 너무 잦습니다. 잠시 후 다시 시도해주세요"`로 교체한다(FE 고정 문구 상수 — 서버 응답 문자열을 렌더하는 것이 아니다) | 종전 `"잠시 후 다시 시도해 주세요"`는 사유를 알려주지 않아, 재시도를 연속으로 누른 사용자가 같은 안내만 반복해 본다(사용자 실사용 보고). 대안 ①서버 `message` 패스스루: MSG-488 §1-5의 FE 고정 문구 정책이 깨지고 임의 서버 문자열이 화면에 그대로 나간다 ②클라이언트 쿨다운 타이머로 버튼을 잠금: MSG-488 Q7에서 이미 기각한 범위 | | 2026-08-29 | MSG-489 | 결정(§13, codex 리뷰 P2): 정착 대기 상한을 "이펙트 재실행마다 다시 거는 타이머"에서 **대기 사이클당 절대 마감 1회**(`advanceSettleDeadline` 순수 함수 + 뷰-레이어 훅의 ref)로 바꾼다 — 재실행은 남은 시간만 스케줄하고, 마감이 지났으면 타이머 없이 즉시 종결한다. 마감은 가시 구간만 소모하고 숨은 구간만큼 뒤로 밀린다 | 종전 구조는 `bounds`·`zoom` 갱신마다 상한을 처음부터 다시 재서, 사용자가 계속 패닝하면 2차·정규화가 무한히 연기되고 패널이 로딩에 갇혔다(§11 D13 "영구 로딩 금지" 위반 — 패닝 반복 테스트가 `Infinity`로 재현). 대안 ①이펙트 deps에서 뷰포트 제거: 도달 판정이 갱신을 못 봐 옛 뷰포트로 발사(L18 파기) ②최초 예약 타이머를 재실행에서 안 건드림: 클로저가 옛 `send`를 잡아 예약 시점 뷰포트로 쏜다 ③복귀 시 상한 전체를 다시 재기: 숨김·복귀 반복으로 같은 무한 연기가 재현된다 | | 2026-08-29 | MSG-489 | 결정(§13, codex 리뷰 P1): 2차 자동 재요청 인스턴스에도 1차와 같은 `onLoginRequired`를 넘긴다. `docs/spec/MSG-489.md` §10·`docs/STATUS.md`에 "알려진 한계"로 적혀 있던 서술을 결함으로 정정하고 삭제했다 | 지연된 2차(서버 10초 창) 구간에 세션이 만료되면 401에서 패널만 입력 대기로 돌아가고 로그인 모달이 뜨지 않아, 사용자는 결과가 사라진 이유를 알 수 없다. `secondary` 플래그는 요청 **시작** 처리만 가르고 실패 처리는 공통이므로 인증 동작이 요청 순서에 따라 갈릴 이유가 없다 | - +| 2026-08-28 | MSG-490 | 결정: walk-paths를 mutation이 아니라 `useQuery`로 부르고 **세그먼트 좌표 자체를 queryKey**로 삼는다 | 서버 응답에 결과 id가 없어(RouteRecommendResponseDto는 points·notice·mentionedArea뿐) 경합 차단의 식별자를 만들 수단이 좌표뿐이다. 새 추천 결과는 좌표가 달라 다른 키를 만들고 늦게 온 이전 응답은 그 키 캐시에만 앉는다 — 스토어에 결과 id 필드를 넣는 대안은 489 소유 파일(ai-route-store)을 열어야 해 병렬 웨이브를 깬다 | +| 2026-08-28 | MSG-490 | 결정: 응답 개수 검증·originOffset 정렬을 `alignWalkSegments` 한 함수로 뽑아 `buildRouteLegs`·`buildAiRouteOverlay`가 공유 | 거리(커넥터)와 폴리라인(지도)이 **같은 인덱스 대응**을 써야 하는데 방어를 각자 구현하면 한쪽만 어긋났을 때 "거리는 실거리인데 선은 직선"처럼 화면이 거짓말한다. 사용처가 둘이라 성급한 추상화도 아니다 | +| 2026-08-28 | MSG-490 | 결정: walk-paths 호출을 생성 mutation 팩토리(`walkPathsMutation().mutationFn`) 대신 생성 SDK `walkPaths`로 직접 | 팩토리의 mutationFn 타입이 `(variables, mutationContext)` 2인자라 useQuery의 queryFn 컨텍스트로는 채울 수 없다(타입 오류). SDK 직접 호출은 같은 생성물을 쓰면서 AbortSignal 전달까지 자연스럽다 — use-route-recommend의 관례(mutation)는 mutation일 때만 성립한다 | diff --git a/docs/spec/MSG-490.md b/docs/spec/MSG-490.md new file mode 100644 index 0000000..67f2ebd --- /dev/null +++ b/docs/spec/MSG-490.md @@ -0,0 +1,353 @@ +# MSG-490: 경로 추천 선을 실제 걷는 길로 — walk-paths 연동 · 점진 렌더 · 세그먼트 단위 폴백 · 실보행 거리 + +> 원문: `_workspace/MSG-490/00_ticket.md` (2026-08-28 FE 재작성본이 정본, 하단 `
`는 BE 초안) +> 상위 설계 정본: `docs/spec/MSG-488.md` — 특히 **§4-2 웨이브 2 파일 소유권 표** +> 브랜치: **`feat/ai-route-walk-paths`** (이미 존재·체크아웃됨 — 새로 만들지 않는다. develop `d61e1cc` = MSG-488 머지 커밋에서 분기) + +## 기획 요약 + +MSG-488이 그린 AI 경로추천의 **이웃 좌표 직선** 폴리라인을 서버 프록시 `POST /api/routes/walk-paths`(BE MSG-483, TMap 보행자 경로)의 실보행 좌표열로 바꿔 끼운다. 추천 결과가 오면 직선을 **먼저 그린 채로** 세그먼트 요청을 1회 쏘고, 응답이 도착하면 `resolved: true`인 세그먼트만 실경로로 교체한다(점진 렌더 · 세그먼트 단위 폴백). 카드 사이 "도보 약 Nm"도 해당 세그먼트의 `distanceMeters`로 교체하되 `resolved: false`면 직선 거리를 유지한다. 요청 실패는 **조용한 폴백**(에러 UI 없음)이고, 새 추천 결과가 오면 진행 중이던 응답은 버린다. + +**웨이브 위치**: 웨이브 0(hey-api 스냅샷 `107aa59`) → 웨이브 1(MSG-488, 머지 완료 PR #104) → **웨이브 2 = MSG-489 ∥ 이 티켓**. 따라서 이 스펙의 두 번째 제약은 **489가 여는 파일을 한 줄도 열지 않는 것**이다(§5). + +--- + +## 1. 착수 전 실측 (티켓 전제 2건 정정 · 재조사 불요) + +| # | 티켓/전제 기재 | 실측 | 영향 | +|---|---|---|---| +| 1 | "`openapi/api-docs.json`에 walk-paths 없음 → 착수 전 스냅샷 갱신 커밋 필요" | **스테일.** 웨이브 0 커밋이 이미 넣었다 — `sdk.gen.ts:311 walkPaths` · `@tanstack/react-query.gen.ts:434 walkPathsMutation` · 타입 `RouteWalkPathRequestDto`·`SegmentDto`·`RouteWalkPathResponseDto`·`WalkSegmentDto`·`PathPointDto` 전부 존재 | **`api:generate` 실행 없음. 생성물 diff 0줄** — 489와의 생성물 충돌 창을 닫는다 | +| 2 | walk-paths 로그인 필요 여부 미확정 | **로그인 전용.** 2026-08-28 익명 `curl -X POST https://api.fillmap.kr/api/routes/walk-paths` → `401 {"developCode":2403}` (recommend와 동일) | 화면이 `RequireAuth` 뒤라 정상 경로에서 발생하지 않는다. 세션 만료 등으로 401이 오면 **조용한 폴백**으로 흡수한다(§1-2 표) — recommend와 달리 로그인 모달을 열지 않는다(추천은 이미 성공한 상태라 화면을 흔들 이유가 없다) | + +### 1-1. 계약 요약 (생성 타입 실측) + +```ts +RouteWalkPathRequestDto { segments?: SegmentDto[] } // 1~8개 +SegmentDto { startLat?, startLng?, endLat?, endLng? } // number (생성 타입은 전부 optional) +RouteWalkPathResponseDto { segments: WalkSegmentDto[] } // 요청과 같은 개수·같은 순서 +WalkSegmentDto { resolved: boolean; path: PathPointDto[] | null; distanceMeters: number | null } +PathPointDto { lat: number; lng: number } +``` + +응답은 봉투(`ApiResponseDtoRouteWalkPathResponseDto`)라 `unwrapEnvelope` 그대로 쓴다. + +### 1-2. 실패 → UI 반응 (전부 조용한 폴백 — 에러 UI 신설 0) + +| 조건 | 지도 | 커넥터 거리 | 부가 | +|---|---|---|---| +| 200 + 전 세그먼트 `resolved:true` | 실경로 폴리라인 | 실거리 | — | +| 200 + 일부 `resolved:false` | 해결분만 실경로, 나머지 직선 (한 줄로 이어짐) | 세그먼트별로 실거리/직선 혼재 | 부분 성공 | +| 400 (14402) · 503 (14504) · 401 · 5xx · 네트워크 | 488 직선 유지 | 직선 거리 유지 | **에러 UI 없음 · 재시도 0회 · 콘솔 에러 0** | +| 응답 세그먼트 개수 ≠ 요청 개수 (계약 위반) | 전량 직선 폴백 | 직선 거리 | 방어 (L11) | + +--- + +## 2. 수용 기준 + +**검증 프로파일: 화면** — 지도 위 폴리라인 모양과 카드 사이 거리 문구가 시각 결과이고 Figma `route-line-walkpath`(15666:13020)가 정본이다. 로직 비중이 크지만 화면 기준이 하나라도 있으면 넓은 쪽으로 판정한다(스킬 규칙). 풀코스 중 **a11y는 변경된 인터랙티브 요소가 0개**라 콘솔 에러·낭독 회귀 확인만 한다. + +### 로직 기준 (vitest) + +| # | 기준 | 검증 파일 | +|---|------|----------| +| L1 | `buildWalkSegments(stops)`가 이웃 좌표쌍마다 `{startLat,startLng,endLat,endLng}` 하나를 순서대로 만들고, 좌표가 1개 이하면 **빈 배열**을 만든다 | `route-legs.test.ts` | +| L2 | 좌표가 9개(출발지 1 + 지점 8)면 세그먼트는 **8개**이고 첫 세그먼트가 `stops[0]→stops[1]`(출발지→1번)이다. 9개를 넘는 입력은 앞에서 8개로 자른다(서버 400 방어) | `route-legs.test.ts` | +| L3 | `isWithinKoreaRange(stops)`가 위도 33~39·경도 124~132 밖 좌표가 **하나라도** 있으면 false를 낸다 (§7 Q3 — 요청 자체를 스킵하는 사전 필터의 판정자) | `route-legs.test.ts` | +| L4 | `buildRouteLegs(points, walk?)`가 `walk` 미제공이면 **MSG-488과 완전히 같은 직선 결과**를 낸다 (회귀 고정 — 기존 L3 케이스 전건 유지) | `route-legs.test.ts` | +| L5 | `walk.segments[i].resolved === true && distanceMeters !== null`인 구간은 `meters`가 **서버 실거리**이고 `label`이 그 값의 표기이며 `resolved: true`가 실린다. 그 외(`resolved:false` · `distanceMeters:null` · 인덱스 없음)는 하버사인 직선 거리·`resolved: false` | `route-legs.test.ts` | +| L6 | 부분 해결(예: `[true,false]`)에서 두 구간이 각각 실거리·직선 거리로 **섞여** 나온다 | `route-legs.test.ts` | +| L7 | `walk.originOffset === 1`이면 카드 사이 leg `i`가 `walk.segments[i + 1]`에 대응한다(출발지 구간을 건너뛴다). 기본값 0 | `route-legs.test.ts` | +| L8 | 표기 규칙은 실거리·직선이 **같은 `formatWalkDistance`**를 쓴다 — 1000m 미만 10m 반올림 `"도보 약 600m"`, 1000m 이상 소수 1자리 `"도보 약 1.2km"` (§7 Q1) | `route-legs.test.ts` | +| L9 | `buildAiRouteOverlay(points, occupied, selected, walk?)`가 `resolved:true` 세그먼트는 `path` 좌표열을, 아니면 두 끝점 직선을 이어 붙인 **단일 폴리라인**을 만들고, 세그먼트 접점의 **중복 좌표를 1개로 합친다** | `route-overlay.test.ts` | +| L10 | `walk` 미제공이거나 전 세그먼트 미해결이면 `path`가 MSG-488과 **바이트 동일**한 order 순 직선이다 (회귀 고정) | `route-overlay.test.ts` | +| L11 | 응답 세그먼트 개수가 요청 개수와 다르면 walk 결과를 **통째로 무시**하고 직선으로 되돌린다(거리·폴리라인 양쪽) | `route-legs.test.ts` · `route-overlay.test.ts` | +| L12 | 부분 해결이어도 `waypoints`(번호 마커)와 `cells`(격자 초록 틴트·빗금)는 MSG-488과 **동일**하다 — walk 결과는 `path`와 거리에만 닿는다 | `route-overlay.test.ts` | +| L13 | `walkPathsQueryKey(segments)`가 세그먼트 좌표 내용으로 결정된다 — 좌표가 다른 새 추천 결과는 **다른 키**를 만들고, 같은 좌표는 같은 키를 만든다(§4-2 경합 방지의 기계적 근거) | `use-walk-paths-query.test.ts` | +| L14 | 세그먼트가 0개이거나 한국 범위 밖이면 쿼리가 **비활성**이라 요청이 0회 나간다 | `use-walk-paths-query.test.ts` | +| L15 | 요청이 400(14402)·503(14504)·네트워크 오류로 실패하면 훅이 `segments: undefined`를 반환하고 **에러를 던지지 않으며 재시도도 하지 않는다**(retry 0) — 소비자는 직선을 유지한다 | `use-walk-paths-query.test.ts` | +| L16 | 성공 시 훅이 봉투를 벗겨 `data.segments`를 그대로 반환한다(`unwrapEnvelope`) | `use-walk-paths-query.test.ts` | +| L17 | Hermes 미구현 API 미사용 — `route-legs.ts`·`route-overlay.ts`에 `toSorted`·`toReversed`·`toSpliced`·`Object.groupBy`·`structuredClone`이 없다(기존 `[...arr].sort()` 유지) | oxlint + 코드 리뷰(§8 R5) | + +### 화면 기준 (브라우저 실동작 · Figma 대조) + +| # | 기준 | 검증 방법 | +|---|------|----------| +| S1 | 결과 도착 직후 지도에 **직선**이 먼저 뜨고, walk-paths 응답이 온 뒤 같은 색·같은 두께의 **도로를 따라 굽은 선**으로 바뀐다(재마운트 깜빡임 없이 경로만 교체) | 브라우저(전/후 스크린샷) | +| S2 | 카드 사이 "도보 약 Nm"이 응답 도착 후 해당 구간의 **실거리 값**으로 바뀐다(직선 근사값과 다른 값이 나온다) | 브라우저 | +| S3 | 일부 세그먼트가 `resolved:false`인 응답에서 그 구간만 직선·직선 거리로 남고 나머지는 실경로다(한 폴리라인 안에 굽은 구간과 직선 구간 공존) | 브라우저(스텁 응답) | +| S4 | walk-paths가 실패해도(오프라인·503·400) **에러 문구·배너·토스트가 뜨지 않고** 직선과 직선 거리가 그대로 남으며, "다시 짜기"를 포함한 추천 기능이 정상 동작한다. 콘솔 에러 0 | 브라우저(오프라인·네트워크 스텁) | +| S5 | "다시 짜기"로 새 결과가 오면 이전 실경로가 즉시 사라지고 새 직선 → 새 실경로 순으로 간다(잔상 0). 새 결과 도착 후 **이전 요청의 응답이 늦게 와도 화면이 되돌아가지 않는다** | 브라우저(느린 네트워크 스로틀) | +| S6 | 도감으로 갔다가 AI 경로추천으로 돌아오면 **실경로가 그대로 복원**되고 walk-paths 요청이 **0회** 추가로 나간다(Network 탭 실측) | 브라우저 | +| S7 | 실경로 선의 색·두께가 Figma `route-line-walkpath`(#34C759 · 4px · 실선)와 일치하고, 번호 마커·격자 초록 틴트·빗금은 MSG-488과 **동일**하다 | 브라우저 + Figma 15666:12855 대조 | +| S8 | 지점 N곳 결과에서 walk-paths 요청이 **결과당 1회**이고 body `segments` 길이가 N-1(489 머지 후 origin 있으면 N)이다 | 브라우저 Network 탭 | + +> **정합성**: L1~L17·S1~S8이 §4의 모든 신규/수정 파일을 덮고, §5 제외 범위(요청·패널 상태·에러 안내·자동 동작·FE 캐시 정책)에 대한 기준은 하나도 없다. + +--- + +## 3. 재사용 감사 — 새로 만들기 전에 있는 것부터 + +### 3-1. 그대로 재사용 (신규 코드 0) + +| 필요 | 재사용물 | 위치 | 비고 | +|---|---|---|---| +| walk-paths 호출 | 생성 `walkPathsMutation().mutationFn` (또는 `walkPaths` SDK) | `shared/api/generated/@tanstack/react-query.gen.ts:434` | **재생성 금지**(§1 실측 1). `use-route-recommend`가 `recommendMutation().mutationFn!`을 쓰는 것과 같은 관례 | +| 봉투 언랩 | `unwrapEnvelope` | `shared/api/envelope.ts` | | +| 요청 상태·캐시·경합 | TanStack `useQuery` (전역 `QueryProvider`) | `app/QueryProvider.tsx` | **queryKey 교체가 곧 경합 차단**(§4-2). `retry`는 전역 `shouldRetryQuery`를 **끈다**(503을 2회 더 두드릴 이유가 없다) | +| 폴리라인·번호 마커 렌더 | `MapCanvas`의 `routes` 렌더 (`Polyline` + `Marker`) | `pages/map-home/ui/MapCanvas.tsx:689` | **0줄** — 실경로도 같은 `RouteOverlay.path`(`LatLng[]`)에 실린다 | +| 오버레이 게시 슬롯 | `map-overlay-store.setRoutes` | `widgets/map-shell/map-overlay-store.ts` | **0줄** — 스타일 슬롯 신설 불요(§3-3) | +| 직선 거리 | `distanceMeters`(하버사인) | `entities/cell/model/geo-distance.ts` | 폴백 경로에서 그대로 | +| 거리 표기 | `formatWalkDistance` | `features/ai-route/model/route-legs.ts` | 실거리도 같은 규칙(§7 Q1) | +| 좌표 타입 | `LatLng` | `entities/cell` | `PathPointDto`와 구조 동일 | +| 폴리라인 선례 | `buildCourseRoutes` | `features/map-home/model/mission-overlay.ts:118` | 서버 좌표열을 그대로 `path`에 싣는 형태(MSG-473 GeoJSON 수용)와 같은 모양 — **새 패턴 없음** | + +### 3-2. 스타일 실측 — "렌더 0줄"의 근거 + +Figma `route-line-walkpath`(15666:13020)의 내보낸 SVG 실측: + +``` + ×3 +``` + +현재 코드: `route.color = palette["theme-route"]`(**#34C759**) · `ROUTE_STROKE_WEIGHT = 4` · 실선 · `ROUTE_STROKE_OPACITY = 0.9`. +→ **실경로 전용 스타일이 없다.** 직선과 실경로는 같은 선이고 정점 목록만 달라진다. 따라서 `MapCanvas.tsx`·`map-overlay-store.ts`·`MapShell.tsx` **전부 0줄**이고 §4-2 표의 "0줄 목표"가 유지된다. (Figma는 세그먼트마다 벡터를 3개로 나눠 그렸지만 같은 색·굵기가 이어지는 한 단일 폴리라인과 시각 동일 — §8 오탐 1.) + +### 3-3. 새로 만들 수밖에 없는 것 + +| 신규 | 왜 기존 것으로 안 되나 | +|---|---| +| `features/ai-route/api/use-walk-paths-query.ts` | walk-paths는 body가 필요해 POST일 뿐 **조회**다. 생성물은 mutation 팩토리만 주는데, 이 티켓이 필요한 것은 "결과가 바뀌면 키가 바뀌고 이전 응답이 버려지며 세션 동안 보관되는" 쿼리 의미론이다(§4-2). 기존 훅 중 이 계약을 가진 것이 없다 | +| `buildWalkSegments`·`isWithinKoreaRange`(route-legs 내 신규 export) | 요청 DTO 변환·범위 판정은 순수 로직이고 RN 이식 대상이다. `route-request.ts`(뷰포트 DTO 변환)는 **489 소유**라 손댈 수 없다 | + +--- + +## 4. 파일 단위 구현 계획 + +### 4-1. 변경 파일 (전부 §4-2 표의 490 열 — 예외 1건은 §7 Q4) + +``` +apps/web/src/features/ai-route/model/ + route-legs.ts [확장] buildWalkSegments · isWithinKoreaRange · buildRouteLegs(walk?) · RouteLeg.resolved + route-legs.test.ts [확장] L1~L8·L11 (기존 L3 케이스 전건 유지 = L4 회귀 고정) + route-overlay.ts [확장] buildAiRouteOverlay(..., walk?) — path 합성만 + route-overlay.test.ts [확장] L9~L12 +apps/web/src/features/ai-route/api/ + use-walk-paths-query.ts [신규] walkPathsQueryKey · useWalkPathsQuery + use-walk-paths-query.test.ts [신규] L13~L16 +apps/web/src/pages/ai-route/ui/ + use-route-legs.ts [확장] 쿼리 합성 — 세그먼트를 buildRouteLegs에 넘긴다 + use-ai-route-overlay-publish.ts [수정 · 표 이탈 1건 — §7 Q4] 같은 쿼리를 읽어 buildAiRouteOverlay에 넘긴다 + RouteWalkConnector.tsx [0줄 권고 — §7 Q2] +``` + +**0줄 유지 대상**(검증 시 diff로 확인): `pages/ai-route/AiRoutePage.tsx` · `pages/map-home/ui/MapCanvas.tsx` · `widgets/map-shell/map-overlay-store.ts`·`MapShell.tsx` · `packages/ui-web/*` · `packages/design-tokens/*` · `apps/mobile/**` · `packages/ui-native/**` · `shared/api/generated/**`. + +**489 소유라 절대 접촉 금지**: `features/ai-route/model/ai-route-store.ts` · `route-request.ts` · `route-origin.ts` · `route-mentioned-area.ts` · `api/use-route-recommend.ts` · `pages/ai-route/ui/RouteInputCard.tsx` · `use-ai-route-auto-move.ts` · `RouteToastHost.tsx` · `features/map-home/model/map-scale.ts`. + +### 4-2. 데이터 흐름과 경합 차단 (설계 핵심) + +``` +useAiRouteStore.points (488·489 소유, 읽기만) + │ + ├─ RouteResultList → useRouteLegs(points) ─┐ + │ ├─ useWalkPathsQuery(points) ← 같은 queryKey = 요청 1회 + └─ AiRoutePage → useAiRouteOverlayPublish ─┘ │ + ▼ + queryKey: ["ai-route","walk-paths", segments] + enabled : segments.length > 0 && isWithinKoreaRange + retry : false · staleTime/gcTime: Infinity + │ + ┌───────────────────────────────────┴───────────────────────────┐ + ▼ ▼ + buildRouteLegs(points, { segments, originOffset }) buildAiRouteOverlay(points, occupied, selected, { segments, originOffset }) + → 커넥터 "도보 약 Nm" → RouteOverlay.path (실경로 ∪ 직선) +``` + +- **경합 방지의 정체**: 서버 응답에 결과 id가 없다(실측 — `RouteRecommendResponseDto`는 `points`·`notice`·`mentionedArea`뿐). 그래서 **세그먼트 좌표 자체를 결과 식별자로 쓴다** — 새 추천 결과는 좌표가 달라 다른 `queryKey`를 만들고, 진행 중이던 이전 키의 응답은 그 키의 캐시에만 앉을 뿐 **화면이 읽는 키에는 절대 적용되지 않는다**. 스토어에 id 필드를 추가하지 않아도 되므로 489 소유 파일을 열지 않는다. +- **점진 렌더가 공짜인 이유**: 응답 전에는 `data === undefined`라 `buildRouteLegs`·`buildAiRouteOverlay`가 488 직선 경로를 그대로 낸다. 응답이 오면 같은 순수 함수가 실경로를 낸다 — 별도 상태 전이·타이머 없음. +- **"세션 동안 유지 · 재요청 0회"**: `staleTime: Infinity` + `gcTime: Infinity`로 섹션 이탈(언마운트) 후 복귀에도 캐시 히트 → 요청 0회(S6). 로그아웃 전이에서는 `QueryProvider`가 `queryClient.clear()`하므로 세션 경계와 정확히 일치한다. **이것은 "FE가 경로 좌표열을 캐시한다"는 정책이 아니라 세션 보관 수단**이고, 디스크·스토리지 영속화는 하지 않는다(티켓 [제외 범위] 준수). +- **origin(489)**: `originOffset`을 **입력으로 받는 형태**로 설계한다. 490 단독으로는 `origin`이 스토어(489 소유)에 없어 읽을 수 없으므로 기본값 0으로 두고, 489 머지 후 소비 훅 2곳에서 `origin`을 읽어 넘기는 **1~2줄 후속 배선**만 남긴다(§8 R2). 세그먼트 배열을 만드는 것은 언제나 `route-legs`다. + +### 4-3. 함수 시그니처 (구현 지시) + +```ts +// features/ai-route/model/route-legs.ts (순수 · RN 이식 대상 — Hermes 금지 API 사용 금지) +export interface WalkPathInput { segments: WalkSegmentDto[]; originOffset?: number } // 기본 0 +export interface RouteLeg { fromOrder; toOrder; meters; label; resolved: boolean } // resolved 가산 +export const buildWalkSegments: (stops: LatLng[]) => SegmentDto[]; // ≤1개 → [] · >8개 → 앞 8개 +export const isWithinKoreaRange: (stops: LatLng[]) => boolean; // lat 33~39 · lng 124~132 +export const buildRouteLegs: (points: RouteStopGeo[], walk?: WalkPathInput) => RouteLeg[]; + +// features/ai-route/model/route-overlay.ts (순수) +export const buildAiRouteOverlay: ( + points, occupiedGridIds, selectedOrder?, walk?: WalkPathInput, +) => AiRouteOverlay; // walk는 routes[0].path에만 반영 — waypoints·cells 불변 (L12) + +// features/ai-route/api/use-walk-paths-query.ts (뷰 경계 — 지도 SDK 미import) +export const walkPathsQueryKey: (segments: SegmentDto[]) => readonly unknown[]; +export const useWalkPathsQuery: (points: RouteStopGeo[]) => { segments: WalkSegmentDto[] | undefined }; +``` + +`buildRouteLegs`·`buildAiRouteOverlay` 모두 **`walk` 미제공 시 488과 동일한 출력**이라야 한다(L4·L10) — 이것이 회귀 안전망이다. + +--- + +## 5. 제외 범위 (이 티켓에서 구현하지 않는다) + +- **MSG-489 몫 전부** — 출발지 자동 판정·요청 body `origin`·"현재 위치에서 출발" 행·지도 위 현재 위치 점·`mentionedArea` 이동·1km 축척 고정·2차 자동 재요청·자동 이동 토스트. **489 소유 파일은 한 줄도 열지 않는다**(§4-1). +- **MSG-488 몫 전부** — 추천 요청·패널 4상태·에러 안내(`RouteErrorNotice`)·결과 부족 배너·카드↔마커 연동·레일·라우트·로그인 게이트. +- **walk-paths 전용 에러 UI** — 티켓이 조용한 폴백을 명시했다. 문구·배너·토스트·재시도 버튼 신설 0. +- **FE 영속 캐시** — localStorage·IndexedDB 저장 없음(서버 몫, MSG-483). +- **`apps/mobile`·`packages/ui-native`** — diff 0줄, parity 테스트 신설 없음. +- **폴리라인 스타일 신설**(색·두께·점선 슬롯) — §3-2 실측상 불요. +- **결과 카드 딥링크·상세 진입** — 488 제외 범위 승계. +- **e2e 스펙 신설** — 로그인 전용 화면이라 488과 같이 스모크 + 브라우저 검증으로 대체. + +--- + +## 6. 디자인 대조 (Figma ver 14 · 15666:12401 섹션) + +| 요소 | Figma 정본 | 코드 매핑 | 이 티켓 | +|---|---|---|---| +| 실경로 선 | `route-line-walkpath` 15666:13020 — `#34C759` · 4px · 실선 · round cap/join | `palette["theme-route"]` · `ROUTE_STROKE_WEIGHT=4` · `Polyline` | **렌더 0줄** — 정점 목록만 교체 | +| 번호 마커 | `route-marker-1~3` 28px | `routeMarkerContent`(488 완결) | 불변 | +| 격자 틴트·빗금 | `cell-*-route` / `hatch-route-*` | `buildAiRouteOverlay.cells`(488) | 불변 | +| 커넥터 | `walk-connector` 15676:3254 — 2×16 `hairline-strong` 선 + `FeelMap/Caption` **"도보 약 600m"** | `RouteWalkConnector`(488) | 문구 값만 바뀐다 — **실거리/추정 구분 표기가 Figma에 없다**(§7 Q2) | + +MVP 지역은 **부산 서면**이다 — 테스트 좌표·브라우저 검증 뷰포트 모두 서면 기준(기존 `route-legs.test.ts` STOPS·`test/route-points` 재사용), 서울 지명 금지. + +--- + +## 7. 추정 및 질문 (승인 게이트 대상) + +| # | 항목 | 내가 정한 것(추정) | 대안 | +|---|---|---|---| +| **Q1** | **실거리 표기 반올림 단위** | **488과 같은 `formatWalkDistance` 그대로** — 1000m 미만 10m 반올림 `"도보 약 600m"`, 1000m 이상 소수 1자리 `"도보 약 1.2km"`. Figma 시안(600m·450m)이 둘 다 10m 배수라 정합하고, 같은 자리에 실거리·직선이 번갈아 뜨는 화면에서 표기 규칙이 갈리면 사용자가 값 변화를 폴백으로 오해한다 | 실거리는 서버 값 그대로(1m 단위) 표기해 정밀도를 살린다 — 시안과 어긋나고 직선 폴백과 자릿수가 달라진다 | +| **Q2** | **실거리/추정 구분 표기** | **표기 구분 없음 · `RouteWalkConnector` 0줄.** Figma `walk-connector`(15676:3254)는 `resolved` 여부와 무관하게 `"도보 약 600m"` 한 형태뿐이고 변형 노드가 없다(실측). 디자인 정본에 없는 표기를 FE가 발명하지 않는다 — 단, `RouteLeg.resolved` 플래그는 만들어 두어 언제든 얹을 수 있게 한다 | ① `resolved:false`에 sr-only "직선 거리 기준" 부가 ② 실거리에만 아이콘/색 구분 — 둘 다 디자인 확인 필요 | +| **Q3** | **한국 범위 밖 좌표 사전 필터** | **요청 자체를 스킵**한다(쿼리 비활성). 서버는 좌표가 하나만 범위를 벗어나도 **요청 전체**를 400(14402)으로 거절하므로 확정 실패를 왕복시킬 이유가 없다. 세그먼트를 골라 빼는 방식은 응답의 "같은 개수·같은 순서" 대응을 인덱스 재매핑으로 깨뜨려 기각 | 필터 없이 보내고 400을 조용한 폴백으로 흡수(코드는 더 단순, 무의미한 왕복 1회) | +| **Q4** | **§4-2 표 이탈 1건 — walk 결과를 어디서 합성하나** | **`use-ai-route-overlay-publish.ts`를 연다.** 표는 이 파일을 "490 미접촉(오버레이 데이터는 route-overlay.ts에서 온다)"로 적었지만, 코드 실측상 `buildAiRouteOverlay`를 **호출하는 곳이 이 훅 하나**이고 순수 함수는 스스로 쿼리를 부를 수 없다. 대안(AiRoutePage에서 합성)은 "490은 AiRoutePage 0줄"을 깨서 더 나쁘다. **이 파일은 489도 미접촉이라 489∥490 교차 파일은 여전히 0건**이고 병렬성은 안 깨진다 | ① `AiRoutePage.tsx`에서 쿼리를 부르고 훅에 prop으로 내린다(489와 같은 파일을 열게 됨 — 기각) ② 오버레이용 별도 게시 훅 신설(`setRoutes`가 배열 전체를 갈아 끼워 두 게시자가 충돌 — 기각) | +| **Q5** | **쿼리 훅을 두 소비자가 함께 부른다** | `useRouteLegs`와 `useAiRouteOverlayPublish`가 **각자 `useWalkPathsQuery(points)`를 부른다** — queryKey가 같아 TanStack이 중복 요청을 합치므로 실요청은 1회다(S8이 이를 실측 고정). prop drilling으로 `AiRoutePage`를 경유하지 않아도 되는 것이 이 선택의 값 | 컨텍스트·전용 스토어로 한 번만 부르고 나눠 준다(부품 1개 추가, 이득 없음) | +| **Q6** | **응답 보관 수명** | `staleTime: Infinity` + `gcTime: Infinity` — 섹션 왕복에도 재요청 0회(S6)이고 로그아웃 시 `QueryProvider`가 전량 clear해 세션 경계와 일치. 항목당 좌표열 수십~수백 점 × 결과 몇 건이라 메모리 무시 가능 | `gcTime` 기본 5분 — 5분 넘게 다른 섹션에 있다가 돌아오면 재요청이 나가 "세션 동안 유지" 요건을 아슬하게 어긴다 | +| **Q7** | **walk-paths 재시도** | `retry: false`. 전역 기본(`shouldRetryQuery` — 네트워크·5xx 2회)은 **503(14504, 기능 꺼짐)을 두 번 더 두드린다**. 조용한 폴백이 계약이므로 첫 실패에서 바로 직선으로 간다 | 전역 기본 유지(불필요한 왕복 2회, 사용자에게 보이는 차이 0) | +| **Q8** | **세그먼트 접점 좌표 중복 제거** | 세그먼트 i의 마지막 점과 i+1의 첫 점이 같은 지점이므로 이어 붙일 때 **중복 1개를 뺀다**(L9). 안 빼면 같은 좌표가 두 번 들어가 네이버가 0길이 선분을 그린다(시각 차이는 미미하나 데이터가 거짓말을 한다) | 그대로 이어 붙인다 | +| **Q9** | **응답 개수 불일치 방어 수준** | 개수가 다르면 **walk 결과를 통째로 버린다**(L11) — 부분 대응은 어느 인덱스가 밀렸는지 알 수 없어 **엉뚱한 구간에 남의 경로를 그리는** 최악을 만든다 | 앞에서부터 짝이 맞는 데까지만 적용 | +| **Q10** | **세그먼트 상한 초과 방어** | 좌표 9개 이상이면 앞에서 8개로 잘라 세그먼트 8개를 만든다(L2). 지점 상한이 8이라 origin 포함해도 도달하지 않지만, 서버 400을 부르는 입력을 FE가 만들지 않는다는 보수적 방어 | 자르지 않고 그대로 보내 400을 조용한 폴백으로 흡수 | + +--- + +## 8. 리스크 + +- **R1 · §4-2 소유권 표와의 유일한 어긋남**: `use-ai-route-overlay-publish.ts`(§7 Q4). **489와의 교차는 0건 유지**이므로 병렬 머지 안전성은 그대로다. 488 스펙의 그 칸은 "순수 함수가 데이터를 스스로 가져올 수 있다"는 전제에서 나온 예측이었고, 이 스펙이 실측으로 정정한다. +- **R2 · origin 배선은 489 머지 후 후속 1~2줄**: 490은 `origin`을 읽을 수 없다(스토어가 489 소유·필드가 아직 없어 컴파일 불가). `originOffset` 파라미터와 주석만 남기므로, **489 머지 시점에 두 소비 훅에서 origin을 넘기는 배선이 필요하다** — 안 하면 origin 구간이 실경로로 안 바뀐다(직선 유지, 조용한 열화라 눈에 안 띈다). 머지 담당자에게 인계 항목으로 남긴다. +- **R3 · 서버 "같은 개수·같은 순서" 계약 의존**: 인덱스 대응이 전부라 계약이 깨지면 남의 경로가 그려진다. L11 방어 + S3 브라우저 확인으로 이중 고정. +- **R4 · TMap 일 1,000건 쿼터**: 검증 중 반복 요청으로 소진하면 전 세그먼트 `resolved:false`가 되어 **실경로를 한 번도 못 보고 "직선만 나온다"고 오판**할 수 있다. 검증 시 첫 성공 응답의 `resolved:true`를 Network 탭에서 먼저 확인한 뒤 판정한다. +- **R5 · Hermes 이식 금지 API**: `route-legs.ts`·`route-overlay.ts`는 **RN 이식 대상 순수 model**이다. `toSorted`·`toReversed`·`toSpliced`·`Object.groupBy`·`Map.groupBy`·`Promise.withResolvers`·`structuredClone` 금지 — 기존 `[...arr].sort()`를 유지한다(MSG-427 실기 크래시, PR #104 리뷰 기각 근거, CLAUDE.md 2026-08-28 행). react-doctor가 `toSorted()`를 권하더라도 **따르지 않는다**(웹 파일이라 게이트가 안 잡는다). +- **R6 · 참조 안정성**: PR #104가 잡은 함정과 같은 자리다 — `useWalkPathsQuery`가 매 렌더 새 배열/새 객체를 내면 `use-ai-route-overlay-publish`의 `useMemo`→게시 `useEffect`가 연쇄로 재실행돼 **타이핑할 때마다 지도 오버레이가 clear→재게시**된다. `segments`는 react-query가 데이터 불변 시 같은 참조를 주지만, `buildWalkSegments`·`walk` 래퍼 객체는 반드시 `useMemo`로 감싼다. `renderHook` + 연속 `rerender()`로 참조 안정성을 테스트에 고정한다. +- **R7 · 저줌 격자 틴트 소멸(488 R2 승계)**: `zoom < 16`에서 채움 셀이 걷힌다 — 선·마커는 남는다. 489의 1km 축척 고정이 해소한다. 결함 아님. +- **R8 · dev 검증 포트 단일**: 웹 브라우저 검증은 `--strictPort 5173`이 필수(네이버 지도 키 도메인 제한)라 **489 워크트리와 dev 서버를 동시에 띄울 수 없다**. 착수 전 `lsof -i :5173`로 점유를 확인하고, 점유 중이면 순번을 잡는다(§9). +- **R9 · 로그인 세션 필요**: walk-paths도 익명 401(§1 실측 2)이라 브라우저 검증에 실계정 세션이 필요하다. 488 검증은 잔존 리프레시 쿠키로 통과했다 — 세션이 없으면 S1~S8 대부분이 확인불가가 되므로 착수 시 로그인 가능 여부를 먼저 확인한다. + +### Figma 오탐 방지 (검증에서 결함으로 세지 않을 것) + +1. **실경로 선이 Figma처럼 3개의 분리된 벡터가 아니라 한 줄의 폴리라인** — 색·굵기가 같아 시각 동일. 세그먼트별 분리 렌더는 하지 않는다(§3-2). +2. **선 끝 모양(cap/join)** — Figma는 round, 네이버 `Polyline` 기본값을 그대로 쓴다. 스타일 슬롯을 만들지 않는 대가이며 실사용 축척에서 판별 불가. +3. **커넥터에 실거리/추정 구분 표기 없음** — Figma 정본에 변형이 없다(§7 Q2). +4. **origin(출발지) 구간이 없다** — Figma 15666:12855·13139은 출발지→1번 선을 그리지만 489 머지 전에는 그 세그먼트가 생기지 않는다(R2). +5. **"현재 위치에서 출발" 행·locate 버튼·현재 위치 점 없음** — 489 몫(488 오탐 목록 승계). +6. **패널 상단 SearchBar·하단 8px divider-band 없음** — 488 오탐 1 승계. +7. **Figma의 좌표·거리(600m/450m)·지점 이름은 목업** — 실데이터가 정본. 실거리는 직선 근사와 값이 다른 것이 **정상**이다(오히려 같으면 교체가 안 된 것). +8. **넓게 본 상태에서 격자 초록 틴트 없음** — 488 R2 승계(zoom < 16). +9. **`resolved:false` 구간이 직선으로 남는 것은 사양** — 결함 아님(S3). + +--- + +## 9. 검증 계획 + +1. **정적 게이트**: `pnpm test`(루트 — 웹 + 모바일) · `pnpm typecheck` · `pnpm lint` · `pnpm format:check` · `pnpm check:duplication` · `pnpm build`. 변경이 `apps/web` 한정이라도 **루트로 돌린다** — 웹 파생 순수 함수를 바꾸는 티켓은 모바일 parity 앵커가 깨질 수 있다(MSG-474·476 교훈). + > **앵커 사전 조회 실측(2026-08-28)**: `apps/mobile`에 `features/ai-route`가 없고, 이름이 겹치는 `apps/mobile/src/features/map-home/model/route-overlay.test.ts`는 **모바일 자체 코스 경로 모듈**을 테스트할 뿐 웹을 동적 import하지 않는다. 즉 **490의 두 파일에 걸린 parity 앵커는 0건**이고 `apps/mobile` diff 0줄이 유지된다. 그래도 루트 게이트는 돌린다(앵커는 이름이 아니라 import로 걸린다). +2. **test-first**: L1~L16 RED 1회 실측 후 구현(RED 로그를 작업 로그에 남긴다). +3. **테스트 매핑 감사**: L1~L17 ↔ 테스트 파일 1:1. 특히 L4·L10(488 회귀 고정)과 R6(참조 안정성) 케이스가 있는지 확인. +4. **브라우저 실동작**: `pnpm dev --strictPort 5173` — **착수 전 `lsof -i :5173`로 489 워크트리 dev 서버 점유를 확인**하고, 점유 중이면 그쪽을 내린 뒤 기동한다(동시 기동 불가, R8). 로그인 세션 필요(R9). 부산 서면 뷰포트, zoom ≥ 16에서 S1~S8. + - S3(부분 resolved)·S4(실패)는 실서버가 재현해 주지 않으므로 **네트워크 스텁/오프라인**으로 만든다. + - S5는 네트워크 스로틀로 이전 응답을 늦춰 경합을 실제로 만든다. + - S6·S8은 **Network 탭 요청 수 실측**이 판정 근거다(정성 관찰 불가). +5. **Figma 대조**: 15666:12855 스크린샷 vs 브라우저. §8 오탐 9항목은 결함이 아니다. +6. **a11y**: 인터랙티브 요소 변경 0이라 낭독 회귀·콘솔 에러 0만 확인한다. +7. **diff 감사**: `git diff --stat`으로 §4-1의 **0줄 유지 목록**(AiRoutePage·MapCanvas·map-overlay-store·MapShell·ui-web·design-tokens·mobile·generated)과 **489 소유 파일 전건 0줄**을 기계적으로 확인한다 — 이 티켓의 병렬 머지 안전성이 걸린 항목이다. +8. **push 전 codex 리뷰**: `codex-companion review --base develop --scope branch` 1회(하네스 규칙). + +--- + +## 10. 승인 기록 (2026-08-28, 사용자 승인 게이트) + +사용자 승인: **"기본안대로 하거라"** — §7 Q1~Q10 전부 기본안(추정) 확정. 특히 Q4(`use-ai-route-overlay-publish.ts` 개방 — 489도 미접촉이라 교차 파일 0건 유지) 승인. 브랜치는 워크트리 기존 `feat/ai-route-walk-paths` 사용(신규 생성 없음). + +--- + +## 11. 작업 로그 (2026-08-28 — 구현·검증 후 승격) + +### 실측 동작 (게이트 — 검증 단계 최종 회차) + +| 게이트 | 결과 | +|---|---| +| vitest (`pnpm test`) | web 194파일/**1435**케이스 · mobile 156파일/982케이스 전건 통과 (신규 22케이스 — 재작업 후 케이스 수 무증가, 단정만 강화) | +| typecheck | 6 워크스페이스 통과 | +| lint (oxlint) | 에러·경고 0, 신규 비활성 주석 0 | +| format:check | 1111 파일 통과 | +| check:duplication | 신규 패밀리 0 · `nose.baseline.json` 0줄 | +| 생성물 드리프트 (`openapi-ts` 후 diff) | **0줄** — 웨이브 0 스냅샷이 이미 walk-paths를 포함, `api:generate` 미실행 | +| `pnpm build` | 미실행 (page-verification 풀 게이트 6종 밖 — 변경이 순수 TS 로직·훅이라 typecheck로 갈음) | + +**뮤테이션 검증**(검증자가 구현을 일부러 깨뜨려 테스트가 FAIL하는지 확인): L9·L10·L11·L13·L16·R6 정상 FAIL. **L14·L15는 최초 회차에서 뮤테이션(`enabled: true` / `retry: 3`)에도 8/8 통과 → 실패 판정** — 구현은 옳고 테스트가 기준을 못 고정한 것. 재작업 1회차(프로덕션 0줄, `use-walk-paths-query.test.tsx`만)로 L14는 같은 렌더의 활성 대조군 쿼리 완료 후 요청 수·body를 단정, L15는 `retryDelay: 0` 클라이언트에서 에러 최종 상태 도달 후 요청 수 1을 단정하도록 교체. 재검증에서 뮤테이션 A/B/B2(`retry: false` 줄 삭제) 각각 해당 2케이스만 국소 FAIL 확인 → **통과**. + +### 변경 파일 (8개 — 전부 §4-1 490 열, 소유권 표 이탈은 승인된 Q4 1건) + +| 파일 | +/− | 내용 | +|---|---|---| +| `features/ai-route/model/route-legs.ts` | +98/−14 | `buildWalkSegments`·`isWithinKoreaRange`·`alignWalkSegments`(공유)·`buildRouteLegs(points, walk?)`·`RouteLeg.resolved` | +| `features/ai-route/model/route-legs.test.ts` | +156/−1 | L1~L8·L11 (488 케이스 전건 보존) | +| `features/ai-route/model/route-overlay.ts` | +43/−3 | `buildAiRouteOverlay(..., walk?)` — `path` 합성만(`waypoints`·`cells` 불변) | +| `features/ai-route/model/route-overlay.test.ts` | +91 | L9~L12 | +| `features/ai-route/api/use-walk-paths-query.ts` | 신규 61줄 | `walkPathsQueryKey`·`useWalkPathsQuery` (SDK `walkPaths` 직접 + `unwrapEnvelope`, `staleTime/gcTime: Infinity`, `retry: false`) | +| `features/ai-route/api/use-walk-paths-query.test.tsx` | 신규 | L13~L16 + R6 | +| `pages/ai-route/ui/use-route-legs.ts` | +14/−4 | 쿼리 합성 → `buildRouteLegs(points, walk)` | +| `pages/ai-route/ui/use-ai-route-overlay-publish.ts` | +14/−2 | 같은 쿼리 → `buildAiRouteOverlay(..., walk)` (Q4) | + +0줄 유지 실측: `AiRoutePage.tsx`·`MapCanvas.tsx`·`map-overlay-store.ts`·`MapShell.tsx`·`RouteWalkConnector.tsx`(Q2)·ui-web·design-tokens·mobile·generated·**489 소유 9파일 전건**. + +### 검토한 대안 (기각 사유) + +- **walk-paths 호출**: 생성 mutation 팩토리 `walkPathsMutation().mutationFn` 재사용(use-route-recommend 관례) → `mutationFn` 타입이 `(variables, mutationContext)` 2인자라 `useQuery`의 `queryFn` 컨텍스트로 채울 수 없음(타입 오류). SDK `walkPaths` 직접 호출이 같은 생성물을 쓰면서 `AbortSignal`까지 전달 → 채택. 직접 fetch는 생성물 우회라 기각. +- **경합 식별자**: 스토어에 결과 id 필드 추가 → 489 소유 파일(`ai-route-store.ts`) 개방이라 병렬 웨이브를 깸 → 기각. **세그먼트 좌표 자체를 queryKey**로 → 채택(§4-2). +- **개수 검증·originOffset 정렬**: legs·overlay 각자 구현 → 한쪽만 어긋나면 "거리는 실거리·선은 직선" 불일치 → `alignWalkSegments` 공유 export(스펙 §4-3 미기재, 빌드 리포트 §4 이슈 2). +- **접점 중복 제거**: 세그먼트 첫 점만 조건부 제거 → 서버 좌표열 내부 중복(0길이 선분)을 못 막음 → `pushPoint`가 직전 좌표와 같으면 건너뛰는 일반 규칙 채택. 미해결 leg는 두 끝점 모두 push(선이 마커를 반드시 지나도록). +- **`walk` 래퍼 생성 위치**: 쿼리 훅이 `WalkPathInput`을 직접 반환 → originOffset(489 소비 훅이 아는 값)이 캐시 키와 무관하게 섞임 → 소비 훅 2곳 `useMemo` 채택(PR #104 참조 안정성 교훈). +- **L2·Q10 "앞에서 8개"**: 좌표 기준이면 세그먼트 7개가 되어 같은 문장과 모순 → **세그먼트 상한 8**(서버 계약 "9개 이상 400"과 일치)로 해석. +- **L14 단정**: `fetchStatus` 노출 → 테스트 때문에 프로덕션 시그니처를 넓히는 것이라 기각 → 활성 대조군 기법. + +### 확인불가 · WAIVED + +- **S1~S8(브라우저 실동작) — 착수 시 확인불가 → 재검증 2회차에서 전건 통과**: MSG-489 워크트리 dev 서버가 5173(네이버 키 도메인 제한)을 19시간 점유해 최초 회차는 확인불가였고, 사용자가 그쪽 서버를 내린 뒤 3-A 실행. 실측 — S1 150ms 샘플러로 상태 정확히 3개(없음 → 직선 정점 2·450m → 굽은 선 정점 12·650m, 중간 `nPath=0` 0회 = 깜빡임 없음) · S2 450m→650m(서버 647) · S3 세그먼트 0만 `resolved:false` 조작 시 단일 폴리라인에 직선·도로 구간 공존 · S4 503·네트워크 오류 모두 직선 유지·`[role="alert"]` 0·호출 1회(재시도 0) · S5 이전 응답을 t=9077ms에 늦게 도착시켜도 새 결과 유지("5.6km" 식별 데이터 DOM 미출현) · S6 섹션 왕복 후 42정점 실경로 복원 + walk-paths 재요청 **0회** · S7 DOM `stroke:#34C759; stroke-width:4px` 실선 path 1개 · S8 추천 1건당 walk-paths 1건, body 길이 N−1 3회 일치(6→5·7→6·8→7). 콘솔 에러·경고 0. R4 전제 확인: 첫 응답에 `resolved:true` 존재(쿼터 미소진). 스크린샷 5장은 세션 scratchpad(레포 밖). +- **검증 도구 함정 2건(구현 결함 아님)**: 계측 스크립트 이중 래핑으로 요청이 2회로 보여 S8 오판 직전까지 갔다가 clean 세션으로 1회 확증 · 직접 만든 `Response`에 content-type이 없어 전량 직선 폴백 → `Response.json()`으로 교체 후 정상. 자기 계측을 그대로 믿었으면 거짓 실패 2건. +- **실사용 관찰**: 같은 장소에 팝업이 몰리면 0m 세그먼트가 생겨 TMap이 `resolved:false`를 돌려준다("도보 약 0m" 표기 — 사양). Q2(구분 표기) 후속 논의의 실측 근거. +- **`pnpm build` 미실행** — 커밋 전 1회 실행 권고(참고). +- **검증 절차 사고 1건**: 최초 검증 회차에서 뮤테이션 복원에 `git checkout`을 써 `route-legs.ts` 미커밋 변경이 한 번 유실 → 사전에 읽어 둔 원문으로 즉시 복원, `git diff --numstat` 98/14·format·typecheck·77케이스로 4중 확증. 이후 회차는 `cp` 백업 + md5 대조 + `trap EXIT` 복원만 사용. + +### codex 리뷰 (push 전 게이트, 2026-08-29) — P2 1건 기각 + +- **[P2] "walk 응답 도착 시 effect cleanup(`clearOverlays`)이 재게시 전에 지도를 비워 깜빡임 회귀"** (`use-ai-route-overlay-publish.ts:47-48`) → **기각 (반증 실측 존재)**. + 1. **S1 직접 실측이 반증한다** — 3-A에서 150ms 샘플러로 전이 전 구간을 기록했고 상태는 정확히 3개(없음 → 직선 정점 2 → 굽은 선 정점 12), 중간 `nPath=0` 상태 **0회**였다. + 2. **React 의미론상 페인트가 끼어들 수 없다** — passive effect의 cleanup과 다음 setup은 같은 flush에서 동기로 연달아 실행되고, cleanup의 `clear()`와 setup의 `setRoutes()`는 React 18 자동 배칭으로 **한 번의 커밋**에 합쳐진다. 빈 상태가 렌더로 커밋될 틈이 없다. + 3. **488부터 있던 선례 패턴이다** — 이 effect 구조(`use-home-overlay-publish` 선례)는 488에서 `selectedOrder` 변경마다 이미 같은 clear→재게시를 수행했고, 488 검증(4회 연속 갱신 잔상 0)·490 검증 모두 잔상이 없었다. 490은 같은 종류의 전이를 1회 더할 뿐이다. + cleanup을 언마운트 전용으로 분리하는 대안은 이론상 이득(스토어 중간 상태 제거)이 실측상 0인데 선례 패턴에서 이탈하고 검증 완료 코드를 다시 여는 비용만 남아 기각. + +### PR #106 리뷰 반영 (2026-08-30) — 2건 중 1건 채택 · 1건 기각 + +- **[🟡 채택] `route-overlay.ts` — `resolved:true`인데 `path: []`면 그 구간이 통째로 빠진다** → `segment.path.length > 0`까지 확인해 직선 폴백. 검증 참고 4가 "계약상 도달 불가라 과잉 방어"로 두었던 자리인데, 리뷰가 짚은 구체적 결함이 있었다: 중간 구간은 앞뒤 정점이 직접 이어져 사실상 직선 폴백이 되지만 **첫/마지막 구간이 빈 path면 0번/N번 지점이 한 번도 push되지 않아 선이 끝점 마커에 닿지 않는다**. `route-legs`는 같은 판정에서 `distanceMeters !== null`을 함께 보므로 두 판정 지점의 방어 수준을 맞췄다. test-first — "resolved:true인데 path가 빈 배열이면 그 구간은 두 끝점 직선으로 폴백한다" RED 확인 후 1줄 수정, ai-route 122케이스·web 전체 통과. +- **[🟢 기각(참고)] `pushPoint` 접점 중복 제거가 부동소수점 `===`에 의존** → 미채택. 미해결 구간의 접점은 같은 `RoutePointDto` 객체의 숫자를 양쪽에 쓰므로 구성상 완전 일치가 보장되고, 해결↔해결 접점(TMap 스냅 좌표)이 미세하게 다르면 정점 2개가 sub-meter 간격으로 남을 뿐 어떤 줌에서도 보이지 않는다(S7 실측). 해결→미해결 전환에서 스냅 끝점 뒤에 지점 좌표가 붙는 것은 선이 마커까지 닿게 하는 바람직한 정점이다. 엡실론 비교는 근거 있는 임계값이 없어 복잡도만 늘린다 — 리뷰어도 참고용으로 표기. + +### 후속 권장 (지라 코멘트 환류 대상) + +1. **R2 · origin 배선 — 489 머지 후 실측 결과 "현재 불필요"로 정정(2026-08-29)**: 스펙 작성 시점 전제("origin이 붙으면 출발지→1번 구간이 세그먼트 맨 앞에 추가된다")는 티켓 초안 가정이었고, **머지된 489 실구현은 출발지 구간을 UI에 그리지 않는다** — origin은 추천 요청 body 필드·입력 카드 상태 행·버튼 라벨로만 존재(스토어에 `originSent` 불리언뿐, 좌표 미보관·결과 리스트/오버레이 origin 참조 0건). 따라서 walk-paths로 실경로화할 출발지 구간 자체가 없어 `originOffset: 1` 배선 대상이 없다. 서버가 출발지를 `points[0]`으로 반환하는 경우에도 일반 지점으로 자동 처리된다(배선 0줄). `originOffset` 파라미터·L7 테스트는 **훗날 출발지 구간을 그리는 티켓이 생기면** 쓰는 예비 기반으로 존치 — 그 티켓은 origin 좌표 보관(스토어)부터 필요하다. +- 참고 1: `distanceMeters === undefined`면 `resolved`가 `true`로 새는 분기 — 생성 타입상 도달 불가·표시 0(Q2)이라 Q2 후속(구분 표기)을 얹을 때 `!= null`로 교체. +- 참고 3: 두 소비자 동시 마운트 시 요청 1회를 고정하는 단위 테스트 없음 — 3-A Network 실측이 대체.