Published on

Python UnicodeDecodeError 해결 - 인코딩 탐지·일괄변환

Authors

서버/크롤러/데이터 파이프라인에서 파일을 읽거나 로그를 파싱하다 보면 가장 흔하게 마주치는 예외 중 하나가 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가지

  1. 기본 인코딩 의존: open(path)만 쓰면 OS/로케일에 따라 기본 인코딩이 달라집니다.
  2. CP949/EUC-KR ↔ UTF-8 혼재: 한국어 환경에서 가장 흔한 케이스.
  3. UTF-8 BOM(UTF-8-SIG): 파일 앞에 BOM이 붙어 있으면 첫 컬럼/첫 글자가 이상해지거나 파서가 깨질 수 있습니다.
  4. 바이너리/깨진 파일을 텍스트로 읽음: .log 확장자라도 실제로는 바이너리 조각이 섞일 수 있습니다.
  5. 부분 다운로드/전송 손상: 파일이 중간에 끊겨 잘못된 바이트 시퀀스가 포함.

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 우선, 실패하면 후보군 시도”

한국어 데이터 파이프라인에서는 아래 전략이 매우 실용적입니다.

  1. UTF-8(또는 UTF-8-SIG)로 먼저 시도
  2. 실패하면 CP949 → EUC-KR 순으로 시도
  3. 그래도 실패하면 탐지 라이브러리 결과를 참고
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가지입니다.

  1. 가능한 한 인코딩을 명시하고(특히 CSV/로그)
  2. 불확실한 입력에는 UTF-8 우선 + 후보군 + 탐지 라이브러리로 안전하게 읽고
  3. 장기적으로는 UTF-8로 일괄 변환해 파이프라인 전체를 단순화

위 스크립트를 기반으로 “대상 확장자”, “백업 정책”, “실패 파일 격리 디렉터리” 등을 추가하면 팀 단위 데이터 정제에도 바로 적용할 수 있습니다.