48.1 그냥 문자열 보내면 안 되나요?
연결이라는 통로를 뚫었으니, 이제 그 통로로 무언가를 주고받을 차례예요. 웹소켓의 send는 아무 문자열이나 받아줘요. 그래서 처음엔 다들 이렇게 해요. "안녕하세요"라고 보내고, 상대는 그 문자열을 그대로 화면에 붙이는 거죠. 채팅만 있을 땐 이게 돌아가요.
문제는 기능이 두 개 이상이 되는 순간 터져요. 채팅 메시지도 오고, 알림도 오고, 연결 확인용 pong도 오고, 누가 입장했다는 신호도 와요. 전부 똑같은 통로로요. 받는 쪽 입장에서 "지금 온 이 문자열이 채팅인가, 알림인가, pong인가"를 어떻게 구별하죠? 문자열만 봐서는 알 길이 없어요. 저는 초기에 "메시지 앞글자가 !면 명령, 아니면 채팅" 같은 주먹구구 규칙을 만들었다가, 사용자가 느낌표로 시작하는 말을 하는 순간 다 꼬여버렸어요. 그래서 실시간 앱은 시작부터 메시지의 형식을 약속하고 가야 해요. 이 약속을 프로토콜(protocol)이라고 불러요.
48.2 메시지 봉투가 대체 뭐예요?
가장 널리 쓰는 약속은 봉투(envelope) 방식이에요. 편지를 봉투에 넣어 보내듯, 실제 내용물을 겉면에 종류를 적은 봉투에 담는 거예요. 자바스크립트에선 JSON(제이슨, 데이터를 문자열로 표현하는 형식)으로 이 봉투를 만들어요. 봉투의 뼈대는 딱 두 칸이에요.
{
"type": "chat",
"payload": { "text": "안녕하세요", "room": "general" }
}
type은 이 메시지가 무슨 종류인지 적은 겉면이에요. "chat", "notification", "ping" 같은 딱지죠. payload(페이로드, 실을 짐)는 그 종류에 필요한 실제 내용물이고요. 채팅이면 텍스트와 방 이름, 알림이면 알림 제목이 여기 들어가요. 이 두 칸만 약속해두면, 받는 쪽은 봉투 겉면(type)만 보고 "아 이건 채팅이구나" 하고 바로 갈라낼 수 있어요. 내용물의 모양이 종류마다 달라도 상관없어요. 겉면이 안내해주니까요.
48.3 타입으로 어떻게 갈라 처리하나요?
받는 쪽은 문자열을 봉투로 되돌린 다음, type을 보고 알맞은 처리로 보내요. 가장 읽기 쉬운 방법은 switch로 종류마다 갈래를 나누는 거예요.
ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
switch (msg.type) {
case "chat":
appendChat(msg.payload);
break;
case "notification":
showNotification(msg.payload);
break;
case "pong":
markAlive();
break;
default:
console.warn("모르는 타입:", msg.type);
}
};
여기서 default 갈래를 꼭 넣으세요. 서버가 나중에 새 타입을 추가했는데 우리 앱은 아직 모를 수 있잖아요. 그때 default가 없으면 조용히 무시되거나 에러가 나는데, 경고라도 찍어두면 "어 서버가 새 걸 보내네" 하고 바로 알아챌 수 있어요. 종류가 많아지면 switch 대신 객체 지도를 쓰기도 해요. { chat: appendChat, notification: showNotification } 처럼 type을 열쇠로 함수를 꺼내는 방식인데, 갈래가 열댓 개 넘어가면 이쪽이 훨씬 깔끔해요.
48.4 보내는 쪽은 매번 감싸야 하나요?
맞아요. 보낼 때마다 손으로 봉투를 만들고 JSON으로 바꾸는 건 귀찮고 실수하기 쉬워요. type 철자를 하나 틀리면 상대가 못 알아듣거든요. 그래서 봉투 싸는 함수 하나를 만들어 두고 늘 그걸 거쳐 보내요.
function sendMessage(ws, type, payload) {
if (ws.readyState !== WebSocket.OPEN) {
console.warn("연결 안 됨, 전송 취소");
return;
}
ws.send(JSON.stringify({ type, payload }));
}
// 쓸 때는 이렇게
sendMessage(ws, "chat", { text: "안녕", room: "general" });
이 함수의 진짜 값어치는 readyState 검사에 있어요. 연결이 끊긴 상태에서 send를 부르면 브라우저가 예외를 던져 앱이 멈출 수 있어요. 그래서 보내기 전에 WebSocket.OPEN인지 확인하는 거예요. 이 한 겹만 있어도 "재연결 중에 사용자가 전송 버튼을 눌러 앱이 죽는" 사고를 막아줘요. 저는 이 검사를 안 넣었다가, 지하철에서 쓰던 사용자들에게서만 앱이 멈춘다는 이상한 제보를 받고 원인을 찾느라 고생했어요.
48.5 id나 시간은 왜 넣어요?
type과 payload만으로도 돌아가지만, 실무에선 봉투에 몇 칸을 더 붙이는 게 보통이에요. 대표적으로 id와 보낸 시각이에요.
{
"type": "chat",
"id": "a1b2c3",
"ts": 1720000000000,
"payload": { "text": "안녕하세요" }
}
id는 메시지마다 붙는 고유 번호예요. 이게 있으면 중복을 걸러낼 수 있어요. 재연결 과정에서 같은 메시지가 두 번 도착하는 일이 실제로 생기는데, id가 같으면 "이미 처리했다"고 버리면 되죠. 또 받았다는 확인 응답(ack)을 보낼 때도 "몇 번 메시지 받았어"라고 id로 콕 집어 말할 수 있어요. ts(timestamp, 보낸 시각)는 메시지를 시간순으로 정렬할 때 써요. 네트워크 사정으로 도착 순서가 뒤바뀌어도, ts를 보고 제자리에 꽂아 넣을 수 있거든요. 이 두 칸은 지금 당장 안 써도, 나중에 반드시 필요해지니 처음부터 넣어두길 권해요. 봉투 형식을 뒤늦게 바꾸는 건 서버와 클라이언트를 동시에 고쳐야 해서 훨씬 번거롭거든요.
48.6 이상한 메시지가 오면 어떡하죠?
네트워크 너머에서 오는 데이터는 절대 착하다고 믿으면 안 돼요. 반쯤 잘려 온 문자열, 형식이 깨진 JSON, type이 아예 없는 봉투가 실제로 날아와요. 그런데 JSON.parse는 형식이 깨지면 예외를 던져요. 이걸 그냥 두면 메시지 하나 잘못 왔다고 앱 전체가 멈춰요.
ws.onmessage = (event) => {
let msg;
try {
msg = JSON.parse(event.data);
} catch {
console.warn("깨진 메시지 무시");
return;
}
if (!msg || typeof msg.type !== "string") {
console.warn("type 없는 메시지 무시");
return;
}
route(msg); // 여기서부터 안심하고 처리
};
parse는 반드시 try 안에서 하고, 통과한 뒤엔 type이 진짜 문자열인지 한 번 더 확인해요. 이 두 겹의 문지기를 통과한 메시지만 실제 처리로 넘기는 거죠. 이렇게 해두면 서버가 실수로 이상한 걸 보내거나, 누가 장난으로 아무 문자열이나 밀어넣어도 앱은 그 하나만 조용히 버리고 멀쩡히 계속 돌아요. 실시간 앱은 24시간 켜져 있는 경우가 많아서, 이런 방어 한 겹이 있고 없고가 안정성에서 큰 차이를 내요.
48.7 프로토콜 설계를 정리하면요
메시지 설계는 눈에 안 보이는 약속이라 소홀히 하기 쉬운데, 여기가 엉성하면 기능이 늘 때마다 코드가 스파게티가 돼요.
1. 통로 하나로 여러 종류가 오니, 봉투에 담아 종류를 겉면에 적으세요.
2. 뼈대는 type과 payload, 받는 쪽은 type으로 갈래를 나눠 처리해요.
3. 보낼 땐 봉투 싸는 함수를 거치고, readyState로 연결을 확인하세요.
4. id와 시각을 미리 넣어 중복 제거와 정렬에 대비하세요.
5. 들어오는 메시지는 try와 검사로 걸러, 하나 깨져도 앱은 살아있게 하세요.
봉투 형식은 한번 정하면 서버와 클라이언트가 같이 지켜야 하는 헌법 같은 거예요. 그래서 저는 프로젝트를 시작할 때 이 봉투 모양부터 문서에 딱 박아두고 시작해요. 그 한 장이 나중에 수십 시간의 혼란을 막아줘요.