Published on

Python UnicodeDecodeError - utf-8 해결 7가지

Authors

서버 로그, 크롤러, 데이터 파이프라인을 운영하다 보면 어느 순간 아래 같은 에러가 튀어나옵니다.

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

핵심은 단순합니다. 바이트열이 실제로는 UTF-8이 아닌데, Python이 UTF-8로 해석(디코딩)하려고 해서 실패한 것입니다. 문제는 “어디서 UTF-8로 가정했는가”, “원본 바이트는 무엇이었는가”를 찾는 과정이 생각보다 복잡하다는 점입니다.

이 글에서는 실무에서 자주 맞닥뜨리는 케이스를 기준으로, 진단 루틴과 함께 7가지 해결 방법을 정리합니다. (가능한 한 “임시로 에러만 숨기기”가 아니라 “원인을 제거”하는 방향으로 설명합니다.)

참고로 운영 환경에서 이런 텍스트/인코딩 이슈는 종종 크래시 루프나 장애로 이어집니다. 장애 대응 관점은 Kubernetes CrashLoopBackOff 원인별 로그·Probe·리소스 디버깅 같은 글의 접근(재현, 로그, 원인 분리)과 유사합니다.

에러가 발생하는 대표 패턴

대부분 아래 중 하나입니다.

  1. 파일을 open() 했는데 파일 인코딩이 UTF-8이 아님
  2. subprocess 출력, OS 기본 인코딩, 터미널 인코딩이 UTF-8이 아님
  3. HTTP 응답이 헤더/바디와 다르게 인코딩되어 있음
  4. DB/메시지큐에서 꺼낸 값이 이미 깨진 바이트이거나 이중 인코딩됨
  5. bytesstr 경계에서 잘못된 가정으로 .decode('utf-8') 수행

아래 해결책들은 “어떤 입력 소스에서 왔는지”를 기준으로 적용하면 됩니다.

해결 1) 파일 open() 에서 인코딩을 명시한다

가장 흔한 원인입니다. Python 3의 open() 은 플랫폼/환경에 따라 기본 인코딩이 달라질 수 있고, 파일 자체가 UTF-8이 아닌 경우도 많습니다(특히 Windows에서 생성된 CSV, 레거시 시스템 로그 등).

증상 재현

with open("data.csv", "r") as f:
    text = f.read()  # 여기서 UnicodeDecodeError

해결

파일의 실제 인코딩이 cp949(EUC-KR 계열)라면 다음처럼 명시합니다.

with open("data.csv", "r", encoding="cp949") as f:
    text = f.read()

가능하면 errors 정책도 함께 정해두면 운영 안정성이 좋아집니다.

with open("data.csv", "r", encoding="cp949", errors="strict") as f:
    text = f.read()
  • strict: 기본값, 문제를 즉시 드러냄(개발/검증에 유리)
  • replace: 깨진 문자를 로 치환
  • ignore: 깨진 문자를 버림(데이터 손실 가능)

replaceignore 는 “서비스를 살리기 위한 임시 조치”로는 유효하지만, 데이터 품질을 망가뜨릴 수 있으니 원인 파악 후 제거하는 것을 권장합니다.

해결 2) 바이너리로 읽고, “어떤 바이트가 문제인지” 먼저 확인한다

인코딩을 모를 때 무작정 cp949, latin-1 등을 시도하면 운 좋게 읽히더라도 텍스트가 조용히 깨질 수 있습니다. 먼저 바이트를 확인해 “이 데이터가 정말 텍스트가 맞는지”, “중간에 바이너리 조각이 섞였는지”를 봐야 합니다.

path = "data.txt"

with open(path, "rb") as f:
    raw = f.read()

print(raw[:200])

문제가 발생한 위치 주변을 잘라 보는 것도 도움이 됩니다.

# 예: position 12345 부근을 확인
pos = 12345
start = max(0, pos - 40)
end = pos + 40

print(raw[start:end])

이 단계에서 “텍스트 파일인 줄 알았는데 사실 gzip이었음”, “BOM/제어문자/NULL 바이트가 섞임” 같은 원인을 빠르게 잡아낼 수 있습니다.

해결 3) UTF-8 BOM(시그니처) 이슈면 utf-8-sig 를 사용한다

Windows/일부 도구는 UTF-8 파일 앞에 BOM(Byte Order Mark)을 붙입니다. 이 경우 디코딩은 되지만, 문자열 첫 글자에 이상한 문자가 섞이거나(예: \ufeff) 파싱이 깨질 수 있습니다. 또는 어떤 파서가 BOM을 이상하게 처리하면서 디코딩 에러로 번지기도 합니다.

with open("bom.txt", "r", encoding="utf-8-sig") as f:
    text = f.read()

CSV/JSON의 첫 키가 \ufeffid 처럼 보인다면 거의 이 케이스입니다.

해결 4) 외부 프로세스 출력은 text=True + encoding= 을 명시한다

subprocess 는 OS/터미널 인코딩 영향을 강하게 받습니다. 특히 Windows에서 기본 코드페이지가 UTF-8이 아닐 때 흔히 터집니다.

문제 패턴

import subprocess

out = subprocess.check_output(["somecmd", "--help"])  # bytes
text = out.decode("utf-8")  # 여기서 실패 가능

해결

처음부터 텍스트 모드로 받고 인코딩을 지정하세요.

import subprocess

cp = subprocess.run(
    ["somecmd", "--help"],
    capture_output=True,
    text=True,
    encoding="cp949",  # 환경에 맞게
    errors="replace",
)

print(cp.stdout)
print(cp.stderr)

리눅스 서버에서도 컨테이너의 LANG, LC_ALL 설정이 비어 있거나 C 로 잡혀 있으면 예상과 다른 디코딩 문제가 생길 수 있습니다.

해결 5) HTTP 응답은 “추측 디코딩”을 피하고, 헤더/바이트 기준으로 처리한다

웹 크롤링이나 API 연동에서 자주 발생합니다. 서버가 Content-Typecharset 을 잘못 쓰거나, 실제 바디 인코딩과 불일치하는 경우입니다.

requests 사용 시 권장 패턴

import requests

resp = requests.get("https://example.com")
raw = resp.content  # bytes

# 서버가 charset을 제대로 주면 resp.encoding이 잡힐 수 있음
# 하지만 불일치가 잦다면 명시적으로 지정
resp.encoding = "utf-8"
text = resp.text

서버가 EUC-KR로 주는 페이지라면:

resp = requests.get("https://example.com")
resp.encoding = "euc-kr"
text = resp.text

더 안전한 방법은 바이트를 확보한 뒤, 헤더/메타태그/휴리스틱으로 인코딩을 결정하는 것입니다. 외부 라이브러리를 쓸 수 있다면 charset-normalizer(Python 3 환경에서 requests 가 내부적으로 활용하기도 함) 같은 도구를 고려하세요.

해결 6) “이미 str인데 decode” 또는 “bytes를 잘못 encode” 하는 경계 오류를 잡는다

실무에서 의외로 많습니다. 예를 들어 아래는 str.decode() 를 호출해서 나는 에러이지만, 주변 코드가 복잡하면 UnicodeDecodeError 와 섞여 보이기도 합니다. 그리고 더 흔한 건 이중 인코딩/디코딩입니다.

올바른 규칙

  • 네트워크/파일/프로세스 경계에서는 bytes
  • 애플리케이션 내부에서는 str(유니코드)
  • 경계에서만 encode/decode 를 수행

방어적 변환 함수 예시

def ensure_text(x, encoding="utf-8"):
    if isinstance(x, str):
        return x
    if isinstance(x, (bytes, bytearray, memoryview)):
        return bytes(x).decode(encoding, errors="strict")
    return str(x)

그리고 “DB에서 꺼낸 값이 이미 깨진 것인지”도 의심해야 합니다. 예를 들어 UTF-8 바이트를 latin-1 로 잘못 디코딩해 저장하면, 이후 어떤 인코딩을 써도 원복이 어려운 상태가 됩니다.

해결 7) 원본 인코딩을 확정하고, 파이프라인 전체를 UTF-8로 표준화한다

단발성 패치(예: errors="ignore")는 장애를 숨길 뿐, 데이터 품질과 검색/분석/머신러닝 단계에서 더 큰 비용을 만듭니다. 가장 좋은 해결은 입력 지점에서 인코딩을 확정하고, 내부 표준을 UTF-8로 통일하는 것입니다.

예: 레거시 CSV(cp949)를 UTF-8로 변환하여 저장

from pathlib import Path

src = Path("legacy_cp949.csv")
dst = Path("normalized_utf8.csv")

text = src.read_text(encoding="cp949", errors="strict")
dst.write_text(text, encoding="utf-8", newline="\n")

예: 로그/ETL에서 표준화 레이어 두기

def normalize_to_utf8_bytes(raw_bytes, source_encoding):
    text = raw_bytes.decode(source_encoding, errors="replace")
    return text.encode("utf-8")

운영 환경에서는 “어느 단계에서 어떤 인코딩을 기대하는지”를 문서화하고, 경계마다 테스트를 두는 게 중요합니다. 장애 대응 관점에서 재현과 원인 분리가 중요하다는 점은 bash set -euo pipefail로 스크립트 터질 때 대처법에서 말하는 접근과도 닮아 있습니다.

빠른 진단 체크리스트

아래 순서대로 보면 대부분 빠르게 해결됩니다.

  1. 에러가 난 코드가 파일/네트워크/프로세스/DB 중 어디 경계인지 확인
  2. 해당 입력을 rb 로 읽어 바이트를 확보
  3. “텍스트가 맞는지”부터 확인(압축/바이너리 혼입 여부)
  4. 파일이면 생성 주체(엑셀, 레거시 시스템, OS)를 기준으로 인코딩 후보를 좁힘
  5. HTTP면 Content-Type charset 과 실제 바디의 불일치 여부 확인
  6. 임시로 errors="replace" 로 서비스 안정화 후, 원본 인코딩 확정 및 UTF-8 표준화

자주 쓰는 최소 예제 모음

파일을 안전하게 읽기(인코딩 명시)

def read_text(path, encoding):
    with open(path, "r", encoding=encoding, errors="strict") as f:
        return f.read()

디코딩 실패 시 원본 바이트 일부 덤프

def debug_decode(raw, encoding="utf-8"):
    try:
        return raw.decode(encoding)
    except UnicodeDecodeError as e:
        start = max(0, e.start - 20)
        end = min(len(raw), e.end + 20)
        snippet = raw[start:end]
        raise RuntimeError(f"decode failed at {e.start}:{e.end}, bytes={snippet!r}") from e

CSV 처리 시 흔한 패턴

import csv

with open("data.csv", "r", encoding="cp949", newline="") as f:
    reader = csv.DictReader(f)
    rows = list(reader)

newline="" 는 CSV 처리에서 줄바꿈 이슈를 줄이는 데 도움이 됩니다.

마무리

UnicodeDecodeError: 'utf-8' 는 “UTF-8이 나쁘다”가 아니라 입력의 실제 인코딩과 코드의 가정이 어긋났다는 신호입니다. 가장 좋은 해결은 (1) 경계에서 바이트를 확보해 원인을 확인하고, (2) 입력 인코딩을 확정한 뒤, (3) 내부 표준을 UTF-8로 통일하는 것입니다.

운영 중이라면 우선 errors="replace" 로 장애를 멈추고, 이후 재현 케이스를 확보해 인코딩을 고정하는 순서로 진행하세요. 이 방식이 데이터 손실과 재발 가능성을 가장 크게 줄입니다.