Following system colour scheme Selected dark colour scheme Selected light colour scheme

Python 개선 제안 한국어 번역

PEP 538 – 레거시 C 로케일을 UTF-8 기반 로케일로 강제 변환하기

Author:
Alyssa Coghlan <ncoghlan at gmail.com>
BDFL-Delegate:
INADA Naoki
Status:
Final
Type:
Standards Track
Created:
28-Dec-2016
Python-Version:
3.7
Post-History:
03-Jan-2017, 07-Jan-2017, 05-Mar-2017, 09-May-2017
Resolution:
Python-Dev message

Table of Contents

번역·라이선스 안내

이 비공식 한국어 번역은 원문 Copyright 절의 Public Domain or CC0-1.0, whichever is more permissive 조건에 따라 제공합니다. 원저자와 공식 원문은 그대로 표시합니다. 수정되지 않은 기준 원문 · 공식 최신판

초록

*nix 시스템에서 Python 3가 계속 겪고 있는 과제는 두 가지 사이의 충돌입니다. 하나는 같은 프로세스나 하위 프로세스에 있는 다른 로케일 인식 컴포넌트와 일관성을 유지하려면 설정된 로케일 인코딩을 기본으로 사용해야 한다는 점입니다. 다른 하나는 (POSIX:2001에 정의된) 표준 C 로케일이 일반적으로 ASCII를 기본 텍스트 인코딩으로 삼는다는 사실입니다. ASCII는 다국어 세계에서 네트워크 서비스와 클라이언트 애플리케이션을 개발하기에는 전혀 부적합합니다.

PEP 540은 CPython의 레거시 C 로케일 처리 방식을 변경할 것을 제안합니다. 운영 체제 인터페이스와 통신하기에 적절한 인코딩이 ASCII라는, 문제가 있음이 명백히 드러난 가정을 고수하지 않고, 그러한 환경에서 CPython이 UTF-8을 사용한다고 가정하도록 하는 것입니다. 이는 로컬 인코딩 상호 운용성보다 네트워크 인코딩 상호 운용성이 더 중요한 경우에 좋은 접근 방식입니다.

그러나 이 방식을 따르면 CPython의 인코딩 가정이 같은 프로세스에 있는 다른 로케일 인식 컴포넌트의 가정과 달라지고, 같은 환경을 공유하는 하위 프로세스에서 실행되는 컴포넌트의 가정과도 달라진다는 대가를 치러야 합니다.

이로 인해 일부 확장 모듈(GNU readline의 명령줄 히스토리 편집 등)은 물론, 하위 프로세스에서 실행되는 컴포넌트(이전 버전의 Python 런타임 등)와도 상호 운용성 문제가 발생할 수 있습니다.

또한 Python 3.7 이전 버전에서 지원되는 기존 구성 설정에 주로 의존하는 것이 아니라, CPython 자체의 내부 동작 방식을 상당히 변경해야 합니다.

따라서 이 PEP는 PEP 540에서 제안된 UTF-8 모드와는 별개로, CPython 구현이 기본 C 로케일을 처리하는 방식을 (Python 3.1부터 지원되는) 다음의 기존 구성 설정과 대략 동등하도록 변경할 것을 제안합니다:

LC_CTYPE=C.UTF-8
PYTHONIOENCODING=utf-8:surrogateescape

강제 변환의 정확한 대상 로케일은 런타임에 실제로 사용 가능한 로케일을 바탕으로 미리 정의된 목록에서 선택됩니다.

재해석된 로케일 설정은 환경에 다시 기록됩니다. 따라서 같은 프로세스의 다른 구성 요소와 하위 프로세스에서도 이 설정을 볼 수 있습니다. 다만 변경된 PYTHONIOENCODING 기본값은 암묵적으로 적용합니다. surrogateescape 오류 핸들러를 제공하지 않는 Python 2 하위 프로세스와 호환성 문제가 생기지 않도록 하기 위해서입니다.

새로운 레거시 로케일 강제 변환 동작은 LC_ALL을 설정하거나(이 경우에도 유니코드 호환성 경고가 발생할 수 있습니다) 새로운 PYTHONCOERCECLOCALE 환경 변수를 0으로 설정하여 비활성화할 수 있습니다.

이 변경으로 인해, 표준 구성의 일부로 C.UTF-8, C.utf8, UTF-8 로케일 중 적어도 하나를 제공하지 않는 *nix 플랫폼은 기본 C 로케일이 아닌 적절한 로케일(예: en_AU.UTF-8, zh_CN.gb18030)이 명시적으로 설정된 경우에만 CPython 3.7+ 배포에서 완전히 지원되는 플랫폼으로 간주됩니다. 이 PEP와 함께 PEP 540도 수락된다면 제안된 PYTHONUTF8 모드를 사용할 때 순수 Python 모듈도 지원됩니다. 그러나 확장 모듈에서 완전한 유니코드 호환성을 기대할 수 있는 범위는 계속해서 이 PEP가 다루는 플랫폼으로 제한됩니다.

이는 근본적으로 새로운 기능이 아니라 기본 설정의 변경만을 반영합니다. 따라서 업스트림 CPython 개발 팀보다 대상 사용자층이 좁은 재배포자(Linux 배포판 등)는 필요한 변경 사항을 다운스트림 패치로 적용하여 Python 3.6.x 시리즈에서도 이 로케일 강제 변환 동작을 선택적으로 사용할 수 있습니다.

구현 참고 사항

원래 수락된 대로 PEP를 구현하려고 시도해 보니, 로케일 강제 변환 경고와 호환성 경고를 기본적으로 출력하자는 제안은 전혀 실용적이지 않다는 것이 드러났습니다(이전에 잘 동작하던 코드가 실패하는 경우가 너무 많았는데, 그 원인은 해당 코드에 잠재된 로케일 처리 결함이 아니라 경고 자체였습니다).

그 결과 PY_WARN_ON_C_LOCALE 구성 플래그가 제거되고, 런타임 PYTHONCOERCECLOCALE=warn 환경 변수 설정으로 대체되었습니다. 이 설정을 통해 개발자와 시스템 통합자는 로케일 강제 변환 경고와 호환성 경고를 받도록 선택할 수 있으며, 기본적으로는 이러한 경고가 출력되지 않습니다.

PEP 자체의 출력 예제도 경고를 제거하여 읽기 쉽도록 갱신되었습니다.

배경

CPython 인터프리터는 시작되는 동안, 전체 시스템의 로케일 설정과 일관된 방식으로 char * 형식에서 wchar_t * 형식으로, 또는 이 두 형식 중 하나에서 PyUnicodeObject *로 변환해야 할 수 있습니다. CPython은 운영 체제에 변환을 맡긴 다음, sys.getfilesystemencoding()이 보고하는 텍스트 인코딩 이름이 이 초기 부트스트래핑 과정에서 사용된 인코딩과 일치하도록 보장하는 방식으로 이러한 경우를 처리합니다.

Windows에서는 이러한 변환에 기본적으로 사용되는 mbcs 형식의 한계가 상당히 문제가 되는 것으로 드러났습니다. 그래서 바이너리 데이터 처리를 위해 운영 체제가 제공하는 인터페이스를 우회하고 대신 UTF-8을 강제로 사용하도록 PEP 528과 PEP 529가 구현되었습니다.

Mac OS X, iOS, Android에서는 CPython을 포함한 많은 컴포넌트가 로케일 설정과 관계없이 이미 UTF-8을 시스템 인코딩으로 가정합니다. 그러나 모든 컴포넌트가 그런 것은 아니며, 이러한 불일치는 일부 상황에서 문제를 일으킬 수 있습니다(예를 들어 GNU readline 모듈을 사용할 때 [16]).

Apple과 Android가 아닌 *nix 시스템에서는 이러한 작업이 glibc의 C 로케일 시스템을 사용하여 처리되며, 이 시스템에는 다음과 같은 특성이 있습니다 [4]:

  • 기본적으로 모든 프로세스는 C 로케일에서 시작하며, 이 로케일은 이러한 변환에 ASCII를 사용합니다. 다국어 텍스트를 처리하는 쪽(CPython과 C/C++ GUI 프레임워크 포함)에서 실제로 원하는 동작은 거의 언제나 이것이 아닙니다.
  • setlocale(LC_ALL, "")을 호출하면 현재 프로세스 환경에 설정된 로케일 범주를 바탕으로 활성 로케일이 재구성됩니다
  • 현재 환경이 요청한 로케일을 알 수 없거나 특정 로케일이 설정되어 있지 않으면 기본 C 로케일이 계속 활성 상태로 유지됩니다

CPython이 의존하는 API를 다루는 특정 로케일 범주는 LC_CTYPE이며, 이 범주는 “문자의 분류와 변환, 그리고 멀티바이트 문자와 와이드 문자”에 적용됩니다 [5]. 따라서 CPython에는 setlocale에 대한 다음과 같은 핵심 호출이 포함되어 있습니다:

  • 메인 python 바이너리에서 CPython은 setlocale(LC_ALL, "")를 호출하여 프로세스 환경에 따라 C 로케일 하위 시스템 전체를 구성합니다. 이 호출은 공유 CPython 라이브러리를 처음 호출하기 전에 이루어집니다.
  • Py_Initialize에서 CPython은 setlocale(LC_CTYPE, "")를 호출합니다. 따라서 해당 범주에 구성된 로케일 설정은 환경에 설정된 값과 항상 일치합니다. 이 호출은 조건 없이 이루어지며, CPython은 Py_Finalize에서 이 프로세스 상태 변경을 되돌리지 않습니다.

(로케일 처리에 대한 이 요약에서는 로케일 설정의 일부로 선언된 텍스트 인코딩이 정확히 어디에서 언제 사용되는지에 관한 여러 기술적 세부 사항을 생략했습니다. 자세한 논의는 PEP 540을 참조하십시오. 이러한 세부 사항은 로케일을 UTF-8 기반 로케일로 재정의할 때보다 CPython을 선언된 C 로케일에서 분리할 때 더 중요합니다.)

보통은 이러한 호출만으로도 합리적으로 동작하지만, 다음과 같은 경우에는 여전히 실패할 수 있습니다:

  • SSH 환경 전달 때문에 SSH 클라이언트가 클라이언트의 로케일 설정을 해당 로케일이 설치되지 않은 서버로 전달하는 경우가 있습니다. 이 경우 CPython은 기본 ASCII 기반 C 로케일에서 실행됩니다.
  • 일부 프로세스 환경(예: Linux 컨테이너)에는 명시적으로 구성된 로케일이 전혀 없을 수 있습니다. 알 수 없는 로케일의 경우와 마찬가지로, 이 경우에도 CPython은 기본 ASCII 기반 C 로케일에서 실행됩니다.
  • Android에서는 환경 변수를 바탕으로 로케일을 구성하지 않고, 빈 로케일 ""를 "C" 로케일을 명시적으로 요청한 것으로 처리합니다.

현재 릴리스된 CPython 버전에서 이 문제를 해결하는 가장 간단한 방법은 애플리케이션을 실행할 때 더 적절한 로케일을 명시적으로 설정하는 것입니다. 예를 들면 다음과 같습니다.:

LC_CTYPE=C.UTF-8 python3 ...

C.UTF-8 로케일은 LC_CTYPE 범주에는 UTF-8을 사용하고, 나머지 모든 범주(LC_COLLATE 포함)에는 C 로케일과 같은 설정을 사용하는 완전한 로케일 정의입니다. 여러 Linux 배포판(Debian, Ubuntu, Fedora, Alpine, Android 포함)이 ASCII 기반 C 로케일의 대안으로 이 로케일을 제공합니다. 일부 다른 플랫폼(예: HP-UX)은 이와 동등한 로케일 정의를 C.utf8이라는 이름으로 제공합니다.

Mac OS X와 기타 *BSD 시스템은 다른 방식을 택했습니다. 이들 시스템은 C.UTF-8 로케일 대신 LC_CTYPE 범주만 정의하는 부분적인 UTF-8 로케일을 제공합니다. 이러한 시스템에서는 LC_ALL이나 LANG을 설정하기보다 LC_CTYPE=UTF-8을 설정하여 환경의 로케일을 조정하는 방식이 권장됩니다. [17]

Docker 컨테이너나 이와 유사한 기술을 사용하는 경우에는 컨테이너 이미지 정의에서 적절한 로케일 설정을 직접 지정할 수 있습니다.

또 다른 흔한 실패 사례는 개발자가 원래라면 번역되어 표시될 사용자 인터페이스 메시지를 영어로 보려고, 범위가 더 좁은 LC_MESSAGES=C나 LANGUAGE=en 대신 LANG=C를 지정하는 경우입니다.

다른 PEP와의 관계

이 PEP는 PEP 540과 같은 문제(기본 C 로케일에서 Python 3의 동작 개선)를 다루지만, 제안하는 해결책은 크게 다릅니다:

  • PEP 540은 이 경우 CPython의 기본 텍스트 인코딩을 C 로케일 시스템과 완전히 분리하자고 제안합니다. 그러면 CPython과, 같은 프로세스나 하위 프로세스에서 실행되는 다른 로케일 인식 구성 요소 사이에 텍스트 처리 불일치가 생길 수 있습니다. 이 접근 방식은 CPython이 로케일 인식 애플리케이션처럼 동작하는 부분을 줄이고, Go, Node.js(V8), Rust 등의 로케일 독립적인 언어 런타임처럼 동작하도록 하는 것을 목표로 합니다.
  • 이 PEP는 레거시 C 로케일을, UTF-8을 기본 텍스트 인코딩으로 사용하는 더 최근에 정의된 로케일로 재정의하자고 제안합니다. 즉, 텍스트 인코딩 재정의는 CPython뿐 아니라 현재 프로세스에 로드된 모든 로케일 인식 확장 모듈에도 적용되며, 부모 프로세스로부터 환경을 상속받는 하위 프로세스에서 실행되는 로케일 인식 애플리케이션에도 적용됩니다. 이 접근 방식은 다른 로케일 인식 구성 요소와의 통합을 강력하게 지원해 온 CPython의 전통을 유지하는 동시에, 더 넓은 C/C++ 생태계에서 C.UTF-8 로케일이 레거시 C 로케일을 대체하는 유니코드 인식 로케일로 채택되고 표준화되도록 적극적으로 돕는 것을 목표로 합니다.

두 PEP를 검토한 결과, 둘은 기술적으로 실제로 충돌하지 않는다는 점이 분명해졌습니다. 또한 PEP 540의 제안은 적절한 로케일을 사용할 수 없는 경우에 더 나은 선택지를 제공했으며, “로케일 인코딩”이라는 개념이 의미가 없는 플랫폼(예: CPython 참조 인터프리터 대신 MicroPython을 실행하는 임베디드 시스템)에서도 더 나은 참조 동작을 제공했습니다.

한편, 이 PEP는 다른 로케일 인식 구성 요소와의 호환성을 개선했으며, 다운스트림 재배포자가 Python 3.6에 백포트하기에도 더 적합한 접근 방식을 제공했습니다.

그 결과, 이 PEP는 표준 UTF-8 기반 로케일을 하나도 사용할 수 없을 때 개선된 동작을 제공하는 보완적 해법으로 PEP 540을 참조하도록 수정되었으며, 기본 설정의 변경을 현재 독립적으로 설정할 수 없는 API(예를 들어 open() 함수의 기본 인코딩과 오류 핸들러)로까지 확장하였습니다.

또한 PEP 540을 사용할 수 있게 됨에 따라, 강제 변환 대상으로 시도하는 UTF-8 로케일 목록에서 레거시 대체 로케일인 LC_CTYPE=en_US.UTF-8이 제거되었습니다. 그러한 경우 CPython은 대신 제안된 PYTHONUTF8 모드에만 의존할 것으로 예상됩니다.

동기

Docker, Kubernetes, OpenShift 같은 Linux 컨테이너 기술은 웹 서비스 개발에 쓰이는 것으로 가장 잘 알려져 있지만, 관련 컨테이너 형식과 실행 모델은 Linux 명령줄 애플리케이션 개발에도 채택되고 있습니다. Gnome Flatpak [7] 및 Ubuntu Snappy [8] 같은 기술은 한 걸음 더 나아가 이와 같은 기법을 Linux GUI 애플리케이션 개발에도 도입하는 것을 목표로 합니다.

이러한 환경에서 Python 3로 애플리케이션을 개발하다 보면 다음과 같은 텍스트 인코딩 관련 오류를 드물지 않게 볼 수 있습니다:

$ docker run --rm fedora:25 python3 -c 'print("ℙƴ☂ℌøἤ")'
Unable to decode the command from the command line:
UnicodeEncodeError: 'utf-8' codec can't encode character '\udce2' in position 7: surrogates not allowed
$ docker run --rm ncoghlan/debian-python python3 -c 'print("ℙƴ☂ℌøἤ")'
Unable to decode the command from the command line:
UnicodeEncodeError: 'utf-8' codec can't encode character '\udce2' in position 7: surrogates not allowed

같은 명령을 로컬에서 실행하면 대개 문제없이 동작하는데도 그렇습니다:

$ python3 -c 'print("ℙƴ☂ℌøἤ")'
ℙƴ☂ℌøἤ

대신 세 환경에서 locale 명령을 실행해 보면 문제의 원인을 알 수 있습니다:

$ locale | grep -E 'LC_ALL|LC_CTYPE|LANG'
LANG=en_AU.UTF-8
LC_CTYPE="en_AU.UTF-8"
LC_ALL=
$ docker run --rm fedora:25 locale | grep -E 'LC_ALL|LC_CTYPE|LANG'
LANG=
LC_CTYPE="POSIX"
LC_ALL=
$ docker run --rm ncoghlan/debian-python locale | grep -E 'LC_ALL|LC_CTYPE|LANG'
LANG=
LANGUAGE=
LC_CTYPE="POSIX"
LC_ALL=

이 예에서는 호스트 시스템 로케일이 “en_AU.UTF-8”로 설정되어 있으므로 CPython이 기본 텍스트 인코딩으로 UTF-8을 사용한다는 것을 알 수 있습니다. 반면 Fedora와 Debian의 베이스 Docker 이미지에는 특정 로케일이 설정되어 있지 않으므로 기본적으로 POSIX 로케일을 사용하며, 이는 ASCII 기반의 기본 C 로케일의 별칭입니다.

Fedora 및 Debian 기반 컨테이너에서 Python 3가 (정확한 버전과 관계없이) 합리적으로 동작하게 하는 가장 간단한 방법은 두 배포판 모두가 제공하는 C.UTF-8 로케일에서 실행하는 것입니다:

$ docker run --rm -e LC_CTYPE=C.UTF-8 fedora:25 python3 -c 'print("ℙƴ☂ℌøἤ")'
ℙƴ☂ℌøἤ
$ docker run --rm -e LC_CTYPE=C.UTF-8 ncoghlan/debian-python python3 -c 'print("ℙƴ☂ℌøἤ")'
ℙƴ☂ℌøἤ

$ docker run --rm -e LC_CTYPE=C.UTF-8 fedora:25 locale | grep -E 'LC_ALL|LC_CTYPE|LANG'
LANG=
LC_CTYPE=C.UTF-8
LC_ALL=
$ docker run --rm -e LC_CTYPE=C.UTF-8 ncoghlan/debian-python locale | grep -E 'LC_ALL|LC_CTYPE|LANG'
LANG=
LANGUAGE=
LC_CTYPE=C.UTF-8
LC_ALL=

Docker, Inc.가 제공하는 Alpine Linux 기반 Python 이미지는 이미 기본적으로 C.UTF-8 로케일을 사용합니다:

$ docker run --rm python:3 python3 -c 'print("ℙƴ☂ℌøἤ")'
ℙƴ☂ℌøἤ
$ docker run --rm python:3 locale | grep -E 'LC_ALL|LC_CTYPE|LANG'
LANG=C.UTF-8
LANGUAGE=
LC_CTYPE="C.UTF-8"
LC_ALL=

마찬가지로 사용자 정의 컨테이너 이미지(즉, 베이스 배포판 이미지 위에 추가 내용을 더한 이미지)의 경우 이미지 정의에서 더 적합한 로케일을 설정하여 기본 상태에서 모든 것이 그대로 동작하도록 할 수 있습니다. 하지만 재배포자나 최종 사용자가 시스템 설정을 변경하여 이 문제를 처리하는 데 의존하기보다 CPython이 이 문제를 자동으로 처리할 수 있다면 훨씬 더 좋고 일관된 사용자 경험을 제공할 수 있을 것입니다.

glibc 개발자들이 CPython 같은 glibc 기반 애플리케이션이 C.UTF-8 로케일을 어디서나 사용할 수 있도록 작업하고 있지만 [6], 안타깝게도 이는 해당 기능이 없는 구버전 glibc를 탑재하면서 Debian이나 Fedora처럼 C.UTF-8(또는 그에 상응하는 로케일)을 디스크상의 로케일로 제공하지도 않는 플랫폼에서는 도움이 되지 않습니다. 이러한 플랫폼은 이 PEP의 범위를 벗어나는 것으로 간주합니다. 그러한 환경에서 CPython의 기본 동작을 개선하기 위한 가능한 선택지에 대한 추가 논의는 PEP 540을 참조하십시오.

설계 원칙

위의 동기로부터 제안하는 해결책에 대한 다음과 같은 핵심 설계 원칙이 도출됩니다:

  • 기본 C 로케일 이외의 로케일이 명시적으로 설정되어 있으면 계속해서 그 설정을 존중합니다
  • 가능한 한, 모든 변경 사항은 기존 설정 옵션을 사용합니다
  • 잠재적인 강제 변환 대상 로케일에서 Python의 런타임 동작은 해당 로케일이 환경에서 명시적으로 설정되었는지 또는 로케일 강제 변환 대상으로서 암묵적으로 설정되었는지와 관계없이 동일해야 합니다
  • Python 3.7에서는 명시적인 설정 옵션 없이 로케일 설정을 변경하는 경우, 프로세스 설정을 조용히 변경하는 대신 그렇게 하고 있다는 경고를 stderr에 출력합니다. 이를 통해 애플리케이션 및 시스템 통합자는 PEP 절차나 Python 릴리스 공지를 자세히 따라가지 않더라도 이 변경 사항을 알 수 있습니다. 하지만 최종 사용자에게 새로운 문제가 생길 가능성을 최소화하기 위해 이 경고는 warnings 시스템을 사용하지 않고 출력하므로, -Werror로 실행하더라도 런타임 예외로 바뀌지 않습니다. (참고: 이 경고는 결국 기본적으로 표시되지 않게 되었습니다. 자세한 내용은 위의 구현 참고 사항을 참조하십시오)
  • Python 3.7에서는 변경된 모든 기본값에 대해 빌드 시점이나 런타임, 또는 둘 다에서 어떤 형태로든 명시적인 “끄기” 스위치를 제공합니다

현재 GB-18030 또는 부분적으로 ASCII와 호환되는 다른 범용 인코딩을 사용하도록 올바르게 설정된 시스템에 미치는 부정적인 영향을 최소화하려는 목표로부터 다음과 같은 설계 원칙이 도출됩니다:

  • UTF-8 기반 Linux 컨테이너가 UTF-8이 아닌 인코딩을 사용하도록 명시적으로 설정된 호스트에서 실행되고, 명시적으로 UTF-8로 인코딩된 데이터 대신 로컬 인코딩된 데이터를 해당 호스트와 주고받으려 하는 경우, CPython은 공통 ASCII 호환 코드 포인트에서만 연결되거나 분할된 호스트 제공 데이터는 올바르게 왕복 변환하도록 노력하지만, 그 밖의 경우에는 무의미한 결과를 내보낼 수 있습니다.

그 밖에는 레거시 C 로케일에서 실행되면서 LC_TIME, LC_MONETARY 또는 LC_NUMERIC 같은 명시적인 로케일 범주를 사용하도록 올바르게 설정된 시스템과 프로그램에 미치는 부정적인 영향을 최소화하려는 목표로부터 다음과 같은 설계 원칙이 도출됩니다:

  • LC_CTYPE 이외의 로케일 범주에 대한 기존 설정을 바꾸는 환경 변경은 하지 마십시오(특히 LC_ALL 또는 LANG을 설정하지 마십시오)

마지막으로, 오케스트레이션 사용 사례에서 임의의 서브프로세스를 실행할 때의 호환성을 유지하려는 목표로부터 다음과 같은 설계 원칙이 도출됩니다:

  • 아직 지원되는 CPython 버전(CPython 2.7 포함) 중 어느 것과도 호환되지 않을 수 있는 Python 고유의 환경 변경은 하지 마십시오

명세

달리 처리하지 않으면 CPython이 결국 C 로케일에서 동작하려고 하게 되는 경우를 더 잘 처리하기 위해, 이 PEP는 CPython이 독립 실행형 명령줄 애플리케이션으로 실행될 때 LC_CTYPE 범주에 대해 레거시 C 로케일을 UTF-8 기반 로케일로 자동으로 강제 변환하도록 시도할 것을 제안합니다.

또한 언어 런타임 자체가 초기화되는 시점에 LC_CTYPE 범주에 레거시 C 로케일이 적용되어 있고 로케일 강제 변환을 비활성화하는 명시적인 환경 플래그가 설정되어 있지 않으면, 시스템 및 애플리케이션 통합자에게 CPython을 지원되지 않는 설정에서 실행하고 있음을 알리기 위해 stderr에 경고를 출력할 것을 제안합니다.

이러한 일반적인 변경 외에도, Android 플랫폼에서 setlocale의 동작 차이를 처리하기 위한 몇 가지 Android 전용 변경을 추가로 제안합니다.

독립 실행형 Python 인터프리터 바이너리에서의 레거시 C 로케일 강제 변환

독립 실행형 애플리케이션으로 실행될 때 CPython은 프로세스에서 로케일에 의존하는 작업이 실행되기 전에 C 로케일을 재설정할 기회가 있습니다.

즉, CPython 런타임의 로케일 설정만 변경할 수 있는 것이 아닙니다. 현재 프로세스에서 실행되는 다른 로케일 인식 구성 요소(예: 확장 모듈의 일부)의 로케일 설정도 변경할 수 있습니다. 현재 프로세스로부터 환경을 상속받는 하위 프로세스의 로케일 설정도 마찬가지입니다.

현재 프로세스의 로케일 설정을 초기화하기 위해 setlocale(LC_ALL, "")를 호출한 후, 다음 호출을 포함하도록 메인 인터프리터 바이너리를 수정합니다:

const char *ctype_loc = setlocale(LC_CTYPE, NULL);

이 난해한 호출은 현재 로케일 설정을 변경하지 않고 조회하기 위해 C가 제공하는 API입니다. 이 조회 결과를 이용하면 strcmp로 정확히 C 로케일인지 검사할 수 있습니다:

ctype_loc != NULL && strcmp(ctype_loc, "C") == 0 # true only in the C locale

이 호출은 특정 로케일이 설정되지 않았거나 명목상의 로케일이 C 로케일의 별칭(예: POSIX)으로 설정된 경우에도 "C"를 반환합니다.

이 정보를 바탕으로 CPython은 로케일을 ASCII 대신 UTF-8을 기본 인코딩으로 사용하는 로케일로 강제 변환하려고 시도할 수 있습니다.

다음 세 가지 로캘을 시도합니다:

  • C.UTF-8 (최소한 Debian, Ubuntu, Alpine, Fedora 25 이상에서 사용할 수 있으며, 향후 glibc 버전에서 기본으로 제공될 것으로 예상됩니다)
  • C.utf8 (최소한 HP-UX에서 사용할 수 있습니다)
  • UTF-8 (Mac OS X를 포함한 적어도 일부 *BSD 변형에서 사용할 수 있습니다)

강제 변환은 LC_CTYPE 환경 변수를 후보 로케일 이름으로 설정하는 방식으로 구현합니다. 그러면 이후의 setlocale() 호출이 이 값을 보게 됩니다. 해당 설정을 찾는 다른 구성 요소(예: GUI 개발 프레임워크나 Python 자체의 locale 모듈)도 마찬가지입니다.

크로스 플랫폼 바이너리 이식성을 높이고 향후 로케일 가용성 변화에 자동으로 대응하기 위해, 이러한 검사는 Windows를 제외한 모든 플랫폼에서 런타임에 수행합니다. 어떤 로케일을 시도할지를 컴파일 시점에 결정하려 하지 않습니다.

이 로케일 강제 변환이 활성화되면 다음 경고가 stderr에 출력되며, 경고에는 성공적으로 설정된 로케일이 포함됩니다:

Python detected LC_CTYPE=C: LC_CTYPE coerced to C.UTF-8 (set another
locale or PYTHONCOERCECLOCALE=0 to disable this locale coercion behaviour).

(참고: 이 경고는 결국 기본적으로 표시되지 않도록 변경되었습니다. 자세한 내용은 위의 구현 참고 사항을 참조하십시오)

현재 플랫폼이 후보 UTF-8 기반 환경을 하나 이상 제공하는 한, 이 로케일 강제 변환 덕분에 표준 Python 바이너리 및 로케일 인식 확장은 우리가 알고 있는 세 가지 주요 실패 사례에서 다시 “그냥 동작”하게 됩니다. 세 가지 사례는 로케일 설정 누락, LANG이나 LC_CTYPE을 통한 알 수 없는 로케일의 SSH 전달, 개발자의 명시적 LANG=C 요청입니다.

실패가 여전히 발생할 수 있는 유일한 경우는 stderr에 출력이 없는지를 특별히 검사하는 경우입니다. 이 경우는 C 로케일 이외의 로케일을 설정하여 해결할 수 있습니다. 또는 “stderr에 출력이 없었음” 이외의 메커니즘(예: 프로세스 반환 코드 검사)으로 하위 프로세스 오류를 검사하여 해결할 수도 있습니다.

후보 로케일 중 어느 것도 성공적으로 설정되지 않았거나 현재 프로세스 환경에 LC_ALL, 로케일 재정의가 정의되어 있으면, C 로케일에서 초기화를 계속합니다. 이때 다음 절에서 설명하는 유니코드 호환성 경고가 다른 애플리케이션과 마찬가지로 출력됩니다.

PYTHONCOERCECLOCALE=0이 명시적으로 설정되어 있으면 C 로케일에서 초기화를 계속하며, 다음 절에서 설명하는 유니코드 호환성 경고는 자동으로 억제됩니다.

로케일 강제 변환 검사는 반드시 명령줄 인자 처리 전에 이루어집니다. 따라서 인터프리터는 시작 시 항상 PYTHONCOERCECLOCALE 환경 변수를 검사합니다(-E나 -I 스위치로 실행할 때도 마찬가지입니다). 일관성을 위해, 로케일 호환성 경고를 억제할지 여부를 결정하는 런타임 검사도 마찬가지로 이러한 설정과 무관하게 동작합니다.

런타임 초기화 중 레거시 C 로케일 경고

Py_Initialize가 호출될 즈음에는 현재 프로세스에서 임의의 로케일 의존 작업이 이미 수행되었을 수 있습니다. 즉, 호출 시점에는 다른 로케일로 안정적으로 전환하기에 너무 늦습니다. 그렇게 하면 독립 실행형 Python 인터프리터 바이너리 맥락에서조차 디코딩된 텍스트에 비일관성이 생깁니다.

따라서 Py_Initialize가 호출될 때 다음 두 조건을 CPython이 감지하면 다음 경고를 출력합니다. 설정된 로케일이 여전히 기본 C 로케일이고, PYTHONCOERCECLOCALE=0이 설정되지 않은 경우입니다:

Python runtime initialized with LC_CTYPE=C (a locale with default ASCII
encoding), which may cause Unicode compatibility problems. Using C.UTF-8,
C.utf8, or UTF-8 (if available) as alternative Unicode-compatible
locales is recommended.

(참고: 이 경고는 결국 기본적으로 표시되지 않도록 변경되었습니다. 자세한 내용은 위의 구현 참고 사항을 참조하십시오)

이 경우 로케일 설정은 실제로 변경되지 않습니다.

대신 이 경고는 시스템 통합자와 애플리케이션 통합자 모두에게 올바르게 동작하지 않을 것으로 예상되는 구성에서 Python 3를 실행하고 있음을 알려 줍니다.

권장 사항을 제공하는 두 번째 문장은 나중에 운영 체제에 따라 조건부로 컴파일될 수도 있지만(예: *BSD 시스템에서는 LC_CTYPE=UTF-8을 권장), 초기 구현에서는 위에 표시된 공통의 일반 메시지만 사용합니다.

새로운 빌드 시점 구성 옵션

위의 두 동작은 모두 기본으로 활성화됩니다. 다만 이 기본 설정을 재정의하려는 재배포자를 위해 관련 구성 옵션과 전처리기 정의도 새로 제공됩니다.

로케일 강제 변환 동작은 --with[out]-c-locale-coercion 플래그로 제어되며, 이 플래그는 PY_COERCE_C_LOCALE 전처리기 정의를 설정합니다.

로케일 경고 동작은 --with[out]-c-locale-warning 플래그로 제어되며, 이 플래그는 PY_WARN_ON_C_LOCALE 전처리기 정의를 설정합니다.

(참고: 이 컴파일 시점 경고 옵션은 결국 런타임 PYTHONCOERCECLOCALE=warn 옵션으로 대체되었습니다. 자세한 내용은 위의 구현 참고 사항을 참조하십시오)

autotools 기반 빌드 시스템을 사용하지 않는 플랫폼(즉, Windows)에서는 이러한 전처리기 변수가 항상 정의되지 않습니다.

표준 스트림의 기본 오류 처리 변경

Python 3.5부터 CPython은 현재 로케일이 C이고 PYTHONIOENCODING 환경 변수나 Py_setStandardStreamEncoding API로 특정 오류 처리기가 설정되지 않았음을 감지하면 표준 스트림(sys.stdin, sys.stdout)에 기본으로 surrogateescape를 사용해 왔습니다. 다른 로케일의 경우 표준 스트림의 기본 오류 핸들러는 strict입니다.

이 동작을 유지하려면 로케일 강제 변환과 명시적 로케일 설정 사이에 동작 차이가 생기지 않아야 합니다. 이를 위해 강제 변환 대상 로케일(C.UTF-8, C.utf8, UTF-8)을 목록에 추가합니다. 이 목록은 표준 스트림의 기본 오류 핸들러로 surrogateescape를 사용하는 로케일 목록입니다.

sys.stderr의 기본 오류 핸들러는 변경하지 않으며, 계속 backslashreplace로 유지됩니다.

Android의 로케일 설정 변경

이 PEP의 다른 변경 사항과는 별개로, Android 시스템의 CPython은 현재 setlocale(LC_ALL, "")를 호출하는 곳에서 setlocale(LC_ALL, "C.UTF-8")를 호출하고, 현재 setlocale(LC_CTYPE, "")를 호출하는 곳에서 setlocale(LC_CTYPE, "C.UTF-8")를 호출하도록 변경됩니다.

이 Android 전용 동작은 다음과 같은 Android 고유의 특성 때문에 도입됩니다:

  • Android에서는 setlocale에 ""를 전달하는 것이 "C"를 전달하는 것과 같습니다
  • C.UTF-8 로케일은 항상 사용할 수 있습니다

플랫폼 지원 변경

PEP 11에 다음 내용을 담은 “레거시 C 로케일” 섹션이 새로 추가됩니다:

  • CPython 3.7부터 *nix 플랫폼은 레거시 C 로케일의 대안으로 C.UTF-8 (전체 로케일), C.utf8 (전체 로케일), UTF-8 (LC_CTYPE 전용 로케일) 중 적어도 하나를 제공해야 합니다. 레거시 C 로케일에서만 발생하고 적절히 구성된 비 ASCII 로케일에서는 재현되지 않는 유니코드 관련 통합 문제는 모두 “수정하지 않음(won’t fix)”으로 종료됩니다.

근거

C 로케일 처리 개선

C 로케일의 기본 인코딩인 ASCII가 현대적인 네트워크 서비스 개발에는 완전히 잘못된 선택이라는 점은 이미 한동안 분명했습니다. Rust와 Go 같은 최신 언어는 이 기본값을 아예 배제했습니다. 그 대신 운영 체제 인터페이스의 텍스트 인코딩으로 UTF-8을 사용하도록 시스템을 구성하는 것을 배포 요구 사항으로 삼았습니다. 마찬가지로 Node.js는 기본적으로 UTF-8을 가정합니다(V8 JavaScript 엔진에서 물려받은 동작). 로케일 인식 작업에 시스템 로케일 설정을 사용하게 하려면 사용자 정의 빌드 설정이 필요합니다. JVM과 .NET CLR은 모두 애플리케이션과 애플리케이션 런타임(즉, 호스트 운영 체제가 아니라 JVM/CLR) 사이에서 텍스트를 전달하는 주 인코딩으로 UTF-16-LE를 사용합니다.

CPython의 어려움은 네트워크 서비스 개발뿐만 아니라 더 큰 애플리케이션에 내장되는 스크립팅 언어와 데스크톱 애플리케이션 개발 언어로도 널리 사용된다는 점이었습니다. 이러한 용도에서는 현대 네트워크 서비스 개발에서 새로 생겨나는 관례를 따르는 것보다 두 가지 일관성이 더 중요합니다. 하나는 같은 프로세스를 공유하는 다른 로케일 인식 컴포넌트와의 일관성이고, 다른 하나는 사용자의 데스크톱 로케일 설정과의 일관성입니다.

이 PEP의 핵심 전제는 이러한 모든 사용 사례에서 기본 “C” 로케일이 암시하는 ASCII 가정이 잘못된 선택이라는 것입니다. 더 나아가 다음 가정들이 타당하다는 것도 이 PEP의 전제입니다:

  • 데스크톱 애플리케이션 사용 사례에서는 프로세스 로케일이 이미 적절히 구성되어 있을 것입니다. 그렇지 않다면 이는 운영 체제나 임베딩 애플리케이션 수준의 문제이므로 운영 체제 제공자나 애플리케이션 개발자에게 보고하여 해결해야 합니다
  • 네트워크 서비스 개발 사용 사례(특히 Linux 컨테이너 기반인 경우)에서는 프로세스 로케일이 전혀 구성되어 있지 않을 수 있습니다. 그런 경우 컴포넌트가 현재 CPython처럼 레거시 C 기본 인코딩인 ASCII를 신뢰하기보다는 Rust, Go, Node.js처럼 자체 기본 인코딩을 적용할 것으로 기대합니다

표준 IO 스트림에서 “surrogateescape” 오류 처리를 기본값으로 사용

이 PEP는 레거시 C 기본값과 그 기본값이 전제하는 ASCII 선호 텍스트 인코딩에서 벗어나도록 로케일을 강제 변환합니다. 그 결과 Python 3.5에서 도입된 표준 IO 스트림의 “surrogateescape” 오류 핸들러 암묵적 사용([15])도 비활성화됩니다. PEP 540이 제안한 UTF-8 모드에서 동작할 때의 surrogateescape 자동 사용도 마찬가지로 비활성화됩니다.

이 동작을 조정하기 위해 또 다른 설정 옵션을 도입하는 대신, 이 PEP는 stdin과 stderr 오류 처리의 “surrogateescape” 기본값을 세 가지 잠재적 강제 변환 대상 로케일에도 적용하도록 확장할 것을 제안합니다.

이 동작의 목적은 Python 3 애플리케이션이 텍스트가 UTF-8로 인코딩되었다고 잘못 가정하더라도, 운영 체제가 제공하는 텍스트 값이 대개 해당 애플리케이션을 투명하게 통과할 수 있도록 보장하려는 것입니다.

특히 GB 18030 [12]은 모든 유니코드 코드 포인트를 처리하는 중국 국가 텍스트 인코딩 표준으로, 형식상 ASCII 및 UTF-8 모두와 호환되지 않지만, 그럼에도 서로게이트 이스케이프된 데이터로 처리되는 것을 대체로 견뎌 냅니다. GB 18030이 ASCII 바이트 값을 호환되지 않는 방식으로 재사용하는 지점은 UTF-8에서 유효하지 않을 가능성이 높으므로, 이스케이프되어 해당 ASCII 코드 포인트를 기준으로 분할하거나 검색하는 문자열 처리 연산에는 불투명하게 됩니다. 특정 ASCII 또는 유니코드 코드 포인트 값을 기준으로 분할하거나 검색하는 작업이 포함되지 않은 연산은 거의 확실히 올바르게 동작합니다.

마찬가지로 Shift-JIS [13]와 ISO-2022-JP [14]는 일본에서 여전히 널리 사용되고 있으며 ASCII 및 UTF-8 모두와 호환되지 않지만, 특정 ASCII 또는 유니코드 코드 포인트 값을 기준으로 분할하거나 검색하는 작업이 포함되지 않은 텍스트 처리 연산은 견뎌 냅니다.

예로 두 파일을 생각해 봅시다. 하나는 UTF-8(en_AU.UTF-8의 기본 인코딩)로, 다른 하나는 GB-18030(zh_CN.gb18030의 기본 인코딩)으로 인코딩된 파일입니다:

$ python3 -c 'open("utf8.txt", "wb").write("ℙƴ☂ℌøἤ\n".encode("utf-8"))'
$ python3 -c 'open("gb18030.txt", "wb").write("ℙƴ☂ℌøἤ\n".encode("gb18030"))'

디스크상에서 보면 이 둘은 매우 다른 파일임을 알 수 있습니다:

$ python3 -c 'print("UTF-8:  ", open("utf8.txt", "rb").read().strip()); \
              print("GB18030:", open("gb18030.txt", "rb").read().strip())'
UTF-8:   b'\xe2\x84\x99\xc6\xb4\xe2\x98\x82\xe2\x84\x8c\xc3\xb8\xe1\xbc\xa4\n'
GB18030: b'\x816\xbd6\x810\x9d0\x817\xa29\x816\xbc4\x810\x8b3\x816\x8d6\n'

그럼에도 출력하기 전에 디코딩하기만 하면 둘 다 터미널에 올바르게 렌더링될 수 있습니다:

$ python3 -c 'print("UTF-8:  ", open("utf8.txt", "r", encoding="utf-8").read().strip()); \
              print("GB18030:", open("gb18030.txt", "r", encoding="gb18030").read().strip())'
UTF-8:   ℙƴ☂ℌøἤ
GB18030: ℙƴ☂ℌøἤ

반면, cat과 유사한 C/C++ 유틸리티들이 흔히 그러듯 원시 바이트를 그대로 전달하기만 하면 다음과 같습니다:

$ LANG=en_AU.UTF-8 cat utf8.txt gb18030.txt
ℙƴ☂ℌøἤ
�6�6�0�0�7�9�6�4�0�3�6�6

특별히 중국어 로케일을 설정하더라도 GB-18030으로 인코딩된 파일을 올바르게 렌더링하는 데에는 도움이 되지 않습니다:

$ LANG=zh_CN.gb18030 cat utf8.txt gb18030.txt
ℙƴ☂ℌøἤ
�6�6�0�0�7�9�6�4�0�3�6�6

문제는 명목상의 로케일과 관계없이 터미널 인코딩 설정이 여전히 UTF-8이라는 점입니다. iconv 유틸리티를 사용하여 GB18030 터미널을 에뮬레이트할 수 있습니다:

$ cat utf8.txt gb18030.txt | iconv -f GB18030 -t UTF-8
鈩櫰粹槀鈩屆羔激
ℙƴ☂ℌøἤ

이렇게 하면 문제가 뒤바뀌어, GB18030 파일은 올바르게 렌더링되지만 UTF-8 파일은 “Python”을 비ASCII 문자로 표현한 기대된 결과 대신 관련 없는 한자로 변환됩니다.

에뮬레이트된 GB18030 터미널 인코딩에서 Python이 UTF-8을 가정하면 두 파일 모두 잘못 표시됩니다:

$ python3 -c 'print("UTF-8:  ", open("utf8.txt", "r", encoding="utf-8").read().strip()); \
              print("GB18030:", open("gb18030.txt", "r", encoding="gb18030").read().strip())' \
  | iconv -f GB18030 -t UTF-8
UTF-8:   鈩櫰粹槀鈩屆羔激
GB18030: 鈩櫰粹槀鈩屆羔激

그러나 로케일을 올바르게 설정하면 에뮬레이트된 GB18030 터미널은 이제 두 파일을 모두 원래 의도대로 표시합니다:

$ LANG=zh_CN.gb18030 \
  python3 -c 'print("UTF-8:  ", open("utf8.txt", "r", encoding="utf-8").read().strip()); \
              print("GB18030:", open("gb18030.txt", "r", encoding="gb18030").read().strip())' \
  | iconv -f GB18030 -t UTF-8
UTF-8:   ℙƴ☂ℌøἤ
GB18030: ℙƴ☂ℌøἤ

surrogateescape를 기본 IO 인코딩으로 유지하는 근거는 C 로케일에서의 다음과 같은 유용한 동작을 보존하기 때문입니다:

$ cat gb18030.txt \
  | LANG=C python3 -c "import sys; print(sys.stdin.read())" \
  | iconv -f GB18030 -t UTF-8
ℙƴ☂ℌøἤ

현재 UTF-8 기반 로케일이 명시적으로 설정되었을 때 발생하는 예외로 되돌아가지 않습니다:

$ cat gb18030.txt \
  | python3 -c "import sys; print(sys.stdin.read())" \
  | iconv -f GB18030 -t UTF-8
Traceback (most recent call last):
File "<string>", line 1, in <module>
File "/usr/lib64/python3.5/codecs.py", line 321, in decode
    (result, consumed) = self._buffer_decode(data, self.errors, final)
UnicodeDecodeError: 'utf-8' codec can't decode byte 0x81 in position 0: invalid start byte

추가적인 이점으로, 강제 변환 대상 로케일 중 하나를 사용하도록 명시적으로 설정된 환경은 현재 C 로케일에서 기본적으로 활성화된 인코딩 투명성 동작을 암묵적으로 얻게 됩니다.

UTF-8 로케일 강제 변환 중 PYTHONIOENCODING 설정 피하기

이 PEP의 초기 버전은 인터프리터 초기화 중에 표준 스트림의 기본 처리를 변경하는 대신, PYTHONIOENCODING을 utf-8:surrogateescape로 설정할 것을 제안했습니다. 이는 심각한 호환성 문제를 일으키는 것으로 드러났습니다. surrogateescape 핸들러는 Python 3.1 이상에만 존재하므로, 하위 프로세스에서 Python 2.7 프로세스를 실행하면 해당 설정 때문에 혼란스러운 방식으로 실패할 수 있습니다.

현재 설계에서는 이전 Python 버전이 표준 스트림에서 기본 strict 오류 처리를 그대로 유지하는 반면, Python 3.7+는 이러한 로케일이 (로케일 강제 변환을 거쳐 도달한 것이 아니라) 명시적으로 설정된 경우에도 일관되게 더 관대한 surrogateescape 핸들러를 사용합니다.

레거시 C 로케일에서 ASCII 기반 텍스트 처리에 대한 공식 지원 중단

우리는 지금까지 10년 넘게 레거시 C 로케일에서 엄격한 바이트/텍스트 분리가 안정적으로 동작하도록 노력해 왔습니다. 우리도 성공하지 못했을 뿐 아니라 다른 누구도 성공하지 못했습니다. 찾아낸 유일하게 실행 가능한 대안은 바이트를 텍스트로 성급하게 디코딩하지 않고 그대로 전달하거나(C/C++, Python 2.x, Ruby 등), 명목상의 C/C++ 로케일 인코딩을 대체로 무시하고 UTF-8(PEP 540, Rust, Go, Node.js 등) 또는 UTF-16-LE(JVM, .NET CLR)의 사용을 가정하는 것이었습니다.

이 PEP는 정말로 그럴 필요가 있는 개발자가 (LC_ALL=C, PYTHONCOERCECLOCALE=0을 설정하거나 --without-c-locale-coercion을 설정한 커스텀 빌드를 실행하여) 여전히 레거시 C 로케일에서 Python 코드를 실행하도록 선택할 수 있게 보장하지만, 동시에 그러한 구성에서 Python 3의 유니코드 처리가 완전히 신뢰할 수 있을 것으로 기대하지 않는다는 점을 분명히 하며, 권장되는 대안은 (가능하다면 PEP 540의 UTF-8 모드와 함께) 더 적절한 로케일 설정을 사용하는 것입니다.

독립 실행 시에만 암묵적 로케일 강제 변환 제공

이 PEP에서 제안하는 설계의 주요 단점은 CPython 런타임이 독립 실행형 애플리케이션으로 실행될 때와 더 큰 시스템 내부의 임베디드 구성 요소로 실행될 때(예: Apache httpd 내부에서 실행되는 mod_wsgi)의 동작 사이에 잠재적인 불일치가 생긴다는 것입니다.

Python 3.x 개발 과정에서 Python 인터프리터가 초기화되는 시점에 잘못된 로케일 설정의 처리를 개선하려는 여러 시도가 있었습니다. 드러난 문제는 이 시점이 인터프리터 시작 과정에서 결국 너무 늦다는 것입니다. 명령줄 인자나 환경 변수의 내용과 같은 데이터는 Py_Initialize가 호출되기 훨씬 전에 이미 운영 체제에서 조회되어 잘못된 ASCII 텍스트 인코딩 가정하에 처리되었을 수 있습니다.

그 결과 이러한 불일치로 인해 생기는 문제는, ASCII가 운영 체제 인터페이스에 사용하기에 적합한 인코딩이라는 운영 체제의 주장을 그대로 믿어서 생기는 문제보다 진단하고 디버깅하기가 훨씬 더 어려웠습니다. 이는 CPython을 스크립팅 엔진으로 내장하는 더 큰 C/C++ 애플리케이션은 말할 것도 없고, 기본 CPython 바이너리에서조차 마찬가지였습니다.

이 PEP에서 제안하는 접근 방식은 독립 실행 시 로케일 강제 변환을 인터프리터 시작 순서에서 가능한 한 이른 시점으로 옮겨 이 문제를 해결합니다. 로케일 강제 변환은 C 수준의 main() 함수에서 직접 이루어지며, 이는 CPython 인터프리터 CLI의 기능을 구현하는 Py_Main() 라이브러리 함수를 호출하기도 전입니다.

그러면 Py_Initialize API는 C 로케일의 사용을 감지했을 때 (stderr에 출력되는) 명시적인 경고만 추가로 내보내고, 더 합리적인 설정을 지정하는 일은 내장 애플리케이션에 맡깁니다.

다만 이 PEP의 참조 구현은 기능의 대부분을 공유 라이브러리에 추가하며, CLI는 두 개의 새로운 비공개 API를 무조건 호출하도록 갱신됩니다.:

if (_Py_LegacyLocaleDetected()) {
    _Py_CoerceLegacyLocale();
}

이들은 내장 애플리케이션을 위한 다른 “사전 구성” API와 비슷합니다. 즉, Py_Initialize 이전에 호출되도록 설계되었으며, 따라서 인터프리터가 초기화되는 방식을 바꿉니다.

이들이 (이 PEP의 일부로든 후속 RFE에서든) 공개된다면, 다른 내장 애플리케이션도 CPython CLI에 제안된 것과 같은 동작을 손쉽게 재현할 수 있을 것입니다.

레거시 동작 복원 허용

CPython 명령줄 인터프리터는 CPython을 내장하는 다른 애플리케이션에서 발생하는 결함을 조사하는 데 자주 사용되며, 그러한 애플리케이션은 이 PEP가 구현된 이후에도 여전히 C 로케일을 사용하고 있을 수 있습니다.

로케일 강제 변환 동작을 간단히 켜고 끌 수 있는 스위치를 제공하면, 디버깅 목적으로 그러한 애플리케이션의 동작을 재현하기가 훨씬 쉬워질 뿐 아니라, 이 변경이 적용된 버전을 실행하는 경우에도 이전 3.x 런타임의 동작을 재현하기가 더 쉬워집니다.

C 로케일 감지를 위한 LC_CTYPE 조회

LC_CTYPE는 CPython이 환경 변수, 명령줄 인자, 그리고 운영 체제로부터 받는 기타 텍스트 값의 암묵적 디코딩을 수행할 때 실제로 의존하는 로케일 범주입니다.

따라서 현재 로케일 구성이 유니코드 처리 문제를 일으킬 가능성이 있는지 판단하려 할 때 이 범주를 특별히 확인하는 것이 합리적입니다.

UTF-8 로케일 강제 변환을 위한 LC_CTYPE 명시적 설정

Python은 현재 프로세스에서는 C/C++ ABI와 호환되는 다른 구성 요소를, 하위 프로세스에서는 임의의 언어로 작성된 구성 요소를 통합하는 글루 언어로 자주 사용됩니다.

LC_CTYPE를 C.UTF-8로 설정하는 것은 UTF-8 로케일이 정의되어 있지 않은 시스템에 LC_CTYPE=UTF-8 같은 설정이 제공되어 문제가 발생한 경우(예: Mac OS X ssh 클라이언트가 로케일 설정을 전달하도록 구성되어 있고, 사용자가 Linux 서버에 로그인하는 경우)를 처리하는 데 중요합니다.

이것으로 로케일 강제 변환이 활성화되었을 때 UTF-8 기반 로케일로의 전환이 현재 프로세스와 현재 환경을 상속하는 모든 하위 프로세스 전반에 일관되게 적용되도록 보장하기에 충분할 것입니다.

UTF-8 로케일 강제 변환 시 LANG 설정 회피

이 PEP의 이전 버전에서는 LC_CTYPE 설정에 더해, 범주와 무관한 기본 로케일인 LANG을 설정하는 것을 제안했습니다.

이는 나중에 제거되었는데, LC_CTYPE만 설정해도 이 PEP가 해결하려던 모든 문제 시나리오를 처리하기에 충분한 반면, LANG까지 설정하면 LANG이 올바르게 설정되어 있고 로케일 문제가 오로지 잘못된 LC_CTYPE 설정 때문에 발생한 경우를 망가뜨리기 때문입니다([22]).

예를 들어, 날짜 서식을 직접 처리하는 대신 하위 프로세스에서 Linux date 유틸리티를 호출하는 Python 애플리케이션을 생각해 보십시오.:

$ LANG=ja_JP.UTF-8 LC_CTYPE=C date
2017年  5月 23日 火曜日 17:31:03 JST

$ LANG=ja_JP.UTF-8 LC_CTYPE=C.UTF-8 date  # Coercing only LC_CTYPE
2017年  5月 23日 火曜日 17:32:58 JST

$ LANG=C.UTF-8 LC_CTYPE=C.UTF-8 date  # Coercing both of LC_CTYPE and LANG
Tue May 23 17:31:10 JST 2017

Python 프로세스에서 LC_CTYPE만 갱신하면 하위 프로세스는 계속 예상대로 동작할 것입니다. 그러나 LANG까지 갱신하면 사실상 LC_TIME 설정을 덮어쓰게 되어 잘못된 날짜 서식 규칙을 사용하게 됩니다.

UTF-8 로케일 강제 변환 시 LC_ALL 설정 회피

이 PEP의 이전 버전에서는 LC_CTYPE 설정에 더해 LC_ALL 로케일 재정의를 설정하는 것을 제안했습니다.

이는 LC_CTYPE와 LANG만 설정해도 이 PEP가 다루려는 모든 시나리오를 처리하기에 충분하다고 판단된 후에 변경되었는데, 그렇게 하면 다음과 같은 경우에 아무런 문제도 일으키지 않기 때문입니다.:

$ LANG=C LC_MONETARY=ja_JP.utf8 ./python -c \
  "from locale import setlocale, LC_ALL, currency; setlocale(LC_ALL, ''); print(currency(1e6))"
¥1000000

현재 환경에 LC_ALL이 설정되어 있으면 로케일 강제 변환 건너뛰기

이제 로케일 강제 변환은 LC_CTYPE와 LANG만 설정하므로, LC_ALL도 설정되어 있으면 아무런 효과가 없습니다. 그런 경우에 불필요한 로케일 강제 변환 알림을 내보내지 않도록, 대신 강제 변환을 완전히 건너뜁니다.

“UTF-8 모드”와 별개로 로케일 강제 변환 고려

이 PEP의 로케일 강제 변환과 PEP 540의 UTF-8 모드가 모두 Python 3.7을 위해 검토되고 있으므로, 두 가지 변경을 모두 적용하는 대신 둘 중 하나만 적용하는 것으로 한정할 수 있는지 묻는 것은 타당합니다.

PEP 540에서 제안된 UTF-8 모드에는 두 가지 주요 한계가 있으므로, 이 PEP를 대체할 수 있는 방안이라기보다는 보완할 수 있는 방안입니다.

첫째, 이 PEP와 달리 PEP 540의 UTF-8 모드를 사용하면 현재 전혀 설정할 수 없는 기본 동작을 변경할 수 있습니다. 바로 그 점이 이 제안을 흥미롭게 만들지만, 동시에 이 제안을 전혀 검증되지 않은 접근 방식으로 만들기도 합니다. 반면, 이 PEP에서 제안하는 접근 방식은 C 로케일 시스템(LC_CTYPE, LANG)과 Python 표준 스트림(PYTHONIOENCODING)의 기존 설정을 직접 기반으로 합니다. 이 설정들은 이 PEP에서 논의하는 종류의 호환성 문제를 처리하는 데 이미 수년간 사용되어 왔습니다.

둘째, 그 경험을 통해 알게 된 사실 중 하나는 제안된 로케일 강제 변환이 CPython 자체의 문제뿐만 아니라 GNU readline처럼 표준 스트림과 상호 작용하는 확장 모듈의 문제도 해결할 수 있다는 것입니다. 예를 들어, PEP 538이 활성화된 CPython 빌드에서 진행한 다음 대화형 세션을 살펴보십시오. 첫 번째 줄 이후의 각 줄은 “위쪽 화살표, 왼쪽 화살표 4번, delete, enter”를 입력하여 실행한 것입니다.:

$ LANG=C ./python
Python 3.7.0a0 (heads/pep538-coerce-c-locale:188e780, May  7 2017, 00:21:13)
[GCC 6.3.1 20161221 (Red Hat 6.3.1-1)] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> print("ℙƴ☂ℌøἤ")
ℙƴ☂ℌøἤ
>>> print("ℙƴ☂ℌἤ")
ℙƴ☂ℌἤ
>>> print("ℙƴ☂ἤ")
ℙƴ☂ἤ
>>> print("ℙƴἤ")
ℙƴἤ
>>> print("ℙἤ")
ℙἤ
>>> print("ἤ")
ἤ
>>>

이는 올바르게 동작하는 명령 기록 편집기에서 기대할 수 있는 결과와 정확히 일치합니다.

반면, 로케일 설정을 갱신하지 않고 Python 수준의 스트림 인코딩 설정만 변경하면 현재 이전 릴리스에서는 다음과 같은 일이 발생합니다.:

$ LANG=C PYTHONIOENCODING=utf-8:surrogateescape python3
Python 3.5.3 (default, Apr 24 2017, 13:32:13)
[GCC 6.3.1 20161221 (Red Hat 6.3.1-1)] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> print("ℙƴ☂ℌøἤ")
ℙƴ☂ℌøἤ
>>> print("ℙƴ☂ℌ�")
 File "<stdin>", line 0

   ^
SyntaxError: 'utf-8' codec can't decode bytes in position 20-21:
invalid continuation byte

그 특정한 오작동은 CPython이 아니라 GNU readline에서 비롯된 것입니다 - 명령 기록 편집이 UTF-8을 인식하지 못했기 때문에 기록 버퍼를 손상시켰고, surrogateescape 오류 핸들러조차 우회될 만큼 엉터리 데이터를 stdin에 공급했습니다. 기술적으로는 PEP 540의 UTF-8 모드가 readline도 재구성하도록 갱신할 수 있겠지만, readline은 CPython C API를 거치지 않고 표준 스트림과 상호 작용할 수 있는 확장 모듈 중 하나에 불과합니다. 또한 CPython이 적용하는 변경 사항은 readline이 별도의 하위 프로세스가 아니라 Python 3.7의 일부로 직접 실행될 때에만 적용됩니다.

그러나 설정된 로케일을 실제로 변경하면, 임베딩하는 애플리케이션을 전혀 변경하지 않아도 GNU readline이 올바르게 동작하기 시작합니다.:

$ LANG=C.UTF-8 python3
Python 3.5.3 (default, Apr 24 2017, 13:32:13)
[GCC 6.3.1 20161221 (Red Hat 6.3.1-1)] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> print("ℙƴ☂ℌøἤ")
ℙƴ☂ℌøἤ
>>> print("ℙƴ☂ℌἤ")
ℙƴ☂ℌἤ
>>> print("ℙƴ☂ἤ")
ℙƴ☂ἤ
>>> print("ℙƴἤ")
ℙƴἤ
>>> print("ℙἤ")
ℙἤ
>>> print("ἤ")
ἤ
>>>
$ LC_CTYPE=C.UTF-8 python3
Python 3.5.3 (default, Apr 24 2017, 13:32:13)
[GCC 6.3.1 20161221 (Red Hat 6.3.1-1)] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> print("ℙƴ☂ℌøἤ")
ℙƴ☂ℌøἤ
>>> print("ℙƴ☂ℌἤ")
ℙƴ☂ℌἤ
>>> print("ℙƴ☂ἤ")
ℙƴ☂ἤ
>>> print("ℙƴἤ")
ℙƴἤ
>>> print("ℙἤ")
ℙἤ
>>> print("ἤ")
ἤ
>>>

Mac OS X, iOS, Android에서 C 로케일 강제 변환 및 경고 활성화

Mac OS X, iOS, Android에서 CPython은 이미 시스템 인터페이스에 UTF-8을 사용한다고 가정합니다. 로케일을 인식하는 다른 구성 요소도 대부분 마찬가지일 것으로 예상합니다.

따라서 이 PEP는 원래 이러한 플랫폼에서는 로케일 강제 변환과 경고가 완전히 불필요할 것이라는 가정하에, 빌드 시점에 이를 비활성화하도록 제안했습니다.

그러나 이후 조사에서 이러한 플랫폼에서 LANG=C를 명시적으로 설정하면 GNU readline 같은 확장 모듈이 다른 *nix 시스템에서와 거의 같은 방식으로 오동작한다는 사실이 밝혀졌습니다. 따라서 그 가정은 잘못된 것으로 드러났습니다. [21]

또한 Mac OS X는 다른 *nix 환경(Linux나 Android 등)에 배포할 Python 소프트웨어를 개발하고 테스트하는 플랫폼으로도 자주 사용됩니다. 마찬가지로 Linux도 모바일 및 Mac OS X 애플리케이션을 개발하고 테스트하는 플랫폼으로 자주 사용됩니다.

따라서 이 PEP는 CPython의 autotools 기반 빌드 도구 체인을 사용하는 모든 플랫폼(즉, Windows를 제외한 모든 곳)에서 로케일 강제 변환 및 경고 기능을 기본으로 활성화합니다.

구현

참조 구현은 Alyssa Coghlan이 GitHub의 CPython 저장소를 포크한 저장소에서 pep538-coerce-c-locale 기능 브랜치 [18]로 개발되고 있습니다. 진행 중인 PR은 [20]에서 확인할 수 있습니다.

이 참조 구현은 이슈 28180 [1]의 개선 요청뿐만 아니라 이슈 28997 [16]을 해결하는 데 필요한 Android 호환성 수정 사항도 포함합니다.

이전 Python 3 릴리스로의 백포팅

Python 3.6.x로의 백포팅

이 PEP가 Python 3.7에 채택되면, 재배포자가 이 변경 사항을 특별히 자신들의 최초 Python 3.6.x 릴리스로 백포팅하는 것은 허용될 뿐만 아니라 권장됩니다. 그러나 이러한 백포트는 적절한 로케일을 기본으로 제공하는 데 필요한 변경과 함께 진행하거나, 그러한 로케일을 이미 일관되게 사용할 수 있는 플랫폼에 한해서만 진행해야 합니다.

적어도 Fedora 프로젝트는 곧 출시될 Fedora 26 릴리스에서 이 접근 방식을 따를 계획입니다 [19].

다른 3.x 릴리스로의 백포팅

제안된 동작 변경은 주로 ASCII 기반 기본 C 로케일에서 나타나는 Python 3의 현재 오동작을 해결하는 버그 수정으로 여겨집니다. 그럼에도 CPython이 C 로케일 시스템과 상호 작용하는 방식에는 상당히 큰 변화입니다. 따라서 일부 재배포자는 특정 사용자 기반의 요구와 관심에 따라 이 변경을 더 이전의 Python 3.x 릴리스에까지 백포트하기로 선택할 수도 있지만, 이를 일반적인 관행으로 권장하지는 않습니다.

그러나 Python 3 환경(베이스 컨테이너 이미지 등)이 이러한 설정을 기본으로 사용하도록 구성하는 것은 허용될 뿐만 아니라 권장됩니다.

감사의 글

이 PEP에서 제안하는 로케일 강제 변환 접근 방식은 Armin Ronacher가 click 명령줄 유틸리티 개발 프레임워크에서 이 문제를 처리한 방식에서 직접 영감을 받았습니다 [2]:

$ LANG=C python3 -c 'import click; cli = click.command()(lambda:None); cli()'
Traceback (most recent call last):
  ...
RuntimeError: Click will abort further execution because Python 3 was
configured to use ASCII as encoding for the environment.  Either run this
under Python 2 or consult http://click.pocoo.org/python3/ for mitigation
steps.

This system supports the C.UTF-8 locale which is recommended.
You might be able to resolve your issue by exporting the
following environment variables:

    export LC_ALL=C.UTF-8
    export LANG=C.UTF-8

이 변경 사항은 원래 Fedora의 시스템 Python 3.6 패키지를 위한 다운스트림 패치로 제안되었으며 [3], 이후 재배포자가 이전 버전으로 백포트할 수 있도록 허용하는 절을 포함하여 Python 3.7용 PEP로 재구성되었습니다. 업스트림 패치 개발과 병행하여 Charalampos Stratakis는 Fedora 26 백포트 작업을 진행하면서 제안된 변경 사항이 실제로 실현 가능한지에 대한 피드백을 제공해 왔습니다.

초기 초안은 논의를 위해 Python Linux SIG에 게시되었으며 [10], 이후 그 논의와 PEP 540에서 진행된 Victor Stinner의 작업 [11] 양쪽을 바탕으로 수정되었습니다.

이 PEP 전반의 유니코드 처리 예제에서 사용된 “ℙƴ☂ℌøἤ” 문자열은 Ned Batchelder의 훌륭한 “Pragmatic Unicode” 발표에서 가져온 것입니다 [9].

Stephen Turnbull은 쓰쿠바 대학(筑波大学)에서 자신이 정기적으로 마주치는 텍스트 인코딩 처리 문제에 대해 오랫동안 귀중한 통찰을 제공해 왔습니다.

참고 문헌