본문으로 건너뛰기
2026. 9. 8

브라우저에서 실시간성을 어떻게 보장하나요?

가로로 전달되는 이벤트와 허용 시간을 나타내는 경계선

“이 화면은 실시간으로 바뀌어야 합니다.”

요구사항에 이렇게 적혀 있으면 WebSocket부터 떠올리기 쉽습니다. 그런데 채팅 메시지, 파일 처리 완료 알림, 함께 편집하는 사람의 커서는 모두 기다릴 수 있는 시간이 다릅니다. 잠깐 연결이 끊겼을 때 빠짐없이 되찾아야 하는 데이터도 있고, 지금의 값만 다시 받으면 되는 데이터도 있습니다.

실시간이라는 말은 무엇을 약속하며, 브라우저에서는 그 약속을 어디까지 지킬 수 있을까요?


이 글에서 다루는 내용

실시간성을 먼저 정의하고 그 정의를 기술 선택의 기준으로 사용합니다. 폴링과 롱폴링, SSE, WebSocket이 데이터를 전달하는 과정을 따라가며 지연이 생기는 자리와 연결이 끊겼을 때 남는 일을 살펴봅니다. 이어서 WebRTC DataChannel·WebTransport, 탭 밖의 푸시까지 범위를 넓힙니다.

예제는 특정 라이브러리나 서비스의 구현을 재현하지 않습니다. 파일 작업 목록과 협업 화면을 가정해 브라우저 API, 서버 계약, 사용자에게 보이는 상태를 연결합니다. 처음 나오는 용어는 본문에서 풀고 끝의 용어 사전에서 다시 찾을 수 있게 모았습니다.



실시간의 정확한 의미

빠른 응답과 마감 안의 응답

공학에서 실시간(real-time)은 결과의 정확성을 판단할 때 언제 결과가 나왔는지도 포함하는 성질입니다. 계산 결과가 맞아도 필요한 시점을 넘겼다면 요구를 충족하지 못할 수 있습니다. Carnegie Mellon SEI의 실시간 시스템 보고서는 반응이 빠른 온라인 시스템과 시간 제약을 만족해야 하는 시스템을 구분합니다.

그래서 “평균 10ms 걸린다”와 “항상 100ms 안에 끝난다”는 다른 주장입니다. 전자는 관측한 속도를 말하고 후자는 마감(deadline)을 넘지 않는다는 상한을 약속합니다. 평균이 짧아도 드물게 3초씩 멈추면 두 번째 약속은 지킬 수 없습니다.

마감을 넘겼을 때의 의미에 따라 구분하기도 합니다.

구분마감을 넘긴 결과의 의미
하드 실시간(hard real-time)요구된 마감을 넘기면 시스템의 시간적 정확성을 위반함
펌 실시간(firm real-time)늦은 결과의 가치는 사라지지만 일부 마감 위반은 허용할 수 있음
소프트 실시간(soft real-time)늦어질수록 결과의 가치나 서비스 품질이 낮아짐

예를 들어 협업 화면에서 이미 지나간 커서 위치를 뒤늦게 표시하면 사용자는 상대가 거꾸로 움직인다고 느낄 수 있습니다. 이전 위치보다 최신 위치가 유용합니다. 반면 조금 늦게 도착한 채팅 메시지는 여전히 읽을 가치가 있습니다. 같은 연결을 사용하더라도 오래된 데이터를 처리하는 정책은 달라질 수 있습니다.

100ms나 30초는 실시간의 보편적인 기준일까요?

협업 커서는 100ms 안에 반영하고 일반 알림은 30초 안에 보여주기로 정할 수 있습니다. 둘 다 요구사항의 예시입니다. 어떤 화면에서 얼마나 기다릴 수 있는지는 사용자 행동과 실패 비용으로 정해야 하며, 특정 숫자만으로 실시간 여부가 결정되지는 않습니다.

웹에서 실시간 요구를 적는 세 가지 질문

제품에서 “실시간”이라고 부르는 기능은 엄격한 마감 보장보다 짧은 지연과 최신 상태를 기대하는 경우가 많습니다. 그 기대를 구현하려면 다음 질문에 답할 수 있어야 합니다.

질문요구사항에 적을 내용선택에 미치는 영향
얼마나 늦어도 되는가?시작·종료 시점, 허용 지연, 목표 달성 비율폴링 간격, 전송·처리 예산, 오래된 상태의 표시
누가 언제 보내야 하는가?서버의 변경 통지, 클라이언트의 명령, 양쪽의 발생 빈도반복 조회, 서버 스트림, 양방향 메시지 연결
놓친 것은 어떻게 되찾는가?최신 상태만 복구, 모든 이벤트 재생, 중복 허용 여부스냅샷, 이벤트 로그·커서, 멱등 처리

파일 처리 완료 알림을 예로 들면 “실시간으로 알려준다”보다 아래 문장이 구현에 도움이 됩니다.

사용자가 작업 목록을 보고 있고 통신 가능한 동안, 서버가 완료 상태를 확정한 시점부터 목록에 반영될 때까지의 지연을 측정합니다. 목표는 99%를 2초 이내에 반영하는 것입니다. 연결 복구 시 현재 목록을 다시 조회하고, 확인하지 못한 상태는 갱신 중으로 표시합니다.

여기서 서비스 수준 목표(SLO, Service Level Objective)는 달성하려는 기준이고, 실제로 측정한 지연·성공 비율은 서비스 수준 지표(SLI, Service Level Indicator)입니다. 측정 기간과 분모도 정해야 합니다. 화면이 닫혀 있던 시간을 제외했다면 그 조건을 숨기지 않고, 화면 복귀 후 복구 시간은 별도 목표로 둡니다.

지연과 최신성의 차이

지연(latency)은 어떤 일이 시작되고 관측될 때까지 걸린 시간입니다. 최신성(freshness)은 지금 보고 있는 상태가 원본의 현재 상태를 얼마나 잘 반영하는지에 관한 요구입니다.

연결이 끊긴 동안 변경이 발생했는데 화면이 그대로라면 마지막으로 받은 이벤트의 전송 지연이 아무리 짧았어도 현재 화면은 오래된 상태일 수 있습니다. 반대로 문서가 하루 동안 한 번도 바뀌지 않았다면 updatedAt이 어제라는 이유만으로 화면이 오래됐다고 판단할 수는 없습니다.

마지막 데이터 변경 시각, 마지막 서버 확인 시각, 마지막으로 적용한 버전은 서로 다른 정보입니다. UI에 “방금 갱신됨”을 표시할 때도 무엇을 확인한 시각인지 정해야 합니다.

Recap

실시간 요구에는 결과가 유효한 시간 조건이 들어갑니다. 웹에서는 허용 지연과 통신 방향, 유실 복구 방법으로 그 조건을 구체화할 수 있습니다. 연결 속도와 화면의 최신성은 따로 확인해야 합니다.



브라우저에서 무엇을 보장할 수 있을까요?

서버에서 화면까지의 경로

이벤트가 서버에서 발생해 화면에 보이기까지는 여러 단계를 지나갑니다.

WebSocket이나 SSE는 이 경로 중 전달 방법을 정합니다. 서버 큐가 밀리는 문제나 브라우저의 긴 JavaScript 작업까지 해결하지는 않습니다. 서버가 20ms 만에 전송했더라도 메인 스레드가 큰 파일을 분석하느라 막혀 있으면 화면 반영은 늦어집니다.

브라우저의 타이머도 지정한 시각에 정확히 실행되는 예약 장치가 아닙니다. 대기 중인 작업과 백그라운드 정책 때문에 실제 실행이 늦어질 수 있습니다. 타이머의 지연 조건을 보면 setTimeout(fn, 100)을 100ms 상한 보장으로 해석할 수 없는 이유가 드러납니다.

인터넷 연결과 일반적인 브라우저 실행 환경 전체에 대해 모든 이벤트가 정해진 시간 안에 화면까지 도착한다고 보장하기는 어렵습니다. 특히 기기가 절전 상태에 들어가거나 네트워크가 끊긴 구간에는 전송 방식만 바꿔 해결할 수 없습니다.

보장 범위를 나누는 방법

그렇다고 실시간 기능을 구현할 수 없다는 뜻은 아닙니다. 무엇을 어느 조건에서 지킬지 나누면 됩니다.

범위명시할 수 있는 약속
전송사용하는 연결·스트림 안의 순서와 전달 특성
애플리케이션이벤트 식별, 재생 범위, 중복 처리, 상태 재조회
서비스 운영특정 조건에서 지연 목표를 얼마나 자주 충족하는지
사용자 경험연결 단절·복구 중·오래된 데이터 상태를 어떻게 알리는지

모든 상황에서의 마감 준수와, 정해진 조건에서 측정하는 지연 목표는 구분해야 합니다. 목표를 넘겼을 때 최신 상태를 다시 가져오거나 오래된 결과를 버리는 것도 설계의 일부입니다.

연결됨은 최신 상태라는 뜻일까요?

연결은 열렸어도 아직 구독을 복원하지 않았거나, 단절 중의 이벤트를 따라잡는 중일 수 있습니다. 연결 상태와 데이터 동기화 상태를 별도로 두면 “연결됨” 표시 아래 과거 데이터가 남는 문제를 줄일 수 있습니다.

Recap

전송 API는 서버부터 화면까지의 전체 지연을 보장하지 않습니다. 연결의 전달 특성, 상태 복구 계약, 운영 목표를 각각 정해야 합니다. 브라우저가 멈추거나 연결이 끊기는 상황에서는 복구 절차와 상태 표시가 약속을 이어받습니다.



폴링과 롱폴링

주기적으로 현재 상태 묻기

폴링(polling)은 클라이언트가 일정 간격으로 서버에 변경을 묻는 방식입니다. 매번 일반적인 HTTP 요청·응답을 사용하므로 기존 조회 API로 시작하기 쉽습니다. HTTP 연결을 재사용할 수 있어 매 요청마다 TCP 연결을 새로 만든다는 뜻은 아닙니다.

조회 직후 서버 상태가 바뀌면 다음 조회까지 기다려야 합니다. 일정한 간격을 Δ라고 하고 변경이 그 구간에 고르게 발생하며 요청 처리 시간이 충분히 짧다고 가정하면, 폴링 대기 시간의 평균은 대략 Δ / 2입니다. 여기에 네트워크·서버 처리·화면 반영 시간이 더해집니다. 타이머 지연이나 실패·재시도까지 포함한 실제 최대 지연은 Δ로 보장되지 않습니다.

동시에 폴링하는 클라이언트가 N개라면 고정 간격에서 요청률은 대략 N / Δ입니다. 예를 들어 1만 개 클라이언트가 5초마다 조회하면 초당 약 2천 건입니다. 간격을 줄여 지연을 낮추면 변경이 없는 동안의 조회 비용도 함께 늘어납니다.

이 계산은 이벤트 개수와 다릅니다. 최신 상태만 조회하면 두 번의 조회 사이에서 일어난 중간 변화를 모두 알 수는 없습니다. 모든 이벤트가 필요하다면 조회 API도 마지막 커서 이후의 이벤트를 반환해야 합니다.

요청이 겹치지 않는 폴링

setInterval()에서 요청을 보내면 앞 요청이 끝나기 전에 다음 요청이 시작될 수 있습니다. 서버가 느릴수록 요청이 쌓이고, 응답 순서가 뒤집히면 오래된 상태로 화면을 덮을 수도 있습니다.

아래 코드는 한 번의 조회와 화면 반영이 끝난 뒤 다음 조회를 예약합니다. readAbortSignal을 받아 데이터를 조회하는 비동기 함수, applyonError는 동기적으로 화면 상태를 반영하는 함수라고 가정합니다.

function startPolling({ read, apply, onError }, intervalMs = 5000) {
let stopped = false;
let timer;
let inFlight;
let failures = 0;

async function tick() {
if (stopped) return;
const controller = new AbortController();
inFlight = controller;
const timeout = setTimeout(() => controller.abort(), 10000);

try {
const snapshot = await read(controller.signal);
if (stopped) return;
apply(snapshot);
failures = 0;
} catch (error) {
if (stopped) return;
failures += 1;
onError(error);
} finally {
clearTimeout(timeout);
inFlight = undefined;
if (!stopped) {
const ceiling = Math.min(60000, intervalMs * 2 ** Math.min(failures, 4));
// 정상 조회는 고정 간격, 실패 시 대기 시간을 늘리고 분산한다.
const delay = failures ? ceiling * (0.5 + Math.random() * 0.5) : intervalMs;
timer = setTimeout(tick, delay);
}
}
}

void tick();
return () => {
stopped = true;
clearTimeout(timer);
inFlight?.abort();
};
}

여기서는 시작 시점 간격이 조회·반영 시간 + intervalMs가 됩니다. 앞의 Δ / 2 계산을 코드의 intervalMs에 그대로 대입하면 안 됩니다. 10초 타임아웃과 최대 60초 대기는 예제 정책이며 실제 요구에 맞춰 정합니다. read는 전달받은 signal을 fetch에 연결하고 HTTP 오류도 실패로 처리해야 합니다.

실패가 반복될 때 대기 시간을 늘리는 것이 백오프(backoff), 클라이언트가 같은 순간에 다시 몰리지 않도록 무작위 지연을 섞는 것이 지터(jitter)입니다. 서버가 Retry-After를 내려주는 API라면 그 정책도 반영합니다. 인증 만료처럼 재시도만으로 해결할 수 없는 실패는 별도로 중단하고 인증 흐름으로 넘겨야 합니다.

변경되지 않은 응답의 본문을 반복해서 보내는 비용은 ETag와 조건부 요청으로 줄일 수 있습니다. 다만 서버에 요청이 도착하고 변경 여부를 확인하는 비용까지 없어지는 것은 아닙니다. 브라우저·CDN 캐시에서 과거 응답을 계속 받지 않도록 조회 목적에 맞는 캐시 정책도 필요합니다.

변경이 생길 때까지 기다리는 롱폴링

롱폴링(long polling)은 변경이 없을 때 서버가 응답을 바로 끝내지 않고 기다리는 방식입니다. 변경이 생기거나 정해진 대기 시간이 지나면 응답하고, 클라이언트는 곧바로 다음 요청을 보냅니다.

응답을 받은 뒤 다음 요청을 여는 짧은 틈에도 이벤트는 발생할 수 있습니다. 따라서 “연결이 열렸을 때 발생한 이벤트만 보내기”로 구현하면 누락이 생깁니다. 서버가 커서 이후의 이벤트를 보관하고 다음 요청에 돌려줘야 합니다. 롱폴링 자체가 그 저장소를 만들어주지는 않습니다.

서버에는 대기 중인 요청을 유지할 용량이 필요합니다. 이는 반드시 요청마다 운영체제 스레드를 하나씩 점유한다는 뜻은 아니지만 연결·요청 상태·타이머 등의 자원은 사용합니다. 프록시가 먼저 응답을 끊지 않도록 애플리케이션의 대기 시간과 중간 장비의 타임아웃도 맞춰야 합니다. 이러한 운영 문제는 RFC 6202에 정리돼 있습니다.

Recap

폴링은 조회 간격과 요청량을 교환합니다. 롱폴링은 변경이 없을 때 응답을 기다려 전달 지연을 줄이지만 대기 요청과 재요청을 관리해야 합니다. 어느 쪽이든 빠진 이벤트를 되찾으려면 조회 API의 커서·보관 계약이 필요합니다.



SSE: 응답을 닫지 않고 이벤트 보내기

HTTP 응답 하나에 이어지는 이벤트

SSE(Server-Sent Events)는 서버가 HTTP 응답을 열어둔 채 텍스트 이벤트를 이어 보내는 방식입니다. 브라우저에서는 보통 EventSource API로 구독합니다. 서버가 임의로 브라우저에 새 연결을 만드는 것이 아니라, 브라우저가 시작한 요청의 응답을 계속 쓰는 구조입니다.

서버가 응답에 쓰는 형식은 다음과 같습니다. 첫 부분은 HTTP 응답 헤더이고 그 뒤가 UTF-8 이벤트 스트림입니다.

Content-Type: text/event-stream
Cache-Control: no-cache

id: 42
event: job-updated
retry: 3000
data: {"jobId":"job-7","status":"done","version":8}

: keep-alive

빈 줄이 이벤트 블록을 구분합니다. 코드에서 데이터를 한 번 write()했다고 바로 브라우저의 이벤트 하나가 되는 것은 아닙니다. 네트워크 청크의 경계와 이벤트의 경계도 다릅니다.

필드의미
data전달할 문자열. 여러 줄이면 줄바꿈으로 이어짐
event이벤트 이름. 생략하면 기본 message 이벤트
id재연결 시 이어받을 위치로 쓸 이벤트 ID
retry정수 밀리초로 지정하는 재연결 대기 시간
:로 시작하는 줄주석. 애플리케이션 메시지 이벤트를 발생시키지 않음

SSE가 JSON 프로토콜인 것은 아닙니다. 예제에서 JSON 문자열을 실었으므로 클라이언트가 JSON.parse()하는 것입니다. 이벤트 이름과 데이터 구조는 애플리케이션이 합의합니다. MDN의 SSE 가이드에 필드와 이벤트 처리 방식이 나옵니다.

EventSource가 맡아주는 일

const source = new EventSource("/events");

source.addEventListener("job-updated", (event) => {
try {
const update = JSON.parse(event.data);
// 스키마와 버전을 검사한 뒤 상태를 반영하거나 목록을 재조회한다.
console.log(event.lastEventId, update);
} catch (error) {
// 잘못된 데이터와 화면 처리 실패를 별도로 기록한다.
console.error("Invalid event", error);
}
});

source.onerror = () => {
// CONNECTING이면 브라우저가 재연결을 진행할 수 있다.
// 여기서 EventSource를 계속 새로 만들면 연결이 중복될 수 있다.
console.log("EventSource state:", source.readyState);
};

// 화면·세션에서 구독을 끝낼 때 호출한다.
// source.close();

EventSource는 정상적으로 열린 스트림의 연결이 끊기면 재연결을 시도하는 동작을 제공합니다. 이전에 받은 id가 있으면 재연결 요청의 Last-Event-ID 헤더로 전달합니다. 예제에서 ID가 42라면 서버가 그 이후 이벤트를 보낼 때 사용할 수 있습니다.

모든 실패를 무조건 재시도하는 것은 아닙니다. 서버는 HTTP 204 No Content로 재연결 중단을 알릴 수 있고, close()로 직접 종료할 수도 있습니다. 동작의 기준은 HTML의 Server-sent events 명세입니다.

자동 재연결은 이벤트 복구까지 해줄까요?

Last-Event-ID는 서버에 이어받을 위치를 알려줍니다. 서버가 이벤트를 보관·재생하지 않으면 빠진 내용은 돌아오지 않습니다. 또한 브라우저가 이벤트를 수신했다는 사실은 애플리케이션이 그 이벤트를 성공적으로 적용했다는 확인이 아닙니다. 화면 반영에 실패한 경우의 복구는 별도로 설계해야 합니다.

브라우저 새로고침 후 새 EventSource를 만드는 경우까지 이전 객체의 커서가 자동으로 이어지는 것도 아닙니다. 재시작 후에도 이어받아야 한다면 저장한 애플리케이션 커서나 서버 스냅샷으로 시작하는 계약이 필요합니다.

인증과 요청 방향

SSE 스트림은 서버에서 클라이언트로 흐릅니다. 클라이언트가 작업을 취소하거나 메시지를 보낼 때는 별도의 POST 요청을 사용할 수 있습니다. 사용자 입력이 있다는 이유만으로 SSE가 부적합한 것은 아닙니다. 양쪽 데이터의 빈도와 상호작용 방식을 봐야 합니다.

브라우저의 기본 EventSource 생성자는 URL과 withCredentials 옵션을 받으며 임의의 Authorization 헤더를 넣는 옵션은 없습니다. 같은 오리진의 쿠키 인증이나, 자격 증명을 허용하도록 CORS를 구성한 교차 오리진 요청을 검토할 수 있습니다. 옵션은 EventSource 생성자 문서에 정의돼 있습니다.

헤더나 POST 본문이 필요한 스트리밍이라면 fetch로 응답 스트림을 읽는 방법도 있습니다. 이때는 텍스트 디코딩, 이벤트 구분, 재연결·커서 처리를 직접 구현하거나 이를 제공하는 라이브러리를 사용합니다. fetch로 SSE 형식을 읽는 것과 네이티브 EventSource가 재연결을 관리하는 것은 구분해야 합니다.

토큰을 URL에 넣으면 로그나 관측 도구에 남을 수 있습니다. 인증 수단을 정할 때는 연결 편의뿐 아니라 세션 만료와 권한 회수 시 구독을 어떻게 종료할지도 함께 정합니다.

서버는 보냈는데 브라우저에는 늦게 도착하는 경우

스트림은 중간에서 버퍼링되면 여러 이벤트가 한꺼번에 도착할 수 있습니다. 애플리케이션이 응답을 기록한 시각과 브라우저 수신 시각을 나눠 보면 이 지연을 찾는 데 도움이 됩니다.

Nginx에서는 proxy_buffering 설정이나 X-Accel-Buffering: no 응답 헤더가 관련됩니다. 이는 Nginx의 동작이며 모든 CDN·프록시에서 같은 헤더가 통하는 것은 아닙니다. Nginx 프록시 문서를 기준으로 실제 경로를 확인해야 합니다. Cache-Control: no-cache를 넣는 것만으로 전송 버퍼링까지 꺼지지는 않습니다.

연결에 오랫동안 데이터가 없으면 중간 장비가 idle timeout으로 닫을 수 있습니다. 주석 줄을 주기적으로 보내는 방식이 도움이 될 수 있지만, 실제로 flush되는지와 각 장비의 제한을 확인해야 합니다. 주석을 보냈다는 사실만으로 브라우저가 최신 상태를 적용했다고 알 수는 없습니다.

HTTP/1.1에서는 브라우저의 오리진별 동시 연결 제한 때문에 여러 탭의 SSE 연결이 다른 요청과 자원을 경쟁할 수 있습니다. HTTP/2에서는 여러 스트림을 한 연결에 실을 수 있지만 동시 스트림 수와 서버 자원에 제한이 있습니다. 버전을 바꿔도 구독을 무제한으로 만들 수는 없습니다. EventSource의 연결 제약을 함께 확인할 수 있습니다.

Recap

SSE는 HTTP 응답을 유지하며 서버의 텍스트 이벤트를 전달합니다. 네이티브 EventSource는 재연결과 마지막 이벤트 ID 전달을 제공하지만 이벤트 보관·재생·적용 성공까지 맡아주지는 않습니다. 인증 방식과 프록시 버퍼링도 스트림 설계에 포함해야 합니다.



WebSocket: 양쪽이 메시지를 보내는 연결

HTTP에서 메시지 연결로

WebSocket은 연결을 열어 둔 채 클라이언트와 서버가 독립적으로 메시지를 보낼 수 있는 프로토콜입니다. 이런 통신을 전이중(full-duplex) 통신이라고 합니다. 요청 하나에 응답 하나를 대응시키는 대신 같은 연결에서 채팅 메시지, 구독 요청, 상대의 활동 상태 등을 주고받습니다.

HTTP/1.1의 대표적인 연결 절차는 다음과 같습니다. 브라우저가 WebSocket으로 전환하겠다는 요청을 보내고 서버가 101 Switching Protocols로 수락하면, 이후에는 WebSocket 프레임을 주고받습니다. 실제 핸드셰이크에는 키와 버전 등의 헤더도 들어갑니다. RFC 6455가 이 절차와 프레임 형식을 정의합니다.

다만 WebSocket을 언제나 “HTTP를 업그레이드한 TCP 연결”이라고 설명하면 범위가 좁아집니다. HTTP/2에서는 확장 CONNECT, HTTP/3에서는 HTTP/3용 확장 CONNECT로 연결하는 방법이 정의돼 있습니다. 실제 사용 경로는 브라우저·서버·프록시의 지원에 따라 달라집니다. JavaScript에서는 같은 WebSocket API를 사용합니다.

프레임과 메시지

WebSocket은 텍스트와 바이너리 메시지를 전달할 수 있습니다. JSON은 애플리케이션이 선택하는 데이터 형식이지 WebSocket 자체의 형식은 아닙니다. 바이너리 메시지에는 ArrayBufferBlob을 사용할 수 있습니다.

프레임(frame)은 프로토콜이 데이터를 나누어 전송하는 단위이고, 메시지(message)는 애플리케이션이 받는 논리적인 데이터 단위입니다. 한 메시지가 여러 프레임으로 나뉠 수 있으며 브라우저의 message 이벤트에서는 조립된 메시지를 받습니다. 네트워크 패킷 하나, 프레임 하나, message 이벤트 하나를 같은 것으로 세면 안 됩니다.

WebSocket의 마스킹은 암호화일까요?

클라이언트가 보내는 프레임의 마스킹은 데이터를 비밀로 만드는 암호화가 아닙니다. 전송 구간 보호에는 wss://를 사용합니다. 암호화된 연결이더라도 누가 어떤 데이터를 구독할 수 있는지는 서버가 따로 검사해야 합니다.

연결 상태와 구독 상태

브라우저 API의 연결 상태는 CONNECTING, OPEN, CLOSING, CLOSED로 나뉩니다. 메시지를 보내려면 연결이 OPEN인지 확인해야 합니다. 아래는 연결과 메시지 수신을 보여주는 작은 예제입니다. 예제의 메시지 형식과 subscribe 동작은 서버와 합의해야 합니다.

const socket = new WebSocket("wss://example.com/realtime");

socket.addEventListener("open", () => {
socket.send(JSON.stringify({
type: "subscribe",
resource: "jobs",
}));
});

socket.addEventListener("message", (event) => {
// 이 예제의 서버 계약은 JSON 텍스트 메시지다.
try {
const message = JSON.parse(event.data);
console.log(message);
} catch (error) {
console.error("메시지 해석 실패", error);
}
});

socket.addEventListener("close", (event) => {
console.log("연결 종료", event.code, event.reason);
// 재연결 여부와 상태 복구는 애플리케이션에서 결정한다.
});

socket.addEventListener("error", () => {
console.error("WebSocket 연결 오류");
});

// 화면·세션에서 구독을 끝낼 때 호출한다.
// socket.close(1000, "leaving");

open 이벤트가 발생해도 서버가 구독 요청을 받아들이고 누락분을 전달했다는 뜻은 아닙니다. 서버가 구독 승인과 동기화 완료를 별도 메시지로 알려주도록 하면 연결 상태와 데이터 준비 상태를 구분할 수 있습니다.

기본 WebSocket API에는 자동 재연결이 없습니다. 재연결할 때는 아래 정책을 애플리케이션에서 정합니다.

  • 재시도 간격을 점차 늘리는 백오프(backoff)와, 동시 재접속을 분산하는 무작위 지터(jitter)를 적용합니다.
  • 인증 만료, 권한 거부, 사용자의 명시적 종료처럼 재시도하면 안 되는 경우를 구분합니다.
  • 이전 연결의 타이머와 이벤트 핸들러를 정리하고, 늦게 끝난 비동기 작업이 새 연결의 상태를 덮어쓰지 않게 합니다.
  • 연결이 잠깐 열렸다가 곧바로 닫히는 상황을 고려해, 백오프 초기화 시점을 단순히 open 발생으로만 정하지 않습니다.

종료 코드와 앱의 동기화 메시지까지 포함해 상태 전이를 정하면, 컴포넌트마다 재연결 타이머를 만드는 일을 피할 수 있습니다. 연결 수명과 페이지 이동은 WebSocket 클라이언트 작성 가이드에서도 다룹니다.

하트비트가 알려주는 것

네트워크가 끊겨도 양쪽이 즉시 알아채지 못하는 경우가 있습니다. 하트비트(heartbeat)는 주기적으로 신호를 교환해 상대가 응답하는지 확인하는 방법입니다.

WebSocket 프로토콜에는 Ping/Pong 제어 프레임이 있지만 브라우저 JavaScript에는 이를 직접 보내는 API가 없습니다. 서버의 프로토콜 Ping에 대한 처리는 브라우저가 맡습니다. 앱에서 응답 시간을 재려면 {"type":"ping","requestId":"p-1"} 같은 별도 메시지와 대응 응답을 설계할 수 있습니다. 이는 프로토콜의 Ping 프레임과 다릅니다. WebSocket 표준의 Ping/Pong 설명에 이 경계가 명시돼 있습니다.

하트비트 응답은 그 왕복 경로가 동작했다는 증거입니다. 구독한 데이터가 최신이거나 직전 명령이 저장됐다는 증거는 아닙니다. 응답에 서버의 구독 버전이나 처리 위치를 싣기로 합의했다면 그 정보까지 확인할 수 있지만, 단순한 pong에서 이를 추론할 수는 없습니다.

타임아웃 역시 즉시 장애 확정이라기보다 더 기다릴 수 없다는 판단입니다. 지연된 타이머 하나로 끊어진 연결을 확신하지 말고, 화면 가시성·최근 수신·재연결 정책과 함께 처리해야 합니다.

빠르게 보내는 쪽과 느리게 처리하는 쪽

백프레셔(backpressure)는 수신·처리가 느릴 때 생산이나 전송 속도를 조절하는 흐름 제어입니다. 기본 WebSocket API에는 수신 메시지의 처리를 기다리도록 생산자에게 자동으로 압력을 전달하는 인터페이스가 없습니다. 이벤트가 처리 속도보다 빨리 들어오면 메모리가 늘고 화면 반영도 밀릴 수 있습니다. WebSocket API 문서가 이 제약을 설명합니다.

송신 쪽의 bufferedAmountsend()로 넣었지만 아직 네트워크로 보내지 못한 데이터의 바이트 수입니다. 값이 줄었다고 서버가 업무 처리를 완료한 것은 아닙니다.

데이터 성격에 따라 밀린 데이터를 다루는 방법도 달라집니다.

데이터밀렸을 때 검토할 정책
커서 위치·진행률 같은 현재 값같은 대상의 중간 값을 합치고 최신 값만 반영
채팅 기록·변경 이력유실 없이 이어받을 로그와 처리 속도에 맞는 배치 구성
사용자가 실행한 명령요청 식별자와 처리 결과 확인, 중복 실행 방지

수신 속도와 화면 갱신 속도를 같게 만들 필요는 없습니다. 들어온 상태를 모아 렌더링 주기에 맞춰 표시할 수 있습니다. 다만 서로 다른 변경을 의미하는 이벤트까지 무조건 “마지막 것만 남기기”로 합치면 정보가 사라집니다.

연결 이후에도 필요한 권한 검사

네이티브 WebSocket 생성자에도 임의의 요청 헤더를 지정하는 옵션은 없습니다. 쿠키 등 실제 브라우저가 보낼 수 있는 인증 수단으로 연결을 설계하고, 연결 후 인증 메시지를 쓰는 경우에는 인증 전 구독·데이터 전송을 허용하지 않아야 합니다.

브라우저 WebSocket 연결을 일반적인 fetch의 CORS 정책이 그대로 보호한다고 가정해서는 안 됩니다. 서버에서 Origin을 허용 목록과 대조하고, 사용자 인증과 구독 대상별 권한을 검사합니다. Origin 검사는 브라우저를 통한 원치 않는 연결을 막는 데 쓰이며 사용자 인증을 대체하지 않습니다.

오래 유지되는 연결에서는 로그인 상태가 바뀔 수도 있습니다. 로그아웃, 계정 전환, 권한 회수 후에 이전 구독이 남지 않도록 연결과 구독의 수명을 세션에 연결해야 합니다.

Recap

WebSocket은 양쪽이 독립적으로 메시지를 보내는 연결입니다. 연결 재시도, 구독 복원, 하트비트, 느린 소비자 처리와 권한 검사는 앱과 서버의 책임으로 남습니다. 연결이 열리거나 송신 버퍼가 비었다는 사실만으로 데이터 처리 완료를 판단할 수는 없습니다.



재연결 이후의 상태 복구

현재 상태와 그동안의 변경

연결이 30초 끊겼다가 돌아왔다고 가정하겠습니다. 파일 작업 목록은 현재 상태를 다시 조회하면 충분할 수 있습니다. 채팅 기록은 그동안 추가된 메시지를 받아야 합니다. 같은 “복구”라도 가져올 대상이 다릅니다.

스냅샷(snapshot)은 특정 시점의 상태를 나타내고, 이벤트 로그는 상태가 변해 온 기록을 보관합니다. 커서(cursor)는 그 기록에서 어디까지 읽었는지 가리키는 위치입니다. 이들은 HTTP 조회, SSE, WebSocket 중 어느 것을 사용하더라도 서버 계약으로 정의할 수 있습니다.

단순히 연결이 돌아오면 다시 구독하는 것만으로 누락분이 복구되지는 않습니다. 서버가 현재 구독자에게만 이벤트를 뿌렸다면 연결이 없던 동안의 데이터는 그 전달 경로에 남아 있지 않을 수 있습니다.

스냅샷 조회와 구독 사이의 빈틈

“목록을 조회한 다음 변경 이벤트를 구독한다”는 구현에는 틈이 있습니다. 조회가 끝나고 구독이 시작되기 전에 바뀐 항목은 스냅샷에도, 이후 이벤트에도 없을 수 있습니다.

이를 막는 한 가지 계약은 스냅샷과 그 스냅샷에 대응하는 커서를 함께 반환하는 것입니다. 클라이언트는 그 커서 이후를 구독하고 서버는 누락 없이 현재까지 재생한 다음 실시간 전달로 이어갑니다.

이 그림은 서버가 반드시 제공해야 할 일관성 조건도 담고 있습니다. 스냅샷을 읽은 뒤 별도로 “지금의 최신 커서”를 가져와 붙이면, 중간 변경이 빠졌는데도 이미 반영한 위치처럼 보일 수 있습니다. 스냅샷의 내용과 커서는 같은 상태 경계를 가리켜야 합니다.

로그 재생에서 새 이벤트 전달로 바뀌는 순간에도 빈틈이 없어야 합니다. 서버에서 따라잡을 위치를 고정하고 이후 이벤트를 버퍼링하는 등 일관된 전환 절차가 필요합니다. 먼저 구독해 이벤트를 모으고 스냅샷을 조회하는 설계도 가능하지만, 이 경우에도 버전 비교와 중복 제거, 버퍼 상한을 정해야 합니다.

커서가 너무 오래됐다면 어떻게 할까요?

로그 보관 기간을 넘긴 커서로는 모든 변경을 되찾을 수 없습니다. 서버가 복구 불가를 명시하면 클라이언트는 새 스냅샷으로 초기화해야 합니다. 이전 커서를 조용히 무시하고 최신 이벤트부터 보내면 화면 일부가 영구적으로 빠진 상태가 될 수 있습니다.

서로 다른 식별자

실시간 메시지에 id 하나만 두면 무엇을 식별하는 값인지 모호해지기 쉽습니다. 필요한 책임에 따라 구분합니다.

식별하는 대상사용하는 이유
이벤트 ID발생한 이벤트 하나같은 이벤트의 중복 수신 식별
커서재생 가능한 스트림의 위치단절 이후 이어받기
엔티티 버전특정 문서·작업의 상태 버전이전 상태가 새 상태를 덮어쓰는 것 방지
요청 ID클라이언트 요청과 그 응답비동기 응답을 원래 요청과 대응
멱등 키중복돼도 한 번의 효과로 처리할 업무 요청결과를 못 받은 요청을 안전하게 재시도

이벤트 ID를 커서로 쓰는 계약도 가능합니다. 이름을 무조건 나누라는 뜻은 아닙니다. 같은 값을 쓰더라도 유일성의 범위, 정렬 가능 여부, 보관 기간을 문서화해야 합니다. 모든 커서가 정수이거나 서로 크기를 비교할 수 있는 것은 아닙니다.

예를 들어 서버가 문서별 증가 버전과 전체 상태를 함께 보낸다면, 다음과 같은 메시지를 사용할 수 있습니다.

{
"type": "job-state",
"eventId": "evt-7c9f",
"cursor": "log-partition-2:901",
"entityId": "job-123",
"version": 18,
"state": {
"status": "completed",
"progress": 100
}
}

현재 job-123의 버전이 18이면 같은 버전의 전체 상태를 다시 적용하지 않아도 됩니다. 버전 17이 나중에 도착해도 무시할 수 있습니다. 반면 “진행률에 10을 더한다” 같은 차분 이벤트라면 중간 이벤트 누락을 무시하고 최신 번호만 적용해서는 상태가 맞지 않을 수 있습니다.

순서가 있는 연결인데 버전이 왜 필요할까요?

한 WebSocket 연결에서 전달되는 메시지의 순서와 전체 서비스의 변경 순서는 범위가 다릅니다. HTTP 재조회와 스트림 수신이 경합하거나 재연결 후 이벤트가 다시 오면 버전 검사가 필요할 수 있습니다. await를 포함한 메시지 처리도 시작 순서와 완료 순서가 다를 수 있으므로, 순서가 필요한 처리는 직렬화해야 합니다.

누락 탐지도 번호 계약에 따라 달라집니다. 모든 이벤트를 받는 스트림에서 연속 번호를 약속했다면 41 다음 43으로 누락을 의심할 수 있습니다. 사용자 권한에 따라 일부 이벤트만 받는 전역 로그에서는 번호가 건너뛰어도 정상일 수 있습니다. 발생 시각만으로 순서를 정하면 서버 간 시계 차이나 같은 시각의 이벤트도 처리해야 합니다.

전달 확인과 처리 완료

서버가 명령을 처리한 직후 연결이 끊기면 클라이언트는 결과를 못 받을 수 있습니다. 이때 타임아웃의 의미는 “실패했다”가 아니라 성공했는지 아직 모른다입니다.

ACK(acknowledgement)는 확인 응답을 뜻하지만 무엇을 확인하는지 정해야 합니다. 바이트 수신, 메시지 해석, 상태 저장, 화면 반영은 서로 다른 단계입니다. TCP의 전달 확인이나 WebSocket의 Pong으로 업무 처리 완료를 대신할 수는 없습니다.

재시도한 명령으로 같은 작업이 두 번 생성되는 것을 막으려면 서버가 멱등 키를 받아 이미 처리한 결과를 돌려주는 등의 계약이 필요합니다. 키의 기록과 실제 상태 변경도 원자적으로 다뤄야 합니다. 중복 기록만 먼저 저장되고 실제 처리가 빠지거나, 반대 순서로 중복 실행되는 틈이 없어야 합니다. 키 보관 기간과 같은 키에 다른 입력을 보냈을 때의 정책도 포함됩니다. 이런 계약의 구체적인 설계는 Amazon Builders’ Library의 멱등 API 글에서 참고할 수 있습니다.

전달·처리 계약얻는 성질남는 책임
최대 한 번(at-most-once)중복을 피하지만 전달되지 않을 수 있음누락을 허용하거나 상태 재조회
최소 한 번(at-least-once)재시도·재생으로 전달을 추구하며 중복 가능중복 제거와 멱등 처리
한 번의 업무 효과같은 명령의 반복 수신에도 효과를 한 번으로 제한서버의 영속 기록·트랜잭션·키 범위 설계

이 계약들도 보관 기간과 장애 가정 안에서 성립합니다. “WebSocket이라 정확히 한 번 처리된다”는 약속은 성립하지 않습니다. 전송 수단을 고르는 것과 업무 효과를 한 번으로 만드는 것은 다른 설계 문제입니다.

복구를 표현하는 상태 모델

UI 상태는 isConnected 하나보다 연결과 동기화 단계를 함께 표현할 때 정확해집니다. 다음은 한 가지 예시입니다.

연결을 다시 열었다면 바로 “최신”으로 바꾸지 않고 동기화 완료를 기다립니다. 오프라인 동안 기존 데이터를 보여줄지는 화면의 성격에 따라 정하되, 최신 여부를 확인하지 못한 상태라는 점은 구분합니다. 입력을 잠시 보관한다면 전송 대기와 처리 완료도 다르게 표시합니다.

서버 쪽에서도 연결 수와 함께 구독 수, 송신 큐 크기, 가장 오래 대기한 이벤트의 나이를 봐야 합니다. 느린 클라이언트의 큐를 무제한으로 늘리면 지연을 회복하기 어렵습니다. 상한을 넘으면 최신 상태로 합칠지, 연결을 종료하고 다시 동기화시킬지 정합니다.

연결 서버를 여러 대로 늘릴 때는 다른 서버에 재접속해도 같은 커서를 해석할 수 있어야 합니다. 현재 연결에 뿌리는 Pub/Sub와 복구용 영속 로그를 같은 것으로 가정하지 않고, 상태 변경과 이벤트 기록 사이의 유실 가능성도 검토합니다.

Recap

복구에는 스냅샷·커서·로그 보관 범위가 필요합니다. 스냅샷 조회와 구독 사이, 로그 재생과 실시간 전달 사이의 빈틈을 서버 계약으로 막아야 합니다. 순서·중복·처리 완료는 각자의 식별자와 확인 절차로 다루며, UI는 연결됨과 동기화 완료를 구분합니다.



전달 순서와 재전송을 다르게 고를 때

오래된 데이터 때문에 새 데이터가 기다리는 문제

데이터가 순서대로 빠짐없이 도착하는 성질은 유용합니다. 다만 앞의 데이터가 유실돼 복구를 기다리는 동안 뒤의 데이터도 애플리케이션에 전달되지 못할 수 있습니다. 이런 대기를 Head-of-Line Blocking이라고 부릅니다. 어느 계층과 스트림에서 생기는 대기인지 구분해서 봐야 합니다.

예를 들어 과거 커서 위치를 되찾느라 더 새로운 위치까지 기다리는 것이 항상 좋은 선택은 아닙니다. 중간 위치를 버리고 최신 위치를 빨리 쓰는 편이 요구에 맞을 수 있습니다. 이때는 순서와 재전송을 다르게 선택할 수 있는 전송 수단이 후보가 됩니다.

WebRTC DataChannel

WebRTC DataChannelRTCPeerConnection의 두 피어 사이에서 일반 데이터를 주고받는 채널입니다. 오디오·영상만을 위한 기능은 아닙니다. 브라우저끼리 연결할 수도 있고 서버가 피어 역할을 할 수도 있습니다.

기본 채널은 순서를 지키며 신뢰성 있게 전달합니다. 채널 생성 시 ordered, maxRetransmits, maxPacketLifeTime 등으로 순서와 재전송 정책을 조정할 수 있습니다. 아래는 이미 만든 RTCPeerConnection에서 재전송 없이 순서에 얽매이지 않는 위치 채널을 만드는 예입니다. 피어 연결을 설정하는 과정은 별도로 필요합니다.

const positions = peerConnection.createDataChannel("positions", {
ordered: false,
maxRetransmits: 0,
});

maxRetransmitsmaxPacketLifeTime은 동시에 지정하지 않습니다. 전자는 재전송 횟수, 후자는 전송·재전송을 시도할 시간에 대한 제한이며, 화면 반영까지의 마감 보장은 아닙니다. 늦거나 뒤바뀐 위치를 무시하려면 메시지에 순서 번호를 넣는 등 애플리케이션의 판단도 필요합니다. 옵션은 createDataChannel 문서에 정리돼 있습니다.

WebRTC 데이터 채널은 SCTP와 DTLS를 사용합니다. SCTP는 데이터 채널의 메시지 전달을, DTLS는 전송 보안을 담당합니다. RFC 8831이 이 구조를 설명합니다.

연결을 성립시키는 과정도 고려해야 합니다. 두 피어가 연결 정보를 교환하는 시그널링은 애플리케이션이 준비해야 합니다. ICE는 통신 가능한 경로를 찾고, STUN은 외부에서 보이는 주소 등을 파악하는 데, TURN은 직접 연결이 어려울 때 중계하는 데 사용됩니다. 따라서 “피어 간 통신이므로 서버 비용이 없다”거나 “항상 직접 연결된다”고 가정할 수는 없습니다. 연결 경로 선택은 ICE 명세를 참고할 수 있습니다.

이미 음성·영상 통화용 피어 연결이 있는 화면에서 위치·상호작용 데이터를 함께 보내는 경우라면 검토하기 좋습니다. 서버가 확정해야 하는 공동 문서 상태는 별개입니다. 낮은 지연으로 메시지를 교환해도 동시 편집 충돌과 최종 상태의 권위는 해결되지 않습니다.

WebTransport

WebTransport는 브라우저와 서버 사이에서 스트림과 데이터그램을 사용할 수 있는 API입니다. HTTP/3·QUIC 기반 연결에서 신뢰성 있는 단방향·양방향 스트림과, 전달·순서를 보장하지 않는 데이터그램을 용도에 맞춰 사용할 수 있습니다. WebTransport 명세가 이 전송 모델을 정의합니다.

독립된 스트림으로 나누면 한 스트림의 유실 복구가 다른 스트림의 순서 대기를 직접 만들지 않도록 할 수 있습니다. 다만 연결의 대역폭과 혼잡 제어 같은 자원은 공유하며, 같은 스트림 내부의 순서 대기까지 사라지는 것은 아닙니다.

WebRTC가 피어 연결을 중심으로 한다면 WebTransport는 브라우저와 서버의 통신을 중심으로 검토할 수 있습니다. 어느 쪽이든 순서·재전송을 조정하는 기능이 하드 실시간 보장으로 바뀌지는 않습니다. 사용할 브라우저의 WebTransport 지원 범위와 서버·프록시·네트워크의 HTTP/3 및 UDP 경로도 함께 확인해야 합니다.

Recap

오래된 데이터의 복구보다 새로운 데이터 전달이 중요하다면 순서·재전송 정책을 다르게 고를 수 있습니다. WebRTC DataChannel과 WebTransport는 그 선택지를 제공하지만, 연결 토폴로지와 인프라 비용까지 함께 바뀝니다. 늦은 데이터의 폐기와 상태 일관성은 여전히 앱에서 다룹니다.



탭을 벗어난 실시간 경험

숨겨진 탭과 종료된 페이지

탭이 보이지 않는 상태, 브라우저가 페이지 실행을 동결한 상태, 페이지를 메모리에서 버린 상태는 다릅니다. 모바일에서는 운영체제의 절전 정책도 개입합니다. 열려 있는 연결이나 주기적인 타이머가 계속 같은 빈도로 실행된다고 전제할 수 없습니다. Page Lifecycle 문서가 동결·폐기를 포함한 수명주기를 설명합니다.

페이지가 다시 보일 때는 연결 상태를 확인하고 필요한 재조회나 이어받기를 시작합니다. 서버 확인이 끝나기 전까지는 기존 화면을 곧바로 최신 상태로 표시하지 않습니다. 가시성 이벤트는 이런 복구를 시작하는 신호로 사용할 수 있지만, 앱 종료 직전에 정리 이벤트가 반드시 실행된다고 의존해서도 안 됩니다.

Web Worker에 연결 코드를 옮기면 메인 스레드의 계산 부하를 나누는 데 도움이 될 수 있습니다. 그러나 운영체제가 앱을 멈추거나 종료하는 문제를 해결하지는 않습니다. Service Worker도 이벤트를 처리할 때 실행되며 브라우저가 수명을 관리하므로 상시 연결 서버처럼 사용할 수 없습니다. 실행 환경의 차이는 Web Worker와 Service Worker 글에서 더 자세히 다룹니다.

Push API와 Service Worker

페이지가 열려 있지 않을 때의 알림에는 Push API가 관련됩니다. 앱 서버가 푸시 서비스에 메시지를 전달하고, 브라우저가 푸시를 받으면 Service Worker의 이벤트 처리로 이어집니다. 사용자 권한, 구독, 브라우저·운영체제 정책이 함께 작동합니다. Push API 문서를 기준으로 대상 환경의 조건을 확인해야 합니다.

푸시는 정확한 시각의 실행 예약이 아닙니다. 기기 연결 상태나 보관 기한에 따라 늦거나 전달되지 않을 수 있습니다. 알림을 받았다고 실제 페이지의 상태가 갱신된 것도 아닙니다. 사용자가 알림을 눌러 돌아왔을 때 서버에서 현재 상태를 확인하는 흐름이 필요합니다.

또한 브라우저가 요구하는 사용자에게 보이는 알림 정책을 따져야 합니다. 푸시를 이용해 숨은 페이지를 계속 깨우거나 영구적인 백그라운드 연결을 유지할 수 있다고 설계하면 안 됩니다.

여러 탭에서의 연결 공유

같은 서비스의 탭을 여러 개 열면 SSE나 WebSocket 연결도 탭마다 늘어날 수 있습니다. BroadcastChannel은 같은 오리진과 저장소 파티션 안의 실행 환경끼리 메시지를 나누는 데 사용할 수 있습니다. 서버와 통신하는 전송 수단은 아니며, 닫힌 탭을 위해 메시지를 영속 보관하지도 않습니다. 범위는 Broadcast Channel API 문서에 설명돼 있습니다.

한 탭이 서버 연결을 맡고 다른 탭에 데이터를 전달하거나, SharedWorker가 연결을 소유하는 설계를 검토할 수 있습니다. 대신 소유 탭 종료, 연결 소유권 교체, 늦게 열린 탭의 초기 스냅샷, 계정 전환 시 구독 정리까지 필요합니다. 연결 하나를 줄이려고 복구 상태 머신을 훨씬 복잡하게 만들지는 않는지 비교해야 합니다.

Recap

페이지 밖의 전달은 열린 탭 안의 스트림과 실행 조건이 다릅니다. 푸시는 사용자를 다시 불러오는 경로로, 탭 간 통신은 연결·상태 공유 경로로 검토할 수 있습니다. 어느 경로로 돌아오든 현재 상태를 확인하는 복구 절차가 필요합니다.



고르는 순서와 검증 기준

요구사항에서 후보 좁히기

처음의 세 질문으로 돌아가면 전송 수단을 비교할 근거가 생깁니다. 아래 흐름은 출발점을 찾는 방법이며, 인프라와 기존 연결 구조까지 고려해 최종 선택합니다.

파일 작업 완료를 몇 초 간격으로 확인해도 되고 활성 사용자 수도 작다면 폴링으로 시작할 수 있습니다. 목표를 맞추려고 조회 간격을 계속 줄여야 하고 대부분의 응답이 “변경 없음”이라면 서버가 변경을 통지하는 SSE를 검토할 이유가 생깁니다. 작업 취소는 별도 HTTP 요청으로 보내도 됩니다.

협업 화면에서 양쪽이 위치와 입력 상태를 자주 보낸다면 WebSocket이 자연스러운 후보입니다. 이미 WebRTC 피어 연결이 있고 오래된 위치를 재전송하지 않아도 된다면 DataChannel도 검토할 수 있습니다. 프로토콜 이름만으로 답을 고르기보다 현재 서비스가 운영할 수 있는 서버 구조와 복구 비용을 함께 봅니다.

수단전달 방식브라우저가 기본 제공하는 재연결앱·서버가 정할 복구
폴링주기적인 HTTP 조회요청 반복·재시도는 직접 구성현재 상태 재조회 또는 커서 조회
롱폴링변경·제한 시간까지 응답 대기다음 요청·재시도는 직접 구성요청 사이의 이벤트 이어받기
SSE / EventSourceHTTP 응답의 서버→클라이언트 텍스트 이벤트명세의 조건에 따른 재연결·Last-Event-ID로그 재생, 커서 만료, 적용 실패 처리
WebSocket연결 위의 양방향 텍스트·바이너리 메시지없음재접속, 구독 복원, 누락·중복 처리
WebRTC DataChannel피어 간 메시지, 순서·재전송 정책 선택피어 연결 상태에 따른 앱 정책 필요연결 회복과 데이터별 복구·폐기
WebTransport서버와 스트림·데이터그램 교환앱 정책 필요세션 재생성, 이어받기·폐기
Push API페이지 밖의 푸시 이벤트앱이 유지하는 상시 연결과 다름페이지 복귀 후 서버 상태 확인

지연을 측정하는 경계

“빠르게 보인다”는 인상으로 운영 목표를 확인할 수는 없습니다. 어디부터 어디까지 재는지 먼저 정합니다.

  • 서버 내부 지연: 상태 확정부터 이벤트 기록·전송 큐 진입·실제 전송까지의 시간입니다.
  • 클라이언트 처리 지연: 메시지 핸들러 진입부터 상태 적용과 화면 반영까지의 시간입니다. UI 프레임워크의 상태 갱신 완료가 실제 화면 표시와 같은 시점인지는 별도로 확인합니다.
  • 복구 지연: 연결을 다시 시도한 시점부터 누락분 적용이나 스냅샷 교체를 마친 시점까지의 시간입니다.
  • 최신 여부 미확인 시간: 연결이 끊겼거나 동기화 확인을 못 한 상태가 얼마나 지속됐는지입니다.

브라우저 안의 구간 시간은 performance.now()처럼 단조 증가하는 시계로 측정할 수 있습니다. 서버의 시각과 브라우저의 시각을 빼서 종단 지연을 구할 때는 두 시계의 오차를 고려해야 합니다. performance.now()를 서버의 Unix 타임스탬프에서 바로 빼는 것도 잘못된 계산입니다. Performance.now 문서가 시간 원점과 시계 특성을 설명합니다.

RTT의 절반이 서버에서 화면까지의 지연일까요?

RTT는 요청이 갔다가 응답이 돌아오는 왕복 시간입니다. 양방향 경로와 처리 시간이 같다는 보장이 없으므로 절반을 정확한 편도 지연으로 쓸 수 없습니다. 메시지 수신 이후의 파싱·상태 갱신·렌더링 시간도 따로 남습니다.

평균과 함께 p95·p99 같은 상위 백분위 지연을 봅니다. 예를 들어 p99는 관측값의 99%가 그 값 이하에 있다는 뜻이며, 남은 1%의 상한까지 알려주지는 않습니다. 서버와 브라우저에서 각각 계산한 p99를 더한다고 전체 경로의 p99가 되지도 않습니다. 가능하면 같은 이벤트 ID로 전체 경로를 연결해 관측합니다. 지연 분포와 실패 신호를 나누어 보는 접근은 Google SRE의 모니터링 장에서 참고할 수 있습니다.

아예 도착하지 않은 이벤트를 지연 표본에서 빼면 수신에 성공한 것만 측정하게 됩니다. 전달 대상이었던 변경 중 목표 시간 안에 적용한 비율과 미수신·복구 실패를 함께 집계해야 합니다. 최신 값만 합쳐 보내는 화면이라면 모든 중간 이벤트의 수신률 대신 상태 버전을 따라잡는 시간처럼 요구에 맞는 지표를 정합니다.

끊기는 조건에서의 검증

앞의 파일 목록 예제처럼 “화면이 보이고 통신 가능한 동안 99%를 2초 이내 반영”을 목표로 정했다면, 평상시 지연과 연결 복구를 나누어 검증합니다. 아래는 자동화나 수동 재현 시 사용할 수 있는 시나리오입니다.

조건확인할 결과
30초 단절 중 서버 상태 변경재연결 후 변경이 빠짐없이 반영되거나 새 스냅샷으로 교체되는가?
서버는 명령을 처리했지만 응답 유실같은 멱등 키로 확인·재시도하며 중복 실행을 막는가?
이벤트 중복·늦은 HTTP 응답화면이 이전 버전으로 되돌아가지 않는가?
메인 스레드에 긴 작업·대량 이벤트 수신큐와 메모리에 상한이 있고 최신 화면을 회복하는가?
서버 재시작·여러 서버로 재접속커서와 구독 권한을 일관되게 해석하는가?
로그 보관 기간을 넘긴 단절복구 불가를 인식하고 스냅샷으로 재시작하는가?
탭 전환·기기 절전·앱 복귀확인 전 상태를 최신으로 표시하지 않는가?
로그인 만료·계정 전환이전 계정의 이벤트 수신과 재시도를 종료하는가?
실제 CDN·로드밸런서 경유버퍼링과 idle timeout 때문에 묶음 수신·주기적 단절이 생기지 않는가?

로컬 개발 서버에서 이벤트가 바로 도착하는 것과 사용자 네트워크에서 목표를 맞추는 것은 검증 범위가 다릅니다. 배포 경로에서 연결 수, 재접속 빈도, 큐 지연과 복구 실패도 함께 관측해야 지연 목표가 어긋난 이유를 찾을 수 있습니다.

Recap

브라우저에서 실시간성을 다루려면 먼저 유효한 시간과 실패 조건을 정해야 합니다. 그 조건에 맞는 전송 수단을 고르고, 누락 복구와 처리 확인을 설계한 뒤, 실제 서버부터 화면까지의 지연을 측정합니다. 모든 상황의 마감을 약속할 수 없더라도 언제 데이터가 유효하고 언제 다시 확인해야 하는지는 명확하게 표현할 수 있습니다.



용어 사전

시간과 관측

용어
실시간(real-time)결과의 정확성을 판단할 때 결과가 나온 시간도 포함하는 성질
마감(deadline)결과가 유효하려면 완료돼야 하는 시점
지연(latency)정의한 시작 사건부터 종료·관측 사건까지의 시간
최신성(freshness)현재 화면·복제본이 원본의 최신 상태를 반영하는 정도
RTT요청과 응답이 왕복하는 데 걸린 시간
지터(jitter)지연의 변동을 뜻하며, 재시도 문맥에서는 간격에 더하는 무작위 분산을 가리키기도 함
SLI / SLO실제 서비스 수준을 나타내는 지표 / 그 지표로 달성하려는 목표
p99관측값의 99%가 그 값 이하인 백분위 값

연결과 흐름 제어

용어
폴링 / 롱폴링주기적으로 조회 / 변경이나 제한 시간까지 응답을 기다린 뒤 다음 조회
SSE서버가 유지 중인 HTTP 응답으로 텍스트 이벤트를 보내는 형식과 전달 방식
EventSourceSSE 연결·이벤트 수신·재연결 동작을 제공하는 브라우저 API
전이중(full-duplex)양쪽이 독립적으로 데이터를 보낼 수 있는 통신
프레임 / 메시지프로토콜의 전송 단위 / 애플리케이션이 다루는 논리적 데이터 단위
하트비트상대가 응답하는지 확인하기 위해 교환하는 주기적 신호
백오프반복 실패 시 재시도 간격을 늘리는 정책
백프레셔소비 속도에 맞게 생산·전송 속도를 조절하는 흐름 제어
Head-of-Line Blocking앞의 데이터 처리를 기다리느라 뒤의 데이터도 진행하지 못하는 대기
데이터그램독립적으로 보내며 전달·순서 보장 여부를 별도로 다루는 데이터 단위
QUICHTTP/3의 기반인 UDP 위의 보안 전송 프로토콜로, 독립적인 스트림 등을 제공
ICE / STUN / TURN피어 연결 경로 선택 / 외부에서 보이는 주소 등의 확인 / 직접 연결이 어려울 때의 중계
SCTP / DTLSWebRTC 데이터 채널의 메시지 전송 / 전송 보안을 담당하는 프로토콜

복구와 처리 계약

용어
스냅샷특정 시점·버전의 전체 상태
커서이벤트 로그 등에서 이어 읽을 위치를 나타내는 값
재생(replay)보관한 이벤트를 특정 위치부터 다시 전달하는 것
재연결 / 재동기화통신 경로를 다시 여는 것 / 누락·차이를 반영해 상태를 다시 맞추는 것
워터마크(watermark)처리·동기화가 어디까지 진행됐는지 나타내는 기준 위치. 구체적 의미는 시스템 계약에 따름
ACK정해진 단계의 수신·처리 완료를 알리는 확인 응답
멱등성(idempotency)같은 작업을 반복해도 한 번 수행한 것과 같은 효과를 갖는 성질
팬아웃(fan-out)하나의 이벤트를 여러 구독자에게 분배하는 것
Pub/Sub발행자와 구독자를 분리해 메시지를 전달하는 모델. 영속 보관·재생 여부는 구현과 설정에 따름


References

시간 조건과 브라우저 실행

HTTP 스트리밍과 WebSocket

재시도와 중복 처리

다른 전송 경로와 탭 간 통신

좋은 사람들과 재미있는 일을 하며 열정적이고 즐겁게 살고 싶은 개발자