Vana Blog

Node.js Design Pattern - ES6이후 비동기식 프로그램의 제어 흐름 패턴(2)

May 04, 2019

제너레이터(Generator)

세미코루틴(semi-coroutines)이라고도 함. 함수와 비슷하지만(yield 문을 사용하여) 일시적으로 실행의 흐름을 중지시켰다가 다시 시작시킬 수 있다. 제너레이터는 반복자(Iterator)를 구현할 때 특히 유용함.

제너레이터의 기본

문법은 다음과 같다. function키워드에 * 연산자를 추가하여 선언.

function* makeGenerator() {
  // 함수 본문
}
 
function* makeGenerator() {
  yield 'Hello World'; // 실행을 일시 중시 후 전달된 값을 호출자에게 반환
  console.log('Re-entered');
}

next() 메소드 : 제너레이터의 실행을 시작/재시작하는데 사용되며, value, done 객체 반환

{
  value: <yield >
  done: <  >
}

fruitGenerator.js

function* fruitGenerator() {
  yield 'apple';
  yield 'oragne';
  return 'watermelon';
}
 
const newFruitGenerator = fruitGenerator();
console.log(newFruitGenerator.next()); // {value : 'apple', done : false}
console.log(newFruitGenerator.next()); // {value : 'orange', done : false}
console.log(newFruitGenerator.next()); // {value : 'watermelon', done : true}

반복자(Iterator) 로서의 제너레이터(Generator)

function* iteratorGenerator(arr) {
  for(let i = 0; i < arr.length; i++) {
    yield arr[i];
  }
}
 
const iterator = iteratorGenerator(['apple', 'orange', 'watermelon']);
let currentItem = iterator.next();
while(!currentItem.done) {
  console.log(currentItem.value); // apple
                                  // orange
                                  // watermelon
  currentItem = iterator.next();
}

값을 제너레이터로 전달하기

next()메소드의 인자로 값을 전달할 수 있다. 이 값이 제너레이터 내부의 yield문의 반환값으로 제공된다.

function* twoWayGenerator() {
  const what = yield null;
  console.log('Hello ' + what);
}
 
const twoWay = twoWayGenerator();
twoWay.next(); // 첫 yield문에 도달한 다음 일시중지 상태
twoWay.next('world');
 
// throw 메소드를 사용할 수 있음.
const twoWay = twoWayGenerator();
twoWay.next();
twoWay.throw(new Error()); // yield문에서 값이 반환되는 순간 예외 처리함.

제너레이터를 사용한 비동기 제어 흐름

const fs = require('fs');
const path = require('path');
 
function asyncFlow(generatorFunction) {
  function callback(err) {
    if (err) {
      return generator.throw(err);
    }
    const results = [].slice.call(arguments, 1);
    generator.next(results.length > 1 ? results : results[0]);
  }
  const generator = generatorFunction(callback);
  generator.next();
}
 
asyncFlow(function* (callback) {
  const fileName = path.basename(__filename);
  const myself = yield fs.readFile(fileName, 'utf8', callback);
  yield fs.writeFile(`clone_of_${fileName}`, myself, callback);
  console.log('Clone created');
});

각 비동기 함수에 전달된 callback의 역할은 해당 비동기 작업이 종료되자마자 제너레이터를 다시 시작시키는 것이다. yield를 지정하여 반환받을 수 있는 객체의 유형으로 Promise, thunk를 사용하는 두 가지 변형 기술이 있다. thunk는 콜백을 제외한 원래 함수의 모든 인자들을 받아 콜백 만을 인자로 가지는 함수를 리턴하는 함수.

function readFileThunk(filename, options) {
  return function(callback) {
    fs.readFile(filename, options, callback);
  }
}

Node.js 스타일의 함수를 thunk로 변환하기 위한 라이브러리 thunkify

const thunkify = require('thunkify');
const mkdirp = thunkify(require('mkdirp'));
const nextTick = thunkify(process.nextTick);

co를 사용한 제너레이터 기반의 제어 흐름

co가 지원하는 yield를 지정할 수 있는 객체 : Thunks, Promises, Arrays, Objects, Generators, Generator functions

co는 다음과 같은 패키지의 자체적인 생태계를 가지고 있다.

  • 웹 프레임워크koa
  • 특정 제어 흐름 패턴을 구현한 라이브러리
  • co를 지원하기 위해 널리 사용되는 API를 랩핑한 라이브러리

순차 실행

const path = require('path');
const utilities = require('./utilities');
const thunkify = require('thunkify');
const co = require('co');
const request = thunkify(require('request')); // 코드를 thunkified
const fs = require('fs');
const mkdirp = thunkify(require('mkdirp'));
const readFile = thunkify(fs.readFile);
const writeFile = thunkify(fs.writeFile);
const nextTick = thunkify(process.nextTick);
function* spiderLinks(currentUrl, body, nesting) {
if(nesting === 0) {
return nextTick();
}
const links = utilities.getPageLinks(currentUrl, body);
for(let i = 0; i < links.length; i++) {
yield spider(links[i], nesting - 1);
}
}
function* download(url, filename) {
console.log('Downloading ' + url);
const response = yield request(url);
const body = response[1];
yield mkdirp(path.dirname(filename));
yield writeFile(filename, body);
console.log(`Downloaded and saved: ${url}`);
return body;
}
function* spider(url, nesting) {
const filename = utilities.urlToFilename(url);
let body;
try {
body = yield readFile(filename, 'utf8');
} catch(err) {
if(err.code !== 'ENOENT') {
throw err;
}
body = yield download(url, filename);
//co가 yield를 지정가능 하고 다른 제너레이터를 지원하기에 사용 가능.
}
yield spiderLinks(url, body, nesting);
}
// entry point
// co는 yield문에 전달하는 모든 제너레이터(함수 or 객체)를 감싼다.
co(function* () {
try {
yield spider(process.argv[2], 1);
console.log('Download complete');
} catch(err) {
console.log(err);
}
});
view raw generator_seq.js hosted with ❤ by GitHub

병렬 실행

function* spiderLinks(currentUrl, body, nesting) {
if(nesting === 0) {
return nextTick();
}
//배열에서 일시 정지(yield)할 수 있는 co의 특징을 이용한 방법
const links = utilities.getPageLinks(currentUrl, body);
const tasks = links.map(link => spider(link, nesting - 1)); // 호출을 병렬로 변환한 부분
yield tasks;
// 위의 코드를 callback 사용하여 구현한 코드. returns a thunk
return callback => {
let completed = 0, hasErrors = false;
const links = utilities.getPageLinks(currentUrl, body);
if (links.length === 0) {
return process.nextTick(callback);
}
function done(err, result) {
if(err && !hasErrors) {
hasErrors = true;
return callback(err);
}
if(++completed === links.length && !hasErrors) {
callback();
}
}
for(let i = 0; i < links.length; i++) {
co(spider(links[i], nesting - 1)).then(done);
// spider를 병렬로 실행. resolve되면 done함수 호출.
}
}
}

제한된 병렬 실행

동시 다운로드 작업의 수에 제한을 둔다.

  • co-limiter 사용.
  • co-limiter 패턴 : 생산자 - 소비자 패턴(producer-consumer)을 기반으로 구현. 목표는 queue를 활용하여 우리가 설정하려는 동시 실행 수만큼의 고정된 수의 worker들을 공급하는 것.
    const co = require('co');
    class TaskQueue {
    constructor(concurrency) {
    this.concurrency = concurrency;
    this.running = 0;
    this.taskQueue = [];
    this.consumerQueue = [];
    this.spawnWorkers(concurrency); // worker 시작
    }
    pushTask(task) {
    if (this.consumerQueue.length !== 0) {
    this.consumerQueue.shift()(null, task);
    // 대기중인 첫 번째 콜백을 호출함으로써 자례대로 worker의 차단을 해제한다.
    } else {
    this.taskQueue.push(task); // 모든 worker가 작업을 실행 중.
    }
    }
    spawnWorkers(concurrency) {
    const self = this;
    for(let i = 0; i < concurrency; i++) {
    // 즉시 실행되어 병렬 처리됨.
    co(function* () {
    while(true) {
    // 무한 loop에서 블록(yield)되어 큐에서 새로운 작업을 기다린다.
    const task = yield self.nextTask();
    yield task;
    }
    });
    }
    }
    nextTask() { // co 라이브러리를 통해 yieldable thunk를 반환한다.
    return callback => {
    if(this.taskQueue.length !== 0) {
    return callback(null, this.taskQueue.shift());
    // 즉시 worker의 yield가 해제되어 작업을 수행할 수 있다.
    }
    this.consumerQueue.push(callback); // 큐에 작업이 없는 경우.
    }
    }
    }
    module.exports = TaskQueue;
    TaskQueue 클래스에서 worker는 소비자 역할을 하고, pushTask()를 사용하는 쪽은 공급자로 간주할 수 있다. 이 패턴은 제너레이터가 스레드와 매우 유사할 수 있다는 것을 보여준다.

다운로드 작업 동시성 제한

function* spiderLinks(currentUrl, body, nesting) {
  //...
  return (callback) => {
    //...
    function done(err, result) {
      //...
    }
    links.forEach(function(link){
      downloadQueue.pushTask(function*() {
        yield spider(link, nesting - 1);
        done();
      });
    });
  }
}

각 작업에서 다운로드가 완료된 직후에 done() 함수를 호출하므로 다운로드된 링크 수를 계산하여 모두 완료되었을 때 thunk의 콜백을 호출할 수 있다.

Babel을 사용한 async await

  • ECMA2017(ES8) 사양으로 소개 된 async/await

    async 함수의 정의에 async와 await라는 두 가지 새로운 키워드를 언어에 도입함으로써 비동기 코드 작성을 위한 모델을 언어수준에서 크게 향상 시키는 것을 목표로 한다.

const request = require('request');
 
function getPageHtml(url) {
  return new Promise((resolve, reject) => {
    request(url, (error, response, body) => {
      resolve(body);
    });
  });
}
 
async function main() {
  const html = await getPageHtml('http://google.com');
  console.log(html);
}
 
main();
console.log('Loading...');

async/await는 셋트로 묶여다님. getPageHtml을 호출하기 전에 await 키워드를 사용하면 javascript인터프리터가 getPageHtml에서 반환한 Promise의 resolve를 기다리면서 다음 명령을 계속 진행하라는 것. 이렇게 하면 main함수는 프로그램의 나머지 부분의 실행을 차단하지 않고 비동기 코드가 완료될 때까지 내부적으로 일시 중지된다. ES8에서 사용할 수 있는 문법이기에 호환 가능한 코드로 변환해 주는 컴파일러 Babel을 이용해서 ES8을 지원하지 않는 환경에서도 사용할 수 있다.

npm install --save-dev babel-cli

async/await의 분석과 변환을 지원하기 위해 확장 기능을 설치해야 한다.

npm install --save-dev babel-plugin-syntax-async-functions
babel-plugin-transform-async-to-generator

노드에서 실행시킬 때

node_modules/.bin/babel-none --plugins
"syntax-async-functions, transform-async-to-generator" index.js

index.js의 소스코드를 변환하여 새로운 하위 호환성 코드는 메모리에 저장되어 Node.js runtime에서 즉시 실행된다.

비교

해결책 장점 단점
일반 javascript 1. 추가적인 라이브러리나 기술이 필요하지 않음
2. 최고의 성능을 제공함
3. 다른 라이브러리들과 최상의 호환성을 제공
4. 즉석에서 고급 알고리즘의 생성이 가능
1. 많은 코드와 비교적 복잡한 알고리즘이 필요할 수 있음
Async(라이브러리) 1. 가장 일반적인 제어 흐름 패턴들을 단순화
2. 여전히 콜백 기반의 솔루션
3. 좋은 성능
1. 외부 종속성
2. 복잡한 제어 흐름에 충분하지 않을 수 있음
Promise 1. 일반적인 제어 흐름의 패턴을 크게 단순화
2. 강력한 오류 처리
3. ES2015 사양의 일부
4. OnFulfilled 및 OnRejected의 지연 호출 보장
1. Promise화 콜백 기반의 API가 필요
2. 다소 낮은 성능
제너레이터 1. 논 블로킹 API를 블로킹과 유사하게 사용
2. 오류 처리 단순화
3. ES2015 사양의 일부
1. 보완적인 제어 흐름 라이브러리가 필요
2. 비순차적 흐름을 구현할 콜백 또는 Promise가 필요
3. thunk화 또는 Promise화가 필요
Async Await 1. 논 블로킹 API를 블로킹과 유사하게 사용
2. 깨끗하고 직관적인 구문
1. JavaScript및 Node.js에서 기본적으로 사용할 수 없음
2. Babel 또는 transpiler 및 일부 설정들이 필요함

Vana Yun

Written by Vana Yun