Zettelkasten

HTTP fan-out 동시성 캡은 TCP 연결 풀로 걸어야 ephemeral port가 안 터진다

·수정 1

요약

  • in-flight Promise 카운팅으로 동시성을 막아도, 끝난 연결이 TIME_WAIT으로 남아 ephemeral port를 잡아먹는다
  • connection pool은 같은 숫자를 port 수준의 동시성으로 해석해 keep-alive로 소켓을 재사용하므로 port가 늘지 않는다

본문

문제: Promise 카운터는 "끝난 연결"을 못 본다

1000개 fetch를 32개씩 굴린다고 하자. Semaphore로 in-flight Promise를 32로 제한하면 JS 레벨에서는 깔끔하게 막힌다.

근데 매번 새 TCP 소켓을 열고 닫는 패턴이면, fetch가 끝나도 그 연결은 OS의 TIME_WAIT 상태로 ~60초간 ephemeral port를 점유한다.

"실행 중인 Promise" 기준으로는 32지만, "OS가 들고 있는 소켓" 기준으로는 수백~수천이 누적될 수 있다. ephemeral port 범위(~28K)를 다 쓰면 EADDRINUSE / fetch failed.

해결: 같은 32를 "TCP 소켓 32개"로 해석한다

undici Pool 같은 connection pool은 동시성 캡을 port 수준에서 건다.

  • 소켓 32개를 만들고 HTTP keep-alive로 재사용 → 연결을 닫지 않음
  • TIME_WAIT이 발생하지 않음 (닫는 행위 자체가 없음)
  • 1000개 요청이 와도 ephemeral port는 32개로 고정

핵심은 "32개의 Promise"가 아니라 "32개의 소켓" — 캡을 거는 자원의 단위가 다르다.

식당 비유

  • Promise 카운팅 = "동시 손님 32명까지" — 손님 나간 뒤 청소 중인 테이블이 따로 쌓여서 테이블 수가 무한정 늘 수 있음 (TIME_WAIT)
  • Pool = "테이블 32개로 박아두고 손님만 갈아끼움" — 테이블 자체는 늘지 않음

언제 쓰나

  • 같은 origin으로 fan-out이 큰 배치 (예: storeId × date 조합 수백 건)
  • 짧고 빠른 요청을 반복할 때 (TIME_WAIT 누적이 빠름)
  • 한 번에 띄우는 요청 수가 ephemeral port 범위 근처일 때

한계

  • HTTP/2 multiplexing: 한 소켓에 여러 요청이 다중화돼서 "소켓 = 동시성" 등식이 깨짐
  • 서버가 keep-alive를 거부하거나 idle timeout이 짧으면 결국 새 연결이 생긴다 — Pool의 의미가 약해짐
  • 다른 origin으로의 fan-out은 origin별 Pool이 별도로 필요 (한 Pool은 한 origin 전용)

참고