Frameworks, Platforms and Libraries/React

Redux Toolkit으로 상태 관리 하기

iam102 2023. 8. 20. 16:40

도입하여 사용한지는 꽤 됐지만 현재 도입하여 사용 중인 상태 관리 기술 스택 중 하나인 Redux Toolkit에 대해 이야기하려고 합니다. 기존에 Redux를 사용하며 겪었던 여러 이슈들이 있었습니다. 복잡한 환경설정, 추가로 설치해야 하는 수많은 서드파티 라이브러리, 불어 나는 Boilerplate 등 Redux를 제대로 쓸려면 꽤나 많은 부분에 대해 신경을 써야 했습니다. 하지만 Redux Toolkit이 나와 도입을 하면서 이런 점들이 많이 해소될 수 있게 되었습니다.

 

Redux Toolkit 셋업

Redux Toolkit을 RTK로 줄여서 명시하며 TypeScript 환경에서 예시를 보이겠습니다.

 

store부터 구성해보겠습니다. RTK에서 제공하는 configureStore를 이용하여 구성하며 RootState, Dispatch의 type을 내보내 맵핑할 수 있도록 합니다. store에서 RootState, Dispatch의 type을 내보냄으로써 feature 단위로 slice가 추가되거나 middleware가 수정되더라도 type의 무결성을 가져 안전합니다.

// store/index.ts
import { configureStore } from '@reduxjs/toolkit';
import reducer from './reducer';

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

export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;

 

React application 내부에서 사용하기 위해 store을 등록합니다.

// main.tsx
...

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

...

ReactDOM.createRoot(document.getElementById('root')!).render(
  <Provider store={store}>
    <App />
  </Provider>,
);

 

redux state 조회를 위해 사용되는 useSelector와 action 발생을 위한 useDispatch 훅을 store에 정의된 type을 미리 맵핑하여 훅을 만들어 사용합니다. (그렇지 않을 경우 useSelector와 useDispatch 사용할 때마다 type을 바인딩해줘야 합니다.)

// store/hook.ts
import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux';
import type { RootState, AppDispatch } from './index';

export const useAppDispatch = () => useDispatch<AppDispatch>();
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;

 

reducer의 관리 용이성을 위해 분리하여 사용하겠습니다. 각 feature 단위로 slice를 만들며 아래의 코드에선 예시로 countSlice와 userSlice 두 케이스로 설명하겠습니다. 

// store/reducer.ts
import countSlice from '@/features/count/countSlice';
import userSlice from '@/features/user/userSlice';

export default {
  count: countSlice,
  user: userSlice,
};

 

slice는 feature 단위로 나누어서 관리하며 RTK에서 제공하는 createSlice를 통해 생성합니다. createSlice 내부에서 자동으로 Immer를 사용하므로 immutable 하게 관리합니다. slice name과 사용할 state 및 state의 상태를 변경하는 reducer를 명시해줍니다. 예시인 countSlice는 전역 상태 관리를 다루는 case로 제일 기본적인 형태입니다.  

// features/count/countSlice.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit';

interface CounterState {
  value: number
}

const initialState: CounterState = {
  value: 0,
}

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

const { reducer, actions } = CounterSlice;

export const { increment, decrement, incrementByAmount } = actions;

export default reducer;

 

countSlice를 사용한 component case입니다. 전역 상태를 사용하기 위해 typed hook을 가져와 사용하며 해당 slice name을 통해 state을 가져오고 선언된 dispatch에 action을 명시하여 사용합니다.

// features/count/Count.tsx
import { useAppSelector, useAppDispatch } from '@/store/hook';

import { increment, decrement, incrementByAmount } from './slice/counterSlice';

function Count() {
  const count = useAppSelector((state) => state.count.value);
  const dispatch = useAppDispatch();

  return (
    <div className="count">
      <div>count is: {count}</div>
      <div>
        <button type="button" onClick={() => dispatch(increment())}>+</button>
        <button type="button" onClick={() => dispatch(decrement())}>-</button>
        <button 
          type="button"
          onClick={() => dispatch(incrementByAmount(3))}
        >
          +3
        </button>
      </div>
    </div>
  )
};

export default Count;

 

RTK에선 비동기 처리를 위해 createAsyncThunk를 사용합니다. createSlice를 통해 생성된 action type이 아니므로 비동기 처리에 따른 서버 상태 관리와 데이터 후처리는 extraReducers를 사용합니다. extraReducers의 build notation을 통해 비동기 상태에 따른 처리를 해줍니다.

// features/user/userSlice.ts
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
import { getUserData, updateUserData } from '@/api';
import { UserData, UserUpdateData } from '@/interface';
import { Request, RequestState } from '@/interface/Request';

export const getUsers = createAsyncThunk(
  'user/getUsers',
  async (): Promise<UserData> => {
    const response = await getUserData();
    return response.data;
  },
);

export const updateUsers = createAsyncThunk(
  'user/updateUsers',
  async (user: UserUpdateData): Promise<void> => {
    await updateUserData(user);
  },
);

export interface UserState {
  users: UserData;
  usersRequest: Request;
  updateRequest: Request;
}

const initialState: UserState = {
  users: {
    data: [],
    page: 0,
    per_page: 0,
    total: 0,
    total_pages: 0,
    support: {
      url: '',
      text: '',
    },
  },
  usersRequest: {
    state: RequestState.INIT,
  },
  updateRequest: {
    state: RequestState.INIT,
  },
};

const UserSlice = createSlice({
  name: 'user',
  initialState,
  reducers: {},
  extraReducers: (builder) => {
    builder
      .addCase(getUsers.pending, (state: AppState) => {
        state.usersRequest.state = RequestState.LOADING;
      })
      .addCase(
        getUsers.fulfilled,
        (state: AppState, action: PayloadAction<UserData>) => {
          state.users = action.payload;
          state.usersRequest.state = RequestState.SUCCESS;
        },
      )
      .addCase(getUsers.rejected, (state: AppState) => {
        state.usersRequest.state = RequestState.ERROR;
      });

    builder
      .addCase(updateUsers.pending, (state: AppState) => {
        state.updateRequest.state = RequestState.LOADING;
      })
      .addCase(updateUsers.fulfilled, (state: AppState) => {
        state.updateRequest.state = RequestState.SUCCESS;
      })
      .addCase(updateUsers.rejected, (state: AppState) => {
        state.updateRequest.state = RequestState.ERROR;
      });
  },
});

const { reducer } = UserSlice;

export default reducer;

 

userSlice를 사용한 component case입니다. 비동기 처리에 따른 서버 상태를 extraReducers를 통해 client에서 state로 관리하고 있어 상태를 표현할 수 있습니다.

// features/user/User.tsx
import React, { useEffect } from 'react';

import { useAppDispatch, useAppSelector } from '@/store/hook';
import { RequestState } from '@/interface/Request';
import { getUsers } from '@/features/user/userSlice';

function User() {
  const dispatch = useAppDispatch();

  const usersSuccess = useAppSelector((state) => {
    return state.app.usersRequest.state === RequestState.SUCCESS;
  });
  const users = useAppSelector((state) => {
    return state.app.users;
  });

  useEffect(() => {
    dispatch(getUsers());
  }, []);

  return (
	<div className="user-list">
	  {!usersSuccess && <p>loading...</p>}
	  {users && users.data.map((v) => (<span key={v.id}>{v.first_name}</span>))}
	</div>
  );
}

export default User;

 

회고

RTK를 도입하면서 이전에 Redux를 제대로 사용하기 위해 겪어야 했던 이슈들은 많이 줄었습니다. 추가로 설치해야 할 서드파티 라이브러리에 대한 러닝 커브와 Boilerplate의 코드가 많이 감소한 점이 개인적으로 만족스러웠습니다. 다른 상태 관리 라이브러리도 많지만 Redux를 사용해야 하는 프로젝트를 진행할 경우 RTK를 사용한다면 보다 쉽게 store구조를 잡을 수 있겠습니다.

 

여담

1. 서버 상태 관리 라이브러리

초기 프로젝트 설계 시 비동기 처리의 규모가 작을 것이라 생각했지만 비동기 처리 case가 점점 많아졌고 그에 따라 store가 점점 비대해져 전역 상태 관리로써 store의 역할이 모호해졌습니다. 자연스럽게 비동기 처리를 위한 서버 상태 관리 라이브러리를 찾게 되었고 현재는 react-query를 도입하여 사용 중입니다. RTK에서도 RTK Query를 통해 서버 상태 관리를 할 수 있지만 다른 라이브러리와 비교 후 현재 프로젝트에선 react-query가 적절하여 사용 중입니다. 이 내용은 추후에 포스팅할 예정입니다.

 

2.  reducer의 동적 할당

Vue.js 프로젝트에서 Vuex를 사용하면서 registerModule와 unregisterModule를 통해 VuexModule을 동적으로 생성 및 삭제가 가능했는데 이처럼 Redux에서 필요시에만 reducer을 동적으로 처리할 수 있지 않을까?를 고민을 하게 되었습니다. 그래서 Redux Code Splitting을 찾아보기도 하고 Dynamic Redux Reducers의 키워드로 여러 reference을 찾을 수 있었습니다. Redux의 Code Splitting 내용을 보며 Reducer Manager를 작업 중인 프로젝트에 반영을 했지만 현재의 프로젝트에 그다지 효율을 못 얻어 현재는 빠진 상태입니다. 하지만 관련 reference를 참고로 보면 좋겠습니다.

관련 reference

https://redux.js.org/usage/code-splitting
https://tylergaw.com/blog/dynamic-redux-reducers/


참고 자료

https://redux-toolkit.js.org/tutorials/quick-start

https://redux-toolkit.js.org/tutorials/typescript