From c4b4a7b5f164d41dd8c2025d09e9869eaeeb4e68 Mon Sep 17 00:00:00 2001 From: KangShinGyu Date: Fri, 6 Nov 2026 17:02:31 +0900 Subject: [PATCH 1/7] =?UTF-8?q?feat(api):=20=EB=B3=80=EA=B2=BD=20=EC=9A=94?= =?UTF-8?q?=EC=B2=AD=EC=97=90=20=ED=83=AD=20ID=20=ED=97=A4=EB=8D=94(X-Clie?= =?UTF-8?q?nt-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 2/7] =?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 3/7] =?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 4/7] =?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 5/7] =?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 6/7] =?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 7/7] =?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": {