Day 18. MCP Server 구현 + A2A 프로토콜 소개 — "이번엔 우리가 서버다"
안녕하세요, 여러분의 Spring AI 가이드 홍순구 튜터입니다.
지난 시간 우리는 MCP Client 입장에서 외부 서버의 도구를 표준 프로토콜로 받아들이는 사이클을 7 Step 으로 완성했어요. filesystem 서버로 파일을 읽고, fetch 서버로 웹페이지를 끌어오고, github 서버로 이슈를 가져왔죠.
의존성 한 줄 + yml connection 블록 + 5 가드 빈 + advisor + 캐릭터별 정책까지 얹어서, 외부 도구가 신뢰 경계 안에서 동작하는 골격을 갖췄어요.
그런데 Day 17 마무리에서 거울 비유를 하나 심어 뒀죠. "다음 시간엔 정반대 입장에 선다" 고요. 본 시간 우리가 외부 서버의 도구를 받아들이는 Client 였다면, 다음 시간엔 우리 도구를 외부 LLM 앱에 내어주는 Server 입장이라고요.
오늘이 바로 그 거울 속 시간이에요.
Cursor 사용자: "ai-friends 의 ARIA 호감도가 지금 몇이지?" Claude Desktop 사용자: "ARIA 에게 선물 이벤트를 발동시켜 줘."
이 요청을 우리 ai-friends 가 직접 처리해요. Cursor 나 Claude Desktop 같은 외부 LLM 앱이 우리 서버에 MCP 프로토콜로 도구 호출을 보내고, 우리가 결과를 돌려주는 구조예요.
Day 17 에서 우리가 외부 서버에 tools/call 을 보냈듯이, 이번엔 외부가 우리에게 tools/call 을 보내요. 같은 JSON-RPC 프로토콜인데 화살표 방향만 뒤집힌 거예요.
그리고 오늘 후반부에선 한 걸음 더 나아가요. MCP 가 도구 호출의 표준이라면, 에이전트 사이의 작업 위임은 어떤 표준으로 풀 수 있을까요? Google 이 제안하고 Linux Foundation 이 거버넌스를 맡은 A2A (Agent-to-Agent) 프로토콜을 소개해요.
MCP 와 A2A 가 어떤 축에서 갈라지고 어떻게 보완하는지, 표 한 장으로 정리하는 시간이에요.
🎯 학습 목표
spring-ai-starter-mcp-server의존성 한 줄로 우리 Spring 앱을 MCP Server 로 전환하는 사이클을 익혀요.- ai-friends 도메인 도구 3종 (캐릭터 상태 조회, 이벤트 트리거, 세이브 슬롯 목록) 을 MCP 전용으로 노출하고, 내부 도구와 분리하는 이유를 이해해요.
- STDIO transport 로 Claude Desktop / Cursor 연동을 시연하고, Streamable-HTTP transport 로 원격 접근까지 확장해요.
- MCP Server 보안 5축 (인증, 감사 로그, 도구 스코프, 입력 검증, 네트워크 노출) 을 Day 17 Client 가드의 거울로 이해해요.
- A2A 프로토콜의 핵심 개념 (Agent Card, Task, Message) 과 MCP 와의 축 구분을 설명할 수 있어요.
Step 1. MCP Server 개념 + Transport 선택 기준
Day 17 에서 우리는 MCP 의 3축 (Host, Client, Server) 을 익혔어요. Host 가 사용자와 만나는 LLM 앱이고, Client 가 Host 안에서 외부 서버와 1:1 연결을 맺는 진입점이고, Server 가 실제 도구를 제공하는 프로세스였죠.
그때 우리 ai-friends 는 Host 겸 Client 위치에 있었어요. 외부 MCP Server (filesystem, fetch, github) 의 도구를 소비하는 쪽이었죠. 이번 Step 에서는 우리가 Server 위치로 이동해요.
MCP Server 란 무엇인가
MCP Server 는 자기가 가진 도구 카탈로그를 JSON-RPC 프로토콜로 외부에 노출하는 프로세스예요. Day 17 Step 1 에서 봤던 그 JSON-RPC 통신의 반대편이에요. 외부 클라이언트가 tools/list 를 보내면 우리가 "이런 도구들이 있어요" 를 돌려주고, tools/call 을 보내면 우리가 실행해서 결과를 돌려줘요.
Day 11 에서 우리가 만든 @Tool 메서드를 기억하시죠? 그때는 우리 앱 내부의 ChatClient 만 호출할 수 있었어요. MCP Server 를 띄우면 같은 @Tool 어노테이션으로 정의한 도구가 외부 LLM 앱에서도 호출 가능해져요. Spring AI 의 @Tool 이 내부용과 외부용 양쪽으로 쓰일 수 있는 거예요.
왜 우리가 Server 를 만들어야 하는가
"튜터님, 그냥 REST API 를 만들면 되지 않나요? 왜 MCP Server 를 별도로 띄우나요?"
좋은 질문이에요. REST API 도 물론 가능하고, 지금도 우리 ai-friends 에는 REST 컨트롤러가 잘 동작하고 있어요. 하지만 MCP Server 를 따로 노출하는 이유는 소비자가 LLM 앱이라는 점에서 달라져요.
REST API 는 프론트엔드 개발자가 문서를 읽고, 엔드포인트 경로를 외우고, 요청 body 를 직접 짜야 해요. 반면 MCP 도구는 LLM 이 도구 카탈로그에서 자동으로 발견해요.
Claude Desktop 사용자가 "ARIA 호감도 알려줘" 라고 자연어로 말하면, Claude 가 우리 MCP Server 의 getCharacterStatus 도구를 자동으로 골라서 호출하는 거예요. 사람이 API 문서를 읽을 필요가 없어요.
| 축 | REST API | MCP Server |
|---|---|---|
| 소비자 | 프론트엔드 / 다른 백엔드 서비스 | LLM 앱 (Claude Desktop, Cursor, 다른 Spring AI 앱) |
| 발견 방식 | Swagger / API 문서를 사람이 읽음 | tools/list 로 LLM 이 자동 발견 |
| 호출 방식 | HTTP 직접 호출 (경로 + body 직접 작성) | LLM 이 자연어 → 도구 호출로 자동 변환 |
| 인증 | JWT / OAuth / API Key | API Key / OAuth 2.1 (MCP 스펙 표준) |
| 기존 유지 | 그대로 유지 | REST API 옆에 추가로 노출 |
핵심은 REST 를 대체하는 게 아니라 병행한다는 점이에요. 우리 ai-friends 의 REST 컨트롤러는 프론트엔드가 계속 호출하고, MCP Server 는 LLM 앱이 호출해요. 같은 도메인 서비스를 두 가지 통로로 노출하는 구조예요.
Transport 선택 — STDIO vs Streamable-HTTP
Day 17 에서 transport 세 가지를 비교했어요. STDIO, Streamable-HTTP, SSE (legacy). MCP Server 쪽에서도 transport 선택이 필요한데, 기준은 누가 어디서 우리 서버에 접속하느냐예요.
| 기준 | STDIO | Streamable-HTTP |
|---|---|---|
| 연결 방식 | 로컬 자식 프로세스의 stdin/stdout | 원격 HTTP POST + streaming 응답 |
| 누가 연결하나 | 같은 머신의 Claude Desktop / Cursor | 네트워크 너머의 다른 앱 |
| 인증 필요성 | 없음 (OS 프로세스 권한으로 충분) | 필수 (API Key / OAuth 2.1) |
| 학습 진입 | 가벼움 — 설정 파일 한 장으로 연동 | 인증 + CORS + 포트 노출이 추가됨 |
| 우리 강의 | Step 2~4 (로컬 시연) | Step 5 (원격 접근) |
SSE 는 Day 17 에서 말씀드린 대로 2026 년 중반 sunset 이 진행 중이라 다루지 않아요. 신규 프로젝트는 Streamable-HTTP 가 표준이에요.
🙋 학생 질문 — "튜터님, Day 17 에서 우리가 Client 로 쓴 STDIO 와 지금 Server 로 쓰는 STDIO 가 같은 건가요?"
같은 프로토콜이에요. STDIO transport 는 자식 프로세스의 stdin/stdout 으로 JSON-RPC 를 주고받는 통신 방식이에요.
Day 17 에서는 우리 앱이 부모 프로세스로서 외부 MCP 서버를 자식 프로세스로 띄웠죠. 이번엔 거꾸로, Claude Desktop 이 부모 프로세스로서 우리 ai-friends JAR 를 자식 프로세스로 띄워요. 같은 통신 규약인데 부모-자식 관계가 뒤집힌 거예요.
💡 튜터의 결론
MCP Server 는 우리 도메인 도구를 LLM 앱에 노출하는 표준 통로예요. REST API 와 병행하며, transport 는 로컬이면 STDIO, 원격이면 Streamable-HTTP 를 선택해요.
Step 2. spring-ai-starter-mcp-server 세팅 + STDIO 첫 기동
이론은 충분해요. 이제 코드로 갈게요. 우리 ai-friends 를 MCP Server 로 만드는 데 필요한 세팅은 놀라울 정도로 적어요.
1. 의존성 추가
build.gradle 에 두 줄을 추가해요.
// Day 18 — MCP Server (STDIO transport).
// Day 17 의 반대 방향: 우리 도구를 외부 LLM 앱(Claude Desktop · Cursor 등)에 노출한다.
// STDIO transport 는 자식 프로세스로 기동되어 stdin/stdout 으로 JSON-RPC 통신.
implementation 'org.springframework.ai:spring-ai-starter-mcp-server'
// Day 18 Step 5 — MCP Server (Streamable-HTTP transport).
// 원격 클라이언트가 HTTP 로 MCP 도구를 호출할 수 있게 한다.
// Spring MVC 기반이라 기존 REST API 와 같은 포트에서 공존 가능.
implementation 'org.springframework.ai:spring-ai-starter-mcp-server-webmvc'
이 부분에서 짚어야 할 점이 두 가지 있어요.
첫째, spring-ai-starter-mcp-server 는 STDIO transport 를 지원하는 기본 starter 예요. 이것만으로도 Claude Desktop 이 우리 JAR 를 자식 프로세스로 띄워서 도구를 호출할 수 있어요.
둘째, spring-ai-starter-mcp-server-webmvc 는 Streamable-HTTP transport 를 추가하는 starter 예요. Spring MVC 기반이라 우리 기존 REST 컨트롤러와 같은 포트에서 공존해요. Step 5 에서 본격 활용하지만, 의존성은 미리 추가해 둬요.
2. Spring AI 의 MCP Server 자동 구성
의존성을 추가하면 Spring AI 의 자동 구성이 동작해요. @Tool 어노테이션이 달린 @Component 빈을 스캔해서, 그 메서드들을 MCP 도구 카탈로그에 자동 등록해요.
Day 11 에서 @Tool 을 처음 만났을 때 기억나시죠? 그때는 ChatClient 의 .tools(...) 에 직접 넘겨야 했어요.
MCP Server 에서는 한 단계 더 자동이에요 — @Component + @Tool 조합만으로 외부 카탈로그에 바로 등록돼요. Spring AI 가 부팅 시점에 도구 카탈로그를 조립하고, 외부 클라이언트가 tools/list 를 보내면 그 카탈로그를 JSON 으로 돌려줘요.
3. STDIO 기동 확인
STDIO transport 는 별도 설정 없이 동작해요. JAR 를 실행하면 Spring AI 가 자동으로 stdin/stdout JSON-RPC 리스너를 등록해요. 확인 방법은 Step 4 에서 Claude Desktop 연동을 시연할 때 함께 볼게요.
지금 단계에서 중요한 건 의존성 두 줄 + @Component + @Tool 만으로 MCP Server 가 완성된다는 사실이에요. REST 컨트롤러처럼 라우팅을 직접 짜지 않아도 돼요.
🙋 학생 질문 — "튜터님, 기존 @Tool 도구들 (Day 11 의 AffinityTool 같은) 도 자동으로 MCP 에 노출되나요?"
네, 자동으로 노출돼요. @Component + @Tool 조합이면 MCP 도구 카탈로그에 등록되거든요. 그런데 여기서 중요한 판단이 필요해요 — 내부용 도구를 외부에 그대로 노출하는 게 안전한가?
Day 11 의 AffinityTool 은 우리 ChatClient 가 내부에서 호출하는 도구예요. 호감도를 올리고 내리는 쓰기 작업까지 포함돼 있죠. 이걸 외부 LLM 앱에 그대로 열어 두면 누군가가 악의적으로 호감도를 조작할 수 있어요.
그래서 다음 Step 3 에서 MCP 전용 도구를 별도 패키지로 분리해요. 내부용 도구는 그대로 두고, 외부용 도구는 노출 범위를 제한한 래퍼로 따로 만드는 거예요. 패키지 분리 + 도구 스코프 제한이 MCP Server 보안의 첫 번째 축이에요.
💡 튜터의 결론
spring-ai-starter-mcp-server의존성 한 줄 +@Component+@Tool조합이면 우리 Spring 앱이 MCP Server 가 돼요. REST 컨트롤러 없이도 외부 LLM 앱이 도구를 발견하고 호출할 수 있어요.
Step 3. ai-friends 도메인 도구 3종 MCP 전용 노출
Step 2 에서 MCP Server 의 뼈대가 완성됐어요. 이제 우리 ai-friends 도메인에서 외부에 노출할 도구 3종을 만들어요. Day 11 의 내부 도구와 왜 분리하는지, 각 도구가 어떤 범위까지 노출하는지를 함께 짚어요.
내부 도구 vs 외부 도구 — 왜 분리하는가
Day 11 에서 만든 AffinityTool, GameStateTool 같은 내부 도구는 우리 ChatClient 전용이에요. 우리 앱 안에서 LLM 이 호출하니까, 트랜잭션 컨텍스트도 공유하고 접근 권한도 전권이에요.
반면 MCP 로 외부에 노출하는 도구는 외부 LLM 앱이 호출해요. Claude Desktop, Cursor, 또 다른 회사의 Spring AI 앱 등 누가 호출할지 모르는 상황이에요. 그래서 세 가지 원칙으로 분리해요.
- 노출 범위 제한 — 내부 엔티티 (
Soulmate) 를 그대로 돌려주지 않고, 외부 소비자에게 필요한 필드만 담은 전용 record 로 변환해요. - 쓰기 범위 제한 — 이벤트 트리거 도구는 미리 정의된 이벤트 목록만 허용하고, 임의 데이터 수정은 차단해요.
- 패키지 분리 —
kr.spartaclub.aifriends.mcp.server패키지에 모아서, 내부 도구 (kr.spartaclub.aifriends.tool) 와 물리적으로 구분해요.
도구 1 — McpCharacterStatusTool (읽기 전용)
외부 LLM 앱이 캐릭터의 현재 상태를 조회하는 도구예요.
// kr.spartaclub.aifriends.mcp.server.McpCharacterStatusTool
// (전체 코드: lecture-source-code/ai-friends/.../mcp/server/McpCharacterStatusTool.java)
@Component
public class McpCharacterStatusTool {
private static final Logger log = LoggerFactory.getLogger(McpCharacterStatusTool.class);
private final SoulmateRepository soulmateRepository;
public McpCharacterStatusTool(SoulmateRepository soulmateRepository) {
this.soulmateRepository = soulmateRepository;
}
@Tool(description = "Get the current status of an AI character by ID. "
+ "Returns the character's name, personality, hobbies, affection score, and level. "
+ "Use this to check a character's state before interacting with them.")
public McpCharacterStatus getCharacterStatus(
@ToolParam(description = "The unique ID of the character to look up")
Long characterId
) {
log.info("[MCP Server] getCharacterStatus invoked — characterId={}", characterId);
return soulmateRepository.findById(characterId)
.map(this::toStatus)
.orElseGet(() -> McpCharacterStatus.notFound(characterId));
}
private McpCharacterStatus toStatus(Soulmate soulmate) {
return new McpCharacterStatus(
true,
soulmate.getId(),
soulmate.getName(),
soulmate.getPersonalityKeywords(),
soulmate.getHobbies(),
soulmate.getAffectionScore(),
soulmate.getLevel()
);
}
}
몇 가지를 짚어 볼게요.
@Tool 의 description 이 영문인 이유 — MCP 도구의 description 은 외부 LLM 이 읽어요. Claude Desktop 에 연결하면 Claude 모델이 이 description 을 보고 "이 도구를 호출해야겠다" 를 판단해요. 영문이 대부분의 LLM 에서 인식률이 높아요.
McpCharacterStatus record 로 변환하는 이유 — Soulmate 엔티티를 직접 돌려주면 JPA 프록시 + 지연 로딩 + 내부 필드 (패스워드 해시 등 민감 정보가 있을 수 있는 부분) 가 그대로 외부에 노출돼요. 외부 전용 record 로 변환해서 노출 범위를 통제해요.
응답 record 는 깔끔해요.
// kr.spartaclub.aifriends.mcp.server.dto.McpCharacterStatus
public record McpCharacterStatus(
boolean found,
Long characterId,
String name,
String personality,
String hobbies,
int affectionScore,
int level
) {
public static McpCharacterStatus notFound(Long characterId) {
return new McpCharacterStatus(false, characterId, null, null, null, 0, 0);
}
}
notFound 정적 팩토리가 있어서, 존재하지 않는 캐릭터 ID 로 호출해도 예외 대신 구조화된 응답을 돌려줘요. 외부 LLM 앱이 예외를 받으면 대화 흐름이 끊기거든요. "찾을 수 없다" 는 정보도 정상 응답으로 전달하는 게 MCP 도구 설계의 좋은 패턴이에요.
도구 2 — McpEventTriggerTool (쓰기)
외부 LLM 앱이 캐릭터에게 이벤트를 발생시키는 도구예요. 읽기 전용인 1번 도구와 달리 호감도를 변경하는 쓰기 작업이 포함돼요.
// kr.spartaclub.aifriends.mcp.server.McpEventTriggerTool
// (전체 코드: lecture-source-code/ai-friends/.../mcp/server/McpEventTriggerTool.java)
@Component
public class McpEventTriggerTool {
static final Map<String, EventEffect> EVENT_EFFECTS = Map.of(
"gift", new EventEffect(5, "선물을 받아서 기분이 좋아졌어!"),
"compliment", new EventEffect(3, "칭찬 고마워, 기분 좋다!"),
"date", new EventEffect(8, "같이 놀아서 정말 즐거웠어!"),
"insult", new EventEffect(-5, "그런 말 하면 속상해..."),
"ignore", new EventEffect(-3, "왜 무시하는 거야...")
);
private final SoulmateRepository soulmateRepository;
@Tool(description = "Trigger a named event for an AI character. "
+ "Supported events: gift (+5), compliment (+3), date (+8), "
+ "insult (-5), ignore (-3). "
+ "Returns the affection change and the character's reaction message.")
public McpEventResult triggerEvent(
@ToolParam(description = "The unique ID of the target character")
Long characterId,
@ToolParam(description = "Event name: gift, compliment, date, insult, or ignore")
String eventName
) {
String normalizedEvent = eventName.toLowerCase().trim();
EventEffect effect = EVENT_EFFECTS.get(normalizedEvent);
if (effect == null) {
return McpEventResult.failure(characterId, eventName,
"Unknown event. Supported: " + EVENT_EFFECTS.keySet());
}
return soulmateRepository.findById(characterId)
.map(soulmate -> applyEvent(soulmate, normalizedEvent, effect))
.orElseGet(() -> McpEventResult.failure(characterId, eventName,
"Character not found"));
}
private McpEventResult applyEvent(Soulmate soulmate, String eventName,
EventEffect effect) {
soulmate.addAffection(effect.delta());
soulmateRepository.save(soulmate);
return new McpEventResult(true, soulmate.getId(), eventName,
effect.delta(), soulmate.getAffectionScore(), effect.reaction());
}
record EventEffect(int delta, String reaction) {}
}
이 도구에서 주목할 설계 결정이 두 가지예요.
허용 이벤트를 Map.of(...) 로 제한한 이유 — 외부 LLM 앱이 임의 이벤트 이름을 보낼 수 있어요. "deleteAll" 이나 "resetDatabase" 같은 이름을 보내도 EVENT_EFFECTS map 에 없으면 failure 응답으로 거절돼요. 화이트리스트 기반의 입력 검증이 MCP 쓰기 도구의 기본이에요.
호감도 변동 폭을 서버 측에서 고정한 이유 — @ToolParam 으로 delta 값을 외부에서 받지 않아요. 이벤트 이름만 받고, 변동 폭은 서버 측 map 에서 결정해요. 외부가 "호감도를 +10000 올려줘" 같은 요청을 보내도 서버 측 정책으로 통제되는 구조예요.
도구 3 — McpSaveSlotTool (읽기 전용)
외부 LLM 앱이 ai-friends 의 세이브 슬롯 목록을 조회하는 도구예요.
// kr.spartaclub.aifriends.mcp.server.McpSaveSlotTool
// (전체 코드: lecture-source-code/ai-friends/.../mcp/server/McpSaveSlotTool.java)
@Component
public class McpSaveSlotTool {
private static final int MAX_SLOTS = 50;
private final GameStateEntryRepository gameStateEntryRepository;
@Tool(description = "List all save slots in the ai-friends game. "
+ "Returns slot ID, player ID, turn count, and save timestamp. "
+ "Results are ordered by most recent first, limited to 50 entries.")
public List<McpSaveSlotSummary> listSaveSlots() {
return gameStateEntryRepository.findAll().stream()
.sorted((a, b) -> b.getCreatedAt().compareTo(a.getCreatedAt()))
.limit(MAX_SLOTS)
.map(entry -> new McpSaveSlotSummary(
entry.getId(),
entry.getPlayerId(),
entry.getTurnCount(),
entry.getCreatedAt()
))
.toList();
}
}
MAX_SLOTS = 50 제한의 의미 — findAll() 전체를 가져오지만 50건으로 잘라요. MCP 응답이 너무 크면 LLM 의 컨텍스트 윈도우를 잡아먹어요.
Day 17 Step 4 에서 FetchMcpResponseLimiter 가 64KB 로 잘랐던 것과 같은 원리예요. 방향만 반대 — Day 17 은 우리가 받는 외부 응답을 잘랐고, 여기서는 우리가 보내는 응답을 잘라요.
대화 내용은 노출하지 않는 이유 — McpSaveSlotSummary record 에는 ID, 플레이어 번호, 턴 수, 저장 시각만 포함돼요. 대화 원문은 빠져 있어요. 외부 LLM 앱이 다른 플레이어의 대화 내용을 엿보는 사고를 구조적으로 차단하는 거예요.
도구 3종의 패키지 구조 정리
kr.spartaclub.aifriends
├── tool/ # Day 11 — 내부 ChatClient 전용
│ ├── AffinityTool.java
│ └── GameStateTool.java
└── mcp/
└── server/ # Day 18 — 외부 MCP 전용
├── McpCharacterStatusTool.java
├── McpEventTriggerTool.java
├── McpSaveSlotTool.java
├── dto/
│ ├── McpCharacterStatus.java
│ ├── McpEventResult.java
│ └── McpSaveSlotSummary.java
└── security/
├── McpServerApiKeyFilter.java
└── McpServerAuditInterceptor.java
내부 도구와 외부 도구가 같은 Repository 를 참조하지만, 반환 타입과 접근 범위가 다른 구조예요. 하나의 도메인 서비스를 두 관문으로 노출하되, 관문마다 통과하는 정보량이 다른 거예요.
💡 튜터의 결론
MCP 전용 도구는 내부 도구와 분리해서 노출 범위, 쓰기 범위, 응답 크기를 통제해요. 같은 도메인 데이터를 쓰되 외부 소비자에게는 record 로 변환한 안전한 뷰만 내보내요.
Step 4. Claude Desktop / Cursor 연동 시연
도구 3종이 준비됐어요. 이제 외부 LLM 앱에서 실제로 연동해 봐요. STDIO transport 기반이라 같은 머신에서 동작하는 Claude Desktop 과 Cursor 를 대상으로 시연해요.
Claude Desktop 연동
Claude Desktop 은 claude_desktop_config.json 파일로 MCP Server 를 등록해요. 파일 위치는 운영체제마다 달라요.
- macOS:
~/Library/Application Support/Claude/claude_desktop_config.json - Windows:
%APPDATA%\Claude\claude_desktop_config.json
설정 파일에 우리 ai-friends 서버를 추가해요.
{
"mcpServers": {
"ai-friends": {
"command": "java",
"args": [
"-jar",
"/absolute/path/to/ai-friends/build/libs/ai-friends-0.0.1-SNAPSHOT.jar",
"--spring.profiles.active=mcp-stdio"
],
"env": {
"SPRING_DATASOURCE_URL": "jdbc:mysql://localhost:3306/ai_friends",
"SPRING_DATASOURCE_USERNAME": "root",
"SPRING_DATASOURCE_PASSWORD": "your-password"
}
}
}
}
세 가지 포인트를 짚어요.
command + args 가 JAR 실행 명령인 이유 — STDIO transport 에서 Claude Desktop 은 우리 앱을 자식 프로세스로 직접 기동해요.
Day 17 에서 우리가 npx @modelcontextprotocol/server-filesystem 으로 외부 서버를 자식 프로세스로 띄웠던 것과 같은 원리예요. 이번엔 우리 JAR 가 자식 프로세스가 되는 거예요.
--spring.profiles.active=mcp-stdio — MCP STDIO 모드 전용 프로파일이에요. 웹 서버를 띄우지 않고 stdin/stdout 만 리슨하는 설정이에요. 기존 ./run.sh 로 띄우는 웹 서버 모드와 분리해요.
환경변수로 DB 접속 정보를 넘기는 이유 — STDIO 자식 프로세스는 부모 프로세스 (Claude Desktop) 의 환경을 상속받아요. .env 파일을 직접 읽지 못하니까, env 블록으로 필요한 변수를 명시적으로 전달해요.
시연 시나리오
Claude Desktop 에서 자연어로 대화해 봐요.
사용자: "ai-friends 의 1번 캐릭터 상태를 알려줘" Claude: (getCharacterStatus 호출) "1번 캐릭터 ARIA 의 현재 상태예요. 호감도 42점, 레벨 3이에요. 성격 키워드는 '상냥함, 호기심' 이고..."
사용자: "ARIA 에게 선물을 줘" Claude: (triggerEvent 호출) "ARIA 에게 선물 이벤트를 발동했어요. 호감도가 +5 올랐고 현재 47점이에요. ARIA 의 반응: '선물을 받아서 기분이 좋아졌어!'"
사용자: "세이브 슬롯 목록 보여줘" Claude: (listSaveSlots 호출) "현재 3개의 세이브 슬롯이 있어요. 가장 최근 저장은 2026-05-23 14:30..."
Claude 가 우리 도구의 description 을 읽고 자동으로 적절한 도구를 골라서 호출해요. 사용자는 API 엔드포인트를 알 필요가 없어요.
Cursor 연동
Cursor 도 MCP Server 를 지원해요. .cursor/mcp.json 파일에 같은 구조로 등록하면 돼요.
{
"mcpServers": {
"ai-friends": {
"command": "java",
"args": [
"-jar",
"/absolute/path/to/ai-friends/build/libs/ai-friends-0.0.1-SNAPSHOT.jar",
"--spring.profiles.active=mcp-stdio"
]
}
}
}
Cursor 에서는 코딩 중에 "이 캐릭터의 현재 호감도를 확인하고 싶어" 라고 Chat 에 물어보면, Cursor 의 LLM 이 우리 MCP 도구를 호출해서 답해줘요.
STDIO 연동의 한계
STDIO 가 간편하지만 한계가 명확해요.
- 같은 머신에서만 동작 — Claude Desktop / Cursor 가 JAR 를 직접 기동하니까 네트워크 너머에서는 접근 불가.
- 프로세스 기동 비용 — 매번 JAR 를 새로 띄워요. Spring Boot 앱이라 cold start 가 수 초 걸릴 수 있어요.
- 단일 클라이언트 — 한 번에 하나의 클라이언트만 stdin/stdout 을 점유해요.
이 한계를 넘어서 원격 접근이 필요하면? Step 5 의 Streamable-HTTP 가 답이에요.
🙋 학생 질문 — "튜터님, Claude Desktop 이 없으면 STDIO 테스트를 어떻게 하나요?"
MCP Inspector 라는 공식 디버깅 도구가 있어요. npx @modelcontextprotocol/inspector 명령으로 웹 UI 가 뜨고, 거기서 우리 서버의 도구 목록을 확인하고 직접 호출해 볼 수 있어요. Claude Desktop 이 없어도 MCP Server 가 정상 동작하는지 검증할 수 있는 도구예요.
💡 튜터의 결론
Claude Desktop / Cursor 의 설정 JSON 에 우리 JAR 경로를 한 줄 넣으면 연동 끝이에요. 외부 LLM 이 도구 description 을 읽고 자동으로 호출해요. 단, STDIO 는 로컬 전용이라 원격 접근은 Streamable-HTTP 로 전환해야 해요.
Step 5. Streamable-HTTP transport — 원격 접근
STDIO 의 한계 세 가지 (로컬 전용, 프로세스 기동 비용, 단일 클라이언트) 를 넘어서 원격에서도 우리 MCP Server 에 접근하려면 Streamable-HTTP transport 를 활성화해야 해요.
Streamable-HTTP 란
Day 17 Step 1 에서 transport 표를 봤었죠. Streamable-HTTP 는 일반 HTTP POST 로 MCP JSON-RPC 요청을 보내고, streaming 응답을 받는 통로예요.
우리 기존 REST API 와 같은 포트 (8080) 에서 공존할 수 있어요. spring-ai-starter-mcp-server-webmvc 가 Spring MVC 기반으로 엔드포인트를 자동 등록해 주거든요.
활성화 설정
application.yml 에 MCP Server 설정을 추가해요.
spring:
ai:
mcp:
server:
enabled: true
name: ai-friends-mcp-server
version: 1.0.0
type: SYNC
stdio: true # STDIO transport 유지
streamable-http:
enabled: true # Streamable-HTTP 추가 활성화
endpoint: /mcp # HTTP 진입점
이 설정으로 두 transport 가 동시에 활성화돼요. STDIO 는 Claude Desktop 로컬 연동용, Streamable-HTTP 는 원격 연동용으로 공존해요.
endpoint: /mcp — 우리 앱의 http://localhost:8080/mcp 경로로 MCP JSON-RPC 요청을 받아요. 기존 REST API (/api/...) 와 경로가 겹치지 않아요.
원격 클라이언트에서 연결
Streamable-HTTP 가 활성화되면 원격 MCP Client 가 HTTP 로 접속할 수 있어요. 예를 들어 다른 팀의 Spring AI 앱이 우리 ai-friends 도구를 사용하고 싶다면, application.yml 에 이렇게 연결해요.
# 다른 팀의 Spring AI 앱 — 우리 ai-friends MCP Server 에 연결
spring:
ai:
mcp:
client:
streamable-http:
connections:
ai-friends:
url: http://ai-friends-server:8080
endpoint: /mcp
Day 17 에서 우리가 외부 MCP 서버에 연결했던 yml 구조와 정확히 같아요. 이번엔 다른 앱이 우리에게 같은 구조로 연결하는 거예요. 거울이죠.
STDIO vs Streamable-HTTP 트레이드오프 정리
| 축 | STDIO | Streamable-HTTP |
|---|---|---|
| 배포 위상 | 로컬 데스크탑 도구 | 원격 서비스 |
| 인증 | 불필요 (OS 권한) | 필수 (API Key / OAuth) |
| 동시 접속 | 1 클라이언트 | 다중 클라이언트 |
| cold start | 매 연결마다 JVM 기동 | 앱이 이미 떠 있으면 즉시 |
| 네트워크 노출 | 없음 (stdin/stdout) | HTTP 포트 노출 |
프로덕션에서는 Streamable-HTTP + 인증이 표준이에요. STDIO 는 개인 데스크탑 도구나 로컬 테스트용이에요.
💡 튜터의 결론
streamable-http.enabled: true+endpoint: /mcp한 줄로 원격 MCP 접근이 열려요. 기존 REST API 와 같은 포트에서 공존하고, 인증은 다음 Step 6 에서 추가해요.
Step 6. MCP Server 보안 5축 — Day 17 가드의 거울
Streamable-HTTP 로 원격 접근을 열었어요. 그런데 인증 없이 열어 두면 누구나 우리 도구를 호출할 수 있어요. Day 17 에서 우리가 5 가드 빈으로 외부 MCP 서버의 응답을 검증했듯이, 이번엔 들어오는 요청을 검증하는 거울 방향의 보안이 필요해요.
Day 17 의 보안이 "나가는 방향" (우리가 외부 응답을 필터링) 이었다면, Day 18 의 보안은 "들어오는 방향" (외부가 우리에게 보내는 요청을 필터링) 이에요.
보안 5축 개요
| 축 | 내용 | Day 17 거울 |
|---|---|---|
| 1. 인증 | API Key 헤더 검증 | Day 17 은 PAT (GitHub) 로 외부 인증 |
| 2. 감사 로그 | 도구 호출 기록 (누가, 언제, 무엇을) | Day 17 의 응답 로깅과 거울 |
| 3. 도구 스코프 | 읽기/쓰기 도구 분리 | Day 17 의 캐릭터별 도구 정책과 거울 |
| 4. 입력 검증 | 이벤트 화이트리스트, ID 범위 확인 | Day 17 의 호스트 화이트리스트와 거울 |
| 5. 네트워크 노출 | Streamable-HTTP 만 인증 강제, STDIO 는 면제 | Day 17 은 STDIO 로컬 신뢰 |
축 1 — API Key 인증 (McpServerApiKeyFilter)
Streamable-HTTP 로 들어오는 요청에 API Key 를 요구해요.
// kr.spartaclub.aifriends.mcp.server.security.McpServerApiKeyFilter
// (전체 코드: lecture-source-code/ai-friends/.../security/McpServerApiKeyFilter.java)
@Component
@ConditionalOnProperty(name = "aifriends.mcp.server.api-key")
public class McpServerApiKeyFilter {
static final String API_KEY_HEADER = "X-MCP-API-Key";
private final String expectedApiKey;
public boolean authenticate(String apiKeyHeader) {
if (apiKeyHeader == null || apiKeyHeader.isBlank()) return false;
return expectedApiKey.equals(apiKeyHeader.trim());
}
}
핵심 설계 결정 두 가지를 짚어요.
@ConditionalOnProperty 가 하는 일 — aifriends.mcp.server.api-key 프로퍼티가 설정돼 있을 때만 이 빈이 등록돼요.
설정이 없으면 빈 자체가 생성되지 않아요. STDIO 전용 환경 (로컬 Claude Desktop) 에서는 API Key 가 필요 없으니까 이 프로퍼티를 빼면 필터가 자동으로 비활성화돼요.
헤더 이름 X-MCP-API-Key — MCP 스펙 자체는 인증 헤더를 강제하지 않아요. 우리 앱 정책으로 정한 커스텀 헤더예요. 프로덕션에서는 OAuth 2.1 Bearer 토큰으로 교체하는 것이 표준이에요.
축 2 — 감사 로그 (McpServerAuditInterceptor)
외부 클라이언트가 어떤 도구를 언제 호출했는지 기록해요.
// kr.spartaclub.aifriends.mcp.server.security.McpServerAuditInterceptor
@Component
public class McpServerAuditInterceptor {
private final Map<String, AtomicLong> invocationCounts = new ConcurrentHashMap<>();
public void logInvocation(String toolName, String clientId) {
invocationCounts.computeIfAbsent(toolName, k -> new AtomicLong(0))
.incrementAndGet();
log.info("[MCP Server Audit] tool={}, client={}, timestamp={}, totalCalls={}",
toolName, clientId, Instant.now(),
invocationCounts.get(toolName).get());
}
public long getInvocationCount(String toolName) {
AtomicLong count = invocationCounts.get(toolName);
return count != null ? count.get() : 0;
}
}
ConcurrentHashMap + AtomicLong 조합인 이유 — Streamable-HTTP 는 다중 클라이언트가 동시에 접속할 수 있어요. 동시성 안전한 자료구조로 카운터를 관리해야 해요.
학습 단계에서 인메모리로 충분한 이유 — 앱이 재시작되면 카운터가 초기화돼요. 프로덕션에서는 이 로그가 Micrometer 메트릭 + 구조화 로깅으로 확장돼요 (Day 21 Observability 에서 다룰 예정이에요).
축 3~5 요약
| 축 | 구현 방식 | 한 줄 요약 |
|---|---|---|
| 3. 도구 스코프 | 읽기 (Status, SaveSlot) vs 쓰기 (EventTrigger) 분리 | 쓰기 도구는 추가 권한 검증 가능 |
| 4. 입력 검증 | EVENT_EFFECTS.get() 화이트리스트 |
허용 목록에 없는 이벤트는 거절 |
| 5. 네트워크 노출 | STDIO = 로컬 신뢰, HTTP = API Key 강제 | transport 별 보안 수준 차등 |
Day 17 에서 5 가드 빈을 만들면서 배운 원칙이 그대로 적용돼요 — 외부와 만나는 모든 지점에 의식적인 결정을 넣는다. 방향만 반대예요.
🙋 학생 질문 — "튜터님, API Key 말고 OAuth 2.1 은 언제 도입하나요?"
API Key 는 학습 단계에서 가장 간단한 인증이에요. 프로덕션에서는 OAuth 2.1 Bearer 토큰이 표준이에요. Spring Security 의 Resource Server 설정과 MCP 엔드포인트를 결합하면 돼요.
MCP 스펙은 2025-03 개정에서 OAuth 2.1 을 인증 표준으로 권장했어요. Spring AI 2.0 에서 spring-ai-starter-mcp-server-webmvc 의 OAuth 통합이 더 매끄러워질 예정이에요.
본 강의에서는 API Key 로 "인증 경계가 필요하다" 는 감각을 익히고, OAuth 전환은 Day 19 과제에서 도전해 보는 구조예요.
💡 튜터의 결론
MCP Server 보안은 Day 17 Client 가드의 거울이에요. 방향만 반대 (나가는 → 들어오는) 이고, 원칙은 동일 — 외부와 만나는 지점마다 인증, 감사, 스코프, 검증, 네트워크 노출을 의식적으로 결정해요.
Step 7. A2A 프로토콜 개요 — MCP(도구) vs A2A(에이전트) 축 구분
MCP Server 까지 완성했어요. 우리 ai-friends 의 도구를 외부 LLM 앱이 표준 프로토콜로 호출할 수 있게 됐죠. 그런데 한 가지 질문이 남아요.
"도구를 호출하는 건 알겠는데, 에이전트끼리 협업하려면 어떻게 하죠?"
MCP 는 도구 호출의 표준이에요. LLM 앱이 "이 함수를 실행해 줘" 라고 요청하면 서버가 실행해서 결과를 돌려주는 구조죠. 그런데 에이전트가 다른 에이전트에게 "이 작업을 처리해 줘" 라고 작업 자체를 위임하는 건 MCP 의 범위 밖이에요.
그래서 등장한 게 A2A (Agent-to-Agent) 프로토콜이에요.
A2A 란 무엇인가
A2A 는 Google 이 2025 년에 제안하고, 이후 Linux Foundation 에 거버넌스가 이관된 오픈 프로토콜이에요. 2026 년 5월 현재 150개 이상 조직이 프로덕션에서 사용 중이에요.
MCP 와 A2A 의 관계를 실생활 비유로 풀어 볼게요.
MCP 는 연장통이에요. 목수 (LLM) 가 "이 망치를 써야겠다" 고 판단하면 연장통에서 망치를 꺼내서 못을 때려요. 도구를 골라서 실행하는 거예요.
A2A 는 하청 계약이에요. 건축 현장의 총 책임자 (에이전트 A) 가 "배관 작업은 배관 전문업체 (에이전트 B) 에 맡기자" 고 판단하면, 작업 요청서를 보내고 진행 상황을 추적하다가 완료 보고를 받아요. 도구를 직접 쓰는 게 아니라 다른 전문가에게 작업을 위임하는 거예요.
| 축 | MCP | A2A |
|---|---|---|
| 무엇을 전달하나 | 도구 호출 요청 (함수명 + 파라미터) | 작업 요청 (자연어 또는 구조화된 task) |
| 상대방은 누구 | 도구 서버 (함수 실행기) | 다른 에이전트 (자율적 판단 주체) |
| 응답 특성 | 결과값 즉시 반환 | 비동기 진행 (pending → working → done) |
| 상태 추적 | 없음 (1회성 호출) | Task 상태 머신 (submitted → working → done/failed) |
| 발견 메커니즘 | tools/list |
Agent Card (JSON 메타데이터) |
| 프로토콜 | JSON-RPC 2.0 | HTTP + JSON (REST-like) |
A2A 의 핵심 3 개념
Agent Card
A2A 에서 에이전트는 Agent Card 라는 JSON 문서로 자기 능력을 공개해요. MCP 의 tools/list 에 대응하는 개념이에요.
{
"name": "ai-friends-agent",
"description": "AI 캐릭터 관리 에이전트 — 캐릭터 상태 조회, 이벤트 처리, 세이브 관리",
"url": "https://ai-friends.example.com/a2a",
"capabilities": {
"streaming": true,
"pushNotifications": false
},
"skills": [
{
"id": "character-management",
"name": "캐릭터 관리",
"description": "AI 캐릭터의 상태 조회, 이벤트 발동, 호감도 관리"
}
]
}
Agent Card 가 /.well-known/agent.json 경로에 공개되면, 다른 에이전트가 이걸 읽고 "이 에이전트에게 캐릭터 관리 작업을 맡길 수 있겠다" 를 판단해요.
Task
A2A 에서 작업 단위는 Task 예요. MCP 의 tools/call 이 1회성 함수 호출이라면, A2A 의 Task 는 상태를 가진 비동기 작업이에요.
submitted → working → done (또는 failed)
요청하는 에이전트가 Task 를 생성하면, 받는 에이전트가 작업을 처리하면서 상태를 업데이트해요. 요청자는 상태를 폴링하거나 push notification 으로 완료를 기다릴 수 있어요.
Message
Task 안에서 에이전트 간에 주고받는 메시지예요. 텍스트, 이미지, 파일 등 다양한 형태의 Part 를 담을 수 있어요.
MCP + A2A 가 보완하는 구조
둘은 경쟁이 아니라 보완 관계예요.
하나의 에이전트가 A2A 로 작업을 위임받고, 그 작업을 처리하는 과정에서 MCP 도구를 호출하는 구조가 자연스러워요.
예를 들어:
- 총괄 에이전트가 "ai-friends 캐릭터 ARIA 의 호감도를 높여 줘" 라는 A2A Task 를 ai-friends 에이전트에 위임
- ai-friends 에이전트가 Task 를 받아서, MCP 도구
getCharacterStatus로 현재 상태를 확인 - MCP 도구
triggerEvent로 선물 이벤트를 발동 - A2A Task 상태를
done으로 업데이트하고 결과를 반환
MCP 가 손 (도구를 직접 쓰는 행위) 이라면, A2A 는 전화 (다른 전문가에게 일을 맡기는 행위) 예요. 같은 일을 해도 접근 방식이 달라요.
🙋 학생 질문 — "튜터님, A2A 없이 MCP 만으로도 에이전트 간 협업이 가능하지 않나요?"
기술적으로는 가능해요. 에이전트 A 가 에이전트 B 의 MCP 도구를 직접 호출하면 되니까요. 하지만 MCP 는 1회성 동기 호출이에요. "이 함수 실행하고 결과 줘" 로 끝나죠.
에이전트 간 협업에서는 비동기 작업 추적이 필요해요. "이 작업 얼마나 진행됐어?", "중간에 추가 정보가 필요하면 다시 물어봐" 같은 상호작용이에요. MCP 로 이걸 구현하면 polling + 상태 관리를 우리가 직접 짜야 해요. A2A 는 이 패턴을 프로토콜 수준에서 표준화한 거예요.
정리하면 — 단순한 도구 호출이면 MCP 로 충분하고, 복잡한 작업 위임 + 상태 추적이 필요하면 A2A 가 가치를 갖기 시작해요.
💡 튜터의 결론
MCP 는 도구 호출의 표준, A2A 는 에이전트 간 작업 위임의 표준이에요. 둘은 경쟁이 아니라 보완 관계 — 하나의 에이전트가 A2A 로 작업을 받아서, MCP 도구로 처리하는 구조가 자연스러워요.
Step 8. A2A 맛보기 + 트레이드오프 정리
마지막 Step 이에요. 코드를 만지지 않는 정리 시간이에요. A2A 의 현재 생태계를 한 줄로 짚고, 오늘 8 Step 의 트레이드오프를 테이블로 정리하고, Day 19 복선을 심어요.
A2A 생태계 현황 (2026-05 기준)
- 거버넌스: Google 제안 → Linux Foundation 이관 완료
- 참여 규모: 150+ 조직 프로덕션 사용
- SDK: Google ADK (Agent Development Kit) 1.0 GA
- Spring AI 통합: 블로그 포스트 (2026-01-29) 에서 Spring AI 의 A2A 통합 패턴이 공개됐어요.
spring-ai-starter-a2a같은 first-class starter 는 아직 없지만,RestClient+ A2A JSON 스펙으로 통합하는 패턴이 문서화돼 있어요.
A2A 는 아직 MCP 만큼 성숙하지 않아요. 하지만 에이전트 간 협업이 필요한 시점이 오면 자연스럽게 도입하게 되는 프로토콜이에요.
트레이드오프 정리 — 오늘의 5가지 결정
| 결정 | 선택 A | 선택 B | 우리의 선택 + 이유 |
|---|---|---|---|
| 1. 내부 도구 vs 외부 도구 분리 | 하나로 통합 | 패키지 분리 | 분리 — 노출 범위 통제 |
| 2. STDIO vs Streamable-HTTP | 하나만 | 둘 다 활성화 | 둘 다 — 로컬 + 원격 모두 지원 |
| 3. 인증 방식 | API Key | OAuth 2.1 | API Key (학습) → OAuth (프로덕션) |
| 4. 감사 로그 | 인메모리 | 외부 저장소 | 인메모리 (학습) → Micrometer (Day 20) |
| 5. 에이전트 협업 | MCP 만으로 | MCP + A2A | MCP 기반 + A2A 인식 — 필요 시점에 도입 |
Day 19 으로 잇는 다리
다음 시간은 Agent 를 프로덕션에 올리기 위한 harness 엔지니어링이에요. Day 14 에서 손으로 구현한 4 가드 (반복 횟수, 타임아웃, 토큰 예산, 툴 호출 횟수) 가 Spring AI Agent Client 에서는 선언적으로 처리되는 모습을 볼 거예요.
복선 키워드 4종을 한 줄씩 남겨 둘게요.
- Agent Client — Day 14 의 수동 가드가 선언적 설정으로 전환되는 지점
- Spring AI Bench — 에이전트 품질을 정량 측정하는 벤치마크 도구
- Rate Limit — 클라이언트별 호출 횟수 제한 (오늘 Step 6 감사 로그의 확장)
- harness 엔지니어링 — "에이전트를 프로덕션에 올리려면 harness 가 필요하다"
🎯 오늘 한 줄 회수
"Day 17 Client 의 거울 — 우리 도메인 도구를 MCP Server 로 노출하고, 보안 5축으로 들어오는 요청을 검증하고, A2A 로 에이전트 협업의 다음 축을 인식했어요."
💡 튜터의 결론
MCP 가 도구의 표준이라면 A2A 는 에이전트 협업의 표준이에요. 두 프로토콜이 보완하는 구조를 인식하는 것만으로도 오늘의 가장 큰 수확이에요. 다음 시간 Day 19 에서는 이 에이전트를 프로덕션 수준으로 끌어올리는 harness 엔지니어링을 다뤄요.
마무리
Day 17 에서 우리는 외부 MCP Server 의 도구를 받아들이는 Client 였어요. 오늘은 정반대 — 우리 ai-friends 의 도메인 도구를 외부 LLM 앱에 내어주는 Server 가 됐어요. 같은 MCP 프로토콜인데 화살표 방향만 뒤집힌 거예요.
오늘의 8 Step 을 한 표에 담아 회수해요.
| Step | 한 줄 회수 |
|---|---|
| 1 | MCP Server = 우리 도구를 외부에 노출하는 표준 통로. REST API 와 병행. Transport 는 STDIO (로컬) + Streamable-HTTP (원격) |
| 2 | spring-ai-starter-mcp-server 의존성 한 줄 + @Component + @Tool = MCP Server 완성 |
| 3 | 도구 3종 — getCharacterStatus (읽기) + triggerEvent (쓰기, 화이트리스트) + listSaveSlots (읽기, 50건 제한). 내부 도구와 패키지 분리 |
| 4 | Claude Desktop / Cursor 연동 — 설정 JSON 한 장으로 STDIO 연동. LLM 이 도구를 자동 발견 + 자동 호출 |
| 5 | Streamable-HTTP — /mcp 엔드포인트로 원격 접근. 기존 REST API 와 같은 포트에서 공존 |
| 6 | 보안 5축 — API Key 인증 + 감사 로그 + 도구 스코프 + 입력 검증 + 네트워크 노출 차등. Day 17 Client 가드의 거울 |
| 7 | A2A = 에이전트 간 작업 위임의 표준. MCP (도구 호출) 와 보완 관계. Agent Card + Task + Message 3 개념 |
| 8 | 트레이드오프 5종 정리 + Day 19 Agent Client / Bench / Rate Limit 복선 |
Day 17 에서 배운 메타 원칙을 다시 한 번 확인했어요 — 외부와 만나는 모든 지점에 의식적인 결정을 넣는다. Day 17 은 나가는 방향 (외부 응답 검증), Day 18 은 들어오는 방향 (외부 요청 검증) 으로 같은 원칙이 양쪽에서 일관되게 적용됐어요.
다음 시간 (Day 19) 으로 잇는 다리
Day 19 는 에이전트를 프로덕션에 올리기 위한 harness 엔지니어링이에요. Day 14 에서 반복 횟수, 타임아웃, 토큰 예산, 툴 호출 횟수를 손으로 구현했던 4 가드가, Spring AI Agent Client 에서는 선언적 설정으로 전환돼요.
그리고 본 시간 Step 6 에서 인메모리로 카운트만 했던 감사 로그가 Rate Limit 정책으로 확장되는 대목도 함께 다뤄요.
도전 과제
오늘 우리는 MCP Server 3종 도구 + 보안 5축 + A2A 개념을 8 Step 으로 익혔어요. 진짜로 감각이 잡히는 건 본인이 직접 도구를 확장하고 보안을 강화해 보는 경험에서 시작돼요.
과제 1. MCP 서버에 새 도구 추가 — get_world_lore(keyword) 🌱
💡 왜 이 과제인가
오늘 Step 3 에서 우리는 도구 3종 (get_character_info · trigger_event · get_save_slot) 을 MCP Server 로 노출했어요. 그런데 이 도구들은 전부 RDB 기반이에요. Day 15~16 에서 구축한 RAG vectorStore 를 MCP 도구로 노출 하면 어떻게 될까요?
본 과제는 Day 15 의 VectorStore.similaritySearch() 와 Day 18 의 MCP 도구 패턴이 자연스럽게 합류 하는 자리를 본인 손으로 만드는 거예요. 외부 LLM (Claude Desktop · Cursor) 이 우리 ai-friends 의 세계관 설정 RAG 를 직접 검색할 수 있게 되는 그림이라, "MCP Server 의 진짜 가치는 도구 카탈로그를 확장 가능하게 노출하는 결" 이 잡혀요.
✅ 요구사항
- 도구 이름:
get_world_lore - 파라미터:
keyword(검색할 세계관 키워드) - 동작: vectorStore 에서 keyword 로 유사도 검색 → 상위 3건의 세계관 텍스트를 반환
- 반환 record:
McpWorldLoreResult(boolean found, List<String> loreTexts) - Step 3 의 도구 패턴 (별도 패키지 + 전용 응답 record + 결과 크기 제한) 그대로 따르기
- 단위 테스트 —
VectorStore를 모킹해서 검색 결과 0건 / 1건 / 3건 케이스 모두 검증
💡 힌트
- 패키지 위치:
kr.spartaclub.aifriends.mcp.tool(Step 3 의 도구 3종과 동일) - record 시그니처는 Step 3 의
McpCharacterInfo결 —found플래그 + 결과 리스트 VectorStore.similaritySearch(SearchRequest.builder().query(keyword).topK(3).build())한 줄@Tool(description = "...")의 description 은 Claude Desktop / Cursor 에서 도구 선택의 근거가 돼요 — "세계관 키워드로 lore 를 검색합니다. 캐릭터 배경 / 세계관 설정 / 스토리 이벤트 등을 찾을 때 사용" 결로 구체적으로
과제 2. Streamable-HTTP 인증 강화 — API Key 에서 Bearer Token + 만료 검증으로 🪪
💡 왜 이 과제인가
Step 6 의 McpServerApiKeyFilter 는 고정 API Key 한 줄로 인증을 처리해요. 학습용 lab 으론 단순 + 깔끔하지만, 운영에선 키 유출 = 영구 권한 탈취 의 위험이 큰 결이에요.
본 과제는 "고정 키 → 만료가 있는 토큰" 으로 한 단계 진화시키는 거예요. OAuth 2.1 의 전체 흐름까지 가지 않더라도, Bearer Token + 만료 시각 검증 만으로도 운영 안전선이 크게 올라가는 그림을 본인 손으로 확인할 수 있어요.
✅ 요구사항
- 헤더:
Authorization: Bearer <token>형식 수용 - 토큰 형식: Base64 인코딩된
clientId:timestamp:signature - 검증 1 —
timestamp가 현재 시각에서 5분 이내인지 - 검증 2 —
signature가 유효한지 (HMAC-SHA256 + 서버 비밀키) - 만료/위조된 토큰이면
401 Unauthorized+ 구조화된 에러 응답 - 단위 테스트 — 정상 토큰 / 만료 토큰 / 위조 signature / 잘못된 형식 4 케이스 모두 검증
💡 힌트
OncePerRequestFilter를 그대로 확장 — Step 6 의McpServerApiKeyFilter가 출발점- 서버 비밀키는
application.yml의aifriends.mcp.server.secret-key로 외부화 (Day 2 결) HmacUtils또는javax.crypto.Mac.getInstance("HmacSHA256")한 줄로 signature 검증- 5분 윈도우는
Instant.now().minusSeconds(300).isBefore(tokenTime)결 - 에러 응답은
ApiResponse.fail(ErrorResponse)결로 —GlobalExceptionHandler와 일관
과제 3. 도구 호출 Rate Limit — 클라이언트별 분당 호출 횟수 제한 🦙
💡 왜 이 과제인가
Step 6 의 McpServerAuditInterceptor 는 도구 호출을 로깅 만 해요. 운영에선 "악성 클라이언트가 1초에 1000번 도구를 두드린다" 같은 시나리오가 자연스러운 위협이라, 로깅만으론 부족하고 차단 이 필요한 결이에요.
본 과제는 Day 19 의 cost guardrail 복선 이기도 해요. 도구 호출은 LLM 호출보다 싸지만, 외부 API 를 부르는 도구 (예: 과제 1 의 get_world_lore 는 pgvector embedding 호출) 라면 호출 횟수가 곧 비용이에요. 분당 호출 한도는 비용 곡선의 지붕 을 박는 결이라, Day 19 의 guardrail 감각이 미리 잡혀요.
✅ 요구사항
- 정책: 클라이언트당 분당 30회 (설정 외부화 가능)
- 클라이언트 식별 — API Key (Step 6) 또는 Bearer Token 의
clientId(과제 2) - 초과 시 — 도구 실행을 거부하고
"Rate limit exceeded"응답 +429 Too Many Requests - Sliding window 방식 (정확) 또는 fixed window 방식 (단순) 중 택1
- 단위 테스트 — 30회 정상 통과 / 31번째 차단 / 1분 뒤 리셋 3 케이스 검증
💡 힌트
- 단순 fixed window:
Map<String, AtomicInteger> counter+ 매 분마다clear()호출하는 스케줄러 - 정확한 sliding window: Redis 의
INCR+EXPIRE조합 또는 Bucket4j 라이브러리 (io.github.bucket4j:bucket4j-core) - 외부화:
aifriends.mcp.server.rate-limit.per-minute: 30으로 yml 한 줄 - Day 19 의 cost guardrail 과 합류할 때는 "호출 횟수 → 호출 비용" 으로 차원이 바뀌어요 — 한도의 단위가 횟수 가 아니라 USD 로 가는 자연스러운 진화
생각해볼 주제
오늘 우리는 MCP Server + A2A 의 8 Step 골격을 추가했어요. 그런데 진짜 운영 의 면은 본 강의가 추가한 디폴트 위에서 한 단계 더 깊게 확장돼요. 세 독립적인 주제를 던져 둡니다. 각 주제는 답이 하나가 아니에요 — 본인의 시나리오와 운영 가치 위에서 본인의 답 을 찾아오는 거예요.
주제 1. MCP Server 공개 범위 — 조직 내부 vs 외부 파트너 vs 퍼블릭
💭 생각해보기
우리 ai-friends MCP Server 를 외부에 공개한다고 가정해 봐요. 공개 범위에 따라 보안 + 운영 부담이 어떻게 달라질까요?
- 조직 내부: VPN + 서비스 메시 뒤에서만 접근 가능
- 외부 파트너: API Key / OAuth + 계약 기반 접근
- 퍼블릭: 누구나 접근 가능 — rate limit, 과금, abuse 방어 필요
세 범위 각각에서 어떤 보안 축이 새로 필요해지는지, 어떤 운영 부담이 추가되는지, 그리고 Step 6 의 보안 5축이 어디까지 커버되고 어디서부터 부족해지는지 를 본인의 시나리오로 매핑해 보세요.
주제 2. MCP vs REST API — 언제 MCP 로 노출하고 언제 REST 로 충분한가
💭 생각해보기
우리 ai-friends 에는 REST API 도 있고 MCP Server 도 있어요. 새로운 기능을 추가할 때 어떤 기준으로 "이건 REST 로 충분하다" 또는 "이건 MCP 로도 노출해야 한다" 를 판단할 수 있을까요?
소비자가 사람 (프론트엔드 개발자) 이면 REST, 소비자가 LLM 이면 MCP 라는 단순 기준 외에 더 정밀한 판단 기준이 있을지 고민해 보세요. 호출 패턴 (단발 vs 도구 카탈로그 탐색), 파라미터의 자연어 친화성, 응답 스키마의 LLM 친화도, 운영 비용 (MCP Server 의 추가 인프라 부담) 등의 축으로 분해해 보면 결이 잡혀요.
주제 3. A2A 도입 시점 — 에이전트가 몇 개일 때 A2A 가 가치를 갖는가
💭 생각해보기
A2A 프로토콜은 에이전트 간 협업을 표준화해요. 하지만 에이전트가 1~2개뿐이라면 직접 MCP 호출이나 REST 호출로도 충분할 수 있어요. 어떤 조건에서 A2A 를 도입하는 게 비용 대비 가치가 있을까요?
에이전트 수, 팀 경계 (한 팀 vs 여러 팀이 각자 에이전트 소유), 비동기 작업 비율 (동기 호출로 충분한지 vs 장시간 작업이 빈번한지), 에이전트 발견 필요성 (정적 매핑 vs 동적 카드 탐색) 등을 기준으로 판단해 보세요.
✅ 예시 답안정답 보기
과제 1 예시답안: MCP 서버에 새 도구 추가 — get_world_lore(keyword)
핵심 접근
Day 15~16 에서 구축한 VectorStore (PgVectorStore) 의 similaritySearch() 를 MCP 도구 안에서 호출해요.
Day 18 Step 3 에서 배운 MCP 전용 도구 패턴 (별도 패키지 + 전용 응답 record + 결과 크기 제한) 을 그대로 따르되, 이번엔 데이터 소스가 RDB 가 아니라 벡터 저장소라는 점이 다릅니다.
예시 구현
응답 record
// kr.spartaclub.aifriends.mcp.server.dto.McpWorldLoreResult
public record McpWorldLoreResult(
boolean found,
List<String> loreTexts
) {
public static McpWorldLoreResult empty() {
return new McpWorldLoreResult(false, List.of());
}
}
MCP 도구 본체
// kr.spartaclub.aifriends.mcp.server.McpWorldLoreTool
@Component
public class McpWorldLoreTool {
private static final Logger log = LoggerFactory.getLogger(McpWorldLoreTool.class);
private static final int TOP_K = 3;
private final VectorStore vectorStore;
public McpWorldLoreTool(VectorStore vectorStore) {
this.vectorStore = vectorStore;
}
@Tool(description = "Search the ai-friends world lore knowledge base by keyword. "
+ "Returns the top 3 most relevant lore passages. "
+ "Use this to look up character backstories, world rules, or setting details.")
public McpWorldLoreResult getWorldLore(
@ToolParam(description = "The keyword or phrase to search for in the world lore")
String keyword
) {
log.info("[MCP Server] getWorldLore invoked — keyword={}", keyword);
if (keyword == null || keyword.isBlank()) {
return McpWorldLoreResult.empty();
}
SearchRequest request = SearchRequest.builder()
.query(keyword.trim())
.topK(TOP_K)
.build();
List<Document> hits = vectorStore.similaritySearch(request);
if (hits == null || hits.isEmpty()) {
return McpWorldLoreResult.empty();
}
List<String> loreTexts = hits.stream()
.map(Document::getText)
.toList();
return new McpWorldLoreResult(true, loreTexts);
}
}
채점 포인트
| 포인트 | 설명 | 배점 가중 |
|---|---|---|
| VectorStore 주입 | VectorStore 인터페이스로 주입했는가 (구체 타입 PgVectorStore 가 아닌) |
상 |
| SearchRequest 빌더 | SearchRequest.builder().query(...).topK(3).build() 로 검색 요청을 올바르게 구성했는가 |
상 |
| 전용 응답 record | McpWorldLoreResult 를 별도 record 로 정의하고, Document 를 직접 반환하지 않았는가 |
상 |
| 빈 결과 처리 | keyword 가 null/blank 이거나 검색 결과가 없을 때 예외 대신 구조화된 빈 응답을 반환하는가 | 중 |
| @Tool description | 영문 description 이 LLM 이 도구를 자동 발견할 수 있을 만큼 명확한가 | 중 |
| 패키지 위치 | mcp.server 패키지에 배치하여 내부 도구 (tool/) 와 분리했는가 |
하 |
흔한 실수
Document를 그대로 반환 --Document에는 벡터 배열, 메타데이터 전체가 포함돼요. 외부 LLM 앱의 컨텍스트 윈도우를 불필요하게 잡아먹고, 내부 구조가 노출돼요.getText()로 텍스트만 꺼내서List<String>으로 변환해야 해요.topK를 외부 파라미터로 노출 -- MCP 도구의@ToolParam으로 topK 를 받으면 외부 클라이언트가 100건을 요청할 수 있어요. Day 18 Step 3 에서McpSaveSlotTool이MAX_SLOTS = 50으로 서버 측에서 제한했듯이, topK 도 서버 측 상수로 고정하는 게 안전해요.- null 체크 누락 --
vectorStore.similaritySearch()가 빈 리스트를 반환할 수 있어요. null 또는 empty 일 때 예외를 던지면 외부 LLM 앱의 대화 흐름이 끊겨요.
실무 개선 포인트 (심화)
- 메타데이터 필터 추가 --
SearchRequest.builder().filterExpression("character_id == 'ARIA'")처럼 캐릭터별 필터를 걸면, 특정 캐릭터의 세계관만 검색할 수 있어요. Day 16 에서 배운FilterExpressionBuilder를 활용하는 확장이에요. - 유사도 임계값 (similarity threshold) --
SearchRequest에similarityThreshold(0.7)을 추가하면 관련성이 낮은 결과를 자동으로 걸러줘요. 세계관과 전혀 무관한 키워드가 들어왔을 때 "관련 내용을 찾을 수 없습니다" 를 정확하게 반환할 수 있어요.
과제 2 예시답안: Streamable-HTTP 인증 강화 — Bearer Token + 만료 검증
핵심 접근
Step 6 의 McpServerApiKeyFilter 가 단순 문자열 비교 (expectedApiKey.equals(...)) 로 인증했어요.
이번 과제는 토큰을 Base64 디코딩해서 clientId:timestamp:signature 세 필드를 추출하고, timestamp 만료 + signature 유효성을 검증하는 구조로 확장해요. 프로덕션의 JWT/OAuth 플로우 전 단계로, "토큰 구조를 직접 파싱하고 검증하는 감각" 을 익히는 게 핵심이에요.
예시 구현
// kr.spartaclub.aifriends.mcp.server.security.McpServerBearerTokenFilter
@Component
@ConditionalOnProperty(name = "aifriends.mcp.server.signing-secret")
public class McpServerBearerTokenFilter {
private static final Logger log = LoggerFactory.getLogger(McpServerBearerTokenFilter.class);
private static final String BEARER_PREFIX = "Bearer ";
private static final long TOKEN_VALIDITY_SECONDS = 300; // 5분
private final String signingSecret;
public McpServerBearerTokenFilter(
@Value("${aifriends.mcp.server.signing-secret}") String signingSecret) {
this.signingSecret = signingSecret;
}
/**
* Authorization 헤더에서 Bearer 토큰을 꺼내 검증한다.
* 토큰 형식: Base64(clientId:timestamp:signature)
*
* @return 인증 성공 시 clientId, 실패 시 빈 Optional
*/
public Optional<String> authenticate(String authorizationHeader) {
if (authorizationHeader == null || !authorizationHeader.startsWith(BEARER_PREFIX)) {
log.warn("[MCP Bearer] Authorization header missing or not Bearer type");
return Optional.empty();
}
String token = authorizationHeader.substring(BEARER_PREFIX.length()).trim();
// 1. Base64 디코딩
String decoded;
try {
decoded = new String(Base64.getDecoder().decode(token), StandardCharsets.UTF_8);
} catch (IllegalArgumentException e) {
log.warn("[MCP Bearer] Invalid Base64 token");
return Optional.empty();
}
// 2. clientId:timestamp:signature 분리
String[] parts = decoded.split(":", 3);
if (parts.length != 3) {
log.warn("[MCP Bearer] Token format invalid — expected clientId:timestamp:signature");
return Optional.empty();
}
String clientId = parts[0];
String timestampStr = parts[1];
String signature = parts[2];
// 3. timestamp 만료 검증 (5분 이내)
long tokenTimestamp;
try {
tokenTimestamp = Long.parseLong(timestampStr);
} catch (NumberFormatException e) {
log.warn("[MCP Bearer] Invalid timestamp format");
return Optional.empty();
}
long now = Instant.now().getEpochSecond();
if (Math.abs(now - tokenTimestamp) > TOKEN_VALIDITY_SECONDS) {
log.warn("[MCP Bearer] Token expired — issued={}, now={}, maxAge={}s",
tokenTimestamp, now, TOKEN_VALIDITY_SECONDS);
return Optional.empty();
}
// 4. signature 검증 (HMAC-SHA256)
String expectedSignature = computeSignature(clientId, timestampStr);
if (!expectedSignature.equals(signature)) {
log.warn("[MCP Bearer] Signature mismatch for client={}", clientId);
return Optional.empty();
}
log.info("[MCP Bearer] Authenticated client={}", clientId);
return Optional.of(clientId);
}
private String computeSignature(String clientId, String timestamp) {
try {
Mac mac = Mac.getInstance("HmacSHA256");
SecretKeySpec keySpec = new SecretKeySpec(
signingSecret.getBytes(StandardCharsets.UTF_8), "HmacSHA256");
mac.init(keySpec);
byte[] hash = mac.doFinal((clientId + ":" + timestamp).getBytes(StandardCharsets.UTF_8));
return Base64.getEncoder().encodeToString(hash);
} catch (NoSuchAlgorithmException | InvalidKeyException e) {
throw new IllegalStateException("HMAC-SHA256 computation failed", e);
}
}
/**
* 인증 실패 시 반환할 구조화 에러 JSON.
*/
public String rejectionResponseJson(String reason) {
return "{\"success\":false,\"error\":{\"code\":\"MCPS003\","
+ "\"message\":\"" + reason + "\"}}";
}
}
채점 포인트
| 포인트 | 설명 | 배점 가중 |
|---|---|---|
| Base64 디코딩 + 3분할 | 토큰을 디코딩해서 clientId:timestamp:signature 세 부분으로 올바르게 분리하는가 |
상 |
| timestamp 만료 검증 | Instant.now() 기준 5분 이내인지 검증하는가. 미래 timestamp 도 고려하여 Math.abs() 로 양방향 검증하는가 |
상 |
| signature 검증 | HMAC-SHA256 등 서버 측 secret 으로 서명을 재계산해서 비교하는가 | 상 |
| 구조화 에러 응답 | 인증 실패 시 401 + 구조화된 JSON 에러를 반환하는가 (raw 문자열이 아닌) | 중 |
| @ConditionalOnProperty | 설정이 없으면 빈이 비활성화되는 조건부 등록을 구현했는가 | 중 |
| clientId 반환 | 인증 성공 시 clientId 를 추출해서 감사 로그에 흘려줄 수 있는 구조인가 | 하 |
흔한 실수
- timestamp 를 밀리초로 해석 --
Instant.now().toEpochMilli()와Instant.now().getEpochSecond()를 혼용하면 만료 검증이 항상 실패하거나 항상 통과해요. 토큰 생성과 검증에서 같은 단위를 쓰는지 확인해야 해요. - signature 를 평문 비교 --
clientId + timestamp을 단순 해시 없이 secret 과 concat 만 해서 비교하면 secret 유출 시 토큰 위조가 사소해져요. 반드시 HMAC 같은 MAC 알고리즘으로 서명해야 해요. Math.abs()누락 -- 클라이언트 시계가 서버보다 약간 빠른 경우 미래 timestamp 가 올 수 있어요. 단방향 (now - token > 300) 만 체크하면 미래 timestamp 를 무한정 허용하게 돼요.
실무 개선 포인트 (심화)
- JWT 전환 -- 이 과제의
clientId:timestamp:signature구조는 JWT 의 단순화 버전이에요. 프로덕션에서는spring-security-oauth2-resource-server의JwtDecoder를 활용해서 표준 JWT 검증으로 전환해요.
claims 에 scope, exp, iss 같은 표준 필드를 넣으면 OAuth 2.1 흐름과 자연스럽게 연결돼요.
2. replay attack 방어 -- 같은 토큰을 5분 내 반복 사용하는 replay attack 을 막으려면, 검증 통과한 토큰의 해시를 Redis 에 TTL 5분으로 저장하고 중복 사용을 거절하는 nonce 패턴을 추가해요.
과제 3 예시답안: 도구 호출 Rate Limit — 클라이언트별 분당 호출 횟수 제한
핵심 접근
Step 6 의 McpServerAuditInterceptor 가 "기록만" 하는 감사 로그였어요. 이번 과제는 기록을 넘어서 호출 횟수를 세고, 임계값 초과 시 실행을 거부하는 Rate Limiter 로 확장해요. 슬라이딩 윈도우 방식으로 "현재 시각 기준 직전 1분" 동안의 호출 수를 추적하는 게 핵심이에요.
예시 구현
// kr.spartaclub.aifriends.mcp.server.security.McpServerRateLimiter
@Component
public class McpServerRateLimiter {
private static final Logger log = LoggerFactory.getLogger(McpServerRateLimiter.class);
private final int maxCallsPerMinute;
// clientId -> 호출 타임스탬프 큐
private final Map<String, Deque<Instant>> clientCallLog = new ConcurrentHashMap<>();
public McpServerRateLimiter(
@Value("${aifriends.mcp.server.rate-limit:30}") int maxCallsPerMinute) {
this.maxCallsPerMinute = maxCallsPerMinute;
log.info("[MCP RateLimit] Initialized — maxCallsPerMinute={}", maxCallsPerMinute);
}
/**
* 클라이언트의 호출이 허용되는지 확인하고, 허용이면 호출을 기록한다.
*
* @param clientId 호출자 식별자
* @return true 면 허용, false 면 rate limit 초과
*/
public boolean tryAcquire(String clientId) {
Instant now = Instant.now();
Instant windowStart = now.minusSeconds(60);
Deque<Instant> callTimestamps = clientCallLog.computeIfAbsent(
clientId, k -> new ConcurrentLinkedDeque<>());
// 윈도우 밖의 오래된 기록 제거
while (!callTimestamps.isEmpty()
&& callTimestamps.peekFirst().isBefore(windowStart)) {
callTimestamps.pollFirst();
}
if (callTimestamps.size() >= maxCallsPerMinute) {
log.warn("[MCP RateLimit] Rate limit exceeded — client={}, "
+ "calls={}, limit={}/min",
clientId, callTimestamps.size(), maxCallsPerMinute);
return false;
}
callTimestamps.addLast(now);
return true;
}
/**
* 특정 클라이언트의 현재 윈도우 내 호출 수를 조회한다.
*/
public int getCurrentCount(String clientId) {
Deque<Instant> timestamps = clientCallLog.get(clientId);
if (timestamps == null) return 0;
Instant windowStart = Instant.now().minusSeconds(60);
return (int) timestamps.stream()
.filter(t -> !t.isBefore(windowStart))
.count();
}
/**
* Rate limit 초과 시 반환할 구조화 에러 응답.
*/
public String rateLimitExceededResponse(String clientId) {
return "{\"success\":false,\"error\":{"
+ "\"code\":\"MCPS004\","
+ "\"message\":\"Rate limit exceeded. "
+ "Maximum " + maxCallsPerMinute + " calls per minute. "
+ "Client: " + clientId + "\"}}";
}
}
채점 포인트
| 포인트 | 설명 | 배점 가중 |
|---|---|---|
| 슬라이딩 윈도우 | 고정 윈도우 (매 분 0초 리셋) 가 아닌 슬라이딩 윈도우 (직전 60초) 로 구현했는가 | 상 |
| 클라이언트별 분리 | Map<String, ...> 으로 클라이언트별 독립 카운팅을 했는가 |
상 |
| 설정 외부화 | @Value 또는 @ConfigurationProperties 로 분당 호출 제한을 외부 설정에서 주입하는가 |
중 |
| 동시성 안전 | ConcurrentHashMap + ConcurrentLinkedDeque 등 동시성 안전한 자료구조를 썼는가 |
중 |
| 거부 응답 구조화 | 초과 시 구조화 JSON 에러를 반환하는가 (단순 예외가 아닌) | 중 |
| 오래된 기록 정리 | 윈도우 밖 타임스탬프를 자동으로 제거해서 메모리 누수를 방지하는가 | 하 |
흔한 실수
- 고정 윈도우 사용 -- 매 분 정각에 카운터를 리셋하는 방식은 경계 시점에 취약해요. 예: 10:00:59 에 30회, 10:01:00 에 30회를 호출하면 실질적으로 2초 안에 60회가 통과해요. 슬라이딩 윈도우는 "직전 60초" 를 항상 기준으로 삼아서 이 문제를 방지해요.
- 메모리 누수 -- 윈도우 밖의 오래된 타임스탬프를 제거하지 않으면
Deque가 무한 증가해요.tryAcquire()진입 시 윈도우 밖 기록을 poll 하는 청소 로직이 필수예요. - 전역 카운터로 구현 -- 클라이언트 구분 없이 전체 호출 수를 세면, 한 클라이언트의 과도한 호출이 다른 정상 클라이언트까지 차단해요. 반드시 클라이언트별 독립 카운팅이어야 해요.
실무 개선 포인트 (심화)
- Redis 기반 분산 Rate Limit -- 인메모리 방식은 인스턴스별 독립 카운팅이라 서버 3대면 실질 한도가 90회가 돼요. Redis 의
ZRANGEBYSCORE(Sorted Set + timestamp score) 패턴을 쓰면 인스턴스 간 공유되는 글로벌 Rate Limit 을 구현할 수 있어요. - Bucket4j + Spring Boot Starter -- Rate Limit 전용 라이브러리인 Bucket4j 를 쓰면 토큰 버킷 알고리즘으로 더 정교한 제어가 가능해요. burst 허용 + 평균 속도 제한을 동시에 설정할 수 있어서 "순간 폭주는 허용하되 평균은 유지" 같은 정책을 선언적으로 표현할 수 있어요.
주제 1 예시답안: MCP Server 공개 범위
[문제 상황 요약]
우리 ai-friends MCP Server 를 외부에 공개한다고 가정했을 때, 공개 범위가 넓어질수록 보안과 운영 부담이 어떻게 달라지는지를 판단해야 해요. Step 6 에서 API Key + 감사 로그로 기본적인 보안을 갖추었지만, 이것으로 충분한 범위는 어디까지인지가 핵심 질문이에요.
[튜터의 가이드 및 해설]
MCP Server 의 공개 범위는 세 단계로 나눌 수 있고, 각 단계마다 보안과 운영의 무게가 질적으로 달라져요.
-
Option A: 조직 내부 전용 -- VPN 또는 서비스 메시 (Istio, Linkerd) 뒤에서만 접근 가능해요. 네트워크 레벨에서 외부 접근이 차단되니까, 인증은 내부 mTLS 또는 단순 API Key 로 충분해요. Step 6 의
McpServerApiKeyFilter수준이면 실무에서도 동작해요. 장점은 운영 부담이 가장 가벼운 것, 단점은 외부 파트너와 협업이 불가능한 것. -
Option B: 외부 파트너 공개 -- 계약 기반으로 특정 파트너사에만 접근을 허용해요. OAuth 2.1 Client Credentials 플로우로 파트너별 client_id 를 발급하고, 도구별 scope (read / write) 를 분리해요. 감사 로그는 파트너별 호출 패턴을 추적해야 해서 구조화 로깅 + 대시보드가 필요해요. 장점은 통제 가능한 범위에서 생태계를 확장하는 것, 단점은 파트너 온보딩 프로세스와 SLA 관리가 추가되는 것.
-
Option C: 퍼블릭 공개 -- 누구나 가입하면 API Key 를 받아서 도구를 호출할 수 있어요. Rate Limit (과제 3) 은 필수이고, 과금 체계, abuse 탐지 (비정상 호출 패턴), DDoS 방어, IP 차단이 추가돼요. API Gateway (Kong, AWS API Gateway) 를 앞단에 두고 인증/과금/Rate Limit 을 위임하는 게 일반적이에요. 장점은 생태계 최대 확장, 단점은 운영 비용과 보안 부담이 가장 큰 것.
-
현업에서는 보통: "조직 내부에서 시작하고, 검증된 파트너에게 점진적으로 확장" 하는 패턴이에요. 처음부터 퍼블릭으로 여는 경우는 MCP Server 자체가 비즈니스 모델 (API as a Product) 인 경우에만 해요. 대부분의 사내 MCP Server 는 Option A 에서 시작해서, 필요에 따라 Option B 로 확장하는 흐름을 따릅니다.
🎯 면접관을 홀리는 핵심 멘트
"MCP Server 공개 범위는 '누가 호출하느냐' 에 따라 보안 스택이 질적으로 달라집니다. 조직 내부면 네트워크 격리 + API Key 로 충분하고, 외부 파트너면 OAuth 2.1 + scope 분리 + 파트너별 감사가 추가되고, 퍼블릭이면 Rate Limit + 과금 + abuse 탐지 + API Gateway 까지 필요합니다. 처음부터 퍼블릭 수준의 보안을 구축하면 오버엔지니어링이고, 내부 전용으로 시작해서 파트너 요청이 들어오는 시점에 OAuth 를 도입하는 게 현실적인 전략입니다."
주제 2 예시답안: MCP vs REST API
[문제 상황 요약]
우리 ai-friends 에는 REST API (/api/...) 와 MCP Server (/mcp) 가 공존해요. 새로운 기능을 추가할 때 "이건 REST 로 충분하다" 와 "이건 MCP 로도 노출해야 한다" 를 어떤 기준으로 판단할 수 있을까요? 단순히 "소비자가 사람이면 REST, LLM 이면 MCP" 라는 기준만으로는 부족한 경우가 있어요.
[튜터의 가이드 및 해설]
판단 기준은 크게 네 가지 축으로 나눌 수 있어요.
1. 소비자가 누구인가 -- 가장 기본적인 기준이에요. 프론트엔드 개발자가 Swagger 를 보고 호출하면 REST 가 맞고, LLM 앱이 자동으로 발견해서 호출하면 MCP 가 맞아요.
그런데 "다른 팀의 백엔드 서비스" 가 호출하는 경우에는? 그 서비스가 Spring AI ChatClient 를 쓰고 있다면 MCP 가 자연스럽고, 순수 RestClient 를 쓰고 있다면 REST 가 자연스러워요.
2. 발견 가능성이 필요한가 -- REST API 는 Swagger/OpenAPI 문서를 사람이 읽어야 해요. MCP 도구는 LLM 이 tools/list 로 자동 발견해요. 도구 목록이 자주 바뀌고, 소비자가 매번 최신 목록을 자동으로 파악해야 한다면 MCP 의 가치가 커져요.
3. 호출 결정을 누가 하는가 -- 사람이 "이 API 를 호출해야지" 라고 미리 결정하는 케이스면 REST 가 충분해요. LLM 이 자연어 입력을 보고 "이 상황에서는 이 도구를 써야겠다" 를 런타임에 판단하는 케이스면 MCP 가 필요해요.
4. 양쪽 모두 노출해야 하는가 -- 같은 도메인 기능을 프론트엔드 (REST) 와 LLM 앱 (MCP) 양쪽에서 호출해야 하는 경우가 있어요.
이때 서비스 레이어를 공유하고, 컨트롤러 (REST) 와 MCP 도구 (MCP) 를 각각 얇은 어댑터로 만드는 게 깔끔해요. Day 18 Step 3 에서 McpCharacterStatusTool 이 SoulmateRepository 를 직접 참조하는 구조가 이 패턴이에요.
- 현업에서는 보통: REST 를 기본으로 두고, "LLM 앱이 이 기능을 호출해야 하는 실제 시나리오가 존재하는가?" 를 따져요. 시나리오가 명확하면 MCP 를 추가로 노출하고, 모호하면 REST 만 유지해요. MCP 도구가 많아질수록 LLM 의 도구 선택 정확도가 떨어지기 때문에, 핵심 도구만 정선해서 노출하는 게 중요해요.
🎯 면접관을 홀리는 핵심 멘트
"REST 는 '사람이 문서를 읽고 호출 경로를 결정하는' 통로이고, MCP 는 'LLM 이 도구 목록을 자동 발견하고 호출 여부를 런타임에 판단하는' 통로입니다. 둘은 대체 관계가 아니라 공존 관계이고, 판단 기준은 '호출 결정을 사람이 하느냐, LLM 이 하느냐' 예요. 다만 MCP 도구를 무분별하게 늘리면 LLM 의 도구 선택 정확도가 떨어지니까, 외부 노출이 실제로 필요한 도구만 정선해서 MCP 카탈로그에 올려야 합니다."
주제 3 예시답안: A2A 도입 시점
[문제 상황 요약]
A2A 프로토콜은 에이전트 간 작업 위임을 표준화해요. 하지만 프로토콜 도입 자체가 코드 복잡성, 인프라 비용, 운영 부담을 동반해요. 에이전트가 1~2개뿐인 초기 단계에서 A2A 를 도입하는 것이 과연 합리적인지, 어떤 조건에서 도입 가치가 비용을 넘어서는지를 판단해야 해요.
[튜터의 가이드 및 해설]
A2A 도입 시점을 판단하는 기준은 네 가지예요.
1. 에이전트 수 -- 에이전트가 2개 이하이고 같은 팀이 관리한다면, 직접 MCP 호출이나 메서드 호출로 충분해요. 표준 프로토콜의 가치는 "서로 모르는 에이전트끼리 협업해야 할 때" 시작돼요. 경험적으로는 에이전트 3개 이상 + 2개 이상 팀이 독립적으로 개발하는 시점이 분기점이에요.
2. 팀 경계 -- 같은 팀이 모든 에이전트를 만들면 내부 인터페이스로 충분해요. 다른 팀 (또는 다른 회사) 의 에이전트와 협업해야 할 때 A2A 의 표준화 가치가 드러나요. Agent Card 가 "이 에이전트가 무엇을 할 수 있는지" 를 팀 간 계약 문서 역할로 제공하거든요.
3. 비동기 작업 비율 -- 모든 작업이 동기적으로 즉시 완료되면 MCP 의 1회성 호출로 충분해요. 작업이 수 초~수 분 이상 걸리고, 중간 상태 추적 (submitted → working → done) 이 필요하면 A2A 의 Task 상태 머신이 가치를 갖기 시작해요.
4. 발견 필요성 -- 에이전트 목록이 고정되어 있으면 하드코딩으로도 돼요. 하지만 새 에이전트가 동적으로 추가되고, 기존 에이전트가 "어떤 새 에이전트에게 이 작업을 맡길 수 있을까" 를 런타임에 판단해야 한다면, Agent Card 의 /.well-known/agent.json 발견 메커니즘이 필수가 돼요.
- 현업에서는 보통: "지금은 MCP 로 충분하되, A2A 의 존재를 인식하고 있다" 가 대부분의 팀 상태예요. 마이크로서비스 간 REST 호출로 시작했다가 서비스 수가 늘어나면서 서비스 메시를 도입하는 것처럼, A2A 도 에이전트 수와 팀 경계가 확장되는 시점에 자연스럽게 도입하면 돼요. 에이전트 1~2개 단계에서 A2A 부터 도입하면 오버엔지니어링이에요.
🎯 면접관을 홀리는 핵심 멘트
"A2A 도입 시점은 '에이전트 수' 보다 '팀 경계 x 비동기 비율' 로 판단합니다. 같은 팀이 관리하는 동기 에이전트 2개라면 직접 호출이 낫고, 서로 다른 팀이 독립 배포하는 비동기 에이전트가 3개 이상이면 A2A 의 Agent Card 발견 + Task 상태 추적이 비용 대비 가치를 갖기 시작합니다. 마이크로서비스에서 REST 직접 호출로 시작해서 필요 시점에 서비스 메시를 도입하듯이, 에이전트 협업도 MCP 로 시작하고 복잡성이 늘어나는 시점에 A2A 를 도입하는 게 현실적입니다."