왜 스트리밍 응답이 끊기거나 지연되는가

SSE(Server-Sent Events), LLM 토큰 스트리밍, 대용량 파일 다운로드 같은 응답은 백엔드가 데이터를 조금씩 흘려보낸다. 그런데 클라이언트에서는 응답이 한꺼번에 몰려서 오거나, 몇 초씩 멈췄다가 뭉텅이로 도착하는 현상이 발생한다. 원인은 대부분 애플리케이션이 아니라 그 앞에 놓인 리버스 프록시(Nginx, Envoy, ALB 등)의 응답 버퍼링이다.

프록시는 기본적으로 백엔드 응답을 자체 버퍼에 모았다가 클라이언트로 전달한다. 느린 클라이언트가 백엔드 커넥션을 오래 점유하는 것을 막고, 디스크로 스풀해 백엔드를 빨리 놓아주기 위한 정당한 최적화다. 문제는 이 동작이 "실시간으로 흘려보내야 하는" 응답과 정면으로 충돌한다는 점이다.

버퍼링이 켜졌을 때 실제로 벌어지는 일

Nginx 기준으로 proxy_buffering on(기본값)이면, 백엔드가 flush한 청크가 proxy_buffer_size와 proxy_buffers를 채우기 전까지 클라이언트로 나가지 않는다. 버퍼가 넘치면 proxy_max_temp_file_size 범위에서 임시 파일로 스풀된다. 결과적으로 백엔드가 1초마다 토큰을 보내도 클라이언트는 버퍼가 찰 때까지 아무것도 못 받는다.

추가로 프록시와 백엔드 사이에 gzip 압축이 끼면 압축 블록 단위로 다시 뭉치므로 체감 지연이 더 커진다. 스트리밍 엔드포인트에서는 압축을 끄는 편이 안전하다.

Nginx에서 버퍼링 끄기

전역으로 끄면 일반 API의 처리량이 떨어지므로, 스트리밍 경로에만 국소적으로 적용한다.

location /api/stream/ {
    proxy_pass http://backend;

    # 이 경로만 버퍼링 해제
    proxy_buffering off;
    proxy_cache off;
    gzip off;

    # SSE/롱커넥션용 타임아웃과 헤더
    proxy_http_version 1.1;
    proxy_set_header Connection "";
    proxy_read_timeout 3600s;
    chunked_transfer_encoding on;
}

경로를 나누기 어렵다면, 백엔드가 응답 헤더 X-Accel-Buffering: no를 내려 해당 응답에 한해 버퍼링을 끌 수 있다. 코드에서 조건부로 제어할 수 있어 실무에서 자주 쓴다.

백엔드에서 flush를 보장하기

프록시 설정만 바꿔도 애플리케이션이 버퍼를 안 비우면 소용없다. 프레임워크의 응답 버퍼와 Content-Length 자동 계산을 피하고, 청크마다 flush해야 한다.

from fastapi import FastAPI
from fastapi.responses import StreamingResponse
import asyncio

app = FastAPI()

async def token_stream():
    for i in range(10):
        yield f"data: chunk-{i}\n\n"   # SSE 포맷
        await asyncio.sleep(1)

@app.get("/api/stream/events")
async def events():
    return StreamingResponse(
        token_stream(),
        media_type="text/event-stream",
        headers={
            "X-Accel-Buffering": "no",   # Nginx 버퍼링 무력화
            "Cache-Control": "no-cache",
        },
    )

WSGI(동기) 환경이라면 제너레이터로 응답을 반환하고, 앞단 gunicorn/uwsgi의 버퍼 옵션(예: uwsgi의 --buffer-size, 응답 스풀)도 함께 점검해야 한다.

버퍼링 on/off 트레이드오프

항목버퍼링 ON (기본)버퍼링 OFF
첫 바이트 지연(TTFB)느림빠름
백엔드 커넥션 점유짧음(빨리 해제)느린 클라이언트만큼 유지
느린 클라이언트 영향프록시가 흡수백엔드로 전파
적합한 응답일반 JSON APISSE·토큰 스트리밍·대용량

즉 버퍼링 해제는 공짜가 아니다. 느린 모바일 클라이언트가 많으면 백엔드 워커가 오래 묶여 동시성이 떨어진다. 스트리밍 경로만 선택적으로 끄는 이유가 여기 있다.

클라우드 로드밸런서와 다층 프록시 주의점

실서비스는 프록시가 한 겹이 아니다. CDN → ALB/L7 LB → Nginx → 앱 구조라면 모든 계층에서 버퍼링과 타임아웃을 맞춰야 한다. 한 곳이라도 응답을 모으면 스트리밍이 깨진다. AWS ALB는 응답을 스트리밍하지만 idle timeout(기본 60초)이 있어 토큰 간격이 길면 커넥션이 끊긴다. CloudFront 같은 CDN은 캐싱·버퍼링 성격이 강하므로 스트리밍 경로는 캐시 우회 또는 origin 직결로 빼는 편이 안전하다.

HTTP/2·HTTP/3에서는 청크 인코딩 개념이 프레임 기반으로 바뀌므로, chunked_transfer_encoding 지시어보다 각 계층의 flush·window 동작을 확인해야 한다.

점검 체크리스트

  • 스트리밍 경로에서 proxy_buffering off 또는 X-Accel-Buffering: no가 실제로 적용되는지 응답 헤더로 확인
  • 해당 경로의 gzip/압축 비활성화
  • 앱 코드에서 청크마다 flush되고 Content-Length가 붙지 않는지 확인
  • LB·CDN의 idle timeout이 토큰 최대 간격보다 큰지 확인(필요 시 keep-alive 주석 이벤트 전송)
  • curl -N https://host/api/stream/events로 실시간 도착 여부를 눈으로 검증

정리하면, 스트리밍 문제는 "코드는 맞는데 왜 안 흐르지"로 시작해 대부분 중간 프록시의 버퍼링에서 끝난다. 경로 단위로 버퍼링을 끄고, 앱의 flush를 보장하고, 모든 프록시 계층의 타임아웃을 정렬하는 것이 핵심이다.