Mojibake는 잘못된 문자 집합으로 바이트를 디코딩했을 때 나타나는 깨진 텍스트다. “Café” 대신 “Café“가 나오거나, 일본어 제목이 뜬금없는 Latin 문자들의 나열로 바뀌는 식이다. 브라우저에서는 드문 일인데, 브라우저는 페이지의 인코딩을 알아내는 정확하고 표준화된 절차를 따르기 때문이다. 스크래핑 파이프라인에서는 흔한 일인데, HTTP 라이브러리가 보통 더 단순한 방식을 쓰기 때문이다.
우리는 실제 사이트에서 이 문제가 얼마나 자주 발생하는지 측정했고, 답은 대부분의 팀이 생각하는 것보다 더 자주였다. 이 글은 우리가 발견한 내용, 그 원인, 그리고 브라우저와 같은 순서를 따르는 디코딩 코드를 다룬다.
Key takeaways
- 1 October 2026에 우리는 여섯 개 국가 도메인(일본, 한국, 중국, 러시아, 대만, 독일)의 상위 50개 도메인 홈페이지를 가져왔다. 193개가 페이지를 반환했다.
- 193개 중 9개는 UTF-8이 아니었다. 일본은 Shift_JIS, 한국은 EUC-KR, 러시아는 windows-1251, 독일은 ISO-8859-1이었다.
- 더 큰 문제는 레거시 인코딩이 아니라 라이브러리의 기본값이었다. Python의 requests는 193개 페이지 중 37개, 약 19%를 ISO-8859-1로 디코딩해 모든 비-ASCII 문자를 깨뜨렸는데, 서버가 헤더에 charset을 명시하지 않았기 때문이다.
- 레거시 레이블은 이름이 말하는 그대로를 의미하지 않는다. 브라우저는 “ISO-8859-1”을 windows-1252로, “Shift_JIS”를 Windows 변형으로 디코딩하며, 엄격한 디코더는 일부 문자를 잘못 처리한다.
- 브라우저의 순서대로 디코딩하라: 바이트 순서 표시, HTTP 헤더, meta 태그, 그다음 유효성 검사와 감지.
What we measured
우리는 Tranco의 인기 도메인 목록에서 .jp, .kr, .cn, .ru, .tw, .de로 끝나는 상위 50개 도메인을 가져와 각 홈페이지를 한 번씩 요청했고, 상태 200으로 HTML 페이지를 반환한 193개를 남겼다. 각 페이지에 대해 선언된 인코딩, 실제 인코딩, 그리고 requests 라이브러리의 .text가 생성한 결과를 기록했다.
| Country domain | Pages | Not UTF-8 | Garbled by requests’ .text |
|---|---|---|---|
| 일본 (.jp) | 36 | 4 (Shift_JIS) | 8 |
| 한국 (.kr) | 31 | 2 (EUC-KR) | 4 |
| 중국 (.cn) | 33 | 0 | 10 |
| 러시아 (.ru) | 25 | 2 (windows-1251) | 4 |
| 대만 (.tw) | 29 | 0 | 6 |
| 독일 (.de) | 39 | 1 (ISO-8859-1) | 5 |
| 합계 | 193 | 9 | 37 |
UTF-8은 분명히 승리했다. 193개 중 184개가 UTF-8을 사용했다. 하지만 오른쪽 열은 인코딩 전쟁에서 승리했다고 해서 문제가 끝나지 않았음을 보여준다. 깨진 페이지 대부분은 UTF-8이었다. 이들은 HTTP 헤더가 아니라 <meta> 태그에서 이를 선언했고, 라이브러리는 그곳을 살펴보지 않았다.
Why the library got it wrong
응답이 charset 없이 Content-Type: text/html이라고만 말하면, requests는 오래된 HTTP/1.1의 텍스트 타입 기본값을 따라 인코딩을 ISO-8859-1로 설정한다. 페이지의 <meta charset> 태그는 읽지 않는다. 그러면 127보다 큰 모든 바이트가 Latin-1 문자로 디코딩되어, 어떤 언어의 UTF-8 텍스트든 깨져서 나온다.
| Original | Bytes decoded as Latin-1 |
|---|---|
| Café | Café |
| 価格 (일본어, “가격”) | ä¾¡æ ¼ |
| JRA日本中央競馬会 (Shift_JIS 페이지) | JRA 뒤에 제어 문자와 뜬금없는 Latin 문자들이 이어짐 |
아무런 오류도 발생하지 않는다. 텍스트는 여전히 유효한 문자열이고, 여전히 HTML로 파싱되며, 셀렉터도 여전히 일치한다. 피해는 나중에, 검색되지 않는 제품명, 깨진 중복 제거, 모델 학습 데이터에 섞인 쓰레기 값으로 나타나며, 이는 성공한 요청이 잘못된 콘텐츠를 반환하는 전형적인 사례다.
Legacy labels do not mean what they say
두 번째 함정은 더 미묘하다. 페이지가 레거시 인코딩을 선언할 때, 브라우저는 그 이름의 엄격한 표준을 사용하지 않는다. 브라우저가 구현하는 WHATWG Encoding Standard는 여러 레이블을 상위 집합으로 매핑한다.
| Declared label | What browsers actually decode |
|---|---|
| iso-8859-1, latin1, ascii | windows-1252 |
| gb2312, gbk | gb18030 |
| shift_jis | Windows 확장이 포함된 Shift_JIS (코드 페이지 932) |
| euc-kr | Windows 확장이 포함된 EUC-KR (코드 페이지 949) |
| big5 | 홍콩 보충 문자가 포함된 Big5 |
같은 이름의 엄격한 코덱으로 디코딩하면 오류가 발생하거나, 더 나쁘게는 다른 문자가 나온다. Shift_JIS를 선언한 한 일본 엔터테인먼트 사이트에서, Python의 엄격한 shift_jis 코덱은 전각 물결표 ”~“(U+FF5E)가 나올 때마다 이를 파동 대시 ”〜“(U+301C)로 바꿔버렸다. Encoding Standard의 인덱스는 해당 바이트 쌍을 전각 물결표로 매핑하는데, 이것이 방문자가 실제로 보는 문자다. 두 문자는 거의 똑같아 보이지만 서로 다른 문자열로 비교되므로, “1,000~2,000” 같은 가격 범위가 조용히 매칭에 실패하게 된다.
나머지 매핑은 더 요란하게 실패한다. gb2312로 레이블된 페이지가 그 표준 밖의 문자를 사용하거나, euc-kr 페이지가 확장된 한글 음절 중 하나를 사용하면, Python의 엄격한 코덱에서 디코딩 오류가 발생한다. 그리고 ISO-8859-1로 레이블된 페이지가 유로 기호를 사용하면 보이지 않는 제어 문자가 대신 나오는데, 유로 기호는 windows-1252에만 존재하기 때문이다.
Decode the way browsers do
HTML Standard는 순서를 이렇게 정의한다. 바이트 순서 표시가 우선하고, 그다음 전송 계층(HTTP 헤더)이 명시한 인코딩, 그다음 문서 시작 부분을 스캔해 찾은 <meta> 선언인데, 작성자는 이를 문서의 첫 1024바이트 안에 두어야 한다. 같은 순서를, 브라우저의 레이블 매핑과 감지 폴백과 함께 따르면 다음과 같다.
import codecs
import re
from charset_normalizer import from_bytes
# Legacy labels mean what browsers decode them as (WHATWG Encoding Standard), not their strict namesakes.
BROWSER_DECODER = {
"iso-8859-1": "cp1252", "latin1": "cp1252", "ascii": "cp1252", "us-ascii": "cp1252",
"gb2312": "gb18030", "gbk": "gb18030", "x-gbk": "gb18030",
"shift_jis": "cp932", "sjis": "cp932", "x-sjis": "cp932", "windows-31j": "cp932",
"euc-kr": "cp949", "ks_c_5601-1987": "cp949",
"big5": "big5hkscs",
}
HEADER_CHARSET = re.compile(r"charset\s*=\s*[\"']?([^;\"'\s]+)", re.I)
META_CHARSET = re.compile(rb"""<meta[^>]+charset\s*=\s*["']?\s*([A-Za-z0-9_.:-]+)""", re.I)
def python_codec(label):
"""Map a declared charset label to a Python codec name, or None if it is unknown."""
label = label.strip().lower()
label = BROWSER_DECODER.get(label, label)
try:
return codecs.lookup(label).name
except LookupError:
return None
def decode_html(body, content_type=""):
"""Decode HTML bytes in the browser's order: BOM, HTTP header, <meta>, then UTF-8, then detection."""
if body.startswith(codecs.BOM_UTF8):
return body[3:].decode("utf-8", "replace"), "utf-8 (BOM)"
header = HEADER_CHARSET.search(content_type or "")
meta = META_CHARSET.search(body[:1024]) # the HTML spec requires the declaration there
labels = (("header", header and header.group(1)), ("meta", meta and meta.group(1).decode("ascii", "replace")))
for source, label in labels:
codec = label and python_codec(label)
if codec:
return body.decode(codec, "replace"), f"{codec} ({source})"
try:
return body.decode("utf-8"), "utf-8 (valid)"
except UnicodeDecodeError:
guess = from_bytes(body).best()
codec = guess.encoding if guess else "cp1252"
return body.decode(codec, "replace"), f"{codec} (detected)"
여기에 원시 바이트(requests에서는 response.content)와 Content-Type 헤더를 전달하고, 절대 response.text를 넘기지 마라. 이 함수는 텍스트와 함께 인코딩이 어떻게 선택되었는지에 대한 기록을 반환하는데, 나중에 잘못된 판단을 추적할 수 있도록 각 페이지와 함께 저장해둘 가치가 있다.
193개 페이지 전체에 대해 실행한 결과, 교체 문자 없이 192개를 디코딩했다. 나머지 한 페이지는 UTF-8을 선언했지만 유효하지 않은 바이트 시퀀스 두 개를 포함하고 있었는데, 브라우저도 이를 교체 문자로 보여준다. 또한 라이브러리의 기본값이 깨뜨렸던 37개 페이지와, 레거시 인코딩인 9개 페이지에 대해서도 올바른 텍스트를 생성했다. 구성된 엣지 케이스에서 테스트한 결과, GBK 전용 문자가 포함된 gb2312 레이블 페이지, 확장된 한글 음절이 포함된 euc-kr 페이지, 유로 기호가 포함된 ISO-8859-1 페이지, 선언되지 않은 Shift_JIS 페이지를 모두 오류 없이 디코딩했다.
What else turned up
- 잘못된 위치의 선언. 첫 번째 조사에서, 17개 페이지가
<meta charset>을 첫 1024바이트 이후에 배치했는데, 이는 HTML 명세에 어긋난다. 그중 13개는 헤더에도 charset을 명시했고, 나머지 4개는 유효한 UTF-8이어서 아무 문제도 생기지 않았지만, meta 태그만 의존하는 디코더였다면 이를 놓쳤을 것이다. - 상충하는 선언. 한 대만 사이트는 헤더에서는 UTF-8을, meta 태그에서는 Big5를 보냈다. 브라우저에서와 마찬가지로 헤더가 우선하며, 이 경우에는 그것이 옳았다.
- 바이트 순서 표시. 4개 페이지가 UTF-8 바이트 순서 표시로 시작했다. 헤더에 charset이 없던 일부는 라이브러리에 의해 여전히 Latin-1로 디코딩되었고, 헤더가 올바른 경우에도 라이브러리는 텍스트 시작 부분에 보이지 않는 표시를 그대로 남겨두었다.
Practical rules
- 직접 바이트를 디코딩하라. 원시 응답을 보관하고, 원시 응답을 아카이브한다면 규칙이 바뀌어도 나중에 다시 디코딩할 수 있다.
- Latin-1을 가정하지 마라. 헤더에 charset이 없다는 것을 ISO-8859-1이 아니라 “더 찾아봐야 한다”는 신호로 다루어라.
- 모든 것을 UTF-8로 저장하라. 파이프라인 끝단에서 한 번만 디코딩하고, 어떤 인코딩이 사용되었는지 기록한 뒤, 그 이후로는 UTF-8을 유지하라.
- 교체 문자를 주시하라. 특정 필드에서 U+FFFD가 갑자기 늘어나는 것은 저렴하고 신뢰할 수 있는 경보이며, 스크래핑된 데이터의 스키마 드리프트에서 설명한 점검들과 함께 다뤄야 한다.
- 디코딩 후 정규화하라. 올바른 문자라도 전각 숫자나 서로 다른 물결표처럼 여러 방식으로 표기될 수 있다. 비교나 중복 제거 전에 정규화하라. 이에 대해서는 로케일 간 가격, 숫자, 날짜 정규화에서 다루었다.
The bottom line
이제 웹의 거의 전부가 UTF-8이지만, 파이프라인은 여전히 이를 깨뜨린다. 가장 흔한 실패 원인이 특이한 인코딩이 아니라 페이지 자체의 선언을 무시하는 라이브러리의 기본값이기 때문이다. 우리의 표본에서, 이 기본값은 약 5개 중 1개 페이지를 깨뜨렸고, 그중 어느 것도 오류를 일으키지 않았다.
바이트에서부터, 브라우저가 사용하는 순서대로, 브라우저가 매핑하는 방식으로 레이블을 매핑하여 디코딩하고, 어떤 규칙이 결정했는지 기록하라. 그러면 눈에 보이지 않던 데이터 품질 문제가 해결된 문제로 바뀐다.
Sources and references
- WHATWG, Encoding Standard, 레이블 표와 Shift_JIS 인덱스를 포함.
- WHATWG, HTML Standard: parsing HTML documents, 문자 인코딩 결정에 관하여.
- Tranco, list Q2K34, 1 to 30 September 2026의 도메인 순위를 집계.
- charset_normalizer, 버전 3.5.2, 그리고 테스트에 사용된 requests 2.32.5.
- Shifter가 1 October 2026에 위 코드를 사용해 가져온 홈페이지들.