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 60fa1614..d1076e01 100644 --- a/src/app/(main)/bookmark/_components/BookmarkFolderDetailView.tsx +++ b/src/app/(main)/bookmark/_components/BookmarkFolderDetailView.tsx @@ -98,8 +98,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(); @@ -166,10 +164,9 @@ export function BookmarkFolderDetailView({ folder }: { folder: BookmarkFolder }) ) : cardsError ? (
+ {/* 에러 문구는 내부 메시지(요청 id·API 오류 코드)라 화면에 그대로 보여 주지 않는다 */}

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

+ ); +} + +it("못 불러온 주소는 숨기고, 주소가 바뀌면 다시 보인다", async () => { + const container = document.createElement("div"); + const root = createRoot(container); + try { + await act(async () => root.render()); + expect(container.textContent).toBe("https://cdn.example/broken.jpg"); + + await act(async () => container.querySelector("button")?.click()); + expect(container.textContent).toBe("사진 없음"); + + await act(async () => root.render()); + expect(container.textContent).toBe("https://cdn.example/fresh.jpg"); + } finally { + await act(async () => root.unmount()); + } +}); diff --git a/src/hooks/useVisiblePlacePhotoUrl.ts b/src/hooks/useVisiblePlacePhotoUrl.ts new file mode 100644 index 00000000..2094b3ab --- /dev/null +++ b/src/hooks/useVisiblePlacePhotoUrl.ts @@ -0,0 +1,14 @@ +import { useState } from "react"; + +/** + * 다시 받아도 불러오지 못한 사진 주소는 그리지 않는다 — 깨진 이미지 대신 각 카드의 '사진 없음' 모양을 보인다. + * 주소가 바뀌면(다시 받은 주소가 다르면) 다시 그린다. + */ +export function useVisiblePlacePhotoUrl(url: string | null | undefined) { + const [unavailableUrl, setUnavailableUrl] = useState(null); + const visibleUrl = url && url !== unavailableUrl ? url : null; + return { + visibleUrl, + markUnavailable: () => setUnavailableUrl(visibleUrl), + }; +} 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..38dff823 100644 --- a/src/lib/place-photo-query.ts +++ b/src/lib/place-photo-query.ts @@ -1,13 +1,23 @@ 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 { normalizeGooglePlaceResourceId } from "@/lib/maps"; +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() : ""; @@ -34,35 +44,42 @@ export const placePhotoUrlQueryDefaults = { refetchOnMount: true, refetchOnWindowFocus: false, refetchOnReconnect: false, - retry: 2, } 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}`), }; } + +/** 응답 id는 요청과 형식(`places/` 접두)이 다르거나 비어 있을 수 있어 같은 기준으로 맞춘다 */ +function placeKey(googlePlaceId: unknown): string { + return typeof googlePlaceId === "string" + ? normalizeGooglePlaceResourceId(googlePlaceId.trim()) + : ""; +} + +/** 같은 커밋에 보이는 카드 사진을 모아 받는다 — 1장이면 단건, 2장 이상이면 batch */ +export const loadPlacePhotoUrl = createPlaceBatchLoader({ + fetchOne: (id) => requestPlacePhotoUrl(id), + 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; + }, +}); diff --git a/src/lib/places/place-batch-cache.ts b/src/lib/places/place-batch-cache.ts index 768826d7..2becad90 100644 --- a/src/lib/places/place-batch-cache.ts +++ b/src/lib/places/place-batch-cache.ts @@ -1,24 +1,10 @@ import type { QueryClient } from "@tanstack/react-query"; -import type { - PlacePhotoUrlBatchItem, - PlacePreview, - PlacePreviewBatchItem, -} from "@/lib/api/places"; +import type { PlacePreview } from "@/lib/api/places"; import { - requestPlacePhotoUrlsBatch, - 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, placePreviewQueryKey, + placePreviewQueryOptions, resolvePreviewGooglePlaceId, } from "@/lib/places/place-queries"; import type { PlanPlace } from "@/lib/plan/types"; @@ -27,67 +13,6 @@ import { getQueryClient } from "@/lib/query-client"; 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; - 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, @@ -156,22 +81,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,55 +96,10 @@ 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, -): 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, @@ -246,107 +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 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..415099bb --- /dev/null +++ b/src/lib/places/place-batch-loader.test.ts @@ -0,0 +1,206 @@ +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"; + +/** 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 }, + ]); + }); + + 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 new file mode 100644 index 00000000..29f55f58 --- /dev/null +++ b/src/lib/places/place-batch-loader.ts @@ -0,0 +1,100 @@ +import { chunkArray, PLACE_BATCH_MAX_SIZE } from "@/lib/api/batch-chunk"; + +/** 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를 모아 서버 한도(100곳)씩 차례로 보낸다. 한 번에 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) }]]); + } + + /** 받은 값은 바로 끝내고, 항목이 실패했거나 응답에 없던 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 = []; + + 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) => { + 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-photo-refresh.test.ts b/src/lib/places/place-photo-refresh.test.ts index 4e227083..04a7ab6b 100644 --- a/src/lib/places/place-photo-refresh.test.ts +++ b/src/lib/places/place-photo-refresh.test.ts @@ -101,4 +101,71 @@ describe("refreshPlacePhotoUrl", () => { }), ); }); + + describe("onPhotoUnavailable", () => { + function brokenImage(dataset: Record = {}) { + return { + src: "https://example.invalid/broken.jpg", + currentSrc: "https://example.invalid/broken.jpg", + complete: true, + naturalWidth: 0, + naturalHeight: 0, + dataset, + isConnected: true, + } as unknown as HTMLImageElement; + } + + async function failImage( + image: HTMLImageElement, + googlePlaceId: string | null = "ChIJ-place-1", + ) { + const { handlePlacePhotoImageError } = await import( + "@/lib/places/place-photo-refresh" + ); + vi.spyOn(console, "warn").mockImplementation(() => {}); + const onPhotoUnavailable = vi.fn(); + handlePlacePhotoImageError({ + source: "plan-place-card", + googlePlaceId, + placeName: "Spasso", + image, + onPhotoUnavailable, + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + return onPhotoUnavailable; + } + + it("is called without another request when the element already tried a refresh", async () => { + const onPhotoUnavailable = await failImage(brokenImage({ photoRefreshAttempted: "true" })); + + expect(onPhotoUnavailable).toHaveBeenCalledOnce(); + expect(requestPlacePhotoUrl).not.toHaveBeenCalled(); + }); + + it("is called right away when there is no place to refresh", async () => { + const onPhotoUnavailable = await failImage(brokenImage(), null); + + expect(onPhotoUnavailable).toHaveBeenCalledOnce(); + expect(requestPlacePhotoUrl).not.toHaveBeenCalled(); + }); + + it("is called when the refresh returns no URL or fails", async () => { + requestPlacePhotoUrl.mockResolvedValueOnce(""); + const afterEmpty = await failImage(brokenImage()); + requestPlacePhotoUrl.mockRejectedValueOnce(new Error("Place photo failed: 500")); + const afterFailure = await failImage(brokenImage()); + + expect(afterEmpty).toHaveBeenCalledOnce(); + expect(afterFailure).toHaveBeenCalledOnce(); + }); + + it("is not called while the refreshed URL loads", async () => { + requestPlacePhotoUrl.mockResolvedValueOnce("https://cdn.example/fresh.jpg"); + const image = brokenImage(); + const onPhotoUnavailable = await failImage(image); + + expect(image.src).toBe("https://cdn.example/fresh.jpg"); + expect(onPhotoUnavailable).not.toHaveBeenCalled(); + }); + }); }); diff --git a/src/lib/places/place-photo-refresh.ts b/src/lib/places/place-photo-refresh.ts index cc82884f..49270be3 100644 --- a/src/lib/places/place-photo-refresh.ts +++ b/src/lib/places/place-photo-refresh.ts @@ -57,13 +57,17 @@ export function handlePlacePhotoImageError(args: { googlePlaceId?: string | null; placeName?: string | null; image: HTMLImageElement; + /** 다시 받아도 보여 줄 사진이 없을 때 — 깨진 이미지 대신 사진 없음 상태로 바꾸게 한다 */ + onPhotoUnavailable?: () => void; }): void { logPlacePhotoImageError(args); const id = typeof args.googlePlaceId === "string" ? args.googlePlaceId.trim() : ""; - if (!id.length) return; - if (args.image.dataset.photoRefreshAttempted === "true") return; + if (!id.length || args.image.dataset.photoRefreshAttempted === "true") { + args.onPhotoUnavailable?.(); + return; + } args.image.dataset.photoRefreshAttempted = "true"; void refreshPlacePhotoUrl(id) @@ -78,6 +82,7 @@ export function handlePlacePhotoImageError(args: { placeName: args.placeName ?? null, imageConnected: args.image.isConnected, }); + args.onPhotoUnavailable?.(); }) .catch((error: unknown) => { console.warn("[PHOTO-URL] refresh failed", { @@ -86,5 +91,6 @@ export function handlePlacePhotoImageError(args: { placeName: args.placeName ?? null, ...describeUnknownError(error), }); + args.onPhotoUnavailable?.(); }); } diff --git a/src/lib/places/place-queries.test.ts b/src/lib/places/place-queries.test.ts new file mode 100644 index 00000000..67ba8384 --- /dev/null +++ b/src/lib/places/place-queries.test.ts @@ -0,0 +1,101 @@ +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("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 + .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 005ff847..f87b051a 100644 --- a/src/lib/places/place-queries.ts +++ b/src/lib/places/place-queries.ts @@ -1,17 +1,20 @@ +import { isBatchItemError, isBatchItemOk } from "@/lib/api/batch-types"; +import { HttpError } from "@/lib/api/errors"; import { requestPlaceDetail, - requestPlacePhotoUrl, requestPlacePreview, + requestPlacePreviewsBatch, type PlaceDetail, type PlacePreview, + type PlacePreviewBatchItem, type PlacePreviewResponse, } from "@/lib/api/places"; import { normalizeGooglePlaceResourceId } from "@/lib/maps"; -import { getQueryClient } from "@/lib/query-client"; import { - placePhotoUrlQueryDefaults, - placePhotoUrlQueryKey, -} from "@/lib/place-photo-query"; + createPlaceBatchLoader, + type PlaceBatchResult, +} from "@/lib/places/place-batch-loader"; +import { getQueryClient } from "@/lib/query-client"; export const placeDetailQueryDefaults = { staleTime: Infinity, @@ -104,6 +107,51 @@ 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 (isBatchItemError(item)) { + // 항목 실패는 외부 API 실패(EXTERNAL_API_ERROR)일 때만 온다 — 단건으로 보냈으면 502였을 실패다 + return { + ok: false, + error: new HttpError(502, `장소 미리보기 조회 실패: ${item.errorCode ?? item.status}`), + }; + } + if (isBatchItemOk(item) || hasPreviewIdAndName(item)) { + return { ok: true, value: normalizePlacePreview(item) }; + } + 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); @@ -116,15 +164,15 @@ export async function loadPlacePreview(googlePlaceId: string): Promise loadPlacePreview(id), + queryFn: () => loadPlacePreviewInBatch(id), enabled: id.length > 0, ...placePreviewQueryDefaults, - retry: 1, }; } @@ -173,21 +221,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(); }); }); 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 d51c2aee..a2b3ac82 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"; @@ -153,33 +152,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( @@ -217,7 +202,7 @@ export async function buildSchedulePlacesFromSchedulesWithItems( const items = Array.isArray(schedule.items) ? schedule.items : []; placesBySchedule.set( sid, - await buildPlanPlacesFromItemsWithPreviewEnrichment(queryClient, items), + buildPlanPlacesFromItemsWithCache(queryClient, items), ); } return placesBySchedule; @@ -275,6 +260,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를 공유하지 않게 합니다. @@ -398,17 +389,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); + } } } 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..ccf25fcc --- /dev/null +++ b/src/lib/plan/schedule-preview-hydration.test.ts @@ -0,0 +1,201 @@ +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 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 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, roomSchedulesQueryKey } = 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, + ]); + }); +}); + +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(); + // 이미 받은 일정을 다시 받는 중에 또 다시 받으면 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(); + // 일정을 불러온 뒤 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", + 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 264a5faf..3936ce31 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, @@ -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"; @@ -398,10 +403,22 @@ 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; } + 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, 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); +} 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, }, diff --git a/src/lib/rooms.ts b/src/lib/rooms.ts index 02c4ea60..9340fca2 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 { @@ -168,6 +168,38 @@ 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; + } + + // 합류한 불러오기가 취소되면 그 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; +} + /** * 일정 전체를 다시 받습니다(본인의 일차 생성·삭제·이동, 여행 날짜 변경 직후). * 진행 중이던 조회는 이 변경 전 스냅샷이라 취소하고, `room-schedules` 조회 하나로 받아