Redux-Saga: 제너레이터와 이펙트


지난 글에서 Redux-Saga와 사이드 이펙트를 가지고 공부 겸 정리 겸 글을 작성했다. 이번 글에선 Redux-Saga에서 사용하는 제너레이터함수, 이펙트에 대해 정리해보려 한다. 제너레이터함수는 협력적이고, run-to-completion이 아니며, 그렇다고 비동기도 아닌 그런 함수다. 그리고 이펙트는 Redux-Saga의 가장 중심이 되는 특징이다. 마치 Redux의 Action처럼 단순한, 일반적인 객체인데 개발자에겐 어마어마한 마법을 보여준다. 그리고 대부분의 서비스 로직을 이 이펙트로 작성한다. 이 두 가지를 이해한다면 이제 Redux-Saga는 뭔지 모르겠는데 엄청 대단해 보이는 것에서 엄청 대단한 건데 난 이해함ㅋ이 될거라 생각한다.

제너레이터

Redux-Saga는 제너레이터를 아름답게 사용한다. Redux-Saga의 Saga가 바로 제너레이터함수다. Redux-Saga의 설명을 이어나가기 위해 제너레이터에 대해 간단히 알아보는 게 좋겠다.

개인적으로 명칭에 (아주 조금) 집착한다. 많은 사람이 제너레이터제너레이터함수를 구분하지 않거나, 이를 제너레이터이터레이터로 잘못 구분하는데, 이를 먼저 정확히 짚고 넘어가고 싶다. 그리고 이 글에서는 시각적 구분을 위해 제너레이터함수 단어에서 함수를 띄어 쓰지 않고 붙여 쓰겠다.

우선 요약하면 제너레이터는 제너레이터함수의 반환이다.

function* myGeneratorFunction() {
  yield 1;
  yield 2;
  yield 3;
}

const generator = myGeneratorFunction();

console.log(generator.next().value); // 1
console.log(generator.next().value); // 2
console.log(generator.next().value); // 3

우리가 function* 키워드로 작성하는 함수는 제너레이터가 아닌 제너레이터함수다. 그리고 이 제너레이터함수를 호출하면 반환되는 객체가 바로 제너레이터다. 제너레이터는 이터레이터(Iterator) 프로토콜과 이터러블(Iterable) 프로토콜을 따른다.

이터러블 프로토콜은 단순히 obj[Symbol.iterator]: Function => Iterator로 표현할 수 있다. 객체는 이터레이터 심볼 키값에 이터레이터를 반환하는 메서드를 가지고 있다면 이터러블이다.

이터레이터 프로토콜도 단순하다. 객체가 next라는 메서드를 가지고 있고, 그 결과로 IteratorResult 라는 객체를 반환하면 된다. 반환되는 IteratorResult는 {done: boolean, value: any} 형태의 단순한 객체다.

이러한 이터러블, 이터레이터에 대한 정확한 정의는 ECMAScript의 명세 - 25장에 있으니 괜찮다면 간단히 살펴봐도 좋다. (참고로, Redux-Saga에서 많이 사용하는 takeEvery, takeLatest 등의 helper는 제너레이터를 사용하지 않고 이터레이터 객체를 직접 만들어 사용한다.)

/* 제너레이터는 이터레이터 프로토콜을 따른다. */

// 1. "function"
typeof generator.next;

// 2. {done: boolean, value: any} 반환
generator.next();

//-----//

/* 제너레이터는 이터러블 프로토콜을 따른다. */

// 1. "function"
typeof generator[Symbol.iterator];

// 2. 이터레이터가 반환된다.
const iterator = generator[Symbol.iterator]();
typeof iterator.next(); // {done: boolean, value: any}

한 가지 재밌는 점은, 제너레이터는 이터러블이면서 이터레이터라는 것인데, 이터러블에서 반환하는 이터레이터가 바로 자기 자신이다.

/* 
  제너레이터의 이터러블 구현은 아래처럼 정말 간단할 것이다.
  generator[Symbol.iterator] = () => this;
*/
generator === generator[Symbol.iterator](); // true

제너레이터함수 - Caller와 Callee

  • 제너레이터함수는 Callee, 이를 호출하는 함수는 Caller다.
  • Caller는 Callee가 반환한 제너레이터를 가지고 로직을 수행한다.
  • Caller는 Callee의 yield 지점에서 다음 진행 여부/시점을 제어한다.

Caller는 Callee를 호출하는 책임뿐 아니라 Callee 내부 로직 수행에 대한 제어권을 갖는다(더 진행하지 않거나, 에러를 발생시킬 수도 있다). 흔히 Caller를 Runner라는 이름으로 부르기도 하는데, 이전에 우리 위클리에서 작성했던 "Generator in Practice - [1부] 기본 속성과 Runner"를 한번 읽어보자. (제너레이터 자체에도 관심이 있다면 "ES6의 제너레이터를 사용한 비동기 프로그래밍" 글도 읽어보길 추천한다.)

Redux-Saga 입장으로 보면 미들웨어는 Caller이고, 우리가 작성한 Saga는 Callee다.

Redux-Saga와 제너레이터

지금까지 제너레이터함수, Caller(=Runner)와 Callee에 대해 간단히 알아보았다. 그리고 Redux-Saga에서 말하는 Saga는 바로 제너레이터함수다. 그럼 왜 Saga를 제너레이터함수로 구현할까? 이는 곧 Redux-Saga가 이펙트라 부르는 것들을 어떻게 만들고 사용하는지와 연관된다. 우리가 Redux-Saga를 사용한다는 것은 곧 Redux-Saga 미들웨어에 우리의 Saga를 등록하고 수행시킨다는 뜻이다. 미들웨어는 Saga를 끊임없이 동작시킨다.

// Saga의 초기화, 시작 코드에는 항상 "run"이 있다.
middleware.run(RootSaga);

Saga는 제너레이터함수이고, 미들웨어는 Saga에게 yield 값을 받아서 또 다른 어떤 동작을 수행할 수 있다. Saga는 명령을 내리는 역할만 하고, 실제 어떤 직접적인 동작은 미들웨어가 처리할 수 있다는 뜻이다. redux-thunk와의 가장 큰 차이점이다.

비교를 위해 간단한 redux-thunk 비동기 함수를 생각해보자.

function asyncIncrement() {
  return async dispatch => {
    await delay(1000);
    dispatch({ type: "INCREMENT" });
  };
}

위 함수는 스스로 비동기적인 처리를 직접 수행한다. 저 함수에 대한 테스트가 필요하다면, 1초를 기다리고 dispatch 하는 것을 어떻게 증명할지 생각해보자. 딱히 마음에 드는 방법은 떠오르지 않는다. 문제는 함수 내부에 비동기적인 로직이 그대로 녹아있다는 것이다.

Saga에서 다음과 같이 표현할 수 있다.

function* asyncIncrement() {
  // Saga는 아래와 같이 간단한 형태의 명령만 yield 한다.
  yield call(delay, 1000); // {CALL: {fn: delay, args: [1000]}}
  yield put({ type: "INCREMENT" }); //  {PUT: {type: 'INCREMENT'}}
}

call이든 put이든 모두 직접적인 처리를 하지 않는다(call, put은 이펙트 생성자(Effect creator)라 부른다). 명령을 만들어주기만 하고, 이 명령에 따른 직접적인 처리는 모두 미들웨어가 한다. 그래서 이런 Saga는 테스트도 정말 간단하다.

// TestCase
// 실제로 Delay 시키는게 아니라 이에 대한 명령뿐이므로 테스트에서 1초씩 기다릴 필요가 없다.
// 단지 어떤 명령이 내려지는지만 확인하면 된다.

const gen = asyncIncrement();
expect(gen.next().value).toEqual(call(delay, 1000));
expect(gen.next().value).toEqual(put({ type: "INCREMENT" }));

그리고 Saga에서 비동기 처리가 아무리 복잡해도 대부분은 if, else, for와 같은 간단한 코드만으로 구현할 수 있다. 스코프가 복잡해지는 것도 아니다. Redux-Saga는 이런 이점을 위해 제너레이터함수를 Saga로 사용한다.

이펙트

이펙트는 미들웨어에 의해 수행되는 명령을 담고 있는 자바스크립트 객체라고 생각하면 된다. 앞서 잠깐 살펴본 call이나 put 모두 이펙트 생성자고, 생성된 이펙트는 모두 일반 자바스크립트 객체일 뿐이다. 이펙트 생성자는 항상 일반 객체를 만들기만 하고, 어느 다른 동작도 수행하지 않는다. Saga는 명령을 담고 있는, 이펙트라 부르는 순수한 객체를 yield 할 것이고, 미들웨어는 이런 명령들을 해석해 처리하고, 그 결과를 다시 Saga에 돌려준다. 예를 들어 call(fn, arg1, arg2) 이펙트를 Saga에서 yield 했다면, 미들웨어는 fn(arg1, arg2);으로 수행하고 그 결과를 다시 Saga에 전달한다.

saga-middleware-flow

물론 Saga는 반드시 이펙트만을 yield 해야 하는 것은 아니다. 일반적인 Promise도 yield 할 수 있고, 미들웨어는 이 역시도 훌륭히 resolve나 reject를 기다려줄 것이다. 하지만 이런 비동기 로직을 Saga 내부에서 직접 처리하면 테스트, 여러 다른 이펙트들과의 상호작용이 어렵다. thunk에서 크게 달라지는 점이 없다. 때문에 되도록 이펙트만을 yield 하는 Saga를 작성하길 추천한다.

Saga의 이펙트는 10가지 이상으로 우리가 활용하는 데 큰 문제가 없을 만큼 다양하다. 비록 이 글에서 이펙트들을 하나하나 설명하진 못했지만, Effect creators API를 참고한다면 Saga를 적극적으로 활용하는 데 많은 도움이 될 것이다. 이펙트는 단순히 테스트만을 위해서 만든 것이 아니다. 이런 이펙트들은 pulling이나, non-blocking, blocking, parallel 등의 다양한 특징들을 가지고 있고, 이런 특징들을 이용해서 정말 수많은 동작들을 손쉽게 처리할 수 있다. 공식 문서의 Advanced Concepts에 그 내용들이 잘 나타나 있다.

그리고 최근 1.0 beta 릴리스에 이펙트 미들웨어가 추가됐다. 이는 기본적으로 제공해주는 이펙트가 아닌 커스텀한 이펙트를 만들어 활용할 수도 있다는 뜻이다. 이펙트를 직접 만들어 사용하는 것은 어렵지 않다. 지금까지 우리는 Redux의 액션을 마음껏 사용해왔으니, 이런 일반 객체를 다루는 것에 익숙해져 있을 것이다.

마치며

Redux-Saga는 비교적 생소한 제너레이터 기반의 미들웨어로 진입 장벽이 높게 느껴지는 것은 맞다. 적어도 redux-thunk에 비교해선 훨씬 높다. 하지만 제너레이터를 이해하고, Redux-Saga를 조금 더 친근하게 바라본다면 이런 활용성에 이 정도로 쉽게 사용할 수 있는 라이브러리도 없지 않을까 싶다.

이펙트 외에도 Redux-Saga를 배우거나 활용하는 내용은 정말 많이 남아있다. 채널도 그중 하나인데, 채널의 경우 정말 활용성이 높기도 하고, 이번 1.0 beta 릴리스에서 Redux-Saga 자체적으로도 개념을 따로 정리하기도 했다.

아직 redux-thunk에서 여러 복잡한 처리들로 고생한다면, 이젠 정말 Redux-Saga를 활용해보는 것도 적극적으로 추천한다.


이민규, FE Development Lab2018.01.19Back to list