- Published on
Python UnicodeDecodeError - utf-8 실전 해결법
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 로그를 파싱하거나 CSV를 읽고, 크롤링한 HTML을 저장하는 순간 갑자기 터지는 오류가 있습니다. 바로 UnicodeDecodeError: 'utf-8' codec can't decode byte ... 입니다. 이 에러는 “UTF-8로 디코딩하려 했는데, 실제 바이트열은 UTF-8 규칙을 만족하지 않는다”는 뜻입니다. 문제는 원인이 한 가지가 아니라는 점입니다. 파일 인코딩이 다를 수도 있고(예: CP949/EUC-KR), 중간에 바이너리가 섞였을 수도 있으며, 네트워크 응답의 charset 헤더가 틀렸을 수도 있습니다.
이 글에서는 **어디서 깨지는지(입력 경로)**를 먼저 분리하고, 그 다음 정확한 인코딩 판별 → 안전한 디코딩 → 재발 방지(파이프라인 설계) 순으로 실전 해결법을 정리합니다.
1) 에러 메시지부터 원인 좁히기
대표적인 형태는 다음과 같습니다.
UnicodeDecodeError: 'utf-8' codec can't decode byte 0xb0 in position 12: invalid start byte
여기서 핵심은:
byte 0xb0: UTF-8에서 시작 바이트로 올 수 없는 값일 가능성이 큼(예: CP949 텍스트에서 자주 등장)position 12: 몇 번째 바이트에서 깨졌는지. 파일/응답에서 해당 위치 주변을 덤프하면 혼입된 바이너리나 잘못된 인코딩을 빨리 찾을 수 있습니다.
바로 확인하는 스니펫:
path = "data.txt"
with open(path, "rb") as f:
b = f.read()
pos = 12
print(b[max(0, pos-20):pos+20])
2) 가장 흔한 케이스: 파일 인코딩이 UTF-8이 아님
2.1 open() 기본값에 기대지 말기
Python 3에서 open("file")은 OS 기본 인코딩(Windows는 흔히 cp949/mbcs)을 따라가기도 하고, 환경에 따라 달라집니다. 항상 명시하는 습관이 재발 방지에 가장 효과적입니다.
# UTF-8 파일을 읽는다고 확신할 때
with open("input.csv", "r", encoding="utf-8") as f:
text = f.read()
그런데 실제 파일이 CP949라면 위 코드는 깨집니다. 그럴 때는 다음처럼 바꿉니다.
# 한국어 Windows 환경에서 흔한 인코딩
with open("input.csv", "r", encoding="cp949") as f:
text = f.read()
2.2 BOM(UTF-8-SIG) 때문에 컬럼명이 이상해질 때
엑셀 등에서 저장한 CSV가 UTF-8 BOM을 포함하면, 첫 컬럼 앞에 \ufeff가 붙어 후속 처리에서 꼬일 수 있습니다. 이때는 utf-8-sig로 읽으면 BOM이 제거됩니다.
import csv
with open("excel.csv", "r", encoding="utf-8-sig", newline="") as f:
reader = csv.DictReader(f)
rows = list(reader)
3) 판별이 어려울 때: 바이트로 읽고 추정하기
“인코딩이 뭔지 모르겠다”가 현실에서 가장 흔합니다. 이때는 텍스트로 바로 열지 말고 먼저 바이트로 읽어 추정하는 것이 안전합니다.
3.1 charset-normalizer(권장)로 추정
Python 생태계에서 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, result.percent_chaos)
text = str(result) # 디코딩된 문자열
encoding: 추정 인코딩chaos: 텍스트로 보기 어려운(혼입/깨짐) 정도
추정이 100% 맞지 않을 수 있으니, 업무 파이프라인에서는 “추정 실패 시 정책”(예: 실패 로그 남기고 스킵)을 같이 설계하는 게 좋습니다.
3.2 “일단 살려서” 처리해야 할 때: errors= 전략
정확한 원인을 찾는 게 최우선이지만, 운영 환경에서는 당장 파이프라인이 멈추면 안 되는 경우가 많습니다. 그럴 때는 errors 옵션을 정책적으로 선택합니다.
raw = open("unknown.txt", "rb").read()
# 1) 깨지는 바이트를 대체 문자(�)로 치환
text = raw.decode("utf-8", errors="replace")
# 2) 깨지는 바이트를 버림(데이터 손실 가능)
text2 = raw.decode("utf-8", errors="ignore")
# 3) 깨지는 바이트를 \xNN 형태로 보존 (디버깅/로깅에 유리)
text3 = raw.decode("utf-8", errors="backslashreplace")
운영 관점에서 추천 순서는 보통:
- 로그/분석용:
backslashreplace - 사용자 노출 텍스트:
replace - 정말 불가피한 경우에만:
ignore
4) 네트워크/크롤링에서 깨질 때: 헤더 charset을 맹신하지 말기
HTTP 응답은 Content-Type: text/html; charset=...로 인코딩을 알려주지만, 서버가 틀리게 주는 경우도 있습니다.
4.1 requests에서의 안전한 처리
import requests
r = requests.get("https://example.com")
r.raise_for_status()
# 1) 서버가 준 인코딩
print("header encoding:", r.encoding)
# 2) requests가 추정한 인코딩(헤더가 없거나 이상할 때)
r.encoding = r.apparent_encoding
html = r.text
만약 특정 사이트가 항상 EUC-KR인데 헤더가 비어 있거나 잘못되면, 아예 고정하는 편이 낫습니다.
r = requests.get("https://legacy.example.kr")
r.encoding = "euc-kr"
html = r.text
4.2 bytes로 받고 직접 디코딩하기
문제 재현/디버깅에는 bytes로 받아서 직접 디코딩하는 방식이 가장 명확합니다.
raw = r.content # bytes
try:
html = raw.decode("utf-8")
except UnicodeDecodeError:
html = raw.decode("cp949", errors="replace")
5) 서브프로세스/파이프에서 깨질 때: text=True의 함정
subprocess에서 text=True(또는 universal_newlines=True)를 쓰면, Python이 자동으로 디코딩합니다. 이때도 기본 인코딩/환경에 따라 UnicodeDecodeError가 날 수 있습니다.
5.1 인코딩을 명시
import subprocess
p = subprocess.run(
["some-command", "--output"],
capture_output=True,
text=True,
encoding="utf-8", # 핵심
errors="backslashreplace",
)
print(p.stdout)
5.2 bytes로 받은 뒤 처리
p = subprocess.run(["some-command"], capture_output=True)
raw = p.stdout
text = raw.decode("utf-8", errors="replace")
6) pandas에서 자주 터지는 지점과 해결 패턴
CSV/TSV를 읽을 때 UnicodeDecodeError가 가장 자주 등장합니다.
import pandas as pd
# UTF-8이 아닐 가능성이 있으면 encoding을 명시
df = pd.read_csv("data.csv", encoding="cp949")
구분자가 애매하거나 줄바꿈/따옴표가 섞여 있으면 엔진/옵션까지 조정해야 합니다.
df = pd.read_csv(
"data.csv",
encoding="utf-8-sig",
sep=",",
engine="python",
on_bad_lines="skip", # 데이터 품질이 나쁠 때 임시 방편
)
pandas 전처리에서 경고/복사 이슈까지 같이 겪는다면 pandas SettingWithCopyWarning 완벽 해결 7가지도 함께 정리해두면 데이터 파이프라인 안정성이 올라갑니다.
7) 근본 처방: “입력은 bytes, 경계에서만 decode” 원칙
대규모 시스템에서 인코딩 문제를 줄이는 가장 좋은 방법은 **텍스트 경계(boundary)**를 명확히 하는 것입니다.
- 파일/네트워크/큐/DB에서 가져오는 값은 우선 bytes로 취급
- 애플리케이션 내부 표준은 str(유니코드)
- 경계에서만
decode(…, errors=정책) - 출력 경계에서만
encode()
예시: 파일을 안전하게 읽어서 내부 표준 UTF-8로 정규화 저장
from charset_normalizer import from_bytes
def normalize_to_utf8(src_path: str, dst_path: str) -> None:
raw = open(src_path, "rb").read()
best = from_bytes(raw).best()
if best is None:
# 완전 실패 시: 바이트를 강제로 살려서 기록
text = raw.decode("utf-8", errors="backslashreplace")
else:
text = str(best)
with open(dst_path, "w", encoding="utf-8", newline="\n") as f:
f.write(text)
normalize_to_utf8("unknown.txt", "normalized.txt")
이렇게 “정규화 단계”를 한 번 두면, 이후 파이프라인은 UTF-8만 가정해도 되어 장애가 급감합니다.
8) 디버깅 체크리스트(현업용)
- 어디서 디코딩이 일어나는지 확인 (
open(),requests.text,subprocess(text=True),pandas.read_csv) - 문제 입력을 bytes로 확보 (
rb,response.content,stdout) - 깨지는
position주변 바이트 덤프 - 추정 도구로 인코딩 후보 확인 (
charset-normalizer) - 임시 복구는
errors=backslashreplace/replace로 파이프라인을 살리고, 원인 데이터는 별도 보관 - 장기적으로는 “입력 정규화(UTF-8) + 경계에서만 decode” 구조로 리팩터링
운영 환경에서 이런 류의 문제는 “한 번 해결하면 끝”이 아니라, 비정형 입력이 늘어날수록 다시 발생합니다. 장애를 줄이려면 진단과 복구를 자동화하는 편이 좋습니다. 예를 들어 재시도/백오프처럼 실패를 전제로 설계하는 접근은 다른 영역에서도 유효합니다. 관련해서는 OpenAI API 429 Rate Limit 재시도·백오프 설계처럼 실패를 다루는 패턴을 참고해도 좋습니다.
9) 자주 쓰는 해결 조합 요약
- 엑셀 CSV:
encoding="utf-8-sig"또는cp949 - 윈도우 생성 텍스트:
cp949의심 - 크롤링 HTML:
r.encoding = r.apparent_encoding또는 bytes로 받아 직접 디코딩 - 운영 임시 복구:
errors="backslashreplace"로 손실 없이 로깅 - 근본 해결: 입력 정규화 단계 추가(모든 데이터를 UTF-8로 변환 후 처리)
UTF-8은 사실상 표준이지만, 현실의 데이터는 표준을 잘 지키지 않습니다. UnicodeDecodeError는 그 현실이 드러나는 지점이고, 해결의 핵심은 “정확한 인코딩을 맞추는 것”과 “실패해도 시스템이 멈추지 않게 만드는 것”을 동시에 달성하는 데 있습니다.