JPA N+1 쿼리 이슈

 

N+1 쿼리 이슈

Select 쿼리 시 연관관계가 있는 N개의 entity를 가져오려 할 때 N개의 Select 쿼리가 발생하는 이슈

JPA와 Hibernates를 활용했던 개발자라면 한 번이라도 맞닥뜨렸을 문제이다. 그만큼 많은 개발자가 이를 겪었으며, 이를 다양한 방법으로 해결하였다. 해당 이슈가 발생하는 경우와 그 이유, 그리고 해결 방법에 대해서 알아보도록 하자.

예시 구조

@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Entity
@Table(name = "user")
@SQLDelete(sql = "UPDATE `user` SET is_deleted = 1 WHERE id = ?")
public class User {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    private String name;
    
    @Builder.Default
    @ColumnDefault("false")
    @Column(nullable = false)
    private final Boolean isDeleted = false;
    
    @OneToMany(mappedBy = "user")
    private List<Order> orderList;
}
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Entity
@Table(name = "order")
@SQLDelete(sql = "UPDATE `order` SET is_deleted = 1 WHERE id = ?")
public class Order {

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

    @ManyToOne
    @JoinColumn(name = "user_id", foreignKey = @ForeignKey(name = "fk_order_user_id"))
    private User user;

    @Builder.Default
    @ColumnDefault("false")
    @Column(nullable = false)
    private Boolean isDeleted = false;
}

N+1 쿼리 이슈 발생

FetchType.EAGER

메인 쿼리를 실행하고 이와 연관관계가 있는 Entity를 조회하기 위해 미리 가져오는 과정에서 N개의 쿼리가 발생한다.

@Transactional
@Test
public void findAll_fetchEager() {
    orderRepository.findAll();
}

/*

* 메인 쿼리 (1)
Hibernate: 
    select
        order0_."id" as id1_0_,
        order0_."is_deleted" as is_delet2_0_,
        order0_."user_id" as user_id3_0_ 
    from
        "order" order0_

* 연관관계 쿼리 (N)
Hibernate: 
    select
        user0_."id" as id1_1_0_,
        user0_."is_deleted" as is_delet2_1_0_,
        user0_."name" as name3_1_0_ 
    from
        "user" user0_ 
    where
        user0_."id"=?
...

*/

FetchType.LAZY

FetchType.EAGER 와 다르게 FetchType.LAZY 의 경우 실질적으로 사용될 때 쿼리를 하게 된다. 그렇기에 연관관계의 엔티티를 사용하게 될 때 N개의 쿼리가 발생한다.

// @ManyToOne(fetch = FetchType.LAZY)로 변경할 경우

@Transactional
@Test
public void findAll_fetchLazy() {
    List<Order> orders = orderRepository.findAll();
    
    for (Order order : orders) {
        User user = order.getUser();
        log.info("UserName: {}", user.getName());
    }
}

/*

* 메인 쿼리 (1)
Hibernate: 
    select
        order0_."id" as id1_0_,
        order0_."is_deleted" as is_delet2_0_,
        order0_."user_id" as user_id3_0_ 
    from
        "order" order0_

* 연관관계 쿼리 (N)
Hibernate: 
    select
        user0_."id" as id1_1_0_,
        user0_."is_deleted" as is_delet2_1_0_,
        user0_."name" as name3_1_0_ 
    from
        "user" user0_ 
    where
        user0_."id"=?

*/

N+1 쿼리 이슈 발생 원인

위에서 FetchType에 따라서 N+1 쿼리 이슈를 살펴본 것 처럼, FetchType와 상관없이 실질적으로 연관관계의 엔티티를 사용(접근)할 때 N개의 쿼리가 발생한다. FetchType.EAGER의 경우 미리 fetch 하기 때문에 메인 쿼리와 함께 바로 쿼리가 실행되며, FetchType.LAZY의 경우 실질적으로 연관관계의 엔티티가 사용할 때 쿼리가 실행된다.

N+1 쿼리 이슈 해결 방법

Fetch Join

Fetch Join 을 통해서 N+1 쿼리 이슈를 해결할 수 있다. 일반적인 JOIN에 FETCH option을 주고 사용할 수 있어 INNER, OUTER 모두 사용할 수 있다. 해당 Fetch 옵션을 FetchType.LAZY로 설정하였어도 FetchType.EAGER 로 데이터를 한 번에 가져온다.

// #1 JPQL

@Repository
public interface OrderRepository extends JpaRepository<Order, Long> {
    @Query("SELECT o FROM Order o JOIN FETCH o.user")
    List<Order> findAllWithJoinFetch();
}

/*

Hibernate: 
  select
      order0_."id" as id1_0_0_,
      user1_."id" as id1_1_1_,
      order0_."is_deleted" as is_delet2_0_0_,
      order0_."user_id" as user_id3_0_0_,
      user1_."is_deleted" as is_delet2_1_1_,
      user1_."name" as name3_1_1_ 
  from
      "order" order0_ 
  inner join
      "user" user1_ 
          on order0_."user_id"=user1_."id"

*/
// #2 Criteria

public class OrderCustomRepositoryImpl extends OrderCustomRepository {
		
    @Autowired
    EntityManager entityManager;
    
    @Override
    public List<Order> findAllWithJoinFetch() {
        CriteriaBuilder criteriaBuilder = entityManager.getCriteriaBuilder();
        CriteriaQuery<Order> criteriaQuery = criteriaBuilder.createQuery(Order.class);
    
        Root<Order> order = criteriaQuery.from(Order.class);
        order.fetch("user", JoinType.INNER);
    
        criteriaQuery.select(order);
    
        TypedQuery<Order> typedQuery = entityManager.createQuery(criteriaQuery);
    
        return typedQuery.getResultList();
    }
}

/*

Hibernate: 
    select
        order0_."id" as id1_2_0_,
        user1_."id" as id1_4_1_,
        order0_."is_deleted" as is_delet2_2_0_,
        order0_."user_id" as user_id3_2_0_,
        user1_."is_deleted" as is_delet2_4_1_,
        user1_."name" as name3_4_1_ 
    from
        "order" order0_ 
    inner join
        "user" user1_ 
            on order0_."user_id"=user1_."id"

	*/
// #3 QueryDSL

public class OrderCustomRepositoryImpl extends QuerydslRepositorySupport implements OrderCustomRepository {

    public OrderCustomRepositoryImpl() {
        super(Order.class);
    }
    
    @Override
    public List<Order> findAllWithJoinFetch() {
        return from(QOrder.order)
            .innerJoin(QOrder.order.user, QUser.user)
            .fetchJoin()
            .fetch();
    }
}

/*

Hibernate: 
    select
        order0_."id" as id1_0_0_,
        user1_."id" as id1_1_1_,
        order0_."is_deleted" as is_delet2_0_0_,
        order0_."user_id" as user_id3_0_0_,
        user1_."is_deleted" as is_delet2_1_1_,
        user1_."name" as name3_1_1_ 
    from
        "order" order0_ 
    inner join
        "user" user1_ 
            on order0_."user_id"=user1_."id"

*/

한계 및 문제점

 

1. 중복 처리

@OneToMany fetch join의 상황에서 쿼리 결과에 중복데이터가 포함된다. 그리하여 @OneToMany fetch join에선 중복 데이터 처리가 필요하다. 중복 처리 방법은 크게 두 가지가 있다. 첫 번째는 DISTINCT 구문을 활용하는 것이며, 두 번째는 Set 컬렉션를 활용하는 방법이다. Set은 자료구조 상 중복데이터를 처리한다.

// DUPLICATED ROWS

@Query(value = "SELECT u FROM User u JOIN FETCH u.orderList")
List<User> findAllWithJoinFetch();

@Test
public void findAllWithJoinFetch_withOnlyFetchJoin_shouldBeReturnedDuplicatedRows() {
    List<User> users = userRepository.findAll();
    List<User> joinFetchResults = userRepository.findAllWithJoinFetch();
    
    log.info("{}", users.size()); // 2 rows
    log.info("{}", joinFetchResults.size()); // 7 rows
    
    assertThat(users.size(), not(joinFetchResults.size()));
}
// FETCH JOIN WITH DISTINCT STATEMENT

@Query(value = "SELECT DISTINCT u FROM User u JOIN FETCH u.orderList")
List<User> findAllWithJoinFetch();

@Test
public void findAllWithJoinFetch_withDistinctStatement_shouldBeReturnedUniqueRows() {
    List<User> users = userRepository.findAll();
    List<User> joinFetchResults = userRepository.findAllWithJoinFetch();

    log.info("{}", users.size()); // 2 rows
    log.info("{}", joinFetchResults.size()); // 2 rows

    assertThat(users.size(), is(joinFetchResults.size()));
}
// FETCH JOIN WITH SET COLLECTION

@Query(value = "SELECT u FROM User u JOIN FETCH u.orderList")
Set<User> findAllWithJoinFetch();

@Test
public void findAllWithJoinFetch_withSetCollection_shouldBeReturnedUniqueRows() {
    List<User> users = userRepository.findAll();
    Set<User> joinFetchResults = userRepository.findAllWithJoinFetch();

    log.info("{}", users.size());  // 2 rows
    log.info("{}", joinFetchResults.size());  // 2 rows

    assertThat(users.size(), is(joinFetchResults.size()));
}

 

2. 페이징 처리

@OneToMany fetch join의 상황에서 쿼리를 통한 페이징 처리가 되지 않는다. 페치 조인한 모든 결괏 값을 가져와 이를 메모리에 올려 페이징 처리를 하기 때문이다. 쿼리 결과가 양이 적으면 큰 문제라고 볼 수 없으나, 쿼리 결과가 많으면 해당 서비스의 메모리를 많이 사용하게 된다.

// GENERAL JOIN WITH PAGING API

@Query(value = "SELECT o FROM Order o JOIN o.user", countQuery = "SELECT COUNT(o) FROM Order o")
Page<Order> findAllWithPaging(Pageable page);

@Test
public void join() {
    orderRepository.findAllWithPaging(PageRequest.of(1, 2));
}

/*

Hibernate: 
    select
        order0_."id" as id1_0_,
        order0_."is_deleted" as is_delet2_0_,
        order0_."user_id" as user_id3_0_ 
    from
        "order" order0_ 
    inner join
        "user" user1_ 
            on order0_."user_id"=user1_."id" limit ? offset ?
Hibernate: 
    select
        user0_."id" as id1_1_0_,
        user0_."is_deleted" as is_delet2_1_0_,
        user0_."name" as name3_1_0_ 
    from
        "user" user0_ 
    where
        user0_."id"=?

*/

// N:1 FETCH JOIN WITH PAGING API

@Query(value = "SELECT o FROM Order o JOIN FETCH o.user", countQuery = "SELECT COUNT(o) FROM Order o")
Page<Order> findAllWithPagingUsingFetchJoin(Pageable page);

@Test
public void fetchJoin_manyToOne() {
    orderRepository.findAllWithPagingUsingFetchJoin(PageRequest.of(1, 2));
}

/*

Hibernate: 
    select
        order0_."id" as id1_0_0_,
        user1_."id" as id1_1_1_,
        order0_."is_deleted" as is_delet2_0_0_,
        order0_."user_id" as user_id3_0_0_,
        user1_."is_deleted" as is_delet2_1_1_,
        user1_."name" as name3_1_1_ 
    from
        "order" order0_ 
    inner join
        "user" user1_ 
            on order0_."user_id"=user1_."id" limit ? offset ?

*/
// 1:N FETCH JOIN WITH PAGING API

@Query(value = "SELECT u FROM User u JOIN FETCH u.orderList", countQuery = "SELECT COUNT(u) FROM User u")
Page<User> findAllWithPagingUsingFetchJoin(Pageable page);

@Test
public void fetchJoin_oneToMany() {
    userRepository.findAllWithPagingUsingFetchJoin(PageRequest.of(1, 2));
}

/*

Hibernate: 
    select
        user0_."id" as id1_1_0_,
        orderlist1_."id" as id1_0_1_,
        user0_."is_deleted" as is_delet2_1_0_,
        user0_."name" as name3_1_0_,
        orderlist1_."is_deleted" as is_delet2_0_1_,
        orderlist1_."user_id" as user_id3_0_1_,
        orderlist1_."user_id" as user_id3_0_0__,
        orderlist1_."id" as id1_0_0__ 
    from
        "user" user0_ 
    inner join
        "order" orderlist1_ 
            on user0_."id"=orderlist1_."user_id"

o.h.h.internal.ast.QueryTranslatorImpl   : HHH000104: firstResult/maxResults specified with collection fetch; applying in memory!

*/

 

3. 다중 Fetch Join

*ToMany 의 Fetch Join은 하나밖에 적용할 수 없다. 여러 개의 *ToMany 지닌 엔티티의 경우 하나만 선택 적용해야 한다.

org.hibernate.loader.MultipleBagFetchException: cannot simultaneously fetch multiple bags

public class User {
    // ...
    @OneToMany(mappedBy = "user")
    private List<Order> orderList;
    
    @OneToMany(mappedBy = "user")
    private List<Cart> cartList;
}
@Query(value = "SELECT u FROM User u JOIN FETCH u.orderList JOIN FETCH u.cartList")
List<User> findAllWithJoinFetch();
@Test
public void findAllWithJoinFetch_multipleFetchJoinWIthJPQL_shouldBeThrowMultipleBagFetchException() {
    userRepository.findAllWithJoinFetch();
}

/*

Caused by: java.lang.IllegalArgumentException: org.hibernate.loader.MultipleBagFetchException: cannot simultaneously fetch multiple bags

*/

BatchSize

@BatchSize 는 DB에서 데이터를 가져올 시(persist) 최대 개수를 정하여 가져올 수 있으며 Paging API를 적용할 수 있다. 이를 통해서 N+1을 완화할 수 있다. SQL의 IN 절을 활용하기에 해당 DB가 최대 몇 개의 value를 지원하는지를 파악하고 적용해야 한다. *ToOne의 경우 target entity에 @BatchSize 를 설정하여 적용할 수 있다. *ToMany의 경우 target collection에 정의할 수 있다. 또한 기본 BatchSize를 gradle이나 maven에 설정할 수 있다.

// *ToOne - entity

@BatchSize(size = 5)
@Entity
@Table(name = "order")
public class Order {
	// ...
}
// *ToMany - collection

@BatchSize(size = 5)
@OneToMany(mappedBy = "user")
private List<Order> orderList;
// application.yml

spring:
  jpa:
    properties:
      hibernate:
        default_batch_fetch_size: 5
@Test
public void findAll_withBatchSize() {
    List<User> users = userRepository.findAll();
    for (User user : users) {
        List<Order> orders = user.getOrderList();
        for (Order order : orders) {
            log.info("[{}]: {}", user.getName(), order.getId());
        }
    }
}

/*

Hibernate: 
    select
        orderlist0_."user_id" as user_id3_0_1_,
        orderlist0_."id" as id1_0_1_,
        orderlist0_."id" as id1_0_0_,
        orderlist0_."is_deleted" as is_delet2_0_0_,
        orderlist0_."user_id" as user_id3_0_0_ 
    from
        "order" orderlist0_ 
    where
        orderlist0_."user_id" in (
            ?, ?, ?, ?, ?
        )

*/

한계 및 문제점

1 + (N / BatchSize)

궁극적으로 N+1 쿼리보단 나은 쿼리 요청이 있겠지만 완전히 N+1 쿼리 문제를 해결하였다곤 볼 수 없다. 하지만 적절한 batchSize로 N+1 쿼리 문제를 회피하고, 전체적으로 쿼리 요청의 양을 줄일 수 있다.

Subselect

@Fetch 어노테이션에선 FetchMode을 지원한다. 이 중 FetchMode.SUBSELECT를 활용하여 N+1 쿼리 이슈를 해결할 수 있다. 연관관계의 엔티티를 가져올(persist) 시 SUBSELECT를 활용한다.

@Fetch(FetchMode.SUBSELECT)
@OneToMany(mappedBy = "user")
private List<Order> orderList;

@Test
public void findAll_withSubselect() {
    List<User> users = userRepository.findAll();
    for (User user : users) {
        List<Order> orders = user.getOrderList();
        for (Order order : orders) {
            log.info("[{}]: {}", user.getName(), order.getId());
        }
    }
}

/*

Hibernate: 
    select
        user0_."id" as id1_4_,
        user0_."is_deleted" as is_delet2_4_,
        user0_."name" as name3_4_ 
    from
        "user" user0_
Hibernate: 
    select
        orderlist0_."user_id" as user_id3_2_1_,
        orderlist0_."id" as id1_2_1_,
        orderlist0_."id" as id1_2_0_,
        orderlist0_."is_deleted" as is_delet2_2_0_,
        orderlist0_."user_id" as user_id3_2_0_ 
    from
        "order" orderlist0_ 
    where
        orderlist0_."user_id" in (
            select
                user0_."id" 
            from
                "user" user0_
        )
*/

한계 및 문제점

Subselect는 IN 절을 활용하여 가져오기 때문에 DB에서 최대 몇 개까지 지원하는지 알아야 하며, 이를 초과할 수 없다.

결론

N+1 쿼리 이슈는 JPA를 활용한다면 겪게 되는 문제이다. FetchType과 관계없이 EAGER, LAZY 모두 발생하여 FetchType으로 해결은 할 수 없다. 그리하여 Fetch Join이나 Batch size, Subselect를 활용하여 해결하곤 한다.

기본적으로 *ToOne의 경우 Fetch Join을 통해서 가져오며, *ToMany는 Batch size를 두어 가져온다. 양방향 매핑을 할 때 어떤 엔티티를 통해서 성능적으로 이점이 있을지를 먼저 테스트를 진행하여 적용한다. 또한 필요한 정보만을 DTO의 형식으로 가져오면 성능의 향상도 기대할 수 있다.

Leave a comment