JPA에서 N+1은 무엇이고 왜 발생할까?

uncle.ra·2023년 10월 10일
post-thumbnail

📗 N+1이 뭘까?

N+1에 대해서 바로 정의를 해보자면,
1개의 query가 수행되길 기대했는데, N개의 추가 쿼리가 발생한 현상을 의미한다.
이렇게 말하면 와닿지 않을 것 같다.🫠

이해를 돕기 위해서 Post와 Comment의 연관관계를 통해서 N+1에 대해서 살펴보고자 한다.

🧐 살펴보기 전에

Post, Comment ERD

Comment_Post_ERD

Post

@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
@Setter
public class Post {
	@Id
	@GeneratedValue(strategy = GenerationType.IDENTITY)
	private Long id;

	@Lob
	private String content;

	private String title;

	// @OneToMany의 경우 기본 fetch type은 지연 로딩이지만, N+1문제를 바로 테스트 해보기 위해서 즉시 로딩으로 설정했다.
	@OneToMany(mappedBy = "post", fetch = FetchType.EAGER)
	private List<Comment> comments = new ArrayList<>();

	public Post(String content, String title) {
		this.content = content;
		this.title = title;
	}
}

Comment

@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
@Setter
public class Comment {

	@Id
	@GeneratedValue(strategy = GenerationType.IDENTITY)
	private Long id;

	@Lob
	private String content;

	public Comment(String content) {
		this.content = content;
	}

	@ManyToOne
	@JoinColumn(name = "post_id")
	private Post post;

	@Override
	public String toString() {
		return "Comment{" +
			"id=" + id +
			", content='" + content + '\'' +
			'}';
	}
}

하나의 게시글(Post)은 여러 개의 댓글(Comment)을 가질 수 있다.
테스트를 위해서 게시글은 총 3개, 각 게시글마다 1개의 댓글을 준비했다.
N+1을 확인하기 위해 모든 게시글을 가져오는 상황을 생각해 보자.🧐

Test Code

테스트 코드는 아래와 같다.

@SpringBootTest
@Transactional
@Rollback(value = false)
class PostTest {

	@Autowired
	EntityManager em;

	@Autowired
	private PostRepository postRepository;

	@Autowired
	private CommentRepository commentRepository;

	@Test
	void checkNPlusOneProblem() throws Exception {
		// 게시글 총 3개
		for (int i = 0; i < 3; i++) {
			Post createdPost = postRepository.save(new Post("content_" + i, "title_" + i));
			// 각 게시글 당 1개의 댓글 생성
			commentRepository.save(new Comment("content_" + i, createdPost));
		}
		
		em.flush();
		em.clear();

		System.out.println("=== START ===");
        // 모든 post 조회
		List<Post> posts = postRepository.findAll();
		System.out.println("=== END ===");
	}
}

🧐 즉시 로딩을 적용했을 때 N+1 발생하는 경우

테스트 코드를 실행했을 때, 결과는 아래와 같다.
output_test_code
Post 전체 개수는 3개이고 각 Post마다 Comment가 1개씩 존재하는 상황에서 출력이 총 4번 발생한다.

Post 전체 개수 조회 쿼리(1번) + Post 전체 개수만큼 Comment 조회 쿼리 발생(N번)

조금 더 구체적으로 상황을 드려다 보자.

  1. findAll() method를 통해서 전체 게시글을 조회하는 쿼리를 한 번 호출한다.
  2. 호출하고 나서 확인해 보니, 영속성 컨텍스트가 연관관계인 Comment가 즉시 로딩임을 감지한다.
  3. 따라서 호출된 게시글의 개수 만큼 Comment 조회 쿼리가 호출 된다.

즉시로딩으로 설정했기 때문이라고 생각할 수 있지만 지연로딩일 경우에도 발생할 수 있다.

🧐 지연로딩을 적용했을 때 N+1 발생하는 경우

상단의 Post를 지연로딩으로 변경하자.


@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
@Setter
public class Post {
	@Id
	@GeneratedValue(strategy = GenerationType.IDENTITY)
	private Long id;

	@Lob
	private String content;

	private String title;

	// 지연 로딩 적용
	@OneToMany(mappedBy = "post", fetch = FetchType.LAZY)
	private List<Comment> comments = new ArrayList<>();

	public Post(String content, String title) {
		this.content = content;
		this.title = title;
	}
}

변경한 이후에 동일한 테스트 코드를 실행시켜보자.
결과는 아래와 같다.

LAZY_LOADING_SNAPSHOT

지연로딩을 설정해서 해결된거 아닌가? 연관관계인 Comment에 접근하지 않는다면 발생하지 않는다.
(정확히는 getComments() method까지는 발생하지 않는다. getComments() method 이후에 각 comment의 필드나, comments의 size() 등에 접근할 경우 N+1이 발생한다.)

아래의 테스트 코드로 수정하면 동일한 N+1이 발생한다.

@SpringBootTest
@Transactional
@Rollback(value = false)
class PostTest {

	@Autowired
	EntityManager em;

	@Autowired
	private PostRepository postRepository;

	@Autowired
	private CommentRepository commentRepository;

	@Test
	void checkNPlusOneProblem() throws Exception {
		// 게시글 총 3개
		for (int i = 0; i < 3; i++) {
			Post createdPost = postRepository.save(new Post("content_" + i, "title_" + i));
			// 각 게시글 당 1개의 댓글 생성
			commentRepository.save(new Comment("content_" + i, createdPost));
		}

		em.flush();
		em.clear();

		System.out.println("=== START ===");
		List<Post> posts = postRepository.findAll();
		for (Post post : posts) {
        	// post의 연관관계인 comment의 size() method에 접근
			post.getComments().size();
		}
		System.out.println("=== END ===");

	}
}

output_test_code

정의를 다시 한 번 체크해보자.

1개의 Query로 수행되길 기대했는데 N개의 추가 쿼리가 발생한 현상

사실 전체 게시글을 조회해 달라는 요구 사항에 한 번의 쿼리만 수행한다고 예상하고 전체 게시글을 조회했을 것이다. 하지만 연관관계로 매핑이 되어 있던 Comment가
전체 Post 개수만큼 호출 되었다. 이와 같은 문제를 N+1이라고 한다.

📗 N+1은 왜 발생할까?

그렇다면 N+1은 왜 발생할까?

기본적으로 JPA에서는 엔터티 조회 시 엔터티 자체만 가져오기 때문이다.
(하위 엔터티에 대해서 한 번에 가져오지 않는다.)

하위 엔터티(Comment)에 대해서 데이터를 어떻게 가져올지에 대한 방식은 2가지 존재한다.
1. 즉시 로딩(FetchType.EAGER)
2. 지연 로딩(FetchType.LAZY)

즉시 로딩이라고 하더라도 데이터를 하위 엔터티까지 한 번에 가져오는 것이 아니라, 상위 엔터티 조회 이후에, 즉시 로딩임을 인지하고 추가 쿼리가 발생하게 된다.
지연 로딩은 하위 엔터티의 필드 혹은 하위 엔터티의 size() method 같이 직접 접근할 경우에 추가 쿼리가 발생하게 된다.

📗 정리

Post와 Comment의 연관 관계를 통해서 N+1이 무엇이고 왜 발생하는 지에 대해서 살펴봤다.
연관관계가 없는 엔터티를 조회한다면 N+1문제는 절대 발생하지 않는다.
연관 관계를 가진 Entity를 조회할 때 N+1이 발생할 수 있고, 엔터티 조회 시 기본적으로 엔터티 자체만 가져오기 때문에 발생한다는 사실을 인지할 필요가 있다.

다음 포스팅에서는 N+1 해결책에 대해서 상세히 알아보자.

📗 참고

0개의 댓글