Itertools를 활용한 Django 뷰에서의 대규모 데이터셋 처리 간소화
Min-jun Kim
Dev Intern · Leapcell

소개
웹 개발의 세계, 특히 Django와 같은 프레임워크에서는 대규모 데이터셋을 다루는 것이 피할 수 없는 과제입니다. 수백만 개의 레코드를 가진 보고서를 표시하거나 방대한 CSV 파일을 내보내야 하는 시나리오를 상상해 보세요. 일반적인 함정은 이 모든 데이터를 한 번에 메모리에 로드하려고 시도하는 것입니다. 이 접근 방식은 빠르게 지연 시간을 증가시키고, 메모리 고갈을 일으키며, 사용자 경험을 저하시킵니다. Django의 ORM은 강력하지만, 쿼리에 대한 모든 결과를 가져오는 것을 기본으로 합니다. 여기서 스트리밍이라는 개념이 등장합니다. 즉, 모든 데이터를 한 번에 처리하는 것이 아니라 조각별로 처리하는 것입니다. 이 맥락에서 종종 간과되는 Python의 itertools 모듈은 우아하고 효율적인 도구를 제공하며, Django의 기능과 결합될 때 이 과제를 고성능의 확장 가능한 웹 애플리케이션을 구축할 기회로 바꿀 수 있습니다. 이 글에서는 itertools를 Django 뷰 내에서 효과적으로 활용하여 대규모 데이터셋을 스트리밍 및 처리하는 방법에 대해 자세히 알아보고, 애플리케이션이 반응성과 견고성을 유지하도록 보장합니다.
효율적인 데이터 스트리밍을 위한 Itertools 활용
구현에 들어가기 전에 논의의 중심이 될 몇 가지 핵심 개념을 간략하게 정의해 보겠습니다.
- 스트리밍: 데이터의 맥락에서 스트리밍은 전체를 메모리에 로드하는 것이 아니라 연속적인 흐름으로 데이터를 처리하거나 전송하는 것을 의미합니다. 이는 대규모 데이터셋의 메모리 사용량을 효율적으로 관리하는 데 중요합니다.
- 제너레이터: Python에서 제너레이터는 이터레이터를 반환하는 함수입니다. 시퀀스 결과를 한 번에 하나씩 생성하며, 각
yield문 후에 실행을 일시 중지하고 중단된 지점부터 다시 시작합니다. 제너레이터는 전체 시퀀스를 메모리에 저장하지 않기 때문에 메모리 효율성이 높습니다. - 이터레이터: 이터레이터는
__iter__()및__next__()메서드로 구성된 이터레이터 프로토콜을 구현하는 객체입니다. 전체를 한 번에 로드하지 않고 데이터 시퀀스를 순회할 수 있습니다. itertools모듈: 이 내장 Python 모듈은 이터레이터로 작업하기 위한 빠르고 메모리 효율적인 도구 모음을 제공합니다. 복잡한 이터레이터를 생성하고, 기존 이터레이터를 결합하며, 다양한 작업을 효율적이고 지연된 방식으로 수행하는 함수를 제공합니다.
기본 ORM 동작의 문제점
기본적으로 MyModel.objects.all()과 같은 Django ORM 쿼리를 실행할 때 Django는 데이터베이스에서 모든 일치하는 레코드를 가져와 해당 모델 인스턴스를 생성하고 메모리에 리스트로 저장합니다. 기록 수가 매우 많은 경우 이는 사용 가능한 모든 RAM을 빠르게 소모하여 애플리케이션이 충돌하거나 극도로 느려질 수 있습니다.
해결책: QuerySet iterator() 및 itertools
Django의 QuerySet.iterator() 메서드는 스트리밍 데이터로 가는 첫 번째 단계입니다. 이 메서드는 Django에 모든 레코드를 한 번에 가져오는 대신 청크 단위로 데이터베이스에서 가져와 하나씩 생성하도록 지시합니다. 이는 데이터베이스 쿼리 측면의 메모리 사용량을 크게 줄여줍니다. 그러나 iterator()만으로는 이러한 스트리밍 레코드에 대한 추가 처리, 변환 또는 조합이 필요한 경우 충분하지 않을 수 있습니다. 바로 여기서 itertools가 빛을 발합니다.
상품 정보가 포함된 주문의 대규모 CSV 파일을 내보내는 실용적인 예를 들어 보겠습니다.
시나리오: 주문의 대규모 CSV 파일 내보내기
Product와 Order라는 두 개의 모델이 있다고 가정합니다. 각 주문에는 여러 개의 상품이 포함될 수 있습니다. 상품 이름, 가격, 수량 및 해당 항목의 총계를 포함하는 각 주문 항목을 자세히 설명하는 CSV 파일을 생성하려고 합니다.
# models.py from django.db import models class Product(models.Model): name = models.CharField(max_length=255) price = models.DecimalField(max_digits=10, decimal_places=2) def __str__(self): return self.name class Order(models.Model): order_date = models.DateTimeField(auto_now_add=True) customer_email = models.EmailField() def __str__(self): return f"Order {self.id} by {self.customer_email}" class OrderItem(models.Model): order = models.ForeignKey(Order, on_delete=models.CASCADE) product = models.ForeignKey(Product, on_delete=models.CASCADE) quantity = models.PositiveIntegerField(default=1) def total(self): return self.quantity * self.product.price def __str__(self): return f"{self.quantity} x {self.product.name} for Order {self.order.id}"
이제 이 데이터를 CSV로 스트리밍하는 Django 뷰를 만들어 보겠습니다.
# views.py import csv from itertools import chain, islice from django.http import StreamingHttpResponse from .models import OrderItem, Product, Order def generate_order_csv_stream(): """ CSV 파일의 행을 생성하는 제너레이터. 효율성을 위해 QuerySet.iterator()와 itertools를 사용합니다. """ yield ['Order ID', 'Order Date', 'Customer Email', 'Product Name', 'Product Price', 'Quantity', 'Item Total'] # select_related를 사용하여 관련 객체에 대한 데이터베이스 쿼리를 최소화하고 # .iterator()를 사용하여 OrderItems를 스트리밍합니다. order_items_iterator = OrderItem.objects.select_related('order', 'product').order_by('order__id', 'id').iterator() for item in order_items_iterator: yield [ item.order.id, item.order.order_date.strftime('%Y-%m-%d %H:%M:%S'), item.order.customer_email, item.product.name, str(item.product.price), # CSV를 위해 Decimal을 문자열로 변환 item.quantity, str(item.total()), # CSV를 위해 Decimal을 문자열로 변환 ] def order_export_csv_view(request): """ 주문의 대규모 CSV 파일을 스트리밍하는 Django 뷰. """ response = StreamingHttpResponse( # csv.writer는 시퀀스(리스트/튜플)의 이터러블을 예상합니다. # csv.writer가 쓸 수 있는 행을 생성하는 제너레이터가 필요합니다. # 따라서 제너레이터를 조정합니다. (csv.writer(response_buffer).writerow(row) for row in generate_order_csv_stream()), content_type='text/csv', ) response['Content-Disposition'] = 'attachment; filename="all_orders.csv"' return response # StreamingHttpResponse가 csv.writer와 함께 작동하기 위한 헬퍼 class Echo: """파일과 같은 인터페이스의 쓰기 메서드만 구현하는 객체.""" def write(self, value): """값을 버퍼에 저장하는 대신 반환하여 씁니다.""" return value response_buffer = Echo()
이 예제에서:
OrderItem.objects.select_related('order', 'product').iterator(): 이것이 핵심입니다.select_related는 단일 쿼리에서 관련Order및Product객체를 미리 가져와 N+1 문제를 피합니다. 결정적으로iterator()는 Django가 모든OrderItem객체를 한 번에 메모리에 로드하지 않고 필요에 따라 하나씩 생성하도록 합니다.generate_order_csv_stream(): 이것은 Python 제너레이터 함수입니다. CSV의 각 행을 준비하는 로직을 포함합니다. 개별 행을yield하는 것을 알 수 있습니다. 헤더가 먼저 생성되고, 그 다음 각 데이터 행이 생성됩니다.StreamingHttpResponse: Django의StreamingHttpResponse는 정확히 이 목적을 위해 설계되었습니다. 이터레이터(또는 생성 가능한 객체)를 가져와 전체를 메모리에 로드하지 않고 해당 콘텐츠를 클라이언트에 스트리밍합니다.csv.writer(response_buffer).writerow(row):csv.writer는 파일과 같은 객체를 예상합니다.write메서드를 단순히 반환하는Echo라는 간단한 클래스를 사용하여 이 인터페이스를 만족시킵니다. 이를 통해csv.writer는 각 행을 CSV 문자열로 형식화할 수 있으며, 이는StreamingHttpResponse로 생성됩니다.
더 고급 itertools 응용
iterator() 메서드가 기초적이지만, itertools는 더 복잡한 스트리밍 시나리오를 위해 더 정교한 도구를 제공합니다.
1. itertools.chain으로 이터레이터 결합:
두 개의 다른 모델에서 데이터를 가져와 단일 CSV로 내보내야 하는 경우를 상상해 보세요. itertools.chain은 해당 이터레이터의 이터레이터를 우아하게 결합할 수 있습니다.
from itertools import chain def generate_combined_report_stream(): yield ['Type', 'ID', 'Name', 'Description'] products_iterator = (['Product', p.id, p.name, 'N/A'] for p in Product.objects.iterator()) orders_iterator = (['Order', o.id, f"Order {o.id}", o.customer_email] for o in Order.objects.iterator()) for row in chain(products_iterator, orders_iterator): yield row
여기서 chain은 여러 이터러블을 가져와서 이를 단일 이터러블로 만듭니다. 중간 리스트를 빌드하지 않으므로 메모리 효율적입니다.
2. itertools.groupby로 그룹화 (정렬된 데이터 필요):
groupby는 이터레이터에서 연속적으로 동일한 요소를 그룹화하는 데 강력합니다. 이를 사용하려면 그룹화하려는 키를 기준으로 입력 이터러블이 정렬되어야 합니다.
from itertools import groupby # 이 예제는 개념적입니다. QuerySet.iterator()를 사용한 실제 사용은 # 신중한 정렬 및 데이터베이스 쿼리 경계에 걸쳐 청크를 # 가져오는 것을 포함할 수 있어 groupby가 올바르게 작동하도록 보장해야 합니다. # 상품별로 주문 항목을 그룹화한다고 가정해 보겠습니다. # 이는 관련 항목을 모두 가져온 다음 Python에서 정렬해야 할 수 있으며, # 매우 큰 데이터셋 스트리밍의 목적을 무효화할 수 있습니다. # groupby에 대한 더 가능성 있는 시나리오는 그룹 수가 관리 가능한 경우, # 또는 데이터베이스에서 가져온 더 작은, 사전 그룹화된 청크를 처리하는 경우입니다. # 시연 목적 (잠재적으로 작은 사전 로드된 데이터에 대해): def get_product_grouped_items(): # 실제 대규모 데이터 시나리오에서는 DB에서 정렬된 데이터를 반복합니다. # 지금은 Product.objects.annotate().order_by('name') 등이 있다고 가정합니다. products_with_items = OrderItem.objects.select_related('product').order_by('product__name').iterator() for product_name, group in groupby(products_with_items, key=lambda item: item.product.name): total_quantity = sum(item.quantity for item in group) yield [product_name, total_quantity] # 이러한 유형의 논리는 가능한 경우 적극적으로 데이터베이스에서 집계하는 것이 좋습니다. # 그러나 스트림 후 처리가 필요한 경우 groupby는 옵션입니다.
itertools.groupby 자체는 지연되지만, 매우 큰 데이터셋과 QuerySet.iterator()를 효과적으로 사용하려면 신중한 계획이 필요하며, 종종 데이터베이스 수준 정렬(order_by())과 groupby는 연속적인 동일한 항목만 그룹화한다는 점을 이해해야 합니다.
3. itertools.islice로 제한 및 건너뛰기:
이미 생성된 스트림에 페이지 매김과 유사한 동작을 구현해야 하는 경우(예: 미리 보기용), itertools.islice가 완벽합니다.
from itertools import islice def generate_limited_report_stream(full_iterator, start=0, stop=None): # 헤더가 있는 경우 건너뛰고, islice 적용 # full_iterator가 먼저 헤더를 생성하고 그 다음 데이터를 생성한다고 가정 header = next(full_iterator) yield header # 헤더 생성 # islice(iterable, [start], stop, [step]) for item in islice(full_iterator, start, stop): yield item # 뷰에서의 사용 예: # streaming_data = generate_order_csv_stream() # 원본 전체 스트림 # limited_streaming_data = generate_limited_report_stream(streaming_data, start=100, stop=200) # response = StreamingHttpResponse(...) # limited_streaming_data 사용
islice는 모든 이터레이터에서 작동하므로 전체 시퀀스를 메모리에 로드하지 않고도 이터레이터의 슬라이스를 얻을 수 있습니다.
적용 시나리오
- CSV/Excel 내보내기: 설명된 대로 주요 사용 사례입니다. 서버를 충돌시키지 않고 대규모 보고서를 생성합니다.
- API 응답: 매우 많은 수의 레코드를 반환할 수 있는 API의 경우, 스트리밍을 통해 클라이언트가 전체 응답이 생성되기 전에 데이터를 처리하기 시작할 수 있습니다. 이는
drf-writable-nested와 같은 라이브러리를 사용자 지정 렌더러와 함께 사용하거나 JSON 라인별로 전송하여 달성할 수 있지만, 순수 스트리밍 JSON은 CSV보다 복잡합니다. - 데이터 처리 파이프라인: Django 애플리케이션이 중간자 역할을 하여 한 소스에서 데이터를 가져와 변환한 다음 다른 소스로 보내는 경우, 스트리밍을 통해 메모리 병목 현상을 방지할 수 있습니다.
중요 고려 사항:
- 데이터베이스 부하:
iterator()는 Django 애플리케이션 측의 메모리를 줄이지만, 여전히 데이터베이스에 부담을 줍니다. 쿼리가 매우 복잡하거나 동시성이 매우 높으면 데이터베이스 성능이 병목 현상이 됩니다. - 네트워크 지연 시간: 클라이언트가 스트림을 느리게 소비하는 경우 스트리밍은 연결 시간을 더 길게 만들 수 있습니다.
- 오류 처리: 스트림 중간에 발생하는 오류는 헤더가 이미 전송되었을 수 있으므로 정상적으로 처리하기 어려울 수 있습니다.
StreamingHttpResponse제한 사항:StreamingHttpResponse는 전체 응답 콘텐츠에 액세스해야 하는 미들웨어(예: 콘텐츠 길이 계산 또는 콘텐츠 수정)와 함께 사용할 수 없습니다.- 관련 객체 (
select_related,prefetch_related): 스트리밍 루프 내에서 N+1 쿼리 문제를 피하기 위해 필요한 경우 항상iterator()를select_related또는prefetch_related와 함께 사용하십시오. 이는 성능 이점을 크게 저하시킬 수 있습니다.select_related는 SQL 조인을 사용하므로 일반적으로 일대일 또는 외래 키 관계에 선호됩니다.prefetch_related는 각 부모에 대한 관련 객체 수가 매우 많은 경우 메모리 문제가 발생할 수 있는 별도의 조회를 수행하여 다대다 또는 역외래 키 관계를 처리합니다.
결론
Django 뷰에서 대규모 데이터셋을 효율적으로 처리하는 것은 단순히 모범 사례가 아니라 확장 가능하고 안정적인 애플리케이션을 구축하는 데 필수적입니다. Python의 제너레이터 함수, Django의 QuerySet.iterator(), 그리고 itertools 모듈 내의 강력한 유틸리티를 활용함으로써 개발자는 데이터를 효과적으로 스트리밍하여 메모리 고갈을 방지하고 애플리케이션 성능을 크게 향상시킬 수 있습니다. 이 접근 방식은 잠재적인 메모리 병목 현상을 관리 가능하고 반응성이 뛰어난 데이터 흐름으로 바꾸어 Django 애플리케이션이 어떤 규모의 데이터라도 우아하고 빠르게 처리할 수 있도록 합니다.

