LabHub
배우기 러닝패스 코스

고객 데이터 다루기 · 글자가 깨졌다 · 이론

글자가 깨졌다 — 추측하지 말고 판정하라

LabHub 에서 이어서 보기

한 줄 요약

파일에는 인코딩이 적혀 있지 않다. 그래서 인코딩은 알아내는 것이 아니라 후보를 순서대로 배제하며 판정하는 것이고, 그 판정의 마지막 질문은 "이 파일은 되살릴 수 있는가" 다.

왜 이게 필요했나

고객이 보낸 명부를 열었더니 이름 칸이 전부 네모이거나 알 수 없는 기호였다. 여기서 대부분의 사람이 가장 먼저 하는 일은 에디터의 인코딩 메뉴를 하나씩 눌러 보는 것이다. 그 방법으로 맞는 경우도 있지만, 맞았는지 아닌지를 눈으로만 판단하게 된다는 것이 문제다. 파일이 300개면 눈은 쓸 수 없다.

더 곤란한 것은, 열어 보면 멀쩡한데 틀린 파일이 있다는 사실이다. 한글이 또렷하게 보이는데 조회가 안 된다. 이름이 같아 보이는데 조인이 0건이다. 눈으로 보는 판정이 통하지 않는 자리는 늘 이런 곳이다.

텍스트 파일은 바이트의 나열일 뿐이고, 그 바이트를 어떤 표로 읽을지는 파일 안에 적혀 있지 않다. [유니코드 컨소시엄의 UTF-8·BOM 안내](https://www.unicode.org/faq/utf_bom.html)는 규격을 지키는 프로그램이 잘못되거나 규격에서 벗어난 바이트 열을 글자로 해석해서는 안 된다고 못박고, [RFC 3629](https://www.rfc-editor.org/rfc/rfc3629.html)는 어떤 바이트 열이 올바른 UTF-8 인지를 규정한다. 이 엄격함이 우리에게 공짜로 주는 것이 하나 있다. 아무 바이트나 UTF-8 이 되지는 않는다. 그래서 "UTF-8 로 읽힌다" 는 사실 자체가 강한 증거가 된다.

어떻게 동작하나

판정은 네 단계로 내려간다. 위에서부터 확실한 순서다.

1. BOM 을 본다. 파일이 EF BB BF 로 시작하면 UTF-8 이고, 그 세 바이트는 데이터가 아니다. 파이썬에서 이 세 바이트를 떼고 읽는 코덱이 utf-8-sig 다([codecs 문서](https://docs.python.org/3/library/codecs.html)). 이것을 떼지 않고 읽으면 첫 칼럼 이름이 member_id 가 되어 헤더 비교가 조용히 실패한다.
2. 엄격 UTF-8 디코딩을 시도한다. 성공하면 거의 확실히 UTF-8 이다. 한글 한 글자가 3바이트이고 이어지는 바이트의 상위 비트까지 정해져 있어서, 다른 인코딩의 한글 바이트가 우연히 UTF-8 로 통과할 확률은 매우 낮다.
3. 실패하면 후보를 세운다. 한국어 자료라면 CP949 와 EUC-KR 이다. CP949 는 EUC-KR 의 확장이라 EUC-KR 로 읽히는 것은 CP949 로도 읽힌다. 그래서 둘을 구별하는 대신 CP949 로 통일해 읽고, 필요할 때만 구별한다.
4. 표본을 대조한다. 후보로 읽어 낸 글자들이 말이 되는가를 기계가 판정할 잣대를 정한다. 한국어 명부라면 ASCII 를 뺀 글자 가운데 완성형 한글 음절(U+AC00 부터 U+D7A3 까지)의 비율이 잣대가 된다. 이 잣대가 없으면 "디코딩이 에러 없이 끝났다" 만으로 넘어가게 되는데, 그것으로는 모자란다.

그다음이 이 실습의 진짜 주제다. 판정이 끝나면 파일은 세 부류로 갈린다.

바이트  ├─ 올바른 인코딩으로 읽힌다              → 정상. 읽어서 UTF-8 로 통일한다  ├─ 한 번 잘못 읽혀 그대로 굳었다(mojibake) → 잘못 읽은 그 표로 되돌려 다시 읽으면 살아난다  └─ 잘못 읽으면서 글자가 뭉개졌다          → 못 되살린다. 원본을 다시 받아야 한다

가운데가 이중 인코딩이다. UTF-8 바이트를 latin-1 로 읽으면 한글 한 글자가 알파벳 세 글자로 보인다. 이때 바이트는 하나도 잃지 않았다 — latin-1 은 0부터 255까지 모든 바이트에 글자를 하나씩 붙여 둔 표라서 되돌리는 길이 막히지 않는다. 그래서 깨진문자열.encode("latin-1").decode("utf-8") 한 줄로 원문이 돌아온다.

오른쪽이 잃어버린 파일이다. 잘못 읽는 쪽이 관대할 때 이렇게 된다. 읽을 수 없는 바이트를 U+FFFD(대체 문자)로 바꾸고 넘어가면, 서로 다른 바이트 여러 개가 같은 한 글자로 뭉개진다. 그 파일에는 원래 바이트를 알아낼 정보가 남아 있지 않다. 이 구별은 판정이지 노력의 문제가 아니다 — U+FFFD 가 보이면 그 파일은 다시 받아야 한다.

현장에서 만나는 모습

첫째, 보이는 대로 같은데 조인이 0건이다. 한글 "박"은 완성된 음절 하나(U+BC15)로 쓸 수도 있고 자모 세 개(U+1107 U+1161 U+11A8)로 쓸 수도 있다. 화면에는 똑같이 보이지만 다른 문자열이다. macOS 에서 만든 파일 이름과 자료가 특히 자주 이렇다. [UAX #15](https://www.unicode.org/reports/tr15/)가 이 둘을 오가는 정규화를 정의하고, 합치는 쪽이 NFC, 분해하는 쪽이 NFD 다. 비교하기 전에 양쪽 다 한 가지로 정규화해야 한다. 파이썬에서는 [unicodedata.normalize](https://docs.python.org/3/library/unicodedata.html) 다.

둘째, 눈에 안 보이는 글자가 키에 섞인다. 웹 화면에서 복사한 값에는 줄바꿈을 막으려고 넣은 NBSP(U+00A0)나, 줄바꿈 위치를 잡으려고 넣은 제로폭 공백(U+200B)이 딸려 온다. 둘 다 strip() 으로 지워지지 않는다 — NBSP 는 파이썬에서 공백으로 취급되어 지워지지만, 자바나 일부 DB 함수에서는 남는다. 제로폭 문자는 어디서도 안 지워진다. 그래서 키로 쓸 값은 코드포인트 단위로 훑어 목록에 있는 것만 지운다.

셋째, 인코딩을 고쳐 놓고 원본을 덮어쓴다. 되살린 파일을 원본 자리에 저장해 버리면, 판정이 틀렸을 때 돌아갈 곳이 없다. 받은 바이트는 그대로 두고 고친 판을 다른 디렉터리에 만든다.

넷째, 파일 하나를 보고 전체를 단정한다. 한 전달분 안에서도 파일마다 인코딩이 다른 일이 흔하다. 시스템마다 내보내는 경로가 달라서다. 판정은 파일 단위로 하고, 결과를 표로 남긴다.

실무에서 진짜 중요한 것

다음 실습에서 할 것

같은 명부를 일곱 가지 상태로 만드는 재현기를 직접 돌려 수신함을 차린 뒤, 판정 도구 enc_probe.py 를 한 단계씩 키운다. BOM 과 UTF-8 디코딩 가능성 검사에서 시작해 후보 인코딩과 표본 대조를 붙이고, 이중 인코딩을 되살리고, U+FFFD 가 남은 파일을 잃은 것으로 가른다. 그다음 NFD 로 온 이름이 왜 하나도 안 맞는지를 숫자로 확인하고, 보이지 않는 문자를 코드포인트별로 세어 지운다. 채점기는 매번 다른 이름으로 자기 파일을 만들어 여러분의 도구를 실제로 실행하고 판정을 대조한다.