들어가기 전에

자바스크립트 개발자라면 알아야 할 33가지 개념 #24-2 자바스크립트 : 예제와 함께 자바스크립트 ES6 Generator 이해하기

제너레이터(Generator)

ES6는 제너레이터(Generator) 또는 Generator 함수 형태에서 함수와 Iterator를 다루는 방법을 새롭게 소개했습니다. 제너레이터는 함수를 중간에서 멈추고, 다시 멈췄던 부분부터 실행할 수 있게 합니다. 요약하면, Generator는 함수의 형태를 띄지만, Iterator처럼 동작합니다.

재미있는 사실은 async/await이 Generator를 기반으로 한 것이라는 겁니다. 여기에서 더 자세히 읽어보세요.

Generator는 Iterator와 복잡하게 연결되어 있습니다.

Generator를 자세히 알아보기전에 다음 비유를 통해 Generator에 대해 직관적으로 알아봅시다.

여러분이 손톱을 물어뜯게하는 긴박감 넘치는 하이테크 추리소설을 읽고 있다고 상상해보세요. 책의 페이지에 엄청나게 집중하고 있고, 거의 초인종 소리도 못 들을 정도라고 생각해봅시다. 그 상황에서 갑자기 피자배달부가 왔습니다. 여러분은 문을 열어주기 위해서 자리에서 일어났습니다. 문을 열어주기 전에, 여러분은 여러분이 읽던 책 중간에 책갈피를 꽂아뒀습니다. 여러분은 머릿속에 여러분이 읽고 있던 스토리를 잠시 기억해둡니다. 그리고 여러분은 문 밖으로 나가서 피자를 받습니다. 방에 다시 돌아왔을 때, 여러분은 여러분이 책갈피를 꽂아둔 페이지부터 다시 읽기 시작합니다. 여러분은 책을 첫 페이지부터 다시 읽을 필요가 없습니다. 이러한 방식으로 여러분은 이미 Generator 함수처럼 행동하고 있는 것입니다.

소개

프로그래밍 중에 발생하는 일반적인 문제를 해결하기 위해 어떻게 제너레이터를 활용할 수 있을지 봅시다. 하지만 그 전에, 제너레이터가 뭔지부터 알아봅시다.

제너레이터란 무엇인가?

아래 소스에 있는 함수와 같이 일반적인 함수들은 작업이 끝나기 전엔 끝낼 수 없습니다. 이러한 형태의 함수를 run-to-complete 모델이라고 합니다.

function normalFunc() {
  console.log('I');
  console.log('cannot');
  console.log('be')
  console.log('stopped.')
}

normalFunc에서 탈출하는 유일한 방법은 return 영역에 들어가는 것입니다. 아니면 에러를 throw하거나요. 만일 여러분이 함수를 다시한번 호출하면, 함수는 다시 맨위 에서부터 실행을 시작할 것입니다.

반대로, 제너레이터는 중간에 멈출 수 있는 함수입니다. 그리고 멈춘 부분부터 다시 실행을 시작할 수 있습니다.

제너레이터의 일반적인 정의는 다음과 같습니다.

  • 제너레이터는 아이터레이터 작성 작업을 간단하게 해줄 수 있는 함수들의 특별한 클래스입니다.
  • 제너레이터는 하나의 값 대신에 결과의 순서를 생성하는 함수입니다. 이를테면 제너레이터는 값의 시리즈를 만들어(generate) 냅니다.

자바스크립트에서, 제너레이터는 next()를 호출할 수 있는 오브젝트를 반환하는 함수입니다. 여러분이 next() 호출을 할 때마다, 다음과 같은 형태의 오브젝트를 반환합니다.

{
  value: Any,
  done: true|false
}

value 프로퍼티는 값을 가집니다. done 프로퍼티는 true 혹은 false를 갖습니다. donetrue가 될 때, 제너레이터는 멈추고 더 이상 값을 만들어내지 않습니다.

아래는 제너레이터를 설명한 그림입니다.

GeneratorArchitecture.png

일반적인 함수 vs 제너레이터 함수

그림에 나온 메시지를 간단히 해석하자면, 제너레이터가 실행되다가 yield를 만나면 멈추고 그 구간을 빠져나오면 다시 시작되고 다시 yield를 만나면 멈춘다는 이야기입니다.

이미지의 제너레이터 부분을 끝내기 전에 yield-resume-yield 루프로 다시 연결되는 화살표를 잘 보세요. 제너레이터가 영영 끝나지 않는 경우의 수도 있습니다. 이 예제는 다음에 다시 살펴봅시다.

제너레이터 만들어보기

자바스크립트에서 우리는 어떻게 제너레이터를 만들 수 있을까요?

function * generatorFunction() { // Line 1
  console.log('This will be executed first.');
  yield 'Hello, '; // Line 2

  console.log('I will be printed after the pause');
  yield 'World!' ;
}

const generatorObject = generatorFunction(); // Line 3

console.log(generatorObject.next().value); // Line 4
console.log(generatorObject.next().value); // Line 5
console.log(generatorObject.next().value); // Line 6

// This will be executed first.(이게 아마 처음 실행될 것입니다.)
// Hello,
// I will be printed after the pause(정지 후엔 다음과 같은 말이 출력될 것입니다.)
// World!
// undeifned

위에서 function *, yield, generatorObject, generatorObject.next 부분을 유심히 봅시다. 위의 소스에서 우리는 function이라는 일반적인 함수 선언 대신에 function *이라는 문법을 사용해서 함수를 선언했습니다. function 키워드와 * 그리고 함수 이름 사이에는 얼만큼의 빈공간이든 들어올 수 있습니다. 왜냐하면 이건 그냥 함수이고, 함수가 사용되는 곳이면 어디서든 사용할 수 있습니다. 예를 들면, 오브젝트 내부 그리고 클래스 메소드로도 사용 가능합니다.

함수의 바디 부분 내부에서, 우리는 return 키워드를 사용하지 않습니다. 그 대신에, 우리는 yield라는 키워드를 대신 사용합니다. (Line 2) yield라는 키워드는 제너레이터가 멈추게 할 수 있는 연산자(operator)입니다. 제너레이터가 yield를 만날 때마다, 제너레이터는 yield 뒤에 기재된 값을 반환합니다. 이번 경우에는 Hello,라는 값이 반환되었습니다. 하지만, 우리는 제너레이터의 맥락에서는 "반환(리턴)되었다." 라는 표현을 쓰지 않습니다. 우리는 대신에 "제너레이터가 Hello,라는 값을 yield(생산) 했다." 라고 말합니다.

yield는 한국어 단어 중 어떤 말로 써도 어색하기에 그냥 영어 문자 그대로를 가져가겠습니다.

제너레이터에서 물론 값을 그냥 반환(return)하는 것도 가능합니다. 하지만, returndone 프로퍼티를 true로 설정합니다. 그래서 그 이후로는 제너레이터는 어떠한 값도 generate(생산)해낼 수 없습니다.

function * generatorFunc() {
  yield 'a';
  return 'b'; // 제너레이터는 여기서 끝납니다.
  yield 'a'; // 영영 실행될 수 없습니다.
}

Line 3에서, 우리는 제너레이터 오브젝트를 만들었습니다. 이는 마치 우리가 generatorFunction 함수를 호출하는 것처럼 보입니다. 사실대로 말하자면 우리는 정말 그렇게 했습니다. 단지 차이점은 제너레이터는 값을 반환하는 대신에 항상 제너레이터 오브젝트를 반환한다는 것입니다. 제너레이터 오브젝트는 iterator입니다. 그래서 여러분들은 for-of 루프 내부에서 제너레이터를 사용할 수도 있습니다. 또는 다른 함수에서도 역시 iterator처럼 받아들여집니다.

Line 4에서, 우리는 generatorObject 내부에 존재하는 next() 메소드를 호출합니다. 이 호출로 인해, 제너레이터는 실행되기 시작합니다. 처음으로 제너레이터는 console.log(This will be executed first.)를 수행합니다. 그 이후에 제너레이터는 yield 'Hello, '를 마주하게 됩니다. 제너레이터는 { value: 'Hello, ', done: false }와 같은 형태의 오브젝트 값을 yield하게 됩니다. 그리고 잠시 거기서 일시정지합니다. 그 후에 다음 호출을 기다립니다.

Line 5에서, 우리는 next()를 다시 한번 호출합니다. 제너레이터가 깨어나고 남은 부분을 마저 실행하기 시작합니다. 다음 줄에서 console.log가 나오고, I will be printed after the pause라는 텍스트를 console.log합니다. 그리고 또 다른 yield를 마주합니다. { value: 'World!', done: false }와 같은 오브젝트 값이 yield됩니다. 우리는 value 프로퍼티를 추출해서 로깅합니다. 제너레이터는 다시 잠에 듭니다.

Line 6에서, 우리는 또 next()를 호출합니다. 이번에는 다음에 더이상 실행할 라인이 없습니다. 여러분이 기억해야 할 것은 모든 함수가 명시된 return값이 없다면 묵시적으로 undefined를 반환한다는 것입니다. 그러므로, 제너레이터는 yield하는 대신에, { value: undefined, done: true } 형태의 오브젝트를 반환하게 됩니다. 이제, 이 제너레이터는 더 이상 값을 반환하거나 재실행 될 수 없습니다. 왜냐하면 더이상 실행할 구문이 남아있지 않기 때문입니다.

이제 다시 값을 생성하기 위해서는 새로운 제너레이터를 만들어야 합니다.

제너레이터의 쓰임

제너레이터는 다양한 방법으로 멋지게 쓰일 수 있습니다. 몇가지 예시를 보러갑시다.

Iterable 수행하기

여러분이 iterator를 수행할 때, 여러분은 next() 메소드를 가진 iterator 오브젝트를 직접 만들어야 합니다. 또한, 여러분은 상태를 직접 저장해야 합니다. 가끔씩은, 정말 그렇게 하기 귀찮은 상황이 옵니다. 제너레이터는 iterable(반복 가능)하기 때문에, 제너레이터는 귀찮은 추가적인 보일러플레이트 코드 없이 iterable을 수행하는데에 사용될 수 있습니다. 간단한 예제를 봅시다.

문제: 우리는 커스텀 This, is, iterable.을 반환하는 iterable을 만들기를 원합니다. 여기 iterator를 이용한 예제가 있습니다.

const iterableObj = {
  [Symbol.iterator]() {
    let step = 0;
    return {
      next() {
        step++;
        if (step === 1) {
          return { value: 'This', done: false };
        }
        else if (step === 2) {
          return { value: 'is', done: false };
        }
        else if (step === 3) {
          return { value: 'iterable.', done: false };
        }
        return { value: '', done: true };
      }
    }
  }
}

for (const val of iterableObj) {
  console.log(val);
}

// This
// is
// iterable.

제너레이터를 이용하면 다음과 같은 코드로 작성 가능합니다.

function * iterableObj() {
  yield 'This';
  yield 'is';
  yield 'iterable.';
}

for (const val of iterableObj()) {
  console.log(val);
}

// This
// is
// Iterable.

두가지 버전을 비교해보세요. 사실 약간 억지 느낌이 있는 것은 사실이지만, 다음과 같은 사실들을 살펴볼 수 있습니다.

  • Symbol.iterator를 생각할 필요가 없습니다.
  • next()를 구현할 필요가 없습니다.
  • next() 내부에 쓰이는 { value: 'This', done: false }와 같은 반환 오브젝트에 대해 작성할 필요도 없습니다.
  • 상태를 저장할 필요도 없습니다. iterator 예제에서는, 상태가 step이라는 변수에 저장되었습니다. step은 iterable로부터 무엇이 결과물로 나왔는지를 정의하는 값이었습니다. 제너레이터에서는 이러한 귀찮은 작업들이 필요 없습니다.

더 나은 비동기 함수성

promise와 콜백을 사용한 코드가 가능합니다.

function fetchJson(url) {
  return fetch(url)
  .then(request => request.text())
  .then(text => {
    return JSON.parse(text);
  })
  .catch(error => {
    console.log(`ERROR: ${error.stack}`);
  });
}

위 코드는 다음과 같이 쓰여질 수 있습니다. (co.js라는 라이브러리의 도움을 조금 받습니다.)

const fetchJson = co.wrap(function * (url) {
  try {
    let request = yield function(url);
    let text = yield request.text();
    return JSON.parse(text);
  }catch (error) {
    console.log(`ERROR: ${error.stack}`);
  }
});

몇몇 독자들은 위의 코드를 보고 "async/await의 사용을 나열해놓은 것 같은데?" 라고 생각할 수도 있습니다. 사실 틀린 생각이 아닙니다. async/await은 비슷한 전략을 따라갑니다. async/await은 promise가 있고, yieldawait으로 교체합니다. async/await은 generator를 바탕으로 할 수 있습니다. 더 많은 정보를 얻기 위해서는 이 코멘트를 보시면 도움이 될 것입니다.

끊이지 않는 데이터 스트림

영원히 끝나지 않는 제너레이터를 만드는 것도 가능합니다. 다음 예제를 봅시다.

function * naturalNumbers() {
  let num = 1;
  while (true) {
    yield num;
    num = num + 1;
  }
}

const numbers = naturalNumbers();

console.log(numbers.next().value);
console.log(numbers.next().value);

// 1
// 2

우리는 naturalNumbers라는 generator를 만들었습니다. 함수 내부에서, 우리는 무한한 while 루프를 갖습니다. 그 루프 내부에서, 우리는 numyield합니다. generator가 yield할 때, 함수는 잠시 정지(suspended)됩니다. 우리가 next()를 다시 호출할 때, generator는 다시 깨어나고 멈췄던 부분부터 다시 동작합니다.(정확히 말하자면 yield다음부터 입니다.) 그리고 또 다른 yield를 만날 때까지 계속 실행합니다. 만일 또 다른 yield를 만나지 못한다면, generator를 종료합니다. 이번 경우에, 다음 문장은 num = num + 1이기 때문에, num을 업데이트 합니다. 그 후에, while loop의 맨 위로 갑니다. 조건이 여전히 true여서 계속 실행됩니다. 이 함수는 또 다음 줄의 yield num을 만납니다. 그 이후에 업데이트된 numyield하고, 정지합니다. 여러분이 원하는만큼 이러한 동작을 반복합니다.

Observer(관찰자)로서의 Generator

Generator는 next(val)함수를 사용하여 값을 받을 수 있습니다. generator가 새로운 값을 받을 때, 깨어나기 때문에, generator는 observer로도 불립니다. 이러한 동작은 값을 지켜보다(observing) 가 generator가 값을 가졌을 때, generator가 동작한다고 생각될 수 있습니다. 여기에서 이러한 패턴에 대해 조금 더 알아볼 수 있습니다.

Generator의 장점

Lazy Evaluation (게으른 계산)

끊이지 않는 데이터 스트림 예제에서 보았듯이, 그와 같은 행동은 Lazy Evaluation이라는 특성 때문에 가능합니다. Lazy Evaluation은 값이 필요로 될 때까지, 표현식의 Evaluation을 미루는 Evaluation 모델입니다. 우리가 만일 값이 필요 없다면, Evaluation이 일어나지 않습니다. 우리가 필요한 때에 값은 계산됩니다. 다음 예제를 봅시다.

function * powerSeries(number, power) {
  let base = number;
  while (true) {
    yield Math.pow(base, power);
    base++;
  }
}

powerSeries는 power에 여러가지 number값을 줍니다. 예를 들면, powerSeries에 3을 준다면 3의 제곱부터 순서대로, 9, 16, 25, 36, 49yield 하게 될 것입니다. 우리가 const powersOf2 = powerSeries(3, 2);를 소스코드에 칠 때, 우리는 그냥 generator 오브젝트를 하나 만들 뿐입니다. 아직 아무런 값도 계산되지 않았습니다. 이제, 우리는 next()를 호출합니다. 9가 계산되고 반환됩니다.

메모리 효율

Lazy Evaluation은 즉각적으로 우리의 generator가 메모리 효율을 고려할 수 있다는 것을 알려줍니다. 우리는 필요로되는 값만 생성합니다. 일반적인 함수로 값을 구한다면, 우리는 모든 값을 미리 계산하여 구해놔야 합니다. 그리고 우리가 나중에 쓸 일을 대비해서 그 값을 가지고 있어야 합니다. 하지만, generator를 사용하여, 우리는 우리가 필요할 때까지 우리는 계산을 미룰 수 있습니다.

우리는 generator에서 작동할 Combinator 함수를 만들 수 있습니다. Combinator는 새로운 값들을 만들기 위해 존재하는 iterable을 함께 사용하는 함수입니다. 다음은 Combinator함수인 take입니다. 이 함수는 iterable의 첫 n 엘리먼트를 가져가서 그에 따른 연산을 합니다. 다음 구현을 참조하세요.

function * take(n, iter) {
  let index = 0;
  for (const val of iter) {
    if (index >= n) {
      return;
    }
    index = index + 1;
    yield val;
  }
}

다음은 take의 사용 예제입니다.

take(3, ['a', 'b', 'c', 'd', 'e']);

// a b c

take(7, naturalNumbers());

// 1 2 3 4 5 6 7

take(5, powerSeries(3, 2));

// 9 16 25 36 49

다음은 cycled 라이브러리의 구현입니다. (함수의 성질 자체를 뒤집지 않고 만듭니다.)

function * cycled(iter) {
  const arrOfValues = [...iter];
  while (true) {
    for (const val of arrOfValues) {
      yield val;
    }
  }
}

console.log(...take(10, cycled(take(3, naturalNumbers()))));

// 1 2 3 1 2 3 1 2 3 1

경고(Caveats)

generator를 이용하여 프로그래밍하는 중에 반드시 잊지 말아야 할 것이 있습니다.

  • generator 오브젝트는 오직 한 번만 접근 가능하다. 여러분이 만일 모든 값을 사용했다면, 소진된 generator는 다시는 반복을 수행할 수 없습니다. 다시 값을 생성해내려면, 다시 generator 오브젝트를 생성해야 합니다.
const numbers = naturalNumbers();

console.log(...take(10, numbers)) // 1 2 3 4 5 6 7 8 9 10
console.log(...take(10, numbers)) // 데이터를 얻을 수 없습니다.
  • generator 오브젝트는 배열을 이용한 값의 랜덤한 접근을 허락하지 않습니다. 왜냐하면 generator는 오직 한 번에 한 개의 값만 만들어냅니다. 랜덤한 값에 접근하는 행위는 그 엘리먼트에 도달할 때까지, 값을 계속 계산하게 할 것입니다. 결국 랜덤한 접근이 아니게 됩니다.

결론

generator 에 대한 많은 것들이 아직 다뤄지지 않았습니다. yield *라던지, return(), throw()와 같은 것들이 다루어지지 않았습니다. generator는 또한 coroutine도 가능하게 만듭니다. 아래에 generator에 대한 추가적인 이해를 돕기 위한 몇가지 참고자료들을 나열해놓았습니다.

파이썬의 [itertools] 페이지를 둘러보는 것도 좋은 방법입니다. 그리고 iterator와 generator를 이용하여 어떤 것을 구현할 수 있는지 구경해보세요. 연습문제로, 여러분은 여러분 스스로 그 활용 예제들을 코딩해볼 수 있을 것입니다.

References -

  • PEP 255 — It’s the proposal for generators in Python. But the rationale is applicable to JavaScript as well.
  • Mozilla Docs
  • An in-depth four-part series by Kyle Simpson on generators and co-routines. Read here.
  • An in-depth review of generators by Axel Rauschmayer. Read it here.
  • Python’s itertools — It’s an builtin library in Python that has lots of utilities for working with generators and iterators.