- Published on
Python UnicodeDecodeError 원인별 재현·해결 7가지
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 로그, 크롤러, ETL, 메시지 컨슈머 같은 파이프라인에서 UnicodeDecodeError는 가장 흔한 장애 중 하나입니다. 겉으로는 “utf-8로 디코딩하다가 실패”로 보이지만, 실제 원인은 파일 인코딩 불일치, 잘못된 I/O 모드, 부분 바이트(스트리밍), 압축/바이너리 오판, BOM, 로케일/환경 차이 등으로 다양합니다.
이 글은 원인별로 재현 코드를 먼저 보여주고, 그 다음에 해결 전략을 제시합니다. 장애를 “운 좋게 우회”하는 게 아니라, 어디서 무엇이 깨졌는지 추적 가능한 형태로 고칩니다.
관련해서 장애를 원인별로 쪼개 추적하는 관점은 다음 글도 참고할 만합니다.
UnicodeDecodeError 빠른 해석: 메시지 읽는 법
에러는 보통 이런 형태입니다.
UnicodeDecodeError: 'utf-8' codec can't decode byte 0x.. in position N: invalid start byte... invalid continuation byte... unexpected end of data
핵심 정보는 3개입니다.
- 어떤 코덱으로 디코딩하려 했는지 예:
utf-8 - 어떤 바이트에서 깨졌는지 예:
0x80,0xe3 - 어느 위치에서 깨졌는지 예:
position 1234
이 3개를 바탕으로 “원인 후보”를 좁혀야 합니다.
공통 진단 도구: 바이트를 먼저 확보하라
문제가 되는 입력을 문자열이 아니라 바이트로 확보하면 원인 추적이 쉬워집니다.
# bytes를 확보해 일부를 덤프
bad = payload_bytes # 예: 파일에서 rb로 읽은 결과
print(len(bad), bad[:80])
# 깨지는 지점을 중심으로 주변 바이트 확인
pos = 1234
print(bad[pos-20:pos+20])
또한 “정말 utf-8이 맞는지”를 확인하려면, 우선 엄격 디코딩을 시도해보는 게 좋습니다.
try:
s = bad.decode("utf-8", errors="strict")
except UnicodeDecodeError as e:
print("codec=utf-8", "start=", e.start, "end=", e.end, "reason=", e.reason)
이제부터 원인 7가지를 재현해 봅니다.
1) 파일 인코딩 불일치: CP949·Latin-1을 UTF-8로 읽음
재현
윈도우/레거시 시스템에서 온 텍스트는 CP949(EUC-KR 계열)인 경우가 많습니다. 이를 UTF-8로 열면 바로 깨집니다.
# CP949로 인코딩된 바이트를 만들어 놓고 utf-8로 디코딩 시도
b = "한글".encode("cp949")
print(b)
print(b.decode("utf-8")) # UnicodeDecodeError
해결
- 정답 인코딩을 명시해서 읽습니다.
- 모르면
charset_normalizer같은 추정 도구를 쓰되, “추정 결과를 로그로 남기고” 재현 가능하게 운영합니다.
from pathlib import Path
p = Path("data.txt")
text = p.read_text(encoding="cp949")
추정이 필요한 경우:
from charset_normalizer import from_bytes
raw = Path("data.txt").read_bytes()
best = from_bytes(raw).best()
print(best.encoding, best.chaos)
text = str(best)
운영 팁:
- 입력 소스별로 “기본 인코딩 계약”을 문서화하세요.
- CP949를 UTF-8로 변환해 저장하는 정규화 단계(ingestion)를 두면 이후 파이프라인이 단순해집니다.
2) 텍스트 모드에서 바이너리를 읽음: open(..., "r")로 이미지·gzip을 열기
재현
바이너리 파일을 텍스트로 열면 파이썬이 자동 디코딩을 시도하면서 실패합니다.
# gzip 헤더는 보통 0x1f 0x8b로 시작
raw = b"\x1f\x8b\x08\x00" + b"xxxx"
print(raw.decode("utf-8")) # UnicodeDecodeError 가능
실무에서는 아래처럼 발생합니다.
# 잘못된 예: 바이너리 파일을 텍스트로 열기
with open("data.gz", "r", encoding="utf-8") as f:
f.read() # 여기서 UnicodeDecodeError
해결
- 바이너리는 항상
rb로 읽고, 압축이면 먼저 해제한 뒤 텍스트로 디코딩합니다.
import gzip
with gzip.open("data.gz", "rb") as f:
raw = f.read()
text = raw.decode("utf-8")
또는 파일이 텍스트인지 바이너리인지 애매하면 “매직 바이트”를 간단히 검사하고 분기하세요.
from pathlib import Path
raw = Path("input").read_bytes()
if raw.startswith(b"\x1f\x8b"):
# gzip
import gzip
raw = gzip.decompress(raw)
text = raw.decode("utf-8")
3) 스트리밍·청크 처리에서 멀티바이트 경계가 잘림
UTF-8은 한 글자가 1바이트가 아닐 수 있습니다. 예를 들어 가는 3바이트입니다. 스트리밍에서 청크를 잘못 나누면 중간 바이트만 들어와 unexpected end of data가 납니다.
재현
# '가'는 UTF-8에서 3바이트
b = "가".encode("utf-8")
chunk1 = b[:2]
chunk2 = b[2:]
print(chunk1.decode("utf-8")) # UnicodeDecodeError: unexpected end of data
해결 1: 증분 디코더 사용
네트워크/파일 스트리밍에서는 codecs.getincrementaldecoder가 정석입니다.
import codecs
dec = codecs.getincrementaldecoder("utf-8")()
out = []
for chunk in [chunk1, chunk2]:
out.append(dec.decode(chunk))
out.append(dec.decode(b"", final=True))
print("".join(out))
해결 2: TextIOWrapper로 바이너리 스트림을 텍스트로 래핑
import io
bio = io.BytesIO("가나다".encode("utf-8"))
text_stream = io.TextIOWrapper(bio, encoding="utf-8")
print(text_stream.read())
운영 팁:
- HTTP 스트리밍, Kafka consumer, socket 등에서 바이트 청크를 바로
decode하지 말고, 증분 디코딩 계층을 두세요.
4) BOM(바이트 순서 표식) 때문에 첫 글자가 이상하거나 파서가 실패
utf-8-sig로 저장된 파일은 첫 3바이트가 BOM(0xef 0xbb 0xbf)입니다. 디코딩 자체는 되지만, CSV 헤더나 JSON 키 앞에 보이지 않는 문자가 붙어 장애가 납니다.
재현
raw = b"\xef\xbb\xbf" + "col1,col2".encode("utf-8")
text = raw.decode("utf-8")
print(repr(text)) # '\ufeffcol1,col2'
해결
- BOM 가능성이 있으면
utf-8-sig로 읽어 BOM을 제거합니다.
from pathlib import Path
text = Path("data.csv").read_text(encoding="utf-8-sig")
또는 이미 문자열이라면 선두 \ufeff를 제거합니다.
text = text.lstrip("\ufeff")
5) 잘못된 이중 디코딩/인코딩: bytes와 str 혼용으로 깨짐
실무에서 흔한 패턴은 “이미 문자열인데 또 디코딩”하거나, “문자열을 잘못된 방식으로 bytes로 바꿨다가 다시 디코딩”하는 경우입니다.
재현: mojibake(깨진 글자) 유발
s = "한글"
# 잘못된 경로: utf-8 bytes를 cp949로 해석하는 등
b = s.encode("utf-8")
bad = b.decode("cp949", errors="strict") # 경우에 따라 UnicodeDecodeError 또는 깨진 문자열
print(bad)
해결
- 경계는 명확히 하세요: I/O 경계에서는 bytes, 애플리케이션 내부에서는 str.
- 함수 시그니처에서 타입을 고정하고, 들어오는 값이 bytes인지 str인지 검사합니다.
from typing import Union
def ensure_text(x: Union[str, bytes], encoding: str = "utf-8") -> str:
if isinstance(x, str):
return x
return x.decode(encoding, errors="strict")
운영 팁:
- 로깅 시에도 bytes를 그대로 찍지 말고, 어디서 str로 바뀌는지 경계를 남겨두면 재발 방지가 됩니다.
6) subprocess 출력 인코딩 불일치: 로케일·콘솔 코드페이지 문제
리눅스에서는 보통 UTF-8이지만, 컨테이너/서버 환경에 따라 로케일이 C로 잡혀 있거나, 윈도우에서는 코드페이지 영향으로 출력 인코딩이 달라집니다. subprocess에서 text=True를 쓰면 파이썬이 기본 인코딩으로 디코딩하면서 실패할 수 있습니다.
재현(개념)
환경에 따라 다르지만, 핵심은 “프로세스 출력 bytes”를 “잘못된 encoding”으로 디코딩할 때입니다.
import subprocess
# 권장하지 않는 패턴: text=True에 encoding 미지정
p = subprocess.run(["python", "-c", "print('한글')"], capture_output=True, text=True)
print(p.stdout)
해결
text=True를 쓰되encoding을 명시합니다.- 또는 bytes로 받고 직접 디코딩합니다.
import subprocess
p = subprocess.run(
["python", "-c", "print('한글')"],
capture_output=True,
text=True,
encoding="utf-8",
errors="strict",
)
print(p.stdout)
로케일도 함께 고정하면 더 안전합니다.
import os
import subprocess
env = {**os.environ, "LC_ALL": "C.UTF-8", "LANG": "C.UTF-8"}
subprocess.run(["bash", "-lc", "locale"], env=env, check=True)
7) “일단 살려야 해서” errors=를 남발: 데이터 손실과 2차 장애
errors="ignore"나 errors="replace"는 에러를 없애지만, 데이터를 조용히 손상시킵니다. 특히 키/식별자/서명/해시/JSON 파싱 같은 곳에서 2차 장애를 만듭니다.
재현
b = b"abc\xffdef" # utf-8로는 불가
print(b.decode("utf-8", errors="ignore")) # 'abcdef' (바이트 유실)
print(b.decode("utf-8", errors="replace")) # 'abc�def' (대체 문자)
해결: 목적에 따라 “격리”해서 사용
- 사용자에게 보여줄 로그/미리보기:
replace허용 - 저장/정산/식별자/프로토콜:
strict유지 - 레거시 혼합 인코딩을 최대한 살려야 할 때:
surrogateescape고려
# bytes를 최대한 보존하면서 str로 옮기기(라운드트립 목적)
s = b.decode("utf-8", errors="surrogateescape")
back = s.encode("utf-8", errors="surrogateescape")
assert back == b
운영 팁:
errors="ignore"는 최후의 최후에만 쓰고, 사용 시 “손실 발생 가능”을 로그/메트릭으로 남기세요.
실무 체크리스트: 어디부터 고칠까
- 파일/네트워크/프로세스 등 입력 경계에서 bytes로 확보
- “이 입력은 어떤 인코딩이어야 하는가” 계약을 확인
- 스트리밍이면 증분 디코딩 적용
- 압축/바이너리 여부를 먼저 판별하고, 텍스트는 마지막에 디코딩
- BOM 가능성 있으면
utf-8-sig우선 고려 - 임시 우회(
ignore)가 필요하면, 손실 범위를 측정하고 격리
보너스: 실패 지점 주변을 사람이 읽을 수 있게 덤프하는 함수
원인 파악이 어려울 때, 실패 지점 주변 바이트를 헥스로 찍으면 바로 감이 옵니다.
def dump_around(raw: bytes, pos: int, window: int = 32) -> str:
start = max(0, pos - window)
end = min(len(raw), pos + window)
chunk = raw[start:end]
hexs = " ".join(f"{b:02x}" for b in chunk)
return f"pos={pos} range=[{start},{end}) hex={hexs}"
raw = "가나다".encode("utf-8")
try:
raw[:2].decode("utf-8")
except UnicodeDecodeError as e:
print(dump_around(raw[:2], e.start))
마무리
UnicodeDecodeError는 “utf-8이 싫다”가 아니라, 바이트의 정체(인코딩/형식/경계)가 불명확한 상태에서 텍스트로 단정했을 때 발생합니다. 위 7가지 원인 중 어디에 해당하는지 재현 코드로 분류한 뒤, 입력 경계에서 계약을 고정하고(인코딩 명시, 바이너리 분기, 스트리밍 증분 디코딩), errors=는 목적에 맞게 제한적으로 사용하면 재발률이 크게 떨어집니다.