Swagger MCP Server 만들기 2편 - Swagger API 파싱

2026. 2. 13. 03:08·AI

Introduce

이 시리즈는 Backend API의 Swagger 문서를 MCP 서버로 제공하여, LLM에서 자연어로 API를 검색하고, 완전한 네트워킹 코드를 자동 생성하는 시스템을 만드는 과정입니다.

이전 글을 참고하실 분들은 하단 글을 확인해주시길 바랍니다.
 

Swagger MCP Server 만들기 1편 - LLM 기반 API 탐색 자동화 설계

Introduce GitHub - WooJJam/swagger-mcp-server: Spring AI를 사용하여 Swagger API를 LLM이 자연어로 조회할 수 있도록 제공Spring AI를 사용하여 Swagger API를 LLM이 자연어로 조회할 수 있도록 제공하는 MCP Server - WooJJam

woojjam.tistory.com

 

모든 코드는 해당 레포지토리에서 확인할 수 있습니다.
 

GitHub - WooJJam/swagger-mcp-server: Spring AI를 사용하여 Swagger API를 LLM이 자연어로 조회할 수 있도록 제공

Spring AI를 사용하여 Swagger API를 LLM이 자연어로 조회할 수 있도록 제공하는 MCP Server - WooJJam/swagger-mcp-server

github.com

 

이 프로젝트를 진행하면서 가장 먼저 고민한 것은 파싱 시점이었다.

 

2가지 방식을 생각할 수 있는데

1. MCP 서버를 호출할 때마다 Swagger API에서 JSON을 가져와서 실시간 파싱

2. 미리 Swagger JSON을 파싱해서 구조화된 형태로 DB에 저장

 

결론부터 말하자면 두 번째 방식을 선택했다.

 

그 이유는 첫째로 속도다. LLM이 "로그인 API 찾아줘"라고 물어볼때마다 MCP 서버에서 HTTP 요청을 보내고, 수십개의 API가 담긴 JSON을 파싱한다면 응답 시간이 느려질 수 있겠다고 생각했다.

 

둘째, $ref 구조의 한계이다. OpenAPI 3.0 스펙에서는 스키마를 $ref로 참조하는 방식을 사용한다.

예를들어 로그인 api의 requestBody가 다음과 같다면

{
  "requestBody": {
    "content": {
      "application/json": {
        "schema": {
          "$ref": "#/components/schemas/LoginRequest"
        }
      }
    }
  }
}

실제 LoginRequest의 구조를 알기 위해서는 `components/schemas/LoginRequest` 까지 내려가며 찾아야한다. 이 과정을 호출할 때 마다 매번 반복하는것은 너무 비효율적이다. 파싱 시점에 한번만 resolve해서 실제 스키마 구조를 DB에 저장해두면 조회시에는 그냥 바로 꺼내쓰기만 하면 된다.

 

마지막으로, LLM 친화적 포맷으로의 변환이 필요하다. DB에 저장할 때마다 LLM이 이해하기 쉬운 형태로 미리 가공해두면, MCP가 조회할때도 별도 변환 작업 없이 단순 조회만 진행하면 되기에 비용이 최소화된다.


OpenAPI 3.0 Structure Overview

Swagger API를 파싱하기 전에 먼저 JSON 구조를 파악해야한다. 스프링에서 OpenAPI의 스펙은 `/v3/api-docs` 경로에 노출되어 있다. 이 구조를 처음 보면 복잡해보이지만 핵심은 세 가지이다.

{
  "openapi": "3.0.1",
  "paths": {
    "/api/auth/login": {
      "post": {
        "operationId": "login",
        "summary": "로그인",
        "tags": ["Auth"],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/LoginRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/TokenResponse"
                }
              }
            }
          },
          "401": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                },
                "example": {
                  "errorCode": "AUTH-CREDENTIAL-INVALID",
                  "message": "아이디 또는 비밀번호가 올바르지 않습니다"
                }
              }
            }
          }
        }
      }
    }
  },
  "components": {
    "schemas": {
      "LoginRequest": {
        "type": "object",
        "required": ["email", "password"],
        "properties": {
          "email": { "type": "string", "format": "email" },
          "password": { "type": "string", "format": "password" }
        }
      }
    }
  }
}

`paths` : 각 API 엔드포인트 자체를 의미

`response`: 상태코드별로 성공 응답과 예외 응답

`$ref` : components/schemas/XXX 를 가리키는 포인터

`components/scemas` : 실제 DTO 스키마

 

그러므로 파싱 작업의 핵심은 $ref 를 따라가며 실제 스키마를 가져오는 것이다.


What to Parse

코드를 작성하기에 앞서 가장 먼저 한 고민은 "Swagger 문서에서 무엇을 꺼내야 할까?" 이다.

 

Swagger가 제공하는 spec 문서에는 정말 많은 데이터가 있다.

API 경로, HTTP 메서드, 파라미터 정보, 요청 및 응답 스키마, 에러, 보안 등.. 이걸 전부 다 파싱하면 DB가 복잡해지고 불필요한 데이터로 인해 노이즈가 생길 수 있다. 그렇다고 또 너무 적게 파싱하면 LLM이 코드를 효율적으로 생성하지 못할 수 있다.

 

그래서 먼저 API를 연동하기 위해서 어떤 정보가 필요할 것인가를 고민해보았다.

개발자가 API를 연동할 때 실제로 하는 작업을 쪼개보면

 

1. 어떤 API인지? -> API 기본 정보 필요

2. 요청을 어떻게 보내야하는지? -> Request Body, Query Param 필요

3. 응답이 어떻게 오는지? -> Response Body 구조 필요

4. 에러가 발생하면 어떤 에러코드가 있는지? -> Error Response 및 에러코드 필요

 

이렇게 4가지로 정리할 수 있다.

 

따라서 정의한 4가지 정보를 가져오기 위해서 파싱할 목록들을 직접 리스트업 해보았다.

정보 파싱 여부 이유
path, method, summary O 기본적인 검색과 API 식별을 위해서 필수
Request Body O Request DTO 생성에 필수
Query, Path 파라미터 O Request Body와 구조가 다르므로 별도 처리 필요
Success Response 스키마 O Response DTO 생성에 필수
Error Response + 에러 코드 O 에러 핸들링 코드 생성에 필수
태그 △ API 그룹핑, 연관 API 탐색에 유용하나 의미가 있는지는 의문
보안 스키마 (SecuritySchemes) X Claude가 직접 처리하기보다 개발자가 설정하는 영역
서버 정보 (servers) X baseURL은 환경마다 달라 하드코딩 부적절
미디어 타입 협상 X application/json이 대부분
Deprecated 정보 X 있으면 좋지만 없어도 코드 생성에 영향 없음

이처럼 코드 생성에 직접 필요한 정보만 파싱하기로 하였다. 필요하지 않을 것으로 예상되는 정보들은 흥미롭더라도 과감하게 파싱하지 않기로 하였다. 파싱하는 데이터는 저장을 해야하고, 저장된 데이터는 조회되어야 하며 조회된 데이터는 LLM에게 전달해야한다. 고로 불필요한 정보는 오히려 노이즈가 된다.


How to Parse

처음에는 하나의 거대한 파서를 만들려고 했다. 하지만 Swagger를 실제로 파싱을 해보니 엄연히 간단한 작업이 아니었다.

 

  • 기본 정보 파싱: path, method, operationId, summary의 단순 텍스트 추출
  • Request 파싱: Request Body와 Parameter는 완전히 다른 구조이므로 분리 필요
  • Response 파싱: 200번대 상태코드만 추출하고, $ref 구조 분해 필요
  • Error 파싱: 400번대 상태코드들을 추출하고, 자체 커스텀 에러 코드 파싱 필요

결론적으로 4가지 정보들을 파싱하기 위해서 4개의 parser로 분리하였다.


📌 기본 정보 파싱
public ParsedEndpointBasicInfo parseBasicInfo(final String path, final String method, final JsonNode operation) {
    final String operationId = operation.path("operationId").asText("");
    final String summary     = operation.path("summary").asText("");
    final String description = operation.path("description").asText("");

    return new ParsedEndpointBasicInfo(
            path, method, operationId, summary, description
    );
}

먼저 가장 단순한 파싱로직이다. `path`와 `method`는 SwaggerParserService가 paths 노드를 순회하면서 직접 넘겨준다.

그리고 operationId, summary, description operation 노드에서 텍스트를 그대로 꺼내서 반환한다. 

 

단순히 텍스트를 그대로 꺼내서 반환하는 것 밖에 없기에 딱히 어려운 부분은 없다.


📌 JsonSchemaParsingSupport 공통 유틸

5개 parser 모두가 공통으로 사용하는 유틸 클래스이다. 핵심 메서드 두 개만 살펴보자.

 

1. $ref Resolution

public JsonNode resolveSchemaRef(final JsonNode swaggerJson, final String schemaRef) {
    if (schemaRef == null || schemaRef.isEmpty() || !schemaRef.startsWith("#/")) {
        return null;
    }

    // "#/components/schemas/LoginRequest" → "components/schemas/LoginRequest"
    final String path = schemaRef.substring(2);
    final String[] parts = path.split("/");

    JsonNode current = swaggerJson;
    for (final String part : parts) {
        if (current == null || current.isMissingNode()) {
            log.warn("Schema ref를 resolve할 수 없습니다: {}", schemaRef);
            return null;
        }
        current = current.path(part);
    }

    return current.isMissingNode() ? null : current;
}

앞서 언급하였듯이 실제 스키마 정보는 `#/components/scemas/**` 하위에 존재한다고 했었다.

그렇기에 `/` 를 기준으로 분리하여 JSON 트리를 단계적으로 탐색한다. 코드 자체는 단순하지만 유틸 메소드로 분리하지 않으면 모든 파서에서 직접 구현해야 하므로 유틸로 분리해주었다.

 

2. enrichScemaWithRequired

해당 메서드는 각 스키마의 필드들에 required 여부를 매핑해주는 메서드이다.

이 부분이 처음 설계할 때 가장 고민이 많았던 부분이다. 이는 OpenAPI 스펙의 특징때문이었다.

 

OpenAPI 스펙에서 required 정보는 schema 루트 레벨의 배열로 존재한다.

{
  "type": "object",
  "required": ["email", "password"],
  "properties": {
    "email": { "type": "string" },
    "password": { "type": "string" }
  }
}

이 구조를 그대로 LLM에게 전달하면, LLM은 properties를 순회하다가 required 배열을 별도로 다시 확인해야 했다. 이는 포맷팅 과정에서 실수할 가능성이 존재했고, 해당 필드가 필수적인지 판단이 직관적이지가 않았다.

 

그래서 내가 생각한 방법은 각 필드에 required 여부를 직접 매핑해주는 것이다.

public JsonNode enrichSchemaWithRequired(final JsonNode schema) {
    if (schema == null || schema.isMissingNode()) {
        return schema;
    }

    final var enrichedSchema = schema.deepCopy(); // 원본 보존
    final JsonNode properties = enrichedSchema.path("properties");
    if (properties.isMissingNode() || !properties.isObject()) {
        return enrichedSchema;
    }

    // required 배열에서 Set으로 변환
    final JsonNode requiredArray = enrichedSchema.path("required");
    final var requiredFields = new java.util.HashSet<String>();
    if (requiredArray.isArray()) {
        requiredArray.forEach(field -> requiredFields.add(field.asText()));
    }

    // 각 property에 required 정보 추가
    final var propertiesObject = (ObjectNode) properties;
    propertiesObject.fields().forEachRemaining(entry -> {
        final String fieldName = entry.getKey();
        final var fieldSchema = (ObjectNode) entry.getValue();
        fieldSchema.put("required", requiredFields.contains(fieldName));
    });

    return enrichedSchema;
}

흐름을 순서대로 살펴보면

  1. `deepCopy()` 로 원본을 복사해 원본 데이터는 유지
  2. `required` 배열 요소들을 `HashSet`으로 변환한다.
    (contains()을 통해 조회를 O(1)로 보장하기 위함)
  3. `properties`의 각 필드들을 순회하며 `HashSet`에 있으면 true, 없으면 false 주입
  4. 변경된 scema 반환

이렇게 된다.

// Before: required 정보가 루트에만 존재
{
  "type": "object",
  "required": ["email", "password"],
  "properties": {
    "email": { "type": "string" },
    "password": { "type": "string" },
    "rememberMe": { "type": "boolean" }
  }
}

// After: 각 필드에 required 정보 포함
{
  "type": "object",
  "required": ["email", "password"],
  "properties": {
    "email": { "type": "string", "required": true },
    "password": { "type": "string", "required": true },
    "rememberMe": { "type": "boolean", "required": false }
  }
}

반환 결과를 확인하면 다음과 같이 required가 주입된 것을 확인할 수 있다.


📌 Request Body와 Parameters 분리

첫번째 required 난관을 지나서 나머지는 쉬울줄 알았더만.. 두번째 난관이 바로 생겼다.

처음에는 요청 자체를 `RequestSchema`로 통합하여 관리하려고 하였다. 하지만 문제가 곧 바로 드러났다.

 

`GET /api/v1/travels?page=0&size=10` 같은 요청은 requestBody가 없다.
`POST /api/auth/login` 은 parameters(path, query)가 없다.

그렇기에 파싱하는 로직을 두 가지 조건에 맞게 분리해주어야 한다.

 

1. parseRequestBody

public ParsedRequestBody parseRequestBody(final JsonNode swaggerJson, final JsonNode operation) {
    final JsonNode requestBody = operation.path("requestBody");
    if (requestBody.isMissingNode()) {
        return null; // GET/DELETE 등은 여기서 바로 종료
    }

    final JsonNode jsonContent = parsingSupport.selectContentNode(requestBody.path("content"));
    if (jsonContent.isMissingNode()) {
        return null;
    }

    final JsonNode schema = jsonContent.path("schema");
    final String schemaRef = schema.path("$ref").asText("");

    // $ref를 실제 schema로 resolve
    final JsonNode resolvedSchema = schemaRef.isEmpty()
            ? schema
            : parsingSupport.resolveSchemaRef(swaggerJson, schemaRef);

    // required 정보를 각 필드에 주입
    final JsonNode enrichedSchema = parsingSupport.enrichSchemaWithRequired(
            resolvedSchema != null ? resolvedSchema : schema
    );

    return new ParsedRequestBody(
            parsingSupport.extractDtoNameFromRef(schemaRef),
            parsingSupport.convertToJsonString(enrichedSchema),
            parsingSupport.convertToJsonString(parsingSupport.extractExample(jsonContent, resolvedSchema).value()),
            requestBody.path("required").asBoolean(false),
            "application/json"
    );
}

requestBody가 없으면 즉시 null을 반환하고, 있으면 앞서 언급한 2가지의 유틸 메소드를 이용하여 $ref 구조를 분해한 뒤 `enrichScemaWithRequired`로 예시 데이터를 추출한다.

 

2. Parameters 파싱

public List<ParsedParameter> parseParameters(final JsonNode operation) {
    final List<ParsedParameter> parameters = new ArrayList<>();
    final JsonNode parametersNode = operation.path("parameters");

    if (parametersNode.isMissingNode() || !parametersNode.isArray()) {
        return parameters; // 없으면 빈 리스트 반환 (null 아님)
    }

    parametersNode.forEach(paramNode -> {
        final String name = paramNode.path("name").asText("");
        final String in = paramNode.path("in").asText(""); // path / query / header / cookie
        final JsonNode schema = paramNode.path("schema");

        if (!name.isEmpty() && !in.isEmpty()) {
            parameters.add(new ParsedParameter(
                    name,
                    in,
                    paramNode.path("required").asBoolean(false),
                    schema.path("type").asText(""),
                    schema.path("format").asText(null),
                    paramNode.path("description").asText("")
            ));
        }
    });

    return parameters;
}

RequestBody와 달리 Parameters는 단순하다. 
$ref resolve나 schema enrichment가 필요 없고, 각 파라미터의 name, in, type, format을 그대로 추출한다. in 값은 path, query, header, cookie 중 하나로, DB에 저장해두면 "이 파라미터가 URL에 붙는 건지, 헤더에 넣는 건지" LLM이 바로 구분할 수 있다.

 

그리고 DB에서도 이를 분리해서 저장한다. request_schemas 테이블은 Body, parameters 테이블은 path, query, param을 각각 저장하는데 이는 추후에 다루어 보도록 하겠다.


 

📌 에러 응답 파싱

에러 응답 파싱에서는 큰 어려움은 없었다.

public List<ParsedErrorResponse> parseErrorResponses(final JsonNode swaggerJson, final JsonNode operation) {
     
     ...
     
     final String code = example.path("code").asText("");
     final String message = example.path("message").asText("");
     final List<Map<String, Object>> errors = parseValidationErrors(example);
     final String[] codeParts = parseErrorCode(code);
     
     ...
}

code, message, errors는 example에서 직접 추출한다. 내부 팀 프로젝트이므로 에러 응답 필드명이 고정되어 있다. 불필요한 방어 코드 없이 필드를 직접 꺼내도록 하였다.

{
  "code": "COMM-01-005",
  "message": "유효성 검증에 실패하였습니다",
  "errors": [
    { "field": "templateId", "message": "여행 템플릿 ID는 필수입니다." },
    { "field": "startDate",  "message": "여행 시작일은 필수입니다." }
  ]
}

유효성 검증 실패시 어떤 필드가 왜 실패했는지가 배열로 내려온다. 이 배열을 그냥 무시해버리면 LLM이 어떤 필드에서 어떤 에러가 났는지를 알 수가 없다. 그러므로 이를 추출해서 별도 JSON 칼럼에 저장해야한다.


📌 에러 코드 파싱

우리 NDGL 팀의 백엔드는 에러 코드를 `domain-category-detail` 형식으로 관리한다.

예를 들어 `TRAVEL-BUSINESS RULE VIOLATION-001` 이면 여행 도메인에서 1번째 비즈니스 룰 에러가 발생했다 라는 의미이다.

 

이 에러 코드를 단순히 문자열로 저장하는 것보다 파싱해서 관리를 한다면 "TRAVEL 도메인의 모든 에러를 찾아줘"와 같은 쿼리가 가능해진다.

private String[] parseErrorCode(final String code) {
    final String[] parts = new String[]{"", "", ""};
    if (code == null || code.isEmpty()) {
        return parts;
    }
    final String[] split = code.split("-");
    if (split.length >= 1) parts[0] = split[0]; // TRAVEL
    if (split.length >= 2) parts[1] = split[1]; // BUSINESS RULE VIOLATION
    if (split.length >= 3) parts[2] = split[2]; // 001
    return parts;
}

code를 `-` 기준으로 분리하여 domain, category, detail 코드로 나누어서 저장한다.


What's Next

이번 편에서 다룬 핵심을 정리하면 다음과 같다.

  1. 미리 파싱해서 $ref를 resolve한 상태로 저장
  2. 4개 Parser로 분리
    1. 기본 정보 파싱
    2. Request Body + Parameters 파싱
    3. Response Body 파싱
    4. Error Response 및 Error Code 파싱

아직까지는 외부 인프라나 Spring AI, MCP 등 어떠한 것도 사용하지 않았다. 단순히 스프링과 자바로 swagger 문서를 파싱하는 부분에 대해서만 다루었다.

 

다음 편에서는 파싱한 데이터를 DB에 저장하는 설계와 이를 MCP Tool로 노출시키는 과정을 다룬다. 그리고 최종적으로 Spring AI MCP로 Tool을 등록하고, Bearer 토큰 인증을 붙이고, LLM에 연결하는 것까지 이어지도록 해보겠다.

 

'AI' 카테고리의 다른 글

Swagger MCP Server 만들기 1편 - LLM 기반 API 탐색 자동화 설계  (0) 2026.02.05
'AI' 카테고리의 다른 글
  • Swagger MCP Server 만들기 1편 - LLM 기반 API 탐색 자동화 설계
WooJJam
WooJJam
  • WooJJam
    우쨈의 개발 블로그
    WooJJam
  • 전체
    오늘
    어제
    • 분류 전체보기 (19) N
      • 끄적끄적 (1)
      • Backend (7)
        • Spring Boot (5)
        • MySQL (1)
        • Java (1)
      • DevOps (6)
        • Monitoring (3)
        • Deployment (1)
        • Github Actions (2)
      • Computer Science (3)
        • Network (1)
        • Operating System (0)
        • Database (2)
      • AI (2) N
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 공지사항

  • 인기 글

  • 태그

    스프링 이벤트
    GitHub Actions
    self-hosted runner
    CompletableFuture
    공간인덱스
    GitHub hosted runner
    non repeatable
    Ai
    devops
    plg stack
    비동기
    동시성
    llm
    mcp server
    모니터링
    swagger
    트랜잭션 분리
    로깅 시스템
    List.of
    promtail
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.3
WooJJam
Swagger MCP Server 만들기 2편 - Swagger API 파싱
상단으로

티스토리툴바