WebSocket 완전 정복 · 2부. 중급: 프로토콜 내부와 설계 패턴 · 16편 중 8편
이전 편: 7편 — 제어 프레임
프레임과 제어 프레임까지가 프로토콜이 정해 주는 전부입니다. 메시지 안에 무엇을 어떤 형식으로 담을지는 WebSocket이 정하지 않으므로, 이번 편은 그 애플리케이션 프로토콜을 직접 설계합니다 — 봉투 형식, 요청과 응답을 잇는 correlation id, 재연결 복구의 토대가 되는 seq입니다.
봉투(envelope) 형식
WebSocket은 메시지의 의미를 정하지 않으므로 애플리케이션 프로토콜을 직접 설계합니다.
ts
type Envelope =
| { type: "subscribe"; id: string; channel: string }
| { type: "unsubscribe"; id: string; channel: string }
| { type: "publish"; id: string; channel: string; data: unknown }
| { type: "ack"; id: string; ok: boolean; error?: string }
| { type: "event"; channel: string; seq: number; data: unknown };다섯 가지 원칙
type으로 분기하고, 모르는 타입은 무시하지 말고 오류 응답.- 요청-응답에는
id(correlation id). 클라이언트가 만들고 서버가ack에 실어 보냄. - 서버 이벤트에는 채널별
seq. 재연결 후 "마지막 seq 이후"를 요청할 수 있고 누락·중복 감지 가능. - 처음부터 버전 관리(서브프로토콜
chat.v1또는v필드). 배포 중엔 구·신버전이 반드시 섞임. - 성능이 중요하면 MessagePack/Protobuf 바이너리. 단, 병목 확인 후에.
요청-응답을 잇는 RPC 클라이언트
ts
class RpcClient {
private pending = new Map<string, (msg: any) => void>();
constructor(private ws: WebSocket) {
ws.addEventListener("message", (e) => {
const msg = JSON.parse(e.data);
if (msg.type === "ack") {
this.pending.get(msg.id)?.(msg);
this.pending.delete(msg.id);
}
});
}
request(body: object, timeoutMs = 5000): Promise<any> {
const id = crypto.randomUUID();
return new Promise((resolve, reject) => {
const t = setTimeout(() => {
this.pending.delete(id);
reject(new Error("timeout"));
}, timeoutMs);
this.pending.set(id, (m) => { clearTimeout(t); resolve(m); });
this.ws.send(JSON.stringify({ ...body, id }));
});
}
}더 깊이
- 9편 — 재연결 전략: 지수 백오프와 지터, seq 기반 상태 복구: 봉투에 넣어 둔
seq가 실제로 일하는 곳입니다. 재연결 직후 "마지막 seq 이후"를 요청하고 중복을 걸러 내는 클라이언트 코드가 있습니다.