[drf] 장고 ORM N+1 문제 해결

최동혁·2023년 5월 9일
0

DRF

목록 보기
13/19
post-thumbnail

트러블

N + 1 문제가 발생한 이유

views.py

class ProductPagination(pagination.CursorPagination):
    page_size = 10
    ordering = "-created_at"
    cursor_query_param = "cursor"

    def get_paginated_response(self, data):
        if self.get_previous_link() is None:
            return Response(
                {
                    "meta": {"code": 301, "message": "OK"},
                    "data": {
                        "next": self.get_next_link(),
                        "previous": self.get_previous_link(),
                        "products": data,
                    },
                },
                status=status.HTTP_301_MOVED_PERMANENTLY,
            )
        else:
            return Response(
                {
                    "meta": {"code": 200, "message": "OK"},
                    "data": {
                        "next": self.get_next_link(),
                        "previous": self.get_previous_link(),
                        "products": data,
                    },
                },
                status=status.HTTP_200_OK,
            )

class ProductViewSet(viewsets.ModelViewSet):
    queryset = Product.objects.all()
    serializer_class = ProductSerializer
    permission_classes = [IsOwner]
    pagination_class = ProductPagination

    def list(self, request, *args, **kwargs):
        queryset = Product.objects.filter(user=request.user.pk)
        name = self.request.query_params.get("name", None)
        global result
        if name:
            initial_sound_str = get_initial_sound_list(name)
            queryset = Product.objects.filter(
                name_initial__icontains=initial_sound_str, user=request.user.pk
            )

        page = self.paginate_queryset(queryset)

        if len(queryset) > 10:
            serializer = self.get_serializer(page, many=True)
            return self.get_paginated_response(serializer.data)
        elif len(queryset) == 0:
            result["meta"]["code"] = status.HTTP_200_OK
            result["meta"]["message"] = "검색한 결과가 없습니다."
            result["data"] = "null"
            return Response(result, status=status.HTTP_200_OK)
        else:
            serializer = self.get_serializer(queryset, many=True)
            result["meta"]["code"] = status.HTTP_200_OK
            result["meta"]["message"] = "ok"
            result["data"] = serializer.data
            return Response(result, status=status.HTTP_200_OK)
  • 장고는 기본적으로 Lazy-loading 이기 때문에, 당장 해당 쿼리셋을 사용하지 않으면 쿼리문을 호출하지 않는다.
  • 사용자는 해당 쿼리셋을 불러온줄 알고 객체에 쿼리셋을 저장한 후, 재사용을 하지만, foreign key로 이어져 있는 모델을 부를 때 해당 데이터의 수만큼 N번 더 호출하게 된다.
  • 그래서 원래 1번 쿼리문이 수행되는걸 N + 1번만큼 쿼리문이 호출이 된다.
  • 위의 코드도 마찬가지이다.
  • 기본 default queryset을 Product.objects.all()을 해서 Lazy-loading 으로 불러오기 때문이다.
  • 이렇게 불러오면 캐싱이 되지 않기 때문에 재사용을 하더라도 쓸데없이 N번 더 부르게 된다.
  • 그래서 우리는 캐싱을 하기 위해 Lazy-loading이 아닌 Eager-loading으로 ORM을 사용한 순간 쿼리문을 날릴 것이다.
  • Eager Loading을 하게 도와주는 method는 select_realated()와 prefetch_related()가 있다.
  • 보통 foreignkey나 OneToOneField로 연결된 객체를 가져올때에는 select_related, ManyToMany는 prefetch_related를 사용한다.
  • select_related는 join문을 이용해 쿼리문을 날리고, prefetch_related는 추가 쿼리문을 하나 더 날려준다.

views.py 수정 전 N + 1 문제 쿼리문

(0.000) SELECT `account_user`.`id`, `account_user`.`password`, `account_user`.`last_login`, `account_user`.`phone_number`, `account_user`.`is_active`, `account_user`.`is_admin`, `account_user`.`is_staff` FROM `account_user` WHERE `account_user`.`id` = 1 LIMIT 21; args=(1,); alias=default
2023-05-09T11:48:20.482725101Z (0.001) SELECT `product_product`.`id`, `product_product`.`user_id`, `product_product`.`category_id`, `product_product`.`price`, `product_product`.`cost`, `product_product`.`name`, `product_product`.`name_initial`, `product_product`.`description`, `product_product`.`barcode`, `product_product`.`expiration_date`, `product_product`.`size`, `product_product`.`created_at`, `product_product`.`updated_at`, `account_user`.`id`, `account_user`.`password`, `account_user`.`last_login`, `account_user`.`phone_number`, `account_user`.`is_active`, `account_user`.`is_admin`, `account_user`.`is_staff` FROM `product_product` INNER JOIN `account_user` ON (`product_product`.`user_id` = `account_user`.`id`) ORDER BY `product_product`.`created_at` DESC LIMIT 11; args=(); alias=default
2023-05-09T11:48:20.484608852Z (0.000) SELECT `product_product`.`id`, `product_product`.`user_id`, `product_product`.`category_id`, `product_product`.`price`, `product_product`.`cost`, `product_product`.`name`, `product_product`.`name_initial`, `product_product`.`description`, `product_product`.`barcode`, `product_product`.`expiration_date`, `product_product`.`size`, `product_product`.`created_at`, `product_product`.`updated_at`, `account_user`.`id`, `account_user`.`password`, `account_user`.`last_login`, `account_user`.`phone_number`, `account_user`.`is_active`, `account_user`.`is_admin`, `account_user`.`is_staff` FROM `product_product` INNER JOIN `account_user` ON (`product_product`.`user_id` = `account_user`.`id`); args=(); alias=default
2023-05-09T11:48:20.488192321Z (0.000) SELECT `product_category`.`id`, `product_category`.`name` FROM `product_category` WHERE `product_category`.`id` = 1 LIMIT 21; args=(1,); alias=default
2023-05-09T11:48:20.489372298Z (0.000) SELECT `product_category`.`id`, `product_category`.`name` FROM `product_category` WHERE `product_category`.`id` = 1 LIMIT 21; args=(1,); alias=default
2023-05-09T11:48:20.490560462Z (0.000) SELECT `product_category`.`id`, `product_category`.`name` FROM `product_category` WHERE `product_category`.`id` = 1 LIMIT 21; args=(1,); alias=default
2023-05-09T11:48:20.491831032Z (0.000) SELECT `product_category`.`id`, `product_category`.`name` FROM `product_category` WHERE `product_category`.`id` = 1 LIMIT 21; args=(1,); alias=default
2023-05-09T11:48:20.492950213Z (0.000) SELECT `product_category`.`id`, `product_category`.`name` FROM `product_category` WHERE `product_category`.`id` = 1 LIMIT 21; args=(1,); alias=default
2023-05-09T11:48:20.494140275Z (0.000) SELECT `product_category`.`id`, `product_category`.`name` FROM `product_category` WHERE `product_category`.`id` = 1 LIMIT 21; args=(1,); alias=default
2023-05-09T11:48:20.495227131Z (0.000) SELECT `product_category`.`id`, `product_category`.`name` FROM `product_category` WHERE `product_category`.`id` = 1 LIMIT 21; args=(1,); alias=default
2023-05-09T11:48:20.496222763Z (0.000) SELECT `product_category`.`id`, `product_category`.`name` FROM `product_category` WHERE `product_category`.`id` = 1 LIMIT 21; args=(1,); alias=default
2023-05-09T11:48:20.497243875Z (0.000) SELECT `product_category`.`id`, `product_category`.`name` FROM `product_category` WHERE `product_category`.`id` = 1 LIMIT 21; args=(1,); alias=default
2023-05-09T11:48:20.498436013Z (0.000) SELECT `product_category`.`id`, `product_category`.`name` FROM `product_category` WHERE `product_category`.`id` = 1 LIMIT 21; args=(1,); alias=default
  • 로직을 생각해보면 category에 해당하는 객체도 product 쿼리셋을 불러올 때 같이 캐싱해서 다시 쿼리문을 안 불러야 한다.
  • 하지만 위에서 Lazy-loading으로 데이터를 불러오기 때문에 N(10번) + 1번의 쿼리문을 날리는 문제가 발생한다.

views.py(수정)

class ProductViewSet(viewsets.ModelViewSet):
    queryset = Product.objects.all().select_related('user', 'category')
    serializer_class = ProductSerializer
    permission_classes = [IsOwner]
    pagination_class = ProductPagination
  • select_related를 이용해 기본 queryset을 user와 category를 JOIN해서 eager loading으로 캐싱해준다.
  • 그 후, 해당 쿼리셋을 재사용하게 되더라도 위에서 처럼 쓸데없는 쿼리문을 날리지 않고, 저장되어 있는 객체들을 이용해 처리하게 된다.

views.py 수정 후 쿼리문

(0.000) SELECT `account_user`.`id`, `account_user`.`password`, `account_user`.`last_login`, `account_user`.`phone_number`, `account_user`.`is_active`, `account_user`.`is_admin`, `account_user`.`is_staff` FROM `account_user` WHERE `account_user`.`id` = 1 LIMIT 21; args=(1,); alias=default
2023-05-09T11:57:19.378191181Z (0.001) SELECT `product_product`.`id`, `product_product`.`user_id`, `product_product`.`category_id`, `product_product`.`price`, `product_product`.`cost`, `product_product`.`name`, `product_product`.`name_initial`, `product_product`.`description`, `product_product`.`barcode`, `product_product`.`expiration_date`, `product_product`.`size`, `product_product`.`created_at`, `product_product`.`updated_at`, `account_user`.`id`, `account_user`.`password`, `account_user`.`last_login`, `account_user`.`phone_number`, `account_user`.`is_active`, `account_user`.`is_admin`, `account_user`.`is_staff`, `product_category`.`id`, `product_category`.`name` FROM `product_product` INNER JOIN `account_user` ON (`product_product`.`user_id` = `account_user`.`id`) INNER JOIN `product_category` ON (`product_product`.`category_id` = `product_category`.`id`) ORDER BY `product_product`.`created_at` DESC LIMIT 11; args=(); alias=default
2023-05-09T11:57:19.380561924Z (0.000) SELECT `product_product`.`id`, `product_product`.`user_id`, `product_product`.`category_id`, `product_product`.`price`, `product_product`.`cost`, `product_product`.`name`, `product_product`.`name_initial`, `product_product`.`description`, `product_product`.`barcode`, `product_product`.`expiration_date`, `product_product`.`size`, `product_product`.`created_at`, `product_product`.`updated_at`, `account_user`.`id`, `account_user`.`password`, `account_user`.`last_login`, `account_user`.`phone_number`, `account_user`.`is_active`, `account_user`.`is_admin`, `account_user`.`is_staff`, `product_category`.`id`, `product_category`.`name` FROM `product_product` INNER JOIN `account_user` ON (`product_product`.`user_id` = `account_user`.`id`) INNER JOIN `product_category` ON (`product_product`.`category_id` = `product_category`.`id`); args=(); alias=default
  • 각 데이터마다 카테고리를 불러오는 쿼리문이 사라진것을 확인할 수 있다.
  • 이유는 가장 위에 default 쿼리셋을 세팅할 때 category 모델에 관한 객체를 쿼리셋에 같이 저장한 것이 아닌, category의 id만 저장하기 때문에 데이터를 보여줄 때 category 객체를 보여주기 위해서는 쿼리문을 계속해서 날려야 했다.
  • 하지만 수정된 views.py에서는 category를 join해서 객체를 같이 저장하기 때문에 더이상의 쿼리문을 날리지 않게 된다.
profile
항상 성장하는 개발자 최동혁입니다.

0개의 댓글