Python 3.15 업그레이드 전, 한글 파일·외부 명령 인코딩 점검하기

Python 3.15로 올리기 전에는 한글 파일과 외부 명령의 입출력 인코딩부터 확인하는 편이 좋습니다. 기본값이 UTF-8로 바뀌어도 기존 CP949 파일의 바이트는 그대로입니다. 입력 형식에 맞는 인코딩을 명시하고, 변환 결과와 CSV 헤더까지 테스트에 넣으면 작은 팀에서도 변경 범위를 좁힐 수 있습니다.

Python 3.15.0은 2026년 10월 9일 정식 출시됐습니다. 이 글은 2026년 10월 13일 확인한 공식 문서를 바탕으로 변경 사항을 설명합니다. 공식 릴리스와 텍스트 인코딩 문서에서 기준을 확인할 수 있습니다.

검증 범위: 아래 예제는 Linux의 Python 3.12.14와 3.13.5에서 실행했습니다. 두 런타임의 로캘 인코딩은 UTF-8이며, 각각 UTF-8 모드를 끈 경우와 켠 경우를 확인했습니다. Python 3.15와 Windows에서는 직접 실행하지 않았습니다. 예제는 인코딩을 명시한 코드의 동작을 확인하는 사전 점검용이며, 실제 업그레이드 성공을 입증하지는 않습니다.

무엇이 달라지고, 무엇을 먼저 확인할까?

3.15는 UTF-8 모드를 기본으로 활성화합니다. 인코딩을 생략한 텍스트 파일 읽기처럼 기본값에 기대던 코드가 영향을 받을 수 있습니다. 이미 UTF-8 로캘에서 실행하던 프로그램은 차이를 못 느낄 수도 있습니다. 따라서 개발용 Linux에서 통과했다는 사실만으로 다른 운영 환경도 같다고 판단하면 안 됩니다.

  • 파일 생산자: 다운로드한 CSV가 UTF-8인지, BOM이 붙은 UTF-8인지, CP949인지 확인합니다. 확장자만으로 구분할 수 없습니다.
  • 파일 소비자: 우리 프로그램이 만든 파일을 다른 프로그램이 어떤 인코딩으로 읽는지 확인합니다. 읽기만 고치고 쓰기 계약을 놓치기 쉽습니다.
  • 외부 명령: 표준출력이 어떤 바이트를 내보내는지 별도로 확인합니다. Python의 디코딩 옵션이 외부 프로그램의 출력 형식을 바꾸지는 않습니다.

한국어 Windows라고 언제나 CP949인 것은 아닙니다. 또한 encoding="locale"은 현재 환경의 로캘 인코딩을 따르는 선택이지 CP949의 다른 이름이 아닙니다. 공급자가 CP949라고 명시한 파일이라면 그 계약을 코드에 직접 적는 쪽이 명확합니다. PEP 686의 호환성 설명도 함께 참고하세요.

1. 파일·변환·CSV 헤더·외부 명령을 한 번에 검사하기

다음 코드를 probe.py로 저장합니다. 표준 라이브러리만 사용하며 추가 패키지는 필요 없습니다. 이 점검 도구들은 3.15에서 새로 도입된 기능이 아니며, 기존 런타임에서 인코딩 의존성을 정리하기 위한 방법입니다. 임시 디렉터리에 직접 만든 샘플을 사용하므로 실제 업무 파일을 덮어쓰지 않습니다. CP949 바이트를 UTF-8로 읽는 실패는 이 샘플에서 의도한 결과입니다.

import csv
import io
import locale
import subprocess
import sys
from pathlib import Path
from tempfile import TemporaryDirectory

text = 'name\n가\n'
with TemporaryDirectory() as directory:
    root = Path(directory)
    legacy = root / 'legacy.csv'
    legacy.write_bytes(text.encode('cp949'))

    try:
        legacy.read_text(encoding='utf-8')
    except UnicodeDecodeError:
        print('cp949_as_utf8: rejected')
    else:
        raise AssertionError('Unexpected decode success')

    decoded = legacy.read_text(encoding='cp949')
    assert decoded == text
    converted = root / 'converted.csv'
    converted.write_text(decoded, encoding='utf-8', newline='')
    assert converted.read_bytes() == text.encode('utf-8')
    print('explicit_conversion: exact bytes')

    bom = text.encode('utf-8-sig')
    header = next(csv.reader(io.StringIO(bom.decode('utf-8'))))
    assert header == ['\ufeffname']
    clean = next(csv.reader(io.StringIO(bom.decode('utf-8-sig'))))
    assert clean == ['name']
    print('utf8_bom: header checked')

    child = "import sys; sys.stdout.buffer.write('가'.encode('cp949'))"
    command = [sys.executable, '-c', child]
    raw = subprocess.run(command, capture_output=True, check=True)
    assert raw.stdout.decode('cp949') == '가'
    result = subprocess.run(
        command, capture_output=True, check=True, encoding='cp949'
    )
    assert result.stdout == '가'
    print('subprocess: raw and text agree')

print('runtime:', sys.version.split()[0])
print('locale:', locale.getencoding())
print('utf8_mode:', sys.flags.utf8_mode)

실행은 새 프로세스에서 합니다. python이 가리키는 런타임을 확인한 뒤 같은 파일을 다음 두 모드로 실행하세요.

python -X utf8=0 -X warn_default_encoding probe.py
python -X utf8=1 -X warn_default_encoding probe.py

예상 결과: 앞의 네 검사 메시지가 출력되고 예외 없이 끝나야 합니다. 마지막 세 줄은 실제 런타임과 로캘, UTF-8 모드를 보여줍니다. 이 환경에서 Python 3.12.14의 첫 번째 명령은 다음과 같이 출력됐습니다.

cp949_as_utf8: rejected
explicit_conversion: exact bytes
utf8_bom: header checked
subprocess: raw and text agree
runtime: 3.12.14
locale: UTF-8
utf8_mode: 0

실제 확인 결과: Python 3.12.14와 3.13.5에서 모드 0·1 조합 네 개가 모두 통과했습니다. 모드 1에서는 마지막 값이 1이며, 3.13.5에서는 런타임 값도 해당 버전으로 바뀝니다. 두 환경 모두 로캘 자체가 UTF-8이므로 이 결과는 Windows의 기본 인코딩 전환을 재현한 것이 아닙니다.

검사가 잡아내는 네 가지 경계

  1. 잘못된 디코더: 이 CP949 샘플을 UTF-8로 해석하면 UnicodeDecodeError가 납니다. 모든 CP949 파일이 반드시 실패하는 것은 아닙니다. ASCII만 있는 파일 등은 차이가 드러나지 않을 수 있으므로 한글을 포함한 대표 샘플이 필요합니다.
  2. 변환 결과: 문자열을 읽을 수 있는지만 확인하지 않고 출력 파일의 바이트가 의도한 UTF-8인지 비교합니다. 실제 변환 작업에서는 원본을 보존한 별도 파일로 내보내고 소비 프로그램에서도 검증하세요.
  3. BOM과 헤더: BOM이 있는 UTF-8 CSV를 일반 utf-8로 읽으면 첫 필드에 \ufeff가 남습니다. 화면에서는 비슷해 보여도 name이라는 키와 다릅니다. 입력 계약이 UTF-8 BOM 형식이면 utf-8-sig로 이를 처리할 수 있습니다.
  4. 자식 프로세스: 예제의 자식은 CP949 바이트를 의도적으로 출력합니다. 부모는 바이트를 받은 뒤 직접 해석하거나, 확인한 인코딩을 subprocess.run에 전달합니다. 실제 도구에서는 정상 출력뿐 아니라 오류 출력의 형식도 확인해야 합니다.

subprocess 공식 문서에 따르면 인코딩을 지정하면 텍스트 모드로 처리됩니다. 출력 인코딩을 알 수 없는 도구는 원시 바이트를 보존하고 생산자 설정부터 조사하는 것이 안전합니다. errors="ignore"로 통과시키면 데이터 손실을 놓칠 수 있습니다.

2. 인코딩 생략 지점을 경고로 찾기

직접 작성한 코드뿐 아니라 실제로 호출하는 코드 경로에서 기본 인코딩 사용을 찾으려면 -X warn_default_encoding을 활용할 수 있습니다. 아래 코드를 warning_probe.py로 저장하면, 의도적으로 인코딩을 생략한 읽기가 경고를 내는지 먼저 확인할 수 있습니다.

import warnings
from pathlib import Path
from tempfile import TemporaryDirectory

with TemporaryDirectory() as directory:
    path = Path(directory) / 'input.txt'
    path.write_bytes(b'hello')
    with warnings.catch_warnings(record=True) as caught:
        warnings.simplefilter('always', EncodingWarning)
        with path.open() as stream:
            assert stream.read() == 'hello'
    assert any(isinstance(w.message, EncodingWarning) for w in caught)
    print('implicit_open_warning: detected')
python -X utf8=0 -X warn_default_encoding warning_probe.py
python -X utf8=1 -X warn_default_encoding warning_probe.py

이 예제는 경고를 수집해 검사하므로 화면에 경고 전문 대신 implicit_open_warning: detected를 출력합니다. 두 런타임의 두 모드에서 이 메시지를 확인했습니다. 경고 플래그 없이 실행하면 이 검사의 전제가 성립하지 않습니다.

실제 프로젝트에도 같은 경고 옵션을 적용하고 CSV 가져오기, 보고서 내보내기, 외부 도구 오류 처리 경로를 실행해 보세요. 경고가 없다는 결과는 실행한 경로에 대한 정보입니다. 전체 코드나 모든 입력의 호환성을 보증하지 않습니다. EncodingWarning 사용법에서 적용 범위를 확인할 수 있습니다.

작은 팀의 업그레이드·되돌리기 체크리스트

  • 운영 중인 Python 버전, 로캘 인코딩, UTF-8 모드와 파일 생산자의 형식을 기록합니다.
  • UTF-8·CP949·BOM 포함 파일 중 실제로 받는 형식의 샘플을 보관하고, 정상 데이터와 기대한 오류를 모두 테스트합니다.
  • 인코딩을 명시한 뒤 개발 환경에서 두 모드를 점검합니다. 이어 별도 검증 환경의 Python 3.15와 실제 운영 OS·로캘에서 업무 흐름을 다시 실행합니다.
  • 문제가 생겼을 때 되돌릴 기존 런타임과 배포 설정, 원본 파일을 보존합니다. PYTHONUTF8=0 또는 -X utf8=0은 새 프로세스 시작 시 적용하는 임시 호환성 수단입니다. 인코딩 계약 수정이나 실제 복구 시험을 대신하지 않습니다.
  • 이미 잘못 변환해 저장한 파일은 UTF-8 모드를 끈다고 복구되지 않습니다. 원본에서 다시 변환하고 결과를 확인해야 합니다.

핵심은 버전 번호만 바꾸기 전에 입력과 출력의 형식을 명확히 만드는 것입니다. 위 예제의 네 검사를 실제 서비스의 작은 회귀 테스트로 옮기면, 이후 런타임이나 외부 도구를 바꿀 때도 같은 경계를 다시 확인할 수 있습니다.

댓글