위코드 기업협업(브랜디 인턴쉽) 과제 리뷰 - Backend 기준

Taeha Kim·2020년 10월 20일
2

PROJECT

목록 보기
3/3
post-thumbnail

위코드 기업협업(브랜디 인턴쉽)

위코드 부트캠프 11기 기업 협업(브랜디 인턴쉽) 과제로 진행한 BRANDI 웹사이트 클론 입니다.

  • DB 커넥션과 트랜잭션에 중점을 두고 구현하였으며, raw sql을 사용하여
    데이터베이스에서 레코드를 추가, 조회, 수정, 삭제(논리 삭제)를 하였습니다.

개발 인원 및 기간

개발 인원

Service

개발 기간

  • 2020/09/14 ~ 2020/10/15

프로젝트 목적

패션 커머스 기업 브랜디 웹사이트를 클론함으로써 모델링과 회원가입, 소셜 로그인, 상품 구매와 같은 핵심 기능을 구현하고, 각자의 개발 역량을 기르고자 한다.

Modeling

적용 기술 및 구현된 기능

Frontend, Backend 공통

  • Git
  • Scrum 방식의 프로젝트 진행

적용 기술

  • Python, Flask web framework
  • Bcrypt
  • JWT
  • MySQL
  • AWS EC2, RDS

내가 구현한 기능

  • 회원가입시 정규식을 사용한 회원정보 유효성 검사
  • Bcrypt를 활용한 비밀번호 암호화
  • JWT를 활용한 엑세스 토큰 발행
  • 구글 소셜 로그인
  • 회원당 최대 5개 까지의 배송지 정보를 가질 수 있게 구현하고 배송지는 수정 및 삭제가 가능함
    처음 등록한 배송지는 기본 배송지로 설정됨
    기본 배송지를 삭제하면 가장 최근에 추가한 배송지를 기본 배송지로 변경함
  • 마이페이지에서 회원이 구매한 상품의 데이터를 DB에서 가져와 보여주고, 주문상태에 따라서 주문 취소 및 환불 할 수 있게 구현

구현 영상(이미지 클릭해서 확인)

기억에 남는 코드

상품 구매시 주문 번호 생성하기

business layer

class PurchaseService:
    def __init__(self, purchase_dao):
        self.purchase_dao = purchase_dao

    def purchase(self, token_paylod, requestion, db):
        # 현재 DB 날짜 확인 ex) 20201013
        db_time_cherker = self.purchase_dao.db_time(db)
        today = db_time_cherker['db_today']
        # 구매한 상품의 개수
        product_count = len(requestion['option_id'])

        # 최근의 주문 번호 가져오기
        recent_order_number = self.purchase_dao.order_number_getter(db)
        # 최근의 주문 상세 번호 가져오기
        recent_order_detail_number =  self.purchase_dao.detail_number_getter(db)
        # 서비스 시작후 처음 주문한 경우나 하루가 지났을 경우 기본 상품 구매번호와 상품 상세 구매번호를 0으로 설정해준다.
        if recent_order_number is None or today > recent_order_number['order_number'][:8]:
            order_number = 0
            order_detail_number = 0
            
        else:
            order_number = int(recent_order_number['order_number'][8:])
            order_detail_number = int(recent_order_detail_number['order_detail_number'][9:])
 
        requestion['order_number'] = today + str(order_number + 1).zfill(9)
        # 주문 자체는 한번 실행 되므로 order_result의 결과로 1이 반환된다. 
        order_result = self.purchase_dao.order(token_paylod, requestion, db)

        # order_result가 제대로 실행되었으면 1이다.
        if order_result == 1:
            for count_number in range(product_count):
                requestion['order_detail_number'] = 'B' + today + str(order_detail_number + count_number+1).zfill(8)
                order_id = self.purchase_dao.order_id_getter(token_paylod, db)
                order_detail_results = self.purchase_dao.order_detail(token_paylod, requestion, count_number, order_id, db)
                order_status_results = self.purchase_dao.order_status(order_id, db)
                if order_detail_results != 1 and order_status_results !=1:
                    return False             
            return True  
        return False

persistence layer

class PurchaseDao:
    def order_number_getter(self, db):
        with db.cursor() as cursor:
            sql = """
            SELECT order_number 
            FROM orders 
            WHERE id = (SELECT MAX(id) FROM orders)
            FOR UPDATE
            """
            cursor.execute(sql)
            results = cursor.fetchone()
            return results

설명

우선 위의 주문번호를 생성하는 로직에는 문제가 있습니다.
무슨 문제가 있는지 그리고 무엇을 알게 되었는지 설명하겠습니다.

예를 들어서 2020년 10월 20일에 첫 구매가 발생하면 20201020000000001 이런 식으로 주문번호를 생성하게 됩니다. 만약 그후에 누군가가 구매를 하면 20201020000000002 이렇게 숫자가 하나씩 증가합니다.

숫자가 증가하면서 주문번호가 생성되다가 하루가 지나면 다시 1부터 시작하여 20201021000000001 이렇게 날짜에 9자리의 숫자를 더해주는 식인데

처음 기능 구현을 할때에는 DB에서 가장 최신의 주문번호를 가져와서 1씩 더하는 식이였습니다.
위의 방법으로 기능을 구현 하다가 문제가 있다고 생각했는데 문제는 다음과 같습니다.

예를들어서 가장 최근 주문번호가 20201020000000001이고 그 다음 두번째 구매가 발생해서 DB에서 20201020000000001를 가져와서 20201020000000002를 만들었다고 가정하면, 두번째 구매가 온전히 DB에 반영(commit)되고 세번째 구매가 발생해서DB에 있는 최신 구매번호인 20201020000000002를 보고 20201020000000003을 만드는데 만약 두번째 구매가 DB에 반영(commit)되기 전에 세번째 구매가 발생하면

두번째 구매도 20201020000000001를 보고 주문번호20201020000000002를 생성하고,
세번째 구매도 20201020000000001를 보고 주문번호20201020000000002를 생성하게 되어 주문번호가 중복되게 됩니다.

따라서 위와 같은 문제를 막기위해 고심하여, 선택한 방법은 SQL문중에서 FOR UPDATE를 사용하는 것입니다. FOR UPDATE를 사용하여 트랜잭션이 동시에 생성될때 같은 주문번호를 참조 하지 못하게 막아서 주문번호의 중복 문제를 해결했습니다.

여기까지가 위의 써놓은 코드 입니다. 제가 위의 코드로 코드 리뷰를 받았는데,
위의 방식에도 문제가 있다는것을 알았습니다.

위의 코드와 같이 FOR UPDATE를 사용하면, 테이블락이 걸리는데, 만약 해당 프로세스가 길어지게 된다면, 그 뒤에 따라오는 모든 트랜잭션이 그만큼의 딜레이가 발생하게 될것입니다.
따라서 위와 같은 방법은 잘못되었으며, 제가 받은 피드백은 다음과 같습니다.

DB에서 로직을 처리하라!

즉, DB에서 SQL문으로 최신 주문 번호를 생성하고 바로 반영하는 식으로 하여 빠르게 구매가 진행 될 수 있도록 만드는 것입니다.

SQL 쿼리문으로 주문번호를 생성하면 다음과 같습니다.

SELECT CONCAT((SELECT DATE_FORMAT(NOW(), '%Y%m%\d')), LPAD(
(SELECT IFNULL((SELECT IF((SELECT date_format(order_date, '%Y-%m-%d') < CURRENT_DATE() FROM orders WHERE id = (SELECT MAX(id) FROM orders)),0, 
(SELECT cast(substr(order_number, 9)  as decimal) FROM orders WHERE id = (SELECT MAX(id) FROM orders)))),0) +1 ), 9, '0')) as order_number;

최신 주문번호를 가져와서 오늘 날짜와 비교하여 하루가 지났는지 확인하고 지났다면 0으로 만들고 안지났다면 최신주문 번호의 날짜부분을 제외한 숫자(구매 개수)만 가져옵니다.
만약에 서비스를 처음 시작했다면 주문번호가 없을것이기에 IFNULL을 사용하여 NULL값이면 0으로 만들어 줍니다. 주문번호는 날짜를 제외한 숫자만 가져오기 위해서 substr를 이용하여 필요한 문자열을 추출하고 cast를 이용하여 10진수로 바꾸어 주었습니다.
그후 LPAD를 사용하여 날짜 부분을 제외한 숫자를 9자리이며 구매개수를 제외한 자리를 0으로 만듭니다.
마지막으로 DB 기준으로 오늘의 날짜를 가져와서 CONCAT으로 합칩니다.

파이썬의 내장 라이브러리중에서 현재 날짜를 확인 할 수 있는 라이브러리가 있으며,
이를 이용하여 서버 시간을 기준으로 현재 날짜를 확인 할 수 있지만
서버 시간을 기준으로 주문 번호를 만들게 되면 문제가 생깁니다.

실제 서비스에서는 서버가 못해도 최소 2대 이상은 돌아가고 보통 수십대가 가동되는데
서버시간은 서버마다 조금씩 차이가 있습니다.
이렇게 될경우에 다음과 같은 문제가 발생합니다.

예를들어서 오늘 자정까지만 특정 상품을 50%세일을 한다고 하며, 밤 11시 59분 59초에 이 상품을 여러명이 동시에 구매를 했다고 하면, 서버 시간이 기준일 경우
누구는 서버시간이 몇초 느려서 구매가 가능하고, 누구는 서버 시간이 몇초 빨라서 구매하지 못하는 경우가 생깁니다. 따라서

시간이나 날짜는 서버가 아니라 DB를 기준으로 합니다.


개인적으로 주문 번호 생성에 대해서 고민할때가 SQL 쿼리문의 사용법이나
트래픽이 동시다발적으로 발생하게 될 경우
어떤 문제가 생기는지에 대해서 많이 고민하고 배운거 같습니다.

profile
함께 성장하는 개발자가 되고 싶습니다.

3개의 댓글

comment-user-thumbnail
2021년 4월 21일

좋은 글, 유익하게 잘 읽었습니다!
현재 브랜디 면접 준비중인 신입 백엔드 구직자입니다. 혹시 실례가 안된다면, 면접 준비하면서 특히 중요하게 준비할 게 있을까요?
아니면 면접 준비는 어느정도까지 하는게 좋을까요? 물론 준비는 많이 하는것이 좋겠지만, 어느 부분을 어느 정도까지 준비해야할 지 궁금해서요

1개의 답글