서버 상태 관리 라이브러리의 표준이 된 React Query. 그 핵심 기능은 단연 '캐싱'입니다. 하지만 설정을 들여다보면 staleTime과 cacheTime이라는 비슷해 보이는 두 가지 옵션이 우리를 혼란스럽게 합니다.

"둘 다 시간 설정 같은데, 하나만 쓰면 안 되나?", "왜 캐시 타임을 늘렸는데 데이터가 계속 새로고침 되지?" 이런 의문을 가져보셨다면 오늘 글이 완벽한 해답이 될 것입니다. 이 두 지표의 메커니즘을 이해하는 것은 효율적인 프런트엔드 성능 최적화의 첫걸음입니다.


1. 데이터의 상태: Fresh vs Stale

본격적인 비교에 앞서, 리액트 쿼리가 데이터를 바라보는 두 가지 상태를 이해해야 합니다.

  • Fresh (신선한 상태): 서버에서 막 가져온 데이터입니다. 이 상태에서는 컴포넌트가 다시 마운트되어도 추가적인 네트워크 요청을 보내지 않습니다.
  • Stale (탁한/상한 상태): 가져온 지 일정 시간이 지나 '신선하지 않다'고 판단된 데이터입니다. 이 상태의 데이터는 컴포넌트가 마운트 되거나 윈도가 포커스 될 때 백그라운드에서 다시 요청(Refetch)됩니다.

2. staleTime: "이 데이터는 언제까지 신선한가?"

staleTime은 데이터를 Fresh 상태에서 Stale 상태로 변경하는 데 걸리는 시간입니다.

2.1 동작 원리

  • 데이터를 처음 가져오면 즉시 Fresh 상태가 됩니다.
  • staleTime이 5분(300,000ms)이라면, 5분 동안은 컴포넌트가 몇 번을 리렌더링 되거나 다시 마운트 되어도 서버에 요청을 보내지 않습니다.
  • 5분이 지나면 데이터는 Stale 상태가 됩니다. 이때부터는 컴포넌트가 마운트될 때마다 서버에 "최신 데이터 맞니?"라고 물어보게 됩니다.

2.2 실무 팁

자주 바뀌지 않는 공지사항이나 설정 데이터는 staleTime을 길게(예: 1시간) 잡는 것이 서버 부하를 줄이는 데 매우 효과적입니다. 반면 주식 시세나 실시간 채팅은 0으로 설정하여 항상 최신 상태를 유지해야 합니다. (기본값은 0입니다.)


3. gcTime (과거 cacheTime): "메모리에 얼마나 남겨둘 것인가?"

Next.js나 최신 버전의 React Query(v5)에서는 cacheTime이 gcTime (Garbage Collection Time)으로 이름이 바뀌었습니다. 이름에서 알 수 있듯, 이는 데이터를 메모리에서 완전히 삭제(청소)하기까지의 시간입니다.

3.1 동작 원리

  • 데이터를 사용하는 모든 컴포넌트가 언마운트되어 '비활성(Inactive)' 상태가 된 순간부터 타이머가 돌아갑니다.
  • 기본값인 5분(300,000ms) 동안 사용되지 않으면, 리액트 쿼리는 메모리 절약을 위해 해당 데이터를 삭제합니다.
  • 데이터가 삭제되기 전에 다시 컴포넌트가 마운트 되면, 서버 응답을 기다리는 동안 메모리에 남아있던 '캐시 데이터'를 먼저 보여주어 사용자 경험을 높입니다.

4. 결정적 차이: 쟁점은 '네트워크 요청'인가 '메모리 삭제'인가

두 개념을 한 문장으로 정리하면 다음과 같습니다.

staleTime은 다시 요청(Refetch)할 것인가를 결정하고, gcTime은 메모리에서 지울 것인가를 결정합니다.

4.1 시나리오로 이해하기

설정: staleTime: 2분, gcTime: 5분

  1. 0분: 데이터 패칭 완료. 데이터는 Fresh 상태.
  2. 1분: 페이지 이동으로 컴포넌트 언마운트. 데이터는 Inactive 상태가 되며 gcTime 타이머 시작.
  3. 3분: 다시 컴포넌트 마운트.
    • staleTime(2분)이 지났으므로 데이터는 Stale 상태입니다. -> 네트워크 요청 발생.
    • gcTime(5분)은 아직 안 지났으므로 메모리에 데이터가 남아있습니다. -> 서버 응답 전까지 이전 데이터를 화면에 즉시 보여줌 (Flash of old content).
  4. 6분: 컴포넌트 마운트.
    • gcTime이 지났으므로 메모리에 데이터가 없습니다. -> 로딩 스피너를 보여주며 새로 가져옴.

5. 실무 최적화 전략: 어떻게 설정하는 것이 베스트인가?

5.1 staleTime < gcTime (권장 패턴)

가장 일반적인 설정입니다. 메모리에는 데이터를 남겨두어 로딩 속도를 높이되, 일정 시간이 지나면 서버에서 최신화를 시도하는 방식입니다.

5.2 staleTime > gcTime (의미 없는 설정)

만약 데이터를 신선하다고 생각해서 요청을 안 하려는데(staleTime 10분), 메모리에서 이미 지워버렸다면(gcTime 2분), 결국 다시 서버에서 가져와야 하므로 최적화 효과가 사라집니다. 항상 gcTime은 staleTime보다 길거나 같아야 합니다.

리액트(React)를 사용하여 데이터를 불러오는 컴포넌트를 만들 때, 우리는 보통 다음과 같은 코드를 작성하곤 합니다.

JavaScript
 
if (isLoading) return <LoadingSpinner />;
if (isError) return <ErrorMessage />;
return <DataView data={data} />;

익숙한 코드지만, 이 방식은 '명령형(Imperative)'에 가깝습니다. 컴포넌트 하나가 비즈니스 로직뿐만 아니라 로딩 처리, 에러 처리라는 세 가지 책임을 모두 떠안게 되기 때문입니다. 프로젝트가 커질수록 모든 컴포넌트에 이런 중복 코드가 들어가게 되고, UI의 일관성은 떨어집니다.

오늘은 리액트가 지향하는 '선언적 UI'의 완성형인 Suspense와 ErrorBoundary를 활용해, 비정상적인 상태(로딩, 에러)를 컴포넌트 밖으로 우아하게 밀어내는 전략을 알아보겠습니다.


1. 명령형 UI vs 선언적 UI

1.1 무엇이 문제인가?

명령형 방식에서는 개발자가 "로딩 중이면 스피너를 보여주고, 아니면 데이터를 보여줘"라고 일일이 로직을 지시해야 합니다. 이는 컴포넌트를 무겁게 만들고, 테스트를 어렵게 하며, 코드의 흐름을 방해합니다.

1.2 선언적 방식의 지향점

선언적 방식은 "이 컴포넌트는 데이터가 있을 때 이렇게 그려져야 해"라고 정의만 합니다. 데이터가 아직 준비되지 않았거나(Loading), 가져오는 데 실패했을 때(Error)의 처리는 컴포넌트를 감싸고 있는 '부모(상위 환경)'에게 맡깁니다.


2. Suspense: 데이터 로딩을 기다리는 우아한 방법

Suspense는 하위 컴포넌트가 무언가를 '기다리고 있다'는 것을 리액트에게 알리는 도구입니다.

2.1 주요 특징

  • 관심사 분리: 컴포넌트 내부에서 isLoading을 체크할 필요가 없습니다.
  • 로딩 UI의 중앙 집중화: 여러 개의 컴포넌트가 로딩 중일 때 하나의 스켈레톤 UI로 묶어서 보여줄 수 있습니다.
JavaScript
 
import { Suspense } from 'react';

function App() {
  return (
    <Layout>
      <Suspense fallback={<SkeletonUI />}>
        <UserProfile /> {/* 내부에서 데이터 패칭 중이라면 fallback이 보임 */}
      </Suspense>
    </Layout>
  );
}

이렇게 작성하면 UserProfile은 데이터가 성공적으로 도착했을 때의 로직만 담으면 됩니다. 데이터가 비어있을 때 발생하는 런타임 에러 걱정에서도 자유로워집니다.


3. ErrorBoundary: 애플리케이션의 안전망

에러 바운더리는 하위 컴포넌트 트리 어디에서든 자바스크립트 에러가 발생했을 때, 앱 전체가 하얗게 변하는(White Screen) 것을 방지하고 대신 폴백(Fallback) UI를 보여주는 컴포넌트입니다.

3.1 왜 필요한가?

리액트에서 렌더링 중 에러가 발생하면 전체 컴포넌트 트리가 언마운트됩니다. 사용자 입장에서는 서비스가 갑자기 꺼지는 것과 같습니다. ErrorBoundary를 사용하면 특정 영역의 에러가 전체 앱으로 전파되는 것을 막을 수 있습니다.

3.2 선언적 에러 처리 예시

(참고: 현재 에러 바운더리는 클래스 컴포넌트로만 작성이 가능하거나, react-error-boundary 라이브러리를 사용합니다.)

JavaScript
 
import { ErrorBoundary } from 'react-error-boundary';

function ErrorFallback({ error, resetErrorBoundary }) {
  return (
    <div role="alert">
      <p>문제가 발생했습니다.</p>
      <pre>{error.message}</pre>
      <button onClick={resetErrorBoundary}>다시 시도</button>
    </div>
  );
}

function App() {
  return (
    <ErrorBoundary FallbackComponent={ErrorFallback} onReset={() => {/* 상태 초기화 */}}>
      <Suspense fallback={<Loading />}>
        <DataDependentComponent />
      </Suspense>
    </ErrorBoundary>
  );
}

4. 실무 패턴: 선언적 UI의 중첩 구조

실무에서는 보통 이 두 가지를 겹쳐서 사용합니다. 이를 통해 '로딩 -> 에러 -> 데이터 성공'이라는 데이터 패칭의 3단계를 완벽하게 제어할 수 있습니다.

  1. 가장 바깥쪽: ErrorBoundary (치명적인 에러 방어)
  2. 중간: Suspense (비동기 데이터 로딩 대기)
  3. 안쪽: 실제 비즈니스 로직 컴포넌트

이 구조를 사용하면 개발자는 안쪽 컴포넌트에서 데이터가 '무조건 존재한다'고 가정하고 코드를 짤 수 있습니다. 이를 'Suspense-enabled Data Fetching'이라고 부르며, React Query(TanStack Query)와 함께 쓸 때 가장 강력한 시너지를 냅니다.


5. 선언적 UI 도입의 장점

  • 가독성 폭발: 컴포넌트 본연의 목적(UI 정의)에만 집중할 수 있어 코드가 짧아지고 명확해집니다.
  • UX 개선: 로딩과 에러 화면을 디자이너가 의도한 대로 일관성 있게 보여줄 수 있습니다.
  • 유지보수 용이: 로딩 스피너 디자인을 바꾸고 싶다면, 수백 개의 컴포넌트를 수정하는 대신 상위의 Suspense fallback만 고치면 됩니다.

+ Recent posts