JPA N+1
- N+1 문제란 1번의 쿼리를 날렸을 때 의도하지 않는 N번의 쿼리가 추가적으로 실행되는 것을 의미한다.
- 엔티티 조회 쿼리(1번) + 연관된 엔티티를 조회하기 위한 추가 쿼리(N번)
FetchType.EAGER (즉시 로딩)
- JPQL에서 만든 SQL을 통해 데이터를 조회
- JPA에서 Fetch 전략을 가지고 해당 데이터의 연관 관계인 하위 엔티티들을 추가 조회
- 2번 과정으로 N+1 문제 발생
FetchType.LAZY (지연 로딩)
- JPQL에서 만든 SQL을 통해 데이터를 조회
- JPA에서 Fetch 전략을 가지지만, 지연 로딩이기 때문에 추가 조회는 하지 않음
- 하위 엔티티를 가지고 작업하게 되면 추가 조회가 발생하여 N+1 문제 발생
발생하는 상황
게시글(post)와 댓글(comment)가 있습니다.
하나의 게시글에는 여러개의 댓글이 달릴 수 있습니다.
Post.java
@Entity
@Getter
@NoArgsConstructor
@AllArgsConstructor
public class Post {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column
private String title;
@Column
private String content;
@OneToMany(mappedBy = "post", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Comment> comments = new ArrayList<>();
public Comment writeComment(final String content) {
Comment comment = new Comment(content, this);
this.comments.add(comment);
return comment;
}
public Post(String title, String content) {
this.title = title;
this.content = content;
}
@Override
public String toString() {
return
"id=" + id +
", title='" + title + '\\'' +
", content='" + content + '\\'';
}
}
Comment.java
@Entity
@Getter
@NoArgsConstructor
@AllArgsConstructor
public class Comment {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column
private String content;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "post_id")
private Post post;
public Comment(String content, Post post) {
this.content = content;
this.post = post;
}
@Override
public String toString() {
return
"id=" + id +
", content='" + content + '\\'' +
", post=" + post;
}
}
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
class PostRepositoryTest {
@Autowired
private EntityManager em;
@Autowired
private PostRepository postRepository;
@Test
@DisplayName("N+1 발생 테스트")
@Transactional
public void test() {
int i=0;
saveData();
em.flush();
em.clear();
System.out.println("------------ 영속성 컨텍스트 비우기 -----------\\n\\n");
System.out.println("비우지 않을 시 영속성 컨텍스트에서 가져와 쿼리가 발생하지 않는다.\\n\\n");
System.out.println("게시글 전체 조회 요청");
List<Post> posts = postRepository.findAll();
System.out.println("게시글 전체 조회 완료 & Post 조회 쿼리 1번 발생\\n\\n");
System.out.println("게시글 내용 조회 요청");
for (Post post : posts) {
System.out.printf("POST 제목: [%s], POST 내용: [%s]%n", post.getTitle(), post.getContent());
}
System.out.println("게시글 조회 완료 & 추가적인 쿼리 발생하지 않음\\n\\n");
System.out.println("게시글의 댓글 내용 조회 요청");
for (Post post : posts) {
for (Comment comment : post.getComments()) {
System.out.printf("POST 제목: [%s], COMMENT 내용: [%s]%n", comment.getPost().getTitle(), comment.getContent());
}
System.out.printf("[%s]번 추가 쿼리 발생\\n", ++i);
}
System.out.println("게시글의 댓글 내용 조회 완료");
}
private void saveData() {
final String postTitleFormat = "[%d] post-title";
final String postContentFormat = "[%d] post-content";
final String commentContentFormat = "[%d] comment-content";
IntStream.rangeClosed(1, 10).forEach(i -> {
Post post = new Post(format(postTitleFormat, i), format(postContentFormat, i));
IntStream.rangeClosed(1, 3).forEach(j -> {
post.writeComment(format(commentContentFormat, j));
});
postRepository.save(post);
});
}
}
테스트 실행 시 다음과 같은 로그를 확인할 수 있습니다.
------------ 영속성 컨텍스트 비우기 -----------
비우지 않을 시 영속성 컨텍스트에서 가져와 쿼리가 발생하지 않는다.
게시글 전체 조회 요청
Hibernate:
select
post0_.id as id1_1_,
post0_.content as content2_1_,
post0_.title as title3_1_
from
post post0_
게시글 전체 조회 완료 & Post 조회 쿼리 1번 발생
게시글 내용 조회 요청
POST 제목: [[1] post-title], POST 내용: [[1] post-content]
POST 제목: [[2] post-title], POST 내용: [[2] post-content]
POST 제목: [[3] post-title], POST 내용: [[3] post-content]
POST 제목: [[4] post-title], POST 내용: [[4] post-content]
POST 제목: [[5] post-title], POST 내용: [[5] post-content]
POST 제목: [[6] post-title], POST 내용: [[6] post-content]
POST 제목: [[7] post-title], POST 내용: [[7] post-content]
POST 제목: [[8] post-title], POST 내용: [[8] post-content]
POST 제목: [[9] post-title], POST 내용: [[9] post-content]
POST 제목: [[10] post-title], POST 내용: [[10] post-content]
게시글 조회 완료 & 추가적인 쿼리 발생하지 않음
게시글의 댓글 내용 조회 요청
Hibernate:
select
comments0_.post_id as post_id3_0_0_,
comments0_.id as id1_0_0_,
comments0_.id as id1_0_1_,
comments0_.content as content2_0_1_,
comments0_.post_id as post_id3_0_1_
from
comment comments0_
where
comments0_.post_id=?
POST 제목: [[1] post-title], COMMENT 내용: [[1] comment-content]
POST 제목: [[1] post-title], COMMENT 내용: [[2] comment-content]
POST 제목: [[1] post-title], COMMENT 내용: [[3] comment-content]
[1]번 추가 쿼리 발생
Hibernate:
select
comments0_.post_id as post_id3_0_0_,
comments0_.id as id1_0_0_,
comments0_.id as id1_0_1_,
comments0_.content as content2_0_1_,
comments0_.post_id as post_id3_0_1_
from
comment comments0_
where
comments0_.post_id=?
POST 제목: [[2] post-title], COMMENT 내용: [[1] comment-content]
POST 제목: [[2] post-title], COMMENT 내용: [[2] comment-content]
POST 제목: [[2] post-title], COMMENT 내용: [[3] comment-content]
[2]번 추가 쿼리 발생
( 생략 )
Hibernate:
select
comments0_.post_id as post_id3_0_0_,
comments0_.id as id1_0_0_,
comments0_.id as id1_0_1_,
comments0_.content as content2_0_1_,
comments0_.post_id as post_id3_0_1_
from
comment comments0_
where
comments0_.post_id=?
POST 제목: [[10] post-title], COMMENT 내용: [[1] comment-content]
POST 제목: [[10] post-title], COMMENT 내용: [[2] comment-content]
POST 제목: [[10] post-title], COMMENT 내용: [[3] comment-content]
[10]번 추가 쿼리 발생
게시글의 댓글 내용 조회 완료
조회된 게시글의 수(10) 만큼 추가적인 쿼리가 발생한 것을 확인할 수 있습니다.
이렇게 1번의 쿼리를 날렸을 때 의도하지 않는 10번의 쿼리가 추가적으로 실행되는 것을 확인하였고 이를 N+1 문제라고 부릅니다.
ManyToOne 관계에서 발생하는 N+1
위의 Post, Comment Entity를 그대로 사용합니다.
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
class CommentRepositoryTest {
@Autowired
private EntityManager em;
@Autowired
private PostRepository postRepository;
@Autowired
private CommentRepository commentRepository;
@Test
@DisplayName("ManyToOne N + 1 발생 테스트")
void test2() {
int i=0;
saveSampleData(); // 3개의 post와, 각각의 post마다 2개씩 댓글 저장
em.flush();
em.clear();
System.out.println("------------ 영속성 컨텍스트 비우기 -----------\\n\\n");
System.out.println("비우지 않을 시 영속성 컨텍스트에서 가져와 쿼리가 발생하지 않는다.\\n\\n");
System.out.println("댓글 전체 조회 요청");
List<Comment> comments = commentRepository.findAll();
System.out.println("댓글 전체 조회 완료 & Post 조회 쿼리 1번 발생\\n\\n");
System.out.println("댓글과 연관된 게시글 조회-> N+1 발생");
for (Comment comment : comments) {
System.out.printf("POST 제목: [%s], COMMENT 내용: [%s]%n", comment.getPost().getTitle(), comment.getContent());
}
System.out.println("댓글과 연관된 게시글 조회 완료");
}
private void saveSampleData() {
final String postTitleFormat = "[%d] post-title";
final String postContentFormat = "[%d] post-content";
final String commentContentFormat = "[%d] comment-content";
IntStream.rangeClosed(1, 3).forEach(i -> {
Post post = new Post(format(postTitleFormat, i), format(postContentFormat, i));
IntStream.rangeClosed(1, 2).forEach(j -> {
post.writeComment(format(commentContentFormat, j));
});
postRepository.save(post);
});
}
}
테스트 실행 시 다음과 같은 로그를 확인할 수 있습니다.
------------ 영속성 컨텍스트 비우기 -----------
비우지 않을 시 영속성 컨텍스트에서 가져와 쿼리가 발생하지 않는다.
댓글 전체 조회 요청
Hibernate:
select
comment0_.id as id1_0_,
comment0_.content as content2_0_,
comment0_.post_id as post_id3_0_
from
comment comment0_
댓글 전체 조회 완료 & Post 조회 쿼리 1번 발생
댓글과 연관된 게시글 조회-> N+1 발생
Hibernate:
select
post0_.id as id1_1_0_,
post0_.content as content2_1_0_,
post0_.title as title3_1_0_
from
post post0_
where
post0_.id=?
POST 제목: [[1] post-title], COMMENT 내용: [[1] comment-content]
POST 제목: [[1] post-title], COMMENT 내용: [[2] comment-content]
Hibernate:
select
post0_.id as id1_1_0_,
post0_.content as content2_1_0_,
post0_.title as title3_1_0_
from
post post0_
where
post0_.id=?
POST 제목: [[2] post-title], COMMENT 내용: [[1] comment-content]
POST 제목: [[2] post-title], COMMENT 내용: [[2] comment-content]
Hibernate:
select
post0_.id as id1_1_0_,
post0_.content as content2_1_0_,
post0_.title as title3_1_0_
from
post post0_
where
post0_.id=?
POST 제목: [[3] post-title], COMMENT 내용: [[1] comment-content]
POST 제목: [[3] post-title], COMMENT 내용: [[2] comment-content]
댓글과 연관된 게시글 조회 완료
fetch join 사용
@Repository
public interface CommentRepository extends JpaRepository<Comment, Long> {
@Override
@Query("select c from Comment c join fetch c.post")
List<Comment> findAll();
}
댓글 전체 조회 요청
Hibernate:
select
comment0_.id as id1_0_0_,
post1_.id as id1_1_1_,
comment0_.content as content2_0_0_,
comment0_.post_id as post_id3_0_0_,
post1_.content as content2_1_1_,
post1_.title as title3_1_1_
from
comment comment0_
inner join
post post1_
on comment0_.post_id=post1_.id
댓글 전체 조회 완료 & Post 조회 쿼리 1번 발생
댓글과 연관된 게시글 조회-> N+1 발생
POST 제목: [[1] post-title], COMMENT 내용: [[1] comment-content]
POST 제목: [[1] post-title], COMMENT 내용: [[2] comment-content]
POST 제목: [[2] post-title], COMMENT 내용: [[1] comment-content]
POST 제목: [[2] post-title], COMMENT 내용: [[2] comment-content]
POST 제목: [[3] post-title], COMMENT 내용: [[1] comment-content]
POST 제목: [[3] post-title], COMMENT 내용: [[2] comment-content]
댓글과 연관된 게시글 조회 완료
로그를 통해 한 번의 쿼리문만 실행된 것을 확인할 수 있다.
@Repository
public interface CommentRepository extends JpaRepository<Comment, Long> {
//@Query("select c from Comment c join fetch c.post")
@Override
@EntityGraph(attributePaths = {"post"})
List<Comment> findAll();
}
@EntityGraph를 사용해도 fetch join과 무방하다
OneToMany 관계에서 발생하는 N+1
위의 Post, Comment Entity를 그대로 사용합니다.
fethc join, @EntityGraph로 해결 가능하나 페이징을 진행할 수 없고 둘 이상의 컬렉션을 fetch join한 데이터가 부정합하게 조회되기 때문에 권장하지 않는다.
그래서 @BatchSize or @Fetch(FetchMode.SUBSELECT)로 해결합니다.
맨 처음 N+1 발생 상황에 보여줬던 테스트 코드와 로그를 그대로 사용합니다.
Post.java
@Entity
@Getter
@NoArgsConstructor
@AllArgsConstructor
public class Post {
@BatchSize(size = 100)
@OneToMany(mappedBy = "post", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Comment> comments = new ArrayList<>();
}
OneToMany관계를 가지는 곳에 @BatchSize(Size = 100)을 추가합니다.
size 는 간단히 말해서 IN 절에 들어갈 요소의 최대 갯수를 의미합니다.
만약 IN 절에 size 보다 더 많은 요소가 들어가야 한다면 여러 개의 IN 쿼리로 나누어 날립니다.
위에서 봤던 로그가 아래와 같이 바뀌었습니다.
------------ 영속성 컨텍스트 비우기 -----------
비우지 않을 시 영속성 컨텍스트에서 가져와 쿼리가 발생하지 않는다.
게시글 전체 조회 요청
Hibernate:
select
post0_.id as id1_1_,
post0_.content as content2_1_,
post0_.title as title3_1_
from
post post0_
게시글 전체 조회 완료 & Post 조회 쿼리 1번 발생
게시글 내용 조회 요청
POST 제목: [[1] post-title], POST 내용: [[1] post-content]
POST 제목: [[2] post-title], POST 내용: [[2] post-content]
POST 제목: [[3] post-title], POST 내용: [[3] post-content]
POST 제목: [[4] post-title], POST 내용: [[4] post-content]
POST 제목: [[5] post-title], POST 내용: [[5] post-content]
POST 제목: [[6] post-title], POST 내용: [[6] post-content]
POST 제목: [[7] post-title], POST 내용: [[7] post-content]
POST 제목: [[8] post-title], POST 내용: [[8] post-content]
POST 제목: [[9] post-title], POST 내용: [[9] post-content]
POST 제목: [[10] post-title], POST 내용: [[10] post-content]
게시글 조회 완료 & 추가적인 쿼리 발생하지 않음
게시글의 댓글 내용 조회 요청
Hibernate:
select
comments0_.post_id as post_id3_0_1_,
comments0_.id as id1_0_1_,
comments0_.id as id1_0_0_,
comments0_.content as content2_0_0_,
comments0_.post_id as post_id3_0_0_
from
comment comments0_
where
comments0_.post_id in (
?, ?, ?, ?, ?, ?, ?, ?, ?, ?
)
in 을 사용해서 한 번에 댓글 내용을 조회합니다.
@Fetch(FetchMode.SUBSELECT)
@Entity
@Getter
@NoArgsConstructor
@AllArgsConstructor
public class Post {
@Fetch(FetchMode.SUBSELECT)
@OneToMany(mappedBy = "post", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Comment> comments = new ArrayList<>();
}
해당 어노테이션을 사용할 시 서브 쿼리를 사용해서 댓글을 조회합니다.
Hibernate:
select
comments0_.post_id as post_id3_0_1_,
comments0_.id as id1_0_1_,
comments0_.id as id1_0_0_,
comments0_.content as content2_0_0_,
comments0_.post_id as post_id3_0_0_
from
comment comments0_
where
comments0_.post_id in (
select
post0_.id
from
post post0_
)
결론
XToOne의 경우 : fetch join를 사용하여 해결
XToMany의 경우 : BatchSize를 사용하여 해결
최초 조회되는 대상에 따라서 쿼리의 수가 달라지기 때문에 상황에 따라 최적화를 해야합니다.
'Spring' 카테고리의 다른 글
Spring Actuator 와 Swagger를 사용할 시 의존성 오류 (0) | 2023.10.26 |
---|---|
SpringBoot, Prometheus, Grafana를 사용하여 Monitoring 구축하기 (1) (1) | 2023.10.24 |
QueryDsl를 활용하여 DTO로 바로 만들기 (0) | 2023.08.02 |
Spring Boot JPA 영속성 컨텍스트 (0) | 2023.07.27 |
Querydsl에서 Cross Join이 발생할 경우 (0) | 2023.07.23 |