BlockChain

깨진 한글을 되살리는 코드 87줄을 지웠다

  • #BlockChain
  • #AI
  • #Claude Code
  • #Java
  • #인코딩

문제: 감사 로그의 한글이 깨져 보였다

  • 감사 로그(누가 어떤 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줄이 남았다. 매퍼 어노테이션을 repairAndTruncateDatatruncateData로 되돌린 것뿐이다.

살아남은 건 맨 처음의 저장 시점 charset 명시 하나다. 처음부터 그것만 옳았다.

남는 교훈

손실된 정보는 복원되지 않는다.

  • 인코딩이 어긋난 채로 저장되면 바이트 일부가 U+FFFD로 뭉개진다. 이건 되돌릴 수 없다
  • 되돌릴 수 없는 걸 되돌리려 하면 남는 건 휴리스틱뿐이고, 휴리스틱은 반례가 나올 때마다 커진다
  • 정직한 선택지는 두 개였다 — 과거 데이터를 마이그레이션하거나, 그냥 원본을 보여주거나. 나는 세 번째(화면에서 되살리기)를 골랐고 그게 틀렸다

복구 로직의 실패는 조용하다.

  • 문자 4개 → 유니코드 범위 전체로 간 순간, 대상은 “깨진 데이터”에서 “라틴 문자가 든 모든 데이터”로 바뀌었다
  • 정상 데이터를 깨진 것으로 오인해 변환해도 아무도 예외를 못 받는다. 그래서 조건이 넓어지는 걸 눈치채기 어렵다
  • 원본을 그냥 보여주는 쪽은 최소한 거짓말은 하지 않는다

“저장은 고쳤는데 화면이 그대로다”는 데이터 문제지 코드 문제가 아니다.

  • 원인을 고치는 코드는 한 번이면 끝난다 — charset 명시가 그랬다
  • 이걸 코드로 풀려고 한 순간부터 네 번의 덧댐이 예약돼 있었다
  • 5분 사이에 두 번 키운 것이 그 신호였는데, 당시엔 “잘 고쳐지고 있다”고 느꼈다