저는 채팅방이 30개가 넘는 순간, 최근 메시지를 한꺼번에 불러오다가 Supabase가 429를 뿜는 걸 보고 멘붕에 빠졌습니다. 한 번에 모든 방을 조회하는 건 무리였던 거죠. 그래서 mapWithConcurrencyLimit라는 유틸 함수를 만들어 병렬 호출 개수를 제한했습니다.
mapWithConcurrencyLimit는 worker 함수 여러 개를 만들어 items 길이만큼 nextIndex를 증가시키며 mapper를 호출합니다. 각 워커는 자신의 인덱스가 배열 길이를 넘으면 종료됩니다.
export async function mapWithConcurrencyLimit<T, R>(
items: T[],
limit: number,
mapper: (item: T, index: number) => Promise<R>,
): Promise<R[]> {
const results: R[] = new Array(items.length);
let nextIndex = 0;
async function worker() {
while (nextIndex < items.length) {
const current = nextIndex++;
try {
results[current] = await mapper(items[current], current);
} catch {
// 실패한 항목은 undefined로 채워둔다.
// @ts-expect-error 오류 허용
results[current] = undefined;
}
}
}
await Promise.all(
Array.from({ length: Math.min(limit, items.length) }, () => worker()),
);
return results;
}
실제 호출부에서는 다음과 같이 사용했습니다. 배열 길이가 수십 개라도 동시에 실행되는 요청 수는 6개를 넘지 않습니다.
const latestMessages = await mapWithConcurrencyLimit(chatRoomIds, 6, async roomId => {
const { data } = await supabase
.from('chat_message')
.select('*')
.eq('chat_room_id', roomId)
.order('created_at', { ascending: false })
.limit(1);
return data?.[0] ?? null;
});
mapper가 던진 오류는 catch에서 무시하고 결과 배열에 undefined를 넣습니다. 덕분에 Promise.all이 빠르게 실패하지 않고, 호출자가 실패한 항목만 골라 다시 시도할 수 있습니다.
채팅방 ID 리스트를 넣고 limit을 6으로 설정해 Supabase 호출이 6개 이상 동시에 나가지 않도록 했습니다.
current 값을 기록했습니다. 나중에는 Sentry에 이벤트를 보내 어느 방이 실패했는지 추적했습니다.지금은 채팅방이 수십 개여도 최신 메시지를 안정적으로 가져옵니다. 429 오류가 사라지고, 사용자에게는 항상 최신 콘텐츠를 보여줄 수 있게 됐죠. 앞으로는 백오프 전략을 추가해 Supabase가 과부하 상태일 때 자동으로 속도를 늦출 계획입니다.
여러분은 비동기 병렬 호출을 어떻게 조율하고 계신가요? 다른 패턴이 있다면 댓글로 공유해 주세요. 상황에 따라 적절한 동시성 제한을 정하는 방법이 궁금합니다.