- Published on
Django REST Framework에서 복잡한 JSON 응답을 효율적으로 다루기 SerializerMethodField부터 JsonField와 JSONObject까지
- Authors
- Name
- hongreat
- ✉️hongreat95@gmail.com
Django REST Framework에서 복잡한 (JSON)응답을 구성할 때 SerializerMethodField를 사용하곤 합니다.
Serializer 레벨에서 object 에 해당하는 로직을 수행할 수 있으니, 계산이 필요하거나 데이터 응답에 대한 비즈니스 로직이 복잡한 경우 종종 이 방법(SerializerMethodField) 을 사용하는 것이죠.
하지만 이를 잘못사용하거나 쿼리 최적화에 대한 이해가 부족하다면 N+1 문제가 발생하기 너무 좋은 방법입니다.
그래서 저는 주로 View에서 쿼리를 최적화하여 가능하면 SerializerMethodField 를 사용을 지양하는 방식으로 개발을 했습니다.
이번에는 Serializer를 사용하는 기본적인 방법부터, JSONField와 JSONObject를 활용해 복잡한 구조를 효율적으로 다루는 방법을 기록합니다.
또, JSONField 가 섞여있는 복잡한 응답을 효율적인 응답으로 구현하면서 사용한 방식과 느낀점을 기록합니다.
- 1. SerializerMethodField
- 2. ModelSerializer
- 3. JSONObject
- 4. JsonField와 JSONObject 활용
- 5. JSONField 업데이트 전략
1. SerializerMethodField
Order 와 OrderItem 이라는 모델이 있고, 이를 보여주는 API 를 개발한다고 가정합니다.
구조는 아래와 같습니다.
class Order(models.Model):
...(주문에 관한 정보)
class OrderItem(models.Model):
order = models.ForeignKey(Order, related_name='items', on_delete=models.CASCADE)
product = models.ForeignKey(Product, related_name='product_order_items',...)
quantity = models.IntegerField(...)
price = models.DecimalField(...)
...
1.1 SerializerMethodField 의 특징
Order 에서 SerializerMethodField를 통해 item 을 보여주는 방법이 있습니다.
이 방식의 장점은 NestedSerailzer를 사용하는 방식보다 표현에 있어서, 복잡한 중첩 관계를 직관적으로 표현하기 용이하다는 것 입니다.
자유롭고 파이썬 메서드를 활용한 비즈니스 로직을 다루기 용이하다는 것이 장점입니다.
class OrderSerializer(serializers.ModelSerializer):
total_amount = SerializerMethodField()
items = SerializerMethodField()
def get_total_amount(self, obj):
return obj.total
def get_items(self, obj):
return [{
'id': item.id,
'name': item.product.name,
'quantity': item.quantity
} for item in obj.items.all()]
1.2 Prefetch 로 최적화
하지만 SerializerMethodField 를 그냥 사용하게 되면 N+1 쿼리 문제 발생 가능성이 매우 높아집니다.
이런 경우, Serializer가 종속되는 View의 queryset에 prefetch 하는 것이 해결방법 입니다.
class OrderViewSet(viewsets.ModelViewSet):
def get_queryset(self):
return Order.objects.annotate(
total=Sum(F('items__price') * F('items__quantity'))
).select_related('customer').prefetch_related(
Prefetch(
'items',
queryset=OrderItem.objects.select_related('product'),
to_attr='prefetched_items' # 캐싱을 위한 속성 지정
)
)
class OrderSerializer(serializers.ModelSerializer):
items = SerializerMethodField()
def get_items(self, obj):
# prefetched_items 활용
items = getattr(obj, 'prefetched_items', obj.items.all())
return [{
'id': item.id,
'name': item.product.name,
'quantity': item.quantity
} for item in items]
- prefetch_related 사용 시 정확한 관계명을 사용하고, to_attr 사용으로 캐싱을 최적화 해줘야합니다.
하지만 이 방법도 결국 SerializerMethodField 사용에 맞추기위한 방법일 뿐, DRF를 구조적으로 잘사용하는 것은 아니라고 생각합니다.
그 이유는 코드로서 N+1 최적화에 대한 100% 보장을 할 수 있으면서, Swagger를 사용해야하는 경우 드러납니다.
- SerializerMethodField를 사용하면 Swagger가 메소드 내부의 로직을 해석할 수 없어 API 문서가 불완전해집니다.
- N+1 이 발생하는 경우라면, 트랜잭션에 대한 일관성이 사라져 로직이 의도와 다르게 작동될 수 있습니다.
2. ModelSerializer
가장 직관적인 방법으로 ModelSerializer를 사용할 수 있습니다
코드상에서 반환되는 데이터의 타입과 구조가 명시적으로 드러나지 않는 것은 API 사용자가 정확한 응답 구조를 파악하기 어렵다는 것을 의미합니다.
class OrderItemSerializer(ModelSerializer):
class Meta:
model = OrderItem
fields = ['id', 'product', 'quantity', 'price']
class OrderSerializer(ModelSerializer):
items = OrderItemSerializer(many=True)
class Meta:
model = Order
fields = ['id', 'total_amount', 'items']
그렇기 때문에 Serializer를 사용하는 대신, ModelSerializer를 사용해 모델 변경 시 자동 반영되어 스키마를 안정적으로 유지하는 방법을 사용할 수 있습니다.
ModelSerializer의 장점은 다음과 같습니다.
- 직관적으로 모델에 기반한 코드를 통해 직렬화에 용이합니다.
- ViewSet과 잘 어울린다는 장점을 통해 CRUD 구현에 매우 용이합니다.
모델 구조와 일치되는 스키마 유지가 가능하며, CRUD 작업 중심의 API 인 경우 매우 유용하다는 장점이 있습니다.
하지만 이 역시 조회의 경우, 자동 최적화 기능이 없기 때문에 위의 SerializerMethodField 처럼 Prefetch 해야합니다.
3. JSONObject
위에서 기본적인 Prefetch 방식과 다른 방식입니다.
get_queryset을 통하는 View의 레벨에서 JSONObject 로 간단하게 스키마를 정의할 수 있습니다.
class OrderViewSet(viewsets.ModelViewSet):
serializer_class = OrderSerializer
def get_queryset(self):
return Order.objects.annotate(
total_amount=Sum(F('items__price') * F('items__quantity')),
items=JSONObject(
id=F('items__id'),
name=F('items__product__name'),
quantity=F('items__quantity')
)
)
class OrderSerializer(serializers.ModelSerializer):
class Meta:
model = Order
fields = ['id', 'total_amount', 'items']
기능상으로는 동일한 결과물을 나타냅니다.
하지만 내부적으로 보면, prefetch_related로 캐시해도 메모리에서 추가 처리 필요하던 부분을 DB 레벨에서 명확한 구조로 Output 하기 때문에 복잡한 집계/계산 필요한 부분에 컴퓨팅 효율적인 개발 방식입니다.
추가적으로 Serializer 타입까지 정의해준다면 Swagger가 이를 정확하게 문서화할 수 있습니다.
4. JsonField와 JSONObject 활용
앞서 JSONObject를 통해 DB 레벨에서 JSON 구조를 만드는 방법을 살펴보았습니다.
하지만 실제 프로덕션 환경에서는 이미 JsonField에 저장된 데이터를 효율적으로 조회하고 필터링해야 하는 경우가 많을 수도 있습니다.
4.1 JsonField를 활용한 모델 설계
다른 관계형 모델이 아닌, 유동적인 데이터를 저장해야 하는 경우 JsonField가 유용할 수도 있습니다.
class OrderItem(models.Model):
...
options = models.JSONField(default=dict) # 상품 옵션 정보
shipping_info = models.JSONField(default=dict) # 배송 관련 정보
내부적인 데이터는 언제든지 바뀔수있으며 예시는 아래와 같습니다.
# options 예시
{
"size": "XL",
"color": "Navy",
"material": "Cotton"
}
# shipping_info 예시
{
"method": "express",
"tracking_number": "1234567890",
"estimated_delivery": "2024-01-30",
"special_instructions": "부재시 경비실"
}
4.2 KeyTextTransform을 활용한 쿼리 최적화
JSONField의 데이터를 조회 시에 활용하는 방법도 있습니다.
일반적으로 Serializer를 잘 정의해둬도 응답으로 사용할 수는 있습니다.
하지만 최적화된 데이터 추출이 필요하거나, 추출된 필드 기반으로 필터링이나 정렬이 필요한 경우, KeyTextTransform을 활용하면 DB 레벨에서 효율적으로 처리할 수 있습니다.
...
from django.db.models.functions import ExtractValue
from django.db.models.functions.json import KeyTextTransform
class OrderViewSet(viewsets.ModelViewSet):
def get_queryset(self):
# shipping_method로 필터링
queryset = OrderItem.objects.annotate(
shipping_method=KeyTextTransform('method', 'shipping_info')
).filter(shipping_method='express')
# size와 color를 기준으로 정렬
queryset = queryset.annotate(
option_size=KeyTextTransform('size', 'options'),
option_color=KeyTextTransform('color', 'options')
).order_by('option_size', 'option_color')
return queryset
KeyTextTransform 덕분에 JSON 데이터를 읽고 특정 Key 값을 파싱할 수 있어, 높은 활용도를 가짐을 새삼 느꼈습니다.
최근에 집계에 대한 부분에서 반드시 필요한 경우가 있어서 사용했는데, 매우 유용했습니다.
4.3 중첩된 JSON 데이터
JSON 으로 이뤄진 데이터 내에 또다른 JSON 데이터가 중첩된 경우, 여러 KeyTextTransform을 체이닝하여 처리할 수 있습니다.
"""예시
{
"details": {
"manufacturer": {
"name": "Company A",
"country": "Korea"
}
}
}
"""
queryset = OrderItem.objects.annotate(
manufacturer_country=KeyTextTransform(
'country',
KeyTextTransform( # 중첩 가능
'manufacturer',
KeyTextTransform('details', 'options')
)
)
).filter(manufacturer_country='Korea')
5. JSONField 업데이트 전략
JSONField는 JSONObject를 통해 Update가 가능합니다.
JSONField를 업데이트할 때는 데이터의 일관성과 성능을 모두 고려해야 합니다. 특히 대량의 레코드를 처리할 때는 더욱 신중한 접근이 필요합니다.
5.1 기본 업데이트
가장 기본적인 방법으로, JSON 필드의 특정 키만 업데이트하는 경우입니다
OrderItem.objects.filter(id=1).update(
options=JSONObject(
size=F('options__size'),# 유지됨
material=F('options__material'),# 유지됨
color='Blue',# 새로운 값으로 업데이트
...
)
)
주의점은 모든 필드를 명시적으로 표현해야하며, 포함되지 않은 키는 삭제된다는 것 입니다.
SQL 로는 jsonb_build_object()
를 통해서 새로운 JSON 객체 생성하는 것과 동일합니다.
>>
는 JSON 객체에서 키를 통해 텍스트를 추출 합니다.(>
는 JSON 객체에서 키를 통해 JSON 객체를 추출합니다.)- depth 가 복잡한 구조의 경우, JSONObject를 중첩하여 사용하면 됩니다.
UPDATE "order_item"
SET "options" = jsonb_build_object(
'size', "order_item"."options"->>'size',
'color', 'Blue',
'material', "order_item"."options"->>'material'
)
WHERE "order_item"."id" = 1;
5.2 업데이트 일괄처리 함수
실제 bulk_update는 아닙니다.
구조적으로 JSON을 가지고 있는 모델 특성 상, 업데이트는 실제로 필드의 업데이트 입니다.
메모리 레벨에서 미리 필터링 된 queryset에 업데이트를 하려는 경우, 아래 함수를 응용한다면 깔끔한 코드로 활용이 가능합니다.
@transaction.atomic
def bulk_update_json_field(queryset, updates):
"""
효율적인 벌크 업데이트를 위한 함수
:param queryset: 업데이트할 queryset
:param updates: 업데이트할 내용을 별도로 입력하여 구분.
"""
updated_count = queryset.update(
options=JSONObject(
**{
'size': F('options__size'),
'material': F('options__material'),
**updates # 새로운 값으로 업데이트할 필드
}
)
)
return updated_count
# 사용 예시
items_to_update = OrderItem.objects.filter(Q(options__color='Red'))
updates = {'color': 'Blue', 'updated_at': '2025-01-01'}
updated_count = bulk_update_json_field(items_to_update, updates)
이렇게 JsonField와 KeyTextTransform을 활용하면, 유연한 데이터 구조를 가지면서도 DB 레벨에서 효율적인 쿼리 처리가 가능합니다.
특히 마이크로서비스 아키텍처에서 서비스 간 데이터 구조가 자주 변경되는 경우나, 동적인 필드가 많은 경우에 유용하게 활용할 수 있을 것 같습니다.
그럼에도 저는 JsonField가 반드시 필요한 경우가 아니라면, 자주 사용되는 중첩 데이터는 별도의 모델로 정규화하는 것을 고려해야 한다고 생각합니다.
JsonField가 주는 기능 구조적인 확장성이 관계형 구조의 의의를 넘어서는 것이 좋지 않다고 생각하기 때문입니다.