비동기 이터레이션이 필요한 상황
네트워크로 데이터가 여러 번에 걸쳐 들어오는 경우가 흔합니다. 이런 데이터를 필요할 때마다 처리하려면 async 이터레이션을 사용합니다. 이를 위해 async 이터레이터와 async 제너레이터를 활용하고, 반복은 for await..of로 수행합니다
먼저 async 이터레이터가 일반 이터레이터와 어떻게 다른지 확인하고, 다음으로 async 제너레이터가 어떤 형태로 확장되는지 정리한 뒤, 실무에서 자주 나오는 페이지네이션 패턴을 예시로 살펴봅니다
async 이터레이터 문법
비동기 이터레이터는 일반 이터레이터와 구조가 비슷합니다. 차이는 반복 가능한 객체를 만들 때 쓰는 심볼과 next 반환 타입입니다
일반 이터러블은 Symbol.iterator를 구현하는 객체입니다. 비동기 이터러블은 아래 조건을 만족해야 합니다
- iterable을 비동기적으로 반복 가능하게 하려면 Symbol.asyncIterator를 구현해야 합니다
- next는 Promise를 반환해야 합니다
- 반복은 for await (let item of iterable) 형태의 for await..of를 사용합니다
동작 흐름은 다음과 같습니다
- for await..of가 실행되면 rangeSymbol.asyncIterator를 한 번 호출합니다
- 이후 반복마다 next()를 호출합니다
- next가 반환하는 Promise가 resolve된 값으로 value와 done을 확인하며 반복이 진행됩니다
아래는 1초 간격으로 값을 내보내는 비동기 이터러블 예시입니다
let range = {
from: 1,
to: 5,
[Symbol.asyncIterator]() {
return {
current: this.from,
last: this.to,
async next() {
await new Promise(resolve => setTimeout(resolve, 1000));
if (this.current <= this.last) {
return { done: false, value: this.current++ };
}
return { done: true };
}
};
}
};
(async () => {
for await (let value of range) {
alert(value);
}
})();일반 이터레이터와 async 이터레이터의 핵심 비교는 다음처럼 정리할 수 있습니다
| 구분 | 일반 이터레이터 | async 이터레이터 |
|---|---|---|
| 반복을 제공하는 메서드 | Symbol.iterator | Symbol.asyncIterator |
| next가 반환하는 값 | 값 자체 | {value, done}을 감싼 Promise |
| 반복문 | for..of | for await..of |
전개 구문은 async 이터레이터와 함께 쓰지 않음
전개 구문 …은 비동기적으로 동작하지 않습니다. 그래서 아래처럼 작성하면 Symbol.iterator를 찾게 되고 실패합니다
alert([...range]);전개는 await가 없는 for..of와 비슷하게 동작한다고 보면 됩니다. 즉 Symbol.asyncIterator 기반의 반복과 문법적으로 맞지 않습니다
async 제너레이터로 async 이터러블 만들기
제너레이터는 이터러블입니다. 일반 제너레이터 function* 본문에서는 await를 사용할 수 없고, 값은 동기적으로 생산됩니다
네트워크 요청처럼 본문에서 await가 필요하다면 async를 제너레이터 앞에 붙이면 됩니다
async function* generateSequence(start, end) {
for (let i = start; i <= end; i++) {
await new Promise(resolve => setTimeout(resolve, 1000));
yield i;
}
}
(async () => {
let generator = generateSequence(1, 5);
for await (let value of generator) {
alert(value);
}
})();이렇게 만들면 for await..of로 반복 가능한 async 제너레이터를 사용할 수 있습니다. 또한 async 제너레이터의 generator.next()는 비동기적이 되고 Promise를 반환합니다. 일반 제너레이터처럼 result = generator.next()를 바로 쓰는 대신 result = await generator.next() 형태로 처리합니다
async 이터러블과 async 이터레이션의 연결
반복 가능한 객체를 만들 때는 보통 Symbol.iterator를 추가합니다. 그 구현을 제너레이터로 대체하는 패턴도 많이 씁니다
예를 들어 아래처럼 *Symbol.iterator를 제너레이터로 구현하면 range는 for..of로 반복 가능합니다
let range = {
from: 1,
to: 5,
*[Symbol.iterator]() {
for (let value = this.from; value <= this.to; value++) {
yield value;
}
}
};
for (let value of range) {
alert(value);
}여기에 비동기 동작을 섞고 싶으면 Symbol.iterator를 Symbol.asyncIterator로 바꾸고 async 제너레이터로 구현하면 됩니다
let range = {
from: 1,
to: 5,
async *[Symbol.asyncIterator]() {
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) {
alert(value);
}
})();이제 1초 간격으로 값을 얻을 수 있습니다
실무 사례로 보는 페이지네이션 처리
실무에서는 페이지네이션이 흔합니다. 보통 서버는 요청한 페이지에 해당하는 데이터 묶음을 응답하고, 다음 페이지를 가져오기 위한 정보 예를 들어 Link 헤더의 next 링크를 같이 내려줍니다
이 패턴을 커밋 목록처럼 여러 번에 걸친 요청이 필요한 API에 적용하면, 클라이언트는 다음 링크를 따라가며 요청을 반복해야 합니다. async 제너레이터를 쓰면 이 반복 과정을 내부로 숨기고, 바깥에서는 for await..of로만 항목을 처리하게 만들 수 있습니다
아래는 Link 헤더에서 다음 페이지 URL을 얻고, 각 페이지의 응답 배열을 순회하며 커밋을 하나씩 yield 하는 예시입니다
async function* fetchCommits(repo) {
let url = `https://api.github.com/repos/${repo}/commits`;
while (url) {
const response = await fetch(url, {
headers: { 'User-Agent': 'Our script' }
});
const body = await response.json();
let nextPage = response.headers.get('Link').match(/<(.*?)>; rel="next"/);
nextPage = nextPage?.[1];
url = nextPage;
for (let commit of body) {
yield commit;
}
}
}사용은 매우 단순해집니다. 예를 들어 100개까지만 처리한 뒤 중단할 수 있습니다
(async () => {
let count = 0;
for await (const commit of fetchCommits('javascript-tutorial/en.javascript.info')) {
console.log(commit.author.login);
if (++count === 100) {
break;
}
}
})();이 접근의 장점은 페이지네이션의 내부 루프와 다음 요청 결정 로직을 감추고, 소비자는 비동기 이터레이션만 사용하면 된다는 점입니다
마무리
일반 이터레이터와 제너레이터는 값 생성에 지연이 없을 때 적합합니다. 반대로 지연이 있고 데이터가 비동기적으로 들어오는 경우에는 async 이터레이터와 async 제너레이터를 쓰고, 반복은 for await..of를 사용합니다
문법상 차이는 Symbol.iterator vs Symbol.asyncIterator, next가 반환하는 값의 형태, 그리고 반복문 형태로 정리할 수 있습니다
실무에서는 페이지네이션이나 스트림처럼 여러 번에 걸친 요청과 처리가 필요한 상황에서 async 제너레이터가 잘 맞습니다. 또한 일부 호스트 환경은 Streams 같은 인터페이스로 스트림 처리를 제공하지만, 여기서는 async 이터레이션으로도 동일한 흐름을 만들 수 있다는 점을 확인했습니다