k
korAI
고급 전체
🔥 고급2026-05-246~8분

스트리밍 UI 복원력 설계: 부분 응답 복구와 지수 백오프 재시도 패턴

Claude 스트리밍 응답 중 네트워크 단절이 발생하면 사용자는 빈 화면을 보게 된다. 부분 수신된 텍스트를 보존하고 중단 지점부터 재개하는 복구 전략과, 529·529·overloaded 오류에 특화된 재시도 로직을 설계한다.

streamingerror-handlingresilience

스트리밍 실패의 세 가지 시나리오

프로덕션에서 스트리밍이 중단되는 주요 원인과 특성은 다음과 같다.

| 시나리오 | HTTP 상태 | 재시도 가능 | 평균 발생 빈도 | |---|---|---|---| | 네트워크 타임아웃 | 408/504 | 즉시 가능 | 0.3~0.8% | | 서버 과부하 | 529 | 대기 후 가능 | 트래픽 피크 시 2~5% | | 스트림 도중 연결 끊김 | N/A (소켓 닫힘) | 부분 복구 필요 | 0.1~0.5% |

핵심 문제: 스트림 도중 단절 시 이미 수신한 텍스트를 버리고 처음부터 재요청하면 비용이 2배가 되고 사용자 경험도 저하된다.

부분 응답 보존 + 재개 패턴

수신된 텍스트를 누적 저장하고, 재시도 시 messages에 부분 응답을 assistant 턴으로 삽입해 이어서 생성을 요청한다. Claude는 이전 맥락을 이어받아 중복 없이 계속 생성한다.

import anthropic
import time
from typing import Generator

client = anthropic.Anthropic()

def stream_with_resume(
    messages: list,
    max_retries: int = 3,
    model: str = "claude-opus-4-5"
) -> Generator[str, None, None]:
    partial_text = ""
    attempt = 0

    while attempt <= max_retries:
        try:
            # 부분 수신된 텍스트가 있으면 assistant 턴으로 삽입
            effective_messages = messages.copy()
            if partial_text:
                effective_messages.append(
                    {"role": "assistant", "content": partial_text}
                )

            with client.messages.stream(
                model=model,
                max_tokens=2048,
                messages=effective_messages
            ) as stream:
                for text_chunk in stream.text_stream:
                    partial_text += text_chunk
                    yield text_chunk
            return  # 정상 완료

        except (anthropic.APIStatusError, anthropic.APIConnectionError) as e:
            status = getattr(e, 'status_code', 0)
            attempt += 1

            if status == 529 or status == 503:
                # 과부하: 지수 백오프 (2^attempt * 1s, 최대 30s)
                wait = min(2 ** attempt, 30)
                print(f"과부하 오류. {wait}s 대기 후 재시도 ({attempt}/{max_retries})")
                time.sleep(wait)
            elif status in (408, 504) or isinstance(e, anthropic.APIConnectionError):
                # 타임아웃/연결 오류: 짧은 대기
                time.sleep(1)
            else:
                raise  # 4xx 클라이언트 오류는 재시도 불필요

            if attempt > max_retries:
                raise RuntimeError(f"최대 재시도 초과. 부분 수신: {len(partial_text)}자")

운영 설계 원칙과 트레이드오프

부분 재개의 한계: Claude가 부분 텍스트를 이어받을 때 문장 중간에서 시작하면 의미적 일관성이 깨질 수 있다. 안전한 재개 지점은 문장 종료 기호(. ? !) 또는 단락 경계까지만 partial_text로 유지하고 나머지는 버리는 방식이 낫다.

비용 트레이드오프: 부분 재개 시 이전 partial_text가 입력 토큰으로 재과금된다. partial_text가 500토큰 미만이면 재개가 유리하고, 1500토큰을 넘으면 처음부터 재시도하는 편이 비용상 비슷해진다.

운영 체크리스트:

  • [ ] 스트림 오류율을 stream_error_rate 메트릭으로 분리 추적 (타임아웃 vs 과부하 vs 연결 끊김)
  • [ ] 재시도 횟수 > 1인 요청 비율이 1% 초과 시 알람
  • [ ] partial_text 재개 시 토큰 수 로깅으로 비용 이중과금 규모 파악
  • [ ] UI 레이어에서 재시도 중임을 사용자에게 표시 (스피너 유지)
  • [ ] 최대 재시도 초과 시 partial_text를 사용자에게 표시하고 "계속 생성" 버튼 제공