고객 데이터 다루기 · 글자가 깨졌다 · 이론
글자가 깨졌다 — 추측하지 말고 판정하라
한 줄 요약
파일에는 인코딩이 적혀 있지 않다. 그래서 인코딩은 알아내는 것이 아니라 후보를 순서대로 배제하며 판정하는 것이고, 그 판정의 마지막 질문은 "이 파일은 되살릴 수 있는가" 다.
왜 이게 필요했나
고객이 보낸 명부를 열었더니 이름 칸이 전부 네모이거나 알 수 없는 기호였다. 여기서 대부분의 사람이 가장 먼저 하는 일은 에디터의 인코딩 메뉴를 하나씩 눌러 보는 것이다. 그 방법으로 맞는 경우도 있지만, 맞았는지 아닌지를 눈으로만 판단하게 된다는 것이 문제다. 파일이 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 함수에서는 남는다. 제로폭 문자는 어디서도 안 지워진다. 그래서 키로 쓸 값은 코드포인트 단위로 훑어 목록에 있는 것만 지운다.
셋째, 인코딩을 고쳐 놓고 원본을 덮어쓴다. 되살린 파일을 원본 자리에 저장해 버리면, 판정이 틀렸을 때 돌아갈 곳이 없다. 받은 바이트는 그대로 두고 고친 판을 다른 디렉터리에 만든다.
넷째, 파일 하나를 보고 전체를 단정한다. 한 전달분 안에서도 파일마다 인코딩이 다른 일이 흔하다. 시스템마다 내보내는 경로가 달라서다. 판정은 파일 단위로 하고, 결과를 표로 남긴다.
실무에서 진짜 중요한 것
- 추측을 절차로 바꾼다. 에디터 메뉴를 눌러 보는 대신, 같은 순서로 내려가는 코드를 만든다. 그래야 파일이 300개여도 같은 답이 나온다.
- 되살릴 수 있는 것과 잃은 것을 갈라서 말한다. "깨졌습니다" 는 보고가 아니다. "세 파일 중 둘은 되살렸고 하나는 원본을 다시 주셔야 합니다" 가 보고다.
- 비교 전에 정규화한다. 정규화는 비교를 위한 것이지 데이터를 고치는 것이 아니다. 원본은 원본대로 둔다.
- 보이지 않는 문자는 목록으로 지운다. 무엇을 지웠는지 세어서 남기면, 나중에 건수가 안 맞을 때 설명할 수 있다.
다음 실습에서 할 것
같은 명부를 일곱 가지 상태로 만드는 재현기를 직접 돌려 수신함을 차린 뒤, 판정 도구 enc_probe.py 를 한 단계씩 키운다. BOM 과 UTF-8 디코딩 가능성 검사에서 시작해 후보 인코딩과 표본 대조를 붙이고, 이중 인코딩을 되살리고, U+FFFD 가 남은 파일을 잃은 것으로 가른다. 그다음 NFD 로 온 이름이 왜 하나도 안 맞는지를 숫자로 확인하고, 보이지 않는 문자를 코드포인트별로 세어 지운다. 채점기는 매번 다른 이름으로 자기 파일을 만들어 여러분의 도구를 실제로 실행하고 판정을 대조한다.