JPA N+1 문제

JPA를 사용하면 DB와 객체를 매핑하여 객체를 통해 DB를 조작할 수 있도록 하여 객체지향적인 코드를 작성하는 데에 도움을 주어 많은 개발자들이 사용하고 있다. 편한 점이 많지만 그만큼 내부 동작이 숨겨진 채로 사용되기 때문에, 사용자가 의도하지 않은 동작이 발생할 수 있는데, 그 중 가장 유명하고 흔히 발생하는 문제가 N+1 문제이다.

N+1 문제란?

연관 관계에서 발생하는 이슈로 연관 관계가 설정된 엔티티를 조회할 경우에 조회된 데이터 개수(n) 만큼 조회 쿼리가 추가로 발생하여 데이터를 읽어오게 되는 문제를 말한다. 최근 진행한 개인 프로젝트 중 주문 정보가 담긴 엔티티(OrderInfo)가 있었고, 이 엔티티는 다음과 같이 사용자(User)와 상품(Product)과 연관 관계를 갖고 있었다.


@Getter
@Entity
@Table(name = "order_info")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class OrderInfo extends BaseTime {

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

    @ManyToOne(fetch = FetchType.EAGER)
    @JoinColumn(name = "user_id", nullable = false)
    private User user;

    @ManyToOne(fetch = FetchType.EAGER)
    @JoinColumn(name = "product_id", nullable = false)
    private Product 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개 조회 쿼리 발생

idorder_idproduct_iduser_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개 조회 쿼리 발생

idorder_idproduct_iduser_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은 다음과 같이 동작한다.

  1. OrderInfo 조회를 위해 select o from OrderInfo o 실행

  2. 즉시로딩이 걸려있기 때문에 데이터를 전부 가져오기 위한 Product와 User에 대한 정보도 필요

  3. 때문에 조회 된 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)가 발생할 수 있기 때문에 다른 방법을 고려해야 하며, 추후 해당 문제 발생 시 해결 후 업데이트 할 예정이다.

Last updated