서버 상태 관리 라이브러리의 표준이 된 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) 프로젝트의 규모가 커지면 필연적으로 마주하게 되는 현상이 있습니다. 바로 Props Drilling입니다. 최상위 컴포넌트에 있는 데이터를 5단계 아래에 있는 자식 컴포넌트로 전달하기 위해, 중간에 있는 컴포넌트들이 그 데이터를 사용하지 않음에도 불구하고 단순히 '전달'만 하는 고통스러운 상황을 말합니다.

Props Drilling 그 자체는 반드시 나쁜 것은 아니지만, 깊이가 깊어질수록 코드를 추적하기 어렵게 만들고 리팩토링을 불가능하게 만드는 주범이 됩니다. 오늘은 이 문제를 해결하기 위한 세 가지 전략을 단계별로 알아보겠습니다.


1. 첫 번째 단계: 컴포넌트 합성 (Component Composition)

많은 개발자가 Props Drilling을 발견하면 즉시 Context API나 상태 관리 라이브러리를 떠올립니다. 하지만 리액트 팀에서 권장하는 가장 첫 번째 해결책은 컴포넌트 합성입니다.

1.1 children을 활용한 구조 개선

데이터를 하위로 계속 내려보내는 대신, 컴포넌트를 조립하는 방식으로 구조를 변경해 보세요.

JavaScript
 
// 기존 방식 (Drilling 발생)
<Parent data={data}>
  <Intermediate data={data}>
    <Child data={data} />
  </Intermediate>
</Parent>

// 컴포넌트 합성 방식
<Parent>
  <Intermediate>
    <Child data={data} />
  </Intermediate>
</Parent>

이렇게 하면 Intermediate 컴포넌트는 data를 알 필요가 없습니다. 단순히 children으로 받은 내용을 렌더링 하기만 하면 됩니다. 데이터를 직접 사용하는 컴포넌트를 부모 수준으로 끌어올림으로써 의존성을 끊어내는 것이 핵심입니다.


2. 두 번째 단계: Context API (의존성 주입)

합성만으로 해결하기에 구조가 너무 복잡하거나, 앱 전체에서 공유되어야 하는 데이터(테마, 로그인 유저 정보 등)가 있다면 리액트 내장 기능인 Context API를 사용합니다.

2.1 Context API의 목적

Context는 '전역 상태 관리' 도구라기보다 '의존성 주입(Dependency Injection)' 도구에 가깝습니다. 특정 범위(Provider) 내에 있는 모든 컴포넌트가 계층 구조와 상관없이 데이터에 접근할 수 있게 해줍니다.

2.2 주의할 점: 리렌더링 최적화

Context의 값이 바뀌면 해당 Provider를 구독하고 있는 모든 하위 컴포넌트가 리렌더링 됩니다. 따라서 자주 바뀌는 동적인 데이터보다는 변경 빈도가 낮은 설정 데이터를 관리하는 데 적합합니다.

JavaScript
 
const UserContext = createContext();

function App() {
  return (
    <UserContext.Provider value={user}>
      <Layout />
    </UserContext.Provider>
  );
}

function UserProfile() {
  const user = useContext(UserContext); // 중간 단계 생략하고 바로 사용
  return <div>{user.name}</div>;
}

3. 세 번째 단계: 외부 상태 관리 라이브러리 (Zustand)

복잡하고 빈번하게 변하는 상태(장바구니, 필터링 조건 등)를 관리해야 한다면 전용 라이브러리의 힘을 빌려야 합니다. 과거에는 Redux가 대세였지만, 최근에는 설정이 간편하고 성능이 뛰어난 Zustand가 각광받고 있습니다.

3.1 왜 Zustand인가?

  • 보일러플레이트 제로: Redux처럼 복잡한 초기 설정이 필요 없습니다.
  • 선택적 구독 (Selector): 컴포넌트가 필요한 상태만 선택해서 구독할 수 있어, 불필요한 리렌더링이 발생하지 않습니다.
  • 리액트 외부에서도 접근 가능: 훅(Hook) 형태뿐만 아니라 일반 JS 로직에서도 상태를 읽고 쓸 수 있습니다.

3.2 실전 예시

JavaScript
 
import { create } from 'zustand';

const useCartStore = create((set) => ({
  items: [],
  addItem: (item) => set((state) => ({ items: [...state.items, item] })),
}));

function AddButton({ product }) {
  const addItem = useCartStore((state) => state.addItem);
  return <button onClick={() => addItem(product)}>담기</button>;
}

4. 어떤 상황에서 무엇을 선택해야 할까?

가장 중요한 것은 상황에 맞는 도구를 선택하는 선구안입니다.

  1. 컴포넌트 합성: Props 전달 단계가 2~3단계 내외이며, UI 구조를 변경함으로써 해결 가능한 경우. (가장 깔끔한 코드 유지 가능)
  2. Context API: 다크 모드, 현재 언어(i18n), 로그인 정보처럼 앱 전체가 알아야 하지만 자주 바뀌지 않는 정보를 다룰 때.
  3. Zustand: 여러 컴포넌트가 복잡한 상태를 서로 공유하고, 업데이트가 매우 빈번하며, 성능 최적화가 필수적인 경우.

+ Recent posts