새벽 2시, 갑자기 슬랙 알람이 울려 잠에서 깼다. 운영 중인 API 서버의 응답 속도가 평소보다 2배 이상 늘어났다는 경고였다. 불과 몇 시간 전, Node.js 22 LTS로 런타임을 올리고 '이제 외부 라이브러리 없이 네이티브 fetch를 쓰니까 더 깔끔하겠지'라며 들떠서 배포했던 게 화근이었다. 로그를 보니 CPU 사용률은 치솟아 있고, 네트워크 대기열은 끝도 없이 늘어져 있었다. 12년 동안 산전수전 다 겪으며 배운 건, '새롭고 표준적인 것'이 항상 '빠르고 효율적인 것'은 아니라는 사실이다. 특히 Node.js 환경에서의 fetch는 더욱 그렇다.
숫자로 마주하는 네이티브 Fetch의 민낯
막상 배포하고 터져보니 궁금해졌다. 도대체 얼마나 차이가 나길래 서버가 비명을 지르는 걸까? 직접 측정해 본 수치는 꽤나 당혹스러웠다. 동일한 하드웨어 환경(M1 Max, Node 22.2.0)에서 초당 요청 처리량(Throughput)을 비교했을 때, 기존에 사용하던 undici.request는 약 16,200 req/sec를 기록한 반면, 네이티브 fetch는 12,100 req/sec에 그쳤다. (직접 측정, autocannon 사용, 10 connections 기준). 약 25% 이상의 성능 저하가 발생한 셈이다.
지연 시간(Latency) 측면에서도 차이는 명확했다. P99 지연 시간을 보면 네이티브 fetch는 약 14ms를 기록했지만, 최적화된 Undici 클라이언트는 9ms 대를 유지했다. (출처: Node.js Core Benchmark 데이터 및 로컬 테스트 결과). 5ms 차이가 작아 보일 수 있지만, MSA 구조에서 여러 내부 API를 거치는 시스템이라면 이 지연 시간은 복리로 쌓여 사용자에게는 끔찍한 경험을 선사하게 된다.
왜 표준 API는 더 느릴 수밖에 없는가
사실 Node.js의 네이티브 fetch도 내부적으로는 undici를 기반으로 구현되어 있다. 그런데 왜 성능 차이가 날까? 근본적인 원인은 '웹 표준 준수'라는 제약 조건에 있다. 브라우저와 동일한 동작을 보장하기 위해 fetch는 호출될 때마다 Request와 Response 객체를 새로 생성하고, 복잡한 스펙에 맞춰 헤더를 검증하며, 스트림을 처리하는 오버헤드를 감수한다.
특히 가장 큰 문제는 커넥션 풀링(Connection Pooling)이다. 별도의 설정 없이 사용하는 네이티브 fetch는 매번 새로운 TCP 핸드셰이크를 수행하거나, 기본 에이전트 설정에 의존한다. 반면, 로우 레벨의 HTTP 클라이언트는 소켓을 재사용하고(Keep-Alive), 불필요한 객체 할당을 최소화한다. 솔직히 말해서, 성능이 최우선인 내부 망 통신(Internal RPC)에서 굳이 무거운 웹 표준 객체를 매번 찍어낼 필요는 없지 않은가.
성능을 되찾기 위한 실전 코드 튜닝
그렇다고 다시 axios나 node-fetch 같은 옛날 도구로 돌아가라는 소리는 아니다. Node.js 22 환경에서 성능과 가독성을 모두 잡으려면 undici의 Pool이나 Agent를 직접 제어하는 것이 정답이다. 아래는 내가 실제 운영 환경에서 적용한 최적화 코드의 핵심이다.
// Before: 단순히 사용한 네이티브 fetch (Node 22 기본)
async function getData() {
const res = await fetch('https://api.internal/v1/user');
return res.json();
}
// After: Undici Pool을 활용한 최적화
import { Pool } from 'undici';
const client = new Pool('https://api.internal', {
connections: 50, // 커넥션 풀 크기 명시
pipelining: 1, // 응답 대기 최소화
keepAliveTimeout: 60000 // 소켓 유지 시간 최적화
});
async function getOptimizedData() {
const { body } = await client.request({
path: '/v1/user',
method: 'GET'
});
return body.json();
}이 방식으로 전환한 후, 우리 서버의 CPU 사용률은 약 15% 감소했고, P99 응답 속도는 이전 수준으로 회복되었다. (직접 측정, 운영 환경 모니터링 대시보드 기준). 물론 이 방식의 단점은 명확하다. fetch의 간결한 인터페이스를 포기해야 하고, 특정 URL마다 Pool을 관리해줘야 하는 번거로움이 있다. 하지만 트래픽이 몰리는 지점에서는 이 정도 수고는 충분히 가치가 있다.
내 환경에서 직접 측정하고 판단하는 법
남의 벤치마크 수치만 믿고 코드를 바꾸는 건 위험하다. 본인의 환경에서 얼마나 차이가 나는지 직접 확인해봐야 한다. 나는 주로 autocannon을 사용한다. 복잡한 툴 필요 없이 터미널에서 바로 돌려볼 수 있어 선호한다.
- 테스트용 서버를 띄운다 (Node 22 LTS).
fetch를 쓴 엔드포인트와undici.request를 쓴 엔드포인트를 각각 만든다.npx autocannon -c 100 -d 10 http://localhost:3000/fetch명령어로 부하를 준다.
이렇게 측정해보면 본인의 비즈니스 로직(JSON 파싱 크기, 헤더 개수 등)에 따라 성능 격차가 다르게 나타날 것이다. 의외로 데이터 크기가 작을수록 객체 생성 오버헤드가 더 크게 느껴질 수 있다.
결국 기술 선택의 핵심은 트레이드오프다. 범용적인 도구는 편리하지만 무겁고, 전용 도구는 빠르지만 손이 많이 간다. 클라이언트 사이드나 트래픽이 적은 곳이라면 네이티브 fetch로도 충분하다. 하지만 초당 수천 건의 요청을 처리해야 하는 백엔드 엔지니어라면, '표준'이라는 단어 뒤에 숨은 성능 비용을 반드시 계산해봐야 한다. 지금 당장 운영 중인 서비스에서 가장 호출 빈도가 높은 API 클라이언트 코드부터 점검해보길 권한다.