ES6 In Depth: 제너레이터 (이어서)

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

ES6 In Depth 에 잘 오셨습니다! 지난 글 이후로 편히 쉬셨는지요? 하지만 프로그래머의 삶이 불꽃놀이나 레모네이드로 가득찬 편안한 삶일 수는 없지 않습니까? 이제 다시 남은 주제들을 공부할 시간이 되었습니다. 쉬었다 시작하기에 완벽한 주제를 골라두었습니다.

몇 달 전에 제너레이터에 대해 소개한 적이 있습니다. 제너레이터는 ES6 가 도입한 새로운 종류의 함수입니다. 저는 제너레이터를 ES6 의 가장 신비한 기능이라고 소개했습니다. 저는 제너레이터가 비동기 프로그래밍의 미래를 바꿀 것이라고 소개했습니다. 그리고 또 다음과 같이 소개했습니다.

제너레이터에 대한 이야기 거리가 더 남아 있습니다… 하지만 제 생각에 이번 글은 이정도로도 충분히 길고 충분히 난해합니다. 제너레이터처럼 우리도 잠시 멈출 필요가 있습니다. 나머지는 다음 시간에 다루기로 합시다.

지금이 바로 그 시간입니다.

지난 번 글은 여기서 보실 수 있습니다. 아마도 이번 글을 읽기 전에 지난 글을 읽어 보는 것이 좋을 것입니다. 읽어 보세요, 재밌습니다. 약간 길고 난해하지만… 지난 글에는 말하는 고양이도 나옵니다!

짧은 연극

지난 시간에, 우리는 제너레이터의 기본적인 동작양식을 살펴보았습니다. 다소 이상해 보였겠지만, 어렵진 않았을 것입니다. 제너레이터-함수(generator-function)는 일반 함수와 무척 비슷합니다. 가장 큰 차이점은 제너레이터-함수의 본문이 한번에 모두 실행되지 않는다는 점입니다. 제너레이터-함수는 매번 조금씩 실행됩니다. 그리고 yield 구문에 도달할 때마다 실행을 멈춥니다.

Part 1 링크에서 지난 글을 볼 수 있습니다. 하지만 지난 글은 제너레이터의 모든 측면을 다루지 않습니다. 지금부터 제너레이터의 모든 측면을 살펴 봅시다.

function* someWords() {
  yield "hello";
  yield "world";
}

for (var word of someWords()) {
  alert(word);
}

이 스크립트는 단순 명료합니다. 하지만 만약 이 스크립트를 통해 일어나는 일을 연극으로 표현한다면, 그건 아주 다른 종류의 스크립트(연극대본)가 될 것입니다. 아마도 다음과 같은 대본이 될 것입니다.

장면 - 컴퓨터를 배경으로, 낮

FOR LOOP 가 무대 위에 홀로 서 있다. 그는 안전모를 쓴 채, 
클립보드를 들고 근무 중이다.

                         FOR LOOP
                         (호출한다)
                        someWords()!
                              
GENERATOR 가 등장한다. 키가 크고, 양철로 만들어진, 태엽으로 
동작하는 신사다. GENERATOR 는 아주 친절한 인상을 하고 있지만, 
동상처럼 굳어 있다.

                         FOR LOOP
                      (크게 손뼉 치면서)
                   좋아요! 뭔가 일을 합시다.
                    (GENERATOR 를 향해서)
                         .next()!

GENERATOR 가 깨어난다.

                         GENERATOR
               {value: "hello", done: false}

GENERATOR 가 엉거주춤한 자세로 얼어붙는다.

                         FOR LOOP
                          alert!

ALERT 이 황급히 등장한다. 눈을 크게 뜨고 숨을 헐떡인다. 
그는 언제나 그런 모습인 것 같다.

                         FOR LOOP
                 사용자에게 "hello" 라고 말하세요.

ALERT 이 뒤로 돌아 무대 밖으로 뛰어간다.

                           ALERT
                    (무대 밖에서, 큰 소리로)
                        모두 멈추시오!
            hacks.mozilla.or.kr 웹페이지가 전합니다.
                          "hello"!

몇 초간 정적이 흐른 뒤, ALERT 이 뛰어서 돌아 온다. FOR LOOP 앞에 
미끄러지 듯 멈춰선다.

                           ALERT
                   사용자가 OK 라고 말했습니다.

                         FOR LOOP
                       (크게 손뼉 치면서)
                    좋아요! 뭔가 일을 합시다.
                     (GENERATOR 를 향해서)
                          .next()!

GENERATOR 가 다시 살아난다.

                         GENERATOR
                {value: "world", done: false}

GENERATOR 가 또다른 자세로 엉거주춤 얼어붙는다.

                         FOR LOOP
                          alert!

                           ALERT
                       (이미 뛰고 있다)
                            차렷!
                    (무대 밖에서, 큰 소리로)
                        모두 멈추시오!
            hacks.mozilla.or.kr 웹페이지가 전합니다.
                          "world"!

다시 한번, 멈췄다가, ALERT 이 무대 위로 올라와서 기진맥진한 
목소리로 말한다.

                           ALERT
              사용자가 또 OK 라고 말했습니다. 하지만...
            하지만 이 페이지가 더이상 추가 다이얼로그를
                    띄우지 않도록 막아주세요.

ALERT 이 지친 표정으로 퇴장한다.

                         FOR LOOP
                       (크게 손뼉 치면서)
                    좋아요! 뭔가 일을 합시다.
                     (GENERATOR 를 향해서)
                          .next()!

GENERATOR 가 세 번째로 다시 살아난다.

                        GENERATOR
                      (비장한 목소리로)
               {value: undefined, done: true}

GENERATOR 가 고개를 떨군다. 그의 눈에서 불빛이 사라진다. 
완전히 멈춘 것 같다.

                         FOR LOOP
                   점심 시간이군요. 그만 합시다.

FOR LOOP 가 퇴장한다.

잠시후, GARBAGE COLLECTOR 가 등장해서 멈춰진 GENERATOR 를 끌고 
무대 밖으로 사라진다.

그렇죠. 햄릿 같지는 않습니다. 하지만 대강의 그림을 이해하실 수 있을 겁니다.

이 연극에서 보는 것처럼, 제너레이터 객체는 처음 등장할 때 멈춰져 있습니다. 제너레이터 객체는 .next() 메소드가 호출될 때마다 깨어나서 조금씩 움직입니다.

제너레이터의 동작은 동기적(synchronous)으로 싱글-쓰레드(single-threaded) 환경에서 실행됩니다. 어떤 시점에 오직 한명의 배우만 움직인다는 사실에 주의합시다. 배우들은 서로 방해하지도 않고 서로 대화하지도 않습니다. 배우들은 자기 차례가 되면 이야기 합니다. 배우는 자기가 원하는 만큼 길게 이야기 할 수 있습니다. (세익스피어와 똑같습니다!)

이 드라마는 제너레이터가 forof 루프에 데이터를 공급할 때마다 펼쳐집니다. 거기에는 언제나 .next() 메소드를 호출하는 시퀀스가 존재합니다. 당신이 만든 코드에는 존재하지 않지만 말이죠. 저는 모든 것을 무대 위에 펼쳐 놓았습니다. 하지만 당신과 당신 프로그램 입장에서는 모든 일이 무대 뒤에서 일어납니다. 이터레이터 인터페이스 규격에 따라 제너레이터와 forof 루프가 함께 동작하도록 설계되었기 때문입니다.

이 시점에서 상황을 정리하면 다음과 같습니다.

  • 제너레이터 객체는 value 를 yield 하는 온순한 양철 로봇입니다.
  • 똑같은 로봇을 여러개 만들 수 있으며 각 로봇은 같은 코드로 프로그램되어 있습니다. 바로 로봇을 만든 제너레이터 함수의 코드 말입니다.

제너레이터의 종료 방식

제너레이터에는 지난 번 Part 1 기사에서 다루지 않은 몇 가지 소소한 기능들이 존재합니다.

  • generator.return()
  • generator.next() 구문에 인자 전달하기
  • generator.throw(error)
  • yield*

지난 번 기사에서 이들을 소개하지 않은 것은 이런 기능들이 존재하는지 이유를 이해하지 못하면, 이 기능들을 이해할 수 없을 뿐더러 이해한다 하더라도 금방 잊어 먹을 것이라고 생각했기 때문입니다. 하지만 이제 제너레이터를 조금 알게 되었으니, 그 이유를 알아봅시다.

여기 우리가 자주 쓰는 코드 패턴이 있습니다.

function doThings() {
  setup();
  try {
    // ... 뭔가 일을 합시다 ...
  } finally {
    cleanup();
  }
}

doThings();

cleanup 함수는 네트워크 커넥션(또는 파일)을 닫는 일이나, 시스템 자원을 해지하는 일이나, “진행중” 표시를 멈추기 위해 DOM 을 수정하는 일 등을 합니다. 이런 일들은 작업의 성공 여부와 상관 없이 작업이 끝날 때마다 일어나야 합니다. 그래서 cleanup 코드는 finally 블럭에 위치합니다.

이것을 제너레이터에 적용하면 어떻게 될까요?

function* produceValues() {
  setup();
  try {
    // ... 뭔가 값을 yield 합시다 ...
  } finally {
    cleanup();
  }
}

for (var value of produceValues()) {
  work(value);
}

별로 이상한 것 없어 보입니다. 하지만 이 코드에는 미묘한 문제가 존재합니다. 바로 work(value) 코드가 try 블럭 내부에 존재하지 않는 것입니다. 만약 work(value) 코드에서 예외(exception)가 발생하면, cleanup 코드가 실행될까요?

forof 루프가 break 구문이나 return 구문을 포함하는 상황을 가정해 봅시다. 이 경우에 cleanup 함수가 실행될까요?

실행됩니다. ES6 가 당신을 지켜줍니다.

우리가 이터레이터(iterator)와 forof 루프 구문을 처음 논의했을 때, 이터레이터 인터페이스에는 경우에 따라 호출되는(optional) .return() 메소드가 존재한다고 이야기했습니다. 이 메소드는 이터레이터가 완료를 선언하지 않은 상황에서 루프가 끝날 경우 랭귀지에 의해 반드시 호출됩니다. 제너레이터도 이 메소드를 지원합니다. myGenerator.return() 가 호출되면 제너레이터는 finally 블럭의 코드를 실행하고 종료합니다. 마치 현재의 yield 구문이 알 수 없는 마법에 의해 return 구문으로 바뀌어진 것처럼 말이죠.

.return() 코드가 랭귀지에 의해 항상 호출되는 것이 아니라는 점에 주의합시다. 오직 이터레이터 프로토콜을 사용하는 경우에만 호출됩니다. 따라서 어떤 경우에는 제너레이터의 finally 블럭이 실행되지 않은 채로 제너레이터가 가비지 컬렉터에 의해 수거되는 일도 생길 수 있습니다.

이 상황을 연극으로 표현하면 어떻게 될까요? 제너레이터가 집을 짓는 일처럼 사전 준비가 필요한 작업 도중에 얼어붙습니다. 갑자기 누군가 에러를 던집니다! for 루프가 그 에러를 잡아서 한옆에 치워둡니다. 그리고 for 루프가 제너레이터에게 .return() 을 명령합니다. 제너레이터는 말없이 짓던 집을 무너뜨려 치웁니다. 그러면 for 루프가 에러를 다시 주워들고 나머지 예외처리를 진행합니다.

제너레이터와 주도권

지금까지 관찰한 제너레이터와 그 사용자 사이의 대화는 꽤 일방적이었습니다. 극장 비유 대신 다음 그림을 생각해봅시다.

(A fake screenshot of iPhone text messages between a generator and its user, with the user just saying 'next' repeatedly and the generator replying with values.)

이것은 콜러(caller)가 주도하는 동작 양식입니다. 제너레이터는 요청에 따라 작업을 수행합니다. 하지만 이것이 제너레이터를 이용하는 유일한 방법은 아닙니다.

Part 1 에서, 저는 비동기 프로그래밍에 제너레이터를 사용할 수 있다고 말했습니다. 비동기 콜백이나 Promise 체이닝을 사용해서 하고 있는 일들을 제너레이터로 대신할 수 있습니다. 어떻게 대신하는지 알게 되면 아마 놀랄 것입니다. 어떻게 yield 기능(결과적으로 제너레이터가 가진 단 하나의 특별한 기능)을 써서 비동기 프로그래밍이 가능할까요? 당연한 이야기지만, 비동기 코드는 단순히 값을 리턴하기만 하는 코드가 아닙니다. 비동기 코드는 어떤 일을 일어나게 합니다. 비동기 코드는 파일이나 데이터베이스에 있는 데이터를 요청하거나, 서버에 리퀘스트를 보내 데이터를 요청합니다. 그리고 나서 비동기 코드는 이런 데이터 요청이 종료되는 것을 기다리기 위해 이벤트 루프로 복귀합니다. 제너레이터로 이런 일을 할 수 있을까요? 콜백 없이 제너레이터는 어떻게 파일(또는 데이터베이스) 작업의 결과 또는 서버 작업의 결과를 받을 수 있을까요?

정답을 말하기 전에, 제너레이터의 .next() 구문을 호출하는 코드(caller)가 제너레이터에게 어떤 값을 전달할 수 있다면 무슨 일이 생길지 생각해 봅시다. 단지 이것만으로도 아주 새로운 방식의 대화가 가능해집니다.

(A fake screenshot of iPhone text messages between a generator and its caller; each value the generator yields is an imperious demand, and the caller passes whatever the generator wants as an argument the next time it calls .next().)

이제 제너레이터의 .next() 메소드에 인자를 전달할 수 있습니다. 멋진 점은 이 인자가 제너레이터 입장에서는 yield 구문의 리턴 값처럼 보인다는 것입니다. 이제 yield 구문은 return 구문과 완전히 다른 형태가 됩니다. yield 구문은 제너레이터가 동작을 재개할 때 값을 반환하는 형태가 됩니다.

  var results = yield getDataAndLatte(request.areaCode);

이 한 줄이 많은 일을 합니다.

  • 이 코드는 getDataAndLatte() 함수를 호출합니다. 이 함수가 우리가 스크린샷에서 보는 것처럼 "get me the database records for area code..." 문자열을 리턴한다고 가정합시다.
  • 이 코드는 제너레이터를 멈추고, 문자열 값을 yield 합니다.
  • 이 시점에서, 임의의 시간이 지날 수 있습니다.
  • 결과적으로, 누군가 .next({data: ..., coffee: ...}) 를 호출합니다. 우리는 그 인자로 전달된 객체를 로컬 변수 results 에 저장하고 다음 라인의 코드로 진행합니다.

무슨 말인지 확실히 알기 위해, 위의 대화를 코드로 바꿔봅시다.

function* handle(request) {
  var results = yield getDataAndLatte(request.areaCode);
  results.coffee.drink();
  var target = mostUrgentRecord(results.data);
  yield updateStatus(target.id, "ready");
}

yield 구문이 여전히 예전과 같은 역할을 하는 것에 주의합시다. 즉, 제너레이터를 잠시 멈추고 콜러(caller)에게 값을 전달하는 역할을 합니다. 하지만 무언가 바뀌었습니다! 이 제너레이터는 콜러가 정해진 동작양식을 따라주기를 기대합니다. 이 제너레이터는 콜러가 비서처럼 행동해 줄 것을 기대합니다.

통상적인 함수들은 그런 기대를 하지 않습니다. 통상적인 함수들은 콜러(caller)의 요구를 만족시키기 위해 존재합니다. 하지만 제너레이터는 함께 대화할 수 있는 코드입니다. 바로 그 점 때문에 제너레이터와 콜러는 아주 다양한 관계를 맺을 수 있습니다.

이렇게 비서 역할을 하는 제너레이터-구동 코드(콜러 코드)는 어떻게 구현될까요? 이 코드는 복잡할 필요가 없습니다. 이 코드는 아래와 같을 것입니다.

function runGeneratorOnce(g, result) {
  var status = g.next(result);
  if (status.done) {
    return;  // 휴!
  }

  // 일처리가 끝나면 자신(제너레이터)을
  // 다시 부르도록 제너레이터가 요청함.
  doAsynchronousWorkIncludingEspressoMachineOperations(
    status.value,
    (error, nextResult) => runGeneratorOnce(g, nextResult));
}

공이 굴러가게 하려면, 다음처럼 제너레이터를 만들고 한번 실행시켜야 합니다.

  runGeneratorOnce(handle(request), undefined);

예전에, Q.async() 코드가 제너레이터를 비동기 처리 용도로 활용하는 라이브러리의 표본이라고 이야기한 적이 있습니다. runGeneratorOnce 도 그런 종류의 코드입니다. 실제라면, 제너레이터는 콜러(caller)가 해야 하는 일을 서술하기 위해 문자열을 yield 하지 않을 것입니다. 아마도 Promise 객체를 yield 할 것입니다.

만약 Promise 를 이미 이해하고 있다면, 그리고 지금 제너레이터를 이해했다면, Promise 를 사용해서 runGeneratorOnce 코드를 수정해보세요. 쉬운 일은 아니지만, 일단 스스로 수정해 보면 Promise 를 이용해서 복잡한 비동기 알고리즘을 .then() 이나 콜백 없이 간결하게 작성하는 방법을 배우게 될 것입니다.

제너레이터의 예외처리 방식

runGeneratorOnce 코드가 어떻게 에러를 처리하는지 주의해서 봤나요? 에러를 그냥 무시하고 있습니다!

그냥 무시하는 것은 좋은 방법이 아닙니다. 실제 상황이라면 에러가 발생할 경우 어떻게든 제너레이터에게 보고해야 할 것입니다. 역시나 제너레이터는 에러를 보고할 수 있는 방법을 제공합니다. 당신은 generator.next(result) 구문을 호출하는 대신 generator.throw(error) 구문을 호출할 수 있습니다. 이렇게 하면 yield 구문에서 예외가 발생합니다 (throw 처리됨). .return() 구문처럼 제너레이터는 종료될 것입니다. 덧붙여 만약 현재의 yield 구문이 try 블럭 안에 존재하고 있다면, catch 블럭과 finally 블럭의 구문들이 처리될 것입니다.

.throw() 구문이 적절히 호출되도록 runGeneratorOnce 코드를 수정하는 것도 또다른 연습 과제입니다. 제너레이터 안에서 발생하는 예외(exception)가 언제나 콜러(caller)로 전파된다는 것을 기억하세요. 그래서 generator.throw(error) 구문 이후 제너리에터가 예외 처리를 하지 않을 경우 (error 를 catch 하지 않을 경우), 거꾸로 당신에게 error 가 던져질 것입니다!

제너레이터가 yield 구문을 만나 멈췄을 때 일어날 수 있는 모든 가능성을 요약하면 다음과 같습니다.

  • 누군가 generator.next(value) 구문을 호출합니다. 이 경우, 해당 제너레이터는 멈췄던 바로 그자리에서 다시 코드를 진행할 것입니다.
  • 누군가 generator.return() 구문을 호출합니다 (경우에 따라 .return() 구문을 호출할 때 인자 값을 전달할 수도 있습니다). 이 경우, 해당 제너레이터는 바로 직전에 했던 일과 상관 없이 코드를 진행하지 않습니다. 해당 제너레이터 finally 블럭만 실행합니다.
  • 누군가 generator.throw(error) 구문을 호출합니다. 해당 제너레이터는 마치 yield 구문에서 예외(exception)가 발생해서 error 가 던져진(throw) 것처럼 동작합니다.
  • 또는, 전혀 아무런 호출도 없을 수 있습니다. 해당 제너레이터는 영원히 멈춰 있을 것입니다. (그렇습니다. 제너레이터 코드가 try 블럭에 진입해서 영원히 finally 블럭에 도달하지 못하는 일이 일어날 수 있습니다. 심할 경우, 제너레이터가 이 상태에 머물러 있는 채로 가비지 컬렉터에 의해 수거될 수도 있습니다.)

이건 평범한 함수 호출과 별반 다르지 않습니다. 오직 .return() 구문만 정말 새로운 것입니다.

사실, yield 구문은 일반적인 함수 호출과 아주 많은 공통점을 갖고 있습니다. 당신이 함수를 호출할 때, 당신은 잠시 멈춰집니다. 맞죠? 이때 당신이 호출한 함수가 흐름을 제어합니다. 그리고 함수로부터 리턴될 수도 있고, 예외가 발생할 수도 있습니다. 아니면 무한루프에 빠질 수도 있습니다.

제너레이터 여러개를 한꺼번에 사용하기

한가지 기능만 더 소개하겠습니다. 2개의 이터러블 객체를 이어붙이는 간단한 제너레이터 함수를 작성한다고 가정해 봅시다.

function* concat(iter1, iter2) {
  for (var value of iter1) {
    yield value;
  }
  for (var value of iter2) {
    yield value;
  }
}

ES6 는 이런 코드를 위한 간략한 표현법을 제공합니다.

function* concat(iter1, iter2) {
  yield* iter1;
  yield* iter2;
}

평범한 yield 구문은 1개의 값을 yield 합니다. yield* 구문은 이터레이터를 전부 구동시켜 모든 값들을 yield 합니다.

동일한 문법으로 재미있는 문제를 해결할 수 있습니다. 제너레이터 안에서 제너레이터를 호출하는 문제입니다. 평범한 함수라면, 우리는 함수 하나를 긴 코드 덩어리로 구현할 수 있습니다. 그리고 정상적인 동작을 유지하면서 그 함수를 여러개의 작은 함수로 분리시키는 리팩토링을 할 수 있습니다. 분명히 제너레이터도 리팩토링하고 싶어질 때가 있을 것입니다. 그러러면 우리에게 분할된 서브루틴 제너레이터를 호출하는 방법이 필요합니다. 그리고 서브루틴 제너레이터의 모든 값이 yield 되는 것을 보장하는 방법이 필요합니다. 물론 서브루틴 제너레이터를 호출하는 측(본체 제너레이터)도 모든 값을 yield 해야 합니다. yield* 구문이 바로 그런 필요를 위해 사용하는 방법입니다.

function* factoredOutChunkOfCode() { ... }

function* refactoredFunction() {
  ...
  yield* factoredOutChunkOfCode();
  ...
}

양철 로봇 하나가 세부 작업들을 다른 양철 로봇에게 전달하는 것을 생각해 보세요. 규모가 큰 제너레이터 프로젝트를 진행할 때, 이 아이디어가 코드를 깔끔하고 구조적으로 유지하는 데 얼마나 큰 도움이 되는지 알 수 있을 것입니다. 바로 함수가 복잡한 코드를 구조화하는 데 도움이 되는 것처럼 말입니다.

마치며

그래요, 제너레이터에 대해서는 여기까지 입니다! 제가 즐거웠던 것만큼 당신도 즐거우셨기를 바랍니다. 다시 만나니 반갑군요.

다음 글에서 또다시 어려운 주제를 다룰 것입니다. ES6 에 처음으로 도입된 객체입니다. 아주 미묘하고, 아주 까다로운 객체라서 알고도 사용하지 않을지 모릅니다. EX6 proxy 에 대해 살펴볼 다음 글도 함께 해주세요.

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

작성자: ingeeKim

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

ingeeKim가 작성한 문서들…


댓글이 없습니다.

댓글 쓰기