1. 배경
React Query를 사용하면 캐싱이 가능하지만, 기본적으로 브라우저 메모리에 저장되므로 새 창을 열거나 새로고침하면 캐시 데이터가 유지되지 않습니다. 특히, 슈프라이즈 앱처럼 페이지 이동 시 새 창이 열리는 구조에서는 react-query 캐시를 활용할 수 없는 문제가 발생합니다.
이를 해결하기 위해 persistQueryClient 를 사용하여 캐시를 로컬 스토리지에 저장하고, 새 창이나 새로고침 시에도 캐시 데이터를 활용할 수 있도록 최적화하였습니다.
persistQueryClient?
persistQueryClient는 React Query에서 제공하는 기능으로, 쿼리 클라이언트(QueryClient)의 상태(예: 캐시된 데이터)를 브라우저의 로컬 스토리지, 세션 스토리지 등에 영구적으로 저장(persist)할 수 있게 해주는 기능입니다. 이렇게 하면 사용자가 페이지를 새로 고치거나 브라우저를 닫았다가 다시 열어도 캐시된 데이터를 복구할 수 있습니다.
2. 구현 방법
1) 패키지 설치
npm install @tanstack/react-query-persist-client @tanstack/query-async-storage-persister
2) ReactQueryProvider 수정
기존 코드 (QueryClientProvider 사용)
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
변경 후 코드 (PersistQueryClientProvider 사용)
<PersistQueryClientProvider
client={queryClient}
persistOptions={{
persister: asyncStoragePersister,
dehydrateOptions: {
shouldDehydrateQuery: (query) => query.options.meta?.persist === true,
},
}}
>
{children}
</PersistQueryClientProvider>
meta.persist: true가 설정된 데이터만 스토리지에 저장하도록 설정- 불필요한 데이터 저장을 방지하여 저장 공간 절약
3) useQuery 옵션 수정
const { data: product, isPending } = useQuery({
queryKey: ['getProductDetailByPermalink', permalink],
queryFn: () => getProductDetailByPermalink(permalink),
enabled: !!permalink,
meta: { persist: true },
});
meta: { persist: true }설정을 추가하여 필요한 데이터만 로컬 스토리지에 저장
로컬스토리지에 저장 된 데이터를 확인할 수 있습니다.

3. 해결해야 했던 문제 및 대응 방법
Next.js 14에서 window 사용 이슈
Next.js14는 서버 사이드에서도 실행되기 때문에 window.localStorage를 직접 참조하면 undefined 오류가 발생합니다.
이를 방지하기 위해, 클라이언트 환경에서만 localStorage를 설정하도록 처리했습니다.

서버에서 실행하는 경우 undefined, 클라이언트에서 저장소 지정을 해주는것으로 했습니다.
const ReactQueryProvider = ({ children }: { children: React.ReactNode }) => {
const persister = createSyncStoragePersister({
storage: typeof window !== 'undefined' ? window.localStorage : undefined,
});
return (
<PersistQueryClientProvider
client={queryClient}
persistOptions={{
persister: persister,
dehydrateOptions: {
shouldDehydrateQuery: (query) => {
return query.options.meta?.persist === true;
},
},
}}
>
{children}
{process.env.NODE_ENV !== 'production' && <ReactQueryDevtools initialIsOpen={false} />}
</PersistQueryClientProvider>
);
};
persistQueryClient를 적용 후,
한번 확인한 상품데이터는 로컬스토리에 저장 되어 2번째 방문시 빠르게 노출이 되었습니다.
persistQueryClient 적용전

persistQueryClient 적용후

데이터 로딩 시간이 약 2초에서 0.1초로 약 95% 빨라졌습니다.
4. 추가 개선: placeholderData를 활용한 초기 렌더링 개선
저장된 캐시가 있는 상품은 렌더링 시간이 많이 개선이 되었지만
캐시가 없는 상품은 기존과 동일하게 2초 정도의 렌더링 시간이 걸렸습니다.
홈 화면에서 상품 리스트를 불러올 때 상세페이지에 필요한 일부 데이터(예: 이름, 상품코드 등)를 포함하고 있기 때문에, 이 데이터를 react-query의 placeholderData로 활용하여 초기 렌더링 속도를 더욱 개선할 수 있었습니다.
아래 코드와 같이 상품 permalink를 딕셔너리 형태로 로컬스토리지에 상품리스트 데이터를 저장해줍니다.
{
"supreme-x-nike-air-max-dn-black": {},
"air-jordan-4-retro-bred-reimagined": {},
"asics-gel-kayano-14-cream-black-1201a019-108": {}
}
상세페이지에서 서버 데이터를 받아오기 전에 상품리스트에서 받아온 데이터를 활용하여 초기데이터로 사용하고
데이터가 없는곳은 스켈레톤 처리를 하여 데이터 fetch가 완료되면 렌더링이 되도록 하였습니다.
const placeholder = JSON.parse(localStorage.getItem('products') || '{}')?.[permalink];
const { data: product, isPending } = useQuery({
queryKey: ['getProductDetailByPermalink', permalink],
queryFn: () => getProductDetailByPermalink(permalink),
enabled: !!permalink,
meta: { persist: true },
placeholderData: placeholder || {},
});
부분데이터 placeholderData로 적용후

모든 데이터가 렌더링 되진않지만 persistQueryClient 캐시가 있는 경우보다 약 73.33%정도 빨라졌습니다.
5. 추가개선: placeholderData 용량 관리
현재 상품리스트 placeholderData를 로컬스토리지에 저장하고 있는데
로컬스토리지 데이터는 도메인 당 5MB로 한정되어있어 데이터가 쌓이는경우 저장이 안되는 이슈가 있을 수 있습니다.
placeholderData를 최대 1MB로 용량을 제한하고
저장하는 시점에 로컬스토리지 사이즈를 확인하고 1MB가 넘는경우 오래된 데이터를 삭제하고 저장하도록 하였습니다.
export function getByteSize(str: string) {
return new Blob([str]).size;
}
const STORAGE_KEY = 'products';
const MAX_SIZE = 1 * 1048576; // 1MB
export function saveDataWithLimit(newValue: any) {
let data = JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}');
data = { ...data, ...newValue };
let keys = Object.keys(data);
let size = getByteSize(JSON.stringify(data));
while (size > MAX_SIZE && keys.length > 0) {
const oldestKey = keys.shift() as string;
delete data[oldestKey];
size = getByteSize(JSON.stringify(data));
}
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
}
6. 추가개선: placeholderData 만료일 지정
placeholderData 만료일을 지정하지 않으면 오래된 데이터가 placeholder Data로 들어오는 경우가 있을수 있기때문에
만료일 2일로 정하고 만료일 이 후에 상품리스트 페이지에 들어오는경우 만료된 데이터를 삭제하고 새로운 데이터를 fetch해서 다시 set할 수 있도록 했습니다.
export function getByteSize(str: string) {
return new Blob([str]).size;
}
const STORAGE_KEY = 'products';
const MAX_SIZE = 1000000; // 1MB
const EXPIRY_BUFFER_DAYS = 2;
export function isExpired(expiryDate: string): boolean {
const now = new Date();
const expiry = new Date(expiryDate);
expiry.setDate(expiry.getDate() + EXPIRY_BUFFER_DAYS);
return now > expiry;
}
export function saveDataWithLimit(newValue: any) {
let data = JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}');
// 데이터의 만료일을 체크하는 로직 추가
for (const key in data) {
if (data[key]?.expiry && isExpired(data[key].expiry)) {
delete data[key];
}
}
data = { ...data, ...newValue };
let keys = Object.keys(data);
let size = getByteSize(JSON.stringify(data));
while (size > MAX_SIZE && keys.length > 0) {
const oldestKey = keys.shift() as string;
delete data[oldestKey];
size = getByteSize(JSON.stringify(data));
}
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
}
만료일 이 후 placeholder data를 사용하는 페이지로 바로 들어오는 경우
placeholder 데이터의 만료일을 확인하고 만료일이 지난경우 삭제하는 로직을 넣어두었습니다.
const getStoredProducts = (): Record<string, any> => {
return JSON.parse(localStorage.getItem('products') || '{}');
};
const saveStoredProducts = (products: Record<string, any>) => {
localStorage.setItem('products', JSON.stringify(products));
};
const getValidPlaceholder = (permalink: string) => {
const products = getStoredProducts();
const placeholder = products?.[permalink];
if (!placeholder || isExpired(placeholder.expiry)) {
delete products[permalink];
saveStoredProducts(products);
return undefined;
}
return placeholder.value;
};
const { data: product, isPending } = useQuery({
queryKey: ['getProductDetailByPermalink', permalink],
queryFn: () => getProductDetailByPermalink(permalink),
enabled: !!permalink,
meta: { persist: true },
staleTime: 1000 * 60 * 60,
placeholderData: getValidPlaceholder(permalink),
});
저코드를 적용하고 오류가 있었는데. 요청한 스크립트가 취소되는 현상이였다.

알고보니 localstorage가 없는경우 placeholder데이터에 set해서 오류가 나는것 같아보였다.
useEffect로 localStorage가 있는지 체크하고 set 하는 것으로 변경했다.
7. 전체 동작 흐름
- 홈/목록 페이지에서 상품 리스트를 fetch
- 상세 페이지 진입 시:
- 홈에서 가져온 데이터가 있을 경우 → placeholderData로 우선 렌더링
- persistQueryClient에 저장된 캐시가 있을 경우 → 복원하여 렌더링
- 위 조건이 만족되지 않으면 → 스켈레톤 렌더링
- 상품상세 API 호출 후 최신 데이터로 갱신
(캐시 데이터와 api로 가져온 데이터가 동일하다면 리렌더링 하지 않음)
결론
- persistQueryClient를 통해 React Query의 캐시 데이터를 영속화하여 UX를 개선할 수 있었습니다.
- 여기에 placeholderData를 활용한 초기 렌더링 최적화를 더해, 새 창 진입 또는 새로고침 시에도 더 빠르고 부드러운 사용자 경험을 제공하게 되었습니다.
| 구분 | 데이터 로딩 시간 |
|---|---|
| 홈 상품리스트에서 저장 된 placeholder 데이터가 있는경우 | 32ms |
| persistQueryClient 캐시가 있는 경우 | 78ms |
| 상품상세 API fetch | 1924ms |
'Next.js' 카테고리의 다른 글
| Server Component와 Client Component 차이 (3) | 2025.07.25 |
|---|---|
| Next 스트리밍 with Suspense (5) | 2025.07.24 |
| Next.js with CSS-in-JS (0) | 2024.12.26 |
| Next 13 이후 변경 점 (0) | 2024.07.21 |
| NEXT IMAGE TEST (0) | 2023.09.14 |