Building Context-Query Library

March 16, 2025


동기

리액트에서는 역활 별로 컴포넌트를 나누어 재사용성과 유지보수성을 높일 수 있습니다. 이때 각 컴포넌트 간의 상태를 공유하는 여러가지 방법이 있습니다.

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

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

전역상태는 모든 컴포넌트가 접근할 수 있는 상태를 정의하는 방법입니다. 손쉽게 상태를 정의하고 공유 할 수 있으나, 컴포넌트 사이클에 벗어나므로 메모리 관리를 위해서 수동으로 상태를 관리해야 합니다.

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

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

이렇게 각 방법에는 특징이 존재하는데 제가 원하는 요구사항은 다음과 같습니다.

  1. 컴포넌트 라이프사이클에 따라 상태를 관리 - 컴포넌트가 사라지면 상태도 사라지도록 해야 합니다.
  2. 리렌더링 최적화 - 상태가 변경되지 않으면 리렌더링 되지 않습니다.

결국 Context API와 React Query의 각 장점을 결합한 기능이 필요했습니다. 그래서 Context Query를 개발하였습니다.

Context Query 구조

Context Query는 모노레포로 구성되어 있으며 코어 패키지와 코어 패키지를 이용해 각 라이브러리나 프레임워크에 맞게 개발 할 수 있도록, 어댑터 패턴을 이용해 구성됩니다. 이는 현재는 리액트만 지원하지만 추후 확장성을 염두한 아키텍처입니다.

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

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

개발자 경험과 성능 최적화

개발자 경험을 높이기 위해 여러가지를 고려했습니다. 그 중 하나가 Context 외부에서 데이터를 주입하는 방법에 관한것입니다 Context Query는 결국 Context API의 Provider로 구성되는 라이브러리 입니다. 이는 Provider로 초기값을 Props로 전달 할 수 있는 구조를 의미합니다.

처음에는 이런 방향으로 개발을 시작했지만 성능 최적화 문제에 부딪쳤습니다. props가 변경되면 리액트에서는 하위 컴포넌트를 모두 리렌더링 하는 문제였습니다. 성능을 최적화 하면서 개발자가 Provider 외부에서 데이터를 초기화 시킬 수 있는 방법을 제공해야 했습니다.

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

그리고 개발자가 상태를 쉽게 구독 할 수 있도록 구독하고자 하는 상태의 각 키값을 배열로 전달하여 다중 또는 단일로 상태를 가져올 수 있게 하였습니다.

마지막으로 Context Query를 만들고 이를 사용하는 방식을 결정할 때 컴포넌트 내부에서 생성하는 것이 아닌 외부에서 선언하여 필요한 곳에 주입하는 방식을 선택했습니다. 이는 Zustand 라이브러리처럼 비슷한 방식입니다. 함수를 이용해서 ContextQuery를 생성하면 Provider와 해당 데이터를 조작할 수 있는 훅을 반환합니다.

Provider와 커스텀 훅은 내부적으로 하나의 스토어를 공유하므로 Provider와 훅을 같이 제공하는 것이 동일한 상태를 조작하고 제공하는 기능임을 인지시킬 수 있다고 생각했습니다.

Context Query 라이브러리 사용방법

createContextQuery 함수를 이용해 초기값을 전달하고 이에 따라 타입이 자동으로 추론되기 때문에 사실 제네릭은 선언할 필요 없습니다. 제네릭은 명시적인 선언일 뿐입니다. 그리고 반환된 Provider과 use훅에 별칭을 부여하여 구분이 잘되게 하고 사용하면 됩니다. 그리고 훅에서 제공하는 함수는 리액트의 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로 관리하는 것은 맞지 않습니다. 적절하게 목적에 따라 상태 관리 방법을 선택해야 합니다. 그럼 Context Query가 가장 적합한 시기는 언제일까요?

바로 Props Drilling 없이 여러 컴포넌트로 조합된 하나의 기능을 구현할 때에 적합합니다. 전역 상태나 리액트 쿼리로 관리하면 오버 엔지니어링이고 Context API는 전체 컴포넌트 트리에는 적합하나 작은 컴포넌트 트리를 위해서는 적합하지 않기 때문입니다.

그리고 Context API는 생성하기위해 직접 훅과 Provider를 생성해야 하기 때문에 코드가 많아집니다. 그렇기 때문에 작게, Props Drilling 없이 상태를 공유하는 목적으로 사용해야합니다.

만들면서 무엇이 힘들었나

기술적인 난이도가 높은 것은 아닙니다. 그러나 각 문제에 부딪힐때 아이디어를 이용해 문제를 해결하는 것이 중요했습니다.

그리고 개발자가 손쉽게 이용하도록 설계하는것 역시 쉬운 과정은 아니였습니다.

더 개선해야 할 점은 많지만 버전이 올라가면서 많은 이들에게 저처럼 동일한 문제를 만났을때 이 라이브러리를 통해 손쉽게 개발 할 수 있으면 좋겠습니다.

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