dev notes

OpenAI나 Claude의 API 장애에 대응하는 법 — Provider Fallback

2026-04-0722 min read
공유

단일 Provider의 위험#

이전 편에서 MapReduce 체인과 Adaptive Chain으로 토큰 제한 문제까지는 풀었습니다. 근데 토큰 제한을 넘기지 않는다고 해서 끝나는 건 아니었습니다. LLM 호출 자체가 실패하면 거기서 바로 멈추기 때문입니다.

OpenAI API는 생각보다 자주 불안정합니다. rate limit에 걸리기도 하고, 간헐적으로 500 에러를 뱉기도 하고, 가끔은 수십 분 단위로 장애가 나기도 합니다. Anthropic도 마찬가지입니다. 어떤 LLM Provider든 100% 가용성을 보장하지 않습니다.

서비스가 하나의 Provider에만 의존하고 있으면, 그 Provider가 죽는 순간 LLM을 쓰는 기능 전체가 멈춥니다. 사용자 입장에서는 "AI 요약" 버튼을 눌렀는데 에러가 나는 거고, 운영 입장에서는 외부 의존성 하나 때문에 장애 리포트를 받는 겁니다.

그래서 결국 Provider를 여러 개 두고, 하나가 실패하면 다음 걸로 넘기는 구조가 필요했습니다. 이 글은 그 패턴을 Spring에서 어떻게 붙였는지, 그리고 운영하면서 어디서 걸렸는지 정리한 기록입니다.

실패 유형 이해하기#

Fallback 구조를 설계하려면, LLM API가 실패하는 방식부터 알아야 합니다. 실패 유형에 따라 대응이 달라지기 때문입니다.

Rate Limit (429)#

가장 흔한 실패입니다. 분당/일당 호출 한도를 초과하면 429 에러가 옵니다. OpenAI는 TPM(Tokens Per Minute)과 RPM(Requests Per Minute) 두 가지 제한이 있고, 어느 쪽이든 걸리면 429를 반환합니다.

이 경우 같은 Provider를 재시도하면 똑같이 실패합니다. 한도가 리셋될 때까지 기다리거나, 다른 Provider로 넘기는 게 맞습니다. Retry-After 헤더가 오면 그 시간만큼 기다릴 수도 있지만, 사용자 요청을 수십 초 동안 블로킹하는 건 현실적이지 않습니다.

서버 에러 (500, 502, 503)#

Provider 내부 장애입니다. 이건 일시적인 경우가 많아서 재시도하면 성공할 수도 있습니다. 하지만 대규모 장애라면 재시도해도 계속 실패하기 때문에, 빠르게 다른 Provider로 넘기는 게 나을 수 있습니다.

타임아웃#

LLM 호출은 원래 느립니다. GPT-4o 기준으로 보통 2-10초가 걸리는데, 부하가 몰리면 30초 이상 걸리기도 합니다. 타임아웃을 너무 짧게 잡으면 정상 응답을 못 받고, 너무 길게 잡으면 장애 상황에서 스레드가 오래 잡혀있습니다.

재시도 vs Fallback#

여기서 결국 판단해야 하는 건 하나였습니다. 실패했을 때 같은 Provider를 다시 시도할지, 아니면 바로 다음 Provider로 넘길지.

저희는 재시도 없이 바로 Fallback하는 쪽으로 갔습니다. 이유는 단순했습니다:

  1. LLM 호출 자체가 느려서(2-10초), 재시도하면 사용자 대기 시간이 두 배가 됩니다
  2. Rate limit이면 재시도해도 실패합니다
  3. 서버 에러도 대규모 장애라면 재시도가 무의미합니다
  4. 다른 Provider로 넘기면 대부분 바로 성공합니다

재시도가 의미 있는 건 간헐적 네트워크 오류 정도인데, 그건 HTTP 클라이언트 레벨에서 처리하는 게 맞다고 봤습니다. 애플리케이션 레벨에서는 "이 Provider 안 되면 다음 거"가 더 실용적입니다.

Provider Fallback 구현#

LLMProviderPriority#

Provider에 우선순위를 붙입니다.

java
@Getter
@AllArgsConstructor(staticName = "of")
public class LLMProviderPriority {
    private final LLMProvider provider;
    private final int priority;  // 숫자가 낮을수록 먼저 시도
 
    public static Collection<LLMProviderPriority> defaultOpenAiAndAnthropic() {
        return List.of(
            LLMProviderPriority.of(OpenAiProvider.withDefaults(), 1),
            LLMProviderPriority.of(AnthropicProvider.withDefaults(), 2)
        );
    }
}

기본 설정은 OpenAI가 1순위, Anthropic이 2순위입니다. 평소에는 OpenAI를 쓰다가, 실패하면 Anthropic으로 넘어갑니다. 순위를 바꾸거나 Provider를 3개 이상 넣는 것도 가능합니다.

LLMExecutor — Fallback 루프#

핵심 로직입니다. Provider를 우선순위 순으로 시도하고, 실패하면 다음으로 넘깁니다.

java
@Component
@RequiredArgsConstructor
public class LLMExecutor {
 
    private final LLMProviderExecutor providerExecutor;
 
    public String executeChatSync(Collection<LLMProviderPriority> providers,
                                   Collection<LLMMessage> messages) {
 
        List<LLMProviderPriority> sorted = providers.stream()
            .sorted(Comparator.comparing(LLMProviderPriority::getPriority))
            .toList();
 
        Map<String, Exception> failureMap = new LinkedHashMap<>();
 
        for (LLMProviderPriority pp : sorted) {
            try {
                String response = providerExecutor.execute(
                    pp.getProvider(), messages);
 
                // 실제 성공한 Provider 기록
                LLMExecutionContext.setActualProvider(
                    pp.getProvider().getName(),
                    pp.getProvider().getModel()
                );
 
                return response;
 
            } catch (Exception e) {
                failureMap.put(pp.getProvider().getName(), e);
                log.warn("[LLMExecutor] {} 실패, 다음 Provider 시도: {}",
                    pp.getProvider().getName(), e.getMessage());
            }
        }
 
        // 모든 Provider 실패
        throw new LLMProviderFailureException(
            "모든 LLM Provider가 실패했습니다", failureMap);
    }
}

LinkedHashMap으로 failureMap 관리 — 어떤 Provider가 어떤 순서로, 어떤 에러로 실패했는지 전부 기록합니다. 모든 Provider가 실패했을 때 이 map을 예외에 담아서 던지면, 디버깅할 때 "OpenAI는 rate limit, Anthropic은 timeout이었구나"를 바로 알 수 있습니다. LinkedHashMap을 쓰는 이유는 시도 순서를 보존하기 위해서입니다. 일반 HashMap을 쓰면 순서가 섞여서 "어떤 Provider를 먼저 시도했는지"를 알 수 없습니다.

Loading diagram...

LLMProviderFailureException — 실패 추적#

모든 Provider가 실패하면 failureMap과 함께 예외를 던집니다.

java
@Getter
public class LLMProviderFailureException extends RuntimeException {
 
    private final Map<String, Exception> failureMap;
 
    public LLMProviderFailureException(String message,
                                        Map<String, Exception> failureMap) {
        super(message);
        this.failureMap = failureMap;
    }
}

이 예외를 받는 쪽에서는 failureMap을 순회하면서 각 Provider의 실패 원인을 로깅할 수 있습니다.

java
try {
    return llmExecutor.executeChatSync(providers, messages);
} catch (LLMProviderFailureException e) {
    e.getFailureMap().forEach((provider, ex) ->
        log.error("[LLM] {} 실패: {}", provider, ex.getMessage()));
    throw e;
}

운영 환경에서는 이 예외가 발생하면 슬랙 알림을 보내도록 설정해두었습니다. 모든 Provider가 동시에 죽는 건 꽤 심각한 상황이니까요.

LLMExecutionContext — 실제 사용 Provider 추적#

Fallback이 발동하면 원래 의도했던 Provider와 실제 사용된 Provider가 다릅니다. "OpenAI를 쓰려고 했는데 실제로는 Anthropic이 응답했다"는 정보가 필요한 곳이 두 군데 있습니다.

첫째, 비용 집계. OpenAI와 Anthropic의 토큰당 단가가 다릅니다. 실제 사용된 Provider를 모르면 비용 추정이 안 맞습니다.

둘째, 사용량 추적. LLM 사용량 추적 글에서 Decorator 패턴으로 모든 LLM 호출의 사용량을 DB에 기록하는 구조를 만들었는데, 거기서 LLMExecutionContext.getActualProvider()를 호출해서 실제 provider와 model을 저장합니다.

java
public class LLMExecutionContext {
 
    private static final ThreadLocal<ProviderInfo> ACTUAL_PROVIDER
        = new ThreadLocal<>();
 
    public static void setActualProvider(String name, String model) {
        ACTUAL_PROVIDER.set(new ProviderInfo(name, model));
    }
 
    public static ProviderInfo getActualProvider() {
        return ACTUAL_PROVIDER.get();
    }
 
    public static void clear() {
        ACTUAL_PROVIDER.remove();
    }
 
    public record ProviderInfo(String name, String model) {}
}

ThreadLocal을 쓰는 이유는, 하나의 요청 안에서 LLM 호출 → 사용량 기록이 같은 스레드에서 순차적으로 일어나기 때문입니다. 주의할 점은 요청이 끝나면 반드시 clear()를 호출해야 합니다. 스레드 풀에서 스레드가 재사용되면 이전 요청의 값이 남아있을 수 있으니까요. Decorator에서 finally 블록으로 처리합니다.

java
// LLMUsageTrackingDecorator 내부
try {
    T result = delegate.chat(providers, messages, entity);
    ProviderInfo actual = LLMExecutionContext.getActualProvider();
    saveUsage(actual.name(), actual.model(), "SUCCESS");
    return result;
} catch (Exception e) {
    saveUsage("unknown", "unknown", "ERROR");
    throw e;
} finally {
    LLMExecutionContext.clear();
}

LLMProviderFactory — Provider 생성#

Langfuse 1편에서 프롬프트와 함께 config(provider, model, temperature)를 Langfuse에서 관리한다고 했는데, 이 config에서 실제 Provider 객체를 생성하는 게 LLMProviderFactory입니다.

java
@Component
public class LLMProviderFactory {
 
    private static final String DEFAULT_OPENAI_MODEL = "gpt-5.2";
    private static final String DEFAULT_ANTHROPIC_MODEL = "claude-sonnet-4-6";
    private static final double DEFAULT_TEMPERATURE = 0.5;
 
    public Collection<LLMProviderPriority> createProviders(PromptConfigData config) {
        if (config == null || config.getProvider() == null) {
            return LLMProviderPriority.defaultOpenAiAndAnthropic();
        }
 
        try {
            String provider = config.getProvider().toLowerCase();
 
            return switch (provider) {
                case "openai" -> List.of(
                    createOpenAiProvider(config),
                    createAnthropicFallback()
                );
                case "anthropic" -> List.of(
                    createAnthropicProvider(config),
                    createOpenAiFallback()
                );
                default -> {
                    log.warn("[ProviderFactory] 알 수 없는 provider: {}, 기본값 사용",
                        provider);
                    yield LLMProviderPriority.defaultOpenAiAndAnthropic();
                }
            };
        } catch (Exception e) {
            log.warn("[ProviderFactory] Provider 생성 실패, 기본값 사용", e);
            return LLMProviderPriority.defaultOpenAiAndAnthropic();
        }
    }
 
    private LLMProviderPriority createOpenAiProvider(PromptConfigData config) {
        String model = config.getModelOrDefault(DEFAULT_OPENAI_MODEL);
        double temperature = config.getTemperatureOrDefault(DEFAULT_TEMPERATURE);
        Optional<Integer> maxTokens = config.getMaxTokensOptional();
 
        return LLMProviderPriority.of(
            OpenAiProvider.of(model, temperature, maxTokens), 1);
    }
 
    private LLMProviderPriority createAnthropicProvider(PromptConfigData config) {
        String model = config.getModelOrDefault(DEFAULT_ANTHROPIC_MODEL);
        double temperature = config.getTemperatureOrDefault(DEFAULT_TEMPERATURE);
        Optional<Integer> maxTokens = config.getMaxTokensOptional();
 
        return LLMProviderPriority.of(
            AnthropicProvider.of(model, temperature, maxTokens), 1);
    }
 
    private LLMProviderPriority createAnthropicFallback() {
        return LLMProviderPriority.of(
            AnthropicProvider.of(DEFAULT_ANTHROPIC_MODEL, DEFAULT_TEMPERATURE,
                Optional.empty()), 2);
    }
 
    private LLMProviderPriority createOpenAiFallback() {
        return LLMProviderPriority.of(
            OpenAiProvider.of(DEFAULT_OPENAI_MODEL, DEFAULT_TEMPERATURE,
                Optional.empty()), 2);
    }
}

Graceful Degradation#

이 코드에서 가장 중요한 건 모든 단계에서 예외 대신 기본값을 반환한다는 점입니다.

Loading diagram...
상황동작로그
config가 null기본값 반환없음 (정상 동작)
provider 필드가 null기본값 반환없음
provider가 "opanai" (오타)기본값 반환warn: 알 수 없는 provider
model이 "gpt-9" (존재하지 않음)기본 모델로 fallbackwarn: 알 수 없는 model
Provider 생성 중 예외기본값 반환warn: 생성 실패

Langfuse에서 config를 잘못 설정했다고 서비스가 멈추면 안 됩니다. 누군가가 실수로 provider를 "opanai"라고 오타를 쳤을 때, 500 에러를 던지는 대신 기본값으로 동작하면서 경고 로그를 남깁니다. 운영 중에 로그를 보고 발견해서 고치면 되니까요.

이런 식으로 흘려보내는 걸 graceful degradation이라고 하는데, 설정 하나 잘못됐다고 서비스를 멈추는 것보다 기본 모델로라도 동작하는 쪽이 사용자 입장에서는 낫습니다. 물론 원래 의도한 모델과 다를 수는 있지만, 에러 화면을 보는 것보다는 훨씬 낫습니다.

Primary + Fallback 조합#

createProviders()가 항상 2개 이상의 Provider를 반환하게 한 것도 의도적이었습니다. config에서 "openai"를 지정하면 OpenAI가 primary(우선순위 1), Anthropic이 fallback(우선순위 2)으로 세팅됩니다. 반대로 "anthropic"이면 Anthropic이 primary입니다.

Fallback Provider는 기본값(기본 모델, 기본 temperature)으로 생성합니다. config에서 세밀하게 조정한 파라미터는 primary에만 적용되고, fallback은 "최소한 동작하는" 수준이면 됩니다. 어차피 fallback이 발동하는 건 비정상 상황이니까요.

Provider 간 응답 차이 대응#

OpenAI에서 Anthropic으로 Fallback이 발동하면, 같은 프롬프트를 보내도 응답 형식이 다를 수 있습니다. 실제로 겪은 케이스를 몇 가지 들면:

  • OpenAI는 JSON을 깔끔하게 주는데, Anthropic은 ```json 마크다운 블록으로 감싸서 줌
  • 숫자 필드를 OpenAI는 42로, Anthropic은 "42"(문자열)로 주는 경우
  • 배열 필드를 OpenAI는 비어있으면 []로, Anthropic은 아예 필드를 빼는 경우

이런 차이가 있으면 JSON 파싱이 깨지고, Provider를 바꿨을 뿐인데 서비스가 다운됩니다. Fallback의 의미가 없어지는 거죠.

JSON Schema로 응답 구조 강제#

해결 방법은 프롬프트에 JSON Schema를 포함시켜서, 어떤 Provider가 응답하든 같은 형식을 지키게 하는 겁니다.

java
public <T> T executeChatSync(Collection<LLMProviderPriority> providers,
                              Collection<LLMMessage> messages,
                              Class<T> responseType) {
 
    // 응답 타입에서 JSON Schema 자동 생성
    String schemaPrompt = JsonSchemaGenerator.generate(responseType);
 
    // System 메시지로 스키마 추가
    List<LLMMessage> messagesWithSchema = new ArrayList<>(messages);
    messagesWithSchema.add(0,
        new LLMMessage.System(schemaPrompt));
 
    // Fallback 루프 실행
    String rawResponse = executeChatSync(providers, messagesWithSchema);
 
    // 응답 파싱
    try {
        return objectMapper.readValue(rawResponse, responseType);
    } catch (JsonProcessingException e) {
        throw new LLMParsingException(
            "LLM 응답 파싱 실패: " + rawResponse, e);
    }
}

JsonSchemaGenerator.generate(responseType)가 응답 DTO 클래스에서 JSON Schema를 자동으로 만들어냅니다. 예를 들어:

java
public record SummaryResult(
    String summary,
    List<String> keywords
) {}

이 클래스를 넘기면 이런 스키마 프롬프트가 생성됩니다:

다음 JSON 형식으로만 응답하세요. 마크다운 블록이나 추가 설명 없이 순수 JSON만 반환하세요.

{
  "type": "object",
  "properties": {
    "summary": { "type": "string" },
    "keywords": { "type": "array", "items": { "type": "string" } }
  },
  "required": ["summary", "keywords"]
}

"마크다운 블록이나 추가 설명 없이 순수 JSON만"이라는 문구가 중요합니다. 이게 없으면 Anthropic이 ```json으로 감싸서 주는 경우가 있어서, 파싱할 때 마크다운 블록을 먼저 벗겨내야 하는 번거로움이 생깁니다.

파싱 실패 처리#

파싱이 실패하면 LLMParsingException을 던집니다. 이건 Provider 실패와는 성격이 다릅니다.

java
@Getter
public class LLMParsingException extends RuntimeException {
    private final String rawResponse;
 
    public LLMParsingException(String message, Throwable cause) {
        super(message, cause);
        this.rawResponse = message;
    }
}

Provider는 정상 응답을 줬는데 형식이 안 맞는 경우라서, Fallback을 다시 시도하지 않습니다. 같은 프롬프트를 다른 Provider에 보내도 파싱이 실패할 가능성이 높으니까요. 대신 rawResponse를 예외에 담아서, 디버깅할 때 "LLM이 뭘 반환했길래 파싱이 깨졌는지"를 바로 확인할 수 있게 합니다.

실무에서는 이 예외가 나면 프롬프트를 수정해야 하는 경우가 대부분입니다. 스키마 프롬프트의 문구를 더 명확하게 고치거나, DTO 구조를 단순화하거나.

Fallback 모니터링#

Fallback이 잘 동작하는 것도 중요하지만, 얼마나 자주 발동하는지 모니터링하는 것도 중요합니다. Fallback이 빈번하게 발동한다면 primary Provider에 문제가 있다는 신호이고, 비용 구조도 달라지니까요.

LLMExecutionContext에서 실제 사용된 Provider를 알 수 있으니, 이걸로 Fallback 비율을 추적할 수 있습니다.

java
// 사용량 저장 시 Fallback 여부 기록
ProviderInfo actual = LLMExecutionContext.getActualProvider();
boolean isFallback = !actual.name().equals(intendedProvider);
 
llmUsageService.saveUsageAsync(
    companySn,
    actual.name(),
    actual.model(),
    isFallback,    // Fallback 여부
    responseLength,
    status
);

이렇게 쌓으면 "이번 주 OpenAI Fallback 비율이 15%다" 같은 데이터를 뽑을 수 있습니다. 비율이 갑자기 올라가면 OpenAI 쪽에 문제가 있는 거고, 지속적으로 높다면 primary를 Anthropic으로 바꾸는 걸 검토해야 합니다.

비용 측면에서도 모니터링이 필요합니다. OpenAI와 Anthropic의 토큰당 단가가 다르기 때문에, Fallback이 빈번하면 예상했던 것보다 비용이 높아질 수 있습니다. 월간 비용 리포트에서 Provider별 사용량과 비용을 분리해서 보면, Fallback이 비용에 얼마나 영향을 주는지 파악할 수 있습니다.

전체 아키텍처#

이전 편(MapReduce 체인)과 이번 편(Provider Fallback)을 합치면, LLM 호출의 전체 구조는 이렇게 됩니다.

Loading diagram...

비즈니스 서비스에서 AdaptiveChain에 텍스트를 넘기면:

  1. 토큰 추정 - Simple or MapReduce 자동 선택
  2. 체인 실행 - 단일 호출 또는 병렬 Map + Reduce
  3. Provider 선택 - Langfuse config 기반, 못 읽으면 기본값
  4. Fallback - 1순위 실패 시 자동 전환, failureMap에 기록
  5. 응답 파싱 - JSON Schema로 Provider 무관하게 동일 형식
  6. 사용 Provider 기록 - ExecutionContext로 비용/사용량 추적

Langfuse 시리즈에서 다뤘던 프롬프트 관리와 캐싱이 이 구조의 윗단에서 동작합니다. 프롬프트와 config는 Langfuse에서 가져오고, Redis 캐싱과 fallback 체인으로 안정성을 확보하고, 여기서 다룬 체인 패턴과 Provider Fallback이 실제 LLM 호출을 처리합니다. 각 레이어가 자기 역할만 하고, 서로의 디테일은 모릅니다.


참고 자료

Connected Notes