본문으로 건너뛰기
개발 머꼬
개발 노트Node.js
hohyeon.dev25

외부 API 호출이 느려서 보니 매번 TCP 연결을 새로 맺고 있던 문제

  • #Engineering Note
  • #Node.js
  • #Performance

문제 발생

외부 결제 API 호출이 평균 400ms였는데, 그중 상당 부분이 응답 대기가 아니라 연결 수립이었습니다. 부하가 오르자 TIME_WAIT 소켓이 쌓였습니다.

await fetch("https://api.example.com/charge", { method: "POST", body });

원인 분석

요청마다 TCP+TLS 핸드셰이크를 다시 합니다. HTTPS라면 왕복이 여러 번 필요하고, 상대 서버가 멀수록 그 비용이 그대로 응답 시간에 더해집니다.

Node.js 문서는 이 비용을 없애는 장치를 설명합니다 — http.Agent의 **keepAlive 기본값은 false**이고, 켜면 처리 중인 요청이 없어도 소켓을 유지해, TCP 연결을 다시 맺지 않고 이후 요청에 재사용할 수 있습니다.

에이전트의 역할도 정리돼 있습니다 — 주어진 호스트·포트에 대한 대기 요청 큐를 관리하며 큐가 빌 때까지 하나의 소켓 연결을 재사용하고, 풀링된 연결에는 TCP Keep-Alive가 켜집니다. 다만 서버가 유휴 연결을 닫을 수는 있습니다.

동시성 한도도 여기 있습니다 — **maxSockets 기본값은 Infinity**로, origin당 열 수 있는 동시 소켓 수를 정합니다.

해결 방안

  1. 에이전트를 만들어 재사용합니다. 모듈 스코프에 하나 두고 모든 호출이 공유합니다.
import { Agent } from "node:https";
const agent = new Agent({ keepAlive: true, keepAliveMsecs: 15_000, maxSockets: 50 });
  1. maxSockets를 무한으로 두지 않습니다. 상대 서버의 rate limit과 우리 쪽 파일 디스크립터를 고려해 상한을 정합니다 — 무한은 장애 상황에서 연결을 폭주시킵니다.

  2. fetch(undici)를 쓴다면 그쪽 커넥션 풀을 설정합니다. Node의 전역 fetchhttp.Agent가 아니라 undici의 Agent/Pool을 사용합니다 — 설정 지점이 다릅니다.

  3. 서버가 연결을 닫는 경우를 감안합니다. 문서가 지적하듯 유휴 연결은 상대가 닫을 수 있습니다 — 재시도 로직에서 ECONNRESET을 정상적인 상황으로 처리합니다.

  4. 효과를 측정합니다. 연결 수립 시간과 전체 응답 시간을 나눠 재보면 이 설정의 이득이 그대로 보입니다. 같은 호스트로 반복 호출이 많을수록 큽니다.

  5. 호출이 드물면 굳이 켜지 않습니다. 하루 몇 번 호출하는 API에는 유휴 소켓 유지 비용이 이득보다 클 수 있습니다.

공식 문서

마지막 수정

좋아요북마크

댓글0

아직 댓글이 없어요. 첫 의견을 편하게 남겨 보세요.