이번 포스팅에서는 2번째 프리온 보딩 프로젝트였던 베어 로보틱스의 문제를 진행하면서 겪었던 이슈들과 배웠던 점에서 다시 한번 정리해보려고 한다!
첫 번째 프로젝트를 거의 3-4일 동안 마무리하고 난 뒤, 바로 다음 주부터 주제가 확확 바뀌어서 진행되었던 것만큼 굉장히 정신이 없었지만 팀원들과 함께여서 전혀 두려울 게 없었다. 또한 개인적인 공부를 하는 것보다 엄청난 양의 지식이 들어오는 것을 느낄 수 있었다.
많이 들어온 만큼 팀원들과 공유하려고 노력하였으며, 내 나름대로 나의 언어로 적지만, 다른 분들이 보았을 때에도 이해가 될 수 있게끔 적는 활동이 필수적인 것 같았다.
아무튼 서론이 너무 길었고 바로 두 번째 프로젝트에서는 내가 어떤 것을 배웠는지 정리를 해보자!
이번 프로젝트의 주제는 영수증 데이터(pos log)를 활용한 서비스였다.
쉽게 말해 주어진 영수증 데이터는 어느 음식점에서 몇명의 인원이 어떤 음식을 먹었으며 결제 수단은 무엇이었는지에 대한 데이터였다.
이 데이터를 가지고, KPI 수치에 대한 REST API를 구현하는 것이 이번 프로젝트의 목표였다.
우선은 지난번에 진행했던 광고 데이터처럼 csv파일이 주어졌었고, 이를 적절히 모델링하여 테이블을 만들어주고 ORM으로 연결하여 데이터들을 Mysql에 적재하는 부분까지는 거의 동일하게 진행되었다.
지난번 프로젝트에서는 다른 팀원이 진행했던 부분이었는데 이번 기회에는 필자가 Django 프로젝트 초기 설정과 설정 파일을 분리하고, 주어진 데이터에 대한 모델링 부분과 DB에 적재하는 초기 단계들을 진행하였다.
models.py
class PosResultData(TimeStamp):
# 결제 수단 정의
PAYMENTS = [
('1', 'CARD'),
('2', 'CASH'),
('3', 'PHONE'),
('4', 'BITCOIN')
]
timestamp = models.DateTimeField(verbose_name="결제 시각")
price = models.PositiveIntegerField(verbose_name="결제 금액", default=0)
restaurant = models.ForeignKey(Restaurant, max_length=50, null=False, verbose_name="결제 레스토랑 id", blank=False, on_delete=models.CASCADE)
number_of_party = models.PositiveSmallIntegerField(verbose_name="파티원 수", default=0)
payment = models.CharField(max_length=20, verbose_name="결제 수단", choices=PAYMENTS)
class Meta:
db_table = 'pos_result_data'
영수증 데이터에 대한 모델을 정의해 보았다.
정의하면서 결제 수단 같은 경우는 4가지밖에 존재하지 않는것을 파악하여 4가지 값을 갖는 리스트로 정의를 해주었다.
지금 와서 생각해보니 payment라는 table을 두어 각 영수증 row 데이터에 payment_id값을 가지고 있는 방법도 있어 보이는 것 같았다. 아무튼, 시각 같은 경우는 TimeStamp 모델을 core model에 정의해두었고 상속받아 사용하였다. core model에는 자주 사용되는 타임스탬프 같은 것들을 정의해둔다고 한다. utils app 같은 느낌인 듯?
그다음으로는 결제 금액과 파티원 수 같은 경우는 무조건 양수이므로 PositiveIntegerField와 PositiveSmallIntegerField를 사용하였다. 값이 예측된다면 DB를 설계할 때 최대한 값이 가질 수 있는 범위를 확정 지어준다는 것이 DB 성능에 좋다.
무조건 CharField를 쓰는 것보다 조금만 더 생각해서 일반화 하는 방향으로 정의해두면 좋다는 점을 깨닫게 되었다.
위와 같이 영수증 데이터들을 적재하기 위한 모델링을 마쳤고, DB에 적재까지는 수월하게 진행되었다. 그리고는 팀원이 영수증 데이터를 추가, 삭제, 수정을 할 수 있는 CRUD 기능을 APIView를 사용하여 구현하였고 이는 나중에 Swagger 문서를 적용하기 위해서 제너릭 View로 수정되었다.
여기서 지난 프로젝트에서 애먹었던? 날짜 데이터에 대한 확인을 바짝 했던 것 같았다. (두 번의 실수는 없다!)
일반적으로 DateTimeField를 사용하면 위처럼 기존 초까지만 있던 데이터가 밀리초까지 추가되어 만들어지는 것을 알 수 있다.
그다음으로는 Swagger 문서를 적용하였다. Nest에서도 Swagger를 적용해본 경험이 있어서 Django에도 똑같은 기능이 있어서 프로젝트의 API 문서를 뽑는데 적용해보았다. 서버를 실행하고, /swagger 경로로 접속하면 아래와 같은 창이 뜬다!
우리가 정의한 API들에 대한 설명과 어떤 URL로 접근해야 하는지에 대한 정보들이 나오며 더 자세하게 클릭하면
위와 같이 필요한 query parameter에 대한 정보들과 시도해볼 수 있는 Try it out이라는 버튼도 보인다.
실제로 저 버튼을 통해서 API에 대한 테스트도 진행할 수 있다.
아무튼 구현 내용은 간단하게 이 정도로 남겨두고, 다음으로는 팀원이 작업한 부분에 있어서 리팩터링을 진행한 PR을 남겨보려고 한다.
수정한 부분에 대해서 정리를 해보자면 다음과 같다.
- Serializer에 존재하는 query params에 대한 Validation 함수 로직들을 따로 파일로 분리
- Django Q와 F를 사용한 쿼리 연결 및 DB 참조 횟수 최소화
- 코드 가독성 및 중복성 최소화
이정도 될 것 같다. 하지만 기존 팀원이 작업한 내용 위에서 필자가 추가적인 작업을 진행하였고, 작업을 진행하고 나니 생각보다 수정된 부분이 많았었다. 게다가 기존에 동작하던 validation을 중간에 빼먹었는지 동일하게 동작하지 않고 에러를 내뿜고 있었다.
본인이 작업을 했지만 이는 매우 안 좋은 리팩터링이었다고 생각한다. 왜냐하면 우선 리팩터링의 목적이 아니라 그냥 갈아치우는 식으로 작업을 했기 때문이다. 적어도 기능에는 변함이 없어야 하는데, 오류를 내뿜고 있었으니 얼마나 최악이었는가.. 나 같았어도 심기 불편했을 것 같았다.
이때 당시에 필자는 대부분의 요구사항이 1번과 비슷하다고 판단되어서 첫 번째 api만 잘 가다듬는다면 나머지 api들도 대부분 비슷할 것이라고 생각되어 중간 중간 리팩터링 작업을 했던 것 같았다. 이 부분에서 팀원들과 공유하지 못하고 작업을 진행한 내 실수였던 것이다.
이후 팀원들과 회의를 통해서 작업에 대한 본인의 생각을 늦게 공유하게 되었고, 페어 프로그래밍을 하면서 다시 맞춰가는 작업을 했던 것 같았다. 앞으로는 어떤 작업을 착수하기 전에 팀원들과 어떤 부분에 대해서 수정 및 작업을 할 것인지 좀 더 명확하게 할 필요성을 느꼈다.
URL에서 대문자를 사용해야하는가 소문자를 사용해야 하는가?
프로젝트를 진행하면서 초기에 시간 범위(time_window)에 대한 query param을 아래와 같이 대문자를 사용해서 날렸었다.
/api/v1/kpi/restaurant?start_time=2022-02-23&end_time=2022-02-25&time_window=DAY&restaurant_group=1
근데 생각해보면 대문자로 들어오더라도 소문자로 바뀌는 경우를 본 적이 있었고, 서칭을 좀 해보니 대부분 소문자를 사용한다고 한다.
왜냐하면 UI/UX 관점에서 모바일에서는 대문자를 입력하는데 불편함이 있을 수 있다는 점이 있다는 게 있어서 입력 인자를 대문자로 들어오더라도. lower() 함수를 사용해서 소문자로 바꾸어 처리를 한다던지, 입력을 소문자로만 하여 바꿔주었다.
Query Parameter의 변수명을 Dash(-)로 연결하는가? 아니면 Underbar(_)로 연결하는가?
프로젝트를 진행하기에 앞서 변수명, 클래스명에 대한 case convention을 아래와 같이 정하고 시작하였다.
- Class 명
- PascalCase
ex)
ad_info → AdInfo
result_data_set → ResultDataSet
- Model(Table, Column)
- snake_case
- Function(get, set, check)
- lower snake case
- Variables
- lower snake case
발생한 이슈로는 다음과 같았다. 검색 입력 param을 아래처럼 받아왔다.
즉, 변수명은 lower snake case로 사용하였고 값을 받아오는 부분에는 '-'으로 연결했었다. 이 때문에 swagger 문서에서 테스트 시 파라미터를 넘겨주려고 하는데 serializer에 변수명을 '_'로 선언을 하니 문서상에서 다음과 같이 인자 값 자체를 '_'로만 받아오는 것이었다.
그러므로 값을 넣어도 'start-time'에 대한 쿼리 인자 값을 가져올 수 없었다. 그래서 질문을 해보니 swagger 설정에서 바꿀 수 있다고는 하였지만, 애초에 그냥 '_'로 통일시키는 게 좋아 보인다고 해서 언더 바로 통일했던 기억이 있다.
데이터 키값 출력 annotate 관련
이번 프로젝트에서는 기간별로 데이터를 뽑아내어 결과를 도출해내는 것이 핵심이었다.
예를 들어 time window를 month로 요청을 날린다면,
[
{
"restaurant_id": 21,
"month": 2,
"total_price": 160000
},
{
"restaurant_id": 22,
"month": 2,
"total_price": 280000
}
]
위와 같이 "month"라는 key값으로 나와야 하고, time window를 day로 요청을 날린다면,
date, restaurant_id, total_price
'2022-02-23', '21', '40000'
'2022-02-25', '21', '40000'
'2022-02-23', '22', '80000'
'2022-02-24', '22', '120000'
'2022-02-25', '22', '80000'
테이블 형태이지만 위처럼 "date"로 key값이 달리 나와야 한다.
처음에 이 부분을 어떻게 해결해야 할지 잘 몰라서 서칭을 하던 도중 발견한 스택 오버플로우!
이를 통해 힌트를 얻었으며, 피드백 내용 중에서 annotate시 annotate_options를 주어서 처리할 수 있다는 방법을 알 수 있게 되어 바로 적용해 보았다. 처음에는 아래와 같이 if-else의 반복이었다..
if time_window == 'hour':
result_data_set = PosResultData.objects.filter(q)\
.annotate(hour=time_window_archive[time_window]).values('hour')\
.annotate(restaurant_id=F('restaurant'), payment=F('payment'))\
.annotate(count=Count('payment'))\
.values('hour', 'restaurant_id', 'payment', 'count')
elif time_window == 'day':
result_data_set = PosResultData.objects.filter(q)\
.annotate(day=time_window_archive[time_window]).values('day')\
.annotate(restaurant_id=F('restaurant'), payment=F('payment'))\
.annotate(count=Count('payment'))\
.values('day', 'restaurant_id', 'payment', 'count')
# 생략
이 부분을 아래와 같이 수정하였다.
time_window_archive = {
'hour' : ExtractHour('timestamp'),
'day' : ExtractDay('timestamp'),
'week' : ExtractWeek('timestamp'),
'month': ExtractMonth('timestamp'),
'year' : ExtractYear('timestamp')
}
# Aggreate
annotate_options = {
time_window: self.time_window_archive[time_window.lower()],
'total_price': Sum('price')
}
data = queryset.values('restaurant_id').annotate(**annotate_options)
annotate_options에서 time_window는 입력 query param이고. lower() 함수를 통해 해당 key값을 time_window_archive 딕셔너리에서 찾는다.
이후 **annotate_options를 적용하면 끝!
실제 출력 결과를 살펴보자.
// request url (time_window: month)
// /api/v1/kpi/restaurant?start_time=2022-02-23&end_time=2022-02-25&time_window=month&restaurant_group=1
[
{
"restaurant_id": 21,
"month": 2,
"total_price": 160000
},
{
"restaurant_id": 22,
"month": 2,
"total_price": 280000
}
]
// request url (time_window: day)
// /api/v1/kpi/restaurant?start_time=2022-02-23&end_time=2022-02-25&time_window=day&restaurant_group=1
[
{
"restaurant_id": 21,
"day": 23,
"total_price": 40000
},
{
"restaurant_id": 21,
"day": 24,
"total_price": 80000
},
{
"restaurant_id": 21,
"day": 25,
"total_price": 40000
},
{
"restaurant_id": 22,
"day": 23,
"total_price": 80000
},
{
"restaurant_id": 22,
"day": 24,
"total_price": 120000
},
{
"restaurant_id": 22,
"day": 25,
"total_price": 80000
}
]
Django ORM Q 객체
Django ORM에서 쿼리문처럼 OR나 AND 조건을 사용하려면 Q 객체를 사용할 수 있다.
#sql 쿼리문
select * from product where category=소 or sub_category=등심
# 장고 orm
Product.objects.filter(Q(category=소) | Q(sub_category=등심))
위의 예시처럼 " | "를 사용한다면 SQL에서 OR 연산이고,
" & "를 사용한다면 SQL에서 AND 연산을 수행할 수 있다.
아래는 우리 프로젝트에서 적용한 내용이다.
# Query
q = Q()
# 기간 검색
q &= Q(timestamp__range=(start_time, end_time + timedelta(days=1)))
# 금액 범위 검색
if start_price and end_price:
q &= Q(price__range=(start_price, end_price))
# 인원 범위 검색
if start_number_of_people and end_number_of_people:
q &= Q(number_of_party__range=(start_number_of_people, end_number_of_people))
# 특정 레스토랑 검색
if restaurant_group:
q &= Q(restaurant__group=restaurant_group)
# 결재수단 검색
if payment:
q &= Q(payment=payment)
return PosResultData.objects.filter(q)
기간에 대한 쿼리는 필수이므로 조건이 붙지 않는 모습이다.
더 자세한 내용은 아래 링크 참조
Django F() 객체
Serializer의 2가지 용도 정리
1. GET
- GET One: PostSerializer(obj).data
- 결과는 Dictionary
- GET List: PostSerializer(objs, many=True).data
- 결과는 List of dictionary
- 이 과정에선 validation 은 불 필요합니다.
2. POST
- POST One
- serializer 생성: serializer = PostSerializer(data=data)
- data 는 Dictionary
- 모델 생성에 앞선 검증: serializer.is_valid()
- 이과정에서 모델 자체의 validation + serializer 에 정의 된 validation 이 발동.
- 모델 생성: serializer.save()
- POST List
- serializer 생성: serializer = PostSerializer(data=data_list, many=True)
- data_list 는 List of Dictionary
- 이하 같음.
- (모델이 리스트내 Dict 개수만큼 생성)
- .data 는 GET 할때만
- data=.., is_valid(), .save() 는 POST 에서만
- many=True 는 두 경우 모두 사용됩니다.
피드백 내용
💡 Good!
- README 가 깔끔하고 필요한 정보가 잘 정리되어 있음.
- Swagger tool로 제공.
- KPI 쪽 코드 및 주석으로 전반적인 형태가 깔끔함.
- Generic 적용.(진행 중)
☝ Tips!
- 각 과제 요구사항 분석 + 타임라인(보드) + Git repository 페이지 링크도.
- API에 app name 제거(또는 RESTful 하게 사용).
- 가독성(API 로직 이해를 위한)을 위한 함수 분리(Validate 분리).
이렇게 두 번째 프로젝트에 대한 내용을 쭉 정리해보았다. 지난번 프로젝트에 비해 고려해야 하는 조건들이 까다로웠다고 생각한다. 게다가 초반에 요구사항을 분석하는데 해외와 연관된 기업이라서 그런지 거의 대부분의 설명이 영어로 되어있었다.
그래서 초기 방향성을 잡고 문제를 이해하는 부분에서 시간이 좀 걸렸던 것 같았다. 하지만 설계를 끝내고 난 뒤, 작업을 진행하는 데 있어서는 지난번보다 속도감이 느껴졌었다. 분업화도 어느 정도 되었고, 서로 지식을 공유하면서 단계를 진행하려는 모습이 보였었다. 다음번 휴먼 스케이프 프로젝트에서는 어떤 문제가 있었고 뭘 배웠는지 정리해보려고 한다.
'💪 Study 참여 > 원티드 프리온보딩 백엔드' 카테고리의 다른 글
[ 원티드 프리온보딩 ] Human Scape - 공공 API 활용 임상정보 Batch Task Server (0) | 2022.06.03 |
---|---|
[ 원티드 프리온보딩 ] MadUp - 매체별 기간 내 광고 효율 분석 (3) | 2022.05.13 |