프론트 웹 캐시 가이드 (Part 2) : TanStack Query 캐싱

개발.log·2025년 12월 10일
post-thumbnail

프론트 웹 캐시 가이드 (Part 2): TanStack Query - 클라이언트에서 API 응답을 캐싱하는 방법

1편에서 브라우저 HTTP 캐시를 다뤘다.
2부에서는 API 응답 데이터를 캐싱하는 TanStack Query를 파헤쳐보자.
"왜 로딩이 또 뜨지?", "왜 데이터가 안 바뀌지?"의 답이 여기 있다.


1. HTTP 캐시 vs TanStack Query: 뭐가 다른가?

1편에서 배운 HTTP 캐시는 파일 단위다. JS, CSS, 이미지 같은 정적 파일을 저장한다.

HTTP 캐시가 저장하는 것:
├─ bundle.js (500KB)
├─ styles.css (50KB)
└─ logo.png (20KB)

하지만 API 응답 데이터는 다르다.

// 이건 HTTP 캐시로 관리하기 어렵다
const response = await fetch('/api/products')
const products = await response.json()

// 왜?
// 1. 같은 URL이어도 사용자마다 다른 데이터
// 2. 실시간으로 바뀌는 데이터
// 3. 인증 토큰에 따라 다른 응답
HTTP 캐시 (Part 1)TanStack Query (Part 2)
대상정적 파일API 응답 데이터
URLQuery Key (커스텀)
무효화Cache-Control, ETagstaleTime, refetch
저장 위치디스크메모리 (RAM)
지속성브라우저 종료 후에도 유지탭 닫으면 사라짐

2. 왜 TanStack Query를 쓰나?

2-1. 직접 구현하면 생기는 문제들

// 😩 직접 구현할 때 마주치는 문제들
function ProductList() {
  const [products, setProducts] = useState([])
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState(null)

  useEffect(() => {
    fetchProducts()
      .then(setProducts)
      .catch(setError)
      .finally(() => setLoading(false))
  }, [])

  // 문제 1: 다른 컴포넌트에서도 같은 데이터 필요하면?
  // → 또 fetch하거나, 전역 상태로 올려야 함
  
  // 문제 2: 데이터 새로고침은?
  // → refetch 로직 직접 구현
  
  // 문제 3: 캐싱은?
  // → 직접 구현... 복잡해짐
  
  // 문제 4: 로딩/에러 상태 관리?
  // → 매번 useState 3개씩
}

2-2. TanStack Query가 해결해주는 것

// 😎 TanStack Query
function ProductList() {
  const { data, isLoading, error } = useQuery({
    queryKey: ['products'],
    queryFn: fetchProducts,
  })

  // ✅ 다른 컴포넌트에서 같은 queryKey로 호출하면 캐시 공유
  // ✅ 자동 refetch (포커스, 네트워크 복구 시)
  // ✅ 캐싱 + stale 관리 내장
  // ✅ 로딩/에러 상태 자동 관리
}

3. 핵심 개념: staleTime과 gcTime

TanStack Query의 캐싱을 이해하려면 두 가지 시간을 알아야 한다.

3-1. staleTime: "이 데이터 아직 신선해?"

staleTime = 데이터가 "신선하다"고 간주되는 시간

┌─────────────────────────────────────────────────────┐
│                                                     │
│   fetch 완료        staleTime 경과                  │
│       │                  │                          │
│       ▼                  ▼                          │
│   ┌───────┐          ┌───────┐                      │
│   │ fresh │ ──────▶  │ stale │                      │
│   │ 신선함 │          │ 오래됨 │                      │
│   └───────┘          └───────┘                      │
│                                                     │
│   refetch 안 함      refetch 트리거 가능            │
│                                                     │
└─────────────────────────────────────────────────────┘
useQuery({
  queryKey: ['products'],
  queryFn: fetchProducts,
  staleTime: 1000 * 60 * 5, // 5분 동안 신선
})

// staleTime 동안:
// - 같은 queryKey로 useQuery 호출해도 fetch 안 함
// - 캐시된 데이터 즉시 반환

// staleTime 지나면:
// - 데이터는 여전히 캐시에 있음 (바로 보여줌)
// - 백그라운드에서 refetch 시도

staleTime 기본값: 0

// 기본값이 0이면?
useQuery({
  queryKey: ['products'],
  queryFn: fetchProducts,
  // staleTime: 0 (기본값)
})

// 결과: 매번 stale 상태
// → 컴포넌트 마운트할 때마다 refetch
// → 창 포커스할 때마다 refetch
// → 데이터는 바로 보여주지만, 계속 요청이 나감

3-2. gcTime: "캐시에서 언제 삭제해?"

gcTime (Garbage Collection Time) = 캐시 메모리 유지 시간

┌─────────────────────────────────────────────────────┐
│                                                     │
│   마지막 사용        gcTime 경과                    │
│   (구독 해제)            │                          │
│       │                  │                          │
│       ▼                  ▼                          │
│   ┌───────┐          ┌───────┐                      │
│   │ 캐시  │ ──────▶  │ 삭제  │                      │
│   │ 유지  │          │  됨   │                      │
│   └───────┘          └───────┘                      │
│                                                     │
│   inactive 상태      메모리에서 제거                │
│                                                     │
└─────────────────────────────────────────────────────┘
useQuery({
  queryKey: ['products'],
  queryFn: fetchProducts,
  gcTime: 1000 * 60 * 10, // 10분 후 캐시에서 삭제
})

// 컴포넌트가 언마운트되면:
// 1. 쿼리가 "inactive" 상태가 됨
// 2. gcTime 동안 캐시에 유지
// 3. gcTime 지나면 메모리에서 삭제
// 4. 다시 마운트하면 처음부터 fetch

gcTime 기본값: 5분

3-3. staleTime vs gcTime 비교

타임라인 예시 (staleTime: 1분, gcTime: 5분)
─────────────────────────────────────────────────────

0분      1분      2분      3분      5분      6분
 │        │        │        │        │        │
 ▼        ▼        ▼        ▼        ▼        ▼
fetch   stale    컴포넌트              gcTime   캐시
완료    됨       언마운트             경과     삭제
        │        │                    │        │
        │        └─ inactive 시작 ───┘        │
        │                                      │
        └─ 이 사이에 재접근하면                │
           캐시 데이터 즉시 반환               │
           + 백그라운드 refetch                │
                                               │
                                 다시 접근하면 │
                                 처음부터 fetch
staleTimegcTime
의미신선함 유지 시간캐시 유지 시간
기본값05분
영향refetch 여부메모리 점유
0이면항상 stale (계속 refetch)즉시 삭제 (캐시 없음)
Infinity면영원히 fresh (refetch 안 함)영원히 유지 (메모리 주의)

4. 캐시 동작 흐름

4-1. 첫 번째 요청

1. useQuery 호출
      │
      ▼
2. 캐시에 데이터 있나? ─── 없음 ───▶ 3. fetch 실행
                                           │
                                           ▼
                                    4. 캐시에 저장
                                           │
                                           ▼
                                    5. 컴포넌트에 반환

4-2. 같은 queryKey로 두 번째 요청 (staleTime 내)

1. useQuery 호출
      │
      ▼
2. 캐시에 데이터 있나? ─── 있음
      │
      ▼
3. stale인가? ─── 아니오 (fresh)
      │
      ▼
4. 캐시 데이터 즉시 반환 (fetch 없음!)

4-3. 같은 queryKey로 요청 (staleTime 경과 후)

1. useQuery 호출
      │
      ▼
2. 캐시에 데이터 있나? ─── 있음
      │
      ▼
3. stale인가? ─── 예
      │
      ├──────────────────────────┐
      ▼                          ▼
4. 캐시 데이터               5. 백그라운드에서
   즉시 반환                    refetch
   (일단 보여줌)                   │
                                  ▼
                           6. 새 데이터로
                              캐시 업데이트
                                  │
                                  ▼
                           7. 컴포넌트 리렌더링

이게 바로 Stale-While-Revalidate 패턴이다.


5. Query Key: 캐시의 핵심

5-1. Query Key란?

Query Key는 캐시의 고유 식별자다. 같은 키 = 같은 캐시 데이터.

// 이 두 쿼리는 같은 캐시를 공유한다
// ComponentA.tsx
useQuery({ queryKey: ['products'], queryFn: fetchProducts })

// ComponentB.tsx
useQuery({ queryKey: ['products'], queryFn: fetchProducts })

5-2. Query Key 설계 패턴

// 1. 단순 키
useQuery({ queryKey: ['products'] })

// 2. 파라미터 포함 (ID)
useQuery({ queryKey: ['products', productId] })

// 3. 필터/옵션 포함
useQuery({ 
  queryKey: ['products', { category: 'electronics', sort: 'price' }] 
})

// 4. 계층 구조
useQuery({ queryKey: ['users', userId, 'posts'] })

5-3. Query Key가 다르면 다른 캐시

// 이 세 쿼리는 모두 다른 캐시!
useQuery({ queryKey: ['products'] })
useQuery({ queryKey: ['products', 1] })
useQuery({ queryKey: ['products', 2] })

// 캐시 상태:
// ['products'] → { data: [...전체 상품...] }
// ['products', 1] → { data: { id: 1, name: '맥북' } }
// ['products', 2] → { data: { id: 2, name: '아이폰' } }

5-4. Query Key Factory 패턴

// 추천: Query Key를 한 곳에서 관리
export const productKeys = {
  all: ['products'] as const,
  lists: () => [...productKeys.all, 'list'] as const,
  list: (filters: Filters) => [...productKeys.lists(), filters] as const,
  details: () => [...productKeys.all, 'detail'] as const,
  detail: (id: number) => [...productKeys.details(), id] as const,
}

// 사용
useQuery({ queryKey: productKeys.all })
useQuery({ queryKey: productKeys.detail(1) })
useQuery({ queryKey: productKeys.list({ category: 'phone' }) })

// 무효화도 쉬움
queryClient.invalidateQueries({ queryKey: productKeys.all })
// → products로 시작하는 모든 쿼리 무효화

6. 자동 Refetch 트리거

TanStack Query는 여러 상황에서 자동으로 refetch한다.

6-1. 기본 트리거들

useQuery({
  queryKey: ['products'],
  queryFn: fetchProducts,
  
  // 창에 포커스 돌아올 때 refetch (기본: true)
  refetchOnWindowFocus: true,
  
  // 네트워크 재연결 시 refetch (기본: true)
  refetchOnReconnect: true,
  
  // 컴포넌트 마운트 시 refetch (기본: true)
  refetchOnMount: true,
})

6-2. staleTime과의 관계

// staleTime: 0 (기본값)
// → 위 트리거들이 발생하면 무조건 refetch

// staleTime: 5분
// → 5분 내에는 트리거가 발생해도 refetch 안 함
// → 5분 지나면 트리거 발생 시 refetch

6-3. 폴링 (주기적 refetch)

useQuery({
  queryKey: ['stockPrice'],
  queryFn: fetchStockPrice,
  refetchInterval: 1000 * 5, // 5초마다 refetch
  refetchIntervalInBackground: true, // 탭이 백그라운드여도 refetch
})

7. 캐시 무효화 (Invalidation)

7-1. 언제 무효화하나?

데이터를 수정/삭제/추가한 후에 관련 캐시를 무효화해야 한다.

// 상품 추가 후 목록 캐시 무효화
const mutation = useMutation({
  mutationFn: createProduct,
  onSuccess: () => {
    queryClient.invalidateQueries({ queryKey: ['products'] })
  },
})

7-2. invalidateQueries 동작

invalidateQueries 호출
         │
         ▼
해당 쿼리를 "stale"로 표시
         │
         ├─ 쿼리가 현재 사용 중 (active) ───▶ 즉시 refetch
         │
         └─ 쿼리가 미사용 (inactive) ───▶ 다음 사용 시 refetch

7-3. 무효화 범위 조절

// 정확히 일치하는 키만
queryClient.invalidateQueries({ 
  queryKey: ['products', 1],
  exact: true 
})

// products로 시작하는 모든 키
queryClient.invalidateQueries({ 
  queryKey: ['products'] 
})
// → ['products'], ['products', 1], ['products', { filter: 'x' }] 모두 무효화

7-4. setQueryData: 직접 캐시 업데이트

refetch 없이 캐시를 직접 수정할 수도 있다.

const mutation = useMutation({
  mutationFn: updateProduct,
  onSuccess: (newProduct) => {
    // 방법 1: 무효화 (refetch 발생)
    queryClient.invalidateQueries({ queryKey: ['products'] })
    
    // 방법 2: 직접 업데이트 (refetch 없음, 더 빠름)
    queryClient.setQueryData(['products', newProduct.id], newProduct)
    
    // 방법 3: 목록에서 해당 항목만 업데이트
    queryClient.setQueryData(['products'], (old) => 
      old.map(p => p.id === newProduct.id ? newProduct : p)
    )
  },
})

8. Optimistic Update: 낙관적 업데이트

8-1. 개념

"일단 성공했다고 가정하고 UI 먼저 업데이트"

일반적인 흐름:
클릭 → 로딩... → 서버 응답 → UI 업데이트 (느림)

낙관적 업데이트:
클릭 → UI 즉시 업데이트 → 서버 응답 확인
                              │
                              ├─ 성공: 그대로 유지
                              └─ 실패: 롤백

8-2. 구현 예시

const mutation = useMutation({
  mutationFn: updateTodo,
  
  // 1. 뮤테이션 시작 전
  onMutate: async (newTodo) => {
    // 진행 중인 refetch 취소 (충돌 방지)
    await queryClient.cancelQueries({ queryKey: ['todos'] })
    
    // 이전 값 저장 (롤백용)
    const previousTodos = queryClient.getQueryData(['todos'])
    
    // 캐시 즉시 업데이트 (낙관적)
    queryClient.setQueryData(['todos'], (old) =>
      old.map(todo => todo.id === newTodo.id ? newTodo : todo)
    )
    
    // context로 이전 값 전달
    return { previousTodos }
  },
  
  // 2. 에러 시 롤백
  onError: (err, newTodo, context) => {
    queryClient.setQueryData(['todos'], context.previousTodos)
  },
  
  // 3. 성공/실패 상관없이 refetch로 서버 상태 동기화
  onSettled: () => {
    queryClient.invalidateQueries({ queryKey: ['todos'] })
  },
})

9. 실전 패턴

9-1. staleTime 설정 가이드

// 거의 안 바뀌는 데이터
useQuery({
  queryKey: ['categories'],
  queryFn: fetchCategories,
  staleTime: 1000 * 60 * 60, // 1시간
})

// 가끔 바뀌는 데이터
useQuery({
  queryKey: ['products'],
  queryFn: fetchProducts,
  staleTime: 1000 * 60 * 5, // 5분
})

// 자주 바뀌는 데이터
useQuery({
  queryKey: ['notifications'],
  queryFn: fetchNotifications,
  staleTime: 1000 * 30, // 30초
})

// 실시간 데이터
useQuery({
  queryKey: ['stockPrice'],
  queryFn: fetchStockPrice,
  staleTime: 0, // 항상 stale
  refetchInterval: 1000, // 1초마다 polling
})

9-2. 전역 기본값 설정

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 1000 * 60 * 5, // 5분
      gcTime: 1000 * 60 * 10,   // 10분
      retry: 1,                  // 실패 시 1번 재시도
      refetchOnWindowFocus: false, // 포커스 시 refetch 끄기
    },
  },
})

9-3. Suspense와 함께 사용

// React 18+ Suspense 모드
const { data } = useSuspenseQuery({
  queryKey: ['products'],
  queryFn: fetchProducts,
})

// 부모 컴포넌트
function App() {
  return (
    <Suspense fallback={<Loading />}>
      <ProductList />
    </Suspense>
  )
}

9-4. 에러 바운더리와 함께

useQuery({
  queryKey: ['products'],
  queryFn: fetchProducts,
  throwOnError: true, // 에러 시 ErrorBoundary로 전파
})

// 부모 컴포넌트
function App() {
  return (
    <ErrorBoundary fallback={<Error />}>
      <Suspense fallback={<Loading />}>
        <ProductList />
      </Suspense>
    </ErrorBoundary>
  )
}

10. DevTools로 디버깅

import { ReactQueryDevtools } from '@tanstack/react-query-devtools'

function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <MyApp />
      <ReactQueryDevtools initialIsOpen={false} />
    </QueryClientProvider>
  )
}

DevTools에서 확인할 수 있는 것:

  • 모든 쿼리의 상태 (fresh, stale, fetching, inactive)
  • 캐시된 데이터 내용
  • 각 쿼리의 staleTime, gcTime
  • refetch 트리거 시점

11. 흔한 실수와 해결

"왜 매번 로딩이 뜨지?"

// ❌ staleTime이 0이고 gcTime도 짧으면
// 컴포넌트 언마운트 → 마운트할 때마다 처음부터 fetch
useQuery({
  queryKey: ['products'],
  queryFn: fetchProducts,
  // staleTime: 0 (기본값)
  // gcTime: 1000 * 60 * 5 (기본값 5분)
})

// ✅ staleTime을 적절히 설정
useQuery({
  queryKey: ['products'],
  queryFn: fetchProducts,
  staleTime: 1000 * 60 * 5, // 5분 동안 fresh
})

"수정했는데 목록에 반영이 안 돼요"

// ❌ 무효화 안 함
const mutation = useMutation({
  mutationFn: updateProduct,
  onSuccess: () => {
    // 아무것도 안 함...
  },
})

// ✅ 관련 쿼리 무효화
const mutation = useMutation({
  mutationFn: updateProduct,
  onSuccess: () => {
    queryClient.invalidateQueries({ queryKey: ['products'] })
  },
})

"같은 API인데 왜 따로 fetch 하지?"

// ❌ queryKey가 다르면 다른 캐시
useQuery({ queryKey: ['product', '1'] })  // 문자열 '1'
useQuery({ queryKey: ['product', 1] })    // 숫자 1
// → 서로 다른 캐시!

// ✅ 타입 일관성 유지
const productId = Number(id)
useQuery({ queryKey: ['product', productId] })

"네트워크 탭에 요청이 너무 많아요"

// ❌ 기본값 그대로 사용
// → 창 포커스, 마운트, 네트워크 복구마다 refetch

// ✅ 필요한 것만 켜기
useQuery({
  queryKey: ['products'],
  queryFn: fetchProducts,
  staleTime: 1000 * 60 * 5,
  refetchOnWindowFocus: false,
  refetchOnMount: false,
  refetchOnReconnect: false,
})

12. TanStack Query vs HTTP 캐시 vs Next.js 캐시

요청 흐름에서의 위치:

┌─────────────────────────────────────────────────────────────┐
│                        브라우저                              │
│  ┌─────────────────────────────────────────────────────┐   │
│  │  TanStack Query Cache (메모리)                       │   │
│  │  → API 응답 데이터 캐싱                              │   │
│  │  → staleTime, gcTime으로 관리                        │   │
│  └─────────────────────────────────────────────────────┘   │
│  ┌─────────────────────────────────────────────────────┐   │
│  │  HTTP Cache (디스크)                                 │   │
│  │  → 정적 파일 캐싱 (JS, CSS, 이미지)                  │   │
│  │  → Cache-Control, ETag로 관리                        │   │
│  └─────────────────────────────────────────────────────┘   │
└─────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────┐
│                      Next.js 서버                            │
│  → Router Cache, Full Route Cache, Data Cache 등            │
│  → Part 3에서 다룸                                          │
└─────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────┐
│                          CDN                                 │
│  → 전세계 엣지 서버에 캐싱                                   │
│  → Part 4에서 다룸                                          │
└─────────────────────────────────────────────────────────────┘

12-1. TanStack Query와 Next.js Data Cache 비교

둘 다 API 응답을 캐싱하지만, 실행 위치가 다르다:

TanStack QueryNext.js Data Cache
위치브라우저 (클라이언트)Next.js 서버
범위현재 사용자의 현재 탭모든 사용자가 공유
지속성탭 닫으면 사라짐서버에 영구 저장
용도Client ComponentServer Component
무효화invalidateQueriesrevalidateTag

12-2. 언제 뭘 쓰나?

상황추천이유
Server Component에서 fetchNext.js Data Cache서버에서 캐싱, SEO 유리
Client Component에서 fetchTanStack Query클라이언트 상태 관리
실시간 데이터 (polling)TanStack QueryrefetchInterval 지원
정적 데이터 (SSG/ISR)Next.js Full Route Cache빌드 시 생성
사용자별 다른 데이터TanStack Query + private개인화 데이터

13. 빠른 참조

주요 옵션

useQuery({
  queryKey: ['key'],           // 캐시 식별자
  queryFn: fetchFn,            // 데이터 fetch 함수
  staleTime: 0,                // fresh 유지 시간 (기본: 0)
  gcTime: 1000 * 60 * 5,       // 캐시 유지 시간 (기본: 5분)
  refetchOnWindowFocus: true,  // 포커스 시 refetch
  refetchOnMount: true,        // 마운트 시 refetch
  refetchOnReconnect: true,    // 재연결 시 refetch
  refetchInterval: false,      // 폴링 간격
  retry: 3,                    // 재시도 횟수
  enabled: true,               // 쿼리 활성화 여부
})

캐시 조작

// 무효화
queryClient.invalidateQueries({ queryKey: ['products'] })

// 직접 데이터 설정
queryClient.setQueryData(['products', id], newData)

// 데이터 가져오기
queryClient.getQueryData(['products'])

// 캐시에서 제거
queryClient.removeQueries({ queryKey: ['products'] })

// refetch 강제 실행
queryClient.refetchQueries({ queryKey: ['products'] })

상태 체크

const {
  data,           // 캐시된 데이터
  error,          // 에러 객체
  isLoading,      // 첫 로딩 중 (캐시 없음)
  isFetching,     // fetch 진행 중 (캐시 있어도)
  isStale,        // stale 상태인지
  isSuccess,      // 성공 상태
  isError,        // 에러 상태
  refetch,        // 수동 refetch 함수
} = useQuery(...)

다음 편 예고

Part 3: Next.js 캐싱에서는:

  • TanStack Query가 클라이언트라면, Next.js는 서버!
  • 서버 사이드의 4가지 캐시 레이어
  • RSC Payload와 캐싱의 관계
  • Server Component vs Client Component 데이터 전략

참고 자료

profile
Think Big Aim High Act Now

0개의 댓글