본문 바로가기
programming

HTTP와 스트리밍 (SSE)

by AtoN 2023. 5. 10.

HTTP와 스트리밍 (SSE)

HTTP/1.1, HTTP/2, HTTP/3의 발전을 Head-of-Line(HOL) blocking을 줄여 온 과정으로 보면 큰 흐름을 잡기 쉽습니다. 다만 이것만이 전부는 아닙니다.

HTTP/1.1에서는 하나의 연결에서 여러 요청을 동시에 처리하기 어려웠고, HTTP/2는 한 TCP 연결 위에 여러 HTTP stream을 multiplexing해 이 문제를 크게 줄였습니다. 하지만 아래에는 여전히 TCP가 있습니다. 패킷 하나가 유실되면 TCP의 순서 보장 때문에 재전송을 기다리는 동안 같은 연결의 다른 stream까지 영향을 받을 수 있습니다.

HTTP/3은 전송 계층을 QUIC으로 바꿉니다. QUIC에서는 stream이 독립적으로 전달되므로 한 stream의 packet loss가 다른 stream의 진행까지 막는 TCP 수준의 HOL blocking을 줄일 수 있습니다. HTTP의 의미 자체를 바꾼 것이 아니라, 여러 요청을 전달하는 방식이 달라진 것입니다.

LLM의 text streaming에서는 text/event-stream 형식이 자주 사용됩니다. 서버가 완성된 응답을 한 번에 보내는 대신 생성된 데이터를 차례대로 흘려보내고, 클라이언트는 도착하는 부분부터 사용자에게 보여줄 수 있습니다.

여기서 SSE(Server-Sent Events) 형식과 브라우저의 EventSource API는 구분해야 합니다. text/event-stream은 서버가 보내는 데이터 형식이고, EventSource는 브라우저에서 그 스트림을 받아 처리하는 방법 중 하나입니다. 또한 SSE는 기본적으로 서버에서 클라이언트로 데이터를 보내는 단방향 통신이므로, 실시간 음성 대화처럼 양쪽이 지속적으로 데이터를 주고받아야 하는 경우에는 WebSocket이나 WebRTC 같은 방식이 더 적합할 수 있습니다.

HTTP 버전마다 요청과 stream이 어떻게 전달되는지부터 시작해 SSE가 그 위에서 어떻게 동작하는지 살펴봅니다. 마지막에는 실제 스트리밍 서비스에서 자주 문제가 되는 proxy buffering, timeout, 연결 종료와 cancellation까지 연결해서 봅니다.


1장. HTTP/1.1의 한계

커넥션 하나에 요청 하나씩 순서대로가 기본 규칙입니다. keep-alive 로 커넥션을 재사용해 핸드셰이크 비용은 줄였지만, 앞 요청이 끝나야 다음 요청을 보낼 수 있습니다.

앞의 무거운 요청 하나가 뒤를 다 막습니다. 이것이 HOL 블로킹입니다.

파이프라이닝이라는 우회로가 명세에 있었지만 응답 순서를 보장해야 해서 결국 같은 문제가 남았고, 중간 장비 호환성도 나빠 사실상 폐기됐습니다. 브라우저는 도메인당 커넥션을 6개쯤 열어 병렬성을 흉내 냈는데, 근본 해결이 아니라 우회였습니다.

chunked transfer encoding. HTTP/1.1이 준 중요한 기능 하나는 길이를 모른 채 응답을 보내는 것입니다.

Transfer-Encoding: chunked

1a
여기까지 첫 조각입니다
0                       ← 끝 표시

Content-Length를 미리 정할 수 없는 HTTP/1.1 응답을 생성하면서 보낼 때 사용할 수 있습니다. 다만 HTTP/2와 HTTP/3에서는 chunked transfer encoding을 쓰지 않고 각 프로토콜의 frame으로 body를 전달합니다. 따라서 chunked encoding과 streaming 자체를 같은 개념으로 보면 안 됩니다.


2장. HTTP/2, 커넥션 하나에 여러 스트림

핵심 변화가 셋입니다.

바이너리 프레이밍. 텍스트 대신 이진 프레임으로 쪼갭니다. 파싱이 빠르고 모호함이 없습니다.

멀티플렉싱. 커넥션 하나에서 여러 스트림이 동시에 오갑니다.

HTTP/1.1                    HTTP/2

커넥션1: A ──►              커넥션1: A B C 인터리빙
커넥션2: B ──►                       ▼ ▼ ▼
커넥션3: C ──►                      완료되는 대로

요청 A의 응답을 기다리며 B와 C를 보낼 수 있고, 순서와 상관없이 완료되는 대로 받습니다. 애플리케이션 레벨 HOL 블로킹이 사라졌습니다.

HPACK 헤더 압축. 매 요청 반복되는 헤더를 테이블로 관리해 크게 줄입니다.

남은 문제. TCP 위에 있다는 점입니다. 스트림은 논리적으로 독립인데 TCP는 바이트를 순서대로만 올려줍니다. 패킷 하나가 유실되면 재전송될 때까지 뒤에 온 다른 스트림의 데이터도 전달이 막힙니다.

이것이 TCP 레벨 HOL 블로킹입니다. 애플리케이션에서 아무리 스트림을 나눠도 전송 계층이 막으면 소용없습니다.


3장. HTTP/3, QUIC 위로

TCP의 순서 보장이 문제라면 TCP를 안 쓰면 됩니다. 그래서 QUIC을 UDP 위에 새로 만들었습니다. QUIC이 하는 일이 넷입니다.

신뢰성과 혼잡 제어를 사용자 공간에서 직접 구현합니다. TCP처럼 커널에 박혀 있지 않아 개선이 빠릅니다.

스트림마다 독립적인 순서를 보장합니다. 한 스트림의 손실이 다른 스트림을 막지 않습니다.

QUIC은 TLS 1.3과 통합된 handshake를 사용하고 connection migration과 0-RTT resumption 같은 기능을 제공합니다. 하지만 첫 연결인지 재연결인지, 이전 TLS state가 있는지에 따라 실제 handshake 비용이 달라지므로 HTTP/3은 항상 1 RTT, 재접속은 항상 0 RTT라고 고정해서 외우면 안 됩니다.

커넥션 ID 기반이라 IP가 바뀌어도 연결이 유지됩니다. 모바일에서 와이파이와 LTE를 오갈 때 끊기지 않습니다.

HTTP/1.1 HTTP/2 HTTP/3
전송 계층 TCP TCP UDP (QUIC)
멀티플렉싱 없음 있음 있음
앱 레벨 HOL 있음 해소 해소
TCP 레벨 HOL 있음 있음 해소
헤더 압축 없음 HPACK QPACK
연결 수립 1+ RTT 1+ RTT 1 RTT / 0-RTT

4장. 스트리밍 방식 세 가지

서버가 클라이언트에게 계속 데이터를 보내는 방식은 요구되는 방향성과 환경에 따라 갈립니다.

SSE. Content-Type: text/event-stream인 HTTP 응답에서 event를 연속으로 보내는 표준입니다. 각 event는 빈 줄로 구분하고 data, event, id, retry 필드를 사용할 수 있습니다.

Content-Type: text/event-stream

id: 1
data: 안녕

data: 하세요

브라우저의 EventSource API는 서버 → 클라이언트 단방향 스트림을 쉽게 열고, 연결이 끊기면 재연결하며 id가 있으면 Last-Event-ID를 보낼 수 있습니다.

여기서 중요한 점은 SSE event-stream 형식과 EventSource API가 동일하지 않다는 것입니다. EventSource는 일반적으로 GET 기반 API입니다. 반면 많은 LLM API는 prompt를 POST하고 응답 body만 text/event-stream 형식으로 보냅니다. 이 경우 브라우저에서는 fetch()의 response stream을 직접 읽을 수 있고, EventSource의 자동 재연결이 저절로 적용되지는 않습니다.

WebSocket. handshake 이후 full-duplex 연결을 유지해 client와 server가 언제든 message를 보낼 수 있습니다. tool event, 음성, realtime collaboration처럼 양방향 상호작용이 중요할 때 적합합니다. 현대 proxy와 load balancer도 WebSocket을 널리 지원하므로 WebSocket은 HTTP 인프라와 호환되지 않는다고 말하면 과도합니다. 다만 reconnect와 connection state 복구를 더 직접 관리해야 합니다.

gRPC. HTTP/2 위에서 Protobuf schema를 사용하고 unary, server streaming, client streaming, bidirectional streaming을 지원합니다. 내부 service-to-service 통신에서 강점이 크고, 일반 브라우저에서는 gRPC-Web 같은 별도 계층을 고려해야 합니다.


5장. LLM이 SSE를 쓰는 이유

LLM은 토큰을 하나씩 만듭니다. 다 만들고 보내면 사용자는 수 초간 빈 화면을 봅니다. 첫 토큰까지의 시간이 체감 속도를 좌우합니다.

async def stream(prompt):
    async for tok in llm.generate(prompt):
        yield f"data: {json.dumps({'text': tok})}\n\n"
    yield "data: [DONE]\n\n"

일반적인 text chat completion은 request를 한 번 보내고 server가 token/event를 계속 보내는 server → client 중심 흐름이라 SSE-style HTTP streaming과 잘 맞습니다. 기존 HTTP authentication, proxy, rate limit, logging을 그대로 쓰면서 event framing도 단순합니다.

다만 POST + fetch streaming 형태에서는 EventSource의 자동 재연결이 적용되지 않으므로 reconnect와 resume semantics를 애플리케이션이 직접 설계해야 할 수 있습니다. 또 모든 LLM API가 SSE인 것도 아닙니다. 실시간 음성이나 bidirectional event가 필요한 Realtime API는 WebSocket/WebRTC가 더 적합합니다.


6장. 스트리밍이 만드는 문제

프로토콜을 고르고 나서 실제로 부딪히는 것들입니다.

타임아웃. 중간 프록시나 로드밸런서가 유휴 커넥션을 끊습니다. 토큰 생성이 잠시 멈추면 연결이 죽을 수 있어서, 주기적으로 heartbeat 주석 라인을 보내 살려둡니다.

: ping

버퍼링. nginx 같은 프록시가 응답을 모았다 보내면 스트리밍이 의미를 잃습니다.

X-Accel-Buffering: no

이 헤더나 프록시 설정으로 꺼야 합니다.

중간 취소. 사용자가 정지를 누르면 서버의 생성도 멈춰야 합니다. 안 그러면 GPU가 버려질 토큰을 계속 만듭니다. 클라이언트 연결 종료를 감지해 취소 신호를 넣는 구조가 필요합니다.

커넥션 점유. thread-per-connection 같은 동기 구조에서 스트리밍 커넥션 하나가 worker 하나를 오래 점유하면 동시 처리량이 빠르게 제한될 수 있습니다. 이벤트 기반 서버가 장시간 연결에 잘 맞는 이유가 여기 있고, I/O 모델과 이어지는 지점입니다.

Backpressure. 모델이나 upstream이 데이터를 만드는 속도보다 client가 읽는 속도가 느리면 buffer가 계속 쌓일 수 있습니다. 생성 producer와 network writer 사이에서 큐 크기와 흐름 제어를 설계해야 합니다.

Resume semantics. 연결이 끊겼다고 같은 generation을 자동으로 이어받을 수 있는 것은 아닙니다. EventSource에 Last-Event-ID가 있어도 서버가 해당 ID 이후 event를 재생할 상태를 보관해야 실제 resume이 됩니다. POST 기반 LLM streaming은 generation ID와 replay 정책이 없다면 재요청 시 처음부터 다시 생성할 수 있습니다.


7장. 정리

HTTP/2는 HTTP stream multiplexing으로 애플리케이션 계층의 직렬화를 크게 줄였지만 TCP connection 수준의 HOL은 남았고, HTTP/3은 QUIC의 독립 stream으로 다른 stream까지 함께 막히는 문제를 줄였습니다.

SSE는 server → client event를 HTTP response body로 전달하기 좋은 형식이라 LLM text streaming에 널리 쓰입니다. 그러나 SSE 형식과 EventSource의 GET·자동 재연결 동작은 분리해서 이해해야 합니다.

실무에서는 프로토콜 이름보다 buffering, idle timeout, cancellation, backpressure, resume semantics가 더 자주 문제가 됩니다.


용어 정리

용어 한 줄 뜻
HOL 블로킹 앞선 것이 막히면 뒤가 다 막히는 현상
keep-alive 커넥션을 닫지 않고 재사용하는 것
chunked transfer 길이를 모른 채 조각으로 나눠 보내는 인코딩
멀티플렉싱 한 커넥션에서 여러 스트림을 동시에 주고받는 것
HPACK / QPACK HTTP/2와 HTTP/3의 헤더 압축 방식
QUIC UDP 위에 구현된 신뢰성 있는 전송 프로토콜
0-RTT 이전 연결 정보를 재사용해 왕복 없이 데이터를 보내는 것
SSE 서버가 HTTP 응답으로 이벤트를 밀어주는 단방향 방식
TTFT 첫 토큰까지 걸리는 시간
heartbeat 연결 유지를 위해 주기적으로 보내는 빈 메시지

참고자료

'programming' 카테고리의 다른 글

가상 메모리와 mmap  (0) 2023.09.13
프로세스와 스레드 그리고 GIL  (0) 2023.05.17
트랜잭션과 격리 수준  (0) 2023.05.09
IO 모델과 비동기  (0) 2023.03.09
[PY] line_profiler (@profile)  (0) 2023.03.04

댓글