본문 바로가기

Front/React

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

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

1. 작업 환경 준비

$ yarn create react-app learn-redux-middleware

 

$ yarn add redux react-redux redux-actions

 

[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);

const initialState = 0 // 상태는 꼭 객체일 필요가 없다. 숫자도 가능

const counter = handleActions(
    {
        [INCREASE]: state => state + 1,
        [DECREASE]: state => state - 1
    },
    initialState
);

export default counter;

 

[modules/index.js]

import {combineReducers } from 'redux';
import counter from './counter';

const rootReducer = combineReducers({
    counter
});

export default rootReducer;

 

리듀서를 다 만들었으면 src. 디렉터이의 index.js에서 스토어를 생성한 후, Provider로 리액트 프로젝트에 리덕스를 적용한다.

 

[index.js]

import React from 'react';
import ReactDOM from 'react-dom';
import {createStore} from 'redux';
import {Provider} from 'react-redux';
import './index.css';
import App from './App';
import reportWebVitals from './reportWebVitals';
import rootReducer from './modules'

const store = createStore(rootReducer);

ReactDOM.render(
  <Provider store={store}>
    <App />
  </Provider>,
  document.getElementById('root')
);

reportWebVitals();

 

이어서 카운터 컴포넌트와 카운터 컨테이너 컴포넌트를 만든다.

프레젠테이셔널 컴포넌트를 components 디렉터리에 저장하고, 컨테이너 컴포넌트는 containers디렉터리에 저장한다.

 

[components/Counter.js]

import React from 'react';

const Counter = ({ onIncrease, onDecrease, number }) => {
    return (
        <div>
            <h1>{number}</h1>
            <button onClick={onIncrease}>+1</button>
            <button onClick={onDecrease}>-1</button>
        </div>
    );
};

export default Counter;

 

[containers/CounterContainers.js]

import React from 'react';
import { connect } from 'react-redux';
import { increase, decrease } from '../modules/counter';
import Counter from '../components/Counter';

const CounterContainer = ({ number, increase, decrease }) => {
    return(
        <Counter number={number} onIncrease={increase} onDecrease={decrease} />
    );
};

export default connect(
    state => ({
        number: state.counter
    }),
    {
        increase,
        decrease
    }
)(CounterContainer);

 

[App.js]

import React from 'react';
import CounterContainer from './containers/CounterContainer';

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

export default App;

 

[결과]

 

2. 미들웨어란?

  • 리덕스 미들웨어 => 액션을 디스패치했을 때 리듀서에서 이를 처리하기에 앞서 사전에 지정된 작업들을 실행
  • 미들웨어는 액션과 리듀서 사이의 중간자
  • 액션 -> 미들웨서 -> 리듀서 -> 스토어
  • 리듀서가 액션을 처리하기 전에 미들웨어가 할 수 있는 작업은 여러가지 있다.
    • 전달 받은 액션을 단순히 콘솔에 기록
    • 전달받은 액션 정보를 기반으로 액션을 아예 취소
    • 다른 종류의 액션을 추가로 디스패치

2.1. 미들웨어 만들기

  • 실제 프로젝트를 작업할 때 미들웨어를 직접 만들어 사용할 일은 많지 않다.
    • 다른 개발자가 만들어 놓은 미들웨어를 사용하면 되기 때문
    • 하지만 미들웨어가 어떻게 작동하는지 이해하려면 직접 만들어 보는 것이 가장 효과적
  • 원하는 미들웨어를 찾을 수 없을 때는 상황에 따라 직접 만들거나 기존 미들웨어들을 커스터마이징하여 사용할 수 있다.
  • 액션이 디스패치될 때마다 액션의 정보와 액션이 디스패치되기 전후의 상태를 콘솔에 보여주는 로깅 미들웨어를 작성해보겠다.

[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를 사용하지 않으면 액션이 리듀서에 전달되지 않는다.(액션이 무시되는 것)
  • 이번에 만들 미들웨어는 다음 정보를 순차적으로 콘솔에 보여준다.
    • 1. 이전상태
    • 2. 액션 정보
    • 3. 새로워진 상태

[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;

 

만든 리덕스 미들웨어를 스토어에 적용하겠다.

미들웨어는 스토어를 생성하는 과정에서 적용한다.

 

[index.js]

import React from 'react';
import ReactDOM from 'react-dom';
import {createStore, applyMiddleware} from 'redux';
import {Provider} from 'react-redux';
import './index.css';
import App from './App';
import reportWebVitals from './reportWebVitals';
import rootReducer from './modules'
import loggerMiddleware from './lib/loggerMiddleware';

const store = createStore(rootReducer, applyMiddleware(loggerMiddleware));

ReactDOM.render(
  <Provider store={store}>
    <App />
  </Provider>,
  document.getElementById('root')
);

reportWebVitals();

 

[결과]

 

  • 액션정보와 업데이트되기 전후 상태가 잘 나타난다.
  • 미들웨어는 여러 종류의 작업을 처리할 수 있다.
    • 특정 조건에 따라 액션을 무시하게 할 수 도 있고
    • 특정 조건에 따라 액션 정보를 가로채서 변경한 후 리듀서에게 전달해줄 수도 있다.
    • 아니면 특정 액션에 기반하여 새로운 액션을 여러 번 디스패치할 수도 있다.
  • 이러한 미들웨어 속성을 사용하여 네트워크 요청과 같은 비동기 작업을 관리하면 매우 유용하다.

 

 

2.2. redux-logger 사용하기

  • 이번엔 오픈 소스 커뮤니티에 이미 올라와 있는 redux-logger 미들웨어를 설치하고 사용해 보겠다.
  • 방금 만든 loggerMiddleware보다 훨씬 더 잘 만들어진 라이브러리이며, 브라우저 콘솔에 나타나는 형식도 훨씬 깔끔하다.
  • 명령어를 사용하여 redux-logger를 설치한다.
    • $ yarn add redux-logger
  • index.js 수정

[src/index.js]

import React from 'react';
import ReactDOM from 'react-dom';
import {createStore, applyMiddleware} from 'redux';
import {Provider} from 'react-redux';
import './index.css';
import App from './App';
import reportWebVitals from './reportWebVitals';
import rootReducer from './modules'
// import loggerMiddleware from './lib/loggerMiddleware';
import {createLogger} from 'redux-logger';

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

ReactDOM.render(
  <Provider store={store}>
    <App />
  </Provider>,
  document.getElementById('root')
);

reportWebVitals();

 

[결과]

 

콘솔에 색상도 입혀지고, 액션 디스패치 시간도 나타난다.

리덕스 미들웨어를 사용할 때는 이렇게 이미 완성된 미들웨어를 라이브러리로 설치해서 사용하는 경우가 많다.

 

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

여기서 다룰 미들웨어는 다음과 같다.

1. redux-thunk

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

2. redux-saga

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

 

3.1. redux-thunk

3.1.1. Thunk란?

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

예) 주어진 파라미터에 1을 더하는 함수를 만들고 싶다면 다음과 같이 작성

 

const addOne = x => x+1;
addOne(1); //2

 

이 코드를 실행하면 addOne을 호출했을 때 바로 1+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);

 

이렇게 하면 특정 작업을 나중에 하도록 미룰 수 있다.

만약 addOneThunk를 화살표 함수로만 사용한다면 다음과 같이 구현할 수 있다.

 

const addOne = x => x+1;
const addOneThunk = x => () => addOne(x);

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

 

redux-thunk라이브러리를 사용하면 thunk함수를 만들어서 디스패치할 수 있다.

그러면 리덕스 미들웨어가 그 함수를 전달받아 store의 dispatch와 getState를 파라미터로 넣어서 호출해준다.

 

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

 

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

 

3.1.2. 미들웨어 적용하기

$ yarn add redux-thunk

 

[index.js]

import React from 'react';
import ReactDOM from 'react-dom';
import {createStore, applyMiddleware} from 'redux';
import {Provider} from 'react-redux';
import './index.css';
import App from './App';
import reportWebVitals from './reportWebVitals';
import rootReducer from './modules'
// import loggerMiddleware from './lib/loggerMiddleware';
import {createLogger} from 'redux-logger';
import ReduxThunk from 'redux-thunk';

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

ReactDOM.render(
  <Provider store={store}>
    <App />
  </Provider>,
  document.getElementById('root')
);

reportWebVitals();

 

3.1.3. Thunk 생성 함수 만들기

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

increaseAsync와 decreaseAsync함수를 만들어 카운터 값을 비동기적으로 한번 변경시켜 보자

 

[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);
};

const initialState = 0 // 상태는 꼭 객체일 필요가 없다. 숫자도 가능

const counter = handleActions(
    {
        [INCREASE]: state => state + 1,
        [DECREASE]: state => state - 1
    },
    initialState
);

export default counter;

 

리덕스 모듈을 수정했으면 CounterContainer에서 호출하던 액션 생성 함수도 변경해준다.

 

[container/CounterContainer.js]

import React from 'react';
import { connect } from 'react-redux';
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);

 

[결과]

 

처음 디스패치되는 액션은 함수형태이고, 두 번째 액션은 객체 형태이다.

 

 

3.1.4. 웹요청 비동기 작업 처리하기

 

사용할 API

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

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

 

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

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

 

  • API를 호출할 때는 주로 Promise 기반 웹 클라이언트인 axios를 사용한다. 해당 라이브러리를 설치
    • $ yarn add axios
  • API를 모두 함수화해 준다.
    • 각 API를 호출하는 함수를 따로 작성하면, 나중에 사용할 때 가독성도 좋고 유지 보수도 쉬워진다.
    • 다른 파일에서 불러와 사용할 수 있도록 export를 사용하여 내보내준다.

[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`);

위 API를 사용하여 데이터를 받아와 상태를 관리할 sample이라는 리듀서를 생성한다.

 

[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;

 

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

 

[modules/index.js]

import {combineReducers } from 'redux';
import counter from './counter';
import sample from './sample';

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

export default rootReducer;

 

[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 && (
                    <ul>
                        {users.map(user => (
                            <li key={user.id}>
                                {user.username} ({user.email})
                            </li>
                        ))}
                    </ul>
                )}
            </section>
        </div>
    );
};

export default Sample;
  • 데이터를 불러와 렝더링해 줄 때 유효성 검사를 해 주는 것이 중요
    • 예) post && 를 사용하면 post객체가 유효할 때만 그 내부의 post.title 혹인 post.body 값을 보여준다.
    • 만약 데이터가 없는 상태라면 post.title을 조회하려고 할 때 자바스크립트 오류가 발생
  • users도 마찬가지로 데이터가 배열 형태로 들어올 것을 기대하고 map함수를 사용
    • 하지만 유효성 검사를 하지 않으면 null값에 대해 map함수를 호출하고, 결국 map함수가 존재하지 않아 오류 발생
  • 이제 컨테이너 컴포넌트를 만들어 보겠다.

[containers/SampleContainer.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}
            loadingUsers={loadingUsers}
        />
    );
};

export default connect(
    ({sample}) => ({
        post: sample.post,
        users: sample.users,
        loadingPost: sample.loading.GET_POST,
        loadingUsers: sample.loading.GET_USERS
    }),
    {
        getPost,
        getUsers
    }
)(SampleContainer);

 

[App.js]

import React from 'react';
import SampleContainer from './containers/SampleContainer';

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

export default App;

 

[결과]

 

3.1.5. 리팩토링

  • 반복되는 로직을 따로 분리하여 코드의 양을 줄여보자

[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);
  • 이번에 만든 유틸함수는 API요청을 해 주는 thunk함수를 한 줄로 생성할 수 있게 해준다.
  • 액션 타입과 API를 요청한느 함수를 파라미터로 넣어 주면 나머지 작업을 대신 처리해준다.
  • 이 함수를 사용하여 기존 thunk함수의 코드를 대체해보겠다.

[modules/sample.js]

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 // 요청 완료
            }
        }),
        [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;
  • 이번엔 요청의 로딩상태를 관리하는 작업을 개선하겠다.
  • 기존에는 리듀서 내부에서 각 요청에 관련된 액션이 디스패치될 때마다 로딩 상태를 변경해 주었다.
    • 이 작업을 로딩 상태만 관리하는 리덕스 모듈을 따로 생성하여 처리하겠다.

 

[modules/loading.js]

import {createAction, handleActions} from 'redux-actions';

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;

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

{
   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]

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;

 

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

 

[lib/createRequestThunk.js]

import {startLoading, finishLoading} from '../modules/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 (e) {
            dispatch({
                type: FAILURE,
                payload: e,
                error: true
            }); // 에러 발생
            dispatch(startLoading(type))
            throw e;
        } 
    };
}

// 사용법: createRequestThunk('GET_USERS', api.getUsers);

 

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

 

[containers/SampleContainer.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}
            loadingUsers={loadingUsers}
        />
    );
};

export default connect(
    ({sample, loading}) => ({
        post: sample.post,
        users: sample.users,
        loadingPost: loading['sample/GET_POST'],
        loadingUsers: loading['sample/GET_USERS']
    }),
    {
        getPost,
        getUsers
    }
)(SampleContainer);

 

sample 리듀서에서 불필요한 코드를 지워보자.

 

[modules/sample.js]

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]: state => ({
            ...state,
            loading: {
                ...state.loading,
                GET_POST: true // 요청 시작
            }
        }),
        [GET_POST_SUCCESS]: (state, action) => ({
            ...state,
            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,
            users: action.payload
        }),
        [GET_USERS_FAILURE]: (state, action) => ({
            ...state,
            loading: {
                ...state.loading,
                GET_USERS: false // 요청 완료
            }
        })
    },
    initialState
);

export default sample;
  • 이제 sample리듀서에서는 로딩 중에 대한 상태를 관리할 필요가 없다.
  • 추가로 실패했을 때의 케이스를 관리하고 싶다면 _FAILURE가 붙은 액션을 리듀서에서 처리해주면 된다.
    • 혹은 컨테이너 컴포넌트에서 try/catch구문을 사용하여 에러 값을 조회할 수도 있다.

[SampleContainer.js - useEffect]

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(() => {
        // useEffect에 파라미터로 넣는 함수는 async로 할 수 없기 때문에
        // 그 내부에서 async 함수를 선언하고 호출해준다.
        const fn = async() => {
            try {
                await getPost(1);
                await getUsers(1);
            } catch (e) {
                console.log(e); // 에러 조회
            }
        };
        fn();
    }, [getPost, getUsers]);
    return (
        <Sample
            post={post}
            users={users}
            loadingPost={loadingPost}
            loadingUsers={loadingUsers}
        />
    );
};

export default connect(
    ({sample, loading}) => ({
        post: sample.post,
        users: sample.users,
        loadingPost: loading['sample/GET_POST'],
        loadingUsers: loading['sample/GET_USERS']
    }),
    {
        getPost,
        getUsers
    }
)(SampleContainer);

 

3.2. redux-saga

  • 다음과 같은 상황에서 redux-saga를 사용하는 것이 유리하다.
    • 기존 요청을 취소 처리해야 할 때(불필요한 중복 요청 방지)
    • 특정 액션이 발생했을 때 다른 액션을 발생기키거나, API요청 등 리덕스와 관계없는 코드를 실행할 때
    • 웹소켓을 사용할 때
    • API 요청 실패 시 재요청해야 할 때

3.2.1. 제너레이터 함수 이해하기

  • ES6의 제너레이터 함수 문법 사용
    • 보통 일반적인 상황에서는 많이 사용되지 않기 때문에 초반에 진입 장벽이 있을 수 있다.
    • 이 문법의 핵심 기능은 함수를 작성할 때 함수를 특정 구간에 멈춰 놓을 수 있고, 원할 때 다시 돌아가게 할 수도 있다.
  • 다음과 같은 함수가 있다고 가정
function weirdFunction() {
   return 1;
   return 2;
   return 3;
   return 4;
   return 5;
}
  • 하나의 함수에서 값을 여러 개 반환하는 것은 불가능하므로 이 코드는 제대로 작동하지 않음
    • 정확히는 호출할 때마다 맨 위에 있는 값인 1이 반환됨
  • 하지만 제너레이터 함수를 사용하면 함수에서 값을 순차적으로 반환할 수 있다.
    • 심지어 함수의 흐름을 도중에 멈춰 놓았다가 다시 이어서 진행시킬 수도 있다.
  • 크롬 개발자 도구 콘솔에서 다름 함수를 한번 작성해보자
function* generatorFunction() {
   console.log('안녕하세요');
   yield 1;
   console.log('제너레이터 함수');
   yield 2;
   console.log('function*');
   yield 3;
   return 4;
}
  • 제너레이터 함수를 만들 때는 function* 키워드를 사용한다.
  • 함수를 작성한 뒤에는 다음 코드를 사용해 제너레이터를 생성한다.

 

const generator = generatorFunction();
  • 제너레이터 함수를 호출했을 때 반환되는 객체를 제너레이터라고 부른다.
  • 이제 다음 코드를 순차적으로 한 줄씩 입력하고 어떤 결과가 나오는지 확인해 보자
generator.next();

 

[결과]

  • 제너레이터가 처음 만들어지면 함수의 흐름은 멈춰 있는 상태이다.
  • next() 가 호출되면 다음 yield가 있는 곳까지 호출하고 다시 함수가 멈춘다.
  • 제너레이터 함수를 사용하면 함수를 도중에 멈출 수도 있고, 순차적으로 여러 값을 반환시킬 수도 있다.
  • next 함수에 파라미터를 넣으면 제너레이터 함수에서 yield를 사용하여 해당 값을 조회할 수도 있다.
  • 다음 예시 코드를 크롬 개발자 도구 콘솔에서 한번 입력해보자
function* sumGenerator(){
   console.log('sumGenerator가 만들어졌습니다.');
   let a = yield;
   let b = yield;
   yield a+b;
}
const sum = sumGenerator();
sum.next();
// sumGenerator가 만들어졌습니다.
// {value: undefined, done: false}
sum.next(1);
// {value: undefined, done: false}
sum.next(2);
// {value: 3, done: false}
sum.next()
// {value: undefined, done: false}

 

[결과]

  • redux-saga 는 제너레이터 함수 문법을 기반으로 비동기 작업을 관리해준다.
  • redux-saga는 우리가 디스패치하는 액션을 모니터링해서 그에 따라 필요한 작업을 따로 수행할 수 있는 미들웨어
  • 다음 예시 코드를 크롬 개발자 도구에 입력해보자
function* watchGenerator() {
   console.log('모니터링 중...');
   let prevAction = null;
   while(true) {
      const action = yield;
      console.log('이전 액션: ', prevAction);
      prevAction = action;
      if (action.type == 'HELLO') {
         console.log('안녕하세요!');
      }
   }
}

 

[결과]

  • redux-saga는 위 코드와 비슷한 원리로 작동한다.
  • 제너레이터 함수의 작동 방식만 기본적으로 파악하고 있으면, redux-saga에서 제공하는 여러 유용한 유틸 함수를 사용하여 액션을 쉽게 처리할 수 있다.

 

3.2.2. 비동기 카운터 만들기

  • 기존에 thunk 함수로 구현했던 비동기 카운터를 이번에는 redux-saga를 사용하여 구현해 보자.
  • 라이브러리 설치
    • $ yarn add redux-saga
  • counter리덕스 모듈을 열어 기존 thunk 함수를 제거하고, INCREASE_ASYNC와 DECREASE_ASYNC라는 액션 타입을 선언한다.
  • 해당 액션에 대한 액션 생성 함수도 만들고, 이어서 제너레이터 함수를 만든다.
  • 이 제너레이터 함수를 사가(saga)라고 부른다.

[modules/counter.js]

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;

 

그리고 루트 리듀서를 만들었던 것처럼 루트 사가를 만들어 주어야 한다.

추후 다른 리듀서에서도 사가를 만들어 등록할 것이기 때문에

 

[modules/index.js]

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;

 

이제 스토어에 redux-saga 미들웨어를 적용해준다.

 

[index.js]

import React from 'react';
import ReactDOM from 'react-dom';
import {createStore, applyMiddleware} from 'redux';
import {Provider} from 'react-redux';
import './index.css';
import App from './App';
import reportWebVitals from './reportWebVitals';
import rootReducer, { rootSaga } from './modules'
// import loggerMiddleware from './lib/loggerMiddleware';
import {createLogger} from 'redux-logger';
import ReduxThunk from 'redux-thunk';
import createSagaMiddleware from 'redux-saga';

const logger = createLogger();
const sagaMiddleware = createSagaMiddleware();
const store = createStore(
  rootReducer, 
  applyMiddleware(logger, ReduxThunk, sagaMiddleware)
);
sagaMiddleware.run(rootSaga)

ReactDOM.render(
  <Provider store={store}>
    <App />
  </Provider>,
  document.getElementById('root')
);

reportWebVitals();

 

[App.js]

import React from 'react';
import CounterContainer from './containers/CounterContainer';

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

export default App;

 

리덕스 개발자 도구를 적용하여 어떤 액션이 디스패치되고 있는지 더 편하게 확인할 수 있다.

$ yarn add redux-devtools-extension

이 라이브러리의 composeWithDevTools함수를 리덕스 미들웨어와 함께 사용할 때는 그냥 applyMiddleware부분을 감싸주면 된다.

 

[index.js]

import React from 'react';
import ReactDOM from 'react-dom';
import {createStore, applyMiddleware} from 'redux';
import {Provider} from 'react-redux';
import './index.css';
import App from './App';
import reportWebVitals from './reportWebVitals';
import rootReducer, { rootSaga } from './modules'
// import loggerMiddleware from './lib/loggerMiddleware';
import {createLogger} from 'redux-logger';
import ReduxThunk from 'redux-thunk';
import createSagaMiddleware from 'redux-saga';
import {composeWithDevTools} from 'redux-devtools-extension'

const logger = createLogger();
const sagaMiddleware = createSagaMiddleware();
const store = createStore(
  rootReducer, 
  composeWithDevTools(applyMiddleware(logger, ReduxThunk, sagaMiddleware))
);
sagaMiddleware.run(rootSaga)

ReactDOM.render(
  <Provider store={store}>
    <App />
  </Provider>,
  document.getElementById('root')
);

reportWebVitals();

 

[결과]

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

  • -1 버튼을 두 번 누르면 DECREASE_ASYNC액션이 두 번 디스패치되었음에도 DECREASE액션은 단 한 번 디스패치 되었다.
  • decreaseSaga를 등록할 때 takeLatest를 사용했기 때문에 여러 액션이 중첩되어 디스패치되었을 때는 기존의 것들은 무시하고 마지막 액션만 제대로 처리한다.

 

3.2.3. API 요청 상태 관리하기

기존에 thunk로 관리하던 액션 생성 함수를 없애고, 사가를 사용하여 처리

 

[modules/sample.js]

import {createAction, handleActions} from 'redux-actions';
import {call, put, takeLatest} from 'redux-saga/effects';
import * as api from '../lib/api'
import {startLoading, finishLoading} from './loading';

// 액션 타입을 선언한다.
// 한 요청당 세 개를 만들어야 한다.

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 = createAction(GET_POST, id => id);
export const getUsers = createAction(GET_USERS);

function* getPostSaga(action){
    yield put(startLoading(GET_POST)); // 로딩 시작
    // 파라미터로 action을 받아 오면 액션의 정보를 조회할 수 있다.
    try {
        // call을 사용하면 Promise를 반환하는 함수를 호출하고, 기다릴 수 있다.
        // 첫 번째 파라미터는 함수, 나머지 파라미터는 해당 함수에 넣을 인수이다.
        const post = yield call(api.getPost, action.payload); // api.getPost(action.payload)를 의미
        yield put({
            type: GET_POST_SUCCESS,
            payload: post.data
        });
    } catch (e) {
        // try/catch 문을 사용하여 에러도 잡을 수 있다.
        yield put({
            type: GET_POST_FAILURE,
            payload: e,
            error: true
        });
    }
    yield put(finishLoading(GET_POST)); // 로딩 완료
}

function* getUserSaga() {
    yield put(startLoading(GET_USERS));
    try {
        const users = yield call(api.getUsers);
        yield put({
            type: GET_USERS_SUCCESS,
            payload: users.data
        });
    } catch (e) {
        yield put({
            type: GET_USERS_FAILURE,
            payload: e,
            error: true
        });
    }
    yield put(finishLoading(GET_USERS));
}

export function* sampleSaga() {
    yield takeLatest(GET_POST, getPostSaga);
    yield takeLatest(GET_USERS, getUserSaga);
}

// 초기 상태를 선언한다.
// 요청의 로딩 중 상태는 loading 이라는 객체에서 관리한다.

const initialState = {
    post: null,
    users: null
};

const sample = handleActions(
    {
        [GET_POST_SUCCESS]: (state, action) => ({
            ...state,
            post: action.payload
        }),
        [GET_USERS_SUCCESS]: (state, action) => ({
            ...state,
            users: action.payload
        })
    },
    initialState
);

export default sample;
  • GET_POST 액션의 경우에는 API 요청을 할 때 어떤 id로 조회할지 정해주어야 한다.
  • redux-saga를 사용할 때는 id처럼 요청에 필요한 값을 액션의 payload로 넣어주어야 한다.
  • 예를 들어 지금 상황이라면 다음과 같은 액션이 디스패치 된다.
{
   type: 'sample/GET_POST',
   payload: 1
}

 

  • 이 액션을 처리하기 위한 사가를 작성할 때 payload 값을 API를 호출하는 함수의 인수로 넣어주어야 한다.
  • API를 호출해야 하는 상황에는 사가 내부에서 직접 호출하지 않고 call 함수를 사용한다.
    • call 함수의 경우, 첫 번째 인수는 호출하고 싶은 함수이고, 그 뒤에 오는 인수들은 해당 함수에 넣어 주고 싶은 인수이다.
    • 지금 getPostSaga의 경우 id를 의미하는 action.payload가 인수가 된다.
  • redux-thunk를 사용했을 때처럼 반복되는 코드가 나왔다. 
    • 충분히 간소화할 수 있는 코드이다.

[modules/index.js]

import {combineReducers } from 'redux';
import {all} from 'redux-saga/effects';
import counter, {counterSaga} from './counter';
import sample, {sampleSaga} from './sample';
import loading from './loading'

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

export function* rootSaga(){
    // all 함수는 여러 사가를 합쳐 주는 역할을 한다.
    yield all([counterSaga(), sampleSaga()]);
}

export default rootReducer;

 

[App.js]

import React from 'react';
import SampleContainer from './containers/SampleContainer';

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

export default App;

 

[결과]

 

3.2.4. 리팩토링

  • 반복되는 코드를 따로 함수화 하여 리팩토링 한다.
  • 이전에 thunk함수를 위해 createRequestThunk라는 함수를 만들었던 것처럼 createRequestSaga라는 함수를 만들겠다.

[lib/createRequestSaga.js]

import {call, put} from 'redux-saga/effects';
import {startLoading, finishLoading} from '../modules/loading';

export default function createRequestSaga(type, request) {
    const SUCCESS = `${type}_SUCCESS`;
    const FAILURE = `${type}_FAILURE`;

    return function*(action) {
        yield put(startLoading(type)); // 로딩 시작
        try {
            const response = yield call(request, action.payload);
            yield put({
                type: SUCCESS,
                payload: response.data
            });
        } catch (e) {
            yield put({
                type: FAILURE,
                payload: e,
                error: true
            });
        }
        yield put(finishLoading(type)); // 로딩 끝
    };
}

 

이제 구현했던 사가를 다음과 같이 짧은 코드로 구현할 수 있다.

[modules/sample.js]

import {createAction, handleActions} from 'redux-actions';
import {takeLatest} from 'redux-saga/effects';
import * as api from '../lib/api'
import createRequestSaga from '../lib/createRequestSaga';

// 액션 타입을 선언한다.
// 한 요청당 세 개를 만들어야 한다.

const GET_POST = 'sample/GET_POST';
const GET_POST_SUCCESS = 'sample/GET_POST_SUCCESS';

const GET_USERS = 'sample/GET_USERS';
const GET_USERS_SUCCESS = 'sample/GET_USERS_SUCCESS';

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

export const getPost = createAction(GET_POST, id => id);
export const getUsers = createAction(GET_USERS);

const getPostSaga = createRequestSaga(GET_POST, api.getPost);
const getUsersSaga = createRequestSaga(GET_USERS, api.getUsers);

export function* sampleSaga() {
    yield takeLatest(GET_POST, getPostSaga);
    yield takeLatest(GET_USERS, getUsersSaga);
}

// 초기 상태를 선언한다.
// 요청의 로딩 중 상태는 loading 이라는 객체에서 관리한다.

const initialState = {
    post: null,
    users: null
};

const sample = handleActions(
    {
        [GET_POST_SUCCESS]: (state, action) => ({
            ...state,
            post: action.payload
        }),
        [GET_USERS_SUCCESS]: (state, action) => ({
            ...state,
            users: action.payload
        })
    },
    initialState
);

export default sample;

 

3.2.5. 알아 두면 유용한 기능들

  • 사가 내부에서 현재 상태를 조회하는 방법

[modules/counter.js]

import {createAction, handleActions} from 'redux-actions';
import {delay, put, takeEvery, takeLatest, select} 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()); // 특정 액션을 디스패치한다.
    const number = yield select(state => state.counter); // state는 스토어 상태를 의미함
    console.log(`현재 값은 ${number}입니다.`)
}

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;

[결과]

 

 

 

  • 사가가 실행되는 주기를 제한하는 방법
    • takeEvery대신 throttle이라는 함수를 사용하면 사가가 n초에 한 번만 호출되도록 설정할 수 있다.
    • 예를 들어 counterSaga를 다음과 같이 수정하면 increaseSaga는 3초에 단 한 번만 호출된다.

[modules/counter.js]

import {createAction, handleActions} from 'redux-actions';
import {delay, put, takeLatest, select, throttle} 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()); // 특정 액션을 디스패치한다.
    const number = yield select(state => state.counter); // state는 스토어 상태를 의미함
    console.log(`현재 값은 ${number}입니다.`)
}

function* decreaseSaga() {
    yield delay(1000); // 1초를 기다린다.
    yield put(decrease()); // 특정 액션을 디스패치한다.
}

export function* counterSaga() {
    // 첫 번째 파라미터: n초 * 1000
    yield throttle(3000, 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;

 

  • redux-saga는 이 외에도 여러 기능을 제공하기 때문에 비동기 작업을 처리하면서 겪을 수 있는 다양한 상황에 맞춰 개발할 수 있다.
    • 복잡한 상황을 접했다면 redux-saga의 메뉴얼 (https://redux-saga.js.org/)을 참고하면 된다.