diff --git a/src/main/java/com/eactive/eai/adapter/handler/TemplateAdapterErrorMsgHandler.java b/src/main/java/com/eactive/eai/adapter/handler/TemplateAdapterErrorMsgHandler.java new file mode 100644 index 0000000..3e6cd3e --- /dev/null +++ b/src/main/java/com/eactive/eai/adapter/handler/TemplateAdapterErrorMsgHandler.java @@ -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 템플릿] + * + * ${callprop.ADAPTER_NAME} + * ${MSG.MAIN_MSG.outp_msg_cd} + * + * {{#foreach MSG.MSG_LIST}} + * ${outp_msg_cd}${outp_msg_ctnt} + * {{/foreach}} + * + * + * + * [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> rows = arrayItem.getList(); + if (rows == null || rows.isEmpty()) { + return ""; + } + + List renderedItems = new ArrayList<>(); + for (LinkedHashMap 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 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(); + } +} diff --git a/src/main/java/com/eactive/eai/adapter/http/client/HttpClient5AdapterServiceSupport.java b/src/main/java/com/eactive/eai/adapter/http/client/HttpClient5AdapterServiceSupport.java index 601b163..046ace8 100644 --- a/src/main/java/com/eactive/eai/adapter/http/client/HttpClient5AdapterServiceSupport.java +++ b/src/main/java/com/eactive/eai/adapter/http/client/HttpClient5AdapterServiceSupport.java @@ -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); + } } \ No newline at end of file diff --git a/src/main/java/com/eactive/eai/adapter/http/client/impl/HttpClient5AdapterServiceBody.java b/src/main/java/com/eactive/eai/adapter/http/client/impl/HttpClient5AdapterServiceBody.java index 2468b2f..691a213 100644 --- a/src/main/java/com/eactive/eai/adapter/http/client/impl/HttpClient5AdapterServiceBody.java +++ b/src/main/java/com/eactive/eai/adapter/http/client/impl/HttpClient5AdapterServiceBody.java @@ -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); } diff --git a/src/main/java/com/eactive/eai/adapter/http/client/impl/HttpClient5AdapterServiceRest.java b/src/main/java/com/eactive/eai/adapter/http/client/impl/HttpClient5AdapterServiceRest.java index 88558bb..cefe58d 100644 --- a/src/main/java/com/eactive/eai/adapter/http/client/impl/HttpClient5AdapterServiceRest.java +++ b/src/main/java/com/eactive/eai/adapter/http/client/impl/HttpClient5AdapterServiceRest.java @@ -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 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); + } } } diff --git a/src/main/java/com/eactive/eai/agent/inflow/ReloadInflowClientControlCommand.java b/src/main/java/com/eactive/eai/agent/inflow/ReloadInflowClientControlCommand.java index 8afe1e1..becbfeb 100644 --- a/src/main/java/com/eactive/eai/agent/inflow/ReloadInflowClientControlCommand.java +++ b/src/main/java/com/eactive/eai/agent/inflow/ReloadInflowClientControlCommand.java @@ -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)) { diff --git a/src/main/java/com/eactive/eai/agent/inflow/RemoveInflowClientControlCommand.java b/src/main/java/com/eactive/eai/agent/inflow/RemoveInflowClientControlCommand.java index fe02028..97fe72c 100644 --- a/src/main/java/com/eactive/eai/agent/inflow/RemoveInflowClientControlCommand.java +++ b/src/main/java/com/eactive/eai/agent/inflow/RemoveInflowClientControlCommand.java @@ -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."); diff --git a/src/main/java/com/eactive/eai/common/inflow/AbstractInflowControlManager.java b/src/main/java/com/eactive/eai/common/inflow/AbstractInflowControlManager.java index 12c23b1..8898d1f 100644 --- a/src/main/java/com/eactive/eai/common/inflow/AbstractInflowControlManager.java +++ b/src/main/java/com/eactive/eai/common/inflow/AbstractInflowControlManager.java @@ -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(); } diff --git a/src/main/java/com/eactive/eai/common/inflow/dual/ClientDualBucket.java b/src/main/java/com/eactive/eai/common/inflow/dual/ClientDualBucket.java new file mode 100644 index 0000000..da8d686 --- /dev/null +++ b/src/main/java/com/eactive/eai/common/inflow/dual/ClientDualBucket.java @@ -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); +} diff --git a/src/main/java/com/eactive/eai/common/inflow/dual/ClientDualInflowControlManager.java b/src/main/java/com/eactive/eai/common/inflow/dual/ClientDualInflowControlManager.java new file mode 100644 index 0000000..606d1e7 --- /dev/null +++ b/src/main/java/com/eactive/eai/common/inflow/dual/ClientDualInflowControlManager.java @@ -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)에 클라이언트 유량제어를 추가한 관리자. + * + *

적용 방법: INFLOW.properties에 아래 한 줄 추가. + *

inflow.control.bucket.className=com.eactive.eai.common.inflow.dual.ClientDualInflowControlManager
+ */ +@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 clientBucketMap = new ConcurrentHashMap<>(); + private final ConcurrentHashMap 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 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 getClientBucketMap() { + return clientBucketMap; + } +} diff --git a/src/main/java/com/eactive/eai/common/inflow/dual/DualBucket.java b/src/main/java/com/eactive/eai/common/inflow/dual/DualBucket.java index 7f7a47c..fa4d18e 100644 --- a/src/main/java/com/eactive/eai/common/inflow/dual/DualBucket.java +++ b/src/main/java/com/eactive/eai/common/inflow/dual/DualBucket.java @@ -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)에서 차단됐는지 반환하는 메서드 추가. - * 클라이언트별 유량제어 메서드도 포함. + * + *

클라이언트 유량제어는 {@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); } diff --git a/src/main/java/com/eactive/eai/common/inflow/dual/DualInflowControlManager.java b/src/main/java/com/eactive/eai/common/inflow/dual/DualInflowControlManager.java index de1b827..d6c415a 100644 --- a/src/main/java/com/eactive/eai/common/inflow/dual/DualInflowControlManager.java +++ b/src/main/java/com/eactive/eai/common/inflow/dual/DualInflowControlManager.java @@ -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)로 관리하는 유량제어 관리자. * - *

기존 InflowControlManager는 어댑터/인터페이스에 단일 LocalBucket(복수 Bandwidth)을 사용하여 - * 차단 시 어느 한도(초당/기간)를 초과했는지 알 수 없었다. - * 이 클래스는 그룹 버킷(CustomGroupBucket)과 동일하게 두 버킷을 분리하고 - * {@link DualBucket#isAdapterPassDetail}/{@link DualBucket#isInterfacePassDetail}로 - * 차단 원인을 반환한다. 그룹 버킷 통과 여부는 {@link TargetMetrics}로 카운팅된다. + *

클라이언트 유량제어는 {@link ClientDualInflowControlManager}로 분리됨. * *

적용 방법: INFLOW.properties에 아래 한 줄 추가. - *

inflow.control.bucket.className=com.eactive.eai.common.inflow.dual.DualInflowControlManager
+ *
inflow.control.bucket.className=com.eactive.eai.common.inflow.dual.ClientDualInflowControlManager
*/ -@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 adapterBucketMap = new ConcurrentHashMap<>(); private volatile Map interfaceBucketMap = new ConcurrentHashMap<>(); - private volatile Map clientBucketMap = new ConcurrentHashMap<>(); private final ConcurrentHashMap adapterMetricsMap = new ConcurrentHashMap<>(); private final ConcurrentHashMap interfaceMetricsMap = new ConcurrentHashMap<>(); - private final ConcurrentHashMap clientMetricsMap = new ConcurrentHashMap<>(); private final ConcurrentHashMap 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 loadBucketMap(InflowType type) throws Exception { + protected Map loadBucketMap(InflowType type) throws Exception { Map 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 targetMap, InflowType type, String name) throws Exception { + protected void reloadBucketMap(Map targetMap, InflowType type, String name) throws Exception { Map 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 getAllAdapterMetrics() { return Collections.unmodifiableMap(adapterMetricsMap); } public Map getAllInterfaceMetrics() { return Collections.unmodifiableMap(interfaceMetricsMap); } - public Map 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 metricsMap, String key, String result) { + protected void recordMetrics(ConcurrentHashMap 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 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(); } diff --git a/src/main/java/com/eactive/eai/inbound/processor/DualRequestProcessor.java b/src/main/java/com/eactive/eai/inbound/processor/DualRequestProcessor.java deleted file mode 100644 index 318a326..0000000 --- a/src/main/java/com/eactive/eai/inbound/processor/DualRequestProcessor.java +++ /dev/null @@ -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 확장. - * - *

DualInflowControlManager와 함께 사용: - *

- * inflow.control.bucket.className=com.eactive.eai.common.inflow.dual.DualInflowControlManager
- * 
- * - *

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); - } - - /** - * 차단 원인에 따라 에러 메시지에 한도 정보를 포함. - *

    - *
  • PER_SECOND: "API 호출 한도 초과 [name: Nreq/sec]"
  • - *
  • THRESHOLD: "API 호출 한도 초과 [name: Nreq/UNIT]"
  • - *
  • 그 외: "API 호출 한도 초과 [name]" (기존 동작)
  • - *
- */ - @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); - } -} diff --git a/src/main/java/com/eactive/eai/outbound/HTTPProcess.java b/src/main/java/com/eactive/eai/outbound/HTTPProcess.java index da67208..b965eca 100644 --- a/src/main/java/com/eactive/eai/outbound/HTTPProcess.java +++ b/src/main/java/com/eactive/eai/outbound/HTTPProcess.java @@ -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(); diff --git a/src/test/java/com/eactive/eai/adapter/handler/TemplateAdapterErrorMsgHandlerTest.java b/src/test/java/com/eactive/eai/adapter/handler/TemplateAdapterErrorMsgHandlerTest.java new file mode 100644 index 0000000..b73811e --- /dev/null +++ b/src/test/java/com/eactive/eai/adapter/handler/TemplateAdapterErrorMsgHandlerTest.java @@ -0,0 +1,725 @@ +package com.eactive.eai.adapter.handler; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; + +import java.lang.reflect.Field; +import java.util.LinkedHashMap; +import java.util.Properties; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.context.ApplicationContext; + +import com.eactive.eai.common.property.PropManager; +import com.eactive.eai.common.util.ApplicationContextProvider; +import com.eactive.eai.message.StandardItem; +import com.eactive.eai.message.StandardMessage; +import com.eactive.eai.message.StandardType; + +/** + * TemplateAdapterErrorMsgHandler 단위테스트 + * + * - render() : package-private 이므로 같은 패키지에서 직접 호출 + * - generateNonStandardErrorResponseMessage() : ApplicationContextProvider 에 + * mock ApplicationContext 를 리플렉션으로 주입하여 PropManager 격리 + */ +class TemplateAdapterErrorMsgHandlerTest { + + private TemplateAdapterErrorMsgHandler handler; + + @BeforeEach + void setUp() { + handler = new TemplateAdapterErrorMsgHandler(); + } + + // ================================================================ + // StandardMessage 빌더 헬퍼 + // ================================================================ + + /** FIELD 아이템 (type=2) */ + private static StandardItem field(String name, String value) { + return new StandardItem(name, 3, StandardType.FIELD, 1, 0, 300, 1, "", "", value, name); + } + + /** GROUP 아이템 (type=3, size=1 → 활성) */ + private static StandardItem group(String name) { + return new StandardItem(name, 2, StandardType.GROUP, 1, 0, 0, 1, "", "", null, name); + } + + /** + * DJB 표준전문 MSG 영역을 모사한 StandardMessage 생성. + * + * 구조: + * MSG (GROUP) + * MAIN_MSG (GROUP) + * outp_msg_cd, outp_msg_ctnt, outp_msg_desc + * MSG_LIST (GRID) + * row[n]: outp_msg_cd, outp_msg_ctnt, outp_msg_desc, err_occu_item_nm + * + * @param listRows null 또는 빈 배열이면 MSG_LIST 행 없음 + */ + private StandardMessage buildMsg(String errCode, String errMsg, String errDesc, + String[][] listRows) { + StandardMessage msg = new StandardMessage(); + + StandardItem msgGroup = group("MSG"); + + StandardItem mainMsg = group("MAIN_MSG"); + mainMsg.addItem(field("outp_msg_cd", errCode)); + mainMsg.addItem(field("outp_msg_ctnt", errMsg)); + mainMsg.addItem(field("outp_msg_desc", errDesc)); + msgGroup.addItem(mainMsg); + + StandardItem msgList = new StandardItem( + "MSG_LIST", 2, StandardType.GRID, 0, 0, 0, 1, "", "", null, "MSG_LIST"); + msgList.addItem(field("outp_msg_cd", "")); + msgList.addItem(field("outp_msg_ctnt", "")); + msgList.addItem(field("outp_msg_desc", "")); + msgList.addItem(field("err_occu_item_nm", "")); + + if (listRows != null) { + for (String[] row : listRows) { + LinkedHashMap rowMap = new LinkedHashMap<>(); + rowMap.put("outp_msg_cd", field("outp_msg_cd", get(row, 0))); + rowMap.put("outp_msg_ctnt", field("outp_msg_ctnt", get(row, 1))); + rowMap.put("outp_msg_desc", field("outp_msg_desc", get(row, 2))); + rowMap.put("err_occu_item_nm", field("err_occu_item_nm", get(row, 3))); + msgList.addArray(rowMap); + } + } + msgGroup.addItem(msgList); + msg.addItem(msgGroup); + return msg; + } + + private static String get(String[] arr, int idx) { + return (arr != null && arr.length > idx) ? arr[idx] : ""; + } + + private static Properties props(String... keyValues) { + Properties p = new Properties(); + for (int i = 0; i < keyValues.length - 1; i += 2) { + p.setProperty(keyValues[i], keyValues[i + 1]); + } + return p; + } + + // ================================================================ + // 스칼라 변수 치환 ${path} + // ================================================================ + + @Nested + @DisplayName("StandardMessage 스칼라 변수 치환 ${path}") + class ScalarSubstitution { + + @Test + @DisplayName("단순 JSON 템플릿 치환") + void 단순_치환() { + StandardMessage msg = buildMsg("E001", "오류발생", "상세설명", null); + String template = "{\"code\":\"${MSG.MAIN_MSG.outp_msg_cd}\",\"msg\":\"${MSG.MAIN_MSG.outp_msg_ctnt}\"}"; + + String result = handler.render(template, msg, null); + + assertEquals("{\"code\":\"E001\",\"msg\":\"오류발생\"}", result); + } + + @Test + @DisplayName("동일 변수 중복 치환") + void 동일_변수_중복_치환() { + StandardMessage msg = buildMsg("E001", "오류", "", null); + String template = "${MSG.MAIN_MSG.outp_msg_cd}/${MSG.MAIN_MSG.outp_msg_cd}"; + + assertEquals("E001/E001", handler.render(template, msg, null)); + } + + @Test + @DisplayName("존재하지 않는 경로는 빈 문자열로 치환") + void 없는_경로_빈_문자열() { + StandardMessage msg = buildMsg("E001", "오류", "", null); + + assertEquals("", handler.render("${NONE.PATH}", msg, null)); + } + + @Test + @DisplayName("변수 없는 템플릿은 원본 그대로 반환") + void 변수_없는_템플릿_원본() { + StandardMessage msg = buildMsg("E001", "오류", "", null); + + assertEquals("{\"static\":\"value\"}", handler.render("{\"static\":\"value\"}", msg, null)); + } + + @Test + @DisplayName("outp_msg_desc 필드 치환") + void outp_msg_desc_치환() { + StandardMessage msg = buildMsg("E001", "오류", "상세설명입니다", null); + + assertEquals("상세설명입니다", handler.render("${MSG.MAIN_MSG.outp_msg_desc}", msg, null)); + } + + @Test + @DisplayName("경로:기본값 – 경로 존재 시 값 반환") + void 표준전문_기본값_경로존재() { + StandardMessage msg = buildMsg("E001", "오류", "", null); + + assertEquals("E001", handler.render("${MSG.MAIN_MSG.outp_msg_cd:DEFAULT}", msg, null)); + } + + @Test + @DisplayName("경로:기본값 – 경로 없으면 기본값 반환") + void 표준전문_기본값_경로없음() { + StandardMessage msg = buildMsg("E001", "오류", "", null); + + assertEquals("UNKNOWN", handler.render("${NONE.PATH:UNKNOWN}", msg, null)); + } + + @Test + @DisplayName("경로:기본값 – 기본값이 빈 문자열") + void 표준전문_기본값_빈문자열() { + StandardMessage msg = buildMsg("E001", "오류", "", null); + + assertEquals("", handler.render("${NONE.PATH:}", msg, null)); + } + } + + // ================================================================ + // callProp 변수 치환 ${callprop.키} + // ================================================================ + + @Nested + @DisplayName("callProp 변수 치환 ${callprop.키}") + class CallPropSubstitution { + + @Test + @DisplayName("callProp 값을 템플릿에서 참조") + void callProp_단순_참조() { + StandardMessage msg = buildMsg("E001", "오류", "", null); + Properties callProp = props("ADAPTER_NAME", "MY_ADAPTER", "IF_ID", "SVC001"); + String template = "{\"adapter\":\"${callprop.ADAPTER_NAME}\",\"ifId\":\"${callprop.IF_ID}\"}"; + + String result = handler.render(template, msg, callProp); + + assertEquals("{\"adapter\":\"MY_ADAPTER\",\"ifId\":\"SVC001\"}", result); + } + + @Test + @DisplayName("callProp 과 StandardMessage 혼합 참조") + void callProp_stdmsg_혼합() { + StandardMessage msg = buildMsg("E001", "오류발생", "", null); + Properties callProp = props("ADAPTER_NAME", "MY_ADAPTER"); + String template = "{\"adapter\":\"${callprop.ADAPTER_NAME}\",\"code\":\"${MSG.MAIN_MSG.outp_msg_cd}\"}"; + + String result = handler.render(template, msg, callProp); + + assertEquals("{\"adapter\":\"MY_ADAPTER\",\"code\":\"E001\"}", result); + } + + @Test + @DisplayName("callProp 에 없는 키는 빈 문자열") + void callProp_없는_키_빈문자열() { + StandardMessage msg = buildMsg("E001", "오류", "", null); + Properties callProp = new Properties(); + + assertEquals("", handler.render("${callprop.NONE_KEY}", msg, callProp)); + } + + @Test + @DisplayName("callProp 이 null 이면 빈 문자열") + void callProp_null_빈문자열() { + StandardMessage msg = buildMsg("E001", "오류", "", null); + + assertEquals("", handler.render("${callprop.ADAPTER_NAME}", msg, null)); + } + + @Test + @DisplayName("callProp 값이 Properties 인 경우 중첩 탐색") + void callProp_중첩_Properties() { + Properties sub = new Properties(); + sub.setProperty("IF_ID", "SVC001"); + sub.setProperty("SVC_NM", "조회서비스"); + + Properties callProp = new Properties(); + callProp.put("OUTBOUND", sub); + callProp.setProperty("ADAPTER_NAME", "MY_ADAPTER"); + + StandardMessage msg = buildMsg("E001", "오류", "", null); + String template = "{\"adapter\":\"${callprop.ADAPTER_NAME}\",\"ifId\":\"${callprop.OUTBOUND.IF_ID}\",\"svcNm\":\"${callprop.OUTBOUND.SVC_NM}\"}"; + + String result = handler.render(template, msg, callProp); + + assertEquals("{\"adapter\":\"MY_ADAPTER\",\"ifId\":\"SVC001\",\"svcNm\":\"조회서비스\"}", result); + } + + @Test + @DisplayName("중첩 Properties 3단계 탐색") + void callProp_3단계_중첩() { + Properties level2 = new Properties(); + level2.setProperty("CODE", "L2CODE"); + + Properties level1 = new Properties(); + level1.put("LEVEL2", level2); + + Properties callProp = new Properties(); + callProp.put("LEVEL1", level1); + + StandardMessage msg = buildMsg("E001", "오류", "", null); + + assertEquals("L2CODE", handler.render("${callprop.LEVEL1.LEVEL2.CODE}", msg, callProp)); + } + + @Test + @DisplayName("중간 값이 Properties 가 아니면 전체 keyPath 로 폴백 조회") + void callProp_중간값_비Properties_폴백() { + Properties callProp = new Properties(); + callProp.setProperty("A.B", "FALLBACK_VALUE"); // 점 포함 키 + callProp.setProperty("A", "NOT_PROPS"); // String 값 + + StandardMessage msg = buildMsg("E001", "오류", "", null); + + // A 는 String 이므로 Properties 가 아님 → "A.B" 전체 키로 폴백 + assertEquals("FALLBACK_VALUE", handler.render("${callprop.A.B}", msg, callProp)); + } + + @Test + @DisplayName("중첩 Properties 에서 없는 키는 빈 문자열") + void callProp_중첩_없는키() { + Properties sub = new Properties(); + sub.setProperty("EXIST", "VALUE"); + + Properties callProp = new Properties(); + callProp.put("SUB", sub); + + StandardMessage msg = buildMsg("E001", "오류", "", null); + + assertEquals("", handler.render("${callprop.SUB.NONE_KEY}", msg, callProp)); + } + + @Test + @DisplayName("callprop.키:기본값 – 키 존재 시 값 반환") + void callProp_직접_기본값_키존재() { + StandardMessage msg = buildMsg("E001", "오류", "", null); + Properties callProp = props("ADAPTER_NAME", "MY_ADAPTER"); + + assertEquals("MY_ADAPTER", handler.render("${callprop.ADAPTER_NAME:DEFAULT}", msg, callProp)); + } + + @Test + @DisplayName("callprop.키:기본값 – 키 없으면 기본값 반환") + void callProp_직접_기본값_키없음() { + StandardMessage msg = buildMsg("E001", "오류", "", null); + Properties callProp = new Properties(); + + assertEquals("DEFAULT_VAL", handler.render("${callprop.NONE_KEY:DEFAULT_VAL}", msg, callProp)); + } + + @Test + @DisplayName("callprop.키:기본값 – callProp null 이면 기본값 반환") + void callProp_직접_기본값_null() { + StandardMessage msg = buildMsg("E001", "오류", "", null); + + assertEquals("FALLBACK", handler.render("${callprop.ADAPTER_NAME:FALLBACK}", msg, null)); + } + } + + // ================================================================ + // callProp 간접 참조 ${callprop[표준전문경로]} / ${callprop[경로]:기본값} + // ================================================================ + + @Nested + @DisplayName("callProp 간접 참조 ${callprop[경로]}") + class IndirectCallPropSubstitution { + + /** 표준전문에 cp_key 필드가 있는 메시지 */ + private StandardMessage buildMsgWithKey(String cpKey, String errCode) { + StandardMessage msg = buildMsg(errCode, "오류", "", null); + // MSG 그룹 아래 cp_key 필드 추가 + StandardItem msgGroup = msg.findItem("MSG"); + if (msgGroup != null) { + msgGroup.addItem(field("cp_key", cpKey)); + } + return msg; + } + + @Test + @DisplayName("표준전문 경로 값으로 callProp 키를 결정해 값 조회") + void 간접_참조_정상() { + StandardMessage msg = buildMsgWithKey("ADAPTER_NAME", "E001"); + Properties callProp = props("ADAPTER_NAME", "MY_ADAPTER"); + String template = "${callprop[MSG.cp_key]}"; + + assertEquals("MY_ADAPTER", handler.render(template, msg, callProp)); + } + + @Test + @DisplayName("표준전문 경로 값이 없으면 기본값 반환") + void 표준전문_경로_없으면_기본값() { + StandardMessage msg = buildMsg("E001", "오류", "", null); + Properties callProp = props("ADAPTER_NAME", "MY_ADAPTER"); + String template = "${callprop[MSG.NONE_KEY]:DEFAULT_ADAPTER}"; + + assertEquals("DEFAULT_ADAPTER", handler.render(template, msg, callProp)); + } + + @Test + @DisplayName("callProp 에 해당 키 없으면 기본값 반환") + void callProp_키_없으면_기본값() { + StandardMessage msg = buildMsgWithKey("MISSING_KEY", "E001"); + Properties callProp = props("OTHER_KEY", "OTHER_VALUE"); + String template = "${callprop[MSG.cp_key]:FALLBACK}"; + + assertEquals("FALLBACK", handler.render(template, msg, callProp)); + } + + @Test + @DisplayName("기본값 미지정 시 빈 문자열") + void 기본값_없으면_빈문자열() { + StandardMessage msg = buildMsg("E001", "오류", "", null); + Properties callProp = props("ADAPTER_NAME", "MY_ADAPTER"); + String template = "${callprop[MSG.NONE_KEY]}"; + + assertEquals("", handler.render(template, msg, callProp)); + } + + @Test + @DisplayName("callProp 이 null 이면 기본값 반환") + void callProp_null_기본값() { + StandardMessage msg = buildMsgWithKey("ADAPTER_NAME", "E001"); + String template = "${callprop[MSG.cp_key]:NO_PROP}"; + + assertEquals("NO_PROP", handler.render(template, msg, null)); + } + + @Test + @DisplayName("기본값이 빈 문자열인 경우") + void 기본값이_빈문자열() { + StandardMessage msg = buildMsg("E001", "오류", "", null); + Properties callProp = new Properties(); + String template = "${callprop[MSG.NONE_KEY]:}"; + + assertEquals("", handler.render(template, msg, callProp)); + } + + @Test + @DisplayName("간접 참조와 직접 참조 혼합") + void 간접_직접_혼합() { + StandardMessage msg = buildMsgWithKey("IF_ID_KEY", "E001"); + Properties callProp = props("IF_ID_KEY", "SVC_001", "ADAPTER_NAME", "MY_ADAPTER"); + String template = "{\"adapter\":\"${callprop.ADAPTER_NAME}\",\"ifId\":\"${callprop[MSG.cp_key]}\"}"; + + assertEquals("{\"adapter\":\"MY_ADAPTER\",\"ifId\":\"SVC_001\"}", handler.render(template, msg, callProp)); + } + } + + // ================================================================ + // 배열 반복 {{#foreach path}}...{{/foreach}} + // ================================================================ + + // ================================================================ + // DATA 영역 변수 치환 ${DATA.xxx} + // ================================================================ + + @Nested + @DisplayName("DATA 영역 변수 치환 ${DATA.xxx}") + class DataSectionSubstitution { + + /** + * DATA > BIZDATA(FIELD, JSON String value) 구조의 StandardMessage 생성. + * BIZDATA 는 파싱 없이 JSON 문자열 그대로 세팅되는 형태. + */ + private StandardMessage buildMsgWithBizData(String bizDataJson) { + StandardMessage msg = buildMsg("E001", "오류", "", null); + + StandardItem dataGroup = group("DATA"); + dataGroup.addItem(field("BIZDATA", bizDataJson)); + msg.addItem(dataGroup); + + return msg; + } + + @Test + @DisplayName("DATA.BIZDATA JSON 필드 치환 – 단일 depth") + void DATA_BIZDATA_JSON_필드_치환() { + StandardMessage msg = buildMsgWithBizData("{\"acctNo\":\"1234567890\",\"custNm\":\"홍길동\"}"); + String template = "{\"acctNo\":\"${DATA.BIZDATA.acctNo}\",\"custNm\":\"${DATA.BIZDATA.custNm}\"}"; + + assertEquals("{\"acctNo\":\"1234567890\",\"custNm\":\"홍길동\"}", + handler.render(template, msg, null)); + } + + @Test + @DisplayName("DATA.BIZDATA JSON 필드 치환 – 중첩 depth") + void DATA_BIZDATA_JSON_중첩_필드_치환() { + StandardMessage msg = buildMsgWithBizData("{\"addr\":{\"city\":\"서울\",\"zip\":\"12345\"}}"); + + assertEquals("서울", handler.render("${DATA.BIZDATA.addr.city}", msg, null)); + } + + @Test + @DisplayName("DATA.BIZDATA 원본 JSON 문자열 치환") + void DATA_BIZDATA_원본_치환() { + String rawJson = "{\"acctNo\":\"1234567890\"}"; + StandardMessage msg = buildMsgWithBizData(rawJson); + + assertEquals(rawJson, handler.render("${DATA.BIZDATA}", msg, null)); + } + + @Test + @DisplayName("DATA.BIZDATA 와 MSG 영역 혼합 치환") + void DATA_MSG_혼합_치환() { + StandardMessage msg = buildMsgWithBizData("{\"acctNo\":\"9876543210\"}"); + String template = "{\"code\":\"${MSG.MAIN_MSG.outp_msg_cd}\",\"acctNo\":\"${DATA.BIZDATA.acctNo}\"}"; + + assertEquals("{\"code\":\"E001\",\"acctNo\":\"9876543210\"}", + handler.render(template, msg, null)); + } + + @Test + @DisplayName("DATA.BIZDATA JSON 에 없는 필드는 빈 문자열") + void DATA_BIZDATA_없는_JSON_필드_빈문자열() { + StandardMessage msg = buildMsgWithBizData("{\"acctNo\":\"1234567890\"}"); + + assertEquals("", handler.render("${DATA.BIZDATA.noneField}", msg, null)); + } + + @Test + @DisplayName("DATA.BIZDATA JSON 에 없는 필드에 기본값 지정") + void DATA_BIZDATA_없는_JSON_필드_기본값() { + StandardMessage msg = buildMsgWithBizData("{\"acctNo\":\"1234567890\"}"); + + assertEquals("UNKNOWN", handler.render("${DATA.BIZDATA.noneField:UNKNOWN}", msg, null)); + } + + @Test + @DisplayName("DATA 영역 미설정 시 기본값 반환") + void DATA_없으면_기본값() { + StandardMessage msg = buildMsg("E001", "오류", "", null); + + assertEquals("{}", handler.render("${DATA.BIZDATA:{}}", msg, null)); + } + } + + @Nested + @DisplayName("배열 반복 블록 {{#foreach}}") + class ForeachBlock { + + @Test + @DisplayName("MSG_LIST 1건 렌더링") + void 단건_렌더링() { + StandardMessage msg = buildMsg("E001", "대표오류", "", + new String[][]{{"E001", "메시지A", "설명A", "fieldA"}}); + String template = "[{{#foreach MSG.MSG_LIST}}{\"c\":\"${outp_msg_cd}\",\"m\":\"${outp_msg_ctnt}\"}{{/foreach}}]"; + + assertEquals("[{\"c\":\"E001\",\"m\":\"메시지A\"}]", handler.render(template, msg, null)); + } + + @Test + @DisplayName("MSG_LIST 2건 → 쉼표로 연결") + void 다건_쉼표_연결() { + StandardMessage msg = buildMsg("E001", "대표오류", "", + new String[][]{ + {"E001", "오류A", "설명A", "fieldA"}, + {"E002", "오류B", "설명B", "fieldB"} + }); + String template = "[{{#foreach MSG.MSG_LIST}}{\"c\":\"${outp_msg_cd}\"}{{/foreach}}]"; + + assertEquals("[{\"c\":\"E001\"},{\"c\":\"E002\"}]", handler.render(template, msg, null)); + } + + @Test + @DisplayName("MSG_LIST 가 비어있으면 foreach 결과는 빈 문자열") + void 빈_배열() { + StandardMessage msg = buildMsg("E001", "오류", "", new String[][]{}); + String template = "[{{#foreach MSG.MSG_LIST}}{\"c\":\"${outp_msg_cd}\"}{{/foreach}}]"; + + assertEquals("[]", handler.render(template, msg, null)); + } + + @Test + @DisplayName("foreach 경로가 없으면 빈 문자열") + void 없는_경로() { + StandardMessage msg = buildMsg("E001", "오류", "", null); + String template = "[{{#foreach NONE.LIST}}{\"c\":\"${outp_msg_cd}\"}{{/foreach}}]"; + + assertEquals("[]", handler.render(template, msg, null)); + } + + @Test + @DisplayName("err_occu_item_nm 필드 포함 렌더링") + void err_occu_item_nm_포함() { + StandardMessage msg = buildMsg("E001", "오류", "", + new String[][]{{"E001", "오류A", "설명A", "accountNo"}}); + String template = "{{#foreach MSG.MSG_LIST}}{\"field\":\"${err_occu_item_nm}\"}{{/foreach}}"; + + assertEquals("{\"field\":\"accountNo\"}", handler.render(template, msg, null)); + } + } + + // ================================================================ + // 혼합 템플릿 (스칼라 + callProp + 배열) + // ================================================================ + + @Nested + @DisplayName("혼합 템플릿") + class MixedTemplate { + + @Test + @DisplayName("JSON – MAIN_MSG + callProp + MSG_LIST 2건") + void JSON_전체_혼합() { + StandardMessage msg = buildMsg("E001", "대표오류", "대표설명", + new String[][]{ + {"E001", "오류A", "설명A", "fieldA"}, + {"E002", "오류B", "설명B", "fieldB"} + }); + Properties callProp = props("ADAPTER_NAME", "TEST_ADAPTER"); + + String template = + "{" + + "\"adapter\":\"${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}\",\"field\":\"${err_occu_item_nm}\"}" + + "{{/foreach}}]" + + "}"; + + String result = handler.render(template, msg, callProp); + + assertEquals( + "{\"adapter\":\"TEST_ADAPTER\"," + + "\"resultCode\":\"E001\",\"resultMsg\":\"대표오류\"," + + "\"errors\":[{\"code\":\"E001\",\"msg\":\"오류A\",\"field\":\"fieldA\"}," + + "{\"code\":\"E002\",\"msg\":\"오류B\",\"field\":\"fieldB\"}]}", + result); + } + + @Test + @DisplayName("XML – 스칼라 + callProp 치환") + void XML_스칼라_callProp() { + StandardMessage msg = buildMsg("E001", "오류발생", "", null); + Properties callProp = props("IF_ID", "IF_001"); + String template = "${callprop.IF_ID}${MSG.MAIN_MSG.outp_msg_cd}"; + + assertEquals("IF_001E001", + handler.render(template, msg, callProp)); + } + + @Test + @DisplayName("XML – foreach 배열") + void XML_foreach_배열() { + StandardMessage msg = buildMsg("E001", "오류", "", + new String[][]{{"E001", "오류A", "", ""}, {"E002", "오류B", "", ""}}); + String template = + "{{#foreach MSG.MSG_LIST}}" + + "${outp_msg_cd}${outp_msg_ctnt}" + + "{{/foreach}}"; + + assertEquals( + "" + + "E001오류A," + + "E002오류B" + + "", + handler.render(template, msg, null)); + } + } + + // ================================================================ + // generateNonStandardErrorResponseMessage – PropManager mock + // ================================================================ + + @Nested + @DisplayName("generateNonStandardErrorResponseMessage") + class GenerateMethod { + + private void injectMockPropManager(PropManager mockPropManager) throws Exception { + ApplicationContext mockCtx = Mockito.mock(ApplicationContext.class); + Mockito.when(mockCtx.getBean(PropManager.class)).thenReturn(mockPropManager); + Field ctxField = ApplicationContextProvider.class.getDeclaredField("context"); + ctxField.setAccessible(true); + ctxField.set(null, mockCtx); + } + + @Test + @DisplayName("템플릿 미설정 시 null 반환") + void 템플릿_없으면_null() throws Exception { + PropManager mockProp = Mockito.mock(PropManager.class); + Mockito.when(mockProp.getProperty(anyString(), anyString())).thenReturn(null); + injectMockPropManager(mockProp); + + StandardMessage msg = buildMsg("E001", "오류", "", null); + Object result = handler.generateNonStandardErrorResponseMessage( + "TEST_GROUP", "TEST_ADAPTER", new Properties(), null, msg); + + assertNull(result); + } + + @Test + @DisplayName("빈 템플릿 시 null 반환") + void 빈_템플릿_null() throws Exception { + PropManager mockProp = Mockito.mock(PropManager.class); + Mockito.when(mockProp.getProperty(anyString(), anyString())).thenReturn(" "); + injectMockPropManager(mockProp); + + StandardMessage msg = buildMsg("E001", "오류", "", null); + Object result = handler.generateNonStandardErrorResponseMessage( + "TEST_GROUP", "TEST_ADAPTER", new Properties(), null, msg); + + assertNull(result); + } + + @Test + @DisplayName("템플릿 설정 시 StandardMessage 치환 결과 반환") + void 템플릿_stdmsg_치환() throws Exception { + String template = "{\"code\":\"${MSG.MAIN_MSG.outp_msg_cd}\"}"; + PropManager mockProp = Mockito.mock(PropManager.class); + Mockito.when(mockProp.getProperty( + eq(TemplateAdapterErrorMsgHandler.PROP_GROUP), + eq("MY_GROUP.template"))).thenReturn(template); + injectMockPropManager(mockProp); + + StandardMessage msg = buildMsg("E999", "테스트오류", "", null); + Object result = handler.generateNonStandardErrorResponseMessage( + "MY_GROUP", "MY_ADAPTER", new Properties(), null, msg); + + assertEquals("{\"code\":\"E999\"}", result); + } + + @Test + @DisplayName("callProp 값이 템플릿에 치환되어 반환") + void 템플릿_callProp_치환() throws Exception { + String template = "{\"adapter\":\"${callprop.ADAPTER_NAME}\",\"code\":\"${MSG.MAIN_MSG.outp_msg_cd}\"}"; + PropManager mockProp = Mockito.mock(PropManager.class); + Mockito.when(mockProp.getProperty( + eq(TemplateAdapterErrorMsgHandler.PROP_GROUP), + eq("MY_GROUP.template"))).thenReturn(template); + injectMockPropManager(mockProp); + + StandardMessage msg = buildMsg("E999", "테스트오류", "", null); + Properties callProp = props("ADAPTER_NAME", "REAL_ADAPTER"); + + Object result = handler.generateNonStandardErrorResponseMessage( + "MY_GROUP", "MY_ADAPTER", callProp, null, msg); + + assertEquals("{\"adapter\":\"REAL_ADAPTER\",\"code\":\"E999\"}", result); + } + + @Test + @DisplayName("템플릿 키는 {adapterGroupId}.template 형식으로 조회") + void 템플릿_키_형식_검증() throws Exception { + PropManager mockProp = Mockito.mock(PropManager.class); + Mockito.when(mockProp.getProperty(anyString(), anyString())).thenReturn(null); + injectMockPropManager(mockProp); + + StandardMessage msg = buildMsg("E001", "오류", "", null); + handler.generateNonStandardErrorResponseMessage( + "SAMPLE_GROUP", "ANY_ADAPTER", new Properties(), null, msg); + + Mockito.verify(mockProp).getProperty( + TemplateAdapterErrorMsgHandler.PROP_GROUP, + "SAMPLE_GROUP.template"); + } + } +} diff --git a/src/test/java/com/eactive/eai/agent/inflow/ReloadInflowClientControlCommandTest.java b/src/test/java/com/eactive/eai/agent/inflow/ReloadInflowClientControlCommandTest.java index d0d4985..31ece45 100644 --- a/src/test/java/com/eactive/eai/agent/inflow/ReloadInflowClientControlCommandTest.java +++ b/src/test/java/com/eactive/eai/agent/inflow/ReloadInflowClientControlCommandTest.java @@ -11,7 +11,7 @@ import org.springframework.test.util.ReflectionTestUtils; import com.eactive.eai.agent.command.CommandException; import com.eactive.eai.common.inflow.InflowControlManager; import com.eactive.eai.common.inflow.InflowControlUtil; -import com.eactive.eai.common.inflow.dual.DualInflowControlManager; +import com.eactive.eai.common.inflow.dual.ClientDualInflowControlManager; /** * ReloadInflowClientControlCommand 단위 테스트. @@ -21,11 +21,11 @@ import com.eactive.eai.common.inflow.dual.DualInflowControlManager; */ class ReloadInflowClientControlCommandTest { - private DualInflowControlManager mockDualManager; + private ClientDualInflowControlManager mockDualManager; @BeforeEach void setUp() { - mockDualManager = mock(DualInflowControlManager.class); + mockDualManager = mock(ClientDualInflowControlManager.class); ReflectionTestUtils.setField(InflowControlUtil.class, "cachedInflowControlManager", null); } @@ -51,11 +51,11 @@ class ReloadInflowClientControlCommandTest { } // ========================================================================= - // DualInflowControlManager 비활성 + // ClientDualInflowControlManager 비활성 // ========================================================================= @Test - void execute_notDualManager_throwsCommandException() { + void execute_notClientDualManager_throwsCommandException() { InflowControlManager baseMgr = mock(InflowControlManager.class); ReflectionTestUtils.setField(InflowControlUtil.class, "cachedInflowControlManager", baseMgr); diff --git a/src/test/java/com/eactive/eai/agent/inflow/RemoveInflowClientControlCommandTest.java b/src/test/java/com/eactive/eai/agent/inflow/RemoveInflowClientControlCommandTest.java index 10cff5b..5dfb8d4 100644 --- a/src/test/java/com/eactive/eai/agent/inflow/RemoveInflowClientControlCommandTest.java +++ b/src/test/java/com/eactive/eai/agent/inflow/RemoveInflowClientControlCommandTest.java @@ -11,7 +11,7 @@ import org.springframework.test.util.ReflectionTestUtils; import com.eactive.eai.agent.command.CommandException; import com.eactive.eai.common.inflow.InflowControlManager; import com.eactive.eai.common.inflow.InflowControlUtil; -import com.eactive.eai.common.inflow.dual.DualInflowControlManager; +import com.eactive.eai.common.inflow.dual.ClientDualInflowControlManager; /** * RemoveInflowClientControlCommand 단위 테스트. @@ -21,11 +21,11 @@ import com.eactive.eai.common.inflow.dual.DualInflowControlManager; */ class RemoveInflowClientControlCommandTest { - private DualInflowControlManager mockDualManager; + private ClientDualInflowControlManager mockDualManager; @BeforeEach void setUp() { - mockDualManager = mock(DualInflowControlManager.class); + mockDualManager = mock(ClientDualInflowControlManager.class); ReflectionTestUtils.setField(InflowControlUtil.class, "cachedInflowControlManager", null); } @@ -50,11 +50,11 @@ class RemoveInflowClientControlCommandTest { } // ========================================================================= - // DualInflowControlManager 비활성 + // ClientDualInflowControlManager 비활성 // ========================================================================= @Test - void execute_notDualManager_throwsCommandException() { + void execute_notClientDualManager_throwsCommandException() { InflowControlManager baseMgr = mock(InflowControlManager.class); ReflectionTestUtils.setField(InflowControlUtil.class, "cachedInflowControlManager", baseMgr); diff --git a/src/test/java/com/eactive/eai/common/inflow/InflowControlManagerTest.java b/src/test/java/com/eactive/eai/common/inflow/InflowControlManagerTest.java index 01e1a79..8b59d40 100644 --- a/src/test/java/com/eactive/eai/common/inflow/InflowControlManagerTest.java +++ b/src/test/java/com/eactive/eai/common/inflow/InflowControlManagerTest.java @@ -1,5 +1,7 @@ package com.eactive.eai.common.inflow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -36,9 +38,13 @@ class InflowControlManagerTest { InflowControlManager inflowControlManager; @BeforeEach - void setUp() throws LifecycleException { + void setUp() throws Exception { if (!inflowControlManager.isStarted()) { inflowControlManager.start(); + } else { + inflowControlManager.reloadAdapter(); + inflowControlManager.reloadInterface(); + inflowControlManager.reloadGroup(); } } @@ -57,4 +63,22 @@ class InflowControlManagerTest { assertNull(inflowControlManager.getGroupBucket("non-existent-group")); } + @Test + void adapter_spikePasses_thenBurstBlocked() { + for (int i = 0; i < 5; i++) assertTrue(inflowControlManager.isAdapterPass("TEST_ADAPTER")); + assertFalse(inflowControlManager.isAdapterPass("TEST_ADAPTER")); + } + + @Test + void interface_spikePasses_thenBurstBlocked() { + for (int i = 0; i < 5; i++) assertTrue(inflowControlManager.isInterfacePass("TEST_INTERFACE")); + assertFalse(inflowControlManager.isInterfacePass("TEST_INTERFACE")); + } + + @Test + void group_spikePasses_thenBurstBlocked() { + for (int i = 0; i < 5; i++) assertNull(inflowControlManager.isGroupPass("test-group-quota")); + assertEquals(CustomGroupBucket.RESULT_BLOCKED_THRESHOLD, inflowControlManager.isGroupPass("test-group-quota")); + } + } diff --git a/src/test/java/com/eactive/eai/common/inflow/dual/ClientDualInflowControlManagerMetricsTest.java b/src/test/java/com/eactive/eai/common/inflow/dual/ClientDualInflowControlManagerMetricsTest.java new file mode 100644 index 0000000..cb991d2 --- /dev/null +++ b/src/test/java/com/eactive/eai/common/inflow/dual/ClientDualInflowControlManagerMetricsTest.java @@ -0,0 +1,190 @@ +package com.eactive.eai.common.inflow.dual; + +import static org.junit.jupiter.api.Assertions.*; + +import java.time.Duration; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import com.eactive.eai.common.inflow.InflowTargetVO; + +import io.github.bucket4j.Bandwidth; +import io.github.bucket4j.Bucket4j; +import io.github.bucket4j.local.LocalBucket; + +/** + * ClientDualInflowControlManager 클라이언트 메트릭 단위 테스트. + */ +class ClientDualInflowControlManagerMetricsTest { + + private ClientDualInflowControlManager manager; + + @BeforeEach + void setUp() { + manager = new ClientDualInflowControlManager(); + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private InflowTargetVO makeTargetVO(String name) { + InflowTargetVO vo = new InflowTargetVO(); + vo.setName(name); + vo.setThresholdPerSecond(100); + vo.setThreshold(10000); + vo.setThresholdTimeUnit("DAY"); + vo.setActivate(true); + return vo; + } + + private LocalBucket stableBucket(long capacity) { + return Bucket4j.builder() + .addLimit(Bandwidth.simple(capacity, Duration.ofHours(1))) + .build(); + } + + private LocalBucket exhaustedBucket(long capacity) { + LocalBucket b = stableBucket(capacity); + b.tryConsume(capacity); + return b; + } + + private DualCustomBucket passDualBucket(String name) { + return new DualCustomBucket(stableBucket(100), null, makeTargetVO(name)); + } + + private DualCustomBucket blockedPerSecondDualBucket(String name) { + return new DualCustomBucket(exhaustedBucket(1), null, makeTargetVO(name)); + } + + private DualCustomBucket blockedThresholdDualBucket(String name) { + return new DualCustomBucket(stableBucket(100), exhaustedBucket(1), makeTargetVO(name)); + } + + private void injectClientMap(Map map) { + ReflectionTestUtils.setField(manager, "clientBucketMap", map); + } + + // ========================================================================= + // isClientPassDetail() — 카운터 증가 + // ========================================================================= + + @Test + void isClientPassDetail_bucketNotRegistered_noMetricsCreated() { + manager.isClientPassDetail("UNKNOWN"); + + assertNull(manager.getClientMetrics("UNKNOWN")); + } + + @Test + void isClientPassDetail_pass_incrementsAllowed() { + Map map = new ConcurrentHashMap<>(); + map.put("C1", passDualBucket("C1")); + injectClientMap(map); + + manager.isClientPassDetail("C1"); + + assertEquals(1, manager.getClientMetrics("C1").allowed.get()); + } + + @Test + void isClientPassDetail_blockedThreshold_incrementsRejectedThreshold() { + Map map = new ConcurrentHashMap<>(); + map.put("C1", blockedThresholdDualBucket("C1")); + injectClientMap(map); + + manager.isClientPassDetail("C1"); + + assertEquals(1, manager.getClientMetrics("C1").rejectedThreshold.get()); + } + + @Test + void isClientPassDetail_blockedPerSecond_incrementsRejectedPerSecond() { + Map map = new ConcurrentHashMap<>(); + map.put("C1", blockedPerSecondDualBucket("C1")); + injectClientMap(map); + + manager.isClientPassDetail("C1"); + + assertEquals(1, manager.getClientMetrics("C1").rejectedPerSecond.get()); + } + + // ========================================================================= + // getAllClientMetrics — 조회 및 불변성 + // ========================================================================= + + @Test + void getAllClientMetrics_empty_returnsEmptyMap() { + assertTrue(manager.getAllClientMetrics().isEmpty()); + } + + @Test + void getAllClientMetrics_afterCalls_containsEntries() { + Map map = new ConcurrentHashMap<>(); + map.put("C1", passDualBucket("C1")); + map.put("C2", passDualBucket("C2")); + injectClientMap(map); + + manager.isClientPassDetail("C1"); + manager.isClientPassDetail("C2"); + + Map all = manager.getAllClientMetrics(); + assertEquals(2, all.size()); + assertTrue(all.containsKey("C1")); + assertTrue(all.containsKey("C2")); + } + + // ========================================================================= + // resetClientMetrics / resetAllClientMetrics + // ========================================================================= + + @Test + void resetClientMetrics_resetsTargetClient() { + Map map = new ConcurrentHashMap<>(); + map.put("C1", passDualBucket("C1")); + injectClientMap(map); + + manager.isClientPassDetail("C1"); + manager.resetClientMetrics("C1"); + + assertEquals(0, manager.getClientMetrics("C1").allowed.get()); + } + + @Test + void resetAllClientMetrics_resetsAll() { + Map map = new ConcurrentHashMap<>(); + map.put("C1", passDualBucket("C1")); + map.put("C2", passDualBucket("C2")); + injectClientMap(map); + + manager.isClientPassDetail("C1"); + manager.isClientPassDetail("C2"); + manager.resetAllClientMetrics(); + + assertEquals(0, manager.getClientMetrics("C1").allowed.get()); + assertEquals(0, manager.getClientMetrics("C2").allowed.get()); + } + + // ========================================================================= + // removeClient — 메트릭 자동 삭제 + // ========================================================================= + + @Test + void removeClient_cleansUpMetrics() { + Map map = new ConcurrentHashMap<>(); + map.put("C1", passDualBucket("C1")); + injectClientMap(map); + + manager.isClientPassDetail("C1"); + assertNotNull(manager.getClientMetrics("C1")); + + manager.removeClient("C1"); + + assertNull(manager.getClientMetrics("C1")); + } +} diff --git a/src/test/java/com/eactive/eai/common/inflow/dual/DualInflowControlManagerMakeBucketTest.java b/src/test/java/com/eactive/eai/common/inflow/dual/DualInflowControlManagerMakeBucketTest.java new file mode 100644 index 0000000..c1a77c3 --- /dev/null +++ b/src/test/java/com/eactive/eai/common/inflow/dual/DualInflowControlManagerMakeBucketTest.java @@ -0,0 +1,92 @@ +package com.eactive.eai.common.inflow.dual; + +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.Test; + +import com.eactive.eai.common.inflow.InflowTargetVO; + +/** + * DualInflowControlManager.makeBucket() 버킷 생성 동작 검증. + * + *

검증 포인트: + * - threshold/perSecond 버킷이 실제로 생성되는지 + * - 할당량 이내 spike는 통과하고 초과 burst는 차단되는지 + * - perSecond/threshold 중 어느 쪽이 binding 제약인지 구분되는지 + */ +class DualInflowControlManagerMakeBucketTest { + + private final DualInflowControlManager manager = new DualInflowControlManager(); + + private InflowTargetVO vo(long perSecond, long threshold, String timeUnit) { + InflowTargetVO vo = new InflowTargetVO(); + vo.setName("TEST"); + vo.setThresholdPerSecond(perSecond); + vo.setThreshold(threshold); + vo.setThresholdTimeUnit(timeUnit); + vo.setActivate(true); + return vo; + } + + // ========================================================================= + // threshold 단독 (5건/분) + // ========================================================================= + + @Test + void thresholdOnly_spikePasses_thenBurstBlocked() { + DualCustomBucket bucket = manager.makeBucket(vo(0, 5, "MIN")); + + for (int i = 0; i < 5; i++) assertNull(bucket.tryConsume()); + assertEquals(DualCustomBucket.RESULT_BLOCKED_THRESHOLD, bucket.tryConsume()); + } + + // ========================================================================= + // perSecond 단독 + // ========================================================================= + + @Test + void perSecondOnly_spikePasses_thenBurstBlocked() { + DualCustomBucket bucket = manager.makeBucket(vo(5, 0, null)); + + for (int i = 0; i < 5; i++) assertNull(bucket.tryConsume()); + assertEquals(DualCustomBucket.RESULT_BLOCKED_PER_SECOND, bucket.tryConsume()); + } + + // ========================================================================= + // perSecond + threshold 동시 설정 — binding 제약 구분 + // ========================================================================= + + @Test + void both_perSecondIsBinding_blockedByPerSecond() { + // perSecond=3 이 binding: threshold=100/MIN은 여유 있음 + DualCustomBucket bucket = manager.makeBucket(vo(3, 100, "MIN")); + + for (int i = 0; i < 3; i++) assertNull(bucket.tryConsume()); + assertEquals(DualCustomBucket.RESULT_BLOCKED_PER_SECOND, bucket.tryConsume()); + } + + @Test + void both_thresholdIsBinding_blockedByThreshold() { + // threshold=3/MIN 이 binding: perSecond=100은 여유 있음 + DualCustomBucket bucket = manager.makeBucket(vo(100, 3, "MIN")); + + for (int i = 0; i < 3; i++) assertNull(bucket.tryConsume()); + assertEquals(DualCustomBucket.RESULT_BLOCKED_THRESHOLD, bucket.tryConsume()); + } + + // ========================================================================= + // 비활성 / 조건 미충족 → null 반환 + // ========================================================================= + + @Test + void inactiveVO_returnsNull() { + InflowTargetVO vo = vo(5, 0, null); + vo.setActivate(false); + assertNull(manager.makeBucket(vo)); + } + + @Test + void noLimitsSet_returnsNull() { + assertNull(manager.makeBucket(vo(0, 0, null))); + } +} diff --git a/src/test/java/com/eactive/eai/common/inflow/dual/DualInflowControlManagerMetricsTest.java b/src/test/java/com/eactive/eai/common/inflow/dual/DualInflowControlManagerMetricsTest.java index c52f309..be4280c 100644 --- a/src/test/java/com/eactive/eai/common/inflow/dual/DualInflowControlManagerMetricsTest.java +++ b/src/test/java/com/eactive/eai/common/inflow/dual/DualInflowControlManagerMetricsTest.java @@ -21,9 +21,8 @@ import io.github.bucket4j.local.LocalBucket; /** * DualInflowControlManager 메트릭 단위 테스트. * - *

start() 없이 버킷 맵을 ReflectionTestUtils로 직접 주입하여 - * isAdapterPassDetail / isInterfacePassDetail / isClientPassDetail / isGroupPass() 의 - * 카운팅 동작과 getXxxMetrics / resetXxxMetrics 메서드를 검증한다. + *

어댑터/인터페이스/그룹 메트릭을 검증한다. + * 클라이언트 메트릭은 ClientDualInflowControlManagerMetricsTest 에서 검증한다. */ class DualInflowControlManagerMetricsTest { @@ -100,10 +99,6 @@ class DualInflowControlManagerMetricsTest { ReflectionTestUtils.setField(manager, "interfaceBucketMap", map); } - private void injectClientMap(Map map) { - ReflectionTestUtils.setField(manager, "clientBucketMap", map); - } - private void injectGroupBucketList(Map map) { ReflectionTestUtils.setField(manager, "groupBucketList", map); } @@ -358,7 +353,6 @@ class DualInflowControlManagerMetricsTest { @Test void isAdapterPassDetail_bucketNotRegistered_noMetricsCreated() { - // 버킷 없는 어댑터는 메트릭 맵에 항목을 생성하지 않음 manager.isAdapterPassDetail("UNKNOWN"); assertNull(manager.getAdapterMetrics("UNKNOWN")); @@ -473,39 +467,6 @@ class DualInflowControlManagerMetricsTest { assertEquals(1, manager.getInterfaceMetrics("IF2").rejectedPerSecond.get()); } - // ========================================================================= - // isClientPassDetail() — 카운터 증가 - // ========================================================================= - - @Test - void isClientPassDetail_bucketNotRegistered_noMetricsCreated() { - manager.isClientPassDetail("UNKNOWN"); - - assertNull(manager.getClientMetrics("UNKNOWN")); - } - - @Test - void isClientPassDetail_pass_incrementsAllowed() { - Map map = new ConcurrentHashMap<>(); - map.put("C1", passDualBucket("C1")); - injectClientMap(map); - - manager.isClientPassDetail("C1"); - - assertEquals(1, manager.getClientMetrics("C1").allowed.get()); - } - - @Test - void isClientPassDetail_blockedThreshold_incrementsRejectedThreshold() { - Map map = new ConcurrentHashMap<>(); - map.put("C1", blockedThresholdDualBucket("C1")); - injectClientMap(map); - - manager.isClientPassDetail("C1"); - - assertEquals(1, manager.getClientMetrics("C1").rejectedThreshold.get()); - } - // ========================================================================= // getAllXxxMetrics — 조회 및 불변성 // ========================================================================= @@ -537,11 +498,6 @@ class DualInflowControlManagerMetricsTest { assertTrue(manager.getAllInterfaceMetrics().isEmpty()); } - @Test - void getAllClientMetrics_empty_returnsEmptyMap() { - assertTrue(manager.getAllClientMetrics().isEmpty()); - } - // ========================================================================= // resetXxxMetrics / resetAllXxxMetrics // ========================================================================= @@ -560,7 +516,7 @@ class DualInflowControlManagerMetricsTest { manager.resetAdapterMetrics("A1"); assertEquals(0, manager.getAdapterMetrics("A1").allowed.get()); - assertEquals(1, manager.getAdapterMetrics("A2").allowed.get()); // 영향 없음 + assertEquals(1, manager.getAdapterMetrics("A2").allowed.get()); } @Test @@ -601,33 +557,6 @@ class DualInflowControlManagerMetricsTest { assertDoesNotThrow(() -> manager.resetAllInterfaceMetrics()); } - @Test - void resetClientMetrics_resetsTargetClient() { - Map map = new ConcurrentHashMap<>(); - map.put("C1", passDualBucket("C1")); - injectClientMap(map); - - manager.isClientPassDetail("C1"); - manager.resetClientMetrics("C1"); - - assertEquals(0, manager.getClientMetrics("C1").allowed.get()); - } - - @Test - void resetAllClientMetrics_resetsAll() { - Map map = new ConcurrentHashMap<>(); - map.put("C1", passDualBucket("C1")); - map.put("C2", passDualBucket("C2")); - injectClientMap(map); - - manager.isClientPassDetail("C1"); - manager.isClientPassDetail("C2"); - manager.resetAllClientMetrics(); - - assertEquals(0, manager.getClientMetrics("C1").allowed.get()); - assertEquals(0, manager.getClientMetrics("C2").allowed.get()); - } - // ========================================================================= // remove 시 메트릭 자동 삭제 // ========================================================================= @@ -659,18 +588,4 @@ class DualInflowControlManagerMetricsTest { assertNull(manager.getInterfaceMetrics("IF1")); } - - @Test - void removeClient_cleansUpMetrics() { - Map map = new ConcurrentHashMap<>(); - map.put("C1", passDualBucket("C1")); - ReflectionTestUtils.setField(manager, "clientBucketMap", map); - - manager.isClientPassDetail("C1"); - assertNotNull(manager.getClientMetrics("C1")); - - manager.removeClient("C1"); - - assertNull(manager.getClientMetrics("C1")); - } } diff --git a/src/test/java/com/eactive/eai/inbound/processor/DualRequestProcessorTest.java b/src/test/java/com/eactive/eai/inbound/processor/DualRequestProcessorTest.java deleted file mode 100644 index ac6853d..0000000 --- a/src/test/java/com/eactive/eai/inbound/processor/DualRequestProcessorTest.java +++ /dev/null @@ -1,197 +0,0 @@ -package com.eactive.eai.inbound.processor; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.*; - -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.Test; -import org.springframework.context.ApplicationContext; -import org.springframework.test.util.ReflectionTestUtils; - -import com.eactive.eai.common.inflow.Bucket; -import com.eactive.eai.common.inflow.InflowTargetVO; -import com.eactive.eai.common.inflow.dual.DualBucket; -import com.eactive.eai.common.inflow.dual.DualCustomBucket; -import com.eactive.eai.common.server.EAIServerManager; -import com.eactive.eai.common.util.ApplicationContextProvider; - -/** - * DualRequestProcessor 단위 테스트. - * - *

RequestProcessor 클래스는 static 초기화 블록에서 EAIServerManager.getInstance()를 호출한다. - * @BeforeAll에서 mock ApplicationContext를 주입한 뒤 DualRequestProcessor를 최초 로딩시킨다. - * - *

주의: 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); - } -} diff --git a/src/test/resources/com/eactive/eai/common/inflow/init_inflow_control_group.sql b/src/test/resources/com/eactive/eai/common/inflow/init_inflow_control_group.sql index d55aeb7..3b97c89 100644 --- a/src/test/resources/com/eactive/eai/common/inflow/init_inflow_control_group.sql +++ b/src/test/resources/com/eactive/eai/common/inflow/init_inflow_control_group.sql @@ -1,2 +1,3 @@ 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'); + ('test-group-001', '테스트 그룹', 100, 10, 'MIN', '1'), + ('test-group-quota', '쿼터전용 그룹', 5, 0, 'MIN', '1'); diff --git a/src/test/resources/com/eactive/eai/common/inflow/init_tseaifr09.sql b/src/test/resources/com/eactive/eai/common/inflow/init_tseaifr09.sql index ac25a7f..ec220ab 100644 --- a/src/test/resources/com/eactive/eai/common/inflow/init_tseaifr09.sql +++ b/src/test/resources/com/eactive/eai/common/inflow/init_tseaifr09.sql @@ -1,6 +1,8 @@ -INSERT INTO tseaifr09 (type,name,threshold,useyn,thresholdpersecond,thresholdtimeunit) VALUES - ('01','_AA0_IN_NET_AsS',10,'1',0,NULL), - ('01','_AB0_IN_NET_AsS',3,'1',0,NULL), - ('01','_TST_IN_NET_AsS',1,'1',0,NULL), - ('02','FASSFEP00000004S2',10,'1',0,NULL), - ('02','FDASFEP00000009S2',2,'1',0,NULL); \ No newline at end of file +INSERT INTO tseaifr09 (type,name,threshold,useyn,thresholdpersecond,thresholdtimeunit) VALUES + ('01','_AA0_IN_NET_AsS',10,'1',0,NULL), + ('01','_AB0_IN_NET_AsS',3,'1',0,NULL), + ('01','_TST_IN_NET_AsS',1,'1',0,NULL), + ('02','FASSFEP00000004S2',10,'1',0,NULL), + ('02','FDASFEP00000009S2',2,'1',0,NULL), + ('01','TEST_ADAPTER',5,'1',0,'MIN'), + ('02','TEST_INTERFACE',5,'1',0,'MIN'); \ No newline at end of file