Curt Poem

프론트 엔드 공부와 지식 나눔을 위한 블로그

CS(computer Science)/웹(Web)

웹 프로토콜: WebSocket으로 실시간 통신 구현하기 (+SSE)

Dovelop 2025. 4. 6. 16:27

지난 글에서는 HTTP를 중심으로 웹 통신의 기본 개념과 동작 원리를 다루었습니다. 이번 글에서는 실시간 데이터 전송이 중요한 현대 웹 애플리케이션에서 자주 사용되는 두 가지 기술, WebSocketSSE (Server-Sent Events)에 대해 자세하게 알아보도록 하겠습니다.

HTTP는 기본적으로 클라이언트가 요청할 때마다 서버가 응답하는 요청/응답 모델입니다. 그런데 채팅, 실시간 알림, 주식 가격 업데이트 등에서는 서버가 발생하는 이벤트를 즉각적으로 클라이언트에 전달할 필요가 있습니다. 이런 서비스에선 양방향 통신 또는 서버 푸시 방식을 통해 실시간으로 통신하는 방법이 필요합니다.

WebSocket

WebSocket은 기존 HTTP 연결을 업그레이드하여 하나의 전송 통로로 동시에 양쪽 방향으로 통신을 가능하게 하는 기술입니다. 초기 핸드셰이크 과정은 HTTP를 통해 이루어지지만, 연결이 확립되면 별도의 TCP 기반 채널을 통해 서버와 클라이언트가 자유롭게 데이터를 주고받습니다.

연결 생성 및 내부 동작

핸드셰이크

WebSocket 연결은 HTTP 요청을 통해 시작됩니다. 클라이언트는 일반 HTTP 요청에 Upgrade: websocket 및 Connection: Upgrade 헤더를 추가하여 서버에 WebSocket 프로토콜로 전환을 요청합니다.

GET /chat HTTP/1.1
Host: server.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: x3JJHMbDL1EzLkh9GBhXDw==
Sec-WebSocket-Protocol: chat, superchat
Sec-WebSocket-Version: 13
Origin: http://example.com

서버가 이 요청을 승인하면, HTTP 101 Switching Protocols 응답을 보내며 WebSocket 연결이 확립됩니다.

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: HSmrc0sMlYUkAGmm5OPpG2HaGWk=
Sec-WebSocket-Protocol: chat

데이터 프레이밍

WebSocket은 데이터를 프레임 단위로 전송합니다. 큰 메시지는 여러 프레임으로 나누어 전송할 수 있으며, 프레임 단위의 분할 전송(Fragmentation)을 지원합니다.

  • FIN 비트: 프레임의 마지막 여부를 표시합니다. 여러 프레임에 걸쳐 하나의 메시지를 전송할 때 사용됩니다.
  • RSV 비트: 확장을 위해 예약된 비트입니다.
  • Opcode: 프레임의 유형(텍스트, 바이너리, 핑, 퐁, 클로즈 등)을 지정합니다.
  • Payload Length: 전송할 데이터의 길이를 나타냅니다.
  • Masking Key: 클라이언트에서 서버로 전송되는 데이터는 반드시 마스킹되어 전송됩니다.
  • Payload Data: 실제 전송되는 데이터입니다.

메시지 전송 및 연결 유지

한 번 연결이 확립되면 클라이언트와 서버는 서로 자유롭게 데이터를 주고받을 수 있습니다. 연결 상태를 유지하고, 상대방이 살아있는지 확인하기 위해 Ping 및 Pong 프레임이 사용됩니다. 연결을 종료할 때는 클로즈 프레임을 사용하여 정상 종료 또는 에러 종료를 알립니다. 양측 모두 클로즈 프레임을 교환한 후 연결을 종료합니다.

WebSocket 인터페이스

WebSocket 인터페이스는 웹 애플리케이션에서 실시간 양방향 통신을 위해 사용되는 객체를 정의합니다. 이 인터페이스는 EventTarget을 상속받으며, 여러 속성과 메서드, 그리고 이벤트 핸들러를 통해 연결 상태, 데이터 전송, 연결 종료 등의 기능을 제공합니다.

스펙에서는 WebSocket 인터페이스를 다음과 같이 정의합니다.

생성자

new WebSocket(url [, protocols])
  • url: WebSocket 연결을 설정할 URL을 나타내며, URL은 내부적으로 URL 파서를 거쳐 유효성을 검사합니다. 단, "ws", "wss", "http", "https" 스킴만 허용되며, 스킴이 "http"인 경우 "ws"로, "https"인 경우 "wss"로 변경합니다. 프래그먼트(#...)가 포함되면 오류가 발생합니다.
  • protocols: 선택 인자로, 문자열 또는 문자열 배열 형태로 서브프로토콜을 지정합니다. 하나의 문자열을 넣으면 단일 서브프로토콜로 간주되며, 중복되거나 올바르지 않은 값이 있으면 SyntaxError가 발생합니다. 연결이 실패하면 연결 종료 알고리즘이 실행되어 close 이벤트가 발생합니다.
  • url 속성: WebSocket 객체와 연결된 URL 기록을 나타내며, getter를 통해 직렬화된 URL을 반환합니다.

WebSocket 객체는 연결 상태를 나타내는 readyState 속성을 가지며 CONNECTING(0), OPEN(1), CLOSING(2), CLOSED(2)의 속성을 가집니다. CONNECTING은 연결 확립전, OPEN은 연결이 확립되어 데이터 전송이 가능한 상태, CLOSING은 연결 종료가 시작된 상태 CLOSED는 연결이 종료되었거나 시작되지 않은 상태를 의미합니다.

속성 및 메서드

이벤트 핸들러

  • onopen: 연결이 성공적으로 열렸을 때 발생하는 이벤트를 처리합니다.
  • onmessage: 서버로부터 메시지를 수신할 때 호출됩니다.
  • onerror: 에러가 발생했을 때 호출됩니다.
  • onclose: 연결이 종료될 때 호출되며, 종료 코드와 이유가 포함될 수 있습니다.
close([code], [reason])

WebSocket 연결을 종료하기 위한 메서드입니다. 연결 상태가 CLOSING 또는 CLOSED인 경우 아무 동작도 하지않습니다.
code: 선택 인자로, 1000(정상 종료) 또는 3000~4999 범위의 정수여야 하며, 올바르지 않으면 InvalidAccessError를 발생시킵니다.
reason: 종료 이유를 나타내는 문자열로, UTF-8로 인코딩한 결과 123바이트를 초과하면 SyntaxError가 발생합니다.

send(data)

WebSocket 연결을 통해 데이터를 전송합니다. 전송 가능한 데이터 타입은 USVString (텍스트), Blob, ArrayBuffer, ArrayBufferView 입니다. 버퍼가 가득 차면 연결이 종료될 수 있습니다.

코드 예시

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

socket.onopen = () => {
  console.log("WebSocket 연결이 성공했어요.");
  socket.send("안녕하세요, 서버님!");
};

socket.onmessage = (event) => {
  console.log("서버에서 온 메시지:", event.data);
};

socket.onerror = (error) => {
  console.error("WebSocket 오류가 발생했습니다:", error);
};

socket.onclose = () => {
  console.log("WebSocket 연결이 종료되었습니다.");
};

SSE

Server-sent events(SSE)는 서버가 클라이언트로 지속적으로 데이터를 전송할 수 있도록 하는 단방향 스트리밍 메커니즘입니다. 이를 통해 브라우저는 서버가 보내는 업데이트(예: 뉴스, 주식 시세, 실시간 알림 등)를 실시간으로 받을 수 있습니다.

 

HTTP 연결 위에서 동작하며, 텍스트 기반 데이터 전송 방식을 사용합니다. 한 번 연결되면 서버는 지속적으로 이벤트 데이터를 스트리밍할 수 있도록 설계되었습니다. 또한, 연결이 끊어질 경우 자동 재연결 기능을 제공하는 등 단순하면서도 효율적인 서버 푸시 방식입니다.

 

아래의 연결 방식을 살펴보면 알 수 있겠지만, 웹소켓과는 달리 독립된 새로운 프로토콜이 아니라 HTTP 프로토콜을 이용한 통신 방식입니다. 그 외에도 양방향 통신이 아닌 서버 → 클라이언트 방향의 단방향 통신만 가능하고 텍스트만을 전송할 수 있다는 한계도 존재합니다. 하지만 웹소켓에 비해 구현이 간단하고 재연결, 중복 전송 방지 등의 기능이 내장되어 있다는 장점도 있습니다.

연결 방식

클라이언트는 EventSource 객체를 사용하여 특정 URL에 HTTP 요청을 보냅니다. 서버는 이 요청에 대해 Content-Type: text/event-stream 헤더를 포함하여 응답하며, 이때 연결은 지속적으로 열린 채로 유지됩니다. 즉, 서버는 데이터를 텍스트 스트림 형태로 전송합니다. 이 스트림은 UTF-8 인코딩된 텍스트로 구성되며, 각 이벤트는 여러 줄의 텍스트로 표현됩니다.

내부 동작 과정

  1. 새로운 EventSource 객체 ev를 생성
  2. url을 파싱하여 유효한 URL인지 검사 (실패 시 SyntaxError)
  3. 내부 속성 설정
    url: 사용자가 넘긴 URL
    withCredentials: 전달된 옵션에 따라 설정 (true면 "include" 모드)
  4. 요청 생성
    요청 헤더에 Accept: text/event-stream 자동 추가 가능
    cache mode는 항상 "no-store"
  5. 응답 수신 시 처리
    200이 아니거나, Content-Type이 text/event-stream이 아니면 → 연결 실패
    정상 응답(200)이면 → 본문을 한 줄씩 해석하며 이벤트 처리
  6. 네트워크 오류 처리
    일시적인 네트워크 오류 발생 시 → 자동 재연결 시도

생성자

new EventSource(url [, { withCredentials: true }])

EventSource는 브라우저에서 Server-Sent Events(SSE)를 구현하기 위해 제공되는 기본 인터페이스이며, EventTarget을 상속받습니다. 이 인터페이스는 서버로부터 수신되는 지속적인 텍스트 이벤트 스트림을 처리하는 데 사용됩니다.

  • url: 이벤트 스트림을 제공하는 서버의 URL
  • withCredentials (선택): true로 설정하면 credentials mode가 "include"로 설정되어 쿠키, 인증 정보가 함께 전송됨, CORS 정책이 적용되는 환경에서 서버 측에서 Access-Control-Allow-Credentials: true 설정이 필요

웹소켓과 마찬가지로 readyState 속성을 통해 연결 상태를 나타내며, CONNECTING(0), OPEN(1), CLOSED(2)를 가지고 있습니다.

속성 및 메서드

이벤트 핸들러

  • onopen: 연결이 성공적으로 열렸을 때
  • onmessage: 서버로부터 이벤트를 수신했을 때
  • onerror: 오류가 발생했거나 연결이 끊어졌을 때
close()

내부적으로 실행 중인 fetch 요청을 중단시키고,readyStateCLOSED로 설정합니다.

브라우저 내부적으로 작동하는 기능들

  • 연결 유지 및 끊어졌을 때 자동 재연결(CLOSED 상태 제외)
  • id: 필드 기반으로 중복된 이벤트 재전송 방지
  • retry: 필드를 통해 재연결 간격 조정, 연결이 끊어진 뒤 서버에서 설정한 retry(ms) 시간 후에 브라우저가 재연결 시도

예시 코드

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

source.onopen = () => console.log('연결 성공');
source.onmessage = e => console.log('데이터 수신:', e.data);
source.onerror = e => console.error('오류 발생 또는 연결 끊김');

// 연결 종료
source.close();

성능 관점에서 본 SSE와 WebSocket

SSE와 WebSocket은 모두 실시간 통신을 가능하게 하지만, 내부 구조와 서버 리소스 사용 방식은 꽤 큰 차이를 보입니다. 단순히 "단방향이냐, 양방향이냐"를 넘어서, 실제 서비스 환경에서 수천, 수만 명이 동시에 접속했을 때 어떤 영향을 주는지 알아두는 게 중요합니다. 

구조적 차이

항목 SSE (Server-Sent Events) WebSocket
프로토콜 기반 HTTP 기반 (단방향 스트리밍) 독립적인 프로토콜 (양방향 통신 가능)
연결 방식 HTTP Keep-Alive 기반의 단일 요청 유지 TCP 기반의 지속 연결 (Upgrade 방식)
통신 방향 서버 → 클라이언트 (단방향) 서버 ↔ 클라이언트 (양방향)

 

우선 SSE는 HTTP 기반의 스트리밍 방식입니다. HTTP의 Keep-Alive 기능을 이용해서 하나의 요청을 계속 유지하고, 서버가 데이터를 전송할 때마다 응답 스트림에 이벤트를 밀어넣는 구조죠. 이 방식은 단순하고 브라우저 지원도 좋아서 구현이 쉬운 게 장점입니다.

 

하지만 연결 하나당 HTTP 요청이 유지된다는 것은 결국 커널 소켓 하나와 스레드(혹은 핸들러) 하나가 지속적으로 바인딩되어 있다는 의미입니다. 특히 스레드를 사용하는 전통적인 서버 환경에서는 동시 연결 수가 많아질수록 서버 메모리와 CPU 부담이 크게 증가합니다. HTTP/2의 멀티플렉싱 기능이 있다고 해도 SSE는 하나의 연결에서 하나의 이벤트 스트림만 사용할 수 있어서 본질적인 제약은 여전합니다. HTTP/3도 전송층이 UDP로 바뀌긴 했지만, SSE는 여전히 HTTP 응답 스트림을 기반으로 하기 때문에 성능 구조상 큰 개선은 기대하기 어렵습니다.

 

반면 WebSocket은 초기에 HTTP를 사용해 연결을 맺지만, 그 이후에는 자체 프로토콜로 업그레이드되어 별도의 통신 채널을 유지합니다. 이 채널은 여전히 커널 소켓 위에서 돌아가지만, 이벤트 기반 서버 환경에서는 수천~수만 개의 소켓도 단일 스레드로 효율적으로 관리할 수 있습니다. 요청 하나가 소켓 하나를 필요로하지만, 쓰레드 하나를 필요로하지는 않습니다. 즉, 물리적인 소켓 수는 같더라도 이를 처리하는 방식에서 큰 차이가 발생하는 셈이죠. 이 덕분에 WebSocket은 실시간 게임, 주식 정보, 채팅 앱처럼 많은 연결이 필요한 상황에서도 훨씬 더 안정적으로 확장할 수 있습니다.

 

물론 두 방식 모두 지속적인 연결을 위해 소켓을 사용하는데, 소켓마다 커널 수준의 송수신 버퍼(기본 64KB~256KB 이상)가 붙고, 데이터가 애플리케이션 레벨로 소비되지 않으면 이 공간에서 대기하다가 메모리 부족(OOM)으로 이어질 수도 있습니다. 이런 문제를 방지하기 위해서는 커널 수준의 버퍼를 최대한 빠르게 소비하는 것도 중요하겠지만 클라이언트 수 제한 또는 클러스터링으로 분산처리, 오랫동안 데이터 송수신이 없는 경우 연결을 끊는 등 추가적인 대책이 필요합니다.

 

그렇지만 실제 실험을 진행한 글을 보면 프로토콜에 따른 통신과 연결에 드는 비용은 작은 부분을 차지하는 것처럼 보입니다. 실제로는 데이터를 전송하는 데보다 데이터를 파싱하고 렌더링하는 데 CPU 리소스가 더 많이 사용되기 때문에, 성능보다는 기능적인 요구 사항을 중심으로 선택하는 것이 더 중요하다고 봅니다. 결론적으로 양방향 통신이 필요하거나(or) 문자열 외의 이진 데이터를 전송한다면 WebSocket이, 서버에서 클라이언트로 전송만 필요하고(and) 문자열 데이터 전송만 필요하다면 SSE를 선택하는 것이 좋을 것입니다.

수정 내역

2025.4.16 - 실제 실험 진행한 글을 추가하여 성능 차이가 미미하다는 점 추가

참고 자료

위키백과, 웹소켓
WebSockets Standards
RFC 6455
HTML Standard, Server-sent events

WebSocket vs. Server-sent Events: A Performance Comparison