연관관계에서 발생하는 문제로 연관 관계가 설정된 엔티티를 조회할 경우에 엔티티의 개수(n)만큼 연관관계 조회 쿼리가 추가적으로 나가는 상황을 말한다.
흔히 객체지향 설계에서 객체 간의 연관을 표현하는 것을 연관관계라 한다.
@OnetoMany, @ManyToOne , @OneToOne 과 같은 어노테이션을 사용하는 필드들을 일컫는다.
public class Post {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id")
@NotNull
private User user;
@OneToMany(mappedBy = "post", cascade = CascadeType.ALL)
private List<PostComment> postComments = new ArrayList<>();
...
}
public class PostComment {
@Id
@GeneratedValue
private Long id;
@ManyToOne
@JoinColumn(name = "post_id")
private Post post;
...
}

// 1. userId를 통해 모든 Post 조회
List<Post> allByUserId = postRepository.findAllByUserId(2L);
// 쿼리
select
post0_.id as id1_0_,
post0_.content as content2_0_,
post0_.search_counts as search_c3_0_,
post0_.solved as solved4_0_,
post0_.tags as tags5_0_,
post0_.title as title6_0_,
post0_.user_id as user_id8_0_,
post0_.views as views7_0_
from
post post0_
left outer join
user user1_
on post0_.user_id=user1_.id
where
user1_.id=?
// 2. 조회된 Post 에 달린 댓글들(PostComment) 조회
for( Post p : allByUserId ){
p.getPostComments().size();
}
// 쿼리 : 총 11번의 select문이 발생!
Hibernate:
select
postcommen0_.post_id as post_id3_1_0_,
postcommen0_.id as id1_1_0_,
postcommen0_.id as id1_1_1_,
postcommen0_.post_id as post_id3_1_1_,
postcommen0_.selected as selected2_1_1_
from
post_comment postcommen0_
where
postcommen0_.post_id=?
...
Hibernate:
select
postcommen0_.post_id as post_id3_1_0_,
postcommen0_.id as id1_1_0_,
postcommen0_.id as id1_1_1_,
postcommen0_.post_id as post_id3_1_1_,
postcommen0_.selected as selected2_1_1_
from
post_comment postcommen0_
where
postcommen0_.post_id=?
쿼리문에 집중해보자.
Hibernate:
select
postcommen0_.post_id as post_id3_1_0_,
postcommen0_.id as id1_1_0_,
postcommen0_.id as id1_1_1_,
postcommen0_.post_id as post_id3_1_1_,
postcommen0_.selected as selected2_1_1_
from
post_comment postcommen0_
where
postcommen0_.post_id=?
내가 조회한 Post들의 결과는 총 11개가 나왔다.
이때 각 Post들은 다른 PK들을 가지고 해당 PK를 댓글인 PostComment는 FK로 관리하고 있을 것이다.
Post 입장에서 댓글들을 모두 조회하려면?
조회된 Post의 PK를 FK로 가지는 PostComment들을 가져오는 수 밖에 없다.
그래서 11개의 Post에 대하여 11번의 개별 쿼리가 나간 것이다.
위의 결과쿼리들을 상세히 보면 이러한 형태일 것이다.
select * from PostComment postcommen0_ where postcommen0_.id = 1
select * from PostComment postcommen0_ where postcommen0_.id = 2
...
select * from PostComment postcommen0_ where postcommen0_.id = 11
Post Table을 조회할때 PostComment Table을 inner join하여 해당되는 결과를 함께 가져오는 것이다.
// PostRepository.java
@Query(value = "select p from Post p " +
"join fetch p.postComments "
+ "where p.user.id=:id")
List<Post> findAllByUserId(@Param(value = "id") Long id);
// 쿼리
Hibernate:
select
post0_.id as id1_0_0_,
postcommen1_.id as id1_1_1_,
post0_.content as content2_0_0_,
post0_.search_counts as search_c3_0_0_,
post0_.solved as solved4_0_0_,
post0_.tags as tags5_0_0_,
post0_.title as title6_0_0_,
post0_.user_id as user_id8_0_0_,
post0_.views as views7_0_0_,
postcommen1_.post_id as post_id3_1_1_,
postcommen1_.selected as selected2_1_1_,
postcommen1_.post_id as post_id3_1_0__,
postcommen1_.id as id1_1_0__
from
post post0_
inner join
post_comment postcommen1_
on post0_.id=postcommen1_.post_id
where
post0_.user_id=?

간단한 JPQL 작성으로 1번의 쿼리로 원하는 결과를 가져올 수 있다.
EntityGraph란
엔티티들은 서로 연관되어 있는 관계가 보통이며 이 관계는 그래프로 표현이 가능합니다. EntityGraph는 JPA가 어떤 엔티티를 불러올 때 이 엔티티와 관계된 엔티티를 불러올 것인지에 대한 정보를 제공합니다.
// PostReposiotry.java
@EntityGraph(attributePaths = {"postComments"})
List<Post> findAllByUserId(Long id);
// 쿼리
Hibernate:
select
post0_.id as id1_0_0_,
postcommen2_.id as id1_1_1_,
post0_.content as content2_0_0_,
post0_.search_counts as search_c3_0_0_,
post0_.solved as solved4_0_0_,
post0_.tags as tags5_0_0_,
post0_.title as title6_0_0_,
post0_.user_id as user_id8_0_0_,
post0_.views as views7_0_0_,
postcommen2_.post_id as post_id3_1_1_,
postcommen2_.selected as selected2_1_1_,
postcommen2_.post_id as post_id3_1_0__,
postcommen2_.id as id1_1_0__
from
post post0_
left outer join
user user1_
on post0_.user_id=user1_.id
left outer join
post_comment postcommen2_
on post0_.id=postcommen2_.post_id
where
user1_.id=?
해당 엔티티 조회를 조회하는 쿼리는 그대로(LAZY) 발생하고 이후 컬렉션을 조회할때 서브쿼리를 통해 조회하는 방법이다.
// Post.java
@Fetch(FetchMode.SUBSELECT)
@OneToMany(mappedBy = "post", cascade = CascadeType.ALL)
private List<PostComment> postComments = new ArrayList<>();
// PostRepository.java
List<Post> findAllByUserId(Long id);
// 쿼리
Hibernate:
select
post0_.id as id1_0_,
post0_.content as content2_0_,
post0_.search_counts as search_c3_0_,
post0_.solved as solved4_0_,
post0_.tags as tags5_0_,
post0_.title as title6_0_,
post0_.user_id as user_id8_0_,
post0_.views as views7_0_
from
post post0_
left outer join
user user1_
on post0_.user_id=user1_.id
where
user1_.id=?
Hibernate:
select
postcommen0_.post_id as post_id3_1_1_,
postcommen0_.id as id1_1_1_,
postcommen0_.id as id1_1_0_,
postcommen0_.post_id as post_id3_1_0_,
postcommen0_.selected as selected2_1_0_
from
post_comment postcommen0_
where
postcommen0_.post_id in (
select
post0_.id
from
post post0_
left outer join
user user1_
on post0_.user_id=user1_.id
where
user1_.id=?
)
하이버네이트가 제공하는 org.hibernate.annotations.BatchSize를 통해 연관된 엔티티를 조회할때 원하는 BatchSize만큼 IN 절에 사용하여 조회한다.
Fetch join과 EntityGraph와 달리 Join 연산이 필요가 없다.
IN clause에 들어가는 BatchSize의 적절한 크기를 찾기 힘들 수 있다.
hibernate.default_batch_fetch_size 속성을 사용하면 애플리케이션 전체에 기본으로 @BatchSize를 적용할 수 있다.
<property name="hibernate.default_batch_fetch_size" value="10" />
// Post.java
@BatchSize(size = 10)
@OneToMany(mappedBy = "post", cascade = CascadeType.ALL)
private List<PostComment> postComments = new ArrayList<>();
// PostRepository.java
List<Post> findAllByUserId(Long id);
// 쿼리
Hibernate:
select
post0_.id as id1_0_,
post0_.content as content2_0_,
post0_.search_counts as search_c3_0_,
post0_.solved as solved4_0_,
post0_.tags as tags5_0_,
post0_.title as title6_0_,
post0_.user_id as user_id8_0_,
post0_.views as views7_0_
from
post post0_
left outer join
user user1_
on post0_.user_id=user1_.id
where
user1_.id=?
Hibernate:
select
postcommen0_.post_id as post_id3_1_1_,
postcommen0_.id as id1_1_1_,
postcommen0_.id as id1_1_0_,
postcommen0_.post_id as post_id3_1_0_,
postcommen0_.selected as selected2_1_0_
from
post_comment postcommen0_
where
postcommen0_.post_id in (
?, ?, ?, ?, ?, ?, ?, ?, ?, ?
)
Hibernate:
select
postcommen0_.post_id as post_id3_1_1_,
postcommen0_.id as id1_1_1_,
postcommen0_.id as id1_1_0_,
postcommen0_.post_id as post_id3_1_0_,
postcommen0_.selected as selected2_1_0_
from
post_comment postcommen0_
where
postcommen0_.post_id=?
Query를 자동으로 실행해주는 다양한 플러그인이 있다.
대표적으로 Mybatis, QueryDSL, JOOQ, JDBC Template 등이 있을 것이다. 이를 사용하면 로직에 최적화된 쿼리를 구현할 수 있다.
// 예시
// QueryDSL로 구현한 예제
return from(owner).leftJoin(owner.cats, cat)
.fetchJoin()
이번 기회에 N+1 문제가 왜 생기는지에 대하여 알게 된 것같다.
다양한 해결방법이 존재하는만큼 프로젝트에 맞는 적절한 방법을 선택하는 것이 좋을 것 같고 상황에 따라 최적의 방법이 다를 것이다.
나의 경우 게시물들을 조회할때 페이지네이션을 적용시켜 10개정도를 함께 가져올 예정이기 때문에 BatchSize를 10으로 설정하면 Join 연산 없이 두번의 쿼리로 게시물과 댓글들을 함께 조회할 수 있을 것이다.
댓글들이 많아지거나 댓글들의 대댓글까지 관리하는 경우 게시물들을 10개씩 가져오더라도 부하가 심할 수 있다.
이 경우 해당 페이지의 게시물들만 따로 가져온 뒤 각 게시물들의 댓글들에 대해서도 페이지네이션을 적용하고 해당 페이지의 댓글들을 조회할때 대댓글을 함께 가져오는 전략을 취하면 될 것 같다.
(사실상 위에서 게시물들과 댓글들을 BatchSize를 통해 가져오는 전략과 비슷하다.)