본문 바로가기

책과 강연/독후감 - 개발

[도서 리뷰] 리액트 훅을 활용한 마이크로 상태 관리

 

리액트 훅을 활용한 마이크로 상태 관리 | 다이시 카토 - 교보문고

리액트 훅을 활용한 마이크로 상태 관리 | 이 책에서는 다양한 상태 관리 방법과 유명한 상태 관리 라이브러리인 Zustand, Jotai, Valtio, React Tracked의 사용법을 소개한다. 또한 실무에서 유용하게 활

product.kyobobook.co.kr

 

계속되는 지인의 추천으로 읽게 되었다.

이 책의 저자는 zustand, jotai 등의 최신 상태 관리 라이브러리의 개발자이다.

상태 관리 개발자의 책이니 다양한 걸 설명할 줄 알았지만 상태 관리 하나만 다룬다. (심지어 예제도 하나)

 

근데, 그 깊이가 달랐다.

 

한줄평 : 상태 관리는 정말 어렵구나. 두 번째 프로젝트를 끝냈으면 꼭 읽어보면 좋을 책

 

책의 핵심 내용 

[주석] 은 책의 주석이 아니라 내가 책을 읽으며 헷갈린 부분을 추가로 정리한 것이다. 참고바란다.

 

[ 상태 ]

상태는 사용자 인터페이스를 나타내는 모든 데이터를 의미한다.

 

hook이 나오기 전에, 상태는 중앙 집중형 상태 관리 라이브러리에서 다뤘으며 일반적으로 단 하나만 존재하였다. 하지만, 이런 관리는 hook이 나오면서 목적에 따라 다르게 해결할 수 있게 되었다.

- 폼(form) 상태 : 전역 상태와 별도로 처리해야하는데, 이는 단일 상태로 해결되지 않는다.

- 서버 캐시 상태 : 리패치 등 몇 가지 고유한 특성이 있다.

- 네비게이션 상태 : 원 상태가 브라우져에 있기 때문에 단일 상태가 적합하지 않다.

[주석1] - form 상태는 react hook form이 주로 해결한다. - 서버 상태는 react-query가 주로 해결한다. - 브라우져 상태는 react-router가 주로 해결한다.

 

위 문제를 해결한 다양한 hook 기반 라이브러리들이 있고, 애플리케이션들은 대부분 가볍고 범용적인 상태관리 라이브러리들을 필요로 하게 된다.  

 

이 때, 개발자의 요구사항에 따라서 적절하고 가벼운 방법을 선택할 수 있는 것을 마이크로 상태 관리라 한다.

(책에서 정의한 용어로, "마이크로 상태 관리"는 일반적인 용어가 아니다.)

 

범용적인 상태관리 라이브러리는 다음 기능이 필요하다.

- 상태 읽기, 쓰기, 렌더링 시키기

 

하지만, 추가 작업을 위해서는 아래 기능이 추가적으로 필요하다.

- 리렌더링 최적화

- 다른 시스템과의 상호작용

- 비동기 지원

- 파생 상태

- 간단한 문법 등

 

개발자는 위 사항을 고려하여 애플리케이션에 적절한 상태 관리 앱을 선택하고 상태 관리를 진행해야한다.

 

[ hook ]

- useState는 지역 상태를 생성하는 기본 함수로 캡슐을 로직화하고 재사용 가능하게 만든다.

- useEffect는 리액트 렌더링 프로세스 바깥에서 로직을 실행시킨다. 이를 통해 리액트 생명 주기와 함께 움직이는 앱을 만들 수 있다.

 

react hook은 UI 컴포넌트에서 로직을 뽑아낼 수 있다. (분리할 수 있다.)

// useCounter.js
import { useState } from "react";

function useCounter(initialValue = 0) {
  const [count, setCount] = useState(initialValue);

  const increment = () => setCount((prev) => prev + 1);
  const decrement = () => setCount((prev) => prev - 1);

  return { count, increment, decrement };
}

// Counter.js
import React from "react";
import useCounter from "./useCounter";

function Counter() {
  const { count, increment, decrement } = useCounter(10);

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={increment}>Increment</button>
      <button onClick={decrement}>Decrement</button>
    </div>
  );
}

이렇게 UI 렌더링 로직과 구현을 분리함으로써 다음 이점을 얻을 수 있다.

 

1. 가독성이 좋아지고, 이름을 명확하게 지을 수 있다.

2. 로직과 UI가 분리된다. 그 결과, 컴포넌트(Counter.js)를 수정하지 않고 로직(useCounter)만을 수정해서 기능을 추가할 수 있다.

 

hook을 사용할 때는 다음 사항을 고려하면 좋다. 

- 동시성 렌더링 : 렌더링 프로세스를 청크 단위로 분할해서 CPU가 장시간 차단되는 걸 방지한다. 따라서, 렌더링 도중에 입력이나 상호작용이 가능해진다.

- Suspense : 비동기 처리를 도와주는 컴포넌트이다. fallback을 제공해준다.

- state나 ref 객체를 직접 변경해서는 안된다 => 렌더링을 너무 많이 시키거나, 일부 렌더링을 안되게 할 수 있다.

- 충분히 "순수"해야한다. => 여러번 훅이 호출되어도 문제가 있으면 안된다.

 

위 내역은 버그가 발생하지 않는 것처럼 보일 수 있어서 특히 조심해야한다.

[주석2] 이 내용은 react 18의 패치 내역과 연관이 깊다. 만약, 동시성 렌더링 hook을 사용한다면 이 부분을 주의하자

 

 

[ 최적화 ]

- 베일 아웃 : 리렌더링을 발생시키지 않는 리액트 기술 용어

- 지연 초기화(lazy initalization) : 렌더링 시, 함수나 초기값을 전달받으면 한 번 평가를 한 후 해당 값을 캐시하여 사용한다.

 

[ 상태의 종류 ] 

일반적으로 컴포넌트 내에서 사용되는 상태를 "지역 상태"라 부른다

그리고 "전역 상태"는 여러 컴포넌트에서 같이 사용되는 상태이다.

이 때, 전역 상태는 싱글턴일 수도 있지만, 아닐 수도 있으므로 싱글턴이 아닌 전역 상태를 "공유 상태"라 부른다.

 

리액트의 컴포넌트는 재사용이 가능한 하나의 단위로써, 외부(전역 상태)에 의존할 경우 재사용을 하기 어려워진다. 따라서, 전역 상태를 지양하는 것이 좋다.

 

리액트 컴포넌트에서 상태가 컴포넌트 내부에서만 사용되어서, 다른 컴포넌트에 영향을 끼치지 않을 경우 이 특성을 "억제됨(contained)"라 표현한다.

 

[ 지역 상태의 한계]

함수 외부에서 컴포넌트의 상태를 변경해야할 경우, 지역 상태를 넘어서 전역 상태가 필요하다. 이 때, 전역상태를 안 쓰고 지역 상태를 효율적으로 사용하려면 상태 끌어올리기(lifting state up)을 활용해야한다.

 

상위에서 컴포넌트를 선언하고 아래로 내려주는 방식이다.

import React, { useState } from 'react';

// 카운터 컴포넌트 (하위 컴포넌트)
const Counter = ({ count }) => {
  return (
    <div>
      <p>Count: {count}</p>
      <div>i'm not related count</div> // 1번 문제
    </div>
  );
};

// App 컴포넌트 (상위 컴포넌트)
const App = () => {
  const [count, setCount] = useState(0);

  return (
    <div>
      <h1>State Lifting Example</h1>
      {/* 카운터 상태와 함수들을 하위 컴포넌트로 전달 */}
      <Counter count={count} />
      <Counter count={count} />
    </div>
  );
};

export default App;

이러면 이제 전역 상태를 쓰지 않고도, 지역 상태로 해결 가능하다. 하지만, 두 가지 문제가 생긴다.

- 지역 상태에 따라서 하위 트리가 모두 재렌더링 된다.

- props drilling이 문제가 생긴다. (너무 깊이까지 전달된다.)

 

하위 트리가 모두 재렌더링 되는 문제의 경우는 정말 어쩔 수 없다. 하지만, "// 1번 문제" 라 표기한 부분은 count랑 관계없는데 props가 전달되면서 같이 렌더링이 되어야한다. 이는 불필요한 렌더링이므로 이 내용을 끌어올리면 더욱 최적화를 할 수 있다.

import React, { useState } from 'react';

// 카운터 컴포넌트 (하위 컴포넌트)
const Counter = ({ count }) => {
  return (
    <div>
      <p>Count: {count}</p>
      [renderComp}
    </div>
  );
};

// App 컴포넌트 (상위 컴포넌트)
const App = () => {
  const [count, setCount] = useState(0);
  const renderComp = () => {
  	return <div>i'm not related count</div>
  }

  return (
    <div>
      <h1>State Lifting Example</h1>
      {/* 카운터 상태와 함수들을 하위 컴포넌트로 전달 */}
      <Counter count={count} renderComp = {renderComp} />
      <Counter count={count} renderComp = {renderComp} />
    </div>
  );
};

export default App;

// [주석3] memo 까지 걸면 더 최적화가 가능하지만, 책에 없으므로 주석으로만 남긴다.

 

- 이렇게 하면 renderComp를 매 번 재생성하지 않고 위에서 받아서 쓰므로 또 다시 렌더링 횟수를 줄일 수 있다.

 

[ 전역 상태 ]

"props를 전달하는 것이 적절하지 않을 때", "이미 리액트 외부에 상태가 있을 때" 두 경우에는 전역 상태를 쓰는 것을 고려할만하다.

 

- props를 전달하는 것이 적절하지 않을 때

흔히 props drilling이라 부르는 문제이다. 깊이가 3단계 이상 깊어진다면 관리가 너무 복잡하고, 중간 렌더링으로 인한 성능 문제가 발생할 수 있기 때문에 전역 상태를 써볼만한다.

 

- 이미 리액트 외부에 상태가 있을 때

이미 외부에 전역 상태가 있기 때문에 이를 그대로 사용하면 된다.

 

[ context ]

context는 전역 상태를 위해 설계된 것은 아니다. 하지만 이를 이용하여 간단한 전역 상태를 관리할 수 있다.

[주석4] 리액트 문서에는 props drilling을 해결하기 위해서라 적혀있다

 

contexts는 아래와 같은 개념과 주의 사항이 있다.

- context는 provider(공급자)와 consumer(소비자)가 있으며, 소비자는 가장 가까운 공급자에게 context 값을 가져와서 사용한다.

- context는 중첩이 가능하며 중첩되었을 경우, 가장 가까운 소비자에게 context value를 가져온다.

- context는 memo 기능으로 리렌더링을 막을 수 없다.

- context value가 객체일 경우, 객체 속성 하나만 변하여도 모든 부분에서 리렌더링이 진행된다. 따라서, context는 작은 조각으로 나누어서 사용해야한다.

 

[ context의 모범적 활용 ] 

import React, { createContext, useContext, useState, useCallback } from 'react';

// Context Factory 함수
function createContextFactory(defaultValue) {
  const Context = createContext(defaultValue);

  const useCustomContext = () => {
    const context = useContext(Context);
    if (!context) {
      throw new Error('useCustomContext must be used within a Provider');
    }
    return context;
  };

  const Provider = ({ children, value }) => {
    const [state, setState] = useState(value);
    const updateState = useCallback((newValue) => setState(newValue), []);

    const contextValue = {
      state,
      setState: updateState,
    };

    return <Context.Provider value={contextValue}>{children}</Context.Provider>;
  };

  return { Provider, useCustomContext };
}

// Example: User Context
const { Provider: UserProvider, useCustomContext: useUserContext } =
  createContextFactory({ name: '', isLoggedIn: false });

// Example: Theme Context
const { Provider: ThemeProvider, useCustomContext: useThemeContext } =
  createContextFactory('light');

// App Component
function App() {
  return (
    <UserProvider value={{ name: 'Alice', isLoggedIn: true }}>
      <ThemeProvider value="dark">
        <Main />
      </ThemeProvider>
    </UserProvider>
  );
}

// Example Main Component
function Main() {
  const { state: user, setState: setUser } = useUserContext();
  const { state: theme, setState: setTheme } = useThemeContext();

  return (
    <div>
      <h1>Welcome, {user.name}</h1>
      <p>Current theme: {theme}</p>
      <button
        onClick={() => setUser({ ...user, name: 'Bob' })}
      >
        Change User Name
      </button>
      <button
        onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}
      >
        Toggle Theme
      </button>
    </div>
  );
}

export default App;

context를 팩토리 패턴과 조합한 모범 사례이다. 

- 초기값 또는 함수(reducer)를 받고 해당 값을 다루는 provider와 context hook을 반환한다.

- 반환받은 porvider와 HOC를 활용하면 이를 중첩하여 재사용할 수 있다.

 

근데, 여기서 context가 많아지는 게 불편한 사람이 있을 수 있다.

// Provider 설정
function createProviders() {
  const themeState = useThemeState();
  const userState = useUserState();
  const languageState = useLanguageState();

  return [
    {
      context: ThemeContext,
      value: themeState
    },
    {
      context: UserContext,
      value: userState
    },
    {
      context: LanguageContext,
      value: languageState
    }
  ];
}

// ComposeProviders 컴포넌트
const ComposeProviders = ({ providers, children }) => {
  return providers.reduceRight((acc, { context: Context, value }) => (
    <Context.Provider value={value}>{acc}</Context.Provider>
  ), children);
};

// 훅을 통한 Context 사용
const useTheme = () => useContext(ThemeContext);
const useUser = () => useContext(UserContext);
const useLanguage = () => useContext(LanguageContext);

// 실제 사용 예시
function App() {
  const providers = createProviders();
  
  return (
    <ComposeProviders providers={providers}>
      <Layout />
    </ComposeProviders>
  );
}

그래서, reduceRight를 통해서 깔끔하게 정리해줄 수 있다.

 

[ 구독과 모듈 상태 관리 ]

모듈 상태 : 본래 모듈 스코프에 정의된 상태를 의미하나 책에서는 편의상 react 외부 최상단 모듈에 정의한 상태를 의미한다. (거의 전역 상태라 봐도 무방하다)

 

모듈 상태에서 전역 상태를 정의하면 편하지만, 변경했을 때 리렌더링을 유발하지 않는다.(그래서 최적화가 가능한 리렌더링 훅을 만들어야한다.) 반대로, 직접 변경에 참여하지 않은 컴포넌트는 이 변경사항을 알 방법이 없다. 이를 해결하기 위하여 구독(subscription)을 사용한다.

 

구독의 원리는 다음과 같다.

1. state, subscribe, subscribe를 store를 생성하고 이를 hook으로 사용한다.

2. store 사용 시, 자동으로 해당 부분을 subscribe (hook 내부에 사용처 배열에 추가한다)하고 최신 값을 가져온다. 

3. store의 값이 변경될 시, subscribe 되어있는 모든 객체에 새로운 값을 전파하여 갱신한다.

4. component의 생명주기가 끝나면 subscribe를 해제하여 불필요한 렌더링을 막는다.

 

이 때, 객체의 일부 속성 등만 사용할 경우 불필요한 리렌더링이 일어날 확률이 높다. 이 부분이 일어나지 않게 선택자 함수나 메모를 이용하여 최적화를 해두어야한다.

 

[ context와 구독의 조합]

구독은 store(외부 전역 상태)에 의존하여 재사용성이 낮다. 예시로, comp1과 comp2는 모두 store를 봐서 모두 동일하지만 데이터가 다를 경우 재활용이 불가능하다. props로 속성을 전달해도 되지만, 그러면 props drilling 문제가 조만간 발생할 것이다.

 

context는 props drilling을 해결하고, 서로 다른 하위 트리에 값을 제공할 수 있어서 재사용성을 높여주지만 불필요한 리렌더링이 발생할 확률이 높다.

 

이 둘을 조합하면 props drilling, 컴포넌트 재사용, 하위 트리마다 다른 값 사용, 렌더링 최적화가 모두 가능하다.

 

const StoreContext = createContext<Store<State>>(
  createStore<State>({ count: 0, text: "hello" })
);

const StoreProvider = ({
  initialState,
  children,
}: {
  initialState: State;
  children: ReactNode;
}) => {
  const storeRef = useRef<Store<State>>();
  if (!storeRef.current) {
    storeRef.current = createStore(initialState);
  }
  return (
    <StoreContext.Provider value={storeRef.current}>
      {children}
    </StoreContext.Provider>
  );
};

const Component = () => {
  const count = useSelector(selectCount);
  const setState = useSetState();
  const inc = () => {
    setState((prev) => ({
      ...prev,
      count: prev.count + 1,
    }));
  };
  return (
    <div>
      count: {count} <button onClick={inc}>+1</button>
    </div>
  );
};

const App = () => (
  <>
    <h1>Using default store</h1>
    <Component />
    <Component />
    <StoreProvider initialState={{ count: 10 }}>
      <h1>Using store provider</h1>
      <Component />
      <Component />
      <StoreProvider initialState={{ count: 20 }}>
        <h1>Using inner store provider</h1>
        <Component />
        <Component />
      </StoreProvider>
    </StoreProvider>
  </>
);

1. StoreProvider는 선언과 동시에 Store를 생성한다. 해당 Store 값은 Context를 이용하여 관리된다.

2. 하위 트리에 StoreProvider를 중첩할 수 있다. 전역적으로 쓰고 싶다면 하나만 생성하면 된다.

3. 소비자는 가장 가까운 Provider에서 값을 가져오므로 컴포넌트는 모두 재활용이 가능하다. (props drilling, 하위 트리 다른 값 사용, 컴포넌트 재사용 문제)

4. 만약 값을 업데이트 한다면 기존 Context는 하위트리를 모두 리렌더링한다. 하지만, 현재는 Store를 참조하고 있고, Provider 내부 Store의 값이 바뀐 것이지 Store가 바뀐 것이 아니다. 그러므로 Context는 리렌더링하지 않는다. 그래서, Store가 구독하고 있는 요소만 업데이트 된다. (context 리렌더링 문제 해결)

 

[ 리렌더링 최적화 ]

실제로 상태는 대부분 객체다. 이를 최적화하지 않으면 리렌더링이 무수히 일어난다.

 

크게 세 가지 방법이 있다.

- 선택자 함수 사용하기 : 특정 속성을 사용할 것을 알고, memorization을 걸어서 그 속성이 변경되었을 때만 렌더링을 하는 것이다. 이를 수동 최적화라 한다.

- 속성 접근 감지하기 : 자동으로 사용한 속성을 감지하고 해당 속성이 변화되었을 때 리렌더링을 시키는 것이다. 즉, 자동 최적화다. 하지만, 객체에 속성 접근을 확인하려면 proxy를 필요로 한다.

- 아톰 사용 : 리렌더링을 유발하는 최소 상태 단위(atom)를 만들고 이를 구독하는 것이다.

 

[주석5] proxy 객체는 객체에 조작되는 어떠한 동작을 가로채서 대신 수행해준다. 즉, 어떤 동작을 명시적으로 추척하고 검증해서 실행시킬 수 있기 때문에 자동 최적화가 가능하다.

 

이후는 이런 특성들을 다뤄서 이미 잘 만들어진 라이브러리들을 소개한다. 이 부분은 간단하게 요약한다.

- zustand : react의 원리를 그대로 사용하며 가볍다. 그래서 일부 수동 최적화가 필요하다.

- jotai : 아톰을 이용하며, 의존성(아톰 사용처)을 추적하여 파생된 값들을 최신화한다.

- valtio : 속성 접근을 감지하여 최적화한다. 변경가능한 객체를 통해서 불변성 객체를 생성하기에 두 라이브러리와는 많이 다르다.

 

또한, 이 라이브러리들은 기존에 redux, mobx, recoil과 동일하게 보이는데 이 부분에 대해서도 의견을 주었다. 주로, 기존 라이브러리들은 이를 관리하기 위한 구체적인 패턴(class 등)을 제공한다. 다만, 최신 라이브러리들은 이 부분에서 좀 더 유연하고 가벼워서 보일러 플레이트들이 사라졌으며 기존에 있던 아쉬운 문제를 해결하기 위해 노력했다.

 

따라서, 모든 라이브러리는 취향 차이이다.

 

[ 그 외 배운 점]

주로 용어를 배웠다.

- WeakMap : 메모리 관리가 최적화된 Map의 특수한 형태다.

- XState : 유한 상태 모델을 이용한 META에 상태관리 라이브러리

- 멱등성 : 어떤 결과를 여러 번 적용해도 결과가 변하지 않는 성질. "순수"와 의미가 거의 동일하다.

 

결론

[ 장점 ] 

- 상태관리를 매우 깊게 설명한다.

- 풍부한 예시

 

[ 단점 ]

- 내용의 결론은 정해져있고 모두 알고 있다. 그래서 읽었을 때, 실력에 엄청난 변화가 생기지는 않는다. 이는 아마 기초 과학 같은 느낌으로 천천히 성장할 것이라 생각한다.

 

배울 게 너무 많고 재밌었다. 깊게 파고들고 싶다면 한 번쯤 볼만한 책이라 생각한다. 다만, 결론은 최신 라이브러리에서 지향하는 방향성에 맞게 쓰라가 될 것 같다.

 

본의 아니게 책을 5번 정도 읽었다. 읽을 때마다 아리송했는데 이제 드디어 다 이해했다. 마지막에 읽을 때, 정말 다 해결되었는데 한 번 빠르게 읽고 블로그 쓰며 정독하는 방법도 괜찮아 보인다. 다음부터 그렇게 해야겠다.