프로젝트를 진행하다 보면 어느 순간 상태가 복잡해지기 시작합니다.

처음에는 간단한 기능만 있어서 Context API로도 충분해 보였고, 그렇게 빠르게 개발을 시작했습니다.
하지만 기능이 늘어나고 화면과 상태의 연동이 많아지면서, 상태 관리 구조에 대한 고민이 본격적으로 생겼습니다.


Context API에서 발생한 문제

Context는 간단하고 직관적인 구조라는 점에서 좋지만, 하나의 상태가 바뀌면 해당 Provider 아래의 모든 컴포넌트가 함께 리렌더링되는 특성이 있습니다.

이 문제를 해결하기 위해 Provider를 잘게 쪼개는 방식으로 대응해보기도 했습니다.
하지만 상태가 많아지고, 각각의 의존성을 분리해야 하는 상황에서는 점점 코드가 복잡해졌습니다.

Redux도 고려했지만, 이전 프로젝트에서 경험한 바에 따르면 action, reducer, saga까지 이어지는 구조는 간단한 상태를 처리하기에는 다소 과하다는 생각이 들었습니다.

그래서 자연스럽게 클라이언트 상태와 서버 상태를 나누어 관리해보자는 방향으로 흐르게 되었습니다.


역할에 따라 나눈 상태 관리 구조

서버에서 받아오는 데이터는 React Query로 관리하고,
UI 상태나 사용자와의 상호작용 등 브라우저 내부에서만 사용하는 상태는 Recoil을 통해 관리했습니다.

이렇게 나누고 나니 상태의 책임이 명확해졌고, 어떤 상태가 어디서 오는지 고민하는 시간이 줄었습니다.
결과적으로 유지보수나 협업에서도 훨씬 편해졌습니다.


Recoil의 사용과 느꼈던 점

Recoil은 React와 매우 유사한 방식으로 상태를 관리할 수 있어 익숙하게 사용할 수 있었습니다.
useState를 사용하는 것처럼 코드를 구성할 수 있었고, 상태를 atom 단위로 작게 나눌 수 있었기 때문에 렌더링 최적화 측면에서도 도움이 되었습니다.

interface IAuthType {
  id: number;
  username: string;
}

export const authState = atom<IAuthType>({
  key: "authState",
  default: {
    id: 0,
    username: "",
  },
});
const [auth, setAuth] = useRecoilState(authState);

이처럼 특정 상태에 의존하는 컴포넌트만 리렌더링되도록 되어 있어서, Context API를 사용할 때 겪었던 과도한 렌더링 문제를 줄일 수 있었습니다. 상태의 단위를 명확하게 나눌 수 있으니, 의존성도 더 쉽게 관리할 수 있었습니다.

Selector의 활용 Recoil에서는 파생 상태를 계산할 수 있도록 selector라는 개념을 제공합니다. Redux에서 selector를 사용할 때보다 훨씬 간결하게 작성할 수 있었고, get/set 구조로 되어 있어서 읽기 전용 상태와 쓰기 가능한 상태를 명확하게 구분할 수 있었습니다.

export const persistedAuthSelector = selector<IAuthType>({
  key: "persistedAuthSelector",
  get: () => {
    if (typeof window !== "undefined") {
      const stored = localStorage.getItem("auth");
      return stored ? JSON.parse(stored) : { id: 0, username: "" };
    }
    return { id: 0, username: "" };
  },
  set: ({ set }, newValue) => {
    if (typeof window !== "undefined") {
      localStorage.setItem("auth", JSON.stringify(newValue));
    }
    set(authState, newValue);
  },
});

이렇게 selector를 사용하면 파생 데이터를 따로 저장하지 않아도 되기 때문에, 원시 상태는 atom으로만 정의하고, 필요한 계산 결과는 selector에서 처리하는 방식으로 구성할 수 있었습니다. 불필요한 상태를 줄이고, 로직과 데이터의 책임을 분리하는 데도 도움이 되었습니다.

마무리하며

React Query와 Recoil을 함께 사용하면서 클라이언트 상태와 서버 상태를 명확하게 구분할 수 있었습니다. 서버 상태는 React Query로 가져오고 캐싱하며 에러도 함께 관리하고, 클라이언트에서만 필요한 상태는 Recoil로 구성해서 각각의 책임을 분리했습니다.

이후로는 상태를 설계할 때마다 먼저 이 질문을 던지게 되었습니다. “이 값은 서버에서 받아오는가?” “아니면 브라우저 안에서만 사용되는가?”

이 기준 하나만 정해두어도 상태 구조를 훨씬 명확하게 구성할 수 있었습니다. 처음에는 어떤 라이브러리를 쓰는 게 더 나은가를 고민했지만, 지금은 **“이 상태는 어떤 역할을 맡고 있는가”**를 먼저 생각해보고, 그에 맞는 도구를 선택하는 것이 더 중요하다는 생각을 하게 되었습니다.