01.16 리덕스 미들웨어와 외부데이터 연동

React.js

01.16. 리덕스 미들웨어와 외부데이터 연동

웹 애플리케이션을 만들 때는 대부분 서버와 데이터를 연동해야한다. 데이터를 연동하려면 일반적으로 서버에 구현된 REST API에 Ajax를 요청하여 데이터를 가져오거나 입력해야한다. 

01.16.1. 리덕스 미들웨어(middleware)란?

액션을 디스패치했을 때 리듀서에서 이를 처리하기 전에 사전에 지정된 작업들을 실행한다. 
(미들웨어는 액션과 리듀서 사이의 중간자라고 볼 수 있다.)
미들웨어가 할 수 있는 작업은 여러 가지가 있다. 단순히 전달받은 액션을 콘솔에 기록할 수도 있고, 전달받은 액션 정보를 기반으로 액션을 취소해 버리거나 다른 종류의 액션을 추가로 디스패치할 수도 있다.

01.16.1.1. 미들웨어 생성

// src/lib/middlewareTest.js
const middlewareTest = store => next => action => {
    // 현재 스토어 상태 값 기록
    console.log('현재 상태', store.getState());
    // 액션 기록
    console.log('액션',action);

    // 액션을 다음 미들웨어 또는 리듀서에 전달
    const result = next(action);

    // 액션 처리 후 스토어의 상태를 기록
    console.log('다음 상태', store.getState());
    
    return result; // 여기에서 반환하는 값은 store.dispatch(ACTION_TYPE)했을 때 결과로 설정한다.

}

export default middlewareTest;// 내보내기
store 와 action 은 익숙하지만, next 는 익숙하지 않다. next는 store.dispatch 와 비슷한 역할이다. 차이점은 next(action) 을 했을 때는 그다음 처리해야 할 미들웨어로 액션을 넘겨주고, 추가로 처리할 미들웨어가 없다면 바로 리듀서에 넘겨준다는 것이다. 하지만 store.dispatch는 다음 미들웨어로 넘기는 것이 아니라 액션을 처음부터 디스패치한다.
//src/store.js
import { createStore, applyMiddleware } from 'redux';
import modules from './modules';
import middlewareTest from './lib/middlewareTest';

// 미들웨어가 여러 개일 경우 파라미터로 전달하면 된다. (applyMiddleware(a,b,c))
// 미들웨어 순서는 여기에서 전달한 파라미터 순서대로 지정한다.
const store = createStore(modules, applyMiddleware(middlewareTest));

export default store;
미들웨어는 store를 생성할 때 적용할 수 있다. 액션과 리듀서 사이에서 액션 정보를 가로채 수정한 후 리듀서로 전달할 수도 있고, 액션 정보에 따라 아예 무시할 수 있다(next를 호출하지 않고 return 하면 된다).

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

미들웨어의 작동 방식이 이해되었다면, 오픈소스 커뮤니팅 공개된 미들웨어를 사용해 비동기 액션을 다루는 방법을 알아본다.

01.16.2.1. redux-thunk

리덕스를 사용하는 애플리케이션에서 비동기 작업을 처리할 때 가장 기본적인 방법은 redux-thunk 미들웨어를 사용하는 것이다. 리덕스 공식 매뉴얼에서도 이를 사용하여 비동기 작업을 다룬다.

※ thunk 는 특정 작업을 나중에 할 수 있도록 미루려고 함수 형태로 감싼 것을 의미한다.
const x = 1+2; // 바로 실행
const foo = () => 1+2 ; // foo()를 호출해야 실행
redux-thunk는 객체가 아닌 함수도 디스패치할 수 있다. 일반 액션 객체로는 특정 액션을 디스패치한 후 몇 초 뒤에 실제로 반영시키거나 현재 상태에 따라 아예 무시하게 만들 수 없다. 하지만 redux-thunk 미들웨어는 함수를 디스패치할 수 있게 함으로써 일반 액션 객체로는 할 수 없는 작업들도 할 수 있게 한다.
// src/modules/counter.js
import {handleActions, createAction} from 'redux-actions';

const INCREMENT = 'INCREMENT';
const DECREMENT = 'DECREMENT';

export const increment = createAction(INCREMENT);
export const decrement = createAction(DECREMENT);

export const incrementAsync = () => dispatch => {
    // 1초 뒤 액션 디스패치
    setTimeout(
        () => { dispatch(increment()) },
        1000
    );
}

export const decrementAsync = () => dispatch => {
    // 1초 뒤 액션 디스패치
    setTimeout(
        () => { dispatch(decrement()) },
        1000
    );
}

export default handleActions({
    [INCREMENT]: (state, action) => state +1,
    [DECREMENT]: (state, action) => state -1
}, 0);

// src/App.js - 버튼
<button onClick={CounterActions.incrementAsync}>+</button>
<button onClick={CounterActions.decrementAsync}>-</button>
INCREMENT 와 DECREMENT 액션을 1초 뒤에 디스패치하는 액션 함수 incrementAsync 와  decrementAsync 를 만들었다. 그리고 App 컴포넌트에서 button 코드를 수정하면 함수형 액션을 먼저 디스패치하고, 1초 뒤에 지정한 액션을 디스패치한다.

01.16.3. 웹 요청 처리

axios 라는 Promise 기반 HTTP 클라이언트 라이브러리를 사용하여 웹 요청을 한다.

01.16.3.1. Promise 

Promise 는 ES6 문법에서 비동기 처리를 다루는데 사용하는 객체이다.
function promiseTest(number, fn) {
    setTImeout(
        function() {
            console.log(number);
            if(fn) fn();
        },
        1000
    );
}

promiseTest(1, function() {
    promiseTest(2, function() {
        promiseTest(3, function() {
            promiseTest(4);
        })
    })
});
위 코드를 실행하면 1초씩 간격을 두고 숫자 1,2,3,4를 콘솔에 표시한다. 이렇게 코드를 작성할 경우 콜백이 콜백을 불러 깊고 복잡해진다. 이런 문제를 해결해 주는 것이 Promise 이다.
function promiseTest(number) {
    return new Promise( // Promise 생성 후 리턴
        resolve => {
            setTimeout( // 1초 뒤 실행
                () => {
                    console.log(number);
                    resolve(); // promise가 끝났음을 알린다.
                }, 1000
            );
        }
    );
}

promiseTest(1)
    .then( () => promiseTest(2) )
    .then( () => promiseTest(3) )
    .then( () => promiseTest(4) )
Promise 를 사용하면 코드를 더 간결하게 작성할 수 있다.
Promise 에서 결과 값을 반환할 때는 resolve(결과 값) 을 작성하고, 오류를 발생시킬 때는 reject(오류)를 작성한다. 여기에서 반환하는 결과 값과 오류는 .then() 또는 .catch() 에 전달하는 함수의 파라미터로 설정된다.
function promiseTest(number) {
    return new Promise( // Promise 생성 후 리턴
        (resolve, reject) => {
            if ( number > 4 ) {
                return reject('number is greater than 4'); // reject 는 오류를 발생시킨다.
            }
            setTimeout( // 1초 뒤 실행
                () => {
                    console.log(number);
                    resolve(number + 1); // 현재 숫자에 1을 더한 값을 반환한다.
                }, 1000
            );
        }
    );
}

promiseTest(1)
    .then( num => promiseTest(num) )
    .then( num => promiseTest(num) )
    .then( num => promiseTest(num) )
    .then( num => promiseTest(num) )
    .catch( e => console.log(e) );
비동기적으로 숫자 1씩 더하는 Promise 를 생성했다. 처음 promiseTest(1) 를 실행하면 숫자 2를 반환하고, 이 값은 .then 에 설정한 함수 파라미터로 전달한다. 순차적으로 5까지 숫자가 올라가면 오류를 발생한다.

01.16.3.1. axios

$ yarn add axios

import axios from 'axios';

axios.get('url')
    .then(response => console.log(response));
axios 로 웹 요청을 했을 대 반환되는 객체는 해당 요청의 응답 정보를 지낸 객체이다.

01.16.3.2. redux-thunk와 axios 사용

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

import axios from 'axios';

funciton getPostAPI(postId) {
    return axios.get('url/${postId}');
}

const GET_POST_PENDING = 'GET_POST_PENDING';
const GET_POST_SUCCESS = 'GET_POST_SUCCESS';
const GET_POST_FAILURE = 'GET_POST_FAILURE';

const getPostPending = createAction(GET_POST_PENDING);
const getPostSuccess = createAction(GET_POST_SUCCESS);
const getPostFailure = createAction(GET_POST_FAILURE);

export const getPost = (postId) => dispatch => {
    dispatch(getPostPending()); // 요청 시작했다는 것을 알림
    // 요청 시작. 여기에서 만든 promise를 return해야 나중에 컴포넌트에서 호출할 때 getPost().then(...)을 할 수 있다.
    return getPostAPI(postId)
    .then( () => {
        // 요청이 성공했다면 서버 응답 내용을 payload로 설정하여 GET_POST_SUCCESS 액션을 디스패치한다.
        dispatch(getPostSuccess(response));
        // 후에 getPostAPI.then 을 했을 때 then에 전달하는 함수에서 response 에 접근할 수 있게 한다.
        return response;
    } )
    .catch( error => {
        // 오류가 발생하면 오류 내용을 payload로 설정하여 GET_POST_FAILURE 액션을 디스패치한다.
        dispatch(getPostFailure(error));
        // error를 throw하여 이 함수를 실행한 후 다시 한 번 catch를 할 수 있게 한다.
        throw(error);
    } );
}
웹 요청을 시작할 때 POST_PENDING 액션을 디스패치하고 서버가 응답할 때까지 대기한다.
요청이 끝나 성공했을 때는 POST_SUCCESS 액션을 디스패치하고
요청이 실패했을 때는 POST_FAILURE 액션을 디스패치한다.
리듀서의 초기 상태를 정의하고 handleActions를 사용하여 리듀서 함수를 구현한다.
// src/modules/post.js
(...)
const initialState = {
    pending: false,
    error: false,
    data: {
        title: '',
        body: ''
    }
}

export default handleAction({
    [GET_POST_PENDING]: (state, action) => {
        return {
            ...state,
            pending: true,
            error: false
        };
    },
    [GET_POST_SUCCESS]: (state, action) => {
        const {title, body} = action.payload.data;
        return {
            ...state,
            pending: false,
            data: {
                title,
                body
            }
        };
    },
    [GET_POST_FAILURE]: (state, action) => {
        return {
            ...state,
            pending: false,
            error: true
        }
    }
}, initialState);
이 모듈의 리듀서를 루트 리듀서에 넣어준다.
// src/modules/index.js
import {combineReducers} from 'redux';
import counter from './counter';
import post from './post';

export default combineReducers({
    counter,
    post
});

// scr/modules/counter.js
(...)
export default handleActions({
    [INCREMENT]: (state, action) => state + 1,
    [DECREMENT]: (state, action) => state - 1
},1);
App 컴포넌트에서 post 모듈의 액션 생성 함수를 불러와 PostActions에 바인딩하고, post 모듈 안의 상태인 data, pending, error 값을 연결한다.
// src/App.js
import React, {Component} from 'react';
import {bindActionCreators} from 'redux';
import {connect} from 'react-redux';
import * as counterActions from './modules/counter';
import * as postActions from './modules/post';

class App extends Component {
    loadData = () => {
        const {PostActions, number} = this.props;
        PostActions.getPost(number);
    }

    componentDidMount() {
        this.loadData();
    }
    
    componentDidUpdate(prevProps, prevState) {
        // 이전 number 와 현재 number 가 다르면 요청을 시작
        if(this.props.number != prevProps.number) {
            this.loadData();
        }
    }

    render() {
        const {CounterActions, number, post, error, loading} = this.props;
        return (
            <div>
                <h1>{number}</h1>
                {
                    ( () => {
                        if (loading) return (<h2>로딩중...</h2>);
                        if (error) return (<h2>에러발생...</h2>);
                        return (
                            <div>
                                <h2>{post.title}</h2>
                                <p>{post.body}</p>
                            </div>
                        );
                    } )
                }
                <button onClick={CounterActions.increment}>+</button>
                <button onClick={CounterActions.decrement}>-</button>
            </div>
        );
    }
}

export default connect(
    (state) => ({
        number: state.counter,
        post: state.post.data,
        loading: state.post.pending,
        error: state.post.error
    }),
    (dispatch) => ({
        CounterActions: bindActionCreators(counterActions, dispatch),
        PostActions: bindActionCreators(postActions, dispatch)
    })
)(App);
해당 메서드는 PostActions.getPost 를 호출한다.
그리고 render 함수에서는 상황따라 다른 결과물을 렌더링하도록 설정되었다.
{} 내부에서 함수를 선언하고 그 함수 자체를 호출했다. 삼항 연산자가 여러 번 겹칠 때는 아예 함수를 만들어서 if 문을 사용하는 것이 더욱 가독성이 높다.
// src/App.js
PostActions.getPost(number).then(
    (response) => {
        console.log(response);
    }
).catch(
    (error) => {
        console.log(error);
    }
);
위 코드는 요청을 완료한 후나 오류가 발생했을 때 추가할 작업으로 redux-thunk 로 만든 액션 함수는 Promise 를 반환하기 때문에 해당 함수를 호출하고는 뒤에 .then 또는 .catch 를 입력해서 구현하면 된다.

리덕스의 정석대로 비동기 웹 요청을 하는 방법을 알아보았다. 모든 흐름을 다 이해한다 하더라도 각 요청마다 액션 타입을 세 개씩 선언하고 요청 전, 완료, 실패 상황에 따라 각각 다른 액션을 디스패치해야 하므로 조금 번거로울 수 있는 작업이다.

리덕스에서 비동기 작업을 처리하는 방법은 redux-thunk 외에도 여러 가지가 있다. redux-promise-middleware, redux-saga, redux-pender, redux-observable 등이 있는데 작동 방식과 설계 방식에 조금 차이가 있기 때문에 각 라이브러리를 한번 사용해 보는 것이 좋다.

01.16.4. redux-pender

redux-pender 는 Promise 기반 액션들을 관리하는 미들웨어가 포함되어 있는 라이브러리이다. 작동 방식은 redux-promise-middleware 와 유사하다. 액션 객체 안에 payload 가 Promise 형태라면 시작하기 전, 완료 또는 실패 했을 때 위에 PENDING, SUCCESS, FAILURE 접미사를 붙여 준다.

추가로 요청을 관리하는 리듀서가 포함되어 있으며, 요청 관련 액션들을 처리하는 액션 핸들러 함수들을 자동으로 만드는 도구도 들어있다.

그리고 요청 중인 액션을 취소할 수 있는 기능도 내장되어 있다.
$ yarn add redux-pender

// src/store.js
import {createStore, applyMiddleware} from 'redux';
import modules from './modules';

import {createLogger} from 'redux-logger';
import penderMiddleware from 'redux-pender';

/* 로그 미들웨어를 만들 때 설정을 커스터마이징 할 수 있다.
    https://github.com/LogRocket/redux-logger#options
 */
const logger = createLogger();

const store = createStore(modules, applyMiddleware(logger, penderMiddleware));

export default store;

// src/modules/index.js
import {combineReducers} from 'redux';
import counter from './counter';
import post from './post';
import {penderReducer} from 'redux-pender';

export default combineReducers({
    counter,
    post,
    pender: penderReducer
});
위 리듀서는 요청 상태를 관리한다. 이 리듀서가 가진 상태 구조는 다음과 같다.
{
    pending: {},
    success: {},
    failure: {}
}
새 Promise 기반 액션을 디스패치하면 상태는 다음과 같이 변한다.
{
    pending: {
        'ACTION_NAME': true
    },
    success: {
        'ACTION_NAME': false
    },
    failure: {
        'ACTION_NAME': false
    }
}
요청이 성공한다면 다음과 같이 변한다.
{
    pending: {
        'ACTION_NAME': false
    },
    success: {
        'ACTION_NAME': true
    },
    failure: {
        'ACTION_NAME': false
    }
}
요청이 실패한다면 다음과 같이 변한다.
{
    pending: {
        'ACTION_NAME': false
    },
    success: {
        'ACTION_NAME': false
    },
    failure: {
        'ACTION_NAME': true
    }
}
이런 작업은 pender 리듀서가 액션 이름에 따라서 자동으로 상태를 변경해 주기 때문에 요청과 관련된 상태는 더 이상 직접 관리할 필요가 없다.

redux-pender를 적용하면 액션 생성 함수와 리듀서의 액션 처리 관련 코드들을 간소화할 수 있다.
// src/modules/post.js
import {handleActions, createAction} from 'redux-action';
import {pender} from 'redux-pender';
import axios from 'axios';

function getPostAPI(postId) {
    return axios.get('url/${postId}');
}

const GET_POST = 'GET_POST';

/* redux-pender 의 액션 구조는 Flux standard action(https://github.com/redux-utilities/flux-standard-action)을 따르기 때문에, createAction 으로 액션을 만들 수 있다.
    두 번째로 들어가는 파라미터는 Promise 를 반환하는 함수여야 한다.
 */
export const getPost = createAction(GET_POST, getPostAPI);

const initialState = {
    // 요청이 진행 중인지, 오류가 발생했는지 여부는 더 이상 직접 관리할 필요가 없다.
    // penderReducer가 담당하기 때문이다.
    data: {
        title: '',
        body: ''
    }
}

export default handleActions({
    ...pender({
        type: GET_POST, // type 이 주어지면 이 type에 접미사를 붙인 액션 핸들러들이 담긴 객체를 만든다
        /* 요청 중일 때와 실패 했을 때 추가로 해야할 작업이 있다면
            onPending: (state, action) => state,
            onFailure: (state, action) => state
            를 추가하며 된다.
         */
        onSuccess: (state, action) => {
            // 성공했을 때 해야할 작업이 따로 없다면, 이 함수도 생략할 수 있다.
            const {title, body} = action.payload.data;
            return {
                data: {
                    title,
                    body
                }
            }
        }
        // 함수를 생략했을 때 기본 값으로는 (state, action) => state 를 설정한다.
        // (state 를 그대로 반환한다는 의미.)
    });
}, initialState);

신경 써야 할 상태가 줄었고, 코드의 길이도 짧아졌다. 리듀서에서 비동기 작업을 redux-pender 로 관리할 경우에는 ...pender 를 사용한다.

비동기 작업을 여러 개 관리한다면 ...pender 를 여러 번 사용하면 된다. 또 applyPenders 함수를 사용할 수도 있다.
// src/modules/post.js
import {handleActions, createAction} from 'redux-action';
import {pender, applyPenders} from 'redux-pender';
import axios from 'axios';

function getPostAPI(postId) {
    return axios.get('url/${postId}');
}

const GET_POST = 'GET_POST';

export const getPost = createAction(GET_POST, getPostAPI);

const initialState = {
    data: {
        title: '',
        body: ''
    }
}

export default handleActions({
    // 다른 일반 액션들을 관리 ...
}, initialState);

export default applyPenders(reducer, [
    {
        type: GET_POST,
        onSuccess: (state, action) => {
            // 성공했을 때 해야할 작업이 따로 없다면, 이 함수도 생략할 수 있다.
            const {title, body} = action.payload.data;
            return {
                data: {
                    title,
                    body
                }
            }
        },
        /* 다른 pender 액션들
            { type: GET_SOMTHING, onSuccess: (state, action) => ... },
            { type: GET_SOMTHING, onSuccess: (state, action) => ... },
         */
    }
]);
applyPenders 함수를 사용할 때 첫 번째 파라미터에는 일반 리듀서를 넣어주고, 두 번째 파라미터에는 pender 관련 객체들을 배열 형태로 넣어주면 된다.

리듀서에서 error 값과 pending 값을 더 이상 관여하지 않고, pender 리듀서가 대신 하게 되었다. App 컴포넌트 마지막 connect 하는 부분에 적용한다.
// src/App.js
(...)
export default connect(
    (state) => ({
        number: state.counter,
        post: state.post.data,
        loading: state.post.pending['GET_POST'],
        error: state.post.failure['GET_POST']
    }),
    (dispatch) => ({
        CounterActions: bindActionCreators(counterActions, dispatch),
        PostActions: bindActionCreators(postActions, dispatch)
    })
)(App);

Promise 기반 액션을 시작하면 액션 두 개를 디스패치한다. 하나는 GET_POST_PENDING이고, 다른 하나는 @@redux-pender/PENDING이다.
@@redux-pender/PENDING 의 payload 값에는 액션 이름이 들어가고, 이에 따라 pender 리듀서의 상태를 변화시킨다.

redux-pender 를 사용하면 Promise 기반 액션을 아주 쉽게 취소할 수 있다. Promise 기반 액션을 디스패치하고 나면 cancel 함수가 포함된 Promise 를 반환한다. 이 cancel 함수를 호출하면 미들웨어가 해당 요청을 처리하지 않는다.
// src/App.js
(...)

class App extends Component {
    cancelRequest = null

    handleCancel = () => {
        if(this.cancelRequest) {
            this.cancelRequest();
            this.cancelRequest = null;
        }
    }

    loadData = () => {
        const {PostActions, number} = this.props;
        PostActions.getPost(number);
    }

    componentDidMount() {
        this.loadData();
        // esc 키를 눌렀을 때 요청 취소
        window.addEventListener('keyup', (e) => {
            if(e.key === 'Escape') {
                this.handleCancel();
            }
        });
    }
(...)

// src/modules/post.js
...pender({
        type: GET_POST, 
        onSuccess: (state, action) => {
            const {title, body} = action.payload.data;
            return {
                data: {
                    title,
                    body
                }
            }
        },
        onCancel: (state, action) => {
            return {
                data: {
                    title: '취소됨',
                    body: '취소됨'
                }
            }
        }
    });
여기에서 cancel 함수를 호출한다고 해서 웹 요청을 취소하는 것은 아니다. 서버에 이미 요청을 보냈기 때문에 서버는 응답할 것이다. 이 응답을 미들웨어 쪽에서 무시할 뿐이다.


댓글

이 블로그의 인기 게시물

01.7 React ref (DOM에 이름 달기)

01.11 리액트 컴포넌트 CSS 적용 ( CSS Module, Sass, styled-components )

01.9 React 컴포넌트 라이프사이클