dev notes

LLM 외부 의존성 장애를 격리하는 Provider Fallback과 NoOp 설계

2026-05-2812 min read
공유

추가로 고려해야할 외부 의존성#

이전 글에서는 Redis 캐시와 LKG를 적용해 Langfuse 장애가 프롬프트 조회 경로에 미치는 영향을 줄였습니다.

프롬프트를 확보해도 실제 응답을 만드는 LLM Provider는 여전히 외부 의존성입니다. rate limit, timeout, 일시적인 API 장애나 네트워크 오류는 애플리케이션에서 직접 통제할 수 없습니다.

AWS 환경에서 사용하는 LLM 클라이언트는 connect timeout 10초, read timeout 120초로 설정돼 있습니다. 1순위 Provider가 429를 바로 반환하면 다음 후보로 빠르게 전환할 수 있지만, timeout을 모두 소비한 뒤 실패하면 다음 Provider가 성공하더라도 앞선 호출 시간이 그대로 누적됩니다.

외부 연동이라고 해서 실패를 모두 같은 방식으로 처리하지는 않았습니다. LLM Provider는 응답 생성에 필요하므로 다음 후보로 전환하고, tracing처럼 응답 생성과 직접 관계없는 기능은 실패해도 고객 요청을 중단하지 않았습니다.

Loading diagram...

Provider Fallback과 NoOp#

Provider Fallback은 하나의 Provider가 실패했을 때 다른 Provider로 요청을 이어가는 방식입니다. 반면 NoOp은 해당 기능을 실행하지 않아도 본래 요청을 처리할 수 있는 경우에 사용했습니다.

장애 지점처리 방식결과
1순위 LLM Provider 실패다음 Provider로 전환다른 Provider가 성공하면 응답 반환
모든 LLM Provider 실패명시적인 예외실패를 정상 응답으로 숨기지 않음
Langfuse 프롬프트 조회 실패Redis cache / LKG / 기본 프롬프트프롬프트 조회 경로 복구
Langfuse tracing 비활성·전송 실패NoOp / best-effortLLM 응답은 그대로 처리

모든 Provider가 실패했을 때 빈 문자열이나 임의의 기본 응답을 반환하지 않았습니다. 호출부가 이를 정상 결과로 처리하면 실제 장애가 데이터 오류로 바뀔 수 있기 때문입니다.

NoOp은 tracing처럼 생략해도 핵심 결과가 달라지지 않는 기능에만 사용했습니다. 프롬프트처럼 LLM 실행에 필요한 데이터는 NoOp으로 없애는 대신 cache, LKG 또는 기능별 기본값을 이용해 별도의 fallback 경로를 뒀습니다.

예외처리 전략#

timeout, rate limit, 네트워크 오류처럼 Provider 호출 자체가 실패한 경우에는 같은 입력을 다음 Provider에서 처리할 수 있습니다. 반면 응답을 애플리케이션 타입으로 변환하는 과정에서 발생한 파싱 오류는 다음 Provider로 넘기지 않았습니다.

구조화 응답 경로에서는 Provider 호출과 응답 파싱을 분리했습니다. Provider 호출 중 발생한 오류만 fallback 대상으로 처리하고, 선택된 응답을 변환하는 과정에서 계약을 만족하지 못하면 LLMParsingException으로 종료합니다.

파싱 실패는 Provider 가용성 문제가 아니라 프롬프트와 출력 스키마 사이의 계약 실패로 처리했습니다.

Fallback에도 지연 비용은 남습니다. 앞선 Provider가 timeout을 모두 소비한 뒤 다음 Provider를 호출하면 최종 응답 시간에는 두 호출 시간이 모두 포함됩니다.

Fallback은 Provider 장애 시 성공 가능성을 높이지만 timeout 자체를 줄여주지는 않기 때문에 전체 요청 시간을 별도로 제한하려면 Provider별 timeout 외에 요청 단위의 deadline이나 시간 예산을 추가로 관리해야 합니다.

기능별 모델 선택과 실행 시점 Fallback#

기능마다 중요하게 보는 출력 품질과 응답 시간, 비용이 다르기 때문에 기능마다 여러가지 모델을 선정하여 활용하고 있습니다.

따라서 동일한 평가 데이터셋과 기준으로 후보 모델을 비교한 뒤 기능별 우선 Provider를 정하고, provider, model, temperature, maxTokens를 Langfuse config에 저장하고 이를 활용해서 provider 호출 시점에 model이 정해지도록 하였습니다.

java
PromptWithConfig prompt =
    promptService.loadPromptWithConfig(promptKey, promptLabel);
 
Collection<LLMProviderPriority> providers = prompt.hasConfig()
    ? providerFactory.createProviders(prompt.config())
    : preferredProviders;

실행 계층은 전달받은 후보를 priority 순서대로 호출하고, 호출이 실패하면 fallback 용 Provider로 넘어갑니다. config가 없거나 사용할 수 없는 경우에도 기본 Provider를 활용하게 하였습니다.

java
List<LLMProviderPriority> orderedProviders = providers.stream()
    .sorted(comparing(LLMProviderPriority::getPriority))
    .toList();
 
Map<LLMProvider, Exception> failures = new LinkedHashMap<>();
 
for (LLMProviderPriority candidate : orderedProviders) {
    LLMProvider provider = candidate.getProvider();
 
    try {
        String result =
            providerExecutor.execute(provider, messages, jsonMode);
 
        LLMExecutionContext.setActualProvider(provider);
        return result;
    } catch (Exception e) {
        failures.put(provider, e);
    }
}
 
throw new LLMProviderFailureException(
    "모든 LLM Provider 호출이 실패했습니다",
    failures
);

위 반복문은 Provider 호출 단계의 실패를 처리합니다. 구조화 응답의 파싱은 Provider 선택이 끝난 뒤 별도 단계에서 수행하며, 파싱 중 발생한 LLMParsingException은 각 Provider별 실패 원인을 함께 보관합니다.

예를 들어 OpenAI에서 rate limit이 발생한 뒤 Anthropic이 timeout으로 실패한 경우와 두 Provider가 모두 인증 오류를 반환한 경우는 대응 방법이 다릅니다. 따라서 최종 예외에서 어떤 Provider를 어떤 순서로 시도했고 각각 왜 실패했는지 확인할 수 있도록 했습니다.

최초 선택 Provider와 실제 응답 Provider 분리하기#

이처럼 Fallback을 사용하면 처음 선택한 Provider와 실제 응답을 만든 Provider가 달라질 수 있습니다. 그래서 사용량 레코드에 최초 설정값만 기록하면 OpenAI에서 실패한 뒤 Anthropic이 응답한 요청도 OpenAI 사용량으로 남기 때문에, Provider 호출이 성공한 직후 실제 Provider를 LLMExecutionContext에 기록했습니다.

java
try {
    T result = executor.execute();
 
    String provider =
        providerExtractor.extractActualProvider(preferredProviders);
    String model =
        providerExtractor.extractActualModel(preferredProviders);
 
    int responseLength = lengthCalculator.calculate(result);
 
    llmUsageService.saveUsageAsync(
        metrics.tenantId(),
        metrics.apiEndpoint(),
        provider,
        model,
        metrics.requestTextLength(),
        responseLength,
        STATUS_SUCCESS,
        null
    );
 
    return result;
} finally {
    LLMExecutionContext.clear();
}

사용량을 비동기로 전달하기 전에는 실제 Provider와 모델을 일반 값으로 꺼냅니다. LLMExecutionContext는 호출이 끝난 뒤 finally에서 제거해 재사용되는 스레드에 이전 요청의 실행 정보가 남지 않도록 했습니다.

이 값은 다음 글에서 좀더 자세히 다루고 있습니다.

tracing은 NoOp으로 격리하기#

Langfuse tracing은 LLM 응답 생성에 필요한 기능이 아닙니다. 로컬 개발이나 일부 실행 환경에서는 tracing 자체를 사용하지 않을 수 있고, 운영 중 전송에 실패하더라도 오류가 다른 기능에 전파되지 않도록 해야 했습니다.

이를 위해 비활성 환경에서는 LangfuseTraceService의 NoOp 구현을 주입했습니다.

java
@Service
@ConditionalOnProperty(
    prefix = "langfuse.trace",
    name = "enabled",
    havingValue = "false",
    matchIfMissing = true
)
public class NoOpLangfuseTraceService
        implements LangfuseTraceService {
 
    @Override
    public boolean isEnabled() {
        return false;
    }
 
    @Override
    public String createTrace(TraceRequest request) {
        return UUID.randomUUID().toString();
    }
 
    @Override
    public void recordGeneration(GenerationRequest request) {
    }
 
    @Override
    public void flush() {
    }
}

matchIfMissing = true이므로 별도 설정이 없는 환경에서도 NoOp 구현이 선택됩니다. 호출부는 동일한 LangfuseTraceService 인터페이스를 사용하여 Langfuse 전송 오류는 관측 데이터 누락으로 남기고 LLM 응답에는 전파하지 않게 했습니다.

프롬프트 조회의 경우에는 프롬프트는 응답 생성에 필요한 입력이므로 NoOp으로 생략하지 않고 Redis cache와 LKG, 기능별 기본 프롬프트를 통해 복구합니다. 이 경로는 이전 글에서 다뤘습니다.

비즈니스 서비스에서는 LLMService를 호출하고, Provider 전환과 프롬프트 fallback, tracing 활성 여부는 그 아래 계층에서 각각 처리했습니다.

마무리하며#

LLM Provider 호출에 실패하면 다음 후보로 전환하고, 모든 후보가 실패하면 오류를 명시하도록 했습니다. 반면 tracing처럼 응답 생성에 필요하지 않은 외부 연동은 NoOp과 best-effort 처리로 고객 요청에서 분리했습니다.

Fallback이 발생하면 최초 선택 Provider와 실제 응답 Provider가 달라지는데 다음 글에서는 실제 응답 Provider와 테넌트 정보를 사용량 레코드까지 전달하면서 HTTP 요청 컨텍스트 의존성을 제거한 과정을 다룹니다.

Connected Notes