- 3부에서는 마이크로 상태 관리를 위한 네가지 라이브러리와 함께 리렌더링 최적화를 위한 접근 방식과 사용법을 알아본다.
- 3부의 구성은 다음과 같다.
- 6. 전역 상태 관리 라이브러리 소개
- 7. 사용 사례 시나리오 1: Zustand
- 8. 사용 사례 시나리오 2: Jotai
- 9. 사용 사례 시나리오 3: Valtio
- 10. 사용 사례 시나리오 4: React Tracked
- 11. 세 가지 전역 상태 관리 라이브러리의 유사점과 차이점
6. 전역 상태 관리 라이브러리 소개
- 지금까지 컴포넌트 간에 상태를 공유하는 데 사용되는 몇 가지 패턴을 살펴봤다. 앞으로는 이러한 패턴을 사용하는 다양한 전역 상태 라이브러리를 소개한다.
- 라이브러리를 알아보기에 앞서 전역 상태의 특성을 이해하기 위해 전역 상태와 관련된 문제와 함께 전역 상태 라이브러리에서 상태가 위치하는 곳과 리렌더링을 제어하는 방법이라는 두 가지 측면에 대해서 먼저 알아보겠다.
- 이번 장에서는 다음 주제를 다룬다.
- 전역 상태 관리 문제 해결하기
- 데이터 중심 접근 방식과 컴포넌트 중심 접근 방식 사용하기
- 리렌더링 최적화
기술 요구사항
- 리액트와 리액트 훅에 대한 적절한 지식이 필요하다. 자세한 내용은 공식 사이트(https://reactjs.org)를 참고한다. 이번 장의 코드를 실행하려면 Craete React App(https://create-react-app.dev) 또는 CodeSandbox(https://codesandbox.io)같은 리액트를 실행할 수 있는 환경이 필요하다.
전역 상태 관리 문제 해결하기
- 리액트는 컴포넌트라는 개념을 중심으로 설계됐다. 컴포넌트 모델에서는 모든 것이 재사용 가능한 것으로 여겨진다. 지금까지 다룬 전역 상태는 컴포넌트 외부에 존재한다. 컴포넌트에 대한 추가적인 의존성이 필요하기 때문에 가능하면 전역 상태 사용을 피하는 것이 좋지만 전역 상태를 사용하는 것은 매우 편리하며 생산성을 높일 수 있다. 그리고 어떤 애플리케이션에는 전역 상태가 필요한 요구사항이 있을 수 있다.
- 전역 상태를 설계할 때는 두 가지 문제점이 있다.
- 첫 번째 문제점은 전역 상태를 읽는 방법이다.
전역 상태는 여러 값을 가질 수 있고, 전역 상태를 사용하는 컴포넌트는 전역 상태의 모든 값이 필요하지 않은 경우가 있다. 전역 상태가 바뀌면 리렌더링이 발생하는 데, 변경된 값이 컴포넌트와 관련 없는 경우에도 리렌더링이 발생한다. 이러한 리렌더링은 바람직하지 않으며, 전역 상태 라리브러리는 이에 대한 해결책을 제공할 필요가 있다. 불필요한 리렌더링을 피하는 방법에는 여러 가지가 있으며, '리렌더링 최적화' 절에서 자세히 다루겠다. - 두 번째 문제점은 전역 상태에 값을 넣거나 갱신하는 방법이다.
앞서 말했듯이 전역 상태는 여러 값을 가질 수 있으며, 그중 일부는 중첩된 객체일 수 있다. 이럴 때 하나의 전역 변수를 가지고 개발자가 직접 값을 변경하는 것은 좋은 방법이 아닐 수 있다. 다음 코드는 전역 변수에서 하나의 프로퍼티를 개발자가 직접 값을 변경하는 예다.
- 첫 번째 문제점은 전역 상태를 읽는 방법이다.
let globalVariable = {
a:1,
b:{
c:2,
d:3,
},
e:[4,5,6],
}
globalVariable.b.d = 9;
- 위 예제에서 globalVariable.b.d = 9;라는 변경은 전역 상태에서 작동하지 않을 수 있다. 변경 사항을 감지하고 리액트 컴포넌트를 리렌더링할 방법이 없기 때문이다.
- 따라서 전역 상태 변경을 감지하기 위해서는 전역 상태를 변경하는 함수를 제공해야 한다. 또한 변수가 직접 변경될 수 없도록 클로저에서 변수를 숨기는 경우도 있다. 다음 코드는 클로저에서 변수를 읽고 쓰는 두 가지 함수를 만드는 예다.
const createContainer = () => {
let state = {a:1, b:2};
const getState = () => state;
const setState = (...) => {...};
return {getState, setState}
};
const globalContainer = createContainer();
globalContainer.setState(...);
- createContainer 함수는 getState와 setState 함수를 포함하는 globalContainer를 생성한다. getState는 전역 상태를 읽는 함수이고 setState는 전역 상태를 갱신하는 함수다. 전역 상태를 갱신하기 위해 setState와 같은 함수를 구현하는 방법에는 여러 가지가 있다. 구체적인 예제와 함께 살펴보자.
♣ 전역 상태 관리 대 범용 상태 관리 :
- 이 책에서는 전역 상태 관리에 초점을 맞추고 있으며, 범용 상태 관리는 이 책에서 다루지 않는다. 범용 상태 관리를 위해서는 Redux 같은 단방향 데이터 흐름을 통한 접근 방식과 XState 같은 상태 머신 기반 접근 방식이 널리 사용된다. 범용 상태 관리 접근 방식은 전역 상태뿐만 아니라 지역 상태에도 유용하다.
♣ 리액트 및 React Redux에 대한 참고 사항 :
- Redux는 전역 상태 관리에서 큰 역할을 해왔다. Redux는 전역 상태를 염두에 두고 단방향 데이터 흐름으로 상태 관리를 해결한다. 하지만 Redux 자체는 리액트와 아무 관련이 없다. 리액트와 Redux를 묶는 것은 React Redux 라는 라이브러리다. Redux 자체에는 리렌더링을 피할 수 있는 기능이나 개념이 없지만 React Redux에는 그러한 기능이 있다.
- Redux와 React Redux가 워낙 인기가 많았었기 때문에 과거에는 남용하는 개발자도 있었다. 이는 리액트 16.3 이전 버전에서 리액트 컨텍스트의 기능이 부족했기 때문이며, 대체 가능한 대중적인 방법이 없었다. 그런 개발자들은 단방향 데이터 흐름이 필요하지 않은 사례에 대한 레거시 컨텍스트에 React Redux를 잘못 사용 하곤 했다. 리액트 16.3 버전 이후의 리액트 컨텍스트와 리액트 16.8 버전 이후의 useContext 훅을 사용하게 되면서 프로퍼티 내려꽂기와 불필요한 리렌더링을 쉽게 피할 수 있었다. 그로 인해 이 책에서 중점으로 다루는 마이크로 단위의 상태 관리가 가능해졌다.
- 따라서 엄밀히 이 책의 범위는 React Redux에서 Redux를 뺀 것이다. Redux 자체는 범용적인 상태 관리를 위한 훌륭한 해결책이며, React Redux와 함께 이번 절에서 논의한 전역 상태 관련 문제를 해결한다.
- 이번 절에서는 전역 상태 라이브러리와 관련된 일반적인 문제에 대해 논의했다. 다음으로 상태가 어디에 위치하는 지 알아보겠다.
데이터 중심 접근 방식과 컴포넌트 중심 접근 방식 사용하기
- 전역 상태는 엄밀히 말해 데이터 중심과 컴포넌트 중심이라는 두 가지 유형으로 나눌 수 있다. 다음 절에서는 이 두가지 접근 방식에 대해 자세히 알아보겠다. 그런 다음, 몇 가지 예외에 대해서도 알아본다.
데이터 중심 접근 방식 이해하기
- 애플리케이션을 설계할 때 데이터 모델은 싱글턴으로 가질 수 있으며 처리할 데이터가 이미 있을 수 있다. 이 경우 컴포넌트를 정의한 후 데이터와 컴포넌트를 연결한다. 다른 라이브러리나 서버 등 외부에서 데이터를 변경하는 것도 가능하다.
- 데이터 중심 접근 방식의 경우 모듈 상태가 리액트 외부의 자바스크립트 메모리에 있기 때문에 모듈 상태를 사용하는 편이 더 적합하다. 모듈 상태는 리액트가 렌더링을 시작하기 전이나 모든 리액트 컴포넌트가 마운트 해제된 후에도 존재할 수 있다.
- 데이터 중심 접근 방식을 사용하는 전역 상태 라이브러리는 모듈 상태를 생성하고 모듈 상태를 리액트 컴포넌트에 연결하는 API를 제공한다. 모듈 상태는 보통 상태 변수에 접근하고 갱신하는 메서드를 가진 store 객체로 감싼다.
컴포넌트 중심 접근 방식 이해하기
- 데이터 중심 접근 방식과 다르케 컴포넌트 중심 접근 바식을 사용하면 컴포넌트를 먼저 설계 할 수 있다. 특정 시점에 컴포넌트는 공유 정보에 접근해야 할 수도 있다. 2장 '지역 상태와 전역 상태 사용하기'의 '지역 상태를 효과적으로 사용하는 방법'절에서 논의한 것처럼 상태를 위로 끌어올리고 props로 전달할 수 있다. 앞서 이와 같은 방식을 프로퍼티 내려꽂기라고 표현했다. 프로터피 내려꽂기가 적합한 해결책이 아닌 경우 전역 상태를 도입할 수 있다. 물론 먼저 데이터 모델을 설계하는 것부터 시작할 수 있지만 컴포넌트 중심 접근 방식에는 데이터 모델이 컴포넌트에 강한 의존성을 가지고 있다.
- 컴포넌트 중심 접근 방식에서는 컴포넌트 생명 주기 내에서 전역 상태를 유지하는 것이 더 적합하다. 의존하는 컴포넌트가 모두 마운트 해제되면 전역 상태도 함께 사라지기 때문이다. 이를 활용하면 자바스크립트 메모리에 두 개 이상의 동일한 전역 상태를 둘 수 있는데, 각 전역 상태는 서로 다른 컴포넌트 하위 트리에 존재하기 때문이다.
- 데이터 중심 접근 방식을 사용하는 전역 상태 라이브러리는 책토리 함수를 제공하며, 이러한 팩토리 함수에서는 리액트 컴포넌트에서 사용할 전역 상태를 초기화하는 함수를 생성한다. 팩토리 함수는 직접 전역 상태를 생성하지 않지만, 생성된 함수를 사용해 리액트가 전역 상태의 생명 주기를 처리하도록 한다.
두 접근 방식의 예외
- 앞에서 설명한 것은 일반적인 사용 사례이며, 여기엔 몇 가지 예외가 있다. 꼭 데이터 중심 접근 방식과 컴포넌트 중심 접근 방식 중 하나만 선택해야 하는 것은 아니다. 실제로는 두 가지 접근 방식 중 하나를 사용하거나 두 가지 접근 방식을 함께 사용할 수 있다.
- 모듈 상태는 대체로 싱글턴 패턴으로 구현되지만 하위 트리에 대해 여러 모듈 상태를 만들 수도 있다. 심지어 모듈 상태의 생명 주기를 제어할 수도 있다.
- 하위 트리에 상태를 제공하기 위해 컴포넌트 상태가 사용되기도 한다. 하지만 공급자 컴포넌트를 트리의 최상위에 두고 트리가 하나만 있으면 사실상 싱글턴 패턴이라고 볼 수 있다.
- 컴포넌트 상태는 대체로 useState 훅으로 구현되지만 변경 가능한 변수나 store가 필요한 경우 useRef 훅으로도 구현이 가능하다. 구현은 useState를 사용하는 것보다 복잡할 수도 있지만 여전히 컴포넌트 생명 주기에 포함된다.
- 이번 절에서는 전역 상태를 사용하는 두 가지 접근 방식에 대해 알아봤다. 모듈 상태는 주로 데이터 중심 접근 방식에서 사용되며, 컴포넌트 상태는 주로 컴포넌트 중심 접근 방식에서 사용된다. 다음으로 리렌더링을 최적화하기 위한 몇 가지 패텬에 대해 알아보겠다.
리렌더링 최적화
- 전역 상태에서 리렌더링을 피하는 것은 정말 중요한 문제다. 이는 리액트를 위한 전역 상태 라이브러리를 설계할 때 고려해야 할 핵심 문제라 할 수 있다.
- 일반적으로 전역 상태는 여러 속성이 있으며, 중첩된 객체일 수 있다. 다음 예제를 보자.
let state = {
a: 1,
b: { c: 2, d: 3 },
e: { f: 4, g: 5 },
};
- 이 state 객체를 가지고 state.b.c와 state.e.g를 사용하는 두 개의 컴포넌트인 ComponentA와 ComponentB가 있다고 가정해 보자. 다음은 두 컴포넌트의 의사 코드다.
const ComponentA = () => {
return <>value: {state.b.c}</>;
};
const ComponentB = () => {
return <>value: {state.e.g}</>;
};
- 이제 다음과 같이 state를 변경한다고 가정해 보자.
++state.a;
- 이것은 state의 속성을 변경하지만 state.b.c또는 state.e.g는 변경하지 않는다. 이 경우 두 컴포넌트를 리렌더링 할 필요가 없다.
- 리렌더링 최적화의 핵심은 컴포넌트에서 state의 어느 부분이 사용될 지 지정하는 것이다. state의 일부분을 지정하는 몇 가지 접근 방식이 있다. 이번 절에서는 다음과 같은 세 가지 접근 방식을 살펴볼 것이다.
- 선택자 함수 사용
- 속성 접근 감지
- 아톰 사용
- 이 세 가지 방식을 하나씩 살펴보자.
선택자 함수 사용
- 첫 번째 접근 방식은 선택자 함수를 사용하는 것이다. 선택자 함수는 상태를 받아 상태의 일부를 반환한다.
- 예를 들어, 선택자 함수를 받아 state의 일부를 반환하는 useSelector 훅이 있다고 가정하자.
const Component = () => {
const value = useSelector((state) => state.b.c);
return <>{value}</>;
};
- state.b.c가 2라면 Component는 2를 표시한다. 이제 이 컴포넌트가 state.b.c에만 관심이 있다는 것을 알고 있으므로 state.a가 변경된 경우에는 리렌더링을 피해야 한다.
- useSelector는 상태가 변경될 때마다 선택자 함수의 결과를 비교하는 데 사용된다. 따라서 선택자 함수는 동일한 입력이 주어졌을 때 state를 참조해서 동일한 결과를 반환하는 것이 중요하다.
- 선택자 함수는 매우 유연해서 상태의 일부뿐만 아니라 파생된 값도 반환할 수 있다. 예를 들어, 다음과 같이 두 배로 곱한 값을 반환할 수 있다.
const Component = () => {
const value = useSelector((state) => state.b.c * 2);
return <>{value}</>
};
♣ 선택자와 메모이제이션에 대한 주요 사항 :
- 선택자 함수가 반환하는 값이 숫자와 같은 원시 값이면 문제가 없다. 하지만 선택자 함수가 파생된 객체 값을 반환하는 경우에는 메모이제이션을 사용해 동일한 객체 값을 반환하도록 해야 한다. 메모이제이션에 대해서는 https://en.wikipedia.org/wiki/Memoization을 참고한다.
- 선택자 함수는 컴포넌트의 어느 부분을 사용할지 명시적으로 지정하는 방법이므로 이를 수동 최적화라고 한다.
속성 접근 감지
- 컴포넌트에서 원하는 상태를 명시적으로 지정할 수 있는 선택자 함수를 사용하지 않고도 렌더링 최적화를 자동으로 수행할 수 있을까? 속성 접근을 감지한 정보를 렌더링 최적화에 사용할 수 있는 상태 사용 추적(state usage tracking)이라는 것이 있다.
- 예를 들어, 상태 사용 추적 기능이 있는 useTrackedState 훅이 있다고 가정해 보자.
const Component = () => {
// useTrackedState 훅은 상태 사용 추적 기능이 있다고 가정
const trackedState = useTrackedState();
return <>{trackedState.b.c}</>;
};
- 이 훅을 통해 trackedState가 .b.c 속성에 접근했음을 감지할 수 있고 .b.c 속성 값이 변경될 때만 useTrackedState가 리렌더링을 발생시킨다. 따라서 useSelector는 수동 렌더링 최적화인 반면 useTrackedState는 자동 렌더링 최적화다.
- 앞의 예제 코드는 간단히 설명하기 위해 인위적으로 만들었다. 위 예제는 수동 렌더링 최적화인 useSelector로도 쉽게 구현할 수 있다. 이번에는 두 가지 값을 사용하는 다른 예제를 살펴보자.
const Component = () => {
const trackedState = useTrackedState();
return (
<>
<p>{trackedState.b.c}</p>
<p>{trackedState.e.g}</p>
</>
);
};
- useSelector 훅으로 위 코드와 같은 동작을 수행하도록 구현하는 것은 쉽지 않다. 위 코드와 같은 동작을 하도록 선택자를 작성하려면 메모이제이션이나 사용자 지정 비교 함수와 같은 복잡한 기법이 필요하다. 하지만 useTrackedState를 사용하면 그런 복잡한 기법 없이도 작동 한다.
- useTrackedState를 구현하려면 상태 객체에 대한 속성 접근을 확인하기 위한 proxy가 필요하다. 이 기능을 제대로 구현하면 useSelector를 사용하는 경우를 대부분 대체할 수 있고 자동으로 렌더링 최적화를 수행할 수 있다. 하지만 자동 렌더링 최적화가 완벽하게 작동하지 않는 경우가 있다. 다음 절에서 살펴보자.
useSelector와 useTrackedState의 차이점
- 경우에 따라 useTrackedState보다 useSelector가 더 적합한 사례가 있다. useSelector는 파생값을 만들 수 있기 때문에 상태를 더 간단한 값으로 만들 수 있다.
- 간단한 예제를 통해 useSelector와 useTrackedStated의 차이를 확인해 보자. 다음은 useSelector를 사용하는 컴포넌트 예제다.
const Component = () => {
const isSmall = useSelector((state) => state.a < 10);
return <>{isSmall ? "small" : "big"}</>;
};
- 이번에는 useTrackedStated를 사용해 같은 동작을 수행하는 다음과 같은 컴포넌트를 만들어 보자.
const Component = () => {
const isSmall = useTrackedState().a < 10;
return <>{isSmall ? "small" : "big"}</>;
};
- 기능 면에서 useTrackedStated를 사용한 컴포넌트는 잘 작동하지만 state.a가 변경될 때마다 리렌더링된다. 반대로 useSelector를 사용하면 isSmall이 변경될 때만 리렌더링되므로 useTrackedStated보다 더 최적화됐다고 볼 수 있다.
아톰 사용
- 아톰 사용(using atom)이라고 부르는 또 다른 접근 방식이 있다. 아톰은 리렌더링을 발생시키는 데 사용되는 최소 상태 단위다. 전체 전역 상태를 구독해서 리렌더링을 피하는 대신 아톰을 사용하면 좀 더 세분화해서 구독하는 것이 가능하다.
- 예를 들어, 아톰만 구독하는 useAton 훅이 있다고 가정해 보자. atom 함수는 state 객체에서 아톰을 생성할 수 있다.
const globalState = {
a: atom(1),
b: atom(2),
c: atom(3),
};
// atom 함수는 state 객체에서 아톰을 생성할 수 있다.
const Component = () => {
// useAtom은 atom만 구독한다.
const value = useAtom(globalState.a);
return <>{value}</>;
};
- 아톰이 완전히 분리돼 있다면 별도의 전역 상태를 갖는 것과 거의 같다고 볼 수 있다. 하지만 아톰으로 파생 값을 만들 수 있다. 예를 들어, globalState의 모든 속성을 더하고 싶다고 가정해 보자. 의사 코드는 다음과 같다.
const sum = globalState.a + globalState.b;
- 이 작업을 수행하기 위해서는 의존성을 추적해서 아톰이 갱신될 때마다 파생값을 다시 평가해야 한다.
- 아톰을 사용하는 접근 방식은 수동 최적화와 자동 최적화의 중간 정도로 볼 수 있다. 아톰과 파생 값의 정의는 명시적이지만 의존성 추적은 자동으로된다.
- 이번 절에서는 리렌더링 최적화를 위한 다양한 패턴에 대해 알아봤다. 전역 상태 라이블러리에서는 리렌더링을 최적화하는 방법을 설계하는 것이 중요하다. 이는 라이브러리 API에 영향을 미치는 경우가 많으며, 리렌더링을 최적화하는 방법을 이해하는 것도 라이브러리 사용자에게 도움이 된다.
정리
- 이번 장에서는 전역 상태 라이브러리의 실제 구현을 살펴보기에 앞서 그와 관련된 몇 가지 기본적인 지식과 각 전역 상태 라이브러리 사이의 차이점이 무엇인지 알아봤다. 또한 전역 상태 라이브러리를 선택할 때 라이브러리가 전역 상태를 읽고 작성하는 방법, 저장하는 위치, 리렌더링을 최적화하는 방법을 알아봤다. 이는 특정 사용 사례에 대해 어떤 라이브러리가 적합한지 이해하는 데 중요하며, 필요에 맞는 라이브러리를 선택하는 데도 도움이 된다.
'FE > 리액트 훅을 활용한 마이크로 상태관리' 카테고리의 다른 글
| 7. 사용 사례 시나리오 1: Zustand (0) | 2025.10.08 |
|---|---|
| 1. 리액트 훅을 이용한 마이크로 상태 관리 - 1 (0) | 2025.09.24 |