[Typescript] React + Redux toolkit(ft. axios 등등 활용)

박기영·2022년 8월 14일
3

Typescript

목록 보기
11/11

문제 상황

Redux를 사용하던 중 Redux Toolkit(RTK)이라는 상위 버전 라이브러리를 알게되었다. Redux 사용시 부수적으로 필요한 각종 라이브러리를 일일이 설치하고, 불변성을 지키는 등 귀찮은 것들을 대폭 완화하여 출시된 "공식 라이브러리"로 특히나 Typescript 사용시 적극적으로 이용하기를 권장하고 있다.
필자는 여전히 일반 Redux조차 완벽하게 이해하지 못하고 있지만, Typescript를 공부해나가고 있기에, Redux Toolkit을 공부해보고자 한다.

왜 TS + Redux Toolkit을 써야하나?

참고 이미지

"RTK is already written in Typescript, and it's API is designed to provide a good exprience for Typescript usage."

그렇다. Redux Toolkit이 Typescript에 어울리는 이유가 무려 공식 문서에 나와있다.
현재 Typescript가 점점 필수 능력이 되어가고 있으므로, Redux Toolkit의 사용을 연습할 필요가 있다.

단계

  1. Redux store 생성
  2. React에 store 제공
  3. Redux state slice 생성
  4. store에 3번에서 생성한 Slice Reducers 추가
  5. useDispatch, useSelector를 통한 Redux state와 actions 사용

꼭 이 단계로 작성할 필요는 없겠지만 공식 문서의 튜토리얼을 따라가기로 한다.

설치

npm install @reduxjs/toolkit react-redux

1. store 생성하기

// app/store.js //
import { configureStore } from '@reduxjs/toolkit'

// 스토어 생성
export const store = configureStore({
  reducer: {
  	// 3번에서 만들 slice를 여기에 넣을 예정
  },
})

// useSelector 사용시 타입으로 사용하기 위함
export type RootState = ReturnType<typeof store.getState>

// useDispatch를 좀 더 명확하게 사용하기 위함
export type AppDispatch = typeof store.dispatch

RootState, AppDispatch의 경우 후에 Typed hook을 만들기 위해서 사용된다.
이 친구들은 reducer 안에 들어갈 slice들의 타입을 더 명확하게 해주기 위한 용도로 사용한다는 것 같다.

You will, however, want to extract the RootState type and the Dispatch type so that they can be referenced as needed.Inferring these types from the store itself means that they correctly update as you add more state slices or modify middleware settings.

2. React에 store 제공하기

// index.js //
import React from 'react'
import ReactDOM from 'react-dom'
import './index.css'
import App from './App'
import { store } from './app/store'
import { Provider } from 'react-redux'

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


// 현재 React v.18에서는 이렇게 사용된다.
const root = ReactDOM.createRoot(
  document.getElementById("root") as HTMLElement
);

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

이 부분은 일반적인 Redux와 차이가 없다.
1번에서 생성한 store를 컴포넌트들이 접근할 수 있게 만들어주는 것이다.

3. createSlice()로 slice 생성

// features/counter/counterSlice.js //
import { createSlice } from '@reduxjs/toolkit'
import type { PayloadAction } from '@reduxjs/toolkit'

// 초기 값의 타입 정의
export interface CounterState {
  value: number
}

// 초기 값 선언
const initialState: CounterState = {
  value: 0,
}

// slice 생성
export const counterSlice = createSlice({
  // slice 이름 정의
  name: 'counter',
  // 초기 값
  initialState,
  // 리듀서. 여러 개 기입 가능
  reducers: {
    increment: (state) => {
      state.value += 1
    },
    decrement: (state) => {
      state.value -= 1
    },
    // RTK에서는 action에 PayloadAction<T>를 타입으로 사용해야한다.
    incrementByAmount: (state, action: PayloadAction<number>) => {
      state.value += action.payload
    },
  },
})

// 각각의 리듀서의 액션을 생성
export const { increment, decrement, incrementByAmount } = counterSlice.actions

// slice를 내보냄
export default counterSlice.reducer

createSlice로 slice를 생성한다.
Redux를 사용할 때는 액션 함수, 액션 타입, 리듀서(Reducer), 초기 상태 등을 나눠서 만들었는데, RTK에서는 액션 함수, 액션 타입, 리듀서가 합쳐진 모양이다.
리듀서 내부에서 타입을 사용해서 switch문으로 case를 나눠 사용했던 것들이 굉장히 간소화되었다.

4. store에 Slice Reducers 추가

// app/store.js //
import { configureStore } from '@reduxjs/toolkit'
import counterReducer from '../features/counter/counterSlice'

export const store = configureStore({
  reducer: {
    // 3번에서 만든 slice를 여기에 넣는다
    // name으로 설정했던 것을 key로 사용한 것을 볼 수 있다.
    counter: counterReducer,
  },
})

export type RootState = ReturnType<typeof store.getState>

export type AppDispatch = typeof store.dispatch

이 부분도 일반 Redux랑 차이가 없다.
3번에서 생성한 slice를 컴포넌트들이 접근할 수 있도록 store에 추가해줬다.

5. state와 actions 사용하기

이제 사용하는 것만 남았다.
Redux를 사용할 때는 useSelector와 useDispatch를 사용하여 각각 store 접근과 액션 디스패치를 진행했었다. RTK에서는 어떨까?

// features/counter/Counter.js //
import React from 'react'
import type { RootState } from '../../app/store'
import { useSelector, useDispatch } from 'react-redux'
import { decrement, increment } from './counterSlice'

export function Counter() {
  // 1번에서 언급했던 RootState가 useSelector에서 state의 타입으로 사용된 것을 볼 수 있다
  const count = useSelector((state: RootState) => state.counter.value)
  const dispatch = useDispatch()

  return (
    <div>
      <div>
        <button
          aria-label="Increment value"
          onClick={() => dispatch(increment())}
        >
          Increment
        </button>
        <span>{count}</span>
        <button
          aria-label="Decrement value"
          onClick={() => dispatch(decrement())}
        >
          Decrement
        </button>
      </div>
    </div>
  )
}

다른게 useSelector에서 state의 타입 명시밖에 없다.
그런데...1번에서 우리는

// useDispatch를 좀 더 명확하게 사용하기 위함
export type AppDispatch = typeof store.dispatch

dispatch의 타입을 설정해놨다. 이건 도대체 왜 설정한 것일까?
방금 본 예시에서 useDispatch는 그냥 사용하던데??

추가. RootState와 AppDispatch

참고 이미지
공식 문서에서는 이 둘을 사용하여 typed versions hook을 만드는 이유를 위와 같이 설명한다. 필자의 하찮은 영어 실력으로 읽어보겠다.

useDispatch와 useSelector의 typed versions를 만드는 것이 사용하는데 더 좋다. 이유는 다음과 같다.
1. useSelector에서 state 인자에 RootState를 import해서 타입으로 넣어줬었는데, 이 짓을 매번 반복하는 것에서 벗어날 수 있다.
2. useDispatch에서 기본적인 Dispatch 타입은 thunks에 대하여 알지 못하기에, 이를 올바르게 사용하기 위해서 thunk middleware 타입을 포함하고 있는 AppDispatch라는 커스텀 타입을 useDispatch에 사용할 필요가 있다. 사전에 useDispatch에 AppDispatch를 적용시켜놓으면 사용할 때마다 AppDispatch를 import하지 않아도 된다.

type을 정해줘야하는 Typescript의 특성에서 비롯된 사용 방법이라고 생각된다.
개발 편의성을 증진시키기 위해, 매번 type을 import하는 것을 막아준다는 것 같다.
이유는 알겠다! 그럼 어떻게 사용해야할까?

// app/hooks.ts //
import { useDispatch, useSelector } from 'react-redux'
import type { TypedUseSelectorHook } from 'react-redux'
import type { RootState, AppDispatch } from './store'

// 그냥 useDispatch와 useSelector를 쓰지말고 이걸 불러서 사용하자.
export const useAppDispatch: () => AppDispatch = useDispatch
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector

이렇게 선언해준다. 이제 useSelector, useDispatch 대신 useAppSelector, useAppDispatch를 import해서 사용하도록 하자.

// features/counter/Counter.tsx //
import React, { useState } from 'react'
import { useAppSelector, useAppDispatch } from 'app/hooks'
import { decrement, increment } from './counterSlice'

export function Counter() {
  // 여기서 state 인자는 이미 RootState로 타입이 지정되어있다.
  const count = useAppSelector((state) => state.counter.value)
  // dispatch는 이전처럼 사용하면 된다. dispatch(actionName());
  const dispatch = useAppDispatch()

  // ... //
}

추가. createAsyncThunk(비동기 통신)

Redux에서는 비동기 통신을 진행하기 위해서 redux-thunk 미들웨어를 사용했다.
RTK에서는 별다른 미들웨어 설치없이 비동기 통신을 구현할 수 있다.
바로 createAsyncThunk()를 사용하는 것이다.
공식 문서에서는 fetch를 사용하는데, 필자는 주로 axios를 많이 사용했기때문에 필자 본인의 이해를 위하여 axios를 사용한 예시를 찾아서 설명을 진행하겠다.

// 통신 에러 시 보여줄 에러 메세지의 타입
interface MyKnownError {
  errorMessage: string
}

// 통신 성공 시 가져오게 될 데이터의 타입
interface TodosAttributes {
  id: number;
  text: string;
  completed: boolean
}

// 비동기 통신 구현
const fetchTodos = createAsyncThunk<
  // 성공 시 리턴 타입
  TodosAttributes[],
  // input type. 아래 콜백함수에서 userId 인자가 input에 해당
  number,
  // ThunkApi 정의({dispatch?, state?, extra?, rejectValue?})
  { rejectValue: MyKnownError }
>('todos/fetchTodos', async(userId, thunkAPI) => {
  try { 
    const {data} = await axios.get(`https://localhost:3000/todos/${userId}`);
    return data;
  } catch(e){
    // rejectWithValue를 사용하여 에러 핸들링이 가능하다
    return thunkAPI.rejectWithValue({ errorMessage: '알 수 없는 에러가 발생했습니다.' });
  }
})

createAsyncThunk의 타입에는 통신 성공 시 받아올 데이터 타입, 우리가 넣어준 데이터의 타입, ThunkAPI의 타입을 넣어준다.

ThunkAPI의 타입

ThunkAPI의 타입에는 dispatch, state, extra, rejectValue가 있다.
공식 문서 상에서는 이들을 명시하는 이유가 아래와 같이 나와있다.
참고 이미지
불필요하게 불려지는 타입들을 무시할 수 있게 된다.
즉, 사용할 타입만 적어놓고 사용하고, 나머지는 딸려오지않도록 막아준다는 것 같다.
위 예시에 사용된 rejectValue는 통신에 에러가 발생했을 때 사용되는 타입이다.
이들은 리듀서쪽에서 사용되므로, 일단 여기까지만 알고 넘어가자.

extraReducers 사용

비동기 통신 액션에 대한 리듀서는 extraReducers로 작성한다.

// ... //

const todosSlice = createSlice({
  name: 'todos',
  initialState,
  reducers: {
    ...
  },
  extraReducers: (builder) => {
    builder
      // 통신 중
      .addCase(fetchTodos.pending, (state) => {
        state.error = null;
        state.loading = true;
      })
      // 통신 성공
      .addCase(fetchTodos.fulfilled, (state, { payload }) => {
        state.error = null;
        state.loading = false;
        state.todos = payload;
      })
      // 통신 에러
      .addCase(fetchTodos.rejected, (state, { payload }) => {
        state.error = payload;
        state.loading = false;
      });
  },
})

createSlice에서 reducers에 다양한 리듀서들을 만들었다면, 같은 위치에 extraReducers를 사용해서 비동기 통신 액션에 관한 리듀서를 작성한다.
extraReducers는 외부 작업을 참조하기 위한 것이기 때문에 slice.actions에 생성되지 않으며, ActionReducerMapBuilder를 수신하는 콜백으로 작성하는 것이 권장된다고 한다.

createAsyncThunk를 사용하면 pending, fulfilled, rejected 상태에 대한 액션이 자동으로 생성되므로, 위 예시와 같이 활용할 수 있다.
각각 통신 중, 통신 성공, 통신 에러를 의미한다.

위에서 우리가 rejcetValue에 MyKnownError 타입을 넣어놓고, rejectWithValue를 사용하여 에러 메세지를 넣어놨다.
이를 활용해서 rejected 부분을 다음과 같이 사용할 수도 있다.

builder.addCase(updateUser.rejected, (state, action) => {
      if (action.payload) {
        // rejectValue에 MyKnownError를 넣어놨기 때문에 그것의 타입 정보에 접근 가능하다
        state.error = action.payload.errorMessage
      } else {
        state.error = action.error
      }
    })

이번에는 공식 문서 예시를 잠깐 보겠다.

const usersSlice = createSlice({
  name: 'users',
  initialState: {
    entities: {},
    error: null,
  },
  reducers: {},
  extraReducers: (builder) => {
    builder.addCase(updateUser.fulfilled, (state, { payload }) => {
	  // initialState에 있는 error를 사용한다
      state.entities[payload.id] = payload
    })
    builder.addCase(updateUser.rejected, (state, action) => {
      if (action.payload) {
        // initialState에 있는 error를 사용한다
        state.error = action.payload.errorMessage
      } else {
        state.error = action.error
      }
    })
  },
})

다른 예시에서 잠깐 설명이 부족했는데, extraReducers에서 state.뭐시기로 사용하는 부분은 뭐시기가 initialState에서 선언되어 있어야한다.
다른 예시에서는 이 부분을 생략해서 헷갈릴 것 같아서 추가로 설명을 남겼다.

unwrapResult으로 Promise 바로 사용하기

Thunk 액션은 프로미스를 반환하는데, unwrapResult 이용해서 바로 컴포넌트에서 비동기 액션 결과값을 핸들링 할 수 있다고 한다.

import {unwrapResult} from '@reduxjs/toolkit';

// ... //

  try{
    const resultAction = await dispatch(fetchTodos(1));
    const todos = unwrapResult(resultAction);
    setTodos(todos);
  } catch(e){
    console.log(e)
  }

// ... //

추가. 공부하며 알게된 것...?

많은 분들이 RTK를 처음 봤을 때, createAction, createReducer를 사용하는게 있고, createSlice를 사용하는게 있어서 헷갈릴 수 있다고 생각한다.(필자가 엄청 헤맸다...)
알고보니 아래와 같은 구조였다.

Redux -> RTK -> createAction, createReducer -> createSlice

Redux를 쉽게 사용하기 위해 RTK에서 사용하는 createAction과 createReducer를 더욱 더 함축시킨 것이 createSlice 인 것이다.
물론, 모두 RTK에서 사용하는 것이므로 선택해서 사용하시면 된다.
다만, 필자가 createSlice를 예시로 사용한 이유는 공식 문서에서 많이 사용했기 때문이다.
특히나 RTK에서의 비동기 통신을 알고싶어서 시작한 공부이기 때문에 createAsyncThunk가 사용된 예시를 많이 참고했고, 그게 바로 createSlice가 사용된 예시였다.

기본적인 사용법은 여기까지 마무리하도록 하겠다.
필자도 아직까지 이해가 잘 안된다.
특히나 이 예시를 보면 이렇게 쓰고, 저 예시를 보면 저렇게 쓰는게 너무 많아서 공부한 것을 한꺼번에 적용했을 때 잘 돌아갈지는 해봐야지 알 것 같다.
특히나, 비동기 통신 부분에서 느낀건데, 만약 axios 통신마다 createAysncThunk를 만들어줘야한다면 코드가 굉장히 늘어날 것 같은데...
따라서, 초기 데이터를 불러오는 것에만 사용하거나 해야할 것 같다.
꼭 리듀서 내부에서 axios 통신을 할 필요는 없기 때문이다.
stackoverflow에서 본 글에도 컴포넌트에서 axios 통신을 하고, 데이터 관리만 스토어에서 하라는 경우도 꽤 많았기 때문에 조심스럽게 고민해봐야겠다.

필자는 이렇게 사용했다

공부를 해보고, 며칠동안 삽질해본 결과.
회원가입 기능을 RTK와 axios를 사용해서 구현했다.
코드를 살펴보자.

// src/_reducers/userSlice.ts // 
import { createSlice, createAsyncThunk } from "@reduxjs/toolkit";
import type { PayloadAction } from "@reduxjs/toolkit";
import axios from "axios";

interface MyKnownError {
  errorMessage: string;
}

interface RegisterActionType {
  success: string;
}

export const registerUser = createAsyncThunk<
  RegisterActionType,
  object,
  { rejectValue: MyKnownError }
>("users/registerUser", async (registerInfo, thunkAPI) => {
  try {
    // success 객체가 들어올 것으로 예상됨.
    const { data } = await axios.post("/api/users/register", registerInfo);
    return data;
  } catch (err) {
    return thunkAPI.rejectWithValue({
      errorMessage: "회원가입 실패",
    });
  }
});

export interface InitailStateType {
  userData: object;
  error: null | MyKnownError | undefined;
  loading: boolean;
}

const initialState: InitailStateType = {
  userData: {},
  error: null,
  loading: false,
};

export const userSlice = createSlice({
  name: "user",
  initialState,
  reducers: {
	// ... //
  },
  extraReducers: (builder) => {
    builder
      // 통신 중
      .addCase(registerUser.pending, (state) => {
        state.error = null;
        state.loading = true;
      })
      // 통신 성공
      .addCase(registerUser.fulfilled, (state, { payload }) => {
        state.error = null;
        state.loading = false;
        state.userData = payload;
      })
      // 통신 에러
      .addCase(registerUser.rejected, (state, { payload }) => {
        state.error = payload;
        state.loading = false;
      });
  },
});

export const {} = userSlice.actions;

export default userSlice.reducer;

위에서 설명했던 예시들을 그대로 활용했다. 그래서 문법에 관한 것은 설명 생략!
extraReducers에서 잘 작동하는지를 보자.
컴포넌트에서는 아래와 같은 코드로 액션을 디스패치했다.

// ... //
const dispatch = useAppDispatch();

// ... //

try {
  dispatch(registerUser(body));
  alert("회원가입 성공. 로그인 화면으로 이동합니다.");
  navigate("/login");
} catch (err) {
    console.log("register error", err);
}

데이터가 전송이 되는 순간, requestStatus가 pending이 된 것을 확인 할 수 있다.
참고 이미지
따라서, loading이 true로 변한다. 콜백함수에 payload는 사용하지 않았기 때문에 initialState에 있던 빈 object인 userData가 여전히 비어있는 것도 볼 수 있다.
참고 이미지

자, 이제 요청한 데이터가 처리되어 결과가 어떻게 나왔는지 살펴보자.
우선 통신 성공한 케이스부터 살펴보자.
requestStatus가 fulfilled로 변경된 것을 확인할 수 있다.

참고 이미지
따라서, loading이 false로 변하고, payload에 담긴 데이터(createAsyncThunk에서 return했던 data)가 userData에 들어간 것을 확인 할 수 있다.
저 데이터는 필자가 API를 간단하게 구현해놓은 곳에서 보내준 것으로, 본인이 어떤 API로 통신했느냐에 따라 달라질 것이다.(너무 당연한 말인가...?)

참고 이미지

이번에는 통신이 실패한 케이스를 살펴보자.
에러를 발생시키기 위해서 서버를 껐다.(5000번 포트에 간단하게 구현해놨었다)
참고 이미지
이번엔 requestStatus가 rejected로 변경되었다.
그래서 rejectedWithValue도 true가 됐다.
참고 이미지
userData에는 필자가 API를 구현해놓은 곳에서 보낸 에러 메세지가 보인다.
error에는 rejectWithValue에 적어놨던 errorMessage가 보인다.

여기서 필자가 여러가지 실험을 하다가 알게된 것이 있다.
rejected는 "통신 에러"가 발생했을 때만 발생한다.
즉, API "통신 성공" 후 DB 내에서 조건이 맞지 않아서 에러 메세지를 return한다면 rejected가 아니라 fulfilled 상태가 된다.
아래는 서버를 계속 연결시켜놓은 상태에서 DB 검색 조건을 불만족하도록 만든 상황이다.(이미 가입된 정보로 다시 가입을 시도했다)

참고 이미지

requestStatus가 fulfilled로 뜨는 것을 확인 할 수 있다.
그런데, 에러 메세지를 받아왔다.
참고 이미지

userData에 저장된 에러 메세지는 API를 구현해놓은 곳에서 보낸 것이라고 아까 설명했다.
error가 null이다. 즉, rejectWithValue에 적어놨던 errorMessage는 반환되지 않은 것이다.

이를 잘 구분해서 사용할 줄 알아야할 것 같다. 자칫 잘못하면, 둘을 헷갈릴 수도 있겠다.

이번에는 컴포넌트 쪽에서 이런 데이터들을 어떻게 활용할지에 대해서 간단하게 필자의 예시를 작성해보고자한다.
아까 위에서 작성했던 코드에서 약간 더 발전한(?) 코드이다.

// ... //
const dispatch = useAppDispatch();

// ... //

dispatch(registerUser(body))
  .then((response) => {
	if (response.payload?.success) {
	  alert("회원가입 성공. 로그인 화면으로 이동합니다.");
	  navigate("/login");
	} else {
		alert("회원가입 실패");
		alert(response.payload?.message);
	}
  })
  .catch((err) => console.log("회원가입 에러", err));

response.payload에 데이터가 들어가있다.
success라는 성공 여부를 알려주는 데이터로 분기처리를 해서 회원가입 성공 여부를 알려준다.

에러 처리 코드에서의 타입 오류

그런데, 실패 시 작동하는 코드에서 의문점이 생겼다.

alert(response.payload?.message);

message 부분이 원래는 error였다. 왜냐? API에서 주는 데이터의 key를 error라고 해놨기 때문이다...
그런데 왜 message라고 해놨느냐? error를 쓰면 아래와 같은 에러가 발생한다.

참고 이미지
RegisterDataFromServerType은 초기에 RegisterActionType이라고 작성해놨던 것인데, API에서 return해주는 데이터의 타입을 명시한 것이다.
MyKnownError는 지금까지 봐왔듯이 rejectValue의 타입이다.

// 이전 코드
interface MyKnownError {
  errorMessage: string;
}

interface RegisterActionType {
  success: boolean;
  error: string;
}

// 수정한 코드
interface MyKnownError {
  message: string;
  success: boolean;
}

interface RegisterDataFromServerType {
  success?: boolean;
  message?: string;
  error?: any;
}

위에서 보여드린 에러 메세지는 이전 코드에서 발생했다.
필자는 API에서 보내준 데이터 중 error라는 key에 들어가 있는 값을 사용하려고 erorr의 타입을 정해줬고, 통신 오류 타입에는 errorMessage의 타입을 정해줬다.
이게 문제였던 것 같다.
두 타입 모두 같은 값을 설정해줘야 저 에러가 사라졌다.
그래서 이전 코드의 errorMessage와 error를 전부 message라는 이름으로 통일해줬다.
같은 이유로, success 또한 오류가 발생하는 바람에 값을 추가해줬다.

참고 자료

알기 쉽게 정리해주신 멋있는 분들의 게시물 링크들이다...
참고 자료 1
참고 자료 2
참고 자료 3
참고 자료 4
Redux-Toolkit 공식 docs

profile
나를 믿는 사람들을, 실망시키지 않도록

1개의 댓글

comment-user-thumbnail
2022년 10월 5일

안녕하세요 리덕스 툴킷 입문을 하고있습니다.

글을 보며 도움이 많이됐습니다.

개발을 하면서 리덕스 툴킷에서 axios 호출을 통해 전역 데이터 관리를 하면 유용한 점이 무엇인지 궁금합니다.
너무 어렵게 생각해주시지 마시고 가볍게라도 이야기 해주시면 좋겠습니다!

답글 달기