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() 에 전달하는 함수의 파라미터로 설정된다.
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_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 값을 연결한다.
추가로 요청을 관리하는 리듀서가 포함되어 있으며, 요청 관련 액션들을 처리하는 액션 핸들러 함수들을 자동으로 만드는 도구도 들어있다.
그리고 요청 중인 액션을 취소할 수 있는 기능도 내장되어 있다.
신경 써야 할 상태가 줄었고, 코드의 길이도 짧아졌다. 리듀서에서 비동기 작업을 redux-pender 로 관리할 경우에는 ...pender 를 사용한다.
비동기 작업을 여러 개 관리한다면 ...pender 를 여러 번 사용하면 된다. 또 applyPenders 함수를 사용할 수도 있다.
// 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 문을 사용하는 것이 더욱 가독성이 높다.
그리고 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 등이 있는데 작동 방식과 설계 방식에 조금 차이가 있기 때문에 각 라이브러리를 한번 사용해 보는 것이 좋다.
redux-pender 는 Promise 기반 액션들을 관리하는 미들웨어가 포함되어 있는 라이브러리이다. 작동 방식은 redux-promise-middleware 와 유사하다. 액션 객체 안에 payload 가 Promise 형태라면 시작하기 전, 완료 또는 실패 했을 때 위에 PENDING, SUCCESS, FAILURE 접미사를 붙여 준다.리덕스의 정석대로 비동기 웹 요청을 하는 방법을 알아보았다. 모든 흐름을 다 이해한다 하더라도 각 요청마다 액션 타입을 세 개씩 선언하고 요청 전, 완료, 실패 상황에 따라 각각 다른 액션을 디스패치해야 하므로 조금 번거로울 수 있는 작업이다.
리덕스에서 비동기 작업을 처리하는 방법은 redux-thunk 외에도 여러 가지가 있다. redux-promise-middleware, redux-saga, redux-pender, redux-observable 등이 있는데 작동 방식과 설계 방식에 조금 차이가 있기 때문에 각 라이브러리를 한번 사용해 보는 것이 좋다.
01.16.4. redux-pender
추가로 요청을 관리하는 리듀서가 포함되어 있으며, 요청 관련 액션들을 처리하는 액션 핸들러 함수들을 자동으로 만드는 도구도 들어있다.
그리고 요청 중인 액션을 취소할 수 있는 기능도 내장되어 있다.
$ 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를 적용하면 액션 생성 함수와 리듀서의 액션 처리 관련 코드들을 간소화할 수 있다.
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 함수를 호출한다고 해서 웹 요청을 취소하는 것은 아니다. 서버에 이미 요청을 보냈기 때문에 서버는 응답할 것이다. 이 응답을 미들웨어 쪽에서 무시할 뿐이다.
댓글
댓글 쓰기