frontend/react

[React] React.js 강좌 23. React Query로 데이터 캐싱 전략 세우기

mirabo01 2025. 11. 11. 08:48

React 프로젝트를 하다 보면 ‘데이터를 언제, 얼마나 자주 불러와야 하는가’가 늘 고민거리입니다.
단순히 한두 번의 요청이라면 신경 쓰지 않아도 되지만,
규모가 커질수록 같은 데이터를 반복해서 요청하게 되고,
API 트래픽이 급격히 늘어나면서 성능 저하가 일어납니다.

저는 이런 문제를 React Query를 통해 해결했습니다.
이 라이브러리를 처음 접했을 땐 단순히 useQuery로 API를 감싸는 정도로만 썼지만,
이후 실무에서 본격적으로 캐싱 전략을 세워보니 정말 다른 세상이 열리더군요.


캐싱을 ‘잘’ 한다는 건 결국 타이밍의 문제입니다

React Query의 핵심은 “데이터를 다시 불러올 타이밍을 제어한다”는 점입니다.
즉, 같은 데이터를 언제 새로 요청하고, 언제 캐시에서 꺼내 쓸지 결정하는 게 핵심입니다.

처음엔 React Query가 알아서 다 해줄 줄 알았습니다.
하지만 실제로 써보면 staleTime, cacheTime, refetchOnWindowFocus,
이 세 가지 옵션을 이해하지 못하면 의도치 않게 매번 API를 호출하게 됩니다.

제가 쓰는 기준은 아래와 같습니다.

  • staleTime: 데이터가 “신선한 상태”로 간주되는 시간
    → 예를 들어 5분이라면, 5분 안에는 다시 API를 부르지 않음
  • cacheTime: 메모리에서 캐시를 유지하는 시간
    → 탭을 닫고 다시 돌아왔을 때 같은 데이터를 보여줄지 결정
  • refetchOnWindowFocus: 브라우저 창으로 돌아올 때 자동으로 새로고침할지 여부

이걸 조합해서 서비스 성격에 맞게 조절해야 합니다.
예를 들어, 대시보드처럼 실시간 데이터가 중요하면 staleTime을 짧게,
게시판이나 설정 페이지처럼 자주 변하지 않는 데이터는 길게 설정합니다.


React Query 캐싱이 주는 체감적인 변화

이전에 React Query를 쓰기 전엔 API 요청 횟수가 많아서
네트워크 탭에 GET 요청이 줄줄이 찍히는 걸 자주 봤습니다.
특히 페이지 전환 후 돌아올 때마다 데이터를 새로 불러오니
사용자 입장에선 ‘페이지가 느리다’는 느낌을 받을 수밖에 없었습니다.

React Query를 적용하고 나서는 상황이 완전히 바뀌었습니다.
캐싱된 데이터를 즉시 보여준 뒤,
백그라운드에서 새 데이터를 받아와 업데이트하는 구조로 변경된 겁니다.

결과적으로 화면은 즉시 렌더링되고,
데이터는 최신 상태로 유지되니 사용자 경험이 훨씬 자연스러워졌습니다.


실무에서 자주 쓰는 캐싱 패턴

제가 가장 자주 쓰는 패턴은 “페이지 단위 캐싱”입니다.
즉, 리스트 페이지에서는 데이터 전체를 캐싱하고,
상세 페이지로 들어가면 선택한 항목만 별도의 쿼리로 캐싱합니다.

예를 들어 아래와 같은 구조입니다.

const { data: users } = useQuery({
  queryKey: ['users'],
  queryFn: fetchUserList,
  staleTime: 1000 * 60 * 5, // 5분 동안 신선함 유지
});

const { data: userDetail } = useQuery({
  queryKey: ['user', id],
  queryFn: () => fetchUserDetail(id),
  enabled: !!id, // id가 있을 때만 요청
});

이렇게 하면 리스트에서 특정 유저를 클릭해 상세 페이지로 넘어가도
이미 캐싱된 데이터가 즉시 보여집니다.
뒤로가기를 눌러 다시 리스트로 돌아와도 네트워크 요청이 발생하지 않습니다.

이런 구조를 처음 적용했을 때,
대시보드의 체감 속도가 2~3배는 빨라졌습니다.


invalidateQueries와 prefetchQuery로 완성하는 데이터 흐름

React Query는 단순히 캐싱만 해주는 게 아닙니다.
데이터를 “언제 무효화하고”, “언제 미리 불러올지”까지 제어할 수 있습니다.

예를 들어, 새 게시글을 작성한 뒤 리스트를 최신화하려면 이렇게 합니다.

const queryClient = useQueryClient();
const mutation = useMutation(addPost, {
  onSuccess: () => {
    queryClient.invalidateQueries(['posts']);
  },
});

invalidateQueries는 해당 캐시를 “오래된 데이터”로 표시하고,
자동으로 다시 API를 불러옵니다.
반대로 prefetchQuery는 페이지 이동 전에 미리 데이터를 가져와
사용자가 진입하자마자 화면이 즉시 렌더링되게 해줍니다.

await queryClient.prefetchQuery(['post', id], () => fetchPost(id));

이 두 기능을 조합하면,
사용자가 페이지를 이동하는 순간 데이터를 기다릴 필요가 없어집니다.
저는 이 방식을 “느낌상 SSR처럼 보이는 CSR”이라고 부릅니다.


캐시 정책을 정할 때 고려해야 할 점

React Query를 실무에 적용하면서 느낀 건
‘모든 데이터를 캐싱하면 오히려 느려질 수도 있다’는 점이었습니다.
예를 들어, 항상 최신 상태여야 하는 알림이나 채팅 데이터는
캐싱보다는 WebSocket이나 SSE로 처리하는 게 낫습니다.

React Query는 변하지 않는 데이터에 가장 적합합니다.
즉, “읽기 빈도는 높지만, 변경은 적은 데이터”에 최적화되어 있습니다.
실시간성이 강한 데이터와는 병행해서 사용하는 게 좋습니다.


실제 프로젝트에서의 변화

React Query를 본격적으로 도입한 뒤,
백엔드 개발자들과의 협업 방식도 바뀌었습니다.
예전에는 “이 API 또 호출돼요” 같은 이야기를 자주 들었는데,
지금은 캐싱 정책을 문서화해두고, 각 페이지의 쿼리 키와 유지 시간을 명시해둡니다.

이렇게 구조를 정리해두니
API 트래픽이 평균 40% 정도 줄었고,
페이지 전환 속도도 눈에 띄게 빨라졌습니다.

무엇보다 개발자 입장에서 “데이터를 다시 불러와야 하나?”라는 고민이 사라졌습니다.
React Query가 알아서 판단해주기 때문입니다.


데이터를 잘 캐싱한다는 건 단순히 API 요청을 줄이는 게 아니라,
사용자가 “빠르게 느끼게 하는 기술”이라고 생각합니다.
React Query는 그걸 가장 자연스럽게 구현할 수 있는 도구였습니다.

다음 글에서는 이 흐름을 조금 더 확장해서,
Suspense와 Error Boundary를 활용한 비동기 렌더링 UX 이야기를 해보려 합니다.
React 18부터 달라진 렌더링 방식과 함께,
로딩 경험을 자연스럽게 만드는 법을 실제 예제 중심으로 정리해보겠습니다.