Published on

Python UnicodeDecodeError - CSV 인코딩 자동탐지 실전

Authors

서버에서 수집한 CSV를 pandas.read_csv()로 읽는 순간 UnicodeDecodeError가 터지는 경험은 흔합니다. 특히 한국어 데이터는 UTF-8, UTF-8-SIG, CP949, EUC-KR이 섞여 들어오는 경우가 많고, 엑셀에서 저장된 파일은 BOM 유무나 줄바꿈 규칙까지 달라서 재현도 어렵습니다.

이 글에서는 다음을 목표로 합니다.

  • UnicodeDecodeError가 발생하는 원인과 흔한 패턴 이해
  • CSV 파일의 인코딩을 “자동 탐지”해서 안전하게 읽는 방법
  • 자동 탐지가 틀릴 때의 폴백 전략과 운영 환경에서의 수습 방법
  • pandas, 표준 csv, 대용량 파일까지 고려한 실전 코드

운영에서 이런 류의 문제는 배포 파이프라인이나 배치 잡에서 갑자기 터져 장애로 이어지기도 합니다. CI나 러너 환경이 꼬여 원인을 찾기 어려울 때는 다른 레이어의 트러블슈팅 글도 도움이 됩니다. 예를 들어 GitHub Actions 러너가 멈추는 케이스는 로그 수집 자체가 막히기도 합니다: GitHub Actions self-hosted runner 멈춤 원인 8가지

UnicodeDecodeError가 나는 이유를 빠르게 정리

Python에서 파일을 텍스트로 열 때는 바이트를 특정 문자셋으로 디코딩합니다. 이때 실제 파일 바이트가 지정한 인코딩 규칙과 맞지 않으면 예외가 발생합니다.

대표적인 에러 형태는 다음과 같습니다.

  • UnicodeDecodeError: 'utf-8' codec can't decode byte ...
  • UnicodeDecodeError: 'cp949' codec can't decode byte ...

대부분의 원인은 아래 중 하나입니다.

  1. 파일은 CP949인데 코드에서 UTF-8로 읽음
  2. 파일은 UTF-8인데 중간에 깨진 바이트(전송/저장 손상)가 섞임
  3. 파일은 UTF-8-SIG인데 BOM 때문에 컬럼명이 \ufeffcol처럼 꼬이거나, 일부 라이브러리에서 예상과 다르게 처리됨
  4. CSV가 사실상 바이너리(예: xlsx를 csv로 착각) 또는 다른 포맷

먼저 “파일이 텍스트가 맞는지”부터 체크

인코딩 이전에, 파일이 진짜 텍스트 CSV인지 확인하면 삽질을 크게 줄일 수 있습니다.

헤더 몇 바이트를 헥사로 확인

from pathlib import Path

def hexdump_head(path: str, n: int = 64) -> str:
    b = Path(path).read_bytes()[:n]
    return b.hex(" ")

print(hexdump_head("data.csv"))
  • 50 4b 03 04로 시작하면 zip 계열(대개 xlsx)일 가능성이 큽니다.
  • 텍스트 파일이면 대체로 사람이 읽을 수 있는 ASCII 영역이 섞여 보입니다.

자동 탐지 전략: “추정”은 하되, 폴백과 검증이 핵심

인코딩 자동 탐지는 100퍼센트 정답을 보장하지 않습니다. 특히 짧은 샘플, 숫자 위주의 파일, 한글이 거의 없는 파일은 어떤 인코딩으로도 그럴듯하게 보입니다.

따라서 실전에서는 다음 3단계를 추천합니다.

  1. BOM 기반 확정 (UTF-8-SIG, UTF-16 계열)
  2. 라이브러리 기반 확률 추정 (charset-normalizer 또는 chardet)
  3. 폴백 리스트로 실제 디코딩 테스트 후 “가장 덜 깨지는” 값을 선택

방법 1) BOM 기반으로 먼저 확정하기

BOM은 파일 앞부분에 붙는 시그니처로, 있으면 인코딩을 거의 확정할 수 있습니다.

from pathlib import Path


def detect_bom_encoding(path: str) -> str | None:
    head = Path(path).read_bytes()[:4]

    # UTF-8 BOM
    if head.startswith(b"\xef\xbb\xbf"):
        return "utf-8-sig"

    # UTF-16 LE/BE
    if head.startswith(b"\xff\xfe"):
        return "utf-16"
    if head.startswith(b"\xfe\xff"):
        return "utf-16"

    # UTF-32 LE/BE
    if head.startswith(b"\xff\xfe\x00\x00"):
        return "utf-32"
    if head.startswith(b"\x00\x00\xfe\xff"):
        return "utf-32"

    return None

엑셀에서 저장된 파일은 UTF-8이라도 BOM이 붙는 경우가 있어 utf-8-sig로 여는 것이 가장 안전합니다.

방법 2) charset-normalizer로 인코딩 추정하기

Python 3.10+ 환경에서는 requests도 내부적으로 charset-normalizer를 활용하는 흐름이 일반적입니다. chardet보다 정확도가 나은 케이스가 많고, 유지보수도 활발한 편입니다.

설치:

pip install charset-normalizer

추정 코드:

from charset_normalizer import from_bytes
from pathlib import Path


def guess_encoding_charset_normalizer(path: str, sample_size: int = 200_000) -> str | None:
    raw = Path(path).read_bytes()[:sample_size]
    result = from_bytes(raw).best()
    if result is None:
        return None
    return result.encoding

print(guess_encoding_charset_normalizer("data.csv"))

주의할 점:

  • 샘플이 너무 작으면 오탐이 늘어납니다.
  • 너무 크면 느려집니다. 운영에서는 100KB에서 500KB 사이가 무난합니다.

방법 3) 폴백 리스트로 “실제 디코딩” 검증하기

추정값이 틀릴 수 있으므로, 후보 인코딩을 몇 개 정해두고 실제로 디코딩을 시도해 가장 안정적인 것을 고르는 방식이 강력합니다.

한국어 CSV에서 현실적인 후보는 보통 다음입니다.

  • utf-8-sig
  • utf-8
  • cp949
  • euc-kr

여기에 latin-1을 넣으면 “절대 안 깨지는” 대신 “조용히 망가지는” 디코딩이 되므로 권장하지 않습니다. 즉, 실패를 성공으로 바꿔버려 데이터 품질을 더 나쁘게 만들 수 있습니다.

다음 코드는 후보 인코딩을 순회하며 디코딩 성공 여부와 “대체 문자” 발생 여부를 기반으로 선택합니다.

from pathlib import Path


def choose_encoding_by_trial(path: str, candidates: list[str], sample_size: int = 500_000) -> str:
    raw = Path(path).read_bytes()[:sample_size]

    scored: list[tuple[int, str]] = []

    for enc in candidates:
        try:
            text = raw.decode(enc, errors="strict")
        except UnicodeDecodeError:
            continue

        # 간단 품질 점수: 유니코드 치환문자 U+FFFD가 있으면 감점
        # strict로 디코딩하면 보통 여기까지 오지 않지만, 일부 인코딩 조합에서
        # 파일이 깨져 있는 경우를 대비해 확장하기 쉬운 구조로 둡니다.
        score = text.count("\ufffd")
        scored.append((score, enc))

    if not scored:
        raise UnicodeDecodeError("unknown", b"", 0, 1, "no encoding matched")

    scored.sort(key=lambda x: x[0])
    return scored[0][1]


candidates = ["utf-8-sig", "utf-8", "cp949", "euc-kr"]
print(choose_encoding_by_trial("data.csv", candidates))

pandas로 안전하게 읽는 “권장 조합”

현업에서 가장 많이 쓰는 조합은 다음과 같습니다.

  1. BOM 체크
  2. charset-normalizer 추정
  3. 후보 리스트로 최종 확정
  4. pandas.read_csv()에 확정된 인코딩 전달
import pandas as pd


def detect_csv_encoding(path: str) -> str:
    bom = detect_bom_encoding(path)
    if bom:
        return bom

    guessed = guess_encoding_charset_normalizer(path)

    candidates = [
        "utf-8-sig",
        "utf-8",
        "cp949",
        "euc-kr",
    ]

    # 추정값이 후보에 있으면 우선순위 앞으로
    if guessed and guessed.lower() in [c.lower() for c in candidates]:
        candidates = [guessed] + [c for c in candidates if c.lower() != guessed.lower()]

    return choose_encoding_by_trial(path, candidates)


path = "data.csv"
enc = detect_csv_encoding(path)

df = pd.read_csv(path, encoding=enc)
print(enc)
print(df.head())

utf-8-sig를 우선하는 이유

utf-8로 열면 BOM이 데이터로 남아 첫 컬럼명이 \ufeffid처럼 되는 경우가 있습니다. utf-8-sig는 BOM을 자동으로 제거하므로 CSV 헤더 파싱에서 안전합니다.

표준 csv 모듈로도 동일하게 적용

pandas가 아니라 표준 라이브러리 csv로 읽어야 하는 경우도 많습니다(의존성 최소화, 스트리밍 처리 등).

import csv

path = "data.csv"
enc = detect_csv_encoding(path)

with open(path, "r", encoding=enc, newline="") as f:
    reader = csv.DictReader(f)
    for i, row in enumerate(reader):
        if i == 5:
            break
        print(row)

newline=""은 Python의 csv 문서에서 권장하는 패턴이며, 줄바꿈 처리 이슈를 줄여줍니다.

자동탐지가 실패하는 케이스와 대응법

1) 파일에 한글이 거의 없어서 어떤 인코딩이든 “그럴듯”한 경우

  • 해결: 샘플 크기를 늘리거나, “헤더 라인” 또는 “특정 컬럼”에서 한글이 등장하는지 추가 검증을 넣습니다.
  • 예: 디코딩 후 정규식으로 한글 범위가 존재하는지 체크하고 없으면 다른 후보를 시도
import re


def has_hangul(s: str) -> bool:
    return re.search(r"[\uac00-\ud7a3]", s) is not None

2) 파일 자체가 부분적으로 손상된 경우

운영에서는 “완벽히 실패”보다 “어느 정도 읽고 최대한 건져야” 하는 요구가 생깁니다. 이때는 errors="replace" 또는 errors="ignore"를 고려할 수 있지만, 데이터 품질이 바뀐다는 점을 로그로 강하게 남겨야 합니다.

from pathlib import Path

raw = Path("data.csv").read_bytes()
text = raw.decode("utf-8", errors="replace")
# replace는 깨진 지점을 U+FFFD로 치환

권장 패턴은 다음입니다.

  • 기본은 strict로 실패하게 둔다
  • 실패 시에만 replace로 재시도하고, 치환문자 개수를 메트릭으로 남긴다

3) 구분자가 콤마가 아닌 세미콜론, 탭인 경우

인코딩 문제로 보였지만 사실 delimiter 문제인 경우도 있습니다. 디코딩이 성공해도 컬럼이 1개로만 들어오면 delimiter 자동 추정도 함께 고려하세요.

import csv

with open("data.csv", "r", encoding=enc, newline="") as f:
    sample = f.read(4096)
    dialect = csv.Sniffer().sniff(sample)
    print(dialect.delimiter)

운영 관점 체크리스트

  • 업로드/수집 단계에서 인코딩을 가능한 한 UTF-8로 통일 (가장 강력한 예방)
  • 저장 시점에 원본 바이트를 그대로 보관 (재현 가능성 확보)
  • 디코딩 실패 로그에 다음을 포함
    • 파일명, 크기, 샘플 해시(예: 앞 1MB의 SHA256)
    • BOM 여부
    • 탐지 후보와 최종 선택 인코딩
  • 배치/파이프라인에서 실패 시 재시도 정책을 명확히

CI 파이프라인에서 이런 장애가 반복되면 “코드 문제”가 아니라 “환경 문제”로 번질 때가 많습니다. 특히 Docker 기반 CI에서 캐시나 트리거 루프가 꼬이면 같은 파일을 계속 재처리해 장애가 증폭될 수 있습니다: GitHub Actions Docker CI/CD 무한 재빌드 루프 끊기

결론: 자동탐지는 도구, 정답은 검증과 폴백

UnicodeDecodeError는 단순히 encoding="cp949" 같은 한 줄로 끝나는 문제가 아닙니다. 파일 출처가 다양해질수록 인코딩은 혼재하고, 자동 탐지는 확률 게임이 됩니다.

실전에서 안정적으로 굴리는 핵심은 다음입니다.

  • BOM으로 확정 가능한 건 확정
  • charset-normalizer로 빠르게 후보를 좁히되 맹신하지 않기
  • 후보 리스트를 두고 실제 디코딩을 “검증”해서 선택
  • 실패 시 replace 같은 응급 처치는 하되 품질 저하를 관측 가능하게 만들기

위 패턴을 유틸 함수로 만들어두면, CSV뿐 아니라 TSV, 로그 파일, 레거시 데이터 마이그레이션에서도 그대로 재사용할 수 있습니다.