feat: Refactor monolith to microservices architecture

This commit is contained in:
jade
2026-07-05 17:27:44 +09:00
parent 367a47516d
commit 10f3f0513d
200 changed files with 541 additions and 1789 deletions

View File

@@ -0,0 +1,12 @@
plugins {
id 'java-library'
}
dependencies {
api project(':axhub-common')
}
dependencies {
compileOnly 'org.projectlombok:lombok:1.18.32'
annotationProcessor 'org.projectlombok:lombok:1.18.32'
}

View File

@@ -0,0 +1,49 @@
package io.shinhanlife.axhub.biz.mcp.adapter.aop;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.stereotype.Component;
import java.util.Arrays;
@Slf4j
@Aspect
@Component
public class EimsMonitoringAspect {
// 포인트컷: io.shinhanlife.axhub.biz.mcp.adapter.sender 패키지 내의 EimsSender를 구현한 모든 클래스의 메서드를 타겟으로 지정합니다.
@Around("execution(* io.shinhanlife.axhub.biz.mcp.adapter.sender.*EimsSender.*(..))")
public Object monitorEimsCommunication(ProceedingJoinPoint joinPoint) throws Throwable {
// 1. 호출되는 클래스와 메서드 이름 추출
String className = joinPoint.getTarget().getClass().getSimpleName();
String methodName = joinPoint.getSignature().getName();
Object[] args = joinPoint.getArgs(); // 파라미터
long startTime = System.currentTimeMillis();
// 2. [요청 로깅] EIMS망으로 요청이 나가기 직전
log.info(" [EIMS 요청] {} - {}() | Params: {}", className, methodName, Arrays.toString(args));
try {
// 실제 레거시 통신 로직 실행 (이 코드가 없으면 통신이 진행되지 않습니다)
Object result = joinPoint.proceed();
// 3. [응답 로깅] 정상적으로 통신이 완료된 후
long executionTime = System.currentTimeMillis() - startTime;
log.info("◀ [EIMS 응답] {} - {}() | 소요시간: {}ms | Result: {}", className, methodName, executionTime, result);
return result;
} catch (Exception e) {
// 4. [에러 로깅] 레거시 통신 중 장애(타임아웃 등) 발생 시
long executionTime = System.currentTimeMillis() - startTime;
log.error(" [EIMS 에러] {} - {}() | 소요시간: {}ms | Error: {}", className, methodName, executionTime, e.getMessage());
// 에러를 삼키지 않고 다시 던져서 기존 예외 처리 로직(서킷 브레이커 등)이 작동하게 합니다.
throw e;
}
}
}

View File

@@ -0,0 +1,41 @@
package io.shinhanlife.axhub.biz.mcp.adapter.aop;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.stereotype.Component;
import org.springframework.util.StopWatch;
@Slf4j
@Aspect
@Component
public class GatewayLoggingAspect {
// controller 및 connector 패키지 하위의 모든 클래스/메서드 실행 시 작동
@Around("execution(* io.shinhanlife.axhub.biz.mcp.gateway.controller..*(..)) " +
"|| execution(* io.shinhanlife.axhub.biz.mcp.tool.controller..*(..)) " +
"|| execution(* io.shinhanlife.axhub.biz.mcp.adapter.connector..*(..))")
public Object logConnectorExecutionTime(ProceedingJoinPoint joinPoint) throws Throwable {
String targetMethod = joinPoint.getSignature().toShortString();
StopWatch stopWatch = new StopWatch();
log.info(" [Gateway 송신 시작] Target: {}", targetMethod);
stopWatch.start();
try {
// 실제 대상 메서드(외부 통신) 실행
Object result = joinPoint.proceed();
return result;
} finally {
stopWatch.stop();
long timeMillis = stopWatch.getTotalTimeMillis();
if (timeMillis > 3000) { // 3초 이상 걸리면 WARN 로그로 슬로우 통신 경고
log.warn("⏱ [Gateway 슬로우 응답] Target: {} | 소요시간: {}ms", targetMethod, timeMillis);
} else {
log.info("⏹ [Gateway 송신 완료] Target: {} | 소요시간: {}ms", targetMethod, timeMillis);
}
}
}
}

View File

@@ -0,0 +1,58 @@
package io.shinhanlife.axhub.biz.mcp.adapter.connector;
import io.shinhanlife.axhub.biz.mcp.adapter.support.DynamicPayloadBuilder;
import io.shinhanlife.axhub.biz.mcp.adapter.support.DynamicSchemaValidator;
import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker;
import io.github.resilience4j.ratelimiter.annotation.RateLimiter;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestClient;
import java.util.Map;
import java.util.List;
@Slf4j
@Service
public class ExternalApiConnector {
private final RestClient restClient;
private final DynamicSchemaValidator schemaValidator;
private final DynamicPayloadBuilder payloadBuilder;
public ExternalApiConnector(DynamicSchemaValidator schemaValidator, DynamicPayloadBuilder payloadBuilder) {
this.restClient = RestClient.create();
this.schemaValidator = schemaValidator;
this.payloadBuilder = payloadBuilder;
}
@RateLimiter(name = "externalApi", fallbackMethod = "fallbackForExternalApi")
@CircuitBreaker(name = "externalApi", fallbackMethod = "fallbackForExternalApi")
public String callExternalApi(String apiName, String endpoint, Map<String, Object> data, List<Map<String, Object>> spec, boolean isFixedLength) throws Exception {
// 1. 요청 데이터 검증 (errorLog 전달하여 구체적 에러 포착)
StringBuilder errorLog = new StringBuilder();
if (!schemaValidator.validate(spec, data, errorLog)) {
String errorMsg = "API 요청 데이터 스키마 불일치 [" + apiName + "]: " + errorLog.toString();
log.error(" {}", errorMsg);
throw new IllegalArgumentException(errorMsg);
}
// 2. 페이로드 빌드
Object payload = isFixedLength ? payloadBuilder.buildFixedLengthString(spec, data) : data;
String contentType = isFixedLength ? "application/x-www-form-urlencoded;charset=EUC-KR" : "application/json";
log.info("🌐 외부 API 호출 [{}] 시작 (FixedLength: {})", apiName, isFixedLength);
return restClient.post()
.uri(endpoint)
.contentType(MediaType.parseMediaType(contentType))
.body(payload)
.retrieve()
.body(String.class);
}
public String fallbackForExternalApi(String apiName, String endpoint, Map<String, Object> data, List<Map<String, Object>> spec, boolean isFixedLength, Throwable t) {
log.error(" [외부 API 장애] {} 호출 실패: {}", apiName, t.getMessage());
return String.format("{\"status\":\"EXTERNAL_API_ERROR\", \"message\":\"외부 서비스 연동 중 오류 발생: %s\"}", t.getMessage());
}
}

View File

@@ -0,0 +1,60 @@
package io.shinhanlife.axhub.biz.mcp.adapter.connector;
import io.shinhanlife.axhub.biz.mcp.adapter.support.DynamicPayloadBuilder;
import io.shinhanlife.axhub.biz.mcp.adapter.support.DynamicSchemaValidator;
import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker;
import io.github.resilience4j.ratelimiter.annotation.RateLimiter;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestClient;
import java.util.Map;
import java.util.List;
@Slf4j
@Service
public class InternalSystemConnector {
private final RestClient restClient;
private final DynamicSchemaValidator schemaValidator;
private final DynamicPayloadBuilder payloadBuilder;
// 생성자를 통한 의존성 주입 (Spring이 알아서 Validator와 Builder를 넣어줍니다)
public InternalSystemConnector(DynamicSchemaValidator schemaValidator, DynamicPayloadBuilder payloadBuilder) {
this.restClient = RestClient.create();
this.schemaValidator = schemaValidator;
this.payloadBuilder = payloadBuilder;
}
@RateLimiter(name = "internalSystem", fallbackMethod = "fallbackForInternal")
@CircuitBreaker(name = "internalSystem", fallbackMethod = "fallbackForInternal")
public String callInternalSystem(String targetName, String endpoint, Map<String, Object> data, List<Map<String, Object>> spec, boolean isFixedLength) throws Exception {
// 1. 요청 데이터 검증 (errorLog 전달하여 구체적 에러 포착)
StringBuilder errorLog = new StringBuilder();
if (!schemaValidator.validate(spec, data, errorLog)) {
String errorMsg = "대내외 시스템 연계 데이터 스키마 불일치 [" + targetName + "]: " + errorLog.toString();
log.error(" {}", errorMsg);
throw new IllegalArgumentException(errorMsg);
}
// 2. 페이로드 빌드 (고정장 vs JSON)
Object payload = isFixedLength ? payloadBuilder.buildFixedLengthString(spec, data) : data;
String contentType = isFixedLength ? "application/x-www-form-urlencoded;charset=EUC-KR" : "application/json";
log.info(" 대내외 시스템 호출 [{}] 시작 (FixedLength: {})", targetName, isFixedLength);
return restClient.post()
.uri(endpoint)
.contentType(MediaType.parseMediaType(contentType))
.body(payload)
.retrieve()
.body(String.class);
}
// 통신 장애(CircuitBreaker) 또는 허용량 초과(RateLimiter) 시 처리 로직
public String fallbackForInternal(String targetName, String endpoint, Map<String, Object> data, List<Map<String, Object>> spec, boolean isFixedLength, Throwable t) {
log.error(" [대내외 시스템 장애/지연] {} 연계 실패: {}", targetName, t.getMessage());
return String.format("{\"status\":\"INTERNAL_SYSTEM_ERROR\", \"message\":\"대내외 연계 시스템 호출 중 오류가 발생했거나 요청이 지연되었습니다. 사유: %s\"}", t.getMessage());
}
}

View File

@@ -0,0 +1,44 @@
package io.shinhanlife.axhub.biz.mcp.adapter.connector;
import io.shinhanlife.axhub.biz.mcp.adapter.support.ResultStandardizer;
import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker;
import io.github.resilience4j.ratelimiter.annotation.RateLimiter;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
@Slf4j
@Service
@RequiredArgsConstructor
public class LegacyDbConnector {
private final NamedParameterJdbcTemplate jdbcTemplate; // 동적 파라미터 바인딩을 위한 템플릿
private final ResultStandardizer resultStandardizer;
@RateLimiter(name = "legacyDb", fallbackMethod = "fallbackForDb")
@CircuitBreaker(name = "legacyDb", fallbackMethod = "fallbackForDb")
public List<Map<String, Object>> executeDynamicQuery(String queryId, String sql, Map<String, Object> params) {
log.info("🗄 Legacy DB 조회 시작 [QueryID: {}]", queryId);
// 1. SQL 쿼리 실행 (오라클 등)
List<Map<String, Object>> rawResults = jdbcTemplate.queryForList(sql, params);
// 2. 스키마 매핑 및 결과 정형화 (대문자 -> 카멜케이스 변환)
List<Map<String, Object>> standardResults = rawResults.stream()
.map(resultStandardizer::standardize)
.collect(Collectors.toList());
log.info(" Legacy DB 조회 완료 ({}건 반환)", standardResults.size());
return standardResults;
}
public List<Map<String, Object>> fallbackForDb(String queryId, String sql, Map<String, Object> params, Throwable t) {
log.error(" [Legacy DB 장애/지연] 쿼리 실행 실패 [{}]: {}", queryId, t.getMessage());
throw new RuntimeException("레거시 DB 연동 중 오류가 발생했습니다. (QueryID: " + queryId + ")");
}
}

View File

@@ -0,0 +1,121 @@
package io.shinhanlife.axhub.biz.mcp.adapter.connector;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.shinhanlife.axhub.biz.mcp.adapter.sender.EimsSender;
import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker;
import io.github.resilience4j.ratelimiter.RequestNotPermitted;
import io.github.resilience4j.ratelimiter.annotation.RateLimiter;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Map;
import io.shinhanlife.axhub.biz.mcp.adapter.util.LegacyDataTransformer;
@Slf4j
@Service
@RequiredArgsConstructor
public class LegacyEimsConnector {
// 4가지 방식의 Sender를 모두 주입받습니다. (변수명이 아주 중요합니다!)
private final EimsSender httpEimsSender; // 1~20 (EIMS API)
private final EimsSender tcpEimsSender; // 21~30 (EIMS Socket)
private final EimsSender jspFormEimsSender; // 31~40 (JSP Form)
private final EimsSender jspJsonEimsSender; // 41~50 (JSP JSON)
private final EimsSender mciEimsSender; // 실시간 연계 (기존 HTTP/TCP 대체, 동기식 API)
private final EimsSender eaiEimsSender; // 비동기/대용량 연계 (배치 통신 등)
private final ObjectMapper jsonMapper;
// 방어막 적용: 예외가 발생하거나 차단기가 열리면 fallbackMethod를 즉시 실행합니다!
// 서킷 브레이커와 Rate Limiter를 동시에 적용 (둘 중 하나라도 걸리면 fallbackForEims 실행)
@RateLimiter(name = "eims", fallbackMethod = "fallbackForEims")
@CircuitBreaker(name = "eims", fallbackMethod = "fallbackForEims")
// 2번 업그레이드 적용: 파라미터 기반의 스마트 캐싱 (고객의 요청 데이터가 다르면 캐시도 다르게 적용)
@Cacheable(value = "eimsData", key = "#routingType + '-' + #interfaceId + '-' + (#data != null ? #data.hashCode() : 0)")
public String executeByTool(String routingType, String interfaceId, Map<String, Object> data, List<Map<String, Object>> spec) throws Exception {
// 1. 데이터 조립 (방어 로직 포함)
String payload = buildPayload(data, spec);
log.info("\n [Adapter -> Legacy] EIMS 통신 요청 - RoutingType: {}, Interface: {}, Payload: {}", routingType, interfaceId, payload);
// 2. 4단계 라우팅 분기 처리
String legacyResponse = null;
if ("HTTP".equalsIgnoreCase(routingType)) {
log.info("[라우팅] EIMS API (HTTP) 통신으로 전달");
legacyResponse = httpEimsSender.send(interfaceId, payload);
}
else if ("TCP".equalsIgnoreCase(routingType)) {
log.info("[라우팅] EIMS 소켓 (TCP) 통신으로 전달");
legacyResponse = tcpEimsSender.send(interfaceId, payload);
}
else if ("JSP_FORM".equalsIgnoreCase(routingType)) {
log.info("[라우팅] JSP Form 통신으로 전달");
legacyResponse = jspFormEimsSender.send(interfaceId, payload);
}
else if ("JSP_JSON".equalsIgnoreCase(routingType)) {
log.info("[라우팅] JSP JSON 통신으로 전달");
legacyResponse = jspJsonEimsSender.send(interfaceId, payload);
}
else {
// 3. 아키텍처 규격에 맞춘 라우팅 (실시간 vs 비동기)
if (isMciRouting(routingType, interfaceId)) {
log.info("[라우팅] 실시간 AI 요청 -> MCI 연계 어댑터를 통해 EIMS 전달");
legacyResponse = mciEimsSender.send(interfaceId, payload);
} else {
log.info("[라우팅] 비동기/대용량 요청 -> EAI 연계 어댑터를 통해 EIMS 전달");
legacyResponse = eaiEimsSender.send(interfaceId, payload);
}
}
log.info("\n [Legacy -> Adapter] EIMS 통신 응답 수신: {}", legacyResponse);
return legacyResponse;
}
// 라우팅 조건을 판단하는 내부 메서드 (관리를 위해 분리)
private boolean isMciRouting(String routingType, String interfaceId) {
// AI 에이전트가 툴을 호출하는 경우는 대부분 즉각적인 대답이 필요한 '실시간 조회(MCI)'입니다.
return "MCI".equalsIgnoreCase(routingType) || (interfaceId != null && interfaceId.startsWith("MCI"));
}
// 비상용 응답 메서드 (차단기가 열려있거나, 타임아웃/에러가 났을 때 실행됨)
// 주의: 파라미터는 원본 메서드와 100% 똑같이 맞추고, 마지막에 Throwable을 받아야 합니다.
public String fallbackForEims(String routingType, String interfaceId, Map<String, Object> data, List<Map<String, Object>> spec, Throwable t) {
// 1. Rate Limiter에 의해 차단된 경우 (트래픽 폭주)
if (t instanceof RequestNotPermitted) {
log.warn(" [Rate Limiter 발동] 트래픽 폭주로 요청 차단! interfaceId: {}", interfaceId);
return String.format(
"{\"status\":\"TOO_MANY_REQUESTS\", \"message\":\"순간적인 요청 폭주로 인해 일시적으로 제한되었습니다. 잠시 후 시도해 주세요.\", \"interfaceId\":\"%s\"}",
interfaceId
);
}
// 2. Circuit Breaker에 의해 차단된 경우 (레거시 시스템 장애/지연)
log.error(" [서킷 브레이커 발동] 레거시 통신 차단! 원인: {}", t.getMessage());
return String.format(
"{\"status\":\"CIRCUIT_OPEN\", \"message\":\"신한은행 내부 시스템 장애로 인해 일시적으로 차단되었습니다. 복구 후 재시도 부탁드립니다.\", \"interfaceId\":\"%s\"}",
interfaceId
);
}
private String buildPayload(Map<String, Object> data, List<Map<String, Object>> spec) {
// 3번 항목 적용: 원본 데이터를 스펙에 맞게 엄격히 정제(변환, 형변환, 잘라내기, 기본값 등)
Map<String, Object> transformedData = LegacyDataTransformer.transform(data, spec);
if (transformedData == null || transformedData.isEmpty()) return "{}";
try {
return jsonMapper.writeValueAsString(transformedData);
} catch (Exception e) {
log.error(" JSON 변환 에러: {}", e.getMessage());
return "{}";
}
}
}

View File

@@ -0,0 +1,57 @@
package io.shinhanlife.axhub.biz.mcp.adapter.connector;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.Map;
@Slf4j
@Service
@RequiredArgsConstructor
public class ThirdPartySecurityConnector {
private final ObjectMapper jsonMapper;
/**
* 3rd Party 보안 모듈(DRM, BM 등)과 연동하기 위한 전용 메서드입니다.
*/
public String executeSecurityModule(String interfaceId, Map<String, Object> data) throws Exception {
log.info("🔐 [3rd Party Security] 보안 모듈 연동 시작 - Interface: {}", interfaceId);
// 보안 모듈 통신을 위한 특수 페이로드 조립 (예시)
// 실제로는 RestTemplate이나 WebClient를 통해 보안 VM의 전용 엔드포인트로 호출합니다.
String resultJson;
if (interfaceId.startsWith("DRM_")) {
log.info(" [DRM 처리] 내부 문서 암/복호화 모듈과 통신 중...");
resultJson = jsonMapper.writeValueAsString(Map.of(
"status", "SUCCESS",
"module", "DRM",
"message", "문서 보안 처리가 완료되었습니다.",
"interfaceId", interfaceId,
"data", data != null ? data : Map.of()
));
} else if (interfaceId.startsWith("BM_")) {
log.info("👆 [BM 처리] 바이오 인증 모듈과 통신 중...");
resultJson = jsonMapper.writeValueAsString(Map.of(
"status", "SUCCESS",
"module", "Bio-Metric",
"message", "바이오 인증이 완료되었습니다.",
"interfaceId", interfaceId,
"data", data != null ? data : Map.of()
));
} else {
log.warn(" 알 수 없는 보안 모듈 연동 요청: {}", interfaceId);
resultJson = jsonMapper.writeValueAsString(Map.of(
"status", "UNKNOWN_MODULE",
"message", "알 수 없는 보안 모듈 인터페이스입니다."
));
}
log.info(" [3rd Party Security] 보안 모듈 처리 완료");
return resultJson;
}
}

View File

@@ -0,0 +1,90 @@
package io.shinhanlife.axhub.biz.mcp.adapter.sender;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.dataformat.xml.XmlMapper;
import io.shinhanlife.axhub.biz.mcp.adapter.support.TicketManager;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.stereotype.Service;
import org.springframework.util.StopWatch;
@Slf4j
@Service("eaiEimsSender")
@RequiredArgsConstructor
public class EaiEimsSender implements EimsSender {
private final ObjectMapper jsonMapper;
private final XmlMapper xmlMapper;
// 실전 코드: 스프링이 제공하는 카프카 템플릿 주입
private final KafkaTemplate<String, String> kafkaTemplate;
// 🎫 비동기 티켓 매니저
private final TicketManager ticketManager;
@Override
public String send(String interfaceId, String jsonPayload) throws Exception {
StopWatch stopWatch = new StopWatch(); stopWatch.start();
try {
JsonNode jsonNode = jsonMapper.readTree(jsonPayload);
String xmlData = xmlMapper.writeValueAsString(jsonNode);
String esbStandardXml = wrapWithEaiHeader(interfaceId, xmlData);
log.info("📦 [EAI 어댑터] Kafka 토픽(eai-topic)으로 전송 시도...");
try {
// 실전 코드 적용: Kafka로 메시지 발행
kafkaTemplate.send("eai-topic", esbStandardXml);
log.info("📦 [EAI 어댑터] Kafka 전송 완료!");
} catch (Exception e) {
// 로컬 환경에는 카프카가 없으므로 에러가 날 수 있습니다. 테스트를 위해 로깅만 하고 넘깁니다.
log.warn(" 로컬 환경이거나 Ka<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\"?>\n" +
"<EaiMessage>\n" +
" <Header>\n" +
" <ChannelId>MCP_GATEWAY_ASYNC</ChannelId>\n" +
" <InterfaceId>EAI_BATCH_JOB</InterfaceId>\n" +
" <Timestamp>1782705901850</Timestamp>\n" +
" <TransferType>ASYNC</TransferType>\n" +
" </Header>\n" +
" <Body>\n" +
" <ObjectNode>\n" +
" <batchId>BATCH_20260629_001</batchId>\n" +
" <targetSystem>GLOBAL_MINIMUM_TAX_SYS</targetSystem>\n" +
" <recordCount>50000</recordCount>\n" +
" </ObjectNode>\n" +
" </Body>\n" +
"</EaiMessage>fka 서버에 연결할 수 없습니다. (메시지 출력으로 대체합니다) \n전송하려던 메시지: {}", esbStandardXml);
}
// 비동기 폴링을 위한 티켓 발급
String ticketId = ticketManager.issueTicket(interfaceId, jsonPayload);
return String.format(
"{\"status\":\"PROCESSING\", \"interfaceId\":\"%s\", \"ticketId\":\"%s\", \"message\":\"비동기 작업이 접수되었습니다. 상태 조회 API를 통해 결과를 확인하세요.\"}",
interfaceId, ticketId);
} finally {
stopWatch.stop();
log.info("📊 [SLA 모니터링 - EAI] 소요시간: {} ms", stopWatch.getTotalTimeMillis());
}
}
private String wrapWithEaiHeader(String interfaceId, String xmlData) {
// 비동기 EAI는 추적을 위해 TransferType이나 Batch ID 같은 속성이 추가로 들어가는 경우가 많습니다.
return String.format(
"<EaiMessage>" +
"<Header>" +
"<ChannelId>MCP_GATEWAY_ASYNC</ChannelId>" +
"<InterfaceId>%s</InterfaceId>" +
"<Timestamp>%d</Timestamp>" +
"<TransferType>ASYNC</TransferType>" +
"</Header>" +
"<Body>%s</Body>" +
"</EaiMessage>",
interfaceId, System.currentTimeMillis(), xmlData
);
}
}

View File

@@ -0,0 +1,6 @@
package io.shinhanlife.axhub.biz.mcp.adapter.sender;
public interface EimsSender {
// 프로토콜에 상관없이 이 메서드 하나로 통일합니다.
String send(String interfaceId, String payload) throws Exception;
}

View File

@@ -0,0 +1,45 @@
package io.shinhanlife.axhub.biz.mcp.adapter.sender;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
import java.util.Map;
@Slf4j
@Component
public class HttpEimsSender implements EimsSender {
private final RestClient restClient;
private final String eimsUrl;
public HttpEimsSender(@Value("${eims.http.url}") String eimsUrl) {
this.eimsUrl = eimsUrl;
this.restClient = RestClient.builder().build(); // 필요시 타임아웃 팩토리 추가
}
@Override
public String send(String interfaceId, String payload) {
log.info("🌐 [HTTP 모드] EIMS API 호출 중... URL: {}", eimsUrl);
// EIMS가 요구하는 JSON 포맷으로 래핑해서 전송 (EIMS 규격에 따라 수정 가능)
Map<String, String> requestBody = Map.of(
"interfaceId", interfaceId,
"data", payload
);
// 📊 4번 항목 적용: MDC에 저장된 traceId를 추출하여 HTTP Header(X-Trace-Id)로 전파
String traceId = org.slf4j.MDC.get("traceId");
if (traceId == null) traceId = "SYSTEM-GENERATED-" + java.util.UUID.randomUUID().toString();
return restClient.post()
.uri(eimsUrl)
.header("X-Trace-Id", traceId)
.header("X-Shinhan-Global-ID", traceId)
.body(requestBody)
.retrieve()
.body(String.class); // 응답 결과를 String으로 받음
}
}

View File

@@ -0,0 +1,44 @@
package io.shinhanlife.axhub.biz.mcp.adapter.sender;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.client.RestClient;
@Slf4j
@Component
public class JspFormEimsSender implements EimsSender {
private final RestClient restClient;
private final String jspUrl;
public JspFormEimsSender(@Value("${eims.jsp.form.url}") String jspUrl) {
this.jspUrl = jspUrl;
this.restClient = RestClient.builder().build();
}
@Override
public String send(String interfaceId, String payload) {
log.info("🌐 [JSP Form 모드] 레거시 폼 데이터 전송 중... URL: {}", jspUrl);
// 1. Form Data 조립 (HTML <form> 태그 전송과 동일한 효과)
MultiValueMap<String, String> formData = new LinkedMultiValueMap<>();
formData.add("interfaceId", interfaceId);
formData.add("data", payload);
// 2. HTTP 전송 (Content-Type: application/x-www-form-urlencoded)
String rawResponse = restClient.post()
.uri(jspUrl)
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.body(formData)
.retrieve()
.body(String.class);
// 3. JSP 특유의 앞뒤 공백 및 엔터 제거
return rawResponse != null ? rawResponse.trim() : "";
}
}

View File

@@ -0,0 +1,45 @@
package io.shinhanlife.axhub.biz.mcp.adapter.sender;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
import java.util.Map;
@Slf4j
@Component
public class JspJsonEimsSender implements EimsSender {
private final RestClient restClient;
private final String jspUrl;
// 여기서 eims.jsp.json.url 딱 하나만 깔끔하게 받아옵니다!
public JspJsonEimsSender(@Value("${eims.jsp.json.url}") String jspUrl) {
this.jspUrl = jspUrl;
this.restClient = RestClient.builder().build();
}
@Override
public String send(String interfaceId, String payload) {
log.info("🌐 [JSP JSON 모드] JSON 페이로드 전송 중... URL: {}", jspUrl);
// 1. JSON 객체로 조립 (스프링이 알아서 JSON String으로 변환해 줌)
Map<String, String> jsonBody = Map.of(
"interfaceId", interfaceId,
"data", payload
);
// 2. HTTP 전송 (Content-Type: application/json)
String rawResponse = restClient.post()
.uri(jspUrl)
.contentType(MediaType.APPLICATION_JSON)
.body(jsonBody)
.retrieve()
.body(String.class);
// 3. JSP 응답 정제
return rawResponse != null ? rawResponse.trim() : "";
}
}

View File

@@ -0,0 +1,61 @@
package io.shinhanlife.axhub.biz.mcp.adapter.sender;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.dataformat.xml.XmlMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Service;
import org.springframework.util.StopWatch;
import org.springframework.web.client.RestClient;
@Slf4j
@Service("mciEimsSender")
public class MciEimsSender implements EimsSender {
private final ObjectMapper jsonMapper;
private final XmlMapper xmlMapper;
private final RestClient restClient; // Spring Boot 3.2+ 최신 HTTP 클라이언트
private final String mciUrl;
public MciEimsSender(ObjectMapper jsonMapper, XmlMapper xmlMapper, @Value("${eims.mci.url}") String mciUrl) {
this.jsonMapper = jsonMapper;
this.xmlMapper = xmlMapper;
this.mciUrl = mciUrl;
this.restClient = RestClient.create(); // 클라이언트 초기화
}
@Override
public String send(String interfaceId, String jsonPayload) throws Exception {
StopWatch stopWatch = new StopWatch(); stopWatch.start();
try {
JsonNode jsonNode = jsonMapper.readTree(jsonPayload);
String xmlData = xmlMapper.writer().withRootName("Body").writeValueAsString(jsonNode);
String esbStandardXml = wrapWithEsbHeader(interfaceId, xmlData);
log.info("🌐 [ESB 어댑터] 전송 준비 완료 - RestClient 호출 시작");
String responseXml = restClient.post()
.uri(mciUrl)
.contentType(MediaType.APPLICATION_XML)
.body(esbStandardXml)
.retrieve()
.body(String.class);
log.info("🌐 [ESB 어댑터] 응답 수신 완료: {}", responseXml);
JsonNode responseNode = xmlMapper.readTree(responseXml);
return jsonMapper.writeValueAsString(responseNode);
} finally {
stopWatch.stop();
log.info("📊 [SLA 모니터링 - MCI] 소요시간: {} ms", stopWatch.getTotalTimeMillis());
}
}
private String wrapWithEsbHeader(String interfaceId, String xmlData) {
return String.format("<EsbMessage><Header><InterfaceId>%s</InterfaceId></Header><Body>%s</Body></EsbMessage>", interfaceId, xmlData);
}
}

View File

@@ -0,0 +1,57 @@
package io.shinhanlife.axhub.biz.mcp.adapter.sender;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.net.Socket;
@Slf4j
@Component
public class TcpEimsSender implements EimsSender {
@Value("${eims.tcp.host}")
private String host;
@Value("${eims.tcp.port}")
private int port;
@Value("${eims.tcp.timeout}")
private int timeout;
@Override
public String send(String interfaceId, String payload) throws Exception {
log.info("🔌 [TCP 소켓 모드] EIMS 접속 중... {}:{}", host, port);
// TCP 소켓 자원을 사용 후 안전하게 닫아주는 try-with-resources 구문
try (Socket socket = new Socket()) {
// 1. 타임아웃 및 연결 설정
socket.connect(new InetSocketAddress(host, port), timeout);
socket.setSoTimeout(timeout); // 읽기 타임아웃
OutputStream os = socket.getOutputStream();
InputStream is = socket.getInputStream();
// 2. 데이터 송신 (EUC-KR 인코딩 필수)
// 보통 금융권 TCP 통신은 맨 앞에 전체 길이나 인터페이스 ID를 헤더로 붙입니다.
String sendData = interfaceId + payload;
os.write(sendData.getBytes("EUC-KR"));
os.flush();
// 3. 데이터 수신
byte[] buffer = new byte[4096];
int readByte = is.read(buffer);
if (readByte == -1) {
throw new RuntimeException("EIMS 서버가 응답 없이 연결을 종료했습니다.");
}
// 받은 바이트를 다시 한글(EUC-KR) 문자열로 복원
return new String(buffer, 0, readByte, "EUC-KR");
}
}
}

View File

@@ -0,0 +1,45 @@
package io.shinhanlife.axhub.biz.mcp.adapter.support;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Map;
@Service
public class DynamicPayloadBuilder {
public String buildFixedLengthString(List<Map<String, Object>> specList, Map<String, Object> data) throws Exception {
StringBuilder sb = new StringBuilder();
for (Map<String, Object> spec : specList) {
String name = (String) spec.get("name");
// EIMS가 고정장을 요구할 경우를 대비한 len 파라미터 체크 (기본값 0 방어)
int len = spec.get("len") != null ? (Integer) spec.get("len") : 0;
String type = (String) spec.get("type");
String rawValue = String.valueOf(data.getOrDefault(name, ""));
// len 값이 없으면 변환 없이 바로 이어붙임 (JSON 통신용)
if (len == 0) {
sb.append(rawValue);
continue;
}
// len 값이 있으면 고정장 통신 규칙 적용
byte[] rawBytes = rawValue.getBytes("EUC-KR");
if (rawBytes.length > len) {
throw new IllegalArgumentException(name + " 길이가 " + len + " 바이트를 초과합니다.");
}
int padLength = len - rawBytes.length;
StringBuilder paddedValue = new StringBuilder(rawValue);
if ("NUMBER".equalsIgnoreCase(type)) {
for (int i = 0; i < padLength; i++) paddedValue.insert(0, "0");
} else {
for (int i = 0; i < padLength; i++) paddedValue.append(" ");
}
sb.append(paddedValue.toString());
}
return sb.toString();
}
}

View File

@@ -0,0 +1,33 @@
package io.shinhanlife.axhub.biz.mcp.adapter.support;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Map;
@Service
public class DynamicSchemaValidator {
public boolean validate(List<Map<String, Object>> specList, Map<String, Object> data, StringBuilder errorLog) {
for (Map<String, Object> spec : specList) {
String name = (String) spec.get("name");
String type = (String) spec.get("type");
boolean isRequired = spec.get("required") != null && (Boolean) spec.get("required");
Object value = data.get(name);
// 1. 필수값 체크
if (isRequired && (value == null || String.valueOf(value).trim().isEmpty())) {
errorLog.append(String.format("[%s] 필드는 필수 입력 항목입니다. ", name));
return false;
}
// 2. 타입 체크 (값이 있을 때만)
if (value != null && !String.valueOf(value).trim().isEmpty()) {
if ("NUMBER".equalsIgnoreCase(type) && !String.valueOf(value).matches("-?\\d+(\\.\\d+)?")) {
errorLog.append(String.format("[%s] 필드는 숫자여야 합니다. ", name));
return false;
}
}
}
return true;
}
}

View File

@@ -0,0 +1,39 @@
package io.shinhanlife.axhub.biz.mcp.adapter.support;
import org.springframework.stereotype.Component;
import java.util.HashMap;
import java.util.Map;
@Component
public class ResultStandardizer {
// 오라클 조회 결과(Map)의 키 값을 카멜 케이스로 변환 (스키마 매핑)
public Map<String, Object> standardize(Map<String, Object> rawData) {
Map<String, Object> standardMap = new HashMap<>();
rawData.forEach((key, value) -> {
String camelKey = convertToCamelCase(key);
standardMap.put(camelKey, value);
});
return standardMap;
}
private String convertToCamelCase(String snakeCase) {
if (snakeCase == null || snakeCase.isEmpty()) return snakeCase;
StringBuilder result = new StringBuilder();
boolean nextIsUpper = false;
for (char c : snakeCase.toLowerCase().toCharArray()) {
if (c == '_') {
nextIsUpper = true;
} else {
result.append(nextIsUpper ? Character.toUpperCase(c) : c);
nextIsUpper = false;
}
}
return result.toString();
}
}

View File

@@ -0,0 +1,83 @@
package io.shinhanlife.axhub.biz.mcp.adapter.support;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
@Slf4j
@Component
public class TicketManager {
// 로컬 시뮬레이션을 위한 인메모리 저장소 (운영 환경에서는 Redis 등을 사용)
private final Map<String, Ticket> ticketStore = new ConcurrentHashMap<>();
// 가짜 EAI 작업(10초 대기)을 실행할 백그라운드 쓰레드 풀
private final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(5);
/**
* 새로운 비동기 작업을 접수하고 티켓을 발급합니다.
*/
public String issueTicket(String interfaceId, String payload) {
String ticketId = "TICKET-" + UUID.randomUUID().toString();
Ticket ticket = new Ticket();
ticket.setTicketId(ticketId);
ticket.setStatus("PROCESSING");
ticket.setMessage("EAI 시스템에서 데이터 처리 중입니다...");
ticketStore.put(ticketId, ticket);
log.info("🎫 [TicketManager] 비동기 작업 티켓 발급 완료: {}", ticketId);
// 10초 뒤에 자동으로 작업을 완료 상태로 변경하는 백그라운드 시뮬레이터 실행
simulateEaiProcessing(ticketId, interfaceId);
return ticketId;
}
/**
* 특정 티켓의 현재 상태를 조회합니다.
*/
public Ticket getTicketStatus(String ticketId) {
return ticketStore.getOrDefault(ticketId, new Ticket("NOT_FOUND", "해당 티켓을 찾을 수 없습니다."));
}
/**
* 10초 뒤에 상태를 COMPLETED로 변경하여 진짜 EAI 배치가 끝난 것처럼 흉내냅니다.
*/
private void simulateEaiProcessing(String ticketId, String interfaceId) {
scheduler.schedule(() -> {
Ticket ticket = ticketStore.get(ticketId);
if (ticket != null) {
ticket.setStatus("COMPLETED");
ticket.setMessage("EAI 배치가 정상적으로 완료되었습니다.");
// 가짜 최종 결과 데이터 주입
ticket.setResultData("{\"resultCode\":\"0000\", \"interfaceId\":\"" + interfaceId + "\", \"processedRecords\":50000}");
ticketStore.put(ticketId, ticket);
log.info("🏁 [TicketManager] EAI 비동기 작업 시뮬레이션 완료! (Ticket: {})", ticketId);
}
}, 10, TimeUnit.SECONDS); // 10초 지연
}
// 티켓 상태를 담을 내부 DTO 클래스
@lombok.Data
public static class Ticket {
private String ticketId;
private String status; // PROCESSING, COMPLETED, FAILED, NOT_FOUND
private String message;
private String resultData; // 최종 완료 시 담길 데이터
public Ticket() {}
public Ticket(String status, String message) {
this.status = status;
this.message = message;
}
}
}

View File

@@ -0,0 +1,76 @@
package io.shinhanlife.axhub.biz.mcp.adapter.support;
import io.shinhanlife.axhub.biz.mcp.adapter.connector.LegacyEimsConnector;
import io.shinhanlife.axhub.biz.mcp.adapter.dto.ErrorDetail;
import io.shinhanlife.axhub.biz.mcp.adapter.dto.JsonRpcRequest;
import io.shinhanlife.axhub.biz.mcp.adapter.dto.JsonRpcResponse;
import io.shinhanlife.axhub.biz.mcp.adapter.dto.Params;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.HashMap;
import java.util.Map;
@Slf4j
@Service
@RequiredArgsConstructor
public class ToolExecutionService {
// 방어막(서킷/캐시)이 적용된 커넥터 주입
private final LegacyEimsConnector legacyEimsConnector;
/**
* AI Agent가 호출한 툴을 실제 레거시 커넥터로 전달합니다.
*/
public JsonRpcResponse executeTool(JsonRpcRequest request) {
Params params = request.getParams();
// 1. 필수 파라미터 검증
if (params == null || params.getName() == null) {
return createErrorResponse(request.getId(), -32602, "Invalid params: 'name' is required");
}
try {
// 2. Connector의 executeByTool 메서드 호출
// [참고] connector에 선언된 파라미터 구조에 맞게 매핑합니다.
String result = legacyEimsConnector.executeByTool(
params.getRoutingType(),
params.getInterfaceId(),
params.getData(),
params.getSpec()
);
// 3. 성공 응답 생성 (result가 JSON 문자열일 경우, 실제 DTO로 변환하여 반환하면 더 좋습니다)
return createSuccessResponse(request.getId(), result);
} catch (Exception e) {
log.error(" [ToolExecution] 커넥터 호출 실패: RoutingType={}, Error={}", params.getRoutingType(), e.getMessage());
return createErrorResponse(request.getId(), -32000, "Connector error: " + e.getMessage());
}
}
/**
* AI Agent를 위한 툴 목록 스키마 반환
*/
public JsonRpcResponse getToolList(String requestId) {
// 기존에 정의한 Tool 스키마 리스트 반환 로직...
Map<String, Object> result = new HashMap<>();
// ... (Tool 명세 내용)
return createSuccessResponse(requestId, result);
}
private JsonRpcResponse createSuccessResponse(String id, Object result) {
JsonRpcResponse response = new JsonRpcResponse();
response.setId(id);
response.setResult(result);
return response;
}
private JsonRpcResponse createErrorResponse(String id, int code, String message) {
JsonRpcResponse response = new JsonRpcResponse();
response.setId(id);
response.setError(new ErrorDetail(code, message));
return response;
}
}

View File

@@ -0,0 +1,60 @@
package io.shinhanlife.axhub.biz.mcp.adapter.test;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.core.io.ClassPathResource;
import java.util.Map;
@Slf4j
@RestController
@RequestMapping("/api")
public class MockEimsHttpServer {
private final ObjectMapper objectMapper;
public MockEimsHttpServer(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}
@PostMapping("/gateway")
public Map<String, Object> mockEimsReceiver(
@RequestHeader(value = "X-Trace-Id", required = false) String traceId,
@RequestBody Map<String, Object> request) {
String interfaceId = (String) request.get("interfaceId");
String data = (String) request.get("data");
log.info(" [가짜 EIMS 서버] HTTP 요청 수신 완료!");
log.info(" 수신된 Trace-ID (거래고유번호): {}", traceId != null ? traceId : "없음");
log.info(" 인터페이스ID: {}, 데이터: [{}]", interfaceId, data);
// No-Code Mock 응답 생성 (JSON 설정 파일 기반)
try {
ClassPathResource resource = new ClassPathResource("mock-responses.json");
Map<String, Object> mockDataMap = objectMapper.readValue(resource.getInputStream(), Map.class);
if (mockDataMap.containsKey(interfaceId)) {
return (Map<String, Object>) mockDataMap.get(interfaceId);
}
} catch (Exception e) {
log.warn("JSON 파싱 에러 또는 mock 파일 읽기 실패 (기본 응답 반환)", e);
}
// 기본 응답
return Map.of(
"status", "404",
"message", "MOCK 데이터가 정의되지 않았습니다.",
"receivedLength", data.length()
);
}
@PostMapping("/mock/esb/api")
public String mockEsbReceiver(@RequestBody String xmlPayload) {
log.info(" [가짜 ESB 서버] MCI/ESB 요청 수신 완료!");
log.info(" 수신된 XML 전문: {}", xmlPayload);
// MciEimsSender가 기대하는 JSON 변환용 XML 포맷 응답
return "<Response><status>SUCCESS</status><message>MOCK_MCI_EIMS_RECEIVE_SUCCESS</message><data><info>정상 처리되었습니다.</info></data></Response>";
}
}

View File

@@ -0,0 +1,116 @@
package io.shinhanlife.axhub.biz.mcp.adapter.test;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.ServerSocket;
import java.net.Socket;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.core.io.ClassPathResource;
import java.util.Map;
@Slf4j
@Component
// 대신 "local" 환경(application-local.properties)에서만 가짜 소켓 서버가 켜지도록 보장합니다.
@Profile("local")
public class MockEimsTcpServer {
private final ObjectMapper objectMapper;
public MockEimsTcpServer(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}
@Value("${eims.tcp.port:8090}")
private int port;
@Value("${server.port:8081}")
private int serverPort;
private ServerSocket serverSocket;
private boolean running = true;
@EventListener(ApplicationReadyEvent.class)
public void startTcpServer() {
if (serverPort != 8081) {
log.info(" [가짜 EIMS 서버] Gateway 포트(8081)가 아니므로 EIMS TCP 서버를 중복해서 띄우지 않습니다.");
return;
}
// Java 21 가상 스레드를 사용하여 메인 서버 가동에 방해 없이 백그라운드에서 실행
Thread.ofVirtual().start(() -> {
try {
serverSocket = new ServerSocket(port);
log.info(" [가짜 EIMS 서버] 로컬 TCP 소켓 서버 가동 완료 (Port: {})", port);
while (running) {
Socket clientSocket = serverSocket.accept();
// 연결된 요청을 별도 스레드로 처리
Thread.ofVirtual().start(() -> handleClient(clientSocket));
}
} catch (Exception e) {
if (running) log.error(" 가짜 TCP 서버 에러: {}", e.getMessage());
}
});
}
private void handleClient(Socket socket) {
try (socket) {
InputStream is = socket.getInputStream();
OutputStream os = socket.getOutputStream();
byte[] buffer = new byte[4096];
int readByte = is.read(buffer);
if (readByte != -1) {
String receivedData = new String(buffer, 0, readByte, "EUC-KR");
log.info(" [가짜 EIMS 서버] TCP 데이터 수신 완료!");
log.info(" 수신된 전문: [{}]", receivedData);
String responseData = "{\"status\":\"404\",\"message\":\"MOCK 데이터가 정의되지 않았습니다.\"}";
try {
int braceIndex = receivedData.indexOf('{');
String interfaceId = "";
if (braceIndex > 0) {
interfaceId = receivedData.substring(0, braceIndex).trim();
} else if (braceIndex == -1) {
interfaceId = receivedData.trim();
}
ClassPathResource resource = new ClassPathResource("mock-responses.json");
Map<String, Object> mockDataMap = objectMapper.readValue(resource.getInputStream(), Map.class);
if (mockDataMap.containsKey(interfaceId)) {
responseData = objectMapper.writeValueAsString(mockDataMap.get(interfaceId));
}
} catch (Exception e) {
log.warn("TCP JSON 파싱 에러 또는 파일 읽기 실패", e);
}
// 응답 데이터 송신
os.write(responseData.getBytes("EUC-KR"));
os.flush();
}
} catch (Exception e) {
log.error(" 가짜 TCP 클라이언트 처리 에러: {}", e.getMessage());
}
}
@PreDestroy
public void stopTcpServer() {
this.running = false;
try {
if (serverSocket != null) serverSocket.close();
} catch (Exception ignored) {}
}
}

View File

@@ -0,0 +1,44 @@
package io.shinhanlife.axhub.biz.mcp.adapter.test;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
@Slf4j
@RestController
@RequestMapping("/mock")
public class MockJspServer {
// ==========================================
// 1. Form Data 방식 테스트 수신부 (jsp-form)
// ==========================================
@PostMapping(value = "/jsp-form", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE)
public String mockJspFormReceiver(
@RequestParam("interfaceId") String interfaceId,
@RequestParam("data") String data) {
log.info(" [가짜 JSP 서버] 폼 데이터(Form) 수신 완료!");
log.info(" 파라미터 파싱 확인 - ID: {}, DATA: [{}]", interfaceId, data);
// 실제 JSP 서버처럼 앞뒤에 의미 없는 줄바꿈(엔터)과 공백을 잔뜩 넣어서 리턴합니다.
return "\n\n SUCCESS_FROM_MOCK_JSP_FORM \n\n";
}
// ==========================================
// 2. JSON 방식 테스트 수신부 (jsp-json)
// ==========================================
@PostMapping(value = "/jsp-json", consumes = MediaType.APPLICATION_JSON_VALUE)
public String mockJspJsonReceiver(@RequestBody Map<String, String> request) {
String interfaceId = request.get("interfaceId");
String data = request.get("data");
log.info(" [가짜 JSP 서버] 제이슨(JSON) 수신 완료!");
log.info(" JSON 파싱 확인 - ID: {}, DATA: [{}]", interfaceId, data);
// 여기도 마찬가지로 쓰레기 여백을 넣어줍니다.
return "\n\n SUCCESS_FROM_MOCK_JSP_JSON \n\n";
}
}

View File

@@ -0,0 +1,78 @@
package io.shinhanlife.axhub.biz.mcp.adapter.util;
import lombok.extern.slf4j.Slf4j;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@Slf4j
public class LegacyDataTransformer {
/**
* AI 모델이 보낸 자유로운 형태의 data를 레거시 시스템 규격(spec)에 맞게 강제 변환합니다.
*
* @param data AI가 보낸 파라미터 맵
* @param spec 레거시 시스템이 요구하는 파라미터 스펙 (name, type, maxLength, defaultValue, required 등)
* @return 엄격하게 정제된 파라미터 맵
*/
public static Map<String, Object> transform(Map<String, Object> data, List<Map<String, Object>> spec) {
if (spec == null || spec.isEmpty()) {
return data != null ? data : new HashMap<>();
}
Map<String, Object> transformedData = new HashMap<>();
for (Map<String, Object> fieldSpec : spec) {
String fieldName = (String) fieldSpec.get("name");
if (fieldName == null) continue;
Object rawValue = data != null ? data.get(fieldName) : null;
Object finalValue = rawValue;
// 1. 기본값(Default Value) 주입
if (finalValue == null && fieldSpec.containsKey("defaultValue")) {
finalValue = fieldSpec.get("defaultValue");
log.debug(" [DataTransformer] '{}' 필드 누락 -> 기본값 '{}' 주입", fieldName, finalValue);
}
// 2. 강제 형변환 (Type Coercion)
String type = (String) fieldSpec.getOrDefault("type", "string");
if (finalValue != null) {
if ("string".equalsIgnoreCase(type) && !(finalValue instanceof String)) {
finalValue = String.valueOf(finalValue);
log.debug(" [DataTransformer] '{}' 필드 강제 String 형변환", fieldName);
} else if ("number".equalsIgnoreCase(type) && finalValue instanceof String) {
try {
finalValue = Long.parseLong((String) finalValue);
log.debug(" [DataTransformer] '{}' 필드 강제 Number 형변환", fieldName);
} catch (NumberFormatException e) {
log.warn(" [DataTransformer] '{}' 필드 Number 형변환 실패. 기존 값 유지", fieldName);
}
}
}
// 3. 길이 제한 (Truncation / MaxLength)
if (finalValue instanceof String && fieldSpec.containsKey("maxLength")) {
int maxLength = (Integer) fieldSpec.get("maxLength");
String strVal = (String) finalValue;
if (strVal.length() > maxLength) {
finalValue = strVal.substring(0, maxLength);
log.warn(" [DataTransformer] '{}' 필드 길이 초과! {}자로 강제 자름 (Truncated)", fieldName, maxLength);
}
}
// 4. 필수값(Required) 누락 체크 (에러를 던지지 않고 빈 문자열 강제 주입하여 레거시 팅김 방지)
boolean isRequired = (Boolean) fieldSpec.getOrDefault("required", false);
if (isRequired && finalValue == null) {
log.error(" [DataTransformer] 필수 필드 '{}' 누락! 강제 공백 주입하여 시스템 장애 방지", fieldName);
finalValue = "string".equalsIgnoreCase(type) ? "" : 0;
}
if (finalValue != null) {
transformedData.put(fieldName, finalValue);
}
}
return transformedData;
}
}

View File

@@ -0,0 +1,21 @@
package io.shinhanlife.axhub.biz.mcp.adapter.util;
import ch.qos.logback.classic.pattern.MessageConverter;
import ch.qos.logback.classic.spi.ILoggingEvent;
/**
* Logback 커스텀 컨버터
* 모든 로그 메시지(%msg)가 파일이나 콘솔에 찍히기 직전에 이 클래스를 거쳐가게 됩니다.
* 여기서 PiiMaskingUtils.mask()를 호출하여 PII(주민번호, 계좌번호 등)를 안전하게 별표(*) 처리합니다.
*/
public class PiiMaskingLogbackConverter extends MessageConverter {
@Override
public String convert(ILoggingEvent event) {
// 원본 로그 메시지를 가져옵니다.
String originalMessage = super.convert(event);
// 정규식을 이용하여 개인정보가 포함되어 있으면 마스킹 처리하여 반환합니다.
return PiiMaskingUtils.mask(originalMessage);
}
}

View File

@@ -0,0 +1,63 @@
package io.shinhanlife.axhub.biz.mcp.adapter.util;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public class PiiMaskingUtils {
// 1. 주민등록번호 패턴 (ex: 900101-1234567 또는 9001011234567)
private static final Pattern RRN_PATTERN = Pattern.compile("(\\d{6})[-]?([1-4]\\d{6})");
// 2. 휴대전화번호 패턴 (ex: 010-1234-5678)
private static final Pattern PHONE_PATTERN = Pattern.compile("(01[016789])[-]?(\\d{3,4})[-]?(\\d{4})");
// 3. 신한은행 계좌번호 패턴 (단순 숫자 연속 또는 하이픈 조합으로 11~14자리)
private static final Pattern ACCOUNT_PATTERN = Pattern.compile("(\\d{3})[-]?(\\d{3})[-]?(\\d{5,8})");
public static String mask(String input) {
if (input == null || input.isEmpty()) {
return input;
}
String masked = input;
// [1] 주민번호 뒷자리 마스킹 (첫자리 성별 식별자는 남기고 마스킹: 900101-1******)
Matcher rrnMatcher = RRN_PATTERN.matcher(masked);
StringBuffer rrnBuffer = new StringBuffer();
while (rrnMatcher.find()) {
String firstPart = rrnMatcher.group(1);
String secondPart = rrnMatcher.group(2);
rrnMatcher.appendReplacement(rrnBuffer, firstPart + "-" + secondPart.charAt(0) + "******");
}
rrnMatcher.appendTail(rrnBuffer);
masked = rrnBuffer.toString();
// [2] 전화번호 중간자리 마스킹 (010-****-5678)
Matcher phoneMatcher = PHONE_PATTERN.matcher(masked);
StringBuffer phoneBuffer = new StringBuffer();
while (phoneMatcher.find()) {
String p1 = phoneMatcher.group(1);
String p2 = phoneMatcher.group(2);
String p3 = phoneMatcher.group(3);
String maskedP2 = p2.replaceAll(".", "*");
phoneMatcher.appendReplacement(phoneBuffer, p1 + "-" + maskedP2 + "-" + p3);
}
phoneMatcher.appendTail(phoneBuffer);
masked = phoneBuffer.toString();
// [3] 계좌번호 뒷자리 마스킹 (110-123-********)
Matcher accMatcher = ACCOUNT_PATTERN.matcher(masked);
StringBuffer accBuffer = new StringBuffer();
while (accMatcher.find()) {
String a1 = accMatcher.group(1);
String a2 = accMatcher.group(2);
String a3 = accMatcher.group(3);
String maskedA3 = a3.replaceAll(".", "*");
accMatcher.appendReplacement(accBuffer, a1 + "-" + a2 + "-" + maskedA3);
}
accMatcher.appendTail(accBuffer);
masked = accBuffer.toString();
return masked;
}
}

View File

@@ -0,0 +1,16 @@
package io.shinhanlife.axhub.biz.mcp.tool.annotation;
import java.lang.annotation.*;
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface McpFunction {
String name();
String description();
String prompt() default "";
String mappingId() default "";
// 추가: 해당 함수가 요구하는 비즈니스 파라미터(JSON 형태의 properties)를 정의
String parameterSchema() default "{}";
}

View File

@@ -0,0 +1,11 @@
package io.shinhanlife.axhub.biz.mcp.tool.annotation;
import java.lang.annotation.*;
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface McpParameter {
String description();
boolean required() default false;
}

View File

@@ -0,0 +1,20 @@
package io.shinhanlife.axhub.biz.mcp.tool.annotation;
import org.springframework.core.annotation.AliasFor;
import org.springframework.stereotype.Component;
import java.lang.annotation.*;
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Component
public @interface McpTool {
@AliasFor(annotation = Component.class)
String value() default "";
String name();
String description();
String group() default "biz_core";
String routingType() default "HTTP";
}

View File

@@ -0,0 +1,159 @@
package io.shinhanlife.axhub.biz.mcp.tool.controller;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
import java.util.List;
import java.util.ArrayList;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.networknt.schema.JsonSchema;
import com.networknt.schema.JsonSchemaFactory;
import com.networknt.schema.SpecVersion;
import com.networknt.schema.ValidationMessage;
import org.springframework.context.ApplicationContext;
import io.shinhanlife.axhub.biz.mcp.tool.annotation.McpTool;
import io.shinhanlife.axhub.biz.mcp.tool.annotation.McpFunction;
import java.lang.reflect.Method;
import io.shinhanlife.axhub.biz.mcp.tool.util.JsonSchemaGenerator;
import lombok.extern.slf4j.Slf4j;
@Slf4j
@RestController
@RequestMapping("/api/tool")
@RequiredArgsConstructor
public class BusinessToolController {
private final ApplicationContext applicationContext;
private final ObjectMapper objectMapper;
// 함수명(functionName) 기반의 단일 동적 라우팅 엔드포인트
@PostMapping("/execute/{functionName}")
public Map<String, Object> executeDynamicTool(@PathVariable String functionName, @RequestBody(required = false) Map<String, Object> payload) {
log.info("\n [Tool] 동적 툴 실행 요청 수신 (함수명): {}", functionName);
if (payload != null) {
try {
log.info(" [Tool] 호출 파라미터: {}", objectMapper.writeValueAsString(payload));
} catch (Exception e) {
log.info(" [Tool] 호출 파라미터: {}", payload);
}
}
Map<String, Object> params = payload != null ? (Map<String, Object>) payload.get("params") : null;
Map<String, Object> arguments = params != null ? (Map<String, Object>) params.get("arguments") : null;
// 1. 대상 Bean 및 Method 찾기 (ApplicationContext 활용)
Object targetBean = null;
Method targetMethod = null;
McpFunction targetFunctionAnnotation = null;
Map<String, Object> toolBeans = applicationContext.getBeansWithAnnotation(McpTool.class);
outerLoop:
for (Object bean : toolBeans.values()) {
for (Method method : bean.getClass().getDeclaredMethods()) {
McpFunction mcpFunc = method.getAnnotation(McpFunction.class);
if (mcpFunc != null && mcpFunc.name().equals(functionName)) {
targetBean = bean;
targetMethod = method;
targetFunctionAnnotation = mcpFunc;
break outerLoop;
}
}
}
if (targetBean == null || targetMethod == null) {
log.error("[Tool] 실행할 함수(Method)를 찾을 수 없습니다: {}", functionName);
Map<String, Object> error = new HashMap<>();
error.put("jsonrpc", "2.0");
error.put("error", Map.of("code", -32601, "message", "실행할 함수를 찾을 수 없습니다: " + functionName));
error.put("id", payload != null ? payload.get("id") : null);
return error;
}
// 2. 파라미터 유효성 검증 (JSON Schema)
if (targetMethod.getParameterCount() > 0) {
Class<?> paramType = targetMethod.getParameterTypes()[0];
if (!Map.class.isAssignableFrom(paramType)) {
try {
Map<String, Object> autoSchema = JsonSchemaGenerator.generatePropertiesSchema(paramType);
String fullSchemaJson = "{\"type\":\"object\", \"properties\":" + objectMapper.writeValueAsString(autoSchema) + "}";
JsonSchemaFactory factory = JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V7);
JsonSchema schema = factory.getSchema(fullSchemaJson);
Set<ValidationMessage> errors = schema.validate(objectMapper.valueToTree(arguments));
if (!errors.isEmpty()) {
log.error("[Tool] 파라미터 유효성 검증 실패: {}", errors);
Map<String, Object> error = new HashMap<>();
error.put("jsonrpc", "2.0");
List<String> errorMessages = new ArrayList<>();
for (ValidationMessage vm : errors) {
errorMessages.add(vm.getMessage());
}
error.put("error", Map.of("code", -32602, "message", "파라미터 유효성 검증 실패: " + String.join(", ", errorMessages)));
error.put("id", payload != null ? payload.get("id") : null);
return error;
}
} catch (Exception e) {
log.error("[Tool] 스키마 검증 중 오류 발생: {}", e.getMessage());
}
}
}
log.info("[Tool] 리플렉션 직접 실행 -> Method: {}", targetMethod.getName());
try {
// 3. DTO 파라미터 자동 매핑 (Map -> DTO)
Object invokeArgument = arguments;
if (targetMethod.getParameterCount() > 0 && arguments != null) {
Class<?> paramType = targetMethod.getParameterTypes()[0];
if (!Map.class.isAssignableFrom(paramType)) {
invokeArgument = objectMapper.convertValue(arguments, paramType);
log.info("[Tool] DTO 자동 매핑 성공: {}", paramType.getSimpleName());
}
}
// 4. 메서드 실행
Object methodResult = targetMethod.invoke(targetBean, invokeArgument);
// 5. 결과 조립 (JSON-RPC 응답)
Map<String, Object> resultPayload = new HashMap<>();
if (methodResult instanceof Map) {
resultPayload = (Map<String, Object>) methodResult;
} else {
resultPayload.put("status", "SUCCESS");
resultPayload.put("legacy_response", methodResult);
}
resultPayload.put("ai_insight", "다이렉트 메서드(" + targetMethod.getName() + ") 통신이 성공적으로 수행되었습니다.");
Map<String, Object> rpcResponse = new java.util.LinkedHashMap<>(); // 순서 보장을 위해 LinkedHashMap 사용
rpcResponse.put("jsonrpc", "2.0");
rpcResponse.put("result", resultPayload);
rpcResponse.put("id", payload != null ? payload.get("id") : null);
try {
log.info("\n [Tool -> MCP Gateway] 동적 툴 실행 결과 반환: {}", objectMapper.writeValueAsString(rpcResponse));
} catch (Exception e) {
log.info("\n [Tool -> MCP Gateway] 동적 툴 실행 결과 반환: {}", rpcResponse);
}
return rpcResponse;
} catch (Exception e) {
log.error("[Tool] 리플렉션 실행 중 예외 발생: {}", e.getMessage());
Map<String, Object> error = new HashMap<>();
error.put("jsonrpc", "2.0");
error.put("error", Map.of("code", -32000, "message", "메서드 실행 중 예외 발생: " + (e.getCause() != null ? e.getCause().getMessage() : e.getMessage())));
error.put("id", payload != null ? payload.get("id") : null);
return error;
}
}
}

View File

@@ -0,0 +1,58 @@
package io.shinhanlife.axhub.biz.mcp.tool.service;
import io.shinhanlife.axhub.biz.mcp.adapter.connector.LegacyEimsConnector;
import io.shinhanlife.axhub.biz.mcp.adapter.util.PiiMaskingUtils;
import org.springframework.beans.factory.annotation.Autowired;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.core.type.TypeReference;
import java.util.HashMap;
import java.util.Map;
import lombok.extern.slf4j.Slf4j;
@Slf4j
public abstract class AbstractMcpToolService {
@Autowired
protected LegacyEimsConnector legacyEimsConnector;
@Autowired
protected ObjectMapper objectMapper;
/**
* 레거시 시스템을 호출하고 공통 처리(PII 마스킹 등)를 수행합니다.
*/
protected Map<String, Object> executeLegacy(String routingType, String interfaceId, Object inputData) {
Map<String, Object> inputMap;
if (inputData == null) {
inputMap = new HashMap<>();
} else if (inputData instanceof Map) {
@SuppressWarnings("unchecked")
Map<String, Object> map = (Map<String, Object>) inputData;
inputMap = map;
} else {
inputMap = objectMapper.convertValue(inputData, new TypeReference<Map<String, Object>>() {});
}
try {
log.info("\n [Tool -> Adapter] 레거시 실행 요청 - RoutingType: {}, Interface: {}, Data: {}", routingType, interfaceId, inputMap);
// 1. Adapter 공통 모듈 직접 호출
String executionResult = legacyEimsConnector.executeByTool(routingType, interfaceId, inputMap, null);
log.info("\n [Adapter -> Tool] 레거시 실행 응답 수신: {}", executionResult);
// 2. PII 마스킹 처리
String maskedResult = PiiMaskingUtils.mask(executionResult);
Map<String, Object> result = new HashMap<>();
result.put("status", "SUCCESS");
result.put("legacy_response", maskedResult);
return result;
} catch (Exception e) {
Map<String, Object> error = new HashMap<>();
error.put("status", "ERROR");
error.put("message", e.getMessage());
return error;
}
}
}

View File

@@ -0,0 +1,46 @@
package io.shinhanlife.axhub.biz.mcp.tool.util;
import io.shinhanlife.axhub.biz.mcp.tool.annotation.McpParameter;
import java.lang.reflect.Field;
import java.util.HashMap;
import java.util.Map;
public class JsonSchemaGenerator {
/**
* Java DTO 클래스를 분석하여 MCP 규격의 JSON Schema (Properties)를 생성합니다.
*/
public static Map<String, Object> generatePropertiesSchema(Class<?> clazz) {
Map<String, Object> properties = new HashMap<>();
for (Field field : clazz.getDeclaredFields()) {
Map<String, Object> fieldSchema = new HashMap<>();
// 1. 타입 매핑
fieldSchema.put("type", mapJavaTypeToJsonType(field.getType()));
// 2. 어노테이션 기반 설명 추출
McpParameter paramAnnotation = field.getAnnotation(McpParameter.class);
if (paramAnnotation != null && !paramAnnotation.description().isEmpty()) {
fieldSchema.put("description", paramAnnotation.description());
} else {
fieldSchema.put("description", field.getName()); // 기본값
}
properties.put(field.getName(), fieldSchema);
}
return properties;
}
private static String mapJavaTypeToJsonType(Class<?> clazz) {
if (clazz == String.class) return "string";
if (clazz == Integer.class || clazz == int.class) return "integer";
if (clazz == Long.class || clazz == long.class) return "integer";
if (clazz == Double.class || clazz == double.class) return "number";
if (clazz == Float.class || clazz == float.class) return "number";
if (clazz == Boolean.class || clazz == boolean.class) return "boolean";
if (java.util.List.class.isAssignableFrom(clazz)) return "array";
return "object";
}
}

View File

@@ -0,0 +1,139 @@
package io.shinhanlife.axhub.biz.mcp.tool.util;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Scanner;
/**
* MCP Tool 코드를 자동 생성(Scaffolding)하는 유틸리티 클래스
*
* [실행 방법]
* 방법 1. IDE(IntelliJ 등)에서 직접 실행 (대화형 모드 추천 ⭐)
* - 이 클래스(ToolScaffolder.java)를 열고 main 메서드를 직접 실행(Run)합니다.
* - 콘솔 창에 뜨는 질문에 차례대로 값을 입력하기만 하면 파일이 생성됩니다.
*
* 방법 2. 커맨드라인(터미널)에서 실행 (명령어 기반)
* - 컴파일: javac -encoding UTF-8 src/main/java/io/shinhanlife/axhub/biz/mcp/tool/util/ToolScaffolder.java
* - 띄어쓰기를 포함한 인자와 함께 실행: java -cp src/main/java io.shinhanlife.axhub.biz.mcp.tool.util.ToolScaffolder [이름] [ID] "[설명]"
* - 실행 예시: java -cp src/main/java io.shinhanlife.axhub.biz.mcp.tool.util.ToolScaffolder ExchangeRate EXCH_001 "환율 조회 기능"
*/
public class ToolScaffolder {
private static final String BASE_PACKAGE = "io.shinhanlife.axhub.biz.mcp.tool";
// 프로젝트 루트 기준 상대 경로
private static final String BASE_PATH = "src/main/java/io/shinhanlife/axhub/biz/mcp/tool";
public static void main(String[] args) throws IOException {
Scanner scanner = new Scanner(System.in);
System.out.println("=========================================");
System.out.println(" 🛠️ MCP Tool Scaffolder (Java CLI) 🛠️ ");
System.out.println("=========================================\n");
String baseName = getOrAsk(args, 0, scanner, "1. 생성할 Tool의 기본 이름 (예: ExchangeRate) [영문 PascalCase]: ");
String interfaceId = getOrAsk(args, 1, scanner, "2. 레거시 API 인터페이스 ID (예: EXCH_001): ");
String description = getOrAsk(args, 2, scanner, "3. Tool 기능 설명 (예: 환율 조회): ");
String group = getOrAsk(args, 3, scanner, "4. Tool 소속 그룹 (기본: biz_core): ");
if (group.isEmpty()) group = "biz_core";
String routingType = getOrAsk(args, 4, scanner, "5. 통신 프로토콜 (예: HTTP, TCP, MCI): ");
if (routingType.trim().isEmpty()) {
routingType = "HTTP";
}
scaffold(baseName, interfaceId, description, group, routingType);
}
private static String getOrAsk(String[] args, int index, Scanner scanner, String prompt) {
if (args.length > index) {
return args[index];
}
System.out.print(prompt);
return scanner.nextLine().trim();
}
private static void scaffold(String baseName, String interfaceId, String description, String group, String routingType) throws IOException {
Path serviceDir = Paths.get(BASE_PATH, "service");
Path dtoDir = Paths.get(BASE_PATH, "dto");
Files.createDirectories(serviceDir);
Files.createDirectories(dtoDir);
String camelCaseName = baseName.substring(0, 1).toLowerCase() + baseName.substring(1);
String toolName = camelCaseName + "_tool";
// Generate Req DTO
String reqContent = """
package %s.dto;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.Data;
@Data
@JsonInclude(JsonInclude.Include.NON_NULL)
public class %sReq {
// TODO: Add request fields here
}
""".formatted(BASE_PACKAGE, baseName);
Files.writeString(dtoDir.resolve(baseName + "Req.java"), reqContent);
// Generate Res DTO
String resContent = """
package %s.dto;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.Data;
@Data
@JsonInclude(JsonInclude.Include.NON_NULL)
public class %sRes {
private String status;
private String message;
// TODO: Add response fields here
}
""".formatted(BASE_PACKAGE, baseName);
Files.writeString(dtoDir.resolve(baseName + "Res.java"), resContent);
// Generate Service
String serviceContent = """
package %s.service;
import %s.annotation.McpFunction;
import %s.annotation.McpTool;
import %s.dto.%sReq;
import %s.dto.%sRes;
import org.springframework.stereotype.Service;
@Service
@McpTool(
name = "%s",
description = "%s",
group = "%s",
routingType = "%s"
)
public class %sService extends AbstractMcpToolService {
@McpFunction(
name = "execute",
description = "%s 처리",
mappingId = "%s"
)
public Object execute(%sReq req) {
return executeLegacy("%s", "%s", req);
}
}
""".formatted(BASE_PACKAGE, BASE_PACKAGE, BASE_PACKAGE, BASE_PACKAGE, baseName, BASE_PACKAGE, baseName,
toolName, description, group, routingType, baseName, description, interfaceId, baseName, routingType, interfaceId);
Files.writeString(serviceDir.resolve(baseName + "Service.java"), serviceContent);
System.out.println("\n=========================================");
System.out.println("✅ Scaffolding Complete!");
System.out.println("=========================================");
System.out.println("[Service] " + serviceDir.resolve(baseName + "Service.java"));
System.out.println("[Req DTO] " + dtoDir.resolve(baseName + "Req.java"));
System.out.println("[Res DTO] " + dtoDir.resolve(baseName + "Res.java"));
System.out.println("\n💡 팁: " + interfaceId + " 목업 데이터를 mock-responses.json에 추가하세요.");
}
}