Tech/React

[Redux&RTK] Redux의 등장배경과 7가지 기능으로 배우는 Redux Toolkit

Lamue 2024. 2. 29. 10:57

React에서는 전역상태 관리 라이브러리를 사용하여 체계적인 상태관리를 할 수 있습니다. React에서 활용되는 상태 관리 라이브러리들은 여러 개가 있습니다. 대표적으로 Redux, Recoil, Zustand, Jotai, MobX 등이 존재합니다. 

 

그렇다면 이렇게 많은 상태 관리 라이브러리중에 어떤 라이브러리를 사용하면 좋을까요?

제 경우 사이드 프로젝트의 경우 가볍게 Recoil, Zustand를 많이 사용하곤 합니다. 저의 멘토님은 MobX의 열렬한 신봉자(?)이시고, 지인 중 한명은 Jotai 아이콘이 귀엽다는 이유로 Jotai공부를 최근에 시작했습니다. 

실제로 Jotai 아이콘은 꽤 귀엽습니다.

물론 위와 같은 가벼운 이유로 상태 관리 라이브러리를 선정하기엔 리액트에서 상태 관리는 굉장히 큰 이슈입니다. 자신의 상황, 회사 내부 상황을 고려하여 프로젝트의 상태 관리 라이브러리를 선정하는 것이 무엇보다 중요합니다. 

 

다음은 npm trends를 통해 프론트엔드 상태 관리 라이브러리의 이용 추이를 확인해본 이미지입니다. 

최근 1년 상태관리 라이브러리 이용 추이(출처: npm trends)

Redux가 강세를 보이고 있는 모습을 확인해볼수 있습니다. 라이브러리 이용자 수가 많다는 것은 그만큼 개발자 커뮤니티층이 두껍다고도 생각할 수 있습니다. 이러한 이유로 프론트엔드를 처음 시작하는 주니어개발자들에게 Redux(리액트의 경우엔 react-redux)를 먼저 공부해보는 것을 권장하는 거 같습니다.

 

이번 포스팅에선 Redux에 등장배경과 함께 Redux를 쉽게 사용하기 위해 Redux 개발 팀에서 권장하는 RTK(Redux Toolkit)에 대해 알아보겠습니다. 본 포스팅은 리액트 개발환경을 기준으로 작성되었습니다. 

 

왜 리덕스(Redux)인가?

먼저 Redux(리덕스)에 대한 이야기로 시작해 보려고 합니다. 리덕스는 Flux 아키텍처의 구현체로 대형 MVC 애플리케이션에서 종종 나타나는 데이터 간 의존성 이슈, 즉 연쇄적인 갱신이 뒤얽혀 데이터의 흐름을 예측할 수 없게 만들었던 문제를 해결하기 위해서 고안되었습니다.

리덕스를 이용한 상태관리

Flux 패턴
Flux 패턴은 사용자 입력을 기반으로 Action을 만들고, Action을 Dispatcher에 전달하여 Store(Model)의 데이터를 변경한 뒤 View에 반영하는 단방향의 흐름으로 애플리케이션을 만드는 아키텍처입니다.

 

연쇄적인 갱신이 뒤얽혀 데이터의 흐름을 예측할 수 없게 만들었던 문제를 대표하는 사례 중 하나로 2014년 컨퍼런스에서 소개된 페이스북의 채팅 버그가 있습니다. 읽지 않은 메시지 상태를 나타내는 카운트를 확인하고 사용자가 메시지를 확인해도 다시금 메시지의 카운트 숫자가 되살아나면서 사용자를 괴롭히던 버그였습니다.

개발자가 버그를 수정해도 잠시 동안은 괜찮아 보였다가 계속해서 같은 버그가 보고되는 상황이 연출됐습니다. 이러한 경험을 거치면서 페이스북 팀은 설계에 기반한 근본적인 문제가 있다고 판단했습니다.

그리고 이에 대한 해결책으로 애플리케이션의 데이터가 단방향으로 흐르는 구조를 고안하게 되었습니다. 이는 플럭스 아키텍처의 핵심 멘탈 모델, 다시 말해서 사고 과정, 동기, 철학적 배경 등에 대해 깊이 이해할 수 있게 하는 하나의 모델이 되었고, 그 구현체인 리덕스는 애플리케이션을 위한 상태 컨테이너로써 단방향 데이터 흐름을 활용하여 시스템을 예측 가능할 수 있도록 보조하는 역할을 하게 되었습니다.

 

리덕스를 사용하는 구조에서는 전역 상태를 전부 하나의 저장소(store) 안에 있는 객체 트리에 저장하며, 상태를 변경하는 것은 어떤 일이 일어날지를 서술하는 객체인 액션(action)내보내는(dispatch) 것이 유일한 방법입니다. 그리고 액션이 전체 애플리케이션의 상태를 어떻게 변경할지 명시하기 위해서는 리듀서(reducer)의 작성이 필요합니다. 이 때 사용자의 상호작용에 응답하기 위해서 뷰(View)는 액션을 만들어서 시스템에 전파합니다.

그리고 이 모든 설계는 데이터가 단방향으로 흐른다는 전제하에 데이터의 일관성을 향상시키고 버그 발생 원인을 더 쉽게 파악하려는 의도에서 출발했습니다.

사용자의 상호작용에 응답하기 위해서 뷰는 액션을 만들어서 시스템에 전파합니다.

 

Redux 코어는 무엇을 하나요?

Redux가 수행하는 역할을 정리하면 다음과 같습니다:

  • "전역" 상태를 포함하는 단일 스토어
  • 앱에 어떤 일이 일어날 때 스토어에 일반 객체 액션을 디스패치
  • 액션을 살펴보고 불변성을 유지한 채 업데이트된 상태를 반환하는 순수 리듀서 함수

위의 역할을 수행하기 위해 Redux 코어는 몇 가지 작은 API를 제공합니다:

  • createStore는 실제 Redux 스토어를 생성합니다.
  • combineReducers는 여러 개의 slice리듀서를 하나의 큰 리듀서로 결합합니다.
  • applyMiddleware는 여러 개의 미들웨어를 스토어 인핸서(enhancer)로 결합합니다.
  • compose는 여러 개의 스토어 인핸서를 하나의 스토어 인핸서로 결합합니다.

이 외에, 애플리케이션에서 Redux와 관련된 모든 로직은 리덕스를 사용하는 개발자 본인(혹은 팀)이 작성해야 합니다.

이러한 특징은 Redux를 다양한 방법으로 사용할 수 있다는 장점으로 작용하면서 동시에 Redux 코드 작성을 어렵게 만들었습니다. 

 

왜 리덕스 툴킷(Redux Toolkit)인가?

그렇다면 RTK(Redux Toolkit)는 어떤 목적을 가지고 세상에 나온 것일까요? 

사실 완벽할 것만 같았던 리덕스에도 문제가 있었습니다. 다음은 대표적으로 언급되는 리덕스의 3가지 문제입니다.

  • 리덕스 스토어 환경 설정은 너무 복잡합니다.
  • 리덕스를 유용하게 사용하려면 많은 패키지를 추가해야 합니다.
  • 리덕스는 보일러플레이트, 즉 어떤 일을 하기 위해 꼭 작성해야 하는 (상용구)코드를 너무 많이 요구합니다.

사실 대부분의 보일러 플레이트 코드들은 Redux를 사용하는 데 있어서 필요하지 않습니다. 게다가, 이러한 보일러 플레이트 코드는 개발자로 하여금 더 많은 실수를 유발할 가능성이 있습니다.

이러한 Redux 로직에서 "보일러 플레이트"를 제거하고, 흔한 실수를 방지하고, 기본적인 Redux 작업을 간단하게 만드는 API를 제공하기 위해 리덕스 툴킷(Redux Toolkit)이 등장하였습니다.

 

리덕스 개발팀은 공식문서를 통해 RTK에 대해 다음과 같이 소개하고 있습니다.

 

Redux 사용 시 Redux Toolkit 사용을 적극 권장하고 있습니다.

리덕스 개발 팀에 따르면 RTK는 리덕스를 더 쉽게 사용하기 위해서 등장했습니다. 이름 그대로 리덕스를 위한 도구 모음(키트)입니다.

 

Redux Toolkit은 모든 Redux 앱에서 가장 일반적으로 하는 작업을 간소화하는 두 가지 주요 API로부터 시작합니다:

  • configureStore는 한 번의 호출로 Redux 스토어를 설정하며, 리듀서를 결합하고 thunk 미들웨어를 추가하고, Redux DevTools 통합을 하는 등의 작업을 수행합니다. 또한, 이름이 있는 옵션 매개변수를 사용하기 때문에 createStore보다 구성이 쉽습니다.
  • createSlice는 Immer 라이브러리를 사용하는 리듀서를 작성할 수 있게 해줍니다. 이를 통해 변이(mutating) JS 문법을 스프레이드(spreads) 없이도 불변성을 유지하며 업데이트할 수 있습니다. 또한, 각 리듀서에 대한 액션 생성자 함수를 자동으로 생성하고, 리듀서 이름에 기반하여 내부적으로 액션 타입 문자열을 생성합니다.

툴킷에서 제공하는 함수는 사용자에게 애플리케이션 코드를 간단히 작성할 수 있도록 지원합니다. 그리고 이 모든 도구는 이미 리덕스를 경험해본 사용자나 처음으로 RTK를 활용해서 프로젝트를 구성하는 신규 사용자 모두에게 동일한 혜택을 제공해야 한다는 기본적인 철학을 바탕으로 하고 있었습니다.

 

RTK 완전 정복: 7가지 기능으로 한번에 정리하기

Redux Toolkit(이하 RTK)는 어떻게 이전의 리덕스 코어 라이브러리가 가진 복잡함을 단순화시킬 수 있던 것일까요?

RTK에서 제공하는 7가지 주요 API를 예제와 함께 살펴보겠습니다.

1. configureStore()

가장 먼저 스토어를 구성하는 함수에 대해서 알아보겠습니다. configureStore는 리덕스 코어 라이브러리의 표준 함수인 createStore추상화한 것입니다. 더 좋은 개발 경험을 위해서 기존 리덕스의 번거로운 기본 설정 과정을 자동화하는 역할을 수행합니다. 아래처럼 간단히 작성하여 사용할 수 있습니다.

import { configureStore } from '@reduxjs/toolkit'
import rootReducer from './reducers'

const store = configureStore({ reducer: rootReducer })

위처럼 선언하면 기본 미들웨어로 redux-thunk를 추가하고 개발 환경에서 리덕스 개발자 도구(Redux DevTools Extension)를 활성화해줍니다. 이전에는 매번 프로젝트를 시작할 때마다 이런 설정을 직접 하는 불편한 과정이 있었다고 하니 개발 경험을 높이기 위해서 RTK가 어떤 접근을 했었는지 알 수 있는 대목입니다.

 

다음 예제는 configureStore를 사용한 전체적인 구성을 담고 있습니다.

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

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

const rootReducer = {
  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 함수는 reducermiddlewaredevToolspreloadedStateenchancer 정보를 전달합니다.

  • reducer: 리듀서에는 단일 함수를 전달하여 스토어의 루트 리듀서(root reducer)로 바로 사용할 수 있습니다. 또한 슬라이스 리듀서들로 구성된 객체를 전달하여 루트 리듀서를 생성하도록 할 수 있습니다. 이런 경우에는 내부적으로 기존 리덕스 combineReducers 함수를 사용해서 자동적으로 병합하여 루트 리듀서를 생성합니다.
  • middleware: 기본적으로는 리덕스 미들웨어를 담는 배열입니다. 사용할 모든 미들웨어를 배열에 담아서 명시적으로 작성할 수도 있는데요. 그렇지 않으면 getDefaultMiddleware를 호출하게 됩니다. 사용자 정의, 커스텀 미들웨어를 추가하면서 동시에 리덕스 기본 미들웨어를 사용할 수 있습니다.
  • devTools: 불리언값으로 리덕스 개발자 도구를 끄거나 켭니다.
  • preloadedState: 스토어의 초기값을 설정할 수 있습니다.
  • enchaners: 기본적으로는 배열이지만 콜백 함수로 정의하기도 합니다. 예를 들어 다음과 같이 작성하면 개발자가 원하는 store enhancer를 미들웨어가 적용되는 순서보다 앞서서 추가할 수 있습니다.
const store = configureStore({
  ...
  enhancers: (defaultEnhancers) => [reduxBatch, ...defaultEnhancers],
})
// [reduxBatch, applyMiddleware, devToolsExtension]

 

1-1. 리덕스 미들웨어 (Redux Middleware)

리덕스에서 미들웨어란 무엇일까요? 그리고 미들웨어는 어떤 역할을 할까요?

먼저 용어에 대해 정리해 보고자 합니다. 소프트웨어 공학에서 미들웨어란 운영 체제와 응용 소프트웨어 중간에서 조정과 중개의 역할을 수행하는 소프트웨어로 정의됩니다.

 

그렇다면 리덕스에서 미들웨어의 역할은 무엇일까요?

리덕스에서 미들웨어는 dispatch(이하 디스패치)된 액션이 리듀서에 도달하기 전 중간 영역에서 사용자의 목적에 맞게 기능을 확장할 수 있도록 돕습니다.

위의 소개한 Redux 전파 구조에서 Middleware가 추가된 모습입니다.

예를 들어 미들웨어로 redux-logger를 추가했다면 액션이 디스패치될 때마다 개발자 도구 콘솔에 로그가 찍히는 것을 생각해 볼 수 있습니다. 이처럼 개발자는 자신의 필요에 의해 미들웨어를 작성하여 원하는 목적을 달성할 수 있습니다.

출처: https://github.com/LogRocket/redux-logger

 

이외에도 개발 모드에서 일부 미들웨어는 특정한 역할을 기본적으로 수행합니다. 상태의 변형(mutation)을 감지하거나 직렬화, 즉 데이터를 다른 데이터 구조로 맞추어 가공하는 행위가 불가능한 값(non-serializable value)을 사용하는 실수를 방지할 수 있도록 경고해 줍니다.

2. createReducer()

createReducer는 상태에 변화를 일으키는 리듀서 함수를 생성하는 유틸 함수입니다. 내부적으로 immer 라이브러리 사용하여 mutative한 코드, 예컨대 state.todos[3].completed = true 형태로 작성해도 불변(immutable) 업데이트가 이루어지도록 로직을 간단히 할 수 있습니다.

그렇지 않으면 아래처럼 중첩된 모든 단계에서 복사 작업이 필요합니다. 이는 사용자의 실수로 원본 객체에 직접적인 변형을 일으키거나 얕은 복사가 이루어지는 등 다양한 사이드 이펙트를 발생시켜 애플리케이션이 예기치 않게 동작할 위험성이 있습니다.

그리고 무엇보다 코드가 길어집니다.

// 기존 스위치 문으로 이루어진 카운터 리듀서 함수입니다.
// 많은 보일러플레이트 코드와 에러를 발생시키기 쉬운 구조를 보여주고 있습니다.
function todosReducer(state = [], action) {
  switch (action.type) {
    case 'UPDATE_VALUE': {
      return {
        ...state,
        first: {
          ...state.first,
          second: {
            ...state.first.second,
            [action.someId]: {
              ...state.first.second[action.someId],
              fourth: action.someValue,
            },
          },
        },
      };
    }
    default: {
      return state;
    }
  }
}

// 하지만 createReducer 함수를 사용하면 아래처럼 간단히 작성할 수 있습니다.
const todosReducer = createReducer(state = [], (builder) => {
  builder.addCase('UPDATE_VALUE', (state, action) => {
    const {someId, someValue} = action.payload;
    state.first.second[someId].fourth = someValue;
  })
})

RTK에서 case reducer(이하 케이스 리듀서)가 액션을 처리하는 두 가지 방법으로 builder callback 표기법map object 표기법이 있습니다. 두 방법 모두 동일한 역할을 하지만 타입스크립트와의 호환성을 위해서는 builder callback 표기법이 더 선호됩니다.

2-1. Builder Callback 표기법

createReducer의 콜백 함수 인자로 주어지는 builder 객체는 addCaseaddMatcheraddDefaultCase라는 메서드를 제공합니다. 그리고 각 함수에서 액션을 리듀서에서 어떻게 처리할지를 정의할 수 있습니다.

각 라인마다 빌더 메서드를 나누어 호출하거나 체이닝(chaining) 형태로 작성할 수 있습니다.

// 각 라인마다 빌더 메서드를 나누어 호출합니다.
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 callback의 주요 메서드를 살펴보면 다음과 같습니다.

  • builder.addCase(actionCreator, reducer): 액션 타입과 맵핑되는 케이스 리듀서를 추가하여 액션을 처리합니다. addMatcher 또는 addDefaultCase 메서드 보다 먼저 작성되어야 합니다.
  • builder.addMatcher(matcher, reducer): 새로 들어오는 모든 액션에 대해서 주어진 패턴과 일치하는지 확인하고 리듀서를 실행합니다.
  • builder.addDefaultCase(reducer): 그 어떤 케이스 리듀서나 매처 리듀서도 실행되지 않았다면, 기본 케이스 리듀서가 실행됩니다.

다음 코드는 보다 일반적인 builder callback 용례를 담고 있습니다. 

const increment = createAction<number>('increment')
const decrement = createAction<number>('decrement')

function isActionWithNumberPayload(
  action: AnyAction
): action is PayloadAction<number> {
  return typeof action.payload === 'number'
}

const initialState = {
  counter: 0,
  sumOfNumberPayloads: 0,
  unhandledActions: 0,
};

const counterReducer = createReducer(initialState, (builder) => {
  builder
    .addCase(increment, (state, action) => {
      state.counter += action.payload
    })
    .addCase(decrement, (state, action) => {
      state.counter -= action.payload
    })
    .addMatcher(isActionWithNumberPayload, (state, action) => {})
    .addDefaultCase((state, action) => {})
})

</number></number></number>

 

2-2. Map Object 표기법

Map Object 표기법은 액션 타입 문자열을 ‘키’로 사용하는 객체를 받아서 케이스 리듀서에 맵핑합니다. 이는 builder callback 표기법보다 짧게 작성할 수 있다는 장점이 있습니다.

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

// 위 예제처럼 작성하거나
// 또는 'createAction'에서 생성된 액션 생성자(action creator)를
// 연산된 프로퍼티(computed property) 문법을 사용해서 바로 '키'로 사용할 수 있습니다.

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
})

아래는 map object 표기법을 사용하는 createReducer 함수의 인자를 차례로 나열한 것입니다.

  • createReducer(initialState, actionsMap, actionMatchers, defaultCaseReducer)
    • initialState: 리듀서가 최초로 호출되었을 때 사용될 상태 값입니다.
    • actionsMap: 액션 타입이 케이스 리듀서에 맵핑되어 있는 객체입니다.
    • actionMatchers: { matcher, reducer } 형태로 정의된 매처를 배열로 담습니다. 매칭된 리듀서는 순서대로 독립적으로 실행됩니다.
    • defaultCaseReducer: 그 어떤 케이스 리듀서나 매처 리듀서도 실행되지 않았다면, 기본 케이스 리듀서가 실행됩니다.

다음 코드는 보다 일반적인 map object 용례를 담고 있습니다. 

// matcher
const isStringPayloadAction = (action) => typeof action.payload === 'string'

const lengthOfAllStringsReducer = createReducer(
  // initialState
  { strLen: 0, nonStringActions: 0 },
  // actionsMap
  {
    /* [...]: (state, action) => {} */
  },
  // actionMatchers
  [
    {
      matcher: isStringPayloadAction,
      reducer(state, action) {
        state.strLen += action.payload.length
      },
    },
  ],
  // defaultCaseReducer
  (state) => {
    state.nonStringActions++
  }
💡 TypeScript를 고려한다면 대부분의 경우 builder callback 표기법을 권장합니다.

3. createAction()

기존 리덕스 코어 라이브러리에서 액션을 정의하는 일반적인 접근법은 액션 타입 상수와 액션 생성자 함수를 분리하여 선언하는 것이었습니다. RTK에서는 이러한 두 과정을 createAction 함수를 사용하여 하나로 결합하여 추상화했습니다.

// BEFOR
const INCREMENT = 'counter/increment'

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

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

const increment = createAction<number>('counter/increment')

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

 

3-1. 액션 콘텐츠 편집하기

일반적으로 액션 생성자는 단일 인자를 받아서 action.payload 값을 생성하지만 payload에 사용자 정의 값을 추가하고 싶은 경우가 있을 수 있습니다. 랜덤 아이디 값을 만들거나, 액션이 생성되는 시점을 넣는 행위 등이 여기에 해당합니다.

prepare callback 함수를 사용해서 원하는 값을 추가하고 플럭스 표준 액션(FSA) 형태로 반환할 수 있습니다.

import { createAction, nanoid } from '@reduxjs/toolkit'

const addTodo = createAction('todos/add', function prepare(text: string) {
  return {
    payload: {
      text,
      id: nanoid(),
      createdAt: new Date().toISOString(),
    },
  }
})

console.log(addTodo('Write more docs'))
/**
 * {
 *   type: 'todos/add',
 *   payload: {
 *     text: 'Write more docs',
 *     id: '4AJvwMSWEHCchcWYga3dj',
 *     createdAt: '2019-10-03T07:53:36.581Z'
 *   }
 * }
 **/

 

4. createSlice()

const alertSlice = createSlice({
  name: 'todos',
  initialState,
  reducers: {},
	extraReducers: (builder) => {}
});

리덕스 로직을 작성하는 표준 접근법은 createSlice를 사용하는 것에서 출발합니다.

앞서 소개해드린 createAction, createReducer 함수가 내부적으로 사용되며 createSlice에 선언된 슬라이스 이름을 따라서 리듀서와 그리고 그것에 상응하는 액션 생성자와 액션 타입을 자동으로 생성합니다. 따라서 createSlice를 사용하면 따로 createAction, createReducer를 작성할 필요가 없습니다.

공식 문서의 리덕스 스타일 가이드에 따르면 슬라이스 파일은 feature 폴더 안에서 상태 도메인 별로 나누어 정리하고 있습니다.

// features/todos/todosSlice.js

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

interface Item {
  id: string
  text: string
}

// 투두 슬라이스
const todosSlice = createSlice({
  name: 'todos',
  initialState: [] as Item[],
  reducers: {
    // 액션 타입은 슬라이스 이름을 접두어로 사용해서 자동 생성됩니다. -> 'todos/addTodo'
    // 이에 상응하는 액션 타입을 가진 액션이 디스패치 되면 리듀서가 실행됩니다.
    addTodo: {
      reducer: (state, action: PayloadAction<item>) => {
        state.push(action.payload)
      },
      // 리듀서가 실행되기 이전에 액션의 내용을 편집할 수 있습니다.
      prepare: (text: string) => {
        const id = nanoid()
        return { payload: { id, text } }
      },
    },
  },
})

const { actions, reducer } = todosSlice
export const { addTodo } = actions

export default reducer

 

4-1. extraReducers 

extraReducers는 createSlice가 생성한 액션 타입 외 다른 액션 타입에 응답할 수 있도록 합니다. 슬라이스 리듀서에 맵핑된 내부 액션 타입이 아니라, 외부의 액션을 참조하려는 의도를 가지고 있습니다.

const usersSlice = createSlice({
  name: 'users',
  initialState: { entities: [], loading: 'idle' },
  reducers: {},
  extraReducers: (builder) => {
    // 'users/fetchUserById' 액션 타입과 상응하는 리듀서가 정의되어 있지 않지만
    // 아래처럼 슬라이스 외부에서 액션 타입을 참조하여 상태를 변화시킬 수 있습니다.
    builder.addCase(fetchUserById.fulfilled, (state, action) => {
      state.entities.push(action.payload)
    })
  },
})

비동기 액션을 생성하는 createAsyncThunk 함수와의 조화를 생각해 볼 수 있는데요. 위 예제는 아래에서 이어서 다시 알아보겠습니다.

 

5. createAsyncThunk

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

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) => {})
  },
})

// 위에서 fetchUserById, 즉 thunk를 작성해두고
// 앱에서 필요한 시점에 디스패치 하여 사용합니다.

// ...
dispatch(fetchUserById(123))

꼭 서버와 통신이 이루어지는 구간에서만 사용되어야 하는 것은 아닙니다. 비즈니스 로직을 비동기 형태로 구현할 때에도 응용할 수 있습니다.

 

6. createSelector

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

// useSelector는 스토어에서 값을 조회합니다.
const users = useSelector((state) => state.users)

Reselect 라이브러리를 살펴보면 createSelector 함수가 memoization(이하 메모이제이션), 즉 이전에 계산한 값을 메모리에 저장하여 값이 변경됐을 경우에만 계산하도록 동작하는 것을 확인할 수 있었습니다. 이것은 아래와 같은 상황을 개선할 수 있습니다.

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

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

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

이러한 문제를 회피하기 위해서 createSelector를 사용하면 애플리케이션을 최적화할 수 있습니다.

const shopItemsSelector = state => state.shop.items
const taxPercentSelector = state => state.shop.taxPercent

// subtotal 값을 메모이제이션 합니다.
const subtotalSelector = createSelector(
  shopItemsSelector,
  items => items.reduce((subtotal, item) => subtotal + item.value, 0)
)

// 메모이제이션된 subtotal 값과 taxPercentSelector를 합성하여
// 새로운 값을 메모이제이션 합니다.
const taxSelector = createSelector(
  subtotalSelector,
  taxPercentSelector,
  (subtotal, taxPercent) => subtotal * (taxPercent / 100)
)

const totalSelector = createSelector(
  subtotalSelector,
  taxSelector,
  (subtotal, tax) => ({ total: subtotal + tax })
)

const exampleState = {
  shop: {
    taxPercent: 8,
    items: [
      { name: 'apple', value: 1.20 },
      { name: 'orange', value: 0.95 },
    ]
  }
}

console.log(subtotalSelector(exampleState)) // 2.15
console.log(taxSelector(exampleState))      // 0.172
console.log(totalSelector(exampleState))    // { total: 2.322 }

 

7. createEntityAdapter

createEntityAdapter함수는 정규화된 상태 구조, 즉 중복을 취소화하기 위해서 데이터가 구조화되고, 일관성이 보장된 구조에서 효율적인 CRUD를 수행하기 위해 미리 빌드된 리듀서 및 셀렉터를 생성하는 함수입니다. CRUD 함수를 따로 제공하고 있습니다.

다음 공식 예제에서 전체적인 사용법을 다루고 있습니다.

import {
  createEntityAdapter,
  createSlice,
  configureStore,
} from '@reduxjs/toolkit'

// Since we don't provide `selectId`, it defaults to assuming `entity.id` is the right field
const booksAdapter = createEntityAdapter({
  // Keep the "all IDs" array sorted based on book titles
  sortComparer: (a, b) => a.title.localeCompare(b.title),
})

const booksSlice = createSlice({
  name: 'books',
  initialState: booksAdapter.getInitialState({
    loading: 'idle',
  }),
  reducers: {
    // Can pass adapter functions directly as case reducers.  Because we're passing this
    // as a value, `createSlice` will auto-generate the `bookAdded` action type / creator
    bookAdded: booksAdapter.addOne,
    booksLoading(state, action) {
      if (state.loading === 'idle') {
        state.loading = 'pending'
      }
    },
    booksReceived(state, action) {
      if (state.loading === 'pending') {
        // Or, call them as "mutating" helpers in a case reducer
        booksAdapter.setAll(state, action.payload)
        state.loading = 'idle'
      }
    },
    bookUpdated: booksAdapter.updateOne,
  },
})

const {
  bookAdded,
  booksLoading,
  booksReceived,
  bookUpdated,
} = booksSlice.actions

const store = configureStore({
  reducer: {
    books: booksSlice.reducer,
  },
})

// Check the initial state:
console.log(store.getState().books)
// {ids: [], entities: {}, loading: 'idle' }

const booksSelectors = booksAdapter.getSelectors((state) => state.books)

store.dispatch(bookAdded({ id: 'a', title: 'First' }))
console.log(store.getState().books)
// {ids: ["a"], entities: {a: {id: "a", title: "First"}}, loading: 'idle' }

store.dispatch(bookUpdated({ id: 'a', changes: { title: 'First (altered)' } }))
store.dispatch(booksLoading())
console.log(store.getState().books)
// {ids: ["a"], entities: {a: {id: "a", title: "First (altered)"}}, loading: 'pending' }

store.dispatch(
  booksReceived([
    { id: 'b', title: 'Book 3' },
    { id: 'c', title: 'Book 2' },
  ])
)

console.log(booksSelectors.selectIds(store.getState()))
// "a" was removed due to the `setAll()` call
// Since they're sorted by title, "Book 2" comes before "Book 3"
// ["c", "b"]

console.log(booksSelectors.selectAll(store.getState()))
// All book entries in sorted order
// [{id: "c", title: "Book 2"}, {id: "b", title: "Book 3"}]

 

데이터 패칭과 상태 관리의 분리

리덕스 생태계에 데이터 패칭 및 캐싱을 위한 라이브러리가 등장하기 전까지는 리듀서를 사용해서 데이터 패칭을 직접 관리하는 경우가 일반적이었습니다. 공식 문서에서도 어떻게 패칭 로직을 작성해야 하는지 직접 가이드를 했었고, 지금도 그러한 형태로 리덕스를 사용하기도 합니다.

그러나 사용자들은 데이터 패칭 로직을 리덕스에서 직접 관리하는 것에 대해 의문을 가지게 되었고, 한편으로는 RTK가 보일러플레이트 코드를 최적화하긴 했지만 로직을 일일이 작성하는 방법은 여전히 피로도를 높였던 것으로 생각됩니다.

그러한 시점에 최근 프론트엔드 개발 생태계는 리덕스를 통한 상태 관리의 피로도 증가와 함께 불만의 목소리를 키워오고 있습니다. 그리고 훅 기반의 API 지원이 가속화되고 React Query, SWR 등 강력한 데이터 패칭과 캐싱 라이브러리를 사용하면서 리덕스 사용이 줄어드는 방향으로 프론트엔드 기술 트렌드가 변화하고 있습니다.

React Query, SWR과 같은 훅 기반의 데이터 패칭 라이브러리를 사용하기 시작하면서 리덕스를 통한 앱의 전역 상태 관리와 데이터 패칭의 역할이 서로 구분된 것 같습니다. 다행히 리덕스 개발팀에서도 이러한 커뮤니티의 요구에 맞추어 RTK에 데이터 패칭과 캐싱을 위한 솔루션인 RTK Query를 선보이면서 현재는 데이터 패칭에서는 이를 사용하도록 안내하고 있습니다.

소개에 따르면 RTK Query를 사용하는 데는 리덕스와 RTK에 대한 지식은 별도로 필요하지 않다고 합니다. 다만 전역 스토어 관리 기능, 예를 들어서 개발자 도구 사용 방법 같은 것은 확인해둘 필요가 있다고 합니다.

RTK Query는 RTK 1.6 버전부터 패키지에 함께 포함되어 릴리즈 되었습니다. 예제와 API를 살펴보니 기존 경험처럼 데이터 패칭의 로딩과 에러 상태를 나타내면서도 동시에 데이터 캐싱을 지원하고 있음을 확인할 수 있었습니다.

import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'
import { Pokemon } from './types'

// Define a service using a base URL and expected endpoints
export const pokemonApi = createApi({
  reducerPath: 'pokemonApi',
  baseQuery: fetchBaseQuery({ baseUrl: '<https://pokeapi.co/api/v2/>' }),
  endpoints: (builder) => ({
    getPokemonByName: builder.query<pokemon, string="">({
      query: (name) => `pokemon/${name}`,
    }),
  }),
})

// Export hooks for usage in functional components, which are
// auto-generated based on the defined endpoints
export const { useGetPokemonByNameQuery } = pokemonApi

</pokemon,>
import * as React from 'react'
import { useGetPokemonByNameQuery } from './services/pokemon'

export default function App() {
  // Using a query hook automatically fetches data and returns query values
  const { data, error, isLoading } = useGetPokemonByNameQuery('bulbasaur')
  // Individual hooks are also accessible under the generated endpoints:
  // const { data, error, isLoading } = pokemonApi.endpoints.getPokemonByName.useQuery('bulbasaur')

  // render UI based on data and loading state
}

import { configureStore } from '@reduxjs/toolkit'
// Or from '@reduxjs/toolkit/query/react'
import { setupListeners } from '@reduxjs/toolkit/query'
import { pokemonApi } from './services/pokemon'

export const store = configureStore({
  reducer: {
    // Add the generated reducer as a specific top-level slice
    [pokemonApi.reducerPath]: pokemonApi.reducer,
  },
  // Adding the api middleware enables caching, invalidation, polling,
  // and other useful features of `rtk-query`.
  middleware: (getDefaultMiddleware) =>
    getDefaultMiddleware().concat(pokemonApi.middleware),
})

// optional, but required for refetchOnFocus/refetchOnReconnect behaviors
// see `setupListeners` docs - takes an optional callback as the 2nd arg for customization
setupListeners(store.dispatch)

 

마치며

지금까지 Redux에 등장배경과 RTK(Redux Toolkit)에 대해 자세히 알아봤습니다.

 

꽤 오랜만에 긴 호흡의 포스팅을 진행한 거 같습니다. 아직 실무 경험이 없는 저에게 상태 관리 라이브러리 선택은 항상 어렵게 다가옵니다. 처음 프론트 엔드를 시작했을 땐 리드 개발자의 추천으로 Recoil을 사용했었고, 그 이후엔 Zustand 마스코트에 빠져 Zustand를 사용했습니다. 물론 최근까지 진행하고 있는 개인 작업에는 Zustand를 애용하고 있습니다. 

우리 Zustand도 많이 사랑해주세요 ~

저에게 Redux는 항상 큰 벽으로 다가왔습니다. Recoil이나 Zustand가 분명 더 편하고, Redux를 충분히 대체할 수 있는거같은데 왜 사용해야할까, 굳이 배워야 할까 하는 의문에 쉽사리 적용해보지 못했습니다. 

 

하지만 최근에 진행하게 된 프로젝트에서 Redux를 상태관리 라이브러리로 채택하고 난 후 리덕스에 대해 깊게 공부해보는 시간을 가졌고, 부족하지만 정리한 내용을 공유할 수 있으면 좋겠다는 생각에 오랜만에 포스팅을 진행했습니다.

 

프로젝트의 시작을 리덕스로 한 이유는 크게 3가지 입니다. 

1. 실무에서 내가 자주 사용하는 상태관리 라이브러리를 사용하지 않을 가능성이 있습니다. 그렇기에 실무에서 가장 많이 사용하는 라이브러리중에 가장 사용하기 꺼려지는 라이브러리인 리덕스를 선택했습니다.

2. 리덕스 커뮤니티는 Recoil이나 Zustand와 비교할 수 없을 정도로 방대하고, 충분히 농익었습니다.

3. 리덕스로 프로젝트를 실제 배포하고 유지보수까지 한 시점에서 Zustand와 Recoil로의 마이그레이션을 논의해보고 싶었습니다. 과연 기존 프로젝트의 코드를 바꿔서 까지 Zustand와 Recoil을 도입해야할 이유가 있을까, 있다면 그 근거는 무엇인가에 대해 논의해보고 싶었습니다. 

 

위와 같은 이유로 Redux를 도입하고, 실제 작업을 이어나가며 Redux에 대한 안좋은 선입견이 꽤나 깨진거같습니다. 특히 초기 작업을 제외하면 Zustand를 사용했을 때보다 커스텀하기 수월하다고 느끼는 지점도 여러부분 있었습니다. 무엇보다 방대한 Redux 커뮤니티에는 언제나 제가 원하는 레퍼런스가 있다는게 매력적으로 다가왔습니다. 

 

아직 한번도 리덕스를 사용하여 개발을 진행해보지 않으신 분들은 이번 기회에 Redux를 사용하여 작업을 진행해보시면 좋을거같습니다. 특히 스스로 Redux에서 다른 상태관리 라이브러리로 마이그레이션할 납득할 만한 이유를 찾는다면 실제 현업에서도 팀원을 설득하여 마이그레이션을 주도할 수 있는 경험을 가질 수 있지 않을까 생각됩니다. 

 

참고

https://ko.redux.js.org/introduction/why-rtk-is-redux-today/ 

 

Redux Toolkit이 오늘날 Redux를 사용하는 방법인 이유 | Redux

소개 > RTK가 현재의 Redux인 이유: RTK가 어떻게 Redux 코어를 대체하는지에 대한 상세 내용

ko.redux.js.org

https://blog.hwahae.co.kr/all/tech/6946

 

Redux Toolkit (리덕스 툴킷)은 정말 천덕꾸러기일까?

Redux Toolkit 최근 훅 기반의 API 지원이 가속화되고 React Query, SWR 등 강력한 데이터 패칭과 캐싱 라이브러리를 사용하면서 리덕스 사용이 줄어드는 방향으로 프론트엔드 기술 트렌드가 변화하고 있

blog-wp.hwahae.co.kr