[Spring] N+1문제 원인과 해결방법


목표 : JPA에서 발생하는 N+1 문제의 발생 원인과 해결 방안 확인하기
시각화를 위해 예시를 사용한다. 다이어그램에서는 Iterable을 사용했지만 , 실제 구현에서는 List를 사용한다.
1️⃣ N+1 ?
N+1 문제는, 예를 들어 User
목록을 조회하는 단일 요청 하나에 대해, 각 User
의 정보를 가져오기 위해 추가적인 쿼리가 발생하는 상황을 말한다. 다양한 연관관계들의 매핑에 의해서 관계가 맺어졌을때 다른 객체가 함께 조회되는 경우에 발생한다. 이 문제는 어떤 종류의 관계에서도 발생할 수 있다. 하지만 보통 다대다(many-to-many) 또는 일대다(one-to-many) 관계에서 주로 나타난다.
Lazy-Loading 지연 로딩
@Entity @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) public class User { @Id private Long id; private String username; private String email; @OneToMany(cascade = CascadeType.ALL, mappedBy = "author",fetch = FetchType.LAZY) private List<Post> posts; } @Entity @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) public class Post { @Id@GeneratedValue private Long id; private String content; @ManyToOne(fetch = FetchType.LAZY) private User author; }
User
는Post
와 일대다 관계를 가지고 있으며, 각 사용자(User)는 여러 개의 게시글(Post)을 갖는다. 모든User
를 가져오려 해도, Lazy Fetch는 우리가 접근한 정보만 조회한다. 즉, 모든User
를 가져올 때는 단 하나의 쿼리만 실행된다.posts
정보는 이전에 가져오지 않았기 때문에posts
에 접근하려고 하면, Hibernate는 추가 쿼리를 실행한다.@Test @DisplayName("Lazy type은 User 검색 후 필드 검색을 할 때 N+1문제가 발생한다.") void userFindTest() { System.out.println("== start =="); List<User> users = userRepository.findAll(); System.out.println("== find all =="); for (User user : users) { System.out.println(user.getPosts().size()); } }
User가 2명일때, findAll에서 쿼리 1개 + user.getPosts().size()에서 추가 쿼리가 2개(User가2명이니까)로 N+1문제가 발생한다.
Eager fetch 즉시 로딩
즉시 로딩으로 변환했을 때,
@OneToMany(cascade = CascadeType.ALL, mappedBy = "author",fetch = FetchType.EAGER) private List<Post> posts; @ManyToOne(fetch = FetchType.EAGER) private User author;
테스트 코드는 다음과 같다.
@Test @DisplayName("Eager type은 User를 단일 조회할 때 join문이 날아간다.") void userSingleFindTest() { System.out.println("== start =="); User user = userRepository.findById(1L) .orElseThrow(RuntimeException::new); System.out.println("== end =="); System.out.println(user.getUsername()); }
단일 조회에서는 조인으로 쿼리가 한번만 나가는 것을 확인 할 수 있다.
하지만 모든 User를 조회하는 경우 Posts 필드를 실제로 사용하든 말든 무조건 N+1 문제가 발생한다.
@Test @DisplayName("Eager type은 User를 전체 검색할 때 N+1문제가 발생한다.") void userFindTestEager() { System.out.println("== start =="); List<User> users = userRepository.findAll(); System.out.println("== find all =="); }
2️⃣ 해결 방안
일반적인 Fetch Join
쿼리를 날릴 때 post을 한번에 가져옴을 알 수 있다.
@Query("select distinct u from User u left join fetch u.posts") List<User> findAllJPQLFetch(); //테스트 @Test @DisplayName("fetch join을 하면 N+1문제가 발생하지 않는다.") void fetchJoinTest() { System.out.println("== start =="); List<User> users = userRepository.findAllJPQLFetch(); System.out.println("== find all =="); for (User user : users) { System.out.println(user.getPosts().size()); } }
//결과 == start == select distinct u1_0.id,u1_0.email,p1_0.user_id,p1_0.id,p1_0.content,u1_0.username from users u1_0 left join posts p1_0 on u1_0.id=p1_0.user_id; == find all == 2 1
@EntityGraph
위 테스트 코드에서 findAllEntityGraph를 사용했을때, 결과이다.@EntityGraph(attributePaths = {"posts"}, type = EntityGraph.EntityGraphType.FETCH) @Query("select distinct u from User u left join u.posts") List<User> findAllEntityGraph();
//결과 == start == select distinct u1_0.id,u1_0.email,p1_0.user_id,p1_0.id,p1_0.content,u1_0.username from users u1_0 left join posts p1_0 on u1_0.id=p1_0.user_id; == find all == 2 1
장점 : 단 한번의 쿼리만 발생하도록 설계할 수 있다.
단점 :
번거롭게 쿼리문을 작성해야 함
JPA가 제공하는 Pageable 기능 사용 불가→ 페이징 단위로 데이터 가져오기 불가능
- batch size로 해결 : 즉시로딩이나 지연로딩 시에 연관된 엔티티를 조회할 때 지정한 size 만큼 sql의 IN절을 사용해서 조회하는 방식
- 1 : N 관계가 2개인 엔티티를 패치 조인 사용 불가→ MultipleBagFetchException 발생
- batch size로 해결
출처
Subscribe to my newsletter
Read articles from Soyulia directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by

Soyulia
Soyulia
Nice to meet u :) Im Backend Developer