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

Python 개선 제안 한국어 번역

PEP 837 – 확장 가능한 JSON 직렬화

Author:
Serhiy Storchaka <storchaka at gmail.com>
Discussions-To:
Discourse thread
Status:
Draft
Type:
Standards Track
Created:
12-Jul-2026
Python-Version:
3.16
Post-History:
12-Jul-2026

Table of Contents

번역·라이선스 안내

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

초록

이 PEP는 json 인코더에 확장 메커니즘을 추가합니다. 이 메커니즘은 사용자 정의 수준마다 하나씩, 서로 보완하는 세 부분으로 구성됩니다:

  • 라이브러리 수준: 직렬화 프로토콜 — 특수 메서드 __json__()과 __raw_json__();
  • 애플리케이션 수준: 전역 레지스트리 — copyreg.pickle()의 선례를 따라 copyreg.json_dispatch_table을 채우는 copyreg.json(cls, function);
  • 호출 수준: 인코더별 디스패치 테이블입니다. pickle.Pickler.dispatch_table을 본뜬 것으로, json.JSONEncoder의 dispatch_table 속성입니다.

더 구체적인 수준이 우선합니다.

헬퍼 클래스 copyreg.RawJSON은 이미 인코딩된 JSON 문자열을 감싸서 그대로 출력되게 합니다. 이를 통해 다른 방법으로는 나타낼 수 없는 표현(예: decimal.Decimal을 정밀도를 모두 유지한 JSON 숫자로 직렬화하는 것)이 가능해집니다.

JSON 형태가 모호하지 않은 몇몇 표준 라이브러리 컨테이너 타입, 곧 collections.deque, types.MappingProxyType, collections.ChainMap, collections.UserDict, collections.UserList, collections.UserString에 __json__ 메서드가 추가되므로, 이 타입들은 별도 설정 없이 바로 직렬화됩니다.

json 모듈 자체에는 새로운 공개 이름이 추가되지 않습니다.

동기

현재 기본 타입(dict, frozendict, list, tuple, str, int, float, bool, None) 이외의 것을 json.dumps()로 직렬화하려면, 모든 호출에 default= 함수를 전달하거나, json.JSONEncoder를 서브클래싱한 뒤 그 서브클래스를 모든 호출 지점에 일일이 전달해야 합니다. 두 메커니즘 모두 그 지식을 타입이 아니라 호출에 연결합니다:

  • 이들은 서로 조합되지 않습니다. 각자 사용자 정의 default=가 필요한 두 라이브러리는 애플리케이션이 병합 래퍼를 직접 작성하지 않는 한 하나의 문서에 둘 다 적용될 수 없습니다. 내부적으로(로깅, 캐싱, IPC를 위해) 직렬화하는 라이브러리는 애플리케이션의 default=를 전혀 볼 수 없습니다.
  • 서드파티 타입은 옵트인할 수 없습니다. 새 타입을 정의하는 라이브러리는 그 타입을 사용자가 JSON으로 직렬화할 수 있게 만들 방법이 없습니다. 모든 사용자가 그 주문을 익혀 반복해야 합니다. NumPy 스칼라와 배열(python/cpython#68501, python/cpython#62503), D-Bus 정수 타입(python/cpython#77978)에 대해 이를 해결해 달라는 요청은 표준 라이브러리의 범위를 벗어난다는 이유로 종료되었습니다 — 올바른 판단이었지만, 근본적인 필요는 충족되지 않은 채 남았습니다.
  • 일부 표현은 불가능합니다. default=는 기본 타입 중 하나를 반환해야 하므로, float가 정확히 표현할 수 없는 정수가 아닌 JSON 숫자를 출력할 방법이 없습니다. json.loads()는 parse_float=decimal.Decimal을 통해 십진수를 손실 없이 읽을 수 있지만, 그 결과를 다시 쓸 수는 없습니다(python/cpython#67312). 같은 제약 때문에 float 서식 제어(python/cpython#81022)와 유한하지 않은 float의 표현(python/cpython#98306, python/cpython#134717), 그리고 미리 인코딩된 JSON을 출력하려는 일반적인 요청(python/cpython#86957)도 막혀 있습니다.

이 요구는 오래되었고 폭넓습니다. TypeError를 발생시키기 전에 확인하는 직렬화 훅에 대한 일반적인 제안(python/cpython#71549 — 2016년부터 열려 있음, python/cpython#79292, python/cpython#114285, python/cpython#86931) 외에도, 이슈 트래커에는 구체적인 표준 라이브러리 타입을 별도 설정 없이 지원해 달라는 요청이 쌓였습니다: decimal.Decimal (python/cpython#67312, python/cpython#118810, python/cpython#145115), array.array (python/cpython#70451), collections.deque (python/cpython#64973, python/cpython#73849), types.MappingProxyType (python/cpython#79039), 객체로서의 collections.namedtuple() (python/cpython#67661), datetime.datetime (python/cpython#65742). 그리고 범주 전체에 대한 요청도 있었습니다: 집합, frozenset, bytearray, 이터레이터(python/cpython#75338), 제너레이터(python/cpython#78031), 딕셔너리 뷰(python/cpython#83377).

이 아이디어는 또한 16년 동안 여러 차례 독립적으로 제안되었으며, 매번 이 설계의 일부를 새로 고안했습니다: July 2010과 April 2020에 python-ideas에서, 그리고 discuss.python.org in 2022–2024에서 __json__() 메서드가, python-ideas in 2019에서 원시 출력과 표준화된 인코더 프로토콜이, discuss.python.org in 2022에서 등록 함수가 제안되었습니다.

한편 생태계는 이 관례를 단편적으로 받아들였습니다. TurboGears는 인자 없는 __json__()을 통해 객체를 직렬화하며, 그 custom_encoders 설정이 이보다 우선합니다 — 이 PEP와 같은 순서입니다. Pyramid’s JSON renderer는 __json__(request)를 호출합니다. conda, Checkmk, TatSu 같은 애플리케이션과 라이브러리는 현재 __json__을 정의하고 있습니다. simplejson은 대신 옵트인 방식의 for_json() 메서드를 추가했는데, 던더 이름은 언어를 위해 예약되어 있다는 바로 그 이유로 이 이름을 택했습니다(simplejson issue #52). 그 이슈의 논의에서는 2016년에 이미 GitHub 코드 검색으로 실제 코드에서 for_json보다 __json__ 정의가 더 많이 발견되었으며, 서드파티 라이브러리가 동일한 시그니처에 의존할 수 있도록 PEP로 프로토콜을 공식화해 달라는 요청이 있었습니다. __json__을 정의할 수 있는 것은 표준 라이브러리뿐입니다. 이 PEP가 이를 정의하여 파편화를 끝냅니다.

타입 지원 요청을 하나씩 그대로 받아들일 수는 없었습니다. 요청된 타입 대부분은 JSON 표현이 모호합니다:

  • Decimal — JSON 숫자(1234567890.0987654321)입니까, JSON 문자열("1234567890.0987654321")입니까? 둘 다 흔히 쓰이며, 어느 쪽이든 일부 소비자에게는 잘못된 것입니다.
  • namedtuple — JSON 배열(튜플이므로)입니까, JSON 객체(이름 있는 필드가 있으므로)입니까?
  • array.array는 숫자로 이루어진 JSON 배열이어야 합니까, 아니면 간결하게 인코딩된 문자열이어야 합니까? 합리적인 답은 타입 코드에 따라서도 달라집니다.
  • datetime — 여러 문자열 표현(ISO 8601 변형, epoch 초, RFC 형식) 중 어느 것입니까?

어떤 표준 라이브러리 기본값도 옳을 수 없으므로, 표준 라이브러리는 각 애플리케이션이 자신의 정책을 선언하는 메커니즘을 제공해야 합니다. 그 메커니즘이 바로 이 PEP입니다.

근거

수집된 모든 요구를 아우르는 하나의 시스템

이 기능은 의도적으로 단일 훅이 아니라 작은 시스템으로 설계되었습니다. 각 부분은 위 요청들 중 서로 다른 묶음에 답하며, 어느 한 부분만으로는 그 모두를 다루지 못합니다.

  • __json__ 프로토콜은 일반적인 확장 제안과 서드파티 타입 관련 보고에 답합니다. 타입 작성자가 임포트 없이 한 번만 직렬화 가능하게 만들면 서브클래스가 이를 상속합니다.
  • 레지스트리는 요청자가 제어하지 않거나 표현 방식이 정책적 선택인 타입(Decimal, namedtuple, array.array, datetime)에 대한 요청에 답합니다. 애플리케이션은 한 줄로 자신의 정책을 선언합니다.
  • 원시 출력(__raw_json__/RawJSON)은 어떤 기본 타입 변환으로도 표현할 수 없는 표현에 대한 요청에 답합니다. 미리 인코딩된 조각, 전체 정밀도 숫자, float 서식, 유한하지 않은 float가 그것입니다.
  • 훅 결과의 덕 타이핑은 범주 단위의 요청(이터러블, 집합, 제너레이터, 딕셔너리 뷰, 매핑)에 답합니다. copyreg.json(set, sorted)는 집합에 대한 완전한 해법이며, 훅은 리스트나 dict를 실체화하는 대신 임의의 이터러블이나 매핑 유사 객체를 반환할 수 있습니다.
  • 표준 라이브러리 __json__ 메서드는 모호하지 않은 컨테이너(deque, mappingproxy)에 대한 요청에 직접 답합니다.
  • 인코더별 dispatch_table은 한 프로세스가 출력마다 서로 다른 정책을 필요로 할 때 위의 어떤 것이든 하나의 인코더로 범위를 한정합니다.

세 가지 수준의 사용자 정의

세 부분은 직렬화가 결정되는 세 가지 수준에 대응하며, 의도적으로 서로 다른 형태를 갖습니다.

  • 라이브러리 수준 — __json__()은 타입 작성자를 위한 것입니다. 메서드는 임포트가 필요 없고 서브클래스에 상속됩니다. 따라서 라이브러리는 json이나 copyreg에 전혀 의존하지 않고도 자신의 타입을 JSON 직렬화 가능하게 만들 수 있습니다.
  • 애플리케이션 수준 — copyreg.json()은 자신이 제어하지 않는 타입의 사용자를 위한 것입니다. 애플리케이션은 서드파티 타입이나 모호한 표준 라이브러리 타입이 어떻게 직렬화될지를 프로세스 전역으로 선택합니다. 등록은 정확한 타입 단위로 이루어지며, 같은 처리를 원하는 서브클래스는 대신 __json__을 상속합니다.
  • 호출 수준: json.JSONEncoder 서브클래스나 인스턴스의 dispatch_table 속성은 특정 호출을 사용자 정의합니다. 이는 한 프로그램이 출력마다 다른 정책을 필요로 할 때(예를 들어 외부 API와 내부 캐시) 쓰입니다. 기존 default= 매개변수는 다른 어떤 메커니즘도 처리하지 않은 객체를 위한 호출 수준의 포괄 처리기로 남습니다.

더 구체적인 수준이 우선합니다. 레지스트리 항목은 타입의 __json__보다 먼저 조회되고(애플리케이션이 라이브러리를 재정의), 인코더별 테이블은 전역 레지스트리를 대체합니다(호출이 애플리케이션을 재정의).

레지스트리가 copyreg에 있는 이유

레지스트리는 json.register()가 될 수도 있었습니다. 그러나 세 가지 이유로 대신 copyreg에 두었습니다.

임포트 비용과 계층화. 임포트 시점에 외부 타입의 직렬화 함수를 등록하는 라이브러리가 json 모듈의 비용까지 치러서는 안 됩니다. 이 모듈의 임포트 비용은 대부분 re가 차지하며, 어차피 copy와 pickle이 임포트하는 copyreg를 임포트하는 비용의 약 50배에 이릅니다. 레지스트리와 RawJSON이 모두 copyreg에 있으므로, 등록하는 쪽은 작은 모듈 정확히 하나만 임포트합니다. json 모듈은 (copy가 이미 그러하듯) copyreg를 임포트하며, 그 반대는 결코 없습니다.

선례와 대칭성. 이는 pickle 모델을 양쪽 절반 모두에서 그대로 따른 것입니다. 즉, 전역 테이블을 채우는 등록 함수(copyreg.pickle() / copyreg.json())와 이를 재정의하는 인스턴스별 dispatch_table 속성(pickle.Pickler / json.JSONEncoder)입니다. 함수 이름은 copyreg.pickle() 관례를 따릅니다. 등록은 모듈 접두사가 붙은 일회성 호출이므로, 30년에 걸친 그 선례는 프로토콜 이름을 딴 등록 함수가 아무런 혼란도 일으키지 않음을 보여 줍니다.

중립 지대. 서드파티 JSON 인코더는 표준 json 모듈을 임포트하지 않고도 같은 레지스트리와 헬퍼 클래스를 따를 수 있으므로, 생태계가 단일 등록 지점으로 수렴할 수 있습니다.

레지스트리를 copyreg에 두면 이 모듈의 역할도 일반화되어, 직접 제어하지 않는 타입에 대한 프로토콜 구현을 등록하는 모듈이 됩니다. 다른 프로토콜을 위한 레지스트리(예: copy 모듈을 위한 __copy__ 및 __deepcopy__와 유사한 훅)도 같은 패턴에 들어맞으며, 별도로 제안될 것입니다.

원시 출력 메커니즘

이미 인코딩된 조각을 그대로 출력하는 것은 기본 타입 밖의 표현, 무엇보다도 완전한 정밀도의 숫자를 표현하는 유일한 방법입니다. 선택된 메커니즘은 덕 타이핑되는 특수 메서드 __raw_json__()이며, copyreg.RawJSON이 편의용 래퍼 역할을 합니다:

  • 원시 출력을 원하는 훅(또는 __json__)은 copyreg.RawJSON(fragment)를 반환하거나(등록하는 쪽이 이미 치른 import 하나면 됩니다), import가 전혀 필요 없는 방법으로 __raw_json__만 정의하는 자체 래퍼 클래스의 인스턴스를 반환합니다.
  • RawJSON은 추가로 self를 반환하는 __json__을 정의하여 두 번째 기능을 수행합니다. 이 기능은 사전 직렬화된 조각을 훅 결과로만이 아니라 문서 안에 직접 포함하는 것으로, 예를 들어 json.dumps()에 전달하는 dict나 list의 값으로 넣을 수 있습니다. 여기서 기능은 함수로서의 역할을 뜻합니다.

인터페이스는 클래스가 아니라 프로토콜이며, 클래스는 편의 문법일 뿐입니다.

성능: 불변 조건으로서의 인코딩 파이프라인

json 인코더는 표준 라이브러리에서 가장 빈번하게 실행되는 코드 중 하나이며, 이 설계는 인코더의 브랜치 순서를 불변 조건으로 취급합니다.

  1. 정확히 기본 타입인 객체는 속성 조회를 전혀 하지 않고 인코딩됩니다. 압도적으로 흔한 이 경우는 이 PEP로 인한 비용을 전혀 치르지 않습니다.
  2. 그다음으로 디스패치 테이블과 __json__을 조회합니다. 이는 dict 조회 한 번과 타입 속성 조회 한 번이며, isinstance 대체 검사보다 먼저 수행됩니다. 이렇게 하는 이유는 오로지 기본 타입의 서브클래스가 직렬화 방식을 사용자 정의할 수 있게 하기 위해서입니다(그렇지 않으면 int 서브클래스는 isinstance(o, int) 검사에 먼저 걸리게 되는데, 이는 바로 IntEnum에서 실제로 겪었던 문제입니다: python/cpython#62464).
  3. 이어서 isinstance 대체 검사가 사용자 정의를 하지 않는 서브클래스를 처리하므로, 이들에 대한 현재 동작은 정확히 그대로 유지됩니다(IntEnum은 여전히 숫자로 직렬화됩니다).
  4. 훅 결과에 대한 덕 타이핑(__index__는 숫자로, __float__는 숫자로, keys()가 있는 __iter__는 객체로, __iter__만 있으면 배열로, __raw_json__은 그대로)은 훅이 실행된 경우에만 적용됩니다. 특히 __raw_json__은 훅에서 나오지 않은 객체에 대해서는 절대 조회되지 않으므로, 서브클래스 검사나 default()로 향하는 객체는 추가 조회 비용을 치르지 않습니다.
  5. default 훅은 변경 없이 마지막에 실행됩니다.

기본 __json__을 갖는 표준 라이브러리 타입

기준은 다음과 같습니다: 표준 라이브러리 타입은 JSON 형태가 모호하지 않을 때에만 기본 ``__json__``을 갖습니다. 투명한 컨테이너는 이 기준을 충족합니다. deque, mappingproxy, ChainMap, UserDict, UserList, UserString은 의미상 그저 배열과 객체일 뿐입니다(python/cpython#64973, python/cpython#73849, python/cpython#79039를 직접 해결합니다). Decimal, namedtuple, array.array, datetime은 동기 절에서 설명한 모호성 때문에 이 기준을 충족하지 않습니다. 이들에 대한 이 PEP의 답은 애플리케이션이 직접 하는 한 줄짜리 등록입니다(How to Teach This 참조).

이 기준은 앞으로 들어올 “X에 __json__만 추가해 주십시오” 같은 요청에 대한 준비된 답이기도 합니다.

명세

__json__ 프로토콜

클래스는 __json__(self) 메서드를 정의할 수 있습니다. json 인코더는 정확히 지원되는 기본 타입이 아니면서 디스패치 테이블 항목도 없는 객체를 만나면, (다른 모든 특수 메서드와 마찬가지로) 객체의 타입에서 __json__을 조회하고, 존재하면 이를 호출합니다. 그런 다음 객체 대신 그 결과가 직렬화됩니다:

  • 결과가 기본 타입이거나 기본 타입 서브클래스의 인스턴스이면 평소대로 직렬화됩니다.
  • 그렇지 않으면 결과는 덕 타이핑으로 해석됩니다: __index__가 있는 객체는 operator.index()를 통해 JSON 숫자로 직렬화되고, 그렇지 않고 __float__가 있는 객체는 float를 통해 JSON 숫자로, 그렇지 않고 keys() 메서드(및 __getitem__)가 있는 이터러블은 JSON 객체로, 그렇지 않고 이터러블이면 JSON 배열로 직렬화되며, 그렇지 않고 타입이 __raw_json__을 정의하는 객체는 이를 호출하여 직렬화됩니다(아래 참조). 그 밖의 경우에는 TypeError가 발생합니다.

__json__의 결과는 프로토콜에 재귀적으로 다시 넘겨지지 않습니다: 결과 자체가 훅을 정의하는 타입이더라도 다시 변환되지 않습니다.

__raw_json__ 프로토콜

__raw_json__(self)는 str을 반환해야 하며, 인코더는 그 내용을 검증하지 않고 그대로 출력합니다. 이 메서드는 디스패치 테이블 함수나 __json__이 만들어 낸 객체에 대해서만 조회되므로(위의 파이프라인 불변 조건 참조), 훅 결과로 쓰이는 래퍼 클래스에는 __raw_json__만 있으면 됩니다. RawJSON은 self를 반환하는 __json__도 정의하므로, 그 인스턴스가 입력 문서에 직접 나타날 수 있습니다.

레지스트리

다음 항목이 copyreg에 추가됩니다:

copyreg.json(ob_type, json_function)
공식 등록 인터페이스입니다. json_function을 ob_type의 직렬화 함수로 등록합니다. json_function이 호출 가능 객체가 아니면 TypeError를 발생시킵니다. 이 함수는 객체를 유일한 인자로 하여 호출되며, 그 결과는 __json__의 결과와 정확히 같은 방식으로 해석됩니다. 등록은 정확한 타입을 기준으로 이루어집니다: ob_type의 서브클래스에는 적용되지 않습니다.
copyreg.json_dispatch_table
타입을 등록된 함수에 매핑하는 dict로, json 모듈이 참조합니다(레지스트리를 따르는 다른 JSON 인코더도 사용할 수 있습니다). 애플리케이션은 테이블을 직접 변경하는 대신 copyreg.json()을 통해 등록합니다.
copyreg.RawJSON(encoded_json)
인스턴스가 encoded_json을 그대로 출력하는 방식으로 직렬화되는 래퍼입니다. 인스턴스의 str()은 그 조각을 반환합니다.

등록된 함수가 __json__보다 우선합니다. 정확히 지원되는 기본 타입 자체(dict, list, str, int, …)에 대한 항목은 절대 참조되지 않습니다. 해당 타입들의 빠른 경로가 먼저 실행되기 때문입니다.

인코더

json.JSONEncoder (및 C 가속기)는 getattr(encoder, 'dispatch_table', copyreg.json_dispatch_table)을 참조하므로, 서브클래스는 dispatch_table 클래스 속성이나 인스턴스 속성을 설정하여 해당 인코더의 전역 레지스트리를 대체할 수 있습니다. 이는 pickle.Pickler와 같은 방식입니다.

dict 키는 이 PEP의 어떤 부분에도 영향을 받지 않습니다: 키 직렬화는 현재 허용하는 타입만 정확히 허용하며, 키에 대해서는 디스패치 테이블도 __json__도 참조하지 않습니다(미해결 문제 참조).

check_circular 메커니즘은 변경되지 않습니다: 컨테이너와 default의 결과는 현재와 같이 추적됩니다. 훅의 결과는 별도로 추적되지 않습니다. 호출될 때마다 새로운 순환을 반환하는 병적인 훅은 여느 폭주 재귀와 마찬가지로 RecursionError로 끝납니다.

표준 라이브러리 __json__ 메서드

명백한 컨테이너의 뷰를 반환하는 __json__이 다음에 추가됩니다: collections.deque와 types.MappingProxyType (C로 구현되며 self를 반환합니다. 인코더는 이들을 배열 경로와 객체 경로로 처리합니다), 그리고 collections.ChainMap, collections.UserDict, collections.UserList, collections.UserString (Python으로 구현되며 self 또는 기저의 data를 반환합니다).

하위 호환성

json 모듈에는 새로운 공개 이름이 추가되지 않으며, 새 훅을 하나도 정의하지 않은 객체에 대해서는 기존 동작이 변경되지 않습니다:

  • 정확히 지원되는 모든 기본 타입은 이전과 바이트 단위로 동일하게 직렬화됩니다.
  • 훅이 없는 기본 타입의 서브클래스는 이전과 같이 직렬화됩니다(IntEnum은 숫자로, str 서브클래스는 문자열로, dict 및 list 서브클래스는 각각 객체와 배열로 직렬화됩니다).
  • 이전에 TypeError로 거부되던 객체 중 __json__을 정의한 객체는 이제 직렬화됩니다. TurboGears 관례(인자 없는 __json__)를 따르는 클래스는 의도한 동작을 정확히 얻습니다. __json__(self, request)가 인자를 받는, Pyramid 렌더러용으로 작성된 클래스는 계속 TypeError를 발생시킵니다 — 이제는 default가 아니라 누락된 인자 때문에 발생합니다 — 따라서 메시지는 바뀌지만 예외를 잡는 코드는 계속 동작합니다. 이 이름을 무관한 용도로 사용하는 클래스는 동작이 바뀌겠지만, 던더 이름은 언어 레퍼런스에 의해 예약되어 있으며, 그럴듯한 무관한 의미도 알려져 있지 않습니다.
  • 비공개 _json.make_encoder의 시그니처에 dispatch_table 인자가 추가됩니다.
  • __json__을 새로 갖게 되는 표준 라이브러리 클래스 여섯 개는 이전에는 json.dumps()에서 (default=로 처리하지 않는 한) TypeError를 발생시켰습니다. default=를 사용해 이들을 직렬화하던 코드는 계속 동작합니다. 해당 객체가 더 이상 default에 도달하지 않기 때문입니다. 다만 내장 표현(배열/객체)은 사용자 정의 default가 만들던 결과와 다를 수 있습니다. 이는 새로운 기본 동작이라면 늘 따르는 위험이며, 모호하지 않은 컨테이너에 대해서는 수용 가능하다고 판단합니다.
  • from copyreg import json은 from copyreg import pickle이 늘 그래 왔듯이, 임포트하는 네임스페이스에서 json 모듈 이름을 가립니다. 문서에서는 모듈 접두사를 붙인 호출을 보여 줄 것입니다.

보안 영향

__raw_json__과 RawJSON은 검증 없이 문자열을 내보내므로, 공격자가 제어하는 조각을 반환하는 훅은 유효하지 않거나 오해를 부르는 JSON을 만들어 낼 수 있습니다. 이는 새로운 능력이 아닙니다. default=는 이미 인코딩 중에 임의의 코드를 실행하며, 잘못된 형식의 출력을 만들려면 애플리케이션이 해당 훅을 설치해야 합니다. 문서에는 원시 조각이 신뢰할 수 있는 생산자에게서 와야 한다는 점을 명시할 것입니다.

교육 방법

사용자 정의 수준마다 하나씩, 네 가지 레시피가 있습니다:

  1. 라이브러리 수준 — 자신의 클래스: __json__을 정의합니다:
    class Money:
        def __json__(self):
            return {"amount": str(self.amount),
                    "currency": self.currency}
    
  2. 애플리케이션 수준 — 다른 사람의 클래스: 등록합니다:
    import copyreg, decimal
    copyreg.json(decimal.Decimal, str)
    
  3. 애플리케이션 수준 — JSON으로 달리 표현할 수 없는 표현: 원시 조각을 반환합니다:
    copyreg.json(decimal.Decimal,
                 lambda d: copyreg.RawJSON(str(d)))
    
  4. 호출 수준 — 하나의 인코더, 다른 정책:
    class APIEncoder(json.JSONEncoder):
        dispatch_table = {decimal.Decimal: str}
    

기존 default=는 다른 어떤 메커니즘도 처리하지 않은 객체를 위한 호출 수준의 최종 처리기로 남으며, 가장 마지막에 실행된다고 문서화됩니다.

참조 구현

python/cpython#153607 (브랜치 json-customize4)는 저자의 2017–2022년 json-customize 브랜치를 리베이스하고 재작업한 것으로, C 및 Python 인코더를 지원하는 프로토콜과 레지스트리, 표준 라이브러리 __json__ 메서드, 그리고 코드 주석으로 기록된 인코딩 파이프라인 불변 조건을 포함하여 두 인코더 구현 모두에 대한 테스트로 구성됩니다.

기각된 아이디어

copyreg 대신 json.register()
json 모듈에 등록하게 하면 등록하는 쪽이 이 모듈을 임포트해야 하고(copyreg의 약 50배에 달하는 임포트 비용이며, 대부분은 re가 차지합니다), pickle과의 대칭이 깨지며, 서드파티 인코더에게 중립적인 레지스트리를 제공하지 못합니다. 발견 가능성 문제는 json 문서에서 copyreg를 가리키는 설명으로 해결합니다.
copyreg.register_json() 스타일의 이름
30년 된 선례인 copyreg.pickle()과 일관되지 않습니다.
Decimal, namedtuple, array.array, datetime에 대한 기본 __json__
각각에는 적어도 두 가지의 정당한 JSON 표현이 있습니다(숫자 대 문자열, 배열 대 객체, 타입 코드에 따라 배열 대 인코딩된 문자열, 다양한 문자열 형식). 기본값을 두면 상당수의 사용자에게 잘못된 결과가 됩니다. 이 모호성은 기본값이 아니라 레지스트리를 도입해야 한다는 인자, 곧 논거가 됩니다.
옵트인 없이 모든 이터러블과 매핑을 직렬화하기
python/cpython#75338에서 요청되었고 python/cpython#78031과 python/cpython#83377에서도 암시되었지만, 이는 조용한 동작 변경입니다. __iter__를 가진 모든 객체(집합, 제너레이터, 파일 객체)가 default=에 도달하거나 예외를 발생시키는 대신 배열로 직렬화되기 시작할 것입니다. 따라서 덕 타이핑은 훅 결과에만 적용되며, 이는 명시적인 옵트인입니다.
__raw_json__ 대신 isinstance(o, RawJSON)
잘못된 대상에게 부담을 지우기 때문에 거부되었습니다. 아무것도 import하지 않고 __json__을 통해 옵트인한 타입이 원시 출력이 필요해지는 순간 copyreg를 import해야 합니다. 덕 타이핑 프로토콜에서 클래스는 메커니즘이 아니라 편의 수단입니다.
클래스 이름으로 원시 래퍼 인식하기
이름 기반 디스패치에는 pickle이라는 선례가 있습니다. pickle은 reduce 튜플 안의 __newobj__와 __newobj_ex__ 호출 가능 객체를 __name__으로 인식합니다. 그러나 이 PEP는 그 예를 따르지 않습니다. type(o).__name__ == "RawJSON"으로 원시 래퍼를 인식하면 임의의 객체에서 흔하고 그럴듯한 클래스 이름을 검사하게 됩니다. 이 이름을 우연히 사용하는 무관한 클래스는 모두 인코딩 동작이 조용히 바뀌게 됩니다. 덕 타이핑 방식의 __raw_json__은 마커를 예약된 던더 네임스페이스 안에 둡니다.
모든 객체에서 __raw_json__을 직접 조회하기
이 방식을 쓰면 클래스가 메서드 하나로 스스로를 원시 직렬화할 수 있습니다. 하지만 서브클래스 검사, 덕 타이핑 해석(__index__, __iter__ 등), default()에 이르는 경로에서 모든 객체마다 타입 속성 조회가 하나씩 추가됩니다. 이는 표준 라이브러리에서 가장 자주 실행되는 경로 중 하나에 실제 비용을 더합니다. 게이트 방식의 설계에서는 그 대신 원시 객체가 메서드 호출 한 번의 비용을 부담합니다.
__repr__/__str__ 또는 범용 __serialize__ 재사용하기
python/cpython#114285에서 제안되었습니다. 이 메서드들은 원시 출력의 마커로 쓰일 수 없습니다. 거의 모든 타입이 이 메서드들을 정의하고 그 결과는 대개 유효한 JSON이 아니므로, 인코더가 원시 출력이 가능한 객체를 구별할 수 없습니다. 형식에 구애받지 않는 __serialize__ 역시 어떤 형식을 생성하는지 알려 줄 수 없습니다. 그러나 별도의 마커가 있다면 __str__은 페이로드로 쓰일 수 있습니다. 미해결 문제를 참조하십시오.
레지스트리의 MRO 기반(isinstance) 디스패치
레지스트리는 copyreg.dispatch_table처럼 정확한 타입 일치를 사용합니다. 서브클래스 디스패치는 프로토콜이 맡습니다. 베이스 클래스가 __json__을 한 번 정의하면 서브클래스가 이를 상속합니다. 정확한 일치를 사용하면 조회가 딕셔너리 접근 한 번으로 끝나고, 자주 실행되는 경로에서 MRO 탐색을 피할 수 있습니다.

미해결 문제

  • 딕셔너리 키. 레지스트리와 __json__ 모두 딕셔너리 키에는 적용되지 않습니다. 이를 원하는 요청은 있습니다(python/cpython#63020, python/cpython#117391, python/cpython#85741, python/cpython#117592). 호환되는 향후 확장으로는 현재 keys must be str... TypeError가 발생하는 바로 그 지점(skipkeys 건너뛰기 이전)에서 훅을 조회하는 방법이 있습니다. 이렇게 하면 규칙에 맞는 키는 아무 비용도 치르지 않습니다. 훅 결과는 다시 키 정규화를 거치고, 컨테이너나 원시 결과는 거부됩니다. 이 PEP에서는 보류합니다.
  • 훅을 통한 순환 감지. 호출할 때마다 동등하지만 동일하지는 않은 새 순환 구조를 반환하는 훅은 Circular reference detected를 보고하지 않고 재귀 한도를 소진합니다(RecursionError). check_circular 마커에서 훅 결과를 추적하면 이 문제를 해결할 수 있지만, 훅 경로에 어느 정도 비용이 듭니다. 이 문제가 실제로 중요하다는 근거가 나올 때까지 보류합니다.
  • 순수 마커로서의 __raw_json__. 아직 검토 중인 대안이 하나 있습니다. __raw_json__을 마커로 유지하되, 출력되는 조각을 메서드의 반환값 대신 str(o)에서 가져오는 방식입니다. RawJSON이 조각을 반환하는 __str__을 정의하는 것도 바로 이 때문입니다. 원시 래퍼를 동일성이나 이름으로 인식하는 경우에도 마찬가지입니다. __str__ 슬롯을 호출하는 것은 완전한 메서드 호출보다 빠릅니다. 그러나 이 설계는 모든 원시 래퍼가 __raw_json__과 __str__을 모두 정의하도록 요구하게 됩니다.

부록: 관련 이슈

이 PEP에서 참조한 CPython 이슈를 시간순으로 나열하면 다음과 같습니다.

  • enum.IntEnum이 JSON 직렬화와 호환되지 않음 (python/cpython#62464, 닫힘)
  • json.dumps()가 numpy.ndarray와 numpy.bool_을 직렬화할 수 없다고 함 (python/cpython#62503, 닫힘)
  • json.dump()가 딕셔너리 키를 직렬화할 때 ‘default’ 옵션을 무시함 (python/cpython#63020, 열림)
  • collections.deque용 표준 라이브러리 json 직렬화기를 기본 제공해야 함 (python/cpython#64973, 열림)
  • json 라이브러리가 datetime 같은 객체를 직렬화하지 못함 (python/cpython#65742, 닫힘)
  • json에서 Decimal은 읽기(READ)만 지원됨 (python/cpython#67312, 열림)
  • namedtuple을 dict로 JSON 인코딩할 수 있도록 허용 (python/cpython#67661, closed)
  • json이 numpy.int64를 직렬화하지 못하는 문제 (python/cpython#68501, closed)
  • 기본적으로 array.array를 JSON으로 직렬화 (python/cpython#70451, open)
  • TypeError를 발생시키기 전에 json.dumps가 obj.__json__을 확인하도록 변경 (python/cpython#71549, open)
  • collections.deque를 json으로 직렬화할 수 있도록 변경 (python/cpython#73849, closed)
  • set, frozenset, bytearray, 이터레이터를 json 배열로 인코딩 (python/cpython#75338, closed)
  • dbus.Byte의 json int 인코딩이 잘못되는 문제 (python/cpython#77978, closed)
  • 제너레이터 사용 시 발생하는 Json.dump() 버그 (python/cpython#78031, closed)
  • MappingProxy 객체도 딕셔너리와 똑같이 JSON으로 직렬화되어야 함 (python/cpython#79039, open)
  • 사용자 정의 객체 클래스를 JSON으로 직렬화할 수 있도록 변경 (python/cpython#79292, open)
  • JSON의 float 인코딩 사용자 정의 지원 (python/cpython#81022, open)
  • json이 딕셔너리 뷰 타입을 인코딩하지 못하는 문제 (python/cpython#83377, closed)
  • dict 키에 대해서도 json.JSONEncoder.default가 호출되어야 함 (python/cpython#85741, closed)
  • 새로운 데이터 모델 메서드 __iter_items__ 도입 (python/cpython#86931, closed)
  • 객체를 json으로 인코딩해 str로 만들 방법이 없습니다. (python/cpython#86957, open)
  • 유한하지 않은 float 값의 올바른 JSON 직렬화 또는 사용자 정의 JSON 직렬화 (python/cpython#98306, open)
  • 가능한 경우 __repr__, __str__ 또는 __serialize__를 이용한 Json 인코딩 (python/cpython#114285, closed)
  • TypeError를 발생시키기 전에 지원되지 않는 dict 키를 .default()로 넘겨 JSONEncoder가 처리할 수 있도록 허용 (python/cpython#117391, open)
  • json의 default 호출 가능 객체/메서드는 키를 무시합니다. (python/cpython#117592, closed)
  • JSON 인코더가 선택적으로 decimal.Decimal 객체를 지원할 수 있도록 허용 (python/cpython#118810, closed)
  • json 모듈에서 NaN 및 Infinity 직렬화를 사용자 정의할 수 있도록 허용 (python/cpython#134717, closed)
  • json 모듈에서 Decimal을 JSON Number로 선택적으로 변환 (python/cpython#145115, closed)