728x90

사내에 LLM을 붙이기 시작하면 보통 이 순서로 간다. PoC 하나 만들고, OpenAI 키 하나 발급받고, 백엔드 환경변수에 넣고, 잘 돌아가니까 팀 두세 개가 같이 쓰고, 어느 날 청구서를 보고 "이거 누가 쓴 거지?"라는 질문이 나온다. 그때부터 게이트웨이 얘기가 시작된다.

최근에 흥미롭게 읽은 글이 하나 있다. 캘리포니아 법원에서 법정 운영 업무를 하는 분이 오픈소스 AI 게이트웨이인 Bifrost(Maxim AI, Apache 2.0)로 롤 세 개를 만들고 아홉 번의 호출을 던져서 여섯 번 통과 세 번 거절을 확인한 기록이다. 기술 글인데 읽다 보면 "이건 그냥 내가 화요일마다 하는 일"이라는 문장이 나온다. 법정 서기, 속기사, 통역사가 같은 사건을 다루는데 각자 볼 수 있는 부분이 다르다는 것. 그리고 그 경계선은 파일 단위가 아니라 파일 내부를 관통한다는 것.

이 관점이 LLM 인가 설계의 핵심을 정확히 찌른다. 우리가 다루는 데이터도 마찬가지다. 문서 하나가 통째로 공개거나 통째로 비밀인 경우는 거의 없다.

왜 애플리케이션 인가만으로는 부족한가

실무에서 반복해서 만나는 실패 모양은 이렇다.

1) 키 하나를 열 네 명이 공유한다. 원문의 표현을 빌리면 "단톡방에 붙여넣은 API 키 하나". 누가 얼마 썼는지 아무도 모르고, 한 사람만 차단하려면 전원이 끊긴다. 이 상태에서는 최소 권한(least privilege)을 논할 여지 자체가 없다. 나눌 권한이 존재하지 않으니까.

2) 인가 로직이 앱마다 흩어진다. 챗봇 서비스, 요약 배치, 사내 검색 세 군데가 각자 "이 사용자가 이 모델 써도 되나"를 판단한다. 새 모델을 추가하면 세 군데를 고쳐야 하고, 한 군데를 빠뜨리면 그게 바로 구멍이다.

3) 거절 기록이 남지 않는다. 허용된 호출은 로그가 남는데, 거절된 호출은 앱 안에서 조용히 예외 처리되고 끝난다. 감사 관점에서는 "누가 무엇을 시도했다가 막혔는가"가 오히려 더 중요한데도.

게이트웨이는 이 셋을 한 계층으로 내린다. 앱은 게이트웨이 하나만 보고, 게이트웨이가 프로바이더들을 본다. 모든 트래픽이 지나가는 지점이므로 인가를 판단하기에 자연스러운 위치다. 원문 저자도 "게이트웨이에서, 프로바이더에 도달하기 전에" 거절되고 "롤 이름이 붙은 채로 로그에 남았다"는 점을 확인 포인트로 잡았다.

동작 원리: 가상 키와 스코프, 그리고 조직도가 아닌 기능

Bifrost가 쓰는 개념이 virtual key다. 밖으로 나눠주는 건 가상 키고, 진짜 OpenAI/Anthropic 키는 게이트웨이 안에만 있다. 가상 키는 자기 권한과 자기 예산 한도를 들고 다니는 하나의 신원(identity)이다. 다운스트림의 누구도 실제 프로바이더 자격증명을 쥐지 않는다.

원문에서 만든 매트릭스가 이해에 좋다.

접근 가능한 모델 티어일일 한도
interpreter (통역사)routine$0.05
reporter (속기사)routine, standard$1.00
clerk (서기)routine, standard, restricted$5.00

여기서 놓치면 안 되는 게 저자가 강조한 지점이다. 서기가 통역사보다 직급이 높아서 권한이 많은 게 아니다. 서기는 재판이 진행되는 동안 실시간으로 사건을 감사(audit)하기 때문에 그 순간 필요한 기능이 그만큼 넓은 것이다. 서기가 뭔가를 못 보면 재판이 멈춘다.

사내 시스템 설계할 때 이 함정에 정말 자주 빠진다. 직급 테이블을 그대로 권한 계층으로 매핑하는 것. 실제로는 신입 온콜 엔지니어가 새벽 3시에 프로덕션 로그를 봐야 하고, 팀장은 볼 일이 없다. 권한은 직함이 아니라 기능(function)에 붙어야 하고, 사람은 그 기능에 담긴다. 사람이 직무를 옮기면 롤을 바꿔주면 끝이지, 계정 하나 붙잡고 권한을 한 개씩 주고 뺏고 하지 않는다.

재밌는 건 법원 쪽 규칙이 이걸 이미 언어로 갖고 있다는 점이다. 캘리포니아 Rule 2.550(c)는 "법률상 비밀이 요구되지 않는 한 법원 기록은 공개된 것으로 추정한다"고 하고, 봉인 명령에 관한 2.550(e)(1)은 narrowly tailored, 즉 보호가 필요한 부분만 봉인하고 나머지는 공개 파일에 남기라고 요구한다. 사건 전체가 아니라 부분(portions). 이거 그냥 최소 권한 원칙을 다른 어휘로 쓴 거다.

실행해보기: 롤별로 아홉 번 던지기

게이트웨이를 세웠다면 확인은 단순하다. 가상 키를 헤더에 넣고 롤×모델 조합을 전부 돌린다.

#!/usr/bin/env bash
# 3 roles x 3 model tiers = 9 calls
declare -A KEYS=( [interpreter]=$VK_INTERPRETER [reporter]=$VK_REPORTER [clerk]=$VK_CLERK )
MODELS=(routine-model standard-model restricted-model)

for role in "${!KEYS[@]}"; do
  for m in "${MODELS[@]}"; do
    code=$(curl -s -o /tmp/resp.json -w '%{http_code}' \
      http://localhost:8080/v1/chat/completions \
      -H "Authorization: Bearer ${KEYS[$role]}" \
      -H 'Content-Type: application/json' \
      -d "{\"model\":\"$m\",\"messages\":[{\"role\":\"user\",\"content\":\"ping\"}]}")
    echo "role=$role model=$m http=$code"
    [ "$code" != "200" ] && jq -r '.error.message' /tmp/resp.json
  done
done

출력은 대략 이런 모양이 된다(거절 메시지 문구는 버전마다 다를 수 있으니 실제 응답 확인 필요).

role=interpreter model=routine-model      http=200
role=interpreter model=standard-model    http=403
  model 'standard-model' is not allowed for this virtual key
role=interpreter model=restricted-model  http=403
  model 'restricted-model' is not allowed for this virtual key
role=reporter    model=routine-model      http=200
role=reporter    model=standard-model     http=200
role=reporter    model=restricted-model   http=403
  model 'restricted-model' is not allowed for this virtual key
role=clerk       model=routine-model      http=200
role=clerk       model=standard-model     http=200
role=clerk       model=restricted-model   http=200

여섯 통과, 세 거절. 원문 저자가 강조한 건 거절 세 개가 이 테스트의 본체라는 점이다. 뭔가 막히는 걸 눈으로 보기 전까지 스코프 설정은 검증되지 않은 주장일 뿐이다. 통과한 여섯 개는 사실 아무것도 증명하지 않는다.

실무 관점: 트레이드오프와 흔한 함정

함정 1. 예산 한도를 스코프로 착각하기

원문에서 "하마터면 나 자신을 속일 뻔한 화면"으로 나오는 대목이 가장 실용적이다. 저자는 interpreter 롤에 일일 5센트 예산을 걸고 "다 됐다"고 생각했다. 그런데 바로 아래 줄에 이렇게 적혀 있었다.

Access rate limits: All keys · All models · No rate limits

모델 하나만 쓰라고 만든 키가 세 모델 전부에 도달할 수 있는 상태였다. UI에서 위아래로 붙어 있는 두 컨트롤이 완전히 다른 일을 한다.

  • Model budgets: 모델이 얼마까지 쓸 수 있는지 상한. 어떤 모델에 도달할 수 있는지에 대해서는 아무 말도 하지 않는다.
  • Access rate limits: 키가 어떤 모델에 도달할 수 있는지 결정. 기본값은 제한 없음.

이건 Bifrost만의 문제가 아니다. AWS에서도 Budgets 알람 걸어놓고 IAM 정책은 Resource: "*"인 상태를 수도 없이 봤다. 비용 통제와 접근 통제는 다른 축인데, 대시보드가 나란히 놓여 있으면 뇌가 하나로 합쳐버린다. "돈을 못 쓰게 했다"와 "못 가게 했다"는 다른 문장이다.

함정 2. RBAC라는 단어가 두 군데를 가리킨다

원문이 친절하게 정리해둔 부분. Bifrost에는 이름이 비슷한 접근 계층이 둘 있다.

계층무엇을 스코핑어디에
Virtual keys (Governance)애플리케이션/컨슈머가 도달 가능한 범위. 모델, 프로바이더, 예산, 레이트 리밋오픈소스
Bifrost가 "RBAC"라 부르는 기능사람 관리자가 Bifrost 안에서 할 수 있는 일. 로그 열람, 프로바이더 생성, 키 노출엔터프라이즈

즉 셀프호스팅으로 검증 가능한 건 governance 쪽이다. 브리프만 읽고 "roles 화면"을 찾으러 들어가면 빌드에 그 화면이 없다. 도입 검토할 때 이런 라이선스 경계는 반드시 먼저 확인해야 하는 항목이다.

함정 3. 체크가 값의 복사본을 들고 있다

저자가 라이브 실행 전 dry-run 단계에서 잡아낸 것들이 있는데, 인프라 하는 사람이면 다 겪어본 종류다.

  • dry-run 모드인데 로깅 헬퍼가 무조건 raw_output.log에 append하고 있었다. 결과의 증거가 되어야 할 파일이, 실제 호출이 아닌 것에 의해 오염되고 있었던 것.
  • 아홉 개 호출을 세는 체크가 출력 라인의 badge= 문자열을 매칭하고 있었는데, badge를 role로 리네임하면서 role=이 됐다. 매처는 0을 세고, 그러고도 PASS를 찍는다. 0을 읽고 PASS를 내는 체크는 체크가 없는 것보다 나쁘다. 커버리지가 있는 것처럼 보이니까.
  • 키가 커밋될까 봐 넣은 가드가 이런 걸 뱉었다.
$ git check-ignore .env
fatal: not a git repository (or any of the parent directories): .git

순간 키가 샜나 싶어서 심장이 내려앉지만, 실은 아직 저장소가 없어서 아무것도 커밋될 수 없는 상태였다. "가드가 발화했다"와 "문제가 있다"는 같은 사건이 아니다. 이걸 구분해주는 건 사람이 읽을 수 있는 설명뿐이다.

세 건의 공통 패턴이 있다. 체크가 값의 복사본을 품고 있었고, 원본이 움직였다. 포트 충돌도, 티어 리네임도, badge 매처도 전부 같은 모양이다. 해결도 같은 모양이다 — 체크를 복사본이 아니라 source of truth에 연결한다. 설정 파일에서 롤 목록과 모델 목록을 읽어와 매트릭스를 생성하면, 이름을 바꿔도 테스트가 따라온다.

운영 체크리스트

  • 단계 분리: 원문의 Phase A(키 없이 전체 하네스 + dry-run) → Phase B(키 넣고 직접 호출 3건으로 도달성 확인) → Phase C(라이브)는 그대로 훔쳐 쓸 만하다. 돈이 드는 단계는 마지막 하나뿐이다.
  • 증거 파일 두 개: 가공하지 않은 원본 로그 하나, 사람이 읽는 리포트 하나. 리포트의 모든 주장은 로그의 라인을 인용한다. 이 분리가 신뢰 메커니즘의 전부다.
  • 거절 로그에 주체를 붙여라: 403만 남으면 감사 때 쓸모없다. 롤 이름, 요청한 모델, 시각이 같이 남아야 한다.
  • 페일 클로즈: 정책 엔진이나 인증 백엔드가 죽었을 때 "일단 통과"로 떨어지면 안 된다. 게이트웨이가 정책을 못 읽으면 거절이 기본값이어야 한다. 다만 이건 가용성과의 트레이드오프라 SLA와 같이 결정해야 한다.
  • RAG 쪽은 별도 문제: 게이트웨이가 모델 접근을 막아도, 벡터 DB에서 권한 없는 문서가 검색되면 그대로 프롬프트에 들어간다. 검색 시점에 사용자 컨텍스트로 메타데이터 필터를 걸어야 하고, 인덱싱 시점에 박아둔 권한 정보는 시간이 지나면 실제 권한과 어긋난다. 이 스냅샷 문제는 게이트웨이 레이어가 대신 풀어주지 않는다.

대안

같은 자리를 노리는 게 여럿이다. LiteLLM 프록시, Portkey, Kong/Envoy 같은 범용 게이트웨이에 AI 플러그인을 얹는 방식, 혹은 그냥 사내 BFF 하나를 세워 직접 만드는 것. 판단 기준은 단순하다. 프로바이더가 두 개 이상이고, 소비하는 팀이 세 개 이상이고, 비용을 팀 단위로 귀속해야 한다면 별도 계층이 값을 한다. 팀 하나 앱 하나면 게이트웨이 도입 비용이 더 크다. 각 제품의 오픈소스/엔터프라이즈 경계는 반드시 공식 문서로 직접 확인하는 걸 권한다.

정리

한 줄 요약: AI 게이트웨이의 가치는 "여러 프로바이더를 한 주소로"가 아니라 "권한과 예산을 가진 신원을 발급할 수 있게 되는 것"에 있다. 진짜 키를 게이트웨이 안에 가두는 순간부터 최소 권한을 적용할 대상이 생긴다.

그리고 검증은 통과가 아니라 거절로 한다. 막힐 걸 예상한 호출이 실제로 막히고, 그 기록이 주체 이름과 함께 로그에 남는지. 그것만 확인하면 나머지는 따라온다.

지금 당장 볼 사람: 하나의 API 키를 팀 여럿이 공유 중이거나, LLM 비용을 팀별로 나눌 방법이 없거나, 감사 요구사항이 있는 도메인(금융·의료·공공)에서 LLM을 붙이려는 팀. 반대로 앱 하나에 프로바이더 하나면 급하지 않다. 다만 그 상태가 오래 가는 경우를 아직 못 봤다.

참고 자료

728x90

+ Recent posts