다양한 경험을 하고 그 가치를 함께 나누는 것을 좋아합니다. 오픈소스컨설팅 프론트엔드 개발자 노은지입니다.
안녕하세요. Playce Dev team Frontend 개발자 노은지입니다.
지난 글에서 저희는 State의 개념에 대해 이해하고, state를 슬기롭게 관리 하기 위한 방법에 어떤 것들이 있는지, 왜 Client state와 Server state를 나누어 관리해야 하는지 이해해보는 시간을 가졌습니다.
지난 글을 읽지 못하셨다면 여기를 눌러 읽어 보시는 것을 추천드립니다 🙂
또한 server state 를 client state 와 분리하게 되면서, 기존에 사용하던 Redux 보다 가볍고, 쉽게 client state 를 관리할 수 있는 새로운 전역 상태 관리 라이브러리들에 대한 필요성이 생겨났을 것이라 추측하였습니다.
그럼 이번에는 다양한 전역 상태 관리 라이브러리에 대해서 조금 알아보는 시간을 가져볼까요?
피드백은 언제나 환영입니다 🙂
앞서 정리한 내용을 토대로 저는 한 어플리케이션 내의 다수의 컴포넌트에서 쓰이고, 자주 변화하는 여러 개의 프로퍼티를 가진 객체 형태의 state 는 React Hooks 나 Context API 로 관리 하는 것이 한계가 있기 때문에 전역 상태관리 라이브러리를 쓰는 것이 좋겠구나, 라는 것을 깨달았습니다.
하지만 현존하는 매우 다양한 상태관리 도구들을 선택하는 데 있어서 우리는 또 고민에 빠지게 될 수밖에 없습니다. 따라서, 간단한 카운터 앱을 구현한 예제코드를 통해서 Redux 를 포함한 다른 전역 상태 관리 도구들을 서로 비교해 보려고 합니다.
Redux 에서 다른 tool 로의 핵심적인 migration 이유가 더 가볍고, 쉬운 state management 가 가능하기 때문이라고 생각하여서, 다른 기능들 보다 그 부분에 초점을 맞추어 비교했습니다.
Redux는 현존하는 라이브러리들 중 독보적으로 많이 사용 되고 있는 전역 상태관리 라이브러리입니다. npm trends 차트에서 볼 수 있듯이, Redux는 월등히 높은 npm 다운로드 수와 Github star를 보유하고 있습니다.
Redux 공식문서는 Redux를 이용해 일관적으로 동작하고, 서로 다른 환경에서 작동하며, 테스트하기 쉬운 앱을 설계할 수 있다고 말합니다. Flux 패턴의 구현체로도 널리 알려진 Redux는 Model – View – Controller의 양방향 패턴이 가지는 복잡하고 예측이 불가능한 데이터 흐름 때문에 생기는 문제들을 해결
하기 위한 대안으로 떠올랐습니다.
Redux에 대한 보다 더 자세한 내용은 Playce Dev team 이정현 Front-end 개발자님의 “복잡하고 어려운 Redux 적응기”에서 찾아 보실 수 있습니다.
Flux 패턴은 크게 3개 부분인 dispatcher, store, view(React components)로 구성되어있습니다. 모든 데이터는 중앙 허브인 dispatcher를 통해 흐르며 화면을 보는 유저의 상호작용에 의해서 action creator 메서드를 통해 action들이 dispatcher로 전달되고 동작합니다. 어플리케이션의 데이터와 비즈니스로직을 가지고 있는 store는 action이 전파되면 이 action에 영향을 받는 모든 view를 갱신하게 됩니다.
어플리케이션의 state는 store에 의해서 관리되고 이것은 어플리케이션의 다른 부분과 완전히 분리된 상태로 유지하여 state로 하여금 어플리케이션의 다른 부분들과의 의존도를 낮추는 것을 가능하게 합니다.
// configStore.js
import { createStore } from "redux";
import { combineReducers } from "redux";
import counter from "../modules/counter";
const rootReducer = combineReducers({
counter: counter,
});
const store = createStore(rootReducer);
export default store;
rootReducer와 store를 생성해 줍니다. 이곳에서 모든 리듀서들을 한 번에 관리할 수 있습니다.
// index.js에서 Provider로 App을 감싸주고 import한 store를 넣어 줍니다.
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import reportWebVitals from "./reportWebVitals";
import store from "./redux/config/configStore";
import { Provider } from "react-redux";
const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(
<Provider store={store}>
<App />
</Provider>
);
// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();
Provider로 감싸 store를 최상단 컴포넌트에 보내줍니다. App 컴포넌트는 이제 store를 구독하게 되었습니다.
// module.js
// 초기 상태값
const initialState = {
number: 0,
};
// 액션밸류
const PLUS_ONE = "PLUS_ONE";
const MINUS_ONE = "MINUS_ONE";
// 액션크리에이터
export const plusOne = () => {
return {
type: PLUS_ONE,
};
};
export const minusOne = () => {
return {
type: MINUS_ONE,
};
};
// 리듀서
const counter = (state = initialState, action) => {
switch (action.type) {
case PLUS_ONE:
return {
number: state.number + 1,
};
case MINUS_ONE:
return {
number: state.number - 1,
};
default:
return state;
}
};
export default counter;
모듈에 initial state, action value, action creator, reducer를 정의합니다.
import React from "react";
import { useDispatch, useSelector } from "react-redux";
import { plusOne, minusOne } from "./redux/modules/counter";
const App = () => {
const dispatch = useDispatch();
const number = 0;
const globalNumber = useSelector((state) => state.counter.number);
const onClickAddNumberHandler = () => {
dispatch(plusOne(number));
};
const onClickMinusNumberHandler = () => {
dispatch(minusOne(number));
};
return (
<div>
<div>{globalNumber}</div>
<button onClick={onClickAddNumberHandler}>+1</button>
<button onClick={onClickMinusNumberHandler}>-1</button>
</div>
);
};
export default App;
단순히 +1, -1을 하는 기능이 있는 어플리케이션의 모듈을 생성하는 과정에서 우리는 action value, action creator, reducer를 각각 정의해주어야 합니다.
이러한 불편으로 인해 Redux팀은 Redux-toolkit(RTK)을 공식적으로 사용하기를 권장하고 있습니다. RTK는 createSlice에 각 Action에 따른 reducer를 작성할 수 있도록 하여 보일러 플레이트를 줄였으며, 기존 Redux는 서버와의 비동기 통신 등 유용하게 활용하기 위해서 많은 라이브러리를 설치해야 했던 반면, RTK는 ‘toolkit’이란 이름답게 더 많은 기능을 제공하고 있습니다.
이 글에서는 RTK에 대한 내용은 자세히 다루지 않고 있지만, 공식 문서를 통해 자세한 내용을 확인할 수 있습니다.
순수 함수란 동일한 인자가 전달되면 항상 동일한 결과를 반환하는 함수(코드블록)입니다. 그리고 리듀서는 순수한 함수로 만들어졌기 때문에 그 결과를 쉽게 예측할 수 있습니다.
Zustand는 작고 빠르고 확장성이 좋은 상태 관리 툴이며 hooks를 기반으로 사용이 편리한 API를 제공합니다. 보일러 플레이트가 없고, 유연하지만 명확성을 가질 만큼 충분한 convention 또한 보유하고 있는 Flux-like 상태관리 툴입니다.
Zustand의 창시자 Daishi Kato는 Zustand가 React에서의 Zombie child 문제, React concurrency, renderers 사이에서 일어나는 context loss와 같은 문제들을 해결할 수 있는 상태관리 매니저가 될 수 있다고 말합니다.
예를 들자면 이런 것입니다. First pass에서 여러 nested connected components들이 mount 되면서, child compent가 parent component 보다 먼저 store를 구독하는 경우가 발생합니다.
이때, todo item과 같은 data를 store에서 지우는 action을 dispatch 하게 되면 결과적으로 parent 컴포넌트는 해당하는 child compent의 렌더링을 멈추어야 합니다.
하지만 child compent가 parent compent 보다 먼저 store를 구독하고 있는 상태이기 때문에 extraction logic에 유의하지 않을 경우 action에 의해 지워진 데이터는 더 이상 존재하지 않음에도 불구하고 child compent가 렌더링이 되면서 에러를 초래할 수 있습니다.
concurrent란 동시성을 뜻합니다. Concurrency issue란 Zombie child 문제와 비슷하게, 2개 이상의 컴포넌트들이 같은 가변성 데이터 소스로부터 데이터를 받아올 때, 오직 일부 components만이 같은 결과를 갖는 일이 발생할 수 있습니다.
한가지 예로, 가변적 데이터를 제공하는 소스로부터 data를 요청하는 경우, 밀리초의 간격을 두고 서로 다른 컴포넌트들이 같은 가변적 data를 제공 받더라도 동시에 서로 조금 다른 결과 값을 보여주게 될지도 모르죠. 따라서 동시성 문제를 야기시킵니다. Zustand는 state의 불변성을 지킴으로써 이 문제를 해결합니다.
Context loss는 useContext를 사용하는 component가 unmount 되면서 state를 잃었을 때 발생하는 흔한 문제입니다.
이것은 page간 이동이나 DOM에서 component가 사라지면서 발생하는데 이 때문에 component는 data를 refetch 하거나 state를 reset 해야 하는 상황이 연출됩니다.
Zustand는 각각의 component 밖에서 전역으로 state를 저장하는 방식을 사용해 언제든지 component들이 state에 접근할 수 있게 하여 이 문제를 해결합니다.
이번엔 카운터 앱을 Zustand를 이용해 구현하였습니다.
// useStore.js
import { create } from "zustand";
const useStore = create((set) => ({
number: 0,
plusOne: () => set((state) => ({ number: state.number + 1 })),
minusOne: () => set((state) => ({ number: state.number - 1 })),
}));
export default useStore;
useStore라는 hook을 store로 만듭니다. number의 초기 값은 0이며, plusOne은 number에 1을 더한 값, minusOne은 1을 뺀 값입니다. store에는 원시 값, 객체, 함수 등 모든 것을 저장할 수 있습니다. set 함수로 state를 병합해 줍니다.
// App.js
import "./App.css";
import useStore from "./useStore";
function App() {
const number = useStore((state) => state.number);
const addNumber = useStore((state) => state.plusOne);
const minusNumber = useStore((state) => state.minusOne);
return (
<div>
<div>{number}</div>
<button onClick={addNumber}>+ 1</button>
<button onClick={minusNumber}>- 1</button>
</div>
);
}
export default App;
그리고 컴포넌트에 바인딩하면 끝입니다! 보일러 플레이트 없이 가볍고, provider로 store를 제공해 줄 필요 없이 hook을 어디서나 사용하면 됩니다. 필요한 state와 필요한 component를 선택해 사용하고 state가 바뀌면 리렌더링 될 겁니다.
그럼 Redux와 비교해 보았을 때, Zustand는 어떤 장점과 단점이 있을까요?
많은 상태관리 라이브러리들은 state 변경을 불가하게 하거나, 수정하지 못하도록 제한하는 방법을 시도했습니다. 하지만 이런 방법은 새로운 문제를 유발합니다.
데이터는 정규화되어야 하고 참조 무결성 또한 보장될 수 없고, 데이터가 필요한 경우 클래스와 같은 효과적인 개념을 사용하는 것이 불가능합니다.(Redux의 경우 state의 불변성을 지키기 위해 이전 상태의 객체의 값을 변경시키지 않고 새로운 객체를 반환합니다.)
MobX는 쉬운 state 관리를 위해서 일관되지 않는 state가 애초에 생성되지 않도록 합니다. 애플리케이션 내의 state에서 파생 가능한 모든 것이 자동적으로 파생되도록 합니다. 따라서 불변성에 신경 쓰지 않아도 알아서 불변성을 유지시켜 줍니다.
개념적으로 MobX는 애플리케이션을 스프레드시트처럼 다룹니다.
아래 예시 코드는 리덕스와 마찬가지로 버튼을 통해 +1, -1을 할 수 있는 간단한 기능을 가진 어플리케이션을 구현한 코드입니다.
// counterStore.js
import { observable } from "mobx";
export const counterStore = observable({
num: 0,
addNumber() {
this.num++;
},
minusNumber() {
this.num = this.num - 1;
},
reset() {
this.num = 0;
},
});
export default counterStore;
Observable은 Observable state를 만들어 우리가 관찰하려는 state를 주시하며 state에 변화가 있을 때 MobX에 알려줍니다.
// useStore.js
import { counterStore } from "./counterStore";
const useStore = () => {
return { counterStore };
//여러개일 경우, { counterStore, others ... }
};
export default useStore;
useStore는 counterStore를 return 하는 hook입니다.
// App.jsx
import "./App.css";
import useStore from "./useStore";
import { useObserver } from "mobx-react";
function App() {
const { counterStore } = useStore();
const addNumber = () => {
counterStore.addNumber();
};
const minusNumber = () => {
counterStore.minusNumber();
};
return useObserver(() => (
<div>
<div>{counterStore.num}</div>
<button onClick={addNumber}>+ 1</button>
<button onClick={minusNumber}>- 1</button>
</div>
));
}
export default App;
App.js에서 state를 관찰하기 위해 useObserver를 사용해 return 해 줍니다.
Recoil은 Facebook에서 출시한 React 스러운 상태관리 라이브러리입니다. 2020년 5월에 출시 되었지만 현재 많은 관심을 받고 있습니다. 그럼 바로 Recoil이 가진 핵심 개념을 살펴보겠습니다.
Recoil을 사용하면 atoms(공유된 state)에서 selectors(순수 함수들)을 거치는 데이터 흐름을 만들 수 있습니다. Atom은 컴포넌트가 구독할 수 있는 단위입니다. Selectors는 이 상태를 동기, 비동기 적으로 변환해 줍니다.
Atoms
state의 단위로, 업데이트와 구독이 가능합니다. Atom이 업데이트되면 구독하고 있던 컴포넌트들은 새로운 값을 반영하며 리렌더링 됩니다. 하나의 Atom은 전역적으로 고유한 key 값을 가지기 때문에 같은 키를 공유하는 Atoms는 존재할 수 없습니다. React 컴포넌트의 state처럼 default 값도 가집니다. 컴포넌트에서 Atom을 읽고 쓰기 위해 useRecoilState hook을 사용합니다. 이를 통해 state가 컴포넌트 간에 공유될 수 있습니다.
Selector는 Atoms나 다른 Selectors를 입력으로 받아들이는 순수한 함수입니다. 상위 Atoms, Selectors가 업데이트되었을 때 하위 selector 함수는 재평가 됩니다. Selectors는 state를 기반으로 하는 파생된 데이터들을 계산하는데 쓰입니다. selectors는 useRecoilValue()를 사용해 읽을 수 있으며 하나의 atom이나 selector를 인자로 받아 그에 맞는 값을 반환해줍니다.
같은 카운터 어플리케이션을 Recoil을 이용해 구현해 보겠습니다.
// index.js
import React from "react";
import ReactDOM from "react-dom/client";
import "./index.css";
import App from "./App";
import reportWebVitals from "./reportWebVitals";
import { RecoilRoot } from "recoil";
const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(
<RecoilRoot>
<App />
</RecoilRoot>
);
// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();
최상단의 index.js에서 RecoilRoot로 공유하고 싶은 컴포넌트를 감싸줍니다.
// atom.js
import { atom } from "recoil";
export const countState = atom({
key: "countState",
default: 0,
});
countState라는 atom을 정의합니다. 고유한 key는 countState, 기본 값은 0으로 설정하겠습니다.
// App.js
import "./App.css";
import { useRecoilState } from "recoil";
import { countState } from "./atom";
function App() {
const [number, setNumber] = useRecoilState(countState);
const addNumber = () => {
setNumber((prev) => prev + 1);
};
const minusNumber = () => {
setNumber((prev) => prev - 1);
};
return (
<div>
<div>{number}</div>
<button onClick={addNumber}>+ 1</button>
<button onClick={minusNumber}>- 1</button>
</div>
);
}
export default App;
useRecoilState()를 이용해 countStatse를 불러옵니다. 기본적으로 useState와 생김새가 흡사합니다. 왜 Recoil을 ‘React스러운’이라고 표현하였는지 알 것 같습니다. useState를 사용하던 문법과 이질감 없이 자연스러운 상태 관리가 가능합니다.
저는 간단한 예제를 통해 상태관리 라이브러리들을 비교하고 있기 때문에, Recoil이 가지고 있는 더 많은 기능들을 살펴보시려면 공식 문서를 통해 찾아볼 수 있습니다.
그럼 Redux와 비교하여 장점과 단점을 비교해 볼까요?
간단한 예시코드를 통해 비교했음에도 불구하고 Redux를 제외한 다른 라이브러리들이 보였던 공통적인 특징은 코드의 작성이 쉽고, 간단하다는 점이었습니다.
아직까지 대부분의 페이스북 같은 큰 프로젝트들은 Redux를 이용한 상태 관리를 하고 있기 때문에 이 솔루션들이 얼마나 확장성이 좋은지는 확인할 수 없는 것이 사실입니다.
하지만 오픈 소스 커뮤니티의 성장과 함께 한다면 프로젝트 규모도 자연스럽게 커질 수 있지 않을까 조심스럽게 생각해 보았습니다.
Migration이란 복잡하고도 힘든 여정을 지나온 많은 국내외 개발자들의 사례들을 많이 보면서, 계속해서 바뀌는 트렌드를 따라가야 하는 Frontend 개발이지만 그들이 그것을 결정하고 추진하기까지 수많은 고민을 했을 것이라는 것을 체감할 수 있었습니다.
많은 포스트들은 불편함의 원인을 찾아 그것을 팀에게 공유하고, 모두가 납득할 만한 결론을 이끌어 냈고, 프로젝트를 마치고 회고하기까지의 과정을 설명하고 있었습니다. 진행한 방식이나 모양은 모두 조금씩 달랐을지 몰라도 모두가 결국 ‘왜 이 migration을 진행하게 되었는가?’라는 질문에 대답하고 있었습니다.
나 자신이 ‘왜?’라고 자주 생각하는 사람인가?를 생각해 보았을 때, 호기심은 많지만 때로는 사람들이 그렇다면 그런 거겠지, 하고 깊은 고민 없이 받아들인 적도 많았던 것 같습니다. 항상 공부 하면서 드는 생각이지만, 오늘도 누군가 나의 의견에 ‘왜’냐고 묻는다면 누구나 그 뜻을 이해할 수 있도록 항상 나의 의도를 분명히 전달해야겠다고 생각했습니다.
긴 글을 읽어주셔서 감사합니다. 다음 글에서 뵙겠습니다 🙂
React Docs : State: A Component’s Memory
freeCodeCamp: How to manage state in your react apps
Geeksforgeeks: what is prop drilling and how to avoid it?
Jannik Wempe: When to not use react context api for state
GitNation: React Query: It’s time to break up with your “Global State”!
Nathan Sebhastian: How and Why you should use React Query
Redux: Flux
MobX: 10분만에 알아보는 MobX와 React
Recoil Docs: Recoil official
Reaktor: is Recoil what will kill Redux?
Fernando Doglio: Let’s talk about Zustand (A converstation with Daishi Kato)
다양한 경험을 하고 그 가치를 함께 나누는 것을 좋아합니다. 오픈소스컨설팅 프론트엔드 개발자 노은지입니다.