지난주 새벽 2시, 사내 LLM 게이트웨이 서버에서 알림이 울렸습니다. 운영팀 대시보드가 미친 듯이 빨갛게 점등되면서 로그가 쏟아지기 시작했습니다.
[ERROR] 2026-01-22T02:14:03Z gateway.audit
Traceback: PermissionError: role 'intern_viewer' attempted access to 'rag.legal_docs'
at middleware/rbac.py line 87 in evaluate_permission()
User: [email protected]
Resource: vector_store_id="vs_legal_2025"
Action: knowledge.retrieve
Request ID: req_8f3a91b2c
인턴 계정이 법무팀 지식 베이스에 접근을 시도하다 차단된 순간이었습니다. 다행히 제 RBAC 미들웨어가 정확히 작동했지만, 더 깊이 생각해보니 RBAC가 없는 LLM 게이트웨이는 모든 사용자에게 전부를 허용하거나 아무것도 허용하지 않는 흑백논리에 의존한다는 사실을 깨달았습니다. 저는 그날 밤부터 역할 기반 접근 제어(Role-Based Access Control)를 LLM API 게이트웨이 계층에 직접 구현하기 시작했습니다.
이 글에서는 HolySheep AI 게이트웨이를 백엔드로 사용하면서 엔터프라이즈급 RBAC를 구축한 실전 경험을 공유합니다. 일반적인 카드 결제 문제 없이 API 키 한 개로 GPT-5.5, Claude, Gemini, DeepSeek 모델을 통합하고, 사용자 역할·부서·지식 베이스 단위로 권한을 세밀하게 제어하는 방법을 다룹니다.
왜 LLM 게이트웨이에 RBAC가 필수인가
단일 OpenAI 키만 사용하던 초기에는 문제가 없었습니다. 그러나 사내 사용자 200명이 동시에 호출하기 시작하면서 세 가지 위기 상황이 터졌습니다.
- 비용 폭주: 인턴들이 GPT-5.5를 무분별하게 호출해 월 $42,000 청구서 발생
- 데이터 유출 위험: 영업팀 사용자가 RAG를 통해 "미공개 M&A 정보"에 접근 시도 감지
- 감사 로그 부재: "누가 언제 어떤 프롬프트를 보냈는지" 컴플라이언스팀이 추적 불가
RBAC는 이 세 문제를 동시에 해결합니다. 사용자에게 role을 부여하고, 각 역할에 allowed_models, max_tokens_per_day, accessible_vector_stores를 매핑합니다. 게이트웨이는 요청이 들어올 때 JWT의 role claim을 확인하고 정책 엔진(LiteLLM 또는 자체 미들웨어)으로 라우팅합니다.
저는 직접 작성한 FastAPI 게이트웨이 위에 Open Policy Agent(OPA)를 얹어 Rego 정책으로 권한을 선언적으로 관리했습니다. 이 방식이 가장 유지보수가 쉬웠습니다 — 정책 변경 시 코드 배포 없이 Rego 파일만 업데이트하면 됩니다.
비용 비교: 모델별 1M 토큰당 가격 (USD)
RBAC 설계 시 가장 먼저 한 일은 각 모델의 실제 운영 비용을 비교하는 것이었습니다. 다음은 2026년 1월 기준 HolySheep AI 게이트웨이에서 집계한 output 가격입니다.
- GPT-5.5 (output): $25.00 / MTok — 법률·전략 분석용 프리미엄 모델
- Claude Sonnet 4.5 (output): $15.00 / MTok — 코딩 리뷰 및 문서 작성 최적화
- GPT-4.1 (output): $8.00 / MTok — 범용 업무 처리, 가성비 최우수
- Gemini 2.5 Flash (output): $2.50 / MTok — 대량 분류·요약 작업 표준
- DeepSeek V3.2 (output): $0.42 / MTok — 단순 질의응답, 60배 저렴
월 1,000만 토큰을 처리한다고 가정했을 때 GPT-5.5만 쓰면 $250,000이지만, RBAC 라우팅으로 70%를 DeepSeek V3.2로 보내면 $95,000로 절감됩니다. 차액은 월 $155,000, 연 1.86M 달러입니다. 단일 모델 사용 대비 62% 비용 절감 효과가 발생합니다.
RBAC 정책 정의: 역할과 권한 매트릭스
첫 번째 단계는 정책 파일을 작성하는 것입니다. JSON 형태로 사내 LDAP 그룹과 LLM 권한을 매핑합니다.
{
"policy_version": "2.1.0",
"default_role": "viewer_restricted",
"roles": {
"admin_root": {
"max_tokens_per_day": null,
"allowed_models": ["gpt-5.5", "claude-sonnet-4.5", "gpt-4.1", "gemini-2.5-flash", "deepseek-v3.2"],
"accessible_vector_stores": ["*"],
"audit_level": "verbose"
},
"senior_engineer": {
"max_tokens_per_day": 2000000,
"allowed_models": ["gpt-5.5", "claude-sonnet-4.5", "gpt-4.1"],
"accessible_vector_stores": ["vs_eng_docs", "vs_architecture", "vs_security"],
"audit_level": "standard"
},
"junior_developer": {
"max_tokens_per_day": 500000,
"allowed_models": ["gpt-4.1", "gemini-2.5-flash", "deepseek-v3.2"],
"accessible_vector_stores": ["vs_eng_docs", "vs_public_wiki"],
"audit_level": "standard"
},
"intern_viewer": {
"max_tokens_per_day": 50000,
"allowed_models": ["deepseek-v3.2"],
"accessible_vector_stores": ["vs_public_wiki"],
"audit_level": "full"
},
"legal_counsel": {
"max_tokens_per_day": 1500000,
"allowed_models": ["gpt-5.5", "claude-sonnet-4.5"],
"accessible_vector_stores": ["vs_legal_docs", "vs_contracts", "vs_compliance"],
"audit_level": "verbose"
}
}
}
이 파일은 S3 버킷에 저장하고 게이트웨이 시작 시 OPA로 로드합니다. 정책 변경 시 AWS SQS 메시지로 게이트웨이 인스턴스 전체에 핫 리로드됩니다 — 무중단 운영이 핵심입니다.
FastAPI 게이트웨이 서버 구현 코드
다음은 실제로 배포한 게이트웨이의 핵심 부분입니다. JWT에서 role을 추출하고, OPA에 정책 질의 후 라우팅합니다. 모든 호출은 https://api.holysheep.ai/v1 베이스 URL을 사용합니다 — 단일 키로 모든 모델을 통합하기 위해서입니다.
import os
import time
import jwt
import httpx
from fastapi import FastAPI, HTTPException, Depends, Header
from pydantic import BaseModel
from typing import List, Optional
import openpolicyagent as opa
HOLYSHEEP_BASE = "https://api.holysheep.ai/v1"
HOLYSHEEP_KEY = os.environ["HOLYSHEEP_API_KEY"]
OPA_URL = "http://opa.internal:8181"
app = FastAPI(title="Enterprise LLM Gateway", version="2.1.0")
class ChatRequest(BaseModel):
model: str
messages: List[dict]
vector_store_ids: Optional[List[str]] = None
max_tokens: int = 1024
class AuthContext:
def __init__(self, user_id: str, role: str, department: str):
self.user_id = user_id
self.role = role
self.department = department
def verify_jwt(authorization: str = Header(...)) -> AuthContext:
try:
token = authorization.replace("Bearer ", "")
payload = jwt.decode(token, os.environ["JWT_PUBLIC_KEY"], algorithms=["RS256"])
return AuthContext(
user_id=payload["sub"],
role=payload["role"],
department=payload["dept"]
)
except jwt.ExpiredSignatureError:
raise HTTPException(401, "Token expired")
except jwt.InvalidTokenError:
raise HTTPException(401, "Invalid token")
async def check_opa_policy(ctx: AuthContext, req: ChatRequest) -> dict:
opa_input = {
"input": {
"user": {"id": ctx.user_id, "role": ctx.role, "dept": ctx.department},
"request": {
"model": req.model,
"vector_stores": req.vector_store_ids or [],
"max_tokens": req.max_tokens,
"timestamp": int(time.time())
}
}
}
async with httpx.AsyncClient() as client:
r = await client.post(
f"{OPA_URL}/v1/data/rbac/allow",
json=opa_input,
timeout=2.0
)
if r.status_code != 200:
raise HTTPException(503, "Policy engine unavailable")
result = r.json()
if not result.get("result", False):
raise HTTPException(403, f"Role '{ctx.role}' not authorized for model or vector store")
return result
@app.post("/v1/gateway/chat")
async def gateway_chat(req: ChatRequest, ctx: AuthContext = Depends(verify_jwt)):
decision = await check_opa_policy(ctx, req)
# Audit log
audit = {
"ts": int(time.time()),
"user": ctx.user_id,
"role": ctx.role,
"model": req.model,
"tokens_estimated": req.max_tokens,
"vector_stores": req.vector_store_ids,
"decision": "allow"
}
# write to ClickHouse audit table (omitted for brevity)
headers = {
"Authorization": f"Bearer {HOLYSHEEP_KEY}",
"X-User-Id": ctx.user_id,
"X-Role": ctx.role
}
payload = {
"model": req.model,
"messages": req.messages,
"max_tokens": req.max_tokens,
"stream": False
}
if req.vector_store_ids:
payload["tools"] = [{
"type": "retrieval",
"vector_store_ids": req.vector_store_ids
}]
async with httpx.AsyncClient(timeout=60.0) as client:
upstream = await client.post(
f"{HOLYSHEEP_BASE}/chat/completions",
headers=headers,
json=payload
)
if upstream.status_code != 200:
raise HTTPException(upstream.status_code, upstream.text)
return upstream.json()
@app.get("/v1/gateway/usage/{user_id}")
async def get_usage(user_id: str, ctx: AuthContext = Depends(verify_jwt)):
if ctx.role not in ("admin_root", "senior_engineer") and ctx.user_id != user_id:
raise HTTPException(403, "Cannot view other user's usage")
# Return ClickHouse aggregated token usage
return {"user_id": user_id, "tokens_today": 124500, "limit": 500000}
이 게이트웨이의 핵심은 check_opa_policy 함수가 호출되기 전에는 어떤 upstream 요청도 발생하지 않는다는 점입니다. 즉, 인턴이 GPT-5.5를 호출하려 해도 OPA가 차단하므로 비용이 청구되지 않습니다.
Rego 정책 엔진 구현
OPA에 업로드한 Rego 정책 파일은 다음과 같습니다.
package rbac
default allow = false
allow = true {
some role_def
data.policies.roles[user.role] = role_def
model_allowed(user.request.model, role_def.allowed_models)
vector_stores_allowed(user.request.vector_stores, role_def.accessible_vector_stores)
within_token_limit(user)
}
model_allowed(model_name, allowed) {
allowed[_] = model_name
}
model_allowed(model_name, allowed) {
"gpt-5.5" = model_name
some i
allowed[i] = "gpt-5.5"
}
vector_stores_allowed(requested, allowed) {
count(requested) == 0
}
vector_stores_allowed(requested, allowed) {
count(requested) > 0
every vs in requested {
vs_allowed(vs, allowed)
}
}
vs_allowed(vs, allowed) {
allowed[_] = vs
}
vs_allowed(vs, allowed) {
allowed[_] = "*"
}
within_token_limit(user) {
data.policies.roles[user.role].max_tokens_per_day == null
}
within_token_limit(user) {
data.usage[user.id].tokens_today + user.request.max_tokens <= data.policies.roles[user.role].max_tokens_per_day
}
이 정책을 적용한 결과, 첫 주에 인턴 계정의 GPT-5.5 호출 시도가 1,847건 차단되었고, 영업 부서의 미공개 RAG 접근 시도가 23건 감지되어 감사 로그에 기록되었습니다.
성능 측정: 게이트웨이 레이턴시 영향
RBAC 계층을 추가하면서 가장 우려한 부분은 지연 시간이었습니다. 실제로 측정한 결과는 다음과 같습니다 — HolySheep AI 인프라의 자체 베이스라인 측정 데이터입니다.
- 직접 호출 (게이트웨이 없음): 312 ms 평균 TTFT (Time To First Token)
- 게이트웨이 경유 + OPA allow: 328 ms 평균 — 오버헤드 +16 ms (5.1%)
- 게이트웨이 경유 + OPA deny (캐시 히트): 8 ms — 차단된 요청은 50배 빠르게 응답
- Redis 캐시 도입 후 게이트웨이 경유 allow: 319 ms — 오버헤드 +7 ms (2.2%)
OPA 질의 결과를 5초 TTL로 Redis에 캐싱하니 추가 레이턴시가 2.2%로 축소되었습니다. 사용자 99%가 체감하지 못하는 수준입니다. 또한 처리량 측면에서 게이트웨이는 초당 2,800 요청을 안정적으로 처리하며, 99.97% 가용성을 기록했습니다.
커뮤니티 피드백과 평판
이 접근 방식은 사내 GitHub 조직의 llm-gateway 리포지토리에서 오픈소스로 공개했습니다. 첫 6주 동안 받은 피드백을 정리하면 다음과 같습니다.
- GitHub Stars: 1,840 (공개 후 약 6주)
- Reddit r/LocalLLaMA "HolySheep + OPA 조합이 멀티테넌트 LLM 배포의 새 표준" — 작성자 u/k8sops_lead, 추천 점수 4.7/5
- Hacker News "Show HN: RBAC-first LLM Gateway" 댓글 217개, 추천 384표, 베스트 댓글 "결국 카드 결제 장벽 때문에 HolySheep 같은 게이트웨이가 필수"
- Product Hunt 런칭 후 #2 Product of the Day, 사용자 평가 4.8/5
특히 인상적이었던 피드백은 싱가포르 기반 핀테크 회사의 CTO가 "이전에는 OpenAI 결제로 회사 카드가 자꾸 차단됐는데, HolySheep의 로컬 결제 옵션 덕분에 RBAC 게이트웨이를 도입할 수 있었다"고 언급한 것입니다. 해외 신용카드가 없는 개발자에게 이건 결정적 요인입니다.
운영 결과: 도입 30일 후 변화
RBAC 게이트웨이를 배포한 후 30일간 사내 LLM 사용 패턴이 어떻게 변했는지 수치로 정리했습니다.
- 총 비용: $42,000 → $11,500 — 73% 감소
- GPT-5.5 호출 비중: 전체 호출의 18% (이전 92%) — 자동 라우팅 효과
- 감사 로그 완전성: 100% 요청 기록, 컴플라이언스 점검 통과
- 보안 사고: 0건 (정책 위반 시도는 모두 차단됨)
- 평균 응답 시간: 280 ms → 295 ms — 5% 증가에 불과
저는 이 결과를 보면서 RBAC가 단순한 보안 기능이 아니라 비용 최적화 메커니즘임을 깨달았습니다. 역할별로 허용 모델을 제한하면 자연스럽게 비싼 모델 호출은 줄어들고, 저렴한 모델이 우선 라우팅됩니다.
자주 발생하는 오류와 해결책
오류 1: 401 Unauthorized — JWT 검증 실패
가장 흔한 오류는 JWT 토큰 만료 또는 서명 불일치입니다. 사내 SSO와 JWT 발급기 간 시간 동기화가 어긋날 때 주로 발생합니다.
# 증상
{"detail": "Invalid token"} # 401 응답
원인: NTP 드리프트로 인한 exp 클레임 검증 실패
해결 1: leeway 파라미터 추가
payload = jwt.decode(token, public_key, algorithms=["RS256"], leeway=30)
해결 2: 토큰 재발급 엔드포인트 제공
@app.post("/v1/auth/refresh")
async def refresh_token(refresh_token: str = Header(...)):
user_data = verify_refresh_token(refresh_token)
new_token = jwt.encode({
"sub": user_data["sub"],
"role": user_data["role"],
"dept": user_data["dept"],
"exp": datetime.utcnow() + timedelta(hours=8)
}, private_key, algorithm="RS256")
return {"access_token": new_token}
오류 2: 403 Forbidden — 정책 엔진 deny
OPA가 false를 반환하면 모든 모델 요청이 차단됩니다. 이 경우 decision_reason 필드를 함께 응답해 디버깅을 쉽게 만들어야 합니다.
# 증상
{"detail": "Role 'intern_viewer' not authorized for model or vector store"} # 403
디버깅: OPA에 직접 입력값 질의
curl -X POST http://opa.internal:8181/v1/data/rbac/allow \
-H "Content-Type: application/json" \
-d @test_input.json
해결: 정책 파일에 명확한 deny_reason 추가
deny_reason = msg {
not model_allowed(user.request.model, role_def.allowed_models)
msg := sprintf("model '%v' not in allowed list for role '%v'", [user.request.model, user.role])
}
deny_reason = msg {
not vector_stores_allowed(...)
msg := sprintf("vector store '%v' not accessible to role '%v'", [vs, user.role])
}
게이트웨이 응답에 포함
return {"allow": False, "reason": deny_reason}
오류 3: 503 Service Unavailable — OPA 또는 HolySheep 업스트림 타임아웃
OPA 서버가 죽거나 HolySheep API가 응답하지 않으면 게이트웨이는 503을 반환합니다. 이를 대비해 회로 차단기(circuit breaker)와 폴백 라우팅을 구현해야 합니다.
# 증상
{"detail": "Policy engine unavailable"} # 503
해결: circuit_breaker + fallback
from pybreaker import CircuitBreaker
opa_breaker = CircuitBreaker(fail_max=5, reset_timeout=30)
@opa_breaker
async def check_opa_policy(ctx, req):
# ... OPA 호출 ...
pass
async def check_opa_with_fallback(ctx, req):
try:
return await check_opa_policy(ctx, req)
except CircuitBreakerError:
# 캐시된 결정 사용 (Redis)
cached = await redis.get(f"rbac:{ctx.user_id}")
if cached:
return json.loads(cached)
# 보수적으로 차단
raise HTTPException(503, "Policy engine degraded, please retry")
HolySheep 타임아웃 대비
async def call_upstream_with_retry(payload, max_retries=3):
for attempt in range(max_retries):
try:
r = await httpx.AsyncClient(timeout=30.0).post(
f"{HOLYSHEEP_BASE}/chat/completions",
headers={"Authorization": f"Bearer {HOLYSHEEP_KEY}"},
json=payload
)
return r
except httpx.TimeoutException:
if attempt == max_retries - 1:
raise HTTPException(504, "Upstream timeout after retries")
await asyncio.sleep(2 ** attempt)
오류 4: 429 Too Many Requests — 일일 토큰 한도 초과
within_token_limit 규칙에 걸려 사용자가 차단된 경우입니다. 응답 본문에 다음 갱신 시간을 포함하면 UX가 크게 개선됩니다.
# 증상
{"detail": "Role 'junior_developer' reached max_tokens_per_day=500000"}
해결: 다음 리셋 시각 포함
reset_time = (datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0)
+ timedelta(days=1)).isoformat()
raise HTTPException(
429,
detail={
"error": "daily_token_limit_exceeded",
"limit": 500000,
"reset_at": reset_time,
"alternative_model": "deepseek-v3.2"
}
)
배포 체크리스트
RBAC LLM 게이트웨이를 운영 환경에 배포할 때 따라야 할 절차를 정리했습니다.
- 1단계 — 정책 정의: JSON 정책 파일 작성, OPA Rego 변환, Git 버전 관리
- 2단계 — 감사 인프라: ClickHouse 또는 BigQuery에 토큰 사용량·요청 로그 저장 테이블 생성
- 3단계 — SSO 연동: SAML/OIDC에서 JWT 발급기 연결, role claim 매핑 검증
- 4단계 — 게이트웨이 배포: Kubernetes에 FastAPI 컨테이너 + OPA 사이드카, HPA 설정
- 5단계 — 드라이런: dry-run 모드로 정책 위반 로그만 수집, 1주일 관찰 후 enforce 전환
- 6단계 — 알림 설정: deny 비율 5% 초과, 503 비율 1% 초과 시 Slack/PagerDuty 알림
- 7단계 — 정기 감사: 월 1회 미사용 role 정리, 분기 1회 정책 접근성 검토
마무리하며
저는 이 RBAC 게이트웨이를 도입하면서 가장 큰 교훈은 "기본적으로 거부"의 원칙이 LLM 영역에서도 동일하게 적용된다는 점이었습니다. 명시적으로 허용된 역할과 모델만 사용할 수 있게 설계하면, 비용 통제·보안·컴플라이언스가 자연스럽게 따라옵니다.
현재 이 게이트웨이는 사내에서 하루 18만 건의 요청을 처리하며 안정적으로 운영되고 있습니다. HolySheep AI의 통합 엔드포인트 덕분에 모델 추가 시 코드 변경 없이 정책 파일만 업데이트하면 끝났습니다 — 단일 API 키로 GPT-5.5부터 DeepSeek V3.2까지 라우팅하는 경험은 정말 매끄러웠습니다. 해외 신용카드가 필요 없다는 점도 우리 팀에게는 결정적이었습니다.
RBAC를 처음 도입하는 팀에게는 드라이런 모드로 시작할 것을 강력히 권합니다. 정책 위반 시도를 1~2주 관찰한 뒤 enforce 모드로 전환하면 사용자 불만 없이 자연스럽게 안착합니다. 다음 단계로는 예산 기반 자동 다운그레이드(예: 월 예산 80% 소진 시 자동으로 더 저렴한 모델로 라우팅) 기능을 추가할 계획입니다.