티스토리 뷰

대부분의 경우에는 직전에 다룬 redux-thunk로도 충분히 기능을 구현할 수 있지만, redux-saga 는 좀 더 까다로운 상황에서 유용하게 쓸 수 있다. 

 

아래와 같은 상황에서 redux-saga 를 주로 사용한다.

 

- 기존 요청을 취소 처리해야 할 때(불필요한 중복 요청 방지)

- 특정 액션이 발생했을 때 다른 액션을 발생시키거나, API 요청 등 리덕스와 관계없는 코드를 실행할 때

- 웹소켓을 사용할 때

- API 요청 실패 시 재요청해야 할 때

 

redux-saga 에서는 ES6의 제너레이터 함수 문법을 사용하는데 보통 일반적인 상황에서는 많이 사용되지 않기 때문에 먼저 다뤄보고 들어가자.


generator function

제너레이터 함수의 핵심 기능은 함수를 작성할 때 함수를 특정 구간에 멈춰 놓을 수도 있고, 원할 때 다시 돌아가게 할 수도 있다는 것이다.

 

아래와 같은 함수가 있다고 가정해보자.

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

 

제너레이터 객체가 가지고 있는 next 를 호출하여 아래와 같이 순차적으로 제너레이터 함수를 실행할 수 있다.

제너레이터가 처음 만들어지면 함수의 흐름은 멈춰 있는 상태이며 next() 가 호출되면 다음 yield가 있는 곳까지 호출하고 다시 함수가 멈춘다.

 

제너레이터 함수를 사용하면 함수를 도중에 멈출 수도 있고, 순차적으로 여러 값을 반환시킬 수도 있다. 

 

next 함수에 파라미터를 넣으면 제너레이터 함수에서 yield를 사용하여 해당 값을 조회할 수도 있다.

 

즉, 아래와 같은 제너레이트 함수를 작성하고

function* sumGenerator() {
    console.log('sumGenerator has been created!');
    let a = yield;
    let b = yield;
    yield a + b;
}

next 에 파라미터를 넘겨주면 a와 b를 셋팅하여 반환해준다.


redux-saga는 제너레이터 함수 문법을 기반으로 비동기 작업을 관리해준다. 

 

좀 더 디테일하게 보면, redux-saga는 우리가 디스패치하는 액션을 모니터링해서 그에 따라 필요한 작업을 따로 수행하는 미들웨어이다.

 

아래 코드는 redux-saga의 작동 원리를 이해하기 위한 간단한 제너레이터 함수 사용 예시이다.

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


비동기 카운터

이제 직전 포스팅에서 redux-thunk로 구현했던 비동기 카운터를 redux-saga를 사용하여 구현해보자.

 

우선, 라이브러리부터 설치해주자.

npm install redux-saga

 

직전에 사용했던 예제를 그대로 사용하려고 한다.

counter 리덕스 모듈을 열어서 thunk 함수를 제거하고, 아래와 같이 제너레이터 함수를 작성해준다.

 

◎modules/counters.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);
    yield put(increase()); // 특정 액션을 디스패치함
}

function* decreaseSaga() {
    yield delay(1000);
    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 counter, {counterSaga} from "./counter";
import sample from "./sample";
import {all} from 'redux-saga/effects';

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

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

export default rootReducer;

 

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

 

◎index.js

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

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

sagaMiddleware.run(rootSaga);

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

 

그 외 Counter 컴포넌트나 컨테이너는 딱히 건들 필요는 없다. (액션 함수 이름이 똑같기 때문에)

 

◎containers/CounterContainer.js

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

 

◎components/Counter.js

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

export default Counter;

 

◎App.js

import './App.css';
import CounterContainer from "./containers/CounterContainer";
import SampleContainer from "./containers/SampleContainer";

function App() {
  return (
    <>
      <CounterContainer/>
    </>
  );
}

export default App;

카운터 구현은 비교적 쉽게 할 수 있다.

 

+1은 takeEvery 함수로 처리하기 때문에 두번 연달아 누르면 2가 올라가지만, -1은 takeLatest 함수로 처리했기 때문에 마지막에 호출된 함수만 처리하여 1만 내려간다.


API 요청 상태 관리

이제 redux-saga를 사용하여 API 요청을 처리해보자. 기존에 thunk로 관리하던 액션 생성 함수를 제거하고 saga를 사용하여 처리하자.

 

우선 로딩에 사용할 모듈을 따로 생성하여 로딩 액션을 디스패치할 수 있게 해주자.

 

◎modules/loading.js

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

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

/*
* 요청을 위한 액션 타입을 payload로 설정한다.
* */

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;

◎modules/index.js

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

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

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

export default rootReducer;

이제, thunk 함수를 위해 createRequestThunk를 만들었던 것처럼 createRequestSaga라는 함수를 만들자.

 

◎ lib/createRequestSaga.js

import {call, put} from 'redux-saga/effects';
import {finishLoading, startLoading} 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 {call, put, takeLatest} from 'redux-saga/effects';
import * as api from '../lib/api';
import {startLoading, finishLoading} from './loading';
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';

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;

 

마찬가지로 컨테이너와 컴포넌트를 이전과 동일하게 작성해주자.

 

◎containers/SampleContainer.js

import {connect} from "react-redux";
import Sample from '../components/Sample';
import {getPost, getUsers} from '../modules/sample';
import {useEffect} from "react";

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, loading}) => ({
        post: sample.post,
        users: sample.users,
        loadingPost: loading['sample/GET_POST'],
        loadingUsers: loading['sample/GET_USERS']
    }),
    {
        getPost,
        getUsers
    }
)(SampleContainer);

 

◎components/Sample.js

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;

 

마지막으로 루트 사가에 sampleSaga를 등록하기만하면 끝이다.

 

◎modules/index.js

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

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

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

export default rootReducer;

 

끝!!

 

설명이 좀 없다고 생각할 수도 있는데, 나도 글을 쓰면서 따라가기 바빴던... 뭔가 복잡한 개념이라 설명 없이 데이터를 한번 프레젠테이션단 부터 따라가며 확실하게 익히는 게 좋을 거 같다.

Comments