Skip to content
Published on

N+1 문제 완전 해부 — ORM의 조용한 성능 킬러

Authors
  • Name
    Twitter
N+1 Problem

들어가며

코드 리뷰에서 가장 자주 나오는 피드백 중 하나: "N+1 쿼리 발생하고 있어요."

N+1 문제는 ORM을 쓰는 거의 모든 프로젝트에서 발생합니다. 그리고 조용히 성능을 죽입니다. 로컬에서는 안 느끼다가, 데이터가 1만 건만 넘어가면 갑자기 API 응답이 10초가 됩니다.

N+1 문제란?

시나리오

블로그 시스템에서 게시글 목록과 작성자를 보여주려 합니다.

# 모델 정의 (Django)
class Author(models.Model):
    name = models.CharField(max_length=100)

class Post(models.Model):
    title = models.CharField(max_length=200)
    author = models.ForeignKey(Author, on_delete=models.CASCADE)
    created_at = models.DateTimeField(auto_now_add=True)
# ❌ N+1 문제 발생!
posts = Post.objects.all()[:100]  # 1번 쿼리: 게시글 100개 조회

for post in posts:
    print(f"{post.title} by {post.author.name}")
    # ↑ 게시글마다 author를 조회! 100번 추가 쿼리!

실제 발생하는 SQL

-- 1번째 쿼리 (게시글 목록)
SELECT id, title, author_id, created_at FROM posts LIMIT 100;

-- 2번째 ~ 101번째 쿼리 (작성자 각각 조회!)
SELECT id, name FROM authors WHERE id = 1;
SELECT id, name FROM authors WHERE id = 2;
SELECT id, name FROM authors WHERE id = 3;
SELECT id, name FROM authors WHERE id = 1;  -- 같은 작성자도 또 조회!
SELECT id, name FROM authors WHERE id = 4;
...
-- 총 101번의 쿼리! (1 + N = 1 + 100)

성능 영향

# 벤치마크
import time

# N+1 (101 queries)
start = time.time()
posts = Post.objects.all()[:1000]
for post in posts:
    _ = post.author.name
print(f"N+1: {time.time() - start:.2f}s")
# → N+1: 3.47s (1001 queries)

# 최적화 후 (2 queries)
start = time.time()
posts = Post.objects.select_related('author').all()[:1000]
for post in posts:
    _ = post.author.name
print(f"Optimized: {time.time() - start:.2f}s")
# → Optimized: 0.05s (2 queries)

# 69배 빠름!!
데이터 수  │  N+1 쿼리 수  │  응답 시간    │  최적화 후
──────────┼──────────────┼─────────────┼──────────
10110.05s      │  0.01s
1001010.35s      │  0.02s
1,0001,0013.5s       │  0.05s
10,00010,001      │  35s        │  0.1s
100,000100,0015+ 💀    │  0.3s

N+1의 종류

1. ForeignKey N+1 (1:N의 "1" 쪽)

# Post → Author (Many-to-One)
# 게시글에서 작성자 조회

# ❌ N+1
for post in Post.objects.all():
    print(post.author.name)  # 매번 SELECT

# ✅ select_related (SQL JOIN)
for post in Post.objects.select_related('author'):
    print(post.author.name)  # JOIN으로 한 번에!

생성되는 SQL:

-- select_related → INNER JOIN
SELECT posts.*, authors.name
FROM posts
INNER JOIN authors ON posts.author_id = authors.id
LIMIT 100;
-- 1번의 쿼리!

2. Reverse ForeignKey N+1 (1:N의 "N" 쪽)

# Author → Posts (One-to-Many)
# 작성자별 게시글 목록 조회

# ❌ N+1
authors = Author.objects.all()[:50]
for author in authors:
    posts = author.post_set.all()  # 매번 SELECT
    print(f"{author.name}: {posts.count()} posts")

# ✅ prefetch_related (별도 쿼리 + Python 매핑)
authors = Author.objects.prefetch_related('post_set').all()[:50]
for author in authors:
    posts = author.post_set.all()  # 캐시된 결과 사용!
    print(f"{author.name}: {len(posts)} posts")

생성되는 SQL:

-- prefetch_related → 2번의 쿼리
SELECT id, name FROM authors LIMIT 50;
SELECT id, title, author_id FROM posts WHERE author_id IN (1, 2, 3, ..., 50);
-- Python에서 매핑!

3. ManyToMany N+1

class Post(models.Model):
    tags = models.ManyToManyField('Tag')

# ❌ N+1
for post in Post.objects.all():
    tag_names = [t.name for t in post.tags.all()]  # 매번 SELECT

# ✅ prefetch_related
for post in Post.objects.prefetch_related('tags'):
    tag_names = [t.name for t in post.tags.all()]  # 캐시!

4. 중첩 N+1 (가장 위험!)

# Post → Author → Company (3단계 중첩)

# ❌ N+1+1 (게시글 N + 작성자 N + 회사 N)
for post in Post.objects.all():
    print(f"{post.title} by {post.author.name} at {post.author.company.name}")
    # 게시글마다: author SELECT + company SELECT

# ✅ select_related 체이닝
for post in Post.objects.select_related('author__company'):
    print(f"{post.title} by {post.author.name} at {post.author.company.name}")
-- 3중 JOIN, 1번의 쿼리!
SELECT posts.*, authors.*, companies.*
FROM posts
JOIN authors ON posts.author_id = authors.id
JOIN companies ON authors.company_id = companies.id;
select_related:
  ├── SQL JOIN으로 한 번에 가져옴
  ├── ForeignKey / OneToOneField에만 사용
  ├── 1번의 쿼리 (JOIN)
  └── 데이터가 적을 때 효율적

prefetch_related:
  ├── 별도 쿼리 + Python에서 매핑
  ├── ManyToMany / Reverse FK에 사용
  ├── 2번의 쿼리 (메인 + IN)
  └── 데이터가 많거나 복잡한 관계에서 효율적
# 둘 다 같이 사용 가능!
posts = Post.objects.select_related(
    'author',           # FK → JOIN
    'author__company',  # FK → JOIN
).prefetch_related(
    'tags',             # M2M → 별도 쿼리
    'comments',         # Reverse FK → 별도 쿼리
).all()[:100]

# 총 4번의 쿼리로 모든 관계 로드!

ORM별 해결법

Django

# select_related (FK, O2O)
Post.objects.select_related('author', 'category')

# prefetch_related (M2M, Reverse FK)
Post.objects.prefetch_related('tags', 'comments')

# Prefetch 객체 (필터링 + 최적화)
from django.db.models import Prefetch

Post.objects.prefetch_related(
    Prefetch(
        'comments',
        queryset=Comment.objects.filter(is_approved=True).select_related('user'),
        to_attr='approved_comments'  # post.approved_comments로 접근
    )
)

# annotate로 COUNT 최적화 (서브쿼리 대신 집계)
from django.db.models import Count
Author.objects.annotate(post_count=Count('post')).filter(post_count__gte=5)

SQLAlchemy (Python)

from sqlalchemy.orm import joinedload, selectinload, subqueryload

# joinedload = select_related (JOIN)
session.query(Post).options(joinedload(Post.author)).all()

# selectinload = prefetch_related (SELECT ... IN)
session.query(Author).options(selectinload(Author.posts)).all()

# 중첩
session.query(Post).options(
    joinedload(Post.author).joinedload(Author.company),
    selectinload(Post.tags)
).all()

# 관계 로딩 전략 (모델에서 설정)
class Post(Base):
    author = relationship("Author", lazy="joined")     # 항상 JOIN
    tags = relationship("Tag", lazy="selectin")         # 항상 IN 쿼리
    comments = relationship("Comment", lazy="dynamic")  # 명시적 쿼리만

JPA / Hibernate (Java/Kotlin)

// ❌ Lazy Loading → N+1 기본 발생!
@Entity
public class Post {
    @ManyToOne(fetch = FetchType.LAZY)  // 기본값이 LAZY
    private Author author;
}

// ✅ 해결 1: JPQL JOIN FETCH
@Query("SELECT p FROM Post p JOIN FETCH p.author")
List<Post> findAllWithAuthor();

// ✅ 해결 2: EntityGraph
@EntityGraph(attributePaths = {"author", "tags"})
List<Post> findAll();

// ✅ 해결 3: Batch Size (Hibernate)
@BatchSize(size = 100)  // IN 절로 100개씩 묶어서 조회
@OneToMany(mappedBy = "author")
private List<Post> posts;

// application.yml
// spring.jpa.properties.hibernate.default_batch_fetch_size: 100

Prisma (TypeScript/Node.js)

// ❌ N+1
const posts = await prisma.post.findMany()
for (const post of posts) {
  const author = await prisma.author.findUnique({
    where: { id: post.authorId },
  })
}

// ✅ include (Eager Loading)
const posts = await prisma.post.findMany({
  include: {
    author: true,
    tags: true,
    comments: {
      where: { approved: true },
      include: { user: true },
    },
  },
})

ActiveRecord (Ruby on Rails)

# ❌ N+1
Post.all.each { |post| puts post.author.name }

# ✅ includes (자동으로 최적 전략 선택)
Post.includes(:author).each { |post| puts post.author.name }

# ✅ eager_load (항상 JOIN)
Post.eager_load(:author, :tags).all

# ✅ preload (항상 별도 쿼리)
Post.preload(:comments).all

# Bullet gem으로 자동 탐지!
# Gemfile: gem 'bullet'

N+1 탐지 방법

1. Django Debug Toolbar

# settings.py
INSTALLED_APPS = ['debug_toolbar']
MIDDLEWARE = ['debug_toolbar.middleware.DebugToolbarMiddleware']

# → 브라우저에서 SQL 쿼리 수 + 중복 확인!

2. 로그에서 쿼리 수 카운팅

# Django: 쿼리 수 자동 로깅 미들웨어
from django.db import connection, reset_queries
import logging

logger = logging.getLogger(__name__)

class QueryCountMiddleware:
    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request):
        reset_queries()
        response = self.get_response(request)
        query_count = len(connection.queries)

        if query_count > 10:  # 임계값
            logger.warning(
                f"⚠️ {request.path}: {query_count} queries! "
                f"Possible N+1 problem."
            )
            # 중복 쿼리 찾기
            queries = [q['sql'] for q in connection.queries]
            from collections import Counter
            dupes = {q: c for q, c in Counter(queries).items() if c > 1}
            if dupes:
                logger.warning(f"Duplicate queries: {dupes}")

        return response

3. pytest로 자동 탐지

import pytest
from django.test.utils import override_settings

@pytest.mark.django_db
def test_no_n_plus_1_on_post_list(client, django_assert_max_num_queries):
    # 게시글 100개 생성
    author = Author.objects.create(name="Test")
    for i in range(100):
        Post.objects.create(title=f"Post {i}", author=author)

    # 쿼리 수 제한 (100개 게시글인데 10번 이하여야 함)
    with django_assert_max_num_queries(10):
        response = client.get('/api/posts/')
        assert response.status_code == 200

4. SQLAlchemy 이벤트 리스너

from sqlalchemy import event
import warnings

@event.listens_for(Engine, "before_cursor_execute")
def receive_before_cursor_execute(conn, cursor, statement, *args):
    if not hasattr(conn, '_query_count'):
        conn._query_count = 0
    conn._query_count += 1
    if conn._query_count > 50:
        warnings.warn(f"Over 50 queries in one request! ({conn._query_count})")

GraphQL의 N+1 (DataLoader)

# GraphQL은 N+1의 온상!
# resolver가 필드별로 호출되므로

# ❌ N+1 발생
class PostType:
    def resolve_author(post, info):
        return Author.objects.get(id=post.author_id)  # 매번 SELECT!

# ✅ DataLoader로 배칭
from promise import Promise
from promise.dataloader import DataLoader

class AuthorLoader(DataLoader):
    def batch_load_fn(self, author_ids):
        # 한 번에 모두 조회!
        authors = {a.id: a for a in Author.objects.filter(id__in=author_ids)}
        return Promise.resolve([authors.get(id) for id in author_ids])

# Request마다 새 loader 생성
author_loader = AuthorLoader()

class PostType:
    def resolve_author(post, info):
        return author_loader.load(post.author_id)  # 배칭!

정리: N+1 해결 의사결정 트리

관계 타입이 뭔가?
├── ForeignKey / OneToOne
│   └── select_related (JOIN)├── ManyToMany / Reverse FK
│   └── prefetch_related (IN 쿼리)├── 중첩 관계 (ABC)
│   └── select_related 체이닝 ('b__c')├── 조건부 프리페치
│   └── Prefetch 객체 + queryset 필터 ✅
└── COUNT/SUM만 필요
    └── annotate + 집계 함수  (프리페치보다 효율적)

📝 퀴즈 — N+1 문제 (클릭해서 확인!)

Q1. N+1 문제에서 N과 1은 각각 무엇인가? ||1: 메인 엔티티 목록을 가져오는 첫 번째 쿼리. N: 각 엔티티의 연관 데이터를 개별 조회하는 N번의 추가 쿼리. 총 1+N번의 쿼리 발생||

Q2. select_related와 prefetch_related의 차이는? ||select_related: SQL JOIN으로 한 번에 조회 (FK/O2O용). prefetch_related: 별도 쿼리 후 Python에서 매핑 (M2M/Reverse FK용)||

Q3. 게시글 1,000개를 N+1로 조회하면 몇 번의 쿼리가 발생하나? ||1,001번 (목록 1번 + 작성자 1,000번). 최적화하면 2번 (select_related) 또는 2번 (prefetch_related)||

Q4. JPA에서 Lazy Loading이 기본인 이유와 문제점은? ||이유: 불필요한 연관 데이터를 미리 로딩하지 않아 메모리 절약. 문제점: 루프에서 접근 시 N+1 발생. JOIN FETCH 또는 EntityGraph로 해결||

Q5. GraphQL에서 N+1이 특히 심각한 이유와 해결책은? ||이유: resolver가 필드별로 독립 호출되어 각 Post의 author를 개별 조회. 해결: DataLoader로 같은 턴의 요청을 모아 배치 처리 (IN 쿼리 1번)||

Q6. 중첩 N+1 (Post → Author → Company)에서 총 쿼리 수는? ||최악의 경우 1 + N + N = 2N+1. 게시글 100개면 201번 쿼리. select_related('author__company')로 1번의 JOIN 쿼리로 해결||

Q7. annotate가 prefetch_related보다 효율적인 경우는? ||연관 데이터의 전체가 아닌 COUNT, SUM 같은 집계만 필요할 때. prefetch_related는 모든 데이터를 가져와 Python에서 처리하지만, annotate는 DB에서 집계 후 결과만 반환||

Q8. N+1을 자동으로 탐지하는 방법 3가지는? ||1) Django Debug Toolbar (브라우저에서 쿼리 수 확인) 2) 쿼리 카운팅 미들웨어 (임계값 초과 시 경고) 3) pytest django_assert_max_num_queries (테스트에서 쿼리 수 제한)||