문제: 감사 로그의 한글이 깨져 보였다
- 감사 로그(누가 어떤 API를 언제 호출했는지 남기는 기록) 화면에 한글이
ìë°처럼 표시됨 - 이런 깨짐을 mojibake(모지바케)라고 부른다 — 어떤 인코딩으로 저장한 바이트를 다른 인코딩으로 읽었을 때 나오는 글자
원인은 금방 찾았다.
// 기존 — 플랫폼 기본 charset 에 맡긴다
new String(wrapper.getContentAsByteArray())
-
new String(byte[])은 JVM 기본 charset을 쓴다 - 이 값은 OS·로케일·실행 옵션에 따라 달라진다 — 개발 머신과 서버가 다를 수 있다
- 즉 “어떤 인코딩으로 읽을지”를 코드가 정하지 않고 환경에 위임한 상태
그래서 요청의 인코딩을 우선 쓰고, 없으면 UTF-8로 떨어지도록 명시했다.
private String decodePayload(byte[] payloadBytes, String encoding) {
if (payloadBytes == null || payloadBytes.length == 0) return null;
Charset charset = StandardCharsets.UTF_8;
if (CommonUtils.string.isNotEmpty(encoding)) {
try { charset = Charset.forName(encoding); }
catch (Exception ignored) { /* fallback UTF-8 */ }
}
return new String(payloadBytes, charset);
}
여기까지가 옳은 수정이었다. 그리고 이 글의 나머지는 전부, 이 수정 이후에 내가 잘못한 것들이다.
왜 계속 고치게 됐나
저장을 고쳤는데도 화면은 여전히 깨져 있었다. 당연했다 — DB에 이미 깨진 데이터가 쌓여 있었으니까.
여기서 내린 판단이 문제의 시작이다.
“그럼 조회할 때 되돌려서 보여주자.”
과거 데이터를 마이그레이션하지 않고, 읽는 시점에 복구하기로 한 것이다. 그리고 그 복구 로직은 다음 5분 동안 세 번 자란다.
① 되돌리기 — 한글이 늘어나면 성공으로 친다
private String tryRecoverMojibake(String source) {
// ... null·blank 가드 생략
if (!containsMojibakeSigns(source)) return source;
try {
String candidate = new String(source.getBytes(StandardCharsets.ISO_8859_1),
StandardCharsets.UTF_8);
if (hangulCount(candidate) > hangulCount(source)) return candidate;
} catch (Exception ignored) { }
return source;
}
- 깨진 문자열을 latin1(ISO-8859-1) 바이트로 되돌린 뒤 UTF-8로 다시 읽는다
- 판정 기준이 “한글 글자 수가 늘어났는가” — 즉 휴리스틱(경험적 어림짐작)이다
- 복구 실패 시 U+FFFD(
�)가 3개 이상 연속되면[인코딩 손상 데이터]로 치환
이 시점에 이미 신호가 있었다. 정답을 아는 게 아니라 “더 그럴듯해 보이는 쪽”을 고르고 있었다.
② 이스케이프 해제 — 3분 뒤
한 같은 유니코드 이스케이프가 문자 그대로 남은 케이스가 보였다. 파이프라인 앞에 한 단계를 더 붙였다.
private String unescapeUnicodeLiterals(String source) {
if (source == null || !source.contains("\\u")) return source;
// ... \uXXXX 를 실제 문자로 변환
}
이제 흐름은 이스케이프 해제 → 복구 → 치환 → 자르기 4단계다.
③ 탐지 범위 확대 — 다시 2분 뒤
여전히 안 걸리는 게 있었다. 탐지 조건을 넓혔다.
return source.contains("�")
|| source.contains("Ã")
|| source.contains("â")
- || source.contains("�");
+ || source.contains("�")
+ // UTF-8 한글이 latin1로 잘못 해석되면 ì, ë, í, ê 같은 문자군으로 나타남
+ || source.matches(".*[\\u00C0-\\u00FF].*");
문자열 4개를 나열하던 것에서 U+00C0~U+00FF 범위 전체로 바뀌었다.
- 이 범위에는 é, ü, ñ, ç 같은 정상적인 라틴 문자가 전부 들어있다
- 즉 프랑스어·독일어·스페인어가 섞인 정상 데이터를 mojibake 후보로 집어넣게 된다
- 복구를 시도했다가 오히려 멀쩡한 문자열을 망칠 수 있다
②와 ③은 ①로부터 각각 3분, 5분 뒤 커밋이다. 5분 사이에 복구 로직을 두 번 키웠다 — 한 번은 단계를 붙였고(②), 한 번은 탐지 조건을 넓혔다(③). 안 걸리는 케이스를 볼 때마다 뭔가를 추가하고 있었던 것이다.
④ 고친 것을 다시 고침 — 한 달 뒤
③에서 쓴 String.matches()가 틀렸다.
-
matches()는 문자열 전체가 정규식과 일치해야 true다 (부분 일치가 아니다) - 그리고
.은 기본적으로 개행에 매칭되지 않는다 - 감사 로그 본문에 개행이 들어 있으면 → 항상 false
즉 ③에서 넓힌 조건은 개행이 든 데이터에서는 한 번도 동작하지 않았다. 압축된 JSON처럼 개행이 없으면 동작했으므로 “항상”은 아니지만, 정작 이 조건을 넓힌 이유였던 긴 본문일수록 개행이 있다.
+ Pattern MOJIBAKE_PATTERN = Pattern.compile(
+ "[\\u00C0-\\u00FF]|�|Ã|â|[\\uFFFD]", Pattern.DOTALL);
+
private boolean containsMojibakeSigns(String source) {
+ // String.matches()는 전체 문자열 매칭이라 개행 문제가 있으므로 Pattern.find() 사용
+ Matcher matcher = MOJIBAKE_PATTERN.matcher(source);
+ return matcher.find();
이 수정은 배포 스크립트 재작성이 포함된 대형 커밋 안에 섞여 들어갔다. 나중에 “이 로직이 왜 이렇게 됐지”를 추적할 때 이 지점이 잘 안 보인다.
그래서 전부 지웠다
한 달 뒤, 이 로직을 통째로 삭제했다. 커밋 메시지는 이렇다.
로그 상세/목록에서 인코딩 손상 데이터 치환 제거
- 상세 조회: DB 원본 데이터를 변환 없이 그대로 표시
- 목록 조회: 500자 잘림만 적용, 인코딩 복구/치환 로직 제거
- repairAndTruncateData 메서드 및 관련 코드 삭제
삭제된 것:
| 대상 | 하는 일 |
|---|---|
repairAndTruncateData() |
복구 파이프라인 진입점 |
tryRecoverMojibake() |
latin1 재해석 휴리스틱 |
containsMojibakeSigns() |
깨짐 탐지 |
hangulCount() |
복구 성공 판정용 한글 세기 |
unescapeUnicodeLiterals() |
이스케이프 해제 |
MOJIBAKE_PATTERN |
탐지 정규식 |
87줄이 사라지고 2줄이 남았다. 매퍼 어노테이션을 repairAndTruncateData → truncateData로 되돌린 것뿐이다.
살아남은 건 맨 처음의 저장 시점 charset 명시 하나다. 처음부터 그것만 옳았다.
남는 교훈
손실된 정보는 복원되지 않는다.
- 인코딩이 어긋난 채로 저장되면 바이트 일부가 U+FFFD로 뭉개진다. 이건 되돌릴 수 없다
- 되돌릴 수 없는 걸 되돌리려 하면 남는 건 휴리스틱뿐이고, 휴리스틱은 반례가 나올 때마다 커진다
- 정직한 선택지는 두 개였다 — 과거 데이터를 마이그레이션하거나, 그냥 원본을 보여주거나. 나는 세 번째(화면에서 되살리기)를 골랐고 그게 틀렸다
복구 로직의 실패는 조용하다.
- 문자 4개 → 유니코드 범위 전체로 간 순간, 대상은 “깨진 데이터”에서 “라틴 문자가 든 모든 데이터”로 바뀌었다
- 정상 데이터를 깨진 것으로 오인해 변환해도 아무도 예외를 못 받는다. 그래서 조건이 넓어지는 걸 눈치채기 어렵다
- 원본을 그냥 보여주는 쪽은 최소한 거짓말은 하지 않는다
“저장은 고쳤는데 화면이 그대로다”는 데이터 문제지 코드 문제가 아니다.
- 원인을 고치는 코드는 한 번이면 끝난다 — charset 명시가 그랬다
- 이걸 코드로 풀려고 한 순간부터 네 번의 덧댐이 예약돼 있었다
- 5분 사이에 두 번 키운 것이 그 신호였는데, 당시엔 “잘 고쳐지고 있다”고 느꼈다