Redux Toolkit으로 Redux 사용하기

Chani·2023년 3월 17일
1
post-thumbnail

What is Redux ?

ReduxFlux 아키텍쳐의 구현체 입니다.
Redux 공식 홈페이지에서는 Redux를 A Predictable State Container for JS Apps 라고 표현하고 있습니다. React와 함께 Redux를 사용하는 경우가 많지만 정확히는 React만을 위한 라이브러리는 아닙니다.

React의 상태(State) 중 전역적으로 사용해야하는 상태를 Redux를 통해 관리하는 구조라고 할 수 있습니다.

Redux is complicated

여러가지 장점을 주는 Redux에게도 큰 단점이 존재했는데 그것은 다음과 같습니다.

  1. Configuring a Redux store is too complicated
  2. I have to add a lot of packages to get Redux to do anything useful
  3. Redux requires too much boilerplate code

이러한 이유로 Redux를 사용하기 위한 진입장벽이 높았고, 생산성도 좋지 못했습니다.

그래서 Redux Toolkit이 탄생하였습니다.

Redux Toolkit에서는 redux-actions, redux-thunk, immer, reselect 등의 여러가지 라이브러리 기능을 모두 지원합니다.

예제들을 통해 어떻게 Redux Toolkit으로 Redux를 쉽게 사용하는지 알아보도록 하겠습니다.

ConfigureStore

ConfigureStore에서는 reducer, middleware, devTools, preloadedState 등을 설정 할 수 있습니다. reducer만 필수이고 나머지는 optional 입니다.

import { configureStore } from "@reduxjs/toolkit";

export default configureStore({
  reducer: {},
});

confiureStore를 만들었다면 <App />Provider로 감싸줍니다.
redux의 상태를 App전체에서 사용해야하기 때문에 가장 상단의 AppProvider를 감싸주어야 합니다.

//...
import { Provider } from 'react-redux';
import store from './app/store';

// ...
root.render(
  <React.StrictMode>
    <Provider store={store}>
      <App />
    </Provider>
  </React.StrictMode>
);

CreateSlice

initialState, action, reducer 를 한번에 정의합니다.

import { PayloadAction, createSlice } from "@reduxjs/toolkit";

export const countSlice = createSlice({
  name: "count",
  initialState: 0,
  reducers: {
    add: (state, actions: PayloadAction<number>) => {
      return state + actions.payload;
    },
  },
});

// this is for dispatch
export const { add } = countSlice.actions;

// this is for configureStore
export default countSlice.reducer;

count라는 상태는 기본값(initialState)0이고, 한가지 액션 add를 취할 수 있습니다.

createSlice를 사용하여 리듀서와 액션을 모두 한번에 정의해주었으니 이걸 나누어서 configureStore에 직접 reducer를 넣어주고 사용하는 컴포넌트에서는 action을 가져와서 사용해야합니다.

import { configureStore, combineReducers } from "@reduxjs/toolkit";

import count from "./ducks/count";

const ReducerRoot = combineReducers({
  count,
});

const store = configureStore({
  reducer: ReducerRoot,
});

export default store;
export type RootState = ReturnType<typeof ReducerRoot>;

위와 같이 combineReducers 를 활용하여 configureStorereducercount 를 넣어줍니다.
원래라면 각 reducer마다 어떤 데이터 타입을 사용하는지 명세해야하고, reducer 의 개수가 늘어나는 경우 configureStore가 비대해지지만 combineReducers를 활용하면 이를 간단하게 해결할 수 있습니다.

useSelector

전역상태 count를 늘리고 줄일 수 있는 Test Component를 만드려합니다.

먼저 count 값을 가져와야합니다 !!

import { useSelector } from "react-redux";
import { RootState } from "./store";

const Test = () => {
  const count = useSelector((state: RootState) => state.count);

  return (
    <div>
      <p>{count}</p>
  );
};

export default Test;

현재 Test Component에서는 count의 값을 가져오고 있습니다.
미리 선언해준 RootState 타입을 활용하여 state에 count가 있다는 사실도 코드상으로 확인할 수 있습니다.

지금은 statecount 그대로 사용했지만 useSelector는 전역상태를 해당 컴포넌트에 맞게 serialize 하거나 필터링하는데도 사용할 수 있습니다.

예를 들어 다음과 같은 메시지 객체들이 있다고 가정해봅시다.

[
	{"subscribed": true, "message": "welcome !", createdAt: '2022-02-13'},
	{"subscribed": false, "message": "LGTM :)", createdAt: '2022-02-14'},
	{"subscribed": true, "message": "Red Kiwi !", createdAt: '2022-02-15'},
]

해당 데이터 중 subscribe가 된 객체만 사용한다고 하면 다음과 같이 코드를 작성하면 됩니다.

const chats = useSelector(
  (state) => state.message.filter(msg => msg.subscribed)
);

Dispatch

이제 count값을 바꿔봅시다 !
Flux 패턴에 따르면 모든 ActionDispatcher를 거져 Store를 변경해야합니다.

Action이 어떤 동작을 하는지는 createSlice에서 정의해주었으니 Test Component에서 사용해봅시다 !

import { useDispatch, useSelector } from "react-redux";

import { RootState } from "./store";
import { add } from "./ducks/count";

const Test = () => {
  const count = useSelector((state: RootState) => state.count);
  const dispatch = useDispatch();

  return (
    <div>
      <p>{count}</p>
      <div>
        <button onClick={() => dispatch(add(1))}>+1</button>
        <button onClick={() => dispatch(add(5))}>+5</button>
        <button onClick={() => dispatch(add(-1))}>-1</button>
        <button onClick={() => dispatch(add(-5))}>-5</button>
      </div>
    </div>
  );
};

export default Test;

버튼이 4개가 있고 각각 1, 5를 더해주고 빼주는 기능을 합니다.

작동 화면

잘 작동하는 모습입니다 🙂

createAsyncThunk

지금까지는 모두 동기적인 작업이었습니다.

만약 상태가 서버의 상태에 따라 달라지고 따라서 상태가 변경되는데 시간이 걸리는 비동기적으로 작동해야한다면 어떻게 해야할까요?

createAsyncThunkAction의 비동기 작업을 위해 만들어졌습니다.

PromisePending, fulfilled, reject 된 경우에 각각에 대한 상태 관리를 reducer에서 가능하도록 합니다.

const requests = async ({
  delay,
  count,
}: {
  delay: number;
  count: number;
}): Promise<number> => {
  console.log(`request :: delay : ${delay} count : ${count}`);
  return await new Promise((resolve, reject) => {
    setTimeout(() => {
      if (Math.random() * 5 < 1) {
        reject("Error");
      }
      resolve(count);
    }, delay);
  });
};

export { requests };

fetch 요청과 같은 비동기 함수를 가정하기 위해 delayms 후에 count만큼 값을 반환해주는 함수를 작성하였습니다. 추가로 요청이 에러를 응답하는 상황도 함께 보기 위해 20%reject 되도록 하였습니다.

이제 Thunk를 어떻게 작성하는지 알아봅시다 !!

export const sleepAndAdd = createAsyncThunk<
  number,
  { delay: number; count: number }
>("count/sleepAdd", async ({ delay, count }) => {
  const add = await requests({ delay, count });
  return add;
});

TypeScript에서 createAsyncThunk 함수는 제네릭으로 타입을 지정해주어야 합니다.
제네릭의 첫번째 인자는 어떤 타입을 반환할건지, 두번째 인자는 어떤 타입을 패러미터로 받을 것인지, 세번째 인자는 optional 인데 thunkApi field type을 넣어주게 됩니다.
위 코드같은 경우 number 타입을 반환하고, 인자로 {delay: number, count: number}를 받는 함수가 됩니다.

createAsyncThunk 함수의 인자는 첫번째는 action의 타입, 두번째 인자는 callback function입니다. 여기에서 이야기하는 action의 타입이라는 것은 타입스크립트의 타입은 아닙니다.

redux의 모든 action은 TYPE이라는 것으로 구분되는데, 기존에 만들어주었던 동기적인 action들은 createSlice로 만들었기 때문에 자동적으로 TYPE이 생성되어 들어간 것입니다.
내부적으로는 ‘${name}/${action_name}’ 으로 TYPE이 자동 생성된다고 합니다.

이제 해당 Thunk가 실행될 때 각각의 상황에서 어떻게 동작하는지 정의해주어야 합니다.

export const countSlice = createSlice({
  name: "count",
  initialState: { isLoading: false, value: 0 },
  reducers: {
    add: (state, actions: PayloadAction<number>) => {
      return {
        value: state.value + actions.payload,
        isLoading: state.isLoading,
      };
    },
  },
	extraReducers: (builder) => {
    builder.addCase(sleepAndAdd.pending, (state) => {
      state.isLoading = true;
    });
    builder.addCase(sleepAndAdd.fulfilled, (state, actions) => {
      state.isLoading = false;
      state.value += actions.payload;
    });
    builder.addCase(sleepAndAdd.rejected, (state) => {
      state.isLoading = false;
      console.log("20% 확률로 rejected !!");
    });
  },
});

비동기 함수가 추가됨에 따라 로딩이라는 개념도 생겼기 때문에 먼저 initialState를 변경해줍니다.

이후에는 extraReducers에 위와같이 정의해줍니다. pending 상태와 fulfilled 상태인 경우를 정의해주었는데, pending인 경우는 isLoadingtrue로 바꿔주고, fulfilled인 경우는 isLoadingfalse로 바꾸고, valuepayload만큼 더해줍니다.

이때 여기에서 말하는 payloadcreateAsyncThunk에서 정의해주었던 리턴값을 이야기합니다.
따라서 위 코드에서 payload의 타입은 number가 됩니다.

reject 된 경우는 console.log로 오류 발생을 기록하고 isLoadingfalse로 바꿔줍니다.

///...

export type AppDispatch = typeof store.dispatch;

비동기 액션을 dispatch 하기위해서는 먼저 useDispatch에 제네릭으로 타입을 명시해주어야 합니다.

configureStore가 있던 store.ts 로 돌아가 AppDispatch type을 명시해줍니다.

import { useDispatch, useSelector } from "react-redux";

import { RootState, AppDispatch } from "./store";
import { add, sleepAndAdd } from "./ducks/count";

const Test = () => {
  const count = useSelector((state: RootState) => state.count.value);
  const isLoading = useSelector((state: RootState) => state.count.isLoading);
  const dispatch = useDispatch<AppDispatch>();

  const asyncBtnHandler = async () => {
    await dispatch(sleepAndAdd({ delay: 1000, count: 1 }));
  };

  return (
    <div>
      {!isLoading && <p>{count}</p>}
      <div>
        <button onClick={() => dispatch(add(1))}>+1</button>
        <button onClick={() => dispatch(add(5))}>+5</button>
        <button onClick={() => dispatch(add(-1))}>-1</button>
        <button onClick={() => dispatch(add(-5))}>-5</button>
        <button onClick={asyncBtnHandler}>sleep and + 1</button>
      </div>
    </div>
  );
};

export default Test;

이제 모든 준비가 완료되었습니다. countisLoadinguseSelector로 받아줍니다.
비동기 액션이 실행될 함수도 만들어주고, 로딩중에는 count가 보이지 않도록 구현해줍니다.

작동 화면

잘 작동하는 것을 확인할 수 있고, 20% 확률로 reject 되는 경우도 잘 처리하고 있습니다.

비동기 실행 중엔 추가 요청 막기

지금은 비동기 버튼을 누르는대로 요청이 마구마구 보내지게 됩니다.
요청이 보내지는 도중에 추가적인 요청을 막으려면 어떻게 해야할까요 ?

export const sleepAndAdd = createAsyncThunk<
  number,
  { delay: number; count: number }
>(
  "count/sleepAdd",
  async ({ delay, count }) => {
    const add = await requests({ delay, count });
    return add;
  },
  {
    condition: (arg, { getState }) => {
      const { count } = getState() as any;
      if (count.isLoading) {
        alert("이미 요청이 진행중입니다.");
        return false;
      }
    },
  }
);

createAsyncThunk 는 실은 3번째 인자가 있습니다. 위와 같이 3번째 인자에 condition 함수를 정의해주는 경우 요청이 보내지기 전에 Thunk를 취소할 수 있습니다.

condition 함수가 false를 반환하면 Thunk가 취소되고 그렇지 않은 경우 그대로 실행되게 됩니다.

작동 화면

마무리

정리하자면 리덕스의 데이터 흐름은 아래 그림과 같습니다. (출처)

redux-saga, middleware, createSelector 등 아직 리덕스에 대해 공부해야할 부분은 많지만 간단한 케이스로부터 동기, 비동기적으로 다루어봤다는 점에서 의미가 있는 것 같습니다.

리덕스를 공부하며 많은 개념들이 잘 녹아있는 라이브러리라는게 느껴졌습니다.

redux-toolkit를 활용했음에도 많은 개념들이 녹아있기에 러닝커브가 높고, 복잡하다는 인상을 받았습니다. 하지만 이러한 개념들을 잘 이해하고 잘 사용한다면 기존의 recoil, react-query 등 여러 라이브러리를 사용해야하는 것에 비해 리덕스 커뮤니티로 모두 구현이 가능할 것 같다는 생각이 들었습니다.

읽어주셔서 감사합니다.

profile
프론트엔드에 스며드는 중 🌊

0개의 댓글