Spring AI로 MCP 서버 만드는 건 생각보다 간단합니다. 메서드에 @McpTool 하나 붙이면 JSON Schema까지 알아서 만들어 주니까요.
문제는 그 전에 있었습니다. Spring Boot 3.5에 올렸더니 서버가 아예 뜨질 않더군요. 표준 출력을 막아놓은 터라 로그도 안 나오고, 한참 헤매다 로그 파일을 열어보고 알았습니다. Jackson이었습니다.
이 이야기부터 하겠습니다. 여기서 막히면 아래는 볼 필요가 없으니까요.
Spring Boot 3.5에서는 뜨지 않습니다
spring-ai-starter-mcp-server 2.0.1을 Spring Boot 3.5.6에 올리고 실행하면 이렇게 죽습니다.
Caused by: java.lang.NoClassDefFoundError:
com/fasterxml/jackson/annotation/JsonSerializeAs
at tools.jackson.databind.introspect.JacksonAnnotationIntrospector.<clinit>
at org.springframework.ai.mcp.server.common.autoconfigure
.McpServerJsonMapperAutoConfiguration.mcpServerJsonMapper
tools.jackson 이라는 패키지 이름이 눈에 걸립니다. 의존성 트리를 열어보니 답이 나왔습니다.
spring-ai-starter-mcp-server:2.0.1
+- spring-ai-mcp:2.0.1
| \- io.modelcontextprotocol.sdk:mcp:2.0.0
| +- io.modelcontextprotocol.sdk:mcp-json-jackson3:2.0.0
| \- io.modelcontextprotocol.sdk:mcp-core:2.0.0
\- spring-ai-mcp-annotations:2.0.1
MCP Java SDK 2.0이 Jackson 3을 씁니다. Spring Boot 3.x는 Jackson 2 계열을 관리하니 어긋날 수밖에 없습니다.
여기까지 알고 나서, 간단히 넘어갈 수 있을 줄 알았습니다. jackson-annotations 버전만 올려주면 되지 않을까 싶었거든요. 올려서 다시 띄웠더니 이번엔 다른 데서 걸립니다.
Caused by: java.lang.reflect.MalformedParameterizedTypeException:
Mismatch of count of formal and actual type arguments in constructor of
tools.jackson.core.TreeCodec: 0 formal argument(s) 1 actual argument(s)
at org.springframework.core.ResolvableType.forMethodParameter
at org.springframework.core.BridgeMethodResolver.findBridgedMethod
이번엔 Spring Framework 6의 타입 해석기가 Jackson 3 타입을 못 다룹니다. 라이브러리 하나 갈아끼워서 될 문제가 아니었습니다. 두 번 시도해 보고 포기했습니다.
결국 Spring Boot 4.0.8로 올렸고, 그러니 바로 떴습니다. Spring Boot 4는 Jackson 3과 Spring Framework 7을 기본으로 쓰거든요. 아래 내용은 전부 Spring Boot 4.0.8 / Spring AI 2.0.1 / JDK 21에서 돌려보고 쓴 것입니다.
Spring Boot 4로 올릴 때 무엇이 깨지는지는 Spring Boot 3.x → 4.0 마이그레이션 정리에 따로 적어두었습니다. 그 글에서도 Jackson 2 → 3 전환이 가장 손이 많이 가는 항목이었는데, 이번에 실제로 발목을 잡혔습니다.
설정
의존성은 하나로 끝납니다.
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.0.8</version>
</parent>
<properties>
<java.version>21</java.version>
<spring-ai.version>2.0.1</spring-ai.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-bom</artifactId>
<version>${spring-ai.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-mcp-server</artifactId>
</dependency>
</dependencies>
스타터는 전송 방식에 따라 셋으로 나뉩니다. 로컬 프로세스로 띄울 거면 spring-ai-starter-mcp-server(stdio), 원격으로 열 거면 -webmvc나 -webflux를 쓰면 Streamable HTTP로 열립니다. 이 글은 확인이 제일 쉬운 stdio로 갑니다.
도구 만들기
메서드에 애너테이션만 붙이면 됩니다. 주문 조회 도구 세 개를 만들어 봤습니다.
@Service
public class OrderTools {
@McpTool(name = "get-order",
description = "주문 번호로 주문 상태와 결제 금액을 조회한다")
public Map<String, Object> getOrder(
@McpToolParam(description = "주문 번호. 예: ORD-1001", required = true)
String orderId) {
Map<String, Object> found = ORDERS.get(orderId);
if (found == null) {
throw new IllegalArgumentException("주문을 찾을 수 없습니다: " + orderId);
}
return found;
}
@McpTool(name = "list-orders", description = "주문 번호 목록을 반환한다")
public List<String> listOrders() {
return ORDERS.keySet().stream().sorted().toList();
}
@McpTool(name = "estimate-shipping",
description = "주문 금액과 지역으로 배송비를 계산한다")
public int estimateShipping(
@McpToolParam(description = "주문 금액(원)", required = true) int total,
@McpToolParam(description = "도서산간 여부", required = false) Boolean remote) {
int base = total >= 50_000 ? 0 : 3_000;
return Boolean.TRUE.equals(remote) ? base + 5_000 : base;
}
}
패키지는 org.springframework.ai.mcp.annotation 입니다. 같은 자리에 @McpResource, @McpPrompt, @McpArg도 있습니다. 빈으로만 등록되어 있으면 알아서 수집되니 별도 등록 코드는 필요 없습니다.
stdio에서는 표준 출력에 아무것도 흘리면 안 됩니다
앞에서 로그가 안 나와 헤맸다고 했는데, 그 이유가 여기 있습니다. stdio 전송에서는 표준 출력이 곧 JSON-RPC 메시지 통로입니다. 스프링 배너 한 줄만 섞여도 클라이언트가 JSON 파싱에 실패합니다.
배너 끄는 건 쉽습니다.
spring.main.web-application-type=none
spring.main.banner-mode=off
spring.ai.mcp.server.name=order-tools
spring.ai.mcp.server.version=1.0.0
문제는 로그였습니다. logging.pattern.console= 로 빈 값을 주면 되겠지 싶었는데, logback이 오히려 화를 내더군요.
ERROR in ch.qos.logback.classic.PatternLayout("") - Empty or null pattern.
콘솔 어펜더를 아예 두지 않는 쪽이 확실했습니다.
<!-- src/main/resources/logback-spring.xml -->
<configuration>
<appender name="FILE" class="ch.qos.logback.core.FileAppender">
<file>mcp-server.log</file>
<encoder><pattern>%d{HH:mm:ss} %-5level %logger{20} - %msg%n</pattern></encoder>
</appender>
<root level="INFO">
<appender-ref ref="FILE"/>
</root>
</configuration>
이렇게 해두면 디버깅이 오히려 편해집니다. 서버가 안 뜰 때 표준 출력에는 아무것도 없으니, 이 로그 파일이 유일한 단서가 됩니다. 저도 앞의 Jackson 오류를 이 파일에서 찾았습니다.
붙여서 확인하기
MCP는 JSON-RPC 2.0이라 클라이언트 없이도 표준 입출력으로 직접 붙을 수 있습니다. 줄바꿈으로 구분된 JSON을 한 줄씩 던지면 됩니다.
echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{
"protocolVersion":"2025-06-18","capabilities":{},
"clientInfo":{"name":"probe","version":"1.0"}}}' \
| java -jar target/mcp-server-1.0.jar
돌아온 응답입니다.
협상 protocolVersion: 2025-06-18
serverInfo: {'name': 'order-tools', 'version': '1.0.0'}
capabilities: ['completions', 'logging', 'prompts', 'resources', 'tools']
application.properties에 적은 이름과 버전이 그대로 serverInfo로 나갑니다. 도구만 만들었는데 prompts와 resources 능력까지 함께 선언되는 게 좀 의외였습니다. 실제로 제공하는 건 없는데도요.
이어서 tools/list를 던져봤습니다.
tools/list: 3개
- estimate-shipping 주문 금액과 지역으로 배송비를 계산한다
properties=['total', 'remote'] required=['total']
- get-order 주문 번호로 주문 상태와 결제 금액을 조회한다
properties=['orderId'] required=['orderId']
- list-orders 주문 번호 목록을 반환한다
properties=[] required=[]
메서드 시그니처만 보고 JSON Schema를 만들어냈습니다. estimate-shipping을 보면 required = false로 준 remote가 required 배열에서 정확히 빠져 있습니다. 애너테이션에 적은 대로 나오는군요.
이게 왜 중요하냐면, 모델이 이 스키마를 보고 인자를 만들기 때문입니다. description은 사람 읽으라고 쓰는 주석이 아니라 모델에게 주는 명세입니다. 대충 적으면 모델이 도구를 엉뚱하게 씁니다. 저 설명들을 쓸 때 “이걸 처음 보는 사람이 인자를 채울 수 있나”를 기준으로 삼으면 얼추 맞습니다.
호출도 잘 됩니다.
tools/call get-order(ORD-1001):
{
"content": [
{ "type": "text",
"text": "{\"status\":\"배송중\",\"id\":\"ORD-1001\",\"total\":34500}" }
],
"isError": false
}
Map을 반환했는데 JSON 문자열로 직렬화되어 content[].text에 담겼습니다. int를 반환한 estimate-shipping도 "8000" 이라는 문자열로 왔습니다.
예외를 던지면 어떻게 될까
여기가 제일 궁금했던 부분입니다. 없는 주문 번호를 넣어 IllegalArgumentException이 나게 해봤습니다.
{
"content": [
{ "type": "text", "text": "주문을 찾을 수 없습니다: NOPE" }
],
"isError": true
}
JSON-RPC의 error 필드가 아니라 정상 result 안에 isError: true로 옵니다. 예외 메시지가 그대로 실려 나가고요.
처음엔 이상하다고 생각했는데, 생각해 보니 이게 맞습니다. 도구 실행 실패는 프로토콜 오류가 아니라 모델이 읽고 판단해야 할 정보니까요. 프로토콜 오류로 던지면 클라이언트 라이브러리가 예외를 뱉고 대화가 끊깁니다. isError로 오면 모델이 메시지를 읽고 인자를 고쳐 다시 시도할 수 있습니다.
그래서 실무에서 지킬 게 두 가지 생깁니다.
예외 메시지를 모델이 읽는다고 생각하고 쓰세요. “주문을 찾을 수 없습니다: NOPE”처럼 뭐가 잘못됐는지 알 수 있어야 재시도가 됩니다. NullPointerException이 그대로 나가면 모델은 아무것도 못 합니다.
그리고 내부 정보가 새지 않게 조심해야 합니다. 스택 트레이스나 DB 오류 원문이 그대로 나가면 모델을 거쳐 사용자에게까지 노출됩니다.
한 가지 아쉬운 점도 있었습니다. 예외 메시지가 두 번 반복되어 담기는 경우가 있더군요. "주문을 찾을 수 없습니다: NOPE\n주문을 찾을 수 없습니다: NOPE" 이런 식으로요. Spring AI 2.0.1에서 본 동작이고, 메시지를 직접 구성해서 반환하면 피할 수 있습니다.
원격으로 열 생각이라면
stdio는 로컬 전용입니다. 원격으로 열려면 두 가지를 더 알아야 합니다.
먼저 전송 방식입니다. MCP의 원격 전송은 2024년 11월 사양의 HTTP+SSE에서 Streamable HTTP로 넘어갔고, 주요 클라이언트가 2026년 중반에 SSE 지원을 끊었습니다.
-webmvc나 -webflux 스타터를 쓰면 Streamable HTTP로 열리니 그대로 가면 됩니다. 다만 검색해서 나오는 예제 중에 SSE 기준으로 쓰인 게 아직 많습니다. 그걸 따라 만들면 클라이언트가 안 붙습니다.
다음은 인가입니다. stdio에서는 프로세스 경계가 곧 신뢰 경계라 인가가 없어도 되지만, 원격은 다릅니다. MCP 인가 사양은 OAuth 2.1 기반이고 클라이언트에 PKCE(S256) 구현을 의무화합니다. 인가 서버 메타데이터에 code_challenge_methods_supported가 없으면 진행을 거부하라고까지 못 박아 놨습니다. MCP 서버는 OAuth 2.1의 자원 서버로 모델링됩니다.
왜 하필 OAuth 2.1인지, 에이전트 환경에서 혼동된 대리인 문제와 어떻게 엮이는지는 OAuth 2.1 변경점 정리에 적어두었습니다.
해보고 나서
코드 자체는 정말 간단합니다. @McpTool 붙인 빈 하나면 도구 목록도 스키마도 알아서 만들어집니다. 반나절이면 충분할 거라 생각했는데, 실제로 시간을 쓴 곳은 두 군데였습니다. Jackson 때문에 Spring Boot를 올려야 했던 것, 그리고 표준 출력에 로그가 섞여서 원인을 못 찾고 헤맨 것.
둘 다 코드가 아니라 환경 문제였습니다. Spring AI 문서만 보고 시작하면 이 둘을 만나기 전까지는 순조롭게 진행되는 것처럼 느껴집니다.
MCP 자체가 무엇이고 왜 나왔는지 궁금하다면 MCP란? AI와 데이터를 연결하는 차세대 표준에 정리해 두었습니다. 참조 서버에 직접 붙어서 확인한 프로토콜 메시지도 같이 실어 두었습니다.
여기 실린 코드와 실행 결과는 Spring Boot 4.0.8 / Spring AI 2.0.1 / MCP Java SDK 2.0.0 / JDK 21에서 직접 빌드하고 stdio로 접속해 확인한 것입니다(2026년 9월, 협상된 프로토콜 버전
2025-06-18). Spring AI와 MCP 사양 모두 개정이 빠른 편이니 시작 전에 최신 버전을 한 번 확인하세요.
참고 자료
- Spring AI Reference Documentation
- Model Context Protocol — Authorization
- spring-ai-bom 릴리스 목록 (Maven Central)
함께 읽으면 좋은 글
- Spring Boot NoUniqueBeanDefinitionException 해결 방법: 다중 EntityManager 설정
- Spring Security 보안 강화: Redis 기반 Refresh Token Rotation(RTR) 및 블랙리스트 전략 완벽 가이드
- OAuth 2.1 무엇이 달라지나: PKCE 의무화와 Implicit 퇴출 (2026년 기준)
서울·경기 아파트 실거래 주간 브리핑
국토교통부 실거래가 공개 데이터를 모아 서울·경기 관심 지역 17곳의 아파트 매매 거래를 매주 정리합니다. 거래 건수와 평당가 추이, 신고가 갱신 단지, 거래 위치 지도를 한 편에 담습니다. 무료입니다.
- 브리핑 소개와 지난 편 보기
- 텔레그램 실거래 채널: @loghubs_arttrade
- 텔레그램 DART 공시 채널: @loghubs_dart
공개 데이터를 정리한 통계 정보이며 특정 단지의 매수·매도를 권유하지 않습니다.