[SuperfluidClone] 2nd project - superfluid 클론: 프로젝트 회고록 (2) 기억에 남는 코드

Alex of the year 2020 & 2021·2020년 8월 17일
0
post-thumbnail

이번 2차 프로젝트에서는
크게 User, Product, Order로 앱을 나누어 구성하였으며
그 중 내가 작업했던

1) User앱 전체
2) Product앱 중 상품 ListView, FilterView

코드들에 대한 리뷰를 해보려 한다.

User

SignupView

import json
import jwt
import bcrypt
import re
import datetime
import requests

from django.views   import View
from django.http    import (
    HttpResponse, 
    JsonResponse
)

from .models        import User             
from .utils         import login_decorator
from order.models   import Order, OrderStatus
from product.models import Product

from my_settings    import (
    SECRET_KEY,
    ALGORITHM
)

from .models        import User             


class SignUpView(View):
    def post(self, request):
    #post로 받아야 하는 이유: User 정보를 DB에 저장할 것이므로
        try:
            data = json.loads(request.body)
            # json 형식으로 frontend로부터 온 request 중 body에 담겨져 온 부분을 data라는 변수에 저장

            if User.objects.filter(email=data['email']).exists():
                return JsonResponse({'message': 'EMAIL_ALREADY_EXISTS'}, status=400) 
                # 유저가 등록하고자 하는 이메일이 email이 이미 존재할 경우 400 error

            if '@' not in data['email']:
                return JsonResponse({'message': 'INVALID_EMAIL'}, status=401)
                # 유저가 등록하고자 하는 이메일에 @가 없을 경우 401 error

            if len(data['password']) < 8:
                return JsonResponse({'message': 'INVALID_PASSWORD'}, status=401)
                # 유저가 등록하고자 하는 password가 8자리 미만일 경우 401 error

            # 위의 조건에 걸리지 않은 경우에는 (else 굳이 쓰지 않아도 됨)
            User.objects.create(
                first_name  = data['first_name'],
                last_name   = data['last_name'],
                birthday    = datetime.datetime.strptime(data['birthday'], '%Y.%m.%d').date(),
                email       = data['email'],
                password    = bcrypt.hashpw(data['password'].encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
                )
                # json 형식의 data는 모두 dictionary형태로 오기 때문에 key값을 특정하는 할 때는 []를 사용.
                # first_name, last_name, birthday, email, password 모두 저장
                # birthday : data['birthday'] 값을 datetime 객체 중 strptime() 클래스 메서드를 사용하여 
                #            '1999.9.19' 형태로 받아(date_string) 1999/09/19(format) 형태로 저장함
                #            아래 사진을 참고하였음.
                # password : data['password'] 값을 utf-8 형태로 encode하여 byte화 시킴. 
                # 	     이후 bcrypt.hashpw를 이용하여 암호화 (gensalt를 뿌려서)
                #            맨 마지막 decode('utf-8')은 byte의 string화를 위해 필요.

            return JsonResponse ({'message':'SIGNUP_SUCCESS'}, status=200)

        except KeyError:
            return JsonResponse ({'message':'INVALID_KEY'}, status=401)
            

먼저 위 코드의 가장 먼저 보이는 단점은
정규표현식을 작성하지 않아 좀 더 세밀하고 정밀한 validation 구현이 어려웠다는 것이다.
정규표현식을 사용한 리팩토링이 필요해보인다.


SignInView

class SignInView(View):
    def post(self, request):
    # 이번에는 올바른 사용자인지 확인하여 토큰을 직접 발급해주어야하므로 post 메소드가 필요하다
        try:
            data = json.loads(request.body)
            # 역시 이번에도 front로부터 들어온 request의 body 부분을 json으로 받아 data에 저장하고

            if User.objects.filter(email=data['email']).exists():
                user = User.objects.get(email=data['email'])
                # 만일 email이 존재하는 email이라면 DB에서 해당 email에 해당하는 User 객체를 찾아 user 변수에 저장한다

                if bcrypt.checkpw(data['password'].encode('utf-8'), user.password.encode('utf-8')):
                    access_token = jwt.encode({'user_id':user.id}, SECRET_KEY['secret'], ALGORITHM['algorithm']).decode('utf-8')
                    # SignUp에서 암호화 시에는 bcrypt의 hashpw 메서드를 사용하지만, SignIn에서는 암호화된 비밀번호에 대한 확인을 위해 checkpw 메서드를 사용한다. 
                    # 유저가 입력한 password를 byte화 한 값과 유저가 입력한 email에 해당한 이미 등록된 유저의 password를 바이트한 값이 같을 경우에
                    # jwt를 이용하여 access_token을 발급해준다.
                    # 토큰발급: jwt.encode( 누구나 알아도 괜찮은 정보(대표적으로 고유 id) , 시크릿키 , 알고리즘 ).decode('utf-8')
                      
                    return JsonResponse({'access_token':access_token}, status=200)
                    # 유효한 유저임이 확인되어 토큰이 발급된 것 == 로그인에 성공한 것

                else:
                    return JsonResponse({'message':'UNAUTHORIZED'}, status=401)
            else:
                return JsonResponse({'message':'INVALID_USER'}, status=401)

        except KeyError:
            return JsonResponse({'message':'KEY_ERROR'}, status=400)

MyPageView (first name, order한 상품에 대한 정보 return)

class MyPage(View):
    @login_decorator
    # 확실히 인증된(로그인이 끝난) 사용자만 볼 수 있는 페이지에 대한 뷰를 작성할 때마다
    # 매번 로그인 절차를 쓰는 것이 상당히 비효율적이기 때문에 로그인 데코레이터를 user앱 내 utils.py에 작성하고 사용해준다.
    def get(self, request):
    # 사용자의 first_name만 띄워주는 목적이므로 get 메소드를 사용하였다.
        return JsonResponse ({'first_name': request.user.first_name})
class MyOrder(View):
    @login_decorator
    def post(self, request):
    # 먼저 cart에 담긴 아이템(order_status_id=1, order_status='pending') 중 front에서 주문하겠다고 요청한 상품들에 대해서만 
    # order_status를 'ordered'로 바꾸어 저장할 필요가 있다. (order_status_id=3)
    # 데이터에 대한 저장이 이루어져야 하므로 post 메소드를 사용했다.
    
        ordered_items = Order.objects.filter(order_status_id=1)
        # order_status_id=1 즉 pending 상태인 모든 상품의 객체를 조회하여

        for ordered_item in ordered_items:
            ordered_item.order_status_id=3
            ordered_item.save()
            # for 문을 돌려 order_status_id를 3으로, 즉 상태를 ordered로 변화시켜 다시 저장한다.

        return JsonResponse ({'message':'ORDER_COMPLETE'}, status=200)
        # 이후 상품 주문 완료 메시지를 띄운다

    @login_decorator
    def get(self, request):
    # order_status_id를 3으로 바꾸어 'ordered' 상태로 저장시킨 객체들을 이제 페이지에 실제로 보여줘야하는 메소드이므로
    # get 메소드를 사용하였다.

        ordered_items = []
        # 주문 완료된 상품들을 채워 보여주기 위한 빈 배열을 먼저 선언한다.
        order_items = Order.objects.filter(user_id=request.user.id, order_status_id=3).select_related("order_status").prefetch_related("product")
        # 주문된 상품 중, 현재 로그인한 사용자의 주문된 상품만을 보여줘야하므로 filter 조건에는 
        # 1) 현재 로그인한 사용자의 (user_id=request.user.id) 2) 주문된 상품 (order_status_id=3)만을 골라
        # Order 테이블이 정참조하고 있는 order_status 테이블은 select_related로,
        # Order 테이블이 역참조하고 있는 product 테이블은 prefetch_related로 미리 queryset을 캐싱해 둔다.
        # select_related와 prefetch_related는 모두 불필요한 쿼리를 줄일 수 있는 방법이므로 사용할 수 있는 조건이라면 무조건 사용하는 것이 유리하다.
        
        for item in order_items:
            data = {
                "name" : item.product.name,
                "price" : item.product.price,
                "quantity" : item.quantity,
                "color" : item.product.color.name,
                "order_status" : item.order_status.name,
                "image" : item.product.image_set.get(image_category=1).image_url
            }
            ordered_items.append(data)
            # select_related와 prefetch_related로 미리 캐싱해둔 쿼리셋을 for문으로 돌며
            # front와 미리 협의하여 주고받기로한 정보들을 dictionary 형식으로 만들어  
            # 미리 선언해둔 빈 배열에 append 시켜 JsonResponse로 렌더할 data list를 만든다.
            # 이 때 select_related로 캐싱해둔 쿼리셋은 dot notation + [table명] + 속성명 으로,
            # prefetch_related로 캐싱해둔 쿼리셋은 dot notation + [table명_set] + 객체 특정 + 속성명 으로 접근

        return JsonResponse ({'MyOrder': ordered_items})

API는 MyPage, MyOrder 두 개를 따로 작성하였지만
실제 웹페이지에서는 한 페이지 내에서 렌더된다.
(아래 사진에서 좌측은 MyPage API, 우측은 MyOrders API)

이번 프로젝트를하며 select_related와 prefetch_related의 필요성을 확실히 느꼈다.
이제는 여태까지 select_related와 prefetch_related를 이해하기 위해 읽었던 수많은 포스팅들을 다시 보며
내가 다시 정리해볼 필요가 있을 것 같다.


Product

ProductListView (전체 상품 리스트 뷰)

class ProductListView(View):
    def get(self, request):
    # 상품을 보여주기만 하므로 역시 get메소드 사용
            all_products = Product.objects.prefetch_related("image_set").all()
            # image_url을 프론트쪽에 보내줘야 하므로 역시 prefetch_related를 이용해 미리 쿼리셋을 캐싱해둔다 
            products = [ {
                "product_id"         : product.id,
                "product_name"       : product.name,
                "product_group"      : product.group,
                "product_img"        : product.image_set.get(image_category_id=1).image_url,
                "model_img"          : product.image_set.get(image_category_id=2).image_url,
                "product_price"      : product.price
            } for product in all_products ]
            # 미리 캐싱해둔 쿼리셋을 돌며 속성값에 접근하여 front에 보내줄 리스트를 만든다
            # 이 리스트 컴프리헨션을 구상하며 애를 좀 먹었다. 내가 참고했던 리스트 컴프리헨션 자료는 아래 사진이다.
            
            return JsonResponse({'products' : products}, status=200)


FilterView (category, apply on 별로 상품 필터링하여 보여주기)

category는 color, care, gift_card로 나눌 수 있고 (색조, 기초, 기프트카드 상품)
apply on은 lips, face, eyes 로 나눌 수 있다. (입술, 얼굴, 눈에 바르는 상품)
즉 category 별로 나누어진 상품을 모두 합쳐도 전체 상품이 나오고
apply on 별로 나누어진 상품을 모두 합쳐도 전체 상품이 나온다.
게다가 category와 apply on은 중복 선택이 가능하다. 즉 category=color&care, apply on=lips&eyes 이런 식으로의 필터링이 가능한 것.
이 API를 제대로 구현하기 위해서는 QueryString을 사용해야 했다.

QueryString을 이용한다는 것은

이런 식으로
내가 보내는 FilterView의 엔드포인트 뒤에 ? 가 생기고 그 이후 입력한 파라미터 값에 따라
필요한 데이터만 제공하게 되는 것이다.
파라미터는 위에서처럼 parameter = value 형태로 적어 보내진다.
중복 필터를 사용할 때처럼 여러개의 파라미터가 필요할 경우에는 &를 붙여 여러개의 파라미터를 보내게 된다.
그래서 최종적으로 [엔드포인트주소]?파라미터=값&파라미터=값 형식의 주소를 갖게 되는 것이다.

class FilterView(View):
    def get(self, request):
            category_names = request.GET.getlist('category_names', None)
            applying_names = request.GET.getlist('applying_names', None)
            # 백엔드에서 프론트로부터의 요청을 받을 때에는 request.GET.get을 이용한다. 다만 이번에 내 경우
            # 중복 필터링을 가능하게 해야했기 때문에 request.GET.getlist를 사용하여 여러 개의 파라미터를 받을 수 있도록 처리했다.

            if applying_names == []:
            # 사용자가 apply on 필터를 설정하지 않은 경우, 즉 category만 필터하여 요청을 보내 오는 경우
                found_products = Product.objects.select_related('category').prefetch_related('image_set').filter(category__name__in=category_names)
                # 역시 select_related와 prefetch_related를 이용하여 쿼리셋을 미리 캐싱한다.
                # 여기서 처음 사용해본 것은 filter(category__name__in = category_names) 부분인데
                # category 테이블의 name이 parameter로 받은 category_names인 것만 캐싱하겠다는 쿼리이다.

                products = [{
                    "product_id"         : category_product.id,
                    "product_name"       : category_product.name,
                    "product_group"      : category_product.group,
                    "product_img"        : category_product.image_set.get(image_category_id=1).image_url,
                    "model_img"          : category_product.image_set.get(image_category_id=2).image_url,
                    "product_price"      : category_product.price
                } for category_product in found_products ]

                return JsonResponse({'products': products}, status=200)
                    
            if category_names == []:
            # 이번에는 사용자가 category 필터를 설정하지 않은 경우, 즉 apply on만 필터하여 요청을 보내오는 경우인데
                for name in applying_names:
                # 여기서는 위에서와 달리 포문을 한번 더 사용했다. (리팩토링하는 과정에서 위에 if applying_names==[] 부분만 고치고 이 부분은 고치지 않은 것 같음)
                # 하지만 위에서처럼 포문을 빼고, filter(applying__name__in=applying_names)로 해주어도 똑같이 작동한다.
                found_products = Product.objects.select_related('applying').prefetch_related('image_set').filter(applying__name=name)

                products = [{
                    "product_id"           : applying_product.id,
                    "product_name"         : applying_product.name,
                    "product_group"        : applying_product.group,
                    "product_img"          : Image.objects.select_related('product').get(product_id=applying_product.id, image_category_id=1).image_url,
                    "model_img"            : Image.objects.select_related('product').get(product_id=applying_product.id, image_category_id=2).image_url,
                    "product_price"        : applying_product.price
                } for applying_product in found_products ]

                return JsonResponse({'products' : products}, status=200)
            

            found_products = Product.objects.select_related('category').select_related('applying').prefetch_related('image_set').filter(category__name__in=category_names, applying__name__in=applying_names)
                # category나 applying filter 중 하나만 필터하는 것이 아닌 둘 다 모두 필터링한 경우는 아래의 식을 타게 된다.
                products = [{
                    "product_id"            : category_applying_product.id,
                    "product_name"          : category_applying_product.name,
                    "product_group"         : category_applying_product.group,
                    "product_img"           : Image.objects.select_related('product').get(product_id=category_applying_product.id, image_category_id=1).image_url,
                    "model_img"             : Image.objects.select_related('product').get(product_id=category_applying_product.id, image_category_id=2).image_url,
                    "product_price"         : category_applying_product.price
            } for category_applying_product in found_products ]

            return JsonResponse ({'products' : products}, status=200)

여기까지가 내가 작성한 코드에 대한 모든 리뷰이다. 직접 리뷰를 해보니
  • 정규표현식
  • List comprehension
  • helper function
  • unit_test

에 대한 필요성이 느껴진다.
이제 리팩토링 지옥으로 들어가보자. ㅎㅎㅎㅎ



references:
https://docs.python.org/ko/3/library/datetime.html (datetime, strptime)
https://jay-ji.tistory.com/35 (select_related, prefetch_related)
https://wikidocs.net/22805 (list comprehension)

profile
Backend 개발 학습 아카이빙 블로그입니다. (현재는 작성하지 않습니다.)

0개의 댓글