From 44093681b75b57831358e7a967b16b20acf21b18 Mon Sep 17 00:00:00 2001 From: curry772 Date: Fri, 31 Jul 2026 09:35:48 +0900 Subject: [PATCH] =?UTF-8?q?AdapterErrorMessageHandler=20=ED=94=84=EB=9F=AC?= =?UTF-8?q?=ED=8D=BC=ED=8B=B0=20=EA=B7=B8=EB=A3=B9=EC=97=90=20default=20te?= =?UTF-8?q?mplate=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - default.template - default.in.template - default.sys.template --- .../TemplateAdapterErrorMsgHandler.java | 34 +- .../TemplateAdapterErrorMsgHandlerTest.java | 424 ++++++++++++++++++ 2 files changed, 453 insertions(+), 5 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 c11d928..589221f 100644 --- a/src/main/java/com/eactive/eai/adapter/handler/TemplateAdapterErrorMsgHandler.java +++ b/src/main/java/com/eactive/eai/adapter/handler/TemplateAdapterErrorMsgHandler.java @@ -127,6 +127,9 @@ public class TemplateAdapterErrorMsgHandler implements AdapterErrorMessageHandle StandardMessage resStandardMessage = resEaiMsg.getStandardMessage(); String templateKey = inboundAdapterGroupName + ".template"; String template = PropManager.getInstance().getProperty(PROP_GROUP, templateKey); + + if (StringUtils.isBlank(template)) + template = PropManager.getInstance().getProperty(PROP_GROUP, "default.template"); if (StringUtils.isBlank(template)) { if (logger.isWarn()) { @@ -320,6 +323,8 @@ public class TemplateAdapterErrorMsgHandler implements AdapterErrorMessageHandle ? expr.substring(closeIdx + 2) : ""; + if (msg == null) return defaultVal; + String key = msg.findItemValue(msgPath); if (StringUtils.isBlank(key)) return defaultVal; if (callProp == null) return defaultVal; @@ -409,6 +414,9 @@ public class TemplateAdapterErrorMsgHandler implements AdapterErrorMessageHandle String templateKey = inboudnAdapterGroupName + ".sys.template"; String template = PropManager.getInstance().getProperty(PROP_GROUP, templateKey); + if (StringUtils.isBlank(template)) + template = PropManager.getInstance().getProperty(PROP_GROUP, "default.sys.template"); + if (StringUtils.isBlank(template)) { if (logger.isWarn()) { logger.warn("TemplateAdapterErrorMsgHandler] template not found. group=" + PROP_GROUP @@ -456,6 +464,10 @@ public class TemplateAdapterErrorMsgHandler implements AdapterErrorMessageHandle throws Exception { String templateKey = outboundadapterGroupName + ".template"; String template = PropManager.getInstance().getProperty(PROP_GROUP, templateKey); + + if (StringUtils.isBlank(template)) + template = PropManager.getInstance().getProperty(PROP_GROUP, "default.template"); + if (StringUtils.isBlank(template)) { return null; } @@ -466,15 +478,27 @@ public class TemplateAdapterErrorMsgHandler implements AdapterErrorMessageHandle String adptMsgType, String encode, Throwable e1, int httpCode) { String templateKey = adapterGroupName + ".in." + httpCode + ".template"; String template = PropManager.getInstance().getProperty(PROP_GROUP, templateKey); + if (StringUtils.isBlank(template)) { + templateKey = "default.in." + httpCode + ".template"; + template = PropManager.getInstance().getProperty(PROP_GROUP, templateKey); + } + if (StringUtils.isBlank(template)) { templateKey = adapterGroupName + ".in.template"; template = PropManager.getInstance().getProperty(PROP_GROUP, templateKey); - if (StringUtils.isBlank(template)) { - String errorResponseFormat = httpProp.getProperty("ERROR_RESPONSE_FORMAT"); - return MessageUtil.makeErrorMessageByMessageType(adptMsgType, encode, - MessageUtil.ERROR_CODE_AP_ERROR, e1.getMessage(), errorResponseFormat); - } } + + if (StringUtils.isBlank(template)) { + templateKey = "default.in.template"; + template = PropManager.getInstance().getProperty(PROP_GROUP, templateKey); + } + + if (StringUtils.isBlank(template)) { + String errorResponseFormat = httpProp.getProperty("ERROR_RESPONSE_FORMAT"); + return MessageUtil.makeErrorMessageByMessageType(adptMsgType, encode, + MessageUtil.ERROR_CODE_AP_ERROR, e1.getMessage(), errorResponseFormat); + } + return render(template, null, callProp, e1); } 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 aa9d174..f6ed1a5 100644 --- a/src/test/java/com/eactive/eai/adapter/handler/TemplateAdapterErrorMsgHandlerTest.java +++ b/src/test/java/com/eactive/eai/adapter/handler/TemplateAdapterErrorMsgHandlerTest.java @@ -1383,4 +1383,428 @@ class TemplateAdapterErrorMsgHandlerTest { assertNotNull(result); } } + + // ================================================================ + // default.* 템플릿 폴백 + // + // 그룹 전용 키가 미설정/공백이면 "default." 로 시작하는 공통 키를 재조회한다. + // generateNonStandardErrorResponseMessage : {group}.template → default.template + // generateOutboundErrorResponseMessage : {group}.template → default.template + // generateNonStandardInternalErrorResponseMessage : {group}.sys.template → default.sys.template + // generateNonStandardInboundErrorResponseMessage : {group}.in.{code}.template + // → default.in.{code}.template + // → {group}.in.template + // → default.in.template + // → MessageUtil 폴백 + // ================================================================ + + @Nested + @DisplayName("default.* 템플릿 폴백") + class DefaultTemplateFallback { + + private static final String G = TemplateAdapterErrorMsgHandler.PROP_GROUP; + + private PropManager mockPropManager; + private AdapterManager mockAdapterManager; + private AdapterPropManager mockAdapterPropManager; + + @BeforeEach + void setUpDefaults() throws Exception { + mockPropManager = Mockito.mock(PropManager.class); + mockAdapterManager = Mockito.mock(AdapterManager.class); + mockAdapterPropManager = Mockito.mock(AdapterPropManager.class); + + // 명시적으로 stub 하지 않은 모든 키는 미설정(null) 로 간주 + Mockito.when(mockPropManager.getProperty(anyString(), anyString())).thenReturn(null); + Mockito.when(mockPropManager.getProperty(anyString(), anyString(), anyString())) + .thenAnswer(invocation -> invocation.getArgument(2)); + + AdapterGroupVO adapterGroupVO = Mockito.mock(AdapterGroupVO.class); + AdapterVO adapterVO = Mockito.mock(AdapterVO.class); + Mockito.when(adapterGroupVO.getMessageType()).thenReturn("JSON"); + Mockito.when(adapterGroupVO.getMessageEncode()).thenReturn("UTF-8"); + Mockito.when(adapterVO.getPropGroupName()).thenReturn("TEST_PROP"); + Mockito.when(adapterVO.getAdapterGroupVO()).thenReturn(adapterGroupVO); + Mockito.when(mockAdapterManager.getAdapterGroupVO("MY_GROUP")).thenReturn(adapterGroupVO); + Mockito.when(mockAdapterManager.getAdapterVO("MY_GROUP", "MY_ADAPTER")).thenReturn(adapterVO); + + Properties httpProp = new Properties(); + httpProp.setProperty("ERROR_RESPONSE_FORMAT", ""); + Mockito.when(mockAdapterPropManager.getProperties("TEST_PROP")).thenReturn(httpProp); + + ApplicationContext mockCtx = Mockito.mock(ApplicationContext.class); + Mockito.when(mockCtx.getBean(PropManager.class)).thenReturn(mockPropManager); + Mockito.when(mockCtx.getBean(AdapterManager.class)).thenReturn(mockAdapterManager); + Mockito.when(mockCtx.getBean(AdapterPropManager.class)).thenReturn(mockAdapterPropManager); + Field ctxField = ApplicationContextProvider.class.getDeclaredField("context"); + ctxField.setAccessible(true); + ctxField.set(null, mockCtx); + } + + private void prop(String key, String value) { + Mockito.when(mockPropManager.getProperty(eq(G), eq(key))).thenReturn(value); + } + + // ------------------------------------------------------------ + // generateNonStandardErrorResponseMessage : default.template + // ------------------------------------------------------------ + + @Nested + @DisplayName("generateNonStandardErrorResponseMessage – default.template") + class NonStandardError { + + @Test + @DisplayName("그룹 템플릿이 있으면 default.template 을 조회하지 않는다") + void 그룹_템플릿_우선() throws Exception { + prop("MY_GROUP.template", "{\"code\":\"${MSG.MAIN_MSG.outp_msg_cd}\"}"); + + Object result = handler.generateNonStandardErrorResponseMessage( + "MY_GROUP", "MY_ADAPTER", null, null, + toEaiMessage(buildMsg("E001", "오류", "", null))); + + assertEquals("{\"code\":\"E001\"}", result); + Mockito.verify(mockPropManager, Mockito.never()).getProperty(G, "default.template"); + } + + @Test + @DisplayName("그룹 템플릿 미설정 시 default.template 으로 폴백") + void default_폴백() throws Exception { + prop("default.template", "{\"code\":\"${MSG.MAIN_MSG.outp_msg_cd}\",\"src\":\"default\"}"); + + Object result = handler.generateNonStandardErrorResponseMessage( + "MY_GROUP", "MY_ADAPTER", null, null, + toEaiMessage(buildMsg("E777", "오류", "", null))); + + assertEquals("{\"code\":\"E777\",\"src\":\"default\"}", result); + } + + @Test + @DisplayName("그룹 템플릿이 공백 문자열이어도 default.template 으로 폴백") + void 공백_템플릿도_폴백() throws Exception { + prop("MY_GROUP.template", " "); + prop("default.template", "{\"src\":\"default\"}"); + + Object result = handler.generateNonStandardErrorResponseMessage( + "MY_GROUP", "MY_ADAPTER", null, null, + toEaiMessage(buildMsg("E001", "오류", "", null))); + + assertEquals("{\"src\":\"default\"}", result); + } + + @Test + @DisplayName("그룹/default 둘 다 없으면 기존대로 null 반환") + void 둘다_없으면_null() throws Exception { + Object result = handler.generateNonStandardErrorResponseMessage( + "MY_GROUP", "MY_ADAPTER", null, null, + toEaiMessage(buildMsg("E001", "오류", "", null))); + + assertNull(result); + // 폴백 조회가 실제로 시도되었는지 확인 + Mockito.verify(mockPropManager).getProperty(G, "MY_GROUP.template"); + Mockito.verify(mockPropManager).getProperty(G, "default.template"); + } + + @Test + @DisplayName("조회 순서: {group}.template → default.template") + void 조회_순서() throws Exception { + handler.generateNonStandardErrorResponseMessage( + "MY_GROUP", "MY_ADAPTER", null, null, + toEaiMessage(buildMsg("E001", "오류", "", null))); + + InOrder inOrder = Mockito.inOrder(mockPropManager); + inOrder.verify(mockPropManager).getProperty(G, "MY_GROUP.template"); + inOrder.verify(mockPropManager).getProperty(G, "default.template"); + } + + @Test + @DisplayName("default.template 에서도 callProp/foreach 치환이 동일하게 동작") + void default_템플릿_전체_치환() throws Exception { + prop("default.template", + "{\"adapter\":\"${callprop.ADAPTER_NAME}\"," + + "\"code\":\"${MSG.MAIN_MSG.outp_msg_cd}\"," + + "\"errors\":[{{#foreach MSG.MSG_LIST}}{\"c\":\"${outp_msg_cd}\"}{{/foreach}}]}"); + + Object result = handler.generateNonStandardErrorResponseMessage( + "MY_GROUP", "MY_ADAPTER", props("ADAPTER_NAME", "REAL"), null, + toEaiMessage(buildMsg("E001", "대표오류", "", + new String[][]{{"E001", "오류A", "", ""}, {"E002", "오류B", "", ""}}))); + + assertEquals( + "{\"adapter\":\"REAL\",\"code\":\"E001\"," + + "\"errors\":[{\"c\":\"E001\"},{\"c\":\"E002\"}]}", + result); + } + } + + // ------------------------------------------------------------ + // generateNonStandardInternalErrorResponseMessage : default.sys.template + // ------------------------------------------------------------ + + @Nested + @DisplayName("generateNonStandardInternalErrorResponseMessage – default.sys.template") + class InternalError { + + @Test + @DisplayName("그룹 sys 템플릿이 있으면 default.sys.template 을 조회하지 않는다") + void 그룹_템플릿_우선() throws Exception { + prop("MY_GROUP.sys.template", "{\"code\":\"${MSG.MAIN_MSG.outp_msg_cd}\"}"); + + Object result = handler.generateNonStandardInternalErrorResponseMessage( + "MY_GROUP", "MY_ADAPTER", null, null, + toEaiMessage(buildMsg("E500", "내부오류", "", null))); + + assertEquals("{\"code\":\"E500\"}", result); + Mockito.verify(mockPropManager, Mockito.never()).getProperty(G, "default.sys.template"); + } + + @Test + @DisplayName("그룹 sys 템플릿 미설정 시 default.sys.template 으로 폴백") + void default_폴백() throws Exception { + prop("default.sys.template", + "{\"code\":\"${MSG.MAIN_MSG.outp_msg_cd}\",\"msg\":\"${MSG.MAIN_MSG.outp_msg_ctnt}\"}"); + + Object result = handler.generateNonStandardInternalErrorResponseMessage( + "MY_GROUP", "MY_ADAPTER", null, null, + toEaiMessage(buildMsg("E500", "시스템오류", "", null))); + + assertEquals("{\"code\":\"E500\",\"msg\":\"시스템오류\"}", result); + } + + @Test + @DisplayName("내부오류는 default.template 이 아니라 default.sys.template 을 조회한다") + void sys_전용_키_사용() throws Exception { + // default.template 만 설정된 상태 → 내부오류에는 적용되지 않아야 한다 + prop("default.template", "{\"src\":\"non-sys\"}"); + + Object result = handler.generateNonStandardInternalErrorResponseMessage( + "MY_GROUP", "MY_ADAPTER", null, null, + toEaiMessage(buildMsg("E500", "시스템오류", "", null))); + + assertNull(result); + Mockito.verify(mockPropManager).getProperty(G, "default.sys.template"); + Mockito.verify(mockPropManager, Mockito.never()).getProperty(G, "default.template"); + } + + @Test + @DisplayName("그룹/default 둘 다 없으면 기존대로 null 반환") + void 둘다_없으면_null() throws Exception { + Object result = handler.generateNonStandardInternalErrorResponseMessage( + "MY_GROUP", "MY_ADAPTER", null, null, + toEaiMessage(buildMsg("E500", "오류", "", null))); + + assertNull(result); + } + + @Test + @DisplayName("조회 순서: {group}.sys.template → default.sys.template") + void 조회_순서() throws Exception { + handler.generateNonStandardInternalErrorResponseMessage( + "MY_GROUP", "MY_ADAPTER", null, null, + toEaiMessage(buildMsg("E500", "오류", "", null))); + + InOrder inOrder = Mockito.inOrder(mockPropManager); + inOrder.verify(mockPropManager).getProperty(G, "MY_GROUP.sys.template"); + inOrder.verify(mockPropManager).getProperty(G, "default.sys.template"); + } + } + + // ------------------------------------------------------------ + // generateOutboundErrorResponseMessage : default.template + // ------------------------------------------------------------ + + @Nested + @DisplayName("generateOutboundErrorResponseMessage – default.template") + class OutboundError { + + @Test + @DisplayName("그룹 템플릿이 있으면 default.template 을 조회하지 않는다") + void 그룹_템플릿_우선() throws Exception { + prop("OUT_GROUP.template", "{\"code\":\"${MSG.MAIN_MSG.outp_msg_cd}\"}"); + + Object result = handler.generateOutboundErrorResponseMessage( + "OUT_GROUP", "OUT_ADAPTER", null, null, + toEaiMessage(buildMsg("E001", "오류", "", null))); + + assertEquals("{\"code\":\"E001\"}", result); + Mockito.verify(mockPropManager, Mockito.never()).getProperty(G, "default.template"); + } + + @Test + @DisplayName("그룹 템플릿 미설정 시 default.template 으로 폴백") + void default_폴백() throws Exception { + prop("default.template", "{\"code\":\"${MSG.MAIN_MSG.outp_msg_cd}\",\"src\":\"default\"}"); + + Object result = handler.generateOutboundErrorResponseMessage( + "OUT_GROUP", "OUT_ADAPTER", null, null, + toEaiMessage(buildMsg("E888", "오류", "", null))); + + assertEquals("{\"code\":\"E888\",\"src\":\"default\"}", result); + } + + @Test + @DisplayName("아웃바운드/인바운드가 동일한 default.template 을 공유한다") + void inbound_outbound_default_공유() throws Exception { + prop("default.template", "{\"shared\":\"${MSG.MAIN_MSG.outp_msg_cd}\"}"); + + Object outbound = handler.generateOutboundErrorResponseMessage( + "OUT_GROUP", "OUT_ADAPTER", null, null, + toEaiMessage(buildMsg("E001", "오류", "", null))); + Object inbound = handler.generateNonStandardErrorResponseMessage( + "IN_GROUP", "IN_ADAPTER", null, null, + toEaiMessage(buildMsg("E001", "오류", "", null))); + + // 두 경로 모두 같은 키를 쓰므로 결과가 동일하다. + // 방향별로 다른 기본 응답이 필요하면 그룹 전용 키를 설정해야 한다. + assertEquals("{\"shared\":\"E001\"}", outbound); + assertEquals(outbound, inbound); + } + + @Test + @DisplayName("그룹/default 둘 다 없으면 기존대로 null 반환") + void 둘다_없으면_null() throws Exception { + Object result = handler.generateOutboundErrorResponseMessage( + "OUT_GROUP", "OUT_ADAPTER", null, null, + toEaiMessage(buildMsg("E001", "오류", "", null))); + + assertNull(result); + } + } + + // ------------------------------------------------------------ + // generateNonStandardInboundErrorResponseMessage : default.in.* + // ------------------------------------------------------------ + + @Nested + @DisplayName("generateNonStandardInboundErrorResponseMessage – default.in.*") + class InboundError { + + /** 500 으로 매핑되는 일반 예외로 호출한다. */ + private Object call500(Properties callProp) throws Exception { + return handler.generateNonStandardInboundErrorResponseMessage( + "MY_GROUP", "MY_ADAPTER", callProp, null, null, + new RuntimeException("처리 중 오류")); + } + + @Test + @DisplayName("1순위: {group}.in.{code}.template 이 있으면 그것을 사용") + void 그룹_코드별_템플릿_우선() throws Exception { + prop("MY_GROUP.in.500.template", "{\"src\":\"group-code\"}"); + prop("default.in.500.template", "{\"src\":\"default-code\"}"); + prop("MY_GROUP.in.template", "{\"src\":\"group-common\"}"); + prop("default.in.template", "{\"src\":\"default-common\"}"); + + assertEquals("{\"src\":\"group-code\"}", call500(null)); + } + + @Test + @DisplayName("2순위: {group}.in.{code} 가 없으면 default.in.{code}.template 사용") + void default_코드별_템플릿() throws Exception { + prop("default.in.500.template", "{\"src\":\"default-code\"}"); + prop("MY_GROUP.in.template", "{\"src\":\"group-common\"}"); + prop("default.in.template", "{\"src\":\"default-common\"}"); + + // 주의: 현재 구현은 코드별 default 가 그룹 공통보다 우선한다. + assertEquals("{\"src\":\"default-code\"}", call500(null)); + } + + @Test + @DisplayName("3순위: 코드별 키가 모두 없으면 {group}.in.template 사용") + void 그룹_공통_템플릿() throws Exception { + prop("MY_GROUP.in.template", "{\"src\":\"group-common\"}"); + prop("default.in.template", "{\"src\":\"default-common\"}"); + + assertEquals("{\"src\":\"group-common\"}", call500(null)); + } + + @Test + @DisplayName("4순위: 앞의 3개가 모두 없으면 default.in.template 사용") + void default_공통_템플릿() throws Exception { + prop("default.in.template", "{\"src\":\"default-common\"}"); + + assertEquals("{\"src\":\"default-common\"}", call500(null)); + } + + @Test + @DisplayName("4개 키가 모두 없으면 MessageUtil 폴백") + void 전부_없으면_MessageUtil_폴백() throws Exception { + Object result = call500(null); + + assertNotNull(result); + Mockito.verify(mockPropManager).getProperty(G, "MY_GROUP.in.500.template"); + Mockito.verify(mockPropManager).getProperty(G, "default.in.500.template"); + Mockito.verify(mockPropManager).getProperty(G, "MY_GROUP.in.template"); + Mockito.verify(mockPropManager).getProperty(G, "default.in.template"); + } + + @Test + @DisplayName("조회 순서: group.code → default.code → group.common → default.common") + void 조회_순서_4단계() throws Exception { + handler.generateNonStandardInboundErrorResponseMessage( + "MY_GROUP", "MY_ADAPTER", null, null, null, + new HttpStatusException("Service Unavailable", 503)); + + InOrder inOrder = Mockito.inOrder(mockPropManager); + inOrder.verify(mockPropManager).getProperty(G, "MY_GROUP.in.503.template"); + inOrder.verify(mockPropManager).getProperty(G, "default.in.503.template"); + inOrder.verify(mockPropManager).getProperty(G, "MY_GROUP.in.template"); + inOrder.verify(mockPropManager).getProperty(G, "default.in.template"); + } + + @Test + @DisplayName("코드별 default 키 형식은 'default.in.{code}.template' (점 누락 회귀 방지)") + void default_코드별_키_형식_검증() throws Exception { + handler.generateNonStandardInboundErrorResponseMessage( + "MY_GROUP", "MY_ADAPTER", null, null, null, + new HttpStatusException("Bad Request", 400)); + + Mockito.verify(mockPropManager).getProperty(G, "default.in.400.template"); + // 점이 빠진 잘못된 키로 조회하지 않아야 한다 + Mockito.verify(mockPropManager, Mockito.never()).getProperty(G, "default.in400.template"); + } + + @Test + @DisplayName("JwtAuthException 은 default.in.401.template 로 폴백") + void jwt_401_default_폴백() throws Exception { + prop("default.in.401.template", "{\"code\":\"${exception.code}\",\"msg\":\"${exception.message}\"}"); + + Object result = handler.generateNonStandardInboundErrorResponseMessage( + "MY_GROUP", "MY_ADAPTER", null, null, null, + new JwtAuthException("JWT_EXPIRED", "토큰이 만료되었습니다")); + + assertEquals("{\"code\":\"JWT_EXPIRED\",\"msg\":\"토큰이 만료되었습니다\"}", result); + } + + @Test + @DisplayName("default.in.template 에서 exception/callProp 치환이 동일하게 동작") + void default_공통_템플릿_치환() throws Exception { + prop("default.in.template", + "{\"adapter\":\"${callprop.ADAPTER_NAME}\",\"msg\":\"${exception.message}\"}"); + + Object result = call500(props("ADAPTER_NAME", "REAL")); + + assertEquals("{\"adapter\":\"REAL\",\"msg\":\"처리 중 오류\"}", result); + } + + @Test + @DisplayName("default.in.template 에 ${callprop[경로]} 를 써도 msg=null 로 NPE 없이 기본값 반환") + void default_공통_템플릿_간접참조_NPE_회귀방지() throws Exception { + // 인바운드 경로는 render(template, null, ...) 로 호출되므로 + // 간접 참조의 표준전문 경로 조회 대상이 없다 → 기본값으로 처리되어야 한다. + prop("default.in.template", "{\"key\":\"${callprop[MSG.cp_key]:NO_MSG}\"}"); + + Object result = call500(props("ADAPTER_NAME", "REAL")); + + assertEquals("{\"key\":\"NO_MSG\"}", result); + } + + @Test + @DisplayName("그룹 코드별 템플릿이 공백이면 default 로 폴백") + void 공백_템플릿도_폴백() throws Exception { + prop("MY_GROUP.in.500.template", " "); + prop("default.in.500.template", "{\"src\":\"default-code\"}"); + + assertEquals("{\"src\":\"default-code\"}", call500(null)); + } + } + } }