시간 통계 추가
This commit is contained in:
@@ -9,27 +9,40 @@ import java.util.concurrent.ConcurrentHashMap;
|
|||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import com.eactive.eai.custom.statistics.config.ApiStatsConfig;
|
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.MinuteStatsKey;
|
||||||
import com.eactive.eai.custom.statistics.dto.TransactionEvent;
|
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 com.eactive.eai.data.entity.custom.statistics.ApiStatsMinute;
|
||||||
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* API 통계 수집기
|
* API 통계 수집기
|
||||||
* - 거래 이벤트를 분단위로 메모리에 집계
|
* - 거래 이벤트를 분/시간 단위로 메모리에 집계
|
||||||
* - Thread-safe한 ConcurrentHashMap 사용
|
* - Thread-safe한 ConcurrentHashMap 사용
|
||||||
*/
|
*/
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@Service
|
@Service
|
||||||
public class ApiStatsCollector {
|
public class ApiStatsCollector {
|
||||||
|
|
||||||
|
/** 최대 Aggregator 수 제한 (메모리 보호) */
|
||||||
|
private static final int MAX_MINUTE_AGGREGATORS = 10000;
|
||||||
|
private static final int MAX_HOUR_AGGREGATORS = 5000;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 분단위 집계 맵
|
* 분단위 집계 맵
|
||||||
* Key: MinuteStatsKey (시간 + 차원들)
|
* Key: MinuteStatsKey (시간 + 차원들)
|
||||||
* Value: MinuteStatsAggregator (집계 데이터)
|
* 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 이벤트 발생 시간
|
* @param eventTime 이벤트 발생 시간
|
||||||
*/
|
*/
|
||||||
public void recordEvent(TransactionEvent event, LocalDateTime 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()) {
|
if (log.isDebugEnabled()) {
|
||||||
log.debug("API stats collection is disabled");
|
log.debug("API stats collection is disabled (both minute and hour)");
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 집계 키 생성 (분 단위로 절사)
|
MinuteStatsKey minuteKey = null;
|
||||||
MinuteStatsKey key = MinuteStatsKey.from(event, eventTime);
|
MinuteStatsAggregator minuteAggregator = null;
|
||||||
|
HourStatsKey hourKey = null;
|
||||||
|
HourStatsAggregator hourAggregator = null;
|
||||||
|
|
||||||
// 집계기 가져오기 또는 생성
|
// 분단위 집계 (활성화된 경우만)
|
||||||
MinuteStatsAggregator aggregator = aggregators.computeIfAbsent(
|
if (minuteEnabled) {
|
||||||
key,
|
// Aggregator 개수 제한 체크 (메모리 보호)
|
||||||
MinuteStatsAggregator::new
|
if (minuteAggregators.size() >= MAX_MINUTE_AGGREGATORS) {
|
||||||
);
|
log.warn("Minute aggregator limit reached ({}), forcing flush of completed minutes", MAX_MINUTE_AGGREGATORS);
|
||||||
|
flushCompletedMinutes();
|
||||||
|
|
||||||
// 이벤트 기록
|
// flush 후에도 여전히 제한 초과 시 경고
|
||||||
aggregator.recordEvent(event);
|
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()) {
|
if (log.isDebugEnabled()) {
|
||||||
log.debug("Recorded event: {}, total count: {}",
|
log.debug("Recorded event: minute={}, hour={}, minute_total={}, hour_total={}",
|
||||||
key, aggregator.getTotalCount());
|
minuteKey, hourKey,
|
||||||
|
minuteAggregator != null ? minuteAggregator.getTotalCount() : 0,
|
||||||
|
hourAggregator != null ? hourAggregator.getTotalCount() : 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
@@ -79,20 +132,20 @@ public class ApiStatsCollector {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 지정된 시간 이전의 집계 데이터를 flush하여 반환
|
* 지정된 시간 이전의 분단위 집계 데이터를 flush하여 반환
|
||||||
* - 반환된 데이터는 메모리에서 제거됨
|
* - 반환된 데이터는 메모리에서 제거됨
|
||||||
*
|
*
|
||||||
* @param beforeTime 이 시간 이전의 데이터를 flush (exclusive)
|
* @param beforeTime 이 시간 이전의 데이터를 flush (exclusive)
|
||||||
* @return 집계된 통계 리스트
|
* @return 집계된 통계 리스트
|
||||||
*/
|
*/
|
||||||
public List<ApiStatsMinute> flushBefore(LocalDateTime beforeTime) {
|
public List<ApiStatsMinute> flushMinuteBefore(LocalDateTime beforeTime) {
|
||||||
LocalDateTime cutoffTime = beforeTime.truncatedTo(ChronoUnit.MINUTES);
|
LocalDateTime cutoffTime = beforeTime.truncatedTo(ChronoUnit.MINUTES);
|
||||||
List<ApiStatsMinute> result = new ArrayList<>();
|
List<ApiStatsMinute> result = new ArrayList<>();
|
||||||
|
|
||||||
// 대상 키 수집 및 스냅샷 생성
|
// 대상 키 수집 및 스냅샷 생성
|
||||||
List<MinuteStatsKey> keysToRemove = new ArrayList<>();
|
List<MinuteStatsKey> keysToRemove = new ArrayList<>();
|
||||||
|
|
||||||
aggregators.forEach((key, aggregator) -> {
|
minuteAggregators.forEach((key, aggregator) -> {
|
||||||
if (key.getStatTime().isBefore(cutoffTime)) {
|
if (key.getStatTime().isBefore(cutoffTime)) {
|
||||||
// 스냅샷 생성 (건수가 0보다 큰 경우만)
|
// 스냅샷 생성 (건수가 0보다 큰 경우만)
|
||||||
if (aggregator.getTotalCount() > 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: {}",
|
log.info("Flushed {} minute stats records before {}, remaining: {}",
|
||||||
result.size(), cutoffTime, aggregators.size());
|
result.size(), cutoffTime, minuteAggregators.size());
|
||||||
|
|
||||||
return result;
|
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() {
|
public List<ApiStatsMinute> flushCompletedMinutes() {
|
||||||
@@ -121,32 +208,70 @@ public class ApiStatsCollector {
|
|||||||
LocalDateTime now = LocalDateTime.now();
|
LocalDateTime now = LocalDateTime.now();
|
||||||
LocalDateTime currentMinute = now.truncatedTo(ChronoUnit.MINUTES);
|
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<>();
|
List<ApiStatsMinute> result = new ArrayList<>();
|
||||||
|
|
||||||
aggregators.forEach((key, aggregator) -> {
|
minuteAggregators.forEach((key, aggregator) -> {
|
||||||
if (aggregator.getTotalCount() > 0) {
|
if (aggregator.getTotalCount() > 0) {
|
||||||
result.add(aggregator.snapshot());
|
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;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 현재 집계 중인 키 개수 반환 (모니터링용)
|
* 모든 시간별 집계 데이터를 강제로 flush (종료 시 사용)
|
||||||
*/
|
*/
|
||||||
public int getActiveAggregatorCount() {
|
public List<ApiStatsHour> flushAllHours() {
|
||||||
return aggregators.size();
|
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.math.RoundingMode;
|
||||||
import java.util.concurrent.atomic.AtomicLong;
|
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.MinuteStatsKey;
|
||||||
import com.eactive.eai.custom.statistics.dto.TransactionEvent;
|
import com.eactive.eai.custom.statistics.dto.TransactionEvent;
|
||||||
import com.eactive.eai.data.entity.custom.statistics.ApiStatsMinute;
|
import com.eactive.eai.data.entity.custom.statistics.ApiStatsMinute;
|
||||||
@@ -15,7 +12,7 @@ import lombok.Getter;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 분단위 통계 집계기
|
* 분단위 통계 집계기
|
||||||
* - HdrHistogram을 사용하여 응답시간 백분위 계산
|
* - Bucket 방식으로 응답시간 백분위 계산 (메모리 최적화)
|
||||||
* - Thread-safe한 집계 처리
|
* - Thread-safe한 집계 처리
|
||||||
*/
|
*/
|
||||||
public class MinuteStatsAggregator {
|
public class MinuteStatsAggregator {
|
||||||
@@ -35,27 +32,34 @@ public class MinuteStatsAggregator {
|
|||||||
private final AtomicLong seq900SystemErrCnt = new AtomicLong(0);
|
private final AtomicLong seq900SystemErrCnt = new AtomicLong(0);
|
||||||
private final AtomicLong seq900BizErrCnt = new AtomicLong(0);
|
private final AtomicLong seq900BizErrCnt = new AtomicLong(0);
|
||||||
|
|
||||||
// 응답시간 히스토그램 (1ms ~ 60초, 3자리 정밀도)
|
// 응답시간 Bucket (10개 구간으로 분류)
|
||||||
private final Histogram histogram;
|
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 totalRespTime = new AtomicLong(0); // 평균 계산용
|
||||||
private volatile long minRespTime = Long.MAX_VALUE;
|
private final AtomicLong minRespTime = new AtomicLong(Long.MAX_VALUE);
|
||||||
private volatile long maxRespTime = Long.MIN_VALUE;
|
private final AtomicLong maxRespTime = new AtomicLong(Long.MIN_VALUE);
|
||||||
|
|
||||||
public MinuteStatsAggregator(MinuteStatsKey key) {
|
public MinuteStatsAggregator(MinuteStatsKey key) {
|
||||||
this.key = key;
|
this.key = key;
|
||||||
// HdrHistogram: PropManager에서 설정값 읽기
|
// Bucket 초기화
|
||||||
long minMs = ApiStatsConfig.getHistogramMinMs();
|
for (int i = 0; i < respTimeBuckets.length; i++) {
|
||||||
long maxMs = ApiStatsConfig.getHistogramMaxMs();
|
respTimeBuckets[i] = new AtomicLong(0);
|
||||||
int precision = ApiStatsConfig.getHistogramPrecision();
|
}
|
||||||
this.histogram = new Histogram(minMs, maxMs, precision);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 거래 이벤트 기록
|
* 거래 이벤트 기록
|
||||||
|
* - AtomicLong 사용으로 synchronized 불필요 (성능 최적화)
|
||||||
*/
|
*/
|
||||||
public synchronized void recordEvent(TransactionEvent event) {
|
public void recordEvent(TransactionEvent event) {
|
||||||
int logSeq = event.getLogSequence();
|
int logSeq = event.getLogSequence();
|
||||||
|
|
||||||
// 로그 시퀀스 400: 일반 통계 집계
|
// 로그 시퀀스 400: 일반 통계 집계
|
||||||
@@ -82,14 +86,9 @@ public class MinuteStatsAggregator {
|
|||||||
long respTime = event.getResponseTimeMs();
|
long respTime = event.getResponseTimeMs();
|
||||||
totalRespTime.addAndGet(respTime);
|
totalRespTime.addAndGet(respTime);
|
||||||
|
|
||||||
// 히스토그램에 기록
|
// Bucket에 기록
|
||||||
long maxMs = ApiStatsConfig.getHistogramMaxMs();
|
int bucketIndex = findBucketIndex(respTime);
|
||||||
try {
|
respTimeBuckets[bucketIndex].incrementAndGet();
|
||||||
long clippedRespTime = Math.min(Math.max(1, respTime), maxMs);
|
|
||||||
histogram.recordValue(clippedRespTime);
|
|
||||||
} catch (ArrayIndexOutOfBoundsException e) {
|
|
||||||
histogram.recordValue(maxMs);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Min/Max 업데이트
|
// Min/Max 업데이트
|
||||||
updateMinMax(respTime);
|
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) {
|
private void updateMinMax(long respTime) {
|
||||||
if (respTime < minRespTime) {
|
// Min 업데이트 (compare-and-swap)
|
||||||
minRespTime = respTime;
|
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로 변환
|
* 현재 집계 결과를 Entity로 변환
|
||||||
*/
|
*/
|
||||||
@@ -158,12 +204,12 @@ public class MinuteStatsAggregator {
|
|||||||
stats.setAvgRespTime(BigDecimal.valueOf(avg).setScale(3, RoundingMode.HALF_UP));
|
stats.setAvgRespTime(BigDecimal.valueOf(avg).setScale(3, RoundingMode.HALF_UP));
|
||||||
|
|
||||||
// Min/Max
|
// Min/Max
|
||||||
stats.setMinRespTime(BigDecimal.valueOf(minRespTime).setScale(3, RoundingMode.HALF_UP));
|
stats.setMinRespTime(BigDecimal.valueOf(minRespTime.get()).setScale(3, RoundingMode.HALF_UP));
|
||||||
stats.setMaxRespTime(BigDecimal.valueOf(maxRespTime).setScale(3, RoundingMode.HALF_UP));
|
stats.setMaxRespTime(BigDecimal.valueOf(maxRespTime.get()).setScale(3, RoundingMode.HALF_UP));
|
||||||
|
|
||||||
// 백분위 (HdrHistogram 사용)
|
// 백분위 (Bucket 기반 계산)
|
||||||
long p50 = histogram.getValueAtPercentile(50.0);
|
long p50 = calculatePercentile(50.0);
|
||||||
long p95 = histogram.getValueAtPercentile(95.0);
|
long p95 = calculatePercentile(95.0);
|
||||||
stats.setP50RespTime(BigDecimal.valueOf(p50).setScale(3, RoundingMode.HALF_UP));
|
stats.setP50RespTime(BigDecimal.valueOf(p50).setScale(3, RoundingMode.HALF_UP));
|
||||||
stats.setP95RespTime(BigDecimal.valueOf(p95).setScale(3, RoundingMode.HALF_UP));
|
stats.setP95RespTime(BigDecimal.valueOf(p95).setScale(3, RoundingMode.HALF_UP));
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -21,12 +21,11 @@ public class ApiStatsConfig {
|
|||||||
|
|
||||||
/** 기본값 상수 */
|
/** 기본값 상수 */
|
||||||
private static final boolean DEFAULT_ENABLED = true;
|
private static final boolean DEFAULT_ENABLED = true;
|
||||||
private static final long DEFAULT_HISTOGRAM_MIN_MS = 1L;
|
private static final boolean DEFAULT_MINUTE_ENABLED = true;
|
||||||
private static final long DEFAULT_HISTOGRAM_MAX_MS = 60000L;
|
private static final boolean DEFAULT_HOUR_ENABLED = true;
|
||||||
private static final int DEFAULT_HISTOGRAM_PRECISION = 3;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 통계 수집 활성화 여부
|
* 통계 수집 활성화 여부 (전체 ON/OFF)
|
||||||
* - true: 통계 수집 활성화 (기본값)
|
* - true: 통계 수집 활성화 (기본값)
|
||||||
* - false: 통계 수집 비활성화
|
* - 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 {
|
try {
|
||||||
Properties prop = PropManager.getInstance().getProperties(GROUP_NAME);
|
Properties prop = PropManager.getInstance().getProperties(GROUP_NAME);
|
||||||
String value = prop.getProperty("histogram.min.ms", String.valueOf(DEFAULT_HISTOGRAM_MIN_MS));
|
String value = prop.getProperty("minute.enabled", String.valueOf(DEFAULT_MINUTE_ENABLED));
|
||||||
return Long.parseLong(value);
|
return Boolean.parseBoolean(value);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
logger.debug("Failed to get histogram.min.ms from PropManager, using default: " + DEFAULT_HISTOGRAM_MIN_MS);
|
logger.debug("Failed to get minute.enabled from PropManager, using default: " + DEFAULT_MINUTE_ENABLED);
|
||||||
return DEFAULT_HISTOGRAM_MIN_MS;
|
return DEFAULT_MINUTE_ENABLED;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 히스토그램 최대값 (ms)
|
* 시간별 통계 수집 활성화 여부
|
||||||
|
* - true: 시간별 통계 수집 활성화 (기본값)
|
||||||
|
* - false: 시간별 통계 수집 비활성화
|
||||||
|
* - 전체 enabled가 false이면 이 설정과 무관하게 비활성화됨
|
||||||
*/
|
*/
|
||||||
public static long getHistogramMaxMs() {
|
public static boolean isHourEnabled() {
|
||||||
try {
|
if (!isEnabled()) {
|
||||||
Properties prop = PropManager.getInstance().getProperties(GROUP_NAME);
|
return false;
|
||||||
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 {
|
try {
|
||||||
Properties prop = PropManager.getInstance().getProperties(GROUP_NAME);
|
Properties prop = PropManager.getInstance().getProperties(GROUP_NAME);
|
||||||
String value = prop.getProperty("histogram.precision", String.valueOf(DEFAULT_HISTOGRAM_PRECISION));
|
String value = prop.getProperty("hour.enabled", String.valueOf(DEFAULT_HOUR_ENABLED));
|
||||||
return Integer.parseInt(value);
|
return Boolean.parseBoolean(value);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
logger.debug("Failed to get histogram.precision from PropManager, using default: " + DEFAULT_HISTOGRAM_PRECISION);
|
logger.debug("Failed to get hour.enabled from PropManager, using default: " + DEFAULT_HOUR_ENABLED);
|
||||||
return DEFAULT_HISTOGRAM_PRECISION;
|
return DEFAULT_HOUR_ENABLED;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,8 +85,7 @@ public class ApiStatsConfig {
|
|||||||
*/
|
*/
|
||||||
public static void logCurrentConfig() {
|
public static void logCurrentConfig() {
|
||||||
logger.info("ApiStatsConfig] enabled = " + isEnabled());
|
logger.info("ApiStatsConfig] enabled = " + isEnabled());
|
||||||
logger.info("ApiStatsConfig] histogram.min.ms = " + getHistogramMinMs());
|
logger.info("ApiStatsConfig] minute.enabled = " + isMinuteEnabled());
|
||||||
logger.info("ApiStatsConfig] histogram.max.ms = " + getHistogramMaxMs());
|
logger.info("ApiStatsConfig] hour.enabled = " + isHourEnabled());
|
||||||
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 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.collector.ApiStatsCollector;
|
||||||
import com.eactive.eai.custom.statistics.config.ApiStatsConfig;
|
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.custom.statistics.persistence.ApiStatsMinuteLogger;
|
||||||
|
import com.eactive.eai.data.entity.custom.statistics.ApiStatsHour;
|
||||||
import com.eactive.eai.data.entity.custom.statistics.ApiStatsMinute;
|
import com.eactive.eai.data.entity.custom.statistics.ApiStatsMinute;
|
||||||
|
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
@@ -18,7 +20,8 @@ import lombok.extern.slf4j.Slf4j;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* API 통계 스케줄러
|
* API 통계 스케줄러
|
||||||
* - 매 분마다 실행되어 집계된 통계를 DB에 저장
|
* - 분단위: 매 분 5초에 실행하여 집계된 통계를 DB에 저장
|
||||||
|
* - 시간별: 매 시간 5분에 실행하여 집계된 통계를 DB에 저장
|
||||||
*/
|
*/
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@Component
|
@Component
|
||||||
@@ -26,7 +29,12 @@ import lombok.extern.slf4j.Slf4j;
|
|||||||
public class ApiStatsScheduler {
|
public class ApiStatsScheduler {
|
||||||
|
|
||||||
private final ApiStatsCollector collector;
|
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에 저장
|
* - 이전 분의 완료된 통계를 DB에 저장
|
||||||
|
* - 10초 여유를 두어 이전 분 데이터가 완전히 수집되도록 함
|
||||||
*/
|
*/
|
||||||
@Scheduled(cron = "5 * * * * ?")
|
@Scheduled(cron = "10 * * * * ?")
|
||||||
public void saveMinuteStats() {
|
public void saveMinuteStats() {
|
||||||
// 통계 수집 비활성화 체크
|
boolean currentEnabled = ApiStatsConfig.isMinuteEnabled();
|
||||||
if (!ApiStatsConfig.isEnabled()) {
|
|
||||||
log.debug("API stats collection is disabled, skipping save");
|
// 설정이 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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,11 +88,11 @@ public class ApiStatsScheduler {
|
|||||||
|
|
||||||
for (ApiStatsMinute stats : statsList) {
|
for (ApiStatsMinute stats : statsList) {
|
||||||
try {
|
try {
|
||||||
logger.log(stats);
|
minuteLogger.log(stats);
|
||||||
savedCount++;
|
savedCount++;
|
||||||
|
|
||||||
if (log.isDebugEnabled()) {
|
if (log.isDebugEnabled()) {
|
||||||
log.debug("Saved stats: {} - total={}, success={}, p95={}ms",
|
log.debug("Saved minute stats: {} - total={}, success={}, p95={}ms",
|
||||||
stats.getApiName(),
|
stats.getApiName(),
|
||||||
stats.getTotalCnt(),
|
stats.getTotalCnt(),
|
||||||
stats.getSuccessCnt(),
|
stats.getSuccessCnt(),
|
||||||
@@ -79,18 +101,150 @@ public class ApiStatsScheduler {
|
|||||||
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
errorCount++;
|
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={}",
|
log.info("Minute stats saved: success={}, error={}, active_minute_aggregators={}",
|
||||||
savedCount, errorCount, collector.getActiveAggregatorCount());
|
savedCount, errorCount, collector.getActiveMinuteAggregatorCount());
|
||||||
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("Failed to flush minute stats", 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 {
|
try {
|
||||||
log.info("Shutting down ApiStatsScheduler, flushing remaining stats...");
|
log.info("Shutting down ApiStatsScheduler, flushing remaining stats...");
|
||||||
|
|
||||||
List<ApiStatsMinute> statsList = collector.flushAll();
|
// 분단위 통계 저장
|
||||||
|
List<ApiStatsMinute> minuteStatsList = collector.flushAllMinutes();
|
||||||
if (!statsList.isEmpty()) {
|
if (!minuteStatsList.isEmpty()) {
|
||||||
for (ApiStatsMinute stats : statsList) {
|
for (ApiStatsMinute stats : minuteStatsList) {
|
||||||
try {
|
try {
|
||||||
logger.log(stats);
|
minuteLogger.log(stats);
|
||||||
} catch (Exception e) {
|
} 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) {
|
} 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