ApiKeyPoolService 알고리즘 심층 분석
ApiKeyPoolService는 다중 API 키를 위한 로드 밸런싱 솔루션을 구현합니다: 가중 라운드 로빈 키 선택, Prompt Cache 유지를 위한 세션 수준 어피니티, 속도 제한에 대한 지수 백오프 쿨다운, 인증 실패 시 영구 비활성화를 제공합니다.
파일 위치
| 파일 | 경로 |
|---|---|
| ApiKeyPoolService | packages/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 Router | packages/desktop/app/main/services/routers/llm/ApiKeyRouter.ts |
| 프론트엔드 UI | packages/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<sessionId, SessionBinding>"]
RRI["rrIndex<br/>Map<providerId, number>"]
KC["keyCache<br/>Map<providerId, ApiKeyEntry[]>"]
CD["cooldowns<br/>Map<keyId, KeyCooldown>"]
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;
인메모리 데이터 구조 한눈에 보기
| 구조 | 타입 | 목적 | 생명주기 |
|---|---|---|---|
sessionBindings | Map<sessionId, SessionBinding> | 세션과 바인딩된 키 매핑 | 세션 종료 시 releaseSession()으로 제거 |
rrIndex | Map<providerId, number> | 프로바이더별 라운드 로빈 인덱스 | 애플리케이션 수명 동안 유지 |
keyCache | Map<providerId, ApiKeyEntry[]> | 키 목록 캐시 (요청마다 DB 조회 방지) | CRUD 작업 후 invalidateCache()로 제거 |
cooldowns | Map<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) | 누적값 | 선택된 키 |
|---|---|---|
| 0 | A: 3 | A (0 < 3) |
| 1 | A: 3 | A (1 < 3) |
| 2 | A: 3 | A (2 < 3) |
| 3 | A: 3, B: 4 | B (3 < 4) |
| 4 | A: 3, B: 4, C: 6 | C (4 < 6) |
| 5 | A: 3, B: 4, C: 6 | C (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)
| 연속 오류 횟수 | 계산 | 쿨다운 시간 |
|---|---|---|
| 1 | 60,000 * 2^0 | 60초 (1분) |
| 2 | 60,000 * 2^1 | 120초 (2분) |
| 3 | 60,000 * 2^2 | 240초 (4분) |
| 4 | 60,000 * 2^3 | 480초 (8분) |
| 5+ | 60,000 * 2^4 | 900초 (15분 상한) |
상수 설정:
| 상수 | 값 | 설명 |
|---|---|---|
DEFAULT_COOLDOWN_MS | 60,000 (60초) | 기본 쿨다운 시간 |
MAX_COOLDOWN_MS | 900,000 (15분) | 최대 쿨다운 시간 |
COOLDOWN_MULTIPLIER | 2 | 지수 밑수 |
| 정리 주기 | 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 상태 코드 분류:
| 상태 코드 | 분류 | 처리 방식 |
|---|---|---|
| 401 | AUTH_FAILURE | 키 영구 비활성화 |
| 403 | AUTH_FAILURE | 키 영구 비활성화 |
| 429 | RATE_LIMIT | 지수 백오프 쿨다운 |
| 529 | RATE_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:getApiKeys | R → M | providerId: string | 없음 | 프로바이더의 모든 키 조회 |
llmConfig:addApiKey | R → M | { providerId, label?, apiKey, enabled?, weight? } | AddApiKeySchema | 키 추가 (기본값 weight=1, enabled=true) |
llmConfig:updateApiKey | R → M | { id, label?, apiKey?, enabled?, weight? } | UpdateApiKeySchema | 키 정보 수정 |
llmConfig:deleteApiKey | R → M | { id: string } | 없음 | 키 삭제 |
llmConfig:toggleApiKey | R → 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.ts | DB Worker 타입 정의 |
routers/llm/ApiKeyRouter.ts | IPC 레이어: 프론트엔드 키 관리 작업 |
renderer/.../ApiKeyPoolSection.tsx | 프론트엔드 UI: 키 목록 CRUD |
shared/llm-config.ts | ApiKeyEntry 타입 정의 |