본문 바로가기
IT CHANNEL/Python

[Django] data 순서 필드(row number)를 위한 ORM(Window expression),admin에서 cached queryset 구현

by TitanX 2023. 8. 20.

다음과 같은 상황이 있다

 

  • 게시물 리스트 api 가 필요하다.(pagination 적용)
  • 데이터 기본 정렬 규칙은 최신데이터 순이다.
  • 앞단에서 데이터 옆에 번호가 보여져야 한다.
    • 사실 여기까지만 필요하다고 하면, 페이지네이션과 결합해 프론트만으로도 구현이 가능하지 않을까 싶었다.
  • 백오피스(django admin)에서 해당 모델의 각 데이터 옆에 똑같은 순서로 번호가 보여져야 한다.
  • 데이터에 제약조건들이 있다.(여러가지가 있지만 편의상 노출/비노출 여부 만 적용)

 

 

 

데이터 순서 부여에 대한 의견.

 

리스트형태의 api에서는 restful한 개발을 위해 id를 포함해서 데이터를 내려주는 것이 좋다고 생각하지만

기본적으로 존재하는 id(pk) 외에 데이터의 번호를 부여해야 상황이 생겼다.

id는 데이터가 생성됨에 따라 부여되는 숫자이므로, 실제 view단(api or backoffice 어디든)에서 id를 데이터의 번호로 사용하기에는 적절하지 않을 수 있다.

 

 

떠올린 방법들.

  • model 에 순서 관련 필드를 만들어 데이터가 수정/삭제 등의 이벤트가 일어날 때 마다, 순서를 목적으로 만든 필드를 동기화 시키는 것이다.
    • api 와 admin 같은 방식으로 보여질 것이라 보여지는 것에 대한 로직은 구현할 필요가 없어짐.
    • 예외의 경우가 발생할 확률이 크다.
    • 이벤트에 대한 조건에 대한 변경이 발생할 경우가 농후함.
    • 따라서 사용하지 않기로 한다.
  • queryset annotation(채택)
    • api와 admin 각각 다르게 구현하고 보여지게 만들어야 한다.
    • 새로운 field를 만들지 않고 이벤트로 데이터를 변경하지 않고 orm으로 가상의 field를 만들어낸다.
    • 데이터를 변경하지 않아도 됨에 따라 리스크가 생기지 않는다.

 

결과적으로

api에서 번호가 부여된 각 데이터가 보여져야하고, api 와 동일한 번호로 admin 도 관리가 되어야함.

  • api를 위해 views 의 annotate를 활용하기
  • admin 을 위해 list_display 에 보여질 숫자 함수로 정의하기

코드로 보면 다음과 같다.

 

number는 SomeModel 에 없는 가상의 field 이다.

# serializer.py
from rest_framework import serializers

from app.ir.models import Ir, IrFile

class SomeModelSerializer(serializers.ModelSerializer):
    number = serializers.IntegerField(help_text="순서")

    class Meta:
        model = Ir
        fields = [
            "id",
            "number",
            "title",
            "created_at",
        ]

api

정렬과 필터링이 필요하다면 적용하고

내장된 Window 클래스와 RowNumber로 해당 데이터에 대한 순서 자체를 내보낸다.

from django.db.models import Window
from django.db.models.functions import RowNumber
from drf_spectacular.utils import extend_schema, extend_schema_view
from rest_framework import mixins
from rest_framework.pagination import LimitOffsetPagination
from rest_framework.viewsets import GenericViewSet

@extend_schema_view(
    list=extend_schema(summary="SomeModel 목록 조회"),
    retrieve=extend_schema(summary="SomeModel 상세 조회"),
)
class IrViewSet(
    mixins.ListModelMixin,
    mixins.RetrieveModelMixin,
    GenericViewSet,
):
    queryset = SomeModel.objects.all()
    serializer_class = SomeModelSerializer
    permission_classes = [SomeModelPermission]
    pagination_class = LimitOffsetPagination
    filter_class = SomeModelFilter

    def get_queryset(self):
        queryset = super().get_queryset()
        queryset = queryset.filter(is_exposure=True).annotate(
            number=Window(expression=RowNumber(), order_by="created_at")
        )
        return queryset

admin

_get_number_queryset 에서 위와 동일한 순서로 정렬된 queryset에서 id 별로 mapping 하기 위한 준비를 한다.

클래스 내부에서 새로운 객체 _cached_number_queryset 에 담아둔다.

 

이렇게하면 매번 query를 요청할 필요 없이, 캐싱해둔 queryset을 가져올 수 있다.

 

그러나

새로고침을 해도 데이터 변경 시 순서가 올바르게 적용되지 않는 문제를 발견했다.

 

데이터 저장과 삭제가 이뤄질때 마다 순서에 대해

save_model 과 delete_queryset 함수에서도 올바른 순서가 보여질 수 있도록 했다.

from django.contrib import admin

@admin.register(SomeModel)
class SomeModelAdmin(admin.ModelAdmin):
    list_display = [
        "id",
        "number",
        "is_exposure",
        "title",
        "created_at",
    ]

    def _get_number_queryset(self):
        check = {
            id: idx for idx, id in enumerate(SomeModel.objects.filter(is_exposure=True).values_list("id", flat=True)[::-1], 1)
        }
        return check

    @admin.display(description="순서")
    def number(self, obj):
        if not hasattr(self, "_cached_number_queryset"):
            self._cached_number_queryset = self._get_number_queryset()
        return self._cached_number_queryset.get(obj.id, None)

    def save_model(self, request, obj, form, change):
        super().save_model(request, obj, form, change)
        self._cached_number_queryset = self._get_number_queryset()

    def delete_queryset(self, request, queryset):
        super().delete_queryset(request, queryset)
        self._cached_number_queryset = self._get_number_queryset()