feat: Dual 이중 버킷 유량제어 패키지 추가 (DJErp→Dual 리네임·이관)

- InflowType 열거형 추가 (ADAPTER/INTERFACE/CLIENT 타입 코드 캡슐화)
- InflowControlDAO: InflowType 기반 범용 메서드 추가, 기존 메서드 위임
- RequestProcessor: private→protected 전환, 클라이언트/어댑터/인터페이스
  유량제어 훅 메서드 5개 추가 (checkClientInflow 등)
- dual 패키지 신규 추가
  · DualCustomBucket: perSecond/threshold 이중 버킷, 차단 원인 구분
  · DualBucket: isAdapterPassDetail 등 차단 원인 반환 인터페이스
  · DualInflowControlManager: 그룹 메트릭 카운팅(TargetMetrics), 리로드 연동
  · DualRequestProcessor: 클라이언트 유량제어 + 상세 오류 메시지 훅 구현
- 단위 테스트 5종 추가 (DualCustomBucket, Manager, Metrics, RequestProcessor, Concurrency)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
curry772
2026-04-27 09:40:27 +09:00
parent 632550220d
commit a4c9a2f2bd
12 changed files with 1842 additions and 14 deletions
@@ -0,0 +1,164 @@
package com.eactive.eai.common.inflow.dual;
import static org.junit.jupiter.api.Assertions.*;
import java.time.Duration;
import org.junit.jupiter.api.Test;
import com.eactive.eai.common.inflow.InflowTargetVO;
import io.github.bucket4j.Bandwidth;
import io.github.bucket4j.Bucket4j;
import io.github.bucket4j.local.LocalBucket;
class DualCustomBucketTest {
private InflowTargetVO makeVO(String name, long perSec, long threshold, String unit) {
InflowTargetVO vo = new InflowTargetVO();
vo.setName(name);
vo.setThresholdPerSecond(perSec);
vo.setThreshold(threshold);
vo.setThresholdTimeUnit(unit);
vo.setActivate(true);
return vo;
}
private LocalBucket freshBucket(long capacity) {
return Bucket4j.builder()
.addLimit(Bandwidth.simple(capacity, Duration.ofSeconds(1)))
.build();
}
private LocalBucket exhaustedBucket(long capacity) {
LocalBucket b = freshBucket(capacity);
b.tryConsume(capacity);
return b;
}
// -------------------------------------------------------------------------
// tryConsume — 통과 케이스
// -------------------------------------------------------------------------
@Test
void bothBucketsNull_returnsPass() {
DualCustomBucket bucket = new DualCustomBucket(null, null, makeVO("A", 0, 0, null));
assertNull(bucket.tryConsume());
}
@Test
void onlyPerSecond_withinLimit_returnsPass() {
DualCustomBucket bucket = new DualCustomBucket(freshBucket(5), null, makeVO("A", 5, 0, null));
assertNull(bucket.tryConsume());
}
@Test
void onlyThreshold_withinLimit_returnsPass() {
DualCustomBucket bucket = new DualCustomBucket(null, freshBucket(100), makeVO("A", 0, 100, "MIN"));
assertNull(bucket.tryConsume());
}
@Test
void bothBuckets_bothWithinLimit_returnsPass() {
DualCustomBucket bucket = new DualCustomBucket(freshBucket(10), freshBucket(100), makeVO("A", 10, 100, "MIN"));
assertNull(bucket.tryConsume());
}
// -------------------------------------------------------------------------
// tryConsume — 차단 케이스
// -------------------------------------------------------------------------
@Test
void onlyPerSecond_exceeded_returnsBlockedPerSecond() {
DualCustomBucket bucket = new DualCustomBucket(exhaustedBucket(1), null, makeVO("A", 1, 0, null));
assertEquals(DualCustomBucket.RESULT_BLOCKED_PER_SECOND, bucket.tryConsume());
}
@Test
void onlyThreshold_exceeded_returnsBlockedThreshold() {
DualCustomBucket bucket = new DualCustomBucket(null, exhaustedBucket(1), makeVO("A", 0, 1, "MIN"));
assertEquals(DualCustomBucket.RESULT_BLOCKED_THRESHOLD, bucket.tryConsume());
}
@Test
void perSecondExceeded_thresholdRemaining_returnsBlockedPerSecond() {
DualCustomBucket bucket = new DualCustomBucket(exhaustedBucket(1), freshBucket(100), makeVO("A", 1, 100, "MIN"));
assertEquals(DualCustomBucket.RESULT_BLOCKED_PER_SECOND, bucket.tryConsume());
}
@Test
void perSecondRemaining_thresholdExceeded_returnsBlockedThreshold() {
DualCustomBucket bucket = new DualCustomBucket(freshBucket(10), exhaustedBucket(1), makeVO("A", 10, 1, "MIN"));
assertEquals(DualCustomBucket.RESULT_BLOCKED_THRESHOLD, bucket.tryConsume());
}
@Test
void bothExceeded_returnsBlockedPerSecond() {
DualCustomBucket bucket = new DualCustomBucket(exhaustedBucket(1), exhaustedBucket(1), makeVO("A", 1, 1, "MIN"));
assertEquals(DualCustomBucket.RESULT_BLOCKED_PER_SECOND, bucket.tryConsume());
}
// -------------------------------------------------------------------------
// tryConsume — 연속 호출
// -------------------------------------------------------------------------
@Test
void consecutiveConsumes_blockedAfterCapacityExhausted() {
DualCustomBucket bucket = new DualCustomBucket(freshBucket(3), null, makeVO("A", 3, 0, null));
assertNull(bucket.tryConsume());
assertNull(bucket.tryConsume());
assertNull(bucket.tryConsume());
assertEquals(DualCustomBucket.RESULT_BLOCKED_PER_SECOND, bucket.tryConsume());
}
@Test
void bothBuckets_thresholdExhaustedAfterMultiplePasses() {
DualCustomBucket bucket = new DualCustomBucket(freshBucket(10), freshBucket(3), makeVO("A", 10, 3, "MIN"));
assertNull(bucket.tryConsume());
assertNull(bucket.tryConsume());
assertNull(bucket.tryConsume());
assertEquals(DualCustomBucket.RESULT_BLOCKED_THRESHOLD, bucket.tryConsume());
}
// -------------------------------------------------------------------------
// 가용 토큰 수 조회
// -------------------------------------------------------------------------
@Test
void nullBuckets_availableTokensReturnMinusOne() {
DualCustomBucket bucket = new DualCustomBucket(null, null, makeVO("A", 0, 0, null));
assertEquals(-1, bucket.getPerSecondAvailableTokens());
assertEquals(-1, bucket.getThresholdAvailableTokens());
}
@Test
void availableTokensReflectInitialCapacity() {
DualCustomBucket bucket = new DualCustomBucket(freshBucket(5), freshBucket(100), makeVO("A", 5, 100, "MIN"));
assertEquals(5, bucket.getPerSecondAvailableTokens());
assertEquals(100, bucket.getThresholdAvailableTokens());
}
@Test
void availableTokensDecrementAfterEachConsume() {
DualCustomBucket bucket = new DualCustomBucket(freshBucket(5), freshBucket(10), makeVO("A", 5, 10, "MIN"));
bucket.tryConsume();
assertEquals(4, bucket.getPerSecondAvailableTokens());
assertEquals(9, bucket.getThresholdAvailableTokens());
bucket.tryConsume();
assertEquals(3, bucket.getPerSecondAvailableTokens());
assertEquals(8, bucket.getThresholdAvailableTokens());
}
@Test
void perSecondBlocked_thresholdTokenNotConsumed() {
DualCustomBucket bucket = new DualCustomBucket(exhaustedBucket(1), freshBucket(10), makeVO("A", 1, 10, "MIN"));
long thresholdBefore = bucket.getThresholdAvailableTokens();
bucket.tryConsume();
assertEquals(thresholdBefore, bucket.getThresholdAvailableTokens());
}
@Test
void resultPassConstant_isNull() {
assertNull(DualCustomBucket.RESULT_PASS);
}
}
@@ -0,0 +1,344 @@
package com.eactive.eai.common.inflow.dual;
import static org.junit.jupiter.api.Assertions.*;
import java.time.Duration;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import com.eactive.eai.common.inflow.InflowTargetVO;
import io.github.bucket4j.Bandwidth;
import io.github.bucket4j.Bucket4j;
import io.github.bucket4j.local.LocalBucket;
/**
* DualCustomBucket / DualInflowControlManager 동시성 테스트.
*
* <p>모든 버킷을 Duration.ofHours(1)로 생성하여 테스트 실행 중 리필이 없도록 한다.
* CountDownLatch로 모든 스레드를 동시 출발시켜 race window를 최대화한다.
*/
class DualInflowControlConcurrencyTest {
private static final int THREAD_COUNT = 20;
private static final int TRIES_PER_THREAD = 50;
private static final int TOTAL_TRIES = THREAD_COUNT * TRIES_PER_THREAD; // 1000
private DualInflowControlManager manager;
@BeforeEach
void setUp() {
manager = new DualInflowControlManager();
}
// -------------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------------
private InflowTargetVO makeVO(String name, long perSec, long threshold, String unit) {
InflowTargetVO vo = new InflowTargetVO();
vo.setName(name);
vo.setThresholdPerSecond(perSec);
vo.setThreshold(threshold);
vo.setThresholdTimeUnit(unit);
vo.setActivate(true);
return vo;
}
private LocalBucket stableBucket(long capacity) {
return Bucket4j.builder()
.addLimit(Bandwidth.simple(capacity, Duration.ofHours(1)))
.build();
}
private boolean runConcurrently(int threads, int triesPerThread, Runnable work)
throws InterruptedException {
CountDownLatch ready = new CountDownLatch(threads);
CountDownLatch start = new CountDownLatch(1);
CountDownLatch done = new CountDownLatch(threads);
ExecutorService executor = Executors.newFixedThreadPool(threads);
for (int i = 0; i < threads; i++) {
executor.submit(() -> {
ready.countDown();
try {
start.await();
for (int j = 0; j < triesPerThread; j++) {
work.run();
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
done.countDown();
}
});
}
ready.await();
start.countDown();
boolean completed = done.await(30, TimeUnit.SECONDS);
executor.shutdown();
return completed;
}
// =========================================================================
// DualCustomBucket 동시성 테스트
// =========================================================================
@Test
void perSecondOnly_totalPassEqualsCapacity() throws InterruptedException {
long capacity = 200;
DualCustomBucket bucket = new DualCustomBucket(
stableBucket(capacity), null, makeVO("A", capacity, 0, null));
AtomicInteger passCount = new AtomicInteger();
AtomicInteger blockedPerSecond = new AtomicInteger();
AtomicInteger blockedThreshold = new AtomicInteger();
boolean done = runConcurrently(THREAD_COUNT, TRIES_PER_THREAD, () -> {
String result = bucket.tryConsume();
if (result == null) passCount.incrementAndGet();
else if (DualCustomBucket.RESULT_BLOCKED_PER_SECOND.equals(result)) blockedPerSecond.incrementAndGet();
else blockedThreshold.incrementAndGet();
});
assertTrue(done, "테스트가 타임아웃되었습니다");
assertEquals(TOTAL_TRIES, passCount.get() + blockedPerSecond.get() + blockedThreshold.get());
assertEquals(capacity, passCount.get());
assertEquals(0, blockedThreshold.get());
}
@Test
void thresholdOnly_totalPassEqualsCapacity() throws InterruptedException {
long capacity = 200;
DualCustomBucket bucket = new DualCustomBucket(
null, stableBucket(capacity), makeVO("A", 0, capacity, "HOUR"));
AtomicInteger passCount = new AtomicInteger();
AtomicInteger blockedThreshold = new AtomicInteger();
boolean done = runConcurrently(THREAD_COUNT, TRIES_PER_THREAD, () -> {
String result = bucket.tryConsume();
if (result == null) passCount.incrementAndGet();
else if (DualCustomBucket.RESULT_BLOCKED_THRESHOLD.equals(result)) blockedThreshold.incrementAndGet();
});
assertTrue(done, "테스트가 타임아웃되었습니다");
assertEquals(TOTAL_TRIES, passCount.get() + blockedThreshold.get());
assertEquals(capacity, passCount.get());
}
@Test
void dualBuckets_thresholdIsBottleneck_totalPassEqualsThreshold() throws InterruptedException {
long perSecCapacity = 2000;
long threshCapacity = 200;
DualCustomBucket bucket = new DualCustomBucket(
stableBucket(perSecCapacity), stableBucket(threshCapacity),
makeVO("A", perSecCapacity, threshCapacity, "HOUR"));
AtomicInteger passCount = new AtomicInteger();
AtomicInteger blockedPerSecond = new AtomicInteger();
AtomicInteger blockedThreshold = new AtomicInteger();
boolean done = runConcurrently(THREAD_COUNT, TRIES_PER_THREAD, () -> {
String result = bucket.tryConsume();
if (result == null) passCount.incrementAndGet();
else if (DualCustomBucket.RESULT_BLOCKED_PER_SECOND.equals(result)) blockedPerSecond.incrementAndGet();
else blockedThreshold.incrementAndGet();
});
assertTrue(done, "테스트가 타임아웃되었습니다");
assertEquals(TOTAL_TRIES, passCount.get() + blockedPerSecond.get() + blockedThreshold.get());
assertEquals(threshCapacity, passCount.get());
assertEquals(0, blockedPerSecond.get());
}
@Test
void dualBuckets_perSecondIsBottleneck_noThresholdTokenLeak() throws InterruptedException {
long perSecCapacity = 100;
long threshCapacity = 2000;
DualCustomBucket bucket = new DualCustomBucket(
stableBucket(perSecCapacity), stableBucket(threshCapacity),
makeVO("A", perSecCapacity, threshCapacity, "HOUR"));
AtomicInteger passCount = new AtomicInteger();
AtomicInteger blockedPerSecond = new AtomicInteger();
AtomicInteger blockedThreshold = new AtomicInteger();
boolean done = runConcurrently(THREAD_COUNT, TRIES_PER_THREAD, () -> {
String result = bucket.tryConsume();
if (result == null) passCount.incrementAndGet();
else if (DualCustomBucket.RESULT_BLOCKED_PER_SECOND.equals(result)) blockedPerSecond.incrementAndGet();
else blockedThreshold.incrementAndGet();
});
assertTrue(done, "테스트가 타임아웃되었습니다");
assertEquals(TOTAL_TRIES, passCount.get() + blockedPerSecond.get() + blockedThreshold.get());
assertEquals(perSecCapacity, passCount.get());
assertEquals(0, blockedThreshold.get());
assertEquals(perSecCapacity, threshCapacity - bucket.getThresholdAvailableTokens());
}
@Test
void availableTokensConsistentAfterConcurrentConsume() throws InterruptedException {
long capacity = 300;
DualCustomBucket bucket = new DualCustomBucket(
stableBucket(capacity), null, makeVO("A", capacity, 0, null));
AtomicInteger passCount = new AtomicInteger();
runConcurrently(THREAD_COUNT, TRIES_PER_THREAD, () -> {
if (bucket.tryConsume() == null) passCount.incrementAndGet();
});
long remaining = bucket.getPerSecondAvailableTokens();
assertEquals(capacity, passCount.get() + remaining);
}
// =========================================================================
// DualInflowControlManager 동시성 테스트
// =========================================================================
@Test
void manager_isAdapterPassDetail_concurrent_totalPassEqualsCapacity() throws InterruptedException {
long capacity = 200;
Map<String, DualCustomBucket> map = new ConcurrentHashMap<>();
map.put("ADAPTER_A", new DualCustomBucket(
stableBucket(capacity), null, makeVO("ADAPTER_A", capacity, 0, null)));
ReflectionTestUtils.setField(manager, "adapterBucketMap", map);
AtomicInteger passCount = new AtomicInteger();
AtomicInteger blockCount = new AtomicInteger();
boolean done = runConcurrently(THREAD_COUNT, TRIES_PER_THREAD, () -> {
String result = manager.isAdapterPassDetail("ADAPTER_A");
if (result == null) passCount.incrementAndGet();
else blockCount.incrementAndGet();
});
assertTrue(done, "테스트가 타임아웃되었습니다");
assertEquals(TOTAL_TRIES, passCount.get() + blockCount.get());
assertEquals(capacity, passCount.get());
}
@Test
void manager_isInterfacePassDetail_concurrent_totalPassEqualsCapacity() throws InterruptedException {
long capacity = 200;
Map<String, DualCustomBucket> map = new ConcurrentHashMap<>();
map.put("IF_001", new DualCustomBucket(
null, stableBucket(capacity), makeVO("IF_001", 0, capacity, "HOUR")));
ReflectionTestUtils.setField(manager, "interfaceBucketMap", map);
AtomicInteger passCount = new AtomicInteger();
AtomicInteger blockCount = new AtomicInteger();
boolean done = runConcurrently(THREAD_COUNT, TRIES_PER_THREAD, () -> {
String result = manager.isInterfacePassDetail("IF_001");
if (result == null) passCount.incrementAndGet();
else blockCount.incrementAndGet();
});
assertTrue(done, "테스트가 타임아웃되었습니다");
assertEquals(TOTAL_TRIES, passCount.get() + blockCount.get());
assertEquals(capacity, passCount.get());
}
@Test
void manager_volatileMapSwap_noConcurrentException() throws InterruptedException {
String adapterName = "ADAPTER_A";
int readerCount = THREAD_COUNT;
int swapCount = 200;
Map<String, DualCustomBucket> initialMap = new ConcurrentHashMap<>();
initialMap.put(adapterName, new DualCustomBucket(
stableBucket(10_000), null, makeVO(adapterName, 10_000, 0, null)));
ReflectionTestUtils.setField(manager, "adapterBucketMap", initialMap);
AtomicReference<Throwable> error = new AtomicReference<>();
CountDownLatch ready = new CountDownLatch(readerCount + 1);
CountDownLatch start = new CountDownLatch(1);
CountDownLatch done = new CountDownLatch(readerCount + 1);
ExecutorService executor = Executors.newFixedThreadPool(readerCount + 1);
for (int i = 0; i < readerCount; i++) {
executor.submit(() -> {
ready.countDown();
try {
start.await();
for (int j = 0; j < TRIES_PER_THREAD; j++) {
manager.isAdapterPassDetail(adapterName);
}
} catch (Throwable t) {
error.compareAndSet(null, t);
} finally {
done.countDown();
}
});
}
executor.submit(() -> {
ready.countDown();
try {
start.await();
for (int i = 0; i < swapCount; i++) {
Map<String, DualCustomBucket> newMap = new ConcurrentHashMap<>();
newMap.put(adapterName, new DualCustomBucket(
stableBucket(10_000), null, makeVO(adapterName, 10_000, 0, null)));
ReflectionTestUtils.setField(manager, "adapterBucketMap", newMap);
}
} catch (Throwable t) {
error.compareAndSet(null, t);
} finally {
done.countDown();
}
});
ready.await();
start.countDown();
boolean completed = done.await(30, TimeUnit.SECONDS);
executor.shutdown();
assertTrue(completed, "테스트가 타임아웃되었습니다");
assertNull(error.get(), error.get() != null ? "예외 발생: " + error.get() : null);
}
@Test
void manager_mixedPassAndDetailCalls_totalPassConsistent() throws InterruptedException {
long capacity = 300;
Map<String, DualCustomBucket> map = new ConcurrentHashMap<>();
map.put("ADAPTER_A", new DualCustomBucket(
stableBucket(capacity), null, makeVO("ADAPTER_A", capacity, 0, null)));
ReflectionTestUtils.setField(manager, "adapterBucketMap", map);
AtomicInteger passCount = new AtomicInteger();
AtomicInteger blockCount = new AtomicInteger();
AtomicInteger callIndex = new AtomicInteger();
boolean done = runConcurrently(THREAD_COUNT, TRIES_PER_THREAD, () -> {
boolean useDetail = (callIndex.getAndIncrement() % 2 == 0);
boolean passed;
if (useDetail) {
passed = (manager.isAdapterPassDetail("ADAPTER_A") == null);
} else {
passed = manager.isAdapterPass("ADAPTER_A");
}
if (passed) passCount.incrementAndGet();
else blockCount.incrementAndGet();
});
assertTrue(done, "테스트가 타임아웃되었습니다");
assertEquals(TOTAL_TRIES, passCount.get() + blockCount.get());
assertEquals(capacity, passCount.get());
}
}
@@ -0,0 +1,318 @@
package com.eactive.eai.common.inflow.dual;
import static org.junit.jupiter.api.Assertions.*;
import java.time.Duration;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import com.eactive.eai.common.inflow.CustomGroupBucket;
import com.eactive.eai.common.inflow.InflowGroupVO;
import io.github.bucket4j.Bandwidth;
import io.github.bucket4j.Bucket4j;
import io.github.bucket4j.local.LocalBucket;
/**
* DualInflowControlManager 그룹 메트릭 단위 테스트.
*
* <p>start() 없이 groupBucketList를 ReflectionTestUtils로 직접 주입하여
* isGroupPass() 카운팅과 getGroupMetrics / reset 메서드를 검증한다.
*/
class DualInflowControlManagerMetricsTest {
private DualInflowControlManager manager;
@BeforeEach
void setUp() {
manager = new DualInflowControlManager();
}
// -------------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------------
private InflowGroupVO makeGroupVO(String groupId, String groupName) {
InflowGroupVO vo = new InflowGroupVO();
vo.setGroupId(groupId);
vo.setGroupName(groupName);
vo.setActivate(true);
return vo;
}
private LocalBucket stableBucket(long capacity) {
return Bucket4j.builder()
.addLimit(Bandwidth.simple(capacity, Duration.ofHours(1)))
.build();
}
private LocalBucket exhaustedBucket(long capacity) {
LocalBucket b = stableBucket(capacity);
b.tryConsume(capacity);
return b;
}
private CustomGroupBucket passBucket(String groupId) {
return new CustomGroupBucket(stableBucket(100), null, makeGroupVO(groupId, groupId + "-name"));
}
private CustomGroupBucket blockedPerSecondBucket(String groupId) {
return new CustomGroupBucket(exhaustedBucket(1), null, makeGroupVO(groupId, groupId + "-name"));
}
private CustomGroupBucket blockedThresholdBucket(String groupId) {
return new CustomGroupBucket(stableBucket(100), exhaustedBucket(1), makeGroupVO(groupId, groupId + "-name"));
}
private void injectGroupBucketList(Map<String, CustomGroupBucket> map) {
ReflectionTestUtils.setField(manager, "groupBucketList", map);
}
// =========================================================================
// TargetMetrics 내부 클래스
// =========================================================================
@Test
void targetMetrics_initialState_allCountersZero() {
DualInflowControlManager.TargetMetrics m = new DualInflowControlManager.TargetMetrics();
assertEquals(0, m.allowed.get());
assertEquals(0, m.rejectedPerSecond.get());
assertEquals(0, m.rejectedThreshold.get());
assertTrue(m.lastResetTime > 0);
}
@Test
void targetMetrics_reset_countersZeroAndResetTimeUpdated() throws InterruptedException {
DualInflowControlManager.TargetMetrics m = new DualInflowControlManager.TargetMetrics();
m.allowed.set(50);
m.rejectedPerSecond.set(10);
m.rejectedThreshold.set(5);
long before = System.currentTimeMillis();
Thread.sleep(1);
m.reset();
assertEquals(0, m.allowed.get());
assertEquals(0, m.rejectedPerSecond.get());
assertEquals(0, m.rejectedThreshold.get());
assertTrue(m.lastResetTime >= before);
}
// =========================================================================
// isGroupPass() — 카운터 증가
// =========================================================================
@Test
void isGroupPass_groupNotInBucketList_incrementsAllowedAndReturnsNull() {
String result = manager.isGroupPass("UNKNOWN_G");
assertNull(result);
DualInflowControlManager.TargetMetrics m = manager.getGroupMetrics("UNKNOWN_G");
assertNotNull(m);
assertEquals(1, m.allowed.get());
assertEquals(0, m.rejectedPerSecond.get());
assertEquals(0, m.rejectedThreshold.get());
}
@Test
void isGroupPass_pass_incrementsAllowed() {
Map<String, CustomGroupBucket> map = new ConcurrentHashMap<>();
map.put("G1", passBucket("G1"));
injectGroupBucketList(map);
String result = manager.isGroupPass("G1");
assertNull(result);
DualInflowControlManager.TargetMetrics m = manager.getGroupMetrics("G1");
assertEquals(1, m.allowed.get());
assertEquals(0, m.rejectedPerSecond.get());
assertEquals(0, m.rejectedThreshold.get());
}
@Test
void isGroupPass_blockedPerSecond_incrementsRejectedPerSecond() {
Map<String, CustomGroupBucket> map = new ConcurrentHashMap<>();
map.put("G1", blockedPerSecondBucket("G1"));
injectGroupBucketList(map);
String result = manager.isGroupPass("G1");
assertEquals(CustomGroupBucket.RESULT_BLOCKED_PER_SECOND, result);
DualInflowControlManager.TargetMetrics m = manager.getGroupMetrics("G1");
assertEquals(0, m.allowed.get());
assertEquals(1, m.rejectedPerSecond.get());
assertEquals(0, m.rejectedThreshold.get());
}
@Test
void isGroupPass_blockedThreshold_incrementsRejectedThreshold() {
Map<String, CustomGroupBucket> map = new ConcurrentHashMap<>();
map.put("G1", blockedThresholdBucket("G1"));
injectGroupBucketList(map);
String result = manager.isGroupPass("G1");
assertEquals(CustomGroupBucket.RESULT_BLOCKED_THRESHOLD, result);
DualInflowControlManager.TargetMetrics m = manager.getGroupMetrics("G1");
assertEquals(0, m.allowed.get());
assertEquals(0, m.rejectedPerSecond.get());
assertEquals(1, m.rejectedThreshold.get());
}
@Test
void isGroupPass_multipleCalls_countersAccumulate() {
Map<String, CustomGroupBucket> map = new ConcurrentHashMap<>();
LocalBucket b = stableBucket(5);
map.put("G1", new CustomGroupBucket(b, null, makeGroupVO("G1", "G1-name")));
injectGroupBucketList(map);
for (int i = 0; i < 8; i++) {
manager.isGroupPass("G1");
}
DualInflowControlManager.TargetMetrics m = manager.getGroupMetrics("G1");
assertEquals(5, m.allowed.get());
assertEquals(3, m.rejectedPerSecond.get());
assertEquals(8, m.allowed.get() + m.rejectedPerSecond.get() + m.rejectedThreshold.get());
}
@Test
void isGroupPass_differentGroups_independentMetrics() {
Map<String, CustomGroupBucket> map = new ConcurrentHashMap<>();
map.put("G1", passBucket("G1"));
map.put("G2", blockedPerSecondBucket("G2"));
injectGroupBucketList(map);
manager.isGroupPass("G1");
manager.isGroupPass("G1");
manager.isGroupPass("G2");
assertEquals(2, manager.getGroupMetrics("G1").allowed.get());
assertEquals(0, manager.getGroupMetrics("G1").rejectedPerSecond.get());
assertEquals(0, manager.getGroupMetrics("G2").allowed.get());
assertEquals(1, manager.getGroupMetrics("G2").rejectedPerSecond.get());
}
// =========================================================================
// getGroupMetrics / getAllGroupMetrics
// =========================================================================
@Test
void getGroupMetrics_beforeAnyCall_returnsNull() {
assertNull(manager.getGroupMetrics("G1"));
}
@Test
void getGroupMetrics_afterCall_returnsNonNull() {
manager.isGroupPass("G1");
assertNotNull(manager.getGroupMetrics("G1"));
}
@Test
void getAllGroupMetrics_empty_returnsEmptyMap() {
assertTrue(manager.getAllGroupMetrics().isEmpty());
}
@Test
void getAllGroupMetrics_afterMultipleGroups_containsAll() {
manager.isGroupPass("G1");
manager.isGroupPass("G2");
manager.isGroupPass("G3");
Map<String, DualInflowControlManager.TargetMetrics> all = manager.getAllGroupMetrics();
assertEquals(3, all.size());
assertTrue(all.containsKey("G1"));
assertTrue(all.containsKey("G2"));
assertTrue(all.containsKey("G3"));
}
@Test
void getAllGroupMetrics_returnsUnmodifiableView() {
manager.isGroupPass("G1");
Map<String, DualInflowControlManager.TargetMetrics> all = manager.getAllGroupMetrics();
assertThrows(UnsupportedOperationException.class, () -> all.put("NEW", null));
}
// =========================================================================
// resetGroupMetrics / resetAllGroupMetrics
// =========================================================================
@Test
void resetGroupMetrics_resetsTargetGroup() {
Map<String, CustomGroupBucket> map = new ConcurrentHashMap<>();
map.put("G1", passBucket("G1"));
injectGroupBucketList(map);
manager.isGroupPass("G1");
manager.isGroupPass("G1");
assertEquals(2, manager.getGroupMetrics("G1").allowed.get());
manager.resetGroupMetrics("G1");
DualInflowControlManager.TargetMetrics m = manager.getGroupMetrics("G1");
assertEquals(0, m.allowed.get());
assertEquals(0, m.rejectedPerSecond.get());
assertEquals(0, m.rejectedThreshold.get());
}
@Test
void resetGroupMetrics_updatesLastResetTime() throws InterruptedException {
manager.isGroupPass("G1");
long timeBefore = manager.getGroupMetrics("G1").lastResetTime;
Thread.sleep(1);
manager.resetGroupMetrics("G1");
assertTrue(manager.getGroupMetrics("G1").lastResetTime >= timeBefore);
}
@Test
void resetGroupMetrics_nonExistentGroup_noException() {
assertDoesNotThrow(() -> manager.resetGroupMetrics("NON_EXISTENT"));
}
@Test
void resetGroupMetrics_doesNotAffectOtherGroups() {
manager.isGroupPass("G1");
manager.isGroupPass("G2");
manager.resetGroupMetrics("G1");
assertEquals(0, manager.getGroupMetrics("G1").allowed.get());
assertEquals(1, manager.getGroupMetrics("G2").allowed.get());
}
@Test
void resetAllGroupMetrics_resetsAllGroups() {
manager.isGroupPass("G1");
manager.isGroupPass("G1");
manager.isGroupPass("G2");
manager.resetAllGroupMetrics();
assertEquals(0, manager.getGroupMetrics("G1").allowed.get());
assertEquals(0, manager.getGroupMetrics("G2").allowed.get());
}
@Test
void resetAllGroupMetrics_emptyMap_noException() {
assertDoesNotThrow(() -> manager.resetAllGroupMetrics());
}
@Test
void resetAllGroupMetrics_preservesKeys() {
manager.isGroupPass("G1");
manager.isGroupPass("G2");
manager.resetAllGroupMetrics();
assertNotNull(manager.getGroupMetrics("G1"));
assertNotNull(manager.getGroupMetrics("G2"));
}
}
@@ -0,0 +1,244 @@
package com.eactive.eai.common.inflow.dual;
import static org.junit.jupiter.api.Assertions.*;
import java.time.Duration;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import com.eactive.eai.common.inflow.InflowTargetVO;
import io.github.bucket4j.Bandwidth;
import io.github.bucket4j.Bucket4j;
import io.github.bucket4j.local.LocalBucket;
/**
* DualInflowControlManager 단위 테스트.
*
* <p>start() 호출 없이 adapterBucketMap / interfaceBucketMap을
* ReflectionTestUtils로 직접 주입하여 detail 메서드만 검증한다.
*/
class DualInflowControlManagerTest {
private DualInflowControlManager manager;
@BeforeEach
void setUp() {
manager = new DualInflowControlManager();
}
// -------------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------------
private InflowTargetVO makeVO(String name) {
InflowTargetVO vo = new InflowTargetVO();
vo.setName(name);
vo.setThresholdPerSecond(10);
vo.setThreshold(100);
vo.setThresholdTimeUnit("MIN");
vo.setActivate(true);
return vo;
}
private LocalBucket freshBucket(long capacity) {
return Bucket4j.builder()
.addLimit(Bandwidth.simple(capacity, Duration.ofSeconds(1)))
.build();
}
private LocalBucket exhaustedBucket(long capacity) {
LocalBucket b = freshBucket(capacity);
b.tryConsume(capacity);
return b;
}
private DualCustomBucket passBucket(String name) {
return new DualCustomBucket(freshBucket(10), freshBucket(100), makeVO(name));
}
private DualCustomBucket blockedPerSecondBucket(String name) {
return new DualCustomBucket(exhaustedBucket(1), freshBucket(100), makeVO(name));
}
private DualCustomBucket blockedThresholdBucket(String name) {
return new DualCustomBucket(freshBucket(10), exhaustedBucket(1), makeVO(name));
}
private void injectAdapterMap(Map<String, DualCustomBucket> map) {
ReflectionTestUtils.setField(manager, "adapterBucketMap", map);
}
private void injectInterfaceMap(Map<String, DualCustomBucket> map) {
ReflectionTestUtils.setField(manager, "interfaceBucketMap", map);
}
// -------------------------------------------------------------------------
// isAdapterPassDetail
// -------------------------------------------------------------------------
@Test
void isAdapterPassDetail_adapterNotInMap_returnsNull() {
assertNull(manager.isAdapterPassDetail("UNKNOWN"));
}
@Test
void isAdapterPassDetail_adapterInMap_pass_returnsNull() {
Map<String, DualCustomBucket> map = new ConcurrentHashMap<>();
map.put("ADAPTER_A", passBucket("ADAPTER_A"));
injectAdapterMap(map);
assertNull(manager.isAdapterPassDetail("ADAPTER_A"));
}
@Test
void isAdapterPassDetail_adapterInMap_blockedPerSecond() {
Map<String, DualCustomBucket> map = new ConcurrentHashMap<>();
map.put("ADAPTER_A", blockedPerSecondBucket("ADAPTER_A"));
injectAdapterMap(map);
assertEquals(DualCustomBucket.RESULT_BLOCKED_PER_SECOND,
manager.isAdapterPassDetail("ADAPTER_A"));
}
@Test
void isAdapterPassDetail_adapterInMap_blockedThreshold() {
Map<String, DualCustomBucket> map = new ConcurrentHashMap<>();
map.put("ADAPTER_A", blockedThresholdBucket("ADAPTER_A"));
injectAdapterMap(map);
assertEquals(DualCustomBucket.RESULT_BLOCKED_THRESHOLD,
manager.isAdapterPassDetail("ADAPTER_A"));
}
// -------------------------------------------------------------------------
// isInterfacePassDetail
// -------------------------------------------------------------------------
@Test
void isInterfacePassDetail_interfaceNotInMap_returnsNull() {
assertNull(manager.isInterfacePassDetail("UNKNOWN"));
}
@Test
void isInterfacePassDetail_interfaceInMap_pass_returnsNull() {
Map<String, DualCustomBucket> map = new ConcurrentHashMap<>();
map.put("IF_001", passBucket("IF_001"));
injectInterfaceMap(map);
assertNull(manager.isInterfacePassDetail("IF_001"));
}
@Test
void isInterfacePassDetail_interfaceInMap_blockedPerSecond() {
Map<String, DualCustomBucket> map = new ConcurrentHashMap<>();
map.put("IF_001", blockedPerSecondBucket("IF_001"));
injectInterfaceMap(map);
assertEquals(DualCustomBucket.RESULT_BLOCKED_PER_SECOND,
manager.isInterfacePassDetail("IF_001"));
}
@Test
void isInterfacePassDetail_interfaceInMap_blockedThreshold() {
Map<String, DualCustomBucket> map = new ConcurrentHashMap<>();
map.put("IF_001", blockedThresholdBucket("IF_001"));
injectInterfaceMap(map);
assertEquals(DualCustomBucket.RESULT_BLOCKED_THRESHOLD,
manager.isInterfacePassDetail("IF_001"));
}
// -------------------------------------------------------------------------
// isAdapterPass — boolean 래퍼
// -------------------------------------------------------------------------
@Test
void isAdapterPass_notInMap_returnsTrue() {
assertTrue(manager.isAdapterPass("UNKNOWN"));
}
@Test
void isAdapterPass_pass_returnsTrue() {
Map<String, DualCustomBucket> map = new ConcurrentHashMap<>();
map.put("ADAPTER_A", passBucket("ADAPTER_A"));
injectAdapterMap(map);
assertTrue(manager.isAdapterPass("ADAPTER_A"));
}
@Test
void isAdapterPass_blocked_returnsFalse() {
Map<String, DualCustomBucket> map = new ConcurrentHashMap<>();
map.put("ADAPTER_A", blockedPerSecondBucket("ADAPTER_A"));
injectAdapterMap(map);
assertFalse(manager.isAdapterPass("ADAPTER_A"));
}
// -------------------------------------------------------------------------
// isInterfacePass — boolean 래퍼
// -------------------------------------------------------------------------
@Test
void isInterfacePass_notInMap_returnsTrue() {
assertTrue(manager.isInterfacePass("UNKNOWN"));
}
@Test
void isInterfacePass_pass_returnsTrue() {
Map<String, DualCustomBucket> map = new ConcurrentHashMap<>();
map.put("IF_001", passBucket("IF_001"));
injectInterfaceMap(map);
assertTrue(manager.isInterfacePass("IF_001"));
}
@Test
void isInterfacePass_blocked_returnsFalse() {
Map<String, DualCustomBucket> map = new ConcurrentHashMap<>();
map.put("IF_001", blockedThresholdBucket("IF_001"));
injectInterfaceMap(map);
assertFalse(manager.isInterfacePass("IF_001"));
}
// -------------------------------------------------------------------------
// 모니터링 접근자
// -------------------------------------------------------------------------
@Test
void getAdapterBucket_returnsCorrectBucket() {
Map<String, DualCustomBucket> map = new ConcurrentHashMap<>();
DualCustomBucket expected = passBucket("ADAPTER_A");
map.put("ADAPTER_A", expected);
injectAdapterMap(map);
assertSame(expected, manager.getAdapterBucket("ADAPTER_A"));
assertNull(manager.getAdapterBucket("UNKNOWN"));
}
@Test
void getInterfaceBucket_returnsCorrectBucket() {
Map<String, DualCustomBucket> map = new ConcurrentHashMap<>();
DualCustomBucket expected = passBucket("IF_001");
map.put("IF_001", expected);
injectInterfaceMap(map);
assertSame(expected, manager.getInterfaceBucket("IF_001"));
assertNull(manager.getInterfaceBucket("UNKNOWN"));
}
@Test
void getAdapterBucketMap_containsInjectedEntries() {
Map<String, DualCustomBucket> injected = new ConcurrentHashMap<>();
injected.put("ADAPTER_A", passBucket("ADAPTER_A"));
injectAdapterMap(injected);
assertTrue(manager.getAdapterBucketMap().containsKey("ADAPTER_A"));
}
}
@@ -0,0 +1,195 @@
package com.eactive.eai.common.inflow.dual;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.springframework.context.ApplicationContext;
import org.springframework.test.util.ReflectionTestUtils;
import com.eactive.eai.common.inflow.Bucket;
import com.eactive.eai.common.inflow.InflowTargetVO;
import com.eactive.eai.common.server.EAIServerManager;
import com.eactive.eai.common.util.ApplicationContextProvider;
/**
* DualRequestProcessor 단위 테스트.
*
* <p>RequestProcessor 클래스는 static 초기화 블록에서 EAIServerManager.getInstance()를 호출한다.
* @BeforeAll에서 mock ApplicationContext를 주입한 뒤 DualRequestProcessor를 최초 로딩시킨다.
*
* <p>주의: DualRequestProcessor를 필드 타입으로 선언하면 테스트 클래스 로딩 시 함께 로딩되어
* @BeforeAll 실행 전에 NPE가 발생한다. 반드시 메서드 본문 내에서만 참조해야 한다.
*/
class DualRequestProcessorTest {
@BeforeAll
static void setupMockApplicationContext() {
ApplicationContext mockContext = mock(ApplicationContext.class);
EAIServerManager mockServerManager = mock(EAIServerManager.class);
when(mockContext.getBean(EAIServerManager.class)).thenReturn(mockServerManager);
when(mockServerManager.getLocalServerName()).thenReturn("TEST-SERVER");
when(mockServerManager.getGroupInstId()).thenReturn("TEST-INST");
ReflectionTestUtils.setField(ApplicationContextProvider.class, "context", mockContext);
}
private InflowTargetVO makeVO(String name, long perSec, long threshold, String unit) {
InflowTargetVO vo = new InflowTargetVO();
vo.setName(name);
vo.setThresholdPerSecond(perSec);
vo.setThreshold(threshold);
vo.setThresholdTimeUnit(unit);
vo.setActivate(true);
return vo;
}
// -------------------------------------------------------------------------
// checkAdapterInflow
// -------------------------------------------------------------------------
@Test
void checkAdapterInflow_dualBucket_pass_returnsNull() {
DualRequestProcessor processor = new DualRequestProcessor();
DualBucket mockBucket = mock(DualBucket.class);
when(mockBucket.isAdapterPassDetail("ADAPTER_A")).thenReturn(null);
assertNull(processor.checkAdapterInflow(mockBucket, "ADAPTER_A"));
verify(mockBucket).isAdapterPassDetail("ADAPTER_A");
}
@Test
void checkAdapterInflow_dualBucket_blockedPerSecond() {
DualRequestProcessor processor = new DualRequestProcessor();
DualBucket mockBucket = mock(DualBucket.class);
when(mockBucket.isAdapterPassDetail("ADAPTER_A")).thenReturn(DualCustomBucket.RESULT_BLOCKED_PER_SECOND);
assertEquals(DualCustomBucket.RESULT_BLOCKED_PER_SECOND,
processor.checkAdapterInflow(mockBucket, "ADAPTER_A"));
}
@Test
void checkAdapterInflow_dualBucket_blockedThreshold() {
DualRequestProcessor processor = new DualRequestProcessor();
DualBucket mockBucket = mock(DualBucket.class);
when(mockBucket.isAdapterPassDetail("ADAPTER_A")).thenReturn(DualCustomBucket.RESULT_BLOCKED_THRESHOLD);
assertEquals(DualCustomBucket.RESULT_BLOCKED_THRESHOLD,
processor.checkAdapterInflow(mockBucket, "ADAPTER_A"));
}
@Test
void checkAdapterInflow_nonDualBucket_pass_returnsNull() {
DualRequestProcessor processor = new DualRequestProcessor();
Bucket mockBucket = mock(Bucket.class);
when(mockBucket.isAdapterPass("ADAPTER_A")).thenReturn(true);
assertNull(processor.checkAdapterInflow(mockBucket, "ADAPTER_A"));
}
@Test
void checkAdapterInflow_nonDualBucket_blocked_returnsBlocked() {
DualRequestProcessor processor = new DualRequestProcessor();
Bucket mockBucket = mock(Bucket.class);
when(mockBucket.isAdapterPass("ADAPTER_A")).thenReturn(false);
assertEquals("BLOCKED", processor.checkAdapterInflow(mockBucket, "ADAPTER_A"));
}
// -------------------------------------------------------------------------
// checkInterfaceInflow
// -------------------------------------------------------------------------
@Test
void checkInterfaceInflow_dualBucket_pass_returnsNull() {
DualRequestProcessor processor = new DualRequestProcessor();
DualBucket mockBucket = mock(DualBucket.class);
when(mockBucket.isInterfacePassDetail("IF_001")).thenReturn(null);
assertNull(processor.checkInterfaceInflow(mockBucket, "IF_001"));
verify(mockBucket).isInterfacePassDetail("IF_001");
}
@Test
void checkInterfaceInflow_dualBucket_blockedPerSecond() {
DualRequestProcessor processor = new DualRequestProcessor();
DualBucket mockBucket = mock(DualBucket.class);
when(mockBucket.isInterfacePassDetail("IF_001")).thenReturn(DualCustomBucket.RESULT_BLOCKED_PER_SECOND);
assertEquals(DualCustomBucket.RESULT_BLOCKED_PER_SECOND,
processor.checkInterfaceInflow(mockBucket, "IF_001"));
}
@Test
void checkInterfaceInflow_nonDualBucket_pass_returnsNull() {
DualRequestProcessor processor = new DualRequestProcessor();
Bucket mockBucket = mock(Bucket.class);
when(mockBucket.isInterfacePass("IF_001")).thenReturn(true);
assertNull(processor.checkInterfaceInflow(mockBucket, "IF_001"));
}
@Test
void checkInterfaceInflow_nonDualBucket_blocked_returnsBlocked() {
DualRequestProcessor processor = new DualRequestProcessor();
Bucket mockBucket = mock(Bucket.class);
when(mockBucket.isInterfacePass("IF_001")).thenReturn(false);
assertEquals("BLOCKED", processor.checkInterfaceInflow(mockBucket, "IF_001"));
}
// -------------------------------------------------------------------------
// buildInflowTargetErrorMsg
// -------------------------------------------------------------------------
@Test
void buildInflowTargetErrorMsg_perSecond_includesReqPerSec() {
DualRequestProcessor processor = new DualRequestProcessor();
InflowTargetVO vo = makeVO("결제API", 30, 1000, "MIN");
String msg = processor.buildInflowTargetErrorMsg(vo, DualCustomBucket.RESULT_BLOCKED_PER_SECOND);
assertEquals("API 호출 한도 초과 [결제API: 30req/sec]", msg);
}
@Test
void buildInflowTargetErrorMsg_threshold_includesReqPerUnit() {
DualRequestProcessor processor = new DualRequestProcessor();
InflowTargetVO vo = makeVO("결제API", 30, 1000, "MIN");
String msg = processor.buildInflowTargetErrorMsg(vo, DualCustomBucket.RESULT_BLOCKED_THRESHOLD);
assertEquals("API 호출 한도 초과 [결제API: 1000req/MIN]", msg);
}
@Test
void buildInflowTargetErrorMsg_blockedFallback_simpleFormat() {
DualRequestProcessor processor = new DualRequestProcessor();
InflowTargetVO vo = makeVO("결제API", 30, 1000, "MIN");
String msg = processor.buildInflowTargetErrorMsg(vo, "BLOCKED");
assertEquals("API 호출 한도 초과 [결제API]", msg);
}
@Test
void buildInflowTargetErrorMsg_nullBlockedType_simpleFormat() {
DualRequestProcessor processor = new DualRequestProcessor();
InflowTargetVO vo = makeVO("결제API", 30, 1000, "MIN");
String msg = processor.buildInflowTargetErrorMsg(vo, null);
assertEquals("API 호출 한도 초과 [결제API]", msg);
}
@Test
void buildInflowTargetErrorMsg_hourUnit() {
DualRequestProcessor processor = new DualRequestProcessor();
InflowTargetVO vo = makeVO("조회API", 10, 500, "HOUR");
String msg = processor.buildInflowTargetErrorMsg(vo, DualCustomBucket.RESULT_BLOCKED_THRESHOLD);
assertEquals("API 호출 한도 초과 [조회API: 500req/HOUR]", msg);
}
}