Outsider's Dev Story

Stay Hungry. Stay Foolish. Don't Be Satisfied.
RetroTech 팟캐스트 44BITS 팟캐스트

ECMAScript 6의 generator

ECMAScript 6에는 generator가 포함되어 있다. 제너레이터는 이터레이션을 제어할 수 있는 어떤 특수한 객체를 만들어 주는데 많은 프로그래밍 언어는 제너레이터를 이미 가지고 있는데 이번에 자바스크립트에도 들어온 것이다. 현재 파이어폭스가 구현한 제너레이터와 하모니 프로젝트의 제너레이터는 모양이 약간 다른이 부분은 조만간 하나로 합쳐질듯 하다.

V8의 제너레이터

제너레이터는 V8 3.19에 포함되었으므로 크롬 28버전 이상에서 사용할 수 있고 기본 적으로는 사용할 수 없도록 되어 있으므로 chrome://flags/#enable-javascript-harmony 부분을 다음과 같이 사용함으로 바꾸어 주어야 사용할 수 있다.

크롬 설정의 실험용 자바스크립트 사용 여부

제너레이터를 사용하려면 function 대신에 function* 문법을 사용해야 한다.

> function* increase() {
    for (var i = 0; i < 5; i++) {
      yield i;
    }
  }
  undefined
> var index = increase();
  undefined
> index.next();
  Object {value: 0, done: false}
> index.next();
  Object {value: 1, done: false}
> index.next();
  Object {value: 2, done: false}
> index.next();
  Object {value: 3, done: false}
> index.next();
  Object {value: 4, done: false}
> index.next();
  Object {value: undefined, done: true}

여기서 increase라는 이름으로 만든 것이 제너레이터고 제너레이터라는 의미로 function를 사용했다. 여기서 제너레이터를 할당한 indexindex.toString()를 실행해 보면 "[object Generator]"로 나와서 제너레이터임을 알 수 가 있다. 이 예제에서 제너레이터는 for문을 순회하면서 yield로 인덱스 값을 반환하게 되는데 제너레이터는 여기서 멈추고 next()를 호출될 때마다 다음 yield를 만날 때까지만 다시 실행을 하게 된다. next()를 실행할 때마다 yield의 값이 value에 담기고 done은 완료 여부를 나타낸다.

이는 제너레이터에 로그 메시지를 출력해 보면 좀 더 정확하게 알 수 있다.

> function* increase() {
    for (var i = 0; i < 5; i++) {
      console.log('before ' + i);
      yield i;
      console.log('after ' + i);
    }
  }
  undefined
> var index = increase();
  undefined
> index.next();
  before 0
  Object {value: 0, done: false}
> index.next();
  after 0
  before 1
  Object {value: 1, done: false}
> index.next();
  after 1
  before 2
  Object {value: 2, done: false}
> index.next();
  after 2
  before 3
  Object {value: 3, done: false}
> index.next();
  after 3
  before 4
  Object {value: 4, done: false}
> index.next();
  after 4
  Object {value: undefined, done: true}

이 예제를 실행한 과정을 보면 제너레이터에서 출력한 로그 메시지가 한번에 출력되는 것이 아니라 next()를 호출할 때마다 실행되는 것을 볼 수가 있다. 그래서 이터레이터를 좀더 세밀하게 제어할 수 있게 된다. 그래서 제너레이터를 활용하면 훨씬 다양한 일을 할 수 있다.

jsPerf에서 제너레이터가 for루프나 forEach보다 속도가 떨어지는 그래프

하지만 물론 이제 막 도입된 상황이고 JSPerf의 결과를 보면 일반적인 for 루프와 제너레이터를 비교했을 때 제너레이터의 성능이 무척 떨어지는 것을 볼 수가 있다. 속도는 시간이 지나면 개선이 되겠지만 느리다고 해서 제너레이터의 장점이 없는 것은 아니다.

Node.js에서의 제너레이너

자바스크립트의 최신 기능들도 브라우저에서는 대부분 크로스 브라우징 이슈때문에 사용하기 어려운데 제너레이터같은 기능은 특히 사용하기가 쉽지 않아 보인다. 하지만 V8에 들어간 것이기 때문에 Node.js에서는 사용할 수가 있다. Node.js v0.10.x에서는 아직 V8이 3.14 버전대이므로 사용할 수 없지만 현재 개발 버전이 v0.11.7에서는 이미 V8 3.20.17이 들어가 있고 이는 정식 버전인 v0.12가 나오면 Node.js에서 제너레이터를 사용할 수 있다는 의미이다.

node --harmony
> process.versions
{ http_parser: '1.0',
  node: '0.11.7',
  v8: '3.20.17',
  uv: '0.11.13',
  zlib: '1.2.3',
  modules: '0x000C',
  openssl: '1.0.1e' }

> var a = function* () {}
undefined
> function* increase() {
... for (var i = 0; i < 5; i++) {
..... yield i;
..... }
... }
undefined
> var index = increase();
undefined
> index.next();
{ value: 0, done: false }
> index.next();
{ value: 1, done: false }
> index.next();
{ value: 2, done: false }
> index.next();
{ value: 3, done: false }
> index.next();
{ value: 4, done: false }
> index.next();
{ value: undefined, done: true }

Node.js v0.11.7에서도 (크롬 카나리에서와 마찬가지로) 제너레이터를 사용하려면 실행시에 --harmony옵션을 주어야 한다. 이렇게 실행했을 때 제너레이터가 앞에서와 동일하게 잘 동작하는 것을 볼 수 있다.

제너레이터의 사용법을 좀 더 보자.

function loop() {
  for (var i = 0; i < 1000; i++) {
    // some cpu intensive work here
  }
  console.log('done');
}

loop();

위와 같은 코드가 있다고 할 때 Node.js에서는 보통 CPU 부하가 큰 작업은 좋지 않으므로 성능 개선을 위해서 대량의 작업을 분할해서 처리하는게 일반적이다.

function loop(n) {
  var i;
  for (i = n; i < n + 100; i++) {
    // some cpu intensive work here
  }

  if (i < 1000) {
    process.nextTick(function() {
      loop(i);
    });
  } else {
    console.log('done');
  }
}

loop(0);

아마 이런 식이 될 것이다. for문을 100개씩 나누어서 process.nextTick을 사용해서 나눠서 처리할 수 있도록 할 수 있다. 간단히 짠 예제이기는 하지만 이렇게 하면 순회하는 로직과 분산을 위한 로직이 섞여서 헷갈리는게 사실이고 이 코드는 아주 간단한 순회이므로 큰 상관없지만 로직이 복잡해 지면 다루기가 더 어려워진다.

function* list() {
  var arr = [];
  for (var i = 0; i <= 1000; i++) {
    arr.push(i);
    if (i !== 0 && i % 100 === 0) {
      yield arr;
      arr = [];
    }
  }
}

function loop(generator) {
  var arr = generator.next().value;

  if (arr) {
    console.log(arr[0]);
    for (var i = 0; i < arr.length; i++) {
      // arr[i]
    }
    process.nextTick(function() {
      loop(generator);
    });
  } else {
    console.log('done');
  }
}

loop(list());

제너레이터를 사용하면 이런 식으로도 작성할 수 있다.(이는 제너레이터를 사용하는 방법을 보여주는 예제일 뿐이 예제에서 딱히 좋아진 느낌은 안 들 수도 있다. 나도 이제 막 제너레이터를 접한지라...) 루프문을 관리하는 함수를 제너레이터를 따로 분리한 뒤 이 제너레이터를 가져와서 next()로 값을 가져와서 비즈니스 로직을 처리할 수 있다.

좀더 실용적인 예제를 보자.

var fs = require('fs');

fs.readFile('./one.txt', 'utf-8', function(error, data1) {
  fs.readFile('./two.txt', 'utf-8', function(error, data2) {
    fs.readFile('./three.txt', 'utf-8', function(error, data3) {
       console.log(data1 + data2 + data3); // one two three
    });
  });
});

위와 같이 3개의 파일을 읽어서 내용을 이어붙혀서 출력해야 한다고 해보자. 이는 순차적으로 수행해야 하므로 위와 같이 콜백을 이어붙힌 Node.js에서 일반적인 형태로 작성할 것이다. 보통 콜백헬(callback hell)이라는 이 문제를 해결하기 위해서 프로미스 라이브러이인 q를 사용하면 다음과 같이 작성할 수 있다.

var Q = require('q'),
    fs = require('fs');

function step1() {
  var deferred = Q.defer();
  fs.readFile('./one.txt', 'utf-8', function(error, data) {
    if (error) {
      deferred.reject(new Error(error));
    } else {
      deferred.resolve(data);
    }
  });
  return deferred.promise;
}
function step2(p) {
  var deferred = Q.defer();
  fs.readFile('./two.txt', 'utf-8', function(error, data) {
    if (error) {
      deferred.reject(new Error(error));
    } else {
      deferred.resolve(p + data);
    }
  });
  return deferred.promise;
}
function step3(p) {
  var deferred = Q.defer();
  fs.readFile('./three.txt', 'utf-8', function(error, data) {
    if (error) {
      deferred.reject(new Error(error));
    } else {
      deferred.resolve(p + data);
    }
  });
  return deferred.promise;
}

Q.fcall(step1)
 .then(step2)
 .then(step3)
 .then(function (value3) {
   console.log(value3)
 }, function (error) {
   console.log(error);
 })
 .done();

step1, step2, step3는 읽어오는 파일명만 다르고 완전히 같은 코드이지만 각 과정이 분리되어 있다는 의미로 따로 작성했고 앞에서 3개의 파일을 읽어오는 과정을 별도의 함수로 분리한 것이다. 이를 Q.fcall을 사용하면 위와 같이 순차적으로 실행되는 것처럼 코드를 작성할 수 있다. 이렇게 작성해도 callback hell을 상당히 깔끔히 해결할 수 있다. 여기서 제너레이터를 사용하면 다음과 같이 작성할 수 있다.

Q.async(function* () {
   try {
     var value1 = yield step1();
     var value2 = yield step2(value1);
     var value3 = yield step3(value2);
     console.log(value3);
   } catch (e) {
     console.log(e);
   }
 })().done();

(step함수는 앞에와 동일하다.) Q.async에 제너레이터 함수를 전달했고 각 단계에서 yield를 사용했다.(여기서 제너레이터의 복구는 Q의 async가 담당한다.) 앞에서 then으로 연결한 것보다 훨씬 더 깔끔해 졌고 실제로 비동기 콜백으로 이어진 것이 아니라 순차적으로 실행되는 것으로 보인다. 그리고 이렇게 제너레이터를 썼을 때의 장점은 제너레이터가 오류를 던져주기 때문에 try - catch 구문을 그대로 사용할 수 있고 훨씬 자연스러운 로직을 작성할 수 있다.(콜백방식은 콜백 자체도 다루기 어렵지만 예외를 다루기가 특히 어렵다.)


참고자료

2013/09/14 21:36 2013/09/14 21:36