From bf02a055602e0a9201054857556866cdbc5df405 Mon Sep 17 00:00:00 2001 From: "bottlenote-app[bot]" <289617182+bottlenote-app[bot]@users.noreply.github.com> Date: Fri, 24 Jul 2026 18:18:56 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=ED=81=90=EB=A0=88=EC=9D=B4=EC=85=98=20?= =?UTF-8?q?=EC=8A=A4=ED=8E=99=20=EB=B8=8C=EB=9D=BC=EC=9A=B0=EC=A0=80=20?= =?UTF-8?q?=EC=BA=90=EC=8B=9C=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hooks/__tests__/useCurations.test.ts | 147 +++++++++++++-- src/hooks/useCurations.ts | 96 +++++++--- .../curation-spec-browser-cache.test.ts | 123 +++++++++++++ src/lib/curation-spec-browser-cache.ts | 169 ++++++++++++++++++ .../curation/useCurationSpecFormModel.ts | 9 +- src/services/curation.service.ts | 7 +- 6 files changed, 498 insertions(+), 53 deletions(-) create mode 100644 src/lib/__tests__/curation-spec-browser-cache.test.ts create mode 100644 src/lib/curation-spec-browser-cache.ts diff --git a/src/hooks/__tests__/useCurations.test.ts b/src/hooks/__tests__/useCurations.test.ts index b3c4be1..8c1fa91 100644 --- a/src/hooks/__tests__/useCurations.test.ts +++ b/src/hooks/__tests__/useCurations.test.ts @@ -1,19 +1,23 @@ -import { describe, expect, it, vi } from 'vitest'; -import { waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, waitFor } from '@testing-library/react'; import { http, HttpResponse } from 'msw'; import { server } from '@/test/mocks/server'; import { wrapApiResponse } from '@/test/mocks/data'; import { renderHook } from '@/test/test-utils'; -import type { - CurationV2CreateRequest, - CurationV2Spec, - CurationV2SpecListItem, -} from '@/types/api'; +import { + cacheCurationSpecDetail, + getCachedCurationSpecDetail, + readCurationSpecBrowserCache, + writeCurationSpecBrowserCache, +} from '@/lib/curation-spec-browser-cache'; +import { useAuthStore } from '@/stores/auth'; +import type { CurationV2CreateRequest, CurationV2Spec, CurationV2SpecListItem } from '@/types/api'; +import { useCurationSpecFormModel } from '@/pages/curation/useCurationSpecFormModel'; import { useCurationCreate, useCurationList, - useCurationSpecByCode, + useCurationSpec, useCurationSpecs, useCurationUpdate, } from '../useCurations'; @@ -68,47 +72,152 @@ const createRequest: CurationV2CreateRequest = { }, }; +const ADMIN_ID = 7; + +beforeEach(() => { + localStorage.clear(); + act(() => { + useAuthStore.setState({ + user: { adminId: ADMIN_ID, email: 'admin@example.com', roles: ['ROOT_ADMIN'] }, + accessToken: 'test-access-token', + refreshToken: 'test-refresh-token', + isAuthenticated: true, + }); + }); +}); + describe('useCurations hooks', () => { - it('큐레이션 스펙 목록을 반환한다', async () => { + it('fresh browser manifest를 목록 API 호출 없이 사용한다', async () => { + let listRequestCount = 0; server.use( http.get(SPEC_BASE, () => { + listRequestCount++; return HttpResponse.json(wrapApiResponse([mockTastingEventSpecListItem])); }) ); + writeCurationSpecBrowserCache(ADMIN_ID, { + schemaVersion: 1, + checkedAt: Date.now(), + specs: [mockTastingEventSpecListItem], + details: {}, + }); + const { result } = renderHook(() => useCurationSpecs()); - await waitFor(() => expect(result.current.isSuccess).toBe(true)); + await waitFor(() => expect(result.current.data?.[0]?.version).toBe(1)); + expect(listRequestCount).toBe(0); + }); - expect(result.current.data![0]!.code).toBe('WHISKY_TASTING_EVENT'); + it('stale manifest 확인 후 version이 바뀐 detail cache를 제거한다', async () => { + const staleCache = cacheCurationSpecDetail( + { + schemaVersion: 1, + checkedAt: Date.now() - 1000 * 60 * 60 - 1, + specs: [mockTastingEventSpecListItem], + details: {}, + }, + mockTastingEventSpec, + Date.now() - 1000 * 60 * 60 - 1 + ); + writeCurationSpecBrowserCache(ADMIN_ID, staleCache); + + server.use( + http.get(SPEC_BASE, () => + HttpResponse.json(wrapApiResponse([{ ...mockTastingEventSpecListItem, version: 2 }])) + ) + ); + + const { result } = renderHook(() => useCurationSpecs()); + + await waitFor(() => expect(result.current.data?.[0]?.version).toBe(2)); + + const cache = readCurationSpecBrowserCache(ADMIN_ID); + expect(getCachedCurationSpecDetail(cache, mockTastingEventSpec.id, 1)).toBeNull(); }); - it('specCode로 큐레이션 스펙을 resolve한다', async () => { + it('matching browser detail을 재사용하고 detail API를 호출하지 않는다', async () => { + let detailRequestCount = 0; + server.use( + http.get(`${SPEC_BASE}/:specId`, () => { + detailRequestCount++; + return HttpResponse.json(wrapApiResponse(mockTastingEventSpec)); + }) + ); + + const cache = cacheCurationSpecDetail( + { + schemaVersion: 1, + checkedAt: Date.now(), + specs: [mockTastingEventSpecListItem], + details: {}, + }, + mockTastingEventSpec, + Date.now() + ); + writeCurationSpecBrowserCache(ADMIN_ID, cache); + + const { result } = renderHook(() => useCurationSpec(mockTastingEventSpec.id, 1)); + + await waitFor(() => expect(result.current.data?.id).toBe(mockTastingEventSpec.id)); + expect(detailRequestCount).toBe(0); + }); + + it('detail cache miss 시 응답 schema를 browser cache에 저장한다', async () => { + server.use( + http.get(`${SPEC_BASE}/:specId`, () => + HttpResponse.json(wrapApiResponse(mockTastingEventSpec)) + ) + ); + + const { result } = renderHook(() => useCurationSpec(mockTastingEventSpec.id, 1)); + + await waitFor(() => expect(result.current.data?.id).toBe(mockTastingEventSpec.id)); + + const cache = readCurationSpecBrowserCache(ADMIN_ID); + expect(getCachedCurationSpecDetail(cache, mockTastingEventSpec.id, 1)?.data).toEqual( + mockTastingEventSpec + ); + }); + + it('form model이 canonical 목록 query를 재사용한다', async () => { + let listRequestCount = 0; server.use( http.get(SPEC_BASE, () => { + listRequestCount++; return HttpResponse.json(wrapApiResponse([mockTastingEventSpecListItem])); - }) + }), + http.get(`${SPEC_BASE}/:specId`, () => + HttpResponse.json(wrapApiResponse(mockTastingEventSpec)) + ) ); - const { result } = renderHook(() => useCurationSpecByCode('WHISKY_TASTING_EVENT')); + const { result } = renderHook(() => { + const specsQuery = useCurationSpecs(); + const formModel = useCurationSpecFormModel({ + specCode: 'WHISKY_TASTING_EVENT', + createFormModel: (spec) => spec.id, + }); - await waitFor(() => expect(result.current.isSuccess).toBe(true)); + return { specsQuery, formModel }; + }); - expect(result.current.data?.id).toBe(3); + await waitFor(() => expect(result.current.formModel.formModel).toBe(mockTastingEventSpec.id)); + expect(listRequestCount).toBe(1); }); - it('존재하지 않는 specCode는 null로 반환한다', async () => { + it('큐레이션 스펙 목록을 반환한다', async () => { server.use( http.get(SPEC_BASE, () => { return HttpResponse.json(wrapApiResponse([mockTastingEventSpecListItem])); }) ); - const { result } = renderHook(() => useCurationSpecByCode('UNKNOWN_SPEC')); + const { result } = renderHook(() => useCurationSpecs()); await waitFor(() => expect(result.current.isSuccess).toBe(true)); - expect(result.current.data).toBeNull(); + expect(result.current.data![0]!.code).toBe('WHISKY_TASTING_EVENT'); }); it('spec 기반 큐레이션 목록을 반환한다', async () => { diff --git a/src/hooks/useCurations.ts b/src/hooks/useCurations.ts index edf034e..2245a94 100644 --- a/src/hooks/useCurations.ts +++ b/src/hooks/useCurations.ts @@ -4,12 +4,21 @@ import { useQueryClient } from '@tanstack/react-query'; +import { + cacheCurationSpecDetail, + CURATION_SPEC_QUERY_GC_MS, + CURATION_SPEC_REVALIDATE_AFTER_MS, + getCachedCurationSpecDetail, + readCurationSpecBrowserCache, + reconcileCurationSpecManifest, + writeCurationSpecBrowserCache, +} from '@/lib/curation-spec-browser-cache'; +import { useAuthStore } from '@/stores/auth'; import { useApiQuery, type UseApiQueryOptions } from './useApiQuery'; import { useApiMutation, type UseApiMutationOptions } from './useApiMutation'; import { curationKeys, curationService, - resolveCurationSpecByCode, type CurationListResponse, } from '@/services/curation.service'; import type { @@ -18,7 +27,6 @@ import type { CurationV2Detail, CurationV2SearchParams, CurationV2Spec, - CurationV2SpecCode, CurationV2SpecListItem, CurationV2UpdateRequest, CurationV2UpdateResponse, @@ -26,45 +34,81 @@ import type { /** * 큐레이션 스펙 목록 조회 훅 + * + * 목록 API는 version manifest로 사용한다. 브라우저 cache가 1시간 이내면 즉시 복원하고, + * stale 상태 또는 탭 재포커스 때만 목록을 다시 조회해 상세 schema cache를 정리한다. */ export function useCurationSpecs() { - return useApiQuery(curationKeys.specs(), curationService.listSpecs, { - staleTime: 1000 * 60 * 5, - }); + const adminId = useAuthStore((state) => state.user?.adminId); + const browserCache = adminId ? readCurationSpecBrowserCache(adminId) : null; + + return useApiQuery( + curationKeys.specs(adminId ?? 0), + async () => { + const specs = await curationService.listSpecs(); + + if (adminId) { + const checkedAt = Date.now(); + const reconciledCache = reconcileCurationSpecManifest( + readCurationSpecBrowserCache(adminId), + specs, + checkedAt + ); + writeCurationSpecBrowserCache(adminId, reconciledCache); + } + + return specs; + }, + { + staleTime: CURATION_SPEC_REVALIDATE_AFTER_MS, + gcTime: CURATION_SPEC_QUERY_GC_MS, + refetchOnWindowFocus: true, + initialData: browserCache?.specs, + initialDataUpdatedAt: browserCache?.checkedAt, + } + ); } /** * 큐레이션 스펙 상세 조회 훅 + * + * 상세 query key에 version을 포함해 manifest version이 바뀌면 기존 TanStack Query data도 + * 재사용하지 않는다. 현재 version의 browser cache가 있으면 상세 API 요청을 생략한다. */ export function useCurationSpec( specId: number | undefined, + version: number | undefined, options?: UseApiQueryOptions ) { - return useApiQuery( - curationKeys.spec(specId ?? 0), - () => curationService.getSpec(specId!), - { - enabled: !!specId && specId > 0, - staleTime: 1000 * 60 * 5, - ...options, - } - ); -} + const adminId = useAuthStore((state) => state.user?.adminId); + const browserCache = adminId ? readCurationSpecBrowserCache(adminId) : null; + const cachedDetail = + specId && version ? getCachedCurationSpecDetail(browserCache, specId, version) : null; + const { enabled: isEnabled = true, ...restOptions } = options ?? {}; -/** - * 큐레이션 스펙 code 기준 조회 훅 - * specId는 환경별로 달라질 수 있으므로 code로 조회 후 런타임에 resolve한다. - */ -export function useCurationSpecByCode(specCode: CurationV2SpecCode | undefined) { - return useApiQuery( - curationKeys.specByCode(specCode ?? ''), + return useApiQuery( + curationKeys.spec(adminId ?? 0, specId ?? 0, version ?? 0), async () => { - const specs = await curationService.listSpecs(); - return specCode ? resolveCurationSpecByCode(specs, specCode) : null; + const spec = await curationService.getSpec(specId!); + + if (adminId && spec.version === version) { + const nextCache = cacheCurationSpecDetail( + readCurationSpecBrowserCache(adminId), + spec, + Date.now() + ); + writeCurationSpecBrowserCache(adminId, nextCache); + } + + return spec; }, { - enabled: !!specCode, - staleTime: 1000 * 60 * 5, + ...restOptions, + enabled: !!specId && specId > 0 && !!version && isEnabled, + staleTime: Infinity, + gcTime: CURATION_SPEC_QUERY_GC_MS, + initialData: cachedDetail?.data, + initialDataUpdatedAt: cachedDetail?.cachedAt, } ); } diff --git a/src/lib/__tests__/curation-spec-browser-cache.test.ts b/src/lib/__tests__/curation-spec-browser-cache.test.ts new file mode 100644 index 0000000..f6e78a0 --- /dev/null +++ b/src/lib/__tests__/curation-spec-browser-cache.test.ts @@ -0,0 +1,123 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + cacheCurationSpecDetail, + CURATION_SPEC_CACHE_RETENTION_MS, + getCachedCurationSpecDetail, + getCurationSpecBrowserCacheKey, + readCurationSpecBrowserCache, + reconcileCurationSpecManifest, + writeCurationSpecBrowserCache, +} from '../curation-spec-browser-cache'; +import type { CurationV2Spec, CurationV2SpecListItem } from '@/types/api'; + +const ADMIN_ID = 7; +const NOW = new Date('2026-07-24T08:00:00.000Z').getTime(); + +const tastingEventListItem: CurationV2SpecListItem = { + id: 3, + code: 'WHISKY_TASTING_EVENT', + name: '위스키 시음회', + description: '시음회', + version: 2, + isActive: true, +}; + +const pairingListItem: CurationV2SpecListItem = { + id: 2, + code: 'WHISKY_PAIRING', + name: '위스키 페어링', + description: '페어링', + version: 2, + isActive: true, +}; + +const tastingEventSpec: CurationV2Spec = { + ...tastingEventListItem, + hydratorKey: 'alcohol', + requestSpec: { type: 'object', properties: { eventDate: { type: 'string' } } }, + responseSpec: { type: 'object' }, +}; + +const pairingSpec: CurationV2Spec = { + ...pairingListItem, + hydratorKey: 'alcohol', + requestSpec: { type: 'object', properties: { foodName: { type: 'string' } } }, + responseSpec: { type: 'object' }, +}; + +describe('curation spec browser cache', () => { + beforeEach(() => { + localStorage.clear(); + vi.useFakeTimers(); + vi.setSystemTime(NOW); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('손상되었거나 보존 기간이 지난 cache는 무시한다', () => { + localStorage.setItem(getCurationSpecBrowserCacheKey(ADMIN_ID), '{broken json'); + expect(readCurationSpecBrowserCache(ADMIN_ID)).toBeNull(); + + writeCurationSpecBrowserCache(ADMIN_ID, { + schemaVersion: 1, + checkedAt: NOW - CURATION_SPEC_CACHE_RETENTION_MS - 1, + specs: [tastingEventListItem], + details: {}, + }); + + expect(readCurationSpecBrowserCache(ADMIN_ID)).toBeNull(); + expect(localStorage.getItem(getCurationSpecBrowserCacheKey(ADMIN_ID))).toBeNull(); + }); + + it('manifest version 변경 또는 inactive 상태의 detail만 제거한다', () => { + const cached = cacheCurationSpecDetail( + cacheCurationSpecDetail( + { + schemaVersion: 1, + checkedAt: NOW, + specs: [tastingEventListItem, pairingListItem], + details: {}, + }, + tastingEventSpec, + NOW + ), + pairingSpec, + NOW + ); + + const reconciled = reconcileCurationSpecManifest( + cached, + [ + tastingEventListItem, + { + ...pairingListItem, + isActive: false, + }, + ], + NOW + 1000 + ); + + expect(getCachedCurationSpecDetail(reconciled, 3, 2)?.data).toEqual(tastingEventSpec); + expect(getCachedCurationSpecDetail(reconciled, 2, 2)).toBeNull(); + expect(reconciled.checkedAt).toBe(NOW + 1000); + }); + + it('현재 manifest version과 일치하는 detail만 반환한다', () => { + const cache = cacheCurationSpecDetail( + { + schemaVersion: 1, + checkedAt: NOW, + specs: [tastingEventListItem], + details: {}, + }, + tastingEventSpec, + NOW + ); + + expect(getCachedCurationSpecDetail(cache, 3, 2)?.data).toEqual(tastingEventSpec); + expect(getCachedCurationSpecDetail(cache, 3, 3)).toBeNull(); + }); +}); diff --git a/src/lib/curation-spec-browser-cache.ts b/src/lib/curation-spec-browser-cache.ts new file mode 100644 index 0000000..cab1e93 --- /dev/null +++ b/src/lib/curation-spec-browser-cache.ts @@ -0,0 +1,169 @@ +import type { CurationV2Spec, CurationV2SpecListItem } from '@/types/api'; + +export const CURATION_SPEC_REVALIDATE_AFTER_MS = 1000 * 60 * 60; +export const CURATION_SPEC_QUERY_GC_MS = 1000 * 60 * 60; +export const CURATION_SPEC_CACHE_RETENTION_MS = 1000 * 60 * 60 * 24 * 30; + +const CURATION_SPEC_CACHE_SCHEMA_VERSION = 1; + +export interface CachedCurationSpecDetail { + version: number; + cachedAt: number; + data: CurationV2Spec; +} + +export interface CurationSpecBrowserCache { + schemaVersion: typeof CURATION_SPEC_CACHE_SCHEMA_VERSION; + checkedAt: number; + specs: CurationV2SpecListItem[]; + details: Record; +} + +export function getCurationSpecBrowserCacheKey(adminId: number) { + return `bottlenote:curation-spec-cache:v${CURATION_SPEC_CACHE_SCHEMA_VERSION}:${adminId}`; +} + +function getLocalStorage() { + if (typeof window === 'undefined') { + return null; + } + + try { + return window.localStorage; + } catch { + return null; + } +} + +function removeLocalStorageItem(storage: Storage, key: string) { + try { + storage.removeItem(key); + } catch { + // Browser storage privacy mode failures must not block curation forms. + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isCurationSpecBrowserCache(value: unknown): value is CurationSpecBrowserCache { + if (!isRecord(value)) { + return false; + } + + return ( + value.schemaVersion === CURATION_SPEC_CACHE_SCHEMA_VERSION && + typeof value.checkedAt === 'number' && + Array.isArray(value.specs) && + isRecord(value.details) + ); +} + +function createEmptyCurationSpecBrowserCache(checkedAt = Date.now()): CurationSpecBrowserCache { + return { + schemaVersion: CURATION_SPEC_CACHE_SCHEMA_VERSION, + checkedAt, + specs: [], + details: {}, + }; +} + +export function readCurationSpecBrowserCache(adminId: number): CurationSpecBrowserCache | null { + const storage = getLocalStorage(); + if (!storage) { + return null; + } + + const key = getCurationSpecBrowserCacheKey(adminId); + + try { + const raw = storage.getItem(key); + if (!raw) { + return null; + } + + const cache: unknown = JSON.parse(raw); + if ( + !isCurationSpecBrowserCache(cache) || + Date.now() - cache.checkedAt > CURATION_SPEC_CACHE_RETENTION_MS + ) { + removeLocalStorageItem(storage, key); + return null; + } + + return cache; + } catch { + removeLocalStorageItem(storage, key); + return null; + } +} + +export function writeCurationSpecBrowserCache(adminId: number, cache: CurationSpecBrowserCache) { + const storage = getLocalStorage(); + if (!storage) { + return; + } + + try { + storage.setItem(getCurationSpecBrowserCacheKey(adminId), JSON.stringify(cache)); + } catch { + // Browser storage quota/privacy mode failures must not block curation forms. + } +} + +export function reconcileCurationSpecManifest( + cache: CurationSpecBrowserCache | null, + remoteSpecs: CurationV2SpecListItem[], + checkedAt: number +): CurationSpecBrowserCache { + const currentCache = cache ?? createEmptyCurationSpecBrowserCache(checkedAt); + const remoteSpecById = new Map(remoteSpecs.map((spec) => [spec.id, spec])); + + const details = Object.fromEntries( + Object.entries(currentCache.details).flatMap(([specId, detail]) => { + const remoteSpec = remoteSpecById.get(Number(specId)); + if (!remoteSpec || !remoteSpec.isActive || remoteSpec.version !== detail.version) { + return []; + } + + return [[specId, { ...detail, cachedAt: checkedAt }]]; + }) + ); + + return { + schemaVersion: CURATION_SPEC_CACHE_SCHEMA_VERSION, + checkedAt, + specs: remoteSpecs, + details, + }; +} + +export function getCachedCurationSpecDetail( + cache: CurationSpecBrowserCache | null, + specId: number, + version: number +): CachedCurationSpecDetail | null { + const detail = cache?.details[String(specId)]; + return detail?.version === version ? detail : null; +} + +export function cacheCurationSpecDetail( + cache: CurationSpecBrowserCache | null, + spec: CurationV2Spec, + cachedAt: number +): CurationSpecBrowserCache { + const currentCache = cache ?? createEmptyCurationSpecBrowserCache(cachedAt); + + return { + ...currentCache, + details: { + ...currentCache.details, + [spec.id]: { + version: spec.version, + cachedAt, + data: spec, + }, + }, + }; +} diff --git a/src/pages/curation/useCurationSpecFormModel.ts b/src/pages/curation/useCurationSpecFormModel.ts index 8237aef..032fc77 100644 --- a/src/pages/curation/useCurationSpecFormModel.ts +++ b/src/pages/curation/useCurationSpecFormModel.ts @@ -1,4 +1,4 @@ -import { useCurationSpec, useCurationSpecByCode } from '@/hooks/useCurations'; +import { useCurationSpec, useCurationSpecs } from '@/hooks/useCurations'; import type { CurationV2Spec, CurationV2SpecCode } from '@/types/api'; interface UseCurationSpecFormModelOptions { @@ -13,9 +13,10 @@ export function useCurationSpecFormModel({ createFormModel, showErrorToast = false, }: UseCurationSpecFormModelOptions) { - const specsQuery = useCurationSpecByCode(specCode); - const targetSpec = specsQuery.data?.isActive ? specsQuery.data : null; - const specDetailQuery = useCurationSpec(targetSpec?.id, { + const specsQuery = useCurationSpecs(); + const targetSpec = + specsQuery.data?.find((spec) => spec.code === specCode && spec.isActive) ?? null; + const specDetailQuery = useCurationSpec(targetSpec?.id, targetSpec?.version, { showErrorToast, }); const specDetail = specDetailQuery.data; diff --git a/src/services/curation.service.ts b/src/services/curation.service.ts index 5542f4c..f0599f6 100644 --- a/src/services/curation.service.ts +++ b/src/services/curation.service.ts @@ -24,10 +24,9 @@ import { export const curationKeys = { all: ['curation'] as const, - specs: () => [...curationKeys.all, 'specs'] as const, - spec: (specId: number) => [...curationKeys.specs(), specId] as const, - specByCode: (specCode: CurationV2SpecCode) => - [...curationKeys.specs(), 'code', specCode] as const, + specs: (adminId: number) => [...curationKeys.all, 'specs', adminId] as const, + spec: (adminId: number, specId: number, version: number) => + [...curationKeys.specs(adminId), 'detail', specId, version] as const, lists: () => [...curationKeys.all, 'list'] as const, list: (params?: CurationV2SearchParams) => params ? ([...curationKeys.lists(), params] as const) : curationKeys.lists(),