3월21일(화) Redux-Thunk

Mindfulness·2023년 3월 21일
post-thumbnail
🚩 [학습 목표]
  1. 미들웨어의 개념에 대해 알아본다.
  2. Redux 구조에서 비동기를 다루는 기술(미들웨어)인 Thunk에 대해 알아보고, 사용해본다.

1. Redux 미들웨어

(1) 미들웨어란?

이미지 출처 : 벨로퍼트 모던 리액트 깃북

리덕스에서 dispatch를 하면 action 이 리듀서로 전달이 되고, 리듀서는 새로운 state를 반환한다. 근데 미들웨어를 사용하면 이 과정 사이에 우리가 하고 싶은 작업들을 넣어서 할 수 있다.

만약 counter 프로그램에서 더하기 버튼을 클릭했을 때 바로 +1를 더하지 않고 3초를 기다렸다가, +1이 되도록 구현하려면 미들웨어를 사용하지 않고서는 구현할 수 없다. 왜냐하면 dispatch가 되자마자 바로 action이 리듀서로 달려가서 새로운 state를 반환해버리기 때문. 즉 여기서 “3초를 기다리는 작업" 이 작업을 미들웨어가 해주는 것.

보통 우리가 리덕스 미들웨어를 사용하는 이유는 서버와의 통신을 위해서 사용하는 것이 대부분이고, 또한 그 중에서도 많이 사용되고 있는 리덕스 미들웨어는 Redux-thunk 라는 것이 있다. 이것을 직접 실습해보면서, 미들웨어에 대해 이해해보자.

2. thunk

  • (1) thunk 소개

    리덕스 thunk란,

    리덕스에서 많이 사용하고 있는 미들웨어중에 하나다. thunk를 사용하면 우리가 dispatch를 할때 객체가 아닌 함수를 dispatch 할 수 있게 해준다. 즉 dispatch(객체) 가 아니라 dispatch(함수)를 할 수 있게 되는 것

    그래서 중간에 우리가 하고자 하는 작업을 함수를 통해 넣을 수 있고, 그것이 중간에 실행이 되는 것. 그래서 아래 흐름과 같이 실행이 된다. 그리고 이 함수를 thunk 함수라고 부른다.

    dispatch(함수) → **함수실행** → 함수안에서 dispatch(객체)
  • (2) thunk 사용하기

    아래 순서대로 구현을 해보자.

    1. 첫 thunk 함수 만들기
    2. extraReducer에 thunk 등록하기
    3. dispatch(thunk 함수) 하기
    4. 테스트
  • (3) 첫 thunk 함수

    thunk 함수를 만들어보자.

    thunk 함수의 역할은 “3초를 기다리는 것” 으로 해보자. 그리고 3초가 지나면 원래 하려고 했던 ADD_NUMBER를 해주는 것 까지가 thunk함수가 해야 할 일.

    툴킷에서는 createAsyncThunk 라는 API를 사용해서 thunk 함수를 생성할 수 있다. 이 API는 함수인데, 첫번째 인자에는 Action Value, 두번째 인자에는 함수가 들어간다. 이 함수에 우리가 하고 싶은 작업들을 구현하면 된다.

    두번째 들어가는 함수에서도 인자를 꺼낼 수 있는데, 첫번째 인자(arg)는 이 thunk함수가 외부에서 사용되었을 때 넣은 값을 여기에서 조회할 수 있고, 두번째 인자에서는 thnuk가 제공하는 여러가지 API 기능들이 담긴 객체를 꺼낼 수 있다. 일단 이 부분은 이해가 되지 않으면 넘어가고, 뒤에서 실제로 구현된 코드를 보면 이해될 것이다.

    // thunk 함수는 createAsyncThunk 라는 툴킷 API를 사용해서 생성한다.
    
    // __가 함수 이름에 붙는 이유는 이 함수가 thunk 함수라는 것을 표시하기 위한 
    // 개인의 convention 이다. 함수의 이름은 본인이 편한 이름으로 명명하면 된다.
    
    export const __addNumber = createAsyncThunk(
    	"ADD_NUMBER_WAIT",
    	(arg, thunkAPI)=>{},
    );

    우리가 원래 하려고 했던 3초를 기다리는 thunk 함수를 만들어보자. 아래 코드를 참고하자. 첫번째 자리에는 action value를 넣었다. 그리고 두번째에는 함수를 넣었다.

    함수 안에는 setTimeout 라는 Web API를 이용해서 3초를 기다리게 했고, 이후에 thunkAPI 안에 있는 dispatch를 통해서 우리가 원래 하려고 했던 addNumber라는 action creator를 넣었다.

    모듈 코드 추가, arg를 payload로 변경

    // src/redux/modules/counterSlice.js
    
    import { createSlice, createAsyncThunk } from "@reduxjs/toolkit";
    
    export const __addNumber = createAsyncThunk(
    	// 첫번째 인자 : action value
      "addNumber", 
    	// 두번째 인자 : 콜백함수 
      (payload, thunkAPI) => {
        setTimeout(() => {
          thunkAPI.dispatch(addNumber(payload));
        }, 3000);
      }
    );
    
    const initialState = {
      number: 0,
    };
    
    const counterSlice = createSlice({
      name: "counter",
      initialState,
      reducers: {
        addNumber: (state, action) => {
          state.number = state.number + action.payload;
        },
    
        minusNumber: (state, action) => {
          state.number = state.number - action.payload;
        },
      },
    });
    
    export const { addNumber, minusNumber } = counterSlice.actions;
    export default counterSlice.reducer;
    

    arg는 컴포넌트에서 사용자가 input에 입력한 더하고자 하는 값이다. 예전에 구현했던 것인데, 이런식으로 payload 값을 받아올 수 있다.

    컴포넌트에서의 코드는 아래와 같다. 기존에는 addNumber 라는 action creator를 dispatch했다면, 이제는 __addNumber 라는 thunk함수를 dispatch 해준다.

    App.jsx에 아래 코드를 작성해보자.

    파일이름 수정 : counter → counterSlice

    // src/App.jsx
    
    import React from "react";
    import { useState } from "react";
    import { useDispatch, useSelector } from "react-redux";
    import { minusNumber, __addNumber } from "./redux/modules/counterSlice";
    
    const App = () => {
      const dispatch = useDispatch();
      const [number, setNumber] = useState(0);
      const globalNumber = useSelector((state) => state.counter.number);
    
      const onChangeHandler = (evnet) => {
        const { value } = evnet.target;
        setNumber(+value);
      };
    
      // thunk 함수를 디스패치한다. payload는 thunk함수에 넣어주면,
      // 리덕스 모듈에서 payload로 받을 수 있다.
      const onClickAddNumberHandler = () => {
        dispatch(__addNumber(number));
      };
    
      const onClickMinusNumberHandler = () => {
        dispatch(minusNumber(number));
      };
    
      return (
        <div>
          <div>{globalNumber}</div>
          <input type="number" onChange={onChangeHandler} />
          <button onClick={onClickAddNumberHandler}>더하기</button>
          <button onClick={onClickMinusNumberHandler}>빼기</button>
        </div>
      );
    };
    
    export default App;

3. 정리

  • 리덕스 미들웨어를 사용하면, 액션이 리듀서로 전달되기 전에 중간에 어떤 작업을 더 할 수 있다.
  • Thunk를 사용하면, 객체가 아닌 함수를 dispatch 할 수 있게 해준다. [thunk의 핵심]
  • 리덕스 툴킷에서 Thunk 함수를 생성할 때는 **createAsyncThunk 를 이용한다.**
  • **createAsyncThunk() 의 첫번째 자리에는 action value, 두번째에는 함수가 들어간다.**
  • 두번째로 들어가는 함수에서 2개의 인자를 꺼내 사용할 수 있는데, 첫번째 인자는 컴포넌트에서 보내준 payload이고, 두번째 인자는 thunk에서 제공하는 여러가지 기능이다.
  • dispatch: thunk 함수 안에서 dispatch를 할 때 사용
  • getState: thunk 함수 안에서 현재 리덕스 모듈의 state 값을 사용하고 싶을 때 사용

4. thunk에서 Promise 다루기

  • (1) Todos 조회하기 기능 구현

    위에서는 thunk를 통해서 3초를 기다리고, 이후에 숫자를 더하는 기능을 구현했다. 이번에는 조금 더 실용적인 예시를 다뤄보자.

    json-server를 띄우고 Thunk 함수를 통해서 API를 호출하고 서버로부터 가져온 값을 Store에 dispatch 하는 기능. 공부한 것을 한번에 모두 사용하는 모든 개념들이 총 집합되어있는 기능이 될 것 같다.

    시작에 앞서 아래 작업을 먼저 진행해보자.

    1. json-server 설치 및 서버 가동 (db.json)

      {
        "todos": []
      }
    2. Slice로 todos 모듈 추가 구현 (우리가 이전에 챕터에서 작성했던 todos 모듈 뼈대다.)

      // src/redux/modules/todosSlice.js
      
      import { createSlice } from "@reduxjs/toolkit";
      
      const initialState = {
        todos: [],
      };
      
      export const todosSlice = createSlice({
        name: "todos",
        initialState,
        reducers: {},
      });
      
      export const {} = todosSlice.actions;
      export default todosSlice.reducer;

      모듈을 추가했으니, configStore에서도 리듀서를 추가해주자.

      // src/redux/config/configStore.js
      
      import { configureStore } from "@reduxjs/toolkit";
      /**
       * import 해온 것은 slice.reducer.
       */
      import counter from "../modules/counterSlice";
      import todos from "../modules/todosSlice";
      
      /**
       * 모듈(Slice)이 여러개인 경우
       * 추가할때마다 reducer 안에 각 모듈의 slice.reducer를 추가해줘야 한다.
       *
       * 아래 예시는 하나의 프로젝트 안에서 counter 기능과 todos 기능이 모두 있고,
       * 이것을 각각 모듈로 구현한 다음에 아래 코드로 2개의 모듈을 스토어에 연결해준 것 이다.
       */
      const store = configureStore({
        reducer: { counter: counter, todos: todos },
      });
      
      export default store;
  • (2) 구현 순서

    예제 코드는 아래 순서에 따라 진행한다.

    1. thunk 함수 구현 → __ getTodos()

    2. 리듀서 로직 구현

      1. extraReducers 사용: reducers에서 바로 구현되지 않는 기타 Reducer 로직을 구현할 때 사용하는 기능. 보통 thunk 함수를 사용할 때 extraReducers를 사용한다.

      2. [중요 🔥] 통신 진행중, 실패, 성공에 대한 케이스를 모두 상태로 관리하는 로직을 구현한다. 서버와의 통신은 100% 성공하는 것이 아니다. 서버와 통신을 실패했을때도 우리의 서비스가 어떻게 동작할지 우리는 구현해야 한다. 또한 서버와의 통신은 ‘과정' 이다. 그래서 서버와 통신을 진행하고 있는 ‘진행중' 상태일때 우리의 서비스가 어떻게 작동 해야할지 마찬가지로 구현해야 한다.

    3. 기능확인

      1. devtools 이용해서 작동 확인
    4. Store 값 조회하고, 화면에 렌더링 하기

5. 구현하기

  • (1) Thunk 함수 구현 → 서버에서 데이터 가져오기

    먼저, initialState 에 대해서 설명하면,

    isLoading은 서버에서 todos를 가져오는 상태를 나타내는 값이다. 초기값은 false이고, 서버와 통신이 시작되면 true였다가 통신이 끝나면 다시 false로 변경된다.

    error는 만약 서버와의 통신이 실패한 경우 서버에서 어떤 에러 메시지를 보내주는데, 그것을 담아놓는 값이다. 초기에는 에러가 없기때문에 null로 지정했다.

    대부분 서버와의 통신을 상태관리 할때는 data, isLoading, error 로 관리한다.

    // src/redux/modules/todosSlice.js
    
    import { createAsyncThunk, createSlice } from "@reduxjs/toolkit";
    
    const initialState = {
      todos: [],
      isLoading: false,
      error: null,
    };
    
    // 우리가 추가한 Thunk 함수
    export const __getTodos = createAsyncThunk(
      "getTodos",
      (payload, thunkAPI) => {}
    );
    
    export const todosSlice = createSlice({
      name: "todos",
      initialState,
      reducers: {},
      extraReducers: {}, // 새롭게 사용할 extraReducers를 꺼내보자.
    });
    
    export const {} = todosSlice.actions;
    export default todosSlice.reducer;

    thunk 함수를 아래와 같이 작성한다. const data는 Promise를 반환한다.

    다시 말해 axios.get() (함수)은 Promise를 반환한다. 그래서 반환된 Promise의 fullfilled 또는 rejected된 것을 처리하기 위해 async/await 을 추가했다.

    그리고 이 요청이 성공하는 경우에 실행되는 부분과 실패했을 때 실행되어야 하는 부분을 나누기 위해 try..catch 구문을 사용했다.

    // src/redux/modules/todosSlice.js
    
    import { createAsyncThunk, createSlice } from "@reduxjs/toolkit";
    import axios from "axios";
    
    const initialState = {
      todos: [],
      isLoading: false,
      error: null,
    };
    
    // 완성된 Thunk 함수
    export const __getTodos = createAsyncThunk(
      "todos/getTodos",
      async (payload, thunkAPI) => {
        try {
          const data = await axios.get("http://localhost:3001/todos");
          console.log(data);
        } catch (error) {
          console.log(error);
        }
      }
    );
    
    export const todosSlice = createSlice({
      name: "todos",
      initialState,
      reducers: {},
      extraReducers: {},
    });
    
    export const {} = todosSlice.actions;
    export default todosSlice.reducer;

    1차적으로 Thunk 함수의 구현이 끝났다. 이렇게 구현한 함수가 잘 작동하는지 1차적으로 한번 확인해보자. App.jsx 에 임시적으로 아래와 같은 코드를 구현해보겠다.

    useEffect를 통해 App.js가 mount 됐을 때 thunk 함수를 dispatch 하는 코드.

    코드 수정: 파일이름 수정 todos → todosSlice

    // src/App.jsx
    
    import React, { useEffect } from "react";
    import { useDispatch } from "react-redux";
    import { __getTodos } from "./redux/modules/todosSlice";
    
    const App = () => {
      const dispatch = useDispatch();
    
      useEffect(() => {
        dispatch(__getTodos());
      }, [dispatch]);
    
      return <div>App</div>;
    };
    
    export default App;

    App.js 에서 콘솔을 보면, json-server로부터 데이터를 잘 가져온 것을 볼 수 있다. 우리가 db에 넣어준 todo가 없으니 빈 배열로 표시되고 있다. 그리고 리덕스 devtools 에서도 dispatch된 action을 잘 보여주고 있다.

    이제 서버에서 데이터를 가져오는 부분은 문제가 없으니, 가져온 데이터를 Store로 넣는 로직을 구현해보자.

  • (2) Thunk 함수 구현 → 가져온 데이터 Store로 dispatch 하기

    썽크 함수에 아래 코드를 추가한다.

    **fulfillWithValue 는 툴킷에서 제공하는 API**

    Promise에서 **resolve**된 경우, 다시 말해 네트워크 요청이 성공한 경우에 dispatch 해주는 기능을 가진 API이다. 그리고 인자로는 payload를 넣어줄 수 있다.

    rejectWithValue 도 툴킷에서 제공하는 API 이다.

    Promise가 reject 된 경우, 네트워크 요청이 실패한 경우 dispatch 해주는 기능을 가진 API이다. 마찬가지로 인자로 어떤 값을 넣을 수 있다. 필자는 catch 에서 잡아주는 error 객체를 넣었다.

    // src/redux/modules/todosSlice.js
    
    import { createAsyncThunk, createSlice } from "@reduxjs/toolkit";
    import axios from "axios";
    
    const initialState = {
      todos: [],
      isLoading: false,
      error: null,
    };
    
    export const __getTodos = createAsyncThunk(
      "todos/getTodos",
      async (payload, thunkAPI) => {
        try {
          const data = await axios.get("http://localhost:3001/todos");
          return thunkAPI.fulfillWithValue(data.data);
        } catch (error) {
          return thunkAPI.rejectWithValue(error);
        }
      }
    );
    
    export const todosSlice = createSlice({
      name: "todos",
      initialState,
      reducers: {},
      extraReducers: {},
    });
    
    export const {} = todosSlice.actions;
    export default todosSlice.reducer;

    근데 생각해보니 각각의 API가 dispatch 해준다고 하는데, 어디로 dispatch를 해주는 것인가? dispatch라는 것은 리듀서에게 action과 payload를 전달해주는 과정인데 우리는 아직 아무런 리듀서를 작성한적이 없다. 지금부터 그 리듀서를 구현해보자.

  • (3) 리듀서 로직 구현 → extraRecuders

    Slice 내부에 있는 extraRecuders에서 아래와 같이 코드를 구현한다. extraRecuders 에서는 아래와 같이 pending, fulfilled, rejected에 대해 각각 어떻게 새로운 state를 반환할 것인지 구현할 수 있다.

    우리가 thunk 함수에서 thunkAPI.fulfillWithValue([data.data](http://data.data)) 라고 작성하면 [__getTodos.fulfilled] 이 부분으로 디스패치가 된다. 그래서 action을 콘솔에 찍어보면 fulfillWithValue([data.data](http://data.data))가 보낸 액션객체를 볼 수 있다. typepayload가 있다.

    정리하자면 원래는 우리가 action creator를 만들고,

    리듀서에서 스위치문을 통해서 구현해줘야 하는 부분을 모두 자동으로 해주고 있는 모습이다.

    // src/redux/modules/todosSlice.js
    
    import { createAsyncThunk, createSlice } from "@reduxjs/toolkit";
    import axios from "axios";
    
    const initialState = {
      todos: [],
      isLoading: false,
      error: null,
    };
    
    export const __getTodos = createAsyncThunk(
      "todos/getTodos",
      async (payload, thunkAPI) => {
        try {
          const data = await axios.get("http://localhost:3001/todos");
          return thunkAPI.fulfillWithValue(data.data);
        } catch (error) {
          return thunkAPI.rejectWithValue(error);
        }
      }
    );
    
    export const todosSlice = createSlice({
      name: "todos",
      initialState,
      reducers: {},
      extraReducers: {
        [__getTodos.fulfilled]: (state, action) => {
          console.log("fulfilled 상태", state, action); // Promise가 fullfilled일 때 dispatch
        },
      },
    });
    
    export const {} = todosSlice.actions;
    export default todosSlice.reducer;

    이제, 각각의 상태로 thunkAPIdispatch 해주는 것을 확인했으니, 실제로 리듀서 로직을 구현해보자. db에 임시 데이터가 없으니 구분하기가 힘들다. { "id": 1, "title": "hello world!" } 라는 테스트 Todo를 하나 추가하고 진행해보자.

    아래와 같이 extraReducerspendingrejected 상태에 따른 리듀서 로직을 추가로 구현해준다.

    // src/redux/modules/todosSlice.js
    
    import { createAsyncThunk, createSlice } from "@reduxjs/toolkit";
    import axios from "axios";
    
    const initialState = {
      todos: [],
      isLoading: false,
      error: null,
    };
    
    export const __getTodos = createAsyncThunk(
      "todos/getTodos",
      async (payload, thunkAPI) => {
        try {
          const data = await axios.get("http://localhost:3001/todos");
          return thunkAPI.fulfillWithValue(data.data);
        } catch (error) {
          return thunkAPI.rejectWithValue(error);
        }
      }
    );
    
    export const todosSlice = createSlice({
      name: "todos",
      initialState,
      reducers: {},
      extraReducers: {
        [__getTodos.pending]: (state) => {
          state.isLoading = true; // 네트워크 요청이 시작되면 로딩상태를 true로 변경한다.
        },
        [__getTodos.fulfilled]: (state, action) => {
          state.isLoading = false; // 네트워크 요청이 끝났으니, false로 변경한다.
          state.todos = action.payload; // Store에 있는 todos에 서버에서 가져온 todos를 넣는다.
        },
        [__getTodos.rejected]: (state, action) => {
          state.isLoading = false; // 에러가 발생했지만, 네트워크 요청이 끝났으니, false로 변경한다.
          state.error = action.payload; // catch 된 error 객체를 state.error에 넣는다.
        },
      },
    });
    
    export const {} = todosSlice.actions;
    export default todosSlice.reducer;
  • (4) 기능 확인

    리덕스 devtools를 보면 우리가 만든 기능이 정상적으로 작동하고 있음을 알 수 있다.

    App.jsx가 mount됐을 때 Thunk 함수가 dispatch되었고, Axios에 의해서 네트워크 요청이 시작됐다. 그래서 todos의 isLoading이 true로 변경된 것을 알 수 있다.

    네트워크 요청이 끝났고, 성공했다. 그래서 thunkAPI.fulfillWithValue(data.data); 에 의해서 생성된 todos/getTodos/fulfillled 라는 액션이 dispatch가 되었고, 그로 인해 리듀서에서 새로운 payload를 받아 todos를 업데이트 시켰다. 그리고 네트워크가 종료되었으니 isLoading상태도 false로 변경되었다.

    rejected 가 된 것을 보고자 한다면, 의도적으로 실패하게 네트워크 요청을 하면 된다. 이상한 url로 네트워크 요청을 보내는 것 등등.

    결과를 보면, 역시 정상적으로 작동했음을 알 수 있다.

  • (5) Store 값 조회하고, 화면에 렌더링 하기

    이제 모든 로직을 구현했으니, 이제 useSelector를 이용해서 store값을 조회하고, 화면에 렌더링해보자. 이 부분은 기존과 동일하다. 다만 각각의 상태에 따라 화면이 다르게 표시되어야 하는 부분이 추가되었다.

    서버에서 data를 가져오는 동안에는 우리의 서비스를 사용하는 유저에게 ‘로딩중' 임을 표시한다. 그리고 만약에 네트워크가 실패해서 정보를 가져오지 못한 경우, 에러 메시지를 보여준다. 위 두가지가 모두 아닌 경우에는 서버에서 불러온 todos를 화면에 보여준다. App.jsx 에 아래 코드를 작성해보자.

    // src/App.jsx
    
    import React, { useEffect } from "react";
    import { useDispatch, useSelector } from "react-redux";
    import { __getTodos } from "./redux/modules/todosSlice";
    
    const App = () => {
      const dispatch = useDispatch();
      const { isLoading, error, todos } = useSelector((state) => state.todos);
    
      useEffect(() => {
        dispatch(__getTodos());
      }, [dispatch]);
    
      if (isLoading) {
        return <div>로딩 중....</div>;
      }
    
      if (error) {
        return <div>{error.message}</div>;
      }
    
      return (
        <div>
          {todos.map((todo) => (
            <div key={todo.id}>{todo.title}</div>
          ))}
        </div>
      );
    };
    
    export default App;
profile
Junior Frontend Developer

0개의 댓글