블로그 목록으로
엔지니어링·2026년 5월 24일·9분 분량

Kunavo의 장애 조치 방식: 업스트림 오류와 클라이언트 사이의 4개 계층

각 숫자의 근거가 되는 상수와 함께 전체 장애 조치 경로를 설명합니다. 요청을 보내기 전에 장애가 발생한 채널을 건너뛰는 회로 차단기, 하나의 요청 안에서 비용 순서로 이루어지는 최대 세 번의 홉, 오류로 바뀌는 200 응답을 커밋하지 않는 스트림 게이트, 전혀 응답하지 않는 업스트림을 위한 4분 백스톱을 다룹니다.

읽어 본 모든 게이트웨이 게시물은 가용성 비율을 제시합니다. 이 글은 그렇지 않습니다. SLA를 공개하지 않으며, 확인할 수 없는 숫자보다 확인할 수 있는 메커니즘을 설명하는 편을 선호하기 때문입니다. 다음은 업스트림에서 오류가 반환되는 순간부터 클라이언트가 오류를 보는 순간까지의 전체 경로입니다. 네 개의 계층, 네 개의 상수, 그리고 각 상수가 그 값을 갖는 이유를 설명합니다.

단일 업스트림만으로는 충분하지 않은 이유

모델 공급자에 20분 동안 문제가 생기는 일은 흔합니다. 지역 장애, 속도 제한 강화, 새로 출시된 모델의 용량 부족 — 어느 것도 특이한 일이 아니며, 자체 오류율이 움직이기 전까지는 모두 보이지 않습니다. 애플리케이션이 하나의 엔드포인트와 통신한다면 가용성은 해당 엔드포인트의 가용성과 같고, 클라이언트에서 할 수 있는 일은 재시도뿐입니다. 그때쯤이면 사용자는 이미 기다린 뒤입니다.

대안은 다른 곳으로 요청을 보낼 수 있도록 하는 것입니다. Kunavo는 각 모델에 대해 라우팅 체인을 결정합니다. 이는 해당 모델을 제공할 수 있는 최대 3개의 독립적인 채널을 비용순으로 정렬한 것입니다. 아래의 네 계층은 개발자가 재시도 로직을 작성하지 않아도 요청이 해당 체인을 따라 이동하도록 하는 방식입니다.

계층 1: 회로 차단기(요청 전)

가장 저렴한 장애 조치는 왕복을 전혀 발생시키지 않는 것입니다. 각 게이트웨이 프로세스는 채널별로 최근 5분간의 실패 횟수를 유지합니다. 해당 기간 내에 3번 실패하면 채널이 열린 것으로 간주되며, 이후 60초 동안은 요청을 디스패치하기 전에 체인에서 제거됩니다.

provider-health.ts
// Layer 1, simplified from lib/providers/provider-health.ts.
// A channel that has been failing is skipped BEFORE we send anything,
// so the cost of avoiding it is zero — no request, no timeout, no wait.
const WINDOW_MS   = 300_000;  // failures older than 5 minutes are forgotten
const FAIL_THRESHOLD = 3;     // 3 failures inside that window opens the circuit
const COOLDOWN_MS = 60_000;   // and the channel is bypassed for 60 seconds

function isOpen(channel: string, now = Date.now()): boolean {
  const s = state.get(channel);
  return s != null && s.openUntil > now;
}

function recordFailure(channel: string, now = Date.now()) {
  const s = state.get(channel) ?? { failures: [], openUntil: 0 };
  s.failures = s.failures.filter((t) => now - t < WINDOW_MS);
  s.failures.push(now);
  if (s.failures.length >= FAIL_THRESHOLD) s.openUntil = now + COOLDOWN_MS;
}

이 계층만이 진정으로 즉각적이며, 그 이유를 정확히 말할 필요가 있습니다. 무언가를 빠르게 측정하는 것이 아닙니다. 최대 5분 전, 사용자를 대신해 실패를 감수한 이전 요청이 결정을 내린 것입니다. 상태는 프로세스별이므로 각 머신이 독립적으로 학습합니다. 실제 트래픽에서는 문제가 있는 채널이 모든 머신에서 차단되는 시점이 서로 몇 초밖에 차이 나지 않으며, 잠시만 불안정했던 채널은 쿨다운이 끝나면 자동으로 복구됩니다.

계층 2: 요청 내 장애 조치(최대 3홉)

정상으로 보이던 채널이 그래도 오류를 반환하면 요청은 체인의 다음 채널로 넘어갑니다. 한도는 ROUTING_CHAIN_MAX인 3홉이며, 체인은 비용순으로 정렬되므로 첫 시도는 항상 해당 모델을 제공할 수 있는 가장 저렴한 채널입니다.

dispatch.ts
// Layer 2. The routing chain is resolved from the catalog, cost-ordered,
// and capped at ROUTING_CHAIN_MAX hops (3). It is static configuration, not
// a learned weighting: you can read the exact chain for any model in the
// admin UI, and it is the same chain for every customer.
async function dispatch(req: ChatRequest) {
  const chain = routingChain(req.model)          // up to 3 channels, cheapest first
    .filter((c) => !isOpen(c.id));               // layer 1 removes the known-bad

  let lastError: UpstreamError | null = null;
  for (const channel of chain) {
    try {
      return await callUpstream(channel, req);
    } catch (err) {
      if (!isRetryable(err)) throw err;          // a 400 is yours, not ours
      recordFailure(channel.id, Date.now());
      lastError = err;
    }
  }
  throw lastError ?? new Error("all channels exhausted");
}

클라이언트 오류는 장애 조치 대상이 아닙니다. 잘못된 요청에 대한 400이나 존재하지 않는 모델에 대한 404는 모든 채널에서 동일한 응답이므로, 이를 3번 재시도하면 같은 메시지를 받기까지 3배 오래 기다릴 뿐입니다. 재시도 가능한 업스트림 실패만 체인을 따라 이동합니다.

이 계층의 실제 비용은 홉마다 실패한 왕복 1회입니다. 채널이 연결을 수락한 후 2초 뒤 503을 반환한다면, 두 번째 시도가 시작되기 전에 그 2초를 기다린 것입니다. 이것이 계층 1이 중요한 실제 이유입니다. 안정적으로 실패하는 채널이 모든 요청에 그 2초의 통행료를 부과하지 않도록 하기 때문입니다.

계층 3: 스트림 게이트(30초)

스트리밍은 쉽게 놓칠 수 있는 방식으로 장애 조치를 어렵게 만듭니다. 업스트림은 200 OK을 반환하고 스트림을 연 다음에야 오류 이벤트를 발생시킬 수 있습니다. 이 이벤트가 도착할 때쯤이면 단순한 프록시는 이미 클라이언트에 헤더와 일부 바이트를 보냈으므로, 더 이상 넘어갈 방법이 없습니다. 응답하지 않을 채널에 요청이 이미 묶인 것입니다.

stream-gate.ts
// Layer 3, from lib/providers/stream-gate.ts. An upstream can answer 200 OK
// and then put the error inside the stream. Committing that to your client
// means you get a truncated answer and we cannot fail over any more, so the
// gateway holds the first frames back until it knows which it is.
export const STREAM_GATE_TIMEOUT_MS = 30_000;

// Outcomes:
//   a decisive content frame  -> commit, stream the rest through untouched
//   a decisive error frame    -> discard, fall through to the next channel
//   nothing decisive in 30s   -> commit anyway (a slow model is not an error)

따라서 게이트웨이는 상태 코드만으로 커밋하지 않습니다. 결정적인 신호를 볼 때까지 시작 프레임을 보류합니다. 실제 콘텐츠가 오면 스트림을 커밋하고 그 시점부터 수정 없이 전달하며, 오류가 오면 아무것도 사용자에게 도달하지 않은 상태로 요청이 체인을 계속 내려갑니다. 이 결정을 기다리는 한도는 30초이며, 이는 첫 토큰을 내보내기 전에 실제로 오랫동안 사고하는 추론 모델을 고려한 것입니다. 그 이후에는 커밋합니다. 느린 모델이 고장 난 모델은 아니기 때문입니다.

계층 4: 헤더 시간 제한(240초)

마지막 계층은 누구도 좋아하지 않는 계층입니다. 업스트림이 연결을 수락한 후 응답 헤더를 전혀 반환하지 않으면 요청이 멈추며, 이를 끝내는 유일한 방법은 시간 제한입니다. UPSTREAM_HEADERS_TIMEOUT_MS, 4분입니다.

4분은 기다리기 긴 시간이며, 의도적인 선택입니다. 동영상과 이미지 생성에는 실제로 몇 분이 걸릴 수 있습니다. 채팅에서 즉각적으로 느껴질 만큼 짧은 시간 제한은 성공할 예정이었던 동영상 작업을 취소합니다. 멈춘 업스트림은 명시적으로 오류를 반환하는 업스트림보다 드물고, 명시적인 오류는 이미 계층 1~3에서 처리됩니다. 드문 경우에는 느리더라도 일반적인 경우에 잘못 처리하는 것보다 낫다고 판단합니다.

자체 애플리케이션에 이보다 더 짧은 한도가 필요하다면 클라이언트 측 시간 제한을 설정하세요. 당사의 시간 제한은 최후의 안전장치이지 지연 시간 목표가 아닙니다.

당사가 주장하지 않는 것

  • 밀리초 단위 장애 조치 수치는 없습니다. 계층 1은 요청 전에 결정이 내려지므로 왕복 비용이 없습니다. 계층 2~4는 실패하는 업스트림이 실패하는 데 걸린 시간만큼 정확히 비용이 듭니다. 어느 것도 마케팅 페이지에 정직하게 제시할 수 있는 숫자가 아닙니다.
  • 가용성 비율은 없습니다. SLA도 크레딧 제도도 없으므로 숫자는 장식에 불과합니다. 상태 페이지에는 실제로 관찰한 성공률이 표시되며, 성공률이 좋지 않았던 기간도 포함됩니다.
  • 모델을 조용히 대체하지 않습니다. 장애 조치는 동일한 모델을 제공하는 채널 사이에서 요청을 이동시킵니다. 요청한 모델보다 저렴한 모델로 응답하지 않습니다. 이는 응답만으로는 알 수 없으므로 모든 게이트웨이에 직접 확인할 가치가 있는 관행입니다.

이를 기반으로 구축할 때의 의미

대부분의 경우 재시도 루프를 삭제할 수 있다는 뜻입니다. 재시도 가능한 업스트림 오류는 사용자가 실패를 보기 전에 이미 독립적인 채널을 통해 재시도되므로, 당사의 5xx에 대한 클라이언트 측 재시도는 이미 3번 시도된 작업을 다시 시도하는 것입니다. 시간 제한은 유지하고 백오프는 제거하세요.

직접 처리할 가치가 있는 한 가지는 402 insufficient balance입니다. 이는 애초에 장애 조치 사례가 아니며, 아무리 재시도해도 지갑의 잔액은 충전되지 않습니다. 어떤 상태가 두 번째 시도를 할 가치가 있고 어떤 상태가 최종적인지는 오류 참조를 확인하세요.