From b7cfd63272f4a96c587923dfbe278a1ce8a8ce7f Mon Sep 17 00:00:00 2001 From: pksup Date: Tue, 13 Jan 2026 09:15:09 +0900 Subject: [PATCH] =?UTF-8?q?=EC=8B=9C=EA=B0=84=20=ED=86=B5=EA=B3=84=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../collector/ApiStatsCollector.java | 189 +++++++++++--- .../collector/HourStatsAggregator.java | 232 ++++++++++++++++++ .../collector/MinuteStatsAggregator.java | 112 ++++++--- .../statistics/config/ApiStatsConfig.java | 60 +++-- .../custom/statistics/dto/HourStatsKey.java | 90 +++++++ .../persistence/ApiStatsHourLogger.java | 16 ++ .../persistence/ApiStatsHourRepository.java | 9 + .../scheduler/ApiStatsScheduler.java | 205 ++++++++++++++-- .../custom/statistics/메모리_사용량_예측.md | 190 ++++++++++++++ .../custom/statistics/ApiStatsHour.java | 118 +++++++++ .../custom/statistics/ApiStatsHourId.java | 26 ++ 11 files changed, 1131 insertions(+), 116 deletions(-) create mode 100644 src/main/java/com/eactive/eai/custom/statistics/collector/HourStatsAggregator.java create mode 100644 src/main/java/com/eactive/eai/custom/statistics/dto/HourStatsKey.java create mode 100644 src/main/java/com/eactive/eai/custom/statistics/persistence/ApiStatsHourLogger.java create mode 100644 src/main/java/com/eactive/eai/custom/statistics/persistence/ApiStatsHourRepository.java create mode 100644 src/main/java/com/eactive/eai/custom/statistics/메모리_사용량_예측.md create mode 100644 src/main/java/com/eactive/eai/data/entity/custom/statistics/ApiStatsHour.java create mode 100644 src/main/java/com/eactive/eai/data/entity/custom/statistics/ApiStatsHourId.java diff --git a/src/main/java/com/eactive/eai/custom/statistics/collector/ApiStatsCollector.java b/src/main/java/com/eactive/eai/custom/statistics/collector/ApiStatsCollector.java index c10a904..9d1180e 100644 --- a/src/main/java/com/eactive/eai/custom/statistics/collector/ApiStatsCollector.java +++ b/src/main/java/com/eactive/eai/custom/statistics/collector/ApiStatsCollector.java @@ -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 aggregators = new ConcurrentHashMap<>(); + private final ConcurrentHashMap minuteAggregators = new ConcurrentHashMap<>(); + + /** + * 시간별 집계 맵 + * Key: HourStatsKey (시간 + 차원들) + * Value: HourStatsAggregator (집계 데이터) + */ + private final ConcurrentHashMap 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 flushBefore(LocalDateTime beforeTime) { + public List flushMinuteBefore(LocalDateTime beforeTime) { LocalDateTime cutoffTime = beforeTime.truncatedTo(ChronoUnit.MINUTES); List result = new ArrayList<>(); // 대상 키 수집 및 스냅샷 생성 List 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 flushHourBefore(LocalDateTime beforeTime) { + LocalDateTime cutoffTime = beforeTime.truncatedTo(ChronoUnit.HOURS); + List result = new ArrayList<>(); + + // 대상 키 수집 및 스냅샷 생성 + List 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 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 flushAll() { + public List flushCompletedHours() { + // 현재 시간은 아직 진행 중이므로 제외 + LocalDateTime now = LocalDateTime.now(); + LocalDateTime currentHour = now.truncatedTo(ChronoUnit.HOURS); + + return flushHourBefore(currentHour); + } + + /** + * 모든 분단위 집계 데이터를 강제로 flush (종료 시 사용) + */ + public List flushAllMinutes() { List 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 flushAllHours() { + List 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(); } } diff --git a/src/main/java/com/eactive/eai/custom/statistics/collector/HourStatsAggregator.java b/src/main/java/com/eactive/eai/custom/statistics/collector/HourStatsAggregator.java new file mode 100644 index 0000000..0feb141 --- /dev/null +++ b/src/main/java/com/eactive/eai/custom/statistics/collector/HourStatsAggregator.java @@ -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(); + } +} diff --git a/src/main/java/com/eactive/eai/custom/statistics/collector/MinuteStatsAggregator.java b/src/main/java/com/eactive/eai/custom/statistics/collector/MinuteStatsAggregator.java index 15b0e97..794a19b 100644 --- a/src/main/java/com/eactive/eai/custom/statistics/collector/MinuteStatsAggregator.java +++ b/src/main/java/com/eactive/eai/custom/statistics/collector/MinuteStatsAggregator.java @@ -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 { diff --git a/src/main/java/com/eactive/eai/custom/statistics/config/ApiStatsConfig.java b/src/main/java/com/eactive/eai/custom/statistics/config/ApiStatsConfig.java index bdb7e36..1929b13 100644 --- a/src/main/java/com/eactive/eai/custom/statistics/config/ApiStatsConfig.java +++ b/src/main/java/com/eactive/eai/custom/statistics/config/ApiStatsConfig.java @@ -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()); } } diff --git a/src/main/java/com/eactive/eai/custom/statistics/dto/HourStatsKey.java b/src/main/java/com/eactive/eai/custom/statistics/dto/HourStatsKey.java new file mode 100644 index 0000000..5097ddb --- /dev/null +++ b/src/main/java/com/eactive/eai/custom/statistics/dto/HourStatsKey.java @@ -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); + } +} diff --git a/src/main/java/com/eactive/eai/custom/statistics/persistence/ApiStatsHourLogger.java b/src/main/java/com/eactive/eai/custom/statistics/persistence/ApiStatsHourLogger.java new file mode 100644 index 0000000..40bab27 --- /dev/null +++ b/src/main/java/com/eactive/eai/custom/statistics/persistence/ApiStatsHourLogger.java @@ -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 { + +} diff --git a/src/main/java/com/eactive/eai/custom/statistics/persistence/ApiStatsHourRepository.java b/src/main/java/com/eactive/eai/custom/statistics/persistence/ApiStatsHourRepository.java new file mode 100644 index 0000000..a4484b5 --- /dev/null +++ b/src/main/java/com/eactive/eai/custom/statistics/persistence/ApiStatsHourRepository.java @@ -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 { + +} diff --git a/src/main/java/com/eactive/eai/custom/statistics/scheduler/ApiStatsScheduler.java b/src/main/java/com/eactive/eai/custom/statistics/scheduler/ApiStatsScheduler.java index 3918e73..72adfe0 100644 --- a/src/main/java/com/eactive/eai/custom/statistics/scheduler/ApiStatsScheduler.java +++ b/src/main/java/com/eactive/eai/custom/statistics/scheduler/ApiStatsScheduler.java @@ -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 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 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 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 statsList = collector.flushAll(); - - if (!statsList.isEmpty()) { - for (ApiStatsMinute stats : statsList) { + // 분단위 통계 저장 + List 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 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) { diff --git a/src/main/java/com/eactive/eai/custom/statistics/메모리_사용량_예측.md b/src/main/java/com/eactive/eai/custom/statistics/메모리_사용량_예측.md new file mode 100644 index 0000000..1193a91 --- /dev/null +++ b/src/main/java/com/eactive/eai/custom/statistics/메모리_사용량_예측.md @@ -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됩니다. diff --git a/src/main/java/com/eactive/eai/data/entity/custom/statistics/ApiStatsHour.java b/src/main/java/com/eactive/eai/data/entity/custom/statistics/ApiStatsHour.java new file mode 100644 index 0000000..6636b0e --- /dev/null +++ b/src/main/java/com/eactive/eai/data/entity/custom/statistics/ApiStatsHour.java @@ -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; +} diff --git a/src/main/java/com/eactive/eai/data/entity/custom/statistics/ApiStatsHourId.java b/src/main/java/com/eactive/eai/data/entity/custom/statistics/ApiStatsHourId.java new file mode 100644 index 0000000..a4cc8a7 --- /dev/null +++ b/src/main/java/com/eactive/eai/data/entity/custom/statistics/ApiStatsHourId.java @@ -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; +}