한글 깨짐의 원리와 복구: 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 로 읽었다" 는 단서입니다.
같은 원리가 언어마다 일어납니다. 이 사이트 독자가 자주 만나는 조합을 실행한 결과입니다.
| 원래 문자열 | 저장 | 읽기 | 보이는 것 |
|---|---|---|---|
한글 |
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개 중 0개 였습니다. 20자 중 한 글자라도 CP949 로 올바르지 않으면 � 가 들어가는데, 위에서 본 바이트 범위 때문에 사실상 모든 문자열이 걸렸습니다. 서유럽 언어는 모든 바이트를 남기는 디코딩(Latin-1, 또는 브라우저처럼 미정의 바이트를 남기는 c1)이라면 1,000개 전부 돌아옵니다. 즉 "깨진 문자열에서 고치기" 는 영어·스페인어에서는 통해도 한국어에서는 거의 통하지 않습니다.
되돌릴 수 없는 경우를 정리하면 다음과 같습니다.
- 저장된
�. 그 뒤의 바이트는 이미 없습니다. - 인코더가
errors="replace"로 쓴?(똠을 EUC-KR 로, 이모지를 CP949 로). ignore나iconv -c로 버려진 글자.- 자모로 풀어진
ㅤㄸㅗㅁ처럼 다른 코드 포인트로 바뀐 글자(사람이 보면 알아도==는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건 실험은 짧은 예문의 글자에서 뽑았기 때문에 실제 데이터 분포와 다릅니다. 고객에게 "복구됩니다" 라고 약속하기 전에 자기 데이터로 재 보세요.
연습 문제
- 고객 테이블에서
¾È³ç이 발견됐습니다. 원래 무엇이었고, 어떤 한 줄로 복구할 수 있나요? - 파일이
c7 d1 b1 db로 시작합니다. 어떤 인코딩이고, 무엇이라고 쓰여 있나요? UTF-8 로 읽으면 어떤 에러가 나나요? - "EUC-KR" 이라고 적힌 파일을 받았습니다. 파이썬에서
euc_kr대신cp949로 읽는 편이 안전한 이유는?
정답
안녕입니다. CP949 바이트be c8 b3 e7을 cp1252 로 읽은 것이므로s.encode("cp1252").decode("cp949")로 돌아옵니다.- CP949(또는 EUC-KR)의
한글입니다. UTF-8 로 읽으면UnicodeDecodeError: 'utf-8' codec can't decode byte 0xc7 in position 0: invalid continuation byte가 납니다. - 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개였습니다. 원래 바이트(원본 파일)에서 다시 읽어야 합니다.