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

만든 것4분조회 –

관리 페이지에서 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 를 붙이면 됩니다.

  1. 불러오는 중