오픈소스 AI 게이트웨이 New API 구조 분석
셀프 호스팅 AI 게이트웨이 New API의 소스를 요청 스케줄링, 프로토콜 변환, 과금 정산 세 가지 문제를 중심으로 구조와 구현을 설명했다.
중국어 원문을 AI로 번역했습니다. 고유명사와 수치는 원문 표기를 우선하며, 중요한 판단에는 아래 출처 원문을 함께 확인하세요.
이 글은 QuantumNous/new-api(AGPLv3, one-api에서 포크)의 2026년 10월 소스 코드를 처음부터 끝까지 읽고 정리한 것을 바탕으로 한다. 모든 코드 인용은 파일과 위치를 표기하여 저장소와 대조해 읽기 쉽게 했다. 행 번호는 이후 버전이 진화하면서 달라질 수 있으므로 함수명으로 검색하는 것이 기준이다.
셀프 호스팅 AI 게이트웨이를 분해해 보면, 실제로 해결해야 할 문제는 세 가지뿐이다. 요청을 누구에게 보낼 것인가(스케줄링), 어떻게 말해야 모든 모델이 이해할 수 있는가(프로토콜), 어떻게 돈을 정확히 계산할 것인가(과금)이다.
세상에 나온 New API 관련 글들은 대부분 어떻게 배포하고 어떻게 채널을 설정하는지를 다룬다. 이 글은 그런 이야기를 하지 않는다. 나는 저장소 전체를 처음부터 끝까지 읽었고, 소스 코드에서 가장 배울 만한 메커니즘 일곱 묶음을 골라 "한 번의 요청이 문을 들어와 정산되기까지"의 순서로 명확히 설명한다. 다 읽고 나면 알게 될 것이다. abilities 역인덱스, RelayKit 별형 변환, 선차감—정산—환불이라는 이 장부를 이해했다면, 이 프로젝트의 엔지니어링 의사결정 절반을 이해한 셈이다.
먼저 네 개의 숫자로 감각을 잡아 보자.
- 47 : 채널 어댑터 구현 수. OpenAI / Claude / Gemini / Azure / Bedrock / Vertex 등 40여 개 이상의 벤더를 커버한다.
- 4 : 네이티브 상호 변환 프로토콜 수——OpenAI Chat, OpenAI Responses, Anthropic Messages, Gemini.
- 500,000 : 내부 단가 환산(500K quota = 1달러). 모든 장부의 분자와 분모가 바로 이것이다.
- 60s : 다중 노드 배포 시 설정이 노드 간에 동기화되는 주기——설정을 한 번 바꾸면 1분 이내에 전체 클러스터에 적용되며 재시작이 필요 없다.
一. 전체 아키텍처: 삼단식 계층 + 하나의 스토리지 미들웨어 벨트
전체 시스템은 전형적인 "관리면 + 데이터면" 이중 평면 게이트웨이다. 관리면(/api)은 콘솔 조작을 담당하고, 데이터면(/v1, /v1beta, /v1/tasks)은 AI 클라이언트에 대한 중계 포워딩을 담당한다.
따로 짚어 볼 만한 세 가지 엔지니어링 의사결정이 있다.
- 단일 바이너리 배포. 프런트엔드 React 빌드 산출물은 go:embed를 거쳐 Go 실행 파일에 직접 컴파일된다——docker run으로 바로 시작되며, Electron 데스크톱 버전도 같은 바이너리를 spawn할 뿐이다.
- 프로토콜 변환과 토큰 계수를 독립 모듈로 분리. relaykit/과 tokenkit/ 두 Go module은 호스트에 역으로 의존하는 것이 명시적으로 금지되어 있어, 개별 빌드와 개별 재사용이 가능하다. relaykit은 런타임에 uuid / lo / gjson / sjson 네 개의 라이브러리에만 의존한다.
- 스토리지를 네 개의 역할로 나눴으며, 하나의 데이터베이스로 모든 것을 처리하지 않는다. 프로세스 내 캐시는 라우팅과 가격 책정을 관리하고(60초마다 마스터 DB에서 전체를 재구축), 마스터 데이터베이스는 비즈니스 테이블을 관리하며, 로그 DB는 독립 배포가 가능하고(헤비 유저는 ClickHouse, 월 단위 파티션 + TTL), Redis는 노드 간 장부와 브로드캐스트를 담당한다. 아래 과금 부분에서 마지막 이 부분을 펼쳐서 설명한다.
二. 한 번의 요청이 거치는 전체 여정
이것이 전문의 메인 라인이다. 한 번의 POST /v1/chat/completions가 문을 들어와 정산되기까지 일곱 단계다.
01 TokenAuth 다중 프로토콜 자격 증명 통일 → Redis에서 토큰 우선 조회 → IP 화이트리스트 → 사용자 컨텍스트 주입 02 레이트 리밋 총량 토큰 버킷 + 성공 수 슬라이딩 윈도(실패는 성공 할당량을 차지하지 않음) 03 Distribute model 파싱 → abilities 인덱스로 채널 선택 → 이번 라운드에 사용할 키 획득 04 선차감 tokenkit 로컬로 입력 추산 → 가격 조립 → Redis Lua 원자적 차감 05 재시도 루프 실패 시 채널 우선순위 계층 교체 → BodyStorage로 요청 본문 재생 06 어댑터 포워딩 ConvertRequest → DoApiRequest로 업스트림에 전송 07 스트리밍 정산 SSE 프레임 단위로 클라이언트에 포워딩 → 업스트림의 실제 usage에 따라 정산
전체 링크를 관통하는 설계 하나를 먼저 기억해 두자. 요청 본문은 생애 주기 전체에서 오직 한 부만 존재한다. common/body_storage.go는 반복적으로 Seek할 수 있는 저장소를 구현했는데(작은 요청은 메모리 상주, 큰 요청은 디스크에 기록), 미들웨어의 model 파싱, 어댑터의 프로토콜 변환, 패스스루 모드, 그리고 매 라운드 재시도의 "재생"이 모두 같은 저장소에서 읽는다. 외부로 나가는 요청에는 ContentLength와 GetBody도 보충해서 net/http 전송 계층 자체의 재시도도 온전히 재생할 수 있게 한다——근본적으로 "재시도에서 빈 body가 나가는" 상황을 원천 차단한다.
이 일곱 단계 그림을 기억하라. 뒤에서 설명하는 각 메커니즘은 그중 어느 한 단계의 전개일 뿐이다.
三. 채널 스케줄링: abilities 역인덱스
이것이 내가 이 프로젝트에서 가장 정독을 추천하는 설계다.
3.1 왜 "역인덱스(inverted)"라고 부르는가
channels 테이블의 자연스러운 형태는 주체에서 속성으로 가는 정방향 저장이다.
id = 1 name= "OpenAI-A" models= "gpt-4o,gpt-4o-mini,o3" group= "default,vip" id = 2 name= "Claude-B" models= "claude-3.5,claude-4" group= "default"
모델 목록은 채널의 속성이다——책의 목차처럼 챕터 순서대로 내용을 배열하는 것과 같다. 그러나 게이트웨이가 매번 던지는 질문은 그 반대다. "누가 gpt-4o를 서비스할 수 있는가?" 정방향으로 답하려면 전체 테이블을 끌어와 쉼표로 구분된 models 문자열을 한 줄씩 파싱한 뒤 필터링해야 한다. O(채널 수 × 모델 수)이고, TEXT 필드에 대한 LIKE 쿼리는 인덱스를 전혀 탈 수 없다.
New API의 방식은 모든 채널의 속성을 미리 역으로 배열하는 것이다——쿼리 키를 기본 키로 삼아 abilities 테이블로 물질화한다.
// model/ability.go type Ability struct { Group string `gorm:"type:varchar(64);primaryKey;autoIncrement:false"` Model string `gorm:"type:varchar(255);primaryKey;autoIncrement:false"` ChannelId int `gorm:"primaryKey;autoIncrement:false;index"` Enabled bool Priority *int64 `gorm:"bigint;default:0;index"` Weight uint `gorm:"default:0;index"` }
복합 기본 키 (group, model, channel_id)는 "어느 채널이 어느 그룹에서 어느 모델을 서비스할 수 있는가"라는 삼각 관계를 단 한 번의 인덱스 조회로 압축한다:
SELECT * FROM abilities WHERE `group` = ? AND model = ? AND enabled = true ;
이것이 바로 역색인(inverted index)의 사상이다. 구글 검색에서 "단어 → 그 단어를 포함한 웹페이지", Elasticsearch에서 "term → doc_ids"가 같은 것과 동일하다. 즉, 조회 키를 주 인덱스로 삼고, 다대다 관계를 물질화하며, 빈번한 역방향 조회를 위해 인덱스를 미리 구축 하는 것이다. 이것이 수지에 맞는 근본 이유는 이 데이터가 "쓰기는 적고 읽기는 많은" 데이터이기 때문이다. 채널 설정은 몇 달에 한 번 바뀌지만, 매초 수십 건의 요청이 조회를 한다. 역색인을 구축하는 함수는 같은 파일 안의 AddAbilities() 이며(Models × Group의 카테시안 곱을 청크 단위로 읽어 삽입), 채널이 변경될 때는 트랜잭션 내에서 먼저 삭제한 뒤 삽입하는 방식으로 유지한다.
3.2 선택 알고리즘: 세 가지 메커니즘
역색인 위에 있는 것은 model/channel_cache.go 의 GetRandomSatisfiedChannel 이다(Redis를 켜면 메모리 캐시가 암묵적으로 활성화되며, 전체 채널을 group2model2channels의 2단계 map으로 구축한다):
- 우선순위 계층화, 재시도는 곧 강등 . 이것은 프로젝트 전체에서 가장 기발한 코드다. 재시도 횟수가 곧바로 우선순위 계층의 인덱스로 재사용된다:
// model/ability.go · getPriority // N번째 재시도는 자동으로 N번째 계층의 우선순위를 취한다 priorities, _ := getPriority(group, model) // 중복 제거 후 내림차순 targetPriority := priorities[retry]
첫 회차는 최고 우선순위 계층에 떨어지고, 첫 재시도에서는 자동으로 다음 상위 계층으로 내려가며, 계층 수를 초과하면 최저 계층에 고정된다. auto 그룹은 더 영리하다. 각 그룹 내에서 모든 계층을 소진한 뒤에야 다음 그룹으로 전환한다. "그룹 인덱스 + 그룹 내 계층"이라는 작은 상태 머신 하나로 "동일 계층 재시도 → 계층 강등 → 그룹 간 이동"이라는 완전한 장애 강등 체인을 구현하며, 추가적인 상태 객체를 전혀 도입하지 않았다.
- 평활 가중치 랜덤 . 가중치가 모두 0이면 균등 확률이 된다. 평균 가중치가 10 미만이면 먼저 100배로 확대한 뒤 랜덤을 돌린다. 작은 가중치 채널이 정수 연산에서 반올림으로 "굶어 죽는" 것을 방지하고, 트래픽 분배가 설정에 충실하도록 보장한다.
- 세션 친화 + 다중 키 . 같은 세션은 우선 지난번에 성공한 채널을 재사용한다(컨텍스트가 흐트러지지 않도록 보장). 하나의 채널에 여러 업스트림 키를 설정할 수 있고, 라운드 로빈으로 가져다 쓰며 커서가 지속화된다. 캐시를 재구축해도 처음부터 다시 시작하지 않는다. 개별 키가 비활성화되면 상태 목록에 기록되고, 모든 키가 실패해야 채널 전체가 비활성화된다.
四、 실패 재시도 폐루프: 결정과 부작용의 분리
재시도 메인 루프는 controller/relay.go 의 Relay() 안에 있으며, getChannel() 은 루프 본체 내부에 있다. 그래서 이 프로젝트에서 "재시도"의 실제 의미는 같은 경로로 다시 보내는 것이 아니라 채널을 바꿔 다시 한 번 스케줄링하는 것이다:
for ; retryParam.GetRetry() <= common.RetryTimes; retryParam.IncreaseRetry() { channel, err := getChannel(c, info, retryParam) // ① 매 회차마다 채널 재선택 bodyStorage, _ := common.GetBodyStorage(c) // ② 요청 본문 재생 c.Request.Body = io.NopCloser(bodyStorage) newAPIError = relayHandler(c, info) // ③ 포워딩 if newAPIError == nil { return } // 성공 시 종료 decision := service.DecideRelayRetry(...) // ④ 결정 service.RecordPolicyFailure(...) // ⑤ 정책 이벤트 로그 기록 processChannelError(...) // ⑥ 부작용 if decision.Action != "retry" { break } }
스케줄링과 재시도는 맞물려 있으며, 세 가지 맞물림 지점이 있다:
- 매 회차의 루프가 곧 한 번의 스케줄링 이다. 재시도 루프는 구동기이고, 스케줄러는 매 회차의 결정 함수다.
- 하나의 공유된 숫자 : retry 는 재시도 측에서는 "몇 번째 시도인가"(시간 차원)이고, 스케줄링 측에서는 "몇 번째 계층의 우선순위를 쓰는가"(공간 차원)이다. 한 번의 인자 전달로 "재시도 즉 강등"이 완성된다.
- 실패 정보의 환류가 스케줄링의 입력을 다시 쓴다 : 매 회차 실패의 부작용은 채널을 비활성화한 뒤 abilities.enabled=false를 기록하고 메모리 캐시에서 제거한다. 다음 회차의 스케줄링(그리고 모든 동시 요청의 스케줄링)은 이 채널을 곧바로 보지 못한다. 반대도 성립한다. 채널 고정(sk-xxx-{id})이나 엄격한 세션 친화가 실패하면 DecideRelayRetry 는 곧바로 stop을 반환하며, 재시도 자격조차 없다.
DecideRelayRetry (service/relay_error.go:20) 자체는 통째로 읽어볼 가치가 있다. 99줄짜리 파일 안에서 9개의 return 분기가 우선순위대로 배열되어 있다: 엄격 세션 → 채널 고정 → 채널 수준 오류 → 재시도 불가 표시 → 횟수 소진 → 2xx 제외 → 알 수 없는 상태 코드 → 재시도 코드 적중 → 최후의 수단으로 재시도 안 함.
가장 배울 만한 점은 '결정과 부작용의 분리'다. 모든 에러 객체 NewAPIError는 메타 플래그(IsChannelError / IsSkipRetryError, relaykit/types/error.go:371/378)를 스스로 지니고 있으며, 정책 함수는 이 플래그를 읽기만 할 뿐 판단하지 않는다. 에러가 어디서 발생했든, 그 자리에서 해야 할 말을 다 해 둔다. 기본 재시도 코드 구간은 100-199, 300-399, 401-407, 409-499, 500-503, 505-523, 525-599다. 즉 429와 대부분의 5xx는 재시도(채널 교체)가 가능하고, 400, 408, 504, 524는 명확히 재시도하지 않는다(400은 클라이언트의 잘못이라 바꿔도 소용없고, 타임아웃류 에러는 링크가 느리다는 뜻이므로 즉시 채널을 바꾸는 편이 원래 경로에서 기다리는 것보다 경제적이다). 매 stop/retry 결정의 사유는 전부 요청 로그의 '정책 이벤트'에 기록된다. 문제가 생겼을 때 '당시 왜 재시도하지 않았는가'를 답할 수 있다는 것, 이것이 복기 가능성이다.
부작용은 비동기적이다. 401이 자동 비활성화 코드에 해당할 때 gopool.Go로 비동기적으로 채널을 비활성화하고(요청 스레드를 차단하지 않음), Redis Pub/Sub을 통해 각 노드에 브로드캐스트하여 해당 채널의 WebSocket 장기 연결을 끊는다.
五、어댑터 패턴: 47 Adaptors, One Contract
47개의 채널 어댑터가 동일한 Adaptor 계약(relay/channel/adapter.go)을 공유하며, relay/relay_adaptor.go:50의 GetAdaptor(apiType) switch-case로 분배된다. 설계상의 트레이드오프가 매우 영리하다. 포맷 변환(ConvertOpenAIRequest / ConvertClaudeRequest / ConvertGeminiRequest 등)은 조합 가능한 순수 함수로 만들고, 실제 네트워크 IO는 세 개의 공통 구현, 즉 relay/channel/api_request.go의 DoApiRequest (L315) / DoFormRequest (L345) / DoWssRequest (L381)에 위임한다.
그 효과는 이렇다. 새 채널을 붙일 때 URL을 어떻게 조립할지, 인증 헤더를 어떻게 설정할지, 요청 본문이 어떤 모양인지 이 세 가지만 신경 쓰면 된다. relay/channel/openai/adaptor.go:192와 relay/channel/claude/adaptor.go:116의 SetupRequestHeader를 비교해 보면, 인증 헤더(Authorization: Bearer vs x-api-key + anthropic-version)와 URL 조립 방식만 다를 뿐 구조는 똑같다는 것을 알 수 있다. 차이는 몇 개의 순수 함수로 압축되고, 나머지는 모두 공통 구현의 재사용 속에 녹아든다. claude/adaptor.go는 겨우 182줄로 '최소 완전 어댑터'의 산 교재다.
六、프로토콜 변환: OpenAI Chat은 성형 허브다
네 가지 프로토콜을 상호 변환하며, 레지스트리에는 총 12개의 변환기가 있다. 그러나 12개의 양방향 직결 메시 회선이 아니라, OpenAI Chat을 중심으로 한 성형(스타) 토폴로지다. 실제로 변환 로직을 작성한 것은 단 6개(Claude↔Chat, Gemini↔Chat, Chat↔Responses가 각 한 쌍)이고, 나머지 6개는 전부 조합이다. relaykit/relayconvert/text_converter_registry.go에서 Claude→Gemini의 등록을 보자:
ID: requestConverterClaudeToGemini, Quality: TextConverterQualityDiscouraged, Req: TextRequestSide{ StepConverters: [] string { ConverterClaudeMessagesToOpenAIChat, // 1번째 홉: Claude → Chat ConverterOpenAIChatToGeminiContent, // 2번째 홉: Chat → Gemini }, },
여기에는 자체 변환 로직이 전혀 없고, 이미 존재하는 두 개의 직결 변환기를 사슬로 이어 붙였을 뿐이다. 이것이 성형의 본질이다. n개의 프로토콜을 전부 상호 변환하려면 O(n²)개의 변환기가 필요하지만, Chat을 허브로 삼으면 O(n)개만 필요하고 나머지 경로는 조합으로 커버된다. 즉 새 프로토콜을 추가할 때는 2개(해당 프로토콜 ↔ Chat)만 작성하면 되고, 기존 경로는 자동으로 재사용된다.
왜 OpenAI Chat을 허브로 선택했는가? 그것은 클라이언트 생태계의 사실상 표준이고(대다수 SDK가 이것을 사용한다), 표현력이 가장 완전한 중간 포맷이며(도구 호출, 멀티모달, 스트리밍 델타까지 모두 매핑 가능), 게다가 Chat↔Responses가 같은 OpenAI 계열이라 이 쌍의 품질에는 good이 표시되어 있다(나머지는 fair). 대가는 두 홉에서 필드를 잃을 수 있다는 점(매 변환은 손실 번역이다)이며, 그래서 Claude↔Gemini라는 순수 두 홉 경로는 discouraged로 표시되어 있다.
구현은 네 계층으로 나뉜다(relaykit/relayconvert/, 약 2600줄):
- 레지스트리와 라우팅: 변환기는 (From, To) 기준으로 라우팅 테이블에 등록되며, 등록 시 매우 강력한 방어적 검증을 수행한다(Convert와 StepConverters는 상호 배타적, step 체인은 반드시 앞뒤가 연결되어야 함). 잘못된 변환 매트릭스는 기동 시점에 panic으로 차단된다.
- 요청 측 실행 체인(executeRequestSteps): 먼저 도구를 추출하고, 홉마다 변환한 뒤, 다시 도구를 장착한다. 도구 호출은 네 프로토콜 간 차이가 가장 큰 지점이라, 먼저 하나의 '도구 집합'으로 통일해 추출하고 변환 과정에서는 건드리지 않으며, 마지막에 목표 프로토콜에 맞춰 다시 인코딩한다. 홉마다 도구 차이를 처리하지 않도록 하기 위해서다.
- 스트리밍 응답 측 상태 머신(ResponseStreamState): 다중 홉 스트리밍은 '한 줄을 한 줄로 변환'하는 것이 아니라, 홉마다 독립 상태를 보유하고, 하나의 업스트림 이벤트가 여러 개로 증폭되거나 압축될 수 있으며, 마무리 시 보충 발행되는 종료 이벤트가 이후 단계들을 계속 흘러간다.
- 호스트 접착 계층(convmeta.Meta): 변환기 자체에는 네트워크도, 과금도 없다. 모든 세션 컨텍스트(업스트림 모델명, 사용자 설정, 추론 파라미터 상태)는 이 인터페이스를 통해 전달되며, 호스트의 RelayInfo가 이를 구현한다.
매핑할 수 없는 모든 필드는 구조화된 진단(code/path/severity)을 생성해 요청 로그에 남긴다. 손실 정책 ToolLossPolicy는 세 단계 중 선택할 수 있다: allow(기본), safe(요청 단계에서 error 등급 손실을 만나면 즉시 오류 발생), strict(어떤 손실도 거부). 업무상 민감한 시나리오에서는 strict를 켠다 — 실패하는 편이 낫지, 조용히 잃어버려서는 안 된다.
7. SSE 스트리밍 엔진: 세 개의 고루틴 + 하나의 메인 루프
스트리밍 포워딩은 게이트웨이에서 가장 사고가 나기 쉬운 지점이다. 전체 엔진은 relay/helper/stream_scanner.go(316행)에 있고, 모든 채널이 이 하나의 진입점 StreamScannerHandler(L83)를 공유한다 — 각 채널은 클로저 하나만 넘겨주며(자사 업스트림 형식을 클라이언트 형식으로 번역하는 역할), 엔진이 동시성, 타임아웃, 킵얼라이브, 연결 끊김 같은 공통 난제를 통일적으로 처리한다:
몇 가지 핵심 방어 설계:
- 끊기면 즉시 손절: 메인 루프가 클라이언트 연결 끊김을 감시하고, cleanup()에서 즉시 업스트림 resp.Body를 닫는다 — 이미 포기한 요청을 위해 계속 돈과 토큰을 태우지 않는다. 이는 장기 연결 + 과금 시나리오에서 가장 중요한 손절 설계다;
- 프레임별 쓰기 타임아웃: DataHandler는 매번 쓰기 전에 ExtendWriteDeadline으로 30초 쓰기 타임아웃을 설정한다 — 느린 클라이언트가 고루틴을 끌어죽이지 못한다;
- 쓰기 락은 요청 내부에 존재: writeMutex는 함수 지역 변수로, ping과 dataHandler가 같은 ResponseWriter에 교차로 쓰는 것만 방지하며, 요청 간에 공유되는 락은 없다;
- 고루틴 회수 엄격: cleanup은 sync.Once로 멱등성을 보장하고, wg.Wait()로 세 고루틴이 전부 종료된 후에야 gin.Context를 연결 풀에 반환한다 — 고루틴 참조가 재사용된 Context를 가리켜 엉뚱한 곳에 쓰는 것을 방지한다;
- panic 전면 커버: 세 고루틴 모두 recover를 갖추고 원인을 통일된 StreamStatus(9종 EndReason)에 기록해, 과금과 로그 복기에 활용한다.
또 하나 쉽게 간과되는 경계가 있다: 엔진 진입점은 compatible_handler.go:151의 httpResp.StatusCode != 200 검사 이후에 있다 — 업스트림이 거부(429/500)하면 클라이언트는 아직 아무 바이트도 받지 못한 상태이므로, 오류를 구성해 재시도 루프로 돌아가 채널을 바꾸는 데 비용이 전혀 들지 않는다; 그러나 일단 200으로 엔진에 진입하고 SSE 응답 헤더가 클라이언트에 기록되면, HTTP 응답이 이미 커밋되어 중간에 스트림이 끊겨도 재시도가 절대 불가능하며, EndReason만 기록하고 이미 수신한 데이터로 정산할 수밖에 없다. 이 분수령이 "재시도"와 "스트리밍"이라는 두 세계를 깔끔하게 갈라놓는다.
8. 과금 체계: 예약 차감 — 정산 — 환불
과금은 하나의 완전한 생명주기이며, 코드 삼각형은 다음과 같다: relay/helper/price.go(예약 차감 추정), service/text_quota.go(실제 계산), service/billing.go(차액 정산).
8.1 예약 차감: 하나의 보증금
요청이 재시도 루프에 진입하기 전에, PrepareRequestBilling(relay/request_billing.go:25)이 네 가지 일을 한다: 민감어 검사 → tokenkit으로 입력 토큰을 로컬 추정(OpenAI 계열은 tiktoken으로 정확히 카운트, 나머지 계열은 10가지 텍스트 특징 가중치로 추정) → 가격 책정 조립 → 쿼터 예약 차감:
예약 차감 = 추정 입력 토큰 × 예약 차감 배율 × ModelRatio × GroupRatio // 사용량 과금 예약 차감 = ModelPrice × QuotaPerUnit × GroupRatio // 건당 과금
예약 차감은 입력만 계산한다(출력은 미지)는 점에 유의하라. 이는 의도된 보수적 추정이다 — 보증금일 뿐 최종 청구서가 아니다. 세 가지 특수 처리: 잔액이 "신뢰 한도"(기본 $10)보다 높은 사용자는 예약 차감을 건너뛰고(고동시성에서의 원장 부하 최적화, 정산 시 일괄 보정), 무료 모델은 원장 전체 흐름을 건너뛰며, 계단식 표현식 과금은 예약 차감 시 계산 스냅샷(표현식 해시, 적용 구간, 그룹 배율)을 고정하고 재시도로 채널을 바꿔도 같은 스냅샷을 재사용해 한 번의 요청에 하나의 규칙 세트로만 비용을 계산하도록 보장한다.
예약 차감의 물리적 구현은 Redis Lua 스크립트(model/quota_reserve.go:19)다 — "잔액 검사"와 "차감"이 같은 스크립트 안에서 원자적으로 완료된다:
if HGET key 'Quota' < amount then return 0 end -- 부족 → 명확히 거부 HINCRBY key 'Quota' -amount -- 원자적 차감
8.2 정산: 전체 재계산 + 차액 보정
응답이 완료되면, calculateTextQuotaSummary(text_quota.go:227)가 업스트림이 반환한 실제 usage로 전체 청구서를 처음부터 다시 계산한다(전 구간 shopspring/decimal로 부동소수점 드리프트 방지):
실제 비용 = (입력 상세 + 출력 × CompletionRatio) × ModelRatio × GroupRatio 입력 상세 = 기본 token + 캐시 읽기 × CacheRatio // gpt-4o=0.5, claude=0.1 + 캐시 쓰기(5m/1h) × 구간별 배율 // Claude 장기 캐시는 더 비쌈 + 이미지 token × ImageRatio + Gemini 오디오 독립 입력가
반드시 처리해야 할 의미상 함정 하나: Claude와 OpenAI의 usage 기준이 다르다 — OpenAI의 prompt_tokens는 캐시 부분을 포함하므로(제외한 뒤 캐시 가격으로 따로 계산해야 함), Claude의 input_tokens는 원래 캐시를 포함하지 않는다(빼서는 안 됨). 코드에서 IsClaudeUsageSemantic의 두 분기(L305/L314)가 바로 이를 위한 것으로, 이 분기를 틀리면 캐시 비용이 중복 계산된다. OpenAI의 cache-write는 조정되지 않은 프리픽스 수를 보고해 기본량이 음수가 되기도 하는데 — L344에 0으로 clamp하는 방어가 한 군데 있다.
다음은 차액 정산(billing.go:52)이다. delta = 실제 비용 − 예치값이며, 양수면 추가 차감, 음수면 환불, 0이면 그대로 둔다. 정산 시 입력 부분도 다시 계산된다는 점에 주목해야 한다. 즉 "예치된 입력 요금은 그대로 두고 출력 요금만 보충한다"가 아니라, 전체 주문이 하나의 원자적 계정이며 예치는 일시적인 보증금일 뿐이다. 호텔에 비유하면 예치는 프런트에서 긁은 사전 승인이요, 정산은 체크아웃 시 실제 소비(객실료 + 미니바 + 지연 체크아웃 요금)에 따라 발행되는 전액 청구서이며, 사전 승인은 더 내고 덜 돌려받는 방식이다.
정산의 2단계 커밋(BillingSession.Settle)은 이중 환불을 막는다. 자금을 먼저 커밋하고 fundingSettled를 세우며, 이후 토큰 조정이 실패하면 로그만 남기고, settled 플래그가 이후 Refund를 즉시 단락시킨다. 즉 자금이 이미 정산된 세션은 영원히 다시 환불될 수 없다. 전면 실패 경로의 Refund는 멱등(플래그 비트) + 비동기(코루틴 풀) + 재시도 불가(지갑 환불은 멱등하지 않으므로, 차라리 환불 누락을 로그에 남겨 가시화할지언정 중복 환불은 안 된다)이다.
8.3 Redis는 캐시가 아니라 권위 있는 원장이다
이는 과금 체계에서 가장 깊이 다룰 만한 계층이다. 대표적인 함수 DecreaseUserQuota(model/user.go:1387)를 보자.
func DecreaseUserQuota (id, quota int , db bool ) error { gopool.Go( func () { cacheDecrUserQuota(id, quota) }) // ① Redis 즉시 반영 if !db && common.BatchUpdateEnabled { addNewRecord(BatchUpdateTypeUserQuota, id, -quota) // ② DB는 5초 후 집계로 따라잡음 return nil } return decreaseUserQuota(id, quota) }
예치/정산은 항상 Redis를 먼저 고친다(밀리초 수준). 데이터베이스는 BatchUpdater가 5초 후 따라잡는다. 이 5초 창 안에서 잔액 검사와 원자적 예치는 모두 Redis를 기준으로 삼는다. 두 개의 동시 요청이 DB 지연 때문에 이중 차감되는 일은 없다. Redis를 쓸 수 없을 때는 DB 조건부 갱신(UPDATE ... WHERE quota >= ? , 영향 행 수가 곧 성패)으로 강등되어 레이스 없는 상태를 유지한다.
이에 딸린 세부 사항은 모두 "원장은 틀리면 안 된다"를 중심으로 설계되었다.
- 충전도 캐시를 보충해야 한다(syncCreditUserQuotaCache): 신용이 캐시를 우회하면 새로 충전한 한도가 캐시 만료 전까지 "보이지 않아" 잔액 부족으로 오판된다. 차변과 대변은 반드시 같은 원장을 거쳐야 한다.
- fence 변경 울타리(token_cache.go:28): 토큰 변경을 DB에 쓰기 전에 SET fence EX 10s 후 캐시를 삭제하면, 변경 전 스냅샷을 들고 있는 리더가 캐시 삭제 직후 옛 값을 곧바로 되쓸 수 없다. 10초가 "DB 쓰기 + 리더의 읽기-수정-쓰기" 창을 덮는다.
- auth version 바닥: 비밀번호 변경 등 제한적 변경에는 단조 증가 버전 번호로 더 강한 울타리를 친다. 구 세션 스냅샷은 결코 재인가될 수 없다. 이는 보안 속성이라 시간형 울타리로는 덮을 수 없다.
- live hash는 TTL만 갱신하고 필드는 절대 덮어쓰지 않는다: 어떤 DB 스냅샷도 핫 해시의 RemainQuota를 덮어쓸 수 없다. 예치는 Redis를 먼저 고치므로 DB 스냅샷이 반드시 더 오래되었고, 한 번 덮어쓰면 차감 한 번을 잃는다.
8.4 돈이 모자라 차감할 수 없으면: 세 가지 방어선 + 하나의 철학
정산 보정액이 잔액을 초과할 때 WalletFunding.Settle에는 실패 분기가 없다. 무조건 차감하며 잔액은 음수가 된다. 이는 누락이 아니다. 미납을 초래할 수 있는 모든 경로는 이미 사전에 막혀 있거나(입장 검사 403, 예치 원자적 거부, 이미지 작업 진행 중 보충 차감), 설계상 의도적으로 허용된 것이다. reserveFunding의 코드 주석은 아주 노골적으로 적혀 있다.
전액을 무조건 차감하고, 잔액이 부족한 부분은 미납으로 기록하며(잔액은 음수가 될 수 있음), 요청을 중단하지 않아 로그에 기록된 예치额度와 사용자 잔액의 실제 변동이 항상 대사 일치하도록 보장한다.
주요 원인은 신뢰 우회(잔액이 충분해 예치를 건너뛴 주문이 실제로 초과 지출된 경우)와 스트리밍 응답의 중도 번복 불가(사용자에게 반쯤 된 답변을 보낸 뒤 "잔액 부족"을 끼워 넣을 수는 없다)이다. 미납 이후의 처리: 잔액은 음수로 표시되고, 다음 요청은 입장 즉시 403이 되며, 감사 로그로 추적할 수 있다. 미납 상한은 자연히 "단일 요청 최대 비용"으로 상한이 정해진다. 이 절충은 세 가지를 지켜낸다. 요청 경로 처리량(블로킹 없음), 계정 대사 일관성(이중 차감·누락 없음), 사용자 경험(시작된 응답은 끊기지 않음). 한 건의 미납을 감수할지언정 모든 요청을 예치에서 블로킹하지는 않는다.
九、인증과 접근 제어: 이중 평면 + 이중 카운터
데이터 평면(/v1/*)은 TokenAuth(middleware/auth.go:528)를 거친다. 첫 단계는 다중 프로토콜 자격 증명 정규화다. Claude SDK의 x-api-key, Gemini SDK의 ?key=, Realtime WebSocket의 Sec-WebSocket-Protocol: openai-insecure-api-key.{key}, MJ 프록시의 mj-api-secret이 모두 식별되어 표준 Bearer로 통일 개서된다. 게이트웨이가 발급한 Key 하나로 네 가지 프로토콜 SDK를 모두 커버한다. 이어서 토큰 조회(Redis 캐시 키는 Key의 HMAC, 평문 키는 Redis에 남지 않음) → IP CIDR 화이트리스트 → 사용자 캐시 → 2단계 그룹 결정(토큰 그룹은 반드시 사용자 그룹의 사용 가능 집합 안에 있어야 하며, 상위 그룹을 무단 사용하는 것을 막는다) → 모델 화이트리스트, auto 그룹, 채널 고정 제약 등의 컨텍스트 주입이 이루어진다. sk-xxx-{channelId} 채널 고정 접미사는 관리자만 사용할 수 있다.
제어 평면(/api)은 3계층 자격 증명이다. PAT(데이터베이스에는 SHA-256 지문만 저장, 라우트 레벨 scope 선언표는 fail-closed로 선언되지 않은 라우트는 일괄 거부), 세션(단기 JWT + 리프레시 Cookie + auth_version 승격으로 구 세션 무효화). 세밀한 권한은 Casbin으로 처리하지만, 모델은 의도적으로 극히 단순하다. 순수 ACL에 역할 상속 구간이 없고, 역할은 subject 문자열에 인코딩되며, 판정 체인은 root 단락 → 미등록 권한 거부 → 사용자 레벨 명시적 재정의(명시적 거부가 종국) → 역할 기준선 폴백 순이다. 권한 리소스는 코드 레지스트리로 선언되고, 다중 노드는 StartPolicySync가 주기적으로 ReloadPolicy를 수행해 수렴을 보장한다.
레이트 리밋은 인증 이후, 채널 선택 이전에 이루어진다. 모델 요청 레이트 리밋은 두 개의 독립적인 카운터를 유지한다. 전체 수 제한은 토큰 버킷(버스트 허용, 속도 평활화)을 쓰고, 성공 수 제한은 List를 이용한 근사 슬라이딩 윈도를 쓴다. 그리고 오직 응답이 성공했을 때만 카운트하며, 실패는 성공 쿼터를 잡아먹지 않는다(429/타임아웃은 애초에 업스트림 성공 쿼터를 소모하지 않으므로, 이를 카운트에 넣는 것은 자기 자신과 싸우는 셈이다). Redis 모드에서는 전체 노드가 카운트를 공유하고, 메모리 모드에서는 노드마다 독립적이므로 배포 시 토폴로지에 따라 총액을 평가해야 한다.
왜 레이트 리밋이 필요한가? 다섯 계층의 이유 중 가장 핵심적인 두 가지는 업스트림 쿼터 보호(단일 사용자가 당신이 돈을 주고 산 업스트림 자원을 다 태워버리는 것을 방지하여 "공용 풀"을 "인당 할당 쿼터"로 바꾸는 것)와 비용 폭주 방어(LLM은 토큰 단위로 과금되고 출력에 상한이 없어, 잘못 작성된 무한 루프 스크립트 하나가 1분 만에 한 달 예산을 태워버릴 수 있다. 레이트 리밋은 청구서 바깥의 첫 번째 수문이다)이다.
10. 세 가지 엔지니어링 철학
마지막으로, 소스 코드에서 곧바로 자신의 프로젝트로 가져갈 수 있는 세 가지 엔지니어링 시사점을 골라본다.
1. 단일 복사본 재생 가능. BodyStorage의 Seek 가능한 저장 하나가 인증, 변환, 그대로 전달, 매 회차 재시도를 관통하며, 외부로 나가는 요청에는 ContentLength와 GetBody를 보충한다. 그로 인한 직접적 이점은 재시도 시 제로 카피, 의미의 절대적 일관성(재시도로 나간 요청 본문이 첫 번째와 완전히 동일함)이다. 많은 경우 "데이터를 한 번만 만들고, 사방에서 읽기 전용으로만 쓴다"가 사방에서 깊은 복사를 하는 것보다 훨씬 저렴하고 훨씬 안전하다.
2. 재시도가 곧 강등. 재시도 횟수를 우선순위 계층 인덱스로 재사용하여, 한 번의 인자 전달로 "동일 계층 재시도 → 계층 강등 → 그룹 간 이동"이라는 완전한 장애 강등 사슬을 구현한다. 반드시 모든 상태에 새로운 객체가 필요한 것은 아니다. 이미 존재하는 두 개의 양(시도 횟수, 계층 인덱스)의 의미를 서로 통하게 하면 복잡성이 사라진다.
3. 강·약 일관성 분리. 설정류(options, 채널 라우팅)는 최종 일관성(60초 전량 재구축, 재시작 불필요)으로, 장부류(잔액)는 강한 일관성(Redis Lua 원자 연산, DB 비동기 따라잡기)으로 처리한다. 일괄적으로 분산 트랜잭션을 도입하는 대신 데이터 성질에 따라 일관성 등급을 분리하는 것이다. 윈도 기간 동안 Redis를 권위로 삼아 처리량과 정확성을 동시에 얻는다.
결론
New API의 본질은 "다중 업스트림, 다중 프로토콜, 다중 포맷"이라는 분산 문제를 채널 스케줄링, 프로토콜 변환, 과금 장부라는 독립적으로 진화 가능한 세 가지 주선으로 쪼개고, 다시 하나의 재생 가능한 요청 본문과 한 세트의 재검토 가능한 재시도 전략으로 그것들을 엮어 고가용성의 데이터 플레인으로 만드는 것이다.
시간이 한정되어 코드 세 군데만 읽는다면, 이 순서대로 읽으라: model/ability.go의 getPriority + service/channel_select.go의 RetryParam("재시도가 곧 강등"의 이해); relay/helper/stream_scanner.go 전문 316줄(스트리밍의 모든 함정의 이해); model/quota_reserve.go의 Lua 스크립트 + service/billing_session.go의 Settle/Refund(장부의 정확성이 어디서 오는지의 이해).