Initial commit

This commit is contained in:
jade
2026-08-04 21:59:08 +09:00
commit b698c0e8f8
290 changed files with 16715 additions and 0 deletions

39
.agents/AGENTS.md Normal file
View File

@@ -0,0 +1,39 @@
# AX HUB 프로젝트 개발 규칙 (AGENTS.md)
이 파일은 AI Agent(Antigravity)가 이 프로젝트에서 작업할 때 항상 명심하고 지켜야 할 규칙을 정의하는 파일입니다.
작업 시 유의해야 할 사항이 생기면 언제든지 이 아래에 자유롭게 내용을 추가해 주세요!
## 개발 가이드라인
* 패키지명은 `controller` 대신 `presentation`을 사용합니다.
* MapStruct 사용 시, Spring DI를 활용하여 의존성 주입(`private final Converter converter;`)을 받는 방식을 권장합니다. (단위 테스트 시에는 `@MockBean` 또는 직접 구현체를 주입하여 테스트)
## 명심해야 할 규칙 추가란
* `application.yml` 등 설정 파일 수정 시 한글이 깨지지 않도록 항상 UTF-8 인코딩을 유지하고, 깨진 문자열(`?\uFFFD` 등)이 발생하지 않도록 각별히 주의한다.
* 이모지는 무조건 넣지 않는다
* import 할것 무조건 한다
* Git commit과 push는 사용자의 명시적인 허락(지시) 없이는 절대 수행하지 않는다.
* 자바 만들때는 무조건 아래 내용을 넣는다
/**
* @package io.shinhanlife.axhub.biz.mcp.tool.sms
* @className AxHubToolSmsApplication
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
* 신한라이프 인터페이스 통신 프로토콜 및 메시지 포맷 가이드 (준버티컬 Tool 대상):
* 현재 개발 대상은 **대내MCI / EAI (JSON)** 연동으로 한정한다. (대외MCI FixedLength 연동은 범위에서 제외)
* 통신 노선, 데이터 변환 규격, 시스템별 연계 방식 등은 MCI/EIMS 상에서 관리되므로 코드 레벨에서 식별하거나 분기 처리하지 않는다. (단순 통합 JSON 요청만 수행)
* 처리계 UI ↔ 처리계 AP 구간: `HTTPS` / `SSV`를 사용하며, FW에서 x-api를 통해 SSV↔DTO 변환을 수행한다.
* 신한라이프 표준 로그 기준 (Logback 설정):
* **로그 생성 경로:** `/swlog/어플리케이션명(모듈명)/코드명/` (예: `/swlog/dap-gateway/A01/`)
* **로그 네이밍 규칙:** `${HOSTNAME}_코드명_yyyyMMdd.log` (예: `${HOSTNAME}_A01_20260722.log`)
* **기본 로그 구분 코드:** 시스템 운영기록 가동기록의 경우 `A01`을 기본으로 사용한다.
* **호스트명 동적 할당:** Logback 설정 시 `<property name="HOSTNAME" value="${HOSTNAME}" />` 를 선언하여 사용한다.

7
.agents/mcp.json Normal file
View File

@@ -0,0 +1,7 @@
{
"mcpServers": {
"axhub-gateway": {
"url": "http://localhost:8281/mcp/sse"
}
}
}

View File

@@ -0,0 +1,16 @@
---
name: chaos-tool-tester
description: 현재 연결된 MCP 도구들에 대해 엣지 케이스(Null, 타입 오류, SQL 인젝션 문자열 등)를 고의로 주입하여 안정성을 테스트합니다.
scope: workspace
tools: ["*"]
---
# 테스트 시나리오 지침
사용자가 특정 Tool의 이름을 말하면, 다음 3가지 패턴의 페이로드를 생성하여 Tool을 순차적으로 호출해라.
1. **타입 브레이커:** Integer가 들어가야 할 곳에 긴 String 넣기
2. **누락 테스트:** 필수(Required) 파라미터를 누락시키고 호출하기
3. **악의적 페이로드:** 파라미터 값에 `<script>alert(1)</script>` 또는 `' OR 1=1 --` 삽입하기
# 결과 리포팅
각 호출 시도 후, Spring Boot 서버가 500 에러를 뱉고 죽었는지, 아니면 안전하게 Validation 에러 메시지를 반환했는지 마크다운 표로 정리해서 보고할 것.

View File

@@ -0,0 +1,12 @@
---
name: gateway-routing-tracer
description: 게이트웨이 로그를 분석하여 특정 Tool Call이나 Request ID의 라우팅 경로와 소요 시간을 추적합니다.
scope: workspace
---
# 실행 스크립트
이 스킬은 같은 폴더에 위치한 `trace.sh` 스크립트를 실행하여 로그를 파싱합니다.
# 에이전트 지침
1. 사용자가 조회하고자 하는 `Request ID` 또는 `Tool 이름`을 감지하면, 백그라운드 터미널에서 `trace.sh <인자>`를 실행해라.
2. 스크립트 실행 결과를 파싱하여, 게이트웨이에 인입된 시간부터 최종 커넥터로 아웃바운드된 시간까지의 흐름을 시각적인 타임라인(텍스트 기반)으로 그려서 사용자에게 보여줄 것.

View File

@@ -0,0 +1,18 @@
#!/bin/bash
# Spring Boot 로그 파일 경로 (기본 설정: ./logs/spring-boot-logger.log)
LOG_FILE="./logs/spring-boot-logger.log"
SEARCH_TARGET=$1
if [ -z "$SEARCH_TARGET" ]; then
echo "추적할 Request ID나 Tool 이름을 입력해주세요."
exit 1
fi
if [ ! -f "$LOG_FILE" ]; then
echo "오류: 로그 파일($LOG_FILE)을 찾을 수 없습니다. 경로를 확인해주세요."
exit 1
fi
echo "[$SEARCH_TARGET] 라우팅 추적 시작..."
# Spring Boot 로그에서 대상 검색하여 주요 필드 출력
grep -i "$SEARCH_TARGET" "$LOG_FILE" | awk '{print $1, $2, $3, $5, $NF}'

View File

@@ -0,0 +1,16 @@
---
name: mcp-schema-autogen
description: Java 21 Record 클래스나 Spring Controller 코드를 분석하여 MCP 표준 JSON Schema(Tool 명세)를 자동 생성합니다.
scope: workspace
---
# 시스템 지침 (System Prompt)
너는 MCP(Model Context Protocol) Tool 스키마 전문 엔지니어다.
사용자가 Java 코드를 제공하면 다음 규칙에 따라 JSON Schema를 작성해라.
1. **타입 매핑:** Java 21 `Record`의 필드 타입을 완벽하게 JSON Schema 타입(string, integer, boolean, object, array 등)으로 매핑할 것.
2. **필수값 추출:** `@NotNull`, `@NotBlank` 또는 Java 21 Record의 Non-null 필드는 반드시 `required` 배열에 포함시킬 것.
3. **설명 추가:** JavaDoc 주석이 있거나 필드 위에 주석이 있다면 스키마의 `description` 필드로 적극 반영할 것.
# 출력 형식
결과물은 반드시 순수한 JSON 형태로만 출력하고, 불필요한 부연 설명은 생략한다.

View File

@@ -0,0 +1,249 @@
# MCP Server Best Practices
## Quick Reference
### Server Naming
- **Python**: `{service}_mcp` (e.g., `slack_mcp`)
- **Node/TypeScript**: `{service}-mcp-server` (e.g., `slack-mcp-server`)
### Tool Naming
- Use snake_case with service prefix
- Format: `{service}_{action}_{resource}`
- Example: `slack_send_message`, `github_create_issue`
### Response Formats
- Support both JSON and Markdown formats
- JSON for programmatic processing
- Markdown for human readability
### Pagination
- Always respect `limit` parameter
- Return `has_more`, `next_offset`, `total_count`
- Default to 20-50 items
### Transport
- **Streamable HTTP**: For remote servers, multi-client scenarios
- **stdio**: For local integrations, command-line tools
- Avoid SSE (deprecated in favor of streamable HTTP)
---
## Server Naming Conventions
Follow these standardized naming patterns:
**Python**: Use format `{service}_mcp` (lowercase with underscores)
- Examples: `slack_mcp`, `github_mcp`, `jira_mcp`
**Node/TypeScript**: Use format `{service}-mcp-server` (lowercase with hyphens)
- Examples: `slack-mcp-server`, `github-mcp-server`, `jira-mcp-server`
The name should be general, descriptive of the service being integrated, easy to infer from the task description, and without version numbers.
---
## Tool Naming and Design
### Tool Naming
1. **Use snake_case**: `search_users`, `create_project`, `get_channel_info`
2. **Include service prefix**: Anticipate that your MCP server may be used alongside other MCP servers
- Use `slack_send_message` instead of just `send_message`
- Use `github_create_issue` instead of just `create_issue`
3. **Be action-oriented**: Start with verbs (get, list, search, create, etc.)
4. **Be specific**: Avoid generic names that could conflict with other servers
### Tool Design
- Tool descriptions must narrowly and unambiguously describe functionality
- Descriptions must precisely match actual functionality
- Provide tool annotations (readOnlyHint, destructiveHint, idempotentHint, openWorldHint)
- Keep tool operations focused and atomic
---
## Response Formats
All tools that return data should support multiple formats:
### JSON Format (`response_format="json"`)
- Machine-readable structured data
- Include all available fields and metadata
- Consistent field names and types
- Use for programmatic processing
### Markdown Format (`response_format="markdown"`, typically default)
- Human-readable formatted text
- Use headers, lists, and formatting for clarity
- Convert timestamps to human-readable format
- Show display names with IDs in parentheses
- Omit verbose metadata
---
## Pagination
For tools that list resources:
- **Always respect the `limit` parameter**
- **Implement pagination**: Use `offset` or cursor-based pagination
- **Return pagination metadata**: Include `has_more`, `next_offset`/`next_cursor`, `total_count`
- **Never load all results into memory**: Especially important for large datasets
- **Default to reasonable limits**: 20-50 items is typical
Example pagination response:
```json
{
"total": 150,
"count": 20,
"offset": 0,
"items": [...],
"has_more": true,
"next_offset": 20
}
```
---
## Transport Options
### Streamable HTTP
**Best for**: Remote servers, web services, multi-client scenarios
**Characteristics**:
- Bidirectional communication over HTTP
- Supports multiple simultaneous clients
- Can be deployed as a web service
- Enables server-to-client notifications
**Use when**:
- Serving multiple clients simultaneously
- Deploying as a cloud service
- Integration with web applications
### stdio
**Best for**: Local integrations, command-line tools
**Characteristics**:
- Standard input/output stream communication
- Simple setup, no network configuration needed
- Runs as a subprocess of the client
**Use when**:
- Building tools for local development environments
- Integrating with desktop applications
- Single-user, single-session scenarios
**Note**: stdio servers should NOT log to stdout (use stderr for logging)
### Transport Selection
| Criterion | stdio | Streamable HTTP |
|-----------|-------|-----------------|
| **Deployment** | Local | Remote |
| **Clients** | Single | Multiple |
| **Complexity** | Low | Medium |
| **Real-time** | No | Yes |
---
## Security Best Practices
### Authentication and Authorization
**OAuth 2.1**:
- Use secure OAuth 2.1 with certificates from recognized authorities
- Validate access tokens before processing requests
- Only accept tokens specifically intended for your server
**API Keys**:
- Store API keys in environment variables, never in code
- Validate keys on server startup
- Provide clear error messages when authentication fails
### Input Validation
- Sanitize file paths to prevent directory traversal
- Validate URLs and external identifiers
- Check parameter sizes and ranges
- Prevent command injection in system calls
- Use schema validation (Pydantic/Zod) for all inputs
### Error Handling
- Don't expose internal errors to clients
- Log security-relevant errors server-side
- Provide helpful but not revealing error messages
- Clean up resources after errors
### DNS Rebinding Protection
For streamable HTTP servers running locally:
- Enable DNS rebinding protection
- Validate the `Origin` header on all incoming connections
- Bind to `127.0.0.1` rather than `0.0.0.0`
---
## Tool Annotations
Provide annotations to help clients understand tool behavior:
| Annotation | Type | Default | Description |
|-----------|------|---------|-------------|
| `readOnlyHint` | boolean | false | Tool does not modify its environment |
| `destructiveHint` | boolean | true | Tool may perform destructive updates |
| `idempotentHint` | boolean | false | Repeated calls with same args have no additional effect |
| `openWorldHint` | boolean | true | Tool interacts with external entities |
**Important**: Annotations are hints, not security guarantees. Clients should not make security-critical decisions based solely on annotations.
---
## Error Handling
- Use standard JSON-RPC error codes
- Report tool errors within result objects (not protocol-level errors)
- Provide helpful, specific error messages with suggested next steps
- Don't expose internal implementation details
- Clean up resources properly on errors
Example error handling:
```typescript
try {
const result = performOperation();
return { content: [{ type: "text", text: result }] };
} catch (error) {
return {
isError: true,
content: [{
type: "text",
text: `Error: ${error.message}. Try using filter='active_only' to reduce results.`
}]
};
}
```
---
## Testing Requirements
Comprehensive testing should cover:
- **Functional testing**: Verify correct execution with valid/invalid inputs
- **Integration testing**: Test interaction with external systems
- **Security testing**: Validate auth, input sanitization, rate limiting
- **Performance testing**: Check behavior under load, timeouts
- **Error handling**: Ensure proper error reporting and cleanup
---
## Documentation Requirements
- Provide clear documentation of all tools and capabilities
- Include working examples (at least 3 per major feature)
- Document security considerations
- Specify required permissions and access levels
- Document rate limits and performance characteristics

View File

@@ -0,0 +1,602 @@
# MCP Server Evaluation Guide
## Overview
This document provides guidance on creating comprehensive evaluations for MCP servers. Evaluations test whether LLMs can effectively use your MCP server to answer realistic, complex questions using only the tools provided.
---
## Quick Reference
### Evaluation Requirements
- Create 10 human-readable questions
- Questions must be READ-ONLY, INDEPENDENT, NON-DESTRUCTIVE
- Each question requires multiple tool calls (potentially dozens)
- Answers must be single, verifiable values
- Answers must be STABLE (won't change over time)
### Output Format
```xml
<evaluation>
<qa_pair>
<question>Your question here</question>
<answer>Single verifiable answer</answer>
</qa_pair>
</evaluation>
```
---
## Purpose of Evaluations
The measure of quality of an MCP server is NOT how well or comprehensively the server implements tools, but how well these implementations (input/output schemas, docstrings/descriptions, functionality) enable LLMs with no other context and access ONLY to the MCP servers to answer realistic and difficult questions.
## Evaluation Overview
Create 10 human-readable questions requiring ONLY READ-ONLY, INDEPENDENT, NON-DESTRUCTIVE, and IDEMPOTENT operations to answer. Each question should be:
- Realistic
- Clear and concise
- Unambiguous
- Complex, requiring potentially dozens of tool calls or steps
- Answerable with a single, verifiable value that you identify in advance
## Question Guidelines
### Core Requirements
1. **Questions MUST be independent**
- Each question should NOT depend on the answer to any other question
- Should not assume prior write operations from processing another question
2. **Questions MUST require ONLY NON-DESTRUCTIVE AND IDEMPOTENT tool use**
- Should not instruct or require modifying state to arrive at the correct answer
3. **Questions must be REALISTIC, CLEAR, CONCISE, and COMPLEX**
- Must require another LLM to use multiple (potentially dozens of) tools or steps to answer
### Complexity and Depth
4. **Questions must require deep exploration**
- Consider multi-hop questions requiring multiple sub-questions and sequential tool calls
- Each step should benefit from information found in previous questions
5. **Questions may require extensive paging**
- May need paging through multiple pages of results
- May require querying old data (1-2 years out-of-date) to find niche information
- The questions must be DIFFICULT
6. **Questions must require deep understanding**
- Rather than surface-level knowledge
- May pose complex ideas as True/False questions requiring evidence
- May use multiple-choice format where LLM must search different hypotheses
7. **Questions must not be solvable with straightforward keyword search**
- Do not include specific keywords from the target content
- Use synonyms, related concepts, or paraphrases
- Require multiple searches, analyzing multiple related items, extracting context, then deriving the answer
### Tool Testing
8. **Questions should stress-test tool return values**
- May elicit tools returning large JSON objects or lists, overwhelming the LLM
- Should require understanding multiple modalities of data:
- IDs and names
- Timestamps and datetimes (months, days, years, seconds)
- File IDs, names, extensions, and mimetypes
- URLs, GIDs, etc.
- Should probe the tool's ability to return all useful forms of data
9. **Questions should MOSTLY reflect real human use cases**
- The kinds of information retrieval tasks that HUMANS assisted by an LLM would care about
10. **Questions may require dozens of tool calls**
- This challenges LLMs with limited context
- Encourages MCP server tools to reduce information returned
11. **Include ambiguous questions**
- May be ambiguous OR require difficult decisions on which tools to call
- Force the LLM to potentially make mistakes or misinterpret
- Ensure that despite AMBIGUITY, there is STILL A SINGLE VERIFIABLE ANSWER
### Stability
12. **Questions must be designed so the answer DOES NOT CHANGE**
- Do not ask questions that rely on "current state" which is dynamic
- For example, do not count:
- Number of reactions to a post
- Number of replies to a thread
- Number of members in a channel
13. **DO NOT let the MCP server RESTRICT the kinds of questions you create**
- Create challenging and complex questions
- Some may not be solvable with the available MCP server tools
- Questions may require specific output formats (datetime vs. epoch time, JSON vs. MARKDOWN)
- Questions may require dozens of tool calls to complete
## Answer Guidelines
### Verification
1. **Answers must be VERIFIABLE via direct string comparison**
- If the answer can be re-written in many formats, clearly specify the output format in the QUESTION
- Examples: "Use YYYY/MM/DD.", "Respond True or False.", "Answer A, B, C, or D and nothing else."
- Answer should be a single VERIFIABLE value such as:
- User ID, user name, display name, first name, last name
- Channel ID, channel name
- Message ID, string
- URL, title
- Numerical quantity
- Timestamp, datetime
- Boolean (for True/False questions)
- Email address, phone number
- File ID, file name, file extension
- Multiple choice answer
- Answers must not require special formatting or complex, structured output
- Answer will be verified using DIRECT STRING COMPARISON
### Readability
2. **Answers should generally prefer HUMAN-READABLE formats**
- Examples: names, first name, last name, datetime, file name, message string, URL, yes/no, true/false, a/b/c/d
- Rather than opaque IDs (though IDs are acceptable)
- The VAST MAJORITY of answers should be human-readable
### Stability
3. **Answers must be STABLE/STATIONARY**
- Look at old content (e.g., conversations that have ended, projects that have launched, questions answered)
- Create QUESTIONS based on "closed" concepts that will always return the same answer
- Questions may ask to consider a fixed time window to insulate from non-stationary answers
- Rely on context UNLIKELY to change
- Example: if finding a paper name, be SPECIFIC enough so answer is not confused with papers published later
4. **Answers must be CLEAR and UNAMBIGUOUS**
- Questions must be designed so there is a single, clear answer
- Answer can be derived from using the MCP server tools
### Diversity
5. **Answers must be DIVERSE**
- Answer should be a single VERIFIABLE value in diverse modalities and formats
- User concept: user ID, user name, display name, first name, last name, email address, phone number
- Channel concept: channel ID, channel name, channel topic
- Message concept: message ID, message string, timestamp, month, day, year
6. **Answers must NOT be complex structures**
- Not a list of values
- Not a complex object
- Not a list of IDs or strings
- Not natural language text
- UNLESS the answer can be straightforwardly verified using DIRECT STRING COMPARISON
- And can be realistically reproduced
- It should be unlikely that an LLM would return the same list in any other order or format
## Evaluation Process
### Step 1: Documentation Inspection
Read the documentation of the target API to understand:
- Available endpoints and functionality
- If ambiguity exists, fetch additional information from the web
- Parallelize this step AS MUCH AS POSSIBLE
- Ensure each subagent is ONLY examining documentation from the file system or on the web
### Step 2: Tool Inspection
List the tools available in the MCP server:
- Inspect the MCP server directly
- Understand input/output schemas, docstrings, and descriptions
- WITHOUT calling the tools themselves at this stage
### Step 3: Developing Understanding
Repeat steps 1 & 2 until you have a good understanding:
- Iterate multiple times
- Think about the kinds of tasks you want to create
- Refine your understanding
- At NO stage should you READ the code of the MCP server implementation itself
- Use your intuition and understanding to create reasonable, realistic, but VERY challenging tasks
### Step 4: Read-Only Content Inspection
After understanding the API and tools, USE the MCP server tools:
- Inspect content using READ-ONLY and NON-DESTRUCTIVE operations ONLY
- Goal: identify specific content (e.g., users, channels, messages, projects, tasks) for creating realistic questions
- Should NOT call any tools that modify state
- Will NOT read the code of the MCP server implementation itself
- Parallelize this step with individual sub-agents pursuing independent explorations
- Ensure each subagent is only performing READ-ONLY, NON-DESTRUCTIVE, and IDEMPOTENT operations
- BE CAREFUL: SOME TOOLS may return LOTS OF DATA which would cause you to run out of CONTEXT
- Make INCREMENTAL, SMALL, AND TARGETED tool calls for exploration
- In all tool call requests, use the `limit` parameter to limit results (<10)
- Use pagination
### Step 5: Task Generation
After inspecting the content, create 10 human-readable questions:
- An LLM should be able to answer these with the MCP server
- Follow all question and answer guidelines above
## Output Format
Each QA pair consists of a question and an answer. The output should be an XML file with this structure:
```xml
<evaluation>
<qa_pair>
<question>Find the project created in Q2 2024 with the highest number of completed tasks. What is the project name?</question>
<answer>Website Redesign</answer>
</qa_pair>
<qa_pair>
<question>Search for issues labeled as "bug" that were closed in March 2024. Which user closed the most issues? Provide their username.</question>
<answer>sarah_dev</answer>
</qa_pair>
<qa_pair>
<question>Look for pull requests that modified files in the /api directory and were merged between January 1 and January 31, 2024. How many different contributors worked on these PRs?</question>
<answer>7</answer>
</qa_pair>
<qa_pair>
<question>Find the repository with the most stars that was created before 2023. What is the repository name?</question>
<answer>data-pipeline</answer>
</qa_pair>
</evaluation>
```
## Evaluation Examples
### Good Questions
**Example 1: Multi-hop question requiring deep exploration (GitHub MCP)**
```xml
<qa_pair>
<question>Find the repository that was archived in Q3 2023 and had previously been the most forked project in the organization. What was the primary programming language used in that repository?</question>
<answer>Python</answer>
</qa_pair>
```
This question is good because:
- Requires multiple searches to find archived repositories
- Needs to identify which had the most forks before archival
- Requires examining repository details for the language
- Answer is a simple, verifiable value
- Based on historical (closed) data that won't change
**Example 2: Requires understanding context without keyword matching (Project Management MCP)**
```xml
<qa_pair>
<question>Locate the initiative focused on improving customer onboarding that was completed in late 2023. The project lead created a retrospective document after completion. What was the lead's role title at that time?</question>
<answer>Product Manager</answer>
</qa_pair>
```
This question is good because:
- Doesn't use specific project name ("initiative focused on improving customer onboarding")
- Requires finding completed projects from specific timeframe
- Needs to identify the project lead and their role
- Requires understanding context from retrospective documents
- Answer is human-readable and stable
- Based on completed work (won't change)
**Example 3: Complex aggregation requiring multiple steps (Issue Tracker MCP)**
```xml
<qa_pair>
<question>Among all bugs reported in January 2024 that were marked as critical priority, which assignee resolved the highest percentage of their assigned bugs within 48 hours? Provide the assignee's username.</question>
<answer>alex_eng</answer>
</qa_pair>
```
This question is good because:
- Requires filtering bugs by date, priority, and status
- Needs to group by assignee and calculate resolution rates
- Requires understanding timestamps to determine 48-hour windows
- Tests pagination (potentially many bugs to process)
- Answer is a single username
- Based on historical data from specific time period
**Example 4: Requires synthesis across multiple data types (CRM MCP)**
```xml
<qa_pair>
<question>Find the account that upgraded from the Starter to Enterprise plan in Q4 2023 and had the highest annual contract value. What industry does this account operate in?</question>
<answer>Healthcare</answer>
</qa_pair>
```
This question is good because:
- Requires understanding subscription tier changes
- Needs to identify upgrade events in specific timeframe
- Requires comparing contract values
- Must access account industry information
- Answer is simple and verifiable
- Based on completed historical transactions
### Poor Questions
**Example 1: Answer changes over time**
```xml
<qa_pair>
<question>How many open issues are currently assigned to the engineering team?</question>
<answer>47</answer>
</qa_pair>
```
This question is poor because:
- The answer will change as issues are created, closed, or reassigned
- Not based on stable/stationary data
- Relies on "current state" which is dynamic
**Example 2: Too easy with keyword search**
```xml
<qa_pair>
<question>Find the pull request with title "Add authentication feature" and tell me who created it.</question>
<answer>developer123</answer>
</qa_pair>
```
This question is poor because:
- Can be solved with a straightforward keyword search for exact title
- Doesn't require deep exploration or understanding
- No synthesis or analysis needed
**Example 3: Ambiguous answer format**
```xml
<qa_pair>
<question>List all the repositories that have Python as their primary language.</question>
<answer>repo1, repo2, repo3, data-pipeline, ml-tools</answer>
</qa_pair>
```
This question is poor because:
- Answer is a list that could be returned in any order
- Difficult to verify with direct string comparison
- LLM might format differently (JSON array, comma-separated, newline-separated)
- Better to ask for a specific aggregate (count) or superlative (most stars)
## Verification Process
After creating evaluations:
1. **Examine the XML file** to understand the schema
2. **Load each task instruction** and in parallel using the MCP server and tools, identify the correct answer by attempting to solve the task YOURSELF
3. **Flag any operations** that require WRITE or DESTRUCTIVE operations
4. **Accumulate all CORRECT answers** and replace any incorrect answers in the document
5. **Remove any `<qa_pair>`** that require WRITE or DESTRUCTIVE operations
Remember to parallelize solving tasks to avoid running out of context, then accumulate all answers and make changes to the file at the end.
## Tips for Creating Quality Evaluations
1. **Think Hard and Plan Ahead** before generating tasks
2. **Parallelize Where Opportunity Arises** to speed up the process and manage context
3. **Focus on Realistic Use Cases** that humans would actually want to accomplish
4. **Create Challenging Questions** that test the limits of the MCP server's capabilities
5. **Ensure Stability** by using historical data and closed concepts
6. **Verify Answers** by solving the questions yourself using the MCP server tools
7. **Iterate and Refine** based on what you learn during the process
---
# Running Evaluations
After creating your evaluation file, you can use the provided evaluation harness to test your MCP server.
## Setup
1. **Install Dependencies**
```bash
pip install -r scripts/requirements.txt
```
Or install manually:
```bash
pip install anthropic mcp
```
2. **Set API Key**
```bash
export ANTHROPIC_API_KEY=your_api_key_here
```
## Evaluation File Format
Evaluation files use XML format with `<qa_pair>` elements:
```xml
<evaluation>
<qa_pair>
<question>Find the project created in Q2 2024 with the highest number of completed tasks. What is the project name?</question>
<answer>Website Redesign</answer>
</qa_pair>
<qa_pair>
<question>Search for issues labeled as "bug" that were closed in March 2024. Which user closed the most issues? Provide their username.</question>
<answer>sarah_dev</answer>
</qa_pair>
</evaluation>
```
## Running Evaluations
The evaluation script (`scripts/evaluation.py`) supports three transport types:
**Important:**
- **stdio transport**: The evaluation script automatically launches and manages the MCP server process for you. Do not run the server manually.
- **sse/http transports**: You must start the MCP server separately before running the evaluation. The script connects to the already-running server at the specified URL.
### 1. Local STDIO Server
For locally-run MCP servers (script launches the server automatically):
```bash
python scripts/evaluation.py \
-t stdio \
-c python \
-a my_mcp_server.py \
evaluation.xml
```
With environment variables:
```bash
python scripts/evaluation.py \
-t stdio \
-c python \
-a my_mcp_server.py \
-e API_KEY=abc123 \
-e DEBUG=true \
evaluation.xml
```
### 2. Server-Sent Events (SSE)
For SSE-based MCP servers (you must start the server first):
```bash
python scripts/evaluation.py \
-t sse \
-u https://example.com/mcp \
-H "Authorization: Bearer token123" \
-H "X-Custom-Header: value" \
evaluation.xml
```
### 3. HTTP (Streamable HTTP)
For HTTP-based MCP servers (you must start the server first):
```bash
python scripts/evaluation.py \
-t http \
-u https://example.com/mcp \
-H "Authorization: Bearer token123" \
evaluation.xml
```
## Command-Line Options
```
usage: evaluation.py [-h] [-t {stdio,sse,http}] [-m MODEL] [-c COMMAND]
[-a ARGS [ARGS ...]] [-e ENV [ENV ...]] [-u URL]
[-H HEADERS [HEADERS ...]] [-o OUTPUT]
eval_file
positional arguments:
eval_file Path to evaluation XML file
optional arguments:
-h, --help Show help message
-t, --transport Transport type: stdio, sse, or http (default: stdio)
-m, --model Claude model to use (default: claude-3-7-sonnet-20250219)
-o, --output Output file for report (default: print to stdout)
stdio options:
-c, --command Command to run MCP server (e.g., python, node)
-a, --args Arguments for the command (e.g., server.py)
-e, --env Environment variables in KEY=VALUE format
sse/http options:
-u, --url MCP server URL
-H, --header HTTP headers in 'Key: Value' format
```
## Output
The evaluation script generates a detailed report including:
- **Summary Statistics**:
- Accuracy (correct/total)
- Average task duration
- Average tool calls per task
- Total tool calls
- **Per-Task Results**:
- Prompt and expected response
- Actual response from the agent
- Whether the answer was correct (✅/❌)
- Duration and tool call details
- Agent's summary of its approach
- Agent's feedback on the tools
### Save Report to File
```bash
python scripts/evaluation.py \
-t stdio \
-c python \
-a my_server.py \
-o evaluation_report.md \
evaluation.xml
```
## Complete Example Workflow
Here's a complete example of creating and running an evaluation:
1. **Create your evaluation file** (`my_evaluation.xml`):
```xml
<evaluation>
<qa_pair>
<question>Find the user who created the most issues in January 2024. What is their username?</question>
<answer>alice_developer</answer>
</qa_pair>
<qa_pair>
<question>Among all pull requests merged in Q1 2024, which repository had the highest number? Provide the repository name.</question>
<answer>backend-api</answer>
</qa_pair>
<qa_pair>
<question>Find the project that was completed in December 2023 and had the longest duration from start to finish. How many days did it take?</question>
<answer>127</answer>
</qa_pair>
</evaluation>
```
2. **Install dependencies**:
```bash
pip install -r scripts/requirements.txt
export ANTHROPIC_API_KEY=your_api_key
```
3. **Run evaluation**:
```bash
python scripts/evaluation.py \
-t stdio \
-c python \
-a github_mcp_server.py \
-e GITHUB_TOKEN=ghp_xxx \
-o github_eval_report.md \
my_evaluation.xml
```
4. **Review the report** in `github_eval_report.md` to:
- See which questions passed/failed
- Read the agent's feedback on your tools
- Identify areas for improvement
- Iterate on your MCP server design
## Troubleshooting
### Connection Errors
If you get connection errors:
- **STDIO**: Verify the command and arguments are correct
- **SSE/HTTP**: Check the URL is accessible and headers are correct
- Ensure any required API keys are set in environment variables or headers
### Low Accuracy
If many evaluations fail:
- Review the agent's feedback for each task
- Check if tool descriptions are clear and comprehensive
- Verify input parameters are well-documented
- Consider whether tools return too much or too little data
- Ensure error messages are actionable
### Timeout Issues
If tasks are timing out:
- Use a more capable model (e.g., `claude-3-7-sonnet-20250219`)
- Check if tools are returning too much data
- Verify pagination is working correctly
- Consider simplifying complex questions

10
.dockerignore Normal file
View File

@@ -0,0 +1,10 @@
.git
.gradle
.idea
/build/
target/
*/target/
bin/
*/bin/
out/
*/out/

25
.gitattributes vendored Normal file
View File

@@ -0,0 +1,25 @@
# 湲곕낯媛? 紐⑤뱺 ?띿뒪???뚯씪 CRLF (Windows 媛쒕컻 ?섍꼍)
* text=auto eol=crlf
# Shell ?ㅽ겕由쏀듃??LF 怨좎젙 (Linux CI/CD ?ㅽ뻾 ?섍꼍)
*.sh text eol=lf
mvnw text eol=lf
gradlew text eol=lf
# 諛붿씠?덈━ ?뚯씪 (以꾨컮轅?蹂€???쒖쇅)
*.jar binary
*.war binary
*.ear binary
*.class binary
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.ico binary
*.svg binary
*.ttf binary
*.woff binary
*.woff2binary
*.pdf binary
*.zip binary
*.tar.gz binary

View File

@@ -0,0 +1,67 @@
name: Deploy to OCIWP
on:
push:
branches:
- main
- master
workflow_dispatch:
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Prepare Environment (Install Node.js & Git & Java 21)
run: |
if command -v apt-get &> /dev/null; then
apt-get update
apt-get install -y nodejs git openjdk-21-jdk rsync
elif command -v apk &> /dev/null; then
apk add --no-cache nodejs git openjdk21 rsync
fi
- name: Checkout Code
uses: actions/checkout@v3
- name: Sync Code to Host Volume
run: |
echo "Copying latest code to /app (Host Volume) and removing stale files..."
if command -v rsync &> /dev/null; then
rsync -a --delete --exclude='.git' . /app/
else
rm -rf /app/dap-* /app/src /app/build.gradle /app/settings.gradle
cp -a . /app/
fi
- name: Deploy Task on Host
run: |
echo "Starting Standard CI/CD Deploy pipeline..."
# 1. runner에 설치된 java로 직접 빌드! (격리된 환경 문제 해결)
cd /app
chmod +x gradlew
./gradlew bootJar -x test
# 2. 빌드 결과 plain jar 아카이브 정리
find /app -name "*-plain.jar" -delete
# 3. 좀비 컨테이너 청소 (현재 우리가 쓸 포트를 점유 중인 옛날 컨테이너들만 정확히 색출해서 사살)
echo "Finding and killing zombie containers holding our ports..."
for port in 6379 8080 8089 8281 8282 8283 8284 8285 8288; do
cid=$(docker ps -q --filter "publish=$port")
if [ ! -z "$cid" ]; then
echo "Force killing container $cid using port $port"
docker rm -f $cid
fi
done
# 4. 마운트된 /app 디렉토리로 이동하여 호스트의 도커 컴포즈 제어!
cd /app
docker system prune -f
ACTIVE_PROFILE=dev docker compose up -d --build --remove-orphans gateway redis mci-mock dozzle tool-sms tool-oth
# 5. 배포 후 대롱대롱 매달려 있는 가비지 이미지 자동 소거 청소!
docker image prune -f
echo "CI/CD Deploy Success!"

124
.gitignore vendored Normal file
View File

@@ -0,0 +1,124 @@
# ===== 鍮뚮뱶 寃곌낵臾?=====
target/
!**/src/main/**/target/
!**/src/test/**/target/
*.class
*.jar
!gradle/wrapper/gradle-wrapper.jar
*.war
*.ear
*.nar
# ===== 濡쒓렇 =====
*.log
logs/
spring-shell.log
# ===== Maven =====
.mvn/wrapper/maven-wrapper.jar
pom.xml.tag
pom.xml.releaseBackup
pom.xml.versionsBackup
pom.xml.next
release.properties
dependency-reduced-pom.xml
buildNumber.properties
.mvn/timing.properties
# ===== IntelliJ IDEA =====
.idea/
*.iws
*.iml
*.ipr
out/
# ===== Eclipse / STS =====
.apt_generated
.classpath
.factorypath
.project
.settings
.springBeans
.sts4-cache
bin/
# ===== NetBeans =====
/nbproject/private/
/nbbuild/
/dist/
/nbdist/
/.nb-gradle/
build/
.gradle/
!**/src/main/**/build/
!**/src/test/**/build/
# ===== VS Code =====
.vscode/
# ===== 鍮뚮뱶 寃곌낵臾?=====
target/
!**/src/main/**/target/
!**/src/test/**/target/
*.class
*.jar
!gradle/wrapper/gradle-wrapper.jar
*.war
*.ear
*.nar
# ===== 濡쒓렇 =====
*.log
logs/
spring-shell.log
# ===== Maven =====
.mvn/wrapper/maven-wrapper.jar
pom.xml.tag
pom.xml.releaseBackup
pom.xml.versionsBackup
pom.xml.next
release.properties
dependency-reduced-pom.xml
buildNumber.properties
.mvn/timing.properties
# ===== IntelliJ IDEA =====
.idea/
*.iws
*.iml
*.ipr
out/
# ===== Eclipse / STS =====
.apt_generated
.classpath
.factorypath
.project
.settings
.springBeans
.sts4-cache
bin/
# ===== NetBeans =====
/nbproject/private/
/nbbuild/
/dist/
/nbdist/
/.nb-gradle/
build/
.gradle/
!**/src/main/**/build/
!**/src/test/**/build/
# ===== VS Code =====
.vscode/
# ===== OS =====
.DS_Store
Thumbs.db
ehthumbs.db
# ===== ?섍꼍?ㅼ젙 (誘쇨컧?뺣낫 遺꾨━ ?? =====
# application-local.yml
# application-secret.yml

103
AddJavadoc.java Normal file
View File

@@ -0,0 +1,103 @@
import java.io.*;
import java.nio.file.*;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.*;
import java.util.regex.*;
public class AddJavadoc {
static final String ROOT_DIR = ".";
static final String TEMPLATE =
"/**\n" +
" * @package %s\n" +
" * @className %s\n" +
" * @description AX HUB ?쒖뒪??泥섎━ ?대옒??n" +
" * @author 源€?뺤떇\n" +
" * @create 2026.09.01\n" +
" * <pre>\n" +
" * ---------- 媛쒖젙?대젰 ----------\n" +
" * ?섏젙?? ?섏젙?? ?섏젙?댁슜\n" +
" * ---------- -------- ---------------------------\n" +
" * 2026.09.01 源€?뺤떇 理쒖큹?앹꽦\n" +
" * \n" +
" * </pre>\n" +
" */";
public static void main(String[] args) throws Exception {
Files.walkFileTree(Paths.get(ROOT_DIR), new SimpleFileVisitor<Path>() {
@Override
public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) {
String name = dir.getFileName().toString();
if (name.equals(".git") || name.equals("build") || name.equals(".gradle") || name.equals("scratch") || name.equals("out") || name.equals("bin")) {
return FileVisitResult.SKIP_SUBTREE;
}
return FileVisitResult.CONTINUE;
}
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {
if (file.toString().endsWith(".java") && !file.getFileName().toString().equals("AddJavadoc.java")) {
processJavaFile(file);
}
return FileVisitResult.CONTINUE;
}
});
System.out.println("Done.");
}
private static void processJavaFile(Path file) throws IOException {
List<String> lines = Files.readAllLines(file);
String content = String.join("\n", lines);
if (content.contains("---------- 媛쒖젙?대젰 ----------") || content.contains("@className")) {
System.out.println("Skipping (already has javadoc): " + file);
return;
}
String packageName = "unknown";
String className = "unknown";
Matcher pkgMatcher = Pattern.compile("(?m)^\\s*package\\s+([\\w\\.]+)\\s*;").matcher(content);
if (pkgMatcher.find()) {
packageName = pkgMatcher.group(1);
}
int classDeclIdx = -1;
for (int i = 0; i < lines.size(); i++) {
String line = lines.get(i);
Matcher classMatcher = Pattern.compile("^\\s*(?:public\\s+|protected\\s+|private\\s+|abstract\\s+|final\\s+|static\\s+)*(class|interface|enum|record)\\s+(\\w+)").matcher(line);
if (classMatcher.find()) {
classDeclIdx = i;
className = classMatcher.group(2);
break;
}
}
if (classDeclIdx == -1) {
System.out.println("Skipping (no class declaration found): " + file);
return;
}
int insertIdx = classDeclIdx;
for (int i = classDeclIdx - 1; i >= 0; i--) {
String line = lines.get(i).trim();
if (line.isEmpty()) {
continue;
}
if (line.startsWith("@")) {
insertIdx = i;
} else if (line.startsWith("//") || line.startsWith("/*") || line.startsWith("*")) {
break;
} else {
break;
}
}
String javadoc = String.format(TEMPLATE, packageName, className);
lines.add(insertIdx, javadoc);
Files.write(file, String.join("\n", lines).getBytes("UTF-8"));
System.out.println("Updated: " + file);
}
}

31
Dockerfile Normal file
View File

@@ -0,0 +1,31 @@
# 1. ??<3F><><EFBFBD>???<3F>꼍 (JDK 21)
FROM eclipse-temurin:21-jdk-alpine AS builder
WORKDIR /app
# Gradle ??<3F>띁?? ???<3F><> 蹂듭<E8B982>?
COPY gradlew .
COPY gradle gradle
COPY build.gradle settings.gradle ./
COPY src src
# 沅뚰<E6B285>??<3F>€??<3F>???<3F><><EFBFBD>?(???<3F><>????<3F>쇅)
RUN chmod +x gradlew
RUN ./gradlew clean build -x test
# 2. ??<3F>뻾 ??<3F>꼍 (JRE 21)
FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
# ???꾩<>???<3F>젙 (??<3F>뎅 ??<3F>컙)
RUN apk add --no-cache tzdata
ENV TZ=Asia/Seoul
# ??<3F><><EFBFBD>??JAR ???<3F><> 蹂듭<E8B982>?
COPY --from=builder /app/build/libs/*.jar app.jar
# 湲곕???????몄텧
EXPOSE 8081
# ?<3F>⑦???<3F><>???<3F>뻾 ??JAR ??<3F>
ENTRYPOINT ["java", "-jar", "app.jar"]

31
HELP.md Normal file
View File

@@ -0,0 +1,31 @@
# Read Me First
The following was discovered as part of building this project:
* The original package name 'io.shinhanlife.chat-backend' is invalid and this project uses 'io.shinhanlife.chatbackend' instead.
# Getting Started
### Reference Documentation
For further reference, please consider the following sections:
* [Official Apache Maven documentation](https://maven.apache.org/guides/index.html)
* [Spring Boot Maven Plugin Reference Guide](https://docs.spring.io/spring-boot/3.5.3/maven-plugin)
* [Create an OCI image](https://docs.spring.io/spring-boot/3.5.3/maven-plugin/build-image.html)
* [Spring Web](https://docs.spring.io/spring-boot/3.5.3/reference/web/servlet.html)
* [Spring Session for Spring Data Redis](https://docs.spring.io/spring-session/reference/)
* [Spring Data JPA](https://docs.spring.io/spring-boot/3.5.3/reference/data/sql.html#data.sql.jpa-and-spring-data)
### Guides
The following guides illustrate how to use some features concretely:
* [Building a RESTful Web Service](https://spring.io/guides/gs/rest-service/)
* [Serving Web Content with Spring MVC](https://spring.io/guides/gs/serving-web-content/)
* [Building REST services with Spring](https://spring.io/guides/tutorials/rest/)
* [Accessing Data with JPA](https://spring.io/guides/gs/accessing-data-jpa/)
### Maven Parent overrides
Due to Maven's design, elements are inherited from the parent POM to the project POM.
While most of the inheritance is fine, it also inherits unwanted elements like `<license>` and `<developers>` from the parent.
To prevent this, the project POM contains empty overrides for these elements.
If you manually switch to a different parent and actually want the inheritance, you need to remove those overrides.

57
McpBridge.java Normal file
View File

@@ -0,0 +1,57 @@
/**
* @package io.shinhanlife
* @className McpBridge
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
public class McpBridge {
public static void main(String[] args) {
HttpClient client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(5))
.build();
try (BufferedReader reader = new BufferedReader(new InputStreamReader(System.in))) {
String line;
while ((line = reader.readLine()) != null) {
line = line.trim();
if (line.isEmpty()) continue;
try {
HttpRequest req = HttpRequest.newBuilder()
// Spring AI MCP Server??怨듭떇 ?⑥씪 ?붾뱶?ъ씤??
.uri(URI.create("http://localhost:8081/mcp"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(line))
.build();
HttpResponse<String> response = client.send(req, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
System.out.flush();
} catch (Exception e) {
// MCP ?쒖? ?먮윭 ?щ㎎?쇰줈 諛섑솚 (?꾩쓽濡?id 異붿텧 ?쒖쇅, 理쒖냼?쒖쓽 ?먮윭 ?묐떟)
System.out.println("{\"jsonrpc\":\"2.0\",\"error\":{\"code\":-32603,\"message\":\"" + e.getMessage().replace("\"", "\\\"") + "\"}}");
System.out.flush();
}
}
} catch (Exception e) {
e.printStackTrace();
}
}
}

606
README.md Normal file
View File

@@ -0,0 +1,606 @@
# AX HUB MCP Tool
신한라이프 업무 시스템과 AI Agent를 연결하는 MCP(Model Context Protocol) Gateway 및 Tool 서버 프로젝트입니다.
Agent는 Gateway에서 Tool 목록과 입력 명세를 받고, Gateway는 권한과 정책을 확인한 뒤 Tool 서버로 요청을 전달합니다. 업무 Tool은 `DTO → UseCase → Converter → MCI/EAI Client` 구조로 레거시 시스템을 호출합니다.
## 전체 흐름
```text
AI Agent / MCP Client
MCP Gateway (dap-gateway)
├─ Tool 목록·스키마 제공
├─ Tool 권한·승인·가드레일 확인
├─ Redis Registry 및 실행 추적
└─ 대상 Tool 서버로 라우팅
Tool Server (dap-tool-sms / dap-tool-oth)
└─ BusinessToolController
UseCase → Converter → MCI/EAI Client → 레거시 시스템
```
## 현재 구조와 목표 구조
현재는 Gateway와 두 개의 Tool 애플리케이션으로 구성됩니다.
```text
현재: Gateway + SMS Tool Pod + OTH Tool Pod
목표: Gateway + 고객 Pod + 영업 Pod + 지급/납입 Pod + 알림 Pod + 인사 Pod + 공통 Pod
```
`dap-tool-oth`에는 여러 업무 카테고리가 함께 있습니다. AA 협의 후에는 부서·업무 소유권 단위로 Tool 서버, 이미지, Pod, 배포 파이프라인을 분리합니다. 이 목표 구조는 향후 전환 방향이며 현재 구현 완료 상태가 아닙니다.
## Gradle 멀티모듈
| 모듈 | 역할 | 실행 포트 |
|---|---|---:|
| `dap-gateway` | MCP 진입점, Tool Registry, 라우팅, 권한·가드레일, Chat API | 8081 |
| `dap-tool-core` | 공통 어노테이션, Controller, JSON Schema, MCI/EAI 지원, 보안·로깅 공통 기능 | 라이브러리 |
| `dap-tool-sms` | SMS/알림 Tool 서버 | 8082 |
| `dap-tool-oth` | 공통·업무·샘플·MCI Tool 서버 | 8084 |
기술 기준은 Java 21, Spring Boot 4, Gradle, Spring AI MCP Server, Redis, MapStruct, MyBatis, Resilience4j입니다.
## 환경 (Environment)
| 항목 | 버전 / 기준 |
|---|---|
| Java | 21 |
| Spring Boot | 4.0.5 |
| Gradle Wrapper | 8.14.3 |
| Spring AI BOM | 2.0.0 |
| Spring AI MCP Server | `spring-ai-starter-mcp-server-webmvc` |
| Redis Client | Lettuce 6.6.0.RELEASE |
| Resilience | Resilience4j 2.2.0 |
| MyBatis Spring Boot Starter | 3.0.3 |
| MapStruct | 1.5.5.Final |
| Lombok | 1.18.32 |
| JSON Schema Validator | networknt 1.4.0 |
| OpenAPI UI | springdoc 2.5.0 |
| 컨테이너 실행 | Docker Compose |
프로젝트는 JDK 21을 기준으로 컴파일됩니다. IntelliJ에서는 Project SDK, Gradle JVM, Run Configuration JRE를 모두 JDK 21로 맞춰야 합니다.
## 5분 빠른 시작
Docker와 JDK 21이 준비된 로컬 개발 환경 기준입니다.
```powershell
# 1. Redis와 MCI Mock만 먼저 실행
$env:ACTIVE_PROFILE = 'local'
docker compose up -d redis mci-mock
# 2. Gateway 실행 (새 PowerShell)
$env:SPRING_PROFILES_ACTIVE = 'local'
$env:OPENROUTER_API_KEY = '<개발용 비밀 저장소의 키>'
.\gradlew.bat :dap-gateway:bootRun
# 3. OTH Tool 실행 (또 다른 PowerShell)
$env:SPRING_PROFILES_ACTIVE = 'local'
$env:AXHUB_GATEWAY_URL = 'http://localhost:8081'
$env:AXHUB_TOOL_URL = 'http://localhost:8084'
.\gradlew.bat :dap-tool-oth:bootRun
```
Tool 서버가 기동된 뒤 아래 URL로 등록된 Tool 목록을 확인합니다.
```text
http://localhost:8081/mcp/api/v1/tools/list
```
SMS Tool도 함께 확인하려면 별도 PowerShell에서 아래 명령을 실행합니다.
```powershell
$env:SPRING_PROFILES_ACTIVE = 'local'
$env:AXHUB_GATEWAY_URL = 'http://localhost:8081'
$env:AXHUB_TOOL_URL = 'http://localhost:8082'
.\gradlew.bat :dap-tool-sms:bootRun
```
전체 컨테이너 환경이 필요하면 개별 실행 대신 다음 한 줄을 사용합니다.
```powershell
$env:ACTIVE_PROFILE = 'local'
docker compose up -d --build
```
## Tool 등록과 실행
### 등록과 목록 제공
1. Tool 서버 기동 시 `ToolRegistryHeartbeatSender``@McpTool`, `@McpFunction`을 스캔합니다.
2. Tool 이름, 설명, 입력 JSON Schema, `categoryKey`, 연동 방식, 실행 URL을 메타데이터로 생성합니다.
3. Gateway Redis Registry에 등록·Heartbeat 정보를 전송합니다.
4. Agent와 관리 화면은 Gateway에서 Tool 목록과 명세를 조회합니다.
### 실행
1. Agent가 Gateway에 Tool 이름과 입력값을 보냅니다.
2. Gateway가 Tool 존재 여부, 허용 Tool, 쓰기 승인, 가드레일을 확인합니다.
3. Gateway가 Tool 서버의 `/mcp/{toolName}`으로 요청을 전달합니다.
4. `BusinessToolController`가 Tool 메서드를 찾아 DTO로 변환하고 JSON Schema를 검증합니다.
5. UseCase가 업무 흐름을 수행합니다.
6. Converter가 업무 DTO를 인터페이스 ID 기반 MCI 요청 DTO로 변환합니다.
7. MCI/EAI Client가 레거시를 호출하고 결과를 Tool 응답으로 반환합니다.
## 주요 URL
로컬에서 Gateway를 직접 실행할 때의 기준입니다. Docker Compose를 사용하면 Gateway 호스트 포트는 `8281`입니다.
| 용도 | 메서드 | URL |
|---|---|---|
| Tool 목록 | `GET` | `http://localhost:8081/mcp/api/v1/tools/list` |
| Tool 실행 | `POST` | `http://localhost:8081/mcp/api/v1/tools/call` |
| Tool Markdown 문서 | `GET` | `http://localhost:8081/mcp/api/v1/tools/docs/markdown` |
| 카테고리별 SSE MCP 연결 | `GET` | `http://localhost:8081/mcp/sse/{categoryKey}` |
| 카테고리별 MCP 호출 채널 | `POST` | `http://localhost:8081/mcp/custom/{categoryKey}` |
| Chat 스트리밍 API | `POST` | `http://localhost:8081/api/chat` |
| Scaffold API | `POST` | `http://localhost:8081/api/v1/scaffold/pod` 또는 `/tool` |
`/mcp/sse/{categoryKey}`는 SSE 연결을 여는 전송 경로이고, `/mcp/custom/{categoryKey}`는 같은 카테고리의 MCP 요청을 처리하는 호출 경로입니다. 두 URL은 역할이 다릅니다.
## Tool 개발 표준
| 구성 요소 | 책임 |
|---|---|
| `XxxRequest`, `XxxResponse` | Agent/Tool 관점의 입력·응답 DTO |
| `XxxUseCase` | Tool 계약과 MCP 메타데이터 선언 |
| `XxxUseCaseImpl` | 업무 흐름 조합과 Client 호출 |
| `XxxConverter` | 업무 DTO와 레거시 인터페이스 DTO 사이 변환 |
| `MciXxxClient` | Glow/MCI 또는 EAI 통신 호출 |
| `INTERFACE_ID_I`, `INTERFACE_ID_O` | 인터페이스 ID 기준 MCI 요청·응답 DTO |
### Tool 선언
Tool 그룹에는 `@McpTool`, Agent가 호출하는 함수에는 `@McpFunction`을 사용합니다.
```java
@McpTool(routingType = "MCI", categoryKey = "claim")
public interface ClaimInquiryUseCase {
@McpFunction(
name = "claim_inquiry",
displayName = "보험금 청구 조회",
description = "청구 번호로 보험금 청구 상태를 조회합니다.",
mappingId = "CLM00000001"
)
ClaimInquiryResponse inquire(ClaimInquiryRequest request);
}
```
`categoryKey`는 Tool의 업무 그룹입니다. Gateway의 목록 필터링, Agent 권한, 동적 MCP 서버 구분에 사용하므로 합의된 업무 키를 사용합니다.
- `MCI`: 사내 MCI 인터페이스 호출
- `EAI`: EAI 연동
- `DIRECT`: 외부 HTTP 또는 내부 직접 연동
### 변환 원칙
UseCase 구현체는 Tool 요청을 레거시 요청과 섞어 쓰지 않습니다. Converter에서 변환한 뒤 Client에 전달합니다.
```java
@Override
public ClaimInquiryResponse inquire(ClaimInquiryRequest request) {
CLM00000001_I mciRequest = converter.toMciRequest(request);
CLM00000001_O mciResponse = mciClmClient.callClm00000001(mciRequest);
return converter.toResponse(mciResponse);
}
```
MCI 입출력 객체는 업무 이름이 아니라 인터페이스 ID를 기준으로 둡니다.
```text
CLCNNB00001_I : CLCNNB00001 요청 DTO
CLCNNB00001_O : CLCNNB00001 응답 DTO
```
예를 들어 `Onnba3011Request``CLCNNB00001_I`는 같은 업무 데이터를 담을 수 있지만 같은 객체가 아닙니다. 둘 사이의 변환 책임은 `Onnba3011Converter`에 둡니다.
### 새 Tool 추가 체크리스트
새 업무 Tool을 추가할 때는 아래 순서로 확인합니다.
- [ ] 소속 모듈과 `categoryKey`를 업무 소유 조직 기준으로 결정한다.
- [ ] Agent 입력·응답 DTO인 `XxxRequest`, `XxxResponse`를 만든다.
- [ ] `XxxUseCase``@McpTool`을 선언하고, 호출 메서드에 `@McpFunction`의 이름·설명·연동 ID를 선언한다.
- [ ] `XxxUseCaseImpl`에서 업무 흐름만 조합한다.
- [ ] `XxxConverter`에 업무 DTO ↔ 인터페이스 ID DTO 변환을 둔다.
- [ ] `MciXxxClient``INTERFACE_ID_I`, `INTERFACE_ID_O`를 인터페이스 ID 기준으로 만든다.
- [ ] DTO에 Bean Validation을 선언하고, 중첩 DTO가 있으면 입력 Schema와 검증 대상에 포함되는지 확인한다.
- [ ] 조회·변경 작업 특성에 따라 `readOnlyHint`, `requiresApproval`, `idempotentHint`를 설정한다.
- [ ] 단위 테스트를 작성하고 `:dap-tool-core:test` 또는 대상 모듈 테스트를 실행한다.
- [ ] Tool 서버 기동 후 `/mcp/api/v1/tools/list`에서 이름, 설명, category, JSON Schema가 맞는지 확인한다.
- [ ] 요청·응답 로그에 개인정보나 인증값이 남지 않는지 확인한다.
### 등록·노출 제어
| 속성 | 의미 |
|---|---|
| `register` | Redis Registry와 Gateway 카탈로그에 등록할지 여부 |
| `visible` | Agent/클라이언트 목록에 표시할지 여부 |
| `requiresApproval` | 쓰기·고위험 작업의 승인 요구 여부 |
| `readOnlyHint`, `destructiveHint`, `idempotentHint` | Agent 호출 특성 힌트 |
`register = false`인 Tool은 자동 카탈로그 등록 대상이 아닙니다. 별도 실행 목적이 있는 경우에만 사용하고, 필요한 fallback 경로를 운영 설정으로 확인합니다.
## 빌드·테스트·로컬 실행
### 사전 조건
- JDK 21
- Gradle Wrapper 사용 권장
- 로컬 Redis 또는 Docker Compose 환경
- 필요 시 MCI Mock 또는 사내 MCI/EAI 접근 환경
### 전체 빌드와 대표 검증
```powershell
.\gradlew.bat clean build
.\gradlew.bat :dap-tool-core:test
.\gradlew.bat :dap-tool-core:compileJava
```
### 애플리케이션 실행
각 애플리케이션은 별도 PowerShell에서 실행합니다.
```powershell
# Gateway
.\gradlew.bat :dap-gateway:bootRun
# SMS Tool
.\gradlew.bat :dap-tool-sms:bootRun
# 기타 업무 Tool
.\gradlew.bat :dap-tool-oth:bootRun
```
기본 프로필은 `local`입니다. 개발 서버 설정이 필요하면 실행 환경에 프로필을 지정합니다.
```powershell
$env:SPRING_PROFILES_ACTIVE = 'dev'
.\gradlew.bat :dap-tool-oth:bootRun
```
## Docker Compose 실행
Docker Compose는 Redis, Gateway, MCI Mock, SMS Tool, OTH Tool을 함께 기동합니다.
```powershell
$env:ACTIVE_PROFILE = 'local'
docker compose up -d --build
docker compose ps
```
| 서비스 | 컨테이너 포트 | 호스트 포트 |
|---|---:|---:|
| Redis | 6379 | 6379 |
| Gateway | 8081 | 8281 |
| MCI Mock | 8080 | 8089 |
| SMS Tool | 8082 | 8282 |
| OTH Tool | 8084 | 8284 |
컨테이너 내부와 PC 브라우저의 접속 주소는 다릅니다.
```text
컨테이너 내부: http://gateway:8081, http://tool-oth:8084
PC 브라우저: http://localhost:8281, http://localhost:8284
```
## 환경 설정
### 프로필
기본 프로필은 `local`입니다. Tool 서버와 Gateway 모두 `local`, `dev` 프로필 파일을 사용합니다.
| 프로필 | 목적 | 주요 설정 파일 |
|---|---|---|
| `local` | PC 개발·MCI Mock·H2 메모리 DB 기반 실행 | `application-local.yml` |
| `dev` | 개발 서버 배포 실행 | `application-dev.yml` |
프로필은 환경 변수로 지정합니다.
```powershell
# 로컬 개발
$env:SPRING_PROFILES_ACTIVE = 'local'
# 개발 서버 설정으로 실행
$env:SPRING_PROFILES_ACTIVE = 'dev'
```
### 환경별 연결 주소 비교
| 항목 | `local` 프로세스 실행 | Docker Compose | `dev` 프로필 |
|---|---|---|---|
| Gateway 접근 주소 | `http://localhost:8081` | 컨테이너 내부 `http://gateway:8081` | 배포 환경 Gateway URL |
| SMS Tool 주소 | `http://localhost:8082` | `http://tool-sms:8082` | `PORT` 기본 8082 |
| OTH Tool 주소 | `http://localhost:8084` | `http://tool-oth:8084` | `PORT` 기본 8084 |
| Redis 주소 | 로컬 Redis 또는 `localhost:6379` | `redis:6379` | 운영/개발 Redis 설정 |
| MCI/EAI 대상 | MCI Mock 또는 로컬 설정 | `mci-mock:8080` | 개발망 연동 설정 |
| 브라우저 Gateway 접속 | `http://localhost:8081` | `http://localhost:8281` | 운영·개발 도메인 |
`local`에서 프로세스를 직접 실행하면 Tool의 `AXHUB_GATEWAY_URL``localhost`를 사용합니다. Docker에서는 각 컨테이너가 서로 다른 네트워크 공간에 있으므로 반드시 Compose 서비스 이름을 사용합니다.
### Gateway 환경 변수
| 변수 | 적용 대상 | 설명 | 로컬 기본값/예시 |
|---|---|---|---|
| `SPRING_PROFILES_ACTIVE` | Gateway, 모든 Tool | 활성 Spring 프로필 | `local` |
| `OPENROUTER_API_KEY` | Gateway | Chat/LLM 호출 API 키 | 운영·개발 환경의 비밀 저장소에서 주입 |
| `SPRING_DATA_REDIS_HOST` | Gateway, 모든 Tool | Redis 호스트 | Docker: `redis` |
| `SPRING_DATA_REDIS_PORT` | Gateway, 모든 Tool | Redis 포트 | `6379` |
| `MCP_GATEWAY_FALLBACK_DEFAULT_URL` | Gateway | Registry에 없는 Tool의 기본 fallback URL | Docker: `http://tool-oth:8084` |
| `MCP_GATEWAY_FALLBACK_ROUTES_SMS` | Gateway | SMS 계열 fallback URL | Docker: `http://tool-sms:8082` |
| `ACTIVE_PROFILE` | Docker Compose | Compose가 `SPRING_PROFILES_ACTIVE`에 전달할 프로필 | `local` |
`OPENROUTER_API_KEY` 같은 인증값은 `application.yml`, README, Git 커밋에 직접 넣지 않습니다. 개발·운영 환경의 Secret, CI/CD 변수 또는 안전한 환경 변수로 주입합니다.
### Tool 서버 환경 변수
| 변수 | 적용 대상 | 설명 | 로컬 기본값/예시 |
|---|---|---|---|
| `PORT` | `dap-tool-sms`, `dap-tool-oth` | 개발 프로필에서 Tool 서버 포트 변경 | SMS `8082`, OTH `8084` |
| `AXHUB_GATEWAY_URL` | 모든 Tool | Tool 등록·Heartbeat 대상 Gateway 주소 | 로컬 `http://localhost:8081`, Docker `http://gateway:8081` |
| `AXHUB_TOOL_URL` | 모든 Tool | Gateway가 해당 Tool Pod를 호출할 주소 | 로컬 `http://localhost:{server.port}` |
| `GLOW_COMMUNICATION_MCI_HOMT` | Docker Tool 컨테이너 | MCI 대상 호스트 | 로컬 Compose는 `mci-mock` |
| `GLOW_COMMUNICATION_MCI_PORT` | Docker Tool 컨테이너 | MCI 대상 포트 | 로컬 Compose는 `8080` |
| `GLOW_COMMUNICATION_EAI_HOMT` | Docker Tool 컨테이너 | EAI 대상 호스트 | 로컬 Compose는 `mci-mock` |
| `GLOW_COMMUNICATION_EAI_PORT` | Docker Tool 컨테이너 | EAI 대상 포트 | 로컬 Compose는 `8080` |
`AXHUB_TOOL_URL`은 반드시 Gateway가 실제로 접근 가능한 주소여야 합니다. PC에서 각각 실행할 때는 `localhost`를 사용하고, Docker 컨테이너 안에서는 `tool-sms`, `tool-oth` 같은 Compose 서비스 이름을 사용합니다.
### 로컬 실행용 권장 설정
아래는 키 값 없이 로컬 프로세스를 실행하는 예시입니다. Redis를 Docker로 먼저 기동하거나 전체 Docker Compose를 사용합니다.
```powershell
# 선택 1: Redis만 기동
$env:ACTIVE_PROFILE = 'local'
docker compose up -d redis mci-mock
# 선택 2: 각 프로세스를 로컬에서 기동
$env:SPRING_PROFILES_ACTIVE = 'local'
$env:OPENROUTER_API_KEY = '<개인 또는 개발용 비밀 저장소의 키>'
.\gradlew.bat :dap-gateway:bootRun
```
다른 PowerShell에서 Tool을 실행합니다.
```powershell
$env:SPRING_PROFILES_ACTIVE = 'local'
$env:AXHUB_GATEWAY_URL = 'http://localhost:8081'
$env:AXHUB_TOOL_URL = 'http://localhost:8084'
.\gradlew.bat :dap-tool-oth:bootRun
```
### 권한 도메인 설정
`mcp.security.tenant-domains`는 테넌트 또는 호출 주체가 접근할 수 있는 `categoryKey`를 정의합니다. 운영 환경에서는 `ALL`을 무분별하게 사용하지 않고, Agent·조직별 허용 도메인을 최소 권한으로 설정합니다.
```yaml
mcp:
security:
tenant-domains:
claims-agent: claim, customer
notification-agent: notification
```
## 공통 기능과 운영 기준
### Redis
Redis는 Tool Registry의 등록 상태와 Heartbeat, Gateway 실행 추적 정보에 사용됩니다. 현재 Redis는 PII 원문 보관소가 아닙니다.
### 권한과 승인
Gateway는 Agent가 전달한 허용 Tool 목록, 서버 정책, 신뢰된 Claim 여부, 쓰기 승인 여부를 확인합니다. Tool마다 권한 로직을 중복 구현하지 말고 Gateway 공통 정책과 `@McpFunction` 메타데이터를 사용합니다.
### Trace ID와 Request ID
```text
trace-id : 사용자 요청 전체에서 유지되는 상관관계 ID
request-id : Gateway → Tool, Tool → MCI 등 HTTP 호출마다 새로 생성되는 ID
```
Gateway와 Tool에는 관련 헤더 및 MDC 기반 로그 처리가 있습니다. 신규 HTTP Client도 공통 전파 정책을 따르며, Tool별로 임의의 헤더 이름을 추가하지 않습니다.
### 로그와 개인정보
Gateway에는 민감 키와 일부 형식을 마스킹하는 공통 기능이 있습니다. 마스킹은 원문을 Agent에서 분리하는 PII 토큰화와는 다릅니다.
- 요청·응답 전문을 로그에 남길 때는 반드시 마스킹합니다.
- 운영 로그에 주민번호, 계좌번호, 전화번호, 이메일, 인증값을 남기지 않습니다.
- Tool 서버의 신규 로그도 같은 마스킹 정책을 적용합니다.
- 민감정보 복원 필요 여부는 Tool 개발자가 임의로 결정하지 않고 보안·AA 정책을 따릅니다.
## AA 협의 기반 향후 전환 과제
아래 항목은 현재 구현 완료 기능이 아니라 회의에서 합의한 목표 구조입니다.
### 부서별 Tool Pod와 저장소 경계
현재 `dap-tool-oth`에 함께 있는 업무 Tool을 부서·업무 소유권 단위로 분리합니다. 각 Pod는 독립 이미지, 독립 배포, 독립 장애 범위를 갖도록 구성합니다. 실제 분리는 AA가 확정한 Tool 소유 부서와 운영 책임 매핑을 기준으로 수행합니다.
### Agent별 Tool 노출 수 제한
Agent에게 모든 Tool을 한 번에 제공하지 않고, 업무 도메인과 권한에 따라 약 10~20개 수준의 Tool 그룹을 제공합니다. `categoryKey`는 이를 위한 기초 메타데이터이며, 향후 Agent-Tool Group 정책으로 확장합니다.
### PII 토큰화
```text
원문 개인정보
→ PII Gateway가 Redis에 짧은 TTL로 보관
→ Agent에는 PII 토큰 또는 안전한 식별자만 전달
→ 인가된 Tool이 MCI 호출 직전에 필요한 항목만 복원
```
이 전환 전까지는 현행 마스킹 기능을 PII 분리 구현으로 오해하지 않아야 합니다.
### MCP SDK와 Glow Framework
Gateway에는 Spring AI MCP Server 의존성이 포함되어 있습니다. MCP SDK/Glow 표준 적용 시에는 업무 Tool을 재작성하지 않고, 기존 `@McpTool`·`@McpFunction`과 UseCase를 표준 MCP Tool 명세·호출 콜백으로 연결하는 Adapter 계층을 공통 Core에 추가합니다.
```text
MCP SDK 표준 tools/list, tools/call
공통 Adapter
기존 UseCase → Converter → MCI Client
```
따라서 업무 DTO, Converter, MCI Client의 책임은 유지됩니다.
## 참고 소스
| 주제 | 대표 위치 |
|---|---|
| Gateway Tool API | `dap-gateway/.../presentation/McpRouterController.java` |
| 동적 MCP SSE/호출 경로 | `dap-gateway/.../sync/DynamicMcpController.java` |
| Tool 실행 Controller | `dap-tool-core/.../presentation/BusinessToolController.java` |
| Tool 자동 등록 | `dap-tool-core/.../usecase/ToolRegistryHeartbeatSender.java` |
| Tool 어노테이션 | `dap-tool-core/.../annotation/McpTool.java`, `McpFunction.java` |
| Tool 예시 | `dap-tool-oth/.../biz/oth`, `biz/sol`, `biz/smp` |
| SMS Tool 예시 | `dap-tool-sms/.../biz/sms` |
| Docker 환경 | `docker-compose.yml` |
---
문서에 없는 업무·보안·배포 기준은 임의로 추가하지 말고 AA 및 플랫폼 운영 기준과 먼저 합의합니다.
## Input/Output Schema 작성 가이드
Tool Schema는 Agent가 Tool을 정확히 호출하고, 반환값의 의미를 일관되게 해석하도록 하는 계약입니다. 인증 정보·사번·주민번호 등 민감정보(PII)는 Input/Output Schema와 Tool 응답에 포함하지 않습니다.
### Input Schema
Input Schema는 Agent가 Tool에 전달하는 파라미터의 이름, 타입, 필수 여부, 허용값, 형식 등을 정의합니다.
적용 우선순위는 다음과 같습니다.
1. `inputSchemaResource` — 복잡한 규칙을 담은 JSON Schema 리소스
2. `inputSchema` — 어노테이션에 직접 선언한 JSON Schema
3. 요청 DTO 필드의 `@McpValidation` — 자동 JSON Schema 생성
단순한 요청 DTO는 `@McpValidation`만으로 관리합니다.
```java
public class ClaimSearchRequest {
@McpValidation(required = true, pattern = "^CLM[0-9]{13}$")
private String claimNo;
@McpValidation(minimum = 1, maximum = 100)
private Integer size;
}
```
### Output Schema
Output Schema는 Tool이 반환하는 결과의 타입과 의미를 정의합니다. `BusinessToolController`는 Tool 실행 후 반환값을 Output Schema 기준으로 검증합니다.
적용 우선순위는 다음과 같습니다.
1. `outputSchemaResource` — 조건부 필드·중첩 배열 등 복잡한 규칙을 담은 JSON Schema 리소스
2. `outputSchema` — 어노테이션에 직접 선언한 JSON Schema
3. 반환 DTO의 `@McpOutputSchema`와 필드 `@McpValidation` — 자동 JSON Schema 생성
4. 위 설정이 모두 없으면 Output Schema 검증을 수행하지 않음
따라서 단순한 응답은 별도 `outputSchemaResource` 없이 반환 DTO에 `@McpOutputSchema`를 선언하면 됩니다. `null`이 정상 값일 수 있는 필드는 `nullable = true`를 반드시 지정합니다.
```java
@McpOutputSchema
public class ClaimSearchResponse {
@McpValidation(required = true, allowedValues = {"SUCCESS", "FAILURE"})
private String resultCode;
@McpValidation(nullable = true, minimum = 0)
private Long approvedAmount;
}
```
### 복잡한 Schema는 Tool 모듈별 리소스로 관리
조건부 응답, 중첩 DTO, 배열 정렬 기준처럼 어노테이션만으로 표현하기 어려운 규칙은 Tool Core가 아니라 각 Tool 모듈의 리소스에 JSON Schema로 둡니다.
```text
src/main/resources/
└─ tool-schemas/
└─ {categoryKey}/
├─ claim-search-resource-input-schema.json
└─ claim-search-resource-output-schema.json
```
예를 들어 `categoryKey``cmm`이면 아래와 같이 선언합니다.
```java
@McpFunction(
name = "oth.cmm.claim.search",
inputSchemaResource = "classpath:tool-schemas/cmm/claim-search-resource-input-schema.json",
outputSchemaResource = "classpath:tool-schemas/cmm/claim-search-resource-output-schema.json"
)
public ClaimSearchResponse search(ClaimSearchRequest request) {
// ...
}
```
`inputSchemaResource``outputSchemaResource`는 복잡한 경우에만 선언합니다. 단순한 Tool까지 JSON 파일을 별도 생성할 필요는 없습니다.
### Output 설계 규칙
- 코드와 표시용 라벨을 함께 반환합니다. 예: `status` + `statusLabel`
- `null`이 정상인 값은 의미를 설명에 명시하고 DTO에는 `nullable = true`를 설정합니다.
- 조건부 필드는 어떤 조건에서 값이 존재하는지 JSON Schema에 명시합니다.
- 배열은 정렬 기준을 설명에 명시합니다. 예: `접수일 내림차순`
- 목록 응답에는 추가 조회 여부를 나타내는 `hasMore`를 포함합니다.
- 민감정보는 마스킹보다 **응답에서 제외**하는 것을 우선합니다.
### 실행 로그 및 확인
Tool 실행이 끝나면 아래 로그는 Schema 정의가 아니라 **검증을 통과한 실제 최종 응답값**을 출력합니다.
```text
[Tool -> MCP Gateway] Output Schema Result: { ... }
```
따라서 로그에도 실제 응답이 남으므로, 응답 DTO와 Output Schema에 민감정보가 포함되지 않도록 설계해야 합니다.
스키마 리소스와 DTO 기반 자동 Schema는 아래 테스트로 함께 검증할 수 있습니다.
```powershell
.\gradlew.bat :dap-tool-oth:test --tests "io.shinhanlife.dap.mcc.biz.cmm.dto.ClaimSearchRequestSchemaTest"
```
### Tool Naming Convention
All Tool names use the four-level lowercase format `pod.domain.service.action`. Do not use underscores or CamelCase; use a hyphen (`-`) only when a single level has multiple words.
- `pod`: deployment Tool Pod/module (`dap-tool-oth``oth`, `dap-tool-sms``sms`)
- `domain`: business-domain package (`cmm`, `smp`, `sol`, etc.)
- `service`: business service or resource
- `action`: the requested operation (`search`, `list`, `detail`, `issue`, `inquiry`, etc.)
```text
oth.cmm.bond.issue
oth.cmm.claim.search
oth.sol.request.list
oth.smp.weather.inquiry
```
When Scaffold receives `dap-tool-oth`, `cmm`, and `ClaimSearch`, it generates `oth.cmm.claim.search`. The `validateMcpToolNames` Gradle task rejects both a duplicate name and any name outside this format before packaging, including its source file and line number.
### Tool Test Console
각 Tool Pod는 공통 테스트 화면을 제공합니다.
```text
http://localhost:8084/tool-test-console.html
```
화면은 현재 Pod의 `/tool-manifest`에서 Tool 목록과 `inputSchema`를 읽습니다. Tool을 선택한 뒤 `Schema 샘플 채우기`로 요청 JSON을 만들고 실행할 수 있습니다. 업무에 맞게 보정한 요청은 `현재 요청 저장`으로 브라우저의 `localStorage`에 보관합니다.
`Run saved cases`는 저장된 테스트 케이스를 순차 실행해 성공/실패, HTTP 상태, 소요 시간을 보여줍니다. 따라서 Tool이 수백 개여도 각 Tool마다 테스트 화면을 만들 필요 없이, 유효한 업무 테스트 데이터만 한 번 저장하면 이후에는 몇 번의 클릭으로 회귀 테스트할 수 있습니다.
- Tool 호출은 현재 Pod의 `/mcp/{toolName}`로 수행합니다.
- 매 실행마다 `trace-id`, `request-id`를 새로 생성하여 응답과 함께 표시합니다.
- 외부 MCI/EAI Tool은 샘플값 대신 개발계에서 허용된 테스트 데이터를 저장해서 사용해야 합니다.

68
build.gradle Normal file
View File

@@ -0,0 +1,68 @@
plugins {
id 'java'
id 'org.springframework.boot' version '4.0.5' apply false
id 'io.spring.dependency-management' version '1.1.6' apply false
}
allprojects {
group = 'io.shinhanlife'
version = '0.0.1-SNAPSHOT'
}
subprojects {
apply plugin: 'java'
apply plugin: 'io.spring.dependency-management'
java {
sourceCompatibility = '21'
}
repositories {
mavenCentral()
maven { url 'https://repo.spring.io/milestone' }
maven { url 'https://repo.spring.io/snapshot' }
}
dependencyManagement {
imports {
mavenBom org.springframework.boot.gradle.plugin.SpringBootPlugin.BOM_COORDINATES
mavenBom "org.springframework.ai:spring-ai-bom:2.0.0"
}
}
dependencies {
compileOnly 'org.projectlombok:lombok:1.18.32'
annotationProcessor 'org.projectlombok:lombok:1.18.32'
annotationProcessor 'org.projectlombok:lombok-mapstruct-binding:0.2.0'
implementation 'org.mapstruct:mapstruct:1.5.5.Final'
annotationProcessor 'org.mapstruct:mapstruct-processor:1.5.5.Final'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}
tasks.withType(JavaCompile) {
options.compilerArgs << '-parameters'
options.compilerArgs << '-Amapstruct.defaultComponentModel=spring'
}
tasks.withType(Test) {
useJUnitPlatform()
}
}
def toolCoreProject = project(':dap-tool-core')
tasks.register('validateMcpToolNames', JavaExec) {
group = 'verification'
description = 'Checks duplicate @McpFunction names across all Tool modules before packaging.'
dependsOn toolCoreProject.tasks.named('classes')
classpath = toolCoreProject.sourceSets.main.runtimeClasspath
mainClass.set('io.shinhanlife.dap.lib.validation.McpToolNameValidationRunner')
args rootProject.projectDir.absolutePath
}
subprojects {
tasks.matching { it.name == 'bootJar' }.configureEach {
dependsOn rootProject.tasks.named('validateMcpToolNames')
}
}

View File

@@ -0,0 +1,40 @@
plugins {
id 'java-library'
}
dependencies {
api 'org.springframework.boot:spring-boot-starter-web'
api 'org.springframework.boot:spring-boot-starter-validation'
api 'org.springframework.boot:spring-boot-starter-data-redis'
api 'io.github.resilience4j:resilience4j-spring-boot3:2.2.0'
api 'io.github.resilience4j:resilience4j-core:2.2.0'
api 'io.github.resilience4j:resilience4j-circuitbreaker:2.2.0'
api 'io.github.resilience4j:resilience4j-ratelimiter'
api 'io.github.resilience4j:resilience4j-retry:2.2.0'
api 'org.springframework.boot:spring-boot-starter-aop:3.3.0'
api 'org.springframework.boot:spring-boot-starter-jdbc'
api 'org.mybatis.spring.boot:mybatis-spring-boot-starter:3.0.3'
api 'com.h2database:h2'
api 'p6spy:p6spy:3.9.1'
api 'io.lettuce:lettuce-core:6.6.0.RELEASE'
api "org.mapstruct:mapstruct:1.5.5.Final"
api 'com.fasterxml.jackson.dataformat:jackson-dataformat-xml:2.17.1'
api 'com.fasterxml.jackson.core:jackson-databind:2.17.1'
api 'com.networknt:json-schema-validator:3.0.0'
api 'org.springframework.ai:spring-ai-starter-mcp-server-webmvc'
api 'org.springframework.kafka:spring-kafka:3.2.0'
api 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.5.0'
}
dependencies {
compileOnly 'org.projectlombok:lombok:1.18.32'
annotationProcessor 'org.projectlombok:lombok:1.18.32'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testCompileOnly 'org.projectlombok:lombok:1.18.32'
testAnnotationProcessor 'org.projectlombok:lombok:1.18.32'
}

View File

@@ -0,0 +1,63 @@
package io.shinhanlife.dap.lib.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;
/**
* @package io.shinhanlife.dap.lib.adapter.aop
* @className EimsMonitoringAspect
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
@Slf4j
@Aspect
@Component
public class EimsMonitoringAspect {
// 포인트컷: io.shinhanlife.dap.lib.adapter.sender 패키지 내의 EimsSender를 구현한 모든 클래스의 메서드를 타겟으로 지정합니다.
@Around("execution(* io.shinhanlife.dap.lib.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,72 @@
package io.shinhanlife.dap.lib.adapter.connector;
import io.shinhanlife.dap.lib.adapter.support.DynamicPayloadBuilder;
import io.shinhanlife.dap.lib.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;
/**
* @package io.shinhanlife.dap.lib.adapter.connector
* @className ExternalApiConnector
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
@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,74 @@
package io.shinhanlife.dap.lib.adapter.connector;
import io.shinhanlife.dap.lib.adapter.support.DynamicPayloadBuilder;
import io.shinhanlife.dap.lib.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;
/**
* @package io.shinhanlife.dap.lib.adapter.connector
* @className InternalSystemConnector
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
@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,58 @@
package io.shinhanlife.dap.lib.adapter.connector;
import io.shinhanlife.dap.lib.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;
/**
* @package io.shinhanlife.dap.lib.adapter.connector
* @className LegacyDbConnector
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
@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,151 @@
package io.shinhanlife.dap.lib.adapter.connector;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.shinhanlife.dap.lib.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.dap.lib.adapter.util.LegacyDataTransformer;
/**
* @package io.shinhanlife.dap.lib.adapter.connector
* @className LegacyEimsConnector
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
@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 mciStringEimsSender; // 실시간 연계 (String 전문 버전)
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단계 라우팅 분기 처리 (Switch Expression 활용)
String upperRoutingType = routingType != null ? routingType.toUpperCase() : "";
String legacyResponse = switch (upperRoutingType) {
case "HTTP" -> {
log.info("[라우팅] EIMS API (HTTP) 통신으로 전달");
yield httpEimsSender.send(interfaceId, payload);
}
case "TCP" -> {
log.info("[라우팅] EIMS 소켓 (TCP) 통신으로 전달");
yield tcpEimsSender.send(interfaceId, payload);
}
case "JSP_FORM" -> {
log.info("[라우팅] JSP Form 통신으로 전달");
yield jspFormEimsSender.send(interfaceId, payload);
}
case "JSP_JSON" -> {
log.info("[라우팅] JSP JSON 통신으로 전달");
yield jspJsonEimsSender.send(interfaceId, payload);
}
case "MCI" -> {
if (isStringMci(spec)) {
log.info("[라우팅] 스펙 자동 판별 (String 포맷 감지) -> MCI 연계 어댑터(String)를 통해 EIMS 전달");
yield mciStringEimsSender.send(interfaceId, payload);
} else {
log.info("[라우팅] 실시간 AI 요청 -> MCI 연계 어댑터를 통해 EIMS 전달");
yield mciEimsSender.send(interfaceId, payload);
}
}
case "EAI" -> {
log.info("[라우팅] 비동기/대용량 요청 -> EAI 연계 어댑터를 통해 EIMS 전달");
yield eaiEimsSender.send(interfaceId, payload);
}
default -> {
log.error("[라우팅] 알 수 없는 라우팅 타입: {}", routingType);
throw new IllegalArgumentException("지원하지 않는 라우팅 타입입니다: " + routingType);
}
};
log.info("\n [Legacy -> Adapter] EIMS 통신 응답 수신: {}", legacyResponse);
return legacyResponse;
}
// 비상용 응답 메서드 (차단기가 열려있거나, 타임아웃/에러가 났을 때 실행됨)
// 주의: 파라미터는 원본 메서드와 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 "{}";
}
}
private boolean isStringMci(List<Map<String, Object>> spec) {
if (spec == null || spec.isEmpty()) return false;
// 스펙 내에 maxLength 등 고정길이 전문 관련 속성이 하나라도 존재하거나 명시적으로 STRING 힌트가 있으면 String 전문으로 간주
return spec.stream().anyMatch(field ->
field.containsKey("maxLength") ||
field.containsKey("byteSize") ||
"STRING".equalsIgnoreCase(String.valueOf(field.get("mciFormat")))
);
}
}

View File

@@ -0,0 +1,71 @@
package io.shinhanlife.dap.lib.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;
/**
* @package io.shinhanlife.dap.lib.adapter.connector
* @className ThirdPartySecurityConnector
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
@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);
// 보안 모듈 통신을 위한 특수 페이로드 조립 (예시)
// 실제로는 RestClient나 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,32 @@
package io.shinhanlife.dap.lib.adapter.dto;
import lombok.Builder;
import lombok.NoArgsConstructor;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.Setter;
/**
* @package io.shinhanlife.dap.lib.adapter.dto
* @className ErrorDetail
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
@Getter
@Setter
@AllArgsConstructor
@Builder
@NoArgsConstructor
public class ErrorDetail {
private int code;
private String message;
}

View File

@@ -0,0 +1,27 @@
package io.shinhanlife.dap.lib.adapter.dto;
import lombok.Getter;
import lombok.Setter;
/**
* @package io.shinhanlife.dap.lib.adapter.dto
* @className JsonRpcRequest
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
@Getter
@Setter
public class JsonRpcRequest {
private String jsonrpc;
private String method;
private Params params;
private String id;
}

View File

@@ -0,0 +1,30 @@
package io.shinhanlife.dap.lib.adapter.dto;
import com.fasterxml.jackson.annotation.JsonPropertyOrder;
import lombok.Getter;
import lombok.Setter;
// 2. 응답 DTO
/**
* @package io.shinhanlife.dap.lib.adapter.dto
* @className JsonRpcResponse
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
@Getter
@Setter
@JsonPropertyOrder({"jsonrpc", "result", "error", "id"})
public class JsonRpcResponse {
public String jsonrpc = "2.0";
public Object result;
public Object error;
public String id;
}

View File

@@ -0,0 +1,38 @@
package io.shinhanlife.dap.lib.adapter.dto;
import lombok.Builder;
import lombok.NoArgsConstructor;
import lombok.AllArgsConstructor;
import java.util.List;
import java.util.Map;
import lombok.Getter;
import lombok.Setter;
/**
* @package io.shinhanlife.dap.lib.adapter.dto
* @className Params
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Params {
private String routingType;
private String name;
private String interfaceId;
private Map<String, Object> data;
private List<Map<String, Object>> spec;
}

View File

@@ -0,0 +1,26 @@
package io.shinhanlife.dap.lib.adapter.exception;
/**
* @package io.shinhanlife.dap.lib.adapter.exception
* @className MciCommunicationException
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
public class MciCommunicationException extends RuntimeException {
public MciCommunicationException(String message) {
super(message);
}
public MciCommunicationException(String message, Throwable cause) {
super(message, cause);
}
}

View File

@@ -0,0 +1,104 @@
package io.shinhanlife.dap.lib.adapter.sender;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.dataformat.xml.XmlMapper;
import io.shinhanlife.dap.lib.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;
/**
* @package io.shinhanlife.dap.lib.adapter.sender
* @className EaiEimsSender
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
@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,20 @@
package io.shinhanlife.dap.lib.adapter.sender;
/**
* @package io.shinhanlife.dap.lib.adapter.sender
* @className EimsSender
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
public interface EimsSender {
// 프로토콜에 상관없이 이 메서드 하나로 통일합니다.
String send(String interfaceId, String payload) throws Exception;
}

View File

@@ -0,0 +1,81 @@
package io.shinhanlife.dap.lib.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 org.springframework.http.client.SimpleClientHttpRequestFactory;
import java.util.Map;
import java.util.UUID;
import org.slf4j.MDC;
import io.shinhanlife.dap.lib.config.GlowCommunicationProperties;
/**
* @package io.shinhanlife.dap.lib.adapter.sender
* @className HttpEimsSender
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
@Slf4j
@Component
public class HttpEimsSender implements EimsSender {
private final RestClient restClient;
private final String eimsUrl;
private final GlowCommunicationProperties glowProps;
public HttpEimsSender(GlowCommunicationProperties glowProps) {
this.glowProps = glowProps;
// yml의 대내 MCI host, port, uri를 조합하여 EIMS 호출 주소 생성
this.eimsUrl = glowProps.getMci().getHost() + ":" + glowProps.getMci().getPort() + glowProps.getMci().getUri();
/*
*********************************************** 중요 **************************************************
this.restClient = RestClient.create();
보통 금융권(신한라이프 등 은행/보험사)의 내부 레거시 시스템이나 MCI(Message Channel Integration) 솔루션은 HTTP/2를 기본으로 지원하지 않는 경우가 훨씬 많습니다.
*********************************************** 중요 **************************************************
*/
// HTTP/2 통신 시 Stream Cancelled(RST_STREAM) 에러 방지를 위해 HTTP/1.1 전용 Factory 사용
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
factory.setConnectTimeout(3000);
factory.setReadTimeout(5000);
this.restClient = RestClient.builder().requestFactory(factory).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 = MDC.get("traceId");
if (traceId == null) traceId = "SYSTEM-GENERATED-" + 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,58 @@
package io.shinhanlife.dap.lib.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;
/**
* @package io.shinhanlife.dap.lib.adapter.sender
* @className JspFormEimsSender
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
@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,59 @@
package io.shinhanlife.dap.lib.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;
/**
* @package io.shinhanlife.dap.lib.adapter.sender
* @className JspJsonEimsSender
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
@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,86 @@
package io.shinhanlife.dap.lib.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.http.client.SimpleClientHttpRequestFactory;
import org.springframework.stereotype.Service;
import org.springframework.util.StopWatch;
import org.springframework.web.client.RestClient;
/**
* @package io.shinhanlife.dap.lib.adapter.sender
* @className MciEimsSender
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
@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(); // 클라이언트 초기화
/*
*********************************************** 중요 **************************************************
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
factory.setConnectTimeout(3000);
factory.setReadTimeout(5000);
this.restClient = RestClient.builder().requestFactory(factory).build();
보통 금융권(신한라이프 등 은행/보험사)의 내부 레거시 시스템이나 MCI(Message Channel Integration) 솔루션은 HTTP/2를 기본으로 지원하지 않는 경우가 훨씬 많습니다.
*********************************************** 중요 **************************************************
*/
}
@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,68 @@
package io.shinhanlife.dap.lib.adapter.sender;
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;
/**
* @package io.shinhanlife.dap.lib.adapter.sender
* @className MciStringEimsSender
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
@Slf4j
@Service("mciStringEimsSender")
public class MciStringEimsSender implements EimsSender {
private final RestClient restClient;
private final String mciUrl;
public MciStringEimsSender(@Value("${eims.mcistring.url}") String mciUrl) {
this.mciUrl = mciUrl;
this.restClient = RestClient.create();
/*
*********************************************** 중요 **************************************************
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
factory.setConnectTimeout(3000);
factory.setReadTimeout(5000);
this.restClient = RestClient.builder().requestFactory(factory).build();
보통 금융권(신한라이프 등 은행/보험사)의 내부 레거시 시스템이나 MCI(Message Channel Integration) 솔루션은 HTTP/2를 기본으로 지원하지 않는 경우가 훨씬 많습니다.
*********************************************** 중요 **************************************************
*/
}
@Override
public String send(String interfaceId, String payload) throws Exception {
StopWatch stopWatch = new StopWatch(); stopWatch.start();
try {
log.info(" [ESB 어댑터(String)] 전송 준비 완료 - RestClient 호출 시작 (Interface: {})", interfaceId);
String response = restClient.post()
.uri(mciUrl)
.contentType(MediaType.TEXT_PLAIN)
.body(payload != null ? payload : "")
.retrieve()
.body(String.class);
log.info(" [ESB 어댑터(String)] 응답 수신 완료: {}", response);
return response != null ? response : "";
} finally {
stopWatch.stop();
log.info(" [SLA 모니터링 - MCI(String)] 소요시간: {} ms", stopWatch.getTotalTimeMillis());
}
}
}

View File

@@ -0,0 +1,109 @@
package io.shinhanlife.dap.lib.adapter.sender;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.shinhanlife.dap.lib.integration.mci.config.ShinhanIntegrationProperties;
import io.shinhanlife.dap.lib.integration.mci.dto.MciRequestWrapper;
import io.shinhanlife.dap.lib.integration.mci.dto.ShinhanCommonHeaderDto;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Service;
import org.springframework.util.StopWatch;
import org.springframework.web.client.RestClient;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicInteger;
/**
* @package io.shinhanlife.dap.lib.adapter.sender
* @className ShinhanMciSender
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
import org.springframework.boot.context.properties.EnableConfigurationProperties;
@Slf4j
@Service
@EnableConfigurationProperties(ShinhanIntegrationProperties.class)
public class ShinhanMciSender {
private final ObjectMapper jsonMapper;
private final RestClient restClient;
private final ShinhanIntegrationProperties properties;
private final AtomicInteger sequenceGenerator = new AtomicInteger(1);
public ShinhanMciSender(ObjectMapper jsonMapper, ShinhanIntegrationProperties properties) {
this.jsonMapper = jsonMapper;
this.properties = properties;
this.restClient = RestClient.create();
}
public <T> String send(String targetUrl, MciRequestWrapper<T> requestWrapper) throws Exception {
StopWatch stopWatch = new StopWatch();
stopWatch.start();
try {
ShinhanCommonHeaderDto header = requestWrapper.getTgrmCmnnhddValu();
if (header == null) {
header = new ShinhanCommonHeaderDto();
requestWrapper.setTgrmCmnnhddValu(header);
}
// 필수 헤더 자동 세팅 로직
header.setEnvrTypeCd(properties.getEnvrTypeCd());
header.setReqRspnScCd("S"); // S: 요청
header.setAppliDutjCd("DAP"); // 어플리케이션업무코드
if (header.getGlbId() == null || header.getGlbId().isEmpty()) {
header.setGlbId(generateGlbId());
}
int currentSeq = sequenceGenerator.getAndIncrement();
header.setPgrsSriaNo(String.format("%03d", currentSeq)); // 3자리
if (header.getReqTgrmTnsmDtptDt() == null) {
header.setReqTgrmTnsmDtptDt(LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmmssSSS")));
}
String jsonPayload = jsonMapper.writeValueAsString(requestWrapper);
log.info(" [신한 통합 MCI 어댑터] 전송 준비 완료 - URL: {}", targetUrl);
log.debug(" [신한 통합 MCI 어댑터] 요청 Payload: {}", jsonPayload);
String responseJson = restClient.post()
.uri(targetUrl)
.contentType(MediaType.APPLICATION_JSON)
.body(jsonPayload)
.retrieve()
.body(String.class);
log.info(" [신한 통합 MCI 어댑터] 응답 수신 완료");
log.debug(" [신한 통합 MCI 어댑터] 응답 Payload: {}", responseJson);
return responseJson;
} finally {
stopWatch.stop();
log.info(" [SLA 모니터링 - MCI 연계] 소요시간: {} ms", stopWatch.getTotalTimeMillis());
}
}
private String generateGlbId() {
// 전사공통키 (37 Byte)
// 전문생성상세일시(17) + 전문생성시스템명(9) + 어플리케이션업무코드(3) + 전문세션번호(8)
String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmmssSSS")); // 17 byte
String systemName = String.format("%-9s", "AXHUB"); // 9 byte, left-aligned padded with spaces
String appCode = "DAP"; // 3 byte
String sessionNo = UUID.randomUUID().toString().substring(0, 8).toUpperCase(); // 8 byte
return timestamp + systemName + appCode + sessionNo;
}
}

View File

@@ -0,0 +1,71 @@
package io.shinhanlife.dap.lib.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;
/**
* @package io.shinhanlife.dap.lib.adapter.sender
* @className TcpEimsSender
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
@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,59 @@
package io.shinhanlife.dap.lib.adapter.support;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Map;
/**
* @package io.shinhanlife.dap.lib.adapter.support
* @className DynamicPayloadBuilder
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
@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,47 @@
package io.shinhanlife.dap.lib.adapter.support;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Map;
/**
* @package io.shinhanlife.dap.lib.adapter.support
* @className DynamicSchemaValidator
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
@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,67 @@
package io.shinhanlife.dap.lib.adapter.support;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.shinhanlife.dap.lib.adapter.exception.MciCommunicationException;
import io.shinhanlife.dap.lib.adapter.sender.EimsSender;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
/**
* @package io.shinhanlife.dap.lib.adapter.support
* @className MciTemplate
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class MciTemplate {
private final EimsSender httpEimsSender;
private final ObjectMapper objectMapper;
/**
* MCI 인터페이스를 호출하고 결과를 지정된 타입으로 반환합니다.
*
* @param interfaceId 호출할 MCI 인터페이스 ID
* @param requestDto 요청 데이터 객체
* @param responseType 응답을 매핑할 클래스 타입
* @param <T> 요청 객체 타입
* @param <R> 응답 객체 타입
* @return 매핑된 응답 객체
* @throws MciCommunicationException 통신 또는 파싱 실패 시 예외 발생
*/
public <T, R> R call(String interfaceId, T requestDto, Class<R> responseType) {
log.info(" [MciTemplate] 시작 - Interface ID: {}", interfaceId);
try {
String payload = objectMapper.writeValueAsString(requestDto);
log.debug(" [MciTemplate] 전송 페이로드: {}", payload);
String responseJson = httpEimsSender.send(interfaceId, payload);
log.debug(" [MciTemplate] 수신 응답 JSON: {}", responseJson);
R response = objectMapper.readValue(responseJson, responseType);
log.info(" [MciTemplate] 완료 - Interface ID: {}", interfaceId);
return response;
} catch (JsonProcessingException e) {
log.error(" [MciTemplate] JSON 변환 중 오류 발생", e);
throw new MciCommunicationException("MCI 통신 중 JSON 파싱 오류", e);
} catch (Exception e) {
log.error(" [MciTemplate] 통신 중 오류 발생", e);
throw new MciCommunicationException("MCI 통신 실패", e);
}
}
}

View File

@@ -0,0 +1,53 @@
package io.shinhanlife.dap.lib.adapter.support;
import org.springframework.stereotype.Component;
import java.util.HashMap;
import java.util.Map;
/**
* @package io.shinhanlife.dap.lib.adapter.support
* @className ResultStandardizer
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
@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,96 @@
package io.shinhanlife.dap.lib.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;
/**
* @package io.shinhanlife.dap.lib.adapter.support
* @className TicketManager
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
@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,90 @@
package io.shinhanlife.dap.lib.adapter.support;
import io.shinhanlife.dap.lib.adapter.connector.LegacyEimsConnector;
import io.shinhanlife.dap.lib.adapter.dto.ErrorDetail;
import io.shinhanlife.dap.lib.adapter.dto.JsonRpcRequest;
import io.shinhanlife.dap.lib.adapter.dto.JsonRpcResponse;
import io.shinhanlife.dap.lib.adapter.dto.Params;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.HashMap;
import java.util.Map;
/**
* @package io.shinhanlife.dap.lib.adapter.support
* @className ToolExecutionService
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
@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,85 @@
package io.shinhanlife.dap.lib.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;
/**
* @package io.shinhanlife.dap.lib.adapter.test
* @className MockEimsHttpServer
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
@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>";
}
@PostMapping("/mock/esb/string")
public String mockEsbStringReceiver(@RequestBody(required = false) String payload) {
log.info(" [가짜 ESB 서버] MCI String 요청 수신 완료!");
log.info(" 수신된 String 전문: {}", payload);
// MciSampleStringResponse 에 맞게 고정 길이 응답 생성
// name (10), age (3), joinDate (8), statusCode (2)
// targetList (30) -> MciSampleTargetDto (itemCode 5, itemValue 5) x 3
return "홍길동 03020260901OKA0001B0001A0002B0002A0003B0003";
}
}

View File

@@ -0,0 +1,128 @@
package io.shinhanlife.dap.lib.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)에서만 가짜 소켓 서버가 켜지도록 보장합니다.
/**
* @package io.shinhanlife.dap.lib.adapter.test
* @className MockEimsTcpServer
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
@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() {
// MSA 환경에서는 각 툴 Pod 내부에서 독립적인 가짜 EIMS 서버가 실행되도록 허용
log.info(" [가짜 EIMS 서버] 로컬 테스트 환경용 EIMS TCP 서버 가동을 준비합니다.");
// 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,58 @@
package io.shinhanlife.dap.lib.adapter.test;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
/**
* @package io.shinhanlife.dap.lib.adapter.test
* @className MockJspServer
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
@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,92 @@
package io.shinhanlife.dap.lib.adapter.util;
import lombok.extern.slf4j.Slf4j;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* @package io.shinhanlife.dap.lib.adapter.util
* @className LegacyDataTransformer
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
@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,35 @@
package io.shinhanlife.dap.lib.adapter.util;
import ch.qos.logback.classic.pattern.MessageConverter;
import ch.qos.logback.classic.spi.ILoggingEvent;
/**
* Logback 커스텀 컨버터
* 모든 로그 메시지(%msg)가 파일이나 콘솔에 찍히기 직전에 이 클래스를 거쳐가게 됩니다.
* 여기서 PiiMaskingUtils.mask()를 호출하여 PII(주민번호, 계좌번호 등)를 안전하게 별표(*) 처리합니다.
*/
/**
* @package io.shinhanlife.dap.lib.adapter.util
* @className PiiMaskingLogbackConverter
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
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,77 @@
package io.shinhanlife.dap.lib.adapter.util;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* @package io.shinhanlife.dap.lib.adapter.util
* @className PiiMaskingUtils
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
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,19 @@
package io.shinhanlife.dap.lib.annotation;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* MCP 스키마 생성 시 anyOf (해당 필드들 중 최소 1개 이상 필수) 제약을 부여합니다.
*/
@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface McpAnyOf {
/**
* anyOf 제약에 포함될 필드명 목록
* 예: @McpAnyOf({"claimNo", "contractNo"})
*/
String[] value();
}

View File

@@ -0,0 +1,76 @@
package io.shinhanlife.dap.lib.annotation;
/**
* @package io.shinhanlife.dap.lib.annotation
* @className McpFunction
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
import java.lang.annotation.*;
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface McpFunction {
String displayName(); // 사람이 읽는 라벨 (예: "고객 조회 툴")
String name(); // MCP 서브툴 명칭 (예: "customer_search")
String description();
String prompt() default "";
String mappingId() default "";
/**
* Tool 입력 JSON Schema를 인라인으로 지정한다. 지정하지 않으면 요청 DTO에서 자동 생성한다.
*/
String inputSchema() default "{}";
/**
* 복합 조건(anyOf 등)이 필요한 Tool의 입력 JSON Schema 클래스패스 경로다.
* inputSchemaResource가 지정되면 inputSchema 및 DTO 자동 생성보다 우선한다.
*/
// 추가: Redis 자동 등록 및 Heartbeat 대상 여부 제어
String inputSchemaResource() default "";
/**
* Tool response JSON Schema. When unset, output validation is skipped.
*/
String outputSchema() default "{}";
/**
* Classpath resource for a complex Tool response JSON Schema.
* This value has priority over outputSchema.
*/
String outputSchemaResource() default "";
boolean register() default false;
// 추가: 툴 목록 노출 여부 제어 (false 시 라우팅은 되나 목록에서 숨김)
boolean visible() default true;
// 추가: HITL 승인 체계 지원 (실행 전 사용자 승인 필요 여부)
boolean requiresApproval() default false;
boolean readOnlyHint() default false;
boolean destructiveHint() default false;
boolean idempotentHint() default false;
boolean openWorldHint() default false;
/** Version exposed as _meta.version in the Tool Manifest. */
String version() default "1.0.0";
/** Maximum execution time exposed as _meta.timeoutMillis in the Tool Manifest. */
long timeoutMillis() default 300000L;
/** Whether the Tool is available for MCP exposure. */
boolean enabled() default true;
// 추가: 툴 별 기본 Timeout 설정 (기본 300초 = 300000ms)
}

View File

@@ -0,0 +1,17 @@
package io.shinhanlife.dap.lib.annotation;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* Marks a Tool response DTO for automatic output JSON Schema generation.
* Field constraints are declared with {@link McpValidation}.
*/
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface McpOutputSchema {
}

View File

@@ -0,0 +1,26 @@
package io.shinhanlife.dap.lib.annotation;
/**
* @package io.shinhanlife.dap.lib.annotation
* @className McpParameter
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
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,33 @@
package io.shinhanlife.dap.lib.annotation;
/**
* @package io.shinhanlife.dap.lib.annotation
* @className McpTool
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
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 categoryKey() default "common";
String routingType() default "HTTP";
}

View File

@@ -0,0 +1,36 @@
package io.shinhanlife.dap.lib.annotation;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* @package io.shinhanlife.dap.lib.annotation
* @className McpValidation
* @description Declares JSON Schema validation constraints for MCP tool input fields
* @author 0986406
* @create 2026.07.27
* <pre>
* ---------- revision history ----------
* date author description
* ---------- --------- ---------------------------
* 2026.07.27 0986406 initial creation
* </pre>
*/
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface McpValidation {
boolean required() default false;
String pattern() default "";
long minimum() default Long.MIN_VALUE;
long maximum() default Long.MAX_VALUE;
int minLength() default -1;
int maxLength() default -1;
String[] allowedValues() default {};
String format() default "";
boolean nullable() default false; String defaultValue() default "";
String[] examples() default {};
}

View File

@@ -0,0 +1,83 @@
package io.shinhanlife.dap.lib.aop;
/**
* @package io.shinhanlife.dap.lib.aop
* @className ToolSlaMonitoringAspect
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
import io.shinhanlife.dap.lib.annotation.McpFunction;
import io.shinhanlife.dap.lib.config.McpProperties;
import java.lang.reflect.Method;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.stereotype.Component;
import org.springframework.util.StopWatch;
@Slf4j
@Aspect
@Component
@RequiredArgsConstructor
public class ToolSlaMonitoringAspect {
private final McpProperties mcpProperties;
// @McpFunction 어노테이션이 붙은 모든 비즈니스 툴 메서드 실행을 가로챕니다.
@Around("@annotation(McpFunction)")
public Object monitorToolSla(ProceedingJoinPoint joinPoint) throws Throwable {
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();
McpFunction functionAnnotation = method.getAnnotation(McpFunction.class);
// 네임스페이스 자동 주입 로직을 반영하여 최종 툴 이름을 산출합니다.
String baseName = functionAnnotation.name();
String finalName = mcpProperties.getNamespace() != null && !mcpProperties.getNamespace().isEmpty()
? mcpProperties.getNamespace() + "_" + baseName
: baseName;
StopWatch stopWatch = new StopWatch();
stopWatch.start();
try {
// 실제 비즈니스 로직(툴) 실행
Object result = joinPoint.proceed();
stopWatch.stop();
long timeMillis = stopWatch.getTotalTimeMillis();
// SLA 기준을 초과하면 (예: 2초 이상) 경고 로깅 처리 가능
if (timeMillis > 2000) {
log.warn(" [SLA 경고] Tool: {} | 소요시간: {}ms | 상태: SLOW_RESPONSE", finalName, timeMillis);
} else {
log.info(" [SLA 추적] Tool: {} | 소요시간: {}ms | 상태: SUCCESS", finalName, timeMillis);
}
return result;
} catch (Throwable e) {
if (stopWatch.isRunning()) {
stopWatch.stop();
}
long timeMillis = stopWatch.getTotalTimeMillis();
// 에러 발생 시 명확하게 실패 로그 기록
log.error(" [SLA 장애] Tool: {} | 소요시간: {}ms | 상태: FAILED | 사유: {}", finalName, timeMillis, e.getMessage());
// 원래 흐름대로 예외를 던져서 게이트웨이나 상위 로직이 에러를 처리하게 함
throw e;
}
}
}

View File

@@ -0,0 +1,34 @@
package io.shinhanlife.dap.lib.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
/**
* @package io.shinhanlife.dap.lib.config
* @className CorsConfig
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
@Configuration
public class CorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**") // 모든 엔드포인트에 대해 CORS 허용
.allowedOriginPatterns("*") // 외부 Agent Builder 등 모든 오리진 허용
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS", "HEAD", "PATCH") // 허용할 HTTP 메서드
.allowedHeaders("*") // 모든 헤더 허용
.exposedHeaders("Mcp-Session-Id") // MCP-HTTP 세션 아이디 노출 허용
.allowCredentials(true) // 쿠키/인증 정보 허용
.maxAge(3600); // preflight 요청 캐시 시간 (초 단위)
}
}

View File

@@ -0,0 +1,85 @@
package io.shinhanlife.dap.lib.config;
import lombok.Getter;
import lombok.Setter;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
/**
* [Glow Framework 통신 환경 설정 클래스]
* application-glow-local.yml 의 'glow.communication' 하위 설정값들을
* 자바 객체(Bean)로 매핑하여 제공합니다.
*/
/**
* @package io.shinhanlife.dap.lib.config
* @className GlowCommunicationProperties
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
@Component
@Getter
@Setter
@ConfigurationProperties(prefix = "glow.communication")
public class GlowCommunicationProperties {
private Common common = new Common();
private Http http = new Http();
private Mci mci = new Mci();
private ExtMci extmci = new ExtMci();
private Eai eai = new Eai();
private Websocket websocket = new Websocket();
@Getter @Setter
public static class Common {
private String envType; // 대내표준 헤더의 환경 타입정보 (D, T, P)
}
@Getter @Setter
public static class Http {
private int connectionTimeout; // 연결 타임아웃 시간 (초 단위)
private int readTimeout; // 읽기 타임아웃 시간 (초 단위)
}
@Getter @Setter
public static class Mci {
private String host;
private int port;
private String uri;
private String receiveUri;
private int connectionTimeout;
private int readTimeout;
private String encoding;
}
@Getter @Setter
public static class ExtMci {
private String host;
private int port;
private String uri;
private String jsonUri;
private String receiveUri;
private int connectionTimeout;
private int readTimeout;
private String encoding;
}
@Getter @Setter
public static class Eai {
private String host;
private int port;
}
@Getter @Setter
public static class Websocket {
private String endpoint;
private String allowedOrigins;
}
}

View File

@@ -0,0 +1,37 @@
package io.shinhanlife.dap.lib.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;
import java.util.Map;
/**
* @package io.shinhanlife.dap.lib.config
* @className McpProperties
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
@Data
@Configuration
@ConfigurationProperties(prefix = "mcp")
public class McpProperties {
private String namespace;
private Manifest manifest = new Manifest();
@Data
public static class Manifest {
private String bundleId;
private String namePrefix;
}
}

View File

@@ -0,0 +1,33 @@
package io.shinhanlife.dap.lib.config;
import javax.sql.DataSource;
import org.apache.ibatis.session.SqlSessionFactory;
import org.mybatis.spring.SqlSessionFactoryBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* @package io.shinhanlife.dap.lib.config
* @className MybatisConfig
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
@Configuration
public class MybatisConfig {
@Bean
public SqlSessionFactory sqlSessionFactory(DataSource dataSource) throws Exception {
SqlSessionFactoryBean sessionFactory = new SqlSessionFactoryBean();
sessionFactory.setDataSource(dataSource);
return sessionFactory.getObject();
}
}

View File

@@ -0,0 +1,54 @@
package io.shinhanlife.dap.lib.config;
import com.p6spy.engine.logging.Category;
import com.p6spy.engine.spy.appender.MessageFormattingStrategy;
/**
* @package io.shinhanlife.dap.lib.config
* @className P6SpySqlFormatter
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
public class P6SpySqlFormatter implements MessageFormattingStrategy {
@Override
public String formatMessage(int connectionId, String now, long elapsed,
String category, String prepared, String sql, String url) {
if (sql == null || sql.isBlank()) return "";
if (Category.STATEMENT.getName().equals(category)) {
String prettySQL = sql
.replaceAll("(?i)\\bSELECT\\b", "\nSELECT")
.replaceAll("(?i)\\bFROM\\b", "\n FROM")
.replaceAll("(?i)\\bWHERE\\b", "\n WHERE")
.replaceAll("(?i)\\bAND\\b", "\n AND")
.replaceAll("(?i)\\bOR\\b", "\n OR")
.replaceAll("(?i)\\bINNER JOIN\\b", "\n INNER JOIN")
.replaceAll("(?i)\\bLEFT JOIN\\b", "\n LEFT JOIN")
.replaceAll("(?i)\\bORDER BY\\b", "\n ORDER BY")
.replaceAll("(?i)\\bGROUP BY\\b", "\n GROUP BY")
.replaceAll("(?i)\\bINSERT INTO\\b", "\nINSERT INTO")
.replaceAll("(?i)\\bVALUES\\b", "\n VALUES")
.replaceAll("(?i)\\bUPDATE\\b", "\nUPDATE")
.replaceAll("(?i)\\bSET\\b", "\n SET")
.replaceAll("(?i)\\bDELETE FROM\\b", "\nDELETE FROM");
return String.format("""
\n┌─────────────────────────────────────────
│ SQL [%dms]
│%s
└─────────────────────────────────────────
""", elapsed, prettySQL.indent(2).stripTrailing());
}
return "";
}
}

View File

@@ -0,0 +1,18 @@
package io.shinhanlife.dap.lib.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.shinhanlife.dap.lib.util.ToolSchemaResolver;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* Common MCP Tool Schema Bean configuration.
*/
@Configuration
public class ToolSchemaConfiguration {
@Bean
public ToolSchemaResolver toolSchemaResolver(ObjectMapper objectMapper) {
return new ToolSchemaResolver(objectMapper);
}
}

View File

@@ -0,0 +1,105 @@
package io.shinhanlife.dap.lib.integration;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
// TODO: 실제 Glow Framework 의존성이 추가되면 아래 주석들을 풀고 사용하세요!
// import io.shinhanlife.glow.communication.dto.CommonHeader;
// import io.shinhanlife.glow.communication.dto.Transfer;
// import io.shinhanlife.glow.communication.module.eai.component.GlowEaiComponent;
// import io.shinhanlife.glow.communication.module.mci.component.GlowExtMciComponent;
// import io.shinhanlife.glow.communication.module.mci.component.GlowMciComponent;
// import io.shinhanlife.glow.communication.util.CommonHeaderFactory;
/**
* [MCI / EAI 공통 연동 래퍼(Wrapper) 템플릿]
* 신한라이프 Glow Framework 개발표준정의서를 바탕으로 대내/대외망/EAI 통신을
* MCP 툴에서 손쉽게 호출할 수 있도록 일원화한 컴포넌트입니다.
*/
/**
* @package io.shinhanlife.dap.lib.integration
* @className GlowIntegrationCall
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
@Component
@RequiredArgsConstructor
public class GlowIntegrationCall {
// 1. 실제 의존성이 주입될 프레임워크 컴포넌트들 (임시 주석 처리)
/*
private final GlowMciComponent mci;
private final GlowEaiComponent eai;
private final GlowExtMciComponent extMci;
*/
/**
* 1. 대외 MCI 호출 (타행, 금융결제원 등 외부 기관)
* 가이드 2.2.2에 명시된 필수 파라미터(기관코드, 종별코드, 업무코드, 거래코드)를 모두 포함합니다.
*/
/*
public <S, R> Transfer<R> callExtMci(String itrfId, String frbuCd, String cmouDutjCd, String cmouCssfCd, String cmouTraCd, S body, Class<R> resBody) {
// 1. 공통 헤더 생성
CommonHeader header = CommonHeaderFactory.createRequestHeader(itrfId);
// 2. 대외 전용 필수 코드 세팅 로직 (프레임워크 내부 스펙에 맞게 가공)
// (예: 헤더에 해당 속성들을 주입하거나 Transfer 객체에 싣는 과정 추가)
// 3. Transfer 객체 빌드
Transfer<S> req = Transfer.<S>builder()
.header(header)
.body(body)
.build();
// 4. 대외 MCI 컴포넌트를 통해 최종 전송
return extMci.call(req, resBody);
}
*/
/**
* 2. EAI 호출 (대내망 중계기)
* 가이드 2.3에 명시된 대로 수신서비스 ID 없이 인터페이스 ID(itrfId)만 필수로 받습니다.
*/
/*
public <S, R> Transfer<R> callEai(String itrfId, S body, Class<R> resBody) {
// 1. EAI는 인터페이스 ID만으로 심플하게 헤더 생성
CommonHeader header = CommonHeaderFactory.createRequestHeader(itrfId);
// 2. Transfer 객체 빌드
Transfer<S> req = Transfer.<S>builder()
.header(header)
.body(body)
.build();
// 3. EAI 컴포넌트를 통해 최종 전송
return eai.call(req, resBody);
}
*/
/**
* 3. 대내 MCI 호출 (사내 시스템 간 통신)
*/
/*
public <S, R> Transfer<R> callMci(String itrfId, S body, Class<R> resBody) {
CommonHeader header = CommonHeaderFactory.createRequestHeader(itrfId);
Transfer<S> req = Transfer.<S>builder()
.header(header)
.body(body)
.build();
return mci.call(req, resBody);
}
*/
}

View File

@@ -0,0 +1,84 @@
package io.shinhanlife.dap.lib.integration.dto;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.util.List;
// TODO: 실제 Glow Framework 의존성이 추가되면 아래 주석을 풀고 사용하세요!
// import io.shinhanlife.glow.communication.annotation.GlowMciFieldInfo;
/**
* [대외 MCI 연동용 DTO 표준 템플릿]
* Glow Framework 개발표준정의서(2.2.1 IO 작성) 규칙을 100% 준수한 샘플입니다.
* 새로운 대외 통신 전문을 만들 때 이 파일을 복사해서 필드명과 길이만 수정하여 사용하세요.
*/
/**
* @package io.shinhanlife.dap.lib.integration.dto
* @className SampleGlowMessage
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor(access = AccessLevel.PUBLIC) // [규칙 1] Reflection을 위한 기본 생성자 필수 (public 유지)
public class SampleGlowMessage {
// [규칙 2] @GlowMciFieldInfo 선언 필수 (order: 순서)
// @GlowMciFieldInfo(order = 1)
private MessageHeader header;
// [규칙 2] @GlowMciFieldInfo 선언 필수 (order: 순서)
// @GlowMciFieldInfo(order = 2)
private List<MessageBody> msgDtdvValu; // 다건(List) 본문 데이터
@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor(access = AccessLevel.PUBLIC)
public static class MessageHeader {
// [규칙 2] 단건 필드의 경우 length 필수 입력 (EIMS 길이와 일치해야 함)
// @GlowMciFieldInfo(order = 1, length = 1)
private String msgTnsmTypeCd;
// @GlowMciFieldInfo(order = 2, length = 8)
private int msdvLen;
// [규칙 3] 다건(List) 건수 필드의 경우, target 속성에 대상 변수명("msgDtdvValu") 필수 기입!
// @GlowMciFieldInfo(order = 3, length = 2, target = "msgDtdvValu")
private int msgRpttCc;
}
@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor(access = AccessLevel.PUBLIC)
public static class MessageBody {
// @GlowMciFieldInfo(order = 1, length = 8)
private String msgCd;
// @GlowMciFieldInfo(order = 2, length = 1)
private String msgPrnAttrCd;
// @GlowMciFieldInfo(order = 3, length = 200)
private String msgCt;
// @GlowMciFieldInfo(order = 4, length = 200)
private String anxMsgCt;
}
}

View File

@@ -0,0 +1,99 @@
package io.shinhanlife.dap.lib.integration.eai.component;
import io.shinhanlife.dap.lib.config.GlowCommunicationProperties;
import io.shinhanlife.glow.communication.dto.CommonHeader;
import io.shinhanlife.glow.communication.dto.Transfer;
import io.shinhanlife.glow.communication.module.eai.component.GlowEaiComponent;
import io.shinhanlife.glow.communication.util.CommonHeaderFactory;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
/**
* 신한라이프 내부 Glow 표준 EAI 컴포넌트 어댑터 (AXHUB)
*/
@Slf4j
@Component
@RequiredArgsConstructor
/**
* @package io.shinhanlife.dap.lib.integration.eai.component
* @className AxhubEaiComponent
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
public class AxhubEaiComponent {
@SuppressWarnings("rawtypes")
private final GlowEaiComponent eai;
private final GlowCommunicationProperties communicationProperties;
@SuppressWarnings("unchecked")
private <O> Transfer<O> syncEai(Transfer<Object> request) {
// LOG 저장 (AXHUB 방식 로깅)
CommonHeader reqHeader = (CommonHeader) request.getHeader();
log.info("[AxhubEaiComponent] {} EAI 호출시작 (수신서비스: {})", reqHeader.getItrfId(), reqHeader.getRcvSvcId());
Transfer<O> response = (Transfer<O>) eai.sync(request);
log.info("[AxhubEaiComponent] {} EAI 호출종료 (수신서비스: {})", reqHeader.getItrfId(), reqHeader.getRcvSvcId());
return response;
}
/**
* EAI 호출
*/
public <O, I> Transfer<O> call(String itrfId, String rcvSvcId, I inputDto) throws Exception {
CommonHeader header = CommonHeaderFactory.createRequestHeader(itrfId, rcvSvcId);
Transfer<Object> request = Transfer.builder()
.header(header)
.body(inputDto)
.build();
return syncEai(request);
}
/**
* EAI 호출 (응답 타입 명시)
*/
@SuppressWarnings("unchecked")
public <O, I> Transfer<O> call(String itrfId, String rcvSvcId, I inputDto, Class<O> resBodyClass) throws Exception {
CommonHeader header = CommonHeaderFactory.createRequestHeader(itrfId, rcvSvcId);
Transfer<Object> request = Transfer.builder()
.header(header)
.body(inputDto)
.resBodyClass((Class<Object>) (Class<?>) resBodyClass)
.build();
return syncEai(request);
}
/**
* EAI 호출 (rcvSvcId 없는 경우)
*/
public <O, I> Transfer<O> call(String itrfId, I inputDTO, Class<O> resBodyClass) throws Exception {
String className = inputDTO.getClass().getSimpleName();
String rcvSvcId = className.replace("_I", "");
return call(itrfId, rcvSvcId, inputDTO, resBodyClass);
}
/**
* EAI 호출 (Response body class와 rcvSvcId 없는 경우)
*/
public <O, I> Transfer<O> call(String itrfId, I inputDTO) throws Exception {
String className = inputDTO.getClass().getSimpleName();
String rcvSvcId = className.replace("_I", "");
return call(itrfId, rcvSvcId, inputDTO);
}
}

View File

@@ -0,0 +1,168 @@
package io.shinhanlife.dap.lib.integration.mci.component;
import io.shinhanlife.dap.lib.session.dto.SessionDto;
import io.shinhanlife.dap.lib.util.SessionUtil;
import io.shinhanlife.dap.lib.config.GlowCommunicationProperties;
import io.shinhanlife.glow.communication.dto.CommonHeader;
import io.shinhanlife.glow.communication.dto.HeaderDefaults;
import io.shinhanlife.glow.communication.dto.Transfer;
import io.shinhanlife.glow.communication.module.mci.component.GlowMciComponent;
import io.shinhanlife.glow.communication.util.CommonHeaderFactory;
import io.shinhanlife.dap.lib.integration.mci.enums.IndvCtinRoleTyp;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.util.HashMap;
import java.util.Map;
/**
* 신한라이프 내부 Glow 표준 컴포넌트 어댑터 (AXHUB)
*/
@Slf4j
@Component
@RequiredArgsConstructor
/**
* @package io.shinhanlife.dap.lib.integration.mci.component
* @className AxhubMciComponent
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
public class AxhubMciComponent {
@SuppressWarnings("rawtypes")
private final GlowMciComponent mci;
private final GlowCommunicationProperties communicationProperties;
/** 전문생성채널유형코드 : 1 (채널계) */
private final static String TGRM_CREA_CHNN_TYPE_CD_1 = "1";
private static final String SUCO_UNBL_CODE = "NNB00147"; // 청약불가
/**
* 전문 Common Header 생성
* @param itrfName 인터페이스Id
* @param rcvSvcId 수신서비스Id
* @return Map<HeaderDefaults, String>
*/
private Map<HeaderDefaults, String> createCommonHeaderMap(String itrfName, String rcvSvcId) {
SessionDto sessionDto = SessionUtil.getSession();
Map<HeaderDefaults, String> commonHeaderMap = new HashMap<>();
commonHeaderMap.put(HeaderDefaults.ITRF_ID, itrfName);
commonHeaderMap.put(HeaderDefaults.RCV_SVC_ID, rcvSvcId);
if (sessionDto != null) {
commonHeaderMap.put(HeaderDefaults.STR_YMD, sessionDto.getStrYmd());
commonHeaderMap.put(HeaderDefaults.ACNT_OGNZ_NO, sessionDto.getBrafNo());
commonHeaderMap.put(HeaderDefaults.PSMR_ASRT_CD, sessionDto.getPsmrAsrtCd());
commonHeaderMap.put(HeaderDefaults.SBSN_RULP_ASRT_CD, sessionDto.getSbsnRulpAsrtCd());
commonHeaderMap.put(HeaderDefaults.BSDU_CD, sessionDto.getBsduCd());
commonHeaderMap.put(HeaderDefaults.BSQU_CD, sessionDto.getBsquCd());
commonHeaderMap.put(HeaderDefaults.OGNZ_ASRT_CD, sessionDto.getOgnzAsrtCd());
commonHeaderMap.put(HeaderDefaults.OGNZ_LEVE_CD, sessionDto.getOgnzLeveCd());
commonHeaderMap.put(HeaderDefaults.SCRN_ID, sessionDto.getPrgrId());
}
commonHeaderMap.put(HeaderDefaults.INDV_CTIN_ROLE_CD, IndvCtinRoleTyp.CD_Z99.getCode());
commonHeaderMap.put(HeaderDefaults.TGRM_CREA_CHNN_TYPE_CD, TGRM_CREA_CHNN_TYPE_CD_1);
if (communicationProperties != null && communicationProperties.getCommon() != null) {
commonHeaderMap.put(HeaderDefaults.ENVR_TYPE_CD, communicationProperties.getCommon().getEnvType());
}
return commonHeaderMap;
}
@SuppressWarnings("unchecked")
private <O> Transfer<O> syncMci(Transfer<Object> request) {
// LOG 저장 (AXHUB 방식 로깅)
CommonHeader reqHeader = (CommonHeader) request.getHeader();
log.info("[AxhubMciComponent] {} MCI 호출시작 (수신서비스: {})", reqHeader.getItrfId(), reqHeader.getRcvSvcId());
Transfer<O> response = (Transfer<O>) mci.sync(request);
log.info("[AxhubMciComponent] {} MCI 호출종료 (수신서비스: {})", reqHeader.getItrfId(), reqHeader.getRcvSvcId());
if (response != null && response.getHeader() != null) {
CommonHeader resHeader = (CommonHeader) response.getHeader();
String tgrmDalRsltCd = resHeader.getTgrmDalRsltCd();
// TODO 추가 메시지 처리 및 오류 코드 제어 로직
}
return response;
}
public <O, I> Transfer<O> callTo(String itrfName, String rcvSvcId, I inputDto) throws Exception {
Map<HeaderDefaults, String> commonHeaderMap = createCommonHeaderMap(itrfName, rcvSvcId);
CommonHeader header = CommonHeaderFactory.createRequestHeader(commonHeaderMap);
Transfer<Object> request = Transfer.builder()
.header(header)
.body(inputDto)
.build();
return syncMci(request);
}
/**
* 대내 mci 호출
* @param itrfName 인터페이스Id
* @param rcvSvcId 수신서비스Id
* @param inputDto inputDto
* @param resBodyClass resBodyClass
* @return Transfer
* @param <O> resBodyClass 제너릭
* @param <I> inputDto 제너릭
*/
@SuppressWarnings("unchecked")
public <O, I> Transfer<O> callTo(String itrfName, String rcvSvcId, I inputDto, Class<O> resBodyClass) throws Exception {
Map<HeaderDefaults, String> commonHeaderMap = createCommonHeaderMap(itrfName, rcvSvcId);
CommonHeader header = CommonHeaderFactory.createRequestHeader(commonHeaderMap);
Transfer<Object> request = Transfer.builder()
.header(header)
.body(inputDto)
.resBodyClass((Class<Object>) (Class<?>) resBodyClass)
.build();
return syncMci(request);
}
/**
* 대내 mci 호출 (rcvSvcId 없는 경우)
* @param itrfName 인터페이스Id
* @param inputDTO 수신서비스Id (클래스명 대체)
* @param resBodyClass resBodyClass
* @return Transfer
* @param <O> resBodyClass 제너릭
* @param <I> inputDto 제너릭
* @throws Exception Exception
*/
public <O, I> Transfer<O> callTo(String itrfName, I inputDTO, Class<O> resBodyClass) throws Exception {
String className = inputDTO.getClass().getSimpleName();
String rcvSvcId = className.replace("_I", "");
return callTo(itrfName, rcvSvcId, inputDTO, resBodyClass);
}
/**
* 대내 mci 호출 (Response body class와 rcvSvcId 없는 경우)
* @param itrfName 인터페이스Id
* @param inputDTO 수신서비스Id (클래스명 대체)
* @return Transfer
* @param <O> resBodyClass 제너릭
* @param <I> inputDto 제너릭
* @throws Exception Exception
*/
public <O, I> Transfer<O> callTo(String itrfName, I inputDTO) throws Exception {
String className = inputDTO.getClass().getSimpleName();
String rcvSvcId = className.replace("_I", "");
return callTo(itrfName, rcvSvcId, inputDTO);
}
}

View File

@@ -0,0 +1,40 @@
package io.shinhanlife.dap.lib.integration.mci.config;
import io.shinhanlife.glow.communication.module.mci.component.GlowMciComponent;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* TODO: 실제 Glow Framework 의존성이 추가되어 io.shinhanlife.glow 패키지가
* ComponentScan에 잡히게 되면 이 설정 클래스는 삭제하세요.
*/
@Configuration
/**
* @package io.shinhanlife.dap.lib.integration.mci.config
* @className GlowMockConfig
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
public class GlowMockConfig {
@Bean
@SuppressWarnings("rawtypes")
public GlowMciComponent glowMciComponent() {
return new GlowMciComponent();
}
@Bean
@SuppressWarnings("rawtypes")
public io.shinhanlife.glow.communication.module.eai.component.GlowEaiComponent glowEaiComponent() {
return new io.shinhanlife.glow.communication.module.eai.component.GlowEaiComponent();
}
}

View File

@@ -0,0 +1,40 @@
package io.shinhanlife.dap.lib.integration.mci.config;
import lombok.Getter;
import lombok.Setter;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;
/**
* @package io.shinhanlife.dap.lib.integration.mci.config
* @className ShinhanIntegrationProperties
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
@Getter
@Setter
@ConfigurationProperties(prefix = "shinhan.integration")
public class ShinhanIntegrationProperties {
/**
* 환경유형코드: 운영(R), 테스트(T), 개발(D)
*/
private String envrTypeCd = "D";
private ServerInfo eai = new ServerInfo();
private ServerInfo internalMci = new ServerInfo();
@Getter
@Setter
public static class ServerInfo {
private String url;
}
}

View File

@@ -0,0 +1,34 @@
package io.shinhanlife.dap.lib.integration.mci.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import com.fasterxml.jackson.annotation.JsonUnwrapped;
/**
* @package io.shinhanlife.dap.lib.integration.mci.dto
* @className MciRequestWrapper
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class MciRequestWrapper<T> {
private ShinhanCommonHeaderDto tgrmCmnnhddValu;
@JsonUnwrapped
private T body;
}

View File

@@ -0,0 +1,32 @@
package io.shinhanlife.dap.lib.integration.mci.dto;
import com.fasterxml.jackson.annotation.JsonUnwrapped;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* @package io.shinhanlife.dap.lib.integration.mci.dto
* @className MciResponseWrapper
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class MciResponseWrapper<T> {
private ShinhanCommonHeaderDto tgrmCmnnhddValu;
@JsonUnwrapped
private T body;
}

View File

@@ -0,0 +1,90 @@
package io.shinhanlife.dap.lib.integration.mci.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
/**
* @package io.shinhanlife.dap.lib.integration.mci.dto
* @className OlCommonHeaderDto
* @description AX HUB 시스템 처리 클래스 - OL(구 오렌지라이프) 공통 헤더
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class OlCommonHeaderDto {
private String custNm; // 고객명
private String custRrn; // 고객 주민등록번호
private String custNo; // 고객번호
private String rcevNo; // 접수번호
private String pono; // 증권번호
private String scrNm; // 화면명
private String scrId; // 화면ID
private String lginDttm; // 사용자가 로그인한 접속일시
private String lginIpAddr; // 사용자가 접속한 IP 주소
private String userNm; // 사용자 이름(한글)
private String userEngNm; // 사용자 영문이름
private String userId; // 사용자 ID(AD ID)
private String userNo; // 사용자번호
private String deptCd; // 사용자조직 코드
private String salsDvCd; // 영업본부코드
private String salsBoCd; // 영업지점코드
private String uppDeptCd; // 상위조직코드
private String prcsrUserId; // 처리자 ID(AD ID)
private String prcsrUserNo; // 처리지번호
private String prcsrDeptCd; // 처리지조직 코드
private String prcsrDvCd; // 처리지 영업본부코드
private String prcsrBoCd; // 처리지 영업지점코드
private String prcsrUppDeptCd; // 부서코드
private String sysCd; // 요청이 들어온 시스템을 표시
private String reqtSvcNm; // 요청하는 서비스 모듈명
private String reqtMthdNm; // 요청하는 메소드명
private String reqtVoNm; // 요청메소드에 전달할 값을 담는 VO명
private String scrButnFuncClssCd; // 화면에서 버튼 별 이벤트 구분을 위한 구분코드
private String scrGriCnt; // 화면 그리드 개수
private List<OlPageDto> pageList; // 페이징 리스트 (L2 반복)
private String reqtDttm; // 요청일시
private String crdtInfoIcluFlg; // 신용정보포함여부(Y,N)
private String crdtInfoDataChgTypCd; // 업무내역별 식별코드 부여
private String crdtInfoIdfInEngAbbrNm; // 신용정보식별영문약어명
private String crdtInfoIdfnSysCd; // 신용정보식별시스템코드
private String scrButnNm; // 화면버튼명
private String msgCnt; // 메시지 개수
private List<OlMsgDto> msgList; // 메시지 리스트 (L2 반복)
private String respDttm; // 응답일시
private String svcRunNm; // 거래별로 유일한 ServiceExecutionID
private String stdate; // 기준일자
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class OlPageDto {
private String pageSrno; // 페이지 인덱스값 (L3)
private String pageInqCnt; // 한페이지에 조회될 건수 (L3)
private String nxtButnNm; // 다음버튼ID (L3)
private String nxtButnEnbFlg; // 다음버튼 활성여부 (L3)
}
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class OlMsgDto {
private String msgNo; // 서버 측에서 세팅한 정상/에러 메시지코드 (L3)
private String msgTypCd; // 메시지유형코드 (L3)
private String msgNm; // 메시지코드의 내용 (L3)
}
}

View File

@@ -0,0 +1,72 @@
package io.shinhanlife.dap.lib.integration.mci.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* @package io.shinhanlife.dap.lib.integration.mci.dto
* @className ShinhanCommonHeaderDto
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ShinhanCommonHeaderDto {
private String tgrmLencn; // 전문길이
private String glbId; // 글로벌ID (전사공통키)
private String pgrsSriaNo; // 진행일련번호
private String tgrmVrsnInfoValu; // 전문버전정보값
private String tgrmEncrYn; // 전문암호화여부
private String gpcpCd; // 그룹사코드
private String appliDutjCd; // 어플리케이션업무코드
private String appliDtptDutjCd; // 어플리케이션상세업무코드
private String frbuCd; // 대외기관코드
private String cmouDutjCd; // 대외업무코드
private String cmouCssfCd; // 대외종별코드
private String cmouTraCd; // 대외거래코드
private String rcvSvcId; // 수신서비스ID
private String rsltRcvSvcId; // 결과수신서비스ID
private String tgrmCreaChnnTypeCd; // 전문생성채널유형코드
private String lnggDvsnCd; // 언어구분코드
private String simulTraYn; // 시뮬레이션거래여부
private String itrIfId; // 인터페이스ID
private String reqRspnScCd; // 요청응답구분코드
private String tnsmTypeCd; // 전송유형코드
private String envrTypeCd; // 환경유형코드
private String inqrTraTypeCd; // 조회거래유형코드
private String reqTgrmTnsmDtptDt; // 요청전문전송상세일시
private String strYmd; // 기준일자
private String scrnId; // 화면ID
private String scrnBtnId; // 화면버튼ID
private String userIpAddr; // 사용자IP주소
private String drtmCd; // 부서코드
private String userId; // 사용자ID
private String indvCtinRoleCd; // 개인신용정보역할코드
private String acntOgnzNo; // 경리조직번호
private String rspnTgrmTnsmDtptDt; // 응답전문전송상세일시
private String tgrmDalRsltCd; // 전문처리결과코드
private String ognzAsrtCd; // 조직분류코드
private String ognzLeveCd; // 조직레벨코드
private String psmrAsrtCd; // 인사조직분류코드
private String sbsnRulpAsrtCd; // 영업규정분류코드
private String bsduCd; // 영업지국코드
private String bsquCd; // 영업자격코드
private String linkPrafDutyCd; // 연계인사직책코드
private String indvInfoLogWritYn; // 개인정보로그작성여부
private String prepImhdNm; // 예비항목명
}

View File

@@ -0,0 +1,51 @@
package io.shinhanlife.dap.lib.integration.mci.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* @package io.shinhanlife.dap.lib.integration.mci.dto
* @className ShinhanMessageDto
* @description AX HUB 시스템 처리 클래스 - MCI 전문 메시지부
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ShinhanMessageDto {
private MsgHddvValu msgHddvValu; // 메시지헤더부값
private MsgDtdvValu msgDtdvValu; // 메시지데이터부값
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class MsgHddvValu {
private String msgTnsmTypeCd; // 메시지전송유형코드
private Integer msdvLencn; // 메시지부길이
private Integer msgRpttCc; // 메시지반복건수
}
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class MsgDtdvValu {
private String msgCd; // 메시지코드
private String msgPrnAttrCd; // 메시지출력속성코드
private String msgCt; // 메시지내용
private String anxMsgCt; // 부가메시지내용
}
}

View File

@@ -0,0 +1,38 @@
package io.shinhanlife.dap.lib.integration.mci.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import com.fasterxml.jackson.annotation.JsonUnwrapped;
/**
* @package io.shinhanlife.dap.lib.integration.mci.dto
* @className ShinhanTelegramWrapper
* @description AX HUB 시스템 처리 클래스 - MCI 전문 전체 래퍼 (공통헤더부 + 메시지부 + 데이터부)
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ShinhanTelegramWrapper<T> {
// 1. 공통 헤더부
private ShinhanCommonHeaderDto tgrmCmnnhddValu;
// 2. 메시지부
private ShinhanMessageDto tgrmMsdvValu;
// 3. 데이터부 (비즈니스마다 다름, JsonUnwrapped로 평탄화하거나 객체 자체로 유지 가능. 여기서는 객체 유지)
private T tgrmDtdvValu;
}

View File

@@ -0,0 +1,84 @@
package io.shinhanlife.dap.lib.integration.mci.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* @package io.shinhanlife.dap.lib.integration.mci.dto
* @className SlCommonHeaderDto
* @description AX HUB 시스템 처리 클래스 - SL(신한라이프) 표준 헤더
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class SlCommonHeaderDto {
private String length; // 전문길이
private SlGlobalId globalId; // 글로벌ID
private String headerVer; // 전문헤더버전
private String encodeFlag; // 전문암호화여부
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class SlGlobalId {
private String writeDate; // 전문작성일 (8)
private String sysCd; // 생성시스템명 (8)
private String typeCd; // 구분코드 (2)
private String detailCd; // 세부업무코드 (4)
private String seqNo; // 채번번호 (8)
private String step; // 진행상황 (2)
}
private String groupCoCd; // 그룹사코드
private String instCd; // 기관코드
private String applCd; // 업무코드
private String kindCd; // 종별코드
private String txCd; // 거래코드
private String pfmAppName; // 어플리케이션 명
private String pfmSvcName; // 서비스 명
private String pfmFnName; // 오퍼레이션 명
private String systemCd; // 생성시스템구분
private String trFlag; // 요청응답구분
private String syncFlag; // 동기구분
private String envrFlag; // 환경구분
private String crudFlag; // 조회거래구분
private String sendTime; // 전문전송일시
private String screenId; // 화면ID
private String clntIp; // Client IP
private String orgCd; // 부서(지점)코드
private String userId; // 사용자 사번(아이디)
private String indvCrdtInfo; // 개인신용정보역할코드
private String acntOgnzNo; // 경리조직번호
private String ttiFlag; // TimeOut사용
private String ttiStartTm; // 최초시작시간
private String ttiKeepTm; // 유지시간초수
private String outMsgTm; // 응답전문작성일시
private String resType; // 처리결과
private String resCode; // 응답코드
private String resBascMsg; // 응답기본내역
private String msgType; // 메시지 유형
private String rcvSvcCd; // 수신 서비스 Code
private String rsltRcvSvcCd; // 결과수신 서비스 Code
private String realSvcCd; // Real 서비스 Code
private String ognzAsrtCd; // 조직분류코드
private String ognzLeveCd; // 조직레벨구분코드
private String psmrAsrtCd; // 인사조직분류코드
private String sbsnRulpAsrtCd; // 영업규정분류코드
private String bsduCd; // 영업지국코드
private String bsquCd; // 영업자격코드
private String linkPrafDutyCd; // 직책코드
private String temp; // 예비 필드
}

View File

@@ -0,0 +1,27 @@
package io.shinhanlife.dap.lib.integration.mci.enums;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
@Getter
@RequiredArgsConstructor
/**
* @package io.shinhanlife.dap.lib.integration.mci.enums
* @className IndvCtinRoleTyp
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
public enum IndvCtinRoleTyp {
CD_Z99("Z99");
private final String code;
}

View File

@@ -0,0 +1,32 @@
package io.shinhanlife.dap.lib.mcp.config;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.cache.concurrent.ConcurrentMapCacheManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* @package io.shinhanlife.dap.lib.mcp.config
* @className CacheConfig
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
@Configuration
@EnableCaching
public class CacheConfig {
// 스프링이 캐시를 관리할 기본 저장소를 빈(Bean)으로 등록합니다.
@Bean
public CacheManager cacheManager() {
return new ConcurrentMapCacheManager("eimsData"); // 아까 설정한 캐시 이름 등록
}
}

View File

@@ -0,0 +1,41 @@
package io.shinhanlife.dap.lib.mcp.config;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.dataformat.xml.XmlMapper;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
/**
* @package io.shinhanlife.dap.lib.mcp.config
* @className JacksonConfig
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
@Configuration
public class JacksonConfig {
// 1. JSON 변환기(ObjectMapper)를 스프링 Bean으로 등록
@Bean
@Primary
public ObjectMapper jsonMapper() {
ObjectMapper mapper = new ObjectMapper();
mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
return mapper;
}
// 2. XML 변환기(XmlMapper)를 스프링 Bean으로 등록
@Bean
public XmlMapper xmlMapper() {
return new XmlMapper();
}
}

View File

@@ -0,0 +1,58 @@
package io.shinhanlife.dap.lib.mcp.config;
import org.apache.kafka.clients.producer.ProducerConfig;
import org.apache.kafka.common.serialization.StringSerializer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.kafka.core.DefaultKafkaProducerFactory;
import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.kafka.core.ProducerFactory;
import org.springframework.beans.factory.annotation.Value;
import java.util.HashMap;
import java.util.Map;
/**
* @package io.shinhanlife.dap.lib.mcp.config
* @className KafkaLocalConfig
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
@Configuration
public class KafkaLocalConfig {
@Value("${spring.kafka.bootstrap-servers:localhost:9092}")
private String bootstrapServers;
// 1. 카프카 전송 공장(Factory) 세팅
@Bean
public ProducerFactory<String, String> producerFactory() {
Map<String, Object> configProps = new HashMap<>();
// 가짜 로컬 주소 혹은 환경변수 세팅
configProps.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers);
// 데이터를 카프카로 보낼 때 문자열(String) 형태로 변환하겠다는 규칙
configProps.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
configProps.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
// 3초 만에 빠른 실패 처리 (로컬 무한 대기 방지)
configProps.put(ProducerConfig.MAX_BLOCK_MS_CONFIG, 3000);
// 재접속 주기를 10초로 설정 (콘솔 로그 도배 방지)
configProps.put(ProducerConfig.RECONNECT_BACKOFF_MAX_MS_CONFIG, 10000);
return new DefaultKafkaProducerFactory<>(configProps);
}
// 2. EaiEimsSender가 애타게 찾던 KafkaTemplate을 스프링 Bean으로 등록!
@Bean
public KafkaTemplate<String, String> kafkaTemplate() {
return new KafkaTemplate<>(producerFactory());
}
}

View File

@@ -0,0 +1,47 @@
package io.shinhanlife.dap.lib.mcp.config;
import io.swagger.v3.oas.models.Components;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Info;
import io.swagger.v3.oas.models.security.SecurityRequirement;
import io.swagger.v3.oas.models.security.SecurityScheme;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* @package io.shinhanlife.dap.lib.mcp.config
* @className SwaggerConfig
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
@Configuration
public class SwaggerConfig {
@Bean
public OpenAPI customOpenAPI() {
return new OpenAPI()
.info(new Info()
.title("Shinhan MCP Gateway API 명세서")
.version("v1.0")
.description("AI Agent와 신한라이프 내부망(EIMS/EAI)을 연결하는 Adapter Gateway API 문서입니다."))
.addServersItem(new io.swagger.v3.oas.models.servers.Server().url("http://localhost:8080").description("Adapter Pod (8080)"))
.addServersItem(new io.swagger.v3.oas.models.servers.Server().url("http://localhost:8081").description("Gateway Pod (8081)"))
// 전역적으로 X-API-KEY 보안 설정을 Swagger UI에 추가합니다.
.addSecurityItem(new SecurityRequirement().addList("X-API-KEY"))
.components(new Components()
.addSecuritySchemes("X-API-KEY",
new SecurityScheme()
.name("X-API-KEY")
.type(SecurityScheme.Type.APIKEY)
.in(SecurityScheme.In.HEADER)
.description("헤더에 API Key를 입력해주세요. ")));
}
}

View File

@@ -0,0 +1,54 @@
package io.shinhanlife.dap.lib.mcp.config;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import io.shinhanlife.dap.lib.mcp.security.ApiKeyInterceptor;
/**
* @package io.shinhanlife.dap.lib.mcp.config
* @className WebConfig
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
@Configuration
@RequiredArgsConstructor
public class WebConfig implements WebMvcConfigurer {
// 1. 우리가 만든 인터셉터를 주입받습니다.
private final ApiKeyInterceptor apiKeyInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
// 2. 인터셉터 등록 및 검사할 URL 패턴 지정
registry.addInterceptor(apiKeyInterceptor)
.addPathPatterns("/rpc/**", "/mcp/api/v1/**") // /rpc/, /mcp/api/v1/ 로 시작하는 모든 API는 API Key 검사 수행!
.excludePathPatterns(
"/test/**", "/health", "/error", "/mcp/api/v1/admin/**",
"/swagger-ui/**", "/v3/api-docs/**", "/swagger-resources/**", "/webjars/**", // Swagger UI 경로는 인증 제외
"/mcp/api/v1/tools/docs/markdown", "/favicon.ico", "/mcp/api/v1/tools/list"
);
}
@Override
public void addCorsMappings(CorsRegistry registry) {
// Swagger UI(8080)에서 Gateway(8081)로 API 호출 시 발생하는 CORS 에러 해결
registry.addMapping("/**")
.allowedOriginPatterns("*")
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowedHeaders("*")
.exposedHeaders("Mcp-Session-Id")
.allowCredentials(true);
}
}

View File

@@ -0,0 +1,60 @@
package io.shinhanlife.dap.lib.mcp.exception;
/**
* @package io.shinhanlife.dap.lib.mcp.exception
* @className GlobalExceptionHandler
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
import io.shinhanlife.dap.lib.adapter.dto.ErrorDetail;
import io.shinhanlife.dap.lib.adapter.dto.JsonRpcResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.servlet.resource.NoResourceFoundException;
@Slf4j
@RestControllerAdvice // 이 어노테이션이 전역 적용의 핵심입니다!
public class GlobalExceptionHandler {
@ExceptionHandler(NoResourceFoundException.class)
public ResponseEntity<Void> handleNoResourceFound(NoResourceFoundException e) {
log.warn(" [Gateway Not Found] 요청하신 리소스를 찾을 수 없습니다: {}", e.getResourcePath());
return ResponseEntity.notFound().build();
}
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<JsonRpcResponse> handleIllegalArgument(IllegalArgumentException e) {
log.warn(" [Gateway Bad Request] 잘못된 요청: {}", e.getMessage());
return buildErrorResponse(-32602, "Invalid params: " + e.getMessage());
}
@ExceptionHandler(RuntimeException.class)
public ResponseEntity<JsonRpcResponse> handleRuntime(RuntimeException e) {
log.error(" [Gateway Internal Error] 시스템 장애: {}", e.getMessage(), e);
return buildErrorResponse(-32603, "Internal error: " + e.getMessage());
}
@ExceptionHandler(Exception.class)
public ResponseEntity<JsonRpcResponse> handleAllException(Exception e) {
log.error(" [Gateway Fatal Error] 치명적 오류 발생", e);
return buildErrorResponse(-32000, "Server error: 시스템 관리자에게 문의하세요.");
}
private ResponseEntity<JsonRpcResponse> buildErrorResponse(int code, String message) {
JsonRpcResponse response = new JsonRpcResponse();
response.setError(new ErrorDetail(code, message));
return ResponseEntity.ok(response);
}
}

View File

@@ -0,0 +1,56 @@
package io.shinhanlife.dap.lib.mcp.filter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.slf4j.MDC;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.UUID;
/**
* @package io.shinhanlife.dap.lib.mcp.filter
* @className MdcLoggingFilter
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
@Component
public class MdcLoggingFilter extends OncePerRequestFilter {
private static final String TRACE_ID_HEADER = "X-Trace-Id";
private static final String MDC_KEY = "traceId";
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
// 클라이언트가 보낸 Trace ID가 있으면 쓰고, 없으면 새로 생성
String traceId = request.getHeader(TRACE_ID_HEADER);
if (traceId == null || traceId.isEmpty()) {
// 간결하게 8자리 UUID만 사용
traceId = UUID.randomUUID().toString().substring(0, 8);
}
// 로깅 컨텍스트에 고유 ID 저장
MDC.put(MDC_KEY, traceId);
try {
// 이 요청이 처리되는 동안 찍히는 모든 log.info, log.error에 traceId가 자동으로 붙습니다.
filterChain.doFilter(request, response);
} finally {
// 메모리 누수 방지를 위해 요청이 끝나면 반드시 비워줍니다.
MDC.clear();
}
}
}

View File

@@ -0,0 +1,80 @@
package io.shinhanlife.dap.lib.mcp.security;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.slf4j.MDC;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import java.util.Map;
/**
* @package io.shinhanlife.dap.lib.mcp.security
* @className ApiKeyInterceptor
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class ApiKeyInterceptor implements HandlerInterceptor {
// 1. 다중 테넌트 API Key 목록이 담긴 프로퍼티 객체를 주입받습니다.
private final SecurityProperties securityProperties;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
if ("OPTIONS".equalsIgnoreCase(request.getMethod())) {
return true;
}
String apiKey = request.getHeader("X-API-KEY");
Map<String, String> validApiKeys = securityProperties.getApiKeys();
// 2. 만약 프로퍼티에 API Key가 하나도 설정되어 있지 않다면 (개발/로컬 환경 등) 인증 없이 통과시킵니다.
if (validApiKeys == null || validApiKeys.isEmpty()) {
MDC.put("tenantId", "anonymous");
request.setAttribute("tenantId", "anonymous");
log.debug(" [보안 패스] 등록된 API Key 없음 - 익명 사용자(anonymous)로 통과");
return true;
}
// 3. 헤더로 들어온 API Key가 우리가 발급해준 목록(Map)에 존재하는지 확인합니다.
if (apiKey == null || !validApiKeys.containsKey(apiKey)) {
log.warn(" [보안 차단] 유효하지 않은 API Key 접근 시도 - IP: {}", request.getRemoteAddr());
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Invalid API Key");
return false; // 컨트롤러로 넘어가지 않음
}
// 4. 유효하다면 해당 키에 맵핑된 Tenant ID(식별자)를 가져옵니다. (ex. mcp-client-1)
String tenantId = validApiKeys.get(apiKey);
// 4. 추출한 Tenant ID를 현재 스레드의 로깅 컨텍스트(MDC)에 저장합니다.
// 이렇게 하면 이 요청이 끝날 때까지 찍히는 모든 로그에 어떤 테넌트가 호출했는지 자동으로 기록됩니다.
MDC.put("tenantId", tenantId);
// 5. 필요시 컨트롤러 로직에서 사용할 수 있도록 Request 속성에도 담아줍니다.
request.setAttribute("tenantId", tenantId);
log.debug(" [보안 통과] API Key 인증 성공 - 접속 테넌트: {}", tenantId);
return true; // 인증 통과! 컨트롤러로 진행
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
// 6. 메모리 누수를 방지하기 위해 요청 처리가 완전히 끝나면 MDC에서 테넌트 정보를 지워줍니다.
MDC.remove("tenantId");
}
}

View File

@@ -0,0 +1,40 @@
package io.shinhanlife.dap.lib.mcp.security;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* [다중 테넌트 설정 매핑 클래스]
* application-local.properties 파일에 정의된 mcp.security.api-keys.* 설정들을
* Map 자료구조로 자동 바인딩(주입) 받기 위한 설정 클래스입니다.
*/
/**
* @package io.shinhanlife.dap.lib.mcp.security
* @className SecurityProperties
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
@Data
@Component
@ConfigurationProperties(prefix = "mcp.security")
public class SecurityProperties {
// API Key를 Key로, Tenant ID를 Value로 가지는 맵
private Map<String, String> apiKeys = new HashMap<>();
// Tenant ID를 Key로, 허용된 도메인 그룹 목록을 Value로 가지는 맵 (ex. mcp-client-1 -> [CUSTOMER, COMMON])
// 만약 "ALL" 이 포함되어 있다면 모든 도메인에 접근 허용
private Map<String, List<String>> tenantDomains = new HashMap<>();
}

View File

@@ -0,0 +1,29 @@
package io.shinhanlife.dap.lib.session.converter;
import io.shinhanlife.dap.lib.session.dto.SessionDto;
import io.shinhanlife.dap.lib.session.dto.ZtUsacOutDto;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
/**
* @package io.shinhanlife.dap.lib.session.converter
* @className ZtUsacConverter
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
@Mapper(componentModel = "spring")
public abstract class ZtUsacConverter {
@Mapping(target = "loginDtm", ignore = true)
@Mapping(target = "isManager", ignore = true)
public abstract SessionDto toSessionDto(ZtUsacOutDto dto);
}

View File

@@ -0,0 +1,51 @@
package io.shinhanlife.dap.lib.session.domain.model;
import io.shinhanlife.glow.db.dto.AuditInfo;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
/**
* @package io.shinhanlife.dap.lib.session.domain.model
* @className ZtUsacModel
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
@Getter
@Builder
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
public class ZtUsacModel extends AuditInfo {
/* 인사번호 */
private String prafNo;
/* 인사명 */
private String prafNm;
/* 조직번호 */
private String ognzNo;
/* 이메일주소 */
private String addre;
/* 인사직무코드 */
private String prafOfduCd;
/* 인사직무명 */
private String prafOfduNm;
/* 인사직급코드 */
private String prafOfleCd;
/* 인사직급명 */
private String prafOfleNm;
/* 인사직책코드 */
private String prafDutyCd;
/* 인사직책명 */
private String prafDutyNm;
}

View File

@@ -0,0 +1,33 @@
package io.shinhanlife.dap.lib.session.domain.repository;
import io.shinhanlife.glow.GlowMybatisMapper;
import io.shinhanlife.dap.lib.session.dto.ZtUsacInDto;
import io.shinhanlife.dap.lib.session.dto.ZtUsacOutDto;
/**
* @package io.shinhanlife.dap.lib.session.domain.repository
* @className ZtUsacRepository
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
@GlowMybatisMapper
public interface ZtUsacRepository {
/**
* 사용자 조회 (단건)
*
* @param dto 사번
* @return 인사정보
*/
ZtUsacOutDto selectZtUsac(ZtUsacInDto dto);
}

View File

@@ -0,0 +1,29 @@
package io.shinhanlife.dap.lib.session.domain.usecase;
import io.shinhanlife.dap.lib.session.dto.ZtUsacInDto;
import io.shinhanlife.dap.lib.session.dto.ZtUsacOutDto;
/**
* @package io.shinhanlife.dap.lib.session.domain.service
* @className ZtUsacUseCase
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
public interface ZtUsacUseCase {
/**
* 사용자 조회 (단건)
*
* @param dto 사번
* @return 인사정보
*/
ZtUsacOutDto selectZtUsac(ZtUsacInDto dto);
}

View File

@@ -0,0 +1,42 @@
package io.shinhanlife.dap.lib.session.domain.usecase.impl;
import io.shinhanlife.dap.lib.session.domain.repository.ZtUsacRepository;
import io.shinhanlife.dap.lib.session.domain.usecase.ZtUsacUseCase;
import io.shinhanlife.dap.lib.session.dto.ZtUsacInDto;
import io.shinhanlife.dap.lib.session.dto.ZtUsacOutDto;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
/**
* @package io.shinhanlife.dap.lib.session.domain.usecase.impl
* @className ZtUsacUseCaseImpl
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
@Service
@RequiredArgsConstructor
public class ZtUsacUseCaseImpl implements ZtUsacUseCase {
private final ZtUsacRepository ztUsacRepository;
/**
* 사용자 조회 (단건)
*
* @param dto 사번
* @return 인사정보
*/
@Override
public ZtUsacOutDto selectZtUsac(ZtUsacInDto dto) {
ZtUsacOutDto result = ztUsacRepository.selectZtUsac(dto);
result.initLists();
return result;
}
}

View File

@@ -0,0 +1,90 @@
package io.shinhanlife.dap.lib.session.dto;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.List;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.Setter;
/**
* @package io.shinhanlife.dap.lib.session.dto
* @className SessionDto
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
@Getter
@Setter
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class SessionDto {
/* 인사번호 */
private String prafNo;
/* 인사명 */
private String prafNm;
/* 조직번호 */
private String ognzNo;
/* 조직번호 */
private String ognzNm;
/* 이메일주소 */
private String addre;
/* 인사직무코드 */
private String prafOfduCd;
/* 인사직무명 */
private String prafOfduNm;
/* 인사직급코드 */
private String prafOfleCd;
/* 인사직급명 */
private String prafOfleNm;
/* 인사직책코드 */
private String prafDutyCd;
/* 인사직책명 */
private String prafDutyNm;
private List<String> roleNoList;
private List<String> roleNmList;
private List<String> tgtrPrafNoList;
private List<String> tgtrOgnzNoList;
// 추가된 LICO 연동 공통 헤더 필수 필드들
private String strYmd;
private String brafNo;
private String psmrAsrtCd;
private String sbsnRulpAsrtCd;
private String bsduCd;
private String bsquCd;
private String ognzAsrtCd;
private String ognzLeveCd;
private String prgrId;
// 유틸성
private String loginDtm; // 로그인일시
private String isManager; // 관리자여부
public void setLoginDtm() {
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyyMMddHHmmssSSS");
this.loginDtm = LocalDateTime.now().format(formatter);
}
public void setIsManager(String isManager) {
// TODO 역할 필터링 후 관리자 여부 체크
this.isManager = "Y";
}
}

View File

@@ -0,0 +1,37 @@
package io.shinhanlife.dap.lib.session.dto;
import lombok.NoArgsConstructor;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.Setter;
/**
* @package io.shinhanlife.dap.lib.session.dto
* @className ZtUsacInDto
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
@Getter
@Setter
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class ZtUsacInDto {
/* 인사번호 */
private String prafNo;
/* 사용여부 */
@Builder.Default
private String puseYn = "Y";
}

View File

@@ -0,0 +1,85 @@
package io.shinhanlife.dap.lib.session.dto;
import lombok.Setter;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
/**
* @package io.shinhanlife.dap.lib.session.dto
* @className ZtUsacOutDto
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
@Getter
@Builder
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Setter
public class ZtUsacOutDto {
/* 인사번호 */
private String prafNo;
/* 인사명 */
private String prafNm;
/* 조직번호 */
private String ognzNo;
/* 조직명 */
private String ognzNm;
/* 이메일주소 */
// @GlowSecureField(type = DataSecureType.DECRYPT_DB, direction = SafeDBType.COMM) TODO 방화벽 뚫리면 확인
private String addre;
/* 인사직무코드 */
private String prafOfduCd;
/* 인사직무명 */
private String prafOfduNm;
/* 인사직급코드 */
private String prafOfleCd;
/* 인사직급명 */
private String prafOfleNm;
/* 인사직책코드 */
private String prafDutyCd;
/* 인사직책명 */
private String prafDutyNm;
/* 사용여부 */
private String puseYn;
private String roleNoStrList;
private String roleNmStrList;
private String tgtrPrafNoStrList;
private String tgtrOgnzNoStrList;
private List<String> roleNoList;
private List<String> roleNmList;
private List<String> tgtrPrafNoList;
private List<String> tgtrOgnzNoList;
public void initLists() {
this.roleNoList = convertStrToList(roleNoStrList);
this.roleNmList = convertStrToList(roleNmStrList);
this.tgtrPrafNoList = convertStrToList(tgtrPrafNoStrList);
this.tgtrOgnzNoList = convertStrToList(tgtrOgnzNoStrList);
}
private List<String> convertStrToList(String str) {
if (str == null || str.isEmpty()) {
return new ArrayList<>();
}
return Arrays.asList(str.split(","));
}
}

View File

@@ -0,0 +1,91 @@
package io.shinhanlife.dap.lib.session.presentation;
import io.micrometer.common.util.StringUtils;
import io.shinhanlife.glow.BaseResponse;
import io.shinhanlife.glow.BizException;
import io.shinhanlife.glow.GlowControllerId;
import io.shinhanlife.glow.ResponseUtil;
import io.shinhanlife.dap.lib.session.converter.ZtUsacConverter;
import io.shinhanlife.dap.lib.session.domain.usecase.ZtUsacUseCase;
import io.shinhanlife.dap.lib.session.dto.SessionDto;
import io.shinhanlife.dap.lib.session.dto.ZtUsacInDto;
import io.shinhanlife.dap.lib.session.dto.ZtUsacOutDto;
import io.shinhanlife.dap.lib.session.presentation.io.SsoResponse;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpSession;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.ibatis.javassist.NotFoundException;
import org.springframework.http.ResponseEntity;
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.Objects;
/**
* @package io.shinhanlife.dap.lib.session.presentation
* @className SsoRestController
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
@RestController
@RequiredArgsConstructor
@Slf4j
@RequestMapping("/sso")
public class SsoRestController {
private static final String NLS_LOGIN_URL = "";
private final ZtUsacUseCase ztUsacService;
private final ZtUsacConverter ztUsacConverter;
/**
* sso 연동 전 임시 로그인
*
* @param request
* @param response
* @param session
* @param <T>
* @return
*/
@GlowControllerId("tempLogin")
@PostMapping("/tempLogin")
public <T> ResponseEntity<BaseResponse<SsoResponse>> tempLogin(HttpServletRequest request, HttpServletResponse response,
HttpSession session, @RequestBody SessionDto requestDto) {
try {
if (StringUtils.isEmpty(requestDto.getPrafNo())) {
throw new NotFoundException("SSO >> not found sso id");
}
// DB 유저 가져오기
ZtUsacOutDto ztUsacOutDto = ztUsacService.selectZtUsac(ZtUsacInDto.builder().prafNo(requestDto.getPrafNo()).puseYn("Y")
.build());
if (Objects.isNull(ztUsacOutDto) || StringUtils.isEmpty(ztUsacOutDto.getPrafNo())) {
throw new BizException("SSO >> not found UserInfo >> retCode:");
}
SessionDto sessionDto = ztUsacConverter.toSessionDto(ztUsacOutDto);
sessionDto.setLoginDtm(); // 로그인 시점 세팅
session.setAttribute("userInfo", sessionDto);
return ResponseUtil.ok(SsoResponse.builder().retCode("0").userInfo(sessionDto).build());
} catch (Exception e) {
log.error("로그인 실패", e);
session.invalidate();
}
return ResponseUtil.ok(SsoResponse.builder().redirectUrl(NLS_LOGIN_URL).build());
}
}

View File

@@ -0,0 +1,35 @@
package io.shinhanlife.dap.lib.session.presentation.io;
import io.shinhanlife.dap.lib.session.dto.SessionDto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
/**
* @package io.shinhanlife.dap.lib.session.presentation.io
* @className SsoResponse
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class SsoResponse {
private String retCode;
private SessionDto userInfo;
private String redirectUrl;
}

View File

@@ -0,0 +1,205 @@
package io.shinhanlife.dap.lib.util;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonPropertyDescription;
import io.shinhanlife.dap.lib.annotation.McpParameter;
import io.shinhanlife.dap.lib.annotation.McpValidation;
import io.shinhanlife.dap.lib.annotation.McpAnyOf;
import java.lang.reflect.Field;
import java.lang.reflect.ParameterizedType;
import java.lang.reflect.Type;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* @package io.shinhanlife.dap.lib.util
* @className JsonSchemaGenerator
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
public class JsonSchemaGenerator {
/**
* Java DTO 클래스를 분석하여 MCP 규격의 완전한 JSON Schema를 생성합니다.
*/
public static Map<String, Object> generateSchema(Class<?> clazz) {
return generateSchema(clazz, new HashSet<>());
}
private static Map<String, Object> generateSchema(Class<?> clazz, Set<Class<?>> visiting) {
Map<String, Object> schema = new HashMap<>();
schema.put("type", "object");
schema.put("additionalProperties", false);
if (!visiting.add(clazz)) {
return schema;
}
Map<String, Object> properties = new HashMap<>();
List<String> requiredList = new ArrayList<>();
for (Field field : clazz.getDeclaredFields()) {
Map<String, Object> fieldSchema = createFieldSchema(field, visiting);
// 1. 타입 매핑
// 2. 어노테이션 기반 설명 추출
McpParameter paramAnnotation = field.getAnnotation(McpParameter.class);
JsonPropertyDescription descAnnotation = field.getAnnotation(JsonPropertyDescription.class);
if (paramAnnotation != null && !paramAnnotation.description().isEmpty()) {
fieldSchema.put("description", paramAnnotation.description());
} else if (descAnnotation != null && !descAnnotation.value().isEmpty()) {
fieldSchema.put("description", descAnnotation.value());
} else {
fieldSchema.put("description", field.getName()); // 기본값
}
// 3. 필수 여부 판단
JsonProperty jsonProp = field.getAnnotation(JsonProperty.class);
if ((jsonProp != null && jsonProp.required()) || (paramAnnotation != null && paramAnnotation.required())) {
requiredList.add(field.getName());
}
McpValidation validation = field.getAnnotation(McpValidation.class);
if (validation != null && validation.required() && !requiredList.contains(field.getName())) {
requiredList.add(field.getName());
}
if (validation != null && !validation.pattern().isEmpty()) {
fieldSchema.put("pattern", validation.pattern());
}
if (validation != null && validation.minimum() != Long.MIN_VALUE) {
fieldSchema.put("minimum", validation.minimum());
}
if (validation != null && validation.maximum() != Long.MAX_VALUE) {
fieldSchema.put("maximum", validation.maximum());
}
if (validation != null && validation.minLength() >= 0) {
fieldSchema.put("minLength", validation.minLength());
}
if (validation != null && validation.maxLength() >= 0) {
fieldSchema.put("maxLength", validation.maxLength());
}
if (validation != null && validation.allowedValues().length > 0) {
fieldSchema.put("enum", List.of(validation.allowedValues()));
}
if (validation != null && !validation.format().isEmpty()) {
fieldSchema.put("format", validation.format());
}
if (validation != null && !validation.defaultValue().isEmpty()) {
fieldSchema.put("default", coerceDefaultValue(validation.defaultValue(), field.getType()));
}
if (validation != null && validation.examples().length > 0) {
fieldSchema.put("examples", List.of(validation.examples()));
}
if (validation != null && validation.nullable()) {
Map<String, Object> nonNullSchema = new HashMap<>(fieldSchema);
fieldSchema = new HashMap<>();
fieldSchema.put("anyOf", List.of(
nonNullSchema,
Map.of("type", "null")
));
}
properties.put(field.getName(), fieldSchema);
}
schema.put("properties", properties);
if (!requiredList.isEmpty()) {
schema.put("required", requiredList);
}
McpAnyOf anyOfAnnotation = clazz.getAnnotation(McpAnyOf.class);
if (anyOfAnnotation != null && anyOfAnnotation.value().length > 0) {
List<Map<String, Object>> anyOfList = new ArrayList<>();
for (String fieldName : anyOfAnnotation.value()) {
anyOfList.add(Map.of("required", List.of(fieldName)));
}
schema.put("anyOf", anyOfList);
}
visiting.remove(clazz);
return schema;
}
private static Object coerceDefaultValue(String value, Class<?> fieldType) {
try {
if (fieldType == Integer.class || fieldType == int.class
|| fieldType == Long.class || fieldType == long.class
|| fieldType == Short.class || fieldType == short.class
|| fieldType == Byte.class || fieldType == byte.class) {
return Long.valueOf(value);
}
if (fieldType == Double.class || fieldType == double.class
|| fieldType == Float.class || fieldType == float.class) {
return Double.valueOf(value);
}
if (fieldType == Boolean.class || fieldType == boolean.class) {
return Boolean.valueOf(value);
}
return value;
} catch (NumberFormatException e) {
throw new IllegalArgumentException("Invalid MCP default value: " + value, e);
}
}
private static Map<String, Object> createFieldSchema(Field field, Set<Class<?>> visiting) {
Class<?> fieldType = field.getType();
if (isSimpleType(fieldType)) {
return new HashMap<>(Map.of("type", mapJavaTypeToJsonType(fieldType)));
}
if (List.class.isAssignableFrom(fieldType)) {
Map<String, Object> fieldSchema = new HashMap<>();
fieldSchema.put("type", "array");
fieldSchema.put("items", generateItemsSchema(field, visiting));
return fieldSchema;
}
return generateSchema(fieldType, visiting);
}
private static Map<String, Object> generateItemsSchema(Field field, Set<Class<?>> visiting) {
Type genericType = field.getGenericType();
if (genericType instanceof ParameterizedType parameterizedType) {
Type itemType = parameterizedType.getActualTypeArguments()[0];
if (itemType instanceof Class<?> itemClass) {
if (isSimpleType(itemClass)) {
return new HashMap<>(Map.of("type", mapJavaTypeToJsonType(itemClass)));
}
return generateSchema(itemClass, visiting);
}
}
return new HashMap<>(Map.of("type", "object"));
}
private static boolean isSimpleType(Class<?> clazz) {
return clazz == String.class
|| clazz == Integer.class || clazz == int.class
|| clazz == Long.class || clazz == long.class
|| clazz == Double.class || clazz == double.class
|| clazz == Float.class || clazz == float.class
|| clazz == Boolean.class || clazz == boolean.class;
}
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 (List.class.isAssignableFrom(clazz)) return "array";
return "object";
}
}

View File

@@ -0,0 +1,309 @@
package io.shinhanlife.dap.lib.util;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardOpenOption;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.Scanner;
public class PodScaffolder {
public static void main(String[] args) throws IOException {
Scanner scanner = new Scanner(System.in);
System.out.println("=========================================");
System.out.println(" MCP Tool Pod Scaffolder (Java CLI) ");
System.out.println("=========================================\n");
String rawModuleName = getOrAsk(args, 0, scanner, "1. 생성할 모듈(Pod) 이름 (예: payment 또는 dap-tool-payment): ");
String moduleName = rawModuleName.startsWith("dap-tool-") ? rawModuleName : "dap-tool-" + rawModuleName;
String portStr = getOrAsk(args, 1, scanner, "2. 사용할 포트 번호 (예: 8085): ");
String shortName = moduleName.replace("dap-tool-", "").replace("-", "");
String defaultAuthor = System.getProperty("user.name");
String defaultDate = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy.MM.dd"));
String author = getOrAsk(args, 2, scanner, "3. 작성자 (엔터 입력 시 '" + defaultAuthor + "'): ");
if (author.trim().isEmpty()) author = defaultAuthor;
String createDate = getOrAsk(args, 3, scanner, "4. 작성일 (엔터 입력 시 '" + defaultDate + "'): ");
if (createDate.trim().isEmpty()) createDate = defaultDate;
String result = scaffoldPod(moduleName, portStr, shortName, author, createDate);
System.out.println(result);
}
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();
}
public static String scaffoldPod(String moduleName, String portStr, String shortName, String author, String createDate) throws IOException {
String envSourceDir = System.getenv("AXHUB_SOURCE_DIR");
Path rootDir = envSourceDir != null ? Paths.get(envSourceDir) : Paths.get(".");
Path modulePath = rootDir.resolve(Paths.get(moduleName));
if (Files.exists(modulePath)) {
return "[오류] 이미 존재하는 모듈입니다: " + moduleName;
}
StringBuilder log = new StringBuilder();
log.append("[1/6] 모듈 디렉터리 생성 중...\n");
Files.createDirectories(modulePath);
log.append("[2/6] build.gradle 생성 중...\n");
String buildGradle = """
plugins {
id 'org.springframework.boot'
}
dependencies {
implementation project(':dap-tool-core')
}
dependencies {
compileOnly 'org.projectlombok:lombok:1.18.32'
annotationProcessor 'org.projectlombok:lombok:1.18.32'
}
""";
Files.writeString(modulePath.resolve("build.gradle"), buildGradle);
log.append("[3/6] Dockerfile 생성 중...\n");
String dockerfile = """
FROM eclipse-temurin:21-jdk-alpine
WORKDIR /app
COPY build/libs/%s-0.0.1-SNAPSHOT.jar app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]
""".formatted(moduleName);
Files.writeString(modulePath.resolve("Dockerfile"), dockerfile);
log.append("[4/6] Application 클래스 및 설정 파일 생성 중...\n");
Path srcPath = modulePath.resolve("src/main/java/io/shinhanlife/dap/mcc/" + shortName);
Files.createDirectories(srcPath);
String appClass = """
package io.shinhanlife.dap.mcc.%s;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
import org.springframework.cache.annotation.EnableCaching;
/**
* @package io.shinhanlife.dap.mcc.%s
* @className DapTool%sApplication
* @description AX HUB 시스템 처리 클래스
* @author %s
* @create %s
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* %s %s 최초생성
*
* </pre>
*/
@SpringBootApplication(scanBasePackages = {"io.shinhanlife.dap.mcc", "io.shinhanlife.dap.lib.adapter", "io.shinhanlife.dap.lib.mcp", "io.shinhanlife.dap.lib.config", "io.shinhanlife.dap.lib.integration"})
@ConfigurationPropertiesScan(basePackages = {"io.shinhanlife.dap.mcc", "io.shinhanlife.dap.lib.adapter", "io.shinhanlife.dap.lib.mcp", "io.shinhanlife.dap.lib.config", "io.shinhanlife.dap.lib.integration"})
@EnableCaching
public class DapTool%sApplication {
public static void main(String[] args) {
SpringApplication.run(DapTool%sApplication.class, args);
}
}
""".formatted(shortName, shortName, capitalize(shortName), author, createDate, createDate, author, capitalize(shortName), capitalize(shortName));
Files.writeString(srcPath.resolve("DapTool" + capitalize(shortName) + "Application.java"), appClass);
Path resPath = modulePath.resolve("src/main/resources");
Files.createDirectories(resPath);
String applicationYml = """
server:
port: %s
spring:
application:
name: %s
profiles:
active: local
logging:
level:
org.apache.kafka: ERROR
mcp:
namespace: ""
security:
tenant-domains:
TESTER-DEV: ALL
""".formatted(portStr, moduleName);
Files.writeString(resPath.resolve("application.yml"), applicationYml);
String applicationLocalYml = """
# Local 환경 전용 설정 (H2 메모리 DB 등)
spring:
datasource:
url: jdbc:p6spy:h2:mem:testdb;DB_CLOSE_DELAY=-1;
driverClassName: com.p6spy.engine.spy.P6SpyDriver
username: sa
password: password
h2:
console:
enabled: true
eims:
http:
url: http://localhost:${server.port}/api/gateway
tcp:
host: 127.0.0.1
port: 8090
timeout: 5000
jsp:
form:
url: http://localhost:${server.port}/mock/jsp-form
json:
url: http://localhost:${server.port}/mock/jsp-json
mci:
url: http://localhost:${server.port}/api/mock/esb/api
mcistring:
url: http://localhost:${server.port}/api/mock/esb/string
mcp:
security:
tenant-domains:
mcp-client-1: CUSTOMER,COMMON
mcp-client-2: ALL
axhub:
gateway:
url: http://localhost:8081
tool:
url: ${AXHUB_TOOL_URL:http://localhost:${server.port}}
""";
Files.writeString(resPath.resolve("application-local.yml"), applicationLocalYml);
String applicationDevYml = """
# OCI 클라우드 환경 전용 설정
server:
port: ${PORT:%s}
axhub:
gateway:
url: https://axhubmcp.devjun.net
tool:
url: http://144.24.70.100:%s
eims:
http:
url: http://localhost:${server.port}/api/gateway
tcp:
host: 127.0.0.1
port: 8090
timeout: 5000
jsp:
form:
url: http://localhost:${server.port}/mock/jsp-form
json:
url: http://localhost:${server.port}/mock/jsp-json
mci:
url: http://localhost:${server.port}/api/mock/esb/api
mcistring:
url: http://localhost:${server.port}/api/mock/esb/string
shinhan:
integration:
envrTypeCd: D
eai:
url: http://10.176.32.181
internalMci:
url: http://10.176.32.173
bancaMci:
url: http://10.176.32.117
externalMci:
url: http://10.176.32.176
""".formatted(portStr, portStr);
Files.writeString(resPath.resolve("application-dev.yml"), applicationDevYml);
String logbackXml = """
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<property name="LOG_PATTERN" value="%%d{yyyy-MM-dd HH:mm:ss.SSS} [%%thread] [%%X{traceId}] %%-5level %%logger{36} - %%msg%%n" />
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>${LOG_PATTERN}</pattern>
</encoder>
</appender>
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>logs/%s.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>logs/%s-%%d{yyyy-MM-dd}.log</fileNamePattern>
<maxHistory>30</maxHistory>
</rollingPolicy>
<encoder>
<pattern>${LOG_PATTERN}</pattern>
</encoder>
</appender>
<root level="INFO">
<appender-ref ref="CONSOLE" />
<appender-ref ref="FILE" />
</root>
<logger name="io.shinhanlife" level="DEBUG" />
</configuration>
""".formatted(moduleName, moduleName);
Files.writeString(resPath.resolve("logback-spring.xml"), logbackXml);
log.append("[5/6] settings.gradle 에 모듈 등록 중...\n");
Path settingsPath = rootDir.resolve(Paths.get("settings.gradle"));
if (Files.exists(settingsPath)) {
String settings = Files.readString(settingsPath);
if (!settings.contains("include '" + moduleName + "'")) {
Files.writeString(settingsPath, System.lineSeparator() + "include '" + moduleName + "'" + System.lineSeparator(), StandardOpenOption.APPEND);
}
}
log.append("[6/6] docker-compose.yml 에 서비스 추가 중...\n");
Path dockerComposePath = rootDir.resolve(Paths.get("docker-compose.yml"));
if (Files.exists(dockerComposePath)) {
String compose = Files.readString(dockerComposePath);
String serviceName = moduleName.replace("dap-", ""); // e.g. tool-payment
if (!compose.contains(" " + serviceName + ":")) {
String newService = """
%s:
build:
context: .
dockerfile: %s/Dockerfile
ports:
- "%s:%s"
depends_on:
- redis
environment:
- TZ=Asia/Seoul
- SPRING_REDIS_HOST=redis
- SPRING_REDIS_PORT=6379
- SPRING_DATA_REDIS_PORT=6379
- AXHUB_GATEWAY_URL=http://gateway:8081
- AXHUB_TOOL_URL=http://%s:%s
- GLOW_COMMUNICATION_MCI_HOST=http://mci-mock
- GLOW_COMMUNICATION_MCI_PORT=8080
- GLOW_COMMUNICATION_EXTMCI_HOST=http://mci-mock
- GLOW_COMMUNICATION_EXTMCI_PORT=8080
- GLOW_COMMUNICATION_EAI_HOST=http://mci-mock
- GLOW_COMMUNICATION_EAI_PORT=8080
""".formatted(serviceName, moduleName, portStr, portStr, serviceName, portStr);
Files.writeString(dockerComposePath, System.lineSeparator() + newService, StandardOpenOption.APPEND);
}
}
log.append("\n=========================================\n");
log.append(" Pod Scaffolding Complete! \n");
log.append("=========================================\n");
log.append("1. [새로운 모듈] ").append(moduleName).append(" 폴더가 생성되었습니다.\n");
log.append("2. [ToolScaffolder]를 사용해 이 모듈 안에 툴을 추가하세요.\n");
log.append("3. 실행 전 Gradle 동기화(Sync)를 한 번 진행해 주세요.\n");
return log.toString();
}
private static String capitalize(String str) {
if (str == null || str.isEmpty()) return str;
return str.substring(0, 1).toUpperCase() + str.substring(1);
}
}

View File

@@ -0,0 +1,45 @@
package io.shinhanlife.dap.lib.util;
import io.shinhanlife.dap.lib.session.dto.SessionDto;
import jakarta.servlet.http.HttpSession;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
/**
* @package io.shinhanlife.dap.lib.util
* @className SessionUtil
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
public class SessionUtil {
private static final String SESSION_KEY = "userInfo";
private SessionUtil() {}
public static SessionDto getSession() {
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (attributes == null) return null;
HttpSession session = attributes.getRequest().getSession(false);
if (session == null) return null;
return (SessionDto) session.getAttribute(SESSION_KEY);
}
public static String getPrafNo() {
SessionDto session = getSession();
return session != null ? session.getPrafNo() : null;
}
public static String getOgnzNo() {
SessionDto session = getSession();
return session != null ? session.getOgnzNo() : null;
}
}

Some files were not shown because too many files have changed in this diff Show More