refactor: remove redis and switch to in-memory, rollback incomplete Spring AI 2.0 upgrade
All checks were successful
Deploy to OCIWP / deploy (push) Successful in 4m31s

This commit is contained in:
jade
2026-08-13 16:37:30 +09:00
parent 1cb1d90a7e
commit f1ca0d6df1
21 changed files with 1243 additions and 531 deletions

View File

@@ -37,8 +37,8 @@ public class McpGatewayProperties {
private Boolean agentClaimsRequired;
private Boolean trustedClaimsRequired;
private Boolean writeApprovalRequired;
private Boolean redisTraceEnabled;
private Long redisTraceTtlSeconds;
private Boolean traceEnabled;
private Long traceRetentionSize;
private Long toolTimeoutMillis;
private Integer retryMaxAttempts;
private Long retryInitialBackoffMillis;
@@ -70,8 +70,8 @@ public class McpGatewayProperties {
public void setAgentClaimsRequired(Boolean agentClaimsRequired) { this.agentClaimsRequired = agentClaimsRequired; }
public void setTrustedClaimsRequired(Boolean trustedClaimsRequired) { this.trustedClaimsRequired = trustedClaimsRequired; }
public void setWriteApprovalRequired(Boolean writeApprovalRequired) { this.writeApprovalRequired = writeApprovalRequired; }
public void setRedisTraceEnabled(Boolean redisTraceEnabled) { this.redisTraceEnabled = redisTraceEnabled; }
public void setRedisTraceTtlSeconds(Long redisTraceTtlSeconds) { this.redisTraceTtlSeconds = redisTraceTtlSeconds; }
public void setTraceEnabled(Boolean traceEnabled) { this.traceEnabled = traceEnabled; }
public void setTraceRetentionSize(Long traceRetentionSize) { this.traceRetentionSize = traceRetentionSize; }
public void setToolTimeoutMillis(Long toolTimeoutMillis) { this.toolTimeoutMillis = toolTimeoutMillis; }
public void setRetryMaxAttempts(Integer retryMaxAttempts) { this.retryMaxAttempts = retryMaxAttempts; }
public void setRetryInitialBackoffMillis(Long retryInitialBackoffMillis) { this.retryInitialBackoffMillis = retryInitialBackoffMillis; }
@@ -103,8 +103,8 @@ public class McpGatewayProperties {
public boolean agentClaimsRequired() { return agentClaimsRequired == null || agentClaimsRequired; }
public boolean trustedClaimsRequired() { return trustedClaimsRequired == null || trustedClaimsRequired; }
public boolean writeApprovalRequired() { return writeApprovalRequired == null || writeApprovalRequired; }
public boolean redisTraceEnabled() { return redisTraceEnabled == null || redisTraceEnabled; }
public long redisTraceTtlSeconds() { return redisTraceTtlSeconds == null || redisTraceTtlSeconds < 1 ? 3_600 : redisTraceTtlSeconds; }
public boolean traceEnabled() { return traceEnabled == null || traceEnabled; }
public long traceRetentionSize() { return traceRetentionSize == null || traceRetentionSize < 1 ? 1000 : traceRetentionSize; }
public long toolTimeoutMillis() { return toolTimeoutMillis == null || toolTimeoutMillis < 1 ? 5_000 : toolTimeoutMillis; }
public int retryMaxAttempts() { return retryMaxAttempts == null || retryMaxAttempts < 1 ? 3 : retryMaxAttempts; }
public long retryInitialBackoffMillis() { return retryInitialBackoffMillis == null || retryInitialBackoffMillis < 1 ? 1_000 : retryInitialBackoffMillis; }

View File

@@ -1,46 +0,0 @@
package io.shinhanlife.dap.mcg.config;
import io.shinhanlife.dap.mcc.dto.ToolMetadata;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;
/**
* @package io.shinhanlife.dap.mcg.config
* @className RedisConfig
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
@Configuration
public class RedisConfig {
@Bean
public RedisTemplate<String, ToolMetadata> redisTemplate(RedisConnectionFactory connectionFactory) {
RedisTemplate<String, ToolMetadata> template = new RedisTemplate<>();
template.setConnectionFactory(connectionFactory);
// 1. Key는 무조건 String
template.setKeySerializer(new StringRedisSerializer());
template.setHashKeySerializer(new StringRedisSerializer());
// 2. Value는 Generic 직렬화기 사용 (생성자 인자 없음)
Jackson2JsonRedisSerializer<ToolMetadata> serializer = new Jackson2JsonRedisSerializer<>(ToolMetadata.class);
template.setValueSerializer(serializer);
template.setHashValueSerializer(serializer);
template.afterPropertiesSet();
return template;
}
}

View File

@@ -17,7 +17,7 @@ package io.shinhanlife.dap.mcg.presentation;
*/
import io.shinhanlife.dap.lib.adapter.dto.JsonRpcResponse;
import io.shinhanlife.dap.mcc.dto.ToolMetadata;
import io.shinhanlife.dap.mcg.registry.RedisRegistryService;
import io.shinhanlife.dap.mcg.registry.InMemoryRegistryService;
import io.shinhanlife.dap.mcg.service.ExecuteService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -41,7 +41,7 @@ public class ChatController {
private final ExecuteService executeService;
private final ObjectMapper objectMapper;
private final RedisRegistryService registryService;
private final InMemoryRegistryService registryService;
private final ChatClient.Builder chatClientBuilder;
private final McpRouterController mcpRouterController;

View File

@@ -21,7 +21,7 @@ import io.shinhanlife.dap.lib.adapter.dto.JsonRpcResponse;
import io.shinhanlife.dap.lib.adapter.dto.Params;
import io.shinhanlife.dap.mcg.config.GatewayFallbackProperties;
import io.shinhanlife.dap.mcc.dto.ToolMetadata;
import io.shinhanlife.dap.mcg.registry.RedisRegistryService;
import io.shinhanlife.dap.mcg.registry.InMemoryRegistryService;
import io.shinhanlife.dap.mcg.service.ExecuteService;
import io.shinhanlife.dap.lib.mcp.security.SecurityProperties;
import io.swagger.v3.oas.annotations.Operation;
@@ -49,19 +49,19 @@ import org.springframework.http.client.SimpleClientHttpRequestFactory;
@Tag(name = "MCP Router API", description = "AI Agent의 요청을 받아 Adapter 시스템으로 라우팅하는 게이트웨이 API")
public class McpRouterController {
private final RedisRegistryService redisRegistryService;
private final InMemoryRegistryService registryService;
private final ExecuteService executeService;
private final SecurityProperties securityProperties;
private final ObjectMapper objectMapper;
private final GatewayFallbackProperties gatewayFallbackProperties;
private final RestClient restClient;
public McpRouterController(RedisRegistryService redisRegistryService,
public McpRouterController(InMemoryRegistryService registryService,
ExecuteService executeService,
SecurityProperties securityProperties,
ObjectMapper objectMapper,
GatewayFallbackProperties gatewayFallbackProperties) {
this.redisRegistryService = redisRegistryService;
this.registryService = registryService;
this.executeService = executeService;
this.securityProperties = securityProperties;
this.objectMapper = objectMapper;
@@ -115,12 +115,12 @@ public class McpRouterController {
private List<ToolMetadata> fetchAllActiveTools() {
List<ToolMetadata> activeTools = new ArrayList<>();
try {
activeTools.addAll(redisRegistryService.getAllTools()
activeTools.addAll(registryService.getAllTools()
.stream()
.filter(ToolMetadata::getVisible)
.collect(Collectors.toList()));
} catch (org.springframework.data.redis.RedisConnectionFailureException exception) {
log.warn("Redis is unavailable. Fetching tools from configured fallback Tool Pods instead.");
} catch (Exception exception) {
log.warn("Registry is unavailable. Fetching tools from configured fallback Tool Pods instead.");
}
Set<String> knownTools = activeTools.stream()
@@ -240,19 +240,19 @@ public class McpRouterController {
@PostMapping("/registry/register")
public ResponseEntity<String> registerTool(@RequestBody ToolMetadata meta) {
redisRegistryService.saveTool(meta);
registryService.saveTool(meta);
return ResponseEntity.ok("Registered");
}
@PostMapping("/registry/deregister")
public ResponseEntity<String> deregisterTool(@RequestBody String uid) {
redisRegistryService.removeTool(uid);
registryService.removeTool(uid);
return ResponseEntity.ok("Deregistered");
}
@PostMapping("/registry/heartbeat")
public ResponseEntity<String> heartbeat(@RequestBody String uid) {
boolean success = redisRegistryService.refreshHeartbeat(uid);
boolean success = registryService.refreshHeartbeat(uid);
if (success) {
return ResponseEntity.ok("Heartbeat updated");
} else {

View File

@@ -1,264 +0,0 @@
package io.shinhanlife.dap.mcg.redis;
/**
* @package io.shinhanlife.dap.mcg.redis
* @className RedisToolTraceService
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
import io.shinhanlife.dap.mcg.config.McpGatewayProperties;
import io.shinhanlife.dap.mcg.security.McpRequestContext;
import io.shinhanlife.dap.mcc.dto.ToolMetadata;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Duration;
import java.time.Instant;
import java.util.HexFormat;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.ArrayList;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@Service
public class RedisToolTraceService {
private static final Logger log = LoggerFactory.getLogger(RedisToolTraceService.class);
private static final String KEY_PREFIX = "mcp:request:";
private static final String RECENT_KEY = "mcp:request:recent";
private final McpGatewayProperties properties;
private final ObjectProvider<StringRedisTemplate> redisProvider;
private final ObjectMapper json;
private final McpMonitorEventService monitorEvents;
private final Map<String, AttemptState> attemptStates = new ConcurrentHashMap<>();
public RedisToolTraceService(McpGatewayProperties properties,
ObjectProvider<StringRedisTemplate> redisProvider,
ObjectMapper json,
McpMonitorEventService monitorEvents) {
this.properties = properties;
this.redisProvider = redisProvider;
this.json = json;
this.monitorEvents = monitorEvents;
}
/**
* Agent request has entered the MCP tool gateway.
*/
public void started(McpRequestContext context, ToolMetadata metadata, ObjectNode arguments) {
save(context, metadata, arguments, "STARTED", 0, "", 0, 0, 0,
"Agent request entered MCP", "");
}
/**
* MCP is attempting to call the target Tool server.
*/
public void attemptStarted(McpRequestContext context, ToolMetadata metadata, ObjectNode arguments,
int attempt, int maxAttempts) {
save(context, metadata, arguments, "ATTEMPTING", 0, "", attempt, maxAttempts, 0,
"Calling tool server", "");
}
/**
* MCP will retry the Tool call after backoff.
*/
public void retryWaiting(McpRequestContext context, ToolMetadata metadata, ObjectNode arguments,
int failedAttempt, int maxAttempts, long backoffMillis, String failureType) {
save(context, metadata, arguments, "RETRY_WAITING", 0, failureType, failedAttempt, maxAttempts, backoffMillis,
"Retry will be attempted after backoff", "");
}
/**
* Tool execution has finished.
*/
public void finished(McpRequestContext context, ToolMetadata metadata, ObjectNode arguments,
long elapsedMillis, boolean success, String failureType, String responseText) {
save(context, metadata, arguments, success ? "SUCCESS" : "FAILED", elapsedMillis, failureType, 0, 0, 0,
success ? "Tool call completed" : "Tool call failed", responseText);
}
/**
* Recent live trace entries. One current entry is kept per request id.
*/
public List<String> recent() {
if (!properties.redisTraceEnabled()) {
return List.of("Redis trace is disabled. Set MCP_REDIS_TRACE_ENABLED=true.");
}
StringRedisTemplate redis = redisProvider.getIfAvailable();
if (redis == null) {
return List.of("RedisTemplate is not available.");
}
try {
List<String> requestIds = redis.opsForList().range(RECENT_KEY, 0, 49);
if (requestIds == null || requestIds.isEmpty()) {
return List.of();
}
return requestIds.stream()
.map(requestId -> redis.opsForValue().get(KEY_PREFIX + requestId))
.filter(value -> value != null && !value.isBlank())
.toList();
} catch (Exception error) {
log.warn("Redis trace read failed. message={}", error.getMessage());
return List.of("Redis trace read failed: " + error.getMessage());
}
}
/**
* Historical accumulation is intentionally disabled.
*
* The MCP monitor is used to observe the current Agent request flow only,
* so this method returns the same live entries as recent().
*/
public List<String> history() {
return recent();
}
private void save(McpRequestContext context, ToolMetadata metadata, ObjectNode arguments, String status,
long elapsedMillis, String failureType, int attempt, int maxAttempts,
long backoffMillis, String message, String responseText) {
String stateKey = stateKey(context, metadata);
if (attempt > 0 && maxAttempts > 0) {
attemptStates.put(stateKey, new AttemptState(attempt, maxAttempts));
}
String payload;
try {
payload = tracePayload(context, metadata, arguments, status, elapsedMillis, failureType, attempt,
maxAttempts, backoffMillis, message, responseText);
monitorEvents.publish(payload);
if ("SUCCESS".equals(status) || "FAILED".equals(status)) {
attemptStates.remove(stateKey);
}
} catch (Exception error) {
log.warn("MCP monitor event publish failed. requestId={} tool={} message={}",
context.requestId(), metadata.getName(), error.getMessage());
return;
}
if (!properties.redisTraceEnabled()) {
return;
}
StringRedisTemplate redis = redisProvider.getIfAvailable();
if (redis == null) {
return;
}
try {
String requestId = context.requestId();
Duration ttl = Duration.ofSeconds(properties.redisTraceTtlSeconds());
redis.opsForValue().set(KEY_PREFIX + requestId, payload, ttl);
redis.opsForList().remove(RECENT_KEY, 0, requestId);
redis.opsForList().leftPush(RECENT_KEY, requestId);
redis.opsForList().trim(RECENT_KEY, 0, 49);
redis.expire(RECENT_KEY, ttl);
} catch (Exception error) {
log.warn("Redis trace save failed. requestId={} tool={} message={}",
context.requestId(), metadata.getName(), error.getMessage());
}
}
String tracePayload(McpRequestContext context, ToolMetadata metadata, ObjectNode arguments, String status,
long elapsedMillis, String failureType, int attempt, int maxAttempts,
long backoffMillis, String message, String responseText) throws Exception {
AttemptState state = attemptStates.get(stateKey(context, metadata));
int displayAttempt = attempt > 0 ? attempt : state == null ? 0 : state.attempt();
int displayMaxAttempts = maxAttempts > 0 ? maxAttempts : state == null ? 0 : state.maxAttempts();
int retryCount = Math.max(0, displayAttempt - 1);
Map<String, Object> trace = new LinkedHashMap<>();
trace.put("requestId", context.requestId());
trace.put("traceGroupId", context.traceGroupId());
trace.put("agentId", context.agentId());
trace.put("userId", context.userId());
trace.put("clientAddress", context.clientAddress());
trace.put("toolName", metadata.getName());
trace.put("operationType", metadata.getOperationType() != null ? metadata.getOperationType().name() : "READ");
trace.put("status", status);
trace.put("message", message == null ? "" : message);
trace.put("failureType", failureType == null ? "" : failureType);
trace.put("attempt", displayAttempt);
trace.put("maxAttempts", displayMaxAttempts);
trace.put("retryCount", retryCount);
trace.put("backoffMillis", backoffMillis);
trace.put("elapsedMillis", elapsedMillis);
trace.put("idempotencyKeyHash", hashText(arguments.path("idempotencyKey").asText("")));
List<String> argNames = new ArrayList<>();
arguments.fieldNames().forEachRemaining(argNames::add);
trace.put("argumentNames", argNames);
trace.put("arguments", arguments.toString());
trace.put("responseSummary", responseSummary(responseText));
trace.put("timestamp", Instant.now().toString());
return json.writeValueAsString(trace);
}
/**
* Redis Trace에는 Tool 응답 원문을 저장하지 않고 size/hash/상태/카운트만 저장합니다.
*/
Map<String, Object> responseSummary(String responseText) {
Map<String, Object> summary = new LinkedHashMap<>();
if (responseText == null || responseText.isBlank()) {
summary.put("bytes", 0);
summary.put("sha256", "");
return summary;
}
byte[] bytes = responseText.getBytes(StandardCharsets.UTF_8);
summary.put("bytes", bytes.length);
summary.put("sha256", sha256(bytes));
try {
JsonNode root = json.readTree(responseText);
summary.put("success", root.path("success").asBoolean(false));
summary.put("status", root.path("status").asText(""));
summary.put("toolName", root.path("toolName").asText(""));
summary.put("pageSize", root.path("pageSize").asInt(0));
summary.put("pageCount", root.path("pageCount").asInt(0));
summary.put("returnedCount", root.path("returnedCount").asInt(0));
summary.put("totalCount", root.path("totalCount").asLong(0));
summary.put("hasMore", root.path("hasMore").asBoolean(false));
summary.put("nextCursor", root.path("nextCursor").asText(""));
summary.put("message", root.path("message").asText(""));
summary.put("resultRef", root.path("resultRef").asText(""));
} catch (Exception ignored) {
summary.put("parseable", false);
}
return summary;
}
private String sha256(byte[] bytes) {
try {
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes));
} catch (Exception error) {
return "";
}
}
private String hashText(String value) {
if (value == null || value.isBlank()) {
return "";
}
return sha256(value.getBytes(StandardCharsets.UTF_8));
}
private String stateKey(McpRequestContext context, ToolMetadata metadata) {
return context.requestId() + ":" + metadata.getName();
}
private record AttemptState(int attempt, int maxAttempts) {
}
}

View File

@@ -0,0 +1,111 @@
package io.shinhanlife.dap.mcg.registry;
import io.shinhanlife.dap.mcc.dto.ToolMetadata;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.Collectors;
/**
* @package io.shinhanlife.dap.mcg.registry
* @className InMemoryRegistryService
* @description AX HUB 인메모리 툴 레지스트리
* @author 0986406
* @create 2026.09.01
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class InMemoryRegistryService {
private final Map<String, ToolMetadata> toolCache = new ConcurrentHashMap<>();
private static final long DEFAULT_TTL_MILLIS = 45000; // 45초
/**
* 툴 등록 및 갱신
*/
public void saveTool(ToolMetadata meta) {
meta.setLastHeartbeat(System.currentTimeMillis());
toolCache.put(meta.getUid(), meta);
log.info(" [InMemoryRegistry] 툴 등록 완료: {}", meta.getUid());
}
/**
* 하트비트 갱신 (TTL 초기화)
*/
public boolean refreshHeartbeat(String uid) {
ToolMetadata meta = toolCache.get(uid);
if (meta != null) {
meta.setLastHeartbeat(System.currentTimeMillis());
log.debug(" [InMemoryRegistry] 하트비트 갱신: {}", uid);
return true;
} else {
log.warn(" [InMemoryRegistry] 존재하지 않는 툴에 대한 하트비트 요청: {}", uid);
return false;
}
}
public List<String> getAvailablePods(String uid) {
ToolMetadata tool = getTool(uid);
if (tool != null && tool.getPodUrl() != null) {
return List.of(tool.getPodUrl());
}
return List.of();
}
/**
* 실행 시 툴 정보 조회
*/
public ToolMetadata getTool(String uid) {
return toolCache.get(uid);
}
/**
* 툴 이름으로 정보 조회
*/
public ToolMetadata getToolByName(String name) {
for (ToolMetadata tool : toolCache.values()) {
if (name.equals(tool.getName())) {
return tool;
}
}
return null;
}
/**
* 등록된 모든 활성 툴 목록 조회
*/
public List<ToolMetadata> getAllTools() {
return List.copyOf(toolCache.values());
}
/**
* 툴 명시적 제거 (Deregister)
*/
public void removeTool(String uid) {
toolCache.remove(uid);
log.info(" [InMemoryRegistry] 툴 삭제 완료: {}", uid);
}
/**
* 만료된 툴을 스케줄러로 삭제합니다. (10초마다 실행)
*/
@Scheduled(fixedRate = 10000)
public void evictExpiredTools() {
long now = System.currentTimeMillis();
List<String> expiredKeys = toolCache.entrySet().stream()
.filter(entry -> (now - entry.getValue().getLastHeartbeat()) > DEFAULT_TTL_MILLIS)
.map(Map.Entry::getKey)
.collect(Collectors.toList());
for (String key : expiredKeys) {
toolCache.remove(key);
log.info(" [InMemoryRegistry] TTL 초과로 툴 자동 삭제: {}", key);
}
}
}

View File

@@ -1,110 +0,0 @@
package io.shinhanlife.dap.mcg.registry;
import io.shinhanlife.dap.mcc.dto.ToolMetadata;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import java.time.Duration;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.Objects;
/**
* @package io.shinhanlife.dap.mcg.registry
* @className RedisRegistryService
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class RedisRegistryService {
private final RedisTemplate<String, ToolMetadata> redisTemplate;
private static final String KEY_PREFIX = "mcp:tool:";
private static final Duration DEFAULT_TTL = Duration.ofSeconds(45); // 실무 하트비트 주기 반영
/**
* 툴 등록 및 갱신 (TTL 기반으로 60초 뒤 자동 만료)
*/
public void saveTool(ToolMetadata meta) {
String key = KEY_PREFIX + meta.getUid();
meta.setLastHeartbeat(System.currentTimeMillis());
redisTemplate.opsForValue().set(key, meta, DEFAULT_TTL);
log.info(" [RedisRegistry] 툴 등록 완료: {}", meta.getUid());
}
/**
* 하트비트 갱신 (TTL 초기화)
*/
public boolean refreshHeartbeat(String uid) {
String key = KEY_PREFIX + uid;
Boolean exists = redisTemplate.expire(key, DEFAULT_TTL);
if (Boolean.TRUE.equals(exists)) {
log.debug(" [RedisRegistry] 하트비트 갱신: {}", uid);
return true;
} else {
log.warn(" [RedisRegistry] 존재하지 않는 툴에 대한 하트비트 요청: {}", uid);
return false;
}
}
public List<String> getAvailablePods(String uid) {
ToolMetadata tool = getTool(uid);
if (tool != null && tool.getPodUrl() != null) {
return List.of(tool.getPodUrl());
}
return List.of();
}
/**
* 실행 시 툴 정보 조회 (Tool Execution 시 참조)
*/
public ToolMetadata getTool(String uid) {
return redisTemplate.opsForValue().get(KEY_PREFIX + uid);
}
/**
* 툴 이름으로 정보 조회
*/
public ToolMetadata getToolByName(String name) {
List<ToolMetadata> allTools = getAllTools();
for (ToolMetadata tool : allTools) {
if (name.equals(tool.getName())) {
return tool;
}
}
return null;
}
/**
* 등록된 모든 활성 툴 목록 조회
*/
public List<ToolMetadata> getAllTools() {
Set<String> keys = redisTemplate.keys(KEY_PREFIX + "*");
if (keys == null || keys.isEmpty()) {
return List.of();
}
List<ToolMetadata> tools = redisTemplate.opsForValue().multiGet(keys);
return tools != null ? tools.stream().filter(Objects::nonNull).collect(Collectors.toList()) : List.of();
}
/**
* 툴 명시적 제거 (Deregister)
*/
public void removeTool(String uid) {
redisTemplate.delete(KEY_PREFIX + uid);
log.info(" [RedisRegistry] 툴 삭제 완료: {}", uid);
}
}

View File

@@ -30,7 +30,7 @@ import io.shinhanlife.dap.mcg.audit.AuditLogService;
import io.shinhanlife.dap.mcg.resilience.CircuitBreaker;
import io.shinhanlife.dap.mcg.resilience.CircuitBreakerService;
import io.shinhanlife.dap.mcg.security.ToolAuthorizationService;
import io.shinhanlife.dap.mcg.redis.RedisToolTraceService;
import io.shinhanlife.dap.mcg.trace.InMemoryToolTraceService;
import jakarta.annotation.PreDestroy;
import java.util.Map;
@@ -62,7 +62,7 @@ public class ExecuteService {
private final AuditLogService auditLogService;
private final CircuitBreakerService circuitBreakerService;
private final ToolAuthorizationService authorizationService;
private final RedisToolTraceService redisTrace;
private final InMemoryToolTraceService traceService;
private final McpGatewayProperties properties;
private final LargeToolResponseService largeResponses;
private final PaginationRequestValidator paginationValidator;
@@ -79,7 +79,7 @@ public class ExecuteService {
AuditLogService auditLogService,
CircuitBreakerService circuitBreakerService,
ToolAuthorizationService authorizationService,
RedisToolTraceService redisTrace,
InMemoryToolTraceService traceService,
McpGatewayProperties properties,
LargeToolResponseService largeResponses,
PaginationRequestValidator paginationValidator,
@@ -94,7 +94,7 @@ public class ExecuteService {
this.auditLogService = auditLogService;
this.circuitBreakerService = circuitBreakerService;
this.authorizationService = authorizationService;
this.redisTrace = redisTrace;
this.traceService = traceService;
this.properties = properties;
this.largeResponses = largeResponses;
this.paginationValidator = paginationValidator;
@@ -133,7 +133,7 @@ public class ExecuteService {
long startedAt = System.nanoTime();
auditLogService.toolStarted(context, toolName, argumentsNode);
redisTrace.started(context, metadata, argumentsNode);
traceService.started(context, metadata, argumentsNode);
try {
authorizationService.authorize(context, metadata);
@@ -174,13 +174,13 @@ public class ExecuteService {
finalResult.put("original_size", originalSize);
auditLogService.toolFinished(context, metadata.getName(), elapsedMillis, true, "");
redisTrace.finished(context, metadata, argumentsNode, elapsedMillis, true, "", responseText);
traceService.finished(context, metadata, argumentsNode, elapsedMillis, true, "", responseText);
return finalResult;
} catch (ToolExecutionException error) {
long elapsedMillis = elapsedMillis(startedAt);
auditLogService.toolFinished(context, toolName, elapsedMillis, false, error.failureType().name());
redisTrace.finished(context, metadata, argumentsNode, elapsedMillis, false, error.failureType().name(), "");
traceService.finished(context, metadata, argumentsNode, elapsedMillis, false, error.failureType().name(), "");
Map<String, Object> errorResult = new java.util.LinkedHashMap<>();
errorResult.put("status", "error");
@@ -194,7 +194,7 @@ public class ExecuteService {
} catch (Exception error) {
long elapsedMillis = elapsedMillis(startedAt);
auditLogService.toolFinished(context, toolName, elapsedMillis, false, FailureType.INTERNAL_ERROR.name());
redisTrace.finished(context, metadata, argumentsNode, elapsedMillis, false, FailureType.INTERNAL_ERROR.name(), "");
traceService.finished(context, metadata, argumentsNode, elapsedMillis, false, FailureType.INTERNAL_ERROR.name(), "");
Map<String, Object> errorResult = new java.util.LinkedHashMap<>();
errorResult.put("status", "error");
@@ -225,7 +225,7 @@ public class ExecuteService {
ToolExecutionException lastError = null;
for (int attempt = 1; attempt <= retryPolicy.maxAttempts(); attempt++) {
try {
redisTrace.attemptStarted(context, metadata, arguments, attempt, retryPolicy.maxAttempts());
traceService.attemptStarted(context, metadata, arguments, attempt, retryPolicy.maxAttempts());
circuitBreaker.beforeCall();
Object result = executeOnce(context, metadata, arguments, payload);
circuitBreaker.recordSuccess();
@@ -237,7 +237,7 @@ public class ExecuteService {
throw error;
}
long backoffMillis = retryPolicy.backoffMillis(attempt);
redisTrace.retryWaiting(context, metadata, arguments, attempt, retryPolicy.maxAttempts(), backoffMillis,
traceService.retryWaiting(context, metadata, arguments, attempt, retryPolicy.maxAttempts(), backoffMillis,
error.failureType().name());
sleepBeforeRetry(metadata.getName(), attempt, backoffMillis, error.failureType());
}

View File

@@ -2,34 +2,29 @@ package io.shinhanlife.dap.mcg.service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* @package io.shinhanlife.dap.mcg.service
* @className KillSwitchService
* @description AX HUB 시스템 처리 클래스
* @description AX HUB 시스템 킬 스위치 서비스 (인메모리)
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class KillSwitchService {
// 기본 제공되는 StringRedisTemplate 사용
private final StringRedisTemplate stringRedisTemplate;
// 인메모리 맵으로 변경
private final Map<String, String> killSwitchMap = new ConcurrentHashMap<>();
public void checkAgent(String tenantId) {
String key = "mcp:kill:agent:" + tenantId;
if ("true".equalsIgnoreCase(stringRedisTemplate.opsForValue().get(key))) {
if ("true".equalsIgnoreCase(killSwitchMap.get(key))) {
log.warn(" [KillSwitch] 차단된 에이전트 접근 시도: {}", tenantId);
throw new SecurityException(" 비상 차단: 해당 테넌트(" + tenantId + ")의 접근이 관리자에 의해 차단되었습니다.");
}
@@ -39,7 +34,7 @@ public class KillSwitchService {
if (toolName == null || toolName.isBlank()) return;
String key = "mcp:kill:tool:" + toolName;
if ("true".equalsIgnoreCase(stringRedisTemplate.opsForValue().get(key))) {
if ("true".equalsIgnoreCase(killSwitchMap.get(key))) {
log.warn(" [KillSwitch] 차단된 툴 실행 시도: {}", toolName);
throw new SecurityException(" 비상 차단: 해당 툴(" + toolName + ")의 실행이 관리자에 의해 차단되었습니다.");
}
@@ -49,7 +44,7 @@ public class KillSwitchService {
if (integrationType == null || integrationType.isBlank()) return;
String key = "mcp:kill:route:" + integrationType;
if ("true".equalsIgnoreCase(stringRedisTemplate.opsForValue().get(key))) {
if ("true".equalsIgnoreCase(killSwitchMap.get(key))) {
log.warn(" [KillSwitch] 차단된 라우트 통신 시도: {}", integrationType);
throw new SecurityException(" 비상 차단: 해당 레거시 라우트(" + integrationType + ")로의 통신이 관리자에 의해 차단되었습니다.");
}
@@ -58,14 +53,14 @@ public class KillSwitchService {
// 관리자 API용 토글 메서드
public void toggleKillSwitch(String type, String target, boolean state) {
String key = "mcp:kill:" + type + ":" + target;
stringRedisTemplate.opsForValue().set(key, String.valueOf(state));
killSwitchMap.put(key, String.valueOf(state));
log.info(" [KillSwitch] 상태 변경: {} -> {} (차단 상태: {})", type, target, state);
}
// 관리자 API용 삭제 메서드 (Redis 키 자체를 완전 삭제)
// 관리자 API용 삭제 메서드 (맵 항목 삭제)
public void removeKillSwitch(String type, String target) {
String key = "mcp:kill:" + type + ":" + target;
stringRedisTemplate.delete(key);
killSwitchMap.remove(key);
log.info(" [KillSwitch] 차단 키 완전 삭제: {} -> {}", type, target);
}
}

View File

@@ -1,7 +1,7 @@
package io.shinhanlife.dap.mcg.service;
import io.shinhanlife.dap.lib.mcp.security.SecurityProperties;
import io.shinhanlife.dap.mcg.registry.RedisRegistryService;
import io.shinhanlife.dap.mcg.registry.InMemoryRegistryService;
import io.shinhanlife.dap.mcc.dto.ToolMetadata;
import io.shinhanlife.dap.mcg.config.GatewayFallbackProperties;
import lombok.RequiredArgsConstructor;
@@ -14,14 +14,14 @@ import java.util.Map;
/**
* @package io.shinhanlife.dap.mcg.service
* @className ToolPlanner
* @description AX HUB 시스템 처리 클래스
* @description AX HUB ?<3F>스??처리 ?<3F>래??
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정력 ----------
* 수정일 수정자 수정내
* ---------- 개정?<3F>력 ----------
* ?<3F>정?? ?<3F>정?? ?<3F>정?<3F>
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초
* 2026.09.01 0986406 최초?<3F>
*
* </pre>
*/
@@ -30,30 +30,30 @@ import java.util.Map;
@RequiredArgsConstructor
public class ToolPlanner {
private final RedisRegistryService redisRegistryService;
private final InMemoryRegistryService InMemoryRegistryService;
private final SecurityProperties securityProperties;
private final GatewayFallbackProperties fallbackProperties;
/**
* 청(payload)을 분석하여 실행해야 할 Tool의 계획을 생성합니다.
* ?<3F>청(payload)??분석?<3F>여 ?<3F>행?<3F>야 ??Tool??계획???<3F>성?<3F>니??
*/
public Object createPlan(Map<String, Object> payload, String tenantId) {
log.info(" [Planner] 청 분석 및 실행 계획 수립 시");
log.info(" [Planner] ?<3F>청 분석 <EFBFBD>??<3F>행 계획 ?<3F>립 ?<3F>");
// 1. 요청에서 호출하려는 툴 이름 추출 (JSON-RPC params.name)
// 1. ?<3F>청?<3F>서 ?<3F>출?<3F>려?????<3F>름 추출 (JSON-RPC params.name)
Map<String, Object> params = (Map<String, Object>) payload.get("params");
String toolName = params != null ? (String) params.get("name") : null;
if (toolName == null || toolName.isEmpty()) {
throw new IllegalArgumentException("요청에 toolName이 포함되어 있지 않습니다.");
throw new IllegalArgumentException("?<3F>청??toolName???<3F>함?<3F>어 ?<3F><>? ?<3F>습?<3F>다.");
}
// 2. RedisRegistry에서 해당 툴의 메타데이터 조회
// (제로는 이 메타데이터가 실행 계획의 핵심이 됩니다)
var toolMetadata = redisRegistryService.getToolByName(toolName);
// 2. RedisRegistry?<3F>서 ?<3F>당 ?<3F>의 메<>??<3F>이??조회
// (?<3F>제로는 ??메<>??<3F>이?<3F><>? ?<3F>행 계획???<3F>심???<3F>니??
var toolMetadata = InMemoryRegistryService.getToolByName(toolName);
if (toolMetadata == null) {
log.warn(" [Planner] 등록되지 않은 툴 요청: {}. Fallback 라우팅 규칙을 확인합니다.", toolName);
log.warn(" [Planner] ?<3F>록?<3F><>? ?<3F><>? ???<3F>청: {}. Fallback ?<3F>우??규칙???<3F>인?<3F>니??", toolName);
String fallbackPodUrl = fallbackProperties.getDefaultUrl();
if (fallbackProperties.getRoutes() != null) {
@@ -66,11 +66,11 @@ public class ToolPlanner {
}
if (fallbackPodUrl == null || fallbackPodUrl.isEmpty()) {
log.error(" [Planner] Fallback 라우팅 대상이 아닙니다. 툴: {}", toolName);
throw new RuntimeException("해당 툴(" + toolName + ")이 레지스트리에 존재하지 않습니다.");
log.error(" [Planner] Fallback ?<3F>우???<3F>?<3F>이 ?<3F>닙?<3F>다. ?? {}", toolName);
throw new RuntimeException("?<3F>당 ??" + toolName + ")???<3F><>??<3F>트리에 존재?<3F><>? ?<3F>습?<3F>다.");
}
log.info(" [Planner] Fallback 라우팅 매칭됨: {} -> {}", toolName, fallbackPodUrl);
log.info(" [Planner] Fallback ?<3F>우??매칭?? {} -> {}", toolName, fallbackPodUrl);
toolMetadata = ToolMetadata.builder()
.uid(toolName)
@@ -80,26 +80,26 @@ public class ToolPlanner {
.build();
}
// 2-1. [규] 도메인 그룹핑 기반 권한 검
// 2-1. [?<3F>규] ?<3F>메??그룹??기반 권한 검<EFBFBD>?
if (tenantId != null && !tenantId.equalsIgnoreCase("system") && toolMetadata.getCategoryKey() != null) {
String normalizedTenantId = tenantId.toLowerCase();
List<String> allowedDomains = securityProperties.getTenantDomains().get(normalizedTenantId);
// 만약 대소문자 변환 후에도 없으면 원래 값으로 한 번 더 시도 (하위 호환성)
// 만약 ?<3F>?<3F>문??변???<3F>에???<3F><EFBFBD>??<3F>래 값으<EAB092>???<3F>????<3F>도 (?<3F>위 ?<3F>환??
if (allowedDomains == null) {
allowedDomains = securityProperties.getTenantDomains().get(tenantId);
}
if (allowedDomains == null ||
(!allowedDomains.contains("ALL") && !allowedDomains.contains(toolMetadata.getCategoryKey()))) {
log.warn(" [Planner] 권한 거 - Tenant: {}, Request Domain: {}", tenantId, toolMetadata.getCategoryKey());
throw new SecurityException("해당 도메인(" + toolMetadata.getCategoryKey() + ")의 툴을 실행할 권한이 없습니다.");
log.warn(" [Planner] 권한 거<EFBFBD>? - Tenant: {}, Request Domain: {}", tenantId, toolMetadata.getCategoryKey());
throw new SecurityException("?<3F>당 ?<3F>메??" + toolMetadata.getCategoryKey() + ")???<3F>을 ?<3F>행??권한???<3F>습?<3F>다.");
}
}
log.info(" [Planner] '{}'에 대한 실행 계획 수립 완", toolName);
log.info(" [Planner] ??'{}'???<3F>???<3F>행 계획 ?<3F>립 ?<3F>", toolName);
// 3. 수립된 계획(툴 메타데이터) 반환
// 3. ?<3F>립??계획(??메<>??<3F>이?? 반환
return toolMetadata;
}
}

View File

@@ -1,7 +1,7 @@
package io.shinhanlife.dap.mcg.sync;
import io.shinhanlife.dap.mcc.dto.ToolMetadata;
import io.shinhanlife.dap.mcg.registry.RedisRegistryService;
import io.shinhanlife.dap.mcg.registry.InMemoryRegistryService;
import io.modelcontextprotocol.server.McpSyncServer;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -38,18 +38,18 @@ public class RegistryMcpToolSynchronizer {
private final ObjectProvider<McpSyncServer> mcpServerProvider;
private final DynamicMcpServerManager dynamicMcpServerManager;
private final RedisRegistryService redisRegistryService;
private final InMemoryRegistryService InMemoryRegistryService;
private final RegistryMcpToolSpecificationFactory specificationFactory;
private final Set<String> managedToolNames = new LinkedHashSet<>();
private final ReentrantLock lock = new ReentrantLock();
public RegistryMcpToolSynchronizer(ObjectProvider<McpSyncServer> mcpServerProvider,
DynamicMcpServerManager dynamicMcpServerManager,
RedisRegistryService redisRegistryService,
InMemoryRegistryService InMemoryRegistryService,
RegistryMcpToolSpecificationFactory specificationFactory) {
this.mcpServerProvider = mcpServerProvider;
this.dynamicMcpServerManager = dynamicMcpServerManager;
this.redisRegistryService = redisRegistryService;
this.InMemoryRegistryService = InMemoryRegistryService;
this.specificationFactory = specificationFactory;
}
@@ -66,7 +66,7 @@ public class RegistryMcpToolSynchronizer {
public void synchronize() {
lock.lock();
try {
List<ToolMetadata> activeEntries = redisRegistryService.getAllTools()
List<ToolMetadata> activeEntries = InMemoryRegistryService.getAllTools()
.stream().filter(ToolMetadata::getVisible).collect(Collectors.toList());
// 1. Dynamic MCP Server 동기화 (카테고리별)

View File

@@ -0,0 +1,172 @@
package io.shinhanlife.dap.mcg.trace;
import io.shinhanlife.dap.mcg.config.McpGatewayProperties;
import io.shinhanlife.dap.mcg.security.McpRequestContext;
import io.shinhanlife.dap.mcc.dto.ToolMetadata;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Instant;
import java.util.ArrayList;
import java.util.HexFormat;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* @package io.shinhanlife.dap.mcg.trace
* @className InMemoryToolTraceService
* @description AX HUB 시스템 인메모리 툴 트레이스 서비스
* @author 0986406
* @create 2026.09.01
*/
@Service
public class InMemoryToolTraceService {
private static final Logger log = LoggerFactory.getLogger(InMemoryToolTraceService.class);
private final McpGatewayProperties properties;
private final ObjectMapper json;
private final McpMonitorEventService monitorEvents;
private final Map<String, AttemptState> attemptStates = new ConcurrentHashMap<>();
// LRU Cache for recent traces
private final Map<String, String> recentTraces;
public InMemoryToolTraceService(McpGatewayProperties properties,
ObjectMapper json,
McpMonitorEventService monitorEvents) {
this.properties = properties;
this.json = json;
this.monitorEvents = monitorEvents;
final int maxSize = (int) properties.traceRetentionSize();
this.recentTraces = new LinkedHashMap<String, String>(maxSize, 0.75f, true) {
@Override
protected boolean removeEldestEntry(Map.Entry<String, String> eldest) {
return size() > maxSize;
}
};
}
public void started(McpRequestContext context, ToolMetadata metadata, ObjectNode arguments) {
save(context, metadata, arguments, "STARTED", 0, "", 0, 0, 0,
"Agent request entered MCP", "");
}
public void attemptStarted(McpRequestContext context, ToolMetadata metadata, ObjectNode arguments,
int attempt, int maxAttempts) {
save(context, metadata, arguments, "ATTEMPTING", 0, "", attempt, maxAttempts, 0,
"Calling tool server", "");
}
public void retryWaiting(McpRequestContext context, ToolMetadata metadata, ObjectNode arguments,
int failedAttempt, int maxAttempts, long backoffMillis, String failureType) {
save(context, metadata, arguments, "RETRY_WAITING", 0, failureType, failedAttempt, maxAttempts, backoffMillis,
"Retry will be attempted after backoff", "");
}
public void finished(McpRequestContext context, ToolMetadata metadata, ObjectNode arguments,
long elapsedMillis, boolean success, String failureType, String responseText) {
save(context, metadata, arguments, success ? "SUCCESS" : "FAILED", elapsedMillis, failureType, 0, 0, 0,
success ? "Tool call completed" : "Tool call failed", responseText);
}
public List<String> getRecentTraces(int limit) {
if (!properties.traceEnabled()) {
return List.of("Trace is disabled. Set traceEnabled=true.");
}
List<String> result = new ArrayList<>();
synchronized (recentTraces) {
List<String> values = new ArrayList<>(recentTraces.values());
// reverse to get latest first
for (int i = values.size() - 1; i >= 0 && result.size() < limit; i--) {
result.add(values.get(i));
}
}
return result;
}
public String getTraceDetail(String requestId) {
if (!properties.traceEnabled()) {
return "Trace is disabled.";
}
synchronized (recentTraces) {
return recentTraces.get(requestId);
}
}
private void save(McpRequestContext context, ToolMetadata metadata, ObjectNode arguments,
String state, long elapsedMillis, String failureType, int attempt, int maxAttempts, long backoffMillis,
String message, String responseText) {
if (!properties.traceEnabled()) {
return; // trace is off
}
try {
AttemptState ast = attemptStates.computeIfAbsent(context.requestId(), k -> new AttemptState(0));
if (attempt > 0) ast.currentAttempt = attempt;
ObjectNode root = json.createObjectNode();
root.put("requestId", context.requestId());
root.put("agentId", context.agentId());
root.put("tenantId", context.traceGroupId());
root.put("toolName", metadata.getName());
root.put("targetPod", metadata.getPodUrl());
root.put("state", state);
root.put("timestamp", Instant.now().toString());
root.put("elapsedMillis", elapsedMillis);
root.put("attempt", ast.currentAttempt);
root.put("maxAttempts", maxAttempts > 0 ? maxAttempts : properties.retryMaxAttempts());
root.put("backoffMillis", backoffMillis);
if (failureType != null && !failureType.isEmpty()) {
root.put("failureType", failureType);
}
if (message != null && !message.isEmpty()) {
root.put("message", message);
}
// Mask arguments if needed, for simplicity we just put them
root.set("arguments", arguments);
if (responseText != null && !responseText.isEmpty()) {
if (responseText.length() > 500) {
root.put("responsePreview", responseText.substring(0, 500) + "...");
} else {
root.put("responsePreview", responseText);
}
}
String payload = json.writeValueAsString(root);
synchronized (recentTraces) {
recentTraces.put(context.requestId(), payload);
}
monitorEvents.publish(payload);
if ("SUCCESS".equals(state) || "FAILED".equals(state)) {
attemptStates.remove(context.requestId());
}
} catch (Exception e) {
log.error("Failed to save trace: {}", e.getMessage());
}
}
private static class AttemptState {
int currentAttempt;
AttemptState(int currentAttempt) {
this.currentAttempt = currentAttempt;
}
}
}

View File

@@ -1,8 +1,8 @@
package io.shinhanlife.dap.mcg.redis;
package io.shinhanlife.dap.mcg.trace;
/**
* @package io.shinhanlife.dap.mcg.redis
* @package io.shinhanlife.dap.mcg.trace
* @className McpMonitorEventService
* @description AX HUB 시스템 처리 클래스
* @author 0986406