LLM 스트리밍이 불안정한 네트워크에서 깨지는 5가지 실수
요약
LLM 스트리밍 기능을 불안정한 네트워크 환경에서도 안정적으로 구현하기 위한 5가지 실수를 다룹니다. 특히 재접속 시 발생하는 데이터 중복, 연결 끊김 처리 등을 개선하는 방법을 제시하며, SSE(Server-Sent Events)의 올바른 사용법과 아키텍처 패턴을 강조합니다.
핵심 포인트
- 스트리밍 시작은 POST 요청으로 하고, 구독은 GET 요청으로 분리해야 합니다.
- SSE 이벤트에 순서 ID(`id:`)를 포함하여 재연결 시 데이터 누락 없이 처리하세요.
- 오류 메시지는 `error` 대신 `failed`와 같은 중립적인 이름으로 명명하는 것이 좋습니다.
- 유휴 연결 방지를 위해 주기적으로 데이터를 전송하거나 핑(ping) 메커니즘을 구현해야 합니다.
사내 Wi-Fi에서는 채팅 UI가 정상적으로 작동합니다. 그러다가 기차를 타고 온 사람이 참여하면, SSE(Server-Sent Events) 연결이 문장 중간에 끊어지고, 답변은 처음부터 다시 시작되거나 모델이 아무도 듣지 않는데 토큰을 계속 소모하게 됩니다. 같은 기능인데 네트워크만 다릅니다.
저는 '데모에서는 작동하는' 스트림 기능을 재접속에도 살아남도록 만드는 데 많은 시간을 썼습니다. 전체 작동 프로젝트(ASP.NET Core 10, TypedResults.ServerSentEvents, 테스트)는 Tech Skill Builder에서 확인할 수 있습니다. 이 글은 간결한 체크리스트입니다: 제가 계속 보았던 다섯 가지 실수와 그것들을 고치는 방법의 형태를 다룹니다.
실수 1: 생성을 HTTP 요청에 의존하는 것
5분짜리 버전에서는 GetStreamingResponseAsync를 HttpContext.RequestAborted에 연결합니다. 탭을 닫으면 모델 호출이 중단됩니다. 이는 절약하는 것처럼 느껴지지만, 불안정한 프록시가 똑같이 작동한다는 것을 깨달았을 때까지는 그렇지 않습니다: 매번 깜빡일 때마다 이미 비용을 지불한 작업이 취소되고, 재연결할 때마다 새로운 프롬프트가 시작됩니다.
POST /api/chat/streams에서 레지스트리(registry)에 생성을 시작하고streamId,eventsUrl,cancelUrl과 함께202를 반환합니다.GET /api/chat/streams/{id}/events가 SSE 구독입니다.
골격 코드:
// POST /api/chat/streams
var stream = registry.TryStart(messages, owner);
return TypedResults.Accepted(
...
EventSource는 GET만 사용합니다. 이것 하나만으로도 분할 방식(split)을 취하게 만들며, 재연결 시 본문(body)을 POST 할 수 없게 만듭니다.
실수 2: 순서 ID가 없어 재연결이 '처음부터 다시 시작'을 의미하는 것
각 이벤트에 SSE id:가 없으면 브라우저는 Last-Event-ID에 넣을 것이 없습니다. 클라이언트는 토큰을 중복하거나 서버에 재생성을 요청하게 됩니다.
public long Append(ChatStreamEvent e)
{
// 다음 순서를 할당하고, 이벤트를 저장하며, 대기자를 깨웁니다...
}
완료된 스트림에서 따라잡아야 하는 클라이언트가 있습니까? 204 No Content를 반환하세요. 이것이 EventSource가 영원히 재연결되는 것을 막는 SSE 사양(spec) 방식입니다.
실수 3: 서버 이벤트를 'error'로 명명하는 것
브라우저는 연결 문제에 대해 EventSource 자체의 error 이벤트를 발생시킵니다. 만약 페이로드(payload)에서도 event: error를 사용한다면, 둘 다 같은 개념적 범주(그리고 종종 같은 핸들러)로 처리됩니다. 개발자는 "모델에 문제가 있는가 아니면 Wi-Fi에 문제가 있는가?"라는 질문을 예상보다 더 오래 디버깅하게 될 것입니다.
해결책: 작고 지루한 어휘를 사용하세요:
| Event | 의미 |
|---|---|
delta | 텍스트 조각 (text fragment) |
| ... |
실패 이벤트를 error가 아닌 **failed**로 호출하세요. 페이로드에 사용자 안전 메시지와 errorId를 포함시키세요. 부분적인 텍스트는 화면에 유지해야 합니다.
실수 4: 조용한 스트림과 정지 버튼 부재
프록시(Proxy)와 로드 밸런서(load balancer)는 유휴 연결을 좋아합니다. 모델이 생각하거나 도구가 실행되는 동안에도, SSE(Server-Sent Events)가 너무 오랫동안 조용히 있어 끊어질 수 있습니다. 별개로, 사용자는 정지(Stop) 버튼을 누르고 비용 청구도 멈출 것이라고 예상합니다. 만약 취소(cancel) 기능이 브라우저 측만 닫는다면, 서버는 계속해서 생성을 진행할 수 있습니다.
해결책:
- 작업이 진행되는 동안 타이머를 이용해
heartbeat이벤트를 방출하세요 (예: 15초마다). - 모델 호출을 취소하고
done/finishReason: cancelled로 끝나는POST /api/chat/streams/{id}/cancel엔드포인트를 노출하세요. - nginx가 스트림을 하나의 거대한 블롭(blob)으로 버퍼링하지 않도록
X-Accel-Buffering: no를 전송하세요.
// 취소 엔드포인트 형태
if (registry.Find(id) is not { } stream || stream.Owner != owner)
return TypedResults.NotFound();
...
실수 5: '간편한' 스트림을 프로덕션 환경에 배포하는 것
원시 토큰 루프를 통한 TypedResults.ServerSentEvents는 급증(spike)에는 훌륭합니다. 하지만 이는 제품 API가 아닙니다. 재개 기능(resume), 소유권 확인(ownership check), 활성 스트림에 대한 속도 제한(rate limit), 그리고 다중 인스턴스 호스트를 위한 스토리라인이 전혀 없습니다.
완료했다고 간주하기 전에 체크리스트:
- [ ] 시퀀스 ID와
Last-Event-ID재전송(replay) 기능 추가 - [ ] 생성 작업이 요청보다 오래 지속될 수 있으며, 스위퍼(sweeper)가 고아(orphans) 데이터를 정리함.
- [ ] 이벤트 이름이 EventSource와 충돌하지 않도록 함 (
failed를error대신 사용). - [ ] 하트비트(Heartbeats) 기능과 취소 엔드포인트(cancel endpoint) 추가.
- [ ] 소유자 바인딩(Owner binding) 구현 (쿠키 인증 또는 서명된 URL 사용; EventSource는
Authorization헤더를 설정할 수 없음). - [ ] 확장 시 스티키 세션(Sticky sessions) 또는 공유 로그(Shared log) 구현 (예: Redis Streams 등).
- [ ] HTTP/2 또는 HTTP/3 사용을 선호함 (HTTP/1.1에서는 브라우저가 도메인당 약 6개의 SSE 연결만 제한).
의도적으로 제외한 내용
전체 프로젝트에는 코얼레서(coalescer) (작은 토큰 조각 배치 처리), 스위퍼 타이밍, 키 없이 데모를 위한 오프라인 스트리밍 모델, 그리고 전체 테스트 스위트가 포함되어 있습니다. 이 문서는 하이킹 경로가 아니라 지도입니다.
작동하는 소스 코드와 더 심층적인 작성 패키지를 원한다면 Tech Skill Builder에서 가져가세요. 멤버십에는 dotnet test 및 dotnet run으로 실행할 준비가 된 전체 재개 가능한 스트리밍 솔루션이 포함되어 있습니다. 기간 한정 회원 가격은 제품 페이지에 있으며, 브라우저로 토큰을 스트리밍하는 무언가를 구축하고 있다면, 다음 불안정한 데모 전에 완성해야 할 부분입니다.
불안정한 네트워크는 예외 케이스가 아닙니다. 재개(resume) 기능을 첫 번째 지원 티켓이 발생한 후에 적용하는 패치가 아니라 기능의 일부로 취급해야 합니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기