MSG-488 feat: [웹] AI 경로추천 페이지 — 자연어 요청·결과 카드·지도 오버레이 - #104
Conversation
codex 리뷰 P2. viewport bounds가 null인 동안(지도 초기화 지연·실패) 문장만 유효하면 버튼이 활성이었고, 클릭하면 buildRecommendBody가 null을 내며 submit이 조용히 early-return해 "눌러도 안 되는 버튼"이 됐다. canSubmit에 mapReady(= bounds !== null)를 추가해 L8·L9 판정 집합을 일치시키고, 두 함수의 정합을 양방향으로 고정하는 테스트를 더했다 — 단위 테스트가 각 함수는 검사했지만 둘 사이의 계약은 아무도 검사하지 않던 자리다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ki5yWGDS5mmVW3djBBqvkX
|
Claude finished @gyuhochoime's task in 6m 7s —— View job ✅ 코드 리뷰 완료작업 목록
요약: 전반적으로 매우 꼼꼼한 구현입니다 (로직 순수 함수 + 유닛 테스트, RN 경계 준수, |
|
React Doctor found 1 new issue in 1 file · 1 warning · score 92 / 100 (Great) · 0 fixed · vs 1 warning
Reviewed by React Doctor for commit |
🔍 코드 리뷰 요약 — MSG-488 AI 경로추천 페이지전반 평가: 매우 꼼꼼합니다. 🟡 권장 사항
🟢 사소한 제안
확인한 항목 (문제 없음)
큰 로직 결함은 발견하지 못했습니다. 위 🟡 항목은 기능 정확성보다는 렌더 효율성 쪽 이슈입니다. |
useOccupiedGridsQuery가 flattenGridPages 결과를 memo 없이 반환해 데이터가 그대로여도 매 렌더 새 배열이 나왔다. AiRoutePage는 그 파생을 전역 map-overlay-store에 effect로 게시하므로, 결과 화면에서 타이핑할 때마다 clear → 재게시가 돌아 naver Marker·Polyline이 재생성됐다. renderHook 연속 rerender로 RED 재현 후 근원에서 useMemo로 고정했다. 모바일 이식본(apps/mobile/.../use-occupied-grids-query.ts:69)은 이미 같은 가드를 갖고 있었다 — 웹 원본만 빠져 있었고, 이 수정은 패리티를 되돌린다. MapShell:149의 같은 패턴도 함께 해소된다. RESULT_FOOTNOTE 문단은 로딩·결과 블록에 중복돼 있던 것을 한 곳으로 합쳤다. nose 승인 패밀리 3건 시프트 재키잉(437→437, 2건은 digest 바이트 동일). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ki5yWGDS5mmVW3djBBqvkX
|
Claude finished @gyuhochoime's task in 3m 16s —— View job ✅ 코드 리뷰 완료작업 목록
요약: 최신 푸시( |
🔍 코드 리뷰 요약 — MSG-488 AI 경로추천 페이지 (최신 커밋 98d156d 기준)컨텍스트: 이전 리뷰(703827d 기준)에서 지적된 2건 — ① ✅ 이전 지적사항 반영 확인
🟢 참고 (문제는 아님)
종합 의견새로 도입된 로직( |
PR #104 리뷰에서 react-doctor가 [...arr].sort()를 toSorted()로 바꾸라고 지적했는데, .oxlintrc.json:84는 같은 API를 error로 금지하고 있다(Hermes 미구현 — MSG-427에서 지도 홈을 마운트 즉시 크래시). oxlint 금지가 mobile/ui-native 한정이라 웹 파일에서는 react-doctor만 말하고 게이트가 아무것도 안 잡는다. 웹 toSorted 6곳 중 모바일 이식본이 있는 5곳 전부가 손으로 되돌려져 Hermes 주석을 달고 있는 것이 그 비용의 실측이다. 단 이 설정은 현재 무효다: v0.9.3에서 rules list/explain은 off로 보고하고 공식 `react-doctor rules disable`이 쓴 것도 같은 내용인데 스캐너는 규칙을 그대로 보고한다 (루트·apps/web cwd 양쪽, ignore.tags도 동일). 기존 js-combine-iterations: off도 같은 이유로 무효였다. 실효 방어선은 CLAUDE.md 이력 기록이고, 설정은 도구가 고쳐지면 듣도록 선언만 남긴다. CLAUDE.md 5행 유지 규칙에 따라 2026-08-19 e2e apt 행을 HARNESS_CHANGELOG로 이관. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ki5yWGDS5mmVW3djBBqvkX
|
Claude finished @gyuhochoime's task in 2m 15s —— View job 🔍 코드 리뷰 완료 (커밋
|
| const succeed = useAiRouteStore((s) => s.succeed); | ||
| const fail = useAiRouteStore((s) => s.fail); | ||
|
|
||
| return useMutation({ |
There was a problem hiding this comment.
React Doctor · react-doctor/query-mutation-missing-invalidation (warning)
useMutation with no cache update here can leave your users looking at stale data after it runs.
Fix → Add onSuccess: () => queryClient.invalidateQueries({ queryKey: ['...'] }) so cached data stays in sync after the mutation
React Doctor 지적 3건 — 전부 기각 (근거 첨부)
1.
|
| 웹 | 모바일 이식본 |
|---|---|
hot-region-summary.ts:19 |
:21 — "Hermes(RN 0.86)에 toSorted 미구현" |
course.ts:66 |
:63 |
use-multi-grid-videos-query.ts:54 |
:55 |
region-cluster-overlay.ts:200 |
:115 |
gallery-groups.ts:34 |
:37 |
뒤 두 개는 CLAUDE.md가 MSG-427 사고로 지목한 바로 그 파일들입니다 — "같은 함정이 두 번 주석으로 문서화돼 있었는데도 재발했다."
이번 지적 대상인 route-legs.ts:6은 파일 스스로 **(RN 재사용 대상)**이라 선언하고 있고, 모바일에는 이미 features/map-home/model/route-overlay.ts 이식본이 존재합니다. 권고를 따르면 6번째 이식 세금이 확정됩니다.
덧붙여 규칙의 근거 자체가 사실과 다릅니다. "복사 없이 정렬한다"고 하지만 toSorted()도 새 배열을 할당합니다. 성능 이득은 없고 순수 스타일 변경입니다.
2. invalidateQueries 권고 (use-route-recommend.ts) — 기각
recommend는 서버 상태를 바꾸지 않습니다. 요청 본문이 필요해 POST일 뿐 조회입니다. 결과 정본은 useAiRouteStore(zustand)이고 이 데이터를 담는 쿼리 캐시가 없어 무효화할 대상이 존재하지 않습니다.
오히려 무효화를 넣으면 수용 기준 **S11(섹션 왕복 시 재요청 0회)**이 깨집니다 — 도감에 갔다 돌아왔을 때 결과가 그대로 복원되는 것이 이 화면의 계약입니다.
후속 — 설정으로 못 막습니다 (도구 버그 실측)
같은 경고가 앞으로도 계속 뜰 것이라 doctor.config.json에 규칙 off를 선언했는데, v0.9.3 스캐너가 rules 설정을 무시합니다. rules list·rules explain은 off로 보고하고 공식 react-doctor rules disable이 쓴 내용도 동일한데 스캔 결과는 그대로입니다(레포 루트·apps/web cwd 양쪽에서 재현, ignore.tags도 동일하게 무시). 기존 js-combine-iterations: "off" 항목도 같은 이유로 무효였습니다.
선언은 도구가 고쳐지면 자동으로 듣도록 남겨 두고, 실효 방어선은 CLAUDE.md 변경 이력에 근거와 함께 기록했습니다(커밋 2445fc7).
별도 티켓 후보: oxlint의 toSorted 금지를 레포 전역으로 승격하면 이식 세금이 원천 제거됩니다. 다만 기존 웹 6곳 수정이 따라오므로 이 PR 범위 밖입니다.
🔍 코드 리뷰 요약 — MSG-488 AI 경로추천 페이지 (최신 커밋
|
🎫 관련 티켓
📌 작업 내용
AI 경로추천 페이지(
/ai-route)를 신설했습니다. 자연어 한 문장 →POST /api/routes/recommend→ 좌측 388px 패널의 카드 리스트 + 지도 오버레이(번호 마커 · 격자 초록 틴트 · 지점 간 직선)까지가 이 티켓의 범위입니다.웨이브 1 — MSG-489(출발지 자동 판정·mentionedArea 이동·1km 축척 고정·2차 자동 재요청·토스트)와 MSG-490(walk-paths 실경로·실거리)이 이 PR 머지 후 병렬로 얹힙니다.
주요 결정
/route→/ai-route(NavKeyaiRoute). 코드에route가 이미 3중 의미로 존재 —ROUTES(react-router 상수) ·ThemeId "route"(기존 코스 칩 "경로추천", 같은theme-route초록 공유) ·map-overlay-store.routes401 {developCode: 2403}→RequireAuth+ 레일 비로그인 클릭 시login-modal-store게이트moveTo(줌 불변). 자동 이동은 MSG-489 몫size-7) +border-2 border-background+shadow-raised.routeMarkerContent공유상 기존 코스 칩 경유지 마커도 함께 바뀜 — 두 마커가 같은 초록이라 통일이 낫다는 판단으로 승인됨(markerStyle분기 미생성)notice는 null 여부 신호로만 사용하고 문자열은 화면에 노출하지 않음(테스트로not.toContain고정). 0곳이면 카드·오버레이 없이 배너만재사용 성과 — 신규 렌더 코드가 거의 없습니다
map-overlay-store에 이미routes(폴리라인 + 번호 경유지)·cells(색·빗금) 슬롯이 있어 MapShell → MapCanvas 렌더 경로를 통째로 재사용했습니다. MapCanvas 추가분은 경유지onClick슬롯 +active강조뿐이고,onRouteWaypointClick을 주지 않으면 기존 코스 마커는 비클릭 그대로입니다. 오버레이 파생은 순수 함수(route-overlay.ts)에 두어 RN 경계를 지켰습니다(features/·model/에naverimport 0건).ui-web 승격 2건
Skeleton신설 — 레포 첫 스켈레톤입니다. 착수 전제였던 "도감GalleryTabBody가 첫 사용처"는 실측 오류였습니다(GallerySkeleton은DotsLoader래퍼이고 MSG-403이 의도적으로 도트로 통일한 자리) →GalleryTabBody미접촉RetryNotice이동 —pages/map-home/ui/→packages/ui-web/. 재사용하면pages → pagesimport가 되기 때문이며, 소비 8파일은 import 한 줄씩만 바뀝니다(렌더 불변)웨이브 2 파일 소유권 — 교차 파일 0건
489∥490 병렬 머지에서 충돌이 나지 않도록 확장점을 갈라 두는 것이 이 티켓의 설계 산출물입니다. 전체 표는
docs/spec/MSG-488.md§4-2에 있습니다.ai-route-store·route-request·use-route-recommend·RouteInputCard(+ 신규 4파일)route-legs·use-route-legs·RouteWalkConnector·route-overlay(+ 신규 walk-paths 쿼리)AiRoutePage.tsx확장점 주석 3종을 지정 위치에 심었고(
RouteInputCard.tsx:45·AiRoutePage.tsx:94·use-route-legs.ts:11), 489·490의 신규 예정 파일은 하나도 만들지 않았습니다.features/map-home/model/map-scale.ts미접촉.apps/mobile·packages/ui-nativediff 0줄입니다.✅ 체크리스트
pnpm lint/pnpm typecheck/pnpm --filter web test run통과 — 루트 게이트로 실행(ui-web·design-tokens 변경이 있어 패키지·모바일 파생 회귀를 잡기 위함, MSG-474·476 교훈)docs/STATUS.md(라우트 표 · pages 4→5 · features 9→10 · widgets · ui-web 23→25 · 이력) +docs/spec/MSG-488.md승격🔍 검증 요약
검증자가 빌더 수치를 믿지 않고 루트에서 직접 재실행한 결과입니다.
pnpm testpnpm typecheckDone· exit 0pnpm linteslint-disable/@ts-ignore0건pnpm format:checkpnpm check:duplicationpnpm openapi-ts→git diff --exit-codepnpm buildtest-first: 구현 착수 전 RED 1회 실측(
11 failed / 180 passed파일). 기존 테스트 약화·skip·삭제 0건.기준별 판정
로직 L1~L13 — 전부 통과. 검증자가 빌더 매핑 표를 베끼지 않고 테스트 본문과 구현을 대조했습니다.
toEqual완전 객체 비교로 문구를 문자 단위 고정set이라 "로딩 전에 먼저 비워진다"가 구조적으로 보장됨notice미노출을not.toContain으로 직접 증명Object.keys(ROUTES)순서까지 고정화면 S1~S14 — 전부 통과 (S6의 빗금 1항목만 확인불가).
브라우저에 남아 있던 리프레시 쿠키로 앱이 실제 JWT를 자동 재발급해 실계정 세션으로 검증했고, 첫 제출은 실 API로 8곳을 받았습니다(자격증명 입력 없음). 상태별 재현(부족·0곳·실패 7종)은
window.fetch스텁 구동.핵심 3건 실측:
100m불변)그 외: S8 카드↔마커 양방향(카드 클릭 시 줌 불변 이동 / 마커 클릭 시 지도 미이동), S10 실패 7경로 전건, S12 레일 재클릭 2단, 콘솔 에러 0건. 비로그인 직접 진입 → 홈 리다이렉트 + 모달도 실측.
Figma 대조 — 4프레임(15666:12402 · 12621 · 12855 · 13416) 일치, 스펙 §8 "오탐 방지" 목록 밖 편차 0건.
규칙 감사 — FSD·재사용·RN 경계·토큰 위반 0건.
features/·model/에 지도 SDK import 0건,pages → pagesimport 없음.확인불가 2건
hatched: [false, true, false])로 대체 확인div role="button"·임의tabindex없이 전부 실제<button>·<textarea>)라 Tab 도달·Enter/Space 활성화가 플랫폼 기본으로 보장되며, 각 요소focus()도달을 전수 확인검증이 잡아 수정한 것
a11y 낭독 중복 —
RouteResultHeader(role="status")가 자체role="status"를 가진 ui-webDotsLoader를 감싸"AI 추천 · 동선 찾는 중 동선 찾는 중 지금 지도 범위 기준"으로 두 번 읽혔습니다. 도트를aria-hidden으로 감싸 해소했고(ui-web 무수정 — 다른 소비처 파급 회피), 재검증에서 노출된 중첩 라이브 리전 0개 · 헤더 한 줄 레이아웃 동일 · 시각 회귀 없음을 확인했습니다.codex 리뷰 (push 전 게이트) — 지적 1건 반영
[P2] 지도 준비 전에도 제출 버튼이 활성이었다. 지도 초기화가 느리거나 실패해
viewport-store.bounds가null인 동안에도 문장만 유효하면 버튼이 활성이었고, 클릭하면buildRecommendBody가null을 내며submit이 조용히 early-return했습니다 — "눌러도 아무 일도 안 일어나는 버튼". 원인은canSubmit(L8)과buildRecommendBody(L9)의 성립 조건이 갈라져 있었던 것입니다.canSubmit에mapReady(=bounds !== null)를 추가해 두 함수의 판정 집합을 일치시켰고, 테스트 2건을 더했습니다(RED 실측 후 GREEN) — 하나는mapReady: false차단, 다른 하나는canSubmit === true면buildRecommendBody가 반드시 요청을 만든다는 정합을 양방향으로 고정해 두 조건이 다시 갈라지는 것을 막습니다.검증 리포트가 "미세 갭 ③"으로 남겨 둔 것과 같은 자리입니다 — 단위 테스트가 두 함수를 각각 검사했지만 둘 사이의 계약은 아무도 검사하지 않았습니다. 반영 후 루트 게이트 전건 그린(web 193 files / 1407 tests, +2).
💡 추가 논의할 사항
MapCanvasProps에 없습니다(MapLabelOverlay는 텍스트 pill,waypoints는 번호 뱃지). §4-2가 "488 완결 / 489 0줄"로 선언한MapCanvas.tsx를 489가 다시 열 공산이 큽니다. 489∥490 병렬성 자체는 안 깨집니다(490은 MapCanvas 0줄) — 489 스펙 단계에서 범용 점 슬롯 추가를 계획에 넣으면 됩니다.MapShell.gateFillCells가zoom < 16에서 채움 셀을 버려, 넓게 본 상태에서 요청하면 격자 초록 틴트가 안 보입니다(마커·선은 남음). 488은 지도를 이동·확대하지 않으므로 재현되며, MSG-489의 1km 축척 고정이 해소합니다.scale-125 ring-2 ring-theme-route로 구현했습니다. 디자인 재확인 대상.107aa59)에서 이미exit 1/ 3 families였고 근원은 이 티켓 자신의 웨이브 0 openapi 커밋입니다. 검증자가git archive HEAD로 워크트리 밖에 풀어 독립 재현했고, 추가 30건 중 "멤버 전원 변경 집합 밖" 9건도 id 단위로 일치 — 숨어든 진짜 중복 0건.