ES6 In Depth: 제너레이터(Generator)

ES6 In Depth는 ECMAScript 표준 6번째 에디션(줄여서 ES6) 제정으로 인해 JavaScript 언어에 추가된 새로운 요소들을 살펴보는 시리즈입니다.

저는 이번 글이 흥분됩니다. 이번 글을 통해 우리는 ES6의 가장 마법 같은 요소를 살펴볼 것입니다.

왜 “마법 같다”고 했을까요? 처음 접하는 사람들에게, 제너레이터는 기존에 있던 JS 요소들과 너무 달라서 정말 불가사의하게 보여집니다. 어떤 의미에서 제너레이터는 랭귀지의 일반적인 동작양식을 철저하게 뒤흔들어 놓습니다! 이걸 마법이 아닌 다른 어떤 말로 표현할 수 있을지 모르겠습니다.

그뿐만이 아닙니다. 코드를 간결하게 만들고 “콜백 지옥 (callback hell)”을 제거하는 제너레이터의 능력은 신비로울 지경입니다.

서론이 너무 장황했나요? 이제 내용을 살펴봅시다. 판단은 당신 몫입니다.

ES6 제너레이터를 소개합니다

제너레이터가 뭔가요?

아래 코드를 살펴보는 것으로 시작해 봅시다.

function* quips(name) {
  yield "hello " + name + "!";
  yield "i hope you are enjoying the blog posts";
  if (name.startsWith("X")) {
    yield "it's cool how your name starts with X, " + name;
  }
  yield "see you later!";
}

이것은 말하는 고양이 코드의 일부입니다. 아마도 요즘 인터넷에서 가장 중요한 종류의 어플리케이션일 것입니다. (링크를 클릭해서 고양이와 이야기해 보세요. 고양이의 행동이 이해되지 않으면, 다시 여기로 와서 설명을 들어보세요.)

이 코드는 일종의 함수 같아 보입니다. 맞나요? 이 코드를 제너레이터-함수 (generator-function)라고 부릅니다. 함수와 비슷한 부분이 아주 많습니다. 하지만 한눈에 2가지 다른 점을 발견할 수 있을 것입니다.

  • 일반 함수는 function 키워드로 시작합니다. 제너레이터-함수는 function* 키워드로 시작합니다.

  • 제너레이터-함수 안에는 yield 구문이 존재합니다. yield 구문의 문법은 return 구문과 비슷합니다. 차이점은 함수의 경우 (심지어 제너레이터-함수의 경우도) return 구문은 한번만 실행되지만, 제너레이터-함수의 yield 구문은 여러번 실행됩니다. yield 구문은 제너레이터의 실행을 멈췄다가 다음에 다시 시작할 수 있게 만듭니다.

이게 전부입니다. 이게 일반 함수와 제너레이터-함수의 큰 차이점입니다. 일반 함수는 스스로 실행을 멈출 수 없습니다. 제너레이터-함수는 스스로 실행을 멈출 수 있습니다.

제너레이터가 하는 일

quips() 제너레이터-함수를 호출할 때 어떤 일이 일어날까요?

> var iter = quips("jorendorff");
  [object Generator]
> iter.next()
  { value: "hello jorendorff!", done: false }
> iter.next()
  { value: "i hope you are enjoying the blog posts", done: false }
> iter.next()
  { value: "see you later!", done: false }
> iter.next()
  { value: undefined, done: true }

당신은 아마도 평범한 함수들의 동작양식에 익숙할 것입니다. 평범한 함수들은 호출하면 바로 실행됩니다. 그래서 return 구문을 만나거나 예외(exception)가 발생할 때까지 실행됩니다. JS 프로그래머에게 아주 익숙한 동작양식입니다.

제너레이터를 호출하는 quips("jorendorff") 코드는 외관상 일반 함수를 호출하는 코드와 똑같아 보입니다. 하지만 제너레이터-함수는 호출해도 바로 실행되지 않습니다. 그대신, 제너레이터-함수는 멈춰진 제너레이터 객체 (generator object)를 리턴합니다 (위 예제 코드의 iter 변수입니다). 이 제너레이터 객체를 실행이 얼어붙은 함수라고 생각할 수 있습니다. 제너레이터-함수 코드의 맨 위쪽, 즉 코드의 첫번째 줄 바로 앞에서 실행이 얼어붙은 함수입니다.

제너레이터 객체의 .next() 메소드를 호출할 때마다, 제너레이터 객체는 스스로 해동돼서 다음번 yield 구문에 다다를 때까지 실행됩니다.

그래서 위 예제에서 우리가 iter.next()를 호출할 때마다 각기 다른 문자열 값을 얻었던 것입니다. 이 문자열 값들은 quips() 본문 중의 yield 구문들에 의해 생성된 값들입니다.

마지막 iter.next() 호출에서, 우리는 마침내 제너레이터-함수의 마지막에 도달했습니다. 그래서 결과값의 .done 필드가 true가 됐습니다. 함수의 마지막에 도달하는 것은 undefined를 리턴하는 것과 똑같습니다. 그래서 결과값의 .value 필드는 undefined입니다.

지금쯤 말하는 고양이 데모 페이지로 되돌아가서 진지하게 코드를 살펴보는 것도 좋을 것 같습니다. 루프 안에 yield 구문을 추가해보세요. 어떤 일이 일어나나요?

기술적 관점에서, 제너레이터의 yield 구문이 실행될 때, 제너레이터의 스택 프레임 (stack frame: 로컬 변수, 인자, 임시 값, 제너레이터 코드의 실행 위치)은 스택에서 제거됩니다. 하지만, 제너레이터 객체는 이 스택 프레임에 대한 참조를 (또는 복사본을) 유지하고 있다가 다음번 .next() 호출 때 재활성화 시켜서 실행을 계속합니다.

제너레이터는 쓰래드(thread)가 아니라는 점을 짚고 넘어가는 것이 좋겠습니다. 쓰래드를 지원하는 랭귀지들은 여러개의 코드를 동시에 실행시킵니다. 그래서 자원 경합 상황(race conditions), 비결정적 실행 특성(nondeterminism), 그리고 아주 아주 달콤한 성능(performance)을 만들어냅니다. 제너레이터는 쓰래드와 완전히 다릅니다. 제너레이터 코드는 제너레이터를 호출하는 코드와 같은 쓰래드에서 실행됩니다. 코드는 정의된 순서에 따라 항상 똑같은 순서로 실행되며 여러 코드가 동시에 실행되는 경우는 절대 없습니다. 시스템 쓰래드와 다르게, 제너레이터는 yield 구문에 의해서만 실행을 멈춥니다.

좋습니다. 우리는 제너레이터가 무엇인지 알게 됐습니다. 우리는 제너레이터가 실행되는 것, 제너레이터가 스스로 실행을 멈추는 것, 그리고 제너레이터가 다시 실행을 재개하는 것을 지켜봤습니다. 이제 중요한 질문을 던질 차례입니다. 이렇게 이상한 개념의 무엇(what)이 그렇게 유용하다는 말인가요?

제너레이터는 이터레이터입니다

지난주, 우리는 ES6의 이터레이터(iterator)가 단순히 새로 내장된(built-in) 클래스가 아님을 살펴봤습니다. 이터레이터는 랭귀지를 확장할 수 있는 수단입니다. 우리는 [Symbol.iterator]().next() 2개의 메소드를 구현하는 것만으로 자기만의 이터레이터를 만들 수 있습니다.

하지만 새로운 인터페이스를 구현하는 것은 언제나 어느 정도 노력이 필요한 일입니다. 실제 상황에서 이터레이터 구현한다면 어떤 일을 해야 하는지 살펴봅시다. 예를 들어 간단한 range 이터레이터를 구현해봅시다. 이 이터레이터는 한 숫자에서부터 다른 숫자까지 더해나가는 단순한 이터레이터입니다. C 언어의 흔한 for (;;) 루프처럼 말이죠.

// 이 코드는 "ding"을 3번 표시합니다
for (var value of range(0, 3)) {
  alert("Ding! at floor #" + value);
}

여기 ES6 class를 이용한 한가지 해법이 있습니다. (class 문법이 낯설더라도 걱정하지 마세요. 앞으로 다른 글에서 다룰 것입니다.)

class RangeIterator {
  constructor(start, stop) {
    this.value = start;
    this.stop = stop;
  }

  [Symbol.iterator]() { return this; }

  next() {
    var value = this.value;
    if (value < this.stop) {
      this.value++;
      return {done: false, value: value};
    } else {
      return {done: true, value: undefined};
    }
  }
}

// 'start'에서 'stop'까지 더해나가는 새로운 이터레이터를 리턴합니다.
function range(start, stop) {
  return new RangeIterator(start, stop);
}

이 코드가 실행되는 모습을 보세요.

이 해법은 JavaSwift와 비슷한 방식입니다. 그렇게 나쁘지 않습니다. 하지만 그렇게 간단하지도 않습니다. 혹시 이 코드에 버그는 없나요? 쉽게 이야기할 수 없을 것입니다. 이 코드는 원래 우리가 흉내내려고 했던 for (;;) 루프 구문과 전혀 닮지 않았습니다. 이터레이터 프로토콜을 사용했기 때문에 루프를 철저히 새로 만들게 생겼습니다.

아마 이쯤되면 이터레이터를 회의적으로 볼지도 모르겠습니다. 이터레이터가 사용하기에는 좋을지는 몰라도 구현하기는 어렵구나 하고요.

그렇게 오해할리 없겠지만 우리는 단지 이터레이터를 쉽게 만들기 위한 목적만으로 JS에 어설프고 이상한 실행구조(제너레이터)를 도입한 것이 아닙니다. 하지만 일단 제너레이터를 갖고 있으니, 이터레이터 만드는데 사용해볼 수도 있지 않겠습니까? 한번 해봅시다.

function* range(start, stop) {
  for (var i = start; i < stop; i++)
    yield i;
}

이 코드가 실행되는 모습을 보세요.

위의 4줄짜리 제너레이터가 이전에 RangeIterator class를 이용해 만든 23줄짜리 range() 구현체와 똑같은 대치물입니다. 이렇게 대치 가능한 이유는 제너레이터가 이터레이터이기 때문입니다. 모든 제너레이터는 .next() 코드와 [Symbol.iterator]() 코드를 내장(built-in)하고 있습니다. 당신은 그냥 루프 처리만 작성하면 됩니다.

제너레이터 없이 이터레이터를 구현하는 것은 수동태만 갖고 장문의 이메일을 작성하는 것과 같습니다. 전달하고 싶은 의미를 표현할 수 없을 뿐 아니라, 문장의 의미가 왜곡되고 말 것입니다. RangeIterator 코드는 루프를 위한 문법을 쓰지 않고 루프의 기능을 표현하려 했기 때문에 장황하고 이상했습니다. 제너레이터가 정답입니다.

제너레이터가 이터레이터로 동작하는 능력을 또 어디에 써먹을 수 있을까요?

  • 객체를 이터러블(iterable)하게 만듭니다. 단순히 this를 순회(traverse)하면서 적절한 값을 yield 하는 제너레이터-함수를 작성합니다. 그렇게 작성한 제너레이터-함수를 해당 객체의 [Symbol.iterator] 메소드에 인스톨합니다.

  • 배열 생성 함수를 간단하게 만듭니다. 당신이 아래 예제처럼 호출될 때마다 배열을 결과로 리턴하는 함수를 가지고 있다고 가정해봅시다.

    // 1차원 배열 'icons'를
    // 'rowLength' 길이의 배열 여러개로 나눕니다.
    function splitIntoRows(icons, rowLength) {
      var rows = [];
      for (var i = 0; i < icons.length; i += rowLength) {
        rows.push(icons.slice(i, i + rowLength));
      }
      return rows;
    }
    

    제너레이터를 사용하면 이런 종류의 코드를 더 간단하게 표현할 수 있습니다.

    function* splitIntoRows(icons, rowLength) {
      for (var i = 0; i < icons.length; i += rowLength) {
        yield icons.slice(i, i + rowLength);
      }
    }
    

    동작상의 유일한 차이점은 모든 결과를 한번에 계산하지 않고 일부 결과(배열)를 하나씩 리턴하는 것입니다. 이 제너레이터는 이터레이터를 리턴합니다. 그리고 호출 코드가 요구할 때마다(on demand) 결과를 하나씩 계산합니다.

  • 엄청나게 큰 결과를 처리합니다. 무한 크기의 배열은 만들 수 없지만 무한 시퀀스(sequence)를 생성하는 제너레이터는 만들 수 있습니다. 그래서 제너레이터를 호출하는 코드는 필요한 만큼 많은 결과 값을 얻을 수 있습니다.

  • 복잡한 루프 구문을 리팩토링합니다. 혹시 크고 복잡한 함수를 갖고 있나요? 그 함수를 좀더 간단한 2개의 부분으로 나누고 싶지 않나요? 제너레이터는 당신이 리팩토링할 때 쓸 수 있는 새로운 도구입니다. 복잡한 루프를 만나면, 당신은 데이터를 생성하는 부분을 떼어낼 수 있습니다. 그래서 떼어낸 부분을 별도의 제너레이터-함수로 만듭니다. 그리고 for (var data of myNewGenerator(args)) 문을 이용해서 루프 구문을 수정합니다.

  • 이터러블(iterable)을 다루는 도구로 사용합니다. ES6는 필터링(filtering), 맵핑(mapping) 등 임의의 이터러블 데이터 집합을 다루는 도구를 제공하지 않습니다. 하지만 제너레이터를 이용하면 몇 줄 안되는 코드로 당신이 필요로 하는 도구를 훌륭하게 만들 수 있습니다.

    예를 들어, 당신이 Array가 아닌 DOM NodeList를 다룰 때 Array.prototype.filter 같은 코드가 필요하다고 가정해봅시다. 식은 죽 먹기입니다.

    function* filter(test, iterable) {
      for (var item of iterable) {
        if (test(item))
          yield item;
      }
    }
    

어떤가요? 제너레이터가 쓸만한가요? 물론입니다. 제너레이터는 커스텀 이터레이터를 놀랍도록 쉽게 만드는 방법입니다. 그리고 이터레이터는 ES6에서 데이터와 루프를 처리하는 새로운 표준 규범입니다.

하지만 제너레이터가 할 수 있는 일은 그것 뿐만이 아닙니다. 심지어 이터레이터를 만드는 일은 제너레이터의 가장 중요한 용도도 아닙니다.

제너레이터와 비동기 코드

여기 제가 방금 작성한 JS 코드의 일부가 있습니다.

          };
        })
      });
    });
  });
});

아마 당신도 이런 종류의 코드를 오랫동안 만들어왔을 것입니다. 비동기 API는 보통 콜백을 요구합니다. 즉 당신이 무엇이라도 하려면 별개의 익명 함수 (anonymous function)를 작성해야 한다는 의미입니다. 그래서 만약 당신이 3가지 일을 하는 작은 코드를 만들어야 한다면, 3줄 짜리 코드가 아니라 3단계의 들여 쓰기 레벨을 갖는 코드 더미를 만들어야 한다는 의미입니다.

여기 제가 작성한 JS 코드가 있습니다.

}).on('close', function () {
  done(undefined, undefined);
}).on('error', function (error) {
  done(error);
});

비동기 API들은 나름의 에러 처리 관례를 갖고 있습니다. 예외(exception)는 보통 고려하지 않습니다. 서로 다른 API는 서로 다른 관례를 갖습니다. 비동기 API 대부분은 보통 에러를 조용히 누락시키는 것이 디폴트 동작양식입니다. 심지어 일부 비동기 API는 성공적인 완료에 대한 처리도 누락시킵니다.

지금까지 이런 문제들은 우리가 비동기 프로그래밍의 혜택을 위해 감내해야 하는 비용이었습니다. 우리는 비동기적 코드(asynchronous code)가 같은 일을 하는 동기적 코드(synchronous code)보다 가독성이 나쁘고 복잡하다는 사실을 어쩔 수 없이 인정해왔습니다.

제너레이터는 그렇지 않다는 새로운 희망을 제시합니다.

Q.async()는 제너레이터와 프라미스(promise)를 이용해서 비동기적 코드를 동기적 코드와 비슷한 모습으로 만들려고 하는 실험적인 시도입니다. 예를 들어 다음 코드를 보세요.

// 요란한 소리를 만드는 동기적 코드.
function makeNoise() {
  shake();
  rattle();
  roll();
}

// 요란한 소리를 만드는 비동기적 코드.
// 요란한 소리를 만들고 나면
// 나중에 리졸브(resolve)되는 프라미스 객체를 리턴합니다.
function makeNoise_async() {
  return Q.async(function* () {
    yield shake_async();
    yield rattle_async();
    yield roll_async();
  });
}

중요한 차이점은 비동기 버전의 경우 비동기 함수를 호출하는 자리에 반드시 yield 키워드를 추가해줘야 한다는 점입니다.

Q.async 코드에 if 구문이나 try/catch 블럭을 추가하는 것은 일반 동기적 코드에 if 구문이나 try/catch 블럭을 추가하는 것과 똑같습니다. 비동기 코드를 작성하는 다른 방법들과 비교할 때, 이 방법은 이질적인 새로운 언어를 배운다는 느낌이 훨씬 덜합니다.

여기까지 따라왔다면, 당신은 James Long의 이 주제에 관한 좀더 자세한 글을 즐길 수 있을 것입니다.

제너레이터는 비동기적 프로그래밍 모델로 나가는 새로운 방향을 제시합니다. 그 방향은 인간의 사고 방식에 더 잘 부합합니다. 이 작업은 진행중입니다. 다른 무엇보다도 더 나은 문법이 필요할 것입니다. 프라미스와 제너레이터 모두를 사용하고 C#에 있는 비슷한 요소에서 영감을 얻어 만든 비동기 함수를 위한 제안ES7을 위한 논의 테이블 위에 올라 있습니다.

이 멋진(crazy) 개념을 언제부터 쓸 수 있나요?

서버 프로그래밍의 경우, 지금 당장 io.js에서 (그리고 --harmony 커맨드라인 옵션을 켠 Node에서) ES6 제너레이터를 사용할 수 있습니다.

브라우저 프로그래밍의 경우, 지금은 Firefox 27+ 와 Chrome 39+ 만 ES6 제너레이터를 지원하고 있습니다. 지금 당장 웹 상에서 제너레이터를 사용하고 싶다면, Babel이나 Traceur같은 도구를 써서 당신의 ES6 코드를 웹-친화적인 ES5 코드로 변환시켜야 할 것입니다.

기억해야 할 사람들이 있습니다. JS 제너레이터는 Brendan Eich가 처음 만들었습니다. 그의 디자인은 Python 제너레이터와 무척 닮았습니다. Python 제너레이터는 Icon 랭귀지로부터 영감을 받은 것입니다. Brendan Eich의 JS 제너레이터는 지난 2006년 Firefox 2.0을 통해 출시됐습니다. 표준화에 이르는 여정은 험난했습니다. 그 과정에서 문법과 동작양식이 약간 바뀌었습니다. ES6 제너레이터는 컴파일러 해커인 Andy Wingo에 의해 Firefox와 Chrome 양쪽에 구현됐습니다. Bloomberg가 이 작업을 후원했습니다.

yield;

제너레이터에 대한 이야기 거리가 더 남아 있습니다. 우리는 .throw() 메소드와 .return() 메소드에 대해, .next() 호출시 부여할 수 있는 옵션 인자에 대해, 또 yield* 문법에 대해 이야기하지 않았습니다. 하지만 제 생각에 이번 글은 이정도로도 충분히 길고 충분히 난해합니다. 제너레이터처럼 우리도 잠시 멈출 필요가 있습니다. 나머지는 다음 시간에 다루기로 합시다.

다음 주에는 방향을 약간 바꾸려고 합니다. 지금까지 우리는 2개의 혁신적인 주제를 다뤘습니다. ES6 요소 중에서 그다지 혁신적이지 않은 주제를 다뤄보는 것도 괜찮지 않을까요? 무언가 간단하지만 분명히 유용해보이는 것도 있지 않을까요? 당신을 미소짓게 만들 무엇이 있지 않을까요? ES6에는 그런 요소들이 있습니다.

예고: 당신이 매일 만드는 코드에 당장 써먹을 수 있는 기능을 다루려고 합니다. 다음 주에 함께 ES6 템플릿(template) 문자열을 살펴봅시다.

이 글은 이 쓴 ES6 In Depth: Generators의 한국어 번역본입니다.

작성자: ingeeKim

"누구에게나 평등하고 자유로운 웹"에 공감하는 직장인.

ingeeKim가 작성한 문서들…


5 댓글

  1. 이순빈

    저는 이 글을 보고 C#의 이터레이터로 구현된 유니티의 ‘코루틴’ 이라는 개념이 떠오르네요.
    예를 들어, 서서히 사물의 투명도를 낮추는 효과를 구현하려면 각 프레임마다 for문의 투명도(진행상황)를 기억했다가 다음 프레임에서는 그 지점으로 되돌아와 실행될 필요가 있습니다. 또는 게임의 어떤 상황을 체크하고 싶을 때 매 프레임마다 조건을 호출하는 대신, yield 키워드로 몇 초간 대기한 뒤 다음에 다시 부르게 하여 부하를 줄일 수 있고요.
    이렇게 다양한 조건에 따라 현재 상황을 기억한 뒤 제어권을 넘겨주거나 하는 방식의 구현이 유니티에서는 매우 편리하게 작용할 수 있습니다.
    제 짧은 지식이 혼란을 초래할까봐, 더 좋은 분들의 설명을 걸어놓습니다.

    http://gamecodingschool.org/2015/05/16/%EC%BD%94%EB%A3%A8%ED%8B%B4coroutine-%EC%9D%B4%ED%95%B4%ED%95%98%EA%B8%B0/

    9월 2nd, 2015 at 2:14 오전
  2. HJ.Park

    덕분에 yield 키워드에 대해서 이해할 수 있었습니다.
    잘 보고 갑니다. =)

    9월 2nd, 2015 at 3:05 오후
  3. ingeeKim

    @이순빈, 멋진 소개 감사합니다.
    @HJ.Park, 저도 모질라Hack을 통해 많이 배우고 있습니다. 감사합니다. ^^

    9월 2nd, 2015 at 6:20 오후
  4. CoCoBalll

    글 잘 읽었습니다.많이 배워갑니다!

    10월 7th, 2016 at 2:33 오후
  5. ingeeKim

    자주 들러주세요. ^^

    10월 7th, 2016 at 7:16 오후

댓글 쓰기