- Published on
Python UnicodeDecodeError - utf-8 해결 7가지
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
운영 환경에서 로그를 읽거나 CSV를 파싱하거나, 외부 API 응답을 디코딩하는 순간 갑자기 UnicodeDecodeError: 'utf-8' codec can't decode byte ... 가 터지면 대부분은 “UTF-8로 읽으려고 했는데 실제 바이트가 UTF-8이 아니었다”는 뜻입니다. 문제는 여기서 끝나지 않습니다.
- 데이터가 애초에 다른 인코딩(cp949, euc-kr, latin-1 등)일 수 있고
- 파일 앞에 BOM이 붙어 있을 수 있고
- 네트워크/서브프로세스가 반환하는 바이트가 부분적으로 깨졌을 수 있으며
- 디코딩 시점과 위치가 분산되어 재현이 어렵기도 합니다.
이 글은 원인을 빠르게 분류하고, 상황별로 바로 적용 가능한 7가지 해결책과 함께 “다시는 같은 장애가 안 나게” 만드는 패턴까지 정리합니다.
먼저: 에러 메시지에서 단서 뽑기
에러는 보통 다음 정보를 줍니다.
codec can't decode byte 0x..: UTF-8 규칙에 맞지 않는 바이트가 섞임invalid start byte/invalid continuation byte: UTF-8 멀티바이트 시퀀스가 깨짐position N: 문제가 발생한 바이트 위치
즉, 핵심은 바이트를 어디서 읽었고, 그 바이트의 실제 인코딩이 무엇인지를 찾는 것입니다.
재현용 최소 코드
data = b"\xbe\xc8\xb3\xe7" # 예: cp949에서 '안녕'에 해당하는 바이트(환경에 따라 다름)
print(data.decode("utf-8"))
이런 식으로 “UTF-8이라고 가정한 디코딩”이 실패하면 같은 예외가 납니다.
해결 1) 파일을 열 때 encoding 을 명시한다
가장 흔한 케이스는 파일이 UTF-8이 아닌데 기본값(또는 관성적으로 UTF-8)을 사용한 경우입니다. 특히 Windows에서 만들어진 텍스트 파일은 cp949 계열인 경우가 많습니다.
올바른 예
from pathlib import Path
path = Path("data.txt")
text = path.read_text(encoding="cp949")
print(text)
open 으로 처리
with open("data.txt", "r", encoding="cp949") as f:
text = f.read()
팁: 운영에서 인코딩이 섞인 파일이 들어온다면
- “기본 인코딩은 UTF-8”로 두되
- 실패 시에만 cp949/euc-kr 등을 시도하는 폴백 전략을 적용하세요(아래 해결 4 참고)
해결 2) BOM 문제면 utf-8-sig 를 사용한다
UTF-8 파일인데도 디코딩/파싱이 이상하거나, 첫 컬럼명에 이상한 문자가 붙는 경우가 있습니다. 이는 파일 시작에 BOM(Byte Order Mark) 이 붙어 있는 케이스입니다.
BOM 있는 UTF-8 텍스트 처리
with open("data.csv", "r", encoding="utf-8-sig") as f:
header = f.readline()
print(header)
utf-8-sig 는 BOM이 있으면 제거해 주고, 없으면 일반 UTF-8처럼 동작합니다.
해결 3) 디코딩 에러 처리 전략을 명시한다(errors=)
데이터 품질이 완벽하지 않은 현실에서는 “일단 파이프라인을 멈추지 않고 진행”해야 할 때가 있습니다. 이때는 errors 옵션을 명시해 깨진 바이트를 어떻게 처리할지 정책을 고정합니다.
3-1) 대체 문자로 치환
text = raw_bytes.decode("utf-8", errors="replace")
- 장점: 실패 없이 진행
- 단점: 원문 손실(깨진 부분은
�로 바뀜)
3-2) 깨진 바이트를 무시
text = raw_bytes.decode("utf-8", errors="ignore")
- 장점: 파이프라인 계속 진행
- 단점: 손실이 더 커질 수 있음(문장 의미가 바뀔 수 있음)
3-3) 원인을 추적하기 위해 “역변환 가능한” 처리
text = raw_bytes.decode("utf-8", errors="surrogateescape")
# 나중에 다시 bytes로 되돌릴 수 있음
raw_roundtrip = text.encode("utf-8", errors="surrogateescape")
로그 수집/ETL에서 “일단 저장하고 나중에 정제”해야 할 때 유용합니다.
해결 4) 인코딩을 감지하거나, 폴백 디코딩을 구현한다
외부에서 들어오는 파일/응답이 인코딩이 제각각이면, 한 가지 인코딩으로 고정하는 것만으로는 부족합니다.
4-1) 간단 폴백 체인
def decode_with_fallback(b: bytes) -> str:
for enc in ("utf-8", "utf-8-sig", "cp949", "euc-kr", "latin-1"):
try:
return b.decode(enc)
except UnicodeDecodeError:
continue
# 마지막 방어선
return b.decode("utf-8", errors="replace")
with open("input.txt", "rb") as f:
raw = f.read()
text = decode_with_fallback(raw)
print(text)
latin-1 은 모든 바이트를 1:1로 매핑하므로 “절대 실패하지 않는 디코딩”이지만, 사람이 읽을 수 있는 텍스트가 된다는 보장은 없습니다. 즉, 장애 회피용 최후의 수단으로만 쓰는 게 좋습니다.
4-2) 라이브러리로 감지(정확도는 케이스별로 다름)
# pip install charset-normalizer
from charset_normalizer import from_bytes
raw = open("input.txt", "rb").read()
result = from_bytes(raw).best()
text = str(result)
print(result.encoding)
감지는 “확률”이므로, 중요한 데이터 파이프라인에서는 감지 결과를 로그로 남기고 샘플링 검증을 권장합니다.
해결 5) 텍스트 모드 대신 바이너리로 읽고, 디코딩 위치를 통제한다
에러가 여기저기서 터지는 이유 중 하나는, 파일/네트워크/서브프로세스에서 자동으로 텍스트 디코딩이 일어나기 때문입니다.
원칙은 간단합니다.
- 경계(I/O)에서는
bytes로 받고 - 애플리케이션 내부의 “딱 한 곳”에서만 디코딩한다
파일을 먼저 bytes로 읽기
raw = open("data.txt", "rb").read()
text = raw.decode("utf-8") # 또는 fallback/감지 로직 적용
스트리밍 처리(대용량)
import codecs
with open("big.txt", "rb") as fb:
reader = codecs.getreader("utf-8")(fb, errors="replace")
for line in reader:
pass
대용량 처리에서 텍스트 디코딩이 깨져 프로세스가 죽으면, 재시도 폭증으로 다른 장애(메모리 압박 등)로 번질 수 있습니다. 장애가 연쇄로 확장될 때는 인프라 관점의 진단도 필요합니다. 예를 들어 프로세스가 반복적으로 죽는다면 리눅스 OOM Killer로 프로세스 죽음 원인 추적처럼 시스템 레벨에서 함께 확인해 두면 좋습니다.
해결 6) subprocess 출력 디코딩은 text=True 와 encoding 을 명시한다
서브프로세스 출력은 OS/로케일 영향을 많이 받습니다. 특히 Windows에서는 cp949, 리눅스 컨테이너는 UTF-8, 레거시 서버는 C 로케일 등으로 섞일 수 있습니다.
안전한 패턴
import subprocess
p = subprocess.run(
["bash", "-lc", "echo hello"],
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
check=False,
)
print(p.stdout)
이미 bytes 로 받았다면:
p = subprocess.run(["cmd"], capture_output=True)
stdout = p.stdout.decode("cp949", errors="replace")
포인트는 “어떤 인코딩으로 디코딩할지”를 코드에 박아 환경 의존성을 제거하는 것입니다.
해결 7) DB/HTTP/메시지 큐에서의 인코딩 계약을 고정한다
UnicodeDecodeError는 파일만의 문제가 아닙니다. 실제 운영에서는 다음 경로에서 자주 발생합니다.
- HTTP 응답이
Content-Type에charset을 잘못 표기 - DB 커넥션 인코딩이 서버/클라이언트에서 불일치
- Kafka/SQS 등 메시지가 bytes인데 소비자가 UTF-8로 가정
7-1) HTTP 응답은 response.content 와 response.apparent_encoding 를 구분
import requests
r = requests.get("https://example.com")
raw = r.content # bytes
# 서버가 charset을 잘못 주는 경우가 있어, r.text만 믿지 않기
text = raw.decode("utf-8", errors="replace")
7-2) PostgreSQL은 서버/DB 인코딩 확인
PostgreSQL에서는 데이터베이스 인코딩이 UTF8 인지, 클라이언트 인코딩이 무엇인지가 중요합니다.
SHOW server_encoding;
SHOW client_encoding;
애플리케이션에서 커넥션 옵션으로 인코딩을 고정하거나, 드라이버 기본값이 환경에 따라 바뀌지 않게 설정하세요. 데이터가 깨져 락/대기 문제로 번질 때는 PostgreSQL deadlock detected 진단·해결 9단계처럼 “증상이 다른 장애”로 보일 수 있어, 원인 분리를 위해서라도 텍스트 인코딩 계약을 먼저 안정화하는 게 좋습니다.
7-3) 메시지 큐는 페이로드를 명확히 정의
- JSON이면 “항상 UTF-8로 인코딩한 bytes”로 규약화
- 헤더/메타데이터에
content-encoding같은 필드 추가 - 소비자는 bytes로 받고, 규약에 따라 디코딩
import json
payload = {"message": "안녕"}
raw = json.dumps(payload, ensure_ascii=False).encode("utf-8")
decoded = json.loads(raw.decode("utf-8"))
print(decoded["message"])
디버깅 체크리스트(빠른 원인 분류)
운영에서 빠르게 분류하려면 아래 순서가 효율적입니다.
- 입력을 텍스트가 아니라
bytes로 확보했는가 - 문제 바이트 위치(
position) 주변을 헥스로 덤프했는가 - 파일이면 BOM 여부를 확인했는가
- 데이터 출처(Windows 생성, 레거시 시스템, 외부 벤더)를 확인했는가
- “일단 진행”이 목표인지, “정확한 복구”가 목표인지 결정했는가
문제 바이트 주변 덤프 예시
def hexdump_around(b: bytes, pos: int, window: int = 16) -> str:
start = max(0, pos - window)
end = min(len(b), pos + window)
return b[start:end].hex()
raw = open("input.bin", "rb").read()
try:
raw.decode("utf-8")
except UnicodeDecodeError as e:
print("pos:", e.start)
print("hex:", hexdump_around(raw, e.start))
마무리: 가장 좋은 해결은 “인코딩을 추측하지 않게” 만드는 것
UnicodeDecodeError: 'utf-8' 는 증상이고, 근본 원인은 대개 “인코딩 계약 부재”입니다.
- 파일/메시지/응답의 인코딩을 명시하고
- I/O 경계에서
bytes로 받은 뒤 디코딩 지점을 통제하며 - 불가피하면
errors=정책과 폴백 전략을 문서화
이 3가지를 지키면 같은 에러가 “가끔 터지는 운영 이슈”에서 “재현 가능한 버그”로 바뀌고, 결국 제거할 수 있습니다.