- Published on
Python UnicodeDecodeError - utf-8 해결법
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
운영 환경에서 로그를 파싱하거나 CSV를 읽고, 외부 프로세스 출력을 수집하다 보면 갑자기 UnicodeDecodeError: 'utf-8' codec can't decode byte ... 가 터집니다. 특히 로컬에서는 잘 되는데 서버에서만 실패하거나, 같은 파일인데 특정 줄에서만 죽는 경우가 많습니다.
이 에러는 본질적으로 “지금 당신이 utf-8 로 디코딩하려는 바이트가 실제로는 utf-8 이 아니다”라는 뜻입니다. 해결은 감으로 encoding='cp949' 를 붙이는 수준을 넘어서, 데이터가 어디서 왔는지(파일/네트워크/DB/프로세스) 와 바이트가 언제 문자열로 바뀌는지(디코딩 경계) 를 정확히 잡는 데서 시작합니다.
아래는 실무에서 재현 빈도가 높은 원인과, 재발 방지까지 고려한 해결 패턴입니다.
에러 메시지부터 해석하기
대표 메시지는 다음 형태입니다.
UnicodeDecodeError: 'utf-8' codec can't decode byte 0x?? in position N: invalid start byte... invalid continuation byte
핵심 포인트는 3가지입니다.
byte 0x??: 문제를 일으킨 바이트 값position N: 디코딩 중 N번째 바이트에서 실패invalid start/continuation:utf-8규칙에 맞지 않는 바이트 패턴
즉, 파일이 cp949 나 euc-kr 인데 utf-8 로 읽었거나, 중간에 바이너리/깨진 바이트가 섞였거나, 혹은 “문자열이라고 믿었지만 사실은 바이트” 인 값을 잘못 처리한 경우가 대부분입니다.
1) 파일 읽기: 기본 인코딩에 기대지 않기
흔한 함정
open(path).read()처럼encoding을 생략하면 OS/로케일에 따라 기본값이 달라집니다.- 컨테이너/서버는
C로케일이라 기본이ascii인 경우도 있고, 반대로 로컬은utf-8이라서 문제를 못 느낍니다.
해결 패턴: 명시 + 에러 처리 전략
from pathlib import Path
path = Path("data.txt")
# 1) 가장 권장: 소스가 utf-8임이 확실할 때
text = path.read_text(encoding="utf-8")
# 2) 깨진 바이트가 섞여도 파이프라인을 계속 돌려야 할 때
text = path.read_text(encoding="utf-8", errors="replace")
# 3) 손실 없이 보존해야 할 때(나중에 원복/분석)
text = path.read_text(encoding="utf-8", errors="surrogateescape")
errors="replace"는 문제가 되는 바이트를같은 대체 문자로 바꿉니다. 데이터 품질은 떨어지지만 ETL이 멈추는 건 막습니다.errors="surrogateescape"는 “디코딩 불가능 바이트를 유니코드 서러게이트 영역에 임시 보관”합니다. 원 바이트를 최대한 잃지 않으면서도 문자열 API를 유지할 수 있어, 로그 수집/포렌식에 유리합니다.
2) 인코딩이 섞인 파일: 감지 후 안전하게 읽기
현실의 데이터는 한 파일이 온전히 한 인코딩이 아닐 때도 있습니다.
- 헤더는
utf-8, 본문은cp949 - 특정 라인에만 Windows-1252 바이트가 섞임
- 바이너리 조각(예: 압축/이미지)이 텍스트 파일에 섞임
최소 비용 감지(휴리스틱)
외부 라이브러리 없이도 “대략적인 후보군”을 좁힐 수 있습니다.
from pathlib import Path
raw = Path("data.txt").read_bytes()
candidates = ["utf-8", "cp949", "euc-kr", "latin-1"]
for enc in candidates:
try:
raw.decode(enc)
print("likely:", enc)
break
except UnicodeDecodeError:
pass
else:
print("no candidate matched")
latin-1은 어떤 바이트든 디코딩이 되기 때문에 “최후의 수단”입니다. 대신 원문이 깨져 보일 수 있어, 진짜 해결이라기보다 파이프라인을 멈추지 않는 임시방편으로만 쓰는 게 좋습니다.
실무 추천: charset-normalizer 또는 chardet
정확도를 높이려면 감지 라이브러리를 쓰는 편이 낫습니다.
pip install charset-normalizer
from charset_normalizer import from_bytes
raw = open("data.txt", "rb").read()
result = from_bytes(raw).best()
print(result.encoding, result.percent_chaos)
text = str(result)
- 감지는 100%가 아닙니다. 따라서 감지 결과를 그대로 믿기보다, 소스 시스템/생성 경로를 함께 추적해 “원천 인코딩을 고정”하는 게 최종 목표입니다.
3) CSV에서 자주 터지는 케이스: pandas.read_csv
CSV는 인코딩 이슈가 가장 자주 폭발하는 영역입니다. 특히 엑셀에서 저장된 CSV는 cp949 또는 utf-8-sig 인 경우가 많습니다.
import pandas as pd
# 엑셀/윈도우 계열에서 흔한 케이스
df = pd.read_csv("data.csv", encoding="cp949")
# UTF-8 BOM이 붙은 파일(첫 컬럼명이 이상하게 보일 때)
df = pd.read_csv("data.csv", encoding="utf-8-sig")
# 깨진 줄이 있어도 일단 적재를 진행해야 할 때(데이터 손실 가능)
df = pd.read_csv("data.csv", encoding="utf-8", encoding_errors="replace")
pandas 쪽 이슈는 인코딩 외에도 메모리/타입 추론 문제와 함께 얽히는 경우가 많습니다. 대용량 CSV에서 read_csv 가 경고와 함께 느려지거나 메모리가 터진다면 아래 글도 같이 보는 것을 권합니다.
4) 서브프로세스 출력에서 터지는 케이스: subprocess
운영에서 자주 보는 패턴이 “외부 명령 출력 로그를 받아 파싱하다가 utf-8 디코딩이 실패”입니다. 원인은 보통 다음 중 하나입니다.
- 명령이 로케일에 따라
cp949등으로 출력 - stderr에 바이너리/제어문자가 섞임
- 파이프가 중간에 깨져 불완전한 멀티바이트 시퀀스가 들어옴
해결 패턴: 텍스트 모드의 인코딩을 명시
import subprocess
p = subprocess.run(
["bash", "-lc", "some_command"],
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
)
stdout = p.stdout
stderr = p.stderr
더 안전한 패턴: 바이트로 받고, 내가 디코딩 경계를 통제
import subprocess
p = subprocess.run(
["bash", "-lc", "some_command"],
capture_output=True,
text=False,
)
stdout = p.stdout.decode("utf-8", errors="surrogateescape")
stderr = p.stderr.decode("utf-8", errors="surrogateescape")
이 방식은 “어느 시점에 문자열이 되는지”를 명확히 하므로, 디코딩 실패 지점을 로깅/격리하기가 훨씬 쉽습니다.
5) HTTP 응답/크롤링에서 터지는 케이스
웹 응답은 Content-Type 헤더의 charset 과 실제 바이트가 불일치하는 경우가 있습니다. 또는 EUC-KR 페이지를 utf-8 로 가정하고 디코딩해 실패하기도 합니다.
requests 기본값에만 의존하지 않기
import requests
resp = requests.get("https://example.com")
raw = resp.content # bytes
# 서버가 준 인코딩을 우선 적용(없으면 추정)
encoding = resp.encoding or "utf-8"
text = raw.decode(encoding, errors="replace")
resp.text 는 내부적으로 추정 인코딩을 적용하므로 편하지만, 문제 상황에서는 바이트를 직접 다루는 편이 디버깅에 유리합니다.
6) 로그/ETL 파이프라인에서의 재발 방지 체크리스트
1) “바이트 vs 문자열” 경계를 문서화
- 파일 읽기 지점
- 메시지 큐/카프카 컨슈머에서 디코딩 지점
- DB 드라이버가 반환하는 타입
- 서브프로세스 출력 수집 지점
디코딩이 여러 군데 흩어져 있으면, 같은 데이터를 서로 다른 인코딩으로 두 번 디코딩하려다 사고가 납니다.
2) 저장은 가능하면 utf-8 로 통일
- 원천이
cp949라면 ingest 단계에서utf-8로 정규화 - 원문 바이트가 필요하면 별도 컬럼/파일로 보관
3) 실패한 레코드 격리(Dead-letter) 설계
- 전체 배치 실패 대신 “문제 라인만 격리”가 운영 비용을 크게 줄입니다.
대규모 파이프라인에서 장애가 연쇄적으로 번질 때는 프로세스 재시작 루프와 결합되기도 합니다. 서비스가 계속 재시작되며 같은 입력을 반복 처리해 장애가 증폭된다면 아래 글의 진단 흐름이 도움이 됩니다.
7) “일단 돌아가게”와 “정확히 고치기”의 차이
UnicodeDecodeError 를 빨리 막는 방법은 errors="replace" 나 latin-1 디코딩처럼 많습니다. 하지만 이건 종종 조용한 데이터 손상을 만들 수 있습니다.
실무적으로는 다음 우선순위를 추천합니다.
- 원천 인코딩을 확인하고, ingest 지점에서
encoding을 명시 - 원천이 불명확하거나 혼합이라면 감지 라이브러리로 후보를 좁히고, 실패 레코드 격리
- 파이프라인을 멈추면 안 되는 구간에서만
errors="replace"또는surrogateescape를 사용
재현용 미니 예제: 왜 utf-8 이 실패하는가
아래는 cp949 바이트를 utf-8 로 디코딩하려다 실패하는 전형적인 재현입니다.
# "한글"을 cp949로 인코딩한 바이트
raw = "한글".encode("cp949")
try:
raw.decode("utf-8")
except UnicodeDecodeError as e:
print("failed:", e)
# 올바른 디코딩
print(raw.decode("cp949"))
핵심은 간단합니다. 바이트는 죄가 없고, 잘못된 디코더를 붙인 쪽이 문제입니다. 따라서 해결의 본질은 “어떤 인코딩으로 만들어진 바이트인가”를 소스까지 거슬러 올라가 확정하는 것입니다.
결론
UnicodeDecodeError: 'utf-8' 는 단순히 utf-8 이 나쁘다는 뜻이 아니라, 디코딩 경계와 원천 인코딩이 불일치한다는 신호입니다.
- 파일/CSV는
encoding을 명시하고, 엑셀 계열은cp949와utf-8-sig를 우선 의심 - 서브프로세스/HTTP는 바이트(
bytes)를 먼저 확보한 뒤 디코딩을 통제 - 운영 파이프라인은 실패 레코드 격리와 재시작 루프 방지까지 고려
이 원칙대로 정리하면 “에러를 없애는 수준”을 넘어, 데이터 파이프라인의 신뢰도를 크게 올릴 수 있습니다.