인코딩 자동 판별은 추측일 뿐: 정확도와 변환 속도 실측
mb_detect_encoding 과 표준 라이브러리 판별기를 9,000개 샘플로 비교. 짧은 한글일수록 틀린다. 10MB 변환 속도와 청크 경계에서 한글이 깨지는 버그까지.
시리즈: 문자 인코딩 강좌 6/6 (고급). 직접 관리하지 않는 시스템(공공데이터 포털의 CSV, 거래처의 엑셀 내보내기, 오래된 게시판 백업)에서 파일을 가져오면서 "인코딩은 자동으로 판별하면 되잖아" 라는 말을 들은 개발자를 위한 글입니다. 다 읽으면 자동 판별이 얼마나 맞는지, 왜 짧은 문자열에서 틀리는지, 변환이 실제로 얼마나 빠른지, 운에 기대지 않는 가져오기 흐름을 어떻게 짜는지 알 수 있습니다.
mb_detect_encoding($data) 결과를 그대로 믿어 본 적이 있거나, 공공데이터 CSV 를 파이썬으로 읽었더니 한글이 깨져서 나온 적이 있다면 이 글이 맞습니다. 근본 문제는 단순합니다. 바이트열은 자신이 어떤 인코딩으로 만들어졌는지 말해 주지 않습니다. 자동 판별은 샘플에 대한 통계이고, 4글자 샘플은 증거로 너무 적습니다. 이 글에서는 한국어·일본어·스페인어·영어의 고정 시드 샘플로 이를 재고, PHP 판별기와 40줄짜리 Python 판별기를 비교하고, 10MB 변환 시간을 재고, 1강에서 예고한 청크 경계 디코딩 버그를 고칩니다.
실행 환경: Apple Silicon Mac(M1 Pro), macOS 26.6, Python 3.9.6(표준 라이브러리만), PHP 8.5.6(mbstring·iconv), macOS iconv 명령. 파이썬에서 흔히 쓰는 chardet 같은 라이브러리는 이 맥에 설치되어 있지 않고, 이번에도 아무것도 설치하지 않았으므로 시험하지 않았습니다. 윈도우와 엑셀도 실행하지 않았습니다.
왜 자동 판별은 추측일 수밖에 없나
짧은 문자열 네 개를 각자의 대표적인 옛 인코딩으로 저장하고, 같은 바이트열을 후보마다 읽어 봤습니다.
| 원래 문자열 | 저장 형식 | 바이트열 | cp1252 로 읽기 | cp932 로 읽기 | cp949 로 읽기 |
|---|---|---|---|---|---|
| It’s | cp1252 | 49 74 92 73 |
It’s | It痴 | It뭩 |
| año | cp1252 | 61 f1 6f |
año | a + U+E0EB(사용자 정의 영역) | 오류 |
| 文字化け | cp932 | 95 b6 8e 9a 89 bb 82 af |
•¶Žš‰»‚¯ | 文字化け | 빒럻돸궚 |
| 한글 | cp949 | c7 d1 b1 db |
ÇÑ±Û | ヌムアロ | 한글 |
대부분의 읽기가 오류 없이 디코딩됩니다. Windows-1252 는 거의 모든 바이트를 어떤 문자로든 바꾸므로 "디코딩됐다" 는 아무 증거도 아닙니다. CP932 와 CP949 는 둘 다 겹치는 범위의 2바이트 열을 쓰기 때문에, 일본어 바이트가 그럴듯한 한글(빒럻돸궚)이 되고 그 반대도 일어납니다. 판별기가 물을 수 있는 것은 "어느 읽기가 진짜 문장처럼 보이는가" 뿐이고, 이는 추측이며 텍스트가 길수록 잘 맞습니다.
반대 방향도 마찬가지입니다. UTF-8 바이트를 CP949 로 읽으면 한글 깨짐 이 �븳湲� 源⑥쭚 이 됐고(errors='replace'), CP949 바이트를 Windows-1252 로 읽으면 한글 이 ÇÑ±Û 가 됐습니다. 둘 다 한국 개발자에게 익숙한 "한글 깨짐" 입니다(원리는 2강).
다만 아주 강한 단서가 하나 있습니다. UTF-8 로서 올바른가 입니다. UTF-8 은 어떤 바이트 뒤에 어떤 바이트가 올 수 있는지 엄격하게 정해져 있어서, 옛 인코딩의 문장은 거의 그 규칙을 지키지 못합니다. 뒤에서 쓸 한국어·일본어·스페인어·영어 레거시 샘플 10,000개 중 UTF-8 로서 올바른 것은 0개 였습니다. UTF-8 을 먼저 확인하기만 해도 현실의 파일 대부분이 정리됩니다. 예외는 "이미 깨진 문자열" 로, cp1252 의 é 바이트는 UTF-8 의 é 와 똑같습니다.
mb_detect_encoding 의 함정 세 가지
정확도 숫자를 보기 전에 PHP 판별기의 동작을 짚어 둡니다.
함정 1: 기본 후보 목록. 후보를 주지 않으면 mb_detect_order() 가 쓰이고, 이 환경에서는 ASCII, UTF-8 이었습니다. 게다가 non-strict 모드에서는 어느 것에도 맞지 않아도 "가장 가까운" 후보를 돌려줍니다.
$b = mb_convert_encoding("文字化け", "SJIS-win", "UTF-8");
var_dump(mb_detect_encoding($b)); // string(5) "ASCII"
var_dump(mb_detect_encoding("\xff\xfe", ["UTF-8", "ASCII"])); // string(5) "UTF-8"
var_dump(mb_detect_encoding("\xff\xfe", ["UTF-8", "ASCII"], true)); // bool(false)
Shift_JIS 바이트열이 ASCII, UTF-8 에 절대 나올 수 없는 2바이트가 UTF-8 로 판별됐습니다. 후보 목록을 반드시 명시하고 세 번째 인자로 true(strict)를 주세요.
함정 2: 후보 순서도 답의 일부. 두 후보가 모두 들어맞으면 순서와 PHP 내부 채점이 결정합니다. 아래 결과는 "일본어 먼저" 와 "한국어·서유럽 먼저" 에서 극적으로 달라집니다.
함정 3: 가족까지만 알고, 변형은 모른다. 판별은 "EUC-KR 계열" 까지는 말해도 어느 표인지는 모릅니다. 똠(8c 63)·뷁(94 ee)은 CP949 확장 영역에만 있습니다. 후보를 ["UTF-8", "EUC-KR"] 로 주면 mb_detect_encoding 은 false, ["UTF-8", "EUC-KR", "UHC"] 로 주면 "UHC" 였습니다. 무리하게 mb_convert_encoding($b, "UTF-8", "EUC-KR") 를 하면 ?c?? 가, "UHC" 로 하면 똠뷁 이 나왔습니다. 일본어의 Shift_JIS 와 CP932(물결표 〜 문제), 서유럽의 ISO-8859-1 과 Windows-1252 에서도 같은 일이 일어납니다.
표준 라이브러리만 쓴 판별기: 버전 1 과 2
비교용으로 Python 표준 라이브러리만으로 판별기를 짰습니다. 버전 1 은 먼저 UTF-8 을 시도하고, 안 되면 각 후보로 디코딩해서 문자 종류로 점수를 매깁니다(가나·한글 +2, 한자 +1, 출력 가능한 ASCII +1, 반각 가타카나 −1, 제어 문자·사용자 정의 영역 −5, 글자당 평균). 이 버전은 일본어 CP932 에 약해서 4글자 샘플 200개 중 60개만 맞혔습니다. CP932 바이트를 CP949 로 읽으면 진짜 한글 음절이 나오기 때문입니다.
버전 2 는 실패 사례를 보고 두 가지를 바꿨습니다. 바이트당 점수를 매기고(2바이트 한자 한 글자가 1바이트 라틴 문자 두 개를 이기지 않도록), KS X 1001 상용 2,350음절 밖의 한글(CP949 확장 영역)에는 감점을 줍니다. 실제 한국어 문장에서는 드물지만, 잘못 읽힌 일본어 바이트는 자주 그곳에 떨어지기 때문입니다.
import unicodedata
CANDIDATES = ["cp1252", "cp932", "euc_jp", "cp949"] # on a tie, earlier wins
def char_score(ch):
o = ord(ch)
if o < 0x80: return 1.0 if (ch.isprintable() or ch in "\t\r\n") else -5.0
if 0x3041 <= o <= 0x30FF: return 2.0 # kana
if 0xAC00 <= o <= 0xD7A3: # hangul syllable
b = ch.encode("cp949")
return 2.0 if (b[0] >= 0xB0 and b[1] >= 0xA1) else -1.0 # rare outside KS X 1001
if 0x4E00 <= o <= 0x9FFF: return 1.5 # kanji / hanja
if 0x3000 <= o <= 0x303F or 0xFF01 <= o <= 0xFF5E: return 1.5
if 0x2010 <= o <= 0x2026: return 1.0 # dashes, smart quotes
if 0xFF61 <= o <= 0xFF9F or 0x3131 <= o <= 0x318E: return -1.0
cat = unicodedata.category(ch)
if cat in ("Cc", "Co", "Cn"): return -5.0
if cat.startswith("L"): return 1.0 if 0xC0 <= o <= 0xFF else 0.0
return 0.0
def guess(b):
if b.startswith(b"\xef\xbb\xbf"): return "utf-8-sig"
try:
b.decode("utf-8"); return "utf-8"
except UnicodeDecodeError:
pass
best, best_score = None, float("-inf")
for enc in CANDIDATES:
try: s = b.decode(enc)
except UnicodeDecodeError: continue
score = sum(char_score(c) for c in s) / len(b)
if score > best_score: best, best_score = enc, score
return best
솔직하게 밝혀 두면, 버전 2 는 한 문장 세트의 실패를 보면서 조정했습니다. 자기 숙제를 자기가 채점하지 않도록, 아래 숫자는 모두 조정에 쓰지 않은 별도 문장 세트(스마트폰, 요리, 날씨 같은 일상 문단)로 잰 것입니다. 두 세트 모두 직접 쓴 문장이므로 실제 데이터에서는 결과가 달라집니다.
정확도 재기: 짧은 문자열과 긴 문자열
방법: 언어마다 4·8·16·64·256글자의 무작위 부분 문자열을 뽑고(random.seed(2026)), ASCII 만 있는 것(어떤 추측이든 정답)과 그 인코딩으로 표현할 수 없는 것(㈱, ① 은 EUC-JP 에 없음)을 빼고 인코딩한 뒤 각 판별기에 물었습니다. 그 추측으로 디코딩해서 원래 문자열과 완전히 같으면 정답입니다. 칸마다 N = 200, 모두 9,000개 샘플. PHP 목록 A 는 UTF-8, SJIS-win, EUC-JP, UHC, Windows-1252, 목록 B 는 UTF-8, UHC, Windows-1252, SJIS-win, EUC-JP 입니다(UHC 는 PHP 에서 CP949 의 이름).
200개 중 정답 수, 4글자 샘플:
| 경우 | 표준 라이브러리 v1 | 표준 라이브러리 v2 | PHP 목록 A | PHP 목록 B |
|---|---|---|---|---|
| 한국어 CP949 | 200 | 199 | 47 | 200 |
| 일본어 CP932 | 60 | 185 | 200 | 200 |
| 일본어 EUC-JP | 195 | 195 | 200 | 60 |
| 스페인어 Windows-1252 | 107 | 200 | 153 | 170 |
| 영어 Windows-1252 | 135 | 200 | 135 | 135 |
16글자 샘플:
| 경우 | 표준 라이브러리 v1 | 표준 라이브러리 v2 | PHP 목록 A | PHP 목록 B |
|---|---|---|---|---|
| 한국어 CP949 | 200 | 200 | 132 | 200 |
| 일본어 CP932 | 80 | 200 | 200 | 200 |
| 일본어 EUC-JP | 200 | 200 | 200 | 152 |
| 스페인어 Windows-1252 | 117 | 200 | 166 | 200 |
| 영어 Windows-1252 | 125 | 200 | 125 | 200 |
256글자에서는 v1 과 PHP 목록 A 의 영어 CP1252(둘 다 179개)를 빼면 모든 판별기가 200/200 이었습니다. UTF-8 샘플 4,000개는 모든 판별기에서 전부 정답이었습니다.
한국어 쪽에서 눈여겨볼 것은 목록 A 입니다. "일본어 데이터도 가끔 오니까" 하고 일본어 인코딩을 앞에 두면, 4글자 한국어 CP949 는 4개 중 3개 이상이 EUC-JP 한자로 판별됐습니다. EUC-JP 와 EUC-KR 계열은 같은 바이트 범위(0xA1–0xFE)를 쓰기 때문입니다. 반대로 UHC 를 앞에 둔 목록 B 에서는 일본어 EUC-JP 가 UHC 한글로 바뀌었습니다. 짧은 문자열 + 여러 지역을 아우르는 후보 목록이 최악의 조합이고, 사람 이름·상품 코드·CSV 한 칸이 정확히 그런 입력입니다. 우리 v2 는 반대 방향으로 틀렸습니다. 4글자 일본어 CP932 에서 틀린 15개는 모두 cp1252 로 읽혔는데(天気予報 → “V‹C—\•ñ), 히라가나의 첫 바이트 0x82 가 cp1252 에서는 아래쪽 따옴표 ‚ 이고 v2 가 영어를 위해 스마트 따옴표에 가산점을 주기 때문입니다.
흔한 실수: 큰 파일의 앞부분만으로 판별하기
실수. 10MB 전체 판별은 느리니까(v2 는 10MiB CP932 에 894ms) 앞쪽 64KiB 만 본다: guess(data[:65536]).
실제로 일어나는 일. 자르는 지점은 세 번에 두 번꼴로 글자 중간에 걸립니다. strict 한 guess 에게는 깨진 UTF-8 이자 깨진 레거시 텍스트로 보입니다.
big_cp949_utf8.txt [:65535] None [:65536] None [:65537] utf-8
big_cp932.txt [:65535] None [:65536] cp932 [:65537] None
big_cp949.txt [:65535] cp1252 [:65536] cp949 [:65537] cp1252
UTF-8 파일이 "알 수 없음" 으로, 한국어 CP949 파일이 Windows-1252 로 판별됐습니다. 답이 없는 것보다 나쁩니다. 가져오기는 성공하고 깨진 한글이 저장되기 때문입니다.
고치는 법. 앞부분을 점진적 디코더와 final=False 로 디코딩합니다. 끝에 걸친 미완성 글자를 "뒤에 더 온다" 로 보고 오류로 취급하지 않습니다.
import codecs
def decode_prefix(b, enc):
return codecs.getincrementaldecoder(enc)().decode(b, final=False)
guess 안의 b.decode 를 decode_prefix 로 바꾸자 15가지 자르기 지점 모두 정답이 나왔고, 64KiB 판별은 894ms 가 아니라 6ms 였습니다.
스트리밍 버그: 청크에 걸친 글자
스트림을 청크 단위로 디코딩할 때도 같은 일이 생깁니다. 1MB 일본어 파일(UTF-8, 그리고 같은 내용의 CP932)을 4,096바이트씩 읽었습니다.
with open(path, "rb") as f:
while chunk := f.read(4096):
chunk.decode(enc) # 버그: 청크마다 따로 디코딩
strict: chunk #1 failed: 'utf-8' codec can't decode bytes in position 4094-4095: unexpected end of data
replace: U+FFFD count = 412
strict: chunk #3 failed: 'cp932' codec can't decode byte 0x83 in position 4095: incomplete multibyte sequence
replace: U+FFFD count = 86
CP932 쪽은 숫자보다 더 나쁩니다. 첫 바이트가 치환되면 그 두 번째 바이트가 다음 청크의 맨 앞이 되어 독립된 글자로 읽힙니다. 基幹システム 이 幹シス�eムか 가 됐습니다. テ(0x83 0x65)의 두 번째 바이트 0x65 가 알파벳 e 가 된 것입니다. 출력은 원본보다 85글자 길었고 원본에 없던 반각 가타카나가 59글자 섞였는데 오류는 하나도 나지 않았습니다. CP949 도 2바이트 인코딩이라 같은 구조의 위험이 있습니다.
Python 에서 고치는 법: 점진적 디코더는 미완성 바이트를 다음 호출까지 들고 갑니다.
import codecs
dec = codecs.getincrementaldecoder(enc)()
with open(path, "rb") as f:
parts = [dec.decode(c) for c in iter(lambda: f.read(4096), b"")]
parts.append(dec.decode(b"", final=True)) # 파일이 글자 중간에서 끝나면 예외
두 파일 모두 U+FFFD count = 0 이었고 파일 전체를 한 번에 디코딩한 결과와 같았습니다. 텍스트 모드로 여는 것(open(path, encoding="cp949"))만으로도 같은 장치가 쓰입니다. PHP 에서는 fread 한 청크마다 mb_convert_encoding 을 하면 ? 가 412개(UTF-8), 87개(CP932) 늘었습니다. 파일 핸들에 convert.iconv.CP932/UTF-8 스트림 필터를 붙이면 파일 전체 변환과 같은 출력이 나왔습니다. jsc 에는 TextDecoder 가 없어 JavaScript 의 {stream: true} 는 이번에 실행하지 않았습니다.
10MiB 변환 속도
각 파일은 옛 인코딩으로 저장한 10MiB 의 실제 문장이고, 메모리에서 UTF-8 로 변환해 5회 중앙값을 냈습니다. iconv 명령 행은 프로세스 시작과 파일 읽기를 포함합니다(같은 파일의 cat 은 4ms).
| UTF-8 로 변환 | CP932 | CP949 | CP1252 |
|---|---|---|---|
Python data.decode(enc).encode("utf-8") |
36 ms | 33 ms | 10 ms |
PHP mb_convert_encoding |
22 ms | 37 ms | 19 ms |
PHP iconv() |
283 ms | 197 ms | 186 ms |
iconv 명령(macOS) |
284 ms | 212 ms | 196 ms |
네 결과 모두 바이트 단위로 같았습니다(cmp 와 === 로 확인). Python codecs 와 mbstring 은 대략 초당 280MB~1,000MB, macOS libiconv 는 그 10분의 1 정도였습니다. 그래도 10MiB 에 0.3초 미만이니 야간 배치라면 무엇이든 충분하고, 요청 처리 안이라면 mbstring 이나 Python codecs 를 고르세요. 덤으로, 15.6MB UTF-8 검증은 Python decode 로 10.3ms 였습니다. PHP mb_check_encoding 은 새 문자열에서 3.5~4.1ms 였지만 같은 문자열에 대한 두 번째 호출은 약 0.0002ms 였습니다. PHP 가 문자열에 "올바른 UTF-8" 플래그를 캐시하기 때문이니, 벤치마크는 중앙값이 아니라 첫 호출을 재야 합니다.
운에 기대지 않는 가져오기 흐름
- 인코딩을 선언받는다. HTTP
Content-Type: text/csv; charset=...(HTTP 헤더 강좌), XML·HTML 선언, BOM, 아니면 거래처와의 약속 한 줄. 선언은 어떤 판별기보다 낫습니다. - BOM, 그다음 올바른 UTF-8 인지 확인. 이것으로 UTF-8 샘플 4,000개는 전부 맞았고 레거시 10,000개는 전부 걸러졌습니다.
- 송신처의 알려진 인코딩을 쓴다. 어떤 옛 시스템은 늘 같은 인코딩으로 내보냅니다. 파일마다 추측하지 말고 "송신처 → 인코딩" 표를 둡니다. 공공데이터 CSV 처럼 윈도우 엑셀에서 나온 파일이면 EUC-KR 이 아니라 CP949 로 적어 두어야 똠·뷁 에서 막히지 않습니다.
- strict 하게 디코딩한다. Python 은
errors="strict", PHP 는mb_convert_encoding전에mb_check_encoding. 가져오기 실패가 조용한 한글 깨짐보다 쌉니다. - 추측은 마지막 수단. 수백 바이트 이상으로, 실제로 받는 지역으로 좁힌 후보 목록으로 하고, 결과는 사람의 확인이나 격리 폴더로 보냅니다.
- 스트림은 점진적 디코더로. 청크마다
decode하지 않습니다. - 정규화해서 UTF-8 로 저장. NFC(3강), MySQL 은
utf8mb4(5강), 엑셀 사용자용 CSV 는 4강대로.
솔직한 한계와 버전 메모
- 샘플은 직접 쓴 작은 문장 세트 두 개에서 뽑았습니다. 주소, 상품 코드, 여러 언어가 섞인 실제 데이터에서는 결과가 달라집니다. 표는 짧은 문자열 효과를 보여 주는 시연이지 PHP 벤치마크가 아닙니다.
mb_detect_encoding의 채점 방식은 PHP 8.1 에서 다시 만들어졌습니다. PHP 7 이나 8.0 에서는 결과가 다를 수 있습니다. 실행한 것은 8.5.6 뿐입니다.chardet,charset-normalizer, ICUCharsetDetector는 쓸 수 없어 시험하지 않았습니다. 리눅스와 GNU iconv 도 실행하지 않았습니다.- 같은 인코딩 이름이라도 도구마다 다릅니다. Python 의
euc_kr은 똠 같은 음절을 KS X 1001 의 8바이트 "조합" 열(a4 d4 a4 a8 a4 c7 a4 b1)로 받아들였지만, macOS 의iconv -t EUC-KR은 똠 을Illegal byte sequence로 거부했습니다. - 속도는 Apple M1 Pro 한 대의 값입니다. 가져갈 것은 "mbstring 과 codecs 가 빠르고 macOS libiconv 는 느리다" 는 순서입니다.
연습 문제
1. mb_detect_encoding($b) 가 Shift_JIS 바이트열에 "ASCII" 를 돌려준 이유는 무엇이고, 무엇을 바꾸면 고쳐지나요?
2. 점진적 디코더에 1바이트씩 넣어 보세요. "똠방각하, 뷁! 한글 깨짐 테스트".encode("cp949") 를 codecs.getincrementaldecoder("cp949")() 에 1바이트씩 넣어 디코딩하면 한 번에 디코딩한 결과와 같을까요?
정답
1. 후보 목록이 없어서 PHP 가 판별 순서 ASCII, UTF-8 을 썼고, non-strict 모드에서는 어느 것도 올바르지 않아도 가장 가까운 후보를 돌려주기 때문입니다. 후보를 명시하고 strict 로 바꿉니다. mb_detect_encoding($b, ["UTF-8", "SJIS-win"], true) 는 "SJIS-win" 을 돌려줬습니다.
2. 같습니다. 출력은 똠방각하, 뷁! 한글 깨짐 테스트 True 였습니다. 디코더는 0x8c 같은 첫 바이트를 두 번째 바이트가 올 때까지 들고 있으므로 청크 크기가 1이어도 안전합니다. UTF-8 로 보면 더 잘 보입니다. dec.decode(b"caf\xc3") 는 'caf' 를, 이어서 b"\xa9!" 를 넣으면 'é!' 를 돌려줬습니다. 새 디코더에서 decode(b"\xc3", final=True) 를 부르면 unexpected end of data 예외가 났습니다.
시리즈를 마치며
이번이 마지막 강의입니다. 바이트와 코드 포인트(1), 깨진 글자(2), 정규화(3), CSV 와 엑셀(4), 문자열 길이(5), 그리고 이번 자동 판별까지 규칙은 늘 같았습니다. 지금 다루는 것이 어떤 인코딩, 어떤 단위인지 알고, 모든 경계에서 명시할 것.
자주 묻는 질문
mb_detect_encoding 은 한글 CP949 를 얼마나 맞히나요?
후보 목록의 순서와 글자 수에 크게 좌우됩니다. 일본어 인코딩을 앞에 둔 목록에서는 4글자 한국어 CP949 샘플 200개 중 47개만 맞혔고, 16글자에서 132개, 64글자 이상에서야 200개 모두 맞혔습니다. UHC(CP949)를 앞에 두면 4글자에서도 200개 모두 맞혔지만, 이번에는 일본어 EUC-JP 가 200개 중 60개로 떨어졌습니다.
EUC-KR 로 판별됐는데 똠, 뷁 이 물음표가 되는 이유는?
EUC-KR(KS X 1001 완성형)에는 2,350개 음절만 있고 똠·뷁 은 CP949 의 확장 영역(8C 63, 94 EE)에 있기 때문입니다. PHP 에서 mb_detect_encoding 은 후보가 EUC-KR 뿐이면 false 를 돌려줬고, mb_convert_encoding(…, 'EUC-KR') 은 '?c??' 를 만들었습니다. 윈도우에서 만든 파일이면 EUC-KR 대신 CP949(PHP 이름 UHC)를 명시하세요.
파일을 나눠 읽으면 한글이 깨지는 이유는?
멀티바이트 문자가 두 청크에 걸쳐 잘렸는데 청크마다 따로 디코딩했기 때문입니다. 1MB 일본어 UTF-8 파일을 4,096바이트씩 읽으면 strict 모드에서 첫 경계에서 실패했고 errors='replace' 로는 U+FFFD 가 412개 생겼습니다. Python 의 codecs.getincrementaldecoder, PHP 의 convert.iconv 스트림 필터를 쓰면 파일 전체를 한 번에 변환한 결과와 완전히 같았습니다.