대용량 CSV 스트리밍 — StreamingHttpResponse · select_related
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 를 씁니다.
댓글 0