Published on

Python UnicodeDecodeError - utf-8 실전 해결법

Authors

서버 로그를 파싱하거나 CSV를 읽고, 크롤링한 HTML을 저장하는 순간 갑자기 터지는 오류가 있습니다. 바로 UnicodeDecodeError: 'utf-8' codec can't decode byte ... 입니다. 이 에러는 “UTF-8로 디코딩하려 했는데, 실제 바이트열은 UTF-8 규칙을 만족하지 않는다”는 뜻입니다. 문제는 원인이 한 가지가 아니라는 점입니다. 파일 인코딩이 다를 수도 있고(예: CP949/EUC-KR), 중간에 바이너리가 섞였을 수도 있으며, 네트워크 응답의 charset 헤더가 틀렸을 수도 있습니다.

이 글에서는 **어디서 깨지는지(입력 경로)**를 먼저 분리하고, 그 다음 정확한 인코딩 판별 → 안전한 디코딩 → 재발 방지(파이프라인 설계) 순으로 실전 해결법을 정리합니다.

1) 에러 메시지부터 원인 좁히기

대표적인 형태는 다음과 같습니다.

UnicodeDecodeError: 'utf-8' codec can't decode byte 0xb0 in position 12: invalid start byte

여기서 핵심은:

  • byte 0xb0: UTF-8에서 시작 바이트로 올 수 없는 값일 가능성이 큼(예: CP949 텍스트에서 자주 등장)
  • position 12: 몇 번째 바이트에서 깨졌는지. 파일/응답에서 해당 위치 주변을 덤프하면 혼입된 바이너리나 잘못된 인코딩을 빨리 찾을 수 있습니다.

바로 확인하는 스니펫:

path = "data.txt"
with open(path, "rb") as f:
    b = f.read()

pos = 12
print(b[max(0, pos-20):pos+20])

2) 가장 흔한 케이스: 파일 인코딩이 UTF-8이 아님

2.1 open() 기본값에 기대지 말기

Python 3에서 open("file")은 OS 기본 인코딩(Windows는 흔히 cp949/mbcs)을 따라가기도 하고, 환경에 따라 달라집니다. 항상 명시하는 습관이 재발 방지에 가장 효과적입니다.

# UTF-8 파일을 읽는다고 확신할 때
with open("input.csv", "r", encoding="utf-8") as f:
    text = f.read()

그런데 실제 파일이 CP949라면 위 코드는 깨집니다. 그럴 때는 다음처럼 바꿉니다.

# 한국어 Windows 환경에서 흔한 인코딩
with open("input.csv", "r", encoding="cp949") as f:
    text = f.read()

2.2 BOM(UTF-8-SIG) 때문에 컬럼명이 이상해질 때

엑셀 등에서 저장한 CSV가 UTF-8 BOM을 포함하면, 첫 컬럼 앞에 \ufeff가 붙어 후속 처리에서 꼬일 수 있습니다. 이때는 utf-8-sig로 읽으면 BOM이 제거됩니다.

import csv

with open("excel.csv", "r", encoding="utf-8-sig", newline="") as f:
    reader = csv.DictReader(f)
    rows = list(reader)

3) 판별이 어려울 때: 바이트로 읽고 추정하기

“인코딩이 뭔지 모르겠다”가 현실에서 가장 흔합니다. 이때는 텍스트로 바로 열지 말고 먼저 바이트로 읽어 추정하는 것이 안전합니다.

3.1 charset-normalizer(권장)로 추정

Python 생태계에서 chardet도 많이 쓰지만, 최근에는 charset-normalizer가 기본 선택지로 자주 추천됩니다.

pip install charset-normalizer
from charset_normalizer import from_bytes

raw = open("unknown.txt", "rb").read()
result = from_bytes(raw).best()

print(result.encoding, result.chaos, result.percent_chaos)
text = str(result)  # 디코딩된 문자열
  • encoding: 추정 인코딩
  • chaos: 텍스트로 보기 어려운(혼입/깨짐) 정도

추정이 100% 맞지 않을 수 있으니, 업무 파이프라인에서는 “추정 실패 시 정책”(예: 실패 로그 남기고 스킵)을 같이 설계하는 게 좋습니다.

3.2 “일단 살려서” 처리해야 할 때: errors= 전략

정확한 원인을 찾는 게 최우선이지만, 운영 환경에서는 당장 파이프라인이 멈추면 안 되는 경우가 많습니다. 그럴 때는 errors 옵션을 정책적으로 선택합니다.

raw = open("unknown.txt", "rb").read()

# 1) 깨지는 바이트를 대체 문자(�)로 치환
text = raw.decode("utf-8", errors="replace")

# 2) 깨지는 바이트를 버림(데이터 손실 가능)
text2 = raw.decode("utf-8", errors="ignore")

# 3) 깨지는 바이트를 \xNN 형태로 보존 (디버깅/로깅에 유리)
text3 = raw.decode("utf-8", errors="backslashreplace")

운영 관점에서 추천 순서는 보통:

  • 로그/분석용: backslashreplace
  • 사용자 노출 텍스트: replace
  • 정말 불가피한 경우에만: ignore

4) 네트워크/크롤링에서 깨질 때: 헤더 charset을 맹신하지 말기

HTTP 응답은 Content-Type: text/html; charset=...로 인코딩을 알려주지만, 서버가 틀리게 주는 경우도 있습니다.

4.1 requests에서의 안전한 처리

import requests

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

# 1) 서버가 준 인코딩
print("header encoding:", r.encoding)

# 2) requests가 추정한 인코딩(헤더가 없거나 이상할 때)
r.encoding = r.apparent_encoding
html = r.text

만약 특정 사이트가 항상 EUC-KR인데 헤더가 비어 있거나 잘못되면, 아예 고정하는 편이 낫습니다.

r = requests.get("https://legacy.example.kr")
r.encoding = "euc-kr"
html = r.text

4.2 bytes로 받고 직접 디코딩하기

문제 재현/디버깅에는 bytes로 받아서 직접 디코딩하는 방식이 가장 명확합니다.

raw = r.content  # bytes
try:
    html = raw.decode("utf-8")
except UnicodeDecodeError:
    html = raw.decode("cp949", errors="replace")

5) 서브프로세스/파이프에서 깨질 때: text=True의 함정

subprocess에서 text=True(또는 universal_newlines=True)를 쓰면, Python이 자동으로 디코딩합니다. 이때도 기본 인코딩/환경에 따라 UnicodeDecodeError가 날 수 있습니다.

5.1 인코딩을 명시

import subprocess

p = subprocess.run(
    ["some-command", "--output"],
    capture_output=True,
    text=True,
    encoding="utf-8",  # 핵심
    errors="backslashreplace",
)
print(p.stdout)

5.2 bytes로 받은 뒤 처리

p = subprocess.run(["some-command"], capture_output=True)
raw = p.stdout
text = raw.decode("utf-8", errors="replace")

6) pandas에서 자주 터지는 지점과 해결 패턴

CSV/TSV를 읽을 때 UnicodeDecodeError가 가장 자주 등장합니다.

import pandas as pd

# UTF-8이 아닐 가능성이 있으면 encoding을 명시
df = pd.read_csv("data.csv", encoding="cp949")

구분자가 애매하거나 줄바꿈/따옴표가 섞여 있으면 엔진/옵션까지 조정해야 합니다.

df = pd.read_csv(
    "data.csv",
    encoding="utf-8-sig",
    sep=",",
    engine="python",
    on_bad_lines="skip",  # 데이터 품질이 나쁠 때 임시 방편
)

pandas 전처리에서 경고/복사 이슈까지 같이 겪는다면 pandas SettingWithCopyWarning 완벽 해결 7가지도 함께 정리해두면 데이터 파이프라인 안정성이 올라갑니다.

7) 근본 처방: “입력은 bytes, 경계에서만 decode” 원칙

대규모 시스템에서 인코딩 문제를 줄이는 가장 좋은 방법은 **텍스트 경계(boundary)**를 명확히 하는 것입니다.

  • 파일/네트워크/큐/DB에서 가져오는 값은 우선 bytes로 취급
  • 애플리케이션 내부 표준은 str(유니코드)
  • 경계에서만 decode(…, errors=정책)
  • 출력 경계에서만 encode()

예시: 파일을 안전하게 읽어서 내부 표준 UTF-8로 정규화 저장

from charset_normalizer import from_bytes

def normalize_to_utf8(src_path: str, dst_path: str) -> None:
    raw = open(src_path, "rb").read()

    best = from_bytes(raw).best()
    if best is None:
        # 완전 실패 시: 바이트를 강제로 살려서 기록
        text = raw.decode("utf-8", errors="backslashreplace")
    else:
        text = str(best)

    with open(dst_path, "w", encoding="utf-8", newline="\n") as f:
        f.write(text)

normalize_to_utf8("unknown.txt", "normalized.txt")

이렇게 “정규화 단계”를 한 번 두면, 이후 파이프라인은 UTF-8만 가정해도 되어 장애가 급감합니다.

8) 디버깅 체크리스트(현업용)

  1. 어디서 디코딩이 일어나는지 확인 (open(), requests.text, subprocess(text=True), pandas.read_csv)
  2. 문제 입력을 bytes로 확보 (rb, response.content, stdout)
  3. 깨지는 position 주변 바이트 덤프
  4. 추정 도구로 인코딩 후보 확인 (charset-normalizer)
  5. 임시 복구는 errors=backslashreplace/replace로 파이프라인을 살리고, 원인 데이터는 별도 보관
  6. 장기적으로는 “입력 정규화(UTF-8) + 경계에서만 decode” 구조로 리팩터링

운영 환경에서 이런 류의 문제는 “한 번 해결하면 끝”이 아니라, 비정형 입력이 늘어날수록 다시 발생합니다. 장애를 줄이려면 진단과 복구를 자동화하는 편이 좋습니다. 예를 들어 재시도/백오프처럼 실패를 전제로 설계하는 접근은 다른 영역에서도 유효합니다. 관련해서는 OpenAI API 429 Rate Limit 재시도·백오프 설계처럼 실패를 다루는 패턴을 참고해도 좋습니다.

9) 자주 쓰는 해결 조합 요약

  • 엑셀 CSV: encoding="utf-8-sig" 또는 cp949
  • 윈도우 생성 텍스트: cp949 의심
  • 크롤링 HTML: r.encoding = r.apparent_encoding 또는 bytes로 받아 직접 디코딩
  • 운영 임시 복구: errors="backslashreplace"로 손실 없이 로깅
  • 근본 해결: 입력 정규화 단계 추가(모든 데이터를 UTF-8로 변환 후 처리)

UTF-8은 사실상 표준이지만, 현실의 데이터는 표준을 잘 지키지 않습니다. UnicodeDecodeError는 그 현실이 드러나는 지점이고, 해결의 핵심은 “정확한 인코딩을 맞추는 것”과 “실패해도 시스템이 멈추지 않게 만드는 것”을 동시에 달성하는 데 있습니다.