프로필

프로필 사진
Popomon
Frontend Developer
(2020/12 ~)

    카테고리

    포스트

    [Frontend/React] 26. redux-saga를 이용한 비동기 액션 처리

    2020. 10. 8. 13:51

    꿈가게: To Do List - iOS

    꿈가게: To Do List - Android

    비동기 액션을 처리하는 라이브러리

    리덕스에서 액션이 처리된 이후에, 비동기 처리를 통해 상태값을 변경시키고 싶은 경우가 있습니다. 대표적인 예로는 서버에서 API를 통해 데이터를 가져오는 경우가 있습니다. 그럴 때는 다음과 같이 크게 3가지 라이브러리가 많이 사용됩니다.

     

    redux-thumk

    비동기 로직을 사용하는 경우에 간단하게 시작하기 좋습니다.

     

    redux-observable

    비동기 코드가 많을 때 사용하기 좋습니다. RxJS 패키지 기반으로 만들어 졌고, RxJS는 리액티브 프로그래밍을 기반으로 만들어졌습니다. 리액티브 프로그래밍은 기존 방식과는 많이 다른 새로운 프로그래밍 패러다임이기 때문에 공부를 많이 해야 합니다. 그래서 진입장벽이 가장 높습니다.

     

    redux-saga

    리덕스 사가는 제너레이터를 기반으로 합니다. 리액티브 프로그래밍을 공부하는 것 보다는 제너레이터를 공부하는 것이 좀더 수월하기도 하고, 제너레이터 자체가 자바스크립트에 내장된 기능이기 때문에 공부를 해 놓으면 나중에 활용도가 높다고 생각합니다. 그리고 리덕스사가를 사용하면 테스트 코드를 작성하기 쉽습니다. 이에 대해서는 나중에 설명을 드리겠습니다.

     


    제너레이터(function*)란?

    리덕스 사가를 사용하기 위해서 필수적으로 알아야 하는 개념인 제너레이터에 대해서 설명드리겠습니다. 아래의 코드를 보면 아래와 같이 f1 함수는 제네레이터 함수입니다. 제네레이터 함수의 특징은 function 바로 뒤에 * 기호가 붙어있습니다.

     

    제너레이터 함수를 호출하면, 제너레이터 객체(gen)가 반환됩니다. 이 제너레이터 객체로 무엇을 할 수 있는지 알아보겠습니다.

     

    function* f1() {
      console.log('f1-1');
      yield 10;
      console.log('f1-2');
      yield 20;
      console.log('f1-3');
      return 'finished';
    }
    const gen = f1();

     

    이 제너레이터 객체에는 next 라는 함수가 있습니다. 이것을 총 3번 호출해보겠습니다. 그러면 yield 라고 표시되어있는 부분을 기준으로 구간이 나눠지고, 한번 호출할 때마다 yield 가 있는 구간까지 실행이 됩니다. 반환 객체는 yield 오른쪽 값이 반환되고, done 이라는 속성에 f1 함수가 전부 완료되었는지 상태여부가 boolean 타입으로 리턴됩니다.

     

    console.log(gen.next());
    /*
      실행구간
      console.log('f1-1');
      yield 10;
    
      반환객체
      { value: 10, done: false }
    */
    
    console.log(gen.next());
    /*
      실행구간
      console.log('f1-2');
      yield 20;
    
      반환객체
      { value: 20, done: false }
    */
    
    console.log(gen.next());
    /*
      실행구간
      console.log('f1-3');
      yield 30;
    
      반환객체
      { value: 'finished', done: true }
    */

     

    제너레이터를 제대로 이해하기 위해서는 반복자(iterator)와 반복 가능(iterable)을 잘 이해해야합니다.

     

    다음 조건을 만족하는 객체는 반복자 입니다.

    1. next 함수를 가지고 있습니다.

    2. next 함수는 value와 done 속성값을 가진 객체를 반환합니다. (이전에 봤던 제너레이터 객체가 반복자 입니다.)

    3. done 속성값은 작업이 끝났을 때 참이 됩니다.

     

    다음 조건을 만족하면 반복 가능한 객체입니다.

    1. Symbol.iterator 속성값으로 함수를 가지고 있다.

    2. 해당 함수를 호출하면 반복자를 반환한다.

     

    제너레이터 함수는 gen[Symbole.iterator]() 이 함수를 실행하면 자기 자신인 gen 을 반환합니다. 제너레이터는 반복자이고 반환하는 값이 자기 자신이기 때문에 반복자라고 할 수 있습니다. 따라서 제너레이터는 반복 가능한 객체입니다.

     

    제너레이터는 조금 특이하게도 반복자이면서 반복 가능한 객체입니다.

     

    반복 가능한 객체라면 자바스크립트의 몇가지 기능을 사용할 수 있는데, 이 기능들은 코딩을 편하게 해줍니다. 아래와 같이 for ... of 문법과 전개연산자를 사용할 수 있습니다.

     

    function* f1() {
      yield 10;
      yield 20;
      yield 30;
    }
    
    for (const v of f1()) {
      console.log(v);
    }
    
    const arr = [...f1()];
    console.log(arr); // [ 10, 20, 30 ]

     

    제너레이터는 또한 표현력이 좋기 때문에 아래와 같이 자연수를 표현할 수도 있습니다. 일반 함수였다면 무한루프를 돌기 때문에 먹통이 되었을 것입니다. 하지만 제너레이터는 값을 하나씩 던져주기 때문에 사용하기가 편리합니다. 이게 가능한 이유는 제너레이터는 실행을 멈출 수 있기 때문입니다.

     

    function* naturalNumbers() {
      let v = 1;
      while (true) {
        yield v++;
      }
    }

     

    이렇게 실행을 멈추고 재개할 수 있다는 특성 때문에 제너레이터는 협업이 가능합니다. 아래의 코드를 보시면 이해하기가 더 쉽습니다.

    우선 한번 next 함수를 호출하면 yield 에서 멈추고 반환될 값을 기다린다고 생각하면 됩니다.

     

    아래의 코드를 보면 우선 robot 함수를 호출할 예정입니다. gen 객체에 user 제너레이터가 생성되고나서 아래의 반복작업을 거칩니다.

     

    초기코드

    1. gen = user() 제너레이터 생성

     

    과정 - 첫번째

    2. robot 로그 출력함수 내부에 next 함수 실행 - (전달인자: '')

    3. user 로그 출력함수 실행 대기 (현재 yield 오른쪽의 변수 값: 'Hi, I am robot!')

    4. robot 로그 출력함수 실행 - (next 함수 반환객체 : {value:'Hi, I am robot!', done: false})

     

    과정 - 첫번째 이후

    5. robot 로그 출력함수 내부에 next 함수 실행 - (전달인자: 'Hi, I am user!')

    6. user 로그 출력함수 실행 - (전달로 인한 변경된 값: 'Hi, I am robot!' --> 'Hi, I am user!')

    7. user 로그 출력함수 실행 대기 (현재 yield 오른쪽의 변수 값: 'Good!')

    8. robot 로그 출력함수 실행 - (next 함수 반환객체 : {value:'Good!', done: false})

     

    function* user() {
      const userMessageList = ['Hi, I am robot!', 'Good!'];
      for (const message of userMessageList) {
        console.log('User: ', yield message);
      }
    }
    
    function robot() {
      const gen = user();
    
      const robotMessageList = ['', 'Hi, I am user!'];
      for (const message of robotMessageList) {
        console.log('Robot: ', gen.next(message).value);
      }
    }

     

    우리가 작성한 함수가 user 부분이고 사가 미들웨어가 robot 부분이라고 생각하시면 될 것 같습니다. 우리가 만든 제너레이터를 사가 미들웨어가 처리하는 형식으로 진행되고 있습니다.

     


    리덕스 사가의 이펙트 함수

    리덕스 사가에서는 아래와 같이 put, call, all 이라는 부수효과 함수를 주로사용합니다. put 이라는 것은 리덕스 액션을 발생시키는 겁니다. 

    call 은 서버의 API 호출 함수가 인수로 들어갑니다.

     

    여기에서는 REQUEST_LIKE 액션이 실행될 때마다 fetchData 아래에 들어있는 로직이 실행이 되는 것입니다. 여기서 takeLeading 의 역할은 우리가 만약 좋아요를 한번 눌렀을때, fetchData 함수가 아직 진행중에 있다면, 그 사이에 들어온 액션은 무시하고 처음에 들어온 액션을 우선으로 처리하도록 관리해줍니다.

     

    반대의 기능을 하는 것은 takeLatest 라는 함수가 있습니다. 이것은 처음에 요청했던 부분은 무시하고 최근에 요청한 액션을 우선적으로 처리합니다. 처리중인 것을 취소시키고 새로 들어온 것을 다시 처리하는 역할을 합니다.

     

    import { all, call, put, takeLeading } from 'redux-saga/effects';
    
    export function* fetchData(action) {
      yield put(actions.setLoading(true));
      yield put(actions.addLike(action.timeline.id, 1));
      yield call(callApiLike);
      yield put(actions.setLoading(false));
    }
    
    export default function* () {
      yield all([
        takeLeading(type.REQUEST_LIKE, fetchData)
        // takeLeading(type.REQUEST_LIKE1, fetchData1)
        // takeLeading(type.REQUEST_LIKE2, fetchData2)
        // takeLeading(type.REQUEST_LIKE3, fetchData3)
      ]);
    }

     

    위에서 사용된 put, call 등의 effect 함수가 어떤 값을 반환하는지 알아보겠습니다. 아래의 반환값들을 보면 effect 함수가 반환하는 값들은 전부 객체입니다. 이처럼 리덕스 사가의 부수 효과 함수는 해야 할 일을 설명하는 자바스크립트 객체를 반환합니다.

     

    take 함수의 반환값

     

    var takeReturn = take(types.REQUEST_LIKE);
    var takeReturnLog = {
      TAKE: {
        pattern: 'timeline/REQUEST_LIKE'
      }
    }

     

    put 함수의 반환값

     

    var putReturn = put(actions.setLoading(false));
    var putReturnLog = {
      PUT: {
        channel: null,
        action: {
          type: 'timeline/SET_LOADING',
          isLoading: false
        }
      }
    }

     

    call 함수의 반환값

     

    var callReturn = call(callApiLike);
    var callReturnLog = {
      CALL: {
        context: null,
        fn: callApiLike,
        args: [],
      }
    }

     

    이렇게 반환된 객체는  yield를 호출했을 때, 사가 미들웨어에 전달이 됩니다. 리덕스의 미들웨어 쪽에서 사가 미들웨어가 돌아가고 있는데, 사가 미들웨어는 부수 효과 객체가 설명하는 일을 한 다음에 그 결과와 함께 실행흐름을 다시 제네레이터(fetchData) 쪽으로 넘겨줍니다.

     

    따라서, 부수효과 함수가 실행 되고, 반환된 객체가 사가 미들웨어로 넘어가고, 사가 미들웨어는 이 정보를 바탕으로 로직을 처리하고, 완료가 되면 다시 제네레이터 함수로 넘어와서 다음줄을 실행시키는 과정을 반복합니다.

     

    이 과정을 반복하면서, 우리가 작성한 함수와 사가 미들웨어가 협업을 하는 것입니다.

     


    리덕스 사가 미들웨어 추가하기

    리덕스 사가의 미들웨어를 추가하는 경우에는 스토어 객체를 수정해야합니다. 우선 코드를 먼저 분석해보겠습니다.

     

    아래에 있는 리듀서를 합치는 방법은 이전에 리듀서를 작성할 때 설명을 드리지 못한 부분입니다. 각각 관심사에 따라서 리듀서를 하나씩 만들 수 있습니다. 그런데 우리가 리듀서를 만들 때, createStore 함수를 사용하여 하나의 리듀서를 등록할 수 있습니다. 그렇기 때문에 리듀서를 한개로 합쳐야 할 필요가 있는데 이 때 리덕스에서 제공하는 함수가 combineReducers 함수입니다. 이 함수는 리듀서별로 구분할 수 있는 이름을 지정하여 객체형태로 아래와같이 작성할 수 있습니다.

     

    import { createStore, combineReducers, compose, applyMiddleware } from 'redux';
    import { createSagaMiddleware } from 'redux-saga';
    import { all } from 'redux-saga/effects';
    
    const reducer = combineReducers({
      timeline: timelineReducer,
      friend: friendReducer,
    })

     

    window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ 라는 부분은 리덕스 확장 프로그램을 사용하는 경우에 필요한 함수입니다.  sagaMiddleware는 리덕스 사가를 사용하고 싶은 경우에 createSagaMiddleware 함수를 통해 미들웨어를 생성할 수 있습니다.

     

    import { createStore, combineReducers, compose, applyMiddleware } from 'redux';
    import { createSagaMiddleware } from 'redux-saga';
    import { all } from 'redux-saga/effects';
    
    const reducer = combineReducers({
      timeline: timelineReducer,
      friend: friendReducer,
    })
    
    const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
    const sagaMiddleware = createSagaMiddleware();

     

    이제 스토어를 생성합니다. 첫번째 인자는 리듀서를 병합한 reducer 객체를 입력해 줍니다. 그리고 두번째 인자로는 미들웨어를 등록하는 composeEnhancers 함수를 입력하고 applyMiddleware 함수에 미들웨어 객체를 넣어 반환된 값들을 넣어줍니다.

     

     

    import { createStore, combineReducers, compose, applyMiddleware } from 'redux';
    import { createSagaMiddleware } from 'redux-saga';
    import { all } from 'redux-saga/effects';
    
    const reducer = combineReducers({
      timeline: timelineReducer,
      friend: friendReducer,
    })
    
    const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
    const sagaMiddleware = createSagaMiddleware();
    
    const store = createStore(
      reducer,
      composeEnhancers(
        applyMiddleware(sagaMiddleware)
      )
    )

     

    여기서 미들웨어를 여러개 추가하기를 원한다면 아래와 같은 방법으로 등록하실 수 있습니다.

     

    const store = createStore(
      reducer,
      composeEnhancers(
        applyMiddleware(sagaMiddleware1),
        applyMiddleware(sagaMiddleware2),
        applyMiddleware(sagaMiddleware3)
      )
    )

     

    마지막으로 리덕스 사가 미들웨어를 실행하는 코드입니다. 우선 all 함수를 통해서 모든 사가를 추가시켜준 다음 사가 미들웨어를 실행해 주시면 됩니다.

     

    import { createStore, combineReducers, compose, applyMiddleware } from 'redux';
    import { createSagaMiddleware } from 'redux-saga';
    import { all } from 'redux-saga/effects';
    
    const reducer = combineReducers({
      timeline: timelineReducer,
      friend: friendReducer,
    })
    
    const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
    const sagaMiddleware = createSagaMiddleware();
    
    const store = createStore(
      reducer,
      composeEnhancers(
        applyMiddleware(sagaMiddleware)
      )
    )
    
    function* rootSaga() {
      yield all([
        timelineSaga(),
        friendSaga()
      ]);
    }
    sagaMiddleware.run(rootSage);
    
    export default store;

     


    두가지 이상의 액션을 처리하기

    가장 많이 쓰이는 기능인 로그인 로그아웃 기능으로 예시를 들어보겠습니다. 아래와 같이 로그인 액션함수가 발생하면 로그인 액션을 기다리던 take 함수가 동작하고 그 로그인 입력정보를 가져다가 call 함수로 로그인 API를 호출합니다. 로그인이 성공하면 사용자 정보를 가져온 다음, 그 정보를 상태값으로 저장합니다.

     

    로그아웃도 동일합니다. 로그아웃 액션을 기다리던 함수가 호출되면 로그아웃 API를 호출하고, 성공하면 로그인 정보를 비웁니다.

     

    function* loginFlow() {
      while(true) {
        // [로그인]
        // 로그인 액션을 기다림
        // 로그인 API 호출
        // 로그인 정보를 저장
        const { id, password } = yield take(types.LOGIN);
        const userInfo = yield call(callApiLogin, id, password);
        yield put(types.SET_USER_INFO, userInfo);
        
        // [로그아웃]
        // 로그아웃 액션을 기다림
        // 로그아웃 API 호출
        // 로그인 정보를 제거
        yield take(types.LOGOUT);
        yield call(callApiLogout, id);
        yield put(types.SET_USER_INFO, null);
      }
    }

     


    리덕스 사가 예외처리

    리덕스 사가에서의 예외처리하는 방법을 알아보겠습니다. Java 언어와 마찬가지로 try catch 문을 사용하여 에러를 처리합니다. 에러는 상태값으로 객체를 저장하고, 에러 객체의 존재 여부에 따라서 에러 메시지를 띄우는 형태로 컴포넌트를 작성하시면 됩니다. 그리고나서 이전에 했던 상태값으로 되돌리는 작업을 catch 블록 안에 작성해주시면 될 것 같습니다.

     

    import { all, call, put, takeLeading } from 'redux-saga/effects';
    
    export function* fetchData(action) {
      yield put(actions.setLoading(true));
      yield put(actions.addLike(action.timeline.id, 1));
      
      // 에러처리
      try {
        yield call(callApiLike);
      } catch (error) {
        // 에러객체 저장
        // Like 원래상태로 되돌림
        yield put(actions.setValue('error', error));
        yield put(actions.addLike(action.timeline.id, 1));
      }
      
      yield put(actions.setLoading(false));
    }
    
    export default function* () {
      yield all([
        takeLeading(type.REQUEST_LIKE, fetchData)
      ]);
    }

     


    이벤트가 동시에 여러번 발생하는 경우에 디바운스 사용하기

    디바운스는 같은 함수가 여러번 연속해서 호출 될 때, 첫번째 또는 마지막 호출만 실행하는 기능입니다. 리덕스 사가에서 제공하는 디바운스 함수를 사용해 보겠습다.

     

    리덕사 스가의 디바운스 함수는 일정 시간 내에 제너레이터 함수가 여러번 호출 되더라도 한번만 실행하도록 도와줍니다. 아래의 코드와 같이 정의를 하면, REQUEST_LIKE 액션이 발생했을 때 바로 함수를 호출하지 않고 1초를 기다렸다가 더이상 액션이 발생하지 않으면 fetchData 함수를 실행하는 것입니다.

     

    import { all, call, put, takeLeading } from 'redux-saga/effects';
    
    export function* fetchData(action) {
      yield put(actions.setLoading(true));
      yield put(actions.addLike(action.timeline.id, 1));
      try {
        yield call(callApiLike);
      } catch (error) {
        yield put(actions.setValue('error', error));
        yield put(actions.addLike(action.timeline.id, 1));
      }
      yield put(actions.setLoading(false));
    }
    
    export default function* () {
      yield all([
        debounce(1000, type.REQUEST_LIKE, fetchData)
      ]);
    }

     


    테스트 코드 작성하기

    리덕스 사가를 사용하면 테스트 코드를 작성하기 쉽다고 했는데, 진짜 그런지 한번 테스트코드를 작성해 보겠습니다. 제너레이터의 복사를 위해서 @redux-saga/tessting-utils 패키지를 설치를 했습니다.

     

    우선은 필요한 액션 객체와 제너레이터 객체를 만들었습니다. 그 다음 제너레이터에서 yield 오른쪽에 있는 것이 gen.next().value 가 되기 때문에, 제너레이터 부수효과 함수를 직접 호출한 값과 next 를 통해서 반환된 값이 같은지를 비교하는 것입니다. 바로 이게 사가에서 테스트코드를 작성하기 쉬운 이유입니다.

     

    사가에서는 api를 직접 호출해야하는 것이 아니라 API를 호출 하면 나오는 call 함수의 리턴 객체만 검사하면 되기 때문에 테스트 코드를 작성하기가 상당히 편하고 직관적입니다.

     

    그 이후에 발생하는 로직은 예외 발생 여부에 따라서 달라집니다. 그래서 각각 clone 함수를 사용해서 테스트합니다. 이 함수는 직전까지의 상태를 복사해줍니다.

     

    실패 테스트는 예외를 발생시킥 위해서 next 가 아니라 throw 함수를 호출하였습니다. 그리고 예외처리의 catch 문과 동일하게 setValue 액션과 addLike 액션을 순서대로 검사를 합니다. 그리고 마지막에 setLoading 액션을 확인합니다.

     

    성공 테스트는 실패 테스트에서 catch 블록 안의 부분이 제외된 것이기 때문에 setLoading 액션만 테스트하면 됩니다.

     

    import { put, call } from 'redux-saga/effects';
    import { cloneableGenerator } from '@redux-saga/tessting-utils';
    
    describe('fetchData', () => {
      // 필요한 객체
      const timeline = { id: 1 };
      const action = actions.requestLike(timeline);
      const gen = cloneableGenerator(fetchData)(action);
      
      // 제너레이터
      expect(gen.next().value).toEqual(put(actions.setLoading(true)));
      expect(gen.next().value).toEqual(put(actions.addLike(timeline.id, 1)));
      expect(gen.next().value).toEqual(put(actions.setValue('error', '')));
      expect(gen.next().value).toEqual(call(callApiLike));
      
      // 실패
      it('on fail callApiLike', () => {
        const gen2 = gen.clone();
        const errorMsg = 'error';
        expect(gen2.throw(errorMsg).value).toEqual(put(actions.setValue('error', errorMsg)));
        expect(gen2.next().value).toEqual(put(actions.addLike(timeline.id, -1)));
        expect(gen2.next(Promise.resolve()).value).toEqual(
          put(actions.setLoading(false)),
        );
      })
    
      // 성공
      it('on succes callApiLike', () => {
        const gen2 = gen.clone();
        expect(gen2.next(Promise.resolve()).value).toEqual(
          put(actions.setLoading(false)),
        );
      })
    })

     

     

     

     


    출처 : 실전 리액트 프로그리맹 - 인프런 (추천합니다 !!)

     

    실전 리액트 프로그래밍 - 인프런

    [실전 리액트 프로그래밍] 책의 저자 직강! 리액트의 기초부터 실전 활용법까지 익힐 수 있습니다. 초급 프레임워크 및 라이브러리 웹 개발 Front-End React Redux 웹 개발 온라인 강의 리액트(React)를 �

    www.inflearn.com