DB에서 상품 URL 8천 개쯤 뽑아서 각 페이지의 재고를 확인하는 스크립트를 짰어요. 빨리 끝내려고 Promise.all로 한 방에 다 돌렸는데, 처음 몇백 개는 되다가 그 뒤로 에러가 쏟아집니다.
Error: connect EMFILE ...
FetchError: request to https://... failed, reason: socket hang up
Error: getaddrinfo ENOTFOUND (간헐적)const results = await Promise.all(urls.map(u => fetch(u))) 이런 식으로 짰습니다. 8천 개를 동시에 여는 게 문제인 건 알겠는데, 그렇다고 for문으로 하나씩 순차 처리하면 너무 느려서요. 적당히 병렬로 돌리면서 안 터지게 하려면 어떻게 하나요?
댓글 1
해결은 동시 실행 개수를 제한하는 겁니다. 순차(1개)와 전체 병렬(8천 개) 사이 어딘가, 보통 10~50 정도로 잡아요.
제일 쉬운 건 p-limit 같은 라이브러리예요.
const pLimit = require('p-limit')
const limit = pLimit(20)
const results = await Promise.all(urls.map(u => limit(() => fetch(u))))
코드 구조는 그대로 두고 동시 실행만 20개로 묶입니다. limit()이 슬롯 빌 때까지 대기시켜줘요.
라이브러리 없이 하려면 URL을 20개씩 청크로 잘라 청크마다 Promise.all 하고 다음으로 넘어가는 방식도 됩니다. 다만 이건 청크 안에서 제일 느린 하나가 끝나야 다음으로 가서, 느린 요청이 섞이면 스루풋이 떨어져요. 그래서 워커 패턴(고정 개수의 async 워커가 공유 큐에서 하나씩 꺼내 처리)이 더 균일합니다.
실전 팁도 같이 챙기세요. 첫째, fetch에 타임아웃을 꼭 거세요. AbortController로 몇 초 넘으면 끊게요. 안 그러면 죽은 커넥션 하나가 슬롯을 영영 물고 있어서 전체가 멈춥니다. 둘째, Promise.all은 하나만 reject돼도 전체가 실패하니, 개별 실패를 허용하려면 Promise.allSettled를 쓰거나 각 요청 catch에서 에러를 값으로 담으세요. 셋째, 같은 호스트로 많이 쏘는 거면 keep-alive Agent를 하나 만들어 재사용하면 커넥션을 재활용해서 오히려 더 빠르고 FD도 아낍니다. undici면 Pool/Agent의 connections 옵션으로 동시성을 커넥션 풀 레벨에서 제한할 수도 있어요.
정리하면 동시성 20~30 제한 + 요청 타임아웃 + allSettled. 이 세 개면 8천 개든 8만 개든 안 터지고 돌아갑니다.