Node.js 24 네이티브 WebSocket으로 서버 간 이벤트 통신의 ws 의존성 줄이기
package.json을 열 때마다 드는 생각이 있습니다. "이 의존성들이 다 필요한 걸까?" ws, node-fetch, axios... 프로젝트 초기에 당연하게 깔았던 것들이 어느 순간 CVE 알림과 버전 충돌의 원인이 됩니다. Node.js 24부터 그 고민 하나가 줄어듭니다 — WebSocket 클라이언트가 런타임에 전역으로 내장되었기 때문입니다.
저도 처음엔 "어차피 ws가 잘 돌아가는데 굳이?"라고 생각했습니다. 그런데 새 프로젝트에 ws를 다시 설치하는 순간, 브라우저와 다른 이벤트 모델, 별도 타입 정의, 유지보수 부담을 다시 마주하게 됩니다. 이 글은 그 마찰을 줄이는 관점에서, 마이크로서비스 간 실시간 이벤트 전달 시나리오를 중심으로 네이티브 WebSocket을 어디까지 쓸 수 있는지 정리합니다.
Node.js WebSocket 클라이언트가 걸어온 길
짧은 연표
브라우저에는 오래전부터 WebSocket이 전역 객체로 있었지만, Node.js는 오랫동안 WebSocket 클라이언트를 기본 제공하지 않았습니다. 그 자리를 채운 것이 ws 패키지 — npm 생태계에서 수천만 회 다운로드되는 사실상 표준 라이브러리입니다(정확한 수치는 시점에 따라 크게 다르므로 npm trends에서 조회하는 편이 정확합니다).
- v21: 실험적 WebSocket 클라이언트 도입 (
--experimental-websocket) - v22.4.0: 안정(stable) 격상, 플래그 없이 전역 사용 가능
- v24.0.0: 2025년 4월 24일 출시(공식 릴리즈 노트), Undici 7 번들링
여기서 자주 혼동되는 지점을 정리하면 — Node.js의 전역 WebSocket은 WHATWG WebSocket Living Standard라는 브라우저 JS API 명세를 따르고, 그 아래 와이어 프로토콜로는 RFC 6455를 사용합니다. 두 문서는 계층이 다른 별개의 명세이며, 한쪽이 다른 쪽의 별칭이 아닙니다. HTTP/3 위에서의 WebSocket은 RFC 9220로 별도 정의되어 있는데, 2026년 8월 기준 Node.js/Undici가 이 경로로 WebSocket을 제공한다는 공식 문서는 확인되지 않습니다.
브라우저와 동일한 API 표면
가장 직관적인 변화는 브라우저에서 쓰던 코드를 서버에서도 그대로 쓸 수 있다는 점입니다. import 없이 전역으로 사용합니다.
const ws = new WebSocket('ws://other-service:8080/events');
ws.addEventListener('open', () => {
ws.send(JSON.stringify({ type: 'subscribe', topic: 'orders' }));
});
ws.addEventListener('message', ({ data }) => {
console.log('수신:', JSON.parse(data));
});
ws.addEventListener('close', ({ code, reason }) => {
console.log(`연결 종료: ${code} - ${reason}`);
});
ws.addEventListener('error', (err) => {
console.error('WebSocket 에러:', err);
});ws와의 결정적 차이는 이벤트 모델입니다. ws는 Node.js EventEmitter 스타일(ws.on('message', ...))을, 네이티브는 브라우저와 동일한 EventTarget 스타일(addEventListener)을 씁니다. 프론트엔드 팀과 코드 패턴을 통일하고 싶다면 이 차이가 생각보다 크게 체감됩니다.
서버 간 이벤트 전달 시나리오
시나리오 1: 주문 서비스 → 알림 서비스
주문 상태가 바뀌면 알림 서비스가 즉시 알아야 하는데, 매 초 HTTP 폴링을 돌리면 레이턴시와 비용이 모두 늘어납니다. WebSocket 상시 연결을 이용하면 이벤트를 서비스 간에 밀어 보낼 수 있습니다.
인증 헤더 전달의 현실: WHATWG 표준의 WebSocket 생성자는 new WebSocket(url[, protocols]) 시그니처만 정의합니다. Authorization: Bearer ... 같은 커스텀 헤더를 표준 API로 넣을 방법이 없습니다. 서비스 간 인증을 다루려면 다음 중 하나입니다.
undici패키지의WebSocket클래스를 직접 사용(생성자 옵션으로 헤더 지원)- 인증 토큰을 서브프로토콜(
Sec-WebSocket-Protocol)로 전달 - 서명된 단기 토큰을 쿼리 스트링으로 전달(경로 및 로그에 노출됨을 유의)
아래는 서브프로토콜을 이용해 표준 시그니처만으로 인증을 넘기는 방식입니다.
// notification-service/src/order-events.js
const TOKEN = process.env.ORDER_SERVICE_TOKEN;
const ws = new WebSocket(
'ws://order-service:3000/events',
['v1.orders', `bearer.${TOKEN}`]
);
ws.addEventListener('message', ({ data }) => {
const event = JSON.parse(data);
if (event.type === 'ORDER_PLACED') {
sendPushNotification(event.userId, `주문 ${event.orderId} 접수됨`);
} else if (event.type === 'ORDER_SHIPPED') {
sendPushNotification(event.userId, `주문 ${event.orderId} 발송됨`);
}
});헤더가 꼭 필요하다면 번들된 undici를 명시적으로 임포트합니다. 별도 설치는 필요 없습니다.
import { WebSocket as UndiciWebSocket } from 'undici';
const ws = new UndiciWebSocket('ws://order-service:3000/events', {
headers: { Authorization: `Bearer ${TOKEN}` }
});시나리오 2: 자동 재연결 (직접 구현)
솔직히 말하면, 이 부분은 네이티브 WebSocket을 프로덕션에 올릴 때 가장 큰 숙제입니다. 네이티브 WebSocket에는 자동 재연결이 없습니다. 다만 이건 네이티브의 "단점"이라기보다는 "이전과 동일한 수준의 애플리케이션 책임"에 가깝습니다 — ws에서도 재연결은 애플리케이션 코드로 붙였습니다. 자동 재연결·폴링 폴백이 실제로 절실하다면 여전히 Socket.IO 같은 상위 계층이 정답에 가깝습니다.
직접 구현할 때 꼭 챙겨야 할 세 가지입니다.
- 지수 백오프
- jitter(요동)로 thundering herd 방지 — 다중 인스턴스가 동시에 끊어졌다가 정확히 같은 시각에 재접속을 시도하면 서버가 순간적으로 폭주합니다
- 정상 종료(코드 1000)와 비정상 종료의 명확한 구분
// utils/resilient-ws.js
function createResilientWebSocket(url, options = {}) {
const {
protocols,
maxRetries = 10,
baseDelay = 500,
maxDelay = 30_000,
onMessage,
} = options;
let retries = 0;
let ws = null;
let stopped = false;
function nextDelay() {
const exp = Math.min(maxDelay, baseDelay * 2 ** retries);
return Math.random() * exp;
}
function connect() {
ws = new WebSocket(url, protocols);
ws.addEventListener('open', () => {
retries = 0;
console.log(`[WS] 연결됨: ${url}`);
});
ws.addEventListener('message', ({ data }) => onMessage?.(data));
ws.addEventListener('close', ({ code }) => {
if (stopped || code === 1000) return;
if (retries >= maxRetries) {
console.error('[WS] 최대 재연결 시도 초과');
return;
}
const delay = nextDelay();
retries++;
console.log(`[WS] ${Math.round(delay)}ms 후 재연결 (${retries}/${maxRetries})`);
setTimeout(connect, delay);
});
ws.addEventListener('error', (err) => {
console.error('[WS] 에러:', err.message ?? err);
});
}
connect();
return {
close: () => { stopped = true; ws?.close(1000); },
};
}시나리오 3: 알아두면 좋은 것 — WebSocketStream (실험적)
대용량 로그나 텔레메트리처럼 backpressure가 중요한 스트림에는 WebSocketStream API가 어울립니다. 다만 이 API는 WICG 제안 단계의 초안으로, 2026년 8월 기준 브라우저·런타임 구현 상태가 제각각입니다. Node.js에서는 undici 쪽에 실험적으로 존재하지만 export 이름과 시그니처가 릴리즈마다 바뀔 수 있어, 프로덕션 코드보다는 실험용 스파이크에 한정하는 편이 안전합니다.
개념적 예시(사용 전 실제 사용 중인 undici 버전의 문서를 확인하세요).
// 개념적 예시 — 실제 export/시그니처는 버전에 따라 다를 수 있음
import { WebSocketStream } from 'undici';
const wss = new WebSocketStream('ws://build-agent:9000/logs');
const { readable } = await wss.opened;
const decoder = new TextDecoder();
for await (const chunk of readable) {
// 텍스트 프레임은 string, 바이너리는 Uint8Array로 전달됨
const text = typeof chunk === 'string' ? chunk : decoder.decode(chunk);
process.stdout.write(text);
}이 API를 눈여겨보는 이유는 성능 자체보다 Streams API와 자연스럽게 결합되어 파이프라인이 깔끔해진다는 점입니다. 프로덕션에서 backpressure가 지금 당장 필요하다면, 여전히 ws + 수동 흐름 제어가 안전한 선택입니다.
만능이 아닌 이유 — Redis Pub/Sub과의 공정한 비교
"Redis Pub/Sub은 인프라를 추가해야 한다"라고 쉽게 말하기 쉽지만, 대부분의 마이크로서비스 환경에서 Redis는 캐시·세션·레이트 리밋 등으로 이미 배포되어 있는 경우가 많습니다. WebSocket 상시 연결을 서비스 간 메시지 버스로 쓸 때 감수해야 할 것들을 짚어둡니다.
| 관점 | WebSocket 상시 연결 | Redis Pub/Sub |
|---|---|---|
| 지연 시간 | 매우 낮음 (연결만 유지되면 push) | 낮음 (브로커 경유) |
| 연결 끊김 구간 메시지 | 소실 (별도 오프셋/버퍼 필요) | 소실 (Pub/Sub 특성상 동일. Redis Streams는 별개) |
| 수평 확장 | 어느 인스턴스에 붙었는지 라우팅 필요 | 브로커가 팬아웃 처리 |
| 순서 보장 | 단일 연결 내에서만 보장 | 채널 단위 보장 |
| 인프라 추가 | 없음 (직접 통신) | Redis 필요 |
이벤트 유실이 허용되고 상대 서비스가 소수·고정 토폴로지라면 WebSocket 직접 연결이 단순하고 빠릅니다. 팬아웃이 넓거나 유실을 못 견디면 Kafka/NATS/Redis Streams 쪽이 옳은 선택입니다.
얻는 것과 감수하는 것
네이티브 전환의 이점
| 항목 | 내용 |
|---|---|
| 의존성 제거 | ws를 걷어내 공격 표면과 node_modules 크기 감소 |
| API 통일 | 브라우저와 동일한 EventTarget 기반 — 프론트·백엔드 코드 패턴 공유 |
| 표준 준수 | WHATWG WebSocket JS API를 그대로 따름 (와이어는 RFC 6455) |
| 번들된 Undici | HTTP 클라이언트와 WebSocket이 하나의 네트워킹 스택 위에 있음 |
| TLS 내장 | wss:// 처리에 별도 패키지 없음 |
성능 개선 수치는 이 글에서 인용하지 않습니다. 자신의 워크로드에서 ws와 네이티브 WebSocket을 직접 벤치마크하는 것을 권합니다.
현재의 한계
| 항목 | 설명 |
|---|---|
| 자동 재연결 없음 | 백오프·jitter 포함 직접 구현 필요 |
| 커스텀 헤더 제약 | 표준 생성자에 헤더 옵션 없음. undici.WebSocket 사용 또는 서브프로토콜/쿼리 우회 |
| 방·네임스페이스 없음 | Socket.IO의 rooms, namespaces 같은 고수준 추상화 없음 |
| STOMP/MQTT 미지원 | 서브프로토콜 위 애플리케이션 프로토콜은 직접 처리 |
| WebSocket 서버는 별개 | 이 글은 클라이언트에 집중. 서버까지 무의존성으로 가려면 node:http의 upgrade 이벤트에서 핸드셰이크·프레이밍을 직접 처리해야 함 — 서버는 ws 유지가 현실적 |
| Node.js 버전 제약 | 안정 API는 v22.4.0 이상. 이전 런타임 지원 시 ws 유지 |
언제 무엇을 쓰나
| 상황 | 선택 |
|---|---|
| Node.js 24, 서비스 간 소수 이벤트 스트림, 클라이언트만 필요 | 네이티브 WebSocket |
| Node.js에서 WebSocket 서버까지 직접 구현 | ws (성숙도·문서 우위) |
| rooms·자동 재연결·폴링 폴백이 실질적으로 필요 | Socket.IO |
| Node.js v22 미만 레거시 | ws |
| 초고성능 서버 (C++ 수준) | uWebSockets.js |
| 팬아웃 넓음 또는 유실 불가 | Kafka/NATS/Redis Streams |
결론 — 서버 간 통신에서 네이티브 WebSocket을 언제 쓸까
판단은 세 가지 질문으로 좁혀집니다.
- 클라이언트만 필요한가? 서버까지 무의존성으로 가려면 손이 많이 갑니다. 클라이언트 축소가 목적이면 지금이 좋은 시점입니다.
- 인증을 어떻게 넘길 것인가? 표준 시그니처만 쓸 수 있다면 서브프로토콜·서명 URL이 열려 있고, 헤더가 꼭 필요하면 번들된
undici.WebSocket이 답입니다. - 재연결 정책을 애플리케이션이 소유할 준비가 되어 있는가? jitter 포함 백오프를 팀 표준 유틸리티로 두는 것이 첫 번째 할 일입니다.
세 질문에 모두 "그렇다"라고 답할 수 있는 서비스라면, 새로 짜는 클라이언트에서 ws를 뺄 수 있습니다. 반대라면 ws는 여전히 훌륭한 선택입니다 — 나쁜 라이브러리라서 대체하는 게 아니라, 런타임이 그 역할을 흡수하기 시작해 상황에 따른 선택지가 하나 늘어난 것뿐입니다.