구리

[JavaScript] Iterable, Iterator 그리고 Generator (2) - async 이터레이터와 제너레이터 본문

카테고리 없음

[JavaScript] Iterable, Iterator 그리고 Generator (2) - async 이터레이터와 제너레이터

guriguriguri 2023. 7. 30. 22:40

이터레이터는 동기식 데이터 소스를 나타내는데 적합합니다. 하지만 I/O 접근이 필요한 데이터는 일반적으로 이벤트 기반이거나 스트리밍 비동기 API를 사용하기에 이터레이터를 사용할 수 없습니다.

이런 비동기 데이터 소스에 대한 데이터 접근 프로토콜을 제공하기 위해 비동기 이터레이션이 추가되었습니다.

ES9(ES2018)에서는 비동기 이터레이션을 지원하는 async 이터레이터, for...await of 문이 도입되었으며 비동기적으로 들어오는 데이터를 필요에 따라 처리할 수 있습니다. 또한 비동기 제너레이터를 사용하면 이런 데이터를 더 편리하게 처리할 수 있습니다.

 

async 이터레이터

비동기 이터레이터는 next(){ value, done } 객체를 위한 프라미스 (Promise)를 반환하는 것을 제외하면 기존의 동기식 이터레이터와 유사합니다.

비동기 이테레이터의 내부 설계에는 요청 큐의 설계가 포함되어 있습니다. 이전 요청의 결과가 해결되기 전에 이터레이터 메서드가 여러번 호출될 수 있기에, 모든 이전 요청 조작이 완료될 때까지 각 메서드 호출을 내부적으로 큐에 넣어야 합니다.

이터러블 객체를 비동기적으로 만들기 위해선 다음 작업이 필요합니다.

  1. 이터러블 객체에 필요한 Symbol.iterator 대신 Symbol.asyncIterator를 사용
  2. next() 는 프라미스를 반환
  3. 비동기 이터러블 객체를 반복 순회하기 위해선 for...await of 문을 사용

참고로 스프레드 문법은 비동기적으로 동작하지 않기에 비동기 이터레이터와 사용할 수 없으며 일반 이터레이터만 가능합니다.

아래는 1부터 5까지 숫자를 비동기적로 반환하는 이터러블 객체 range의 예시 코드입니다.

let range = {
  from: 1,
  to: 5,

  // for await..of 최초 실행 시, Symbol.asyncIterator가 호출됩니다.
  [Symbol.asyncIterator]() { // (1)
    // Symbol.asyncIterator 메서드는 이터레이터 객체를 반환합니다.
    // 이후 for await..of는 반환된 이터레이터 객체만을 대상으로 동작하는데,
    // 다음 값은 next()에서 정해집니다.
    return {
      current: this.from,
      last: this.to,

      // for await..of 반복문에 의해 각 이터레이션마다 next()가 호출됩니다.
      async next() { // (2)
        //  next()는 객체 형태의 값, {done:.., value :...}를 반환합니다.
        // (객체는 async에 의해 자동으로 프라미스로 감싸집니다.)

        // 비동기로 무언가를 하기 위해 await를 사용할 수 있습니다.
        await new Promise(resolve => setTimeout(resolve, 1000)); // (3)

        if (this.current <= this.last) {
          return { done: false, value: this.current++ };
        } else {
          return { done: true };
        }
      }
    };
  }
};

(async () => {

  for await (let value of range) { // (4)
    alert(value); // 1,2,3,4,5
  }

})()

위 예시의 next() 처럼 async 메서드일 필요는 없으며, 프라미스를 반환하는 일반 메서드여도 괜찮지만 async를 사용하면 await도 사용할 수 있기에 편의상 사용했습니다.

 

async 제너레이터

일반 제너레이터에선 await을 사용할 수 없는 동기적 문법으로 모든 값은 동기적으로 생산됩니다.

만약 네트워크 요청과 같이 제너레이터에 await을 사용해야 한다면 async를 제너레이터 함수 앞에 붙여주면 됩니다.

일반 제너레이터와 async 제너레이터 함수의 차이점은 다음과 같습니다.

  • async 제너레이터 함수를 통해 생성된 제너레이터 객체의 next()는 비동기적으로 처리
  • next() 는 프라미스를 반환하기에 await 혹은 then()을 사용해 값에 접근
async function* generateSequence(start, end) {
  for (let i = start; i <= end; i++) {
    // await를 사용할 수 있습니다!
    await new Promise((resolve) => setTimeout(resolve, 1000));

    yield i;
  }
}

(async () => {
  let generator = generateSequence(1, 5);
  for await (let value of generator) {
    console.log(value); // 1, 2, 3, 4, 5
  }
})();

위에서 작성한 async 이터러블 객체는 async 제너레이터 함수를 이용해 더 간단히 구현할 수 있습니다.

이터러블 객체를 만들 경우 Symbol.iteratornext가 구현된 객체를  반환하기보단 제너레이터를 반환하도록 구현하는 경우가 많기에 async 이터러블 객체를 만들 때도 Symbol.asyncIteratorasync 제너레이터를 사용해 더 간편하게 구현할 수 있습니다.

let range = {
  from: 1,
  to: 5,

  async *[Symbol.asyncIterator]() {
    // [Symbol.iterator]: function*()를 짧게 줄임
    for (let value = this.from; value <= this.to; value++) {
      await new Promise((resolve) => setTimeout(resolve, 1000));
      yield value;
    }
  },
};

(async () => {
  for await (let value of range) {
    console.log(value);
  }
})();

 

async 제너레이터 use case

페이지네이션을 이용해 Github 커밋 이력을 페이지별로 확인하는 코드 예시입니다.

  • Github에선 커밋 30개의 정보가 담긴 JSON 데이터를 전달하고 다음 페이지에 대한 정보를 Link 헤더에 담아 응답
  • 더 많은 커밋 정보 필요시, 헤더에 담긴 링크를 사용해 다음 요청을 전송
const { default: fetch } = require("node-fetch");

async function* fetchCommits(repo) {
  let url = `https://api.github.com/repos/${repo}/commits`;

  while (url) {
    const response = await fetch(url, {
      // (1)
      headers: { "User-Agent": "Our script" }, // GitHub는 모든 요청에 user-agent헤더를 강제 합니다.
    });

    const body = await response.json(); // (2) 응답은 JSON 형태로 옵니다(커밋이 담긴 배열).

    // (3) 헤더에 담긴 다음 페이지를 나타내는 URL을 추출합니다.
    let nextPage = response.headers.get("Link").match(/<(.*?)>; rel="next"/);
    nextPage = nextPage?.[1];

    url = nextPage;

    for (let commit of body) {
      // (4) 페이지가 끝날 때까지 커밋을 하나씩 반환(yield)합니다. 
      // 전체 다 반환되면 다음 while (url) 반복문이 트리거되어 서버에 다시 요청을 보냅니다.
      yield commit;
    }
  }
}

(async () => {
  let count = 0;

  for await (const commit of fetchCommits(
    "javascript-tutorial/en.javascript.info"
  )) {
    console.log(commit.author.login);

    if (++count == 100) {
      // 100번째 커밋에서 멈춥니다.
      break;
    }
  }
})();

처음에 구상했던 API가 구현되었으며 페이지네이션 관련 내부 메커니즘은 바깥에서 볼 수 없고, 단순히 async 제너레이터만 사용해 원하는 커밋을 반환받을 수 있습니다.

또한 용량이 큰 파일을 다운로드하거나 업로드와 같이 띄엄띄엄 들어오는 데이터 스트림을 다뤄야 할 경우 혹은 사용자 입력 이벤트를 스트림으로 표현하는데, 비동기 제너레이터 혹은 비동기 이터레이터를 사용할 수 있습니다. 참고로 브라우저 등의 몇몇 호스트 환경은 데이터 스트림을 처리할 수 있는 API인 Streams를 제공하기도 합니다.


참고 자료

https://github.com/tc39/proposal-async-iteration

https://tc39.es/proposal-async-iteration/

https://ko.javascript.info/async-iterators-generators