본문 바로가기

프로젝트

[redux] thunk를 활용해서 비동기함수를 redux에서 관리하기

내가 구현 하고자 하는 것 

 

api통신을 하여 가져온 데이터를 전역적 상태 관리 시스템인 redux에 저장하고 

그 데이터를 여러 컴포넌트에서 사용하고자 했다. 

그런데 api통신하는 로직이 여러번 반복되는 것을 보고 

따로 파일을 만들어서 그 안에서 모든 비동기 함수를 관리하여 

전역적으로 사용하여 

코드의 가독성을 높이고 싶었다. 

 

 

 

 

한 개의 파일을 별도로 만들어서 비동기 함수를 작성하고

다른 컴포넌트에서 import  하여 사용하려면 여러가지 선택지가 있다고 생각했다. 

 

1. 자바스크립트 파일에서 함수를 export 하는 것 

2. 컴포넌트 파일을 만들어서 export 하는 것

3.  custom-hook을 만들어서 export 하는 것

4.redux파일을 만들어서 export 하는것  

 

 

 

 

 

 

결론부터 말하자면 사용가능한 것은 두 가지가 남았다. 3번 custom-hook과 4번 redux 

 

왜 1번이 안되는가?

일반 자바스크립트 파일에서 비동기 함수를 작성하는 것 까지는 가능했다. 

그러나 가져온 데이터를 dispatch하려고 할 때 문제가 발생했다. 

 

import { useDispatch } from "react-redux";

export const sendRequest = () => {
  const authKey = localStorage.getItem("authKey");
  const dispatch = useDispatch(); //에러가 났다.
  fetch("http://localhost:8000/missions/", {
    method: "GET",
    headers: {
      "Content-Type": "application/json",
      Authorization: authKey,
    },
  })
    .then((res) => res.json())
    .then((responseData) => useDispatch());
};

 

5번째 줄에서 에러가 났다. 

왜냐하면 

 

 

useDispatch는 내가 작성한 sendRequest 함수에서 호출 됐는데, 

이 함수는 리액트 함수 컴포넌트나 커스텀 리액트 훅 함수가 아니다.

라는 에러다. 

즉, useDispatch는 리액트 함수 컴포넌트나 커스텀 리액트 훅 함수에서만 호출 될 수 있다는 이야기다. 

 

그러므로 1번은 불가능하다는 걸 알았다. 

 

 

왜 2번은 안되는가? 

 

리액트 함수 컴포넌트는 로직만을 담은 것이 아니라, 

리액트 엘리먼트를 return하는 컴포넌트가 리액트 컴포넌트기 때문에 

단순히 로직을 export하기 위해서 사용할 수는 없다.

 

 

나는 실현 가능한 3번과 4번 선택지 중, 

왜 redux를 선택했는가?

 

사실 처음엔 3번 custom hooks를 구현했다.

 

 

import { useCallback, useReducer } from "react";

const initialState = {
  loading: false,
  error: null,
  data: null,
  extra: null,
  identifier: null,
};
const httpReducer = (curhttpState, action) => {
  switch (action.type) {
    case "SEND":
      return {
        loading: true,
        error: null,
        data: null,
        extra: null,
        identifier: action.identifier,
      };
    case "RESPONSE":
      return {
        ...curhttpState,
        loading: false,
        data: action.responseData,
        extra: action.extra,
      };
    case "ERROR":
      return { loading: false, error: action.errorMessage };
    case "CLEAR":
      return initialState;
    default:
      throw new Error("Should not be reached!");
  }
};

const useHttp = () => {
  const [httpState, dispatchHttp] = useReducer(httpReducer, initialState);

  const clear = useCallback(() => {
    dispatchHttp({ type: "CLEAR" });
  }, []);

  const sendRequest = useCallback(
    (url, method, headers, body, reqExtra, reqIdentifier) => {
      dispatchHttp({ type: "SEND", identifier: reqIdentifier });
      fetch(url, {
        method: method,
        body: body,
        headers: headers,
      })
        .then((response) => {
          return response.json();
        })
        .then((responseData) => {
          dispatchHttp({
            type: "RESPONSE",
            responseData: responseData,
            extra: reqExtra,
          });
        })
        .catch((error) => {
          dispatchHttp({
            type: "ERROR",
            errorMessage: "Something went wrong!",
          });
        });
    },
    []
  );

  return {
    httpIsLoading: httpState.loading,
    data: httpState.data,
    error: httpState.error,
    sendRequest: sendRequest,
    clear: clear,
    reqExtra: httpState.extra,
    reqIdentifier: httpState.identifier,
  };
};

export default useHttp;

 

그렇게 해서 Mission.jsx 파일에서 사용한 코드는 아래와 같다. 

 

   //미션 내용 가져오기
  useEffect(() => {
    sendRequest("http://localhost:8000/missions/", "GET", {
      Authorization: authKey,
      "Content-Type": "application/json",
    });

    console.log(authKey);
  }, [authKey, sendRequest]);

  //가져온 data를 setState로 state 설정해주기 
  useEffect(() => {
    if (!httpIsLoading && !error && data) {
      setLoadedData(data);
    }
  }, [data, httpIsLoading, error]);

 

 

그런데 문제가 생겼다. 

로직만 따로 저장될 뿐, 

가져온 데이터는 prop 체인을 통해 자식 컴포넌트들에게 전달전달 해야했기 때문이다. 

그렇게 되면 전역적으로 데이터를 관리하고자하는 나의 의도와 맞지 않았다. 

 

너무 많은 컴포넌트들이 해당 데이터를 원한다면 

코드가 장황해지고 가독성도 떨어지기에 좋지 않아보였다. 

 

즉, 남은 것은 4번, redux였다. 

 

문제는 어떻게 비동기로 가져온 data를 redux store에 dispatch를 통해 저장하여 관리하느냐였다.

 

 

리덕스는 세가지 원칙이 있었다.

 

 

1. 애플리케이션의 전역적 상태는 한 개의 저장소안에서 object tree구조로 저장된다. 

2. state는 읽기전용이다. 

3. 변화는 순수 함수로 작성되어야 한다. 

 

이 세 가지 원칙 중 주목해야 할 것은 3번이다. 

리덕스에는 어떤 side effect도 넣지 말아야 한다는 것이다. 

즉 리덕스는 side effect 즉 비동기함수 등을 사용하는 행위를 할 수 없다. 

 

그런데 내가 하고자 하는 것은 비동기 함수를 리덕스에 사용하고자 했다. 

어디서 사용할 수 있을까?

 

redux 액션 크리에이터에서다. 

 

내용은 아래의 글에서 볼 수 있다.

what is a thunk

Actions are Boring

Isn’t it kind of funny that Redux’s so-called “actions” don’t actually do anything? They’re just objects.
Plain and simple and inert. Wouldn’t it be cool if you could actually make them do something? Like, say, make an API call, or trigger other actions?
Since reducers are supposed to be “pure” (as in, they don’t change anything outside their scope) we can’t do any API calls or dispatch actions from inside a reducer. If you want an action to do something, that code needs to live inside a function. That function (the “thunk”) is a bundle of work to be done.

 

리덕스의 actions객체는 아무것도 하지 않고 그저 객체일 뿐이다. 평범하고 간단하다. 

리듀서가 pure해야 하므로 우리는 api 호출이나 action을 reducer 내에서 dispatch 할 수 없다. 

만약에 액션이 무언가를 하게 하고 싶다면, 그 코드는 함수 내부에 포함해야 한다. 

그 함수("thunk"라는 함수) 는 수행되어야 할 일의 집합체이다. 

 

 

 

이것은 udemy 에서 하고 있는  maximilian 의 react 완벽가이드 강의에서 들어본 내용이기도 하다. 

(이하 max강의라고 칭하겠다)

thunk라는 개념에 대해 정확히 이해하지 못하고 처음에 들었던 기억이 난다. 

 

 

 

나는 thunk를 고차함수 와 같은 것이라고 해석해본다.(잘 모르겠지만)

function wrapper_function() {
  // this one is a "thunk" because it defers work for later:
  return function thunk() {   // it can be named, or anonymous
    console.log('do stuff now');
  };
}

위의 글에서 코드를 인용해와 보자면 위와같은 것이다. 

어떤 함수를 return 하는 함수를 thunk 라고 되어있다. 

 

그러니까 리덕스에서는 비동기함수를 담을 수 없지만,

한번 함수로 감싸서 그 안에서 비동기함수도 담고, 

dispatch도 하게 하는 것이다. 

max 강의에서는 

아래와 같이 구현했었다. 

 

 

import { uiSliceActions } from "./ui-slice";
import { cartActions } from "./cart-slice";


export const sendCartData = (cart) => {
  return async (dispatch) => {
    dispatch(
      uiSliceActions.showNotification({
        status: "pending",
        title: "Sending...",
        message: "Sending cart data!",
      })
    );

    const sendRequest = async () => {
      const response = await fetch(
        "https://react-http-55f5b-default-rtdb.firebaseio.com/cart.json",
        {
          method: "PUT",
          body: JSON.stringify({
            items: cart.items,
            totalQuantity: cart.totalQuantity,
          }),
        }
      );

      if (!response.ok) {
        throw new Error("Something goes wrong!");
      }
    };
    try {
      await sendRequest();
      dispatch(
        uiSliceActions.showNotification({
          status: "success",
          title: "Success!",
          message: "Sent cart data successfully!",
        })
      );
    } catch (error) {
      dispatch(
        uiSliceActions.showNotification({
          status: "error",
          title: "Error!",
          message: "Sending cart data failed!",
        })
      );
    }
  };
};

 

 

 

 

 

 

 

 

 

 

이것을 컴포넌트에서 사용하는 방법은 아래와 같았다. 

 

 

App.jsx

import { useSelector, useDispatch } from "react-redux";
import { useEffect } from "react";

import Cart from "./components/Cart/Cart";
import Layout from "./components/Layout/Layout";
import Products from "./components/Shop/Products";
import Notification from "./components/UI/Notification";
import { sendCartData, fetchCartData } from "./store/cart-actions";

let isInitial = true;

function App() {
  const dispatch = useDispatch();
  const showCart = useSelector(state => state.uiReducer.showCart);
  const cart = useSelector(state => state.cart);
  const notification = useSelector(state => state.uiReducer.notification);

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

  useEffect(() => {
    if (isInitial) {
      isInitial = false;
      return;
    }

    if (cart.changed) {
      dispatch(sendCartData(cart));
    }
  }, [cart, dispatch]);

  return (
    <>
      {notification && (
        <Notification
          status={notification.status}
          title={notification.title}
          message={notification.message}
        />
      )}
      <Layout>
        {showCart && <Cart />}
        <Products />
      </Layout>
    </>
  );
}

export default App;

 

해석을 해보자면, 처음에는 fetchCartData를 실행하게 된다. 지금 현재 isInitial 이 true이므로 

아래의 useEffect내에서의 sendCartData()는 실행되지 않는다. 

 

그리고 다른 컴포넌트에서 장바구니에 물건을 담게 되고,

redux store로 관리되고 있는 cart가 변경되게 된다. 

 

cart의 상태를 app에서 구독하고 있으며, cart가 변경되면 두번째 useEffect가 실행이 된다. 

 

 

 

그런데 우리가 쓰던 dispatch와는 좀 다르다. 

 

max 강의에 의하면,

dispatch는 두 가지 일을 할 수 있다고 한다. 

 

1. type속성을 가진 action object를 받아들인다. 

2. 함수를 반환하는 action creator를 받아들인다. 

 

만일 action object가 아닌 action creator를 dispatch에 전달한다면 

자동으로 함수를 실행한다고 한다. 

 

 

사실 redux-thunk라는 라이브러리가 있기는 하지만,

강사 max는 직접 구현하였다. 

 

 

 

이것은 github에서 async 액션 크리에이터에 대해 설명한 내용이다. 

https://github.com/reduxjs/redux/issues/533

 

Simpler introduction to async action creators · Issue #533 · reduxjs/redux

I asked someone from my team to study the docs about async actions, and he found them very hard to follow. This confirmed my idea that there should be a simpler introduction about this fundamental ...

github.com

 

 

 

In Redux, basic action creators are pure functions with no side effects. This means that they can't directly execute any async code, as the Flux architecture instead suggests, because async code has side effects by definition.
Fortunately, Redux comes with a powerful middleware system and one specific middleware library, redux-thunk, that allows us to integrate async calls into our action creators in a clean and easy way. 

원래 리덕스에서 기본 action creator는 순수한 함수고 side effect가 존재하지 않는다. 

이것은 async code는 정의된 내용에 의해 side effect가 포함되어 있기 때문에 redux에서 사용할 수 없다는 말이 된다. 

redux는 redux-thunk와 같은 미들웨어 시스템이 들어 있으며 redux-thunk 를 사용하면 async 호출을 action creator에서 쉽고 깔끔한 방법으로 사용가능하다. 

 

 

 

위의 사이트에서 가져온 async thunk function을 보면 max 의 thunk 함수와 사용방법이 똑같았다. 

 

// renamed optimistic action creator - this won't be called directly 
// by the React components anymore, but from our async thunk function
export function addTodoOptimistic(text) {
  return { type: ADD_TODO, text };
}

// the async action creator uses the name of the old action creator, so 
// it will get called by the existing code when a new todo item should 
//  be added
export function addTodo(text) {
  // we return a thunk function, not an action object!
  // the thunk function needs to dispatch some actions to change the 
  // Store status, so it receives the "dispatch" function as its first parameter
  return function(dispatch) {
    // here starts the code that actually gets executed when the addTodo action 
    // creator is dispatched

    // first of all, let's do the optimistic UI update - we need to 
    // dispatch the old synchronous action object, using the renamed 
    // action creator
    dispatch(addTodoOptimistic(text));

    // now that the Store has been notified of the new todo item, we 
    // should also notify our server - we'll use here ES6 fetch function 
    // to post the data
    fetch('/add_todo', {
      method: 'post',
      body: JSON.stringify({
        text
      })
    }).then(response => {
      // you should probably get a real id for your new todo item here, 
      // and update your store, but we'll leave that to you
    }).catch(err => {
    // Error: handle it the way you like, undoing the optimistic update,
    //  showing a "out of sync" message, etc.
    });
  // what you return here gets returned by the dispatch function that used   
  // this action creator
  return null; 
  }
}

 

그러므로 위의 thunk 함수를 사용해서 구현한다면 

내가 원하는 대로 redux에서 data도 전역적으로 관리하고, 

비동기 함수도 전역적으로 관리 할 수 있을 것같다. 

구현해본 결과는 다음에 다시 정리해보아야겠다.