Published on

Python UnicodeDecodeError 원인별 재현·해결 7가지

Authors

서버 로그, 크롤러, 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개입니다.

  1. 어떤 코덱으로 디코딩하려 했는지 예: utf-8
  2. 어떤 바이트에서 깨졌는지 예: 0x80, 0xe3
  3. 어느 위치에서 깨졌는지 예: 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"는 최후의 최후에만 쓰고, 사용 시 “손실 발생 가능”을 로그/메트릭으로 남기세요.

실무 체크리스트: 어디부터 고칠까

  1. 파일/네트워크/프로세스 등 입력 경계에서 bytes로 확보
  2. “이 입력은 어떤 인코딩이어야 하는가” 계약을 확인
  3. 스트리밍이면 증분 디코딩 적용
  4. 압축/바이너리 여부를 먼저 판별하고, 텍스트는 마지막에 디코딩
  5. BOM 가능성 있으면 utf-8-sig 우선 고려
  6. 임시 우회(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=는 목적에 맞게 제한적으로 사용하면 재발률이 크게 떨어집니다.