React Query `staleTime`, 무작정 늘렸다간 큰 코 다칩니다! 실전 캐싱 전략과 사용자 경험 최적화
React Query의 캐싱 전략은 사용자 경험을 극적으로 개선할 수 있는 강력한 무기입니다. 특히 `staleTime`은 데이터의 신선도와 네트워크 요청 사이의 균형을 잡는 핵심 요소죠. 이 글에서는 실제 프로젝트에서 겪었던 시행착오를 바탕으로 `staleTime`을 현명하게 활용하여 성능과 개발 경험을 동시에 최적화하는 실전 노하우를 공유합니다.
React Query staleTime, 무작정 늘렸다간 큰 코 다칩니다! 실전 캐싱 전략과 사용자 경험 최적화
“팀장님, 저희 서비스는 왜 이렇게 느리게 느껴지죠? 방금 본 상품 목록인데, 뒤로 가기 했다가 다시 들어오면 꼭 로딩 스피너를 한 번 더 봐야 해요.”
새로운 서비스 론칭 후, 사용자 피드백을 모니터링하던 중 예상치 못한 불만이 터져 나왔습니다. 분명 네트워크 요청을 최적화하고, 이미지도 압축했는데 왜 사용자들은 여전히 답답함을 느낄까요? 문제는 바로 **데이터의 신선도(freshness)**와 캐싱 전략에 대한 오해에서 시작됐습니다. 특히 React Query를 도입했음에도 불구하고, 그 강력한 캐싱 기능을 제대로 활용하지 못하고 있었죠. 오늘은 제가 실제 프로젝트에서 겪었던 이 문제를 어떻게 React Query의 staleTime을 조정하며 해결했는지, 그리고 이 과정에서 얻은 인사이트를 깊이 있게 공유하려 합니다.
캐싱의 두 얼굴: staleTime과 cacheTime 제대로 이해하기
React Query(혹은 TanStack Query)를 사용하면서 가장 강력하다고 느낀 부분 중 하나는 바로 선언적인 데이터 캐싱입니다. 하지만 이 캐싱 기능을 제대로 이해하지 못하면 오히려 독이 될 수 있습니다. 우리 팀이 겪었던 문제의 핵심은 staleTime을 포함한 캐싱 옵션에 대한 깊이 있는 이해 부족이었습니다.
먼저, React Query의 캐싱에서 가장 중요한 두 가지 개념, staleTime과 cacheTime을 명확히 구분해야 합니다.
staleTime(신선도 유지 시간): 쿼리 데이터가 "신선한(fresh)" 상태로 유지되는 시간입니다. 이 시간 동안에는 React Query는 데이터를 "신선하다고" 판단하여 네트워크 요청 없이 캐시된 데이터를 즉시 반환합니다.staleTime이 만료되면 데이터는 "오래된(stale)" 상태가 되고, 이후 해당 쿼리가 마운트되거나 재조회될 때 백그라운드에서 새로운 데이터를 가져오기 위한 네트워크 요청이 발생합니다. 기본값은0입니다.cacheTime(캐시 보관 시간): 쿼리 데이터가 "비활성(inactive)" 상태가 된 후 캐시에서 제거되기 전까지 유지되는 시간입니다. 즉, 컴포넌트가 언마운트되어 쿼리가 더 이상 사용되지 않을 때부터cacheTime타이머가 시작됩니다. 이 시간 동안에는 데이터가 캐시에 남아있어, 다시 해당 쿼리가 필요할 때 즉시 데이터를 사용할 수 있습니다.cacheTime마저 만료되면 해당 쿼리 데이터는 메모리에서 완전히 가비지 컬렉션됩니다. 기본값은5분(300,000ms)입니다.
우리가 겪었던 문제는 staleTime의 기본값이 0이라는 사실을 간과한 데서 비롯되었습니다. 이는 곧 "데이터가 항상 stale하다"는 의미이며, 컴포넌트가 마운트될 때마다 React Query는 백그라운드에서 데이터를 다시 가져오려 시도한다는 뜻입니다. 심지어 cacheTime 덕분에 이전 데이터를 보여주기는 하지만, 네트워크 요청은 불필요하게 계속 발생하고 있었죠.
Before: 기본 staleTime: 0의 함정
우리 서비스의 상품 목록 페이지는 다음과 같은 코드로 데이터를 가져왔습니다.
1// ProductListPage.tsx 2import { useQuery } from '@tanstack/react-query'; 3import { fetchProducts } from '../api'; 4 5function ProductListPage() { 6 const { data: products, isLoading, isError } = useQuery({ 7 queryKey: ['products'], 8 queryFn: fetchProducts, 9 // staleTime을 명시하지 않아 기본값인 0이 적용됩니다. 10 // 즉, 쿼리가 마운트될 때마다 데이터는 즉시 stale 상태로 간주되어 백그라운드에서 refetch 시도 11 }); 12 13 if (isLoading) return <div>상품 목록 로딩 중...</div>; 14 if (isError) return <div>상품 목록을 불러오는 데 실패했습니다.</div>; 15 16 return ( 17 <div> 18 <h1>상품 목록</h1> 19 <ul> 20 {products?.map(product => ( 21 <li key={product.id}>{product.name} - {product.price}원</li> 22 ))} 23 </ul> 24 </div> 25 ); 26} 27 28// api.ts 29async function fetchProducts() { 30 console.log('상품 목록 데이터 FETCHING...'); // 이 로그가 빈번하게 찍혔습니다. 31 const response = await new Promise(resolve => setTimeout(() => { 32 resolve([ 33 { id: 1, name: '노트북', price: 1200000 }, 34 { id: 2, name: '마우스', price: 50000 }, 35 { id: 3, name: '키보드', price: 100000 }, 36 ]); 37 }, 500)); // 실제 API 호출 시뮬레이션 38 return response as { id: number; name: string; price: number }[]; 39} 40 41export { fetchProducts };
사용자가 상품 목록 페이지에 진입할 때마다 fetchProducts 함수가 호출되는 것을 console.log로 확인했습니다. 상품 상세 페이지로 갔다가 다시 목록으로 돌아오면 또 다시 fetchProducts가 호출되었죠. 물론 캐시된 데이터가 있어서 로딩 스피너를 길게 보지는 않았지만, 네트워크 탭에는 불필요한 요청들이 쌓이고 있었고, 사용자 입장에서는 "왜 방금 본 데이터를 또 불러와?"라는 찝찝함을 느낄 수밖에 없었습니다.
After: staleTime 설정으로 불필요한 네트워크 요청 줄이기
우리 서비스의 상품 데이터는 실시간으로 초 단위로 변하는 데이터가 아니었습니다. 일반적으로 5분 정도는 신선하다고 간주해도 무방했죠. 그래서 QueryClient 설정에 전역 staleTime을 적용했습니다.
1// main.tsx (또는 App.tsx) 2import React from 'react'; 3import ReactDOM from 'react-dom/client'; 4import App from './App.tsx'; 5import './index.css'; 6import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; 7 8const queryClient = new QueryClient({ 9 defaultOptions: { 10 queries: { 11 staleTime: 1000 * 60 * 5, // 5분 동안 데이터는 fresh 상태로 간주 12 cacheTime: 1000 * 60 * 30, // 30분 동안 캐시 유지 (기본값 5분보다 늘림) 13 }, 14 }, 15}); 16 17ReactDOM.createRoot(document.getElementById('root')!).render( 18 <React.StrictMode> 19 <QueryClientProvider client={queryClient}> 20 <App /> 21 </QueryClientProvider> 22 </React.StrictMode>, 23);
이 변경 후, 상품 목록 페이지에 처음 진입하면 fetchProducts가 한 번 호출됩니다. 그 후 5분 이내에 상품 상세 페이지로 이동했다가 다시 상품 목록으로 돌아오면, fetchProducts는 호출되지 않고 캐시된 데이터가 즉시 표시됩니다. 5분이 지나면 데이터는 stale 상태가 되지만, 컴포넌트가 마운트될 때 여전히 캐시된 데이터를 먼저 보여주고 백그라운드에서 fetchProducts를 호출하여 최신 데이터를 가져오는 식으로 동작합니다. 사용자 경험 측면에서 로딩 스피너를 보는 횟수가 현저히 줄어든 것이죠.
데이터 특성별 staleTime 전략: 무조건적인 적용은 금물
모든 데이터에 동일한 staleTime을 적용하는 것은 또 다른 문제를 야기할 수 있습니다. 우리 서비스에는 일반 상품 목록 외에도 실시간으로 주식 가격처럼 변동되는 데이터, 혹은 사용자 프로필처럼 비교적 정적인 데이터도 존재했습니다. 여기에 일률적으로 5분의 staleTime을 적용했다가는 사용자에게 오래된 정보를 보여주는 치명적인 결과를 초래할 수 있습니다.
시나리오 1: 실시간에 가까운 데이터 (예: 주식 시세, 채팅 메시지)
주식 시세나 채팅 메시지처럼 최신 정보가 중요한 데이터는 staleTime을 매우 짧게 설정하거나 아예 0으로 두는 것이 맞습니다. 혹은 refetchInterval을 활용하여 주기적으로 데이터를 업데이트하는 전략을 사용할 수도 있습니다.
1// StockPriceWidget.tsx 2import { useQuery } from '@tanstack/react-query'; 3import { fetchStockPrice } from '../api'; 4 5function StockPriceWidget({ symbol }: { symbol: string }) { 6 const { data: price, isLoading, isError } = useQuery({ 7 queryKey: ['stockPrice', symbol], 8 queryFn: () => fetchStockPrice(symbol), 9 staleTime: 1000 * 10, // 10초만 fresh 상태 유지 10 // refetchInterval: 1000 * 30, // 30초마다 자동으로 refetch도 고려해볼 수 있습니다. 11 }); 12 13 if (isLoading) return <div>{symbol} 가격 로딩 중...</div>; 14 if (isError) return <div>{symbol} 가격을 불러오는 데 실패했습니다.</div>; 15 16 return ( 17 <div> 18 <h2>{symbol} 현재가: {price}원</h2> 19 </div> 20 ); 21}
이 경우, 10초가 지나면 데이터는 stale 상태가 됩니다. 만약 위젯이 화면에 계속 표시되어 있다면, 10초가 지난 후 컴포넌트가 리렌더링되거나 다른 이벤트에 의해 쿼리가 다시 활성화될 때 백그라운드에서 새로운 가격을 가져오게 됩니다. refetchInterval을 함께 사용하면, 사용자의 인터랙션 없이도 주기적으로 최신 데이터를 유지할 수 있습니다.
시나리오 2: 거의 변하지 않는 정적 데이터 (예: 사용자 프로필, 제품 카테고리)
사용자 프로필 정보나 제품 카테고리 목록처럼 한 번 가져오면 거의 변하지 않거나, 변하더라도 사용자가 직접 수정하는 경우에만 업데이트되는 데이터는 staleTime을 Infinity로 설정하여 사실상 "영원히 신선한" 상태로 유지할 수 있습니다.
1// UserProfilePage.tsx 2import { useQuery } from '@tanstack/react-query'; 3import { fetchUserProfile } from '../api'; 4 5function UserProfilePage({ userId }: { userId: string }) { 6 const { data: user, isLoading, isError } = useQuery({ 7 queryKey: ['userProfile', userId], 8 queryFn: () => fetchUserProfile(userId), 9 staleTime: Infinity, // 이 데이터는 한 번 가져오면 항상 fresh 상태로 간주 10 }); 11 12 if (isLoading) return <div>사용자 프로필 로딩 중...</div>; 13 if (isError) return <div>사용자 프로필을 불러오는 데 실패했습니다.</div>; 14 15 return ( 16 <div> 17 <h1>{user?.name} 님의 프로필</h1> 18 <p>이메일: {user?.email}</p> 19 <p>가입일: {user?.joinedDate}</p> 20 {/* 프로필 수정 버튼 등 */} 21 </div> 22 ); 23}
이렇게 staleTime: Infinity로 설정된 데이터는 invalidateQueries와 같은 명시적인 무효화 액션이 발생하기 전까지는 네트워크 요청을 발생시키지 않습니다. 예를 들어, 사용자가 프로필 정보를 수정하고 저장했다면, 해당 업데이트 API 호출 후 queryClient.invalidateQueries(['userProfile', userId])를 호출하여 캐시를 수동으로 무효화하고 최신 데이터를 가져오도록 해야 합니다.
실수하기 쉬운 부분과 해결책: staleTime과 cacheTime의 미묘한 관계
우리는 staleTime을 적절히 조절하면서 성능과 사용자 경험을 개선했지만, 한 가지 더 주의해야 할 점을 발견했습니다. 바로 staleTime과 cacheTime이 상호작용하는 방식입니다.
| 특징 | staleTime | cacheTime |
|---|---|---|
| 역할 | 데이터가 "신선한" 상태를 유지하는 시간 | 데이터가 캐시에 "보관"되는 시간 (비활성 상태부터) |
| 기준점 | 데이터가 fetch된 시점 | 쿼리가 비활성(inactive)된 시점 |
| 기본값 | 0 (즉시 stale) | 5분 (300,000ms) |
| 영향 | 네트워크 요청 발생 여부 (fresh vs. stale) | 캐시 데이터 메모리 유지 여부 (가비지 컬렉션) |
| 주요 사용 | 불필요한 refetch 방지, UX 개선 | 언마운트된 컴포넌트의 빠른 재마운트 시 즉시 로드 |
만약 staleTime을 cacheTime보다 길게 설정한다면 어떻게 될까요? 예를 들어, staleTime: 10분, cacheTime: 5분으로 설정했다고 가정해 봅시다. 컴포넌트가 마운트되고 데이터를 가져온 후 5분 뒤 컴포넌트가 언마운트되면, cacheTime에 의해 5분 후 캐시에서 데이터가 제거됩니다. 그런데 staleTime은 아직 5분이 더 남아있죠. 이 경우, staleTime이 만료되기 전에 컴포넌트가 다시 마운트되더라도 이미 캐시가 제거되었기 때문에 데이터를 다시 가져와야 합니다. 이는 예상치 못한 동작으로 이어질 수 있습니다.
해결책: 일반적으로 staleTime은 cacheTime보다 같거나 짧게 설정하는 것이 좋습니다. 대부분의 경우 cacheTime은 기본값(5분)을 유지하고, staleTime을 데이터의 특성에 맞춰 0부터 5분 사이, 혹은 Infinity로 설정하는 것이 일반적입니다.
또 다른 상황은 **"오래된 데이터의 깜빡임(Flash of old data)"**입니다. staleTime이 만료되어 백그라운드에서 refetch가 일어날 때, 캐시된 오래된 데이터를 먼저 보여주다가 최신 데이터가 도착하면 화면이 잠깐 깜빡이는 현상이죠. 이는 placeholderData나 initialData를 활용하여 개선할 수 있습니다.
1// ProductListPage.tsx (placeholderData 활용 예시) 2import { useQuery } from '@tanstack/react-query'; 3import { fetchProducts } from '../api'; 4 5function ProductListPage() { 6 const { data: products, isLoading, isError } = useQuery({ 7 queryKey: ['products'], 8 queryFn: fetchProducts, 9 staleTime: 1000 * 60 * 5, 10 placeholderData: previousData => previousData, // 이전 데이터를 placeholder로 사용 11 // 또는 initialData: { id: 0, name: '초기 상품', price: 0 } 등 초기값 설정 12 }); 13 14 // ... (이전과 동일한 렌더링 로직) 15}
placeholderData를 previousData => previousData로 설정하면, 쿼리가 stale 상태일 때 백그라운드에서 refetch가 일어나더라도 UI는 isLoading 상태로 전환되지 않고 이전에 캐시된 데이터를 계속 보여주게 됩니다. 새로운 데이터가 도착하면 그때서야 UI가 업데이트되므로, 사용자 입장에서는 "깜빡임" 없이 더 부드러운 경험을 할 수 있습니다. initialData는 서버 사이드 렌더링(SSR)이나 초기 데이터를 이미 알고 있을 때 유용하게 사용할 수 있습니다.
staleTime 최적화가 가져다준 변화: 성능과 개발 경험
staleTime을 프로젝트의 데이터 특성에 맞춰 세심하게 조정하면서, 우리 서비스는 다음과 같은 긍정적인 변화를 경험했습니다.
- 체감 성능 향상: 불필요한 로딩 스피너의 노출이 줄어들어 사용자는 "앱이 빠르다"고 느끼게 되었습니다. 특히 페이지 이동이 잦은 대시보드나 목록형 페이지에서 그 효과가 두드러졌습니다.
- 네트워크 부하 감소: 서버에 대한 불필요한 API 요청이 줄어들어 서버의 부하를 낮추고, 클라이언트의 데이터 전송량도 감소했습니다. 이는 특히 모바일 환경에서 사용자 데이터 사용량 절감에도 기여했습니다.
- 개발자 경험(DX) 향상: 개발자는 더 이상 각 컴포넌트에서
useEffect와useState를 조합하여 복잡한 캐싱 로직을 직접 구현할 필요가 없어졌습니다.staleTime한 줄로 대부분의 캐싱 요구사항을 충족할 수 있게 되면서 코드의 가독성과 유지보수성이 크게 개선되었습니다.
물론 staleTime을 너무 길게 설정하면 사용자에게 오래된 데이터를 보여줄 위험이 있고, 너무 짧게 설정하면 캐싱의 이점을 제대로 누리지 못할 수 있습니다. 중요한 것은 데이터의 성격과 사용자의 기대치를 정확히 이해하고, 그에 맞는 최적의 값을 찾아가는 과정입니다.
💡 핵심 정리: React Query의
staleTime은 데이터의 신선도를 정의하여 불필요한 네트워크 요청을 줄이고 사용자 경험을 향상시키는 핵심 캐싱 전략입니다. 데이터 특성에 맞춰 적절히 설정하는 것이 중요하며,cacheTime과의 관계를 이해하고invalidateQueries,placeholderData와 함께 사용하면 더욱 강력해집니다.
- ✅ 데이터 특성 파악: 다루는 데이터가 얼마나 자주 업데이트되는지 파악하고, 이에 따라
staleTime을0,짧게,길게,Infinity중 선택합니다. - ✅ 전역
staleTime설정: 대부분의 쿼리에 적용될 기본staleTime을QueryClientProvider에 설정하여 중복 코드를 줄입니다. - ✅ 예외 쿼리
staleTime재정의: 특정 쿼리의 데이터 특성이 전역 설정과 다를 경우, 해당useQuery훅에서staleTime옵션을 재정의합니다. - ✅
invalidateQueries활용:staleTime: Infinity로 설정했거나, 데이터 변경 후 즉시 최신 정보를 보여줘야 할 때는invalidateQueries를 사용하여 캐시를 수동으로 무효화합니다. - ✅
placeholderData또는initialData고려:stale상태에서 refetch가 일어날 때 사용자에게 부드러운 전환을 제공하기 위해placeholderData나initialData를 활용하는 것을 검토합니다.
🚀 다음 단계: React Query의 staleTime과 cacheTime을 마스터했다면, 다음으로는 refetchOnWindowFocus, refetchOnMount, refetchOnReconnect와 같은 refetch 옵션들을 깊이 있게 살펴보세요. 이 옵션들이 staleTime과 어떻게 상호작용하는지 이해하면, 더욱 정교하고 강력한 데이터 관리 전략을 구축할 수 있을 겁니다. 또한, Mutation 발생 시 onSuccess 콜백에서 invalidateQueries를 효율적으로 사용하는 패턴을 익히는 것도 중요합니다.