이 기사는 브라우저 실시간 통신을 위해 폴링, SSE, WebSocket 등의 전송 수단을 비교하고, 단순한 연결을 넘어 지연, 최신성, 그리고 연결 단절 이후의 상태 복구를 정확히 설계하는 방법을 다룬다.
전송 구간뿐만 아니라 애플리케이션과 운영, 사용자 경험까지 고려한 구체적인 요구사항 정의와 구현 시 주의사항을 설명한다.
"실시간"이라는 요구를 보면 WebSocket부터 떠올리기 쉽지만, 채팅 메시지·작업 완료 알림·협업 커서는 기다릴 수 있는 시간도, 끊겼을 때 되찾아야 하는 것도 모두 다르다는 문제의식에서 출발한 글
전송 수단 비교보다 "무엇을 어디까지 약속할 수 있는가"를 먼저 정하고, 그 기준으로 폴링·롱폴링·SSE·WebSocket·WebRTC DataChannel·WebTransport·Push API를 고르는 순서를 정리함
연결이 열렸다는 것과 화면이 최신이라는 것은 다른 사실이며, 전송 수단을 고르는 것만으로는 단절 이후의 상태 복구 문제가 해결되지 않음
폴링 간격의 빈틈, 프록시 버퍼링, 스냅샷과 구독 사이의 빈틈, 늦게 온 옛 버전, Head-of-Line Blocking 등 어디서 데이터가 늦거나 빠지는지를 애니메이션 도식 9개로 보여 줌
실시간이 약속하는 것
공학에서 실시간은 단순히 "빠르다"는 뜻이 아니라, 결과가 필요한 시간 안에 나왔는지까지 정확성의 일부로 보는 개념임. "평균 10ms"와 "항상 100ms 안에"는 다른 주장이고, 평균이 짧아도 드물게 3초씩 멈추면 두 번째 약속은 못 지킴
늦은 데이터의 가치도 종류마다 다름. 지나간 커서 위치를 뒤늦게 그리면 상대가 거꾸로 움직이는 것처럼 보이지만, 늦게 온 채팅 메시지는 여전히 읽을 가치가 있음
요구사항에는 세 가지를 적어야 함
얼마나 늦어도 되는가: 허용 지연과 달성 비율
누가 언제 보내는가: 통신 방향과 빈도
놓친 것은 어떻게 되찾는가: 스냅샷, 이벤트 재생, 중복 허용
"실시간으로 알려준다" 대신 "화면을 보고 있고 통신 가능한 동안, 서버가 완료를 확정한 시점부터 99%를 2초 안에 반영하고, 복구 시에는 목록을 재조회하며 확인 전 상태는 갱신 중으로 표시한다"처럼 적어야 구현과 측정이 가능함
지연(latency)과 최신성(freshness)은 다른 요구임. 마지막 이벤트가 20ms 만에 왔더라도 끊긴 동안 바뀐 것이 있으면 화면은 오래된 상태임
브라우저에서 보장할 수 있는 것
서버 상태 확정부터 화면 표시까지는 큐, 프록시 버퍼, 네트워크, 파싱, 태스크 대기, 렌더링을 거침. WebSocket이나 SSE가 정하는 것은 그중 전달 구간뿐이라, 메인 스레드가 막혀 있으면 서버가 20ms 만에 보내도 화면은 늦음
setTimeout(fn, 100)도 100ms 상한을 보장하지 않음. 인터넷과 일반 브라우저 환경 전체에서 모든 이벤트의 마감을 보장하기는 어려움
그래서 약속을 전송·애플리케이션·운영·사용자 경험으로 나눠서 해야 함
전송: 연결 안의 순서와 전달 특성
애플리케이션: 이벤트 식별, 재생 범위, 중복 처리, 재조회
운영: 정해진 조건에서 지연 목표를 얼마나 자주 충족하는지
사용자 경험: 단절·복구 중·오래된 상태를 어떻게 알리는지
폴링과 롱폴링
간격이 Δ인 폴링의 평균 대기는 대략 Δ/2지만, 타이머 지연과 재시도까지 포함한 최대 지연은 Δ로 보장되지 않음
요청률은 N/Δ임. 클라이언트 1만 개가 5초마다 조회하면 초당 약 2천 건이고, 간격을 줄이면 변경이 없는 동안의 비용도 같이 늘어남
setInterval로 폴링하면 느린 응답이 겹치고 순서가 뒤집혀 오래된 상태가 화면을 덮을 수 있음. 앞 요청이 끝난 뒤 다음을 예약하고, 실패 시에는 백오프와 지터를 적용해야 함
롱폴링에서는 응답을 받고 다음 요청을 여는 짧은 틈에도 이벤트가 생김. "연결이 열려 있을 때 난 이벤트만 보내기"로 만들면 누락되므로, 서버가 커서 이후의 이벤트를 보관해야 함
SSE
SSE는 JSON 프로토콜이 아니라 열어 둔 HTTP 응답에 텍스트 이벤트를 이어 쓰는 형식임. 한 번 write()한 것이 곧 이벤트 하나가 되지도 않음
EventSource가 재연결과 Last-Event-ID 전달은 해 주지만, 서버가 이벤트를 보관·재생하지 않으면 빠진 내용은 돌아오지 않음. 수신했다는 사실이 화면에 적용했다는 확인도 아님
기본 EventSource에는 Authorization 헤더를 넣을 방법이 없음. 토큰을 URL에 넣으면 로그와 관측 도구에 남을 수 있음
서버는 보냈는데 이벤트가 몰려서 도착한다면 중간 장비의 버퍼링을 의심해야 함. Cache-Control: no-cache만으로는 전송 버퍼링이 꺼지지 않음. Nginx의 X-Accel-Buffering: no 같은 설정은 Nginx에 해당하는 설정이며, 다른 CDN·프록시에서도 그대로 통하는 것은 아님
WebSocket
기본 API에는 자동 재연결이 없음. 백오프와 지터, 재시도하면 안 되는 종료(인증 만료, 권한 거부) 구분, 이전 연결의 핸들러 정리를 앱이 직접 해야 함
open 이벤트는 서버가 구독을 승인하고 누락분을 보냈다는 뜻이 아님. 구독 승인과 동기화 완료를 별도 메시지로 받아야 연결 상태와 데이터 준비 상태를 구분할 수 있음
연결이 열렸다가 곧바로 닫히는 경우가 있으므로, open만 보고 백오프를 초기화하면 안 됨
하트비트 응답은 그 왕복 경로가 살아 있다는 증거일 뿐, 데이터가 최신이라거나 직전 명령이 저장됐다는 증거가 아님
수신 쪽에는 백프레셔 인터페이스가 없음. bufferedAmount가 줄었다고 서버가 일을 끝낸 것도 아님
커서 위치처럼 현재 값만 중요한 데이터는 합쳐서 최신 값만 반영
채팅 기록은 유실 없이 이어받기
사용자 명령은 요청 ID로 결과를 확인하고 중복 실행 방지
WebSocket에는 fetch의 CORS 보호가 그대로 적용되지 않음. 서버가 Origin을 허용 목록과 대조하고, 로그아웃이나 권한 회수 뒤에 이전 구독이 남지 않게 해야 함
재연결 이후의 상태 복구
다시 구독하는 것만으로는 누락분이 복구되지 않음. 현재 구독자에게만 뿌린 이벤트는 끊겨 있던 동안의 전달 경로에 남아 있지 않음
"목록을 조회한 뒤 구독한다"에는 빈틈이 있음. 조회와 구독 사이에 바뀐 항목은 스냅샷에도, 이후 이벤트에도 없을 수 있음. 스냅샷과 그 스냅샷에 대응하는 커서를 함께 받아 그 커서부터 구독해야 함
스냅샷을 읽은 뒤 "지금의 최신 커서"를 따로 가져와 붙이면, 빠진 변경이 있는데도 이미 반영한 것처럼 보임
보관 기간을 넘긴 커서를 서버가 조용히 무시하고 최신부터 보내면 화면 일부가 영구히 빠짐. 복구 불가를 명시하고 새 스냅샷으로 초기화해야 함
id 하나로 모든 것을 식별하려 하면 모호해짐. 식별자마다 맡는 일이 다름
이벤트 ID: 같은 이벤트의 중복 수신 식별
커서: 단절 이후 이어받기
엔티티 버전: 옛 상태가 새 상태를 덮어쓰는 것 방지
요청 ID: 비동기 응답을 원래 요청과 대응
멱등 키: 결과를 못 받은 요청을 안전하게 재시도
한 연결 안의 메시지 순서와 서비스 전체의 변경 순서는 범위가 다름. HTTP 재조회와 스트림이 경합하면 순서가 보장되는 연결이어도 버전 검사가 필요함
응답을 못 받은 명령의 타임아웃은 "실패"가 아니라 "성공했는지 모름" 임. "WebSocket이라 정확히 한 번 처리된다"는 약속은 성립하지 않으며, 한 번의 업무 효과는 멱등 키와 서버의 영속 기록으로 만들어야 함
UI 상태를 isConnected 하나로 표현하지 말고, 연결 상태와 동기화 상태를 구분해야 함. 연결이 다시 열려도 동기화가 끝나기 전에는 데이터를 최신으로 표시하지 않으며, 끊김·연결 시도·동기화 중·최신 확인·최신 여부 미확인처럼 복구 단계를 나눠 표현할 수 있음
순서와 재전송을 다르게 고를 때
빠짐없이 순서대로 받는 성질에는 비용이 있음. 앞의 데이터를 복구하는 동안 뒤의 데이터도 기다림(Head-of-Line Blocking). 커서 위치처럼 최신 값만 중요하면 중간 값을 버리는 편이 나음
WebRTC DataChannel은 ordered: false, maxRetransmits: 0으로 순서와 재전송을 끌 수 있지만, 시그널링과 TURN 중계가 필요하므로 "피어 간 통신이라 서버 비용이 없다"는 가정은 틀림
WebTransport는 스트림을 나눠 한 스트림의 유실 복구가 다른 스트림을 막지 않게 할 수 있음. 다만 대역폭과 혼잡 제어는 공유함. 어느 쪽도 하드 실시간 보장이 되지는 않음
탭 밖에서
숨겨진 탭, 동결된 페이지, 메모리에서 버려진 페이지는 서로 다른 상태이고, 열린 연결과 타이머가 같은 빈도로 돈다고 전제할 수 없음
Web Worker로 옮겨도 운영체제가 앱을 멈추는 문제는 해결되지 않고, Service Worker는 상시 연결 서버로 쓸 수 없음
푸시는 정확한 시각의 실행 예약이 아니며, 알림을 받았다고 페이지 상태가 갱신된 것도 아님. 사용자가 돌아왔을 때 현재 상태를 재조회해야 함
탭마다 늘어나는 연결을 BroadcastChannel이나 SharedWorker로 하나로 줄일 수 있음. 대신 소유 탭 종료, 소유권 교체, 계정 전환까지 처리해야 하므로, 연결 하나 줄이려다 복구 상태 머신이 더 복잡해지지 않는지 비교해야 함
고르는 순서와 검증
몇 초 간격으로 확인해도 되고 활성 사용자 수가 적다면 폴링으로 시작할 수 있음. 목표를 맞추려고 간격을 계속 줄이는데 대부분의 응답이 "변경 없음"이라면 SSE를 검토할 이유가 생김
사용자 입력이 있다는 이유만으로 SSE가 부적합한 것은 아니며, 취소는 별도 POST로 보내도 됨. 양쪽이 위치와 입력 상태를 자주 주고받는 협업 화면이면 WebSocket이 자연스러운 후보임
지연은 서버 내부, 클라이언트 처리, 복구, 최신 여부 미확인 시간의 네 구간으로 나눠 잼
performance.now()를 서버 Unix 타임스탬프에서 빼는 것은 잘못된 계산이고, RTT의 절반을 편도 지연으로 쓸 수도 없음
서버의 p99와 브라우저의 p99를 더해도 전체 경로의 p99가 되지 않음. 도착하지 않은 이벤트를 표본에서 빼면 성공한 것만 측정하게 되므로, 목표 시간 안에 적용한 비율과 미수신·복구 실패를 함께 집계해야 함
끊기는 조건에서 검증할 시나리오
30초 단절 중 서버 상태 변경
서버는 처리했는데 응답 유실
이벤트 중복과 늦게 온 HTTP 응답
메인 스레드 긴 작업과 대량 이벤트 수신
서버 재시작과 다른 서버로의 재접속
로그 보관 기간을 넘긴 단절
탭 전환, 기기 절전, 앱 복귀
로그인 만료와 계정 전환
실제 CDN·로드밸런서 경유 시의 버퍼링과 idle timeout
마치며
모든 상황에서 마감을 약속할 수는 없지만, 언제 데이터가 유효하고 언제 다시 확인해야 하는지는 명확하게 표현할 수 있음
전송 수단을 고르기 전에 "얼마나 늦어도 되고, 누가 보내고, 놓친 것은 어떻게 되찾는가"를 먼저 적을 것