대용량 CSV 스트리밍 — StreamingHttpResponse · select_related
관리 페이지에서 CSV 내려받기 버튼을 누르면 수십 초 아무 반응이 없다가 파일이 떨어지고, 그동안 서버 메모리는 계속 올라갑니다.
Django 로 만든 화면이고 운영 쪽에서 데이터를 엑셀로 열어 보려고 쓰고, 버튼 하나가 전부입니다. 누르면 서버가 목록을 조회해 CSV 로 만들어 응답으로 내려주는 구조라, 초기에 행이 몇백 개뿐일 때는 누르는 즉시 파일이 떨어졌습니다.
브라우저에는 다운로드가 시작됐다는 표시조차 안 떠서, 쓰는 쪽에서는 버튼이 안 먹은 것과 구분이 안 됐습니다.
기능이 바뀐 건 없고 행 수만 늘었으니, 거기에 비례해 커지는 자리를 찾으면 됩니다. 코드에서 전체 목록을 한 번에 들고 있는 데가 어디인지부터 봤습니다.
HttpResponse — 전부 메모리에 생성
처음 구현은 HttpResponse 에 CSV 를 통째로 씁니다.
def driver_csv(request):
response = HttpResponse(content_type='text/csv')
response['Content-Disposition'] = 'attachment; filename=list.csv'
response.write(u''.encode('utf8')) # 엑셀 한글용 BOM
writer = csv.writer(response)
writer.writerow([...]) # 헤더
for row in Driver.objects.all():
writer.writerow([...])
return response
HttpResponse 는 본문이 다 만들어진 다음에 한 번에 나가서, CSV 전체를 메모리에 쌓아 두고 마지막
행까지 쓴 뒤에야 응답이 시작됩니다. 행이 수만 개면 그 전체가 메모리에 올라가 있는 동안
브라우저는 다운로드를 시작조차 못 하고 기다립니다. 눌러도 반응이 없던 구간이 여기입니다.
StreamingHttpResponse로 한 줄씩 내보내기
StreamingHttpResponse 는 응답을 조각 단위로 내보냅니다. 전체를 다 만들 때까지 기다리지 않고
만든 만큼 바로 보내는 방식.
from django.http import StreamingHttpResponse
class Echo:
"""write()가 값을 그대로 돌려주게 해서 csv.writer가 조각을 만들게 한다"""
def write(self, value):
return value
def driver_csv(request):
def rows():
writer = csv.writer(Echo())
yield u'' # BOM
yield writer.writerow([...]) # 헤더
for d in Driver.objects.all().iterator():
yield writer.writerow([...])
response = StreamingHttpResponse(rows(), content_type='text/csv')
response['Content-Disposition'] = 'attachment; filename=list.csv'
return response
csv.writer 는 원래 파일 같은 객체에 값을 쓰는 물건이라, Echo 라는 가짜 객체를 줘서 write
가 받은 값을 그대로 돌려주게 했습니다. 그러면 writer.writerow(...) 가 CSV 한 줄 문자열을
반환하고, 그걸 yield 로 하나씩 내보낼 수 있습니다.
같이 붙은 .iterator() 도 빼면 안 됩니다. Driver.objects.all() 을 그냥 돌면 Django 가 결과
전체를 QuerySet 캐시에 올려 둡니다. 응답은 스트리밍인데 데이터는 여전히 메모리에 다 있는 상태.
.iterator() 를 붙이면 DB 커서로 조금씩 가져오면서 캐싱도 안 하니, 응답을 내보내는 단위와
데이터를 가져오는 단위가 맞아떨어집니다.
스트리밍으로 바꾸고도 남은 N+1
메모리는 잡혔지만 여전히 느렸고, 원인은 CSV 각 행에 연관 객체 필드를 넣고 있던 쪽이었습니다.
# 행마다 driver를 다시 조회 → 행 수만큼 쿼리
transfers = Transfer.objects.all()
for t in transfers:
writer.writerow([t.driver.name, ...]) # t.driver 접근마다 쿼리 1번
Transfer 는 한 번 조회하는데, 각 행에서 t.driver 에 접근할 때마다 DB 를 또 불렀습니다. 행이
1만 개면 쿼리가 그만큼 더 나가고, 스트리밍으로 바꾼 뒤라 이 쿼리도 한 줄 내보낼 때마다 하나씩
나가면서 응답이 계속 느려졌습니다.
select_related 로 연관 객체를 JOIN 해서 한 번에 가져옵니다.
transfers = Transfer.objects.all().select_related('driver')
select_related 는 외래 키처럼 한 개짜리 관계를 JOIN 으로 미리 당겨옵니다. 이제 t.driver 에
접근해도 추가 쿼리가 안 나가고 쿼리가 1만 번에서 1번으로 줄었습니다. 다대다나 역참조 관계면 JOIN
으로 안 되니 prefetch_related 를 씁니다.
응답 만드는 쪽과 데이터 가져오는 쪽
큰 데이터를 CSV 로 내보낼 때는 두 자리를 같이 봐야 합니다. 응답을 어떻게 만드는지가 메모리를 정하고 데이터를 어떻게 가져오는지가 시간을 정합니다. 스트리밍만 하고 N+1 을 두면 메모리는 잡혀도 여전히 느리고, N+1 만 잡고 응답을 통째로 만들면 빨라져도 행이 늘면 다시 메모리가 터집니다.
내보내기가 느리다는 얘기가 나오면 먼저 응답 클래스가 HttpResponse 인지 보고 스트리밍으로 바꾼
다음에도 느리면 쿼리 수를 셉니다. 행 수만큼 쿼리가 나가고 있으면 그 자리에 select_related 나
prefetch_related 를 붙이면 됩니다.
댓글 0