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 전용)
참고
- origin별 connection pool은 HTTP 클라이언트의 Bulkhead 패턴 구현이다 — 이 노트는 "왜 Pool이어야 하는가(소켓 단위 캡)", 저쪽은 "왜 origin별로 나눠야 하는가". 자연스러운 부모 맥락
- connection pool 고갈 문제를 layered defence를 이용해 해결할 수 있다. — pool 고갈을 다층 방어로 막는 일반론. 이 노트는 "왜 in-flight Promise 카운팅이 pool_size 역할을 대신할 수 없는가"의 근거
- HTTP 2.0 — multiplexing은 한 소켓에 여러 요청을 다중화해서 "소켓 = 동시성" 등식이 깨진다 (이 노트의 한계 섹션 근거)