[es2015+] Generator #3 / 3

개요

1편, 2편에서 제네레이터의 기본적인 사용법과 스펙상의 정의를 상세히 살펴봤습니다. 마지막 3편에서는 제네레이트를 비동기와 연계해서 사용하는 개념과 async 함수의 의미를 이해한 뒤 최종적으로 async generator를 사용해보겠습니다.

제네레이터를 이용한 비동기처리 기초

제네레이터의 비동기적인 사용법을 이해하기 위해 아주 간단한 비동기 함수처리기인 슬라이서를 생각해보겠습니다. 이 예제를 제네레이터의 도움없이 구현한 뒤 다시 제네레이터 버전으로 구현해보죠.
슬라이서 함수는 특정함수 f를 n번 실행해주는 실행기입니다. 하지만 타임아웃을 방지하기 위해 limit만큼 루프 끊어서 다음 프레임으로 작업을 넘겨줍니다. 이를 간단하게 표현하는 다음과 같은 모양일 것입니다.

const slicer = (n, f, limit = 3)=>{
  let i = 0;
  const runner =_=>{
    let j = 0;
    while(i < n){
      if(j++ < limit) f(i++);
      else return requestAnimationFrame(runner);
    }
  };
  requestAnimationFrame(runner);
};

slicer(10000, console.log, 100);
//100개씩 끊어서 0~9999까지 출력함

이 슬라이서는 나쁘지는 않지만 requestAnimationFrame이라는 기능은 사실 슬라이서 본연의 기능은 아닙니다. 슬라이서는 그저 나눠주기만 하고 실제 비동기나 프레임을 처리하는 방법은 바뀔 수 있어야합니다.
예를들어 구형IE에서는 requestAnimationFrame대신 setTimeout을 사용해야겠죠. 만약 Node환경이라면 process.nextTick()을 사용하게 될 것입니다. 기존 흐름 제어에서는 흐름제어 로직은 동일한데 일부 코드만 다른 경우 제어구조를 복사해서 만드는 것외엔 뾰족한 방법이 없었습니다. 하지만 제네레이터를 이용하면 제어구조를 그대로 둔 상태에서 변경되는 코드만 외부에 둘 수 있습니다. 보통 이렇게 외부에서 제네레이터의 제어구조를 이용하는 쪽을 실행기(executor)라고 부릅니다. 이러한 관점으로 제네레이터를 이용해 재작성해보죠.

const gene = function*(n, f, limit){
  let i = 0, j = 0;
  while(i < n){
    if(j++ < limit) f(i++);
    else{
      j = 0;
      yield;
    }
  }
};
const slicer = (n, f, limit = 3)=>{
  const iter = gene(n, f, limit);
  const runner =_=>{
    iter.next();
    requestAnimationFrame(runner);
  };
  requestAnimationFrame(runner);
};

slicer(10000, console.log, 100);
//100개씩 끊어서 0~9999까지 출력함

이전 코드와 큰 차이를 느낄 수 없을지도 모르겠습니다만, 이젠 제네레이터 안에는 슬라이스를 처리하는 로직만 들어있고 실행기쪽에는 순수하게 이터레이션과 requestAnimationFrame을 처리하는 로직만 들어있게 되었습니다. 슬라이서를 간단히 setInterval로 변경해보죠.

const slicer = (n, f, limit = 3)=>{
  const iter = gene(n, f, limit);
  const id = setInterval(_=>{
    if(iter.next().done) clearInterval(id);
  }, 1);
};

이 변경을 보면 슬라이서의 핵심 제어는 제네레이터에 위임되어있으므로 실행기의 인터벌로직만 따로 관리할 수 있어 훨씬 역할분리가 잘되는 것을 볼 수 있습니다.
제네레이터의 비동기 사용이란 결국 실행기와 제네레이터를 이용하여 적절한 시점에 이터레이션을 진행시키는 기법이라 할 수 있습니다.

제네레이터가 직접 이터레이션을 통제하도록 하기

앞에서 살펴본 실행기의 아이디어는 단지 역할을 분리하기 위해서만 사용되는 것은 아닙니다.
실행기가 제네레이터에게 실행기의 진행을 위임할 수 있는 방법을 넘겨주게 되면 제네레이터가 흐름제어에 관여하는 방식이 전혀 달라집니다. 이 개념을 슬라이서에서 구현해보죠.

const gene = function*(n, f, limit, next){
  let i = 0, j = 0;
  while(i < n){
    if(j++ < limit) f(i++);
    else{
      j = 0;
      //yield 시점에 직접 다음 단계의 비동기를 지시한다.
      yield requestAnimationFrame(next);
    }
  }
};
const slicer = (n, f, limit = 3)=>{
  //직접 다음을 진행할 수 있는 수단을 네 번째 인자로 넘겨준다
  const iter = gene(n, f, limit, _=>iter.next());
  iter.next();
};

slicer(10000, console.log, 100);

위 코드에서는 더 이상 실행기 쪽에서 제네레이터의 이터레이션을 진행하지 않습니다. 이를 대신할 수 있는 능력을 next로 넘겨줬으므로 제네레이터는 더 이상 실행기와 상호작용하지 않고 스스로 이터레이션을 진행하게 됩니다. 제네레이터가 직접 이터레이션을 진행시킨다는 아이디어에 기반하여 비동기를 동기처럼 기술하는 코드를 만들어보죠. 제이쿼리의 ajax통신을 이용해 순차적인 데이터를 얻어가는 과정을 제네레이터로 작성한다면 다음과 같이 될 것입니다.

const gene = function*(end, next, userid){

  //1. 받아온 userid로 uuid를 얻는다.
  const uuid = yield $.post('/member', {userid}, next);

  //2. uuid를 이용해 자세한 프로필 정보를 얻는다.
  const {nick, thumbnail} = yield $.post('/profile', {uuid}, next);

  //3. uuid로 최근 활동 정보를 얻는다.
  const {bbs, comment, alarm} = yield $.post('/activity', {uuid}, next);

  //4. 정리된 결과를 반환한다.
  end({nick, thumbnail, activity:{bbs, comment, alarm}});
};

const getUserInfo = (userid, end)=>{
  //v값을 받아 v값을 next에 넘겨준다
  const next =v=>iter.next(v);
  const iter = gene(end, next, userid);
  iter.next();
};

위 getUserInfo가 만들어내는 next함수는 슬라이서와 큰 차이가 한 가지 있습니다. 바로 v값을 iter.next(v)로 넘겨준다는 점이죠. 이렇게 넘겨준 값은 yield의 결과값으로 받을 수 있습니다. 바로 이 방법을 이용해 $.post의 결과값을 받을 수 있는 거죠. 제네레이터의 이터레이션이 외부와 데이터를 주고 받을 수 있는 이 장치는 굉장히 유용하게 사용됩니다.

이제 마치 비동기적으로 일어나는 일을 동기적인 코드처럼 기술할 수 있게 되었고 제네레이터의 이터레이션을 외부에서도 내부에서도 선택하여 통제할 수 있게 되었습니다.

fetch와 프라미스로 확장하기

콜백형식을 정형화시켜 프라미스로 변경할거면 fetch를 사용해도 될 것입니다. 위의 예를 fetch와 프라미스로 변경해 볼 것입니다. 이번 구현에서는 이터레이션의 통제를 실행기 쪽에 둘 생각입니다. 제네레이터 내부의 로직을 더욱 간소화하고 fetch의 결과인 프라미스만 넘겨주기 위해서죠(앞에서 언급한 것처럼 실행기와 제네레이터 중 누가 주도적으로 이터레이션 진행을 시킬 것인가는 경우에 따라 선택할 문제입니다)

const gene = function*(end, userid){
  const init = body=>({method:'POST', body:JSON.stringify(body)});
  const uuid = yield fetch('/member', init({userid});
  const {nick, thumbnail} = yield fetch('/profile', init({uuid}));
  const {bbs, comment, alarm} = yield fetch('/activity', init({uuid}));
  end({nick, thumbnail, activity:{bbs, comment, alarm}});
};

const getUserInfo = (userid, end)=>{
  const next = ({value, done})=>{
    if(!done) value.then(res=>res.json()).then(v=>next(iter.next(v)));
  };
  const iter = gene(end, userid);
  next(iter.next());
};

getUserInfo("hika", console.log);

프라미스를 쓰기 전의 제이쿼리 예제와 비교하여 그렇게 큰 차이점은 실행기 쪽의 then을 처리하는 로직일 것입니다.

  1. fetch는 then으로 reposonse를 반환하고,
  2. response.json() 역시 프라미스를 반환하게 됩니다.

중요한 점은 next함수의 마지막처리에 다시 next를 불러 이터레이션을 넣어줘야 한다는 점일 것입니다. 결과적으로 실행기가 제네레이터의 다단계 프라미스를 처리하며 이터레이션 타이밍을 조정해주고 있습니다.

async await로 전환하기

이제 비로소 async-await구문의 본질적인 이해가 가능해졌습니다. async-await는 결국 프라미스 비동기 실행기를 내장한 제네레이터 구문이라 할 수 있습니다. 위의 예를 async-await로 바꿔보죠.
이제 다양한 응용에 익숙해지셨을테니(^^) 간단히 작성하겠습니다. await는 결국 then에 대한 실행기이므로 한번 then이 겹칠 때마다 await를 해줘야 합니다. fetch의 response에서 한번 await하고 json()에서 한 번 더 await해야하므로 최종 코드는 다음과 같을 것입니다.

const getUserInfo = async function(userid){
  const init = body=>({method:'POST', body:JSON.stringify(body)});
  const {uuid} = await(await fetch('/member', init({userid}))).json();
  const {nick, thumbnail} = await(await fetch('/profile', init({uuid}))).json();
  const {bbs, comment, alarm} = await(await fetch('/activity', init({uuid}))).json();
  return {nick, thumbnail, activity:{bbs, comment, alarm}};
};
getUserInfo("hika").then(console.log);

반복적인 실행기 작성이나 then에 대한 여러가지 처리를 생략할 수 있어 이 전 코드에 비해 현격하게 코드량이 줄었습니다. 하지만 다중 프라미스를 해소하려면 결국 await가 반복되는데 코드가 그리 깨끗하게 작성되는 편은 아닙니다. 익숙해지면 괜찮지만 await는 머리쪽에 선언되는데 비해 그 원인은 코드 젤 마지막에 기술되기 때문에 연쇄되는 await구문은 읽기 어렵습니다. 게다가 아래처럼 끊어 써도..

await(
  await fetch('member.php', init({userid}))
).json();

가장 안쪽 괄호부터 해석해가야 하는 일반적인 괄호식의 난해함이 그대로 있습니다(차라리 then을 쓰는게 나은거 아니냥 =.=)

async generator로의 확장

프라미스를 사용한 비동기 실행기와 제네레이터의 사용을 간단히 async-await로 처리할 수 있게 된 건 좋지만 async-await는 중간에 yield를 할 수 없습니다. 즉 1회성 then을 처리하여 이터레이션을 자동화해주는 실행기 정도의 의미를 갖는 일종의 매크로 구문이나 마찬가지인데 이 댓가로 원래 제네레이터의 가치인 코루틴으로서의 기능을 잃어버리게 됩니다.
이럴 거면 차라리 async-await 이 전에 구현된 프라미스 실행기와 제네레이터 쌍이 더 유용할 지경입니다. 아니 실제로 더 유용하기도 해서 일괄처리되는 프라미스구문의 매크로인 경우는 async-await를 사용하지만 아닌 경우는 여전히 실행기를 직접 짜서 구현해야 하는 불편함과 이를 처리해주는 라이브러리들의 유용성이 async-await 이후에도 계속되었습니다.

아직 표준은 아니지만 stage3까지 올라온 제안 중에 Asynchronous Iteration는 이러한 문제를 해결하려고 합니다. 즉 위의 실행기 코드를 표준화하여 Symbol.asyncIterator로 반환되는 비동기이터레이터 객체를 생성하게 하고 next()가 프라미스를 반환하게 하는 아이디어입니다. 기존 이터러블과 이터레이터가 그러하듯 표준 구현 인터페이스는 asyncIterable과 asyncIterator지만 이를 보다 편리하게 사용할 수 있으면서 고유의 suspend기능을 결합한 것이 바로 async genenerator인 셈입니다(비동기 이터레이션은 보다 큰 주제이므로 다른 포스트에서 다룹니다)

다음의 외부 실행기는 제네레이터와 yield를 통해 값을 교환하여 작동하게 됩니다.

const gene = async function*(userid){
  const init = body=>({method:'POST', body:JSON.stringify(body)});

  //1. fetch의 결과인 프라미스를 yield함
  const member = yield await fetch('/member', init({userid}))

  //3. 실행기로부터 받아온 response를 이용해 값을 추출함
  const {uuid} = await member.json();
};

const getUserInfo = userid=>{
  //2. 받아온 value를 그대로 넘겨주는 이터레이션 진행기
  const next = ({value, done})=>{
    if(!done) iter.next(value).then(next);
  };
  const iter = gene(userid);
  iter.next().then(next);
};
getUserInfo("hika");

위의 코드는 매우 생소할 수 있으므로 자세히 설명합니다.

우선 1번의 fetch결과 코드에서 주의할 점은 yield와 await가 같이 등장하면 항상 분리해서 생각하면 된다는 것입니다.

  1. await가 해소된 뒤
  2. yield가 된다

한 줄로 되어있지만 우선적으로 await가 해소된 후에나 yield로 반환된다라는 점만 이해하면 쉽니다. async제네레이터는 설령 yield 3 처럼 프라미스가 아닌 걸 await없이 반환해도 내부적으로 무조건 Promise.resolve(3)으로 처리됩니다. 따라서 예외 없이 프라미스가 반환된다고 생각하면 됩니다.
이는 실행기 입장에서 next()를 호출하면 무조건 then()을 이용해야만 값을 받을 수 있다는 뜻이고 이때 얻어지는 값이 바로 yield에 넘겨진 프라미스의 then값인 셈입니다.

const member = yield await fetch('member.php', init({userid}))

이 코드에서 fetch().then(v=>..)가 반환할 v값은 response객체입니다. 이를 2번 next함수에서 iter.next에게 넘겨주고 있으므로 yield의 결과는 response가 되는 것입니다.
이제 3번에서 response인 member의 json()메소드를 호출하면 이는 다시 프라미스가 되므로 await를 걸긴하지만 실행기쪽에 값을 줄 필요가 없기 때문에 yield는 하지 않는 셈입니다.

즉 await로 프라미스 대기를 손쉽게 해소하면서도 원할 때 실행기측과 통신하거나 제어의 일부를 위임할 수 있게 되어 코드가 전반적으로 깔끔하게 분리되고 다양한 실행정책을 쓸 수 있게 되었습니다.
기존의 await(await(.. 형태였던 예제는 다음과 같이 직관적인 코드로 변하게 되고 실행기 측에서는 async제네레이터의 이터레이션 단계를 외부에 보고할 수 있게 되었습니다(외부에서 프로그래스바를 전진시킨다던가..)

const gene = async function*(userid){
  const init = body=>({method:'POST', body:JSON.stringify(body)});

  //우선 uuid를 얻고
  const member = yield await fetch('/member', init({userid}))
  const {uuid} = await member.json();

  //uuid에 의존적인 fetch를 한 번에 처리
  const [profile, activity] = yield await Promise.all([
    fetch('/profile', init({uuid})),
    fetch('/activity', init({uuid}))
  ]);

  //json을 한 번에 정리
  const [{nick, thumbnail}, {bbs, comment, alarm}] = await Promise.all([
    profile.json(), activity.json()
  ]);

  return {nick, thumbnail, activity:{bbs, comment, alarm}};
};

const getUserInfo = userid=> new Promise(res=>{
  const next = ({value, done})=>{
    console.log('next iteration!', value);
    if(!done) iter.next(value).then(next);
    else res(value);
  };
  const iter = gene(userid);
  iter.next().then(next);
});

getUserInfo("hika").then(console.log);

async제네레이터의 의미를 본질적으로 알게 되었으니 다양한 프라미스 기법과 결합하여 자유롭게 사용할 수 있습니다. await(await..형태로 실행기에 위임하여 제거하거나 suspend에 대한 정책도 외부에서 관여할 수 있는 수단이 생기면서도 동시에 Promise.then에 대한 자동 매크로 생성을 이용할 수 있게 되었습니다.

현재 제안 단계의 스펙임에도 불구하고 이 제안자가 구글 크롬팀의 도미닉 챔피온이기 때문에 이미 크롬63에는 반영되어 작동하게 됩니다(반칙 아니냐능..)

결론

이번 글에서는 여러 가지를 살펴봤습니다.

  1. 제네레이터와 실행기를 알아보고
  2. 실행기를 통해 동기, 비동기를 외부에서 통제하거나
  3. 통제함수를 이터레이터에게 전달하여 제네레이터 내부에서 이터레이션을 제어하는 방법을 살펴봤습니다.
  4. 프라미스를 기준으로 하는 자동 실행기인 async-await로 살펴보고
  5. 이 컨텍스트 하에서 다시 suspend하는 제어권을 가져오기 위해 Asynchronous Iteration 및 async제네레이터도 공부했습니다.

하지만 아직도 제네레이터의 세계는 무궁무진합니다. 비동기는 단지 싱글쓰레드 제약 하에서의 네이티브 통신모듈만 처리할 수 있는 것이 아닙니다. 웹워커의 메세징구조를 래핑하면 멀티쓰레드 간의 조율도 손쉽게 할 수 있을 뿐더러 실행기의 다양한 정책도 외부에서 공급받는 방식을 통해 다양한 실행 코루틴 플랫폼을 구성할 수도 있습니다.

이번 3부작 제네레이터 시리즈에서도 아직

  1. 각 상황에 맞는 throw와 에러처리를 다루지 않았으며
  2. 클래스 메소드로서의 각각 제네레이터 상황의 특수성도 다루지 않았습니다.
  3. async제네레이터의 next교착상태를 비롯한 미묘한 상황도 산재해있으며
  4. 이상의 모든 스펙에서 yield*를 통한 다중 제네레이터 연계도 생략하고 있습니다.

위의 네 가지 주제는 보다 복잡하기 때문에 제네레이터의 기본을 다루는 본 시리즈 포스팅에는 적당하지 않아 뺐습니다만 언젠가 제네레이터 심화편에서 다룰지도 모르겠습니다(…언제..=.= 먼산)