엑셀 내보내기 작업 성능 개선 (2시간 -> 10분)

haremeat·2024년 8월 8일
1

Django

목록 보기
16/16
post-custom-banner

개요

특정 Model의 필드들을 엑셀 파일로 만들어 사용자의 이메일로 전송해주는 기능을 celery 비동기 작업으로 구현한 부분에서 문제 발생.

문제

  • 대용량 데이터의 경우 엑셀 내보내기를 할 때 무한 pending상태가 지속되다 메모리가 상승하고 결국 서버가 꺼지는 일이 발생
  • 데이터가 8만개 정도만 되어도 엑셀이 만들어지는데 2시간을 넘김

원인

원인은 크게 두 가지 있었다.

serializer를 통한 데이터 직렬화

엄밀히 말해 serializer를 통해 데이터를 구성하는 게 오래 걸리기보다는
serializer instance가 매번 재생성되느라 성능 이슈가 있었던 것.
아마 serializer를 그때마다 빼내줬으면 2시간 걸릴 거 30분만에 끝났을 수도 있다.

데이터가 그냥 너무 많음

아무튼 데이터가 많으면 느린 건 자연의 이치...

해결

serializer 방식 제거

사실 엑셀 내보내기를 하는 데 굳이 serializer를 사용하여 field를 정의할 필요는 없다.

위에서 적었듯 메모리 상승의 주 원인은 instance 재생성이라 굳이 기존 코드를 다 갈아엎을 필요는 없었지만 아무튼 serializer를 사용하는 것보다는 그냥 queryset에서 field 직접 빼내서 사용하는 게 성능적으로는 더 나았기때문에 다시 만들었다.

기존 방식은 ExcelExportSerializerMixin이라는 serializer class를 만들고 mixin을 override하여 뽑을 필드를 정의했다.

새로 바꾼 방식은 BaseExcelExporter라는 class를 만들어서 뽑고싶은 필드를 get_fields라는 method에서 정의하도록 만들었다. 마찬가지로 override해서 사용한다.

class BaseExcelExporter:
    model = None

    def __init__(self, queryset):
        if self.model is None:
            raise NotImplementedError(_('subclass에서 반드시 model값을 정의해야 합니다.'))
        self.queryset = queryset

    def get_fields(self, queryset):
        raise NotImplementedError

대충 이런 식이다.
실제 엑셀 파일을 만들고 메일로 보내는 로직도 BaseExcelExporter로 옮겼다.

처음엔 task 실행 함수 안에서 처리했는데, 여기에는 메인 로직이 아닌 celery task를 처리하기 위한 로직만 들어가야 한다는 리뷰를 받고 수정했다.

batch_size 사용

방식을 대폭 변경했는데도 여전히 큰 데이터의 경우 너무 오래 걸렸다.
때문에 batch_size값을 설정하고 (설정값은 자유)
queryset을 batch_size로 나눠서 쿼리를 돌렸다.

concurrent.futures 사용하여 병렬처리

        with concurrent.futures.ThreadPoolExecutor() as executor:
            futures = []
            for start in range(0, self.queryset.count(), batch_size):
                batch_queryset = self.queryset[start : start + batch_size]
                futures.append(executor.submit(self.get_fields, batch_queryset))

batch_size까지만 했을 때 이미 성능 개선은 많이 이뤄진 후였다.
하지만 조금 더 성능 개선을 위해 concurrent.futures 모듈로 데이터를 병렬 처리하여 시간을 조금 더 줄였다.

결과

최종적으로 2시간이 넘게 걸리던 86051건의 데이터 처리를 10분 정도로 줄였다.

사족

queryset이 평가되는 시점

처음엔 원인 분석을 잘못하여 수정할 필요 없는 코드까지 수정하였다.

queryset = self.filter_queryset(self.get_queryset())
pickled_queryset_data = pickle.dumps(queryset.query)

위처럼 task 실행 함수에 filtering된 queryset을 pickle을 통해 bytes로 바꿔서 넘겨주는 코드가 있었다.
self.filter_queryset(self.get_queryset())를 실행할 때 쿼리셋이 평가된다고 보고 이런 무거운 로직은 task 안에 있어야지; 하며 params를 넘겨서 task 안에서 쿼리셋을 필터링하도록 변경했었음.

하지만 저 시점에서는 queryset이 평가되지 않는다. (참고)

lazy하게 작동하기 때문에 실제로 queryset을 loop를 돌거나 값을 access하려고 할때 평가되기 때문.
왠지 성능에 별 영향 없는 것 같더라니... 정말로 영향이 없었음.

그리고

  • 직접 query param을 넘김
  • Model.obejcts.all()
  • filterset_class받아와서 task 안에서 필터링하는 방식

이러면 보안상 이슈가 발생하여 바로 원복시킴.
권한이 없는 대상도 query param을 통해 추출할 수 있게되어버린다.

profile
버그와 함께하는 삶
post-custom-banner

0개의 댓글