Published on

Python UnicodeDecodeError 원인별 해결 9가지

Authors

서버 로그나 데이터 파이프라인을 돌리다 보면 가장 자주 마주치는 예외 중 하나가 UnicodeDecodeError입니다. 핵심은 간단합니다. 바이트를 문자열로 디코딩할 때, 실제 인코딩과 Python이 가정한 인코딩이 다르거나, 해당 코덱으로는 해석 불가능한 바이트가 섞여 있을 때 터집니다.

이 글은 원인을 “상황” 기준으로 9가지로 나누고, 각 케이스마다 바로 적용 가능한 해결책을 코드로 제공합니다.


0) 먼저: 에러 메시지 읽는 법

대표적인 형태는 아래와 같습니다.

  • UnicodeDecodeError: 'utf-8' codec can't decode byte 0x.. in position N: invalid start byte

여기서 중요한 정보는 4가지입니다.

  1. 디코더: 예시에서는 utf-8
  2. 문제 바이트: 예시에서는 0x..
  3. 위치: position N
  4. 원인: 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.openrtencoding을 지정합니다.
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이 아닐 경우 디코딩 단계에서 예외가 납니다(혹은 상위 레벨에서).

해결

  • 먼저 올바른 인코딩으로 decodejson.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 추적기처럼 계층적으로 좁혀가는 방식이 그대로 적용됩니다.


보너스: 빠른 진단 체크리스트

아래 순서대로 보면 대부분 빠르게 결론이 납니다.

  1. 입력이 텍스트가 맞나, 바이너리인가
  2. 입력의 “진짜” 인코딩이 무엇인가
  3. 코드에서 encoding을 명시했나
  4. 혼합/깨짐 가능성이 있나(로그, 스크래핑, 레거시 덤프)
  5. 우회가 필요한가(서비스 지속) 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가지 패턴으로 정리됩니다.

  1. 올바른 인코딩을 명시한다: open(..., encoding="utf-8")
  2. 입력이 텍스트인지 확인하고, 바이너리는 바이너리로 처리한다: rb, 포맷 전용 라이브러리
  3. 깨진 바이트에 대한 정책을 선택한다: errors="replace", ignore, surrogateescape

위 9가지 원인 중 어디에 해당하는지만 정확히 분류하면, 재현과 수정은 대부분 10분 안에 끝납니다.