Dev/Spring

JPA - N+1 문제

kuidoli 2024. 9. 20. 16:49

N + 1 문제란?

1번의 쿼리 결과에 대한 N번의 추가 쿼리가 발생하는 문제를 나타낸다.


상황별 N + 1 문제 정리

쿼리를 조회하는 다양한 상황이 있을 수 있다.

다대일 관계에서 조회를 할 수 있고, 반대로 일대다 관계에서 조회를 할 수 있다.

연관관계 엔티티의 글로벌 패치 전략이 Eager 일 수도 있고 Lazy 일 수도 있다.

엔티티 전체를 조회할 수도 있고, 단일 엔티티를 조회할 수도 있다.

조회를 할 때 Spring Data JPA 를 사용할 수도 있고, Hibernate 를 사용할 수도 있다.

fetch join 을 사용할 수도 있고, 사용하지 않을 수도 있다.

이번 글에서는 이러한 상황별로 조회 쿼리를 실행했을 때 어떠한 결과가 나타나는지 정리해보려고 한다.


글로벌 페치 전략

연관관계의 엔티티를 조회하는 시점에 대한 전략이다. 전략은 Eager, Lazy 두 종류가 있다.

Eager 는 엔티티를 조회할 때 연관관계의 엔티티도 함께 조회한다.

Lazy 는 연관관계의 엔티티를 실제로 사용할 때 조회한다.


Fetch Join

연관관계의 엔티티(혹은 엔티티 컬렉션)를 한번의 SQL 로 조회하는 기능이다.

SQL 의 조인 종류가 아닌 JPQL 에서 제공하는 기능으로 글로벌 페치 전략보다 우선 적용된다.


엔티티

이번 글에서 사용하는 엔티티는 다음과 같다. Post, Comment 엔티티가 양방향 연관관계를 갖는다.


Post 엔티티

@Entity
@Table(name = "post")
@Getter
@NoArgsConstructor
public class Post {
  @Id
  @GeneratedValue(strategy = GenerationType.IDENTITY)
  private Long id;

  @Column(length = 50, nullable = false)
  private String title;

  @Column(length = 200, nullable = false)
  private String content;

  @OneToMany(mappedBy = "post", fetch = FetchType.LAZY)
  private List<Comment> comments = new ArrayList<Comment>();

  @OneToMany(mappedBy = "post", fetch = FetchType.LAZY)
  private List<Attachment> attachments = new ArrayList<Attachment>();
}

Comment 엔티티

@Entity
@Table(name = "comment")
@Getter
@NoArgsConstructor
public class Comment {
  @Id
  @GeneratedValue(strategy = GenerationType.IDENTITY)
  private Long id;

  @ManyToOne(fetch = FetchType.LAZY)
  @JoinColumn(name = "post_id", nullable = false)
  private Post post;

  @Column(length = 200, nullable = false)
  private String content;
}

1. 다대일(N:1) 관계

Comment 엔티티를 기준으로 연관관계가 걸린 Post 를 함께 조회한다.


1 - 1. 전체 조회

List<Comment> comments = commentRepository.findAll();
for (Comment comment : comments) {
  Post post = comment.getPost();
  System.out.println(post.getTitle());
}

모든 Comment 를 조회하고 for 문을 돌면서 Comment 엔티티에 연결된 Post 를 가져와서 Post 의 title 속성에 접근하는 상황이다.


1 - 1 - 1. Lazy Loading

Comment 의 Post 에 대한 fetch 속성을 Lazy 로 두고, Spring Data JPA 와 JPQL 을 쓰면서 fetch join 을 했을 때와 안 했을 때의 차이를 비교해본다.


  • Spring Data JPA + Fetch Join X -> N + 1 쿼리

    인터페이스로 제공하는 findAll 쿼리
    1번의 전체 Comment 에 대한 조회 쿼리가 발생한 후,
    Comment 의 갯수만큼 Post 를 조회하는 추가적인 쿼리가 발생한다.
    fetch type 이 Lazy 라서
    연관 관계에 있는 Post 를 가져오지 않고 
    Post 의 프록시 객체를 가지고 있다가
    Post 에 속성에 접근할 때마다 Post 를 조회하는 쿼리가 발생한다.

  • Spring Data JPA + Fetch Join O -> 1 쿼리

    -- 쿼리 로그
    select
        c1_0.id,
        c1_0.content,
        c1_0.post_id,
        p1_0.id,
        p1_0.content,
        p1_0.title
    from
        comment c1_0
    join
        post p1_0
            on p1_0.id=c1_0.post_id
    public interface CommentRepository extends JpaRepository<Comment, Long> {
      @Query("select c from Comment c join fetch c.post")
      List<Comment> findAllCommentWithPostFetchJoin();
    }

  • JPQL + Fetch Join X -> N + 1 쿼리

    1번의 전체 Comment 에 대한 조회 쿼리가 발생한 후, 
    Comment 의 갯수만큼 Post 를 조회하는 추가적인 쿼리가 발생한다.
    @Repository
    public class CommentEmRepository {
      private final EntityManager em;
    
      public List<Comment> findAll() {
        return em
          .createQuery("select c from Comment c", Comment.class)
          .getResultList();
      }
    }

  • JPQL + Fetch Join O -> 1 쿼리

    -- 쿼리 로그
    select
        c1_0.id,
        c1_0.content,
        c1_0.post_id,
        p1_0.id,
        p1_0.content,
        p1_0.title 
    from
        comment c1_0 
    join
        post p1_0 
            on p1_0.id=c1_0.post_id
    @Repository
    public class CommentEmRepository {
      private final EntityManager em;
    
      public List<Comment> findAllJoinFetch() {
        return em
          .createQuery("select c From Comment c join fetch c.post", Comment.class)
          .getResultList();
      }
    }

1 - 1 - 2. Eager Loading

이번에는 Comment 의 Post 에 대한 fetch 속성을 Eager 로 두고, Spring Data JPA 와 JPQL 을 쓰면서 fetch join 을 했을 때와 안 했을 때의 차이를 비교해본다.


  • Spring Data JPA + Fetch Join X -> N + 1 쿼리

    lazy 와 마찬가지로 n + 1 쿼리가 발생하는데, 
    둘의 차이는 lazy 는 Post 의 속성에 접근하는 시점에 n + 1 쿼리가 발생한다. 
    eager 는 Post 의 속성에 접근하기 전에 n + 1 쿼리가 발생한다. 
    eager 는 Comment 만 가져왔다가 fetch type 이 eager 라 JPA 에서 Post 도 미리 가져온다.

  • Spring Data JPA + Fetch Join O -> 1 쿼리

    Lazy Loading 때와 동일한 (inner) join 쿼리가 발생한다.

  • JPQL+ Fetch Join X -> N + 1 쿼리

    lazy 와 마찬가지로 n + 1 쿼리가 발생하는데, 
    둘의 차이는 lazy 는 Post 의 속성에 접근하는 시점에 n + 1 쿼리가 발생한다.
    eager 는 Post 의 속성에 접근하기 전에 n + 1 쿼리가 발생한다.
    eager 는 Comment 만 가져왔다가 fetch type 이 eager 라 JPA 에서 Post 도 미리 가져온다.

  • JPQL+ Fetch Join O -> 1 쿼리

1 - 2. 단일 조회

comment.getPost().getTitle()

1개의 Comment 를 조회하고 Comment 엔티티에 연결된 Post 를 가져와서 Post 의 title 속성에 접근하는 상황이다.


1 - 2 - 1. Lazy Loading

Comment 의 Post 에 대한 fetch 속성을 Lazy 로 두고, Spring Data JPA 와 JPQL 을 쓰면서 fetch join 을 했을 때와 안 했을 때의 차이를 비교해본다.


  • Spring Data JPA + Fetch Join X -> N + 1 쿼리 (1 + 1 쿼리)

    인터페이스로 제공하는 findById
    1번의 Comment 에 대한 조회 쿼리가 발생한 후, 
    Comment 의 갯수만큼 Post 를 조회하는 추가적인 쿼리가 발생한다. 
    여기서는 Comment 의 갯수가 하나기 때문에 1번의 추가적인 쿼리가 발생한다.
    -- 쿼리 로그
    -- Comment 에 대한 조회 쿼리
    select
        c1_0.id,
        c1_0.content,
        c1_0.post_id 
    from
        comment c1_0 
    where
        c1_0.id=?
    
    -- 1개 Comment 의 Post 에 대한 조회 쿼리
    select
        p1_0.id,
        p1_0.content,
        p1_0.title 
    from
        post p1_0 
    where
        p1_0.id=?

  • Spring Data JPA + Fetch Join O -> 1 쿼리

    -- 쿼리 로그
    select
        c1_0.id,
        c1_0.content,
        c1_0.post_id,
        p1_0.id,
        p1_0.content,
        p1_0.title 
    from
        comment c1_0 
    join
        post p1_0 
            on p1_0.id=c1_0.post_id 
    where
        c1_0.id=?
    public interface CommentRepository extends JpaRepository<Comment, Long> {
      @Query("select c from Comment c join fetch c.post where c.id = :commentId")
      Optional<Comment> findByCommentId(@Param("commentId") Long commentId);
    }

  • JPQL + Fetch Join X -> N + 1 쿼리 (1 + 1 쿼리 발생)

    1번의 Comment 에 대한 조회 쿼리가 발생한 후, 
    Comment 의 갯수만큼 Post 를 조회하는 추가적인 쿼리가 발생한다. 
    여기서는 Comment 의 갯수가 하나기 때문에 1번의 추가적인 쿼리가 발생한다.
    Spring Data JPA + Fetch Join X 와 동일한 쿼리가 발생한다
    public class CommentEmRepository {
      public Comment findById(Long commentId) {
        return em
          .createQuery("select c from Comment c where c.id = :commentId", Comment.class)
          .setParameter("commentId", commentId)
          .getSingleResult();
      }
    }

  • JPQL+ Fetch Join O -> 1 쿼리 (1에 해당하는 Post 가 null 일 수 있으니 주의해야 한다)

    -- 쿼리 로그
    select
        c1_0.id,
        c1_0.content,
        c1_0.post_id,
        p1_0.id,
        p1_0.content,
        p1_0.title 
    from
        comment c1_0 
    join
        post p1_0 
            on p1_0.id=c1_0.post_id 
    where
        c1_0.id=?
    public class CommentEmRepository {
      return em
        .createQuery("select c from Comment c join fetch c.post where c.id = :commentId", Comment.class)
        .setParameter("commentId", commentId)
        .getSingleResult();
    }

1 - 2 - 2. Eager Loading

  • Spring Data JPA + Fetch Join X -> *1 쿼리 *

    Lazy Loading 과는 달리 N + 1 쿼리가 아닌 1 쿼리가 발생했다
    findById 메소드는 내부적으로 EntityMager 의 find 메소드를 호출한다. 
    find 메소드는 내부적으로 최적화가 진행돼서 N + 1 쿼리가 아닌 1쿼리가 발생하는 것으로 보인다.
    -- 조회 쿼리
    select
        c1_0.id,
        c1_0.content,
        c1_0.post_id,
        p1_0.id,
        p1_0.content,
        p1_0.title 
    from
        comment c1_0 
    join
        post p1_0 
            on p1_0.id=c1_0.post_id 
    where
        c1_0.id=?

  • Spring Data JPA + Fetch Join O -> 1 쿼리

    fetch join 을 안 했을때와 동일한 쿼리가 발생한다

  • JPQL + Fetch Join X -> N + 1 쿼리 (1 + 1 쿼리 발생)

    Lazy Loading (JPQL + Fetch Join X) 과 동일한 쿼리가 발생한다. 
    1번의 Comment 에 대한 조회 쿼리가 발생한 후, 
    Comment 의 갯수만큼 Post 를 조회하는 추가적인 쿼리가 발생한다. 
    여기서는 Comment 의 갯수가 하나기 때문에 1번의 추가적인 쿼리가 발생한다.

  • JPQL + Fetch Join O -> 1 쿼리

    Lazy Loading (JPQL + Fetch Join O) 과 동일한 쿼리가 발생한다.

2. 일대다(1:N) 관계

2 - 1. 전체 조회

for (Post p : posts) {
  System.out.println("post = " + p);
  for (Comment c : p.getComments()) {
    System.out.println(c.getContent());
  }
}

모든 Post 를 조회하고 for 문을 돌면서 Post 엔티티에 연결된 Comment 를 가져와서 Comment 의 content 속성에 접근하는 상황이다.


2 - 1 - 1. Lazy Loading

  • Spring Data JPA + Fetch Join X -> N + 1 쿼리

    인터페이스로 제공하는 findAll 쿼리
    전체 Post 에 대한 1번의 쿼리가 발생한 후,
    Post 의 갯수만큼 Post 와 연결된 Comment 를 연결하는 추가 쿼리가 발생한다.
    -- 쿼리 로그
    -- 전체 Post 에 대한 조회 쿼리
    select
        p1_0.id,
        p1_0.content,
        p1_0.title 
    from
        post p1_0
    
    -- Post 하나당 연결된 Comment 에 대한 조회 쿼리
    select
        c1_0.post_id,
        c1_0.id,
        c1_0.content 
    from
        comment c1_0 
    where
        c1_0.post_id=?
    
    select
        c1_0.post_id,
        c1_0.id,
        c1_0.content 
    from
        comment c1_0 
    where
        c1_0.post_id=?

  • Spring Data JPA + Fetch Join O -> 1 쿼리

    -- 쿼리 로그
    select
        p1_0.id,
        c1_0.post_id,
          c1_0.id,
          c1_0.content,
          p1_0.content,
          p1_0.title 
    from
        post p1_0 
    join
          comment c1_0 
              on p1_0.id=c1_0.post_id
    public interface PostRepository extends JpaRepository<Post, Long> {
      @Query("select p from Post p join fetch p.comments")
      List<Post> findAllPostWithCommentJoinFetch();
    }

  • JPQL + Fetch Join X -> N + 1 쿼리

    Spring Data JPA + Fetch Join X 과 동일한 쿼리가 발생한다.
    public class PostEmRepository {
      public List<Post> findAll() {
        return em.createQuery("select p from Post p", Post.class)
          .getResultList();
      }
    }

  • JPQL+ Fetch Join O -> 1 쿼리

    Spring Data JPA + Fetch Join O 과 동일한 쿼리가 발생한다.
    public class PostEmRepository {
      public List<Post> findAllJoinFetchByEm() {
        return em.createQuery("select p from Post p join fetch p.comments", Post.class)
          .getResultList();
      }
    }

2 - 1 - 2. Eager Loading

  • Spring Data JPA + Fetch Join X -> N + 1 쿼리

    lazy 와 마찬가지로 n + 1 쿼리가 발생하는데, 
    둘의 차이는 lazy 는 Comment 의 속성에 접근하는 시점에 n + 1 쿼리가 발생한다. 
    eager 는 Comment 의 속성에 접근하기 전에 n + 1 쿼리가 발생한다. 
    eager 는 Post 만 가져왔다가 fetch type 이 eager 라 JPA 에서 Comment 도 미리 가져온다.

  • Spring Data JPA + Fetch Join O -> 1 쿼리

    lazy 와 동일한 쿼리가 발생한다.

  • JPQL + Fetch Join X -> N + 1 쿼리

    lazy 와 마찬가지로 n + 1 쿼리가 발생하는데, 
    둘의 차이는 lazy 는 Comment 의 속성에 접근하는 시점에 n + 1 쿼리가 발생한다. 
    eager 는 Comment 의 속성에 접근하기 전에 n + 1 쿼리가 발생한다. 
    eager 는 Post 만 가져왔다가 fetch type 이 eager 라 JPA 에서 Comment 도 미리 가져온다.

  • JPQL+ Fetch Join O -> 1 쿼리

    lazy 와 동일한 쿼리가 발생한다.

2 - 2. 단일 조회

for (Comment c : post.getComments()) {
  // Post 를 조회한 후, Comment 의 속성에 접근하면 쿼리 발생 
  System.out.println(c.getContent());
}

1개의 Post를 조회하고 Post엔티티에 연결된 Comment 를 가져와서 Comment 의 content 속성에 접근하는 상황이다.


2 - 2 - 1. Lazy Loading

  • Spring Data JPA + Fetch Join X -> N + 1 쿼리 (1 + 1 쿼리)

    인터페이스로 제공하는 findById
    -- 쿼리 로그
    -- Post 1개에 대한 조회 쿼리
    select
        p1_0.id,
        p1_0.content,
        p1_0.title 
    from
        post p1_0 
    where
        p1_0.id=?
    
    -- 조회된 Post 와 연결된 Comment 에 대한 조회 쿼리
    select
        c1_0.post_id,
        c1_0.id,
        c1_0.content 
    from
        comment c1_0 
    where
        c1_0.post_id=?

  • Spring Data JPA + Fetch Join O -> 1 쿼리

    -- 쿼리 로그
    select
        p1_0.id,
        c1_0.post_id,
        c1_0.id,
        c1_0.content,
        p1_0.content,
        p1_0.title 
    from
        post p1_0 
    join
        comment c1_0 
            on p1_0.id=c1_0.post_id 
    where
        p1_0.id=?
    public interface PostRepository extends JpaRepository<Post, Long> {
      @Query("select p from Post p join fetch p.comments where p.id = :id")
      Post findPostWithComment(@Param("id") Long id);
    }

  • JPQL + Fetch Join X -> N + 1 쿼리 (1 + 1 쿼리)

    Spring Data JPA + Fetch Join X 과 동일한 쿼리가 발생한다.
    public class PostEmRepository {
      public Post findPostWithComment(Long postId) {
        return em.createQuery("select p from Post p where p.id = :id", Post.class)
          .setParameter("id", postId)
          .getSingleResult();
      }
    }

  • JPQL + Fetch Join O -> 1 쿼리

    Spring Data JPA + Fetch Join O 과 동일한 쿼리가 발생한다.
    public class PostEmRepository {
      public Post findPostJoinFetchWithComment(Long postId) {
        return em.createQuery("select p from Post p join fetch p.comments where p.id = :id", Post.class)
          .setParameter("id", postId)
          .getSingleResult();
      }
    }

2 - 2 - 2. Eager Loading

  • Spring Data JPA + Fetch Join X -> 1 쿼리

    N + 1 쿼리가 아닌 1 쿼리가 발생했다.
    
    -- left join
    select
        p1_0.id,
        p1_0.content,
        p1_0.title,
        c1_0.post_id,
        c1_0.id,
        c1_0.content 
    from
        post p1_0 
    left join
        comment c1_0 
            on p1_0.id=c1_0.post_id 
    where
        p1_0.id=?

  • Spring Data JPA + Fetch Join O -> 1 쿼리

    fetch join 을 안했을 경우는 left join 이 발생하는데, 
    fetch join 을 하면 inner join 이 발생한다.
    select
        p1_0.id,
        p1_0.content,
        p1_0.title,
        c1_0.post_id,
        c1_0.id,
        c1_0.content 
    from
        post p1_0 
    join
        comment c1_0 
            on p1_0.id=c1_0.post_id 
    where
        p1_0.id=?

  • JPQL + Fetch Join X -> N + 1 쿼리 (1 + 1 쿼리)

    lazy loading (JPQL + Fetch Join X) 과 동일한 쿼리가 발생한다.

  • JPQL + Fetch Join O -> 1 쿼리

    spring data jpa + fetch join O 의 경우와 동일한 inner join 쿼리가 발생한다.
    select
        p1_0.id,
        p1_0.content,
        p1_0.title,
        c1_0.post_id,
        c1_0.id,
        c1_0.content 
    from
        post p1_0 
    join
        comment c1_0 
            on p1_0.id=c1_0.post_id 
    where
        p1_0.id=?

정리

총 32가지 경우의 수를 이번 글을 통해서 정리해보았다. 경우의 수마다 직접 확인을 해보았는데 예상과 다른 결과가 나오기도 했다.

특히, N:1 관계에서 단건 조회시 Spring Data JPA 로 fetch join 을 사용하지 않고 인터페이스로 제공되는 findById 메소드를 사용했을 때 Lazy Loading 과 달리 Eager Loading 에서는 1번의 쿼리만 발생하는 점이 예상과 달랐다.



<참고>

자바 ORM 표준 JPA 프로그래밍

https://velog.io/@jinyoungchoi95/JPA-%EB%AA%A8%EB%93%A0-N1-%EB%B0%9C%EC%83%9D-%EC%BC%80%EC%9D%B4%EC%8A%A4%EA%B3%BC-%ED%95%B4%EA%B2%B0%EC%B1%85

https://jojoldu.tistory.com/165

https://jojoldu.tistory.com/457

https://cobbybb.tistory.com/18