- Published on
Python UnicodeDecodeError - utf-8 해결 7가지
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 로그, 크롤링, CSV 적재, 서브프로세스 출력 파싱처럼 “바이트를 문자열로 바꾸는 순간”에는 거의 항상 인코딩 문제가 숨어 있습니다. 그중에서도 가장 흔한 예외가 UnicodeDecodeError: 'utf-8' codec can't decode byte ... 입니다.
문제는 이 에러가 “UTF-8이 틀렸다”라기보다, 지금 디코딩하려는 바이트가 UTF-8이 아닐 가능성 또는 텍스트가 아니라 바이너리인데 텍스트로 착각한 가능성을 의미한다는 점입니다. 이 글에서는 원인 진단 흐름과 함께, 현장에서 바로 적용 가능한 7가지 해결책을 코드로 정리합니다.
에러 메시지부터 제대로 읽기
대표 메시지는 아래 형태입니다.
UnicodeDecodeError: 'utf-8' codec can't decode byte 0x.. in position N: invalid start byte... invalid continuation byte
여기서 핵심은 다음입니다.
0x..바이트 값이 UTF-8 규칙에 맞지 않는다position N은 문자 위치가 아니라 바이트 배열에서의 인덱스다
즉, “어떤 입력이 들어왔는지”를 먼저 확인해야 합니다.
10초 진단: 이게 텍스트가 맞나?
def sniff_bytes(b: bytes, n: int = 64) -> None:
print("len=", len(b))
print("head=", b[:n])
# 예: 파일에서 일부만 읽어 확인
with open("data.bin", "rb") as f:
head = f.read(200)
sniff_bytes(head)
b'\x89PNG'로 시작하면 PNG 바이너리입니다b'PK\x03\x04'면 ZIP 계열입니다b'\xff\xd8\xff'면 JPEG입니다
바이너리를 텍스트로 디코딩하면 UTF-8이든 뭐든 깨집니다.
해결 1) 읽는 단계에서 encoding을 명시한다
가장 흔한 원인은 “기본 인코딩을 믿고 열었다”입니다. Python 3의 open() 기본 인코딩은 OS/로케일 영향을 받습니다. 특히 Windows에서는 cp949 계열, 리눅스는 UTF-8이 일반적이라, 개발 환경에서는 되는데 서버에서만 터지기도 합니다.
# UTF-8 텍스트 파일이라면 명시적으로
with open("app.log", "r", encoding="utf-8") as f:
text = f.read()
# BOM이 섞인 UTF-8이라면
with open("app.log", "r", encoding="utf-8-sig") as f:
text = f.read()
utf-8-sig는 파일 시작의 BOM을 제거해주므로, CSV/JSON에서 첫 컬럼명이 \ufeffid처럼 오염되는 문제도 같이 예방합니다.
해결 2) 실제 인코딩을 탐지하고 맞춰 디코딩한다
입력이 UTF-8이 아닐 수 있습니다. 예를 들어 오래된 CSV가 cp949나 euc-kr인 경우가 많습니다. 이때는 “추정 후 명시”가 가장 안전합니다.
charset-normalizer로 추정하기
from charset_normalizer import from_bytes
raw = open("korean.csv", "rb").read()
result = from_bytes(raw).best()
print(result.encoding, result.percent_chaos)
text = str(result)
- 추정 결과가
cp949로 나오면open(..., encoding="cp949")로 고정 percent_chaos가 높으면 텍스트 자체가 손상되었거나 바이너리일 가능성
실전 팁
- 한국어 Windows 산출물은
cp949가 빈번 - 일부 시스템은
euc-kr로 저장하지만cp949로 읽어도 대체로 통과 - CSV는 “엑셀에서 저장” 과정에서 BOM이 끼거나 ANSI로 떨어지는 일이 잦음
해결 3) errors 정책으로 “깨진 바이트”를 처리한다
정상 텍스트가 섞여 있는데 일부 바이트만 깨진 경우, 완벽 복구보다 “서비스 지속”이 목표일 수 있습니다. 이때는 errors 옵션을 사용합니다.
# 깨진 바이트는 대체 문자로 치환
with open("dirty.txt", "r", encoding="utf-8", errors="replace") as f:
text = f.read()
# 깨진 바이트는 무시 (데이터 손실 가능)
with open("dirty.txt", "r", encoding="utf-8", errors="ignore") as f:
text = f.read()
# 원시 바이트를 유니코드 사영역으로 보존 (나중에 재처리 가능)
with open("dirty.txt", "r", encoding="utf-8", errors="surrogateescape") as f:
text = f.read()
권장 순서는 보통 다음입니다.
- 원인 해결이 가능하면 인코딩을 맞춘다
- 불가피하면
replace - “원본 바이트 보존”이 중요하면
surrogateescape
해결 4) 바이너리는 끝까지 bytes로 다루고, 텍스트만 디코딩한다
이미지, 압축파일, PDF, protobuf 같은 바이너리를 중간에 실수로 .decode("utf-8") 해버리면 바로 터집니다. 파이프라인에서 “텍스트”와 “바이너리”를 구분하는 규칙을 세우는 게 중요합니다.
def load_payload(path: str) -> bytes:
with open(path, "rb") as f:
return f.read()
payload = load_payload("report.pdf")
# 여기서는 decode 하지 않는다
# 정말 텍스트로 확정되는 지점에서만 디코딩
text = payload.decode("utf-8", errors="strict") # 확정일 때만
특히 HTTP 응답 처리에서 response.content와 response.text를 혼용하면 문제가 커집니다.
해결 5) HTTP/크롤링: 헤더와 실제 바이트가 다를 때는 “바이트 기반”으로 처리
웹은 생각보다 거짓말을 많이 합니다.
- 헤더는
charset=utf-8인데 실제는euc-kr - 헤더가 없고 HTML
meta charset에만 있음 - 응답이 gzip/deflate로 압축되어 있는데 중간에서 잘못 풀림
requests를 예로 들면, response.text는 추정 인코딩을 사용합니다. 확실히 하려면 response.content로 바이트를 받고 직접 결정하세요.
import requests
from charset_normalizer import from_bytes
r = requests.get("https://example.com")
raw = r.content
best = from_bytes(raw).best()
text = str(best)
또는 서버가 올바른 헤더를 준다는 확신이 있으면:
r = requests.get("https://example.com")
r.encoding = "utf-8" # 강제
text = r.text
해결 6) CSV/JSON 처리에서 흔한 함정: BOM, 줄바꿈, 잘못된 quoting
CSV: newline=""와 utf-8-sig 조합
CSV는 OS별 줄바꿈, 엑셀 BOM, 따옴표 깨짐이 함께 터지는 경우가 많습니다.
import csv
with open("data.csv", "r", encoding="utf-8-sig", newline="") as f:
reader = csv.DictReader(f)
rows = list(reader)
newline=""는 Pythoncsv모듈 권장 설정utf-8-sig는 BOM 제거
JSON: 바이너리로 읽고 디코딩을 통제
import json
raw = open("data.json", "rb").read()
text = raw.decode("utf-8-sig")
obj = json.loads(text)
JSON은 원칙적으로 UTF-8이 흔하지만, BOM이나 파일 생성 도구에 따라 예외가 생깁니다.
해결 7) 서브프로세스/로그 수집: 출력 인코딩을 명시하거나 안전하게 캡처한다
배치 스크립트, CLI 도구, Git, ffmpeg 같은 외부 프로그램 출력은 로케일 영향을 받습니다. subprocess에서 text=True를 쓰면 내부적으로 디코딩이 일어나는데, 여기서 UTF-8 가정이 깨지면 오류가 납니다.
import subprocess
# 방법 A: bytes로 받고 나중에 디코딩
p = subprocess.run(["some-cli", "--help"], capture_output=True)
out = p.stdout.decode("utf-8", errors="replace")
# 방법 B: 인코딩을 명시
p = subprocess.run(
["some-cli", "--help"],
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
)
out = p.stdout
Windows에서 특히 자주 보이는 케이스는 콘솔 코드페이지가 UTF-8이 아닌 경우입니다. 이때는 encoding="cp949"가 필요할 수 있습니다.
재발 방지 체크리스트
아래 중 하나라도 “모호”하면, UTF-8 디코딩은 언젠가 터집니다.
- 입력이 파일인가, 네트워크인가, DB인가, 서브프로세스인가
- 그 입력의 “원본 인코딩”을 누가 보장하는가
- 텍스트가 아닌 바이너리가 섞일 가능성은 없는가
- BOM, 줄바꿈, 압축, 전송 중 손상 가능성은 없는가
권장 패턴은 다음입니다.
- I/O 경계에서는 항상
bytes로 받아서 확인 - “텍스트로 확정되는 지점”에서만
decode() open()에는encoding을 습관적으로 명시- 불가피한 경우
errors="replace"로 장애 전파를 막고, 원인 분석 로그를 남김
디버깅 관점에서 같이 읽으면 좋은 글
인코딩 문제는 결국 “환경/권한/런타임 차이”에서 폭발하는 경우가 많습니다. 장애를 빠르게 좁히는 접근법이 필요하다면 아래 글들도 같은 맥락에서 도움이 됩니다.
마무리: 가장 빠른 해결 순서
실무에서 UnicodeDecodeError를 만나면 보통 아래 순서가 가장 효율적입니다.
- 해당 입력을
rb로 받아 앞부분 바이트를 출력해 텍스트 여부 확인 - 텍스트가 맞다면 BOM 여부 확인 후
utf-8-sig적용 - 인코딩이 다르면
charset-normalizer로 추정 후 고정 - 서비스 지속이 우선이면
errors="replace"로 완화 - 파이프라인 전체에서 “바이너리와 텍스트 경계”를 명확히 분리
이 5단계만 습관화해도, 대부분의 utf-8 디코딩 오류는 재발 빈도가 크게 줄어듭니다.