왜 백프레셔가 문제인가

LLM 토큰을 SSE(Server-Sent Events)로 흘려보낼 때, 서버는 모델이 생성하는 속도로 토큰을 받는다. 문제는 이 속도와 하위 소비 속도가 일치하지 않는다는 점이다. 느린 모바일 네트워크, 백그라운드로 넘어간 브라우저 탭, 중간에 낀 프록시 버퍼가 소비를 지연시키면 서버 쪽에는 아직 전송되지 못한 토큰이 쌓인다. 이 쌓임을 흡수하지 못하면 메모리가 부풀거나, 최악의 경우 이벤트 루프가 막힌다.

특히 위험한 건 상류(LLM API 스트림)를 계속 읽어들이면서 하류(클라이언트 write)가 막히는 구조다. 상류를 멈추지 않으면 큐가 무한히 자란다. 백프레셔란 결국 "하류가 느리면 상류 읽기도 늦춰라"라는 신호 전달이다.

어디서 버퍼가 쌓이는가

병목은 한 곳이 아니다. 각 지점의 기본 버퍼 크기와 동작을 알아야 대응할 수 있다.

지점증상대응
애플리케이션 큐토큰 리스트/asyncio.Queue 무한 증가maxsize 설정, 상류 pause
OS 소켓 송신버퍼write는 성공하나 전송 안 됨drain() 대기
Nginx proxy_buffer토큰이 뭉쳐서 도착(청크 지연)proxy_buffering off

핵심: drain으로 흐름 맞추기

가장 근본적인 처리는 하류 write가 실제로 소켓 밖으로 나갈 때까지 기다린 뒤에야 다음 토큰을 상류에서 읽는 것이다. Python asyncio의 StreamWriter.drain()이나 ASGI의 send await가 이 역할을 한다. await가 걸리는 동안 상류 async iterator도 자연히 멈추므로 별도 코드 없이 백프레셔가 전파된다.

import asyncio
from anthropic import AsyncAnthropic

client = AsyncAnthropic()

async def sse_stream(send, prompt: str):
    async with client.messages.stream(
        model="claude-sonnet-4-6",
        max_tokens=1024,
        messages=[{"role": "user", "content": prompt}],
    ) as stream:
        async for text in stream.text_stream:
            payload = f"data: {text}\n\n".encode()
            # send가 await로 막히면 상류(text_stream)도 자동으로 멈춘다
            await send({
                "type": "http.response.body",
                "body": payload,
                "more_body": True,
            })
        await send({"type": "http.response.body", "body": b"", "more_body": False})

큐를 쓸 때는 maxsize를 걸어라

상류 읽기와 하류 쓰기를 분리하는 프로듀서-컨슈머 구조라면, 무제한 큐가 곧 메모리 폭탄이다. asyncio.Queue(maxsize=N)로 상한을 두면 큐가 가득 찼을 때 put이 대기하고, 그 대기가 상류 읽기를 멈춘다. 이것이 큐 기반 백프레셔의 핵심이다.

queue: asyncio.Queue[str | None] = asyncio.Queue(maxsize=32)

async def producer(stream):
    async for text in stream.text_stream:
        await queue.put(text)   # 큐가 꽉 차면 여기서 멈춤 = 상류 정지
    await queue.put(None)       # 종료 신호

async def consumer(send):
    while (text := await queue.get()) is not None:
        await send({"type": "http.response.body",
                    "body": f"data: {text}\n\n".encode(),
                    "more_body": True})

프록시 버퍼링 끄기

애플리케이션을 완벽하게 짜도 Nginx가 응답을 모아 뒀다가 한 번에 보내면 스트리밍이 무의미해진다. SSE 경로에는 버퍼링을 명시적으로 꺼야 한다.

location /api/stream {
    proxy_pass http://app;
    proxy_http_version 1.1;
    proxy_buffering off;
    proxy_cache off;
    proxy_read_timeout 300s;
    add_header X-Accel-Buffering no;
}

백엔드 응답 헤더에도 Cache-Control: no-cacheX-Accel-Buffering: no를 넣어 프록시·CDN이 중간에 쌓지 않도록 한다.

연결 끊김 감지와 정리

클라이언트가 탭을 닫으면 서버는 그 사실을 write 실패로만 알게 된다. 이때 상류 LLM 스트림을 즉시 닫지 않으면, 아무도 받지 않는 토큰을 계속 생성하며 비용과 리소스를 태운다. ASGI에서는 http.disconnect 이벤트를, asyncio에서는 write 예외를 잡아 상류 스트림을 close() 또는 태스크 cancel()로 정리해야 한다. 스트리밍 핸들러는 반드시 try/finally로 상류 자원 해제를 보장하라.

주의점

드레인 대기가 길어지면 타임아웃과 충돌할 수 있으니 read/idle 타임아웃을 스트림 길이에 맞춰 넉넉히 둔다. 큐 maxsize는 지연(작으면 상류 자주 멈춤)과 메모리(크면 안전마진 감소)의 트레이드오프이므로 실제 토큰 크기와 동시 연결 수로 산정하라. 또한 하트비트(주석 라인 : ping)를 주기적으로 보내 유휴 연결이 프록시에 의해 끊기지 않도록 하되, 이 하트비트 역시 write 경로이므로 백프레셔 상황에서는 억지로 밀어넣지 말고 drain 이후에만 보내는 것이 안전하다.