모델 앞에 문지기를 세우다 — Kotlin 게이트웨이, API 키, GPU 대기열, 그리고 관측
엔진은 인증도, 사용량 기록도, 줄 세우기도 하지 않습니다. 회사의 사내 LLM 서비스처럼 클라이언트가 모델과 직접 대화하지 않도록, 그 사이에 Kotlin 게이트웨이를 세웠습니다.
이 글의 독자: 지난 글에서 이어집니다. Spring을 써본 백엔드 개발자라면 대부분 익숙한 이야기지만, LLM이라서 달라지는 지점을 중심으로 적었습니다.
들어가며 — 엔진만 있는 상태의 문제
지난 글에서 vLLM이 섰습니다. 동시 16명까지 첫 글자를 0.17초에 내줍니다. 그런데 이 상태로 “서비스”라고 부르기엔 빠진 게 너무 많습니다.
- 누가 쓰는지 모릅니다. 엔진에는 인증이 없습니다.
- 누가 얼마나 썼는지 모릅니다. 토큰 수도, 대기 시간도 기록되지 않습니다.
- 넘치면 그냥 느려집니다. 17번째 요청은 vLLM 안에서 기약 없이 기다립니다.
- 엔진을 바꾸면 클라이언트도 바꿔야 합니다. 주소가 다르니까요.
회사에서 사내 LLM 서비스를 운영할 때, 클라이언트가 모델에 직접 붙는 경우는 거의 없습니다. 그 사이에 게이트웨이가 있고, 신원 확인, 라우팅, 한도, 관측을 전부 거기서 합니다. 이 프로젝트의 핵심이 바로 이 부분입니다.
Step 0 — 오늘 나올 용어 4개
| 용어 | 뜻 | 왜 중요한가 |
|---|---|---|
| OpenAI 호환 API | /v1/chat/completions 같은 OpenAI의 요청/응답 형식 | 사실상 업계 표준. 이걸 맞추면 어떤 SDK든 붙음 |
| Rate limit | 키 하나가 일정 시간에 보낼 수 있는 요청 수 | 한 사람이 GPU를 독점하지 못하게 |
| Concurrency limit | 동시에 GPU에서 처리 중인 요청 수의 상한 | 넘치는 요청을 엔진 대신 게이트웨이가 줄 세움 |
| OIDC | 로그인을 외부 서버(여기선 Keycloak)에 맡기는 표준 | 비밀번호를 내 코드가 만지지 않음 |
Step 1 — 왜 Spring Cloud Gateway가 아닌가 (결정 1)
Spring 개발자에게 “게이트웨이”라고 하면 먼저 떠오르는 건 Spring Cloud Gateway입니다. 검토하고 쓰지 않았습니다.
Spring Cloud Gateway는 경로 기반 리버스 프록시입니다. 요청을 거의 건드리지 않고 넘기는 데 최적화되어 있습니다. 그런데 이 게이트웨이는 요청 본문을 읽어야 합니다. 어떤 모델로 보낼지는 본문의 model 필드에 있고, 토큰 사용량은 응답 스트림 안에 있습니다. 필터 모델과 계속 싸우게 됩니다.
그래서 평범한 Spring Boot + WebFlux 애플리케이션을 만들고, Reactor 체인 대신 Kotlin coroutine(suspend, Flow)으로 썼습니다. MVC + 가상 스레드도 충분히 됐을 겁니다. WebFlux를 고른 건, LLM 스트리밍처럼 수십 초짜리 연결이 많이 열려 있는 워크로드가 리액티브를 배우기에 정말 적합한 곳이기 때문입니다.
Step 2 — 본문은 건드리지 않는다 (결정 2)
Kotlin 개발자의 본능은 요청을 data class로 받는 겁니다. 채팅 요청에서는 일부러 그러지 않았습니다.
엔진마다 OpenAI 형식 위에 자기만의 필드를 얹습니다. vLLM은 chat_template_kwargs(예: 생각 모드 끄기), 다른 엔진은 reasoning_effort 같은 것들요. DTO로 받으면 모르는 필드는 조용히 사라집니다. 그래서 채팅 본문만은 JSON 트리(ObjectNode) 그대로 받아 라우팅에 필요한 model만 읽고 나머지는 그대로 넘깁니다.
응답 스트림도 마찬가지입니다. SSE 프레임을 OpenAI/vLLM과 바이트 단위로 같은 모양(data: <json>\n\n)으로 씁니다. 그래야 OpenAI SDK가 게이트웨이를 OpenAI로 착각합니다. 그게 목표입니다.
라우팅은 설정에 적힌 모델 이름 → 백엔드 표로 합니다. 클라이언트가 “이 백엔드로 보내 줘”라고 헤더로 지정하는 길은 일부러 없앴습니다. 어디로 보낼지는 서버가 정합니다.
Step 3 — GPU 앞의 대기열 (결정 3)
지난 글의 측정에서 이 카드는 동시 8~16개에서 포화됐습니다. 그래서 게이트웨이가 백엔드마다 두 개의 숫자를 가집니다.
| 백엔드 | 동시 처리 | 대기열 | 근거 |
|---|---|---|---|
| vLLM | 16 | 32 | vLLM의 --max-num-seqs 16과 같게 |
| Ollama | 1 | 16 | Ollama는 한 번에 하나만 처리 |
17번째 요청은 vLLM 안이 아니라 게이트웨이의 공정한(FIFO) 대기열에서 기다립니다. 대기열마저 차면 바로 503을 돌려줍니다. 차이는 이겁니다.
- 엔진 안에서 기다리면, 얼마나 기다렸는지 아무도 모릅니다.
- 게이트웨이에서 기다리면, 대기 시간이 기록되고, 상한이 있고, 넘치면 빨리 거절합니다.
그 앞에 키별 rate limit(분당 60회, 넘으면 429와 Retry-After)을 두었습니다. 한 사람의 스크립트가 폭주해도 다른 사람의 자리를 다 먹지 못하게요.
실제 스택에서 동시 24개를 보내 봤습니다. 16개는 바로 처리, 8개는 대기열에서 최대 5.4초 기다렸다가 처리, 24개 모두 사용량 기록 완료. 설계한 대로입니다.
GPU 슬롯은 스트림이 끝나거나 클라이언트가 떠날 때까지 붙잡고 있습니다. 그리고 클라이언트가 탭을 닫으면, 게이트웨이는 엔진 쪽 요청도 취소합니다. 아무도 읽지 않을 답을 GPU가 계속 만들고 있으면 안 되니까요.
Step 4 — 모든 요청은 정확히 한 줄을 남긴다
게이트웨이를 통과한 요청은 결과가 무엇이든 사용량 테이블에 정확히 한 줄을 남깁니다. 성공, 에러, 취소, 거절 중 하나로요. 한 줄에는 토큰 수, 첫 글자까지 시간(TTFT), 대기열에서 기다린 시간, 전체 시간이 들어갑니다.
토큰 수에서 작은 문제가 있었습니다. 스트리밍 응답은 기본적으로 토큰 사용량을 알려 주지 않습니다. OpenAI 형식에서는 stream_options.include_usage를 켜야 마지막에 사용량 조각이 하나 더 옵니다. 그래서:
- 게이트웨이가 이 옵션을 스스로 켜서 엔진에 보냅니다.
- 사용량 조각을 읽어 기록합니다.
- 클라이언트가 원래 요청하지 않았다면, 그 조각은 빼고 전달합니다.
클라이언트 입장에서는 아무것도 바뀌지 않았고, 게이트웨이는 모든 요청을 셉니다.
Step 5 — 키는 두 종류, 사람은 로그인으로
인증은 두 갈래로 나눴습니다.
| 누가 | 어떻게 | 어디까지 |
|---|---|---|
| 프로그램 (스크립트, SDK, IDE 플러그인) | API 키 | /v1 (OpenAI 호환 API)만 |
| 사람 (웹 UI) | Keycloak 로그인 (OIDC + PKCE) | /v1과 /api (대화 기록, 내 키 관리) |
API 키는 256비트 난수로 만들고, DB에는 SHA-256 해시만 저장하고, 만들 때 딱 한 번 보여 줍니다. 그리고 중요한 규칙 하나: /api는 로그인 세션만 들어올 수 있습니다. API 키가 유출돼도 그 키로 새 키를 만들거나 대화 기록을 읽을 수는 없습니다.
웹에서는 토큰을 메모리에만 둡니다. localStorage에 넣지 않습니다. 새로고침하면 Keycloak 세션으로 조용히 다시 로그인합니다.
Step 6 — 첫 실사용에서 터진 버그
로그인을 붙이고 처음 실제로 대화를 저장하자 400 에러가 났습니다. 테스트는 다 통과한 상태였습니다.
원인은 웹 UI가 보내는 본문 하나였습니다.
1
{ "role": "assistant", "content": "...", "stats": null }
Kotlin에서 stats: JsonNode?로 받으면, 필드가 없을 때는 null이 됩니다. 그런데 필드가 있고 값이 null일 때는 Jackson이 Kotlin null이 아니라 NullNode라는 객체를 넣어 줍니다. 제 코드는 null만 확인하고 있었습니다.
테스트가 못 잡은 이유는 단순합니다. 테스트는 stats를 아예 빼고 보냈고, UI는 null을 명시해서 보냈습니다. 고친 뒤 테스트에는 UI가 실제로 보내는 본문을 그대로 넣었습니다.
테스트 데이터는 “그럴듯한 요청”이 아니라 실제 클라이언트가 보내는 요청이어야 합니다.
Step 7 — 관측: 숫자가 안 보이면 없는 것
마지막으로 Prometheus + Grafana를 붙였습니다. 운영 화면을 직접 만들지 않고, 업계 표준 도구를 그대로 쓰기로 했습니다.
- 게이트웨이: 요청 수, TTFT, 대기 시간, 토큰, 디코딩 속도 (Micrometer)
- vLLM: 자체
/metrics(KV cache 사용률, 실행 중/대기 중 요청) - GPU: 메모리, 온도, 사용률. NVIDIA의 공식 도구(DCGM)는 GeForce를 지원하지 않아서,
nvidia-smi를 읽는 exporter를 썼습니다
Grafana도 Keycloak으로 로그인하고, 대시보드는 코드로 저장소에 넣어 두었습니다(화면에서 수정 불가). 패널 24개 전부, 부하를 건 상태에서 실제로 데이터가 나오는지 API로 확인했습니다.
그 확인 과정에서 함정 하나를 밟았습니다. 에러 수를 세는 카운터가 처음 생기는 순간 값이 1이면, Prometheus의 rate()는 그 첫 증가를 보지 못합니다. rate()는 두 점 사이의 차이를 보는데, 0이었던 시점이 없으니 차이도 없는 겁니다. 그래서 첫 에러는 그래프에 영원히 안 나타납니다.
해결은 예상되는 라벨 조합(모델 × 결과)의 카운터를 시작할 때 0으로 미리 등록해 두는 것입니다.
정리 — 오늘 얻은 것
게이트웨이가 하는 일 5개:
- OpenAI 형식 그대로 통과 (모르는 필드도, SSE 바이트 모양도)
- 모델 이름으로 서버가 라우팅
- 키별 rate limit + 백엔드별 동시 처리 한도와 대기열
- 모든 요청에 사용량 한 줄 (TTFT, 대기 시간 포함)
- 사람은 로그인, 프로그램은 키 — 키로는 키를 못 만듦
배운 것 2개:
- 명시적
null과 빠진 필드는 다르다. 테스트는 실제 클라이언트의 본문으로 - 처음부터 1인 카운터는
rate()에 안 보인다. 0으로 미리 등록할 것
다음 글
이제 “남의 지식”이 아니라 “내 문서”로 답하게 만들 차례입니다. 보험 약관, 계약서, 자동차 설명서 같은 파일을 올리면 그 안에서 근거를 찾아 출처와 함께 답하는 RAG입니다. 그리고 이번에는 만들기 전에 평가 방법부터 세웠습니다.