- Published on
Python UnicodeDecodeError - CSV 인코딩 자동탐지
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버나 로컬에서 CSV를 처리하다 보면 가장 흔하게 부딪히는 예외 중 하나가 UnicodeDecodeError 입니다. 특히 한국어 데이터가 섞인 CSV는 utf-8 이라고 믿고 읽었다가 cp949 혹은 euc-kr 이 섞여 있거나, 엑셀에서 저장된 utf-8-sig(BOM 포함) 형태로 들어오면서 문제가 터집니다.
문제는 단순히 encoding='cp949' 를 박아 넣는다고 끝나지 않는다는 점입니다. 운영 환경에서는 다음 같은 변수가 동시에 존재합니다.
- 업로드된 CSV의 출처가 제각각이라 인코딩이 섞임
- 파일 일부가 깨져 있어 특정 줄에서만 디코딩 실패
- 구분자가
,가 아니라;혹은 탭인 경우 - 대용량 파일이라 전체를 메모리에 올리기 어렵고, 빠르게 “진단”해야 함
이 글에서는 UnicodeDecodeError 를 확실히 재현하고, 인코딩을 자동탐지한 뒤 pandas 혹은 표준 csv 로 안전하게 읽는 실전 패턴을 정리합니다.
UnicodeDecodeError가 발생하는 이유
파이썬에서 텍스트 파일을 읽는 과정은 크게 두 단계입니다.
- 바이트(파일의 원본 데이터)를 읽는다
- 특정 인코딩 규칙으로 바이트를 유니코드 문자열로 디코딩한다
여기서 “실제 파일 인코딩”과 “파이썬이 가정한 인코딩”이 다르면 디코딩 단계에서 예외가 납니다.
대표적인 에러 메시지는 다음 형태입니다.
UnicodeDecodeError: 'utf-8' codec can't decode byte ...
이 메시지는 “utf-8 로 해석하려 했는데, utf-8 규칙에 맞지 않는 바이트를 발견했다”는 뜻입니다. 즉 파일이 utf-8 이 아니거나, 일부 바이트가 손상된 케이스가 많습니다.
빠른 진단: 파일이 정말 UTF-8인가
운영에서 디버깅할 때는 “원인 파악 속도”가 중요합니다. 장애를 빠르게 좁혀 나가는 방식은 인프라 트러블슈팅과 유사합니다. 예를 들어 권한 문제를 체계적으로 진단하는 글인 S3 AccessDenied 403 진단 - 버킷 정책·SCP·VPCE 처럼, CSV도 먼저 관찰 가능한 신호부터 확인하면 시간을 줄일 수 있습니다.
가장 먼저 확인할 것은 BOM 입니다. BOM이 있는 UTF-8은 파이썬에서 utf-8 로 읽으면 첫 컬럼명 앞에 보이지 않는 문자가 붙어 문제를 만들기도 합니다.
BOM 여부 확인 코드
from pathlib import Path
path = Path("data.csv")
raw = path.read_bytes()[:4]
print(raw)
# UTF-8 BOM: b'\xef\xbb\xbf'
# UTF-16 LE BOM: b'\xff\xfe'
# UTF-16 BE BOM: b'\xfe\xff'
b'\xef\xbb\xbf'로 시작하면utf-8-sig로 읽는 것이 안전합니다.
인코딩 자동탐지 전략 개요
인코딩 자동탐지는 “정답을 100% 맞히는 마법”이 아니라, “높은 확률로 맞히고 실패 시 안전하게 폴백하는 전략”에 가깝습니다.
권장 접근은 다음 순서입니다.
- BOM 기반 확정 (
utf-8-sig,utf-16등) - 통계적 탐지 라이브러리 사용 (
charset-normalizer또는chardet) - 후보 인코딩 리스트를 두고 실제 디코딩을 시도하며 검증
- 그래도 실패하면
errors='replace'또는errors='surrogateescape'로 유실 최소화
파이썬 3 환경에서는 charset-normalizer 가 기본적으로 더 권장됩니다(순수 파이썬 구현, 유지보수 활발).
charset-normalizer로 자동탐지 후 pandas로 읽기
설치
pip install charset-normalizer pandas
탐지 및 로딩 코드
from charset_normalizer import from_bytes
import pandas as pd
def detect_encoding(path: str, sample_size: int = 200_000) -> str:
with open(path, "rb") as f:
raw = f.read(sample_size)
# BOM 우선 처리
if raw.startswith(b"\xef\xbb\xbf"):
return "utf-8-sig"
if raw.startswith(b"\xff\xfe") or raw.startswith(b"\xfe\xff"):
return "utf-16"
result = from_bytes(raw).best()
if result is None:
return "utf-8" # 보수적 기본값
return result.encoding
path = "data.csv"
enc = detect_encoding(path)
print("detected:", enc)
# pandas 로딩
# dtype=str: 숫자/날짜 자동추론으로 인한 깨짐을 줄이기 위해 문자열로 먼저 받는 것을 권장
# keep_default_na=False: "NA" 같은 문자열이 NaN으로 바뀌는 것을 방지(필요 시)
df = pd.read_csv(path, encoding=enc, dtype=str, keep_default_na=False)
print(df.head())
이 패턴은 “대부분의 정상 파일”을 커버합니다. 다만 cp949 와 euc-kr 같은 계열은 샘플에 따라 탐지가 흔들릴 수 있어, 다음의 “검증 기반 폴백”을 추가하면 안정성이 올라갑니다.
후보 인코딩을 두고 ‘실제 파싱 성공’으로 검증하기
탐지 라이브러리의 결과를 그대로 믿기보다, “CSV로 파싱이 되는지”를 기준으로 후보를 평가하는 것이 실무에서 더 강력합니다.
핵심 아이디어
- 후보 인코딩으로 일정 라인 수를 읽어 본다
- 디코딩이 성공하는지
- 구분자 기준으로 컬럼 수가 지나치게 흔들리지 않는지
아래 예시는 표준 csv 로 먼저 검사한 뒤, 최종적으로 pandas 로 읽습니다.
import csv
from collections import Counter
def sniff_encoding_and_delimiter(path: str, encodings=None, sample_lines: int = 50):
if encodings is None:
encodings = [
"utf-8-sig",
"utf-8",
"cp949",
"euc-kr",
"latin1",
]
delimiters = [",", ";", "\t", "|"]
for enc in encodings:
try:
with open(path, "r", encoding=enc, newline="") as f:
lines = []
for _ in range(sample_lines):
line = f.readline()
if not line:
break
lines.append(line)
if not lines:
continue
# 구분자별로 컬럼 수 안정성 평가
best = None
for d in delimiters:
counts = []
for line in lines:
row = next(csv.reader([line], delimiter=d))
counts.append(len(row))
common, freq = Counter(counts).most_common(1)[0]
score = freq # 가장 흔한 컬럼 수가 많이 나오면 안정적
if best is None or score > best[0]:
best = (score, enc, d, common)
if best and best[0] >= max(3, int(sample_lines * 0.6)):
_, enc2, delim2, cols = best
return enc2, delim2, cols
except UnicodeDecodeError:
continue
return None, None, None
path = "data.csv"
enc, delim, cols = sniff_encoding_and_delimiter(path)
print("encoding:", enc, "delimiter:", repr(delim), "cols:", cols)
이 방식은 “인코딩 탐지”와 “CSV 형태 검증”을 함께 하므로, 탐지 결과가 애매한 파일에서도 성공률이 높습니다.
그래도 깨진 바이트가 있다면: errors 옵션으로 유실을 통제
운영에서 종종 겪는 케이스는 파일 전체가 잘못된 인코딩이 아니라, 특정 줄에만 깨진 바이트가 섞인 경우입니다. 이때는 인코딩을 맞춰도 UnicodeDecodeError 가 발생할 수 있습니다.
표준 open에서 errors 처리
path = "data.csv"
enc = "cp949" # 예시
with open(path, "r", encoding=enc, errors="replace") as f:
text = f.read(1000)
print(text)
errors='replace'는 디코딩 불가능한 바이트를�로 치환합니다.- 데이터 유실은 발생하지만 파이프라인이 중단되지 않게 할 수 있습니다.
pandas에서의 현실적인 우회
pandas.read_csv 는 내부에서 텍스트 디코딩을 수행하므로, “깨진 바이트가 있는 파일”은 아래처럼 바이트로 읽어서 정제 후 넘기는 방식이 더 유연합니다.
import pandas as pd
import io
path = "data.csv"
raw = open(path, "rb").read()
# 우선 탐지된 인코딩으로 디코딩하되, 깨진 바이트는 치환
text = raw.decode("utf-8", errors="replace")
df = pd.read_csv(io.StringIO(text), dtype=str, keep_default_na=False)
print(df.head())
이 패턴은 “일단 최대한 읽고, 후처리로 품질을 맞추는” ETL에서 유용합니다.
대용량 CSV에서 자동탐지를 안전하게 적용하는 방법
대용량 파일을 매번 전체 읽어서 탐지하면 느립니다. 탐지는 앞부분 샘플만으로 하고, 로딩은 스트리밍/청크로 처리하는 것이 좋습니다.
청크 로딩 예시
import pandas as pd
path = "big.csv"
enc = "utf-8-sig" # detect_encoding 결과라고 가정
chunks = pd.read_csv(
path,
encoding=enc,
dtype=str,
chunksize=50_000,
keep_default_na=False,
)
for i, chunk in enumerate(chunks, start=1):
# 예: 컬럼 정리, 결측 처리, DB 적재 등
print(i, len(chunk))
청크 처리 중 특정 청크에서만 문제가 발생한다면, 해당 구간의 원본 바이트를 따로 덤프해서 원인 행을 찾아야 합니다. 이런 “부분 실패를 빠르게 좁히는” 접근은 운영 트러블슈팅 전반에 통합니다. 예를 들어 로그나 상태를 바탕으로 원인을 압축해 나가는 방식은 K8s CrashLoopBackOff 원인 10분내 찾는 법 의 사고방식과 유사합니다.
실전에서 자주 헷갈리는 포인트
1) utf-8 과 utf-8-sig 차이
utf-8-sig는 파일 시작의 BOM을 자동으로 제거해 줍니다.- 엑셀에서 “CSV UTF-8”로 저장한 파일은 BOM이 붙는 경우가 많아, 헤더 컬럼명이
\ufeffid처럼 보이는 문제가 생깁니다.
따라서 UTF-8 계열이 의심되면 기본을 utf-8-sig 로 두는 것이 실무에서는 더 안전합니다.
2) cp949 와 euc-kr
- 한국 Windows 환경에서 생성된 CSV는
cp949인 경우가 흔합니다. euc-kr로도 열리는 경우가 있지만, 확장 문자 처리에서 차이가 나므로 보통cp949를 우선합니다.
3) latin1 폴백의 의미
latin1 은 거의 모든 바이트를 “일단” 디코딩해 버립니다. 즉 예외는 피하지만, 실제로는 잘못 해석된 문자열이 될 수 있습니다. 따라서 latin1 은 “파이프라인 중단 방지”용 최후의 폴백으로만 쓰고, 가능하면 원본 인코딩을 찾아 교정하는 것이 좋습니다.
추천하는 최종 템플릿: 탐지 + 검증 + 폴백
아래는 실무에서 그대로 가져다 쓰기 좋은 형태로 정리한 템플릿입니다.
from charset_normalizer import from_bytes
import pandas as pd
def detect_encoding_robust(path: str, sample_size: int = 200_000) -> str:
raw = open(path, "rb").read(sample_size)
if raw.startswith(b"\xef\xbb\xbf"):
return "utf-8-sig"
if raw.startswith(b"\xff\xfe") or raw.startswith(b"\xfe\xff"):
return "utf-16"
guess = from_bytes(raw).best()
enc = guess.encoding if guess else "utf-8"
# 한국어 CSV 실무 폴백 우선순위
candidates = [enc, "utf-8-sig", "utf-8", "cp949", "euc-kr"]
seen = set()
candidates = [c for c in candidates if not (c in seen or seen.add(c))]
for c in candidates:
try:
# 아주 적은 양을 실제로 디코딩해보며 검증
raw.decode(c)
return c
except UnicodeDecodeError:
continue
return "utf-8" # 최후
def read_csv_safely(path: str) -> pd.DataFrame:
enc = detect_encoding_robust(path)
try:
return pd.read_csv(path, encoding=enc, dtype=str, keep_default_na=False)
except UnicodeDecodeError:
# 파일 일부가 깨졌을 가능성: 치환 디코딩 후 재시도
raw = open(path, "rb").read()
text = raw.decode(enc, errors="replace")
import io
return pd.read_csv(io.StringIO(text), dtype=str, keep_default_na=False)
df = read_csv_safely("data.csv")
print(df.shape)
이 코드는 다음을 목표로 합니다.
- BOM을 우선 처리해 헤더 깨짐을 방지
- 탐지 결과가 애매할 때 한국어 환경에서 흔한 후보로 재검증
- 일부 바이트 손상에도 파이프라인이 멈추지 않도록 폴백
마무리
UnicodeDecodeError 는 단순히 “인코딩 하나 지정하면 끝”인 문제가 아니라, 데이터 파이프라인의 신뢰성과 운영 안정성에 직결되는 이슈입니다. 가장 좋은 해법은 업스트림에서 인코딩을 표준화하는 것이지만, 현실적으로는 다양한 출처의 CSV를 받아야 하는 경우가 많습니다.
따라서 실무에서는
- BOM 체크로
utf-8-sig를 우선 고려하고 charset-normalizer로 1차 자동탐지 후- 후보 인코딩 검증과
errors폴백으로 “중단 없는 처리”를 보장하는
이 3단계 방어 전략이 가장 효과적입니다.