커서 기반 페이지네이션 API에서 전체 목록을 한 번에 긁어와야 할 일이 있었다. 응답에 nextCursor가 들어있고, 그게 없을 때까지 계속 요청하는 흔한 구조다. 보통 이렇게 짠다.
async function* fetchAllPages() {
let cursor = null;
do {
const res = await fetch(`/api/items?cursor=${cursor ?? ''}`);
const { items, nextCursor } = await res.json();
yield* items;
cursor = nextCursor;
} while (cursor);
}
제너레이터까지는 깔끔한데, 이걸 실제 배열로 만들려면 결국 아래처럼 받아야 한다.
const all = [];
for await (const item of fetchAllPages()) {
all.push(item);
}
for await 돌면서 push 하는 이 패턴, 쓸 때마다 번거로웠다. 동기 이터러블이면 Array.from()이나 스프레드([...]) 한 방인데, 비동기에는 스프레드가 안 먹는다. [...fetchAllPages()]는 그냥 터진다.
Array.fromAsync() 한 줄로 끝난다
이 경우에 쓰라고 나온 게 Array.fromAsync()다.
const all = await Array.fromAsync(fetchAllPages());
끝이다. 비동기 이터러블을 끝까지 소비해서 배열 하나로 묶어주고, Promise를 반환하니까 await만 붙이면 된다. 임시 배열과 for await 루프가 통째로 사라졌다.
Array.from()과 똑같이 두 번째 인자로 매핑 함수도 받는다. 모으면서 바로 변환할 수 있다.
const ids = await Array.fromAsync(
fetchAllPages(),
(item) => item.id
);
중요한 건 이 매핑 함수가 async여도 된다는 점이다. 각 값마다 비동기 작업을 순차적으로 돌리고 싶을 때 유용하다.
const details = await Array.fromAsync(
idStream,
async (id) => {
const res = await fetch(`/api/items/${id}`);
return res.json();
}
);
동기 이터러블도 받는다
Array.fromAsync()는 비동기 이터러블뿐 아니라 일반 배열·유사 배열도 받는다. 안에 Promise가 들어있으면 알아서 풀어준다.
await Array.fromAsync([Promise.resolve(1), Promise.resolve(2)]);
// → [1, 2]
다만 Promise.all()과는 성격이 다르다. Promise.all()은 동시에 실행하지만, Array.fromAsync()는 하나씩 순차적으로 소비한다. 그래서 수백 개를 병렬로 빠르게 받아야 하는 상황이면 여전히 Promise.all()이 맞다. 반대로 페이지네이션처럼 앞 결과가 있어야 다음을 요청하는 순차 작업엔 Array.fromAsync()가 자연스럽다.
Array.fromAsync()는 이제 모든 최신 브라우저와 Node 22+에서 기본으로 쓸 수 있다. 비동기 제너레이터나 스트림 결과를 배열로 모을 일이 생기면, 빈 배열 만들고 for await부터 짜지 말고 이걸 먼저 떠올리자. 순차 소비라는 특성만 기억하면 쓸 데가 꽤 많다.