JS #11. 자바스크립트 제너레이터(Generator)
Prog. Langs & Tools/JavaScript

JS #11. 자바스크립트 제너레이터(Generator)

이전 포스팅 - 자바스크립트 프로미스(Promise)

이전 포스팅에서 콜백을 통해 비동기 흐름을 어떻게 제어할 수 있을지에 대한 방법들을 이야기 했고, 프로미스를 통해 믿음성/조합성을 살피면서 제어의 역전을 되역전하는 방법을 살펴보았다. 그리고 이번 포스팅에서는 비동기 흐름 제어를 순차적/동기적으로 어떻게 나타낼 수 있을지에 대한 방법을 고민해 본다.

기존 ES5까지는 자바스크립트에서 함수가 실행되기 시작하면 완료될 때 까지 계속 실행되며 도중에 다른 코드가 끼어들어 실행되는 법은 없다고 개발자들은 생각했었다. 하지만 ES6부터 이러한 완전-실행 법칙을 따르지 않는 새로운 종류의 함수, 제너레이터가 등장하였다.

여기서 bar()는 x++와 console.log() 사이에서 실행된다. 하지만 만약 bar()가 이처럼 명시되지 않고도 bar()가 실행될 수 있다면 어떻게 될까? 만약 선점형(preemptive) 멀티스레드 언어라면 일반적으로 두 문 사이의 특정 시점에 bar()가 실행될 수 있지만, 자바는 선점형 언어도 아니고 멀티스레드 언어도 아니다. 하지만 만약 foo() 함수 자체가 멈춤 신호를 줄 수 있다면 이러한 인터럽트를 협동적(cooperative) 동시성 형태로 나타낼 수가 있다. 

협동적 동시성을 달성한 es6 코드는 아래와 같다.

yield 지점에서 bar()는 아래와 같이 실행이 된다.

위의 흐름을 하나씩 자세하게 설명을 해 보면

  1. 첫 번째 it.next()가 *foo() 제너레이터를 시작하고 *foo() 첫째 줄의 x++가 실행된다.
  2. 첫 번째 it.next()가 완료되는 yield 문에서 *foo()는 멈춘다. 
  3. 이 때 x 값은 2로 출력된다.
  4. bar()를 호출하면 x 값은 3이 된다. 따라서 그 다음에는 x는 3으로 출력된다.
  5. 마지막 it.next() 호출부에 의해 *foo() 제너레이터는 좀 전에 멈췄던 곳에서 재개되어 console.log()를 실행한다.

제너레이터는 1회 이상 시작/실행을 거듭할 수 있으면서 반드시 끝까지 실행할 필요는 없는 특별한 함수를 말한다. 제너레이터는 비동기 흐름을 제어하는 측면에서 유용하게 활용이 될 것이며, 이러한 관점에서 코드를 바라보면 도움이 된다.

 

제너레이터는 일반적인 함수처럼 인자를 입력받고 값을 반환하는 출력은 동일하게 기능한다.

반면 제너레이터가 일반 함수와 다른 점은 제너레이터를 제어하는 '이터레이터' 객체를 만들어서 별도로 할당하고(여기서는 변수 it) it.next() 해야 제너레이터가 현재 위치에서 다음 yield 또는 제너레이터 끝까지 실행할 수 있다는 점이다.

그리고 next()의 결괏값은 *foo()가 반환한 값을 value 프로퍼티에 저장한 객체이다. 즉, yield는 실행 도중 제너레이터로부터 값을, 일종의 중간 반환 값 형태로 돌려준다.

인자값을 받아 결괏값을 내는 기능 이외에도 제너레이터는 yield와 next()를 통해 입/출력 메시지를 주고받는 기능이 있다.

위의 예제에서 본 코드를 설명을 하면 먼저 line 9에서 *foo()를 시작한다. 그리고 *foo()에서 var y = x ... 문이 처리될 즈음 yield 표현식에서 걸린다. 여기서 *foo()는 실행을 멈추고 yield 표현식에 해당하는 결괏값을 달라고 호출부 코드에 요청한다. 그리고 it.next(7)을 호출하면 7이 yield 표현식의 결괏값이 되도록 전달한다. 따라서 결과적으로 할당문은 var y = 6 * 7이 되어서 return y 하면 결괏값 42를 반환하게 되는 것이다.

 

그렇다면 제너레이터가 비동기 코딩 패턴과 무슨 상관이 있고, 어떻게 콜백 문제를 해결할 수 있다는 것인가? 이에 대해서 지금부터 설명해 보고자 한다.

콜백식으로 비동기 호출을 하는 예제 코드이다.

위의 흐름을 제너레이터를 사용해서 표현한 코드는 아래와 같다.

이렇게 제너레이터를 쓰게 되면 기존에 비동기 함수(ajax)로 받은 text를 보내주지 못했던 문제를 제너레이터의 yield를 통해 깔끔하게 해결할 수 있다. yield로 코드를 한 번 중단한 뒤 필요한 데이터가 받아지면 다시 코드를 재개한다.

또한 에러 처리를 할 때에도 yield를 통해서 에러를 잡을 수 있게 기다려 준다. 그리고 에러가 발생할 경우 제너레이터에 에러를 던진다.

 

제너레이터는 프로미스와 같이 사용하여 그 효과를 극대화 할 수 있다. foo() 함수에서 프로미스를 생성하면 제너레이터에서 yield 해서 이터레이터 제어 코드를 통해 프로미스를 받게 할 수 있다. 이터레이터는 프로미스가 귀결(.then(), 이룸/버림) 되기를 기다리고 있다가 제너레이터를 이룸 메시지로 재개하든지 아니면 제너레이터로 버림 사유로 채워진 에러를 던진다.

이 기능은 ES7의 async/await 으로도 구현할 수 있다.

 

정리하면...

제너레이터는 ES6부터 도입된 새로운 유형의 함수로, 일반 함수처럼 완전-실행하지 않고 실행 도중 (상태 정보를 그대로 간직한 채) 멈출 수도 있고 멈춘 지점에서 나중에 다시 시작할 수도 있다. 멈춤/재개가 번갈아 일어나므로 제너레이터는 선점적이라기보다는 협동적인 툴이다. yield 키워드를 이용하여 스스로 멈출 수 있고 이 제너레이터를 제어하는 이터레이터는 (next()를 호출하여) 제너레이터를 다시 시작할 수 있다.

yield/next() 이중성은 제어 장치 뿐만 아니라 양 방향 메시징 체계로도 실질적인 활용이 가능하다. yield ... 표현식은 일단 멈추고 어떤 값을 기다리게 하고, next() 호출은 이렇게 멈춘 yield 표현식에 값(또는 undefined)을 전해준다.

비동기 흐름 제어와 연관된 제너레이터의 핵심은 제너레이터 내부 코드가 동기/순차적 형태로 일련의 작업단계를 자연스럽게 표현할 수 있는 능력이다. 그 비결은 바로 yield에 숨겨진 잠재적 비동기성에 있다. 즉, 제너레이터의 '이터레이터'가 제어하는 코드로 비동기성을 옮겨놓은 것이다.

결과적으로 제너레이터는 비동기 코드의 순차/동기/중단 패턴을 유지함으로써 개발자들이 훨씬 더 코드를 자연스럽게 이해할 뿐 아니라 콜백식 비동기 코드의 치명적인 단점또한 해결할 수 있게 해준 고마운 존재이다.

 

참고자료

  1. <YOU DON'T KNOW JS (this와 객체 프로토타입, 비동기와 성능)> 카일 심슨 저