AI 스트리밍 디버깅: 청크(Chunks)와 타임아웃(Timeouts)에 관한 이야기
요약
AI 스트리밍 응답 시 발생하는 데이터 누락 및 연결 끊김 문제를 해결하는 과정을 다룹니다. 타임아웃 연장이나 재시도 로직 대신, 바이트 단위의 수동 버퍼링과 종료 신호 확인을 통해 안정적인 스트리밍을 구현하는 방법을 제시합니다.
핵심 포인트
- 단순 타임아웃 연장으로는 스트리밍 중단 문제를 해결할 수 없음
- 재시도 로직 적용 시 중복 청크 발생 위험 존재
- aiter_lines 대신 aiter_bytes를 사용하여 라인 버퍼 누락 방지
- [DONE]과 같은 명확한 스트림 종료 신호 확인 필요
저는 단순한 AI 채팅 인터페이스를 만들 것이라고 생각하며 3주를 보냈습니다. 다들 아시다시피 하는 방식이죠. 사용자가 질문을 입력하면, AI가 응답을 단어별로 스트리밍(Streaming)하여 돌려주는 방식입니다. API 키도 있었고, SDK도 있었으며, 자신감도 있었습니다. 하지만 이틀 뒤, 저는 완성되지 않은 문장들과 끊겨버린 연결 속에서 허우적거리고 있었습니다.
제가 저지른 실수와, 어떻게 마침내 스트리밍을 안정적으로 작동하게 만들었는지 그 과정을 설명해 드리겠습니다.
문제점: 끝나지 않는 청크(Chunks)
제 앱은 간단했습니다. Python FastAPI 백엔드가 AI API(당시 저는 interwestinfo의 서비스를 사용 중이었습니다)를 호출하고, Server-Sent Events (SSE)를 통해 React 프론트엔드로 응답을 스트리밍하는 구조였습니다. 첫 번째 버전은 짧은 질문에는 아주 잘 작동했습니다. 하지만 사용자가 여러 단락의 질문을 하거나 코드를 생성하려고 하면, 응답이 단어 중간에 끊겨버렸습니다. 프론트엔드에는 _"프랑스의 수도는 파"_라고 표시된 채로 멈춰버리곤 했습니다.
로그를 확인해 보았지만 에러는 없었습니다. 서버가 그냥 청크(Chunks) 전송을 중단한 것이었습니다. AI API 측에서는 완전한 응답을 반환했지만, 제 코드가 마지막 몇 바이트를 삼켜버린 것이었습니다.
처음 시도했던 것들 (그리고 실패한 것들)
더 긴 타임아웃 (Longer Timeouts)
첫 번째 본능은 이랬습니다. "API가 느린 거니까, 그냥 타임아웃을 늘리자." 저는 읽기 타임아웃(Read timeout)을 30초에서 120초로 늘렸습니다. 도움이 되지 않았습니다. 문제는 API가 너무 오래 걸리는 것이 아니라, 스트림이 끝나지 않았음에도 제 코드가 스트림이 끝났다고 판단하는 것이었습니다.
재시도 로직 (Retry Logic)
지수 백오프(Exponential backoff)를 적용한 재시도 루프(Retry loop)로 요청을 감싸보았습니다. 이제는 청크를 놓치는 대신, 중복된 청크가 들어왔습니다. 두 번째 요청이 첫 번째 청크를 다시 보냈기 때문에, 사용자들에게는 "파파리는 수도입니다"와 같은 문장이 보이게 되었습니다.
수동 버퍼링 (Manual Buffering)
모든 청크를 버퍼(Buffer)에 모았다가 일정 지연 시간 후에 한꺼번에 플러시(Flush)하는 방식을 시도했습니다. 이는 스트리밍을 배치(Batching) 방식으로 바꾸어 놓았습니다. 스트리밍의 핵심 목적(낮은 지연 시간)이 사라져 버린 것입니다.
실제로 작동했던 방법: 적절한 청크 조립 (Proper Chunk Assembly)
근본적인 원인은 미묘했습니다. 제가 사용 중인 AI API(그리고 다른 많은 API들)는 청크(Chunks)를 순수한 JSON 형태로 보내지 않습니다. 대신 스트리밍 형식으로 감싸진 JSON-LD(JSON Lines) 행들을 보냅니다. 제 파서(Parser)는 한 줄씩 읽고 있었지만, 특정 수의 행이 수신되면 스트림이 완료된 것으로 간데하고 연결을 즉시 닫아버리고 있었습니다. 해결책은 단순히 빈 줄을 확인하는 것이 아니라, 실제 스트림 종료 신호(Stream termination signal)를 확인하는 것이었습니다.
마침내 제대로 작동했던 코드를 보여드리겠습니다:
import asyncio
import httpx
from typing import AsyncGenerator
...
주요 변경 사항:
aiter_lines()대신aiter_bytes()를 사용하고 라인 버퍼(Line buffer)를 수동으로 관리하도록 변경했습니다. 이를 통해 HTTP 클라이언트가 라인을 미리 분할하여 불완전한 라인을 누락시키는 것을 방지할 수 있습니다.- 정확히 언제 멈춰야 할지 알기 위해
[DONE]센티넬(Sentinel, OpenAI 스타일의 API에 특화됨)을 찾습니다. - 연결을 필요하다고 생각되는 것보다 더 오래 유지합니다.
가장 어려운 교훈: 백프레셔 (Backpressure)
스트림이 안정적으로 작동하기 시작하자, 새로운 문제에 부딪혔습니다. 클라이언트가 속도를 따라오지 못하는 것이었습니다. 청크가 UI가 다시 렌더링(Re-render)되는 속도보다 빠르게 들어오면 React의 EventSource가 과부하 상태에 빠졌습니다. 제 서버는 초당 50개의 청크를 보내고 있었고, 프론트엔드는 초당 50번 React 상태 변수를 업데이트하고 있었습니다. 브라우저 탭이 얼어붙었습니다.
저의 해결책은 서버 측에서의 스로틀링(Throttling)이었습니다. 클라이언트의 버퍼가 커지면 청크 사이에 짧은 지연 시간을 추가했습니다. 이를 위해서는 클라이언트로부터의 확인(Acknowledgment)이라는 다른 접근 방식이 필요했지만, 그것은 다음 기회에 이야기하겠습니다.
트레이드오프 (Trade-offs)와 대안
이 방식은 대부분의 현대적인 AI API(OpenAI, Anthropic, 그리고 제가 테스트한 interwestinfo 엔드포인트)에서 작동합니다. 하지만 트레이드오프(Trade-offs)가 존재합니다:
- 메모리 (Memory): 청크 (Chunk)가 매우 클 경우 버퍼가 커질 수 있습니다. 저는 라인 크기를 10KB로 제한합니다.
- 지연 시간 (Latency):
\n을 기다리는 과정에서 미세한 지연이 발생합니다. 실시간 음성(Real-time speech)의 경우, 문자 단위 스트리밍 (Character-level streaming)이 필요합니다. - 복잡성 (Complexity): 수동 바이트 파싱 (Manual byte-parsing)은 취약합니다. 어떤 벤더는 공백 없이
"data:\n"를 사용하고, 다른 벤더는"event: data\ndata: {...}\n\n"를 사용합니다. 결국 벤더별 전용 파서 (Vendor-specific parsers)를 작성하게 됩니다.
단일 API만 사용한다면 해당 SDK가 이를 처리하는 경우가 많습니다. 문제는 여러 제공자 (Providers)를 지원하고 싶을 때입니다. 그럴 때는 통합된 스트리밍 추상화 (Unified streaming abstraction)가 필요합니다.
제가 다르게 했을 방법
- 스트리밍 사양 (Streaming spec)을 미리 읽으세요.
\n\n이 끝을 의미한다고 가정하지 마세요. 명시적인 종료 이벤트 (Termination event)를 찾으세요. - SSE 파싱을 위해 라이브러리를 사용하세요. 서버 측 생성의 경우 Python의
sse-starlette패키지가 이 작업의 대부분을 처리해 줍니다. 클라이언트 측의 경우EventSource가 유용하지만 한계가 있습니다. - 모든 것에 계측 (Instrument)을 적용하세요. 문제를 수정하기 전에 청크 수, 청크당 지연 시간, 총 바이트 수와 같은 메트릭 (Metrics)을 추가했습니다. 데이터는 추측보다 강력합니다.
마치며
AI 응답 스트리밍은 이미 해결된 문제처럼 들리지만, 진짜 문제는 청크 경계 (Chunk boundaries)에 있습니다. 제가 본 모든 스트리밍 프로토콜은 추가적인 줄바꿈, 예상치 못한 바이트, 또는 누락된 센티널 (Sentinels)과 같은 각기 다른 특이점들을 가지고 있습니다. 유일한 보편적 진리는 이것입니다: 스트림이 우아하게 종료될 것이라고 절대 믿지 마세요.
이제 궁금해지네요. 여러분이 경험한 가장 이상한 스트리밍 버그는 무엇인가요? 단어의 절반만 출력된 채로 끝나는 응답을 받은 적이 있나요?
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기