이전 글에서 Recoil 활용해 클라이언트 상태를 정리했던 이야기를 남긴 적이 있다.
이번에는 같은 프로젝트 내에서 서버 상태를 다루는 방식에 대해 정리해보려 한다.


Context API를 빠르게 선택했던 이유

처음 프로젝트를 시작할 때는 아주 간단한 요구사항만 있었기 때문에, 굳이 무거운 상태 관리 라이브러리를 도입하지 않고 Context API만으로 충분하다고 판단했다.
속도를 중시한 결정이었다. 실제로 빠르게 결과물을 만들 수 있었고, 배포도 문제없이 진행됐다.

하지만 이후 사업이 확장되며 기능이 하나씩 추가되기 시작했고, 그때부터 context 구조가 무겁게 느껴지기 시작했다.
추가되는 context마다 리렌더링 범위가 넓어졌고, UI 단위에서만 필요한 데이터까지 전역에 얹히는 구조가 되었다.


상태 구조가 점점 복잡해지기 시작했습니다.

예를 들어 로그인 기능을 살펴보면, 로그인 성공 여부나 사용자 권한에 따라 이동해야 할 경로는 로그인 컴포넌트 내에서만 알면 되는 정보였다.
하지만 기존 구조에서는 로그인 요청이 context provider 내부에서 이루어졌고,
성공 결과는 reducer state로 흘러 들어가면서 전역 상태로 관리됐다.

이 구조는 작동은 하지만, 다음과 같은 단점이 있었다:

  • 로그인 상태가 변경될 때마다 AuthContext를 구독하고 있는 모든 컴포넌트가 리렌더링
  • 로그인 상태 외에도 userTypeA, userTypeB, … 등의 상태를 추가로 전역에서 분기 관리해야 했음
  • 상태와 UI 흐름이 분리되면서 추적과 디버깅이 어려워짐

팀원들과 선택지를 다시 검토했다…

기존에 쓰던 Redux는 고려 대상이긴 했지만, 반복되는 액션/리듀서 구성에 비해 얻는 이점이 제한적이었다.
그래서 팀원들과 함께 다른 선택지를 검토했고, 다음과 같은 의견이 나왔다:

  • MobX는 과거 사용 경험상 구조가 오히려 불안정하다는 느낌이 있어 제외
  • Constate는 커뮤니티나 안정성 측면에서 부족해 보임
  • Recoil은 클라이언트 상태 관리에 적합해 보여 도입
  • 서버 상태는 React Query를 한 번 적용해보자는 의견이 모아짐

React Query로 리팩토링한 로그인 흐름

기존 방식에서는 로그인 요청이 context 내부에서 발생하고,
성공 결과가 reducer 상태에 저장되며, 해당 값에 따라 route가 이동하는 구조였다.

export default function Login() {
  const navigate = useNavigate();
  const { requestLogin, userTypeA, userTypeB, userTypeC } = useAuth();
  // custom hook의 syntax를 위해 useContext(AuthContext)를 useAuth()로 export한다.

  const handleLoginBtnClick = () => {
    requestLogin({ username, password });
    // context의 provider에서 request가 이루어지고, 값은 reducer에 저장된다.
  };

  useEffect(() => {
    if (!userTypeA) return;
    navigate("/routeA");
  }, [userTypeA]);

  useEffect(() => {
    if (!userTypeB) return;
    navigate("/routeB");
  }, [userTypeB]);

  useEffect(() => {
    if (!userTypeC) return;
    navigate("/routeC");
  }, [userTypeC]);
}

React Query로 전환한 이후에는 로그인 요청과 결과 처리, 라우팅까지 한 번에 처리할 수 있었다.

// 전환 후: useMutation 예시 Code
const { mutate } = useMutation(requestLogin, {
  onSuccess: (data) => {
    if (data.userTypeA) navigate("/routeA");
    if (data.userTypeB) navigate("/routeB");
    if (data.userTypeC) navigate("/routeC");
  },
  onError: () => {
    alert("로그인에 실패했습니다.");
  },
});

const handleLoginBtnClick = () => {
  mutate({ username, password });
};

React Query를 적용하면서 렌더링 효율도 눈에 띄게 개선되었다. 이전에는 AuthContext에서 상태를 받아 사용하는 모든 컴포넌트가 불필요하게 리렌더링되는 경우가 많았다. loading 상태 하나만 바뀌어도 관련 없는 컴포넌트들이 영향을 받았기 때문이다.

반면, React Query를 적용한 이후에는 요청과 상태 관리가 컴포넌트 내부에 국한되기 때문에, 렌더링 범위를 필요 최소한으로 줄일 수 있었다.

아래는 React Profiler를 통해 측정한 LoginForm 컴포넌트 렌더링 비교 결과이다.

설명 텍스트

Context API : 전체 렌더링 시간 약 2.7ms, styled.form 이하 자식 컴포넌트 모두 리렌더링

설명 텍스트

React Query : 렌더링 시간 약 1.0ms, 변경된 부분만 최소 단위로 반응

렌더링 시간 기준으로 약 63%가량 성능이 개선되었고, 이는 사용자 경험 측면에서도 체감 가능한 변화였다. 특히 입력 필드나 버튼의 상태가 바뀌지 않아도 리렌더링되던 문제들이 자연스럽게 해결된 점이 인상 깊었다.


react query 사용하며 좋았던 점

특히 유용했던 점은 defaultOptions를 통한 전역 설정이었다.
요청 마다 옵션을 반복해서 설정하지 않고, 공통적으로 사용하는 retry, staleTime 등을 한 곳에서 관리할 수 있었다.

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      retry: 2,
      refetchOnWindowFocus: false,
    },
  },
});

이 설정 덕분에

  • 예상치 못한 실패 상황에서도 자동 재시도가 작동했고
  • 탭 이동 후 다시 돌아왔을 때 불필요한 리페치도 방지할 수 있었다.

또한 목록 페이지에서 prefetch를 적용하여 상세 페이지에 들어갔을 때 이미 데이터가 메모리에 존재하는 경우에는 바로 렌더링되도록 구성할 수 있었고, UX 측면에서도 응답 속도를 체감할 수 있을 정도로 개선되었다.