처음 만난 리덕스 (Redux) 문서


11.5 Thunk 사용 패턴

그렇다면 실제 Thunk를 사용하는 패턴에는 어떤 것들이 있을까요?

Thunk에서는 다른 Action을 dispatch하거나, Redux Store에 접근해서 state를 읽어올 수 있습니다.
이러한 점을 활용해서 다양한 패턴으로 Thunk를 사용할 수 있는데, 지금부터는 Thunk를 사용하는 패턴에 대해서 하나씩 살펴보도록 하겠습니다.

11.5.1 Dispatching Actions

먼저 첫 번째 Thunk 사용 패턴은 Dispatching Actions입니다.
Thunk 함수 내에서 다른 Redux Action을 Dispatch하는 것이죠.
그리고 Action뿐만 아니라 또 다른 Thunk 함수를 Dispatch 할 수도 있습니다.

function complexSynchronousThunk(someValue) {
      return (dispatch, getState) => {
        dispatch(otherActionCreator(someValue));
        dispatch(otherThunkActionCreator());
      }
}

이 코드는 Thunk 함수 내에서 다른 Action 또는 Thunk를 Dispatch하는 예시 코드입니다.

먼저 otherActionCreator라는 Action Creator를 통해서 만들어진 Action 객체를 Dispatch함수를 사용해서 Dispatch하고 있습니다.
그리고 이후에 otherThunkActionCreator라는 Thunk Action Creator를 사용해서 만든 Thunk 함수를 Dispatch합니다.

이러한 형태로 여러가지 Action이나 Thunk를 Dispatch하게 되면 동기적으로 실행됩니다.
그래서 여기 나온 예시 함수의 이름에도 Synchronous라는 단어가 들어가 있는 것을 볼 수 있죠.
이 패턴은 Redux의 Action들을 순서대로 Dispatch 해야하는 경우에 사용하는 패턴이라고 보면 됩니다.

11.5.2 State에 접근

다음 두 번째 Thunk 사용 패턴은 State에 접근하는 패턴입니다.
Thunk 함수 내에서 Redux Store에 접근할 수 있기 때문에, Store의 State에 접근하여 다양한 작업들을 처리할 수 있습니다.

const MAX_ITEM_COUNT = 10;

function addItemIfPossible(newItem) {
    return (dispatch, getState) => {
        const state = getState();

        if (state.items.length < MAX_ITEM_COUNT) {
            dispatch(addItem(newItem));
        }
    }
}

먼저 state의 값에 따라서 Action을 Dispatch하거나 하지 않거나 할 수 있습니다.
조건부 dispatch가 되는 것이죠.

이 코드에서는 최대 아이템의 개수를 10개로 설정해놓고, 현재 아이템의 개수가 최대 개수를 넘지 않은 경우에만 아이템을 추가하는 addItem Action을 dispatch하도록 하고 있습니다.
이것이 가능한 이유는 Thunk 함수 내에서 Redux store에 접근이 가능하기 때문입니다.

Thunk함수 내에서 state에 접근하는 다른 예시로, 특정 Action을 Dispatch한 이후 state에 접근하는 경우가 있습니다.

function checkStateAfterDispatch() {
    return (dispatch, getState) => {
        const beforeDispatchState = getState();
        dispatch(firstAction());

        const afterDispatchState = getState();
        if (afterDispatchState.someField != beforeDispatchState.someField) {
            dispatch(secondAction());
        }
    }
}

이 코드는 firstAction을 dispatch하기 전에 state를 beforeDispatchState로 저장하고, firstAction이 dispatch 된 이후의 state를 afterDispatchState로 저장합니다.
그리고 이 두 개의 state 내에 들어있는 어떤 변수의 값을 비교하여 조건을 만족할 경우에만 secondAction을 dispatch 합니다.

이러한 방법이 가능한 이유는 Redux에서 Action이 Dispatch되면, 해당 Action을 처리하는 Reducer에서 곧바로 state가 동기적으로 업데이트 되기 때문입니다.
그래서 Action Dispatch 이후에 getState() 함수를 사용해서 State에 접근하게 되면, 업데이트 된 state 값을 가져올 수 있는 것입니다.

Thunk함수 내에서 state에 접근하는 마지막 예시는, State에 있는 다른 정보를 이용해서 Action을 Dispatch 해야하는 경우가 있습니다.

이것을 보통 cross-slice state 문제라고 부르는데, 여기서 slice는 뒤에 나올 Redux Toolkit에서 사용하는 용어입니다.
쉽게 말하면, Redux Store를 주제별로 분리해놓은 조각이라고 생각하면 됩니다.
Slice에 대한 자세한 설명은 Redux Toolkit을 다룰 때 하도록 하겠습니다.

어찌됐든 각각의 slice는 분리되어 있기 때문에 자신의 state에만 접근이 가능한데, 아주 흔한 경우는 아니지만 다른 slice의 데이터에 접근해야 할 경우가 있습니다.
이러한 경우 Thunk함수를 사용하면 이 cross-slice state 문제를 해결할 수 있습니다.

function crossSliceActionThunk() {
    return (dispatch, getState) => {
        const state = getState();
        // Root state로부터 각 slice의 state를 가져옴
        const { stateOfSliceA, stateOfSliceB } = state;

        // 두 slice의 state를 사용하여 Action Dispatch
        dispatch(crossSliceAction(stateOfSliceA, stateOfSliceB));
    }
}

지금 보시는 코드는 crossSliceActionThunk라는 이름의 Thunk 함수를 사용하여, cross-slice state 문제를 해결하는 예시 코드입니다.

먼저 Redux Store의 Root state로부터 각 slice의 state를 가져오고, 이를 이용하여 crossSliceAction이라는 Action을 dispatch하게 됩니다.
이렇게 Thunk를 사용하면, 여러 slice에 흩어져 있는 state를 가져와서 Action을 처리할 수 있게 됩니다.

11.5.3 Async Logic과 Side Effects

다음으로 세 번째 Thunk 사용 패턴은 Async Logic과 Side Effects입니다.

Async Logic은 말 그대로 비동기 로직을 의미하며, Side Effects는 Reducer 외부에서 보여질 수 있는 상태의 변경 또는 동작을 의미했었죠.
참고로 큰 의미에서 Async Logic은 Side Effects에 포함되는 개념이기도 합니다.
그리고 이러한 작업들은 Reducer에서는 수행될 수 없습니다.

왜냐하면 우리가 앞에서 배운 것처럼, Reducer는 Pure Function이어야 하기 때문에 함수 내부에서 Side Effects를 수행해서는 안되기 때문입니다.
앞에서 배운 Reducer의 규칙들 중에서 세 번째 규칙이 바로 그 규칙이었습니다.

“Not do any asynchronous logic or other “side effects”.”

Pure function에서 리턴 값과 직접적으로 관련이 없는 모든 동작은 side effect라고 생각하면 됩니다.
예를 들면, 콘솔에 로그를 출력하거나 파일을 저장하는 등의 동작이 있겠죠.
그래서 Redux에서는 바로 이 Thunk를 사용해서 Side Effects를 가능하게 만듭니다.

여러가지 다양한 Side Effect들이 있지만, 대표적인 Side Effect이자 Async Logic으로는 서버에 보내는 요청이 있습니다.
서버와의 통신은 비동기로 이뤄지기 때문에 이를 Async Request라고 부르기도 합니다.
그리고 Redux에서 이러한 Async Request를 수행하기 위해서 일반적으로 따라야 할 수행 순서가 있습니다.
지금부터 그 순서에 대해서 한 번 살펴보도록 하겠습니다.

  1. Async Request를 보내기 전에 Action Dispatch
    • 서버로 요청을 보냈고 응답을 기다리는 중이라는 것을 state에 업데이트
    • ex) fetchDataRequested
  2. Async Request가 성공한 경우 Action Dispatch
    • 서버로부터 성공 응답을 받았다는 것과 함께 받은 데이터를 state에 업데이트
    • ex) fetchDataSucceeded
  3. Async Request가 실패한 경우 Action Dispatch
    • 서버로부터 실패 응답을 받았다는 것과 함께 받은 에러를 state에 업데이트
    • ex) fetchDataFailed

먼저 서버로 요청을 보내기 전에 Action을 Dispatch해야 합니다.
이 경우에 Dispatch하는 Action의 이름은 보통 Requested로 끝나게 됩니다.
여기서 해주어야 하는 일은 서버로 요청을 보냈고 응답을 기다리는 중이라는 것을 state에 업데이트 하는 것입니다.
이렇게 함으로써 요청이 진행중이라는 것을 UI나 다른 Action에서 알 수 있게 하는 것이죠.
보통 pending이라는 이름의 필드를 사용해서 요청 진행 여부를 나타냅니다.

그리고 두 번째 순서는 서버로부터 성공 응답을 받은 경우입니다.
보통 HTTP Status Code가 200으로 오는 경우가 여기에 해당되겠죠.
이 때도 역시 Action을 Dispatch해야 하는데, 이 경우에 Dispatch하는 Action의 이름은 보통 성공했다는 의미를 가지는 Succeeded로 끝납니다.
그리고 이 단계에서 서버로부터 성공 응답을 받았다는 것과 함께 서버로부터 받은 데이터를 state에 업데이트하게 됩니다.

마지막 세 번째 순서는 서버로부터 실패 응답을 받은 경우입니다.
보통 HTTP Status Code가 400번대 또는 500번대로 오는 경우가 여기에 해당됩니다.
이 때도 역시 Action을 Dispatch해야 하는데, 이 경우에 Dispatch하는 Action의 이름은 보통 실패했다는 의미를 가지는 Failed로 끝납니다.
그리고 이 단계에서 서버로부터 실패 응답을 받았다는 것과 함께 서버로부터 받은 에러 객체 또는 에러 메시지 등을 state에 업데이트하게 됩니다.

하나의 Async Request에 대해서 1, 2, 3이 모두 수행되는 것은 아니고, 서버에 보낸 요청이 성공하는 경우에는 1, 2 순서로 Action이 Dispatch되고, 실패하는 경우에는 1, 3 순서로 Action이 Dispatch 됩니다.

Async Request를 수행하는 과정에 이러한 일반적으로 정해진 순서가 있는 이유는, 서버로 보낸 요청에 대한 상태를 다른 곳에서 알 수 있어야 하기 때문입니다.
이 state값을 이용하면 화면에 서버 통신이 진행 중이라는 것을 보여줄 수도 있고, 성공 또는 실패할 경우의 메시지를 화면에 보여줄 수도 있습니다.

이렇게 글로만 보면 아직 잘 와닿지 않을 것이기 때문에, 실제 예시 코드를 보면서 Async Request 수행 순서를 다시 한 번 살펴보도록 하겠습니다.

function fetchData() {
    return (dispatch, getState) => {
        dispatch(fetchDataRequested());

        apiClient.get('/api/data').then(
            (response) => {
                dispatch(fetchDataSucceeded(response.data));
            },
            (error) => {
                dispatch(fetchDataFailed(error.message));
            }
        );
    }
}

이 코드는 Thunk를 사용해서 Redux에서 Async Request를 처리하는 실제 예시 코드입니다.
여기에서는 Promise를 사용하여 비동기 작업을 처리하고 있습니다.

먼저 서버에 요청을 보내기 전에, fetchDataRequested()라는 이름의 Action Creator를 실행해서 Action 객체를 생성하고 이를 dispatch합니다.
이 과정에서 서버로 요청을 보냈고 응답을 기다리는 중이라는 것을 state에 업데이트하게 됩니다.

그리고 일정 시간이 지나서 서버로부터 성공 응답을 받게 되면, fetchDataSucceeded()라는 이름의 Action Creator를 실행해서 Action 객체를 생성하고 이를 dispatch합니다.
이 과정에서는 서버로부터 성공 응답을 받았다는 것과 함께 받은 데이터를 state에 업데이트하게 됩니다.
여기에서는 response.data가 서버에서 받은 데이터를 나타냅니다.

그리고 만약 서버로부터 실패 응답을 받게 된다면, fetchDataFailed()라는 이름의 Action Creator를 실행해서 Action 객체를 생성하고 이를 dispatch합니다.
이 과정에서는 서버로부터 실패 응답을 받았다는 것과 함께 받은 에러를 state에 업데이트하게 됩니다.
여기에서는 error.message라는 서버에서 받은 에러 메시지를 state에 업데이트 합니다.

Redux에서는 Thunk를 사용해서 이러한 순서로 비동기 요청을 처리하게 됩니다.
근데 Promise를 사용하다 보니 아무래도 가독성이 조금 떨어지는 부분이 있습니다.
그래서 보통은 async/await를 사용하게 되는데, 이번에는 async/await를 사용해서 비동기 요청을 처리하는 예시 코드를 한 번 볼까요?

function fetchData() {
    return async (dispatch, getState) => {
        dispatch(fetchDataRequested());

        let response;

        try {
            response = await apiClient.get('/api/data');
        } catch (error) {
            dispatch(fetchDataFailed(error.message));
            return;
        }

        dispatch(fetchDataSucceeded(response.data));
    }
}

이 코드는 앞에 나온 코드와 동일한 역할을 하지만, Promise가 아닌 async/await를 사용해서 비동기 요청을 처리하는 예시 코드입니다.
그래서 Thunk 함수 앞에 async 키워드를 사용한 것을 볼 수 있습니다.

서버에 요청을 보내기 전에 fetchDataRequested Action을 dispatch합니다.
이 과정에서 서버로 요청을 보냈고 응답을 기다리는 중이라는 것을 state에 업데이트하게 됩니다.
그리고 실제 서버로 요청을 보내는 부분에서 await를 사용해서 서버로부터 응답이 올 때까지 기다리게 됩니다.

일정 시간이 지나서 서버로부터 성공 응답을 받게 되면, fetchDataSucceeded Action을 dispatch합니다.
이 과정에서는 서버로부터 성공 응답을 받았다는 것과 함께 받은 데이터를 state에 업데이트하게 됩니다.
여기에서는 response객체에 저장된 data라는 것이 서버에서 받은 데이터를 나타냅니다.

그리고 만약 서버로부터 실패 응답을 받게 된다면 fetchDataFailed Action을 dispatch합니다.
이 과정에서는 서버로부터 실패 응답을 받았다는 것과 함께 받은 에러를 state에 업데이트하게 됩니다.
여기에서는 try/catch를 사용해서 서버 요청이 실패한 경우에 발생하는 exception을 처리하고 있습니다.

만약 서버 요청이 성공해서 exception이 발생하지 않았다면, catch문 안에 있는 코드는 실행되지 않고 제일 마지막에 있는 fetchDataSucceeded Action이 dispatch될 것입니다.

하지만 서버 요청이 실패해서 exception이 발생한다면, catch문 안에 있는 fetchDataFailed Action이 dispatch되고 곧바로 return을 하여 fetchDataSucceeded Action은 dispatch되지 않도록 처리를 해주었습니다.

이러한 형태가 async/await를 사용해서 비동기 요청을 처리하는 일반적인 형태라고 보면 됩니다.

11.5.4 Thunk에서 값을 반환

마지막 네 번째 Thunk 사용 패턴은 Thunk에서 값을 반환하는 패턴입니다.

dispatch(action)

기본적으로, dispatch(action) 함수는 실제 Action 객체를 반환합니다.
그런데 여기서 middleware를 사용해서 반환하는 값을 원하는대로 변경할 수 있습니다.

const returnHelloMiddleware = next => action => {
    // originalReturnValue는 action 객체를 담고 있음
    const originalReturnValue = next(action);

    return 'Hello';
}


const returnValue = dispatch(anyAction());
// 'Hello'가 출력됨
console.log(returnValue);

예를 들면 이런식이죠.
이 코드는 무조건 ‘Hello’라는 문자열을 반환하는 Redux middleware를 만든 것입니다.

만약 이 middleware를 적용한 이후에 어떤 action을 dispatch하게 되면, action의 종류에 관계없이 dispatch(action)은 모두 ‘Hello’라는 문자열을 반환하게 됩니다.

Thunk는 이처럼 다른 값을 반환하는 패턴으로 사용되기도 합니다.
Thunk에서 값을 반환하는 패턴의 가장 대표적인 것으로는 Promise를 반환하는 것입니다.

const onWritePostClicked = async () => {
    await dispatch(writePost(content));
    setContent("");
}

Promise를 반환하는 Thunk를 사용하게 되면, 위 코드와 같이 await를 걸어서 해당 작업이 끝날때까지 기다렸다가 다음 작업을 수행할 수 있습니다.

여기에서는 writePost Action을 통해, 서버에 게시글 작성이 완료된 이후에 content를 empty string으로 초기화하도록 되어 있습니다.
이러한 방식은 리액트 컴포넌트에서 여러가지 작업들을 순서대로 처리해야 할 때 주로 사용됩니다.


마지막 업데이트: 2023년 07월 14일 00시 00분

이 문서의 저작권은 이인제(소플)에 있습니다. 무단 전재와 무단 복제를 금합니다.