From 5743c12a913418cb7759756d020f093642b07e6f Mon Sep 17 00:00:00 2001 From: KangShinGyu Date: Sun, 25 Oct 2026 18:35:58 +0900 Subject: [PATCH 01/18] =?UTF-8?q?fix(places):=20=EC=82=AC=EC=A7=84=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=EC=8B=A4=ED=8C=A8=20=EC=8B=9C=20=EB=B0=98?= =?UTF-8?q?=EB=B3=B5=20=EC=9A=94=EC=B2=AD=ED=95=98=EC=A7=80=20=EC=95=8A?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EC=9E=AC=EC=8B=9C=EB=8F=84=20=EA=B7=9C?= =?UTF-8?q?=EC=B9=99=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 전역 재시도를 일시적 네트워크 오류 1회로 제한 (429·4xx·5xx는 재시도하지 않음) - 장소 API 실패를 status가 담긴 HttpError로 던짐 - 사진 retry 2·미리보기 retry 1 개별 설정 제거 Co-Authored-By: Claude Opus 5.5 --- src/hooks/usePlacePhotoUrl.test.ts | 33 ++++++++++----- src/lib/api/errors.ts | 8 ++++ src/lib/api/places.test.ts | 66 ++++++++++++++++++++++++++++++ src/lib/api/places.ts | 14 +++---- src/lib/place-photo-query.ts | 1 - src/lib/places/place-queries.ts | 1 - src/lib/query-client.test.ts | 19 +++++++++ src/lib/query-client.ts | 9 +++- 8 files changed, 131 insertions(+), 20 deletions(-) diff --git a/src/hooks/usePlacePhotoUrl.test.ts b/src/hooks/usePlacePhotoUrl.test.ts index 9e46a867..426f592c 100644 --- a/src/hooks/usePlacePhotoUrl.test.ts +++ b/src/hooks/usePlacePhotoUrl.test.ts @@ -31,6 +31,8 @@ let ensurePlacePhotoUrlsCached: typeof import("@/lib/places/place-batch-cache"). let placePhotoUrlQueryKey: typeof import("@/lib/place-photo-query").placePhotoUrlQueryKey; let seededPlacePhotoUrlQueryOptions: typeof import("@/lib/place-photo-query").seededPlacePhotoUrlQueryOptions; let registerQueryClient: typeof import("@/lib/query-client").registerQueryClient; +let createQueryClient: typeof import("@/lib/query-client").createQueryClient; +let HttpError: typeof import("@/lib/api/errors").HttpError; let usePlacePhotoUrlQuery: typeof import("@/hooks/usePlacePhotoUrl").usePlacePhotoUrlQuery; beforeAll(async () => { @@ -40,7 +42,8 @@ beforeAll(async () => { ({ placePhotoUrlQueryKey, seededPlacePhotoUrlQueryOptions } = await import( "@/lib/place-photo-query" )); - ({ registerQueryClient } = await import("@/lib/query-client")); + ({ registerQueryClient, createQueryClient } = await import("@/lib/query-client")); + ({ HttpError } = await import("@/lib/api/errors")); ({ usePlacePhotoUrlQuery } = await import("@/hooks/usePlacePhotoUrl")); }); @@ -139,23 +142,33 @@ describe("usePlacePhotoUrlQuery", () => { ); }); - it("단건 GET 실패 재시도에서는 batch 요청을 보내지 않는다", async () => { - const queryClient = new QueryClient(); + it.each([429, 404, 500, 502])("단건 GET이 %i면 다시 보내지 않는다", async (status) => { + const queryClient = createQueryClient(); + registerQueryClient(queryClient); + requestPlacePhotoUrlMock.mockRejectedValueOnce(new HttpError(status, "Place photo failed")); + + usePlacePhotoUrlQuery("ChIJ-http-error"); + const queryOptions = useQueryMock.mock.lastCall?.[0]; + + await expect(queryClient.fetchQuery(queryOptions)).rejects.toThrow("Place photo failed"); + expect(requestPlacePhotoUrlMock).toHaveBeenCalledTimes(1); + expect(requestPlacePhotoUrlsBatchMock).not.toHaveBeenCalled(); + }); + + it("일시적 네트워크 오류만 한 번 더 보낸다", async () => { + const queryClient = createQueryClient(); registerQueryClient(queryClient); - const failedId = "ChIJ-single-retry"; requestPlacePhotoUrlMock - .mockRejectedValueOnce(new Error("single request failed once")) - .mockRejectedValueOnce(new Error("single request failed twice")) + .mockRejectedValueOnce(new TypeError("Failed to fetch")) .mockResolvedValueOnce("https://cdn.example/retried.jpg"); - usePlacePhotoUrlQuery(failedId); + usePlacePhotoUrlQuery("ChIJ-network-error"); const queryOptions = useQueryMock.mock.lastCall?.[0]; - await expect(queryClient.fetchQuery(queryOptions)).resolves.toBe( + await expect(queryClient.fetchQuery({ ...queryOptions, retryDelay: 0 })).resolves.toBe( "https://cdn.example/retried.jpg", ); - expect(requestPlacePhotoUrlsBatchMock).not.toHaveBeenCalled(); - expect(requestPlacePhotoUrlMock).toHaveBeenCalledTimes(3); + expect(requestPlacePhotoUrlMock).toHaveBeenCalledTimes(2); }); it("seeded multi-photo query도 FAILED 캐시 miss를 빈 URL 성공으로 바꾸지 않는다", async () => { diff --git a/src/lib/api/errors.ts b/src/lib/api/errors.ts index 1f36b0fd..843b1143 100644 --- a/src/lib/api/errors.ts +++ b/src/lib/api/errors.ts @@ -234,6 +234,14 @@ export class HttpError extends Error { } } +/** + * 서버 응답을 받지 못한 일시적 네트워크 오류인지 — fetch는 이 실패를 TypeError로 던진다. + * 429·4xx·5xx 응답은 다시 보내도 같은 결과라 여기에 넣지 않는다. + */ +export function isNetworkError(error: unknown): boolean { + return error instanceof TypeError; +} + /** 방 생성·수정 API의 여행지 관련 서버 에러(HTTP 4xx) → 한국어 사용자 메시지 */ export function messageForRoomDestinationError( body: unknown, diff --git a/src/lib/api/places.test.ts b/src/lib/api/places.test.ts index 86f2c86e..152a3039 100644 --- a/src/lib/api/places.test.ts +++ b/src/lib/api/places.test.ts @@ -71,6 +71,38 @@ describe("place photo API requests", () => { ); }); + it("throws an HttpError carrying the status when a single photo request fails", async () => { + const { requestPlacePhotoUrl } = await import("@/lib/api/places"); + const { HttpError } = await import("@/lib/api/errors"); + apiFetch.mockResolvedValueOnce({ + status: 502, + ok: false, + json: async () => ({ code: "EXTERNAL_API_ERROR", message: "외부 API 호출 중 오류가 발생했습니다" }), + }); + + const error = await requestPlacePhotoUrl("ChIJ-place-1").catch((reason: unknown) => reason); + + expect(error).toBeInstanceOf(HttpError); + expect(error).toMatchObject({ status: 502, message: "외부 API 호출 중 오류가 발생했습니다" }); + }); + + it("throws an HttpError when the whole photo batch request is rate limited", async () => { + const { requestPlacePhotoUrlsBatch } = await import("@/lib/api/places"); + const { HttpError } = await import("@/lib/api/errors"); + apiFetch.mockResolvedValueOnce({ + status: 429, + ok: false, + json: async () => ({ code: "RATE_LIMIT_EXCEEDED", message: "요청이 너무 많습니다" }), + }); + + const error = await requestPlacePhotoUrlsBatch(["ChIJ-place-1", "ChIJ-place-2"]).catch( + (reason: unknown) => reason, + ); + + expect(error).toBeInstanceOf(HttpError); + expect(error).toMatchObject({ status: 429 }); + }); + it("requests batch photo URLs with googlePlaceIds and reads googlePlaceId response items", async () => { const { requestPlacePhotoUrlsBatch } = await import("@/lib/api/places"); apiFetch.mockResolvedValueOnce({ @@ -149,3 +181,37 @@ describe("place photo API requests", () => { ]); }); }); + +describe("place preview API requests", () => { + beforeEach(() => { + apiFetch.mockReset(); + }); + + it("throws an HttpError when the preview batch request is rate limited", async () => { + const { requestPlacePreviewsBatch } = await import("@/lib/api/places"); + const { HttpError } = await import("@/lib/api/errors"); + apiFetch.mockResolvedValueOnce({ + status: 429, + ok: false, + json: async () => ({ code: "RATE_LIMIT_EXCEEDED", message: "요청이 너무 많습니다" }), + }); + + const error = await requestPlacePreviewsBatch(["ChIJ-place-1", "ChIJ-place-2"]).catch( + (reason: unknown) => reason, + ); + + expect(error).toBeInstanceOf(HttpError); + expect(error).toMatchObject({ status: 429, message: "요청이 너무 많습니다" }); + }); + + it("throws an HttpError carrying the status when a single preview request fails", async () => { + const { requestPlacePreview } = await import("@/lib/api/places"); + const { HttpError } = await import("@/lib/api/errors"); + apiFetch.mockResolvedValueOnce({ status: 502, ok: false, json: async () => ({}) }); + + const error = await requestPlacePreview("ChIJ-place-1").catch((reason: unknown) => reason); + + expect(error).toBeInstanceOf(HttpError); + expect(error).toMatchObject({ status: 502 }); + }); +}); diff --git a/src/lib/api/places.ts b/src/lib/api/places.ts index 0a43c89d..a8d16215 100644 --- a/src/lib/api/places.ts +++ b/src/lib/api/places.ts @@ -1,7 +1,7 @@ import { API_BASE } from "./config"; import { apiFetch } from "./client"; import { chunkArray, PLACE_BATCH_MAX_SIZE } from "./batch-chunk"; -import { readUserFacingMessageFromApiBody } from "./errors"; +import { HttpError, readUserFacingMessageFromApiBody } from "./errors"; import { apiUrl, jsonBody, requestJson, tryParseJson } from "./http"; // ─── Response types ──────────────────────────────────────────────────────── @@ -156,7 +156,7 @@ export async function searchPlaces(params: { } const res = await apiFetch(url.toString()); - if (!res.ok) throw new Error(`Places search failed: ${res.status}`); + if (!res.ok) throw new HttpError(res.status, `Places search failed: ${res.status}`); return res.json(); } @@ -166,7 +166,7 @@ export async function requestPlaceDetail( const res = await apiFetch( `${API_BASE}/places/${encodeURIComponent(googlePlaceId)}`, ); - if (!res.ok) throw new Error(`Place detail failed: ${res.status}`); + if (!res.ok) throw new HttpError(res.status, `Place detail failed: ${res.status}`); return res.json(); } @@ -176,7 +176,7 @@ export async function requestPlacePreview( const res = await apiFetch( `${API_BASE}/places/${encodeURIComponent(googlePlaceId)}/preview`, ); - if (!res.ok) throw new Error(`Place preview failed: ${res.status}`); + if (!res.ok) throw new HttpError(res.status, `Place preview failed: ${res.status}`); return res.json(); } @@ -196,7 +196,7 @@ export async function requestPlacePhotoUrl( if (!res.ok) { const body = await tryParseJson(res); const detail = readUserFacingMessageFromApiBody(body); - throw new Error(detail ?? `Place photo failed: ${res.status}`); + throw new HttpError(res.status, detail ?? `Place photo failed: ${res.status}`); } const data: PlacePhotoResponse = await res.json(); return typeof data.photoUrl === "string" ? data.photoUrl : ""; @@ -208,7 +208,7 @@ async function requestPlacePreviewsBatchChunk( const data = await requestJson( apiUrl("/places/previews/batch"), { method: "POST", ...jsonBody({ googlePlaceIds }) }, - { errorMessage: "장소 미리보기 일괄 조회 실패" }, + { errorMessage: "장소 미리보기 일괄 조회 실패", useHttpError: true }, ); return readBatchResponseList(data, "previews"); } @@ -254,7 +254,7 @@ async function requestPlacePhotoUrlsBatchChunk( if (!res.ok) { const body = await tryParseJson(res); const detail = readUserFacingMessageFromApiBody(body); - throw new Error(detail ?? `장소 사진 URL 일괄 조회 실패: ${res.status}`); + throw new HttpError(res.status, detail ?? `장소 사진 URL 일괄 조회 실패: ${res.status}`); } const data = (await res.json()) as PlacePhotoUrlBatchResponse; return readBatchResponseList(data, "photos"); diff --git a/src/lib/place-photo-query.ts b/src/lib/place-photo-query.ts index 299a0596..9493cfeb 100644 --- a/src/lib/place-photo-query.ts +++ b/src/lib/place-photo-query.ts @@ -34,7 +34,6 @@ export const placePhotoUrlQueryDefaults = { refetchOnMount: true, refetchOnWindowFocus: false, refetchOnReconnect: false, - retry: 2, } as const; /** batch 시딩 후 구독 — 항목별 실패로 캐시 miss이면 단건 GET fallback */ diff --git a/src/lib/places/place-queries.ts b/src/lib/places/place-queries.ts index 005ff847..ecd7040a 100644 --- a/src/lib/places/place-queries.ts +++ b/src/lib/places/place-queries.ts @@ -124,7 +124,6 @@ export function placePreviewQueryOptions(googlePlaceId: string) { queryFn: () => loadPlacePreview(id), enabled: id.length > 0, ...placePreviewQueryDefaults, - retry: 1, }; } diff --git a/src/lib/query-client.test.ts b/src/lib/query-client.test.ts index 7bdbad82..d5dc8fa4 100644 --- a/src/lib/query-client.test.ts +++ b/src/lib/query-client.test.ts @@ -21,3 +21,22 @@ describe("getOrCreateQueryClient", () => { expect(queryClientModule.getQueryClient()).toBe(first); }); }); + +describe("shouldRetryQuery", () => { + it("HTTP 오류(429·4xx·5xx)는 다시 보내지 않는다", async () => { + const { shouldRetryQuery } = await import("@/lib/query-client"); + const { HttpError } = await import("@/lib/api/errors"); + + for (const status of [400, 401, 404, 429, 500, 502, 503, 504]) { + expect(shouldRetryQuery(0, new HttpError(status, "failed"))).toBe(false); + } + expect(shouldRetryQuery(0, new Error("일정 목록 조회 실패: 500"))).toBe(false); + }); + + it("일시적 네트워크 오류는 한 번만 다시 보낸다", async () => { + const { shouldRetryQuery } = await import("@/lib/query-client"); + + expect(shouldRetryQuery(0, new TypeError("Failed to fetch"))).toBe(true); + expect(shouldRetryQuery(1, new TypeError("Failed to fetch"))).toBe(false); + }); +}); diff --git a/src/lib/query-client.ts b/src/lib/query-client.ts index cedede77..7953a7d4 100644 --- a/src/lib/query-client.ts +++ b/src/lib/query-client.ts @@ -1,12 +1,19 @@ import { QueryClient } from "@tanstack/react-query"; +import { isNetworkError } from "@/lib/api/errors"; + let registeredQueryClient: QueryClient | null = null; +/** 일시적 네트워크 오류만 한 번 더 보낸다 — Google 429도 백엔드에서 502로 오므로 HTTP 오류는 재시도하지 않는다 */ +export function shouldRetryQuery(failureCount: number, error: unknown): boolean { + return failureCount < 1 && isNetworkError(error); +} + export function createQueryClient() { return new QueryClient({ defaultOptions: { queries: { - retry: 1, + retry: shouldRetryQuery, refetchOnWindowFocus: false, staleTime: 60_000, }, From e9a63c03114d98e51e0117b57d8aa7b7a0dd416b Mon Sep 17 00:00:00 2001 From: KangShinGyu Date: Sun, 25 Oct 2026 19:19:33 +0900 Subject: [PATCH 02/18] =?UTF-8?q?feat(places):=20=EC=82=AC=EC=A7=84=20?= =?UTF-8?q?=EC=B9=B4=EB=93=9C=EB=A5=BC=20batch=EB=A1=9C=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=ED=95=98=EA=B3=A0=20=EC=8B=A4=ED=8C=A8=ED=95=9C=20?= =?UTF-8?q?=EC=9E=A5=EB=A7=8C=20=ED=95=9C=20=EB=B2=88=20=EB=8D=94=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 같은 커밋에 보이는 사진 2장 이상은 batch, 1장은 단건 API로 보냄 - batch 항목 실패는 실패한 장만 한 번 더 보내고 그래도 실패하면 끝냄 - 요청 자체가 실패하면 단건으로 나눠 보내지 않음 - 쓰이지 않던 사진 batch 시딩 코드 제거 Co-Authored-By: Claude Opus 5.5 --- src/hooks/usePlacePhotoUrl.test.ts | 347 +++++------------- src/hooks/usePlacePhotoUrl.ts | 81 +--- src/hooks/usePlacesSearch.test.ts | 8 +- src/lib/place-photo-query.ts | 67 ++-- src/lib/places/place-batch-cache.ts | 121 +----- src/lib/places/place-batch-loader.test.ts | 137 +++++++ src/lib/places/place-batch-loader.ts | 92 +++++ src/lib/places/place-queries.ts | 23 -- .../places/warmChatHistoryPlacePhotos.test.ts | 14 +- 9 files changed, 379 insertions(+), 511 deletions(-) create mode 100644 src/lib/places/place-batch-loader.test.ts create mode 100644 src/lib/places/place-batch-loader.ts diff --git a/src/hooks/usePlacePhotoUrl.test.ts b/src/hooks/usePlacePhotoUrl.test.ts index 426f592c..7cf31a97 100644 --- a/src/hooks/usePlacePhotoUrl.test.ts +++ b/src/hooks/usePlacePhotoUrl.test.ts @@ -1,4 +1,3 @@ -import { QueryClient, QueryObserver } from "@tanstack/react-query"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; const useQueryMock = vi.hoisted(() => vi.fn((options) => options)); @@ -26,23 +25,12 @@ vi.mock("@/lib/api/places", async (importOriginal) => { }; }); -let fetchAndSeedPlacePhotoUrls: typeof import("@/lib/places/place-batch-cache").fetchAndSeedPlacePhotoUrls; -let ensurePlacePhotoUrlsCached: typeof import("@/lib/places/place-batch-cache").ensurePlacePhotoUrlsCached; -let placePhotoUrlQueryKey: typeof import("@/lib/place-photo-query").placePhotoUrlQueryKey; -let seededPlacePhotoUrlQueryOptions: typeof import("@/lib/place-photo-query").seededPlacePhotoUrlQueryOptions; -let registerQueryClient: typeof import("@/lib/query-client").registerQueryClient; let createQueryClient: typeof import("@/lib/query-client").createQueryClient; let HttpError: typeof import("@/lib/api/errors").HttpError; let usePlacePhotoUrlQuery: typeof import("@/hooks/usePlacePhotoUrl").usePlacePhotoUrlQuery; beforeAll(async () => { - ({ fetchAndSeedPlacePhotoUrls, ensurePlacePhotoUrlsCached } = await import( - "@/lib/places/place-batch-cache" - )); - ({ placePhotoUrlQueryKey, seededPlacePhotoUrlQueryOptions } = await import( - "@/lib/place-photo-query" - )); - ({ registerQueryClient, createQueryClient } = await import("@/lib/query-client")); + ({ createQueryClient } = await import("@/lib/query-client")); ({ HttpError } = await import("@/lib/api/errors")); ({ usePlacePhotoUrlQuery } = await import("@/hooks/usePlacePhotoUrl")); }); @@ -53,288 +41,145 @@ beforeEach(() => { requestPlacePhotoUrlsBatchMock.mockReset(); }); +function photoQueryOptions(googlePlaceId: string) { + usePlacePhotoUrlQuery(googlePlaceId); + const options = useQueryMock.mock.lastCall?.[0]; + if (!options) throw new Error("useQuery was not called"); + return options; +} + +/** 같은 커밋에 카드 여러 장이 마운트된 것처럼 한 태스크 안에서 조회를 시작한다 */ +function fetchCards(queryClient: ReturnType, ids: string[]) { + return Promise.allSettled(ids.map((id) => queryClient.fetchQuery(photoQueryOptions(id)))); +} + +const ok = (googlePlaceId: string) => ({ + status: "OK" as const, + googlePlaceId, + photoUrl: `https://cdn.example/${googlePlaceId}.jpg`, +}); +const failed = (googlePlaceId: string) => ({ + status: "FAILED" as const, + googlePlaceId, + errorCode: "EXTERNAL_API_ERROR", +}); + describe("usePlacePhotoUrlQuery", () => { - it("requests a cold single photo directly and reuses the shared cache", async () => { - const queryClient = new QueryClient(); - registerQueryClient(queryClient); - requestPlacePhotoUrlsBatchMock.mockResolvedValue([]); + it("사진 1장은 단건 API로 받고 같은 캐시를 다시 쓴다", async () => { + const queryClient = createQueryClient(); requestPlacePhotoUrlMock.mockResolvedValue("https://cdn.example/single.jpg"); - usePlacePhotoUrlQuery(" ChIJ-single "); - const options = useQueryMock.mock.lastCall![0]; + const options = photoQueryOptions(" ChIJ-single "); + await expect(queryClient.fetchQuery(options)).resolves.toBe("https://cdn.example/single.jpg"); await expect(queryClient.fetchQuery(options)).resolves.toBe("https://cdn.example/single.jpg"); expect(requestPlacePhotoUrlMock).toHaveBeenCalledExactlyOnceWith("ChIJ-single"); expect(requestPlacePhotoUrlsBatchMock).not.toHaveBeenCalled(); }); - it("reuses a batch-seeded photo without issuing a single request", async () => { - const queryClient = new QueryClient(); - registerQueryClient(queryClient); - requestPlacePhotoUrlsBatchMock.mockResolvedValue([{status:"OK",googlePlaceId:"ChIJ-seeded",photoUrl:"https://cdn.example/seeded.jpg"}]); - await fetchAndSeedPlacePhotoUrls(["ChIJ-seeded"],queryClient); - usePlacePhotoUrlQuery("ChIJ-seeded"); - await expect(queryClient.fetchQuery(useQueryMock.mock.lastCall![0])).resolves.toBe("https://cdn.example/seeded.jpg"); - expect(requestPlacePhotoUrlMock).not.toHaveBeenCalled(); - expect(requestPlacePhotoUrlsBatchMock).toHaveBeenCalledTimes(1); - }); - - it("authoritative empty 항목만 캐시하고 per-item FAILED는 cache miss로 남긴다", async () => { - const queryClient = new QueryClient(); - registerQueryClient(queryClient); - const emptyId = "ChIJ-authoritative-empty"; - const failedId = "ChIJ-per-item-failed"; + it("같은 커밋에 마운트된 카드 사진은 batch 하나로 받는다", async () => { + const queryClient = createQueryClient(); requestPlacePhotoUrlsBatchMock.mockResolvedValueOnce([ - { status: "OK", googlePlaceId: emptyId }, - { - status: "FAILED", - googlePlaceId: failedId, - errorCode: "UPSTREAM_TIMEOUT", - }, + ok("ChIJ-a"), + { status: "OK", googlePlaceId: "ChIJ-b", photoUrl: null }, + ok("ChIJ-c"), ]); - await fetchAndSeedPlacePhotoUrls([emptyId, failedId], queryClient); + const results = await fetchCards(queryClient, ["ChIJ-a", "ChIJ-b", "ChIJ-c"]); - expect(queryClient.getQueryData(placePhotoUrlQueryKey(emptyId))).toBe(""); - expect( - queryClient.getQueryData(placePhotoUrlQueryKey(failedId)), - ).toBeUndefined(); + expect(results).toEqual([ + { status: "fulfilled", value: "https://cdn.example/ChIJ-a.jpg" }, + { status: "fulfilled", value: "" }, + { status: "fulfilled", value: "https://cdn.example/ChIJ-c.jpg" }, + ]); + expect(requestPlacePhotoUrlsBatchMock).toHaveBeenCalledExactlyOnceWith(["ChIJ-a", "ChIJ-b", "ChIJ-c"]); + expect(requestPlacePhotoUrlMock).not.toHaveBeenCalled(); }); - it("mixed batch의 FAILED 항목은 빈 URL로 고정하지 않고 단건 GET으로 fallback한다", async () => { - const queryClient = new QueryClient(); - registerQueryClient(queryClient); - const okId = "ChIJ-ok"; - const failedId = "ChIJ-failed"; - const failedItem = { - status: "FAILED" as const, - googlePlaceId: failedId, - errorCode: "UPSTREAM_TIMEOUT", - }; + it("batch 항목이 실패하면 실패한 장만 batch로 한 번 더 받고, 그래도 실패하면 끝낸다", async () => { + const queryClient = createQueryClient(); requestPlacePhotoUrlsBatchMock - .mockResolvedValueOnce([ - { - status: "OK", - googlePlaceId: okId, - photoUrl: "https://cdn.example/ok.jpg", - }, - failedItem, - ]) - .mockResolvedValueOnce([failedItem]); - requestPlacePhotoUrlMock.mockResolvedValueOnce( - "https://cdn.example/fallback.jpg", - ); + .mockResolvedValueOnce([ok("ChIJ-a"), failed("ChIJ-b"), failed("ChIJ-c")]) + .mockResolvedValueOnce([ok("ChIJ-b"), failed("ChIJ-c")]); - await fetchAndSeedPlacePhotoUrls([okId, failedId], queryClient); + const [a, b, c] = await fetchCards(queryClient, ["ChIJ-a", "ChIJ-b", "ChIJ-c"]); - expect(queryClient.getQueryData(placePhotoUrlQueryKey(okId))).toBe( - "https://cdn.example/ok.jpg", - ); - expect(queryClient.getQueryData(placePhotoUrlQueryKey(failedId))).toBeUndefined(); + expect(a).toEqual({ status: "fulfilled", value: "https://cdn.example/ChIJ-a.jpg" }); + expect(b).toEqual({ status: "fulfilled", value: "https://cdn.example/ChIJ-b.jpg" }); + expect(c.status).toBe("rejected"); + expect(requestPlacePhotoUrlsBatchMock.mock.calls).toEqual([ + [["ChIJ-a", "ChIJ-b", "ChIJ-c"]], + [["ChIJ-b", "ChIJ-c"]], + ]); + expect(requestPlacePhotoUrlMock).not.toHaveBeenCalled(); + }); - usePlacePhotoUrlQuery(failedId); - const queryOptions = useQueryMock.mock.lastCall?.[0]; - const photoUrl = await queryClient.fetchQuery(queryOptions); + it("batch 429 → 총 1건: 다시 보내지 않는다", async () => { + const queryClient = createQueryClient(); + requestPlacePhotoUrlsBatchMock.mockRejectedValueOnce(new HttpError(429, "요청이 너무 많습니다")); - expect(photoUrl).toBe("https://cdn.example/fallback.jpg"); - expect(requestPlacePhotoUrlMock).toHaveBeenCalledWith(failedId); - expect(queryClient.getQueryData(placePhotoUrlQueryKey(failedId))).toBe( - "https://cdn.example/fallback.jpg", - ); + const results = await fetchCards(queryClient, ["ChIJ-a", "ChIJ-b"]); + + expect(results.map((result) => result.status)).toEqual(["rejected", "rejected"]); + expect(requestPlacePhotoUrlsBatchMock).toHaveBeenCalledTimes(1); + expect(requestPlacePhotoUrlMock).not.toHaveBeenCalled(); + }); + + it("항목 오류 + 단건 429 → 총 2건: 실패한 1장은 단건으로 한 번만 받는다", async () => { + const queryClient = createQueryClient(); + requestPlacePhotoUrlsBatchMock.mockResolvedValueOnce([ok("ChIJ-a"), failed("ChIJ-b")]); + requestPlacePhotoUrlMock.mockRejectedValueOnce(new HttpError(429, "요청이 너무 많습니다")); + + const [a, b] = await fetchCards(queryClient, ["ChIJ-a", "ChIJ-b"]); + + expect(a.status).toBe("fulfilled"); + expect(b.status).toBe("rejected"); + expect(requestPlacePhotoUrlsBatchMock).toHaveBeenCalledTimes(1); + expect(requestPlacePhotoUrlMock).toHaveBeenCalledExactlyOnceWith("ChIJ-b"); }); it.each([429, 404, 500, 502])("단건 GET이 %i면 다시 보내지 않는다", async (status) => { const queryClient = createQueryClient(); - registerQueryClient(queryClient); requestPlacePhotoUrlMock.mockRejectedValueOnce(new HttpError(status, "Place photo failed")); - usePlacePhotoUrlQuery("ChIJ-http-error"); - const queryOptions = useQueryMock.mock.lastCall?.[0]; - - await expect(queryClient.fetchQuery(queryOptions)).rejects.toThrow("Place photo failed"); + await expect(queryClient.fetchQuery(photoQueryOptions("ChIJ-http-error"))).rejects.toThrow( + "Place photo failed", + ); expect(requestPlacePhotoUrlMock).toHaveBeenCalledTimes(1); - expect(requestPlacePhotoUrlsBatchMock).not.toHaveBeenCalled(); }); it("일시적 네트워크 오류만 한 번 더 보낸다", async () => { const queryClient = createQueryClient(); - registerQueryClient(queryClient); requestPlacePhotoUrlMock .mockRejectedValueOnce(new TypeError("Failed to fetch")) .mockResolvedValueOnce("https://cdn.example/retried.jpg"); - usePlacePhotoUrlQuery("ChIJ-network-error"); - const queryOptions = useQueryMock.mock.lastCall?.[0]; - - await expect(queryClient.fetchQuery({ ...queryOptions, retryDelay: 0 })).resolves.toBe( - "https://cdn.example/retried.jpg", - ); + await expect( + queryClient.fetchQuery({ ...photoQueryOptions("ChIJ-network-error"), retryDelay: 0 }), + ).resolves.toBe("https://cdn.example/retried.jpg"); expect(requestPlacePhotoUrlMock).toHaveBeenCalledTimes(2); }); - it("seeded multi-photo query도 FAILED 캐시 miss를 빈 URL 성공으로 바꾸지 않는다", async () => { - const queryClient = new QueryClient(); - registerQueryClient(queryClient); - const okId = "ChIJ-multi-ok"; - const failedId = "ChIJ-multi-failed"; - requestPlacePhotoUrlsBatchMock.mockResolvedValueOnce([ - { - status: "OK", - googlePlaceId: okId, - photoUrl: "https://cdn.example/multi-ok.jpg", - }, - { - status: "FAILED", - googlePlaceId: failedId, - errorCode: "UPSTREAM_TIMEOUT", - }, - ]); - requestPlacePhotoUrlMock.mockResolvedValueOnce( - "https://cdn.example/multi-fallback.jpg", - ); - - await fetchAndSeedPlacePhotoUrls([okId, failedId], queryClient); - const options = seededPlacePhotoUrlQueryOptions(failedId); - - await expect(queryClient.fetchQuery(options)).resolves.toBe( - "https://cdn.example/multi-fallback.jpg", - ); - expect(requestPlacePhotoUrlMock).toHaveBeenCalledWith(failedId); - expect(queryClient.getQueryData(placePhotoUrlQueryKey(failedId))).toBe( - "https://cdn.example/multi-fallback.jpg", - ); - }); - - it("seeded multi-photo의 no-photo 캐시도 5분 후 단건 GET을 허용한다", async () => { - vi.useFakeTimers(); - vi.setSystemTime(new Date("2026-09-02T00:00:00.000Z")); - + it("사진 없음('')은 5분 동안 다시 묻지 않는다", async () => { + vi.useFakeTimers({ toFake: ["Date"] }); try { - const queryClient = new QueryClient(); - registerQueryClient(queryClient); - const id = "ChIJ-seeded-no-photo"; - requestPlacePhotoUrlsBatchMock.mockResolvedValueOnce([ - { status: "OK", googlePlaceId: id, photoUrl: null }, - ]); - await fetchAndSeedPlacePhotoUrls([id], queryClient); - - const options = seededPlacePhotoUrlQueryOptions(id); - await expect(queryClient.fetchQuery(options)).resolves.toBe(""); - expect(requestPlacePhotoUrlMock).not.toHaveBeenCalled(); + const now = new Date("2026-10-25T00:00:00.000Z").getTime(); + vi.setSystemTime(now); + const queryClient = createQueryClient(); + requestPlacePhotoUrlMock + .mockResolvedValueOnce("") + .mockResolvedValueOnce("https://cdn.example/available-later.jpg"); + const options = photoQueryOptions("ChIJ-no-photo"); - vi.advanceTimersByTime(5 * 60 * 1000 + 1); - requestPlacePhotoUrlMock.mockResolvedValueOnce( - "https://cdn.example/seeded-available-later.jpg", - ); - - const observer = new QueryObserver(queryClient, options); - const unsubscribe = observer.subscribe(() => undefined); - try { - await vi.waitFor(() => { - expect(requestPlacePhotoUrlMock).toHaveBeenCalledWith(id); - expect(queryClient.getQueryData(options.queryKey)).toBe( - "https://cdn.example/seeded-available-later.jpg", - ); - }); - } finally { - unsubscribe(); - } - } finally { - vi.useRealTimers(); - } - }); - - it("single no-photo is cached for five minutes then retried with a single GET", async () => { - vi.useFakeTimers(); - vi.setSystemTime(new Date("2026-09-02T00:00:00.000Z")); - try { - const queryClient = new QueryClient(); - registerQueryClient(queryClient); - const id = "ChIJ-no-photo"; - requestPlacePhotoUrlMock.mockResolvedValueOnce("").mockResolvedValueOnce("https://cdn.example/available-later.jpg"); - usePlacePhotoUrlQuery(id); - const options = useQueryMock.mock.lastCall![0]; await expect(queryClient.fetchQuery(options)).resolves.toBe(""); - vi.advanceTimersByTime(4 * 60 * 1000); + vi.setSystemTime(now + 4 * 60 * 1000); await expect(queryClient.fetchQuery(options)).resolves.toBe(""); expect(requestPlacePhotoUrlMock).toHaveBeenCalledTimes(1); - vi.advanceTimersByTime(60 * 1000 + 1); - await expect(queryClient.fetchQuery(options)).resolves.toBe("https://cdn.example/available-later.jpg"); - expect(requestPlacePhotoUrlMock).toHaveBeenCalledTimes(2); - expect(requestPlacePhotoUrlsBatchMock).not.toHaveBeenCalled(); - } finally { vi.useRealTimers(); } - }); - - it("만료된 no-photo 뒤 batch FAILED면 낡은 빈 값을 쓰지 않고 단건 fallback한다", async () => { - vi.useFakeTimers(); - vi.setSystemTime(new Date("2026-09-02T00:00:00.000Z")); - - try { - const queryClient = new QueryClient(); - registerQueryClient(queryClient); - const id = "ChIJ-expired-then-failed"; - requestPlacePhotoUrlsBatchMock - .mockResolvedValueOnce([ - { status: "OK", googlePlaceId: id, photoUrl: null }, - ]) - .mockResolvedValueOnce([ - { - status: "FAILED", - googlePlaceId: id, - errorCode: "UPSTREAM_TIMEOUT", - }, - ]); - requestPlacePhotoUrlMock.mockResolvedValueOnce( - "https://cdn.example/single-fallback.jpg", - ); - - await fetchAndSeedPlacePhotoUrls([id], queryClient); - usePlacePhotoUrlQuery(id); - const queryOptions = useQueryMock.mock.lastCall?.[0]; - await expect(queryClient.fetchQuery(queryOptions)).resolves.toBe(""); - - vi.advanceTimersByTime(5 * 60 * 1000 + 1); - await fetchAndSeedPlacePhotoUrls([id], queryClient); - - await expect(queryClient.fetchQuery(queryOptions)).resolves.toBe( - "https://cdn.example/single-fallback.jpg", - ); - expect(requestPlacePhotoUrlsBatchMock).toHaveBeenCalledTimes(2); - expect(requestPlacePhotoUrlMock).toHaveBeenCalledWith(id); - } finally { - vi.useRealTimers(); - } - }); - - it("중간 ensure 호출이 no-photo의 최초 5분 TTL을 연장하지 않는다", async () => { - vi.useFakeTimers(); - vi.setSystemTime(new Date("2026-09-02T00:00:00.000Z")); - try { - const queryClient = new QueryClient(); - registerQueryClient(queryClient); - const id = "ChIJ-no-photo-ensure"; - requestPlacePhotoUrlsBatchMock - .mockResolvedValueOnce([ - { status: "OK", googlePlaceId: id, photoUrl: null }, - ]) - .mockResolvedValueOnce([ - { - status: "OK", - googlePlaceId: id, - photoUrl: "https://cdn.example/available-after-ttl.jpg", - }, - ]); - - await ensurePlacePhotoUrlsCached([id], queryClient); - vi.advanceTimersByTime(4 * 60 * 1000); - await ensurePlacePhotoUrlsCached([id], queryClient); - vi.advanceTimersByTime(60 * 1000 + 1); - await ensurePlacePhotoUrlsCached([id], queryClient); - - expect(requestPlacePhotoUrlsBatchMock).toHaveBeenCalledTimes(2); - expect(queryClient.getQueryData(placePhotoUrlQueryKey(id))).toBe( - "https://cdn.example/available-after-ttl.jpg", + vi.setSystemTime(now + 5 * 60 * 1000 + 1); + await expect(queryClient.fetchQuery(options)).resolves.toBe( + "https://cdn.example/available-later.jpg", ); + expect(requestPlacePhotoUrlMock).toHaveBeenCalledTimes(2); } finally { vi.useRealTimers(); } diff --git a/src/hooks/usePlacePhotoUrl.ts b/src/hooks/usePlacePhotoUrl.ts index 47467def..e2f74f34 100644 --- a/src/hooks/usePlacePhotoUrl.ts +++ b/src/hooks/usePlacePhotoUrl.ts @@ -1,93 +1,20 @@ -import { useMemo } from "react"; -import { useQueries, useQuery } from "@tanstack/react-query"; +import { useQuery } from "@tanstack/react-query"; -import { requestPlacePhotoUrl } from "@/lib/api/places"; -import { fetchAndSeedPlacePhotoUrls } from "@/lib/places/place-batch-cache"; import { - PLACE_PHOTO_QUERY_GC_MS, + loadPlacePhotoUrl, placePhotoUrlQueryDefaults, placePhotoUrlQueryKey, - seededPlacePhotoUrlQueryOptions, } from "@/lib/place-photo-query"; -import { getQueryClient } from "@/lib/query-client"; export function usePlacePhotoUrlQuery( googlePlaceId: string | null | undefined, options?: { enabled?: boolean }, ) { const id = typeof googlePlaceId === "string" ? googlePlaceId.trim() : ""; - const queryKey = placePhotoUrlQueryKey(id); return useQuery({ - queryKey, - queryFn: () => requestPlacePhotoUrl(id), + queryKey: placePhotoUrlQueryKey(id), + queryFn: () => loadPlacePhotoUrl(id), enabled: id.length > 0 && (options?.enabled ?? true), ...placePhotoUrlQueryDefaults, }); } - -export function useSeededPlacePhotoUrlQuery( - googlePlaceId: string | null | undefined, - options?: { enabled?: boolean }, -) { - const id = typeof googlePlaceId === "string" ? googlePlaceId.trim() : ""; - return useQuery( - seededPlacePhotoUrlQueryOptions(id, { - enabled: (options?.enabled ?? true) && id.length > 0, - }), - ); -} - -/** - * 여러 googlePlaceId를 단일 카드와 동일한 `["places","photoUrl",id]` 캐시 키로 해석. - * 미캐시 id는 batch API 1회로 시딩 후 개별 query subscribe. - */ -export function usePlacePhotoUrlsQuery( - googlePlaceIds: readonly (string | null | undefined)[] | null | undefined, -) { - const ids = useMemo( - () => - (googlePlaceIds ?? []) - .map((id) => (typeof id === "string" ? id.trim() : "")) - .filter((id) => id.length > 0), - [googlePlaceIds], - ); - - const idsKey = ids.join("\0"); - - const seedQuery = useQuery({ - queryKey: ["places", "photoUrlsSeed", idsKey], - queryFn: async () => { - await fetchAndSeedPlacePhotoUrls(ids, getQueryClient()); - return true; - }, - enabled: ids.length > 0, - staleTime: Infinity, - gcTime: PLACE_PHOTO_QUERY_GC_MS, - refetchOnMount: false, - refetchOnWindowFocus: false, - refetchOnReconnect: false, - retry: 1, - }); - - const photosSeeded = ids.length === 0 || seedQuery.isSuccess; - - const results = useQueries({ - queries: ids.map((id) => - seededPlacePhotoUrlQueryOptions(id, { enabled: photosSeeded }), - ), - }); - - const photoUrls = useMemo( - () => - results - .map((r) => (typeof r.data === "string" ? r.data.trim() : "")) - .filter((u) => u.length > 0), - [results], - ); - - const isLoading = - ids.length > 0 && - (seedQuery.isLoading || !photosSeeded || results.some((r) => r.isLoading)); - - return { photoUrls, isLoading }; -} diff --git a/src/hooks/usePlacesSearch.test.ts b/src/hooks/usePlacesSearch.test.ts index 3aa9924a..c94bf21b 100644 --- a/src/hooks/usePlacesSearch.test.ts +++ b/src/hooks/usePlacesSearch.test.ts @@ -1,7 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const apiFetch = vi.fn(); -const fetchAndSeedPlacePhotoUrls = vi.fn(); vi.mock("@/lib/api/client", () => ({ apiFetch, @@ -11,14 +10,9 @@ vi.mock("@/lib/api/config", () => ({ API_BASE: "https://api.example.test", })); -vi.mock("@/lib/places/place-batch-cache", () => ({ - fetchAndSeedPlacePhotoUrls, -})); - describe("fetchPlacesPageWithPhotos", () => { beforeEach(() => { apiFetch.mockReset(); - fetchAndSeedPlacePhotoUrls.mockReset(); }); it("does not prefetch photo URLs for the full search result page", async () => { @@ -52,7 +46,7 @@ describe("fetchPlacesPageWithPhotos", () => { pageToken: undefined, }); - expect(fetchAndSeedPlacePhotoUrls).not.toHaveBeenCalled(); + expect(apiFetch).toHaveBeenCalledTimes(1); expect(page.items[0]?.googlePlaceId).toBe("ChIJ-place-1"); expect(page.items[0]).not.toHaveProperty("image"); }); diff --git a/src/lib/place-photo-query.ts b/src/lib/place-photo-query.ts index 9493cfeb..dd0f544a 100644 --- a/src/lib/place-photo-query.ts +++ b/src/lib/place-photo-query.ts @@ -1,13 +1,22 @@ import type { Query } from "@tanstack/react-query"; -import { requestPlacePhotoUrl } from "@/lib/api/places"; -import { getQueryClient } from "@/lib/query-client"; +import { isBatchItemOk } from "@/lib/api/batch-types"; +import { HttpError } from "@/lib/api/errors"; +import { + requestPlacePhotoUrl, + requestPlacePhotoUrlsBatch, + type PlacePhotoUrlBatchItem, +} from "@/lib/api/places"; +import { + createPlaceBatchLoader, + type PlaceBatchResult, +} from "@/lib/places/place-batch-loader"; /** * `usePlacePhotoUrlQuery` — 같은 `googlePlaceId`에 대해 불필요한 `GET /places/photos` 재호출 방지. */ -export const PLACE_PHOTO_QUERY_GC_MS = 7 * 24 * 60 * 60 * 1000; -export const PLACE_PHOTO_EMPTY_STALE_MS = 5 * 60 * 1000; +const PLACE_PHOTO_QUERY_GC_MS = 7 * 24 * 60 * 60 * 1000; +const PLACE_PHOTO_EMPTY_STALE_MS = 5 * 60 * 1000; export function placePhotoUrlQueryKey(googlePlaceId: string) { const id = typeof googlePlaceId === "string" ? googlePlaceId.trim() : ""; @@ -36,32 +45,30 @@ export const placePhotoUrlQueryDefaults = { refetchOnReconnect: false, } as const; -/** batch 시딩 후 구독 — 항목별 실패로 캐시 miss이면 단건 GET fallback */ -export function seededPlacePhotoUrlQueryOptions( - googlePlaceId: string, - options?: { enabled?: boolean }, -) { - const id = typeof googlePlaceId === "string" ? googlePlaceId.trim() : ""; - const enabled = (options?.enabled ?? true) && id.length > 0; +function photoBatchItemResult(item: PlacePhotoUrlBatchItem): PlaceBatchResult { + if (isBatchItemOk(item)) { + return { + ok: true, + value: typeof item.photoUrl === "string" ? item.photoUrl.trim() : "", + }; + } + // 항목 실패는 외부 API 실패(EXTERNAL_API_ERROR)일 때만 온다 — 단건으로 보냈으면 502였을 실패다 return { - queryKey: placePhotoUrlQueryKey(id), - queryFn: (): string | Promise => { - const state = getQueryClient()?.getQueryState( - placePhotoUrlQueryKey(id), - ); - const cached = state?.data; - if (typeof cached === "string") { - const trimmed = cached.trim(); - if ( - trimmed.length > 0 || - Date.now() - (state?.dataUpdatedAt ?? 0) < PLACE_PHOTO_EMPTY_STALE_MS - ) { - return trimmed; - } - } - return requestPlacePhotoUrl(id); - }, - enabled, - ...placePhotoUrlQueryDefaults, + ok: false, + error: new HttpError(502, `장소 사진 URL 조회 실패: ${item.errorCode ?? item.status}`), }; } + +/** 같은 커밋에 보이는 카드 사진을 모아 받는다 — 1장이면 단건, 2장 이상이면 batch */ +export const loadPlacePhotoUrl = createPlaceBatchLoader({ + fetchOne: (id) => requestPlacePhotoUrl(id), + fetchMany: async (ids) => + new Map( + (await requestPlacePhotoUrlsBatch(ids)).map( + (item): [string, PlaceBatchResult] => [ + item.googlePlaceId.trim(), + photoBatchItemResult(item), + ], + ), + ), +}); diff --git a/src/lib/places/place-batch-cache.ts b/src/lib/places/place-batch-cache.ts index 768826d7..d1c49940 100644 --- a/src/lib/places/place-batch-cache.ts +++ b/src/lib/places/place-batch-cache.ts @@ -1,20 +1,8 @@ import type { QueryClient } from "@tanstack/react-query"; -import type { - PlacePhotoUrlBatchItem, - PlacePreview, - PlacePreviewBatchItem, -} from "@/lib/api/places"; -import { - requestPlacePhotoUrlsBatch, - requestPlacePreviewsBatch, -} from "@/lib/api/places"; +import type { PlacePreview, PlacePreviewBatchItem } from "@/lib/api/places"; +import { requestPlacePreviewsBatch } from "@/lib/api/places"; import { isBatchItemError, isBatchItemOk } from "@/lib/api/batch-types"; -import { - PLACE_PHOTO_EMPTY_STALE_MS, - placePhotoUrlQueryDefaults, - placePhotoUrlQueryKey, -} from "@/lib/place-photo-query"; import { normalizePlacePreview, placePreviewQueryDefaults, @@ -29,7 +17,6 @@ const PLAN_PLACE_PREVIEW_ERROR_TITLE = "장소 정보를 불러올 수 없음"; /** 동일 googlePlaceId에 대한 in-flight batch 요청 공유 */ const inFlightPreviewSeedByPlaceId = new Map>(); -const inFlightPhotoSeedByPlaceId = new Map>(); function isUsablePreviewBatchItem(item: PlacePreviewBatchItem): boolean { if (isBatchItemError(item)) return false; @@ -156,22 +143,6 @@ export function reconcileSchedulePlanPlacesAfterPreviewSeed( } } -export function seedPlacePhotoUrlCaches( - queryClient: QueryClient, - items: readonly PlacePhotoUrlBatchItem[], -): void { - for (const item of items) { - if (!isBatchItemOk(item)) continue; - const id = - typeof item.googlePlaceId === "string" ? item.googlePlaceId.trim() : ""; - const url = typeof item.photoUrl === "string" ? item.photoUrl.trim() : ""; - if (!id.length) continue; - queryClient.setQueryData(placePhotoUrlQueryKey(id), url, { - updatedAt: Date.now(), - }); - } -} - function uncachedPreviewIds( queryClient: QueryClient, googlePlaceIds: readonly string[], @@ -187,28 +158,6 @@ function uncachedPreviewIds( return [...new Set(out)]; } -function uncachedPhotoPlaceIds( - queryClient: QueryClient, - googlePlaceIds: readonly string[], -): string[] { - const out: string[] = []; - for (const raw of googlePlaceIds) { - const id = typeof raw === "string" ? raw.trim() : ""; - if (!id.length) continue; - const state = queryClient.getQueryState(placePhotoUrlQueryKey(id)); - const cached = state?.data; - if (typeof cached === "string" && cached.trim().length > 0) continue; - if ( - typeof cached === "string" && - Date.now() - (state?.dataUpdatedAt ?? 0) < PLACE_PHOTO_EMPTY_STALE_MS - ) { - continue; - } - out.push(id); - } - return [...new Set(out)]; -} - async function fetchAndSeedUncachedPlacePreviews( missing: readonly string[], queryClient: QueryClient, @@ -285,68 +234,4 @@ export async function fetchAndSeedPlacePreviews( } } -export async function fetchAndSeedPlacePhotoUrls( - googlePlaceIds: readonly string[], - queryClient?: QueryClient | null, -): Promise { - const qc = queryClient ?? getQueryClient(); - if (!qc) return; - - const missing = uncachedPhotoPlaceIds(qc, googlePlaceIds); - if (!missing.length) return; - - const toFetch: string[] = []; - const waits: Promise[] = []; - - for (const id of missing) { - const inFlight = inFlightPhotoSeedByPlaceId.get(id); - if (inFlight) { - waits.push(inFlight); - continue; - } - if (!toFetch.includes(id)) { - toFetch.push(id); - } - } - - let fetchPromise: Promise | null = null; - if (toFetch.length) { - fetchPromise = (async () => { - try { - const results = await requestPlacePhotoUrlsBatch(toFetch); - seedPlacePhotoUrlCaches(qc, results); - } finally { - for (const id of toFetch) { - inFlightPhotoSeedByPlaceId.delete(id); - } - } - })(); - for (const id of toFetch) { - inFlightPhotoSeedByPlaceId.set(id, fetchPromise); - } - } - - const pending = [ - ...(fetchPromise ? [fetchPromise] : []), - ...waits.filter((promise) => promise !== fetchPromise), - ]; - if (pending.length) { - await Promise.all(pending); - } -} - -/** 미캐시·만료된 장소 사진 URL을 batch로 시딩 */ -export async function ensurePlacePhotoUrlsCached( - googlePlaceIds: readonly string[], - queryClient?: QueryClient | null, -): Promise { - const qc = queryClient ?? getQueryClient(); - if (!qc) return; - - await fetchAndSeedPlacePhotoUrls(googlePlaceIds, qc); -} - -export { - placePhotoUrlQueryDefaults, - placePreviewQueryDefaults, -}; +export { placePreviewQueryDefaults }; diff --git a/src/lib/places/place-batch-loader.test.ts b/src/lib/places/place-batch-loader.test.ts new file mode 100644 index 00000000..9083550c --- /dev/null +++ b/src/lib/places/place-batch-loader.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, it, vi } from "vitest"; + +import { createPlaceBatchLoader, type PlaceBatchResult } from "@/lib/places/place-batch-loader"; + +type Outcome = "ok" | "failed" | "missing"; + +/** id마다 응답 결과를 정한 batch 응답을 만든다 */ +function batchResponse(ids: string[], outcomeOf: (id: string) => Outcome) { + const results = new Map>(); + for (const id of ids) { + const outcome = outcomeOf(id); + if (outcome === "ok") results.set(id, { ok: true, value: `many:${id}` }); + if (outcome === "failed") results.set(id, { ok: false, error: new Error(`failed:${id}`) }); + } + return results; +} + +function createLoader(outcomeOf: (id: string, call: number) => Outcome = () => "ok") { + let call = 0; + const fetchOne = vi.fn(async (id: string) => `one:${id}`); + const fetchMany = vi.fn(async (ids: string[]) => { + call += 1; + return batchResponse(ids, (id) => outcomeOf(id, call)); + }); + return { fetchOne, fetchMany, load: createPlaceBatchLoader({ fetchOne, fetchMany }) }; +} + +describe("createPlaceBatchLoader", () => { + it("같은 태스크에서 들어온 id는 batch 하나로 보내고 같은 id는 한 번만 싣는다", async () => { + const { fetchOne, fetchMany, load } = createLoader(); + + await expect(Promise.all([load("a"), load("b"), load("a")])).resolves.toEqual([ + "many:a", + "many:b", + "many:a", + ]); + expect(fetchMany).toHaveBeenCalledExactlyOnceWith(["a", "b"]); + expect(fetchOne).not.toHaveBeenCalled(); + }); + + it("한 건만 들어오면 단건 API로 보낸다", async () => { + const { fetchOne, fetchMany, load } = createLoader(); + + await expect(load("a")).resolves.toBe("one:a"); + expect(fetchOne).toHaveBeenCalledExactlyOnceWith("a"); + expect(fetchMany).not.toHaveBeenCalled(); + }); + + it("다른 태스크에서 들어온 요청은 따로 보낸다", async () => { + const { fetchOne, load } = createLoader(); + + await load("a"); + await load("b"); + expect(fetchOne.mock.calls).toEqual([["a"], ["b"]]); + }); + + it("실패한 항목이 여러 개면 그 id만 batch로 한 번 더 보낸다", async () => { + const { fetchOne, fetchMany, load } = createLoader((id, call) => + call === 1 && id !== "a" ? "failed" : "ok", + ); + + await expect(Promise.all([load("a"), load("b"), load("c")])).resolves.toEqual([ + "many:a", + "many:b", + "many:c", + ]); + expect(fetchMany.mock.calls).toEqual([[["a", "b", "c"]], [["b", "c"]]]); + expect(fetchOne).not.toHaveBeenCalled(); + }); + + it("실패한 항목이 하나면 단건 API로 한 번 더 보낸다", async () => { + const { fetchOne, fetchMany, load } = createLoader((id) => (id === "b" ? "failed" : "ok")); + + await expect(Promise.all([load("a"), load("b")])).resolves.toEqual(["many:a", "one:b"]); + expect(fetchMany).toHaveBeenCalledTimes(1); + expect(fetchOne).toHaveBeenCalledExactlyOnceWith("b"); + }); + + it("다시 보낸 요청에서도 실패하면 그 id는 실패로 끝내고 더 보내지 않는다", async () => { + const { fetchOne, fetchMany, load } = createLoader((id) => (id === "a" ? "ok" : "failed")); + + const settled = await Promise.allSettled([load("a"), load("b"), load("c")]); + + expect(settled.map((result) => result.status)).toEqual(["fulfilled", "rejected", "rejected"]); + expect(fetchMany).toHaveBeenCalledTimes(2); + expect(fetchOne).not.toHaveBeenCalled(); + }); + + it("batch 응답에서 빠진 id도 실패처럼 한 번 더 받는다", async () => { + const { fetchMany, load } = createLoader((id, call) => + call === 1 && id !== "a" ? "missing" : "ok", + ); + + await expect(Promise.all([load("a"), load("b"), load("c")])).resolves.toEqual([ + "many:a", + "many:b", + "many:c", + ]); + expect(fetchMany.mock.calls).toEqual([[["a", "b", "c"]], [["b", "c"]]]); + }); + + it("첫 요청 자체가 실패하면 모은 id 모두 같은 에러로 실패하고 다시 보내지 않는다", async () => { + const failure = new Error("batch failed"); + const fetchMany = vi.fn(async () => { + throw failure; + }); + const fetchOne = vi.fn(async (id: string) => `one:${id}`); + const load = createPlaceBatchLoader({ fetchOne, fetchMany }); + + const settled = await Promise.allSettled([load("a"), load("b")]); + + expect(settled).toEqual([ + { status: "rejected", reason: failure }, + { status: "rejected", reason: failure }, + ]); + expect(fetchMany).toHaveBeenCalledTimes(1); + expect(fetchOne).not.toHaveBeenCalled(); + }); + + it("다시 보낸 요청 자체가 실패하면 실패한 id만 그 에러로 끝난다", async () => { + const failure = new Error("retry failed"); + const fetchMany = vi + .fn() + .mockResolvedValueOnce(batchResponse(["a", "b", "c"], (id) => (id === "a" ? "ok" : "failed"))) + .mockRejectedValueOnce(failure); + const fetchOne = vi.fn(async (id: string) => `one:${id}`); + const load = createPlaceBatchLoader({ fetchOne, fetchMany }); + + const settled = await Promise.allSettled([load("a"), load("b"), load("c")]); + + expect(settled).toEqual([ + { status: "fulfilled", value: "many:a" }, + { status: "rejected", reason: failure }, + { status: "rejected", reason: failure }, + ]); + }); +}); diff --git a/src/lib/places/place-batch-loader.ts b/src/lib/places/place-batch-loader.ts new file mode 100644 index 00000000..593e7617 --- /dev/null +++ b/src/lib/places/place-batch-loader.ts @@ -0,0 +1,92 @@ +/** batch 응답 항목 하나 — 받은 값, 또는 그 항목만의 실패 */ +export type PlaceBatchResult = + | { ok: true; value: T } + | { ok: false; error: unknown }; + +type Deferred = { + promise: Promise; + resolve: (value: T) => void; + reject: (error: unknown) => void; +}; + +function createDeferred(): Deferred { + let resolve: (value: T) => void = () => {}; + let reject: (error: unknown) => void = () => {}; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +/** + * 같은 태스크에서 요청된 장소 id를 모아 보낸다. 1건이면 단건 API, 2건 이상이면 batch API를 쓴다 — + * batch rate limit(사용자당 분당)이 단건보다 작아서 1건짜리 batch는 한도만 먼저 쓴다. + * 항목이 실패하면 실패한 id만 같은 방식으로 한 번 더 보내고, 그래도 실패하면 그 id는 실패로 끝낸다. + * 요청 자체가 실패하면 모은 id 모두 그 에러로 실패한다 — 다시 보낼지는 TanStack 재시도 규칙이 정한다. + */ +export function createPlaceBatchLoader({ + fetchOne, + fetchMany, +}: { + fetchOne: (id: string) => Promise; + fetchMany: (ids: string[]) => Promise>>; +}): (id: string) => Promise { + const pending = new Map>(); + let queued: string[] = []; + + function settle(id: string, result: PlaceBatchResult) { + const deferred = pending.get(id); + pending.delete(id); + if (!deferred) return; + if (result.ok) deferred.resolve(result.value); + else deferred.reject(result.error); + } + + async function request(ids: string[]): Promise>> { + if (ids.length > 1) return fetchMany(ids); + const [id] = ids; + return new Map>([[id, { ok: true, value: await fetchOne(id) }]]); + } + + async function flush() { + const ids = queued; + queued = []; + + let failedIds: string[]; + try { + const results = await request(ids); + failedIds = ids.filter((id) => { + const result = results.get(id); + if (!result?.ok) return true; + settle(id, result); + return false; + }); + } catch (error) { + for (const id of ids) settle(id, { ok: false, error }); + return; + } + if (!failedIds.length) return; + + try { + const retried = await request(failedIds); + for (const id of failedIds) { + settle(id, retried.get(id) ?? { ok: false, error: new Error(`batch 응답에 ${id}가 없음`) }); + } + } catch (error) { + for (const id of failedIds) settle(id, { ok: false, error }); + } + } + + return (id) => { + const inFlight = pending.get(id); + if (inFlight) return inFlight.promise; + + const deferred = createDeferred(); + pending.set(id, deferred); + // 같은 커밋에서 마운트된 카드들의 요청이 이번 태스크 안에 다 들어온 뒤 한 번에 보낸다 + if (!queued.length) setTimeout(() => void flush(), 0); + queued.push(id); + return deferred.promise; + }; +} diff --git a/src/lib/places/place-queries.ts b/src/lib/places/place-queries.ts index ecd7040a..6d369162 100644 --- a/src/lib/places/place-queries.ts +++ b/src/lib/places/place-queries.ts @@ -1,6 +1,5 @@ import { requestPlaceDetail, - requestPlacePhotoUrl, requestPlacePreview, type PlaceDetail, type PlacePreview, @@ -8,10 +7,6 @@ import { } from "@/lib/api/places"; import { normalizeGooglePlaceResourceId } from "@/lib/maps"; import { getQueryClient } from "@/lib/query-client"; -import { - placePhotoUrlQueryDefaults, - placePhotoUrlQueryKey, -} from "@/lib/place-photo-query"; export const placeDetailQueryDefaults = { staleTime: Infinity, @@ -172,21 +167,3 @@ export async function fetchPlacePreview(googlePlaceId: string): Promise { - const id = typeof googlePlaceId === "string" ? googlePlaceId.trim() : ""; - if (!id.length) { - throw new Error("fetchPlacePhotoUrl: empty googlePlaceId"); - } - - const queryClient = getQueryClient(); - if (!queryClient) { - return requestPlacePhotoUrl(id); - } - - return queryClient.fetchQuery({ - queryKey: placePhotoUrlQueryKey(id), - queryFn: () => requestPlacePhotoUrl(id), - ...placePhotoUrlQueryDefaults, - }); -} diff --git a/src/lib/places/warmChatHistoryPlacePhotos.test.ts b/src/lib/places/warmChatHistoryPlacePhotos.test.ts index 2b0fe86e..6c668fe8 100644 --- a/src/lib/places/warmChatHistoryPlacePhotos.test.ts +++ b/src/lib/places/warmChatHistoryPlacePhotos.test.ts @@ -1,15 +1,18 @@ import { QueryClient } from "@tanstack/react-query"; import { beforeEach, describe, expect, it, vi } from "vitest"; -const fetchAndSeedPlacePhotoUrls = vi.fn(); +const requestPlacePhotoUrl = vi.fn(); +const requestPlacePhotoUrlsBatch = vi.fn(); -vi.mock("@/lib/places/place-batch-cache", () => ({ - fetchAndSeedPlacePhotoUrls, +vi.mock("@/lib/api/places", () => ({ + requestPlacePhotoUrl, + requestPlacePhotoUrlsBatch, })); describe("warmPlacePhotoQueriesFromChatHistory", () => { beforeEach(() => { - fetchAndSeedPlacePhotoUrls.mockReset(); + requestPlacePhotoUrl.mockReset(); + requestPlacePhotoUrlsBatch.mockReset(); }); it("does not warm photo URLs before place cards enter the viewport", async () => { @@ -57,6 +60,7 @@ describe("warmPlacePhotoQueriesFromChatHistory", () => { }, ]); - expect(fetchAndSeedPlacePhotoUrls).not.toHaveBeenCalled(); + expect(requestPlacePhotoUrl).not.toHaveBeenCalled(); + expect(requestPlacePhotoUrlsBatch).not.toHaveBeenCalled(); }); }); From b08dc5c750c06ea9c672cfa98b2c140ddc8a823b Mon Sep 17 00:00:00 2001 From: KangShinGyu Date: Sun, 25 Oct 2026 19:22:03 +0900 Subject: [PATCH 03/18] =?UTF-8?q?feat(places):=20=EC=A7=80=EB=8F=84=20?= =?UTF-8?q?=EB=B6=81=EB=A7=88=ED=81=AC=20=ED=95=80=C2=B7=EB=B6=81=EB=A7=88?= =?UTF-8?q?=ED=81=AC=20=EB=AF=B8=EB=A6=AC=EB=B3=B4=EA=B8=B0=EB=A5=BC=20bat?= =?UTF-8?q?ch=EB=A1=9C=20=EC=A1=B0=ED=9A=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 같은 커밋에 마운트된 미리보기는 batch로 모으고 실패한 곳만 한 번 더 조회 - 지도 핀이 batch를 먼저 보낸 뒤 실패하면 전부 단건으로 다시 받던 단계 제거 Co-Authored-By: Claude Opus 5.5 --- src/components/map/MapBookmarkPins.tsx | 39 ++------ .../map/map-bookmark-preview-query.test.ts | 85 ++---------------- .../map/map-bookmark-preview-query.ts | 48 +--------- src/lib/places/place-queries.test.ts | 90 +++++++++++++++++++ src/lib/places/place-queries.ts | 38 +++++++- 5 files changed, 142 insertions(+), 158 deletions(-) create mode 100644 src/lib/places/place-queries.test.ts diff --git a/src/components/map/MapBookmarkPins.tsx b/src/components/map/MapBookmarkPins.tsx index bc73ea4a..68ef26fe 100644 --- a/src/components/map/MapBookmarkPins.tsx +++ b/src/components/map/MapBookmarkPins.tsx @@ -1,8 +1,8 @@ "use client"; import { Bookmark } from "lucide-react"; -import { useEffect, useMemo, useState } from "react"; -import { useQueries, useQueryClient } from "@tanstack/react-query"; +import { useMemo } from "react"; +import { useQueries } from "@tanstack/react-query"; import { AdvancedMarker } from "@vis.gl/react-google-maps"; import { useSelectedPlace } from "@/contexts/SelectedPlaceContext"; @@ -10,10 +10,7 @@ import { useAllRoomBookmarks, useBookmarkCategories } from "@/hooks/useRooms"; import { normalizeGooglePlaceResourceId } from "@/lib/maps"; import type { PlacePreview } from "@/lib/api/places"; -import { - buildMapBookmarkPreviewPlan, - settleMapBookmarkPreviewBatch, -} from "./map-bookmark-preview-query"; +import { buildMapBookmarkPreviewPlan } from "./map-bookmark-preview-query"; import { MAP_PIN_BORDER_STROKE, MAP_PIN_BORDER_STROKE_WIDTH, @@ -50,7 +47,6 @@ export function MapBookmarkPins({ enabled, hiddenNormalizedPlaceIds, }: MapBookmarkPinsProps) { - const queryClient = useQueryClient(); const { selectedPlace, setSelectedPlace } = useSelectedPlace(); const { data: categories, isSuccess: categoriesReady } = @@ -92,34 +88,11 @@ export function MapBookmarkPins({ return out; }, [allBookmarks, categoryColorById, listsSettled]); - const [settledPreviewIdsKey, setSettledPreviewIdsKey] = useState( - null, - ); const previewPlan = useMemo( - () => - buildMapBookmarkPreviewPlan( - flattenedBookmarks, - hiddenNormalizedPlaceIds, - settledPreviewIdsKey, - ), - [flattenedBookmarks, hiddenNormalizedPlaceIds, settledPreviewIdsKey], + () => buildMapBookmarkPreviewPlan(flattenedBookmarks, hiddenNormalizedPlaceIds), + [flattenedBookmarks, hiddenNormalizedPlaceIds], ); - const { visibleBookmarks, previewIdsKey } = previewPlan; - - useEffect(() => { - if (!previewIdsKey.length) return; - - let cancelled = false; - void settleMapBookmarkPreviewBatch(previewIdsKey, queryClient).then( - (settledKey) => { - if (!cancelled) setSettledPreviewIdsKey(settledKey); - }, - ); - - return () => { - cancelled = true; - }; - }, [previewIdsKey, queryClient]); + const { visibleBookmarks } = previewPlan; const placeQueryDefs = useMemo(() => { if (!roomId || !listsSettled || visibleBookmarks.length === 0) { diff --git a/src/components/map/map-bookmark-preview-query.test.ts b/src/components/map/map-bookmark-preview-query.test.ts index 66210e16..9c9aef30 100644 --- a/src/components/map/map-bookmark-preview-query.test.ts +++ b/src/components/map/map-bookmark-preview-query.test.ts @@ -1,98 +1,27 @@ import { describe, expect, it, vi } from "vitest"; -import { QueryClient } from "@tanstack/react-query"; vi.mock("@/lib/places/place-queries", () => ({ placePreviewQueryOptions: (googlePlaceId: string) => ({ queryKey: ["places", "preview", googlePlaceId], queryFn: async () => ({ googlePlaceId }), enabled: googlePlaceId.trim().length > 0, - staleTime: 60_000, - refetchOnWindowFocus: false, - retry: 1, }), })); -vi.mock("@/lib/places/place-batch-cache", () => ({ - fetchAndSeedPlacePreviews: vi.fn(), -})); - -import { - buildMapBookmarkPreviewPlan, - mapBookmarkPreviewQueryOptions, - settleMapBookmarkPreviewBatch, -} from "./map-bookmark-preview-query"; - -describe("mapBookmarkPreviewQueryOptions", () => { - it("벌크 preview 시딩이 끝나기 전에는 개별 preview 요청을 비활성화한다", () => { - const options = mapBookmarkPreviewQueryOptions("ChIJ-bookmark", false); - - expect(options.enabled).toBe(false); - }); - - it("벌크 preview 시딩이 끝난 뒤에는 캐시 miss 개별 조회를 허용한다", () => { - const options = mapBookmarkPreviewQueryOptions("ChIJ-bookmark", true); - - expect(options.enabled).toBe(true); - }); +import { buildMapBookmarkPreviewPlan } from "./map-bookmark-preview-query"; - it("숨길 북마크를 제외하고 벌크가 끝날 때까지 개별 조회를 차단한다", () => { +describe("buildMapBookmarkPreviewPlan", () => { + it("숨길 북마크를 빼고 남은 북마크의 미리보기 쿼리를 바로 연다", () => { const rows = [ { bookmarkId: 1, googlePlaceId: "places/ChIJ-hidden" }, { bookmarkId: 2, googlePlaceId: "ChIJ-visible" }, ]; - const pendingPlan = buildMapBookmarkPreviewPlan( - rows, - new Set(["ChIJ-hidden"]), - null, - ); + const plan = buildMapBookmarkPreviewPlan(rows, new Set(["ChIJ-hidden"])); - expect(pendingPlan.visibleBookmarks).toEqual([rows[1]]); - expect(pendingPlan.previewIdsKey).toBe("ChIJ-visible"); - expect(pendingPlan.queryOptions.map((options) => options.enabled)).toEqual([ - false, + expect(plan.visibleBookmarks).toEqual([rows[1]]); + expect(plan.queryOptions.map((options) => [options.queryKey, options.enabled])).toEqual([ + [["places", "preview", "ChIJ-visible"], true], ]); - - const settledPlan = buildMapBookmarkPreviewPlan( - rows, - new Set(["ChIJ-hidden"]), - pendingPlan.previewIdsKey, - ); - expect(settledPlan.queryOptions.map((options) => options.enabled)).toEqual([ - true, - ]); - }); - - it("현재 ID 벌크가 정착한 뒤에만 해당 key를 반환하고 실패해도 fallback을 연다", async () => { - const queryClient = new QueryClient(); - let resolveBatch: (() => void) | undefined; - const loader = vi.fn( - () => - new Promise((resolve) => { - resolveBatch = resolve; - }), - ); - - const settlement = settleMapBookmarkPreviewBatch( - "ChIJ-a\u0000ChIJ-b", - queryClient, - loader, - ); - let settled = false; - void settlement.then(() => { - settled = true; - }); - - await Promise.resolve(); - expect(loader).toHaveBeenCalledWith(["ChIJ-a", "ChIJ-b"], queryClient); - expect(settled).toBe(false); - - resolveBatch?.(); - await expect(settlement).resolves.toBe("ChIJ-a\u0000ChIJ-b"); - - const failedLoader = vi.fn().mockRejectedValue(new Error("batch failed")); - await expect( - settleMapBookmarkPreviewBatch("ChIJ-c", queryClient, failedLoader), - ).resolves.toBe("ChIJ-c"); }); }); diff --git a/src/components/map/map-bookmark-preview-query.ts b/src/components/map/map-bookmark-preview-query.ts index 2bc8891d..ae2170c4 100644 --- a/src/components/map/map-bookmark-preview-query.ts +++ b/src/components/map/map-bookmark-preview-query.ts @@ -1,68 +1,24 @@ -import type { QueryClient } from "@tanstack/react-query"; - -import { fetchAndSeedPlacePreviews } from "@/lib/places/place-batch-cache"; import { placePreviewQueryOptions } from "@/lib/places/place-queries"; import { filterVisibleMapBookmarks } from "./filter-visible-map-bookmarks"; -const PREVIEW_IDS_KEY_SEPARATOR = "\u0000"; - type MapBookmarkRow = { googlePlaceId: string; }; -type PreviewBatchLoader = ( - googlePlaceIds: readonly string[], - queryClient?: QueryClient | null, -) => Promise; - -export function mapBookmarkPreviewQueryOptions( - googlePlaceId: string, - batchSettled: boolean, -) { - const options = placePreviewQueryOptions(googlePlaceId); - return { - ...options, - enabled: batchSettled && options.enabled, - }; -} - +/** 핀으로 그릴 북마크와 그 미리보기 쿼리 — 같은 커밋에 마운트된 미리보기 요청은 batch 하나로 모인다 */ export function buildMapBookmarkPreviewPlan( rows: readonly T[], hiddenNormalizedPlaceIds: ReadonlySet | undefined, - settledPreviewIdsKey: string | null, ) { const visibleBookmarks = filterVisibleMapBookmarks( rows, hiddenNormalizedPlaceIds, ); - const previewIds = visibleBookmarks.map((row) => row.googlePlaceId); - const previewIdsKey = previewIds.join(PREVIEW_IDS_KEY_SEPARATOR); - const batchSettled = - previewIds.length === 0 || settledPreviewIdsKey === previewIdsKey; - return { visibleBookmarks, - previewIdsKey, queryOptions: visibleBookmarks.map((row) => - mapBookmarkPreviewQueryOptions(row.googlePlaceId, batchSettled), + placePreviewQueryOptions(row.googlePlaceId), ), }; } - -export async function settleMapBookmarkPreviewBatch( - previewIdsKey: string, - queryClient: QueryClient, - loader: PreviewBatchLoader = fetchAndSeedPlacePreviews, -): Promise { - const previewIds = previewIdsKey.length - ? previewIdsKey.split(PREVIEW_IDS_KEY_SEPARATOR) - : []; - - try { - await loader(previewIds, queryClient); - } catch { - // 벌크 실패 시 key를 정착시켜 React Query의 개별 조회 fallback을 허용합니다. - } - return previewIdsKey; -} diff --git a/src/lib/places/place-queries.test.ts b/src/lib/places/place-queries.test.ts new file mode 100644 index 00000000..7bc82596 --- /dev/null +++ b/src/lib/places/place-queries.test.ts @@ -0,0 +1,90 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const requestPlacePreview = vi.fn(); +const requestPlacePreviewsBatch = vi.fn(); + +vi.mock("@/lib/api/places", () => ({ + requestPlaceDetail: vi.fn(), + requestPlacePreview, + requestPlacePreviewsBatch, +})); + +const previewItem = (googlePlaceId: string) => ({ + googlePlaceId, + name: `이름 ${googlePlaceId}`, + formattedAddress: "서울", + location: { latitude: 37.5, longitude: 127.0 }, + primaryType: "cafe", + primaryTypeDisplayName: "카페", +}); +const failed = (googlePlaceId: string) => ({ + status: "FAILED", + googlePlaceId, + errorCode: "EXTERNAL_API_ERROR", +}); + +/** 같은 커밋에 미리보기 쿼리 여러 개가 마운트된 것처럼 한 태스크 안에서 queryFn을 부른다 */ +async function loadPreviews(ids: string[]) { + const { placePreviewQueryOptions } = await import("@/lib/places/place-queries"); + return Promise.allSettled(ids.map((id) => placePreviewQueryOptions(id).queryFn())); +} + +describe("placePreviewQueryOptions", () => { + beforeEach(() => { + requestPlacePreview.mockReset(); + requestPlacePreviewsBatch.mockReset(); + }); + + it("같은 커밋의 미리보기 쿼리는 batch 하나로 받고, 응답 id 형식이 달라도 같은 장소로 맞춘다", async () => { + requestPlacePreviewsBatch.mockResolvedValueOnce([ + { status: "OK", ...previewItem("places/ChIJ-a") }, + { status: "OK", ...previewItem("ChIJ-b") }, + ]); + + const [a, b] = await loadPreviews(["ChIJ-a", "ChIJ-b"]); + + expect(requestPlacePreviewsBatch).toHaveBeenCalledExactlyOnceWith(["ChIJ-a", "ChIJ-b"]); + expect(a).toMatchObject({ + status: "fulfilled", + value: { name: "이름 places/ChIJ-a", location: { lat: 37.5, lng: 127.0 } }, + }); + expect(b).toMatchObject({ status: "fulfilled", value: { name: "이름 ChIJ-b" } }); + expect(requestPlacePreview).not.toHaveBeenCalled(); + }); + + it("FAILED 항목은 실패한 곳만 batch로 한 번 더 받고, 그래도 실패하면 502로 끝낸다", async () => { + const { HttpError } = await import("@/lib/api/errors"); + requestPlacePreviewsBatch + .mockResolvedValueOnce([ + { status: "OK", ...previewItem("ChIJ-ok") }, + failed("ChIJ-retried"), + failed("ChIJ-failed"), + ]) + .mockResolvedValueOnce([{ status: "OK", ...previewItem("ChIJ-retried") }, failed("ChIJ-failed")]); + + const [ok, retried, stillFailed] = await loadPreviews(["ChIJ-ok", "ChIJ-retried", "ChIJ-failed"]); + + expect(requestPlacePreviewsBatch.mock.calls).toEqual([ + [["ChIJ-ok", "ChIJ-retried", "ChIJ-failed"]], + [["ChIJ-retried", "ChIJ-failed"]], + ]); + expect(ok.status).toBe("fulfilled"); + expect(retried.status).toBe("fulfilled"); + expect(stillFailed.status).toBe("rejected"); + if (stillFailed.status === "rejected") { + expect(stillFailed.reason).toBeInstanceOf(HttpError); + expect(stillFailed.reason).toMatchObject({ status: 502 }); + } + expect(requestPlacePreview).not.toHaveBeenCalled(); + }); + + it("미리보기 1곳은 단건 API로 받는다", async () => { + requestPlacePreview.mockResolvedValueOnce(previewItem("ChIJ-single")); + + const [single] = await loadPreviews(["places/ChIJ-single"]); + + expect(single).toMatchObject({ status: "fulfilled", value: { googlePlaceId: "ChIJ-single" } }); + expect(requestPlacePreview).toHaveBeenCalledExactlyOnceWith("ChIJ-single"); + expect(requestPlacePreviewsBatch).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/places/place-queries.ts b/src/lib/places/place-queries.ts index 6d369162..e55dd70e 100644 --- a/src/lib/places/place-queries.ts +++ b/src/lib/places/place-queries.ts @@ -1,11 +1,19 @@ +import { isBatchItemError, isBatchItemOk } from "@/lib/api/batch-types"; +import { HttpError } from "@/lib/api/errors"; import { requestPlaceDetail, requestPlacePreview, + requestPlacePreviewsBatch, type PlaceDetail, type PlacePreview, + type PlacePreviewBatchItem, type PlacePreviewResponse, } from "@/lib/api/places"; import { normalizeGooglePlaceResourceId } from "@/lib/maps"; +import { + createPlaceBatchLoader, + type PlaceBatchResult, +} from "@/lib/places/place-batch-loader"; import { getQueryClient } from "@/lib/query-client"; export const placeDetailQueryDefaults = { @@ -99,6 +107,33 @@ export function resolvePreviewGooglePlaceId(googlePlaceId: string): string { return raw.length ? normalizeGooglePlaceResourceId(raw) : ""; } +function previewBatchItemResult( + item: PlacePreviewBatchItem, +): PlaceBatchResult | null { + if (isBatchItemOk(item)) return { ok: true, value: normalizePlacePreview(item) }; + if (isBatchItemError(item)) { + // 항목 실패는 외부 API 실패(EXTERNAL_API_ERROR)일 때만 온다 — 단건으로 보냈으면 502였을 실패다 + return { + ok: false, + error: new HttpError(502, `장소 미리보기 조회 실패: ${item.errorCode ?? item.status}`), + }; + } + return null; +} + +/** 같은 커밋에 요청된 미리보기를 모아 받는다 — 1곳이면 단건, 2곳 이상이면 batch */ +const loadPlacePreviewInBatch = createPlaceBatchLoader({ + fetchOne: async (id) => normalizePlacePreview(await requestPlacePreview(id)), + fetchMany: async (ids) => { + const results = new Map>(); + for (const item of await requestPlacePreviewsBatch(ids)) { + const result = previewBatchItemResult(item); + if (result) results.set(resolvePreviewGooglePlaceId(item.googlePlaceId), result); + } + return results; + }, +}); + /** useQueries queryFn 등 중첩 fetchQuery 없이 preview API만 호출 */ export async function loadPlacePreview(googlePlaceId: string): Promise { const id = resolvePreviewGooglePlaceId(googlePlaceId); @@ -111,12 +146,13 @@ export async function loadPlacePreview(googlePlaceId: string): Promise loadPlacePreview(id), + queryFn: () => loadPlacePreviewInBatch(id), enabled: id.length > 0, ...placePreviewQueryDefaults, }; From b12a9040c2afa557ddcc54f2a3c6a5f139394160 Mon Sep 17 00:00:00 2001 From: KangShinGyu Date: Sun, 25 Oct 2026 19:28:53 +0900 Subject: [PATCH 04/18] =?UTF-8?q?fix(route):=20=EA=B2=BD=EB=A1=9C=20batch?= =?UTF-8?q?=EC=9D=98=20=EA=B2=BD=EB=A1=9C=20=EC=97=86=EC=9D=8C=20=EB=8B=B5?= =?UTF-8?q?=EC=9D=84=20=EB=8B=A8=EA=B1=B4=EC=9C=BC=EB=A1=9C=20=EB=8B=A4?= =?UTF-8?q?=EC=8B=9C=20=EB=AC=BB=EC=A7=80=20=EC=95=8A=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - NO_ROUTE·LAST_ITEM·SCHEDULE_ITEM_NOT_FOUND 구간은 경로 없음(null)으로 시딩 Co-Authored-By: Claude Opus 5.5 --- src/lib/plan/schedule-bulk-hydration.test.ts | 29 ++++++++++++++++++++ src/lib/plan/schedule-bulk-hydration.ts | 18 ++++++++---- 2 files changed, 41 insertions(+), 6 deletions(-) diff --git a/src/lib/plan/schedule-bulk-hydration.test.ts b/src/lib/plan/schedule-bulk-hydration.test.ts index 78fef7eb..850cbe25 100644 --- a/src/lib/plan/schedule-bulk-hydration.test.ts +++ b/src/lib/plan/schedule-bulk-hydration.test.ts @@ -194,6 +194,35 @@ describe("hydrateScheduleRoutesBatch", () => { expect(getScheduleItemRouteMock).toHaveBeenCalledTimes(2); }); + it.each(["NO_ROUTE", "LAST_ITEM", "SCHEDULE_ITEM_NOT_FOUND"])( + "경로 없음 답(%s)은 null로 시딩해 단건으로 다시 묻지 않는다", + async (errorCode) => { + 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, travelMode: "WALKING", errorCode }, + ]); + + await hydrateScheduleRoutesBatch(queryClient, ROOM_ID, SCHEDULE_ID, places); + + expect( + queryClient.getQueryData(scheduleItemRouteQueryKey(ROOM_ID, SCHEDULE_ID, 2, "WALKING")), + ).toBeNull(); + await expect( + resolveScheduleSegmentRoute({ + roomId: ROOM_ID, + scheduleId: SCHEDULE_ID, + segmentSourceItemId: 2, + travelMode: "WALKING", + }), + ).resolves.toBeNull(); + expect(getScheduleItemRouteMock).not.toHaveBeenCalled(); + }, + ); + it("응답 대기 중 일정이 바뀌면(지문 불일치) 늦게 도착한 결과를 버린다", async () => { const queryClient = new QueryClient(); queryClient.setQueryData( diff --git a/src/lib/plan/schedule-bulk-hydration.ts b/src/lib/plan/schedule-bulk-hydration.ts index fd7e85fa..3f547e68 100644 --- a/src/lib/plan/schedule-bulk-hydration.ts +++ b/src/lib/plan/schedule-bulk-hydration.ts @@ -228,6 +228,12 @@ function batchRouteItemToResponse( 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를 공유하지 않게 합니다. @@ -351,17 +357,17 @@ export async function hydrateScheduleRoutesBatch( if (!snapshotFp.length || snapshotFp !== currentFp) return; for (const result of results) { - if (!isBatchItemOk(result)) continue; - const route = batchRouteItemToResponse(result); const itemId = result.itemId; const mode = canonicalScheduleTravelMode(result.travelMode); if (!mode) continue; if (typeof itemId !== "number" || !Number.isFinite(itemId)) continue; - queryClient.setQueryData( - scheduleItemRouteQueryKey(rid, scheduleId, itemId, mode), - route, - ); + const routeKey = scheduleItemRouteQueryKey(rid, scheduleId, itemId, mode); + if (isBatchItemOk(result)) { + queryClient.setQueryData(routeKey, batchRouteItemToResponse(result)); + } else if (result.errorCode && ROUTE_ANSWER_ERROR_CODES.has(result.errorCode)) { + queryClient.setQueryData(routeKey, null); + } } } From 0763a36ccee1a754a8f44a23a5f16754ce033c0a Mon Sep 17 00:00:00 2001 From: KangShinGyu Date: Sun, 25 Oct 2026 19:33:58 +0900 Subject: [PATCH 05/18] =?UTF-8?q?fix(schedule):=20=EC=9D=BC=EC=A0=95=20?= =?UTF-8?q?=EB=B6=88=EB=9F=AC=EC=98=A4=EA=B8=B0=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EA=B0=99=EC=9D=80=20=EC=9E=A5=EC=86=8C=20=EB=AF=B8=EB=A6=AC?= =?UTF-8?q?=EB=B3=B4=EA=B8=B0=EB=A5=BC=20=EB=B0=98=EB=B3=B5=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=ED=95=98=EC=A7=80=20=EC=95=8A=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 모든 일차 장소를 batch 한 번으로 받고 실패한 곳만 batch로 한 번 더 조회, 단건 조회 제거 - 일차 블록은 일정 불러오기가 끝난 뒤 켜서 같은 장소를 다시 받지 않음 - 자리표시자가 남은 일차는 다시 들어올 때 그 장소만 batch로 다시 조회 - 미리보기 batch가 실패해도 일정 전체를 실패로 만들지 않고 자리표시자로 표시 Co-Authored-By: Claude Opus 5.5 --- src/hooks/useRooms.ts | 11 +- src/lib/places/place-batch-cache.ts | 136 ++---------------- src/lib/plan/schedule-bulk-hydration.ts | 29 +--- .../plan/schedule-preview-hydration.test.ts | 115 +++++++++++++++ 4 files changed, 138 insertions(+), 153 deletions(-) create mode 100644 src/lib/plan/schedule-preview-hydration.test.ts diff --git a/src/hooks/useRooms.ts b/src/hooks/useRooms.ts index f6668bc8..747b4b30 100644 --- a/src/hooks/useRooms.ts +++ b/src/hooks/useRooms.ts @@ -319,20 +319,25 @@ export function useSchedulePlanPlaces( const rid = roomId?.trim() ?? ""; const sid = scheduleId; const queryClient = useQueryClient(); - const { isSuccess: schedulesHydrated } = useRoomSchedules(rid || null); + const { isSuccess: schedulesHydrated, isFetching: schedulesFetching } = + useRoomSchedules(rid || null); return useQuery({ queryKey: scheduleItemsQueryKey(rid || null, sid), queryFn: () => ensureSchedulePlanPlacesEnriched(queryClient, rid, sid!), + // 일정 불러오기가 끝난 뒤에 켠다 — 진행 중에 켜지면 같은 장소의 미리보기를 일차마다 다시 받는다 enabled: rid.length > 0 && typeof sid === "number" && Number.isFinite(sid) && - schedulesHydrated, + schedulesHydrated && + !schedulesFetching, staleTime: Infinity, refetchOnMount: (query) => { const data = query.state.data; - return !Array.isArray(data) || planPlacesNeedPreviewEnrich(data); + if (!Array.isArray(data)) return true; + // 자리표시자('장소 정보를 불러올 수 없음')가 남은 일차는 다시 들어올 때 그 장소만 batch로 다시 받는다 + return planPlacesNeedPreviewEnrich(data) ? "always" : false; }, refetchOnWindowFocus: false, }); diff --git a/src/lib/places/place-batch-cache.ts b/src/lib/places/place-batch-cache.ts index d1c49940..2becad90 100644 --- a/src/lib/places/place-batch-cache.ts +++ b/src/lib/places/place-batch-cache.ts @@ -1,12 +1,10 @@ import type { QueryClient } from "@tanstack/react-query"; -import type { PlacePreview, PlacePreviewBatchItem } from "@/lib/api/places"; -import { requestPlacePreviewsBatch } from "@/lib/api/places"; -import { isBatchItemError, isBatchItemOk } from "@/lib/api/batch-types"; +import type { PlacePreview } from "@/lib/api/places"; import { - normalizePlacePreview, placePreviewQueryDefaults, placePreviewQueryKey, + placePreviewQueryOptions, resolvePreviewGooglePlaceId, } from "@/lib/places/place-queries"; import type { PlanPlace } from "@/lib/plan/types"; @@ -15,66 +13,6 @@ import { getQueryClient } from "@/lib/query-client"; const PLAN_PLACE_PREVIEW_ERROR_TITLE = "장소 정보를 불러올 수 없음"; -/** 동일 googlePlaceId에 대한 in-flight batch 요청 공유 */ -const inFlightPreviewSeedByPlaceId = new Map>(); - -function isUsablePreviewBatchItem(item: PlacePreviewBatchItem): boolean { - if (isBatchItemError(item)) return false; - if (isBatchItemOk(item)) return true; - - const record = item as { - googlePlaceId?: unknown; - name?: unknown; - status?: unknown; - }; - if (record.status === "ERROR") return false; - - const googlePlaceId = - typeof record.googlePlaceId === "string" ? record.googlePlaceId.trim() : ""; - const name = typeof record.name === "string" ? record.name.trim() : ""; - return googlePlaceId.length > 0 && name.length > 0; -} - -function previewBatchItemToNormalized( - item: PlacePreviewBatchItem, -): ReturnType | null { - if (!isUsablePreviewBatchItem(item)) return null; - - const record = item as { - googlePlaceId: string; - name: string; - formattedAddress?: string; - location?: { latitude: number; longitude: number } | null; - primaryType?: string; - primaryTypeDisplayName?: string; - }; - - return normalizePlacePreview({ - googlePlaceId: record.googlePlaceId, - name: record.name, - formattedAddress: record.formattedAddress ?? "", - location: record.location ?? null, - primaryType: record.primaryType ?? "", - primaryTypeDisplayName: record.primaryTypeDisplayName ?? "", - }); -} - -export function seedPlacePreviewCaches( - queryClient: QueryClient, - items: readonly PlacePreviewBatchItem[], -): void { - for (const item of items) { - const preview = previewBatchItemToNormalized(item); - if (!preview) continue; - const id = resolvePreviewGooglePlaceId(preview.googlePlaceId); - if (!id.length) continue; - queryClient.setQueryData(placePreviewQueryKey(id), preview, { - updatedAt: Date.now(), - }); - } - reconcileSchedulePlanPlacesAfterPreviewSeed(queryClient); -} - function readCachedPreviewForReconcile( queryClient: QueryClient, googlePlaceId: string, @@ -158,33 +96,10 @@ function uncachedPreviewIds( return [...new Set(out)]; } -async function fetchAndSeedUncachedPlacePreviews( - missing: readonly string[], - queryClient: QueryClient, -): Promise { - const results = await requestPlacePreviewsBatch(missing); - seedPlacePreviewCaches(queryClient, results); - - const byNormalizedId = new Map>(); - for (const item of results) { - const preview = previewBatchItemToNormalized(item); - if (!preview) continue; - const id = resolvePreviewGooglePlaceId(preview.googlePlaceId); - if (!id.length) continue; - byNormalizedId.set(id, preview); - } - - for (const requestedId of missing) { - const id = resolvePreviewGooglePlaceId(requestedId); - if (!id.length) continue; - const preview = byNormalizedId.get(id); - if (!preview) continue; - queryClient.setQueryData(placePreviewQueryKey(id), preview, { - updatedAt: Date.now(), - }); - } -} - +/** + * 캐시에 없는 장소 미리보기를 batch로 받아 캐시에 채운다(실패한 곳만 batch로 한 번 더). + * 그래도 실패한 곳은 던지지 않고 비워 둔다 — 일정에는 자리표시자로 보이고, 다음 진입 때 다시 받는다. + */ export async function fetchAndSeedPlacePreviews( googlePlaceIds: readonly string[], queryClient?: QueryClient | null, @@ -195,43 +110,8 @@ export async function fetchAndSeedPlacePreviews( const missing = uncachedPreviewIds(qc, googlePlaceIds); if (!missing.length) return; - const toFetch: string[] = []; - const waits: Promise[] = []; - - for (const id of missing) { - const inFlight = inFlightPreviewSeedByPlaceId.get(id); - if (inFlight) { - waits.push(inFlight); - continue; - } - if (!toFetch.includes(id)) { - toFetch.push(id); - } - } - - let fetchPromise: Promise | null = null; - if (toFetch.length) { - fetchPromise = (async () => { - try { - await fetchAndSeedUncachedPlacePreviews(toFetch, qc); - } finally { - for (const id of toFetch) { - inFlightPreviewSeedByPlaceId.delete(id); - } - } - })(); - for (const id of toFetch) { - inFlightPreviewSeedByPlaceId.set(id, fetchPromise); - } - } - - const pending = [ - ...(fetchPromise ? [fetchPromise] : []), - ...waits.filter((promise) => promise !== fetchPromise), - ]; - if (pending.length) { - await Promise.all(pending); - } + await Promise.allSettled(missing.map((id) => qc.fetchQuery(placePreviewQueryOptions(id)))); + reconcileSchedulePlanPlacesAfterPreviewSeed(qc); } export { placePreviewQueryDefaults }; diff --git a/src/lib/plan/schedule-bulk-hydration.ts b/src/lib/plan/schedule-bulk-hydration.ts index 3f547e68..ed36f174 100644 --- a/src/lib/plan/schedule-bulk-hydration.ts +++ b/src/lib/plan/schedule-bulk-hydration.ts @@ -13,7 +13,6 @@ import { } from "@/lib/places/place-batch-cache"; import type { PlacePreview } from "@/lib/api/places"; import { - fetchPlacePreview, placePreviewQueryKey, resolvePreviewGooglePlaceId, } from "@/lib/places/place-queries"; @@ -146,33 +145,19 @@ function buildPlanPlacesFromItemsWithCache( ); } -/** batch 시딩 후 preview 캐시 miss 항목만 GET `/preview`로 보강합니다. */ +/** preview 캐시 miss 항목만 batch로 받아 채웁니다 — 그래도 없는 장소는 단건으로 다시 받지 않고 자리표시자로 둡니다. */ export async function buildPlanPlacesFromItemsWithPreviewEnrichment( queryClient: QueryClient, items: RoomScheduleItem[], ): Promise { - const sorted = sortRoomScheduleItemsByOrder(items); - const placeIds = sorted + const placeIds = items .map((item) => item.googlePlaceId) .filter((id) => typeof id === "string" && id.trim().length > 0); if (placeIds.length) { await fetchAndSeedPlacePreviews(placeIds, queryClient); } - - const out: PlanPlace[] = []; - for (const item of sorted) { - let preview = readCachedPreview(queryClient, item.googlePlaceId); - if (!preview) { - try { - preview = await fetchPlacePreview(item.googlePlaceId); - } catch { - preview = null; - } - } - out.push(planPlaceFromItemAndPreview(item, preview)); - } - return out; + return buildPlanPlacesFromItemsWithCache(queryClient, items); } function collectGooglePlaceIdsFromSchedules( @@ -197,6 +182,7 @@ export async function hydrateScheduleItemsFromSchedulesWithItems( const rid = roomId.trim(); if (!rid.length) return; + // 모든 일차의 장소를 batch 한 번(실패한 곳만 한 번 더)으로 받고, 일차별로는 캐시에서만 채운다 const placeIds = collectGooglePlaceIdsFromSchedules(schedules); if (placeIds.length) { await fetchAndSeedPlacePreviews(placeIds, queryClient); @@ -207,11 +193,10 @@ export async function hydrateScheduleItemsFromSchedulesWithItems( if (typeof sid !== "number" || !Number.isFinite(sid)) continue; const items = Array.isArray(schedule.items) ? schedule.items : []; - const places = await buildPlanPlacesFromItemsWithPreviewEnrichment( - queryClient, - items, + queryClient.setQueryData( + scheduleItemsQueryKey(rid, sid), + buildPlanPlacesFromItemsWithCache(queryClient, items), ); - queryClient.setQueryData(scheduleItemsQueryKey(rid, sid), places); } } diff --git a/src/lib/plan/schedule-preview-hydration.test.ts b/src/lib/plan/schedule-preview-hydration.test.ts new file mode 100644 index 00000000..029e0844 --- /dev/null +++ b/src/lib/plan/schedule-preview-hydration.test.ts @@ -0,0 +1,115 @@ +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { RoomScheduleWithItems } from "@/lib/api/rooms/schedules"; +import type { PlanPlace } from "@/lib/plan/types"; + +const requestPlacePreviewsBatch = vi.hoisted(() => vi.fn()); +const requestPlacePreview = vi.hoisted(() => vi.fn()); + +vi.hoisted(() => { + process.env.NEXT_PUBLIC_API_BASE_URL = "http://localhost:8080"; + process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID = "test-client"; + process.env.NEXT_PUBLIC_GOOGLE_REDIRECT_URI = "http://localhost/callback"; + process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY = "test-maps-key"; +}); + +vi.mock("@/lib/api/places", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, requestPlacePreviewsBatch, requestPlacePreview }; +}); + +let hydrateScheduleItemsFromSchedulesWithItems: typeof import("@/lib/plan/schedule-bulk-hydration").hydrateScheduleItemsFromSchedulesWithItems; +let PLAN_PLACE_PREVIEW_ERROR_TITLE: string; +let createQueryClient: typeof import("@/lib/query-client").createQueryClient; +let scheduleItemsQueryKey: typeof import("@/lib/query-keys").scheduleItemsQueryKey; +let HttpError: typeof import("@/lib/api/errors").HttpError; + +beforeAll(async () => { + ({ hydrateScheduleItemsFromSchedulesWithItems, PLAN_PLACE_PREVIEW_ERROR_TITLE } = await import( + "@/lib/plan/schedule-bulk-hydration" + )); + ({ createQueryClient } = await import("@/lib/query-client")); + ({ scheduleItemsQueryKey } = await import("@/lib/query-keys")); + ({ HttpError } = await import("@/lib/api/errors")); +}); + +beforeEach(() => { + requestPlacePreviewsBatch.mockReset(); + requestPlacePreview.mockReset(); +}); + +const ROOM_ID = "room-1"; + +function schedule(scheduleId: number, googlePlaceIds: string[]): RoomScheduleWithItems { + return { + scheduleId, + roomId: ROOM_ID, + dayNumber: scheduleId, + date: "2026-11-01", + createdAt: "2026-10-01T00:00:00", + items: googlePlaceIds.map((googlePlaceId, orderIndex) => ({ + itemId: scheduleId * 100 + orderIndex, + scheduleId, + googlePlaceId, + startTime: null, + endTime: null, + orderIndex, + travelMode: "DRIVING", + createdAt: "2026-10-01T00:00:00", + })), + }; +} + +const ok = (googlePlaceId: string) => ({ + status: "OK", + googlePlaceId, + name: `이름 ${googlePlaceId}`, + formattedAddress: "서울", + location: { latitude: 37.5, longitude: 127.0 }, + primaryType: "cafe", + primaryTypeDisplayName: "카페", +}); +const failed = (googlePlaceId: string) => ({ + status: "FAILED", + googlePlaceId, + errorCode: "EXTERNAL_API_ERROR", +}); + +function titles(queryClient: ReturnType, scheduleId: number) { + return ( + queryClient.getQueryData(scheduleItemsQueryKey(ROOM_ID, scheduleId)) ?? [] + ).map((place) => place.title); +} + +describe("hydrateScheduleItemsFromSchedulesWithItems", () => { + it("모든 일차 장소를 batch 한 번으로 받고 실패한 곳만 batch로 한 번 더 받는다 — 단건으로는 받지 않는다", async () => { + const queryClient = createQueryClient(); + requestPlacePreviewsBatch + .mockResolvedValueOnce([ok("a"), failed("b"), ok("c"), failed("d")]) + .mockResolvedValueOnce([ok("b"), failed("d")]); + + await hydrateScheduleItemsFromSchedulesWithItems(queryClient, ROOM_ID, [ + schedule(1, ["a", "b"]), + schedule(2, ["c", "d"]), + ]); + + expect(requestPlacePreviewsBatch.mock.calls).toEqual([[["a", "b", "c", "d"]], [["b", "d"]]]); + expect(requestPlacePreview).not.toHaveBeenCalled(); + expect(titles(queryClient, 1)).toEqual(["이름 a", "이름 b"]); + expect(titles(queryClient, 2)).toEqual(["이름 c", PLAN_PLACE_PREVIEW_ERROR_TITLE]); + }); + + it("미리보기 batch 요청이 실패해도 일정은 자리표시자로 채우고 다시 보내지 않는다", async () => { + const queryClient = createQueryClient(); + requestPlacePreviewsBatch.mockRejectedValueOnce(new HttpError(429, "요청이 너무 많습니다")); + + await hydrateScheduleItemsFromSchedulesWithItems(queryClient, ROOM_ID, [schedule(1, ["a", "b"])]); + + expect(requestPlacePreviewsBatch).toHaveBeenCalledTimes(1); + expect(requestPlacePreview).not.toHaveBeenCalled(); + expect(titles(queryClient, 1)).toEqual([ + PLAN_PLACE_PREVIEW_ERROR_TITLE, + PLAN_PLACE_PREVIEW_ERROR_TITLE, + ]); + }); +}); From 056fe21969a4ff6846aab6906228cb4ae8620e21 Mon Sep 17 00:00:00 2001 From: KangShinGyu Date: Sun, 25 Oct 2026 21:47:14 +0900 Subject: [PATCH 06/18] =?UTF-8?q?fix(schedule):=20=EC=9D=BC=EC=A0=95=20?= =?UTF-8?q?=EB=B6=88=EB=9F=AC=EC=98=A4=EB=8A=94=20=EB=8F=99=EC=95=88=20?= =?UTF-8?q?=EC=9D=BC=EC=B0=A8=EC=97=90=20=EB=B9=88=20=EC=83=81=ED=83=9C=20?= =?UTF-8?q?=EB=8C=80=EC=8B=A0=20=EC=8A=A4=EC=BC=88=EB=A0=88=ED=86=A4=20?= =?UTF-8?q?=ED=91=9C=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 일차 쿼리를 일정 불러오기 중에도 켜고, 진행 중인 불러오기를 기다려 그 결과를 사용 - 같은 진입 안에서 장소 미리보기를 다시 받지 않음 Co-Authored-By: Claude Opus 5.5 --- src/hooks/useRooms.ts | 7 +- .../plan/schedule-preview-hydration.test.ts | 64 ++++++++++++++++++- src/lib/plan/scheduleItemPlaces.ts | 6 +- src/lib/rooms.ts | 22 +++++++ 4 files changed, 91 insertions(+), 8 deletions(-) diff --git a/src/hooks/useRooms.ts b/src/hooks/useRooms.ts index 747b4b30..3d4c2c5e 100644 --- a/src/hooks/useRooms.ts +++ b/src/hooks/useRooms.ts @@ -319,19 +319,16 @@ export function useSchedulePlanPlaces( const rid = roomId?.trim() ?? ""; const sid = scheduleId; const queryClient = useQueryClient(); - const { isSuccess: schedulesHydrated, isFetching: schedulesFetching } = - useRoomSchedules(rid || null); + const { isSuccess: schedulesHydrated } = useRoomSchedules(rid || null); return useQuery({ queryKey: scheduleItemsQueryKey(rid || null, sid), queryFn: () => ensureSchedulePlanPlacesEnriched(queryClient, rid, sid!), - // 일정 불러오기가 끝난 뒤에 켠다 — 진행 중에 켜지면 같은 장소의 미리보기를 일차마다 다시 받는다 enabled: rid.length > 0 && typeof sid === "number" && Number.isFinite(sid) && - schedulesHydrated && - !schedulesFetching, + schedulesHydrated, staleTime: Infinity, refetchOnMount: (query) => { const data = query.state.data; diff --git a/src/lib/plan/schedule-preview-hydration.test.ts b/src/lib/plan/schedule-preview-hydration.test.ts index 029e0844..0f187d01 100644 --- a/src/lib/plan/schedule-preview-hydration.test.ts +++ b/src/lib/plan/schedule-preview-hydration.test.ts @@ -19,17 +19,22 @@ vi.mock("@/lib/api/places", async (importOriginal) => { }); let hydrateScheduleItemsFromSchedulesWithItems: typeof import("@/lib/plan/schedule-bulk-hydration").hydrateScheduleItemsFromSchedulesWithItems; +let ensureSchedulePlanPlacesEnriched: typeof import("@/lib/plan/scheduleItemPlaces").ensureSchedulePlanPlacesEnriched; let PLAN_PLACE_PREVIEW_ERROR_TITLE: string; let createQueryClient: typeof import("@/lib/query-client").createQueryClient; let scheduleItemsQueryKey: typeof import("@/lib/query-keys").scheduleItemsQueryKey; +let roomSchedulesQueryKey: typeof import("@/lib/query-keys").roomSchedulesQueryKey; +let placePreviewQueryKey: typeof import("@/lib/places/place-queries").placePreviewQueryKey; let HttpError: typeof import("@/lib/api/errors").HttpError; beforeAll(async () => { ({ hydrateScheduleItemsFromSchedulesWithItems, PLAN_PLACE_PREVIEW_ERROR_TITLE } = await import( "@/lib/plan/schedule-bulk-hydration" )); + ({ ensureSchedulePlanPlacesEnriched } = await import("@/lib/plan/scheduleItemPlaces")); ({ createQueryClient } = await import("@/lib/query-client")); - ({ scheduleItemsQueryKey } = await import("@/lib/query-keys")); + ({ scheduleItemsQueryKey, roomSchedulesQueryKey } = await import("@/lib/query-keys")); + ({ placePreviewQueryKey } = await import("@/lib/places/place-queries")); ({ HttpError } = await import("@/lib/api/errors")); }); @@ -113,3 +118,60 @@ describe("hydrateScheduleItemsFromSchedulesWithItems", () => { ]); }); }); + +describe("ensureSchedulePlanPlacesEnriched", () => { + const placeholder = (googlePlaceId: string, itemId: number): PlanPlace => ({ + id: `item-${itemId}`, + itemId, + googlePlaceId, + title: PLAN_PLACE_PREVIEW_ERROR_TITLE, + subtitle: googlePlaceId, + }); + + it("일정 불러오기가 진행 중이면 끝날 때까지 기다려 그 결과를 쓰고, 미리보기를 다시 받지 않는다", async () => { + const queryClient = createQueryClient(); + let finishHydration: (schedules: RoomScheduleWithItems[]) => void = () => {}; + const hydration = queryClient.fetchQuery({ + queryKey: roomSchedulesQueryKey(ROOM_ID), + queryFn: () => + new Promise((resolve) => { + finishHydration = resolve; + }), + }); + + const enriching = ensureSchedulePlanPlacesEnriched(queryClient, ROOM_ID, 1); + const hydratedPlaces = [placeholder("a", 100)]; + queryClient.setQueryData(scheduleItemsQueryKey(ROOM_ID, 1), hydratedPlaces); + finishHydration([schedule(1, ["a"])]); + await hydration; + + await expect(enriching).resolves.toBe(hydratedPlaces); + expect(requestPlacePreviewsBatch).not.toHaveBeenCalled(); + expect(requestPlacePreview).not.toHaveBeenCalled(); + }); + + it("불러오기가 끝난 뒤 다시 들어오면 자리표시자인 장소만 다시 받는다", async () => { + const queryClient = createQueryClient(); + queryClient.setQueryData(roomSchedulesQueryKey(ROOM_ID), [schedule(1, ["a", "b"])]); + queryClient.setQueryData(scheduleItemsQueryKey(ROOM_ID, 1), [placeholder("a", 100)]); + queryClient.setQueryData(placePreviewQueryKey("b"), { + googlePlaceId: "b", + name: "이름 b", + formattedAddress: "서울", + }); + requestPlacePreview.mockResolvedValueOnce({ + googlePlaceId: "a", + name: "이름 a", + formattedAddress: "서울", + location: { latitude: 37.5, longitude: 127.0 }, + primaryType: "cafe", + primaryTypeDisplayName: "카페", + }); + + const places = await ensureSchedulePlanPlacesEnriched(queryClient, ROOM_ID, 1); + + expect(places.map((place) => place.title)).toEqual(["이름 a", "이름 b"]); + expect(requestPlacePreview).toHaveBeenCalledExactlyOnceWith("a"); + expect(requestPlacePreviewsBatch).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/plan/scheduleItemPlaces.ts b/src/lib/plan/scheduleItemPlaces.ts index 76d6ad4e..7a1b6d62 100644 --- a/src/lib/plan/scheduleItemPlaces.ts +++ b/src/lib/plan/scheduleItemPlaces.ts @@ -1,7 +1,7 @@ import type { QueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; -import { awaitRoomSchedulesHydrated } from "@/lib/rooms"; +import { awaitRoomSchedulesHydrated, waitForRoomSchedulesHydration } from "@/lib/rooms"; import { createScheduleItem, @@ -379,8 +379,10 @@ export async function ensureSchedulePlanPlacesEnriched( const rid = roomId.trim(); if (!rid.length) return []; const key = scheduleItemsQueryKey(rid, scheduleId); + // 일정 불러오기가 진행 중이면 그 결과를 쓴다 — 방금 batch로 받은 장소를 일차마다 다시 받지 않는다 + const waitedForHydration = await waitForRoomSchedulesHydration(queryClient, rid); const cached = queryClient.getQueryData(key); - if (cached != null && !planPlacesNeedPreviewEnrich(cached)) { + if (cached != null && (waitedForHydration || !planPlacesNeedPreviewEnrich(cached))) { return cached; } diff --git a/src/lib/rooms.ts b/src/lib/rooms.ts index 1044a6a5..33f706df 100644 --- a/src/lib/rooms.ts +++ b/src/lib/rooms.ts @@ -160,6 +160,28 @@ export async function awaitRoomSchedulesHydrated( }); } +/** + * `useRoomSchedules`의 일정 불러오기가 진행 중이면 끝날 때까지 기다리고 true를 돌려줍니다. + * 진행 중인 요청에 합류할 뿐 새로 보내지 않습니다 — 진행 중이 아니면 바로 false입니다. + */ +export async function waitForRoomSchedulesHydration( + queryClient: QueryClient, + roomId: string, +): Promise { + const rid = roomId.trim(); + const queryKey = roomSchedulesQueryKey(rid); + if (!rid.length || queryClient.isFetching({ queryKey, exact: true }) === 0) { + return false; + } + + await queryClient.fetchQuery({ + queryKey, + queryFn: () => hydrateRoomSchedulesFromServer(queryClient, rid), + staleTime: 0, + }); + return true; +} + /** `GET /rooms/{roomId}` 결과로 room-detail·방 목록 캐시를 맞춥니다. */ export async function syncRoomDetailFromServer( queryClient: QueryClient, From 11f92a1946d577d9935fd8f96ae00f370f21465f Mon Sep 17 00:00:00 2001 From: KangShinGyu Date: Mon, 26 Oct 2026 14:59:09 +0900 Subject: [PATCH 07/18] =?UTF-8?q?fix(places):=20100=EA=B3=B3=EC=9D=B4=20?= =?UTF-8?q?=EB=84=98=EC=9D=84=20=EB=95=8C=20=EB=92=A4=20=EB=AC=B6=EC=9D=8C?= =?UTF-8?q?=EC=9D=B4=20=EC=8B=A4=ED=8C=A8=ED=95=B4=EB=8F=84=20=EC=95=9E=20?= =?UTF-8?q?=EB=AC=B6=EC=9D=8C=EC=97=90=EC=84=9C=20=EB=B0=9B=EC=9D=80=20?= =?UTF-8?q?=EC=9E=A5=EC=86=8C=EB=8A=94=20=EC=93=B0=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 로더가 서버 한도(100곳)씩 나눠 차례로 보내고, 받은 묶음은 바로 씀 - 요청이 실패하면 그 묶음과 남은 묶음은 그 에러로 끝내고, 실패 항목도 다시 보내지 않음(요청 수는 이전과 같음) - 마지막 묶음이 1곳이면 단건 API로 보냄 Co-Authored-By: Claude Opus 5.5 --- src/lib/places/place-batch-loader.test.ts | 69 +++++++++++++++++++++++ src/lib/places/place-batch-loader.ts | 60 +++++++++++--------- 2 files changed, 103 insertions(+), 26 deletions(-) diff --git a/src/lib/places/place-batch-loader.test.ts b/src/lib/places/place-batch-loader.test.ts index 9083550c..415099bb 100644 --- a/src/lib/places/place-batch-loader.test.ts +++ b/src/lib/places/place-batch-loader.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from "vitest"; +import { PLACE_BATCH_MAX_SIZE } from "@/lib/api/batch-chunk"; import { createPlaceBatchLoader, type PlaceBatchResult } from "@/lib/places/place-batch-loader"; type Outcome = "ok" | "failed" | "missing"; @@ -134,4 +135,72 @@ describe("createPlaceBatchLoader", () => { { status: "rejected", reason: failure }, ]); }); + + describe("서버 한도를 넘으면", () => { + const ids = Array.from({ length: PLACE_BATCH_MAX_SIZE + 20 }, (_, index) => `p${index}`); + const head = ids.slice(0, PLACE_BATCH_MAX_SIZE); + const tail = ids.slice(PLACE_BATCH_MAX_SIZE); + const fulfilled = (id: string) => ({ status: "fulfilled", value: `many:${id}` }); + + it("나눠 차례로 보내고, 뒤 묶음 요청이 실패해도 앞 묶음에서 받은 값은 쓴다", async () => { + const failure = new Error("rate limited"); + const fetchMany = vi + .fn() + .mockResolvedValueOnce(batchResponse(head, () => "ok")) + .mockRejectedValueOnce(failure); + const fetchOne = vi.fn(async (id: string) => `one:${id}`); + const load = createPlaceBatchLoader({ fetchOne, fetchMany }); + + const settled = await Promise.allSettled(ids.map((id) => load(id))); + + expect(fetchMany.mock.calls).toEqual([[head], [tail]]); + expect(settled).toEqual([ + ...head.map(fulfilled), + ...tail.map(() => ({ status: "rejected", reason: failure })), + ]); + }); + + it("앞 묶음 요청이 실패하면 남은 묶음은 보내지 않고 모두 같은 에러로 실패한다", async () => { + const failure = new Error("rate limited"); + const fetchMany = vi.fn().mockRejectedValueOnce(failure); + const fetchOne = vi.fn(async (id: string) => `one:${id}`); + const load = createPlaceBatchLoader({ fetchOne, fetchMany }); + + const settled = await Promise.allSettled(ids.map((id) => load(id))); + + expect(fetchMany).toHaveBeenCalledTimes(1); + expect(settled).toEqual(ids.map(() => ({ status: "rejected", reason: failure }))); + }); + + it("묶음마다 실패한 항목을 모아 한 번에 다시 보낸다", async () => { + const { fetchMany, load } = createLoader((id, call) => + call <= 2 && (id === head[0] || id === tail[0]) ? "failed" : "ok", + ); + + const settled = await Promise.allSettled(ids.map((id) => load(id))); + + expect(fetchMany.mock.calls).toEqual([[head], [tail], [[head[0], tail[0]]]]); + expect(settled).toEqual(ids.map(fulfilled)); + }); + + it("요청이 실패한 묶음이 있으면 앞 묶음의 실패 항목도 다시 보내지 않는다", async () => { + const failure = new Error("rate limited"); + const fetchMany = vi + .fn() + .mockResolvedValueOnce(batchResponse(head, (id) => (id === head[0] ? "failed" : "ok"))) + .mockRejectedValueOnce(failure); + const fetchOne = vi.fn(async (id: string) => `one:${id}`); + const load = createPlaceBatchLoader({ fetchOne, fetchMany }); + + const settled = await Promise.allSettled(ids.map((id) => load(id))); + + expect(fetchMany).toHaveBeenCalledTimes(2); + expect(fetchOne).not.toHaveBeenCalled(); + expect(settled).toEqual([ + { status: "rejected", reason: new Error(`failed:${head[0]}`) }, + ...head.slice(1).map(fulfilled), + ...tail.map(() => ({ status: "rejected", reason: failure })), + ]); + }); + }); }); diff --git a/src/lib/places/place-batch-loader.ts b/src/lib/places/place-batch-loader.ts index 593e7617..29f55f58 100644 --- a/src/lib/places/place-batch-loader.ts +++ b/src/lib/places/place-batch-loader.ts @@ -1,3 +1,5 @@ +import { chunkArray, PLACE_BATCH_MAX_SIZE } from "@/lib/api/batch-chunk"; + /** batch 응답 항목 하나 — 받은 값, 또는 그 항목만의 실패 */ export type PlaceBatchResult = | { ok: true; value: T } @@ -20,10 +22,11 @@ function createDeferred(): Deferred { } /** - * 같은 태스크에서 요청된 장소 id를 모아 보낸다. 1건이면 단건 API, 2건 이상이면 batch API를 쓴다 — - * batch rate limit(사용자당 분당)이 단건보다 작아서 1건짜리 batch는 한도만 먼저 쓴다. + * 같은 태스크에서 요청된 장소 id를 모아 서버 한도(100곳)씩 차례로 보낸다. 한 번에 1건이면 단건 API, 2건 이상이면 + * batch API를 쓴다 — batch rate limit(사용자당 분당)이 단건보다 작아서 1건짜리 batch는 한도만 먼저 쓴다. * 항목이 실패하면 실패한 id만 같은 방식으로 한 번 더 보내고, 그래도 실패하면 그 id는 실패로 끝낸다. - * 요청 자체가 실패하면 모은 id 모두 그 에러로 실패한다 — 다시 보낼지는 TanStack 재시도 규칙이 정한다. + * 요청 자체가 실패하면 그 묶음과 아직 보내지 않은 id가 그 에러로 실패하고, 실패한 항목도 다시 보내지 않는다 — + * 앞 묶음에서 받은 값은 그대로 쓴다. 다시 보낼지는 TanStack 재시도 규칙이 정한다. */ export function createPlaceBatchLoader({ fetchOne, @@ -49,33 +52,38 @@ export function createPlaceBatchLoader({ return new Map>([[id, { ok: true, value: await fetchOne(id) }]]); } + /** 받은 값은 바로 끝내고, 항목이 실패했거나 응답에 없던 id는 실패 결과로 돌려준다 */ + async function send(ids: string[]) { + const failed = new Map>(); + const chunks = chunkArray(ids, PLACE_BATCH_MAX_SIZE); + for (const [index, chunk] of chunks.entries()) { + let results: ReadonlyMap>; + try { + results = await request(chunk); + } catch (error) { + for (const id of chunks.slice(index).flat()) settle(id, { ok: false, error }); + return { failed, stopped: true }; + } + for (const id of chunk) { + const result: PlaceBatchResult = results.get(id) ?? { + ok: false, + error: new Error(`batch 응답에 ${id}가 없음`), + }; + if (result.ok) settle(id, result); + else failed.set(id, result); + } + } + return { failed, stopped: false }; + } + async function flush() { const ids = queued; queued = []; - let failedIds: string[]; - try { - const results = await request(ids); - failedIds = ids.filter((id) => { - const result = results.get(id); - if (!result?.ok) return true; - settle(id, result); - return false; - }); - } catch (error) { - for (const id of ids) settle(id, { ok: false, error }); - return; - } - if (!failedIds.length) return; - - try { - const retried = await request(failedIds); - for (const id of failedIds) { - settle(id, retried.get(id) ?? { ok: false, error: new Error(`batch 응답에 ${id}가 없음`) }); - } - } catch (error) { - for (const id of failedIds) settle(id, { ok: false, error }); - } + const first = await send(ids); + // 요청이 실패했으면 실패한 항목도 다시 보내지 않는다 — 실패 직후에 보내면 같은 이유(429·5xx)로 또 실패한다 + const { failed } = first.stopped ? first : await send([...first.failed.keys()]); + for (const [id, result] of failed) settle(id, result); } return (id) => { From 69ae201e78e3304e2f59432fc37919bf6b941c88 Mon Sep 17 00:00:00 2001 From: KangShinGyu Date: Mon, 26 Oct 2026 14:59:09 +0900 Subject: [PATCH 08/18] =?UTF-8?q?fix(schedule):=20=EA=B8=B0=EB=8B=A4?= =?UTF-8?q?=EB=A6=AC=EB=8D=98=20=EC=9D=BC=EC=A0=95=20=EB=B6=88=EB=9F=AC?= =?UTF-8?q?=EC=98=A4=EA=B8=B0=EA=B0=80=20=EC=B7=A8=EC=86=8C=EB=90=98?= =?UTF-8?q?=EB=A9=B4=20=EC=83=88=20=EB=B6=88=EB=9F=AC=EC=98=A4=EA=B8=B0?= =?UTF-8?q?=EB=A5=BC=20=EA=B8=B0=EB=8B=A4=EB=A6=AC=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 취소된 불러오기를 기다리던 일차 조회가 '조회 중'으로 멈춰 그 일차 경로를 받지 않던 문제 방지 - 진행 중인 일정 불러오기를 취소하고 다시 받는 변경(#247)과 함께 쓰일 때를 대비 Co-Authored-By: Claude Opus 5.5 --- .../plan/schedule-preview-hydration.test.ts | 27 +++++++++++++++++++ src/lib/rooms.ts | 22 ++++++++++----- 2 files changed, 43 insertions(+), 6 deletions(-) diff --git a/src/lib/plan/schedule-preview-hydration.test.ts b/src/lib/plan/schedule-preview-hydration.test.ts index 0f187d01..0d2dc424 100644 --- a/src/lib/plan/schedule-preview-hydration.test.ts +++ b/src/lib/plan/schedule-preview-hydration.test.ts @@ -150,6 +150,33 @@ describe("ensureSchedulePlanPlacesEnriched", () => { expect(requestPlacePreview).not.toHaveBeenCalled(); }); + it("기다리던 일정 불러오기가 취소되고 다시 시작되면 새 불러오기를 기다려 그 결과를 쓴다", async () => { + const queryClient = createQueryClient(); + // 이미 받은 일정을 다시 받는 중에 또 다시 받으면 TanStack이 진행 중인 불러오기를 취소한다 + queryClient.setQueryData(roomSchedulesQueryKey(ROOM_ID), [schedule(1, ["a"])]); + const finishHydrations: Array<(schedules: RoomScheduleWithItems[]) => void> = []; + const queryFn = () => + new Promise((resolve) => { + finishHydrations.push(resolve); + }); + void queryClient.fetchQuery({ queryKey: roomSchedulesQueryKey(ROOM_ID), queryFn, staleTime: 0 }); + + const enriching = ensureSchedulePlanPlacesEnriched(queryClient, ROOM_ID, 1); + const refetching = queryClient.refetchQueries({ + queryKey: roomSchedulesQueryKey(ROOM_ID), + exact: true, + }); + const hydratedPlaces = [placeholder("a", 100)]; + queryClient.setQueryData(scheduleItemsQueryKey(ROOM_ID, 1), hydratedPlaces); + finishHydrations[1]([schedule(1, ["a"])]); + await refetching; + + await expect(enriching).resolves.toBe(hydratedPlaces); + expect(finishHydrations).toHaveLength(2); + expect(requestPlacePreviewsBatch).not.toHaveBeenCalled(); + expect(requestPlacePreview).not.toHaveBeenCalled(); + }); + it("불러오기가 끝난 뒤 다시 들어오면 자리표시자인 장소만 다시 받는다", async () => { const queryClient = createQueryClient(); queryClient.setQueryData(roomSchedulesQueryKey(ROOM_ID), [schedule(1, ["a", "b"])]); diff --git a/src/lib/rooms.ts b/src/lib/rooms.ts index 33f706df..f0f8edb3 100644 --- a/src/lib/rooms.ts +++ b/src/lib/rooms.ts @@ -1,4 +1,4 @@ -import type { QueryClient } from "@tanstack/react-query"; +import { isCancelledError, type QueryClient } from "@tanstack/react-query"; import { apiFetch } from "@/lib/api/client"; import type { @@ -163,6 +163,7 @@ export async function awaitRoomSchedulesHydrated( /** * `useRoomSchedules`의 일정 불러오기가 진행 중이면 끝날 때까지 기다리고 true를 돌려줍니다. * 진행 중인 요청에 합류할 뿐 새로 보내지 않습니다 — 진행 중이 아니면 바로 false입니다. + * 기다리던 불러오기가 취소되고 새로 시작되면 새 불러오기를 기다립니다. */ export async function waitForRoomSchedulesHydration( queryClient: QueryClient, @@ -174,11 +175,20 @@ export async function waitForRoomSchedulesHydration( return false; } - await queryClient.fetchQuery({ - queryKey, - queryFn: () => hydrateRoomSchedulesFromServer(queryClient, rid), - staleTime: 0, - }); + // 합류한 불러오기가 취소되면 그 promise는 CancelledError로 끝난다. 그대로 던지면 이 함수를 부른 + // 일차 조회가 성공도 실패도 아닌 '조회 중'으로 남는다(TanStack이 취소 에러는 상태로 남기지 않음) + while (queryClient.isFetching({ queryKey, exact: true }) > 0) { + try { + await queryClient.fetchQuery({ + queryKey, + queryFn: () => hydrateRoomSchedulesFromServer(queryClient, rid), + staleTime: 0, + }); + return true; + } catch (error) { + if (!isCancelledError(error)) throw error; + } + } return true; } From 65568312e38329e2e673536a701e6ea7b5b0dec9 Mon Sep 17 00:00:00 2001 From: KangShinGyu Date: Mon, 26 Oct 2026 15:38:15 +0900 Subject: [PATCH 09/18] =?UTF-8?q?fix(places):=20=EC=82=AC=EC=A7=84=20batch?= =?UTF-8?q?=20=EC=9D=91=EB=8B=B5=20id=20=ED=98=95=EC=8B=9D=EC=9D=B4=20?= =?UTF-8?q?=EB=8B=AC=EB=9D=BC=EB=8F=84=20=EA=B0=99=EC=9D=80=20=EC=9E=A5?= =?UTF-8?q?=EC=86=8C=EB=A1=9C=20=EB=A7=9E=EC=B6=94=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 응답 id의 `places/` 접두를 떼고 요청 id 기준으로 결과를 돌려줌 - id가 빈 항목이 있어도 batch 전체를 실패로 처리하지 않음 Co-Authored-By: Claude Opus 5.5 --- src/hooks/usePlacePhotoUrl.test.ts | 32 ++++++++++++++++++++++++++++++ src/lib/place-photo-query.ts | 29 ++++++++++++++++++--------- 2 files changed, 52 insertions(+), 9 deletions(-) diff --git a/src/hooks/usePlacePhotoUrl.test.ts b/src/hooks/usePlacePhotoUrl.test.ts index 7cf31a97..cc018a66 100644 --- a/src/hooks/usePlacePhotoUrl.test.ts +++ b/src/hooks/usePlacePhotoUrl.test.ts @@ -95,6 +95,38 @@ describe("usePlacePhotoUrlQuery", () => { expect(requestPlacePhotoUrlMock).not.toHaveBeenCalled(); }); + it("batch 응답 id의 형식이 요청과 달라도 같은 장소로 맞춘다", async () => { + const queryClient = createQueryClient(); + requestPlacePhotoUrlsBatchMock.mockResolvedValueOnce([ok("ChIJ-a"), ok("places/ChIJ-b")]); + + const results = await fetchCards(queryClient, ["places/ChIJ-a", "ChIJ-b"]); + + expect(results).toEqual([ + { status: "fulfilled", value: "https://cdn.example/ChIJ-a.jpg" }, + { status: "fulfilled", value: "https://cdn.example/places/ChIJ-b.jpg" }, + ]); + expect(requestPlacePhotoUrlsBatchMock).toHaveBeenCalledTimes(1); + expect(requestPlacePhotoUrlMock).not.toHaveBeenCalled(); + }); + + it("batch 응답에 id가 빈 항목이 있어도 나머지 장은 받고, 빠진 장만 한 번 더 받는다", async () => { + const queryClient = createQueryClient(); + requestPlacePhotoUrlsBatchMock.mockResolvedValueOnce([ + ok("ChIJ-a"), + { status: "OK", googlePlaceId: null, photoUrl: "https://cdn.example/unknown.jpg" }, + ]); + requestPlacePhotoUrlMock.mockResolvedValueOnce("https://cdn.example/ChIJ-b.jpg"); + + const results = await fetchCards(queryClient, ["ChIJ-a", "ChIJ-b"]); + + expect(results).toEqual([ + { status: "fulfilled", value: "https://cdn.example/ChIJ-a.jpg" }, + { status: "fulfilled", value: "https://cdn.example/ChIJ-b.jpg" }, + ]); + expect(requestPlacePhotoUrlsBatchMock).toHaveBeenCalledTimes(1); + expect(requestPlacePhotoUrlMock).toHaveBeenCalledExactlyOnceWith("ChIJ-b"); + }); + it("batch 항목이 실패하면 실패한 장만 batch로 한 번 더 받고, 그래도 실패하면 끝낸다", async () => { const queryClient = createQueryClient(); requestPlacePhotoUrlsBatchMock diff --git a/src/lib/place-photo-query.ts b/src/lib/place-photo-query.ts index dd0f544a..38dff823 100644 --- a/src/lib/place-photo-query.ts +++ b/src/lib/place-photo-query.ts @@ -7,6 +7,7 @@ import { requestPlacePhotoUrlsBatch, type PlacePhotoUrlBatchItem, } from "@/lib/api/places"; +import { normalizeGooglePlaceResourceId } from "@/lib/maps"; import { createPlaceBatchLoader, type PlaceBatchResult, @@ -59,16 +60,26 @@ function photoBatchItemResult(item: PlacePhotoUrlBatchItem): PlaceBatchResult({ fetchOne: (id) => requestPlacePhotoUrl(id), - fetchMany: async (ids) => - new Map( - (await requestPlacePhotoUrlsBatch(ids)).map( - (item): [string, PlaceBatchResult] => [ - item.googlePlaceId.trim(), - photoBatchItemResult(item), - ], - ), - ), + fetchMany: async (ids) => { + const byPlace = new Map>(); + for (const item of await requestPlacePhotoUrlsBatch(ids)) { + byPlace.set(placeKey(item.googlePlaceId), photoBatchItemResult(item)); + } + const results = new Map>(); + for (const id of ids) { + const result = byPlace.get(placeKey(id)); + if (result) results.set(id, result); + } + return results; + }, }); From 14068b940c1f645b96ece021ff70d6a3793cef85 Mon Sep 17 00:00:00 2001 From: KangShinGyu Date: Mon, 26 Oct 2026 15:38:15 +0900 Subject: [PATCH 10/18] =?UTF-8?q?fix(places):=20status=20=EC=97=86?= =?UTF-8?q?=EB=8A=94=20=EB=AF=B8=EB=A6=AC=EB=B3=B4=EA=B8=B0=20batch=20?= =?UTF-8?q?=ED=95=AD=EB=AA=A9=EB=8F=84=20id=C2=B7=EC=9D=B4=EB=A6=84?= =?UTF-8?q?=EC=9D=B4=20=EC=9E=88=EC=9C=BC=EB=A9=B4=20=EC=82=AC=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 레거시 응답(status 없음)을 예전처럼 받아 일정·북마크 핀이 자리표시자로 남지 않게 함 Co-Authored-By: Claude Opus 5.5 --- src/lib/places/place-queries.test.ts | 11 +++++++++++ src/lib/places/place-queries.ts | 20 +++++++++++++++++++- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/src/lib/places/place-queries.test.ts b/src/lib/places/place-queries.test.ts index 7bc82596..67ba8384 100644 --- a/src/lib/places/place-queries.test.ts +++ b/src/lib/places/place-queries.test.ts @@ -52,6 +52,17 @@ describe("placePreviewQueryOptions", () => { expect(requestPlacePreview).not.toHaveBeenCalled(); }); + it("status 없이 온 항목도 id와 이름이 있으면 받는다", async () => { + requestPlacePreviewsBatch.mockResolvedValueOnce([previewItem("ChIJ-a"), previewItem("ChIJ-b")]); + + const [a, b] = await loadPreviews(["ChIJ-a", "ChIJ-b"]); + + expect(a).toMatchObject({ status: "fulfilled", value: { name: "이름 ChIJ-a" } }); + expect(b).toMatchObject({ status: "fulfilled", value: { name: "이름 ChIJ-b" } }); + expect(requestPlacePreviewsBatch).toHaveBeenCalledTimes(1); + expect(requestPlacePreview).not.toHaveBeenCalled(); + }); + it("FAILED 항목은 실패한 곳만 batch로 한 번 더 받고, 그래도 실패하면 502로 끝낸다", async () => { const { HttpError } = await import("@/lib/api/errors"); requestPlacePreviewsBatch diff --git a/src/lib/places/place-queries.ts b/src/lib/places/place-queries.ts index e55dd70e..f87b051a 100644 --- a/src/lib/places/place-queries.ts +++ b/src/lib/places/place-queries.ts @@ -107,10 +107,25 @@ export function resolvePreviewGooglePlaceId(googlePlaceId: string): string { return raw.length ? normalizeGooglePlaceResourceId(raw) : ""; } +/** status 없이 오는 응답(레거시 `items`)도 id와 이름이 있으면 받은 것으로 본다 */ +function hasPreviewIdAndName({ + googlePlaceId, + name, +}: { + googlePlaceId?: unknown; + name?: unknown; +}): boolean { + return ( + typeof googlePlaceId === "string" && + googlePlaceId.trim().length > 0 && + typeof name === "string" && + name.trim().length > 0 + ); +} + function previewBatchItemResult( item: PlacePreviewBatchItem, ): PlaceBatchResult | null { - if (isBatchItemOk(item)) return { ok: true, value: normalizePlacePreview(item) }; if (isBatchItemError(item)) { // 항목 실패는 외부 API 실패(EXTERNAL_API_ERROR)일 때만 온다 — 단건으로 보냈으면 502였을 실패다 return { @@ -118,6 +133,9 @@ function previewBatchItemResult( error: new HttpError(502, `장소 미리보기 조회 실패: ${item.errorCode ?? item.status}`), }; } + if (isBatchItemOk(item) || hasPreviewIdAndName(item)) { + return { ok: true, value: normalizePlacePreview(item) }; + } return null; } From 867e4ac14917d732d509eb53f9034cfab5500ea3 Mon Sep 17 00:00:00 2001 From: KangShinGyu Date: Mon, 26 Oct 2026 15:38:15 +0900 Subject: [PATCH 11/18] =?UTF-8?q?fix(bookmark):=20=EB=AF=B8=EB=A6=AC?= =?UTF-8?q?=EB=B3=B4=EA=B8=B0=EB=A5=BC=20=EB=B0=9B=EC=A7=80=20=EB=AA=BB?= =?UTF-8?q?=ED=95=98=EB=A9=B4=20=EB=82=B4=EB=B6=80=20=EC=97=90=EB=9F=AC=20?= =?UTF-8?q?=EB=AC=B8=EA=B5=AC=20=EB=8C=80=EC=8B=A0=20=EC=95=88=EB=82=B4=20?= =?UTF-8?q?=EB=AC=B8=EA=B5=AC=20=ED=91=9C=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 요청 id·API 오류 코드가 담긴 에러 메시지를 화면에 그대로 보여 주지 않음 Co-Authored-By: Claude Opus 5.5 --- .../BookmarkFolderDetailView.loading.test.tsx | 33 +++++++++++++++---- .../_components/BookmarkFolderDetailView.tsx | 7 ++-- 2 files changed, 29 insertions(+), 11 deletions(-) diff --git a/src/app/(main)/bookmark/_components/BookmarkFolderDetailView.loading.test.tsx b/src/app/(main)/bookmark/_components/BookmarkFolderDetailView.loading.test.tsx index ced23d46..fc49ceee 100644 --- a/src/app/(main)/bookmark/_components/BookmarkFolderDetailView.loading.test.tsx +++ b/src/app/(main)/bookmark/_components/BookmarkFolderDetailView.loading.test.tsx @@ -4,6 +4,8 @@ import { createRoot } from "react-dom/client"; import { expect, it, vi } from "vitest"; import { BookmarkFolderDetailView } from "./BookmarkFolderDetailView"; +const placeQueries = vi.hoisted(() => ({ current: [] as unknown[] })); + vi.mock("@/hooks/useOpenPlaceOnMap", () => ({ useOpenPlaceOnMap: () => vi.fn() })); vi.mock("@/stores/session-store", () => ({ useSessionStore: () => "room" })); vi.mock("@/hooks/useRooms", () => ({ @@ -11,21 +13,40 @@ vi.mock("@/hooks/useRooms", () => ({ useRoomBookmarks: () => ({ data: [{ bookmarkId: 1, googlePlaceId: "place" }], isPending: false }), })); vi.mock("@/lib/places/place-queries", () => ({ placePreviewQueryOptions: () => ({ queryKey: ["place"] }) })); -vi.mock("@tanstack/react-query", () => ({ - useQueries: () => [{ isPending: false, isFetching: true, data: { name: "저장한 장소", googlePlaceId: "place", formattedAddress: "서울" } }], -})); +vi.mock("@tanstack/react-query", () => ({ useQueries: () => placeQueries.current })); vi.mock("./BookmarkFolderDetailHeader", () => ({ BookmarkFolderDetailHeader: () => null })); vi.mock("./BookmarkPlaceRow", () => ({ BookmarkPlaceRow: ({ place }: { place: { name: string } }) =>
{place.name}
})); Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); -it("keeps cached places visible while their previews refetch", async () => { +async function renderFolder() { const container = document.createElement("div"); const root = createRoot(container); + await act(async () => root.render()); + return { container, unmount: () => act(async () => root.unmount()) }; +} + +it("keeps cached places visible while their previews refetch", async () => { + placeQueries.current = [ + { isPending: false, isFetching: true, data: { name: "저장한 장소", googlePlaceId: "place", formattedAddress: "서울" } }, + ]; + const { container, unmount } = await renderFolder(); try { - await act(async () => root.render()); expect(container.textContent).toContain("저장한 장소"); expect(container.querySelector('[role="status"]')).toBeNull(); } finally { - await act(async () => root.unmount()); + await unmount(); + } +}); + +it("미리보기를 모두 받지 못하면 내부 에러 문구 대신 안내 문구를 보여 준다", async () => { + placeQueries.current = [ + { isPending: false, isFetching: false, isError: true, error: new Error("batch 응답에 place가 없음") }, + ]; + const { container, unmount } = await renderFolder(); + try { + expect(container.textContent).toContain("장소 정보를 불러오지 못했습니다."); + expect(container.textContent).not.toContain("batch 응답에"); + } finally { + await unmount(); } }); diff --git a/src/app/(main)/bookmark/_components/BookmarkFolderDetailView.tsx b/src/app/(main)/bookmark/_components/BookmarkFolderDetailView.tsx index 1bc73da3..b6a421cd 100644 --- a/src/app/(main)/bookmark/_components/BookmarkFolderDetailView.tsx +++ b/src/app/(main)/bookmark/_components/BookmarkFolderDetailView.tsx @@ -97,8 +97,6 @@ export function BookmarkFolderDetailView({ folder }: { folder: BookmarkFolder }) placeQueries.length > 0 && placeQueries.every((q) => q.isError); - const firstPlaceError = placeQueries.find((q) => q.isError)?.error; - const retryPlacePreviews = () => { for (const q of placeQueries) { void q.refetch(); @@ -165,10 +163,9 @@ export function BookmarkFolderDetailView({ folder }: { folder: BookmarkFolder }) ) : cardsError ? (
+ {/* 에러 문구는 내부 메시지(요청 id·API 오류 코드)라 화면에 그대로 보여 주지 않는다 */}

- {firstPlaceError instanceof Error - ? firstPlaceError.message - : "장소 정보를 불러오지 못했습니다."} + 장소 정보를 불러오지 못했습니다.

+ ); } it("못 불러온 주소는 숨기고, 주소가 바뀌면 다시 보인다", async () => { @@ -22,7 +24,7 @@ it("못 불러온 주소는 숨기고, 주소가 바뀌면 다시 보인다", as await act(async () => root.render()); expect(container.textContent).toBe("https://cdn.example/broken.jpg"); - await act(async () => markUnavailable()); + await act(async () => container.querySelector("button")?.click()); expect(container.textContent).toBe("사진 없음"); await act(async () => root.render()); From 3e97a2dee9feede1798ad0c297872821c6dad344 Mon Sep 17 00:00:00 2001 From: KangShinGyu Date: Tue, 27 Oct 2026 16:19:43 +0900 Subject: [PATCH 16/18] =?UTF-8?q?fix(route):=20=EA=B2=BD=EB=A1=9C=EB=A5=BC?= =?UTF-8?q?=20=EB=8B=A4=EC=8B=9C=20=EB=B0=9B=EB=8B=A4=EA=B0=80=20=EC=8B=A4?= =?UTF-8?q?=ED=8C=A8=ED=95=B4=EB=8F=84=20=EB=A7=88=EC=A7=80=EB=A7=89=20?= =?UTF-8?q?=EB=8B=B5=EC=9D=B4=20=EA=B2=BD=EB=A1=9C=20=EC=97=86=EC=9D=8C?= =?UTF-8?q?=EC=9D=B4=EB=A9=B4=20=EC=98=A4=EB=A5=98=20=EB=8C=80=EC=8B=A0=20?= =?UTF-8?q?=EA=B7=B8=EB=8C=80=EB=A1=9C=20=ED=91=9C=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 순서 변경 등으로 다시 받던 "경로 없음" 구간이 429를 받으면 "경로 조회 오류"로 바뀌던 문제 수정 - 받은 답 없이 실패하면 지금처럼 오류 표시 Co-Authored-By: Claude Opus 5.5 --- .../travel-time/PlanTravelTime.test.tsx | 52 +++++++++++++++++++ .../travel-time/PlanTravelTime.tsx | 3 +- 2 files changed, 54 insertions(+), 1 deletion(-) create mode 100644 src/app/(main)/plan/_components/travel-time/PlanTravelTime.test.tsx diff --git a/src/app/(main)/plan/_components/travel-time/PlanTravelTime.test.tsx b/src/app/(main)/plan/_components/travel-time/PlanTravelTime.test.tsx new file mode 100644 index 00000000..90bd5f8d --- /dev/null +++ b/src/app/(main)/plan/_components/travel-time/PlanTravelTime.test.tsx @@ -0,0 +1,52 @@ +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { PlanTravelTime } from "./PlanTravelTime"; + +const selectedRoute = vi.hoisted(() => ({ + current: { data: undefined, isPending: false, isError: false, isFetching: false } as Record, +})); + +vi.mock("@/hooks/useRooms", () => ({ + useScheduleItemRoute: () => selectedRoute.current, + useUpdateScheduleItemTravelMode: () => ({ mutate: vi.fn() }), +})); +vi.mock("@tanstack/react-query", () => ({ + useQueries: ({ queries }: { queries: unknown[] }) => queries.map(() => ({ data: undefined })), +})); +vi.mock("@/contexts/MobileViewContext", () => ({ useMobileView: () => ({ isMobileDevice: false }) })); + +function renderSummary() { + return renderToStaticMarkup( + createElement(PlanTravelTime, { + roomId: "room-1", + scheduleId: 1, + segmentSourceItemId: 100, + scheduleFingerprint: "100:a:|101:b:", + travelMode: "DRIVING", + contentOnly: true, + }), + ); +} + +describe("PlanTravelTime 요약 줄", () => { + beforeEach(() => { + selectedRoute.current = { data: undefined, isPending: false, isError: false, isFetching: false }; + }); + + it("다시 받다가 실패해도 마지막 답이 '경로 없음'이면 오류 대신 그 답을 보여 준다", () => { + selectedRoute.current = { data: null, isPending: false, isError: true, isFetching: false }; + + const html = renderSummary(); + + expect(html).toContain("이동 안내 없음"); + expect(html).not.toContain("경로 조회 오류"); + }); + + it("받은 답 없이 실패하면 오류를 보여 준다", () => { + selectedRoute.current = { data: undefined, isPending: false, isError: true, isFetching: false }; + + expect(renderSummary()).toContain("경로 조회 오류"); + }); +}); diff --git a/src/app/(main)/plan/_components/travel-time/PlanTravelTime.tsx b/src/app/(main)/plan/_components/travel-time/PlanTravelTime.tsx index 62a9f71d..02e11b7c 100644 --- a/src/app/(main)/plan/_components/travel-time/PlanTravelTime.tsx +++ b/src/app/(main)/plan/_components/travel-time/PlanTravelTime.tsx @@ -222,8 +222,9 @@ export function PlanTravelTime({ const summaryPending = !displayRoute && !selectedFetching && selectedPending; + // 마지막 답이 "경로 없음"(null)이면 다시 받다가 실패해도 그 답을 보여 준다 — 재조회 실패로 이미 받은 답을 오류로 바꾸지 않는다 const summaryIsError = - !displayRoute && primarySettled && selectedError; + !displayRoute && primarySettled && selectedError && selectedRoute !== null; const summaryUnavailable = !displayRoute && From c12c5fbb32545963528c85fb54b906afc76d7bee Mon Sep 17 00:00:00 2001 From: KangShinGyu Date: Tue, 27 Oct 2026 16:22:58 +0900 Subject: [PATCH 17/18] =?UTF-8?q?fix(route):=20=EC=A7=80=EB=8F=84=EA=B0=80?= =?UTF-8?q?=20=EB=B0=A9=EA=B8=88=20=EC=8B=A4=ED=8C=A8=ED=95=9C=20=EA=B2=BD?= =?UTF-8?q?=EB=A1=9C=20=EA=B5=AC=EA=B0=84=EC=9D=84=20=EB=8B=A4=EC=8B=9C=20?= =?UTF-8?q?=EC=9A=94=EC=B2=AD=ED=95=98=EC=A7=80=20=EC=95=8A=EB=8F=84?= =?UTF-8?q?=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 - 목록 요청이 429 등으로 실패한 구간을 지도가 fetchQuery로 한 번 더 보내던 문제 수정 - 진행 중인 요청에 합류하고, 받은 적 없는 구간은 지금처럼 받음 Co-Authored-By: Claude Opus 5.5 --- src/components/map/PlanItineraryMapRoutes.tsx | 10 ++++-- .../scheduleItemRoutePersistedQuery.test.ts | 31 ++++++++++++++++++- .../plan/scheduleItemRoutePersistedQuery.ts | 16 ++++++++++ 3 files changed, 53 insertions(+), 4 deletions(-) diff --git a/src/components/map/PlanItineraryMapRoutes.tsx b/src/components/map/PlanItineraryMapRoutes.tsx index 7b116e40..ac567deb 100644 --- a/src/components/map/PlanItineraryMapRoutes.tsx +++ b/src/components/map/PlanItineraryMapRoutes.tsx @@ -16,7 +16,10 @@ import { decodeOrientedPlanItinerarySegmentPath, normalizeGooglePlaceResourceId, } from "@/lib/maps"; -import { persistedScheduleItemRouteQueryOptions } from "@/lib/plan/scheduleItemRoutePersistedQuery"; +import { + loadScheduleItemRouteForMap, + persistedScheduleItemRouteQueryOptions, +} from "@/lib/plan/scheduleItemRoutePersistedQuery"; import { schedulePlacesFingerprint } from "@/lib/plan/planTravelLocalStorage"; import { readSchedulePlanPlacesFromCache } from "@/lib/plan/scheduleItemPlaces"; import { @@ -196,11 +199,12 @@ export function PlanItineraryMapRoutes({ segmentEpoch, ), /** - * 구간 경로는 일정 리스트와 같은 쿼리를 통해 가져옵니다. `fetchQuery`에 맡겨야 + * 구간 경로는 일정 리스트와 같은 쿼리를 통해 가져옵니다. 그 쿼리에 맡겨야 * in-flight 합치기와 재정렬 중 뒤늦게 온 응답 폐기를 React Query가 처리합니다. */ queryFn: async () => { - const route = await queryClient.fetchQuery( + const route = await loadScheduleItemRouteForMap( + queryClient, persistedScheduleItemRouteQueryOptions( rid, seg.scheduleId, diff --git a/src/lib/plan/scheduleItemRoutePersistedQuery.test.ts b/src/lib/plan/scheduleItemRoutePersistedQuery.test.ts index b05a43f4..65afd3ff 100644 --- a/src/lib/plan/scheduleItemRoutePersistedQuery.test.ts +++ b/src/lib/plan/scheduleItemRoutePersistedQuery.test.ts @@ -11,6 +11,7 @@ vi.mock("@/lib/api/rooms/schedule-items", async (importOriginal) => { }); let persistedScheduleItemRouteQueryOptions: typeof import("@/lib/plan/scheduleItemRoutePersistedQuery").persistedScheduleItemRouteQueryOptions; +let loadScheduleItemRouteForMap: typeof import("@/lib/plan/scheduleItemRoutePersistedQuery").loadScheduleItemRouteForMap; let registerQueryClient: typeof import("@/lib/query-client").registerQueryClient; let getScheduleItemRouteMock: ReturnType; @@ -20,7 +21,7 @@ beforeAll(async () => { process.env.NEXT_PUBLIC_GOOGLE_REDIRECT_URI = "http://localhost/callback"; process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY = "test-maps-key"; - ({ persistedScheduleItemRouteQueryOptions } = await import( + ({ persistedScheduleItemRouteQueryOptions, loadScheduleItemRouteForMap } = await import( "@/lib/plan/scheduleItemRoutePersistedQuery" )); ({ registerQueryClient } = await import("@/lib/query-client")); @@ -68,3 +69,31 @@ describe("persistedScheduleItemRouteQueryOptions", () => { expect(result).toEqual(freshRoute); }); }); + +describe("loadScheduleItemRouteForMap", () => { + const route = { travelMode: "TRANSIT", distanceMeters: 1700, durationSeconds: 1140 }; + const routeOptions = () => + persistedScheduleItemRouteQueryOptions("room-1", 10, 1, "TRANSIT", "", { segmentReady: true }); + + it("목록 요청이 방금 실패한 구간은 다시 보내지 않고 가진 값을 쓴다", async () => { + const queryClient = new QueryClient(); + registerQueryClient(queryClient); + const options = routeOptions(); + queryClient.setQueryData(options.queryKey, route); + await queryClient.invalidateQueries({ queryKey: options.queryKey, refetchType: "none" }); + getScheduleItemRouteMock.mockRejectedValueOnce(new Error("이동 정보 조회 실패: 429")); + await queryClient.fetchQuery(options).catch(() => undefined); + + await expect(loadScheduleItemRouteForMap(queryClient, options)).resolves.toEqual(route); + expect(getScheduleItemRouteMock).toHaveBeenCalledOnce(); + }); + + it("받은 적 없는 구간은 경로 쿼리로 받는다", async () => { + const queryClient = new QueryClient(); + registerQueryClient(queryClient); + getScheduleItemRouteMock.mockResolvedValueOnce(route); + + await expect(loadScheduleItemRouteForMap(queryClient, routeOptions())).resolves.toEqual(route); + expect(getScheduleItemRouteMock).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/lib/plan/scheduleItemRoutePersistedQuery.ts b/src/lib/plan/scheduleItemRoutePersistedQuery.ts index 1bfafba1..70d4346c 100644 --- a/src/lib/plan/scheduleItemRoutePersistedQuery.ts +++ b/src/lib/plan/scheduleItemRoutePersistedQuery.ts @@ -1,5 +1,6 @@ import type { OmitKeyof, + QueryClient, QueryKey, UseQueryOptions, } from "@tanstack/react-query"; @@ -103,3 +104,18 @@ export function persistedScheduleItemRouteQueryOptions( retry: false, }; } + +/** + * 지도 구간 경로 — 목록과 같은 경로 쿼리를 쓴다(진행 중이면 합류). 방금 받다가 실패한 구간은 다시 보내지 않고 + * 가진 값(없으면 null)을 쓴다 — 실패(429·5xx) 직후 같은 구간을 지도가 또 요청하지 않게 한다. + */ +export async function loadScheduleItemRouteForMap( + queryClient: QueryClient, + options: ReturnType, +): Promise { + const state = queryClient.getQueryState(options.queryKey); + if (state?.status === "error" && state.fetchStatus === "idle") { + return state.data ?? null; + } + return queryClient.fetchQuery(options); +} From 035b6b6b96066256fa1285bc4e8cd5ee3f3cd519 Mon Sep 17 00:00:00 2001 From: KangShinGyu Date: Tue, 27 Oct 2026 16:32:12 +0900 Subject: [PATCH 18/18] =?UTF-8?q?fix(schedule):=20=EB=AF=B8=EB=A6=AC?= =?UTF-8?q?=EB=B3=B4=EA=B8=B0=20=EC=8B=A4=ED=8C=A8=20=EC=83=81=ED=83=9C?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EB=8B=A4=EC=8B=9C=20=EB=93=A4=EC=96=B4?= =?UTF-8?q?=EC=99=80=EB=8F=84=20=EC=A7=80=EC=9A=B4=20=EC=9E=A5=EC=86=8C?= =?UTF-8?q?=EA=B0=80=20=EB=90=98=EC=82=B4=EC=95=84=EB=82=98=EC=A7=80=20?= =?UTF-8?q?=EC=95=8A=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 - 일차 목록이 있으면 room-schedules의 장소 목록으로 다시 만들지 않고 자리표시자 장소의 미리보기만 다시 받아 채움 - room-schedules의 장소 목록은 장소 추가·삭제·수정을 따라가지 않아, 다시 만들면 지운 장소가 되살아났음 Co-Authored-By: Claude Opus 5.5 --- src/lib/plan/schedule-preview-hydration.test.ts | 17 +++++++---------- src/lib/plan/scheduleItemPlaces.ts | 15 +++++++++++++++ 2 files changed, 22 insertions(+), 10 deletions(-) diff --git a/src/lib/plan/schedule-preview-hydration.test.ts b/src/lib/plan/schedule-preview-hydration.test.ts index 0d2dc424..ccf25fcc 100644 --- a/src/lib/plan/schedule-preview-hydration.test.ts +++ b/src/lib/plan/schedule-preview-hydration.test.ts @@ -24,7 +24,6 @@ let PLAN_PLACE_PREVIEW_ERROR_TITLE: string; let createQueryClient: typeof import("@/lib/query-client").createQueryClient; let scheduleItemsQueryKey: typeof import("@/lib/query-keys").scheduleItemsQueryKey; let roomSchedulesQueryKey: typeof import("@/lib/query-keys").roomSchedulesQueryKey; -let placePreviewQueryKey: typeof import("@/lib/places/place-queries").placePreviewQueryKey; let HttpError: typeof import("@/lib/api/errors").HttpError; beforeAll(async () => { @@ -34,7 +33,6 @@ beforeAll(async () => { ({ ensureSchedulePlanPlacesEnriched } = await import("@/lib/plan/scheduleItemPlaces")); ({ createQueryClient } = await import("@/lib/query-client")); ({ scheduleItemsQueryKey, roomSchedulesQueryKey } = await import("@/lib/query-keys")); - ({ placePreviewQueryKey } = await import("@/lib/places/place-queries")); ({ HttpError } = await import("@/lib/api/errors")); }); @@ -177,15 +175,14 @@ describe("ensureSchedulePlanPlacesEnriched", () => { expect(requestPlacePreview).not.toHaveBeenCalled(); }); - it("불러오기가 끝난 뒤 다시 들어오면 자리표시자인 장소만 다시 받는다", async () => { + it("다시 들어오면 지금 일차 목록은 그대로 두고 자리표시자 장소만 다시 받는다 — 지운 장소가 되살아나지 않는다", async () => { const queryClient = createQueryClient(); - queryClient.setQueryData(roomSchedulesQueryKey(ROOM_ID), [schedule(1, ["a", "b"])]); - queryClient.setQueryData(scheduleItemsQueryKey(ROOM_ID, 1), [placeholder("a", 100)]); - queryClient.setQueryData(placePreviewQueryKey("b"), { - googlePlaceId: "b", - name: "이름 b", - formattedAddress: "서울", - }); + // 일정을 불러온 뒤 c를 지웠다 — room-schedules의 items에는 c가 아직 남아 있다 + queryClient.setQueryData(roomSchedulesQueryKey(ROOM_ID), [schedule(1, ["a", "b", "c"])]); + queryClient.setQueryData(scheduleItemsQueryKey(ROOM_ID, 1), [ + placeholder("a", 100), + { id: "item-101", itemId: 101, googlePlaceId: "b", title: "이름 b", subtitle: "서울" }, + ]); requestPlacePreview.mockResolvedValueOnce({ googlePlaceId: "a", name: "이름 a", diff --git a/src/lib/plan/scheduleItemPlaces.ts b/src/lib/plan/scheduleItemPlaces.ts index 7a1b6d62..54238f92 100644 --- a/src/lib/plan/scheduleItemPlaces.ts +++ b/src/lib/plan/scheduleItemPlaces.ts @@ -10,9 +10,14 @@ import { type CreateScheduleItemResponse, type RoomScheduleItem, } from "@/lib/api/rooms/schedule-items"; +import { + fetchAndSeedPlacePreviews, + reconcileSchedulePlanPlacesAfterPreviewSeed, +} from "@/lib/places/place-batch-cache"; import { fetchPlacePreview } from "@/lib/places/place-queries"; import { buildPlanPlacesFromItemsWithPreviewEnrichment, + PLAN_PLACE_PREVIEW_ERROR_TITLE, planPlacesNeedPreviewEnrich, resolveScheduleItemsFromCacheOrHydrate, } from "@/lib/plan/schedule-bulk-hydration"; @@ -385,6 +390,16 @@ export async function ensureSchedulePlanPlacesEnriched( if (cached != null && (waitedForHydration || !planPlacesNeedPreviewEnrich(cached))) { return cached; } + if (cached != null) { + // 지금 일차 목록(삭제·수정·순서가 반영됨)은 그대로 두고 자리표시자 장소의 미리보기만 채운다 — + // room-schedules의 items는 장소 단위 변경을 따라가지 않아, 거기서 다시 만들면 지운 장소가 되살아난다 + const placeholderIds = cached + .filter((place) => place.title === PLAN_PLACE_PREVIEW_ERROR_TITLE) + .flatMap((place) => (place.googlePlaceId ? [place.googlePlaceId] : [])); + await fetchAndSeedPlacePreviews(placeholderIds, queryClient); + reconcileSchedulePlanPlacesAfterPreviewSeed(queryClient, rid); + return queryClient.getQueryData(key) ?? cached; + } const items = await resolveScheduleItemsFromCacheOrHydrate( queryClient,