ES6 제너레이터의 숨겨진 힘: Observable Async 흐름 제어

  • 이 글은 The Hidden Power of ES6 Generators: Observable Async Flow Control를 번역한 글입니다.
  • 아직 입문자이다보니 오역을 한 경우가 있을 수 있습니다. 양해 부탁드립니다.
  • 매끄러운 문맥을 위하여 의역을 한 경우가 있습니다. 원문의 뜻을 최대한 해치지 않도록 노력했으니 안심하셔도 됩니다.
  • 영어 단어가 자연스러운 경우 원문 그대로의 영단어를 적었습니다.
  • 저의 보충 설명은 인용문에 달았습니다.

피보나치 제너레이터를 자바스크립트로 작성하며 깨달은 놀라운 점 7가지에서는 ES6 제너레이터 함수의 확실한 사용례를 다뤘습니다. 한번에 하나씩 반복할 수 있는(Iterable) 값들로 이루어진 수열을 만드는 것이었는데요. 아직 그 글을 읽지 않으셨다면, 반드시 읽으셔야 합니다! 반복 가능한 객체는 ES6+를 구성하는 다양한 기능들의 바탕을 이루고 있기 때문에, 어떻게 작동하는지를 꼭 이해하고 있어야 합니다.

하지만 그 글에서는 일부러, 제너레이터의 가장 주요한 사용 사례를 다루지 않았습니다. 감히 말하건데, 그것은 바로 비동기 흐름 제어입니다.

Async / Await

아직 자바스크립트 공식 표준 기능으로 채택되지는 않았지만, async/await에 대하여 들어보셨을 겁니다. ES6에서도 못했고, ES2016에서도 못할 것이고, ES2017에서는 될 지도 모릅니다. 그리고 우리 일반 사용자들은 실질적으로 사용할 수 있도록 되기 전에, 모든 자바스크립트 엔진들이 구현하기를 기다려야만 하죠. (참고: 지금은 Babel에서 사용 가능하지만, 계속 장담할 수는 없습니다. 꼬리 호출 최적화같은 경우 몇 개월 전만 하더라도 Babel에서 사용 가능했었지만 결국엔 제거되었습니다.)

원문이 2016년 5월 21일에 작성되었으므로, 원문 작성 당시 최신 자바스크립트 버전은 ES6이었겠군요. 표준 자바스크립트(ES)의 최신 개정은 전통적으로 매년 6월에 이루어집니다.

이런 기다림에도 불구하고, async/await에 대하여 다루는 글은 현재 상당히 많습니다. 왜일까요?

아래와 같은 코드를:

const fetchSomething = () => new Promise((resolve) => {
  setTimeout(() => resolve('future value'), 500);
});

const promiseFunc = () => new Promise((resolve) => {
  fetchSomething().then(result => {
    resolve(result + ' 2');
  });
});

promiseFunc().then(res => console.log(res));

아래와 같이 바꿔주기 때문입니다:

const fetchSomething = () => new Promise((resolve) => {
  setTimeout(() => resolve('future value'), 500);
});

async function asyncFunction() {
  const result = await fetchSomething(); // 프라미스를 반환

  // 프라미스를 기다렸다가, 프라미스의 해결된 결과를 사용
  return result + ' 2';
}

asyncFunction().then(result => console.log(result));

첫번째 코드에서 프라미스 기반의 함수가 중첩을 한 단계 더 만들었다는 점에 주목하시기 바랍니다. async/await 버전은 일반적인 동기 코드처럼 보이지만, 사실은 그렇지 않습니다. 프라미스를 산출(yield)하고 함수를 잠시 멈추어서 자바스크립트 엔진이 마음 놓고 다른 작업을 할 수 있도록 놓아줍니다. 그리고 fetchSomething()이 산출한 프라미스가 값을 정상적으로 가져왔다면(resolve의 실행), 아까 멈췄던 함수가 다시 동작을 재개하고, 프라미스의 해결된 결과 값은 result 변수에 할당됩니다.

마치 동기적으로 보이는 비동기적인 코드입니다. 매일 샐 수 없이 많은 비동기 프로그래밍을 하는 자바스크립트 프로그래머에게 이것은 그야말로 성배 그 자체입니다. 인지적인 과부하가 전혀 없이 비동기 코드의 성능상 이점을 모두 취할 수 있기 때문입니다.

원문의 yield산출로 번역하였습니다. 또한 원문에서 resolve해결로 번역하였습니다.

여기서 좀 더 알아보고자 하는 것은 바로 async/await가 배후에서 어떻게 제너레이터를 활용하는지... 그리고, async/await가 당장 없는 지금, 동기적인 스타일로 흐름 관리를 할 때에 제너레이터를 어떻게 활용할 수 있는지에 대한 것입니다.

제너레이터 복습하기

제너레이터 함수는 ES6의 새로운 기능으로, 이 함수는 계속 반복될 수 있는 객체를 반환함으로써 시간이 지남에 따라 계속해서 여러 값들을 생성해낼 수 있습니다. 여기서 반환되는 객체는 반복 가능한 객체로, 이 객체는 .next() 메서드를 호출하면 아래와 같은 객체를 반환합니다:

{
  value: Any,
  done: Boolean
}
  • 제너레이터 함수 호출 =반환=> 반복자 객체 =.next()호출=> { value, done }
  • 한 반복자에 대하여 반복적으로 .next() 호출 가능

done 속성은 제너레이터가 마지막 값을 산출해냈는지 여부를 알려줍니다.

반복자 프로토콜은 자바스크립트의 다양한 것들에서 사용되고 있는데, 그 중에는 for...of 반복문, 배열의 ... 연산자 등이 포함됩니다.

function* foo() {
  yield 'a';
  yield 'b';
  yield 'c';
}

for (const val of foo()) {
  console.log(val);
}
// a
// b
// c

const [...values] = foo();
console.log(values); // ['a','b','c']

다시 제너레이터의 이야기로 돌아가서

이제부터 재미있어집니다. 제너레이터와의 통신은 양방향으로 이루어집니다. 제너레이터로부터 값을 받는 것뿐 아니라, 제너레이터 함수에 값을 전달할 수도 있습니다. 제너레이터에 값을 전달하려면, 반복자의 .next() 메서드의 인자를 사용합니다.

function* crossBridge() {
  const reply = yield 'What is your favorite color?';
  console.log(reply);
  if (reply !== 'yellow') return 'Wrong!'
  return 'You may pass.';
}

{
  const iter = crossBridge();
  const q = iter.next().value; // 반복자가 질문을 산출한다
  console.log(q);
  const a = iter.next('blue').value; // 답변을 제너레이터로 다시 전달한다
  console.log(a);
}

// What is your favorite color?
// blue
// Wrong!


{
  const iter = crossBridge();
  const q = iter.next().value;
  console.log(q);
  const a = iter.next('yellow').value;
  console.log(a);
}

// What is your favorite color?
// yellow
// You may pass.

question.jpg

몬티 파이튼의 성배 中 죽음의 다리

제너레이터와 통신하는 방법은 몇 가지 더 존재합니다. 오류를 던지는 것도 하나의 방법입니다. .next()를 호출하는 대신, 예를 들어 iterator.throw(error)를 호출하면 제너레이터에서 사용할 데이터를 가져오는 데에 무언가 문제가 있었음을 알릴 수 있습니다. 또한, iterator.return()을 호출하여 제너레이터가 강제로 결과를 반환하도록 만들 수도 있습니다.

두 방법 모두 흐름 제어 코드에 오류 처리 기능을 적용하는 간편한 방법입니다.

제너레이터 + 프라미스 = 성배

그렇다면 이런 함수는 어떤가요? 새로운 프라미스가 생성되면 이를 감지하고, 이 프라미스가 값을 정상적으로 가져올 때까지 대기한 뒤, 해결된 값을 제너레이터로 전해주면서 .next()를 호출하는 함수 말이죠. 이 함수는 제너레이터를 래핑하는 형태가 될 것입니다.

이런 함수를 사용하면, 아래와 같은 async/await 스타일의 코드를 작성할 수 있게 됩니다:

const fetchSomething = () => new Promise((resolve) => {
  setTimeout(() => resolve('future value'), 500);
});

const asyncFunc = gensync(function* () {
  const result = yield fetchSomething(); // 프라미스를 반환

  // 프라미스를 기다렸다가, 프라미스의 해결된 결과를 사용
  yield result + ' 2';
});

// asyncFunc를 호출하면서 인자를 전달한다.
asyncFunc('param1', 'param2', 'param3')
  .then(val => console.log(val));

보아하니, 이런 기능을 수행하는 Co.js라는 라이브러리가 이미 존재합니다. 하지만 Co의 사용법을 가르쳐드리는 대신, Co와 같은 것을 직접 만들어보도록 합시다. 위의 crossBridge() 예제를 잘 살펴보면, 그렇게 어렵지 않아 보입니다.

심플한 isPromise() 함수로부터 시작하도록 하죠.

const isPromise = obj => Boolean(obj) && typeof obj.then === 'function';

다음으로, .next()를 호출하여 제너레이터 내부를 순회하고, 프라미스가 해결된 값을 가져오기를 기다린 뒤 다시 .next()를 호출할 수 있는 수단이 필요하겠군요. 아래의 코드는 이러한 동작을 수행하고 있습니다. 다만, 오류 처리 과정이 생략된 단순 개념 설명 용도의 코드입니다. 그러므로 실제 개발에서는 사용하지 마세요. 오류가 묻혀버리면, 디버깅하는 것이 굉장히 골치아파질 겁니다:

const next = (iter, callback, prev = undefined) => {
  const item = iter.next(prev);
  const value = item.value;

  if (item.done) return callback(prev);

  if (isPromise(value)) {
    value.then(val => {
      setImmediate(() => next(iter, callback, val));
    });
  } else {
    setImmediate(() => next(iter, callback, value));
  }
};

보시는 바와 같이. 최종 결과를 반환하기 위하여 콜백을 전달하고 있습니다. 함수의 최상단 라인을 보면, 바로 직전의 값을 .next() 호출시 인자로 전달하는 것으로 제너레이터와 통신하고 있습니다. 이렇게 하면 직전에 yield로 전달받은 결과를 다시 제너레이터로 건네줄 수 있죠.

gensync 함수가 래핑한 제너레이터 내부에서 fetchSomething의 결과가 yield를 통하여 next 함수에 전달됩니다. 이 값은 iter.next(prev).value임을 알 수 있지요. 이 값은 프라미스이므로, .then()을 통하여 해당 프라미스의 결과가 next가 재귀 호출될 때에 3번째 인자, val로 전달됩니다. 그러면 다시 next가 실행될 때, iter.next(prev)를 통하여 제너레이터로 전달되고, 제너레이터에서는 그 다음 yield 실행에 따라 2가 덧붙여진 결과를 next에 돌려주게 되겠지요.

const next = (iter, callback, prev = undefined) => {
  // 2. 산출된 값은 `.next()`의 호출을 통하여 추출된다.
  // 직전의 값을 제너레이터에 전달하게 되고,
  // 이 값은 제너레이터 내부에서 `result` 변수의 값 할당에 사용된다.

  const item = iter.next(prev);
  const value = item.value;

  // 4. 최종 결과값은 콜백으로 전달된다.
  if (item.done) return callback(prev);

  if (isPromise(value)) {
    value.then(val => {
      setImmediate(() => next(iter, callback, val));
    });
  } else {
    setImmediate(() => next(iter, callback, value));
  }
};

const asyncFunc = gensync(function* () {
  // 1. 산출된 값이 반복자로 전달된다.
  // yield를 만났을 때에 제너레이터 함수는 일시적으로 종료되고,
  // `result` 변수에 값을 할당하는 작업은
  // 제너레이터가 다시 재개되기 전까지 이루어지지 않는다.
  const result = yield fetchSomething();

  // 3. `.next()`가 호출되기 전까지는 다시 작동하지 않는다.
  // `result`는 바로 직전 `.next()` 호출시 전달된 값을 포함하게 된다.
  yield result + ' 2';
});

물론, 직접 함수를 실행시키기 전까지는 아무 일도 일어나지 않습니다. 그리고, 실제 최종값을 반환하는 프라미스는 어디 있을까요?

// 프라미스를 반환하고, 최초 `next()` 호출을 통하여
// 모든 작업을 시동한다.
const gensync = (fn) =>
    (...args) => new Promise(resolve => {
  next(fn(...args), val => resolve(val));
});

gensync의 인자로 전달된 fn이 제너레이터 함수입니다. 커링(currying)이 이루어져 있음에 주의하세요.

자, 이제 전부 모아봅시다. 실제 사용하는 코드를 제외하면 22줄 남짓의 코드로군요.

const isPromise = obj => Boolean(obj) && typeof obj.then === 'function';

const next = (iter, callback, prev = undefined) => {
  const item = iter.next(prev);
  const value = item.value;

  if (item.done) return callback(prev);

  if (isPromise(value)) {
    value.then(val => {
      setImmediate(() => next(iter, callback, val));
    });
  } else {
    setImmediate(() => next(iter, callback, value));
  }
};

const gensync = (fn) =>
    (...args) => new Promise(resolve => {
  next(fn(...args), val => resolve(val));
});



/* How to use gensync() */

const fetchSomething = () => new Promise((resolve) => {
  setTimeout(() => resolve('future value'), 500);
});

const asyncFunc = gensync(function* () {
  const result = yield fetchSomething(); // 프라미스를 반환

  // 프라미스를 기다렸다가, 프라미스의 해결된 결과를 사용
  yield result + ' 2';
});

// asyncFunc를 호출하면서 인자를 전달한다.
asyncFunc('param1', 'param2', 'param3')
  .then(val => console.log(val)); // 'future value 2'

이 기법을 실제 코드에 적용하고 싶다면, 당연히 Co.js를 대신 사용해야 합니다. 이 라이브러리에는 오류 처리 로직이 적용되어 있고(위의 예시에서는 코드가 길어지는 것을 방지하고자 제외했습니다), 배포 수준의 테스트가 완료되었으며, 그 외에 좋은 기능들이 포함되어있습니다.

프라미스에서 옵저버블로

위의 예시는 아주 흥미롭고, Co.js는 분명 비동기 흐름 제어를 단순화해주는 좋은 라이브러리입니다. 다만, 한가지 문제가 있습니다. 반환값이 프라미스라는 것입니다. 다들 아시다시피, 프라미스는 하나의 반환값 또는 거절(rejection)만을 만들어낼 수 있습니다.

제너레이터는 시간이 지남에 따라 많은 값들을 만들어낼 수 있습니다. 시간이 지남에 따라 많은 값들을 만들어낼 수 있는 것, 우리가 알고 있는 다른 게 또 있나요? 바로 옵저버블(Observable)입니다. 피보나치 제너레이터를 자바스크립트로 작성하며 깨달은 놀라운 점 7가지를 다시 떠올려보세요:

처음에는 제너레이터에 대한 것이 굉장히 마음에 들었지만, 조금 사용해보고 나니, 실제 제 어플리케이션 코드 상에서 제너레이터를 잘 활용한 사용례(use-case)를 찾기 힘들었습니다. 제너레이터가 필요한 대부분의 경우라면 저는 오히려 RxJS를 사용했습니다. API가 훨씬 풍부하기 때문이죠.

프라미스는 (제너레이터 함수와 달리) 단 하나의 값만 만들어낼 수 있고, 옵저버블은 (제너레이터 함수처럼) 계속해서 많은 값들을 만들어낼 수 있기 때문에, async 함수를 다루는 데에는 옵저버블의 API가 프라미스보다 더 적합하다는 것이 개인적인 생각입니다.

옵저버블이란 무엇인가요?

Singular Plural
Spatial Value Iterable<Value>
Temporal Promise<Value> Observable<Value>

위의 표는 Kris Kowal이 작성한 글 GTOR: A General Theory of Reactivity에서 가져왔습니다. 대상에 대하여 시간과 공간을 기준으로 분해합니다. 동기적으로 가져올 수 있는 값은 공간을 소비하지만(메모리 상의 값), 시간으로부터 분리되어있습니다. 이러한 값이 Pull API 입니다.

미래의 특정 이벤트에 의존하는 값은 동기적으로 사용될 수 없습니다. 사용할 수 있게 되기 전까지 해당 값이 처리되기를 기다려야 합니다. 이러한 값은 Push API 로, 언제나 특정 형태의 구독 또는 알림 메커니즘을 가지게 됩니다. 자바스크립트에서는, 콜백 함수 형태를 보이는 것이 보통입니다.

미래의 값을 다룰 때에는, 값이 사용 가능해질 때에 알림을 받는 것이 필요합니다. 그것이 Push 입니다.

프라미스란, 어떤 프라미스가 해결되었거나 거절되면서 단일 결과값을 반환할 때에, 미리 정해진 일련의 프로그램 코드를 호출하는 Push 메커니즘입니다.

옵저버블은 프라미스와 비슷하지만, 옵저버블은 새로운 값이 사용가능해질 때마다 항상 미리 정해진 일련의 프로그램 코드를 호출하며, 시간이 지남에 따라 많은 값들을 만들어낼 수 있습니다.

옵저버블의 주요 기능은 .subscribe() 메서드로, 세 가지 콜백들을 인자로 전달합니다:

  • onNext: 옵저버블이 값을 만들어낼 때마다 호출됩니다.
  • onError: 옵저버블이 값을 만들어내는 과정에서 오류가 발생하거나 실패했을 때에 호출됩니다.
  • onCompleted: 가장 마지막으로 onNext가 호출되었을 때에 뒤이어서 호출됩니다. 단, 오류가 발생한 적이 없어야 합니다.

따라서, 동기적인 스타일의 async 함수를 위한 옵저버블 API를 구현하고 싶다면, 위의 세 인자들을 전달해야 합니다. 바로 만들어보도록 합시다. 아, onError는 잠시 뒤로 미뤄두죠.

const isPromise = obj => Boolean(obj) && typeof obj.then === 'function';

const next = (iter, callbacks, prev = undefined) => {
  const { onNext, onCompleted } = callbacks;
  const item = iter.next(prev);
  const value = item.value;

  if (item.done) {
    return onCompleted();
  }

  if (isPromise(value)) {
    value.then(val => {
      onNext(val);
      setImmediate(() => next(iter, callbacks , val));
    });
  } else {
    onNext(value);
    setImmediate(() => next(iter, callbacks, value));
  }
};

const gensync = (fn) => (...args) => ({
  subscribe: (onNext, onError, onCompleted) => {
    next(fn(...args), { onNext, onError, onCompleted });
  }
});


/* How to use gensync() */

const fetchSomething = () => new Promise((resolve) => {
  setTimeout(() => resolve('future value'), 500);
});

const myFunc = function* (param1, param2, param3) {
  const result = yield fetchSomething(); // 프라미스를 반환

  // 프라미스를 기다렸다가, 프라미스의 해결된 결과를 사용
  yield result + ' 2';
  yield param1;
  yield param2;
  yield param3;
}

const onNext = val => console.log(val);
const onError = err => console.log(err);
const onCompleted = () => console.log('done.');

const asyncFunc = gensync(myFunc);

// asyncFunc를 호출하면서 인자를 전달한다.
asyncFunc('a param', 'another param', 'more params!')
  .subscribe(onNext, onError, onCompleted);
// future value
// future value 2
// a param
// another param
// more params!
// done.

이 최종 버전의 코드가 가장 마음에 드는군요. 기능이 더 다양해졌기 때문이죠. 사실, 너무 마음에 들어서, 오류 처리 기능을 더한 뒤 Ogen이라고 이름을 붙였습니다. 무엇보다도, 진정한 Rx Obervable 객체다운 기능들을 추가했습니다. 내부 요소들에 대하여 .map(), .filter(), .skip() 등을 비롯한 다양한 기능들을 사용할 수 있습니다.

Ogen의 기능이 궁금하면 Github를 확인해주세요.

비동기 흐름 제어를 향상시켜주는 다양한 옵저버블 라이브러리들이 존재합니다. 제가 제너레이터를 사용해오지 않았던 가장 큰 이유이기도 합니다만, 이제는 동기적인 스타일의 코드와 옵저버블을 Ogen을 사용하여 적절하게 혼용할 듯 하군요. 어쩌면, 제너레이터를 점점 더 사용하게 될 지도 모르겠습니다.