Redux-toolkit

Y·2022년 1월 8일
7
post-thumbnail

이번에 구글 설문지의 주요 기능을 구현해보면서 redux-toolkit을 사용해보게 되었다. [프로젝트 보기]
기존에는 redux와, thunk 미들웨어를 사용하여 상태관리를 했었는데 redux-toolkit의 장점들도 분명하게 느꼈기에, 조금 더 공부하며 정리해보았다.

💜 Redux-toolkit 사용목적

Redux-toolkitredux 로직을 작성하는 하나의 표준 방법이라고 할 수 있다.
그럼 기존의 redux와는 어떤 다른 목적이 있어서 나오게 된 것일까?
쉽게 말하면 리덕스를 조금 더 쉽게 사용하기 위해서이다. "redux-toolkit"이라는 이름 그대로 리덕스를 위한 도구 모음(키트)인 것이다.

redux의 3가지 문제는 아래와 같다.

- redux store 환경은 너무 복잡하다
- redux를 유용하게 사용하려면 많은 패키지를 추가해야한다
- redux는 보일러플레이트, 즉 어떤 일을 하기 위해서 작성해야하는 (상용구) 코드를 너무 많이 요구한다

이러한 이슈를 해결하기 위해서 toolkit이 등장하게 된 것이다.
redux-toolkit을 사용함으로써 기존 리덕스의 복잡함을 낮추고 사용성을 높일 수 있게 된다.
특히 해당 툴킷에서는 다양한 함수를 제공하는데, 코드를 간단히 작성할 수 있도록 지원하고 있다.


💜 Redux toolkit 설치

npm install @reduxjs/toolkit
또는
yarn add @reduxjs/toolkit


💜 대표적인 API

configureStore()

스토어를 구성하는 함수이다.
리덕스 코더 라이브버리의 표준 함수인 createStore를 추상화한 것이다. 기존 리덕스의 번거러운 기본 설정과정을 자동화한 것이라고 볼 수 있다.
추가적으로, 리덕스 미들웨어 중 하나인 redux-thunk를 디폴트로 제공하고 있으며 Redux DevTools Extension 또한 활성화할 수 있다.

import { configureStore } from '@reduxjs/toolkit'

import rootReducer from './reducers'

const store = configureStore({ reducer: rootReducer })
// The store now has redux-thunk added and the Redux DevTools Extension is turned on

configureStore의 전체적인 구성은 아래와 같다.

import { Reducer } from '@reduxjs/toolkit'
declare const reducer: Reducer<{}>
export default reducer

import { Reducer } from '@reduxjs/toolkit'
declare const reducer: Reducer<{}>
export default reducer

import { configureStore } from '@reduxjs/toolkit'

import logger from 'redux-logger'

import { reduxBatch } from '@manaflair/redux-batch'

import todosReducer from './todos/todosReducer'
import visibilityReducer from './visibility/visibilityReducer'

const reducer = {
  todos: todosReducer,
  visibility: visibilityReducer,
}

const preloadedState = {
  todos: [
    {
      text: 'Eat food',
      completed: true,
    },
    {
      text: 'Exercise',
      completed: false,
    },
  ],
  visibilityFilter: 'SHOW_COMPLETED',
}

const store = configureStore({
  reducer,
  middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(logger),
  devTools: process.env.NODE_ENV !== 'production',
  preloadedState,
  enhancers: [reduxBatch],
})

configureStore함수는 reducer, middleware, devTools, preloadedState, enhancer 정보를 전달한다.

- reducer
(1) 단일함수를 전달하여 스토어의 root reducer로 바로 사용할 수 있다.
(2) 또는 slice reducer들로 구성된 객체를 전달할 수 있다. 이 경우에 configureStore는 해당 객체를 내부적으로 combineReducers 함수를 통해 자동적으로 병합하여 root reducer를 생성하게 된다.

- middleware
리덕스 미들웨어 함수를 담는 배열이다.
사용할 모든 미들웨어를 배열에 담아서 명시할 수 있다.
생략할 경우 getDefaultMiddleware를 호출하게 된다.

- devTools
불리언 값으로 리덕스 개발자 도구를 끄거나 킬 수 있다. 디폴트 값은 true이다.

- preloadedState
스토어의 초기값을 설정할 수 있다.

- enhancers
redux store enhancers의 배열이며, 콜백 함수로 정의할 수도 있다.
배열로 정의된 경우, 이는 리덕스의 compose 함수로 전달되어 병합된 최종적인 enhancercreateStore 함수로 전달된다.
콜백 함수로 전달되는 경우 applyMiddleware 보다 앞에 추가할 수 있다.
즉 개발자가 원하는 enhancer를, middleware가 적용되는 순서보다 앞서서 추가하고 싶을 때 유용하게 사용된다.

const store = configureStore({
  ...
  enhancers: (defaultEnhancers) => [offline, ...defaultEnhancers],
})
// [offline, applyMiddleware, devToolsExtension]

createReducer()

상태에 변화를 일으키는 리듀서 함수를 생성하는 util 함수이다.
immer 라이브러리를 사용하여 mutative한 형태로 작성해도 immutability(불변성) 을 유지할 수 있게 해준다.

기존의 리덕스에서는 보통 switch-case문을 사용하여 각 action type마다 case를 통해 관리했었다.
또한 중첩된 상태에 대해서 값을 업데이트할 때, 모든 단계에서의 복사가 필요했다. 코드가 매우 길어져서 가독성을 떨어뜨리기도 한다.
또한 사용자가 실수로 원본 객체에 직접적인 변형을 일으키거나, 얕은 복사가 이루어지는 등, 다양한 사이드 이펙트를 발생시킬 위험성도 있었다.

redux-toolkit case reducer가 액션을 처리하는 방법은 builder callbackmap object라는 2가지 표기법이 있다.
두 방법 모두 역할은 동일하지만, Typescript와의 호환성을 위해서 첫번째 방법인 builder callback 표기법이 더 선호된다.

// 각 라인마다 빌더 메서드를 나누어 호출
const counterReducer = createReducer(initialState, (builder) => {
  builder.addCase(increment, (state) => {})
  builder.addCase(decrement, (state) => {})
})

// 또는 메서드 호출을 연결하여 연속적으로 작성
const counterReducer = createReducer(initialState, (builder) => {
  builder
    .addCase(increment, (state) => {})
    .addCase(decrement, (state) => {})
})

<Builder Methods>

- builder.addCase(actionCreator, reducer) :
특정 action type과 맵핑되는 case reducer를 추가한다.
builder.addMatcherbuilder.addDefaultCase 메서드보다 먼저 작성되어야한다.

- builder.addMatcher(matcher, reducer) :
새로 들어오는 모든 액션에 대해서 사용자가 작성한 matcher 함수와 일치하는지 확인하고 리듀서를 실행한다.

import { createReducer, AsyncThunk, AnyAction} from '@reduxjs/toolkit'

type GenericAsyncThunk = AsyncThunk<unknown, unknown, any>

type PendingAction = ReturnType<GenericAsyncThunk['pending']>
type RejectedAction = ReturnType<GenericAsyncThunk['rejected']>
type FulfilledAction = ReturnType<GenericAsyncThunk['fulfilled']>

function isPendingAction(action: AnyAction): action is PendingAction {
  return action.type.endsWith('/pending')
}

const reducer = createReducer(initialState, (builder) => {
  builder
    //matcher가 밖에서 함수로 정의
    .addMatcher(isPendingAction, (state, action) => {
      state[action.meta.requestId] = 'pending'
    })
  
    // matcher가 inline으로 정의
    .addMatcher(
      (action): action is RejectedAction => action.type.endsWith('/rejected'),
      (state, action) => {
        state[action.meta.requestId] = 'rejected'
      }
    )
    // matcher가 generic argument를 받아서 처리
    .addMatcher<FulfilledAction>(
      (action) => action.type.endsWith('/fulfilled'),
      (state, action) => {
        state[action.meta.requestId] = 'fulfilled'
      }
    )
})

- builder.addDefaultCase :
그 어떤 case reducer나 matcher 리듀서도 실행되지 않았다면 기본 케이스 리듀서가 실행된다

const reducer = createReducer(initialState, (builder) => {
  builder
    // .addCase(...)
    // .addMatcher(...)
    .addDefaultCase((state, action) => {
      state.otherActions++
    })
})

<Map Object>
액션 타입 문자열을 key로 사용하는 객체를 받고, values는 해당 액션 타입을 처리하는 case reducer이다.
이는 builder callback 표기법보다 짧게 작성할 수 있다는 장점이 있지만 JavaScript를 사용할 때 유효하며, TypeScript를 사용한다면 builder callback 표기법을 권장한다.

const counterReducer = createReducer(0, {
  increment: (state, action) => state + action.payload,
  decrement: (state, action) => state - action.payload
})

const counterReducer = createReducer(0, {
  increment: (state, action) => state + action.payload,
  decrement: (state, action) => state - action.payload
})

또는 createAction 메서드를 사용하여 생성된 액션 생성자를 연산된 프로퍼티 (computed property) 문법을 사용해서 바로 key 형태로 사용할 수 있다.
createAction 메서드는 바로 아래에서 다뤄보겠다.

const increment = createAction('increment')
const decrement = createAction('decrement')

const counterReducer = createReducer(0, {
  [increment]: (state, action) => state + action.payload,
  [decrement.type]: (state, action) => state - action.payload
})

createAction()

기존 리덕스 코어 라이브러리에서 액션을 정의하는 일반적인 방법은 액션 타입 상수와 액션 생성자 함수를 분리하여 선언하는 것이였다.
redux-toolkit에서는 createAction() 함수를 제공하여 이들을 하나로 결합하여 추상화했다.

// [BEFORE]
const INCREMENT = 'counter/increment'

function increment(amount: number) {
  return {
    type: INCREMENT,
    payload: amount,
  }
}

const action = increment(3) // { type: 'counter/increment', payload: 3 }
// [Redux-toolkit]
import { createAction } from '@reduxjs/toolkit'

const increment = createAction('counter/increment')

const action = increment(3) // { type: 'counter/increment', payload: 3 }

또한 createAction 함수는 toString() 메서드를 오버라이딩해서 action creator 객체를 액션 타입 문자열로 표현할 수 있도록 해준다.
따라서 map object 표기법에서 액션 생성자를 직접 key로도 사용할 수 있다.

const increment = createAction('counter/increment')

const counterReducer = createReducer(0, {
  [increment]: (state, action) => state + action.payload,
  [decrement.type]: (state, action) => state - action.payload
})

console.log(increment.toString())
// 'counter/increment'

console.log(`The action type is: ${increment}`)
// 'The action type is: counter/increment'

createSlice()

우선 createSlice 메서드를 사용하면 앞선 createActioncreateReducer를 따로 작성할 필요가 없다.
createSlice 메서드는 (1)initialState, (2)reducer 함수들의 객체, (3)slice 이름을 받아서 이에 상응하는 action creator와 action type을 자동으로 생성한다!

import { createSlice, PayloadAction } from '@reduxjs/toolkit'

interface CounterState {
  value: number
}

const initialState = { value: 0 } as CounterState

const counterSlice = createSlice({
  name: 'counter',
  initialState,
  reducers: {
    increment(state) {
      state.value++
    },
    decrement(state) {
      state.value--
    },
    incrementByAmount(state, action: PayloadAction<number>) {
      state.value += action.payload
    },
  },
})

export const { increment, decrement, incrementByAmount } = counterSlice.actions
export default counterSlice.reducer

추가적으로 createSlice(4)extraReducers 또한 인자로 받을 수 있는데, 이는 createSlice가 생성한 액션 타입외에 다른 액션 타입에도 응답할 수 있게 해준다.
즉 slice reducer에 맵핑된 내부 액션 타입이 아니라 외부의 액션을 찹조하려는 의도를 가지고 있다.

const counterSlice = createSlice({
  name: 'counter',
  initialState: 0,
  reducers: {},
  extraReducers: (builder) => {
    builder
      .addCase(incrementBy, (state, action) => {
      /* -- */
    })
  }
})

createAsyncThunk

createAction의 비동기 버전을 위해서 제안되었다.
(1)액션 타입 문자열과 (2)프로미스를 반환하는 콜백함수를 인자로 받아서 해당 액션 타입을 접두어로 사용하는 프로미스 생명 주기 기반의 액션 타입을 생성한다.

import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'
import { userAPI } from './userAPI'

const fetchUserById = createAsyncThunk(
  'users/fetchByIdStatus',
  async (userId, thunkAPI) => {
    const response = await userAPI.fetchById(userId)

    return response.data
  }
)

const usersSlice = createSlice({
  name: 'users',
  initialState: { entities: [], loading: 'idle' },
  reducers: {},
  // extraReducers에 케이스 리듀서를 추가하면 
  // 프로미스의 진행 상태에 따라서 리듀서를 실행할 수 있다
  extraReducers: (builder) => {
    builder
      .addCase(fetchUserById.pending, (state) => {})
      .addCase(fetchUserById.fulfilled, (state, action) => {
	      state.entities.push(action.payload)
      })
      .addCase(fetchUserById.rejected, (state) => {})
  },
})

dispatch(fetchUserById(123))

두번째 인자로 전달받는 콜백함수를 payloadCreator라고 하는데, 이는 어떤 비동기 로직의 실행 결과를 포함하는 프로미스 형태로 반환한다.
만약에 에러가 발생했다면, Error 인스턴스를 포함하는 rejected promise를 반환하거나 thunkAPI.rejectWithValue 메서드를 사용하여 에러를 처리해줄 수 있다.

redux에서 비동기 처리를 할 때 보통 thunk, saga등의 미들웨어를 사용하여 한 개의 비동기 액션에 대해 pending, fulfilled, rejected의 상태를 생성하여 처리하는 경우가 많았다. 그러나 createAsyncThunk 메서드를 사용하여 더 편리하게 비동기 작업을 코드로 작성할 수 있다.
다만 thunk만 지원한다는 점!


createSelector

리덕스 스토어 상태에서 데이터를 추출할 수 있도록 도와주는 유틸리티이다. Reselect 라이브러리에서 제공하는 함수를 그대로 가져온 것인데, 기존의 useSelector 함수의 결점을 보완하기 위한 좋은 솔루션이라고 할 수 있다.

//[BEFORE] useSelector
const users = useSelector(state => state.users);

Reselect 라이브러리의 createSelector 함수는 memoization(메모이제이션)을 기반으로 동작한다.

Memoization(메모이제이션)이란, 주어진 입력값에 대한 결과를 저장함으로써 같은 입력값에 대해 함수가 한 번만 실행되는 것을 보장한다.
즉 이전에 계산한 값을 메모리에 저장하여 값이 변경되었을 경우에만 계산하도록 하는 방식이다.

아래와 같은 예시를 봐보자.

const users = useSelector(
  (state) => state.users.filter(user => user.subscribed)
);

useSelector 훅이 스토어를 자동으로 구독하고 있기 때문에, 상태 트리가 갱신되어 컴포넌트를 다시 render 해야하는 경우 매번 새로운 인스턴스를 생성하게 된다.

해당 예제에서 useSelector가 실행될 때마다 filter 함수는 매번 새로운 배열을 반환하게 되면서 이전에 참조하던 객체 주소가 현재 주소와의 차이를 발생시키게 된다.
그리고 re-rendering을 발생시키는데, 이때 재계산이 필요한 상태 트리의 사이즈나 계산 비용이 크다면 성능 문제로도 이어질 수 있다.

이와 같은 경우에 createSelector를 사용하면 최적화할 수 있다.

const itemSelector = state => state.items

const totalSelector = createSelector(
  itemSelector,
  items => items.reduce((total, item) => total + item.value, 0)
)

💜 마무리

이번에 해당 포스트를 작성하면서, 전반적인 redux에 대한 개념을 한번 더 정리할 수 있어서 좋았다.

또한 redux-toolkit가 기존 리덕스의 문제점, 불편했던 점 등을 개선할 수 있는 일종의 방안을 제공한다는 점에서 신선했다.

무엇보다 기존에 redux를 사용할 때 상태를 "immutable" 하게 다뤄야함을 주의했었다. 그러나 이번 리덕스 툴킷은 앞서 말했듯이 immer의 produce API를 포용하므로, 상태를 mutable하게 변경해도 이를 감지하여 새로운 객체를 생산하여 return한다.

실제로도 나는 아래와 같이 바로 push 메서드를 통해 상태를 변경했고, 불변성이 보장되는 것을 확인할 수 있었다.

addQuestion: (state, action) => {
   const newQuestion = action.payload;
   state.push(newQuestion);
},

최근에 다양한 상태관리 라이브러리가 등장하는 동시에 redux가 어렵다는 의견이 많이 나오고 나 또한 그렇게 느끼곤 했다. 그러나 이를 조금 더 쉽게 구현할 수 있게 제공하는 redux-toolkit을 사용해보면서, redux의 근본적인 개념과 툴킷에서 추가적으로 제공하는 API를 익힌다면 생각보다 적용할 만 하다는 생각이 들었다.

쨌든 다양한 상태관리 툴의 등장 속에서 내 프로젝트에 알맞는 기술을 적절하게 선택하는 것이 중요할 것 같다.


[References]

profile
기록중

0개의 댓글