프로그래밍/Python

🎯 Django ORM의 숨겨진 함정: 불필요한 데이터까지 가져오는 비효율적인 쿼리들

Tiboong 2025. 9. 17. 11:09
728x90
반응형

"ORM이 편하다고 했는데 왜 이렇게 느리죠?"

Django를 처음 배울 때는 ORM이 정말 마법 같았습니다. SQL을 몰라도 Python 코드만으로 데이터베이스를 다룰 수 있으니까요.

하지만 실제 서비스를 운영해보면 깨닫게 됩니다. ORM의 편리함 뒤에는 예상치 못한 성능 함정들이 숨어있다는 것을요.

27년간 수많은 Django 프로젝트를 경험하면서 확신하게 된 것은, 대부분의 성능 문제가 **"ORM이 생성하는 비효율적인 쿼리"**에서 시작된다는 것입니다.

오늘은 Django ORM이 어떻게 우리 모르게 비효율적인 쿼리를 만들어내는지, 그리고 이를 어떻게 해결할 수 있는지 알아보겠습니다.

🤔 Django ORM은 어떻게 동작할까요?

ORM의 기본 원리: 추상화의 양면성

Django ORM은 **추상화(Abstraction)**의 대표적인 예입니다. 복잡한 SQL을 숨기고 간단한 Python 코드로 바꿔주죠.

 

추상화의 장점:

  • SQL을 몰라도 데이터베이스 조작 가능
  • 데이터베이스 종류(MySQL, PostgreSQL 등)에 관계없이 동일한 코드
  • 객체 지향적 접근으로 직관적인 코드 작성

하지만 추상화의 단점:

  • 실제로 어떤 SQL이 실행되는지 알기 어려움
  • 개발자의 의도와 다른 쿼리가 생성될 수 있음
  • 성능 최적화가 어려워짐

이것이 바로 **"편리함의 대가"**입니다.

 

ORM이 쿼리를 만드는 과정

Django ORM이 내부적으로 어떻게 동작하는지 이해해봅시다:

 

1단계: 지연 평가(Lazy Evaluation)

users = User.objects.filter(is_active=True)  # 아직 쿼리 실행 안 됨!

이 시점에는 아직 데이터베이스에 아무것도 요청하지 않습니다. 단지 "나중에 실행할 쿼리의 조건"만 기록해둘 뿐이죠.

 

2단계: 쿼리 실행 시점

for user in users:  # 이 순간 실제 쿼리 실행!
    print(user.name)

 

실제로 데이터가 필요한 순간에 비로소 SQL 쿼리가 생성되고 실행됩니다.

3단계: SQL 번역 Django는 우리가 작성한 ORM 코드를 SQL로 번역합니다. 이 과정에서 우리가 예상하지 못한 쿼리가 만들어질 수 있습니다.

💸 불필요한 데이터를 가져오는 문제들

문제 1: 모든 컬럼을 가져오는 SELECT *

상황 설명: 게시판에서 제목과 작성자 이름만 보여주는 목록 페이지를 만든다고 생각해보세요. 실제로는 제목과 작성자 이름만 필요하지만, Django ORM은 기본적으로 모든 컬럼을 가져옵니다.

 

실제 벌어지는 일:

# 우리가 원하는 것: 제목과 작성자 이름만
posts = Post.objects.all()
for post in posts:
    print(f"{post.title} - {post.author.name}")

 

Django가 실행하는 SQL:

-- 실제로는 모든 컬럼을 가져옴!
SELECT id, title, content, author_id, created_at, updated_at, 
       view_count, like_count, is_published, thumbnail_url, 
       meta_description, tags, category_id, slug, featured_until, ...
FROM blog_post;

 

문제점:

  • content 필드가 10MB짜리 긴 글이라면?
  • thumbnail_url 같은 불필요한 필드들까지 모두 네트워크로 전송
  • 메모리에도 모든 데이터를 로드
  • 결과적으로 실제 필요한 데이터의 10배-100배를 처리

문제 2: 관련 객체 조회할 때마다 추가 쿼리

상황 설명: 게시글 목록에서 각 글의 작성자 이름을 보여주려고 합니다. 겉보기에는 간단해 보이지만, 내부적으로는 엄청난 비효율이 숨어있습니다.

 

무엇이 벌어지는가:

  1. 먼저 게시글 목록을 가져옴 (쿼리 1개)
  2. 각 게시글마다 작성자 정보를 별도로 조회 (쿼리 N개)
  3. 게시글이 100개면 총 101개의 쿼리 실행!

네트워크 비용:

  • 쿼리 1개당 평균 1-5ms의 네트워크 지연
  • 101개 쿼리면 최소 100ms-500ms 추가 지연
  • 사용자는 빈 화면을 바라보며 기다림

문제 3: 복잡한 조건문에서 생성되는 비효율적인 쿼리

상황 설명: Django ORM의 조건문을 여러 개 연결하면, 때로는 데이터베이스가 최적화하기 어려운 복잡한 SQL이 생성됩니다.

 

예상치 못한 결과: 우리는 단순한 조건을 원했지만, Django가 생성하는 SQL은 복잡한 서브쿼리나 불필요한 JOIN을 포함할 수 있습니다. 이는 데이터베이스 옵티마이저를 혼란스럽게 만들어 비효율적인 실행 계획을 선택하게 만듭니다.

 

🔍 문제를 발견하는 방법들

느린 페이지의 징후들

사용자 관점에서:

  • 페이지 로딩이 3초 이상 걸림
  • "로딩 중..." 메시지가 자주 보임
  • 스크롤할 때마다 멈칫거림
  • 모바일에서 특히 더 느림

개발자 관점에서:

  • Django Debug Toolbar에서 쿼리 개수가 50개 이상
  • 데이터베이스 CPU 사용률이 계속 높음
  • 같은 기능인데 로컬과 프로덕션 속도 차이가 큼
  • 서버 로그에 "slow query" 경고가 자주 나타남

Django Debug Toolbar: 숨겨진 문제를 드러내는 도구

Django Debug Toolbar는 마치 X-Ray와 같습니다. 겉으로는 멀쩡해 보이는 페이지의 내부를 들여다볼 수 있게 해주죠.

주목해야 할 지표들:

  • 쿼리 개수: 10개 이하가 이상적, 50개 이상이면 문제
  • 총 실행 시간: 100ms 이하가 목표
  • 중복 쿼리: 같은 쿼리가 여러 번 실행되고 있다면 N+1 문제
  • 느린 쿼리: 100ms 이상 걸리는 개별 쿼리가 있다면 인덱스 확인

 

⚡ 해결책 1: 필요한 데이터만 선택하기

only()와 defer(): 컬럼 선택의 기술

only()의 작동 원리: only()는 "이 컬럼들만 가져와"라고 명시적으로 지정하는 방법입니다. 마치 뷔페에서 "이것만 주세요"라고 말하는 것과 같죠.

언제 사용해야 할까요?

  • 테이블에 컬럼이 많지만 실제로는 2-3개만 필요한 경우
  • 큰 텍스트 필드(content, description)가 있지만 목록에서는 불필요한 경우
  • 이미지 URL이나 파일 경로 같은 무거운 데이터가 포함된 경우

defer()의 작동 원리: defer()는 반대로 "이 컬럼들만 제외하고 가져와"라고 지정합니다. 대부분의 컬럼은 필요하지만 몇 개만 제외하고 싶을 때 유용하죠.

values()와 values_list(): 딕셔너리와 튜플로 받기

values()의 특징:

  • 결과를 딕셔너리 형태로 받음
  • 모델 인스턴스를 만들지 않아서 메모리 절약
  • JSON API 응답을 만들 때 특히 유용

values_list()의 특징:

  • 결과를 튜플 형태로 받음
  • 메모리 사용량이 가장 적음
  • 단순한 데이터 처리나 CSV 생성에 적합

언제 각각을 사용할까요?

  • 일반적인 경우: 모델 인스턴스 사용 (메서드 호출 가능)
  • API 응답: values() 사용 (JSON 변환 쉬움)
  • 대량 데이터 처리: values_list() 사용 (최소 메모리)
  • 간단한 목록: values_list(flat=True) 사용

⚡ 해결책 2: 관계된 데이터 효율적으로 가져오기

select_related(): JOIN으로 한 번에 가져오기

작동 원리: select_related()는 SQL의 JOIN을 사용해서 관련된 테이블의 데이터를 한 번에 가져옵니다. 마치 "관련 서류도 함께 준비해주세요"라고 미리 요청하는 것과 같습니다.

언제 사용해야 할까요?

  • 일대일(OneToOne) 관계: 사용자 프로필 정보
  • 다대일(ManyToOne) 관계: 게시글의 작성자, 상품의 카테고리
  • 관계 깊이가 깊지 않을 때: 2-3단계까지가 적당

주의사항: 너무 많은 관계를 한 번에 JOIN하면 오히려 느려질 수 있습니다. 데이터베이스는 JOIN이 많을수록 복잡한 계산을 해야 하기 때문입니다.

prefetch_related(): 별도 쿼리로 효율적으로 가져오기

작동 원리: prefetch_related()는 별도의 쿼리를 실행해서 관련 데이터를 가져온 후, Python에서 연결합니다. 마치 "관련 서류는 따로 준비해서 나중에 연결할게요"라고 하는 것과 같습니다.

 

언제 사용해야 할까요?

  • 다대다(ManyToMany) 관계: 게시글의 태그들, 사용자의 권한들
  • 역참조(Reverse Foreign Key): 작가의 모든 책들
  • 복잡한 관계: JOIN으로는 너무 복잡해지는 경우

장점:

  • JOIN보다 예측 가능한 성능
  • 메모리 사용량 제어 가능
  • 복잡한 조건도 쉽게 적용 가능

⚡ 해결책 3: 집계와 어노테이션 활용하기

데이터베이스에서 계산하게 하기

기본 개념: 많은 개발자들이 데이터를 Python으로 가져온 후 계산하는 실수를 합니다. 하지만 데이터베이스는 계산을 위해 최적화된 엔진입니다. 가능한 한 데이터베이스에서 계산하게 하는 것이 훨씬 효율적입니다.

 

예시 상황들:

  • 게시글별 댓글 개수 계산
  • 주문별 총 금액 계산
  • 월별 매출 집계
  • 평균 평점 계산

annotate(): 각 행에 계산 결과 추가하기

작동 원리: annotate()는 기존 데이터에 새로운 컬럼을 추가하는 것처럼 작동합니다. 데이터베이스가 계산한 결과를 각 행에 포함시켜 줍니다.

실제 사용 예시:

  • 블로그 글 목록에 각 글의 댓글 수 표시
  • 상품 목록에 평균 평점 표시
  • 사용자 목록에 작성한 게시글 수 표시

aggregate(): 전체 데이터의 요약 정보

작동 원리: aggregate()는 전체 데이터셋에 대한 요약 정보를 계산합니다. 마치 엑셀의 SUM(), AVG() 함수와 같은 역할을 합니다.

언제 사용할까요?:

  • 대시보드의 총계 정보
  • 통계 페이지의 요약 데이터
  • 리포트 생성시 집계 데이터

🚨 흔히 하는 실수들과 해결법

실수 1: 반복문에서 데이터베이스 접근

왜 문제가 될까요? Python의 반복문은 매우 빠르지만, 각 반복마다 데이터베이스에 접근하면 네트워크 지연이 누적됩니다. 1ms씩 1000번 하면 1초가 걸리죠.

 

올바른 접근 방법:

  • 반복 전에 필요한 모든 데이터를 미리 가져오기
  • select_related(), prefetch_related() 활용
  • 불가피한 경우 배치 처리 사용

실수 2: 테스트 데이터의 함정

문제 상황: 개발할 때는 데이터가 10-100개 정도라서 모든 게 빠르게 작동합니다. 하지만 실제 서비스에는 만 개, 십만 개의 데이터가 있죠.

 

해결 방법:

  • 개발 환경에도 충분한 양의 테스트 데이터 준비
  • 성능 테스트를 정기적으로 실행
  • 프로덕션과 유사한 환경에서 테스트

실수 3: ORM 체이닝의 오해

흔한 오해: "메서드를 많이 연결할수록 쿼리도 복잡해진다"고 생각하는 경우가 많습니다. 하지만 Django ORM은 지연 평가를 사용하므로, 실제로는 마지막에 하나의 쿼리만 실행됩니다.

 

올바른 이해:

# 이 모든 것이 하나의 쿼리로 합쳐짐
queryset = User.objects.filter(is_active=True)\
                      .select_related('profile')\
                      .prefetch_related('posts')\
                      .order_by('-created_at')[:10]

📊 성능 측정과 모니터링

개발 단계에서의 성능 확인

Django Debug Toolbar 활용:

  • 모든 페이지에서 쿼리 개수와 실행 시간 확인
  • 중복 쿼리나 느린 쿼리 식별
  • 실행 계획(EXPLAIN) 확인

쿼리 로깅 설정: 개발 환경에서 모든 SQL 쿼리를 로그로 남겨서 어떤 쿼리가 실행되는지 정확히 파악할 수 있습니다.

프로덕션에서의 지속적 모니터링

중요한 지표들:

  • 응답 시간: 95%의 요청이 200ms 이내에 완료되는가?
  • 데이터베이스 부하: CPU, 메모리, 커넥션 수
  • 느린 쿼리: 100ms 이상 걸리는 쿼리들
  • 에러율: 타임아웃이나 커넥션 에러 발생률

알림 설정: 성능이 급격히 나빠졌을 때 즉시 알 수 있도록 모니터링 시스템을 구축하는 것이 중요합니다.

🎯 실제 최적화 사례: 전자상거래 사이트

최적화 전 상황

문제점들:

  • 상품 목록 페이지 로딩 시간: 8-12초
  • 데이터베이스 CPU 사용률: 90% 이상 지속
  • 사용자 이탈률: 60% (로딩 중 떠남)
  • 서버 비용: 월 300만원 (과도한 DB 리소스)

원인 분석:

  • 상품당 20개씩 추가 쿼리 실행 (N+1 문제)
  • 모든 컬럼을 조회하지만 실제로는 5개만 사용
  • 카테고리, 브랜드 정보를 매번 별도 조회
  • 평균 평점을 Python에서 계산

최적화 후 결과

성능 개선:

  • 상품 목록 페이지 로딩 시간: 0.8초 (10배 개선)
  • 데이터베이스 CPU 사용률: 30% (안정적)
  • 사용자 이탈률: 15% (75% 감소)
  • 서버 비용: 월 120만원 (60% 절약)

적용한 최적화 기법들:

  • select_related()로 관련 데이터 한 번에 조회
  • only()로 필요한 컬럼만 선택
  • annotate()로 평균 평점을 데이터베이스에서 계산
  • 적절한 인덱스 추가

🎓 정리하며: 효율적인 Django ORM 사용 원칙

1. 항상 실제 SQL을 확인하세요

ORM의 편리함에 속아서는 안 됩니다. 작성한 코드가 어떤 SQL로 변환되는지 항상 확인하는 습관을 기르세요.

2. 필요한 데이터만 가져오세요

"혹시 나중에 필요할까봐"라는 생각으로 모든 데이터를 가져오지 마세요. 실제로 사용하는 데이터만 선택적으로 가져오는 것이 훨씬 효율적입니다.

3. 관계된 데이터는 미리 계획하세요

어떤 관련 데이터가 필요한지 미리 파악하고, select_related()나 prefetch_related()를 사용해서 한 번에 가져오세요.

4. 데이터베이스의 힘을 활용하세요

Python보다 데이터베이스가 훨씬 빠르게 할 수 있는 일들(계산, 집계, 정렬)은 데이터베이스에 맡기세요.

5. 지속적으로 모니터링하세요

성능은 한 번 최적화하고 끝나는 것이 아닙니다. 서비스가 성장하고 데이터가 늘어나면서 새로운 병목점이 생길 수 있습니다.


💬 Django ORM 최적화가 어려우신가요?

"어떤 쿼리가 문제인지 모르겠어요", "최적화는 했는데 여전히 느려요"

27년 경력의 시니어 개발자가 여러분의 Django 프로젝트를 직접 분석하고 최적화해드립니다.

  • 🔍 전체 시스템 성능 진단 및 병목점 분석
  • ORM 쿼리 최적화 및 데이터베이스 튜닝
  • 📊 성능 모니터링 시스템 구축 및 운영 가이드
  • 🎓 팀 개발자 대상 실무 중심 교육 프로그램

➡️ Django 성능 최적화 전문가 상담받기

"느린 시스템 때문에 사용자를 잃지 마세요. 전문가가 근본적인 해결책을 제시해드립니다!"

728x90
반응형