- Published on
Python UnicodeDecodeError - CSV 인코딩 자동탐지·복구
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버나 배치에서 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% 정답이 아닙니다. 실무에서는 아래 전략이 좋습니다.
- BOM 여부 확인
- 탐지 라이브러리로 1차 후보 도출
utf-8,utf-8-sig,cp949,euc-kr,latin-1(마지막 수단) 순으로 폴백- 실패 시 라인 단위 복구 모드로 전환
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가 재발하지 않게 하려면 아래를 권장합니다.
- 수집 단계에서 원본 바이트를 보존(
.raw또는 원본 파일 그대로) - 정규화 단계에서 UTF-8로 통일(가능하면 BOM 제거)
- 탐지 결과(인코딩, 신뢰도, 실패 라인 수)를 로그/메타데이터로 저장
- 실패 파일은 격리 디렉터리로 이동
대량 파일 처리 중 디스크가 꽉 차면서 임시 파일이 꼬이면 “인코딩 문제처럼 보이는” 2차 장애가 나기도 합니다. 배치 서버에서 공간 이슈가 의심되면 리눅스 디스크 100%인데 용량이 안 보일 때 해결도 함께 점검해보세요.
결론: 자동탐지의 한계는 “검증과 폴백”으로 메운다
CSV 인코딩은 생각보다 표준화되어 있지 않고, 생성 주체(Excel, 레거시, 외부 파트너)에 따라 쉽게 흔들립니다. 따라서 정답은 “한 번에 맞추는 탐지”가 아니라:
- 바이트 기반으로 탐지하고
- 후보 인코딩을 순차 시도하며
- CSV 파싱 가능 여부로 검증하고
- 깨진 라인은 라인 단위로 복구/격리
이 4단계로 파이프라인을 설계하는 것입니다.
위 코드들을 공용 유틸로 묶어두면 UnicodeDecodeError는 더 이상 장애가 아니라 “정규화 단계에서 처리되는 이벤트”가 됩니다.