(학습 중의 겸손한 글임을 밝힙니다.)
상태관리의 필요성(모델(스토어)의 분리)에 대한 고민은 이 글에 적었다.
프로젝트 설계, 기획 단계에서 리액트 상태관리 라이브러리를 고민하고 있다. 이유 있는 결정을 위해 장단점을 정리한다.
후보군은 Context, Recoil, Redux 이다. 물론, 이들만이 아니라, mobx, jotai, xState 등 다양한 라이브러리가 있고 커스텀 스토어를 직접 구현할 수도 있다. 각 방법만의 장단점이 존재한다. 라이브러리를 사용하는 이유는 팀원들과 라이브러리가 추구하는 방향과 얻을 수 있는 이점을 이해함으로서 생산성을 높이기 위함이여야한다.
리액트에서 기본으로 제공하는 api는 props drilling을 해결하기 위한 가장 간단한 방법이다. provider로 app을 감싸고 consumer를 통해 하위 컴포넌트에 상태를 전달할 수 있다.
간단한만큼 단점이 존재한다. 바로 불필요한 랜더링이다. context는 상태값이 달라지면 구독하고 있는 하위 컴포넌트가 모두 다시 랜더링이 된다. object는 식별자가 달라지기만 해도 렌더링이 일어나기 때문에 더욱 관리가 어렵다. 이를 해결하기 위해서는 다음 예시처럼 Context를 분리해서 사용하는 것인데 스토어의 크기가 커진다면 매우 불편해질 것이다.
// app.js
const ThemeContext = React.createContext('light');
const UserContext = React.createContext({
name: 'Guest',
});
class App extends React.Component {
render() {
const {signedInUser, theme} = this.props;
return (
<ThemeContext.Provider value={theme}>
<UserContext.Provider value={signedInUser}>
<Content />
</UserContext.Provider>
</ThemeContext.Provider>
);
}
}
// component.js
function Content() {
return (
<ThemeContext.Consumer>
{theme => (
<UserContext.Consumer>
{user => (
<ProfilePage user={user} theme={theme} />
)}
</UserContext.Consumer>
)}
</ThemeContext.Consumer>
);
}
Redux
Redux는 현재 가장 많이 사용되고 있는 상태관리 라이브러리이다. 왜 이렇게 인기가 많은 것일까? 내가 생각하는 장점은 다음과 같다.
리덕스는 action과 reducer를 통해 상태관리를 컴포넌트와 완전히 분리시킨다. flux 패턴으로 데이터변경은 한 방향에서만 일어난다. 컴포넌트 입장에서는 action을 보내 reducer에 데이터 변경을 메시지로 요청하는 꼴이고, 이는 매우 객체지향적이고, 직관적으로 느껴진다.
redux의 큰 특징중 하나는 reducer 순수함수라는 점이다. 이는 함수형 프로그래밍의 장점을 상속 받아 테스트가 가능하게 하고, 함수의 동작을 예측하기가 쉽다.
Redux는 사용자 풀이 많다는 것은 큰 장점이다. 그 만큼 다양한 케이스에 대한 예시가 있으며, 사용자들이 겪었던 문제점들을 해결하기 위해, 또는 더 나은 생산성을 위한 추가적인 api들이 개발 되었다.
Redux에의 단점으로 가장 많이 언급된 것은 보일러플레이트 코드가 너무 많다는 것이다. 하나의 상태를 위해 액션 타입, 액션 생성함수, 리듀서를 선언하는 것은 매우 피곤한 일이다. 이를 해결하기 위해 Redux-toolkit이 공식적으로 나왔다. 아래 예시 코드를 보면 한번에 초기값, 타입, 액션, 리듀서를 하나의 함수를 선언할 수 있는 것을 볼 수 있다.
import { createSlice } from '@reduxjs/toolkit'
const initialState = {
value: 0,
}
export const counterSlice = createSlice({
name: 'counter',
initialState,
reducers: {
increment: (state) => {
// Redux Toolkit allows us to write "mutating" logic in reducers. It
// doesn't actually mutate the state because it uses the Immer library,
// which detects changes to a "draft state" and produces a brand new
// immutable state based off those changes
state.value += 1
},
decrement: (state) => {
state.value -= 1
},
incrementByAmount: (state, action) => {
state.value += action.payload
},
},
})
// Action creators are generated for each case reducer function
export const { increment, decrement, incrementByAmount } = counterSlice.actions
export default counterSlice.reducer
Redux에는 object 상태에 대한 성능 이슈가 있었다. 이에 대한 최적화 방법으로 useSelector 훅을 사용할 수 있다. useSelector로 필요한 상태를 구분해 구독하거나, equalityFn, shallowEqual을 사용해 렌더링 여부를 결정할 수 있다. 이 글을 참고하면 좋을 것 같다.
Redux 유저들은 컴포넌트의 책임을 위해 api같은 비동기 로직을 관리하기 위한 방법을 고민해왔다. Redux thunk, Redux Saga를 통해 미들웨어 방식으로 비동기 로직을 컴포넌트로부터 분리할 수 있다. 미들웨어를 사용하면 api응답에 따른 분기처리를 손쉽게 할 수 있다는 큰 장점이 있다. (예를 들어, 특정 조건 만족 시 이전 요청 취소, 연달은 요청 무시, 특정 액션 발생시 원하는 콜백 실행). 훅이 나온 뒤로는 커스텀훅을 사용해서 api 요청을 관리할 수 있는 방식이 사용되기 시작했고, 나아가, 훅을 사용해 api요청을 관리해주고, 캐쉬까지 해주는 React Query, SWR(https://swr.vercel.app/ko) 같은 라이브러리도 나왔다.
여러 단점을 보완화고, 추상화 되어가는 Redux이기는하나, 개념을 이해하기 위해서는 시간이 필요하다. 또한 이런 추가적인 라이브러리까지 사용법을 익혀야하니 러닝 커브는 분명히 존재한다. 성능은 떨어지지만 Context api와 useReducer만을 사용하는 간단한 패턴도 제시되고 있으니, 이런 식으로 접근하다보면 차차 Redux의 강력함을 느껴볼 수 있지 않을까.
Recoil
Recoil은 페이스북에서 만든 상태관리 라이브러리로, 아직 안정화 단계에 있는 단계는 아니지만 사용을 고려할만한 다음과 같은 장점을 가지고 있다.
Recoil은 상태 하나를 atom으로 정의하고, 그것을 구독하는 패턴이다. 또한 이런 atom들로부터 파생된 상태를 selector로 선언한다. 이 selector들은 구독한 atom이 변화하면 자동적으로 변화한다. 데이터 흐름이 직관적이다. 페이스북에서 만든 만큼 리액트와도 매우 잘어울려서 훅을 사용해보았다면 쉽게 배울 수 있다.
//atom
const fontSizeState = atom({
key: 'fontSizeState',
default: 14,
});
//selector
const fontSizeLabelState = selector({
key: 'fontSizeLabelState',
get: ({get}) => {
const fontSize = get(fontSizeState);
const unit = 'px';
return `${fontSize}${unit}`;
},
});
Context에서는 상태변경시 구독한 하위 컴포넌트들이 모두 재렌더링 되는 문제가 있었는데, Recoil은 atom, selector를 구독하면 구독한 컴포넌트만 재렌더링이 일어난다. Redux useSelector로 일일히 최적화 해주던일을 굉장히 쉽게 실현할 수 있다.
무엇보다, Redux에서는 추가적인 api로 구현되었던 비동기 처리나 캐싱을 Recoil에서는 자체적으로 selector에서 지원을 한다. 에러처리도 리액트의 suspense를 사용할 수 있고, useRecoilValueLoadable을 사용한다면, hasValue, loading, hasError 상태를 제공해서, 로딩완료시, 로딩시, 에러시 분기처리가 손쉽게 가능하다.
unstable이지만 스냅샷으로 테스팅도 간편하다.
const numberState = atom({key: 'Number', default: 0});
const multipliedState = selector({
key: 'MultipliedNumber',
get: ({get}) => get(numberState) * 100,
});
test('Test multipliedState', () => {
const initialSnapshot = snapshot_UNSTABLE();
expect(initialSnapshot.getLoadable(multipliedState).valueOrThrow()).toBe(0);
const testSnapshot = snapshot_UNSTABLE(({set}) => set(numberState, 1));
expect(testSnapshot.getLoadable(multipliedState).valueOrThrow()).toBe(100);
});
Recoil 공식문서를 살펴봐도 Redux에 비해 예제가 상당히 간편한 편이다. Redux보다 코드량이 적은 이유도 있지만, 아직까지 다양한 패턴에 대한 예시가 부족하다는 의견이 있다. 실제로 웹소캣과 Recoil을 사용한 케이스에 캐쉬로 인한 성능 저하 이슈를 겪은 분이 계신걸 보니 복잡한 동적인 페이지라면 고민이 필요할 것 같다.
고석진 - Context Api vs Redux
RIDI - 리덕스 잘 쓰고 계시나요?
카타나제로 - redux recoil 내용정리
emma goto - redux vs recoil
b00032 - react의 새로운 상태관리 라이브러리 recoil 에 대해 알아보기 - atom, selector
벨로퍼트와 함께하는 모던 리액트 - 리덕스
ToastUI - Recoil - 또 다른 React 상태 관리 라이브러리?