리덕스 툴킷이란 리덕스를 더 사용하기 쉽게 만들기 위해 리덕스에서 공식 제공하는 개발도구 입니다. 리덕스는 훌륭한 라이브러리지만 단점 역시 있습니다. 리덕스 툴킷은 이러한 리덕스의 단점을 보완하기 위해 등장하였습니다.
우리는 리덕스를 사용하면서 편리함을 느끼지만 한편으로는 너무 눈에 보이는 단점을 가지고 있다고 한번쯤 생각했을 것이다.
이러한 단점과 툴킷을 비교해보며 툴킷을 본격적으로 다루기 전 왜 필요한지 왜 현시점
리덕스를 활용하는 개발에서 툴킷이 필수라는 소리를 들으며 트렌드가 되었는지 알아보겠다.
리덕스의 단점중에 첫번째로 너무 많은 boiler plate code를 준비해야 한다는 것이다.
액션 타입, 액션 생성함수, 리듀서 이렇게 3가지 종류의 코드를 준비해야한다. 또한 다른 패키지를 사용하지 않는다면 코드가 불필요하게 길어진다(제 리덕스 velog 참고).
물론 적응하게 되겠지만 프로젝트가 커지면서 하나의 리듀서에서 다루는 상태가 커지고 세부적인 업데이트가 많아지면 불변성을 지키기 위해 ...state를 지속적으로 사용하는것도 번거롭습니다.
물론 immer 라이브러리를 활용해 불변성을 지키며 리덕스를 사용하는데 어느정도 도움을 받을 수는 있지만 이런식으로 관련 문제를 해결하기위해 계속해서 다른 패키지를 install받아서 사용한다는 점 또한 다른 패키지 의존성이 높다는 점에서 리덕스의 단점으로 꼽을 수 있습니다.
이뿐만 아니라 불필요한 렌더링을 막기위해 우리는 reselect를 사용해야 했으며 비동기 작업을 위해서는 관련 미들웨어 라이브러리인 thunk나 saga를 사용해야 했습니다.
하지만 redux-toolkit은 위의 문제점에서 saga를 제외한 모든 기능을 제공하여 리덕스의 단점을 보완합니다.
위의 사진은 리덕스 툴킷 공식문서 첫번째 페이지에서 보여주는 리덕스 툴킷의 장점을 간단히 설명한것이다.
실제 아래의 내용을 통해 학습하면 위의 사진에서 보여주는 강점을 직접 체험해 볼 수 있는데 사용전 해당 사진이 포함된 첫번째 장부터 API DOCS에 게시되어있는 내용을 한번 읽어보는 것이 좋을 것 같다.
npm install @reduxjs/toolkit
리덕스로 작성된 코드를 툴킷으로 리팩토링을 진행하다보면 전체적인 코드양이 감소하고 반복되는 코드 또한 제거할 수 있는데 직접 해보면서 리덕스 툴킷을 이해하겠다. 우선 리덕스 툴킷이 없을 때 코드를 보도록하자.
export const ADD_TODO = "ADD_TODO";
export const DELETE_TODO = "DELETE_TODO";
export const UPDATE_TODO = "UPDATE_TODO";
export const CHECKED_TODO = "CHECKED_TODO";
export function addTodo(todo) {
return{
type:ADD_TODO,
payload: todo
}
}
export function deleteTodo(todo) {
return{
type:DELETE_TODO,
payload: todoId
}
}
export function updateTodo(todo) {
return{
type:UPDATE_TODO,
payload: todo
}
}
export function checkedTodo(todo) {
return{
type:CHECKED_TODO,
payload: todo
}
}
const initialState = [
{
id: 0,
text: 'redux',
checked: false
}
];
const initialState = []
export const reducer = (state = initialState, action) => {
let newTodos = [...state];
switch (action.type) {
case ADD_TODO:
return newTodos.concat(action.payload)
case DELETE_TODO:
newTodos = newTodos.filter((todo) => todo.id !== action.payload);
return newTodos;
case UPDATE_TODO:
const index = newTodos.findIndex((todo) => todo.id === action.payload.id);
newTodos[index] = action.payload;
return newTodos;
case CHECKED_TODO:
const findCheckedIndex = newTodos.findIndex((todo) => todo.id === action.payload.id);
newTodos[findCheckedIndex].checked = !newTodos[findCheckedIndex].checked;
return newTodos;
}
return state;
};
export default reducer
위의 코드는 투두 리스트에서 추가, 삭제, 수정, 완료 기능이 있는 리덕스를 덕스 패턴을 활용해 작성한 코드다.
보시면 위에서 우리가 단점이라 하였던 boilerplate code가 너무 많다는걸 한눈에 확인할 수 있다.
물론 여기서 코드를 줄일 수는 있다.
npm install --save redux-actions
npm install immer
이러한 패키지를 사용해 redux-actions에서 제공하는 createAction, handleAction을 통해 조금더 코드를
간소화 할 수 있고 또다른 문제점인 불변성 해결을 위해 immer라이브러리를 사용할 수 있다.
여기서 우리는 리덕스가 각 목적별로 패키지에 대한 의존성이 높다는 것이다.
패키지 의존성이 높다는 것은 하나의 덕스패턴 구조를 작성할 때 마다 필요로하는 패키지를 Import 해와야한다.
이제 이러한 단점을 모두 보완해 주었다는 리덕스 툴킷을 통해 완성된 덕스패턴 구조를 보도록 하자.
import { createAction, createReducer } from '@reduxjs/toolkit';
export const ADD_TODO_SAGA = 'todo/ADD_TODO_SAGA';
export const DELETE_TODO_SAGA = 'todo/DELETE_TODO_SAGA';
export const UPDATE_TODO_SAGA = 'todo/UPDATE_TODO_SAGA';
export const CHECKED_TODO_SAGA = 'todo/CHECKED_TODO_SAGA';
export const addTodoSaga = createAction(ADD_TODO_SAGA);
export const deleteTodoSaga = createAction(DELETE_TODO_SAGA);
export const updateTodoSaga = createAction(UPDATE_TODO_SAGA);
export const checkedTodoSaga = createAction(CHECKED_TODO_SAGA);
const reducer = createReducer([], {
[addTodoSaga]: (state, action) => {
state.push(action.payload);
},
[deleteTodoSaga]: (state, action) => {
return state.filter((todo) => todo.id !== action.payload);
},
[updateTodoSaga]: (state, action) => {
const index = state.findIndex((todo) => todo.id === action.payload.id);
state[index] = action.payload;
},
[checkedTodoSaga]: (state, action) => {
const index = state.findIndex((todo) => todo.id === action.payload.id);
state[index].checked = !state[index].checked;
}
});
export default reducer;
딱히 비교할 필요없이 확연히 줄어든 코드수가 보인다.
이제 하나하나 알아가 보자.
위의 코드에서 보면 액션 함수를 정의하기위해 2번의 과정이 필요하며 별도 액션 타입을 정의 해 주어야한다.
하지만 변경된 코드에서는 한번의 과정이면 되고 타입만 인자로 넣어주면 default로 타입을 가진 액션 함수를 생성해 주며 이 함수 호출시 파라미터를 추가로 넣어주면 자동으로 payload의 value값으로 들어간다.
왜 이런게 가능할까?
어느정도 예측이 가능한데 createAction은 3가지를 파라미터로 가져온다.
이러한 이유로 코드는 대폭 감소하면서 전과 같은 기능을 할 수 있는 것이다.
위의 코드를 보면 눈에 띄게 리듀서 함수의 코드가 줄은 것을 확인할 수 있다.
또한 initialState를 따로 선언해 주지않았다는것, newTodos를 선언해 상태값을 스프레드 연산자를 통해 얕은 복사를 해서 사용하지 않고 그냥 state값에 직접 접근을 했다는점 또한 concat이라는 메서드를 통해 원본배열 파괴를 막는게 아니라 push라는 원본 배열을 파괴하는 메서드를 사용했다는점이 눈에 들어온다.
하나씩 알아보자.
createReducer를 사용하면 리듀서 함수의 간소화가 가능한데 두가지 인수를 필요로 한다.
첫째로 초기상태(initialState), 두번째로는 액션타입에서 case Reducer로의 객체 매핑이며 각각은 하나의 특정 액션 유형을 처리해 준다.
이러한 이유로 initialState를 따로 지정할 필요도 없으며 switch/case문을 사용할 필요 또한 없게 되었다.
또한 createReducer는 불변성을 지켜주는 것을 더 쉽게 해줍니다.
툴킷을 사용한 리듀서 함수는 내부적으로 immer의 produce를 사용합니다. 그렇기 때문에 새로운 상태 객체를 리턴할 필요가 없습니다. 그대신 상태 값을 직접 변경하는 방식으로 코드를 작성하면 됩니다.
툴킷 createReducer공식문서에서 확인할 수 있듯 createReducer는 자체적으로 불변성을 쉽게 처리하기 위해 immer를 사용하고 이는 마치 상태를 직접변경하는 것처럼 리듀서를 작성할 수 있으며 실제 리듀서는 모든 변형을 동등한 복사 작업으로 변환하는 proxy 상태를 받습니다.
우선 configureStore를 사용하기전 store.js를 보자
// store.js
import { createStore, applyMiddleware } from 'redux';
import { composeWithDevtools } from 'redux-devtools-extension';
import createSagaMiddleware from 'redux-saga';
import index from './modules/index';
import rootSaga from './sagas';
import logger from 'redux-logger';
const sagaMiddleware = createSagaMiddleware();
const middleware = applyMiddleware(sagaMiddleware, logger);
const devTools = composeWithDevtools(...middleware);
export const store = createStore(index, devTools);
sagaMiddleware.run(rootSaga);
코드를 보면 리듀서 함수들과 사용된 미들웨어를 합쳐서 하나의 스토어를 구성해 주기 위해 createStore를 사용하고, 미들웨어들을 합쳐주기 위해 applyMiddleware를 사용하고, 리덕스 데브툴을 사용하기 위해 composeWithDevTools를 redux-devtools-extension에서 install 받고 import받아와서 사용하고...
물론 작동한다 하지만 보시다시피 너무 많은 패키지에 대한 의존성이 보인다.
이제 configureStore를 사용해서 변환해 보자.
import createSagaMiddleware from 'redux-saga';
import logger from 'redux-logger';
import index from './modules/index';
import rootSaga from './sagas/index';
import { configureStore } from '@reduxjs/toolkit';
const sagaMiddleware = createSagaMiddleware();
const middlewares = [logger, sagaMiddleware];
export const store = configureStore({
devTools: true, //없어도 됩니다.
middleware: middlewares,
reducer: index
});
sagaMiddleware.run(rootSaga);
사가를 제외하고는 import부분에 redux/toolkit뿐입니다.
또한 import 아래부분의 코드도 훨씬 간결해진 것을 확인할 수 있습니다.
왜이런게 가능한지 확인해 보겠습니다.
configureStore를 사용하면 5가지를 인자로 받아옵니다.
reducer, middleware, devTools, preloadedState, enhancer를 받아옵니다.
combineReducer에서 전달된 rootReducer를 다루는 reducer,
사용하는 미들웨어를 등록할 수 있는 middleware,
별도의 설치없이 redux-devtools를 사용하게 해주는 devTools 여기서 devTools는 boolean값을 통해 등록
하는데 만약 dev-Tools를 사용한다면 true를 셋팅해줄 필요없이 devTools자체를 삭제해 줘두 된다.
기본값으로 true가 지정되 있기 때문이다.
만약 사용하지 않을 거라면 devTools: false로 셋팅하면 된다.
이러한 이유로 인해 configureStore를 사용하면 사용하는 패키지가 줄어들고 코드또한 간결해 지는 것이다.
[참고자료] : 리덕스 툴킷 공식문서 ⇒ 링크