- Published on
Python UnicodeDecodeError 원인별 해결 7가지
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 로그, 크롤링, CSV 적재, S3 내려받기, 서브프로세스 출력 파싱처럼 텍스트를 다루는 작업을 하다 보면 어느 순간 UnicodeDecodeError가 터집니다. 겉으로는 “utf-8로 디코딩하다 실패”처럼 보이지만, 실제 원인은 다양합니다. 중요한 건 에러 메시지의 구성(어떤 코덱으로, 어떤 바이트에서, 어떤 바이트 값 때문에 실패했는지)을 읽고, 원인에 맞는 처방을 하는 것입니다.
이 글에서는 실무에서 가장 자주 만나는 원인을 7가지로 나누고, 각 케이스별로 재현 코드와 해결책을 제시합니다. CSV 인코딩 자동탐지까지 더 깊게 다루고 싶다면 Python UnicodeDecodeError - CSV 인코딩 자동탐지 실전도 함께 참고하세요. 또한 UTF-8 관련 케이스를 더 넓게 보고 싶다면 Python UnicodeDecodeError - utf-8 에러 해결 가이드도 연결해 두겠습니다.
UnicodeDecodeError 메시지부터 읽는 법
대표적인 형태는 다음과 같습니다.
UnicodeDecodeError: 'utf-8' codec can't decode byte 0x.. in position N: invalid start byte
여기서 핵심 단서는 3개입니다.
- 어떤 코덱으로 디코딩했는가: 예)
utf-8 - 문제 바이트: 예)
0x80,0xff등 - 위치: 예)
position 1234
즉 “UTF-8로 해석하려 했는데 해당 위치의 바이트열이 UTF-8 규칙에 맞지 않는다”는 뜻입니다. 그러면 다음 질문으로 넘어갑니다.
- 데이터 자체가 UTF-8이 아닌가
- 읽는 방식(텍스트 모드, 줄바꿈 변환, 잘린 바이트)이 잘못됐나
- 바이트는 맞는데 BOM, 혼합 인코딩, 잘못된 헤더 등 주변 조건이 문제인가
이제 원인별로 해결책을 보겠습니다.
1) 파일 인코딩이 UTF-8이 아닌데 UTF-8로 읽었다
가장 흔한 케이스입니다. 윈도우에서 만든 CSV나 레거시 시스템 덤프는 cp949, euc-kr, shift_jis, latin-1 등일 수 있습니다.
재현
# wrong_read.py
with open("data.txt", "r", encoding="utf-8") as f:
text = f.read()
print(text)
파일이 cp949인데 utf-8로 열면 디코딩 에러가 납니다.
해결
- 올바른 인코딩을 지정해서 읽기
with open("data.txt", "r", encoding="cp949") as f:
text = f.read()
- 인코딩을 모르면 후보를 좁히는 전략을 씁니다.
candidates = ["utf-8", "utf-8-sig", "cp949", "euc-kr", "latin-1"]
for enc in candidates:
try:
with open("data.txt", "r", encoding=enc) as f:
f.read()
print("seems ok:", enc)
break
except UnicodeDecodeError:
pass
latin-1은 어떤 바이트도 실패하지 않고 매핑해버리므로 “성공”이 곧 “정답”은 아닙니다. 다만 긴급 복구(최소한 깨진 문자라도 살려야 할 때)에는 유용합니다.
2) UTF-8 BOM(utf-8-sig) 때문에 첫 컬럼명이 깨진다
에러가 아니라 “이상한 문자”로 시작하는 케이스도 많습니다. 대표적으로 CSV 첫 헤더가 \ufeffid처럼 보이는 현상입니다. UTF-8 BOM이 붙은 파일을 utf-8로 읽으면 BOM이 문자로 들어옵니다.
재현
with open("bom.csv", "r", encoding="utf-8") as f:
header = f.readline()
print(repr(header))
해결
with open("bom.csv", "r", encoding="utf-8-sig") as f:
header = f.readline()
print(repr(header))
Pandas를 쓰면 다음처럼 처리합니다.
import pandas as pd
df = pd.read_csv("bom.csv", encoding="utf-8-sig")
3) 바이너리 파일을 텍스트로 읽었다(또는 gzip을 그냥 열었다)
로그 수집 파이프라인에서 .gz를 그냥 open()으로 열거나, 이미지나 parquet 같은 바이너리를 텍스트로 읽으면 디코딩 에러가 납니다. 이건 “인코딩이 틀렸다”가 아니라 “텍스트가 아니다”가 원인입니다.
재현
with open("image.png", "r", encoding="utf-8") as f:
f.read()
해결
- 바이너리는 바이너리 모드로 읽습니다.
with open("image.png", "rb") as f:
data = f.read()
- gzip은 전용 모듈로 엽니다.
import gzip
with gzip.open("app.log.gz", "rt", encoding="utf-8") as f:
for line in f:
pass
여기서도 rt 모드에서 인코딩을 명시하는 습관이 중요합니다.
4) 스트림/네트워크 데이터가 중간에서 잘려 멀티바이트가 깨졌다
UTF-8은 문자 하나가 1바이트일 수도, 2~4바이트일 수도 있습니다. 네트워크 스트림이나 파일 tailing에서 임의로 바이트를 잘라 디코딩하면, 문자 경계가 깨져 에러가 납니다.
재현: 바이트를 임의로 끊어서 디코딩
s = "한글ABC"
b = s.encode("utf-8")
chunk1 = b[:2] # '한'은 3바이트라서 여기서 끊으면 깨짐
chunk2 = b[2:]
print(chunk1.decode("utf-8"))
해결 1: 증분 디코더 사용
import codecs
decoder = codecs.getincrementaldecoder("utf-8")()
out = []
for chunk in [b[:2], b[2:]]:
out.append(decoder.decode(chunk))
out.append(decoder.decode(b"", final=True))
print("".join(out))
해결 2: errors="replace" 또는 errors="ignore"는 최후의 수단
데이터를 반드시 살려야 하는 로그 파이프라인에서 부분 손상을 허용한다면:
text = chunk1.decode("utf-8", errors="replace")
replace는 깨진 바이트를 \ufffd로 치환하고, ignore는 버립니다. 둘 다 데이터 품질을 떨어뜨리므로 “왜 스트림이 잘렸는지”를 먼저 추적하는 게 정석입니다.
5) 기본 인코딩(로케일) 차이로 운영체제마다 터진다
개발 PC에서는 잘 되는데 리눅스 서버에서만, 혹은 반대로 윈도우에서만 터지는 경우가 있습니다. 원인은 다음 중 하나입니다.
- OS 기본 인코딩이 다름(윈도우는
cp949계열이 흔함) open()에encoding을 지정하지 않음- 서브프로세스 출력 디코딩이 플랫폼 기본값에 의존
해결: 텍스트 I/O는 항상 encoding을 명시
with open("data.txt", "r", encoding="utf-8") as f:
...
현재 기본 인코딩 확인
import locale
import sys
print(locale.getpreferredencoding(False))
print(sys.getdefaultencoding())
운영 환경이 컨테이너라면 LANG, LC_ALL 설정도 영향을 줍니다. 다만 근본적으로는 “기본값에 기대지 말고 명시하라”가 가장 안정적입니다.
6) subprocess 출력이 실제로는 UTF-8이 아닌데 UTF-8로 디코딩했다
외부 명령의 출력이 OS 로케일에 따라 cp949로 나오거나, 툴 자체가 다른 인코딩을 쓰는 경우가 있습니다. text=True를 쓰면 파이썬이 자동 디코딩하는데, 이때 인코딩이 맞지 않으면 에러가 납니다.
재현 패턴
import subprocess
p = subprocess.run(
["some-cli"],
capture_output=True,
text=True, # 자동 디코딩
encoding="utf-8" # 잘못 지정하면 여기서 터짐
)
print(p.stdout)
해결 1: 올바른 인코딩 지정
p = subprocess.run(
["some-cli"],
capture_output=True,
text=True,
encoding="cp949",
errors="strict"
)
해결 2: 바이트로 받은 뒤 직접 처리
p = subprocess.run(["some-cli"], capture_output=True)
raw = p.stdout
text = raw.decode("utf-8", errors="replace")
바이트로 받으면 “어디서 디코딩할지”를 통제할 수 있어 디버깅이 쉬워집니다.
7) 데이터가 혼합 인코딩이거나 일부 라인만 깨져 있다
실무에서 가장 골치 아픈 케이스입니다.
- 파일의 대부분은 UTF-8인데 특정 라인만
cp949 - 크롤링 결과가 페이지마다 인코딩이 다름
- ETL 중간 단계에서 잘못된 변환이 섞임
이 경우 “파일 전체를 하나의 인코딩으로 읽기”가 실패합니다.
해결 1: 바이너리로 읽고 라인 단위로 복구
from typing import Iterable
def decode_lines(path: str, encodings: list[str]) -> Iterable[str]:
with open(path, "rb") as f:
for raw_line in f:
for enc in encodings:
try:
yield raw_line.decode(enc)
break
except UnicodeDecodeError:
continue
else:
# 어떤 인코딩으로도 안 되면 대체 처리
yield raw_line.decode("utf-8", errors="replace")
for line in decode_lines("mixed.txt", ["utf-8", "cp949", "euc-kr"]):
pass
라인 단위 복구는 “완벽한 정답”이라기보다, 장애 대응이나 마이그레이션에서 자주 쓰는 현실적인 접근입니다.
해결 2: 자동 탐지 도구 사용(정확도 한계 인지)
charset-normalizer(파이썬3 계열에서 권장)나 chardet로 추정할 수 있습니다.
from charset_normalizer import from_bytes
data = open("data.txt", "rb").read()
result = from_bytes(data).best()
print(result.encoding)
text = str(result)
자동 탐지는 샘플링/확률 기반이라 100퍼센트가 아닙니다. 특히 짧은 텍스트, 숫자 위주 데이터, 혼합 인코딩에서는 오판이 잦습니다. CSV 실전 자동탐지 전략은 위에서 링크한 글(Python UnicodeDecodeError - CSV 인코딩 자동탐지 실전)에서 더 깊게 다뤘습니다.
디버깅 체크리스트: 재현 가능한 형태로 좁히기
원인을 빠르게 확정하려면 아래 순서가 효율적입니다.
- 문제가 난 입력을 텍스트 모드가 아니라
rb로 읽어 “원본 바이트”를 확보 - 에러 메시지의
position근처 바이트를 덤프 - 후보 인코딩으로 해당 구간만 디코딩 시도
- 스트림이라면 “chunk 경계”에서 깨지는지 확인(증분 디코더로 테스트)
바이트 덤프 예시는 다음처럼 만들 수 있습니다.
path = "data.txt"
try:
open(path, "r", encoding="utf-8").read()
except UnicodeDecodeError as e:
pos = e.start
with open(path, "rb") as f:
f.seek(max(0, pos - 16))
snippet = f.read(64)
print("error pos:", pos)
print("bytes around:", snippet)
이렇게 하면 “정말로 UTF-8이 아닌 바이트가 섞였는지”, “gzip 헤더 같은 바이너리 시그니처가 섞였는지”를 빠르게 판단할 수 있습니다.
마무리: 해결책은 하나가 아니라, 원인에 맞춰야 한다
UnicodeDecodeError를 무조건 errors="ignore"로 덮으면 당장은 넘어가지만, 데이터 유실과 침묵 오류가 쌓입니다. 대신 다음 원칙을 추천합니다.
- 텍스트 I/O는
encoding을 명시한다 - 파일이 텍스트인지 바이너리인지 먼저 구분한다
- 스트림은 증분 디코딩을 고려한다
- 혼합 인코딩은 라인 단위 복구 또는 자동탐지를 “제한적으로” 사용한다
UTF-8 중심의 실전 대응을 더 모아둔 글은 Python UnicodeDecodeError - utf-8 에러 해결 가이드에서 이어서 볼 수 있습니다.