오픈 소스 LLM 키 풀 게이트웨이를 구축한 과정과 그 필요성
요약
LLM 개발 시 발생하는 속도 제한(rate limits)과 할당량 고갈 문제를 해결하기 위해, 자체 호스팅되는 오픈 소스 게이트웨이 'Palimpsest Gateway'를 구축했습니다. 이 게이트웨이는 여러 LLM 키를 풀링하고, 자동으로 장애 복구 및 세션 연속성을 보장하여 안정적인 서비스 운영을 가능하게 합니다.
핵심 포인트
- 여러 LLM 키 관리가 인프라스트럭처 문제임.
- Palimpsest Gateway는 자체 호스팅되며 OpenAI와 호환됨.
- 키 풀링, 자동 장애 복구, 대화 연속성 기능을 제공함.
- Python/FastAPI로 작성되어 Docker Compose로 쉽게 배포 가능.
LLM을 활용하여 개발하는 사람이라면 누구나 공감할 만한 벽에 부딪혔습니다.
문제점: 저는 OpenAI 키 3개(무료 티어 2개, 유료 1개), Anthropic 키 1개, 그리고 Gemini 키 1개를 가지고 있었습니다. 제가 만든 모든 앱은 속도 제한(rate limits)과 할당량 고갈(quota exhaustion)을 처리하기 위한 자체 로직이 필요했습니다. 하나의 키가 한계에 도달하면 앱이 작동을 멈췄고, 이는 제가 모든 개별 앱에 커스텀 장애 복구(failover) 로직을 작성하지 않는 이상 문제가 되었습니다. 제공업체 키들은 .env 파일 곳곳에 분산되어 공유되었으며, 무언가를 망가뜨리지 않고는 순환시키기 불가능했습니다.
깨달음: 이것은 앱의 문제가 아닙니다. 인프라스트럭처(infrastructure) 문제입니다. LLM을 활용하는 모든 팀이 세 가지 문제를 제대로 해결하지 못하고 있습니다:
- 키 풀링 (Key pooling) - 여러 키에 걸쳐 부하 분산시키기
- 장애 복구 (Failover) - 키가 실패할 때 자동으로 전환하기
- 세션 연속성 (Session continuity) - 전환 과정에서도 대화 유지하기
그래서 저는 존재하기를 바랐던 것을 직접 만들었습니다: Palimpsest Gateway. 이 게이트웨이는 자체 호스팅(self-hosted)되며 OpenAI와 호환되는 게이트웨이로, 모든 LLM 키 앞에 위치하여 키들을 풀링하고, 자동으로 장애 복구를 수행하며, 전환 과정에서도 대화를 지속시킵니다.
이것은 오픈 소스(MIT)이며 Python/FastAPI로 작성되었고, 단일 docker compose up으로 실행됩니다. 이 글은 제가 왜 이것을 만들었는지, 어떻게 작동하는지, 그리고 예상치 못했던 어려운 부분들에 대한 이야기입니다.
문제점: 키가 고갈되면 앱이 멈춥니다
LLM으로 대규모 프로젝트를 구축해 본 경험이 있다면 다음과 같은 어려움을 알고 있을 것입니다:
| 어려움 | 실제로 발생하는 일 |
|---|---|
| 속도 제한 (Rate limits) | 무료 티어 키가 429에 도달합니다. 앱이 충돌하고, 이를 잡아서 슬립(sleep) 후 재시도하지만... 이미 사용자는 떠난 상태입니다. |
| ... |
┌─────────────┐ ┌──────────────────────┐ ┌─────────────────┐
│ 앱 (Your App) │────▶│ Palimpsest Gateway │────▶│ LLM 제공업체 (LLM Providers) │
│ (OpenAI │ │ • 인증 및 진입 허가 (Auth & Admission) │ │ OpenAI │
...
요청 흐름 (The Request Flow)
- 인증 (Authenticate) - 앱은 게이트웨이 키(
pgw_live_...)만 전송하며, 제공업체 키는 절대 전송하지 않습니다. - 진입 허가 (Admit) - 사용자의 일일 할당량과 풀 용량을 확인합니다.
- 컨텍스트 로드 (Load context) -
X-Session-ID를 사용하여 저장된 기록을 가져와, 앱이 새로운 메시지만 전송하도록 합니다. - 키 선택 (Choose a key) - 요청된 모델에 대해 활성화된 키 중에서, 여유 공간이 남아 있는 제한된 키(capped keys)를 우선하여 선택합니다.
- 제공업체 호출 (Call provider) - 제공업체 고유 형식(OpenAI, Anthropic, Google, OpenRouter)으로 전달합니다.
- 필요시 장애 조치 (Fail over if needed) - 속도 제한, 서비스 중단, 잘못된 키 발생 시 → 해당 키를 휴지/소진/정지시키고 다음 키로 재시도합니다 (동일 호출, 컨텍스트 유지).
- 응답 및 기록 (Answer & record) - OpenAI 형식의 응답을 반환하고, 턴(turn)을 저장하며, 사용량을 한 번 계산합니다.
어려운 부분들 (The Hard Parts)과 해결 방법
1. 클라이언트를 끊지 않고 스트리밍 장애 조치 구현하기
이것이 가장 어려웠던 부분이었습니다. 제공업체가 스트리밍 응답을 반환할 때, 단순히 '다른 키로 재시도' 할 수 없습니다. 이미 클라이언트가 토큰을 받고 있기 때문입니다.
해결책: 게이트웨이는 모든 제공업체 스트림 전체를 메모리에 버퍼링한 후, 클라이언트에 아무것도 보내기 전에 처리합니다. 만약 제공업체가 스트리밍 도중에 실패하면, 게이트웨이는 완전한 컨텍스트(시스템 프롬프트 + 마지막 N개 턴 + 현재 메시지)를 가지고 다음 키로 재시도합니다. 클라이언트는 항상 완전하고 유효한 OpenAI 스트림만을 보게 됩니다.
# 간소화된 코드: app/router/exhaustion.py의 장애 조치 루프
async def _call_with_failover(chain: list[KeyRecord], payload: dict) -> Response:
for attempt, key in enumerate(chain):
...
핵심 통찰: 스트림을 버퍼링하고, 게이트웨어를 떠나기 전에 장애 조치를 수행합니다. 클라이언트는 부분적인 응답을 절대 보지 못합니다.
2. 제공업체 간 컨텍스트 유지 (Context Carry-Over Across Providers)
장애 복구(failover)가 발생하면, 대체 제공업체는 대화 기록이 필요합니다. 하지만 각 제공업체마다 컨텍스트 창 크기(context window sizes)와 메시지 형식이 다릅니다.
해결책: 게이트웨이는 세션별로 모든 턴(turn)을 Redis에 저장합니다. 장애 복구 시, 시스템 프롬프트와 현재 메시지를 포함하여 '문자 그대로의 창(verbatim window)'—즉, 마지막 N개 턴(기본값 4)을 전송된 그대로 재구성합니다. 이는 어떤 합리적인 컨텍스트 창에도 맞고 대화 흐름을 보존합니다.
# app/context/rebuild.py - 간소화됨
def build_failover_context(session_history: list[Turn], current_message: Message) -> list[Message]:
verbatim = session_history[-settings.verbatim_turns_n:] # 마지막 4개 턴
...
3. 재시작에도 살아남는 암호화된 키 저장소
제공업체 키(Provider keys)는 핵심 자산입니다. 이들은 반드시 저장 시점(at rest)에 암호화되어야 하며, 로깅되거나 생성 후 반환되어서는 안 됩니다. 하지만 게이트웨이는 요청 시간에 이를 복호화해야 합니다.
해결책: 첫 부팅 시 생성되고 Docker 볼륨에 영구 저장되는 마스터 키(Fernet)를 사용합니다. 모든 제공업체 키는 이 마스터 키로 암호화된 후 Redis에 저장됩니다. 마스터 키는 컨테이너를 벗어나지 않습니다. 이를 순환(Rotating)시키는 것은 파괴적인 작업(make rotate-secrets)입니다—이는 설계상 그렇습니다.
# app/core/crypto.py
class SecretsManager:
def __init__(self, master_key: bytes):
...
4. 의도를 존중하는 키 선택
사용자는 무료 키(일일 제한)와 유료 키(제한 없음)를 가지고 있습니다. 사용자는 무료 키가 먼저 사용되고, 유료 키는 백업으로 사용되기를 원합니다.
해결책: 선택 알고리즘은 다음 순서로 키를 정렬합니다:
- 요청 제한이 있고 남은 요청 수가 있는 키 → 남은 것이 가장 많은 순
- 제한이 없는 키 → 마지막
각 그룹 내에서는 세션의 이전 턴을 처리했던 제공업체(지역성, locality)를 선호합니다.
# app/pool/registry.py - routable_keys()
def sort_for_selection(keys: list[KeyRecord]) -> list[KeyRecord]:
capped = [k for k in keys if k.daily_cap is not None]
...
빠른 시작: 60초 만에 실행까지
# Provider 키 추가하기 (일회성, 또는 대시보드 사용)
echo 'PALIMPSEST_PROVIDER_API_KEY=sk-your-openai-key' >> .env
docker compose up -d
...
from openai import OpenAI
client = OpenAI(base_url="http://localhost:8000/v1", api_key="pgw_live_...")
...
대화를 서버 측에서 지속시키려면 X-Session-ID: my-chat 헤더를 전송하세요.
실제 사용 예시
대시보드에서는 모든 것을 확인할 수 있습니다:
- Pool Keys (키 풀) - 상태, 사용량, 한도(caps), 재설정 일정, 대기 시간(cooldown) 상태
- Utilization & Failover (활용 및 장애 조치) - 어떤 키가 무엇을 처리했는지, 장애 조치 이벤트, 재시도 체인
- Users & Sessions (사용자 및 세션) - 계정별 사용량, 활성 세션, 한도 소모
- Costs (비용) - 모델/사용자/일별 지출액 (직접 입력한 가격 기준) (번들링된 비용 없음)
- Alerts (알림) - 낮은 용량, 키 문제, 오류율, 웹훅 지원
- Audit & Export (감사 및 내보내기) - 모든 운영자(operator) 작업 기록, 사용량/요청/감사 CSV 내보내기
샘플 요청 로그 라인 보기 클릭
{
"timestamp": "2026-09-04T20:00:00Z",
"request_id": "req_abc123",
...
첫 번째 키가 속도 제한됨 → 유료 키로 장애 조치 → 컨텍스트 유지 → 성공
왜 'Palimpsest'인가?
팔림프세스트(palimpsest)는 글을 쓰고, 긁어내고, 다시 쓰는 양피지 페이지를 의미합니다. 이전의 글이 여전히 희미하게 남아있죠. 이 게이트웨이가 하는 일도 이와 같습니다: 제공자 전환에 걸쳐 대화 기록을 계층화하여, 근본적인 키가 바뀌었더라도 새로운 제공자가 '오래된 글(컨텍스트)'을 볼 수 있게 합니다.
다음 단계는?
- Multi-gateway 클러스터링 (Multi-gateway clustering) - 로드 밸런서 뒤에 여러 게이트웨이 인스턴스를 실행하고 Redis를 공유합니다.
- 프로바이더 할당량 동기화 (Provider quota sync) - 수동 제한(manual caps) 대신 실제 프로바이더 할당량을 읽습니다 (OpenRouter 이미 지원됨).
- 요청 변환 (Request transformations) - 모든 프로바이더에 걸쳐 도구 호출을 정규화하고 시스템 프롬프트를 주입합니다.
- PR 제출하기 (Your PR here) - 좋은 첫 이슈(good first issues)가 태그되어 있으며,
CONTRIBUTING.md에 개발 루프가 있습니다.
사용해보고, 스타하고, 부숴보세요 (Try It, Star It, Break It)
저장소 (Repo): github.com/hammrouni/palimpsest-gateway
문서 (Docs): 사용자 가이드 (User Guide) · 개발 (Development)
라이선스 (License): MIT
Docker: docker pull ghcr.io/hammrouni/palimpsest-gateway:latest
실행하다가 이상한 점을 발견하면 이슈를 열어주세요. 수정했다면 PR을 여주세요. 싫다면, 왜 싫은지 말씀해주세요. 저 역시 이미 그 벽에 부딪혔을 가능성이 높습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기