From c328d5b3278b23500d8246a00b98c36426172f81 Mon Sep 17 00:00:00 2001 From: gyuhochoime Date: Sat, 29 Aug 2026 23:28:04 +0900 Subject: [PATCH 1/4] =?UTF-8?q?MSG-490=203-A=20=EB=B8=8C=EB=9D=BC=EC=9A=B0?= =?UTF-8?q?=EC=A0=80=20=EC=8B=A4=EB=8F=99=EC=9E=91=20=EA=B2=80=EC=A6=9D=20?= =?UTF-8?q?=EC=99=84=EB=A3=8C=20=E2=80=94=20S1~S8=20=EC=A0=84=EA=B1=B4=20?= =?UTF-8?q?=ED=86=B5=EA=B3=BC,=20=EC=8B=A4=ED=8C=A8=200=EA=B1=B4,=20?= =?UTF-8?q?=ED=99=95=EC=9D=B8=EB=B6=88=EA=B0=80=200=EA=B1=B4.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/use-walk-paths-query.test.tsx | 177 +++++++++ .../ai-route/api/use-walk-paths-query.ts | 61 ++++ .../ai-route/model/route-legs.test.ts | 157 +++++++- .../src/features/ai-route/model/route-legs.ts | 112 +++++- .../ai-route/model/route-overlay.test.ts | 91 +++++ .../features/ai-route/model/route-overlay.ts | 46 ++- .../ui/use-ai-route-overlay-publish.ts | 16 +- .../src/pages/ai-route/ui/use-route-legs.ts | 18 +- docs/STATUS.md | 7 +- docs/decisions/DECISIONS.md | 3 + docs/spec/MSG-490.md | 340 ++++++++++++++++++ 11 files changed, 1001 insertions(+), 27 deletions(-) create mode 100644 apps/web/src/features/ai-route/api/use-walk-paths-query.test.tsx create mode 100644 apps/web/src/features/ai-route/api/use-walk-paths-query.ts create mode 100644 docs/spec/MSG-490.md 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..8070f0d 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,93 @@ 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("응답 세그먼트 개수가 요청 개수와 다르면 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..435b3ef 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,40 @@ 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]; + if (segment?.resolved === true && segment.path !== null) { + 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 +77,7 @@ export const buildAiRouteOverlay = ( points: RouteStopGeometry[], occupiedGridIds: string[], selectedOrder: number | null = null, + walk?: WalkPathInput, ): AiRouteOverlay => { if (points.length === 0) return { routes: [], cells: [] }; @@ -61,7 +101,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 2b3e3ab..02c7dd5 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 제출 판정), `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[]` 순수 파생) · api: `use-route-recommend`(`recommendMutation` 래핑 + `unwrapEnvelope`) · 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 제출 판정), `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[]` 순수 파생) · api: `use-route-recommend`(`recommendMutation` 래핑 + `unwrapEnvelope`) · `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 — 193개, smoke 32개) + e2e 3스펙(apps/web/e2e — 로그인 시딩 `auth-session-stub`(getMe `locationConsent: true` 스텁 포함), `consent-gate-popstate.spec.ts`는 실브라우저 히스토리 방향 판별 고정 — MSG-407) +## 테스트 자산 (apps/web/src — 194개, 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 7 · 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 23 · 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 8 · 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 23 · 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` @@ -162,3 +162,4 @@ - MSG-475: [웹] 줌아웃 행정 단위 집계 마커 리디자인 + 핫구역 행정 단위 집계 신설 — ① 원형 3단(size-17/20/23 + `border-3 border-background` 흰 링) 마커를 Figma `15221:20097` **말풍선**으로 교체: 라운드 10·패딩 6/12/7·링 없음·`shadow-raised`·2줄(지역명 `text-fm-label` 백 85% / 개수 `text-fm-heading font-bold` 백), 채움은 Figma 정확 일치 토큰 **`bg-secondary`**(#0071E3 — 종전 `bg-primary` #0066CC에서 이동). `CLUSTER_UNIT_SIZE_CLASS` 삭제로 **단위별 크기 3단이 사라지고** 폭만 지명 길이에 hug된다(브라우저 실측 DONG 63×48·SIGUNGU 66×48 — 높이 동일). ② 축제·팝업 칩 활성 중에는 같은 마크업에 채움만 `bg-theme-festival`/`bg-theme-popup`으로 갈린다(MSG-451 ① 규칙 — 새 색·타이포 토큰 0, `design-tokens` diff 0). ③ **핫구역 칩 저줌 집계 신설** — `use-hotzone-aggregation-query`(`GET /api/hotzones/aggregation`, BE MSG-466)를 미션 집계와 같은 축척 표·같은 상한(동 1°·구 4°·시 10° `clampBoundsToSpan`)·같은 bbox 정본(**뷰포트** — 저줌엔 "장소 불러오기"가 없어 확정 영역으로는 갱신 수단이 없다)으로 배선하고, `toHotZoneClusterMarkers`(접두 `hot-{unit}-{regionCode}`, `theme: "hot"`)로 붉은 말풍선을 띄운다. **인증 게이트 없음**(사용자별 값 없음 — MSG-463 익명 선례). `occupancy-visibility.hotCellsVisible` 신설 + `use-home-overlays` 배선으로 저줌에서 개별 핫 격자를 걷어 마커와 상호 배타를 만든다(종전엔 `gateFillCells`가 색 있는 테마 셀을 저줌에도 남겨 점으로 뭉개졌다). 집계 소스는 **칩 기준 택일**(핫 / 축제·팝업 / 칩 없음+로그인 도감) — 두 종류가 한 지도에 서지 않는다. ④ **`gridIds` 교집합 좁힘은 승인 결정으로 미구현** — 시/구 클릭은 여전히 집계 구간에 내리고 DONG 클릭 후 패널은 이미 확정 행정동으로 걸러지는데 서버가 집계·개별의 판정 집합 동일을 보장해 효과가 null 버킷뿐이다(MSG-451 `missionIds` 후속 미룸과 같은 처리). ⑤ `MARKER_MERGE_PX`(68/80/92)는 **값 불변**(주석만 갱신) — 말풍선(약 61×49, 폭 가변)과 근거가 어긋나지만 모바일 parity 테스트가 리터럴로 고정하고 RN 렌더가 원 지름으로 소비한다. 재보정은 RN 렌더 교체 티켓과 함께. **명세 재생성 안 함** — `getHotZoneAggregates`·`HotZoneRegionAggregateResponseDto`가 MSG-445 스냅샷에 이미 들어와 있었다(티켓 [참고]의 "로컬 스냅샷에 없음"이 스테일). 생성물 4파일 diff 0줄로 병렬 MSG-474와의 충돌 창을 닫았다. **`apps/mobile` diff 0줄** — 웹 `region-cluster-overlay` 변경을 타입 확장·함수 신설의 **추가만**으로 묶어 parity 3종이 모바일 무수정 통과(신규 필드는 `theme`처럼 조건부 스프레드라야 `toEqual`이 안 깨진다). **모바일 화면은 이 티켓 후 두 가지가 벌어진다** — 마커 형태(원형 3단 vs 말풍선)와 핫 칩 저줌 표시(모바일은 격자 낱개 유지, 집계 훅 없음): 별도 [모바일] 티켓 몫. 검증 실측: 브라우저 계산 스타일 전건 일치·칩 5상태 택일·드릴다운 줌 2건·**AC 13 정합(부전2동 마커 1 = 격자 줌 붉은 셀 1)**·콘솔 에러 0, e2e `cluster-zoom-gate` 무수정 2/2, 웹 1289+모바일 982 전건. 확인불가 1건 — 미판정 버킷(`regionCode` null)이 실데이터에 0건(전국 SIDO 프로브 실측)이라 단위 테스트로 대체. nose 신규 9패밀리(신설 훅·테스트 ↔ 도감·미션 형제) baseline 등재 — 3형제 훅은 게이트 조건·envelope·placeholder 정책이 9개 지점에서 갈려 공통 추출이 부적합(nose 자신의 9파라미터 판정, MSG-476 선례). **등재 시 무관 흡수 1건(`profile-edit-modal.smoke` ↔ `envelope-response` — develop에도 있던 hidden 패밀리)을 되돌렸다**(MSG-476 교훈). 참고: 흰 글자 대비가 hot 3.6:1·popup 2.2:1로 WCAG 미달이나 MSG-451부터 승계된 테마 토큰 규칙이자 Figma 정본 값 — 디자인 차원 후속 거리. - 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-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줄). **489 머지 후 필수 배선 1건**: 소비 훅 2곳 `originOffset: 1`(빠뜨리면 출발지 구간만 직선 — 조용한 열화). 브라우저 실동작은 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 29bd146..77b4399 100644 --- a/docs/decisions/DECISIONS.md +++ b/docs/decisions/DECISIONS.md @@ -420,3 +420,6 @@ | 2026-08-28 | MSG-488 | 결정: `kindTag`가 tailwind 클래스가 아니라 **tone(의미)** 4종(`place`·`festival`·`popup`·`route`)을 반환하고, 토큰 클래스 매핑은 뷰(`RouteStopCard.TONE_CLASS`)가 소유 | 대안 ①모델이 클래스 문자열 반환(`theme-classes.ts` 선례): 짧지만 model 레이어가 웹 스타일 체계를 알게 돼 RN 재사용 시 그대로 못 옮긴다 ②태그 전부를 뷰로: L2가 모델 유닛으로 매핑을 못박게 요구한다. tone 유니언은 라벨/색을 한 소스에서 갈라두면서 경계를 지킨다 — 색 4종 중 2종(EVENT·MISSION_FESTIVAL)이 같은 tone을 공유하는 승인 Q3도 한 곳에서 표현된다 | | 2026-08-28 | MSG-488 | 결정: 로딩·결과 공통 각주("축제, 팝업, 코스, 행사 정보와 장소 검색 결과로만 추천해요")를 **결과 상태에도** 렌더 (스펙 S4는 로딩만 명시) | Figma 정본 15666:12855·13416 두 프레임 모두 리스트 아래에 같은 각주를 그렸다. 스펙 S5가 각주를 언급하지 않은 것은 누락으로 판단 — 디자인 정본을 따르고 빌드 리포트에 "스펙과 달라진 점"으로 기록했다. 반대로 스펙만 따르면 결과 화면이 Figma와 어긋나 검증에서 결함으로 잡힌다 | | 2026-08-28 | MSG-488 | 결정: `nose.baseline.json` 전량 재등재(437 패밀리, +30/−26). 감사 결과 **member 파일이 전부 이 티켓 변경 집합 밖인 추가 패밀리 9건** 중 3건(`1fff7443bd`·`6ead82d6f3` new, `d5c756408e` changed)은 착수 전 HEAD(107aa59, MSG-488 웨이브 0 openapi 스냅샷)에서 이미 off-baseline이었고, 나머지 6건은 내 편집이 이웃 패밀리를 재분할하며 생긴 재키잉이다 | MSG-475가 세운 "등재 후 무관 흡수 감사" 규칙 적용. 9건을 손으로 되돌리는 대안은 기각 — 재키잉 6건은 되돌리면 파티션이 깨져 baseline이 코드와 불일치하고, 나머지 3건의 드리프트 근원이 이 티켓 자신의 웨이브 0 커밋이라 in-scope다. 되돌림 대신 id 단위 실측을 리포트·이 로그에 남겨 추적 가능하게 한다 | +| 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..ef12dd8 --- /dev/null +++ b/docs/spec/MSG-490.md @@ -0,0 +1,340 @@ +# 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` 복원만 사용. + +### 후속 권장 (지라 코멘트 환류 대상) + +1. **R2 · 489 머지 후 origin 배선(필수, 1~2줄)**: `use-route-legs.ts`·`use-ai-route-overlay-publish.ts`의 `// [MSG-489 확장점]` 자리에 `originOffset: 1`을 넘긴다. 검증자 교차 확인 — `alignWalkSegments`의 `requested = min(legCount + offset, 8)`가 `buildWalkSegments`의 `min(points.length, 8)`과 일치해 배선만 하면 성립. 빠뜨리면 출발지→1번 구간만 직선으로 남는 **조용한 열화**. +- 참고 1: `distanceMeters === undefined`면 `resolved`가 `true`로 새는 분기 — 생성 타입상 도달 불가·표시 0(Q2)이라 Q2 후속(구분 표기)을 얹을 때 `!= null`로 교체. +- 참고 3: 두 소비자 동시 마운트 시 요청 1회를 고정하는 단위 테스트 없음 — 3-A Network 실측이 대체. From e2561ef0a0cee77f11971653c757a1344a4c66e7 Mon Sep 17 00:00:00 2001 From: gyuhochoime Date: Sun, 30 Aug 2026 11:30:32 +0900 Subject: [PATCH 2/4] =?UTF-8?q?MSG-490=20docs:=20R2=20origin=20=EB=B0=B0?= =?UTF-8?q?=EC=84=A0=EC=9D=84=20489=20=EC=8B=A4=EA=B5=AC=ED=98=84=20?= =?UTF-8?q?=EC=8B=A4=EC=B8=A1=EC=9C=BC=EB=A1=9C=20"=ED=98=84=EC=9E=AC=20?= =?UTF-8?q?=EB=B6=88=ED=95=84=EC=9A=94"=20=EC=A0=95=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/STATUS.md | 2 +- docs/spec/MSG-490.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/STATUS.md b/docs/STATUS.md index 04556cd..28b9820 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -163,4 +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줄). **489 머지 후 필수 배선 1건**: 소비 훅 2곳 `originOffset: 1`(빠뜨리면 출발지 구간만 직선 — 조용한 열화). 브라우저 실동작은 489 워크트리 dev 서버의 5173 점유(19시간)로 착수 시 확인불가였다가 해제 후 3-A **S1~S8 전건 통과** — 점진 렌더 중간 빈 상태 0회·섹션 왕복 재요청 0회·결과당 요청 1회(body N−1)·늦은 이전 응답 무시 실측(작업 로그 참조). +- 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/spec/MSG-490.md b/docs/spec/MSG-490.md index ef12dd8..e758e9a 100644 --- a/docs/spec/MSG-490.md +++ b/docs/spec/MSG-490.md @@ -335,6 +335,6 @@ MVP 지역은 **부산 서면**이다 — 테스트 좌표·브라우저 검증 ### 후속 권장 (지라 코멘트 환류 대상) -1. **R2 · 489 머지 후 origin 배선(필수, 1~2줄)**: `use-route-legs.ts`·`use-ai-route-overlay-publish.ts`의 `// [MSG-489 확장점]` 자리에 `originOffset: 1`을 넘긴다. 검증자 교차 확인 — `alignWalkSegments`의 `requested = min(legCount + offset, 8)`가 `buildWalkSegments`의 `min(points.length, 8)`과 일치해 배선만 하면 성립. 빠뜨리면 출발지→1번 구간만 직선으로 남는 **조용한 열화**. +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 실측이 대체. From 26321129f940f5eb0ccc657d2dab8fb60b9ec7c8 Mon Sep 17 00:00:00 2001 From: gyuhochoime Date: Sun, 30 Aug 2026 11:37:24 +0900 Subject: [PATCH 3/4] =?UTF-8?q?MSG-490=20docs:=20codex=20=EB=A6=AC?= =?UTF-8?q?=EB=B7=B0=20P2=20=EA=B8=B0=EA=B0=81=20=EA=B7=BC=EA=B1=B0=20?= =?UTF-8?q?=EA=B8=B0=EB=A1=9D=20=E2=80=94=20S1=20=EC=8B=A4=EC=B8=A1=C2=B7R?= =?UTF-8?q?eact=20=EB=B0=B0=EC=B9=AD=C2=B7488=20=EC=84=A0=EB=A1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/spec/MSG-490.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/spec/MSG-490.md b/docs/spec/MSG-490.md index e758e9a..11e0ece 100644 --- a/docs/spec/MSG-490.md +++ b/docs/spec/MSG-490.md @@ -333,6 +333,14 @@ MVP 지역은 **부산 서면**이다 — 테스트 좌표·브라우저 검증 - **`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인데 선례 패턴에서 이탈하고 검증 완료 코드를 다시 여는 비용만 남아 기각. + ### 후속 권장 (지라 코멘트 환류 대상) 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 좌표 보관(스토어)부터 필요하다. From e89a925a6e21b3e30469d8ef03b312a2b738731b Mon Sep 17 00:00:00 2001 From: gyuhochoime Date: Sun, 30 Aug 2026 16:55:58 +0900 Subject: [PATCH 4/4] =?UTF-8?q?MSG-490=20fix:=20resolved:true+=EB=B9=88=20?= =?UTF-8?q?path=20=EC=84=B8=EA=B7=B8=EB=A8=BC=ED=8A=B8=20=EC=A7=81?= =?UTF-8?q?=EC=84=A0=20=ED=8F=B4=EB=B0=B1=20=E2=80=94=20=EB=81=9D=EC=A0=90?= =?UTF-8?q?=20=EB=A7=88=EC=BB=A4=20=EB=AF=B8=EB=8F=84=EB=8B=AC=20=EB=B0=A9?= =?UTF-8?q?=EC=96=B4=20(PR=20#106=20=EB=A6=AC=EB=B7=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ai-route/model/route-overlay.test.ts | 18 ++++++++++++++++++ .../features/ai-route/model/route-overlay.ts | 8 +++++++- docs/spec/MSG-490.md | 5 +++++ 3 files changed, 30 insertions(+), 1 deletion(-) 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 8070f0d..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 @@ -131,6 +131,24 @@ describe("buildAiRouteOverlay — walk-paths 실보행 폴리라인 합성 (L9~L ]); }); + 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])], 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 435b3ef..4b18462 100644 --- a/apps/web/src/features/ai-route/model/route-overlay.ts +++ b/apps/web/src/features/ai-route/model/route-overlay.ts @@ -57,7 +57,13 @@ const buildRoutePath = ( ordered.slice(1).forEach((to, index) => { const from = ordered[index]; const segment = segments[index]; - if (segment?.resolved === true && segment.path !== null) { + // 빈 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; } diff --git a/docs/spec/MSG-490.md b/docs/spec/MSG-490.md index 11e0ece..67f2ebd 100644 --- a/docs/spec/MSG-490.md +++ b/docs/spec/MSG-490.md @@ -341,6 +341,11 @@ MVP 지역은 **부산 서면**이다 — 테스트 좌표·브라우저 검증 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 좌표 보관(스토어)부터 필요하다.