<aside> 🔗

GitHub 예시 코드

</aside>

<aside> 📄

이 글에서 React Context 와의 비교에는 zustand를 사용하였습니다.

</aside>

들어가는 말


부끄럽지만 아직 상태 관리 라이브러리를 써본 적이 없다. 언젠가 쓴다면 어떤 라이브러리가 좋을지 사용 패턴을 미리 알아두기 위해 리서치를 해본 적은 있는데, 실제로 적용해 본 경험은 없다.

이전 회사에서 서비스를 만들 때는 리액트 컨텍스트를 썼다. 리액트 내장 도구이기도 하고 별도의 학습이 필요하지 않았기 때문에, 일단 사용하다가 한계에 부딪히면 그때 상태 관리 라이브러리를 도입하자는 생각이었다.

몇 번의 고비는 있었지만, 프로젝트 도중 새로운 툴을 도입하는 비용을 감당하기는 쉽지 않았기 때문에 그때마다 적당히 타협하며 고비를 넘겼고, 결국 상태 관리 라이브러리를 도입하는 때는 오지 않았다. 어쩌면 내가 만든 서비스의 규모가 작았던 것일 수도 있고, 어쩌면 리액트 컨텍스트만으로 충분했을 수도 있다.

그런데 리액트 컨텍스트에 대해 찾아보면, 나의 경험과는 반대로 늘 따라다니는 부정적인 평가가 있다. “리액트 컨텍스트는 하위 컴포넌트 전체를 리렌더링한다.”, “상태 변경 시 리렌더링 범위를 예측하기 어렵다.”, “코드가 번거롭고 복잡하다.” 같은 말들이다.

나는 그 말들이 정확히 무엇을 의미하는지, 왜 나에겐 리액트 컨텍스트만으로 충분하게 느껴졌는지, 상태 관리 라이브러리들은 이런 불편들을 어떻게 해소하는지 궁금해졌다.

내가 들었던 한계


하위 컴포넌트 리렌더링 문제

난 지금까지 이 말이, 리액트 컨텍스트는 프로바이더를 동반해야 하기 때문에 부모가 리렌더링되면 하위 컴포넌트들도 리렌더링 된다는 말인 줄 알았다. 하지만 이건 리액트 전반적인 규칙일 뿐이라서, 이게 왜 문제라고 하는지 납득하지 못했다.

그런데 자세히 찾아보니, “하위 컴포넌트가 리렌더링된다.”는 말은 “하위 컴포넌트들은 컨텍스트를 통째로 구독하기 때문에, 컨텍스트 중 일부만 사용하더라도 (관심 없는 값이 업데이트되더라도) 리렌더링된다.” 는 말이었다.

나이를 업데이트 하지만 이름을 사용하는 컴포넌트도 리렌더링 되는 모습

나이를 업데이트 하지만 이름을 사용하는 컴포넌트도 리렌더링 되는 모습

하지만 이 역시 리액트의 일반적인 렌더 사이클을 고려하면 당연한 사실일 뿐인데, 그럼에도 이것을 단점으로 꼽는다는 건 상태 관리 라이브러리에서는 그렇지 않다는 뜻이 된다. 그리고 실제로 상태 관리 라이브러리는 구독 대상을 통으로 취급하지 않고 필요한 부분만 선택해서 구독할 수 있었다!

나이를 업데이트 하면 나이를 사용하는 컴포넌트만 리렌더링 되는 모습

나이를 업데이트 하면 나이를 사용하는 컴포넌트만 리렌더링 되는 모습

상태 관리 라이브러리에서는 공유 대상을 “상태”로 두지 않는 것이 핵심인 것 같다. “상태”로 두지 않기 때문에, 일반적인 리액트의 렌더 사이클에서 벗어날 수 있고, 공유 데이터의 형식과 필요한 부분만을 구독하는 기능을 자체적으로 만들어 낼 수 있었던 것 같다.

이 장점은 결코 작지 않아서, 결국 서비스의 규모가 확장됨에 따라 공유해야 하는 상태가 거대해지고 많아질수록 상태 관리 라이브러리의 필요성도 점점 커지게 될 것이다.

예측하기 어려운 리렌더링 범위

이 말도 앞서 다룬 “하위 컴포넌트 리렌더링 문제”처럼 부분 구독의 한계를 지적하는 것일 수도 있지만, 그 부분은 이미 다뤘으니, 이번에는 다른 방향으로 살펴본다.

“공유된 상태가 있을 때, 어떤 컴포넌트가 이 상태를 구독하고 있는지 어떻게 추적할 수 있을까?”라는 관점이다.

기본적으로는 코드에서 해당 상태를 참조하는 부분을 직접 검색하는 방법밖에 없어 보인다. 아니면 각 상태를 공유하거나 참조하는 훅에 로그를 심어 확인할 수도 있을 것이다.

// 예시3 코드

const MyContext = createContext<User | undefined>(undefined)

const useMyContext = () => {
  const value = useContext(MyContext)

	// 훅 실행 시 함께 찍히는 로그
  console.log(`MyContext referenced`)

  return value
}

반대로 상태 관리 라이브러리들은 DevTools를 지원하는데, 이를 활용하면 상태 변경, 구독, 업데이트, 액션 흐름 등을 한눈에 파악할 수 있다.

상태 변경을 추적해준다!

상태 변경을 추적해준다!

번거롭고 복잡하다

리액트 컨텍스트를 사용하려면 다음 과정을 거쳐야 한다.

컨텍스트를 만든다. 프로바이더를 만든다. 프로바이더를 사용한다. 컨텍스트 전용 훅을 만든다.

공유할 컨텍스트마다 이 과정을 반복해야 한다. 그래서 프로젝트가 커지면 컨텍스트 관련 코드만으로도 양이 꽤 많아지고, 프로바이더가 여러 겹으로 중첩되는 모습을 보게 된다.

리액트 컨텍스트가 번거롭고 복잡하다는 말은 부정하기 어려워 보인다.

// 예시1 코드

export const Try = () => {
  return (
    <MyContext.Provider>
      <UserNameConsumer />
      <UserAgeConsumer />
    </MyContext.Provider>
  )
}

반면에 상태 관리 라이브러리는 어떨까? 공유할 상태 객체를 만드는 것 말고는 크게 할 일이 없어 보인다. 그냥 불러와서 쓰면 된다.

// 예시2 코드

export const Try = () => {
  return (
    <>
      <UserNameConsumer />
      <UserAgeConsumer />
    </>
  )
}

그런데 이런 생각이 든다.

리액트 컨텍스트는 프로바이더로 감싸는 과정이 번거롭긴 했지만, 코드상에서 상태의 공유 범위를 명확히 드러낼 수 있었다.

반면 상태 관리 라이브러리는 프로바이더가 필요 없는데, 그렇다면 특정 스토어가 어느 범위에서만 사용되어야 하는지, 혹은 “이 스토어는 이 범위 안에서만 쓰이길 바란다”는 의도를 코드상에서 어떻게 표현할 수 있을까?

기본적으로 zustand 는 전역 단일 store 컨셉으로 간단히 사용하는게 기본 컨셉이긴 하다. 하지만 공식 문서에 리액트 컨텍스트를 이용해 공유 범위를 제한하는 방식도 소개되어 있는데, 컨텍스트 패턴을 쓰지 않으려고 Zustand를 선택했는데, 다시 컨텍스트로 감싸야 한다는 점은 다소 아이러니하게 느껴진다.

(예전에는 zustand/context라는 전용 API도 있었지만, 지금은 사라졌다.)

// 예시5 코드

const StoreContext = createContext<StoreApi<UserState> | null>(null)

export const StoreProvider = ({ children }: PropsWithChildren) => {
  const storeRef = useRef<StoreApi<UserState> | null>(null)

  if (storeRef.current === null) {
    storeRef.current = createStore<UserState>(set => ({
      user: { name: 'John', age: 20 },
      increaseAge: () =>
        set(state => ({
          user: { ...state.user, age: state.user.age + 1 },
        })),
    }))
  }

  return (
    <StoreContext.Provider value={storeRef.current}>
      {children}
    </StoreContext.Provider>
  )
}

export function useStoreInContext<T>(selector: (state: UserState) => T): T {
  const store = useContext(StoreContext)
  
  if (!store) throw new Error('Missing StoreProvider')

  return useStore(store, selector)
}

export const Try = () => {
  return (
    <StoreProvider>
      <IncreaseAgeButton />
      <UserNameConsumer />
      <UserAgeConsumer />
    </StoreProvider>
  )
}

(컨텍스트 생성도 타입 정의도 꽤나 복잡하다. zustand를 사용한다면 컨텍스트나 프로바이더 없이 그냥 사용하는게 좋을 것 같다..)