Merge branch 'master' into feature/crypto-module

This commit is contained in:
curry772
2026-05-12 16:33:59 +09:00
23 changed files with 1611 additions and 514 deletions
@@ -0,0 +1,319 @@
package com.eactive.eai.adapter.handler;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Properties;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.apache.commons.lang3.StringUtils;
import com.eactive.eai.common.property.PropManager;
import com.google.gson.JsonElement;
import com.google.gson.JsonParser;
import com.eactive.eai.common.util.Logger;
import com.eactive.eai.message.StandardItem;
import com.eactive.eai.message.StandardMessage;
/**
* 템플릿 기반 어댑터 에러 메시지 핸들러.
*
* [프로퍼티 설정]
* AdapterPropManager 의 "AdapterErrorMessageHandler" 프로퍼티 그룹에서
* "{adapterGroupId}.template" 키로 등록된 템플릿 문자열을 읽어
* StandardMessage 의 필드 값으로 치환한 결과를 반환한다.
*
* [변수 치환 규칙]
* 1. "${callprop.키}" → callProp.getProperty("키") 로 치환
* "${callprop.키:기본값}" → 키 없거나 callProp null 이면 기본값 반환
* 2. "${callprop[경로]}" → StandardMessage 경로 값을 키로 callProp 간접 조회
* "${callprop[경로]:기본값}" → 키 결정 실패 또는 키 없으면 기본값 반환
* 3. "${경로}" → StandardMessage.findItemValue("경로") 로 치환
* "${경로:기본값}" → 경로 값이 null/공백이면 기본값 반환
* 모든 형태에서 기본값 미지정 시 빈 문자열을 반환한다.
*
* [배열 반복 문법] (MSG_LIST 등 GRID 타입)
* {{#foreach MSG.MSG_LIST}}
* { "code":"${outp_msg_cd}", "msg":"${outp_msg_ctnt}" }
* {{/foreach}}
* → MSG_LIST 각 행을 반복하며 행 내 필드명(접두어 없이)으로 치환,
* 반복 결과를 쉼표(,)로 이어 붙임
*
* [사용 예 JSON 템플릿]
* {
* "adapterName": "${callprop.ADAPTER_NAME}",
* "resultCode": "${MSG.MAIN_MSG.outp_msg_cd}",
* "resultMsg": "${MSG.MAIN_MSG.outp_msg_ctnt}",
* "errors": [
* {{#foreach MSG.MSG_LIST}}
* {
* "code": "${outp_msg_cd}",
* "msg": "${outp_msg_ctnt}",
* "desc": "${outp_msg_desc}",
* "field": "${err_occu_item_nm}"
* }
* {{/foreach}}
* ]
* }
*
* [사용 예 – XML 템플릿]
* <error>
* <adapter>${callprop.ADAPTER_NAME}</adapter>
* <code>${MSG.MAIN_MSG.outp_msg_cd}</code>
* <errors>
* {{#foreach MSG.MSG_LIST}}
* <item><code>${outp_msg_cd}</code><msg>${outp_msg_ctnt}</msg></item>
* {{/foreach}}
* </errors>
* </error>
*
* [callProp 중첩 Properties 참조]
* callProp 의 값이 Properties 인 경우 점(.) 으로 체인 탐색이 가능하다.
* callProp.put("OUTBOUND", subProps) // subProps.setProperty("IF_ID", "SVC001")
* → 템플릿에서 ${callprop.OUTBOUND.IF_ID} 로 참조
* 중간 값이 Properties 가 아닌 경우 전체 keyPath 를 키로 폴백 조회한다.
*/
public class TemplateAdapterErrorMsgHandler implements AdapterErrorMessageHandler {
private static final Logger logger = Logger.getLogger(Logger.LOGGER_ADAPTER);
/** AdapterPropManager 에서 템플릿을 조회할 프로퍼티 그룹 이름 */
static final String PROP_GROUP = "AdapterErrorMessageHandler";
/** callProp 참조 변수 접두어: ${callprop.키} */
static final String CALLPROP_PREFIX = "callprop.";
/** callProp 간접 참조 접두어: ${callprop[표준전문경로]} */
static final String CALLPROP_INDIRECT_PREFIX = "callprop[";
/** ${path} 또는 ${callprop.key} 스칼라 변수 패턴 */
private static final Pattern VAR_PATTERN =
Pattern.compile("\\$\\{([^}]+)\\}");
/** {{#foreach path}}...{{/foreach}} 배열 반복 블록 패턴 */
private static final Pattern FOREACH_PATTERN =
Pattern.compile("\\{\\{#foreach\\s+([^}]+)\\}\\}(.*?)\\{\\{/foreach\\}\\}",
Pattern.DOTALL);
@Override
public Object generateNonStandardErrorResponseMessage(
String inboundAdapterGroupName,
String inboundAdapterName,
Properties callProp,
Object outboundRequestData,
StandardMessage resStandardMessage) throws Exception {
String templateKey = inboundAdapterGroupName + ".template";
String template = PropManager.getInstance().getProperty(PROP_GROUP, templateKey);
if (StringUtils.isBlank(template)) {
if (logger.isWarn()) {
logger.warn("TemplateAdapterErrorMsgHandler] template not found. group=" + PROP_GROUP
+ ", key=" + templateKey);
}
return null;
}
return render(template, resStandardMessage, callProp);
}
/**
* 템플릿에 StandardMessage 및 callProp 값을 치환해 최종 문자열을 반환한다.
* package-private: 단위 테스트에서 직접 호출 가능
*/
String render(String template, StandardMessage msg, Properties callProp) {
// 1단계: {{#foreach path}}...{{/foreach}} 배열 반복 처리
StringBuffer sb = new StringBuffer();
Matcher foreachMatcher = FOREACH_PATTERN.matcher(template);
while (foreachMatcher.find()) {
String arrayPath = foreachMatcher.group(1).trim();
String blockContent = foreachMatcher.group(2);
String rendered = renderForeach(arrayPath, blockContent, msg);
foreachMatcher.appendReplacement(sb, Matcher.quoteReplacement(rendered));
}
foreachMatcher.appendTail(sb);
// 2단계: 나머지 ${path} / ${callprop.키} 스칼라 변수 치환
String intermediate = sb.toString();
StringBuffer result = new StringBuffer();
Matcher varMatcher = VAR_PATTERN.matcher(intermediate);
while (varMatcher.find()) {
String expr = varMatcher.group(1).trim();
String value = resolveScalar(expr, msg, callProp);
varMatcher.appendReplacement(result, Matcher.quoteReplacement(value));
}
varMatcher.appendTail(result);
return result.toString();
}
/**
* 변수 표현식을 해석해 값을 반환한다.
* 모든 형태에서 ':기본값' 접미사를 지원한다. 기본값 미지정 시 빈 문자열.
* - "callprop[경로][:기본값]" → 표준전문 경로 값을 키로 callProp 간접 조회
* - "callprop.키[:기본값]" → callProp 직접/중첩 조회
* - "경로[:기본값]" → StandardMessage.findItemValue(경로)
*/
private String resolveScalar(String expr, StandardMessage msg, Properties callProp) {
if (expr.startsWith(CALLPROP_INDIRECT_PREFIX)) {
return resolveIndirectCallProp(expr, msg, callProp);
}
if (expr.startsWith(CALLPROP_PREFIX)) {
String rest = expr.substring(CALLPROP_PREFIX.length());
int colonIdx = rest.indexOf(':');
String keyPath = colonIdx >= 0 ? rest.substring(0, colonIdx) : rest;
String defaultVal = colonIdx >= 0 ? rest.substring(colonIdx + 1) : "";
if (callProp == null) return defaultVal;
String value = resolveCallProp(callProp, keyPath);
return value != null ? value : defaultVal;
}
int colonIdx = expr.indexOf(':');
String path = colonIdx >= 0 ? expr.substring(0, colonIdx) : expr;
String defaultVal = colonIdx >= 0 ? expr.substring(colonIdx + 1) : "";
String value = resolveMessagePath(msg, path);
return StringUtils.isNotEmpty(value) ? value : defaultVal;
}
/**
* StandardMessage 경로를 해석한다.
* 1. findItemValue(path) 로 직접 조회한다.
* 2. null 이면 경로를 뒤에서부터 분리하며, 부모 경로 값이 JSON 문자열인 경우
* 나머지 경로(subPath)를 키로 JSON 필드를 추출한다.
* 예) DATA.BIZDATA.acctNo → findItemValue("DATA.BIZDATA") → JSON 파싱 → acctNo
* DATA.BIZDATA.addr.city → findItemValue("DATA.BIZDATA") → JSON 파싱 → addr.city
*/
private String resolveMessagePath(StandardMessage msg, String path) {
String value = msg.findItemValue(path);
if (value != null) return value;
int dotIdx = path.lastIndexOf('.');
while (dotIdx > 0) {
String parentPath = path.substring(0, dotIdx);
String subPath = path.substring(dotIdx + 1);
String parentVal = msg.findItemValue(parentPath);
if (StringUtils.isNotBlank(parentVal)) {
String extracted = extractFromJson(parentVal, subPath);
if (extracted != null) return extracted;
}
dotIdx = parentPath.lastIndexOf('.');
}
return null;
}
/**
* JSON 문자열에서 dot 구분 경로로 필드 값을 추출한다.
* 중간 경로가 JSON 오브젝트면 계속 탐색하고, 최종 값이 primitive 면 문자열로,
* 오브젝트/배열이면 JSON 문자열 그대로 반환한다. 파싱 실패 시 null 반환.
*/
private String extractFromJson(String json, String fieldPath) {
try {
JsonElement el = new JsonParser().parse(json);
for (String part : fieldPath.split("\\.", -1)) {
if (!el.isJsonObject()) return null;
el = el.getAsJsonObject().get(part);
if (el == null) return null;
}
return el.isJsonPrimitive() ? el.getAsString() : el.toString();
} catch (Exception e) {
return null;
}
}
/**
* ${callprop[경로]} 또는 ${callprop[경로]:기본값} 처리.
* 1. 표준전문 경로로 callProp 키를 동적으로 결정한다.
* 2. 결정된 키로 callProp 에서 값을 조회한다.
* 3. 키 또는 값을 찾지 못하면 기본값(없으면 빈 문자열)을 반환한다.
*/
private String resolveIndirectCallProp(String expr, StandardMessage msg, Properties callProp) {
int closeIdx = expr.indexOf(']');
if (closeIdx < 0) return "";
String msgPath = expr.substring(CALLPROP_INDIRECT_PREFIX.length(), closeIdx);
String defaultVal = (closeIdx + 1 < expr.length() && expr.charAt(closeIdx + 1) == ':')
? expr.substring(closeIdx + 2)
: "";
String key = msg.findItemValue(msgPath);
if (StringUtils.isBlank(key)) return defaultVal;
if (callProp == null) return defaultVal;
String value = resolveCallProp(callProp, key);
return value != null ? value : defaultVal;
}
/**
* Properties 체인을 점(.) 구분자로 재귀 탐색한다.
* - 첫 세그먼트의 값이 Properties 이면 나머지 경로로 재귀
* - String 이면 반환, 그 외 Object 이면 toString() 반환
* - 첫 세그먼트 값이 Properties 가 아닌 경우 전체 keyPath 로 폴백 조회
*/
private String resolveCallProp(Properties props, String keyPath) {
int dotIdx = keyPath.indexOf('.');
if (dotIdx < 0) {
Object val = props.get(keyPath);
if (val == null) return null;
if (val instanceof String) return (String) val;
return val.toString();
}
String first = keyPath.substring(0, dotIdx);
String rest = keyPath.substring(dotIdx + 1);
Object val = props.get(first);
if (val instanceof Properties) {
return resolveCallProp((Properties) val, rest);
}
// 중간 값이 Properties 가 아닌 경우: 전체 keyPath 를 키로 폴백
Object direct = props.get(keyPath);
if (direct == null) return null;
if (direct instanceof String) return (String) direct;
return direct.toString();
}
/**
* GRID 타입 배열 아이템을 반복하며 blockContent 를 각 행으로 치환,
* 결과를 쉼표로 이어 반환한다. (foreach 블록 내부는 callProp 참조 미지원)
*/
private String renderForeach(String arrayPath, String blockContent, StandardMessage msg) {
StandardItem arrayItem = msg.findItem(arrayPath);
if (arrayItem == null) {
if (logger.isWarn()) logger.warn("TemplateAdapterErrorMsgHandler] foreach path not found: " + arrayPath);
return "";
}
List<LinkedHashMap<String, StandardItem>> rows = arrayItem.getList();
if (rows == null || rows.isEmpty()) {
return "";
}
List<String> renderedItems = new ArrayList<>();
for (LinkedHashMap<String, StandardItem> row : rows) {
String rowText = renderRow(blockContent, row);
if (StringUtils.isNotBlank(rowText)) {
renderedItems.add(rowText);
}
}
return String.join(",", renderedItems);
}
/**
* 배열 한 행(LinkedHashMap) 기준으로 blockContent 의 ${fieldName} 을 치환한다.
* fieldName 은 경로 없이 해당 행의 컬럼명만 사용한다.
*/
private String renderRow(String blockContent, LinkedHashMap<String, StandardItem> row) {
StringBuffer result = new StringBuffer();
Matcher varMatcher = VAR_PATTERN.matcher(blockContent);
while (varMatcher.find()) {
String fieldName = varMatcher.group(1).trim();
String value = "";
StandardItem item = row.get(fieldName);
if (item != null) {
value = StringUtils.defaultString(item.getValue());
}
varMatcher.appendReplacement(result, Matcher.quoteReplacement(value));
}
varMatcher.appendTail(result);
return result.toString().trim();
}
}
@@ -40,7 +40,9 @@ import com.eactive.eai.adapter.AdapterManager;
import com.eactive.eai.adapter.Keys;
import com.eactive.eai.adapter.http.secure.HttpClient5SSLContextFactory;
import com.eactive.eai.common.TransactionContextKeys;
import com.eactive.eai.common.message.EAIMessageKeys;
import com.eactive.eai.common.property.PropManager;
import com.eactive.eai.common.server.EAIServerManager;
import com.eactive.eai.common.util.HttpAdapterExtraLogUtil;
import com.eactive.eai.common.util.Logger;
import com.eactive.eai.httpouttlsinfo.HttpOutTlsInfoVO;
@@ -566,4 +568,10 @@ public abstract class HttpClient5AdapterServiceSupport implements HttpClientAdap
}
public abstract Object execute(Properties prop, Object message, Properties tempProp) throws Exception;
protected boolean isTas(Properties tempProp) {
String simYn = tempProp.getProperty("SIM_YN");
return EAIServerManager.getInstance().isTASEnabledEAIServer()
&& StringUtils.equals(simYn, EAIMessageKeys.TRANTYPE_TAS);
}
}
@@ -41,14 +41,11 @@ public class HttpClient5AdapterServiceBody extends HttpClient5AdapterServiceSupp
boolean useForwardProxy = StringUtils.equalsIgnoreCase(prop.getProperty("FORWARD_PROXY_USE_YN", "N"), "Y");
String forwardProxyUrl = prop.getProperty("FORWARD_PROXY_URL");
if(useForwardProxy) {
if(useForwardProxy && !isTas(tempProp)) {
java.net.URL url = new java.net.URL(forwardProxyUrl);
// 프로토콜, 호스트, 포트 추출
String protocol = url.getProtocol();
String host = url.getHost();
int port = url.getPort();
HttpHost proxy = new HttpHost(protocol,host, port);
HttpHost proxy = new HttpHost(url.getProtocol(), url.getHost(), url.getPort());
requestConfigBuilder.setProxy(proxy);
}
@@ -11,6 +11,7 @@ import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Base64;
import java.util.Date;
import java.util.Enumeration;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@@ -294,14 +295,11 @@ public class HttpClient5AdapterServiceRest extends HttpClient5AdapterServiceSupp
RequestConfig.Builder requestConfigBuilder = RequestConfig.custom();
if(useForwardProxy) {
if(useForwardProxy && !isTas(tempProp)) {
java.net.URL url = new java.net.URL(forwardProxyUrl);
// 프로토콜, 호스트, 포트 추출
String protocol = url.getProtocol();
String host = url.getHost();
int port = url.getPort();
HttpHost proxy = new HttpHost(protocol,host, port);
HttpHost proxy = new HttpHost(url.getProtocol(), url.getHost(), url.getPort());
requestConfigBuilder.setProxy(proxy);
}
@@ -708,12 +706,19 @@ public class HttpClient5AdapterServiceRest extends HttpClient5AdapterServiceSupp
return new String(responseMessage, encode);
}
String[] relayKeyArr = org.springframework.util.StringUtils.tokenizeToStringArray(relayHeaderKeys, ",");
JSONObject headerJson = new JSONObject();
for (String key : relayKeyArr) {
String value = responseHeaderProp.getProperty(key);
if (StringUtils.isNotBlank(value)) {
headerJson.put(key, value);
if (StringUtils.equalsIgnoreCase(relayHeaderKeys, "ALL")) {
for (Enumeration<Object> e = responseHeaderProp.keys(); e.hasMoreElements(); ) {
String key = (String)e.nextElement();
headerJson.put(StringUtils.lowerCase(key), responseHeaderProp.getProperty(key));
}
} else {
String[] relayKeyArr = org.springframework.util.StringUtils.tokenizeToStringArray(relayHeaderKeys, ",");
for (String key : relayKeyArr) {
String value = responseHeaderProp.getProperty(key);
if (StringUtils.isNotBlank(value)) {
headerJson.put(StringUtils.lowerCase(key), value);
}
}
}
@@ -4,7 +4,7 @@ import com.eactive.eai.agent.command.Command;
import com.eactive.eai.agent.command.CommandException;
import com.eactive.eai.common.inflow.AbstractInflowControlManager;
import com.eactive.eai.common.inflow.InflowControlUtil;
import com.eactive.eai.common.inflow.dual.DualInflowControlManager;
import com.eactive.eai.common.inflow.dual.ClientDualInflowControlManager;
import com.eactive.eai.common.util.Logger;
public class ReloadInflowClientControlCommand extends Command {
@@ -31,10 +31,10 @@ public class ReloadInflowClientControlCommand extends Command {
String keyName = (String) args;
try {
AbstractInflowControlManager base = InflowControlUtil.getInflowControlManager();
if (!(base instanceof DualInflowControlManager)) {
throw new IllegalStateException("DualInflowControlManager 가 활성화되지 않았습니다.");
if (!(base instanceof ClientDualInflowControlManager)) {
throw new IllegalStateException("ClientDualInflowControlManager 가 활성화되지 않았습니다.");
}
DualInflowControlManager manager = (DualInflowControlManager) base;
ClientDualInflowControlManager manager = (ClientDualInflowControlManager) base;
if (keyName != null) {
if ("ALL".equals(keyName)) {
@@ -4,7 +4,7 @@ import com.eactive.eai.agent.command.Command;
import com.eactive.eai.agent.command.CommandException;
import com.eactive.eai.common.inflow.AbstractInflowControlManager;
import com.eactive.eai.common.inflow.InflowControlUtil;
import com.eactive.eai.common.inflow.dual.DualInflowControlManager;
import com.eactive.eai.common.inflow.dual.ClientDualInflowControlManager;
import com.eactive.eai.common.util.Logger;
public class RemoveInflowClientControlCommand extends Command {
@@ -31,10 +31,10 @@ public class RemoveInflowClientControlCommand extends Command {
String key = (String) args;
try {
AbstractInflowControlManager base = InflowControlUtil.getInflowControlManager();
if (!(base instanceof DualInflowControlManager)) {
throw new IllegalStateException("DualInflowControlManager 가 활성화되지 않았습니다.");
if (!(base instanceof ClientDualInflowControlManager)) {
throw new IllegalStateException("ClientDualInflowControlManager 가 활성화되지 않았습니다.");
}
DualInflowControlManager manager = (DualInflowControlManager) base;
ClientDualInflowControlManager manager = (ClientDualInflowControlManager) base;
manager.removeClient(key);
if (logger.isWarn())
logger.warn(this.name + "] " + key + " removed.");
@@ -21,6 +21,7 @@ import com.eactive.eai.common.util.Logger;
import io.github.bucket4j.Bandwidth;
import io.github.bucket4j.Bucket4j;
import io.github.bucket4j.Refill;
import io.github.bucket4j.local.LocalBucket;
import io.github.bucket4j.local.LocalBucketBuilder;
@@ -345,12 +346,14 @@ public abstract class AbstractInflowControlManager implements Lifecycle, Bucket
if (AbstractInflowControlManager.isInflowTarget(inflowTarget)) {
LocalBucketBuilder builder = Bucket4j.builder();
if (inflowTarget.getThresholdPerSecond() > 0) {
builder.addLimit(Bandwidth.simple(inflowTarget.getThresholdPerSecond(), Duration.ofSeconds(1)));
builder.addLimit(Bandwidth.classic(inflowTarget.getThresholdPerSecond(),
Refill.greedy(inflowTarget.getThresholdPerSecond(), Duration.ofSeconds(1))));
}
if (inflowTarget.getThreshold() > 0 && StringUtils.isNotBlank(inflowTarget.getThresholdTimeUnit())) {
builder.addLimit(Bandwidth.simple(inflowTarget.getThreshold(),
AbstractInflowControlManager.getPeriod(inflowTarget.getThresholdTimeUnit())));
builder.addLimit(Bandwidth.classic(inflowTarget.getThreshold(),
Refill.intervally(inflowTarget.getThreshold(),
AbstractInflowControlManager.getPeriod(inflowTarget.getThresholdTimeUnit()))));
}
return new CustomBucket(builder.build(), inflowTarget);
@@ -369,14 +372,16 @@ public abstract class AbstractInflowControlManager implements Lifecycle, Bucket
if (groupVo.getThresholdPerSecond() > 0) {
perSecondBucket = Bucket4j.builder()
.addLimit(Bandwidth.simple(groupVo.getThresholdPerSecond(), Duration.ofSeconds(1)))
.addLimit(Bandwidth.classic(groupVo.getThresholdPerSecond(),
Refill.greedy(groupVo.getThresholdPerSecond(), Duration.ofSeconds(1))))
.build();
}
if (groupVo.getThreshold() > 0 && StringUtils.isNotBlank(groupVo.getThresholdTimeUnit())) {
thresholdBucket = Bucket4j.builder()
.addLimit(Bandwidth.simple(groupVo.getThreshold(),
AbstractInflowControlManager.getPeriod(groupVo.getThresholdTimeUnit())))
.addLimit(Bandwidth.classic(groupVo.getThreshold(),
Refill.intervally(groupVo.getThreshold(),
AbstractInflowControlManager.getPeriod(groupVo.getThresholdTimeUnit()))))
.build();
}
@@ -0,0 +1,22 @@
package com.eactive.eai.common.inflow.dual;
import com.eactive.eai.common.inflow.InflowTargetVO;
/**
* DualBucket에 클라이언트 유량제어 메서드를 추가한 인터페이스.
* {@link ClientDualInflowControlManager}가 구현한다.
*/
public interface ClientDualBucket extends DualBucket {
/**
* 클라이언트 유량제어 체크 — 차단 원인 포함.
* @return null이면 통과, "PER_SECOND" 또는 "THRESHOLD"이면 해당 버킷에서 차단
*/
String isClientPassDetail(String clientId);
/**
* 클라이언트 유량제어 설정 조회 — 에러 메시지 생성용.
* @return 등록된 설정이 없으면 null
*/
InflowTargetVO getClientInflowThreshold(String clientId);
}
@@ -0,0 +1,112 @@
package com.eactive.eai.common.inflow.dual;
import java.util.Collections;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import org.springframework.stereotype.Component;
import com.eactive.eai.common.inflow.Bucket;
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.util.ApplicationContextProvider;
import com.eactive.eai.common.util.Logger;
/**
* 어댑터/인터페이스 이중 버킷(DualInflowControlManager)에 클라이언트 유량제어를 추가한 관리자.
*
* <p>적용 방법: INFLOW.properties에 아래 한 줄 추가.
* <pre>inflow.control.bucket.className=com.eactive.eai.common.inflow.dual.ClientDualInflowControlManager</pre>
*/
@Component
public class ClientDualInflowControlManager extends DualInflowControlManager implements ClientDualBucket {
private static final Logger logger = Logger.getLogger(Logger.LOGGER_DEFAULT);
public static synchronized Bucket getBucket() {
return ApplicationContextProvider.getContext().getBean(ClientDualInflowControlManager.class);
}
private volatile Map<String, DualCustomBucket> clientBucketMap = new ConcurrentHashMap<>();
private final ConcurrentHashMap<String, TargetMetrics> clientMetricsMap = new ConcurrentHashMap<>();
// -------------------------------------------------------------------------
// Lifecycle
// -------------------------------------------------------------------------
@Override
public void start() throws LifecycleException {
super.start();
try {
initClients();
} catch (Exception e) {
throw new LifecycleException("ClientDualInflowControlManager init failed: " + e.getMessage());
}
}
// -------------------------------------------------------------------------
// 초기화 / 리로드
// -------------------------------------------------------------------------
private void initClients() throws Exception {
this.clientBucketMap = loadBucketMap(InflowType.CLIENT);
if (logger.isInfo()) logger.info("ClientDualInflowControlManager] initClients: " + clientBucketMap.size() + " buckets");
}
public synchronized void reloadClient() throws Exception {
initClients();
clientMetricsMap.clear();
}
public synchronized void reloadClient(String clientId) throws Exception {
reloadBucketMap(clientBucketMap, InflowType.CLIENT, clientId);
TargetMetrics m = clientMetricsMap.get(clientId);
if (m != null) m.reset();
}
public synchronized void removeClient(String clientId) {
clientBucketMap.remove(clientId);
clientMetricsMap.remove(clientId);
}
// -------------------------------------------------------------------------
// 클라이언트 메트릭 접근자
// -------------------------------------------------------------------------
public TargetMetrics getClientMetrics(String clientId) { return clientMetricsMap.get(clientId); }
public Map<String, TargetMetrics> getAllClientMetrics() { return Collections.unmodifiableMap(clientMetricsMap); }
public void resetClientMetrics(String clientId) { TargetMetrics m = clientMetricsMap.get(clientId); if (m != null) m.reset(); }
public void resetAllClientMetrics() { clientMetricsMap.values().forEach(TargetMetrics::reset); }
// -------------------------------------------------------------------------
// ClientDualBucket — 클라이언트 유량제어
// -------------------------------------------------------------------------
@Override
public String isClientPassDetail(String clientId) {
DualCustomBucket b = clientBucketMap.get(clientId);
if (b == null) return DualCustomBucket.RESULT_PASS;
String result = b.tryConsume();
recordMetrics(clientMetricsMap, clientId, result);
return result;
}
@Override
public InflowTargetVO getClientInflowThreshold(String clientId) {
DualCustomBucket b = clientBucketMap.get(clientId);
return (b == null) ? null : b.getInflowTargetVo();
}
// -------------------------------------------------------------------------
// 모니터링용 접근자
// -------------------------------------------------------------------------
public DualCustomBucket getClientBucket(String clientId) {
return clientBucketMap.get(clientId);
}
public Map<String, DualCustomBucket> getClientBucketMap() {
return clientBucketMap;
}
}
@@ -1,13 +1,13 @@
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)에서 차단됐는지 반환하는 메서드 추가.
* 클라이언트별 유량제어 메서드도 포함.
*
* <p>클라이언트 유량제어는 {@link ClientDualBucket}으로 분리됨.
*/
public interface DualBucket extends Bucket {
@@ -22,16 +22,4 @@ public interface DualBucket extends Bucket {
* @return null이면 통과, "PER_SECOND" 또는 "THRESHOLD"이면 해당 버킷에서 차단
*/
String isInterfacePassDetail(String inter);
/**
* 클라이언트 유량제어 체크 — 차단 원인 포함.
* @return null이면 통과, "PER_SECOND" 또는 "THRESHOLD"이면 해당 버킷에서 차단
*/
String isClientPassDetail(String clientId);
/**
* 클라이언트 유량제어 설정 조회 — 에러 메시지 생성용.
* @return 등록된 설정이 없으면 null
*/
InflowTargetVO getClientInflowThreshold(String clientId);
}
@@ -9,7 +9,6 @@ import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Component;
import com.eactive.eai.common.inflow.AbstractInflowControlManager;
import com.eactive.eai.common.inflow.Bucket;
@@ -22,21 +21,17 @@ import com.eactive.eai.common.util.Logger;
import io.github.bucket4j.Bandwidth;
import io.github.bucket4j.Bucket4j;
import io.github.bucket4j.Refill;
import io.github.bucket4j.local.LocalBucket;
/**
* 어댑터/인터페이스/클라이언트 버킷을 이중 구조(perSecond + threshold)로 관리하는 유량제어 관리자.
* 어댑터/인터페이스 버킷을 이중 구조(perSecond + threshold)로 관리하는 유량제어 관리자.
*
* <p>기존 InflowControlManager는 어댑터/인터페이스에 단일 LocalBucket(복수 Bandwidth)을 사용하여
* 차단 시 어느 한도(초당/기간)를 초과했는지 알 수 없었다.
* 이 클래스는 그룹 버킷(CustomGroupBucket)과 동일하게 두 버킷을 분리하고
* {@link DualBucket#isAdapterPassDetail}/{@link DualBucket#isInterfacePassDetail}로
* 차단 원인을 반환한다. 그룹 버킷 통과 여부는 {@link TargetMetrics}로 카운팅된다.
* <p>클라이언트 유량제어는 {@link ClientDualInflowControlManager}로 분리됨.
*
* <p>적용 방법: INFLOW.properties에 아래 한 줄 추가.
* <pre>inflow.control.bucket.className=com.eactive.eai.common.inflow.dual.DualInflowControlManager</pre>
* <pre>inflow.control.bucket.className=com.eactive.eai.common.inflow.dual.ClientDualInflowControlManager</pre>
*/
@Component
public class DualInflowControlManager extends AbstractInflowControlManager implements DualBucket {
private static final Logger logger = Logger.getLogger(Logger.LOGGER_DEFAULT);
@@ -48,11 +43,9 @@ public class DualInflowControlManager extends AbstractInflowControlManager imple
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> adapterMetricsMap = new ConcurrentHashMap<>();
private final ConcurrentHashMap<String, TargetMetrics> interfaceMetricsMap = new ConcurrentHashMap<>();
private final ConcurrentHashMap<String, TargetMetrics> clientMetricsMap = new ConcurrentHashMap<>();
private final ConcurrentHashMap<String, TargetMetrics> groupMetricsMap = new ConcurrentHashMap<>();
public static class TargetMetrics {
@@ -79,7 +72,6 @@ public class DualInflowControlManager extends AbstractInflowControlManager imple
try {
initAdapters();
initInterfaces();
initClients();
} catch (Exception e) {
throw new LifecycleException("DualInflowControlManager init failed: " + e.getMessage());
}
@@ -99,12 +91,7 @@ public class DualInflowControlManager extends AbstractInflowControlManager imple
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 {
protected Map<String, DualCustomBucket> loadBucketMap(InflowType type) throws Exception {
Map<String, DualCustomBucket> newMap = new HashMap<>();
for (InflowTargetVO vo : inflowControlDAO.getInflowList(type)) {
DualCustomBucket bucket = makeBucket(vo);
@@ -139,22 +126,6 @@ public class DualInflowControlManager extends AbstractInflowControlManager imple
if (m != null) m.reset();
}
public synchronized void reloadClient() throws Exception {
initClients();
clientMetricsMap.clear();
}
public synchronized void reloadClient(String clientId) throws Exception {
reloadBucketMap(clientBucketMap, InflowType.CLIENT, clientId);
TargetMetrics m = clientMetricsMap.get(clientId);
if (m != null) m.reset();
}
public synchronized void removeClient(String clientId) {
clientBucketMap.remove(clientId);
clientMetricsMap.remove(clientId);
}
@Override
public synchronized void reloadGroup() throws Exception {
super.reloadGroup();
@@ -168,7 +139,7 @@ public class DualInflowControlManager extends AbstractInflowControlManager imple
if (m != null) m.reset();
}
private void reloadBucketMap(Map<String, DualCustomBucket> targetMap, InflowType type, String name) throws Exception {
protected void reloadBucketMap(Map<String, DualCustomBucket> targetMap, InflowType type, String name) throws Exception {
Map<String, InflowTargetVO> updated = inflowControlDAO.getInflowMap(type, name);
if (updated == null) return;
for (InflowTargetVO vo : updated.values()) {
@@ -179,24 +150,20 @@ public class DualInflowControlManager extends AbstractInflowControlManager imple
}
// -------------------------------------------------------------------------
// 어댑터/인터페이스/클라이언트 메트릭 접근자
// 어댑터/인터페이스 메트릭 접근자
// -------------------------------------------------------------------------
public TargetMetrics getAdapterMetrics(String adapter) { return adapterMetricsMap.get(adapter); }
public TargetMetrics getInterfaceMetrics(String inter) { return interfaceMetricsMap.get(inter); }
public TargetMetrics getClientMetrics(String clientId) { return clientMetricsMap.get(clientId); }
public Map<String, TargetMetrics> getAllAdapterMetrics() { return Collections.unmodifiableMap(adapterMetricsMap); }
public Map<String, TargetMetrics> getAllInterfaceMetrics() { return Collections.unmodifiableMap(interfaceMetricsMap); }
public Map<String, TargetMetrics> getAllClientMetrics() { return Collections.unmodifiableMap(clientMetricsMap); }
public void resetAdapterMetrics(String adapter) { TargetMetrics m = adapterMetricsMap.get(adapter); if (m != null) m.reset(); }
public void resetInterfaceMetrics(String inter) { TargetMetrics m = interfaceMetricsMap.get(inter); if (m != null) m.reset(); }
public void resetClientMetrics(String clientId) { TargetMetrics m = clientMetricsMap.get(clientId); if (m != null) m.reset(); }
public void resetAllAdapterMetrics() { adapterMetricsMap.values().forEach(TargetMetrics::reset); }
public void resetAllInterfaceMetrics() { interfaceMetricsMap.values().forEach(TargetMetrics::reset); }
public void resetAllClientMetrics() { clientMetricsMap.values().forEach(TargetMetrics::reset); }
// -------------------------------------------------------------------------
// 그룹 메트릭 — isGroupPass() 카운팅 및 접근자
@@ -255,32 +222,15 @@ public class DualInflowControlManager extends AbstractInflowControlManager imple
return result;
}
@Override
public String isClientPassDetail(String clientId) {
DualCustomBucket b = clientBucketMap.get(clientId);
if (b == null) return DualCustomBucket.RESULT_PASS;
String result = b.tryConsume();
recordMetrics(clientMetricsMap, clientId, result);
return result;
}
private void recordMetrics(ConcurrentHashMap<String, TargetMetrics> metricsMap, String key, String result) {
protected void recordMetrics(ConcurrentHashMap<String, TargetMetrics> metricsMap, String key, String result) {
TargetMetrics m = metricsMap.computeIfAbsent(key, k -> new TargetMetrics());
if (result == null) m.allowed.incrementAndGet();
else if (DualCustomBucket.RESULT_BLOCKED_PER_SECOND.equals(result)) m.rejectedPerSecond.incrementAndGet();
else m.rejectedThreshold.incrementAndGet();
}
@Override
public InflowTargetVO getClientInflowThreshold(String clientId) {
DualCustomBucket b = clientBucketMap.get(clientId);
return (b == null) ? null : b.getInflowTargetVo();
}
// -------------------------------------------------------------------------
// Bucket 인터페이스 오버라이드 — 이중 버킷으로 교체 (boolean 반환 유지)
// isAdapterPassDetail/isInterfacePassDetail이 토큰을 소비하므로
// 두 메서드를 동일 요청에서 중복 호출하면 토큰이 이중 소비됨에 주의.
// -------------------------------------------------------------------------
@Override
@@ -295,7 +245,6 @@ public class DualInflowControlManager extends AbstractInflowControlManager imple
// -------------------------------------------------------------------------
// 어댑터/인터페이스 키·VO·삭제 — Dual 맵 기준으로 오버라이드
// (base의 adapterBucketList / interfaceBucketList 는 읽지 않아 DAO 이중 호출 제거)
// -------------------------------------------------------------------------
@Override
@@ -356,19 +305,16 @@ public class DualInflowControlManager extends AbstractInflowControlManager imple
return interfaceBucketMap;
}
public DualCustomBucket getClientBucket(String clientId) {
return clientBucketMap.get(clientId);
}
public Map<String, DualCustomBucket> getClientBucketMap() {
return clientBucketMap;
}
// -------------------------------------------------------------------------
// 버킷 팩토리
// -------------------------------------------------------------------------
private DualCustomBucket makeBucket(InflowTargetVO vo) {
/**
* perSecond 버킷: greedy refill — 초당 N개 요청을 균등하게 허용.
* threshold 버킷: intervally refill — 기간(시/일/월 등) 단위 쿼터를 기간 시작 시 일괄 충전.
* Bandwidth.simple은 두 버킷 모두 greedy로 생성되어 시간 단위 구분이 없어지므로 classic으로 교체.
*/
protected DualCustomBucket makeBucket(InflowTargetVO vo) {
if (!AbstractInflowControlManager.isInflowTarget(vo)) {
return null;
}
@@ -378,14 +324,16 @@ public class DualInflowControlManager extends AbstractInflowControlManager imple
if (vo.getThresholdPerSecond() > 0) {
perSecondBucket = Bucket4j.builder()
.addLimit(Bandwidth.simple(vo.getThresholdPerSecond(), Duration.ofSeconds(1)))
.addLimit(Bandwidth.classic(vo.getThresholdPerSecond(),
Refill.greedy(vo.getThresholdPerSecond(), Duration.ofSeconds(1))))
.build();
}
if (vo.getThreshold() > 0 && StringUtils.isNotBlank(vo.getThresholdTimeUnit())) {
Duration period = AbstractInflowControlManager.getPeriod(vo.getThresholdTimeUnit());
thresholdBucket = Bucket4j.builder()
.addLimit(Bandwidth.simple(vo.getThreshold(),
AbstractInflowControlManager.getPeriod(vo.getThresholdTimeUnit())))
.addLimit(Bandwidth.classic(vo.getThreshold(),
Refill.intervally(vo.getThreshold(), period)))
.build();
}
@@ -1,79 +0,0 @@
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);
}
}
@@ -17,6 +17,7 @@ import com.eactive.eai.adapter.AdapterPropManager;
import com.eactive.eai.adapter.AdapterVO;
import com.eactive.eai.adapter.http.HttpStatusException;
import com.eactive.eai.adapter.http.client.HttpClientAdapterServiceKey;
import com.eactive.eai.adapter.http.dynamic.HttpAdapterServiceKey;
import com.eactive.eai.common.EAIKeys;
import com.eactive.eai.common.exception.ExceptionUtil;
import com.eactive.eai.common.header.HeaderAction;
@@ -352,24 +353,26 @@ public class HTTPProcess extends DefaultProcess {
this.tempProp.put(HttpClientAdapterServiceKey.INSTANCE_ID, instanceId);
}
EAIServerManager eaiServerManager = EAIServerManager.getInstance();
AdapterManager adapterManager = AdapterManager.getInstance();
AdapterGroupVO adptGrpVO = adapterManager.getAdapterGroupVO(reqEaiMsg.getCurrentSvcMsg().getPsvSysItfTp());
if (eaiServerManager.isTASEnabledEAIServer() && EAIMessageKeys.TRANTYPE_TAS.equals(reqEaiMsg.getTranType())
// RESTAdapter는 교체하지 않는다
&& !StringUtils.equals(adptGrpVO.getType(), com.eactive.eai.adapter.Keys.TYPE_REST)) {
PropManager manager = PropManager.getInstance();
Properties prop = manager.getProperties(RouteKeys.SIM);
if (reqEaiMsg.getCurrentSvcMsg().isSyncItfTp()) {
this.adapterGroupName = prop.getProperty(RouteKeys.SIM_ADAPTER_SYNC, "_SIM_OU_HTT_SyC");
} else {
this.adapterGroupName = prop.getProperty(RouteKeys.SIM_ADAPTER_ASYNC, "_SIM_OU_HTT_AsC");
}
this.reqEaiMsg.getSvcMsg(0).setPsvSysItfTp(this.adapterGroupName);
} else {
this.adapterGroupName = reqEaiMsg.getCurrentSvcMsg().getPsvSysItfTp();
}
// EAIServerManager eaiServerManager = EAIServerManager.getInstance();
// AdapterManager adapterManager = AdapterManager.getInstance();
// AdapterGroupVO adptGrpVO = adapterManager.getAdapterGroupVO(reqEaiMsg.getCurrentSvcMsg().getPsvSysItfTp());
// if (eaiServerManager.isTASEnabledEAIServer() && EAIMessageKeys.TRANTYPE_TAS.equals(reqEaiMsg.getTranType())
// // RESTAdapter는 교체하지 않는다
// && !StringUtils.equals(adptGrpVO.getType(), com.eactive.eai.adapter.Keys.TYPE_REST)) {
// PropManager manager = PropManager.getInstance();
// Properties prop = manager.getProperties(RouteKeys.SIM);
//
// if (reqEaiMsg.getCurrentSvcMsg().isSyncItfTp()) {
// this.adapterGroupName = prop.getProperty(RouteKeys.SIM_ADAPTER_SYNC, "_SIM_OU_HTT_SyC");
// } else {
// this.adapterGroupName = prop.getProperty(RouteKeys.SIM_ADAPTER_ASYNC, "_SIM_OU_HTT_AsC");
// }
// this.reqEaiMsg.getSvcMsg(0).setPsvSysItfTp(this.adapterGroupName);
// } else {
// this.adapterGroupName = reqEaiMsg.getCurrentSvcMsg().getPsvSysItfTp();
// }
this.adapterGroupName = reqEaiMsg.getCurrentSvcMsg().getPsvSysItfTp();
this.svcTsmtUsgTp = this.reqEaiMsg.getSvcTsmtUsgTp(); // Inbound 호출방식
this.psvItfTp = this.reqEaiMsg.getCurrentSvcMsg().getPsvItfTp(); // 호출방식
@@ -404,6 +407,7 @@ public class HTTPProcess extends DefaultProcess {
}
// Inbound Adapter mesageType
AdapterManager adapterManager = AdapterManager.getInstance();
AdapterGroupVO inAdptGrpVO = adapterManager.getAdapterGroupVO(this.reqEaiMsg.getSngSysItfTp());
this.inAdapterMsgType = MessageType.ASC;
@@ -592,6 +596,22 @@ public class HTTPProcess extends DefaultProcess {
logger.debug(guidLogPrefix + " HTTP NONSTANDARD Request Message : "
+ MessageUtil.toAdapterTypeString(this.reqObject, this.inboundCharset));
}
// TAS 선택시 시뮬레이터 url로 변경, HTT 어댑터도 RST 처럼 방식을 변경하였다.
EAIServerManager eaiServerManager = EAIServerManager.getInstance();
if (eaiServerManager.isTASEnabledEAIServer() && EAIMessageKeys.TRANTYPE_TAS.equals(reqEaiMsg.getTranType())) {
PropManager manager = PropManager.getInstance();
Properties prop = manager.getProperties(RouteKeys.SIM);
String simAddr = prop.getProperty(RouteKeys.SIM_REST_MOCKSERVER);
if (StringUtils.isBlank(simAddr)) {
throw new Exception(String.format("Properties %s > %s not setted", RouteKeys.SIM,
RouteKeys.SIM_REST_MOCKSERVER));
}
String url = StringUtils.removeEnd(simAddr, "/") + "/mockapi/" + this.reqEaiMsg.getEAISvcCd();
this.outboundProp.put(HttpClientAdapterServiceKey.URL, url);
this.tempProp.remove(HttpAdapterServiceKey.INBOUND_REWRITE_PATH);
logger.debug("simUrl=" + url);
}
} catch (Exception e) {
String[] msgArgs = new String[1];
msgArgs[0] = this.reqEaiMsg.getEAISvcCd();