[실전 Django - database 정복기 2부] 기능은 똑같은데 왜 쿼리가 100방이나 나가죠? (N+1 문제 해결)

2025. 12. 12. 16:58·ServerDev/DJango

 

1편에서 다루었던 동시성 이슈(Race Condition)가 데이터의 '정확성'을 지키기 위한 방패였다면, 이번 2편은 데이터의 '속도'를 책임지는 창에 대한 이야기입니다.

 

Django를 사용하면서 가장 흔하게 겪는 성능 문제인 N+1 문제를 심도 있게 파헤쳐 보겠습니다.

 

단순히 "쿼리를 줄인다"는 개념을 넘어, Django ORM이 내부적으로 어떻게 SQL을 생성하는지 그 원리를 이해하는 것이 핵심입니다.

 

 

 

 


 

 "기능은 똑같은데 왜 쿼리가 100방이나 나가죠?" (N+1 문제 완벽 해결)

1.  로컬에선 빨랐는데 배포하니까 느려요

"분명 로컬 개발 환경에서는 페이지가 0.1초 만에 떴거든요? 근데 운영 서버에 데이터 1,000개 정도 넣으니까 목록 조회하는 데 3초가 걸려요."

백엔드 개발자로 일하면서 가장 많이 듣는 성능 이슈 중 하나입니다. 코드를 열어보면 비즈니스 로직은 완벽합니다. 복잡한 연산도 없습니다. 그저 DB에서 데이터를 가져와서 뿌려주는 것뿐입니다.

하지만 Django ORM의 지연 로딩(Lazy Loading) 특성을 이해하지 못하고 코드를 짰다면, 당신의 서버는 지금 이 순간에도 데이터베이스에 수백, 수천 번의 노크를 하고 있을지도 모릅니다.

오늘은 N+1 문제가 무엇인지, 그리고 select_related와 prefetch_related를 언제, 어떻게 사용해야 하는지 실제 코드를 통해 알아보겠습니다.

 

 


 

2. N+1 문제란 무엇인가?

상황을 가정해 봅시다. 블로그 서비스를 만들고 있습니다. 하나의 Article(게시글)은 한 명의 User(작성자)를 가지고, 여러 개의 Comment(댓글)를 가질 수 있습니다.

모델 정의 (Models.py)

from django.db import models

class User(models.Model):
    name = models.CharField(max_length=100)

class Article(models.Model):
    title = models.CharField(max_length=100)
    # User와 1:N 관계 (Forward 참조)
    author = models.ForeignKey(User, on_delete=models.CASCADE)

class Comment(models.Model):
    content = models.TextField()
    # Article과 1:N 관계 (Reverse 참조)
    article = models.ForeignKey(Article, on_delete=models.CASCADE, related_name='comments')

재앙의 시작: 아무 생각 없이 조회하기

이제 게시글 목록 API를 만듭니다. 요구사항은 "게시글 제목과 작성자 이름, 그리고 달린 댓글들을 보여달라"는 것입니다.

# views.py (나쁜 예시)

def get_articles():
    # 1. 모든 게시글을 가져온다. (쿼리 1방)
    articles = Article.objects.all()

    results = []
    for article in articles:
        results.append({
            "title": article.title,
            # 2. 작성자 이름을 가져온다. (문제 발생 지점 A)
            "author": article.author.name, 
            # 3. 댓글 목록을 가져온다. (문제 발생 지점 B)
            "comments": [c.content for c in article.comments.all()] 
        })
    return results

 

이 코드를 실행하면 어떤 일이 벌어질까요? 만약 게시글이 100개라면

  1. Article.objects.all(): 게시글 전체 조회 (1번)
  2. article.author.name: 각 게시글마다 작성자를 찾기 위해 User 테이블 조회 (100번)
  3. article.comments.all(): 각 게시글마다 댓글을 찾기 위해 Comment 테이블 조회 (100번)

총 201번(1 + N + N)의 쿼리가 발생합니다. 게시글이 1,000개라면 2,001번이 되겠죠. 이것이 바로 N+1 문제입니다.

(1번의 조회로 N개의 데이터를 가져왔는데, 그 N개를 처리하기 위해 N번의 쿼리가 추가로 발생하는 현상)

 

 


 

3. 해결책 1: select_related (JOIN의 마법)

첫 번째 해결책은 select_related입니다. 이것은 SQL의 JOIN 기능을 사용합니다.

언제 쓰는가?

  • 정방향 참조 (Forward): ForeignKey, OneToOneField
  • 즉, "이 데이터 하나에 연결된 저 데이터 하나"를 가져올 때 사용합니다. (1:1 또는 N:1)

코드 적용

# views.py (개선된 예시 1)

def get_articles_optimized_step1():
    # select_related를 사용하여 author 정보를 JOIN 해서 한 번에 가져옵니다.
    articles = Article.objects.select_related('author').all()

    results = []
    for article in articles:
        results.append({
            "title": article.title,
            # 이미 메모리에 author 객체가 로딩되어 있으므로 DB 조회가 발생하지 않음!
            "author": article.author.name, 
        })
    return results

동작 원리

Django는 다음과 같은 SQL을 생성합니다.

SELECT article.id, article.title, user.id, user.name 
FROM article 
INNER JOIN user ON article.author_id = user.id;

 

단 1번의 쿼리로 게시글과 작성자 정보를 모두 가져와 파이썬 메모리에 올려둡니다. for문을 돌 때 DB를 다시 찾을 필요가 없습니다.

 

 


 

4. 해결책 2: prefetch_related (Python의 조립)

 

두 번째 해결책은 prefetch_related입니다. select_related가 JOIN을 쓴다면, 이 녀석은 "쿼리를 두 번 날려서 파이썬 안에서 짝을 맞추는" 방식을 씁니다.

언제 쓰는가?

  • 역방향 참조 (Reverse): ForeignKey의 역참조 (article.comments.all())
  • 다대다 관계 (Many-to-Many): ManyToManyField
  • 즉, "이 데이터 하나에 연결된 여러 데이터들"을 가져올 때 사용합니다. (1:N 또는 N:M)

SQL JOIN으로 1:N 관계를 가져오면 데이터 뻥튀기(Cartesian Product)가 발생하여 비효율적이기 때문에, Django는 별도의 전략을 취하는 것입니다.

 

코드 적용

# views.py (개선된 예시 2 - 댓글까지 포함)

def get_articles_optimized_complete():
    # select_related와 prefetch_related를 체이닝하여 사용
    articles = Article.objects.select_related('author').prefetch_related('comments').all()

    results = []
    for article in articles:
        results.append({
            "title": article.title,
            "author": article.author.name,     # DB 조회 X
            "comments": [c.content for c in article.comments.all()] # DB 조회 X
        })
    return results

 

동작 원리

Django는 내부적으로 총 2번의 쿼리를 실행합니다.

  1. Main Query: 모든 게시글을 가져옵니다.
    SELECT * FROM article ...;
  2. Prefetch Query: 가져온 게시글들의 ID를 모아(IN 연산자), 관련된 댓글을 한 번에 가져옵니다.
    SELECT * FROM comment WHERE article_id IN (1, 2, 3, ... 100);
  3. Python Level: Django가 메모리 상에서 Article ID와 Comment의 article_id를 매칭시켜서 article.comments 캐시에 집어넣습니다.

결과적으로 게시글이 1,000개든 10,000개든, 쿼리는 딱 2번(또는 select_related 포함 1번 + 1번)만 나갑니다.

 

 


 

5. 심화: Prefetch 객체로 필터링하기

 

많은 분들이 prefetch_related를 쓸 때 겪는 딜레마가 있습니다.
"댓글을 미리 가져오긴 하는데, '삭제된 댓글'은 빼고 가져오고 싶어요."

그냥 prefetch_related('comments')를 하면 모든 댓글을 가져오고, for문 안에서 article.comments.filter(is_deleted=False)를 하면 또다시 쿼리가 발생합니다. (Prefetch 해놓은 데이터를 무시하고 DB에 다시 질의하기 때문입니다.) 이때 사용하는 것이 Prefetch 객체입니다.

from django.db.models import Prefetch

def get_articles_with_active_comments():
    # 1. 미리 가져올 쿼리셋(QuerySet)을 정의합니다.
    active_comments = Comment.objects.filter(is_deleted=False)

    articles = Article.objects.prefetch_related(
        # 2. Prefetch 객체를 통해 '어떤 조건으로 가져올지' 명시합니다.
        Prefetch('comments', queryset=active_comments, to_attr='active_comments_list')
    )

    results = []
    for article in articles:
        # 3. to_attr로 지정한 이름으로 접근하면 추가 쿼리 없이 필터링된 데이터를 씁니다.
        for comment in article.active_comments_list:
            print(comment.content)

 

이 기술을 사용하면 복잡한 조건이 걸린 연관 관계 데이터도 N+1 문제 없이 깔끔하게 가져올 수 있습니다.

 

 

 

 


 

 

6. 마치며: ORM을 믿되, 검증하라

 

 

Django ORM은 개발자에게 엄청난 생산성을 선물해주지만, 그 대가로 SQL 제어권을 가져갔습니다. 우리가 무심코 적은 점(. ) 하나가 서버를 멈추게 할 수도 있다는 사실을 항상 인지해야 합니다.

오늘의 요약

  1. N+1 문제: 반복문 안에서 연관 관계 모델을 접근하면 쿼리가 폭발한다.
  2. select_related: 1:1, N:1 관계 (ForeignKey) -> JOIN으로 해결.
  3. prefetch_related: 1:N, N:M 관계 (Reverse, M2M) -> 추가 쿼리 + 메모리 병합으로 해결.
  4. Prefetch 객체: 연관된 데이터를 가져올 때 **조건(filter)**을 걸고 싶을 때 사용.

데이터의 무결성(1편)과 조회 성능(2편)을 잡았다면, 이제는 "대량의 데이터를 쓰고(Write) 수정(Update)하는 성능"을 챙길 차례입니다.

 

다음 [[실전 Django -database 정복기 3부] 대용량 데이터 처리: "for문 돌리지 말고 Bulk를 믿으세요"] 에서는, 10만 건의 데이터를 저장하는 데 걸리는 시간을 30초에서 0.5초로 단축시키는 bulk_create와 bulk_update의 활용법에 대해 다루겠습니다.

 

긴 글 읽어주셔서 감사합니다!

'ServerDev > DJango' 카테고리의 다른 글

[실전 Django - database 정복기 5부] 인덱스(Index) 설계: "쿼리가 느리다면 답은 정해져 있다"  (0) 2025.12.12
[실전 Django - database 정복기 4부] 트랜잭션과 Celery의 미묘한 관계: "왜 알림이 안 가죠?"  (0) 2025.12.12
[실전 Django - database 정복기 3부] 대용량 데이터 처리: "for문 돌리지 말고 Bulk를 믿으세요"  (0) 2025.12.12
[실전 Django - database 정복기 1부 ] ORM의 배신, 그리고 select_for_update로 해결한 동시성 이슈  (0) 2025.12.12
'ServerDev/DJango' 카테고리의 다른 글
  • [실전 Django - database 정복기 5부] 인덱스(Index) 설계: "쿼리가 느리다면 답은 정해져 있다"
  • [실전 Django - database 정복기 4부] 트랜잭션과 Celery의 미묘한 관계: "왜 알림이 안 가죠?"
  • [실전 Django - database 정복기 3부] 대용량 데이터 처리: "for문 돌리지 말고 Bulk를 믿으세요"
  • [실전 Django - database 정복기 1부 ] ORM의 배신, 그리고 select_for_update로 해결한 동시성 이슈
yeseul-kim01
yeseul-kim01
  • yeseul-kim01
    슬 개발일지
    yeseul-kim01
  • 전체
    오늘
    어제
    • 분류 전체보기 (79)
      • 자격증 (1)
        • 정보보안기사 (0)
      • DevOps (17)
        • Docker (6)
        • Kubernetes (1)
        • GitHub Actions (0)
        • AWS (4)
        • Monitoring (1)
        • Nginx (1)
        • GCP (3)
      • ServerDev (34)
        • SpringBoot (13)
        • DJango (5)
        • FastAPI (14)
        • Next (0)
        • Flask (0)
        • Database (2)
      • Algorithm (2)
        • BFS (0)
        • DFS (1)
        • 다익스트라 (0)
      • CS (8)
      • Data Engineering (1)
      • AI&MLOps (2)
      • Architecture (6)
      • Software Engineering (0)
        • Library Packaging (0)
      • Project (5)
        • docx-generator (0)
        • speak-note (2)
        • ms-serving (1)
        • keyshield (2)
      • ProgrammingLanguages (3)
        • Python (1)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    rag
    동시성제어
    KeyShield
    아키텍처
    di
    트러블슈팅
    multipartfile
    MLops
    아키텍처설계
    실무일기-백엔드편
    NLP부트캠프
    SpringBoot
    FastAPI - CORS 마스터
    프로젝트기록-speaknote
    Django
    백엔드
    비동기처리
    FastAPI
    멀티모듈
    docker
    실무일기-인프라편
    하이브리드아키텍처
    프로젝트기록-KeyShield
    grpc
    실시간시스템
    depends
    KServe
    Kubernetes
    SpeakNote
    STT
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.5
yeseul-kim01
[실전 Django - database 정복기 2부] 기능은 똑같은데 왜 쿼리가 100방이나 나가죠? (N+1 문제 해결)
상단으로

티스토리툴바