2026년 8월 11일
결국 While 루프다 - 프레임워크 없이 사내 AI 에이전트 플랫폼 만들기

안녕하세요. 볼트업에서 백엔드 소프트웨어 개발을 맡고 있는 Jay(손정연)입니다.
저희 테크 조직은 작년부터 Claude Code, Cursor와 같은 AI 코딩 에이전트를 업무에 도입해 쓰고 있는데요. 그 과정에서 직접 코드를 작성하는 비중은 줄고, 설계와 리뷰에 더 많은 시간을 쓰게 되었습니다.
자연스럽게 테크 직군이 아닌 동료들도 같은 도움을 받을 수 있으면 좋겠다는 생각으로 이어졌습니다. 동시에 AI 에이전트가 대신할 수 있는 업무들도 하나둘씩 보이기 시작했습니다.
그러나 시중에 나와 있는 Claude Code와 같은 AI 에이전트 도구는 모두가 사용하기에 몇 가지 문제점이 있었습니다. 첫째로 해당 도구들은 설치와 설정이 복잡해 기술에 익숙하지 않은 동료들이 사용하기에 진입장벽이 높았습니다. 둘째로 동료마다 AI 에이전트를 업무에 활용하는 정도가 모두 달라, 일괄 지급 시 실제 사용량 대비 비용이 과도하게 지출될 것으로 예상했습니다. 마지막으로 해당 도구들은 볼트업 시스템과 연동이 어려웠습니다. AI 에이전트가 실제 업무에 쓰이려면 볼트업 내 데이터를 조회·조작하고 충전기에 명령을 내릴 수 있어야 했습니다. 그러나 시중의 도구들이 제공하는 외부 시스템 연동 방식은 MCP(Model Context Protocol)를 사용하거나 로컬 CLI(Command Line Interface)를 실행하는 것이었습니다. 이 방식들은 통제·관리가 어렵고, 테크 직군이 아닌 동료들이 각자 설정해 쓰는 것은 불가능에 가까웠습니다.
그래서 저희는 볼트업 시스템 내부에 AI 에이전트를 직접 개발하기로 결정했습니다. 이것을 개발할 때도 AI 코딩 에이전트의 도움을 받았고, 2주 만에 ‘에이전트 루프 엔진’, ‘사용자 권한 관리’, ‘토큰 사용량 제한’ 기능을 갖춘 MVP를 만들었습니다. 곧바로 전사 구성원이 쓸 수 있게 배포했고, 이후로도 신규 에이전트 추가와 UI 개선을 이어오고 있습니다.
어떤 에이전트를 만들었을까요

저희가 만든 AI 에이전트의 닉네임은 ‘Voltbot(볼트봇)’입니다. 한 가지 업무만 수행하는 단일 에이전트가 아니라, 업무에 맞는 에이전트를 골라 쓸 수 있는 플랫폼으로 설계했습니다. Voltbot에 추가된 에이전트 몇 개를 소개하면 아래와 같습니다.
에이전트 | 하는 일 |
|---|---|
시스템 정책 에이전트 | GitHub 저장소의 코드를 검색하고 읽어서 시스템 동작을 해설 |
데이터 분석 에이전트 | Google BigQuery 등 데이터 웨어하우스에서 데이터를 조회하고 분석해 답변 |
법률 지원 에이전트 | 법령과 판례, 과거 법무검토 사례를 교차 조회해 구조화된 법률 검토 의견서를 작성 |
로그 분석 에이전트 | GCP 운영 로그와 분석 가이드, 코드를 교차 조회해 상황을 분석 및 진단 |
운영 지원 에이전트 | 문제가 있는 충전기의 상태·로그를 조회해 원인을 분석하고 필요 시 원격 재시동 및 제어 |
Voltbot Crew | 요청 내용에 맞는 에이전트가 자동으로 배정되어 처리 |
신규 에이전트를 추가하기 쉬운 구조라, 지금도 동료들의 제안을 받아 새로운 에이전트를 늘려가고 있습니다.
앞으로 다룰 내용
이번 글은 AI 에이전트를 이용하는 것을 넘어, 각자 상황에 맞는 에이전트를 직접 개발해보려는 분들께 도움을 주고자 작성되었습니다. 그래서 저희가 Voltbot을 개발하며 실제로 적용했던 구현 패턴들을 가능한 한 상세히 공유드릴 예정입니다. 특별한 방법은 아니지만, AI 에이전트를 처음 만들어보려는 분들께 시작점이 되었으면 합니다.
사용 기술
저희는 LangChain과 같은 LLM(Large Language Model) 애플리케이션 프레임워크를 사용하지 않았기 때문에 프로그래밍 언어의 제약이 없었습니다. 그래서 유지보수가 쉽고 익숙한 기술들을 위주로 선택했습니다. LLM은 사내에서 GCP(Google Cloud Platform)와 함께 계약해 쓰고 있던 Gemini를 그대로 사용하기로 했습니다. 나머지 영역까지 정리하면 다음과 같습니다.
영역 | 사용 기술 |
|---|---|
서버 | Kotlin, Spring Boot |
클라이언트 | TypeScript, React |
데이터베이스 | MySQL, BigQuery |
통신 프로토콜 | WebSocket, HTTP |
LLM | Gemini |
인프라 | Kubernetes, Google Cloud Storage |
서버 - 클라이언트 구조

설치 과정 없이 쓸 수 있어야 했기 때문에, 브라우저에서 바로 동작하는 웹 애플리케이션으로 설계했습니다. 서버 - 클라이언트 구조로 ‘에이전트 루프’, ‘요청 컨텍스트 관리’를 포함한 핵심 로직은 전부 서버 측에 두고, 클라이언트에는 UI 표현과 관련된 로직만 남겼습니다. 볼트업 시스템 내 다른 기능과의 연동도 다 서버 측에서 이루어져, 실행 권한을 중앙에서 관리할 수 있습니다. 같은 이유로 토큰 사용량 추적과 사용자별 비용 제한도 쉬워졌습니다.
클라이언트와 서버는 WebSocket 메시지와 HTTP API를 통해 통신합니다. WebSocket은 에이전트 응답을 스트리밍으로 서버에서 클라이언트로 전달하거나 도구 실행 결과를 전달하는 등 에이전트 루프 실행 중 양방향 통신이 필요한 경우에 주로 사용되고, HTTP API는 그 밖의 양방향 통신이 불필요한 데이터의 조회와 조작에 사용합니다.
WebSocket 통신 규약
WebSocket으로 주고받는 메시지는 JSON 형식으로, 하위 프로토콜로 아래와 같은 메시지들을 정의해 사용합니다.
클라이언트 → 서버
메시지 유형 | 언제 보내는가 |
|---|---|
USER | 사용자가 업무를 요청할 때 |
TOOL_APPROVAL | 사용자 허가가 필요한 도구 호출에 허가 또는 거절로 응답할 때 |
TOOL_ANSWER | 에이전트가 질문으로 추가 정보를 요청했을 때 답변할 때 |
INTERRUPTION | 진행 중인 작업의 중단을 요청할 때 |
서버 → 클라이언트
메시지 유형 | 언제 보내는가 |
|---|---|
AGENT | 에이전트의 최종 응답 텍스트를 흘려보낼 때 (여러 조각으로 나뉘어 전송) |
TOOL_CALL | 에이전트가 도구를 호출할 때 (실제 실행 전) |
TOOL_RESULT | 도구 실행이 끝났을 때 |
SESSION_STATUS | 세션 처리 상태와 토큰·비용 사용량이 바뀔 때 |
ERROR | 처리 중 오류가 발생했을 때 |
각 유형의 메시지를 주고받는 순서를 시퀀스 다이어그램으로 나타내면 다음과 같습니다.

사용자가 업무를 요청하면 클라이언트가 USER 메시지를 보내고, 서버는 곧바로 SESSION_STATUS로 세션이 RUNNING 상태가 되었음을 알립니다. 이때부터 해당 세션은 새로운 요청을 받지 않습니다.
이후 에이전트 루프가 돌기 시작합니다. LLM이 도구 호출로 응답하면 서버는 도구를 실제로 실행하기 전에 TOOL_CALL을 먼저 보내, 어떤 도구를 어떤 인자로 호출하려는지 사용자에게 노출합니다. 사용자 허가가 필요한 도구라면 클라이언트가 TOOL_APPROVAL을 보낼 때까지 기다렸다가 허가된 경우에만 실행하고, 에이전트가 추가 정보를 묻는 도구를 호출했다면 TOOL_ANSWER로 답변이 올 때까지 기다립니다. 도구 실행이 끝나면 성공 여부와 결과를 담아 TOOL_RESULT를 보냅니다. 이 과정은 LLM이 더 이상 도구를 호출하지 않을 때까지, 최대 N회 반복됩니다.
루프가 도는 중에 사용자가 중단을 요청하면 클라이언트가 INTERRUPTION을 보냅니다. 서버는 진행 중이던 도구 실행까지 마친 뒤 루프를 빠져나오고, 중단되었다는 사실을 ERROR로 알립니다.
LLM이 도구 호출이 아닌 최종 답변을 생성하기 시작하면, 서버는 응답을 여러 AGENT 메시지로 나눠 도착하는 대로 흘려보냅니다. 답변이 끝나면 세션이 다시 IDLE 상태가 되었음을 SESSION_STATUS로 알리고, 한 번의 요청 처리가 마무리됩니다. 그 사이 어느 단계에서든 오류가 발생하면 ERROR를 보낸 뒤 루프를 종료합니다.
SESSION_STATUS는 다이어그램에 표시된 시작과 끝뿐 아니라, 루프가 도는 동안 토큰·비용 사용량이 갱신될 때마다 함께 전송됩니다.
에이전트 루프
에이전트가 단발성 답변을 넘어 복잡한 과업을 수행하려면 LLM 호출을 여러 번 반복해야 합니다. 예를 들어 "이번 달 충전 매출 알려줘" 라는 요청을 처리하려면 데이터셋과 테이블 목록을 확인하고, 스키마를 조회하고, 쿼리를 작성해 실행하고, 결과를 해석하는 과정이 필요합니다. 이 반복을 담당하는 것이 에이전트 루프입니다.
루프의 핵심 구조는 단순합니다. LLM 응답이 도구 호출이면 해당 도구를 실행한 뒤 결과를 포함해 다시 LLM에 요청하고, 도구 호출이 아니면 그 응답을 최종 답변으로 보고 루프를 종료합니다. 이것을 코드로 간략히 구현하면 다음과 같습니다.
var iteration = 0
while (true) {
if (isInterrupted(session)) throw RequestInterrupted()
if (iteration >= MAX_ITERATIONS) throw MaxIterationsExceeded()
val context = buildContext(session)
val response = generateResponse(agent, context)
if (isToolCall(response)) {
handleToolCall(agent, session, response)
} else {
saveAgentResponse(session, response)
break
}
iteration += 1
}
LLM의 응답은 비결정적이라, 요청에 따라서는 최종 답변에 도달하지 못할 수도 있습니다. 빈도는 낮지만 루프가 종료되지 않고 무한히 도는 상황에 대비해, 사용자가 원하면 직접 루프를 중단할 수 있게 했습니다. 더불어 최대 반복 횟수를 지정해, 해당 횟수까지도 최종 답변이 없을 경우엔 요청 실패로 처리합니다.
하나의 대화 세션은 하나의 메인 루프만을 가집니다. 이때 루프의 상태에는 IDLE(유휴), RUNNING(실행 중) 두 가지가 있고, 이를 통해 이미 루프가 실행 중일 때 동시에 새로운 루프가 실행되는 것을 방지합니다. 루프는 최종 답변으로 정상 종료되거나 실행 중 에러가 발생했을 때 다시 유휴 상태로 되돌아갑니다.
컨텍스트 관리
에이전트 루프가 실행될 때, 직전까지 실행한 도구 호출의 결과를 LLM이 알고 있어야 최종 답변에 수렴할 수 있습니다. 기본적으로 LLM은 Stateless하기 때문에, 에이전트로서 동작하게 하려면 이전까지의 사용자 요청, 에이전트 답변, 도구 호출의 결과를 따로 저장해 API 요청에 매번 넘겨주어야 합니다.
저희는 사용자와 에이전트가 주고받는 모든 메시지를 관계형 데이터베이스에 저장했습니다. 그리고 메시지 유형을 아래와 같은 6가지로 나누었습니다. 여기서 SUMMARY와 SYSTEM은 사용자에게는 직접 노출되지 않고 컨텍스트를 구성할 때만 사용되는 유형입니다.
유형 | 의미 |
|---|---|
USER | 사용자가 보낸 업무 요청 |
AGENT | 에이전트가 도구 호출 없이 생성한 최종 답변 |
TOOL_CALL | 에이전트가 호출한 도구의 이름과 인자 |
TOOL_RESULT | 도구 실행 결과와 성공 여부 |
SUMMARY | 컨텍스트가 임계값을 넘어 압축했을 때 생성되는 요약 |
SYSTEM | 최종 답변 이후 에이전트에 후속 작업을 지시하는 내부 시스템 요청 |
저장된 모든 메시지가 그대로 컨텍스트로 구성되어 LLM에 전달되는 것은 아닙니다. 컨텍스트는 아래 코드와 같이 매 반복마다 새롭게 조립됩니다.

저희는 컨텍스트 윈도우가 빠르게 소진되는 것을 방지하고, 최근 요청의 컨텍스트에 더 큰 비중을 주기 위해 두 가지 규칙을 적용했습니다. 첫 번째는 요약입니다. 컨텍스트가 특정 크기를 넘어서면, 누적된 메시지들을 LLM으로 요약해 저장합니다. 그리고 요약 이전 메시지들은 컨텍스트를 구성할 때 포함하지 않고, 요약으로 대체합니다. 두 번째 규칙은 도구 호출·결과 메시지를 가장 최근 사용자 요청에 해당하는 것만 컨텍스트에 포함하는 것입니다. 이전 요청의 도구 호출 결과는, 중요한 내용이라면 그때의 에이전트 답변에 이미 반영되어 있는 경우가 많기 때문입니다.
컨텍스트 요약은 아래와 같은 형식으로 출력하도록 LLM에 함께 지시합니다. 형식을 지정하지 않고 자유롭게 요약하게 하면 이후 에이전트가 진행 중이던 작업을 놓치는 경우가 있었습니다.
- 목표: (사용자의 궁극적인 목적)
- 상태: (현재 진행 단계 및 직전 상태)
- 핵심 사항: (추출된 엔티티 및 결정 사항들)
- 해야 할 일: (아직 해결되지 않은 질문이나 에이전트가 해야 할 일)
도구 호출
AI 에이전트가 챗봇과 다른 점은 외부 시스템과 연동되어 특정 액션을 직접 수행한다는 것입니다. 이를 가능케 하는 것이 바로 도구 호출(Tool Calling)인데요. LLM에 도구의 기능과 인자 형식 등 사용법을 알려주면, LLM이 도구와 인자를 선택해 응답하는 방식입니다.
LLM에 전달되는 도구에 대한 설명과 필요한 인자, 그리고 도구 사용법은 아래와 같은 시스템 프롬프트로 전달됩니다. 시스템 프롬프트에 따라 LLM이 형식으로 응답하면 시스템이 내부에 포함된 JSON을 파싱해 대신 도구를 실행하고 결과를 저장합니다. 저장된 결과는 LLM이 다음번 에이전트 루프 반복 때 참고할 수 있도록 컨텍스트에 포함돼 전달됩니다.
# 사용 가능한 도구
## getWeather
특정 지역의 날씨 정보를 조회합니다.
arguments:
- location (string (필수)): 조회할 지역 이름
- days (integer): 조회할 예보 일수
# 도구 호출 방법
도구를 사용하려면 다음 형식으로 응답하세요:
<tool_call>
{"name": "toolName", "arguments": {"argumentKey": "argumentValue"}}
</tool_call>
도구 호출은 이제 많이 보편화된 패턴입니다. 그래서 요즘 LLM API들은 도구 호출에 특화된 요청값을 평문 메시지와 별도로 전달할 수 있게 인터페이스가 정의되어 있습니다. LLM 개발사에서 공식적으로 제공하는 기능인 만큼 성능 향상을 위한 최적화가 되어 있을 것으로 예상됩니다. 저희도 향후 해당 방식으로 구현을 변경해 평가 후 적용할 예정입니다.
가드레일
앞서 말했듯 LLM의 응답은 비결정적이라, LLM이 도구를 통해 시스템 내 기능을 사용할 때 항상 올바르게 사용한다는 보장이 없습니다. 따라서 LLM이 도구 사용을 요청할 때, 입력값을 검증하고, 실행 기록을 남기고, 출력값을 필터링하는 등 도구가 안전하게 사용될 수 있도록 하는 가드레일 로직이 필수적입니다.
저희가 적용한 가드레일 중 몇 가지를 소개하면 다음과 같습니다.
- 시스템 내 로그를 조회하는 도구가 실행될 경우, 에이전트 사용자가 누구인지 감사 로그를 남김
- 출력값에 이름, 이메일 등 민감 정보가 있을 시 마스킹 처리
- BigQuery 쿼리 실행 전 Dry Run을 먼저 수행해, 예상 스캔량이 50GB 이상일 경우 거부
가드레일은 도구가 안전하게 사용되도록 도울 뿐 아니라, 입력값과 출력값을 보정해 도구 호출이 성공하는 비율을 높이는 데에도 기여합니다. 이런 역할을 하는 가드레일의 예를 들면 다음과 같습니다.
- GitHub 코드 조회 시 지정한 브랜치가 없을 경우, main 브랜치를 대상으로 재조회
- 법령 검색 시 키워드에 일치하는 검색 결과가 없을 시, n-gram으로 기반으로 재시도
- 마크다운 표 출력 시 불필요한 문자 제거
이 밖에도 도구 호출의 안정성을 높이기 위해 저희가 적용한 방법이 몇 가지 더 있습니다.
첫 번째는 도구 호출에 실패했을 때, 결과에 실패 사유와 재시도 방법을 잘 남기는 것입니다. 이를 통해 다음번 에이전트 루프 반복에서 도구 호출 재시도를 유도하고, 실패를 스스로 회복하게 만듭니다.
두 번째는 도구 설명에 그 도구를 사용해야 할 상황을 조건문으로 명시하는 것입니다. “~할 때, 이 도구를 사용하시오.”라는 설명을 넣으면 LLM이 해당 도구를 사용해야 할 시점을 더 정확히 판단합니다.
스트리밍 처리
에이전트는 사용자의 요청을 짧으면 수 초, 길면 수 분 동안 처리합니다. 짧지 않은 시간이기 때문에 중간 과정에 대한 피드백이 없으면 사용자는 무슨 일이 일어나고 있는지 알 수가 없어 루프를 이탈할 수 있습니다. 저희는 기본적으로 도구 호출 시마다 어떤 도구를 호출했는지, 그리고 결과는 무엇인지 순서대로 노출합니다. 더 나아가 에이전트의 최종 답변도 스트리밍 방식의 LLM API를 사용해 조각(Chunk) 단위로 곧바로 사용자에게 전달합니다.
스트리밍 방식 LLM API는 보통 SSE(Server-Sent Events) 방식으로 응답 조각을 전달합니다. 저희는 SSE 조각이 전달될 때마다 data:로 시작하는 텍스트를 읽어 파싱 후 클라이언트와 연결된 WebSocket으로 전달합니다. 다만 응답이 도착하기 전까지는 LLM이 도구를 호출하려는 것인지, 최종 답변을 내놓는 것인지 구분할 수 없습니다. 그래서 저희는 도구 호출일 가능성이 남아 있는 동안은 조각을 전달하지 않고 버퍼에 모아, 도구 호출이 아닌 것으로 확정되는 시점에 모아둔 텍스트를 한 번에 전달하도록 구현했습니다. 그 이후에 오는 조각들은 모두 곧바로 클라이언트에 전달합니다. 코드로 보면 아래와 같습니다.
val buffer = StringBuilder()
var isAnswering = false
generateStream(agent, context) { chunk ->
buffer.append(chunk)
// 최종 답변으로 확정된 뒤에는 그대로 흘려보낸다
if (isAnswering) {
send(AgentResponse(chunk))
return@generateStream
}
// 도구 호출일 가능성이 남아 있으면 보류한다
if (isToolCallCandidate(buffer.toString())) {
return@generateStream
}
// 도구 호출이 아닌 것으로 확정. 모아둔 버퍼를 한 번에 보낸다
isAnswering = true
send(AgentResponse(buffer.toString()))
}
fun isToolCallCandidate(text: String): Boolean {
val trimmed = text.trim()
if (trimmed.contains(TOOL_CALL_OPEN_TAG)) return true
if (TOOL_CALL_OPEN_TAG.startsWith(trimmed)) return true
return false
}
지금까지 받은 조각들이 도구 호출 태그의 접두사인 경우, 즉 버퍼에 누적된 텍스트가 <, <t, <too, <tool_c인 동안에는 도구 호출의 후보로 간주해 클라이언트 전달을 보류합니다.
파일 첨부

업무를 요청하면서 파일을 함께 첨부하는 경우가 많습니다. 법률 검토를 요청하면서 계약서를 첨부하거나, 분석하려는 데이터를 파일로 전달하는 식입니다.
파일은 GCS(Google Cloud Storage)에 업로드합니다. GCS에서 제공하는 Signed URL 생성 기능을 이용해 짧은 시간에만 접근 가능한 업로드 URL을 만들고, 해당 URL로 파일을 업로드합니다.
대부분의 LLM API는 파일을 URL로 첨부하는 기능을 제공합니다. Gemini API도 마찬가지였고, 이 기능을 이용해 텍스트 메시지와 첨부된 파일을 함께 분석해 응답하도록 만들 수 있었습니다. 다운로드 URL도 같은 방식으로 Signed URL을 만들어 일시적으로만 접근 가능하게 합니다.
LLM API에 파일을 첨부하면 내용 분석을 위해 일반 텍스트보다 훨씬 더 많은 토큰을 소모합니다. 그래서 저희는 PDF, Markdown, MS Word와 같이 잘 알려진 형식의 파일이 업로드될 시에는 PDFBox나 Apache POI 같은 라이브러리를 사용해서 텍스트를 추출해 따로 저장합니다. 파일 크기에 따라 시간이 걸릴 수 있어서 업로드 요청의 응답과 분리해 비동기로 수행합니다. 이후 LLM API 요청 시 추출된 텍스트가 있다면 파일 URL 첨부 대신 텍스트를 전달합니다. 덕분에 토큰 소모와 응답 시간을 함께 줄일 수 있었습니다.
재시도 정책
LLM API 호출은 실패할 수 있습니다. LLM이 답변 생성에 실패하거나, 응답 지연으로 Read Timeout이 되거나, 응답은 왔으나 도구 호출 시 JSON 규약을 지키지 않았을 수 있습니다. 이런 실패는 단순히 호출을 다시 하는 것만으로 회복되는 경우가 많습니다.
그래서 저희는 LLM API 호출 실패 시, N회 재시도를 수행합니다. 재시도 동안에는 사용자에게 호출 실패를 노출시키지 않고, 재시도까지 모두 실패했을 때 사용자에게 알리고 에이전트 루프를 종료시킵니다. 단순히 재시도를 넣는 것만으로도 최종 답변까지 도달하는 비율이 올라갔습니다.
LLM API의 Read Timeout 시간은 사용하는 목적에 따라 다르게 설정했습니다. 스트리밍 방식으로 사용자에게 전달할 답변을 생성하는 경우에는 30초로 두었습니다. 스트리밍 방식일 때 Read Timeout은 첫 조각이 도착하기까지의 시간으로 측정되며, 사용자 요청에 대한 답변은 Thinking이 발동하더라도 보수적으로 30초 내에는 첫 조각이 오는 것으로 관측했기 때문입니다. 반면에 큰 파일에서 텍스트를 추출하기 위해 LLM API를 비스트리밍 방식으로 사용할 땐 600초로 길게 설정했습니다. 파일 분석에 시간이 꽤 소요되고, 모든 텍스트가 출력된 뒤에 한 번에 응답되기 때문입니다.
Human in the Loop

에이전트가 항상 처음부터 끝까지 혼자 처리할 수 있는 것은 아닙니다. 되돌릴 수 없는 작업을 실행하기 전에는 사용자 승인을 받아야 하고, 요청이 모호하면 추가 정보를 물어봐야 합니다.
에이전트 루프 도중에 사용자에게 승인을 구하거나, 추가 정보를 묻는 기능을 구현할 때 까다로웠던 점은 스레드 간 메시지 전달입니다. 루프는 이미 별도 스레드에서 실행 중이고, 사용자로부터 온 응답은 다른 WebSocket 메시지 처리 스레드에서 수신됩니다. 다행히 현재 실행 중인 에이전트 루프와 사용자 응답이 같은 WebSocket 연결로 들어오기 때문에, 두 스레드가 같은 애플리케이션 프로세스 안에서 실행된다고 가정할 수 있었습니다.
저희는 에이전트 루프의 스레드가 JVM의 CompletableFuture로 사용자의 승인·답변을 기다리게 했습니다. 그리고 스레드 간 공유하는 HashMap에 해당 CompletableFuture를 대화 세션 UUID를 Key로 저장했습니다. 이후 사용자의 승인·답변이 별도의 WebSocket 메시지 처리 스레드에 도달하면 CompletableFuture를 complete하며 전달해 루프 스레드의 대기를 해제합니다. 이것을 코드로 표현하면 아래와 같습니다.
class ToolApprovalStore {
private val pending = ConcurrentHashMap<String, CompletableFuture<Boolean>>()
// 루프 스레드가 호출한다. 사용자 응답이 올 때까지 대기한다
fun waitForApproval(uuid: String): Boolean {
val future = CompletableFuture<Boolean>()
pending[uuid] = future
return try {
future.get(APPROVAL_TIMEOUT_SECONDS, TimeUnit.SECONDS)
} catch (_: TimeoutException) {
false // 응답이 없으면 거절로 처리한다
} finally {
pending.remove(uuid)
}
}
// WebSocket 메시지를 수신한 스레드가 호출한다. 대기를 해제한다
fun submitApproval(uuid: String, isApproved: Boolean) {
pending[uuid]?.complete(isApproved)
}
}
에이전트가 질문을 던지고 사용자의 답변을 기다리는 것도 동일한 구조로 구현했습니다. 대기 결과가 Boolean 대신 답변 텍스트와 첨부 파일이라는 점만 다릅니다.
사용자가 에이전트 진행 중 자리를 떠나거나 승인·답변 요청을 아예 인지하지 못할 수 있습니다. 그 경우를 대비해 요청 만료 시간을 두었고, 만료되면 거절·답변 없음 처리 후 에이전트 루프가 이어서 진행됩니다.
벡터 DB 없이 RAG
RAG(Retrieval-Augmented Generation)는 LLM이 답변을 생성하기 전에 외부 데이터베이스나 문서에서 관련된 정보를 찾아, 그 내용을 바탕으로 더 정확한 답변을 생성하는 기술입니다. 이를 통해 학습 데이터에 포함된 정보뿐만 아니라 최신 정보까지 반영한 답변을 생성할 수 있습니다.
일반적인 방식의 RAG는 LLM이 참조할 데이터들을 임베딩 벡터(Embedding Vector) 형태로 변환해 데이터베이스에 저장합니다. 그리고 사용자 요청이 들어오면 해당 요청도 임베딩 벡터로 변환해 코사인 유사도가 높은 데이터를 검색합니다. 의미적으로 비슷한 데이터는 임베딩 벡터 공간에서도 가깝게 위치하기 때문입니다.
그러나 빠르게 벡터 검색이 동작하려면, 이에 특화된 데이터베이스가 필요합니다. 저희는 데이터베이스를 새로 늘리고 싶지 않았기 때문에, 더 단순한 방식으로 RAG를 구현했습니다.

두 단계로 RAG 과정을 나눴는데요. 첫 번째는 키워드 검색 등으로 요약이 담긴 목록을 조회하는 과정이고, 두 번째는 상세 내용을 조회하는 과정입니다. 그리고 이 과정을 수행할 수 있는 두 개의 도구를 세트로 에이전트에게 제공합니다.
예를 들어 법률 정보에 대한 RAG가 필요할 경우, 법률 목록을 조회하는 도구로 ‘민법’을 키워드 검색하면 아래와 같은 결과를 얻습니다.
'민법' 주요 조문 검색 결과 (총 6건 / 6건 표시)
[284415] 민법 제390조 (채무불이행과 손해배상) | 법률 | 법무부 | 시행일: 2026-03-17 [284415] 민법 제393조 (손해배상의 범위) | 법률 | 법무부 | 시행일: 2026-03-17 [284415] 민법 제664조 (도급의 의의) | 법률 | 법무부 | 시행일: 2026-03-17 [284415] 민법 제667조 (수급인의 담보책임) | 법률 | 법무부 | 시행일: 2026-03-17 [284415] 민법 제750조 (불법행위의 내용) | 법률 | 법무부 | 시행일: 2026-03-17 [284415] 민법 제756조 (사용자의 배상책임) | 법률 | 법무부 | 시행일: 2026-03-17 이어서 상세 조회 도구로 민법 제667조의 본문을 가져옵니다.
법령명: 민법 | 법령 ID: 001706 | 공포일자: 20260317 | 시행일자: 20260317
제667조 (수급인의 담보책임)
① 완성된 목적물 또는 완성전의 성취된 부분에 하자가 있는 때에는 도급인은 수급인에 대하여 상당한 기간을 정하여 그 하자의 보수를 청구할 수 있다. 그러나 하자가 중요하지 아니한 경우에 그 보수에 과다한 비용을 요할 때에는 그러하지 아니하다.
② 도급인은 하자의 보수에 갈음하여 또는 보수와 함께 손해배상을 청구할 수 있다. <개정 2014.12.30>
③ 전항의 경우에는 제536조의 규정을 준용한다.
이와 같은 두 도구를 이용해 에이전트가 루프 중 필요한 정보를 점진적으로 찾아가며 컨텍스트를 확장해 나갑니다.
요약 목록을 검색하는 도구를 만들기 위해, 미리 참조 문서들의 요약을 만들어 놓는 작업이 필요합니다. 참조 문서들을 관리하는 메뉴를 만들어, 문서를 업로드하면 마찬가지로 LLM API를 사용해 본문 텍스트를 추출하고 요약을 미리 생성해 저장합니다. 저희는 문서 제목과 요약에 포함된 키워드 검색을 빠르게 하기 위해서, MySQL의 FULLTEXT INDEX와 n-gram 파서를 활용했습니다.
두 단계로 RAG를 수행하는 것은 외부 데이터 조회가 필요한 경우에도 동일하게 적용할 수 있습니다. 예를 들어 GitHub 코드에 대해 RAG를 수행하는 상황이라고 가정해보겠습니다. GitHub에서 제공하는 키워드 검색 API로 해당 키워드가 포함된 코드 목록을 조회하는 도구를 만들고, 코드 상세 조회 API로 특정 코드의 내용을 가져오는 도구를 만듭니다. 이것으로 에이전트가 GitHub 저장소 내 코드를 점진적으로 탐색하고 필요한 코드 내용을 컨텍스트에 추가할 수 있게 됩니다.
물론 이 방식에 한계가 없는 것은 아닙니다. 키워드 검색은 같은 내용을 다른 표현으로 적어둔 자료를 놓치기 때문에, 의미 검색(Semantic Search)의 정확도는 임베딩 벡터 방식보다 낮을 수 있습니다. 저희는 이를 검색 범위를 넓히는 것으로 보완했습니다. 키워드가 포함된 자료를 넉넉하게 목록으로 보여주고, 그중 무엇이 실제로 필요한지는 LLM이 판단하도록 맡긴 것입니다. 의미를 파악하는 일을 검색 단계가 아니라 에이전트에게 위임해, 간접적으로 의미 검색과 비슷한 효과를 얻는 셈입니다. 대신 데이터베이스를 새로 늘리지 않아 구현이 단순하고, 검색 API가 있는 데이터 소스라면 어디에나 같은 방식을 적용할 수 있으며, 원본이 바뀌어도 임베딩을 다시 만들 필요가 없어 유지보수도 쉽습니다. 정확도를 조금 내주고 이 세 가지를 얻은 셈인데, 저희 상황에서는 충분히 해볼 만한 트레이드오프였습니다.
추상화

Voltbot에 여러 종류의 에이전트를 붙일 수 있었던 이유는, AI 에이전트들이 공통으로 공유하는 로직과 서로 달라지는 로직이 비교적 명확히 구분되기 때문입니다.
에이전트는 사용자의 요청을 어떻게 처리해야 할지 명시한 시스템 프롬프트와 도구들로 정의됩니다. 에이전트 루프의 실행 흐름은 에이전트 유형과 무관하게 큰 틀에서 동일합니다. 그래서 저희는 아래와 같은 Agent 인터페이스를 정의하고, 인터페이스 구현체를 추가하는 방식으로 신규 에이전트를 추가하게끔 만들었습니다. 에이전트 루프는 Agent 인터페이스에만 의존하며 구현체가 추가되더라도 동일하게 동작해 수정할 필요가 없습니다.
interface Agent {
val name: String // 사용자에게 표시되는 이름
val description: String // 사용자에게 표시되는 설명
val systemInstruction: String // 이 에이전트의 업무 규칙
val tools: List<Tool> // 사용할 도구 목록
// 아래부터는 모든 에이전트가 공유한다. 구현체는 재정의하지 않는다
// 업무 규칙에 도구 목록과 호출 규약을 덧붙여 최종 프롬프트를 완성한다
val systemInstructionWithTooling: String
get() = """ |당신은 $name 입니다. $description | |현재 시각: ${currentDateTime()} | |# 요구사항 |$systemInstruction | |# 사용 가능한 도구 |$toolDescriptions | |# 도구 호출 방법 |$TOOL_CALL_PROTOCOL """.trimMargin()
// 각 도구가 선언한 스키마를 프롬프트 텍스트로 렌더링한다
private val toolDescriptions: String
get() = tools.joinToString("\n\n") { tool ->
val schema = tool.argumentsSchema
val properties = schema.properties.entries.joinToString("\n") { (key, property) ->
val required = if (key in schema.required) " (필수)" else ""
" - $key (${property.type}$required): ${property.description}"
}
""" |## ${tool.name} |${tool.description} | |arguments: |$properties """.trimMargin()
}
}
에이전트가 사용하는 도구도 설명과 호출에 필요한 인자로 정의됩니다. 그래서 도구도 아래와 같은 Tool 인터페이스로 정의하고, 새로운 도구는 인터페이스를 구현하는 방식으로 추가합니다. 도구를 추가하는 쪽도 마찬가지로 에이전트 루프 수정은 필요 없습니다.
interface Tool {
val name: String
val description: String
val argumentsSchema: ArgumentsSchema
fun execute(arguments: Arguments, metadata: Metadata): Result
// 실패도 예외가 아니라 LLM 이 읽을 텍스트로 반환한다
data class Result(val isSuccess: Boolean, val content: String)
// 프롬프트의 도구 설명으로 렌더링된다
data class ArgumentsSchema(
val properties: Map<String, Property>,
val required: List<String>
) {
data class Property(val type: String, val description: String)
}
// 실행 주체 정보
data class Metadata(
val userEmail: String, // 실행 시점 권한 검증
val sessionUuid: String, // 세션 단위 사용량 확인
val clientIpChain: String? // 접근 기록
)
}
에이전트 내부에서 사용하는 LLM도 인터페이스로 만들었습니다. 저희는 현재 Gemini를 사용하고 있지만 향후 더 나은 성능의 모델이 나오거나, 비용이 더 저렴한 모델이 있을 경우 쉽게 교체하기 위함입니다. 아래와 같이 텍스트와 이미지, 그리고 파일로 이루어진 컨텍스트를 받아 답변을 생성하는 스트리밍, 비스트리밍 메서드 두 개를 정의했습니다.
interface LanguageModel {
// 스트리밍 생성. 조각이 도착할 때마다 콜백이 호출된다
fun generateStream( context: Context, systemInstruction: String? = null, options: Options? = null, onResponse: (StreamResponse) -> Unit )
// 비스트리밍 생성. 라우팅과 대화 요약처럼 중간 출력이 필요 없는 호출에 사용한다
fun generate( context: Context, systemInstruction: String? = null, options: Options? = null ): Response
data class Options(
val readTimeout: Duration? = null,
val temperature: Double? = null
)
data class Response(
val content: String,
val totalTokenCount: Long, // 사용량 집계에 사용
val promptTokenCount: Long // 컨텍스트 압축 임계값 판단에 사용
)
sealed class StreamResponse {
data class Chunk(
val content: String
) : StreamResponse()
data class End(
val totalTokenCount: Long,
val promptTokenCount: Long,
val finishReason: String? = null // 재시도 정책 분기에 사용
) : StreamResponse()
}
}
에이전트 라우팅

에이전트의 수가 늘어나면서 사용자가 매번 요청에 적합한 에이전트를 선택하는 것이 번거로워졌습니다. 더 나아가 특정 에이전트와 나눈 요청 컨텍스트를 다른 에이전트에 그대로 전달하고 싶다는 요구사항도 생겼습니다. 그래서 저희는 ‘Voltbot Crew’라는 닉네임으로 사용자 요청에 적합한 에이전트가 자동으로 선택되어 처리하는 에이전트 유형을 새로 추가했습니다.
사용자 요청이 에이전트 루프로 넘어가기 전에, LLM이 에이전트 유형을 먼저 고르는 단계를 두었습니다. 현재 사용자가 권한을 가지고 있는 에이전트 목록을 취합하고, 다음과 같은 시스템 프롬프트로 컨텍스트와 함께 LLM에 전달합니다. 그리고 에이전트가 선택되면 사용자에게 알리기 위해, 에이전트 루프가 시작되기 전 “데이터 분석 에이전트가 답변드리겠습니다!” 같은 문장을 먼저 클라이언트에 전송합니다.
# 역할
당신은 사용자 요청에 가장 적합한 에이전트를 선택하는 라우팅 전문가입니다.
지금까지의 대화 내역을 보고, 아래 후보 에이전트 중 사용자 요청을 가장 잘 처리할 수 있는 에이전트를 하나만 선택하세요.
# 후보 에이전트
{에이전트 설명 목록}
# 출력 형식
선택한 에이전트의 이름만 정확히 출력하세요.
다른 설명, 기호, 따옴표 없이 이름 그 자체만 출력해야 합니다.
토큰 소비를 줄이기 위해, 만약 사용자에게 권한이 있는 에이전트가 하나라면 라우팅을 LLM에 요청하지 않고 곧바로 해당 에이전트를 사용합니다.
권한, 비용 관리

사내에서 공식적으로 사용하는 업무 도구가 되려면 권한과 비용 관리가 필수입니다. 사용자의 업무 범위에 따라서 조회·실행 권한이 다르고, 필요한 토큰의 양도 다르기 때문입니다. 그래서 이 기능들은 MVP의 기본 요구사항이었습니다.
저희는 사용자의 권한을 에이전트 단위로 부여했습니다. 도구 단위가 아닌 이유는, 정의한 도구의 일부만 사용 가능한 에이전트는 제대로 된 업무 수행이 불가능하다고 판단했기 때문입니다. 에이전트를 하나의 업무 단위, 함께 실행되어야 할 도구들의 최소 집합으로 보고 이와 같이 설계했습니다.
권한은 사용자 계정 단위로 부여됩니다. 사용자가 웹 클라이언트를 통해 특정 에이전트의 사용 권한을 요청하고, 승인권자가 승인 후 사용이 가능한 구조입니다. 권한 요청과 승인·거절 여부는 이메일을 통해서 요청자와 승인권자에게 전달됩니다.
권한은 사용자 역할에도 부여할 수 있습니다. 여러 사용자가 같은 역할을 공유하며, 보통 직군에 따라 역할이 할당됩니다. 역할에 미리 여러 에이전트에 대한 권한을 부여해두고, 사용자에게 역할을 할당해 일괄로 권한을 부여할 수 있습니다. 직군에 따라 개인별 승인이 불필요한 경우가 많아 편의 기능으로 추가했습니다.
비용은 LLM 토큰 단위로 측정합니다. 거의 모든 LLM API가 사용량을 응답에 담아 주기 때문에, 이를 사용자별·대화별로 데이터베이스에 영속 저장합니다. 그리고 사용자별 한도를 초과하면 신규 요청을 거부합니다. 일간 한도와 주간 한도가 나뉘어 있고, 일간 한도는 요일별 유동적 토큰 사용을 위해 주간 한도를 7로 나눈 값의 1.5배로 설정합니다.
토큰 사용량은 에이전트 루프가 실행되는 중에도 사용자가 실시간으로 확인할 수 있게끔 SESSION_STATUS라는 WebSocket 메시지로 클라이언트에 전달되어 표시됩니다.
평가와 개선

배포 이후에도 Voltbot은 꾸준히 개선되고 있습니다. 기본 시스템 프롬프트를 보완하거나, 쓰는 도구를 손보거나, 새로운 도구를 붙이거나, 루프 엔진 자체를 수정하는 식입니다.
그런데 저희가 수정한 사항이 에이전트 성능을 개선했는지 어떻게 알 수 있을까요? 성능이 더 나빠지지는 않았다는 것을 어떻게 보장할 수 있을까요? 저희는 에이전트의 성능을 평가할 방법이 필요했습니다.
저희는 클라이언트 없이 에이전트에게 요청을 보내 실행하고, 실행 과정과 최종 답변을 평가하는 통합 테스트를 만들었습니다. 이때는 테스트 더블(Test Double)을 사용하지 않고, 실제 LLM API와 작동하는 도구들을 사용해 운영 환경과 동일한 조건에서 테스트됩니다.
에이전트의 실행 과정과 결과는 비결정적이기 때문에 일반적인 Assert 기반 테스트로는 평가가 어렵습니다. 그래서 평가 또한 LLM이 진행합니다. 이와 같은 방법을 LLM as a Judge라고도 부릅니다. 테스트셋으로 사용할 요청 메시지와 평가 기준, 점수 가중치를 정의합니다. 해당 요청을 에이전트에 보내, 실행 과정과 결과를 보고 LLM이 평가 기준을 지켰는지 PASS, FAIL로 판단합니다. 마지막으로 평가 기준별 가중치를 적용해 평균 점수를 구합니다.
이러한 방식을 루브릭 평가라 하고, LLM이 바로 점수를 출력하는 것보다 편향이 적다고 알려져 있어 사용했습니다. 저희는 루브릭 평가를 테스트 케이스별로 N회 수행해 평균 점수와 95% 신뢰 구간을 구합니다. 그리고 최근 Git 커밋 ID와 함께 점수의 히스토리를 남깁니다. 이후 에이전트를 개선할 때는 이 점수를 비교해 성능이 나아졌는지 판단합니다.
LLM as a Judge는 평가가 불가능했던 것을 평가 가능하게 만들었다는 점에서 의의가 있지만, 평가에 사용되는 모델마다 편향이 있고, 모델이 바뀌면 평가가 달라지는 경우가 많아 근본적인 한계가 있습니다. 그래서 95% 신뢰 구간 점수가 큰 폭으로 올랐을 때만 유의미한 개선으로 봅니다.
향후 계획
저희는 향후 에이전트 루프 엔진을 아래와 같이 개선할 계획을 가지고 있습니다.

첫 번째로 현재는 하나의 메인 에이전트가 모든 도구 호출을 수행하는데, 일부 작업을 서브 에이전트에 위임하는 기능을 추가할 생각입니다. 특히 필요한 데이터를 탐색하고 조회하는 과정에서 불필요한 중간 컨텍스트들이 많이 생기는데, 서브 에이전트에 데이터 탐색을 위임하고 최종 데이터만 요약해 메인 에이전트가 전달받는다면 컨텍스트 윈도우를 더 효율적으로 사용할 것으로 보입니다.
두 번째로 도구 호출 중에는 서로 순서 의존성이 없는 경우가 있습니다. 현재는 한 순간에 하나의 도구만 순차적으로 실행하는 구조인데, 여러 도구 호출 요청을 LLM으로부터 한 번에 받아 병렬로 실행하는 기능을 구현할 예정입니다. 구현된다면 더 짧은 시간 안에 과업을 마치고 사용자에게 피드백을 줄 수 있을 것입니다.
마지막으로 LLM API에서 제공하는 프롬프트 캐싱이 더 잘 적중하도록 프롬프트 구조를 개선할 예정입니다. 에이전트는 실행 중에 사용자 요청과 도구 호출 결과를 누적하며 유사한 프롬프트를 반복적으로 LLM API에 전달합니다. 프롬프트 캐싱을 통해 LLM이 캐싱된 토큰을 읽게 하면, 비용과 응답 시간을 모두 줄일 수 있을 것으로 기대합니다.
마치며

저희가 만든 에이전트 플랫폼은 올해 5월에 사내 구성원들에게 처음 오픈해 3개월 정도 운영했습니다. 그동안 사내 구성원의 90% 이상이 가입했고, 가입자 중 약 20%가 매일 이용하는 업무 도구가 되었습니다.
숫자보다 눈에 띄는 변화는 실제로 줄어든 업무입니다. 빈번했던 데이터 추출·분석 요청은 이제 데이터 분석 담당자가 아닌 데이터 분석 에이전트에게 먼저 요청됩니다. 그리고 사용자 앱이나 백엔드 시스템의 로직이 궁금할 땐 이제 담당 엔지니어가 아닌, 시스템 정책 에이전트에 먼저 문의합니다.
단발성으로 처리하던 운영 업무가 줄었고, 그 일을 맡던 담당자들은 그만큼의 시간을 다른 일에 쓸 수 있게 되었습니다. 앞으로 LLM의 성능은 더 좋아지고 비용은 낮아질 테니, 지금보다 많은 업무를 에이전트가 맡게 될 것이라고 생각합니다.
여기까지 저희가 AI 에이전트를 만들며 적용한 내용들을 정리해봤습니다. AI 에이전트를 만들어보고자 하는 분들께 도움이 되었으면 좋겠습니다. 긴 글 읽어주셔서 감사합니다.
참고문헌
[1] Model Context Protocol. Specification. https://modelcontextprotocol.io/specification/2026-07-28
[2] S. Yao, J. Zhao, D. Yu, et al. ReAct: Synergizing Reasoning and Acting in Language Models. ICLR, 2023. https://arxiv.org/abs/2210.03629
[3] Anthropic. Building Effective AI Agents. https://www.anthropic.com/engineering/building-effective-agents
[4] Google. Function calling with the Gemini API. https://ai.google.dev/gemini-api/docs/function-calling
[5] MDN Web Docs. Using server-sent events. https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events
[6] Google Cloud. Signed URLs. https://docs.cloud.google.com/storage/docs/access-control/signed-urls
[7] Google. Document understanding. https://ai.google.dev/gemini-api/docs/document-processing
[8] P. Lewis, E. Perez, A. Piktus, et al. Retrieval-Augmented Generation for Knowledge-Intensive NLP Tasks. NeurIPS, 2020. https://arxiv.org/abs/2005.11401
[9] Oracle. MySQL 8.0 Reference Manual: ngram Full-Text Parser. https://dev.mysql.com/doc/refman/8.0/en/fulltext-search-ngram.html
[10] L. Zheng, W.-L. Chiang, Y. Sheng, et al. Judging LLM-as-a-Judge with MT-Bench and Chatbot Arena. NeurIPS Datasets and Benchmarks Track, 2023. https://arxiv.org/abs/2306.05685
[11] Google. Context caching. https://ai.google.dev/gemini-api/docs/caching