들어가며
React는 컴포넌트 기반의 라이브러리여서, 작은 컴포넌트들이 모여서 복잡한 구조를 구성하게 됩니다.
앱의 규모가 커질수록 상태관리가 점점 복잡해져서 컴포넌트 간의 상태를 전달할 때 Props Drilling 이라는 문제가 발생합니다.
이러한 상태 관리의 복잡함을 해결하기 위해 상태관리 라이브러리를 사용할 수 있습니다.
상태 관리 라이브러리는 크게 3가지로 나눌 수 있습니다.
Reducer-based
신뢰 가능한 단일 출처 (single source of truth) 라고 불리며, Action
을 Dispatcher를 통해 발생시킴으로 중앙 집중식으로 상태를 관리합니다.
해당 그룹에는 Redux, Zustand 등이 있습니다.
Atom-based
상태를 Atom
단위로 관리하며, React Hooks를 사용하여 상태를 읽고 쓰는 방식으로 사용합니다.
해당 그룹에는 Redoil, Jotai 등이 있습니다.
Mutable-based
Proxy를 활용하여 직접적으로 읽거나 쓸 수 있는 데이터 소스를 생성하는 방식으로 사용합니다.
해당 그룹에는 MobX, Valtio 등이 있습니다.
아래 글에서 해당 기반의 라이브러리는 다루지 않습니다.
이번 글에선 상태관리 라이브러리별 특징과 장단점을 비교해보도록 하겠습니다.
📢 내용에 들어가기 전에
전역 상태 관리는 쉽게 남용될 수 있습니다. props를 몇 단계 깊이 전달해야 한다고 해서 해당 정보를 전역 상태에 넣어야 한다는 의미는 아닙니다.
데이터를 다른 컴포넌트로 전달하는 가장 기본적인 방법은 props를 사용하는 것입니다. 상위 컴포넌트에서 하위 컴포넌트로 전달하면, 데이터의 흐름이 명확하게 드러나서 코드 유지보수에 더 유리합니다.
Context API
React에 내장되어 있는 라이브러리로 추가로 설치할 필요가 없습니다.
또한 컴포넌트에 의존성을 주입하는 방법중 가장 효과적인 방법입니다.
하지만 ContextAPI는 아래와 같은 주의 사항이 존재합니다.
- Provider의 컴포넌트 트리의 상위에서 상태 변경이 일어나면 하위에 Context를 구독하고 있는 모든 컴포넌트가 리렌더링 됩니다.
- 특정 Context.Provider에 의존하기 때문에 컴포넌트 간 결합도가 증가하여 재사용이 어려워 집니다.
아래는 간단한 예시입니다.
- 먼저 Context를 생성합니다.
- 이후 사용할 하위 컴포넌트를 Provider로 감싸줍니다.
그러면 하위 컴포넌트에서 useSettingContext
를 통해 상태를 가져올 수 있습니다.
React 공식 문서에서 언제 context를 써야 할까 를 참고하면 전역적 ( global )이라고 볼 수 있는 데이터를 공유하는 방법으로 사용하도록 고안된 방법이라 명시되어 있습니다.
context는 React 컴포넌트 트리 안에서 전역적(global)이라고 볼 수 있는 데이터를 공유할 수 있도록 고안된 방법입니다. 그러한 데이터로는 현재 로그인한 유저, 테마, 선호하는 언어 등이 있습니다.
Context는 전역적으로 데이터를 공유하는 API 입니다.
반복적이고 복잡한 업데이트에 사용할 경우 불필요한 리렌더링이 일어날 수 있다는 것을 인지해야 합니다.
때문에 아래의 경우 사용할 것을 권장합니다.
- Component가 ContextAPI 에서 관리하는 상태에 의존성을 주입해야 할 경우
- Provider 내부에만 존재할 수 있으므로, 전역 상태가 잘못 사용되는 것을 방지할 수 있습니다.
- 낮은 빈도로 업데이트가 일어나는 데이터를 공유할 때
Recoil
Recoil은 React를 구현한 페이스북에서 직접 구현한 React 만을 위한 상태관리 라이브러리로 가장 큰 장점은 러닝커브가 낮습니다. API가 단순하고 hook 과 비슷한 사용경험을 제공합니다.
또한 React v18 부터 도입된 Concurrent Mode ( 동시성 모드 )와 개발 방향성이 같습니다. Recoil 에서 Transition을 지원하는 기능을 개발하여 업데이트가 무거운 컴포넌트의 경우 상태 업데이트 중 상위 Suspense를 호출하는 기능 등을 개발중에 있습니다.
Concurrent Mode 는 이전에 작성하였던 글을 참고해주세요
하지만 2024년 현재, Recoil의 포지션은 그다지 좋은 상태라고 볼 수 없습니다.
Recoil은 아직 정식 릴리즈가 되지 않았고, 마지막 업데이트는 2023년 4월 12일로 최근 1년간 업데이트가 되지 않았습니다.
아래부턴 코드 예시입니다.
Recoil을 사용하기 위해선 앱을 RecoilRoot로 감싸고, 데이터를 atom 단위로 선언하여 사용하면 됩니다.
Atoms
Atoms( 공유 상태 )은 상태의 단위 입니다. Atom을 업데이트하거나 구독할 수 있고, atom이 업데이트 되면 각각 구독된 컴포넌트는 새로운 값을 반영하여 다시 렌더링 됩니다.
atom을 생성하기 위해선 고유한 키 값과 디폴트 값을 설정해야 합니다.
이 후 컴포넌트에서 atom을 읽고 쓰려면 useRecoilState 훅을 사용하면 됩니다.
useRecoilState 외에 atom 값만 사용하기 위해선 useRecoilValue, setter만 사용하려면 useSetRecoilState Hook을 사용하면 됩니다.
atom with TypeScript
Recoil 은 타입스크립트를 지원합니다. 아래는 타입 스크립트를 사용해서 atom을 정의한 예시 입니다.
selector
selector는 상태에서 파생된 데이터를 정의하는데 사용합니다. selector 를 사용하면 하나 이상의 'atom' 이나 selector를 기반으로 계산되는 상태를 만들 수 있습니다. selector는 구현한 함수에 따라 반환되는 객체가 다른데, get 함수만 제공되면 RecoilValueReadOnly, set 함수 또한 제공되면 RecoilState를 반환합니다.
읽기 전용 Selector
읽기만 가능한 selector는 의존하는 상태가 변경될 때만 재계산하여 리렌더링을 수행합니다.
쓰기 가능한 Selector
입력 값을 받아서 다른 Recoil State에 변경 사항을 전파하는 데 사용할 수 있습니다.
섭씨/화씨를 표시하고 사용자가 변경할 수 있는 컴포넌트 입니다.
위 코드에서 setTempF 를 호출할 경우 tempCelcius Selector 의 get 에서 tempFahrenheit이 변경된 것을 탐지하여 리렌더링하여 tempF, tempC 모두 리렌더링 시킬 수 있습니다.
또한 setTempC를 호출할 경우에도 tempFahrenheit에 변경 사항을 전파하여서 상태를 업데이트 시키고, tempFahrenheit이 변경됨에 따라 tempCelcius Selector의 get을 수행시켜서 tempF, tempC 모두 리렌더링 시킬 수 있습니다.
비동기 Selector를 사용하여 비동기 작업 및 Suspense를 사용하여 데이터를 로딩하는 것도 가능합니다.
하지만 Store
안에서 API Response와 전역 상태 관리 라이브러리를 같이 사용하는 경우는 드믈어서 설명을 생략하겠습니다.
비동기 처리는 다양한 상태와 캐싱 및 에러 핸들링을 제공하는 React-Query
와 같은 라이브러리를 사용하는 것이 더 좋은 선택일 수 있습니다.
Redux
React가 출시한 당시엔 전역 상태를 관리하기 위한 라이브러리가 존재하지 않았습니다.
Redux는 그 당시 가장 처음으로 나온 상태 관리 라이브러리로, 복잡하다는 비판이 존재하지만 지금까지도 가장 높은 인기를 유지하고 있습니다.
우선 Redux는 리액트용이 아닌 JavaScript 상태 관리 라이브러리 입니다.
Redux 로 상태를 안정적으로 유지하기 위해선 Flux 패턴에 맞게 많은 반복적인 코드 구현이 필요한데 이를 Redux Boilerplate ( 리덕스 보일러플레이트 ) 라고 부릅니다.
Boilerplate 주요 요소
Action
- 상태를 변화시키기 위해 발생시키는 이벤트로 type 필드를 반드시 가져야 합니다. type 에 따라서 어떤 이벤트를 발생시킬지 결정합니다.
Reducer
- 상태가 변화하는 로직을 담당하는 함수입니다.
Dispatcher
- 액션을 발생시키는 역할을 합니다. 액션을 생성하고, 생성된 액션을 Store로 보내 상태 변화를 요청합니다.
Store
- 애플리케이션의 상태를 담고있는 객체입니다.
위 Boilerplate를 모두 구현하면 React에서 상태관리를 사용할 수 있습니다. 아래는 간단한 예시 입니다.
우선 액션을 정의합니다.
리듀서를 작성하여 액션에 따른 상태 변화를 정의합니다.
리듀서를 합치고 스토어를 생성합니다.
프로바이더로 감싸서 스토어를 명시해줍니다.
위 과정을 전부 거치면 드디어 Redux로 상태관리를 할 수 있습니다.
useSelector를 사용하여 상태값을 가져오고, useDispatch 를 이용해서 액션을 수행시킬 수 있습니다.
React-Redux v6의 Context API 도입과 성능 최적화
Redux는 React 16.3에서 새로 도입된 createContext API를 도입하였습니다.
Redux Store State를 Context API를 통해 전파하였으나, 이는 이전 v5 대비 성능 저하를 일으켰습니다.
때문에 Redux v7 부터는 Store 내부적으로 상태를 관리할 시에만 Context API를 사용하는 방식으로 변경되었고, Store와 Component간 데이터 접근 시 React.memo를 사용하여 성능을 최적화 하였습니다.
Redux의 단점
- Redux는 위처럼 간단 상태 변경을 위해 많은 코드를 작성해야 합니다.
- 위는 핵심 내용만 적었을 뿐, Redux를 마스터하기 위해선 Saga, Thunk, Reselect, Immer, Redux Toolkit 등 다른 라이브러리와 함께 사용하는 방법을 익혀야 합니다.
- TypeScript를 지원하지만, Action, Reducer, Selector에 대해 명시적으로 타입 지정이 필요합니다.
결론적으로 Learning Curve 가 상당히 높고 비용이 많이 들어서 Redux를 사용하기 위해 많은 시간을 투자해야 합니다.
Redux의 장점
하지만 그럼에도 가장 인기있는 이유는
- 모든 앱의 상태가 중앙 집중식으로 관리되어, 상태의 변화를 예측 가능하게 만들어줍니다. 단일 상태 값은 하나의 UI만 생성하므로 특정 상태에서 일관되게 작동합니다.
- 상태를 백업해두었다면,
REVERT(pastState) { state = pastState }
와 같은 방식으로 상태를 되돌릴 수 있습니다. - 가장 강력한 DevTools를 제공합니다. 상태의 변화를 마치 Git commit log 처럼 확인할 수 있습니다.
Zustand
상태 관리 라이브러리의 추세는 계속 변화하고 있습니다. 이전까진 Redux가 대세였으나, 최근 동일한 Reducer-based
구조인 Zustand의 점유율이 높아지고 있습니다.
Zustand
를 사용하기 위해 필요한 주요 코드는 위가 전부입니다.
개발자는 상태를 정의하고, 상태를 변경하는 함수를 정의하고, useStore
를 통해 상태를 가져오기만 하면 됩니다.
Zustand는 Redux의 복잡한 구조를 간소화하고, Context API의 성능 문제를 해결하였습니다.
- 중앙 집중식으로 상태를 관리합니다.
- Redux와 달리 Boilerplate가 적어 러닝 커브가 낮고, 코드가 간결하여 쉽게 이해할 수 있습니다.
- Redux DevTools를 지원합니다.
- 번들 사이즈가 3.1 kb입니다. 이는 Recoil ( 79.4 kb ), react-redux ( 11.2 kb ) 보다 작습니다.
- 컴포넌트 외부에서도 상태에 접근할 수 있습니다.
- store의
state
가 변경될 때 리렌더링됩니다. 이는 Context API와 달리 불필요한 리렌더링이 발생하지 않습니다.
Zustand 레시피
아래는 Zustand를 사용하기 전에 알아두면 좋은 팁들입니다.
불필요한 리렌더링 방지하기
Store를 구독하는 방식에 따라 불필요한 리렌더링을 방지할 수 있습니다.
상태 덮어쓰기
set에는 기본적으로 false
인 두번째 인자가 있습니다. true
로 변경할 경우 기존 값에 merge하지 않고, 새로운 모델로 덮어쓰게 됩니다.
대신, action처럼 의존하는 부분을 지우지 않도록 주의해야 합니다.
Redux Devtools + Persist 묶어서 사용하기
Redux Devtools나, Persist 등의 미들웨어를 묶어서 사용할 수 있습니다.
필자의 선택
저는 각 상태 관리 라이브러리마다 장/단점이 존재한다고 생각합니다. Best Practice는 없으며, 프로젝트의 규모와 팀의 구성원에 따라 선택해야 합니다.
하지만 제가 프로젝트를 담당하게 된다면 Zustand + React Query 조합을 사용할 것입니다.
저는 DX도 프로젝트 진행에 있어서 중요하게 생각합니다. Zustand는 Redux에 비해 러닝 커브가 낮고, 코드가 간결하여 쉽게 이해할 수 있습니다.
Zustand를 사용하기 전엔 Recoil을 선호하였으나, Zustand로 변경하게된 이유는 아래와 같습니다.
- 더이상 Key를 정의할 필요가 없습니다. 이름을 정하는 것은 머리아픈 일입니다. 심지어 Recoil의 키는 개발자가 꺼내쓸 일이 없습니다. Recoil 내부에서 Key를 랜덤으로 생성해주면 되는 일인데 왜 안해주는 걸까요? Recoil 팀의 답변이 존재하나 제 의견은 글쎼? 입니다.
- Next.js와 사용할 경우 Hydration 과정으로 인해 "중복 Key 발생" Warning이 발생합니다. 중요 3rd-party 라이브러리와 호환성을 맞춰주지 않는 것은 큰 단점입니다.
- Recoil의 JS 번들 사이즈는 지나치게 큽니다.