Immer
Redux Toolkit은 리듀서 로직에 내부적으로 immer를 채택하고 있습니다.
LIVESCRIPT
import produce from 'immer';
const initialState = {
counter: 0,
text: 'hello',
list: []
};
const nextState = produce(initialState, draft => {
draft.counter = 1;
draft.text = 'world';
draft.list.push({ id: 1, text: 'immer' });
});
draft라는 proxy객체 기반의 업데이트를 수행
JAVASCRIPT
const exampleSlice = createSlice({
initialState,
reducers: {
exampleReducer:(state,action)=> { // 상태 변경 로직 .. immer적용
state.example.count++ // 원본을 직접 수정하는 코드
}
}
})
내부적으로 immer가 적용되어 state 객체를 직접 수정
따라서 redux 렌더링 최적화에는 이 immer의 불변성 유지 방식을 이해하는 것이 중요한데요,
Immer 라이브러리는 내부적으로 어떤 방식을 사용해 불변성을 유지해줄까요?
Structural Sharing 기법을 활용한 불변성 유지 방법
YAML
{
todos: [
{ id: 1, text: '책 읽기', done: true },
{ id: 2, text: '블로그 글 쓰기', done: true },
{ id: 3, text: '운동하기', done: false },
{ id: 4, text: '요리하기', done: false }
],
otherState: { /* ... */ }
}
Immer.js, Immutable.js 와 같은 라이브러리들은 불변성 유지를 최적화하기 위해 구조 공유라는 방법을 사용합니다.
이 방식은 특정 속성이 변경되면, 그 속성이 속한 객체와 그 객체의 상위 객체 등 모든 부모 객체들이 새로운 참조로 교체되는 방식입니다.
단, 변경되는 부분만을 새로 초기화하고, 변경되지 않는 부분은 이전의 값을 그대로 재사용함으로써
메모리 사용량을 최소화하면서도 불변성을 유지할 수 있는 장점이 있습니다.
이러한 immer의 불변성 유지 원리를 이해 했다면 아래의 전역 상태 변화를 추적할 수 있습니다.
위 데이터 구조중 todos 배열 내부 요소의 프로퍼티가 변경되면 todos 상위의 모든 프로퍼티는 새로운 참조로 초기화됩니다.
하지만 otherState의 프로퍼티가 변경된다면 immer는 변경되지 않은 상태인 todos에 대한 참조는 재사용하고 가장 바깥 객체와 otherState 객체의 참조에 대해서만 초기화를 실행하는 것입니다.
이 방식을 아래의 트라이 자료구조로도 한번 확인해봅시다,

우리는 전체 상태 트리에서 tea노드의 값을 3 → 14로 변경하고 싶은데요,
이를 어떻게 최적화할 수 있을까요 ?

구조 공유 방식을 사용한다면 새로운 상태를 만들기 위해서는 root노드까지의 경로에 있는, 단 4개의 노드의 참조만 초기화하고
나머지 변경되지 않은 노드의 참조는 그대로 유지함으로써 최적화된 연산을 보장할 수 있습니다.
(구조 공유 방식의 불변성 유지로 불필요한 메모리 사용을 줄이고 성능을 최적화할 수 있는 것이죠.)
Immer에 대해 알아보았다면 이제 셀렉터에 대해 알아봅시다.
createSelector를 활용한 memoized selector로 렌더링 최적화하기
redux에서 일반적으로 selector 함수는 아래와 같이 작성하게 되는데요,
REASONML
function UndoneTasks() {
const tasks = useSelector(state => state.todos.filter(todo => todo.done));
// ...
}
하지만 위 방식은 불필요한 리렌더링을 발생시키게 됩니다.
useSelector 훅의 selector함수는 기본적으로 '(===)' 연산자를 사용하여 이전과 이후 값을 비교해 리렌더링을 수행하는데요,위 경우 , selector 함수가 모든 상황에서 새로운 배열을 반환하므로
selector 반환값이 계속 변경된 것으로 간주되어 리렌더링이 발생하게 되는 것이지요.
( * filter는 배열 요소중 콜백 반환값이 true가 되는 요소만 모아 새로운 배열로 반환하는 고차함수 형태의 배열메서드 )
이러한 문제를 해결하기 위해 memoized selector를 활용할 수 있습니다.
TYPESCRIPT
import { createSelector } from '@reduxjs/toolkit'
const todosSelector = (state) => state.todos;
const undoneTodos = createSelector(
todosSelector,
(todos) => todos.filter((todo) => !todo.done)
);
function UndoneTasks() {
const tasks = useSelector(undoneTodos);
// ...
}
createSelector 를 사용하여 Memoized Selector를 만들 수 있는데요, 이 함수에는 selector 들을 연달아서 넣을 수 있습니다.
만약 이 첫번째 selector 에서 반환된 값이 변경될 때에만 그 다음 selector를 호출하여 원하는 값을 연산하여 조회합니다.
todos 배열에 변화가 있을 때 ( todosSelector 반환값이 변할 때)에만 filter 함수를 돌리게 되고, 리렌더링을 하게 됩니다.
만약 전역상태의 state.otherState값에 변화가 생겼을 때에는, todos 상태의 변화는 없으므로 불필요한 리렌더를 방지할 수 있겠죠?
( immer의 불변성 유지 원리)
+ useMemo를 사용한 방법도 있어요
JAVASCRIPT
function UndoneTasks() {
const tasks = useSelector(state => state.todos);
const undoneTasks = useMemo(() => tasks.filter(task => !tasks.done), [tasks])
// ...
}
위 방식을 사용한다면 좀 더 직관적으로 최적화 할 수 있습니다.
하지만 렌더링을 최적화 하기 위해 무엇보다 중요한 것은 가능한 작은 상태 단위를 select하는 것입니다.
전역 상태 객체에서 상위 프로퍼티를 select하는 행위는 더 많은 리렌더를 트리거하기 때문이죠.
shallowEqual
useSelector의 두번째 인자로 얕은 비교를 수행하는 shallowEqual 함수를 전달할 수 있는데,
selector반환값의 최상위 프로퍼티에 실제 변화가 있을 때만 컴포넌트가 리렌더링되도록 최적화 할 수 있습니다.
추가적으로, useSelector로 여러 상태를 select하다보면 useSelector훅 호출이 잦아지는 경우도 있는데
이럴 때도 shallowEqual 함수를 사용하면 코드를 더욱 간결하게 만들 수 있습니다.
PF
function CounterContainer() {
//const number = useSelector(state => state.counter.number);
//const diff = useSelector(state => state.counter.diff);
const { number, diff } = useSelector(
state => ({
number: state.counter.number,
diff: state.counter.diff
}),
shallowEqual
);
const { number, diff } 같이 구조 분해 할당과 shallowEqual을 함께 사용하면 상태를 더욱 간결하고 직관적으로 접근할 수 있습니다.
이 방식은 커스텀훅으로도 만들어줄 수 있는데요,
* Recipe
useShallowEqualSelector()
JAVASCRIPT
import { useSelector, shallowEqual } from 'react-redux'
export function useShallowEqualSelector(selector) {
return useSelector(selector, shallowEqual)
}
위와 같이 커스텀 훅을 사용하면
selector함수만 전달해도 자동으로 얕은비교를 수행하니 더욱 편리하겠죠 ?
Deep dive +
React의 shallowEqual VS react-redux shallowEqual 무엇이 다를까 ?
React의 shallowEqual, react-redux의 shallowEqual 모두 얕은 비교를 수행하는 측면에서 거의 동일하지만 약간의 차이가 있습니다.
- React shallowEqual: 두 데이터의 동등비교(Object.is)를 우선 수행 -> 얕은 비교(shallow comparison)
- Redux-Redux shallowEqual : 참조 동일성을 먼저 검사하지 않고, 바로 얕은 비교를 수행
이는 react-redux shallowEqual이 Redux의 상태 관리 패러다임에 맞추어져 있으며, 상태 객체들이 변경될 때 불변성 유지를 위해 새로운 객체가 생성되는 것이 일반적이기 때문입니다.
Reference
https://ridicorp.com/story/how-to-use-redux-in-ridi/
https://react.vlpt.us/redux/08-optimize-useSelector.html
https://redux-toolkit.js.org/api/createSelector
https://react-redux.js.org/api/hooks#recipe-useshallowequalselector
Immer
Redux Toolkit은 리듀서 로직에 내부적으로 immer를 채택하고 있습니다.
LIVESCRIPT
import produce from 'immer';
const initialState = {
counter: 0,
text: 'hello',
list: []
};
const nextState = produce(initialState, draft => {
draft.counter = 1;
draft.text = 'world';
draft.list.push({ id: 1, text: 'immer' });
});
draft라는 proxy객체 기반의 업데이트를 수행
JAVASCRIPT
const exampleSlice = createSlice({
initialState,
reducers: {
exampleReducer:(state,action)=> { // 상태 변경 로직 .. immer적용
state.example.count++ // 원본을 직접 수정하는 코드
}
}
})
내부적으로 immer가 적용되어 state 객체를 직접 수정
따라서 redux 렌더링 최적화에는 이 immer의 불변성 유지 방식을 이해하는 것이 중요한데요,
Immer 라이브러리는 내부적으로 어떤 방식을 사용해 불변성을 유지해줄까요?
Structural Sharing 기법을 활용한 불변성 유지 방법
YAML
{
todos: [
{ id: 1, text: '책 읽기', done: true },
{ id: 2, text: '블로그 글 쓰기', done: true },
{ id: 3, text: '운동하기', done: false },
{ id: 4, text: '요리하기', done: false }
],
otherState: { /* ... */ }
}
Immer.js, Immutable.js 와 같은 라이브러리들은 불변성 유지를 최적화하기 위해 구조 공유라는 방법을 사용합니다.
이 방식은 특정 속성이 변경되면, 그 속성이 속한 객체와 그 객체의 상위 객체 등 모든 부모 객체들이 새로운 참조로 교체되는 방식입니다.
단, 변경되는 부분만을 새로 초기화하고, 변경되지 않는 부분은 이전의 값을 그대로 재사용함으로써
메모리 사용량을 최소화하면서도 불변성을 유지할 수 있는 장점이 있습니다.
이러한 immer의 불변성 유지 원리를 이해 했다면 아래의 전역 상태 변화를 추적할 수 있습니다.
위 데이터 구조중 todos 배열 내부 요소의 프로퍼티가 변경되면 todos 상위의 모든 프로퍼티는 새로운 참조로 초기화됩니다.
하지만 otherState의 프로퍼티가 변경된다면 immer는 변경되지 않은 상태인 todos에 대한 참조는 재사용하고 가장 바깥 객체와 otherState 객체의 참조에 대해서만 초기화를 실행하는 것입니다.
이 방식을 아래의 트라이 자료구조로도 한번 확인해봅시다,

우리는 전체 상태 트리에서 tea노드의 값을 3 → 14로 변경하고 싶은데요,
이를 어떻게 최적화할 수 있을까요 ?

구조 공유 방식을 사용한다면 새로운 상태를 만들기 위해서는 root노드까지의 경로에 있는, 단 4개의 노드의 참조만 초기화하고
나머지 변경되지 않은 노드의 참조는 그대로 유지함으로써 최적화된 연산을 보장할 수 있습니다.
(구조 공유 방식의 불변성 유지로 불필요한 메모리 사용을 줄이고 성능을 최적화할 수 있는 것이죠.)
Immer에 대해 알아보았다면 이제 셀렉터에 대해 알아봅시다.
createSelector를 활용한 memoized selector로 렌더링 최적화하기
redux에서 일반적으로 selector 함수는 아래와 같이 작성하게 되는데요,
REASONML
function UndoneTasks() {
const tasks = useSelector(state => state.todos.filter(todo => todo.done));
// ...
}
하지만 위 방식은 불필요한 리렌더링을 발생시키게 됩니다.
useSelector 훅의 selector함수는 기본적으로 '(===)' 연산자를 사용하여 이전과 이후 값을 비교해 리렌더링을 수행하는데요,위 경우 , selector 함수가 모든 상황에서 새로운 배열을 반환하므로
selector 반환값이 계속 변경된 것으로 간주되어 리렌더링이 발생하게 되는 것이지요.
( * filter는 배열 요소중 콜백 반환값이 true가 되는 요소만 모아 새로운 배열로 반환하는 고차함수 형태의 배열메서드 )
이러한 문제를 해결하기 위해 memoized selector를 활용할 수 있습니다.
TYPESCRIPT
import { createSelector } from '@reduxjs/toolkit'
const todosSelector = (state) => state.todos;
const undoneTodos = createSelector(
todosSelector,
(todos) => todos.filter((todo) => !todo.done)
);
function UndoneTasks() {
const tasks = useSelector(undoneTodos);
// ...
}
createSelector 를 사용하여 Memoized Selector를 만들 수 있는데요, 이 함수에는 selector 들을 연달아서 넣을 수 있습니다.
만약 이 첫번째 selector 에서 반환된 값이 변경될 때에만 그 다음 selector를 호출하여 원하는 값을 연산하여 조회합니다.
todos 배열에 변화가 있을 때 ( todosSelector 반환값이 변할 때)에만 filter 함수를 돌리게 되고, 리렌더링을 하게 됩니다.
만약 전역상태의 state.otherState값에 변화가 생겼을 때에는, todos 상태의 변화는 없으므로 불필요한 리렌더를 방지할 수 있겠죠?
( immer의 불변성 유지 원리)
+ useMemo를 사용한 방법도 있어요
JAVASCRIPT
function UndoneTasks() {
const tasks = useSelector(state => state.todos);
const undoneTasks = useMemo(() => tasks.filter(task => !tasks.done), [tasks])
// ...
}
위 방식을 사용한다면 좀 더 직관적으로 최적화 할 수 있습니다.
하지만 렌더링을 최적화 하기 위해 무엇보다 중요한 것은 가능한 작은 상태 단위를 select하는 것입니다.
전역 상태 객체에서 상위 프로퍼티를 select하는 행위는 더 많은 리렌더를 트리거하기 때문이죠.
shallowEqual
useSelector의 두번째 인자로 얕은 비교를 수행하는 shallowEqual 함수를 전달할 수 있는데,
selector반환값의 최상위 프로퍼티에 실제 변화가 있을 때만 컴포넌트가 리렌더링되도록 최적화 할 수 있습니다.
추가적으로, useSelector로 여러 상태를 select하다보면 useSelector훅 호출이 잦아지는 경우도 있는데
이럴 때도 shallowEqual 함수를 사용하면 코드를 더욱 간결하게 만들 수 있습니다.
PF
function CounterContainer() {
//const number = useSelector(state => state.counter.number);
//const diff = useSelector(state => state.counter.diff);
const { number, diff } = useSelector(
state => ({
number: state.counter.number,
diff: state.counter.diff
}),
shallowEqual
);
const { number, diff } 같이 구조 분해 할당과 shallowEqual을 함께 사용하면 상태를 더욱 간결하고 직관적으로 접근할 수 있습니다.
이 방식은 커스텀훅으로도 만들어줄 수 있는데요,
* Recipe
useShallowEqualSelector()
JAVASCRIPT
import { useSelector, shallowEqual } from 'react-redux'
export function useShallowEqualSelector(selector) {
return useSelector(selector, shallowEqual)
}
위와 같이 커스텀 훅을 사용하면
selector함수만 전달해도 자동으로 얕은비교를 수행하니 더욱 편리하겠죠 ?
Deep dive +
React의 shallowEqual VS react-redux shallowEqual 무엇이 다를까 ?
React의 shallowEqual, react-redux의 shallowEqual 모두 얕은 비교를 수행하는 측면에서 거의 동일하지만 약간의 차이가 있습니다.
- React shallowEqual: 두 데이터의 동등비교(Object.is)를 우선 수행 -> 얕은 비교(shallow comparison)
- Redux-Redux shallowEqual : 참조 동일성을 먼저 검사하지 않고, 바로 얕은 비교를 수행
이는 react-redux shallowEqual이 Redux의 상태 관리 패러다임에 맞추어져 있으며, 상태 객체들이 변경될 때 불변성 유지를 위해 새로운 객체가 생성되는 것이 일반적이기 때문입니다.
Reference
https://ridicorp.com/story/how-to-use-redux-in-ridi/
https://react.vlpt.us/redux/08-optimize-useSelector.html
https://redux-toolkit.js.org/api/createSelector
https://react-redux.js.org/api/hooks#recipe-useshallowequalselector