“모델이 JSON 형식만 잘 지켜주면 되겠지”라고 생각했던 순간부터, 실제 문제 해결은 시작되었습니다.
사내 플랫폼에 자연어로 서비스를 조작할 수 있는 AI 에이전트를 붙이는 작업을 진행하였습니다. 구성은 두 개의 서비스로 나누었습니다. MCP 서버는 도구 목록과 정책·승인·감사를 담당하고, 에이전트 호스트는 대화 처리와 LLM 호출, 그리고 도구 실행 루프를 담당하는 구조였습니다. 원칙은 하나였습니다. 추론은 범용 LLM에 위임하고, 실행 통제는 플랫폼이 직접 소유한다는 것이었습니다.
초기 구현은 동작은 하였습니다. 다만 “동작한다”와 “믿을 수 있다”는 달랐습니다. 루프의 판단이 프롬프트에 붙인 도구 목록과, 모델이 텍스트로 써 준 JSON을 직접 파싱하는 방식에 의존하고 있었기 때문입니다. 형식이 한 번 어긋나면 재시도 후 도구를 한 번도 부르지 못한 채 종료되었습니다.
이 글에서는 프롬프트 JSON 파싱 방식의 도구 루프를 provider native tool calling으로 전환하고, 손으로 만들었던 LLM 호출과 MCP 클라이언트를 Spring AI 2.0으로 교체한 과정을 정리하였습니다. 증상은 여러 개였지만 뿌리가 하나였다는 점, 그리고 실행 계층을 통째로 갈아 끼우면서도 통제 계층은 한 줄도 바꾸지 않았다는 점이 이번 작업의 핵심입니다.
1. 기술 선택 배경 — 왜 프롬프트 JSON 파싱은 실패했는가
전환을 결정하기 전에, 이미 동작하고 있던 루프를 전수 리뷰하였습니다. 리뷰에서 드러난 문제는 다음과 같았습니다.
-
판단이 형식에 종속되었습니다. 도구 목록을 문자열로 프롬프트에 붙이고, 모델이 텍스트로 쓴 {"action":"call_tool", ...} 을 직접 파싱하였습니다. 형식을 어기면 2회 재시도 후 도구 호출 없이 종료되었습니다.
-
관측이 무제한 누적되었습니다. 매 스텝마다 이전 관측 전문을 다시 이어 붙여 사용자 메시지를 새로 조립하였습니다. 스텝이 늘어날수록 프롬프트가 선형으로 부풀었습니다.
-
도구를 부르지 않고 바로 답해도 막을 장치가 없었습니다. 시스템 프롬프트에 “업무 데이터를 지어내지 말라”고 적어 두었을 뿐, 강제하는 코드는 없었습니다.
-
도구가 40개를 넘는데 매 요청에 전량을 전송하였습니다. 스텝당 약 4,000 토큰이 도구 목록만으로 소모되었습니다.
-
MCP 클라이언트를 raw HttpClient와 JSON-RPC로 직접 구현하였고, initialize 이후 notifications/initialized를 보내지 않아 프로토콜 위반 소지가 있었습니다.
처음에는 이 항목들을 각각의 개선 과제로 나열하였습니다. 그러나 원인을 따라가 보니 앞의 세 가지는 뿌리가 하나였습니다. LLM 호출 포트인 AgentAskRequest가 prompt와 systemPrompt 라는 문자열 두 개뿐이었다는 점입니다.
[문제의 뿌리 — 문자열 두 개짜리 엔진 포트]
// Before: the engine port had no notion of a messages array.
public class AgentAskRequest {
private String prompt; // question + tool list + observations, all in here
private String systemPrompt; // rules go here
}
메시지 배열이라는 개념이 구조에 없으니, 대화 이력을 담을 자리도 없고 도구 목록을 실을 자리도 없었습니다. 결국 도구 목록도 관측도 전부 문자열로 프롬프트에 밀어 넣는 방식 외에는 나올 수가 없었습니다. 즉, native tool calling을 안 쓴 것이 아니라 쓸 수 없는 구조였습니다.
결론은 명확했습니다. 개별 증상을 하나씩 고치는 대신, 엔진 포트를 messages + tools 기반으로 올리고 실행 경로를 provider native tool calling으로 전환하기로 하였습니다. 동시에 직접 구현했던 provider REST 호출과 MCP 클라이언트를 Spring AI 2.0으로 교체하기로 하였습니다. 이미 root build.gradle에 spring-ai-bom:2.0.0 이 선언되어 있었음에도, 클라이언트 쪽 모듈은 Spring AI 클래스를 하나도 import하지 않고 같은 기능을 손으로 만들고 있었기 때문입니다.
2. 적용 과정 및 구현
작업은 네 단계로 진행하였습니다. 담당 경계 정의, 엔진 포트 전환, 도구 이름·목록 통제, 그리고 통제 계층 불변 확인입니다. 각 단계는 독립적으로 롤백할 수 있도록 순서를 잡았습니다.
2.1 담당 경계 정의 — 무엇을 라이브러리에 맡기고 무엇을 남길 것인가
코드를 옮기기 전에 경계부터 정하였습니다. 라이브러리로 대체할 수 있는 것과, 우리가 반드시 소유해야 하는 것을 구분하는 작업이었습니다.
|
구분 |
Spring AI 담당 |
에이전트 직접 담당 |
|---|---|---|
|
범위 |
LLM 호출, 메시지·tool_calls 변환, MCP protocol/client, tool schema, metrics·tracing |
loop 진행 정책, 도구 허용 정책, risk·approval, 호출 횟수·시간 상한, audit, SSE 이벤트 |
|
기준 |
프로토콜과 전송 — 표준이 있고 우리가 더 잘 만들 이유가 없는 영역 |
통제와 책임 — 우리 플랫폼의 정책이 들어가는 영역 |
이 표가 이후 모든 결정의 기준이 되었습니다. “이건 어디에 두어야 하는가”라는 질문이 나올 때마다 표준의 문제인지 정책의 문제인지만 물으면 답이 나왔습니다.
2.2 엔진 포트 전환 — ChatModel을 직접 호출하되, 실행은 넘기지 않는다
Spring AI를 도입하면서 가장 주의한 지점은 도구 자동 실행이었습니다. Spring AI에는 모델이 요청한 도구를 프레임워크가 알아서 실행해 주는 경로가 있습니다. 편리하지만, 그 경로를 타면 정책·승인·감사를 우회하게 됩니다.
확인해 보니 Spring AI 2.0의 ChatModel 계층은 ToolCallingManager.resolveToolDefinitions만 호출하고 executeToolCalls는 부르지 않았습니다. 즉 ChatModel을 직접 호출하는 한 도구 자동 실행은 일어나지 않습니다. 이 성질을 이용해, 도구 정의는 스키마로만 넘기고 실행 의도만 돌려받는 어댑터를 두었습니다.
[실행 의도만 받는 엔진 포트]
// The engine returns only the model's tool-call intent. It never executes.
public interface NativeToolCallingEngine extends AgentEngine {
AgentNativeToolResponse askWithTools(AgentNativeToolRequest request);
}
// Calling ChatModel directly: ToolCallback only supplies the schema, it is not run.
ChatResponse chatResponse = chatModel.call(new Prompt(nativeMessages(request), options));
AssistantMessage assistant = chatResponse.getResult().getOutput();
List<AgentNativeToolCall> toolCalls = toolCalls(assistant); // structured tool_calls
이전 스텝의 도구 호출과 결과는 문자열로 다시 이어 붙이지 않고, AssistantMessage와 ToolResponseMessage로 메시지 배열에 쌓았습니다. 관측 누적 문제가 프롬프트 재조립이 아니라 대화 배열이라는 제자리로 옮겨간 것입니다.
다중 tool call은 받아서 순차 실행하고, 모든 tool_call_id에 반드시 응답하도록 하였습니다. 허용되지 않은 도구나 중복 호출은 예외로 루프를 죽이지 않고 ERROR: 로 시작하는 관측으로 되돌려, 모델이 다시 판단하게 하였습니다.
2.3 도구 이름과 목록 — 제약은 경계에서만 흡수한다
전환 과정에서 걸린 실무적인 제약이 하나 있었습니다. 도구 이름을 navi.<service>.<verb> 형태로 쓰고 있었는데, OpenAI 계열 function name 규격이 점(.)을 허용하지 않았습니다.
이름 체계를 바꾸는 선택지도 있었지만, 그렇게 하면 MCP·로그·정책·감사에 쓰이는 이름까지 전부 바뀝니다. 제약이 걸리는 곳은 LLM에 넘기는 필드 한 군데뿐이므로, 그 경계에서만 조회 테이블로 변환하기로 하였습니다.
[이름 제약을 경계 한 곳에서 흡수]
// Convert the name only at the LLM boundary.
// MCP, policy and audit keep the original name.
private String nativeName(String qualifiedName) {
String sanitized = qualifiedName.replaceAll("[^A-Za-z0-9_-]", "_");
if (sanitized.length() <= MAX_NATIVE_NAME_LENGTH) return sanitized;
String hash = String.format("%08x", qualifiedName.hashCode());
return sanitized.substring(0, MAX_NATIVE_NAME_LENGTH - hash.length() - 1) + "_" + hash;
}
// If two names collapse into one, fail fast instead of silently overwriting.
String previous = nativeNames.putIfAbsent(nativeName, qualifiedName);
if (previous != null && !previous.equals(qualifiedName)) {
throw new AgentNativeToolException("Native tool name collision: " + previous);
}
이름 길이 상한 때문에 잘라내는 경우가 생기므로, 서로 다른 도구가 같은 이름으로 축약되면 즉시 예외를 던지도록 하였습니다. 이런 종류의 충돌은 조용히 덮이면 “모델이 엉뚱한 도구를 불렀다”는 형태로 한참 뒤에 발견됩니다.
도구 목록 자체도 줄였습니다. 프롬프트의 도메인 어휘로 도구 목록을 좁히는 선택기를 두되, 매칭이 없으면 전체를 유지하도록 하였습니다. 또한 인사나 도움말처럼 도구가 필요 없는 요청은 MCP 세션을 열기 전에 일반 응답으로 종료시키고, 도구 카탈로그에는 짧은 TTL 캐시를 두었습니다.
2.4 통제 계층은 건드리지 않는다
이번 작업에서 가장 중요하게 본 지표는 성능이 아니라 “무엇이 바뀌지 않았는가”였습니다. 실행 계층을 통째로 교체하는 작업이었으므로, 정책·승인·감사를 담당하는 ToolExecutionGateway가 변경되었다면 그것은 경계가 잘못 그어져 있었다는 뜻입니다.
결과적으로 ToolExecutionGateway는 한 줄도 바뀌지 않았습니다. 실행 방식이 프롬프트 JSON 파싱에서 native tool calling으로 바뀌었지만, 게이트웨이 입장에서는 여전히 “서버 id + 도구 이름 + 인자”가 들어올 뿐이었기 때문입니다.
한편 fallback은 유지하였습니다. native tool calling이 보장하는 것은 형식뿐이고, 빈 응답이나 모델의 침묵은 여전히 가능하기 때문입니다. 다만 루프 모드와 finishReason을 SSE 완료 이벤트와 감사 로그에 함께 노출시켜, 어떤 경로로 끝났는지가 항상 기록에 남도록 하였습니다.
3. 시행착오를 통해 얻은 교훈
첫째, 증상을 세는 것보다 뿌리를 찾는 것이 빠릅니다. 처음에는 관측 폭증·형식 오류·인자 검증을 별개 항목으로 나열하였습니다. 셋 다 문자열 두 개짜리 엔진 인터페이스라는 같은 제약에서 나온 것이었고, 뿌리를 고치자 세 항목이 함께 사라졌습니다. 개별 대응을 먼저 했다면 세 번 고치고도 네 번째 증상을 만났을 것입니다.
둘째, LLM 연동 포트는 처음부터 대화 배열을 1급으로 두어야 합니다. 단발 질의응답으로 시작하더라도 messages를 기본 자료구조로 잡아 두면, 이후에 도구든 멀티턴이든 붙일 때 인터페이스를 갈아엎지 않아도 됩니다. 실제로 이번 전환의 절반은 기능 추가가 아니라 이 인터페이스를 고치는 작업이었습니다.
셋째, 의존성 추가는 선언이 아니라 사용 시점에 완료됩니다. BOM은 이미 선언되어 있었지만 클라이언트 모듈은 같은 기능을 손으로 구현하고 있었고, 그 상태가 한동안 유지되었습니다. 손으로 만든 JSON-RPC가 사라지면서 notifications/initialized 누락 버그도 함께 원천 소멸하였습니다.
넷째, 편리한 자동화 경로는 통제 경계를 우회할 수 있는지부터 확인해야 합니다. 프레임워크가 제공하는 도구 자동 실행은 매력적이지만, 그 경로를 탔다면 승인과 감사가 비어 있는 실행이 생겼을 것입니다. ChatModel 계층이 도구를 실행하지 않는다는 사실을 코드로 확인한 뒤에야 도입을 확정하였습니다.
다섯째, 대비 경로는 안 타는 동안 조용히 낡습니다. 이후 정리 단계에서 정규식으로 문장을 도구에 못 박던 지름길과 JSON 판단 루프를 함께 제거하였습니다. 두 경로 모두 “안 되는 경우를 대비해” 만든 것이었지만, 실제로는 설정이 비어 한 번도 타지 않은 채 흐름도만 두 배로 만들고 있었습니다.
4. 적용 결과
전환 결과는 다음과 같이 정리할 수 있습니다.
|
항목 |
전환 전 |
전환 후 |
|---|---|---|
|
판단 경로 |
프롬프트 JSON 파싱 + 2회 재시도 |
provider native tool calling (구조화된 tool_calls) |
|
대화 상태 |
관측 전문을 매 스텝 문자열로 재조립 |
messages 배열에 assistant·tool 메시지로 누적 |
|
도구 목록 |
40개+ 전량 전송, 스텝당 약 4,000 토큰 |
도메인 기준 선택 + TTL 캐시, 불필요 시 MCP 미진입 |
|
MCP 클라이언트 |
raw HttpClient + 직접 만든 JSON-RPC |
Spring AI 2.0 (프로토콜 누락 버그 소멸) |
|
통제 계층 |
정책·승인·감사 게이트웨이 |
변경 없음 (0줄) |
native 경로와 fallback 경로가 모두 Spring AI 전송을 타게 되어 provider 코드가 두 벌로 갈라지지 않았습니다. 빌드와 테스트를 통과하였고 단위 테스트 7종을 추가하였습니다.
부수 효과도 있었습니다. native tool calling이 요구하는 messages 배열이 대화 세션이 필요로 하는 자료구조와 정확히 같았기 때문에, 이번 전환으로 대화 기억 기능의 기반이 함께 깔렸습니다. 이후 다중 세션 대화 이력을 붙일 때 서버 계약을 다시 설계할 필요가 없었습니다.
5. 한계 및 향후 계획
첫째, 모델 의존성이 남아 있습니다. 사내에서 사용하는 모델이 tool call을 한 번에 하나만 주는지 여러 개 주는지는 실측이 필요했고, 정책에 실어 둔 병렬 실행 플래그는 아직 루프가 사용하지 않는 상태로 남았습니다. 병렬 실행은 읽기 전용 도구에 한정해 단계적으로 여는 것이 안전하다고 보고 있습니다.
둘째, 도구 목록을 도메인 어휘로 좁히는 방식은 어휘가 겹치는 도메인이 늘어나면 한계가 있습니다. 이후 작업에서는 전량 노출을 도구 색인과 이름 지목 로딩으로 교체하는 방향으로 확장하였고, 이 부분은 별도로 정리할 계획입니다.
셋째, 도구 결과를 모델이 말로 풀어 쓰게 두는 한 표현이 매번 흔들립니다. 이 문제는 서버가 표시 계약을 내려주고 화면이 그대로 그리도록 하는 방향으로 이어졌습니다.
이번 작업을 통해, 에이전트를 직접 만든다는 것이 추론을 직접 만든다는 뜻은 아니라는 점을 다시 확인할 수 있었습니다. 판단은 언제나 LLM이 하고, 우리가 만드는 것은 루프와 도구 연결, 그리고 정책과 감사입니다. 그 경계를 코드 구조로 못 박아 두었기에 실행 계층을 통째로 교체하면서도 통제 계층을 한 줄도 건드리지 않을 수 있었습니다.
참고 문헌
-
Anthropic, “Model Context Protocol Specification”, https://modelcontextprotocol.io/specification
-
Spring AI, “Tool Calling”, https://docs.spring.io/spring-ai/reference/api/tools.html
-
Spring AI, “Model Context Protocol (MCP)”, https://docs.spring.io/spring-ai/reference/api/mcp/mcp-overview.html
-
OpenAI, “Function calling”, https://platform.openai.com/docs/guides/function-calling
-
Ollama, “Tool support”, https://ollama.com/blog/tool-support
Junny