: 통계거래 데이터 저장 - transaction 격리
Task-Url:
This commit is contained in:
@@ -0,0 +1,152 @@
|
||||
package com.eactive.eai.custom.statistics;
|
||||
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import com.eactive.eai.common.message.EAIMessage;
|
||||
import com.eactive.eai.common.monitor.StatisticsMonitor;
|
||||
import com.eactive.eai.common.util.Logger;
|
||||
import com.eactive.eai.custom.statistics.collector.ApiStatsCollector;
|
||||
import com.eactive.eai.custom.statistics.dto.TransactionEvent;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
/**
|
||||
* StatisticsMonitor 구현체 - EAIServiceMonitor에서 호출되어 API 통계 수집 - EAIMessage에서 필요한
|
||||
* 정보를 추출하여 ApiStatsCollector에 전달
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class ApiStatsMonitorImpl implements StatisticsMonitor {
|
||||
|
||||
private static final Logger logger = Logger.getLogger(Logger.LOGGER_DEFAULT);
|
||||
|
||||
private final ApiStatsCollector apiStatsCollector;
|
||||
|
||||
@Override
|
||||
public void recordStatistics(EAIMessage requestEaiMessage) {
|
||||
try {
|
||||
// 로그 시퀀스 400 또는 900만 처리
|
||||
int logSequence = requestEaiMessage.getLogPssSno();
|
||||
if (logSequence != 400 && logSequence != 900) {
|
||||
return;
|
||||
}
|
||||
|
||||
String apiName = extractApiName(requestEaiMessage);
|
||||
String gwInstanceId = extractGwInstanceId(requestEaiMessage);
|
||||
String bizDivCode = extractBizDivCode(requestEaiMessage);
|
||||
String clientId = extractClientId(requestEaiMessage);
|
||||
String inboundAdapter = extractInboundAdapter(requestEaiMessage);
|
||||
String outboundAdapter = extractOutboundAdapter(requestEaiMessage);
|
||||
long responseTimeMs = extractResponseTime(requestEaiMessage);
|
||||
boolean success = extractSuccess(requestEaiMessage);
|
||||
TransactionEvent.ErrorType errorType = extractErrorType(requestEaiMessage);
|
||||
|
||||
// TransactionEvent 생성
|
||||
TransactionEvent event = TransactionEvent.builder().apiName(apiName).gwInstanceId(gwInstanceId)
|
||||
.bizDivCode(TransactionEvent.normalizeValue(bizDivCode))
|
||||
.clientId(TransactionEvent.normalizeValue(clientId))
|
||||
.inboundAdapter(TransactionEvent.normalizeValue(inboundAdapter))
|
||||
.outboundAdapter(TransactionEvent.normalizeValue(outboundAdapter)).responseTimeMs(responseTimeMs)
|
||||
.logSequence(logSequence).success(success).errorType(errorType).build();
|
||||
|
||||
// 통계 수집
|
||||
apiStatsCollector.recordEvent(event);
|
||||
|
||||
if (logger.isDebug()) {
|
||||
logger.debug("API statistics recorded: " + apiName);
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
logger.error("Failed to record API statistics", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* API 명 추출
|
||||
*/
|
||||
private String extractApiName(EAIMessage msg) {
|
||||
String apiName = msg.getEAISvcCd();
|
||||
return apiName != null ? apiName : "UNKNOWN";
|
||||
}
|
||||
|
||||
/**
|
||||
* Gateway 인스턴스 ID 추출
|
||||
*/
|
||||
private String extractGwInstanceId(EAIMessage msg) {
|
||||
String instanceId = msg.getEAISvrInstNm();
|
||||
return instanceId != null ? instanceId : "UNKNOWN";
|
||||
}
|
||||
|
||||
/**
|
||||
* 업무구분코드 추출
|
||||
*/
|
||||
private String extractBizDivCode(EAIMessage msg) {
|
||||
return msg.getBwkCls();
|
||||
}
|
||||
|
||||
/**
|
||||
* 클라이언트 ID 추출
|
||||
*/
|
||||
private String extractClientId(EAIMessage msg) {
|
||||
return msg.getClientId();
|
||||
}
|
||||
|
||||
/**
|
||||
* Inbound Adapter 추출
|
||||
*/
|
||||
private String extractInboundAdapter(EAIMessage msg) {
|
||||
return msg.getSngSysItfTp();
|
||||
}
|
||||
|
||||
/**
|
||||
* Outbound Adapter 추출
|
||||
*/
|
||||
private String extractOutboundAdapter(EAIMessage msg) {
|
||||
if (msg.getCurrentSvcMsg() != null) {
|
||||
// TODO 실제 값이 맞는 지 확인 필요
|
||||
return msg.getCurrentSvcMsg().getPsvSysItfTp();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 응답시간 추출 (ms)
|
||||
*/
|
||||
private long extractResponseTime(EAIMessage msg) {
|
||||
long msgPssTm = msg.getMsgPssTm(); // 메시지 처리시각
|
||||
long msgRcvTm = msg.getMsgRcvTm(); // 메시지 수신시각
|
||||
|
||||
if (msgPssTm > 0 && msgRcvTm > 0) {
|
||||
return msgPssTm - msgRcvTm;
|
||||
}
|
||||
|
||||
return 0L;
|
||||
}
|
||||
|
||||
/**
|
||||
* 성공 여부 추출
|
||||
*/
|
||||
private boolean extractSuccess(EAIMessage msg) {
|
||||
return msg.getStatErrorCode() == 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 에러 유형 추출
|
||||
*/
|
||||
private TransactionEvent.ErrorType extractErrorType(EAIMessage msg) {
|
||||
int errorCode = msg.getStatErrorCode();
|
||||
|
||||
switch (errorCode) {
|
||||
case 0:
|
||||
return null; // 정상
|
||||
case 2:
|
||||
return TransactionEvent.ErrorType.TIMEOUT;
|
||||
case 3:
|
||||
return TransactionEvent.ErrorType.SYSTEM_ERROR;
|
||||
case 1:
|
||||
return TransactionEvent.ErrorType.BUSINESS_ERROR;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
# API Gateway 통계 수집 모듈
|
||||
|
||||
분단위로 API 거래 통계를 수집하고 DB에 저장하는 모듈입니다.
|
||||
|
||||
## 아키텍처
|
||||
|
||||
```
|
||||
거래 발생
|
||||
↓
|
||||
TransactionEvent 생성
|
||||
↓
|
||||
ApiStatsCollector.recordEvent() ← 거래마다 호출
|
||||
↓
|
||||
MinuteStatsAggregator (메모리 집계)
|
||||
↓
|
||||
ApiStatsScheduler (매 분 5초 실행)
|
||||
↓
|
||||
DB 저장 (API_STATS_MINUTE 테이블)
|
||||
```
|
||||
|
||||
## 패키지 구조
|
||||
|
||||
```
|
||||
com.eactive.eai.custom.statistics/
|
||||
├── dto/
|
||||
│ ├── TransactionEvent.java - 거래 이벤트 DTO
|
||||
│ └── MinuteStatsKey.java - 집계 키 (시간 + 차원)
|
||||
├── collector/
|
||||
│ ├── MinuteStatsAggregator.java - HdrHistogram 기반 집계
|
||||
│ └── ApiStatsCollector.java - 메인 수집기 (ConcurrentHashMap)
|
||||
├── scheduler/
|
||||
│ └── ApiStatsScheduler.java - 스케줄러 (매 분 실행)
|
||||
├── ApiStatsMinuteRepository.java - JPA Repository
|
||||
└── ApiStatsMinuteLogger.java - DB 저장 Logger
|
||||
```
|
||||
|
||||
## 사용법
|
||||
|
||||
### 1. 거래 처리 시 통계 수집
|
||||
|
||||
거래가 발생할 때마다 `ApiStatsCollector.recordEvent()` 호출:
|
||||
|
||||
```java
|
||||
@Service
|
||||
public class ApiGatewayService {
|
||||
|
||||
@Autowired
|
||||
private ApiStatsCollector statsCollector;
|
||||
|
||||
public void processTransaction(ApiRequest request) {
|
||||
long startTime = System.currentTimeMillis();
|
||||
|
||||
try {
|
||||
// 거래 처리
|
||||
ApiResponse response = executeApi(request);
|
||||
|
||||
// 통계 수집 (성공)
|
||||
recordStats(request, startTime, true, null);
|
||||
|
||||
return response;
|
||||
|
||||
} catch (TimeoutException e) {
|
||||
// 통계 수집 (Timeout)
|
||||
recordStats(request, startTime, false, ErrorType.TIMEOUT);
|
||||
throw e;
|
||||
|
||||
} catch (SystemException e) {
|
||||
// 통계 수집 (System Error)
|
||||
recordStats(request, startTime, false, ErrorType.SYSTEM_ERROR);
|
||||
throw e;
|
||||
|
||||
} catch (BusinessException e) {
|
||||
// 통계 수집 (Business Error)
|
||||
recordStats(request, startTime, false, ErrorType.BUSINESS_ERROR);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
private void recordStats(ApiRequest request, long startTime,
|
||||
boolean success, ErrorType errorType) {
|
||||
long responseTime = System.currentTimeMillis() - startTime;
|
||||
|
||||
TransactionEvent event = TransactionEvent.builder()
|
||||
.apiName(request.getApiName())
|
||||
.gwInstanceId(getInstanceId())
|
||||
.bizDivCode(TransactionEvent.normalizeValue(request.getBizDivCode()))
|
||||
.clientId(TransactionEvent.normalizeValue(request.getClientId()))
|
||||
.inboundAdapter(TransactionEvent.normalizeValue(request.getInboundAdapter()))
|
||||
.outboundAdapter(TransactionEvent.normalizeValue(request.getOutboundAdapter()))
|
||||
.responseTimeMs(responseTime)
|
||||
.success(success)
|
||||
.errorType(errorType)
|
||||
.build();
|
||||
|
||||
statsCollector.recordEvent(event);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 스케줄러 자동 실행
|
||||
|
||||
`ApiStatsScheduler`가 자동으로 매 분 5초에 실행되어 DB에 저장합니다.
|
||||
|
||||
```
|
||||
매 분 5초 (예: 10:00:05, 10:01:05, ...)
|
||||
↓
|
||||
완료된 분의 통계를 flush (예: 10:00:00 분 데이터)
|
||||
↓
|
||||
DB 저장
|
||||
```
|
||||
|
||||
### 3. 스케줄러 활성화
|
||||
|
||||
Spring Boot 애플리케이션에 `@EnableScheduling` 추가:
|
||||
|
||||
```java
|
||||
@SpringBootApplication
|
||||
@EnableScheduling // ← 스케줄러 활성화
|
||||
public class EapimOnlineApplication {
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(EapimOnlineApplication.class, args);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 주요 클래스 설명
|
||||
|
||||
### TransactionEvent
|
||||
|
||||
거래 1건의 정보를 담는 DTO:
|
||||
- API명, Instance ID, 업무구분코드, Client ID
|
||||
- Inbound/Outbound Adapter
|
||||
- 응답시간, 성공 여부, 오류 유형
|
||||
|
||||
### MinuteStatsAggregator
|
||||
|
||||
분단위 집계를 수행:
|
||||
- **HdrHistogram**: P50, P95 백분위 계산
|
||||
- **AtomicLong**: 건수 카운터 (Thread-safe)
|
||||
- **snapshot()**: 집계 결과를 Entity로 변환
|
||||
|
||||
### ApiStatsCollector
|
||||
|
||||
메모리에서 통계 수집:
|
||||
- **ConcurrentHashMap**: 멀티스레드 안전
|
||||
- **computeIfAbsent()**: Lock-free 집계
|
||||
- **flushCompletedMinutes()**: 완료된 분의 데이터 반환
|
||||
|
||||
### ApiStatsScheduler
|
||||
|
||||
매 분마다 DB 저장:
|
||||
- **@Scheduled(cron = "5 * * * * ?")**: 매 분 5초 실행
|
||||
- **@PreDestroy**: 종료 시 남은 데이터 저장
|
||||
|
||||
## 성능 특징
|
||||
|
||||
### 메모리 효율
|
||||
|
||||
- **HdrHistogram**: 수만 건을 2~4KB로 압축
|
||||
- **ConcurrentHashMap**: 차원별로 독립적인 집계
|
||||
- **분 단위 flush**: 메모리 누적 방지
|
||||
|
||||
### Thread-safe
|
||||
|
||||
- **ConcurrentHashMap**: Lock-free read
|
||||
- **AtomicLong**: Lock-free increment
|
||||
- **synchronized**: HdrHistogram 기록 시만 사용
|
||||
|
||||
### 성능 영향 최소화
|
||||
|
||||
- 거래당 오버헤드: < 1ms (메모리 연산만)
|
||||
- DB 저장: 비동기 (스케줄러)
|
||||
- Lock contention: 최소화
|
||||
|
||||
## 설정
|
||||
|
||||
### PropManager를 통한 동적 설정 (권장)
|
||||
|
||||
운영 중에 변경 가능한 설정값을 PropManager로 관리합니다.
|
||||
|
||||
#### 1. DB에 설정값 등록
|
||||
|
||||
PropManager 그룹명: **`ApiStats`**
|
||||
|
||||
| 키 | 설명 | 기본값 | 예시 |
|
||||
|---|---|---|---|
|
||||
| `enabled` | 통계 수집 활성화 여부 | true | true: 활성화, false: 비활성화 |
|
||||
| `histogram.min.ms` | 히스토그램 최소값 (ms) | 1 | 응답시간 최소 기록값 |
|
||||
| `histogram.max.ms` | 히스토그램 최대값 (ms) | 60000 | 60초 초과 시 클리핑 |
|
||||
| `histogram.precision` | 히스토그램 정밀도 (유효숫자) | 3 | 3자리 정밀도 |
|
||||
|
||||
**DB 설정 예시:**
|
||||
```sql
|
||||
-- TSEAIRM24 테이블에 설정값 추가
|
||||
INSERT INTO TSEAIRM24 (GROUP_NAME, PROP_KEY, PROP_VALUE, DESCRIPTION)
|
||||
VALUES ('ApiStats', 'enabled', 'true', '통계 수집 활성화 여부');
|
||||
|
||||
INSERT INTO TSEAIRM24 (GROUP_NAME, PROP_KEY, PROP_VALUE, DESCRIPTION)
|
||||
VALUES ('ApiStats', 'histogram.min.ms', '1', '히스토그램 최소값(ms)');
|
||||
|
||||
INSERT INTO TSEAIRM24 (GROUP_NAME, PROP_KEY, PROP_VALUE, DESCRIPTION)
|
||||
VALUES ('ApiStats', 'histogram.max.ms', '60000', '히스토그램 최대값(ms)');
|
||||
|
||||
INSERT INTO TSEAIRM24 (GROUP_NAME, PROP_KEY, PROP_VALUE, DESCRIPTION)
|
||||
VALUES ('ApiStats', 'histogram.precision', '3', '히스토그램 정밀도');
|
||||
```
|
||||
|
||||
#### 2. 설정 변경 방법
|
||||
|
||||
**통계 수집 OFF (긴급 상황 시)**
|
||||
```sql
|
||||
-- 통계 수집 비활성화
|
||||
UPDATE TSEAIRM24
|
||||
SET PROP_VALUE = 'false'
|
||||
WHERE GROUP_NAME = 'ApiStats' AND PROP_KEY = 'enabled';
|
||||
```
|
||||
|
||||
```java
|
||||
// PropManager 리로드 (즉시 적용)
|
||||
PropManager.getInstance().reload("ApiStats");
|
||||
```
|
||||
|
||||
**통계 수집 ON (다시 활성화)**
|
||||
```sql
|
||||
-- 통계 수집 재활성화
|
||||
UPDATE TSEAIRM24
|
||||
SET PROP_VALUE = 'true'
|
||||
WHERE GROUP_NAME = 'ApiStats' AND PROP_KEY = 'enabled';
|
||||
```
|
||||
|
||||
```java
|
||||
// PropManager 리로드
|
||||
PropManager.getInstance().reload("ApiStats");
|
||||
```
|
||||
|
||||
**히스토그램 범위 변경**
|
||||
```sql
|
||||
-- 최대값을 300초로 변경
|
||||
UPDATE TSEAIRM24
|
||||
SET PROP_VALUE = '300000'
|
||||
WHERE GROUP_NAME = 'ApiStats' AND PROP_KEY = 'histogram.max.ms';
|
||||
```
|
||||
|
||||
```java
|
||||
// PropManager 리로드
|
||||
PropManager.getInstance().reload("ApiStats");
|
||||
```
|
||||
|
||||
**방법 2: 관리 화면을 통한 수정**
|
||||
- EMS 관리 화면에서 PropManager 설정 수정
|
||||
- 저장 시 자동으로 메모리에 반영
|
||||
|
||||
#### 3. 설정값 확인
|
||||
|
||||
애플리케이션 시작 시 로그에서 확인:
|
||||
```
|
||||
ApiStatsScheduler initialized
|
||||
ApiStatsConfig] enabled = true
|
||||
ApiStatsConfig] histogram.min.ms = 1
|
||||
ApiStatsConfig] histogram.max.ms = 60000
|
||||
ApiStatsConfig] histogram.precision = 3
|
||||
```
|
||||
|
||||
**비활성화 시 로그:**
|
||||
```
|
||||
ApiStatsConfig] enabled = false
|
||||
API stats collection is disabled, skipping save
|
||||
```
|
||||
|
||||
### 스케줄러 주기 변경
|
||||
|
||||
`ApiStatsScheduler.java`의 cron 표현식 수정:
|
||||
|
||||
```java
|
||||
@Scheduled(cron = "5 * * * * ?") // 매 분 5초
|
||||
@Scheduled(cron = "0 * * * * ?") // 매 분 0초
|
||||
@Scheduled(cron = "0 */5 * * * ?") // 5분마다
|
||||
```
|
||||
|
||||
**주의:** 스케줄러 주기는 코드 변경 후 재배포 필요
|
||||
|
||||
## 모니터링
|
||||
|
||||
### 활성 집계기 개수 확인
|
||||
|
||||
```java
|
||||
int count = statsCollector.getActiveAggregatorCount();
|
||||
log.info("Active aggregators: {}", count);
|
||||
```
|
||||
|
||||
### 로그 레벨 조정
|
||||
|
||||
```properties
|
||||
# application.properties
|
||||
logging.level.com.eactive.eai.custom.statistics=DEBUG
|
||||
```
|
||||
|
||||
## 주의사항
|
||||
|
||||
1. **NULL 값 처리**: 차원 값이 NULL이면 "NONE"으로 변환 필수
|
||||
2. **응답시간 범위**: PropManager 설정값 범위 초과 시 클리핑됨 (기본: 1ms ~ 60초)
|
||||
3. **스케줄러 활성화**: `@EnableScheduling` 필수
|
||||
4. **DB 저장 실패**: 로그로 기록되며 다음 분에 재시도하지 않음
|
||||
5. **PropManager 설정**: `ApiStats` 그룹이 DB에 없으면 기본값 사용
|
||||
|
||||
## 테스트
|
||||
|
||||
### 단위 테스트 예제
|
||||
|
||||
```java
|
||||
@Test
|
||||
public void testStatsCollection() {
|
||||
ApiStatsCollector collector = new ApiStatsCollector();
|
||||
|
||||
// 거래 이벤트 생성
|
||||
TransactionEvent event = TransactionEvent.builder()
|
||||
.apiName("TEST_API")
|
||||
.gwInstanceId("GW01")
|
||||
.bizDivCode("NONE")
|
||||
.clientId("CLIENT01")
|
||||
.inboundAdapter("HTTP")
|
||||
.outboundAdapter("REST")
|
||||
.responseTimeMs(100)
|
||||
.success(true)
|
||||
.build();
|
||||
|
||||
// 이벤트 기록
|
||||
LocalDateTime now = LocalDateTime.of(2026, 1, 8, 10, 0, 30);
|
||||
collector.recordEvent(event, now);
|
||||
|
||||
// Flush (10시 1분 이전)
|
||||
LocalDateTime flushTime = LocalDateTime.of(2026, 1, 8, 10, 1, 0);
|
||||
List<ApiStatsMinute> result = collector.flushBefore(flushTime);
|
||||
|
||||
// 검증
|
||||
assertEquals(1, result.size());
|
||||
ApiStatsMinute stats = result.get(0);
|
||||
assertEquals("TEST_API", stats.getApiName());
|
||||
assertEquals(1L, stats.getTotalCnt());
|
||||
assertEquals(1L, stats.getSuccessCnt());
|
||||
}
|
||||
```
|
||||
|
||||
## 문제 해결
|
||||
|
||||
### 통계가 저장되지 않음
|
||||
|
||||
1. `@EnableScheduling` 확인
|
||||
2. 로그에서 스케줄러 실행 확인
|
||||
3. DB 연결 및 권한 확인
|
||||
|
||||
### 메모리 증가
|
||||
|
||||
1. 활성 집계기 개수 확인
|
||||
2. 스케줄러 정상 동작 확인
|
||||
3. 차원 카디널리티 확인 (너무 많은 고유 키)
|
||||
|
||||
### P50, P95 값이 이상함
|
||||
|
||||
1. 응답시간 단위 확인 (ms)
|
||||
2. PropManager 설정값 확인 (`ApiStats` 그룹)
|
||||
3. 히스토그램 범위 확인 (기본: 1~60000ms)
|
||||
4. 데이터 건수 확인 (최소 1건 이상)
|
||||
|
||||
### PropManager 설정이 적용되지 않음
|
||||
|
||||
1. DB에 `ApiStats` 그룹 설정 확인
|
||||
2. PropManager 초기화 확인
|
||||
3. 로그에서 설정값 로딩 확인
|
||||
4. 설정 변경 후 `PropManager.reload("ApiStats")` 실행
|
||||
|
||||
### 통계 수집을 비활성화하고 싶음
|
||||
|
||||
**운영 중 성능 이슈 발생 시:**
|
||||
```sql
|
||||
-- DB에서 비활성화
|
||||
UPDATE TSEAIRM24 SET PROP_VALUE = 'false'
|
||||
WHERE GROUP_NAME = 'ApiStats' AND PROP_KEY = 'enabled';
|
||||
```
|
||||
|
||||
```java
|
||||
// 즉시 적용
|
||||
PropManager.getInstance().reload("ApiStats");
|
||||
```
|
||||
|
||||
이후 모든 `recordEvent()` 호출과 스케줄러 실행이 즉시 중단됩니다.
|
||||
메모리에 쌓인 데이터는 유지되며, 다시 활성화 시 계속 수집됩니다.
|
||||
@@ -0,0 +1,152 @@
|
||||
package com.eactive.eai.custom.statistics.collector;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import com.eactive.eai.custom.statistics.config.ApiStatsConfig;
|
||||
import com.eactive.eai.custom.statistics.dto.MinuteStatsKey;
|
||||
import com.eactive.eai.custom.statistics.dto.TransactionEvent;
|
||||
import com.eactive.eai.data.entity.custom.statistics.ApiStatsMinute;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
/**
|
||||
* API 통계 수집기
|
||||
* - 거래 이벤트를 분단위로 메모리에 집계
|
||||
* - Thread-safe한 ConcurrentHashMap 사용
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
public class ApiStatsCollector {
|
||||
|
||||
/**
|
||||
* 분단위 집계 맵
|
||||
* Key: MinuteStatsKey (시간 + 차원들)
|
||||
* Value: MinuteStatsAggregator (집계 데이터)
|
||||
*/
|
||||
private final ConcurrentHashMap<MinuteStatsKey, MinuteStatsAggregator> aggregators = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 거래 이벤트 기록
|
||||
*
|
||||
* @param event 거래 이벤트
|
||||
*/
|
||||
public void recordEvent(TransactionEvent event) {
|
||||
recordEvent(event, LocalDateTime.now());
|
||||
}
|
||||
|
||||
/**
|
||||
* 거래 이벤트 기록 (시간 지정 - 테스트용)
|
||||
*
|
||||
* @param event 거래 이벤트
|
||||
* @param eventTime 이벤트 발생 시간
|
||||
*/
|
||||
public void recordEvent(TransactionEvent event, LocalDateTime eventTime) {
|
||||
// 통계 수집 비활성화 체크
|
||||
if (!ApiStatsConfig.isEnabled()) {
|
||||
if (log.isDebugEnabled()) {
|
||||
log.debug("API stats collection is disabled");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 집계 키 생성 (분 단위로 절사)
|
||||
MinuteStatsKey key = MinuteStatsKey.from(event, eventTime);
|
||||
|
||||
// 집계기 가져오기 또는 생성
|
||||
MinuteStatsAggregator aggregator = aggregators.computeIfAbsent(
|
||||
key,
|
||||
MinuteStatsAggregator::new
|
||||
);
|
||||
|
||||
// 이벤트 기록
|
||||
aggregator.recordEvent(event);
|
||||
|
||||
if (log.isDebugEnabled()) {
|
||||
log.debug("Recorded event: {}, total count: {}",
|
||||
key, aggregator.getTotalCount());
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to record event: {}", event, e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 지정된 시간 이전의 집계 데이터를 flush하여 반환
|
||||
* - 반환된 데이터는 메모리에서 제거됨
|
||||
*
|
||||
* @param beforeTime 이 시간 이전의 데이터를 flush (exclusive)
|
||||
* @return 집계된 통계 리스트
|
||||
*/
|
||||
public List<ApiStatsMinute> flushBefore(LocalDateTime beforeTime) {
|
||||
LocalDateTime cutoffTime = beforeTime.truncatedTo(ChronoUnit.MINUTES);
|
||||
List<ApiStatsMinute> result = new ArrayList<>();
|
||||
|
||||
// 대상 키 수집 및 스냅샷 생성
|
||||
List<MinuteStatsKey> keysToRemove = new ArrayList<>();
|
||||
|
||||
aggregators.forEach((key, aggregator) -> {
|
||||
if (key.getStatTime().isBefore(cutoffTime)) {
|
||||
// 스냅샷 생성 (건수가 0보다 큰 경우만)
|
||||
if (aggregator.getTotalCount() > 0) {
|
||||
ApiStatsMinute snapshot = aggregator.snapshot();
|
||||
result.add(snapshot);
|
||||
}
|
||||
keysToRemove.add(key);
|
||||
}
|
||||
});
|
||||
|
||||
// 메모리에서 제거
|
||||
keysToRemove.forEach(aggregators::remove);
|
||||
|
||||
log.info("Flushed {} stats records before {}, remaining: {}",
|
||||
result.size(), cutoffTime, aggregators.size());
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 분의 집계 데이터를 flush
|
||||
* - 현재 분 이전의 모든 데이터를 반환하고 제거
|
||||
*/
|
||||
public List<ApiStatsMinute> flushCompletedMinutes() {
|
||||
// 현재 분은 아직 진행 중이므로 제외
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
LocalDateTime currentMinute = now.truncatedTo(ChronoUnit.MINUTES);
|
||||
|
||||
return flushBefore(currentMinute);
|
||||
}
|
||||
|
||||
/**
|
||||
* 모든 집계 데이터를 강제로 flush (종료 시 사용)
|
||||
*/
|
||||
public List<ApiStatsMinute> flushAll() {
|
||||
List<ApiStatsMinute> result = new ArrayList<>();
|
||||
|
||||
aggregators.forEach((key, aggregator) -> {
|
||||
if (aggregator.getTotalCount() > 0) {
|
||||
result.add(aggregator.snapshot());
|
||||
}
|
||||
});
|
||||
|
||||
aggregators.clear();
|
||||
|
||||
log.info("Flushed all {} stats records", result.size());
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 집계 중인 키 개수 반환 (모니터링용)
|
||||
*/
|
||||
public int getActiveAggregatorCount() {
|
||||
return aggregators.size();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
package com.eactive.eai.custom.statistics.collector;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
import org.HdrHistogram.Histogram;
|
||||
|
||||
import com.eactive.eai.custom.statistics.config.ApiStatsConfig;
|
||||
import com.eactive.eai.custom.statistics.dto.MinuteStatsKey;
|
||||
import com.eactive.eai.custom.statistics.dto.TransactionEvent;
|
||||
import com.eactive.eai.data.entity.custom.statistics.ApiStatsMinute;
|
||||
|
||||
import lombok.Getter;
|
||||
|
||||
/**
|
||||
* 분단위 통계 집계기
|
||||
* - HdrHistogram을 사용하여 응답시간 백분위 계산
|
||||
* - Thread-safe한 집계 처리
|
||||
*/
|
||||
public class MinuteStatsAggregator {
|
||||
|
||||
@Getter
|
||||
private final MinuteStatsKey key;
|
||||
|
||||
// 건수 메트릭
|
||||
private final AtomicLong totalCnt = new AtomicLong(0);
|
||||
private final AtomicLong successCnt = new AtomicLong(0);
|
||||
private final AtomicLong timeoutCnt = new AtomicLong(0);
|
||||
private final AtomicLong systemErrCnt = new AtomicLong(0);
|
||||
private final AtomicLong bizErrCnt = new AtomicLong(0);
|
||||
|
||||
// 로그 시퀀스 900 구간 에러 카운트
|
||||
private final AtomicLong seq900TimeoutCnt = new AtomicLong(0);
|
||||
private final AtomicLong seq900SystemErrCnt = new AtomicLong(0);
|
||||
private final AtomicLong seq900BizErrCnt = new AtomicLong(0);
|
||||
|
||||
// 응답시간 히스토그램 (1ms ~ 60초, 3자리 정밀도)
|
||||
private final Histogram histogram;
|
||||
|
||||
// 응답시간 통계용
|
||||
private final AtomicLong totalRespTime = new AtomicLong(0); // 평균 계산용
|
||||
private volatile long minRespTime = Long.MAX_VALUE;
|
||||
private volatile long maxRespTime = Long.MIN_VALUE;
|
||||
|
||||
public MinuteStatsAggregator(MinuteStatsKey key) {
|
||||
this.key = key;
|
||||
// HdrHistogram: PropManager에서 설정값 읽기
|
||||
long minMs = ApiStatsConfig.getHistogramMinMs();
|
||||
long maxMs = ApiStatsConfig.getHistogramMaxMs();
|
||||
int precision = ApiStatsConfig.getHistogramPrecision();
|
||||
this.histogram = new Histogram(minMs, maxMs, precision);
|
||||
}
|
||||
|
||||
/**
|
||||
* 거래 이벤트 기록
|
||||
*/
|
||||
public synchronized void recordEvent(TransactionEvent event) {
|
||||
int logSeq = event.getLogSequence();
|
||||
|
||||
// 로그 시퀀스 400: 일반 통계 집계
|
||||
if (logSeq == 400) {
|
||||
totalCnt.incrementAndGet();
|
||||
|
||||
if (event.isSuccess()) {
|
||||
successCnt.incrementAndGet();
|
||||
} else {
|
||||
switch (event.getErrorType()) {
|
||||
case TIMEOUT:
|
||||
timeoutCnt.incrementAndGet();
|
||||
break;
|
||||
case SYSTEM_ERROR:
|
||||
systemErrCnt.incrementAndGet();
|
||||
break;
|
||||
case BUSINESS_ERROR:
|
||||
bizErrCnt.incrementAndGet();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 응답시간 기록 (400 구간만)
|
||||
long respTime = event.getResponseTimeMs();
|
||||
totalRespTime.addAndGet(respTime);
|
||||
|
||||
// 히스토그램에 기록
|
||||
long maxMs = ApiStatsConfig.getHistogramMaxMs();
|
||||
try {
|
||||
long clippedRespTime = Math.min(Math.max(1, respTime), maxMs);
|
||||
histogram.recordValue(clippedRespTime);
|
||||
} catch (ArrayIndexOutOfBoundsException e) {
|
||||
histogram.recordValue(maxMs);
|
||||
}
|
||||
|
||||
// Min/Max 업데이트
|
||||
updateMinMax(respTime);
|
||||
}
|
||||
// 로그 시퀀스 900: 900 전용 에러 카운트만 집계
|
||||
else if (logSeq == 900 && !event.isSuccess()) {
|
||||
switch (event.getErrorType()) {
|
||||
case TIMEOUT:
|
||||
seq900TimeoutCnt.incrementAndGet();
|
||||
break;
|
||||
case SYSTEM_ERROR:
|
||||
seq900SystemErrCnt.incrementAndGet();
|
||||
break;
|
||||
case BUSINESS_ERROR:
|
||||
seq900BizErrCnt.incrementAndGet();
|
||||
break;
|
||||
}
|
||||
}
|
||||
// 그 외 로그 시퀀스는 무시
|
||||
}
|
||||
|
||||
/**
|
||||
* Min/Max 응답시간 업데이트 (thread-safe)
|
||||
*/
|
||||
private synchronized void updateMinMax(long respTime) {
|
||||
if (respTime < minRespTime) {
|
||||
minRespTime = respTime;
|
||||
}
|
||||
if (respTime > maxRespTime) {
|
||||
maxRespTime = respTime;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 집계 결과를 Entity로 변환
|
||||
*/
|
||||
public ApiStatsMinute snapshot() {
|
||||
ApiStatsMinute stats = new ApiStatsMinute();
|
||||
|
||||
// 키 정보
|
||||
stats.setStatTime(key.getStatTime());
|
||||
stats.setApiName(key.getApiName());
|
||||
stats.setGwInstanceId(key.getGwInstanceId());
|
||||
stats.setBizDivCode(key.getBizDivCode());
|
||||
stats.setClientId(key.getClientId());
|
||||
stats.setInboundAdapter(key.getInboundAdapter());
|
||||
stats.setOutboundAdapter(key.getOutboundAdapter());
|
||||
|
||||
// 건수 메트릭
|
||||
stats.setTotalCnt(totalCnt.get());
|
||||
stats.setSuccessCnt(successCnt.get());
|
||||
stats.setTimeoutCnt(timeoutCnt.get());
|
||||
stats.setSystemErrCnt(systemErrCnt.get());
|
||||
stats.setBizErrCnt(bizErrCnt.get());
|
||||
|
||||
// 로그 시퀀스 900 구간 에러 카운트
|
||||
stats.setSeq900TimeoutCnt(seq900TimeoutCnt.get());
|
||||
stats.setSeq900SystemErrCnt(seq900SystemErrCnt.get());
|
||||
stats.setSeq900BizErrCnt(seq900BizErrCnt.get());
|
||||
|
||||
// 응답시간 메트릭
|
||||
long total = totalCnt.get();
|
||||
if (total > 0) {
|
||||
// 평균
|
||||
double avg = (double) totalRespTime.get() / total;
|
||||
stats.setAvgRespTime(BigDecimal.valueOf(avg).setScale(3, RoundingMode.HALF_UP));
|
||||
|
||||
// Min/Max
|
||||
stats.setMinRespTime(BigDecimal.valueOf(minRespTime).setScale(3, RoundingMode.HALF_UP));
|
||||
stats.setMaxRespTime(BigDecimal.valueOf(maxRespTime).setScale(3, RoundingMode.HALF_UP));
|
||||
|
||||
// 백분위 (HdrHistogram 사용)
|
||||
long p50 = histogram.getValueAtPercentile(50.0);
|
||||
long p95 = histogram.getValueAtPercentile(95.0);
|
||||
stats.setP50RespTime(BigDecimal.valueOf(p50).setScale(3, RoundingMode.HALF_UP));
|
||||
stats.setP95RespTime(BigDecimal.valueOf(p95).setScale(3, RoundingMode.HALF_UP));
|
||||
} else {
|
||||
stats.setAvgRespTime(BigDecimal.ZERO);
|
||||
stats.setMinRespTime(BigDecimal.ZERO);
|
||||
stats.setMaxRespTime(BigDecimal.ZERO);
|
||||
stats.setP50RespTime(BigDecimal.ZERO);
|
||||
stats.setP95RespTime(BigDecimal.ZERO);
|
||||
}
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* 집계된 총 건수 반환 (빈 집계기 체크용)
|
||||
*/
|
||||
public long getTotalCount() {
|
||||
return totalCnt.get();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package com.eactive.eai.custom.statistics.config;
|
||||
|
||||
import java.util.Properties;
|
||||
|
||||
import com.eactive.eai.common.property.PropManager;
|
||||
import com.eactive.eai.common.util.Logger;
|
||||
|
||||
import lombok.experimental.UtilityClass;
|
||||
|
||||
/**
|
||||
* API 통계 설정 관리
|
||||
* - PropManager를 통해 운영 중 동적 변경 가능한 설정값 관리
|
||||
*/
|
||||
@UtilityClass
|
||||
public class ApiStatsConfig {
|
||||
|
||||
private static final Logger logger = Logger.getLogger(Logger.LOGGER_DEFAULT);
|
||||
|
||||
/** PropManager 그룹명 */
|
||||
private static final String GROUP_NAME = "ApiStats";
|
||||
|
||||
/** 기본값 상수 */
|
||||
private static final boolean DEFAULT_ENABLED = true;
|
||||
private static final long DEFAULT_HISTOGRAM_MIN_MS = 1L;
|
||||
private static final long DEFAULT_HISTOGRAM_MAX_MS = 60000L;
|
||||
private static final int DEFAULT_HISTOGRAM_PRECISION = 3;
|
||||
|
||||
/**
|
||||
* 통계 수집 활성화 여부
|
||||
* - true: 통계 수집 활성화 (기본값)
|
||||
* - false: 통계 수집 비활성화
|
||||
*/
|
||||
public static boolean isEnabled() {
|
||||
try {
|
||||
Properties prop = PropManager.getInstance().getProperties(GROUP_NAME);
|
||||
String value = prop.getProperty("enabled", String.valueOf(DEFAULT_ENABLED));
|
||||
return Boolean.parseBoolean(value);
|
||||
} catch (Exception e) {
|
||||
logger.debug("Failed to get enabled from PropManager, using default: " + DEFAULT_ENABLED);
|
||||
return DEFAULT_ENABLED;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 히스토그램 최소값 (ms)
|
||||
*/
|
||||
public static long getHistogramMinMs() {
|
||||
try {
|
||||
Properties prop = PropManager.getInstance().getProperties(GROUP_NAME);
|
||||
String value = prop.getProperty("histogram.min.ms", String.valueOf(DEFAULT_HISTOGRAM_MIN_MS));
|
||||
return Long.parseLong(value);
|
||||
} catch (Exception e) {
|
||||
logger.debug("Failed to get histogram.min.ms from PropManager, using default: " + DEFAULT_HISTOGRAM_MIN_MS);
|
||||
return DEFAULT_HISTOGRAM_MIN_MS;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 히스토그램 최대값 (ms)
|
||||
*/
|
||||
public static long getHistogramMaxMs() {
|
||||
try {
|
||||
Properties prop = PropManager.getInstance().getProperties(GROUP_NAME);
|
||||
String value = prop.getProperty("histogram.max.ms", String.valueOf(DEFAULT_HISTOGRAM_MAX_MS));
|
||||
return Long.parseLong(value);
|
||||
} catch (Exception e) {
|
||||
logger.debug("Failed to get histogram.max.ms from PropManager, using default: " + DEFAULT_HISTOGRAM_MAX_MS);
|
||||
return DEFAULT_HISTOGRAM_MAX_MS;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 히스토그램 정밀도 (유효숫자)
|
||||
*/
|
||||
public static int getHistogramPrecision() {
|
||||
try {
|
||||
Properties prop = PropManager.getInstance().getProperties(GROUP_NAME);
|
||||
String value = prop.getProperty("histogram.precision", String.valueOf(DEFAULT_HISTOGRAM_PRECISION));
|
||||
return Integer.parseInt(value);
|
||||
} catch (Exception e) {
|
||||
logger.debug("Failed to get histogram.precision from PropManager, using default: " + DEFAULT_HISTOGRAM_PRECISION);
|
||||
return DEFAULT_HISTOGRAM_PRECISION;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 설정값 로깅 (초기화 시)
|
||||
*/
|
||||
public static void logCurrentConfig() {
|
||||
logger.info("ApiStatsConfig] enabled = " + isEnabled());
|
||||
logger.info("ApiStatsConfig] histogram.min.ms = " + getHistogramMinMs());
|
||||
logger.info("ApiStatsConfig] histogram.max.ms = " + getHistogramMaxMs());
|
||||
logger.info("ApiStatsConfig] histogram.precision = " + getHistogramPrecision());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package com.eactive.eai.custom.statistics.dto;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.Objects;
|
||||
|
||||
import lombok.Getter;
|
||||
|
||||
/**
|
||||
* 분단위 통계 집계 키
|
||||
* - 동일 분의 동일 차원별로 집계하기 위한 키
|
||||
*/
|
||||
@Getter
|
||||
public class MinuteStatsKey {
|
||||
|
||||
/** 통계 시간 (분 단위, 초는 00) */
|
||||
private final LocalDateTime statTime;
|
||||
|
||||
/** API명 */
|
||||
private final String apiName;
|
||||
|
||||
/** API Gateway 인스턴스 ID */
|
||||
private final String gwInstanceId;
|
||||
|
||||
/** 업무구분코드 */
|
||||
private final String bizDivCode;
|
||||
|
||||
/** 클라이언트 ID */
|
||||
private final String clientId;
|
||||
|
||||
/** Inbound Adapter */
|
||||
private final String inboundAdapter;
|
||||
|
||||
/** Outbound Adapter */
|
||||
private final String outboundAdapter;
|
||||
|
||||
public MinuteStatsKey(LocalDateTime statTime, String apiName, String gwInstanceId,
|
||||
String bizDivCode, String clientId, String inboundAdapter, String outboundAdapter) {
|
||||
// 초와 나노초를 0으로 절사하여 분 단위로 정규화
|
||||
this.statTime = statTime.truncatedTo(ChronoUnit.MINUTES);
|
||||
this.apiName = apiName;
|
||||
this.gwInstanceId = gwInstanceId;
|
||||
this.bizDivCode = bizDivCode;
|
||||
this.clientId = clientId;
|
||||
this.inboundAdapter = inboundAdapter;
|
||||
this.outboundAdapter = outboundAdapter;
|
||||
}
|
||||
|
||||
/**
|
||||
* TransactionEvent로부터 키 생성
|
||||
*/
|
||||
public static MinuteStatsKey from(TransactionEvent event, LocalDateTime eventTime) {
|
||||
return new MinuteStatsKey(
|
||||
eventTime,
|
||||
event.getApiName(),
|
||||
event.getGwInstanceId(),
|
||||
event.getBizDivCode(),
|
||||
event.getClientId(),
|
||||
event.getInboundAdapter(),
|
||||
event.getOutboundAdapter()
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o) return true;
|
||||
if (o == null || getClass() != o.getClass()) return false;
|
||||
MinuteStatsKey that = (MinuteStatsKey) o;
|
||||
return Objects.equals(statTime, that.statTime) &&
|
||||
Objects.equals(apiName, that.apiName) &&
|
||||
Objects.equals(gwInstanceId, that.gwInstanceId) &&
|
||||
Objects.equals(bizDivCode, that.bizDivCode) &&
|
||||
Objects.equals(clientId, that.clientId) &&
|
||||
Objects.equals(inboundAdapter, that.inboundAdapter) &&
|
||||
Objects.equals(outboundAdapter, that.outboundAdapter);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(statTime, apiName, gwInstanceId, bizDivCode,
|
||||
clientId, inboundAdapter, outboundAdapter);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return String.format("%s|%s|%s|%s|%s|%s|%s",
|
||||
statTime, apiName, gwInstanceId, bizDivCode,
|
||||
clientId, inboundAdapter, outboundAdapter);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package com.eactive.eai.custom.statistics.dto;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
|
||||
/**
|
||||
* 거래 1건의 이벤트 정보
|
||||
*/
|
||||
@Getter
|
||||
@Builder
|
||||
public class TransactionEvent {
|
||||
|
||||
/** API명 */
|
||||
private final String apiName;
|
||||
|
||||
/** API Gateway 인스턴스 ID */
|
||||
private final String gwInstanceId;
|
||||
|
||||
/** 업무구분코드 (없으면 "NONE") */
|
||||
private final String bizDivCode;
|
||||
|
||||
/** 클라이언트 ID (없으면 "NONE") */
|
||||
private final String clientId;
|
||||
|
||||
/** Inbound Adapter (없으면 "NONE") */
|
||||
private final String inboundAdapter;
|
||||
|
||||
/** Outbound Adapter (없으면 "NONE") */
|
||||
private final String outboundAdapter;
|
||||
|
||||
/** 응답시간 (ms) */
|
||||
private final long responseTimeMs;
|
||||
|
||||
/** 성공 여부 */
|
||||
private final boolean success;
|
||||
|
||||
/** 오류 유형 (성공시 null) */
|
||||
private final ErrorType errorType;
|
||||
|
||||
/** 로그 시퀀스 번호 (100, 200, 300, 400, 900) */
|
||||
private final int logSequence;
|
||||
|
||||
/**
|
||||
* 오류 유형
|
||||
*/
|
||||
public enum ErrorType {
|
||||
TIMEOUT, // Timeout 오류
|
||||
SYSTEM_ERROR, // 시스템 오류
|
||||
BUSINESS_ERROR // 업무 오류
|
||||
}
|
||||
|
||||
/**
|
||||
* 기본값을 "NONE"으로 정규화
|
||||
*/
|
||||
public static String normalizeValue(String value) {
|
||||
return (value == null || value.trim().isEmpty()) ? "NONE" : value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* API Gateway 통계 수집 모듈
|
||||
*
|
||||
* <p>분단위로 API 거래 통계를 수집하고 DB에 저장합니다.
|
||||
* HdrHistogram을 사용하여 메모리 효율적으로 응답시간 백분위(P50, P95)를 계산합니다.
|
||||
*
|
||||
* <h3>주요 컴포넌트</h3>
|
||||
* <ul>
|
||||
* <li>ApiStatsMonitorImpl - StatisticsMonitor 구현체 (진입점)</li>
|
||||
* <li>ApiStatsCollector - 거래 이벤트 수집 및 메모리 집계</li>
|
||||
* <li>ApiStatsScheduler - 매 분 DB 저장 스케줄러</li>
|
||||
* </ul>
|
||||
*/
|
||||
package com.eactive.eai.custom.statistics;
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.eactive.eai.custom.statistics.persistence;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Propagation;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import com.eactive.eai.data.entity.custom.statistics.ApiStatsMinute;
|
||||
import com.eactive.eai.data.entity.custom.statistics.ApiStatsMinuteId;
|
||||
import com.eactive.eai.data.jpa.AbstractDataLogger;
|
||||
|
||||
@Service
|
||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||
public class ApiStatsMinuteLogger
|
||||
extends AbstractDataLogger<ApiStatsMinute, ApiStatsMinuteId, ApiStatsMinuteRepository> {
|
||||
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package com.eactive.eai.custom.statistics.persistence;
|
||||
|
||||
import com.eactive.eai.data.entity.custom.statistics.ApiStatsMinute;
|
||||
import com.eactive.eai.data.entity.custom.statistics.ApiStatsMinuteId;
|
||||
import com.eactive.eai.data.jpa.BaseRepository;
|
||||
|
||||
public interface ApiStatsMinuteRepository extends BaseRepository<ApiStatsMinute, ApiStatsMinuteId> {
|
||||
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package com.eactive.eai.custom.statistics.scheduler;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import javax.annotation.PostConstruct;
|
||||
import javax.annotation.PreDestroy;
|
||||
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import com.eactive.eai.custom.statistics.collector.ApiStatsCollector;
|
||||
import com.eactive.eai.custom.statistics.config.ApiStatsConfig;
|
||||
import com.eactive.eai.custom.statistics.persistence.ApiStatsMinuteLogger;
|
||||
import com.eactive.eai.data.entity.custom.statistics.ApiStatsMinute;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
/**
|
||||
* API 통계 스케줄러
|
||||
* - 매 분마다 실행되어 집계된 통계를 DB에 저장
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class ApiStatsScheduler {
|
||||
|
||||
private final ApiStatsCollector collector;
|
||||
private final ApiStatsMinuteLogger logger;
|
||||
|
||||
/**
|
||||
* 초기화 시 설정값 로깅
|
||||
*/
|
||||
@PostConstruct
|
||||
public void init() {
|
||||
log.info("ApiStatsScheduler initialized");
|
||||
ApiStatsConfig.logCurrentConfig();
|
||||
}
|
||||
|
||||
/**
|
||||
* 매 분마다 실행 (매 분 5초에 실행)
|
||||
* - 이전 분의 완료된 통계를 DB에 저장
|
||||
*/
|
||||
@Scheduled(cron = "5 * * * * ?")
|
||||
public void saveMinuteStats() {
|
||||
// 통계 수집 비활성화 체크
|
||||
if (!ApiStatsConfig.isEnabled()) {
|
||||
log.debug("API stats collection is disabled, skipping save");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
log.debug("Starting minute stats flush...");
|
||||
|
||||
// 완료된 분의 통계 데이터 가져오기
|
||||
List<ApiStatsMinute> statsList = collector.flushCompletedMinutes();
|
||||
|
||||
if (statsList.isEmpty()) {
|
||||
log.debug("No stats to save");
|
||||
return;
|
||||
}
|
||||
|
||||
// DB에 저장
|
||||
int savedCount = 0;
|
||||
int errorCount = 0;
|
||||
|
||||
for (ApiStatsMinute stats : statsList) {
|
||||
try {
|
||||
logger.log(stats);
|
||||
savedCount++;
|
||||
|
||||
if (log.isDebugEnabled()) {
|
||||
log.debug("Saved stats: {} - total={}, success={}, p95={}ms",
|
||||
stats.getApiName(),
|
||||
stats.getTotalCnt(),
|
||||
stats.getSuccessCnt(),
|
||||
stats.getP95RespTime());
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
errorCount++;
|
||||
log.error("Failed to save stats: {}", stats, e);
|
||||
}
|
||||
}
|
||||
|
||||
log.info("Minute stats saved: success={}, error={}, active_aggregators={}",
|
||||
savedCount, errorCount, collector.getActiveAggregatorCount());
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to flush minute stats", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 애플리케이션 종료 시 남은 통계 저장
|
||||
*/
|
||||
@PreDestroy
|
||||
public void shutdown() {
|
||||
try {
|
||||
log.info("Shutting down ApiStatsScheduler, flushing remaining stats...");
|
||||
|
||||
List<ApiStatsMinute> statsList = collector.flushAll();
|
||||
|
||||
if (!statsList.isEmpty()) {
|
||||
for (ApiStatsMinute stats : statsList) {
|
||||
try {
|
||||
logger.log(stats);
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to save stats during shutdown: {}", stats, e);
|
||||
}
|
||||
}
|
||||
log.info("Saved {} remaining stats during shutdown", statsList.size());
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("Error during shutdown", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package com.eactive.eai.data.entity.custom.statistics;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import javax.persistence.Column;
|
||||
import javax.persistence.Entity;
|
||||
import javax.persistence.Id;
|
||||
import javax.persistence.IdClass;
|
||||
import javax.persistence.Table;
|
||||
|
||||
import org.hibernate.annotations.Comment;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
/**
|
||||
* API Gateway 분단위 처리 통계
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
@Entity
|
||||
@Table(name = "API_STATS_MINUTE")
|
||||
@org.hibernate.annotations.Table(appliesTo = "API_STATS_MINUTE", comment = "API Gateway 분단위 처리 통계")
|
||||
@IdClass(ApiStatsMinuteId.class)
|
||||
public class ApiStatsMinute implements Serializable {
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@Id
|
||||
@Column(name = "STAT_TIME", nullable = false)
|
||||
@Comment("통계시간(분단위, 초는 00초로 절사)")
|
||||
private LocalDateTime statTime;
|
||||
|
||||
@Id
|
||||
@Column(name = "API_NAME", nullable = false, length = 100)
|
||||
@Comment("API 명칭")
|
||||
private String apiName;
|
||||
|
||||
@Id
|
||||
@Column(name = "GW_INSTANCE_ID", nullable = false, length = 50)
|
||||
@Comment("API Gateway 인스턴스 ID")
|
||||
private String gwInstanceId;
|
||||
|
||||
@Id
|
||||
@Column(name = "BIZ_DIV_CODE", nullable = false, length = 50, columnDefinition = "VARCHAR2(50) DEFAULT 'NONE'")
|
||||
@Comment("업무구분코드 (없으면 NONE)")
|
||||
private String bizDivCode = "NONE";
|
||||
|
||||
@Id
|
||||
@Column(name = "CLIENT_ID", nullable = false, length = 100, columnDefinition = "VARCHAR2(100) DEFAULT 'NONE'")
|
||||
@Comment("클라이언트 ID (없으면 NONE)")
|
||||
private String clientId = "NONE";
|
||||
|
||||
@Id
|
||||
@Column(name = "INBOUND_ADAPTER", nullable = false, length = 100, columnDefinition = "VARCHAR2(100) DEFAULT 'NONE'")
|
||||
@Comment("Inbound Adapter (없으면 NONE)")
|
||||
private String inboundAdapter = "NONE";
|
||||
|
||||
@Id
|
||||
@Column(name = "OUTBOUND_ADAPTER", nullable = false, length = 100, columnDefinition = "VARCHAR2(100) DEFAULT 'NONE'")
|
||||
@Comment("Outbound Adapter (없으면 NONE)")
|
||||
private String outboundAdapter = "NONE";
|
||||
|
||||
// 건수 메트릭
|
||||
@Column(name = "TOTAL_CNT", nullable = false, precision = 12, columnDefinition = "NUMBER(12) DEFAULT 0")
|
||||
@Comment("총 처리 건수")
|
||||
private Long totalCnt = 0L;
|
||||
|
||||
@Column(name = "SUCCESS_CNT", nullable = false, precision = 12, columnDefinition = "NUMBER(12) DEFAULT 0")
|
||||
@Comment("성공 건수")
|
||||
private Long successCnt = 0L;
|
||||
|
||||
@Column(name = "TIMEOUT_CNT", nullable = false, precision = 12, columnDefinition = "NUMBER(12) DEFAULT 0")
|
||||
@Comment("Timeout 오류 건수")
|
||||
private Long timeoutCnt = 0L;
|
||||
|
||||
@Column(name = "SYSTEM_ERR_CNT", nullable = false, precision = 12, columnDefinition = "NUMBER(12) DEFAULT 0")
|
||||
@Comment("시스템 오류 건수")
|
||||
private Long systemErrCnt = 0L;
|
||||
|
||||
@Column(name = "BIZ_ERR_CNT", nullable = false, precision = 12, columnDefinition = "NUMBER(12) DEFAULT 0")
|
||||
@Comment("업무 오류 건수")
|
||||
private Long bizErrCnt = 0L;
|
||||
|
||||
@Column(name = "SEQ900_TIMEOUT_CNT", nullable = false, precision = 12, columnDefinition = "NUMBER(12) DEFAULT 0")
|
||||
@Comment("로그시퀀스900 Timeout 오류 건수")
|
||||
private Long seq900TimeoutCnt = 0L;
|
||||
|
||||
@Column(name = "SEQ900_SYSTEM_ERR_CNT", nullable = false, precision = 12, columnDefinition = "NUMBER(12) DEFAULT 0")
|
||||
@Comment("로그시퀀스900 시스템 오류 건수")
|
||||
private Long seq900SystemErrCnt = 0L;
|
||||
|
||||
@Column(name = "SEQ900_BIZ_ERR_CNT", nullable = false, precision = 12, columnDefinition = "NUMBER(12) DEFAULT 0")
|
||||
@Comment("로그시퀀스900 업무 오류 건수")
|
||||
private Long seq900BizErrCnt = 0L;
|
||||
|
||||
// 응답시간 메트릭 (ms, 소수점 3자리)
|
||||
@Column(name = "AVG_RESP_TIME", precision = 10, scale = 3, columnDefinition = "NUMBER(10,3) DEFAULT 0")
|
||||
@Comment("평균 응답시간(ms)")
|
||||
private BigDecimal avgRespTime = BigDecimal.ZERO;
|
||||
|
||||
@Column(name = "MIN_RESP_TIME", precision = 10, scale = 3, columnDefinition = "NUMBER(10,3) DEFAULT 0")
|
||||
@Comment("최소 응답시간(ms)")
|
||||
private BigDecimal minRespTime = BigDecimal.ZERO;
|
||||
|
||||
@Column(name = "MAX_RESP_TIME", precision = 10, scale = 3, columnDefinition = "NUMBER(10,3) DEFAULT 0")
|
||||
@Comment("최대 응답시간(ms)")
|
||||
private BigDecimal maxRespTime = BigDecimal.ZERO;
|
||||
|
||||
@Column(name = "P50_RESP_TIME", precision = 10, scale = 3, columnDefinition = "NUMBER(10,3) DEFAULT 0")
|
||||
@Comment("50 백분위 응답시간(ms)")
|
||||
private BigDecimal p50RespTime = BigDecimal.ZERO;
|
||||
|
||||
@Column(name = "P95_RESP_TIME", precision = 10, scale = 3, columnDefinition = "NUMBER(10,3) DEFAULT 0")
|
||||
@Comment("95 백분위 응답시간(ms)")
|
||||
private BigDecimal p95RespTime = BigDecimal.ZERO;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.eactive.eai.data.entity.custom.statistics;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* API 통계 분단위 복합키
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class ApiStatsMinuteId implements Serializable {
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
private LocalDateTime statTime;
|
||||
private String apiName;
|
||||
private String gwInstanceId;
|
||||
private String bizDivCode;
|
||||
private String clientId;
|
||||
private String inboundAdapter;
|
||||
private String outboundAdapter;
|
||||
}
|
||||
Reference in New Issue
Block a user