문서 읽는 데 93분 · day18

Day 18. MCP Server 구현 + A2A 프로토콜 소개 — "이번엔 우리가 서버다"

전체 26강 중 20강 · 스프링 AI
난이도 · 심화선수지식자바 기초스프링 부트

ℹ️스프링 부트로 만든 ai-friends 프로젝트 위에 얹어 진행해요. 자바·스프링이 처음이라면 먼저 “서버 만들기” 트랙부터 권해요.

안녕하세요, 여러분의 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 앱 등 누가 호출할지 모르는 상황이에요. 그래서 세 가지 원칙으로 분리해요.

  1. 노출 범위 제한 — 내부 엔티티 (Soulmate) 를 그대로 돌려주지 않고, 외부 소비자에게 필요한 필드만 담은 전용 record 로 변환해요.
  2. 쓰기 범위 제한 — 이벤트 트리거 도구는 미리 정의된 이벤트 목록만 허용하고, 임의 데이터 수정은 차단해요.
  3. 패키지 분리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 가 간편하지만 한계가 명확해요.

  1. 같은 머신에서만 동작 — Claude Desktop / Cursor 가 JAR 를 직접 기동하니까 네트워크 너머에서는 접근 불가.
  2. 프로세스 기동 비용 — 매번 JAR 를 새로 띄워요. Spring Boot 앱이라 cold start 가 수 초 걸릴 수 있어요.
  3. 단일 클라이언트 — 한 번에 하나의 클라이언트만 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 도구를 호출하는 구조가 자연스러워요.

예를 들어:

  1. 총괄 에이전트가 "ai-friends 캐릭터 ARIA 의 호감도를 높여 줘" 라는 A2A Task 를 ai-friends 에이전트에 위임
  2. ai-friends 에이전트가 Task 를 받아서, MCP 도구 getCharacterStatus 로 현재 상태를 확인
  3. MCP 도구 triggerEvent 로 선물 이벤트를 발동
  4. 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 의 진짜 가치는 도구 카탈로그를 확장 가능하게 노출하는 결" 이 잡혀요.

✅ 요구사항

  1. 도구 이름: get_world_lore
  2. 파라미터: keyword (검색할 세계관 키워드)
  3. 동작: vectorStore 에서 keyword 로 유사도 검색 → 상위 3건의 세계관 텍스트를 반환
  4. 반환 record: McpWorldLoreResult(boolean found, List<String> loreTexts)
  5. Step 3 의 도구 패턴 (별도 패키지 + 전용 응답 record + 결과 크기 제한) 그대로 따르기
  6. 단위 테스트 — 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 + 만료 시각 검증 만으로도 운영 안전선이 크게 올라가는 그림을 본인 손으로 확인할 수 있어요.

✅ 요구사항

  1. 헤더: Authorization: Bearer <token> 형식 수용
  2. 토큰 형식: Base64 인코딩된 clientId:timestamp:signature
  3. 검증 1 — timestamp 가 현재 시각에서 5분 이내인지
  4. 검증 2 — signature 가 유효한지 (HMAC-SHA256 + 서버 비밀키)
  5. 만료/위조된 토큰이면 401 Unauthorized + 구조화된 에러 응답
  6. 단위 테스트 — 정상 토큰 / 만료 토큰 / 위조 signature / 잘못된 형식 4 케이스 모두 검증

💡 힌트

  • OncePerRequestFilter 를 그대로 확장 — Step 6 의 McpServerApiKeyFilter 가 출발점
  • 서버 비밀키는 application.ymlaifriends.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 감각이 미리 잡혀요.

✅ 요구사항

  1. 정책: 클라이언트당 분당 30회 (설정 외부화 가능)
  2. 클라이언트 식별 — API Key (Step 6) 또는 Bearer Token 의 clientId (과제 2)
  3. 초과 시 — 도구 실행을 거부하고 "Rate limit exceeded" 응답 + 429 Too Many Requests
  4. Sliding window 방식 (정확) 또는 fixed window 방식 (단순) 중 택1
  5. 단위 테스트 — 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 에서 McpSaveSlotToolMAX_SLOTS = 50 으로 서버 측에서 제한했듯이, topK 도 서버 측 상수로 고정하는 게 안전해요.
  • null 체크 누락 -- vectorStore.similaritySearch() 가 빈 리스트를 반환할 수 있어요. null 또는 empty 일 때 예외를 던지면 외부 LLM 앱의 대화 흐름이 끊겨요.

실무 개선 포인트 (심화)

  1. 메타데이터 필터 추가 -- SearchRequest.builder().filterExpression("character_id == 'ARIA'") 처럼 캐릭터별 필터를 걸면, 특정 캐릭터의 세계관만 검색할 수 있어요. Day 16 에서 배운 FilterExpressionBuilder 를 활용하는 확장이에요.
  2. 유사도 임계값 (similarity threshold) -- SearchRequestsimilarityThreshold(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 를 무한정 허용하게 돼요.

실무 개선 포인트 (심화)

  1. JWT 전환 -- 이 과제의 clientId:timestamp:signature 구조는 JWT 의 단순화 버전이에요. 프로덕션에서는 spring-security-oauth2-resource-serverJwtDecoder 를 활용해서 표준 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 하는 청소 로직이 필수예요.
  • 전역 카운터로 구현 -- 클라이언트 구분 없이 전체 호출 수를 세면, 한 클라이언트의 과도한 호출이 다른 정상 클라이언트까지 차단해요. 반드시 클라이언트별 독립 카운팅이어야 해요.

실무 개선 포인트 (심화)

  1. Redis 기반 분산 Rate Limit -- 인메모리 방식은 인스턴스별 독립 카운팅이라 서버 3대면 실질 한도가 90회가 돼요. Redis 의 ZRANGEBYSCORE (Sorted Set + timestamp score) 패턴을 쓰면 인스턴스 간 공유되는 글로벌 Rate Limit 을 구현할 수 있어요.
  2. 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 에서 McpCharacterStatusToolSoulmateRepository 를 직접 참조하는 구조가 이 패턴이에요.

  • 현업에서는 보통: 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 를 도입하는 게 현실적입니다."

더 배우려면

실무 프로젝트까지 가고 싶다면

팀스파르타 백엔드 부트캠프에서 인스타그램 클론을 풀스택으로 완성합니다.