LLM API를 '서비스'로 만든다는 것 — 기억·캐시·공개·봇을 K3s에 엮어 본 고찰
curl 한 번은 서비스가 아니다
LLM API를 부르는 건 쉽다. POST /chat/completions 한 줄이면 답이 온다. 그런데 그게 “실제 작동하는 자동화 서비스”냐고 물으면, 아니다. 이번에 내 홈랩 K3s 위에서 LLM API와 내 데이터 기반을 엮어 실제 돌아가는 RAG 자동화 서비스를 처음부터 끝까지 세워보면서, “AI를 서비스화한다”는 말의 진짜 무게를 확인했다. 이 글은 그 과정에서 얻은 고찰이다.
결론부터: LLM은 서비스의 한 부품일 뿐이다. 돌아가는 자동화가 되려면 그 위에 기억(데이터)·속도(캐시)·접근(공개)·인터페이스(봇)·자율성(스케줄) 이 붙어야 한다. 체감상 “AI” 자체는 전체의 20%도 안 됐다. 나머지 80%는 배관이었다.
세운 것: memory-qa
한 문장으로: 질문을 던지면 내 기억을 검색해 LLM으로 답하는 서비스를, K3s에 상주시키고 캐싱·공개·자동 큐레이션·텔레그램까지 붙였다.
질문/이벤트
│
├─▶ [레이트리밋·캐시 조회] ← Dragonfly (Redis 호환)
│
├─▶ [의미 검색: 관련 기억 top-k] ← OpenViking (컨텍스트 DB)
│ └─ 원문 read → 컨텍스트 구성
│
├─▶ [LLM 생성: 컨텍스트로 grounded 답변] ← Gemini (gemini-2.5-flash)
│
├─▶ [응답 + 출처] → 캐시 저장
│
└─▶ 전달: HTTP API / 공개 도메인 / 텔레그램 봇
구현은 의도적으로 표준 라이브러리만 썼다(외부 의존성·이미지 빌드 0, 코드는 ConfigMap으로 주입). 이유는 뒤에서.
층마다 배운 것
① RAG — LLM을 ‘내 데이터’에 묶는다, 그리고 모르면 모른다고 한다
가장 얇은 첫 슬라이스는 POST /ask 하나였다. 질문이 오면 OpenViking에서 의미적으로 가까운 기억을 찾고, 그 원문을 컨텍스트로 넣어 Gemini에게 “이 컨텍스트 안에서만 답하라”고 시킨다.
핵심은 답을 잘하는 게 아니라 모를 때 모른다고 하는 것이었다. “오늘 서울 날씨?”처럼 기억에 없는 걸 물으면 서비스는 “해당 정보는 메모리에 없습니다”라고 답한다. RAG의 가치는 모델의 창의력이 아니라 검색된 근거에 답을 묶고, 근거를 인용하고, 없으면 지어내지 않는 데 있다. 지난 글에서 다룬 evidence-gate·측정가능성과 같은 정신이다.
② 캐시·레이트리밋 — LLM 호출은 비싸고 느리다
같은 질문에 매번 LLM을 부르는 건 낭비다. Dragonfly(Redis 호환 인메모리 스토어)를 붙여 응답을 캐시하니 반복 질의가 3.7초 → 0.3초(12배) 로 줄고 그만큼 LLM 비용이 0이 됐다. 남용 방지로 IP별 레이트리밋(30회/60초 초과 시 429)도 얹었다.
여기서 얻은 감각: AI 서비스의 병목은 대개 모델이 아니라 그 앞뒤의 I/O와 비용이다. 캐시 하나가 UX와 지갑을 동시에 살린다. 그리고 Dragonfly가 죽어도 서비스가 계속 돌도록 fail-open 으로 짰다 — 부가 기능이 본체를 죽이면 안 된다.
③ 공개 + 게이트 — 접근은 ‘열되 지키는’ 문제
내부에서만 도는 서비스는 반쪽이다. Cloudflare Tunnel로 ask.lemuel.co.kr에 공개하되, 먼저 Access 게이트(이메일 OTP)를 만들고 그 다음 터널·DNS를 붙였다 — 무방비로 노출되는 창을 0으로. 여기서 마주친 진짜 긴장: 사람용 게이트(브라우저 OTP)는 API 자동호출을 막는다. “공개”와 “프로그램 접근”은 다른 축이라, 자동화엔 서비스 토큰이나 내부 경로가 따로 필요하다. 보안은 언제나 이 트레이드오프의 조율이다.
④ 자율성 — 기억이 스스로 갱신되게
여기서 “자동화”가 완성된다. 6시간마다 도는 CronJob이 클러스터 상태(노드·파드·경고 이벤트)를 수집해 LLM으로 요약하고, 그 요약을 다시 기억으로 저장한다. 그러면 “지금 클러스터에 문제 있어?” 라는 질문에 방금 자동 큐레이트된 최신 상태로 답한다. 실제로 이 큐레이터는 돌자마자 settlement-prod·velero의 Failed 파드, postgres 스케줄링 실패 같은 진짜 이상 징후를 잡아냈다.
루프가 닫혔다: 수집 → LLM 요약 → 저장 → 질의 → 답변. 심지어 서비스 자신의 설명을 기억에 넣으니, 서비스에게 “너 뭐야?”라고 물으면 그 기억을 검색해 스스로를 설명했다. 컨텍스트=RAM, 위키=디스크라던 기억층이, 이제 스스로 쓰고 읽는 시스템이 됐다.
⑤ 인터페이스 통합 — 새 걸 만들기보다 있는 걸 만난다
마지막으로 텔레그램 봇 연동. 여기서 현실의 제약을 배웠다. 텔레그램은 봇 하나당 폴러(getUpdates) 하나만 허용한다. 내가 붙이려던 봇(@ServerCheck40Bot)은 이미 Go로 짠 ChatOps 봇이 상주 폴링 중이었다. 새 폴러를 붙이면 409 충돌로 기존 봇이 깨진다.
그래서 답은 새 서비스가 아니라 기존 봇에 통합이었다 — 그 Go 봇에 “비명령 자연어 = 질문”으로 라우팅하는 핸들러 한 줄을 더해, 기존 명령(/서버, /파드, /힙…)은 그대로 두고 자연어 질문만 memory-qa로 흘렸다. 교훈: 돌아가는 시스템에 기능을 얹을 땐, 내 방식을 강요하지 말고 그 시스템이 있는 자리로 가라. 실무 인프라는 늘 제약을 먼저 내민다.
배관에서 만난 함정들 — 정직한 디버깅이 이긴다
“AI 서비스”의 실체는 이런 함정을 하나씩 뚫는 일이었다.
- 메모리 스크립트의 API 엔드포인트가 “가상 명세” 였다 — 실제로는 404.
/openapi.json을 직접 찔러 진짜 경로를 찾았다. - 인증 키가 함정이었다 — 설정 파일의 눈에 띄는
api_key두 개는 임베딩용(Gemini)이었고, 진짜 인증키는 따로 있는root_api_key였다. - 저장 API가 tenant 헤더를 요구하고, 특정 scope에선 “memory 파일”만 받는 등 규약이 있었다 — 문서가 아니라 에러 메시지가 답을 줬다.
- 배포 중 다른 세션과 클러스터 동시작업이 겹쳐 coordinator가 막았다 — 강제 우회 대신 기다렸다.
- 봇을 처음엔 잘못 골랐다 — 지적받고 즉시 롤백했다.
관통하는 원칙 하나: 추측하지 말고 실물을 찔러라. 진실의 원천은 read-back이다. 저장이 성공했다고 응답이 말해도, 되읽어 값이 일치하는지로만 성공을 판정했다. LLM 시대에도, 아니 LLM 시대라서 더, 검증은 코드가 한다.
고찰 — ‘AI 서비스’의 80%는 AI가 아니다
돌이켜보면 이 서비스에서 “지능”이 하는 일은 딱 두 곳이었다: 질문에 답하기, 상태를 요약하기. 나머지 전부 — 기억을 어디에 어떻게 저장하고, 무엇을 검색해 넘기고, 캐시로 아끼고, 게이트로 지키고, 스케줄로 갱신하고, 기존 봇에 얹는 것 — 는 평범한 분산 시스템 엔지니어링이었다.
이게 핵심 통찰이다. LLM을 서비스화한다는 건 LLM을 잘 쓰는 게 아니라, LLM을 신뢰할 수 있는 부품으로 만드는 배관을 잘 짜는 것이다. 기억이 없으면 매번 백지에서 답하고, 캐시가 없으면 느리고 비싸고, 게이트가 없으면 위험하고, 자율 갱신이 없으면 지식이 썩고, 인터페이스가 없으면 아무도 안 쓴다. 모델은 그 배관 한가운데 꽂히는 커넥터일 뿐이다.
그리고 그 배관을 짜는 힘은 결국 하네스 엔지니어링과 같은 근육이다 — 스펙을 명확히 하고, 실물로 검증하고, 실패를 정직하게 드러내고, 제약을 존중하는 것.
한 줄 요약
curl로 LLM을 부르는 건 데모고, 기억·캐시·공개·자율·인터페이스를 엮어 K3s에 상주시키면 서비스다. 그 사이의 거리가 곧 “AI 서비스화”의 실체이며, 그 거리의 대부분은 지능이 아니라 배관 — 즉 오래된 시스템 엔지니어링 — 으로 메워진다. LLM은 마법이 아니라, 잘 짠 하네스에 꽂았을 때 비로소 서비스가 되는 부품이다.