배너

API 연동 자동화를 위한 여정: 토스는 왜 사내 MCP 서버를 개발하였는가? with Spring-AI

#AI#MCP
조민규 · 토스 Server Developer
2025년 10월 27일

안녕하세요, 토스 Server Developer 조민규입니다.

"혹시 저번에 공유했던 API 스펙 다시 공유주실 수 있나요?"

이런 메시지를 받아본 적 있으신가요? 저는 하루에도 몇 번씩 받곤 했습니다. 신규 API를 개발할 때마다 Swagger에 접속해서 해당 API를 찾고, 링크를 복사해 사내 메신저에메신저 공유하는 과정은 생각보다 번거로웠죠.

하지만 진짜 문제는 따로 있었습니다. API를 연동하는 개발자들은 자신의 개발 주기에 맞춰 확인하기 때문에, 며칠 지난 스레드에서 링크를 찾아 다시 공유해야 했습니다. API 스펙이 변경되는 경우는 더 복잡했고요.

"API 호출 코드는 비즈니스 로직이 없는 단순한 데이터 교환일 뿐인데, LLM에게 API 스펙을 제공하면 이 과정을 자동화할 수 있지 않을까?" 이런 생각에서 출발한 것이 바로 Swagger MCP 서버입니다.

이 글에서는 토스에서 MCP 서버를 개발하며 얻은 경험과 인사이트를 공유하려고 합니다.

API 스펙 공유, 이제는 자연어로

Swagger MCP 서버를 도입한 후, API 스펙 공유 과정이 획기적으로 간소해졌습니다. 서버 개발자는 더 이상 Swagger 링크를 찾아 공유할 필요가 없어요. 개발 완료 사실만 자연어로 알려주면 됩니다.

“외국인 여권 등록 API 개발 완료했습니다.”

이제 API를 연동하는 개발자는 MCP를 통해 해당 API 스펙에 즉시 접근할 수 있습니다. LLM에게 API 스펙 분석을 요청하거나, 호출 로직 구현을 맡길 수도 있어요. API 스펙 공유를 위한 시간이 줄어들고, 모든 개발자의 생산성이 높아지며, 정말 중요한 일에 집중할 수 있는 업무 환경이 만들어진 것이죠.

이제 토스에서는 다음과 같은 방식으로 LLM을 통해 API 스펙을 서로 공유하고 논의할 수 있어요.

PUT /apis/1.0/foreigners/{foreignerId}/passport

Headers:
- user-id (integer, required)

Request body:
{
	"firstName": "string",
	"middleName": "string",
	"lastName": "string",
	"fullName": "string",
	"dateOfBirth": "date",
	"dateOfExpiry": "date",
	"gender": "FEMALE|MALE",
	"passportNumber": "string",
	"nationality": "string"
}

Response:
{
	"resultType": "SUCCESS|HTTP_TIMEOUT|NETWORK_ERROR|...",

MCP, LLM에게 컨텍스트를 제공하는 방법

MCP(Model Context Protocol)는 AI 모델과 외부 도구를 연결하기 위한 표준 프로토콜입니다. 쉽게 말해, LLM이 외부 시스템과 안정적이고 일관된 방식으로 대화할 수 있도록 컨텍스트를 제공하는 것이에요.

MCP 이전에는 LLM과 외부 도구를 연결할 때마다 맞춤형 플러그인이나 커넥터를 따로 만들어야 했습니다. IntelliJ에 메신저, GitHub 등을 연결하려면 각각 독립적인 방법을 구축해야 했고, 호환성도 부족했죠. MCP는 "어떤 도구든 MCP만 따르면 모델과 쉽게 연결 가능하다"는 목표로 등장했습니다.

MCP가 제공하는 3가지 핵심 기능

MCP 서버는 LLM에 컨텍스트를 제공하기 위한 3가지 기본 빌딩 블록을 구현합니다.

기능
제어 주체
설명
예시
Prompts
사용자 제어
사용자가 선택하여 호출하는 대화형 템플릿
슬래시 명령어, 메뉴 옵션
Resources
애플리케이션 제어
클라이언트가 첨부하고 관리하는 컨텍스트 데이터
파일 내용, Git 히스토리
Tools
모델 제어
LLM이 작업 수행을 위해 호출하는 함수
API 호출, 파일 쓰기

Resources(리소스)는 파일이나 데이터베이스 스키마 같은 구조화된 데이터를 통해 LLM에 추가 컨텍스트를 제공합니다. 애플리케이션이 리소스를 어떻게 활용할지 판단하죠.

Tools(도구)는 LLM이 실행할 수 있는 함수입니다. LLM은 사용 가능한 도구를 인지하고, 사용자 프롬프트와 대화 맥락을 기반으로 필요한 시점에 도구를 자동으로 호출합니다.

Prompts(프롬프트)는 재사용 가능한 템플릿으로, 사용자가 명시적으로 프롬프트를 선택하고 인자를 전달해 원하는 기능을 호출할 수 있습니다.

Spring-AI로 MCP 서버 개발하기

MCP는 2025년 3월 26일부터 Streamable HTTP를 공식 전송 메커니즘으로 변경했습니다. 스프링 진영은 Spring-AI 프로젝트를 통해 MCP 서버 개발을 지원하고 있어요. 그 중에서도 MCP 서버 개발을 위해 spring-ai-starter-mcp-server-webmvc 프로젝트를 제공하고 있습니다.

Spring-AI는 해당 프로토콜 지원을 1.1.0 버전부터 시작했는데, 해당 포스팅을 작성하는 시점에는 1.1.0 버전이 M3 단계로 Pre-release 상태입니다. 따라서 정식 릴리즈 상태가 아닌 상태에서 개발을 진행하게 되었어요.

implementation("org.springframework.ai:spring-ai-starter-mcp-server-webmvc:1.1.0-M3")

프로토콜과 실행 모드 선택

Spring-AI의 MCP 서버 프레임워크는 다양한 서버 프로토콜을 제공합니다:

  • STDIO: 프로세스 내부에서 표준 입출력으로 통신
  • SSE: 서버-발송 이벤트 프로토콜로 실시간 업데이트
  • Streamable-HTTP: HTTP POST/GET 요청으로 여러 클라이언트 연결 처리
  • Stateless Streamable-HTTP: 요청 간 세션 상태를 유지하지 않아 MSA 환경에 적합

토스 사내 원격 서버로 MCP 서버를 제공하기 위해 Stateless Streamable-HTTP 방식을 선택했습니다. SSE나 일반 Streamable-HTTP는 서버 배포나 연결 끊김 시 세션이 유실되어 사용에 불편함이 많았거든요.

spring.ai.mcp.server.protocol=STATELESS

실행 모드는 ASYNC로 설정했습니다. Swagger MCP는 네트워크 I/O가 많을 것으로 예상되어 비동기 모드가 적합했어요.

spring.ai.mcp.server.type=ASYNC

Tools 개발: 현재 시간 제공하기

앞서 설명하였듯, MCP 서버의 Tools을 개발한다는 것은 정보 검색(Information Retrieval) 또는 행동 수행(Taking Action)을 처리하겠다는 의미에요. Spring-AI는 @Tool 애너테이션을 사용하는 선언적 방식으로 Tools 개발을 지원합니다.

@Tool 애너테이션 기반의 선언적인 방식 외에도 MethodToolCallback를 구현하는 프로그래밍 방식도 제공하는데, 개인적으로는 애노테이션 기반의 선언적 방식을 선호하여 이를 활용했습니다. 현재 시간을 LLM에 제공하는 Tools를 구현하면 다음과 같습니다:

class DateTimeTools {
    @Tool(description = "Get the current date and time from the user's timezone")
    String getCurrentDateTimeWithZone(
        @ToolParam(description = "Zone Id ex) Asia/Seoul") ZoneId zoneId
    ) {
        return LocalDateTime.now()
            .atZone(zoneId)
            .toString()
    }
}

@Configuration
class DateTimeToolsConfig {
    @Bean
    fun dateTimeTools(dateTimeTools: DateTimeTools): ToolCallbackProvider {
        return MethodToolCallbackProvider
            .builder()
            .toolObjects(dateTimeTools)
            .build()
    }
}

@Tool 애너테이션의 주요 속성은 다음과 같습니다:

Tool의 호출을 위한 파라미터를 전달하기 위해 @ToolParam 애노테이션 역시 제공되는데, 주요 속성은 다음과 같습니다:

Spring-AI를 활용하면 Tools를 손쉽게 정의하고, LLM의 툴 호출 요청을 자동으로 처리할 수 있습니다. 최종적으로 Spring AI를 통해 개발된 Tools가 처리되는 방식을 살펴보면 다음과 같습니다.

토스 Swagger MCP 구현하기

Swagger MCP는 여러 단계에 걸쳐 LLM과 상호작용하며 API 정보를 제공합니다.

Step 1: 전체 서비스 목록 조회

중앙 Swagger 관리 서버인 SwaggerCenter를 통해 Swagger가 제공되는 서버 정보(serviceName, domain, Swagger 주소 등)를 조회합니다. 테스트용 서비스는 제외하고, 유효한 Swagger 주소를 가진 서비스만 포함시킵니다.

응답 예시:

[
  {
    "serviceName": "foreigner",
    "apiGroups": ["guest", "foreigner"]
  }
]

Step 2: 특정 서비스의 API 스펙 조회

해당 서버를 직접 호출하여 Swagger JSON 정보를 가져온 후, 핵심 데이터(URL, HTTP Method, operationId, summary 등)만 포함되도록 가공합니다. Swagger 원본 데이터는 복잡하고 크기 때문에 단순화가 필요했어요.

응답 예시:

{
  "/apis/1.0/foreigners/{foreignerId}/passport": {
    "put": {
      "tags": ["여권 API"],
      "operationId": "addPassport",
      "summary": "여권 등록 API"
    }
  }
}

Step 3: 특정 API 상세 정보 조회

serviceName, apiGroup, requestUrl, httpMethod로 특정 API를 찾아 parameters, requestBody, responses의 상세 정보를 추출합니다. $ref로 연결된 컴포넌트 스키마는 별도 제공을 위해 참조 경로만 포함시켰어요.

응답 예시:

{
  "parameters": [
    {
      "name": "user-id",
      "in": "header",
      "required": true,
      "schema": {
        "type": "integer",
        "format": null,
        "enum": null
      }
    }
  ],
  "requestBody": {
    "required": [
      "name",
      "dateOfBirth",
      "dateOfExpiry",
      "nationality",
      "gender",
      "passportNumber"
    ],
    "type": "object",
    "properties": {
      "name": {
        "type": "string"
      },
      "fullName": {
        "type": "string"
      },
      "dateOfBirth": {
        "type": "string",
        "format": "date"
      },
      "dateOfExpiry": {
        "type": "string",
        "format": "date"
      },
      "gender": {
        "type": "string",
        "enum": [
          "FEMALE",
          "MALE"
        ]
      },
      "passportNumber": {
        "type": "string"
      },
      "nationality": {
        "type": "string"
      }
    }
  },
  "responses": {
    "type": "object",
    "properties": {
      "resultType": {
        "type": "string",
        "enum": [
          "SUCCESS",
          "TIMEOUT",
          "NETWORK_ERROR",
          "INTERNAL_SERVER_ERROR",
        ]
      },
      "error": {
        "$ref": "#/components/schemas/ErrorMessage"
      },
      "success": {
        "type": "boolean"
      }
    }
  }
}

Step 4: 컴포넌트 상세 정보 조회

#/components/schemas/ComponentName 형태의 참조 경로 목록을 파싱해 해당 컴포넌트의 스키마 정의를 추출하여 반환합니다.

응답 예시:

{
  "#/components/schemas/ErrorMessage": {
    "type": "object",
    "properties": {
      "errorType": {
        "type": "string",
        "format": "int32"
      },
      "errorCode": {
        "type": "integer",
        "format": "int32"
      },
      "message": {
        "type": "object"
      }
    }
  }
}

겪었던 문제와 해결 방안

SSE 통신 방식의 문제점

처음 MCP 서버를 개발할 때는 SSE 기반 방식을 사용했는데, 빈번한 세션 끊김으로 사실상 이용이 불가능했습니다. Spring-AI가 1.1.0 버전부터 Streamable HTTP 방식을 지원하면서 본격적으로 활용할 수 있게 되었습니다.

토큰 초과 에러

대량의 API를 제공하는 서버의 모든 API 스펙을 LLM에 제공하니 토큰 초과 에러가 발생했습니다. 이를 해결하기 위해 세 가지 방안을 실행했습니다:

프롬프트 추가하기

LLM이 MCP 서버를 호출하지 않는 경우가 빈번해 프롬프트를 추가했습니다. ASYNC 서버에서는 다음과 같이 구현할 수 있습니다:

@Configuration
class SwaggerPrompt {
    @Bean
    fun searchApisPrompt(): List<McpStatelessServerFeatures.AsyncPromptSpecification> {
        val prompt = Prompt(
            "search-toss-servers-apis",
            "Search relevant apis from one of toss-servers",
            listOf(
                PromptArgument("serverName", "The name of toss server", true),
                PromptArgument("apiDesc", "Description of api to search", true),
            )
        )

        val promptSpecification = McpStatelessServerFeatures.AsyncPromptSpecification(prompt) { _, promptRequest ->
            val serverName = promptRequest.arguments()["serverName"] as String
            val apiDesc = promptRequest.arguments()["apiDesc"] as String
            val userMessage = PromptMessage(
                Role.USER,
                TextContent("target server name: ${serverName}, search command: $apiDesc"),
            )
            Mono.just(
                GetPromptResult(
                    "Search result for APIs from $serverName matching '${apiDesc}

MCP 클라이언트에서는 이 프롬프트를 통해 MCP 서버 호출을 강제할 수 있습니다.

Swagger 컴포넌트 이름 충돌

SpringDoc는 기본적으로 클래스 이름을 키로 사용하기 때문에, 서로 다른 패키지에 같은 이름의 클래스가 있으면 충돌이 발생합니다. 다음 설정으로 전체 패키지 이름을 포함하도록 해결했어요:

springdoc.use-fqn=true

ArgumentResolver 사용 시 Swagger 설정 고도화

ArgumentResolver를 통해 파라미터를 객체로 받는 경우, Swagger에 원본 값이 아닌 객체 정보가 노출됩니다. 다음과 같이 OperationCustomizer를 구현해 원본 값으로 노출되도록 설정했어요:

@Bean
fun loginUserOperationCustomizer(): OperationCustomizer {
    return OperationCustomizer { operation: Operation, handlerMethod: HandlerMethod ->
        val hasLoginUser = handlerMethod.methodParameters.any {
            it.hasParameterAnnotation(LoginUser::class.java)
        }

        if (hasLoginUser) {
            operation.parameters?.removeIf { param ->
                param.name == "loginUser" ||
                param.schema?.`$ref`?.contains("User") == true
            }

            val userIdParameter = Parameter()
                .name("user-id")
                .`in`("header")
                .description("User ID")
                .required(true)
                .schema(Schema<String>().type("integer"))

            if (operation.parameters == null) {
                operation.parameters = mutableListOf()
            }
            operation.parameters.add(userIdParameter)
        }

        operation
    }
}

컨텍스트 제공을 위한 Swagger 작성 자동화

LLM에게 충분한 API 정보를 제공하기 위해 정확한 Swagger 문서가 필요합니다. GitHub Actions를 통해 Swagger 작성을 자동화하는 워크플로를 개발 중이에요:

다만 LLM이 도메인 지식이나 작업 컨텍스트 부족으로 완벽하게 작성하지 못하는 경우가 있어, RAG 구성이나 추가 컨텍스트 제공이 필요할 것으로 보입니다.

마치며

MCP 서버 개발 과정에서 MCP Inspector라는 디버깅 도구가 큰 도움이 되었습니다. 이 도구 없이 개발하면 문제 지점을 찾고 해결하는 데 많은 시간을 쏟을 수 있어요. 현재 Swagger MCP Tools 외에도 Elasticsearch MCP Tools 등 다양한 기능을 준비 중입니다. AI 기반의 생산성 증대를 다방면에서 이룰 수 있을 것으로 기대합니다.

토스에서는 개발자 경험을 개선하고, AI를 활용한 생산성 향상에 관심이 많습니다. 이런 도전에 함께하고 싶다면 언제든 토스에 합류해 주세요!

(해당 내용은 시스템 개발을 위한 연구&개발 환경에서만 사용 가능하며, 망분리된 내부망과의 통신은 존재하지 않습니다.)

댓글 0댓글 관련 문의: toss-tech@toss.im
㈜비바리퍼블리카 Copyright © Viva Republica, Inc. All Rights Reserved.