async/await의 동작 원리를 살펴보기에 앞서 generator에 대한 이해를 위해 iterable, iterator, iteration result에 대한 개념을 정리해보자.

interface Iterable<T> {
  [Symbol.iterator](): Iterator<T>;
}

interface Iterator<T> {
  next(): IterationResult<T>;
}

interface IterationResult<T> {
  value: T;
  done: boolean;
}
  • Iterable: [Symbol.iterator] 메서드를 구현한 객체
  • Iterator : next 메서드를 구현한 객체
  • IterationResult: value, done이라는 프로퍼티를 갖고 있는 객체
Iterable[Symbol.iterator]()
             │
             └→ Iterator ─→ Iterator.next()
                                      │
                                      └─→ IterationResult

Generator

제너레이터는 특수한 함수로써 일반 함수와는 다르게 호출 시 함수 몸체를 바로 실행하는 것이 아니라 IterableIterator(Iterable이면서 Iterator인) 객체를 반환한다.

interface IterableIterator<T> extends Iterator<T> {
  [Symbol.iterator](): IterableIterator<T>;
}

yield

yield는 제너레이터 함수 몸체 내부의 실행(execution)을 잠시 중단하고 호출자에게 결과를 전달하게끔 하는 키워드다. 따라서 제너레이터가 반환한 이터레이터의 next를 호출하면 yield 뒤에 위치한 표현식을 평가하고 해당 위치에서 함수를 일시 중지 상태로 전환시킨다.

또한 yield 키워드는 호출자에게 값을 전달하는 데에도 이용되지만, 호출자로부터 값을 전달받는 것에도 이용할 수 있다. 우선은 호출자에게 값을 전달하는 경우부터 살펴보자.

// (1)
function* generator(arg) {
  yield arg;
  return 2;
}

const iterator = generator('hello'); // (2)

const r1 = iterator.next().value; // (3)

const r2 = iterator.next().value; // (4)

(1) 런타임에 들어가기 직전 함수 객체를 생성하고 식별자에 바인딩한다.

┌──────────────┐
│  Global E.C  │
│ ┈┈┈┈┈┈┈┈┈┈┈  │
│ generator: ● ┼────→ function* () {}
│ iterator : - │
│ r1       : - │
│ r2       : - │
└──────────────┘

(2) generator가 호출되면서 실행 컨텍스트가 콜 스택에 push되고, iterator를 반환하면서 generator의 실행 컨텍스트는 pop 된다.

┌──────────────┐                     ┌──────────┐
│generator E.C │←────────────────────│ iterator │
│ ┈┈┈┈┈┈┈┈┈┈┈  │                     │ ┈┈┈┈┈┈┈┈ │
│ arg: "hello" │                     │  next()  │
└──────────────┘ ┌──────────────────→└──────────┘
┌──────────────┐ │
│  Global E.C  │ │
│ ┈┈┈┈┈┈┈┈┈┈┈  │ │
│ generator: ● ┼─┼───→ function* () {}
│ iterator : ● ┼─┘
│ r1       : - │
│ r2       : - │
└──────────────┘

하지만 generator의 실행 컨텍스트는 여전히 메모리에 상주하게 되는데, generator 자체가 실행을 재개할 수 있어야 하기 때문에 iterator가 모든 순회를 마치기 전까지는 계속 참조되기 때문이다.

┌──────────────┐
│  Global E.C  │
│ ┈┈┈┈┈┈┈┈┈┈┈  │
│ generator: ● ┼────→ function* () {}
│ iterator : ● ┼────────────┐
│ r1       : - │            │
│ r2       : - │       ┌──────────┐       ┌──────────────┐
└──────────────┘       │ iterator │       │generator E.C │
                       │ ┈┈┈┈┈┈┈┈ │ ────→ │ ┈┈┈┈┈┈┈┈┈┈┈  │
                       │  next()  │       │ arg: "hello" │
                       └──────────┘       └──────────────┘

(3) iterator의 next가 호출되면 다시 콜 스택에 push되었다가 yield 키워드 뒤의 표현식을 평가할 때까지 실행되었다가 평가한 값을 반환하게 된다.

┌──────────────┐                     ┌──────────┐
│generator E.C │←────────────────────│ iterator │
│ ┈┈┈┈┈┈┈┈┈┈┈  │                     │ ┈┈┈┈┈┈┈┈ │
│ arg: "hello" │                     │  next()  │
└──────────────┘ ┌──────────────────→└──────────┘
┌──────────────┐ │
│  Global E.C  │ │
│ ┈┈┈┈┈┈┈┈┈┈┈  │ │
│ generator: ● ┼─┼───→ function* () {}
│ iterator : ● ┼─┘
│ r1       : - │
│ r2       : - │
└──────────────┘
┌──────────────┐
│  Global E.C  │
│ ┈┈┈┈┈┈┈┈┈┈┈  │
│ generator: ● ┼────→ function* () {}
│ iterator : ● ┼────────────┐
│ r1:   "hello"│            │
│ r2       : - │       ┌──────────┐       ┌──────────────┐
└──────────────┘       │ iterator │       │generator E.C │
                       │ ┈┈┈┈┈┈┈┈ │ ────→ │ ┈┈┈┈┈┈┈┈┈┈┈  │
                       │  next()  │       │ arg: "hello" │
                       └──────────┘       └──────────────┘

(4) 제너레이터 함수 몸체 내부에서 return을 통해 값을 반환하는 것 역시 가능하다. 하지만 return 문을 실행하면 Iteration Result 객체의 donetrue가 된다.

┌──────────────┐                     ┌──────────┐
│generator E.C │←────────────────────│ iterator │
│ ┈┈┈┈┈┈┈┈┈┈┈  │                     │ ┈┈┈┈┈┈┈┈ │
│ arg: "hello" │                     │  next()  │
└──────────────┘ ┌──────────────────→└──────────┘
┌──────────────┐ │
│  Global E.C  │ │
│ ┈┈┈┈┈┈┈┈┈┈┈  │ │
│ generator: ● ┼─┼───→ function* () {}
│ iterator : ● ┼─┘
│ r1:   "hello"│
│ r2       : - │
└──────────────┘
┌──────────────┐
│  Global E.C  │
│ ┈┈┈┈┈┈┈┈┈┈┈  │
│ generator: ● ┼────→ function* () {}
│ iterator : ● ┼────────────┐
│ r1:   "hello"│            │
│ r2:        2 │       ┌──────────┐       ┌──────────────┐
└──────────────┘       │ iterator │       │generator E.C │
                       │ ┈┈┈┈┈┈┈┈ │ ────→ │ ┈┈┈┈┈┈┈┈┈┈┈  │
                       │  next()  │       │ arg: "hello" │
                       └──────────┘       └──────────────┘

이번에는 호출자가 제너레이터 함수 측으로 값을 전달하는 방법을 살펴보자. 우선 제너레이터 함수의 인수로 전달하는 방법 외에도 next의 인수로 전달하는 방법이 존재한다.

function* generator() {
  const first = yield 1;
  const second = yield first + 2;
  yield second + 3;
}

const iterator = generator();

iterator.next(); // (1) { value: 1, done: false }
iterator.next(4); // (2) { value: 6, done: false }
iterator.next(5); // (3) { value: 8, done: false }

(1) 자바스크립트에서 할당문의 경우 우측이 먼저 평가되므로 yield에서 실행이 중단된다는 것은 우측의 표현식까지만 평가되고 할당은 이루어지지 않은 상태라는 의미이다. 이제 그 다음 next를 호출할 때 할당이 이루어지게 된다.

iterator.next(); // { value: 1, done: false }

function* generator() {
  //           ↓ [resume]
  const first = yield 1;  // ...
}

(2) 여기서는 next를 호출할 때 인수를 전달하게 된다. 그러면 제너레이터 함수 몸체에서 실행을 멈추었던 위치부터 실행을 재개하게 된다. 단 이때 할당 이전에 수행된 yield는 전달한 인수로 대체된다.

iterator.next(4); // { value: 6, done: false }

function* generator() {
  const first = 4;
  //            ↓ [resume]
  const second = yield 4 + 2;  // ...
}

(3) 이 지점에서 주의해야 하는데, 다음 next 호출 시 second에 할당되는 값은 6이 아니라 호출 때 전달한 인수라는 것이다.

iterator.next(5); // { value: 8, done: false }

function* generator() {
  const first = 4;
  const second = 5;
  yield 5 + 3;}

Asynchronous Task Running

제너레이터가 가장 강력한 힘을 발휘하는 곳은 바로 비동기 상황이다. 우선 제너레이터를 이용하여 비동기 상황을 제어하는 경우를 가급적 단순화시키기 위해 setTimeout을 이용하여 비동기 호출을 모방해보자.

const getFirstData = (callback) => {
  setTimeout(() => {
    callback('first data');
  }, 1000);
};

const getSecondData = (callback) => {
  setTimeout(() => {
    callback('second data');
  }, 1000);
};

이 두 함수는 전달한 ms 이후에 전달받은 콜백 함수에 값을 인수로 전달하여 호출하는 함수이다. 그렇다면 제너레이터를 이용하지 않고 두 함수를 사용하는 사례를 살펴보자.

getFirstData((data) =>
  console.log(`data received ${data}`),
);
getSecondData((data) =>
  console.log(`data received ${data}`),
);

// [after 1,000ms]
// data received first data
// data received second data

위 코드에서 콜백을 사용하여야 하는 이유와 콜백 패턴이 가질 수 있는 문제점들은 차치하고 우선은 제너레이터를 이용하여 비동기 호출을 제어하는 방식을 살펴보자. yield는 함수 실행을 잠시 멈추었다가 다시 재개하기 이전에 next 메서드가 호출되기를 대기하고 있기 때문에 콜백을 이용하지 않더라도 비동기적인 호출을 관리할 수 있다. 그렇다면 우선 두 함수가 데이터를 전달하는 시점에 콜백 함수 대신 이터레이터를 사용하게끔 바꾸어보자.

let iterator;

const getFirstData = () => {
  setTimeout(() => {
    iterator.next('first data');
  }, 1000);
};

const getSecondData = () => {
  setTimeout(() => {
    iterator.next('second data');
  }, 1000);
};

다시 한번 제너레이터 함수의 정의를 생각해보면 호출 시 이터레이터를 반환하는 함수이다. 또한 반환한 이터레이터의 next를 호출하면 yield 키워드 우측의 표현식을 평가한 뒤 반환하게 된다.

function* generator() {
  const firstData = yield getFirstData();  const secondData = yield getSecondData();
  console.log(`firstData: ${firstData}`);
  console.log(`secondData: ${secondData}`);
}

iterator = generator();
iterator.next();
// firstData: first data
// secondData: second data

generator 함수 몸체를 살펴보면 분명 비동기 상황을 다루는 함수 호출이지만 마치 동기 함수 호출처럼 보이는 것을 확인할 수 있을 것이다.

[시간]
──────────────────[1000ms]───────────────[2000ms]
next()
  ↓
getFirstData()──→ next("first data")
                    ↓
                 firstData
                    ↓
               getSecondData() ────────→ next("second data")
                                            ↓
                                       secondData
                                            ↓
                                       console.log

중요한 점은 yield 우측의 표현식까지 평가한 이후 함수 실행을 중단하더라도 next를 호출한 호출자는 blocking되지 않는다는 점이다.

iterator.next();
console.log('non-blocking');
// non-blocking
// ....results

현재 상황에서는 비동기와 동기를 구분할 수 있는 방법은 이터레이터의 next를 호출한 결과가 함수인 지 여부로 식별할 수 있을 것이다. 이러한 상황에서 제너레이터 함수를 호출하고 반환된 이터레이터의 next 메서드를 호출해 줄 수 있는 함수를 Task Runner라 한다면 콜백 함수를 통해 비동기 상황을 제어하는 경우에는 Task Runner 역시 해당 콜백 함수가 어떻게 동작하는 지에 대해 이해하고 있어야 한다는 점이다. 즉, 모든 비동기 호출에 전달되는 콜백 함수의 시그니처나 동작이 동일하다는 전제가 성립하지 않는다면 각각의 콜백 함수에 치중된 Task Runner의 구현이 필요할 수 밖에 없다.

다만 redux-thunk에서 action이 plain object 대신 함수인 경우 비동기 상황이라 가정할 수 있는 이유는 동일한 인터페이스가 보장되기 때문인 것이라 생각한다.

function createThunkMiddleware(extraArgument) {
  return ({ dispatch, getState }) => (next) => (action) => {
    if (typeof action === 'function') {      return action(dispatch, getState, extraArgument);
    }
    return next(action);
  };
}

따라서 콜백 함수 대신 ES6에 도입된 Promise를 사용하여 비동기 상황을 제어한다면 Promise의 인터페이스를 사용하여 보다 일관된 방식으로 Task Running을 수행할 수 있을 것이다.

const async = (generator) => {
  const iterator = generator();

  const onResolved = (arg) => {
    const result = iterator.next(arg);
    // 비동기 상황은 Promise를 통해 제어하므로 일관된 인터페이스를 갖는다.
    return result.done
      ? result.value
      : result.value.then(onResolved);
  };

  return onResolved;
};

async(function* fetchData(url) {
  const response = yield fetch(url);
  const data = yield response.json();
  console.log(data);
})(url);

async 키워드를 사용한 함수의 경우에는 명시적으로 Promise를 반환하지 않더라도 암묵적으로 반환 값을 Promise로 래핑한 값을 반환한다는 차이는 존재하지만 기본적인 형태를 살펴보면 async라는 함수 명을 async 키워드로, yieldawait으로 바꾸면 일반적으로 우리가 사용하는 async/await 함수의 패턴과 거의 동일한 것을 확인할 수 있다. 거기에 추가적으로 await 키워드를 사용할 수 있는 대상이 Promise로 한정되는 것 역시 Task Runner가 각각의 콜백에 특화된 구현 대신 일관된 인터페이스를 사용하기 위함이 아닐까 추측해본다.

Reference