redux toolkit에서는 createSelector라는 함수를 지원한다. 공식문서의 해당 파트를 보면 createSelector는 Reselect 라이브러리에서 비롯됐으며, 사용하기 쉽게 재배포 한것이라고 설명한다.
The createSelector utility from the Reselect library, re-exported for ease of use.
또, Reselect 라이브러리의 README를 보면 다음과 같이 설명하고 있다.
A library for creating memoized "selector" functions
즉, createSelector는 selector에 메모이제이션을 더해 렌더링 최적화에 기여할 수 있는 기능을 제공한다고 이해할 수 있다.
사실 redux toolkit은 기본적으로 state를 가져오려면 useSelector라는 훅을 쓰기 때문에 굳이 createSelector를 쓰지 않더라도 state값을 전처리하여 가져올 수 있다. 가령, 현재 나이에서 10을 더한 값을 가져오고 싶다면 이렇게 할 수 있는 것이다.
const age = useSelector((state)=> state.user.age + 10)
하지만 이런 경우 컴포넌트가 리렌더링 될 때마다 새로운 age를 구하기 위한 연산 과정이 반복적으로 이루어지게 된다. 지금은 그냥 +10 수준이지만, 만약 이 로직이 아주 복잡하다면, 그리고 컴포넌트가 수시로 리렌더링 되는 상황이라면 심각한 성능 이슈로 이어질 우려가 생긴다.
이 문제를 해결하기 위해 createSelector를 사용하는 것이다. createSelector는 Reselect 라이브러리를 기반으로 하고, Reselect는 기본적으로 메모이제이션을 제공한다. 때문에 createSelector을 이용해 새로운 값을 반환하면 해당 값은 다시 리렌더링 되더라도 연산을 새로 수행하는 대신 이전에 캐싱해두었던 값을 반환하게 되는 것이다.
코드 베이스로는 [redux toolkit를 사용해봤다 (with. TS)]에서 작성한 user 예제를 활용했다.
//userSlice.tsx
const initialState: UserState = {
name: "kim",
age: 15,
};
export const userSilce = createSlice({
...body...
});
...somcodes...
//createSelector를 위해 추가해야하는 부분
const nameSelector = (state: RootState): string =>
state.user.name || initialState.name;
const ageSelector = (state: RootState): number =>
state.user.age || initialState.age;
export const greetingSelector = createSelector(
nameSelector,
ageSelector,
(name, age) => {
return `My name is ${name}. I'm ${age} years old.`;
}
);
먼저 createSelector에 사용할 state들을 선언하는 과정이 필요하다. 이번에는 name과 age 모두 사용할 예정이기 때문에 nameSelector와 ageSelector를 모두 선언해주었다. 이때 선언하는 형태는 state를 매개변수로 받는 함수의 형태인데, 컴포넌트에서 useSelector가 인수로 받는 형태와 동일하다.
createSelector는 n개의 parameter를 받는데, n-1번째 parameter까지는 새로운 값을 계산하는데 필요한 state를 받는다. 이번 케이스에서는 앞서 정의했던 nameSelector와 ageSelector를 넣어주었다. 그리고 마지막 n번째 매개변수도 함수 형태로 넣어주는데, 이 함수의 리턴값이 바로 우리가 사용하려고 하는 새롭게 추론된 state가 된다.
주의할 점은 이 마지막 함수의 매개변수의 순서는 createSelector에 n-1번까지 넣어준 매개변수의 순서와 동일하다는 것이다. 예를 들어 아래와 같이 name과 age 순서가 바뀌면 안된다.
export const greetingSelector = createSelector(
nameSelector,
ageSelector,
(age, name) => {
return `My name is ${name}. I'm ${age} years old.`;
}
);
// 위와 같이 name과 age 순서가 바뀌면 결과값이 아래와 같이 나온다.
// 'My name is 15. I'm kim years old.'
이제 greetingSelector를 사용할 준비는 모두 끝났다. 사용하는 방법은 아주 간단한데, 그냥 이전과 동일하게 useSelector를 이용하면 된다.
//App.tsx
import {
greetingSelector,
} from "./app/features/user/userSlice";
...
const greeting = useSelector(greetingSelector);
...
return <div>
...
{greeting}
</div>
이렇게 값을 가져오는 것 까지는 성공적으로 완료했다. 이제 정말 문서에서 설명한 것처럼 memoization이 적용되어 렌더링 최적화에 기여할 것인지 확인해보고자 한다.
확인을 위해 App 컴포넌트와, greetingSelector 로직 내부에 console.log를 삽입했으며, 컴포넌트의 리렌더를 유발하는 버튼을 배치했다.
//userSlice.tsx
...
export const greetingSelector = createSelector(
nameSelector,
ageSelector,
(name, age) => {
console.log("calculating")
return `My name is ${name}. I'm ${age} years old.`;
}
);
// App.tsx
import ...
function App() {
console.log("re-render");
const [toggle, setToggle] = useState(false);
...
return (
<div>
...
<button onClick={() => setToggle(!toggle)}>re-render</button>
</div>
);
}
export default App;
greetingSelector 계산에 필요한 name, age state가 변하면 greetingSelector도 다시 연산이 이루어지지만, 그렇지 않은 경우에는 greetingSelector가 호출되더라도 이전에 캐싱해두었던 값을 반환하고 있음을 확인할 수 있다.