- Published on
Python UnicodeDecodeError 해결 - 인코딩 탐지·일괄변환
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버/크롤러/데이터 파이프라인에서 파일을 읽거나 로그를 파싱하다 보면 가장 흔하게 마주치는 예외 중 하나가 UnicodeDecodeError입니다. 특히 윈도우에서 생성된 CSV(예: CP949), 오래된 시스템에서 내려받은 텍스트(EUC-KR), BOM이 섞인 UTF-8(UTF-8-SIG), 바이너리가 섞인 로그 등 다양한 입력이 한 파이프라인으로 들어오면 “내 로컬에서는 되는데 배포 서버에서만 깨짐” 같은 상황이 쉽게 발생합니다.
이 글에서는 (1) 에러가 나는 구조적 이유를 이해하고, (2) 인코딩을 안전하게 탐지/추정하며, (3) 폴더 단위로 텍스트 파일을 일괄 UTF-8로 변환하는 방법을 코드로 정리합니다. 대용량 처리 시 성능/안정성 팁과, 어디서 깨졌는지 빠르게 추적하는 진단 방법까지 포함합니다.
UnicodeDecodeError의 정체: “바이트 → 문자열” 변환 실패
파이썬에서 텍스트는 str(유니코드)이고, 파일/네트워크 입력은 bytes(바이트)입니다. 바이트를 문자열로 바꾸는 과정이 decode이며, 여기서 잘못된 인코딩을 지정하거나(혹은 기본값이 맞지 않거나) 입력 바이트가 해당 인코딩 규칙을 위반하면 예외가 발생합니다.
대표적인 메시지는 다음과 같습니다.
UnicodeDecodeError: 'utf-8' codec can't decode byte 0xb0 in position 10: invalid start byte
이 의미는 “UTF-8로 해석하려고 했는데, 10번째 위치의 0xb0가 UTF-8 규칙상 시작 바이트가 될 수 없다”입니다. 즉, 실제 인코딩이 UTF-8이 아닐 가능성이 큽니다(예: CP949/EUC-KR).
자주 발생하는 원인 5가지
- 기본 인코딩 의존:
open(path)만 쓰면 OS/로케일에 따라 기본 인코딩이 달라집니다. - CP949/EUC-KR ↔ UTF-8 혼재: 한국어 환경에서 가장 흔한 케이스.
- UTF-8 BOM(UTF-8-SIG): 파일 앞에 BOM이 붙어 있으면 첫 컬럼/첫 글자가 이상해지거나 파서가 깨질 수 있습니다.
- 바이너리/깨진 파일을 텍스트로 읽음:
.log확장자라도 실제로는 바이너리 조각이 섞일 수 있습니다. - 부분 다운로드/전송 손상: 파일이 중간에 끊겨 잘못된 바이트 시퀀스가 포함.
1단계: 무조건 인코딩을 명시하는 습관
가장 좋은 예방책은 “내가 다루는 포맷의 인코딩을 명시”하는 것입니다.
from pathlib import Path
path = Path("data.txt")
# UTF-8 텍스트가 확실할 때
text = path.read_text(encoding="utf-8")
# BOM 가능성이 있을 때(특히 CSV)
text = path.read_text(encoding="utf-8-sig")
만약 입력이 CP949일 가능성이 높다면:
text = path.read_text(encoding="cp949")
하지만 현실에서는 “확실하지 않은 입력”이 문제이므로, 탐지/추정 로직이 필요합니다.
2단계: 인코딩 탐지(추정) 전략
인코딩은 파일에 메타데이터로 항상 저장되는 것이 아니라 “바이트 패턴을 보고 추정”하는 경우가 많습니다. 따라서 100% 정확한 탐지는 불가능하며, 확률/휴리스틱 기반으로 접근해야 합니다.
(권장) charset-normalizer로 탐지하기
파이썬 3.10+ 환경에서는 chardet보다 charset-normalizer가 기본적으로 더 권장되는 흐름이 많습니다(정확도/유지보수 측면에서).
pip install charset-normalizer
from charset_normalizer import from_bytes
from pathlib import Path
def detect_encoding(path: Path, sample_size: int = 200_000) -> str:
raw = path.read_bytes()[:sample_size]
result = from_bytes(raw).best()
if result is None:
return "utf-8" # fallback
return result.encoding
p = Path("unknown.txt")
enc = detect_encoding(p)
print(enc)
print(p.read_text(encoding=enc, errors="strict"))
탐지 시 주의점
- 샘플링: 대용량 파일은 앞부분만 샘플링해도 충분한 경우가 많지만, 파일 뒤쪽에 다른 문자셋이 등장하면 오탐지 가능.
- 짧은 파일: 내용이 너무 짧으면 탐지 정확도가 급격히 떨어집니다.
- 숫자/영문 위주: 어떤 인코딩으로도 해석이 가능해 “그럴듯한” 결과가 나오기 쉽습니다.
(실무형) “UTF-8 우선, 실패하면 후보군 시도”
한국어 데이터 파이프라인에서는 아래 전략이 매우 실용적입니다.
- UTF-8(또는 UTF-8-SIG)로 먼저 시도
- 실패하면 CP949 → EUC-KR 순으로 시도
- 그래도 실패하면 탐지 라이브러리 결과를 참고
from pathlib import Path
CANDIDATES = ["utf-8", "utf-8-sig", "cp949", "euc-kr"]
def read_text_smart(path: Path) -> tuple[str, str]:
raw = path.read_bytes()
for enc in CANDIDATES:
try:
return raw.decode(enc), enc
except UnicodeDecodeError:
continue
# 최후: 손실 허용(데이터 품질 정책에 따라 선택)
return raw.decode("utf-8", errors="replace"), "utf-8 (replace)"
text, used = read_text_smart(Path("unknown.txt"))
print("used:", used)
이 방식은 “정확한 탐지”가 아니라 “안전하게 읽기”에 초점이 있습니다. ETL/로그 분석에서는 종종 이게 더 중요합니다.
3단계: 어디서 깨졌는지(바이트 위치) 빠르게 진단
에러 메시지의 position은 매우 유용합니다. 해당 위치 주변 바이트를 덤프하면 어떤 유형의 데이터인지 감이 옵니다.
from pathlib import Path
path = Path("unknown.txt")
raw = path.read_bytes()
try:
raw.decode("utf-8")
except UnicodeDecodeError as e:
pos = e.start
window = raw[max(0, pos-16):pos+16]
print("error:", e)
print("pos:", pos)
print("bytes:", window)
print("hex:", window.hex(" "))
- 특정 구간이
00(널) 바이트가 반복되면 UTF-16/바이너리 가능성을 의심 b'\xff\xfe'로 시작하면 UTF-16 LE BOM 가능성b'\xef\xbb\xbf'면 UTF-8 BOM
4단계: 폴더 단위 “일괄 UTF-8 변환” 스크립트
요구사항은 보통 다음과 같습니다.
- 특정 디렉터리의 텍스트 파일을 찾아
- 인코딩을 추정해서 읽고
- UTF-8(권장: BOM 없는 UTF-8) 로 다시 저장
- 원본 백업/로그 남기기
아래 스크립트는 charset-normalizer + 후보군 시도를 혼합해 실전에서 실패율을 낮춘 버전입니다.
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import Iterable
from charset_normalizer import from_bytes
TEXT_EXTS = {".txt", ".csv", ".tsv", ".log", ".md", ".json"}
@dataclass
class ConvertResult:
path: Path
source_encoding: str
converted: bool
reason: str = ""
def guess_encoding(raw: bytes) -> str:
# 1) BOM 빠른 처리
if raw.startswith(b"\xef\xbb\xbf"):
return "utf-8-sig"
if raw.startswith(b"\xff\xfe"):
return "utf-16"
if raw.startswith(b"\xfe\xff"):
return "utf-16"
# 2) UTF-8 먼저 엄격 시도
try:
raw.decode("utf-8")
return "utf-8"
except UnicodeDecodeError:
pass
# 3) 한국어 환경 후보군
for enc in ("cp949", "euc-kr"):
try:
raw.decode(enc)
return enc
except UnicodeDecodeError:
continue
# 4) 라이브러리 추정
best = from_bytes(raw).best()
return best.encoding if best else "utf-8"
def iter_files(root: Path, exts: set[str] = TEXT_EXTS) -> Iterable[Path]:
for p in root.rglob("*"):
if p.is_file() and p.suffix.lower() in exts:
yield p
def convert_to_utf8(
path: Path,
*,
backup: bool = True,
newline: str | None = None,
) -> ConvertResult:
raw = path.read_bytes()
# 바이너리 간단 필터: 널 바이트가 많으면 텍스트가 아닐 가능성
if raw.count(b"\x00") > 0:
return ConvertResult(path, "unknown", False, "contains NUL (maybe binary/utf-16)")
src_enc = guess_encoding(raw)
try:
text = raw.decode(src_enc, errors="strict")
except UnicodeDecodeError as e:
return ConvertResult(path, src_enc, False, f"decode failed: {e}")
# 이미 UTF-8이면 스킵(정책에 따라 재저장할 수도 있음)
if src_enc in ("utf-8", "utf-8-sig"):
return ConvertResult(path, src_enc, False, "already utf-8")
if backup:
bak = path.with_suffix(path.suffix + ".bak")
if not bak.exists():
bak.write_bytes(raw)
# newline 제어: CSV/로그에서 CRLF/LF 통일이 필요하면 사용
if newline is not None:
text = text.replace("\r\n", "\n").replace("\r", "\n")
if newline != "\n":
text = text.replace("\n", newline)
path.write_text(text, encoding="utf-8", newline="")
return ConvertResult(path, src_enc, True, "converted")
def batch_convert(root: str) -> list[ConvertResult]:
root_path = Path(root)
results: list[ConvertResult] = []
for f in iter_files(root_path):
results.append(convert_to_utf8(f, backup=True))
return results
if __name__ == "__main__":
results = batch_convert("./data")
converted = [r for r in results if r.converted]
failed = [r for r in results if (not r.converted and "failed" in r.reason)]
print("total:", len(results))
print("converted:", len(converted))
print("failed:", len(failed))
for r in failed[:20]:
print("FAIL", r.path, r.source_encoding, r.reason)
운영 팁: “일괄 변환”은 되돌릴 수 있어야 한다
.bak백업을 남기거나, 변환 결과를 별도 디렉터리에 저장하세요.- Git으로 관리되는 리포지터리라면 변환 커밋을 분리해 diff 확인이 가능하게 하세요.
5단계: Pandas/CSV에서 특히 많이 터지는 케이스
pandas.read_csv()는 기본적으로 UTF-8을 기대하는 경우가 많고, CP949 CSV에서 자주 깨집니다.
import pandas as pd
# CP949 CSV
df = pd.read_csv("korean.csv", encoding="cp949")
# UTF-8 BOM 포함 CSV(엑셀에서 저장된 파일에 흔함)
df = pd.read_csv("excel.csv", encoding="utf-8-sig")
또한 “인코딩 문제처럼 보이지만 사실은 데이터 조인/병합 이후 행이 폭증” 같은 데이터 품질 이슈가 함께 나타날 수 있습니다. 병합 후 NaN이 늘거나 행 수가 예상과 다르면 인코딩 외에도 조인 키 정합성을 점검해야 합니다. 관련해서는 Pandas merge 후 NaN 폭증·행수 증가 원인 7가지도 함께 참고하면 디버깅 시간이 줄어듭니다.
6단계: errors 옵션(무시/대체)은 ‘정책’이다
errors="ignore"나 errors="replace"는 당장 파이프라인을 살릴 수 있지만, 데이터 손실을 유발합니다.
errors="strict": 실패를 조기에 발견(권장, 특히 원천 데이터 정제 단계)errors="replace": 문제가 되는 바이트를�로 치환(로그/검색 인덱싱 등에서 타협 가능)errors="ignore": 조용히 삭제(대부분 비권장)
raw = Path("unknown.txt").read_bytes()
text = raw.decode("utf-8", errors="replace")
실무에서는 “원천 데이터 레이어(raw)에서는 strict”, “서빙/검색 레이어에서는 replace”처럼 계층별로 정책을 나누는 편이 안전합니다.
7단계: 배포 환경에서만 깨질 때 체크리스트
로컬에서는 되는데 서버에서만 UnicodeDecodeError가 나는 경우가 많습니다.
open()에encoding=을 명시했는지- Docker/서버의 로케일(
LANG,LC_ALL)이C로 되어 기본 인코딩 처리에 영향을 주는지 - 동일 파일을 정말 같은 경로/같은 바이트로 읽고 있는지(다운로드/마운트 과정에서 변형 여부)
- systemd 서비스로 돌릴 때 환경 변수가 다르게 주입되는지
서비스/배치가 systemd로 돌아가고 재시작을 반복한다면, 인코딩 오류가 트리거가 되어 크래시 루프가 날 수도 있습니다. 장애 상황에서 빠른 진단 흐름은 systemd 서비스가 계속 재시작될 때 진단 체크리스트가 도움이 됩니다.
결론: “탐지”보다 “일관된 정책 + 안전한 변환”
UnicodeDecodeError는 파이썬이 까다로워서가 아니라, 입력 바이트가 혼재되어 있고(혹은 메타데이터가 없고) 운영 환경이 달라서 생기는 경우가 대부분입니다. 해결의 핵심은 다음 3가지입니다.
- 가능한 한 인코딩을 명시하고(특히 CSV/로그)
- 불확실한 입력에는 UTF-8 우선 + 후보군 + 탐지 라이브러리로 안전하게 읽고
- 장기적으로는 UTF-8로 일괄 변환해 파이프라인 전체를 단순화
위 스크립트를 기반으로 “대상 확장자”, “백업 정책”, “실패 파일 격리 디렉터리” 등을 추가하면 팀 단위 데이터 정제에도 바로 적용할 수 있습니다.