특정 Model의 필드들을 엑셀 파일로 만들어 사용자의 이메일로 전송해주는 기능을 celery 비동기 작업으로 구현한 부분에서 문제 발생.
원인은 크게 두 가지 있었다.
엄밀히 말해 serializer를 통해 데이터를 구성하는 게 오래 걸리기보다는
serializer instance가 매번 재생성되느라 성능 이슈가 있었던 것.
아마 serializer를 그때마다 빼내줬으면 2시간 걸릴 거 30분만에 끝났을 수도 있다.
아무튼 데이터가 많으면 느린 건 자연의 이치...
사실 엑셀 내보내기를 하는 데 굳이 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값을 설정하고 (설정값은 자유)
queryset을 batch_size로 나눠서 쿼리를 돌렸다.
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 = 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을 통해 추출할 수 있게되어버린다.