78606514ad
상대 시스템이 0 패딩된 코드값을 따옴표 없이(=JSON 숫자로) 보내면 표준 파서가 "Invalid numeric value: Leading zeroes not allowed" 로 전문 전체를 거부한다. 전문을 못 읽고 실패하는 것보다 값을 받아들이는 쪽이 낫다고 판단해 ALLOW_LEADING_ZEROS_FOR_NUMBERS 를 켠다. newNumberSafeMapper() 한 곳에 넣어 이를 쓰는 전 경로(JsonReader 표준전문, JacksonUtil.OBJECT_MAPPER 를 타는 어댑터/필터)를 함께 덮는다. WRITE_BIGDECIMAL_AS_PLAIN 을 여기 넣었던 것과 같은 이유다. 주의: 이 옵션은 00001 을 숫자 1 로 만든다(선행 0 미보존). 항목이 NUMBER/LL_NUMBER 로 선언돼 있으면 StandardItem.toTypeValue() 가 어차피 선행 0 을 깎으므로 결과가 같고, STRING/ZZ_STRING 선언이면 자릿수가 사라진다. 후자는 상대에게 따옴표를 붙여 보내달라고 요청하는 것이 정답이다. Jackson 2.12.7 실측: 선행 0 파싱 8종, 숫자 정밀도 왕복 5종, 제어문자 정규화 조합, 과허용 여부(+1 / .5 / 무따옴표키 / 홑따옴표는 여전히 거부) 19건 PASS. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Phh6MSGvgHM3rPgGUU3dzR
413 lines
15 KiB
Java
413 lines
15 KiB
Java
package com.eactive.eai.common.util;
|
|
|
|
import java.util.ArrayList;
|
|
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.core.json.JsonReadFeature;
|
|
import com.fasterxml.jackson.databind.DeserializationFeature;
|
|
import com.fasterxml.jackson.databind.JsonMappingException;
|
|
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.ObjectNode;
|
|
import com.fasterxml.jackson.databind.node.TextNode;
|
|
|
|
import org.json.simple.JSONObject;
|
|
|
|
/**
|
|
* Jackson JsonNode 범용 접근 유틸리티
|
|
*
|
|
* "." 구분자로 필드 단계를 이동하고, "[n]" 으로 배열 인덱스에 접근하는
|
|
* path 표현식을 지원한다.
|
|
*
|
|
* 사용 예:
|
|
* JacksonUtil.getText(root, "term_agreements[0].is_agreed")
|
|
* JacksonUtil.getBoolean(root, "term_agreements[0].is_agreed", false)
|
|
* JacksonUtil.getNode(root, "data.list[2].child[0].name")
|
|
*
|
|
* 경로 중간에 필드가 없거나, 배열 인덱스가 범위를 벗어나거나,
|
|
* 배열이 아닌 노드에 인덱스 접근을 시도하는 경우 모두
|
|
* 예외를 던지지 않고 null / 기본값을 반환한다.
|
|
*/
|
|
public final class JacksonUtil {
|
|
|
|
/** "fieldName" 또는 "fieldName[0]" 형태의 토큰을 분해하는 패턴 */
|
|
private static final Pattern TOKEN_PATTERN = Pattern.compile("([^\\[\\]]*)((?:\\[\\d+\\])*)");
|
|
private static final Pattern INDEX_PATTERN = Pattern.compile("\\[(\\d+)\\]");
|
|
|
|
private static final ObjectMapper OBJECT_MAPPER = newNumberSafeMapper();
|
|
|
|
/**
|
|
* JSON 숫자를 double 로 좁히지 않고 BigDecimal 로, 수신한 자릿수 그대로 유지하는 ObjectMapper.
|
|
*
|
|
* 입력측(파싱) - 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)
|
|
*
|
|
* 선행 0 허용 - ALLOW_LEADING_ZEROS_FOR_NUMBERS.
|
|
* JSON 표준은 숫자의 선행 0 을 금지하므로, 상대가 0 패딩된 코드값을 따옴표 없이 보내면
|
|
* 파서가 아래 오류로 거부한다.
|
|
* Invalid numeric value: Leading zeroes not allowed
|
|
* 전문 자체를 못 읽고 실패하는 것보다 값을 받아들이는 쪽이 낫다고 판단해 옵션으로 허용한다.
|
|
* ⚠ 이 옵션은 00001 을 숫자 1 로 만든다. 즉 선행 0 은 보존되지 않는다.
|
|
* - 표준전문 항목이 NUMBER/LL_NUMBER 로 선언돼 있으면 StandardItem.toTypeValue() 가
|
|
* 어차피 선행 0 을 깎으므로 결과가 같다.
|
|
* - STRING/ZZ_STRING 으로 선언된 0 패딩 코드값이라면 자릿수가 사라진다.
|
|
* 그런 항목은 상대에게 따옴표를 붙여 보내달라고 요청하는 것이 정답이다.
|
|
* (Jackson 2.12.7 실측, 2026-08-28)
|
|
*/
|
|
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);
|
|
objectMapper.getFactory().configure(
|
|
JsonReadFeature.ALLOW_LEADING_ZEROS_FOR_NUMBERS.mappedFeature(), true);
|
|
return objectMapper;
|
|
}
|
|
|
|
/**
|
|
* JSON 문자열 리터럴 안의 이스케이프되지 않은 제어문자(0x00~0x1F)를 JSON 이스케이프로 바꾼다.
|
|
*
|
|
* JSON 표준은 문자열 안의 제어문자를 반드시 이스케이프하도록 요구하므로, 파서는 raw 개행 등을
|
|
* 만나면 아래 오류로 파싱을 거부한다.
|
|
* Illegal unquoted character ((CTRL-CHAR, code 10)): has to be escaped using backslash
|
|
* 그런데 연동 상대 시스템들이 개행을 이스케이프하지 않고 그대로 보내는 사례가 있다.
|
|
* 파서 옵션(ALLOW_UNESCAPED_CONTROL_CHARS)으로 푸는 대신, 입력을 표준 JSON 으로
|
|
* 정규화해서 파서는 strict 로 유지한다.
|
|
*
|
|
* 중요: 문자열 리터럴 "안" 에 있는 것만 바꾼다. JSON 은 토큰 사이의 개행/탭을 공백으로
|
|
* 허용하므로, 구조적 공백까지 치환하면 pretty-print 된 JSON 이 오히려 깨진다.
|
|
*
|
|
* 값 자체는 보존된다. raw 개행은 \n 으로 바뀌어 파싱 후 다시 개행 문자가 된다.
|
|
* 제어문자가 데이터가 아니라 쓰레기 값(고정길이 전문의 0x00 패딩 등)이라면
|
|
* 이 메서드에 의존하지 말고 파싱 전에 제거해야 한다.
|
|
*
|
|
* @param json 원본 JSON 문자열. null 이면 null 반환
|
|
* @return 제어문자가 이스케이프된 JSON. 바꿀 게 없으면 원본을 그대로 반환
|
|
*/
|
|
public static String escapeControlChars(String json) {
|
|
if (json == null) {
|
|
return null;
|
|
}
|
|
|
|
// 빠른 경로: 제어문자가 아예 없으면 원본 그대로 (대부분의 전문이 여기 해당)
|
|
boolean found = false;
|
|
for (int i = 0; i < json.length(); i++) {
|
|
if (json.charAt(i) < 0x20) {
|
|
found = true;
|
|
break;
|
|
}
|
|
}
|
|
if (!found) {
|
|
return json;
|
|
}
|
|
|
|
StringBuilder sb = new StringBuilder(json.length() + 16);
|
|
boolean inString = false;
|
|
boolean escaped = false;
|
|
|
|
for (int i = 0; i < json.length(); i++) {
|
|
char c = json.charAt(i);
|
|
|
|
if (!inString) {
|
|
// 문자열 밖: 구조적 공백(개행/탭 등)은 건드리지 않는다
|
|
if (c == '"') {
|
|
inString = true;
|
|
}
|
|
sb.append(c);
|
|
continue;
|
|
}
|
|
|
|
if (escaped) {
|
|
// 백슬래시 뒤 한 글자는 그대로 통과 (이스케이프된 따옴표/백슬래시,
|
|
// 유니코드 이스케이프의 선두 u 등). 이미 이스케이프된 것은 건드리지 않는다.
|
|
sb.append(c);
|
|
escaped = false;
|
|
continue;
|
|
}
|
|
if (c == '\\') {
|
|
sb.append(c);
|
|
escaped = true;
|
|
continue;
|
|
}
|
|
if (c == '"') {
|
|
sb.append(c);
|
|
inString = false;
|
|
continue;
|
|
}
|
|
if (c < 0x20) {
|
|
switch (c) {
|
|
case '\n': sb.append("\\n"); break;
|
|
case '\r': sb.append("\\r"); break;
|
|
case '\t': sb.append("\\t"); break;
|
|
case '\b': sb.append("\\b"); break;
|
|
case '\f': sb.append("\\f"); break;
|
|
default: sb.append(String.format("\\u%04x", (int) c)); break;
|
|
}
|
|
continue;
|
|
}
|
|
sb.append(c);
|
|
}
|
|
return sb.toString();
|
|
}
|
|
|
|
private JacksonUtil() {
|
|
// 인스턴스화 방지
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// 기본 트리 탐색
|
|
// ------------------------------------------------------------------
|
|
|
|
/**
|
|
* Object(JSONObject/String/JsonNode 등)를 JsonNode로 변환한다.
|
|
* KakaopayFilter.readTree()를 일반화한 버전.
|
|
*/
|
|
public static JsonNode readTree(Object jsonData, ObjectMapper objectMapper)
|
|
throws JsonMappingException, JsonProcessingException {
|
|
|
|
if (jsonData == null) {
|
|
return null;
|
|
}
|
|
|
|
if (jsonData instanceof JsonNode) {
|
|
return (JsonNode) jsonData;
|
|
}
|
|
|
|
String jsonStr = null;
|
|
|
|
if (jsonData instanceof JSONObject) {
|
|
jsonStr = ((JSONObject) jsonData).toJSONString();
|
|
} else if (jsonData instanceof String) {
|
|
jsonStr = (String) jsonData;
|
|
} else {
|
|
// 그 외 POJO 등은 writeValueAsString을 통해 변환
|
|
jsonStr = objectMapper.writeValueAsString(jsonData);
|
|
}
|
|
|
|
if (jsonStr == null) {
|
|
return null;
|
|
}
|
|
|
|
// 상대 시스템이 제어문자를 이스케이프하지 않고 보내는 경우가 있어 정규화 후 파싱한다.
|
|
// 이미 표준을 지킨 JSON 이면 원본을 그대로 반환하므로 사실상 무해하다.
|
|
return objectMapper.readTree(escapeControlChars(jsonStr));
|
|
}
|
|
|
|
public static JsonNode readTree(Object jsonData) throws JsonMappingException, JsonProcessingException {
|
|
return readTree(jsonData, OBJECT_MAPPER);
|
|
}
|
|
|
|
public static String writeAsString(JsonNode node) throws JsonProcessingException {
|
|
return OBJECT_MAPPER.writeValueAsString(node);
|
|
}
|
|
|
|
public static ObjectNode createObjectNode() {
|
|
return OBJECT_MAPPER.createObjectNode();
|
|
}
|
|
|
|
/**
|
|
* path 표현식으로 JsonNode를 탐색한다.
|
|
* 경로가 존재하지 않으면 null을 반환한다 (MissingNode가 아닌 진짜 null).
|
|
*
|
|
* @param root 탐색을 시작할 JsonNode
|
|
* @param path 예: "term_agreements[0].is_agreed", "data.list[2].name"
|
|
*/
|
|
public static JsonNode getNode(JsonNode root, String path) {
|
|
if (root == null || path == null || path.isEmpty()) {
|
|
return null;
|
|
}
|
|
|
|
JsonNode current = root;
|
|
|
|
for (String rawToken : path.split("\\.")) {
|
|
if (current == null || current.isMissingNode() || current.isNull()) {
|
|
return null;
|
|
}
|
|
|
|
String fieldName = extractFieldName(rawToken);
|
|
List<Integer> indices = extractIndices(rawToken);
|
|
|
|
// 필드명이 있으면 먼저 필드로 이동 (빈 문자열이면 현재 노드 유지 - 최상위 배열 접근용)
|
|
if (!fieldName.isEmpty()) {
|
|
if (!current.has(fieldName)) {
|
|
return null;
|
|
}
|
|
current = current.get(fieldName);
|
|
}
|
|
|
|
// 이어지는 [n][m]... 인덱스를 순서대로 적용
|
|
for (Integer idx : indices) {
|
|
if (current == null || !current.isArray() || idx < 0 || idx >= current.size()) {
|
|
return null;
|
|
}
|
|
current = current.get(idx);
|
|
}
|
|
}
|
|
|
|
return current;
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// 타입별 getter (전부 null-safe, 기본값 지원)
|
|
// ------------------------------------------------------------------
|
|
|
|
public static String getText(JsonNode root, String path) {
|
|
return getText(root, path, null);
|
|
}
|
|
|
|
public static String getText(JsonNode root, String path, String defaultValue) {
|
|
JsonNode node = getNode(root, path);
|
|
return (node == null || node.isMissingNode() || node.isNull()) ? defaultValue : node.asText(defaultValue);
|
|
}
|
|
|
|
public static boolean getBoolean(JsonNode root, String path, boolean defaultValue) {
|
|
JsonNode node = getNode(root, path);
|
|
return (node == null || node.isMissingNode() || node.isNull()) ? defaultValue : node.asBoolean(defaultValue);
|
|
}
|
|
|
|
public static int getInt(JsonNode root, String path, int defaultValue) {
|
|
JsonNode node = getNode(root, path);
|
|
return (node == null || node.isMissingNode() || node.isNull()) ? defaultValue : node.asInt(defaultValue);
|
|
}
|
|
|
|
public static long getLong(JsonNode root, String path, long defaultValue) {
|
|
JsonNode node = getNode(root, path);
|
|
return (node == null || node.isMissingNode() || node.isNull()) ? defaultValue : node.asLong(defaultValue);
|
|
}
|
|
|
|
public static double getDouble(JsonNode root, String path, double defaultValue) {
|
|
JsonNode node = getNode(root, path);
|
|
return (node == null || node.isMissingNode() || node.isNull()) ? defaultValue : node.asDouble(defaultValue);
|
|
}
|
|
|
|
/** path가 가리키는 노드가 실제로 존재하는지 (null/missing이 아닌지) */
|
|
public static boolean exists(JsonNode root, String path) {
|
|
JsonNode node = getNode(root, path);
|
|
return node != null && !node.isMissingNode() && !node.isNull();
|
|
}
|
|
|
|
/** path가 가리키는 노드가 배열일 때 그 크기를 반환, 배열이 아니거나 없으면 -1 */
|
|
public static int size(JsonNode root, String path) {
|
|
JsonNode node = getNode(root, path);
|
|
return (node != null && node.isArray()) ? node.size() : -1;
|
|
}
|
|
|
|
/** path가 가리키는 ArrayNode를 List<JsonNode>로 반환, 없으면 빈 리스트 */
|
|
public static List<JsonNode> getList(JsonNode root, String path) {
|
|
List<JsonNode> result = new ArrayList<>();
|
|
JsonNode node = getNode(root, path);
|
|
if (node != null && node.isArray()) {
|
|
for (JsonNode item : node) {
|
|
result.add(item);
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// 값 설정 (필요 시 사용 - 존재하는 경로에 대해서만 동작)
|
|
// ------------------------------------------------------------------
|
|
|
|
/**
|
|
* path가 가리키는 위치의 텍스트 값을 변경한다.
|
|
* 부모 컨테이너(ObjectNode/ArrayNode)가 존재해야 하며, 중간 경로가 없으면 false를 반환한다.
|
|
* (자동으로 중간 경로를 생성하지는 않음)
|
|
*/
|
|
public static boolean setText(JsonNode root, String path, String value) {
|
|
return setValue(root, path, value);
|
|
}
|
|
|
|
private static boolean setValue(JsonNode root, String path, String value) {
|
|
int lastDot = path.lastIndexOf('.');
|
|
String parentPath = (lastDot == -1) ? "" : path.substring(0, lastDot);
|
|
String lastToken = (lastDot == -1) ? path : path.substring(lastDot + 1);
|
|
|
|
JsonNode parent = parentPath.isEmpty() ? root : getNode(root, parentPath);
|
|
if (parent == null) {
|
|
return false;
|
|
}
|
|
|
|
String fieldName = extractFieldName(lastToken);
|
|
List<Integer> indices = extractIndices(lastToken);
|
|
|
|
JsonNode target = parent;
|
|
if (!fieldName.isEmpty()) {
|
|
if (!target.has(fieldName)) {
|
|
return false;
|
|
}
|
|
target = target.get(fieldName);
|
|
}
|
|
|
|
// 마지막 인덱스 전까지 이동
|
|
for (int i = 0; i < indices.size() - 1; i++) {
|
|
int idx = indices.get(i);
|
|
if (target == null || !target.isArray() || idx < 0 || idx >= target.size()) {
|
|
return false;
|
|
}
|
|
target = target.get(idx);
|
|
}
|
|
|
|
if (!indices.isEmpty()) {
|
|
// 배열의 특정 인덱스 값 교체
|
|
int lastIdx = indices.get(indices.size() - 1);
|
|
if (target == null || !target.isArray() || lastIdx < 0 || lastIdx >= target.size()) {
|
|
return false;
|
|
}
|
|
// 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 기준 재설정 필요)
|
|
if (parent.isObject() && fieldName != null && !fieldName.isEmpty()) {
|
|
((ObjectNode) parent).put(fieldName, value);
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// 내부 파싱 헬퍼
|
|
// ------------------------------------------------------------------
|
|
|
|
/** "term_agreements[0]" -> "term_agreements" / "[0]" -> "" */
|
|
private static String extractFieldName(String token) {
|
|
Matcher m = TOKEN_PATTERN.matcher(token);
|
|
if (m.matches()) {
|
|
return m.group(1) == null ? "" : m.group(1);
|
|
}
|
|
return token;
|
|
}
|
|
|
|
/** "term_agreements[0][1]" -> [0, 1] / "term_agreements" -> [] */
|
|
private static List<Integer> extractIndices(String token) {
|
|
List<Integer> indices = new ArrayList<>();
|
|
Matcher m = INDEX_PATTERN.matcher(token);
|
|
while (m.find()) {
|
|
indices.add(Integer.parseInt(m.group(1)));
|
|
}
|
|
return indices;
|
|
}
|
|
|
|
|
|
}
|