최신 트렌드

MCP 서버 직접 만들기 - Spring Boot로 사내 시스템을 AI 에이전트에 연결하기

백엔드 개발자 김승원 2026. 4. 17. 23:00

들어가며

지난 Claude Skills 완벽 가이드에서 "Skill은 절차·지식, MCP는 외부 시스템 연결"이라는 구분을 정리했습니다. 그럼 질문이 하나 남죠. "그래서 MCP 서버는 어떻게 직접 만드는데?"

MCP 완벽 가이드클라이언트·개념 관점이었다면, 이 글은 서버 구현 관점입니다. 공개 MCP 서버를 가져다 쓰는 단계를 넘어서, 우리 회사 DB·내부 API·도메인 서비스를 Claude Code나 Cursor에 붙이는 방법을 Spring Boot로 구체화합니다.

Spring AI가 2026년 초 MCP Server Starter를 정식화하면서 난이도가 크게 낮아졌습니다. 예전처럼 JSON-RPC 핸들러를 수동으로 짜지 않아도 되고, @McpTool 어노테이션 하나로 메서드가 MCP 도구로 노출됩니다. 오늘은 "사내 고객 조회 MCP 서버"라는 가상 시나리오로 처음부터 끝까지 만들어봅니다.

1. MCP 서버가 뭔지, 1분 복습

MCP(Model Context Protocol)는 Anthropic이 2024년 11월 공개한 오픈 프로토콜입니다. "AI 애플리케이션과 외부 도구·데이터 소스의 표준 인터페이스"가 목적이고, AI 세계의 USB-C 비유가 가장 유명하죠.

서버가 제공하는 3가지 primitive

primitive 의미 예시
Tools 호출 가능한 함수 고객 ID로 조회, 주문 상태 변경
Resources 읽기 가능한 데이터 사내 문서, 대시보드 스냅샷
Prompts 재사용 가능한 프롬프트 템플릿 "버그 리포트 초안 작성하기"

클라이언트(Claude Code, Cursor, Claude Desktop 등)가 서버에 연결하면, 서버가 위 3가지를 목록으로 알려주고, 클라이언트는 필요할 때 호출합니다.

왜 사내 시스템에 MCP 서버를 붙이는가

  • 기존 내부 API를 그대로 노출: 이미 있는 REST API를 MCP 도구로 래핑만 하면 AI가 바로 사용 가능
  • AI별로 별도 통합 불필요: MCP 한 번 만들면 Claude, Cursor, OpenAI가 모두 호출
  • 권한/감사 중앙화: 모든 AI 접근이 MCP 레이어를 통과 → 로깅·인증이 한 곳에서
  • 도메인 지식 보존: "우리 회사 고객 ID 체계"를 AI가 이해하도록 유도 가능

개인 개발자 관점에서는 Notion MCP, GitHub MCP 같은 기성품을 쓰면 됩니다. 하지만 기업 시스템에는 기성품이 없고, 외부 SaaS에 내부 데이터를 태울 수도 없습니다. 이때 사내 MCP 서버가 해답입니다.

2. Spring AI MCP Server Starter 선택 이유

MCP 서버를 만드는 방법은 세 가지입니다.

방식 난이도 적합 상황
공식 TypeScript/Python SDK 낮음 경량 프로토타입, 서버리스
Java SDK 직접 사용 중간 기존 Java 시스템에 임베드
Spring AI MCP Server Starter 낮음 Spring Boot 기반 엔터프라이즈

세 가지 중 Spring Boot 기반 기업이라면 고민 없이 Starter입니다. 이유는 간단합니다.

  • 의존성 1줄: spring-ai-starter-mcp-server 추가하면 자동 설정 끝
  • 어노테이션 기반: @McpTool, @McpResource, @McpPrompt 만으로 메서드 노출
  • Spring Security 연동: 기존 인증 체계 그대로 재사용
  • Actuator/Observability: 헬스체크, 메트릭이 기본 제공

Spring AI로 LLM 애플리케이션 개발에서 다룬 Function Calling과 Tool 개념을 MCP로 확장하는 구조입니다.

3. 실전 - 사내 고객 조회 MCP 서버 만들기

가상의 요구사항입니다.

  • 사내 CRM에 있는 고객 정보를 AI 에이전트가 조회할 수 있어야 한다
  • 조회는 ID 기반, 이메일 기반 두 가지
  • AI가 "VIP 고객 목록" 같은 리소스도 읽을 수 있어야 한다
  • 응답은 개인정보 마스킹 적용 (이메일 @ 앞 3자리만, 전화번호 뒤 4자리만)

Step 1: 프로젝트 setup

// build.gradle.kts (Spring Boot 3.4+, Spring AI 1.1+)
dependencies {
    implementation("org.springframework.boot:spring-boot-starter-web")
    implementation("org.springframework.ai:spring-ai-starter-mcp-server-webmvc")
    implementation("org.springframework.boot:spring-boot-starter-data-jpa")
    implementation("org.springframework.boot:spring-boot-starter-security")
    runtimeOnly("com.h2database:h2")
}

Step 2: application.yml

spring:
  application:
    name: customer-mcp-server
  ai:
    mcp:
      server:
        name: customer-mcp
        version: 1.0.0
        type: async   # 또는 sync
        transport: streamable-http  # stdio | sse | streamable-http
        sse:
          message-endpoint: /mcp/message

server:
  port: 8090

트랜스포트 3가지 선택 기준은 뒤에서 자세히 다룹니다. 지금은 원격 배포용으로 streamable-http 선택.

Step 3: @McpTool 작성 - 고객 조회

@Service
public class CustomerMcpTools {

    private final CustomerRepository repository;
    private final PiiMasker masker;

    public CustomerMcpTools(CustomerRepository repository, PiiMasker masker) {
        this.repository = repository;
        this.masker = masker;
    }

    @McpTool(
        name = "get_customer_by_id",
        description = "사내 고객 ID로 고객 정보 조회. 이메일·전화번호는 마스킹 적용된 값만 반환."
    )
    public CustomerResponse getCustomerById(
            @McpToolParam(description = "사내 고객 ID (예: CUST-000123)", required = true)
            String customerId) {
        
        Customer c = repository.findByCustomerId(customerId)
                .orElseThrow(() -> new CustomerNotFoundException(customerId));
        
        return new CustomerResponse(
            c.getCustomerId(),
            c.getName(),
            masker.maskEmail(c.getEmail()),
            masker.maskPhone(c.getPhone()),
            c.getTier(),
            c.getCreatedAt()
        );
    }

    @McpTool(
        name = "search_customers_by_email",
        description = "이메일 주소 일부로 고객 검색. 최대 20건까지 반환."
    )
    public List<CustomerSummary> searchByEmail(
            @McpToolParam(description = "이메일 검색어 (부분 일치)", required = true)
            String emailQuery,
            @McpToolParam(description = "최대 반환 건수 (1~20)", required = false)
            Integer limit) {
        
        int actualLimit = limit == null ? 10 : Math.min(limit, 20);
        return repository.searchByEmailLike(emailQuery, PageRequest.of(0, actualLimit))
                .stream()
                .map(c -> new CustomerSummary(
                    c.getCustomerId(),
                    c.getName(),
                    masker.maskEmail(c.getEmail())))
                .toList();
    }
}

핵심은 @McpTooldescription입니다. AI는 이 description만 보고 "이 도구를 언제 써야 하는지"를 판단합니다. 애매한 설명은 잘못된 호출로 이어지니, 동료 개발자에게 API 스펙 설명하듯 구체적으로 쓰세요.

Step 4: Bean 등록 - ToolCallbackProvider

@Configuration
public class McpServerConfig {

    @Bean
    public ToolCallbackProvider customerTools(CustomerMcpTools tools) {
        return MethodToolCallbackProvider.builder()
                .toolObjects(tools)
                .build();
    }
}

Spring AI가 이 Bean을 감지해서 MCP 서버에 자동 등록합니다. @Service에 붙은 메서드 중 @McpTool이 있는 것만 노출됩니다.

4. @McpResource - 데이터 노출하기

고객 개별 조회는 Tool이지만, "VIP 고객 목록" 같은 자주 읽히는 정적/준정적 데이터는 Resource가 적합합니다.

@Service
public class CustomerMcpResources {

    private final CustomerRepository repository;

    @McpResource(
        uri = "customer://vip-list",
        name = "vip_customer_list",
        description = "현재 VIP 등급 고객 전체 목록 (마스킹 적용)",
        mimeType = "application/json"
    )
    public String listVipCustomers() {
        return repository.findByTier("VIP").stream()
                .map(c -> Map.of(
                    "id", c.getCustomerId(),
                    "name", c.getName(),
                    "since", c.getCreatedAt().toString()
                ))
                .collect(Collectors.collectingAndThen(
                    Collectors.toList(),
                    ObjectMapperHelper::toJson));
    }

    @McpResource(
        uri = "customer://tier/{tier}",
        name = "customers_by_tier",
        description = "등급별 고객 목록. tier = BRONZE | SILVER | GOLD | VIP"
    )
    public String listByTier(@McpResourceParam String tier) {
        validateTier(tier);
        return toJson(repository.findByTier(tier));
    }
}

Tool vs Resource 구분 기준

기준 Tool Resource
의미 동작 실행 데이터 읽기
부작용 있을 수 있음 없어야 함
매개변수 구조화된 인자 URI path/query
예시 주문 취소, 이메일 발송 문서 읽기, 목록 조회

규칙 하나만 기억하면 됩니다. "이 호출이 실패해도 다시 호출해도 괜찮은가"가 Yes면 Resource, No면 Tool입니다.

5. @McpPrompt - 재사용 가능한 프롬프트

AI에게 "이 고객 정보를 바탕으로 영업 리포트 초안 작성" 같은 반복 작업이 있다면, Prompt로 템플릿화합니다.

@Service
public class CustomerMcpPrompts {

    @McpPrompt(
        name = "customer_sales_report",
        description = "특정 고객에 대한 영업 리포트 초안을 작성하기 위한 프롬프트"
    )
    public GetPromptResult customerSalesReport(
            @McpPromptParam(description = "대상 고객 ID") String customerId,
            @McpPromptParam(description = "리포트 톤: formal | casual") String tone) {
        
        String template = String.format("""
            당신은 B2B 영업 어시스턴트입니다.
            
            아래 절차로 작업하세요:
            1. get_customer_by_id 도구로 고객 %s 정보 조회
            2. 고객 등급(tier)과 가입 기간을 요약
            3. %s 톤으로 영업 리포트 초안 작성
            4. 리포트는 한국어, 500자 이내, 마크다운
            
            주의: 개인정보는 마스킹된 상태로만 표기하세요.
            """, customerId, tone);
        
        return GetPromptResult.builder()
                .description("고객 영업 리포트 프롬프트")
                .addMessage(PromptMessage.user(template))
                .build();
    }
}

Prompt는 "사용자가 /report CUST-000123 formal 같이 호출하면 위 템플릿이 바로 대화에 투입되는" 형태로 작동합니다. Claude Skills와 비슷해 보이지만, Skills는 클라이언트 내부의 절차, Prompt는 서버가 제공하는 외부 템플릿이라는 차이가 있습니다.

6. 트랜스포트 3종 - 어느 걸 골라야 하나

MCP 서버는 세 가지 트랜스포트를 지원합니다. 선택 기준을 정리합니다.

트랜스포트 특징 적합 시나리오
stdio 프로세스 파이프. 로컬 전용 개발자 노트북, 개인 도구
SSE HTTP 서버센트 이벤트 (레거시) Streamable-HTTP 미지원 클라이언트
Streamable-HTTP 양방향 HTTP 스트리밍 (2025 표준) 원격 배포, 권장

stdio - 가장 단순

spring:
  ai:
    mcp:
      server:
        transport: stdio

프로세스 표준 입출력으로 통신합니다. 보안/네트워크 걱정 없음. 대신 로컬 같은 기기에서만 동작합니다. Claude Desktop처럼 로컬 바이너리를 실행하는 구조에 적합.

Streamable-HTTP - 현대 표준

spring:
  ai:
    mcp:
      server:
        transport: streamable-http
        streamable-http:
          endpoint: /mcp

원격 클라이언트도 연결 가능. HTTP 기반이라 기존 인프라(로드 밸런서, WAF, Spring Security)를 그대로 활용합니다. 사내 MCP 서버는 99% 이 옵션이라고 봐도 됩니다.

SSE - 레거시지만 아직 유효

Streamable-HTTP가 2025년에 표준이 되기 전까지 쓰이던 방식. 여전히 몇몇 클라이언트는 SSE만 지원하니 호환성을 위해 설정 옵션으로 열어두기도 합니다.

7. 클라이언트에서 붙이기 - Claude Code / Cursor

Claude Code 설정

// ~/.claude/config.json (또는 프로젝트별 .claude/config.json)
{
  "mcpServers": {
    "customer-mcp": {
      "type": "http",
      "url": "http://localhost:8090/mcp",
      "headers": {
        "Authorization": "Bearer ${CUSTOMER_MCP_TOKEN}"
      }
    }
  }
}

Cursor 설정

// .cursor/mcp.json
{
  "mcpServers": {
    "customer": {
      "command": null,
      "url": "http://localhost:8090/mcp",
      "env": {
        "AUTH_TOKEN": "${CUSTOMER_MCP_TOKEN}"
      }
    }
  }
}

실제 호출 예시

User: CUST-000123 고객 정보 알려줘

Claude (내부 동작):
  [customer-mcp 서버의 get_customer_by_id tool 호출]
  [인자: customerId = "CUST-000123"]
  [응답 수신]
  
  고객 CUST-000123 정보입니다:
  - 이름: 홍길동
  - 이메일: hon***@example.com
  - 전화: ***-****-1234
  - 등급: GOLD
  - 가입일: 2023-05-14

사용자는 일반 대화처럼 말하고, Claude는 description을 보고 알아서 적절한 tool을 호출합니다.

8. 보안 - 절대 건너뛸 수 없는 부분

사내 MCP 서버는 AI 에이전트에게 내부 데이터 접근 권한을 부여하는 관문입니다. 보안을 가볍게 보면 바로 사고가 납니다.

5가지 필수 체크

1) 인증

OAuth 2.0 / OIDC 기반 토큰 인증을 기본으로 합니다. Spring Security와 연동.

@Configuration
@EnableWebSecurity
public class McpSecurityConfig {

    @Bean
    public SecurityFilterChain mcpSecurity(HttpSecurity http) throws Exception {
        return http
            .securityMatcher("/mcp/**")
            .authorizeHttpRequests(auth -> auth
                .anyRequest().hasRole("MCP_CLIENT"))
            .oauth2ResourceServer(oauth -> oauth.jwt(Customizer.withDefaults()))
            .csrf(csrf -> csrf.disable())
            .build();
    }
}

2) 권한 세분화

도구별로 필요한 권한이 다릅니다. 조회는 모두 허용, 쓰기는 특정 역할만.

@McpTool(name = "update_customer_tier", ...)
@PreAuthorize("hasRole('ADMIN')")
public void updateTier(String customerId, String newTier) { ... }

3) Rate Limiting

AI 에이전트가 루프에 빠지면 초당 수백 번 호출할 수 있습니다. Bucket4j 또는 Spring Cloud Gateway 레벨에서 방어.

4) 감사 로그

어떤 에이전트가 어떤 tool을 언제 호출했는지 전부 기록. 사고 조사에 필수.

@Aspect
@Component
public class McpAuditAspect {
    @Around("@annotation(org.springframework.ai.mcp.server.McpTool)")
    public Object audit(ProceedingJoinPoint pjp) throws Throwable {
        String caller = SecurityContextHolder.getContext()
                .getAuthentication().getName();
        log.info("MCP tool called - caller={}, method={}, args={}",
                caller, pjp.getSignature(), Arrays.toString(pjp.getArgs()));
        return pjp.proceed();
    }
}

5) PII 마스킹 의무화

AI 에이전트는 데이터를 외부 LLM 제공자에게 보냅니다. 원본 PII를 절대 그대로 반환하지 않고, 서비스 레이어에서 마스킹 후 응답해야 합니다. 이건 협의 사항이 아니라 기본 방침입니다.

보안 관점에서 MCP 서버를 보면 "일반 REST API보다 훨씬 민감"하다는 점을 잊으면 안 됩니다. 사람이 호출하는 게 아니라 자율적으로 판단하는 AI가 호출하니까요.

9. 관측 가능성 - Actuator + OpenTelemetry

MCP 서버도 일반 마이크로서비스와 같은 수준의 관측 가능성이 필요합니다. OpenTelemetry 실전 가이드를 그대로 적용할 수 있습니다.

핵심 메트릭

메트릭 용도
mcp.tool.calls.count 도구별 호출 빈도 - 어떤 도구가 실제로 쓰이는가
mcp.tool.latency 도구별 지연 - AI 체감 응답 시간에 직접 영향
mcp.tool.errors 실패율 - description이 모호하면 잘못된 인자로 호출되어 증가
mcp.resource.reads 리소스 읽기 빈도 - 자주 읽히면 캐싱 대상
mcp.session.active 활성 세션 - 동시 에이전트 수

description 튜닝의 중요성

도구가 실패율이 높다면 90% 확률로 description이 모호하기 때문입니다. 이 경우 해결책은 코드 수정이 아니라 description 문구 개선입니다. AI가 읽는 매뉴얼이라고 생각하고, 예시와 경계 조건까지 적어주세요.

10. 배포 전략

사내 MCP 서버를 본격 운영한다면 고려할 사항들.

배포 단위

  • 도메인별 분리: customer-mcp, billing-mcp, inventory-mcp 처럼 경계 명확히
  • 공통 게이트웨이: 클라이언트는 하나의 진입점으로 접근, 내부에서 라우팅
  • 버전 관리: v1, v2 경로 분리 + deprecation 정책

멀티테넌시

여러 팀/조직이 하나의 MCP 서버를 공유한다면, 도구 이름 네임스페이싱 필수 (crm_get_customer, billing_get_invoice).

로컬 개발

개발자 노트북에서는 stdio 트랜스포트로 돌리고, 스테이징/프로덕션은 streamable-http로 전환하는 이원화 전략이 일반적입니다.

체크리스트

  • [ ] 인증(JWT/OAuth) 적용
  • [ ] 도구별 권한 분리
  • [ ] Rate Limit 설정
  • [ ] 감사 로그 활성화
  • [ ] PII 마스킹 검토
  • [ ] 헬스체크 엔드포인트
  • [ ] OpenTelemetry 메트릭 연결
  • [ ] 버전 관리 경로 설계
  • [ ] Claude Code / Cursor 설정 가이드 문서화
  • [ ] 운영팀 온콜 대응 방안

마치며

MCP 서버 구축의 핵심 포인트를 정리합니다.

  • Spring AI MCP Server Starter로 진입 장벽이 사라졌다. @McpTool, @McpResource, @McpPrompt 어노테이션 세트로 기존 Spring Boot 서비스를 1~2시간 안에 MCP 서버로 확장할 수 있습니다. JSON-RPC 핸들러를 직접 쓰던 시대는 끝났습니다.
  • Tool·Resource·Prompt의 역할 구분이 설계의 핵심. "실패해도 다시 해도 되는가"가 Tool(No)과 Resource(Yes)를 가르는 질문이고, 반복되는 지시문은 Prompt로 추출합니다. 이 구분이 흐트러지면 AI가 예측 불가능하게 동작합니다.
  • Streamable-HTTP가 사실상 표준. 사내 배포라면 99% 이걸로 시작하면 됩니다. stdio는 로컬 개발용, SSE는 호환성용.
  • description이 가장 중요한 코드. 어떤 구현보다 도구 설명 문구가 AI 동작을 좌우합니다. 실패율이 높으면 코드가 아니라 문구를 고치세요.
  • 보안은 일반 API보다 한 단계 더. 인증·권한·Rate Limit·감사로그·PII 마스킹 다섯 가지를 처음부터 설계에 포함해야 합니다. AI는 초당 수백 번 호출할 수 있고, 판단 실수로 민감 데이터를 외부 LLM에 흘릴 위험이 상존합니다.

2024년 말에 공개된 MCP가 1년 반 만에 AI와 기업 시스템을 잇는 표준 레일로 자리 잡았습니다. Claude Skills가 "AI의 지식과 절차"를 다룬다면, MCP는 "AI가 만지는 바깥 세계"를 다룹니다. 두 축을 모두 쥐어야 AI 에이전트를 제품에 안전하게 붙일 수 있습니다. 다음 포스트에서는 이렇게 만든 MCP 서버를 Claude Code Agent Teams와 결합해 병렬 파이프라인을 구축하는 방법을 다뤄 볼 예정입니다.