JSON Serializer

Python 을 다루면서 프로젝트를 하게 되면 JSON을 Binary 형태로 변환하거나, 혹은 그 반대의 작업을 해야 하는 경우는 필연적으로 발생하게 됩니다. 기본적인 타입(Primitive type)들은 자연스럽게 변환을 할 수 있지만, 우리가 실제로 작성하게 되는 클래스들은 그렇지 못한 경우가 대부분 입니다.

이번 게시글에서는 Python이 어떤 과정을 통해서 타입을 JSON 형태로 변환하게 되는 지 살펴보려고 합니다.

JSON 모듈

Python 자체에 내재되어 있으며, 가장 많이 사용되는 json 모듈입니다. 주된 사용법은 이렇습니다.

Serialize

>>> json.dumps(dict(text="hello world"))
'{"text": "hello world"}'

Deserialize

>>> json.loads('{"text": "hello world"}')
{'text': 'hello world'}

json.dumps 내부에서는 어떻게 데이터들을 처리하고 있을까요?

먼저 내부 코드를 살펴보면 기본적으로 JSONEncoder를 호출하고 있음을 살펴볼 수 있습니다.

 if cls is None:
        cls = JSONEncoder
    return cls(
        skipkeys=skipkeys, ensure_ascii=ensure_ascii,
        check_circular=check_circular, allow_nan=allow_nan, indent=indent,
        separators=separators, default=default, sort_keys=sort_keys,
        **kw).encode(obj)
 

그럼 이 JSONEncoder는 정확히 어떻게 동작하는 걸까요?
똑같이 내부 코드를 타고타고 들어가 핵심 변환 로직은 이렇게 작성되어 있습니다.

if isinstance(o, str):
	yield _encoder(o)
elif o is None:
	yield 'null'
elif o is True:
	yield 'true'
elif o is False:
	yield 'false'
elif isinstance(o, int):
	# see comment for int/float in _make_iterencode
	yield _intstr(o)
elif isinstance(o, float):
	# see comment for int/float in _make_iterencode
	yield _floatstr(o)
elif isinstance(o, (list, tuple)):
	yield from _iterencode_list(o, _current_indent_level)
elif isinstance(o, dict):
	yield from _iterencode_dict(o, _current_indent_level)
  • Noneboolean 타입은 단순하게 보이실겁니다.
  • int 타입은 _intstr=int.__repr__ 로 정의되어 있어 __repr__ 를 참조하고 있습니다.
  • float별도 정의된 함수 에서 처리하며 마찬가지로 __repr__ 를 참조하고 있습니다.
  • listtuple요소를 순회하며 각각의 타입마다 똑같이 __repr__를 참조하고 있습니다.
  • dict도 마찬가지로 각각의 요소를 순회 하며 각각의 타입마다 __repr__를 참조하고 있습니다.

한가지 더 중요하게 봐야하는 점은, else 구문입니다.

else:
	if markers is not None:
		markerid = id(o)
		if markerid in markers:
			raise ValueError("Circular reference detected")
		markers[markerid] = o
	newobj = _default(o)

코드에 마지막에보면 new_obj = _default(o) 라고 해서 _default 메소드를 호출하는데, 이 메소드는 json.dumps 의 매개변수에 주입할 수 있는 메소드입니다. JSONEncoder의 default 메소드의 설명을 보면 상속 받아서 타입에 적합한 Serialize 방식을 새로 정의하라고 적혀있습니다.

요약을 하자면, json 모듈은 기본 자료형에 대해서만 Serialization 을 지원합니다.

  • 호환되는 기본 자료형: bool, None, int, float, list, tuple, dict
  • 호환되지 않는 자료형은 먼저 default() 메소드를 호출하여 시도합니다.
  • Enum 은 기본적으로 지원하지 않습니다.
  • datetime 또한 지원하지 않습니다.

DjangoJSONEncoder

Django나 DRF 에서 가장 기본으로 사용하는 Encoder 입니다.

내부 코드를 보면 json.JSONEncoder 를 상속받고 default() 메소드를 간단하게 구현한 것을 확인할 수 있습니다. 해당 코드를 살짝 살펴 보면 datetime에 대해서 추가적인 Serialize 방법을 제공하고 있음을 확인하실 수 있습니다. 그리고 해당되는 타입이 없다면 super().default(o) 를 통해서 상위 메소드에 변환을 위임하고 있네요.

if isinstance(o, datetime.datetime):
	r = o.isoformat()
		if o.microsecond:
			r = r[:23] + r[26:]
		if r.endswith("+00:00"):
			r = r.removesuffix("+00:00") + "Z"
	return r
elif isinstance(o, datetime.date):
	return o.isoformat()
elif isinstance(o, datetime.time):
	if is_aware(o):
		raise ValueError("JSON can't represent timezone-aware times.")
	r = o.isoformat()
	if o.microsecond:
		r = r[:12]
	return r
elif isinstance(o, datetime.timedelta):
	return duration_iso_string(o)
elif isinstance(o, (decimal.Decimal, uuid.UUID, Promise)):
	return str(o)
else:
	return super().default(o)

요약하자면 다음과 같습니다.

  • 기본 자료형은 JSONEncoder에서 처리합니다.
  • datetime_default를 통해서 처리되는데, DjangoJSONEncoder가 이를 구현했습니다.
  • 모델 정의 및 쿼리에 사용되는 BaseModel이나 Queryset은 지원되지 않습니다.
  • 마찬가지로 Enum은 지원되지 않습니다.

DRF JSONEncoder

DRF은 Django의 확장판이라고도 불리고, 사실상 요즘은 DRF로 많이 사용되고 있는 것 같습니다.
여기도 마찬가지로 내부 코드 를 보시면 Django 보다 더 다양한 타입들을 직렬화 하는 것을 보실 수 있습니다.

대표적으로 추가되는 항목들은 다음과 같습니다.

elif isinstance(obj, QuerySet):
	return tuple(obj)
elif isinstance(obj, bytes):
	# Best-effort for binary blobs. See #4187.
	return obj.decode()
elif hasattr(obj, 'tolist'):
	# Numpy arrays and array scalars.
	return obj.tolist()
elif (coreapi is not None) and isinstance(obj, (coreapi.Document, coreapi.Error)):
	raise RuntimeError(
		'Cannot return a coreapi object from a JSON view. '
		'You should be using a schema renderer instead for this view.'
	)
elif hasattr(obj, '__getitem__'):
	cls = (list if isinstance(obj, (list, tuple)) else dict)
	with contextlib.suppress(Exception):
		return cls(obj)
elif hasattr(obj, '__iter__'):
	return tuple(item for item in obj)
return super().default(obj)

보시면 QuerySet부터, bytes 심지어 메소드를 인식하고 적절한 메소드를 호출하는 로직까지 들어가 있습니다.

Ninja JSONEncoder

Ninja는 Django의 또 다른 버전입니다. Ninja 는 내부적으로 Pydantic을 이용하는데, Ninja 에서는 Pydanitc에 대한 Serialize 수단을 추가로 제공합니다.

마찬가지로 내부 코드를 보면 BaseModel 에 대한 처리가 되어 있는 것을 확인하실 수 있습니다.

class NinjaJSONEncoder(DjangoJSONEncoder):
    def default(self, o: Any) -> Any:
        if isinstance(o, BaseModel):
            return o.model_dump()
        if isinstance(o, (IPv4Address, IPv6Address)):
            return str(o)
        if isinstance(o, Enum):
            return str(o)
        return super().default(o)

NinjaEnum을 같이 처리하고 있습니다. 하지만 단순히 str처리만 하고 있기 때문에 직렬화 수단으로는 부족한 면이 있습니다.

정리

flowchart TD
    A[json.dumps] --> B["is (None, bool, int, str, float, list, tuple, dict)"]
    B -->|Yes| C[__repr__를 통한 변환]
    C -->END
    B -->|No| D["주입된 default() 메소드 호출"]
    D -->|DjangoJSONEncoder|END
    D -->|DRFJSONEncoder|END
    D -->|NinjaJSONEncoder|END
    D -->|...etc|END