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