한글 깨짐의 원리와 복구: CP949·EUC-KR·UTF-8 을 파이썬으로 재현

ÇѱÛ, 한글, �븳湲� 은 왜 생길까. UTF-8 과 CP949 를 일부러 바꿔 읽어 한글 깨짐을 재현하고, 패턴 읽는 법, EUC-KR 의 똠 문제, 복구 가능한 경우와 불가능한 경우를 1,000건으로 실측합니다.

시리즈: 문자 인코딩 강좌 2/6 (초급). 앱이나 DB, 내려받은 CSV 에서 ÇѱÛ, 한글, � 를 보고 무슨 일이 일어났는지, 되돌릴 수 있는지 알고 싶은 분을 위한 글입니다. 다 읽고 나면 한글 깨짐을 일부러 재현하고, 흔한 패턴만 보고 원인을 짚고, 이중 인코딩을 안전하게 고치고, 다시는 돌아오지 않는 경우를 구분할 수 있습니다.

공공데이터포털에서 받은 CP949 CSV, 오래된 EUC-KR 게시판의 DB 덤프, UnicodeDecodeError: 'utf-8' codec can't decode byte 0xc7 로 멈춘 배치 작업. 이런 "한글 깨짐" 문의를 받는 분이 이 강좌의 독자입니다. 한글 깨짐은 올바른 바이트를 틀린 인코딩으로 디코딩한 결과이고, 중요한 점은 한국어 깨짐이 영어·스페인어보다 "깨진 문자열에서" 되돌리기가 훨씬 어렵다는 것입니다. 그래서 원래 바이트를 지켜 두는 것 이 무엇보다 중요합니다. 아래는 모두 Apple Silicon Mac(M1 Pro)에서 파이썬 3.9.6, PHP 8.5.6, macOS iconv 로 실행한 결과이고, 트레이스백의 경로는 ~/demo/ 로 바꿨습니다.

바이트, 코드 포인트, UTF-8 이 처음이라면 1강을 먼저 읽어 주세요. 이번 강의는 ed 95 9c 를 3바이트로 읽을 수 있다고 가정합니다.

깨짐의 원리: 바이트는 맞고 디코더가 틀렸다

방향은 두 가지뿐이고, 모양이 각각 다릅니다.

UTF-8 바이트를 레거시 인코딩으로 읽는다. 3바이트짜리 한글이 다른 경계로 잘려 엉뚱한 글자가 됩니다. cp1252 로 읽으면 한글 처럼 라틴 문자가, CP949 로 읽으면 �븳湲� 처럼 엉뚱한 한글·한자가 나옵니다.

레거시 바이트를 UTF-8 로 읽는다. CP949 바이트는 대부분 UTF-8 로서 올바르지 않아서, 엄격한 디코더는 UnicodeDecodeError 로 멈추고 관대한 디코더는 �(U+FFFD, REPLACEMENT CHARACTER)를 보여 줍니다.

두 줄로 둘 다 만들 수 있습니다.

print("한글".encode("utf-8").decode("cp949", "replace"))   # �븳湲�
print("한글".encode("cp949").decode("utf-8", "replace"))   # �ѱ�

두 번째 결과에 섞인 ѱ 는 키릴 문자 PSI(U+0471)입니다. CP949 의 한글 은 c7 d1 b1 db 인데, 가운데 d1 b1 이 우연히 올바른 2바이트 UTF-8 이라서 러시아 문자 같은 글자가 하나 튀어나온 것입니다. 깨진 한글 사이에 키릴 문자나 그리스 문자가 보이면 "CP949 를 UTF-8 로 읽었다" 는 단서입니다.

판단 그림. 깨진 문자열에서 시작한다. 질문 1: 한글 같은 엉뚱한 글자가 섞여 있는가? 그렇다면 UTF-8 을 cp1252 등으로 읽은 것이고 s.encode('cp1252').decode('utf-8') 로 되돌린다. 아니면 질문 2: � 나 UnicodeDecodeError 가 보이는가? 그렇다면 CP949 바이트를 UTF-8 로 읽은 것이므로 원래 바이트를 cp949 로 다시 읽는다. � 나 ? 가 이미 저장됐다면 되돌릴 수 없다.
깨진 글자 읽는 법. 엉뚱한 글자가 늘어났다면 UTF-8 을 레거시 인코딩으로 읽은 것이고 대개 되돌릴 수 있다. � 나 에러라면 레거시 바이트를 UTF-8 로 읽은 것이니 원래 바이트에서 다시 읽는다.

같은 원리가 언어마다 일어납니다. 이 사이트 독자가 자주 만나는 조합을 실행한 결과입니다.

원래 문자열 저장 읽기 보이는 것
한글 UTF-8 cp949 �븳湲�(errors="replace")
안녕하세요 UTF-8 cp949 �븞�뀞�븯�꽭�슂
한글 UTF-8 cp1252 한글
한글 cp949 UTF-8 �ѱ� 또는 UnicodeDecodeError
한글 cp949 cp1252·Latin-1 ÇѱÛ
안녕하세요 cp949 UTF-8 �ȳ��ϼ���
文字化け(일본어) UTF-8 cp932 譁�蟄怜喧縺�
año(스페인어) UTF-8 cp1252 año

ÇÑ±Û 는 옛날 웹 메일이나 해외 서비스에서 한글이 깨질 때 자주 보던 모양입니다. 한글 1자가 라틴 문자 2자가 되고, 바이트는 하나도 사라지지 않았으므로 되돌릴 수 있습니다.

첫 5분: 진짜 에러 메시지 읽기

공공데이터 CSV 처럼 CP949 로 파일을 쓰고, 아무것도 지정하지 않고 열어 봅니다.

# make_old.py
open("old.txt", "wb").write("한글\n".encode("cp949"))
# readfile.py   python3 readfile.py old.txt
import sys
with open(sys.argv[1]) as f:     # encoding= 없음 → macOS·리눅스에서는 UTF-8
    print(f.read())
Traceback (most recent call last):
  File "~/demo/readfile.py", line 3, in <module>
    print(f.read())
  File ".../lib/python3.9/codecs.py", line 322, in decode
    (result, consumed) = self._buffer_decode(data, self.errors, final)
UnicodeDecodeError: 'utf-8' codec can't decode byte 0xc7 in position 0: invalid continuation byte

마지막 줄을 탐정처럼 읽습니다. 위치 0 의 0xc7 은 한 의 CP949 첫 바이트(c7 d1)입니다. 0xc7 은 UTF-8 에서 2바이트 글자의 시작이 될 수 있는 값인데, 다음 바이트 0xd1 이 10xxxxxx 가 아니라서 invalid continuation byte 가 났습니다. 0xB0~0xC8 로 시작하는 2바이트가 이어지는 모양은 한글 완성형의 전형입니다. 고치는 법은 파일의 정체를 밝혀 주는 것뿐입니다. open("old.txt", encoding="cp949") 로 한글 이 출력됐습니다.

셸에서는 iconv -f CP949 -t UTF-8 old.txt 가 한글 을 출력했고, 거꾸로 UTF-8 문자열을 -f CP949 로 읽히면 iconv: iconv(): Illegal byte sequence 를 내고 종료 코드 1 로 멈췄습니다. PHP 에서는 mb_convert_encoding($bytes, "UTF-8", "CP949") 가 한글 을 돌려줬습니다.

EUC-KR 과 CP949 는 같지 않다: 똠·뷁 문제

한국어만의 함정은 "EUC-KR" 이라고 부르는 것과 실제로 쓰이는 CP949 가 다르다는 점입니다. EUC-KR 은 KS X 1001 완성형으로, 한글 음절이 2,350자 뿐입니다. 현대 한글 11,172자 중 나머지는 들어 있지 않고, 그 대표가 똠(똠방각하)과 뷁 입니다. 마이크로소프트가 확장한 CP949(통합형 한글 코드, UHC)는 11,172자를 모두 담습니다.

for s in ["똠", "뷁", "한"]:
    print(s, "euc_kr:", s.encode("euc_kr").hex(" "), "| cp949:", s.encode("cp949").hex(" "))
똠 euc_kr: a4 d4 a4 a8 a4 c7 a4 b1 | cp949: 8c 63
뷁 euc_kr: a4 d4 a4 b2 a4 ce a4 aa | cp949: 94 ee
한 euc_kr: c7 d1 | cp949: c7 d1

예상과 달리 파이썬의 euc_kr 코덱은 에러를 내지 않았습니다. 똠 을 8바이트 로 만들었는데, 이것은 KS X 1001 의 "채움 문자 + 초성 + 중성 + 종성" 조합 방식(a4 d4 가 채움 문자)입니다. 파이썬끼리는 이 8바이트를 다시 똠 으로 읽습니다. 그런데 다른 도구는 다릅니다.

$ iconv -f EUC-KR -t UTF-8 ttom_euckr.txt      # 파이썬 euc_kr 로 저장한 '똠방각하'
ㅤㄸㅗㅁ방각하
$ printf '똠\n' | iconv -f UTF-8 -t EUC-KR; echo "exit=$?"
iconv: iconv(): Illegal byte sequence
exit=1
$ printf '똠방각하\n' | iconv -c -f UTF-8 -t EUC-KR | iconv -f EUC-KR -t UTF-8
iconv: warning: invalid characters: 1
방각하

macOS iconv 는 8바이트 조합을 한 글자로 합치지 않고, 채움 문자(U+3164 HANGUL FILLER)와 호환 자모 ㄸ, ㅗ, ㅁ 네 글자로 풀어 놓았습니다. 화면에는 ㅤㄸㅗㅁ방각하 로 보입니다. UTF-8 에서 EUC-KR 로 바꿀 때는 아예 멈추고, -c 를 주면 똠 이 조용히 사라졌습니다. PHP 8.5 의 mb_convert_encoding("똠방각하", "EUC-KR", "UTF-8") 은 3f b9 e6 ..., 즉 ?방각하 를 돌려줬습니다. 반대로 UHC 와 CP949 를 지정하면 8c 63 으로 저장되고 그대로 돌아왔습니다.

또 하나의 함정: CP949 로 저장한 똠방각하 를 euc_kr 로 읽으면 '�c방각하' 가 됩니다. 똠 의 두 번째 바이트 63 이 ASCII c 로 읽혀서, 깨진 글자 옆에 영문자가 튀어나옵니다. 그러니 "EUC-KR" 이라고 적힌 파일도 실제로는 CP949 로 읽는 것이 안전합니다. CP949 는 EUC-KR 의 상위 집합이라 진짜 EUC-KR 파일도 똑같이 읽힙니다.

패턴 구별법

익숙해지면 깨진 글자가 원인을 알려 줍니다. 위 실행 결과를 정리한 표입니다.

보이는 것 흔한 원인 대처
ÇѱÛ, ¾È³ç 같은 라틴 문자 CP949 를 cp1252·Latin-1 로 읽음 s.encode("cp1252").decode("cp949")
한글, 안녕 UTF-8 을 cp1252 로 읽음 s.encode("cp1252").decode("utf-8")
�븳湲� 처럼 엉뚱한 한글·한자와 � UTF-8 을 CP949 로 읽음 원래 바이트를 UTF-8 로 다시 읽기
�ѱ� 처럼 � 와 키릴·그리스 문자 CP949 를 UTF-8 로 읽음 원래 바이트를 CP949 로 다시 읽기
ㅤㄸㅗㅁ 처럼 풀어진 자모 EUC-KR 8바이트 조합을 못 읽음 CP949 로 다시 저장
?방각하 EUC-KR 에 없는 글자를 ? 로 저장 복구 불가, 원본 필요

참고로 ¾È³ç 는 안녕 의 CP949 바이트(be c8 b3 e7)를 cp1252 로 읽은 결과입니다.

이중 인코딩 고치기

UTF-8 을 cp1252 로 읽고 그것을 다시 UTF-8 로 저장한 "이중 인코딩" 은 바이트가 하나도 사라지지 않았기 때문에 거꾸로 밟으면 돌아옵니다.

print("한글".encode("cp1252").decode("utf-8"))   # 한글
print("안녕".encode("cp1252").decode("utf-8"))   # 안녕
print("ÇѱÛ".encode("cp1252").decode("cp949"))    # 한글

그러나 CP949 로 깨진 UTF-8 한글은 깨진 문자열에서 거의 돌아오지 않습니다. UTF-8 한글의 두 번째 바이트는 80~BF 인데, CP949 의 완성형 영역(첫 바이트 A1~FE)은 두 번째 바이트로 A1 이상을 요구하고, 확장 영역은 첫 바이트가 C6 이하라서 EA~ED 로 시작하는 UTF-8 한글과 맞지 않는 경우가 대부분입니다. 그래서 거의 첫 글자부터 � 가 들어갑니다.

r = "한글".encode("utf-8").decode("cp949", "replace")
print(repr(r.encode("cp949", "replace").decode("utf-8", "replace")))   # '?���?'

한글 은 한 글자도 돌아오지 않았습니다. 그러니 한글 깨짐 신고를 받으면 먼저 깨지기 전의 파일이나 바이트가 남아 있는지 찾아야 합니다.

strict·replace·ignore·surrogateescape

파이썬의 디코딩에는 errors 인자가 있습니다. CP949 로 저장한 한글(c7 d1 b1 db)에 전부 시험했습니다.

errors= 결과 되돌릴 수 있나
strict(기본) UnicodeDecodeError: 'utf-8' codec can't decode byte 0xc7 in position 0: invalid continuation byte 아무것도 디코딩 안 됨
replace '�ѱ�' 불가
ignore 'ѱ' 불가, 게다가 조용함
backslashreplace '\\xc7ѱ\\xdb' 손으로는 가능
surrogateescape '\udcc7ѱ\udcdb' 가능: .encode("utf-8", "surrogateescape") 가 원래 바이트와 일치

ignore 결과가 특히 고약합니다. 한글 두 글자가 사라지고 키릴 문자 ѱ 하나만 남았는데, 에러도 경고도 없습니다. 이런 값이 DB 에 들어가면 나중에 원인을 찾기가 매우 어렵습니다.

인코딩 쪽도 같습니다. "똠 👍" 를 euc_kr 로 errors="replace" 인코딩하면 이모지가 ?(3f)가 됐고, 진짜 물음표와 구별할 수 없습니다.

흔한 실수: replace 나 ignore 로 에러를 "고치기"

실수. 야간 배치가 UnicodeDecodeError 로 죽자, errors="ignore"(또는 iconv -c)를 붙여서 초록불을 만든다.

실제로 일어나는 일. 에러는 사라지지만 데이터가 말없이 사라집니다. 위에서 본 것처럼 CP949 한글을 UTF-8 로 ignore 해서 읽으면 한글 이 ѱ 가 되고, iconv -c 는 iconv: warning: invalid characters: 1 경고만 남기고 똠 을 버린 채 계속 진행합니다.

PHP 에는 또 다른 조용한 함정이 있습니다. 올바르지 않은 UTF-8(여기서는 CP949 파일)을 htmlspecialchars($s, ENT_QUOTES) 에 넘기면 PHP 8.5 에서 빈 문자열 이 돌아왔습니다. PHP 8.1 이후의 기본 플래그에는 ENT_SUBSTITUTE 가 들어 있어서 기본값 그대로라면 잘못된 바이트마다 � 가 됩니다. 8.1 이전에 작성된 코드는 ENT_QUOTES 만 넘기는 경우가 많아서, 잘못된 바이트 하나 때문에 항목 전체가 비어 버립니다.

고치는 법. 바이트의 정체를 확인하고(xxd, 위의 패턴 표, 판별은 6강) 그 인코딩으로 디코딩합니다. 정체를 모르는 바이트를 통과시켜야 한다면 왕복이 가능한 surrogateescape 를 씁니다. replace 는 화면 표시에만 쓰고, 저장할 데이터에는 쓰지 않습니다.

되돌릴 수 없는 깨짐: 실측

어떤 깨짐이 되돌릴 수 있는지 재 봤습니다. 언어별 예문의 글자에서 무작위로 20자를 고른 문자열 1,000개(random.Random(42))를 UTF-8 로 만들고, 틀린 인코딩으로 디코딩한 뒤 복구를 시도했습니다. 복구 함수는 "틀린 인코딩으로 다시 인코딩해서 UTF-8 로 읽는" 것뿐입니다.

rng = random.Random(42)
for _ in range(1000):
    s = "".join(rng.choice(POOL) for _ in range(20))
    raw = s.encode("utf-8")
    try:
        wrong = raw.decode(codec)              # 틀린 디코딩이 애초에 성공하는가
    except UnicodeDecodeError:
        wrong = raw.decode(codec, handler)     # 관대한 도구라면 보여 줬을 문자열
    fixed += unmojibake(wrong, codec) == s
ja read as cp932   (replace): decoded without error   49/1000, repaired   49/1000
ko read as cp949   (replace): decoded without error    0/1000, repaired    0/1000
es read as cp1252  (replace): decoded without error  584/1000, repaired  584/1000
es read as cp1252  (c1     ): decoded without error  584/1000, repaired 1000/1000
es read as latin-1 (strict ): decoded without error 1000/1000, repaired 1000/1000
en read as cp1252  (replace): decoded without error  688/1000, repaired  688/1000
en read as cp1252  (c1     ): decoded without error  688/1000, repaired 1000/1000
1,000건 중 복구 성공 수 막대그래프. 일본어를 cp932 로 대체 문자와 함께 디코딩: 49. 한국어를 cp949 로 대체 문자와 함께: 0. 스페인어를 cp1252 로 대체 문자와 함께: 584. 스페인어를 cp1252 로 미정의 바이트를 남겨서: 1,000. 스페인어를 Latin-1 로: 1,000. 영어를 cp1252 로 대체 문자와 함께: 688. 영어를 cp1252 로 미정의 바이트를 남겨서: 1,000.
행마다 무작위 20자 문자열 1,000개(seed 42)를 UTF-8 로 만들고, 틀린 인코딩으로 디코딩한 뒤 복구에 성공한 개수. 모든 바이트를 남기는 디코딩만 완전히 되돌릴 수 있다. 파이썬 3.9.

한국어는 1,000개 중 0개 였습니다. 20자 중 한 글자라도 CP949 로 올바르지 않으면 � 가 들어가는데, 위에서 본 바이트 범위 때문에 사실상 모든 문자열이 걸렸습니다. 서유럽 언어는 모든 바이트를 남기는 디코딩(Latin-1, 또는 브라우저처럼 미정의 바이트를 남기는 c1)이라면 1,000개 전부 돌아옵니다. 즉 "깨진 문자열에서 고치기" 는 영어·스페인어에서는 통해도 한국어에서는 거의 통하지 않습니다.

되돌릴 수 없는 경우를 정리하면 다음과 같습니다.

  1. 저장된 �. 그 뒤의 바이트는 이미 없습니다.
  2. 인코더가 errors="replace" 로 쓴 ?(똠 을 EUC-KR 로, 이모지를 CP949 로).
  3. ignore 나 iconv -c 로 버려진 글자.
  4. 자모로 풀어진 ㅤㄸㅗㅁ 처럼 다른 코드 포인트로 바뀐 글자(사람이 보면 알아도 == 는 False).

솔직한 한계와 버전 메모

  • 파이썬 3.9.6, PHP 8.5.6(mbstring), macOS 26 의 iconv(--version 이 없고 GNU libiconv 와 다른 구현이라 메시지가 리눅스와 다릅니다). 리눅스와 윈도우에서는 실행하지 않았습니다.
  • 윈도우의 파이썬은 3.15 이전이면 encoding= 을 생략했을 때 한국어 윈도우에서 cp949 로 파일을 엽니다. PEP 686 에 따라 3.15 부터 UTF-8 이 기본이 됩니다. PEP 의 내용이고 여기서 실행하지는 않았습니다.
  • 엑셀이 BOM 없는 UTF-8 CSV 를 CP949 로 여는 동작은 4강에서 다루며, 엑셀은 여기서 실행하지 않았습니다.
  • ftfy 같은 자동 복구 라이브러리는 설치하지 않았으므로(아무것도 설치하지 않는 방침) 비교하지 않았습니다.
  • 1,000건 실험은 짧은 예문의 글자에서 뽑았기 때문에 실제 데이터 분포와 다릅니다. 고객에게 "복구됩니다" 라고 약속하기 전에 자기 데이터로 재 보세요.

연습 문제

  1. 고객 테이블에서 ¾È³ç 이 발견됐습니다. 원래 무엇이었고, 어떤 한 줄로 복구할 수 있나요?
  2. 파일이 c7 d1 b1 db 로 시작합니다. 어떤 인코딩이고, 무엇이라고 쓰여 있나요? UTF-8 로 읽으면 어떤 에러가 나나요?
  3. "EUC-KR" 이라고 적힌 파일을 받았습니다. 파이썬에서 euc_kr 대신 cp949 로 읽는 편이 안전한 이유는?

정답

  1. 안녕 입니다. CP949 바이트 be c8 b3 e7 을 cp1252 로 읽은 것이므로 s.encode("cp1252").decode("cp949") 로 돌아옵니다.
  2. CP949(또는 EUC-KR)의 한글 입니다. UTF-8 로 읽으면 UnicodeDecodeError: 'utf-8' codec can't decode byte 0xc7 in position 0: invalid continuation byte 가 납니다.
  3. CP949 는 EUC-KR 의 상위 집합이라 진짜 EUC-KR 파일도 똑같이 읽히고, 실제로는 CP949 로 저장된 똠(8c 63) 같은 글자도 읽을 수 있습니다. euc_kr 로 읽으면 '�c방각하' 처럼 깨졌습니다.

다음 강의

양쪽 모두 바이트가 올바른 UTF-8 인데도 문자열이 일치하지 않는 일이 있습니다. 맥에서 만든 파일 이름이 윈도우에서 자모로 풀어지는 바로 그 문제, 유니코드 정규화입니다.

자주 묻는 질문

한글이 ÇÑ±Û 처럼 깨지는 건 왜인가요?

CP949(EUC-KR) 로 저장된 한글을 서유럽용 cp1252 나 Latin-1 로 읽었을 때의 전형적인 모양입니다. '한글' 의 CP949 바이트 c7 d1 b1 db 가 한 바이트씩 Ç, Ñ, ±, Û 로 보입니다. s.encode('cp1252').decode('cp949') 로 '한글' 이 복구됐습니다.

EUC-KR 로 저장하면 '똠' 이 사라지는 이유는?

EUC-KR(KS X 1001 완성형)에는 한글 음절이 2,350자만 있고 '똠'·'뷁' 은 없습니다. PHP 의 mb_convert_encoding 으로 '똠방각하' 를 EUC-KR 로 바꾸니 '?방각하' 가 됐고, macOS iconv 는 Illegal byte sequence 로 멈췄습니다. CP949 는 '똠' 을 8c 63 으로 저장합니다.

깨진 한글 문자열을 그대로 되돌릴 수 있나요?

UTF-8 을 cp1252 로 읽은 한글 형태는 되돌릴 수 있지만, UTF-8 을 CP949 로 읽은 경우는 거의 안 됩니다. 무작위 20자 문자열 1,000개로 실험하니 � 없이 디코딩된 것도, 복구된 것도 0개였습니다. 원래 바이트(원본 파일)에서 다시 읽어야 합니다.

  • #문자 인코딩
  • #초급
  • #한글 깨짐
  • #CP949
  • #EUC-KR
  • #파이썬

t.laboratory

직접 만들고 운영하는 앱·서버·서비스에서 겪은 문제와, 그 해결에 쓴 코드를 기록하는 기술 블로그입니다.

운영자 소개 · 앱

↑