- Published on
Python UnicodeDecodeError 원인별 해결 9가지
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 로그나 데이터 파이프라인을 돌리다 보면 가장 자주 마주치는 예외 중 하나가 UnicodeDecodeError입니다. 핵심은 간단합니다. 바이트를 문자열로 디코딩할 때, 실제 인코딩과 Python이 가정한 인코딩이 다르거나, 해당 코덱으로는 해석 불가능한 바이트가 섞여 있을 때 터집니다.
이 글은 원인을 “상황” 기준으로 9가지로 나누고, 각 케이스마다 바로 적용 가능한 해결책을 코드로 제공합니다.
0) 먼저: 에러 메시지 읽는 법
대표적인 형태는 아래와 같습니다.
UnicodeDecodeError: 'utf-8' codec can't decode byte 0x.. in position N: invalid start byte
여기서 중요한 정보는 4가지입니다.
- 디코더: 예시에서는
utf-8 - 문제 바이트: 예시에서는
0x.. - 위치:
position N - 원인:
invalid start byte혹은unexpected end of data등
즉, 어떤 코덱으로 디코딩하려 했는지가 이미 메시지에 박혀 있습니다. 대부분의 해결은 “올바른 코덱으로 읽기” 또는 “깨진/혼합 바이트를 허용 정책으로 처리하기”로 귀결됩니다.
1) 원인: OS 기본 인코딩(cp949 등)과 UTF-8 불일치
Windows나 일부 레거시 환경에서는 기본 인코딩이 cp949(혹은 euc-kr)인 경우가 있습니다. 반대로 리눅스/컨테이너에서는 UTF-8이 기본인 경우가 많습니다. 같은 코드라도 실행 환경이 바뀌면 갑자기 터집니다.
해결
- 파일을 열 때
encoding을 명시합니다.
from pathlib import Path
text = Path("input.txt").read_text(encoding="utf-8")
- 반대로 파일이
cp949라면:
text = Path("input.txt").read_text(encoding="cp949")
- 런타임 기본 인코딩 확인:
import locale
import sys
print(locale.getpreferredencoding(False))
print(sys.getdefaultencoding())
운영 환경이 자주 바뀌는 워크로드라면, 컨테이너/VM 로케일도 함께 고정하는 편이 안전합니다. 쿠버네티스/EKS 환경에서 “환경만 바뀌면 갑자기 예외가 난다” 패턴은 네트워크나 권한만이 아니라 로케일/인코딩에서도 자주 발생합니다. 관련 트러블슈팅 접근 방식은 EKS Pod가 ContainerCreating에 멈출 때 10분 진단처럼 “환경 차이”를 먼저 의심하는 습관이 도움이 됩니다.
2) 원인: open()에 encoding을 안 적어서 플랫폼 기본값을 따라감
open("file")처럼 encoding 없이 열면, Python은 OS 기본 인코딩 또는 로케일 설정을 따릅니다. 개발 PC에서는 우연히 맞다가 CI/서버에서 깨지는 전형적인 케이스입니다.
해결
항상 명시합니다.
with open("data.csv", "r", encoding="utf-8") as f:
rows = f.read().splitlines()
CSV라면 newline도 같이 명시해 플랫폼 차이를 줄입니다.
with open("data.csv", "r", encoding="utf-8", newline="") as f:
...
3) 원인: UTF-8 BOM이 붙은 파일을 utf-8로 읽음
엑셀이나 일부 에디터가 UTF-8 BOM을 붙여 저장하는 경우가 있습니다. 이때 utf-8로 읽으면 문자열 맨 앞에 \ufeff가 남아 후속 파싱이 꼬이거나, 일부 라이브러리에서 예상치 못한 동작을 유발합니다. 경우에 따라 BOM 주변 바이트 처리로 디코드 예외가 나기도 합니다.
해결
utf-8-sig로 읽으면 BOM을 자동으로 제거합니다.
from pathlib import Path
text = Path("bom.txt").read_text(encoding="utf-8-sig")
JSON/CSV/환경설정 파일에서 특히 자주 보입니다.
4) 원인: 파일이 사실은 바이너리(이미지/압축/엑셀)인데 텍스트로 읽음
.xlsx, .png, .parquet, .gz 같은 바이너리를 open(..., "r")로 읽으면 거의 확실하게 디코드 에러가 납니다.
해결
- 바이너리는 반드시
rb로 읽고, 해당 포맷 전용 라이브러리를 사용합니다.
with open("image.png", "rb") as f:
data = f.read()
- gzip 텍스트라면
gzip.open에rt와encoding을 지정합니다.
import gzip
with gzip.open("app.log.gz", "rt", encoding="utf-8") as f:
for line in f:
...
- 엑셀은
openpyxl/pandas로 처리합니다. 텍스트 디코딩으로 해결하려 하지 않는 게 정답입니다.
5) 원인: 외부 시스템이 “혼합 인코딩” 데이터를 줌
한 파일/스트림 안에 UTF-8과 cp949가 섞여 있거나, 중간에 깨진 바이트가 섞인 데이터가 실제로 존재합니다. 특히 오래된 로그/레거시 DB 덤프/스크래핑 결과에서 흔합니다.
해결 A: 손실 허용 정책으로 우회
정확한 복원이 불가능하거나 “일단 파이프라인을 살리는 것”이 목표라면 다음 중 하나를 선택합니다.
# 1) 문제가 되는 바이트를 대체 문자로 치환
text = data.decode("utf-8", errors="replace")
# 2) 문제가 되는 바이트를 제거
text = data.decode("utf-8", errors="ignore")
해결 B: surrogateescape로 바이트를 보존
나중에 다시 원문 바이트를 복구해야 한다면 surrogateescape가 유용합니다.
text = data.decode("utf-8", errors="surrogateescape")
# ... 처리 후
data2 = text.encode("utf-8", errors="surrogateescape")
replace/ignore는 데이터 품질을 훼손할 수 있으니, ETL이라면 “어떤 레코드/필드가 깨졌는지”를 별도로 로깅해 재처리 가능하게 만드는 편이 좋습니다.
6) 원인: 네트워크 응답을 무조건 UTF-8로 가정함
requests.get(...).text는 내부적으로 인코딩을 추정하지만, 서버가 잘못된 Content-Type을 보내거나, 실제 바디가 다른 인코딩이면 디코드 문제가 납니다.
해결
- 가능한 경우, 서버가 준 인코딩을 확인하고 명시적으로 디코드합니다.
import requests
resp = requests.get("https://example.com")
print(resp.headers.get("Content-Type"))
# 바이트로 받은 뒤 명시 디코딩
html = resp.content.decode("utf-8", errors="replace")
requests의 추정값을 강제로 지정할 수도 있습니다.
resp = requests.get("https://example.com")
resp.encoding = "utf-8"
html = resp.text
- HTML은 meta charset에 따라 달라질 수 있으니, 크롤링이라면
charset-normalizer(파이썬3) 기반 추정도 고려합니다.
7) 원인: subprocess 출력 디코딩에서 실패
외부 커맨드 출력이 로케일 기반(예: cp949)인데 Python에서 UTF-8로 디코딩하려 하면 실패합니다. CI에서는 UTF-8, 개발 PC에서는 cp949처럼 엇갈리면 재현이 어려워집니다.
해결
text=True를 쓰되encoding을 명시합니다.
import subprocess
p = subprocess.run(
["git", "status"],
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
)
print(p.stdout)
- 혹은 바이트로 받은 뒤 직접 디코드합니다.
p = subprocess.run(["somecmd"], capture_output=True)
stdout = p.stdout.decode("cp949", errors="replace")
Git 관련 자동화에서 출력 파싱을 한다면, 인코딩 문제로 파이프라인이 깨졌을 때 복구 전략을 같이 준비해두는 게 좋습니다. 충돌/꼬임을 복구하는 운영 관점은 Git rebase -i 충돌·커밋 꼬임 완전 복구 가이드도 참고할 만합니다.
8) 원인: json.loads()에 바이트를 잘못 넘기거나, 잘못된 인코딩의 JSON
json.loads()는 str을 기대합니다. 바이트를 넣으면 동작은 하지만, 그 바이트가 UTF-8 JSON이 아닐 경우 디코딩 단계에서 예외가 납니다(혹은 상위 레벨에서).
해결
- 먼저 올바른 인코딩으로
decode후json.loads를 호출합니다.
import json
data = b"{\"msg\": \"hello\"}"
obj = json.loads(data.decode("utf-8"))
- 파일이라면
open(..., encoding=...)으로 읽고json.load()를 사용합니다.
import json
with open("data.json", "r", encoding="utf-8") as f:
obj = json.load(f)
- 깨진 JSON이 섞여 들어오는 파이프라인이라면,
errors="replace"로 “일단 파싱”이 되지 않습니다. JSON은 구조 문자가 깨지면 로더가 바로 실패합니다. 이 경우에는 원문 바이트를 보관하고, 실패 레코드를 격리해 재수집/재전송하는 쪽이 현실적입니다.
9) 원인: 표준입출력(콘솔/로그) 인코딩 문제
서버에서 print()만 했는데도 UnicodeEncodeError가 나는 경우가 더 흔하지만, 반대로 파이프 입력을 읽거나(예: sys.stdin.buffer) 로그 수집 에이전트가 인코딩을 바꾸는 과정에서 UnicodeDecodeError가 나기도 합니다.
해결
- stdin을 바이트로 받고 명시 디코드:
import sys
data = sys.stdin.buffer.read()
text = data.decode("utf-8", errors="replace")
Python 3.7+에서는
PYTHONUTF8=1로 UTF-8 모드를 강제할 수 있습니다(환경 변수). 컨테이너/CI에서 특히 유용합니다.로깅은 파일 핸들러에 인코딩을 명시합니다.
import logging
handler = logging.FileHandler("app.log", encoding="utf-8")
logging.basicConfig(level=logging.INFO, handlers=[handler])
logging.info("한글 로그")
운영에서 “어떤 노드에서만” 터지는 문제는 네트워크/권한뿐 아니라 런타임/로케일 차이가 원인일 수 있습니다. 인프라 쪽 트러블슈팅 관점은 EKS Pod egress만 502? Envoy/NLB 추적기처럼 계층적으로 좁혀가는 방식이 그대로 적용됩니다.
보너스: 빠른 진단 체크리스트
아래 순서대로 보면 대부분 빠르게 결론이 납니다.
- 입력이 텍스트가 맞나, 바이너리인가
- 입력의 “진짜” 인코딩이 무엇인가
- 코드에서
encoding을 명시했나 - 혼합/깨짐 가능성이 있나(로그, 스크래핑, 레거시 덤프)
- 우회가 필요한가(서비스 지속) vs 복원이 필요한가(정합성)
바이트 샘플로 인코딩 추정(간단 버전)
외부 라이브러리 없이도 “대충” 감은 잡을 수 있습니다.
from pathlib import Path
data = Path("mystery.txt").read_bytes()[:2000]
for enc in ["utf-8", "utf-8-sig", "cp949", "euc-kr", "latin-1"]:
try:
data.decode(enc)
print("seems ok:", enc)
except UnicodeDecodeError:
pass
latin-1은 거의 어떤 바이트도 디코딩이 되기 때문에 “마지막 수단”으로만 보세요. 성공했다고 해서 올바른 텍스트라는 뜻은 아닙니다.
정리
UnicodeDecodeError는 복잡해 보이지만, 실무에서의 해법은 크게 3가지 패턴으로 정리됩니다.
- 올바른 인코딩을 명시한다:
open(..., encoding="utf-8") - 입력이 텍스트인지 확인하고, 바이너리는 바이너리로 처리한다:
rb, 포맷 전용 라이브러리 - 깨진 바이트에 대한 정책을 선택한다:
errors="replace",ignore,surrogateescape
위 9가지 원인 중 어디에 해당하는지만 정확히 분류하면, 재현과 수정은 대부분 10분 안에 끝납니다.