Django | 정렬 뽀개기! order_by와 annotate, extra 활용

Sua·2021년 2월 28일
3

Django

목록 보기
19/23
post-thumbnail
post-custom-banner

오늘의 집에는 다양한 정렬 기준이 있는데 장고로 최신순(+오래된순), 낮은가격순, 높은 가격순, 많은리뷰순 정렬을 시도해보도록 하자.

쿼리 파라미터로 조건 받아오기

정렬 조건을 쿼리 파라미터 형식으로 받을 것이다.

class ProductView(View):
    def get(self, request):
        order_condition = request.GET.get('order', None)

'order'이라는 key로 쿼리 파라미터의 값을 받아와 order_condition이라는 변수에 할당한다.
예를 들어, 쿼리 파라미터로 order=recent가 전달된다면 order_condition에는 recent가 담겨 있는 셈.

쿼리 파라미터 key value 기준

프론트와 소통하기 위한 값들이며 변수명을 짓듯 정하면 된다.
Key : order
Value : recent(최신순), old(오래된 순), min_price(낮은가격순), max_price(높은가격순), review(많은리뷰순)
예시) http://ip주소:8000/products?order=recent 이면 상품을 최신순으로 정렬

단계별 정렬

정렬의 난이도는 정렬하고자 하는 테이블에 정렬 기준이 있느냐에 따라 갈린다. 만약 해당 테이블에 정렬 기준인 컬럼이 있으면 그냥 갖다 쓰면 된다. 하지만 아닐 경우 다른 테이블에서 참조해오거나 값을 계산해주어야 한다.

1단계. 최신순 & 오래된순

최신순은 정렬하고자 하는 테이블인 product에 정렬 기준인 created_at 컬럼이 있는 경우다.

if order_condition == 'recent':
    products = Product.objects.order_by('created_at')

반대로 오래된순의 경우 created_at 앞에 -를 붙여주면 된다.

if order_condition == 'old':
    products = products.order_by('-created_at')

2단계. 리뷰많은순(annotate)

최신순과 오래된순의 경우 상품의 등록일자인 created_at가 product 테이블에 있어 쉽게 정렬을 할 수 있었다. 하지만 리뷰많은순으로 정렬하기 위해 필요한 리뷰 정보는 다른 테이블에 있다. Product와 ProductReview는 One-to-many의 관계이다. 다시 말해 ProductReview 테이블이 fk로 Product 테이블을 참조하고 있다.

따라서, Product에서 ProductReview의 값을 가져오기 위해서는 역참조를 해야한다. 게다가 우리가 원하는 것은 어떤 상품에 대한 리뷰의 '개수'이다. 정리하면 우리는 ForeignKey 필드의 Count를 기준으로 Order by 해야 한다.

이때 사용할 수 있는 것이 annotate()이다.

annotate()는 필드 하나를 만들고 거기에 '어떤 내용'을 채우게 만드는 것이다. 엑셀에서 컬럼 하나를 만드는 것과 같다고 보면 된다. 내용에는 1. 다른 필드의 값을 그대로 복사하거나, 2. 다른 필드의 값들을 조합한 값을 넣을 수 있다.

if order_condition == 'review':
    products = products.annotate(review_count=Count('productreview')).order_by('-review_count')

코드를 하나씩 뜯어가며 설명해보겠다.

  1. annotate(review_count=Count('productreview'))

Count('productreview')는 각 상품의 리뷰의 개수가 담겨져 있는 새로운 필드를 만든다는 뜻이다. 그리고 review_count라는 이름을 붙여준다.
참고로 'productreview'에 언더바가 없는 이유는 역참조하는 클래스명을 (소문자로) 적은 것이기 때문이다.

  1. order_by('-review_count')

annotate에서 정의한 review_count를 기준으로 정렬한다. 단, 리뷰가 많은 순이므로 내림차순이다. -를 붙여준다.

3단계. 낮은가격순 & 높은가격순(extra)

정렬하고자 하는 가격의 기준은 할인가격이다. 하지만 내가 가지고 있는 정보는 원래 가격(original_price)와 할인율(discount_rate)이다. 원래 가격과 할인율로 할인가격을 구하기 위해서는 다음과 같은 수식이 필요하다.

discount_price = original_price * (100 - discount_percentage) / 100

annotate()로 구현하려고 시도했으나, annotate()는 Sum, Count, Avg 등의 기본적인 수식계산만 지원하지 할인가격을 계산하는 수식을 적용할 수 없다.

여기서는 extra()를 사용하면 된다. extra()는 메인 쿼리에 sql문을 추가 반영할 수 있는 메소드이다.

if order_condition == 'min_price':
    products = products.extra(
        select={'discount_price': 'original_price * (100 - discount_percentage) / 100'}).order_by(
        'discount_price')

if order_condition == 'max_price':
    products = products.extra(
        select={'discount_price': 'original_price * (100 - discount_percentage) / 100'}).order_by(
        '-discount_price')

장고에서 복잡한 WHERE 절을 표현하기 쉽지 않은 경우 extra()를 사용한다. 하지만 장고 공식문서에서는 QuerySet으로 표현이 안되는 최후의 경우에만 사용하는 것을 권고한다. 다른 데이터베이스 엔진으로 변경될 때 SQL 코드가 명시적으로 작성된 것이 아니기 때문에 이동할 수 없을지도 모르기 때문이다.
https://hyunalee.tistory.com/23

중복 코드 제거하기

모든 정렬 조건들을 합친 코드이다.

class ProductView(View):
    def get(self, request):
        order_condition = request.GET.get('order', None)

        if order_condition == 'recent':
            products = Product.objects.order_by('created_at')
    
        if order_condition == 'old':
            products = products.order_by('-created_at')
     
        if order_condition == 'min_price':
            products = products.extra(
                select={'discount_price': 'original_price * (100 - discount_percentage) / 100'}).order_by(
                'discount_price')
    
        if order_condition == 'max_price':
            products = products.extra(
                select={'discount_price': 'original_price * (100 - discount_percentage) / 100'}).order_by(
                '-discount_price')
    
        if order_condition == 'review':
            products = Product.objects.annotate(review_count=Count('productreview')).order_by('-review_count')

하지만 여기서 문제는 비슷한 코드가 반복되고 있다는 점이다. 이것은 DRY(Dont' repeat yourself) 코드 내에서 똑같은 일을 두번하지 않는다는 소프트웨어 규칙을 위반하는 것이다. 반복되는 부분을 변수화하고, 딕셔너리를 만들어서 중복 제거를 해보자.

class ProductView(View):
    def get(self, request):
        order_condition = request.GET.get('order', None)
        
        order_by_time = {'recent' : 'created_at', 'old' : '-created_at'}
        order_by_price = {'min_price' : 'discount_price', 'max_price' : '-discount_price'}

        if order_condition in order_by_time:
            products = Product.objects.order_by(order_by_time[order_condition])

        if order_condition in order_by_price:
            products = products.extra(
                select={'discount_price': 'original_price * (100 - discount_percentage) / 100'}).order_by(
                    order_by_price[order_condition])

        if order_condition == 'review':
            products = products.annotate(review_count=Count('productreview')).order_by('-review_count')

[참고] 파이썬 sorted와 lambda를 이용한 정렬

order_by를 이용한 정렬은 데이터를 데이터베이스에서 불러올 때부터 정렬을 해서 가져오는 방법이다. 반면 데이터를 모두 불러온 뒤에 파이썬 sorted 메소드를 이용해 정렬해주는 방식도 있다. 참고로만 알아두고, order_by로 정렬하도록 하자.

products_list = [{
    'id': product.id,
    'name': product.name,
    'discount_percentage': int(product.discount_percentage),
    'discount_price': int(product.original_price) * (100 - int(product.discount_percentage)) // 100,
    'company': product.company.name,
    'image': product.productimage_set.first().image_url,
    'rate_average': round(product.productreview_set.aggregate(Avg('rate'))['rate__avg'], 1) \
        if product.productreview_set.aggregate(Avg('rate'))['rate__avg'] else 0,
    'review_count': product.productreview_set.count(),
    'is_free_delivery': product.delivery.fee.price == 0,
    'is_on_sale': not (int(product.discount_percentage) == 0),
    } for product in products
]

if order_condtion == 'recent':
    product_list = sorted(product_list, key=lambda product: product['created_at'], reverse=True)

if order_condtion == 'old':
    product_list = sorted(product_list, key=lambda product: product['created_at'])

if order_condtion == 'min_price':
    product_list = sorted(product_list, key=lambda product: product['discount_price'])

if order_condtion == 'max_price':
    product_list = sorted(product_list, key=lambda product: product['discount_price'], reverse=True)

if order_condtion == 'review':
    product_list = sorted(product_list, key=lambda product: product['review_count'], reverse=True)

참고사이트
https://able.bio/rhett/how-to-order-by-count-of-a-foreignkey-field-in-django--26y1ug1
https://velog.io/@magnoliarfsit/ReDjango-8.-QuerySet-Method-2
https://medium.com/@kimkkikki1/django-orm-extra-query-1e7010316317
https://hyunalee.tistory.com/23
https://micropyramid.medium.com/how-to-filter-a-django-queryset-using-extra-da6dea41b403

profile
Leave your comfort zone
post-custom-banner

1개의 댓글