이전 글에서는 Ping/Pong 메커니즘과 Closing Handshake에 대해 다뤘다. 이번 글에서는 실제로 웹소켓 클라이언트를 구현하면서 만든 두 개의 클래스에 대해 이야기해보려 한다.
WebSocketConnectionHandle
역할
WebSocketConnectionHandle은 웹소켓 연결의 생명주기를 관리하는 추상 클래스다. 연결 수립, 재연결, 메시지 버퍼링, 연결 종료 등 웹소켓 통신에서 공통적으로 필요한 로직을 캡슐화한다.
export abstract class WebSocketConnectionHandle {
private ws: WebSocket | null = null;
private reconnectTimer: ReturnType<typeof globalThis.setTimeout> | null =
null;
private sendBuf: WebSocketMessage[] = [];
private defaultOptions: Partial<WebSocketManagerOptions>;
private currentOptions: WebSocketManagerOptions | null = null;
// ...
protected abstract onRawMessage(event: MessageEvent): void;
}
이 클래스는 abstract로 선언되어 있어서 직접 인스턴스화할 수 없다. 하위 클래스에서 onRawMessage 메서드를 구현해야만 사용할 수 있다. 이런 설계를 통해 “연결 관리”와 “메시지 처리”라는 두 관심사를 분리했다.
각 메서드의 역할
connect(options)
웹소켓 연결을 수립하는 메서드다. 이미 연결 중이거나 연결된 상태라면 아무것도 하지 않는다.
async connect(options: WebSocketManagerOptions): Promise<void> {
if (
this.ws?.readyState === WebSocket.OPEN ||
this.ws?.readyState === WebSocket.CONNECTING
) {
return;
}
const mergedOptions: WebSocketManagerOptions = {
...this.defaultOptions,
...options,
reconnect: options.reconnect
? {
...this.defaultOptions.reconnect,
...options.reconnect,
}
: this.defaultOptions.reconnect,
};
// ...
}
생성자에서 전달받은 기본 옵션과 connect 호출 시 전달받은 옵션을 병합한다. connect 옵션이 우선권을 가지며, 중첩 객체인 reconnect도 별도로 병합한다.
연결이 성공하면 기존 웹소켓 인스턴스를 정리하고, 새 인스턴스에 이벤트 핸들러를 등록한다. 그리고 버퍼에 쌓인 메시지들을 전송한다.
this.ws = ws;
ws.onmessage = (event: MessageEvent) => {
this.onRawMessage(event);
};
ws.onclose = (event: CloseEvent) => {
// 재연결 로직...
};
this.flushSendBuffer();
attemptConnection(options)
실제로 웹소켓 연결을 시도하는 private 메서드다. reconnect 옵션이 설정된 경우에만 Exponential Backoff1 패턴으로 재연결을 시도한다.
private attemptConnection(options: WebSocketManagerOptions): Promise<WebSocket> {
return new Promise((resolve, reject) => {
const { url, protocols, reconnect } = options;
const tryConnect = (currentAttempt = 1) => {
try {
const ws = new WebSocket(url, protocols);
ws.onopen = () => {
ws.onerror = null;
resolve(ws);
};
ws.onerror = (error) => {
// ... 핸들러 정리 ...
if (reconnect === undefined) {
return reject(error);
}
if (currentAttempt >= reconnect.maxAttempts) {
return reject(new Error("최대 재연결 시도 횟수를 초과했습니다."));
}
const delay = reconnect.interval * Math.pow(2, currentAttempt - 1);
this.reconnectTimer = setTimeout(() => {
tryConnect(currentAttempt + 1);
}, delay);
};
} catch (error) {
reject(error);
}
};
tryConnect();
});
}
interval이 1000ms이고 maxAttempts가 5라면, 재연결 시도는 1초, 2초, 4초, 8초 간격으로 이루어진다. 이렇게 하면 서버가 잠깐 내려갔다가 올라오는 경우에는 빠르게 재연결되고, 장기적인 문제인 경우에는 불필요한 요청을 줄일 수 있다.
sendRaw(data)
메시지를 전송하는 protected 메서드다. 연결이 열려있지 않으면 버퍼에 저장한다.
protected sendRaw(data: WebSocketMessage): void {
const currentWs = this.ws;
if (!currentWs || currentWs.readyState !== WebSocket.OPEN) {
const maxBufferSize = this.currentOptions?.maxBufferSize ?? 50;
if (this.sendBuf.length >= maxBufferSize) {
this.sendBuf.shift();
}
this.sendBuf.push(data);
return;
}
currentWs.send(data);
}
버퍼 크기에 제한을 두는 이유는 메모리 문제도 있지만, 오래된 메시지가 의미 없어지는 경우가 많기 때문이다. 예를 들어 실시간 위치 정보를 전송하는 경우, 10초 전의 위치 정보는 이미 쓸모가 없다. 따라서 버퍼가 가득 차면 가장 오래된 메시지를 버리는 FIFO 방식을 사용했다.
flushSendBuffer()
버퍼에 쌓인 메시지들을 전송하는 private 메서드다.
private flushSendBuffer(): void {
const maybeStaleWS = this.ws;
if (!maybeStaleWS || maybeStaleWS.readyState !== WebSocket.OPEN) {
return;
}
while (this.sendBuf.length > 0) {
const currentWs = this.ws;
if (
currentWs !== maybeStaleWS ||
currentWs.readyState !== WebSocket.OPEN
) {
break;
}
currentWs.send(this.sendBuf.shift()!);
}
}
maybeStaleWS라는 변수명을 사용한 이유가 있다. 버퍼를 비우는 도중에 웹소켓 인스턴스가 바뀌거나2 연결 상태가 변할 수 있다. 그래서 매 반복마다 현재 웹소켓이 여전히 유효한지 확인하는 방어 로직을 넣었다.
disconnect()
연결을 종료하는 메서드다.
disconnect(): void {
this.sendBuf = [];
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
if (this.ws) {
this.ws.onclose = null;
this.ws.onmessage = null;
this.ws.onerror = null;
this.ws.onopen = null;
this.ws.close(WebSocketReservedCloseCode.NormalClosure);
this.ws = null;
}
}
버퍼를 비우고, 재연결 타이머를 취소하고, 이벤트 핸들러를 모두 해제한 후 연결을 종료한다. 이벤트 핸들러를 먼저 해제하는 이유는 close() 호출 후 onclose 이벤트가 발생하면서 재연결 로직이 트리거되는 것을 방지하기 위함이다.
장점과 트레이드오프
이 클래스의 가장 큰 장점은 관심사 분리다. 연결 관리 로직과 메시지 처리 로직이 분리되어 있어서, 메시지 포맷이 바뀌어도 연결 관리 코드를 수정할 필요가 없다. 추상 클래스로 설계했기 때문에 다양한 메시지 포맷(JSON, Protocol Buffers, MessagePack 등)에 대응하는 하위 클래스를 만들 수 있다. 또한 연결이 끊긴 상태에서도 메시지를 버퍼에 저장해두고 연결이 복구되면 순서대로 전송하며, Exponential Backoff 패턴으로 재연결을 시도해서 일시적인 네트워크 문제에 대응할 수 있다.
반면 트레이드오프도 있다. 단순히 WebSocket API를 사용하는 것보다 코드가 복잡해지기 때문에 작은 프로젝트에서는 오버엔지니어링일 수 있다. 연결이 오래 끊긴 상태에서 메시지를 계속 보내면 버퍼가 가득 차고 오래된 메시지는 유실되므로, 버퍼 크기와 유실 정책을 프로젝트 특성에 맞게 조정해야 한다. 그리고 재연결 시도 중에 서버의 상태가 변했을 수 있기 때문에, 재연결 후 상태 동기화 로직을 별도로 구현해야 할 수도 있다.
ZodWebSocketManager
역할
ZodWebSocketManager는 WebSocketConnectionHandle을 확장해서 Zod 스키마 기반의 타입 안전한 메시지 송수신을 제공하는 클래스다.
export class ZodWebSocketManager<
TSend extends ZodWebSocketSchema,
TReceive extends ZodWebSocketSchema
> extends WebSocketConnectionHandle {
private sendSchema: TSend;
private receiveSchema: TReceive;
private subscribers: Map<
z.infer<TReceive>["type"],
Set<
(
data: Extract<z.infer<TReceive>, { type: z.infer<TReceive>["type"] }>
) => void
>
> = new Map();
// ...
}
제네릭 타입 TSend와 TReceive는 각각 송신과 수신 메시지의 Zod 스키마를 나타낸다. 이를 통해 컴파일 타임에 메시지 타입을 검사할 수 있다.
타입 안전성
자바스크립트에서 웹소켓 메시지를 주고받을 때 가장 골치 아픈 부분이 타입이다. WebSocket.send()는 문자열, Blob, ArrayBuffer 등을 받을 수 있고, onmessage 이벤트의 data도 마찬가지다. JSON을 주고받는다고 해도 파싱 후 타입이 any가 되어버린다.
Zod를 사용하면 이 문제를 해결할 수 있다. 송신 스키마와 수신 스키마를 각각 정의하고, 메시지를 보내거나 받을 때 검증을 수행한다.
const sendSchema = z.discriminatedUnion("type", [
z.object({
type: z.literal("ping"),
}),
z.object({
type: z.literal("subscribe"),
channel: z.string(),
}),
]);
const receiveSchema = z.discriminatedUnion("type", [
z.object({
type: z.literal("pong"),
}),
z.object({
type: z.literal("message"),
channel: z.string(),
payload: z.unknown(),
}),
]);
Discriminated Union을 사용하면 type 필드를 기준으로 메시지를 구분할 수 있다. 그리고 Zod의 parse 메서드를 통해 런타임에서도 타입 검증이 이루어진다.
send(message: z.infer<TSend>): void {
try {
const validatedMessage = this.sendSchema.parse(message);
super.sendRaw(JSON.stringify(validatedMessage));
} catch (error) {
console.error("Failed to send message:", error);
throw error;
}
}
protected onRawMessage(event: MessageEvent): void {
try {
const rawData = JSON.parse(event.data);
const parsedData = this.receiveSchema.parse(rawData);
// ...
} catch (error) {
console.error("Failed to parse message:", error);
}
}
송신 시 검증에 실패하면 에러를 던지고, 수신 시 검증에 실패하면 에러를 로깅하고 무시한다. 이렇게 한 이유는 송신은 개발자가 제어할 수 있지만, 수신은 서버나 네트워크 상태에 따라 예상치 못한 데이터가 올 수 있기 때문이다.
구독 패턴
수신 메시지를 처리할 때 pub/sub 패턴을 사용했다. 각 메시지 타입별로 구독자를 등록하고, 해당 타입의 메시지가 오면 구독자들에게 알려준다.
subscribe<TReceiveType extends z.infer<TReceive>["type"]>(
type: TReceiveType,
callback: (data: Extract<z.infer<TReceive>, { type: TReceiveType }>) => void
): () => void {
if (!this.subscribers.has(type)) {
this.subscribers.set(type, new Set());
}
const typeSubscribers = this.subscribers.get(type)!;
typeSubscribers.add(callback as any);
return () => {
typeSubscribers.delete(callback as any);
if (typeSubscribers.size === 0) {
this.subscribers.delete(type);
}
};
}
타입스크립트의 Extract 유틸리티 타입을 사용해서 콜백 함수의 인자 타입을 좁혔다. 예를 들어 subscribe("message", callback)을 호출하면, callback의 인자 타입은 { type: "message", channel: string, payload: unknown }이 된다.
pub/sub 패턴으로 디자인한 이유는 React의 useEffect와 잘 어울리기 때문이다. 구독 함수가 구독 해제 함수를 반환하므로, useEffect의 클린업 함수로 그대로 사용할 수 있다.
useEffect(() => {
return api.subscribe("message", (data) => {
console.log(data.channel, data.payload);
});
}, [api.subscribe]);
컴포넌트가 언마운트되거나 의존성이 변경되면 자동으로 구독이 해제된다. 메시지 타입별로 구독을 분리해두면 각각의 useEffect에서 독립적으로 처리할 수 있어서, 관심사 분리도 자연스럽게 이루어진다.
트레이드오프
현재 구현은 JSON.stringify()와 JSON.parse()를 사용하기 때문에 JSON 형식의 메시지만 주고받을 수 있다. ArrayBuffer나 Blob 같은 바이너리 데이터는 처리할 수 없으며, 만약 바이너리 데이터를 주고받아야 한다면 별도의 구현체를 만들거나 Base64 인코딩을 거쳐야 한다.
Zod 라이브러리에 의존하게 되므로 번들 크기가 중요한 프로젝트에서는 고려해봐야 한다. 다만 Zod는 트리쉐이킹이 잘 되는 편이고 타입 검증의 이점이 크기 때문에 대부분의 경우 감수할 만하다.
또한 type 필드를 기준으로 메시지를 구분하도록 강제했기 때문에, 다른 필드명을 사용하고 싶거나 단일 타입의 메시지만 주고받는 경우에는 맞지 않을 수 있다. 다만 실무에서 웹소켓 메시지는 대부분 여러 종류가 있고 이를 구분할 필드가 필요하기 때문에 합리적인 제약이라고 생각한다.
마지막으로 모든 메시지에 대해 Zod 검증이 수행되므로 약간의 성능 오버헤드가 있다. 초당 수천 개의 메시지를 처리해야 하는 극단적인 경우에는 문제가 될 수 있지만, 일반적인 사용 사례에서는 무시할 수 있는 수준이다.
웹소켓 클라이언트를 직접 구현하면서 느낀 점은, 단순해 보이는 기능이라도 엣지 케이스를 고려하면 생각보다 복잡해진다는 것이다. 특히 재연결 로직과 타입 안전성은 꽤나 까다로웠다. 하지만 이런 고민들을 한 번 해두면, 이후에 비슷한 문제를 만났을 때 훨씬 수월하게 해결할 수 있다.