Published on

Python UnicodeDecodeError - CSV 인코딩 자동탐지·복구

Authors

서버나 배치에서 CSV를 읽다가 UnicodeDecodeError를 만나면 대부분 “파일이 깨졌다”가 아니라 “내가 가정한 인코딩이 틀렸다”에 가깝습니다. 특히 Windows에서 생성된 CSV(Excel), 레거시 시스템에서 내려주는 리포트, S3에 쌓인 로그성 CSV는 utf-8이 아닐 가능성이 높고, 동일 디렉터리 안에서도 파일마다 인코딩이 섞여 있을 수 있습니다.

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

  • UnicodeDecodeError가 왜 발생하는지 빠르게 판별
  • CSV 인코딩을 자동 탐지하고 신뢰도를 확인
  • 깨진 바이트가 섞여 있어도 “가능한 만큼” 복구하여 파이프라인을 살리기
  • pandas/표준 csv 모듈에서의 실전 로딩 패턴 정리

중간에 나오는 경로, 화살표 같은 특수문자는 MDX 빌드 에러를 피하기 위해 모두 코드로 표기합니다.

UnicodeDecodeError가 나는 대표 시나리오

Python에서 CSV를 읽을 때 흔히 보는 에러는 다음 형태입니다.

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

이 메시지는 “파일이 UTF-8이 아닌데 UTF-8로 해석하려 했다”는 뜻입니다. 대표 원인은 아래와 같습니다.

1) Excel CSV의 cp949/euc-kr

한국 환경의 Excel은 CSV를 cp949(사실상 euc-kr 확장)로 저장하는 경우가 많습니다.

2) UTF-8 BOM 포함

UTF-8인데 파일 맨 앞에 BOM이 붙어 있으면 첫 컬럼명이 \ufeffcol처럼 보이거나 일부 도구에서 문제가 됩니다. 이때는 utf-8-sig가 안전합니다.

3) 혼합 인코딩 / 일부 라인만 깨짐

대부분은 정상인데 특정 라인에만 잘못된 바이트가 섞인 경우가 있습니다. 예: 사람이 수동 편집, 시스템이 바이너리 조각을 끼워 넣음.

4) 구분자/따옴표 문제를 인코딩 문제로 착각

인코딩이 아니라 CSV 파싱 규칙(구분자, 인용부호, escape) 문제일 수 있습니다. 인코딩을 바꿔도 계속 깨지면 이 가능성을 의심해야 합니다.

가장 먼저 할 일: 바이트로 읽고 “추정”하지 말기

문제 해결의 첫걸음은 텍스트로 열기 전에 바이트를 확보하는 것입니다.

from pathlib import Path

path = Path("data/report.csv")
raw = path.read_bytes()
print(raw[:200])  # 앞부분 바이트 확인
  • 앞부분에 b"\xef\xbb\xbf"가 보이면 UTF-8 BOM입니다.
  • b"\xb0\xa1" 같은 패턴이 많으면 cp949일 가능성이 큽니다(정확 판별은 탐지 라이브러리 사용 권장).

인코딩 자동탐지: chardet vs charset-normalizer

어떤 라이브러리를 쓰면 좋나

  • chardet: 오래 쓰였고 사례가 많지만, 정확도가 케이스에 따라 흔들릴 수 있습니다.
  • charset-normalizer: Python 3 계열에서 더 권장되는 흐름이 있고, 일부 케이스에서 더 안정적입니다.

둘 다 “추정”이므로 100% 정답이 아닙니다. 실무에서는 아래 전략이 좋습니다.

  1. BOM 여부 확인
  2. 탐지 라이브러리로 1차 후보 도출
  3. utf-8, utf-8-sig, cp949, euc-kr, latin-1(마지막 수단) 순으로 폴백
  4. 실패 시 라인 단위 복구 모드로 전환

charset-normalizer로 탐지

# pip install charset-normalizer
from charset_normalizer import from_bytes

def detect_encoding(raw: bytes):
    result = from_bytes(raw).best()
    if result is None:
        return None, 0.0
    return result.encoding, float(result.fingerprint.get("confidence", 0.0))

raw = open("data/report.csv", "rb").read()
enc, conf = detect_encoding(raw)
print(enc, conf)

confidence가 낮으면(예: 0.2 이하) 탐지 결과를 맹신하면 안 됩니다. 이때는 “후보 인코딩을 실제로 디코딩해보고 CSV 파싱이 되는지”로 검증하는 편이 안전합니다.

안전한 폴백 로더: 후보 인코딩을 순차 시도

아래는 “탐지 결과 + 자주 쓰는 후보”를 조합해 순서대로 시도하는 로더입니다.

import csv
from pathlib import Path
from charset_normalizer import from_bytes

COMMON_ENCODINGS = [
    "utf-8-sig",  # BOM 포함 UTF-8
    "utf-8",
    "cp949",
    "euc-kr",
    "utf-16",     # 엑셀/윈도우에서 가끔 등장
]

def guess_candidates(raw: bytes):
    # BOM 기반 힌트
    if raw.startswith(b"\xef\xbb\xbf"):
        yield "utf-8-sig"
    if raw.startswith(b"\xff\xfe") or raw.startswith(b"\xfe\xff"):
        yield "utf-16"

    best = from_bytes(raw).best()
    if best and best.encoding:
        yield best.encoding

    for enc in COMMON_ENCODINGS:
        yield enc

    # 마지막 수단: 어떤 바이트든 매핑되는 latin-1
    yield "latin-1"


def read_csv_with_fallback(path: str, delimiter=","):
    p = Path(path)
    raw = p.read_bytes()

    tried = []
    for enc in dict.fromkeys(guess_candidates(raw)):  # 중복 제거 + 순서 유지
        try:
            text = raw.decode(enc)
            # 간단 검증: csv 파싱이 가능한지
            rows = list(csv.reader(text.splitlines(), delimiter=delimiter))
            return enc, rows
        except UnicodeDecodeError:
            tried.append(enc)
        except csv.Error:
            # 디코딩은 됐지만 CSV 구조가 깨진 경우도 있음
            tried.append(f"{enc}(csv-error)")

    raise RuntimeError(f"Failed to read CSV. Tried: {tried}")


enc, rows = read_csv_with_fallback("data/report.csv")
print("encoding=", enc)
print("rows=", len(rows))

핵심은 “디코딩 성공”만으로 끝내지 않고, 최소한 CSV 파서가 한 번은 통과하는지 확인하는 것입니다.

pandas에서의 실전 패턴: 자동탐지 + errors 옵션

pandas는 내부적으로 텍스트를 읽으므로 인코딩 지정이 중요합니다.

1) 탐지 후 read_csv에 적용

import pandas as pd
from pathlib import Path
from charset_normalizer import from_bytes

path = Path("data/report.csv")
raw = path.read_bytes()
enc = (from_bytes(raw).best().encoding if from_bytes(raw).best() else "utf-8")

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

2) 일부 바이트가 깨진 파일 “살려 읽기”

완벽한 복구가 아니라 “파이프라인을 멈추지 않는 것”이 목표라면, encoding_errors를 활용할 수 있습니다.

import pandas as pd

df = pd.read_csv(
    "data/report.csv",
    encoding="utf-8",
    encoding_errors="replace",  # 깨진 문자를 U+FFFD로 대체
)
  • replace: 깨진 부분을 로 바꾸고 진행
  • ignore: 깨진 바이트를 버리고 진행(데이터 손실이 더 커질 수 있음)

운영에서는 대개 replace가 “문제 위치를 추적 가능”하게 해줘서 낫습니다.

pandas 경고/할당 이슈까지 같이 겪는 경우가 많다면, 데이터 정제 단계에서의 실수를 줄이기 위해 pandas SettingWithCopyWarning 완벽 해결 7가지도 함께 참고하면 좋습니다.

자동 복구(재인코딩) 파이프라인 만들기

실무에서는 “한 번 읽고 끝”이 아니라, 원본을 표준 인코딩(대개 UTF-8)으로 정규화해서 이후 단계가 흔들리지 않게 만드는 게 중요합니다.

아래 코드는 입력 CSV를 탐지 후 UTF-8로 재저장합니다.

from pathlib import Path
from charset_normalizer import from_bytes

def normalize_to_utf8(src: str, dst: str | None = None):
    src_p = Path(src)
    raw = src_p.read_bytes()

    best = from_bytes(raw).best()
    enc = best.encoding if best and best.encoding else "utf-8"

    text = raw.decode(enc, errors="replace")

    dst_p = Path(dst) if dst else src_p.with_suffix(src_p.suffix + ".utf8.csv")
    dst_p.write_text(text, encoding="utf-8", newline="\n")

    return enc, str(dst_p)


enc, out_path = normalize_to_utf8("data/report.csv")
print("detected=", enc)
print("normalized=", out_path)

포인트:

  • errors="replace"로 복구 가능한 만큼 살립니다.
  • newline="\n"으로 줄바꿈을 정규화하면 OS 간 차이로 생기는 잡음을 줄일 수 있습니다.

“정말로” 깨진 CSV: 라인 단위로 격리하고 원인 추적

인코딩 문제가 아니라 특정 행이 바이너리/깨진 데이터라면, 디코딩을 통째로 실패시키는 대신 라인 단위로 격리해야 합니다.

다음은 바이트를 줄 단위로 쪼개서, 디코딩 실패 라인을 기록하는 방법입니다.

from pathlib import Path

def decode_lines_with_report(path: str, encoding: str):
    raw = Path(path).read_bytes()
    lines = raw.splitlines(keepends=False)

    ok = []
    bad = []

    for i, bline in enumerate(lines, start=1):
        try:
            ok.append(bline.decode(encoding))
        except UnicodeDecodeError as e:
            bad.append((i, str(e), bline[:80]))
            ok.append(bline.decode(encoding, errors="replace"))

    return ok, bad


decoded, bad_lines = decode_lines_with_report("data/report.csv", "cp949")
print("bad_lines=", len(bad_lines))
for i, msg, head in bad_lines[:5]:
    print(i, msg, head)

이렇게 하면:

  • “몇 번째 줄이 문제인지”가 남고
  • 운영에서는 해당 라인을 별도 큐로 보내거나, 원천 시스템에 수정 요청을 할 수 있습니다.

구분자/따옴표 문제도 같이 점검하기

인코딩을 맞췄는데도 csv.Error가 난다면 다음을 의심합니다.

  • 구분자가 ,가 아니라 ; 또는 탭(\t)
  • 따옴표가 닫히지 않은 행
  • escape 규칙이 다른 덤프

탭 구분 파일을 CSV로 착각하는 경우가 흔하니, 처음 몇 줄을 “텍스트로” 확인하세요.

raw = open("data/report.csv", "rb").read()
text = raw.decode("utf-8", errors="replace")
print(text.splitlines()[0])

첫 줄에 쉼표가 거의 없고 탭이 많다면 sep="\t"로 읽는 게 맞습니다.

운영 관점 체크리스트

배치/ETL에서 UnicodeDecodeError가 재발하지 않게 하려면 아래를 권장합니다.

  1. 수집 단계에서 원본 바이트를 보존(.raw 또는 원본 파일 그대로)
  2. 정규화 단계에서 UTF-8로 통일(가능하면 BOM 제거)
  3. 탐지 결과(인코딩, 신뢰도, 실패 라인 수)를 로그/메타데이터로 저장
  4. 실패 파일은 격리 디렉터리로 이동

대량 파일 처리 중 디스크가 꽉 차면서 임시 파일이 꼬이면 “인코딩 문제처럼 보이는” 2차 장애가 나기도 합니다. 배치 서버에서 공간 이슈가 의심되면 리눅스 디스크 100%인데 용량이 안 보일 때 해결도 함께 점검해보세요.

결론: 자동탐지의 한계는 “검증과 폴백”으로 메운다

CSV 인코딩은 생각보다 표준화되어 있지 않고, 생성 주체(Excel, 레거시, 외부 파트너)에 따라 쉽게 흔들립니다. 따라서 정답은 “한 번에 맞추는 탐지”가 아니라:

  • 바이트 기반으로 탐지하고
  • 후보 인코딩을 순차 시도하며
  • CSV 파싱 가능 여부로 검증하고
  • 깨진 라인은 라인 단위로 복구/격리

이 4단계로 파이프라인을 설계하는 것입니다.

위 코드들을 공용 유틸로 묶어두면 UnicodeDecodeError는 더 이상 장애가 아니라 “정규화 단계에서 처리되는 이벤트”가 됩니다.