본문 바로가기

AI

[AI Agent] WSGI 에서 Django SSE 스트리밍 버퍼링 문제

반응형

AI agent 서비스를 개발하며 겪은 문제들을 예제 프로젝트로 재구성해 정리해보려 한다. 예제 코드는 parcel-bot repo에 있고, docker compose로 전부 재현할 수 있다. LLM은 mock으로 대체했기 때문에 API key 없이 실행 가능하다.

 

이번 글에서는 SSE 스트리밍 응답이 WSGI 환경에서 버퍼링되는 문제를 재현하고 원인을 확인해보겠다.

문제 상황

LLM 챗봇의 답변을 ChatGPT처럼 토큰 단위로 출력하기 위해 SSE(Server-Sent Events) 응답을 구현했다. SSE는 HTTP 연결을 유지하면서 서버가 이벤트를 계속 전달하는 방식이다.

 

그런데 브라우저에서 확인해보면 토큰이 하나씩 출력되는 것이 아니라, 한참 동안 아무것도 출력되지 않다가 답변 전체가 한 번에 출력된다.

스트리밍 문제를 검색하면 nginx 버퍼링, 청크 인코딩, X-Accel-Buffering 같은 답이 많이 나온다. 하지만 프록시 없이 로컬에서 실행했는데도 같은 증상이 발생한다면 원인은 다른 곳에 있을 수 있다.

 

이 경우 확인해야 할 것이 서버가 응답을 처리하는 방식과 iterator의 타입, 그리고 WSGI와 ASGI의 차이다.

실험

배송 접수 챗봇이라는 가상의 서비스로 문제를 재현한다.

LLM은 실제로 호출하지 않고, 고정된 답변을 토큰 단위로 흘려보내는 mock으로 대체했다.

 

# parcel_bot/views.py
import asyncio
import json

from django.http import HttpRequest, StreamingHttpResponse


async def _mock_llm(message: str):
    reply = (
        f"'{message}' 문의 확인했습니다. 배송 접수를 도와드릴게요. "
        "보내시는 분 성함과 받으시는 분 주소를 알려주세요."
    )

    for token in reply.split(" "):
        await asyncio.sleep(0.3)
        yield token + " "


async def chat(request: HttpRequest) -> StreamingHttpResponse:
    message = json.loads(request.body or "{}").get("message", "")

    async def stream():
        async for token in _mock_llm(message):
            yield f"data: {json.dumps({'token': token}, ensure_ascii=False)}\n\n"

        yield "data: [DONE]\n\n"

    return StreamingHttpResponse(
        stream(),
        content_type="text/event-stream",
    )

 

chat은 async view이고, stream()은 async generator다. 그리고 이 async generator를 StreamingHttpResponse에 전달한다. 이 구조 자체는 Django가 ASGI 환경에서 지원하는 정상적인 streaming 방식이다.

 

정말 문제가 없는지, 동일한 코드를 WSGI와 ASGI 환경에서 각각 실행해보겠다.

실험환경

실험에서는 동일한 Django application을 서버만 바꿔 실행한다.

  • wsgi: Gunicorn을 이용해 Django의 WSGI application 실행
  • asgi: Uvicorn을 이용해 Django의 ASGI application 실행
# compose.yaml
services:
  wsgi:
    build: .
    command: gunicorn parcel_bot.wsgi:application --bind 0.0.0.0:8000
    ports: ["8000:8000"]

  asgi:
    build: .
    command: uvicorn parcel_bot.asgi:application --host 0.0.0.0 --port 8000
    ports: ["8001:8000"]

 

이렇게 하면 Django application 코드는 동일하고, 요청을 처리하는 인터페이스만 WSGI와 ASGI로 달라진다.

curl로 각 응답의 도착 시각을 출력해서 비교해보겠다. -N은 curl 자체의 출력 버퍼링을 끄는 옵션이다.

 

curl -sN -X POST http://127.0.0.1:8000/chat/ -d '{"message":"책 보내려고요"}' \
  | while IFS= read -r line; do [ -n "$line" ] && echo "$(date +%S.%3N) $line"; done

WSGI의 결과

먼저 WSGI 서버의 결과이다.

asyncio.sleep(0.3)이 반복되는 동안 아무 출력이 없다가, 마지막에 응답 내용이 한꺼번에 전달된다.

 

51.188 data: {"token": "'책 "}
51.190 data: {"token": "보내려고요' "}
51.191 data: {"token": "문의 "}
51.192 data: {"token": "확인했습니다. "}
...
51.201 data: [DONE]

 

토큰 사이에 약 300ms의 간격을 두도록 구현했지만 클라이언트에서는 그 간격을 관찰할 수 없다.

ASGI의 결과

다음은 ASGI 서버의 결과이다.

05.235 data: {"token": "'책 "}
05.537 data: {"token": "보내려고요' "}
05.837 data: {"token": "문의 "}
06.139 data: {"token": "확인했습니다. "}
...
09.153 data: [DONE]

 

첫 번째 토큰 이후 약 300ms 간격으로 다음 토큰이 도착한다.

코드는 한 줄도 변경하지 않았고 서버만 바꿨을 뿐인데 동작이 달라졌다.

원인

원인은 네트워크나 프록시가 아니라 Django가 응답 본문을 처리하는 방식과 WSGI/ASGI의 iterator 모델 차이에 있다.

Django에서는 StreamingHttpResponse에 응답 본문을 전달할 때 iterator의 종류가 중요하다.

  • WSGI 환경 → 동기 iterator
  • ASGI 환경 → 비동기 iterator

우리 예제의 stream()은 async generator이므로 async iterator다.

 

stream()
  │
  └── async iterator
          │
          ├── await
          ├── yield token
          ├── await
          ├── yield token
          └── ...

 

그런데 WSGI 인터페이스는 응답 본문을 동기 iterable로 처리한다.

여기서 중요한 점이 있다. WSGI가 streaming 자체를 지원하지 않는 것은 아니다.

예를 들어 다음과 같은 동기 generator는 WSGI에서도 streaming할 수 있다.

 

def stream():
    for token in tokens:
        time.sleep(0.3)
        yield token

 

문제는 async iterator를 WSGI 환경에서 StreamingHttpResponse에 전달하는 경우다.

Django는 이 상황에서 warning을 출력하고 async iterator를 동기적으로 사용할 수 있도록 변환하기 위해 전체 iterator를 소비한다.

개념적으로 보면 다음과 같다.

 

async iterator
      │
      │ async for
      ▼
[chunk1, chunk2, chunk3, ...]
      │
      ▼
sync iterator
      │
      ▼
WSGI server

 

따라서 다음 코드에서:

 

async def stream():
    for ...:
        await ...
        yield ...

 

각 yield가 발생할 때마다 클라이언트로 전달되는 것이 아니라, Django가 async iterator를 끝까지 소비한 뒤 WSGI가 사용할 수 있는 형태로 넘기게 된다. 이 과정에서 asyncio.sleep(0.3)도 각 토큰마다 그대로 실행된다.

 

예를 들어 15개의 토큰이 있다면:

 

0.3s × 15 = 약 4.5초

 

동안 async iterator를 소비한 후에야 WSGI 쪽에서 응답을 전달할 수 있다.

 

그래서 클라이언트에서는:

 

4.5초 동안 아무것도 없음
        ↓
전체 응답 도착

 

처럼 보인다.

 

Django에서도 이 상황을 warning으로 알려준다.

 

Warning: StreamingHttpResponse must consume asynchronous iterators
in order to serve them synchronously. Use a synchronous iterator instead.

 

즉, SSE 포맷 자체가 잘못된 것이 아니다. 청크 인코딩이나 SSE 이벤트 형식도 정상일 수 있다.

문제는 애플리케이션에서 생성한 async iterator가 WSGI의 동기적인 응답 처리 모델과 맞지 않는다는 것이다.

ASGI에서는 왜 정상적으로 동작하는가

ASGI(Asynchronous Server Gateway Interface)는 비동기 애플리케이션과 응답 처리를 전제로 한다.

따라서 위의 async generator를 그대로 사용할 수 있다.

 

async generator
      │
      │ async for
      ▼
ASGI application
      │
      │ response body message
      ▼
Uvicorn
      │
      ▼
HTTP connection

 

stream()에서 다음 chunk가 생성되기 위해 await가 필요하면 event loop가 해당 작업을 기다리고, chunk가 생성되면 ASGI response body로 전달할 수 있다.

 

따라서 코드에서 의도한:

 

await
 ↓
yield token
 ↓
await
 ↓
yield token
 ↓
...

 

이라는 흐름이 그대로 유지된다.

 

결과적으로 클라이언트에서도 약 300ms 간격의 토큰 스트리밍을 관찰할 수 있다.

Async View / Async Streaming

여기서 혼동하기 쉬운 부분이 하나 있다.

우리 view는:

 

async def chat(request):
    ...

 

이므로 async view다. 그렇다고 WSGI에서 async view를 실행할 수 없는 것은 아니다.

Django는 WSGI 환경에서도 async view를 별도의 event loop를 사용해 실행할 수 있다.

문제는 view의 실행 방식과 response iterator의 실행 방식은 별개의 문제라는 것이다.

 

구분하면 다음과 같다.

 

① View 실행

async def chat(...)
        │
        ▼
async view
        │
        ▼
event loop


② Response body 처리

StreamingHttpResponse(stream())
        │
        ▼
iterator 타입 확인
        │
        ├── sync iterator
        │
        └── async iterator

 

즉:

async view를 실행할 수 있다는 것과 async iterator를 WSGI에서 실시간으로 streaming할 수 있다는 것은 같은 문제가 아니다.

 

이번 문제는 두 번째에 해당한다.

LLM SDK

지금까지의 실험은 mock으로 진행했다. 그렇다면 실제 LLM SDK를 연동해도 같은 문제가 발생할까?

조건은 간단하다. LLM SDK가 반환하는 스트림이 async iterator이고, 이를 StreamingHttpResponse에 직접 연결한다면 같은 문제가 발생한다.

 

대표적인 Python LLM SDK의 async streaming API를 확인해보자.

Anthropic

Anthropic Python SDK에서는 async client의 streaming API를 다음과 같이 사용할 수 있다.

 

from anthropic import AsyncAnthropic

client = AsyncAnthropic()


async def _real_llm(message: str):
    async with client.messages.stream(
        model="...",
        max_tokens=1024,
        messages=[{"role": "user", "content": message}],
    ) as stream:
        async for text in stream.text_stream:
            yield text

 

stream.text_stream을 async for로 순회한다.

즉 애플리케이션에서 소비하는 형태는 다음과 같다.

 

async iterator
      │
      ├── await
      ├── yield text
      ├── await
      ├── yield text
      └── ...

 

Anthropic SDK의 내부 구현을 확인해보면 text_stream은 이벤트 스트림을 비동기적으로 순회하면서 텍스트 delta를 yield하는 구조다.

SDK 내부 구현은 버전에 따라 달라질 수 있으므로, 특정 내부 파일이나 메서드 이름보다는 public API가 async iterator를 제공한다는 점을 보는 것이 중요하다.

OpenAI

OpenAI Python SDK의 async streaming도 비슷한 형태다.

 

from openai import AsyncOpenAI

client = AsyncOpenAI()


async def _real_llm(message: str):
    stream = await client.chat.completions.create(
        model="...",
        messages=[{"role": "user", "content": message}],
        stream=True,
    )

    async for chunk in stream:
        if chunk.choices[0].delta.content:
            yield chunk.choices[0].delta.content

Responses API 역시 streaming을 활성화하면 이벤트 스트림을 async iterator로 순회할 수 있다.

stream = await client.responses.create(
    model="...",
    input=message,
    stream=True,
)

async for event in stream:
    ...

 

따라서 Django에서 이 스트림을 그대로 async generator로 감싼다면 이번 예제의 _mock_llm과 동일한 iterator 구조를 갖게 된다.

Google GenAI

Google GenAI SDK 역시 async API에서는 async iterator 형태로 streaming response를 소비한다.

 

from google import genai

client = genai.Client()


async def _real_llm(message: str):
    async for chunk in await client.aio.models.generate_content_stream(
        model="gemini-2.5-flash",
        contents=message,
    ):
        yield chunk.text

 

여기서도 중요한 것은 SDK 내부 구현의 세부사항이 아니라 호출자가 async iterator를 async for로 소비한다는 점이다.

예제 Mock vs LLM

mock은 다음과 같이 동작한다.

 

async def _mock_llm(message):
    for token in tokens:
        await asyncio.sleep(0.3)
        yield token

 

실제 LLM SDK에서는 대략 다음과 같은 구조가 된다.

 

LLM Server
    │
    │ HTTP streaming
    ▼
HTTP client
    │
    │ await
    ▼
SDK async iterator
    │
    │ yield
    ▼
Django async generator
    │
    │ yield
    ▼
StreamingHttpResponse

 

mock에서는 네트워크에서 다음 데이터가 도착하는 대신:

 

await asyncio.sleep(0.3)

 

으로 그 시간을 흉내 낸다.

 

즉 이번 실험에서 중요한 것은 토큰이 실제 LLM에서 생성되었는지 여부가 아니라, 응답을 생성하는 iterator가 async iterator라는 점이다. 따라서 async iterator를 StreamingHttpResponse에 직접 연결한 구조라면, WSGI에서 발생하는 문제를 API key 없이도 재현할 수 있다.

 

이 글의 실험을 mock으로 진행한 이유이기도 하다. 토큰 간격을 300ms로 균일하게 만들 수 있기 때문에 어느 지점에서 streaming이 끊기는지 명확하게 관찰할 수 있고, API key 없이 누구나 동일한 환경에서 재현할 수 있다.

왜 발견하기 어려운가

python manage.py runserver가 편하기 때문이다.

개발 중에 계속 개발 서버로 실행하면, streaming이 구현은 되어 있지만 뭉쳐서 도착하는 상태를 정상이라고 생각하기 쉽다.

 

게다가 응답 자체는:

 

HTTP 200
정상적인 SSE 데이터
정상적인 응답 내용

 

이므로 애플리케이션 오류처럼 보이지 않는다. 결국 프론트엔드의 EventSource 코드나 SSE 파싱 로직을 먼저 의심하게 된다.

 

하지만 이번 문제처럼 응답은 정상인데 streaming만 정상적으로 동작하지 않는 경우에는 애플리케이션 → 서버 → 프록시 → 클라이언트의 각 계층에서 buffering이 발생하는 지점을 확인해야 한다. 그리고 Django는 이 문제를 warning으로 알려주지만 요청 자체는 정상적으로 처리한다.

 

AI agent 서비스에서는 이런 패턴이 자주 나타난다. 실패가 에러로 나타나는 것이 아니라, 정상적인 응답의 형태로 나타나는 문제다.

해결

해결 방법은 간단하다. async iterator를 streaming response에 사용할 것이라면 ASGI 환경에서 실행한다.

 

개발 환경에서는 다음과 같이 실행할 수 있다.

 

uv run uvicorn parcel_bot.asgi:application

 

프로덕션에서도 Django의 ASGI application을 ASGI 서버로 실행하는 구성을 사용할 수 있다.

 

예를 들어 Uvicorn을 직접 사용하는 경우:

 

uvicorn parcel_bot.asgi:application --host 0.0.0.0 --port 8000

 

Gunicorn을 사용하는 경우에는 사용하는 Gunicorn/ASGI worker 구성에 맞는 ASGI worker를 선택해야 한다.

 

중요한 것은 특정 서버 조합보다:

 

async iterator
      ↓
ASGI application
      ↓
ASGI server

 

라는 실행 경로를 유지하는 것이다.

ASGI 변경 주의점

한 가지 주의할 점은 ASGI로 변경하는 것으로 모든 streaming 문제가 해결되는 것은 아니라는 것이다.

 

실제 서비스에서는 애플리케이션 앞단에 nginx나 cloud load balancer 등의 reverse proxy가 존재할 수 있다. 이 경우에는 애플리케이션까지는 정상적으로 chunk가 전달되고 있어도 proxy의 response buffering 때문에 클라이언트에서는 한꺼번에 도착할 수 있다.

 

구조를 단순화하면:

 

LLM
 ↓
Django / ASGI
 ↓
Reverse Proxy
 ↓
Browser

 

각 구간마다 streaming이 유지되는지 확인해야 한다.

 

따라서:

로컬에서는 정상인데 스테이징이나 운영 환경에서만 streaming이 뭉친다.

 

라면 애플리케이션 서버뿐만 아니라 nginx 등의 proxy buffering도 확인해야 한다.

이 글에서 사용한 timestamp 기반 curl 명령을 이용하면 어느 환경에서 buffering이 발생하는지 비교하기 쉽다.

예제 프로젝트

예제 프로젝트는 GitHub에서 확인할 수 있다.

 

git clone https://github.com/rmk1075/parcel-bot
cd parcel-bot

docker compose up -d --build

 

실행 후:

 

:8000 → WSGI
:8001 → ASGI

 

각 서버에 동일한 요청을 보내고 응답 도착 시간을 비교하면 된다.

 

curl -sN -X POST http://127.0.0.1:8000/chat/ \
  -d '{"message":"책 보내려고요"}' \
  | while IFS= read -r line; do
      [ -n "$line" ] && echo "$(date +%S.%3N) $line"
    done

 

ASGI 서버에서는 포트만 8001로 변경하면 된다.

 

curl -sN -X POST http://127.0.0.1:8001/chat/ \
  -d '{"message":"책 보내려고요"}' \
  | while IFS= read -r line; do
      [ -n "$line" ] && echo "$(date +%S.%3N) $line"
    done

 

정리

이번 문제의 핵심은 SSE 자체가 아니다. 다음 세 가지를 구분해야 한다.

 

SSE
  = HTTP에서 서버가 이벤트를 계속 전달하기 위한 형식

StreamingHttpResponse
  = Django에서 streaming response를 표현하는 API

WSGI / ASGI
  = 애플리케이션과 서버 사이의 실행 인터페이스

 

그리고 iterator의 종류까지 함께 보면 다음과 같다.

 

  WSGI ASGI
Sync iterator 정상적인 streaming 가능 변환 과정에 주의
Async iterator 전체 소비가 발생할 수 있음 정상적인 async streaming
SSE 형식 사용 가능 사용 가능

 

따라서 LLM streaming을 Django에 연결할 때는 단순히:

"SSE를 구현했으니 streaming이 되겠지."

 

라고 생각하면 안 된다.

 

LLM SDK가 어떤 형태의 iterator를 반환하는지, Django가 어떤 환경에서 실행되는지, 그리고 중간 proxy에서 buffering이 발생하지 않는지를 함께 확인해야 한다.

 

특히 async LLM streaming을 StreamingHttpResponse에 연결하는 구조라면:

 

LLM async stream
      ↓
async generator
      ↓
StreamingHttpResponse
      ↓
ASGI

 

라는 경로가 자연스럽다.

반응형