실시간 화면에서 연결이 끊기면 처음에는 onclose에서 다시 연결하면 된다고 생각했다. 실제로는 네트워크가 잠깐 흔들린 경우, 서버가 명시적으로 연결을 닫은 경우, 인증이 만료된 경우가 모두 달랐다. 재접속에 성공해도 끊긴 동안 놓친 데이터를 어떻게 복구할지가 남았다.
2026년 WebTransport가 Baseline Newly available에 들어왔다는 소식을 보며 WebSocket과 무엇이 다른지 다시 정리했다. 새 전송 방식이 재접속 문제를 자동으로 해결해 주지는 않지만, 데이터 성격에 맞는 전달 방식을 고를 선택지가 늘어난다는 점은 흥미로웠다.
하나의 순서 있는 통로와 여러 전송 방식
WebSocket은 하나의 연결에서 순서가 보장된 양방향 메시지 흐름을 제공한다. 앞의 패킷이 지연되면 뒤의 데이터도 기다리는 특성이 있다. 주문 이벤트처럼 순서와 누락 방지가 중요할 때 이해하기 쉬운 모델이다.
WebTransport는 HTTP/3와 QUIC 위에서 bidirectional·unidirectional stream과 unreliable datagram을 제공한다.
| 데이터 | 어울리는 방식 | 이유 |
|---|---|---|
| 인증·구독 명령 | reliable stream | 순서와 전달 확인 필요 |
| 주문 상태 변화 | reliable stream | 중간 이벤트 유실 위험 |
| 최신 마우스 위치 | datagram | 오래된 값보다 최신 값 중요 |
| 주기적 지연 측정 | datagram | 일부 유실 허용 가능 |
| 일반 브라우저 폴백 | WebSocket | 지원 범위가 더 넓음 |
datagram이 빠르다는 이유로 모든 이벤트를 옮기면 안 된다. 유실되거나 순서가 바뀔 수 있으므로, 최신 값 하나로 이전 값을 대체할 수 있는 데이터인지 먼저 봐야 한다.
기능 감지와 연결
간단한 연결 선택 코드는 다음과 같이 만들 수 있다.
async function connectRealtime() {
if ('WebTransport' in window) {
try {
const transport = new WebTransport(
'https://api.example.com/realtime'
);
await transport.ready;
return { kind: 'webtransport', transport };
} catch (error) {
console.info('WebTransport unavailable', error.name);
}
}
return {
kind: 'websocket',
socket: new WebSocket('wss://api.example.com/realtime')
};
}
API 존재만으로 서버와 네트워크 경로가 HTTP/3 WebTransport를 허용한다는 뜻은 아니다. 연결 실패 시 WebSocket으로 돌아가는 경로를 실제 환경에서 확인해야 한다.
이벤트가 아닌 상태 기반 재접속
close → connect()만 쓰면 여러 이벤트가 겹쳐 연결이 두 개 생기거나, 인증 오류에도 무한 재시도할 수 있다. 연결 상태를 제한된 값으로 두면 다음 행동을 정하기 쉽다.
const transitions = {
idle: { START: 'connecting' },
connecting:{ OPEN: 'ready', FAIL: 'retrying', STOP: 'closed' },
ready: { STALE: 'stale', CLOSE: 'retrying', STOP: 'closed' },
stale: { SNAPSHOT_OK: 'ready', CLOSE: 'retrying', STOP: 'closed' },
retrying: { RETRY: 'connecting', AUTH_FAIL: 'closed', STOP: 'closed' },
closed: {}
};
function transition(state, event) {
return transitions[state]?.[event] ?? state;
}
stale 상태를 따로 둔 이유는 소켓이 열려 있다는 것과 데이터가 최신이라는 것이 다르기 때문이다. heartbeat가 오래됐거나 sequence가 건너뛰었다면 화면에 오래된 값을 확정처럼 보여 주지 않고 snapshot을 다시 요청한다.
마지막 sequence = 184
새 이벤트 sequence = 189
→ stale 전환
→ REST 또는 reliable stream으로 snapshot 요청
→ snapshot sequence >= 189 확인
→ 구독 재개 후 ready
재연결 시 상태 보존 조건
재시도 간격은 지수 백오프와 jitter를 사용해 많은 클라이언트가 동시에 서버로 몰리지 않게 한다. 하지만 인증 거부, 지원하지 않는 프로토콜, 사용자의 명시적 로그아웃은 자동 재시도 대상에서 제외한다.
function retryDelay(attempt) {
const capped = Math.min(30000, 500 * 2 ** attempt);
return capped * (0.5 + Math.random() * 0.5);
}
연결이 새로 열렸다고 바로 ready로 두지 않고 다음을 확인한다.
- 인증과 구독이 다시 완료됐는가?
- 마지막 처리 sequence 이후 이벤트를 복구했는가?
- snapshot과 실시간 이벤트의 기준 시점이 맞는가?
- 이전 연결의 timer와 listener를 정리했는가?
- 사용자가 로그아웃했다면 재접속을 중지했는가?
WebTransport는 reliable stream과 datagram을 한 연결에서 선택할 수 있게 해 준다. 그러나 사용자에게 최신 상태를 보여 주는 책임은 전송 API 밖에 있다. WebSocket이든 WebTransport든 연결 상태, 데이터 신선도, 복구 상태를 분리한 상태 머신이 있어야 “다시 연결됨”을 “정상 복구됨”과 구분할 수 있었다.
참고: web.dev Baseline 2026의 WebTransport 안내
기술 문서 기반의 책임 경계
이 문제를 고친 뒤 관련 공식 자료를 찾아 읽으면서, 당시 코드에서 우연히 맞았던 부분과 규칙으로 남겨야 할 부분을 나눠 봤다.
RFC 9110은 HTTP 메서드의 의미와 멱등성을 구분한다. 같은 요청을 다시 보내도 의도한 서버 효과가 한 번과 같아야 멱등하다고 말할 수 있다. 클라이언트에서 버튼을 잠그는 것은 중복 조작을 줄이지만, 타임아웃 뒤 재전송이나 다른 기기 요청까지 막아 주지는 않는다.
또 상태 코드만으로 화면 상태를 모두 결정하기보다 응답이 어떤 요청과 선택 조건에 대한 것인지 확인해야 한다. 네트워크 성공과 현재 화면에 적용 가능한 응답은 다른 판단이다.
다음에 WebTransport와 재접속 상태 머신 같은 문제를 보면 아래 순서부터 확인하려고 한다.
- 재귀 호출 대신 명시적 반복과 상한
- 동시에 실행되는 갱신 작업의 단일 소유자
- 이전 세대 응답을 버리는 번호
- 중단 뒤 사용자가 다시 시작하는 경로
재시도는 횟수보다 같은 요청을 식별하는 키와 서버의 중복 처리 규칙이 먼저 정해져야 한다.
공식 참고 자료
- RFC 9110 HTTP Semantics — HTTP 메서드·상태 코드·멱등성 의미