N+1 문제 (N plus One problem)

N+1 문제는 객체 관계형 매핑(ORM)을 사용하는 애플리케이션에서 발생하는 성능 문제이다.

N+1 문제는 데이터베이스 쿼리 실행 패턴과 관련된 성능 문제로, 하나의 쿼리(1)로 부모 엔티티 목록을 가져온 후, 각 부모 엔티티의 자식 엔티티를 가져오기 위해 추가적인 쿼리(N)를 실행하는 상황을 말한다.

예를 들어, 블로그 애플리케이션에서 10개의 게시글과 각 게시글에 달린 댓글을 가져오려고 할 때:

  1. 먼저 게시글 10개를 가져오는 쿼리 1번
  2. 각 게시글마다 댓글을 가져오기 위해 추가 쿼리 10번

총 11번(1+10)의 쿼리가 실행되는데, 이것이 바로 N+1 문제이다.

N+1 문제는 하나의 쿼리로 N개의 엔티티를 조회한 후, 각 엔티티와 연관된 데이터를 조회하기 위해 N번의 추가 쿼리가 발생하는 현상을 말한다.
주로 ORM(Object-Relational Mapping) 기술을 사용할 때 발생하는 성능 관련 이슈로 데이터베이스에 불필요하게 많은 쿼리를 실행하게 되는 상황을 말한다.

N+1 문제가 발생하는 이유

N+1 문제는 주로 다음과 같은 상황에서 발생한다:

  1. 지연 로딩(Lazy Loading): 관계된 엔티티를 실제로 사용할 때까지 로딩을 지연시키는 설정으로 인해 발생
  2. ORM의 기본 동작 방식: 많은 ORM 프레임워크들이 기본적으로 지연 로딩을 채택함
  3. 관계 탐색: 코드에서 관계를 탐색할 때 ORM이 자동으로 추가 쿼리를 발생시킴

N+1 문제의 영향

N+1 문제는 다음과 같은 부정적인 영향을 미친다:

  1. 성능 저하: 다수의 쿼리로 인한 데이터베이스 부하 증가
  2. 네트워크 오버헤드: 데이터베이스와 애플리케이션 서버 간 통신 증가
  3. 응답 시간 증가: 사용자 경험 저하
  4. 확장성 문제: 데이터 양이 증가할수록 문제가 더 심각해짐

N+1 문제가 어떻게 발생하는지에 대한 간단한 예시:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
# 블로그 게시물과 댓글의 관계를 나타내는 모델
class Post(models.Model):
    title = models.CharField(max_length=200)
    content = models.TextField()

class Comment(models.Model):
    post = models.ForeignKey(Post, related_name='comments')
    content = models.TextField()

# N+1 문제가 발생하는 코드
def get_posts_with_comments():
    # 첫 번째 쿼리: 모든 게시물을 가져옴
    posts = Post.objects.all()  # 1번의 쿼리
    
    # 각 게시물마다 댓글을 가져오는 추가 쿼리가 발생
    for post in posts:
        comments = post.comments.all()  # N번의 추가 쿼리
        print(f"Post: {post.title}, Comments: {len(comments)}")
1
2
3
4
5
6
7
8
9
-- 첫 번째 쿼리 (1번)
SELECT * FROM posts LIMIT 5;

-- 이후 각 게시물마다 실행되는 쿼리 (N번)
SELECT * FROM comments WHERE post_id = 1;
SELECT * FROM comments WHERE post_id = 2;
SELECT * FROM comments WHERE post_id = 3;
SELECT * FROM comments WHERE post_id = 4;
SELECT * FROM comments WHERE post_id = 5;

위 코드에서 발생하는 문제를 단계별로 설명하면,

  1. 첫 번째 쿼리에서 모든 게시물을 가져온다 (1번의 쿼리)
  2. 각 게시물에 대해 댓글을 가져오는 별도의 쿼리가 실행된다 (N번의 쿼리)
  3. 결과적으로 총 N+1번의 데이터베이스 쿼리가 실행된다

주요 ORM 프레임워크별 N+1 문제 발생 사례

Hibernate (Java)

1
2
3
4
5
6
// N+1 문제가 발생하는 코드
List<Post> posts = session.createQuery("from Post").list();
for (Post post : posts) {
    // 각 포스트마다 새로운 쿼리 발생
    List<Comment> comments = post.getComments();
}

JPA (Java)

1
2
3
4
5
6
// N+1 문제 발생
List<Post> posts = entityManager.createQuery("SELECT p FROM Post p", Post.class).getResultList();
for (Post post : posts) {
    // 각 포스트마다 새로운 쿼리 발생
    post.getComments().size();
}

Django ORM (Python)

1
2
3
4
5
# N+1 문제 발생
posts = Post.objects.all()
for post in posts:
    # 각 포스트마다 새로운 쿼리 발생
    comments = post.comment_set.all()

Entity Framework (C#)

1
2
3
4
5
6
7
// N+1 문제 발생
var posts = context.Posts.ToList();
foreach (var post in posts)
{
    // 각 포스트마다 새로운 쿼리 발생
    var comments = post.Comments.Count();
}

N+1 문제 해결 방법

1. 즉시 로딩(Eager Loading)

관련된 엔티티를 미리 함께 로드하는 방식.

Hibernate/JPA
1
2
// 즉시 로딩 사용
List<Post> posts = session.createQuery("SELECT p FROM Post p JOIN FETCH p.comments").list();
Django
1
2
# select_related 또는 prefetch_related 사용
posts = Post.objects.prefetch_related('comment_set').all()
Entity Framework
1
var posts = context.Posts.Include(p => p.Comments).ToList();

2. 배치 로딩(Batch Loading)

여러 객체를 한 번에 로딩하는 기술.

Hibernate 예시
1
2
3
4
// 배치 크기 설정
@BatchSize(size=25)
@OneToMany(mappedBy = "post")
private List<Comment> comments;

3. 조인 쿼리 사용

단일 쿼리로 필요한 모든 데이터를 가져온다.

1
2
3
SELECT p.*, c.* 
FROM posts p 
LEFT JOIN comments c ON p.id = c.post_id

4. DTO 프로젝션 사용

필요한 데이터만 정확히 선택하여 가져온다.

1
2
3
4
// JPA 예시
List<PostDTO> postDTOs = entityManager.createQuery(
    "SELECT new com.example.PostDTO(p.id, p.title, c.id, c.content) " +
    "FROM Post p LEFT JOIN p.comments c", PostDTO.class).getResultList();

프레임워크별 권장 해결책

  1. Spring/Hibernate (Java)
    1. @EntityGraph 사용
    2. JPQL의 JOIN FETCH 구문 활용
    3. @BatchSize 어노테이션 활용
  2. Django (Python)
    1. select_related() - 정방향 참조에 사용
    2. prefetch_related() - 역방향 참조에 사용
    3. Prefetch 객체를 활용한 복잡한 프리페치
  3. Rails (Ruby)
    1. includes() 메서드 사용
    2. preload()eager_load() 메서드 활용
  4. Entity Framework (C#)
    1. Include() 메서드 사용
    2. AsSplitQuery() 활용 (EF Core 5.0 이상)

N+1 문제 탐지 방법

  1. 로깅 활성화
    데이터베이스 쿼리 로그를 활성화하여 발생하는 쿼리를 모니터링한다.

  2. 성능 프로파일링 도구 사용

    • Java: JProfiler, YourKit
    • Python: Django Debug Toolbar
    • Ruby: Bullet gem
    • .NET: Entity Framework Profiler
  3. APM(Application Performance Monitoring) 도구

    • New Relic
    • Datadog
    • AppDynamics
    • Dynatrace

실제 사례 연구

사례 1: 대규모 이커머스 애플리케이션

사례 2: SNS 애플리케이션

N+1 문제 방지를 위한 모범 사례

  1. 개발 초기부터 관계 설계 주의: 양방향 관계와 복잡한 계층 구조를 신중하게 설계
  2. 쿼리 모니터링 습관화: 개발 단계에서 발생하는 쿼리 지속적 확인
  3. 성능 테스트 자동화: 대용량 데이터셋으로 성능 테스트 진행
  4. 적절한 패치 전략 선택: 사용 패턴에 따라 즉시 로딩과 지연 로딩 전략 선택
  5. 캐싱 고려: 자주 접근하는 데이터는 캐싱 고려

참고 및 출처