Streamable HTTP 딥다이브 - SSE와 WebSocket 사이, POST 기반 스트리밍

Published:

SSE는 GET 기반 단방향이고, WebSocket은 인프라가 복잡하다. Streamable HTTP는 그 사이에서 “HTTP의 단순함은 유지하면서, 요청은 POST로 자유롭게, 응답은 필요할 때만 스트림으로, 원하면 stateless 운영까지”를 노리는 방식이다. 이 글에서는 SSE와의 차이 3가지, 서버/클라이언트 코드, 그리고 레이어별 제약(가장 크게 걸리는 건 브라우저다)을 정리한다.

이 글은 토스증권은 왜 느릴까? 탐구에서 파생됐다. 토스증권 웹 번들에서 SSE 기반 실시간 시세와 Leader-Follower 탭 공유 구현을 발견했는데, 그 복잡도를 Streamable HTTP로 얼마나 덜어낼 수 있는지가 이 글의 관심사 중 하나다.

등장 배경

SSE와 WebSocket은 각자의 단점이 뚜렷했다.

  • SSE는 GET만 쓰고 단방향
  • WebSocket은 인프라가 복잡

그래서 “HTTP 단순함은 유지하면서, 양방향도 되고, stateless도 가능한” 방식이 필요해졌다. Spring-AI의 MCP(Model Context Protocol) 서버 프레임워크 쪽에서 이 프로토콜을 표준 옵션 중 하나로 밀고 있다.

핵심 아이디어

POST와 GET을 공유하는 단일 엔드포인트에 요청을 보내면, 서버가 상황에 맞게 응답 모드를 고른다.

  • 단건이면 그냥 단일 JSON 응답 반환
  • 스트리밍이 필요하면 SSE 스트림(text/event-stream)으로 전환해서 이벤트를 흘려보냄

즉 “SSE를 대체하는 새 프로토콜”이 아니라, SSE를 transport로 품은 상위 레이어에 가깝다.

POST /mcp HTTP/1.1
Content-Type: application/json
Accept: application/json, text/event-stream

{"method": "subscribe", "params": {"stock": "005930"}}

서버는 상황에 따라 두 가지로 응답할 수 있다.

# (1) 단건이면 그냥 JSON
HTTP/1.1 200 OK
Content-Type: application/json

{"result": "subscribed"}
# (2) 스트림이 필요하면 SSE로
HTTP/1.1 200 OK
Content-Type: text/event-stream

data: {"price": 72000, "type": "snapshot"}

data: {"price": 72100, "type": "update"}
...

SSE 단독 사용과 비교하면 핵심 차이는 세 가지다.

차이 1 - POST 기반

SSE는 GET이다. 구독할 정보를 URL 쿼리로 넘겨야 한다. 필터가 복잡해지면 URL이 길어지고, 민감한 정보는 URL에 못 넣는다.

Streamable HTTP는 POST다. body에 JSON으로 뭐든 넣을 수 있다.

POST /stream
{
  "subscriptions": ["005930", "000660"],
  "filters": {"priceChangeAbove": 3.0},
  "fields": ["price", "volume"],
  "auth": { "token": "..." }
}

차이 2 - 응답 모드를 서버가 고른다

SSE는 서버 → 클라이언트로 흐르는 스트림 한 종류만 있다. 요청은 쿼리 문자열에 의존하고, 서버가 “이건 스트림이 필요 없는 단순 응답인데?”라고 판단해서 바꿀 수가 없다.

Streamable HTTP는 같은 엔드포인트에서 서버가 JSON 단건 응답과 SSE 스트림 중에 고른다. 요청마다 지연 특성이 달라도 같은 API 표면을 유지할 수 있다.

한 가지 주의. 이걸 흔히 “양방향”이라고 부르는데, 브라우저 fetch는 half-duplex다. Request.duplex: 'half'는 Chrome 105+에서만 실험적으로 허용되고 Firefox/Safari는 아직 막혀 있다. 그래서 실제 MCP 구현도 “한 연결 안에서 양쪽이 동시에 핑퐁”을 가정하지 않는다. 클라이언트가 뭔가 또 보내야 하면 새 POST를 날리고, 서버가 push할 게 있으면 기존 스트림이나 GET으로 연 SSE를 통해 내려준다.

# 실제 MCP 동작은 이런 식:
클라이언트 → POST /mcp {"method": "subscribe", ...}
서버      ← SSE 스트림: {"price": 72000}, {"price": 72100}, ...

# 주문을 보내고 싶으면? 같은 스트림에 끼워 넣는 게 아니라 별도 POST:
클라이언트 → POST /mcp {"method": "order", ...}
서버      ← JSON: {"result": "체결완료"}

“하나의 의미적 세션” 안에서 여러 메시지를 주고받을 수 있다는 뜻이지, “하나의 TCP 연결에서 동시에 양쪽 스트리밍”은 아니다. 진짜 동시 양방향이 필요하면 여전히 WebSocket이다.

차이 3 - Stateless 운영을 선택할 수 있다

설계하기 나름이다. 요청에 필요한 정보를 다 담는 방식으로 짜면 서버가 상태를 들고 있지 않아도 된다.

POST /mcp
{
  "sessionToken": "abc123",
  "lastEventId": "evt-4582",
  "subscriptions": ["005930"]
}

이렇게 두면 서버 A가 받아 처리하다가 연결이 끊기고 다음 요청은 서버 C가 받아도 된다. lastEventId 이후부터 스트림이 이어진다. 세션 고정이 필요 없다.

다만 이건 선택지지 속성이 아니다. MCP 공식 스펙은 Mcp-Session-Id 헤더로 세션 관리 모드도 허용하고, Spring AI MCP도 기본적으로 persistent connection을 전제한다. “Streamable HTTP = Stateless”가 아니라, “Stateless로도, Stateful로도 돌릴 수 있는 transport”에 가깝다.

코드 예시

서버 (Spring SseEmitter):

@PostMapping("/stream")
public SseEmitter subscribe(@RequestBody SubscribeRequest req) {
  SseEmitter emitter = new SseEmitter();

  priceService
    .streamFrom(req.getLastEventId(), req.getStocks())
    .forEach(price -> {
      emitter.send(SseEmitter.event()
        .id(price.getEventId())
        .data(price));
    });

  return emitter;
}

클라이언트 (fetch + ReadableStream):

async function subscribe(stocks, lastEventId) {
  const res = await fetch('/stream', {
    method: 'POST',
    body: JSON.stringify({ stocks, lastEventId }),
  });

  const reader = res.body.getReader();
  const decoder = new TextDecoder();

  while (true) {
    const { done, value } = await reader.read();
    if (done) break;

    const data = JSON.parse(decoder.decode(value));
    updatePrice(data);
    lastEventId = data.id;
  }

  subscribe(stocks, lastEventId); // 이어서 재구독
}

EventSource처럼 브라우저가 알아서 해주는 게 없다. fetch + ReadableStream을 직접 조합해야 한다.

토스증권 SSE 구현에 적용하면?

이 글의 출발점이었던 토스증권의 SSE 구현과 비교해보자.

현재 (SSE 기반)Streamable HTTP로 바꾸면 (stateless 설계 시)
new EventSource(url, {withCredentials: true})fetch(url, {method: 'POST'})
Leader-Follower 패턴단순화 가능 (stateless로 설계한다면)
BroadcastChannel 탭 공유단순화 가능 (설계 선택에 따라)
재연결 시 처음부터 구독lastEventId로 이어서
서버 세션 고정 필요아무 인스턴스나 가능

토스 번들에는 Leader/Follower/BroadcastChannel 관련 코드가 수백 줄 있는데, stateless로 설계한다면 상당 부분 단순화할 수 있다. 다만 transport를 바꾼다고 저 복잡도가 자동으로 사라지는 건 아니다. MCP 스펙 자체는 세션 관리(Mcp-Session-Id)도 허용하기 때문에, 실제 단순화는 “transport 변경 + stateless 설계 결정”이 함께 갈 때 생긴다.

제약사항 - 뭐가 필요한가

“Streamable HTTP”는 완전히 새 프로토콜이 아니라 기존 기술의 조합이다. 그래서 제약은 레이어별로 따져야 한다.

서버 (Java / Spring):

구성요소최소 버전비고
Java17+Spring Boot 3.x 때문
Spring Boot3.2+WebFlux streaming 안정화
Spring AI MCP1.0.0-M6+MCP 스펙 2025-03-26 반영

MCP 스펙을 엄격히 따르고 싶을 때 기준이다. 단순히 “HTTP로 스트림 응답만 내려주면 됨” 수준이면 SseEmitter는 Spring 4.2(2015)부터 있었고 Java 8로도 된다. 스펙을 따르려는지, 구조만 빌려오려는지에 따라 요구치가 확 달라진다.

HTTP 프로토콜:

프로토콜단방향 (서버→클라)양방향
HTTP/1.1OK (chunked transfer)불가 (request body 스트리밍 불가)
HTTP/2OKOK — 진짜 양방향 쓰려면 필수
HTTP/3OKOK (패킷 유실 많은 환경에 유리)

HTTP/1.1은 request body를 한 번에 다 보내야 한다. 응답만 스트리밍 가능. MCP 스펙의 “진짜 양방향”을 쓰려면 HTTP/2 이상이 필요하다.

브라우저 (가장 걸리는 지점):

기능ChromeFirefoxSafari
fetch()42+39+10.1+
ReadableStream43+65+10.1+
TextDecoderStream71+105+14.1+
Request body streaming (양방향 필수)105+ (2022)미지원미지원

현실적으로 가장 크게 걸린다. 진짜 양방향 스트리밍은 Chrome/Edge에서만 된다. Firefox/Safari는 2026년 기준 여전히 request body streaming을 지원하지 않는다. 그래서 MCP 구현체들도 대부분 “POST 하나 보내고 응답은 스트림” 패턴을 기본으로 삼고, 클라이언트가 뭔가 보낼 일이 생기면 별도 POST를 또 날리는 식으로 양방향을 흉내 낸다.

JavaScript / Node.js:

  • ES2017(async/await) 이상이면 작성 가능. 실무 타겟은 보통 ES2020+
  • Node.js는 18+ 면 fetch, ReadableStream이 전부 내장. 16 이하면 undici 필요

인프라 (덜 유명한데 진짜 중요):

  • nginx 기본 설정은 응답을 버퍼링한다. proxy_buffering off, proxy_read_timeout 같은 값을 손봐야 스트림이 덩어리로 뭉쳐서 나가지 않는다
  • 로드밸런서 idle timeout: AWS ALB 기본 60초. 오래 붙어 있는 연결은 잘린다
  • CDN/WAF가 중간 버퍼링을 할 수도 있다 (Cloudflare 등 일부 설정 필요)

그래서 다 바꾸면 될까?

그렇진 않다. 단점도 분명하다.

  • 브라우저 네이티브 API가 없다. EventSource처럼 “객체 하나 만들면 끝”이 아니라 직접 읽어야 한다.
  • 진짜 양방향은 브라우저 커버리지가 좁다. 위 표처럼 Firefox/Safari가 아직 request body streaming을 안 받아준다.
  • 생태계가 얇다. MCP 중심으로 표준화된 상태라 범용 사례가 아직 적다.
  • 중간 프록시 타임아웃·버퍼링 문제가 생길 수 있다. 응답을 오래 붙들고 있는 요청이라 nginx 기본 설정으로는 끊기거나 뭉쳐 나간다.
  • 증권처럼 돈이 오가는 서비스는 리스크가 크다. 검증되지 않은 프로토콜을 실시간 거래에 바로 넣긴 어렵다.

정리하면, Streamable HTTP는 SSE를 대체하는 신기술이 아니라 SSE를 transport로 품은 설계 패턴에 가깝다. HTTP 인프라를 그대로 쓰면서 POST body와 stateless 설계라는 자유도를 얻는 대신, 브라우저 네이티브 지원과 검증된 생태계를 포기한다. MCP처럼 새로 시작하는 프로토콜에는 좋은 선택이고, 이미 잘 돌아가는 실시간 시세 시스템을 갈아엎을 이유까지는 못 된다.