💧Django 메모리 리크: QuerySet이 메모리를 잡아먹는 이유
🔥 실제 상황: 점점 느려지는 서버
새벽 3시, 긴급 전화가 울렸습니다. "서버가 점점 느려지다가 결국 다운됐어요!"
클라이언트의 전자상거래 사이트는 하루 10만 건의 주문을 처리하는 중견 규모였습니다. 배치 작업으로 매일 밤 주문 데이터를 집계하는 스크립트가 돌아가는데, 처음에는 5분이면 끝나던 작업이 점점 느려지더니 결국 서버 메모리가 가득 차서 멈춰버린 것이었습니다.
서버 모니터링을 확인해보니 메모리 사용량이 계단식으로 증가하다가 16GB RAM을 모두 소진한 상태였습니다. 재시작하면 다시 정상 작동하지만, 시간이 지나면 또 같은 현상이 반복됐습니다.
🤔 QuerySet 메모리 리크란?
Django의 QuerySet은 매우 편리하지만, 제대로 이해하지 못하면 메모리를 지속적으로 잡아먹는 "조용한 살인자"가 될 수 있습니다.
식당 뷔페 비유로 이해하기:
일반 식당에서는 주문한 음식만 테이블에 가져옵니다. 하지만 뷔페에서는 접시를 들고 가서 원하는 음식을 담아옵니다.
- 일반 식당 (iterator 사용): 필요한 것만 가져와서 먹고, 먹은 접시는 바로 치웁니다. 테이블은 항상 깔끔합니다.
- 뷔페 전체 옮기기 (일반 QuerySet): 뷔페의 모든 음식을 한 번에 자기 테이블로 옮겨놓고 먹습니다. 테이블이 가득 차고, 다 먹지도 못하는데 공간만 차지합니다.
Django의 기본 QuerySet은 데이터베이스에서 조회한 모든 결과를 메모리에 한 번에 올려놓고 캐싱합니다. 100만 건의 데이터를 조회하면 100만 건이 전부 메모리에 로드됩니다. 그리고 그 QuerySet 객체가 살아있는 동안 계속 메모리를 점유합니다.
📊 실제 비즈니스 영향
이 문제가 왜 심각할까요?
1. 서버 다운타임으로 인한 매출 손실 전자상거래 사이트가 5분 다운되면 평균 시간당 매출의 12%를 잃습니다. 시간당 1천만 원 매출이라면 100만 원이 그냥 증발하는 셈입니다.
2. 데이터 처리 작업 실패 배치 작업이 중간에 멈추면 주문 집계가 안 되고, 재고 관리가 꼬이고, 정산이 지연됩니다. 회계팀과 물류팀이 동시에 혼란에 빠집니다.
3. 스케일링 비용 증가 "메모리가 부족하니 서버를 업그레이드하자"는 잘못된 접근입니다. 16GB에서 32GB로 늘려도, 64GB로 늘려도 문제는 반복됩니다. 근본 원인을 해결하지 않으면 비용만 계속 증가합니다.
4. 다른 서비스 영향 같은 서버에서 실행되는 웹 애플리케이션까지 느려집니다. 메모리가 부족하면 스왑 메모리를 사용하게 되고, 이는 디스크 I/O를 유발해 전체 시스템 성능을 저하시킵니다.
🔍 문제가 발생하는 주요 상황
상황 1: 대량 데이터 일괄 처리
# 위험: 100만 건을 모두 메모리에 로드
orders = Order.objects.all()
for order in orders:
process_order(order)
상황 2: 전역 변수나 클래스 속성에 QuerySet 저장
# 위험: QuerySet이 계속 살아있음
class ReportGenerator:
cached_orders = Order.objects.filter(status='pending') # 여기서 실행됨
상황 3: 중첩 루프에서 QuerySet 반복 사용
# 위험: QuerySet이 누적됨
for user in User.objects.all():
user_orders = Order.objects.filter(user=user) # 메모리 누적
for order in user_orders:
process(order)
상황 4: Celery 같은 장기 실행 프로세스 장시간 실행되는 워커 프로세스에서 QuerySet을 제대로 해제하지 않으면, 시간이 지날수록 메모리가 누적됩니다.
💡 해결 방법
방법 1: iterator() 사용 (가장 기본)
개념: 데이터를 조금씩 가져와서 처리하고 바로 버립니다.
# 안전: 메모리 효율적
for order in Order.objects.all().iterator():
process_order(order)
장점:
- 메모리 사용량이 일정하게 유지됩니다
- 대량 데이터 처리에 적합합니다
단점:
- QuerySet 캐싱을 사용할 수 없습니다
- 같은 데이터를 여러 번 순회해야 한다면 비효율적입니다
적용 시나리오: 100만 건 이상의 데이터를 한 번만 순회하면 되는 배치 작업
방법 2: iterator(chunk_size) 사용
개념: 데이터를 일정 크기 단위로 나눠서 처리합니다.
# chunk_size를 지정하여 데이터베이스 왕복 횟수 조절
for order in Order.objects.all().iterator(chunk_size=1000):
process_order(order)
장점:
- 메모리와 데이터베이스 부하의 균형을 맞출 수 있습니다
- chunk_size를 조절하여 최적화 가능합니다
단점:
- chunk_size가 너무 작으면 DB 쿼리가 너무 많아집니다
- chunk_size가 너무 크면 메모리 문제가 재발합니다
적용 시나리오: 네트워크 지연이 있는 환경에서 대량 데이터 처리
방법 3: 명시적 메모리 해제
개념: QuerySet을 사용한 후 명시적으로 참조를 제거합니다.
def process_orders():
orders = Order.objects.filter(status='pending')
for order in orders:
process_order(order)
# 명시적으로 참조 제거
del orders
# 필요시 가비지 컬렉션 강제 실행
import gc
gc.collect()
장점:
- 함수 범위를 벗어나기 전에 메모리를 해제합니다
- 장기 실행 프로세스에서 유용합니다
단점:
- 코드가 복잡해집니다
- Python의 가비지 컬렉터에 의존합니다
적용 시나리오: Celery 워커나 장기 실행 데몬 프로세스
방법 4: 페이지네이션 방식
개념: offset을 사용하지 않고 pk 기반으로 데이터를 나눠서 처리합니다.
last_pk = 0
batch_size = 1000
while True:
orders = Order.objects.filter(pk__gt=last_pk).order_by('pk')[:batch_size]
if not orders:
break
for order in orders:
process_order(order)
last_pk = orders[len(orders) - 1].pk
del orders # 명시적 해제
장점:
- offset 페이지네이션보다 훨씬 빠릅니다
- 메모리 사용량이 예측 가능합니다
단점:
- pk가 순차적이어야 합니다
- 코드가 복잡합니다
적용 시나리오: 수백만 건 이상의 대용량 데이터 마이그레이션
방법 5: select_related/prefetch_related 최적화
개념: 연관 데이터도 함께 가져오되, 쿼리 수를 최소화합니다.
# N+1 문제와 메모리 문제를 동시에 해결
for order in [Order.objects.select](<http://Order.objects.select>)_related('user').iterator():
print([order.user.name](<http://order.user.name>)) # 추가 쿼리 없음
장점:
- N+1 쿼리 문제를 해결합니다
- iterator와 조합하면 메모리도 효율적입니다
단점:
- 초기 쿼리가 복잡해질 수 있습니다
적용 시나리오: 연관 데이터가 많은 복잡한 모델 처리
🔧 실전 디버깅 팁
1. 메모리 프로파일링
import tracemalloc
tracemalloc.start()
# 문제가 되는 코드 실행
orders = Order.objects.all()
for order in orders:
process_order(order)
snapshot = tracemalloc.take_snapshot()
top_stats = snapshot.statistics('lineno')
for stat in top_stats[:10]:
print(stat)
이렇게 하면 어느 라인에서 메모리를 가장 많이 사용하는지 확인할 수 있습니다.
2. Django Debug Toolbar 활용
개발 환경에서 Django Debug Toolbar를 설치하면 QuerySet이 실제로 몇 개의 객체를 메모리에 로드했는지 확인할 수 있습니다.
3. 메모리 사용량 모니터링
import psutil
import os
def print_memory_usage():
process = psutil.Process(os.getpid())
mem_info = process.memory_info()
print(f"메모리 사용량: {mem_info.rss / 1024 / 1024:.2f} MB")
print_memory_usage()
orders = Order.objects.all()
print_memory_usage()
for order in orders:
pass
print_memory_usage()
4. 로그에 메모리 사용량 기록
운영 환경에서는 배치 작업 시작/종료 시 메모리 사용량을 로그로 남겨서 추세를 파악합니다.
✅ 모범 사례 체크리스트
대량 데이터 처리 시:
- [ ] 10,000건 이상이면 무조건 iterator() 사용
- [ ] chunk_size는 1000~5000 사이로 설정
- [ ] 함수 종료 전 del로 명시적 해제
장기 실행 프로세스:
- [ ] 전역 변수에 QuerySet 저장 금지
- [ ] 클래스 속성 대신 메서드로 QuerySet 생성
- [ ] 주기적인 메모리 사용량 모니터링
성능 최적화:
- [ ] select_related/prefetch_related로 N+1 방지
- [ ] only()/defer()로 필요한 필드만 로드
- [ ] values() 사용으로 모델 인스턴스 생성 방지
모니터링:
- [ ] 배치 작업 전후 메모리 사용량 측정
- [ ] 운영 환경에서 메모리 알림 설정
- [ ] 주기적인 메모리 프로파일링
🎯 마무리: 메모리는 공짜가 아닙니다
많은 개발자들이 "서버 메모리가 충분하니까"라고 생각하지만, QuerySet 메모리 리크는 시간이 지날수록 누적되어 결국 큰 문제를 만들어냅니다.
기억할 핵심:
- QuerySet은 기본적으로 모든 데이터를 메모리에 캐싱합니다
- 대량 데이터 처리 시 iterator()는 선택이 아닌 필수입니다
- 메모리 문제는 서버 증설이 아닌 코드 개선으로 해결해야 합니다
- 예방이 최선의 해결책입니다
냄비에 물이 끓어 넘치기 전에 불을 줄여야 하듯, 메모리 문제는 서버가 다운되기 전에 미리 예방해야 합니다.
💼 Django 개발, 혼자 고민하지 마세요
이 글에서 다룬 메모리 리크 문제는 실제로 많은 Django 프로젝트에서 발생하는 골칫거리입니다. 특히 데이터가 계속 쌓이는 서비스일수록 시한폭탄처럼 잠재되어 있다가 어느 순간 터집니다.
이런 고민이 있으신가요?
- "배치 작업이 점점 느려지는데 원인을 모르겠어요"
- "서버 메모리를 계속 늘려도 문제가 해결되지 않아요"
- "QuerySet 최적화를 어떻게 해야 할지 막막해요"
- "운영 중인 서비스의 메모리 문제를 안전하게 해결하고 싶어요"
8년 이상의 Django 전문 경험으로 여러분의 프로젝트를 안전하게 최적화해드립니다. 서버가 다운되기 전에, 비용이 더 커지기 전에 전문가의 도움을 받아보세요.