Javascript

제너레이터(Generators) 란? :: 마이구미

mygumi 2020. 3. 23. 23:53
반응형
이 글은 ES6 에서 도입된 제너레이터(Generators) 를 다룬다.
사용법보다는 본인이 궁금했던 도입된 이유, 실행 컨텍스트 현황, 사용 이점을 살펴본다.
사용법이나 필요한 용어 및 지식은 크게 다루지 않으니 필요하다면, 다른 글에서 참고하길 바란다.

 

제너레이터는 ES6 에서 새롭게 도입된 개념이다.

기본적인 형태는 다음과 같다.

 

function* myGenerator {
    yield "1";
    yield "2";
}

const it = myGenerator();
it.next();

 

제너레이터의 핵심은 동시성 프로그래밍이다.

비동기 프로그래밍에도 많은 도움을 주지만, 여기서는 동시성(concurrency)을 중점으로 다뤄보려고 한다.

 

동시성이란 무엇인가?

들어갈수록 쉽게 이해할 수 없는 내용이기에 간략히 쉽게 설명해보려고한다.

동시성은 동시에 여러가지 일을 처리하는 것처럼 행동하는 것이다.

병행(concurrency)과 병렬(parallelism)을 혼동하면 안된다.

병렬은 실제로 동시에 여러가지 일을 처리하는 것이다.

 

동시성의 하나의 예로는, 자바스크립트는 알다시피 싱글 스레드이고, 그렇다면 한번에 한가지 일밖에 못한다.

하지만 우리는 자바스크립트가 한번에 여러가지 일을 처리한다는 것을 알고 있다.

ajax 요청을 한다고해서, 응답이 올때까지 시스템은 멈추지 않는다라는 것을 알고 있다.

결론적으로 자바스크립트는 싱글 스레드이지만, 이벤트 루프를 통해 동시성을 지원하고 있다.

 

조금 더 와닿는 이해를 위해 제너레이터를 통해 확인해보자.

일반적인 함수의 흐름은 "Run To Completion" 이라고 표현한다.

알다시피 일반적으로 함수가 실행되면 함수가 종료될 때까지 동작한다.

함수가 실행되는 순간 종료될때까지 제어할 수 있는 방법은 없다.

 

하지만 제너레이터는 이것이 가능하다.

호출자에서 모든 제어권을 가지지 않는다.

호출자는 제어권을 제너레이터에 넘긴다.

그리고 제너레이터는 원하는 시점에 다시 호출자에게 제어권을 넘길 수 있다.

 

 

위 그림처럼 시작과 종료를 원하는 대로 제어할 수 있다.

즉, 함수 실행 도중 멈출 수 있고, 한번이든 두번이든 상관없이 다시 이어서 호출할 수 있다.

 

function* myGenerator() {
    // do something
    const first = yield: "1";
    // do something
    const second = yield: "2";
    // do something
    const third = yield: "3";
    console.log(first, second, third); // a b c
}

const it = myGenerator();

it.next('a'); // {value: 1, done: false}
.....
it.next('b'); // {value: 2, done: false}
.....
it.next('c'); // {value: 3, done: true}

 

제너레이터 함수를 실행하면 아무것도 일어나지 않고 Iterator 객체를 반환한다.

it.next() 를 실행하면 제어권은 제너레이터 함수로 넘어간 후, yield 를 만날때까지 실행된다.

yield 를 만나면 중단되고, 다시 호출자에게 제어권을 넘긴다.

다시 next() 를 실행하면 이어서 yield 를 만날때까지 실행된다.

그러다가 더이상 yield 가 없으면 종료된다.

일반적인 함수의 "Run to Completion" 과 달리 제너레이터 함수는 "Run..Stop..Run" 이라고 표현된다.

 

결론적으로 함수는 스스로 실행과 중단할 수 있는 제어권을 가지게 된다는 점이 중요하다.

스스로 제어권을 가지게 되면, 다른 코드와 함께 "협력적인" 행동이 가능해진다는 것을 상상할 수 있다.

이를 활용하면 동시성 프로그래밍을 할 수 있다.

 

두 명의 아이의 옷을 입힌다고 생각해보자.

옷은 순서대로 입힐 것이고, 번갈아가면서 옷을 입혀줄 것이다.

 

function* firstChild() {
    yield shirt();
    yield pants();
    yield shocks();
}

function* secondChild() {
    yield shirt();
    yield pants();
    yield shocks();
}

function mom() {
    const first = drawGenerator();
    const second = drawGenerator();
    
    first.next();
    second.next();
    
    ......
}

 

(실제로 위 예제는 제대로 동작하지 않는다. 더 많은 내용이 필요하기에 어떤 흐름인지만 파악해주면 된다)

시스템은 first.next() 를 실행한다.

첫째 아이의 옷을 입히기를 시작한다. firstChild() => shirt()

yield 을 통해 이 과정은 완료되어도 더이상 아래 코드는 진행되지 못하고 중단될 것이다.

그리고 프로세스는 다른 작업을 진행하기 위해 firstChild 를 빠져나와 다음 코드 second.next() 를 실행한다. secondChild() => shirt()

그러다가 첫째의 셔츠를 입힌 것이 완료되면 제어권을 넘겨받아 다시 바지를 입히기 위해 호출할 것이다.

내부적으로는 한 번에 한개의 작업만을 하고 있지만, 눈으로는 두 가지를 병행하는 것처럼 보여지게 되는 것이다.

 

위 과정을 실행 컨텍스트를 통해 조금 더 자세히 알아보자.

next() 가 호출되면 제너레이터의 콜 스택에 올라간다.

 

 

그리고 yield 를 만나서 중단되면 다음과 같다.

 

 

 

콜 스택에서 빠져나오지만, 소멸되지 않는다.

디버깅 확인 시에도 중단된 시점에도 제너레이터의 스코프는 유지되고 있다.

핵심은 실행 컨텍스트는 소멸되지 않고, 참조를 통해 유지되고 있다.

위 그림을 실행과 중단을 통해 반복되는 것이다.

done 이 true 가 되는 시점에 gen 은 어느 스코프에도 유지되지 않게 된다.

 

마지막으로 개인적으로 좋았던 예제는 다음과 같다.

예제는 랜덤값을 가진 큰 사이즈의 배열을 생성한 후, 순환한다.

 

function memoryUsedKB() {
    return Math.round(window.performance.memory.usedJSHeapSize * 100 / 1024) / 100;
}

function main(functionToExec, max = 2000000) {
    const beforeKB = memoryUsedKB();
    console.log('Memory before: ', beforeKB);
    let amount = 0;
    for(let item of functionToExec(max)) {
        amount += 1;
    }
    // debugger

    const afterKB = memoryUsedKB();
    console.log('Memory after: ', afterKB);
}

function* bigAmountOfItemsGenerator(amount) {
    for(let i = 0; i < amount; i += 1) {
        yield Math.round(Math.random() * i);
    }
}

function bigAmountOfItems(amount) {
    const array = [];
    for(let i = 0; i < amount; i += 1) {
        array.push(Math.round(Math.random() * i))
    }
    return array;
}

// main(bigAmountOfItemsGenerator);
// main(bigAmountOfItems);

 

일반 함수(bigAmoutOfItems) 는 단순히 필요한 배열을 모두 생성하기 위해 위와 같이 구현되었다.

하지만 동일한 목적으로 제너레이터 함수는 위처럼 구현될 수 있다.

 

예제에서의 제너레이터 함수와 일반 함수의 메모리 차이는 크다.

일반 함수는 계속해서 배열을 늘려가면서 유지시키고 있지만, 제너레이터 함수는 루프마다 필요한 값만을 반환해주고 있다.

amount 가 클수록 메모리 차이는 커질 수 밖에 없다.

debugger 을 걸어주지 않으면, 콘솔창에서 원하는 값을 제대로 보지 못할 것이다.

 

동시적 프로그래밍을 중점으로 글을 정리해보았다.

언급했듯이 제너레이터는 비동기 프로그래밍에 많은 도움을 준다.

많이 알려진 ES7 에서 도입된 async/await 도 제너레이터를 기반으로 한다.

여기에 대한 것은 추후에 따로 다뤄볼 예정이다.

 

참고 링크
https://wooooooak.github.io/kotlin/2019/08/25/%EC%BD%94%ED%8B%80%EB%A6%B0-%EC%BD%94%EB%A3%A8%ED%8B%B4-%EA%B0%9C%EB%85%90-%EC%9D%B5%ED%9E%88%EA%B8%B0/
https://codeburst.io/generators-in-javascript-1a7f9f884439
https://davidwalsh.name/es6-generators
https://levelup.gitconnected.com/how-i-met-your-javascript-generators-reduce-memory-used-on-your-browser-and-server-8ed2c5077d5c

반응형