56.1 재연결이 왜 폭주해요?
실시간에서 제가 가장 크게 데인 함정부터 풀게요. 바로 재연결 폭주예요. 서버를 잠깐 재시작했더니, 붙어 있던 수천 명이 동시에 연결이 끊겼어요. 그다음이 문제였어요. 다들 같은 순간에 재연결을 시도한 거예요. 겨우 살아난 서버가 그 동시 재접속 폭탄을 맞고 또 죽고, 또 다 같이 재시도하고, 이걸 천둥 소 떼(thundering herd, 한꺼번에 우르르 몰리는 무리)라고 불러요.
원인은 모두가 똑같이 "끊기면 바로 재연결"만 해서예요. 해법은 두 가지를 섞어요. 지수 백오프(exponential backoff, 실패할수록 대기 시간을 배로 늘리기)로 재시도 간격을 벌리고, 지터(jitter, 무작위로 살짝 흔들기)로 각자 조금씩 다른 시각에 시도하게 해요.
상황: 실패할수록 더 오래, 그리고 저마다 다른 시각에 재시도해요.
let attempt = 0;
function reconnect() {
const base = Math.min(1000 * 2 ** attempt, 30000);
const wait = base + Math.random() * 1000; // 지터
attempt++;
setTimeout(connect, wait);
}
1초, 2초, 4초 이렇게 배로 늘리되 최대 30초에서 멈춰요. 거기에 랜덤 한 스푼을 더해서 전원이 같은 초에 몰리지 않게 흩뿌려요. 연결에 성공하면 attempt를 0으로 되돌리는 것도 잊지 마세요. 안 그러면 다음에 끊겼을 때 처음부터 30초씩 기다리거든요. 이 한 조각이 서버를 재시작 지옥에서 구해줘요.
56.2 메모리가 왜 새요?
다음은 조용히 서버를 죽이는 메모리 누수(memory leak, 안 쓰는 메모리가 안 치워지고 쌓이는 것)예요. 실시간에서 이게 유독 잘 나요. 왜냐면 연결마다 리스너, 타이머, 명단 항목을 만들어 붙이는데, 연결이 끊길 때 이걸 안 치우면 그대로 쌓이거든요.
제 삽질담이 딱 이거예요. 연결마다 하트비트용 setInterval을 걸어놨는데, close될 때 clearInterval을 안 했어요. 연결은 끊겼는데 타이머는 계속 돌고, 며칠 지나니 서버 메모리가 슬금슬금 차서 새벽에 죽더라고요. 원인 찾는 데 이틀 걸렸어요. 교훈은 하나예요. 연결이 끊길 때, 그 연결이 만든 걸 전부 되돌려라.
상황: 연결이 끊기면 걸어둔 걸 하나도 남김없이 치워요.
ws.on('close', () => {
clearInterval(ws.heartbeat); // 타이머 정리
online.delete(ws); // 명단에서 제거
rooms.get(roomId)?.delete(ws); // 방에서도 제거
});
규칙은 단순해요. 연결을 열 때 뭘 만들었다면, 닫을 때 짝을 맞춰 지운다. 타이머엔 clear, 명단엔 delete, 방 목록에서도 빼기. 이 짝 맞추기를 습관으로 들이면 누수의 대부분이 사라져요.
56.3 메시지 순서가 왜 뒤바뀌어요?
이번엔 알쏭달쏭한 함정이에요. "1, 2, 3을 보냈는데 1, 3, 2로 도착해요." 채팅이면 말이 꼬이고, 주문 상태라면 취소 다음에 결제 완료가 떠서 사고가 나죠. 원인은 여러 갠데, 재전송이 겹치거나, 비동기 처리 순서가 엇갈리거나, 잠깐 끊겼다 받은 게 뒤늦게 끼어들어서예요.
믿을 방법은 하나예요. 메시지에 순번(sequence, 일련번호)을 박아서, 받는 쪽이 순서를 스스로 맞추게 하는 거예요. 도착 순서를 믿지 말고 번호를 믿는 거죠.
상황: 순번이 다음 차례일 때만 반영하고, 건너뛴 건 잠깐 창고에 둬요.
let expected = 1;
const buffer = new Map();
function onMessage(msg) {
buffer.set(msg.seq, msg);
while (buffer.has(expected)) {
apply(buffer.get(expected));
buffer.delete(expected);
expected++;
}
}
expected가 지금 기다리는 순번이에요. 3번이 2번보다 먼저 왔으면 일단 창고(buffer)에 넣어두고, 2번이 도착하는 순간 2번, 3번을 연달아 반영해요. 이렇게 하면 도착이 아무리 뒤죽박죽이어도 화면엔 항상 바른 순서로 나타나요.
56.4 왜 같은 메시지가 두 번 와요?
마지막 단골 함정은 중복이에요. 실시간 전달은 대개 "적어도 한 번"(at-least-once, 빠뜨리느니 겹치더라도 꼭 보냄)을 보장해요. 놓치는 것보단 겹치는 게 낫다는 철학이죠. 그 대가로 같은 메시지가 두 번 올 수 있어요. 재연결하며 놓친 걸 다시 받을 때 이미 가진 것과 겹치는 식으로요.
그래서 받는 쪽이 멱등성(idempotency, 여러 번 처리해도 결과가 한 번 처리한 것과 같음)을 챙겨야 해요. 방법은 앞에서도 나온 id 문지기예요. 메시지마다 고유 id를 달아두고, 이미 본 id면 조용히 버려요.
상황: 이미 처리한 id면 무시해서, 두 번 와도 한 번만 반영해요.
const seen = new Set();
function handle(msg) {
if (seen.has(msg.id)) return; // 중복이면 그냥 버림
seen.add(msg.id);
apply(msg);
}
이 seen 집합 하나가 "같은 알림 두 번 떠요", "댓글이 두 개 달렸어요" 같은 민원을 통째로 막아줘요. 집합이 너무 커지면 오래된 id부터 버리는 상한을 두면 되고요. 실시간에선 중복이 오는 걸 막으려 애쓰기보다, 두 번 와도 괜찮게 받는 쪽을 만드는 게 훨씬 튼튼해요.
56.5 재연결했는데 왜 옛날 화면이에요?
이건 앞의 함정들과 짝을 이루는 마무리 함정이에요. 끊겼다 다시 붙는 데는 성공했는데, 화면이 끊기기 직전 상태에 멈춰 있는 거예요. 끊긴 그 몇 분 사이에 세상은 바뀌었는데, 재연결만 하고 그동안 바뀐 걸 안 받아오면 사용자는 낡은 화면을 진짜인 줄 알아요.
제가 협업 문서에서 이걸로 크게 데였어요. 두 사람이 편집하다 한쪽이 잠깐 끊겼는데, 재연결 후에도 상대가 지운 문단이 그대로 남아 있었어요. 그 상태로 저장하니 지운 게 되살아나는 사고가 났죠. 교훈은 하나예요. 재연결은 끝이 아니라 시작이다. 붙은 직후에 지금 진짜 상태를 다시 맞추는 과정이 반드시 따라와야 해요.
상황: 재연결에 성공하면, 놓친 사이의 변화를 통째로 다시 받아 맞춰요.
ws.onopen = async () => {
attempt = 0; // 백오프 초기화
const since = lastAppliedSeq; // 마지막으로 반영한 순번
const res = await fetch('/sync?since=' + since);
const missed = await res.json();
for (const m of missed) handle(m); // id 문지기가 중복은 걸러줌
};
재연결하자마자 "내가 마지막으로 반영한 이후"를 기준으로 놓친 변화를 받아와 순서대로 반영해요. 넉넉히 겹치게 받아도, 앞에서 만든 id 문지기가 중복을 걸러주니 걱정 없어요. 이 재동기화 한 단계가 "재연결됐는데 화면이 이상해요"라는 민원을 없애줘요.
56.6 오늘 함정들을 정리해볼게요
실시간에서 꼭 한 번씩은 데이는 함정 다섯을 모았어요. 다 제가 겪은 것들이에요.
서버가 재시작되면 재연결 폭주가 나니 지수 백오프와 지터로 흩뿌리세요. 연결이 만든 리스너, 타이머, 명단은 닫을 때 짝 맞춰 지워 메모리 누수를 막으세요. 메시지 순서가 꼬이면 순번을 박아 받는 쪽이 정렬하게 하고, 중복은 id 문지기와 멱등성으로 두 번 와도 한 번만 반영되게 하세요. 그리고 재연결한 직후엔 놓친 변화를 다시 받아 화면을 진짜 상태로 맞추세요.
다섯 함정엔 공통점이 있어요. 전부 "네트워크는 언제든 끊기고, 순서도 횟수도 안 지켜진다"는 현실에서 나와요. 실시간을 잘 만드는 사람은 연결이 완벽하다고 믿지 않아요. 끊길 걸 알고 백오프를 깔고, 뒤섞일 걸 알고 순번을 박고, 겹칠 걸 알고 문지기를 세우죠. 이 안 믿는 습관 하나가 실시간 서비스의 안정성을 통째로 바꿔놔요.