27 Commits

Author SHA1 Message Date
curry772 f36f78d63c refactor: TypedCircuitBreakerManager 미사용 코드 정리
- urlCBConfigMap 선언 주석 처리 (URL 기반 CB 기능 미사용)
- AlarmStateManager 연동 코드 주석 처리 (AlarmEvent/AlarmCondition/CoreAlarmKey import 제거)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 14:47:01 +09:00
curry772 06f0d389a6 test: TypedCircuitBreakerManager 단위테스트 추가 및 idCBConfigMap 버그 수정
- TypedCircuitBreakerManagerTest 추가 (JUnit5/Mockito, DB/Spring 없이 동작)
- getCircuitBreaker 조회/fallback/비활성화/normalizeUseYn 검증 (1-1~1-5)
- apiId 별 CB 인스턴스 독립성 및 상태 격리 검증 (2-1~2-2)
- CLOSED/OPEN/HALF_OPEN 상태 전이 전체 검증 (3-1~3-6)
- reload(key) 설정 갱신/비활성화 제거, reload() 레지스트리 재생성 검증 (4-1~4-3)
- 생명주기 isStarted 검증 (5-1)
- init()에서 idCBConfigMap.remove를 put으로 수정 (reload(key) 동작 오류 버그 수정)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 13:32:44 +09:00
curry772 00735f6614 Merge branch 'feature/hsm-integration-jdk8' into master 2026-04-29 12:59:07 +09:00
curry772 a1a5a34874 feat: HSM 키 캐시 및 PropManager 연동 초기화, DB 설정 파싱 오류 수정
- HsmCryptoService: getPublicKey/getSecretKey ConcurrentHashMap 캐시 추가 (HSM 통신 최소화)
- HsmCryptoService: encryptAes/decryptAes Provider 파라미터 오버로드 추가 (non-extractable 키 지원)
- HsmCryptoService: PropertyChangeListener 구현, HSM.CACHE_RELOAD_YN=Y 시 clearKeyCache() 자동 호출
- HsmCryptoService: @PostConstruct NPE 수정 - PropManager.getInstance() → @Autowired 직접 주입
- HsmManager: DB PKCS11_CONFIG 리터럴 \n → 실제 줄바꿈 치환 (파싱 오류 수정)
- HsmManager: 기동 시 HSM 키 목록 로그 추가

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 12:58:50 +09:00
curry772 acdb58049c feat: SafeNet HSM 연동 구현 (JDK 8 / SunPKCS11)
- HsmManager: SunPKCS11 Provider 초기화, Lifecycle 관리
  - JDK 8 / JDK 9+ 분기를 리플렉션으로 처리하는 createProvider() 추가
  - PropManager DB에서 PKCS11_CONFIG / PIN 읽어 초기화
- HsmCryptoService: RSA / AES 암복호화 서비스
  - encryptRsa: 공개키 기반 JVM 소프트웨어 암호화
  - decryptRsa: HSM 내부 복호화 (미초기화 시 HsmException 발생)
  - encryptAes / decryptAes: AES/CBC/PKCS5Padding
- HsmException: HSM 전용 커스텀 예외
- pkcs11-dev.cfg: SoftHSM2용 (slotListIndex = 0)
- pkcs11-prod.cfg: SafeNet ProtectServer 운영용 (slot = 0)
- HsmSoftHsm2IntegrationTest: 저수준 PKCS11 직접 테스트 (8개)
- HsmManagerServiceTest: HsmManager/HsmCryptoService 경유 테스트 (8개)
  Mockito + GenericApplicationContext로 DB 없이 실행 가능

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 20:52:14 +09:00
curry772 81f7898eb1 Merge branch 'feature/inflow-control-improvement' into master 2026-04-27 17:24:58 +09:00
curry772 d08c7efcdc refactor: DualRequestProcessor를 inbound.processor 패키지로 이동
유량제어 컴포넌트(inflow.dual)가 아닌 요청 처리기이므로
부모 클래스(RequestProcessor)와 동일 패키지로 이동.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 13:50:48 +09:00
curry772 a4c9a2f2bd 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>
2026-04-27 09:40:27 +09:00
curry772 632550220d fix: Reload 동시성 수정 - volatile ConcurrentHashMap + 원자적 맵 교체
1. 맵 5개를 volatile + ConcurrentHashMap으로 변경
   - 비동기 읽기(isGroupPass 등) 중 HashMap 구조 변경으로 발생하던 race condition 제거

2. initAdapter/Interface/Group()에서 빌드-완성 후 원자적 교체 패턴 적용
   - 신규 맵을 완전히 구성한 뒤 this.field = newMap으로 한 번에 교체
   - 구→신 전환 중 불완전 상태가 외부에 노출되던 문제 제거

3. removeAdapter/Interface/Group()에 synchronized 추가
   - 락 없이 맵을 수정하던 메서드와 reload 사이의 race condition 방지

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 15:25:58 +09:00
curry772 d88dbe1eef feat: 리플렉션 제거 - InflowControlManager.getGroupBucket() public 메서드 추가
groupBucketList private 필드에 리플렉션 없이 접근할 수 있도록
getGroupBucket(String groupId) public 메서드 제공.

그룹 테이블 테스트 픽스처 SQL 추가 및 InflowControlManagerTest에
getGroupBucket() 동작 검증 테스트 케이스 2개 추가.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 15:21:00 +09:00
curry772 31b8a2b130 Merge branch 'feature/standard-message-djb' 2026-04-23 14:14:54 +09:00
curry772 d81aeeef67 chore: DJErp InterfaceID 설정 TODO 주석 추가
표준전문 InterfaceID에 SEND_RECV_DIVISION·IN_EX_DIVISION 포함 여부 결정 전까지
mapper.setInterfaceId() 호출을 주석 처리 및 TODO 표기.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 14:10:54 +09:00
curry772 5dae063fe5 fix: FLAT 직렬화 시 비활성 조건부 GROUP 블록 바이트 포함 버그 수정
toByteArray() / getBytesDataLength() GROUP 케이스에서
size==0 이면 refPath/refValue 유무와 무관하게 무조건 skip 하도록 변경.
(toJson() 동작과 일치)

기존 로직은 refPath 또는 refValue 가 없는 경우에만 skip 하여,
조건부 블록(EZDATA, MSG 등)이 비활성 상태(size=0, isHidden=false)일 때도
FLAT 바이트에 잘못 포함되는 문제가 있었음.
→ FlatReader 파싱 시 오프셋 불일치로 overflow 예외 또는 DATA 필드 오염 발생.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 10:34:22 +09:00
curry772 024b24af49 Merge branch 'master' into feature/standard-message-djb 2026-04-22 15:50:23 +09:00
curry772 6807e2b947 Merge branch 'feature/jejubank-oauth-bypass' 2026-04-22 15:49:37 +09:00
curry772 56fc66faa0 fix: 표준헤더 진행번호(SendTime) 처리 수정
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 15:40:49 +09:00
curry772 126f41624d Merge branch 'feature/standard-message-djb' into master 2026-04-21 16:00:25 +09:00
curry772 ae6ed9b262 fix: DJBank 표준전문 대응 프레임워크 수정
- BaseFCProcess.setGUID(): nextGuidSeq 자동 호출 비활성화
  (guid_prgs_no 증가는 Coordinator에서 처리)
- DefaultProcess: 오류 응답 시 nextGuidSeq 자동 호출 비활성화
- RequestProcessor: GUID 조합(guid+seq) 로직 비활성화,
  logger.error에 exception 객체 추가
- StandardMessageManager: config 기본 경로 ./resources/ → ./ 수정

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 15:48:20 +09:00
curry772 6f5af2ac69 fix: toJson 직렬화 시 빈 항목 제외 처리
itemPart가 비어있을 경우 구분자와 내용을 출력하지 않도록 수정.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 09:08:31 +09:00
curry772 6fd9caa7d4 오류응답 디폴트 형식(ERROR_MESSAGE_DEFAULT_FORMAT) 프러퍼티 설정 가능하도록 수정
- 프러퍼티 그룹 : MessageUtil
- 프러퍼티 : ERROR_MESSAGE_DEFAULT_FORMAT
2026-04-17 09:43:32 +09:00
curry772 68cf1dff16 Authorization 오류 메시지 출력 변경 - 실제 사용하는 토큰헤더이름사용하도록 변경 2026-04-17 09:42:13 +09:00
curry772 bebf5fa36d ApiConfig token.header.name 프러퍼티를 추가하여 token 헤더 프러퍼티 추가 2026-04-15 17:39:59 +09:00
curry772 dd9fc6fbdb BearerToken 간헐적 파싱 못하는 경우 발생에 대한 방어로직 2026-04-09 08:59:38 +09:00
curry772 71686fb387 RFC 2616 §13.5.1 (Hop-by-hop 헤더 목록) 오류 정정, Trailers -> Trailer 2026-04-08 14:38:34 +09:00
curry772 bc8b549a33 HTTP Header 로그 off 기능 - -Duse.http.header.log=y 옵션 추가시 로깅 2026-04-08 14:16:59 +09:00
curry772 56ac438265 fix: 비-mTLS https 연결 시 testMode에서 hostname 검증 우회 적용
TrustAllStrategy 사용 시에도 DefaultHostnameVerifier가 적용되어
외부 testmaster 인증서 hostname mismatch 오류 발생.
testMode(-Duse.test.trust=y)일 때 NoopHostnameVerifier 적용하여 해결.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 09:57:51 +09:00
curry772 791e362a74 feature: DJErp OAuth bypass 지원을 위한 공통 모듈 개선
- UnkownMessageLogUtils 추가: unknown 메시지 발생 시 DB 에러 로그 기록
- HttpAdapterServiceSupport: transactionId 추출을 try 블록 외부로 이동,
  예외 분기 처리(HttpStatusException/JwtAuthException/Exception) 및 UnkownMessageLogUtils 연동
- ApiAuthFilter: eaiSvcCd 강제 덮어쓰기 로직 주석처리 (STDMessage 조회값 우선 사용)
- MessageUtil: ERROR_MESSAGE_DEFAULT_FORMAT을 표준 OAuth2 에러 형식으로 변경

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 00:01:38 +09:00
36 changed files with 3735 additions and 125 deletions
@@ -53,6 +53,8 @@ public abstract class HttpClient5AdapterServiceSupport implements HttpClientAdap
private static boolean testMode = TestModeChecker.isTestMode();
private static boolean httpHeaderLogMode = HttpAdapterExtraLogUtil.isHttpHeaderMode();
public synchronized void close() {
if(connectionManager != null) {
connectionManager.close();
@@ -366,8 +368,20 @@ public abstract class HttpClient5AdapterServiceSupport implements HttpClientAdap
}
else {
sslContext = SSLContextBuilder.create().loadTrustMaterial(new TrustAllStrategy()).build();
SSLConnectionSocketFactory csf = new SSLConnectionSocketFactory(sslContext);
cmBuilder.setSSLSocketFactory(csf);
SSLConnectionSocketFactory sslSocketFactory = null;
if(testMode) {
// Hostname verifier 비활성화 (테스트용)
sslSocketFactory = new SSLConnectionSocketFactory(
sslContext
, NoopHostnameVerifier.INSTANCE
);
}
else {
sslSocketFactory = new SSLConnectionSocketFactory(
sslContext
);
}
cmBuilder.setSSLSocketFactory(sslSocketFactory);
}
} catch (KeyManagementException | NoSuchAlgorithmException | KeyStoreException
@@ -384,6 +398,11 @@ public abstract class HttpClient5AdapterServiceSupport implements HttpClientAdap
HttpRequestInterceptor requestLogInterceptor = (request, entity, context) -> {
if (!httpHeaderLogMode) {
logger.info("httpHeader logging off - use.http.header.log");
return;
}
try {
String uuid = (String) context.getAttribute(TransactionContextKeys.TRANSACTION_UUID);
String contextAdapterGroupName = (String) context.getAttribute(HttpClientAdapterServiceKey.ADAPTER_GROUP_NAME);
@@ -403,6 +422,11 @@ public abstract class HttpClient5AdapterServiceSupport implements HttpClientAdap
};
HttpResponseInterceptor responseLogInterceptor = (response, entity, context) -> {
if (!httpHeaderLogMode) {
logger.info("httpHeader logging off - use.http.header.log");
return;
}
try{
HttpRequest request = (HttpRequest)context.getAttribute("http.request");
String url = request.getUri().toString();
@@ -10,29 +10,35 @@ import org.apache.commons.lang3.StringUtils;
import com.eactive.eai.adapter.AdapterGroupVO;
import com.eactive.eai.adapter.AdapterManager;
import com.eactive.eai.adapter.RequestDispatcher;
import com.eactive.eai.adapter.http.HttpStatusException;
import com.eactive.eai.adapter.http.client.HttpClientAdapterServiceKey;
import com.eactive.eai.adapter.http.dynamic.filter.HttpAdapterFilter;
import com.eactive.eai.adapter.http.dynamic.filter.HttpAdapterFilterFactoryKjb;
import com.eactive.eai.adapter.http.dynamic.filter.HttpAdapterFilterType;
import com.eactive.eai.adapter.http.dynamic.filter.JwtAuthException;
import com.eactive.eai.common.util.Logger;
public abstract class HttpAdapterServiceSupport implements HttpAdapterService, HttpAdapterServiceKey {
private static final String LOG_PREFIX = "HttpAdapterServiceSupport] ";
protected long slowTranTime = 2000L;
public static final String HEADER_NAME_CLIENT_ID = "x-elink-client-id";
public static final String PROPERTIES_NAME_CLIENT_ID = "clientId";
static Logger logger = Logger.getLogger(Logger.LOGGER_ADAPTER);
public Object service(String adptGrpName, String adptName, Object message, Properties prop,
HttpServletRequest request, HttpServletResponse response) throws Exception {
try {
String clientId = request.getHeader(HEADER_NAME_CLIENT_ID);
if (StringUtils.isNotBlank(clientId)) {
prop.put(PROPERTIES_NAME_CLIENT_ID, clientId);
}
String transactionId = request.getHeader(HttpClientAdapterServiceKey.TRANSACTION_ID);
if (StringUtils.isNotBlank(transactionId)) {
prop.put(HttpClientAdapterServiceKey.TRANSACTION_ID, transactionId);
}
try {
String clientId = request.getHeader(HEADER_NAME_CLIENT_ID);
if (StringUtils.isNotBlank(clientId)) {
prop.put(PROPERTIES_NAME_CLIENT_ID, clientId);
}
String instanceId = request.getHeader(HttpClientAdapterServiceKey.INSTANCE_ID);
if (StringUtils.isNotBlank(instanceId)) {
prop.put(HttpClientAdapterServiceKey.INSTANCE_ID, instanceId);
@@ -40,9 +46,26 @@ public abstract class HttpAdapterServiceSupport implements HttpAdapterService, H
} catch (Exception e) {
}
Object obj = null;
try {
message = doPreFilters(adptGrpName, adptName, message, prop, request, response);
Object obj = RequestDispatcher.getRequestDispatcher(adptGrpName).handle(adptName, message, prop);
obj = RequestDispatcher.getRequestDispatcher(adptGrpName).handle(adptName, message, prop);
obj = doPostFilters(adptGrpName, adptName, obj, prop, request, response);
} catch (HttpStatusException e) { // inbound error
logger.warn(LOG_PREFIX + transactionId + "-" + adptGrpName + "-" + adptName + ">>" + e.getMessage(), e);
throw e;
} catch (JwtAuthException e) { // filter error
logger.error(LOG_PREFIX + transactionId + "-" + adptGrpName + "-" + adptName + ">>" + e.getMessage(), e);
UnkownMessageLogUtils.logUnkownMessage(adptGrpName, adptName, message, prop, request, response,
"RECEAIIRP202", e);
throw e;
} catch (Exception e) { // filter error
logger.error(LOG_PREFIX + transactionId + "-" + adptGrpName + "-" + adptName + ">>" + e.getMessage(), e);
UnkownMessageLogUtils.logUnkownMessage(adptGrpName, adptName, message, prop, request, response,
"RECEAIIRP201", e);
throw e;
}
if (obj == null) {
return null;
} else if (obj instanceof byte[]) {
@@ -0,0 +1,149 @@
package com.eactive.eai.adapter.http.dynamic;
import java.io.UnsupportedEncodingException;
import java.nio.charset.Charset;
import java.util.Properties;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.apache.commons.lang3.StringUtils;
import com.eactive.eai.adapter.AdapterGroupVO;
import com.eactive.eai.adapter.AdapterManager;
import com.eactive.eai.adapter.http.client.HttpClientAdapterServiceKey;
import com.eactive.eai.common.exception.ExceptionUtil;
import com.eactive.eai.common.server.EAIServerManager;
import com.eactive.eai.common.util.DatetimeUtil;
import com.eactive.eai.common.util.InboundErrorLogger;
import com.eactive.eai.common.util.Logger;
import com.eactive.eai.common.util.UUIDGenerator;
import com.eactive.eai.inbound.error.InboundErrorInfoVO;
import com.eactive.eai.inbound.error.InboundErrorKeys;
import com.eactive.eai.util.HexaConverter;
public class UnkownMessageLogUtils {
static Logger logger = Logger.getLogger(Logger.LOGGER_DEFAULT);
private UnkownMessageLogUtils() {
throw new IllegalStateException("Utility class");
}
public static void logUnkownMessage(String adptGrpName, String adptName, Object message, Properties prop,
HttpServletRequest request, HttpServletResponse response, String errCode, Exception e) {
try {
EAIServerManager eaiServerManager = EAIServerManager.getInstance();
String serverName = eaiServerManager.getLocalServerName();
String txId = prop.getProperty(HttpClientAdapterServiceKey.TRANSACTION_ID);
if(StringUtils.isEmpty(txId)) {
StringBuffer sb = new StringBuffer();
String instanceid1 = serverName.substring(0,2);
String instanceid2 = serverName.substring(serverName.length()-2, serverName.length());
String instid = instanceid1 + instanceid2;
txId = instid + UUIDGenerator.getUUID();
}
String clientId = prop.getProperty("clientId");
String hexClientId = clientId != null ? HexaConverter.bytesToHexa(clientId.getBytes()) : "";
String errorMsg = ExceptionUtil.make(e, errCode);
StringBuffer sb = new StringBuffer();
sb.append(errorMsg).append("\n").append("Remote Addr : ").append(request.getRemoteAddr()).append("\n").append("Request URI : ")
.append(request.getRequestURI()).append("\n").append("clientId=").append(clientId).append(",hexClientId=").append(hexClientId).append(",txProp=").append(prop).append("\n").append("Exception : ").append(e.getMessage());
UnkownMessageLogUtils.logUnknownMessage(txId, adptGrpName, adptName,
"", "", false, errCode, sb.toString(), System.currentTimeMillis(),
serverName, InboundErrorKeys.IN_UNKNOWN, message);
} catch (Exception ex) {
logger.warn("unkown log fail.", ex);
}
}
// public static void logUnkownMessage(String uuid, String adapterGroupName, String adapterName, String errCode,
// String errMsg, Object message) {
// UnkownMessageLogUtils.logUnknownMessage(uuid, adapterGroupName, adapterName,
// "", "", false, errCode, errMsg, System.currentTimeMillis(),
// EAIServerManager.getInstance().getInstId(), InboundErrorKeys.IN_UNKNOWN, message);
// }
private static void logUnknownMessage(String uuid, String adapterGroupName, String adapterName,
String bzwkSvcKeyName, String eaiSvcCd, boolean isTasStarted, String errCode, String errMsg, long errTm,
String svcInstNm, String errDstCd, Object message) {
String msg = null;
if(errDstCd == null || errDstCd.length() ==0) {
errDstCd = "ND";
}
if(isTasStarted) {
errDstCd = InboundErrorKeys.TAS_START;
}
InboundErrorInfoVO errorInfoVO = new InboundErrorInfoVO();
errorInfoVO.setEaiSvcSno(uuid); // EAI서비스일련번호
errorInfoVO.setAdptBwkGrpNm(adapterGroupName); // 어댑터업무그룹명
errorInfoVO.setEaiSvcCd(eaiSvcCd);
errorInfoVO.setErrCd(errCode); // 에러코드
errorInfoVO.setErrTxt(errMsg); // 에러내용
errorInfoVO.setErrTm(DatetimeUtil.getCurrentTime(errTm)); // 에러발생시각
errorInfoVO.setErrDstcd(errDstCd);
errorInfoVO.setBzwkSvcKeyName(bzwkSvcKeyName);
if(message == null) {
msg = "null";
}
else {
if(message instanceof byte[]) {
AdapterManager adapterManager = AdapterManager.getInstance();
AdapterGroupVO srcAdapterGrpVO = adapterManager.getAdapterGroupVO(adapterGroupName);
// TAS 거래일 경우 NULL일 수 있음
if(srcAdapterGrpVO == null) {
msg = new String((byte[])message);
}
else {
// String srcAdapterMsgType = srcAdapterGrpVO.getMessageType();
// if( MessageUtil.isUTF8(srcAdapterMsgType) ) {
// try {
// msg = new String((byte[])message, UJSONMessage.encode);
// } catch (UnsupportedEncodingException e) {
// logger.warn("msg encode error", e);
// msg = new String((byte[])message); }
// }
// else {
// msg = new String((byte[])message);
// }
String charset = StringUtils.defaultIfBlank(srcAdapterGrpVO.getMessageEncode(), Charset.defaultCharset().name());
try {
msg = new String((byte[])message, charset);
} catch (UnsupportedEncodingException e) {
logger.warn("msg encode error", e);
msg = new String((byte[])message);
}
}
}
else if(message instanceof String) {
msg = (String)message;
}
else {
msg = "invaild message ["+message.getClass().getName()+"] "+message.toString();
}
}
errorInfoVO.setBwkDataTxt(msg); // 업무데이터내용
errorInfoVO.setEaiSvrInstNm(svcInstNm); // EAI서버인스턴스명
InboundErrorLogger.error(errorInfoVO);
if (logger.isError()) {
logger.error("======================================================================" );
logger.error(" RequestDispatcher] GET UNKNOWN MESSAGE");
logger.error(" ADAPTER GROUP NAME [" + adapterGroupName + "]");
logger.error(" EAISVCNAME [" + eaiSvcCd + "]");
logger.error(" ADAPTER NAME [" + adapterName + "]");
logger.error("======================================================================" );
}
}
}
@@ -104,7 +104,8 @@ public class ApiAuthFilter implements HttpAdapterFilter {
*/
StandardMessage standardMessage = STDMessageManager.getInstance().getSTDMessage(apiId);
String eaiSvcCd = StandardMessageManager.getInstance().getMapper().getEaiSvcCode(standardMessage);
if(!StringUtils.equals(eaiSvcCd, prop.getProperty("API_SERVICE_CODE"))) eaiSvcCd = prop.getProperty("API_SERVICE_CODE");
// if (StringUtils.isNotEmpty(eaiSvcCd) && !StringUtils.equals(eaiSvcCd, ))
// eaiSvcCd = prop.getProperty("API_SERVICE_CODE");
EAIMessage eaiMessage = EAIMessageManager.getInstance().getEAIMessage(eaiSvcCd);
if(StringUtils.isBlank(eaiMessage.getAuthType())){
@@ -305,7 +306,8 @@ public class ApiAuthFilter implements HttpAdapterFilter {
}
public static String extractBearerToken(HttpServletRequest request) throws JwtAuthException {
String authorization = request.getHeader("Authorization");
String tokenHeaderName = PropManager.getInstance().getProperty("ApiConfig", "token.header.name", "Authorization");
String authorization = request.getHeader(tokenHeaderName);
if (StringUtils.isBlank(authorization)) {
// QueryString으로 전달된 access_token 파라미터 확인
String tokenParamName = PropManager.getInstance().getProperty("ApiConfig", "token.param.name", "access_token");
@@ -313,13 +315,13 @@ public class ApiAuthFilter implements HttpAdapterFilter {
if (StringUtils.isNotBlank(queryToken)) {
return queryToken.trim();
}
throw new JwtAuthException(ERROR_AUTHENTICATION_FAIL, "No have header info: Authorization");
throw new JwtAuthException(ERROR_AUTHENTICATION_FAIL, "No have header info: " + tokenHeaderName);
}
String[] components = authorization.split("\\s");
String[] components = authorization.trim().split("\\s+", 2);
if (components.length != 2) {
throw new JwtAuthException(ERROR_AUTHENTICATION_FAIL, "Malformat [Authorization] content");
throw new JwtAuthException(ERROR_AUTHENTICATION_FAIL, "Malformat [" + tokenHeaderName + "] content");
}
if (!StringUtils.equalsIgnoreCase(components[0], "Bearer")) {
@@ -45,7 +45,7 @@ public class HttpAdapterServiceBypass extends HttpAdapterServiceSupport {
public static final String HTTP_STATUS = "HTTP_STATUS";
public static final String PROPERTIES_NAME_HTTP_REQUEST_METHOD = "httpRequestMethod";
public static final String[] HOP_BY_HOP_HEADERS = new String[] { "Connection", "Keep-Alive", "Proxy-Authenticate",
"Proxy-Authorization", "TE", "Trailers", "Transfer-Encoding", "Upgrade" };
"Proxy-Authorization", "TE", "Trailers", "Trailer", "Transfer-Encoding", "Upgrade" };
protected boolean doPreserveCookies = false;
protected boolean doPreserveCookiePath = false;
@@ -17,10 +17,6 @@ import com.eactive.eai.common.lifecycle.LifecycleSupport;
import com.eactive.eai.common.property.PropManager;
import com.eactive.eai.common.util.ApplicationContextProvider;
import com.eactive.eai.common.util.Logger;
import com.eactive.eai.custom.alarm.AlarmStateManager;
import com.eactive.eai.custom.alarm.condition.AlarmCondition;
import com.eactive.eai.custom.alarm.event.AlarmEvent;
import com.eactive.eai.custom.alarm.key.CoreAlarmKey;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
@@ -45,7 +41,7 @@ public class TypedCircuitBreakerManager implements Lifecycle {
TypedCircuitBreakerVO defaultConfigVo;
HashMap<String, TypedCircuitBreakerVO> idCBConfigMap = new HashMap<>();
HashMap<String, TypedCircuitBreakerVO> apiIdCBConfigMap = new HashMap<>();
HashMap<String, TypedCircuitBreakerVO> urlCBConfigMap = new HashMap<>();
// HashMap<String, TypedCircuitBreakerVO> urlCBConfigMap = new HashMap<>();
private boolean started;
@Autowired
@@ -116,7 +112,7 @@ public class TypedCircuitBreakerManager implements Lifecycle {
CircuitBreakerConfig circuitBreakerConfig = this.createCircuitBreakerConfig(vo);
vo.setCircuitBreakerConfig(circuitBreakerConfig);
this.apiIdCBConfigMap.put(vo.getName(), vo);
this.idCBConfigMap.remove(vo.getId(), vo);
this.idCBConfigMap.put(vo.getId(), vo);
}
}
@@ -359,14 +355,15 @@ public class TypedCircuitBreakerManager implements Lifecycle {
// } else {
// this.sendAlert(stateTransitionEvent);
// }
AlarmEvent alarmEvent = AlarmEvent.builder().key(CoreAlarmKey.CircuitBreaker)
.condition(AlarmCondition.builder().build())
.message(String.format("CircuitBreaker state changed. apiId=%s, %s -> %s", event.getCircuitBreakerName(), fromState, toState))
.build();
AlarmStateManager.getAlarmStateManager().onEvnet(alarmEvent);
// AlarmEvent alarmEvent = AlarmEvent.builder().key(CoreAlarmKey.CircuitBreaker)
// .condition(AlarmCondition.builder().build())
// .message(String.format("CircuitBreaker state changed. apiId=%s, %s -> %s", event.getCircuitBreakerName(), fromState, toState))
// .build();
//
// AlarmStateManager.getAlarmStateManager().onEvnet(alarmEvent);
logger.warn("CircuitBreaker state changed. apiId={}, {} -> {}", event.getCircuitBreakerName(), fromState, toState);
TypedCircuitBreakerManager.logger.warn("CircuitBreaker state changed. apiId={}, {} -> {}", event.getCircuitBreakerName(), fromState, toState);
} else {
TypedCircuitBreakerManager.logger.error(
@@ -0,0 +1,266 @@
package com.eactive.eai.common.hsm;
import java.beans.PropertyChangeEvent;
import java.beans.PropertyChangeListener;
import java.security.KeyStore;
import java.security.PrivateKey;
import java.security.Provider;
import java.security.PublicKey;
import java.security.cert.Certificate;
import java.util.concurrent.ConcurrentHashMap;
import javax.annotation.PostConstruct;
import javax.crypto.Cipher;
import javax.crypto.SecretKey;
import javax.crypto.spec.IvParameterSpec;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import com.eactive.eai.common.property.PropManager;
import com.eactive.eai.common.util.Logger;
/**
* SafeNet ProtectServer HSM 암복호화 서비스 (JDK 8)
*
* [키 조회]
* getPublicKey - 공개키 반환 (외부 노출). 최초 1회만 HSM 통신, 이후 캐시 반환.
* getSecretKey - AES 대칭키 반환 (CKA_EXTRACTABLE=true 필요). 최초 1회만 HSM 통신, 이후 캐시 반환.
*
* [암복호화]
* encryptRsa - 공개키로 JVM 소프트웨어 암호화 (HSM 불필요)
* decryptRsa - alias 기반, HSM 내부 복호화 (매 호출마다 HSM 통신 발생 - 불가피)
* encryptAes - SecretKey 객체로 AES/CBC 암호화
* decryptAes - SecretKey 객체로 AES/CBC 복호화
*/
@Service
public class HsmCryptoService implements PropertyChangeListener {
static Logger logger = Logger.getLogger(Logger.LOGGER_DEFAULT);
private static final String RSA_ALGORITHM = "RSA/ECB/PKCS1Padding";
private static final String AES_ALGORITHM = "AES/CBC/PKCS5Padding";
private static final String PROP_GROUP = "HSM";
private static final String PROP_CACHE_RELOAD_YN = "CACHE_RELOAD_YN";
// HSM 통신 최소화: 최초 1회 조회 후 JVM 메모리에 캐싱
private final ConcurrentHashMap<String, SecretKey> secretKeyCache = new ConcurrentHashMap<String, SecretKey>();
private final ConcurrentHashMap<String, PublicKey> publicKeyCache = new ConcurrentHashMap<String, PublicKey>();
@Autowired
private PropManager propManager;
@PostConstruct
public void registerPropertyChangeListener() {
propManager.addPropertyChangeListener(this);
logger.warn("HsmCryptoService] PropManager PropertyChangeListener 등록 완료");
}
/**
* 관리포털에서 PropManager.reload("HSM") 호출 시 이벤트를 수신한다.
* HSM.CACHE_RELOAD_YN = Y 이면 키 캐시를 초기화한다.
*/
@Override
public void propertyChange(PropertyChangeEvent evt) {
if (!PROP_GROUP.equals(evt.getPropertyName())) {
return;
}
String reloadYn = propManager.getProperty(PROP_GROUP, PROP_CACHE_RELOAD_YN, "N");
if ("Y".equalsIgnoreCase(reloadYn)) {
clearKeyCache();
}
}
// -------------------------------------------------------------------------
// 키 조회 (외부 노출)
// -------------------------------------------------------------------------
/**
* HSM KeyStore 에서 공개키를 반환합니다. 최초 1회만 HSM 통신하고 이후 캐시를 반환합니다.
*/
public PublicKey getPublicKey(String keyAlias) throws HsmException {
checkReady();
PublicKey cached = publicKeyCache.get(keyAlias);
if (cached != null) {
return cached;
}
try {
Certificate cert = HsmManager.getInstance().getKeyStore().getCertificate(keyAlias);
if (cert == null) {
throw new HsmException("인증서를 찾을 수 없습니다. alias=" + keyAlias);
}
PublicKey key = cert.getPublicKey();
publicKeyCache.put(keyAlias, key);
logger.warn("HsmCryptoService] 공개키 캐시 등록: alias=" + keyAlias);
return key;
} catch (HsmException e) {
throw e;
} catch (Exception e) {
throw new HsmException("공개키 조회 실패: " + e.getMessage(), e);
}
}
/**
* HSM KeyStore 에서 AES 대칭키를 반환합니다. 최초 1회만 HSM 통신하고 이후 캐시를 반환합니다.
* HSM 키 생성 시 CKA_EXTRACTABLE=true (ctkmu -x 플래그) 로 생성된 키만 반환됩니다.
*/
public SecretKey getSecretKey(String keyAlias) throws HsmException {
checkReady();
SecretKey cached = secretKeyCache.get(keyAlias);
if (cached != null) {
return cached;
}
try {
KeyStore keyStore = HsmManager.getInstance().getKeyStore();
java.security.Key key = keyStore.getKey(keyAlias, null);
if (!(key instanceof SecretKey)) {
throw new HsmException("AES 키를 찾을 수 없습니다 (CKA_EXTRACTABLE=true 확인 필요). alias=" + keyAlias);
}
SecretKey secretKey = (SecretKey) key;
secretKeyCache.put(keyAlias, secretKey);
logger.warn("HsmCryptoService] 대칭키 캐시 등록: alias=" + keyAlias);
return secretKey;
} catch (HsmException e) {
throw e;
} catch (Exception e) {
throw new HsmException("AES 키 조회 실패: " + e.getMessage(), e);
}
}
/** 캐시를 비웁니다. HSM 키 교체 후 재로드가 필요할 때 호출합니다. */
public void clearKeyCache() {
secretKeyCache.clear();
publicKeyCache.clear();
logger.warn("HsmCryptoService] 키 캐시 초기화 완료");
}
// -------------------------------------------------------------------------
// RSA
// -------------------------------------------------------------------------
/**
* RSA 암호화 - HSM 인증서의 공개키로 JVM 소프트웨어 암호화.
* 공개키는 HSM 에서 추출하므로 HsmManager 가 초기화되어 있어야 합니다.
*/
public byte[] encryptRsa(String keyAlias, byte[] plaintext) throws HsmException {
return encryptRsa(getPublicKey(keyAlias), plaintext);
}
/**
* RSA 암호화 - 전달받은 공개키로 JVM 소프트웨어 암호화.
* HSM 없이도 호출 가능합니다.
*/
public byte[] encryptRsa(PublicKey publicKey, byte[] plaintext) throws HsmException {
try {
// Provider 미명시 → JVM 소프트웨어 Provider(SunRsaSign) 자동 선택
Cipher cipher = Cipher.getInstance(RSA_ALGORITHM);
cipher.init(Cipher.ENCRYPT_MODE, publicKey);
return cipher.doFinal(plaintext);
} catch (Exception e) {
throw new HsmException("RSA 암호화 실패: " + e.getMessage(), e);
}
}
/**
* RSA 복호화 - 개인키 핸들을 HSM 에서 가져와 HSM 내부에서 복호화.
* HsmManager 가 초기화되지 않은 경우 JVM 소프트웨어로 fallback 합니다.
*
* @param keyAlias HSM KeyStore 의 개인키 별칭
* @param pin HSM 슬롯 PIN (null 이면 HsmManager 초기화 시 사용한 PIN 재사용)
* @param ciphertext 암호문 바이트
*/
public byte[] decryptRsa(String keyAlias, char[] pin, byte[] ciphertext) throws HsmException {
try {
HsmManager hsmManager = HsmManager.getInstance();
if (hsmManager.isReady()) {
// HSM 내부 복호화: pkcs11Provider 명시 필수
Provider pkcs11Provider = hsmManager.getPkcs11Provider();
java.security.Key key = hsmManager.getKeyStore().getKey(keyAlias, pin);
if (!(key instanceof PrivateKey)) {
throw new HsmException("개인키를 찾을 수 없습니다. alias=" + keyAlias);
}
Cipher cipher = Cipher.getInstance(RSA_ALGORITHM, pkcs11Provider);
cipher.init(Cipher.DECRYPT_MODE, (PrivateKey) key);
return cipher.doFinal(ciphertext);
} else {
throw new HsmException("RSA 복호화 불가: HsmManager 가 초기화되지 않았습니다. alias=" + keyAlias);
}
} catch (HsmException e) {
throw e;
} catch (Exception e) {
throw new HsmException("RSA 복호화 실패: " + e.getMessage(), e);
}
}
// -------------------------------------------------------------------------
// AES
// -------------------------------------------------------------------------
/**
* AES/CBC/PKCS5Padding 암호화.
* extractable 키: provider=null 로 JVM 소프트웨어 암호화.
* non-extractable 키: provider 에 pkcs11Provider 를 전달해야 합니다.
*
* @param secretKey 대칭키 (HSM 추출 키 또는 소프트웨어 키)
* @param iv 초기화 벡터 (16바이트)
* @param plaintext 평문 바이트
* @param provider null 이면 JVM 기본 Provider 사용
*/
public byte[] encryptAes(SecretKey secretKey, byte[] iv, byte[] plaintext, Provider provider) throws HsmException {
try {
Cipher cipher = (provider != null)
? Cipher.getInstance(AES_ALGORITHM, provider)
: Cipher.getInstance(AES_ALGORITHM);
cipher.init(Cipher.ENCRYPT_MODE, secretKey, new IvParameterSpec(iv));
return cipher.doFinal(plaintext);
} catch (Exception e) {
throw new HsmException("AES 암호화 실패: " + e.getMessage(), e);
}
}
/** AES 암호화 - extractable 키 전용 (JVM 소프트웨어 Provider 사용). */
public byte[] encryptAes(SecretKey secretKey, byte[] iv, byte[] plaintext) throws HsmException {
return encryptAes(secretKey, iv, plaintext, null);
}
/**
* AES/CBC/PKCS5Padding 복호화.
* extractable 키: provider=null 로 JVM 소프트웨어 복호화.
* non-extractable 키: provider 에 pkcs11Provider 를 전달해야 합니다.
*
* @param secretKey 대칭키 (HSM 추출 키 또는 소프트웨어 키)
* @param iv 초기화 벡터 (16바이트)
* @param ciphertext 암호문 바이트
* @param provider null 이면 JVM 기본 Provider 사용
*/
public byte[] decryptAes(SecretKey secretKey, byte[] iv, byte[] ciphertext, Provider provider) throws HsmException {
try {
Cipher cipher = (provider != null)
? Cipher.getInstance(AES_ALGORITHM, provider)
: Cipher.getInstance(AES_ALGORITHM);
cipher.init(Cipher.DECRYPT_MODE, secretKey, new IvParameterSpec(iv));
return cipher.doFinal(ciphertext);
} catch (Exception e) {
throw new HsmException("AES 복호화 실패: " + e.getMessage(), e);
}
}
/** AES 복호화 - extractable 키 전용 (JVM 소프트웨어 Provider 사용). */
public byte[] decryptAes(SecretKey secretKey, byte[] iv, byte[] ciphertext) throws HsmException {
return decryptAes(secretKey, iv, ciphertext, null);
}
// -------------------------------------------------------------------------
// Private helpers
// -------------------------------------------------------------------------
private void checkReady() throws HsmException {
if (!HsmManager.getInstance().isReady()) {
throw new HsmException("HsmManager 가 초기화되지 않았습니다.");
}
}
}
@@ -0,0 +1,13 @@
package com.eactive.eai.common.hsm;
public class HsmException extends Exception {
private static final long serialVersionUID = 1L;
public HsmException(String msg) {
super(msg);
}
public HsmException(String msg, Throwable cause) {
super(msg, cause);
}
}
@@ -0,0 +1,183 @@
package com.eactive.eai.common.hsm;
import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.InputStream;
import java.lang.reflect.Method;
import java.nio.file.Files;
import java.security.KeyStore;
import java.security.Provider;
import java.security.Security;
import org.springframework.stereotype.Component;
import com.eactive.eai.common.exception.ExceptionUtil;
import com.eactive.eai.common.lifecycle.Lifecycle;
import com.eactive.eai.common.lifecycle.LifecycleException;
import com.eactive.eai.common.lifecycle.LifecycleListener;
import com.eactive.eai.common.lifecycle.LifecycleSupport;
import com.eactive.eai.common.property.PropManager;
import com.eactive.eai.common.util.ApplicationContextProvider;
import com.eactive.eai.common.util.Logger;
/**
* SafeNet ProtectServer HSM 연동 관리자 (JDK 8 / SunPKCS11)
*
* PropManager 그룹 "HSM" 에서 읽는 :
* PKCS11_CONFIG - pkcs11.cfg 파일 내용 (name/library/slot 설정을 DB에 직접 저장)
* PIN - HSM 슬롯 PIN
*
* PKCS11_CONFIG 예시 (개행은 \n 으로 입력):
* name = ProtectServer
* library = /opt/safenet/protecttoolkit5/ptk/lib/libcryptoki.so
* slot = 0
*/
@Component
public class HsmManager implements Lifecycle {
static Logger logger = Logger.getLogger(Logger.LOGGER_DEFAULT);
private static final String GROUP_NAME = "HSM";
private static final String PROP_CONFIG = "PKCS11_CONFIG";
private static final String PROP_PIN = "PIN";
private Provider pkcs11Provider;
private KeyStore keyStore;
private boolean started;
private final LifecycleSupport lifecycle = new LifecycleSupport(this);
private HsmManager() {
}
public static HsmManager getInstance() {
return ApplicationContextProvider.getContext().getBean(HsmManager.class);
}
@Override
public void start() throws LifecycleException {
if (started) {
throw new LifecycleException("RECEAIHSM001");
}
lifecycle.fireLifecycleEvent(STARTING_EVENT, this);
try {
init();
} catch (Exception e) {
throw new LifecycleException(ExceptionUtil.getErrorCode(e, "RECEAIHSM002"));
}
started = true;
lifecycle.fireLifecycleEvent(STARTED_EVENT, this);
}
private void init() throws Exception {
String configContent = PropManager.getInstance().getProperty(GROUP_NAME, PROP_CONFIG);
String pin = PropManager.getInstance().getProperty(GROUP_NAME, PROP_PIN);
if (configContent == null || configContent.trim().isEmpty()) {
logger.warn("HsmManager] PKCS11_CONFIG 가 설정되지 않았습니다. HSM 초기화를 건너뜁니다.");
return;
}
pkcs11Provider = createProvider(configContent.trim().replace("\\n", "\n"));
// 이미 등록된 Provider 있으면 제거 재등록
Provider existing = Security.getProvider(pkcs11Provider.getName());
if (existing != null) {
Security.removeProvider(existing.getName());
}
Security.addProvider(pkcs11Provider);
keyStore = KeyStore.getInstance("PKCS11", pkcs11Provider);
char[] pinChars = (pin != null) ? pin.toCharArray() : null;
keyStore.load(null, pinChars);
logger.warn("HsmManager] 초기화 완료. Provider=" + pkcs11Provider.getName());
java.util.Enumeration<String> aliases = keyStore.aliases();
StringBuilder aliasList = new StringBuilder();
while (aliases.hasMoreElements()) {
if (aliasList.length() > 0) aliasList.append(", ");
aliasList.append(aliases.nextElement());
}
logger.warn("HsmManager] HSM 키 목록: [" + aliasList + "]");
}
/**
* JDK 버전에 따라 SunPKCS11 Provider 생성한다.
*
* JDK 8 : SunPKCS11(InputStream) 생성자를 리플렉션으로 호출
* JDK 9+: Provider.configure(configFilePath) 리플렉션으로 호출
*
* 경로 모두 리플렉션을 사용하므로 컴파일 타임에 JDK 버전 의존성이 없다.
*/
static Provider createProvider(String cfgContent) throws Exception {
String javaVersion = System.getProperty("java.version");
if (javaVersion.startsWith("1.")) {
// JDK 8: new SunPKCS11(InputStream)
InputStream is = new ByteArrayInputStream(cfgContent.getBytes("UTF-8"));
return (Provider) Class.forName("sun.security.pkcs11.SunPKCS11")
.getConstructor(InputStream.class)
.newInstance(is);
} else {
// JDK 9+: Security.getProvider("SunPKCS11").configure(configFilePath)
// configure() JDK 9 에서 추가된 메서드이므로 리플렉션으로 호출
File tmp = File.createTempFile("pkcs11-hsm-", ".cfg");
tmp.deleteOnExit();
Files.write(tmp.toPath(), cfgContent.getBytes("UTF-8"));
Provider base = Security.getProvider("SunPKCS11");
Method configure = Provider.class.getMethod("configure", String.class);
return (Provider) configure.invoke(base, tmp.getAbsolutePath());
}
}
@Override
public void stop() throws LifecycleException {
if (!started) {
throw new LifecycleException("RECEAIHSM003");
}
lifecycle.fireLifecycleEvent(STOPING_EVENT, this);
if (pkcs11Provider != null) {
Security.removeProvider(pkcs11Provider.getName());
}
pkcs11Provider = null;
keyStore = null;
started = false;
lifecycle.fireLifecycleEvent(STOPPED_EVENT, this);
}
@Override
public void addLifecycleListener(LifecycleListener listener) {
lifecycle.addLifecycleListener(listener);
}
@Override
public LifecycleListener[] findLifecycleListeners() {
return lifecycle.findLifecycleListeners();
}
@Override
public void removeLifecycleListener(LifecycleListener listener) {
lifecycle.removeLifecycleListener(listener);
}
@Override
public boolean isStarted() {
return started;
}
public Provider getPkcs11Provider() {
return pkcs11Provider;
}
public KeyStore getKeyStore() {
return keyStore;
}
public boolean isReady() {
return started && pkcs11Provider != null && keyStore != null;
}
}
@@ -41,20 +41,36 @@ public class InflowControlDAO extends BaseDAO {
@Autowired
private InflowControlGroupMappingLoader inflowControlGroupMappingLoader;
// -------------------------------------------------------------------------
// Enum 기반 범용 메서드 type 코드를 직접 노출하지 않음
// -------------------------------------------------------------------------
public List<InflowTargetVO> getInflowList(InflowType type) throws DAOException {
return getInflowList(type.code());
}
public Map<String, InflowTargetVO> getInflowMap(InflowType type, String name) throws DAOException {
return getInflowMap(type.code(), name);
}
// -------------------------------------------------------------------------
// 기존 명명 메서드 enum 기반 메서드로 위임
// -------------------------------------------------------------------------
public List<InflowTargetVO> getInflowAdapterList() throws DAOException {
return getInflowList("01");
return getInflowList(InflowType.ADAPTER);
}
public List<InflowTargetVO> getInflowInterfaceList() throws DAOException {
return getInflowList("02");
return getInflowList(InflowType.INTERFACE);
}
public Map<String, InflowTargetVO> getInflowTargetByAdater(String name) throws DAOException {
return getInflowMap("01", name);
return getInflowMap(InflowType.ADAPTER, name);
}
public Map<String, InflowTargetVO> getInflowTargetByInterface(String name) throws DAOException {
return getInflowMap("02", name);
return getInflowMap(InflowType.INTERFACE, name);
}
private List<InflowTargetVO> getInflowList(String type) throws DAOException {
@@ -7,6 +7,7 @@ import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
@@ -39,11 +40,11 @@ public class InflowControlManager implements Lifecycle, Bucket {
public static final String QUOTA_TIMEUNIT_DAY = "DAY";
public static final String QUOTA_TIMEUNIT_MONTH = "MON";
private Map<String, CustomBucket> adapterBucketList = new HashMap<>();
private Map<String, CustomBucket> interfaceBucketList = new HashMap<>();
private Map<String, CustomGroupBucket> groupBucketList = new HashMap<>();
private Map<String, String> interfaceToGroupMap = new HashMap<>();
private Map<String, InflowGroupVO> groupVoMap = new HashMap<>();
private volatile Map<String, CustomBucket> adapterBucketList = new ConcurrentHashMap<>();
private volatile Map<String, CustomBucket> interfaceBucketList = new ConcurrentHashMap<>();
private volatile Map<String, CustomGroupBucket> groupBucketList = new ConcurrentHashMap<>();
private volatile Map<String, String> interfaceToGroupMap = new ConcurrentHashMap<>();
private volatile Map<String, InflowGroupVO> groupVoMap = new ConcurrentHashMap<>();
private boolean started;
private LifecycleSupport lifecycle = new LifecycleSupport(this);
@@ -97,37 +98,39 @@ public class InflowControlManager implements Lifecycle, Bucket {
}
private void initAdapter() throws Exception {
adapterBucketList = new HashMap<>();
Map<String, CustomBucket> newMap = new HashMap<>();
List<InflowTargetVO> inflowAdapterVoList = inflowControlDAO.getInflowAdapterList();
for (InflowTargetVO inflowVo : inflowAdapterVoList) {
if (InflowControlManager.isInflowTarget(inflowVo)) {
CustomBucket bucket = makeBucketUsingInflowVO(inflowVo);
if (bucket != null) {
adapterBucketList.put(inflowVo.getName(), bucket);
newMap.put(inflowVo.getName(), bucket);
}
}
}
this.adapterBucketList = newMap;
}
private void initInterface() throws Exception {
interfaceBucketList = new HashMap<>();
Map<String, CustomBucket> newMap = new HashMap<>();
List<InflowTargetVO> inflowInterfaceVoList = inflowControlDAO.getInflowInterfaceList();
for (InflowTargetVO inflowVo : inflowInterfaceVoList) {
if (InflowControlManager.isInflowTarget(inflowVo)) {
CustomBucket bucket = makeBucketUsingInflowVO(inflowVo);
if (bucket != null) {
interfaceBucketList.put(inflowVo.getName(), bucket);
newMap.put(inflowVo.getName(), bucket);
}
}
}
this.interfaceBucketList = newMap;
}
private void initGroup() throws Exception {
groupBucketList = new HashMap<>();
interfaceToGroupMap = new HashMap<>();
groupVoMap = new HashMap<>();
Map<String, CustomGroupBucket> newGroupBucketList = new HashMap<>();
Map<String, String> newInterfaceToGroupMap = new HashMap<>();
Map<String, InflowGroupVO> newGroupVoMap = new HashMap<>();
List<InflowGroupVO> inflowGroupVoList = inflowControlDAO.getInflowGroupList();
@@ -135,20 +138,24 @@ public class InflowControlManager implements Lifecycle, Bucket {
if (isInflowGroupTarget(groupVo)) {
CustomGroupBucket bucket = makeGroupBucket(groupVo);
if (bucket != null) {
groupBucketList.put(groupVo.getGroupId(), bucket);
groupVoMap.put(groupVo.getGroupId(), groupVo);
newGroupBucketList.put(groupVo.getGroupId(), bucket);
newGroupVoMap.put(groupVo.getGroupId(), groupVo);
// 인터페이스 그룹 매핑 등록
for (String interfaceId : groupVo.getInterfaceList()) {
interfaceToGroupMap.put(interfaceId, groupVo.getGroupId());
newInterfaceToGroupMap.put(interfaceId, groupVo.getGroupId());
}
}
}
}
if (logger.isInfo()) {
logger.info("InflowControlManager] initGroup completed. groups=" + groupBucketList.size()
+ ", mappings=" + interfaceToGroupMap.size());
logger.info("InflowControlManager] initGroup completed. groups=" + newGroupBucketList.size()
+ ", mappings=" + newInterfaceToGroupMap.size());
}
this.groupBucketList = newGroupBucketList;
this.interfaceToGroupMap = newInterfaceToGroupMap;
this.groupVoMap = newGroupVoMap;
}
public synchronized void reloadAdapter() throws Exception {
@@ -293,11 +300,11 @@ public class InflowControlManager implements Lifecycle, Bucket {
// Notify our interested LifecycleListeners
lifecycle.fireLifecycleEvent(STOPING_EVENT, this);
adapterBucketList.clear();
interfaceBucketList.clear();
groupBucketList.clear();
interfaceToGroupMap.clear();
groupVoMap.clear();
adapterBucketList = new ConcurrentHashMap<>();
interfaceBucketList = new ConcurrentHashMap<>();
groupBucketList = new ConcurrentHashMap<>();
interfaceToGroupMap = new ConcurrentHashMap<>();
groupVoMap = new ConcurrentHashMap<>();
started = false;
// Notify our interested LifecycleListeners
@@ -340,15 +347,15 @@ public class InflowControlManager implements Lifecycle, Bucket {
return svcCd;
}
public void removeAdapter(String adapter) {
public synchronized void removeAdapter(String adapter) {
adapterBucketList.remove(adapter);
}
public void removeInterface(String inter) {
public synchronized void removeInterface(String inter) {
interfaceBucketList.remove(inter);
}
public void removeGroup(String groupId) {
public synchronized void removeGroup(String groupId) {
groupBucketList.remove(groupId);
groupVoMap.remove(groupId);
// 해당 그룹에 속한 인터페이스 매핑도 제거
@@ -371,6 +378,10 @@ public class InflowControlManager implements Lifecycle, Bucket {
return groupIds;
}
public CustomGroupBucket getGroupBucket(String groupId) {
return groupBucketList.get(groupId);
}
private CustomBucket makeBucketUsingInflowVO(InflowTargetVO inflowTarget) {
if (InflowControlManager.isInflowTarget(inflowTarget)) {
LocalBucketBuilder builder = Bucket4j.builder();
@@ -0,0 +1,23 @@
package com.eactive.eai.common.inflow;
/**
* 유량제어 대상 유형.
* {@link InflowControlDAO} type 컬럼 값을 enum으로 관리하여
* 호출부에 코드 문자열이 노출되지 않도록 한다.
*/
public enum InflowType {
ADAPTER("01"),
INTERFACE("02"),
CLIENT("03");
private final String code;
InflowType(String code) {
this.code = code;
}
public String code() {
return code;
}
}
@@ -0,0 +1,37 @@
package com.eactive.eai.common.inflow.dual;
import com.eactive.eai.common.inflow.Bucket;
import com.eactive.eai.common.inflow.InflowTargetVO;
/**
* Bucket 인터페이스 확장.
* 기존 isAdapterPass/isInterfacePass(boolean) 더해
* 어느 버킷(PER_SECOND/THRESHOLD)에서 차단됐는지 반환하는 메서드 추가.
* 클라이언트별 유량제어 메서드도 포함.
*/
public interface DualBucket extends Bucket {
/**
* 어댑터 유량제어 체크 차단 원인 포함.
* @return null이면 통과, "PER_SECOND" 또는 "THRESHOLD"이면 해당 버킷에서 차단
*/
String isAdapterPassDetail(String adapter);
/**
* 인터페이스 유량제어 체크 차단 원인 포함.
* @return null이면 통과, "PER_SECOND" 또는 "THRESHOLD"이면 해당 버킷에서 차단
*/
String isInterfacePassDetail(String inter);
/**
* 클라이언트 유량제어 체크 차단 원인 포함.
* @return null이면 통과, "PER_SECOND" 또는 "THRESHOLD"이면 해당 버킷에서 차단
*/
String isClientPassDetail(String clientId);
/**
* 클라이언트 유량제어 설정 조회 에러 메시지 생성용.
* @return 등록된 설정이 없으면 null
*/
InflowTargetVO getClientInflowThreshold(String clientId);
}
@@ -0,0 +1,58 @@
package com.eactive.eai.common.inflow.dual;
import com.eactive.eai.common.inflow.InflowTargetVO;
import io.github.bucket4j.local.LocalBucket;
/**
* 어댑터/인터페이스 유량제어 버킷.
* 기존 CustomBucket(단일 버킷) 달리 perSecond/threshold를 별도 버킷으로 분리하여
* 어느 한도를 초과했는지 구분 가능 (CustomGroupBucket과 동일한 이중 구조).
*/
public class DualCustomBucket {
public static final String RESULT_PASS = null;
public static final String RESULT_BLOCKED_PER_SECOND = "PER_SECOND";
public static final String RESULT_BLOCKED_THRESHOLD = "THRESHOLD";
private final LocalBucket perSecondBucket;
private final LocalBucket thresholdBucket;
private final InflowTargetVO inflowTargetVo;
public DualCustomBucket(LocalBucket perSecondBucket, LocalBucket thresholdBucket, InflowTargetVO inflowTargetVo) {
this.perSecondBucket = perSecondBucket;
this.thresholdBucket = thresholdBucket;
this.inflowTargetVo = inflowTargetVo;
}
/**
* 버킷을 순서대로 소비 시도.
* @return null이면 통과, "PER_SECOND" 또는 "THRESHOLD"이면 해당 버킷에서 차단
*/
public String tryConsume() {
boolean perSecondPass = (perSecondBucket == null) || perSecondBucket.tryConsume(1);
if (!perSecondPass) {
return RESULT_BLOCKED_PER_SECOND;
}
boolean thresholdPass = (thresholdBucket == null) || thresholdBucket.tryConsume(1);
if (!thresholdPass) {
// perSecond 토큰은 이미 소비됨 Token Bucket 특성상 롤백 불가
// 초당 버킷은 빠르게 리필되므로 실질적 영향 미미
return RESULT_BLOCKED_THRESHOLD;
}
return RESULT_PASS;
}
public long getPerSecondAvailableTokens() {
return (perSecondBucket != null) ? perSecondBucket.getAvailableTokens() : -1;
}
public long getThresholdAvailableTokens() {
return (thresholdBucket != null) ? thresholdBucket.getAvailableTokens() : -1;
}
public LocalBucket getPerSecondBucket() { return perSecondBucket; }
public LocalBucket getThresholdBucket() { return thresholdBucket; }
public InflowTargetVO getInflowTargetVo() { return inflowTargetVo; }
}
@@ -0,0 +1,313 @@
package com.eactive.eai.common.inflow.dual;
import java.time.Duration;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import com.eactive.eai.common.inflow.Bucket;
import com.eactive.eai.common.inflow.CustomGroupBucket;
import com.eactive.eai.common.inflow.InflowControlDAO;
import com.eactive.eai.common.inflow.InflowControlManager;
import com.eactive.eai.common.inflow.InflowTargetVO;
import com.eactive.eai.common.inflow.InflowType;
import com.eactive.eai.common.lifecycle.LifecycleException;
import com.eactive.eai.common.message.EAIMessage;
import com.eactive.eai.common.server.EAIServerManager;
import com.eactive.eai.common.util.ApplicationContextProvider;
import com.eactive.eai.common.util.Logger;
import io.github.bucket4j.Bandwidth;
import io.github.bucket4j.Bucket4j;
import io.github.bucket4j.local.LocalBucket;
/**
* 어댑터/인터페이스/클라이언트 버킷을 이중 구조(perSecond + threshold) 관리하는 유량제어 관리자.
*
* <p>기존 InflowControlManager는 어댑터/인터페이스에 단일 LocalBucket(복수 Bandwidth) 사용하여
* 차단 어느 한도(초당/기간) 초과했는지 없었다.
* 클래스는 그룹 버킷(CustomGroupBucket) 동일하게 버킷을 분리하고
* {@link DualBucket#isAdapterPassDetail}/{@link DualBucket#isInterfacePassDetail}
* 차단 원인을 반환한다. 그룹 버킷 통과 여부는 {@link TargetMetrics} 카운팅된다.
*
* <p>적용 방법: INFLOW.properties에 아래 추가.
* <pre>inflow.control.bucket.className=com.eactive.eai.common.inflow.dual.DualInflowControlManager</pre>
*/
@Component
public class DualInflowControlManager extends InflowControlManager implements DualBucket {
private static final Logger logger = Logger.getLogger(Logger.LOGGER_DEFAULT);
// InflowControlUtil이 리플렉션으로 호출하는 진입점
public static synchronized Bucket getBucket() {
return ApplicationContextProvider.getContext().getBean(DualInflowControlManager.class);
}
public static Boolean isTargetOfInflowControl(EAIServerManager eaiServerManager, EAIMessage eaiMessage) {
return InflowControlManager.isTargetOfInflowControl(eaiServerManager, eaiMessage);
}
@Autowired
private InflowControlDAO dualDao;
private volatile Map<String, DualCustomBucket> adapterBucketMap = new ConcurrentHashMap<>();
private volatile Map<String, DualCustomBucket> interfaceBucketMap = new ConcurrentHashMap<>();
private volatile Map<String, DualCustomBucket> clientBucketMap = new ConcurrentHashMap<>();
private final ConcurrentHashMap<String, TargetMetrics> groupMetricsMap = new ConcurrentHashMap<>();
public static class TargetMetrics {
public final AtomicLong allowed = new AtomicLong();
public final AtomicLong rejectedPerSecond = new AtomicLong();
public final AtomicLong rejectedThreshold = new AtomicLong();
public volatile long lastResetTime = System.currentTimeMillis();
public void reset() {
allowed.set(0);
rejectedPerSecond.set(0);
rejectedThreshold.set(0);
lastResetTime = System.currentTimeMillis();
}
}
// -------------------------------------------------------------------------
// Lifecycle
// -------------------------------------------------------------------------
@Override
public void start() throws LifecycleException {
super.start();
try {
initAdapters();
initInterfaces();
initClients();
} catch (Exception e) {
throw new LifecycleException("DualInflowControlManager init failed: " + e.getMessage());
}
}
// -------------------------------------------------------------------------
// 초기화 / 리로드
// -------------------------------------------------------------------------
private void initAdapters() throws Exception {
this.adapterBucketMap = loadBucketMap(InflowType.ADAPTER);
if (logger.isInfo()) logger.info("DualInflowControlManager] initAdapters: " + adapterBucketMap.size() + " buckets");
}
private void initInterfaces() throws Exception {
this.interfaceBucketMap = loadBucketMap(InflowType.INTERFACE);
if (logger.isInfo()) logger.info("DualInflowControlManager] initInterfaces: " + interfaceBucketMap.size() + " buckets");
}
private void initClients() throws Exception {
this.clientBucketMap = loadBucketMap(InflowType.CLIENT);
if (logger.isInfo()) logger.info("DualInflowControlManager] initClients: " + clientBucketMap.size() + " buckets");
}
private Map<String, DualCustomBucket> loadBucketMap(InflowType type) throws Exception {
Map<String, DualCustomBucket> newMap = new HashMap<>();
for (InflowTargetVO vo : dualDao.getInflowList(type)) {
DualCustomBucket bucket = makeBucket(vo);
if (bucket != null) newMap.put(vo.getName(), bucket);
}
return newMap;
}
@Override
public synchronized void reloadAdapter() throws Exception {
super.reloadAdapter();
initAdapters();
}
@Override
public synchronized void reloadAdapter(String adapter) throws Exception {
super.reloadAdapter(adapter);
reloadBucketMap(adapterBucketMap, InflowType.ADAPTER, adapter);
}
@Override
public synchronized void reloadInterface() throws Exception {
super.reloadInterface();
initInterfaces();
}
@Override
public synchronized void reloadInterface(String inter) throws Exception {
super.reloadInterface(inter);
reloadBucketMap(interfaceBucketMap, InflowType.INTERFACE, inter);
}
public synchronized void reloadClient() throws Exception {
initClients();
}
public synchronized void reloadClient(String clientId) throws Exception {
reloadBucketMap(clientBucketMap, InflowType.CLIENT, clientId);
}
@Override
public synchronized void reloadGroup() throws Exception {
super.reloadGroup();
groupMetricsMap.clear();
}
@Override
public synchronized void reloadGroup(String groupId) throws Exception {
super.reloadGroup(groupId);
TargetMetrics m = groupMetricsMap.get(groupId);
if (m != null) m.reset();
}
private void reloadBucketMap(Map<String, DualCustomBucket> targetMap, InflowType type, String name) throws Exception {
Map<String, InflowTargetVO> updated = dualDao.getInflowMap(type, name);
if (updated == null) return;
for (InflowTargetVO vo : updated.values()) {
DualCustomBucket bucket = makeBucket(vo);
if (bucket != null) targetMap.put(vo.getName(), bucket);
else targetMap.remove(vo.getName());
}
}
// -------------------------------------------------------------------------
// 그룹 메트릭 isGroupPass() 카운팅 접근자
// -------------------------------------------------------------------------
@Override
public String isGroupPass(String groupId) {
String result = super.isGroupPass(groupId);
TargetMetrics m = groupMetricsMap.computeIfAbsent(groupId, k -> new TargetMetrics());
if (result == null) {
m.allowed.incrementAndGet();
} else if (CustomGroupBucket.RESULT_BLOCKED_PER_SECOND.equals(result)) {
m.rejectedPerSecond.incrementAndGet();
} else {
m.rejectedThreshold.incrementAndGet();
}
return result;
}
public TargetMetrics getGroupMetrics(String groupId) {
return groupMetricsMap.get(groupId);
}
public Map<String, TargetMetrics> getAllGroupMetrics() {
return Collections.unmodifiableMap(groupMetricsMap);
}
public void resetGroupMetrics(String groupId) {
TargetMetrics m = groupMetricsMap.get(groupId);
if (m != null) m.reset();
}
public void resetAllGroupMetrics() {
groupMetricsMap.values().forEach(TargetMetrics::reset);
}
// -------------------------------------------------------------------------
// DualBucket 차단 원인 포함 메서드
// -------------------------------------------------------------------------
@Override
public String isAdapterPassDetail(String adapter) {
DualCustomBucket b = adapterBucketMap.get(adapter);
return (b == null) ? DualCustomBucket.RESULT_PASS : b.tryConsume();
}
@Override
public String isInterfacePassDetail(String inter) {
DualCustomBucket b = interfaceBucketMap.get(inter);
return (b == null) ? DualCustomBucket.RESULT_PASS : b.tryConsume();
}
@Override
public String isClientPassDetail(String clientId) {
DualCustomBucket b = clientBucketMap.get(clientId);
return (b == null) ? DualCustomBucket.RESULT_PASS : b.tryConsume();
}
@Override
public InflowTargetVO getClientInflowThreshold(String clientId) {
DualCustomBucket b = clientBucketMap.get(clientId);
return (b == null) ? null : b.getInflowTargetVo();
}
// -------------------------------------------------------------------------
// Bucket 인터페이스 오버라이드 이중 버킷으로 교체 (boolean 반환 유지)
// isAdapterPassDetail/isInterfacePassDetail이 토큰을 소비하므로
// 메서드를 동일 요청에서 중복 호출하면 토큰이 이중 소비됨에 주의.
// -------------------------------------------------------------------------
@Override
public boolean isAdapterPass(String adapter) {
return isAdapterPassDetail(adapter) == DualCustomBucket.RESULT_PASS;
}
@Override
public boolean isInterfacePass(String inter) {
return isInterfacePassDetail(inter) == DualCustomBucket.RESULT_PASS;
}
// -------------------------------------------------------------------------
// 모니터링용 접근자
// -------------------------------------------------------------------------
public DualCustomBucket getAdapterBucket(String adapter) {
return adapterBucketMap.get(adapter);
}
public DualCustomBucket getInterfaceBucket(String inter) {
return interfaceBucketMap.get(inter);
}
public Map<String, DualCustomBucket> getAdapterBucketMap() {
return adapterBucketMap;
}
public Map<String, DualCustomBucket> getInterfaceBucketMap() {
return interfaceBucketMap;
}
public DualCustomBucket getClientBucket(String clientId) {
return clientBucketMap.get(clientId);
}
public Map<String, DualCustomBucket> getClientBucketMap() {
return clientBucketMap;
}
// -------------------------------------------------------------------------
// 버킷 팩토리
// -------------------------------------------------------------------------
private DualCustomBucket makeBucket(InflowTargetVO vo) {
if (!InflowControlManager.isInflowTarget(vo)) {
return null;
}
LocalBucket perSecondBucket = null;
LocalBucket thresholdBucket = null;
if (vo.getThresholdPerSecond() > 0) {
perSecondBucket = Bucket4j.builder()
.addLimit(Bandwidth.simple(vo.getThresholdPerSecond(), Duration.ofSeconds(1)))
.build();
}
if (vo.getThreshold() > 0 && StringUtils.isNotBlank(vo.getThresholdTimeUnit())) {
thresholdBucket = Bucket4j.builder()
.addLimit(Bandwidth.simple(vo.getThreshold(),
InflowControlManager.getPeriod(vo.getThresholdTimeUnit())))
.build();
}
return new DualCustomBucket(perSecondBucket, thresholdBucket, vo);
}
}
@@ -22,6 +22,10 @@ public class HttpAdapterExtraLogUtil {
static Logger logger = Logger.getLogger(Logger.LOGGER_DEFAULT);
public static boolean isHttpHeaderMode() {
return StringUtils.equals(System.getProperty("use.http.header.log", "n"), "y");
}
public static void insertHttpAdapterExtraLog(String guid, int serviceProcessNumber, String adapterGroupName, String adapterName, Map<Object, Object> headers, String url, String httpMethod){
insertHttpAdapterExtraLog(guid, serviceProcessNumber, adapterGroupName, adapterName, headers, url, httpMethod, 0);
}
@@ -23,6 +23,7 @@ import org.w3c.dom.Document;
import com.eactive.eai.common.EAIKeys;
import com.eactive.eai.common.message.MessageType;
import com.eactive.eai.common.monitor.EAIServiceMonitor;
import com.eactive.eai.common.property.PropManager;
import com.eactive.eai.transformer.message.ISO8583Message;
import com.eactive.eai.transformer.message.ISO8583MessageFactory;
import com.eactive.eai.transformer.util.IConv;
@@ -35,7 +36,8 @@ public final class MessageUtil {
static Logger logger = LoggerFactory.getLogger(MessageUtil.class);
//public static final String ERROR_MESSAGE_DEFAULT_FORMAT = "{\"error\":{\"code\":\"%s\",\"message\":\"%s\"}}";
public static final String ERROR_MESSAGE_DEFAULT_FORMAT = "{\"code\":\"%s\",\"message\":\"%s\"}";
// public static final String ERROR_MESSAGE_DEFAULT_FORMAT = "{\"code\":\"%s\",\"message\":\"%s\"}";
public static final String ERROR_MESSAGE_DEFAULT_FORMAT = "{\"error\":\"%s\",\"error_description\":\"%s\"}";
public static final String ERROR_CODE_AP_ERROR = "E.GW.AP_ERROR";
public static final String ERROR_CODE_AUTH_FAIL = "E.GW.AUTH_FAIL";
@@ -516,7 +518,8 @@ public final class MessageUtil {
public static String makeJsonErrorMessage(String code, String msg, String errorFormat) {
if (StringUtils.isBlank(errorFormat)) {
errorFormat = ERROR_MESSAGE_DEFAULT_FORMAT;
errorFormat = PropManager.getInstance().getProperty("MessageUtil",
"ERROR_MESSAGE_DEFAULT_FORMAT", ERROR_MESSAGE_DEFAULT_FORMAT);
}
// errorFormat에 %s가 1개만 있으면 msg만, 2개 이상이면 code, msg 전달
@@ -427,15 +427,15 @@ public class BaseFCProcess extends Process {
public void setGUID() throws Exception {
InterfaceMapper mapper = this.resEaiMsg.getMapper();
StandardMessage standardMessage = this.resEaiMsg.getStandardMessage();
String guid = mapper.getGuid(standardMessage);
String guidseq = mapper.getGuidSeq(standardMessage);
if (logger.isDebug())
logger.debug(guidLogPrefix + " REQ Current GUID [" + guid + "][" + guidseq + "]");
mapper.nextGuidSeq(standardMessage);
guidseq = mapper.getGuidSeq(standardMessage);
if (logger.isDebug())
logger.debug(guidLogPrefix + " REQ ADD PATHSEQ GUID [" + guid + "][" + guidseq + "]");
// String guid = mapper.getGuid(standardMessage);
// String guidseq = mapper.getGuidSeq(standardMessage);
//
// if (logger.isDebug())
// logger.debug(guidLogPrefix + " REQ Current GUID [" + guid + "][" + guidseq + "]");
// mapper.nextGuidSeq(standardMessage);
// guidseq = mapper.getGuidSeq(standardMessage);
// if (logger.isDebug())
// logger.debug(guidLogPrefix + " REQ ADD PATHSEQ GUID [" + guid + "][" + guidseq + "]");
String svcTsmtUsgTp = this.reqEaiMsg.getSvcTsmtUsgTp(); // 기동 호출방식
String psvItfTp = this.reqEaiMsg.getCurrentSvcMsg().getPsvItfTp(); // 수동 호출방식
@@ -0,0 +1,79 @@
package com.eactive.eai.inbound.processor;
import org.apache.commons.lang3.StringUtils;
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;
/**
* 어댑터/인터페이스 유량제어 차단 어느 버킷(PER_SECOND/THRESHOLD)에서 차단됐는지
* 에러 메시지에 포함하는 RequestProcessor 확장.
*
* <p>DualInflowControlManager와 함께 사용:
* <pre>
* inflow.control.bucket.className=com.eactive.eai.common.inflow.dual.DualInflowControlManager
* </pre>
*
* <p>Spring Bean 등록 기존 RequestProcessor 대신 클래스를 어댑터에 설정한다.
*/
public class DualRequestProcessor extends RequestProcessor {
@Override
protected String checkClientInflow(Bucket bucket, String clientId) {
if (StringUtils.isBlank(clientId)) return null;
if (bucket instanceof DualBucket) {
return ((DualBucket) bucket).isClientPassDetail(clientId);
}
return null;
}
@Override
protected InflowTargetVO resolveClientInflowThreshold(Bucket bucket, String clientId) {
if (bucket instanceof DualBucket) {
return ((DualBucket) bucket).getClientInflowThreshold(clientId);
}
return null;
}
@Override
protected String checkAdapterInflow(Bucket bucket, String adapterGroupName) {
if (bucket instanceof DualBucket) {
return ((DualBucket) bucket).isAdapterPassDetail(adapterGroupName);
}
return super.checkAdapterInflow(bucket, adapterGroupName);
}
@Override
protected String checkInterfaceInflow(Bucket bucket, String eaiSvcCd) {
if (bucket instanceof DualBucket) {
return ((DualBucket) bucket).isInterfacePassDetail(eaiSvcCd);
}
return super.checkInterfaceInflow(bucket, eaiSvcCd);
}
/**
* 차단 원인에 따라 에러 메시지에 한도 정보를 포함.
* <ul>
* <li>PER_SECOND: "API 호출 한도 초과 [name: Nreq/sec]"</li>
* <li>THRESHOLD: "API 호출 한도 초과 [name: Nreq/UNIT]"</li>
* <li> : "API 호출 한도 초과 [name]" (기존 동작)</li>
* </ul>
*/
@Override
protected String buildInflowTargetErrorMsg(InflowTargetVO inflowTargetVO, String blockedType) {
if (DualCustomBucket.RESULT_BLOCKED_PER_SECOND.equals(blockedType)) {
return String.format("API 호출 한도 초과 [%s: %dreq/sec]",
inflowTargetVO.getName(),
inflowTargetVO.getThresholdPerSecond());
}
if (DualCustomBucket.RESULT_BLOCKED_THRESHOLD.equals(blockedType)) {
return String.format("API 호출 한도 초과 [%s: %dreq/%s]",
inflowTargetVO.getName(),
inflowTargetVO.getThreshold(),
inflowTargetVO.getThresholdTimeUnit());
}
return super.buildInflowTargetErrorMsg(inflowTargetVO, blockedType);
}
}
@@ -4,6 +4,9 @@ import java.util.Calendar;
import java.util.Properties;
import java.util.Set;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import com.eactive.eai.common.TransactionContextKeys;
import com.eactive.eai.common.context.ElinkTransactionContext;
import com.eactive.eai.common.util.*;
@@ -85,8 +88,8 @@ import java.util.Map;
public class RequestProcessor extends RequestProcessorSupport {
private static String instid = null;
private static String serverName = null;
protected static String instid = null;
protected static String serverName = null;
//private static boolean isPEAIServer = true;
static {
@@ -97,7 +100,9 @@ public class RequestProcessor extends RequestProcessorSupport {
instid = eaiServerManager.getGroupInstId();
}
private ProcessVO setVO(Properties prop,long msgRcvTm) throws RequestProcessorException{
protected static boolean httpHeaderLogMode = HttpAdapterExtraLogUtil.isHttpHeaderMode();
protected ProcessVO setVO(Properties prop,long msgRcvTm) throws RequestProcessorException{
String actionName = prop.getProperty(Processor.REQUEST_ACTION);
String adapterGroupName = prop.getProperty(Processor.ADAPTER_GROUP_NAME);
String adapterName = prop.getProperty(Processor.ADAPTER_NAME);
@@ -363,6 +368,9 @@ public class RequestProcessor extends RequestProcessorSupport {
logUnknownMessage(vo,orgMessage);
throw new RequestProcessorException(vo.getRspErrorMsg());
}else {
// TODO : DJErp - 표준전문의 InterfaceID는 SEND_RECV_DIVISION, IN_EX_DIVISION 포함해야 할지에 따라 결정
// mapper.setInterfaceId(standardMessage, eaiSvcCd);
/**
* MCI Sync-Async 응답을 대표인터페이스로 구현하기 위해 추가
* 조건)MCI 거래 && 일반거래 && 응답 거래
@@ -422,6 +430,9 @@ public class RequestProcessor extends RequestProcessorSupport {
// INBOUND_HEADER NULL인 경우는 HTTP가 아닌것으로 간주함.
if(prop.get(HttpClientAdapterServiceKey.INBOUND_HEADER) != null) {
if (!httpHeaderLogMode) {
logger.info("httpHeader logging off - use.http.header.log");
} else {
HttpAdapterExtraLogUtil.insertHttpAdapterExtraLog(vo.getUuid(), 100,
prop.getProperty(HttpClientAdapterServiceKey.ADAPTER_GROUP_NAME),
prop.getProperty(HttpClientAdapterServiceKey.ADAPTER_NAME),
@@ -429,6 +440,7 @@ public class RequestProcessor extends RequestProcessorSupport {
prop.getProperty(HttpClientAdapterServiceKey.INBOUND_EXTURI)+"?"+prop.getProperty(HttpClientAdapterServiceKey.INBOUND_EXTPARAMS),
prop.getProperty(HttpClientAdapterServiceKey.INBOUND_METHOD));
}
}
EAIMessage eaiMsg = null;
try {
@@ -1021,13 +1033,20 @@ public class RequestProcessor extends RequestProcessorSupport {
Bucket bucket = InflowControlUtil.getBucket();
String blockedType = "";
// Client 유량제어 체크 clientId가 있는 경우에만
String clientBlockedType = checkClientInflow(bucket, clientId);
if (clientBlockedType != null) {
blockedType += "C";
}
// Adapter 유량제어 체크
if (!bucket.isAdapterPass(vo.getAdapterGroupName())) {
String adapterBlockedType = checkAdapterInflow(bucket, vo.getAdapterGroupName());
if (adapterBlockedType != null) {
blockedType += "A";
}
// 그룹 우선, 없으면 인터페이스 유량제어 체크
String groupId = bucket.getGroupIdByInterface(vo.getEaiSvcCd());
String groupBlockedType = null;
String interfaceBlockedType = null;
if (groupId != null) {
// 그룹에 속한 인터페이스 그룹 유량제어 체크
groupBlockedType = bucket.isGroupPass(groupId);
@@ -1036,7 +1055,8 @@ public class RequestProcessor extends RequestProcessorSupport {
}
} else {
// 그룹에 속하지 않은 인터페이스 인터페이스 유량제어 체크
if (!bucket.isInterfacePass(vo.getEaiSvcCd())) {
interfaceBlockedType = checkInterfaceInflow(bucket, vo.getEaiSvcCd());
if (interfaceBlockedType != null) {
blockedType += "I";
}
}
@@ -1045,7 +1065,9 @@ public class RequestProcessor extends RequestProcessorSupport {
if (blockedType.length() > 0) {
InflowTargetVO inflowTargetVO = null;
InflowGroupVO inflowGroupVO = null;
if (blockedType.startsWith("A")) {
if (blockedType.startsWith("C")) {
inflowTargetVO = resolveClientInflowThreshold(bucket, clientId);
} else if (blockedType.startsWith("A")) {
inflowTargetVO = bucket.getAdapterInflowThreashold(vo.getAdapterGroupName());
} else if (blockedType.contains("G")) {
inflowGroupVO = bucket.getGroupInflowThreshold(groupId);
@@ -1066,7 +1088,11 @@ public class RequestProcessor extends RequestProcessorSupport {
inflowGroupVO.getThresholdTimeUnit());
}
} else if (inflowTargetVO != null) {
inflowErrorMsg = String.format("API 호출 한도 초과 [%s]", inflowTargetVO.getName());
String targetBlockedType;
if (blockedType.startsWith("C")) targetBlockedType = clientBlockedType;
else if (blockedType.startsWith("A")) targetBlockedType = adapterBlockedType;
else targetBlockedType = interfaceBlockedType;
inflowErrorMsg = buildInflowTargetErrorMsg(inflowTargetVO, targetBlockedType);
} else {
inflowErrorMsg = "API 호출 한도 초과";
}
@@ -1292,17 +1318,17 @@ public class RequestProcessor extends RequestProcessorSupport {
STDMessageKeys.SEND_RECV_CD_SEND.equals(returnType) ) {
try {
retEaiMsg.setLogPssSno(400);
String retGuid = mapper.getGuid(retEaiMsg.getStandardMessage());
String retGuidSeq = mapper.getGuidSeq(retEaiMsg.getStandardMessage());
String newGuid = retGuid + StringUtils.defaultString(StringUtils.leftPad(retGuidSeq, UUIDGenerator.GUID_SEQ_LENGTH, '0'));
mapper.setGuid(retEaiMsg.getStandardMessage(), newGuid);
// String retGuid = mapper.getGuid(retEaiMsg.getStandardMessage());
// String retGuidSeq = mapper.getGuidSeq(retEaiMsg.getStandardMessage());
// String newGuid = retGuid + StringUtils.defaultString(StringUtils.leftPad(retGuidSeq, UUIDGenerator.GUID_SEQ_LENGTH, '0'));
// mapper.setGuid(retEaiMsg.getStandardMessage(), newGuid);
mapper.setSendTime(retEaiMsg.getStandardMessage(), DatetimeUtil.getCurrentTimeMillis());
EAILogSender.send(retEaiMsg, null);
} catch(Exception e) {
vo.setRspErrorCode("RECEAIIRP100");
vo.setRspErrorMsg(ExceptionUtil.make(e, vo.getRspErrorCode(), new String[]{vo.getEaiSvcCd()} ));
if (logger.isError()) logger.error(guidLogPrefix + " : " + vo.getRspErrorMsg());
if (logger.isError()) logger.error(guidLogPrefix + " : " + vo.getRspErrorMsg(), e);
}
if (logger.isInfo()) logger.info(guidLogPrefix + " : LOG(" + eaiMsg.getLogPssSno() + ") EAIMessage : "+eaiMsg.getEAISvcCd());
}
@@ -1372,7 +1398,29 @@ public class RequestProcessor extends RequestProcessorSupport {
}
}
private String getInboundErrorResponse(Properties prop, StandardMessage standardMessage, InterfaceMapper mapper
// 클라이언트 유량제어 체크 기본 구현은 체크 없음, DJErpRequestProcessor가 오버라이드
protected String checkClientInflow(Bucket bucket, String clientId) {
return null;
}
protected InflowTargetVO resolveClientInflowThreshold(Bucket bucket, String clientId) {
return null;
}
// 어댑터/인터페이스 유량제어 체크 서브클래스에서 오버라이드하여 차단 원인(PER_SECOND/THRESHOLD) 반환 가능
protected String checkAdapterInflow(Bucket bucket, String adapterGroupName) {
return bucket.isAdapterPass(adapterGroupName) ? null : "BLOCKED";
}
protected String checkInterfaceInflow(Bucket bucket, String eaiSvcCd) {
return bucket.isInterfacePass(eaiSvcCd) ? null : "BLOCKED";
}
protected String buildInflowTargetErrorMsg(InflowTargetVO inflowTargetVO, String blockedType) {
return String.format("API 호출 한도 초과 [%s]", inflowTargetVO.getName());
}
protected String getInboundErrorResponse(Properties prop, StandardMessage standardMessage, InterfaceMapper mapper
, String errCode, String errMsg, String charset) {
String errorResponse = null;
@@ -1401,7 +1449,7 @@ public class RequestProcessor extends RequestProcessorSupport {
return errorResponse;
}
private String getInboundErrorResponseForStandard(StandardMessage standardMessage, InterfaceMapper mapper,
protected String getInboundErrorResponseForStandard(StandardMessage standardMessage, InterfaceMapper mapper,
String errCode, String errMsg, String charset, String messageType) {
mapper.nextGuidSeq(standardMessage);
mapper.setSendRecvDivision(standardMessage, STDMessageKeys.SEND_RECV_CD_RECV);
@@ -783,13 +783,16 @@ public class StandardItem implements Serializable, Cloneable {
if(p>0) sb.append(",");
sb.append("{");
while(keyIter.hasNext()) {
if(ai>0) sb.append(",");
String key = keyIter.next();
StandardItem item = group.get(key);
String itemPart = item.toJson(showPretty, withBizData);
if(StringUtils.isNotEmpty(itemPart)) {
if(ai>0) sb.append(",");
if(showPretty) sb.append("\n");
sb.append(item.toJson(showPretty, withBizData));
sb.append(itemPart);
ai++;
}
}
if(showPretty) {
sb.append("\n");
@@ -1020,13 +1023,8 @@ public class StandardItem implements Serializable, Cloneable {
totalSize += getLength();
break;
case StandardType.GROUP :
// skip variable group
if( getSize() == 0
//카카오뱅크 => 카카오카드 400응답시 아래 로직을 추가해야 전체건수에서 메시지부가 처리됨
//메시지부 size 0 refPath refValue 사용 해서 있고 없고 처리
&& (StringUtils.isBlank(getRefPath())
|| StringUtils.isBlank(getRefValue()))
) {
// skip inactive/conditional group (size==0 means not activated)
if( getSize() == 0 ) {
break;
}
if (isHidden) break;
@@ -1120,13 +1118,8 @@ public class StandardItem implements Serializable, Cloneable {
}
break;
case StandardType.GROUP :
// skip variable group
if( getSize() == 0
//카카오뱅크 => 카카오카드 400응답시 아래 로직을 추가해야 전체건수에서 메시지부가 처리됨
//메시지부 size 0 refPath refValue 사용 해서 있고 없고 처리
&& (StringUtils.isBlank(getRefPath())
|| StringUtils.isBlank(getRefValue()))
) {
// skip inactive/conditional group (size==0 means not activated)
if( getSize() == 0 ) {
break;
}
if (isHidden) break;
@@ -47,7 +47,7 @@ reader.XML=com.eactive.eai.message.parser.XmlReader
reader.FLAT=com.eactive.eai.message.parser.FlatReader
*/
public static String STANDARD_MESSAGE_CONFIG = "standard.message.config";
private String DEFAULT_CONFIG_PATH = "./resources/standard-message-config.properties";
private String DEFAULT_CONFIG_PATH = "./standard-message-config.properties";
private String DEFAULT_CONFIG_FILE = "standard-message-config.properties";
private String LAYOUT_FILE_TYPE = "layout.file.type";
@@ -359,10 +359,10 @@ public abstract class DefaultProcess extends Process {
// GUID SET
mapper.nextGuidSeq(standardMessage);
String guid = mapper.getGuid(standardMessage);
String guidSeq = mapper.getGuidSeq(standardMessage);
mapper.setGuid(standardMessage,
guid + StringUtils.defaultString(StringUtils.leftPad(guidSeq, UUIDGenerator.GUID_SEQ_LENGTH, '0')));
// String guid = mapper.getGuid(standardMessage);
// String guidSeq = mapper.getGuidSeq(standardMessage);
// mapper.setGuid(standardMessage,
// guid + StringUtils.defaultString(StringUtils.leftPad(guidSeq, UUIDGenerator.GUID_SEQ_LENGTH, '0')));
// 시스템구분
// stdmsg.setData("stdheader.Inter_chn", EAIServerManager.getInstance().getSystemGubun());
@@ -776,7 +776,7 @@ public abstract class DefaultProcess extends Process {
}
standardMessage.setBizData(this.resObject, this.inboundCharset);
try {
mapper.nextGuidSeq(standardMessage);
//mapper.nextGuidSeq(standardMessage);
mapper.setSendRecvDivision(standardMessage, STDMessageKeys.SEND_RECV_CD_RECV);
StandardMessageManager standardManager = StandardMessageManager.getInstance();
standardManager.getMessageCoordinator().coordinateSetStandardMessageError(standardMessage, mapper,
@@ -0,0 +1,6 @@
# SoftHSM2 for Windows - 개발 환경
# https://github.com/disig/SoftHSM2-for-Windows/releases 에서 설치
# 토큰 초기화: softhsm2-util --init-token --slot 0 --label "dev-token" --pin 1234 --so-pin 1234
name = SoftHSM
library = C:/SoftHSM2/lib/softhsm2-x64.dll
slotListIndex = 0
@@ -0,0 +1,8 @@
# SafeNet ProtectServer Network HSM - 운영 환경
# 환경변수 필요:
# ET_HSM_NETCLIENT_SERVERLIST=<HSM 서버 IP>
# ET_HSM_NETCLIENT_SERVERPORT=9000
# LD_LIBRARY_PATH=/opt/safenet/protecttoolkit5/ptk/lib
name = ProtectServer
library = /opt/safenet/protecttoolkit5/ptk/lib/libcryptoki.so
slot = 0
@@ -0,0 +1,450 @@
package com.eactive.eai.common.circuitBreaker.type;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import java.lang.reflect.Field;
import java.util.Arrays;
import java.util.Collections;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.MethodOrderer;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestMethodOrder;
import org.springframework.beans.factory.support.BeanDefinitionBuilder;
import org.springframework.context.support.GenericApplicationContext;
import com.eactive.eai.common.property.PropManager;
import com.eactive.eai.common.util.ApplicationContextProvider;
import io.github.resilience4j.circuitbreaker.CallNotPermittedException;
import io.github.resilience4j.circuitbreaker.CircuitBreaker;
/**
* TypedCircuitBreakerManager 단위 테스트
*
* DB / Spring 컨텍스트 없이 동작한다.
* - PropManager : Mockito Mock (DB 없이 설정값 반환)
* - TypedCircuitBreakerDAO : Mockito Mock (JPA 없이 VO 반환)
* - Spring Bean : GenericApplicationContext 수동 구성
*
* 테스트 격리:
* 테스트는 고유한 apiId 사용하거나 manager.reload() 레지스트리를 초기화한다.
* manager.reload() CircuitBreakerRegistry 새로 생성하므로 이전 상태가 제거된다.
*/
@TestMethodOrder(MethodOrderer.DisplayName.class)
class TypedCircuitBreakerManagerTest {
// 슬라이딩 윈도우·최소 호출을 4로 설정해 빠른 상태 전이를 유도한다.
private static final int DEFAULT_FAILURE_RATE = 50;
private static final int DEFAULT_SLIDING_WINDOW = 4;
private static final int DEFAULT_MIN_CALLS = 4;
private static final int DEFAULT_WAIT_DURATION_SEC = 1; // OPEN 대기 1초 (테스트용)
private static final int DEFAULT_HALF_OPEN_PERMITS = 2;
private static final int DEFAULT_SLOW_RATE = 100;
private static final int DEFAULT_SLOW_DURATION_SEC = 5;
private static GenericApplicationContext ctx;
private static PropManager mockPropManager;
private static TypedCircuitBreakerDAO mockDao;
private static TypedCircuitBreakerManager manager;
// -------------------------------------------------------------------------
// 픽스처 설정 / 해제
// -------------------------------------------------------------------------
@BeforeAll
static void setUpClass() throws Exception {
mockPropManager = mock(PropManager.class);
mockDao = mock(TypedCircuitBreakerDAO.class);
setupDefaultPropManagerMock();
when(mockDao.getList()).thenReturn(Collections.emptyList());
// TypedCircuitBreakerManager 인스턴스 생성 DAO 리플렉션 주입
manager = new TypedCircuitBreakerManager();
injectField(manager, "dao", mockDao);
// DB / Spring 없이 최소 컨텍스트 구성
ctx = new GenericApplicationContext();
ctx.getBeanFactory().registerSingleton("propManager", mockPropManager);
ctx.getBeanFactory().registerSingleton("typedCircuitBreakerManager", manager);
ctx.registerBeanDefinition("applicationContextProvider",
BeanDefinitionBuilder.genericBeanDefinition(ApplicationContextProvider.class)
.getBeanDefinition());
ctx.refresh();
manager.start();
}
@AfterAll
static void tearDownClass() {
if (ctx != null) ctx.close();
}
@BeforeEach
void resetMocks() throws Exception {
reset(mockDao);
setupDefaultPropManagerMock();
when(mockDao.getList()).thenReturn(Collections.emptyList());
}
// -------------------------------------------------------------------------
// 헬퍼
// -------------------------------------------------------------------------
private static void setupDefaultPropManagerMock() {
when(mockPropManager.getProperty("CircuitBreaker", "failureRateThreshold"))
.thenReturn(String.valueOf(DEFAULT_FAILURE_RATE));
when(mockPropManager.getProperty("CircuitBreaker", "slidingWindowSize"))
.thenReturn(String.valueOf(DEFAULT_SLIDING_WINDOW));
when(mockPropManager.getProperty("CircuitBreaker", "minimumNumberOfCalls"))
.thenReturn(String.valueOf(DEFAULT_MIN_CALLS));
when(mockPropManager.getProperty("CircuitBreaker", "waitDurationInOpenState"))
.thenReturn(String.valueOf(DEFAULT_WAIT_DURATION_SEC));
when(mockPropManager.getProperty("CircuitBreaker", "permittedNumberOfCallsInHalfOpenState"))
.thenReturn(String.valueOf(DEFAULT_HALF_OPEN_PERMITS));
when(mockPropManager.getProperty("CircuitBreaker", "slowCallRateThreshold"))
.thenReturn(String.valueOf(DEFAULT_SLOW_RATE));
when(mockPropManager.getProperty("CircuitBreaker", "slowCallDurationThreshold"))
.thenReturn(String.valueOf(DEFAULT_SLOW_DURATION_SEC));
when(mockPropManager.getProperty("CircuitBreaker", "useYn", "N")).thenReturn("1");
}
private static void injectField(Object target, String fieldName, Object value) throws Exception {
Field field = target.getClass().getDeclaredField(fieldName);
field.setAccessible(true);
field.set(target, value);
}
/** 테스트용 TypedCircuitBreakerVO 생성 (기본 설정값 사용) */
private TypedCircuitBreakerVO buildVo(String id, String apiId, String useYn) {
return TypedCircuitBreakerVO.builder()
.id(id).name(apiId).type("COUNT_BASED").desc(apiId + " 테스트 설정")
.failureRateThreshold(DEFAULT_FAILURE_RATE)
.slidingWindowSize(DEFAULT_SLIDING_WINDOW)
.minimumNumberOfCalls(DEFAULT_MIN_CALLS)
.waitDurationInOpenState(DEFAULT_WAIT_DURATION_SEC)
.permittedNumberOfCallsInHalfOpenState(DEFAULT_HALF_OPEN_PERMITS)
.slowCallRateThreshold(DEFAULT_SLOW_RATE)
.slowCallDurationThreshold(DEFAULT_SLOW_DURATION_SEC)
.useYn(useYn)
.build();
}
/** CB를 count 회 실패 처리한다 */
private void recordFailures(CircuitBreaker cb, int count) {
for (int i = 0; i < count; i++) {
try {
cb.executeCallable(() -> { throw new RuntimeException("테스트 강제 오류"); });
} catch (Exception ignored) {}
}
}
// =========================================================================
// 1. getCircuitBreaker - 기본 조회 동작
// =========================================================================
@Test
@DisplayName("1-1. 등록된 apiId → 해당 설정의 CircuitBreaker 반환")
void testGetCircuitBreaker_registeredApiId_returnsTypedCircuitBreaker() throws Exception {
when(mockDao.getList()).thenReturn(Arrays.asList(buildVo("uuid-001", "API_REG", "1")));
manager.reload();
CircuitBreaker cb = manager.getCircuitBreaker("API_REG");
assertNotNull(cb);
assertEquals("API_REG", cb.getName());
}
@Test
@DisplayName("1-2. 미등록 apiId → default CircuitBreaker 로 fallback")
void testGetCircuitBreaker_unregisteredApiId_returnsDefaultCircuitBreaker() throws Exception {
when(mockDao.getList()).thenReturn(Collections.emptyList());
manager.reload();
CircuitBreaker cb = manager.getCircuitBreaker("API_UNKNOWN");
assertNotNull(cb, "미등록 apiId 는 default CB 를 반환해야 한다");
assertEquals("default", cb.getName());
}
@Test
@DisplayName("1-3. useYn=0 인 apiId → default CircuitBreaker 로 fallback")
void testGetCircuitBreaker_disabledApiId_returnsDefaultCircuitBreaker() throws Exception {
when(mockDao.getList()).thenReturn(Arrays.asList(buildVo("uuid-002", "API_DISABLED", "0")));
manager.reload();
CircuitBreaker cb = manager.getCircuitBreaker("API_DISABLED");
assertNotNull(cb, "비활성 apiId 는 default CB 로 fallback 되어야 한다");
assertEquals("default", cb.getName());
}
@Test
@DisplayName("1-4. apiId 도 default 도 비활성(useYn=0) → null 반환")
void testGetCircuitBreaker_defaultDisabled_returnsNull() throws Exception {
when(mockPropManager.getProperty("CircuitBreaker", "useYn", "N")).thenReturn("0");
when(mockDao.getList()).thenReturn(Collections.emptyList());
manager.reload();
CircuitBreaker cb = manager.getCircuitBreaker("ANY_API");
assertNull(cb, "default 도 비활성이면 null 을 반환해야 한다");
}
@Test
@DisplayName("1-5. PropManager useYn=Y → normalizeUseYn 으로 '1' 정규화 → default 활성")
void testGetCircuitBreaker_defaultUseYn_Y_isNormalizedToEnabled() throws Exception {
when(mockPropManager.getProperty("CircuitBreaker", "useYn", "N")).thenReturn("Y");
when(mockDao.getList()).thenReturn(Collections.emptyList());
manager.reload();
CircuitBreaker cb = manager.getCircuitBreaker("ANY_API");
assertNotNull(cb, "PropManager useYn=Y 는 활성으로 정규화되어야 한다");
assertEquals("default", cb.getName());
}
// =========================================================================
// 2. apiId 독립성
// =========================================================================
@Test
@DisplayName("2-1. 서로 다른 apiId → 독립적인 CircuitBreaker 인스턴스 반환")
void testDifferentApiIds_haveIndependentCircuitBreakerInstances() throws Exception {
when(mockDao.getList()).thenReturn(Arrays.asList(
buildVo("uuid-A", "API_INDEP_A", "1"),
buildVo("uuid-B", "API_INDEP_B", "1")));
manager.reload();
CircuitBreaker cbA = manager.getCircuitBreaker("API_INDEP_A");
CircuitBreaker cbB = manager.getCircuitBreaker("API_INDEP_B");
assertNotNull(cbA);
assertNotNull(cbB);
assertNotSame(cbA, cbB, "서로 다른 apiId 는 다른 CB 인스턴스여야 한다");
assertEquals("API_INDEP_A", cbA.getName());
assertEquals("API_INDEP_B", cbB.getName());
}
@Test
@DisplayName("2-2. API_A 실패 → OPEN, API_B 는 CLOSED 유지 (상태 격리)")
void testDifferentApiIds_statesAreIsolated() throws Exception {
when(mockDao.getList()).thenReturn(Arrays.asList(
buildVo("uuid-ISO-A", "API_ISO_A", "1"),
buildVo("uuid-ISO-B", "API_ISO_B", "1")));
manager.reload();
CircuitBreaker cbA = manager.getCircuitBreaker("API_ISO_A");
CircuitBreaker cbB = manager.getCircuitBreaker("API_ISO_B");
// API_ISO_A minimumNumberOfCalls=4 실패
recordFailures(cbA, 4);
assertEquals(CircuitBreaker.State.OPEN, cbA.getState(), "API_ISO_A 는 OPEN 이어야 한다");
assertEquals(CircuitBreaker.State.CLOSED, cbB.getState(), "API_ISO_B 는 CLOSED 를 유지해야 한다");
}
// =========================================================================
// 3. 상태 전이
// =========================================================================
@Test
@DisplayName("3-1. 실패율 50% 초과(4/4 실패) → CLOSED → OPEN")
void testCircuitBreaker_closedToOpen_afterFailureThresholdExceeded() throws Exception {
when(mockDao.getList()).thenReturn(Arrays.asList(buildVo("uuid-ST-001", "API_ST_OPEN", "1")));
manager.reload();
CircuitBreaker cb = manager.getCircuitBreaker("API_ST_OPEN");
assertEquals(CircuitBreaker.State.CLOSED, cb.getState(), "초기 상태는 CLOSED 여야 한다");
// minimumNumberOfCalls=4, failureRateThreshold=50% 4/4 실패(100%) OPEN
recordFailures(cb, 4);
assertEquals(CircuitBreaker.State.OPEN, cb.getState(), "실패율 초과 시 OPEN 상태여야 한다");
}
@Test
@DisplayName("3-2. 실패율 미달(1/4 실패, 25%) → CLOSED 유지")
void testCircuitBreaker_belowFailureThreshold_remainsClosed() throws Exception {
when(mockDao.getList()).thenReturn(Arrays.asList(buildVo("uuid-ST-002", "API_ST_BELOW", "1")));
manager.reload();
CircuitBreaker cb = manager.getCircuitBreaker("API_ST_BELOW");
// 4회 3회 성공, 1회 실패 실패율 25% < 50% CLOSED 유지
// Resilience4j >= 비교: 50% >= 50% OPEN 이므로 25% 낮춰야 CLOSED 유지
try { cb.executeCallable(() -> "성공"); } catch (Exception ignored) {}
try { cb.executeCallable(() -> "성공"); } catch (Exception ignored) {}
try { cb.executeCallable(() -> "성공"); } catch (Exception ignored) {}
recordFailures(cb, 1);
assertEquals(CircuitBreaker.State.CLOSED, cb.getState(), "실패율 25% (< threshold 50%) 이면 CLOSED 를 유지해야 한다");
}
@Test
@DisplayName("3-3. CB OPEN 상태 → 모든 요청 즉시 차단 (CallNotPermittedException)")
void testCircuitBreaker_open_blocksAllRequests() throws Exception {
when(mockDao.getList()).thenReturn(Arrays.asList(buildVo("uuid-ST-003", "API_ST_BLOCK", "1")));
manager.reload();
CircuitBreaker cb = manager.getCircuitBreaker("API_ST_BLOCK");
recordFailures(cb, 4);
assertEquals(CircuitBreaker.State.OPEN, cb.getState());
assertThrows(CallNotPermittedException.class,
() -> cb.executeCallable(() -> "도달하면 안 됨"),
"OPEN 상태에서 CallNotPermittedException 이 발생해야 한다");
}
@Test
@DisplayName("3-4. OPEN → waitDuration(1초) 경과 → 첫 요청 시 HALF_OPEN 전이")
void testCircuitBreaker_open_transitionsToHalfOpenAfterWaitDuration() throws Exception {
when(mockDao.getList()).thenReturn(Arrays.asList(buildVo("uuid-ST-004", "API_ST_HALFOPEN", "1")));
manager.reload();
CircuitBreaker cb = manager.getCircuitBreaker("API_ST_HALFOPEN");
recordFailures(cb, 4);
assertEquals(CircuitBreaker.State.OPEN, cb.getState());
// waitDurationInOpenState = 1초 대기 요청 HALF_OPEN 전이
Thread.sleep(1200);
try { cb.executeCallable(() -> "HALF_OPEN 트리거"); } catch (Exception ignored) {}
assertEquals(CircuitBreaker.State.HALF_OPEN, cb.getState(),
"waitDuration 경과 후 첫 요청 시 HALF_OPEN 상태여야 한다");
}
@Test
@DisplayName("3-5. HALF_OPEN → 성공(permittedCalls=2회) → CLOSED 복구")
void testCircuitBreaker_halfOpen_transitionsToClosedOnSuccess() throws Exception {
when(mockDao.getList()).thenReturn(Arrays.asList(buildVo("uuid-ST-005", "API_ST_RECOVER", "1")));
manager.reload();
CircuitBreaker cb = manager.getCircuitBreaker("API_ST_RECOVER");
recordFailures(cb, 4);
Thread.sleep(1200);
// HALF_OPEN 에서 permittedNumberOfCallsInHalfOpenState=2 성공
for (int i = 0; i < DEFAULT_HALF_OPEN_PERMITS; i++) {
final String result = cb.executeCallable(() -> "성공");
assertEquals("성공", result);
}
assertEquals(CircuitBreaker.State.CLOSED, cb.getState(),
"HALF_OPEN 에서 허용 호출 수 이상 성공하면 CLOSED 로 복구되어야 한다");
}
@Test
@DisplayName("3-6. HALF_OPEN → 실패 → 다시 OPEN 재전이")
void testCircuitBreaker_halfOpen_transitionsBackToOpenOnFailure() throws Exception {
when(mockDao.getList()).thenReturn(Arrays.asList(buildVo("uuid-ST-006", "API_ST_REOPEN", "1")));
manager.reload();
CircuitBreaker cb = manager.getCircuitBreaker("API_ST_REOPEN");
recordFailures(cb, 4);
Thread.sleep(1200);
// HALF_OPEN 에서 permittedNumberOfCallsInHalfOpenState=2 모두 실패해야 OPEN 으로 재전이된다.
// Resilience4j 허용 호출 (2회) 모두 소진한 후에 실패율을 평가하므로
// 1회만 실패하면 HALF_OPEN 상태가 유지된다.
recordFailures(cb, DEFAULT_HALF_OPEN_PERMITS);
assertEquals(CircuitBreaker.State.OPEN, cb.getState(),
"HALF_OPEN 에서 허용 호출 수(2회) 모두 실패하면 다시 OPEN 으로 전이되어야 한다");
}
// =========================================================================
// 4. reload
// =========================================================================
@Test
@DisplayName("4-1. reload(key) → CircuitBreakerConfig 갱신 (waitDuration 1초 → 60초)")
void testReload_key_updatesCircuitBreakerConfig() throws Exception {
when(mockDao.getList()).thenReturn(Arrays.asList(buildVo("uuid-RLD-001", "API_RELOAD", "1")));
manager.reload();
// waitDurationInOpenState 60초로 변경
TypedCircuitBreakerVO updated = TypedCircuitBreakerVO.builder()
.id("uuid-RLD-001").name("API_RELOAD").type("COUNT_BASED")
.failureRateThreshold(50).slidingWindowSize(4).minimumNumberOfCalls(4)
.waitDurationInOpenState(60)
.permittedNumberOfCallsInHalfOpenState(2)
.slowCallRateThreshold(100).slowCallDurationThreshold(5)
.useYn("1").build();
when(mockDao.get("uuid-RLD-001")).thenReturn(updated);
manager.reload("uuid-RLD-001");
CircuitBreaker cb = manager.getCircuitBreaker("API_RELOAD");
assertEquals(60,
cb.getCircuitBreakerConfig().getWaitDurationInOpenState().getSeconds(),
"reload 후 waitDurationInOpenState 가 60초로 갱신되어야 한다");
}
@Test
@DisplayName("4-2. reload(key) useYn=0 → 맵에서 제거 → default fallback")
void testReload_key_disabledVo_removesFromMap() throws Exception {
when(mockDao.getList()).thenReturn(Arrays.asList(buildVo("uuid-RLD-002", "API_RM", "1")));
manager.reload();
assertEquals("API_RM", manager.getCircuitBreaker("API_RM").getName());
// useYn=0 으로 비활성화
TypedCircuitBreakerVO disabled = TypedCircuitBreakerVO.builder()
.id("uuid-RLD-002").name("API_RM").type("COUNT_BASED")
.failureRateThreshold(50).slidingWindowSize(4).minimumNumberOfCalls(4)
.waitDurationInOpenState(1).permittedNumberOfCallsInHalfOpenState(2)
.slowCallRateThreshold(100).slowCallDurationThreshold(5)
.useYn("0").build();
when(mockDao.get("uuid-RLD-002")).thenReturn(disabled);
manager.reload("uuid-RLD-002");
CircuitBreaker cb = manager.getCircuitBreaker("API_RM");
assertEquals("default", cb.getName(),
"비활성화된 CB 는 맵에서 제거 후 default 로 fallback 되어야 한다");
}
@Test
@DisplayName("4-3. reload() 전체 → CircuitBreakerRegistry 재생성으로 이전 CB 상태 초기화")
void testReload_all_refreshesAllCircuitBreakers() throws Exception {
// 초기: API_FULL_A 등록 OPEN 상태로 만들기
when(mockDao.getList()).thenReturn(Arrays.asList(buildVo("uuid-FULL-A", "API_FULL_A", "1")));
manager.reload();
CircuitBreaker cbA = manager.getCircuitBreaker("API_FULL_A");
recordFailures(cbA, 4);
assertEquals(CircuitBreaker.State.OPEN, cbA.getState(), "전체 reload 전 API_FULL_A 는 OPEN 이어야 한다");
// 전체 reload CircuitBreakerRegistry 재생성
// init() apiIdCBConfigMap 초기화하지 않으므로 API_FULL_A 맵에 남아있다.
// 하지만 레지스트리를 생성하므로 CB 상태(OPEN) 초기화(CLOSED)된다.
when(mockDao.getList()).thenReturn(Arrays.asList(
buildVo("uuid-FULL-A", "API_FULL_A", "1"),
buildVo("uuid-FULL-B", "API_FULL_B", "1")));
manager.reload();
// 레지스트리 재생성으로 API_FULL_A CB 상태가 CLOSED 초기화되어야 한다
CircuitBreaker cbAAfter = manager.getCircuitBreaker("API_FULL_A");
assertEquals(CircuitBreaker.State.CLOSED, cbAAfter.getState(),
"전체 reload 후 CircuitBreakerRegistry 재생성으로 이전 CB 상태가 CLOSED 로 초기화되어야 한다");
// apiId 정상 등록
assertEquals("API_FULL_B", manager.getCircuitBreaker("API_FULL_B").getName(),
"전체 reload 후 새 apiId 는 정상 등록되어야 한다");
}
// =========================================================================
// 5. 생명주기
// =========================================================================
@Test
@DisplayName("5-1. start() 후 isStarted() = true")
void testIsStarted_afterStart_returnsTrue() {
assertTrue(manager.isStarted(), "start() 후 isStarted() 는 true 여야 한다");
}
}
@@ -0,0 +1,304 @@
package com.eactive.eai.common.hsm;
import static org.junit.jupiter.api.Assertions.*;
import static org.junit.jupiter.api.Assumptions.assumeTrue;
import java.io.File;
import java.io.InputStream;
import java.lang.reflect.Constructor;
import java.nio.file.Files;
import java.security.PublicKey;
import java.util.concurrent.TimeUnit;
import javax.crypto.KeyGenerator;
import javax.crypto.SecretKey;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.MethodOrderer;
import org.junit.jupiter.api.Order;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestMethodOrder;
import org.mockito.Mockito;
import org.springframework.beans.factory.support.BeanDefinitionBuilder;
import org.springframework.context.support.GenericApplicationContext;
import com.eactive.eai.common.property.PropManager;
import com.eactive.eai.common.util.ApplicationContextProvider;
/**
* HsmManager / HsmCryptoService 통합 테스트 (JDK 8 / SunPKCS11 / SoftHSM2)
*
* 사전 조건:
* SoftHSM2 설치: https://github.com/disig/SoftHSM2-for-Windows/releases
* C:\SoftHSM2 설치 (미설치 테스트 자동 건너뜀)
*
* PropManager DB 없이 Mockito Mock 으로 대체하여 테스트합니다.
*/
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
public class HsmManagerServiceTest {
private static final String SOFTHSM2_DLL = "C:/SoftHSM2/lib/softhsm2-x64.dll";
private static final String SOFTHSM2_UTIL = "C:/SoftHSM2/bin/softhsm2-util.exe";
private static final String TOKEN_LABEL = "eapim-test";
private static final String PIN = "1234";
private static final String RSA_ALIAS = "test-rsa-key";
private static final String AES_ALIAS = "test-aes-key";
private static HsmManager hsmManager;
private static HsmCryptoService hsmCryptoService;
private static GenericApplicationContext springContext;
private static File tempCfgFile;
// -------------------------------------------------------------------------
// 설정 / 해제
// -------------------------------------------------------------------------
@BeforeAll
static void setUp() throws Exception {
System.setProperty("SOFTHSM2_CONF", "C:\\SoftHSM2\\etc\\softhsm2.conf");
assumeTrue(new File(SOFTHSM2_DLL).exists(),
"SoftHSM2 미설치 (C:/SoftHSM2) - 테스트 건너뜀");
// 1. 토큰 초기화 (없으면 신규 생성, 있으면 기존 사용)
ensureTokenInitialized();
// 2. PKCS11 설정 문자열 구성 (slotListIndex = 0 번째 초기화 토큰)
String cfgContent = "name = SoftHSM\n"
+ "library = " + SOFTHSM2_DLL.replace("\\", "/") + "\n"
+ "slotListIndex = 0\n";
// keytool 호출 파일 경로가 필요하므로 임시 파일에도 저장
tempCfgFile = File.createTempFile("pkcs11-svc-test-", ".cfg");
Files.write(tempCfgFile.toPath(), cfgContent.getBytes("UTF-8"));
// 3. PropManager Mock - DB 없이 HSM 설정값 반환
PropManager mockPropManager = Mockito.mock(PropManager.class);
Mockito.when(mockPropManager.getProperty("HSM", "PKCS11_CONFIG")).thenReturn(cfgContent);
Mockito.when(mockPropManager.getProperty("HSM", "PIN")).thenReturn(PIN);
// 4. HsmManager 인스턴스 생성 (private 생성자 리플렉션)
Constructor<HsmManager> ctor = HsmManager.class.getDeclaredConstructor();
ctor.setAccessible(true);
hsmManager = ctor.newInstance();
// 5. Spring ApplicationContext 구성
// ApplicationContextProvider.context 세팅하여
// HsmManager.getInstance() / PropManager.getInstance() 동작하게
springContext = new GenericApplicationContext();
springContext.getBeanFactory().registerSingleton("propManager", mockPropManager);
springContext.getBeanFactory().registerSingleton("hsmManager", hsmManager);
springContext.registerBeanDefinition("applicationContextProvider",
BeanDefinitionBuilder.genericBeanDefinition(ApplicationContextProvider.class)
.getBeanDefinition());
springContext.refresh();
// 6. HsmManager 시작 (SunPKCS11 Provider 초기화 + KeyStore 오픈)
hsmManager.start();
assertTrue(hsmManager.isReady(), "HsmManager 초기화 실패");
System.out.println("[HsmManager] 초기화 완료. provider=" + hsmManager.getPkcs11Provider().getName());
// 7. RSA 키쌍 생성 (없는 경우에만)
if (!hsmManager.getKeyStore().containsAlias(RSA_ALIAS)) {
generateRsaKeyPair();
hsmManager.getKeyStore().load(null, PIN.toCharArray()); // keytool 결과 반영
}
// 8. AES 생성 (없는 경우에만)
if (!hsmManager.getKeyStore().containsAlias(AES_ALIAS)) {
generateAesKey();
}
// 9. HsmCryptoService 생성 (HsmManager.getInstance() 내부에서 사용)
hsmCryptoService = new HsmCryptoService();
}
@AfterAll
static void tearDown() throws Exception {
if (hsmManager != null && hsmManager.isStarted()) {
hsmManager.stop();
}
if (springContext != null) {
springContext.close();
}
if (tempCfgFile != null) {
tempCfgFile.delete();
}
}
// -------------------------------------------------------------------------
// 테스트: HsmManager 상태
// -------------------------------------------------------------------------
@Test
@Order(1)
void testHsmManagerReady() {
assertTrue(hsmManager.isReady());
assertNotNull(hsmManager.getPkcs11Provider());
assertNotNull(hsmManager.getKeyStore());
System.out.println("[HsmManager] isReady=true / provider="
+ hsmManager.getPkcs11Provider().getName());
}
// -------------------------------------------------------------------------
// 테스트: HsmCryptoService - 조회
// -------------------------------------------------------------------------
@Test
@Order(2)
void testGetPublicKey() throws Exception {
PublicKey pk = hsmCryptoService.getPublicKey(RSA_ALIAS);
assertNotNull(pk);
assertEquals("RSA", pk.getAlgorithm());
System.out.println("[HsmCryptoService] getPublicKey: algorithm=" + pk.getAlgorithm()
+ " / format=" + pk.getFormat());
}
@Test
@Order(3)
void testGetSecretKey() throws Exception {
SecretKey sk = hsmCryptoService.getSecretKey(AES_ALIAS);
assertNotNull(sk);
assertEquals("AES", sk.getAlgorithm());
System.out.println("[HsmCryptoService] getSecretKey: algorithm=" + sk.getAlgorithm());
}
// -------------------------------------------------------------------------
// 테스트: HsmCryptoService - RSA 암복호화
// -------------------------------------------------------------------------
@Test
@Order(4)
void testEncryptRsaByAlias() throws Exception {
byte[] plaintext = "RSA alias 암호화 테스트".getBytes("UTF-8");
byte[] ciphertext = hsmCryptoService.encryptRsa(RSA_ALIAS, plaintext);
assertNotNull(ciphertext);
assertTrue(ciphertext.length > 0);
System.out.println("[HsmCryptoService] encryptRsa(alias): ciphertext.length=" + ciphertext.length);
}
@Test
@Order(5)
void testEncryptRsaByPublicKey() throws Exception {
PublicKey pk = hsmCryptoService.getPublicKey(RSA_ALIAS);
byte[] plaintext = "RSA PublicKey 직접 암호화 테스트".getBytes("UTF-8");
byte[] ciphertext = hsmCryptoService.encryptRsa(pk, plaintext);
assertNotNull(ciphertext);
assertTrue(ciphertext.length > 0);
System.out.println("[HsmCryptoService] encryptRsa(PublicKey): ciphertext.length=" + ciphertext.length);
}
@Test
@Order(6)
void testRsaRoundTrip() throws Exception {
byte[] plaintext = "HSM RSA 암복호화 통합 테스트 - HsmCryptoService".getBytes("UTF-8");
// 암호화: 공개키 JVM 소프트웨어 (encryptRsa 내부에서 Provider 미명시)
byte[] ciphertext = hsmCryptoService.encryptRsa(RSA_ALIAS, plaintext);
// 복호화: 개인키 핸들 HSM 내부 (decryptRsa 내부에서 pkcs11Provider 명시)
byte[] decrypted = hsmCryptoService.decryptRsa(RSA_ALIAS, PIN.toCharArray(), ciphertext);
assertArrayEquals(plaintext, decrypted);
System.out.println("[HsmCryptoService] RSA 암복호화 성공: " + new String(decrypted, "UTF-8"));
}
// -------------------------------------------------------------------------
// 테스트: HsmCryptoService - AES 암복호화
// -------------------------------------------------------------------------
@Test
@Order(7)
void testAesRoundTrip() throws Exception {
byte[] plaintext = "HSM AES 암복호화 통합 테스트 - HsmCryptoService".getBytes("UTF-8");
byte[] iv = "1234567890123456".getBytes("UTF-8"); // 16바이트
SecretKey sk = hsmCryptoService.getSecretKey(AES_ALIAS);
byte[] ciphertext = hsmCryptoService.encryptAes(sk, iv, plaintext);
byte[] decrypted = hsmCryptoService.decryptAes(sk, iv, ciphertext);
assertArrayEquals(plaintext, decrypted);
System.out.println("[HsmCryptoService] AES 암복호화 성공: " + new String(decrypted, "UTF-8"));
}
// -------------------------------------------------------------------------
// 테스트: HsmManager Lifecycle - stop / restart
// -------------------------------------------------------------------------
@Test
@Order(8)
void testHsmManagerStopAndRestart() throws Exception {
// stop
hsmManager.stop();
assertFalse(hsmManager.isStarted());
assertFalse(hsmManager.isReady());
System.out.println("[HsmManager] stop 완료");
// restart - PropManager Mock 에서 재설정 읽어 재초기화
hsmManager.start();
assertTrue(hsmManager.isStarted());
assertTrue(hsmManager.isReady());
System.out.println("[HsmManager] restart 완료. provider="
+ hsmManager.getPkcs11Provider().getName());
}
// -------------------------------------------------------------------------
// 헬퍼
// -------------------------------------------------------------------------
private static void ensureTokenInitialized() throws Exception {
ProcessBuilder pb = new ProcessBuilder(
SOFTHSM2_UTIL, "--init-token", "--free",
"--label", TOKEN_LABEL,
"--pin", PIN,
"--so-pin", PIN);
pb.redirectErrorStream(true);
Process p = pb.start();
String out = readOutput(p);
p.waitFor(10, TimeUnit.SECONDS);
System.out.println("[SoftHSM2] init-token: " + out.trim());
}
private static void generateRsaKeyPair() throws Exception {
ProcessBuilder pb = new ProcessBuilder(
"keytool", "-genkeypair",
"-alias", RSA_ALIAS,
"-keyalg", "RSA",
"-keysize", "2048",
"-storetype", "PKCS11",
"-providerclass", "sun.security.pkcs11.SunPKCS11",
"-providerarg", tempCfgFile.getAbsolutePath(),
"-keystore", "NONE",
"-storepass", PIN,
"-dname", "CN=HSM Test, O=eActive, C=KR",
"-noprompt");
pb.redirectErrorStream(true);
Process p = pb.start();
String out = readOutput(p);
if (!p.waitFor(30, TimeUnit.SECONDS)) {
p.destroy();
throw new RuntimeException("keytool -genkeypair timeout");
}
System.out.println("[keytool] genkeypair exit=" + p.exitValue() + ": " + out.trim());
}
private static void generateAesKey() throws Exception {
KeyGenerator kg = KeyGenerator.getInstance("AES", hsmManager.getPkcs11Provider());
kg.init(256);
SecretKey sk = kg.generateKey();
hsmManager.getKeyStore().setKeyEntry(AES_ALIAS, sk, null, null);
System.out.println("[HsmManager] AES-256 키 생성 완료. alias=" + AES_ALIAS);
}
private static String readOutput(Process p) throws Exception {
InputStream is = p.getInputStream();
byte[] buf = new byte[4096];
StringBuilder sb = new StringBuilder();
int len;
while ((len = is.read(buf)) != -1) {
sb.append(new String(buf, 0, len, "UTF-8"));
}
return sb.toString();
}
}
@@ -0,0 +1,310 @@
package com.eactive.eai.common.hsm;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assumptions.assumeTrue;
import java.io.File;
import java.io.InputStream;
import java.nio.file.Files;
import java.security.Key;
import java.security.KeyStore;
import java.security.PrivateKey;
import java.security.Provider;
import java.security.PublicKey;
import java.security.Security;
import java.security.cert.Certificate;
import java.util.concurrent.TimeUnit;
import javax.crypto.Cipher;
import javax.crypto.KeyGenerator;
import javax.crypto.SecretKey;
import javax.crypto.spec.IvParameterSpec;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.MethodOrderer;
import org.junit.jupiter.api.Order;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestMethodOrder;
/**
* SoftHSM2 연동 통합 테스트 (JDK 8 / SunPKCS11)
*
* 사전 조건:
* 1. SoftHSM2 설치: https://github.com/disig/SoftHSM2-for-Windows/releases
* SoftHSM2-*-x64.msi 다운로드 C:\SoftHSM2 설치
* 2. 토큰 초기화 (최초 1회):
* "C:\SoftHSM2\bin\softhsm2-util.exe" --init-token --free --label "eapim-test" --pin 1234 --so-pin 1234
* 이미 초기화된 경우 @BeforeAll 에서 자동 처리
*
* SoftHSM2 미설치 모든 테스트는 자동으로 건너뜀.
*/
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
public class HsmSoftHsm2IntegrationTest {
private static final String SOFTHSM2_DLL = "C:/SoftHSM2/lib/softhsm2-x64.dll";
private static final String SOFTHSM2_UTIL = "C:/SoftHSM2/bin/softhsm2-util.exe";
private static final String TOKEN_LABEL = "eapim-test";
private static final String PIN = "1234";
private static final String RSA_ALIAS = "test-rsa-key";
private static final String AES_ALIAS = "test-aes-key";
private static Provider provider;
private static KeyStore keyStore;
private static File tempCfgFile;
// -------------------------------------------------------------------------
// 설정 / 해제
// -------------------------------------------------------------------------
@BeforeAll
static void setUpHsm() throws Exception {
System.setProperty("SOFTHSM2_CONF", "C:\\SoftHSM2\\etc\\softhsm2.conf");
assumeTrue(new File(SOFTHSM2_DLL).exists(),
"SoftHSM2 미설치 (C:/SoftHSM2) - 테스트 건너뜀");
// 1. 토큰 초기화 (이미 존재하면 출력만 남기고 계속 진행)
initToken();
// 2. pkcs11 설정 내용 (slotListIndex = 0 번째 초기화된 토큰 사용)
String cfgContent = "name = SoftHSM\n"
+ "library = " + SOFTHSM2_DLL.replace("\\", "/") + "\n"
+ "slotListIndex = 0\n";
// keytool 파일 경로를 요구하므로 임시 파일에도 저장
tempCfgFile = File.createTempFile("pkcs11-test-", ".cfg");
Files.write(tempCfgFile.toPath(), cfgContent.getBytes("UTF-8"));
// 3. SunPKCS11 Provider 초기화 (JDK 8 / JDK 9+ 공용)
provider = HsmManager.createProvider(cfgContent);
Provider existing = Security.getProvider(provider.getName());
if (existing != null) {
Security.removeProvider(existing.getName());
}
Security.addProvider(provider);
// 4. PKCS11 KeyStore 오픈
keyStore = KeyStore.getInstance("PKCS11", provider);
keyStore.load(null, PIN.toCharArray());
// 5. RSA 키쌍 생성 (keytool 경유 자체서명 인증서 포함)
if (!keyStore.containsAlias(RSA_ALIAS)) {
generateRsaKeyPair();
keyStore.load(null, PIN.toCharArray()); // keytool 결과 반영
}
// 6. AES 생성 (KeyGenerator 경유)
if (!keyStore.containsAlias(AES_ALIAS)) {
generateAesKey();
}
}
@AfterAll
static void tearDown() {
if (provider != null) {
Security.removeProvider(provider.getName());
}
if (tempCfgFile != null) {
tempCfgFile.delete();
}
}
// -------------------------------------------------------------------------
// 테스트: Provider / KeyStore
// -------------------------------------------------------------------------
@Test
@Order(1)
void testProviderInitialized() {
assertNotNull(provider);
assertTrue(provider.getName().startsWith("SunPKCS11"),
"예상 Provider 이름: SunPKCS11-*, 실제: " + provider.getName());
System.out.println("[HSM] Provider: " + provider.getName()
+ " / version: " + provider.getVersion());
}
@Test
@Order(2)
void testKeyStoreContainsRsaAlias() throws Exception {
assertTrue(keyStore.containsAlias(RSA_ALIAS),
"RSA 키 없음. alias=" + RSA_ALIAS);
}
@Test
@Order(3)
void testKeyStoreContainsAesAlias() throws Exception {
assertTrue(keyStore.containsAlias(AES_ALIAS),
"AES 키 없음. alias=" + AES_ALIAS);
}
// -------------------------------------------------------------------------
// 테스트: 조회 (HsmCryptoService.getPublicKey / getSecretKey 해당)
// -------------------------------------------------------------------------
@Test
@Order(4)
void testGetPublicKey() throws Exception {
Certificate cert = keyStore.getCertificate(RSA_ALIAS);
assertNotNull(cert, "인증서 없음. alias=" + RSA_ALIAS);
PublicKey pk = cert.getPublicKey();
assertNotNull(pk);
assertEquals("RSA", pk.getAlgorithm());
System.out.println("[HSM] PublicKey algorithm=" + pk.getAlgorithm()
+ " / format=" + pk.getFormat());
}
@Test
@Order(5)
void testGetSecretKey() throws Exception {
Key key = keyStore.getKey(AES_ALIAS, null);
assertNotNull(key, "AES 키 없음. alias=" + AES_ALIAS);
assertInstanceOf(SecretKey.class, key);
assertEquals("AES", key.getAlgorithm());
System.out.println("[HSM] SecretKey algorithm=" + key.getAlgorithm());
}
// -------------------------------------------------------------------------
// 테스트: RSA 암복호화 (HsmCryptoService.encryptRsa / decryptRsa 해당)
// -------------------------------------------------------------------------
@Test
@Order(6)
void testRsaEncrypt() throws Exception {
PublicKey publicKey = keyStore.getCertificate(RSA_ALIAS).getPublicKey();
// Provider 미명시 JVM SunRsaSign 으로 암호화 (HsmCryptoService.encryptRsa 동작)
Cipher cipher = Cipher.getInstance("RSA/ECB/PKCS1Padding");
cipher.init(Cipher.ENCRYPT_MODE, publicKey);
byte[] ciphertext = cipher.doFinal("테스트 평문".getBytes("UTF-8"));
assertNotNull(ciphertext);
assertTrue(ciphertext.length > 0);
System.out.println("[HSM] RSA 암호화 성공. ciphertext.length=" + ciphertext.length
+ " / provider=" + cipher.getProvider().getName());
}
@Test
@Order(7)
void testRsaEncryptDecryptRoundTrip() throws Exception {
byte[] plaintext = "HSM RSA 암복호화 테스트 - eapim-online".getBytes("UTF-8");
// 암호화: 공개키 JVM 소프트웨어 (Provider 미명시)
PublicKey publicKey = keyStore.getCertificate(RSA_ALIAS).getPublicKey();
Cipher encCipher = Cipher.getInstance("RSA/ECB/PKCS1Padding");
encCipher.init(Cipher.ENCRYPT_MODE, publicKey);
byte[] ciphertext = encCipher.doFinal(plaintext);
// 복호화: 개인키 핸들 HSM 내부 수행 (pkcs11Provider 명시)
PrivateKey privateKey = (PrivateKey) keyStore.getKey(RSA_ALIAS, PIN.toCharArray());
Cipher decCipher = Cipher.getInstance("RSA/ECB/PKCS1Padding", provider);
decCipher.init(Cipher.DECRYPT_MODE, privateKey);
byte[] decrypted = decCipher.doFinal(ciphertext);
assertArrayEquals(plaintext, decrypted);
System.out.println("[HSM] RSA 암복호화 성공."
+ " enc.provider=" + encCipher.getProvider().getName()
+ " / dec.provider=" + decCipher.getProvider().getName()
+ " / plaintext=" + new String(decrypted, "UTF-8"));
}
// -------------------------------------------------------------------------
// 테스트: AES 암복호화 (HsmCryptoService.encryptAes / decryptAes 해당)
// -------------------------------------------------------------------------
@Test
@Order(8)
void testAesEncryptDecryptRoundTrip() throws Exception {
byte[] plaintext = "HSM AES 암복호화 테스트 - eapim-online".getBytes("UTF-8");
byte[] iv = "1234567890123456".getBytes("UTF-8"); // 16바이트 IV
SecretKey secretKey = (SecretKey) keyStore.getKey(AES_ALIAS, null);
// 암호화
Cipher encCipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
encCipher.init(Cipher.ENCRYPT_MODE, secretKey, new IvParameterSpec(iv));
byte[] ciphertext = encCipher.doFinal(plaintext);
// 복호화
Cipher decCipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
decCipher.init(Cipher.DECRYPT_MODE, secretKey, new IvParameterSpec(iv));
byte[] decrypted = decCipher.doFinal(ciphertext);
assertArrayEquals(plaintext, decrypted);
System.out.println("[HSM] AES 암복호화 성공. plaintext=" + new String(decrypted, "UTF-8"));
}
// -------------------------------------------------------------------------
// 헬퍼
// -------------------------------------------------------------------------
private static void initToken() throws Exception {
ProcessBuilder pb = new ProcessBuilder(
SOFTHSM2_UTIL, "--init-token", "--free",
"--label", TOKEN_LABEL,
"--pin", PIN,
"--so-pin", PIN);
pb.redirectErrorStream(true);
Process p = pb.start();
String output = readOutput(p);
p.waitFor(10, TimeUnit.SECONDS);
// 이미 초기화된 슬롯이 없으면 실패할 있으나 무시 (기존 토큰 재사용)
System.out.println("[SoftHSM2] init-token: " + output.trim());
}
/**
* keytool RSA 키쌍 + 자체서명 인증서를 PKCS11 토큰에 생성.
* 인증서가 있어야 PKCS11 KeyStore 에서 alias 조회 가능.
*/
private static void generateRsaKeyPair() throws Exception {
ProcessBuilder pb = new ProcessBuilder(
"keytool", "-genkeypair",
"-alias", RSA_ALIAS,
"-keyalg", "RSA",
"-keysize", "2048",
"-storetype", "PKCS11",
"-providerclass", "sun.security.pkcs11.SunPKCS11",
"-providerarg", tempCfgFile.getAbsolutePath(),
"-keystore", "NONE",
"-storepass", PIN,
"-dname", "CN=HSM Test, O=eActive, C=KR",
"-noprompt");
pb.redirectErrorStream(true);
Process p = pb.start();
String output = readOutput(p);
boolean finished = p.waitFor(30, TimeUnit.SECONDS);
if (!finished) {
p.destroy();
throw new RuntimeException("keytool -genkeypair timeout");
}
System.out.println("[keytool] genkeypair exit=" + p.exitValue() + ": " + output.trim());
}
/**
* KeyGenerator AES-256 키를 HSM 토큰에 직접 생성.
* SoftHSM2 AES 키는 기본적으로 extractable(추출 가능).
*/
private static void generateAesKey() throws Exception {
KeyGenerator kg = KeyGenerator.getInstance("AES", provider);
kg.init(256);
SecretKey sk = kg.generateKey();
keyStore.setKeyEntry(AES_ALIAS, sk, null, null);
System.out.println("[HSM] AES-256 키 생성 완료. alias=" + AES_ALIAS);
}
private static String readOutput(Process p) throws Exception {
InputStream is = p.getInputStream();
byte[] buf = new byte[4096];
StringBuilder sb = new StringBuilder();
int len;
while ((len = is.read(buf)) != -1) {
sb.append(new String(buf, 0, len, "UTF-8"));
}
return sb.toString();
}
}
@@ -1,7 +1,10 @@
package com.eactive.eai.common.inflow;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.domain.EntityScan;
@@ -21,21 +24,37 @@ import com.eactive.eai.data.jpa.BaseRepositoryImpl;
@EnableJpaRepositories(repositoryBaseClass = BaseRepositoryImpl.class, basePackages = {
"com.eactive.eai.common.inflow" })
@EntityScan({ "com.eactive.eai.data.entity.onl.inflow" })
@Sql({ "/com/eactive/eai/common/inflow/init_tseaifr09.sql", "/com/eactive/eai/common/inflow/init_tseaifr11.sql" })
@Sql({
"/com/eactive/eai/common/inflow/init_tseaifr09.sql",
"/com/eactive/eai/common/inflow/init_tseaifr11.sql",
"/com/eactive/eai/common/inflow/init_inflow_control_group.sql",
"/com/eactive/eai/common/inflow/init_inflow_control_group_mapping.sql"
})
class InflowControlManagerTest {
@Autowired
InflowControlManager inflowControlManager;
@Test
void testStart() throws LifecycleException {
// given
// when
@BeforeEach
void setUp() throws LifecycleException {
if (!inflowControlManager.isStarted()) {
inflowControlManager.start();
}
}
// then
@Test
void testStart() {
assertTrue(inflowControlManager.isStarted());
}
@Test
void testGetGroupBucket_existingGroup_returnsNotNull() {
assertNotNull(inflowControlManager.getGroupBucket("test-group-001"));
}
@Test
void testGetGroupBucket_unknownGroup_returnsNull() {
assertNull(inflowControlManager.getGroupBucket("non-existent-group"));
}
}
@@ -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,197 @@
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);
}
}
@@ -0,0 +1,2 @@
INSERT INTO inflow_control_group (group_id, group_name, threshold, threshold_per_second, threshold_time_unit, use_yn) VALUES
('test-group-001', '테스트 그룹', 100, 10, 'MIN', '1');
@@ -0,0 +1,2 @@
INSERT INTO inflow_control_group_mapping (interface_id, group_id, use_yn) VALUES
('FASSFEP00000004S2', 'test-group-001', '1');