제어문자 섞인 전문 대응: 수신은 정규화, 송신은 정제
수신측 - JacksonUtil.escapeControlChars() 적용 범위 확대
JsonReader 에만 걸려 있어 TemplateCodeConvertAdapterErrorMsgHandler 등
직접 readTree() 하는 경로가 "Illegal unquoted character ((CTRL-CHAR, code 10))"
로 실패했다. JacksonUtil.readTree() 공용 진입점과 payload 를 파싱하는
핸들러/필터에 적용한다.
송신측 - MessageUtil.stripControlChars() 신규
TemplateAdapterErrorMsgHandler.render() 가 Matcher.quoteReplacement() 로만
치환하고 있었다. 이것은 정규식 치환용이라 JSON/XML 이스케이프가 아니어서,
값에 개행이 있으면 깨진 JSON 을 만들어 응답으로 내보냈다. 실제로 위 파싱
오류의 원문도 상대 전문이 아니라 이 렌더 결과였다.
템플릿이 JSON/XML 어느 쪽인지 알 수 없어 포맷별 이스케이프가 불가능하므로
제어문자를 걸러낸다. 탭/개행/CR 은 공백으로, 나머지 제어문자와 DEL 은 제거.
render() 의 스칼라 치환과 renderRow() 의 행 필드 치환에만 적용해
템플릿 서식(들여쓰기/개행)은 보존한다.
검증: gradlew compileJava BUILD SUCCESSFUL.
databind 2.12.7 런타임에서 0x00~0x1F 전수 + 로그 재현 케이스 통과.
This commit is contained in:
@@ -191,7 +191,9 @@ public final class JacksonUtil {
|
||||
return null;
|
||||
}
|
||||
|
||||
return objectMapper.readTree(jsonStr);
|
||||
// 상대 시스템이 제어문자를 이스케이프하지 않고 보내는 경우가 있어 정규화 후 파싱한다.
|
||||
// 이미 표준을 지킨 JSON 이면 원본을 그대로 반환하므로 사실상 무해하다.
|
||||
return objectMapper.readTree(escapeControlChars(jsonStr));
|
||||
}
|
||||
|
||||
public static JsonNode readTree(Object jsonData) throws JsonMappingException, JsonProcessingException {
|
||||
|
||||
@@ -47,6 +47,61 @@ public final class MessageUtil {
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* 문자열에서 제어문자(0x00~0x1F, 0x7F)를 걸러낸다.
|
||||
*
|
||||
* 값이 JSON / XML / 고정길이 전문 중 어디로 나갈지 모르는 자리 - 대표적으로 템플릿 치환 -
|
||||
* 에서 쓴다. 출력 포맷마다 이스케이프 방식이 달라 포맷을 알아야 하는데, 제어문자를 아예
|
||||
* 걷어내면 포맷과 무관하게 안전해진다.
|
||||
*
|
||||
* 각 포맷에서 제어문자가 일으키는 문제:
|
||||
* - JSON : raw 제어문자는 문자열 안에 올 수 없다
|
||||
* ("Illegal unquoted character ((CTRL-CHAR, code 10))")
|
||||
* - XML : 0x09/0x0A/0x0D 를 제외한 제어문자는 문자 참조로도 표현할 수 없어
|
||||
* 수신측 파서가 거부한다
|
||||
* - 전문 : 제어문자도 1바이트를 차지해 고정길이 자리수가 어긋난다
|
||||
*
|
||||
* 처리 규칙
|
||||
* - 탭/개행/캐리지리턴(0x09/0x0A/0x0D) : 구분 의미가 있으므로 공백 1칸으로 치환
|
||||
* - 그 외 제어문자 및 DEL(0x7F) : 제거
|
||||
*
|
||||
* 연속 공백을 합치지는 않는다(CRLF 는 공백 2칸이 된다). 값 변형을 최소화하기 위함이다.
|
||||
*
|
||||
* @param s 원본 문자열. null/빈 문자열이면 그대로 반환
|
||||
* @return 제어문자가 걸러진 문자열. 걸러낼 게 없으면 원본을 그대로 반환
|
||||
*/
|
||||
public static String stripControlChars(String s) {
|
||||
if (s == null || s.isEmpty()) {
|
||||
return s;
|
||||
}
|
||||
|
||||
// 빠른 경로: 제어문자가 없으면 원본 그대로 (대부분의 값이 여기 해당)
|
||||
boolean found = false;
|
||||
for (int i = 0; i < s.length(); i++) {
|
||||
char c = s.charAt(i);
|
||||
if (c < 0x20 || c == 0x7F) {
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!found) {
|
||||
return s;
|
||||
}
|
||||
|
||||
StringBuilder sb = new StringBuilder(s.length());
|
||||
for (int i = 0; i < s.length(); i++) {
|
||||
char c = s.charAt(i);
|
||||
if (c == '\t' || c == '\n' || c == '\r') {
|
||||
sb.append(' ');
|
||||
} else if (c < 0x20 || c == 0x7F) {
|
||||
continue;
|
||||
} else {
|
||||
sb.append(c);
|
||||
}
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
// ASCII Bytes에서 특정길이의 값을 추출하는 Method
|
||||
public static String getAscBytes(byte[] message, int startPos, int length) {
|
||||
if (message == null || message.length < startPos) {
|
||||
|
||||
Reference in New Issue
Block a user