[TIL] 내일배움캠프 React 과정 2022.12.18_Thunk

Redux thunk 이해하기

Redux 미들웨어란?

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

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

 

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

 

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

 

 

Redux-thunk란?

dispatch(함수) → 함수실행 → 함수안에서 dispatch(객체)

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

 

thunk 사용 순서

  1. thunk 함수 만들기
  2. extraReducer에 thunk 등록하기
  3. dispatch(thunk 함수) 하기

 

 thunk 함수 만들기 

// thunk 함수는 createAsyncThunk 라는 툴킷 API를 사용해서 생성한다.

// __가 함수 이름에 붙는 이유는 이 함수가 thunk 함수라는 것을 표시하기 위한 개인의 convention이다. 

export const __addNumber = createAsyncThunk(
   // 첫번째 인자 : action value
  "addNumber", 
  
   // 두번째 인자 : 콜백함수 
  (payload, thunkAPI) => {
    setTimeout(() => {
      thunkAPI.dispatch(addNumber(payload));
    }, 3000);
  }
);

thunk 함수의 역할은 “3초를 기다리는 것” dl다. 3초가 지난 후에 원래 하려고 했던 ADD_NUMBER를 해주는 것 까지가 thunk함수가 해야 할 일인 것이다.

 

툴킷에서는 createAsyncThunk라는 API를 사용해서 thunk 함수를 생성할 수 있다. 이 API는 함수인데, 첫 번째 인자에는 Action Value, 두 번째 인자에는 함수가 들어간다. 두 번째로 들어가는 콜백함수에서도 인자를 꺼낼 수 있는데, 첫 번째 인자(payload)는 thunk함수가 외부에서 사용되었을 때 넣은 값을 조회할 수 있고, 두 번째 인자에서는 thnuk가 제공하는 여러 가지 API 기능들이 담긴 객체를 꺼낼 수 있다.

 

// 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;

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

 

// src/App.jsx
import { useState } from "react";
import { useDispatch, useSelector } from "react-redux";
import { __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));
  };

  return (
    <div>
      <div>{globalNumber}</div>
      <input type="number" onChange={onChangeHandler} />
      <button onClick={onClickAddNumberHandler}>더하기</button>
    </div>
  );
};

App.js에서 __addNumber라는 thunk함수를 dispatch 해 준다.

 

 

Redux-thunk 정리하기

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

 

Redux Thunk 구현하기 

구현 순서

  1. thunk 함수 구현 → __ getTodos()
  2. 리듀서 로직 구현
    1. extraReducers 사용: reducers에서 바로 구현되지 않는 기타 Reducer로직을 구현할 때 사용하는 기능이다. 보통 thunk 함수를 사용할 때 extraReducers를 사용한다.
    2. [중요 🔥] 통신 진행  중, 실패, 성공에 대한 케이스를 모두 상태로 관리하는 로직을 구현한다. 서버와의 통신은 100% 성공하는 것이 아니기 때문에 서버와 통신을 실패했을 때는 서비스가 어떻게 동작할지, 또한 서버와 통신을 진행 중인 상태일 때는 서비스가 어떻게 작동할지도 마찬가지로 구현해야 한다.
  3. 기능 확인: devtools 이용해서 작동 확인
  4. Store 값 조회하고, 화면에 렌더링 하기

 

 기초 세팅 

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

{
  "todos": []
}

 

2. Slice로 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;

 

3. configStore에 Reducer 추가

// src/redux/config/configStore.js
import { configureStore } from "@reduxjs/toolkit";
/* import 해온 것은 slice.reducer이다. */
import todos from "../modules/todosSlice";

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

export default store;

 

 

 서버에서 데이터 가져오기 

// 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;

initialState

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

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

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

 

thunk 함수

const data는 Promise를 반환한다. 다시 말해 axios.get()이 Promise를 반환하는 것이다. 그리고 반환된 Promise의 fullfilled 또는 rejected된 것을 처리하기 위해 async/await을 추가했다. 또한 요청이 성공하는 경우에 실행되는 부분과 실패했을 때 실행되는 부분을 나누기 위해 try, catch 구문을 사용했다.

 

// 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에 useEffect를 통해 mount 됐을 때 thunk 함수를 dispatch 하는 코드를 작성해서 thunk 함수가 잘 작동하는지 확인한다.

 

콘솔을 보면 json-server로부터 데이터를 잘 가져온 것을 볼 수 있다. 아직 db에 넣어준 todo가 없기 때문에 빈 배열로 표시되고 있다. 

 

 

 가져온 데이터를 Store로 dispatch 하기 

// 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");
      // 코드 추가
      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;

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

 

rejectWithValue도 툴킷에서 제공하는 API인데 Promise가 reject 된 경우, 네트워크 요청이 실패한 경우 dispatch 해주는 기능을 가진 API다. 마찬가지로 인자로 어떤 값을 넣을 수 있다. 상단의 코드에서는 catch에서 잡아주는 error 객체를 넣었다.

 

리덕스 devtools에서 Thunk 함수가 잘 작동되고 있는지를 확인할 수 있다.

 

 

 리듀서 로직 구현 → extraRecuders 

// src/redux/modules/todosSlice.js
import { createAsyncThunk, createSlice } from "@reduxjs/toolkit";
import axios from "axios";

...initialState 생략...

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;

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

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

 

 

 devtools 이용해서 작동 확인 

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

 

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

 

 

 Store 값 조회하고, 화면에 렌더링 하기 

// 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;

이제 useSelector를 이용해서 store값을 조회하고, 화면에 렌더링 할 것인데 이 부분은 기존과 동일하다. 다만 각각의 상태에 따라 화면이 다르게 표시되어야 하는 부분이 추가되었다. 서버에서 data를 가져오는 동안에는 우리의 서비스를 사용하는 유저에게 ‘로딩 중' 임을 표시한다. 그리고 만약에 네트워크가 실패해서 정보를 가져오지 못한 경우, 에러 메시지를 보여준다. 위 두 가지가 모두 아닌 경우에는 서버에서 불러온 todos를 화면에 보여준다.

 

 

thunk 정리하기

  • thunk 함수는 Reducers가 아닌 외부에서 작성한 것 이므로, extraReducers를 사용해야 한다.
  • thunkAPI를 이용해서 Promise를 다룰 수 있다.
  • 서버에서 가져오는 데이터는 로딩 중, 성공, 실패로 나누어서 상태를 관리하고, 컴포넌트 단에서도 이에 따라 다르게 조건부 렌더링을 한다.

// db.json
{
  "todos": [
    { "id": 1, "title": "리액트 정복", "content": "리액트랑 친해지기", "isDone": false },
    { "id": 2, "title": "코딩 공부하기", "content": "성실하게! 열심히!", "isDone": true }
  ]
}

일단 JSON 서버의 데이터를 가져오는 데까지는 성공했다! 개인 과제로 만들었던 투두 리스트를 axios와 jason server를 이용해서 구현해보려 한다.