teklog

훅을 활용하여 마이크로 상태 관리하기

2024/03/10

n°52

category : React

img


Jotai의 개발자 Daishi Kato가 쓴 ‘리액트 훅을 활용한 마이크로 상태관리’ 를 읽고 정리한 내용이다. code snippet을 직접 작성하고, 기억할만한 내용을 선별하고, 글의 순서를 편집해 작성하였다. 지역상태/전역 상태의 구분, context api, 전역 상태가 어떤 문제를 공유하고 각각 어떻게 해결해가는지를 중점으로 작성했다. 이 글을 통해 복잡할 수 있는 전역 상태의 관리를 효과적으로 실천할 수 있기를 바라며…


마이크로 상태 관리란 ?


저자가 제안하는 상태 관리를 위한 방법론이다. 목적 지향형인 리액트 훅과 Redux와 같은 중앙 집중형 상태 관리의 한계를 개선하기 위한 방법을 제시한다.


  • 리액트 훅: 폼 상태, 서버 캐시, 라우팅 기반의 상태 관리 등 각 목적에 맞는 상태를 관리. 목적 지향적인 방법으로 처리할 수 없는 상태도 있어 한계가 있다.
  • 중앙 집중형 상태 관리: 단 하나의 집중된 상태로 관리. 중앙 집중형 라이브러리는 사용되지 않는 기능까지 추가되거나, 사용법이 지나치게 복잡한 경향이 있다.


저자는 대안으로 범용적인 상태관리를 제시한다. 1) 가벼운 상태 관리 2) 개발자가 요구사항에 맞는 적절한 상태 관리법을 선택할 수 있도록 한다. 범용적인 상태관리는 다음과 같은 필수 기능을 포함한다.


  • 상태 read
  • 상태 write
  • 상태 기반의 렌더링
  • 리렌더링 최적화
  • 다른 시스템과의 상호작용
  • 비동기 지원
  • 파생 상태
  • 간단한 문법



지역상태


효과적인 상태 관리를 위해 상태를 '지역 상태’와 '전역 상태’로 구분한다.


리액트에서 지역 상태란


  • 컴포넌트 내부에서만 사용되는 상태. (=컴포넌트에 격리된 상태, 하나의 컴포넌트에 속하고 캡슐화된 상태)
  • 리액트 상태는 컴포넌트 모델을 따른다. 순수함수와 같이 작동(같은 입력에 항상 같은 출력). 재사용에 용이.
  • 컴포넌트에서는 '지역성’이 중요. 컴포넌트(의 상태)가 격리되고 여러번 재사용될 수 있어야함.
  • 컴포넌트는 단방향 데이터 흐름 (props를 통한 전달)과 트리 구조를 기반함
  • useState, useReducer 훅을 사용. 상태 로직을 추출하여 커스텀 훅을 사용하기 용이함.
  • 전역 상태에 의존하는 컴포넌트는 컴포넌트 모델에 적합하지 않을 수 있다.
  • 외부 상태에 의존하게될 시 컴포넌트 독립성이 사라질 수 있음
  • 외부 상태에 대한 의존성이 동작이 일관되지 않을 수 있음


지역상태를 위해 useState가 사용된다. 혹은 지난 글에서 살펴봤듯이, 상태가 크거나 상태 변경의 로직이 늘어날 때 useReducer를 사용할 수 있다. 두 훅과 커스텀 훅은 이전 글에서 충분히 다루었으니, 지역 상태 훅들의 다음과 같은 공통점을 살펴보자.


지역 상태를 사용할 때


상태가 컴포넌트 단위로 독립되어 있어 상태 변경이 일어난 컴포넌트에서만 리렌더링을 한다. (리액트의 주요 설계 목적과도 부합한다.) 독립된 컴포넌트 내의 상태 변경은 다른 컴포넌트에 영향을 미치지 않는다. 이를 이용하여 상태를 효과적으로 관리하고 성능을 최적화할 수 있다.


  • 특정 컴포넌트에서만 사용되는 상태를 관리할 때
  • ex) 키보드 인풋 입력 시 버튼 컴포넌트가 리렌더링될 필요는 없다.
  • 컴포넌트를 재사용할 때
  • ex) 인풋 리스트 아이템은 인풋이 입력될 때 리스트 전체를 리렌더링하지 않게 한다.


지역 상태 훅의 특성


1 초기화 (지연 초기화)

  • 상태의 초기값으로 원시값, 참조값을 넣을 수 있다.
  • 훅의 초기값으로 함수를 넣으면 '지연 초기화’가 일어난다.
  • 첫번째 렌더링(첫번째 마운트)에서만 평가된다. 훅이 호출되기 전까지 함수는 호출되지 않는다.
  • 크고 복잡한 상태라면 초기값에 함수를 넣어 관리할 수 있다.


2 베일아웃 (bailout)

  • 상태의 변경이 없을 때 리렌더링은 생략된다.


useReducer의 초기화, bailout 예시

/* init은 초기화를 위한 함수 */
const init = (count:number):State => ({ count, text:'' })

const reducer = (state:State, action:Action)=> {
	switch(action.type) {
		case 'INCREMENT':
		return { ...state, count: state.count + 1 }
		case 'DECREMENT':
		return { ...state, count: state.count -1 }
		case 'TEXT':
		if(!action.text){
		/* 1 - bailout */*
		return state
		}
		return { ...state, text: action.text }
		default:
		throw new Error("unknown action type")
	}
}

// Component.tsx
const Component = () => {
	/* 2 - 지연 초기화 */*
	const [state, dispatch] = useReducer(reducer, 0, init)
	return (
	<div>
		<p>count: {state.count}</p>
		<button onClick={() => dispatch({type: 'INCREMENT'})}>
			PLUS
		</button>
		<button onClick={() => dispatch({type: 'DECREMENT'})}>
			MINUS
		</button>
		<input 
		onChange={(e) => dispatch({ type:'TEXT', text: e.target.value })} />
	</div>
	)
}

1. Bailout

  • state를 그대로 돌려주어야 이전 상태가 반환되어 bailout이 된다.
  • {…state}를 반환하게 되면 새로운 객체가 생성되어 리렌더링이 일어난다.


2. 지연 초기화

  • useReducer의 3번째 매개변수(init 함수)는 2번째 매개변수를 인자로 반환한 값을 상태의 초기값으로 사용한다.
  • init 함수는 useReducer가 호출될 시(즉 컴포넌트가마운트 되어 처음 렌더링될 시)에 평가, 실행된다



리액트 지역 상태를 사용하기


상태 끌어올리기

  • 동일한 형태의 상태를 사용하는 두 컴포넌트라면, 부모 컴포넌트를 만들어 하나의 상태로 두 컴포넌트에 props를 내려줄 수 있다.
  • 이 방법을 더욱 추상화하여 부모 컴포넌트에서 자식 컴포넌트로 의존성(props)을 주입하는 여러 컴포넌트 설계 패턴들(props getter, state reducer 등)이 있다. (이 패턴들은 역전된 제어Inversion of Control라고 불린다.)


내용 끌어올리기

  • 상위 컴포넌트의 상태 변경에 영향 받지 않는 컴포넌트나 컴포넌트의 일부분을 분리하여 상태 변경이 일어나는 컴포넌트 상위로 끌어올리는 방법.
  • HOC, render props와 같은 패턴과 함께 활용할 수 있다.


참고: 리렌더링이 일어나는 4가지 경우

  • 상태의 변경
  • props의 변경
  • 부모 상태의 변경 (리렌더링)
  • Context(전역 상태)의 변경


예시

상태 끌어올리기

// Parent 컴포넌트
function Parent() {
  const [sharedState, setSharedState] = React.useState("");

  return (
    <div>
      <ChildA sharedState={sharedState} setSharedState={setSharedState} />
      <ChildB sharedState={sharedState} />
    </div>
  );
}

// ChildA 컴포넌트
function ChildA({ sharedState, setSharedState }) {
  return <input value={sharedState} onChange={(e) => setSharedState(e.target.value)} />;
}

// ChildB 컴포넌트
function ChildB({ sharedState }) {
  return <div>Current Value: {sharedState}</div>;
}

내용 끌어올리기

// 상태가 있는 컴포넌트
function StatefulComponent() {
  const [counter, setCounter] = React.useState(0);

  return (
    <div>
      <button onClick={() => setCounter(counter + 1)}>Increment</button>
      <div>Counter: {counter}</div>
	  <div>This is static content that does not change.</div>
    </div>
  );
}

// 상태 변경에 영향을 받지 않는 정적 컴포넌트
function StaticContent() {
  return <div>This is static content that does not change.</div>;
}


function ContentLiftedComponent() {
	<>
	  <StatefulComponent/>
	  <StaticContent/>
	  {/* 혹은 StatefulComponent에 children props를 추가하여 수정할 수 있다.*/}
	  <StatefulComponent>
		  <StaticContent/>
	  </StatefulComponent>
	</>
}



전역상태


모든 컴포넌트가 하나의 상태에 의존하면(ex:최상단의 상태가 앱 전체의 컴포넌트에 상태를 props로 내려줌), 지역상태와 전역상태를 명확히 구분할 수 없다. 일반적으로 리액트에서 '전역상태’란 떨어진 컴포넌트에서 '공유된 상태shared state’이다.


전역상태의 두가지 측면

  • 싱글톤. 특정 컨텍스트에서 상태가 하나의 값을 갖는다. 모듈 파일로 분리된 전역 상태를 사용하면 싱글톤과 유사하게 사용할 수 있다.
  • 공유 상태. 상태 값이 다른 컴포넌트에 공유되지만, 메모리상에서 단일 값일 필요는 없다. 대표적으로 리액트 컨텍스트 API는 싱글톤이 아닌 공유 상태이다.


언제 사용할까?

  • porp을 전달하는 것이 적절하지 않을 때
  • 익히 알려진 상태 공유가 props 드릴링을 유발하는 경우. 상태를 공유하려는 컴포넌트가 멀리 떨어질 수록 컴포넌트의 구조와 코드는 더욱 복잡해진다.
  • 이미 리액트 외부에 상태가 있을 때
  • ex) 리액트 없이 획득한 사용자 인증 정보(OAuth 등으로 획득한 사용자 토큰)가 페이지 전체에 공유되어 특정 컴포넌트의 기능을 막거나 작동하게 해야한다.



Context API


리액트는 16.3부터 context api를 제공한다. Context Provider, useContext 훅을 사용하여 간단하게 전역상태를 사용할 수 있게 되었다. 책에서는 컨텍스트 api의 기본적인 사용법과 함께, 근본적인 문제와 한계, 모범사례를 살펴본다. 이 챕터에서 중요한 부분은 컨텍스트의 한계이다. 여기서 소개되는 문제의식과 해결법은 뒷장에서 소개하는 전역상태 라이브러리들이 같은 문제를 해결하기 위해 어떤 접근법을 취하고 있는지 이해하는데 도움이 된다. 이 챕터에서 설명하는 store, subscribe, selector같은 개념은 여러 라이브러리에서 유사한 개념과 기능을 공유하기 때문에 숙지하는 것이 좋다.



기본적인 사용법


useState를 사용하거나 정적인 값을 사용하여 컨텍스트를 생성할 수 있다. 상태를 공유하는 컴포넌트를 provider로 묶어주어야 한다. context provider에는 정적인 값이나 useState, useReducer의 상태를 넣어줄 수 있다. Provider value prop에 정적인 값이 들어간다면 전역상태를 변경할 수 없게 된다. (정확히는 업데이트할 방법이 없어진다.)


// 컨텍스트를 정적인 값으로 사용하는 경우
const SampleContext = createContext('black')
const SampleContext =  createContext({color: 'black', count: 0, setColor:(param:string) => void })


const Root = () => {
const [color, setColor] = useState('black') // state 훅

return (
	<>
		<SampleContext.Provider value={{ color, setColor, count:0 }}>
		<SampleContext.Provider value={color}> // 정적인 값 사용 시 
			<ColorComponent />
			<MemoColorComponent />
			<CountComponent />
			<MemoCountComponent />
			<DummyComponent />
			<MemoDummyComponent />
		</ColorContext.Provider>
	</>	
	
)
}

const ColorComponent = () => {
	const color = useContext(SampleContext) // 정적
	const {color, setColor} = useContext(SampleContext) // state 훅

	return (
		<div>
			<p>current color : {color}</p>
			<input onChange={(e) => setColor(e.target.value)} />
		</div>	
	)
}

const CountComponent = () =>{
	const {count} = useContext(SampleContext)
	return <>{count}</>
}

const DummyComponent = () => (<>dummy</>)

const MemoColorComponent = memo(ColorComponent)
const MemoCountComponent = memo(CountComponent)
const MemoDummyComponent = memo(DummyComponent



컨텍스트의 전파


컨텍스트 provider와 상태훅을 사용해 컨텍스트를 업데이트할 수 있다. 이 때 프로바이더 내부의 모든 컴포넌트가 리렌더링된다. 이 경우 상태 변경이 이루어지지 않는 컴포넌트까지 불필요하게 리렌더링이 일어난다. 이를 방지하기 위해 앞서 살필 '내용 끌어올리기’나 혹은 memo훅을 사용할 수 있다.


인풋 태그에 텍스트를 입력하여 컨텍스트를 업데이트 시 다음과 같은 컨텍스트 전파가 일어난다.


  1. 처음에 모든 컴포넌트가 렌더링
  2. 텍스트 입력 시 Root의 상태가 바뀌며 Root 컴포넌트가 리렌더링
  3. SampleContext.Provider가 새로운 value를 받는 동시에 내부 컴포넌트 리렌더링
  4. MemoCount를 제외한 모든 컴포넌트가 리렌더링됨
  5. MemoColorComponent 또한 리렌더링된다. 부모 컴포넌트의 상태가 변화하고 컨텍스트가 변경되었기 때문이다.


간단한 방법은 Provider 나누는 것이다. 컨텍스트는 프로바이더를 경계로 공유되기 때문에, 공유하려는 상태가 아닌 컴포넌트는 Provider 경계 밖으로 분리할 수도 있다. 하지만 요구사항에 따라 혹은 이미 개발된 컴포넌트의 설계에 따라 그럴 수 없는 경우 또한 자주 있을 수 있다.



컨텍스트의 한계

  1. 컨텍스트가 업데이트될 때 불필요한 리렌더링이 늘어난다.
  2. 컨텍스트가 객체 형태일 때 이와 같은 리렌더링이 더욱 늘어난다.
  3. 불필요한 리렌더링을 방지하기 위한 코드의 양이 증가한다.


1, 2번은 위의 예제를 통해 설명된다. setColor를 통해 바뀐 상태는 color 밖에 없음에도, CountComponent와 React.memo로 감싸진 MemoCountComponent가 리렌더링된다. 또한 DummyComponent도 Provider에 제공된 값이 바뀌면서 리렌더링된다. 컨텍스트에 의존하는 컴포넌트가 많고, 상태 변경이 자주 이루어진다면 불필요한 리렌더링이 페이지 단위로 불필요한 리렌더링이 무분별하게 실행될 것이다.



해결 방법과 모범 사례


앞선 객체의 일부 컨텍스트를 사용할 시 리렌더링되는 문제를 해결하기 위해서 컨텍스트를 작게 쪼게는 방법이 있다.

const ColorContext = createContext(...)
const CountContext = createContext(0)

const Root = () => {
.... 위와 동일

	<ColorContext.Provider value={{color, setColor}}>
		<CountContext.Provider value={0}>
		...

}

const ColorComponent = () => {
	const {color , setColor } = useContext(ColorContext)
	...
}

const CountComponent = () => {
	const count = useContext(CountContext)
	....
}


프로바이더는 중복하여 사용이 가능하며, ColorContext가 업데이트될 시 CountContext를 사용하는 컴포넌트는 memo를 사용한다면 리렌더링되지 않는다. 물론 이 방법으로 컨텍스트를 작게 쪼게다보면 Provider의 수가 증가하는 단점은 있다. 프로바이더가 너무 많아 value prop으로 내려주어야 할 값을 관리하기 어려울 수 있다.


이같은 문제를 해결하기 위해 저자는 세가지 해결방법을 제시한다.


  • 커스텀 훅과 Provider 컴포넌트
  • 커스텀 훅과 팩토리 패턴
  • reducerRight를 활용한 Provider 중첩 개선


커스텀 훅과 Provider 컴포넌트


Provider와 useContext를 사용하려는 컨텍스트 별로 커스터마이징하여 사용하는 방법이다. 위에서 작게 나눈 컨텍스트를 기반으로 작성한다.

type ColorContextType = [string, Dispatch<SetStateAction<string>>] | null

const ColorContext= createContext(null)

export const ColorProvider = ({children}:...) => {
	const [color, setColor] = useState('black')
	return (
	// ColorProvider에서 ColorContext의 초기값을 주입한다
		<ColorContext.Provider value={{ color, setColor }}>
			{children}
		</ColorContext.Provider>
	)
}

export const useColorContext = () => {
	const {color, setColor} = useContext(ColorContext)
	// 이 훅이 ColorProvider 밖에서 사용되면 컨텍스트는 null일 것이다.
	if(color === null) {
		throw new Error('Provider is missing')
	}
}



const ColorComponent = () {
	const { color, setColor } = useColorContext();
	....
}

// 같은 방식으로 CountContext 커스텀 훅과 프로바이더 컴포넌트를 작성

const Root = () => {
	<ColorProvider>
		<CountProvider>
			<ColorComponent />
			<CountComponent />
			.....
}

ColorProvider를 분리하여 Root에 선언된 상태를 커스텀 프로바이더 내부로 옮기고, useColorContext 훅으로 컨텍스트의 상태를 불러오거나 업데이트할 수 있게 되었다. 앞선 코드에서 반복된 컨텍스트 import를 없애고, 상태와 상태 변경 로직을 커스텀 훅으로 추상화할 수 있었다.



커스텀 훅과 팩토리 패턴


작게 쪼개진 상태에 매번 커스텀 훅과 컴포넌트를 추가하는 것 또한 반복적이다. 이를 한번 더 추상화할 수 있다. ColorContext, CountContext 등 어떤 형태의 컨텍스트라도 커스텀 Provider와 커스텀 useContext훅을 튜플 형태로 반환하는 커스텀 훅을 활용할 수 있다.

const createCustomContext = <T, P>(useValue: (init:T) => P) => {
	const StateContext = createContext(null)
	const StateProvider = ({initialValue, children}:{...}) => (
		<StateContext.Provider value={useValue(initialValue)}>
			{children}
		</StateContext.Provider>
	)

	const useContextState = () => {
		const value = useContext(StateContext)
		if(value === null) {
			throw new Error("Provider missing")
		}
		return value
	}

	return [StateProvider, useContextState] as const
}
...

const useInitState = <T>(init:T) => useState(init)

const colorInit = useInitState('black')
const [ColorProvider,  useColor] = createCustomContext(colorInit)
// 컴포넌트를 만들지 않고 이 커스텀 훅을 재사용할 수 있다.
....


const ColorComponent = () => {
	const {color, setColor} = useColor();
...
}


createCustomContext 팩토리 패턴을 사용하여 매번 컴포넌트를 만들지 않고 함수로 작성하였다. 이 useState 대신 reducer를 활용하여 상태 업데이트 로직을 함수 단위로 나눌 수 있다.



reducerRight를 활용한 Provider 중첩 개선

상태가 잘게 나누어져 위의 훅을 여러번 사용하고 Provider 또한 깊게 네스팅된다면 reducerRight을 사용할 수 있다.

const App = () => {
	
	const providers = [
	[Provider1, {init: 'black'}],
	[Provider2, {init: 'white'}],
	[Provider3, {init: 'yellow'}],
	[Provider4, {init: 'red'}],
	[Provider5, {init: 'blue'}],
	]

	return providers.reduceRight(
		(children, [Provider, props]) => 
			createElement(Provider, props, children),
			<Root />
	)
}

HOC와 함께 사용할 수 있다. 사실 Context.Provider가 깊게 네스팅되는 것은 기본 문법이기에 큰 문제가 있는 것은 아니다.


사실 이 부분까지 읽으면서, 저자가 컨텍스트 API 챕터 도입부에 언급한 "처음부터 컨텍스트 API는 전역상태를 위해 설계된 것이 아니다"라는 언급이 떠올랐다. 비교적 작은 컴포넌트 단위 사이에서 상태를 공유한다면 컨텍스트가 적절할 수 있으나, 컴포넌트가 크고 복잡할 때 너무 많은 훅과 프로바이더가 추가되는 것은 피할 수 없어 보인다. 만약 이 정도로 코드가 추가되어야 한다면, 추후 소개할 상태관리 라이브러리를 사용하는 게 더 적절할 것이다.


다른 상태 라이브러리를 살피기 앞서 다음 개념들을 숙지해보자.



모듈 상태, 스토어, 구독, 셀렉터


이어지는 장에서 module.ts로 분리한 파일에 let count = 0; 변수를 할당하고, 리액트 컴포넌트에서 이 변수를 전역상태로 사용하는 방법을 설명한다. 모듈 파일에 선언된 변수를 전역상태로 사용하기 위해 store를 만들고, store 내부에 메서드를 추가해야한다. 리액트는 useSyncExternalStore 훅을 제공하여 모듈 파일에 store를 구독하여 전역 상태의 변경을 감지할 수 있다. 모듈, 스토어, 구독, 셀렉터는 일반적인 상태 관리 라이브러리에서 공유되는 개념이기에 숙지하는 것이 도움이 된다. 예제 코드는 생략하고 중요 개념을 살펴보자.


모듈 상태

  • 모듈로 분리된 전역 상태. 상태 라이브러리 사용 시 보통 atom.ts처럼 파일로 분리된 전역 상태들을 import하여 사용하게 된다.
  • 모듈에서 할당된 전역 상태 변수를 import하여 사용하는 것은, 여러 컴포넌트에서 동일한 메모리 주소를 사용하여 싱글톤과 유사한 효과를 낼 수 있다.


스토어

  • 중앙 집중된 저장소. 하나 혹은 여러 상태를 스토어에 저장할 수 있다.
  • 리액트에서는 Redux나 Zustand의 createStore 함수를 사용하여 생성할 수 있다. 스토어는 전역 상태의 일관성을 유지하고, 상태 접근 및 업데이트의 중심 지점 역할을 한다.


구독

  • 스토어의 특정 상태 변화를 감지하는 메서드. 이에 반응하여 필요한 동작(UI 업데이트)을 실행한다.
  • 리액트에는 useSyncExternalStore라는 리액트 내장 API를 제공한다. 모듈 파일에 store 변수를 만들어 이 변수를 구독하게 만들 수 있다. (참조)
  • Redux, Zustand, jotai 등 다양한 상태 라이브러리에서는 subscribe 메서드를 통해 스토어의 상태 변화를 구독하는 기능을 제공한다. 컨텍스트 API에서는 useContext 훅을 사용하여 자동으로 변화를 구독한다. 구독을 통해 상태 변화에 즉각적으로 반응할 수 있다.


셀렉터 (선택자 함수)

  • 스토어의 상태 중 필요한 부분만을 선택적으로 추출하는 함수. 특히 객체 형태의 상태에서 일부를 가져올 때 사용한다.
  • 셀렉터는 상태의 특정 부분에 대한 접근을 추상화하고, 계산된 상태를 도출하는 데 유용하다.
  • 셀렉터로 전역 상태의 일부 혹은 가공하여 가져온 것을 "파생 상태 derived state"라고 부른다. 많은 라이브러리에서 공통적으로 파생 상태를 얻기 위한 기능을 제공한다.
  • 셀렉터는 컴포넌트의 재렌더링을 최적화하는 데 중요한 역할을 한다.



상태 관리 라이브러리


리액트는 컴포넌트를 중심으로 설계되었다. 컴포넌트는 모든 것이 재사용 가능한 것으로 여겨진다. 하지만 전역 상태는 컴포넌트 외부에 존재한다. 컴포넌트에 대한 추가적인 의존성이 필요하기 때문에 전역 상태 사용을 피하는 것이 좋지만, 전역 상태를 사용하는 것은 편리하며 생산성을 높일 수 있다. 그리고 요구사항에 따라 전역 상태가 필요할 수 있다.


전역 상태를 설계할 때 일반적인 문제점

  1. 전역 상태를 읽는 방법 (read)
  2. *전역 상태는 여러 값을 가질 수 있고, 전역 상태를 사용하는 컴포넌트는 전역 상태의 모든 값이 필요하지 않은 경우가 있다. 전역 상태가 바뀌면 리렌더링이 발생하는데, 변경된 값이 컴포넌트와 관련 없는 경우에도 리렌더링이 발생한다. 이는 바람직하지 않으며, 전역 상태 라이브러리는 이에 대한 해결책을 제공할 필요가 있다.
  3. 전역 상태에 값을 넣거나 갱신하는 방법 (write)
  4. 전역 상태는 여러 값을 가질 수 있으며, 그증 일부는 중첩된 객체일 수 있다. 이럴 때 하나의 전역 변수를 가지고 개발자가 직접 값을 변경하는 것은 좋은 방법이 아닐 수 있다… 객체의 값을 직접 변경시 리액트 컴포넌트의 리렌더링을 트리거할 방법이 없기 때문이다.


앞서 살핀 문제와 해결법을 모두 압축하고 있다. 컨텍스트 API에서 이 문제를 해결하기 위한 코드 예시를 살펴봤다. 불필요한 리렌더링을 방지하기 위해 컨텍스트를 작게 나누었고, 전역 상태의 값을 직접 바꾸는 대신 useState 훅의 dispatch나 useReducer의 action 함수를 사용했다. 또한 이 해결방법의 한계에 대해서도 살펴보았다.


들어가기에 앞서 전역 상태의 두가지 접근법을 간단히 알아본다.



데이터 중심 접근 방식 vs 컴포넌트 중심 접근 방식


전역 상태는 데이터와 컴포넌트 중심으로 유형을 나눌 수 있다.


데이터 중심

  • 전역 상태의 데이터 모델은 싱글톤으로 갖을 수 있다.
  • 처리할 데이터가 이미 있을 수 있고, 컴포넌트를 정의한 후 상태와 연결한다. 이 상태는 라이브러리, 서버 등 외부에서 변경할 수 있다.
  • 데이터 중심 접근 방식인 경우 모듈 상태가 리액트 외부의 자바스크립트 메모리에 있기 때문에 모듈 상태를 사용하는 것이 더 적합하다.
  • 모듈 상태는 렌더링을 시작하기 전이나 컴포넌트가 마운트 해제된 후에도 존재할 수 있다.
  • 모듈 상태는 보통 store와 상태에 접근하고 상태를 업데이트하는 store의 메서드를 제공한다.


컴포넌트 중심

  • 데이터 중심과 달리 컴포넌트를 먼저 설계하고, 이를 기반으로 필요한 상태를 설계할 수 있다.
  • 컴포넌트의 생명주기나 사용자 인터렉션에 맞추어 전역 상태에 접근하여 필요한 데이터에 접근하거나 업데이트할 수있다.


리렌더링 최적화


보통 상태 라이브러리는 세가지 패턴으로 리렌더링을 최적화한다.


셀렉터 사용

  • 셀렉터는 상태를 받아 상태의 일부를 반환한다. 객체의 일부를 사용하거나 전역 상태에서 파생된 값을 사용할 때 유용하며, 전역 상태 변경에 불필요한 리렌더링을 막을 수 있다. 셀레터는 상태의 어느 부분을 사용할 지 명시적으로 지정하는 방법으로 수동 최적화라고 한다.


속성 접근 감지

  • 속성 접근을 감지하여 렌더링을 최적화하는 상태 사용 추적이라는 패턴도 있다. 셀렉터보다 간단한 문법을 사용하며, 자동으로 렌더링을 최적화할 수 있다. 하지만 셀렉터가 최적화에 더 유리한 경우도 있다.
const Component = () => {
	const trackedState = useTrackedState();
	/* selector */
	// const {a} = useSelector((state) => state.b)
	// const {c} = useSelector((state) => state.e)
	/* selector가 최적화가 잘되는 경우*/
	const isSmallA = useSelector((state) => state.a < 10)
	const isSmallA = useTrackedState().a < 10
	/*useTrackedState().a는 상태 a가 바뀔 때마다 리렌더링되지만, useSelector는 state.a < 10 true/false가 바뀌기 전까지 리렌더링되지 않는다./
	return (
	<>
	{tracked.b.a}
	{tracked.e.c}
	{isSmall ? "smaller" : "bigger"}
	</>
	

	)

}

아톰 사용

  • 아톰은 리렌더링을 발생시키는 최소한의 단위다. 전역 상태를 구독하여 리렌더링을 피하는 대신, 아톰으로 세분화하여 구독한다. 마치 컨텍스트에서 createContext를 잘게 나누는 것과 유사하지만, 아톰은 작게 나눈 상태를 조합하여 하나의 큰 전역상태로 만들 수 있다.
const globalState = {
	a: atom(1),
	b: atom(2),
	c: atom(3)
}

const Component = () => {
 const value = useAtom(globalState.a)
 return <>{value}</>
}

const sum = globalState.a + globalState.b + globalState.c

아톰으로 파생값 또한 만들 수 있다. 이를 수행하기 위해서 의존성을 추적하여 아톰이 갱신될 때마다 파생값을 다시 평가한다. 이처럼 아톰을 사용하는 방식은 수동 최적화와 자동 최적화의 중간 정도이다. 아톰과 파생값의 정의는 명시적이지만, 의존성 추적은 자동으로 된다.


이상 상태 라이브러리가 해결하려는 문제와 접근 방식, 해결 패턴에 대해 알아보았다. 마지막으로 각 라이브러리를 간략히 살펴보자.


Zustand


특징

  • 모듈 상태를 구현하는데 사용하도록 설계되었다.
  • store 생성자 인터페이스를 사용한다.
  • 크기가 작고 데이터 흐름이나 파일 디렉터리를 강제하지 않는다. 최소한으로 설계 되었다.
  • 상태 객체에 새 값을 할당하여 직접 수정할 수 없고 항상 상태를 새로 만드는 불변 갱신 모델을 사용한다.
  • 객체의 참조가 동등성을 확인하여 변경 여부를 확인한다.
  • 렌더링 최적화는 셀렉터를 사용해 수동으로 한다.


사용방법

// store.ts
import create from 'zustand'

export const store = create(() => ({ count: 0 }))


// Component.tsx
import store from '@/libs/store'

const Component = () => {
	const selector = (state) => state.count < 10
	const isSmall = store(selector)

	 return (
	 <>
	 <p>{isSmall ? "small" : "big"}</p>
	 <p>{store.getState()}</p>
	 <button onClick={() => 
	 {store.setState((prev) => ({count: prev.count + 1}))}}
	 >INC</button>
	 </>
	 )

}

모듈 상태 기반으로 작성되어 store.ts에서 store를 정의하고 export하여 사용한다. 상태를 업데이트하기 위해선 새로운 객체를 반환하는 함수를 사용해야한다. 이 방식은 useState의 setter를 통해 상태를 업데이트하는 것과 일치한다. 셀렉터 함수를 사용하여 state.count < 10이 변경될 때만 리렌더링이 일어난다.


  • 읽기 상태 : 리렌더링을 최적화하기 위해 셀렉터를 사용
  • 쓰기 상태: 불변 상태 모델을 기반
  • 객체 불변성의 법칙
  • Zustand와 useState 훅은 상태가 객체일 때 새로운 객체를 생성해야한다. 객체 불변성을 지키기 위해서다. 이는 객체의 데이터가 한 번 생성된 후에는 그 내용을 변경할 수 없다는 원칙이다. 객체의 상태를 변경해야 할 때는 객체의 복사본을 생성하고, 복사본에 변경사항을 적용한 후, 해당 복사본을 새로운 상태로 사용한다. 자바스크립트에선 Object.assign이나 spread를 사용해 간단히 구현할 수 있다.
const originalObject = { a: 1, b: 2 };


const updatedObject = { ...originalObject, b: 3 };
const updated = Object.assign({}, originalObject, { b: 3, c: 4 });

Zustand 역시 객체 불변성을 기반으로 한다. 셀렉터가 참조적으로 동일한 객체(나 값)를 반환하면 객체가 변경되지 않은 것으로 간주하고 리렌더링을 하지 않는다.


장점

  • 셀렉터를 사용하여 리렌더링을 최적화할 수 있다.
  • 리액트와 동일한 사용하기에 단순하고 번들 크기가 작다


단점

  • 셀렉터를 위해 보일러플레이트 코드를 많이 작성해야 할 수 있다.


Jotai


특징

  • Zustand와 마찬가지로 불변 상태 모델
  • 앞서 살펴본 작은 상태 조각인 atom을 기반으로 사용
  • 컨텍스트와 구독을 사용한 패턴을 기반으로 하며 이를 atom으로 구현한다.
  • atom이 바뀔 때 리렌더링이 일어난다.
  • Provider를 선택적으로 사용할 수 있다.
  • 배열 구조로 리렌더링을 최적화하는 Atoms-in-atom을 활용할 수 있음


사용방법

// atoms.ts
import {atom} from 'jotai'

export const count1 = atom(0)
export const count2 = atom(0)
export const sumAtom = atom((get) => (get(count1) + get(count2)))
export const isSmallAtom = atom((get) => (get(count1) < get(count2)))

// Component.tsx
import store from '@/libs/store'

const Component = ({counterAtom}) => {
	const [count, setCount] = useAtom(counterAtom)
	const isSmall = useAtom(isSmallAtom)

	 return (
	 <>

	 <p>{count}</p>
	 <button onClick={() => 
	 {setCount((prev) => (prev + 1))}}
	 >INC</button>
	 </>
	 )
}

const Sum = () => {
	const sum = useAtom(sumAtom)
	return 	<p>{sum}</p>
}

const IsSmall = () => {
	const isSmall = useAtom(isSmallAtom)
	return 	<p>{isSmall ? 'count 1 is smaller' : 'count1 is bigger'}</p>
}

const App = () => {

	return (
		<div>
			<Sum />
			<IsSmall />
			<Component counterAtom={count1} />
			<Component counterAtom={count2} />
		</div>
	)

}

atom을 기반으로 상태를 작게 나누어 조합할 수 있다. 이는 셀렉터를 대신하여 유사한 효과를 낼 수 있다. Jotai의 구독은 atom 기반으로 useAtom훅은 스토어에 있는 특정 atom을 구독한다. 이 예시에서 각 컴포넌트는 count1, count2가 바뀔 때 해당 컴포넌트만 리렌더링되며, 파생 아톰인 sum, IsSmall은 파생 상태가 바뀔 때만 리렌더링된다.


Jotai의 스토어는 아톰 구성 객체와 아톰 값으로 구성된 WeakMap 객체다.


  • 아톰 구성 객체: atom 함수로 생성
  • 아톰 값 : useAtom 훅이 반환하는 값
  • Jotai의 구독은 아톰 기반이므로 useAtom 훅이 store에 있는 특정 아톰을 구독한다는 의미이다. 아톰 기반 구독을 통해 불필요한 리렌더링을 피할 수 있게 된다. 상태의 갱신을 추적하는 것을 의존성 추적이라하며, jotai는 이를 자동으로 수행한다.


상향식과 하향식

  • 하향식top-down: Zustand에선 셀렉터 함수를 통해 객체의 일부를 파생 상태로 만들었다. 이미 존재하는 큰 상태에서 작은 상태로 나누어가는 방식을 상향식이라 한다.
  • 상향식bottom-up: Jotai에서는 아톰을 작게 나누고, 이를 합성하여 더 큰 상태를 만들었다. (counterAtom 예시) 이를 상향식이라고 한다.


동적 아톰 생성과 Atoms-in-atom 패턴


리액트 컴포넌트의 생명 주기에 따라 생성되고 소멸되는 동적인 아톰을 사용할 수 있다. 컨텍스트 API에서 새로운 상태를 추가하는 것은 새로운 Provider 컴포넌트를 추가해 하위 트리를 전부 묶는 것이기 때문에 모든 하위 컴포넌트가 리렌더링된다. 이때 리액트 생성 주기와 무관하게 상태는 지속된다. 하지만 jotai의 동적 아톰 생성을 통해 컴포넌트의 생애주기에 맞춰 전역 상태를 변경할 수 있다. 일견 그럴 필요 있을까 싶지만, 매우 유용한 순간이 있다. 바로 전역 상태가 배열일 때이다. 사용자의 수정, 삭제의 이벤트에 따라 전역상태가 업데이트되는 리스트 컴포넌트를 떠올릴 수 있다. 이 때 효과적으로 전역상태를 관리할 수 있다.

// 동적 상태를 이용한 투두 리스트 예시
// id를 갖고 있지 않다
// atoms.ts
type Todo = {
	title: string;
	done: boolean;
}

type TodoAtom = PrimitiveAtom<Todo>
const todoAtomsAtom = atom<TodoAtom[]>([])

// TodoItem.tsx
const TodoItem = ({todoAtom, remove} 
: {todoAtom:TodoAtom; remove:(todoAtom:TodoAtom) => void }) => {
	const [todo, setTodo] = useAtom(todoAtom)
	return (
		<div>
			<input 
				type="checkbox" 
				checked={todo.done}
				onChange={() => setTodo((prev) => 
				({...prev, done:!prev.done}))}
			/>
			<span 
				style={{textDecoration: todo.done? "line-trough" : "none"}}
			>{todo.title}</span>
			<input 
				type="text" 
				onChange={(e) => 
				setTodo((prev) => ({...prev, title: e.target.value}))}
			<button
				onClick={() => remove(todoItem)}
			>
				Delete
			</button>
		</div>
	)
}

export default memo(TodoItem)

// NewTodo.tsx
const NewTodo = () => {
	const setTodoAtoms = useSetAtom(todoAtomsAtom)
	const [text, setText] = useState('')
	const onClick = {
		setTodoAtoms((prev) => 
		([...prev, atom<Todo>({title: text, done:false})]))
		setText('');
	}
	
	return (
		<div>
			<input onChange={(e) => setText(e.target.value)} />
			<button onClick={onClick}>Add</button>
		</div>
	)
}


// TodoList.tsx
const TodoList = () => {
	const [todoItems, setTodoItems] = useAtom(todoAtomsAtom)
	const remove = useCallback(
		(todoAtom: TodoAtom) => setTodoAtoms(
			(prev) => prev.filter((item) => item !== todoAtom)
		),
		[setTodoAtoms]
	)

	return (
		<div>
		<NewTodo />
		{todoAtoms.map((todoAtom) => (
			<TodoItem
				// atom은 문자열로 평가될 때 UID를 반환한다.
				key={`${todoAtom}`}
				todoAtom={todoAtom}
				remove={remove}
			/>
		))}
		</div>
	)
}
  1. 아톰을 동적으로 생성하기 위해 todo 아톰과 아톰 리스트(todoAtomsAtom)의 상태로 분리한다. 하지만 실제로 atoms.ts에 작성된 전역 상태는 리스트 상태 하나이며, 개별 todo 상태는 컴포넌트에서 생성된다.
  2. 이 코드에서 "동적으로 아톰을 생성"하는 컴포넌트는 NewTodo의 onClick 함수, setTodoAtoms이다. 타이핑 입력 후, 버튼을 클릭하면 리스트 아톰에 아톰이 추가된다. setTodoAtoms((prev) => ([…prev, atom({ title: “여기서 새로운 아톰을 추가”, done:false})]))
  3. 리스트 상태에서 요소 삭제는 TodoList의 remove 함수의 필터링으로 구현된다. 이를 props로 받은 TodoItem의 버튼 이벤트로 리스트 전역 상태가 업데이트된다.
  4. 마지막으로 TodoItem.tsx를 memo로 감싸고, remove에 useCallback을 사용하여 리렌더링을 최소화한다.


이같은 패턴을 Atoms-in-Atom이라고 부른다. 예시 코드에서 다음과 같은 이점이 생긴다.


0. 리렌더링의 이점. TodoItem은 개별적인 아톰(상태)를 갖는다. TodoItem에서 done, title의 상태가 변경되어도, 리스트 상태를 업데이트 하는 것이 아니기 때문에 TodoList 전체의 리렌더링은 일어나지 않는다.


1. 리스트 아이템을 렌더링할 때 uuid같은 외부 라이브러리를 사용할 필요가 없다! atom이 문자열일 때 UID로 평가되어 별도의 라이브러리로 ID를 생성할 필요가 없어진다.


2. 리스트를 업데이트하기 위한 필터링 로직 또한 간단해진다. 아톰 리스트 상태에서 아톰끼리 비교가 가능하다. setTodoAtoms((prev) => prev.filter((item) => item !== todoAtom) 단순한 로직으로 아톰의 비교가 가능하다.


추가적인 라이브러리 없이 로직을 단순화하면서 성능 상의 이점을 챙길 수 있어 매우 유용하다.


추가적인 내용

  • atom 함수에 첫번째 매개변수로 콜백함수만을 추가하면 read 아톰이 되며, 매개변수로 두개를 넣을 시 첫번째는 read 함수, 두번째는 write 함수가 된다. read only 아톰과 readAndWrite 아톰으로 구분할 수 있다. 파생상태를 변경할 일이 없다면 read only 아톰을 활용할 수 있다.
  • 반대로 atom의 첫번째 인자가 null이고 두번째 write 함수만 있는 경우 “액션 아톰” 혹은 write-only 아톰이라고 한다.
  • atom은 onMount, onUnmount 메서드를 제공한다. 아톰 변경함수를 인자로 받으며 사용 시작과 종료 시 로직을 추가할 수 있다.
  • jotai/utils는 많은 atom관련 훅을 제공한다. atomWithStorage, atomWithReducer는 아톰을 로컬 스토리지에 저장하거나 리듀서를 활용해 관리할 수 있게 해준다.


장점

  • 구문이 단순하며 리액트의 상태훅과 동일한 형태로 사용할 수 있다.
  • 아톰을 나누어 상태관리를 세분화할 수 있다.
  • 리렌더링을 최적화할 수 있다. 앞서 살핀 atoms-in-atom을 활용하여 상태를 세분화하고 리렌더링 효과를 동시에 볼 수 있다.

단점

  • 제공하는 기능이 많기 때문에 코드 복잡성이 늘어날 수 있다.
  • 리렌더링을 최적화하기 위해 추가되는 코드가 많이 늘어날 수 있다.
  • 상태를 효율적으로 사용하는데 학습 곡선이 있을 수 있다.


Valtio


특징

  • Zustand와 같이 주로 모듈 상태용으로 사용된다.
  • 리액트와의 통합을 위해 자바스크립트 Proxy 객체를 사용해 변경 불가능한 스냅숏을 가져온다.
  • Proxy를 사용해 리렌더링을 자동으로 최적화한다. 상태 사용 추적이라는 기법을 사용한다.
  • 프록시를 사용해 변경 가능한 객체에서 변경 불가능한 객체를 생성한다. 이를 스냅샷이라고 한다.


사용방법

// 변경 가능한 객체는 proxy를 사용한다
const state = proxy({count:0, obj:{title:"hi"}})

// state의 count를 1 증가 시킨다.
state.count += 1

// 불변 객체를 생성하기 위해선 snapshot을 사용한다.
const snap = snapshot(state)
const snap2 = snapshot(state)

state.count += 1
const snap3 = snapshot(state)

// snap은 Object.freeze로 동결되어 위와 같이 값을 변경할 수 없게 된다.
// snap과 state는 동일한 값을 갖지만 서로 다른 참조를 갖는다.

const Component = () => {
	const snap = useSnapshot(state)
	const inc = () => ++state.count1
	return (
	<>
	<span>{snap.count}</span>
	<button onClick={inc}>Add</button>
	</>
	)

}

스냅샷을 read-only가 가능한 상태(불변 객체)로, proxy를 write가 가능한 상태로 파악하면 쉽다. 리렌더링은 스냅샷에서 변화가 생길 때 트리거된다. 객체 상태의 불변성을 지키기 위해 복잡한 함수와 추가적인 코드가 필요하지 않다. 또한 스냅샷을 통해 자동 리렌더링 최적화가 되기 때문에 셀렉터를 구성하는 추가적인 코드가 필요하지 않다.


Valtio는 불변 객체를 생성하기 위해 snapshot 함수를 사용한다. 예제에서 state와 snap, snap2는 {count:1, …}라는 동일한 값을 갖지만, 다른 참조를 갖는다. 반면 snap, snap2는 snap3과 다른 count를 갖는다. 하지만 snap3.obj는 변경되지 않았기 때문에 snap, snap2.obj는 동일하여 snap.obj === snap3.obj가 유지된다. 참조가 동일하다는 것은 메모리를 공유한다는 것이다. Valtio는 필요한 경우에만 스냅샷을 생성하여 메모리 사용량을 최적화한다. 다른 라이브러리에서처럼 파생 상태로 분리하고, 이 상태의 변경을 감지하기 위한 셀렉터 함수가 불필요하다. Valtio는 라이브러리 내부에서 최적화를 실행한다. 스냅샷은 프록시 기반으로, 상태가 실제로 변화되었을 때만 리렌더링을 일으킨다.


Todo 리스트 예시 (Jotai의 atoms-in-atom과 동일한 결과물이다)

// proxy.ts
type Todo = {
	id: string;
	title: string;
	done: boolean
}

const state = proxy<{todos:Todo[]}>({todos: []})

const createTodo = (title:string) => {
	state.todos.push({
		id: nanoid(),
		title,
		done:false
	})
}

const editTodo = (id:string, title:string) => {
	const index = state.todos.findIndex((item) => 
	(item.id === id))
	state.todos[index].title = title
}

const removeTodo = (id:string) => {
	const index = state.todos.findIndex((item) => 
	(item.id === id))
	state.todos.splice(index, i)
}

const toggleTodo = (id:string) => {
	const index = state.todos.findIndex((item) => 
	(item.id === id))
	state.todos[index].done = !state.todos[index].done
}

// state의 상태를 직접 변경할 수 있기에 다른 라이브러리와 로직의 차이가 있다.

// TodoItem.tsx
const TodoItem = ({id}:{id:string}) => {
	const todoState = state.todos.find((item) => item.id === id)

	if(!todoState) {
		throw new Error("invalid todo id")
	}
	
	const { done, title } = useSnapshot(todoState);
	return (
		<div>
			<input 
				type="checkbox" 
				checked={done}
				onChange={() => removeTodo(id)}
			/>
			<span 
				style={{textDecoration: done ? "line-trough" : "none"}}
			>{title}</span>
			<input 
				type="text" 
				onChange={(e) => 
				editTodo(id, e.target.value})}
			<button
				onClick={() => removeTodo(id)}
			>
				Delete
			</button>
		</div>
	)
}

export default memo(TodoItem)

// NewTodo.tsx
const NewTodo = () => {
	const [text, setText] = useState('')
	const onClick = () => {
		createTodo(text)
	}
	
	return (
		<div>
			<input onChange={(e) => setText(e.target.value)} />
			<button onClick={onClick}>Add</button>
		</div>
	)
}


// TodoList.tsx
const TodoList = () => {
	const { todos } = useSnapshot(state)

	return (
		<div>
		<NewTodo />
		{todos.map((todo) => (
			<TodoItem
				key={todo.id}
				id={todo.id}
			/>
		))}
		</div>
	)
}

  1. snapshot은 TodoList와 TodoItem에서 사용되었다. TodoItem 내부의 done, title이 변경되어도 전체 리스트 상태는 변경되지 않는다.
  2. 라이브러리가 객체 불변성을 지키면서(proxy함수) 직접 객체의 값을 변경할 수 있게 하고, 상태 변경을 감지하여 자동으로 리렌더링을 최적화한다 (useSnapshot)


앞서 살펴본 코드와 비교해보면 코드량이 상당히 줄어든 것을 확인할 수 있다. 특히 리렌더링을 위해 모든 셀렉터를 수동으로 작성해야 하는 zustand와 비교했을 때 코드의 양이 확연히 줄어든다. 또한 객체 불변성을 유지하면서 상태 업데이트를 위해 그저 선언된 상태 proxy에 값을 할당하는 것 zustand, jotai와도 확연히 다르다. proxy는 객체의 deps가 아무리 깊어도 snapshot을 통해 그 변화를 감지할 수 있다. state.user.info.name = “new value” 처럼 깊은 프로퍼티의 변경도 스냡샷은 감지할 수 있다.


장점

  • proxy를 writable state, snapshot을 read-only 상태로 활용해 직관적인 상태 관리 로직을 작성할 수 있다.
  • proxy를 사용해 객체에 새로운 값을 할당하는 자바스크립트 문법을 사용하면서 상태의 불변성을 지킬 수 있다.
  • snapshot의 변경으로 리렌더링이 트리거되며, 리렌더링 최적화를 위한 추가적인 코드가 많이 필요하지 않다.


단점

  • 렌더링 최적화를 내부적으로 처리하기 때문에 디버깅이 어려울 수 있다.
  • Valtio가 snapshot을 내부적으로 처리하기 때문에 상태가 크고 복잡해질수록, 객체 내부의 상태변화를 추적하고 디버깅하는 것이 어려워질 수 있다.



이번 글을 통해 차세대 상태 관리 라이브러리들의 차이점과 장단점을 숙지하고, 세 라이브러리가 어떠한 문제의식을 공유하고 어떤 식으로 문제를 해결했는지 알 수 있었다.