Generator

raccoonback·2020년 6월 28일
1

javascript

목록 보기
9/11
post-thumbnail

이 글은 You Don't Know JS 서적을 참고하여 작성하였습니다.

제너레이터추론/실용적이고 동기/순차적 형태비동기 흐름 제어를 가능하게 해주는 기능이다.
즉, 제너레이터내부 코드동기/순차적 형태인 일련의 작업 단계자연스럽게 표현할 수 있게끔 도와준다.

실행 제어권의 유동성

일반적인 함수는 실행될 때 제어권을 호출부에 넘기지 않고 완전하게 실행된다.

선점형 멀티스레드 환경에서는 스레드의 제어권이 바뀌면서 중간에 다른 함수가 실행될 수는 있겠으나, 자바스크립는 단일 스레드이다.

하지만, 자바스크립트는 협동적 동시성을 달성하기 위해 제너레이터 기능을 제공한다.

제너레이터 선언하는 경우, 함수 앞에 * 키워드를 추가하는 것 이 외에는 기본적인 함수 체계와 동일하다.(제너레이터도 함수이다.)

let global = 0;

function *foo() {
    global++;
    yield;
    console.log(global);
}

function bar() {
    global++;
}

const iter = foo();

iter.next();
bar();
iter.next();
// 2

그럼 위 예제를 통해 제어권이 어떻게 이동하는 지 흐름을 살펴보자.

  1. foo() 함수 호출은 단순히 *foo() 제너레이터를 실행하는 것이 아닌, 제너레이터 실행을 제어할 iterator를 반환한다.
  2. iteratornext() 함수를 호출하면, *foo() 제너레이터를 yield 키워드까지 실행을 하고 멈춰 대기한다.
  3. 그 다음 iteratornext() 함수를 호출하면, yield 키워드 부분에서 멈춰있던 *foo() 제너레이터를 함수 종료(반환)할 때까지 실행하고 끝마친다.

위와 같은 과정에서, 제너레이터는 실행 중에 yield 문에서 잠시 중지하고 대기하는 것을 확인할 수 있다.

뿐만 아니라, 제너레이터 함수 호출시 제너레이터를 제어하는 Iterator 객체를 생성해 반환하는데 next() 함수를 이용해 제너레이터의 현재 위치에서 다음 yield 또는 제너레이터 끝까지 실행한다.

next() 함수의 반환값은 제너레이터 함수가 반환한 값을 value 프로퍼티에 저장한 객체이다.

{
	value: "반환값"
}

yeild 문은 중간 반환값next() 함수의 반환값으로 전달하고, 동시에 제어권을 next() 호출부로 넘긴다.

이러한 부분들이 가능한 것은 제너레이터yield, next()라는 강력한 입출력 기능을 가지고 있기 때문이다.

아래의 예제를 살펴보자.

function *foo(x) {
    const y = x * (yield);
    return y;
}

const iter = foo(6);

iter.next();

const res = iter.next(7);

console.log(res.value);
// 42

iter.next() 함수 호출은 첫 번째 yield문까지 실행하고 대기한다.

이후 두 번째 iter.next(7) 함수가 '7' 인자를 yield 표현식의 결과값으로 전달하면, 함수 실행은 마지막(다음 yield)까지 진행되고 최종 반환값인 '42'를 전달받는다.

이를 통해 일반화해보자.

yield 표현식next() 호출에 대응하여 메시지를 보낼 수 있고(next() 반환값으로 전달됨), next()는 멈춘 yeild 표현식에 값을 전달할 수 있다. 즉, 제너레이터양방향 메시지 시스템이다.

아래의 예제를 보자.

function *foo(x) {
    const y = x * (yield  "hello world");
    return y;
}

const iter = foo(6);

const first = iter.next();

console.log(first.value);
// second

const second = iter.next(7);

console.log(second.value);
// 42

iter.next() 함수는 첫 번째 yield까지 실행하고, yield 표현식이 전달한 "hello world"를 반환한다.

iter.next(7) 함수는 이전과 동일하게 동작한다.

제너레이터 기능을 통해서 제어권이 유동적으로 변하게 되므로써, yield 표현식 적용 시점에 따라 스코프안에 변수 값들의 평가 기준이 달라지므로 유의해야 한다.

아래 예시를 살펴보자.

let value = 3;

function* foo() {
    const a = (yield 2) * value;
    return a;
}

function* bar() {
    const a = value * (yield 2);
    return a;
}

const fooIter = foo();
const barIter = bar();

console.log(fooIter.next()); // 2
console.log(barIter.next()); // 2

// 값 변경
value = 5;

// value는 평가 이전이기 때문에,
// next() 실행시 value 값으로 평가된다.
console.log(fooIter.next(2)); // 10

// 이전 next() 함수 실행에서 value는 3으로 평가가 되었기 때문에 
// next() 실행시 value는 이전값인 3으로 평가된다.
console.log(barIter.next(2)); // 6

마지막에 호출되는 barIter.next(2) 함수는 전역 변수인 value 값이 5이므로 반환값 역시 10으로 예상했을 것이다.

하지만, 6이 출력되는데 무슨일이 벌어진 것일까?

fooIter.next(2) 함수부터 하나씩 살펴보자.

자바스크립트에서 평가 시점은 메모리 공간에 주소를 가진 변수에 값을 할당하는 시점을 말한다. 또한, 평가위 => 아래, 좌 => 우 방향으로 이루어진다.

fooIter.next() 함수가 호출된 시점의 foo() 함수를 평가해보면, (yield 2) * value 코드의 value 변수값을 평가하기 이전에 yield 표현식으로 부터 next() 함수가 종료된 것이다.

따라서, 다음 fooIter.next(2) 함수 호출에서 현재 시점의 value 값인 5가 평가되어 a 변수에는 10이 할당된다.

다음으로, barIter.next() 함수가 호출된 시점의 foo() 함수를 평가해보자.

value * (yield 2) 코드는 다음 barIter.next(2) 함수 호출 이전에, value 변수값이 3으로 평가가 된다.

따라서, 현재 value가 5로 변경됐을 지라도 이전에 평가된 value 값인 3으로 실행이 이루어지며 변수 a에는 6이 할당된다.

값을 제너레이터링

이전과 현재 상태 값의 특별한 규칙을 적용해 일련의 값을 생산하기 위해서 일반적으로 아래와 같이 함수 클로저를 많이 이용한다.

const foo = (function () {
    let value = 1;
    return function () {
        return value = (value * 3) + 6;
    }
})();

console.log(foo());
console.log(foo());
console.log(foo());
console.log(foo());
// 9
// 33
// 105
// 321

iterator

사실 이러한 작업은 iterator로 해결 가능하다.

그럼 여기서 iterator에 대해서 짚고 넘어가보자.

iterator는 생산자로부터 일련의 값들을 받아 하나씩 처리하기 위한 명확한 인터페이스이다. 이에 따라, 자바스크립트에서 iterator 인터페이스는 다음 값이 필요할때마다 iterator에서 next()를 호출한다.

다음은 생산기에 표준 iterator 인터페이스를 적용한 예제이다.

const foo = (function () {
    let value = 1;
    return {
        [Symbol.iterator]: function () {
            return this;
        },
        next: function () {
            value = (value * 3) + 6;
            return {done: false, value}
        }
    }
})();

console.log(foo.next().value);
console.log(foo.next().value);
console.log(foo.next().value);
console.log(foo.next().value);
// 9
// 33
// 105
// 321

next() 함수를 호출하면 done, value 프로퍼티를 가진 객체를 반환하는데, doneiterator 완료상태를 나타내고 value는 순회값을 의미한다.

ES6부터는 for...of 구문을 제공하여 표준 iterator를 자동으로 루프를 돌릴 수 있다.

for (let value of foo) {
    console.log(value);
    if (value > 500) {
        break;
    }
}
// 9
// 33
// 105
// 321
// 969

위와 예제의 경우에는 done 프로퍼티가 항상 false 상태로 무한 반복하기 때문에 value 값을 이용해 루프를 멈췄다. 하지만, for...ofnext()가 반환하는 객체의 done 프로퍼티가 true인 경우에는 자동으로 반복을 중단한다.

const foo = (function () {
    let value = 1;
    return {
        [Symbol.iterator]: function () {
            return this;
        },
        next: function () {
            value = (value * 3) + 6;
            if (value > 500) {
                return {done: true, value: undefined}
            }

            return {done: false, value}
        }
    }
})();

for (let value of foo) {
    console.log(value);
}
// 9
// 33
// 105
// 321

iterable

ES6부터 iterable라는 개념이 나오기 시작한다.

iterable 객체라는 것은 순회 가능한 iterator를 포괄한 객체를 의미한다.

또한, iterable 객체iterator 객체를 가져오기 위해 Symbol.iterator 심볼값을 가진 함수를 포함하고 있어야만 한다.

앞 예제에서 for...of 동작을 자세히 살펴보면, 처음에 Symbol.iterator 함수를 통해 iterator 객체를 가져와 반복분을 수행한다.

for (let iterator = foo[Symbol.iterator](), ret = iterator.next(); !ret.done; ret = iterator.next()) {
    console.log(ret.value);
}

이전 예제에서 아래와 같이 foo 객체가 Symbol.iterator 함수 선언한 것을 보았을 것이다.

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

이는 Symbol.iterator 심볼값을 통해 iterator를 제공하기 위한 인터페이스로, foo 객체를 iterable로 만들어준다.

즉, foo는 iterator 이면서 iterable 객체인 것이다.

또한, for...of 구문은 전달된 객체가 iterable이라는 전제하에, 처음에 Symbol.iterator 함수를 통해 iterator 객체를 가져와 루프를 돈다.

제너레이터 iterator

제너레이터는 일종의 값을 생산하는 공장이며, 이렇게 만들어진 값들은 iterator 인터페이스의 next() 함수를 호출하여 한번씩 추출할 수있다.

제너레이터iterable은 아니지만, 함수를 실행하면 흡사하게도 iterator를 반환받는다.

function *foo() {}

const iterator = foo();

제너레이터 이용해 iterable에서 살펴봤던 예제를 재구성해보자.

function *foo() {
    let value = 1;
    while (true) {
        value = (value * 3) + 6;
        return yield value;
    }
}

제너레이터**yield를 만나면 일단 멈추기 때문에 function *foo() 상태인 스코프는 유지**된다.

무한 반복을 하게 되면 자칫 동기적인 상황에서는 다른 작업에 영향을 미칠 수 있지만, 제너레이터yield 표현식을 만나면 메인 프로그램 또는 EventLoop Queue에 제어권을 넘기기 때문에 염려할 필요가 없다.

이전 코드에 비해, 보다 간결해지고 직접 iterator 인터페이스를 작성할 필요가 없어진 것을 알 수 있다.

그럼 for...of 구문에 제너레이터를 사용하면 어떻게 될까?

for (let value of foo()) {
    console.log(value);
    if (value > 500) {
        break;
    }
}
// 9
// 33
// 105
// 321
// 969

제너레이터iterable 하지 않으므로, 제너레이터 함수 호출을 해서 for...of가 사용할 iterator를 전달하였다.

그런데, for...of가 원하는 것은 iterable 객체가 아니던가?

올바른 추측이다.

제너레이터 함수가 반환하는 iterator 객체Symbol.iterator 함수를 가지고 있기 때문에, 제너레이터iteratoriterable 객체인 것이다.

제너레이터 멈춤

제너레이터iterator 인터페이스에는 break, return, 잡히지 않은 예외로 인해 비정상 완료되는 상황을 위해 iterator에 신호를 보낼 수 있는 함수를 제공한다.

function *foo() {
    try {
        let value = 1;
        while (true) {
            value = (value * 3) + 6;
            yield value;
        }
    } catch (error) {
        console.error(error);
    } finally {
        console.log("정리 완료");
    }
}

const iterator = foo();
for (let value of iterator) {
    console.log(value);
    if (value > 500) {
        const last = iterator.return("종료");
        console.log(last);
    }
}
// 9
// 33
// 105
// 321
// 969
// 정리 완료
// { value: '종료', done: true }

위 예제에서는 iteratorreturn() 함수를 이용해 iterator에 종료 신호를 전달하여 제너레이터 실행을 중단하였다. 따라서 제너레이터finally 문으로 넘어가는 것을 확인할 수 있다.

또한 iterator.return("종료") 함수에서 보시다시피, iterator.return() 함수는 전달받은 인자를 포함하고 done: true{ done: true, value: '종료' } 객체를 반환한다. 따라서, 결국 다음 순회에서 for..of 반복문은 종료하게 된다.

또한, iteratorthrow() 함수를 이용하면 제너레이터 함수의 yield 표현식에서 Error를 발생시킨다.

function *foo() {
    try {
        let value = 1;
        while (true) {
            value = (value * 3) + 6;
            yield value;
        }
    } catch (error) {
        console.error(error);
    } finally {
        console.log("정리 완료");
    }

    return "종료합니다....";
}

const iterator = foo();
for (let value of iterator) {
    console.log(value);
    if (value > 500) {
        const last = iterator.throw("에러!");
        console.log(last);
    }
}
// 9
// 33
// 105
// 321
// 969
// 에러!
// 정리 완료
// { value: '종료합니다....', done: true }

throw() 함수는 return() 와 다르게 인자에 Error를 전달한다.

또한, throw() 함수는 아직 처리해야 할 반복 작업(yield, 함수 종료(return)은 미포함) 없다면 done: true인 객체를 반환하고, 반복 작업이 남아 있다면 done: false인 객체를 반환한다.

function *foo() {
    try {
        let value = 1;
        while (true) {
            value = (value * 3) + 6;
            yield value;
        }
    } catch (error) {
        console.error(error);
    } finally {
        console.log("정리 완료");
    }

    yield "마지막 한번 더 호출합니다!";
    return "종료합니다....";
}

const iterator = foo();
for (let value of iterator) {
    console.log(value);
    if (value > 500) {
        const last = iterator.throw("에러!");
        console.log(last);
    }
}

// 9
// 33
// 105
// 321
// 969
// 에러!
// 정리 완료
// { value: '마지막 한번 더 호출합니다!', done: false }

제너레이터 함수의 return 문{ value: 'return 값', done: true }next() 함수에 전달하고, return 문이 없는 경우에는 { value: undefined, done: true }를 전달한다.

throw() 함수를 이용하면, 제너레이터 외부에서 내부로 에러를 던질 수도 있지만, 양방향 메시지 시스템에 맞게 제너레이터 내부에서 발생한 에러를 외부로 던질 수도 있다.

function* foo() {
    yield "First";
    throw "에러";
}

const it = foo();
console.log(it.next().value);
try {
    it.next();
} catch (err) {
    console.error(err);
}
// First
// 에러

여기서 한번 더 생각해보자.

만약 내부에서 try ~ catch 문을 사용하지 않아서 내부로 전달한 에러를 처리하지 못한다면 어떻게 될까?

throw() 함수로 전달한 에러를 제너레이터에서 잡지 못한다면 다시 외부로 전달되기 때문에, iterator.throw() 함수 호출하는 코드에서 필히 처리해야만 한다.

function* foo() {
    yield "First";

    // 에러가 전달됐는데 내부에서는 처리할 수 없어 
    // 외부로 에러를 다시 던진다.
    // 따라서 아래 코드는 실행되지 않는다.
    yield "Second";
    return "Third";
}

const it = foo();
console.log(it.next().value);
try {
    it.throw("에러");
} catch (err) {
    console.error(err);
}
// First
// 에러

참고 자료

제너레이터를 비동기적으로 순회

function getRandomNumberDelayedBy(time, cb) {
    if (time > 5000) {
        setTimeout(cb, 0, Error("5초이상 지연할 수 없습니다."));
    } else {
        setTimeout(cb, time, null, Math.floor(Math.random() * 100));
    }
}

function foo(time) {
    getRandomNumberDelayedBy(time, function (error, number) {
        if (error) {
            iterator.throw(error);
        } else {
            iterator.next(number);
        }
    });
}

function *main() {
    try {
        const time = Math.floor(Math.random() * 10000);
        console.log("Delay Time", time);

        const randomNumber = yield foo(time);
        console.log("Random Number", randomNumber);
    } catch (error) {
        console.error(error);
    }
}

const iterator = main();
iterator.next();

위 예제를 통해서 비동기로 동작하는 함수인 getRandomNumberDelayedBy제너레이터 이용해 제어하는 과정을 살펴보자.

가장 핵심적인 코드를 살펴보자.

  const randomNumber = yield foo(6000);
  console.log("Random Number", randomNumber);

foo() 함수를 호출하고 랜덤으로 생성된 숫자를 randomNumber 변수에 할당하는 코드이다.

foo() 함수는 내부적으로 getRandomNumberDelayedBy 함수를 호출하는데, 이 함수는 비동기적인 setTimeout() 호출한다.

이전에는 비동기적으로 동작하는 함수를 연결하기 위해 foo() 함수 인자로 콜백 함수를 전달했어야만 했다. 즉, 비동기 함수를 동기적인 방식의 코드로 작성할 없었던 것이다.

const randomNumber = getRandomNumberDelayedBy(6000);
console.log("Random Number", randomNumber); 

하지만 제너레이터 이용하면 겉으로 보기에 동기적으로 동작하는 것처럼 보이지만, iteratoryield 표현식에 의해 비동기적으로 동작하는 코드를 작성하고 제어할 수 있다.

자 이제 위 예제의 동작 순서를 곱씹어보자.

처음 iterator.next() 함수가 호출되면 yield foo(time) 부분까지 실행이 된다. 여기서 foo() 함수가 호출되고 yield 표현식은 next() 함수에 undefined를 전달한다. yield 표현식은 단순히 흐름 제어 수단으로만 사용한 것이다.

이후, 랜덤한 숫자 생성한 직후에 iterator.next(number) 함수를 호출하는데, 제너레이터는 멈췄던 yield 표현식에서 부터 실행을 재개한다. 또한, yield 표현식은 next(number)부터 전달받은 numberrandomNumber 변수에 할당하는 것이다.

따라서 제너레이터 이용해 비동기성을 제어하면, 내부적으로 비동기로 작동하는 함수를 외부에서는 동기/순차적으로 흐름 제어할 수 있게 된다.

구체적으로, 제너레이터yield-멈춤 기능은 비동기 함수 호출로부터 넘겨받은 값을 동기적인 형태로 반환해줄 뿐만 아니라, 비동기 함수 실행중 발생한 에러를 동기적으로 catch()할 수 있게 도와준다.

제너레이터 + 프라미스

이전 랜덤한 숫자를 비동기적으로 생성하는 코드는 제너레이터를 단순히 흐름 제어로서 사용했었다.

하지만, 제너레이터양방향 메시징 시스템로서 메시지, 즉 프라미스의 Resolve한 값을 전달할 수 있다.

iterator는 프라미스가 Resolve(귀결)되기를 리스닝하고 있다가 제너레이터Fulfillment 메시지로 재개하든지 아니면 Rejection 메시로 에러를 전달할 수 있다.

구체적으로, 프라미스를 yield하여 귀결될 때까지 대기하고 해당 프라미스로 제너레이터iterator를 제어하는 것이다.

function getRandomNumberDelayedBy(time) {
    return new Promise(function (resolve, reject) {
        if (time > 5000) {
            reject("5초이상 지연할 수 없습니다.");
        } else {
            setTimeout(resolve, time, Math.floor(Math.random() * 100));
        }
    });
}

function *foo() {
    try {
        const time = Math.floor(Math.random() * 10000);
        console.log("Delay Time", time);

        const randomNumber = yield getRandomNumberDelayedBy(time);
        console.log("Random Number", randomNumber);
    } catch (error) {
        console.error(error);
    }
}

// 프라미스 p가 Resolve 될 때까지 대기
const iterator = foo();

const p = iterator.next().value;

// 프라미스 p가 Resolve 될 때까지 대기
p.then(function (randomNumber) {
    iterator.next(randomNumber);
}, function (error) {
    iterator.throw(error);
});

우선 기존 getRandomNumberDelayedBy() 함수에 프라미스로 리팩토링하고, *main() 함수명을 *foo()로 변경되었다. 이외 주목할 점은 *foo() 내부 코드는 전혀 변경되지 않았다는 것이다.

위 예제에서는 프라미스를 인식하는 단계(yield getRandomNumberDelayedBy(time))가 하나만 있었다. 그런데 이보다 더 많은 단계를 가진 제너레이터가 있다면, 아래와 같이 제너레이터 별로 프라미스 연쇄를 수동으로 작성해야 하는 것일까?

function getRandomNumberDelayedBy() {
    return Math.floor(Math.random() * 100);
}

function getAsyncRandomNumberDelayedBy(time) {
    return new Promise(function (resolve, reject) {
        if (time > 5000) {
            reject("5초이상 지연할 수 없습니다.");
        } else {
            setTimeout(resolve, time, getRandomNumberDelayedBy());
        }
    });
}

function* foo() {
    for (let order of [1, 2, 3, 4, 5]) {
        try {
            console.log('-------', order, '-------');
            const time = Math.floor(Math.random() * 10000);
            console.log("Delay Time", time);

            const asyncRandomNumber = yield getAsyncRandomNumberDelayedBy(time);
            console.log("Async Random Number", asyncRandomNumber);

            const randomNumber = yield getRandomNumberDelayedBy();
            console.log("Random Number", randomNumber);
        } catch (error) {
            console.error(error);
        }
    }
}
// Generator_Promise_Chain_Runner.js
const iterator = foo();

const p = iterator.next().value;

// 프라미스 p가 Resolve 될 때까지 대기
p.then(function (asyncRandomNumber) {
    const randomNumber = iterator.next(asyncRandomNumber).value;
    return iterator.next(randomNumber).value;
}, function (error) {
    return iterator.throw(error).value;
}).then(function (asyncRandomNumber) {
    const randomNumber = iterator.next(asyncRandomNumber).value;
    return iterator.next(randomNumber).value;
}, function (error) {
    return iterator.throw(error).value;
}).then( ... )
  .then( ... )
  .then( ... );
// Generator_Promise_Runner.js
function run(generator) {
    const args = [].slice.call(arguments, 1);
    const iterator = generator.apply(this, args);

    return Promise.resolve()
        .then(function handleNext(value) {
            const next = iterator.next(value);
            return (function handleResult(next) {
                if (next.done) {    // 제너레이터 실행 종료
                    return next.value;
                } else {
                    // next.value 가 즉시값이면 바로 귀결
                    // 프라미스이면 풀어보고 귀결 상태에 따라 Fulfillment, Rejection 콜백함수 호출
                    return Promise.resolve(next.value)
                        .then(
                            // Fulfillment 인 경우, 귀결값을 이용해 재귀적으로 제너레이터 호출
                            handleNext,
                            // Rejection 인 경우,
                            // 제너레이터에 에러를 전달하고
                            // iterator.throw(error)가 반환한 객체({done: , value: })를
                            // handleResult 호출시 next 인자로 전달하여 재귀적으로 다음 스텝 진행
                            function handleError(error) {
                                return Promise.resolve(iterator.throw(error))
                                    .then(handleResult);
                            }
                        )
                }
            })(next);
        })
}

run(foo);

yield 표현식에서 호출하는 함수가 동기적인 함수일 수도 있고 호출하는 함수가 다르기 때문에, Generator_Promise_Chain_Runner.js 처럼 제너레이터마다 프라미스 연쇄를 구성하는 것은 한계가 있다.

따라서, 이러한 작업을 도와주는 많은 라이브러리가 있는데 Generator_Promise_Runner.js 같이 직접 구현해 사용할 수도 있다.

run() 함수 인자로 제너레이터를 전달해 호출하면, 비동기적으로 제너레이터 함수가 완료될 때까지 실행한다.

async await

앞서 봤듯이, 제너레이터가 프라미스를 yield하고 이 프라미스가 제너레이터iterator를 제어하여 끝까지 진행하는 패턴은 매우 강력하다. 여기에 run() 헬퍼 유틸리티없이 흐름 제어를 자동으로 진행할 수 있다면 매우 편리할 것이다.

이러한 니즈를 반영하여 ES7부터는 async/await 지원하기 시작하였다.

기존의 제너레이터-프라미스 이용한 패턴은 아래와 같다.

function *foo() {
		// getAsyncRandomNumberDelayedBy() 함수가 프라미스를 반환
    const randomNumber = yield getAsyncRandomNumberDelayedBy(1000);
}

하지만, async/await는 아래와 같은 구문으로 대체한다.

async function foo() {
    const randomNumber = await getAsyncRandomNumberDelayedBy(1000);
}

async/await를 이용하면 일반 함수를 호출하는 것과 동일하고, run() 헬퍼 유틸리티도 전혀 필요하지 않다.

뿐만 아니라, async function으로 선언한 함수는 내부에서의 await 표현식은 yield 표현식과 흐름 제어가 유사하게 동작한다. 구체적으로, 프라미스가 Resolve(귀결)할 때까지 실행중인 함수를 멈추고 제어권을 메인 프로그램이나 Event Loop Queue에 전달한다.

결과적으로, async/await은 비동기 함수를 동기적인 스타일의 코드로 작성하여 흐름 제어할 수 있게 해준다.

또한 유념해야 할 점은 async 표현식으로 선언된 함수는 항상 프라미스를 반환한다는 것이다.

제너레이터 위임

자바스크립트는 특정 제너레이터에서 다른 제너레이터yield를 위임(delegation)할 수 있다.

아래 예제를 통해서 천천히 알아보자.

function *foo(randomNumber1) {
    const randomNumber2 = yield getRandomNumberDelayedBy(randomNumber1);
    const randomNumber3 = yield getRandomNumberDelayedBy(randomNumber2);
    return randomNumber3;
}

function *bar() {
    const randomNumber1 = yield getRandomNumberDelayedBy(1000);
	// run() 유틸리티를 이용해 *foo()에 위임/전파한다.
    const randomNumber3 = yield run(foo.bind(null, randomNumber1));
    console.log('randomNumber3', randomNumber3);
}

run(bar);

run(foo.bind(null, randomNumber1)) 코드를 보면, *bar()에서 run() 유틸리티로 *foo()를 호출하고 있다.

*foo() 제너레이터가 실행을 완료하면 run() 유틸리티가 귀결(Resolve)된 프라미스를 반환할테니, yield 표현식으로 run(foo.bind(null, randomNumber1))에서 나온 프라미스를 내보내기 위해, *bar()*foo()가 끝날 때까지 멈출 것이다.

즉, run() 유틸리티를 이용해서 다른 제너레이터에 위임/전파를 하는 것이다.

run() 유틸리티를 이용할 수 있지만, 자바스크립트에서는 yield * 위임할 제너레이터 형식으로 yield-위임(delegation)을 지원한다. 즉, yield-위임(delegation) 이용해서 *foo() 호출을 *bar() 안으로 합칠 수 있는 것이다.

function *foo(randomNumber1) {
    const randomNumber2 = yield getRandomNumberDelayedBy(randomNumber1);
    const randomNumber3 = yield getRandomNumberDelayedBy(randomNumber2);
    return randomNumber3;
}

function *bar() {
    const randomNumber1 = yield getRandomNumberDelayedBy(1000);
		// 'yield *' 표현식으로 '*foo()'에 위임한다. 
    const randomNumber3 = yield *foo(randomNumber1);
    console.log('randomNumber3', randomNumber3);
}

run(bar);

여기서 주의해야 할 사항이 있다.

yield * 표현식이 위임하는 것은 제너레이터 제어권이 아닌 iterator 제어권라는 것이다. 즉, yield * 표현식은 iterator 제어권(*bar())을 또 다른 iterator(*foo())에게 위임한다.

메시지 위임

주제를 보고 알 수 있듯이, yield-위임(delegation)iterator뿐만 아니라 양방향 메시징에도 사용된다.

다음 코드를 통해 yield-위임통한 양방향 메시징 흐름을 생각해보자.

function *foo() {
    console.log("*foo() 내부:", yield 'B');
    console.log("*foo() 내부:", yield 'C');
    return 'D';
}

function *bar() {
    console.log("*bar() 내부:", yield 'A');

    // *foo() yield 위임/전파
    console.log("*bar() 내부:", yield *foo());
    console.log("*bar() 내부:", yield 'E');
    return 'F';
}

const iterator = bar();
console.log('외부:', iterator.next().value);
// 외부: A

console.log('외부:', iterator.next(1).value);
// *bar() 내부: 1
// 외부: B

console.log('외부:', iterator.next(2).value);
// *foo() 내부: 2
// 외부: C

console.log('외부:', iterator.next(3).value);
// *foo() 내부: 3
// *bar() 내부: D
// 외부: E

console.log('외부:', iterator.next(4).value);
// *bar() 내부: 4
// 외부: F

iterator.next(3) 함수 호출 이후부터 살펴보자.

  1. 멈춰있는 *foo()yield 'C'에 3이 전달되고, *foo() 실행이 재개된다.
  2. *foo() 함수에서 return 'D' 해도 외부의 호출부인 iterator.next(3)로 전달되는 것이 아니라, 자신에게 yield 위임하여 대기중인 *bar()에게 반환값을 전달한다.
  3. 이후, *bar()에서 yield 'E' 표현식을 호출하여 iterator.next(3) 반환값으로 {done: false, value: 'E' }를 전달한다.

또한, yield-위임은 예외/에러도 양방향으로 전달한다.

function *foo() {
    try {
        yield 'B';
    } catch (error) {
        console.log("'*foo()'에서 잡힌 에러:", error);
    }
    yield 'C';
    throw 'D';
}

function *baz() {
    throw 'F';
}

function *bar() {
    yield 'A';
    try {
        yield *foo();
    } catch (error) {
        console.log("'*bar()'에서 잡힌 에러:", error);
    }
    yield 'E';
    yield *baz();

    yield 'G';

}

const iterator = bar();
console.log('외부:', iterator.next().value);
// 외부: A

console.log('외부:', iterator.next(1).value);
// 외부: B

console.log('외부:', iterator.throw(2).value);
// *foo()'에서 잡힌 에러: 2
// 외부: C

console.log('외부:', iterator.next(3).value);
// *bar()'에서 잡힌 에러: D
// 외부: E

try {
    console.log('외부:', iterator.next(4).value);
} catch (error) {
    console.log("외부에서 잡힌 에러:", error);
    // 외부에서 잡힌 에러: F
}

헷갈릴 수 있는 부분만 설명하겠다.

  1. iterator.throw(2)하면 에러 메시지 2를 *bar()에 전하고, 다시 *foo()yield 'B'로 전파되어 try...catch 문에서 처리가 된다. 이후, yield 'C'iterator.throw(2) 반환값으로 { done: false, value: 'C' }를 전달한다.
  2. 다음으로, throw 'D'로 던져진 에러는 *foo() -> *bar() 순으로 전파되고, *bar()try...catch 문에서 잡아 처리한다. 이후, yield 'E'iterator.next(3) 반환값으로 { done: false, value: 'E' }를 전달한다.
  3. 마지막으로, *baz()에서 발생한 에러는 *baz() -> *bar() -> 전역 순으로 전파되어 외부의 try...catch 문에서 잡아 처리한다.
  4. 주의할 점은 *baz()에서 발생한 에러가 *baz(), *bar() 모두에서 잡히지 않았기 때문에 두 함수는 완료 상태가 되어 더는 실행할 수 없게 된다. 즉, 연달아 iterator.next() 함수를 아무리 호출해도 'G' 값을 받을 방법이 없다.

위임 '재귀'

yield-위임은 많은 위임 단계도 잘 따라가기 때문에 스스로에게 yield-위임하는 비동기형 제너레이터의 재귀에도 사용 가능하다.

function delayedBy(time) {
    return new Promise(function (resolve, reject) {
        if (time > 5000) {
            reject("5초이상 지연할 수 없습니다.");
        } else {
            setTimeout(resolve, time, time + 1);
        }
    });
}

function *foo(time) {
    let nextTime = time;
    if (time > 1) {
        nextTime += yield *foo(nextTime - 1);
    }

    return yield delayedBy(nextTime);
}

function *bar() {
    const lastTime = yield *foo(3);
    console.log(lastTime);
}

run(bar);
// 9

위 예제를 하나씩 따라가 보자.

처음에 *bar() 함수에서 *foo() 함수 호출을 하고, 인자로 전달받은 time이 1이 될 때까지 스스로의 제너레이터를 생성해 위임을 하고 있다.

  1. 처음 *bar()*foo(3)iterator를 생성하고 3을 넘긴다.
  2. 3 > 1 이므로 *foo(3)iterator를 생성하고 2을 넘긴다.
  3. 2 > 1 이므로 *foo(2)iterator를 생성하고 1을 넘긴다.
  4. 1 > 1 이므로, 처음으로 delayedBy(1)를 호출하고 해당 프라미스를 run() 함수까지 전달해 프라미스가 Resolve(귀결)될 때까지 대기한다.
  5. 프라미스가 Resolve(귀결)되면, delayedBy(1) 대한 Resolve된 값인 2는 yield * 표현식에 의해 *foo(2) 제너레이터 인스턴스로 전달된다.
  6. 이후, *foo(2) 함수에서는 '2 +2' 연산 과정을 거친 값으로 delayedBy(4)를 호출하고 해당 프라미스를 run() 함수까지 전달해 프라미스가 Resolve(귀결)될 때까지 대기한다.
  7. 프라미스가 Resolve(귀결)되면, delayedBy(4) 대한 Resolve된 값인 5는 yield * 표현식에 의해 *foo(3) 제너레이터 인스턴스로 전달된다.
  8. 또 다시, *foo(3) 함수에서는 '5 +3' 연산 과정을 거친 값으로 delayedBy(8)를 호출하고 해당 프라미스를 run() 함수까지 전달해 프라미스가 Resolve(귀결)될 때까지 대기한다.
  9. 프라미스가 Resolve(귀결)되면, delayedBy(8) 대한 Resolve된 값인 9는 yield * 표현식에 의해 *bar() 제너레이터 인스턴스로 전달된다.
  10. 마지막으로, *bar() 제너레이터 인스턴스된 9는 lastTime 변수에 할당되어 콘솔에 출력된다.

제너레이터 동시성

동시 실행 중인 두 '프로세스'는 협동적으로 각자의 작업을 인터리빙할 수 있고 비동기 표현식을 구사할 수도 있다.

만약 콜백을 이용한다면, 두 비동기 상황에서 경합 조건이 발생하지 않게 조정하는 방법은 아래와 같다.

const global = [];
function callback(response) {
    if(response.url === "http://ko.goo.1") {
        global[0] = response;
    } else if(response.url === "http://ko.goo.2") {
        global[1] = response;
    }
}

요청한 url 주소를 기준으로 전역 변수인 global에 경합하지 않고 결과값을 할당하였다.

그럼 제너레이터를 이용하면 어떻게 될까?

const global = [];

function getRandomNumberDelayedBy(time) {
    return new Promise(function (resolve, reject) {
        if (time > 5000) {
            reject("5초이상 지연할 수 없습니다.");
        } else {
            setTimeout(resolve, time, Math.floor(Math.random() * 100));
        }
    });
}

function *request(time) {
    const data = yield getRandomNumberDelayedBy(time);
    yield;
    global.push(data);
}

const iter1 = request(2000);
const iter2 = request(1000);

const p1 = iter1.next().value;
const p2 = iter2.next().value;

// 프라미스 연쇄없이, 각각 독립적으로 대기
p1.then(function (data) {
    iter1.next(data);
});

p2.then(function (data) {
    iter2.next(data);
});

Promise.all([p1, p2])
    .then(function () {
        // iter1 가 iter2 보다 1초 늦게 완료됐음에도 global 배열에 먼저 삽입한다.
        iter1.next();
        iter2.next();
        console.log(global);
    });

제너레이터 이용하면 global[0], global[1] 할당이 필요없이 의도한 순서대로 결과값이 global 배열에 삽입되어 콜백을 이용한 경우보다 표현 로직이 간결해진다.

위 예제를 보면, iter1iter2의 각각의 귀결값인 프라미스는 연쇄없이 독립적으로 Resolve될 때까지 대기한다. 따라서, 각각의 *request() 인스턴스는 독립적으로 실행되는 것을 알 수 있다.

뿐만 아니라, *request() 함수는 비동기적으로 생성한 랜덤 숫자를 전달받아 자신의 스코프에 있는 변수에 할당하고 나서 다시 제어권을 넘기고 대기한다.

이후, Promise.all() 통해 두 프라미스가 모두 Resolve한 시점을 알고 독립적인 작업인 iter1iter2를 어떤 순서로 재개할지 결정할 수 있다. 결과적으로, 그 순서에 따라 결과값이 global 배열에 삽입된다.

Thunk

Thunk는 다른 함수를 호출할 운명 가진 인자 필요없는 함수이다.

즉, 함수 정의부를 또 다른 함수 호출부로 감싸 실행을 지연시키는데, 감싼 함수가 바로 Thunk이다. 쉽게 생각하면, Thunk는 원래 함수를 호출하는 함수이다.

function foo(x, y) {
    return x * y;
}

function fooThunk() {
    return foo(2, 3);
}

console.log(fooThunk());
// 6

동기적인 Thunk는 직관적이지만, 비동기적인 Thunk는 어떨까?

function foo(x, y, cb) {
    setTimeout(function () {
        cb(x * y);
    }, 1000);
}

function fooThunk(cb) {
    return foo(2, 3, cb);
}

fooThunk(console.log);
// 6

비동기적인 Thunk콜백을 이용할 수 있는데, 이미 지정된 2, 3와 cb 콜백 함수를 foo 함수에 인자로 전달하여 호출한다.

이러한 Thunk 함수를 만드는 유틸리티가 있으면 유용할 것 같다.

function thunkify(fn) {
    const args = [].slice.call(arguments, 1);
    return function (cb) {
        args.push(cb);
        return fn.apply(null, args);
    }
}

const fooThunk = thunkify(foo, 2, 3);

fooThunk(console.log);

위 예제에서 thunkify() 함수는 필요한 인자를 전달받고 Thunk 함수를 반환한다.

그러나, 일반적인 표준적인 방법은 thunkify()Thunk 함수를 생성하는 것이 아니라, Thunk를 만드는 함수를 생성하는 것이다.

따라서 Thunk 함수 생성기로 아래와 같이 변경해 볼 수 있다.

function thunkify(fn) {
    return function () {
        const args = [].slice.call(arguments);
        return function (cb) {
            args.push(cb);
            return fn.apply(null, args);
        }
    }
}

const fooThunkFactory = thunkify(foo);

const fooThunk1 = fooThunkFactory(2, 3);
const fooThunk2 = fooThunkFactory(4, 5);

fooThunk1(console.log);
fooThunk2(console.log);

이러한 두 단계로 구성한 것이 불필요해 보일지 모르지만, 필요한 시점에 Thunk 함수를 전달받을 수 있어 유용하다.

Thunk와 프라미스는 전혀 관련이 없지만, 요청에 대해 비동기적 응답을 받는다는 점은 유사하다.

뿐만 아니라, 프라미스에서 살펴보았던 Promise.promisify() 유틸리티는 나중에 사용할 프라미스를 생성해준다는 점에서 thunkify() 함수와 매우 유사하다.

function promisify(fn) {
    return function () {
        const args = [].slice.apply(arguments);
        return new Promise(function (resolve, reject) {
            fn.apply(null, args.concat(function cb(error, value) {
                if (error) {
                    reject(error);
                } else {
                    resolve(value);
                }
            }));
        });
    }
}

function thunkify(fn) {
    return function () {
        const args = [].slice.call(arguments);
        return function (cb) {
            return fn.apply(null, args.concat(cb));
        }
    }
}

// Thunk 답변을 받는다.
function foo(x, y, cb) {
    setTimeout(function () {
        cb(null, x * y);
    }, 1000);
}

// 대칭적이다.
// 질의자를 생성
const fooThunkFactory = thunkify(foo);
const fooPromiseFactory = promisify(foo);

const fooThunk = fooThunkFactory(2, 3);
const fooPromise = fooPromiseFactory(2, 3);

// Thunk 답변을 받는다.
fooThunk(function (error, result) {
    if (!error) {
        console.log(result);
    } else {
        console.error(error);
    }
});

// 프라미스 답변을 받는다.
fooPromise
    .then(function (result) {
        console.log(result);
    }, function (error) {
        console.error(error);
    });

// 6
// 6

즉, thunkify, promisify 모두 무언가를 질의하고, fooThunk, fooPromise가 이 질문에 대한 미랫값을 나타낸다. 따라서, 둘은 제너레이터에서 진행할 미랫값을 나타내는 관점에서 '대칭 관계'라고 볼 수 있다.

결과적으로, 제너레이터비동기성 Thunkyield해도 될 것이기 때문에, run() 유틸리티도 한번 변경해보자.

function *foo() {
		const getRandomNumberDelayedByPromisify = getRandomNumberDelayFactoryByPromisify();
    const randomNumber = yield getRandomNumberDelayedByPromisify(1000);
    console.log("Foo Random Number As Promisify:", randomNumber);
}

여기서, getRandomNumberDelayedBy()thunkify, promisify일 수 있다.

const getRandomNumberDelayFactoryByPromisify = promisify(getRandomNumberDelayedBy);

// or

const getRandomNumberDelayFactoryByThunkify = thunkify(getRandomNumberDelayedBy);

그러나 제너레이터 입장에서는 Thunk, Promise 둘 중 어느 것이든 전혀 상관없고, run() 유틸리티에서 Thunk에 대한 처리만 추가해 반복 순회하면 되는 것이다.

function promisify(fn) {
    return function () {
        const args = [].slice.apply(arguments);
        return new Promise(function (resolve, reject) {
            fn.apply(null, args.concat(function cb(error, value) {
                if (error) {
                    reject(error);
                } else {
                    resolve(value);
                }
            }));
        });
    }
}

function thunkify(fn) {
    return function () {
        const args = [].slice.call(arguments);
        return function (cb) {
            return fn.apply(null, args.concat(cb));
        }
    }
}

function getRandomNumberDelayedBy(time, cb) {
    if (time > 5000) {
        cb("5초이상 지연할 수 없습니다.");
    } else {
        cb(null, Math.floor(Math.random() * 100));
    }
}

function getRandomNumberDelayFactoryByPromisify() {
    return promisify(getRandomNumberDelayedBy);
}

function getRandomNumberDelayFactoryByThunkify() {
    return thunkify(getRandomNumberDelayedBy);
}

function* foo() {
    const getRandomNumberDelayedByPromisify = getRandomNumberDelayFactoryByPromisify();
    const randomNumber = yield getRandomNumberDelayedByPromisify(1000);
    console.log("Foo Random Number As Promisify:", randomNumber);
}

function* bar() {
    const getRandomNumberDelayedByThunkify = getRandomNumberDelayFactoryByThunkify();
    const randomNumber = yield getRandomNumberDelayedByThunkify(1000);
    console.log("Bar Random Number As Thunkify:", randomNumber);
}

function run(generator) {
    const args = [].slice.call(arguments, 1);
    const iterator = generator.apply(this, args);

    return Promise.resolve()
        .then(function handleNext(value) {
            const next = iterator.next(value);
            return (function handleResult(next) {
                if (next.done) {    // 제너레이터 실행 종료
                    return next.value;
                } else if (typeof next.value === 'function') { // Thunk 함수인 경우
                    return new Promise(function (resolve, reject) {
                        // next.value 는 Thunk 함수이다.
                        // 에러-우선 콜백으로 Thunk 함수 호출
                        next.value(function (error, result) {
                            if (error) {
                                reject(error);
                            } else {
                                resolve(result);
                            }
                        });
                    }).then(handleNext, function handleError(error) {
                        return Promise.resolve(iterator.throw(error))
                            .then(handleResult);
                    });
                } else {
                    // next.value 가 즉시값이면 바로 귀결
                    // 프라미스이면 풀어보고 귀결 상태에 따라 Fulfillment, Rejection 콜백함수 호출
                    return Promise.resolve(next.value)
                        .then(
                            // Fulfillment 인 경우, 귀결값을 이용해 재귀적으로 제너레이터 호출
                            handleNext,
                            // Rejection 인 경우,
                            // 제너레이터에 에러를 전달하고
                            // iterator.throw(error)가 반환한 객체({done: , value: })를
                            // handleResult 호출시 next 인자로 전달하여 재귀적으로 다음 스텝 진행
                            function handleError(error) {
                                return Promise.resolve(iterator.throw(error))
                                    .then(handleResult);
                            }
                        )
                }
            })(next);
        });
}

run(foo);
run(bar);

이제 우리가 생성한 제너레이터promisify를 호출하여 Promiseyield하거나, thunkify 호출해 Thunk 함수를 yield할 것이다.

이에 맞춰, run() 함수는 어느쪽이든 상관없이 동일하게 Promise 또는 Thunk 함수를 받아 호출하고 Resolve(귀결)할 때까지 대기하다가 완료시 제너레이터 재개하는 용도로 사용된다.

profile
한번도 실수하지 않은 사람은, 한번도 새로운 것을 시도하지 않은 사람이다.

0개의 댓글