Please enable JavaScript to view the comments powered by Disqus.

Redux Toolkit을 활용한 React 상태 관리

Redux Toolkit의 특징

Redux Toolkit은 Redux의 공식 개발 도구다. Redux의 액션 생성자, 리듀서 자체는 단순한 함수인 데다 미들웨어를 추가할 수 있어서 관련 라이브러리와 도구가 매우 많고 그만큼 개발 방식이 다양하다. 처음 접하는 사람들은 어떤 방식을 실무 개발에 적용하면 좋을지 시간을 들여서 조사해 봐야 한다. 하지만 그만큼 시행착오가 많이 발생하며, 사용하다 보면 Redux가 좋긴 한데 액션 및 리듀서를 관리하기 위한 코드의 양이 너무 늘어난다는 불만도 자연스레 생긴다. 그러면서 자연스레 더 간단한 코드로 상태 관리를 할 수 있는 mobx같은 다른 라이브러리로 눈이 돌아가기도 한다.

그래서 Redux에서 직접 개발 도구를 만들어서 공개한 모양이다. 그들은 이 도구는 결코 표준이 아니며, 그들이 생각하기에 효율적인 개발 방법이라고 말하고 있다. 하지만 직접 API를 살펴보고 코드를 작성해 본 결과 나름 코드의 양을 줄일 수 있는 데다 생각의 전환까지 가져올 수 있었다. Redux를 사용하고 있고 뭔가 아쉬움을 느끼고 있는 사람이라면 한 번 살펴봐도 좋을 것 같다.

외부 라이브러리의 도입

먼저 알려 둘 사실은 Redux Toolkit에서는 이름있는 Redux 관련 라이브러리를 내부적으로 도입해서 사용하고 있다는 점이다. 리듀서 생성 API에는 immer를, 셀렉터 생성 API에는 reselect를 사용하고 있다. 저 라이브러리에 익숙한 사람이라면 Redux Toolkit에서 제안하는 상태 관리 방식을 더 자연스럽게 받아들일 수 있을 것이다.

action

createAction API를 사용해서 액션 생성자 함수를 만든다.

const increment = createAction("counter/increment");

let action = increment(); // returns { type: 'counter/increment' }

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

위의 코드에서 볼 수 있듯이 createAction에는 기본적으로 타입 문자열만 제공하면 된다. 그리고 만들어진 액션 생성자의 파라미터는 그대로 payload 필드에 들어간다.

만약 리턴되는 액션 객체에 더 손을 보고 싶다면, 콜백 함수를 createAction의 두 번째 파라미터로 전달하면 된다.

const addTodo = createAction("todos/add", function prepare(text) {
  return {
    payload: {
      text,
      createdAt: new Date().toISOString()
    }
  };
});

addTodo("Write more docs"); // 아래의 객체를 리턴한다.
/**
 * {
 *   type: 'todos/add',
 *   payload: {
 *     text: 'Write more docs',
 *     createdAt: '2019-10-03T07:53:36.581Z'
 *   }
 * }
 **/

콜백 함수 안에서는 액션 생성자 함수의 파라미터로 전달받지 않은 데이터를 추가할 수 있다. 이때 리턴되는 객체는 반드시 FSA 형태를 따라야 한다.

Flux Standard Action

Redux Toolkit에서는 액션 객체의 형태로 FSA(Flux Standard Action)를 강제한다.

{
  type: 'number/increment',
  payload: {
    amount: 1
  }
}

객체는 액션을 구분할 고유한 문자열을 가진 type 필드가 반드시 있으며, payload 필드에 데이터를 담아 전달한다. 그 외에 meta, error 필드를 가질 수도 있다. (더 자세한 사항은 Flux 홈페이지 참조)

reducer

createAction만 보면 조금 심드렁할 수도 있다. 하지만 reducer부터 조금씩 차이가 드러나기 시작한다. Redux Toolkit에서 리듀서 작성은 createReducer API를 사용한다. 그리고 앞서 소개한 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
});

코드에서도 확인할 수 있겠지만, reducer 함수에서 흔히 보이던 switch 문이 사라졌다! 이제 더는 쓸데없는 default 케이스를 작성할 필요가 없다.

createReducer 함수는 첫 번째 파라미터로 초기 상태 값 객체(initialState)를, 두 번째 파라미터로 리듀서 맵 객체를 요구한다. switch 문의 case 문자열이 리듀서 맵의 필드 값이 된 형태다. 리듀서 함수를 구현할 때 사용하는 switch 문법에 대해서 논쟁이 있었는데, 이 라이브러리에서는 완전히 없애버렸다.

그리고 리듀서 맵의 필드에 액션 생성자에 전달한 문자열을 넣어도 되지만, 액션 생성자 함수를 직접 넣어도 된다. 이것이 가능한 이유는 createAction이 리턴하는 액션 생성자 함수의 toString(Object.prototype.toString) 메소드를 오버라이딩했기 때문이다.

`${increment}`; // 'increment'

이 방식 덕분에 액션의 타입 문자열을 할당한 변수를 별도로 관리할 필요가 없다. 타입 문자열을 전달할 필요 없이 액션 생성자를 직접 전달하면 되기 때문이다. createReducer 함수는 리듀서 맵으로 코드의 양을 줄였고, 타입 변수를 관리할 필요를 없애서 또 코드의 양을 줄인 셈이다.

immer를 도입한 Redux Toolkit

리듀서 함수는 내부적으로 immer의 produce를 사용한다. 그래서 리듀서 함수에서 새로운 상태(state) 객체를 리턴할 필요가 없다. 대신 상태 값을 직접 변경하는 방식으로 코드를 작성하면 된다.

const todoReducer = createReducer([], {
  ["addTodo"]: (state, action) => {
    state.push(action.payload);
  }
});

불변성을 유지하기 위해 Object.assign 으로 새 객체를 만들거나, Array.prototype.map 등으로 새 배열을 만들거나, 심지어 immutable.js 같은 라이브러리로 상태 객체를 래핑하는 등의 작업을 할 필요가 없다. 저 함수 안에서만 저런 문법이 가능하다는 사실을 항상 인지하고 있기만 하면 된다.

action + reducer → slice

Redux Toolkit은 액션이나 리듀서 외에도 다른 방식으로 상태를 관리할 수 있는 도구를 제공한다. 그것은 createSlice API로 액션, 리듀서를 한 번에 만드는 것이다.

let todoId = 0;

const todosSlice = createSlice({
  // 액션 타입 문자열의 prefix로 들어간다. ex) "todos/addTodo"
  name: "todos",

  // 초기값
  initialState: [],

  // 리듀서 맵
  reducers: {
    // 리듀서와 액션 생성자가 분리되어 있다.
    addTodo: {
      // 리듀서 함수
      reducer: (state, action) => {
        state.push(action.payload);
      },

      // createAction 함수의 두번째 파라미터인 콜백 함수에 해당한다.
      prepare: (text: string) => ({
        payload: {
          id: todoId++,
          text
        }
      })
    },

    // 리듀서와 액션 생성자 함수가 분리되어 있지 않다.
    // removeTodo 액션은 파라미터가 payload에 바로 할당된다.
    removeTodo: (state, action) => {
      state.splice(
        state.findIndex(item => item.id === action.payload.id),
        1
      );
    }
  }
});

createSlice는 초기값, 리듀서, 액션을 하나의 객체에 담아 전달받는다. 그렇게 만든 슬라이스 객체에서 액션과 리듀서는 아래와 같이 가져올 수 있다.

const { addTodo, removeTodo } = todosSlice.actions;
const { reducer } = todosSlice;

createSlice를 사용하면 액션을 생성하고, 그 액션을 리듀서 맵에 전달할 필요도 없이 한 번에 만들 수 있다. 별도로 작성하는 것보다 가독성은 조금 떨어지긴 하지만 코드의 양을 더 줄이면서 간단하게 리덕스 상태 관리를 구현할 수 있다.

create “slice”?

slice는 상태(state) 트리 구조에서 리듀서 함수 1개를 가리킨다고 한다. 그런데 왜 slice, ‘얇은 조각’이라는 이름을 붙였을까?

앱의 규모가 커지면 리듀서 액션의 덩치도 자연스럽게 커지고 리듀서 함수 1개의 길이가 수백 라인을 넘어가 버린다. 코드의 복잡도가 높아져 테스트 코드가 없으면 건드리기 불안할 정도가 되곤 한다. 소프트웨어 개발에 있어 사실 그런 일은 불가피하긴 하다. slice라는 단어를 사용한 이유는 그래도 앱의 로직을 최대한 분리하고 작은 덩치로 유지하자는 철학을 담으려는 목적이 아닌가 한다.

보통 아래에 있는 트리 구조와 같이 유형별로 소스를 구분하는 방법을 많이 사용할 것이다. 컴포넌트는 components 폴더 아래에, 리덕스 관련 코드는 모두 store 폴더에 아래에 두는 식이다.

.
├── api (API 모듈)
├── components (일반 켬포넌트)
├── constant (상수)
├── models (데이터 모델 변수, 타입)
├── pages (페이지 컴포넌트)
├── store  (Redux 관련 코드)
├── styles (스타일시트)
└── utils (유틸리티 모듈)

그런데 Redux Toolkit 개발팀에서 공개한 공식 템플릿(cra-template-redux)에서는 독특한 방식의 폴더 구조를 제안하고 있다. features 라는 폴더 안에 기능별로 폴더를 만든 후 그 안에 React 컴포넌트와 스타일시트, Redux 구현 등 관련 소스를 모두 같은 곳에 위치시키는 방법이다.

.
├── api
│   └── githubAPI.js
├── components
│   └── Heading.js
├── features
│   ├── counter
│   │   ├── Counter.css
│   │   ├── Counter.js
│   │   └── counterReducer.js
│   ├── github
│   │   ├── RepoDetail.js
│   │   └── repoDetailSlice.js
│   └── todos
│       ├── Todos.js
│       └── todosSlice.js
...

components, styles 폴더에는 공용 컴포넌트, 글로벌 스타일, 믹스인처럼 유틸리티 성격을 가진 모듈을 두고, 실제 기능과 관련된 소스는 모두 features 폴더 아래에 주제별로 모으는 식이다. 이를 보면 slice라는 단어는 리듀서 뿐만 아니라 기능 또한 작은 덩치로 가볍게 유지하자는 의미를 담고 있을 수 있겠다는 생각이 든다.

좋은 방식으로 보이지만, 앱의 규모가 커졌을 때 무엇이 더 좋은 방식일지는 역시 직접 경험해 봐야 알 것 같다. 리듀서의 수가 너무 많아져서 더 복잡하게 느껴질 것 같기도 하고, 관심사 분리 원칙을 더 확실하게 지켜서 사이드 이펙트가 덜 발생하고 레거시 코드 분석이 그나마 더 쉬워질 수도 있을 것 같기도 하다. 역시 코드 관리 방식은 팀과 개인의 선택 문제다.

selector

셀렉터는 리덕스에서 state를 기반으로 새로운 값을 리턴하는 함수를 말한다. 예를 들어 할 일 목록에서 현재 상태가 ‘완료’로 표시된 데이터만 필터링해서 리턴하는 것을 말한다.

Redux Toolkit의 createSelector API는 reselect 라이브러리의 그것과 같다. 이 API는 새로운 상태 추출에 사용하는 값이 변경되지 않으면 다시 실행되지 않는다. 즉 메모이제이션(memoization1)을 지원한다.

아래의 코드는 현재 활성화된 탭에 맞는 데이터 배열을 리턴하는 셀렉터 코드다.

const activeListSelector = createSelector(
  state => state.events.live, // 상태 1 리턴 함수
  state => state.events.closed, // 상태 2 리턴 함수
  state => state.events.tab, // 상태 3 리턴 함수

  // 상태 1, 2, 3이 차례로 들어간다
  (liveEvents, closedEvents, tab) => {
    switch (tab) {
      case "LIVE":
        return liveEvents;

      case "CLOSED":
        return closedEvents;

      default:
        return [];
    }
  }
);

createSelector를 호출할 때 파라미터의 수에는 제한이 없지만, 마지막에는 상태 객체를 리턴할 콜백 함수가 있어야 한다. 마지막 콜백 함수의 파라미터에는 앞선 파라미터에서 리턴한 객체가 차례대로 위치한다. activeListSelectorstate.events.live, state.events.closed, state.events.tab 중 어느 하나의 값이 바뀌어야 다시 실행되어서 새로운 값이 적용되며, 그렇지 않으면 다시 실행되지 않는다. 이렇게 만든 셀렉터는 React 컴포넌트에서 아래와 같은 방식으로 사용한다.

import React from "react";
import { useSelector } from "react-redux";

export default function EventList() {
  const activeTabEvents = useSelector(activeTabEventsSelector);

  return activeTabEvents.map((eventData, i) => (
    <span key={i}>{eventData.title}</span>
  ));
}

store

액션, 리듀서를 만들었으니 이제 스토어 객체를 만들어서 React 컴포넌트 트리에 제공해야 한다. Redux Toolkit은 Redux의 createStore를 활용한 configureStore API를 제공한다.

import {
  configureStore,
  combineReducers, // redux의 그것과 같다.
  getDefaultMiddleware
} from "@reduxjs/toolkit";
import todosSlices from "src/features/todos/todosSlice";
import counter from "src/features/counter/counterReducer";

const rootReducer = combineReducers({
  counter, // createReducer로 만든 리듀서 객체
  todos: todosSlices.reducer // createSlice로 만든 slice 객체가 가진 reducer
});

const store = configureStore({
  reducer: rootReducer
});

combineReducers는 Redux에서 사용하고 있는 것과 같다. configureStore 함수에 필수적으로 필요한 값은 reducer필드다. middleware, preloadedState, enhancers 등의 사용 방법은 문서를 참조하면 될 것이다.

그리고 configureStoremiddleware 필드를 전달하지 않으면 기본적으로 제공되는 미들웨어가 있다. 그 미들웨어들을 가져오는 API가 getDefaultMiddleware다.

const middleware = getDefaultMiddleware();
expect(middleware).toEqual([
  thunk,
  immutableStateInvariant, // development 모드에서만 사용됨
  serializableStateInvariant // development 모드에서만 사용됨
]);

기본 미들웨어로는 redux-thunk를 사용하며 개발 모드에서만 redux-immutable-state-invariant, serializable-state-invariant-middleware 미들웨어가 추가된다. 만약 비동기 액션 처리에 redux-thunk를 사용하지 않거나 redux-saga 등의 다른 미들웨어가 필요하다면 아래와 같이 설정을 수정해야 한다.

import logger from "redux-logger";

const store = configureStore({
  reducer: rootReducer,
  middleware: [...getDefaultMiddleware(), logger]
});

TypeScript 설정

Redux Toolkit은 타입스크립트를 지원한다. 그리고 타입을 사용하면 액션을 호출하고 상태를 가져올 때 버그를 발생할 가능성을 줄여준다.

slice

initialState에 타이핑을 추가한다.

interface ITodo {
  id: number,
  text: string,
  isDone: boolean,
}

const initialState: ITodo[] = []

const todosSlice = createSlice({
  initialState: [],
	// ...
})

슬라이스에서는 리듀서의 리턴 타입으로 payload에 어떤 데이터를 넣어야 하는지 지정할 수 있다. redux-toolkit에서 제공하는 PayloadAction 타입을 사용하면 된다.

const todosSlice = createSlice({
	reducers: {
    removeTodo: (state, { payload }: PayloadAction<{ id: number }>) => {
      // ...
    },
	}
	// ... 
})

root reducer

선언한 슬라이스를 기반으로 전체 상태 트리 타입을 가져올 수 있다. 타입스트립트의 유틸리티 타입인 ReturnType을 사용한다.

import todosSlices from 'features/todo/todoSlice'

const rootReducer = combineReducers({
  todo: todosSlices.reducer,
})

declare global {
  type IRootState = ReturnType<typeof rootReducer>
}

export default rootReducer

더미 스토어 인스턴스로 스토어 관련 타입 선언

스토어 인스턴스, dispatch, getState 함수의 타입을 만들기 위해서 Provider 에 전달하는 인스턴스 대신 더미 인스턴스를 만들어서 타입을 선언했다. createStore 모듈이 별도로 있어서 사용한 트릭인데, 다른 방법을 사용해도 상관없다.

// 타이핑을 위한 더미 스토어 객체. 실제로 사용하지는 않는다.
const dummyStore = configureStore({
  reducer: rootReducer,
})

declare global {
  type IStore = typeof dummyStore
  type IAppDispatch = typeof dummyStore.dispatch
  type IGetState = typeof dummyStore.getState
}
export const sampleSelector = createSelector(
  (state: IRootState, getState: IGetState) => state.todo,
  (todo) => {
    // ...
  }
)

useSelector 훅을 대신할 useTypedSelector

함수형 컴포넌트 안에서 Redux state 객체를 가져오는 useSelector 훅의 파라미터는 root state다. 거기에 아래처럼 일일이 IRootState로 파라미터를 붙여도 되지만, 그렇게 하지 않아도 되도록 react-redux 에서 헬퍼 타입을 제공한다.

import { TypedUseSelectorHook, useSelector } from 'react-redux'

// useSelector hook 대신 사용. useSelector 함수의 파라미터에 타입을 지정하지 않아도 된다.
export const useTypedSelector: TypedUseSelectorHook<IRootState> = useSelector

예제 프로젝트

startkits/next-redux-ts-starter at master · rhostem/startkits

참고 자료


  1. 메모이제이션은 컴퓨터 프로그램이 같은 계산을 반복해야 할 때, 이전에 계산한 값을 메모리에 저장함으로써 같은 계산의 반복 수행을 제거하여 프로그램 실행 속도를 빠르게 하는 기술이다. 동적 계획법의 핵심이 되는 기술이다. 메모아이제이션이라고도 한다.(출처 - 위키백과)