Typescript로 Redux-toolkit 사용하기

오형근·2022년 11월 9일
3

Frontend

목록 보기
7/10
post-thumbnail

WILIM 프로젝트를 진행하면서 상태 관리를 위해 기존에 익숙하게 사용했던 Redux를 사용하려고 했었다. 와중 유튜브 알고리즘이 띄워준 Redux-toolkit 관련한 영상을 시청하게 되었는데, TS 환경에서 Redux-toolkit(이하 rtk)를 적용하는 방법을 기록하고 회고하고자 글을 작성하게 되었다.

Redux-toolkit?

The official, opinionated, batteries-included toolset for efficient Redux development (from https://redux-toolkit.js.org/)

위는 rtk 공식 페이지에서 가져온 설명인데, 대충 설명하자면 redux를 이용한 개발에 효율성을 더해주는 툴셋이라고 한다.

기존의 redux의 경우 초기 세팅이 복잡하고 처음 입문하는 사람들이 이해하는 데 투자하는 시간이 많았으며, 제대로 된 사용을 위해서는 thunk, immer, logger 등등 다양한 패키지를 추가 설치하여 설정해주어야했다.

rtk는 이러한 기존 redux의 단점들을 보완하고 redux를 이용해 상태를 관리하기 위한 보일러 플레이트를 제공해주며, 간단한 설정만으로 redux를 다룰 수 있도록 도와준다.

해당 글은 rtk에 대한 설명글이 아니므로 이 정도만 소개를 하고 본론으로 넘어가보자.


사용 방법

이번 프로젝트는 TS 환경에서 개발을 하였으므로 rtk 또한 타입 시스템에 맞는 타입들을 찾아 적용해주어야했다. 우선 프로젝트의 store 폴더 내부를 살펴보자.

.
store
├ rootReducer.ts
├ store.ts
├ asyncThunks
│   ├ addComment.ts
│   ├ addPost.ts
│   ├ addUserPlan.ts
│   ├ deleteComment.ts
│   └ (...)
└ slices
	├ postSlice.ts
    ├ toggleSlice.ts
    ├ userGoalSlice.ts
    ├ userInfoSlice.ts
    └ userPlanSlice.ts

asyncThunks 폴더 같은 경우 백엔드와 통신하는 비동기 요청 함수들을 모아둔 곳다. 이곳에는 CRUD에 맞는 각 비동기 함수들이 다수 들어있어 4개만 적었다.

폴더 하위에 있는 파일부터 설명하려고 한다. 먼저 slice 폴더 내부에 있는 slice 파일들 중 postSlice.ts를 대표로 살펴보자.

Slice

postSlice.ts

import { createSlice, PayloadAction } from "@reduxjs/toolkit";
import { InitialPostProps, PostProps } from "../../schema/community";
import { addPost } from "../asyncThunks/addPost";
import { getAllPosts } from "../asyncThunks/getAllPosts";
import { getPostById } from "../asyncThunks/getPostById";

const initialState: InitialPostProps = {
  postList: [],
};

export const postSlice = createSlice({
  name: "post",
  initialState,
  reducers: {},
  extraReducers: (builder) => {
    builder.addCase(
      getAllPosts.fulfilled,
      (state: InitialPostProps, action: PayloadAction<PostProps[]>) => ({ postList: [...action.payload] }));
    builder.addCase(
      addPost.fulfilled,
      (state: InitialPostProps, action: PayloadAction<PostProps>) => ({ postList: [
        ...state.postList,
        action.payload,
      ]}));
    builder.addCase(getPostById.fulfilled, (state: InitialPostProps, action: PayloadAction<PostProps>) => {
      const index = state.postList.findIndex(x => x._id === action.payload._id);
      state.postList[index] = action.payload;
      return;
    });
  },
});

export const {} = postSlice.actions;
export default postSlice.reducer;

rtk에서 slice는 상태를 정의하고 해당 상태를 조작하는 함수들을 명시해주는 곳의 단위라고 생각하면될 좋을 것 같다. 상태의 고유 string값과 초기 상태, 상태를 변경할 수 있는 함수들을 지정해주는 곳이다.

createSlice 메서드를 이용하여 slice를 만들 수 있고, reducer를 추가하여 일반적인 이벤트에 대한 상태 변경을, extraReducer를 추가하여 비동기 함수에 대한 상태 변경을 할 수 있다.

프로젝트 코드를 제작한 뒤에 깨달은 것인데, 추후에는 굳이 extraReducer를 각 경우마다 추가해주는 것이 아니라 getPostById와 getAllPost 두 함수만이 상태를 직접 변경할 수 있도록 하고 백엔드와의 CRUD 통신이 완료되었을 때 두 함수 중 알맞는 것을 불러와서 상태를 변경해주는 것이 더 "상태 관리"라는 관점에 부합하다고 생각했다. 또 유지 보수에도 알맞고 이후 코드에 변경사항이 생기더라도 굳이 extraReducer를 추가로 변경할 필요가 없어지도록 만들 수 있을 것 같다.

위 설명을 도식화 하면 다음과 같다.

기존

기존에는 이렇게 CRUD 비동기 함수들이 직접 slice에 접근하여 상태 값을 변경해주었다면,

앞으로

앞으로 개발할 때에는 위와 같이 다양한 비동기 함수들이 성공한 뒤(fulfilled) 각각에 맞는 상태 변경 함수들을 불러와 상태변경을 할 수 있도록 하면 최적화나 유지보수에 적합할 것이다.

그 다음으로는 rootReducer에 대한 설명이다. 먼저 코드를 살펴보자.

RootReducer

rootReducer.ts

import { combineReducers } from "@reduxjs/toolkit";
import userInfo from "./slices/userInfoSlice";
import userPlan from "./slices/userPlanSlice";
import toggle from "./slices/toggleSlice";
import userGoal from "./slices/userGoalSlice";
import post from "./slices/postSlice";

const reducer = combineReducers({
    userInfo,
    userPlan,
    userGoal,
    post,
    toggle,
});

export type ReducerType = ReturnType<typeof reducer>;
export default reducer;

rootReducer는 createSlice를 통해 만들어진 slice들을 하나로 병합하여 store에 전달해주는 기능을 한다.

redux store에는 하나의 reducer만 등록이 가능하기 때문에 모든 상태를 하나의 slice에 저장하면 관리가 매우 어렵고 복잡해진다. 따라서 각 역할에 맞는 상태들을 slice 단위로 분리하고 관리하는 것인데, 이 분리된 slice들을 합쳐 하나의 reducer로 만들어주는 역할을 하는 것이 rootReducer의 combineReducers 함수이다.

그리고 중요한 것이 ReducerType인데, 위 코드처럼 reducer의 타입을 미리 명시해주고 이를 export로 내보내주어야 나중에 컴포넌트에서 상태 값을 가져올 수 있게 된다. 꼭 명시해주자.

마지막으로 store 코드는 다음과 같다.

Store

import { Action, configureStore, getDefaultMiddleware, ThunkDispatch } from '@reduxjs/toolkit';
import logger from 'redux-logger';
import reducer, { ReducerType } from './rootReducer';

const middleware = [ ...getDefaultMiddleware(), logger ];

const store = configureStore({
    reducer,
    middleware,
});

export type AppThunkDispatch = ThunkDispatch<ReducerType, any, Action<string>>;
export type AppDispatch = typeof store.dispatch;
export default store;

store에서는 reducer와 middleware 등을 설정해주고 store를 생성한다.
아마 기존의 redux와 크게 다른 점은 없을 것이다.

그러나 타입 지정은 확실하게 해주어야한다.

기존의 reducer 같은 경우 타입을 지정하지 않아도 작동에 문제가 되지는 않는다. 그러나 공식문서 에서는 AppDispatch 타입을 지정하는 것을 권장하고 있다.

비동기 이벤트를 다루는 extraReducer의 경우

다만 기존의 일반적인 reducer는 타입 명시 없이도 잘 작동할지 모르지만, extraReducer의 경우는 useDispatch<AppThunkDisPatch>();와 같이 타입을 명시해주지 않으면 에러를 발생시킨다. 즉 reducer와 extraReducer의 타입을 다르게 지정해주어야한다는 것이다.

끝으로 상위 컴포넌트에서 store를 사용할 컴포넌트들에 provider를 지정해주면 된다.

index.ts

import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
import reportWebVitals from './reportWebVitals';
import { Provider } from 'react-redux';
import store from './store/store';
import { BrowserRouter } from "react-router-dom";

const root = ReactDOM.createRoot(
  document.getElementById('root') as HTMLElement
);

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

이렇게 하면 컴포넌트 단위에서 아래와 같이 상태를 불러오거나 변경할 수 있다.

example.tsx

import { useDispatch } from "react-redux"
import { AppDispatch, AppThunkDispatch } from "../../store/store"
import { useSelector } from "react-redux"
import { ReducerType } from "../../store/rootReducer"
import { updateGoalDateInfo } from "../../store/slices/userGoalSlice"
import { goalSearchInfoToggle } from "../../store/slices/toggleSlice"

const Example = () => {
  
	const appDispatch = useDispatch<AppThunkDispatch>();
	const dispatch = useDispatch<AppDispatch>();
	const goal = useSelector((state: ReducerType) => state.toggle.goalSearchInfo);
	const { username } = useSelector((state: ReducerType) => state.userInfo);
  
	return <>
      	<Button onClick={() => dispatch(goalSearchInfoToggle())} />
		<AsyncButton onClick={() => appDispatch(updateUserGoal())} />
      </>;
}

비동기 함수를 동기적으로 연쇄 호출하기

내가 위에 보여준 그래프를 보면 특정 비동기 함수가 완료된 것을 확인한 뒤에 다른 비동기 함수가 그 결과를 토대로 작동해야함을 알 수 있다.
앞으로

위 그림에서 CRUD function의 성공 여부에 따라 getPostById나 getAllPost 함수의 호출 여부가 결정되기 때문에 이를 동기적으로 연쇄 호출하는 방법을 찾아야했다.

여러 방법을 찾던 도중, 해당 비동기 함수의 response로 제공되는 것들을 사용하였다. 아래 코드를 살펴보자.

loginPage.tsx

export const LoginPage = () => {
  const dispatch = useDispatch<AppThunkDispatch>();
  const navigate = useNavigate();
  useEffect(() => {
    dispatch(fetchLoginInfo())
    .then(res => {
      if(res.meta.requestStatus === 'fulfilled') navigate('/main');
    })
  }, [])
  return (
    <MediaDiv>
      <InnerMediaDiv>
        <LoginTemplate />
      </InnerMediaDiv>
    </MediaDiv>
  )
};

위 코드는 WILIM 프로젝트에서 초기 화면에서 로그인 세션을 확인하는 과정이다.
const dispatch = useDispatch<AppThunkDispatch>();를 통해 비동기 함수를 디스패치하고, 로그인 여부를 확인한다. 이후 결과값 res에서 res.meta.requestStatus를 확인하여 해당 값이 fulfilled이면 성공 시 로직을, rejected이면 실패 시 로직을 수행하는 것이다.

이와 같은 방법을 적용하여 여러 비동기 로직들을 묶고, 상태 변경에 쓰이는 extraReducer를 두 개로 고정시켜 상태 변경에 견고함을 더할 수 있었다.


후기

rtk를 사용하면서 기존의 redux에서 개선이 정말 많이 되었다고 느꼈다. 또한 TS를 함께 적용하고 나니 상태 값들을 가져오는 데 혼란을 없애주었고, 비동기 함수를 직관적이고 효율적으로 관리하는 등 충분히 좋은 개발 경험을 가져다주었다고 생각한다.

아직 제대로 사용하지 못한 메서드들도 많고, 디렉토리 구조나 아키텍쳐를 많이 신경쓰지 못했다는 생각이 든다. 다음에 또 프로젝트에 rtk를 적용할 일이 생긴다면 그때는 좀 더 나은 코드를 작성할 수 있는 개발자가 되자!

구글링한 자료들에서는 ReducerType을 사용하는 대신 RootState 타입을 명시하여 사용하는 경우가 꽤 있던데, 이건 어떤 차이가 있는지 추후에 알아보자.

profile
eng) https://medium.com/@a01091634257

0개의 댓글