React

[React] 리덕스 미들웨어를 통한 비동기 작업 관리

옝옹 2023. 4. 14. 02:01

 

API 서버를 연동할 때 API 요청에 대한 상태를 잘 관리해야 함
(ex) 요청이 시작되었을 때는 로딩중을, 요청이 성공하거나 실패했을 때는 로딩이 끝났음을 명시해야 한다.
요청이 성공하면 서버에서 받아 온 응답에 대한 상태를 관리하고,
요청이 실패하면 서버에서 반환한 에러에 대한 상태를 관리해야 한다.

→ 리액트 프로젝트에서 리덕스를 사용하고 있으며 이러한 비동기 작업을 관리해야 한다면 '미들웨어'를 사용해 매우 효율적이고 편하게 상태 관리를 할 수 있음

미들웨어

  • 리덕스 미들웨어는 액션을 디스패치했을 때 리듀서에서 이를 처리하기에 앞서 사전에 지정된 작업들을 실행한다
  • 미들웨어는 액션과 리듀서 사이의 중간자라고 볼 수 있음

  • 리듀서가 액션을 처리하기 전에 미들웨어가 할 수 있는 작업
    • 전달받은 액션을 단순히 콘솔에 기록
    • 전달받은 액션 정보를 기반으로 액션을 아예 취소하거나, 다른 종류의 액션을 추가로 디스패치할 수 있음

미들웨어 만들기

// src/lib/loggerMiddleware.js
const loggerMiddleware = store => next => action => {
    // 미들웨어 기본 구조
};

export default loggerMiddleware;

화살표 함수를 연달아서 사용했는데, 일반 function 키워드로 풀어서 쓴다면 다음과 같다

const loggerMiddleware = function loggerMiddleware(store) {
    return function(next) {
        return function(action) {
            // 미들웨어 기본 구조
        };
    };
};
  • 미들웨어는 결국 함수를 반환하는 함수를 반환하는 함수
  • 함수에서 파라미터로 받아오는 값
    • store : 리덕스 스토어 인스턴스
    • action : 디스패치된 액션
    • next : 파라미터는 함수 형태이며, store.dispatch와 비슷한 역할을 한다. 차이점은 next(action)을 호출하면 그 다음 처리해야 할 미들웨어에게 액션을 넘겨주고, 만약 그 다음 미들웨어가 없다면 리듀서에게 액션을 넘겨준다

  • 미들웨어 내부에서 store.dispatch를 사용하면 첫 번째 미들웨어부터 다시 처리함
  • 만약 미들웨어에서 next를 사용하지 않으면 액션이 리듀서에 전달되지 않음(액션이 무시됨)

미들웨어 구현

이전 상태, 액션 정보, 새로워진 상태를 콘솔에 보여줌

// src/lib/loggerMiddleware.js
const loggerMiddleware = store => next => action => {
    console.group(action && action.type);   // 액션 타입으로 log를 그룹화함
    console.log('이전 상태', store.getState());
    console.log('액션', action);
    next(action);   // 다음 미들웨어 혹은 리듀서에게 전달
    console.log('다음 상태', store.getState()); // 업데이트된 상태
    console.groupEnd(); // 그룹 끝
};

export default loggerMiddleware;
  • 만든 리덕스 미들웨어를 스토어에 적용
  • 미들웨어는 스토어를 생성하는 과정에서 적용
// src/index.js
...
import { createStore, applyMiddleware } from 'redux';
...
import loggerMiddleware from './lib/loggerMiddleware';

const store = createStore(rootReducer, applyMiddleware(loggerMiddleware));
root.render(
	<Provider store = {store}>
    	<App />
    </Provider>
);


redux-logger 사용하기

// src/index.js
...
// import loggerMiddleware from './lib/loggerMiddleware';
import { createLogger } from 'redux-logger';

const logger = createLogger();
const store = createStore(rootReducer, applyMiddleware(logger));

const store = ReactDOM.createRoot(document.getElementById("root"));
root.render(
	<Provider store = {store}>
    	<App />
    </Provider>
);


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

redux-thunk

  • 비동기 작업을 처리할 때 가장 기본적으로 많이 사용하는 미들웨어
  • 객체가 아닌 함수형태의 액션을 디스패치할 수 있게 해줌

3-1-1) Thunk

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

// 주어진 파라미터에 1을 더하는 함수
// 이 연산 작업을 나중으로 미루고 싶은 경우

const addOne = x => x+1;
function addOneThunk(x) {
	const thunk = () => addOne(x);
    return thunk;
}

const fn = addOneThunk(1);
setTimeout(() => {
	const value = fn(); // fn이 실행되는 시점에 연산
    console.log(value);
}, 1000);

3-1-2) 미들웨어 적용하기

// src/index.js
...
import {createLogger} from 'redux-logger';
import thunk from 'redux-thunk';

const logger = createLogger();
const store = createStore(rootReducer, applyMiddleware(logger, thunk));
...

3-1-3) Thunk 생성 함수 만들기

redux-thunk는 액션 생성 함수에서 일반 액션 객체를 반환하는 대신 함수를 반환함

// src/modules/counter.js
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);
};

...
// src/container/CounterContainer.js
...
import { increaseAsync, decreaseAsync } from '../modules/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);

  • 버튼을 누르면 숫자가 1초 뒤 변경됨
  • 처음 디스패치 되는 액션은 함수 형태이고, 두번째 액션은 객체 형태임

3-1-4) 웹 요청 비동기 작업 처리하기

유지보수를 위해 API를 모두 함수화한다

// src/lib/api.js
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');

새로운 리듀서를 만들어줌

// src/modules/sample.js
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 (e) {
        dispatch({
            type: GET_POST_FAILURE,
            payload: e,
            error: true
        }); // 에러 발생
        throw e;    // 나중에 컴포넌트단에서 에러를 조회할 수 있게 해줌
    }
};

export const getUsers = () => async dispatch => {
    dispatch({ type: GET_USERS });  // 요청을 시작한 것을 알림
    try {
        const response = await api.getUsers();
        dispatch({
            type: GET_USERS_SUCCESS,
            payload: response.data
        }); // 요청 성공
    } catch (e) {
        dispatch({
            type: GET_USERS_FAILURE,
            payload: e,
            error: true
        }); // 에러 발생
        throw e;
    }
};

// 초기 상태를 선언
// 요청의 로딩 중 상태는 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 // 요청 완료
            }
        }),
        [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 // 요청 완료
            }
        }),
    },
    initialState
);

export default sample;
// src/modules/index.js
import { combineReducers } from 'redux';
import counter from './counter';
import sample from './sample';

const rootReducer = combineReducers({
    counter,
    sample
});

export default rootReducer;
// src/components/Sample.js
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 && (
                    <div>
                        {users.map(user => (
                            <li key={user.id}>
                                {user.username} ({user.email})
                            </li>
                        ))}
                    </div>
                )}
            </section>
        </div>
    );
};

export default Sample;
  • 데이터를 불러와 렌더링해 줄  때는 유효성 검사를 해주는 것이 중요함
    • post && 를 사용하면 post 객체가 유효할 때만 그 내부의 post.title 혹은 post.body 값을 보여줌
    • 만약 데이터가 없는 상태라면 post.title을 조회하려고 할 때 오류가 발생하므로 반드시 유효성 검사 필요
// src/containers/SampleConatiner.js
import React from 'react';
import { connect } from 'react-redux';
import Sample from '../components/Sample';
import { getPost, getUsers } from '../modules/sample';

const { useEffect } = React;
const SampleContainer = ({
    getPost,
    getUsers,
    post,
    users,
    loadingPost,
    loadingUsers
}) => {
    // 클래스 형태 컴포넌트였다면 componentDidMount
    useEffect(() => {
        getPost(1);
        getUsers(1);
    }, [getPost, getUsers]);
    return (
        <Sample 
            post={post}
            users={users}
            loadingPost={loadingPost}
            loadingUser={loadingUsers}
        />
    );
};

export default connect(
    ({ sample }) => ({
        post: sample.post,
        users: sample.users,
        loadingPost: sample.loading.GET_POST,
        loadingUsers: sample.loading.GET_USERS
    }),
    {
        getPost,
        getUsers
    }
)(SampleContainer);
// src/App.js
import React from 'react';
import SampleContainer from './containers/SampleContainer';

const App = () => {
  return (
    <div>
      <SampleContainer />
    </div>
  );
};

export default App;

3-1-5) 리팩토링

(1) API 요청을 해주는 thunk 함수

// src/lib/createRequestThunk.js
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(e) {
            dispatch({
                type: FAILURE,
                payload: e,
                error: true
            }); // 에러 발생
            throw e;
        }
    };
}

// 사용법: createRequestThunk('GET_USERS', api.getUsers);
// src/modules/sample.js
...
import createRequestThunk from '../lib/createRequestThunk';

...

// thunk 함수를 생성
// thunk 함수 내부에서는 시작할 때, 성공했을 때, 실패했을 때 다른 액션을 디스패치한다.

export const getPost = createRequestThunk(GET_POST, api.getPost);
export const getUsers = createRequestThunk(GET_USERS, api.getUsers);

...

(2) 요청의 로딩 상태를 관리하는 작업

// src/modules/loading.js
import { createAction, handleActions } from 'redux-actions';

const START_LOADING = 'loading/START_LOADING';
const FINISH_LOADING = 'loading/FINISH_LOADING';

/*
    요청을 위한 액션 타입을 payload로 설정한다.(ex. "sample/GETPOST")
*/

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;
// src/modules/index.js
...
import loading from './loading';

const rootReducer = combineReducers({
    counter,
    sample,
    loading
});

export default rootReducer;

3-2) redux-saga

  • redux-thunk 다음으로 가장 많이 사용되는 비동기 작업 관련 미들웨어 라이브러리
  • 액션을 모니터링하고 있다가, 특정 액션이 발생하면 이에 따라 특정 작업을 하는 방식
    • 특정 작업 : 자바스크립트를 실행하는 것, 다른 액션을 디스패치 하는 것, 현재 상태를 불러오는 것
  • 대부분의 경우 redux-thunk로 구현할 수 있지만 redux-sage를 사용하는 것이 유리한 상황
    • 기존 요청을 취소 처리해야할 때 (불필요한 중복 요청 방지)
    • 특정 액션이 발생했을 때 다른 액션을 발생시키거나, API 요청 등 리덕스와 관계없는 코드를 실행할 때
    • 웹소켓을 사용할 때
    • API 요청 실패시 재요청
  • Generator 문법을 사용

3-2-1) Generator 함수