k
korAI
고급 전체
🔥 고급2026-06-016~8분

스트리밍 UI 에러 복구 설계: 부분 응답 저장과 재시도 이음새 전략

SSE 스트리밍 도중 네트워크 단절이 발생하면 사용자는 빈 화면을 본다. 부분 응답을 보존하고 중단 지점부터 이어붙이는 프로덕션 패턴을 다룬다.

streamingerror-handlingresilience

스트리밍 실패의 세 가지 유형

유형 A: 네트워크 단절 — 클라이언트 연결이 끊기지만 서버 스트림은 계속 흐름. 응답 소실. 유형 B: 서버 사이드 오류overloaded_error (529) 또는 api_error (500)로 스트림 자체가 중단. Anthropic 공식 문서상 529는 지수 백오프 재시도 권장. 유형 C: 부분 tool_use 블록 중단input_json_delta가 절반만 수신되어 JSON 파싱 불가.

실패 모드별 트레이드오프:

  • 무조건 처음부터 재시도 → 비용 2× + 응답 일관성 깨짐
  • 부분 텍스트를 그냥 이어붙이기 → 문장 중간 접합으로 품질 저하
  • 재시도 없이 에러 표시 → UX 최악

최적 전략: 부분 텍스트를 서버에 저장 → 재시도 시 "이어서 작성" 프롬프트로 연속성 확보

부분 응답 저장과 이음새 프롬프트 구현

import anthropic
import time
from dataclasses import dataclass, field

@dataclass
class StreamState:
    partial_text: str = ""
    input_tokens: int = 0
    attempt: int = 0

def stream_with_recovery(
    messages: list,
    system: str,
    max_retries: int = 3
) -> str:
    client = anthropic.Anthropic()
    state = StreamState()

    for attempt in range(max_retries):
        state.attempt = attempt
        current_messages = messages.copy()

        # 이음새 전략: 부분 응답이 있으면 assistant 턴으로 주입
        if state.partial_text:
            current_messages.append({"role": "assistant", "content": state.partial_text})
            current_messages.append({
                "role": "user",
                "content": "(이전 응답이 중단됨. 마지막 문장에서 자연스럽게 이어서 완성해줘.)"
            })

        try:
            with client.messages.stream(
                model="claude-opus-4-5",
                max_tokens=2048,
                system=system,
                messages=current_messages,
            ) as stream:
                for text in stream.text_stream:
                    state.partial_text += text
                    yield text  # UI로 실시간 전송

                # 정상 완료
                final = stream.get_final_message()
                state.input_tokens = final.usage.input_tokens
                return

        except (anthropic.APIStatusError, anthropic.APIConnectionError) as e:
            status = getattr(e, 'status_code', 0)
            if status == 529 or status >= 500:
                wait = 2 ** attempt  # 1s, 2s, 4s
                print(f"재시도 {attempt+1}/{max_retries} — {wait}s 대기 (부분 저장: {len(state.partial_text)}자)")
                time.sleep(wait)
                continue
            raise  # 4xx는 재시도 무의미

    raise RuntimeError(f"최대 재시도 초과. 부분 응답 {len(state.partial_text)}자 보존됨")

이음새 프롬프트의 핵심은 partial_text를 assistant 메시지로 넣어 모델이 자신이 작성 중이었다고 인식하게 하는 것이다. 실험 결과 단순 "이어서 써줘" 대비 접합 품질이 유의미하게 향상된다.

운영 체크리스트와 수치 기준

지수 백오프 기준: 529 오류 시 1→2→4초, 최대 3회. 4회 이상은 비용 대비 효과 없음. 부분 응답 보존 임계값: 50자 미만이면 처음부터 재시도가 낫다 (이음새 오버헤드 > 절약 토큰). 타임아웃 설정: httpx_client 커스텀으로 read timeout 120초 권장 (기본값 600초는 과도).

운영 체크리스트

  • [ ] stream.text_stream 루프에서 청크별 누적 저장 로직 존재 여부
  • [ ] 529 / 500 구분 처리: 4xx는 재시도 금지
  • [ ] 이음새 프롬프트 삽입 후 토큰 증가분 비용 모니터링
  • [ ] 부분 응답 50자 미만 시 처음부터 재시도 분기 존재 여부
  • [ ] 최종 실패 시 partial_text 사용자에게 노출 또는 로그 저장 여부