채팅 앱에서 상대방이 메시지를 보내면 바로 화면에 뜹니다. 그런데 HTTP는 클라이언트가 요청해야 서버가 응답하는 구조 — 서버가 먼저 데이터를 "밀어줄" 수 없습니다.

이 한계를 넘기 위해 Polling, Long Polling, SSE, WebSocket이 등장했습니다. 각 기법이 어떤 문제를 해결하고, 어떤 한계가 남는지 인과 흐름으로 정리합니다.


Polling

가장 단순한 방법입니다. 일정 간격으로 서버에 "새 데이터 있어?"라고 계속 물어보는 겁니다.

JAVASCRIPT
setInterval(async () => {
  const res = await fetch('/api/messages');
  if (res.data.length > 0) {
    renderMessages(res.data);
  }
}, 3000); // 3초마다

구현은 쉽지만, 문제가 명확합니다.

  • 새 데이터가 없어도 요청을 보냄 → ** 서버 부하**
  • 간격을 줄이면 부하 증가, 늘리면 실시간성 저하 → ** 트레이드오프**
  • 매 요청마다 HTTP 헤더가 왕복 → ** 대역폭 낭비**

채팅처럼 메시지가 언제 올지 모르는 상황에서 1초마다 폴링하면, 대부분의 응답이 "없음"입니다. 사용자가 1,000명이면 초당 1,000개의 빈 응답이 오가는 셈입니다.


Long Polling

Polling의 개선판입니다. 서버가 응답을 바로 주지 않고, 새 데이터가 생길 때까지 ** 연결을 잡아둡니다 **.

PLAINTEXT
클라이언트                         서버
    │                               │
    │──── GET /messages ───────────→│
    │           (대기 중...)          │  ← 새 데이터 올 때까지 응답 보류
    │                               │
    │           ...30초 경과...       │
    │                               │
    │←── 200 OK [새 메시지] ────────│  ← 데이터 생기면 그때 응답
    │                               │
    │──── GET /messages ───────────→│  ← 즉시 다음 요청
    │                               │

Polling보다 훨씬 효율적입니다. 빈 응답이 줄어들고, 데이터가 생기면 거의 즉시 전달됩니다.

하지만 여전히 문제가 있습니다:

  • 매번 HTTP 연결을 새로 맺음 → TCP handshake + HTTP 헤더 오버헤드
  • 서버에서 보류 중인 연결이 많아지면 리소스 점유
  • 타임아웃 관리가 까다로움

Facebook이 초기에 채팅에 Long Polling을 썼다는 건 유명한 이야기입니다. 동작은 하지만, 규모가 커지면 한계에 부딪힙니다.


SSE (Server-Sent Events)

서버 → 클라이언트 ** 단방향** 스트림입니다. 클라이언트가 한 번 연결하면, 서버가 원할 때마다 데이터를 내려보냅니다.

동작 원리

PLAINTEXT
클라이언트                         서버
    │                               │
    │──── GET /stream ─────────────→│
    │     Accept: text/event-stream  │
    │                               │
    │←── HTTP 200 ─────────────────│
    │     Content-Type: text/event-stream
    │                               │
    │←── data: {"price": 52300}\n\n │  ← 서버가 원할 때 push
    │                               │
    │←── data: {"price": 52450}\n\n │
    │                               │
    │        (연결 유지)              │

HTTP 위에서 동작하니까 별도의 프로토콜이 필요 없습니다. text/event-stream Content-Type으로 응답하고, 각 이벤트는 data: 필드 뒤에 붙여서 \n\n으로 구분합니다.

브라우저 API

JAVASCRIPT
const source = new EventSource('/api/notifications');

source.onmessage = (event) => {
  const data = JSON.parse(event.data);
  showNotification(data);
};

source.onerror = () => {
  // 연결 끊기면 브라우저가 자동 재연결
};

EventSource가 자동 재연결을 지원한다는 게 꽤 큰 장점입니다. 개발자가 직접 재연결 로직을 짤 필요가 없습니다.

적합한 케이스

  • 주식 시세, 환율 같은 단방향 실시간 데이터
  • 알림 피드
  • 서버 로그 스트리밍
  • ChatGPT의 스트리밍 응답도 SSE 기반

한계

  • ** 단방향 **. 클라이언트 → 서버 전송은 별도 HTTP 요청으로 해야 함
  • HTTP/1.1에서 브라우저당 동일 도메인 연결 수 제한 (보통 6개)
  • 바이너리 데이터 전송에 부적합 (텍스트 기반)

WebSocket

드디어 ** 양방향 **입니다. 클라이언트와 서버가 하나의 연결 위에서 자유롭게 메시지를 주고받을 수 있습니다.

핸드셰이크 과정

WebSocket은 HTTP로 시작해서 프로토콜을 전환합니다.

PLAINTEXT
클라이언트 → 서버 (HTTP 요청)
GET /chat HTTP/1.1
Host: server.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
PLAINTEXT
서버 → 클라이언트 (HTTP 응답)
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=

101 Switching Protocols — 이 응답이 오면 이제부터 HTTP가 아닙니다. TCP 연결은 유지한 채 WebSocket 프로토콜로 전환됩니다.

Sec-WebSocket-KeySec-WebSocket-Accept는 보안용이 아니라, ** 프록시나 캐시 서버가 WebSocket 연결을 HTTP로 잘못 해석하는 걸 방지 **하기 위한 용도입니다.

프레임 기반 통신

핸드셰이크 이후부터는 HTTP가 아니라 ** 프레임** 단위로 통신합니다.

PLAINTEXT
 0                   1                   2                   3
 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-------+-+-------------+-------------------------------+
|F|R|R|R| opcode|M| Payload len |    Extended payload length    |
|I|S|S|S|  (4)  |A|     (7)     |             (16/64)           |
|N|V|V|V|       |S|             |                               |
| |1|2|3|       |K|             |                               |
+-+-+-+-+-------+-+-------------+-------------------------------+
|     Masking-key (0 or 4 bytes)                                |
+-------------------------------+-------------------------------+
|     Payload Data                                              |
+---------------------------------------------------------------+

HTTP 헤더 없이 프레임 헤더(2~14바이트)만 붙습니다. HTTP 요청 하나가 수백 바이트의 헤더를 가지는 것과 비교하면 오버헤드가 극적으로 줄어듭니다.

브라우저 API

JAVASCRIPT
const ws = new WebSocket('ws://server.example.com/chat');

ws.onopen = () => {
  ws.send(JSON.stringify({ type: 'join', room: 'general' }));
};

ws.onmessage = (event) => {
  const msg = JSON.parse(event.data);
  appendMessage(msg);
};

ws.onclose = (event) => {
  console.log(`연결 종료: ${event.code} ${event.reason}`);
  // 재연결 로직 직접 구현해야 함
};

SSE와 달리 ** 자동 재연결을 지원하지 않습니다 **. 직접 구현해야 합니다.


WebSocket vs SSE vs Long Polling 비교

PollingLong PollingSSEWebSocket
방향단방향단방향단방향 (서버→클라)** 양방향**
프로토콜HTTPHTTPHTTPWS (HTTP로 시작)
연결매번 새로매번 새로유지유지
실시간성낮음중간높음** 높음**
헤더 오버헤드매 요청매 요청최초 1회최초 1회
자동 재연결N/A직접 구현** 브라우저 내장**직접 구현
바이너리XXXO
적합한 경우간헐적 갱신즉시성 필요서버 push채팅, 게임

결론부터 말하면, ** 서버에서 클라이언트로만** 데이터를 보내면 SSE가 더 간단합니다. ** 양방향 **이 필요하면 WebSocket을 써야 합니다.


STOMP 프로토콜

WebSocket은 메시지를 주고받는 "파이프"만 제공합니다. 메시지의 형식, 라우팅, 구독 같은 건 직접 정해야 합니다. 이걸 표준화한 게 STOMP(Simple Text Oriented Messaging Protocol)입니다.

메시지 브로커 패턴

STOMP는 Pub/Sub 모델을 따릅니다.

PLAINTEXT
                    ┌──────────────┐
사용자 A ──SEND──→  │              │ ──MESSAGE──→ 사용자 B
                    │  메시지 브로커  │
사용자 B ──SUBSCRIBE→│  (/topic/chat)│ ──MESSAGE──→ 사용자 C
                    │              │
사용자 C ──SUBSCRIBE→│              │
                    └──────────────┘

클라이언트는 SUBSCRIBE로 특정 목적지(destination)를 구독하고, SEND로 메시지를 보냅니다. 브로커가 해당 목적지를 구독한 모든 클라이언트에게 메시지를 전달합니다.

Spring WebSocket + STOMP

Spring에서 STOMP를 사용하면 이렇게 됩니다.

JAVA
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {

    @Override
    public void configureMessageBroker(MessageBrokerRegistry config) {
        config.enableSimpleBroker("/topic", "/queue");  // 구독 prefix
        config.setApplicationDestinationPrefixes("/app"); // 전송 prefix
    }

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry.addEndpoint("/ws")
                .setAllowedOrigins("*")
                .withSockJS(); // WebSocket 미지원 브라우저 폴백
    }
}
JAVA
@Controller
public class ChatController {

    @MessageMapping("/chat.send")     // /app/chat.send 로 메시지 수신
    @SendTo("/topic/public")           // /topic/public 구독자에게 전달
    public ChatMessage sendMessage(ChatMessage message) {
        return message;
    }
}

/topic은 1:N 브로드캐스트, /queue는 1:1 메시지에 사용하는 게 관례입니다.


Socket.IO

WebSocket을 감싸서 편의 기능을 잔뜩 얹어놓은 라이브러리입니다. Node.js 생태계에서 많이 씁니다.

WebSocket과의 차이

Socket.IO ≠ WebSocket입니다. Socket.IO는 WebSocket을 기본 전송 수단으로 사용 하되, WebSocket이 안 되는 환경에서 자동으로 Long Polling으로 폴백 합니다.

JAVASCRIPT
// 서버
const io = require('socket.io')(server);

io.of('/chat').on('connection', (socket) => {
  socket.join('room-1');  // 룸 참가

  socket.on('message', (data) => {
    io.of('/chat').to('room-1').emit('message', data); // 룸에 브로드캐스트
  });
});

주요 기능

  • **네임스페이스 **: 하나의 연결로 여러 채널을 논리적으로 분리 (/chat, /notifications)
  • ** 룸 **: 네임스페이스 안에서 다시 그룹핑. 채팅방 구현에 딱 맞음
  • ** 자동 재연결 **: 연결 끊기면 알아서 재연결 시도
  • **ACK 콜백 **: 메시지를 보내고 상대방이 받았는지 확인 가능

다만 Socket.IO는 자체 프로토콜을 쓰기 때문에, 순수 WebSocket 클라이언트로는 Socket.IO 서버에 연결할 수 없습니다. 양쪽 다 Socket.IO를 써야 합니다.


스케일아웃 문제

WebSocket의 실무 최대 난제입니다. 서버가 한 대일 때는 문제가 없는데, 여러 대로 늘리면 이야기가 달라집니다.

왜 문제인가

PLAINTEXT
사용자 A ──── WS 연결 ──→ 서버 1
사용자 B ──── WS 연결 ──→ 서버 2

A가 B에게 메시지 전송 → 서버 1이 받음 → 근데 B는 서버 2에 연결돼 있음

HTTP는 stateless니까 아무 서버에서 처리하면 됩니다. 그런데 WebSocket은 stateful 입니다. 특정 클라이언트와의 연결이 특정 서버 인스턴스에 종속됩니다.

해결: Redis Pub/Sub

PLAINTEXT
사용자 A ── 서버 1 ──┐
                      ├── Redis Pub/Sub ── 모든 서버에 브로드캐스트
사용자 B ── 서버 2 ──┘

1. A가 서버 1에 메시지 전송
2. 서버 1이 Redis에 PUBLISH
3. 서버 2가 SUBSCRIBE하고 있다가 메시지 수신
4. 서버 2가 B에게 WebSocket으로 전달

Socket.IO에는 @socket.io/redis-adapter가 있고, Spring에서는 외부 메시지 브로커(RabbitMQ, ActiveMQ)를 STOMP 브로커로 사용하면 동일한 효과를 냅니다.

Sticky Session

로드 밸런서에서 같은 사용자의 요청을 같은 서버로 보내는 방법도 있습니다. WebSocket 핸드셰이크가 HTTP로 시작하니까, 이 단계에서 쿠키 기반으로 라우팅합니다.

하지만 특정 서버에 부하가 집중될 수 있고, 그 서버가 죽으면 연결된 모든 사용자가 끊깁니다. 근본적인 해결책이 아닌 보조 수단 정도로 보면 됩니다.


주의할 점

"WebSocket과 HTTP/2 Server Push의 차이는?"

HTTP/2 Server Push는 클라이언트가 요청하지 않은 리소스를 서버가 미리 보내는 기능이지만, **범용 실시간 통신용이 아닙니다 **. CSS, JS 같은 정적 리소스를 미리 푸시하는 게 목적이었고, 실제로 Chrome 106부터 제거됐습니다.

HTTP/2의 ** 스트림 멀티플렉싱 **과 헷갈리면 안 됩니다. HTTP/2 스트림은 여전히 요청-응답 모델입니다. 서버가 임의로 데이터를 보낼 수 없습니다. WebSocket은 한 번 연결되면 양쪽 어디서든 자유롭게 메시지를 보낼 수 있고요.

"WebSocket에서 하트비트/ping-pong은 왜 필요한가?"

TCP 연결이 살아있어도 ** 중간 장비(프록시, 로드 밸런서, NAT)**가 유휴 연결을 끊을 수 있습니다.

WebSocket 프로토콜 자체에 ping/pong 프레임이 정의되어 있습니다. 서버가 주기적으로 ping을 보내면, 클라이언트가 pong으로 응답합니다.

PLAINTEXT
서버 → ping (opcode 0x9)
클라이언트 → pong (opcode 0xA)

pong이 안 오면 연결이 끊어진 걸로 판단하고 자원을 정리합니다. 보통 30초~1분 간격으로 설정합니다.

"연결이 끊기면 어떻게 처리하나?"

몇 가지 전략이 있습니다.

  1. ** 지수 백오프 재연결 **: 1초 → 2초 → 4초 → 8초 ... 간격으로 재연결 시도. 서버 장애 시 모든 클라이언트가 동시에 몰리는 thundering herd 문제를 완화
  2. ** 메시지 유실 방지 **: 재연결 시 마지막으로 받은 메시지 ID를 보내서, 서버가 그 이후 메시지를 재전송
  3. ** 오프라인 큐 **: 연결이 끊긴 동안 보내려 한 메시지를 로컬에 쌓아두고, 재연결 후 일괄 전송

"WebSocket이 HTTP보다 항상 좋은 건가?"

아닙니다. WebSocket은 연결을 계속 유지하니까 서버 리소스를 잡아먹습니다. 동시 접속자가 적거나, 데이터 갱신 빈도가 낮다면 그냥 Polling이나 SSE가 나을 수 있습니다.

REST API 호출이 초당 몇 번 수준이면 HTTP로 충분합니다. WebSocket은 ** 밀리초 단위의 실시간성 **이 필요하거나, ** 양방향 메시지 **가 빈번한 경우에 선택하는 겁니다.


파생되는 개념들

  • HTTP — HTTP/2 멀티플렉싱, Server Push와의 차이
  • TCP — WebSocket이 올라타는 전송 계층
  • 메시지 큐 — STOMP 외부 브로커, 스케일아웃에서 Redis Pub/Sub
  • Spring MVC@MessageMapping, WebSocket 설정과의 관계
댓글 로딩 중...