1. 혼자 만들어보는 Zara - Q & ORM최적화

혼자 만들어보는 Zara - Q & ORM최적화


1. 구현 부분

Zara에서 여성-코트|트렌치코트를 눌렀을 때 조회되는 리스트에 대한 API입니다.

필터링 부분의 경우 사이즈, 가격, 아이템, 색상에 대해 구현했습니다.
패션쪽을 잘 몰라서 컬렉션과 신발/악세사리는 왜 있는지 아직도 이해가 안 가서
4가지 필터링에 대해서 우선적으로 했습니다.


2. models.py

class Item(TimeStampModel) :
    category = models.ForeignKey(Category, on_delete=models.CASCADE)
    name     = models.CharField(max_length=30)
            
class Product(TimeStampModel) :
    item  = models.ForeignKey(Item, on_delete=models.CASCADE)
    name  = models.CharField(max_length=30)
    price = models.PositiveIntegerField()

class Size(TimeStampModel) :
    size = models.CharField(max_length=20)

class Color(TimeStampModel) :
    color = models.CharField(max_length=20)
        
class DetailProduct(TimeStampModel) :
    product = models.ForeignKey(Product, on_delete=models.CASCADE)
    size    = models.ForeignKey(Size, on_delete=models.CASCADE)
    color   = models.ForeignKey(Color, on_delete=models.CASCADE)

코트를 눌렀을 때 나오는 대표 상품은 Product에 저장이 되고, 상품에 대한 세부정보는
DetailProduct에 저장되는 구조입니다.

참조관계
ProductItem을 정참조합니다.
DetailProductSize, Color, Product를 정참조합니다.
ProductDetailProduct를 역참조합니다.


2. views.py

    def get(self, request) :

        offset      = int(request.GET.get('offset', 0))
        limit       = int(request.GET.get('limit', 15))
        category_id = int(request.GET['category_id'])
        item_id     = request.GET.getlist('item_id', None)
        color_id    = request.GET.getlist('color_id', None)
        size_id     = request.GET.getlist('size_id', None)
        min_price   = request.GET.get('min_price', None)
        max_price   = request.GET.get('max_price', None)
        
        if limit > 20 :
            return JsonResponse({'message' : 'TOO_MUCH_LIST'}, status=400)
        
        product_filter = Q(item__category_id=category_id)
        
        if item_id :
            product_filter.add(Q(item__id__in = item_id), Q.AND)
        
        if color_id :
            product_filter.add(Q(detailproduct__color_id__in = color_id), Q.AND)
        
        if size_id : 
            product_filter.add(Q(detailproduct__size_id__in = size_id), Q.AND)
        
        if min_price and max_price :
            product_filter.add(Q(price__gte=min_price)&Q(price__lte=max_price), Q.AND)
        
        product_list = [{
                'id'        : product.id,
                'name'      : product.name,
                'price'     : product.price,
                'item_id'   : product.item.id,
                'item_name' : product.item.name,
                'thumbnail' : [
                    {
                        'id' : thumbnail.id,
                        'url' : thumbnail.url
                    } for thumbnail in product.thumbnail_set.all()],
                'detail_set' : [
                    {
                        'color_id' : detail.color_id,
                        'color_name' : detail.color.color,
                        'size_id' : detail.size_id,
                        'size_name' : detail.size.size
                    }
                for detail in product.detailproduct_set.all()]
            } for product in Product.objects.select_related('item').\
                prefetch_related('detailproduct_set', 'thumbnail_set').\
                filter(product_filter)[offset:offset+limit]
        ]
        
        return JsonResponse({'message' : product_list}, status=200)

2-1. Query Parameter 필터링

우선, 아래는 Query Parameter로 받아올 변수들입니다.

offset, limit, category_id, item_id, color_id, size_id, min_price, max_price

  1. offsetlimitPagination을 위해서 설정했고,
    아이템, 색상, 사이즈, 가격범위내 조회를 위해 나머지 변수를 설정했습니다.
    limit 제한의 경우, 프론트엔드에서 필요 이상의 데이터 요청을 막기 위해
    제한을 뒀습니다.

  2. 그리고 최초에는 코트라는 카테고리를 눌러야 하므로 제일 먼저 카테고리를 이용한
    필터링을 위해 product_filter에 담았습니다.

  3. 그리고 아이템, 색상, 사이즈, 가격은 선택할 수도 있고 안 할수도 있기 때문에
    조건문을 설정하여 선택했으면 넣어줄 수 있도록 했습니다.

  4. 그리고 아이템, 색상, 사이즈의 경우 다중선택이 가능합니다.
    그래서 상황에 따라 URL 뒤에 Query Parameter가 복잡해질 수 있습니다.
    예를 들면 아래와 같습니다.
    http://localhost:8000/products?category_id=2&item_id=1&item_id=2&color_id=1
    이렇게되면 아이템의 ID가 1, 2로 두 개를 선택하여 요청을 보내기 때문에
    리스트로 받아야 합니다. 그래서 다중선택 가능한 건 리스트로 받아야 한다고
    생각해서 request.GET.getlist를 사용하게 되었습니다.

  5. 그래서 Query Parameter로 받은 변수들이 있으면 필터조건에 계속 추가합니다.

  6. 필수 조건인 category_id의 경우, 카테고리를 눌러야 리스트가 나오기 때문에
    request.GET을 사용했습니다.
    필수값이기 때문에 카테고리ID를 URL에 Query Parameter로 입력하지 않을 시,
    KeyError가 발생합니다.


2-2. ORM 최적화와 N+1 Problem

이렇게 조건들을 다 선택하고 조건에 맞는 상품들을 조회해야 합니다.

Product.objects.select_related('item').\
                prefetch_related('detailproduct_set', 'thumbnail_set').\
                filter(product_filter)[offset:offset+limit]

ProductItem정참조하기 때문에 select_related를 사용했으며
DetailProductThumbnail역참조하기 때문에 prefetch_related를 사용했습니다.

다만, 고민해봐야 할 부분이 생겼습니다.

'detail_set' : [
    {
        'color_id' : detail.color_id,
        'color_name' : detail.color.color,
        'size_id' : detail.size_id,
        'size_name' : detail.size.size
    }

예를 들어 여기서 prefetch_related를 이용해 색상과 사이즈의 ID를 가져왔지만,
그것의 이름을 가져오려면 한 번 더 타고 들어가야해서 어쩔 수 없는 N+1 Problem이 발생합니다.

쿼리 로그를 찍어보고 이 부분에 대해 어떻게 해야 할지 고민하게 되었습니다.


내일은 데이터 셋을 불러오는 걸 모듈로 나눠서 views.py가 간단해질 수 있게,
어떻게보면 객체지향적인(?) 코드로 바꿀 예정입니다.

좋은 웹페이지 즐겨찾기