: 통계거래 데이터 저장 - transaction 격리

Task-Url:
This commit is contained in:
pksup
2026-01-09 10:08:12 +09:00
parent e39ddc9459
commit 312756adc8
14 changed files with 1428 additions and 1 deletions
@@ -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> {
}
@@ -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;
}