Published on

Python UnicodeDecodeError 재현·원인별 5분 해결

Authors

서버 로그나 ETL 파이프라인에서 흔히 보는 UnicodeDecodeError는 대부분 "bytes → str 디코딩" 단계에서 인코딩 가정이 틀렸을 때 발생합니다. 중요한 포인트는 두 가지입니다.

  • 에러 메시지에 나오는 codec(예: utf-8, cp949)는 파이썬이 시도한 디코더입니다.
  • position 123 같은 위치 정보는 문제 바이트가 등장한 오프셋입니다(해당 지점 주변을 보면 원인이 보입니다).

이 글에서는 (1) 10초 재현 코드로 증상을 이해하고, (2) 원인별로 가장 빠른 해결책을 제시합니다. 운영 환경(컨테이너/EKS 등)에서는 로케일/기본 인코딩 차이로 더 잘 터지므로, 마지막에 배포 체크리스트도 넣었습니다.

1) 10초 재현: 왜 "utf-8"이 자주 실패할까?

대표적인 케이스는 CP949(EUC-KR 계열)로 저장된 파일을 UTF-8로 읽는 경우입니다.

# cp949로 인코딩된 바이트를 일부러 만들고, utf-8로 디코딩 시도
b = "한글".encode("cp949")
print(b)
print(b.decode("utf-8"))  # UnicodeDecodeError 발생

실행하면 보통 아래처럼 나옵니다.

  • UnicodeDecodeError: 'utf-8' codec can't decode byte 0xc7 in position ...

즉, 0xC7 같은 바이트는 UTF-8 규칙으로는 유효한 시작 바이트가 아니거나, 뒤 바이트 조합이 맞지 않습니다.

2) 에러 메시지 3요소로 원인 80% 역추적

에러 메시지를 다음 3가지로 쪼개면 진단이 빨라집니다.

  1. codec: 파이썬이 어떤 인코딩으로 디코딩하려 했는가?
  2. byte 값: 어떤 바이트가 문제인가? (예: 0x80~0xFF는 비ASCII)
  3. position: 파일/스트림에서 어디서 깨졌는가?

문제 지점 주변 바이트 덤프

from pathlib import Path

p = Path("data.txt")
b = p.read_bytes()

pos = 120  # 에러 position 근처로 바꿔보기
print(b[pos-20:pos+20])

덤프한 바이트를 보면, 흔히 다음 중 하나입니다.

  • CP949/Shift-JIS 등 레거시 멀티바이트 인코딩
  • UTF-8인데 중간에 **바이너리(이미지/압축/암호화)**가 섞임
  • UTF-8 with BOM / 혹은 Windows 줄바꿈/특수문자 혼입

3) 원인별 "5분 해결" 패턴

아래는 실무에서 가장 자주 만나는 원인 TOP 케이스와 즉시 적용 가능한 해결책입니다.

3.1 파일 읽기: 인코딩을 명시하지 않음(혹은 잘못 명시)

증상

  • 로컬에서는 되는데 컨테이너/서버에서만 실패
  • open('x.csv').read() 같은 코드에서 터짐

원인

  • open()의 기본 인코딩은 OS/로케일 영향을 받습니다.
  • 특히 Windows는 cp949, Linux는 보통 utf-8이라 환경 차이가 큽니다.

해결

  1. 정답 인코딩을 알고 있다면 명시
with open("data.txt", "r", encoding="utf-8") as f:
    text = f.read()
  1. UTF-8 BOM까지 고려한다면 utf-8-sig
with open("data.txt", "r", encoding="utf-8-sig") as f:
    text = f.read()
  1. 레거시 파일(CP949)이라면
with open("data.txt", "r", encoding="cp949") as f:
    text = f.read()
  1. "일단 안 죽게"가 목표라면(권장: 데이터 품질 확인 전 임시)
with open("data.txt", "r", encoding="utf-8", errors="replace") as f:
    text = f.read()
# 깨진 문자는 � replacement 문자로 대체됨

> errors="ignore"는 데이터 유실이 조용히 발생하므로 더 위험합니다.

3.2 bytes/str 혼동: 이미 bytes인데 또 디코딩하거나, 반대로 인코딩

증상

  • some_bytes.decode('utf-8')에서 실패
  • 또는 네트워크/압축 결과를 문자열로 착각

해결

타입을 강제 점검하는 습관이 가장 빠릅니다.

def ensure_text(x, encoding="utf-8"):
    if isinstance(x, bytes):
        return x.decode(encoding, errors="strict")
    if isinstance(x, str):
        return x
    raise TypeError(f"unexpected type: {type(x)}")

그리고 바이너리일 가능성이 있으면 먼저 판별합니다.

import binascii

print(binascii.hexlify(b[:32]))  # 앞부분이 PNG/JPG/ZIP 매직넘버인지 확인

3.3 표준입력/파이프: 컨테이너에서만 깨지는 케이스

증상

  • sys.stdin.read()에서 UnicodeDecodeError
  • cat file | python script.py에서만 실패

원인

  • stdin이 텍스트로 래핑될 때 사용되는 인코딩이 환경에 따라 달라짐

해결

stdin바이트로 받고 직접 디코딩하거나, reconfigure()로 고정합니다.

import sys

# Python 3.7+
sys.stdin.reconfigure(encoding="utf-8", errors="strict")
text = sys.stdin.read()

바이트로 받는 방식:

import sys

data = sys.stdin.buffer.read()  # bytes
text = data.decode("utf-8")

운영이 EKS/컨테이너라면, "환경 차이"라는 점에서 다른 장애들과 결이 비슷합니다. 인프라 레벨에서 환경 편차를 줄이는 접근은 EKS ExternalSecret 미동작 - IRSA·KMS·권한 10분 진단처럼 "기본값/권한/환경"을 체크리스트화하는 방식이 효과적입니다.

3.4 HTTP 응답: requests/urllib에서 잘못된 charset 추정

증상

  • response.text 접근 시 글자가 깨지거나 디코딩 에러
  • 일부 사이트/레거시 API에서 Content-Type의 charset이 없거나 틀림

해결

  1. 원문 바이트(content)로 받고 charset을 명시
import requests

r = requests.get("https://example.com")
raw = r.content  # bytes
text = raw.decode("utf-8", errors="replace")
  1. 서버가 EUC-KR/CP949 계열이면
text = r.content.decode("cp949", errors="replace")
  1. 헤더를 신뢰하되, 의심되면 override
r.encoding = "utf-8"  # requests가 text 만들 때 사용할 인코딩
text = r.text

HTTP 계층 문제는 종종 403/502 같은 네트워크 장애와 함께 나타나며, 원인 분리(응답 본문 인코딩 문제 vs 응답 자체 실패)가 중요합니다. 비슷한 트러블슈팅 접근은 AWS WAF Bot Control 막힘으로 403 지속될 때처럼 "응답이 왔는지/무엇이 차단했는지"를 먼저 분리하는 방식이 통합니다.

3.5 CSV/엑셀 산출물: UTF-8인데 Excel에서만 깨짐

증상

  • 파이썬에서는 정상인데, Excel로 열면 한글 깨짐

원인

  • Excel이 UTF-8을 자동 인식 못 하는 경우가 있어 BOM이 필요

해결

utf-8-sig로 저장

import csv

rows = [["이름", "설명"], ["홍길동", "테스트"]]

with open("out.csv", "w", newline="", encoding="utf-8-sig") as f:
    w = csv.writer(f)
    w.writerows(rows)

3.6 JSON: bytes를 그대로 json.loads에 넣거나, 잘못된 인코딩

증상

  • json.loads(some_bytes) 또는 디코딩 과정에서 에러

해결

bytes → str을 명확히 한 뒤 json.loads

import json

payload = b'{"msg": "hello"}'
obj = json.loads(payload.decode("utf-8"))

만약 외부 시스템이 이상한 JSON을 준다면(스키마/형식 오류 포함) 인코딩 에러와 함께 섞여 보일 수 있습니다. API 연동 시 JSON 형식 자체를 고정하는 접근은 Claude Tool Use 400 에러 - JSON 스키마 해결법처럼 입력 검증을 먼저 두는 것이 재발 방지에 좋습니다.

4) "인코딩을 모를 때"의 현실적인 판별 루틴

정답 인코딩을 모르면, 아래 순서가 가장 빠릅니다.

  1. 데이터가 텍스트가 맞는지(바이너리 혼입 여부)
  2. UTF-8 시도
  3. UTF-8-sig 시도(BOM)
  4. CP949/EUC-KR 시도(한국 환경에서 빈도 높음)
  5. 실패 구간 주변 바이트 확인 후 소스 시스템 추적

간단한 시도 코드:

from pathlib import Path

b = Path("mystery.dat").read_bytes()

candidates = ["utf-8", "utf-8-sig", "cp949", "euc-kr"]
for enc in candidates:
    try:
        s = b.decode(enc)
        print("OK:", enc)
        break
    except UnicodeDecodeError as e:
        print("FAIL:", enc, "pos=", e.start)

> chardet/charset-normalizer 같은 라이브러리는 도움이 되지만 100% 정답은 아닙니다. 특히 짧은 텍스트나 혼합 데이터에서는 오탐이 잦습니다.

5) 재발 방지 체크리스트(운영/팀 기준)

  • 파일/네트워크 I/O는 항상 encoding=을 명시한다.
  • 입력은 가능하면 바이트로 받고, 경계에서만 디코딩한다(경계: 파일 읽기, HTTP 응답 수신, 메시지 큐 컨슘 등).
  • errors="replace"는 임시 대응으로만 사용하고, 원본 시스템 인코딩을 문서화한다.
  • 컨테이너/서버의 로케일을 고정한다(예: LANG=C.UTF-8).
  • CSV 산출물은 소비자가 Excel이면 utf-8-sig를 기본으로 고려한다.

6) 결론: UnicodeDecodeError는 "디코딩 경계"를 잡으면 끝난다

UnicodeDecodeError는 어렵게 보이지만, 실제로는 어느 지점에서 bytes를 str로 바꾸는지만 찾으면 해결이 단순해집니다.

  • 파일이면 open(..., encoding=...)
  • 표준입력이면 sys.stdin.buffer 또는 reconfigure()
  • HTTP면 response.content.decode(...)로 경계를 명확히
  • Excel 호환이면 utf-8-sig

다음에 같은 에러를 보면, 먼저 “지금 다루는 게 bytes인가 str인가?”를 확인하고, 디코딩을 한 곳에서, 명시적으로 수행하도록 코드를 정리해 보세요.