Published on

Python UnicodeDecodeError - utf-8 해결 7가지

Authors

서버 로그, 크롤링, CSV 적재, 서브프로세스 출력 파싱처럼 “바이트를 문자열로 바꾸는 순간”에는 거의 항상 인코딩 문제가 숨어 있습니다. 그중에서도 가장 흔한 예외가 UnicodeDecodeError: 'utf-8' codec can't decode byte ... 입니다.

문제는 이 에러가 “UTF-8이 틀렸다”라기보다, 지금 디코딩하려는 바이트가 UTF-8이 아닐 가능성 또는 텍스트가 아니라 바이너리인데 텍스트로 착각한 가능성을 의미한다는 점입니다. 이 글에서는 원인 진단 흐름과 함께, 현장에서 바로 적용 가능한 7가지 해결책을 코드로 정리합니다.

에러 메시지부터 제대로 읽기

대표 메시지는 아래 형태입니다.

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

여기서 핵심은 다음입니다.

  • 0x.. 바이트 값이 UTF-8 규칙에 맞지 않는다
  • position N문자 위치가 아니라 바이트 배열에서의 인덱스

즉, “어떤 입력이 들어왔는지”를 먼저 확인해야 합니다.

10초 진단: 이게 텍스트가 맞나?

def sniff_bytes(b: bytes, n: int = 64) -> None:
    print("len=", len(b))
    print("head=", b[:n])

# 예: 파일에서 일부만 읽어 확인
with open("data.bin", "rb") as f:
    head = f.read(200)
    sniff_bytes(head)
  • b'\x89PNG'로 시작하면 PNG 바이너리입니다
  • b'PK\x03\x04'면 ZIP 계열입니다
  • b'\xff\xd8\xff'면 JPEG입니다

바이너리를 텍스트로 디코딩하면 UTF-8이든 뭐든 깨집니다.

해결 1) 읽는 단계에서 encoding을 명시한다

가장 흔한 원인은 “기본 인코딩을 믿고 열었다”입니다. Python 3의 open() 기본 인코딩은 OS/로케일 영향을 받습니다. 특히 Windows에서는 cp949 계열, 리눅스는 UTF-8이 일반적이라, 개발 환경에서는 되는데 서버에서만 터지기도 합니다.

# UTF-8 텍스트 파일이라면 명시적으로
with open("app.log", "r", encoding="utf-8") as f:
    text = f.read()

# BOM이 섞인 UTF-8이라면
with open("app.log", "r", encoding="utf-8-sig") as f:
    text = f.read()

utf-8-sig는 파일 시작의 BOM을 제거해주므로, CSV/JSON에서 첫 컬럼명이 \ufeffid처럼 오염되는 문제도 같이 예방합니다.

해결 2) 실제 인코딩을 탐지하고 맞춰 디코딩한다

입력이 UTF-8이 아닐 수 있습니다. 예를 들어 오래된 CSV가 cp949euc-kr인 경우가 많습니다. 이때는 “추정 후 명시”가 가장 안전합니다.

charset-normalizer로 추정하기

from charset_normalizer import from_bytes

raw = open("korean.csv", "rb").read()
result = from_bytes(raw).best()
print(result.encoding, result.percent_chaos)
text = str(result)
  • 추정 결과가 cp949로 나오면 open(..., encoding="cp949")로 고정
  • percent_chaos가 높으면 텍스트 자체가 손상되었거나 바이너리일 가능성

실전 팁

  • 한국어 Windows 산출물은 cp949가 빈번
  • 일부 시스템은 euc-kr로 저장하지만 cp949로 읽어도 대체로 통과
  • CSV는 “엑셀에서 저장” 과정에서 BOM이 끼거나 ANSI로 떨어지는 일이 잦음

해결 3) errors 정책으로 “깨진 바이트”를 처리한다

정상 텍스트가 섞여 있는데 일부 바이트만 깨진 경우, 완벽 복구보다 “서비스 지속”이 목표일 수 있습니다. 이때는 errors 옵션을 사용합니다.

# 깨진 바이트는 대체 문자로 치환
with open("dirty.txt", "r", encoding="utf-8", errors="replace") as f:
    text = f.read()

# 깨진 바이트는 무시 (데이터 손실 가능)
with open("dirty.txt", "r", encoding="utf-8", errors="ignore") as f:
    text = f.read()

# 원시 바이트를 유니코드 사영역으로 보존 (나중에 재처리 가능)
with open("dirty.txt", "r", encoding="utf-8", errors="surrogateescape") as f:
    text = f.read()

권장 순서는 보통 다음입니다.

  1. 원인 해결이 가능하면 인코딩을 맞춘다
  2. 불가피하면 replace
  3. “원본 바이트 보존”이 중요하면 surrogateescape

해결 4) 바이너리는 끝까지 bytes로 다루고, 텍스트만 디코딩한다

이미지, 압축파일, PDF, protobuf 같은 바이너리를 중간에 실수로 .decode("utf-8") 해버리면 바로 터집니다. 파이프라인에서 “텍스트”와 “바이너리”를 구분하는 규칙을 세우는 게 중요합니다.

def load_payload(path: str) -> bytes:
    with open(path, "rb") as f:
        return f.read()

payload = load_payload("report.pdf")
# 여기서는 decode 하지 않는다

# 정말 텍스트로 확정되는 지점에서만 디코딩
text = payload.decode("utf-8", errors="strict")  # 확정일 때만

특히 HTTP 응답 처리에서 response.contentresponse.text를 혼용하면 문제가 커집니다.

해결 5) HTTP/크롤링: 헤더와 실제 바이트가 다를 때는 “바이트 기반”으로 처리

웹은 생각보다 거짓말을 많이 합니다.

  • 헤더는 charset=utf-8인데 실제는 euc-kr
  • 헤더가 없고 HTML meta charset에만 있음
  • 응답이 gzip/deflate로 압축되어 있는데 중간에서 잘못 풀림

requests를 예로 들면, response.text는 추정 인코딩을 사용합니다. 확실히 하려면 response.content로 바이트를 받고 직접 결정하세요.

import requests
from charset_normalizer import from_bytes

r = requests.get("https://example.com")
raw = r.content

best = from_bytes(raw).best()
text = str(best)

또는 서버가 올바른 헤더를 준다는 확신이 있으면:

r = requests.get("https://example.com")
r.encoding = "utf-8"  # 강제
text = r.text

해결 6) CSV/JSON 처리에서 흔한 함정: BOM, 줄바꿈, 잘못된 quoting

CSV: newline=""utf-8-sig 조합

CSV는 OS별 줄바꿈, 엑셀 BOM, 따옴표 깨짐이 함께 터지는 경우가 많습니다.

import csv

with open("data.csv", "r", encoding="utf-8-sig", newline="") as f:
    reader = csv.DictReader(f)
    rows = list(reader)
  • newline=""는 Python csv 모듈 권장 설정
  • utf-8-sig는 BOM 제거

JSON: 바이너리로 읽고 디코딩을 통제

import json

raw = open("data.json", "rb").read()
text = raw.decode("utf-8-sig")
obj = json.loads(text)

JSON은 원칙적으로 UTF-8이 흔하지만, BOM이나 파일 생성 도구에 따라 예외가 생깁니다.

해결 7) 서브프로세스/로그 수집: 출력 인코딩을 명시하거나 안전하게 캡처한다

배치 스크립트, CLI 도구, Git, ffmpeg 같은 외부 프로그램 출력은 로케일 영향을 받습니다. subprocess에서 text=True를 쓰면 내부적으로 디코딩이 일어나는데, 여기서 UTF-8 가정이 깨지면 오류가 납니다.

import subprocess

# 방법 A: bytes로 받고 나중에 디코딩
p = subprocess.run(["some-cli", "--help"], capture_output=True)
out = p.stdout.decode("utf-8", errors="replace")

# 방법 B: 인코딩을 명시
p = subprocess.run(
    ["some-cli", "--help"],
    capture_output=True,
    text=True,
    encoding="utf-8",
    errors="replace",
)
out = p.stdout

Windows에서 특히 자주 보이는 케이스는 콘솔 코드페이지가 UTF-8이 아닌 경우입니다. 이때는 encoding="cp949"가 필요할 수 있습니다.

재발 방지 체크리스트

아래 중 하나라도 “모호”하면, UTF-8 디코딩은 언젠가 터집니다.

  1. 입력이 파일인가, 네트워크인가, DB인가, 서브프로세스인가
  2. 그 입력의 “원본 인코딩”을 누가 보장하는가
  3. 텍스트가 아닌 바이너리가 섞일 가능성은 없는가
  4. BOM, 줄바꿈, 압축, 전송 중 손상 가능성은 없는가

권장 패턴은 다음입니다.

  • I/O 경계에서는 항상 bytes로 받아서 확인
  • “텍스트로 확정되는 지점”에서만 decode()
  • open()에는 encoding을 습관적으로 명시
  • 불가피한 경우 errors="replace"로 장애 전파를 막고, 원인 분석 로그를 남김

디버깅 관점에서 같이 읽으면 좋은 글

인코딩 문제는 결국 “환경/권한/런타임 차이”에서 폭발하는 경우가 많습니다. 장애를 빠르게 좁히는 접근법이 필요하다면 아래 글들도 같은 맥락에서 도움이 됩니다.

마무리: 가장 빠른 해결 순서

실무에서 UnicodeDecodeError를 만나면 보통 아래 순서가 가장 효율적입니다.

  1. 해당 입력을 rb로 받아 앞부분 바이트를 출력해 텍스트 여부 확인
  2. 텍스트가 맞다면 BOM 여부 확인 후 utf-8-sig 적용
  3. 인코딩이 다르면 charset-normalizer로 추정 후 고정
  4. 서비스 지속이 우선이면 errors="replace"로 완화
  5. 파이프라인 전체에서 “바이너리와 텍스트 경계”를 명확히 분리

이 5단계만 습관화해도, 대부분의 utf-8 디코딩 오류는 재발 빈도가 크게 줄어듭니다.