3월 27, 2022

도커 배포하며 겪은 사소한 에러들

 

에러


이미지를 만들려고 할때마다 이 에러가 뜹니다

해결책으로 이 링크 https://dev.to/shriekdj/python-failed-building-wheel-for-backportszoneinfo-on-linux-2mo8 

의 tzdata 를 설치했더니( 두번째 해결책)

이젠 다른 에러가 뜹니다 


문제 해결

저는 pip freeze > requirements.txt로 pip freeze 항목을 그대로 다 requirements에 옮겨줬는데,

그러다 보니 제가 손수 깐 것이 아닌 장고 등을 설치하면서 깔린 라이브러리 등이 있다고 생각합니다.

그래서 backports.zoneinfo라는 제가 깔지 않은것이 pip freeze에 들어가지 않았나 생각이 들었습니다.

그 부분 requirments.txt에서 지워주고 다시 이미지 만드니 이미지가 만들어집니다.

requirements.txt는 pip로 모듈을 설치할때마다 성실하게 업데이트를 해줘야 한다는 것을 느꼈습니다.


문제는 requirments에 gunicorn 관련 내역이 없어서 이미지로 컨테이너를 실행하려니컨테이너가 실행 안됩니다,

가상환경 키고(구니콘이 있는), pip freeze로 gunicorn부분을 확인한 후 이를 requirements에 복사붙여넣기 해줍니다. 이를 바탕으로 이미지를 다시 만들어줍니다.

이미지를 다시 만들었습니다.

근데 도커콘테이너 실행하려니.


이 에러가 뜹니다.

docker ps -a 로 꺼진 콘테이너 확인해보니

api란 이름의 콘테이너가 꺼진채로 있습니다

이 콘테이너를 지워줍니다.



내 컴퓨터 - ec2 - docker 이렇게 연결되는 것이 아니라 ,

내 컴퓨터 → docker

아마존이 가진 ec2 → docker 이런식으로 별개의 컴퓨터 두개에서 도커로 서버를 돌린다고 생각하면 될듯 싶습니다.


여태껏 도커로 서버를 키려할 때 해당 명령어만을 사용했었는데

이 명령어로 키면 wecode/projcet 이미지로 api란 이름을 가진 새로운 컨테이너를 만들고 그거로 서버를 여는것과 같습니다

그래서 매번 이름이 안겹치게 새로운 이름을 가진 애들을 열어줬어야 했습니다. 

docker run -p 8000:8000 wecode/project:0.1.0

그러고 싶지 않으면 이렇게 —name api 부분을 없애주면 됩니다 , 이러면 새로운 이름의 컨테이너 안 열고 기존 컨테이너를 이용하여 그냥 바로 서버열기 가능합니다.

3월 27, 2022

SOLID 원칙

 객체지향 프로그래밍을 하며 지켜야 할 5가지의 원칙을 SOLID 원칙이라 합니다.

A. SRP(Single Responsibility Principle) - 단일 책임의 원칙 

클래스는 오직 하나의 기능만 가지며, 클래스의 모든 작업은 그 단일 책임에 종속돼야 합니다.

이에 맞추어  https://github.com/wecode-bootcamp-korea/30-2nd-WantU-backend/tree/main/applications 해당 링크의 applications view를 살펴보겠습니다.

우선 리팩토링 이전의 StatusView 코드입니다.


해당 view는 지원자의  지원 상태와, 그것들의 개수를 데이터베이스에서 가져오는 역할을 하고 있습니다.

34번째 줄에서 application.status.status는db에서 직접적으로 상태값을 가져오는 역할을 합니다. 

StatusView는 지원상태를 return 해줄 뿐 아니라, DB에서 직접 상태값들을 가져와 return할 값에 직접적으로 투입하고 있습니다. 

하나의 클래스가 여러 기능을 하고 있다고 보여지기에, enum클래스를 따로 만들어주어 상태값을 enum클래스를 통해 불러올 수 있도록 하겠습니다. 




enum 클래스를 분리해준 이후 , 이 클래스의 value값과 상태값의 id를 맞추어서 개수를 셀 때 Status 클래스를 사용해줍니다.


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class StatusView(View):
    @login_decorator
    def get(self, request):
        applicant = Applicant.objects.filter(user=request.user).prefetch_related('application_set')
 
        if not applicant.exists():
            return JsonResponse({'result':"NO valid application"}, status = 404)
 
        applications_in_applicant = applicant[0].application_set.all().select_related('status')          
        status_list               = [application.status.id for application in applications_in_applicant]
 
        result = { 
            "applied"         : status_list.count(Status.APPLIED.value), 
            "document_passed" : status_list.count(Status.DOCUMENT_PASSED.value),
            "accepted"        : status_list.count(Status.ACCEPTED.value),
            "rejection"       : status_list.count(Status.REJECTION.value)
            }
 
        result["total"= len(status_list)
 
        return JsonResponse({'result':result}, status = 200
cs


B. OCP(Open Close Principle) - 개방폐쇄의 원칙

클래스는 확장에 대해서는 개방적이어야 하고, 변경에 대해서는 폐쇄적이어야 합니다.

클래스의 코드 변경은 이 클래스를 사용하는 모든 시스템에 영향을 미치게 됩니다. 따라서 클래스의 기능을 추가하려면, 기존 기능의 변경이 아닌 새로운 함수의 추가가 이상적입니다.


C. LSP(The Liskov Substitution Principle) - 리스코브 치환의 원칙

부모클래스 T의 서브타입인 자식 클래스 S의 객체는 어떠한 문제도 없이 부모 클래스의 객체를 대체할 수 있습니다.

문제없는 대체를 위해서, 자식 클래스는 부모클래스의 기능을 오버라이드 하지 않아야합니다. 자식 클래스는 부모의 기존 기능을 건드리지 않은 채, 확장만 수행하여야 LSP를 만족하게 됩니다

D. ISP(Interface Segregation Principle) - 인터페이스 분리의 원칙

바꿔 말하면, 인터페이스의 단일 책임 원칙입니다. 구체적인 책임을 지는 여러 인터페이스를 분리하여, 사용하지 않는 인터페이스가 존재하지 않게 해야 됩니다.


E. DIP(Dependency Inversion Principle) - 의존성 역전의 원칙

고차원의 모듈과 클래스는 저차원의 모듈과 클래스에 의존해서는 안됩니다. 고차원, 저차원 모두 구체에 의존적이지 않고, 추상에 의존해야 합니다. 구체는 추상에 의존적입니다.

고차원 모듈 또는 클래스는 저차원 모듈 또는 클래스를 도구로 하여 동작하는 클래스입니다. 추상은 두 클래스를 연결하는 인터페이스이고, 구체는 도구가 동작하는 방법입니다. 

자동차가 타이어를 사용할때, 한국타이어, 금호타이어등은 "구체"이고, 단순 "타이어"는 "추상"이라 할 수 있습니다. 자동차라는 고차원 클래스는 타이어라는 추상적 "인터페이스"에 의존하고 있습니다. 이때 타이어들은 모두 인터페이스 입니다.




출처 https://server-engineer.tistory.com/228

인터페이스를 통해 고수준 클래스가 저수준 클래스에 의존성을 가지지 않게 할 수 있습니다.

앞서 Enum클래스로 상태값을 분리해준 과정에서, StatusView는 고차원 클래스, Enum을 상속받은 Status는 저차원 클래스라고 생각됩니다.

3월 26, 2022

[리팩토링] SQL문과 쿼리 개수를 확인하여 prefetch와 select_related의 성능 확인하기

 



해당 erd의 applicants에서 application_status까지 타고 들어가려 합니다.




Get 요청 보냈을 시 


저는 SQL문을 알지 못합니다. 쿼리문을 보고 직관적으로 해석해보았습니다. 틀린 해석일 가능성이 큽니다.

1. id가 8 인 유저를 user테이블로부터 불러옵니다

2. applicants 중에서 user_id가 8인 것을 찾습니다. -> 7번 applicant

3. applications에서 applicant_id가 7 인것을 찾습니다

4. inner join을 이용하여 application_status 중 applications.status_id 와 같은 id를 가진 행을 엮은 뒤, applicant_id가 7 인 것을 찾습니다 



이 부분에서 prefetch_related를 빼주었더니 


오히려 쿼리의 숫자가 하나 줄어들었습니다




이 부분이 없어졌군요

그니까 위의 3번 부분이 없어진 것입니다.


반면에 select_related를 주석처리 해주었더니

쿼리가 21개로 급증했습니다

inner join으로 미리 데이터를 엮어오지 않아서 매번 for문이 돌때마다 데이터를 db에서 가져오는 것을 볼 수 있습니다.


왜 prefetch를 썼더니 쿼리 개수가 하나 더 늘어났을까요??

 https://stackoverflow.com/questions/31237042/whats-the-difference-between-select-related-and-prefetch-related-in-django-orm

이 링크의 답변을 보시면, select_related는 SQL join을 하여 데이터를 SQL서버의 테이블 일부분으로 받는 반면, prefetch_related는 또 다른 쿼리를 발생시켜서 기존 object의 불필요한 컬럼을 줄여준다(inner join을 하여 테이블의 컬럼을 늘리지 않는다는 뜻으로 받아들였습니다) 고 합니다. 

다시 한번 코드를 보면,

applicant에서 status로 가는 과정에서 역참조로 application_set을 사용하는 것은 맞습니다. 다만 prefetch를 사용하지 않아도, 그 밑의 코드에서 application_set.all() 로 역참조한 application의 쿼리셋을 가져오고, 거기에 select_related로 status를 가져오고 있습니다 

저는 prefetch를 사용하면 미리 application을 db에서 join으로 긁어와서 추후에 application_set을 사용할때 db를 다시한번 hit할 일이 없다고 생각했습니다. 그러나 prefetch_related는 "파이썬" 상에서 join을 실시하며(db에서 미리 join을 일으키지 않으며), join될 각 테이블에 대한 쿼리를 발생시킵니다.

따라서 prefecth_related를 사용했을 시 이 부분이 추가되지 않았나 생각이 듭니다


 


한번 더 실행해보니 


쿼리 숫자가 5개로 또 한번 줄어들었습니다.
그런데 화면상에 뜨는 쿼리문은 그대로이네요.. 이 부분은 알게되면 추후 포스팅하겠습니다


밑의 CompanyView를 봅시다.

이 코드의 경우 

applicant에서 application으로 가서 job_position으로 한번 더 이동 한 뒤 , 거기서 각각 company의 name / job_category의 name / 해당 테이블의 created_at을 뽑아와야 하는 구조입니다


쿼리가 무려 31개나 날아갑니다.

자세히보면 job_category, company, application_status를 가져오는 쿼리가 중복됩니다.

job_position만 select_related로 join할 것이 아니라 job_category와 company는 select_related로,  status는 prefetch로 가져옵니다. 

쿼리의 개수가 획기적으로 줄어들었습니다.




application 쿼리셋에서 시작해서, 어떻게 두다리 너머의 company와 category 까지 가져올 수 있을지 고민했는데, 그냥 job_position에서 __ 로 넘어가면 되는거였습니다.

다시한번 prefetch와 select의 차이를 알기 위하여 

prefetch 를 select_related로 바꿔주었습니다.

쿼리가 하나 줄어들었습니다.

마지막의 이 부분이 없어졌네요 



앞서 첨부한 스택오버플로우 답변글에 이런 답변이 있습니다. select_related와 prefetch_related의 쿼리 개수가 딱 하나 차이나는것이 이 답변을 증명하네요.

"prefetch_related run the query on the first model , collects all the IDs it needs to prefetch and then runs a query with an IN clause in the WHERE with all the IDs that it needs. "

이 답변에 해당하는 쿼리가 이것인듯 하네요




3월 20, 2022

AWS로 배포하기: RDS를 사용하기 위해 로컬 mysql dump

 프로젝트를 진행하면서 원격 통신을 위해 ngrok을 사용하려다가 mysql이 2002 에러로 맛이 갔습니다.

ERROR 2002 (HY000): Can't connect to local MySQL server through socket '/var/lib/mysql/mysql.sock' (2)

ngrok을 설정하기 위해 snap으로 ngrok을 설치하려다가, 이 명령어가 듣지 않아서 해결책을 찾다가 

$ sudo daemonize /usr/bin/unshare --fork --pid --mount-proc /lib/systemd/systemd --system-unit=basic.target


해당 명령어를 실행해주면 된다는 포스팅을 보고 덜컥 따라한 이유로 mysql이 켜지지 않습니다. 
sudo service mysql restart 로 mysql을 껐다 키려해봐도

Warning! D-Bus connection terminated. mysql

이 에러가 계속 나서 발이 묶였습니다. 결국 임시방편의 해결책을 찾긴 했습니다. wsl --shutdown으로 wsl을 껐다가 다시 켜주면 mysql에 접속이 됩니다.

이는 어쨌든 임시방편일 뿐이고... 후에 프로젝트가 끝나고 시간적 여유가 생기면 꼭 linux공부를 해야겠습니다. wsl을 사용하면서 수많은 고난을 겪으니 운영체제를 꼭 공부해야겠다는 생각이 듭니다(오히려 좋아)

아무튼 ngrok 사용을 포기하고 이참에 aws로 그냥 배포를 하기로 했습니다. 

이때 RDS를 사용하여 클라우드에 데이터베이스를 만든 이후, 이를 EC2와 연동해줘야 합니다. 이 과정에서 기존 Mysql(로컬)을 dump를 뜬 이후, 나의 RDS에 밀어넣어(?)줍니다.

dump가 뭘까요??
데이터베이스 덤프는 테이블 구조와 데이터에 대한 기록입니다. 이는 SQL 선언문의 리스트입니다. 보통 database dump는 데이터 손실에 대비하여 DB를 백업하는 용도로 사용합니다. 

mysqldump -u root -p wantu > wantu.sql

해당 명령어로 wantu db의 스키마가 담긴 wantu.sql 파일을 생성합니다 
(스키마란 자료의 구조, 자료의 표현 방법, 자료간 관계를 형식 언어로 정의한 구조입니다)

그리고 이를 

mysql -h 데이터베이스명 -u root -p wantu < wantu.sql
명령어로 밀어넣어 줍니다

이후 RDS 상의 데이터베이스를 확인해보면 나의 로컬 데이터베이스가 그대로 RDS에 옮겨간 것을 확인할 수 있습니다. 


3월 20, 2022

aws - django를 통해 s3에 파일 업로드, 삭제 기능 구현하기

 S3는 Simple Storage Service의 약자입니다. 아마존에서 제공하는 파일 서버 서비스입니다.


"버킷(Bucket)" 이라는 디렉토리에 데이터 하나하나(객체)를 저장합니다. 


파이썬을 이용하여 S3 저장소를 이용하기 위해서는 boto3 를 사용해야합니다.

pip로 boto3를 설치해준 뒤, 업로드를 진행하고자 하는 클래스(뷰)에서 boto3.client부터 만들어줍니다. 


s3를 사용할 것이기에 s3를 명시해주고, 그 뒤의 kwargs로 access key와 secret access key를 줍니다. 이때 이 둘은 보안과 관련된 사항이기에, my_settings 또는 환경 변수 등으로 따로 관리해주는 것이 좋습니다.

이를 통해 서비스 클라이언트를 명시해준 이후, upload_fileobj를 통해 s3에 파일 업로드를 진행해줍니다. 


인자는 순서대로 파일 객체, 업로드하려는 버킷 명, 키값(str), 추가인자입니다. 

이때, key 값은 uuid를 사용하여 관리해줍니다. (file_uuid는 uuid값을 담은 변수입니다)

만약 파일이름을 key 값으로 사용한다면, 기존의 객체와 같은 이름을 가진 파일이 추가될때, 버킷에 새로운 파일이 추가되는 것이 아니라, 기존에 있던 동명의 파일이 업데이트 되기에 uuid로 관리를 해주는 것입니다.


uuid는 universally unique identifier를 뜻합니다. 네트워크 상에서 고유성이 보장되는 ID를 만들기 위한 표준 규약입니다. 저는 uuid4 버전을 활용했습니다. 1과 4버전을 많이 이용하는데, 1버전의 경우 보안상 허점이 있을 수 있다하여 4버전을 선택했습니다.


ExtraArgs를 통해 파일의 컨텐츠 타입도 설정해줍니다.


내친김에 삭제 기능도 구현해 줍니다.

삭제는 delete_object 메소드 를 사용하면 됩니다.


이렇게 버킷 이름과, 키를(DB에 저장해두었던 UUID)를 알려주면 삭제가 됩니다.


이상입니다. S3가 생소한 개념이어서 그렇지 구현은 그렇게 어렵지 않았습니다. 세팅을 하는데 시간이 더 많이 걸렸던것 같습니다. 그러나 단순히 업로드/ 다운로드/ 삭제가 가능하게 하는것이 다가 아니라, AWS 전반에 대한 이해가 부족하기에 이를 늘려야 할 듯합니다.




3월 20, 2022

JSONDecodeError - form data란 ?

 

해당 코드에서 


계속해서 JSONDecodeError가 발생합니다.

포스트맨을 이용해서 계속 값을 보내주는데 


뭐가 문제인지 파악하지 못하고 있습니다.

json.loads()는 json형식을 python의 dictionary 형식으로 변경(decode)해줍니다


그러다 문득 체크박스에서 raw를 선택하고 직접 json을 입력해보았습니다


작성해보면 계속 빨간줄이 뜨는데, json형식에 맞게 인덴트까지 세심히 조절해주면 빨간줄이 더이상뜨지 않습니다

이렇게 post 요청을 보내니 잘 됩니다!


코드의 문제가 아니라, 포스트맨을 통한 요청 방식에 따라 차이가 있는 것이었습니다 


HTTP request body는 bytestring 형식으로 오기때문에 decode를 거쳐 str로 만들어준 이후 각각 방식을 프린트해보겠습니다


이것이 form-data로 보낸것의 결과입니다

이것이 raw로 보낸 것을 프린트 찍어본 결과입니다


그렇다면 포스트맨에서 form-data로 보낼때와 raw로 보낼때의 차이점이 정확히 무엇일까요?


웹사이트 form에서 API로 mulipart/form-data 형식으로 값을 보낼 수 있습니다. 이 형식을 모방한 것이 포스트맨의 form-data 입니다. form-data를 통해 키-값 쌍을 content type을 특정해서 보낼 수 있습니다.
파일을 보낼때에는 form-data를 사용할 수 있습니다. 

body로 data를 보낼때, header에 알맞는 cotent type을 지정하여서 API가 값을 적절하게 받을 수 있게 해줘야 합니다. form-data를 사용하면 이 content-type헤더를 포스트맨이 자동으로 지정합니다.(수동으로 지정해주지 않을 시)

반면에 raw로 body에 값을 실을때에는, postman은 제가 고른 type에 맞게 헤더를 고르게 됩니다.

여기서 고를 수 있습니다.


프론트로부터 post로 요청을 받을때에도, 알맞은 type으로 데이터를 보내주지 않는 한 계속 Key Error가 발생하는 것을 확인할 수 있었습니다. 
분명히 키 값을 다 맞췄음에도 계속 키에러가 나서 보내는 형식을 확인해보니, 파일을 post하는데 일반 application/json 형식으로 보내고 있었기에 키에러가 나는 것을 확인할 수 있었습니다.
 이를 form-data형식으로 변경하니 키에러가 나지 않고 알맞게 데이터를 받을 수 있었습니다.






3월 13, 2022

Path Variable VS Query Parameter

Path Variable 

경로를 변수로써 사용합니다.

포스트맨에서는 이런식으로 path parameter에 해당하는 변수(:id)를 정의하고, params에서 값을 줄 수 있습니다




여러개의 path parameter가 올 수 있습니다. 




어떠한 resource를 식별하고 싶으면 Path Variable을 사용합니다.

이유는 다음과 같습니다. 위의 예시의 경우, 존재하지 않는 boards의 id가 들어온다면 path variable은 404 not found 에러를 발생시킬 것입니다. 

/drinks [GET] # Fetch a list of drinks
/drinks [POST] # Create new drink
/drinks/123 [PUT] # Update drink id 123
/drinks/123 [DELETE] # remove drink id 123


리소스를 식별한 이후, HTTP 메소드를 변경함으로써 추가적인 endpoint (drinks/create) 나 쿼리 파라미터(drink?action=create) 없이 원하는 동작을 수행할 수 있습니다.

반면 Query Parameter를 사용한다면, 서버로 데이터가 넘어가 쿼리를 날린 이후, 해당하는 데이터가 없을 경우에 에러가 발생할 것입니다




Query Parameter

경로 "뒤에" 입력 데이터를 함께 제공하는 형태입니다. 

따라서 경로의 일부가 아니고, resource를 호출할때 받는 추가적인 정보에 해당합니다


Key,value의 쌍으로 이루어집니다, &로 연결 가능합니다


? 뒤에 오는 모든 것은 쿼리 파라미터입니다.

 
Optional 하기에 없더라도 문제가 되지 않습니다.



정렬이나 필터링을 할 시 Query Parameter를 사용합니다 

앞서 Path Variable의 경우와 달리, 정렬이나 필터링을 할시 404 not found가 발생하는 상황은 부적절합니다. 따라서 이러한 경우엔 Query Param을 사용해줍니다.



사진에서 보이듯이 query params는 체크박스 해제가 가능하지만, path Variable은 체크 해제가 불가능합니다. 이는 path parameter가 없을 시 path가 달라지기 때문입니다(경로의 일부분이기에)


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