Moment Note

LLM 스트리밍 응답 서버 설계: SSE·백프레셔·타임아웃

AI 개발 최적화 ·

LLM 응답을 한 번에 받아서 보여주면 사용자는 몇 초씩 빈 화면을 바라봐야 합니다. 토큰이 생성되는 대로 흘려보내는 스트리밍은 체감 지연을 극적으로 줄여주지만, 서버 입장에서는 완전히 다른 난이도의 문제를 안깁니다. 장시간 열린 커넥션, 느린 클라이언트, 중간에 끊기는 연결, 그리고 이 모든 것이 동시에 수백 개 발생하는 상황을 다뤄야 합니다.

이 글에서는 SSE(Server-Sent Events)를 기반으로 한 LLM 스트리밍 서버를 설계하면서, 실전에서 반드시 마주치는 세 가지 문제 — 백프레셔, 타임아웃, 그리고 클라이언트 이탈 처리 — 를 어떻게 다루는지 코드와 함께 정리합니다.

왜 WebSocket이 아니라 SSE인가

LLM 토큰 스트리밍은 서버에서 클라이언트로의 단방향 흐름입니다. 양방향 통신이 필요 없다면 WebSocket은 과합니다. SSE는 그냥 HTTP 응답이라 프록시·로드밸런서·인증 미들웨어와 자연스럽게 어울리고, 브라우저가 자동 재연결까지 지원합니다.

SSE의 규약은 간단합니다. Content-Type: text/event-stream으로 응답하고, 각 이벤트를 data: ...\n\n 형식으로 흘려보냅니다.

from fastapi import FastAPI
from fastapi.responses import StreamingResponse
import json

app = FastAPI()

async def token_stream(prompt: str):
    async for chunk in llm_client.stream(prompt):
        payload = json.dumps({"token": chunk.text})
        yield f"data: {payload}\n\n"
    yield "data: [DONE]\n\n"

@app.post("/chat")
async def chat(prompt: str):
    return StreamingResponse(
        token_stream(prompt),
        media_type="text/event-stream",
        headers={
            "Cache-Control": "no-cache",
            "X-Accel-Buffering": "no",  # nginx 버퍼링 비활성화
        },
    )

X-Accel-Buffering: no를 빠뜨리면 nginx가 응답을 통째로 버퍼링해서 스트리밍이 아니라 한꺼번에 도착합니다. 실전에서 가장 흔히 밟는 지뢰입니다.

백프레셔: 느린 클라이언트가 서버를 잡아먹는다

LLM은 초당 수십 토큰을 뿜어내는데 클라이언트(특히 모바일)가 그걸 다 못 받으면, 서버 메모리에 미전송 토큰이 쌓입니다. 이것이 백프레셔 문제입니다. 커넥션 수백 개가 각자 버퍼를 키우면 서버가 OOM으로 죽습니다.

해법은 바운디드 큐입니다. 생산자(LLM)와 소비자(네트워크 전송) 사이에 크기 제한이 있는 큐를 두고, 큐가 차면 생산 속도를 늦춥니다.

import asyncio

async def buffered_stream(prompt: str):
    queue: asyncio.Queue = asyncio.Queue(maxsize=32)

    async def produce():
        async for chunk in llm_client.stream(prompt):
            # 큐가 가득 차면 여기서 대기 = 자연스러운 백프레셔
            await queue.put(chunk.text)
        await queue.put(None)  # 종료 신호

    producer = asyncio.create_task(produce())
    try:
        while True:
            token = await queue.get()
            if token is None:
                break
            yield f"data: {json.dumps({'token': token})}\n\n"
    finally:
        producer.cancel()

maxsize가 있는 큐이므로 소비가 느리면 queue.put이 블록되고, 그 압력이 LLM 스트림 소비까지 전파됩니다. 무한정 메모리를 먹는 사고를 구조적으로 막는 것이죠.

타임아웃을 계층별로 나눠라

타임아웃을 하나로 뭉뚱그리면 안 됩니다. LLM 스트리밍에는 최소 세 종류의 타임아웃이 필요합니다.

  • 첫 토큰 타임아웃(TTFT): 모델이 첫 토큰을 내놓기까지의 한계. 여기서 오래 걸리면 백엔드 장애 신호다.
  • 토큰 간 타임아웃: 스트림 중간에 토큰이 끊긴 채 멈추는 경우를 잡는다.
  • 전체 타임아웃: 아무리 정상이어도 응답 하나가 무한정 자원을 붙잡지 못하게 하는 상한.
async def stream_with_timeouts(prompt: str):
    stream = llm_client.stream(prompt).__aiter__()
    first_token = True
    while True:
        # 첫 토큰은 10초, 이후는 5초 안에 다음 토큰이 와야 함
        limit = 10.0 if first_token else 5.0
        try:
            chunk = await asyncio.wait_for(stream.__anext__(), timeout=limit)
        except asyncio.TimeoutError:
            yield f"data: {json.dumps({'error': 'stream_timeout'})}\n\n"
            return
        except StopAsyncIteration:
            break
        first_token = False
        yield f"data: {json.dumps({'token': chunk.text})}\n\n"

전체 타임아웃은 바깥에서 asyncio.timeout(120)으로 감싸면 됩니다. 계층을 나누면 “느린 응답”과 “멈춘 응답”을 구분해 각기 다르게 대응할 수 있습니다.

클라이언트 이탈을 감지하고 즉시 중단하라

사용자가 탭을 닫거나 “중지”를 누르면 커넥션이 끊깁니다. 이때 서버가 눈치채지 못하면 이미 아무도 안 보는 응답을 계속 생성하며 GPU와 토큰 비용을 태웁니다. LLM 호출은 비싸므로 이 낭비는 결코 사소하지 않습니다.

from fastapi import Request

@app.post("/chat")
async def chat(request: Request, prompt: str):
    async def guarded_stream():
        async for chunk in llm_client.stream(prompt):
            if await request.is_disconnected():
                # 클라이언트가 떠났으면 LLM 호출도 중단
                await llm_client.abort()
                break
            yield f"data: {json.dumps({'token': chunk.text})}\n\n"
    return StreamingResponse(guarded_stream(), media_type="text/event-stream")

request.is_disconnected()를 주기적으로 확인해 이탈을 감지하고, 백엔드 LLM 호출까지 취소 신호를 전파하는 것이 핵심입니다. 취소가 상류로 전파되지 않으면 절반의 최적화에 그칩니다.

재연결과 이벤트 ID

모바일 네트워크는 자주 끊깁니다. SSE는 id: 필드와 Last-Event-ID 헤더로 재연결 시 이어받기를 지원합니다. 완전한 텍스트를 이어붙이기 어렵다면, 최소한 어디까지 받았는지를 클라이언트가 알 수 있게 인덱스를 실어 보내세요.

async def resumable_stream(prompt: str, last_id: int = 0):
    idx = 0
    async for chunk in llm_client.stream(prompt):
        idx += 1
        if idx <= last_id:
            continue  # 이미 받은 토큰은 건너뜀
        yield f"id: {idx}\ndata: {json.dumps({'token': chunk.text})}\n\n"

완벽한 재개는 어렵지만, 인덱스만 있어도 클라이언트가 중복을 걸러내고 매끄럽게 이어붙일 수 있습니다.

부하 앞에서의 현실적 한계

스트리밍 커넥션은 오래 열려 있는 특성 때문에 동시성 관리가 까다롭습니다. 각 커넥션이 이벤트 루프 태스크 하나와 큐 하나를 붙잡으므로, 동시 스트림 수에 상한을 두는 것이 안전합니다. 세마포어로 상한을 걸고, 초과분은 대기시키거나 429로 거절하세요.

stream_limit = asyncio.Semaphore(200)

async def limited_chat(prompt: str):
    if stream_limit.locked():
        # 여유가 없으면 빠르게 거절 - 물고 늘어지지 않는다
        raise HTTPException(status_code=429, detail="server busy")
    async with stream_limit:
        async for event in buffered_stream(prompt):
            yield event

마무리

LLM 스트리밍 서버의 어려움은 모델 호출 자체가 아니라 오래 열린 커넥션을 안전하게 관리하는 일에 있습니다. 백프레셔로 메모리를 지키고, 계층적 타임아웃으로 멈춘 응답을 걸러내며, 클라이언트 이탈을 감지해 비싼 호출을 즉시 끊고, 동시성에 상한을 두는 것. 이 네 가지를 갖추면 트래픽이 몰려도 서버는 우아하게 버팁니다. 화려한 기능보다 이 방어적 설계가 프로덕션 LLM 서비스의 진짜 경쟁력입니다.