잠시만요, 달이 떠오르면 글도 환해질 거예요.
let lastHeartbeat = Date.now();
sub.ctx.interval(() => {
if (Date.now() - lastHeartbeat > 80000) {
clean();
conn.terminate();
}
if (Date.now() - lastHeartbeat > 30000) conn.send('ping');
}, 40000);
conn.on('pong', () => { lastHeartbeat = Date.now(); });ws.onmessage = (event) => {
if (event.data === 'ping') {
ws.send('pong');
return;
}
if (event.data === 'pong') return;
// ...
};ws.onclose = () => {
if (wsRef.current === ws) wsRef.current = null;
handlersRef.current.onClose?.();
scheduleReconnect();
};
ws.onerror = (err) => {
handlersRef.current.onError?.(err);
ws.close();
};if (wsRef.current?.readyState === WebSocket.CONNECTING || wsRef.current?.readyState === WebSocket.OPEN) return;useWebSocket({
url: socketUrl,
onMessage: handleMessage,
autoReconnect: false, // ← 关键
reconnectInterval: 5000,
enabled: !!rid,
});if (![WAITING, JUDGING, COMPILING, FETCHED].includes(rdoc.status)) {
this.disconnectTimeout = setTimeout(() => this.close(4001, 'Ended'), 30000);
}const send = useCallback((data: any) => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify(data));
return;
}
pendingMessages.current.push(data);
}, []);disable={!connected}if (probeAt !== null) {
if (delayed) probe(socket, now);
else if (now - probeAt >= probeTimeout) fail();
} else if (now - lastReceived >= idleTimeout || (resumed && networkInterrupted)) {
probe(socket, now);
}ws.onmessage = (event) => {
if (!current(ws)) return;
// Heartbeats and non-JSON frames are also evidence of a live connection.
lastReceived = Date.now();
probeAt = null;
if (event.data === 'ping') {
try { ws.send('pong'); } catch { fail(); }
return;
}
if (event.data === 'pong') return;
// ...
};
const retire = () => {
const socket = wsRef.current;
wsRef.current = null;
probeAt = null;
if (!socket) return;
// Abandon half-open sockets without waiting for their close event to arrive.
socket.onopen = null;
socket.onmessage = null;
socket.onclose = null;
socket.onerror = null;
socket.close();
};
// Transport-level 1000 can also be a proxy/server restart; only explicit business/policy stops are terminal.
const TERMINAL_CLOSE_CODES = new Set([1008, 4000, 4001]);const offline = () => { networkInterrupted = true; };if (!socket) {
if (resumed && autoReconnect) {
clearTimeout(reconnectTimer);
reconnectTimer = undefined;
connect();
}
return;
}if (now < lastCheck) {
// A system clock correction must not postpone detection for hours.
lastReceived = now;
connectingAt = now;
if (probeAt !== null) probeAt = now;
}// A delayed timer after sleep must grant a new probe window, not immediately expire it.
const delayed = now - lastCheck > 30_000;const fail = (event?: CloseEvent) => {
if (disposed || stopped) return;
stopped = !autoReconnect || Boolean(event && TERMINAL_CLOSE_CODES.has(event.code));
retire();
if (stopped) {
window.clearInterval(watchdog);
clearTimeout(reconnectTimer);
reconnectTimer = undefined;
canQueue.current = false;
pendingMessages.current = [];
}
try {
handlersRef.current.onClose?.(event);
} finally {
scheduleReconnect();
}
};const scheduleReconnect = () => {
if (disposed || stopped || !autoReconnect || reconnectTimer !== undefined) return;
reconnectTimer = setTimeout(() => {
reconnectTimer = undefined;
connect();
}, duration(reconnectInterval, 3000));
};ws.onopen = () => {
if (!current(ws)) return;
lastReceived = Date.now();
probeAt = null;
networkInterrupted = false;
const sendNow = (data: any) => {
if (!current(ws) || ws.readyState !== WebSocket.OPEN) return;
try {
ws.send(JSON.stringify(data));
} catch {
fail();
}
};
for (const message of pendingMessages.current.splice(0)) sendNow(message);
// Each business restores its subscriptions/current activity here.
if (current(ws)) handlersRef.current.onOpen?.(sendNow);
};
const send = useCallback((data: any) => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
try {
wsRef.current.send(JSON.stringify(data));
} catch {
recoverRef.current();
}
return;
}
if (canQueue.current) pendingMessages.current.push(data);
}, []);watchdog = window.setInterval(() => check(), Math.max(1000, Math.min(10_000, probeTimeout)));async prepare() {
this.checkPriv(PRIV.PRIV_USER_PROFILE);
// ...
this.ctx.on('contest/announcement', (recipients) => this.announcement(recipients));
await this.refresh();
}this.queue = this.queue.catch(() => {}).then(async () => {
if (this.closed) return;
if (id) await announcements.acknowledge(this.user._id, id);
const pending = await announcements.pending(this.user._id);
if (!this.closed) this.send({ announcements: pending });
});this.rid = rid.toString();
this.onRecordChange(rdoc);
useWebSocket({
url: socketUrl,
onMessage: handleMessage,
// A reconnect obtains the current snapshot; terminal 4001/Ended stays closed.
autoReconnect: true,
reconnectInterval: 5000,
enabled: !!rid,
});
const handleSocketOpen = useCallback((send: (payload: any) => void) => {
send({ rids: initialRids });
}, [initialRids]);
async message({ rids }) {
this.watchedRids = rids;
// load + replay onRecordChange
}onOpen: (sendNow) => {
connected.current = true;
unsynced();
sendNow({ active: active() });
},export const ACTIVITY_LEASE_MS = 30_000;const elapsed = previous && at - previous.at <= ACTIVITY_LEASE_MS
? Math.max(0, Math.min(at, bounds.end) - Math.max(previous.at, bounds.begin)) : 0;
const status = await activity.heartbeat(domainId, tdoc, this.user._id, this.token, pid, false);
this.send({ ready: true, elapsed: status.problemTimes?.[pid] || 0 });
문제와 관련된 프로젝트
문제 배경
저는 HydroNext 앞에 텐센트 EdgeOne을 한 겹 두고 있습니다. 대회 공지, 제출 기록, 채점 진행 상황처럼 클라이언트와 이어지는 내용은 모두 WebSocket을 통해 서버에서 밀어 보내야 합니다.
OJ 대회라는 특성상 대회가 진행되는 동안 이 서비스들은 연결을 유지해야 합니다. 가장 대표적인 게 공지입니다. 관리자가 대회 도중에 공지를 하나 올리면, 참가 신청한 모든 선수의 화면에 즉시 떠야 합니다. 그런데 한두 시간짜리 대회는 기본이고 열 시간을 넘기는 장기전도 흔한 반면, EdgeOne이 WebSocket을 유휴 상태로 간주해 회수하는 기준은 300초입니다. 그래서 우리 시스템은 긴 연결을 유지하는 동시에 하트비트가 정상인지 확인해야 하고, 장애에서 복구하는 능력까지 갖춰야 합니다.
원래도 시스템에는 하트비트가 있었습니다. farmworker가 40초마다 한 번씩 검사해서, 30초 동안 프레임이 없으면 ping을 보내고, 80초가 지나도 아무것도 못 받으면 연결을 terminate시킵니다. 양쪽의 프레임 간격은 보통 300초보다 훨씬 짧으니 연결 유지에는 차고 넘쳤습니다.
그런데 실제로 EdgeOne을 붙이는 순간 WebSocket 연결이 끊기는 문제를 바로 만났습니다. 클라이언트와 연결을 유지해야 하는 모든 콘텐츠가 실시간으로 전달되지 않았습니다.
문제가 「하트비트」에 없다면 대체 어디에 있을까요? 링크에 트래픽이 흐르고 있으면 중간 장비는 연결을 회수하지 않습니다. 하지만 그건 이 연결이 지금도 쓸 수 있다는 증거가 아닙니다. 중간 계층에서 끊기면 브라우저는 close도 error도 받지 못하고, readyState는 계속 true 상태로 남습니다. 서버는 연결 객체가 아직 살아 있다고 착각하고, 메시지는 큐에 쌓인 채 영영 도착하지 않습니다. 열 시간짜리 대회에서 한 번만 이렇게 되면 그 컴퓨터는 이후의 공지를 전부 놓칩니다. 새로고침해서 연결이 다시 열릴 때까지요. 그런데 라우터로 페이지 이동을 하고 새로고침을 막아 둔 화면에서는 이게 치명적입니다. 계속 막혀 있으니까요.
이번 글에서 다루려는 건 「하트비트」 바깥의 나머지 부분입니다. 연결이 「죽었는지」 어떻게 알 것인가, 그리고 그 연결을 어떻게 갈아 끼울 것인가.
클라이언트를 향한 다섯 개의 장기 연결
HydroNext의 모든 실시간 기능은 WebSocket 기술을 사용합니다.
예를 들어 채점 자체는 비동기이지만, 제출 한 건은 컴파일하고 테스트 케이스를 확인하며 결과를 하나씩 돌려보내야 합니다. 이럴 때는 WS 장기 연결이 비동기 폴링보다 확실히 낫습니다.
설계를 거쳐 클라이언트 쪽에는 이렇게 다섯 개의 장기 연결이 존재합니다.
사용 상황
연결 시점
엔드포인트
연결 목적
전체 대회 공지
로그인 후 아무 페이지에 들어가면 공지용 상시 연결이 수립됨
/contest-announcements-conn
참가 신청한 대회의 공지를 받고, 읽음 확인을 회신
제출 기록 목록
열기 /record 또는 도메인 내 제출 목록
/record-conn?domainId=…&…
상태·점수·신규 제출 실시간 갱신
제출 기록 상세
열기 /record/{rid}
/record-detail-conn?rid=…&domainId=…
컴파일 정보, 채점 진행 상황, 테스트 케이스 결과
에디터 자체 테스트
‘자체 테스트’ 버튼을 누르고 기록 ID가 생긴 뒤
위와 동일하며, 자체 테스트 기록의 ID를 사용
자체 테스트 상태, 출력, 결과
대회 문제 풀이 타이머
참가 신청한 사용자가 대회 모드로 문제에 진입
/contest/{tid}/activity-conn?pid=…
포그라운드/백그라운드 상태, 문제별 및 총 누적 시간
이 연결들에는 몇 가지 공통점이 있고, 뒤의 설계는 모두 이 공통점에서 출발합니다.
기존 코드는 연결이 끊길 때 이벤트가 하나 날아와 알려 줄 거라고 가정했습니다. 하지만 예기치 않게 끊기면 그런 이벤트는 존재하지 않습니다.
EdgeOne을 붙인 뒤
EdgeOne은 WebSocket이 기본적으로 비활성화되어 있고, 최대 회수 정책은 300초 유휴 시 회수입니다. 대부분의 시간은 조용하지만 오래 유지해야 하는 장기 연결에는 치명적인 위협입니다.
다섯 개의 연결이 같은 링크를 쓰기 때문에 회수 정책도 함께 적용받습니다. 브라우저와 CDN 사이에 한 구간, CDN과 서버 사이에 또 한 구간이 있는데, 어느 한 구간이 끊기더라도 브라우저 입장에서는 그저 ‘새 소식이 없는’ 상태가 될 뿐입니다.
기존 하트비트 메커니즘이 하던 일
idle timeout을 보고 저도 첫 반응은 하트비트를 추가하는 것이었습니다. 다만 손대기 전에 프레임워크 계층을 먼저 뒤져 봤더니 하트비트는 이미 있었습니다.
이 타이머는 40초마다 한 번 깨어납니다. 30초 넘게 프레임을 못 받으면 ping을 보내고, 80초가 지나도 아무것도 못 받으면 바로 terminate합니다. 클라이언트는 ping을 받으면 pong로 답합니다.
정상 링크에서는 양방향 프레임 간격이 모두 40초 이내라 300초와는 거리가 멉니다. 그래서 CDN이 보기에는 이 연결이 계속 활성 상태입니다.
즉 CDN이 연결을 닫아 버린 상황에서는 하트비트를 더 넣어 계속 유지하겠다는 접근이 통하지 않습니다. 문제는 애초에 하트비트에 있지 않다는 뜻이죠. 그럼 어디에 있었을까요?
연결 유지와 활성 감지
하트비트라는 말은 사실 두 가지 일을 함께 가리킵니다.
하나는 연결 유지입니다. CDN은 링크에 트래픽이 계속 흐르고 있으면 그 연결을 회수하지 않습니다.
다른 하나가 이번 개선의 핵심인 활성 감지입니다. 활성 감지란 이 연결이 지금도 데이터를 주고받을 수 있는지 확인하는 일이고, 안 된다면 끊어 버리고 새 연결을 맺는 것입니다.
그런데 Hydro에는 연결 유지 메커니즘만 구현되어 있었습니다. 서버가 ping을 보내고 클라이언트가 pong로 답하는 식이죠. 클라이언트는 능동적으로 탐지하지 않고, 마지막으로 메시지를 받은 시각도 기록하지 않으며, 연결 타임아웃도 없습니다. 연결이 죽었는지 판단하는 유일한 근거는 onclose입니다.
이 지점에서 서버와 클라이언트의 역량은 대등하지 않습니다.
여기에 더해 휴대폰이 Wi-Fi와 셀룰러를 오가는 상황, 브라우저가 백그라운드로 들어가 멈추는 상황, 노트북 덮개를 닫는 상황, 서버는 재시작됐는데 클라이언트의 연결 객체는 그대로 남는 상황, CDN이 노드를 바꾸는 상황 등도 모두 끊김으로 이어질 수 있지만 감지되지 않고, 결국 메시지를 보낼 수 없게 됩니다.
이런 경우 TCP 연결은 반쯤 열린 상태입니다. 브라우저는 OPEN라고 믿고 있지만 상대는 진작 사라졌습니다. FIN도, RST도, 아무 이벤트도 없습니다.close은 몇 분 뒤에 올 수도 있고 영영 오지 않을 수도 있는데, Hydro의 복구 로직은 전적으로 이 이벤트에 의존합니다. 그래서 영영 복구되지 않을 수 있습니다.
Hydro 구현의 일곱 가지 문제
변경 전의 use-websocket.ts을 꺼내 분석해 보면 문제가 꽤 한곳에 몰려 있습니다.
1. 재연결이 onclose 안에만 있다
onclose이 오지 않으면 여기서는 재연결이 일어나지 않습니다. 반쯤 열린 연결에서는 이 문제의 영향이 가장 큽니다.
2. onerror 안에서는 그저 close()
을 호출할 뿐이다ws.close()만 호출하고, 재연결은 여전히 onclose에 의존합니다. 링크가 이미 망가진 상태라면 close()도 마찬가지로 콜백을 기다리지 못합니다.
3. 핸드셰이크에 타임아웃이 없다
이 한 줄은 중복 연결을 막기 위한 것인데, 타임아웃이 없다는 게 문제입니다. 핸드셰이크가 CONNECTING에 걸려 있으면(약한 네트워크, 프록시 탈취, CDN 노드 이상 등이 원인이 될 수 있습니다) 이 소켓이 계속 자리를 차지하고, 이후의 모든 재연결 시도가 여기서 막힙니다.
4. 상세 페이지와 자체 테스트는 자동 재연결이 꺼져 있다
상세 페이지는 자동 재연결을 명시적으로 껐습니다. 채점 중인 기록 페이지를 열어 두면 연결이 한 번만 끊겨도 페이지가 더는 갱신되지 않습니다. 자체 테스트는 같은 훅(components/editor/scratchpad.tsx:139)을 쓰기 때문에 증상이 같고, 출력이 끊긴 시점에 멈춥니다.
당시 이렇게 쓴 이유도 있었습니다. 상세 페이지 데이터는 스냅샷 성격이라 사용자가 새로고침하면 다시 받아올 수 있으니까요. 하지만 대회 중에는 감독 클라이언트를 쓰기 때문에 사용자에게 새로고침을 시킬 수 없고, 시스템 전체가 라우터에 의존합니다.
5. 닫힌 이유를 구분하지 않는다
서버는 기록이 종료 상태에 들어간 뒤 30초가 지나면 연결을 능동적으로 닫습니다.
예전 클라이언트는 닫힘 코드를 보지 않습니다. 자동 재연결만 켜져 있으면 어떤 close이든 3초 뒤에 다시 연결합니다.
재연결할 때마다 prepare()이 처음부터 다시 실행됩니다. 실패 원인이 계속 남아 있는 경우, 가령 권한이 부족하거나 파라미터가 틀렸거나 출처 검증을 통과하지 못한다면 무한 루프가 됩니다. 연결하고, prepare에서 오류가 나고, 서버가 4000을 보내고, 3초 뒤 재연결, 또 처음부터.
4001반대로
이 두 문제의 뿌리는 같습니다. 클라이언트가 이번 닫힘에 재연결해야 하는지를 모른다는 것입니다.
6. 브라우저 중단과 시계 역행을 처리하지 않는다
브라우저가 멈춰 있으면 setInterval이 스로틀링되거나 아예 멈춥니다. 노트북 덮개를 닫고 밤새 두었다가 열면 Date.now()은 이미 한참 지나 있습니다.
Hydro 코드에는 타임아웃 로직이 없어서 이 문제가 드러나지 않았습니다. 하지만 타임아웃을 넣는 순간, 깨어나자마자 멀쩡한 연결을 잘못 끊어 버리게 됩니다.
7. 전송 큐에 컨텍스트가 없다
연결을 쓸 수 없을 때 메시지는 무조건 큐에 밀어 넣어집니다. 그 연결이 사실 이미 죽어 있다면 메시지는 계속 큐에 누워 있습니다. 사용자는 보냈다고 생각하지만 실제로는 가지 않았고, 화면에도 아무런 안내가 없습니다.
정리
일곱 개를 늘어놓으니 좀 깁니다. 먼저 표 하나로 줄여 보겠습니다.
구분
Hydro 기존 구현
개선 방식
죽음 판정
오직 onclose
시계와 탐지 lastReceived / probeAt / connectingAt
하트비트 방향
수동 응답 pong
능동 전송 ping
유휴 연결
탐지 없음
120초 유휴 시 탐지, 10초간 프레임 없으면 사망 판정
핸드셰이크 지연
CONNECTING영구 점유
30초 타임아웃 시 fail()
종료 시맨틱
무조건 재연결
화이트리스트 {1008, 4000, 4001}, 1000 복구 가능
반열림(half-open) 소켓
대기 중 onclose
retire() 핸들러 제거, 참조 비우기, 능동적 close()
전송 실패
처리 없음
try/catch 로 전환fail()
메시지 큐
영구 누적
종료·언로드·도메인 전환 시 비우고,onOpen 전에 일괄 flush
브라우저 일시 중단
처리 없음
tick 지연 시 탐지 윈도 재개, 시계 되돌림 시 기준 재설정
페이지 이벤트
없음
online / focus / pageshow / visibilitychange
컨텍스트 격리
없음
current(ws) 참조 검사,domainId 경로 격리
상세 페이지와 자체 테스트
autoReconnect: false
자동 재연결, 서버가 스냅샷 푸시
표만 봐서는 시간 감각이 잘 안 잡히니 타임라인으로 바꿔 보자. 중간 링크가 t = 300초에 완전히 끊겼다고 가정하면:
Hydro의 기존 구현은 끊긴 지점 이후로 아무 동작도 하지 않아 사용자가 직접 새로고침할 때까지 그대로였다. 반면 HydroNext의 새 구현은 유휴가 120초에 도달하면 탐지를 보내고, 10초 안에 응답이 없으면 실패로 판정한 뒤 재연결한다.
이 일곱 가지 문제 가운데 실제로 손봐야 할 곳은 세 군데뿐이다. 연결이 끊겼는지 어떻게 판단할지, 어떤 경우에 재연결하지 말아야 할지, 재연결 후 옛 연결과 옛 메시지를 어떻게 처리할지다.
디버깅이 어려웠던 이유
조사 방향을 정하고 나서 되돌아보면 길이 꽤 순탄해 보인다. 하지만 실제로 문제가 어느 단계에서 생겼는지 확인하는 데 시간을 쏟기 시작하자 여러 난관이 나타났다.
아무런 에러가 없다
연결 전체에서 예외나 에러가 전혀 발생하지 않았고, 콘솔에는 console.error도 없었고 실패한 요청도 없었다. 브라우저는 WS가 끊겼다는 표시를 내지 않는다. 브라우저 입장에서는 WS 연결이 계속 OPEN
readyState이라고 해서 쓸 수 있는 연결은 아니다
WebSocket.OPEN라는 표시는 그 객체가 열린 상태라는 것만 알려준다. 하지만 반열림 연결에서는 읽기든 쓰기든 즉시 에러가 나지 않고, TCP 타임아웃이 되어야 에러가 난다. 그 타임아웃은 보통 몇 분을 넘긴다.
로컬에서는 재현 불가
맞다. 이 문제는 로컬에서 재현할 수 없다. 로컬에는 CDN이 없고, 프록시도 유휴 연결을 능동적으로 회수하지 않기 때문에 하루 종일 켜 두어도 멀쩡하다. 문제는 CDN을 거치면서 오랜 시간 유휴 상태가 이어지고, 특정 네트워크 경로가 조합될 때만 나타난다.
시간이 오래 걸린다
테스트 환경에서 재현하려면 한 번에 120초, 300초, 심지어 그 이상 기다려야 해서 수작업 비용이 너무 컸다. 게다가 기다려서 재현되든 안 되든 그 자체로는 아무것도 입증하지 못한다.
UI 상태도 어긋난다
공지 팝업은 connected로 버튼을 제어하는데,
반면 connected는 오직 onOpen와 onClose로만 구동된다. 반열림 연결에서는 onClose가 전송되지 않으므로 이때 connected는 계속 true인 상태로 남는다. 화면에는 연결됨으로 표시되고 버튼도 눌리는 상태인데, 눌러도 아무 일도 일어나지 않는다.
CDN 내부 상태는 알 수 없다
회수 정책, 노드 전환, FIN을 보낼지 여부 모두 전혀 통제할 수 없다. 결과만 볼 수 있을 뿐이라 마치 바이브 코딩을 하는 기분이다.
쓸 만한 단서
실제로 기댈 수 있는 건 대략 이 정도다:
이 가운데 판단이 가장 직접적인 건 임시 프로브다. 한동안 조용한 뒤에 readyState는 계속 1인데 마지막 프레임 수신 후 경과 시간만 계속 늘어난다면, 그 연결은 사실상 죽었다고 봐도 된다.
다만 이런 수단들은 연결이 실제로失效했다는 것만 확인해 줄 뿐, 재현도 못 하고 고쳐졌다는 것도 증명하지 못한다. 그래서 이후에는 방식을 바꿔, 실패 모드를 테스트에 적어 넣고 테스트가 그 상황을 기다리게 했다.
하트비트를 추가한다고? 그건 좀 이상한데!
조사하다 보면 가장 쉽게 내리는 결론이 '연결이 CDN에 끊긴 거니 하트비트만 넣으면 되겠지'다. 그런데 고칠수록 뭔가 이상해진다. "이런, 이 하트비트도 똑같은 문제가 생기잖아!"
개선 방안
모든 실패 경로가 fail() 로 모인다. 핸드셰이크 타임아웃,send 예외 발생, 탐지 타임아웃,onclose,onerror
1. 먼저 탐지하고, 그다음 판단
가장 단순한 방법은 N초 동안 메시지가 없으면 재연결하는 것이지만, 공지 시나리오에는 쓸 수 없다. 몇 시간 동안 업무 메시지가 없는 건 원래 정상이기 때문이다.
그래서 두 단계로 바꿨다. 먼저 탐지 프레임을 보내고, 응답이 없을 때 실패로 판정한다.
첫 단계에서는 ping 하나만 보내고 즉시 사망 판정을 내리지 않는다. 10초 안에 아무 프레임이나오면 정상으로 본다. 업무용 JSON뿐 아니라 모든 인바운드 프레임을 인정한다. 서버가 여전히 하트비트를 보내고 있다는 사실 자체가 링크가 살아 있다는 증거이기 때문이다. 업무 메시지만 인정하면 공지 연결이 계속 잘못 끊기게 된다.
이 탐지가 성립하는 이유는 서버에 이미 대칭 처리가 갖춰져 있기 때문이다. 프레임워크는 클라이언트의 'ping' 를 받으면 곧바로 'pong' 를 돌려보낸다framework/framework/server.ts:697-698). 반드시 응답이 오는 프레임을 하나 보내고, 그 응답을 증거로 삼는 셈이다.
그런데 왜 120초일까? 서버의 하트비트 주기는 40초이고, 120초는 정확히 세 번 연속 놓친 시간이다. 정상 연결은 이 분기에 들어오지 않으며, 들어왔다면 인바운드 방향이 적어도 2분은 끊겼다는 뜻이다. 더 짧게 잡으면 정상 하트비트 주기와 겹쳐서 정상 연결도 자주 탐지 상태로 들어간다.
클라이언트가 더 이상 40초마다 하트비트를 보내지 않는 이유는, 바로 '서버의 하트비트가 멈췄다'는 신호를 기다리기 위해서다. 같은 주기로 탐지하면 쓸데없는 트래픽과 상태 흔들림만 늘어난다.
2. close 를 기다리지 말고 옛 소켓을 버린다
어차피 close 이벤트가 오지 않을 수도 있으니 기다리지 않는다. 실패로 판정되면 곧바로 기존 소켓을 버린다.
여기에는 순서가 중요하다. 먼저 wsRef.current 를 비워서 옛 소켓의 지연된 이벤트가 current(ws) 검사를 통과하지 못하게 하고, 다음으로 핸들러를 떼어내 그 소켓의 onclose 가 실패 처리를 한 번 더 트리거하지 않게 한다. 마지막으로 close() 를 호출하는데, 이 호출은 어디까지나 최선을 다한 정리에 불과하다.
재연결 로직에서 가장 흔한 경쟁 조건이 바로 이것이다.
만약 current(ws) 검사가 없었다면, 마지막에 도착한 늦은 onclose가 다시 fail()을 호출해 방금 맺은 연결을 retire하게 된다. 그러면 모든 연결이 RTT 하나를 넘기지 못하고, 페이지는 영원히 재연결 상태에 머물 것이다.
3. 종료 코드로 재연결 여부를 구분
서버가 연결을 끊는 이유는 여러 가지이므로 클라이언트가 이를 구분할 수 있어야 한다:
이 세 경우에는 재연결이 의미가 없고, 때로는 오히려 해롭다:
1000 는 의도적으로 제외했다. 직관적으로 보면 1000은 정상 종료를 뜻하므로 거기서 멈춰야 한다. 하지만 실제 네트워크에서는,1000도 프록시 재시작이나 서버의 그레이스풀 재시작, 또는 CDN 노드가 내려가면서 발생할 수 있다. 전송 계층에서는 정상 종료였을지 몰라도, 비즈니스 관점에서는 아직 끝난 게 아니다.
4. 페이지 이벤트를 복구 트리거로 활용하기
타이머 외에도 몇 가지 브라우저 이벤트를 걸어 두었다.
이벤트
처리
online
즉시 탐색하고, 대기 중인 3초 백오프는 건너뛴다
offline
플래그만 세우고 죽었다고 판단하지는 않는다
focus
탐색
pageshow
탐색
visibilitychange
페이지가 다시 보일 때 탐색
offline은 힌트 역할만 한다.
navigator.onLine이 false일 때 연결을 못 쓸 가능성이 높고, true이라고 해서 OJ에 닿을 수 있다는 뜻도 아니다. DNS, 프록시, CDN, 서버 중 어디 하나만 문제여도 그렇다. 그래서 offline에서는 플래그만 세우고, online에서는 탐색을 한 번 트리거하며, 탐색이 실패했을 때만 재연결한다.
online은 덤으로 작은 문제 하나도 해결해 준다. 바로 대기 중인 백오프를 건너뛸 수 있다는 점이다.
네트워크가 막 살아난 참에 굳이 3초를 더 기다릴 이유는 없다.
5. 브라우저 일시 중단과 시계 되돌림
둘 다 코드를 짤 때 놓치기 쉽지만, 실제 운영에서는 분명히 마주치게 된다.
시계 되돌림:
사용자가 시간을 직접 바꾸거나 NTP가 시계를 뒤로 돌리면 now - lastReceived이 음수가 될 수 있다. 이걸 처리하지 않으면 감지 로직이 몇 시간씩 멈춰 있을 수 있다.
일시 중단 후 지연된 tick:
브라우저가 다시 전면으로 돌아오면 setInterval은 tick을 한 번만 보충하는데, 그 사이 Date.now()은 이미 30분이나 지나 있다. 이걸 그대로 탐색 타임아웃으로 처리하면 방금 깨어난 멀쩡한 연결이 곧바로 잘못 종료된다. 여기서는 tick이 미뤄진 걸 발견하면 ping을 다시 보내고 10초짜리 새 윈도를 준다.
전체 흐름
지금까지의 내용을 모두 합치면, 모든 실패 경로가 결국 하나의 함수로 모인다.
이 함수는 세 가지를 보장한다. 첫째, 기존 연결은 반드시 retire된다. 둘째, 종료 코드가 이후의 재연결을 막고 큐와 타이머를 정리한다. 셋째, onClose 콜백이 반드시 실행되며, 재연결은 finally 안에 두어 콜백이 예외를 던져도 복구에는 영향이 없다.
재연결 자체에는 중복 방지를 넣었다.
reconnectTimer !== undefined 덕분에 대기 중인 재연결 작업은 한 번에 하나만 존재한다.
연결이 맺어지면 상태를 일괄 초기화한다.
큐는 먼저 밀려 있던 메시지를 비운 뒤 onOpen을 호출한다.onOpen에서 비즈니스가 구독을 복구하므로, 처리 순서가 기존 메시지와 엉키지 않는다.
send에도 에러 처리를 추가했다.
canQueue은 종료, 언로드, 도메인 전환 시 모두 false로 바뀌고 큐가 비워진다. 큐가 연결 컨텍스트를 넘어 살아남으면, A 도메인에서 못 보낸 메시지가 B 도메인의 연결로 나갈 수 있다.
점검 주기는 탐색 윈도와 맞춰 두었다.
기본 탐색 타임아웃은 10초, 점검 주기도 10초다. 비즈니스 윈도를 줄이면 점검도 촘촘해지는데, 하한은 1초로 두어 메인 스레드가 포화되지 않게 했다.
서버 쪽 협조도 필요하다
자동 재연결에는 전제가 하나 있다. 재연결한 클라이언트가 그동안 놓친 상태를 가져올 수 있어야 한다는 점이다. 서버가 연결만 다시 받아 주고 아무것도 밀어 주지 않으면, 클라이언트는 연결은 되어도 아는 게 하나도 없다. 그래서 이번 변경은 백엔드와 함께 진행했다.
공지
연결할 때마다 prepare()이 실행되고, 끝에는 refresh()가 붙어 읽지 않은 공지를 다시 밀어 준다.
클라이언트가 따로 채울 필요는 없다. 재연결 자체가 곧 한 번의 동기화다.
확인 동작 역시 멱등하게 만들었다.
덕분에 재연결로 같은 확인이 두 번 나가도 문제가 없다.
기록 상세
연결이 맺어질 때마다 서버가 현재 기록을 밀어 주면 되고, 클라이언트는 재연결만 허용하면 된다.
기록 목록
목록 연결은 상태가 없으므로, 클라이언트가 어떤 기록을 보고 있는지 서버에 알려 줘야 한다.
서버는 rids를 받으면 현재 상태를 다시 재생한다.
onOpen이 여기서 재구독 역할까지 겸한다.
대회 타이머
이 비즈니스에는 30초짜리 리스(lease) 제약이 있다.
서버가 정산할 때 직전 보고에서 이번 보고까지의 간격이 30초를 넘지 않아야 그 구간이 집계된다.
클라이언트는 10초마다 보고하니 정상 상황이라면 리스 안에 들어오지만, 연결이 30초 넘게 끊기면 리스가 끝나고 그 공백은 소급해 집계되지 않는다.
그래서 타이머는 재연결 지연에 특히 민감하다. 3초 간격의 재연결이면 시간이 거의 손실되지 않는다. 반면 Hydro의 옛 구현에서는 매달린 반개방 연결이 사용자가 직접 페이지를 새로고침할 때까지 계속 손실되어, 수십 분에서 몇 시간까지 집계되지 않았다.
클라이언트는 상태를 미동기로 표시하고, 재연결되면 곧바로 현재 전면 여부를 다시 보고한다. 서버는 prepare()에서 신청 정보를 검증하고, 겸사겸사 누적 시간도 돌려준다.
마무리
이로써 모든 변경이 끝났다.
사실 이번 조사와 수정에서 새로 들여온 메커니즘은 없다. 하트비트와 재연결은 Hydro 시절부터 이미 있던 것이다. 다만 내가 HydroNext에 '죽었음 판정'을 도입하면서 시스템이 한층 견고해졌고, EdgeOne CDN도 더 잘 뒷받침할 수 있게 되었다. 그렇다. 곰곰이 돌아보면 이 문제들은 그저 CDN 하나를 붙였기 때문에 생긴 것이다. 이는 곧 시스템이 처음 설계될 때 클라이언트용 WS 연결을 더 견고하게 다루지 못했다는 점을 옆에서 보여 준다. 다행히 이 일련의 판정과 처리를 들여와서 결국 그 빈틈을 메웠다.