brunch

You can make anything
by writing

C.S.Lewis

by Hika Feb 03. 2016

[es6] Generator #1

ECMAscript 2015

개념정리


ES6의 generator는 분명 Iterator라고 스펙 문서에 기술되어있지만, 굉장히 혼란스러운 개념입니다. 개인적으로 반복자라기보다는 열거자(enumerable)에 가까운 구현체라고 느껴집니다만, 우선 기초적인 내용을 정리하고 이후 심화된 연구과제를 다뤄보겠습니다.


iterable 인터페이스


generator를 이해하기 앞서 먼저 iterable이 무엇인지 알아야 합니다.
ES6는 Symbol.iterator와 next메소드를 구현한 객체를 iterable로 봅니다. 이 객체는 당연하게도 next메소드를 호출할 수 있고 이 next메소드는


{value:값, done:불린}


형태의 값을 반환합니다. 여기까지의 스펙을 간단히 정의해보죠.


class Test{


  //'@@iterator'키로도 대체가능
  [Symbol.iterator](){
    return this;
  }


  next(){
    return {value:1, done:false};
  }
}


let a = new Test();
console.log(a.next());


//{value:1, done:false}


위 예제는 iterator 인터페이스를 구현한 클래스와 객체입니다. generator는 바로 이 과정을 보다 손쉽게 사용하도록 내장된 언어 표현입니다.


function* Test(){   yield 1;}


let a = Test();
console.log(a.next());


//{value:1, done:false}


function*구문을 이용하면 위와 같이 대체됩니다. new를 쓰는 클래스 대신 팩토리가 제공되는 클래스가 생성되었구나 생각하면 쉽습니다.
function*와 yield가 굉장히 여러 단계를 자동으로 생성(generate!)해주긴 하지만 어느 정도까지는 class구문으로 동일하게 표현할 수 있습니다.



추상화의 가치

하지만 언어가 지원함으로서 복잡성이 코드에서 줄어 들어 더욱 추상적인 단계로 나아갈 수 있죠.


ES6는 generator구문에 iterator를 한 단계 더 추상화하여 위임할 수 있는 yield* 구문을 지원합니다. yield를 반환하는 generator에게 다시 본인의 yield를 위임하는거죠.


물론 이것도 미친 척하고 class구문으로 구현이 되지만 생성된 코드만큼 구현해야하니 점점 불가능에 가까워집니다(기계만 가능한 영역이 되어가는죠 ^^)


언어적 지원을 적극적으로 활용하면 대략 다음과 같이 멍해집니다.


let test;
{
  function* A(value, result){
    console.log('loop');
    for(let i = 0; i < value; i++) result.v = yield i;
  }
 
  function* B(value){
    console.log('double');
    for(let i = 0; i < value; i++) yield i * 2;
  }


  test = function*(value){
    let result = {v:0}; //위임중계
    yield* A(value, result);
    yield* B(result.v);
    console.log('다른 iterable');
    yield* ['a', 'b'];
  };
}


let b = test(2);
// loop
console.log(b.next());  //{value: 0, done: false}
console.log(b.next());  //{value: 1, done: false}
// double
console.log(b.next(2)); //{value: 0, done: false}
console.log(b.next());  //{value: 2, done: false}
// 다른 iterable
console.log(b.next());  //{value: 'a', done: false}
console.log(b.next());  //{value: 'b', done: false}
console.log(b.next());  //{value: undefined, done: true}
[/js]


generator 자체는 class로 대치될지 모릅니다. 하지만 generator를 인자로 받거나 yield*를 통해 generator의 재 위임을 전개하면 사용할 수는 있지만 class로 짜기엔 무리인 복잡성입니다.


반대로 얘기하면 복잡한 기존의 루프가 generator구문을 이용함으로서 상당한 코드를 제거될 수 있다는 뜻이기도 합니다.


특히 iterator라는 규격을 지키는 모든 객체는 스스로가 알아서 반복될 로직을 소유하고 있으므로 그들은 간단히 조합하고 위임함으로서 호스트의 조립기쪽은 굉장히 추상화레벨에서 코드를 사용할 수 있게 됩니다.


 yield의 더 깊은 의미


만약 generator를 사용하지 않으면 iterable의 next에서 모든 반복에 대한 경우를 정의해야 합니다.


let test = {
  data:[1,2,3],
  clone:null,
  [Symbol.iterator]:function(){
    return this;
  },
  next(){
    let v;
    if(!this.clone) this.clone = this.data.slice(0);


    if(v = this.clone.pop()) return {value:v, done:false};


    return {value:undefined, done:true};
  }
};
console.log(test.next()); //{value:3, done:false};
console.log(test.next()); //{value:2, done:false};
console.log(test.next()); //{value:1, done:false};
console.log(test.next()); //{value:undefined, done:true};
s]


위의 코드는 data를 어떻게 처리할지에 대한 완전한 내용을 정의하고 있습니다.
이는 간단하게 generator로 축약됩니다.


let generator = function*(data){
  let v;
  while(v = data.pop()) yield v;
}


let test = generator([1,2,3]);
test.next();
test.next();
test.next();
test.next();


즉 yield는
1. 반복적인 {value:xx, done:false}를 만들어내고
2. 더이상 yield가 없는 마지막에 한 번 더 {value:undefined, done:true} 를 반환하는 코드를 자동화합니다.
3. 또한 [Symbol.iterator]:function(){return this;} 메소드를 내장합니다.
4. 거기에 더해 팩토리 함수도 제공하죠.

이 정도 자동 클래스를 생성기가 언어에 추가된 것으로 굉장히 많은 호스트코드가 사라지게 되는거죠.

generator는 실제로는

iterable class code generator

라고 봐야겠죠.


동기화 해체


하지만 보다 자세히 위의 generator를 보면 그 앞의 표현과 굉장한 차이가 있음을 알게 됩니다.
아니 나아가 기존 자바스크립트사용전체와 차이가 있는 레벨이죠.  


data를 인자로 받아서 while이라는 루프구문으로 동기적인 명령이 실행되는 중인데도 동기성을 벗어날 수 있다는 것을 알 수 있습니다.


본디 제어문의 동기성은 반드시 실행프레임 내에서 실행됩니다. 그래서 블록킹을 일으키죠. 하지만 yield를 사용하면 이러한 제약을 회피할 수 있게 됩니다.


let generator = function*(data){
  let v;
  while(v = data.pop()) yield v;
}

let test = generator([1,2,3]);


let action = function(){
  let v = test.next();
  console.log(v);
  if(!v.done) setTimeout(action, 1);
}
setTimeout(action, 1);
[/js]


우선 generator내부는 분명 동기루프인 while문이 돌고 있습니다. 그러나 하단 코드를 보면 action을 setTimeout을 이용해 비동기적으로 실행되고 있습니다.


즉 비동기적인 실행인데, 동기화구문과 연결되어 처리할 수 있는거죠. 이는 단순히 generator가 iterable의 축약구문이 아닌 걸 말합니다.


generator와 yield는 단순히 여러 번 return하는 기능의 축약구문이 아니라 함수의 실행 중 동기 명령을 대기시키고 외부 컨텍스트 실행을 하다가 다시 next가 호출될 때 동기명령이 이어서 실행될 수 있는 굉장히 native적인 기능입니다.


단 현재의 구조에서는 어쩔 수 없이 generator 내부에서만 동기화할 구문이 비동기로 작동할 수 있는 구조로 되어있습니다만 ES7 async 에서는 좀더 나은 표현이 될겁니다.


프레임 넘어 컨텍스트를 유지하기 위해 기존에는 객체나 클로져가 필요했지만 이젠 그냥 인자나 지역변수만으로도 프레임 넘어 실행 중이던 상태를 유지할 수 있게 된거죠. 굉장한 양으로 코드를 줄일 수 있는 토대가 됩니다.


위의 예제에서도 매번 setTimeout으로 개별 프레임에서 실행됨에서 로직의 컨텍스트를 유지하기 위해 인자 data, 지역변수 v만으로 충분히 그 역할을 수행하고 있습니다.
아마 ES5까지였다면 스코프로 상태를 유지할 함수를 정의할 수 밖에 없었을 것입니다.


var makeAction = function(data){
  var action;
  action = function(){
    var v = data.pop();
    console.log(v);
    if(v) setTimeout(action, 1);
  };
  return action;
};
var action = makeAction([1,2,3]);
setTimeout(action, 1);


거의 비슷한 출력을 하게 되지만 makeAction에서 data인자, action함수 등이 setTimeout을 통한 프레임간 실행에서의 실행컨텍스트 유지를 담당하고 있습니다.
스코프를 사용한거죠. 따라서 기존의 ES버전에서는 이러한 스코프구조물이 로직의 일부로 작성되어야하기 때문에 숙련된 자바스크립트 개발자가 아니라면 이해가 매우 어렵습니다. 하지만 generator의 핵심 로직은 동기적인 제어문(위 예제의 while문)으로 표현되므로 훨씬 이해하기 쉽다는 장점이 있습니다.


복잡한 루프의 추상화


예를 들어 Element를 순회하는 루프를 생각해보죠. 스택머신을 이용하면 다음과 같은 방법으로 전체를 순회할 수 있을 것입니다.


let loop = function(el, visitor){
  let stack = [];
  do{
    visitor(el);
    if(el.firstElementChild) stack.push(el.firstElementChild);
    if(el.nextElementSibling) stack.push(el.nextElementSibling);
  }while(el = stack.pop());
};


loop(document.body, function(el){
  //...
});

언틋 문제없어보이는 loop함수는 사실은 굉장히 큰 문제를 안고 있습니다.
루프를 도는 구문은 확정된 코드이기 때문에 visitor(el) 외의 다른 형태의 변형으로는 쓸 수 없다는 것이죠.
만약 다음과 같이 하고 싶다면 다시 함수를 하나더 만드는 수밖에 없습니다.


do{
  console.log(el.tagName);
  visitor(el);
  el.dataset.check = true;
  if(el.firstElementChild) stack.push(el.firstElementChild);
  if(el.nextElementSibling) stack.push(el.nextElementSibling);
}while(el = stack.pop());

처음 코드와 두 번째 코드의 차이점은 while문 내부에서 콘솔표시와 check마크를 템플릿 메소드처럼 기본으로 해주고 싶다는 것이겠죠.

하지만 이 차이의 해법이 기존의 ES5까지는 함수를 두 판 만드는 거 외엔 없습니다.

generator라고 이 루프를 간단히 처리할 수 있다는게 아닙니다. 하지만 루프 그 자체를 추상화할 수 있습니다.


function* nodeLoop(el){
  let stack = [];
  do{
    yield el;
    if(el.firstElementChild) stack.push(el.firstElementChild);
    if(el.nextElementSibling) stack.push(el.nextElementSibling);
  }while(el = stack.pop());
}

이제 el을 루프도는 부분만 로직화 되었으므로 다양한 상황에 독립적으로 사용할 수 있게 되었습니다. 즉 호스트 코드는 굉장히 짧아지고 무엇보다 중복에 제거되죠.


//첫 번째 케이스
for(let el of nodeLoop(document.body)) visitor(el);

//두 번째 케이스
for(let el of nodeLoop(document.body)){
  console.log(el.tagName);
  visitor(el);
  el.dataset.check = true;
}

이렇듯 자신이 어떻게 순회할지 은닉되어있으므로 호스트코드가 가져가야하는 코드가 크게 줄게 됩니다. 제어문, 특히 루프문에 갖혀 변화되는 부분만 분리할 수 없었던 제약에서 벗어나게 됩니다. 이 모든 파워가 제어문의 동기성 제약을 벗어날 수 있기 때문에 생겨난 것입니다.


결론


generator의 기초를 살펴 봤습니다. 다른 ES6의 기능처럼 이 기능도 ES5로는 번역될 수 없는 완전히 언어를 새로 정의하는 기능입니다.
앞으로 살펴보게 될 다양한 ES6 특징들이 그러하듯 역시 ES6는 기존의 자바스크립트와는 완전히 다른 언어입니다.
다음 포스트에서는 generator의 비동기 및 쓰레딩과 관련된 사용을 살펴보겠습니다.

브런치는 최신 브라우저에 최적화 되어있습니다. IE chrome safari