본문 바로가기

Posts/Django

실서비스에 Django ORM API 삽질 + 성능 개선기

반응형

본 포스팅에 적혀진 코드 작성 부분의 필드명, DB명은 포스팅을 위해 가상으로 작성하였습니다.

대신 코드는 직접 작성하였고 업무상의 내용이 포함되어 자세한 내용은 생략하였습니다.

1. 문제상황

Django를 통해 API를 개발하면 SQL 대신 ORM을 사용하게 됩니다.

Django는 ORM을 작성 할때 debug_toolbar라는 미들웨어를 통해 보다 쉽게

query explainselect query문을 조회할 수 있습니다.

스크린샷 2021-12-18 오전 12 30 44

쿼리의 갯수와 실행계획, ORM을 통해 작성된 쿼리 확인 가능

평소처럼 회사에서 개발하고 있는데 년/월 기준 전체 강사들의 정산 페이지의 정보를 한번에 볼 수 있는

리스트 페이지 API를 개발해야 하는 업무가 생겼습니다.

작업해야하는 API의 조건은 전혀 문제되지 않아 작업에 들어가기 전에 DB를 확인하였습니다.

단순히 기존 강사 1명의 정산 페이지를 작업한 API와 정산용도로 만들어놨던 함수를 그대로 강사들의 수만큼 반복하면

쉽게 해결될 것 같아 코드를 작성하였습니다.

작업이 끝난 이후 API를 요청을 확인하는데 이상하게 pending이 걸려 화면이 보이지 않았습니다.

확인해보니 broken pipe.. 새로고침을 하고 다시 요청하니 캐시가 되어 그나마 빠르게 속도가 나왔지만

캐시가 되지 않은 페이지에서는 1.3min라는 엄청 느린 속도로 동작하고 있었습니다. (쿼리 캐시가 되어도 6~10초 소요)

before

debug_toolbar로 확인한 쿼리


2. 원인 파악

정산 API의 가장 큰 특징은 정산 예정정산 완료의 구분과 예정일때

결제가 키트, 패키지, 강의 따로 구분되어야 한다는 점이었습니다.

정산완료일 경우 정산 스냅샷 테이블에 저장이 되기 때문에 전혀 문제가 없으며 정산 예정일때 문제가 발생하였습니다.

기본적으로 정산은 각각 강의, 패키지, 키트를 기준으로 쿼리셋을 조회하고

해당 쿼리셋으로 정산에 필요한 연산을 각각 하는 로직이었습니다.

가장 크게 의심해볼만한 것은 쿼리셋패키지 연산쪽이었습니다.


2-1. xxx_queryset()

# 대략적인 구조는 이런식으로 구성되어 있다 표현하기 위한 용도 (실제 코드와는 다름)
# 정산 클래스
invoice = MasterInvoice() 

# 모든 강사들 user_set (패키지용)
user_set = UserTable.objects.prefetch_related(
  'xxx__set__xxx__xxx'
).filter(
  user_type="강사",
).values(
  'id',
).annotate(
  Count('id'),
).annotate(
  teacher_name=F('강사명'),
).values('id', 'teacher_name')


 course_id_set = Teacher.objects.all().values_list('course_id', flat=True)
 course_query_set = invoice.course_queryset(
   year, month, course_id_set
 )
 total_invoice_set = invoice.course_calculate_rows(course_query_set)

강의, 패키지, 키트 각각의 쿼리셋들은 해당 년, 월 모든 강사들의 강의, 패키지, 키트 rows들을 불러왔습니다.

강의 쿼리셋과 연산 함수엔 큰 문제가 없었고 잘 작성되어져서

debug_toolbar에서 확인했을때 따로 의심되는 부분은 없었습니다.

스크린샷 2021-12-23 오전 12 04 29

적절한 속도로 잘 동작하고 있는 것을 발견

image

혹시 몰라 해당 쿼리셋이 있는 부분에 time.time() 함수를 사용해 끝시간에서 시작시간을 빼서 소요시간을 계산해봤습니다.

위 2개만 보더라도 문제를 야기시킬만한 문제는 아니라고 판단하였습니다.

이후 정산에 필요한 패키지와 키트 부분을 확인하였습니다.


2-2. package & kit

   # 문제의 그 지점  
for account in account_set: # n번
    course_id_set = Course.objects.filter(
        teacher_id=account['id']
    ).values_list('id', flat=True) # n번

    if course_id_set: # 나름 있을때만 조건에 포함
        package_query_set = = invoice.package_queryset(year, month, course_id_set)
            if product_query_set:
                total_invoice_set += invoice.package_calculate(
                    package_query_set, course_id_set, account['teacher_name']
                )

                kit_query_set = invoice.kit_queryset(
                    year, month, course_id_set, account['id']
                )
                if kit_query_set:
                    total_invoice_set += invoice.kit_calculate(
                        kit_query_set, account['teacher_name']
                    )

원인은 이쪽에서 발생하였습니다.

특히 패키지쪽에서 발생하였는데, 패키지는 정산시 결제 여러 정보가 한꺼번에

varchar 타입으로 저장된 필드를 사용해야하는 이슈가 있었습니다.

물론 강의, 키트도 사용했지만 패키지의 경우는 해당 패키지에 포함된 강의들(리스트 구성)을 개별로 정산해줘야 했기 때문이죠

결국 이 상황은 모든 강사들의 강의 리스트

해당 결제건의 패키지 구성된 강의 리스트와 비교해야하는 연산이 발생하게 됩니다.

위 코드만 봐도 강사의 n번, 강사별 course_id_set 쿼리셋을 구하고 course_id_set 리스트를 가지고

package_queryset() 함수내에서 리스트끼리 비교 + 연산이 발생하고 코드만 봐도 엄청난 반복 비교가 발생하고 있었습니다.

결과적으로 쿼리셋으로 강의별 정산 비율을 고려해

calculate 함수에 한번 더 연산을 처리해야하는 로직이 돌고 있었던 것입니다.

image

강사별 1번 정산 걸리는 속도를 측정해봤습니다.

강사 1명일때는 괜찮았지만 많은 강사들이 있다보니 결과적으로 이런 결과가 나오게 되었습니다.


3. 해결 과정

해결과정은 크게 2가지를 생각하였습니다.

  • 시간복잡도를 줄이기
  • 해당 요청을 강의, 패키지, 키트로 나누기

두번째 방법은 완전한 해결방안으로 느껴지지 않았고,

최근 공부하고 있던 자료구조를 떠올리며 시간복잡도를 개선해보자 라고 생각하였습니다.

일단 테이블 조회와 값을 찾아 비교 후 연산해주는 로직이 반복적으로 있어서

리스트보단 딕셔너리를 사용하는게 훨씬 빠를거라 생각했습니다.

정산에 필요한 정산 정보 테이블을 기준으로 해당 강사가 보유한 강의 id유저 id

키값으로 가지는 강의 딕셔너리와 패키지 딕셔너리 2개를 초기화 해줍니다.

두번째는 기존 강사 기준 -> 결제건 기준으로 변경하였습니다.


3-1. 쿼리셋 함수


  # 실제 table DB와 다릅니다. 
  # 쿼리작성 구조에 대해서만 작성하였습니다.
  def get_product_invoice_set(year, month, course_id_set):
            q = (Q(json_data__type="package") & Q(package_id__isnull=False))

            # subquery qs
        package_title_qs = Package.objects.filter(
            id=OuterRef('package_id')
        ).values('title')[:1]

        # list == list 간의 비교 course_id와 json_data__courseIds간의 비교
        whens = [When(
            Q(json_data__courseIds__contains=i) &
            (Q(year=year) & Q(month=month)),
            then=Cast(
                F('json_data__packageId'),
                output_field=FloatField()
            )
        ) for i in course_id_set]

                return Purchase.objects.annotate(
            json_data=Cast(F('user_data'), output_field=JSONField()),
        ).annotate(
            product_id=Case(
                *whens,
                default=None,
                output_field=IntegerField(),
            )
        ).values(
            'product_id'
        ).filter(
            q
        ).annotate(
            title=(Subquery(package_title_qs)),
            purchased_price=Case(
                When(
                    (Q(status_id=2) | Q(status_id=3)) & (Q(year=year) & Q(
                        month=month)), then=F('purchased_price')
                ), default=Value(0),
                output_field=IntegerField()
            ),
            refund_price=F('refund_price'),
            course_ids=F('json_data__courseIds')
              # etc 추가 다른 values
        ).values('id', 'product_id', 'title', 'purchased_price', 'refund_price', 'course_ids')

3-2. 연산 함수

 def product_calculate(package_set, user_set):
        course_dict = {}
        package_dict = {}
        for user in user_set:
            course_info_dict[user['course_id']] = {
                'commission': user['수수료율'],
                'account_id': user['유저 id'],
                'teacher_name': user['강사 이름'],
                'invoice_type': user['정산타입'],
            }
            package_dict[user['user_id']] = {}

       for package in package_set: 
                # package는 강의 결제 금액 및 정보를 가지고 있다.
              for course_key in package['course_ids']: # 패키지에 묶인 강의들
              # course_key는 course_id 
              if course_dict.get(course_key): 
                if package_dict[course_dict[course_key]['user_id']].get(package['package_id']):
                  package_dict[course_dict[course_key]['user_id']][package['package_id']][
                            '결제금액'] += package['결제금액']
                                    # ...연산 반복

                  else:
                  package_dict[course_dict[course_key]['user_id']][package_id['package_id']] = {
                            '금액': package['결제금액'] / len(package['course_ids'],
                            '환불액': package['환불금액'] / len(package['course_ids'],
                            '총액': 0,
                            '수수료율': 0,
                            '강사 이름': 0,
                            '정산타입': 0,
                        }
  • 결제 row 하나의 데이터에 value를 다음 dict의 key값으로 접근하였습니다.
  • 패키지 1개 결제건에 속한 데이터 강의 id_set을 반복합니다.
    • for course_key in package['course_ids']:
    • 해당 id값으로 초기화한 강의 dict에 접근하여 연산합니다. (이때 key값이 있을때만 접근)
      • if course_dict.get(course_key):
  • 패키지 dict에 강사의 user_id값을 기준으로 접근합니다.
    • 이때 key값이 있으면 누적해주고 key값이 없으면 초기화합니다.
    • if package_dict[course_dict[course_key]['user_id']].get(package['package_id']):

image

package_dict 를 출력해보면 위 사진과 같은 구조를 가지게 됩니다.

json 데이터를 보면 예를 들어 111번 user_id를 가진 강사는 23, 31번의 id를 가진 강의가

패키지 정산정보에 포함되어 있는 것을 확인할 수 있습니다.

이후 xxx_dict.keys() 를 활용해 id를 리스트로 만들어 반복을 돌면서 리스트로 바꿔 리턴해주게 됩니다.

image

개선된 쿼리셋 (편-안)


4. 마치며

이번 정산 리팩토링을 하며 자료구조 공부를 하고 있던 것이 매우 도움이 되었습니다.

스스로 코드를 잘못 작성한 부분도 있지만 문제점을 발견하고 약 6~10초정도 걸리는 API를

0.07~0.1초정도로 개선할 수 있었습니다.

탐색의 경우 리스트보다 딕셔너리를 사용하는게 훨씬 효율적이고 쿼리셋에 대해서도

많은 것들을 찾아보며 배우는 계기가 되었습니다.

특히 문제 해결을 고민하면서 새롭게 알게된 것들은 아래와 같습니다.

  • Caching
    • 모든 리스트를 가져오는 쿼리셋의 경우 모든 데이터가 캐싱되어 있기 때문에 이를 재사용하는 것이 더 효율이 좋다.
    • model = Model.objects.all()
      • 리스트의 순서를 가장 위에놓고 특정 값을 지정해서 사용하고 싶다면 위 쿼리셋의 값을 그대로 재사용
  • N+1 Problem
    • 쿼리 1개로 N건의 데이터를 가져온 후 원하는 데이터를 얻기 위해 N건의 데이터를 N번만큼 반복해 2차 쿼리를 수행하는 것
    • 쿼리셋은 기본적으로 lazy loading이지만 sql로 한 번에 여러 개의 데이터를 가져오고 싶을 때 이때를 eager loading
    • 이 문제를 해결하기 위해 select_related()prefetch_related() 를 적절히 사용
  • Query_set
    • 쿼리셋은 1개의 쿼리와 추가 쿼리로 구성
      • 추가 쿼리는 prefetch_related()가 해당되기에 prefetch_related는 사용시 Prefetch 함수 사용 권장
    • 권장 쿼리셋 작성 순서 Model -> annotate -> select_related -> filter -> prefetch_related

References

반응형