본문 바로가기

AI/Claude

Claude API 폴백·재시도 전략, Spring Boot로 구현하기

추천캐릭터 2026. 7. 13. 08:00
728x90

Claude API 폴백·재시도 전략, Spring Boot로 구현하기

Claude Fable 5를 운영에 쓰려면 기존 모델과 달리 "에러가 아닌 거절"을 처리하는 로직이 반드시 필요합니다. 안전 분류기가 요청을 거절하면 HTTP 200 정상 응답에 stop_reason: "refusal"이 담겨 오기 때문에, HTTP 상태 코드 기반의 기존 예외 처리로는 잡히지 않습니다. 이 글에서는 지난 셧다운 정리글에서 예고한 대로, 거절 응답 처리와 재시도·폴백 전략을 Spring Boot 코드로 구체화합니다.

Claude API에서 재시도가 필요한 세 가지 상황

폴백 설계 전에, 어떤 상황을 구분해서 다뤄야 하는지부터 정리합니다.

  1. 분류기 거절(refusal): Fable 5 고유의 상황. HTTP 200 + stop_reason: "refusal"로 돌아오며, 응답에 어떤 분류기가 거절했는지도 포함됩니다. 같은 요청을 Fable 5에 재시도해도 결과는 같으므로 다른 모델로 전환해야 합니다.
  2. 레이트 리밋·과부하(429, 529): 일시적 상황이므로 같은 모델에 지수 백오프 재시도가 정답입니다. 모델을 바꿀 필요가 없습니다.
  3. 모델 자체의 접근 불가(403, 404 등): 지난 6월 수출통제 사태처럼 특정 모델만 통째로 막히는 경우로, 폴백 체인의 다음 모델로 전환해야 합니다.

세 상황의 대응이 서로 다르다는 것이 핵심입니다. 모든 실패를 "다음 모델로 넘기기"로 뭉뚱그리면 일시적 과부하에도 불필요하게 하위 모델로 응답 품질이 떨어지게 됩니다.

Spring Boot 구현

상황별 분기를 반영한 서비스 예시입니다. 지난 글의 예시가 HTTP 에러 기반 체인이었다면, 이번에는 refusal 판별과 백오프 재시도까지 통합한 형태입니다.

@Service
public class ClaudeResilientChatService {

    private final WebClient webClient;

    // 거절·차단 시 전환할 모델 체인 (설정 외부화 권장)
    private final List<String> modelChain = List.of(
            "claude-fable-5",
            "claude-opus-4-8"
    );

    private static final int MAX_RETRY = 3;

    public ClaudeResilientChatService(WebClient.Builder builder) {
        this.webClient = builder
                .baseUrl("https://api.anthropic.com/v1")
                .build();
    }

    public String chat(String userMessage) {
        for (String model : modelChain) {
            try {
                Map<String, Object> res = callWithBackoff(model, userMessage);

                // 핵심: HTTP 200이어도 stop_reason 확인
                if ("refusal".equals(res.get("stop_reason"))) {
                    continue; // 분류기 거절 → 다음 모델로 폴백
                }
                return extractText(res);

            } catch (WebClientResponseException e) {
                int status = e.getStatusCode().value();
                if (status == 403 || status == 404) {
                    continue; // 모델 접근 불가 → 다음 모델로 폴백
                }
                throw e; // 그 외 에러는 상위로 전파
            }
        }
        throw new IllegalStateException("모든 Claude 모델이 응답에 실패했습니다.");
    }

    /** 429/529는 같은 모델에 지수 백오프로 재시도 */
    private Map<String, Object> callWithBackoff(String model, String msg) {
        long delayMillis = 1000;
        for (int attempt = 1; ; attempt++) {
            try {
                return callModel(model, msg);
            } catch (WebClientResponseException e) {
                int status = e.getStatusCode().value();
                boolean retryable = (status == 429 || status == 529);
                if (!retryable || attempt >= MAX_RETRY) {
                    throw e;
                }
                try {
                    Thread.sleep(delayMillis);
                } catch (InterruptedException ie) {
                    Thread.currentThread().interrupt();
                    throw e;
                }
                delayMillis *= 2; // 1s → 2s → 4s
            }
        }
    }

    @SuppressWarnings("unchecked")
    private Map<String, Object> callModel(String model, String msg) {
        Map<String, Object> body = Map.of(
                "model", model,
                "max_tokens", 1024,
                "messages", List.of(Map.of("role", "user", "content", msg))
        );
        return webClient.post()
                .uri("/messages")
                .header("x-api-key", System.getenv("ANTHROPIC_API_KEY"))
                .header("anthropic-version", "2023-06-01")
                .bodyValue(body)
                .retrieve()
                .bodyToMono(Map.class)
                .block();
    }

    @SuppressWarnings("unchecked")
    private String extractText(Map<String, Object> res) {
        var content = (List<Map<String, Object>>) res.get("content");
        return (String) content.get(0).get("text");
    }
}

실무 주의사항

서버 사이드 폴백부터 검토하세요. Anthropic은 거절 시 API가 대신 다른 모델로 재시도해주는 fallbacks 파라미터를 베타로 제공하고 있습니다. 직접 구현 전에 공식 문서에서 지원 범위를 확인하면 위 코드의 상당 부분을 위임할 수 있습니다. SDK 미들웨어를 이용한 클라이언트 사이드 재시도도 지원됩니다.

폴백 과금을 감안하세요. 거절된 요청이 Opus 4.8로 처리되면 해당 응답은 Opus 요금으로 청구됩니다. 대화 중간에 차단되는 경우 앞부분은 Fable 요금, 이후는 Opus 요금으로 나뉘어 계산되므로, 거절률이 높은 도메인이라면 비용 모니터링 지표에 모델별 분리가 필요합니다.

프롬프트 캐시 이중 과금에 주의하세요. 폴백 재시도 시 같은 프롬프트의 캐시 쓰기 비용을 두 번 내지 않도록, 공식 문서의 캐시 처리 가이드에 따라 재시도 요청을 구성해야 합니다.

폴백은 품질 저하를 동반합니다. 하위 모델로 넘어간 응답은 품질이 달라질 수 있으므로, 어떤 모델이 최종 응답했는지 로그에 남기고 필요하면 사용자에게도 표시하는 것이 운영상 안전합니다. Kraft에서도 외부 LLM 호출 구간에는 응답 모델명을 로깅해 품질 이슈 추적에 활용하고 있습니다.

마무리

Fable 5 세대부터 Claude API 통합의 기본기는 "성공/실패" 이분법이 아니라 거절·과부하·차단을 구분하는 3단 대응으로 바뀌었습니다. 위 패턴처럼 모델 체인을 설정으로 외부화해 두면, 지난 셧다운 같은 돌발 상황에서도 배포 없이 체인 순서만 바꿔 대응할 수 있습니다. 모델 선택 자체가 고민이라면 함께 발행한 GPT-5.6 vs Claude Fable 5 비교글을 참고하세요.

728x90