🔥 고급2026-07-207~9분
스트리밍 UI의 숨겨진 실패 모드: 중단 감지와 부분 복구 설계
SSE 스트림이 중간에 끊겼을 때 사용자 경험을 보호하는 중단 감지, 델타 버퍼링, 부분 재시도 패턴을 Anthropic 스트리밍 SDK 기반으로 설명한다.
streamingerror-handlingreliability
스트리밍이 조용히 실패하는 3가지 지점
프로덕션 스트리밍 UI에서 개발자가 가장 놓치는 문제는 네트워크 단절이 예외 없이 종료되는 경우다. Anthropic Python SDK의 stream() 컨텍스트 매니저는 연결이 끊기면 anthropic.APIConnectionError를 발생시키지만, 프록시·로드밸런서 타임아웃은 스트림을 조용히 닫아버린다.
실패 지점 1 — 첫 토큰 전 타임아웃: 모델이 응답 시작 전에 연결이 끊기면 사용자는 빈 화면을 본다. 감지 방법: 스트림 시작 후 5초 내 첫 text 이벤트 미수신 시 타임아웃 처리.
실패 지점 2 — 중간 끊김: 부분 텍스트가 화면에 표시된 상태에서 스트림이 종료된다. 이 상태를 그대로 두면 사용자는 잘린 응답을 완성된 것으로 오해한다.
실패 지점 3 — stop_reason 미수신: 정상 종료는 stop_reason="end_turn" 이벤트를 포함한다. 이 이벤트 없이 스트림이 닫히면 비정상 종료다. 수신 여부를 플래그로 추적해야 한다.
트레이드오프: 재시도 시 이미 출력된 텍스트를 중복 생성하는 문제가 생긴다. 전체 재시작 vs 부분 재시도(이어쓰기 프롬프트) 중 전자가 구현 단순하고 후자가 비용 효율적이다. 응답 길이 500토큰 이하면 전체 재시작이 낫고, 이상이면 이어쓰기를 고려한다.
실전 코드: 중단 감지와 복구 래퍼
import anthropic
import time
from dataclasses import dataclass, field
@dataclass
class StreamResult:
text: str = ""
completed: bool = False
error: str | None = None
input_tokens: int = 0
output_tokens: int = 0
def resilient_stream(
client: anthropic.Anthropic,
messages: list,
model: str = "claude-opus-4-5",
max_retries: int = 2,
first_token_timeout: float = 8.0,
) -> StreamResult:
result = StreamResult()
for attempt in range(max_retries + 1):
try:
result = StreamResult() # 재시도마다 초기화
first_token_received = False
stream_start = time.monotonic()
with client.messages.stream(
model=model,
max_tokens=1024,
messages=messages,
) as stream:
for event in stream:
# 첫 토큰 타임아웃 감지
if not first_token_received:
if time.monotonic() - stream_start > first_token_timeout:
raise TimeoutError("첫 토큰 미수신 타임아웃")
if event.type == "content_block_delta":
if hasattr(event.delta, "text"):
first_token_received = True
result.text += event.delta.text
yield event.delta.text # UI로 즉시 전달
elif event.type == "message_delta":
if event.delta.stop_reason == "end_turn":
result.completed = True
elif event.type == "message_start":
result.input_tokens = event.message.usage.input_tokens
# stop_reason 미수신 = 비정상 종료
if not result.completed:
raise RuntimeError("스트림이 end_turn 없이 종료됨")
return result # 성공
except (anthropic.APIConnectionError, TimeoutError, RuntimeError) as e:
result.error = str(e)
if attempt < max_retries:
wait = 1.5 ** attempt # 지수 백오프: 1.5s, 2.25s
time.sleep(wait)
else:
return result # 최종 실패 반환
운영 체크리스트
스트림 헬스 모니터링
- [ ] 첫 토큰 지연(TTFT) p99 추적, 임계값 초과 시 알림 (권장 < 3초)
- [ ]
completed=False로 끝난 요청 비율 모니터링 (정상 < 0.5%) - [ ] 재시도 발생률을 별도 메트릭으로 기록 (높으면 업스트림 불안정 신호)
UI 레이어
- [ ] 스트림 시작 후 8초 내 첫 글자 미출력 시 스피너 → 경고 메시지 전환
- [ ] 비정상 종료 감지 시 "응답이 중단되었습니다. 재시도?" UI 노출
- [ ] 부분 텍스트도 사용자에게 표시하되, 완료 아이콘은
completed=True후에만 렌더링
비용 관리
- [ ] 재시도는 입력 토큰을 재과금하므로, 재시도 예산 상한 설정 (요청당 최대 2회)
- [ ] 재시도 비용을 원래 요청과 묶어 로깅해 실제 지출 파악