대용량 CSV 스트리밍 — StreamingHttpResponse · select_related

만든 것3분조회

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

이 방식은 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 가 결과 전체를 메모리에 캐싱합니다. .iterator() 를 붙이면 한 번에 다 안 들고 DB 커서로 조금씩 가져옵니다. 스트리밍과 짝을 맞추는 겁니다.

이제 사용자는 다운로드가 바로 시작되고, 서버는 전체를 메모리에 안 올립니다.

숨어 있던 N+1

스트리밍으로 바꾸고 나서도 느린 게 남았는데, CSV 각 행에 연관 객체 필드를 넣고 있던 게 N+1 쿼리였습니다.

# 행마다 driver를 다시 조회 → 행 수만큼 쿼리
transfers = Transfer.objects.all()
for t in transfers:
    writer.writerow([t.driver.name, ...])   # t.driver 접근마다 쿼리 1번

Transfer 를 한 번 조회하고, 각 행에서 t.driver 에 접근할 때마다 DB 를 또 부릅니다. 행이 1만 개면 쿼리가 1만 번 더 나갑니다.

select_related 로 연관 객체를 JOIN 으로 한 번에 가져옵니다.

transfers = Transfer.objects.all().select_related('driver')

select_related 는 외래 키(한 개짜리 관계)를 JOIN 으로 미리 당겨옵니다. 이제 t.driver 에 접근해도 추가 쿼리가 안 나갑니다. 쿼리가 1만 번에서 1번으로 줄었습니다.

스트리밍과 N+1 — 둘 다 잡아야 하는 이유

큰 데이터를 CSV 로 내보낼 때 두 가지를 같이 봐야 했습니다.

  • 응답을 어떻게 만드느냐 — 전체를 메모리에 만들지 말고 스트리밍으로 내보냅니다 (StreamingHttpResponse + .iterator())
  • 데이터를 어떻게 가져오느냐 — 행마다 연관 객체를 다시 조회하지 말고 JOIN 으로 한 번에 가져옵니다 (select_related)

둘 중 하나만 고치면 반쪽이라, 스트리밍만 하고 N+1 을 안 잡으면 여전히 느리고 N+1 만 잡고 메모리에 다 올리면 여전히 터집니다. 다대일 관계가 아니라 다대다· 역참조면 select_related 대신 prefetch_related 를 씁니다.

  1. 불러오는 중