Published on

Python UnicodeDecodeError - utf-8 에러 해결 가이드

Authors

서버 로그나 크롤러, ETL 파이프라인을 운영하다 보면 UnicodeDecodeError: 'utf-8' codec can't decode byte ...는 거의 한 번은 만나게 됩니다. 핵심은 간단합니다. **"UTF-8로 디코딩하려고 했는데 실제 바이트가 UTF-8이 아니거나(또는 깨졌거나), 경계가 잘렸거나, 잘못된 디코딩 지점을 선택했다"**는 뜻입니다.

이 글에서는 에러 메시지를 읽는 법부터, 파일/표준입출력/네트워크/서브프로세스/DB 등 발생 지점별로 재현과 해결 코드를 정리합니다. 운영 환경에서 자주 같이 터지는 리소스 문제(예: 파일 핸들 누수)도 함께 점검 포인트로 다룹니다. 필요하다면 Linux EMFILE(Too many open files) 원인과 해결도 같이 확인해보세요.

UnicodeDecodeError 메시지부터 해석하기

대표적인 에러 형태는 다음과 같습니다.

UnicodeDecodeError: 'utf-8' codec can't decode byte 0xXX in position N: invalid start byte

해석 포인트:

  • byte 0xXX: 문제를 일으킨 바이트 값
  • position N: 디코딩 대상 바이트열에서의 오프셋
  • invalid start byte / invalid continuation byte: UTF-8 규칙에 맞지 않는 바이트 시퀀스

자주 보이는 바이트 힌트:

  • 0x80~0x9F 등: Windows-1252/CP949/EUC-KR 등 다른 인코딩일 가능성
  • 0xFF 0xFE 또는 0xFE 0xFF: UTF-16 BOM
  • 0xEF 0xBB 0xBF: UTF-8 BOM(대개는 문제 없지만, 처리 방식에 따라 첫 글자에 BOM이 남을 수 있음)

가장 흔한 원인 5가지

1) 파일이 UTF-8이 아니다 (CP949/EUC-KR/Latin-1 등)

한국 환경에서 특히 많은 케이스입니다. open(..., encoding='utf-8')로 열었는데 파일이 CP949라면 바로 터집니다.

2) 바이너리 데이터를 텍스트로 디코딩했다

PDF/이미지/압축파일/프로토콜 바이트를 decode('utf-8') 해버리면 당연히 실패합니다.

3) 스트림 경계가 잘렸다 (멀티바이트 문자 분리)

소켓/서브프로세스 stdout을 chunk 단위로 읽다가 UTF-8 멀티바이트 문자가 중간에서 끊기면 디코딩 에러가 납니다.

4) 잘못된 디코딩 지점 선택 (bytes vs str 혼동)

이미 str인데 또 .decode()를 하거나, 반대로 bytes인데 .encode()를 하는 실수도 잦습니다.

5) 운영체제/터미널/로케일 기본 인코딩 이슈

Windows 콘솔, Docker 이미지의 locale 설정, CI 환경에서 기본 인코딩이 기대와 다를 수 있습니다.

해결 전략 1: 파일 인코딩을 확정하고 명시하기

UTF-8 파일이라면: 항상 encoding을 명시

from pathlib import Path

text = Path("data.txt").read_text(encoding="utf-8")

UTF-8 BOM이 섞인 경우: utf-8-sig

엑셀/윈도우 도구로 저장된 CSV에서 흔합니다.

with open("data.csv", "r", encoding="utf-8-sig") as f:
    header = f.readline()

CP949/EUC-KR 가능성이 높다면

with open("legacy.txt", "r", encoding="cp949") as f:
    text = f.read()

cp949euc-kr은 유사하지만 완전히 같지 않습니다. Windows에서 만들어진 파일이면 cp949가 더 맞는 경우가 많습니다.

해결 전략 2: “모르겠으면 탐지”하되, 운영에서는 보수적으로

인코딩 자동 감지는 100%가 아닙니다. 특히 짧은 텍스트는 오탐이 잦습니다. 그래도 로그/임시 처리에는 유용합니다.

chardet/charset-normalizer로 추정

# pip install charset-normalizer
from charset_normalizer import from_bytes

raw = open("unknown.txt", "rb").read()
result = from_bytes(raw).best()
print(result.encoding, result.chaos)
text = str(result)

운영 팁:

  • 추정 결과를 그대로 신뢰하기보다 후보군(utf-8, utf-8-sig, cp949 등)을 순서대로 시도하는 방식이 더 안정적일 때가 많습니다.
def read_text_with_fallback(path: str, encodings=("utf-8", "utf-8-sig", "cp949", "euc-kr", "latin-1")):
    raw = open(path, "rb").read()
    last_err = None
    for enc in encodings:
        try:
            return raw.decode(enc)
        except UnicodeDecodeError as e:
            last_err = e
    raise last_err

text = read_text_with_fallback("unknown.txt")

해결 전략 3: 실패를 허용해야 한다면 errors 옵션을 설계하기

디코딩 실패를 “없애는” 가장 쉬운 방법은 errors=를 쓰는 것입니다. 다만 이는 데이터 품질을 바꿉니다. 목적에 따라 선택해야 합니다.

1) errors='replace': 대체 문자(�)로 치환

raw = open("broken.log", "rb").read()
text = raw.decode("utf-8", errors="replace")
  • 장점: 파이프라인이 멈추지 않음
  • 단점: 원문 손실, 후처리/검색 정확도 저하

2) errors='ignore': 문제 바이트를 버림

text = raw.decode("utf-8", errors="ignore")
  • 장점: 진행은 됨
  • 단점: 조용히 데이터가 사라짐(디버깅 어려움)

3) errors='surrogateescape': 원 바이트를 보존(고급)

POSIX 환경에서 파일명/로그 처리 등에 유용합니다.

text = raw.decode("utf-8", errors="surrogateescape")
# 나중에 다시 bytes로 복원 가능
back = text.encode("utf-8", errors="surrogateescape")

해결 전략 4: 스트리밍/청크 처리에서는 “증분 디코더”를 사용

소켓이나 서브프로세스 stdout을 chunk로 읽을 때, UTF-8 멀티바이트 문자가 경계에서 잘리면 디코딩이 깨집니다.

잘못된 예: chunk마다 decode

# 위험: chunk 경계에서 멀티바이트가 잘리면 UnicodeDecodeError
while True:
    chunk = sock.recv(4096)
    if not chunk:
        break
    print(chunk.decode("utf-8"))

올바른 예: codecs.getincrementaldecoder

import codecs

decoder = codecs.getincrementaldecoder("utf-8")(errors="strict")

buf = []
while True:
    chunk = sock.recv(4096)
    if not chunk:
        break
    buf.append(decoder.decode(chunk))

buf.append(decoder.decode(b"", final=True))
text = "".join(buf)

이 패턴은 subprocess stdout 처리에도 그대로 적용됩니다.

해결 전략 5: subprocess 출력은 text 모드로 받고 인코딩을 명시

서브프로세스 출력은 OS/로케일 영향을 받습니다. 특히 Windows에서 cp949로 출력되는 경우가 많습니다.

import subprocess

p = subprocess.run(
    ["some-command"],
    capture_output=True,
    text=True,
    encoding="utf-8",  # 필요 시 cp949
    errors="replace",
)
print(p.stdout)

스트리밍으로 처리해야 한다면:

import subprocess

with subprocess.Popen(
    ["some-command"],
    stdout=subprocess.PIPE,
    stderr=subprocess.STDOUT,
) as proc:
    for line in proc.stdout:  # bytes 라인
        # 라인 단위도 경계 문제가 있을 수 있으니 필요 시 incremental decoder 고려
        print(line.decode("utf-8", errors="replace"), end="")

해결 전략 6: requests/HTTP 응답은 “바이트 → 인코딩 결정 → 텍스트” 순서로

requestsresponse.encoding을 추정하지만, 서버가 잘못된 헤더를 보내면 오판할 수 있습니다.

import requests

r = requests.get("https://example.com")
raw = r.content  # bytes

# 1) 서버가 charset을 정확히 줬다면
if r.encoding:
    text = raw.decode(r.encoding, errors="strict")
else:
    # 2) 없으면 apparent_encoding 등으로 추정
    r.encoding = r.apparent_encoding
    text = r.text

운영 팁:

  • HTML/CSV 다운로드 자동화에서 깨짐이 반복되면, 응답 헤더의 Content-Type: ...; charset=...를 먼저 확인하세요.

해결 전략 7: CSV/JSON 처리에서의 함정과 처방

CSV: newline=''와 encoding을 함께

import csv

with open("data.csv", "r", encoding="utf-8-sig", newline="") as f:
    reader = csv.DictReader(f)
    for row in reader:
        pass

JSON: bytes라면 먼저 decode, 또는 json.loads에 bytes를 주지 않기

import json

raw = open("data.json", "rb").read()
text = raw.decode("utf-8")
obj = json.loads(text)

디버깅 체크리스트 (재발 방지용)

1) 문제 파일의 “처음 몇 바이트”를 확인

BOM/UTF-16 여부를 빠르게 판별할 수 있습니다.

raw = open("mystery.bin", "rb").read(16)
print(raw)
print(raw.hex(" "))

2) 예외의 position 주변을 덤프

def dump_around(raw: bytes, pos: int, radius: int = 16):
    start = max(0, pos - radius)
    end = min(len(raw), pos + radius)
    snippet = raw[start:end]
    return start, snippet.hex(" ")

raw = open("unknown.txt", "rb").read()
try:
    raw.decode("utf-8")
except UnicodeDecodeError as e:
    start, hx = dump_around(raw, e.start)
    print("error at", e.start, "dump from", start)
    print(hx)

3) 파일 핸들 누수로 증상이 확대되지 않는지

대량 파일을 순회하면서 열고 닫지 않으면, 인코딩 문제처럼 보이는 2차 장애가 발생할 수 있습니다(처리 중단/부분 읽기/예외 연쇄). 대규모 배치라면 Linux EMFILE(Too many open files) 원인과 해결도 함께 점검하는 것이 좋습니다.

자주 쓰는 “현장용” 해결 레시피

레시피 A: 로그 파일은 최대한 살리고 파이프라인은 멈추지 않기

from pathlib import Path

raw = Path("app.log").read_bytes()
text = raw.decode("utf-8", errors="replace")
# 이후 분석/정규식 처리

레시피 B: 레거시 텍스트를 UTF-8로 정규화해서 저장

from pathlib import Path

src = Path("legacy.txt").read_bytes()
text = src.decode("cp949")
Path("legacy.utf8.txt").write_text(text, encoding="utf-8", newline="\n")

레시피 C: UTF-16 파일을 감지해 처리

from pathlib import Path

raw = Path("maybe-utf16.txt").read_bytes()

if raw.startswith(b"\xff\xfe"):
    text = raw.decode("utf-16-le")
elif raw.startswith(b"\xfe\xff"):
    text = raw.decode("utf-16-be")
else:
    text = raw.decode("utf-8")

정리: “UTF-8로 읽는다”는 가정부터 검증하자

UnicodeDecodeError: 'utf-8'는 파이썬이 까다로운 게 아니라, 입력 바이트의 정체가 불명확한 상태에서 디코딩을 시도했기 때문에 발생합니다. 해결은 크게 세 가지 축으로 정리됩니다.

  1. 인코딩을 확정하고 명시한다(utf-8/utf-8-sig/cp949/utf-16 등).
  2. 스트리밍에서는 증분 디코딩으로 경계 문제를 제거한다.
  3. 실패를 허용해야 한다면 errors= 정책을 명확히 정한다(replace/ignore/surrogateescape).

이 세 가지를 코드 레벨에서 표준화해두면, 크롤링/로그 수집/ETL/서브프로세스 연동 어디서든 재발을 크게 줄일 수 있습니다.