본문 바로가기

Spring

[Spring] JPA N+1 발생 상황과 해결방법

JPA N+1

  • N+1 문제란 1번의 쿼리를 날렸을 때 의도하지 않는 N번의 쿼리가 추가적으로 실행되는 것을 의미한다.
  • 엔티티 조회 쿼리(1번) + 연관된 엔티티를 조회하기 위한 추가 쿼리(N번)

FetchType.EAGER (즉시 로딩)

  1. JPQL에서 만든 SQL을 통해 데이터를 조회
  2. JPA에서 Fetch 전략을 가지고 해당 데이터의 연관 관계인 하위 엔티티들을 추가 조회
  3. 2번 과정으로 N+1 문제 발생

FetchType.LAZY (지연 로딩)

  1. JPQL에서 만든 SQL을 통해 데이터를 조회
  2. JPA에서 Fetch 전략을 가지지만, 지연 로딩이기 때문에 추가 조회는 하지 않음
  3. 하위 엔티티를 가지고 작업하게 되면 추가 조회가 발생하여 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를 사용하여 해결

최초 조회되는 대상에 따라서 쿼리의 수가 달라지기 때문에 상황에 따라 최적화를 해야합니다.