- Published on
Python UnicodeDecodeError 완벽 해결 가이드
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버에서 CSV를 읽거나 로그 파일을 파싱할 때, 혹은 Windows에서 받은 텍스트를 Linux에서 처리할 때 가장 흔하게 만나는 예외가 UnicodeDecodeError 입니다. 문제는 단순히 encoding="utf-8" 을 넣는다고 끝나지 않는다는 점입니다. 파일에 BOM이 섞여 있거나, cp949 와 utf-8 이 혼재되어 있거나, 심지어 바이너리 데이터가 텍스트로 오인되면 같은 에러가 반복됩니다.
이 글은 다음을 목표로 합니다.
- 에러 메시지에서 원인(어떤 바이트가 문제인지) 빠르게 역추적
- BOM(
utf-8-sig) 이슈를 안전하게 처리 cp949와utf-8혼재 데이터를 실무적으로 다루는 전략- 자동 인코딩 탐지(라이브러리 기반)와 그 한계
- 최종적으로 재사용 가능한 “안전한 파일 읽기” 유틸리티 제공
운영 환경에서 재현이 어려운 장애를 다루는 방식은 네트워크나 인프라 트러블슈팅과도 닮아 있습니다. 증상을 분해하고 원인을 좁히는 체크리스트가 중요합니다. 비슷한 접근이 필요한 글로는 GitLab CI DinD TLS 실패, 원인별 해결법도 참고할 만합니다.
UnicodeDecodeError의 정체: “디코더가 바이트를 못 읽었다”
Python에서 파일을 open(..., "r") 로 열면 내부적으로 “바이트 bytes 를 문자열 str 로 디코딩” 합니다. 이때 지정한 인코딩 규칙(예: utf-8)에 맞지 않는 바이트가 나오면 UnicodeDecodeError 가 발생합니다.
대표적인 에러 형태는 다음과 같습니다.
UnicodeDecodeError: 'utf-8' codec can't decode byte 0x.. in position ...: invalid start byteUnicodeDecodeError: 'cp949' codec can't decode byte 0x.. in position ...: illegal multibyte sequence
핵심 포인트는 “파일이 깨졌다”가 아니라 “내가 선택한 디코딩 규칙이 실제 바이트와 맞지 않는다” 입니다.
1분 디버깅: 문제 바이트를 직접 확인하기
에러 메시지에는 보통 byte 0x?? 와 position N 이 포함됩니다. 그 위치 주변 바이트를 직접 보면 원인(UTF-8이 아닌데 UTF-8로 읽었다, BOM이 있다, 바이너리다 등)을 빠르게 판단할 수 있습니다.
from pathlib import Path
path = Path("sample.txt")
data = path.read_bytes()
pos = 0 # 에러 position을 여기에 넣으세요
start = max(0, pos - 16)
end = min(len(data), pos + 16)
chunk = data[start:end]
print("len:", len(data))
print("hex:", chunk.hex(" "))
print("raw:", chunk)
ef bb bf로 시작하면utf-8BOM 입니다.- 초반에
00바이트가 규칙적으로 섞이면 UTF-16 계열이거나 바이너리일 가능성이 큽니다. - Windows에서 온 한국어 텍스트는 높은 확률로
cp949입니다.
BOM 때문에 생기는 함정: utf-8 vs utf-8-sig
BOM(Byte Order Mark)은 UTF-8에서는 필수는 아니지만, Windows 도구(특히 일부 편집기)가 UTF-8 파일 앞에 BOM을 붙이는 경우가 있습니다. 이때 open(..., encoding="utf-8") 로 읽으면 첫 글자에 보이지 않는 \ufeff 가 남아 후속 로직을 망칠 수 있습니다.
- “디코딩 에러”가 아니라 “첫 컬럼명이 이상하다” 같은 형태로 나타나기도 합니다.
해결은 간단합니다.
with open("data.csv", "r", encoding="utf-8-sig", newline="") as f:
text = f.read()
utf-8-sig 는 BOM이 있으면 제거하고, 없으면 그냥 UTF-8로 처리합니다. CSV 헤더가 \ufeffid 처럼 되는 문제를 가장 깔끔하게 없애줍니다.
CSV에서 특히 중요한 이유
CSV는 헤더를 키로 쓰는 경우가 많습니다. BOM이 헤더에 붙으면 row["id"] 가 실패하고 row["\ufeffid"] 로만 접근되는 버그가 생깁니다.
import csv
with open("data.csv", "r", encoding="utf-8-sig", newline="") as f:
reader = csv.DictReader(f)
for row in reader:
print(row)
한국어 Windows 파일의 왕: cp949 (그리고 euc-kr 혼동)
Windows 환경에서 생성된 한국어 텍스트 파일은 cp949 인 경우가 많습니다. 흔히 euc-kr 로 알려져 있지만, 실제로는 확장 인코딩인 cp949 가 더 넓은 문자 범위를 커버합니다.
실전에서는 다음 패턴으로 문제를 만납니다.
- Linux 서버에서
utf-8로 읽었더니invalid start byte - 로컬에서는 잘 되는데 CI/컨테이너에서만 깨짐(기본 인코딩 차이)
가장 단순한 해결은 인코딩을 명시하는 것입니다.
with open("korean.txt", "r", encoding="cp949") as f:
s = f.read()
그런데 왜 cp949 가 “완벽한 해결”이 아닌가
문제는 혼재입니다. 같은 폴더에 있는 파일이
- 어떤 건
utf-8 - 어떤 건
utf-8-sig - 어떤 건
cp949
일 수 있습니다. 이때 고정 인코딩은 곧 장애로 돌아옵니다.
자동 인코딩 탐지: charset-normalizer 와 chardet
Python 3 생태계에서 자동 탐지에 많이 쓰는 라이브러리는 다음 두 가지입니다.
charset-normalizer(순수 Python, 최근 환경에서 선호)chardet(오래된 표준처럼 쓰이지만 정확도는 케이스 의존)
설치:
pip install charset-normalizer
탐지 예시:
from charset_normalizer import from_bytes
raw = open("unknown.txt", "rb").read()
result = from_bytes(raw).best()
print("encoding:", result.encoding)
print("confidence:", result.percent_covered)
text = str(result)
자동 탐지의 한계(중요)
- 짧은 텍스트는 탐지 정확도가 급격히 떨어집니다.
cp949와utf-8은 특정 바이트 패턴에서 오탐이 나기도 합니다.- “정답 인코딩”이 아니라 “가장 그럴듯한 후보”를 주는 것입니다.
따라서 실무에서는 “자동 탐지 + 검증 + 폴백” 전략이 안전합니다.
실전용 해결책: 다단계 폴백으로 안전하게 읽기
다음 함수는 현장에서 자주 쓰는 접근입니다.
- 먼저
utf-8-sig로 시도(BOM 포함 UTF-8을 가장 깔끔하게 처리) - 실패하면
utf-8시도 - 실패하면
cp949시도 - 그래도 실패하면 자동 탐지
- 최후에는
errors="replace"또는errors="surrogateescape"로 “데이터 손실을 통제”하며 진행
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import Iterable, Optional
@dataclass
class DecodedText:
text: str
encoding: str
had_errors: bool
def read_text_safely(
path: str | Path,
encodings: Iterable[str] = ("utf-8-sig", "utf-8", "cp949"),
use_auto_detect: bool = True,
) -> DecodedText:
path = Path(path)
raw = path.read_bytes()
last_err: Optional[Exception] = None
for enc in encodings:
try:
return DecodedText(raw.decode(enc), enc, False)
except UnicodeDecodeError as e:
last_err = e
if use_auto_detect:
try:
from charset_normalizer import from_bytes
best = from_bytes(raw).best()
if best and best.encoding:
return DecodedText(str(best), best.encoding, False)
except Exception as e:
last_err = e
# 최후의 보루: 깨진 부분을 대체 문자로 치환하고 계속 진행
text = raw.decode("utf-8", errors="replace")
return DecodedText(text, "utf-8+replace", True)
사용 예:
result = read_text_safely("unknown.txt")
print(result.encoding, result.had_errors)
print(result.text[:200])
had_errors=True인 경우, 파이프라인에서 경고 로그를 남기고 원본 파일을 격리하는 식으로 운영하면 좋습니다.
errors= 옵션을 정확히 이해하기
open() 또는 decode() 에서 errors 는 “디코딩 실패를 어떻게 처리할지”를 정합니다.
errors="strict"(기본): 실패하면 예외errors="replace": 실패한 바이트를�로 치환errors="ignore": 실패한 바이트를 버림(데이터 손실이 조용히 발생하므로 비추천)errors="surrogateescape": 실패한 바이트를 유니코드 서러게이트 영역으로 임시 보존(나중에 원복 가능)
특히 로그/포렌식/재처리가 필요한 파이프라인이면 surrogateescape 가 유용합니다.
raw = open("unknown.bin", "rb").read()
text = raw.decode("utf-8", errors="surrogateescape")
# 다시 bytes로 복원 가능
restored = text.encode("utf-8", errors="surrogateescape")
assert restored == raw
파일을 “항상 UTF-8로 통일”하는 정석: 입력에서 정규화
궁극적으로는 읽을 때마다 추측하지 말고, ingest 단계에서 UTF-8로 정규화해 저장하는 것이 운영 비용을 줄입니다.
예: 업로드된 파일을 감지 후 UTF-8로 변환해 보관
from pathlib import Path
def normalize_to_utf8(src: Path, dst: Path) -> str:
decoded = read_text_safely(src)
dst.write_text(decoded.text, encoding="utf-8", newline="\n")
return decoded.encoding
used = normalize_to_utf8(Path("incoming.txt"), Path("normalized.txt"))
print("source encoding was:", used)
- 저장 시 줄바꿈을
\n으로 통일하면 OS 간 차이도 줄어듭니다.
자주 나오는 케이스별 처방전
케이스 1: utf-8 로 읽으면 invalid start byte
- Windows에서 만든 파일이면
cp949가능성이 큼 - 바이너리(예: 이미지, gzip)를 텍스트로 읽고 있을 수도 있음
대응:
- 먼저
file명령(리눅스) 또는 매직 바이트 확인 - 텍스트가 맞다면
cp949또는 자동 탐지
케이스 2: CSV 헤더가 이상하고 첫 글자가 매칭이 안 됨
- BOM 가능성 큼
대응:
encoding="utf-8-sig"로 읽기
케이스 3: 로컬에서는 되는데 서버에서만 깨짐
- 기본 인코딩 차이(특히 Windows
cp949vs Linuxutf-8)
대응:
open()에 인코딩 명시- 파이프라인 초기에 UTF-8 정규화
케이스 4: 일부 줄만 깨짐
- 파일 자체가 혼재 인코딩이거나 중간에 바이너리 조각이 섞임
대응:
- 줄 단위로 바이너리를 읽어 부분 디코딩 시도
- 실패 라인을 격리하고 원본 바이트를 별도 저장
from pathlib import Path
p = Path("mixed.txt")
raw = p.read_bytes().splitlines(keepends=True)
ok, bad = [], []
for i, line in enumerate(raw, start=1):
try:
ok.append(line.decode("utf-8"))
except UnicodeDecodeError:
bad.append((i, line))
print("bad lines:", len(bad))
if bad:
n, b = bad[0]
print("first bad line no:", n)
print("hex:", b[:80].hex(" "))
운영 관점 체크리스트: 재발 방지까지
- 입력 채널별로 “허용 인코딩”을 문서화하고 계약한다(업로드 정책, 외부 파트너 포맷 등)
- 수집 단계에서 UTF-8로 정규화 후 저장한다
- 디코딩 실패 시 원본 바이트를 보관한다(
surrogateescape또는 별도 파일) errors="ignore"는 디버깅을 더 어렵게 하므로 피한다- BOM 가능성이 있는 CSV/TSV는 기본을
utf-8-sig로 둔다
장애 대응에서 중요한 건 “조용한 실패”를 없애는 것입니다. 캐시나 파이프라인에서도 비슷한 원리로 문제가 반복되곤 하는데, 그런 맥락에서는 GitHub Actions 캐시가 안 먹을 때 터지는 7가지 함정처럼 체크리스트 기반 접근이 큰 도움이 됩니다.
결론: 가장 안전한 기본값은 utf-8-sig + 폴백 + 정규화
UnicodeDecodeError 를 완전히 없애려면 “한 번에 정답 인코딩을 맞히는 요행”이 아니라, 다음 3단계를 습관화하는 것이 효과적입니다.
- 입력은 바이트로 받고, 디코딩 전략을 명시한다
- BOM과
cp949를 현실적인 1순위 이슈로 취급한다 - 최종 저장은 UTF-8로 정규화해 다음 단계들을 단순화한다
위에서 제공한 read_text_safely() 와 UTF-8 정규화 패턴을 프로젝트 공용 유틸로 넣어두면, CSV 수집/로그 파싱/배치 작업에서 발생하는 인코딩 장애를 체감적으로 크게 줄일 수 있습니다.