그럼에도 불구하고

👨‍💻

[Redux] redux-thunk와 redux-saga란? 본문

React/Redux

[Redux] redux-thunk와 redux-saga란?

zenghyun 2023. 6. 8. 13:08

오늘은 redux-thunk와 redux-saga에 대해 알아보겠습니다.

 

 

목차

    [ 비동기 작업을 처리하는 미들웨어 사용 ]

    오픈 소스 커뮤니티에 공개된 미들웨어를 사용하면 리덕스를 사용하고 있는 프로젝트에서 비동기 작업을 더욱 효율적으로 관리할 수 있습니다.

     

    비동기 작업을 처리할 때 도움을 주는 미들웨어는 다양하지만, 오늘은 redux-thunk와 redux-saga에 대해 알아보겠습니다. 

     

    📌 redux-thunk: 비동기 작업을 처리할 때 가장 많이 사용되는 미들웨어입니다. 객체가 아닌 함수 형태의 액션을 디스패치할 수 있게 해 줍니다.

     

    📌 redux-saga: redux-thunk 다음으로 가장 많이 사용되는 비동기 작업 관련 미들웨어 라이브러리입니다. 특정 액션이 디스패치되었을 때 정해진 로직에 따라 다른 액션을 디스패치시키는 규칙을 작성하여 비동기 작업을 처리할 수 있게 해 줍니다.

     

    [ redux-thunk ]

    redux-thunk는 리덕스를 사용하는 프로젝트에서 비동기 작업을 처리할 때 가장 기본적으로 사용하는 미들웨어입니다. 리덕스의 창시자인 댄 아브라모프(Dan Abramov)가 만들었으며, 리덕스 공식 매뉴얼에서도 이 미들웨어를 사용하여 비동기 작업을 다루는 예시를 보여줍니다. 

     

    💡 Thunk란? 

    Thunk는 특정 작업을 나중에 할 수 있도록 미루기 위해 함수 형태로 감싼 것을 의미합니다.  

    redux-thunk 라이브러리를 사용하면 thunk 함수를 만들어서 디스패치 할 수 있습니다. 그러면 리덕스 미들웨어가 그 함수를 전달받아 store의 dispatch와 getState를 파라미터로 넣어서 호출해 줍니다. 

     

    다음은 redux-thunk에서 사용할 수 있는 예시 thunk 함수입니다. 

     

    const sampleThunk = () => (dispatch, getState) => {
     // 현재 상태를 참조할 수 있고, 새 액션을 디스패치할 수도 있습니다. 
     }

     

    [ 미들웨어 적용하기 ]

    📌 install

    yarn add redux-thunk // yarn ver 
    
    npm install redux-thunk // npm ver

     

     

    ※ 다음은 비동기 작업을 이용하여 버튼을 누를 때마다 1초 delay후 카운터를 증감시키는 코드의 일부입니다.

    📌 index.js 

     

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    import React from 'react';
    import ReactDOM from 'react-dom/client';
    import './index.css';
    import App from './App';
    import reportWebVitals from './reportWebVitals';
    import { configureStore } from '@reduxjs/toolkit'
    import rootReducer from './module';
    import { Provider } from 'react-redux';
    import { createLogger } from 'redux-logger';
    import ReduxThunk from 'redux-thunk';
     
    const logger = createLogger();
     
    const store = configureStore({
      reducer: rootReducer,
      middleware: (getDefaultMiddleware) =>
        getDefaultMiddleware({
          serializableCheck: false,
        }).concat(logger, ReduxThunk) // Redux 사용 
    });
     
    const root = ReactDOM.createRoot(document.getElementById('root'));
    root.render(
      <React.StrictMode>
        <Provider store={store}>
        <App />
        </Provider>
      </React.StrictMode>
    );
     
    reportWebVitals();
     
    cs

     

    📌  components/Counter.js 

     

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    import React from 'react';
    import { connect } from 'react-redux';
    import { increaseAsync, decreaseAsync } from '../module/counter';
    import Counter from '../components/Counter';
     
     
    const CounterContainer = ({ number, increaseAsync, decreaseAsync }) => {
        return (
            <>
             <Counter number={number} onIncrease={increaseAsync} onDecrease={decreaseAsync}/>   
            </>
        );
    };
     
    export default connect(
        state => ({
            number: state.counter
        }),
        {
            increaseAsync,
            decreaseAsync
        }
    )(CounterContainer);
    cs

     

     

    📌  modules/counter.js 

     

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    import { createAction, handleActions } from 'redux-actions';
     
    const INCREASE = 'counter/INCREASE';
    const DECREASE = 'counter/DECREASE';
     
    export const increase = createAction(INCREASE);
    export const decrease = createAction(DECREASE);
     
    // 1초 뒤에 increase 혹은 decrease 함수를 디스패치한다. 
     
    export const increaseAsync = () => dispatch => {
        setTimeout(() => {
            dispatch(increase());
        }, 1000);
    };
     
    export const decreaseAsync = () => dispatch => {
        setTimeout(() => {
            dispatch(decrease());
        }, 1000);
    };
     
     
    const initialState = 0// 상태는 꼭 객체일 필요 없다. 
     
    const counter = handleActions(
        {
            [INCREASE]: state => state + 1 ,
            [DECREASE]: state => state - 1,
        },
        initialState
    );
     
    export default counter;
    cs

     

    📌  containers/CounterContainer.js 

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    import React from 'react';
    import { connect } from 'react-redux';
    import { increaseAsync, decreaseAsync } from '../module/counter';
    import Counter from '../components/Counter';
     
     
    const CounterContainer = ({ number, increaseAsync, decreaseAsync }) => {
        return (
            <>
             <Counter number={number} onIncrease={increaseAsync} onDecrease={decreaseAsync}/>   
            </>
        );
    };
     
    export default connect(
        state => ({
            number: state.counter
        }),
        {
            increaseAsync,
            decreaseAsync
        }
    )(CounterContainer);
    cs

     

    실행 후 브라우저에서 버튼을 눌러보세요. 숫자가 1초 뒤에 변경되는 것을 확인할 수 있습니다. :)

     

     

    [ 웹 요청 비동기 작업 처리하기 ]

     

    이번에는 thunk의 속성을 활용하여 웹 요청 비동기 작업을 처리하는 방법에 대해 알아보겠습니다. 웹 요청을 연습하기 위해 JSONPlaceholder(https://jsonplaceholder.typicode.com)에서 제공하는 가짜 API를 사용하겠습니다. 

     

    사용할 API는 다음과 같습니다. 

     

    # 포스트 읽기 (:id 1~100 사이 숫자) 

    GET https://jsonplaceholder.typicode.com/posts/:id 

     

    # 모든 사용자 정보 불러오기

    GET https://jsonplaceholder.typicode.com/users 

     

    APi를 호출할 때는 주로 Promise 기반 웹 클라이언트인 axios를 사용합니다. 

     

    yarn add axios // yarn ver
    
    npm install axios // npm ver

     

    첫 번째로 API를 모두 함수화해 주겠습니다. 각 API를 호출하는 함수를 따로 작성하면, 나중에 사용할 때 가독성도 좋고 유지 보수도 쉬워집니다. 다른 파일에서 불러와 사용할 수 있도록 export를 사용해 보겠습니다. 

    📌 lib/api.js

     

    1
    2
    3
    4
    5
    import axios from 'axios';
     
    export const getPost = id => axios.get(`https://jsonplaceholder.typicode.com/posts/${id}`); 
     
    export const getUsers = id => axios.get(`https://jsonplaceholder.typicode.com/users`)
    cs

     

    이제 새로운 리듀서를 만들어 줄 차례입니다. 위 API를 사용하여 데이터를 받아와 상태를 관리할 sample이라는 리듀서를 생성해 보겠습니다.

     

    📌 module/sample.js 

     

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    91
    92
    93
    94
    95
    96
    97
    98
    99
    100
    101
    102
    103
    104
    105
    106
    107
    108
    109
    110
    111
    112
    113
    114
    115
    116
    117
    118
    import { handleActions } from "redux-actions";
    import * as api from '../lib/api'
     
    // 액션 타입을 선언합니다. 
     
    // 한 요청당 세 개를 만들어야 합니다. 
     
    const GET_POST = 'sample/GET_POST';
    const GET_POST_SUCCESS = 'sample/GET_POST_SUCCESS';
    const GET_POST_FAILURE = 'sample/GET_POST_FAILURE';
     
    const GET_USERS = 'sample/GET_USERS';
    const GET_USERS_SUCCESS = 'sample/GET_USERS_SUCCESS';
    const GET_USERS_FAILURE = 'sample/GET_USERS_FAILURE';
     
    // thunk 함수를 생성합니다. 
    // thunk 함수 내부에서는 시작할 때, 성공했을 때, 실패했을 때 다른 액션을 디스패치합니다. 
     
    export const getPost = id => async dispatch => {
        dispatch({type: GET_POST}); // 요청을 시작한 것을 알림
        try {
            const response = await api.getPost(id);
            dispatch({
                type: GET_POST_SUCCESS,
                payload: response.data
            }); // 요청 성공 
        } catch (error) {
            dispatch({
                type:GET_POST_FAILURE,
                payload: error,
                error: true
            }); // 에러 발생 
            throw error; // 나중에 컴포넌트단에서 에러를 조회할 수 있게 해준다. 
        }
    }
     
    export const getUsers = () => async dispatch => {
        dispatch({type: GET_USERS}); // 요청을 시작한 것을 알림
        try {
            const response = await api.getUsers(); 
            dispatch({
                type: GET_USERS_SUCCESS,
                payload: response.data
            }); // 요청 성공 
        } catch (error) {
            dispatch({
                type:GET_USERS_FAILURE,
                payload: error,
                error: true
            }); // 에러 발생 
            throw error; 
        }
    };
     
    // 초기 상태를 선언합니다. 
    // 요청의 로딩 중 상태는 loading이라는 객체에서 관리합니다.
     
    const initialState = {
        loading: {
            GET_POST: false,
            GET_USERS: false
        },
        post: null,
        users: null
    };
     
    const sample = handleActions({
        [GET_POST]: state => ({
            ...state,
            loading: {
                ...state.loading,
                GET_POST: true // 요청 시작
            }
        }),
        [GET_POST_SUCCESS]: (state, action) => ({
            ...state,
            loading: {
                ...state.loading,
                GET_POST: false // 요청 완료
            },
            post: action.payload
        }),
        [GET_POST_FAILURE]: (state, action) => ({
            ...state,
            loading: {
                ...state.loading,
                GET_POST:false //요청 완료
            },
            post: action.payload
        }),
        [GET_USERS]: state => ({
            ...state,
            loading: {
                ...state.loading, 
                GET_USERS: true // 요청 시작
            }
        }),
        [GET_USERS_SUCCESS]: (state, action) => ({
            ...state,
            loading: {
                ...state.loading,
                GET_USERS: false // 요청 완료 
            },
            users: action.payload
        }),
        [GET_USERS_FAILURE]: (state, action) => ({
            ...state,
            loading: {
                ...state.loading,
                GET_USERS: false // 요청 완료
            },
            users: action.payload
        })
    },
    initialState
    )
     
    export default sample;
    cs

     

    코드에서 반복되는 로직이 있습니다. 우선 컨테이너 컴포넌트를 사용하여 데이터 요청을 성공적으로 처리하고, 나중에 반복되는 로직을 따로 분리하여 재사용하는 형태로 코드를 리팩토링 해보겠습니다.

     

    리듀서를 다 작성했다면 해당 리듀서를 루트 리듀서에 포함시키면 됩니다.

     

     

    📌 modules/index.js

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    import { combineReducers } from 'redux'
    import counter from './counter';
    import sample from './sample';
     
    const rootReducer = combineReducers({
        counter,
        sample
    });
     
    export default rootReducer;
    cs

     

    다음은 Sample 컴포넌트에서 post의 경우 title, body user의 경우 username과 email을 받아보겠습니다.

     

     

    📌 components/Sample.js

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    import React from 'react';
     
    const Sample = ({ loadingPost, loadingUsers, post, users}) => {
        return (
            <div>
                <section>
                    <h1>포스트</h1>
                    {loadingPost && '로딩 중...'}
                    {!loadingPost && post && (
                        <div>
                            <h3>{post.title}</h3>
                            <h3>{post.body}</h3>
                        </div>
                    )}
                </section>
                <hr />
                <section>
                    <h1>사용자 목록</h1>
                    {loadingUsers && '로딩 중...'}
                    {!loadingUsers && users && (
                        <ul>
                            {users.map(user => (
                                <li key={user.id}>
                                    {user.username} ({user.email})
                                </li>
                            ))}
                        </ul>
                    )}
                </section>
            </div>
        );
    };
     
    export default Sample;
    cs

     

    데이터를 불러와서 렌더링해 줄 때는 유효성 검사를 해 주는 것이 중요합니다. 예를 들어 post &&를 사용하면 post 객체가 유효할 때만 그 내부의 post.title 혹은 post.body 값을 보여 줍니다. 만약 데이터가 없는 상태라면 post.title을 조회하려고 할 때 자바스크립트 오류가 발생하니 반드시 유효성 검사를 해주어야 합니다.

     

    user도 마찬가지로 데이터가 배열 형태로 들어올 것을 기대하고 map 함수를 사용하고 있습니다. 하지만 유효성 검사를 하지 않으면 null 값에 대해 map 함수를 호출하고, 결국 map 함수가 존재하지 않아 오류가 발생합니다.

     

    이제 컨테이너 컴포넌트를 만들어 보겠습니다. 

     

     

    📌 containers/SampleContainer.js

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    import React, { useEffect } from 'react';
    import { connect } from 'react-redux';
    import Sample from '../components/Sample';
    import { getPost, getUsers  } from '../module/sample';
     
     
    const SampleContainer = ({ getPost, getUsers, post, users, loadingPost, loadingUsers}) => {
     
        useEffect(() => {
            getPost(1);
            getUsers(1);
        }, [getPost, getUsers]);
     
        return (
            <Sample
            post={post}
            users={users}
            loadingPost={loadingPost}
            loadingUsers={loadingUsers}
            />
        );
    };
     
    export default connect(
        ({sample}) => ({
            post: sample.post,
            users: sample.users,
            loadingPost: sample.loadingPost,
            loadingUsers: sample.loadingUsers
        }),
        {
            getPost,
            getUsers
        }
    )(SampleContainer);
    cs

     

    그다음 App 컴포넌트에서 SampleContainer를 렌더링 하겠습니다.

     

    📌 App.js

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    import './App.css';
    import CounterContainer from './containers/CounterContainer';
    import SampleContainer from './containers/SampleContainer';
     
    function App() {
      return (
        <div>
          <CounterContainer />
          <SampleContainer />
        </div>
      );
    }
     
    export default App;
     
    cs

     

    아래와 같은 결과가 잘 나오나요?

     

     


     

     

    [ 리팩토링 ]

    반복되는 로직을 따로 분리하여 코드의 양을 줄여보겠습니다.

    📌 lib/createRequestThunk.js 

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    export default function createRequestThunk(type, request) {
        // 성공 및 실패 액션 타입을 정의합니다. 
        const SUCCESS = `${type}_SUCCESS`;
        const FAILURE = `${type}_FAILURE`;
     
        return params => async dispatch => {
            dispatch({type}); // 시작됨
            try {
                const response = await request(params); 
                dispatch({
                    type:SUCCESS,
                    payload: response.data
                }); // 성공 
            } catch (error) {
                dispatch({
                    type:FAILURE,
                    payload:error,
                    error: true
                }); // 에러 발생
                throw error;
            }
        };
    }
    cs

     

    이 유틸 함수는 API 요청을 해 주는 thunk 함수를 한 줄로 생성할 수 있게 해 줍니다. 액션 타입과 API를 요청하는 함수를 파라미터로 넣어 주면 나머지 작업을 대신 처리해 줍니다. 이 함수를 사용하여 기존 thunk 함수의 코드를 대체해 보겠습니다.

     

    📌  modules/sample.js 

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    import { handleActions } from "redux-actions";
    import * as api from '../lib/api'
    import createRequestThunk from "../lib/createRequestThunk";
     
    // 액션 타입을 선언합니다. 
     
    // 한 요청당 세 개를 만들어야 합니다. 
     
    const GET_POST = 'sample/GET_POST';
    const GET_POST_SUCCESS = 'sample/GET_POST_SUCCESS';
    const GET_POST_FAILURE = 'sample/GET_POST_FAILURE';
     
    const GET_USERS = 'sample/GET_USERS';
    const GET_USERS_SUCCESS = 'sample/GET_USERS_SUCCESS';
    const GET_USERS_FAILURE = 'sample/GET_USERS_FAILURE';
     
    // thunk 함수를 생성합니다. 
    // thunk 함수 내부에서는 시작할 때, 성공했을 때, 실패했을 때 다른 액션을 디스패치합니다. 
     
    export const getPost = createRequestThunk(GET_POST, api.getPost);
    export const getUsers = createRequestThunk(GET_USERS, api.getUsers);
     
    // 초기 상태를 선언합니다. 
    // 요청의 로딩 중 상태는 loading이라는 객체에서 관리합니다.
     
    const initialState = {
        loading: {
            GET_POST: false,
            GET_USERS: false
        },
        post: null,
        users: null
    };
     
    const sample = handleActions({
        [GET_POST]: state => ({
            ...state,
            loading: {
                ...state.loading,
                GET_POST: true // 요청 시작
            }
        }),
        [GET_POST_SUCCESS]: (state, action) => ({
            ...state,
            loading: {
                ...state.loading,
                GET_POST: false // 요청 완료
            },
            post: action.payload
        }),
        [GET_POST_FAILURE]: (state, action) => ({
            ...state,
            loading: {
                ...state.loading,
                GET_POST:false //요청 완료
            },
            post: action.payload
        }),
        [GET_USERS]: state => ({
            ...state,
            loading: {
                ...state.loading, 
                GET_USERS: true // 요청 시작
            }
        }),
        [GET_USERS_SUCCESS]: (state, action) => ({
            ...state,
            loading: {
                ...state.loading,
                GET_USERS: false // 요청 완료 
            },
            users: action.payload
        }),
        [GET_USERS_FAILURE]: (state, action) => ({
            ...state,
            loading: {
                ...state.loading,
                GET_USERS: false // 요청 완료
            },
            users: action.payload
        })
    },
    initialState
    )
     
    export default sample;
     
    cs

     

    똑같은 기능을 훨씬 짧은 코드로 구현하게 되었습니다.

     

    이번에는 요청의 로딩 상태를 관리하는 작업을 개선해 보겠습니다. 기존에는 리듀서 내부에서 각 요청에 관련된 액션이 디스패치될 때마다 로딩 상태를 변경해 주었는데요. 이 작업을 로딩 상태만 관리하는 리덕스 모듈을 따로 생성하여 처리하겠습니다. 

     

    📌  modules/loading.js 

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    import { createAction, handleActions } from "redux-actions";
    import { isStatement } from "typescript";
     
    const START_LOADING = "loading/START_LOADING";
    const FINISH_LOADING = "loading/FINISH_LOADING";
     
    /* 
        요청을 위한 액션 타입을 payload로 설정합니다. (예: 'sample/GET_POST)
    */
     
    export const startLoading = createAction(
      START_LOADING,
      (requestType) => requestType
    );
     
    export const finishLoading = createAction(
      FINISH_LOADING,
      (requestType) => requestType
    );
     
    const initialState = [];
     
    const loading = handleActions(
      {
        [START_LOADING]: (state, action) => ({
          ...state,
          [action.payload]: true,
        }),
        [FINISH_LOADING]: (state, action) => ({
          ...state,
          [action.payload]: false,
        }),
      },
      initialState
    );
     
    export default loading;
     
    cs

     

    다음은 요청이 시작될 때 디스패치할 액션입니다. 

     

    {
        type: 'loading/START_LOADING',
        payload: 'sample/GET_POST'
    }

     

    위 액션이 디스패치되면 loading 리듀서가 관리하고 있는 상태에서 sample/GET_POST 값을 true로 설정해 줍니다. 만약 기존 상태에 sample/GET_POST 필드가 존재하지 않으면 새로 값을 설정해 줍니다.

     

    그리고 요청이 끝나면 다음 액션을 디스패치 해야 합니다. 

     

    {
        type: 'loading/FINISH_LOADING',
        payload: 'sample/GET_POST'
    }

     

    그러면 기존에 true로 설정했던 값을 다시 false로 전환해 줍니다.

     

    리듀서를 다 작성했으면 루트 리듀서에 포함시키면 됩니다. 

     

    📌 modules/index.js

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    import { combineReducers } from 'redux'
    import counter from './counter';
    import sample from './sample';
    import loading from './loading';
     
    const rootReducer = combineReducers({
        counter,
        sample,
        loading
    });
     
    export default rootReducer;
    cs

     

    loading 리덕스 모듈에서 만든 액션 생성 함수는 앞에서 만든 createRequestThunk에서 사용해 줍니다. 

    📌 lib/createRequestThunk.js 

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    import { startLoading, finishLoading } from "../module/loading";
     
    export default function createRequestThunk(type, request) {
        // 성공 및 실패 액션 타입을 정의합니다. 
        const SUCCESS = `${type}_SUCCESS`;
        const FAILURE = `${type}_FAILURE`;
     
        return params => async dispatch => {
            dispatch({type}); // 시작됨
            dispatch(startLoading(type));
            try {
                const response = await request(params); 
                dispatch({
                    type:SUCCESS,
                    payload: response.data
                }); // 성공 
                dispatch(finishLoading(type));
            } catch (error) {
                dispatch({
                    type:FAILURE,
                    payload:error,
                    error: true
                }); // 에러 발생
                dispatch(startLoading(type));
                throw error;
            }
        };
    }
    cs

     

    그러면 SampleContainer에서 로딩 상태를 다음과 같이 조회할 수 있습니다.

    📌 containers/SampleContainer.js

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    import React, { useEffect } from 'react';
    import { connect } from 'react-redux';
    import Sample from '../components/Sample';
    import { getPost, getUsers  } from '../module/sample';
    import loading from '../module/loading';
     
     
    const SampleContainer = ({ getPost, getUsers, post, users, loadingPost, loadingUsers}) => {
     
        useEffect(() => {
            getPost(1);
            getUsers(1);
        }, [getPost, getUsers]);
     
        return (
            <Sample
            post={post}
            users={users}
            loadingPost={loadingPost}
            loadingUsers={loadingUsers}
            />
        );
    };
     
    export default connect(
        ({sample}) => ({
            post: sample.post,
            users: sample.users,
            loadingPost: loading['sample/GET_POST'],
            loadingUsers: loading['sample/GET_USERS']
        }),
        {
            getPost,
            getUsers
        }
    )(SampleContainer);
    cs

     

    이제 sample 리듀서에서 불필요한 코드를 지워보겠습니다.

    📌 modules/sample.js

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    import { handleActions } from "redux-actions";
    import * as api from '../lib/api'
    import createRequestThunk from "../lib/createRequestThunk";
     
    // 액션 타입을 선언합니다. 
     
    // 한 요청당 세 개를 만들어야 합니다. 
     
    const GET_POST = 'sample/GET_POST';
    const GET_POST_SUCCESS = 'sample/GET_POST_SUCCESS';
    const GET_POST_FAILURE = 'sample/GET_POST_FAILURE';
     
    const GET_USERS = 'sample/GET_USERS';
    const GET_USERS_SUCCESS = 'sample/GET_USERS_SUCCESS';
    const GET_USERS_FAILURE = 'sample/GET_USERS_FAILURE';
     
    // thunk 함수를 생성합니다. 
    // thunk 함수 내부에서는 시작할 때, 성공했을 때, 실패했을 때 다른 액션을 디스패치합니다. 
     
    export const getPost = createRequestThunk(GET_POST, api.getPost);
    export const getUsers = createRequestThunk(GET_USERS, api.getUsers);
     
    // 초기 상태를 선언합니다. 
    // 요청의 로딩 중 상태는 loading이라는 객체에서 관리합니다.
     
    const initialState = {
        post: null,
        users: null
    };
     
    const sample = handleActions({
      
        [GET_POST_SUCCESS]: (state, action) => ({
            ...state,
            loading: {
                ...state.loading,
                GET_POST: false // 요청 완료
            },
            post: action.payload
        }),
        [GET_USERS_SUCCESS]: (state, action) => ({
            ...state,
            loading: {
                ...state.loading,
                GET_USERS: false // 요청 완료 
            },
            users: action.payload
        }),
    },
    initialState
    )
     
    export default sample;
    cs

     

    위와 같이 코드가 훨씬 깔끔해졌습니다. 이제 sample 리듀서에서는 로딩 중에 대한 상태를 관리할 필요가 없습니다. 성공했을 때의 케이스만 잘 관리해 주면 됩니다. 추가로 실패했을 때의 케이스를 관리하고 싶다면 _FAILURE가 붙은 액션을 리듀서에서 처리해 주면 됩니다. 혹은 컨테이너 컴포넌트에서 try/catch 구문을 사용하여 에러 값을 조회할 수도 있습니다.

     

     

     


     

     

    [ redux-saga ]

    이번에는 redux-saga를 통해 비동기 작업을 관리하는 방법을 알아보겠습니다. 이 미들웨어는 redux-thunk 다음으로 많이 사용하는 비동기 작업 관련 미들웨어입니다. 

     

    redux-thunk는 함수 형태의 액션을 디스패치하여 미들웨어에서 해당 함수의 스토어의 dispatch와 getState를 파라미터로 넣어서 사용하는 원리입니다. 그래서 구현한 thunk 함수 내부에서 원하는 API 요청도 하고, 다른 액션을 디스패치하거나 현재 상태를 조회하기도 했습니다. 대부분의 경우에는 redux-thunk로도 충분히 기능을 구현할 수 있습니다. 

     

    redux-saga의 경우 좀 더 까다로운 상황에서 유용합니다. 예를 들어 다음과 같은 상황에서는 redux-saga를 사용하는 것이 유리합니다.

     

    • 기존 요청을 취소 처리해야 할 때 (불필요한 중복 요청 방지)
    • 특정 액션이 발생했을 때 다른 액션을 발생시키거나, API 요청 등 리덕스와 관계없는 코드를 실행할 때
    • 웹 소켓을 사용할 때
    • API 요청 실패 시 재요청해야 할 때 

     

    [ 제너레이터 함수 이해하기 ]

     

    redux-saga에서는 ES6의 제너레이터 함수라는 문법을 사용합니다. 보통 일반적인 상황에서는 많이 사용되지 않기 때문에 초반에 진입 장벽이 있을 수 있습니다.

     

    우선 제너레이터 함수 문법에 대해 알아보겠습니다. 이 문법의 핵심 기능은 함수를 작성할 때 함수를 특정 구간에 멈춰 놓을 수도 있고, 원할 때 다시 돌아가게 할 수도 있다는 것입니다. 

     

     

    제너레이터가 처음 만들어지면 함수의 흐름은 멈춰있는 상태입니다. next()가 호출되면 다음 yield가 있는 곳까지 호출하고 다시 함수가 멈춥니다. 제너레이터 함수를 사용하면 함수를 도중에 멈출 수도 있고, 순차적으로 여러 값을 반환시킬 수도 있습니다. next 함수에 파라미터를 넣으면 제너레이터 함수에서 yield를 사용하여 해당 값을 조회할 수도 있습니다. 

     

    [ 비동기 카운터 만들기 ]

     

    기존에 thunk 함수로 구현했던 비동기 카운터를 이번에는 redux-saga를 사용하여 구현해 보겠습니다. 

     

    📌 install

    yarn add redux-saga // yarn ver
    
    npm install redux-saga // npm ver

    📌 module/counter.js 

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    import { createAction, handleActions } from "redux-actions";
    import { delay, put, takeEvery, takeLatest } from "redux-saga/effects";
     
    const INCREASE = "counter/INCREASE";
    const DECREASE = "counter/DECREASE";
     
    const INCREASE_ASYNC = "counter/INCREASE_ASYNC";
    const DECREASE_ASYNC = "counter/DECREASE_ASYNC";
     
    export const increase = createAction(INCREASE);
    export const decrease = createAction(DECREASE);
    // 마우스 클릭 이벤트가 payload 안에 들어가지 않도록 () => undefined를 두 번째 파라미터로 넣어줍니다.
     
    export const increaseAsync = createAction(INCREASE_ASYNC, () => undefined);
    export const decreaseAsync = createAction(DECREASE_ASYNC, () => undefined);
     
    function* increaseSaga() {
        yield delay(1000); // 1초를 기다립니다.
        yield put(increase()); // 특정 액션을 디스패치합니다.
    }
     
    function* decreaseSaga() {
        yield delay(1000); // 1초를 기다립니다.
        yield put(decrease()); // 특정 액션을 디스패치합니다.
    }
     
    export function* counterSaga() {
        // takeEvery는 들어오는 모든 액션에 대해 특정 작업을 처리해 줍니다. 
        yield takeEvery(INCREASE_ASYNC, increaseSaga);
        // takeLatest는 기존에 진행 중이던 작업이 있다면 취소 처리하고 가장 마지막으로 실행된 작업만 수행합니다.
        yield takeLatest(DECREASE_ASYNC, decreaseSaga);
    }
     
    const initialState = 0// 상태는 꼭 객체일 필요 없다.
     
    const counter = handleActions(
      {
        [INCREASE]: (state) => state + 1,
        [DECREASE]: (state) => state - 1,
      },
      initialState
    );
     
    export default counter;
     
    cs

    📌 module/index.js

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    import { combineReducers } from "redux";
    import { all  } from "redux-saga/effects";
    import counter, { counterSaga } from "./counter";
    import sample from "./sample";
    import loading from "./loading";
     
    const rootReducer = combineReducers({
      counter,
      sample,
      loading,
    });
     
    export function* rootSaga() {
      // all 함수는 여러 사가를 합쳐 주는 역할을 합니다.
      yield all([counterSaga()]);
    }
     
    export default rootReducer;
     
    cs

     

    이제 스토어에 redux-saga 미들웨어를 적용해 주겠습니다.

     

    📌  index.js

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    import React from 'react';
    import ReactDOM from 'react-dom/client';
    import './index.css';
    import App from './App';
    import reportWebVitals from './reportWebVitals';
    import { configureStore } from '@reduxjs/toolkit'
    import rootReducer, { rootSaga } from './module';
    import { Provider } from 'react-redux';
    import { createLogger } from 'redux-logger';
    import ReduxThunk from 'redux-thunk';
    import createSagaMiddleware from 'redux-saga';
     
    const logger = createLogger();
    const sagaMiddleware = createSagaMiddleware();
     
    const store = configureStore({
      reducer: rootReducer,
      middleware: (getDefaultMiddleware) =>
        getDefaultMiddleware({
          serializableCheck: false,
        }).concat(logger, ReduxThunk, sagaMiddleware)
      });
      sagaMiddleware.run(rootSaga);
     
     
    const root = ReactDOM.createRoot(document.getElementById('root'));
    root.render(
      <React.StrictMode>
        <Provider store={store}>
        <App />
        </Provider>
      </React.StrictMode>
    );
     
    reportWebVitals();
     
    cs

     

     

     

    +1 버튼을 두 번 누르면 INCREASE_ASYNC 액션이 두 번 디스패치되고, 이에 따라 INCREASE 액션도 두 번 디스패치됩니다. takeEvery를 사용하여 increaseSaga를 등록했으므로 디스패치 되는 INCREASE_ASYNC 액션에 대해 1초 후 INCREASE 액션을 발생시켜 줍니다. 

     

    이번에는 페이지를 새로고침한 뒤에 -1 버튼을 두 번 눌러서 어떤 액션이 디스패치되는지 확인해 보겠습니다. 

     

     

    조금 전과는 다르게 DECREASE_ASYNC 액션이 두 번 디스패치되었음에도 불구하고 DECREASE 액션은 단 한 번 디스패치되었습니다. 조금 전에 decreaseSaga를 등록할 때 takeLatest를 사용했기 때문입니다. 여러 액션이 중첩되어 디스패치 되었을 때는 기존의 것들을 무시하고 가장 마지막 액션만 제대로 처리합니다. 

     

     

     

    ref: 리액트를 다루는 기술

     

    Comments