From 2686448791b37c81b9daccdca1686f45f5818193 Mon Sep 17 00:00:00 2001 From: curry772 Date: Tue, 12 May 2026 10:47:23 +0900 Subject: [PATCH 1/3] =?UTF-8?q?fix:=20GenerateMethod=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=EC=97=90=EC=84=9C=20AdapterPropManager=20=E2=86=92=20?= =?UTF-8?q?PropManager=20=EB=AA=A9=20=EA=B0=9D=EC=B2=B4=EB=A1=9C=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../TemplateAdapterErrorMsgHandler.java | 52 ++++++++++-- .../TemplateAdapterErrorMsgHandlerTest.java | 83 ++++++++++++++++--- 2 files changed, 116 insertions(+), 19 deletions(-) diff --git a/src/main/java/com/eactive/eai/adapter/handler/TemplateAdapterErrorMsgHandler.java b/src/main/java/com/eactive/eai/adapter/handler/TemplateAdapterErrorMsgHandler.java index 2d1956f..714f99b 100644 --- a/src/main/java/com/eactive/eai/adapter/handler/TemplateAdapterErrorMsgHandler.java +++ b/src/main/java/com/eactive/eai/adapter/handler/TemplateAdapterErrorMsgHandler.java @@ -9,7 +9,7 @@ import java.util.regex.Pattern; import org.apache.commons.lang3.StringUtils; -import com.eactive.eai.adapter.AdapterPropManager; +import com.eactive.eai.common.property.PropManager; import com.eactive.eai.common.util.Logger; import com.eactive.eai.message.StandardItem; import com.eactive.eai.message.StandardMessage; @@ -62,6 +62,12 @@ import com.eactive.eai.message.StandardMessage; * {{/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 { @@ -91,7 +97,7 @@ public class TemplateAdapterErrorMsgHandler implements AdapterErrorMessageHandle StandardMessage resStandardMessage) throws Exception { String templateKey = inboundAdapterGroupName + ".template"; - String template = AdapterPropManager.getInstance().getProperty(PROP_GROUP, templateKey); + String template = PropManager.getInstance().getProperty(PROP_GROUP, templateKey); if (StringUtils.isBlank(template)) { if (logger.isWarn()) { @@ -135,20 +141,48 @@ public class TemplateAdapterErrorMsgHandler implements AdapterErrorMessageHandle /** * 변수 표현식을 해석해 값을 반환한다. - * - "callprop.키" → callProp.getProperty("키") - * - 그 외 → StandardMessage.findItemValue(expr) + * - "callprop.키" → callProp 에서 직접 조회 + * - "callprop.부모.자식" → callProp.get("부모") 가 Properties 이면 중첩 탐색 + * - 그 외 → StandardMessage.findItemValue(expr) */ private String resolveScalar(String expr, StandardMessage msg, Properties callProp) { if (expr.startsWith(CALLPROP_PREFIX)) { - String key = expr.substring(CALLPROP_PREFIX.length()); - if (callProp != null) { - return StringUtils.defaultString(callProp.getProperty(key)); - } - return ""; + if (callProp == null) return ""; + String keyPath = expr.substring(CALLPROP_PREFIX.length()); + return StringUtils.defaultString(resolveCallProp(callProp, keyPath)); } return StringUtils.defaultString(msg.findItemValue(expr)); } + /** + * 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 참조 미지원) diff --git a/src/test/java/com/eactive/eai/adapter/handler/TemplateAdapterErrorMsgHandlerTest.java b/src/test/java/com/eactive/eai/adapter/handler/TemplateAdapterErrorMsgHandlerTest.java index 7326022..fc9a769 100644 --- a/src/test/java/com/eactive/eai/adapter/handler/TemplateAdapterErrorMsgHandlerTest.java +++ b/src/test/java/com/eactive/eai/adapter/handler/TemplateAdapterErrorMsgHandlerTest.java @@ -15,7 +15,7 @@ import org.junit.jupiter.api.Test; import org.mockito.Mockito; import org.springframework.context.ApplicationContext; -import com.eactive.eai.adapter.AdapterPropManager; +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; @@ -26,7 +26,7 @@ import com.eactive.eai.message.StandardType; * * - render() : package-private 이므로 같은 패키지에서 직접 호출 * - generateNonStandardErrorResponseMessage() : ApplicationContextProvider 에 - * mock ApplicationContext 를 리플렉션으로 주입하여 AdapterPropManager 격리 + * mock ApplicationContext 를 리플렉션으로 주입하여 PropManager 격리 */ class TemplateAdapterErrorMsgHandlerTest { @@ -210,6 +210,69 @@ class TemplateAdapterErrorMsgHandlerTest { 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)); + } } // ================================================================ @@ -341,16 +404,16 @@ class TemplateAdapterErrorMsgHandlerTest { } // ================================================================ - // generateNonStandardErrorResponseMessage – AdapterPropManager mock + // generateNonStandardErrorResponseMessage – PropManager mock // ================================================================ @Nested @DisplayName("generateNonStandardErrorResponseMessage") class GenerateMethod { - private void injectMockPropManager(AdapterPropManager mockPropManager) throws Exception { + private void injectMockPropManager(PropManager mockPropManager) throws Exception { ApplicationContext mockCtx = Mockito.mock(ApplicationContext.class); - Mockito.when(mockCtx.getBean(AdapterPropManager.class)).thenReturn(mockPropManager); + Mockito.when(mockCtx.getBean(PropManager.class)).thenReturn(mockPropManager); Field ctxField = ApplicationContextProvider.class.getDeclaredField("context"); ctxField.setAccessible(true); ctxField.set(null, mockCtx); @@ -359,7 +422,7 @@ class TemplateAdapterErrorMsgHandlerTest { @Test @DisplayName("템플릿 미설정 시 null 반환") void 템플릿_없으면_null() throws Exception { - AdapterPropManager mockProp = Mockito.mock(AdapterPropManager.class); + PropManager mockProp = Mockito.mock(PropManager.class); Mockito.when(mockProp.getProperty(anyString(), anyString())).thenReturn(null); injectMockPropManager(mockProp); @@ -373,7 +436,7 @@ class TemplateAdapterErrorMsgHandlerTest { @Test @DisplayName("빈 템플릿 시 null 반환") void 빈_템플릿_null() throws Exception { - AdapterPropManager mockProp = Mockito.mock(AdapterPropManager.class); + PropManager mockProp = Mockito.mock(PropManager.class); Mockito.when(mockProp.getProperty(anyString(), anyString())).thenReturn(" "); injectMockPropManager(mockProp); @@ -388,7 +451,7 @@ class TemplateAdapterErrorMsgHandlerTest { @DisplayName("템플릿 설정 시 StandardMessage 치환 결과 반환") void 템플릿_stdmsg_치환() throws Exception { String template = "{\"code\":\"${MSG.MAIN_MSG.outp_msg_cd}\"}"; - AdapterPropManager mockProp = Mockito.mock(AdapterPropManager.class); + PropManager mockProp = Mockito.mock(PropManager.class); Mockito.when(mockProp.getProperty( eq(TemplateAdapterErrorMsgHandler.PROP_GROUP), eq("MY_GROUP.template"))).thenReturn(template); @@ -405,7 +468,7 @@ class TemplateAdapterErrorMsgHandlerTest { @DisplayName("callProp 값이 템플릿에 치환되어 반환") void 템플릿_callProp_치환() throws Exception { String template = "{\"adapter\":\"${callprop.ADAPTER_NAME}\",\"code\":\"${MSG.MAIN_MSG.outp_msg_cd}\"}"; - AdapterPropManager mockProp = Mockito.mock(AdapterPropManager.class); + PropManager mockProp = Mockito.mock(PropManager.class); Mockito.when(mockProp.getProperty( eq(TemplateAdapterErrorMsgHandler.PROP_GROUP), eq("MY_GROUP.template"))).thenReturn(template); @@ -423,7 +486,7 @@ class TemplateAdapterErrorMsgHandlerTest { @Test @DisplayName("템플릿 키는 {adapterGroupId}.template 형식으로 조회") void 템플릿_키_형식_검증() throws Exception { - AdapterPropManager mockProp = Mockito.mock(AdapterPropManager.class); + PropManager mockProp = Mockito.mock(PropManager.class); Mockito.when(mockProp.getProperty(anyString(), anyString())).thenReturn(null); injectMockPropManager(mockProp); From c0c3f45d18ebe68843a495a95ab638d7000d0dc3 Mon Sep 17 00:00:00 2001 From: curry772 Date: Tue, 12 May 2026 11:21:58 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat:=20=EB=AA=A8=EB=93=A0=20=EB=B3=80?= =?UTF-8?q?=EC=88=98=20=ED=91=9C=ED=98=84=EC=8B=9D=EC=97=90=20:=EA=B8=B0?= =?UTF-8?q?=EB=B3=B8=EA=B0=92=20=EB=AC=B8=EB=B2=95=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ${callprop.KEY:default} - 직접 참조 기본값 - ${callprop[MSG.path]:default} - 간접 참조 기본값 - ${MSG.path:default} - 표준전문 경로 기본값 키/경로 미존재 또는 callProp null 시 기본값 반환, 기본값 미지정 시 빈 문자열. Co-Authored-By: Claude Sonnet 4.6 --- .../TemplateAdapterErrorMsgHandler.java | 65 ++++++-- .../TemplateAdapterErrorMsgHandlerTest.java | 139 ++++++++++++++++++ 2 files changed, 192 insertions(+), 12 deletions(-) diff --git a/src/main/java/com/eactive/eai/adapter/handler/TemplateAdapterErrorMsgHandler.java b/src/main/java/com/eactive/eai/adapter/handler/TemplateAdapterErrorMsgHandler.java index 714f99b..26eb500 100644 --- a/src/main/java/com/eactive/eai/adapter/handler/TemplateAdapterErrorMsgHandler.java +++ b/src/main/java/com/eactive/eai/adapter/handler/TemplateAdapterErrorMsgHandler.java @@ -23,10 +23,13 @@ import com.eactive.eai.message.StandardMessage; * StandardMessage 의 필드 값으로 치환한 결과를 반환한다. * * [변수 치환 규칙] - * 1. "${callprop.키}" → callProp.getProperty("키") 로 치환 - * 2. "${경로}" → StandardMessage.findItemValue("경로") 로 치환 - * callprop 접두어가 있으면 callProp 을 우선 참조하고, - * 없으면 StandardMessage 경로로 해석한다. + * 1. "${callprop.키}" → callProp.getProperty("키") 로 치환 + * "${callprop.키:기본값}" → 키 없거나 callProp null 이면 기본값 반환 + * 2. "${callprop[경로]}" → StandardMessage 경로 값을 키로 callProp 간접 조회 + * "${callprop[경로]:기본값}" → 키 결정 실패 또는 키 없으면 기본값 반환 + * 3. "${경로}" → StandardMessage.findItemValue("경로") 로 치환 + * "${경로:기본값}" → 경로 값이 null/공백이면 기본값 반환 + * 모든 형태에서 기본값 미지정 시 빈 문자열을 반환한다. * * [배열 반복 문법] (MSG_LIST 등 GRID 타입) * {{#foreach MSG.MSG_LIST}} @@ -79,6 +82,9 @@ public class TemplateAdapterErrorMsgHandler implements AdapterErrorMessageHandle /** 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("\\$\\{([^}]+)\\}"); @@ -141,17 +147,52 @@ public class TemplateAdapterErrorMsgHandler implements AdapterErrorMessageHandle /** * 변수 표현식을 해석해 값을 반환한다. - * - "callprop.키" → callProp 에서 직접 조회 - * - "callprop.부모.자식" → callProp.get("부모") 가 Properties 이면 중첩 탐색 - * - 그 외 → StandardMessage.findItemValue(expr) + * 모든 형태에서 ':기본값' 접미사를 지원한다. 기본값 미지정 시 빈 문자열. + * - "callprop[경로][:기본값]" → 표준전문 경로 값을 키로 callProp 간접 조회 + * - "callprop.키[:기본값]" → callProp 직접/중첩 조회 + * - "경로[:기본값]" → StandardMessage.findItemValue(경로) */ private String resolveScalar(String expr, StandardMessage msg, Properties callProp) { - if (expr.startsWith(CALLPROP_PREFIX)) { - if (callProp == null) return ""; - String keyPath = expr.substring(CALLPROP_PREFIX.length()); - return StringUtils.defaultString(resolveCallProp(callProp, keyPath)); + if (expr.startsWith(CALLPROP_INDIRECT_PREFIX)) { + return resolveIndirectCallProp(expr, msg, callProp); } - return StringUtils.defaultString(msg.findItemValue(expr)); + 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 = msg.findItemValue(path); + return StringUtils.isNotEmpty(value) ? value : defaultVal; + } + + /** + * ${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; } /** diff --git a/src/test/java/com/eactive/eai/adapter/handler/TemplateAdapterErrorMsgHandlerTest.java b/src/test/java/com/eactive/eai/adapter/handler/TemplateAdapterErrorMsgHandlerTest.java index fc9a769..ec7e5e6 100644 --- a/src/test/java/com/eactive/eai/adapter/handler/TemplateAdapterErrorMsgHandlerTest.java +++ b/src/test/java/com/eactive/eai/adapter/handler/TemplateAdapterErrorMsgHandlerTest.java @@ -160,6 +160,30 @@ class TemplateAdapterErrorMsgHandlerTest { 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)); + } } // ================================================================ @@ -273,6 +297,121 @@ class TemplateAdapterErrorMsgHandlerTest { 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)); + } } // ================================================================ From 3eac4eeaa6d57be87f10d8bc8b0aef6fa033a19d Mon Sep 17 00:00:00 2001 From: curry772 Date: Tue, 12 May 2026 13:05:33 +0900 Subject: [PATCH 3/3] =?UTF-8?q?feat:=20=EB=AA=A8=EB=93=A0=20=EB=B3=80?= =?UTF-8?q?=EC=88=98=20=ED=91=9C=ED=98=84=EC=8B=9D=EC=97=90=20:=EA=B8=B0?= =?UTF-8?q?=EB=B3=B8=EA=B0=92=20=EB=AC=B8=EB=B2=95=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?=EB=B0=8F=20JSON=20=ED=95=84=EB=93=9C=20=ED=8C=8C=EC=8B=B1=20?= =?UTF-8?q?=EC=A7=80=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ${callprop.KEY:default} / ${callprop[path]:default} / ${MSG.path:default} 기본값 문법 - ${DATA.BIZDATA.acctNo} : StandardMessage String 값이 JSON이면 Gson으로 필드 추출 (중첩 경로 지원, 없는 필드는 빈 문자열 또는 기본값) Co-Authored-By: Claude Sonnet 4.6 --- .../TemplateAdapterErrorMsgHandler.java | 49 ++++++++++- .../TemplateAdapterErrorMsgHandlerTest.java | 84 +++++++++++++++++++ 2 files changed, 132 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/eactive/eai/adapter/handler/TemplateAdapterErrorMsgHandler.java b/src/main/java/com/eactive/eai/adapter/handler/TemplateAdapterErrorMsgHandler.java index 26eb500..3e6cd3e 100644 --- a/src/main/java/com/eactive/eai/adapter/handler/TemplateAdapterErrorMsgHandler.java +++ b/src/main/java/com/eactive/eai/adapter/handler/TemplateAdapterErrorMsgHandler.java @@ -10,6 +10,8 @@ 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; @@ -168,10 +170,55 @@ public class TemplateAdapterErrorMsgHandler implements AdapterErrorMessageHandle int colonIdx = expr.indexOf(':'); String path = colonIdx >= 0 ? expr.substring(0, colonIdx) : expr; String defaultVal = colonIdx >= 0 ? expr.substring(colonIdx + 1) : ""; - String value = msg.findItemValue(path); + 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 키를 동적으로 결정한다. diff --git a/src/test/java/com/eactive/eai/adapter/handler/TemplateAdapterErrorMsgHandlerTest.java b/src/test/java/com/eactive/eai/adapter/handler/TemplateAdapterErrorMsgHandlerTest.java index ec7e5e6..b73811e 100644 --- a/src/test/java/com/eactive/eai/adapter/handler/TemplateAdapterErrorMsgHandlerTest.java +++ b/src/test/java/com/eactive/eai/adapter/handler/TemplateAdapterErrorMsgHandlerTest.java @@ -418,6 +418,90 @@ class TemplateAdapterErrorMsgHandlerTest { // 배열 반복 {{#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 {