From b7c9bdbcf534799d423e47e7219483d03bc99f6d Mon Sep 17 00:00:00 2001 From: KangShinGyu Date: Tue, 3 Nov 2026 23:20:39 +0900 Subject: [PATCH 01/14] =?UTF-8?q?fix(route):=20=EA=B2=BD=EB=A1=9C=20?= =?UTF-8?q?=EC=BA=90=EC=8B=9C=20key=EC=97=90=20=EB=8F=84=EC=B0=A9=20?= =?UTF-8?q?=EC=9E=A5=EC=86=8C=EB=A5=BC=20=EB=84=A3=EC=96=B4=20=EC=88=9C?= =?UTF-8?q?=EC=84=9C=EB=A5=BC=20=EB=90=98=EB=8F=8C=EB=A6=AC=EB=A9=B4=20?= =?UTF-8?q?=EB=B0=9B=EC=95=84=20=EB=91=94=20=EA=B2=BD=EB=A1=9C=EB=A5=BC=20?= =?UTF-8?q?=EC=93=B0=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 경로 key를 (방, 일차, 출발 항목, 도착 장소, 이동수단)으로 바꾼다 — 목록·지도·batch 시딩이 다음 장소의 googlePlaceId를 넘긴다 - 순서가 바뀐 구간은 key가 달라져 옛 도착지의 거리·선을 보여 주지 않는다 - 도착 장소가 없는 구간은 조회하지 않는다 Co-Authored-By: Claude Opus 5.5 --- .../travel-time/PlanTravelTime.tsx | 2 ++ src/components/map/PlanItineraryMapRoutes.tsx | 1 + src/hooks/useRooms.ts | 3 ++ src/lib/plan/schedule-bulk-hydration.test.ts | 33 ++++++++++++----- src/lib/plan/schedule-bulk-hydration.ts | 35 ++++++++++++++----- .../scheduleItemRoutePersistedQuery.test.ts | 27 +++++++++++++- .../plan/scheduleItemRoutePersistedQuery.ts | 12 +++++-- src/lib/plan/scheduleSegmentRoute.test.ts | 22 ++++++++---- src/lib/plan/scheduleSegmentRoute.ts | 6 +++- src/lib/query-keys.ts | 4 +++ 10 files changed, 117 insertions(+), 28 deletions(-) diff --git a/src/app/(main)/plan/_components/travel-time/PlanTravelTime.tsx b/src/app/(main)/plan/_components/travel-time/PlanTravelTime.tsx index d1e7242c..f8e897e7 100644 --- a/src/app/(main)/plan/_components/travel-time/PlanTravelTime.tsx +++ b/src/app/(main)/plan/_components/travel-time/PlanTravelTime.tsx @@ -106,6 +106,7 @@ export function PlanTravelTime({ roomId, scheduleId, segmentSourceItemId, + destinationGooglePlaceId ?? "", selectedMode, routeQueryEnabled, fpTrim, @@ -127,6 +128,7 @@ export function PlanTravelTime({ rid, scheduleId, segmentSourceItemId, + destinationGooglePlaceId ?? "", mode, fpTrim, { diff --git a/src/components/map/PlanItineraryMapRoutes.tsx b/src/components/map/PlanItineraryMapRoutes.tsx index 084ee376..1aae5a77 100644 --- a/src/components/map/PlanItineraryMapRoutes.tsx +++ b/src/components/map/PlanItineraryMapRoutes.tsx @@ -203,6 +203,7 @@ export function PlanItineraryMapRoutes({ rid, seg.scheduleId, seg.segmentSourceItemId, + seg.destPlaceId, seg.travelModeCanon, fingerprintByScheduleId.get(seg.scheduleId) ?? "", { segmentReady: true }, diff --git a/src/hooks/useRooms.ts b/src/hooks/useRooms.ts index f9561ac3..8acf6fba 100644 --- a/src/hooks/useRooms.ts +++ b/src/hooks/useRooms.ts @@ -176,6 +176,8 @@ export function useScheduleItemRoute( roomId: string | null, scheduleId: number | null, segmentSourceItemId: number | null, + /** 다음 장소의 `googlePlaceId` */ + destinationPlaceId: string, travelMode: ScheduleTravelModeValue, /** 일정 장소 목록상 구간 소스 아이디가 존재·목록 안정 후에만 true */ routeQueryEnabled = true, @@ -189,6 +191,7 @@ export function useScheduleItemRoute( rid, scheduleId, segmentSourceItemId, + destinationPlaceId, travelMode, scheduleFingerprint, { segmentReady: Boolean(routeQueryEnabled) }, diff --git a/src/lib/plan/schedule-bulk-hydration.test.ts b/src/lib/plan/schedule-bulk-hydration.test.ts index 850cbe25..0830440e 100644 --- a/src/lib/plan/schedule-bulk-hydration.test.ts +++ b/src/lib/plan/schedule-bulk-hydration.test.ts @@ -84,7 +84,7 @@ describe("hydrateScheduleRoutesBatch", () => { expect( queryClient.getQueryData( - scheduleItemRouteQueryKey(ROOM_ID, SCHEDULE_ID, 1, "DRIVING"), + scheduleItemRouteQueryKey(ROOM_ID, SCHEDULE_ID, 1, "places/b", "DRIVING"), ), ).toMatchObject({ distanceMeters: 1200, @@ -125,7 +125,7 @@ describe("hydrateScheduleRoutesBatch", () => { expect( queryClient.getQueryData( - scheduleItemRouteQueryKey(ROOM_ID, SCHEDULE_ID, 1, "DRIVING"), + scheduleItemRouteQueryKey(ROOM_ID, SCHEDULE_ID, 1, "places/b", "DRIVING"), ), ).not.toHaveProperty("encodedPolyline"); }); @@ -171,6 +171,7 @@ describe("hydrateScheduleRoutesBatch", () => { ROOM_ID, SCHEDULE_ID, 2, + "places/c", "WALKING", ); expect(queryClient.getQueryData(failedKey)).toBeUndefined(); @@ -179,6 +180,7 @@ describe("hydrateScheduleRoutesBatch", () => { roomId: ROOM_ID, scheduleId: SCHEDULE_ID, segmentSourceItemId: 2, + destinationPlaceId: "places/c", travelMode: "WALKING", }), ).rejects.toThrow("temporary single route failure"); @@ -188,6 +190,7 @@ describe("hydrateScheduleRoutesBatch", () => { roomId: ROOM_ID, scheduleId: SCHEDULE_ID, segmentSourceItemId: 2, + destinationPlaceId: "places/c", travelMode: "WALKING", }), ).resolves.toEqual(fallbackRoute); @@ -209,13 +212,14 @@ describe("hydrateScheduleRoutesBatch", () => { await hydrateScheduleRoutesBatch(queryClient, ROOM_ID, SCHEDULE_ID, places); expect( - queryClient.getQueryData(scheduleItemRouteQueryKey(ROOM_ID, SCHEDULE_ID, 2, "WALKING")), + queryClient.getQueryData(scheduleItemRouteQueryKey(ROOM_ID, SCHEDULE_ID, 2, "places/c", "WALKING")), ).toBeNull(); await expect( resolveScheduleSegmentRoute({ roomId: ROOM_ID, scheduleId: SCHEDULE_ID, segmentSourceItemId: 2, + destinationPlaceId: "places/c", travelMode: "WALKING", }), ).resolves.toBeNull(); @@ -242,7 +246,7 @@ describe("hydrateScheduleRoutesBatch", () => { expect( queryClient.getQueryData( - scheduleItemRouteQueryKey(ROOM_ID, SCHEDULE_ID, 1, "DRIVING"), + scheduleItemRouteQueryKey(ROOM_ID, SCHEDULE_ID, 1, "places/b", "DRIVING"), ), ).toBeUndefined(); }); @@ -255,8 +259,8 @@ describe("fetchAndSeedScheduleRoutesBatch", () => { function seededClient() { const queryClient = new QueryClient(); queryClient.setQueryData(scheduleItemsQueryKey(ROOM_ID, SCHEDULE_ID), places); - queryClient.setQueryData(scheduleItemRouteQueryKey(ROOM_ID, SCHEDULE_ID, 1, "DRIVING"), route); - queryClient.setQueryData(scheduleItemRouteQueryKey(ROOM_ID, SCHEDULE_ID, 2, "DRIVING"), route); + queryClient.setQueryData(scheduleItemRouteQueryKey(ROOM_ID, SCHEDULE_ID, 1, "places/b", "DRIVING"), route); + queryClient.setQueryData(scheduleItemRouteQueryKey(ROOM_ID, SCHEDULE_ID, 2, "places/c", "DRIVING"), route); return queryClient; } @@ -268,7 +272,7 @@ describe("fetchAndSeedScheduleRoutesBatch", () => { it("캐시에 없는 구간이 있으면 일괄로 다시 받는다", async () => { const queryClient = seededClient(); - queryClient.removeQueries({ queryKey: scheduleItemRouteQueryKey(ROOM_ID, SCHEDULE_ID, 2, "DRIVING") }); + queryClient.removeQueries({ queryKey: scheduleItemRouteQueryKey(ROOM_ID, SCHEDULE_ID, 2, "places/c", "DRIVING") }); getScheduleItemRoutesBatchMock.mockResolvedValue([]); await fetchAndSeedScheduleRoutesBatch(queryClient, ROOM_ID, SCHEDULE_ID, places); @@ -276,10 +280,23 @@ describe("fetchAndSeedScheduleRoutesBatch", () => { expect(getScheduleItemRoutesBatchMock).toHaveBeenCalledOnce(); }); + it("순서를 바꿨다가 되돌리면 받아 둔 경로가 있어 일괄 요청을 보내지 않는다", async () => { + const queryClient = seededClient(); + const reordered = [place(1, "places/a"), place(3, "places/c"), place(2, "places/b")]; + queryClient.setQueryData(scheduleItemsQueryKey(ROOM_ID, SCHEDULE_ID), reordered); + getScheduleItemRoutesBatchMock.mockResolvedValue([]); + + await fetchAndSeedScheduleRoutesBatch(queryClient, ROOM_ID, SCHEDULE_ID, reordered); + queryClient.setQueryData(scheduleItemsQueryKey(ROOM_ID, SCHEDULE_ID), places); + await fetchAndSeedScheduleRoutesBatch(queryClient, ROOM_ID, SCHEDULE_ID, places); + + expect(getScheduleItemRoutesBatchMock).toHaveBeenCalledOnce(); + }); + it("일정 변경으로 무효화된 구간이 있으면 일괄로 다시 받는다", async () => { const queryClient = seededClient(); await queryClient.invalidateQueries({ - queryKey: scheduleItemRouteQueryKey(ROOM_ID, SCHEDULE_ID, 1, "DRIVING"), + queryKey: scheduleItemRouteQueryKey(ROOM_ID, SCHEDULE_ID, 1, "places/b", "DRIVING"), refetchType: "none", }); getScheduleItemRoutesBatchMock.mockResolvedValue([]); diff --git a/src/lib/plan/schedule-bulk-hydration.ts b/src/lib/plan/schedule-bulk-hydration.ts index c52a647e..adfa42c0 100644 --- a/src/lib/plan/schedule-bulk-hydration.ts +++ b/src/lib/plan/schedule-bulk-hydration.ts @@ -292,6 +292,19 @@ function scheduleRoutesBatchKey( return `${roomId.trim()}:${scheduleId}:${placesFingerprint}`; } +/** 구간 출발 항목 → 다음 장소의 `googlePlaceId` (마지막 장소와 장소 ID가 빈 구간은 뺀다) */ +function segmentDestinationsByItemId(places: readonly PlanPlace[]): Map { + const out = new Map(); + for (let i = 0; i < places.length - 1; i += 1) { + const itemId = places[i]?.itemId; + const destination = places[i + 1]?.googlePlaceId?.trim() ?? ""; + if (typeof itemId === "number" && Number.isFinite(itemId) && destination.length) { + out.set(itemId, destination); + } + } + return out; +} + /** * 일차의 모든 구간 경로가 이미 캐시에 있는지 — 일정 화면에서 받아 뒀거나, 화면을 다시 열었거나, * 지도 경로 보기로 넘어온 경우다. 캐시가 지워졌거나(gc) 일정 변경으로 무효화된 구간이 하나라도 있으면 false. @@ -303,13 +316,16 @@ export function scheduleSegmentRoutesCached( places: readonly PlanPlace[], ): boolean { const rid = roomId.trim(); - const sources = places - .slice(0, -1) - .filter((p): p is PlanPlace & { itemId: number } => typeof p.itemId === "number" && Number.isFinite(p.itemId)); - if (!rid.length || !sources.length) return false; - return sources.every((place) => { + const destinationByItemId = segmentDestinationsByItemId(places); + if (!rid.length || !destinationByItemId.size) return false; + return places.every((place) => { + if (typeof place.itemId !== "number") return true; + const destination = destinationByItemId.get(place.itemId); + if (destination === undefined) return true; const mode = canonicalScheduleTravelMode(place.travelMode) ?? SCHEDULE_TRAVEL_MODE_DEFAULT; - const state = queryClient.getQueryState(scheduleItemRouteQueryKey(rid, scheduleId, place.itemId, mode)); + const state = queryClient.getQueryState( + scheduleItemRouteQueryKey(rid, scheduleId, place.itemId, destination, mode), + ); return state?.status === "success" && !state.isInvalidated; }); } @@ -393,7 +409,7 @@ export async function hydrateScheduleRoutesBatch( /** * 응답 대기 중 일정이 바뀌었으면(지문 불일치) 늦게 도착한 결과를 버립니다 — - * 구간 캐시 키는 출발 항목만 담고 있어 옛 목적지의 경로가 새 구간에 시딩될 수 있음. + * 서버는 요청을 받은 순간의 순서로 다음 장소를 정하므로, 그사이 순서가 바뀌었으면 어느 도착 장소의 경로인지 알 수 없다. */ const snapshotFp = schedulePlacesFingerprint([...places]); const currentPlaces = queryClient.getQueryData( @@ -402,13 +418,16 @@ export async function hydrateScheduleRoutesBatch( const currentFp = schedulePlacesFingerprint([...(currentPlaces ?? [])]); if (!snapshotFp.length || snapshotFp !== currentFp) return; + const destinationByItemId = segmentDestinationsByItemId(places); for (const result of results) { const itemId = result.itemId; const mode = canonicalScheduleTravelMode(result.travelMode); if (!mode) continue; if (typeof itemId !== "number" || !Number.isFinite(itemId)) continue; + const destination = destinationByItemId.get(itemId); + if (destination === undefined) continue; - const routeKey = scheduleItemRouteQueryKey(rid, scheduleId, itemId, mode); + const routeKey = scheduleItemRouteQueryKey(rid, scheduleId, itemId, destination, mode); if (isBatchItemOk(result)) { queryClient.setQueryData(routeKey, batchRouteItemToResponse(result)); } else if (result.errorCode && ROUTE_ANSWER_ERROR_CODES.has(result.errorCode)) { diff --git a/src/lib/plan/scheduleItemRoutePersistedQuery.test.ts b/src/lib/plan/scheduleItemRoutePersistedQuery.test.ts index 65afd3ff..f74b3ddc 100644 --- a/src/lib/plan/scheduleItemRoutePersistedQuery.test.ts +++ b/src/lib/plan/scheduleItemRoutePersistedQuery.test.ts @@ -42,6 +42,7 @@ describe("persistedScheduleItemRouteQueryOptions", () => { "room-1", 10, 1, + "place-b", "DRIVING", "", { segmentReady: true }, @@ -70,10 +71,34 @@ describe("persistedScheduleItemRouteQueryOptions", () => { }); }); +describe("persistedScheduleItemRouteQueryOptions key", () => { + it("도착 장소가 key에 들어가 같은 출발 항목이라도 도착 장소마다 다른 캐시를 쓴다", () => { + const toB = persistedScheduleItemRouteQueryOptions("room-1", 10, 1, "place-b", "DRIVING", "", { + segmentReady: true, + }); + const toC = persistedScheduleItemRouteQueryOptions("room-1", 10, 1, "place-c", "DRIVING", "", { + segmentReady: true, + }); + + expect(toB.queryKey).toEqual(["schedule-item-route", "room-1", 10, 1, "place-b", "DRIVING"]); + expect(toC.queryKey).not.toEqual(toB.queryKey); + }); + + it("도착 장소가 없으면 조회하지 않는다", () => { + const options = persistedScheduleItemRouteQueryOptions("room-1", 10, 1, " ", "DRIVING", "", { + segmentReady: true, + }); + + expect(options.enabled).toBe(false); + }); +}); + describe("loadScheduleItemRouteForMap", () => { const route = { travelMode: "TRANSIT", distanceMeters: 1700, durationSeconds: 1140 }; const routeOptions = () => - persistedScheduleItemRouteQueryOptions("room-1", 10, 1, "TRANSIT", "", { segmentReady: true }); + persistedScheduleItemRouteQueryOptions("room-1", 10, 1, "place-b", "TRANSIT", "", { + segmentReady: true, + }); it("목록 요청이 방금 실패한 구간은 다시 보내지 않고 가진 값을 쓴다", async () => { const queryClient = new QueryClient(); diff --git a/src/lib/plan/scheduleItemRoutePersistedQuery.ts b/src/lib/plan/scheduleItemRoutePersistedQuery.ts index 70d4346c..978ecbe1 100644 --- a/src/lib/plan/scheduleItemRoutePersistedQuery.ts +++ b/src/lib/plan/scheduleItemRoutePersistedQuery.ts @@ -15,7 +15,7 @@ type RouteCached = ScheduleItemRouteResponse | null; /** * — `segmentReady`: 네트워크 패치·sessionStorage 시드 허용(`routeQueryEnabled`). 쿼리 키는 이것과 무관하게 - * `(room, schedule, 구간, 모드)`로 고정 — 일정 refetch 중 잠깐 false가 되어도 구간끼리 캐시를 공유하지 않음 + * `(room, schedule, 구간, 도착 장소, 모드)`로 고정 — 일정 refetch 중 잠깐 false가 되어도 구간끼리 캐시를 공유하지 않음 * — `networkEnabled` 생략 시 `segmentReady`와 동일; 지연 수단은 메뉴 열림 시만 `true` */ export type PersistedScheduleRoutePersistFlags = { @@ -30,6 +30,8 @@ export function persistedScheduleItemRouteQueryOptions( roomIdTrimmed: string, scheduleId: number | null, segmentSourceItemId: number | null, + /** 다음 장소의 `googlePlaceId` — 비어 있으면 구간이 정해지지 않아 조회하지 않는다 */ + destinationPlaceId: string, travelMode: ScheduleTravelModeValue, fingerprintTrimmed: string, flags: PersistedScheduleRoutePersistFlags, @@ -43,10 +45,11 @@ export function persistedScheduleItemRouteQueryOptions( const itemOk = typeof segmentSourceItemId === "number" && Number.isFinite(segmentSourceItemId); + const dest = destinationPlaceId.trim(); const fp = fingerprintTrimmed.trim(); const keyEligible = - flags.segmentReady && rid.length > 0 && sidOk && itemOk; + flags.segmentReady && rid.length > 0 && sidOk && itemOk && dest.length > 0; const network = typeof flags.networkEnabled === "boolean" @@ -58,8 +61,9 @@ export function persistedScheduleItemRouteQueryOptions( const keyRoom = rid.length > 0 ? rid : null; const keySchedule = sidOk ? sid : null; const keySegment = itemOk ? segmentSourceItemId : null; + const keyDestination = dest.length > 0 ? dest : null; const keyMode = - sidOk && itemOk && rid.length > 0 ? travelMode : null; + sidOk && itemOk && rid.length > 0 && dest.length > 0 ? travelMode : null; const sessionSeed: RouteCached | undefined = fp.length > 0 && @@ -80,6 +84,7 @@ export function persistedScheduleItemRouteQueryOptions( keyRoom, keySchedule, keySegment, + keyDestination, keyMode, ), ...(sessionSeed !== undefined @@ -93,6 +98,7 @@ export function persistedScheduleItemRouteQueryOptions( roomId: rid, scheduleId: sid!, segmentSourceItemId: segmentSourceItemId!, + destinationPlaceId: dest, travelMode, scheduleFingerprint: fp, }), diff --git a/src/lib/plan/scheduleSegmentRoute.test.ts b/src/lib/plan/scheduleSegmentRoute.test.ts index 9fafad1a..6d9c0c6b 100644 --- a/src/lib/plan/scheduleSegmentRoute.test.ts +++ b/src/lib/plan/scheduleSegmentRoute.test.ts @@ -18,6 +18,7 @@ let getScheduleItemRouteMock: ReturnType; const ROOM_ID = "room-1"; const SCHEDULE_ID = 10; const ITEM_ID = 1; +const DESTINATION = "place-b"; const ROUTE = { distanceMeters: 1200, @@ -50,14 +51,15 @@ beforeEach(() => { registerQueryClient(queryClient); }); -const ROUTE_KEY = () => - scheduleItemRouteQueryKey(ROOM_ID, SCHEDULE_ID, ITEM_ID, "DRIVING"); +const ROUTE_KEY = (destination = DESTINATION) => + scheduleItemRouteQueryKey(ROOM_ID, SCHEDULE_ID, ITEM_ID, destination, "DRIVING"); -function resolve() { +function resolve(destinationPlaceId = DESTINATION) { return resolveScheduleSegmentRoute({ roomId: ROOM_ID, scheduleId: SCHEDULE_ID, segmentSourceItemId: ITEM_ID, + destinationPlaceId, travelMode: "DRIVING", }); } @@ -83,10 +85,16 @@ describe("resolveScheduleSegmentRoute", () => { await expect(resolve()).resolves.toBeNull(); }); - /** - * 재정렬로 무효화된 구간은 같은 키라도 목적지가 달라져 있습니다. - * 낡은 값을 그대로 돌려주면 지도에 이전 순서의 폴리라인이 남습니다. - */ + it("같은 출발 항목이라도 도착 장소가 다르면 그 도착 장소의 경로를 받고, 순서를 되돌리면 받아 둔 경로를 쓴다", async () => { + queryClient.setQueryData(ROUTE_KEY("place-b"), ROUTE); + const toC = { ...ROUTE, distanceMeters: 999, encodedPolyline: "zzz" }; + getScheduleItemRouteMock.mockResolvedValue(toC); + + await expect(resolve("place-c")).resolves.toEqual(toC); + await expect(resolve("place-b")).resolves.toEqual(ROUTE); + expect(getScheduleItemRouteMock).toHaveBeenCalledTimes(1); + }); + it("캐시가 무효화된 상태면 낡은 값을 쓰지 않고 다시 조회한다", async () => { queryClient.setQueryData(ROUTE_KEY(), ROUTE); await queryClient.invalidateQueries({ diff --git a/src/lib/plan/scheduleSegmentRoute.ts b/src/lib/plan/scheduleSegmentRoute.ts index 6667d654..4f8b480b 100644 --- a/src/lib/plan/scheduleSegmentRoute.ts +++ b/src/lib/plan/scheduleSegmentRoute.ts @@ -13,13 +13,15 @@ export type ResolveScheduleSegmentRouteArgs = { roomId: string; scheduleId: number; segmentSourceItemId: number; + /** 다음 장소의 `googlePlaceId` — 캐시 key에만 쓴다(서버는 출발 항목의 다음 항목으로 계산) */ + destinationPlaceId: string; travelMode: ScheduleTravelModeValue; /** 일정 지문 — 비면 sessionStorage에 기록하지 않습니다. */ scheduleFingerprint?: string; }; /** - * 구간 `(room, schedule, 출발 항목, 이동수단)` 경로를 가져오는 fetch 본체입니다. + * 구간 `(room, schedule, 출발 항목, 도착 장소, 이동수단)` 경로를 가져오는 fetch 본체입니다. * 거리·소요 시간과 지도 폴리라인이 같은 캐시 항목을 공유하도록 일정 리스트와 지도가 모두 * 이 함수를 queryFn으로 쓰는 하나의 쿼리(`scheduleItemRouteQueryKey`)를 거칩니다. * @@ -31,6 +33,7 @@ export async function resolveScheduleSegmentRoute({ roomId, scheduleId, segmentSourceItemId, + destinationPlaceId, travelMode, scheduleFingerprint, }: ResolveScheduleSegmentRouteArgs): Promise { @@ -44,6 +47,7 @@ export async function resolveScheduleSegmentRoute({ rid, scheduleId, segmentSourceItemId, + destinationPlaceId, travelMode, ); const cached = getQueryClient()?.getQueryState(routeKey); diff --git a/src/lib/query-keys.ts b/src/lib/query-keys.ts index dee035b1..d7b73bdc 100644 --- a/src/lib/query-keys.ts +++ b/src/lib/query-keys.ts @@ -121,12 +121,15 @@ export const scheduleItemsQueryKey = ( /** * 구간별 길찾기 API 결과 (`GET …/route?travelMode=…`). + * 서버는 출발 항목에서 그 순간의 다음 항목까지 경로를 주므로 도착 장소를 key에 넣는다 — + * 순서가 바뀌면 다른 key가 되고, 순서를 되돌리면 받아 둔 값을 그대로 쓴다. * 무효화: `["schedule-item-route", roomId]` prefix. */ export const scheduleItemRouteQueryKey = ( roomId: string | null, scheduleId: number | null, segmentSourceItemId: number | null, + destinationPlaceId: string | null, travelMode: ScheduleTravelModeValue | null, ) => [ @@ -134,6 +137,7 @@ export const scheduleItemRouteQueryKey = ( roomId, scheduleId, segmentSourceItemId, + destinationPlaceId, travelMode, ] as const; From c3297070904091ca379f96c7c9d2c451789bb3d2 Mon Sep 17 00:00:00 2001 From: KangShinGyu Date: Tue, 3 Nov 2026 23:24:52 +0900 Subject: [PATCH 02/14] =?UTF-8?q?refactor(route):=20=EB=8F=84=EC=B0=A9=20?= =?UTF-8?q?=EC=9E=A5=EC=86=8C=EA=B0=80=20key=EC=97=90=20=EB=93=A4=EC=96=B4?= =?UTF-8?q?=EA=B0=80=20=ED=95=84=EC=9A=94=20=EC=97=86=EC=96=B4=EC=A7=84=20?= =?UTF-8?q?=EA=B2=BD=EB=A1=9C=20=EB=AC=B4=ED=9A=A8=ED=99=94=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 순서 변경·장소 추가·삭제·일차 간 이동·이동수단 변경(본인·STOMP)과 일정 전체 재조회 뒤의 구간 경로 무효화를 지운다 — 바뀐 구간은 새 key로 받는다 - 구간 계산·무효화 모듈(scheduleStompRouteScope.ts)과 STOMP 경로 무효화 디바운스 삭제 - 장소 삭제는 응답으로 일차 목록에서 그 장소만 빼고, 일차 삭제 뒤의 경로 무효화도 지운다 - 지도 에폭 증가 호출 삭제 (지도 key 정리는 다음 커밋) Co-Authored-By: Claude Opus 5.5 --- src/hooks/useExpenseInvalidation.test.ts | 2 +- src/hooks/usePrefetchScheduleRoutes.ts | 2 +- src/hooks/useRooms.ts | 26 +- src/lib/plan/planTravelLocalStorage.ts | 15 - src/lib/plan/schedule-bulk-hydration.ts | 21 +- .../plan/scheduleItemPlaces.create.test.ts | 112 +------ src/lib/plan/scheduleItemPlaces.ts | 195 +----------- src/lib/plan/scheduleSegmentRoute.test.ts | 5 +- src/lib/plan/scheduleSegmentRoute.ts | 4 +- src/lib/plan/scheduleStompRouteScope.test.ts | 30 -- src/lib/plan/scheduleStompRouteScope.ts | 301 ------------------ .../stomp/schedules-dispatch.order.test.ts | 15 - src/lib/stomp/schedules-dispatch.test.ts | 173 ++-------- src/lib/stomp/schedules-dispatch.ts | 183 +---------- 14 files changed, 63 insertions(+), 1021 deletions(-) delete mode 100644 src/lib/plan/scheduleStompRouteScope.test.ts delete mode 100644 src/lib/plan/scheduleStompRouteScope.ts diff --git a/src/hooks/useExpenseInvalidation.test.ts b/src/hooks/useExpenseInvalidation.test.ts index ff32c7c3..95d2b099 100644 --- a/src/hooks/useExpenseInvalidation.test.ts +++ b/src/hooks/useExpenseInvalidation.test.ts @@ -17,7 +17,7 @@ vi.mock("@/lib/rooms", async (importOriginal) => ({ })); vi.mock("@/lib/plan/scheduleItemPlaces", () => ({ syncAfterCrossScheduleItemMove: vi.fn(), - applyScheduleItemDeletedOnClient: vi.fn(), + removeScheduleItemFromPlanPlacesCache: vi.fn(), })); import { useMoveRoomSchedule, diff --git a/src/hooks/usePrefetchScheduleRoutes.ts b/src/hooks/usePrefetchScheduleRoutes.ts index b4c52dd3..68373968 100644 --- a/src/hooks/usePrefetchScheduleRoutes.ts +++ b/src/hooks/usePrefetchScheduleRoutes.ts @@ -7,7 +7,7 @@ import type { PlanPlace } from "@/lib/plan/types"; /** * 페이지 진입·새로고침 시 일차별 구간 경로를 저장된 공유 이동수단 기준 `routes/batch`로 1회 시딩합니다. - * 리오더·추가·삭제 등으로 장소 목록이 바뀌면 batch는 재호출하지 않고, 무효화된 구간만 개별 GET합니다. + * 리오더·추가·삭제 등으로 장소 목록이 바뀌면 batch는 재호출하지 않고, 새로 생긴 구간(받은 적 없는 출발·도착 장소)만 개별 GET합니다. */ export function usePrefetchScheduleRoutes( roomId: string, diff --git a/src/hooks/useRooms.ts b/src/hooks/useRooms.ts index 8acf6fba..c8a8de76 100644 --- a/src/hooks/useRooms.ts +++ b/src/hooks/useRooms.ts @@ -59,8 +59,8 @@ import { import { applyCreatedScheduleItemToPlanPlaces, applyRoomScheduleItemToPlanPlaces, - applyScheduleItemDeletedOnClient, createScheduleItemAtPlanIndex, + removeScheduleItemFromPlanPlacesCache, schedulePlanPlacesQueryOptions, syncAfterCrossScheduleItemMove, syncPlanPlacesAfterReorderSuccess, @@ -92,9 +92,7 @@ import { pathSuspendsStomp, } from "@/lib/stomp/stompPathPolicy"; import { persistedScheduleItemRouteQueryOptions } from "@/lib/plan/scheduleItemRoutePersistedQuery"; -import { invalidateScheduleItemRouteForWholeSchedule } from "@/lib/plan/scheduleStompRouteScope"; import type { ScheduleTravelModeValue } from "@/lib/plan/scheduleTravelMode"; -import { usePlanMapDirectionsEpochStore } from "@/stores/plan-map-directions-epoch-store"; import { useSessionUser } from "@/hooks/useSessionUser"; import { applyHostTransferToCache } from "@/lib/rooms/host-transfer-cache"; import { readSessionUserId } from "@/lib/session-user-cache"; @@ -209,22 +207,12 @@ export function useDeleteRoomSchedule() { roomId: string; scheduleId: number; }) => deleteRoomSchedule(roomId, scheduleId), - onSuccess: async (_data, { roomId, scheduleId }) => { + onSuccess: async (_data, { roomId }) => { await invalidateExpenses(queryClient, roomId); const id = roomId.trim(); if (!id.length) return; - const sorted = await refreshRoomSchedules(queryClient, id); - const scheduleStillExists = sorted.some((s) => s.scheduleId === scheduleId); - if (scheduleStillExists) { - await invalidateScheduleItemRouteForWholeSchedule( - queryClient, - id, - scheduleId, - ); - usePlanMapDirectionsEpochStore.getState().bumpForDirections(id); - } - + await refreshRoomSchedules(queryClient, id); await syncRoomDetailFromServer(queryClient, id); }, }); @@ -303,7 +291,7 @@ export function useMoveScheduleItemToSchedule() { }, onSuccess: async ( _moved, - { roomId, sourceScheduleId, targetScheduleId, itemId }, + { roomId, sourceScheduleId, targetScheduleId }, ) => { await invalidateExpenses(queryClient, roomId); await syncAfterCrossScheduleItemMove( @@ -311,7 +299,6 @@ export function useMoveScheduleItemToSchedule() { roomId, sourceScheduleId, targetScheduleId, - itemId, ); }, }); @@ -453,7 +440,7 @@ export function useDeleteScheduleItem() { }) => deleteScheduleItem(vars.roomId, vars.scheduleId, vars.itemId), onSuccess: async (_, { roomId, scheduleId, itemId }) => { await invalidateExpenses(queryClient, roomId); - await applyScheduleItemDeletedOnClient( + removeScheduleItemFromPlanPlacesCache( queryClient, roomId, scheduleId, @@ -481,13 +468,12 @@ export function useReorderScheduleItem() { vars.itemId, vars.body, ), - onSuccess: (items, { roomId, scheduleId, itemId }) => + onSuccess: (items, { roomId, scheduleId }) => syncPlanPlacesAfterReorderSuccess( queryClient, roomId, scheduleId, items, - itemId, ), onSettled: (_items, _error, { roomId, scheduleId }) => invalidateDayPlacesAfterOwnChange(queryClient, roomId, scheduleId), diff --git a/src/lib/plan/planTravelLocalStorage.ts b/src/lib/plan/planTravelLocalStorage.ts index 4a385ff8..2908a5ce 100644 --- a/src/lib/plan/planTravelLocalStorage.ts +++ b/src/lib/plan/planTravelLocalStorage.ts @@ -75,21 +75,6 @@ function removeSessionStorageKeysMatchingPrefixes(prefixes: string[]): void { } } -/** 지정 구간(`segmentSourceItemId`)의 경로 sessionStorage만 제거 */ -export function clearPersistedScheduleRoutesForSegmentSources( - roomId: string, - scheduleId: number, - segmentSourceItemIds: number[], -): void { - const rid = roomId.trim(); - if (!rid.length || segmentSourceItemIds.length === 0) return; - removeSessionStorageKeysMatchingPrefixes( - segmentSourceItemIds.map( - (id) => `${NS_ROUTE}:${rid}:${scheduleId}:${id}:`, - ), - ); -} - /** 해당 일차 경로 sessionStorage 항목 일괄 제거 */ export function clearPersistedScheduleRoutesForSchedule( roomId: string, diff --git a/src/lib/plan/schedule-bulk-hydration.ts b/src/lib/plan/schedule-bulk-hydration.ts index adfa42c0..6eaad4f5 100644 --- a/src/lib/plan/schedule-bulk-hydration.ts +++ b/src/lib/plan/schedule-bulk-hydration.ts @@ -18,13 +18,6 @@ import { } from "@/lib/places/place-queries"; import { schedulePlacesFingerprint } from "@/lib/plan/planTravelLocalStorage"; import { sortRoomSchedules } from "@/lib/plan/scheduleMerge"; -import { - bumpPlanMapRouteSegments, - invalidateScheduleItemRouteForSources, - orderedItemIdsFromPlanPlaces, - readOrderedItemIdsFromScheduleItemsCache, - segmentSourcesWithChangedNext, -} from "@/lib/plan/scheduleStompRouteScope"; import { SCHEDULE_TRAVEL_MODE_DEFAULT, canonicalScheduleTravelMode, @@ -209,7 +202,7 @@ export async function buildSchedulePlacesFromSchedulesWithItems( } /** - * 만든 일차 목록을 한 번에 씁니다. 순서가 바뀐 일차는 이웃이 바뀐 구간 경로만 무효화합니다. + * 만든 일차 목록을 한 번에 씁니다. 경로는 도착 장소까지 key에 있어 순서가 바뀌어도 무효화하지 않습니다. * `startedAt`(일정 전체 조회를 보낸 시각) 뒤에 바뀐 일차는 그쪽이 더 새 상태라 덮지 않고, * 그 일차를 받는 중이면 이 조회보다 먼저 보낸 요청일 수 있어 다시 받게 합니다. */ @@ -227,20 +220,10 @@ export function writeSchedulePlacesFromHydrate( const state = queryClient.getQueryState(key); if (state && state.dataUpdatedAt > startedAt) continue; - const before = readOrderedItemIdsFromScheduleItemsCache(queryClient, rid, sid); queryClient.setQueryData(key, places); if (state?.fetchStatus === "fetching") { void queryClient.refetchQueries({ queryKey: key, exact: true }); } - - const changed = segmentSourcesWithChangedNext( - before, - orderedItemIdsFromPlanPlaces(places), - ); - if (changed.length) { - void invalidateScheduleItemRouteForSources(queryClient, rid, sid, changed); - bumpPlanMapRouteSegments(rid, sid, changed); - } } } @@ -307,7 +290,7 @@ function segmentDestinationsByItemId(places: readonly PlanPlace[]): Map ({ - bumpForDirections: vi.fn(), - bumpPlanMapRouteSegments: vi.fn(), createScheduleItem: vi.fn(), fetchPlacePreview: vi.fn(), getScheduleItems: vi.fn(), - invalidateScheduleItemRouteForSources: vi.fn(), - invalidateScheduleItemRouteForWholeSchedule: vi.fn(), - invalidateScheduleItemRoutesAfterDelete: vi.fn(), - invalidateScheduleItemRoutesAfterItemCreated: vi.fn(), - readOrderedItemIdsFromScheduleItemsCache: vi.fn(), })); vi.mock("@/lib/api/rooms/schedule-items", () => ({ @@ -31,22 +24,6 @@ vi.mock("@/lib/places/place-queries", () => ({ fetchPlacePreview: mocks.fetchPlacePreview, })); -vi.mock("@/lib/plan/scheduleStompRouteScope", () => ({ - bumpPlanMapRouteSegments: mocks.bumpPlanMapRouteSegments, - invalidateScheduleItemRouteForSources: - mocks.invalidateScheduleItemRouteForSources, - invalidateScheduleItemRouteForWholeSchedule: - mocks.invalidateScheduleItemRouteForWholeSchedule, - invalidateScheduleItemRoutesAfterDelete: - mocks.invalidateScheduleItemRoutesAfterDelete, - invalidateScheduleItemRoutesAfterItemCreated: - mocks.invalidateScheduleItemRoutesAfterItemCreated, - invalidateScheduleItemRoutesAfterReorder: vi.fn(), - readOrderedItemIdsFromScheduleItemsCache: - mocks.readOrderedItemIdsFromScheduleItemsCache, - removeRouteQueriesForDeletedItemSource: vi.fn(), -})); - vi.mock("@/lib/rooms", () => ({ awaitRoomSchedulesHydrated: vi.fn(), })); @@ -55,12 +32,6 @@ vi.mock("@/lib/query-client", () => ({ getQueryClient: vi.fn(), })); -vi.mock("@/stores/plan-map-directions-epoch-store", () => ({ - usePlanMapDirectionsEpochStore: { - getState: () => ({ bumpForDirections: mocks.bumpForDirections }), - }, -})); - let createScheduleItemAtPlanIndex: typeof import("@/lib/plan/scheduleItemPlaces").createScheduleItemAtPlanIndex; let applyCreatedScheduleItemToPlanPlaces: typeof import("@/lib/plan/scheduleItemPlaces").applyCreatedScheduleItemToPlanPlaces; let syncAfterCrossScheduleItemMove: typeof import("@/lib/plan/scheduleItemPlaces").syncAfterCrossScheduleItemMove; @@ -96,10 +67,6 @@ describe("createScheduleItemAtPlanIndex", () => { name: "Created place", formattedAddress: "Address", }); - mocks.invalidateScheduleItemRouteForSources.mockResolvedValue(undefined); - mocks.invalidateScheduleItemRoutesAfterItemCreated.mockResolvedValue( - undefined, - ); }); const response: CreateScheduleItemResponse = { @@ -131,7 +98,7 @@ describe("createScheduleItemAtPlanIndex", () => { }); }); - it("merges the create response and invalidates only the route IDs it returns", async () => { + it("merges the create response into the day list", async () => { const queryClient = new QueryClient(); const key = scheduleItemsQueryKey("room-1", 10); queryClient.setQueryData(key, places); @@ -141,82 +108,27 @@ describe("createScheduleItemAtPlanIndex", () => { expect( queryClient.getQueryData(key)?.map((place) => place.itemId), ).toEqual([1, 3, 2]); - expect( - mocks.invalidateScheduleItemRoutesAfterItemCreated, - ).not.toHaveBeenCalled(); - expect(mocks.invalidateScheduleItemRouteForSources).toHaveBeenCalledWith( - queryClient, - "room-1", - 10, - [777], - ); - expect(mocks.bumpPlanMapRouteSegments).toHaveBeenCalledWith( - "room-1", - 10, - [777], - ); }); - it("does not fail the saved create when post-create route synchronization fails", async () => { + it("merges a placeholder place when the created place preview fails", async () => { const queryClient = new QueryClient(); - mocks.invalidateScheduleItemRouteForSources.mockRejectedValueOnce( - new Error("route sync failed"), - ); - queryClient.setQueryData(scheduleItemsQueryKey("room-1", 10), places); + const key = scheduleItemsQueryKey("room-1", 10); + mocks.fetchPlacePreview.mockRejectedValueOnce(new Error("preview 429")); + queryClient.setQueryData(key, places); await expect( applyCreatedScheduleItemToPlanPlaces(queryClient, "room-1", 10, response), ).resolves.toBeUndefined(); - expect( - mocks.invalidateScheduleItemRouteForWholeSchedule, - ).toHaveBeenCalledWith(queryClient, "room-1", 10); - expect(mocks.bumpForDirections).toHaveBeenCalledWith("room-1"); - }); - - it("maps server-provided move route IDs to the refreshed schedules", async () => { - const queryClient = new QueryClient(); - mocks.getScheduleItems.mockResolvedValue([]); - mocks.readOrderedItemIdsFromScheduleItemsCache.mockImplementation( - (_queryClient: QueryClient, _roomId: string, scheduleId: number) => - scheduleId === 10 ? [11] : [20, 21], - ); - - await syncAfterCrossScheduleItemMove( - queryClient, - "room-1", - 10, - 20, - 20, - [11, 21, 20], - ); - - expect(mocks.invalidateScheduleItemRoutesAfterDelete).not.toHaveBeenCalled(); - expect( - mocks.invalidateScheduleItemRoutesAfterItemCreated, - ).not.toHaveBeenCalled(); - expect(mocks.invalidateScheduleItemRouteForSources).toHaveBeenNthCalledWith( - 1, - queryClient, - "room-1", - 10, - [11], - ); - expect(mocks.invalidateScheduleItemRouteForSources).toHaveBeenNthCalledWith( - 2, - queryClient, - "room-1", - 20, - [21, 20], - ); - expect( - mocks.invalidateScheduleItemRouteForWholeSchedule, - ).not.toHaveBeenCalled(); + expect(queryClient.getQueryData(key)?.[1]).toMatchObject({ + itemId: 3, + title: "장소 정보를 불러올 수 없음", + }); }); }); describe("cross-schedule refresh sequencing", () => { - it("waits for source refresh before target and routes", async () => { + it("waits for source refresh before target", async () => { const client = new QueryClient(); let finishSource!: () => void; const source = new Promise((resolve) => { finishSource = resolve; }); @@ -234,12 +146,10 @@ describe("cross-schedule refresh sequencing", () => { await syncAfterCrossScheduleItemMove(client, "room-1", 10, 10); expect(invalidate).toHaveBeenCalledTimes(1); }); - it("does not refresh target or routes when source refresh fails", async () => { - vi.clearAllMocks(); + it("does not refresh target when source refresh fails", async () => { const client = new QueryClient(); const invalidate = vi.spyOn(client, "invalidateQueries").mockRejectedValue(new Error("source failed")); await expect(syncAfterCrossScheduleItemMove(client, "room-1", 10, 20)).rejects.toThrow("source failed"); expect(invalidate).toHaveBeenCalledTimes(1); - expect(mocks.invalidateScheduleItemRouteForWholeSchedule).not.toHaveBeenCalled(); }); }); diff --git a/src/lib/plan/scheduleItemPlaces.ts b/src/lib/plan/scheduleItemPlaces.ts index a2aef4fa..1443483f 100644 --- a/src/lib/plan/scheduleItemPlaces.ts +++ b/src/lib/plan/scheduleItemPlaces.ts @@ -22,16 +22,6 @@ import { resolveScheduleItemsFromCacheOrHydrate, } from "@/lib/plan/schedule-bulk-hydration"; import { getQueryClient } from "@/lib/query-client"; -import { - bumpPlanMapRouteSegments, - invalidateScheduleItemRouteForSources, - invalidateScheduleItemRouteForWholeSchedule, - invalidateScheduleItemRoutesAfterDelete, - invalidateScheduleItemRoutesAfterItemCreated, - invalidateScheduleItemRoutesAfterReorder, - readOrderedItemIdsFromScheduleItemsCache, - removeRouteQueriesForDeletedItemSource, -} from "@/lib/plan/scheduleStompRouteScope"; import { scheduleItemsQueryKey } from "@/lib/query-keys"; import { addMinutesToHm, @@ -41,7 +31,6 @@ import { normalizeStartTimeToHm, } from "@/lib/plan/scheduleTime"; import type { PlanPlace } from "@/lib/plan/types"; -import { usePlanMapDirectionsEpochStore } from "@/stores/plan-map-directions-epoch-store"; /** `orderIndex` 기준 정렬(비변형) */ export function sortRoomScheduleItemsByOrder( @@ -194,48 +183,12 @@ export function removeScheduleItemFromPlanPlacesCache( ); } -/** 본인 DELETE mutation 직후 — 캐시 패치·삭제 구간 route·쿼리 정리 */ -export async function applyScheduleItemDeletedOnClient( - queryClient: QueryClient, - roomId: string, - scheduleId: number, - deletedItemId: number, -): Promise { - const rid = roomId.trim(); - if (!rid.length) return; - - const oldIds = readOrderedItemIdsFromScheduleItemsCache( - queryClient, - rid, - scheduleId, - ); - removeScheduleItemFromPlanPlacesCache( - queryClient, - rid, - scheduleId, - deletedItemId, - ); - removeRouteQueriesForDeletedItemSource( - queryClient, - rid, - scheduleId, - deletedItemId, - ); - await invalidateScheduleItemRoutesAfterDelete( - queryClient, - rid, - scheduleId, - deletedItemId, - oldIds, - ); -} - /** * STOMP·뮤테이션 동기화: 그 일차 장소 목록을 서버에서 다시 받게 합니다. 보는 화면이 있으면 바로 받고, * 없으면 다시 받을 목록으로 표시해 두었다가 그 화면에 들어갈 때 받습니다. * 같은 일차를 받는 중이면 그 조회는 이 변경 전에 보낸 요청이라 취소하고 새로 받습니다(늦게 온 옛 응답은 버려짐). * 본인 저장의 반향처럼 받는 중인 조회가 이미 그 변경 뒤에 보낸 것이면 `joinInFlight`로 합류해 요청을 아낍니다. - * 다시 받지 못하면 throw합니다 — 호출부는 목록이 바뀐 뒤에만 경로를 무효화합니다. + * 다시 받지 못하면 throw합니다. */ export async function invalidateSchedulePlanPlaces( queryClient: QueryClient, @@ -275,98 +228,23 @@ export async function mergeOrRefetchSchedulePlanPlacesFromItems( ); } -/** cross-day item 이동 후 source/target 일차 items·route 캐시 동기화 */ +/** + * 다른 일차로 옮긴 뒤 두 일차 장소 목록을 다시 받습니다. 경로는 도착 장소까지 key에 있어 + * 순서가 바뀐 구간은 새 key로 받으므로 따로 무효화하지 않습니다. + */ export async function syncAfterCrossScheduleItemMove( queryClient: QueryClient, roomId: string, sourceScheduleId: number, targetScheduleId: number, - movedItemId?: number, - affectedRouteItemIds?: number[] | null, ): Promise { const rid = roomId.trim(); if (!rid.length) return; - const scheduleIds = - sourceScheduleId === targetScheduleId - ? [sourceScheduleId] - : [sourceScheduleId, targetScheduleId]; - - const oldSourceOrderedIds = - movedItemId != null - ? readOrderedItemIdsFromScheduleItemsCache( - queryClient, - rid, - sourceScheduleId, - ) - : null; - await invalidateSchedulePlanPlaces(queryClient, rid, sourceScheduleId); if (targetScheduleId !== sourceScheduleId) { await invalidateSchedulePlanPlaces(queryClient, rid, targetScheduleId); } - - if (affectedRouteItemIds !== undefined) { - if (affectedRouteItemIds === null) { - for (const sid of scheduleIds) { - await invalidateScheduleItemRouteForWholeSchedule(queryClient, rid, sid); - } - usePlanMapDirectionsEpochStore.getState().bumpForDirections(rid); - return; - } - - const remaining = new Set(affectedRouteItemIds); - for (const sid of scheduleIds) { - const orderedIds = readOrderedItemIdsFromScheduleItemsCache( - queryClient, - rid, - sid, - ); - const scheduleItemIds = new Set(orderedIds ?? []); - const sources = affectedRouteItemIds.filter( - (id) => remaining.has(id) && scheduleItemIds.has(id), - ); - for (const id of sources) remaining.delete(id); - await invalidateScheduleItemRouteForSources( - queryClient, - rid, - sid, - sources, - ); - bumpPlanMapRouteSegments(rid, sid, sources); - } - - if (remaining.size > 0) { - for (const sid of scheduleIds) { - await invalidateScheduleItemRouteForWholeSchedule(queryClient, rid, sid); - } - usePlanMapDirectionsEpochStore.getState().bumpForDirections(rid); - } - return; - } - - if (movedItemId != null) { - await invalidateScheduleItemRoutesAfterDelete( - queryClient, - rid, - sourceScheduleId, - movedItemId, - oldSourceOrderedIds, - ); - await invalidateScheduleItemRoutesAfterItemCreated( - queryClient, - rid, - targetScheduleId, - movedItemId, - ); - return; - } - - for (const sid of scheduleIds) { - await invalidateScheduleItemRouteForWholeSchedule(queryClient, rid, sid); - } - - usePlanMapDirectionsEpochStore.getState().bumpForDirections(rid); } /** 캐시에 preview 에러 title이 있으면 enrich 후 `schedule-items`를 갱신합니다. */ @@ -541,17 +419,10 @@ export async function syncPlanPlacesAfterReorderSuccess( roomId: string, scheduleId: number, items: RoomScheduleItem[], - movedItemId: number, ): Promise { const rid = roomId.trim(); if (!rid.length) return; - const oldOrderedIds = readOrderedItemIdsFromScheduleItemsCache( - queryClient, - rid, - scheduleId, - ); - await mergeOrRefetchSchedulePlanPlacesFromItems( queryClient, roomId, @@ -560,17 +431,7 @@ export async function syncPlanPlacesAfterReorderSuccess( ); const patches = buildChainedStartPatchesForReorder(items); - if (patches.length === 0) { - await invalidateScheduleItemRoutesAfterReorder( - queryClient, - rid, - scheduleId, - movedItemId, - oldOrderedIds, - items, - ); - return; - } + if (patches.length === 0) return; const snapshot = sortRoomScheduleItemsByOrder(items); const byItemId = new Map(snapshot.map((it) => [it.itemId, it])); @@ -589,20 +450,11 @@ export async function syncPlanPlacesAfterReorderSuccess( return; } - const finalItems = sortRoomScheduleItemsByOrder([...byItemId.values()]); await mergeOrRefetchSchedulePlanPlacesFromItems( queryClient, roomId, scheduleId, - finalItems, - ); - await invalidateScheduleItemRoutesAfterReorder( - queryClient, - rid, - scheduleId, - movedItemId, - oldOrderedIds, - finalItems, + sortRoomScheduleItemsByOrder([...byItemId.values()]), ); } @@ -733,37 +585,20 @@ export function createScheduleItemAtPlanIndex(args: { }); } -/** - * 장소 추가 응답(추가된 항목·순서가 밀린 항목)을 일차 목록에 합치고, 응답이 알려 준 구간 경로만 무효화합니다. - * 추가는 이미 저장됐으므로 반영이 실패해도 던지지 않고 그 일차 경로를 모두 무효화합니다. - */ +/** 장소 추가 응답(추가된 항목·순서가 밀린 항목)을 일차 목록에 합칩니다. */ export async function applyCreatedScheduleItemToPlanPlaces( queryClient: QueryClient, roomId: string, scheduleId: number, response: CreateScheduleItemResponse, ): Promise { - const rid = roomId.trim(); - try { - const createdPlace = await planPlaceFromScheduleItem(response.createdItem); - // 미리보기를 기다리는 사이 다른 사람의 변경을 받았을 수 있어 그 뒤의 목록에 합친다 - queryClient.setQueryData( - scheduleItemsQueryKey(rid, scheduleId), - (old) => mergeCreatedScheduleItemDelta(old, createdPlace, response) ?? old, - ); - await invalidateScheduleItemRouteForSources( - queryClient, - rid, - scheduleId, - response.affectedRouteItemIds, - ); - bumpPlanMapRouteSegments(rid, scheduleId, response.affectedRouteItemIds); - } catch { - await Promise.allSettled([ - invalidateScheduleItemRouteForWholeSchedule(queryClient, rid, scheduleId), - ]); - usePlanMapDirectionsEpochStore.getState().bumpForDirections(rid); - } + // 미리보기를 못 받으면 자리표시자 장소로 합친다(planPlaceFromScheduleItem은 던지지 않음) + const createdPlace = await planPlaceFromScheduleItem(response.createdItem); + // 미리보기를 기다리는 사이 다른 사람의 변경을 받았을 수 있어 그 뒤의 목록에 합친다 + queryClient.setQueryData( + scheduleItemsQueryKey(roomId.trim(), scheduleId), + (old) => mergeCreatedScheduleItemDelta(old, createdPlace, response) ?? old, + ); } /** 드래그를 `toIndex` 자리에 놓았을 때 PATCH에 넣을 `newOrderIndex`(0-based) */ diff --git a/src/lib/plan/scheduleSegmentRoute.test.ts b/src/lib/plan/scheduleSegmentRoute.test.ts index 6d9c0c6b..ba2a171e 100644 --- a/src/lib/plan/scheduleSegmentRoute.test.ts +++ b/src/lib/plan/scheduleSegmentRoute.test.ts @@ -109,10 +109,7 @@ describe("resolveScheduleSegmentRoute", () => { expect(getScheduleItemRouteMock).toHaveBeenCalledTimes(1); }); - /** - * 캐시 쓰기는 React Query가 소유합니다. 리졸버가 직접 setQueryData 하면 - * 무효화 플래그까지 지워져 재정렬 뒤 낡은 경로가 유효한 값으로 굳습니다. - */ + /** 캐시 쓰기는 이 리졸버를 queryFn으로 쓰는 쿼리가 맡는다 */ it("캐시에 직접 쓰지 않는다", async () => { getScheduleItemRouteMock.mockResolvedValue(ROUTE); diff --git a/src/lib/plan/scheduleSegmentRoute.ts b/src/lib/plan/scheduleSegmentRoute.ts index 4f8b480b..355ae04f 100644 --- a/src/lib/plan/scheduleSegmentRoute.ts +++ b/src/lib/plan/scheduleSegmentRoute.ts @@ -25,9 +25,7 @@ export type ResolveScheduleSegmentRouteArgs = { * 거리·소요 시간과 지도 폴리라인이 같은 캐시 항목을 공유하도록 일정 리스트와 지도가 모두 * 이 함수를 queryFn으로 쓰는 하나의 쿼리(`scheduleItemRouteQueryKey`)를 거칩니다. * - * in-flight 합치기와 재정렬 중 뒤늦게 도착한 응답 폐기는 React Query가 담당합니다. - * 여기서 직접 캐시에 쓰지 않습니다 — `setQueryData`는 무효화 플래그까지 지워서 - * 순서가 바뀐 뒤의 낡은 경로를 유효한 값으로 만들어 버립니다. + * in-flight 합치기와 캐시 쓰기는 이 함수를 queryFn으로 쓰는 쿼리(React Query)가 맡아, 여기서 직접 캐시에 쓰지 않습니다. */ export async function resolveScheduleSegmentRoute({ roomId, diff --git a/src/lib/plan/scheduleStompRouteScope.test.ts b/src/lib/plan/scheduleStompRouteScope.test.ts deleted file mode 100644 index 08b4e576..00000000 --- a/src/lib/plan/scheduleStompRouteScope.test.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { segmentSourcesWithChangedNext } from "@/lib/plan/scheduleStompRouteScope"; - -const sorted = (ids: number[]) => [...ids].sort((a, b) => a - b); - -describe("segmentSourcesWithChangedNext", () => { - it("returns nothing when there was no earlier list to route from", () => { - expect(segmentSourcesWithChangedNext(null, [1, 2, 3])).toEqual([]); - }); - - it("returns nothing when the order is unchanged", () => { - expect(segmentSourcesWithChangedNext([1, 2, 3], [1, 2, 3])).toEqual([]); - }); - - it("collects the sources whose next stop changed after a reorder", () => { - // [A,X,B,C] → [A,B,X,C]: A·X·B의 다음 장소가 바뀌었고 C는 마지막이라 구간이 없다 - expect(sorted(segmentSourcesWithChangedNext([1, 9, 2, 3], [1, 2, 9, 3]))).toEqual([ - 1, 2, 9, - ]); - }); - - it("includes the removed stop and its predecessor after a deletion", () => { - expect(sorted(segmentSourcesWithChangedNext([1, 2, 3], [1, 3]))).toEqual([1, 2]); - }); - - it("includes the new stop and its predecessor after an insertion", () => { - expect(sorted(segmentSourcesWithChangedNext([1, 3], [1, 2, 3]))).toEqual([1, 2]); - }); -}); diff --git a/src/lib/plan/scheduleStompRouteScope.ts b/src/lib/plan/scheduleStompRouteScope.ts deleted file mode 100644 index 8e69ea1d..00000000 --- a/src/lib/plan/scheduleStompRouteScope.ts +++ /dev/null @@ -1,301 +0,0 @@ -import type { QueryClient } from "@tanstack/react-query"; - -import type { RoomScheduleItem } from "@/lib/api/rooms/schedule-items"; -import { - clearPersistedScheduleRoutesForSchedule, - clearPersistedScheduleRoutesForSegmentSources, -} from "@/lib/plan/planTravelLocalStorage"; -import { usePlanMapDirectionsEpochStore } from "@/stores/plan-map-directions-epoch-store"; -import type { PlanPlace } from "@/lib/plan/types"; -import { scheduleItemsQueryKey } from "@/lib/query-keys"; - -function asOrderedItemIds(places: unknown): number[] | null { - if (!Array.isArray(places)) return null; - const out: number[] = []; - for (const p of places) { - const row = p as PlanPlace; - if (typeof row?.itemId === "number" && Number.isFinite(row.itemId)) { - out.push(row.itemId); - } - } - return out.length > 0 || places.length === 0 ? out : null; -} - -/** `schedule-items` 캐시의 `PlanPlace[]`에서 순서대로 `itemId`를 뽑습니다. */ -export function readOrderedItemIdsFromScheduleItemsCache( - queryClient: QueryClient, - roomId: string, - scheduleId: number, -): number[] | null { - const data = queryClient.getQueryData( - scheduleItemsQueryKey(roomId, scheduleId), - ); - return asOrderedItemIds(data ?? null); -} - -/** `PlanPlace[]` → 순서대로 `itemId` 목록 */ -export function orderedItemIdsFromPlanPlaces( - places: readonly PlanPlace[], -): number[] { - return asOrderedItemIds(places) ?? []; -} - -/** - * 재조회 전후 순서를 비교해 다음 장소가 바뀐 구간의 출발 `itemId`를 모읍니다. - * 이전 캐시가 없으면 받아 둔 구간 경로도 없으므로 비웁니다. - */ -export function segmentSourcesWithChangedNext( - before: number[] | null, - after: number[], -): number[] { - if (!before) return []; - const nextOf = (ids: number[]) => - new Map(ids.map((id, i) => [id, ids[i + 1]])); - const beforeNext = nextOf(before); - const afterNext = nextOf(after); - const changed = new Set(); - for (const [id, next] of beforeNext) { - if (afterNext.get(id) !== next) changed.add(id); - } - for (const [id, next] of afterNext) { - if (beforeNext.get(id) !== next) changed.add(id); - } - return [...changed]; -} - -/** 리오더 API 응답 `RoomScheduleItem[]` → `orderIndex` 순 `itemId` 목록 */ -export function orderedItemIdsFromRoomScheduleItems( - items: RoomScheduleItem[], -): number[] { - return [...items] - .sort((a, b) => a.orderIndex - b.orderIndex) - .map((it) => it.itemId) - .filter((id): id is number => typeof id === "number" && Number.isFinite(id)); -} - -/** 항목 `itemId`에 인접한 구간의 출발 `segmentSourceItemId` (최대 prev, 자신). */ -function segmentSourcesTouchingItem( - orderedItemIds: number[], - itemId: number, -): Set { - const s = new Set(); - const i = orderedItemIds.indexOf(itemId); - if (i < 0) return s; - if (i > 0) s.add(orderedItemIds[i - 1]!); - if (i < orderedItemIds.length - 1) s.add(orderedItemIds[i]!); - return s; -} - -export function collectSegmentSourcesForCreate( - newOrderedIds: number[] | null, - itemId: number, -): { sources: number[]; useFallback: boolean } { - if (!newOrderedIds?.length) { - return { sources: [], useFallback: true }; - } - if (newOrderedIds.indexOf(itemId) < 0) { - return { sources: [], useFallback: true }; - } - return { - sources: [...segmentSourcesTouchingItem(newOrderedIds, itemId)], - useFallback: false, - }; -} - -export function removeRouteQueriesForDeletedItemSource( - queryClient: QueryClient, - roomId: string, - scheduleId: number, - itemId: number, -): void { - const rid = roomId.trim(); - queryClient.removeQueries({ - predicate: (q) => { - const key = q.queryKey; - if (!Array.isArray(key) || key[0] !== "schedule-item-route") { - return false; - } - if (String(key[1] ?? "").trim() !== rid) return false; - if (key[2] !== scheduleId) return false; - return key[3] === itemId; - }, - }); -} - -export function collectSegmentSourcesForDelete( - oldOrderedIds: number[] | null, - deletedItemId: number, -): { sources: number[]; useFallback: boolean } { - if (!oldOrderedIds?.length) { - return { sources: [], useFallback: true }; - } - const i = oldOrderedIds.indexOf(deletedItemId); - if (i < 0) { - return { sources: [], useFallback: true }; - } - if (i === 0) { - return { sources: [], useFallback: false }; - } - return { sources: [oldOrderedIds[i - 1]!], useFallback: false }; -} - -export function collectSegmentSourcesForReorder( - oldOrderedIds: number[] | null, - newOrderedIds: number[] | null, - itemId: number, -): { sources: number[]; useFallback: boolean } { - if (!newOrderedIds?.length) { - return { sources: [], useFallback: true }; - } - if (newOrderedIds.indexOf(itemId) < 0) { - return { sources: [], useFallback: true }; - } - const s = new Set(segmentSourcesTouchingItem(newOrderedIds, itemId)); - if (oldOrderedIds?.length) { - segmentSourcesTouchingItem(oldOrderedIds, itemId).forEach((id) => - s.add(id), - ); - } - return { sources: [...s], useFallback: false }; -} - -export function bumpPlanMapRouteSegments( - roomId: string, - scheduleId: number, - sourceItemIds: number[], -): void { - if (sourceItemIds.length === 0) return; - const rid = roomId.trim(); - if (!rid.length) return; - usePlanMapDirectionsEpochStore.getState().bumpSegments( - rid, - sourceItemIds.map((segmentSourceItemId) => ({ - scheduleId, - segmentSourceItemId, - })), - ); -} - -/** 항목 삭제 직후 — 삭제 전 순서 스냅샷 기준 인접 구간 route만 무효화(본인 mutation). */ -export async function invalidateScheduleItemRoutesAfterDelete( - queryClient: QueryClient, - roomId: string, - scheduleId: number, - deletedItemId: number, - oldOrderedIds: number[] | null, -): Promise { - const rid = roomId.trim(); - if (!rid.length) return; - const { sources, useFallback } = collectSegmentSourcesForDelete( - oldOrderedIds, - deletedItemId, - ); - if (useFallback) { - await invalidateScheduleItemRouteForWholeSchedule(queryClient, rid, scheduleId); - usePlanMapDirectionsEpochStore.getState().bumpForDirections(rid); - return; - } - await invalidateScheduleItemRouteForSources(queryClient, rid, scheduleId, sources); - bumpPlanMapRouteSegments(rid, scheduleId, sources); -} - -/** 항목 생성 후 캐시 반영 직후 — 인접 구간 route만 무효화(본인 mutation). */ -export async function invalidateScheduleItemRoutesAfterItemCreated( - queryClient: QueryClient, - roomId: string, - scheduleId: number, - itemId: number, -): Promise { - const rid = roomId.trim(); - if (!rid.length) return; - const orderedIds = readOrderedItemIdsFromScheduleItemsCache( - queryClient, - rid, - scheduleId, - ); - const { sources, useFallback } = collectSegmentSourcesForCreate( - orderedIds, - itemId, - ); - if (useFallback) { - await invalidateScheduleItemRouteForWholeSchedule(queryClient, rid, scheduleId); - usePlanMapDirectionsEpochStore.getState().bumpForDirections(rid); - return; - } - await invalidateScheduleItemRouteForSources(queryClient, rid, scheduleId, sources); - bumpPlanMapRouteSegments(rid, scheduleId, sources); -} - -/** - * 리오더·중간 삽입 직후 — STOMP보다 먼저 캐시·서버 순서를 알 때 구간만 무효화. - * `oldOrderedIds`는 merge 전 `schedule-items` 스냅샷, `items`는 리오더(또는 최종 PATCH) 응답. - */ -export async function invalidateScheduleItemRoutesAfterReorder( - queryClient: QueryClient, - roomId: string, - scheduleId: number, - movedItemId: number, - oldOrderedIds: number[] | null, - items: RoomScheduleItem[], -): Promise { - const rid = roomId.trim(); - if (!rid.length) return; - const newOrderedIds = orderedItemIdsFromRoomScheduleItems(items); - const { sources, useFallback } = collectSegmentSourcesForReorder( - oldOrderedIds, - newOrderedIds, - movedItemId, - ); - if (useFallback) { - await invalidateScheduleItemRouteForWholeSchedule(queryClient, rid, scheduleId); - usePlanMapDirectionsEpochStore.getState().bumpForDirections(rid); - return; - } - await invalidateScheduleItemRouteForSources(queryClient, rid, scheduleId, sources); - bumpPlanMapRouteSegments(rid, scheduleId, sources); -} - -export async function invalidateScheduleItemRouteForSources( - queryClient: QueryClient, - roomId: string, - scheduleId: number, - sourceItemIds: number[], -): Promise { - if (sourceItemIds.length === 0) return; - const rid = roomId.trim(); - clearPersistedScheduleRoutesForSegmentSources(rid, scheduleId, sourceItemIds); - const want = new Set(sourceItemIds); - await queryClient.invalidateQueries({ - predicate: (q) => { - const key = q.queryKey; - if (!Array.isArray(key) || key[0] !== "schedule-item-route") { - return false; - } - if (String(key[1] ?? "").trim() !== rid) return false; - if (key[2] !== scheduleId) return false; - return typeof key[3] === "number" && want.has(key[3]); - }, - /** `all`이면 옵저버 없는 캐시(재정렬로 구간이 사라진 뒤)도 refetch되어 모드 4개가 동시에 나감 */ - refetchType: "active", - }); -} - -export async function invalidateScheduleItemRouteForWholeSchedule( - queryClient: QueryClient, - roomId: string, - scheduleId: number, -): Promise { - const rid = roomId.trim(); - clearPersistedScheduleRoutesForSchedule(rid, scheduleId); - await queryClient.invalidateQueries({ - predicate: (q) => { - const key = q.queryKey; - if (!Array.isArray(key) || key[0] !== "schedule-item-route") { - return false; - } - if (String(key[1] ?? "").trim() !== rid) return false; - return key[2] === scheduleId; - }, - refetchType: "active", - }); -} diff --git a/src/lib/stomp/schedules-dispatch.order.test.ts b/src/lib/stomp/schedules-dispatch.order.test.ts index 6656e6d8..91f15bfe 100644 --- a/src/lib/stomp/schedules-dispatch.order.test.ts +++ b/src/lib/stomp/schedules-dispatch.order.test.ts @@ -36,27 +36,12 @@ vi.mock("@/lib/plan/schedule-bulk-hydration", async (importOriginal) => ({ .map((item) => fixtures.place(item.itemId)), resolveScheduleItemsFromCacheOrHydrate: vi.fn(), })); -vi.mock("@/lib/plan/scheduleStompRouteScope", () => ({ - bumpPlanMapRouteSegments: vi.fn(), - invalidateScheduleItemRouteForSources: vi.fn(async () => undefined), - invalidateScheduleItemRouteForWholeSchedule: vi.fn(async () => undefined), - invalidateScheduleItemRoutesAfterDelete: vi.fn(async () => undefined), - invalidateScheduleItemRoutesAfterItemCreated: vi.fn(async () => undefined), - invalidateScheduleItemRoutesAfterReorder: vi.fn(async () => undefined), - readOrderedItemIdsFromScheduleItemsCache: vi.fn(() => null), - removeRouteQueriesForDeletedItemSource: vi.fn(), -})); vi.mock("@/lib/rooms", () => ({ syncRoomDetailFromServer: vi.fn(), })); vi.mock("@/lib/query-client", () => ({ getQueryClient: () => null, })); -vi.mock("@/stores/plan-map-directions-epoch-store", () => ({ - usePlanMapDirectionsEpochStore: { - getState: () => ({ bumpForDirections: vi.fn(), bumpSegments: vi.fn() }), - }, -})); import { removeScheduleItemFromPlanPlacesCache, diff --git a/src/lib/stomp/schedules-dispatch.test.ts b/src/lib/stomp/schedules-dispatch.test.ts index cbd60a4f..6f93d322 100644 --- a/src/lib/stomp/schedules-dispatch.test.ts +++ b/src/lib/stomp/schedules-dispatch.test.ts @@ -1,19 +1,12 @@ import { QueryClient } from "@tanstack/react-query"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { sessionUserQueryKey } from "@/lib/query-keys"; +import { scheduleItemRouteQueryKey, sessionUserQueryKey } from "@/lib/query-keys"; import type { RoomScheduleChangedEvent } from "@/lib/stomp/schedule-events"; const mocks = vi.hoisted(() => ({ - collectSegmentSourcesForCreate: vi.fn(), - collectSegmentSourcesForDelete: vi.fn(), - collectSegmentSourcesForReorder: vi.fn(), invalidateSchedulePlanPlaces: vi.fn(), - invalidateScheduleItemRouteForSources: vi.fn(), - invalidateScheduleItemRouteForWholeSchedule: vi.fn(), - readOrderedItemIdsFromScheduleItemsCache: vi.fn(), - removeRouteQueriesForDeletedItemSource: vi.fn(), syncAfterCrossScheduleItemMove: vi.fn(), })); @@ -22,33 +15,10 @@ vi.mock("@/lib/plan/scheduleItemPlaces", () => ({ syncAfterCrossScheduleItemMove: mocks.syncAfterCrossScheduleItemMove, })); -vi.mock("@/lib/plan/scheduleStompRouteScope", () => ({ - collectSegmentSourcesForCreate: mocks.collectSegmentSourcesForCreate, - collectSegmentSourcesForDelete: mocks.collectSegmentSourcesForDelete, - collectSegmentSourcesForReorder: mocks.collectSegmentSourcesForReorder, - invalidateScheduleItemRouteForSources: - mocks.invalidateScheduleItemRouteForSources, - invalidateScheduleItemRouteForWholeSchedule: - mocks.invalidateScheduleItemRouteForWholeSchedule, - readOrderedItemIdsFromScheduleItemsCache: - mocks.readOrderedItemIdsFromScheduleItemsCache, - removeRouteQueriesForDeletedItemSource: - mocks.removeRouteQueriesForDeletedItemSource, -})); - vi.mock("@/lib/rooms", () => ({ syncRoomDetailFromServer: vi.fn(), })); -vi.mock("@/stores/plan-map-directions-epoch-store", () => ({ - usePlanMapDirectionsEpochStore: { - getState: () => ({ - bumpForDirections: vi.fn(), - bumpSegments: vi.fn(), - }), - }, -})); - import { dispatchRoomScheduleEvent } from "@/lib/stomp/schedules-dispatch"; function itemEvent( @@ -68,15 +38,14 @@ function itemEvent( describe("dispatchRoomScheduleEvent", () => { let queryClient: QueryClient; + // 경로는 도착 장소까지 key에 있어 일정이 바뀌어도 무효화하지 않는다 — 순서가 바뀐 구간은 새 key로 받는다 + const routeKey = scheduleItemRouteQueryKey("room-1", 10, 20, "place-b", "DRIVING"); beforeEach(() => { queryClient = new QueryClient(); + queryClient.setQueryData(routeKey, { travelMode: "DRIVING", distanceMeters: 100, durationSeconds: 60 }); vi.clearAllMocks(); mocks.invalidateSchedulePlanPlaces.mockResolvedValue(undefined); - mocks.invalidateScheduleItemRouteForSources.mockResolvedValue(undefined); - mocks.invalidateScheduleItemRouteForWholeSchedule.mockResolvedValue( - undefined, - ); mocks.syncAfterCrossScheduleItemMove.mockResolvedValue(undefined); }); @@ -96,74 +65,20 @@ describe("dispatchRoomScheduleEvent", () => { expect(mocks.invalidateSchedulePlanPlaces).toHaveBeenCalledExactlyOnceWith(queryClient, "room-1", 10, { joinInFlight: ownUser, }); - expect(mocks.invalidateScheduleItemRouteForSources).not.toHaveBeenCalled(); - expect(mocks.invalidateScheduleItemRouteForWholeSchedule).not.toHaveBeenCalled(); - }); - - it("uses affectedRouteItemIds instead of calculating routes for a created item", async () => { - mocks.readOrderedItemIdsFromScheduleItemsCache.mockReturnValue([10, 20]); - mocks.collectSegmentSourcesForCreate.mockReturnValue({ - sources: [10, 20], - useFallback: false, - }); - - await dispatchRoomScheduleEvent( - queryClient, - itemEvent("SCHEDULE_ITEM_CREATED", [777]), - ); - - expect(mocks.collectSegmentSourcesForCreate).not.toHaveBeenCalled(); - expect(mocks.invalidateScheduleItemRouteForSources).toHaveBeenCalledWith( - queryClient, - "room-1", - 10, - [777], - ); + expect(queryClient.getQueryState(routeKey)?.isInvalidated).toBe(false); }); - it("uses affectedRouteItemIds instead of calculating routes for a deleted item", async () => { - mocks.collectSegmentSourcesForDelete.mockReturnValue({ - sources: [10], - useFallback: false, - }); + it.each(["SCHEDULE_ITEM_CREATED", "SCHEDULE_ITEM_DELETED", "SCHEDULE_ITEMS_REORDERED"] as const)( + "refetches the day list without invalidating route caches for %s", + async (type) => { + await dispatchRoomScheduleEvent(queryClient, itemEvent(type, [20])); - await dispatchRoomScheduleEvent( - queryClient, - itemEvent("SCHEDULE_ITEM_DELETED", [888]), - ); - - expect(mocks.collectSegmentSourcesForDelete).not.toHaveBeenCalled(); - expect(mocks.invalidateScheduleItemRouteForSources).toHaveBeenCalledWith( - queryClient, - "room-1", - 10, - [888], - ); - }); - - it("uses affectedRouteItemIds instead of calculating routes after reorder", async () => { - vi.useFakeTimers(); - mocks.collectSegmentSourcesForReorder.mockReturnValue({ - sources: [10, 20], - useFallback: false, - }); - - await dispatchRoomScheduleEvent( - queryClient, - itemEvent("SCHEDULE_ITEMS_REORDERED", [999]), - ); - await vi.runAllTimersAsync(); + expect(mocks.invalidateSchedulePlanPlaces).toHaveBeenCalledExactlyOnceWith(queryClient, "room-1", 10); + expect(queryClient.getQueryState(routeKey)?.isInvalidated).toBe(false); + }, + ); - expect(mocks.collectSegmentSourcesForReorder).not.toHaveBeenCalled(); - expect(mocks.invalidateScheduleItemRouteForSources).toHaveBeenCalledWith( - queryClient, - "room-1", - 10, - [999], - ); - }); - - it("passes affectedRouteItemIds through a cross-schedule move", async () => { + it("refetches both days after a cross-schedule move", async () => { const event = itemEvent("SCHEDULE_ITEM_MOVED", [11, 21, 20]); event.scheduleId = 20; event.scheduleIds = [10, 20]; @@ -175,20 +90,12 @@ describe("dispatchRoomScheduleEvent", () => { "room-1", 10, 20, - 20, - [11, 21, 20], ); + expect(queryClient.getQueryState(routeKey)?.isInvalidated).toBe(false); }); - it("refreshes travel mode before invalidating affectedRouteItemIds", async () => { + it("refetches the day list after another member changes a travel mode, leaving route caches as they are", async () => { vi.useFakeTimers(); - const order: string[] = []; - mocks.invalidateSchedulePlanPlaces.mockImplementation(async () => { - order.push("refetch"); - }); - mocks.invalidateScheduleItemRouteForSources.mockImplementation(async () => { - order.push("invalidate"); - }); await dispatchRoomScheduleEvent( queryClient, @@ -196,52 +103,10 @@ describe("dispatchRoomScheduleEvent", () => { ); await vi.runAllTimersAsync(); - expect(order).toEqual(["refetch", "invalidate"]); - expect(mocks.invalidateScheduleItemRouteForSources).toHaveBeenCalledWith( - queryClient, - "room-1", - 10, - [20], - ); - }); - - it("preserves a newer route bucket when an earlier travel-mode refresh fails", async () => { - vi.useFakeTimers(); - let rejectFirst!: (reason?: unknown) => void; - let resolveSecond!: () => void; - const first = new Promise((_, reject) => { - rejectFirst = reject; - }); - const second = new Promise((resolve) => { - resolveSecond = resolve; + expect(mocks.invalidateSchedulePlanPlaces).toHaveBeenCalledExactlyOnceWith(queryClient, "room-1", 10, { + joinInFlight: false, }); - mocks.invalidateSchedulePlanPlaces - .mockReturnValueOnce(first) - .mockReturnValueOnce(second); - - await dispatchRoomScheduleEvent( - queryClient, - itemEvent("SCHEDULE_ITEM_TRAVEL_MODE_UPDATED", [20]), - ); - await vi.advanceTimersByTimeAsync(90); - - await dispatchRoomScheduleEvent( - queryClient, - itemEvent("SCHEDULE_ITEM_TRAVEL_MODE_UPDATED", [30]), - ); - await vi.advanceTimersByTimeAsync(90); - - rejectFirst(new Error("first refresh failed")); - resolveSecond(); - await vi.runAllTimersAsync(); - - expect(mocks.invalidateScheduleItemRouteForSources).toHaveBeenCalledTimes(1); - expect(mocks.invalidateScheduleItemRouteForSources).toHaveBeenCalledWith( - queryClient, - "room-1", - 10, - [30], - ); + expect(queryClient.getQueryState(routeKey)?.isInvalidated).toBe(false); }); }); diff --git a/src/lib/stomp/schedules-dispatch.ts b/src/lib/stomp/schedules-dispatch.ts index 6f1bf699..89e87c48 100644 --- a/src/lib/stomp/schedules-dispatch.ts +++ b/src/lib/stomp/schedules-dispatch.ts @@ -5,119 +5,16 @@ import { invalidateSchedulePlanPlaces, syncAfterCrossScheduleItemMove, } from "@/lib/plan/scheduleItemPlaces"; -import { - invalidateScheduleItemRouteForSources, - invalidateScheduleItemRouteForWholeSchedule, - removeRouteQueriesForDeletedItemSource, -} from "@/lib/plan/scheduleStompRouteScope"; import { roomSchedulesQueryKey } from "@/lib/query-keys"; import { syncRoomDetailFromServer } from "@/lib/rooms"; import type { RoomScheduleChangedEvent } from "@/lib/stomp/schedule-events"; import { readSessionUserId } from "@/lib/session-user-cache"; -import { usePlanMapDirectionsEpochStore } from "@/stores/plan-map-directions-epoch-store"; - -function bumpMapForRouteSources( - roomId: string, - scheduleId: number, - sourceItemIds: number[], -): void { - if (sourceItemIds.length === 0) return; - usePlanMapDirectionsEpochStore.getState().bumpSegments( - roomId, - sourceItemIds.map((segmentSourceItemId) => ({ - scheduleId, - segmentSourceItemId, - })), - ); -} - -type DebouncedRouteInvBucket = - | { scope: "whole" } - | { scope: "sources"; ids: Set }; - -/** 리오더 시 같은 구간 무효화를 짧게 묶어 `/route` 이중 패치를 줄임 */ -const scheduleRouteInvalidateDebounceTimers = new Map< - string, - ReturnType ->(); -const scheduleRouteInvalidatePending = new Map(); -const DEBOUNCE_SCHEDULE_ROUTE_INVALIDATE_MS = 100; - -function mergeRouteInvalidateBucket( - prev: DebouncedRouteInvBucket | undefined, - sourcesToAdd: number[], - forceWholeSchedule: boolean, -): DebouncedRouteInvBucket { - if (forceWholeSchedule || prev?.scope === "whole") { - return { scope: "whole" }; - } - const ids = - prev?.scope === "sources" ? prev.ids : new Set(); - for (const id of sourcesToAdd) { - ids.add(id); - } - return { scope: "sources", ids }; -} - -function enqueueDebouncedInvalidateScheduleRoutes( - queryClient: QueryClient, - rid: string, - sid: number, - sourcesForInvalidation: number[], - forceWholeSchedule: boolean, -): void { - const mapKey = `${rid}:${sid}`; - scheduleRouteInvalidatePending.set( - mapKey, - mergeRouteInvalidateBucket( - scheduleRouteInvalidatePending.get(mapKey), - sourcesForInvalidation, - forceWholeSchedule, - ), - ); - const existing = scheduleRouteInvalidateDebounceTimers.get(mapKey); - if (existing !== undefined) clearTimeout(existing); - - scheduleRouteInvalidateDebounceTimers.set( - mapKey, - setTimeout(() => { - scheduleRouteInvalidateDebounceTimers.delete(mapKey); - const epochStore = usePlanMapDirectionsEpochStore.getState(); - const bucket = scheduleRouteInvalidatePending.get(mapKey); - scheduleRouteInvalidatePending.delete(mapKey); - if (!bucket) return; - void (async () => { - if (bucket.scope === "whole") { - await invalidateScheduleItemRouteForWholeSchedule( - queryClient, - rid, - sid, - ); - epochStore.bumpForDirections(rid); - } else if (bucket.ids.size > 0) { - const sources = [...bucket.ids]; - await invalidateScheduleItemRouteForSources( - queryClient, - rid, - sid, - sources, - ); - bumpMapForRouteSources(rid, sid, sources); - } - })(); - }, DEBOUNCE_SCHEDULE_ROUTE_INVALIDATE_MS), - ); -} /** 연속 일정 항목 동기화(GET items)를 한 번으로 묶음 — `SCHEDULE_ITEM_UPDATED` 전용 */ const syncSchedulePlacesFromItemsDebouncers = new Map< string, ReturnType >(); -const syncSchedulePlacesRouteInvalidationPending = new Map< - string, - DebouncedRouteInvBucket ->(); /** 모으는 동안 다른 사람의 변경이 하나라도 들어온 일차 */ const syncSchedulePlacesRemoteChangePending = new Set(); const DEBOUNCE_ITEMS_GET_MS = 90; @@ -127,29 +24,15 @@ function enqueueDebouncedHydratePlacesFromScheduleItemsApi( rid: string, sid: number, ownEcho: boolean, - affectedRouteItemIds?: number[] | null, ): void { const mapKey = `${rid}:${sid}`; if (!ownEcho) syncSchedulePlacesRemoteChangePending.add(mapKey); - if (affectedRouteItemIds !== undefined) { - syncSchedulePlacesRouteInvalidationPending.set( - mapKey, - mergeRouteInvalidateBucket( - syncSchedulePlacesRouteInvalidationPending.get(mapKey), - affectedRouteItemIds ?? [], - affectedRouteItemIds === null, - ), - ); - } const pending = syncSchedulePlacesFromItemsDebouncers.get(mapKey); if (pending !== undefined) clearTimeout(pending); syncSchedulePlacesFromItemsDebouncers.set( mapKey, setTimeout(() => { syncSchedulePlacesFromItemsDebouncers.delete(mapKey); - const routeBucket = - syncSchedulePlacesRouteInvalidationPending.get(mapKey); - syncSchedulePlacesRouteInvalidationPending.delete(mapKey); // 모은 이벤트가 모두 본인 반향이면, 본인 저장이 끝나며 보낸 조회가 이미 그 변경을 담고 있어 합류한다 const joinInFlight = !syncSchedulePlacesRemoteChangePending.has(mapKey); syncSchedulePlacesRemoteChangePending.delete(mapKey); @@ -158,25 +41,6 @@ function enqueueDebouncedHydratePlacesFromScheduleItemsApi( await invalidateSchedulePlanPlaces(queryClient, rid, sid, { joinInFlight, }); - if (routeBucket?.scope === "whole") { - await invalidateScheduleItemRouteForWholeSchedule( - queryClient, - rid, - sid, - ); - usePlanMapDirectionsEpochStore - .getState() - .bumpForDirections(rid); - } else if (routeBucket && routeBucket.ids.size > 0) { - const sources = [...routeBucket.ids]; - await invalidateScheduleItemRouteForSources( - queryClient, - rid, - sid, - sources, - ); - bumpMapForRouteSources(rid, sid, sources); - } } catch { // } @@ -189,10 +53,9 @@ function enqueueDebouncedHydratePlacesFromScheduleItemsApi( * Plan 화면 등이 쓰는 캐시만, 스키마의 `type`별로 갱신합니다. * — `ROOM_SCHEDULES_RESYNCED`: `room-schedules` 전체 재조회 + 방 상세 동기화 (원격 탭; 액터는 mutation `onSuccess`) * — `schedule-items`: 그 일차 장소 목록을 무효화해 다시 받는다(보는 화면이 없으면 그 화면에 들어갈 때) - * — `schedule-item-route`: `itemId`·인접 구간의 `segmentSourceItemId`만 무효화(폴백 시 일정 전체) - * — `SCHEDULE_ITEM_UPDATED`: 일정 목록만 동기화할 때 사용(본인 이벤트 포함); 경로(`schedule-item-route`)는 무효화하지 않음 - * — `SCHEDULE_ITEM_TRAVEL_MODE_UPDATED`: 공유 이동수단 변경 — 일정 목록만 동기화(경로 캐시는 수단별 키로 유지) - * — 맵 polyline: 구간별 에폭(`bumpSegments`); 폴백 시 방 단위(`bumpForDirections`) + * — `schedule-item-route`: 무효화하지 않는다 — key에 도착 장소·이동수단이 있어, 순서·수단이 바뀐 구간은 + * 새 목록을 그릴 때 새 key로 받고 되돌리면 받아 둔 값을 쓴다 + * — `SCHEDULE_ITEM_UPDATED`: 일정 목록만 동기화할 때 사용(본인 이벤트 포함) */ export async function dispatchRoomScheduleEvent( queryClient: QueryClient, @@ -207,7 +70,6 @@ export async function dispatchRoomScheduleEvent( ) { await invalidateExpenses(queryClient, rid); } - const epochStore = usePlanMapDirectionsEpochStore.getState(); switch (event.type) { case "ROOM_SCHEDULES_RESYNCED": { @@ -233,7 +95,6 @@ export async function dispatchRoomScheduleEvent( const sourceScheduleId = scheduleIds[0]!; const targetScheduleId = scheduleIds[1]!; - const itemId = event.itemId; const me = readSessionUserId(queryClient); const actorIsMe = @@ -249,8 +110,6 @@ export async function dispatchRoomScheduleEvent( rid, sourceScheduleId, targetScheduleId, - itemId ?? undefined, - event.affectedRouteItemIds, ); return; } @@ -265,23 +124,12 @@ export async function dispatchRoomScheduleEvent( typeof me === "number" && Number.isFinite(me) && me === event.actorUserId; - /** 본인 mutation은 `applyScheduleItemDeletedOnClient`에서 캐시·route 처리 */ + /** 본인 삭제는 mutation이 응답으로 캐시를 고치고 그 일차를 다시 받는다 */ if (actorIsMe) { return; } - removeRouteQueriesForDeletedItemSource(queryClient, rid, sid, itemId); - await invalidateSchedulePlanPlaces(queryClient, rid, sid); - - const sources = event.affectedRouteItemIds; - if (sources === null) { - await invalidateScheduleItemRouteForWholeSchedule(queryClient, rid, sid); - epochStore.bumpForDirections(rid); - } else { - await invalidateScheduleItemRouteForSources(queryClient, rid, sid, sources); - bumpMapForRouteSources(rid, sid, sources); - } return; } @@ -295,21 +143,12 @@ export async function dispatchRoomScheduleEvent( typeof me === "number" && Number.isFinite(me) && me === event.actorUserId; - /** 본인 mutation은 append/reorder 직후 구간 단위 무효화 — STOMP 선도착 시 일정 전체 `/route` 폭주 방지 */ + /** 본인 추가는 mutation이 응답으로 캐시를 고치고 그 일차를 다시 받는다 */ if (actorIsMe) { return; } await invalidateSchedulePlanPlaces(queryClient, rid, sid); - - const sources = event.affectedRouteItemIds; - if (sources === null) { - await invalidateScheduleItemRouteForWholeSchedule(queryClient, rid, sid); - epochStore.bumpForDirections(rid); - } else { - await invalidateScheduleItemRouteForSources(queryClient, rid, sid, sources); - bumpMapForRouteSources(rid, sid, sources); - } return; } @@ -323,20 +162,12 @@ export async function dispatchRoomScheduleEvent( typeof me === "number" && Number.isFinite(me) && me === event.actorUserId; - /** 본인 mutation은 `syncPlanPlacesAfterReorderSuccess` 직후 구간 무효화 */ + /** 본인 순서 변경은 mutation이 응답으로 캐시를 고치고 그 일차를 다시 받는다 */ if (actorIsMe) { return; } await invalidateSchedulePlanPlaces(queryClient, rid, sid); - const sources = event.affectedRouteItemIds; - enqueueDebouncedInvalidateScheduleRoutes( - queryClient, - rid, - sid, - sources ?? [], - sources === null, - ); return; } @@ -375,13 +206,11 @@ export async function dispatchRoomScheduleEvent( return; } - /** 항목 재조회 후 서버가 지정한 출발 항목의 경로만 갱신합니다. */ enqueueDebouncedHydratePlacesFromScheduleItemsApi( queryClient, rid, sid, false, - event.affectedRouteItemIds, ); return; } From c76978de106c6f0bac9aead86ec4d0e29d127bfd Mon Sep 17 00:00:00 2001 From: KangShinGyu Date: Tue, 3 Nov 2026 23:25:39 +0900 Subject: [PATCH 03/14] =?UTF-8?q?refactor(map):=20=EA=B2=BD=EB=A1=9C=20?= =?UTF-8?q?=EB=AC=B4=ED=9A=A8=ED=99=94=EA=B0=80=20=EC=97=86=EC=96=B4?= =?UTF-8?q?=EC=A0=B8=20=EC=A7=80=EB=8F=84=20=EC=84=A0=20key=EC=9D=98=20?= =?UTF-8?q?=EC=97=90=ED=8F=AD=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 지도 선 key는 (방, 일차, 출발 항목, 출발·도착 장소, 이동수단)만 쓴다 — 도착 장소가 바뀌면 key가 달라져 새 선을 그린다 - 에폭 스토어(plan-map-directions-epoch-store) 삭제 - 선의 React key도 에폭 대신 도착 장소로 구분한다 Co-Authored-By: Claude Opus 5.5 --- src/components/map/PlanItineraryMapRoutes.tsx | 107 +++++++----------- src/lib/query-keys.ts | 4 - src/stores/plan-map-directions-epoch-store.ts | 50 -------- 3 files changed, 40 insertions(+), 121 deletions(-) delete mode 100644 src/stores/plan-map-directions-epoch-store.ts diff --git a/src/components/map/PlanItineraryMapRoutes.tsx b/src/components/map/PlanItineraryMapRoutes.tsx index 1aae5a77..3fb9fc89 100644 --- a/src/components/map/PlanItineraryMapRoutes.tsx +++ b/src/components/map/PlanItineraryMapRoutes.tsx @@ -34,10 +34,6 @@ import { flattenPlanItinerarySegmentsFromPlaces, } from "@/lib/plan/planItineraryMapSegments"; import { planItinerarySegmentMapPathQueryKey } from "@/lib/query-keys"; -import { - planMapSegmentEpochStoreKey, - usePlanMapDirectionsEpochStore, -} from "@/stores/plan-map-directions-epoch-store"; import { usePlanScheduleRouteVisibilityStore } from "@/stores/plan-schedule-route-visibility-store"; import { usePlanMapFocusStore } from "@/stores/plan-map-focus-store"; import { useSessionStore } from "@/stores/session-store"; @@ -116,13 +112,6 @@ export function PlanItineraryMapRoutes({ [singleDay, singleDayScheduleId, visibleByScheduleId], ); - const directionsEpoch = usePlanMapDirectionsEpochStore((s) => - rid.length > 0 ? (s.epochByRoomId[rid] ?? 0) : 0, - ); - const epochBySegmentKey = usePlanMapDirectionsEpochStore( - (s) => s.epochBySegmentKey, - ); - const orderedScheduleIdsForQueries = useMemo( () => [...visibleScheduleIds].sort((a, b) => a - b), [visibleScheduleIds], @@ -173,56 +162,45 @@ export function PlanItineraryMapRoutes({ ); const pathsPerSegmentQueries = useQueries({ - queries: segments.map((seg) => { - const epochKey = planMapSegmentEpochStoreKey( + queries: segments.map((seg) => ({ + queryKey: planItinerarySegmentMapPathQueryKey( rid, seg.scheduleId, seg.segmentSourceItemId, - ); - const segmentEpoch = epochBySegmentKey[epochKey] ?? 0; - - return { - queryKey: planItinerarySegmentMapPathQueryKey( - rid, - seg.scheduleId, - seg.segmentSourceItemId, - seg.originPlaceId, - seg.destPlaceId, - seg.travelModeCanon, - directionsEpoch, - segmentEpoch, - ), - /** - * 구간 경로는 일정 리스트와 같은 쿼리를 통해 가져옵니다. 그 쿼리에 맡겨야 - * in-flight 합치기와 재정렬 중 뒤늦게 온 응답 폐기를 React Query가 처리합니다. - */ - queryFn: async () => { - const route = await loadScheduleItemRouteForMap( - queryClient, - persistedScheduleItemRouteQueryOptions( - rid, - seg.scheduleId, - seg.segmentSourceItemId, - seg.destPlaceId, - seg.travelModeCanon, - fingerprintByScheduleId.get(seg.scheduleId) ?? "", - { segmentReady: true }, - ), - ); - return { - path: await decodeOrientedPlanItinerarySegmentPath( - seg, - route?.encodedPolyline, - ), - }; - }, - enabled: rid.length > 0 && segments.length > 0, - staleTime: Infinity, - refetchOnMount: false, - refetchOnWindowFocus: false, - retry: false, - }; - }), + seg.originPlaceId, + seg.destPlaceId, + seg.travelModeCanon, + ), + /** + * 구간 경로는 일정 리스트와 같은 쿼리를 통해 가져옵니다. 그 쿼리에 맡겨야 + * in-flight 합치기와 재정렬 중 뒤늦게 온 응답 폐기를 React Query가 처리합니다. + */ + queryFn: async () => { + const route = await loadScheduleItemRouteForMap( + queryClient, + persistedScheduleItemRouteQueryOptions( + rid, + seg.scheduleId, + seg.segmentSourceItemId, + seg.destPlaceId, + seg.travelModeCanon, + fingerprintByScheduleId.get(seg.scheduleId) ?? "", + { segmentReady: true }, + ), + ); + return { + path: await decodeOrientedPlanItinerarySegmentPath( + seg, + route?.encodedPolyline, + ), + }; + }, + enabled: rid.length > 0 && segments.length > 0, + staleTime: Infinity, + refetchOnMount: false, + refetchOnWindowFocus: false, + retry: false, + })), }); const polylines: Array = []; @@ -230,13 +208,8 @@ export function PlanItineraryMapRoutes({ const query = pathsPerSegmentQueries[segIdx]; if (!query || query.isPending || query.isFetching) return; const pts = query.data?.path ?? []; - - const segEpochKey = planMapSegmentEpochStoreKey( - rid, - seg.scheduleId, - seg.segmentSourceItemId, - ); - const segEpoch = epochBySegmentKey[segEpochKey] ?? 0; + // 도착 장소가 바뀌면 다른 선이라 새로 그린다 + const polylineKey = `${seg.scheduleId}-${seg.segmentSourceItemId}-${seg.destPlaceId}-${seg.travelModeCanon}`; const routeColor = routeColorByScheduleId.get(seg.scheduleId) ?? fallbackRouteStroke; @@ -244,7 +217,7 @@ export function PlanItineraryMapRoutes({ if (pts.length) { polylines.push( { polylines.push( ; - epochBySegmentKey: Record; - bumpForDirections: (roomId: string) => void; - bumpSegments: (roomId: string, entries: PlanMapSegmentEpochEntry[]) => void; -}; - -export const usePlanMapDirectionsEpochStore = create((set) => ({ - epochByRoomId: {}, - epochBySegmentKey: {}, - bumpForDirections: (roomId) => { - const rid = String(roomId ?? "").trim(); - if (!rid.length) return; - set((s) => ({ - epochByRoomId: { - ...s.epochByRoomId, - [rid]: (s.epochByRoomId[rid] ?? 0) + 1, - }, - })); - }, - bumpSegments: (roomId, entries) => { - const rid = String(roomId ?? "").trim(); - if (!rid.length || entries.length === 0) return; - set((s) => { - const next = { ...s.epochBySegmentKey }; - for (const e of entries) { - const k = planMapSegmentEpochStoreKey(rid, e.scheduleId, e.segmentSourceItemId); - next[k] = (next[k] ?? 0) + 1; - } - return { epochBySegmentKey: next }; - }); - }, -})); From 92728877598bb751bae78bb0758cd4455d54816d Mon Sep 17 00:00:00 2001 From: KangShinGyu Date: Wed, 4 Nov 2026 15:13:54 +0900 Subject: [PATCH 04/14] =?UTF-8?q?test(route):=20=EC=A7=84=EC=9E=85=20batch?= =?UTF-8?q?=C2=B7=EC=9D=BC=EC=A0=95=20=EB=AA=A9=EB=A1=9D=C2=B7=EC=A7=80?= =?UTF-8?q?=EB=8F=84=EA=B0=80=20=EA=B0=99=EC=9D=80=20=EA=B2=BD=EB=A1=9C=20?= =?UTF-8?q?=EC=BA=90=EC=8B=9C=EB=A5=BC=20=EC=93=B0=EB=8A=94=EC=A7=80=20?= =?UTF-8?q?=ED=99=95=EC=9D=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - batch가 받아 둔 구간 경로를 목록 칸(다음 장소 googlePlaceId)과 지도(지도 구간 계산)가 단건 요청 없이 쓰는지 본다 Co-Authored-By: Claude Opus 5.5 --- src/lib/plan/schedule-bulk-hydration.test.ts | 70 ++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/src/lib/plan/schedule-bulk-hydration.test.ts b/src/lib/plan/schedule-bulk-hydration.test.ts index 0830440e..0812f409 100644 --- a/src/lib/plan/schedule-bulk-hydration.test.ts +++ b/src/lib/plan/schedule-bulk-hydration.test.ts @@ -16,6 +16,9 @@ vi.mock("@/lib/api/rooms/schedule-items", async (importOriginal) => { let hydrateScheduleRoutesBatch: typeof import("@/lib/plan/schedule-bulk-hydration").hydrateScheduleRoutesBatch; let fetchAndSeedScheduleRoutesBatch: typeof import("@/lib/plan/schedule-bulk-hydration").fetchAndSeedScheduleRoutesBatch; let resolveScheduleSegmentRoute: typeof import("@/lib/plan/scheduleSegmentRoute").resolveScheduleSegmentRoute; +let persistedScheduleItemRouteQueryOptions: typeof import("@/lib/plan/scheduleItemRoutePersistedQuery").persistedScheduleItemRouteQueryOptions; +let loadScheduleItemRouteForMap: typeof import("@/lib/plan/scheduleItemRoutePersistedQuery").loadScheduleItemRouteForMap; +let flattenPlanItinerarySegmentsFromPlaces: typeof import("@/lib/plan/planItineraryMapSegments").flattenPlanItinerarySegmentsFromPlaces; let scheduleItemsQueryKey: typeof import("@/lib/query-keys").scheduleItemsQueryKey; let scheduleItemRouteQueryKey: typeof import("@/lib/query-keys").scheduleItemRouteQueryKey; let registerQueryClient: typeof import("@/lib/query-client").registerQueryClient; @@ -46,6 +49,12 @@ beforeAll(async () => { ({ resolveScheduleSegmentRoute } = await import( "@/lib/plan/scheduleSegmentRoute" )); + ({ persistedScheduleItemRouteQueryOptions, loadScheduleItemRouteForMap } = await import( + "@/lib/plan/scheduleItemRoutePersistedQuery" + )); + ({ flattenPlanItinerarySegmentsFromPlaces } = await import( + "@/lib/plan/planItineraryMapSegments" + )); ({ scheduleItemsQueryKey, scheduleItemRouteQueryKey } = await import( "@/lib/query-keys" )); @@ -306,3 +315,64 @@ describe("fetchAndSeedScheduleRoutesBatch", () => { expect(getScheduleItemRoutesBatchMock).toHaveBeenCalledOnce(); }); }); + +describe("진입 batch·일정 목록·지도가 같은 구간 경로 캐시를 쓴다", () => { + const places = [place(1, "places/a"), place(2, "places/b"), place(3, "places/c")]; + const batchRoute = (itemId: number, distanceMeters: number) => ({ + status: "OK" as const, + itemId, + travelMode: "DRIVING", + distanceMeters, + durationSeconds: 60, + encodedPolyline: "_p~iF~ps|U_ulLnnqC", + }); + + it("진입 batch가 받아 둔 경로를 목록 칸과 지도가 다시 요청하지 않고 쓴다", async () => { + const queryClient = new QueryClient(); + queryClient.setQueryData(scheduleItemsQueryKey(ROOM_ID, SCHEDULE_ID), places); + getScheduleItemRoutesBatchMock.mockResolvedValue([batchRoute(1, 100), batchRoute(2, 200)]); + + await fetchAndSeedScheduleRoutesBatch(queryClient, ROOM_ID, SCHEDULE_ID, places); + + // 목록 칸 — PlanItinerary가 넘기는 것처럼 다음 장소의 googlePlaceId로 조회한다 + const listDistances = await Promise.all( + places.slice(0, -1).map((origin, index) => + queryClient + .fetchQuery( + persistedScheduleItemRouteQueryOptions( + ROOM_ID, + SCHEDULE_ID, + origin.itemId ?? null, + places[index + 1]?.googlePlaceId ?? "", + "DRIVING", + "", + { segmentReady: true }, + ), + ) + .then((route) => route?.distanceMeters), + ), + ); + // 지도 — 지도가 쓰는 구간 계산 그대로 조회한다 + const mapDistances = await Promise.all( + flattenPlanItinerarySegmentsFromPlaces([SCHEDULE_ID], [places]).map((segment) => + loadScheduleItemRouteForMap( + queryClient, + persistedScheduleItemRouteQueryOptions( + ROOM_ID, + segment.scheduleId, + segment.segmentSourceItemId, + segment.destPlaceId, + segment.travelModeCanon, + "", + { segmentReady: true }, + ), + ).then((route) => route?.distanceMeters), + ), + ); + + expect(listDistances).toEqual([100, 200]); + expect(mapDistances).toEqual([100, 200]); + expect(getScheduleItemRoutesBatchMock).toHaveBeenCalledOnce(); + expect(getScheduleItemRouteMock).not.toHaveBeenCalled(); + }); +}); From 6bc914fe4dc00e1dc3d933c4a51dd89f4139c60e Mon Sep 17 00:00:00 2001 From: KangShinGyu Date: Wed, 4 Nov 2026 16:55:01 +0900 Subject: [PATCH 05/14] =?UTF-8?q?fix(route):=20=EB=8B=A8=EA=B1=B4=20?= =?UTF-8?q?=EA=B2=BD=EB=A1=9C=20=EC=A1=B0=ED=9A=8C=EC=97=90=20=EB=8F=84?= =?UTF-8?q?=EC=B0=A9=20=ED=95=AD=EB=AA=A9=EC=9D=84=20=EB=B3=B4=EB=82=B4?= =?UTF-8?q?=EA=B3=A0=20=EA=B2=BD=EB=A1=9C=20key=EC=9D=98=20=EB=8F=84?= =?UTF-8?q?=EC=B0=A9=20=EA=B0=92=EC=9D=84=20=ED=95=AD=EB=AA=A9=20ID?= =?UTF-8?q?=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 서버가 그 순간의 순서로 다음 항목을 고르던 단건 GET에 destinationItemId를 보낸다 — 요청한 구간과 응답 구간이 항상 같다 - 경로 key·지도 선 key·sessionStorage key의 도착 값을 다음 장소의 itemId로 바꾼다 - 서버에 이 파라미터가 배포되기 전에는 서버가 무시하고 지금처럼 다음 항목으로 계산한다 Co-Authored-By: Claude Opus 5.5 --- .../_components/itinerary/PlanItinerary.tsx | 6 ++- .../mobile/MobilePlanItinerary.tsx | 1 + .../travel-time/PlanTravelTime.test.tsx | 1 + .../travel-time/PlanTravelTime.tsx | 7 ++- src/components/map/PlanItineraryMapRoutes.tsx | 13 ++--- src/hooks/useRooms.ts | 6 +-- src/lib/api/rooms/schedule-items.time.test.ts | 10 +++- src/lib/api/rooms/schedule-items.ts | 6 ++- src/lib/plan/planItineraryMapSegments.ts | 5 +- src/lib/plan/planTravelLocalStorage.test.ts | 49 +++++++++++++++++++ src/lib/plan/planTravelLocalStorage.ts | 9 +++- src/lib/plan/schedule-bulk-hydration.test.ts | 30 ++++++------ src/lib/plan/schedule-bulk-hydration.ts | 14 ++++-- .../scheduleItemRoutePersistedQuery.test.ts | 16 +++--- .../plan/scheduleItemRoutePersistedQuery.ts | 22 +++++---- src/lib/plan/scheduleSegmentRoute.test.ts | 24 +++++---- src/lib/plan/scheduleSegmentRoute.ts | 12 +++-- src/lib/query-keys.ts | 13 ++--- src/lib/stomp/schedules-dispatch.test.ts | 4 +- 19 files changed, 167 insertions(+), 81 deletions(-) create mode 100644 src/lib/plan/planTravelLocalStorage.test.ts diff --git a/src/app/(main)/plan/_components/itinerary/PlanItinerary.tsx b/src/app/(main)/plan/_components/itinerary/PlanItinerary.tsx index 43e1b919..21b6ecb0 100644 --- a/src/app/(main)/plan/_components/itinerary/PlanItinerary.tsx +++ b/src/app/(main)/plan/_components/itinerary/PlanItinerary.tsx @@ -218,6 +218,7 @@ export function PlanItinerary({ roomId, scheduleId }: PlanItineraryProps) { ); const insertIndex = index + 1; const segmentActive = activeInsertIndex === insertIndex; + const nextItemId = places[index + 1]?.itemId; return ( {index < places.length - 1 && typeof place.itemId === "number" && - typeof places[index + 1]?.itemId === "number" ? + typeof nextItemId === "number" ? @@ -264,6 +265,7 @@ export function PlanItinerary({ roomId, scheduleId }: PlanItineraryProps) { roomId={roomId} scheduleId={scheduleId} segmentSourceItemId={place.itemId} + destinationItemId={nextItemId} scheduleFingerprint={scheduleFingerprint} travelMode={place.travelMode} originGooglePlaceId={place.googlePlaceId} @@ -275,7 +277,7 @@ export function PlanItinerary({ roomId, scheduleId }: PlanItineraryProps) { !isFetchingPlaces && routesBatchSettled && existingItemIds.has(place.itemId) && - existingItemIds.has(places[index + 1].itemId!) + existingItemIds.has(nextItemId) } /> diff --git a/src/app/(main)/plan/_components/mobile/MobilePlanItinerary.tsx b/src/app/(main)/plan/_components/mobile/MobilePlanItinerary.tsx index 0b8d765c..4ce706ae 100644 --- a/src/app/(main)/plan/_components/mobile/MobilePlanItinerary.tsx +++ b/src/app/(main)/plan/_components/mobile/MobilePlanItinerary.tsx @@ -240,6 +240,7 @@ function PlaceTravelSegment({ roomId={roomId} scheduleId={scheduleId} segmentSourceItemId={place.itemId} + destinationItemId={next.itemId} scheduleFingerprint={scheduleFingerprint} travelMode={place.travelMode} originGooglePlaceId={place.googlePlaceId} diff --git a/src/app/(main)/plan/_components/travel-time/PlanTravelTime.test.tsx b/src/app/(main)/plan/_components/travel-time/PlanTravelTime.test.tsx index 90bd5f8d..a35386f2 100644 --- a/src/app/(main)/plan/_components/travel-time/PlanTravelTime.test.tsx +++ b/src/app/(main)/plan/_components/travel-time/PlanTravelTime.test.tsx @@ -23,6 +23,7 @@ function renderSummary() { roomId: "room-1", scheduleId: 1, segmentSourceItemId: 100, + destinationItemId: 101, scheduleFingerprint: "100:a:|101:b:", travelMode: "DRIVING", contentOnly: true, diff --git a/src/app/(main)/plan/_components/travel-time/PlanTravelTime.tsx b/src/app/(main)/plan/_components/travel-time/PlanTravelTime.tsx index f8e897e7..644b4a21 100644 --- a/src/app/(main)/plan/_components/travel-time/PlanTravelTime.tsx +++ b/src/app/(main)/plan/_components/travel-time/PlanTravelTime.tsx @@ -41,6 +41,8 @@ export type PlanTravelTimeProps = { roomId: string; scheduleId: number; segmentSourceItemId: number; + /** 다음 장소의 `itemId` — 경로를 이 항목까지 계산한다 */ + destinationItemId: number; scheduleFingerprint: string; /** 서버 저장 공유 이동수단 (`schedule_items.travel_mode`) — 없으면 기본 `DRIVING` */ travelMode?: string; @@ -58,6 +60,7 @@ export function PlanTravelTime({ roomId, scheduleId, segmentSourceItemId, + destinationItemId, scheduleFingerprint, travelMode, routeQueryEnabled = true, @@ -106,7 +109,7 @@ export function PlanTravelTime({ roomId, scheduleId, segmentSourceItemId, - destinationGooglePlaceId ?? "", + destinationItemId, selectedMode, routeQueryEnabled, fpTrim, @@ -128,7 +131,7 @@ export function PlanTravelTime({ rid, scheduleId, segmentSourceItemId, - destinationGooglePlaceId ?? "", + destinationItemId, mode, fpTrim, { diff --git a/src/components/map/PlanItineraryMapRoutes.tsx b/src/components/map/PlanItineraryMapRoutes.tsx index 3fb9fc89..5d7ed6f5 100644 --- a/src/components/map/PlanItineraryMapRoutes.tsx +++ b/src/components/map/PlanItineraryMapRoutes.tsx @@ -45,7 +45,6 @@ import { PlanItineraryStopMapPin } from "./PlanItineraryStopMapPin"; * 일차만 지도에 경로(polyline)와 정류장 마커를 그립니다. * `routeDay`가 있으면(모바일 경로 보기) 토글과 관계없이 그 일차만 그립니다. * 폴리라인 travelMode는 서버에 저장된 구간별 공유 이동수단을 따릅니다. - * STOMP 에폭은 {@link usePlanMapDirectionsEpochStore} 참고. */ /** 모바일 경로 보기 — 고른 일차 하나만 그린다 */ export type PlanItineraryRouteDay = { @@ -167,14 +166,12 @@ export function PlanItineraryMapRoutes({ rid, seg.scheduleId, seg.segmentSourceItemId, + seg.destinationItemId, seg.originPlaceId, seg.destPlaceId, seg.travelModeCanon, ), - /** - * 구간 경로는 일정 리스트와 같은 쿼리를 통해 가져옵니다. 그 쿼리에 맡겨야 - * in-flight 합치기와 재정렬 중 뒤늦게 온 응답 폐기를 React Query가 처리합니다. - */ + /** 구간 경로는 일정 리스트와 같은 쿼리로 가져와, 같은 구간을 받는 중이면 그 요청에 합류한다 */ queryFn: async () => { const route = await loadScheduleItemRouteForMap( queryClient, @@ -182,7 +179,7 @@ export function PlanItineraryMapRoutes({ rid, seg.scheduleId, seg.segmentSourceItemId, - seg.destPlaceId, + seg.destinationItemId, seg.travelModeCanon, fingerprintByScheduleId.get(seg.scheduleId) ?? "", { segmentReady: true }, @@ -208,8 +205,8 @@ export function PlanItineraryMapRoutes({ const query = pathsPerSegmentQueries[segIdx]; if (!query || query.isPending || query.isFetching) return; const pts = query.data?.path ?? []; - // 도착 장소가 바뀌면 다른 선이라 새로 그린다 - const polylineKey = `${seg.scheduleId}-${seg.segmentSourceItemId}-${seg.destPlaceId}-${seg.travelModeCanon}`; + // 도착 항목이 바뀌면 다른 선이라 새로 그린다 + const polylineKey = `${seg.scheduleId}-${seg.segmentSourceItemId}-${seg.destinationItemId}-${seg.travelModeCanon}`; const routeColor = routeColorByScheduleId.get(seg.scheduleId) ?? fallbackRouteStroke; diff --git a/src/hooks/useRooms.ts b/src/hooks/useRooms.ts index c8a8de76..d6284f65 100644 --- a/src/hooks/useRooms.ts +++ b/src/hooks/useRooms.ts @@ -174,8 +174,8 @@ export function useScheduleItemRoute( roomId: string | null, scheduleId: number | null, segmentSourceItemId: number | null, - /** 다음 장소의 `googlePlaceId` */ - destinationPlaceId: string, + /** 다음 장소의 `itemId` */ + destinationItemId: number | null, travelMode: ScheduleTravelModeValue, /** 일정 장소 목록상 구간 소스 아이디가 존재·목록 안정 후에만 true */ routeQueryEnabled = true, @@ -189,7 +189,7 @@ export function useScheduleItemRoute( rid, scheduleId, segmentSourceItemId, - destinationPlaceId, + destinationItemId, travelMode, scheduleFingerprint, { segmentReady: Boolean(routeQueryEnabled) }, diff --git a/src/lib/api/rooms/schedule-items.time.test.ts b/src/lib/api/rooms/schedule-items.time.test.ts index 0e911ac8..6cd61797 100644 --- a/src/lib/api/rooms/schedule-items.time.test.ts +++ b/src/lib/api/rooms/schedule-items.time.test.ts @@ -66,8 +66,16 @@ describe("schedule time JSON contract", () => { }), ), ); - expect(await api.getScheduleItemRoute("room", 10, 1)).toMatchObject({ + expect(await api.getScheduleItemRoute("room", 10, 1, 2)).toMatchObject({ durationSeconds: 1200, }); }); + it("asks the route to the given destination item", async () => { + apiFetch.mockResolvedValue(new Response(null, { status: 204 })); + await api.getScheduleItemRoute("room", 10, 1, 2, "WALKING"); + const url = new URL(String(apiFetch.mock.calls.at(-1)?.[0])); + expect(url.pathname).toMatch(/\/rooms\/room\/schedules\/10\/items\/1\/route$/); + expect(url.searchParams.get("destinationItemId")).toBe("2"); + expect(url.searchParams.get("travelMode")).toBe("WALKING"); + }); }); diff --git a/src/lib/api/rooms/schedule-items.ts b/src/lib/api/rooms/schedule-items.ts index c66ae23d..8afa1f23 100644 --- a/src/lib/api/rooms/schedule-items.ts +++ b/src/lib/api/rooms/schedule-items.ts @@ -118,11 +118,13 @@ function readScheduleItemRouteBatchItems( export async function getScheduleItemRoute( roomId: string, scheduleId: number, - /** 구간 시작(현재) 일정 항목 ID — 명세상 ‘현재 항목에서 다음 항목으로’ */ + /** 구간 출발 일정 항목 ID */ itemId: number, + /** 구간 도착 일정 항목 ID — 보내면 서버가 그 순간의 순서와 관계없이 이 항목까지 계산한다 */ + destinationItemId: number, travelMode?: string | null, ): Promise { - const params = new URLSearchParams(); + const params = new URLSearchParams({ destinationItemId: String(destinationItemId) }); const tm = typeof travelMode === "string" && travelMode.trim().length > 0 ? travelMode.trim() diff --git a/src/lib/plan/planItineraryMapSegments.ts b/src/lib/plan/planItineraryMapSegments.ts index a37aad67..85701c06 100644 --- a/src/lib/plan/planItineraryMapSegments.ts +++ b/src/lib/plan/planItineraryMapSegments.ts @@ -11,6 +11,8 @@ import { export type PlanItinerarySegmentDescriptor = { scheduleId: number; segmentSourceItemId: number; + /** 다음 장소의 `itemId` — 경로를 이 항목까지 계산한다 */ + destinationItemId: number; originPlaceId: string; destPlaceId: string; travelModeCanon: ScheduleTravelModeValue; @@ -36,12 +38,13 @@ export function flattenPlanItinerarySegmentsFromPlaces( typeof a.googlePlaceId === "string" ? a.googlePlaceId.trim() : ""; const d = typeof b.googlePlaceId === "string" ? b.googlePlaceId.trim() : ""; - if (typeof a.itemId !== "number" || !o.length || !d.length) { + if (typeof a.itemId !== "number" || typeof b.itemId !== "number" || !o.length || !d.length) { continue; } segments.push({ scheduleId, segmentSourceItemId: a.itemId, + destinationItemId: b.itemId, originPlaceId: o, destPlaceId: d, travelModeCanon: diff --git a/src/lib/plan/planTravelLocalStorage.test.ts b/src/lib/plan/planTravelLocalStorage.test.ts new file mode 100644 index 00000000..1d32417c --- /dev/null +++ b/src/lib/plan/planTravelLocalStorage.test.ts @@ -0,0 +1,49 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { + readScheduleRouteFromSessionStorage, + writeScheduleRouteToSessionStorage, +} from "./planTravelLocalStorage"; + +function memoryStorage(): Storage { + const map = new Map(); + return { + get length() { + return map.size; + }, + key: (index) => [...map.keys()][index] ?? null, + getItem: (key) => map.get(key) ?? null, + setItem: (key, value) => { + map.set(key, String(value)); + }, + removeItem: (key) => { + map.delete(key); + }, + clear: () => map.clear(), + }; +} + +describe("구간 경로 sessionStorage", () => { + beforeEach(() => { + const storage = memoryStorage(); + vi.stubGlobal("window", { sessionStorage: storage }); + vi.stubGlobal("sessionStorage", storage); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("출발 항목과 일정 지문이 같아도 도착 항목이 다르면 저장한 경로를 쓰지 않는다", () => { + const toC = { distanceMeters: 999, durationSeconds: 60, travelMode: "DRIVING" }; + // 순서 저장 중 새 순서([1,3,2])로 그린 칸이 옛 순서([1,2,3])의 지문으로 1→3 경로를 저장한 경우 + writeScheduleRouteToSessionStorage("room-1", 10, 1, 3, "DRIVING", "fp-1-2-3", toC); + + expect( + readScheduleRouteFromSessionStorage("room-1", 10, 1, 2, "DRIVING", "fp-1-2-3"), + ).toBeUndefined(); + expect( + readScheduleRouteFromSessionStorage("room-1", 10, 1, 3, "DRIVING", "fp-1-2-3"), + ).toEqual(toC); + }); +}); diff --git a/src/lib/plan/planTravelLocalStorage.ts b/src/lib/plan/planTravelLocalStorage.ts index 2908a5ce..e93eeda7 100644 --- a/src/lib/plan/planTravelLocalStorage.ts +++ b/src/lib/plan/planTravelLocalStorage.ts @@ -8,13 +8,15 @@ function roundCoord(n: number): string { const NS_ROUTE = "hau:plan:route:v1"; +/** 구간 = 출발 항목 → 도착 항목. 도착 항목이 없던 예전 형식(`…:출발:수단`)은 이 key와 겹치지 않아 읽지 않는다 */ function routeStorageKey( roomId: string, scheduleId: number, segmentSourceItemId: number, + destinationItemId: number, travelMode: string, ): string { - return `${NS_ROUTE}:${roomId.trim()}:${scheduleId}:${segmentSourceItemId}:${travelMode.trim()}`; + return `${NS_ROUTE}:${roomId.trim()}:${scheduleId}:${segmentSourceItemId}:${destinationItemId}:${travelMode.trim()}`; } type PersistedRouteV1 = { @@ -95,13 +97,14 @@ export function readScheduleRouteFromSessionStorage( roomId: string, scheduleId: number, segmentSourceItemId: number, + destinationItemId: number, travelMode: string, expectedFp: string, ): ScheduleItemRouteResponse | null | undefined { if (typeof window === "undefined" || !expectedFp) return undefined; try { const raw = sessionStorage.getItem( - routeStorageKey(roomId, scheduleId, segmentSourceItemId, travelMode), + routeStorageKey(roomId, scheduleId, segmentSourceItemId, destinationItemId, travelMode), ); if (!raw) return undefined; const o = JSON.parse(raw) as PersistedRouteV1; @@ -118,6 +121,7 @@ export function writeScheduleRouteToSessionStorage( roomId: string, scheduleId: number, segmentSourceItemId: number, + destinationItemId: number, travelMode: string, fp: string, data: ScheduleItemRouteResponse | null, @@ -128,6 +132,7 @@ export function writeScheduleRouteToSessionStorage( roomId, scheduleId, segmentSourceItemId, + destinationItemId, travelMode, ); if (data === null) { diff --git a/src/lib/plan/schedule-bulk-hydration.test.ts b/src/lib/plan/schedule-bulk-hydration.test.ts index 0812f409..b65f90e8 100644 --- a/src/lib/plan/schedule-bulk-hydration.test.ts +++ b/src/lib/plan/schedule-bulk-hydration.test.ts @@ -93,7 +93,7 @@ describe("hydrateScheduleRoutesBatch", () => { expect( queryClient.getQueryData( - scheduleItemRouteQueryKey(ROOM_ID, SCHEDULE_ID, 1, "places/b", "DRIVING"), + scheduleItemRouteQueryKey(ROOM_ID, SCHEDULE_ID, 1, 2, "DRIVING"), ), ).toMatchObject({ distanceMeters: 1200, @@ -134,7 +134,7 @@ describe("hydrateScheduleRoutesBatch", () => { expect( queryClient.getQueryData( - scheduleItemRouteQueryKey(ROOM_ID, SCHEDULE_ID, 1, "places/b", "DRIVING"), + scheduleItemRouteQueryKey(ROOM_ID, SCHEDULE_ID, 1, 2, "DRIVING"), ), ).not.toHaveProperty("encodedPolyline"); }); @@ -180,7 +180,7 @@ describe("hydrateScheduleRoutesBatch", () => { ROOM_ID, SCHEDULE_ID, 2, - "places/c", + 3, "WALKING", ); expect(queryClient.getQueryData(failedKey)).toBeUndefined(); @@ -189,7 +189,7 @@ describe("hydrateScheduleRoutesBatch", () => { roomId: ROOM_ID, scheduleId: SCHEDULE_ID, segmentSourceItemId: 2, - destinationPlaceId: "places/c", + destinationItemId: 3, travelMode: "WALKING", }), ).rejects.toThrow("temporary single route failure"); @@ -199,7 +199,7 @@ describe("hydrateScheduleRoutesBatch", () => { roomId: ROOM_ID, scheduleId: SCHEDULE_ID, segmentSourceItemId: 2, - destinationPlaceId: "places/c", + destinationItemId: 3, travelMode: "WALKING", }), ).resolves.toEqual(fallbackRoute); @@ -221,14 +221,14 @@ describe("hydrateScheduleRoutesBatch", () => { await hydrateScheduleRoutesBatch(queryClient, ROOM_ID, SCHEDULE_ID, places); expect( - queryClient.getQueryData(scheduleItemRouteQueryKey(ROOM_ID, SCHEDULE_ID, 2, "places/c", "WALKING")), + queryClient.getQueryData(scheduleItemRouteQueryKey(ROOM_ID, SCHEDULE_ID, 2, 3, "WALKING")), ).toBeNull(); await expect( resolveScheduleSegmentRoute({ roomId: ROOM_ID, scheduleId: SCHEDULE_ID, segmentSourceItemId: 2, - destinationPlaceId: "places/c", + destinationItemId: 3, travelMode: "WALKING", }), ).resolves.toBeNull(); @@ -255,7 +255,7 @@ describe("hydrateScheduleRoutesBatch", () => { expect( queryClient.getQueryData( - scheduleItemRouteQueryKey(ROOM_ID, SCHEDULE_ID, 1, "places/b", "DRIVING"), + scheduleItemRouteQueryKey(ROOM_ID, SCHEDULE_ID, 1, 2, "DRIVING"), ), ).toBeUndefined(); }); @@ -268,8 +268,8 @@ describe("fetchAndSeedScheduleRoutesBatch", () => { function seededClient() { const queryClient = new QueryClient(); queryClient.setQueryData(scheduleItemsQueryKey(ROOM_ID, SCHEDULE_ID), places); - queryClient.setQueryData(scheduleItemRouteQueryKey(ROOM_ID, SCHEDULE_ID, 1, "places/b", "DRIVING"), route); - queryClient.setQueryData(scheduleItemRouteQueryKey(ROOM_ID, SCHEDULE_ID, 2, "places/c", "DRIVING"), route); + queryClient.setQueryData(scheduleItemRouteQueryKey(ROOM_ID, SCHEDULE_ID, 1, 2, "DRIVING"), route); + queryClient.setQueryData(scheduleItemRouteQueryKey(ROOM_ID, SCHEDULE_ID, 2, 3, "DRIVING"), route); return queryClient; } @@ -281,7 +281,7 @@ describe("fetchAndSeedScheduleRoutesBatch", () => { it("캐시에 없는 구간이 있으면 일괄로 다시 받는다", async () => { const queryClient = seededClient(); - queryClient.removeQueries({ queryKey: scheduleItemRouteQueryKey(ROOM_ID, SCHEDULE_ID, 2, "places/c", "DRIVING") }); + queryClient.removeQueries({ queryKey: scheduleItemRouteQueryKey(ROOM_ID, SCHEDULE_ID, 2, 3, "DRIVING") }); getScheduleItemRoutesBatchMock.mockResolvedValue([]); await fetchAndSeedScheduleRoutesBatch(queryClient, ROOM_ID, SCHEDULE_ID, places); @@ -305,7 +305,7 @@ describe("fetchAndSeedScheduleRoutesBatch", () => { it("일정 변경으로 무효화된 구간이 있으면 일괄로 다시 받는다", async () => { const queryClient = seededClient(); await queryClient.invalidateQueries({ - queryKey: scheduleItemRouteQueryKey(ROOM_ID, SCHEDULE_ID, 1, "places/b", "DRIVING"), + queryKey: scheduleItemRouteQueryKey(ROOM_ID, SCHEDULE_ID, 1, 2, "DRIVING"), refetchType: "none", }); getScheduleItemRoutesBatchMock.mockResolvedValue([]); @@ -334,7 +334,7 @@ describe("진입 batch·일정 목록·지도가 같은 구간 경로 캐시를 await fetchAndSeedScheduleRoutesBatch(queryClient, ROOM_ID, SCHEDULE_ID, places); - // 목록 칸 — PlanItinerary가 넘기는 것처럼 다음 장소의 googlePlaceId로 조회한다 + // 목록 칸 — PlanItinerary가 넘기는 것처럼 다음 장소의 itemId로 조회한다 const listDistances = await Promise.all( places.slice(0, -1).map((origin, index) => queryClient @@ -343,7 +343,7 @@ describe("진입 batch·일정 목록·지도가 같은 구간 경로 캐시를 ROOM_ID, SCHEDULE_ID, origin.itemId ?? null, - places[index + 1]?.googlePlaceId ?? "", + places[index + 1]?.itemId ?? null, "DRIVING", "", { segmentReady: true }, @@ -361,7 +361,7 @@ describe("진입 batch·일정 목록·지도가 같은 구간 경로 캐시를 ROOM_ID, segment.scheduleId, segment.segmentSourceItemId, - segment.destPlaceId, + segment.destinationItemId, segment.travelModeCanon, "", { segmentReady: true }, diff --git a/src/lib/plan/schedule-bulk-hydration.ts b/src/lib/plan/schedule-bulk-hydration.ts index 6eaad4f5..f9b5e9a6 100644 --- a/src/lib/plan/schedule-bulk-hydration.ts +++ b/src/lib/plan/schedule-bulk-hydration.ts @@ -275,19 +275,23 @@ function scheduleRoutesBatchKey( return `${roomId.trim()}:${scheduleId}:${placesFingerprint}`; } -/** 구간 출발 항목 → 다음 장소의 `googlePlaceId` (마지막 장소와 장소 ID가 빈 구간은 뺀다) */ -function segmentDestinationsByItemId(places: readonly PlanPlace[]): Map { - const out = new Map(); +/** 구간 출발 항목 → 다음 장소의 `itemId` (마지막 장소와 항목 ID가 없는 구간은 뺀다) */ +function segmentDestinationsByItemId(places: readonly PlanPlace[]): Map { + const out = new Map(); for (let i = 0; i < places.length - 1; i += 1) { const itemId = places[i]?.itemId; - const destination = places[i + 1]?.googlePlaceId?.trim() ?? ""; - if (typeof itemId === "number" && Number.isFinite(itemId) && destination.length) { + const destination = places[i + 1]?.itemId; + if (isFiniteItemId(itemId) && isFiniteItemId(destination)) { out.set(itemId, destination); } } return out; } +function isFiniteItemId(id: number | undefined): id is number { + return typeof id === "number" && Number.isFinite(id); +} + /** * 일차의 모든 구간 경로가 이미 캐시에 있는지 — 일정 화면에서 받아 뒀거나, 화면을 다시 열었거나, * 지도 경로 보기로 넘어온 경우다. 받은 적 없거나(새 순서·gc) 무효화된 구간이 하나라도 있으면 false. diff --git a/src/lib/plan/scheduleItemRoutePersistedQuery.test.ts b/src/lib/plan/scheduleItemRoutePersistedQuery.test.ts index f74b3ddc..7171e8c4 100644 --- a/src/lib/plan/scheduleItemRoutePersistedQuery.test.ts +++ b/src/lib/plan/scheduleItemRoutePersistedQuery.test.ts @@ -42,7 +42,7 @@ describe("persistedScheduleItemRouteQueryOptions", () => { "room-1", 10, 1, - "place-b", + 2, "DRIVING", "", { segmentReady: true }, @@ -72,20 +72,20 @@ describe("persistedScheduleItemRouteQueryOptions", () => { }); describe("persistedScheduleItemRouteQueryOptions key", () => { - it("도착 장소가 key에 들어가 같은 출발 항목이라도 도착 장소마다 다른 캐시를 쓴다", () => { - const toB = persistedScheduleItemRouteQueryOptions("room-1", 10, 1, "place-b", "DRIVING", "", { + it("도착 항목이 key에 들어가 같은 출발 항목이라도 도착 항목마다 다른 캐시를 쓴다", () => { + const toB = persistedScheduleItemRouteQueryOptions("room-1", 10, 1, 2, "DRIVING", "", { segmentReady: true, }); - const toC = persistedScheduleItemRouteQueryOptions("room-1", 10, 1, "place-c", "DRIVING", "", { + const toC = persistedScheduleItemRouteQueryOptions("room-1", 10, 1, 3, "DRIVING", "", { segmentReady: true, }); - expect(toB.queryKey).toEqual(["schedule-item-route", "room-1", 10, 1, "place-b", "DRIVING"]); + expect(toB.queryKey).toEqual(["schedule-item-route", "room-1", 10, 1, 2, "DRIVING"]); expect(toC.queryKey).not.toEqual(toB.queryKey); }); - it("도착 장소가 없으면 조회하지 않는다", () => { - const options = persistedScheduleItemRouteQueryOptions("room-1", 10, 1, " ", "DRIVING", "", { + it("도착 항목이 없으면 조회하지 않는다", () => { + const options = persistedScheduleItemRouteQueryOptions("room-1", 10, 1, null, "DRIVING", "", { segmentReady: true, }); @@ -96,7 +96,7 @@ describe("persistedScheduleItemRouteQueryOptions key", () => { describe("loadScheduleItemRouteForMap", () => { const route = { travelMode: "TRANSIT", distanceMeters: 1700, durationSeconds: 1140 }; const routeOptions = () => - persistedScheduleItemRouteQueryOptions("room-1", 10, 1, "place-b", "TRANSIT", "", { + persistedScheduleItemRouteQueryOptions("room-1", 10, 1, 2, "TRANSIT", "", { segmentReady: true, }); diff --git a/src/lib/plan/scheduleItemRoutePersistedQuery.ts b/src/lib/plan/scheduleItemRoutePersistedQuery.ts index 978ecbe1..3a134cfe 100644 --- a/src/lib/plan/scheduleItemRoutePersistedQuery.ts +++ b/src/lib/plan/scheduleItemRoutePersistedQuery.ts @@ -15,7 +15,7 @@ type RouteCached = ScheduleItemRouteResponse | null; /** * — `segmentReady`: 네트워크 패치·sessionStorage 시드 허용(`routeQueryEnabled`). 쿼리 키는 이것과 무관하게 - * `(room, schedule, 구간, 도착 장소, 모드)`로 고정 — 일정 refetch 중 잠깐 false가 되어도 구간끼리 캐시를 공유하지 않음 + * `(room, schedule, 출발 항목, 도착 항목, 모드)`로 고정 — 일정 refetch 중 잠깐 false가 되어도 구간끼리 캐시를 공유하지 않음 * — `networkEnabled` 생략 시 `segmentReady`와 동일; 지연 수단은 메뉴 열림 시만 `true` */ export type PersistedScheduleRoutePersistFlags = { @@ -25,13 +25,13 @@ export type PersistedScheduleRoutePersistFlags = { networkEnabled?: boolean; }; -/** `GET …/route?travelMode=…` — sessionStorage(`scheduleFingerprint` 일치) ↔ React Query */ +/** `GET …/route?destinationItemId=…&travelMode=…` — sessionStorage(`scheduleFingerprint` 일치) ↔ React Query */ export function persistedScheduleItemRouteQueryOptions( roomIdTrimmed: string, scheduleId: number | null, segmentSourceItemId: number | null, - /** 다음 장소의 `googlePlaceId` — 비어 있으면 구간이 정해지지 않아 조회하지 않는다 */ - destinationPlaceId: string, + /** 다음 장소의 `itemId` — 없으면 구간이 정해지지 않아 조회하지 않는다 */ + destinationItemId: number | null, travelMode: ScheduleTravelModeValue, fingerprintTrimmed: string, flags: PersistedScheduleRoutePersistFlags, @@ -45,11 +45,13 @@ export function persistedScheduleItemRouteQueryOptions( const itemOk = typeof segmentSourceItemId === "number" && Number.isFinite(segmentSourceItemId); - const dest = destinationPlaceId.trim(); + const destOk = + typeof destinationItemId === "number" && + Number.isFinite(destinationItemId); const fp = fingerprintTrimmed.trim(); const keyEligible = - flags.segmentReady && rid.length > 0 && sidOk && itemOk && dest.length > 0; + flags.segmentReady && rid.length > 0 && sidOk && itemOk && destOk; const network = typeof flags.networkEnabled === "boolean" @@ -61,9 +63,8 @@ export function persistedScheduleItemRouteQueryOptions( const keyRoom = rid.length > 0 ? rid : null; const keySchedule = sidOk ? sid : null; const keySegment = itemOk ? segmentSourceItemId : null; - const keyDestination = dest.length > 0 ? dest : null; - const keyMode = - sidOk && itemOk && rid.length > 0 && dest.length > 0 ? travelMode : null; + const keyDestination = destOk ? destinationItemId : null; + const keyMode = sidOk && itemOk && destOk && rid.length > 0 ? travelMode : null; const sessionSeed: RouteCached | undefined = fp.length > 0 && @@ -74,6 +75,7 @@ export function persistedScheduleItemRouteQueryOptions( rid, sid!, segmentSourceItemId!, + destinationItemId!, travelMode, fp, ) @@ -98,7 +100,7 @@ export function persistedScheduleItemRouteQueryOptions( roomId: rid, scheduleId: sid!, segmentSourceItemId: segmentSourceItemId!, - destinationPlaceId: dest, + destinationItemId: destinationItemId!, travelMode, scheduleFingerprint: fp, }), diff --git a/src/lib/plan/scheduleSegmentRoute.test.ts b/src/lib/plan/scheduleSegmentRoute.test.ts index ba2a171e..ce240784 100644 --- a/src/lib/plan/scheduleSegmentRoute.test.ts +++ b/src/lib/plan/scheduleSegmentRoute.test.ts @@ -18,7 +18,7 @@ let getScheduleItemRouteMock: ReturnType; const ROOM_ID = "room-1"; const SCHEDULE_ID = 10; const ITEM_ID = 1; -const DESTINATION = "place-b"; +const DESTINATION = 2; const ROUTE = { distanceMeters: 1200, @@ -54,12 +54,12 @@ beforeEach(() => { const ROUTE_KEY = (destination = DESTINATION) => scheduleItemRouteQueryKey(ROOM_ID, SCHEDULE_ID, ITEM_ID, destination, "DRIVING"); -function resolve(destinationPlaceId = DESTINATION) { +function resolve(destinationItemId = DESTINATION) { return resolveScheduleSegmentRoute({ roomId: ROOM_ID, scheduleId: SCHEDULE_ID, segmentSourceItemId: ITEM_ID, - destinationPlaceId, + destinationItemId, travelMode: "DRIVING", }); } @@ -72,11 +72,17 @@ describe("resolveScheduleSegmentRoute", () => { expect(getScheduleItemRouteMock).not.toHaveBeenCalled(); }); - it("캐시 miss면 단건 GET 결과를 반환한다", async () => { + it("캐시 miss면 도착 항목을 지정한 단건 GET 결과를 반환한다", async () => { getScheduleItemRouteMock.mockResolvedValue(ROUTE); await expect(resolve()).resolves.toEqual(ROUTE); - expect(getScheduleItemRouteMock).toHaveBeenCalledTimes(1); + expect(getScheduleItemRouteMock).toHaveBeenCalledExactlyOnceWith( + ROOM_ID, + SCHEDULE_ID, + ITEM_ID, + DESTINATION, + "DRIVING", + ); }); it("경로 없음(204)은 null을 반환한다", async () => { @@ -85,13 +91,13 @@ describe("resolveScheduleSegmentRoute", () => { await expect(resolve()).resolves.toBeNull(); }); - it("같은 출발 항목이라도 도착 장소가 다르면 그 도착 장소의 경로를 받고, 순서를 되돌리면 받아 둔 경로를 쓴다", async () => { - queryClient.setQueryData(ROUTE_KEY("place-b"), ROUTE); + it("같은 출발 항목이라도 도착 항목이 다르면 그 도착 항목의 경로를 받고, 순서를 되돌리면 받아 둔 경로를 쓴다", async () => { + queryClient.setQueryData(ROUTE_KEY(2), ROUTE); const toC = { ...ROUTE, distanceMeters: 999, encodedPolyline: "zzz" }; getScheduleItemRouteMock.mockResolvedValue(toC); - await expect(resolve("place-c")).resolves.toEqual(toC); - await expect(resolve("place-b")).resolves.toEqual(ROUTE); + await expect(resolve(3)).resolves.toEqual(toC); + await expect(resolve(2)).resolves.toEqual(ROUTE); expect(getScheduleItemRouteMock).toHaveBeenCalledTimes(1); }); diff --git a/src/lib/plan/scheduleSegmentRoute.ts b/src/lib/plan/scheduleSegmentRoute.ts index 355ae04f..e943810a 100644 --- a/src/lib/plan/scheduleSegmentRoute.ts +++ b/src/lib/plan/scheduleSegmentRoute.ts @@ -13,15 +13,15 @@ export type ResolveScheduleSegmentRouteArgs = { roomId: string; scheduleId: number; segmentSourceItemId: number; - /** 다음 장소의 `googlePlaceId` — 캐시 key에만 쓴다(서버는 출발 항목의 다음 항목으로 계산) */ - destinationPlaceId: string; + /** 다음 장소의 `itemId` — 서버가 그 순간의 순서와 관계없이 이 항목까지 계산한다 */ + destinationItemId: number; travelMode: ScheduleTravelModeValue; /** 일정 지문 — 비면 sessionStorage에 기록하지 않습니다. */ scheduleFingerprint?: string; }; /** - * 구간 `(room, schedule, 출발 항목, 도착 장소, 이동수단)` 경로를 가져오는 fetch 본체입니다. + * 구간 `(room, schedule, 출발 항목, 도착 항목, 이동수단)` 경로를 가져오는 fetch 본체입니다. * 거리·소요 시간과 지도 폴리라인이 같은 캐시 항목을 공유하도록 일정 리스트와 지도가 모두 * 이 함수를 queryFn으로 쓰는 하나의 쿼리(`scheduleItemRouteQueryKey`)를 거칩니다. * @@ -31,7 +31,7 @@ export async function resolveScheduleSegmentRoute({ roomId, scheduleId, segmentSourceItemId, - destinationPlaceId, + destinationItemId, travelMode, scheduleFingerprint, }: ResolveScheduleSegmentRouteArgs): Promise { @@ -45,7 +45,7 @@ export async function resolveScheduleSegmentRoute({ rid, scheduleId, segmentSourceItemId, - destinationPlaceId, + destinationItemId, travelMode, ); const cached = getQueryClient()?.getQueryState(routeKey); @@ -57,6 +57,7 @@ export async function resolveScheduleSegmentRoute({ rid, scheduleId, segmentSourceItemId, + destinationItemId, travelMode, ); @@ -66,6 +67,7 @@ export async function resolveScheduleSegmentRoute({ rid, scheduleId, segmentSourceItemId, + destinationItemId, travelMode, fp, fresh, diff --git a/src/lib/query-keys.ts b/src/lib/query-keys.ts index 0009acbf..e63a7b29 100644 --- a/src/lib/query-keys.ts +++ b/src/lib/query-keys.ts @@ -51,6 +51,7 @@ export function planItinerarySegmentMapPathQueryKey( rid: string, scheduleId: number, segmentSourceItemId: number, + destinationItemId: number, originPlaceId: string, destPlaceId: string, travelModeCanon: string, @@ -61,6 +62,7 @@ export function planItinerarySegmentMapPathQueryKey( rid, scheduleId, segmentSourceItemId, + destinationItemId, originPlaceId.trim(), destPlaceId.trim(), travelModeCanon, @@ -116,16 +118,15 @@ export const scheduleItemsQueryKey = ( ) => ["schedule-items", roomId, scheduleId, "v2-loc"] as const; /** - * 구간별 길찾기 API 결과 (`GET …/route?travelMode=…`). - * 서버는 출발 항목에서 그 순간의 다음 항목까지 경로를 주므로 도착 장소를 key에 넣는다 — - * 순서가 바뀌면 다른 key가 되고, 순서를 되돌리면 받아 둔 값을 그대로 쓴다. - * 무효화: `["schedule-item-route", roomId]` prefix. + * 구간별 길찾기 API 결과 (`GET …/route?destinationItemId=…&travelMode=…`). + * 요청에 보내는 출발·도착 항목과 이동수단이 곧 key라, 순서가 바뀌면 다른 key가 되고 + * 순서를 되돌리면 받아 둔 값을 그대로 쓴다. 일정이 바뀌어도 무효화하지 않는다. */ export const scheduleItemRouteQueryKey = ( roomId: string | null, scheduleId: number | null, segmentSourceItemId: number | null, - destinationPlaceId: string | null, + destinationItemId: number | null, travelMode: ScheduleTravelModeValue | null, ) => [ @@ -133,7 +134,7 @@ export const scheduleItemRouteQueryKey = ( roomId, scheduleId, segmentSourceItemId, - destinationPlaceId, + destinationItemId, travelMode, ] as const; diff --git a/src/lib/stomp/schedules-dispatch.test.ts b/src/lib/stomp/schedules-dispatch.test.ts index 6f93d322..4ca587d9 100644 --- a/src/lib/stomp/schedules-dispatch.test.ts +++ b/src/lib/stomp/schedules-dispatch.test.ts @@ -38,8 +38,8 @@ function itemEvent( describe("dispatchRoomScheduleEvent", () => { let queryClient: QueryClient; - // 경로는 도착 장소까지 key에 있어 일정이 바뀌어도 무효화하지 않는다 — 순서가 바뀐 구간은 새 key로 받는다 - const routeKey = scheduleItemRouteQueryKey("room-1", 10, 20, "place-b", "DRIVING"); + // 경로는 도착 항목까지 key에 있어 일정이 바뀌어도 무효화하지 않는다 — 순서가 바뀐 구간은 새 key로 받는다 + const routeKey = scheduleItemRouteQueryKey("room-1", 10, 20, 21, "DRIVING"); beforeEach(() => { queryClient = new QueryClient(); From 7957b20a08dfa425c24b4d6764ec57ee54f4205f Mon Sep 17 00:00:00 2001 From: KangShinGyu Date: Wed, 4 Nov 2026 16:56:07 +0900 Subject: [PATCH 06/14] =?UTF-8?q?fix(route):=20batch=20=EA=B2=BD=EB=A1=9C?= =?UTF-8?q?=EB=A5=BC=20=EC=9D=91=EB=8B=B5=EC=9D=98=20=EB=8F=84=EC=B0=A9=20?= =?UTF-8?q?=ED=95=AD=EB=AA=A9=EC=9C=BC=EB=A1=9C=20=EC=A0=80=EC=9E=A5?= =?UTF-8?q?=ED=95=B4=20=EA=B8=B0=EB=8B=A4=EB=A6=AC=EB=8A=94=20=EC=82=AC?= =?UTF-8?q?=EC=9D=B4=20=EC=88=9C=EC=84=9C=EA=B0=80=20=EB=B0=94=EB=80=8C?= =?UTF-8?q?=EC=96=B4=EB=8F=84=20=EB=B2=84=EB=A6=AC=EC=A7=80=20=EC=95=8A?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - batch 응답의 toItemId(서버가 계산한 구간의 도착 항목)로 경로 key를 만든다 — 요청 때 목록에서 짐작하지 않는다 - 응답을 기다리는 사이 순서가 바뀌면 결과를 통째로 버리던 일정 지문 비교를 지운다 - 마지막 항목·항목 없음 답은 도착 항목이 없어 저장하지 않고, 그 칸이 도착 항목을 지정한 단건 조회로 받는다 Co-Authored-By: Claude Opus 5.5 --- src/lib/api/rooms/schedule-items.ts | 4 ++ src/lib/plan/schedule-bulk-hydration.test.ts | 55 ++++++++++++++++---- src/lib/plan/schedule-bulk-hydration.ts | 28 +++------- 3 files changed, 57 insertions(+), 30 deletions(-) diff --git a/src/lib/api/rooms/schedule-items.ts b/src/lib/api/rooms/schedule-items.ts index 8afa1f23..a2cdff64 100644 --- a/src/lib/api/rooms/schedule-items.ts +++ b/src/lib/api/rooms/schedule-items.ts @@ -87,6 +87,8 @@ export type ScheduleItemRouteBatchItem = | ({ status: "OK"; itemId: number; + /** 서버가 계산한 구간의 도착 항목(요청을 받은 순간의 다음 항목) */ + toItemId?: number; travelMode: string; distanceMeters: number; durationSeconds: number; @@ -96,6 +98,8 @@ export type ScheduleItemRouteBatchItem = | { status: "ERROR"; itemId: number; + /** 경로 없음(`NO_ROUTE`)이면 있고, 마지막 항목·항목 없음이면 서버가 생략 */ + toItemId?: number; travelMode: string; errorCode?: string; }; diff --git a/src/lib/plan/schedule-bulk-hydration.test.ts b/src/lib/plan/schedule-bulk-hydration.test.ts index b65f90e8..4faa6cdb 100644 --- a/src/lib/plan/schedule-bulk-hydration.test.ts +++ b/src/lib/plan/schedule-bulk-hydration.test.ts @@ -75,13 +75,14 @@ describe("hydrateScheduleRoutesBatch", () => { const routeResult = { status: "OK" as const, itemId: 1, + toItemId: 2, travelMode: "DRIVING", distanceMeters: 1200, durationSeconds: 300, encodedPolyline: "_p~iF~ps|U_ulLnnqC", }; - it("응답 시점에 일정 지문이 그대로면 경로를 시딩한다", async () => { + it("응답이 알려 준 출발·도착 항목 구간으로 경로를 시딩한다", async () => { const queryClient = new QueryClient(); queryClient.setQueryData( scheduleItemsQueryKey(ROOM_ID, SCHEDULE_ID), @@ -156,6 +157,7 @@ describe("hydrateScheduleRoutesBatch", () => { { status: "ERROR", itemId: 2, + toItemId: 3, travelMode: "WALKING", errorCode: "UPSTREAM_TIMEOUT", }, @@ -206,8 +208,35 @@ describe("hydrateScheduleRoutesBatch", () => { expect(getScheduleItemRouteMock).toHaveBeenCalledTimes(2); }); - it.each(["NO_ROUTE", "LAST_ITEM", "SCHEDULE_ITEM_NOT_FOUND"])( - "경로 없음 답(%s)은 null로 시딩해 단건으로 다시 묻지 않는다", + it("경로 없음 답(NO_ROUTE)은 그 구간을 null로 시딩해 단건으로 다시 묻지 않는다", async () => { + const queryClient = new QueryClient(); + registerQueryClient(queryClient); + const places = [place(1, "places/a"), place(2, "places/b"), place(3, "places/c")]; + queryClient.setQueryData(scheduleItemsQueryKey(ROOM_ID, SCHEDULE_ID), places); + getScheduleItemRoutesBatchMock.mockResolvedValue([ + routeResult, + { status: "FAILED", itemId: 2, toItemId: 3, travelMode: "WALKING", errorCode: "NO_ROUTE" }, + ]); + + await hydrateScheduleRoutesBatch(queryClient, ROOM_ID, SCHEDULE_ID, places); + + expect( + queryClient.getQueryData(scheduleItemRouteQueryKey(ROOM_ID, SCHEDULE_ID, 2, 3, "WALKING")), + ).toBeNull(); + await expect( + resolveScheduleSegmentRoute({ + roomId: ROOM_ID, + scheduleId: SCHEDULE_ID, + segmentSourceItemId: 2, + destinationItemId: 3, + travelMode: "WALKING", + }), + ).resolves.toBeNull(); + expect(getScheduleItemRouteMock).not.toHaveBeenCalled(); + }); + + it.each(["LAST_ITEM", "SCHEDULE_ITEM_NOT_FOUND"])( + "도착 항목이 없는 답(%s)은 시딩하지 않고 그 칸이 도착 항목을 지정한 단건으로 받는다", async (errorCode) => { const queryClient = new QueryClient(); registerQueryClient(queryClient); @@ -217,12 +246,14 @@ describe("hydrateScheduleRoutesBatch", () => { routeResult, { status: "FAILED", itemId: 2, travelMode: "WALKING", errorCode }, ]); + const toThree = { travelMode: "WALKING", distanceMeters: 800, durationSeconds: 600 }; + getScheduleItemRouteMock.mockResolvedValue(toThree); await hydrateScheduleRoutesBatch(queryClient, ROOM_ID, SCHEDULE_ID, places); expect( - queryClient.getQueryData(scheduleItemRouteQueryKey(ROOM_ID, SCHEDULE_ID, 2, 3, "WALKING")), - ).toBeNull(); + queryClient.getQueryState(scheduleItemRouteQueryKey(ROOM_ID, SCHEDULE_ID, 2, 3, "WALKING")), + ).toBeUndefined(); await expect( resolveScheduleSegmentRoute({ roomId: ROOM_ID, @@ -231,19 +262,19 @@ describe("hydrateScheduleRoutesBatch", () => { destinationItemId: 3, travelMode: "WALKING", }), - ).resolves.toBeNull(); - expect(getScheduleItemRouteMock).not.toHaveBeenCalled(); + ).resolves.toEqual(toThree); + expect(getScheduleItemRouteMock).toHaveBeenCalledExactlyOnceWith(ROOM_ID, SCHEDULE_ID, 2, 3, "WALKING"); }, ); - it("응답 대기 중 일정이 바뀌면(지문 불일치) 늦게 도착한 결과를 버린다", async () => { + it("응답 대기 중 순서가 바뀌어도 응답이 알려 준 구간에 저장하고 새 순서의 구간에는 넣지 않는다", async () => { const queryClient = new QueryClient(); queryClient.setQueryData( scheduleItemsQueryKey(ROOM_ID, SCHEDULE_ID), snapshot, ); getScheduleItemRoutesBatchMock.mockImplementation(async () => { - // batch 응답이 도착하기 전에 목적지가 B → C 로 교체된 상황 + // batch 응답이 도착하기 전에 1번 다음 장소가 2 → 3으로 바뀐 상황 — 서버는 요청을 받은 순간의 1→2를 계산했다 queryClient.setQueryData(scheduleItemsQueryKey(ROOM_ID, SCHEDULE_ID), [ place(1, "places/a"), place(3, "places/c"), @@ -257,6 +288,11 @@ describe("hydrateScheduleRoutesBatch", () => { queryClient.getQueryData( scheduleItemRouteQueryKey(ROOM_ID, SCHEDULE_ID, 1, 2, "DRIVING"), ), + ).toMatchObject({ distanceMeters: 1200 }); + expect( + queryClient.getQueryState( + scheduleItemRouteQueryKey(ROOM_ID, SCHEDULE_ID, 1, 3, "DRIVING"), + ), ).toBeUndefined(); }); }); @@ -321,6 +357,7 @@ describe("진입 batch·일정 목록·지도가 같은 구간 경로 캐시를 const batchRoute = (itemId: number, distanceMeters: number) => ({ status: "OK" as const, itemId, + toItemId: itemId + 1, travelMode: "DRIVING", distanceMeters, durationSeconds: 60, diff --git a/src/lib/plan/schedule-bulk-hydration.ts b/src/lib/plan/schedule-bulk-hydration.ts index f9b5e9a6..cca4a1c9 100644 --- a/src/lib/plan/schedule-bulk-hydration.ts +++ b/src/lib/plan/schedule-bulk-hydration.ts @@ -261,8 +261,6 @@ const inFlightScheduleRoutesBatch = new Map>(); * 실패가 아니라 "경로 없음"이라는 답인 batch 항목 — 단건으로 다시 물어도 같은 답(204·404)이 오므로 * 경로 없음(`null`)으로 시딩하고 다시 묻지 않는다. */ -const ROUTE_ANSWER_ERROR_CODES = new Set(["NO_ROUTE", "LAST_ITEM", "SCHEDULE_ITEM_NOT_FOUND"]); - /** * in-flight dedup 키 — 일정 지문(`itemId` 순서·장소 정체성)을 포함해 * 목적지만 바뀐 일정(`[A,B]`→`[A,C]`)이 이전 batch Promise를 공유하지 않게 합니다. @@ -394,30 +392,18 @@ export async function hydrateScheduleRoutesBatch( const results = await getScheduleItemRoutesBatch(rid, scheduleId, requestItems); - /** - * 응답 대기 중 일정이 바뀌었으면(지문 불일치) 늦게 도착한 결과를 버립니다 — - * 서버는 요청을 받은 순간의 순서로 다음 장소를 정하므로, 그사이 순서가 바뀌었으면 어느 도착 장소의 경로인지 알 수 없다. - */ - const snapshotFp = schedulePlacesFingerprint([...places]); - const currentPlaces = queryClient.getQueryData( - scheduleItemsQueryKey(rid, scheduleId), - ); - const currentFp = schedulePlacesFingerprint([...(currentPlaces ?? [])]); - if (!snapshotFp.length || snapshotFp !== currentFp) return; - - const destinationByItemId = segmentDestinationsByItemId(places); + // 서버는 요청을 받은 순간의 순서로 다음 항목을 정하므로, 응답이 알려 준 도착 항목(toItemId)의 구간으로 저장한다 — + // 기다리는 사이 순서가 바뀌었어도 그 구간의 값이라 버리지 않는다. 마지막 항목·항목 없음은 도착 항목이 없어 + // 저장하지 않고, 그 칸이 도착 항목을 지정한 단건 조회로 받는다 for (const result of results) { - const itemId = result.itemId; + const { itemId, toItemId } = result; const mode = canonicalScheduleTravelMode(result.travelMode); - if (!mode) continue; - if (typeof itemId !== "number" || !Number.isFinite(itemId)) continue; - const destination = destinationByItemId.get(itemId); - if (destination === undefined) continue; + if (!mode || !isFiniteItemId(itemId) || !isFiniteItemId(toItemId)) continue; - const routeKey = scheduleItemRouteQueryKey(rid, scheduleId, itemId, destination, mode); + const routeKey = scheduleItemRouteQueryKey(rid, scheduleId, itemId, toItemId, mode); if (isBatchItemOk(result)) { queryClient.setQueryData(routeKey, batchRouteItemToResponse(result)); - } else if (result.errorCode && ROUTE_ANSWER_ERROR_CODES.has(result.errorCode)) { + } else if (result.errorCode === "NO_ROUTE") { queryClient.setQueryData(routeKey, null); } } From d92cea3f8947c56392cc77f0aca3ca005e523b4a Mon Sep 17 00:00:00 2001 From: KangShinGyu Date: Wed, 4 Nov 2026 16:56:51 +0900 Subject: [PATCH 07/14] =?UTF-8?q?docs(route):=20=EA=B2=BD=EB=A1=9C=20key?= =?UTF-8?q?=20=EC=84=A4=EB=AA=85=20=EC=A3=BC=EC=84=9D=EC=9D=98=20=EB=8F=84?= =?UTF-8?q?=EC=B0=A9=20=EC=9E=A5=EC=86=8C=EB=A5=BC=20=EB=8F=84=EC=B0=A9=20?= =?UTF-8?q?=ED=95=AD=EB=AA=A9=EC=9C=BC=EB=A1=9C=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- src/hooks/usePrefetchScheduleRoutes.ts | 2 +- src/lib/plan/schedule-bulk-hydration.ts | 2 +- src/lib/plan/scheduleItemPlaces.ts | 2 +- src/lib/stomp/schedules-dispatch.ts | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/hooks/usePrefetchScheduleRoutes.ts b/src/hooks/usePrefetchScheduleRoutes.ts index 68373968..93c6bd48 100644 --- a/src/hooks/usePrefetchScheduleRoutes.ts +++ b/src/hooks/usePrefetchScheduleRoutes.ts @@ -7,7 +7,7 @@ import type { PlanPlace } from "@/lib/plan/types"; /** * 페이지 진입·새로고침 시 일차별 구간 경로를 저장된 공유 이동수단 기준 `routes/batch`로 1회 시딩합니다. - * 리오더·추가·삭제 등으로 장소 목록이 바뀌면 batch는 재호출하지 않고, 새로 생긴 구간(받은 적 없는 출발·도착 장소)만 개별 GET합니다. + * 리오더·추가·삭제 등으로 장소 목록이 바뀌면 batch는 재호출하지 않고, 새로 생긴 구간(받은 적 없는 출발·도착 항목)만 개별 GET합니다. */ export function usePrefetchScheduleRoutes( roomId: string, diff --git a/src/lib/plan/schedule-bulk-hydration.ts b/src/lib/plan/schedule-bulk-hydration.ts index cca4a1c9..fb0c8e60 100644 --- a/src/lib/plan/schedule-bulk-hydration.ts +++ b/src/lib/plan/schedule-bulk-hydration.ts @@ -202,7 +202,7 @@ export async function buildSchedulePlacesFromSchedulesWithItems( } /** - * 만든 일차 목록을 한 번에 씁니다. 경로는 도착 장소까지 key에 있어 순서가 바뀌어도 무효화하지 않습니다. + * 만든 일차 목록을 한 번에 씁니다. 경로는 도착 항목까지 key에 있어 순서가 바뀌어도 무효화하지 않습니다. * `startedAt`(일정 전체 조회를 보낸 시각) 뒤에 바뀐 일차는 그쪽이 더 새 상태라 덮지 않고, * 그 일차를 받는 중이면 이 조회보다 먼저 보낸 요청일 수 있어 다시 받게 합니다. */ diff --git a/src/lib/plan/scheduleItemPlaces.ts b/src/lib/plan/scheduleItemPlaces.ts index 1443483f..b180da11 100644 --- a/src/lib/plan/scheduleItemPlaces.ts +++ b/src/lib/plan/scheduleItemPlaces.ts @@ -229,7 +229,7 @@ export async function mergeOrRefetchSchedulePlanPlacesFromItems( } /** - * 다른 일차로 옮긴 뒤 두 일차 장소 목록을 다시 받습니다. 경로는 도착 장소까지 key에 있어 + * 다른 일차로 옮긴 뒤 두 일차 장소 목록을 다시 받습니다. 경로는 도착 항목까지 key에 있어 * 순서가 바뀐 구간은 새 key로 받으므로 따로 무효화하지 않습니다. */ export async function syncAfterCrossScheduleItemMove( diff --git a/src/lib/stomp/schedules-dispatch.ts b/src/lib/stomp/schedules-dispatch.ts index 89e87c48..1e218a4d 100644 --- a/src/lib/stomp/schedules-dispatch.ts +++ b/src/lib/stomp/schedules-dispatch.ts @@ -53,7 +53,7 @@ function enqueueDebouncedHydratePlacesFromScheduleItemsApi( * Plan 화면 등이 쓰는 캐시만, 스키마의 `type`별로 갱신합니다. * — `ROOM_SCHEDULES_RESYNCED`: `room-schedules` 전체 재조회 + 방 상세 동기화 (원격 탭; 액터는 mutation `onSuccess`) * — `schedule-items`: 그 일차 장소 목록을 무효화해 다시 받는다(보는 화면이 없으면 그 화면에 들어갈 때) - * — `schedule-item-route`: 무효화하지 않는다 — key에 도착 장소·이동수단이 있어, 순서·수단이 바뀐 구간은 + * — `schedule-item-route`: 무효화하지 않는다 — key에 도착 항목·이동수단이 있어, 순서·수단이 바뀐 구간은 * 새 목록을 그릴 때 새 key로 받고 되돌리면 받아 둔 값을 쓴다 * — `SCHEDULE_ITEM_UPDATED`: 일정 목록만 동기화할 때 사용(본인 이벤트 포함) */ From c4b4a7b5f164d41dd8c2025d09e9869eaeeb4e68 Mon Sep 17 00:00:00 2001 From: KangShinGyu Date: Fri, 6 Nov 2026 17:02:31 +0900 Subject: [PATCH 08/14] =?UTF-8?q?feat(api):=20=EB=B3=80=EA=B2=BD=20?= =?UTF-8?q?=EC=9A=94=EC=B2=AD=EC=97=90=20=ED=83=AD=20ID=20=ED=97=A4?= =?UTF-8?q?=EB=8D=94(X-Client-Tab-Id)=20=EC=8B=A3=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 탭마다 한 번 만드는 getTabId 추가(모듈 변수, randomUUID 없으면 getRandomValues로 v4) - 이미 preflight를 하는 PATCH·PUT·DELETE·JSON POST에만 헤더를 붙임 - 백엔드가 이 값을 STOMP payload originTabId로 돌려줘 이 탭의 반향을 가려낼 수 있게 함 Co-Authored-By: Claude Opus 5.5 --- src/lib/api/client.test.ts | 82 ++++++++++++++++++++++++++++++++++++++ src/lib/api/client.ts | 31 +++++++++++++- src/lib/tab-id.test.ts | 54 +++++++++++++++++++++++++ src/lib/tab-id.ts | 29 ++++++++++++++ 4 files changed, 195 insertions(+), 1 deletion(-) create mode 100644 src/lib/api/client.test.ts create mode 100644 src/lib/tab-id.test.ts create mode 100644 src/lib/tab-id.ts diff --git a/src/lib/api/client.test.ts b/src/lib/api/client.test.ts new file mode 100644 index 00000000..52f2f852 --- /dev/null +++ b/src/lib/api/client.test.ts @@ -0,0 +1,82 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + authenticatedFetch: vi.fn(), + getTabId: vi.fn<() => string | null>(), +})); + +vi.mock("@/lib/api/authenticated-fetch", () => ({ + authenticatedFetch: mocks.authenticatedFetch, +})); +vi.mock("@/lib/auth-session-client", () => ({ + expireClientSessionAndRedirect: vi.fn(), +})); +vi.mock("@/lib/auth", () => ({ fetchSessionUserRaw: vi.fn() })); +vi.mock("@/lib/query-client", () => ({ getQueryClient: () => null })); +vi.mock("@/lib/tab-id", () => ({ getTabId: mocks.getTabId })); + +import { apiFetch } from "./client"; +import { jsonBody } from "./http"; + +const TAB_ID = "3f2b8c1e-7d4a-4e9b-9c2f-1a2b3c4d5e6f"; +const REQUEST_URL = "http://localhost:8080/rooms/room-1/schedules/10/items"; + +/** apiFetch가 실제로 보낸 요청의 탭 ID 헤더 */ +function sentTabId(): string | null { + const init: RequestInit | undefined = mocks.authenticatedFetch.mock.calls[0]?.[1]; + return new Headers(init?.headers).get("X-Client-Tab-Id"); +} + +describe("apiFetch tab ID header", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.authenticatedFetch.mockResolvedValue({ + response: new Response(null, { status: 204 }), + didRefresh: false, + }); + mocks.getTabId.mockReturnValue(TAB_ID); + }); + + it.each([ + ["JSON POST", { method: "POST", ...jsonBody({ placeId: "p" }) }], + ["PATCH", { method: "PATCH", ...jsonBody({ memo: "m" }) }], + ["PUT", { method: "PUT", ...jsonBody({ content: "c" }) }], + ["DELETE", { method: "DELETE" }], + ] satisfies [string, RequestInit][])("adds the header to a %s that is already preflighted", async (_, init) => { + await apiFetch(REQUEST_URL, init); + + expect(sentTabId()).toBe(TAB_ID); + }); + + it.each([ + ["GET without init", undefined], + ["GET", { method: "GET" }], + ["POST without body", { method: "POST" }], + ] satisfies [string, RequestInit | undefined][])("leaves a %s without the header", async (_, init) => { + await apiFetch(REQUEST_URL, init); + + expect(sentTabId()).toBeNull(); + }); + + it("does not add the header when the tab has no ID", async () => { + mocks.getTabId.mockReturnValue(null); + + await apiFetch(REQUEST_URL, { method: "DELETE" }); + + expect(sentTabId()).toBeNull(); + }); + + it.each([ + ["object", { "Content-Type": "application/json", "X-Extra": "1" }], + ["Headers", new Headers({ "Content-Type": "application/json", "X-Extra": "1" })], + ["array", [["Content-Type", "application/json"], ["X-Extra", "1"]]], + ] satisfies [string, HeadersInit][])("keeps existing %s headers", async (_, headers) => { + await apiFetch(REQUEST_URL, { method: "POST", headers, body: "{}" }); + + const init: RequestInit | undefined = mocks.authenticatedFetch.mock.calls[0]?.[1]; + const sent = new Headers(init?.headers); + expect(sent.get("Content-Type")).toBe("application/json"); + expect(sent.get("X-Extra")).toBe("1"); + expect(sent.get("X-Client-Tab-Id")).toBe(TAB_ID); + }); +}); diff --git a/src/lib/api/client.ts b/src/lib/api/client.ts index a71ca10c..1578a9af 100644 --- a/src/lib/api/client.ts +++ b/src/lib/api/client.ts @@ -6,6 +6,32 @@ import { readSessionUserCache, setSessionUserCache, } from "@/lib/session-user-cache"; +import { getTabId } from "@/lib/tab-id"; + +const CLIENT_TAB_ID_HEADER = "X-Client-Tab-Id"; + +/** + * 일정·북마크 STOMP 이벤트의 `originTabId`로 돌아와, 이 탭이 보낸 변경의 반향을 가려낸다. + * 이미 CORS preflight를 하는 요청(PATCH·PUT·DELETE, JSON POST)에만 싣는다 — GET·body 없는 POST에 + * 커스텀 헤더를 붙이면 preflight가 새로 생기고, 백엔드 CORS에 maxAge가 없어 Chrome은 그 결과를 약 5초만 기억한다. + */ +function withClientTabIdHeader(init: RequestInit | undefined): RequestInit | undefined { + const tabId = getTabId(); + if (!tabId || !init) return init; + + const method = (init.method ?? "GET").toUpperCase(); + const headers = new Headers(init.headers); + const isPreflighted = + method === "PATCH" || + method === "PUT" || + method === "DELETE" || + (method === "POST" && + (headers.get("Content-Type") ?? "").toLowerCase().startsWith("application/json")); + if (!isPreflighted) return init; + + headers.set(CLIENT_TAB_ID_HEADER, tabId); + return { ...init, headers }; +} /** * Drop-in replacement for fetch that automatically retries once after @@ -15,7 +41,10 @@ export async function apiFetch( input: RequestInfo | URL, init?: RequestInit, ): Promise { - const { response: res, didRefresh } = await authenticatedFetch(input, init); + const { response: res, didRefresh } = await authenticatedFetch( + input, + withClientTabIdHeader(init), + ); if (res.status === 401) { expireClientSessionAndRedirect({ diff --git a/src/lib/tab-id.test.ts b/src/lib/tab-id.test.ts new file mode 100644 index 00000000..3724141c --- /dev/null +++ b/src/lib/tab-id.test.ts @@ -0,0 +1,54 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; + +/** 탭 ID는 모듈 변수에 한 번 만들어 두므로 경우마다 모듈을 새로 읽는다 */ +async function loadGetTabId() { + vi.resetModules(); + return (await import("@/lib/tab-id")).getTabId; +} + +describe("getTabId", () => { + beforeEach(() => { + vi.stubGlobal("window", {}); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("returns null during server render", async () => { + vi.unstubAllGlobals(); + const getTabId = await loadGetTabId(); + + expect(getTabId()).toBeNull(); + }); + + it("creates the ID once with randomUUID and keeps it for the tab", async () => { + const randomUUID = vi.fn(() => "3f2b8c1e-7d4a-4e9b-9c2f-1a2b3c4d5e6f"); + vi.stubGlobal("crypto", { randomUUID, getRandomValues: vi.fn() }); + const getTabId = await loadGetTabId(); + + expect(getTabId()).toBe("3f2b8c1e-7d4a-4e9b-9c2f-1a2b3c4d5e6f"); + expect(getTabId()).toBe("3f2b8c1e-7d4a-4e9b-9c2f-1a2b3c4d5e6f"); + expect(randomUUID).toHaveBeenCalledOnce(); + }); + + it("builds a v4 UUID from getRandomValues when randomUUID is missing (non-secure context)", async () => { + vi.stubGlobal("crypto", { + getRandomValues: (bytes: Uint8Array) => bytes.fill(0xff), + }); + const getTabId = await loadGetTabId(); + + const id = getTabId(); + expect(id).toMatch(UUID_V4); + expect(id).toBe("ffffffff-ffff-4fff-bfff-ffffffffffff"); + }); + + it("returns null when the browser cannot make random values", async () => { + vi.stubGlobal("crypto", {}); + const getTabId = await loadGetTabId(); + + expect(getTabId()).toBeNull(); + }); +}); diff --git a/src/lib/tab-id.ts b/src/lib/tab-id.ts new file mode 100644 index 00000000..95050886 --- /dev/null +++ b/src/lib/tab-id.ts @@ -0,0 +1,29 @@ +/** + * 이 탭이 보낸 변경 요청과 그 STOMP 반향(`originTabId`)을 잇는 ID. + * + * 모듈 변수에만 둔다. sessionStorage는 탭을 복제하면 값이 복사돼 두 탭이 같은 ID가 되고, + * localStorage는 모든 탭이 같은 값을 쓴다. 새로고침하면 새 ID가 된다. + */ +let tabId: string | null | undefined; + +/** 서버 렌더에서는 null. UUID를 만들 수 없는 브라우저도 null이고, 그때는 헤더를 싣지 않는다 */ +export function getTabId(): string | null { + if (tabId !== undefined) return tabId; + if (typeof window === "undefined") return null; + tabId = createUuidV4(); + return tabId; +} + +function createUuidV4(): string | null { + if (typeof crypto === "undefined") return null; + // randomUUID는 보안 컨텍스트(https·localhost)에서만 있다 — `http://`로 연 화면은 getRandomValues로 만든다 + if (typeof crypto.randomUUID === "function") return crypto.randomUUID(); + if (typeof crypto.getRandomValues !== "function") return null; + + const hex = Array.from(crypto.getRandomValues(new Uint8Array(16)), (byte) => + byte.toString(16).padStart(2, "0"), + ).join(""); + // RFC 9562 v4: 13번째 자리는 버전 4, 17번째 자리는 변형 8~b + const variant = ((Number.parseInt(hex.charAt(16), 16) & 0x3) | 0x8).toString(16); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-4${hex.slice(13, 16)}-${variant}${hex.slice(17, 20)}-${hex.slice(20, 32)}`; +} From b0de68a95e26d05787a77ac3edafce438ac37d43 Mon Sep 17 00:00:00 2001 From: KangShinGyu Date: Fri, 6 Nov 2026 17:04:18 +0900 Subject: [PATCH 09/14] =?UTF-8?q?fix(stomp):=20=EA=B0=99=EC=9D=80=20?= =?UTF-8?q?=EA=B3=84=EC=A0=95=20=EB=8B=A4=EB=A5=B8=20=ED=83=AD=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EB=B0=94=EA=BE=BC=20=EC=9D=BC=EC=A0=95=EC=9D=84=20?= =?UTF-8?q?=ED=83=AD=20ID=EB=A1=9C=20=EA=B5=AC=EB=B6=84=ED=95=B4=20?= =?UTF-8?q?=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 이벤트 originTabId가 이 탭 ID와 같을 때만 반향으로 보고 건너뜀(isOwnEcho) - originTabId가 없는 옛 이벤트는 지금처럼 actorUserId로 판정해 배포 순서와 무관 - SCHEDULE_ITEM_UPDATED: 이 탭 반향은 건너뛰고, 다른 탭 변경은 진행 중인 조회에 합류하지 않고 다시 받음 Co-Authored-By: Claude Opus 5.5 --- src/lib/stomp/own-echo.test.ts | 38 +++++++++ src/lib/stomp/own-echo.ts | 20 +++++ src/lib/stomp/schedule-events.ts | 2 + .../stomp/schedules-dispatch.order.test.ts | 24 +++++- src/lib/stomp/schedules-dispatch.test.ts | 83 +++++++++++++++++- src/lib/stomp/schedules-dispatch.ts | 84 +++++-------------- 6 files changed, 184 insertions(+), 67 deletions(-) create mode 100644 src/lib/stomp/own-echo.test.ts create mode 100644 src/lib/stomp/own-echo.ts diff --git a/src/lib/stomp/own-echo.test.ts b/src/lib/stomp/own-echo.test.ts new file mode 100644 index 00000000..ce2eee16 --- /dev/null +++ b/src/lib/stomp/own-echo.test.ts @@ -0,0 +1,38 @@ +import { QueryClient } from "@tanstack/react-query"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { sessionUserQueryKey } from "@/lib/query-keys"; + +vi.mock("@/lib/tab-id", () => ({ getTabId: () => "tab-a" })); + +import { isOwnEcho } from "@/lib/stomp/own-echo"; + +const ME = 7; +const OTHER = 8; + +describe("isOwnEcho", () => { + const queryClient = new QueryClient(); + + afterEach(() => { + queryClient.clear(); + }); + + it.each([ + // 탭 ID가 있으면 계정과 상관없이 탭으로만 판정한다 + { name: "this tab", originTabId: "tab-a", actorUserId: ME, expected: true }, + { name: "same account, other tab", originTabId: "tab-b", actorUserId: ME, expected: false }, + { name: "other account, other tab", originTabId: "tab-b", actorUserId: OTHER, expected: false }, + // 탭 ID가 없으면(옛 백엔드·HTTP 밖 변경) 계정으로 판정한다 + { name: "no tab ID, same account", originTabId: null, actorUserId: ME, expected: true }, + { name: "tab ID field missing, same account", originTabId: undefined, actorUserId: ME, expected: true }, + { name: "no tab ID, other account", originTabId: null, actorUserId: OTHER, expected: false }, + ])("$name → $expected", ({ originTabId, actorUserId, expected }) => { + queryClient.setQueryData(sessionUserQueryKey, { id: ME }); + + expect(isOwnEcho(queryClient, { actorUserId, originTabId })).toBe(expected); + }); + + it("treats an event without a tab ID as remote while the session user is unknown", () => { + expect(isOwnEcho(queryClient, { actorUserId: ME, originTabId: null })).toBe(false); + }); +}); diff --git a/src/lib/stomp/own-echo.ts b/src/lib/stomp/own-echo.ts new file mode 100644 index 00000000..acd9ed2e --- /dev/null +++ b/src/lib/stomp/own-echo.ts @@ -0,0 +1,20 @@ +import type { QueryClient } from "@tanstack/react-query"; + +import { readSessionUserId } from "@/lib/session-user-cache"; +import { getTabId } from "@/lib/tab-id"; + +type ChangeOrigin = { + actorUserId: number; + /** 변경 요청의 `X-Client-Tab-Id`. 헤더를 싣기 전 백엔드나 HTTP 밖의 변경이면 없다 */ + originTabId?: string | null; +}; + +/** + * 이 탭이 보낸 변경의 반향인지 판정한다 — 반향이면 그 탭의 mutation이 이미 캐시를 맞춘다. + * `originTabId`가 없으면 예전처럼 계정으로 판정해, 같은 계정의 다른 탭도 반향으로 본다. + */ +export function isOwnEcho(queryClient: QueryClient, event: ChangeOrigin): boolean { + if (event.originTabId) return event.originTabId === getTabId(); + const me = readSessionUserId(queryClient); + return typeof me === "number" && Number.isFinite(me) && me === event.actorUserId; +} diff --git a/src/lib/stomp/schedule-events.ts b/src/lib/stomp/schedule-events.ts index b4cf8bbb..7b7e0b84 100644 --- a/src/lib/stomp/schedule-events.ts +++ b/src/lib/stomp/schedule-events.ts @@ -16,6 +16,8 @@ export type RoomScheduleChangedEvent = { itemId: number | null; affectedRouteItemIds: number[] | null; scheduleIds: number[] | null; + /** 변경 요청을 보낸 탭 ID — 같으면 이 탭의 반향(`isOwnEcho`) */ + originTabId?: string | null; }; const KNOWN_TYPES = new Set([ diff --git a/src/lib/stomp/schedules-dispatch.order.test.ts b/src/lib/stomp/schedules-dispatch.order.test.ts index 91f15bfe..9b7df890 100644 --- a/src/lib/stomp/schedules-dispatch.order.test.ts +++ b/src/lib/stomp/schedules-dispatch.order.test.ts @@ -3,7 +3,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { RoomScheduleItem } from "@/lib/api/rooms/schedule-items"; import type { PlanPlace } from "@/lib/plan/types"; -import { roomSchedulesQueryKey, scheduleItemsQueryKey } from "@/lib/query-keys"; +import { + roomSchedulesQueryKey, + scheduleItemsQueryKey, + sessionUserQueryKey, +} from "@/lib/query-keys"; import type { RoomScheduleChangedEvent } from "@/lib/stomp/schedule-events"; const fixtures = vi.hoisted(() => ({ @@ -42,6 +46,7 @@ vi.mock("@/lib/rooms", () => ({ vi.mock("@/lib/query-client", () => ({ getQueryClient: () => null, })); +vi.mock("@/lib/tab-id", () => ({ getTabId: () => "tab-a" })); import { removeScheduleItemFromPlanPlacesCache, @@ -236,6 +241,23 @@ describe("schedule day list refetch order", () => { expect(fixtures.getScheduleItems).toHaveBeenCalledOnce(); }); + it("shows a reorder made in another tab of the same account", async () => { + // remoteEvent의 actorUserId(2)가 이 계정 — 예전에는 계정이 같아 버렸다 + client.setQueryData(sessionUserQueryKey, { id: 2 }); + client.setQueryData(DAY_KEY, [100, 101, 102].map(fixtures.place)); + watchDayList(); + fixtures.getScheduleItems.mockResolvedValue(items(102, 100, 101)); + + await dispatchRoomScheduleEvent(client, { + ...remoteEvent("SCHEDULE_ITEMS_REORDERED", 102), + originTabId: "tab-b", + }); + await settle(); + + expect(fixtures.getScheduleItems).toHaveBeenCalledOnce(); + expect(dayItemIds()).toEqual([102, 100, 101]); + }); + it("fetches from the server when the day list is invalidated", async () => { client.setQueryData(DAY_KEY, [100, 102].map(fixtures.place)); watchDayList(); diff --git a/src/lib/stomp/schedules-dispatch.test.ts b/src/lib/stomp/schedules-dispatch.test.ts index 4ca587d9..fa8076dd 100644 --- a/src/lib/stomp/schedules-dispatch.test.ts +++ b/src/lib/stomp/schedules-dispatch.test.ts @@ -8,6 +8,7 @@ import type { RoomScheduleChangedEvent } from "@/lib/stomp/schedule-events"; const mocks = vi.hoisted(() => ({ invalidateSchedulePlanPlaces: vi.fn(), syncAfterCrossScheduleItemMove: vi.fn(), + syncRoomDetailFromServer: vi.fn(), })); vi.mock("@/lib/plan/scheduleItemPlaces", () => ({ @@ -16,9 +17,12 @@ vi.mock("@/lib/plan/scheduleItemPlaces", () => ({ })); vi.mock("@/lib/rooms", () => ({ - syncRoomDetailFromServer: vi.fn(), + syncRoomDetailFromServer: mocks.syncRoomDetailFromServer, })); +vi.mock("@/lib/tab-id", () => ({ getTabId: () => "tab-a" })); +const MY_TAB_ID = "tab-a"; + import { dispatchRoomScheduleEvent } from "@/lib/stomp/schedules-dispatch"; function itemEvent( @@ -120,3 +124,80 @@ describe("expense synchronization on existing schedule events",()=>{ client.clear(); }); }); + +describe("echo of this tab vs. other tabs of the same account", () => { + const SKIPPABLE_TYPES = [ + "ROOM_SCHEDULES_RESYNCED", + "SCHEDULE_ITEM_MOVED", + "SCHEDULE_ITEM_DELETED", + "SCHEDULE_ITEM_CREATED", + "SCHEDULE_ITEMS_REORDERED", + "SCHEDULE_ITEM_TRAVEL_MODE_UPDATED", + ] as const; + let queryClient: QueryClient; + + /** itemEvent의 actorUserId(99)가 이 계정이다 */ + function ownAccountEvent( + type: RoomScheduleChangedEvent["type"], + originTabId: string | null, + ): RoomScheduleChangedEvent { + return { ...itemEvent(type, [20]), scheduleIds: [10, 11], originTabId }; + } + + /** 일차 목록·일정 전체·방 상세 중 어느 것이든 다시 받은 횟수 */ + const refetchCount = () => + mocks.invalidateSchedulePlanPlaces.mock.calls.length + + mocks.syncAfterCrossScheduleItemMove.mock.calls.length + + mocks.syncRoomDetailFromServer.mock.calls.length; + + beforeEach(() => { + vi.useFakeTimers(); + vi.clearAllMocks(); + mocks.invalidateSchedulePlanPlaces.mockResolvedValue(undefined); + mocks.syncAfterCrossScheduleItemMove.mockResolvedValue(undefined); + queryClient = new QueryClient(); + queryClient.setQueryData(sessionUserQueryKey, { id: 99 }); + }); + + afterEach(() => { + vi.useRealTimers(); + queryClient.clear(); + }); + + it.each(SKIPPABLE_TYPES)("skips %s sent from this tab", async (type) => { + await dispatchRoomScheduleEvent(queryClient, ownAccountEvent(type, MY_TAB_ID)); + await vi.runAllTimersAsync(); + + expect(refetchCount()).toBe(0); + }); + + it.each(SKIPPABLE_TYPES)("applies %s sent from another tab of the same account", async (type) => { + await dispatchRoomScheduleEvent(queryClient, ownAccountEvent(type, "tab-b")); + await vi.runAllTimersAsync(); + + expect(refetchCount()).toBe(1); + }); + + it.each(SKIPPABLE_TYPES)("still skips %s of the same account without a tab ID (backend before originTabId)", async (type) => { + await dispatchRoomScheduleEvent(queryClient, ownAccountEvent(type, null)); + await vi.runAllTimersAsync(); + + expect(refetchCount()).toBe(0); + }); + + it("skips a time update sent from this tab — its own settled refetch already has it", async () => { + await dispatchRoomScheduleEvent(queryClient, ownAccountEvent("SCHEDULE_ITEM_UPDATED", MY_TAB_ID)); + await vi.runAllTimersAsync(); + + expect(mocks.invalidateSchedulePlanPlaces).not.toHaveBeenCalled(); + }); + + it("refetches a time update from another tab of the same account without joining an in-flight load", async () => { + await dispatchRoomScheduleEvent(queryClient, ownAccountEvent("SCHEDULE_ITEM_UPDATED", "tab-b")); + await vi.runAllTimersAsync(); + + expect(mocks.invalidateSchedulePlanPlaces).toHaveBeenCalledExactlyOnceWith(queryClient, "room-1", 10, { + joinInFlight: false, + }); + }); +}); diff --git a/src/lib/stomp/schedules-dispatch.ts b/src/lib/stomp/schedules-dispatch.ts index 1e218a4d..977c3db6 100644 --- a/src/lib/stomp/schedules-dispatch.ts +++ b/src/lib/stomp/schedules-dispatch.ts @@ -7,15 +7,15 @@ import { } from "@/lib/plan/scheduleItemPlaces"; import { roomSchedulesQueryKey } from "@/lib/query-keys"; import { syncRoomDetailFromServer } from "@/lib/rooms"; +import { isOwnEcho } from "@/lib/stomp/own-echo"; import type { RoomScheduleChangedEvent } from "@/lib/stomp/schedule-events"; -import { readSessionUserId } from "@/lib/session-user-cache"; /** 연속 일정 항목 동기화(GET items)를 한 번으로 묶음 — `SCHEDULE_ITEM_UPDATED` 전용 */ const syncSchedulePlacesFromItemsDebouncers = new Map< string, ReturnType >(); -/** 모으는 동안 다른 사람의 변경이 하나라도 들어온 일차 */ +/** 모으는 동안 이 탭의 반향이 아닌 변경이 하나라도 들어온 일차 */ const syncSchedulePlacesRemoteChangePending = new Set(); const DEBOUNCE_ITEMS_GET_MS = 90; @@ -33,7 +33,7 @@ function enqueueDebouncedHydratePlacesFromScheduleItemsApi( mapKey, setTimeout(() => { syncSchedulePlacesFromItemsDebouncers.delete(mapKey); - // 모은 이벤트가 모두 본인 반향이면, 본인 저장이 끝나며 보낸 조회가 이미 그 변경을 담고 있어 합류한다 + // 모은 이벤트가 모두 반향이면(탭 ID 없는 옛 이벤트), 저장이 끝나며 보낸 조회가 이미 그 변경을 담고 있어 합류한다 const joinInFlight = !syncSchedulePlacesRemoteChangePending.has(mapKey); syncSchedulePlacesRemoteChangePending.delete(mapKey); void (async () => { @@ -51,11 +51,13 @@ function enqueueDebouncedHydratePlacesFromScheduleItemsApi( /** * Plan 화면 등이 쓰는 캐시만, 스키마의 `type`별로 갱신합니다. - * — `ROOM_SCHEDULES_RESYNCED`: `room-schedules` 전체 재조회 + 방 상세 동기화 (원격 탭; 액터는 mutation `onSuccess`) + * — 이 탭이 보낸 변경의 반향(`isOwnEcho`)은 건너뛴다. 그 탭의 mutation이 캐시를 맞춘다. + * 같은 계정의 다른 탭은 `originTabId`가 달라 다른 사람의 변경처럼 받는다 + * — `ROOM_SCHEDULES_RESYNCED`: `room-schedules` 전체 재조회 + 방 상세 동기화 * — `schedule-items`: 그 일차 장소 목록을 무효화해 다시 받는다(보는 화면이 없으면 그 화면에 들어갈 때) * — `schedule-item-route`: 무효화하지 않는다 — key에 도착 항목·이동수단이 있어, 순서·수단이 바뀐 구간은 * 새 목록을 그릴 때 새 key로 받고 되돌리면 받아 둔 값을 쓴다 - * — `SCHEDULE_ITEM_UPDATED`: 일정 목록만 동기화할 때 사용(본인 이벤트 포함) + * — `SCHEDULE_ITEM_UPDATED`: 탭 ID 없는 옛 이벤트는 계정 단위 반향도 진행 중인 조회에 합류해 받는다 */ export async function dispatchRoomScheduleEvent( queryClient: QueryClient, @@ -73,15 +75,7 @@ export async function dispatchRoomScheduleEvent( switch (event.type) { case "ROOM_SCHEDULES_RESYNCED": { - const me = readSessionUserId(queryClient); - const actorIsMe = - typeof me === "number" && - Number.isFinite(me) && - me === event.actorUserId; - /** 본인 mutation은 `onSuccess`에서 schedules·room-detail 캐시 갱신 */ - if (actorIsMe) { - return; - } + if (isOwnEcho(queryClient, event)) return; // 일정 전체 재조회는 room-schedules 조회 하나로 한다 — 진행 중인 옛 조회가 취소돼 새 일정을 덮지 않는다 await queryClient.invalidateQueries({ queryKey: roomSchedulesQueryKey(rid) }); @@ -96,14 +90,7 @@ export async function dispatchRoomScheduleEvent( const sourceScheduleId = scheduleIds[0]!; const targetScheduleId = scheduleIds[1]!; - const me = readSessionUserId(queryClient); - const actorIsMe = - typeof me === "number" && - Number.isFinite(me) && - me === event.actorUserId; - if (actorIsMe) { - return; - } + if (isOwnEcho(queryClient, event)) return; await syncAfterCrossScheduleItemMove( queryClient, @@ -119,15 +106,7 @@ export async function dispatchRoomScheduleEvent( const itemId = event.itemId; if (sid == null || itemId == null) return; - const me = readSessionUserId(queryClient); - const actorIsMe = - typeof me === "number" && - Number.isFinite(me) && - me === event.actorUserId; - /** 본인 삭제는 mutation이 응답으로 캐시를 고치고 그 일차를 다시 받는다 */ - if (actorIsMe) { - return; - } + if (isOwnEcho(queryClient, event)) return; await invalidateSchedulePlanPlaces(queryClient, rid, sid); return; @@ -138,15 +117,7 @@ export async function dispatchRoomScheduleEvent( const itemId = event.itemId; if (sid == null || itemId == null) return; - const me = readSessionUserId(queryClient); - const actorIsMe = - typeof me === "number" && - Number.isFinite(me) && - me === event.actorUserId; - /** 본인 추가는 mutation이 응답으로 캐시를 고치고 그 일차를 다시 받는다 */ - if (actorIsMe) { - return; - } + if (isOwnEcho(queryClient, event)) return; await invalidateSchedulePlanPlaces(queryClient, rid, sid); return; @@ -157,15 +128,7 @@ export async function dispatchRoomScheduleEvent( const itemId = event.itemId; if (sid == null || itemId == null) return; - const me = readSessionUserId(queryClient); - const actorIsMe = - typeof me === "number" && - Number.isFinite(me) && - me === event.actorUserId; - /** 본인 순서 변경은 mutation이 응답으로 캐시를 고치고 그 일차를 다시 받는다 */ - if (actorIsMe) { - return; - } + if (isOwnEcho(queryClient, event)) return; await invalidateSchedulePlanPlaces(queryClient, rid, sid); return; @@ -176,17 +139,16 @@ export async function dispatchRoomScheduleEvent( const itemId = event.itemId; if (sid == null || itemId == null) return; - const me = readSessionUserId(queryClient); - const actorIsMe = - typeof me === "number" && - Number.isFinite(me) && - event.actorUserId === me; - // 같은 사용자의 다른 탭도 이 이벤트를 받으므로 HTTP 응답과 무관하게 목록을 동기화합니다. + const ownEcho = isOwnEcho(queryClient, event); + // 탭 ID로 확인한 이 탭의 반향은 저장이 끝나며 보낸 조회(onSettled)가 이미 받는다. + // 탭 ID 없는 옛 이벤트는 같은 계정의 다른 탭일 수도 있어, 반향이어도 진행 중인 조회에 합류해 받는다 + if (ownEcho && event.originTabId) return; + enqueueDebouncedHydratePlacesFromScheduleItemsApi( queryClient, rid, sid, - actorIsMe, + ownEcho, ); return; } @@ -196,15 +158,7 @@ export async function dispatchRoomScheduleEvent( const itemId = event.itemId; if (sid == null || itemId == null) return; - const me = readSessionUserId(queryClient); - const actorIsMe = - typeof me === "number" && - Number.isFinite(me) && - event.actorUserId === me; - /** 본인 PATCH는 mutation `onSuccess`에서 `schedule-items` 캐시 갱신 */ - if (actorIsMe) { - return; - } + if (isOwnEcho(queryClient, event)) return; enqueueDebouncedHydratePlacesFromScheduleItemsApi( queryClient, From 45b025805ed77663e26f4c1d98d82cd61ea83916 Mon Sep 17 00:00:00 2001 From: KangShinGyu Date: Fri, 6 Nov 2026 17:04:43 +0900 Subject: [PATCH 10/14] =?UTF-8?q?fix(stomp):=20=EA=B0=99=EC=9D=80=20?= =?UTF-8?q?=EA=B3=84=EC=A0=95=20=EB=8B=A4=EB=A5=B8=20=ED=83=AD=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EB=B0=94=EA=BE=BC=20=EB=B6=81=EB=A7=88=ED=81=AC?= =?UTF-8?q?=EB=A5=BC=20=ED=83=AD=20ID=EB=A1=9C=20=EA=B5=AC=EB=B6=84?= =?UTF-8?q?=ED=95=B4=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - bookmarks 이벤트도 isOwnEcho로 판정(originTabId 같을 때만 건너뜀, 없으면 actorUserId) Co-Authored-By: Claude Opus 5.5 --- src/lib/stomp/bookmarks-dispatch.test.ts | 75 ++++++++++++++++++++++++ src/lib/stomp/bookmarks-dispatch.tsx | 11 +--- src/types/roomBookmarkStomp.ts | 2 + 3 files changed, 80 insertions(+), 8 deletions(-) create mode 100644 src/lib/stomp/bookmarks-dispatch.test.ts diff --git a/src/lib/stomp/bookmarks-dispatch.test.ts b/src/lib/stomp/bookmarks-dispatch.test.ts new file mode 100644 index 00000000..40be13f6 --- /dev/null +++ b/src/lib/stomp/bookmarks-dispatch.test.ts @@ -0,0 +1,75 @@ +import { QueryClient } from "@tanstack/react-query"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { sessionUserQueryKey } from "@/lib/query-keys"; +import type { RoomBookmarkChangedEvent } from "@/types/roomBookmarkStomp"; + +const mocks = vi.hoisted(() => ({ + invalidateRoomBookmarkQueries: vi.fn(), +})); + +vi.mock("@/lib/bookmarks-invalidation", () => ({ + invalidateRoomBookmarkQueries: mocks.invalidateRoomBookmarkQueries, +})); +vi.mock("@/lib/tab-id", () => ({ getTabId: () => "tab-a" })); + +import { applyRoomBookmarkStompMessage } from "@/lib/stomp/bookmarks-dispatch"; + +const ME = 7; + +/** 이 계정(ME)이 폴더를 추가한 이벤트 */ +function categoryCreated(originTabId: string | null | undefined): string { + const event: RoomBookmarkChangedEvent = { + roomId: "room-1", + actorUserId: ME, + type: "CATEGORY_CREATED", + bookmarkId: 0, + categoryId: 10, + originTabId, + }; + return JSON.stringify(event); +} + +describe("applyRoomBookmarkStompMessage — echo of this tab", () => { + let queryClient: QueryClient; + + beforeEach(() => { + vi.clearAllMocks(); + mocks.invalidateRoomBookmarkQueries.mockResolvedValue(undefined); + queryClient = new QueryClient(); + queryClient.setQueryData(sessionUserQueryKey, { id: ME }); + }); + + afterEach(() => { + queryClient.clear(); + }); + + it("skips a change sent from this tab", async () => { + await applyRoomBookmarkStompMessage(queryClient, categoryCreated("tab-a")); + + expect(mocks.invalidateRoomBookmarkQueries).not.toHaveBeenCalled(); + }); + + it("applies a change sent from another tab of the same account", async () => { + await applyRoomBookmarkStompMessage(queryClient, categoryCreated("tab-b")); + + expect(mocks.invalidateRoomBookmarkQueries).toHaveBeenCalledOnce(); + }); + + it.each([null, undefined])( + "skips a change of the same account without a tab ID (%s, backend before originTabId)", + async (originTabId) => { + await applyRoomBookmarkStompMessage(queryClient, categoryCreated(originTabId)); + + expect(mocks.invalidateRoomBookmarkQueries).not.toHaveBeenCalled(); + }, + ); + + it("applies a change of another account without a tab ID", async () => { + queryClient.setQueryData(sessionUserQueryKey, { id: ME + 1 }); + + await applyRoomBookmarkStompMessage(queryClient, categoryCreated(null)); + + expect(mocks.invalidateRoomBookmarkQueries).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/lib/stomp/bookmarks-dispatch.tsx b/src/lib/stomp/bookmarks-dispatch.tsx index 5fe9f2f9..d3510493 100644 --- a/src/lib/stomp/bookmarks-dispatch.tsx +++ b/src/lib/stomp/bookmarks-dispatch.tsx @@ -3,7 +3,7 @@ import type { QueryClient } from "@tanstack/react-query"; import { invalidateRoomBookmarkQueries } from "@/lib/bookmarks-invalidation"; -import { readSessionUserId } from "@/lib/session-user-cache"; +import { isOwnEcho } from "@/lib/stomp/own-echo"; import type { RoomBookmarkChangedEvent } from "@/types/roomBookmarkStomp"; /** bookmarks 토픽 STOMP — React Query 무효화만 (토스트·선 fetch 없음) */ @@ -15,13 +15,8 @@ export async function applyRoomBookmarkStompMessage( const rid = String(event.roomId ?? "").trim(); if (!rid) return; - const me = readSessionUserId(queryClient); - const actorIsMe = - typeof me === "number" && - Number.isFinite(me) && - me === event.actorUserId; - /** 본인 mutation은 `onSuccess`에서 캐시 패치 */ - if (actorIsMe) { + /** 본인 탭의 변경은 그 탭의 mutation이 캐시를 맞춘다 */ + if (isOwnEcho(queryClient, event)) { return; } diff --git a/src/types/roomBookmarkStomp.ts b/src/types/roomBookmarkStomp.ts index 721e17d3..25ec194b 100644 --- a/src/types/roomBookmarkStomp.ts +++ b/src/types/roomBookmarkStomp.ts @@ -12,4 +12,6 @@ export type RoomBookmarkChangedEvent = { categoryId: number; roomId: string; type: RoomBookmarkChangedType; + /** 변경 요청을 보낸 탭 ID — 같으면 이 탭의 반향(`isOwnEcho`) */ + originTabId?: string | null; }; From 3deed131e8a0bb3c084614ed90777d4577618449 Mon Sep 17 00:00:00 2001 From: KangShinGyu Date: Sat, 7 Nov 2026 14:06:27 +0900 Subject: [PATCH 11/14] =?UTF-8?q?fix(stomp):=20=ED=83=AD=20ID=EA=B0=80=20?= =?UTF-8?q?=EC=97=86=EB=8A=94=20=EC=9D=BC=EC=A0=95=C2=B7=EB=B6=81=EB=A7=88?= =?UTF-8?q?=ED=81=AC=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=EB=8F=84=20=EB=8B=A4?= =?UTF-8?q?=EB=A5=B8=20=ED=83=AD=EC=9D=98=20=EB=B3=80=EA=B2=BD=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EB=B0=9B=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - isOwnEcho는 originTabId가 이 탭 ID와 같을 때만 반향으로 판정(계정 판정 제거) - 탭 ID를 못 만든 탭의 변경도 같은 계정의 다른 탭이 놓치지 않게 함 - SCHEDULE_ITEM_UPDATED의 본인 반향 합류(joinInFlight) 분기 제거 Co-Authored-By: Claude Opus 5.5 --- src/lib/stomp/bookmarks-dispatch.test.ts | 12 ++----- src/lib/stomp/bookmarks-dispatch.tsx | 2 +- src/lib/stomp/own-echo.test.ts | 44 +++++++++-------------- src/lib/stomp/own-echo.ts | 14 +++----- src/lib/stomp/schedules-dispatch.test.ts | 25 +++++-------- src/lib/stomp/schedules-dispatch.ts | 46 +++++++----------------- 6 files changed, 45 insertions(+), 98 deletions(-) diff --git a/src/lib/stomp/bookmarks-dispatch.test.ts b/src/lib/stomp/bookmarks-dispatch.test.ts index 40be13f6..2831ab76 100644 --- a/src/lib/stomp/bookmarks-dispatch.test.ts +++ b/src/lib/stomp/bookmarks-dispatch.test.ts @@ -57,19 +57,11 @@ describe("applyRoomBookmarkStompMessage — echo of this tab", () => { }); it.each([null, undefined])( - "skips a change of the same account without a tab ID (%s, backend before originTabId)", + "applies a change of the same account without a tab ID (%s) — it may come from another tab", async (originTabId) => { await applyRoomBookmarkStompMessage(queryClient, categoryCreated(originTabId)); - expect(mocks.invalidateRoomBookmarkQueries).not.toHaveBeenCalled(); + expect(mocks.invalidateRoomBookmarkQueries).toHaveBeenCalledOnce(); }, ); - - it("applies a change of another account without a tab ID", async () => { - queryClient.setQueryData(sessionUserQueryKey, { id: ME + 1 }); - - await applyRoomBookmarkStompMessage(queryClient, categoryCreated(null)); - - expect(mocks.invalidateRoomBookmarkQueries).toHaveBeenCalledOnce(); - }); }); diff --git a/src/lib/stomp/bookmarks-dispatch.tsx b/src/lib/stomp/bookmarks-dispatch.tsx index d3510493..a2d801be 100644 --- a/src/lib/stomp/bookmarks-dispatch.tsx +++ b/src/lib/stomp/bookmarks-dispatch.tsx @@ -16,7 +16,7 @@ export async function applyRoomBookmarkStompMessage( if (!rid) return; /** 본인 탭의 변경은 그 탭의 mutation이 캐시를 맞춘다 */ - if (isOwnEcho(queryClient, event)) { + if (isOwnEcho(event)) { return; } diff --git a/src/lib/stomp/own-echo.test.ts b/src/lib/stomp/own-echo.test.ts index ce2eee16..f4fa27fa 100644 --- a/src/lib/stomp/own-echo.test.ts +++ b/src/lib/stomp/own-echo.test.ts @@ -1,38 +1,28 @@ -import { QueryClient } from "@tanstack/react-query"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { describe, expect, it, vi } from "vitest"; -import { sessionUserQueryKey } from "@/lib/query-keys"; +const tab = vi.hoisted(() => ({ id: "tab-a" as string | null })); -vi.mock("@/lib/tab-id", () => ({ getTabId: () => "tab-a" })); +vi.mock("@/lib/tab-id", () => ({ getTabId: () => tab.id })); import { isOwnEcho } from "@/lib/stomp/own-echo"; -const ME = 7; -const OTHER = 8; - describe("isOwnEcho", () => { - const queryClient = new QueryClient(); - - afterEach(() => { - queryClient.clear(); - }); - it.each([ - // 탭 ID가 있으면 계정과 상관없이 탭으로만 판정한다 - { name: "this tab", originTabId: "tab-a", actorUserId: ME, expected: true }, - { name: "same account, other tab", originTabId: "tab-b", actorUserId: ME, expected: false }, - { name: "other account, other tab", originTabId: "tab-b", actorUserId: OTHER, expected: false }, - // 탭 ID가 없으면(옛 백엔드·HTTP 밖 변경) 계정으로 판정한다 - { name: "no tab ID, same account", originTabId: null, actorUserId: ME, expected: true }, - { name: "tab ID field missing, same account", originTabId: undefined, actorUserId: ME, expected: true }, - { name: "no tab ID, other account", originTabId: null, actorUserId: OTHER, expected: false }, - ])("$name → $expected", ({ originTabId, actorUserId, expected }) => { - queryClient.setQueryData(sessionUserQueryKey, { id: ME }); - - expect(isOwnEcho(queryClient, { actorUserId, originTabId })).toBe(expected); + { name: "sent from this tab", originTabId: "tab-a", expected: true }, + { name: "sent from another tab", originTabId: "tab-b", expected: false }, + // 탭 ID가 없으면 어느 탭 것인지 몰라 놓치지 않도록 받는다 + { name: "no tab ID", originTabId: null, expected: false }, + { name: "tab ID field missing", originTabId: undefined, expected: false }, + { name: "empty tab ID", originTabId: "", expected: false }, + ])("$name → $expected", ({ originTabId, expected }) => { + tab.id = "tab-a"; + + expect(isOwnEcho({ originTabId })).toBe(expected); }); - it("treats an event without a tab ID as remote while the session user is unknown", () => { - expect(isOwnEcho(queryClient, { actorUserId: ME, originTabId: null })).toBe(false); + it("does not match a tab-less event in a tab that could not make an ID", () => { + tab.id = null; + + expect(isOwnEcho({ originTabId: null })).toBe(false); }); }); diff --git a/src/lib/stomp/own-echo.ts b/src/lib/stomp/own-echo.ts index acd9ed2e..e543cf1d 100644 --- a/src/lib/stomp/own-echo.ts +++ b/src/lib/stomp/own-echo.ts @@ -1,20 +1,14 @@ -import type { QueryClient } from "@tanstack/react-query"; - -import { readSessionUserId } from "@/lib/session-user-cache"; import { getTabId } from "@/lib/tab-id"; type ChangeOrigin = { - actorUserId: number; - /** 변경 요청의 `X-Client-Tab-Id`. 헤더를 싣기 전 백엔드나 HTTP 밖의 변경이면 없다 */ + /** 변경 요청의 `X-Client-Tab-Id`. 헤더를 싣기 전 백엔드거나 탭 ID를 못 만든 탭의 변경이면 없다 */ originTabId?: string | null; }; /** * 이 탭이 보낸 변경의 반향인지 판정한다 — 반향이면 그 탭의 mutation이 이미 캐시를 맞춘다. - * `originTabId`가 없으면 예전처럼 계정으로 판정해, 같은 계정의 다른 탭도 반향으로 본다. + * 탭 ID가 없는 이벤트는 어느 탭의 변경인지 알 수 없어, 놓치지 않도록 다른 탭의 변경으로 받는다. */ -export function isOwnEcho(queryClient: QueryClient, event: ChangeOrigin): boolean { - if (event.originTabId) return event.originTabId === getTabId(); - const me = readSessionUserId(queryClient); - return typeof me === "number" && Number.isFinite(me) && me === event.actorUserId; +export function isOwnEcho(event: ChangeOrigin): boolean { + return Boolean(event.originTabId) && event.originTabId === getTabId(); } diff --git a/src/lib/stomp/schedules-dispatch.test.ts b/src/lib/stomp/schedules-dispatch.test.ts index fa8076dd..e7c4b186 100644 --- a/src/lib/stomp/schedules-dispatch.test.ts +++ b/src/lib/stomp/schedules-dispatch.test.ts @@ -58,17 +58,14 @@ describe("dispatchRoomScheduleEvent", () => { queryClient.clear(); }); - it.each([false, true])("refetches ID-only time updates, including own-user tabs (%s)", async (ownUser) => { + it("refetches consecutive ID-only time updates once", async () => { vi.useFakeTimers(); - if (ownUser) queryClient.setQueryData(sessionUserQueryKey, { id: 99 }); const event = itemEvent("SCHEDULE_ITEM_UPDATED", []); await dispatchRoomScheduleEvent(queryClient, event); await dispatchRoomScheduleEvent(queryClient, event); await vi.runAllTimersAsync(); - // 본인 반향만 모였으면 본인 저장이 끝나며 다시 받는 조회에 합류한다 - expect(mocks.invalidateSchedulePlanPlaces).toHaveBeenCalledExactlyOnceWith(queryClient, "room-1", 10, { - joinInFlight: ownUser, - }); + + expect(mocks.invalidateSchedulePlanPlaces).toHaveBeenCalledExactlyOnceWith(queryClient, "room-1", 10); expect(queryClient.getQueryState(routeKey)?.isInvalidated).toBe(false); }); @@ -107,9 +104,7 @@ describe("dispatchRoomScheduleEvent", () => { ); await vi.runAllTimersAsync(); - expect(mocks.invalidateSchedulePlanPlaces).toHaveBeenCalledExactlyOnceWith(queryClient, "room-1", 10, { - joinInFlight: false, - }); + expect(mocks.invalidateSchedulePlanPlaces).toHaveBeenCalledExactlyOnceWith(queryClient, "room-1", 10); expect(queryClient.getQueryState(routeKey)?.isInvalidated).toBe(false); }); }); @@ -178,11 +173,11 @@ describe("echo of this tab vs. other tabs of the same account", () => { expect(refetchCount()).toBe(1); }); - it.each(SKIPPABLE_TYPES)("still skips %s of the same account without a tab ID (backend before originTabId)", async (type) => { + it.each(SKIPPABLE_TYPES)("applies %s of the same account without a tab ID — it may come from another tab", async (type) => { await dispatchRoomScheduleEvent(queryClient, ownAccountEvent(type, null)); await vi.runAllTimersAsync(); - expect(refetchCount()).toBe(0); + expect(refetchCount()).toBe(1); }); it("skips a time update sent from this tab — its own settled refetch already has it", async () => { @@ -192,12 +187,10 @@ describe("echo of this tab vs. other tabs of the same account", () => { expect(mocks.invalidateSchedulePlanPlaces).not.toHaveBeenCalled(); }); - it("refetches a time update from another tab of the same account without joining an in-flight load", async () => { - await dispatchRoomScheduleEvent(queryClient, ownAccountEvent("SCHEDULE_ITEM_UPDATED", "tab-b")); + it.each(["tab-b", null])("refetches a time update of the same account from another tab (%s)", async (originTabId) => { + await dispatchRoomScheduleEvent(queryClient, ownAccountEvent("SCHEDULE_ITEM_UPDATED", originTabId)); await vi.runAllTimersAsync(); - expect(mocks.invalidateSchedulePlanPlaces).toHaveBeenCalledExactlyOnceWith(queryClient, "room-1", 10, { - joinInFlight: false, - }); + expect(mocks.invalidateSchedulePlanPlaces).toHaveBeenCalledExactlyOnceWith(queryClient, "room-1", 10); }); }); diff --git a/src/lib/stomp/schedules-dispatch.ts b/src/lib/stomp/schedules-dispatch.ts index 977c3db6..20913d77 100644 --- a/src/lib/stomp/schedules-dispatch.ts +++ b/src/lib/stomp/schedules-dispatch.ts @@ -15,32 +15,23 @@ const syncSchedulePlacesFromItemsDebouncers = new Map< string, ReturnType >(); -/** 모으는 동안 이 탭의 반향이 아닌 변경이 하나라도 들어온 일차 */ -const syncSchedulePlacesRemoteChangePending = new Set(); const DEBOUNCE_ITEMS_GET_MS = 90; function enqueueDebouncedHydratePlacesFromScheduleItemsApi( queryClient: QueryClient, rid: string, sid: number, - ownEcho: boolean, ): void { const mapKey = `${rid}:${sid}`; - if (!ownEcho) syncSchedulePlacesRemoteChangePending.add(mapKey); const pending = syncSchedulePlacesFromItemsDebouncers.get(mapKey); if (pending !== undefined) clearTimeout(pending); syncSchedulePlacesFromItemsDebouncers.set( mapKey, setTimeout(() => { syncSchedulePlacesFromItemsDebouncers.delete(mapKey); - // 모은 이벤트가 모두 반향이면(탭 ID 없는 옛 이벤트), 저장이 끝나며 보낸 조회가 이미 그 변경을 담고 있어 합류한다 - const joinInFlight = !syncSchedulePlacesRemoteChangePending.has(mapKey); - syncSchedulePlacesRemoteChangePending.delete(mapKey); void (async () => { try { - await invalidateSchedulePlanPlaces(queryClient, rid, sid, { - joinInFlight, - }); + await invalidateSchedulePlanPlaces(queryClient, rid, sid); } catch { // } @@ -52,12 +43,12 @@ function enqueueDebouncedHydratePlacesFromScheduleItemsApi( /** * Plan 화면 등이 쓰는 캐시만, 스키마의 `type`별로 갱신합니다. * — 이 탭이 보낸 변경의 반향(`isOwnEcho`)은 건너뛴다. 그 탭의 mutation이 캐시를 맞춘다. - * 같은 계정의 다른 탭은 `originTabId`가 달라 다른 사람의 변경처럼 받는다 + * 같은 계정의 다른 탭과 탭 ID가 없는 변경은 다른 사람의 변경처럼 받는다 * — `ROOM_SCHEDULES_RESYNCED`: `room-schedules` 전체 재조회 + 방 상세 동기화 * — `schedule-items`: 그 일차 장소 목록을 무효화해 다시 받는다(보는 화면이 없으면 그 화면에 들어갈 때) * — `schedule-item-route`: 무효화하지 않는다 — key에 도착 항목·이동수단이 있어, 순서·수단이 바뀐 구간은 * 새 목록을 그릴 때 새 key로 받고 되돌리면 받아 둔 값을 쓴다 - * — `SCHEDULE_ITEM_UPDATED`: 탭 ID 없는 옛 이벤트는 계정 단위 반향도 진행 중인 조회에 합류해 받는다 + * — `SCHEDULE_ITEM_UPDATED`·`SCHEDULE_ITEM_TRAVEL_MODE_UPDATED`: 연속 이벤트를 모아 그 일차를 한 번만 다시 받는다 */ export async function dispatchRoomScheduleEvent( queryClient: QueryClient, @@ -75,7 +66,7 @@ export async function dispatchRoomScheduleEvent( switch (event.type) { case "ROOM_SCHEDULES_RESYNCED": { - if (isOwnEcho(queryClient, event)) return; + if (isOwnEcho(event)) return; // 일정 전체 재조회는 room-schedules 조회 하나로 한다 — 진행 중인 옛 조회가 취소돼 새 일정을 덮지 않는다 await queryClient.invalidateQueries({ queryKey: roomSchedulesQueryKey(rid) }); @@ -90,7 +81,7 @@ export async function dispatchRoomScheduleEvent( const sourceScheduleId = scheduleIds[0]!; const targetScheduleId = scheduleIds[1]!; - if (isOwnEcho(queryClient, event)) return; + if (isOwnEcho(event)) return; await syncAfterCrossScheduleItemMove( queryClient, @@ -106,7 +97,7 @@ export async function dispatchRoomScheduleEvent( const itemId = event.itemId; if (sid == null || itemId == null) return; - if (isOwnEcho(queryClient, event)) return; + if (isOwnEcho(event)) return; await invalidateSchedulePlanPlaces(queryClient, rid, sid); return; @@ -117,7 +108,7 @@ export async function dispatchRoomScheduleEvent( const itemId = event.itemId; if (sid == null || itemId == null) return; - if (isOwnEcho(queryClient, event)) return; + if (isOwnEcho(event)) return; await invalidateSchedulePlanPlaces(queryClient, rid, sid); return; @@ -128,7 +119,7 @@ export async function dispatchRoomScheduleEvent( const itemId = event.itemId; if (sid == null || itemId == null) return; - if (isOwnEcho(queryClient, event)) return; + if (isOwnEcho(event)) return; await invalidateSchedulePlanPlaces(queryClient, rid, sid); return; @@ -139,17 +130,9 @@ export async function dispatchRoomScheduleEvent( const itemId = event.itemId; if (sid == null || itemId == null) return; - const ownEcho = isOwnEcho(queryClient, event); - // 탭 ID로 확인한 이 탭의 반향은 저장이 끝나며 보낸 조회(onSettled)가 이미 받는다. - // 탭 ID 없는 옛 이벤트는 같은 계정의 다른 탭일 수도 있어, 반향이어도 진행 중인 조회에 합류해 받는다 - if (ownEcho && event.originTabId) return; + if (isOwnEcho(event)) return; - enqueueDebouncedHydratePlacesFromScheduleItemsApi( - queryClient, - rid, - sid, - ownEcho, - ); + enqueueDebouncedHydratePlacesFromScheduleItemsApi(queryClient, rid, sid); return; } @@ -158,14 +141,9 @@ export async function dispatchRoomScheduleEvent( const itemId = event.itemId; if (sid == null || itemId == null) return; - if (isOwnEcho(queryClient, event)) return; + if (isOwnEcho(event)) return; - enqueueDebouncedHydratePlacesFromScheduleItemsApi( - queryClient, - rid, - sid, - false, - ); + enqueueDebouncedHydratePlacesFromScheduleItemsApi(queryClient, rid, sid); return; } } From 6efc351a7311342b6ee31c815834b4852fe2ba7a Mon Sep 17 00:00:00 2001 From: KangShinGyu Date: Sat, 7 Nov 2026 14:06:49 +0900 Subject: [PATCH 12/14] =?UTF-8?q?refactor(plan):=20=EC=93=B0=EB=8A=94=20?= =?UTF-8?q?=EA=B3=B3=EC=9D=B4=20=EC=97=86=EC=96=B4=EC=A7=84=20=EC=9D=BC?= =?UTF-8?q?=EC=B0=A8=20=EB=AC=B4=ED=9A=A8=ED=99=94=EC=9D=98=20joinInFlight?= =?UTF-8?q?=20=EC=98=B5=EC=85=98=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- src/lib/plan/scheduleItemPlaces.ts | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/src/lib/plan/scheduleItemPlaces.ts b/src/lib/plan/scheduleItemPlaces.ts index b180da11..fd147512 100644 --- a/src/lib/plan/scheduleItemPlaces.ts +++ b/src/lib/plan/scheduleItemPlaces.ts @@ -187,25 +187,20 @@ export function removeScheduleItemFromPlanPlacesCache( * STOMP·뮤테이션 동기화: 그 일차 장소 목록을 서버에서 다시 받게 합니다. 보는 화면이 있으면 바로 받고, * 없으면 다시 받을 목록으로 표시해 두었다가 그 화면에 들어갈 때 받습니다. * 같은 일차를 받는 중이면 그 조회는 이 변경 전에 보낸 요청이라 취소하고 새로 받습니다(늦게 온 옛 응답은 버려짐). - * 본인 저장의 반향처럼 받는 중인 조회가 이미 그 변경 뒤에 보낸 것이면 `joinInFlight`로 합류해 요청을 아낍니다. * 다시 받지 못하면 throw합니다. */ export async function invalidateSchedulePlanPlaces( queryClient: QueryClient, roomId: string, scheduleId: number, - { joinInFlight = false }: { joinInFlight?: boolean } = {}, ): Promise { const queryKey = scheduleItemsQueryKey(roomId.trim(), scheduleId); // 목록이 아직 없으면 TanStack은 진행 중인 첫 조회를 취소하지 않고 합류만 한다 const state = queryClient.getQueryState(queryKey); - if (!joinInFlight && state?.data === undefined && state?.fetchStatus === "fetching") { + if (state?.data === undefined && state?.fetchStatus === "fetching") { await queryClient.cancelQueries({ queryKey, exact: true }); } - await queryClient.invalidateQueries( - { queryKey, exact: true }, - { throwOnError: true, cancelRefetch: !joinInFlight }, - ); + await queryClient.invalidateQueries({ queryKey, exact: true }, { throwOnError: true }); } /** From 26de99c69272bc1ff3bf7153bae74a605702d5d5 Mon Sep 17 00:00:00 2001 From: KangShinGyu Date: Sat, 7 Nov 2026 14:41:59 +0900 Subject: [PATCH 13/14] =?UTF-8?q?refactor(stomp):=20=EC=9D=BC=EC=A0=95=20?= =?UTF-8?q?=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EB=B0=98=ED=96=A5=20=ED=8C=90?= =?UTF-8?q?=EC=A0=95=EC=9D=84=20switch=20=EC=95=9E=20=ED=95=9C=20=EA=B3=B3?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EB=AA=A8=EC=9C=BC=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - case마다 반복하던 isOwnEcho를 한 번만 둬 새 이벤트 종류에서 빠뜨리지 않게 함 Co-Authored-By: Claude Opus 5.5 --- src/lib/stomp/schedules-dispatch.ts | 15 +-------------- 1 file changed, 1 insertion(+), 14 deletions(-) diff --git a/src/lib/stomp/schedules-dispatch.ts b/src/lib/stomp/schedules-dispatch.ts index 20913d77..8c699876 100644 --- a/src/lib/stomp/schedules-dispatch.ts +++ b/src/lib/stomp/schedules-dispatch.ts @@ -63,11 +63,10 @@ export async function dispatchRoomScheduleEvent( ) { await invalidateExpenses(queryClient, rid); } + if (isOwnEcho(event)) return; switch (event.type) { case "ROOM_SCHEDULES_RESYNCED": { - if (isOwnEcho(event)) return; - // 일정 전체 재조회는 room-schedules 조회 하나로 한다 — 진행 중인 옛 조회가 취소돼 새 일정을 덮지 않는다 await queryClient.invalidateQueries({ queryKey: roomSchedulesQueryKey(rid) }); await syncRoomDetailFromServer(queryClient, rid); @@ -81,8 +80,6 @@ export async function dispatchRoomScheduleEvent( const sourceScheduleId = scheduleIds[0]!; const targetScheduleId = scheduleIds[1]!; - if (isOwnEcho(event)) return; - await syncAfterCrossScheduleItemMove( queryClient, rid, @@ -97,8 +94,6 @@ export async function dispatchRoomScheduleEvent( const itemId = event.itemId; if (sid == null || itemId == null) return; - if (isOwnEcho(event)) return; - await invalidateSchedulePlanPlaces(queryClient, rid, sid); return; } @@ -108,8 +103,6 @@ export async function dispatchRoomScheduleEvent( const itemId = event.itemId; if (sid == null || itemId == null) return; - if (isOwnEcho(event)) return; - await invalidateSchedulePlanPlaces(queryClient, rid, sid); return; } @@ -119,8 +112,6 @@ export async function dispatchRoomScheduleEvent( const itemId = event.itemId; if (sid == null || itemId == null) return; - if (isOwnEcho(event)) return; - await invalidateSchedulePlanPlaces(queryClient, rid, sid); return; } @@ -130,8 +121,6 @@ export async function dispatchRoomScheduleEvent( const itemId = event.itemId; if (sid == null || itemId == null) return; - if (isOwnEcho(event)) return; - enqueueDebouncedHydratePlacesFromScheduleItemsApi(queryClient, rid, sid); return; } @@ -141,8 +130,6 @@ export async function dispatchRoomScheduleEvent( const itemId = event.itemId; if (sid == null || itemId == null) return; - if (isOwnEcho(event)) return; - enqueueDebouncedHydratePlacesFromScheduleItemsApi(queryClient, rid, sid); return; } From ff15bdd2b21b51f7fad56c3e75f7f41f5283db08 Mon Sep 17 00:00:00 2001 From: KangShinGyu Date: Sat, 7 Nov 2026 14:42:29 +0900 Subject: [PATCH 14/14] =?UTF-8?q?fix(stomp):=20=EB=8B=A4=EB=A5=B8=20?= =?UTF-8?q?=EB=A9=A4=EB=B2=84=EA=B0=80=20=ED=9D=89=EB=82=B4=20=EB=82=B8=20?= =?UTF-8?q?=ED=83=AD=20ID=EB=A1=9C=20=EC=9D=B4=20=ED=83=AD=EC=9D=98=20?= =?UTF-8?q?=EA=B0=B1=EC=8B=A0=EC=9D=B4=20=EB=A7=89=ED=9E=88=EC=A7=80=20?= =?UTF-8?q?=EC=95=8A=EA=B2=8C=20=ED=95=98=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 반향은 originTabId가 이 탭이고 actorUserId도 나일 때만으로 판정 - 탭 ID는 방 멤버 모두에게 보이므로 서버가 인증으로 정한 actorUserId를 함께 봄 Co-Authored-By: Claude Opus 5.5 --- src/lib/stomp/bookmarks-dispatch.test.ts | 8 +++++ src/lib/stomp/bookmarks-dispatch.tsx | 2 +- src/lib/stomp/own-echo.test.ts | 40 ++++++++++++++++++------ src/lib/stomp/own-echo.ts | 11 +++++-- src/lib/stomp/schedules-dispatch.test.ts | 8 +++++ src/lib/stomp/schedules-dispatch.ts | 2 +- 6 files changed, 57 insertions(+), 14 deletions(-) diff --git a/src/lib/stomp/bookmarks-dispatch.test.ts b/src/lib/stomp/bookmarks-dispatch.test.ts index 2831ab76..746d9166 100644 --- a/src/lib/stomp/bookmarks-dispatch.test.ts +++ b/src/lib/stomp/bookmarks-dispatch.test.ts @@ -50,6 +50,14 @@ describe("applyRoomBookmarkStompMessage — echo of this tab", () => { expect(mocks.invalidateRoomBookmarkQueries).not.toHaveBeenCalled(); }); + it("applies a change whose tab ID matches this tab but comes from another account", async () => { + queryClient.setQueryData(sessionUserQueryKey, { id: ME + 1 }); + + await applyRoomBookmarkStompMessage(queryClient, categoryCreated("tab-a")); + + expect(mocks.invalidateRoomBookmarkQueries).toHaveBeenCalledOnce(); + }); + it("applies a change sent from another tab of the same account", async () => { await applyRoomBookmarkStompMessage(queryClient, categoryCreated("tab-b")); diff --git a/src/lib/stomp/bookmarks-dispatch.tsx b/src/lib/stomp/bookmarks-dispatch.tsx index a2d801be..d3510493 100644 --- a/src/lib/stomp/bookmarks-dispatch.tsx +++ b/src/lib/stomp/bookmarks-dispatch.tsx @@ -16,7 +16,7 @@ export async function applyRoomBookmarkStompMessage( if (!rid) return; /** 본인 탭의 변경은 그 탭의 mutation이 캐시를 맞춘다 */ - if (isOwnEcho(event)) { + if (isOwnEcho(queryClient, event)) { return; } diff --git a/src/lib/stomp/own-echo.test.ts b/src/lib/stomp/own-echo.test.ts index f4fa27fa..ec90a607 100644 --- a/src/lib/stomp/own-echo.test.ts +++ b/src/lib/stomp/own-echo.test.ts @@ -1,4 +1,7 @@ -import { describe, expect, it, vi } from "vitest"; +import { QueryClient } from "@tanstack/react-query"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { sessionUserQueryKey } from "@/lib/query-keys"; const tab = vi.hoisted(() => ({ id: "tab-a" as string | null })); @@ -6,23 +9,40 @@ vi.mock("@/lib/tab-id", () => ({ getTabId: () => tab.id })); import { isOwnEcho } from "@/lib/stomp/own-echo"; +const ME = 7; +const OTHER = 8; + describe("isOwnEcho", () => { + const queryClient = new QueryClient(); + + afterEach(() => { + queryClient.clear(); + tab.id = "tab-a"; + }); + it.each([ - { name: "sent from this tab", originTabId: "tab-a", expected: true }, - { name: "sent from another tab", originTabId: "tab-b", expected: false }, + { name: "sent from this tab by me", originTabId: "tab-a", actorUserId: ME, expected: true }, + { name: "sent from another tab of my account", originTabId: "tab-b", actorUserId: ME, expected: false }, + // 탭 ID는 방 멤버 모두에게 보여 흉내 낼 수 있다 — 다른 계정이면 반향이 아니다 + { name: "my tab ID forged by another account", originTabId: "tab-a", actorUserId: OTHER, expected: false }, // 탭 ID가 없으면 어느 탭 것인지 몰라 놓치지 않도록 받는다 - { name: "no tab ID", originTabId: null, expected: false }, - { name: "tab ID field missing", originTabId: undefined, expected: false }, - { name: "empty tab ID", originTabId: "", expected: false }, - ])("$name → $expected", ({ originTabId, expected }) => { - tab.id = "tab-a"; + { name: "no tab ID", originTabId: null, actorUserId: ME, expected: false }, + { name: "tab ID field missing", originTabId: undefined, actorUserId: ME, expected: false }, + { name: "empty tab ID", originTabId: "", actorUserId: ME, expected: false }, + ])("$name → $expected", ({ originTabId, actorUserId, expected }) => { + queryClient.setQueryData(sessionUserQueryKey, { id: ME }); - expect(isOwnEcho({ originTabId })).toBe(expected); + expect(isOwnEcho(queryClient, { actorUserId, originTabId })).toBe(expected); }); it("does not match a tab-less event in a tab that could not make an ID", () => { tab.id = null; + queryClient.setQueryData(sessionUserQueryKey, { id: ME }); + + expect(isOwnEcho(queryClient, { actorUserId: ME, originTabId: null })).toBe(false); + }); - expect(isOwnEcho({ originTabId: null })).toBe(false); + it("receives this tab's change while the session user is not loaded yet", () => { + expect(isOwnEcho(queryClient, { actorUserId: ME, originTabId: "tab-a" })).toBe(false); }); }); diff --git a/src/lib/stomp/own-echo.ts b/src/lib/stomp/own-echo.ts index e543cf1d..0194f017 100644 --- a/src/lib/stomp/own-echo.ts +++ b/src/lib/stomp/own-echo.ts @@ -1,6 +1,10 @@ +import type { QueryClient } from "@tanstack/react-query"; + +import { readSessionUserId } from "@/lib/session-user-cache"; import { getTabId } from "@/lib/tab-id"; type ChangeOrigin = { + actorUserId: number; /** 변경 요청의 `X-Client-Tab-Id`. 헤더를 싣기 전 백엔드거나 탭 ID를 못 만든 탭의 변경이면 없다 */ originTabId?: string | null; }; @@ -8,7 +12,10 @@ type ChangeOrigin = { /** * 이 탭이 보낸 변경의 반향인지 판정한다 — 반향이면 그 탭의 mutation이 이미 캐시를 맞춘다. * 탭 ID가 없는 이벤트는 어느 탭의 변경인지 알 수 없어, 놓치지 않도록 다른 탭의 변경으로 받는다. + * 탭 ID는 방 멤버 모두에게 보이고 헤더로 흉내 낼 수 있어, 서버가 인증으로 정한 `actorUserId`도 나여야 반향으로 본다. */ -export function isOwnEcho(event: ChangeOrigin): boolean { - return Boolean(event.originTabId) && event.originTabId === getTabId(); +export function isOwnEcho(queryClient: QueryClient, event: ChangeOrigin): boolean { + if (!event.originTabId || event.originTabId !== getTabId()) return false; + const me = readSessionUserId(queryClient); + return typeof me === "number" && me === event.actorUserId; } diff --git a/src/lib/stomp/schedules-dispatch.test.ts b/src/lib/stomp/schedules-dispatch.test.ts index e7c4b186..89bf39d4 100644 --- a/src/lib/stomp/schedules-dispatch.test.ts +++ b/src/lib/stomp/schedules-dispatch.test.ts @@ -180,6 +180,14 @@ describe("echo of this tab vs. other tabs of the same account", () => { expect(refetchCount()).toBe(1); }); + it("applies a change whose tab ID matches this tab but comes from another account", async () => { + queryClient.setQueryData(sessionUserQueryKey, { id: 100 }); + + await dispatchRoomScheduleEvent(queryClient, ownAccountEvent("SCHEDULE_ITEM_DELETED", MY_TAB_ID)); + + expect(refetchCount()).toBe(1); + }); + it("skips a time update sent from this tab — its own settled refetch already has it", async () => { await dispatchRoomScheduleEvent(queryClient, ownAccountEvent("SCHEDULE_ITEM_UPDATED", MY_TAB_ID)); await vi.runAllTimersAsync(); diff --git a/src/lib/stomp/schedules-dispatch.ts b/src/lib/stomp/schedules-dispatch.ts index 8c699876..3a10e7ea 100644 --- a/src/lib/stomp/schedules-dispatch.ts +++ b/src/lib/stomp/schedules-dispatch.ts @@ -63,7 +63,7 @@ export async function dispatchRoomScheduleEvent( ) { await invalidateExpenses(queryClient, rid); } - if (isOwnEcho(event)) return; + if (isOwnEcho(queryClient, event)) return; switch (event.type) { case "ROOM_SCHEDULES_RESYNCED": {