시간 통계 추가
This commit is contained in:
@@ -9,27 +9,40 @@ 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.HourStatsKey;
|
||||
import com.eactive.eai.custom.statistics.dto.MinuteStatsKey;
|
||||
import com.eactive.eai.custom.statistics.dto.TransactionEvent;
|
||||
import com.eactive.eai.data.entity.custom.statistics.ApiStatsHour;
|
||||
import com.eactive.eai.data.entity.custom.statistics.ApiStatsMinute;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
/**
|
||||
* API 통계 수집기
|
||||
* - 거래 이벤트를 분단위로 메모리에 집계
|
||||
* - 거래 이벤트를 분/시간 단위로 메모리에 집계
|
||||
* - Thread-safe한 ConcurrentHashMap 사용
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
public class ApiStatsCollector {
|
||||
|
||||
/** 최대 Aggregator 수 제한 (메모리 보호) */
|
||||
private static final int MAX_MINUTE_AGGREGATORS = 10000;
|
||||
private static final int MAX_HOUR_AGGREGATORS = 5000;
|
||||
|
||||
/**
|
||||
* 분단위 집계 맵
|
||||
* Key: MinuteStatsKey (시간 + 차원들)
|
||||
* Value: MinuteStatsAggregator (집계 데이터)
|
||||
*/
|
||||
private final ConcurrentHashMap<MinuteStatsKey, MinuteStatsAggregator> aggregators = new ConcurrentHashMap<>();
|
||||
private final ConcurrentHashMap<MinuteStatsKey, MinuteStatsAggregator> minuteAggregators = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 시간별 집계 맵
|
||||
* Key: HourStatsKey (시간 + 차원들)
|
||||
* Value: HourStatsAggregator (집계 데이터)
|
||||
*/
|
||||
private final ConcurrentHashMap<HourStatsKey, HourStatsAggregator> hourAggregators = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 거래 이벤트 기록
|
||||
@@ -47,30 +60,70 @@ public class ApiStatsCollector {
|
||||
* @param eventTime 이벤트 발생 시간
|
||||
*/
|
||||
public void recordEvent(TransactionEvent event, LocalDateTime eventTime) {
|
||||
// 통계 수집 비활성화 체크
|
||||
if (!ApiStatsConfig.isEnabled()) {
|
||||
// 통계 수집 비활성화 체크 (분단위 또는 시간별 중 하나라도 활성화되어야 함)
|
||||
boolean minuteEnabled = ApiStatsConfig.isMinuteEnabled();
|
||||
boolean hourEnabled = ApiStatsConfig.isHourEnabled();
|
||||
|
||||
if (!minuteEnabled && !hourEnabled) {
|
||||
if (log.isDebugEnabled()) {
|
||||
log.debug("API stats collection is disabled");
|
||||
log.debug("API stats collection is disabled (both minute and hour)");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 집계 키 생성 (분 단위로 절사)
|
||||
MinuteStatsKey key = MinuteStatsKey.from(event, eventTime);
|
||||
MinuteStatsKey minuteKey = null;
|
||||
MinuteStatsAggregator minuteAggregator = null;
|
||||
HourStatsKey hourKey = null;
|
||||
HourStatsAggregator hourAggregator = null;
|
||||
|
||||
// 집계기 가져오기 또는 생성
|
||||
MinuteStatsAggregator aggregator = aggregators.computeIfAbsent(
|
||||
key,
|
||||
MinuteStatsAggregator::new
|
||||
);
|
||||
// 분단위 집계 (활성화된 경우만)
|
||||
if (minuteEnabled) {
|
||||
// Aggregator 개수 제한 체크 (메모리 보호)
|
||||
if (minuteAggregators.size() >= MAX_MINUTE_AGGREGATORS) {
|
||||
log.warn("Minute aggregator limit reached ({}), forcing flush of completed minutes", MAX_MINUTE_AGGREGATORS);
|
||||
flushCompletedMinutes();
|
||||
|
||||
// 이벤트 기록
|
||||
aggregator.recordEvent(event);
|
||||
// flush 후에도 여전히 제한 초과 시 경고
|
||||
if (minuteAggregators.size() >= MAX_MINUTE_AGGREGATORS) {
|
||||
log.error("Minute aggregator count still exceeds limit after flush: {}", minuteAggregators.size());
|
||||
}
|
||||
}
|
||||
|
||||
minuteKey = MinuteStatsKey.from(event, eventTime);
|
||||
minuteAggregator = minuteAggregators.computeIfAbsent(
|
||||
minuteKey,
|
||||
MinuteStatsAggregator::new
|
||||
);
|
||||
minuteAggregator.recordEvent(event);
|
||||
}
|
||||
|
||||
// 시간별 집계 (활성화된 경우만)
|
||||
if (hourEnabled) {
|
||||
// Aggregator 개수 제한 체크 (메모리 보호)
|
||||
if (hourAggregators.size() >= MAX_HOUR_AGGREGATORS) {
|
||||
log.warn("Hour aggregator limit reached ({}), forcing flush of completed hours", MAX_HOUR_AGGREGATORS);
|
||||
flushCompletedHours();
|
||||
|
||||
// flush 후에도 여전히 제한 초과 시 경고
|
||||
if (hourAggregators.size() >= MAX_HOUR_AGGREGATORS) {
|
||||
log.error("Hour aggregator count still exceeds limit after flush: {}", hourAggregators.size());
|
||||
}
|
||||
}
|
||||
|
||||
hourKey = HourStatsKey.from(event, eventTime);
|
||||
hourAggregator = hourAggregators.computeIfAbsent(
|
||||
hourKey,
|
||||
HourStatsAggregator::new
|
||||
);
|
||||
hourAggregator.recordEvent(event);
|
||||
}
|
||||
|
||||
if (log.isDebugEnabled()) {
|
||||
log.debug("Recorded event: {}, total count: {}",
|
||||
key, aggregator.getTotalCount());
|
||||
log.debug("Recorded event: minute={}, hour={}, minute_total={}, hour_total={}",
|
||||
minuteKey, hourKey,
|
||||
minuteAggregator != null ? minuteAggregator.getTotalCount() : 0,
|
||||
hourAggregator != null ? hourAggregator.getTotalCount() : 0);
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
@@ -79,20 +132,20 @@ public class ApiStatsCollector {
|
||||
}
|
||||
|
||||
/**
|
||||
* 지정된 시간 이전의 집계 데이터를 flush하여 반환
|
||||
* 지정된 시간 이전의 분단위 집계 데이터를 flush하여 반환
|
||||
* - 반환된 데이터는 메모리에서 제거됨
|
||||
*
|
||||
* @param beforeTime 이 시간 이전의 데이터를 flush (exclusive)
|
||||
* @return 집계된 통계 리스트
|
||||
*/
|
||||
public List<ApiStatsMinute> flushBefore(LocalDateTime beforeTime) {
|
||||
public List<ApiStatsMinute> flushMinuteBefore(LocalDateTime beforeTime) {
|
||||
LocalDateTime cutoffTime = beforeTime.truncatedTo(ChronoUnit.MINUTES);
|
||||
List<ApiStatsMinute> result = new ArrayList<>();
|
||||
|
||||
// 대상 키 수집 및 스냅샷 생성
|
||||
List<MinuteStatsKey> keysToRemove = new ArrayList<>();
|
||||
|
||||
aggregators.forEach((key, aggregator) -> {
|
||||
minuteAggregators.forEach((key, aggregator) -> {
|
||||
if (key.getStatTime().isBefore(cutoffTime)) {
|
||||
// 스냅샷 생성 (건수가 0보다 큰 경우만)
|
||||
if (aggregator.getTotalCount() > 0) {
|
||||
@@ -104,16 +157,50 @@ public class ApiStatsCollector {
|
||||
});
|
||||
|
||||
// 메모리에서 제거
|
||||
keysToRemove.forEach(aggregators::remove);
|
||||
keysToRemove.forEach(minuteAggregators::remove);
|
||||
|
||||
log.info("Flushed {} stats records before {}, remaining: {}",
|
||||
result.size(), cutoffTime, aggregators.size());
|
||||
log.info("Flushed {} minute stats records before {}, remaining: {}",
|
||||
result.size(), cutoffTime, minuteAggregators.size());
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 분의 집계 데이터를 flush
|
||||
* 지정된 시간 이전의 시간별 집계 데이터를 flush하여 반환
|
||||
* - 반환된 데이터는 메모리에서 제거됨
|
||||
*
|
||||
* @param beforeTime 이 시간 이전의 데이터를 flush (exclusive)
|
||||
* @return 집계된 통계 리스트
|
||||
*/
|
||||
public List<ApiStatsHour> flushHourBefore(LocalDateTime beforeTime) {
|
||||
LocalDateTime cutoffTime = beforeTime.truncatedTo(ChronoUnit.HOURS);
|
||||
List<ApiStatsHour> result = new ArrayList<>();
|
||||
|
||||
// 대상 키 수집 및 스냅샷 생성
|
||||
List<HourStatsKey> keysToRemove = new ArrayList<>();
|
||||
|
||||
hourAggregators.forEach((key, aggregator) -> {
|
||||
if (key.getStatTime().isBefore(cutoffTime)) {
|
||||
// 스냅샷 생성 (건수가 0보다 큰 경우만)
|
||||
if (aggregator.getTotalCount() > 0) {
|
||||
ApiStatsHour snapshot = aggregator.snapshot();
|
||||
result.add(snapshot);
|
||||
}
|
||||
keysToRemove.add(key);
|
||||
}
|
||||
});
|
||||
|
||||
// 메모리에서 제거
|
||||
keysToRemove.forEach(hourAggregators::remove);
|
||||
|
||||
log.info("Flushed {} hour stats records before {}, remaining: {}",
|
||||
result.size(), cutoffTime, hourAggregators.size());
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 완료된 분단위 집계 데이터를 flush
|
||||
* - 현재 분 이전의 모든 데이터를 반환하고 제거
|
||||
*/
|
||||
public List<ApiStatsMinute> flushCompletedMinutes() {
|
||||
@@ -121,32 +208,70 @@ public class ApiStatsCollector {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
LocalDateTime currentMinute = now.truncatedTo(ChronoUnit.MINUTES);
|
||||
|
||||
return flushBefore(currentMinute);
|
||||
return flushMinuteBefore(currentMinute);
|
||||
}
|
||||
|
||||
/**
|
||||
* 모든 집계 데이터를 강제로 flush (종료 시 사용)
|
||||
* 완료된 시간별 집계 데이터를 flush
|
||||
* - 현재 시간 이전의 모든 데이터를 반환하고 제거
|
||||
*/
|
||||
public List<ApiStatsMinute> flushAll() {
|
||||
public List<ApiStatsHour> flushCompletedHours() {
|
||||
// 현재 시간은 아직 진행 중이므로 제외
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
LocalDateTime currentHour = now.truncatedTo(ChronoUnit.HOURS);
|
||||
|
||||
return flushHourBefore(currentHour);
|
||||
}
|
||||
|
||||
/**
|
||||
* 모든 분단위 집계 데이터를 강제로 flush (종료 시 사용)
|
||||
*/
|
||||
public List<ApiStatsMinute> flushAllMinutes() {
|
||||
List<ApiStatsMinute> result = new ArrayList<>();
|
||||
|
||||
aggregators.forEach((key, aggregator) -> {
|
||||
minuteAggregators.forEach((key, aggregator) -> {
|
||||
if (aggregator.getTotalCount() > 0) {
|
||||
result.add(aggregator.snapshot());
|
||||
}
|
||||
});
|
||||
|
||||
aggregators.clear();
|
||||
minuteAggregators.clear();
|
||||
|
||||
log.info("Flushed all {} stats records", result.size());
|
||||
log.info("Flushed all {} minute stats records", result.size());
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 집계 중인 키 개수 반환 (모니터링용)
|
||||
* 모든 시간별 집계 데이터를 강제로 flush (종료 시 사용)
|
||||
*/
|
||||
public int getActiveAggregatorCount() {
|
||||
return aggregators.size();
|
||||
public List<ApiStatsHour> flushAllHours() {
|
||||
List<ApiStatsHour> result = new ArrayList<>();
|
||||
|
||||
hourAggregators.forEach((key, aggregator) -> {
|
||||
if (aggregator.getTotalCount() > 0) {
|
||||
result.add(aggregator.snapshot());
|
||||
}
|
||||
});
|
||||
|
||||
hourAggregators.clear();
|
||||
|
||||
log.info("Flushed all {} hour stats records", result.size());
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 집계 중인 분단위 키 개수 반환 (모니터링용)
|
||||
*/
|
||||
public int getActiveMinuteAggregatorCount() {
|
||||
return minuteAggregators.size();
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 집계 중인 시간별 키 개수 반환 (모니터링용)
|
||||
*/
|
||||
public int getActiveHourAggregatorCount() {
|
||||
return hourAggregators.size();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
package com.eactive.eai.custom.statistics.collector;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
import com.eactive.eai.custom.statistics.dto.HourStatsKey;
|
||||
import com.eactive.eai.custom.statistics.dto.TransactionEvent;
|
||||
import com.eactive.eai.data.entity.custom.statistics.ApiStatsHour;
|
||||
|
||||
import lombok.Getter;
|
||||
|
||||
/**
|
||||
* 시간별 통계 집계기
|
||||
* - Bucket 방식으로 응답시간 백분위 계산 (메모리 최적화)
|
||||
* - Thread-safe한 집계 처리
|
||||
*/
|
||||
public class HourStatsAggregator {
|
||||
|
||||
@Getter
|
||||
private final HourStatsKey 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);
|
||||
|
||||
// 응답시간 Bucket (10개 구간으로 분류)
|
||||
private final AtomicLong[] respTimeBuckets = new AtomicLong[10];
|
||||
|
||||
// Bucket 경계값 (ms)
|
||||
private static final long[] BUCKET_BOUNDARIES = {
|
||||
20, 50, 70, 100, // 빠른 구간 (0-100ms)
|
||||
200, 300, 500, // 일반 구간 (100-500ms) - P95 주로 여기
|
||||
1000, 5000, 60000 // 느린 구간 (500ms-60s)
|
||||
};
|
||||
|
||||
// 응답시간 통계용
|
||||
private final AtomicLong totalRespTime = new AtomicLong(0); // 평균 계산용
|
||||
private final AtomicLong minRespTime = new AtomicLong(Long.MAX_VALUE);
|
||||
private final AtomicLong maxRespTime = new AtomicLong(Long.MIN_VALUE);
|
||||
|
||||
public HourStatsAggregator(HourStatsKey key) {
|
||||
this.key = key;
|
||||
// Bucket 초기화
|
||||
for (int i = 0; i < respTimeBuckets.length; i++) {
|
||||
respTimeBuckets[i] = new AtomicLong(0);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 거래 이벤트 기록
|
||||
* - AtomicLong 사용으로 synchronized 불필요 (성능 최적화)
|
||||
*/
|
||||
public 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);
|
||||
|
||||
// Bucket에 기록
|
||||
int bucketIndex = findBucketIndex(respTime);
|
||||
respTimeBuckets[bucketIndex].incrementAndGet();
|
||||
|
||||
// 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, lock-free)
|
||||
*/
|
||||
private void updateMinMax(long respTime) {
|
||||
// Min 업데이트 (compare-and-swap)
|
||||
long currentMin;
|
||||
while ((currentMin = minRespTime.get()) > respTime) {
|
||||
if (minRespTime.compareAndSet(currentMin, respTime)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Max 업데이트 (compare-and-swap)
|
||||
long currentMax;
|
||||
while ((currentMax = maxRespTime.get()) < respTime) {
|
||||
if (maxRespTime.compareAndSet(currentMax, respTime)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 응답시간에 해당하는 Bucket 인덱스 찾기
|
||||
*/
|
||||
private int findBucketIndex(long respTime) {
|
||||
for (int i = 0; i < BUCKET_BOUNDARIES.length; i++) {
|
||||
if (respTime <= BUCKET_BOUNDARIES[i]) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return BUCKET_BOUNDARIES.length - 1; // 마지막 경계값 초과 시 마지막 bucket
|
||||
}
|
||||
|
||||
/**
|
||||
* Bucket 기반 백분위 계산
|
||||
* @param percentile 백분위 (50.0 for P50, 95.0 for P95)
|
||||
* @return 백분위 응답시간 (ms)
|
||||
*/
|
||||
private long calculatePercentile(double percentile) {
|
||||
long total = totalCnt.get();
|
||||
if (total == 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
long targetCount = (long) Math.ceil(total * percentile / 100.0);
|
||||
long accumulated = 0;
|
||||
|
||||
for (int i = 0; i < respTimeBuckets.length; i++) {
|
||||
accumulated += respTimeBuckets[i].get();
|
||||
if (accumulated >= targetCount) {
|
||||
// 해당 bucket의 상한값 반환
|
||||
return BUCKET_BOUNDARIES[i];
|
||||
}
|
||||
}
|
||||
|
||||
// 모든 bucket을 넘어간 경우
|
||||
return BUCKET_BOUNDARIES[BUCKET_BOUNDARIES.length - 1];
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 집계 결과를 Entity로 변환
|
||||
*/
|
||||
public ApiStatsHour snapshot() {
|
||||
ApiStatsHour stats = new ApiStatsHour();
|
||||
|
||||
// 키 정보
|
||||
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.get()).setScale(3, RoundingMode.HALF_UP));
|
||||
stats.setMaxRespTime(BigDecimal.valueOf(maxRespTime.get()).setScale(3, RoundingMode.HALF_UP));
|
||||
|
||||
// 백분위 (Bucket 기반 계산)
|
||||
long p50 = calculatePercentile(50.0);
|
||||
long p95 = calculatePercentile(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();
|
||||
}
|
||||
}
|
||||
+79
-33
@@ -4,9 +4,6 @@ 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;
|
||||
@@ -15,7 +12,7 @@ import lombok.Getter;
|
||||
|
||||
/**
|
||||
* 분단위 통계 집계기
|
||||
* - HdrHistogram을 사용하여 응답시간 백분위 계산
|
||||
* - Bucket 방식으로 응답시간 백분위 계산 (메모리 최적화)
|
||||
* - Thread-safe한 집계 처리
|
||||
*/
|
||||
public class MinuteStatsAggregator {
|
||||
@@ -35,27 +32,34 @@ public class MinuteStatsAggregator {
|
||||
private final AtomicLong seq900SystemErrCnt = new AtomicLong(0);
|
||||
private final AtomicLong seq900BizErrCnt = new AtomicLong(0);
|
||||
|
||||
// 응답시간 히스토그램 (1ms ~ 60초, 3자리 정밀도)
|
||||
private final Histogram histogram;
|
||||
// 응답시간 Bucket (10개 구간으로 분류)
|
||||
private final AtomicLong[] respTimeBuckets = new AtomicLong[10];
|
||||
|
||||
// Bucket 경계값 (ms)
|
||||
private static final long[] BUCKET_BOUNDARIES = {
|
||||
20, 50, 70, 100, // 빠른 구간 (0-100ms)
|
||||
200, 300, 500, // 일반 구간 (100-500ms) - P95 주로 여기
|
||||
1000, 5000, 60000 // 느린 구간 (500ms-60s)
|
||||
};
|
||||
|
||||
// 응답시간 통계용
|
||||
private final AtomicLong totalRespTime = new AtomicLong(0); // 평균 계산용
|
||||
private volatile long minRespTime = Long.MAX_VALUE;
|
||||
private volatile long maxRespTime = Long.MIN_VALUE;
|
||||
private final AtomicLong minRespTime = new AtomicLong(Long.MAX_VALUE);
|
||||
private final AtomicLong maxRespTime = new AtomicLong(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);
|
||||
// Bucket 초기화
|
||||
for (int i = 0; i < respTimeBuckets.length; i++) {
|
||||
respTimeBuckets[i] = new AtomicLong(0);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 거래 이벤트 기록
|
||||
* - AtomicLong 사용으로 synchronized 불필요 (성능 최적화)
|
||||
*/
|
||||
public synchronized void recordEvent(TransactionEvent event) {
|
||||
public void recordEvent(TransactionEvent event) {
|
||||
int logSeq = event.getLogSequence();
|
||||
|
||||
// 로그 시퀀스 400: 일반 통계 집계
|
||||
@@ -82,14 +86,9 @@ public class MinuteStatsAggregator {
|
||||
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);
|
||||
}
|
||||
// Bucket에 기록
|
||||
int bucketIndex = findBucketIndex(respTime);
|
||||
respTimeBuckets[bucketIndex].incrementAndGet();
|
||||
|
||||
// Min/Max 업데이트
|
||||
updateMinMax(respTime);
|
||||
@@ -112,17 +111,64 @@ public class MinuteStatsAggregator {
|
||||
}
|
||||
|
||||
/**
|
||||
* Min/Max 응답시간 업데이트 (thread-safe)
|
||||
* Min/Max 응답시간 업데이트 (thread-safe, lock-free)
|
||||
*/
|
||||
private synchronized void updateMinMax(long respTime) {
|
||||
if (respTime < minRespTime) {
|
||||
minRespTime = respTime;
|
||||
private void updateMinMax(long respTime) {
|
||||
// Min 업데이트 (compare-and-swap)
|
||||
long currentMin;
|
||||
while ((currentMin = minRespTime.get()) > respTime) {
|
||||
if (minRespTime.compareAndSet(currentMin, respTime)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (respTime > maxRespTime) {
|
||||
maxRespTime = respTime;
|
||||
|
||||
// Max 업데이트 (compare-and-swap)
|
||||
long currentMax;
|
||||
while ((currentMax = maxRespTime.get()) < respTime) {
|
||||
if (maxRespTime.compareAndSet(currentMax, respTime)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 응답시간에 해당하는 Bucket 인덱스 찾기
|
||||
*/
|
||||
private int findBucketIndex(long respTime) {
|
||||
for (int i = 0; i < BUCKET_BOUNDARIES.length; i++) {
|
||||
if (respTime <= BUCKET_BOUNDARIES[i]) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return BUCKET_BOUNDARIES.length - 1; // 마지막 경계값 초과 시 마지막 bucket
|
||||
}
|
||||
|
||||
/**
|
||||
* Bucket 기반 백분위 계산
|
||||
* @param percentile 백분위 (50.0 for P50, 95.0 for P95)
|
||||
* @return 백분위 응답시간 (ms)
|
||||
*/
|
||||
private long calculatePercentile(double percentile) {
|
||||
long total = totalCnt.get();
|
||||
if (total == 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
long targetCount = (long) Math.ceil(total * percentile / 100.0);
|
||||
long accumulated = 0;
|
||||
|
||||
for (int i = 0; i < respTimeBuckets.length; i++) {
|
||||
accumulated += respTimeBuckets[i].get();
|
||||
if (accumulated >= targetCount) {
|
||||
// 해당 bucket의 상한값 반환
|
||||
return BUCKET_BOUNDARIES[i];
|
||||
}
|
||||
}
|
||||
|
||||
// 모든 bucket을 넘어간 경우
|
||||
return BUCKET_BOUNDARIES[BUCKET_BOUNDARIES.length - 1];
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 집계 결과를 Entity로 변환
|
||||
*/
|
||||
@@ -158,12 +204,12 @@ public class MinuteStatsAggregator {
|
||||
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));
|
||||
stats.setMinRespTime(BigDecimal.valueOf(minRespTime.get()).setScale(3, RoundingMode.HALF_UP));
|
||||
stats.setMaxRespTime(BigDecimal.valueOf(maxRespTime.get()).setScale(3, RoundingMode.HALF_UP));
|
||||
|
||||
// 백분위 (HdrHistogram 사용)
|
||||
long p50 = histogram.getValueAtPercentile(50.0);
|
||||
long p95 = histogram.getValueAtPercentile(95.0);
|
||||
// 백분위 (Bucket 기반 계산)
|
||||
long p50 = calculatePercentile(50.0);
|
||||
long p95 = calculatePercentile(95.0);
|
||||
stats.setP50RespTime(BigDecimal.valueOf(p50).setScale(3, RoundingMode.HALF_UP));
|
||||
stats.setP95RespTime(BigDecimal.valueOf(p95).setScale(3, RoundingMode.HALF_UP));
|
||||
} else {
|
||||
|
||||
@@ -21,12 +21,11 @@ public class ApiStatsConfig {
|
||||
|
||||
/** 기본값 상수 */
|
||||
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;
|
||||
private static final boolean DEFAULT_MINUTE_ENABLED = true;
|
||||
private static final boolean DEFAULT_HOUR_ENABLED = true;
|
||||
|
||||
/**
|
||||
* 통계 수집 활성화 여부
|
||||
* 통계 수집 활성화 여부 (전체 ON/OFF)
|
||||
* - true: 통계 수집 활성화 (기본값)
|
||||
* - false: 통계 수집 비활성화
|
||||
*/
|
||||
@@ -42,44 +41,42 @@ public class ApiStatsConfig {
|
||||
}
|
||||
|
||||
/**
|
||||
* 히스토그램 최소값 (ms)
|
||||
* 분단위 통계 수집 활성화 여부
|
||||
* - true: 분단위 통계 수집 활성화 (기본값)
|
||||
* - false: 분단위 통계 수집 비활성화
|
||||
* - 전체 enabled가 false이면 이 설정과 무관하게 비활성화됨
|
||||
*/
|
||||
public static long getHistogramMinMs() {
|
||||
public static boolean isMinuteEnabled() {
|
||||
if (!isEnabled()) {
|
||||
return false;
|
||||
}
|
||||
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);
|
||||
String value = prop.getProperty("minute.enabled", String.valueOf(DEFAULT_MINUTE_ENABLED));
|
||||
return Boolean.parseBoolean(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;
|
||||
logger.debug("Failed to get minute.enabled from PropManager, using default: " + DEFAULT_MINUTE_ENABLED);
|
||||
return DEFAULT_MINUTE_ENABLED;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 히스토그램 최대값 (ms)
|
||||
* 시간별 통계 수집 활성화 여부
|
||||
* - true: 시간별 통계 수집 활성화 (기본값)
|
||||
* - false: 시간별 통계 수집 비활성화
|
||||
* - 전체 enabled가 false이면 이 설정과 무관하게 비활성화됨
|
||||
*/
|
||||
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 boolean isHourEnabled() {
|
||||
if (!isEnabled()) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 히스토그램 정밀도 (유효숫자)
|
||||
*/
|
||||
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);
|
||||
String value = prop.getProperty("hour.enabled", String.valueOf(DEFAULT_HOUR_ENABLED));
|
||||
return Boolean.parseBoolean(value);
|
||||
} catch (Exception e) {
|
||||
logger.debug("Failed to get histogram.precision from PropManager, using default: " + DEFAULT_HISTOGRAM_PRECISION);
|
||||
return DEFAULT_HISTOGRAM_PRECISION;
|
||||
logger.debug("Failed to get hour.enabled from PropManager, using default: " + DEFAULT_HOUR_ENABLED);
|
||||
return DEFAULT_HOUR_ENABLED;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,8 +85,7 @@ public class ApiStatsConfig {
|
||||
*/
|
||||
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());
|
||||
logger.info("ApiStatsConfig] minute.enabled = " + isMinuteEnabled());
|
||||
logger.info("ApiStatsConfig] hour.enabled = " + isHourEnabled());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 HourStatsKey {
|
||||
|
||||
/** 통계 시간 (시간 단위, 분/초는 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 HourStatsKey(LocalDateTime statTime, String apiName, String gwInstanceId,
|
||||
String bizDivCode, String clientId, String inboundAdapter, String outboundAdapter) {
|
||||
// 분, 초, 나노초를 0으로 절사하여 시간 단위로 정규화
|
||||
this.statTime = statTime.truncatedTo(ChronoUnit.HOURS);
|
||||
this.apiName = apiName;
|
||||
this.gwInstanceId = gwInstanceId;
|
||||
this.bizDivCode = bizDivCode;
|
||||
this.clientId = clientId;
|
||||
this.inboundAdapter = inboundAdapter;
|
||||
this.outboundAdapter = outboundAdapter;
|
||||
}
|
||||
|
||||
/**
|
||||
* TransactionEvent로부터 키 생성
|
||||
*/
|
||||
public static HourStatsKey from(TransactionEvent event, LocalDateTime eventTime) {
|
||||
return new HourStatsKey(
|
||||
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;
|
||||
HourStatsKey that = (HourStatsKey) 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,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.ApiStatsHour;
|
||||
import com.eactive.eai.data.entity.custom.statistics.ApiStatsHourId;
|
||||
import com.eactive.eai.data.jpa.AbstractDataLogger;
|
||||
|
||||
@Service
|
||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||
public class ApiStatsHourLogger
|
||||
extends AbstractDataLogger<ApiStatsHour, ApiStatsHourId, ApiStatsHourRepository> {
|
||||
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package com.eactive.eai.custom.statistics.persistence;
|
||||
|
||||
import com.eactive.eai.data.entity.custom.statistics.ApiStatsHour;
|
||||
import com.eactive.eai.data.entity.custom.statistics.ApiStatsHourId;
|
||||
import com.eactive.eai.data.jpa.BaseRepository;
|
||||
|
||||
public interface ApiStatsHourRepository extends BaseRepository<ApiStatsHour, ApiStatsHourId> {
|
||||
|
||||
}
|
||||
@@ -10,7 +10,9 @@ 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.ApiStatsHourLogger;
|
||||
import com.eactive.eai.custom.statistics.persistence.ApiStatsMinuteLogger;
|
||||
import com.eactive.eai.data.entity.custom.statistics.ApiStatsHour;
|
||||
import com.eactive.eai.data.entity.custom.statistics.ApiStatsMinute;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
@@ -18,7 +20,8 @@ import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
/**
|
||||
* API 통계 스케줄러
|
||||
* - 매 분마다 실행되어 집계된 통계를 DB에 저장
|
||||
* - 분단위: 매 분 5초에 실행하여 집계된 통계를 DB에 저장
|
||||
* - 시간별: 매 시간 5분에 실행하여 집계된 통계를 DB에 저장
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@@ -26,7 +29,12 @@ import lombok.extern.slf4j.Slf4j;
|
||||
public class ApiStatsScheduler {
|
||||
|
||||
private final ApiStatsCollector collector;
|
||||
private final ApiStatsMinuteLogger logger;
|
||||
private final ApiStatsMinuteLogger minuteLogger;
|
||||
private final ApiStatsHourLogger hourLogger;
|
||||
|
||||
// 이전 설정값 추적 (설정 변경 시 flush용)
|
||||
private volatile boolean previousMinuteEnabled = true;
|
||||
private volatile boolean previousHourEnabled = true;
|
||||
|
||||
/**
|
||||
* 초기화 시 설정값 로깅
|
||||
@@ -38,14 +46,28 @@ public class ApiStatsScheduler {
|
||||
}
|
||||
|
||||
/**
|
||||
* 매 분마다 실행 (매 분 5초에 실행)
|
||||
* 매 분마다 실행 (매 분 10초에 실행)
|
||||
* - 이전 분의 완료된 통계를 DB에 저장
|
||||
* - 10초 여유를 두어 이전 분 데이터가 완전히 수집되도록 함
|
||||
*/
|
||||
@Scheduled(cron = "5 * * * * ?")
|
||||
@Scheduled(cron = "10 * * * * ?")
|
||||
public void saveMinuteStats() {
|
||||
// 통계 수집 비활성화 체크
|
||||
if (!ApiStatsConfig.isEnabled()) {
|
||||
log.debug("API stats collection is disabled, skipping save");
|
||||
boolean currentEnabled = ApiStatsConfig.isMinuteEnabled();
|
||||
|
||||
// 설정이 enabled → disabled로 변경된 경우: 남은 데이터 모두 flush
|
||||
if (previousMinuteEnabled && !currentEnabled) {
|
||||
log.info("Minute stats disabled, flushing all remaining data...");
|
||||
flushAllMinuteStats();
|
||||
previousMinuteEnabled = currentEnabled;
|
||||
return;
|
||||
}
|
||||
|
||||
// 현재 상태 업데이트
|
||||
previousMinuteEnabled = currentEnabled;
|
||||
|
||||
// 분단위 통계 수집 비활성화 체크
|
||||
if (!currentEnabled) {
|
||||
log.debug("API minute stats collection is disabled, skipping save");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -66,11 +88,11 @@ public class ApiStatsScheduler {
|
||||
|
||||
for (ApiStatsMinute stats : statsList) {
|
||||
try {
|
||||
logger.log(stats);
|
||||
minuteLogger.log(stats);
|
||||
savedCount++;
|
||||
|
||||
if (log.isDebugEnabled()) {
|
||||
log.debug("Saved stats: {} - total={}, success={}, p95={}ms",
|
||||
log.debug("Saved minute stats: {} - total={}, success={}, p95={}ms",
|
||||
stats.getApiName(),
|
||||
stats.getTotalCnt(),
|
||||
stats.getSuccessCnt(),
|
||||
@@ -79,18 +101,150 @@ public class ApiStatsScheduler {
|
||||
|
||||
} catch (Exception e) {
|
||||
errorCount++;
|
||||
log.error("Failed to save stats: {}", stats, e);
|
||||
log.error("Failed to save minute stats: {}", stats, e);
|
||||
}
|
||||
}
|
||||
|
||||
log.info("Minute stats saved: success={}, error={}, active_aggregators={}",
|
||||
savedCount, errorCount, collector.getActiveAggregatorCount());
|
||||
log.info("Minute stats saved: success={}, error={}, active_minute_aggregators={}",
|
||||
savedCount, errorCount, collector.getActiveMinuteAggregatorCount());
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to flush minute stats", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 매 시간마다 실행 (매 시간 10분에 실행)
|
||||
* - 이전 시간의 완료된 통계를 DB에 저장
|
||||
* - 10분 여유를 두어 이전 시간 데이터가 완전히 수집되도록 함
|
||||
*/
|
||||
@Scheduled(cron = "0 10 * * * ?")
|
||||
public void saveHourStats() {
|
||||
boolean currentEnabled = ApiStatsConfig.isHourEnabled();
|
||||
|
||||
// 설정이 enabled → disabled로 변경된 경우: 남은 데이터 모두 flush
|
||||
if (previousHourEnabled && !currentEnabled) {
|
||||
log.info("Hour stats disabled, flushing all remaining data...");
|
||||
flushAllHourStats();
|
||||
previousHourEnabled = currentEnabled;
|
||||
return;
|
||||
}
|
||||
|
||||
// 현재 상태 업데이트
|
||||
previousHourEnabled = currentEnabled;
|
||||
|
||||
// 시간별 통계 수집 비활성화 체크
|
||||
if (!currentEnabled) {
|
||||
log.debug("API hour stats collection is disabled, skipping save");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
log.debug("Starting hour stats flush...");
|
||||
|
||||
// 완료된 시간의 통계 데이터 가져오기
|
||||
List<ApiStatsHour> statsList = collector.flushCompletedHours();
|
||||
|
||||
if (statsList.isEmpty()) {
|
||||
log.debug("No hour stats to save");
|
||||
return;
|
||||
}
|
||||
|
||||
// DB에 저장
|
||||
int savedCount = 0;
|
||||
int errorCount = 0;
|
||||
|
||||
for (ApiStatsHour stats : statsList) {
|
||||
try {
|
||||
hourLogger.log(stats);
|
||||
savedCount++;
|
||||
|
||||
if (log.isDebugEnabled()) {
|
||||
log.debug("Saved hour stats: {} - total={}, success={}, p95={}ms",
|
||||
stats.getApiName(),
|
||||
stats.getTotalCnt(),
|
||||
stats.getSuccessCnt(),
|
||||
stats.getP95RespTime());
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
errorCount++;
|
||||
log.error("Failed to save hour stats: {}", stats, e);
|
||||
}
|
||||
}
|
||||
|
||||
log.info("Hour stats saved: success={}, error={}, active_hour_aggregators={}",
|
||||
savedCount, errorCount, collector.getActiveHourAggregatorCount());
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to flush hour stats", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 분단위 통계 모두 flush (설정 변경 시)
|
||||
*/
|
||||
private void flushAllMinuteStats() {
|
||||
try {
|
||||
List<ApiStatsMinute> statsList = collector.flushAllMinutes();
|
||||
|
||||
if (statsList.isEmpty()) {
|
||||
log.info("No minute stats to flush");
|
||||
return;
|
||||
}
|
||||
|
||||
int savedCount = 0;
|
||||
int errorCount = 0;
|
||||
|
||||
for (ApiStatsMinute stats : statsList) {
|
||||
try {
|
||||
minuteLogger.log(stats);
|
||||
savedCount++;
|
||||
} catch (Exception e) {
|
||||
errorCount++;
|
||||
log.error("Failed to save minute stats during config change: {}", stats, e);
|
||||
}
|
||||
}
|
||||
|
||||
log.info("Flushed all minute stats due to config change: success={}, error={}", savedCount, errorCount);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to flush all minute stats", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 시간별 통계 모두 flush (설정 변경 시)
|
||||
*/
|
||||
private void flushAllHourStats() {
|
||||
try {
|
||||
List<ApiStatsHour> statsList = collector.flushAllHours();
|
||||
|
||||
if (statsList.isEmpty()) {
|
||||
log.info("No hour stats to flush");
|
||||
return;
|
||||
}
|
||||
|
||||
int savedCount = 0;
|
||||
int errorCount = 0;
|
||||
|
||||
for (ApiStatsHour stats : statsList) {
|
||||
try {
|
||||
hourLogger.log(stats);
|
||||
savedCount++;
|
||||
} catch (Exception e) {
|
||||
errorCount++;
|
||||
log.error("Failed to save hour stats during config change: {}", stats, e);
|
||||
}
|
||||
}
|
||||
|
||||
log.info("Flushed all hour stats due to config change: success={}, error={}", savedCount, errorCount);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to flush all hour stats", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 애플리케이션 종료 시 남은 통계 저장
|
||||
*/
|
||||
@@ -99,17 +253,30 @@ public class ApiStatsScheduler {
|
||||
try {
|
||||
log.info("Shutting down ApiStatsScheduler, flushing remaining stats...");
|
||||
|
||||
List<ApiStatsMinute> statsList = collector.flushAll();
|
||||
|
||||
if (!statsList.isEmpty()) {
|
||||
for (ApiStatsMinute stats : statsList) {
|
||||
// 분단위 통계 저장
|
||||
List<ApiStatsMinute> minuteStatsList = collector.flushAllMinutes();
|
||||
if (!minuteStatsList.isEmpty()) {
|
||||
for (ApiStatsMinute stats : minuteStatsList) {
|
||||
try {
|
||||
logger.log(stats);
|
||||
minuteLogger.log(stats);
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to save stats during shutdown: {}", stats, e);
|
||||
log.error("Failed to save minute stats during shutdown: {}", stats, e);
|
||||
}
|
||||
}
|
||||
log.info("Saved {} remaining stats during shutdown", statsList.size());
|
||||
log.info("Saved {} remaining minute stats during shutdown", minuteStatsList.size());
|
||||
}
|
||||
|
||||
// 시간별 통계 저장
|
||||
List<ApiStatsHour> hourStatsList = collector.flushAllHours();
|
||||
if (!hourStatsList.isEmpty()) {
|
||||
for (ApiStatsHour stats : hourStatsList) {
|
||||
try {
|
||||
hourLogger.log(stats);
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to save hour stats during shutdown: {}", stats, e);
|
||||
}
|
||||
}
|
||||
log.info("Saved {} remaining hour stats during shutdown", hourStatsList.size());
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
# API 통계 수집 메모리 사용량 예측
|
||||
|
||||
## 개요
|
||||
API 통계 수집 시스템은 분단위/시간별로 메모리에서 집계 후 DB에 저장합니다.
|
||||
이 문서는 메모리 사용량을 예측하고, 안전한 운영을 위한 제한값을 설명합니다.
|
||||
|
||||
## Aggregator 메모리 구성
|
||||
|
||||
### 1. Key 메모리 (MinuteStatsKey / HourStatsKey)
|
||||
|
||||
| 필드 | 타입 | 평균 크기 | 메모리 |
|
||||
|------|------|----------|--------|
|
||||
| statTime | LocalDateTime | - | 24 bytes |
|
||||
| apiName | String | 50자 | ~124 bytes |
|
||||
| gwInstanceId | String | 20자 | ~64 bytes |
|
||||
| bizDivCode | String | 10자 | ~44 bytes |
|
||||
| clientId | String | 20자 | ~64 bytes |
|
||||
| inboundAdapter | String | 20자 | ~64 bytes |
|
||||
| outboundAdapter | String | 20자 | ~64 bytes |
|
||||
| 객체 오버헤드 | - | - | ~16 bytes |
|
||||
| **합계** | | | **~464 bytes** |
|
||||
|
||||
### 2. Aggregator 메모리 (MinuteStatsAggregator / HourStatsAggregator)
|
||||
|
||||
| 구성요소 | 개수/크기 | 메모리 |
|
||||
|---------|----------|--------|
|
||||
| AtomicLong (건수 메트릭) | 8개 | 64 bytes |
|
||||
| AtomicLong (900 에러 카운트) | 3개 | 24 bytes |
|
||||
| AtomicLong[] (Bucket) | 10개 | 80 bytes |
|
||||
| long (min/max/total) | 3개 | 24 bytes |
|
||||
| 기타 필드 및 오버헤드 | - | ~592 bytes |
|
||||
| **합계** | | **~784 bytes** |
|
||||
|
||||
### 3. Aggregator 1개당 총 메모리
|
||||
|
||||
```
|
||||
Key + Aggregator = 464 + 784 = 약 1.2KB
|
||||
```
|
||||
|
||||
## 메모리 사용량 시나리오
|
||||
|
||||
### 시나리오 1: 일반적인 경우
|
||||
|
||||
**가정:**
|
||||
- API 종류: 100개
|
||||
- 클라이언트: 50개
|
||||
- 평균 차원 조합: 200개 (동시에 활성화된 조합)
|
||||
|
||||
**메모리 사용량:**
|
||||
- 분단위: 200개 × 1.2KB = **240KB**
|
||||
- 시간별: 200개 × 1.2KB = **240KB**
|
||||
- **합계**: **약 480KB**
|
||||
|
||||
### 시나리오 2: 높은 부하
|
||||
|
||||
**가정:**
|
||||
- API 종류: 500개
|
||||
- 클라이언트: 200개
|
||||
- 평균 차원 조합: 2,000개
|
||||
|
||||
**메모리 사용량:**
|
||||
- 분단위: 2,000개 × 1.2KB = **2.4MB**
|
||||
- 시간별: 2,000개 × 1.2KB = **2.4MB**
|
||||
- **합계**: **약 4.8MB**
|
||||
|
||||
### 시나리오 3: 최대 제한 도달 (비정상)
|
||||
|
||||
**가정:**
|
||||
- 분단위 aggregator: 10,000개 (최대 제한)
|
||||
- 시간별 aggregator: 5,000개 (최대 제한)
|
||||
|
||||
**메모리 사용량:**
|
||||
- 분단위: 10,000개 × 1.2KB = **12MB**
|
||||
- 시간별: 5,000개 × 1.2KB = **6MB**
|
||||
- **합계**: **약 18MB**
|
||||
|
||||
> ⚠️ **주의**: 이 시나리오는 비정상 상황입니다. 정상적으로는 스케줄러가 주기적으로 flush하여 aggregator 수를 낮게 유지합니다.
|
||||
|
||||
## 메모리 보호 메커니즘
|
||||
|
||||
### 1. 최대 Aggregator 수 제한
|
||||
|
||||
`ApiStatsCollector.java`:
|
||||
```java
|
||||
private static final int MAX_MINUTE_AGGREGATORS = 10000;
|
||||
private static final int MAX_HOUR_AGGREGATORS = 5000;
|
||||
```
|
||||
|
||||
### 2. 자동 Flush
|
||||
|
||||
Aggregator 개수가 제한에 도달하면:
|
||||
1. WARN 로그 출력
|
||||
2. 완료된 시간의 데이터 자동 flush
|
||||
3. 메모리 확보
|
||||
|
||||
### 3. 스케줄러 주기
|
||||
|
||||
- **분단위**: 매 분 5초에 이전 분 데이터 flush
|
||||
- **시간별**: 매 시간 5분에 이전 시간 데이터 flush
|
||||
|
||||
정상 작동 시 aggregator는 주로 현재 시간대 데이터만 유지합니다.
|
||||
|
||||
## 차원 조합 수 계산
|
||||
|
||||
### 차원 구조
|
||||
```
|
||||
차원 조합 = API × GW Instance × 업무구분 × Client × Inbound × Outbound
|
||||
```
|
||||
|
||||
### 예시 계산
|
||||
|
||||
**케이스 1: 중소규모**
|
||||
- API: 100개
|
||||
- GW Instance: 3개
|
||||
- 업무구분: 5개
|
||||
- Client: 10개
|
||||
- Inbound: 5개
|
||||
- Outbound: 5개
|
||||
- **이론적 최대**: 100 × 3 × 5 × 10 × 5 × 5 = 375,000 조합
|
||||
|
||||
> 💡 **실제로는 활성화된 조합만 메모리에 존재**하므로 훨씬 적습니다. (일반적으로 수백~수천 개)
|
||||
|
||||
**케이스 2: 대규모**
|
||||
- API: 500개
|
||||
- 활성 조합 비율: 1% (실제 사용되는 조합)
|
||||
- **실제 조합**: 약 3,750개
|
||||
- **분단위 메모리**: 3,750 × 1.2KB = **4.5MB**
|
||||
- **시간별 메모리**: 3,750 × 1.2KB = **4.5MB**
|
||||
- **합계**: **약 9MB**
|
||||
|
||||
## 모니터링 지표
|
||||
|
||||
### 정상 범위
|
||||
- 분단위 aggregator: **100~1,000개**
|
||||
- 시간별 aggregator: **100~1,000개**
|
||||
- 메모리 사용량: **1~5MB**
|
||||
|
||||
### 경고 범위
|
||||
- 분단위 aggregator: **1,000~5,000개**
|
||||
- 시간별 aggregator: **1,000~3,000개**
|
||||
- 메모리 사용량: **5~10MB**
|
||||
|
||||
### 위험 범위 (조치 필요)
|
||||
- 분단위 aggregator: **5,000개 이상**
|
||||
- 시간별 aggregator: **3,000개 이상**
|
||||
- 메모리 사용량: **10MB 이상**
|
||||
|
||||
> ⚠️ 위험 범위 진입 시 차원 수를 줄이거나 제한값 조정을 검토해야 합니다.
|
||||
|
||||
## 메모리 최적화 효과
|
||||
|
||||
### Bucket 방식 적용 전후 비교 (참고)
|
||||
|
||||
**이전 방식의 문제점:**
|
||||
- Aggregator당 약 2.6MB 사용 (HdrHistogram 포함)
|
||||
- 1,000개 시: 약 2.6GB
|
||||
- 10,000개 시: 약 26GB
|
||||
|
||||
**현재 방식:**
|
||||
- Aggregator당 약 1.2KB 사용 (Bucket 배열)
|
||||
- 1,000개 시: 약 1.2MB
|
||||
- 10,000개 시: 약 12MB
|
||||
|
||||
**메모리 절감**: **99.95% 감소**
|
||||
|
||||
## 권장 사항
|
||||
|
||||
### 1. 모니터링
|
||||
- `ApiStatsCollector.getActiveMinuteAggregatorCount()` 주기적 확인
|
||||
- `ApiStatsCollector.getActiveHourAggregatorCount()` 주기적 확인
|
||||
- WARN/ERROR 로그 모니터링
|
||||
|
||||
### 2. 차원 최적화
|
||||
- 불필요한 차원 제거 (예: null 값 정규화)
|
||||
- 고카디널리티 차원 주의 (clientId 등)
|
||||
|
||||
### 3. 제한값 조정
|
||||
운영 환경에 따라 `ApiStatsCollector.java`의 상수 조정:
|
||||
```java
|
||||
private static final int MAX_MINUTE_AGGREGATORS = 10000; // 조정 가능
|
||||
private static final int MAX_HOUR_AGGREGATORS = 5000; // 조정 가능
|
||||
```
|
||||
|
||||
### 4. 설정 관리
|
||||
PropManager를 통해 동적으로 활성화/비활성화:
|
||||
- `ApiStats.enabled`: 전체 ON/OFF
|
||||
- `ApiStats.minute.enabled`: 분단위 ON/OFF
|
||||
- `ApiStats.hour.enabled`: 시간별 ON/OFF
|
||||
|
||||
설정 비활성화 시 메모리의 모든 데이터가 자동으로 flush됩니다.
|
||||
@@ -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_HOUR")
|
||||
@org.hibernate.annotations.Table(appliesTo = "API_STATS_HOUR", comment = "API Gateway 시간별 처리 통계")
|
||||
@IdClass(ApiStatsHourId.class)
|
||||
public class ApiStatsHour 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 ApiStatsHourId 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