이 시리즈는 Backend API의 Swagger 문서를 MCP 서버로 제공하여, LLM에서 자연어로 API를 검색하고, 완전한 네트워킹 코드를 자동 생성하는 시스템을 만드는 과정입니다.
1편 : LLM 기반 API 탐색 자동화 설계
2편 : Swagger API 파싱
3편 : DB 저장 설계와 트러블 슈팅 (현재 글)
4편 : Spring AI로 MCP 서버를 구축하고, Claude Code 연동하기
모든 코드는 해당 레포지토리에서 확인할 수 있습니다.
GitHub - WooJJam/swagger-mcp-server: Spring AI를 사용하여 Swagger API를 LLM이 자연어로 조회할 수 있도록 제공
Spring AI를 사용하여 Swagger API를 LLM이 자연어로 조회할 수 있도록 제공하는 MCP Server - WooJJam/swagger-mcp-server
github.com
Introduce
2편에서는 Swagger JSON을 4개의 Parser로 분리하여 파싱했던 방법을 다루어 보았다. 하지만 파싱된 결과가 메모리에만 있으면 의미가 없다. MCP Tool이 조회할 수 있으려면 DB에 저장되어 있어야 하고, 그 과정에서 테이블 구조, 컬럼 타입, 저장 방법등 결정할 것이 많았다.
또한 실제 swagger API를 파싱하는 과정에서 $ref가 중첩되어 있으면 데이터가 제대로 저장되지 않는 문제가 있었다. 따라서 이번 편에서는 DB 설계를 다루고, 파싱하는 과정에서 생긴 트러블 슈팅과 LLM 친화적 포맷 변환까지 다루어보고자 한다.
Database Modeling
2편에서 4개의 Parser로 만들어낸 최종 결과물은 List<ParsedApiEndpoint> 이다. 각 API 엔드포인트마다 path, method, summary 같은 기본 정보들과 requestBody, parameters, responseSchema, errorSchema 들이 있다.
public record ParsedApiEndpoint(
String path,
String method,
String operationId,
String summary,
String description,
ParsedRequestBody requestBody, // nullable (POST, PUT, PATCH만)
List<ParsedParameter> parameters, // 항상 List (빈 리스트 가능)
List<ParsedResponseSchema> responseSchemas,
List<ParsedErrorResponse> errorResponses
) {}
이걸 DB에 넣을려면 결정할 것이 있다. 테이블을 어떻게 구성할 것인가, 스키마는 어떤식으로 저장할 것인가, $ref는 resolve한 상태로 저장할 것인가, 등등.. 하나씩 짚어보자.
결론부터 말하자면 테이블 구조는 api_endpoint를 중심으로 놓고, 나머지 4개 테이블을 외래키로 걸어주었다.

하나의 API 엔드포인트가 여러 개의 request schema, parameter, response schema, error response를 가질 수 있으므로 전부 1:N 관계이다.
📌 ApiEndpoint Entity
public class ApiEndpoint extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 255)
private String path;
@Column(nullable = false, length = 10)
private String method;
@Column(unique = true, length = 255)
private String operationId;
@Column(columnDefinition = "TEXT")
private String summary;
@Column(columnDefinition = "TEXT")
private String description;
operationId에 unique 제약을 걸어주었는데, 이는 OpenAPI 스펙에서 operationId는 API를 고유하게 식별하는 값이기 때문이다. 나중에 MCP Tool에서 키워드 검색 결과로 특정 API의 상세 정보를 조회할 때 이 값을 기준으로 찾게된다. 그렇기에 중복이 있으면 안되므로 DB레벨에서 멱등성을 보장할 수 있도록 unique를 걸어준 것이다.
나머지 필드인 path, method, summary, description 들은 Swagger 기본 정보를 그대로 매핑해주었다.
📌 RequestSchema Entity
public class RequestSchema {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "api_endpoint_id", nullable = false)
private Long apiEndpointId;
@Column(name = "dto_name", length = 255)
private String dtoName;
@JdbcTypeCode(SqlTypes.JSON)
@Column(name = "schema_json", nullable = false, columnDefinition = "JSON")
private Map<String, Object> schemaJson;
@JdbcTypeCode(SqlTypes.JSON)
@Column(name = "example_json", columnDefinition = "JSON")
private Map<String, Object> exampleJson;
}
schemaJson은 요청 DTO의 전체 구조를, exampleJson은 요청 DTO의 예시를 Map<String, Object>로 저장한다. `@JdbcTypeCode(SqlTypes.JSON)`을 통해 Hibernate가 MySQL JSON 컬럼으로 자동으로 직렬화 및 역질렬화를 할 수 있다.
dtoName 필드에는 LoginRequest, CreateTripRequest 같은 DTO의 이름이 들어가는데, Swagger의 $ref에서 추출한 값으로 LLM이 나중에 코드를 생성할 때 클래스명을 그대로 조회하거나 사용할 수 있다.
📌 ErrorReseponse Entity
public class ErrorResponse {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "api_endpoint_id", nullable = false)
private Long apiEndpointId;
@Column(name = "status_code", nullable = false)
private Integer statusCode;
@Column(name = "code", nullable = false, length = 50)
private String code;
@Column(name = "message", nullable = false, columnDefinition = "TEXT")
private String message;
@Column(name = "domain_code", length = 20)
private String domainCode;
@Column(name = "category_code", length = 20)
private String categoryCode;
@Column(name = "detail_code", length = 20)
private String detailCode;
@Column(columnDefinition = "TEXT")
private String description;
@JdbcTypeCode(SqlTypes.JSON)
@Column(name = "schema_json", columnDefinition = "JSON")
private Map<String, Object> schemaJson;
@JdbcTypeCode(SqlTypes.JSON)
@Column(name = "errors", columnDefinition = "JSON")
private List<Map<String, Object>> errors;
NDGL 서버에서는 `DOMAIN-CATEGORY-DETAIL` 구조의 에러 코드 형식을 쓴다.
code 컬럼에 전체 에러 코드를 저장하면서, 동시에 domainCode, categoryCode, detailCode로 분리하여 저장한다. 이는 "AUTH" 도메인의 모든 에러를 조회하거나 특정 "Category" 관련 에러만 필터링 하는 쿼리가 필요할 수 있기 때문이다. 만약 전체 에러 코드만 있다면 `LIKE 'AUTH-%'` 같은 비효율적인 쿼리를 사용해야한다.
따라서 클라이언트 개발자가 에러 코드로 검색하는 시나리오가 있을 수 있다라고 판단하였기에 당장은 구현하지 않더라도 설계 자체는 열어놓는 식으로 진행하였다.
이외에도 `Parameter`, `ResponseSchema`, `SwaggerMetadata`의 테이블들이 존재하지만 앞서 설명한 테이블들과 매우 유사한 구조이므로 따로 설명은 하지 않을 것이다.
📌 JSON 타입 컬럼
schemaJson이나 exampleJson 등을 저장할 때 TEXT로 저장하는 방식과 JSON 타입으로 저장하는 방식에 대해서 고민이 있었다.
@JdbcTypeCode(SqlTypes.JSON)
@Column(name = "schema_json", nullable = false, columnDefinition = "JSON")
private Map<String, Object> schemaJson;
TEXT 타입의 경우 삽입시 따로 유효성 검증을 하지 않아 쓰기 성능은 더 좋다고 알고 있다. 그리고 직렬화는 수동으로 직접 변환해주어야 한다. 그에 반해 JSON 타입의 경우 삽입시 JSON 형식인지 유효성을 검증하기에 쓰기 성능이 비교적 느리다고 알고 있다. 하지만 `@JdbcTypeCode(SqlTypes.JSON)`을 사용하면 hibernate가 자동으로 직렬화와 역직렬화를 지원해준다.
처음에는 Swagger API 문서들을 파싱한 다음 저장해야 하므로 쓰기 성능이 더 좋다는 TEXT를 사용하는 것이 바람직하지 않을까 싶었다. 하지만 직접 벤치마크를 해보지 않은 상태에서 판단하기에는 무리가 있었고, 성능의 차이가 있다하더라도 쓰기 연산이 빈번한 것도 아닌데 직렬화, 역직렬화를 직접 작성할만큼의 메리트가 있을까 싶었다.
그리고 무엇보다도 schemaJson이 만약 잘못된 JSON 구조로 들어간다면 나중에 LLM이 이를 조회할 때 문제가 생길 가능성이 높았다. 쓰기 연산은 Swagger API 문서를 동기화할 때만 발생하고, 제일 중요한 것은 API 문서의 정확도이기 때문에 JSON 타입으로 저장하는 것을 선택하였다.
Sync Strategy
동기화 전략에는 두 가지 선택지가 있었다.
- Partial Sync: 기존 데이터와 비교하여 변경된 것만 수정
- Full Sync: 기존 데이터를 전부 삭제하고, 항상 전체 삽입
결과적으로 Full Sync를 선택하였다.
물론 Partial Sync가 누가봐도 이상적이고, 효율적으로 보이긴한다. 하지만 그만큼 구현의 복잡도가 급격히 올라간다라고 판단하였다.
API의 특정 필드가 수정되었을 때 이를 감지하기도 매우 까다롭고, operationId가 변경된 경우 기존 API가 수정된 건지 삭제 후 새로 생긴 건지 판단하기도 까다롭다. 엣지 케이스가 많아지면 버그도 많아질 수 밖에 없다.
현실적으로 Swagger 동기화는 백엔드 서버의 배포 시점에만 발생한다. 실시간으로 계속 동기화를 해주어야 하는 시스템이 아니라, 배포 후 딱 한번만 전체 동기화를 돌리면 된다. 이는 API가 수백 개라도 배치 삭제 + 삽입이면 늦어도 몇 초면 끝난다. 따라서 복잡성보다는 단순함을 택한 결정이다.
📌 Flow
@Transactional
public void syncAll(final List<ParsedApiEndpoint> endpoints,
final String swaggerUrl, final String swaggerVersion) {
log.info("전체 동기화 시작: {} 엔드포인트", endpoints.size());
// 1. 기존 데이터 삭제
deleteAll();
// 2. API 엔드포인트 저장
int savedCount = 0;
for (final ParsedApiEndpoint parsedEndpoint : endpoints) {
saveApiEndpoint(parsedEndpoint);
savedCount++;
}
// 3. 메타데이터 업데이트
updateMetadata(swaggerUrl, swaggerVersion, savedCount);
log.info("전체 동기화 완료: {} 엔드포인트 저장됨", savedCount);
}
전체 흐름은 단순하다. 삭제 -> 저장 -> 메타데이터 업데이트 순서이다. 그리고 트랜잭션이 걸려있어 중간에 실패하면 전부 롤백된다.
📌 deleteAllInBatch() vs deleteAll()
private void deleteAll() {
errorResponseRepository.deleteAllInBatch();
responseSchemaRepository.deleteAllInBatch();
parameterRepository.deleteAllInBatch();
requestSchemaRepository.deleteAllInBatch();
apiEndpointRepository.deleteAllInBatch();
}
`deleteAll()`은 내부적으로 `findAll()`을 먼저 실행해서 모든 엔티티를 영속성 컨텍스트에 로드한 다음, 하나씩 delete 쿼리를 날린다. 그렇기에 API가 100개라면 자식 테이블까지 합쳤을 때 수백개의 DELETE 쿼리가 발생할 수 있다.
반면 `deleteAllInBatch()`는 DELETE FROM table_name 쿼리 하나만 발생한다. 따라서 영속성 컨텍스트도 거치지 않으므로 전체 삭제에서는 훨씬 빠르게 동작하게 된다.
Recursive $ref Resolution
실제 NGL 백엔드 서버의 Swagger를 연동하고, DB에 저장된 response_shcema를 확인하는데 예상과는 다른게 보였다. $ref가 중첩된 구조에서 실제 스키마가 저장되는 것이 아닌 스키마의 위치를 나타내는 문자열이 그대로 저장되는 것이었다.
{
"type": "object",
"properties": {
"title": { "type": "string" },
"itineraries": {
"type": "array",
"items": {
"$ref": "#/components/schemas/ItineraryPlaceResponse"
}
}
}
}
`itineraries`는 `ItineraryPlaceResponse` 배열인데, 그 안의 실제 필드(장소 명, 지역 정보 등)를 알 수 없다. LLM에게 이 상태로 전달하면 실제 DTO를 생성할 수 없다.
원인은 금방 찾을 수 있었는데, 현재 resolveSchemaRef()는 $ref 문자열 하나를 받아 해당 schema를 반환하는 메서드인데 파서에서는 이를 top-level의 $ref에 대해서 한 번만 호출하고 있었다.
📌 재귀적으로 해결하기
결론부터 말하자면 스키마 트리를 DFS로 순회하면서 모든 $ref를 재귀적으로 resolve하여 문제를 해결하였다. `$ref`는 스키마 안 어디에든 숨어있을 수 있는데, OpenAPI 3.0 스펙 보면 Schema Object 안에서 `$ref`가 등장할 수 있는 위치가 정해져 있다.
가장 흔한 2곳은 이미 앞에서 보았다.
- properties - object 타입의 각 필드
- items - array 타입의 스키마
여기까지는 직관적이다. 한 가지 더 주의해야할 게 있다.
allOf - A 스키마와 B 스키마를 합친 것으로 DTO가 다른 클래스를 상속하면 해당 구조가 나오게 된다.
additionalProperties - 키 이름이 고정되지 않음 Map 구조일 때 해당 구조가 나오게 된다.
private void resolveAllRefsRecursive(final JsonNode swaggerJson,
final JsonNode node, final Set<String> visited) {
if (node == null || node.isMissingNode() || !node.isObject()) {
return;
}
final ObjectNode objectNode = (ObjectNode) node;
// 1. properties 내부 각 필드의 $ref 처리
final JsonNode properties = objectNode.path("properties");
if (properties.isObject()) {
for (final String fieldName : collectFieldNames(properties)) {
final JsonNode fieldSchema = properties.get(fieldName);
final JsonNode resolved = resolveIfRef(swaggerJson, fieldSchema, visited);
if (resolved != fieldSchema) {
((ObjectNode) properties).set(fieldName, resolved);
}
resolveAllRefsRecursive(swaggerJson, resolved, visited);
}
}
// 2. items (array 타입) 내부 $ref 처리
resolveChildRef(swaggerJson, objectNode, "items", visited);
// 3. allOf 배열 내부 $ref 처리
resolveRefsInCompositionArray(swaggerJson, objectNode, "allOf", visited);
// 4. additionalProperties 내부 $ref 처리
resolveChildRef(swaggerJson, objectNode, "additionalProperties", visited);
}
동작 흐름을 정리하면 이렇다. 하나의 스키마를 받으면 properties, items, allOf, additionalProperties 안에 $ref가 있는지 검사한다. 그리고 $ref를 발견하면 실제 스키마로 교체하고, 교체된 스키마 안에 또 $ref가 있을 수 있으므로 같은 과정을 반복한다. 최종적으로 `string`, `integer` 같은 원시 타입 필드까지 내려가면 더 이상 `$ref`가 없으므로 재귀가 자연스럽게 종료되게 된다.
📌 순환 참조 처리
재귀를 쓰면 당연히 순환 참조 문제가 떠오른다. 실제로 Swagger 문서에도 이런 구조가 있을 수 있다. 예를 들어 댓글의 대댓글 같은 기능이 있고, 같은 Response 구조를 사용한다면 무한 루프에 빠질 수 있다.
CommentResponse
+-- replies (array)
+-- items
+-- CommentResponse <- 자기 자신을 참조!
이를 해결하기 위해서 평범한 DFS처럼 방문처리를 해서는 안된다.
private JsonNode resolveIfRef(final JsonNode swaggerJson,
final JsonNode node, final Set<String> visited) {
if (!node.has("$ref")) {
return node;
}
final String ref = node.get("$ref").asText();
// 순환 참조 감지: 현재 DFS 경로에 이미 존재하면 순환
if (visited.contains(ref)) {
log.warn("순환 참조 감지: {}", ref);
final ObjectNode circularMarker = objectMapper.createObjectNode();
circularMarker.put("_circular", true);
circularMarker.put("$ref", ref);
return circularMarker;
}
final JsonNode resolved = resolveSchemaRef(swaggerJson, ref).deepCopy();
visited.add(ref);
resolveAllRefsRecursive(swaggerJson, resolved, visited);
visited.remove(ref); // 재귀 완료 후 제거
return resolved;
}
로직을 보면 add -> 재귀 -> remove 순서로 동작하는 것을 확인할 수 있다. 왜 단순히 한 번 방문한 ref는 다시 방문하지 않는 방식이 아니라 remove로 처리하는 이유는 형제 노드에서 같은 $ref 를 참조하는 경우가 있을 수 있기 때문이다.
TravelResponse
|-- departure: LocationResponse <- resolve 필요
+-- arrival: LocationResponse <- 이것도 resolve 필요 (형제 참조)
departure`를 resolve할 때 LocationResponse를 visited에 추가하고, 재귀가 끝나면 제거한다. 그래야 arrival에서 같은 LocationResponse를 다시 resolve할 수 있다. 조상-자손 관계의 순환만 차단하고, 형제 노드의 동일 참조는 허용하는 것이다.
{
"_circular": true,
"$ref": "..."
}
순환이 감지되면 다음과 같이 마커를 남긴다. 이를 통해 DFS를 진행하면서 이미 처리한 스키마임을 인식하고, 재귀적인 DTO를 생성할 수 있다.
실제 운영에서는 아직 순환 참조를 만난 적은 없다. 우리 NDGL의 Swagger에는 자기 자신을 참조하는 스키마가 존재하지 않았다. 하지만 OpenAPI 3.0 스펙상 순환 참조는 명시적으로 허용되어 있고, 댓글-대댓글 같은 재귀 구조가 언제든 추가될 수 있다. 그렇기에 가능성을 열어두기보다 확실한 안전장치가 필요하다고 판단하였다.
// Before
{
"type": "object",
"properties": {
"title": { "type": "string" },
"itineraries": {
"type": "array",
"items": {
"$ref": "#/components/schemas/ItineraryPlaceResponse"
}
}
}
}
// After
{
"type": "object",
"properties": {
"title": { "type": "string" },
"itineraries": {
"type": "array",
"items": {
"type": "object",
"properties": {
"placeName": { "type": "string" },
"latitude": { "type": "number", "format": "double" },
"longitude": { "type": "number", "format": "double" },
"location": {
"type": "object",
"properties": {
"address": { "type": "string" },
"city": { "type": "string" }
}
}
}
}
}
}
}
$ref 문자열이 사라지고, 실제 필드 구조가 트리 끝까지 펼쳐져 있다. 이 상태로 DB에 저장해야 나중에 LLM이 중첩 객체까지 포함한 완전한 DTO를 생성할 수 있다.
AI Formatting
$ref를 통한 실제 스키마와 required 배열은 이미 각 필드에 인라인된 상태로 매핑해주었다. 하지만 이를 DB에 저장하는 것 까지는 좋지만 LLM에게 반환해줄 때는 그대로 반환해주면 안된다.
{
"type": "object",
"properties": {
"title": { "type": "string", "required": true, "description": "여행 제목" },
"startDate": { "type": "string", "format": "date", "required": true },
"endDate": { "type": "string", "format": "date", "required": true },
"itineraries": {
"type": "array",
"required": false,
"items": {
"type": "object",
"properties": {
"placeName": { "type": "string", "required": false },
"location": {
"type": "object",
"required": false,
"properties": {
"latitude": { "type": "number", "required": false },
"longitude": { "type": "number", "required": false }
}
}
}
}
}
}
}
전체 데이터 구조는 완전하다. 하지만 이걸 그대로 LLM에게 넘기게 되면 불필요한 래퍼 구조가 남아있다. 루트의 "type": "object"와 "properties" 래퍼, 중첩된 "items" 안의 "type": "object"와 "properties" 래퍼... LLM이 해당 트리를 탐색해서 Kotlin이나 Swift의 중첩 DTO로 변환하려면 스키마 구조 자체가 직관적이어야 한다.
결국 LLM한테 필요한건 해당 필드의 타입은 무엇이고, 필수인지, 그리고 중첩 구조가 있다면 그 안에 뭐가 있는지를 필드 단위로 직관적으로 알 수 있는 구조다.
📌 SchemaSupporter
변환 결과를 담는 FieldInfo를 작성할 때 고민이 많았다.
public record FieldInfo(
String type, // "string", "integer", "object", "array"
String format, // "date", "email", "password" 등
Boolean required, // 필수 여부
String description, // 필드 설명
Object example, // 예시 값
Map<String, FieldInfo> properties, // object의 중첩 필드
FieldInfo items // array의 항목 구조
) {}
처음에는 type, format, required, description, example 5개 필드만 있는 구조였다. 하지만 해당 구조는 $ref resolution에서 겪었던 문제처럼 중첩된 $ref 를 표현할 방법이 없었다. 따라서 FieldInfo가 자기 자신을 참조하는 재귀 구조로 변경함으로써 중첩된 구조를 표현할 수 있었다.
진입점인 `formatSchema()`는 루트의 "type": "object" + "properties" 래퍼를 벗기고, 각 필드를 createFieldInfo()로 변환한다.
public Map<String, FieldInfo> formatSchema(final Object schemaJson, final Object exampleJson) {
final Map<String, Object> schemaMap = convertToMap(schemaJson);
final Map<String, Object> exampleMap = exampleJson != null
? convertToMap(exampleJson) : Collections.emptyMap();
final Map<String, Object> properties = extractProperties(schemaMap);
final Map<String, FieldInfo> result = new LinkedHashMap<>();
for (final Map.Entry<String, Object> entry : properties.entrySet()) {
final String fieldName = entry.getKey();
final Map<String, Object> fieldSchema = convertToMap(entry.getValue());
result.put(fieldName, createFieldInfo(fieldSchema, exampleMap.get(fieldName)));
}
return result;
}
extractProperties()로 properties 안의 필드들을 꺼내고, 각각을 createFieldInfo()로 변환해서 LinkedHashMap에 담는다. createFieldInfo()가 재귀 변환의 핵심으로 조건 타입에 따라 분기해야 한다.
private FieldInfo createFieldInfo(final Map<String, Object> fieldSchema, final Object example) {
final String type = getStringValue(fieldSchema, "type");
final String format = getStringValue(fieldSchema, "format");
final String description = getStringValue(fieldSchema, "description");
final boolean required = Boolean.TRUE.equals(fieldSchema.get("required"));
final Object finalExample = example != null ? example : fieldSchema.get("example");
// object 타입 -> 내부 properties 재귀 변환
Map<String, FieldInfo> nestedProperties = null;
if ("object".equals(type) && fieldSchema.containsKey("properties")) {
nestedProperties = formatNestedProperties(fieldSchema);
}
// array 타입 -> items 재귀 변환
FieldInfo itemsFieldInfo = null;
if ("array".equals(type) && fieldSchema.containsKey("items")) {
itemsFieldInfo = createFieldInfo(convertToMap(fieldSchema.get("items")), null);
}
return new FieldInfo(type, format, required, description,
finalExample, nestedProperties, itemsFieldInfo);
}
type이 object면 formatNestedProperties()로 내부 필드를 재귀 변환하고, array면 items에 대해 자기 자신을 호출한다. 그리고 string, integer 같은 원시 타입에서는 분기를 모두 건너뛰고, 재귀는 종료되게 된다.
// Before
{
"type": "object",
"required": ["title", "startDate"],
"properties": {
"title": { "type": "string", "required": true, "description": "여행 제목" },
"startDate": { "type": "string", "format": "date", "required": true },
"itineraries": {
"type": "array",
"required": false,
"items": {
"type": "object",
"required": ["placeName"],
"properties": {
"placeName": { "type": "string", "required": true },
"latitude": { "type": "number", "required": false }
}
}
}
}
}
// After
{
"type": "object",
"required": ["title", "startDate"],
"properties": {
"title": { "type": "string", "required": true, "description": "여행 제목" },
"startDate": { "type": "string", "format": "date", "required": true },
"itineraries": {
"type": "array",
"required": false,
"items": {
"type": "object",
"required": ["placeName"],
"properties": {
"placeName": { "type": "string", "required": true },
"latitude": { "type": "number", "required": false }
}
}
}
}
}
최종 response 구조를 보면 루트의 "type": "object", "properties", "required": [...] 래퍼가 사라지고, 바로 필드 목록으로 시작하는 것을 확인할 수 있다. 따라서 LLM은 이 구조를 보고 title은 non-null 이면서 String 타입, itineraries는 nullable 이면서 List 타입 등으로 바로 응답할 수 있는 구조가 완성되었다.
What's Next
현재 편에서는 데이터베이스 모델링을 통해 파싱한 데이터를 실제로 데이터베이스에 저장하는 방식과 LLM 친화적 포맷팅을 어떤식으로 진행했는지에 대해서 다루어 봤다.
사실상 지금까지는 데이터의 전처리 작업이었다. 전처리 과정이 워낙 복잡해서.. 솔직히 전처리 작업은 100% 이해하지 않아도 된다고 생각한다. 문제가 생기면 해당 부분만 확인하면 되지 않을까 싶기도 하고, 필자도 전처리 작업의 코드 레벨 전부를 이해하고 있지는 않다. (다만 전체적인 플로우는 알고 있어야 함)
이러한 전처리 작업들은 모두 수작업으로 처리할 수 있다면 물론 좋겠지만, $ref 재귀 resolve나 required 정보 enrichment 같은 코드를 직접 한 줄씩 작성하는 건 솔직히 생산적이라고는 생각하지 않는다. 오히려 이런 부분이야말로 AI 에이전트에게 빠르게 위임하고, 개발자는 전체 플로우와 핵심 로직에 집중하는 것이 더 효율적이라고 생각한다.
따라서 해당 글에서는 어떤 데이터가 들어와서 어떤 형태로 변환되는지, 그 흐름만 파악하고 있으면 충분하다고 생각한다. AI가 사용할 툴을 만드는 것을 AI에게 위임하는 것이 아이러니 할지 모르지만 결국 개발자의 역할이 "모든 코드를 직접 작성하는 사람"에서 "AI와 협업하여 시스템을 설계하고 검증하는 사람"으로 변하고 있는 것 같다. 그러므로 오히려 AI를 적극적으로 사용하는 것이 현재의 개발자로서의 올바른 접근이 아닐까?
다음 편에서는 이렇게 변환된 데이터를 MCP 서버로 노출시키는 과정을 다룬다. Spring AI MCP로 Tool을 등록하고, 드디어 LLM에 연결해서 "로그인 API 찾아서 DTO 만들어줘"가 실제로 동작하는 모습을 다룰 예정이다.
'AI' 카테고리의 다른 글
| Swagger MCP Server 만들기 4편 - Spring AI로 MCP 서버를 구축하고, Claude Code에 연동하기 (0) | 2026.02.27 |
|---|---|
| Swagger MCP Server 만들기 2편 - Swagger API 파싱 (2) | 2026.02.13 |
| Swagger MCP Server 만들기 1편 - LLM 기반 API 탐색 자동화 설계 (0) | 2026.02.05 |