3월 13, 2022

차담화 프로젝트 - 내가 담당하지 않았던 기능들

 제가 담당하지 않았던 기능들에 대해서 리뷰하기 위해 타 팀원들의 코드를 분석한 것임을 밝힙니다.


  • 회원가입, 로그인
이전 위스타그램 세션때 한번 다루어보았던 기억을 바탕으로 주석을 달아보았습니다.


 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
import json, bcrypt, jwt

from django.http  import JsonResponse
from django.views import View
from django.core.exceptions import ValidationError

from my_settings  import SECRET, ALGORITHM
from users.models import User
from utils.login_required  import login_required
from utils.validation import validate_email, validate_password #이메일 비밀번호 정규식 따로 빼서 관리

class SignUpView(View):
    def post(self, request):
        try:
            data = json.loads(request.body)

            username = data['username']
            email    = data['email'] 
            password = data['password']
            address  = data['address']
            point    = data.get('point', 100000)
            
            hashed_password = bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8') #str화 하여 db에 저장

            validate_email(email)
            validate_password(password)

            if User.objects.filter(email = email).exists():
                return JsonResponse ({'message' : 'EMAIL_ALREADY_EXIST'}, status=400)

            if User.objects.filter(username = username).exists():
                return JsonResponse ({"message" : "USERNAME_ALREADY_EXIST"}, status=400)

            User.objects.create(
                username = username,
                email    = email,
                password = hashed_password,
                address  = address,
                point    = point         
            )

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

        except ValidationError as e:
            return JsonResponse({'message': e.message }, status=400)

        except json.JSONDecodeError:        
            # json.loads를 쓸때 발생, 받는 값이 없거나 , deserialize(string->object)(23번 줄) 되고 있는 값이 json형식이 아닐 때 에러가 난다. 
            # json 형식을 통과할 때 읽을 수 없기 때문에 발생하는 에러
            return JsonResponse({'message': 'JSON_DECODE_ERROR'}, status=400)

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

class SignInView(View):
    def post(self, request):
        try:
            data     = json.loads(request.body)
            
            email    = data['email']
            password = data['password']

            if not User.objects.filter(email=email).exists():
                return JsonResponse({'message':'INVALID_USER'}, status=400)

            user_password = User.objects.get(email=email).password # 이메일 확인되면 이메일에 해당하는 Password가져옴

            if not bcrypt.checkpw(password.encode('utf-8'), user_password.encode('utf-8')): 
                #bytes타입인 데이터(json으로 들어온 암호 / db에 저장된 암호) 두가지를 받아서 비밀번호 맞는지 확인
                return JsonResponse({'message':'INVALID_USER})



로그인 데코레이터의 경우는 다음과 같습니다
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
def login_required(func):  # 함수명은 동사가 올바른 컨벤션 네이밍
    def wrapper(self, request, *args, **kwargs):
        try: 
            token        = request.headers.get("Authorization", None)  #request의 헤더에 오는 'Authorization"을 받아서
            payload      = jwt.decode(token, SECRET, ALGORITHM) #이를 token을 decode해서 payload에 {'id':1} 이런 형식으로 담음
            request.user = User.objects.get(id=payload['id']) # request의 user 변수에 id가 payload의 id값인 user객체를 담고 이를 리턴해줌

            return func(self, request, *args, **kwargs) # 함수에 request를 담아서 리턴

        except jwt.exceptions.DecodeError:       #  token deocde시 에러 발생                        
            return JsonResponse({"message" : "INVALID_TOKEN" }, status=400)

        except User.DoesNotExist:   # 14번째줄에서 매치하는 user가 없으면 발동
            return JsonResponse({"message" : "INVALID_USER"}, status=400)
            
    return wrapper 


  • 장바구니 기능 
  • 장바구니 추가 - 사용자가 제품과 수량 선택시 장바구니에 들어갑니다
get_or_create를 사용하였습니다
 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
@login_required
    def post(self, request): #장바구니 추가 
        try:
            data = json.loads(request.body)
            
            user     = request.user #로그인 데코레이터에서 리턴받은 user
            quantity = data['quantity']
            drink_id = data['drink_id']

            if not Drink.objects.filter(id = drink_id).exists(): # 해당하는 drink없을시 
                return JsonResponse({'message' : 'DRINK_NOT_EXIST'}, status = 400)

            cart, created = Cart.objects.get_or_create(
                user     = user,
                drink_id = drink_id,
                quantity = quantity
            )
            # get or create -> (object,created) 의 튜플 형식 반환 - created는 boolean flag(True or False)
            # True면 인스턴스가 get_or_create 메서드에 의해 생성된 것
            # False면 인스턴스가 DB에서 꺼내진것(기존에 있던것)
            # cart는 object(모델의 인스턴스) 이기에 save()를 통해 저장시켜줌

            cart.save()

            return JsonResponse({'message' : 'CART_CREATED'}, status = 201)

        except json.JSONDecodeError:
            return JsonResponse({'message' : 'JSON_DECODE_ERROR'}, status = 400)  

        except Cart.DoesNotExist: 
            return JsonResponse({'message' : 'CART_NOT_FOUND'}, status = 400)  
        
        except KeyError:
            return JsonResponse({'message' : 'KEY_ERROR'}, status = 400)
  • 장바구니 조회 - 사용자가 장바구니 내에 있는 데이터를 조회합니다
select_related를 사용하여 정참조한 객체를 가져올때 db가 hit되는 횟수를 줄여줍니다.
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
    @login_required
    def get(self, request): #장바구니 조회 

        carts  = Cart.objects.select_related('drink').filter(user = request.user) # 하나의 쿼리셋을 가져올 때, 연관돼 있는 objects들을 미리 불러옴
        #1대 N 관계에서 N이 사용(정방향 참조에서의 join에 유리하게 사용)
        # SQL join을 통해 데이터를 즉시 가져옴(Eager Loading) / 추가 쿼리 X(prefetch_related)
        # 이렇게 불러온 데이터들은 result_cache에 cahce됨 
        # 미리 db에서 객체를 얻어내므로 db에 액세스 하는 횟수를 줄일 수 있다
        #cart는 drink정참조
        
        result = [
            {
            'cart_id'    : cart.id,
            'drink_name' : cart.drink.name,
            'quantity'   : cart.quantity,
            'price'      : cart.drink.price
            }
            for cart in carts] # 리스트컴프리헨션 사용하여 리스트안에 각 카트에 해당하는 딕셔너리 생성 

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

  • 장바구니 수량 변경
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
    @login_required
    def patch(self, request): # 장바구니 수량 변경 crud 중 u
        #update에는 put과 patch가 있는데,
        #patch의 경우에는 변경하고자 하는 속성의 키와 값만 전달하면 됨 (일부 속성만 유연하게 수정 가능)
        # put의 경우에는 그 자원이 가지는 모든 속성 값을 전달해야 함
        try:
            data = json.loads(request.body)

            cart          = Cart.objects.get(id = data['cart_id']) #json으로 온 cart_id에 해당하는 cart객체를 변수에 저장
            cart.quantity = data['quantity'] # cart의 quantity를 변경
            
            cart.save()
            
            return JsonResponse({'message' : 'ITEM_QUANTITY_CHANGED', 'quantity' : cart.quantity}, status = 200)

        except Cart.DoesNotExist:
            return JsonResponse({'message' : 'CART_NOT_FOUND'}, status = 400)  

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

  • 장바구니 삭제
path parameter를 활용하여 장바구니에 대한 정보를 받아줍니다
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
    @login_required
    def delete(self, request, cart_id): # 장바구니 삭제  # cart_id를 인자로 받음(path_parameter)
        try:
            data = json.loads(request.body)

            user = request.user

            if not Cart.objects.filter(user = user, id = cart_id).exists(): # user,cart_id에 일치하는 쿼리셋 없을시 
                return JsonResponse({'message' : 'CART_DOES_NOT_EXIST'}, status = 400) 

            Cart.objects.filter(user = user, id = cart_id).delete() #있으면 Cart에서 삭제함

            return JsonResponse({'message' : 'CART_DELETED'}, status = 204)

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

  • 리뷰 기능 
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
class CommentView(View):
    @login_required
    def post(self, request, drink_id):
        try:
            data    = json.loads(request.body)

            rating  = data['rating']
            comment = data['comment']
            user_id = request.user.id  # 로그인 데코레이터에서 반환된 user
            drink   = Drink.objects.get(id=drink_id) # drink_id는 path parameter로 받음
        
            Review.objects.update_or_create(  # 인스턴스가 존재한다면 값을 업데이트하고 , 존재하지 않는다면 인스턴스를 생성 
            # get_or_create와 마찬가지로 (object, created)의 튜플을 리턴함 
                user_id  = user_id,
                drink = drink,
                defaults = {'comment':comment, 'rating':rating}  
            )

            return JsonResponse({'message':'review_posting_success'}, status=201)
        
        except KeyError:
            return JsonResponse({'message':'Key_error'}, status=400)

path_parameter로 들어온 drink_id로 update_or_create를 통해 리뷰 코멘트 작성 혹은 업데이트를 수행합니다 
3월 12, 2022

차담화 프로젝트 회고

1. 프로젝트 소개



  • 술담화 사이트를 모티브로 한, 차를 판매하는 차담화란 사이트를 구현하는 것이 목표
  • 2주란 시간적 제약으로 인하여, 많은 기능을 목표로 하지 않았고, 해당 기능 구현에 집중
    • 로그인/회원가입
    • 테마별 정렬이 들어간 메인페이지
    • 필터링과 정렬 기능이 들어간 전체 상품 리스트 페이지
    • 상품 상세 페이지
    • 장바구니 페이지
  • 애자일한 프로젝트 진행을 위해 스크럼 방식으로 프로젝트를 진행(하려 했습니다만...)
    • 2주간의 프로젝트 과정을 1,2차 스프린트로 나눠서 진행
    • 각 스프린트의 시작마다 플래닝 미팅 진행 
    • 데일리 스탠드업 미팅을 통해 각자 진행과정과 블록커등을 공유
    • 진행과정을 시각화하기 위하여 칸반 툴을 활용

2. 사용된 기술

공용 툴 

  • Trello: 칸반 생산성 툴인 트렐로를 활용했습니다. Backlog, To Do, In practice, Reviewing, Done으로 카드의 카테고리를 분류하여 각 카드에 해야 할 업무를 기록했습니다. 
  • GitHub : 프로젝트의 버전 관리와, 개발 과정에서의 협업을 위하여 백엔드와 프론트엔드의 깃헙을 분리하여 운영했습니다. main 브랜치를 건드리지 않고, 각 기능 구현을 별도의 브랜치에서 마친 후, 이를 메인과 merge시키면서 프로젝트를 진행했습니다. 팀원들이 머지 권한을 갖지 않고, 멘토님이 팀원들의 코드를 최종 리뷰한 이후 main에 머지시키는 방식으로 진행했습니다.
  • Notion: 회의록을 노션으로 공유했습니다
  • Slack

BE

  • Mysql: 관계형 데이터베이스 관리시스템인 mysql을 활용했습니다
  • Django : 파이썬 웹 프레임워크인 장고를 활용했습니다. MV패턴을 활용했습니다
  • 프로젝트를 위한 환경설정은 conda 가상환경을 만들어, 이 가상환경으로 프로젝트를 진행하였습니다

개인 툴

  • Notion : 앞서 회의록으로 노션을 활용한 것과 별개로, 개인적으로 1차 프로젝트 전용 노션 페이지를 별도로 구성하여 프로젝트 전반에 대해 기록했습니다.

3. 목표

- 팀 목표 : 기능 구현보다도, 소통과정에 있어 팀원들간 분쟁 없이 화목하게 프로젝트를 마무리하는 것이 목표였습니다. 결론적으로 상호간의 트러블 없이 무사히 프로젝트를 마칠 수 있었기에, 목표를 성공적으로 이룬 듯 합니다.

- 개인 목표 : 맡은 바 기능을 충실히 구현하면서, 구현 과정에 있어서 자기주도적인 해결과 - 타인의 도움을 받는 것 사이의 균형을 유지하는 것을 목표로 했습니다. 블록커를 맞닥드리는 경우, 이전까지는 시간적 인풋이 들더라도 최대한 혼자 골머리를 앓으면서라도 해결하려 했습니다. 하지만 이번 프로젝트의 경우 팀 프로젝트라는 특성상, 진행과정에 있어 호흡을 맞춰가며 진행해야 했기에, 적당한 선에서 고민과 구글링을 마치고 다른 분들에게 도움 또는 키워드 아이디어를 얻으려 의도적으로 노력했습니다. 결론적으로 팀으로써 함께 라인을 맞춰가며 잘 진행하였습니다. 

다만 아쉬웠던 점은 프로젝트 전반적으로 걸쳐 급한 마인드를 가지고 진행하다 보니, 주변을 살피지 않고 혼자서 달려가는 경향이 있었는데, 이에 대해서는 아쉬웠던 점에서 자세히 서술하겠습니다.

4. 기능 구현 - 내가 한 부분

제가 담당한 부분은 다음과 같습니다

1. DBuploader 구현

ORM 쿼리문으로 일일히 데이터를 등록하지 않고 , csv파일을 통해 대량으로 데이터를 등록할 수 있도록 DBuploader를 별도로 만들었습니다. 

아쉬웠던 점은, 하나의 csv파일을 가지고 db의 모든 테이블에 들어갈 데이터를 모두 넣으려 하다보니, csv파일이 옆으로 매우 길어졌다는 것입니다. 

서로 참조관계를 가진 테이블들끼리만 분류를 하여, 서로 관계가 없는 테이블들은 각각 다른 

csv파일을 사용하여서 업로드를 진행할 수 있도록 해줄 수도 있었을 텐데, 그렇게 진행하지 않았습니다. 따라서 db에 파일을 업로드함에 있어서 좌우로 스크롤을 계속해야 해서 피로감이 느껴졌고, 테이블들간의 참조관계를 명확하게 확인하며 데이터를 집어넣기 힘들었습니다. 다음프로젝트에서도  dbuploader를 사용하게 될텐데, 여러개의 csv파일을 이용하여 db에 데이터를 넣어서 불편함을 덜면서 진행할 계획입니다.


2. 상품 리스트 페이지 구현

제일 많은 시간과 노력을 쏟았던 View 입니다.

여러번코드를 들어내고 다시 작성하기를 반복했습니다.

앞서 블로그에,필터링 view를 공유했었는데, 여기서 몇번의 변경이 더 있었습니다.

 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
class ProductView(View):
    def get(self, request):
        
        q = Q()

        category        = request.GET.getlist("category", None)  
        is_caffeinated  = request.GET.get("is_caffeinated", None)
        price_upper     = request.GET.get("price_upper", 200000) 
        price_lower     = request.GET.get("price_lower", 0)
        search_query    = request.GET.get('search_query', None) 

        if search_query:
            q.add(Q(name__contains=search_query), Q.AND)
        
        if category:
            categories = category[0].split(',')
            q.add(Q(category__name__in = categories), Q.AND) 

        if is_caffeinated:
            q.add(Q(caffeine__gt=0), Q.AND) if is_caffeinated=="True" else q.add(Q(caffeine__exact=0), Q.AND) 

        q.add(Q(price__range = (price_lower, price_upper)),Q.AND)

        drinks = Drink.objects.filter(q).annotate(average_rating = Avg('review__rating'), review_count=Count('review'))

        sort_by = request.GET.get('sort_by', None)
        
        sort_by_options = {
            "newest"         : "-updated_at",
            "oldest"         : "updated_at",
            "highest_rating" : "-average_rating",
        }

        result = [{
            "id"              : drink.id,
            "name"           : drink.name,
            "price"          : drink.price,
            "average_rating" : drink.average_rating if drink.average_rating else 0,
            "review_count"   : drink.review_count,
            "image"          : drink.drinkimage_set.all().first().thumb_img 
        }for drink in drinks.order_by(sort_by_options[sort_by])]

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

최종적인 코드입니다.

절차적으로 if-elif로 코드를 작성한 것을 모두 들어내고, linear한 진행을 방해하는 불필요한 함수 import또한 모두 제거했습니다.

프론트로부터 쿼리파라미터를 제공받아 이에 따라 필터링을 q 객체로 진행해주었으며, 필터링을 마친 쿼리셋에 대하여 정렬을 진행합니다.

정렬에 있어서 drink와 연결된 review 테이블의 ratings 컬럼의 평균을 내서 이에 따라 정렬을 해주는 부분이 매우 큰 블록커로 다가왔습니다. 처음에는 장문의 if-elif문을 작성하여 평균을 직접 작성해주었으나, 이를 제거하고 장고의 annotate기능을 활용하여 테이블에 존재하지 않는 새로운 컬럼을 만든 뒤, 이에 따라 정렬을 해 주었습니다. 

q객체, annotate등 장고의 메소드들을 활용하여 코드를 작성하니 훨씬 가독성이 늘어나고 코드 작성이 편리해짐을 느낄 수 있었습니다. 해당 기능들을 사용하면서 공식문서의 위력을 느낄 수 있었습니다. 구글링을 통해 타 블로그 등의 소스를 사용하는 것보다, 공식문서를 집중하여 읽어내고, 이해한 바를 바탕으로 나의 코드에 적용시키는 것이 어렵지만 빠르고 확실하게 가는 방법임을 경험했습니다. 

최신순, 오래된순, 평점순의 정렬도 원래는 if문을 사용하여 각각 경우의 수를 분기해 주었으나, 멘토님의 피드백을 받은 이후, 정렬의 경우의 수를 담은 딕셔너리인 sort_by_options를 만들고, 쿼리 파라미터의 키값을 통해 정렬 방법을 전달받아 분기 없이 바로 .order_by 의 인자로 주는 방식으로 변경하였습니다. 이 또한 선형적인 코드 진행에 대해 배울 수 있는 과정이었습니다. 이처럼 많은 것을 배운 코드였습니다 


3. 테마(농장)별 정렬이 들어간 메인 페이지

사용한 메소드는 필터링과 대동소이하나, 두개의 컴프리헨션을 중첩해서 사용해서 다소 난항을 겪었던 View입니다. 

 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
class FarmProductView(View):
    def get(self, request):

        offset          = request.GET.get("offset", 0)
        limit           = request.GET.get("limit", 4)
        order_method    = request.GET.get("order_method", "highest_rating")

        order_method_options = {
            "highest_rating" : "-average_rating",
            "newest"         : "-updated_at",
            "oldest"         : "updated_at"  
        }

        farms = Farm.objects.all()

        result = {
                "farm" : [{
                    "id"      : farm.id,
                    "name"    : farm.name,
                    "drinks"  : [
                        {
                            "id"             : drink.id,
                            "name"           : drink.name,
                            "price"          : drink.price,
                            "average_rating" : drink.average_rating,
                            "review_count"   : drink.review_count,
                            "image"          : drink.drinkimage_set.all().first().thumb_img,
                        }  for drink in farm.drink_set.all().annotate(average_rating = Avg('review__rating'), review_count=Count('review'))\
                            .order_by(order_method_options[order_method])[offset:offset+limit]]
            } for farm in farms]
        }

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


5. 협업 경험

혼자서 장고로 model과 view로 백엔드 로직을 작성하고, postman으로 crud를 확인하는 과정과 다르게, 여러명의 백엔드와, 거기에 프론트엔드 팀원들까지 함께한 협업 경험은 새롭고도 즐거운 경험이었습니다. 협업을 진행하며 이러한 것들을 느꼈습니다.

  • 기록의 중요성
프로젝트를 진행하며 정말 많은 정기적인 미팅과, 비공식적인 대화를  나누었습니다. 말을 정말 정말 많이하게 되는데, 돌아서면 그 많은 말들을 다 까먹게 되는 신기한(?) 경험을 했습니다. 분명히 서로 구두로 어떠한 점을 협약하거나 정보를 공유했는데, 나중에 보면 둘다 이를 까먹고 있는 사태가 발생했습니다. 일례로 메인 페이지에서 농장별 상품 정렬을 백엔드측은 3가지로 기억하고 이에 따라 DB데이터 작성을 완료했는데, 사이트 시연 전날 얘기를 나눠보니 프론트측은 이를 6개로 기억하고 있어서 그에 맞게 사이트UI를 만들어 놓았던 일이 있었습니다. 말을 한 즉시 어딘가에 기록해두었다면 서로 혼선을 일으켜서 마지막에 고생하는 일은 없었을 것입니다. 개인적으로 최대한 대화를 나누면 바로바로 노션에 저장하려 했으나, 주먹구구식으로 적다보니깐 체계화가 되지 못했던 점은 아쉽습니다. 
다음 프로젝트때는 팀으로서의 기록을 trello를 통해 직관적으로 공유하면서, 개인적으로는 기능별/ 날짜별로 섹션을 나누어 기록을 더 상세히 진행해볼까 합니다.


  • 모델링은 프론트와 협의하면서 신중히 짜자 
모델링을 변경하는 소요는 프로젝트를 진행하면서 어쩔 수 없이 생기는 일인 듯 하지만, 그래도 이를 최소화하는 것이 좋다고 느꼈습니다. 프로젝트 진행 중간에 이에 대한 인풋 투입이 생기다보니 일정에 차질이 생김을 느꼈습니다.
 일례로, 상품상세페이지에서 상품 상세설명 이미지를 프론트측이 하드코딩하기로 하여 이에 대한 db컬럼을 따로 마련하지 않고 있다가, 후에 하드코딩에 들어가는 소요가 너무 큼을 깨닫고 뒤늦게 db에 상세설명 이미지를 위한 컬럼을 따로 만들어주었습니다. 이 과정에서 각 테이블들간의 참조관계가 변경되어 db를 완전히 들어내고 migrate를 다시 진행했습니다. 이 과정에서 마저도 한번의 db 삭제이후 마이그레이트로 끝난것이 아니라, 여러 문제가 생겨서 db삭제와 마이그레이트를 여러번 반복했던 것이 기억이 납니다. 이때 멘탈이 깨져서 어떠한 문제들을 겪었는지 기록해놓지를 않았는데, 다시한번 바로바로 기록으로 남기는 것의 중요성을 블로그를 작성하며 느낍니다... 그냥 되게 고생을 했다는 것만 기억이 나네요..ㅋㅋ


6. 아쉬운 점

  • 회의록을 따로 만들지 말고 트렐로 카드에 직접 기록했으면 어떨까 싶습니다
회의록을 따로 노션페이지를 만들어서 작성했다고 했는데, 그러다 보니 트렐로/ 노션 두 툴을 전환해가면서 일정과 협의사항을 확인하는 것이 불편했습니다. 다른 조원분들을 보면 트렐로에 직접 회의 사항을 정리해서 기록하신 조들이 있던데, 이러한 방식이 더 효율적이라고 느꼈습니다. 협업 툴은 적을수록, 간결할 수록 좋은 듯합니다 


  • 급한 마음에 혼자서 너무 달렸습니다 
앞서 혼자서 너무 달렸던 경향이 있다고 서술했는데, 이에 해당하는 부분입니다. 
혼자서 달린다고 잘해지는 것도 아니고 나의 조급함만 배가 됐습니다. 급한마음에 이것저것 "구현" 그 자체에 목적을 두고 JSON파일 가는 것만 보고 PR을 급하게 날렸습니다. 그러나 이렇게 작성한 코드는 제대로 된 코드가 전혀 아니었고, 결론적으로 시간만 날린 꼴이 됐습니다. 스스로의 능력을 냉철하게 파악하고, 더 고민하여 체계적으로 , 천천히 접근했다면 마음도 편하고 프로젝트 진행도 한결 수월하게 되지 않았을까 하는 아쉬움이 남습니다.

2주간 그럼에도 불구하고 너무 고생많았고, 함께 고생한 팀원들에게 감사를 표하고 싶습니다. 훗날 돌아볼때 즐거웠고 유익했던 프로젝트로 남을 수 있는 좋은 경험을 한것 같아 뿌듯합니다. 

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은 이전 코드와 유사하게 진행했습니다.

3월 06, 2022

REST API

 REST란?

REpresentational

State

Transfer

  • Communication에 관한 것
  • RESTFUL : Communicate하기 위해 REST API를 쓰는 서비스

REST API의 장점들 

  1. Simple, Standardized
  2. Scalabel, Stateless
    1. 서비스가 복잡도가 커질때, 쉽게 수정을 가할 수 있음
    2. stateless하기에 데이터가 어떤 상태인지 걱정할 필요 없고, 그것들을 추적할 필요 없음
  3. Caching을 지원하기에, 높은 Performance를 보임
    1. 서버는 Response cache-control 헤더에 해당 요청이 캐싱이 가능한 지 여부를 제공한다. 이를 제공하면, 클라이언트는 Response를 캐싱하여 서버와 클라이언트 간의 상호작용을 줄이고, 성능과 서버의 가용성을 높인다.

재고가 있는 아이스크림의 맛 종류들을 보여주고, 직원들이 그 맛을 업데이트 할 수 있는 웹 어플리케이션을 만들려고 함

cloud based server와 REST API를 통해 통신



END POINT는 HTTP://Icecream.com/API/FLAVORS 같이 생겼을 것

“FLAVORS” = Resource

REST API를 통해 Request와 Response를 주고받는데, 

이때 REST API를 통해 할 것은? ⇒ CRUD

REST API에서 이 CRUD의 동의어는 HTTP “method” 또는 “operations”임


CREATE 는 POST

READ는 GET

UPDATE는 PUT

DELETE는 DELETE


REQUEST의 구성 
  • operation(HTTP methods)가 있고 ,
  • Endpoint
  • Parameter 혹은 Body가 있고
  • Headers가 있음: API Key 또는 인증 데이터 등을 담음
RESPONSE의 구성
  • JSON데이터의 형식 

어플리케이션에서 현재의 재고를 표시하고 싶다면?
  1. GET FLAVORS
    1. GET이 OPERATION(메소드)
    2. ENDPOINT는 /FLAVORS
    3. RESPONSE에는 맛에 대한 JSON데이터가 들어옴

맛을 바꿔주고 싶다면??

2.UPDATE(or Replace) FLAVORS
    1. PUT OPERATION
    2. ENDPOINT는 “/API/FLAVORS/1” : ID 1을 명시하여 지우고 싶은맛을 표시
    3. BODY(PARAMETER)에는 추가하고 싶은 맛을 적어줌

새로운 맛이 들어왔다면?

3. CREATE
  1. POST 오프레이션
  2. END POINT 는 “/FLAVORS”
  3. BODY에 만들고 싶은 맛을 넣음

REST API RESOURCE NAMING GUIDE 

What is Resource?

REST에서의 기본 데이터 표현이 Resource.

이름붙일 수 있는 한 어떠한 정보도 Resource가 될 수 있음 (문서, 이미지, 일시적 서비스 등등)

싱글톤과 컬렉션 리소스

customers : 컬렉션 리소스 “/customers” URI를 통해 컬렉션 리소스를 식별.

customer : singleton 리소스 “/customers/{customerID}” URI를 통해 개별 customer 리소스 식별 가능

  • 리소스에는 하위 컬렉션 리소스도 포함 가능
    • “/customers/{customerId}/accounts” 특정 customer의 sub-collection 리소스인 accounts를 해당 URN으로 식별.
    • 이 accounts의 singleton리소스인 “account”도 “/customers/{customerId}/accounts/{accountId}” 이렇게 식별됨

URI

  • Uniform : 리스소를 식별하는 통일된 방식
  • Resource : 자원.
  • Identifier

REST API는 URI를 통하여 리소스를 처리함.

REST API 디자이너는 REST API의 리소스 모델을 클라이언트에 전달하는 URI를 만들어야 함

리소스의 이름을 잘 지정하면 API가 직관적이고 사용이 용이함

URI 생성 규칙들

  1. 명사를 사용하여 자원을 나타냄

    리소스의 원형(아키타입)은 4가지로 나누어짐.

    1. document

      document 리소스는 단일개념.(object의 instance, db의 row)

      REST에서 이는 컬렉션 리소스 안의 싱글 리소스.

      resouce archetype을 나타내기 위하여 “단수”를 사용함

    2. collection

      서버가 관리하는 리소스 디렉토리

      “복수”를 사용

      POST기반 등록

    3. Store

      클라이언트가 관리하는 리소스 repository

      store 리소스는 클라이언트가 URI를 관리

      복수 사용

    4. Controller

      “동사” 사용

      실행가능한 기능 같은 것.


  2. 일관성이 제일 중요하다.

    : 일관성 있는 리소스 네이밍과 URI형식을 사용하여, 모호성을 최소화하고 가독성과 유지 보수 가능성을 최대화

    1. 계층 관계 표현을 위해 “/” (슬래쉬)를 사용

      http://api.example.com/device-management

      http://api.example.com/device-management/managed-devices

      http://api.example.com/device-management/managed-devices/{id}

      http://api.example.com/device-management/managed-devices/{id}/scripts

    2. URI의 마지막 값에 슬래쉬를 사용하지 않음

    3. - (하이픈)을 사용하여 URI 가독성 향상

    4. 긴 경로에서 - 사용하여 이름의 가독성을 향상시킴
      http://api.example.com/device-management/managed-devices
      가
      http://api.example.com/device-management/managedDevices
      보다 낫다

    5. 언더바를 사용하지 않는다

      1. 일부 브라우저나 화면에서 _ (언더바)가 가려지거나 숨겨질 수 있음 : 밑줄 대신 하이픈 사용
    6. URI에 소문자를 사용해라

    7. 파일 확장자를 사용하지 말라

      http://api.example.com/device-management/managed-devices.xml /이렇게 쓰면 안됨!

    8. URI에 CRUD 함수 이름을 사용하지 말라

      URI는 리소스를 식별하는데만 사용하고, 리소스에 대한 조치는 포함하면 안된다.

      리소스에 대한 작업은 HTTP 메소드를 사용해서 하기

    9. URI 컬렉션 필터링을 위하여 query component를 사용하라

      정렬, 혹은 필터된 리소스를 위하여 신규 API를 생성하지 않고 쿼리 파라미터를 활용

      http://api.example.com/device-management/managed-devices?region=USA&brand=XYZ&sort=installation-date




참고자료
https://www.youtube.com/watch?v=lsMQRaeKNDk
https://restfulapi.net/resource-naming/
3월 06, 2022

[Django] Q객체

django 공식 문서에 기반하여 게시물을 작성했습니다.

Q객체는 데이터베이스 관련 작업에서 사용할 수 있는 SQL 조건을 나타냅니다. 

Q객체는 |(OR) , &(AND)등을 활용하여 조건들을 정의하고, 재사용하고, 합칠 수 있게 합니다. 


Keyworded argument queries는 filter() 등에서 AND조건으로만 합쳐집니다.

복잡한 쿼리를 실행하기 위해서(OR 등을 사용하고 싶을때), Q 객체를 사용할 수 있습니다.


Q객체는 Keyword arguments 들을 encapsulate 하기 위해 쓰이는 객체입니다. 

(encapsulate=캡슐화 : 연관 있는 변수와 함수를 하나의 단위로 묶는 것, 이는 데이터의 번들링을 의미함, 파이썬의 경우에 이 번들링은 클래스를 통해 이뤄지고, 이 클래스의 인스턴스 생성으로 클래스 안에 포함된 변수와 메소드에 접근할 수 있게 됨) 

(Q객체의 경우는 keyword argument - 정렬의 기준이 되는 값 - 과 field lookup을 통한 sql WHERE 절 - 즉 메소드를, Q클래스를 상속받은 Q객체를 통해서 번들링 한다고 이해했습니다)

이 keyword arguments들은 "Field lookups"를 통해 특정화되는데, 이에 대해서는 후술하겠습니다.


Q 객체는 & 와 | 를 통해서 합쳐질 수 있습니다. 두개의 Q 객체는 & , | 의 오퍼레이터를 거쳐 하나로 합쳐집니다. 

1
Q(question__startswith='where') | Q(question__startswith='When)
예를 들어 위 코드는 두개의 question__startswith 쿼리들을 'OR'로 합친 하나의 Q객체를 만들어냅니다.

keyword arguments들을 받는 lookup function들은(filter()나 exclude(), get() 등) 하나 이상의 Q객체를 positional arguments로 받을 수 있습니다. lookup function에 여러개의 Q 객체를 준다면 각각의 argument들은 AND될 것입니다.


1
2
3
4
Drink.objects.get(
    Q(name__startswith='Nok'),
    Q(caffeine=0 | Q(caffeine=1)
)
get()안의 두개의 Q객체가 있는데, 이들은 AND조건으로 연결된다는 뜻입니다.
 

Lookup 함수들은 Q객체와 keyword argument을 함께 사용할 수 있습니다. 마찬가지로 lookup 함수에 주어지는 모든 arguments는 AND로 연결됩니다. 
이때 주의해야 할 것은 Q객체가 keyword arguments 보다 먼저 와야 된다는 것입니다.


1
2
3
4
Drink.objects.get(
    name__startswith='Nok',
    Q(caffeine=0 | Q(caffeine=1)
)
이러한 코드 구성은 유효하지 않다는 뜻입니다.


앞서 field lookup에 대해 얘기했었죠,
Field lookups는 SQL WHERE 절의 의미를 구체화하는 방식입니다.
이것들은 get() filter() exclude()등의 쿼리셋 메소드 에서 사용됩니다

field__lookuptype=value 
의 형식으로 field lookup을 사용합니다.
lookup에 사용된 field의 이름은 모델 필드의 이름과 같아야 합니다.

다만, 예외가 있습니다. Foreign key의 경우 필드네임을 접미사에 _id를 붙임으로써 나타낼 수 있습니다. 이 때, value 값은 foreign 모델의 pk의 raw value를 포함하고 있어야 합니다.
Drink.objects.filter(review_id=4)
이런식으로 말이죠.

다양한 필드 lookup reference가 있습니다. 

https://docs.djangoproject.com/en/4.0/ref/models/querysets/#field-lookups

해당 링크에서 이에 대해 확인해 볼 수 있습니다. 이들 중 프로젝트에 사용됐던 몇가지 field lookup reference를 알아보도록 하겠습니다.


price_lower과 price_upper 사이의 range 안에 price가 있는지 확인하는 코드입니다. 미리 선언해두었던 q = Q()에 add를 통해서 조건을 더해줍니다.



사용자가 카테고리를 여러가지 선택할 수 있도록 구현해야 했기에 str로 값을 받고, 이를 쉼표로 나눠준 리스트를 만듭니다.  

저 __name 부분에서 매우 애를 먹었는데, __name을 해주지 않으면 id값으로만 필터링이 가능했습니다. erd를 보시면 아시겠지만, category(클래스명은 Category)는 테이블 이름이지, 테이블 내의 컬럼명이 아닙니다. 따라서 그냥 category만 써줬을 경우에는 pk인 id 값을 통해서 구분을 지어준 것이 아닌가 생각이 듭니다. category 테이블의 컬럼명인 name을 __name이렇게 연결해 주고서야 name을 통해서 필터링이 가능해졌습니다. 
__in은 categories라는 iterable (list, tuple , queryset 등) 안에 category__name 이 있는지 확인하기 위해 사용된 filed lookup입니다.