게이트웨이의 보장은 게이트웨이의 속성이 아니다. 호출자가 고른 전송 방식의 속성이고, 실시간에 가까워질수록 한 단계씩 약해진다. 평범한 request-response 호출은 모델이 실제로 반환한 usage 블록으로 계량된다. 같은 호출에 stream: true를 붙이면 정책이 뭐라 하든 prompt tokens는 추정되고, completion tokens도 추정된다. WebSocket 연결은 한번 맺어지면 클라이언트와 backend 사이에 일대일로 고정되므로, backend 사이의 load balancing 자체가 불가능하다. 2부는 인프라를 세웠다. 이번 편은 그 위에 API를 올리는데, 그 API의 정직한 버전은 각 호출자에게 방금 어떤 보장을 포기했는지 말해 주는 버전이다.

소비자 팀이 알아야 하는 전부

정확히 세 가지다. base URL 하나, 클라이언트 자격 증명 하나, 그리고 Azure가 아니라 그들에게 의미 있는 모델 이름 하나. 배포, 리전, provider, 그중 무엇이 지난 화요일에 바뀌었는지에 대해서는 아무것도 몰라야 한다. 그것이 계약의 전부이고, 이번 편의 모든 것은 계약 뒤의 것들이 움직이는 동안에도 그 계약을 참으로 유지하기 위해 존재한다.

API Management의 unified model API는 정확히 이것을 위해 만들어졌다. 클라이언트는 OpenAI Chat Completions 형식으로 말하고, 게이트웨이는 대상 모델이 실제로 쓰는 backend 형식으로 번역한다. 지원하는 backend API 형식은 OpenAI Chat Completions와 Anthropic Messages API 둘이고, 따로 이름 붙일 가치가 있는 일 네 가지를 한다. 클라이언트 대면 형식을 backend와 독립적으로 표준화하고, provider 전반에 하나의 거버넌스 정책 집합을 적용하고, provider 사이의 failover를 구성하고, alias로 클라이언트 대면 모델 이름을 backend 모델 이름에서 분리한다.

여기서 가장 중요한 부분이 alias이고, 2부의 직접적인 연장이다. NoAutoUpgrade와 함께 gpt-4.1 버전 2025-04-14에 고정된 모델 배포는 어떤 애플리케이션도 그 이름을 직접 부르지 않을 때만 쓸모가 있다. 애플리케이션은 chat-default를 호출한다. alias가 chat-default를 배포에 매핑하고, 그 매핑이 바뀌는 날은 다섯 팀에 걸친 조율된 릴리스가 아니라 풀 리퀘스트에서 리뷰되는 게이트웨이 구성 변경이다.

두 가지 주의 사항은 지원 티켓이 아니라 여기에 적혀야 한다. unified model API는 프리뷰 상태로 순차 배포 중이고, 클래식 티어에서는 인스턴스별 업데이트 설정인 AI Gateway Early 릴리스 채널을 통해 조기 접근이 이루어진다. 그리고 지원하는 두 backend 형식이 이 회사가 운영하는 모든 것을 덮지는 못한다. Microsoft Foundry의 Mistral과 Llama 배포는 OpenAI 호환 chat completions 표면을 통해 접근하고, unified API는 그것을 OpenAI 형식 backend로 앞단에 세울 수 있지만, 이는 네이티브 Anthropic 번역과는 다른 통합 경로다. 누군가 새벽 2시에 형식 불일치를 디버깅하기 전에, 어떤 모델이 어떤 경로로 도착하는지 적어 둘 가치가 있다.

라우팅: priority 그룹이 먼저, weight는 그다음

alias 뒤에는 backend 하나가 아니라 backend pool이 있다. API Management의 pool은 round-robin, 가중치 기반, priority 기반, 세션 인지 분배를 지원하고, 모델 트래픽에 유용한 패턴은 앞의 두 가지를 결합한다. 받을 수 있는 만큼 전부 흡수해야 하는 Provisioned Throughput 배포의 priority 그룹 하나, 그리고 첫 그룹이 가용하지 않을 때만 트래픽을 보는 더 낮은 priority의 종량제 배포 그룹 하나. 그룹 안에서는 weight가 부하를 나눈다.

pool은 포털, REST API, 또는 ARM이나 Bicep 템플릿으로 구성한다. 2부는 Terraform을 골랐고, 이것이 정확히 그 편이 azapi 탈출구의 정당한 경우로 묘사한 상황이다. 리소스 하나가 날것의 ARM 바디를 들고 azurerm 리소스들 옆에 앉아 ID로 그것들을 참조한다.

resource "azapi_resource" "chat_pool" {
  type      = "Microsoft.ApiManagement/service/backends@2023-09-01-preview"
  name      = "chat-pool"
  parent_id = azurerm_api_management.gw.id

  # azurerm has no backend-pool schema yet. Revisit when it does.
  body = {
    properties = {
      description = "PTU first, PAYG overflow"
      type        = "Pool"
      pool = {
        services = [
          { id = azapi_resource.be_ptu_west.id,  priority = "1", weight = "1" },
          { id = azapi_resource.be_payg_west.id, priority = "2", weight = "3" },
          { id = azapi_resource.be_payg_north.id, priority = "2", weight = "1" },
        ]
      }
    }
  }
}

priority 1은 PTU 배포다. 이미 시간 단위로 값을 치렀으므로, 그것이 서빙할 수 있는 모든 토큰은 추가 비용이 없는 토큰이다. priority 2는 priority 1이 받지 못할 때만 트래픽을 받고, 두 리전 사이의 3대 1 weight는 선호가 아니라 쿼터를 반영한다. 이것은 라우팅으로 표현된 비용 토폴로지이고, 아키텍처 다이어그램에서는 보이지 않다가 청구서에서 명백해지는 종류의 것이다.

circuit breaker, 그리고 어디까지 믿을 것인가

실패하고 있는 pool 멤버는 사람이 결정하지 않아도 트래픽을 그만 받아야 한다. API Management는 circuit breaker를 backend의 속성으로 노출한다. 규칙이 구간 안의 실패 횟수나 비율, 실패로 집계할 상태 코드 범위를 정의하고, breaker가 열리면 게이트웨이는 trip 지속 시간 동안 그 backend 호출을 멈추고 클라이언트에 503 Service Unavailable을 반환한다. backend가 Retry-After 헤더를 보냈다면 breaker가 그 값을 받아들여 정확히 그만큼 기다릴 수 있는데, rate limit에 걸린 모델 엔드포인트에는 고정된 추측보다 훨씬 낫다.

circuitBreaker = {
  rules = [{
    name = "ptu-5xx"
    failureCondition = {
      count    = 3
      interval = "PT1H"
      statusCodeRanges = [{ min = 500, max = 599 }]
    }
    tripDuration     = "PT1H"
    acceptRetryAfter = true
  }]
}

이 기능의 두 가지 속성이 이것을 어디까지 믿고 추론해도 되는지를 결정한다. 첫째, trip 규칙은 근사치다. 게이트웨이는 분산되어 있고, 인스턴스들은 breaker 상태를 동기화하지 않으며, 각자 자기가 가진 정보로 규칙을 적용한다. 실패 세 번에 열리도록 구성한 breaker가 실제로는 스케일 아웃된 인스턴스 전체에서 상당히 더 많은 요청을 통과시킬 수 있다. 이것은 보호 메커니즘이지 카운터가 아니고, 어떤 SLO 계산도 그 정확한 임계값 위에 세워져서는 안 된다. 둘째, Consumption 티어에서는 쓸 수 없는데, 2부의 티어 결정이 비용에 관한 것이 아니었던 이유가 하나 더 늘어난 셈이다.

503은 의도적으로 설계할 가치가 있다. 게이트웨이에서 503을 받은 호출자는 모델이 죽었는지, breaker가 열렸는지, 인스턴스 전체가 비정상인지 구분할 수 없고, 셋은 서로 다른 클라이언트 동작을 요구한다. outbound 정책에서 어느 backend를 시도했는지 이름을 담은 응답 헤더를 추가하고 breaker 상태를 로깅하면, 불투명한 503이 소비자 팀이 티켓을 열지 않고도 대응할 수 있는 무언가로 바뀐다.

Streaming, 그리고 그 대가로 치르는 회계

모든 소비자 팀이 streaming을 요구하고, 그들이 옳다. 사람이 체감하는 숫자는 첫 토큰까지의 시간이고, 대시보드가 보여 주는 숫자는 전체 지연이다. 같은 HTTP 엔드포인트 위의 server-sent events가 표준 답이고, 클라이언트는 stream: true를 설정하며, 게이트웨이의 라우팅은 아무것도 바뀌지 않는다.

바뀌는 것은 계량기다. 토큰 한도 정책은 보통 실제 usage로 동작한다. estimate-prompt-tokens="false"면 모델 응답의 usage 섹션을 읽는데, 이는 요청이 한도를 넘고 나서 사후에 감지되어 윈도가 리셋될 때까지 후속 요청이 차단된다는 뜻이다. 추정을 켜면 호출 전에 API 정의의 스키마로 prompt tokens를 세는데, 한도를 넘었다는 사실을 알아내려고 backend 쿼터를 태우지 않는 대신 정확도를 약간 내준다.

streaming은 그 선택지를 없앤다. stream: true가 설정되면 정책 설정과 무관하게 prompt tokens는 항상 추정되고, completion tokens도 추정된다. 스트리밍된 요청을 게이트웨이에서 실측값으로 계량하게 만드는 구성은 존재하지 않는다. 이미지를 받는 모델에서는 드리프트가 겹친다. streaming이 켜져 있거나 추정이 켜져 있으면, 정책은 backend의 집계를 받는 대신 이미지 토큰을 과대 계산한다.

이것은 streaming을 거절할 이유가 아니다. 숫자가 무엇을 의미하는지 정확히 해 둘 이유다. 5부가 그 숫자를 돈으로 바꾸기 때문이다:

  • 스트리밍 없는 HTTP: 모델 자신의 usage로 계량. 정확하다.
  • 스트리밍된 SSE: prompt와 completion tokens를 게이트웨이에서 추정. rate limiting에는 충분하고, 과금에는 근사치다.
  • WebSocket: 아래 참조. 사실상 대역 밖이다.

컨트롤 플레인의 답은 더 나은 추정이 아니라 reconciliation이다. 게이트웨이 메트릭은 쿼터를 강제하는 실시간 신호로 남고, 5부가 만드는 월간 귀속 작업이 provider가 보고하는 권위 있는 usage를 기준으로 보정하며, 그 차이는 별도의 메트릭으로 추적된다. 커지는 드리프트는 버그다. 몇 퍼센트에서 평평하게 유지되는 드리프트는 streaming의 비용이고, 제품 오너가 발견하게 두는 대신 chargeback 문서에 적어 두어야 한다.

WebSocket, 추상화가 끝나는 곳

실시간 음성과 speech-to-speech 트래픽은 request-response에 맞지 않고, API Management는 WebSocket API를 지원한다. 그 제약은 아키텍처를 장식하는 수준이 아니라 아키텍처를 바꿀 만큼 날카롭다.

WebSocket 연결은 여러 backend에 분산하거나 load balancing할 수 없다. 한번 맺어지면 각 연결은 클라이언트와 backend 사이에 일대일로 유지된다. 이 글의 모든 라우팅 결정, priority 그룹, PTU 우선 토폴로지, 가중치를 준 리전 분할은 최초 handshake에만 적용되고 그 이후에는 아무것에도 적용되지 않는다. 세션 중간에 성능이 저하된 backend는 게이트웨이가 failover할 수 없다. 세션이 끝나야 하고 클라이언트가 재연결해야 한다. 이 경로의 모든 클라이언트에는 backoff를 갖춘 재연결 로직이 필요하고, 모든 용량 모델은 WebSocket 세션을 독립적으로 라우팅 가능한 요청의 흐름이 아니라 고정된 예약으로 취급해야 한다.

더 작은 제약 세 가지가 따라온다. 캐싱, CORS, 바디 조작, 모든 validate-* 정책을 포함한 긴 목록의 정책을 onHandshake 오퍼레이션에 적용할 수 없고, 전역이나 product 스코프에서 상속된 정책은 시끄럽게 실패하는 대신 런타임에 건너뛰어진다. set-header 정책은 handshake 요청에서 Host를 포함한 특정 잘 알려진 헤더를 바꿀 수 없다. 그리고 TLS 검증이 HTTP API보다 엄격하다. WebSocket backend에 대해 게이트웨이는 서버 인증서가 신뢰되는지 그리고 subject 이름이 호스트명과 일치하는지를 검증하는데, HTTP API는 신뢰만 요구한다. 인증서 subject가 어긋난 채로 HTTP에서는 몇 년을 잘 돌던 backend가 WebSocket API로 앞단에 세워지는 순간 실패한다.

실질적인 귀결은 이렇다. 실시간 경로는 자체 API, 자체 product, 자체 쿼터를 갖고, 소비자 문서는 chat 엔드포인트가 가진 failover 보장이 이 경로에는 적용되지 않는다고 분명하게 말한다. 그렇지 않은 척하는 것이 인시던트 리뷰가 그 차이를 발견하게 되는 경위다.

session affinity, 그리고 필요 없게 설계해야 하는 이유

pool은 쿠키를 통한 세션 인지를 지원한다. 클라이언트가 Set-Cookie 값을 저장했다가 돌려보내면 후속 요청이 같은 backend에 도달한다. 이것은 상태를 가진 서버 측 API를 위해 존재하며, Assistants API가 정식 예다. 클라이언트가 세션 ID를 보관하고, 응답 바디에서 스레드 ID를 추출하고, 맞는 호출에 맞는 쿠키를 보내야 한다.

동작은 한다. 그리고 설계 시점에 피할 가치가 있는 제약이다. session affinity가 걸린 호출은 재라우팅할 수 없는 호출이고, 그래서 WebSocket 경로와 같은 범주에 들어간다. failover가 없고, backend의 부하가 도착한 요청 수가 아니라 어떤 세션이 거기 내려앉았는지에 좌우되는 용량 모델이다. 상태를 가진 API가 정말로 필요한 곳에서는 별도의 alias에 두어, 그 약한 보장이 나머지 볼륨을 이루는 무상태 트래픽에 조용히 상속되지 않게 한다.

지켜봐야 할 실패 모드

  • 네 가지 다른 뜻을 가진 503. breaker 열림, pool 멤버 전원 소진, 게이트웨이 비정상, 아니면 정말로 실패하는 backend. 어느 쪽인지 이름을 담은 헤더나 로그 한 줄이 없으면, 넷 모두가 티켓이 된다.
  • 버그로 취급된 streaming 드리프트. 문서화된 동작이다. 실패는 드리프트가 아니라, 추정된 숫자를 청구서라고 적힌 자리에 게시하는 것이다.
  • 크리티컬 패스에 있는 프리뷰 기능. unified model API는 프리뷰다. 그것이 번역 레이어로 용인되는 것은, 같은 product와 정책 뒤에 놓인 provider별 직접 API라는 폴백이 인시던트 중에 설계되는 대신 이미 존재하고 테스트되어 있을 때뿐이다.
  • WebSocket API에서 조용히 건너뛰어지는 정책. onHandshake에서 지원되지 않는 상속 정책은 런타임에 건너뛰어진다. 전역이라고 가정한 보안 통제가 여기서는 전역이 아니고, 아무것도 알려 주지 않는다.

4부가 물려받는 것

이번 편이 끝난 시점에 소비자 팀은 하나의 엔드포인트, 하나의 형식, 안정적인 모델 alias를 갖고, 그 뒤에는 라우팅과 failover가, 그리고 어떤 보장이 어떤 전송 방식에 적용되는지 문서화된 선언이 있다. 이 중 무엇도 다루지 못하는 것은 지금 당장 답이 필요하지 않은 작업이다. 첫 토큰을 기다리는 고객과 같은 동기 경로에 앉아 있을 이유가 없는, 지원 티켓의 야간 요약 같은 일이다. 그것은 큐이고, 다음 편이다.

다음으로 읽을 글

이런 플랫폼을 대규모로 운영할 때의 인프라와 플랫폼 쪽 이야기는 ercan.cloud에, 허브는 ercanermis.com에 있다.

참고 자료