JPA - N+1 문제
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://jojoldu.tistory.com/165