뒤로가기

Context-Query: React 상태 관리 라이브러리 개발 여정

react

React 상태 관리의 현황

React에서는 역할별로 컴포넌트를 나누어 재사용성과 유지보수성을 높입니다. 이때 각 컴포넌트 간의 상태를 공유하는 여러 방법이 존재합니다:

  • Props
  • 전역 상태
  • Context API
  • React Query

Props의 한계

Props는 가장 기본적인 방법이지만, 컴포넌트의 깊이가 깊어지면 부모 컴포넌트에서 자식 컴포넌트로 데이터를 전달하기 어려워집니다. 이러한 문제를 Prop Drilling이라고 합니다.

전역 상태의 문제

전역 상태는 모든 컴포넌트가 접근할 수 있는 상태를 정의하는 방법입니다. 손쉽게 상태를 정의하고 공유할 수 있지만, 컴포넌트 생명주기와 분리되어 있어 메모리 관리를 위해 수동으로 상태를 정리해야 합니다.

Context API의 성능 문제

Context API는 특정 컴포넌트 트리 내에서 상태를 공유하는 방법입니다. 컴포넌트의 라이프사이클에 따라 상태가 관리되어 편리하지만, Context의 일부 상태가 변경되면 모든 컴포넌트가 리렌더링되는 문제가 있습니다.

React Query의 제약

React Query는 상태를 사용하는 컴포넌트가 없으면 자동으로 상태를 삭제하는 최적화를 제공합니다. 그러나 React Query의 주된 목적은 서버의 상태를 관리하는 것이므로, 로컬 UI 상태 관리에는 적합하지 않습니다.

Context-Query의 설계 목표

기존 상태 관리 방법들의 한계를 분석한 결과, 다음과 같은 요구사항을 도출했습니다:

  1. 컴포넌트 라이프사이클 연동: 컴포넌트가 사라지면 상태도 자동으로 정리되어야 합니다.
  2. 리렌더링 최적화: 상태가 변경되지 않으면 리렌더링이 발생하지 않아야 합니다.

Context-Query는 Context API와 React Query의 각 장점을 결합하여 이러한 요구사항을 충족합니다.

아키텍처 설계

모노레포 구조

Context-Query는 모노레포로 구성되어 있으며, 코어 패키지와 어댑터 패키지로 분리됩니다. 이는 현재는 React만 지원하지만, 추후 다른 프레임워크로 확장할 수 있는 구조입니다.

코어 패키지

코어 패키지는 스토어 기능을 제공합니다. 스토어는 데이터를 저장하고 반환할 수 있으며, 옵저버 패턴을 이용하여 상태가 변경될 때 등록된 리스너 함수를 실행합니다.

React 어댑터

React 패키지는 스토어를 생성하여 각 커스텀 훅에서 상태를 구독하고, 리스너 함수가 실행될 때 React의 상태를 업데이트하는 구조로 되어 있습니다.

성능 최적화 전략

Props를 통한 초기화 문제

Context-Query는 Context API의 Provider로 구성되는 라이브러리입니다. 초기에는 Provider로 초기값을 Props로 전달하는 구조를 고려했지만, 성능 최적화 문제에 부딪쳤습니다.

Props가 변경되면 React에서는 하위 컴포넌트를 모두 리렌더링하는 문제가 발생했습니다. Provider 외부에서 데이터를 초기화할 수 있는 방법을 제공하면서도 성능을 최적화해야 했습니다.

Store 직접 업데이트 방식

이 문제는 Store의 기능을 활용해 해결했습니다. Provider로 Props를 전달하지 않고, Store에 직접적으로 상태를 업데이트하고 그에 따라 상태를 구독하는 컴포넌트들에게 이벤트를 전달하는 방식입니다. 이는 Props를 거치지 않으므로 불필요한 리렌더링을 발생시키지 않습니다.

선택적 구독

개발자가 상태를 쉽게 구독할 수 있도록, 구독하고자 하는 상태의 각 키값을 배열로 전달하여 다중 또는 단일로 상태를 가져올 수 있게 했습니다. 이를 통해 필요한 상태만 구독하여 리렌더링을 최소화할 수 있습니다.

외부 선언 방식

Context-Query를 사용하는 방식을 결정할 때, 컴포넌트 내부에서 생성하는 것이 아닌 외부에서 선언하여 필요한 곳에 주입하는 방식을 선택했습니다. 이는 Zustand 라이브러리와 유사한 패턴입니다.

함수를 이용해서 ContextQuery를 생성하면 Provider와 해당 데이터를 조작할 수 있는 훅을 반환합니다. Provider와 커스텀 훅은 내부적으로 하나의 스토어를 공유하므로, 이를 함께 제공하는 것이 동일한 상태를 조작하고 제공하는 기능임을 명확히 합니다.

사용 방법

createContextQuery 함수를 이용해 초기값을 전달하면 타입이 자동으로 추론됩니다. 제네릭 선언은 명시적인 타입 표현을 위한 옵션입니다. 반환된 Provider와 use 훅에 별칭을 부여하여 구분이 잘되게 사용합니다.

훅에서 제공하는 함수는 React의 useState 훅의 setter 함수와 동일하게 동작하므로, 직관적으로 사용할 수 있습니다.

interface UserData {
  name: string;
  email: string;
  preferences: {
    theme: "light" | "dark";
    notifications: boolean;
  };
}
 
export const {
  Provider: UserQueryProvider,
  useContextQuery: useUserQuery,
  updateState: updateUserState,
  setState: setUserState,
} = createContextQuery<UserData>({
  name: "",
  email: "",
  preferences: {
    theme: "light",
    notifications: true,
  },
});
 
function UserProfilePage({ userId }: { userId: string }) {
  useEffect(() => {
    // Initialize state with external data
    const loadUserData = async () => {
      const userData = await fetchUserData(userId);
      updateUserState(userData); // Update entire state with fetched data
    };
    loadUserData();
  }, [userId]);
 
  return (
    <UserQueryProvider>
      <div className="user-profile">
        <UserInfoForm />
        <UserPreferencesForm />
        <SaveButton />
      </div>
    </UserQueryProvider>
  );
}
 
function UserInfoForm() {
  // Subscribe to user info fields only
  const [state, setState] = useUserQuery(["name", "email"]);
 
  return (
    <div className="user-info">
      <h3>Basic Information</h3>
      <div>
        <label>Name:</label>
        <input
          value={state.name}
          onChange={(e) =>
            setState((prev) => ({ ...prev, name: e.target.value }))
          }
        />
      </div>
      <div>
        <label>Email:</label>
        <input
          value={state.email}
          onChange={(e) =>
            setState((prev) => ({ ...prev, email: e.target.value }))
          }
        />
      </div>
    </div>
  );
}

적용 시나리오

모든 상태를 Context-Query로 관리하는 것은 적절하지 않습니다. 목적에 따라 상태 관리 방법을 선택해야 합니다.

Context-Query가 가장 적합한 경우는 다음과 같습니다:

  • Props Drilling 없이 여러 컴포넌트로 조합된 하나의 기능을 구현할 때
  • 전역 상태나 React Query로 관리하면 오버 엔지니어링이 되는 경우
  • Context API는 전체 컴포넌트 트리에는 적합하나 작은 컴포넌트 트리를 위해서는 보일러플레이트가 많은 경우

Context API는 생성하기 위해 직접 훅과 Provider를 생성해야 하기 때문에 코드가 많아집니다. Context-Query는 이러한 보일러플레이트를 줄이고, Props Drilling 없이 상태를 공유하는 목적으로 사용하기에 적합합니다.

개발 과정의 인사이트

기술적 도전

기술적인 난이도가 높은 것은 아니지만, 각 문제에 부딪힐 때 적절한 아이디어로 해결하는 것이 중요했습니다. 특히 성능 최적화와 개발자 경험 사이의 균형을 맞추는 것이 핵심 과제였습니다.

개발자 경험 설계

개발자가 손쉽게 이용할 수 있도록 API를 설계하는 것 역시 중요한 과정이었습니다. 기존 React 훅과 유사한 API 디자인을 통해 학습 곡선을 낮추고, TypeScript의 타입 추론을 활용하여 타입 안전성을 보장했습니다.

결론

Context-Query는 Context API의 생명주기 관리와 React Query의 최적화를 결합하여, 중간 규모의 컴포넌트 트리에서 효율적인 상태 관리를 제공합니다. 버전이 올라가면서 더 많은 개발자들이 유사한 문제를 만났을 때 이 라이브러리를 통해 손쉽게 해결할 수 있기를 기대합니다.

GitHub: https://github.com/load28/context-query

관련 아티클