3월 06, 2022

[Django] 1차 프로젝트 필터링 기능 구현

 술담화 페이지를 클론코딩하며, 상품 리스트 페이지에 대한 필터링 및 정렬 기능 구현을 맡게 됐습니다.

저희의 기획은 이렇게 카테고리, 카페인 유무(차담화라 도수 말고 카페인 여부로 변경) , 가격으로 필터링을 한 뒤, 이에 대하여 최신순 또는 평점순으로 정렬하는 것이었습니다. 

처음에는 급한 마음에 기획의도와 코드 방향성에 대해 깊게 생각해보지 않고 각각의 개별 필터링 및 정렬 만이 가능하게 하는 코드를 짰습니다.

밑의 코드가 처음으로 짠 코드입니다.

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
import json

from django.http     import JsonResponse
from django.views   import View

from drinks.models import Drink
# Create your views here.

class FilteringView(View):  
    def get(self, request):
        data     = request.GET #쿼리 스트링 전체를 가져옴 
        
        def compute_reviews(drink):
            drink_reviews = drink.review_set.all() 
            review_count  = drink_reviews.count() 
            sum_rating    = 0
            for review in drink_reviews:       
                sum_rating += review.rating
            if review_count == 0:              
                drink_average_review = 0
            elif review_count != 0:                 
                drink_average_review = sum_rating / review_count
            return review_count, drink_average_review


        if data.get("ordering", None) == "-updated_at":
            recently_ordered_queryset = Drink.objects.all().order_by('-updated_at')
            data_ordered_list = []
            for drink in recently_ordered_queryset:
                data_dict = {}
                data_dict["name"] = drink.name
                data_dict["price"] = drink.price
                review_count , drink_average_review = compute_reviews(drink)
                data_dict["average_rating"] = drink_average_review
                data_dict["review_count"] = review_count
                data_ordered_list.append(data_dict)
            return JsonResponse({'message':data_ordered_list}, status=200) 

        elif data.get("Min_price", None) and data.get("max_price",None):
            price_range                = [int(data.get("Min_price", None)), int(data.get("max_price",None))]
            qualified_drinks           = Drink.objects.filter(price__range=(price_range[0],price_range[1]+1))
            qualified_drinks_in_order = qualified_drinks.order_by('price')
            data_ordered_list = []
            for drink in qualified_drinks_in_order:
                data_dict = {}
                data_dict["name"] = drink.name
                data_dict["price"]  = drink.price
                review_count , drink_average_review = compute_reviews(drink)
                data_dict["average_rating"] = drink_average_review
                data_dict["review_count"] = review_count
                data_ordered_list.append(data_dict)
            return JsonResponse({'message':data_ordered_list}, status=200)



        elif data.get("filtering", None) == "caffeinated":
            drinks = Drink.objects.all()
            caffein_drinks  = drinks.filter(caffeine__range =(1,10000)).order_by('caffeine')
            data_ordered_list = []
            for drink in caffein_drinks:
                data_dict = {}
                data_dict["name"] = drink.name
                data_dict["price"]  = drink.price
                review_count , drink_average_review = compute_reviews(drink)
                data_dict["average_rating"] = drink_average_review
                data_dict["review_count"] = review_count
                data_ordered_list.append(data_dict)
            return JsonResponse({'message':data_ordered_list}, status=200)
            
        elif data.get("filtering", None) == "decaffeinated":
            drinks = Drink.objects.all()
            decaffein_drinks    = drinks.filter(caffeine = 0)
            data_ordered_list = []
            for drink in decaffein_drinks:
                data_dict = {}
                data_dict["name"] = drink.name
                data_dict["price"]  = drink.price
                review_count , drink_average_review = compute_reviews(drink)
                data_dict["average_rating"] = drink_average_review
                data_dict["review_count"] = review_count
                data_ordered_list.append(data_dict)
            return JsonResponse({'message':data_ordered_list}, status=200)

        elif data.get("ordering", None) == "-ratings":
            drinks = Drink.objects.all()
            drink_and_average_rating = {}
            for drink in drinks:
                review_count , drink_average_review = compute_reviews(drink)
                drink_and_average_rating[drink.name] = drink_average_review
            sorted_dict = sorted(drink_and_average_rating.items(), key=lambda x: x[1], reverse=True) 

            sorted_key_list= []
            for i in sorted_dict:
                sorted_key_list.append(i[0])
            data_ordered_list = []
            for name in sorted_key_list:
                data_dict = {}
                drink = Drink.objects.get(name=name)
                data_dict["name"] = drink.name
                data_dict["price"] = drink.price
                data_dict["average_rating"] = drink_and_average_rating[name]
                data_dict["review_count"] = drink.review_set.all().count()
                data_ordered_list.append(data_dict)
            return JsonResponse({'message':data_ordered_list}, status=200)


이렇게 코드를 짜고, 개별 필터링 및 정렬이 가능한 것을 보고, 나름의 안도감(?) 을 느끼며 멘토님께 리뷰를 받으러 갔는데, 리뷰를 받고 나서야 코드를 처음부터 다시 짜야 한다는 것을 깨달았습니다. 
심지어 처음에는 각 필터링 및 정렬 조건을 프론트로 부터 받아야 하는 것 아닌가? 라고 생각하며 post를 사용했습니다( GET을 사용한 것은 한번 수정을 거친 이후의 버전이기 때문입니다)


보시면 아시겠지만, 기능 구현 과정에 있어서 절차적으로 모든 경우의 수를 if elif문 등을 이용하여 분기해준 뒤, 각각 경우의 수에 맞는 처리를 해주었습니다.
멘토님게서 이러한 코드는 매우 절차지향적 코드이고, 객체 지향적으로 코드를 재구성 해야겠다는 피드백을 주셨습니다. 

또한, 술담화의 페이지를 보면 하나의 URI안에서 "쿼리 파라미터"를 통해서 모든 요청을 처리하고 있지만, 저는 메인 리스트뷰 페이지와 필터링과 오더링을 담당하는 URI를 새로 생성할 생각을 하고, urls.py를 통해 URI를 분리해주었습니다.

그러나 백엔드로써, 이렇게 요청에 따라 페이지를 분기해주는 페이지적 관점이 아닌 , 데이터를 한 API안에서 끌고와 , 요청에 따라 분리해줄 필요가 있다는 피드백을 주셨습니다.

정리하자면,

1. 상품 데이터를 전체 끌고오는 메인 리스트에 대한 클래스를 views.py에 작성하고
2. 그 안에서 get메소드를 통해 쿼리 파라미터를 통해 들어오는 요청을 받아 필터링과 정렬을 수행해야만 했습니다.

그래서 다시 짰습니다. 
아래는 수정한 코드입니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
from django.http      import JsonResponse
from django.views     import View
from django.db.models import Q

from drinks.models import Drink
# Create your views here.

class ProductsView(View):
    def get(self, request):

        drinks = Drink.objects.all()

        q = Q()

        category    = request.GET.get("category", None)  
        caffeine    = request.GET.get("caffeine", None)
        price_upper = request.GET.get("price_upper", 200000) 
        price_lower = request.GET.get("price_lower", 0) 
    
        if category:
            categories = category.split(',')
            q.add(Q(category__name__in = categories), Q.AND) 

        if caffeine:
            if caffeine   == "yes":
                q.add(Q(caffeine__gt=0), Q.AND)  
            elif caffeine == "no":
                q.add(Q(caffeine__exact=0), Q.AND) 
        
        q.add(Q(price__range = (price_lower, price_upper)),Q.AND)

        filtered_drinks = drinks.filter(q) 


        recently = request.GET.get("recently", None)
        rating   = request.GET.get("rating", None)    

        def compute_reviews(drink):
            drink_reviews = drink.review_set.all() 
            review_count  = drink_reviews.count() 
            sum_rating    = 0
            for review in drink_reviews:       
                sum_rating += review.rating
            if review_count == 0:              
                drink_average_review = 0
            elif review_count != 0:                 
                drink_average_review = sum_rating / review_count    
            return review_count, drink_average_review
        
        def make_whole_data_list(filtered_drinks):
            drink_and_average_rating = {}
            for drink in filtered_drinks:      
                review_count , drink_average_review = compute_reviews(drink)
                drink_and_average_rating[drink.name] = drink_average_review
            whole_data_list = [{
                "name"           : drink.name,
                "price"          : drink.price,
                "average_rating" : drink_average_review,
                "review_count"   : review_count
            } for drink in filtered_drinks]
            return whole_data_list

        if recently:
            filtered_drinks = filtered_drinks.order_by('-updated_at')
            whole_data_list = make_whole_data_list(filtered_drinks)


        elif rating:
            drink_and_average_rating = {}
            for drink in filtered_drinks:      
                review_count , drink_average_review = compute_reviews(drink)
                drink_and_average_rating[drink.name] = drink_average_review
            sorted_dict = sorted(drink_and_average_rating.items(), key=lambda x: x[1], reverse=True)
            sorted_key_list = []
            for i in sorted_dict:
                sorted_key_list.append(i[0])
            whole_data_list = []
            for name in sorted_key_list:
                data_dict = {}
                drink = Drink.objects.get(name=name)
                data_dict["name"]           = drink.name
                data_dict["price"]          = drink.price
                data_dict["average_rating"] = drink_and_average_rating[name]
                data_dict["review_count"]   = drink.review_set.all().count()
                whole_data_list.append(data_dict)

        else: 
            whole_data_list = make_whole_data_list(filtered_drinks)

        return JsonResponse({'message':whole_data_list}, status = 200)
처음에는 Q객체를 사용하고, 객체지향형으로 짜라는 말이 잘 이해가 안됐습니다. 

코드를 다 짜고 나서 보니, Q객체를 이용하여 요청(쿼리파라미터로 들어온) 과 메소드(field lookup)을 캡슐화하여 코드를 구현하라는 뜻인듯 싶습니다.

이렇게 수정을 하고보니 요청에 따른 경우의 수를 일일이 나눠줄 필요가 없을 뿐더러, Q객체를 이용하여 들어온 요청과 메소드를 간단하게 융합할 수 있기에 훨씬 간결하고 객체지향적인 코드가 바뀐게 아닌가 싶습니다.

Q객체를 이용하여 필터링을 모두 진행해준 뒤, 이 필터링이 완료된 쿼리셋에 대하여 ordering은 이전 코드와 유사하게 진행했습니다.

2월 23, 2022

[인증&인가] JWT / [장고]DoesNotExist

 JWT는 authorization, 즉 인가를 위한 것입니다.


jwt를 이용한 authorization 과정입니다. 서버에 저장되는 것은 없습니다.


반면 기존의 방식은 서버 메모리에 user 정보를 저장합니다


왜 이렇게 jwt를 쓸까요?

대부분의 경우 사이트는 한개의 DB만을 가지고 있지 않습니다. 은행을 예시로 들면, 뱅킹에 대한 서버와- 은퇴자들의 정보를 모아놓은 서버가 별도로 존재할 수 있습니다.


이 상황에서, 각 서버에 따로 유저의 인가 정보를 저장한다면, 유저는 backend에서 서버를 이동(유저는 본인이 서버를 옮겨가는지도 모를 가능성이 높습니다) 할 때마다 로그인을 다시 해줘야 하는 불편함을 겪습니다. 

따라서 client가 마치 같은 서버 안에 있는 것처럼, 두 서버를 자유자재로 왔다갔다 하면서 접속할 수 있게 해주려면, JWT를 사용해줘야 합니다. JWT를 사용하면서 Bank와 Retirement서버가 같은 SECRET_KEY를 공유한다면, 클라이언트는 자유자재로 jwt를 가지고 서버를 왔다갔다 할 수 있기에 매우 편리할 것입니다.


SECRET_KEY가 갑자기 왜 나올까요..?

이것이 JWT의 구조입니다.

  • 헤더에는 알고리즘과 토큰의 타입에 대한 정보가 들어갑니다. 즉, 어떤 타입의 데이터인지, 해시 알고리즘 정보는 무엇인지의 정보가 들어가는 것입니다.
  • 내용에는 실제 보관할 데이터를 담습니다. 여기에는 JWT의 만료시간을 나타내는 exp와 같은 registered claim, 공개용 정보 전달 목적의 public claim, 클라이언트와 서버간 협의 하에 사용하는 private claim등이 있습니다.(유저의 ID, iat-토큰 만들어진 시간, exp 등)
  • 위의 두 가지 요소는 Base64방식으로 인코딩(암호화 아닙니다) 돼 있기에, 누구나 그 안의 정보를 볼 수 있기에, 개인정보를 담아서는 안됩니다. 그래서 유저의 이름 등의 값이 아닌, 보안이 지켜지는 DB안의 PK값을 담는 것입니다,
  • 시그니처는 토큰이 바뀌지 않았다는 것을 verify하는 역할입니다. 시그니처 덕분에 유저는 데이터를 보기만 할 수 있고, 서버에만이 이를 수정할 수 있게 됩니다.
  • 헤더와 내용(페이로드)를 조합하고, SECRET_KEY를 이용하여 해시 값을 만들어 냅니다. 이것이 바로 시그니처입니다. 따라서 SECRET_KEY는 과제에서처럼 외부에 노출되지 않게, my_settings등에 따로 보관하며 사용시에는 import해서 사용합니다.
  • 이 시그니처의 비교를 통해 서버는 유저를 verify합니다. 시그니처가 바뀌었다면, 토큰에 변화가 있다는 뜻입니다.
  • 이때, 사용하는 알고리즘 값도 깃헙 등에 올라가서는 안되는 비즈니스 로직이기에, my_settings에 따로 보관해주는 것이 맞습니다.
    • 알고리즘 값은 상수이기에 컨벤션 상, 대문자로 상수명을 지어야 합니다.

2월 22, 2022

[django] westagram mission 5~6 를 진행하며 겪은 문제들(회원가입 비밀번호 암호화, 로그인시 JWT 발급)

 



미션 5 코멘트에 이런 과제를 주셨습니다.

  • phone_number가 꼭 들어가지 않아도 되도록 model을 수정하고
  • phone_number가 없어도 키에러가 나지 않고, 디폴트 값으로 무언가를 불러오도록 해주면 될 듯합니다
첫번째로, models.py의 phone_number 컬럼의 속성으로 null=True를 줄 지, blank=True를 줄 지 고민해보았습니다
  • null=True: 필드 값이 NULL로 저장되는 것 허용. DB의 열에 관한 설정.
    • 주어진 DB컬럼이 NULL값을 가질 수 있는지 정의
  • blank=True: 필드가 폼(입력 양식)에서 빈채로 저장되는 것 허용
    • 장고 관리자 및, 유효성과 관련돼 있습니다
    • blank가 False이면 해당 필드 없이 정보를 저장하고자 하면 유효성 검사에서 걸러지게 됩니다
    • blank=True로 할 시, 해당 필드가 없어도 정상적으로 동작합니다.
    • blank=True는 장고에 관련된 속성이기에 DB에 아무 영향도 주지 않습니다.
  • CharField, TextField에서는 null=True 를 설정할 시 문제가 생깁니다. Null의 "데이터 없음"이 문자열에서는 "빈 문자열"로 표현될 수 있기에. [데이터가 없는 상태]에 대해서 두가지 중복된 값을 가질 수 있게 됩니다. 
  • 따라서 CharField, TextField에는 null=True가 아닌 blank=True를 주는 것이 옳습니다

음... phone_number에 blank=True 속성을 추가해주면 될 듯 합니다.
메인에서 model만을 수정한 코드를 담은 브랜치를 따로 판 이후(현업에서는 모델을 수정할 때 무조건 이렇게 따로 브랜치를 파서 관리해줘야만 한다고 합니다) , 이를 PR후 merge받아서 main에서 migration을 시행하는데!  


에러가 발생합니다. users라는 테이블이 이미 있다고 합니다.
blank=True는 어차피 DB스키마에 영향을 미치지 않기에 migrate 해줄 필요가 없나 했더니, 그것 아닌가 봅니다


마이그레이션 실행자의 내부적인 모델 상태 그래프가 최신상태로 남기 위해 migration이 필요하다고 합니다, migration을 했으니 migrate를 하기 위해 다시 에러를 확인해봅니다.

해결법을 구글링하니 세가지 정도가 나옵니다.
  1. drop으로 users테이블 날린다
  2. python manage.py migrate --fake <appname> 으로 fake migrate한다
  3. 마이그레이션 파일 편집
3번을 하려고 0003_user_delete_users.py 파일을 열어봤습니다

db에 'users'라고 저장되는 테이블을 만든 이후, "Users"모델(db에선 'users'임)을 지워주려 합니다. 
지우고 -> 새로만들어야하는데
새로만들고 -> 지우려니 똑같은걸 또 만드려고 하는 에러가 나는 듯 합니다.

따라서 DeleteModel 부분을 CreateModel위로 올려줬습니다. 문제는 이렇게 수정하게 되면 DB가 날아가게 됩니다. 1번처럼 drop으로 테이블을 날린 것과 차이를 모르겠습니다.
DB에 별 정보가 없어서 망정이지, 매번 이렇게 할 수는 없을 듯 합니다.

다음번에 같은 에러와 마주한다면 fake migrate도 한번 시도해봐야겠습니다.

  • phone_number가 없어도 키에러가 나지 않고, 디폴트 값으로 무언가를 불러오도록 해주면 될 듯합니다
이를 해결해주기 위해 views에서 phone_number를 저장할 때 ,phone_number   = data["phone_number"],
가 아니라


이렇게 딕셔너리의 get메소드를 사용해주었습니다.
get메소드는 해당 키가 존재하지 않을 경우 , 디폴트 값을 저장해줍니다(아까 blank=True 해줬던거 기억하시죠?) 오른쪽의 빈 칸이 디폴트입니다.

어라? 근데 이렇게 저장하니 키에러가 뜹니다.
알고보니 "" 이렇게 디폴트 값으로 "빈 값"을 명시해줘야만 했습니다. 이렇게 하면 클라이언트가 전화번호 키 자체를 주지 않아도 해당 키에 디폴트로 ""값이 들어갑니다. 








2월 20, 2022

[django][westagram] 회원가입 기능 구현 : 이메일, 비밀번호 검증 정규식 / 비밀번호 앞에 @ 붙일시 에러 / django의 exist()

email_regex = '^[a-zA-Z0-9+-\_.]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$’

이메일 정규식은 다음과 같습니다.

정규식에서 [] 대괄호는 안에 있는 값들 중 하나라도 만족하는 것을 찾을 때 이용합니다.

대괄호 "안에" [^a-z] 이렇게 ^를 붙여주면 대괄호 안에 있는 아이들을 제외한 값을 찾아달란 뜻입니다.

반대로, 정규식 맨 처음의(대괄호 안에 들어있지 않은) ^는, 문장에서 시작하는 아이를 지정한다는 뜻입니다.

특정 문자열 다음의 + 는, 하나 있거나, 그 이상(one or more)을 의미합니다.

종합해서 보자면, 

^[a-zA-Z0-9+-\_.]

는 문장의 맨 앞에 영문 대소문자, 숫자, +-/_. 들 중 만족하는 것 하나라도 있는경우 를 따져라. 라는 뜻입니다

그 이후 @ 가 오고, 같은 패턴의 대괄호가 반복됩니다.

그 이후 \.(역슬래쉬 .) 이 오는데요 바로 . 을 찍으면 그것이 정규식에서 어떠한 기능을 할 수 있기에, 단순히 . 문자 자체가 온다는 것을 표현하기 위해 \ 뒤에 . 을 찍어주었습니다.

마지막 대괄호 뒤에 오는 $는 문장의 끝임을 알려주는 기호입니다


password_regex = (?=.*[A-Za-z])(?=.*\d)(?=.*[$@!%*#?&])[A-Za-z\d$@!%*#?&]{8,}

비밀번호 정규식입니다. 이 친구가 좀 어렵습니다. 


"비밀번호는 8자리 이상의 문자,숫자,특수문자의 복합" 이라는 조건에 대한 정규식입니다.

보이는 대로 해석을 해보면 그룹() 이 세개이고, 대괄호 안에는 영문 대소문자와 \d(숫자), 그리고 특수문자가 있습니다

(?=expr)

이 아이는 positive lookahead입니다.

Positive lookahead assertion. 이것은 포함된 정규표현식(expr)이 현재위치에서 성공적으로 match되면 성공하고, 그렇지 않으면 실패합니다. 그러나, 포함된 정규표현식(expr)이 시도된 이후, matching engine은 advance하지 않습니다; 나머지 패턴은 assertion(참이어야 한다고 생각한 사항을 표현한 논리식)이 시작한 곳에서 시도됩니다(tried). 


정규표현식 문서를 그대로 직역했습니다

. 은 모든 문자이고,

* 는 zero or more(없거나 있거나 많거나) 입니다

정리하자면, positive lookahead는 뒤의 표현식이 어떠한 문자열에 있는지 사전검증 하는 역할이고, 뒤의 패턴은 

어떠한 문자가 오거나 오지 않은 이후, 영문 대소문자가 있는지 확인합니다.

이 lookahead 기능을 여러번 사용하면 정규식의 AND연산을 수행할 수 있습니다.

(?=expr1)(?=expr2)(?=expr3) 이런식으로 

1. positive lookahaed를 통해 표현식에 해당하는 문자들이 있는지 찾습니다

2. 찾았지만 이를 포함하지 않습니다(advance 하지 않습니다)

3. 찾은 이후 뒤의 정규표현식을 실행하는데, 그 뒤의 정규표현식이 전방탐색에서 찾은 문자를 포함하면 이를 저장하고, 그렇지 않으면 저장하지 않습니다.

3. 이런식으로 패턴의 일치를 연속적으로 확인합니다 


따라서 위의 식은,

[A-Za-z\d$@!%*#?&] 

이 대괄호에 해당하는 문자열이, (?=) 안에 있는 패턴 세개를 포함하는지 검증하고, 

문자열의 길이는 8자 이상 {8,} 이어야 한다는 뜻인듯 합니다. 


저도 아직 정확히 이해하지 못했지만, 나름대로 이해한 것을 기술해 보았습니다.