JPA를 사용하면 DB와 객체를 매핑하여 객체를 통해 DB를 조작할 수 있도록 하여 객체지향적인 코드를 작성하는 데에 도움을 주어 많은 개발자들이 사용하고 있다.
편한 점이 많지만 그만큼 내부 동작이 숨겨진 채로 사용되기 때문에, 사용자가 의도하지 않은 동작이 발생할 수 있는데, 그 중 가장 유명하고 흔히 발생하는 문제가 N+1 문제이다.
N+1 문제란?
연관 관계에서 발생하는 이슈로 연관 관계가 설정된 엔티티를 조회할 경우에 조회된 데이터 개수(n) 만큼 조회 쿼리가 추가로 발생하여 데이터를 읽어오게 되는 문제를 말한다.
최근 진행한 개인 프로젝트 중 주문 정보가 담긴 엔티티(OrderInfo)가 있었고, 이 엔티티는 다음과 같이 사용자(User)와 상품(Product)과 연관 관계를 갖고 있었다.
Hibernate:
select
o1_0.id,
o1_0.approved_at,
o1_0.created_at,
o1_0.last_transaction_key,
o1_0.method,
...
from
order_info o1_0
join
product p1_0
on p1_0.id=o1_0.product_id
join
user u1_0
on u1_0.id=o1_0.user_id
where
o1_0.id=?
연관 관계를 즉시 로딩으로 설정 하였기 때문에 의도대로 조인되어 하나의 쿼리가 나갔지만, 리스트 조회 시에는 조금 다르게 동작헀다.
전체 조회, 추가적으로 상품 1개 조회, 유저 1개 조회 쿼리 발생
id
order_id
product_id
user_id
4961
ORDER-1699466463831
1
1
4962
ORDER-1699467060817
1
1
4963
ORDER-1699504591545
1
1
4964
ORDER-1699504601386
1
1
4965
ORDER-1699504627472
1
1
Hibernate:
select
o1_0.id,
o1_0.approved_at,
o1_0.created_at,
o1_0.last_transaction_key,
...
from
order_info o1_0
order by
o1_0.id desc
limit
?,?
# order_info만 조회
Hibernate:
select
p1_0.id,
p1_0.created_at,
p1_0.description,
...
from
product p1_0
where
p1_0.id=?
# product 1개 조회
Hibernate:
select
u1_0.id,
u1_0.created_at,
u1_0.email,
...
from
user u1_0
where
u1_0.id=?
# user 1개 조회
쿼리가 총 3회로, 리스트 조회 된 OrderInfo의 product를 가져오기 위해 product 1개 조회, 유저 1개 조회 쿼리가 나갔다.
이는 흔히 말하는 N+1 문제로, 위 상황은 그나마 낫지만 만약에 여러 종류의 데이터가 엮인 리스트를 한 번에 조회하게 된다면 쿼리 횟수가 기하급수적으로 늘어나게 된다.
전체 조회, 상품 4개 조회, 유저 1개 조회 쿼리 발생
id
order_id
product_id
user_id
4961
ORDER-1699466463831
1
1
4962
ORDER-1699467060817
2
1
4963
ORDER-1699504591545
3
1
4964
ORDER-1699504601386
4
1
4965
ORDER-1699504627472
1
1
OrderInfo 테이블에 서로 다른 4개의 Product가 연관 관계로 설정 되어 있을 때의 쿼리이다.
Hibernate:
select
o1_0.id,
o1_0.approved_at,
o1_0.created_at,
o1_0.last_transaction_key,
...
from
order_info o1_0
order by
o1_0.id desc
limit
?,?
# order_info만 조회
Hibernate:
select
p1_0.id,
p1_0.created_at,
p1_0.description,
...
from
product p1_0
where
p1_0.id=?
# product 1개 조회 - 1
Hibernate:
select
u1_0.id,
u1_0.created_at,
u1_0.email,
...
from
user u1_0
where
u1_0.id=?
# user 1개 조회
Hibernate:
select
p1_0.id,
p1_0.created_at,
p1_0.description,
...
from
product p1_0
where
p1_0.id=?
# product 1개 조회 - 2
Hibernate:
select
p1_0.id,
p1_0.created_at,
p1_0.description,
...
from
product p1_0
where
p1_0.id=?
# product 1개 조회 - 3
Hibernate:
select
p1_0.id,
p1_0.created_at,
p1_0.description,
...
from
product p1_0
where
p1_0.id=?
# product 1개 조회 - 4
총 6번의 쿼리가 나가게 되고, 만약 Product 안에서도 또 다른 연관 관계가 설정 되어 있거나 더 많은 데이터를 조회하게 된다면 쿼리 횟수는 기하급수적으로 늘어나게 된다.
즉시 로딩에서의 N+1
위와 같이 단일 조회에서는 쿼리가 조인이 정상적으로 실행되어 1번 나가는 것을 확인했지만, 리스트 조회에서는 별도의 쿼리가 나가는 것을 확인할 수 있다.
이는 JPQL에 원인이 존재하는데, findAll()을 했을 때 JPQL은 다음과 같이 동작한다.
OrderInfo 조회를 위해 select o from OrderInfo o 실행
즉시로딩이 걸려있기 때문에 데이터를 전부 가져오기 위한 Product와 User에 대한 정보도 필요
때문에 조회 된 OrderInfo 데이터 안에서 필요한 Product와 User를 조회하는 쿼리를 실행
결과적으로 개발자는 하나의 쿼리를 실행했지만(1), 즉시 로딩으로 인해 각각의 데이터를 조회하기 위해 추가적인 쿼리가 실행(N)되어 N+1 문제가 발생하게 된다.
지연 로딩에서의 N+1
그럼 단순하게 생각하여 지연 로딩으로 설정하면 N+1 문제를 해결할 수 있다고 생각할 수 있지만 똑같이 N+1 문제가 발생한다.
게다가 단일 조회 시에도 추가 쿼리가 발생하기 때문에 지연로딩만 적용해두면 즉시 로딩보다 더 안 좋다고 볼 수 있다.
연관 관계만 LAZY로 바꿨을 땐 단일/리스트 조회 전부 최초엔 쿼리가 1번만 나가지만,
product나 user 데이터가 필요하게 되면 각각의 데이터를 조회하기 위해 쿼리가 추가적으로 나가게 된다.
@Service
class OrderService {
// ...
// @Transactional이 없으면 LazyInitializationException 발생(준영속 상태에서는 연관 관계를 조회할 수 없음)
@Transactional(readOnly = true)
public Page<OrderFindResponse> findOrderList(Pageable pageable) {
Page<OrderInfo> orderPage = orderInfoRepository.findAll(pageable);
// 여기 까진 하나의 쿼리만 실행(OrderInfo 만 조회, Product와 User는 조회 X)
orderPage.getContent().forEach(orderInfo -> {
// 여기서 Product와 User를 정보가 필요한 경우 각각의 데이터를 조회하기 위해 쿼리가 추가적으로 실행됨
Product product = orderInfo.getProduct();
User user = orderInfo.getUser();
// ...
});
// ...
}
// ...
}
해결 방법 - Fetch Join
Fetch Join은 JPQL에서 제공하는 기능으로, 연관 관계를 한 번에 조회하기 위해 사용한다.
가장 간단한 방법은 @Query를 사용하여 직접 JPQL을 작성하는 것이다.(혹은 @EntityGraph 사용)
class OrderInfoRepository extends JpaRepository<OrderInfo, Long> {
// ...
@Query("select o from OrderInfo o join fetch o.product join fetch o.user")
List<OrderInfo> findAllWithProductAndUser();
// Pageable을 사용하는 경우
@Query("select o from OrderInfo o join fetch o.product join fetch o.user")
Page<OrderInfo> findAllWithProductAndUser(Pageable pageable);
// @EntityGraph를 사용하여 Fetch Join을 적용한 경우
@EntityGraph(attributePaths = {"product", "user"}, type = EntityGraph.EntityGraphType.FETCH)
@Query("select o from OrderInfo o")
Page<OrderInfo> findAllWithProductAndUser(Pageable pageable);
// ...
}
기존 findAll 메서드를 사용하는 곳에서 findAllWithProductAndUser로 변경하면 원했던 대로 쿼리가 실행되는 것을 확인할 수 있다.
Hibernate:
select
o1_0.id,
o1_0.approved_at,
o1_0.created_at,
o1_0.last_transaction_key,
o1_0.method,
o1_0.order_id,
...
from
order_info o1_0
join
product p1_0
on p1_0.id=o1_0.product_id
join
user u1_0
on u1_0.id=o1_0.user_id
order by
o1_0.id desc
limit
?,?
성공적으로 하나의 쿼리로 모든 데이터를 조회하는 것을 확인할 수 있다.
*ToMany 한계
위 예시의 해결법은 *ToOne 관계에서만 적용할 수 있는 방법으로, *ToMany 관계에서는 적용할 수 없다.
*ToMany 관계에서는 OOM(Out Of Memory)가 발생할 수 있기 때문에 다른 방법을 고려해야 하며, 추후 해당 문제 발생 시 해결 후 업데이트 할 예정이다.