refactor: DualBucket/DualInflowControlManager 클라이언트 분리 및 Bandwidth 방식 변경

- DualBucket 인터페이스에서 클라이언트 메서드 분리 → ClientDualBucket 신규 인터페이스
- DualInflowControlManager 클라이언트 구현 분리 → ClientDualInflowControlManager
- DualInflowControlManager @Component 제거 (ClientDualInflowControlManager가 단일 빈)
- makeBucket: Bandwidth.simple → Bandwidth.classic + Refill.greedy/intervally 변경
  (threshold 버킷을 진짜 기간 쿼터로 동작하도록 수정)
- DualRequestProcessor → eapim-online custom 패키지로 이동 (삭제)
- ReloadInflowClientControlCommand / RemoveInflowClientControlCommand:
  instanceof 타입을 ClientDualInflowControlManager로 변경
- 단위 테스트: ClientDualInflowControlManagerMetricsTest 추가,
  DualInflowControlManagerMetricsTest 클라이언트 케이스 분리

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
curry772
2026-05-07 18:02:34 +09:00
parent 437961e7d5
commit 8900966d71
12 changed files with 366 additions and 467 deletions
@@ -11,7 +11,7 @@ import org.springframework.test.util.ReflectionTestUtils;
import com.eactive.eai.agent.command.CommandException;
import com.eactive.eai.common.inflow.InflowControlManager;
import com.eactive.eai.common.inflow.InflowControlUtil;
import com.eactive.eai.common.inflow.dual.DualInflowControlManager;
import com.eactive.eai.common.inflow.dual.ClientDualInflowControlManager;
/**
* ReloadInflowClientControlCommand 단위 테스트.
@@ -21,11 +21,11 @@ import com.eactive.eai.common.inflow.dual.DualInflowControlManager;
*/
class ReloadInflowClientControlCommandTest {
private DualInflowControlManager mockDualManager;
private ClientDualInflowControlManager mockDualManager;
@BeforeEach
void setUp() {
mockDualManager = mock(DualInflowControlManager.class);
mockDualManager = mock(ClientDualInflowControlManager.class);
ReflectionTestUtils.setField(InflowControlUtil.class, "cachedInflowControlManager", null);
}
@@ -51,11 +51,11 @@ class ReloadInflowClientControlCommandTest {
}
// =========================================================================
// DualInflowControlManager 비활성
// ClientDualInflowControlManager 비활성
// =========================================================================
@Test
void execute_notDualManager_throwsCommandException() {
void execute_notClientDualManager_throwsCommandException() {
InflowControlManager baseMgr = mock(InflowControlManager.class);
ReflectionTestUtils.setField(InflowControlUtil.class, "cachedInflowControlManager", baseMgr);
@@ -11,7 +11,7 @@ import org.springframework.test.util.ReflectionTestUtils;
import com.eactive.eai.agent.command.CommandException;
import com.eactive.eai.common.inflow.InflowControlManager;
import com.eactive.eai.common.inflow.InflowControlUtil;
import com.eactive.eai.common.inflow.dual.DualInflowControlManager;
import com.eactive.eai.common.inflow.dual.ClientDualInflowControlManager;
/**
* RemoveInflowClientControlCommand 단위 테스트.
@@ -21,11 +21,11 @@ import com.eactive.eai.common.inflow.dual.DualInflowControlManager;
*/
class RemoveInflowClientControlCommandTest {
private DualInflowControlManager mockDualManager;
private ClientDualInflowControlManager mockDualManager;
@BeforeEach
void setUp() {
mockDualManager = mock(DualInflowControlManager.class);
mockDualManager = mock(ClientDualInflowControlManager.class);
ReflectionTestUtils.setField(InflowControlUtil.class, "cachedInflowControlManager", null);
}
@@ -50,11 +50,11 @@ class RemoveInflowClientControlCommandTest {
}
// =========================================================================
// DualInflowControlManager 비활성
// ClientDualInflowControlManager 비활성
// =========================================================================
@Test
void execute_notDualManager_throwsCommandException() {
void execute_notClientDualManager_throwsCommandException() {
InflowControlManager baseMgr = mock(InflowControlManager.class);
ReflectionTestUtils.setField(InflowControlUtil.class, "cachedInflowControlManager", baseMgr);
@@ -0,0 +1,190 @@
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;
/**
* ClientDualInflowControlManager 클라이언트 메트릭 단위 테스트.
*/
class ClientDualInflowControlManagerMetricsTest {
private ClientDualInflowControlManager manager;
@BeforeEach
void setUp() {
manager = new ClientDualInflowControlManager();
}
// -------------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------------
private InflowTargetVO makeTargetVO(String name) {
InflowTargetVO vo = new InflowTargetVO();
vo.setName(name);
vo.setThresholdPerSecond(100);
vo.setThreshold(10000);
vo.setThresholdTimeUnit("DAY");
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 DualCustomBucket passDualBucket(String name) {
return new DualCustomBucket(stableBucket(100), null, makeTargetVO(name));
}
private DualCustomBucket blockedPerSecondDualBucket(String name) {
return new DualCustomBucket(exhaustedBucket(1), null, makeTargetVO(name));
}
private DualCustomBucket blockedThresholdDualBucket(String name) {
return new DualCustomBucket(stableBucket(100), exhaustedBucket(1), makeTargetVO(name));
}
private void injectClientMap(Map<String, DualCustomBucket> map) {
ReflectionTestUtils.setField(manager, "clientBucketMap", map);
}
// =========================================================================
// isClientPassDetail() — 카운터 증가
// =========================================================================
@Test
void isClientPassDetail_bucketNotRegistered_noMetricsCreated() {
manager.isClientPassDetail("UNKNOWN");
assertNull(manager.getClientMetrics("UNKNOWN"));
}
@Test
void isClientPassDetail_pass_incrementsAllowed() {
Map<String, DualCustomBucket> map = new ConcurrentHashMap<>();
map.put("C1", passDualBucket("C1"));
injectClientMap(map);
manager.isClientPassDetail("C1");
assertEquals(1, manager.getClientMetrics("C1").allowed.get());
}
@Test
void isClientPassDetail_blockedThreshold_incrementsRejectedThreshold() {
Map<String, DualCustomBucket> map = new ConcurrentHashMap<>();
map.put("C1", blockedThresholdDualBucket("C1"));
injectClientMap(map);
manager.isClientPassDetail("C1");
assertEquals(1, manager.getClientMetrics("C1").rejectedThreshold.get());
}
@Test
void isClientPassDetail_blockedPerSecond_incrementsRejectedPerSecond() {
Map<String, DualCustomBucket> map = new ConcurrentHashMap<>();
map.put("C1", blockedPerSecondDualBucket("C1"));
injectClientMap(map);
manager.isClientPassDetail("C1");
assertEquals(1, manager.getClientMetrics("C1").rejectedPerSecond.get());
}
// =========================================================================
// getAllClientMetrics — 조회 및 불변성
// =========================================================================
@Test
void getAllClientMetrics_empty_returnsEmptyMap() {
assertTrue(manager.getAllClientMetrics().isEmpty());
}
@Test
void getAllClientMetrics_afterCalls_containsEntries() {
Map<String, DualCustomBucket> map = new ConcurrentHashMap<>();
map.put("C1", passDualBucket("C1"));
map.put("C2", passDualBucket("C2"));
injectClientMap(map);
manager.isClientPassDetail("C1");
manager.isClientPassDetail("C2");
Map<String, DualInflowControlManager.TargetMetrics> all = manager.getAllClientMetrics();
assertEquals(2, all.size());
assertTrue(all.containsKey("C1"));
assertTrue(all.containsKey("C2"));
}
// =========================================================================
// resetClientMetrics / resetAllClientMetrics
// =========================================================================
@Test
void resetClientMetrics_resetsTargetClient() {
Map<String, DualCustomBucket> map = new ConcurrentHashMap<>();
map.put("C1", passDualBucket("C1"));
injectClientMap(map);
manager.isClientPassDetail("C1");
manager.resetClientMetrics("C1");
assertEquals(0, manager.getClientMetrics("C1").allowed.get());
}
@Test
void resetAllClientMetrics_resetsAll() {
Map<String, DualCustomBucket> map = new ConcurrentHashMap<>();
map.put("C1", passDualBucket("C1"));
map.put("C2", passDualBucket("C2"));
injectClientMap(map);
manager.isClientPassDetail("C1");
manager.isClientPassDetail("C2");
manager.resetAllClientMetrics();
assertEquals(0, manager.getClientMetrics("C1").allowed.get());
assertEquals(0, manager.getClientMetrics("C2").allowed.get());
}
// =========================================================================
// removeClient — 메트릭 자동 삭제
// =========================================================================
@Test
void removeClient_cleansUpMetrics() {
Map<String, DualCustomBucket> map = new ConcurrentHashMap<>();
map.put("C1", passDualBucket("C1"));
injectClientMap(map);
manager.isClientPassDetail("C1");
assertNotNull(manager.getClientMetrics("C1"));
manager.removeClient("C1");
assertNull(manager.getClientMetrics("C1"));
}
}
@@ -21,9 +21,8 @@ import io.github.bucket4j.local.LocalBucket;
/**
* DualInflowControlManager 메트릭 단위 테스트.
*
* <p>start() 없이 버킷 맵을 ReflectionTestUtils로 직접 주입하여
* isAdapterPassDetail / isInterfacePassDetail / isClientPassDetail / isGroupPass() 의
* 카운팅 동작과 getXxxMetrics / resetXxxMetrics 메서드를 검증한다.
* <p>어댑터/인터페이스/그룹 메트릭을 검증한다.
* 클라이언트 메트릭은 ClientDualInflowControlManagerMetricsTest 에서 검증한다.
*/
class DualInflowControlManagerMetricsTest {
@@ -100,10 +99,6 @@ class DualInflowControlManagerMetricsTest {
ReflectionTestUtils.setField(manager, "interfaceBucketMap", map);
}
private void injectClientMap(Map<String, DualCustomBucket> map) {
ReflectionTestUtils.setField(manager, "clientBucketMap", map);
}
private void injectGroupBucketList(Map<String, CustomGroupBucket> map) {
ReflectionTestUtils.setField(manager, "groupBucketList", map);
}
@@ -358,7 +353,6 @@ class DualInflowControlManagerMetricsTest {
@Test
void isAdapterPassDetail_bucketNotRegistered_noMetricsCreated() {
// 버킷 없는 어댑터는 메트릭 맵에 항목을 생성하지 않음
manager.isAdapterPassDetail("UNKNOWN");
assertNull(manager.getAdapterMetrics("UNKNOWN"));
@@ -473,39 +467,6 @@ class DualInflowControlManagerMetricsTest {
assertEquals(1, manager.getInterfaceMetrics("IF2").rejectedPerSecond.get());
}
// =========================================================================
// isClientPassDetail() — 카운터 증가
// =========================================================================
@Test
void isClientPassDetail_bucketNotRegistered_noMetricsCreated() {
manager.isClientPassDetail("UNKNOWN");
assertNull(manager.getClientMetrics("UNKNOWN"));
}
@Test
void isClientPassDetail_pass_incrementsAllowed() {
Map<String, DualCustomBucket> map = new ConcurrentHashMap<>();
map.put("C1", passDualBucket("C1"));
injectClientMap(map);
manager.isClientPassDetail("C1");
assertEquals(1, manager.getClientMetrics("C1").allowed.get());
}
@Test
void isClientPassDetail_blockedThreshold_incrementsRejectedThreshold() {
Map<String, DualCustomBucket> map = new ConcurrentHashMap<>();
map.put("C1", blockedThresholdDualBucket("C1"));
injectClientMap(map);
manager.isClientPassDetail("C1");
assertEquals(1, manager.getClientMetrics("C1").rejectedThreshold.get());
}
// =========================================================================
// getAllXxxMetrics — 조회 및 불변성
// =========================================================================
@@ -537,11 +498,6 @@ class DualInflowControlManagerMetricsTest {
assertTrue(manager.getAllInterfaceMetrics().isEmpty());
}
@Test
void getAllClientMetrics_empty_returnsEmptyMap() {
assertTrue(manager.getAllClientMetrics().isEmpty());
}
// =========================================================================
// resetXxxMetrics / resetAllXxxMetrics
// =========================================================================
@@ -560,7 +516,7 @@ class DualInflowControlManagerMetricsTest {
manager.resetAdapterMetrics("A1");
assertEquals(0, manager.getAdapterMetrics("A1").allowed.get());
assertEquals(1, manager.getAdapterMetrics("A2").allowed.get()); // 영향 없음
assertEquals(1, manager.getAdapterMetrics("A2").allowed.get());
}
@Test
@@ -601,33 +557,6 @@ class DualInflowControlManagerMetricsTest {
assertDoesNotThrow(() -> manager.resetAllInterfaceMetrics());
}
@Test
void resetClientMetrics_resetsTargetClient() {
Map<String, DualCustomBucket> map = new ConcurrentHashMap<>();
map.put("C1", passDualBucket("C1"));
injectClientMap(map);
manager.isClientPassDetail("C1");
manager.resetClientMetrics("C1");
assertEquals(0, manager.getClientMetrics("C1").allowed.get());
}
@Test
void resetAllClientMetrics_resetsAll() {
Map<String, DualCustomBucket> map = new ConcurrentHashMap<>();
map.put("C1", passDualBucket("C1"));
map.put("C2", passDualBucket("C2"));
injectClientMap(map);
manager.isClientPassDetail("C1");
manager.isClientPassDetail("C2");
manager.resetAllClientMetrics();
assertEquals(0, manager.getClientMetrics("C1").allowed.get());
assertEquals(0, manager.getClientMetrics("C2").allowed.get());
}
// =========================================================================
// remove 시 메트릭 자동 삭제
// =========================================================================
@@ -659,18 +588,4 @@ class DualInflowControlManagerMetricsTest {
assertNull(manager.getInterfaceMetrics("IF1"));
}
@Test
void removeClient_cleansUpMetrics() {
Map<String, DualCustomBucket> map = new ConcurrentHashMap<>();
map.put("C1", passDualBucket("C1"));
ReflectionTestUtils.setField(manager, "clientBucketMap", map);
manager.isClientPassDetail("C1");
assertNotNull(manager.getClientMetrics("C1"));
manager.removeClient("C1");
assertNull(manager.getClientMetrics("C1"));
}
}
@@ -1,197 +0,0 @@
package com.eactive.eai.inbound.processor;
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.inflow.dual.DualBucket;
import com.eactive.eai.common.inflow.dual.DualCustomBucket;
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);
}
}