React Query 캐시 전략에 대한 고찰

React Query 캐시 전략에 대한 고찰

1. 서론: 서버 상태(Server State)는 언제 낡는가

프론트엔드를 운영하다 보면 사용자로부터 반복적으로 듣게 되는 두 가지 제보가 있습니다. 하나는 “방금 등록했는데 목록에 안 보여요”이고, 다른 하나는 그 반대인 “가만히 있었는데 화면이 갑자기 깜빡이며 다시 로딩됐어요”입니다. 언뜻 무관해 보이는 이 두 증상은 사실 같은 뿌리에서 나옵니다. 서버의 데이터를 클라이언트가 “얼마 동안, 어떤 기준으로 아직 유효하다고 믿을 것인가”라는 캐시(Cache) 정책의 부재입니다.

많은 팀이 이 문제를 React Query(현재의 TanStack Query)로 풀고 있고, 제가 속한 프로젝트도 마찬가지입니다. 그런데 막상 코드를 들여다보면, 정작 가장 중요한 “캐시를 얼마나 살려둘지”에 대한 합의가 한곳에 모여 있지 않고 개별 훅마다 흩어져 있는 경우가 대단히 흔합니다.

React Query의 가치는 “서버 상태(Server State)”와 “클라이언트 상태(Client State)”를 구분하는 데서 출발합니다. 모달이 열렸는지 같은 값은 화면이 소유하는 클라이언트 상태이지만, 목록·상세·사용자 정보처럼 원본이 서버에 있는 데이터는 우리가 사본을 잠시 빌려 쓰는 것일 뿐입니다. 이 사본은 내가 보고 있는 사이에도 서버에서 바뀔 수 있습니다. 이것을 useState + useEffect로 손수 다루면 로딩·에러 플래그를 직접 관리해야 하고, 같은 데이터를 여러 화면에서 부르면 요청이 그대로 중복됩니다. React Query는 queryKey라는 이름표로 응답을 캐시에 보관해 이 반복을 대신 처리합니다. 결국 “캐시를 어떻게 다스리느냐”가 곧 이 라이브러리를 잘 쓰는 일의 본질입니다.

2. 핵심 개념: 신선함(fresh)과 오래됨(stale), 그리고 캐시 수명

2.1 staleTime — 데이터의 ‘유통기한’

React Query의 모든 데이터는 신선함(fresh) 아니면 오래됨(stale), 둘 중 하나입니다. 방금 받아온 데이터는 fresh이고 staleTime이 지나면 stale로 넘어갑니다. 식료품의 유통기한에 빗대면 쉽습니다. 핵심은 stale이 되어도 데이터를 버리지는 않는다는 점입니다. 유통기한이 지난 음식도 일단 식탁에는 올리되, “다음에 장 볼 기회가 생기면 새것으로 바꾸겠다”는 표시만 붙이는 셈입니다. 그리고 함정이 하나 있습니다. staleTime의 기본값은 0이라, 데이터는 받아오자마자 곧바로 stale이 됩니다.

2.2 gcTime — 캐시의 ‘창고 보관 기간’

어떤 쿼리를 쓰던 컴포넌트가 화면에서 사라지면 그 쿼리는 비활성(inactive) 상태가 됩니다. gcTime은 이 비활성 캐시를 메모리에서 비우기까지 기다리는 시간이며 기본값은 5분입니다. 창고 보관 기간에 해당합니다. 이 시간 안에 같은 화면으로 돌아오면 캐시가 살아 있어 즉시 보여줄 수 있고, 지나면 가비지 컬렉션(Garbage Collection) 대상이 되어 다음에는 처음부터 다시 받습니다.

2.3 재요청(refetch)은 언제 일어나는가

stale 데이터는 네 가지 순간에 다시 받아옵니다. 컴포넌트가 새로 마운트될 때, 브라우저 창에 다시 포커스가 갈 때, 끊겼던 네트워크가 다시 연결될 때, 그리고 우리가 명시적으로 무효화(Invalidation)를 호출할 때입니다. 단, 이 모든 트리거는 데이터가 stale일 때만 작동합니다. fresh라면 창을 아무리 들락거려도 재요청은 일어나지 않습니다. 그래서 “얼마나 자주 다시 받아오느냐”의 대부분은 staleTime을 어떻게 잡느냐로 결정됩니다.

구분

staleTime

gcTime

묻는 질문

얼마나 신선한가

얼마나 보관하는가

기본값

0 (즉시 stale)

5분

영향 범위

재요청(refetch) 시점

메모리 점유 / 재방문 속도

기간이 지나도

데이터는 화면에 유지

캐시 자체가 삭제

3. 흔히 빠지는 캐시 안티패턴

3.1 전역 기준 없이 staleTime을 훅마다 흩뿌린다

전역 기본값을 정하지 않으면 staleTime은 0이 됩니다. 그래서 화면을 다시 들어갈 때마다, 컴포넌트가 다시 마운트될 때마다 같은 요청이 또 나갑니다. 이를 뒤늦게 알아챈 개발자들이 화면별로 staleTime을 손으로 한두 군데 박아 넣기 시작하는데, 누구는 1분, 누구는 5분, 대부분은 무설정인 채로 남습니다. 값에 근거가 없으니 새로 합류한 사람은 “이 데이터는 얼마나 자주 갱신돼야 맞는가”를 판단할 기준을 잃습니다.

3.2 조회 키와 무효화 키가 어긋난다

가장 흔하면서도 잡기 어려운 실제 버그입니다. 예를 들어 게시글 목록을 아래처럼 한 키로 캐싱해 두고, 등록 후에는 엉뚱한 키를 무효화한다고 가정해 보겠습니다.

// 목록은 이 키로 캐싱했는데
useQuery({ queryKey: ['posts'], ... })
// 글 등록 후엔 다른 키를 무효화한다면
queryClient.invalidateQueries({ queryKey: ['postList'] }); // 매칭 안 됨 → 목록 그대로

두 키는 글자가 다르므로 매칭되지 않고, 목록 캐시는 그대로 남습니다. 등록은 성공했는데 화면은 갱신되지 않는 것이죠. 컴파일러는 이 둘이 같은 데이터를 가리키는지 알 수 없어 경고조차 띄우지 못합니다. 사소해 보이지만, 키를 문자열로 손수 적는 한 어느 프로젝트에서나 반복되는 실수입니다. 실제로 제가 본 코드 중에는 같은 파일 안에서 삭제할 때는 올바른 키를, 등록할 때는 엉뚱한 키를 무효화해서 “삭제는 되는데 등록은 화면에 안 뜨는” 미묘한 버그가 살아 있던 경우도 있었습니다.

3.3 캐시 바깥의 수동 패칭이 공존한다

일부 화면은 React Query로, 다른 화면은 여전히 useState와 useEffect로 같은 데이터를 받아오는 경우입니다. 이렇게 되면 한쪽에만 캐시가 있어 데이터가 서로 어긋날 수 있고, 중복 제거와 자동 갱신의 이점도 그 화면에서는 사라집니다. 마이그레이션이 끝나지 않은 코드베이스에서 특히 자주 보입니다.

4. 효과적인 캐시 운영 전략

4.1 전역 기본값(Default Options)부터 합의한다

가장 먼저 할 일은 QueryClient의 defaultOptions에 캐시의 기준선을 명시하는 것입니다. staleTime을 0이 아닌 합리적인 값으로 깔고, refetchOnWindowFocus는 전역에서 끈 뒤 실시간성이 꼭 필요한 화면에서만 예외적으로 켜는 방식을 권합니다. 이 한 번의 설정으로 3.1이 정리되고, 개별 훅에서 같은 옵션을 반복해 적을 일도 사라집니다.

new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 1000 * 30, // 기준선: 30초 동안은 신선하다고 본다
      refetchOnWindowFocus: false, // 기본은 끄고, 필요한 화면만 켠다
      retry: 1, // 실패 시 과한 재시도 방지
    },
  },
});

숫자 자체보다 중요한 것은 “기준선이 코드 한곳에 존재한다”는 사실입니다. 이제 개별 훅은 기준선과 다르게 가야 할 때만 옵션을 덮어쓰면 되고, 그 덮어쓴 자리가 곧 “이 데이터는 특별하다”는 신호가 됩니다.

4.2 데이터의 성격으로 staleTime을 분류한다

“staleTime을 몇 분으로 할까”를 화면마다 정하지 말아야 합니다. 기준은 화면이 아니라 데이터가 얼마나 자주 바뀌느냐입니다. 데이터를 세 부류로 나눠 두면 새 쿼리를 만들 때 고민이 사라집니다. 마스터성 데이터처럼 거의 변하지 않는 값은 staleTime을 길게(때로는 Infinity로) 두고, 대신 “값이 실제로 바뀌는 순간 직접 무효화한다”는 약속을 함께 가져가는 편이 효율적입니다. 그러면 평소에는 네트워크를 전혀 쓰지 않다가, 정말 바뀔 때만 한 번 갱신합니다. gcTime은 보통 기본 5분으로 충분하지만, 키오스크나 임베디드처럼 한 화면에 오래 머무는 흐름이라면 늘려서 “돌아왔더니 다시 로딩”을 피하고, 메모리가 빠듯한 환경이라면 줄이는 식으로 의식적으로 조정합니다.

분류

예시

권장 staleTime

마스터성 (거의 불변)

카테고리, 코드 테이블, 조직/부서 구조

5~10분 이상 + 변경 시 직접 무효화

준정적 (가끔 변함)

내 프로필, 권한·설정

1~3분

트랜잭션성 (자주 변함)

주문/게시글 목록, 알림, 재고

0~30초

4.3 쿼리 키는 ‘팩토리’로 일원화한다

3.2의 키 불일치 버그를 구조적으로 없애는 방법은 단순합니다. 키를 손으로 적지 않고, 키를 만들어 주는 함수(팩토리)를 거치게 하는 것입니다. 계층적으로 설계해 두면 무효화가 훨씬 강력해집니다.

export const postKeys = {
  all: ['posts'] as const,
  lists: () => [...postKeys.all, 'list'] as const,
  list: (filters) => [...postKeys.lists(), filters] as const,
  detail: (id) => [...postKeys.all, 'detail', id] as const,
};

여기서 한 가지 메커니즘을 알아두면 좋습니다. invalidateQueries는 키의 ‘부분 일치(prefix match)’로 동작합니다. 즉 invalidateQueries({ queryKey: postKeys.all }) 한 줄이면 ['posts','list', ...]로 캐싱된 모든 목록과 상세가 한꺼번에 걸립니다. 반대로 특정 글 하나만 갱신하고 싶으면 postKeys.detail(id)만 무효화하면 됩니다. 조회도 무효화도 같은 팩토리에서 키를 꺼내 쓰므로, 글자가 어긋날 일이 원천적으로 사라집니다. 키를 한곳에서만 정의하니 자동완성과 타입 검사의 도움까지 받습니다.

4.4 무효화만이 답은 아니다: 직접 갱신과 낙관적 업데이트

무효화(invalidate)는 “이 캐시는 낡았으니 다시 받아와라”라는 지시입니다. 안전하지만 네트워크 요청을 한 번 더 유발합니다. 그런데 변경 API의 응답에 이미 최신 데이터가 들어 있다면, 굳이 다시 받을 필요 없이 setQueryData로 캐시를 직접 갈아끼우는 편이 빠르고 알뜰합니다. 요청 한 번을 통째로 아끼는 것이죠.

한발 더 나아가, 좋아요 토글이나 체크박스처럼 즉각적인 반응이 중요한 동작에는 낙관적 업데이트(Optimistic Update)를 씁니다. onMutate 단계에서 서버 응답을 기다리지 않고 캐시를 미리 바꿔 화면을 즉시 갱신하고, 혹시 요청이 실패하면 이전 값으로 롤백하는 방식입니다. 사용자는 지연을 거의 느끼지 못합니다. 정리하면 ‘다시 받아오기(invalidate) → 응답으로 갈아끼우기(setQueryData) → 미리 바꾸고 실패 시 되돌리기(optimistic)’ 순으로, 즉시성이 중요할수록 오른쪽을 택하면 됩니다.

4.5 전환을 매끄럽게: keepPreviousData와 placeholderData

페이지네이션이나 검색처럼 같은 화면에서 파라미터만 바뀌는 경우, 키가 바뀔 때마다 화면이 빈 상태로 깜빡이며 스피너가 돕니다. 이때 placeholderData: keepPreviousData를 주면 새 데이터가 도착할 때까지 이전 페이지의 데이터를 그대로 보여줍니다. 표가 사라졌다 나타나지 않고 자연스럽게 다음 페이지로 넘어가죠. 목록·검색 UI에서 체감 품질을 가장 싸게 끌어올리는 방법이고, 이미 잘 쓰고 있다면 다른 목록으로 넓히기를 권합니다.

4.6 기다림을 미리 숨긴다: prefetch와 initialData

사용자가 다음에 무엇을 볼지 예측 가능하다면, 그 데이터를 미리 받아둘 수 있습니다. 목록에서 어떤 항목에 마우스를 올린 순간 prefetchQuery로 상세를 먼저 받아두면, 실제로 클릭했을 때는 이미 캐시에 있어 즉시 열립니다. 반대로 이미 손에 쥔 데이터가 있다면(목록 응답에 상세 일부가 포함되어 있거나 SSR로 내려받은 경우) initialData로 캐시의 시작값을 채워 첫 로딩을 건너뛸 수 있습니다. 둘 다 “기다리는 시간을 사용자 눈에 보이지 않는 곳으로 옮기는” 기법입니다.

4.7 캐시를 눈으로 본다: Devtools

마지막으로, 개발 빌드에만 @tanstack/react-query-devtools를 붙이길 강력히 권합니다. 각 쿼리가 fresh·stale·inactive 중 어떤 상태인지, 무효화가 실제로 먹었는지, 어떤 키가 몇 개나 떠 있는지를 그림으로 보여줍니다. 3.2 같은 키 불일치는 Devtools를 한 번만 열어 봐도 곧바로 드러납니다. 추측으로 디버깅하던 캐시 문제를, 눈으로 확인하는 문제로 바꿔 줍니다.

5. 결론

React Query를 잘 쓴다는 것은 화려한 기능을 많이 쓰는 일이 아니라, 캐시에 대한 약속을 코드 한곳에 명문화하는 일에 가깝습니다. 첫째로 전역 기본값을 세워 기준선을 만들고, 둘째로 데이터의 성격에 따라 staleTime을 분류하며, 셋째로 쿼리 키를 팩토리로 모읍니다. 이 세 가지만으로도 “등록했는데 목록이 안 바뀐다”, “화면을 옮길 때마다 깜빡인다” 같은 증상의 대부분이 사라집니다.

그 위에 한 겹을 더 얹는 것이 효율입니다. 모든 변경을 무효화로 처리해 매번 다시 받아오는 대신, 응답으로 캐시를 직접 갱신하고(setQueryData), 즉각성이 필요한 곳은 낙관적 업데이트로 처리하며, 예측 가능한 다음 화면은 미리 받아둡니다(prefetch). 캐시를 “언제 비울까”의 문제가 아니라 “언제, 어떻게 채울까”의 문제로 바라보기 시작하면, 같은 서버와 같은 네트워크 위에서도 훨씬 빠르고 조용하게 동작하는 화면을 만들 수 있습니다. 결국 좋은 캐시 전략이란, 사용자가 기다림을 느끼지 못하게 만드는 일입니다.

참고 자료

  1. TanStack Query – Important Defaults (기본값 정리) — https://tanstack.com/query/latest/docs/framework/react/guides/important-defaults

  2. TanStack Query – Caching 메커니즘 — https://tanstack.com/query/latest/docs/framework/react/guides/caching

  3. TanStack Query – Query Invalidation — https://tanstack.com/query/latest/docs/framework/react/guides/query-invalidation

  4. TanStack Query – Optimistic Updates — https://tanstack.com/query/latest/docs/framework/react/guides/optimistic-updates

  5. TanStack Query – Prefetching — https://tanstack.com/query/latest/docs/framework/react/guides/prefetching

  6. TkDodo – Effective React Query Keys — https://tkdodo.eu/blog/effective-react-query-keys

  7. TkDodo – Practical React Query — https://tkdodo.eu/blog/practical-react-query

toffeeman

Site footer