From dd7c8a29bf7b4db13398821555e96c12b6369d80 Mon Sep 17 00:00:00 2001 From: curry772 Date: Thu, 27 Aug 2026 18:09:03 +0900 Subject: [PATCH] =?UTF-8?q?newNumberSafeMapper=20=EC=B6=9C=EB=A0=A5?= =?UTF-8?q?=EC=B8=A1=20=EC=98=B5=EC=85=98=20=EC=B6=94=EA=B0=80=20=EB=B0=8F?= =?UTF-8?q?=20ArrayNode.set=20=EB=B2=84=EC=A0=84=20=ED=98=B8=ED=99=98=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JacksonUtil.newNumberSafeMapper() 는 USE_BIG_DECIMAL_FOR_FLOATS + withExactBigDecimals 로 입력(파싱)측만 막고 있었다. DecimalNode 직렬화는 BigDecimal.toString() 이라 scale 이 음수이거나 adjusted exponent < -6 일 때 지수 표기가 나온다. 즉 파싱을 제대로 해도 내보낼 때 작은 소수가 깨졌다. 0.00000012 -> 1.2E-7 , -0.0000005 -> -5E-7 WRITE_BIGDECIMAL_AS_PLAIN 을 추가해 출력측까지 보존한다. (금액처럼 scale >= 0 인 큰 값은 원래 영향 없음. 이율/환율이 대상) 또한 JacksonUtil.setValue() 가 ArrayNode.set(int, String) 을 쓰고 있었다. 이 오버로드는 jackson-databind 2.13 부터이고, 실제 런타임은 2.12.7 이다 (2.12.7 은 set(int, JsonNode) 하나뿐). jackson-dataformat-xml:2.13.1 이 compileOnly 로 컴파일 클래스패스에만 2.13.1 을 올려 컴파일만 통과하던 상태였고, 배열 경로에 setText() 하면 운영에서 NoSuchMethodError 가 났다. TextNode.valueOf() 로 감싸 set(int, JsonNode) 에 바인딩한다. 그 외 - TemplateCodeConvertAdapterErrorMsgHandler: 응답 JSON 왕복 지점 전환 - HttpAdapterServiceSupport, HttpClient5AdapterServiceRestAddFilter: 직렬화 지점 전환 - JsonReader: parse() 호출마다 ObjectMapper 를 새로 만들던 것을 static 싱글턴으로 바꾸고 중복 설정 코드를 JacksonUtil 에 위임 - MessageKeyExtractor: 선언만 하고 쓰지 않는 ObjectMapper 필드 제거 - build.gradle: 미사용 jackson-dataformat-xml 제거 (common 은 Ignite 경유로 jackson 확보) 검증: gradlew compileJava BUILD SUCCESSFUL, databind 2.12.7 런타임에서 숫자 왕복 4건 + setText 배열/객체/미존재 경로 5건 전부 통과. --- build.gradle | 6 +++-- ...lateCodeConvertAdapterErrorMsgHandler.java | 4 ++- ...ttpClient5AdapterServiceRestAddFilter.java | 4 ++- .../dynamic/HttpAdapterServiceSupport.java | 4 ++- .../eactive/eai/common/util/JacksonUtil.java | 26 ++++++++++++++----- .../eai/common/util/MessageKeyExtractor.java | 3 --- .../eai/message/parser/JsonReader.java | 16 +++++------- 7 files changed, 40 insertions(+), 23 deletions(-) diff --git a/build.gradle b/build.gradle index 9cfa9cc..7dfd3df 100644 --- a/build.gradle +++ b/build.gradle @@ -141,8 +141,10 @@ dependencies { compileOnly group: 'javax.servlet.jsp', name: 'javax.servlet.jsp-api', version: '2.3.3' compileOnly 'javax.resource:javax.resource-api:1.7' compileOnly 'javax.jms:javax.jms-api:2.0.1' - - compileOnly group: 'com.fasterxml.jackson.dataformat', name: 'jackson-dataformat-xml', version: '2.13.1' + + // jackson-dataformat-xml 제거 (2026-08-27) + // XmlMapper/JacksonXml* 사용처가 전 소스에 0건이고, 선언 버전(2.13.1)이 + // 실제 해석되는 jackson-core/databind(2.12.7)와 마이너 불일치라 승격 시 위험했다. api "org.springframework.security:spring-security-jwt:1.1.1.RELEASE" diff --git a/src/main/java/com/eactive/eai/adapter/handler/TemplateCodeConvertAdapterErrorMsgHandler.java b/src/main/java/com/eactive/eai/adapter/handler/TemplateCodeConvertAdapterErrorMsgHandler.java index fb33f04..43a32c5 100644 --- a/src/main/java/com/eactive/eai/adapter/handler/TemplateCodeConvertAdapterErrorMsgHandler.java +++ b/src/main/java/com/eactive/eai/adapter/handler/TemplateCodeConvertAdapterErrorMsgHandler.java @@ -6,6 +6,7 @@ import org.apache.commons.lang3.StringUtils; import com.eactive.eai.common.message.EAIMessage; import com.eactive.eai.common.property.PropManager; +import com.eactive.eai.common.util.JacksonUtil; import com.eactive.eai.common.util.Logger; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; @@ -19,7 +20,8 @@ public class TemplateCodeConvertAdapterErrorMsgHandler extends TemplateAdapterEr /** PropManager 에서 코드 변환 설정을 조회할 프로퍼티 그룹 이름 */ static final String PROP_GROUP = "AdapterErrorMessageHandler{CODE_CONVERT}"; - private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + // 기본 ObjectMapper 는 응답 JSON 왕복에서 100000000.00 을 1.0E8 로 바꿔버린다. + private static final ObjectMapper OBJECT_MAPPER = JacksonUtil.newNumberSafeMapper(); @Override public Object generateNonStandardErrorResponseMessage( diff --git a/src/main/java/com/eactive/eai/adapter/http/client/impl/HttpClient5AdapterServiceRestAddFilter.java b/src/main/java/com/eactive/eai/adapter/http/client/impl/HttpClient5AdapterServiceRestAddFilter.java index d9979c6..93c616a 100644 --- a/src/main/java/com/eactive/eai/adapter/http/client/impl/HttpClient5AdapterServiceRestAddFilter.java +++ b/src/main/java/com/eactive/eai/adapter/http/client/impl/HttpClient5AdapterServiceRestAddFilter.java @@ -9,6 +9,7 @@ import org.json.simple.JSONObject; import com.eactive.eai.adapter.http.client.HttpClientAdapterServiceKey; import com.eactive.eai.adapter.http.client.impl.filter.HttpClient5AdapterFilterFactory; import com.eactive.eai.adapter.http.client.impl.filter.HttpClientAdapterFilter; +import com.eactive.eai.common.util.JacksonUtil; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ObjectNode; @@ -37,7 +38,8 @@ public class HttpClient5AdapterServiceRestAddFilter extends HttpClient5AdapterSe // // Adapter에서 Exception이 발생한 경우, 처리할 필터 // static final String EXCEPTION_FILTER = "EXCEPTION_FILTER"; - private ObjectMapper mapper = new ObjectMapper(); + // 기본 ObjectMapper 는 JsonNode 직렬화 시 작은 소수를 1.2E-7 로 바꿔버린다. + private ObjectMapper mapper = JacksonUtil.newNumberSafeMapper(); /** * 1. 기능 : HttpClient 호출 전후 Filter 적용용 2. 처리 개요 :
diff --git a/src/main/java/com/eactive/eai/adapter/http/dynamic/HttpAdapterServiceSupport.java b/src/main/java/com/eactive/eai/adapter/http/dynamic/HttpAdapterServiceSupport.java index e8cebdb..c4c13b9 100644 --- a/src/main/java/com/eactive/eai/adapter/http/dynamic/HttpAdapterServiceSupport.java +++ b/src/main/java/com/eactive/eai/adapter/http/dynamic/HttpAdapterServiceSupport.java @@ -19,6 +19,7 @@ import com.eactive.eai.adapter.http.dynamic.filter.HttpAdapterFilterType; import com.eactive.eai.adapter.http.dynamic.filter.JwtAuthException; import com.eactive.eai.common.TransactionContextKeys; import com.eactive.eai.common.server.EAIServerManager; +import com.eactive.eai.common.util.JacksonUtil; import com.eactive.eai.common.util.Logger; import com.eactive.eai.common.util.TxSiftContext; import com.eactive.eai.common.util.UUIDGenerator; @@ -31,7 +32,8 @@ public abstract class HttpAdapterServiceSupport implements HttpAdapterService, H public static final String HEADER_NAME_CLIENT_ID = "x-elink-client-id"; public static final String PROPERTIES_NAME_CLIENT_ID = "clientId"; static Logger logger = Logger.getLogger(Logger.LOGGER_ADAPTER); - private ObjectMapper mapper = new ObjectMapper(); + // 기본 ObjectMapper 는 JsonNode 직렬화 시 작은 소수를 1.2E-7 로 바꿔버린다. + private ObjectMapper mapper = JacksonUtil.newNumberSafeMapper(); EAIServerManager eaiServerManager; String instid = null; diff --git a/src/main/java/com/eactive/eai/common/util/JacksonUtil.java b/src/main/java/com/eactive/eai/common/util/JacksonUtil.java index 4bdce14..143e03b 100644 --- a/src/main/java/com/eactive/eai/common/util/JacksonUtil.java +++ b/src/main/java/com/eactive/eai/common/util/JacksonUtil.java @@ -5,6 +5,7 @@ import java.util.List; import java.util.regex.Matcher; import java.util.regex.Pattern; +import com.fasterxml.jackson.core.JsonGenerator; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.JsonMappingException; @@ -13,6 +14,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.JsonNodeFactory; import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fasterxml.jackson.databind.node.TextNode; import org.json.simple.JSONObject; @@ -42,16 +44,26 @@ public final class JacksonUtil { /** * JSON 숫자를 double 로 좁히지 않고 BigDecimal 로, 수신한 자릿수 그대로 유지하는 ObjectMapper. * - * readTree() 로 파싱한 뒤 writeValueAsString() 으로 다시 문자열을 만드는 왕복에서, - * 기본 설정이면 100000000.00 이 1.0E8 로 변형된다. - * USE_BIG_DECIMAL_FOR_FLOATS 만 켜고 withExactBigDecimals(true) 를 빼면 기본 - * JsonNodeFactory 가 stripTrailingZeros() 를 적용해 scale 이 음수가 되어 1E+8 이 된다. - * 두 옵션을 함께 켜야 수신한 값이 그대로 보존된다. + * 입력측(파싱) - 2개 옵션이 함께 필요하다. + * readTree() 로 파싱한 뒤 writeValueAsString() 으로 다시 문자열을 만드는 왕복에서, + * 기본 설정이면 100000000.00 이 1.0E8 로 변형된다. + * USE_BIG_DECIMAL_FOR_FLOATS 만 켜고 withExactBigDecimals(true) 를 빼면 기본 + * JsonNodeFactory 가 stripTrailingZeros() 를 적용해 scale 이 음수가 되어 1E+8 이 된다. + * 두 옵션을 함께 켜야 수신한 값이 그대로 보존된다. + * + * 출력측(직렬화) - WRITE_BIGDECIMAL_AS_PLAIN 이 추가로 필요하다. + * DecimalNode 직렬화는 결국 BigDecimal.toString() 이고, 이것은 + * scale 이 음수이거나 adjusted exponent 가 -6 미만일 때 지수 표기를 쓴다. + * 즉 위 2개 옵션으로 파싱을 제대로 해도, 내보낼 때 작은 소수가 깨진다. + * 0.00000012 -> 1.2E-7 , -0.0000005 -> -5E-7 + * 금액처럼 scale 이 0 이상인 큰 값은 영향이 없지만, 이율/환율은 깨진다. + * (Jackson 2.12.7 실측, 2026-08-27) */ public static ObjectMapper newNumberSafeMapper() { ObjectMapper objectMapper = new ObjectMapper(); objectMapper.enable(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS); objectMapper.setNodeFactory(JsonNodeFactory.withExactBigDecimals(true)); + objectMapper.enable(JsonGenerator.Feature.WRITE_BIGDECIMAL_AS_PLAIN); return objectMapper; } @@ -256,7 +268,9 @@ public final class JacksonUtil { if (target == null || !target.isArray() || lastIdx < 0 || lastIdx >= target.size()) { return false; } - ((ArrayNode) target).set(lastIdx, value); + // set(int, String) 오버로드는 jackson-databind 2.13 부터다. 실제 런타임은 2.12.7 이므로 + // TextNode 로 감싸 set(int, JsonNode) 에 바인딩해야 NoSuchMethodError 가 나지 않는다. + ((ArrayNode) target).set(lastIdx, TextNode.valueOf(value)); return true; } else { // 객체 필드 값 교체 (target은 fieldName으로 이미 이동된 상태이므로, parent 기준 재설정 필요) diff --git a/src/main/java/com/eactive/eai/common/util/MessageKeyExtractor.java b/src/main/java/com/eactive/eai/common/util/MessageKeyExtractor.java index 07e8e96..a41a4ef 100644 --- a/src/main/java/com/eactive/eai/common/util/MessageKeyExtractor.java +++ b/src/main/java/com/eactive/eai/common/util/MessageKeyExtractor.java @@ -9,7 +9,6 @@ import com.eactive.eai.common.messagekey.MessageKeyGroupVO; import com.eactive.eai.common.messagekey.MessageKeyManager; import com.eactive.eai.common.messagekey.MessageKeyVO; import com.eactive.eai.transformer.message.ISO8583MessageFactory; -import com.fasterxml.jackson.databind.ObjectMapper; import com.solab.iso8583.IsoMessage; import org.apache.commons.lang3.StringUtils; import org.json.simple.JSONValue; @@ -23,8 +22,6 @@ public final class MessageKeyExtractor { static Logger logger = Logger.getLogger(Logger.LOGGER_DEFAULT); - private static final ObjectMapper objectMapper = new ObjectMapper(); - /** * Private 생성자 * Instance를 생성하지 못함 diff --git a/src/main/java/com/eactive/eai/message/parser/JsonReader.java b/src/main/java/com/eactive/eai/message/parser/JsonReader.java index a25aeb5..e27d9c9 100644 --- a/src/main/java/com/eactive/eai/message/parser/JsonReader.java +++ b/src/main/java/com/eactive/eai/message/parser/JsonReader.java @@ -8,21 +8,25 @@ import org.apache.commons.lang3.StringUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import com.eactive.eai.common.util.JacksonUtil; import com.eactive.eai.message.EncodingVar; import com.eactive.eai.message.StandardItem; import com.eactive.eai.message.StandardMessage; import com.eactive.eai.message.StandardType; import com.fasterxml.jackson.core.JsonFactory; import com.fasterxml.jackson.core.JsonParser; -import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ArrayNode; -import com.fasterxml.jackson.databind.node.JsonNodeFactory; import com.fasterxml.jackson.databind.node.JsonNodeType; public class JsonReader implements StandardReader { static Logger logger = LoggerFactory.getLogger(JsonReader.class); + + // ObjectMapper 는 설정이 끝나면 thread-safe 하고 생성 비용이 크므로 재사용한다. + // 숫자 보존 옵션(입력/출력)은 JacksonUtil.newNumberSafeMapper() 참조. + private static final ObjectMapper MAPPER = JacksonUtil.newNumberSafeMapper(); + private char FIELD_SEPARATOR = '.'; private boolean ZERO_BASE_INDEX = true; @@ -39,15 +43,9 @@ public class JsonReader implements StandardReader { } JsonNode jsonNode = null; - ObjectMapper mapper = null; + ObjectMapper mapper = MAPPER; JsonFactory factory = null; JsonParser parser = null; - mapper = new ObjectMapper(); - mapper.enable(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS); - // USE_BIG_DECIMAL_FOR_FLOATS 만 켜면 기본 JsonNodeFactory 가 DecimalNode 생성 시 - // stripTrailingZeros() 를 적용해 scale 이 음수가 되고, 그 결과 - // 100000000.00 이 1E+8 로 변형된다. exact 모드로 수신한 자릿수를 보존한다. - mapper.setNodeFactory(JsonNodeFactory.withExactBigDecimals(true)); factory = mapper.getFactory(); try { parser = factory.createParser(jsonString);