Python UnicodeDecodeError 해결: 원본 보존과 UTF-8 변환

UnicodeDecodeError의 실패 바이트를 확인하고 원본을 보존하는 방법을 정리했습니다. surrogateescape의 한계, UTF-8 BOM, CP949 변환과 덮어쓰기 방지 코드를 함께 다룹니다.

UnicodeDecodeError가 나면 원본을 보관한 뒤 파일 생성 프로그램의 인코딩부터 살펴봅니다. 오류를 숨기는 것과 글자를 올바르게 읽는 것은 다릅니다. ignore나 replace로 저장하면 원래 바이트를 잃을 수 있습니다.

surrogateescape도 한글을 복구하는 자동 변환기가 아닙니다. 해석하지 못한 일부 바이트를 임시로 보존하는 방법입니다. 아래에서는 실패 위치 확인, 원본 바이트 왕복, 확인된 CP949 파일의 UTF-8 변환을 구분합니다.

이 글에서 바로 찾기 6개 항목

오류 메시지는 인코딩 정답을 알려주지 않습니다

'utf-8' codec can't decode byte ...는 해당 바이트를 UTF-8 규칙으로 읽지 못했다는 뜻입니다. CP949 파일일 수도 있지만, 다운로드가 끊겼거나 서로 다른 인코딩이 섞였거나 텍스트가 아닌 파일을 읽은 경우도 확인해야 합니다.

첫 점검 대상은 파일 확장자와 실제 형식, 다운로드 완료 여부, 내보내기 설정입니다. XLSX나 PDF를 텍스트 파일처럼 열었다면 인코딩 옵션이 아니라 그 형식을 읽는 도구가 필요합니다. PDF에서 복사한 글자만 깨진다면 PDF 글자 복사 오류의 원인과 해결 절차처럼 문서 구조 문제를 먼저 구분해야 합니다.

다음 예제는 Python 3.12 표준 라이브러리만 사용합니다. 작은 입력 전체를 메모리에 읽는 방식이므로 대용량 파일 처리와 실제 업무 데이터 복구를 검증한 예제는 아닙니다.

1. 원본 바이트와 실패 위치 확인

예제 입력 b'\x80abc'는 UTF-8로 해석할 수 없습니다. 실제 파일을 진단할 때는 첫 줄을 raw = Path("input.txt").read_bytes()로 바꾸고 from pathlib import Path를 추가합니다. 원본은 쓰기 모드로 열지 않습니다.

raw = b"\x80abc"
try:
    text = raw.decode("utf-8", errors="strict")
except UnicodeDecodeError as exc:
    print(exc.encoding, exc.start, exc.end, exc.reason)
    print("bad bytes:", raw[exc.start:exc.end].hex(" "))

출력은 utf-8 0 1 invalid start byte와 bad bytes: 80입니다. 시작 위치는 0부터 세고 끝 위치는 포함하지 않습니다. 이 예제는 파일 전체를 한 번에 디코딩하므로 위치를 전체 입력과 대조할 수 있습니다. 스트림을 여러 조각으로 읽는 코드에서는 별도 누적 오프셋이 필요합니다.

바이트 출력도 원문 정보입니다. 고객 정보나 인증값이 든 파일은 공개 게시판이나 외부 변환 사이트에 올리면 안 됩니다. 질문할 때는 직접 만든 최소 재현 입력을 사용합니다. 예외 속성의 의미는 Python UnicodeError 문서에 나와 있습니다.

광고

2. 읽을 인코딩은 생성 시스템에서 확인합니다

  • 내보내기 설정이 있는 경우: UTF-8, CP949 등 실제 선택값을 확인하고 그 값으로 strict 디코딩합니다. 한국어 파일이라는 이유만으로 CP949를 고르지 않습니다.
  • UTF-8 BOM이 있는 경우: 시작 바이트 ef bb bf와 생성 도구를 확인합니다. utf-8-sig는 파일 시작의 UTF-8 BOM을 제거하며, CP949나 손상된 UTF-8을 고쳐주는 옵션은 아닙니다.
  • 형식을 알 수 없는 경우: 인코딩 탐지기는 후보를 좁히는 보조 도구로만 사용합니다. 예외 없이 읽혔거나 점수가 높다는 이유만으로 원문이 맞다고 확정하지 않습니다.

표본은 한글 이름, 특수문자, 구분자를 포함해 파일의 처음·중간·끝에서 고릅니다. ASCII 문자만 있는 짧은 표본은 여러 인코딩에서 똑같이 보일 수 있습니다. 원문을 얻을 수 없는 손상 데이터는 변환 옵션만으로 복구 가능하다고 단정할 수 없습니다.

import codecs

raw = codecs.BOM_UTF8 + "이름,값\r\n".encode("utf-8")
plain = raw.decode("utf-8")
without_bom = raw.decode("utf-8-sig")
print(plain.startswith("\ufeff"))       # True
print(without_bom == "이름,값\r\n")    # True

인코딩을 명시하면 운영체제나 실행 모드의 기본값에 의존하지 않습니다. 기본 인코딩과 Unicode 변환의 차이는 Python Unicode HOWTO에 설명돼 있습니다.

3. surrogateescape는 바이트 보존이지 문자 복구가 아닙니다

UTF-8로 읽지 못한 0x80~0xFF 바이트는 U+DC80~U+DCFF의 서러게이트 코드 포인트로 옮겨집니다. 이것은 private-use 영역이라는 뜻이 아닙니다. 같은 인코딩과 오류 처리 방식으로, 내용을 바꾸지 않고 되돌려야 아래 왕복 검사가 성립합니다.

raw = b"\x80abc\r\n"
text = raw.decode("utf-8", errors="surrogateescape")
restored = text.encode("utf-8", errors="surrogateescape")

print(repr(text))
print(restored == raw)  # True
try:
    text.encode("utf-8", errors="strict")
except UnicodeEncodeError:
    print("not ordinary UTF-8 text")

repr(text)에는 '\udc80abc\r\n'가 표시됩니다. 다시 얻은 바이트에는 여전히 원래의 0x80이 있으므로 정상 UTF-8 파일로 변환된 것이 아닙니다. 이 문자열을 다른 라이브러리나 API에 그대로 전달하면 인코딩 오류 또는 잘못된 데이터 처리가 생길 수 있습니다.

text.replace('\udc80', '[0x80]')처럼 바꾸면 원래 한 바이트를 여러 글자로 교체한 것입니다. 이후 같은 오류 핸들러로 저장해도 원본과 같지 않습니다. 표시용 치환본은 별도 파생 파일로 취급해야 합니다. 자세한 설계는 PEP 383에 설명되어 있습니다.

또한 텍스트 모드의 기본 줄바꿈 처리는 \r\n을 \n으로 바꿀 수 있습니다. 바이트 단위 보존을 확인할 때는 위처럼 바이너리 읽기와 decode/encode를 사용해 줄바꿈 변환을 피합니다. surrogateescape만 지정했다고 파일 전체가 그대로 보존되는 것은 아닙니다.

광고

4. 확인된 인코딩을 UTF-8로 변환해 새 파일에 저장합니다

다음은 생성 시스템에서 CP949로 확인한 작은 텍스트 파일을 변환하는 예제입니다. 추정한 인코딩을 무조건 적용하지 마세요. convert_text.py로 실행할 코드는 아래와 같습니다.

from pathlib import Path
import sys

src = Path(sys.argv[1])
dst = Path(sys.argv[2])
raw = src.read_bytes()
text = raw.decode("cp949", errors="strict")
converted = text.encode("utf-8", errors="strict")

with dst.open("xb") as stream:
    stream.write(converted)

saved = dst.read_bytes()
if saved != converted or saved.decode("utf-8") != text:
    raise RuntimeError("Output verification failed")
print("UTF-8 output verified")

실행 예시는 python convert_text.py input-cp949.txt output-utf8.txt입니다. 출력 파일이 이미 있으면 FileExistsError로 멈추며 기존 파일을 덮어쓰지 않습니다. 디코딩에 실패하면 출력 파일을 만들기 전에 중단됩니다.

디스크 오류가 나면 새 출력 파일이 일부만 생길 수 있습니다. 실패한 결과는 사용하지 않고 원본을 보관해야 합니다. 이 예제는 배포 시스템의 원자적 교체나 장애 복구까지 구현한 코드가 아닙니다. xb와 줄바꿈 옵션은 open() 공식 문서에 정의되어 있습니다.

UTF-8로 변환하면 바이트 배열과 파일 해시는 보통 바뀝니다. 원본과 출력의 해시가 같아야 한다는 검사는 맞지 않습니다. 원본 보존 여부와 출력의 UTF-8 디코딩, 문자·행 수·필드 값은 따로 검사합니다. CSV의 따옴표와 구분자 문제는 인코딩 검증만으로 해결되지 않습니다.

오류를 없앴는데 글자가 사라졌다면

ignore는 해석하지 못한 부분을 버리고, replace는 디코딩 과정에서 대체 문자 U+FFFD를 넣습니다. 두 결과만 가지고 버리거나 바꾼 바이트를 일반적으로 알아낼 수는 없습니다. 원본 파일에서 다시 시작해야 합니다.

surrogatepass는 surrogateescape의 다른 이름이 아니며, UTF-8 한글 복구를 위한 대체 옵션도 아닙니다. 각 핸들러의 정의는 codecs 오류 처리 문서에서 구분할 수 있습니다.

오류가 반복된다면 읽기 코드뿐 아니라 내보내기 인코딩, 전송 중 손상 여부, 파일 버전과 수집 절차까지 점검할 필요가 있습니다. 원본 보존, 올바른 문자 해석, 형식 변환은 각각 검사해야 오류를 숨기지 않고 해결할 수 있습니다.

댓글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다