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:
curry772
2026-08-28 13:17:34 +09:00
parent 7677f23767
commit f1b67df867
2 changed files with 93 additions and 1 deletions
@@ -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() {
// 인스턴스화 방지
}
@@ -42,6 +42,12 @@ public class JsonReader implements StandardReader {
jsonString = (String) obj;
}
// 상대 시스템이 개행 등 제어문자를 이스케이프하지 않고 그대로 보내는 경우가 있다.
// 그대로 파싱하면 "Illegal unquoted character ((CTRL-CHAR, code 10))" 로 실패하므로
// 문자열 리터럴 안의 제어문자만 표준 이스케이프로 정규화한 뒤 파싱한다.
// (파서 옵션으로 푸는 대신 입력을 표준 JSON 으로 맞추는 방식)
jsonString = JacksonUtil.escapeControlChars(jsonString);
JsonNode jsonNode = null;
ObjectMapper mapper = MAPPER;
JsonFactory factory = null;