47.1 실시간은 왜 새로고침이 필요 없죠?
우리가 흔히 만드는 웹은 대부분 요청하고 응답받으면 끝이에요. 사용자가 버튼을 누르면 서버에 물어보고, 답을 받아 화면을 그리고, 연결은 그걸로 닫혀요. 그런데 채팅이나 알림처럼 서버가 먼저 말을 걸어야 하는 서비스는 이 방식으로는 곤란해요. 서버가 "새 메시지 왔어"라고 알려줄 통로가 없거든요.
옛날엔 이걸 폴링(polling, 주기적으로 계속 물어보기)으로 때웠어요. 3초마다 "새 거 있어? 새 거 있어?" 하고 서버를 두드리는 거죠. 저도 첫 채팅 프로젝트를 이렇게 만들었는데, 사용자가 조금만 늘어도 서버가 빈 응답을 수천 번씩 돌려주느라 헉헉댔어요. 대부분은 "없어"라는 답이었는데도요. 이 낭비를 없애려고 나온 게 WebSocket(웹소켓)이에요. 한 번 연결해두면 그 통로가 계속 열려 있어서, 서버든 클라이언트든 필요할 때 아무 때나 메시지를 밀어넣을 수 있어요.
47.2 연결은 어떻게 여나요?
웹소켓 연결은 딱 한 줄로 시작해요. 그리고 네 가지 사건에 대한 처리를 붙여주면 돼요. 연결이 열렸을 때(open), 메시지가 왔을 때(message), 닫혔을 때(close), 오류가 났을 때(error)예요.
const ws = new WebSocket("wss://example.com/socket");
ws.onopen = () => {
console.log("연결됨");
};
ws.onmessage = (event) => {
console.log("받음:", event.data);
};
ws.onclose = (event) => {
console.log("끊김", event.code);
};
ws.onerror = () => {
console.log("오류 발생");
};
여기서 주소가 http가 아니라 wss로 시작하는 걸 눈여겨보세요. 웹소켓은 ws(암호화 안 됨)와 wss(암호화 됨)라는 자기만의 주소 체계를 써요. 실제 서비스에선 무조건 wss를 써야 해요. 그리고 받은 데이터는 항상 event.data에 문자열로 담겨 와요. 이 문자열을 어떤 모양으로 약속해 주고받을지가 실시간 설계의 큰 부분이에요.
47.3 연결이 끊기면 무슨 일이 생기죠?
여기서부터가 실시간의 진짜 어려움이에요. 웹소켓은 한 번 열면 끝이 아니에요. 사용자가 지하철 터널에 들어가거나, 와이파이가 잠깐 끊기거나, 서버가 재배포되거나, 심지어 휴대폰이 절전 모드로 들어가기만 해도 연결은 툭 끊겨요. 그러면 onclose가 호출되고, 그 순간부터 사용자는 아무 메시지도 못 받아요.
문제는 끊겨도 화면은 멀쩡해 보인다는 거예요. 채팅창은 그대로 떠 있고, 사용자는 자기가 지금 남의 말을 하나도 못 듣고 있다는 걸 몰라요. 저는 이걸 초기에 방치했다가 "메시지가 갑자기 안 와요"라는 문의를 잔뜩 받았어요. 다들 새로고침하면 되살아나니 버그 재현이 안 돼서 며칠을 헤맸죠. 그래서 실시간 앱은 끊기면 스스로 다시 연결하는 로직이 반쯤은 본체예요. 연결 코드보다 재연결 코드가 더 중요하다고 해도 과언이 아니에요.
47.4 그냥 바로 다시 연결하면 안 되나요?
가장 순진한 방법은 이거예요. onclose가 뜨면 즉시 새 연결을 만드는 거죠. 얼핏 맞는 것 같은데, 여기에 무서운 함정이 있어요.
// 이렇게 하면 안 돼요
ws.onclose = () => {
connect(); // 끊기자마자 바로 재시도
};
서버가 잠깐 죽어서 모든 사용자의 연결이 동시에 끊겼다고 해봐요. 그럼 수천 명이 동시에 재연결을 시도해요. 겨우 일어서려던 서버가 이 동시 재접속 폭탄을 맞고 다시 쓰러지죠. 이걸 몰려드는 무리(thundering herd) 문제라고 불러요. 게다가 서버가 완전히 죽어 있으면, 실패할 때마다 0.001초 만에 또 시도하느라 사용자 브라우저의 CPU와 배터리까지 잡아먹어요. 재연결은 천천히, 점점 간격을 늘려가며 해야 해요.
47.5 지수 백오프는 어떻게 짜요?
지수 백오프(exponential backoff)는 실패할 때마다 대기 시간을 두 배씩 늘리는 방식이에요. 1초 기다렸다 실패하면 2초, 또 실패하면 4초, 8초... 이렇게요. 대신 무한정 늘리면 안 되니까 최대 상한(예: 30초)을 둬요. 여기에 지터(jitter, 무작위 흔들기)까지 얹으면 완성이에요.
let attempt = 0;
function connect() {
const ws = new WebSocket("wss://example.com/socket");
ws.onopen = () => {
attempt = 0; // 성공하면 초기화
};
ws.onclose = () => {
const base = Math.min(30000, 1000 * 2 ** attempt);
const jitter = Math.random() * 1000;
const delay = base + jitter;
attempt++;
setTimeout(connect, delay);
};
}
connect();
두 가지가 핵심이에요. 첫째, 연결에 성공하면 attempt를 0으로 되돌려요. 안 그러면 다음에 끊겼을 때 처음부터 30초씩 기다리게 되거든요. 둘째, 지터를 왜 넣냐면요. 지수 백오프만 쓰면 모두가 정확히 같은 타이밍에 재시도해서 여전히 무리가 몰려요. 여기에 0~1초 무작위를 섞어주면 재접속 시점이 흩어져서 서버가 숨 쉴 틈이 생겨요. 이 두 줄 차이가 서버가 살아나느냐 마느냐를 가르는 걸 저는 실제 장애에서 겪었어요.
47.6 연결이 살아있는지는 어떻게 알죠?
더 골치 아픈 건 끊겼는데 onclose가 안 뜨는 경우예요. 이게 진짜 있어요. 와이파이가 조용히 죽거나 중간 장비가 연결을 슬쩍 놔버리면, 브라우저는 아직 연결됐다고 착각한 채 아무 사건도 안 띄워요. 이런 좀비 연결은 겉보기엔 멀쩡한데 실제론 죽어 있어요.
이걸 잡으려고 하트비트(heartbeat, 심장박동)를 써요. 주기적으로 서버에 "살아있어?" 신호를 보내고, 답이 안 오면 죽은 걸로 보고 끊어서 재연결하는 거예요. 참고로 웹소켓 프로토콜에도 ping/pong이 있지만, 브라우저 자바스크립트에선 그걸 직접 못 보내요. 그래서 우리는 보통의 메시지로 직접 ping/pong을 주고받아요.
47.7 하트비트 코드를 볼까요?
동작은 이래요. 일정 간격으로 ping을 쏘고, 타이머를 하나 걸어둬요. 서버가 pong으로 답하면 그 타이머를 지우고, 만약 정해진 시간 안에 답이 없으면 연결을 죽은 걸로 판정하고 닫아버려요. 닫으면 아까 만든 재연결 로직이 알아서 이어받죠.
function startHeartbeat(ws) {
let pongTimer;
const interval = setInterval(() => {
ws.send(JSON.stringify({ type: "ping" }));
pongTimer = setTimeout(() => {
ws.close(); // 답 없으면 죽은 연결로 간주
}, 5000);
}, 25000);
ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
if (msg.type === "pong") {
clearTimeout(pongTimer); // 살아있음 확인
}
};
ws.onclose = () => clearInterval(interval);
}
간격은 보통 25초에서 30초 정도로 잡아요. 너무 자주 보내면 배터리와 데이터를 낭비하고, 너무 뜸하면 죽은 걸 늦게 알아채요. 그리고 onclose에서 setInterval을 꼭 지워주세요. 안 그러면 연결이 끊긴 뒤에도 타이머가 계속 돌면서 이미 닫힌 소켓에 ping을 쏘려 들고, 그런 좀비 타이머가 재연결마다 하나씩 쌓여요. 저는 이 clearInterval 한 줄을 빼먹어서 몇 시간 동안 "왜 자꾸 이상한 에러가 늘어나지" 하며 헤맨 적이 있어요.
47.8 오늘 배운 걸 묶어볼게요
연결과 재연결은 실시간 앱의 바닥 공사예요. 화려하진 않지만 여기가 부실하면 위에 뭘 올려도 무너져요.
1. 실시간은 서버가 먼저 말을 거는 통로가 필요하고, 그게 웹소켓이에요.
2. 연결은 언제든 끊긴다고 전제하고, 끊기면 스스로 다시 붙게 만드세요.
3. 재연결은 지수 백오프 + 지터로 천천히, 성공하면 대기 시간을 초기화하세요.
4. 겉만 멀쩡한 좀비 연결은 하트비트로 잡고, 타이머 정리를 잊지 마세요.
이 뼈대만 튼튼하면 그 위에 알림이든 채팅이든 뭘 얹어도 안정적으로 돌아요. 반대로 여기가 흔들리면, 아무리 예쁜 기능을 얹어도 사용자는 말없이 끊긴 화면을 붙들고 답답해할 뿐이에요. 그러니 실시간을 시작한다면 연결 관리부터 손에 익히세요.