본문으로 건너뛰기

ApiKeyPoolService 알고리즘 심층 분석

ApiKeyPoolService는 다중 API 키를 위한 로드 밸런싱 솔루션을 구현합니다: 가중 라운드 로빈 키 선택, Prompt Cache 유지를 위한 세션 수준 어피니티, 속도 제한에 대한 지수 백오프 쿨다운, 인증 실패 시 영구 비활성화를 제공합니다.


파일 위치

파일경로
ApiKeyPoolServicepackages/desktop/app/main/services/capabilities/llm/completion/ApiKeyPoolService.ts
API Key DB 작업packages/desktop/app/main/workers/db/apiKeys.ts
DB Worker 등록packages/desktop/app/main/workers/db/index.ts
DB Worker 타입packages/desktop/app/main/workers/types.ts
IPC Routerpackages/desktop/app/main/services/routers/llm/ApiKeyRouter.ts
프론트엔드 UIpackages/renderer/src/features/settings/components/provider-settings/llm/ApiKeyPoolSection.tsx

아키텍처 컨텍스트

graph TB
subgraph CompletionService
Resolve[resolveApiKeyForRequest]
Retry[retry on 429/529]
Success[reportSuccess]
end

subgraph ApiKeyPoolService
direction TB
GetKey[getKeyForSession]
GetKeyNoSession[getKey]
Report[reportError]
ReportOk[reportSuccess]
Select[selectWeightedRoundRobin]
Available[getAvailableKeys]
Cooldown[applyCooldown]
AuthFail[handleAuthFailure]
Cleanup[cleanupExpiredCooldowns<br/>every 30 seconds]
end

subgraph InMemoryStructures ["인메모리 데이터 구조"]
SB["sessionBindings<br/>Map&lt;sessionId, SessionBinding&gt;"]
RRI["rrIndex<br/>Map&lt;providerId, number&gt;"]
KC["keyCache<br/>Map&lt;providerId, ApiKeyEntry[]&gt;"]
CD["cooldowns<br/>Map&lt;keyId, KeyCooldown&gt;"]
end

subgraph ExternalDeps ["외부 의존성"]
DB[(SQLite<br/>llm_provider_api_keys)]
Loader["loadKeys(providerId)"]
Disabler["disableKey(keyId)"]
end

Resolve --> GetKey
Retry --> Report
Success --> ReportOk

GetKey --> SB
GetKey --> Available
Available --> KC
Available --> CD
KC --> Loader
Loader --> DB

Select --> RRI
Report --> Cooldown
Report --> AuthFail
AuthFail --> Disabler
Disabler --> DB
Cleanup --> CD

데이터 구조

핵심 타입

// 세션 바인딩: 세션을 특정 키에 고정
interface SessionBinding {
keyId: string; // 바인딩된 키 ID
providerId: string; // 소유 프로바이더 ID
}

// 키 쿨다운 상태
interface KeyCooldown {
until: number; // 쿨다운 만료 타임스탬프 (Date.now() + cooldownMs)
errors: number; // 연속 오류 횟수 (지수 백오프에 사용)
}

// API 키 항목 (데이터베이스에서)
interface ApiKeyEntry {
id: string; // UUID
providerId: string; // 소유 프로바이더
label?: string; // 표시 레이블 (예: "프로덕션 키 #1")
apiKey: string; // 실제 키 값 (환경 변수의 경우 $로 시작할 수 있음)
enabled: boolean; // 활성화 여부
weight: number; // 가중치 (1-100)
}

// 키 로더 함수 시그니처
type ApiKeysLoader = (providerId: string) => Promise<ApiKeyEntry[]>;

// 키 비활성화 함수 시그니처
type ApiKeyDisabler = (keyId: string) => Promise<boolean>;

// 키 리졸버 ($ 환경 변수 접두사 처리)
type ApiKeyResolver = (rawKey: string) => string;

인메모리 데이터 구조 한눈에 보기

구조타입목적생명주기
sessionBindingsMap<sessionId, SessionBinding>세션과 바인딩된 키 매핑세션 종료 시 releaseSession()으로 제거
rrIndexMap<providerId, number>프로바이더별 라운드 로빈 인덱스애플리케이션 수명 동안 유지
keyCacheMap<providerId, ApiKeyEntry[]>키 목록 캐시 (요청마다 DB 조회 방지)CRUD 작업 후 invalidateCache()로 제거
cooldownsMap<keyId, KeyCooldown>키 쿨다운 상태30초마다 만료된 항목 정리

알고리즘 및 로직

가중 라운드 로빈 알고리즘

각 키의 weight는 라운드 로빈 사이클에서 차지하는 "슬롯" 수를 결정합니다.

단계:

selectWeightedRoundRobin(providerId, keys):
1. 키가 1개만 있으면 → 바로 반환
2. totalWeight = sum(keys[i].weight) 계산
3. 라운드 로빈 인덱스 증가: idx = (rrIndex[providerId] + 1) % totalWeight
4. 새 인덱스 저장: rrIndex[providerId] = idx
5. 누적하며 순회:
accum = 0
for each key in keys:
accum += key.weight
if idx < accum:
return key
6. 폴백: keys[0] 반환

예시:

세 개의 키: A(weight=3), B(weight=1), C(weight=2), totalWeight=6

라운드 로빈 인덱스 (idx)누적값선택된 키
0A: 3A (0 < 3)
1A: 3A (1 < 3)
2A: 3A (2 < 3)
3A: 3, B: 4B (3 < 4)
4A: 3, B: 4, C: 6C (4 < 6)
5A: 3, B: 4, C: 6C (5 < 6)
0(사이클 반복)A

가중치 의미: weight=3인 키는 사이클당 3번 선택되고, weight=1인 키는 1번 선택됩니다.


세션 어피니티 (세션 바인딩)

flowchart TD
Start[getKeyForSession] --> CheckBinding{세션에 바인딩 있음?}
CheckBinding -->|예| CheckProvider{providerId 일치?}
CheckProvider -->|예| CheckAvailable{바인딩된 키 사용 가능?}
CheckAvailable -->|예| Return[바인딩된 키 반환]
CheckAvailable -->|아니오| ReBind[재바인딩]
CheckProvider -->|아니오| ReBind
CheckBinding -->|아니오| ReBind

ReBind --> GetKeys[getAvailableKeys]
GetKeys --> Empty{키 목록 비어있음?}
Empty -->|예| ReturnEmpty[빈 문자열 반환]
Empty -->|아니오| WRR[selectWeightedRoundRobin]
WRR --> Bind[sessionBindings.set]
Bind --> ReturnNew[새 키 반환]

세션 어피니티가 필요한 이유:

  • Anthropic 등의 프로바이더는 Prompt Cache를 구현합니다
  • 동일한 API Key로 요청을 보내면 캐시에 히트하여 비용과 시간을 절약합니다
  • 키를 전환하면 캐시 미스가 발생합니다
  • 따라서 단일 세션 내에서는 가능한 한 동일한 키를 사용해야 합니다

사용 불가능한 키 처리:

getKeyForSession(providerId, sessionId):
binding = sessionBindings.get(sessionId)
if binding && binding.providerId === providerId:
keys = getAvailableKeys(providerId) // 필터: enabled + 쿨다운 아님
boundKey = keys.find(k.id === binding.keyId)
if boundKey:
return resolveKey(boundKey.apiKey) // 히트: 반환
// 키 비활성화/삭제/쿨다운 중 → 재바인딩
log.info("Session key no longer available, re-binding")

// 새 키 선택 후 바인딩
keys = getAvailableKeys(providerId)
if keys.length === 0: return ''
selected = selectWeightedRoundRobin(providerId, keys)
sessionBindings.set(sessionId, { keyId: selected.id, providerId })
return resolveKey(selected.apiKey)

쿨다운 / 백오프 메커니즘

지수 백오프 공식

cooldownMs = min(DEFAULT_COOLDOWN_MS * 2^(errors - 1), MAX_COOLDOWN_MS)
연속 오류 횟수계산쿨다운 시간
160,000 * 2^060초 (1분)
260,000 * 2^1120초 (2분)
360,000 * 2^2240초 (4분)
460,000 * 2^3480초 (8분)
5+60,000 * 2^4900초 (15분 상한)

상수 설정:

상수설명
DEFAULT_COOLDOWN_MS60,000 (60초)기본 쿨다운 시간
MAX_COOLDOWN_MS900,000 (15분)최대 쿨다운 시간
COOLDOWN_MULTIPLIER2지수 밑수
정리 주기30,000 (30초)cleanupExpiredCooldowns() 실행 간격

쿨다운 적용 흐름

applyCooldown(keyId, providerId, statusCode):
current = cooldowns.get(keyId)
errors = (current?.errors ?? 0) + 1
cooldownMs = min(60_000 * 2^(errors-1), 900_000)
cooldowns.set(keyId, {
until: Date.now() + cooldownMs,
errors: errors
})

쿨다운 초기화

  • 성공한 요청에서 reportSuccess(sessionId) 호출
  • 바인딩된 키에 쿨다운 항목이 있으면 즉시 삭제

인증 실패 처리

HTTP 401 및 403 오류의 경우 키를 영구적으로 무효로 처리합니다:

flowchart TD
Error[reportError] --> Check{HTTP 상태 코드}
Check -->|429/529| Cooldown[applyCooldown<br/>지수 백오프]
Check -->|401/403| AuthFail[handleAuthFailure]
Check -->|기타| Ignore[무시]

AuthFail --> Disable[disableKey<br/>데이터베이스에서 비활성화]
Disable --> InvalidateCache[keyCache.delete<br/>캐시 초기화]

Cooldown --> Rebind[키 재선택]
InvalidateCache --> Rebind

Rebind --> HasMore{사용 가능한 키 더 있음?}
HasMore -->|예| Return[새 키 반환]
HasMore -->|아니오| ReturnNull[null 반환]

HTTP 상태 코드 분류:

상태 코드분류처리 방식
401AUTH_FAILURE키 영구 비활성화
403AUTH_FAILURE키 영구 비활성화
429RATE_LIMIT지수 백오프 쿨다운
529RATE_LIMIT지수 백오프 쿨다운 (Anthropic 과부하)
기타처리 없음, null 반환

키 가용성 필터링

getAvailableKeys(providerId):
all = getAllKeys(providerId) // 캐시 또는 데이터베이스에서 로드
now = Date.now()
return all.filter(key =>
key.enabled === true // 활성화 상태여야 함
&& !(cooldowns[key.id]?.until > now) // 쿨다운 중이 아님
)

정리 주기

cleanupExpiredCooldowns()가 30초마다 실행됩니다:

cleanupExpiredCooldowns():
now = Date.now()
for each [keyId, cd] in cooldowns:
if cd.until <= now:
cooldowns.delete(keyId)

IPC 통합 테이블

IPC 채널방향파라미터Zod 스키마설명
llmConfig:getApiKeysR → MproviderId: string없음프로바이더의 모든 키 조회
llmConfig:addApiKeyR → M{ providerId, label?, apiKey, enabled?, weight? }AddApiKeySchema키 추가 (기본값 weight=1, enabled=true)
llmConfig:updateApiKeyR → M{ id, label?, apiKey?, enabled?, weight? }UpdateApiKeySchema키 정보 수정
llmConfig:deleteApiKeyR → M{ id: string }없음키 삭제
llmConfig:toggleApiKeyR → M{ id, enabled }ToggleApiKeySchema키 활성화/비활성화

Zod 유효성 검사 규칙:

// weight 범위 제한
weight: z.number().int().min(1).max(100)

// apiKey 비어있지 않음
apiKey: z.string().min(1)

// providerId 비어있지 않음
providerId: z.string().min(1)

캐시 무효화: 모든 쓰기 작업(추가/수정/삭제/토글)이 완료되면 apiKeyPool.invalidateCache(providerId)를 호출하여 다음 요청 시 데이터베이스에서 다시 로드하도록 합니다.


확장 포인트

쿨다운 전략 조정

ApiKeyPoolService의 상수를 수정합니다:

// 더 공격적인 쿨다운 (낮은 QPS 시나리오에 적합)
private readonly DEFAULT_COOLDOWN_MS = 30_000; // 30초
private readonly MAX_COOLDOWN_MS = 5 * 60_000; // 5분

// 더 완화된 쿨다운 (높은 QPS 시나리오에 적합)
private readonly DEFAULT_COOLDOWN_MS = 120_000; // 2분
private readonly MAX_COOLDOWN_MS = 30 * 60_000; // 30분

커스텀 키 선택 전략

현재 전략은 가중 라운드 로빈입니다. 다른 방식(예: 최소 연결, 랜덤 가중치)을 사용하려면 selectWeightedRoundRobin() 메서드를 교체하세요.

환경 변수 키

$로 시작하는 키 값은 자동으로 환경 변수로 확장됩니다:

$OPENAI_API_KEY → process.env.OPENAI_API_KEY

이는 ApiKeyResolver 함수가 처리하며 CompletionService.resolveApiKey()에 구현되어 있습니다.


관련 파일

파일관계
capabilities/llm/completion/CompletionService.ts소비자: resolveApiKeyForRequest()를 통해 풀 호출
workers/db/apiKeys.ts데이터 소스: loadKeys 및 CRUD 작업 제공
workers/types.tsDB Worker 타입 정의
routers/llm/ApiKeyRouter.tsIPC 레이어: 프론트엔드 키 관리 작업
renderer/.../ApiKeyPoolSection.tsx프론트엔드 UI: 키 목록 CRUD
shared/llm-config.tsApiKeyEntry 타입 정의