Product List API에서 꾸까의 사이트처럼 이름, 설명, 사이즈, 할인율, 가격을 맞춰 POST하고 GET하는 것이 구현목표라고 생각했습니다. 사실 POST는 빠져야 했는데 잘못 생각했었습니다. 제품을 등록하는 기능은 일반 유저가 하는 기능이 아니기 때문입니다!
제품 id에 해당하는 가격 처리 로직, 그리고 size에 해당하는 size 구현을 해야 했습니다. 그리고 꾸까 사이트에서 없지만 size별로 filtering과 created와 가격별로 sorting하는 것까지 생각하고 있었습니다. 그 다음 pagination을 추가까지..!
상품 리스트 API 코드의 초안은 다음과 같습니다.
urlpatterns = [
path("users", include('users.urls')),
path("products", include('products.urls')),
]
from django.urls import path
from .views import ListView
urlpatterns = [
# path("/list", ListView.as_view()),
path("", ListView.as_view()),
처음에는 /list
로 uri endpoint를 나타내었는데 RESTful API 약속대로/products
처럼 상위 endpoint로도 제품들 정보를 충분히 나타낼 수 있기에 ""
처리를 하였습니다.
import json, jwt
from django.views import View
from django.http import JsonResponse
from products.models import Product, ProductSize
from users.utils import login_decorator
# class ListView(View):
class ProductListView(View):
'''
@login_decorator
def post(self, request):
try:
data = json.loads(request.body)
name = data.get("name", "")
description = data.get("description", "")
thumbnail = data.get("thumbnail", "")
discount_rate = data.get("discount_rate", "")
Product.objects.create(
name = name,
description = description,
thumbnail = thumbnail,
discount_rate = discount_rate
)
return JsonResponse({"MESSAGE": "List Created!"}, status=201)
except KeyError:
return JsonResponse({"MESSAGE": "KEY_ERROR"}, status=400)
'''
def get(self, request):
products = Product.objects.filter()
productsize = ProductSize.objects.filter()
results = []
for product in products:
product_id = product.id
results.append(
{
"id" : product.id,
"name" : product.name,
"description" : product.description,
"thumbnail" : product.thumbnail,
"sizes" : product.sizes.all()[0].size,
"discount_rate" : product.discount_rate,
"price" : productsize[product_id].price
}
)
return JsonResponse({"lists" : results}, status = 200)
제품을 등록하는 기능은 일반 유저가 하는 기능이 아닌데, 그리고 만약에 등록이 가능하다해도 로그인 한 유저가 모두 등록하는 것이 아닌 특정 권한이 있는 유저만 등록해야 하는데 이 점을 간과했습니다. (admin에서 해야되는 API~)
필요없다고 생각이 들어 삭제했습니다.
해당 View가 어떤 자원에 대한 View인지를 명확히 나타낼 수 있는 class 명으로 바꾸었다.
추가로 product_size 정보는 for문 안에서 특정되는 product로 부터 가져오지 못했다... 내가 편하려고 하다가 한방 먹었습니다ㅋㅋ
그리고 result
에 결과를 append하는 것도 좋은 방법이지만 list comprehension으로 바꾸어 성능을 보완하고 더 직관적으로 보일 수 있겠끔 수정하였습니다.
틀린 코드이지만 price
를 ProductSize
를 통해서 끌고 오려고 했으며, result안에서 직접 가공하기에는 너무 길어질것 같다는 생각이 들어 변수를 result
바깥으로 전역변수로 설정하였는데 다음의 에러가 났습니다.
자유 변수가 둘러싸는 범위에서 할당하기 전에 참조되었다는데 처음 보는 에러였다. 결국 지역변수 내에서 productsize
를 선언해주었습니다.
이 때문에 product_size
정보는 for문안에서 특정되는 product로 부터 가져와야 했나 보다.
이렇게 나는 생각했고 결론적으로는 ProductSize로 끌고왔다.
지금까지 작성된 get API
에 추가로 filtering과 sorting을 if문으로 작성해보았습니다.
ordering
과 sorting
의 차이 그리고 filtering
의 차이가 헷갈렸는데요~ ordering은 단순히 주어진 데이터에 오름차순인지 내림차순인지 정의하는 변수이고, sorting은 그 기준이 어떤 컬럼인지 정의하는 변수로 "정렬"이라는 의미를 가지고 있습니다. 마지막으로 filtering은 원하는 조건으로 필터링한다는 의미라고 생각하면 될 것 같습니다.
# products.views
class ProductsView(View):
def get(self, request):
try:
ordering = request.GET.get('ordering', None)
sort = request.GET.get('sort', "id")
size = request.GET.get('size', None).split(',')
page = int(request.GET.get('page', 0))
limit = int(request.GET.get('limit', 8))
q = Q()
if size:
q &= Q(sizes__in=size)
sort_set = {
"id" : "id",
}
if ordering == 'recent':
results = Product.objects.order_by('-created_at')
results = [{
"id" : product.id,
"name" : product.name,
"description" : product.description,
"thumbnail" : product.thumbnail,
"sizes" : ProductSize.objects.filter(product=product).first().size.size,
"discount_rate" : float(product.discount_rate),
"price" : int(ProductSize.objects.filter(product=product).first().price),
"discount_price" : int(ProductSize.objects.filter(product=product).first().price) * float(product.discount_rate)
} for product in Product.objects.filter(q).distinct().order_by(sort_set[sort])[page:page+limit]]
if ordering == 'min_price':
results = sorted(results, key=lambda product: product['discount_price'])
if ordering == 'max_price':
results = sorted(results, key=lambda product: product['discount_price'], reverse=True)
return JsonResponse({"lists" : results}, status = 200)
except KeyError:
return JsonResponse({'message': 'KEY_ERROR'}, status=400)
q
객체를 사용하면 여러개의 조건으로 물고 물어서 조건을 여러개 선택할 수 있게 됩니다. 하지만 우리의 꾸까는 size만 filtering하는 조건밖에 없다. 사실 쓰기도 부끄러웠습니다.
ordering
, sort
, size
, offset
, limit
들은 쿼리 파라미터를 통해 값을 받을 수 있도록 지정해 준 변수들입니다. get을 통해 가져오는데 만약 값이 없을 경우 ("A", "B")의 구조에서 볼 수 있듯 B
를 가져옵니다.
위의 코드 구조에서 쿼리 파라미터는 다음과 같이 받을 수 있습니다.
http://0.0.0.0:8000/products?ordering=min_price&size=3&offset=2
http://0.0.0.0:8000/products?ordering=max_price&size=3&offset=2
http://0.0.0.0:8000/products?sort=old&size=3&offset=2
비효율적인 구조입니다! ordering 대신 sorting으로 하나의 조건으로 엮으면 되었지만 그 생각을 못해서 계속 해매었습니다. 멘토링을 통해 ordering을 잘 다듬어 sort값으로 넣었습니다.
참고로 술담화가 쿼리 파라미터가 잘 정리되어있습니다. (개발자도구 > 네트워크 > product에서 확인 가능)
if문을 쓸 때 pricing
이 result
보다 위에 있을 때 Operational Error
가 발생하였습니다.
if문 ordering에서 필드 리스트인 price 컬럼을 인식하지 못하였습니다. 결국 recent ordering
만 result위에 두고 price ordering
은 아래로 두게 되었습니다. 파이썬은 위에서 아래로 읽는데 price는 result
에서 정의되어있으니 이같은 결과를 냅니다.
sizes를 어떻게 가져올까 고민이 많이 들었습니다. 왜냐하면 모든 size를 가져오길 원했는데 해당되는 상품의 첫번째 size만 가져오기에 예를 들어 1호, 1호, 1호처럼 같은 사이즈만 표시되었기 때문입니다. size를 가져오다가 field에러를 만났고 다음의 코드로 수정하였습니다"sizes" : [size.size for size in product.sizes.all()]
모든 사이즈가 표시되도록 2중 for문을 돌렸습니다.
ProductList API는 다음의 구조를 가지는 것을 추천합니다.
다음의 구조를 지켜 만들어진 API입니다.
class ProductsView(View):
def get(self, request):
try:
# 1. 프론트로부터 받은 조건들 정의
#:8000/products?sort='recent'&size=0&offset=8
sort = request.GET.get('sort', "recent")
size = request.GET.get('size', 0)
offset = int(request.GET.get('offset', 0))
limit = int(request.GET.get('limit', 8))
# 2. 조건들 가지고 filter 요소 만듦
q = Q()
if size:
size = size.split(',')
q &= Q(sizes__in=size)
#3. sort_set
sort_set = {
'recent' : '-created_at',
'old' : 'created_at',
'expensive' : '-base_price',
'cheap' : 'base_price'
}
# 데이터 가공
products = Product.objects.annotate(base_price=Min('productsizes__price'))
results = [{
"id" : product.id,
"name" : product.name,
"description" : product.description,
"thumbnail" : product.thumbnail,
"sizes" : [size.size for size in product.sizes.all().order_by('id')],
"discount_rate" : float(product.discount_rate),
"price" : int(product.base_price),
"discount_price" : int(product.base_price * product.discount_rate)
} for product in products.filter(q).order_by(sort_set[sort])[offset:offset+limit]]
return JsonResponse({"lists" : results}, status = 200)
except KeyError:
return JsonResponse({'message': 'KEY_ERROR'}, status=400)
여기서 주목할 점은 데이터를 가공할 때 사용한 annotate
입니다. annotate
의 사전적 의미는 주석을 다는 것인데 django에서는 필드
를 추가한다(엑셀에서 컬럼 하나를 만드는 것)고 생각하면 쉽습니다. 따라서 가상의 필드인 base_price
를 만들고 그 컬럼으로 sort_set을 지정합니다.
products = Product.objects.annotate(base_price=Min('productsizes__price'))
위의 코드를 분석하면 base_price
를 productsizes라는 관계를 가진 이름의 클래스에서 price를 가져올 수 있습니다. 원래는 클래스 이름의 약자인 productsize
로 입력하지만 related_name
을 설정해주었으므로 productsizes
로 지정하고 그 안의 테이블인 price를 가져올 수 있게 됩니다.
size별로 가격이 다른데 가격 중 최소가격(Min
)을 base_price로 넣고 이를 마치 테이블처럼 만들어서 사용하기 위해 annotate
해줍니다.
이렇게 사용하면 if문을 사용하지 않고 sort_set 값을 넣어줄 수 있습니다. 해당 블로그를 참고 많이 하였습니다.
여기서 제품의 id값과 해당 제품과 연결되는 사이즈 id가 하나라도 연결되지 않는다면 list out of range
에러가 난다! 여기서 삽질했는데, 무조건 되어야 한다고 생각했는데 에러를 내뱉으니 답답했다....... 조심하자!
엄청 고민하고 해매었지만 결국 완성하고 말았다...!🔥