JsonReader: 이스케이프되지 않은 제어문자를 정규화 후 파싱
상대 시스템이 개행을 이스케이프하지 않고 그대로 보내는 경우 "Illegal unquoted character ((CTRL-CHAR, code 10))" 로 파싱이 실패했다. 파서 옵션(ALLOW_UNESCAPED_CONTROL_CHARS)으로 푸는 대신, 입력을 표준 JSON 으로 정규화하는 JacksonUtil.escapeControlChars() 를 추가하고 파서는 strict 로 유지한다. 문자열 리터럴 "안" 의 제어문자만 바꾼다. JSON 은 토큰 사이의 개행/탭을 공백으로 허용하므로 구조적 공백까지 치환하면 pretty-print 된 JSON 이 오히려 깨진다.
This commit is contained in:
@@ -67,6 +67,92 @@ public final class JacksonUtil {
|
||||
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() {
|
||||
// 인스턴스화 방지
|
||||
}
|
||||
|
||||
@@ -41,7 +41,13 @@ public class JsonReader implements StandardReader {
|
||||
} else {
|
||||
jsonString = (String) obj;
|
||||
}
|
||||
|
||||
|
||||
// 상대 시스템이 개행 등 제어문자를 이스케이프하지 않고 그대로 보내는 경우가 있다.
|
||||
// 그대로 파싱하면 "Illegal unquoted character ((CTRL-CHAR, code 10))" 로 실패하므로
|
||||
// 문자열 리터럴 안의 제어문자만 표준 이스케이프로 정규화한 뒤 파싱한다.
|
||||
// (파서 옵션으로 푸는 대신 입력을 표준 JSON 으로 맞추는 방식)
|
||||
jsonString = JacksonUtil.escapeControlChars(jsonString);
|
||||
|
||||
JsonNode jsonNode = null;
|
||||
ObjectMapper mapper = MAPPER;
|
||||
JsonFactory factory = null;
|
||||
|
||||
Reference in New Issue
Block a user