Issues : N+1 무한루프 해결

박채원io·2024년 6월 4일
post-thumbnail

프로젝트를 하다보면 한 번쯤 만나게 되는,,,!

▶️ 문제 상황

판매 승인과 구매확정을 할 때, 주문 객체를 찾게 되는데 그 때 사용되는 쿼리에서 무한루프가 돌았다.

원인 : 해당 주문 객체를 찾아야 한다는 생각에 orderId로 찾아서 조회하면 되지 않을까? 라는 생각으로 findById 로 조회했다.

  public Orders getByOrder(Long orderId) {
        return orderRepository.findById(orderId)
                .orElseThrow(() -> new OrderNotFoundException("해당 주문을 찾을 수 없습니다."));
    }

콘솔에 찍힌 쿼리를 보니 Orders 엔티티를 조회할 때 마다 연관된 seller 정보를 따로 조회하기 위한 쿼리가 실행되어서 Orders를 참조하고, 이 과정이 반복되어 N+1로 무한 루프가 돈 것이다.

다시 생각을 해보니 판매승인과 구매확정을 할 때 제품의 상태와 수량을 확인하고 다양한 유효성 검사가 이루어지고 있기 때문에 판매자와 제품으로 정확한 주문 객체를 찾아야 했다. 테이블에는 다양한 판매자의 주문 객체들이 생성되어 있기 때문이다.

▶️ 해결

Fetch Join을 사용하여 필요한 쿼리를 한 번에 불러와 간단하게 해결하였다.

    // OrderServiceImpl
    public Orders getByOrder(Long orderId) {
        return orderRepository.findByIdWithSellerAndProduct(orderId)
                .orElseThrow(() -> new OrderNotFoundException("해당 주문을 찾을 수 없습니다."));
    }

    // OrderRepository
    @Query("SELECT o FROM Orders o JOIN FETCH o.seller JOIN FETCH o.product WHERE o.id = :orderId")
    Optional<Orders> findByIdWithSellerAndProduct(@Param("orderId") Long orderId);

Fetch Join을 두 번 사용해서 복잡한 쿼리가 아닐까? 라는 생각을 하게 되어 Query DSL을 도입해야하나 고민이 되었다.

우선 현재 쿼리는Orders 엔티티와 연관된 sellerproduct를 함께 조회하는 쿼리로, 이 정도의 단순한 쿼리를 위해 QueryDSL을 사용하는 것은 오버헤드가 될 수 있다고 판단이 들었다. 만약 단순한 조회 목적이 아닌, 동적으로 사용하게 되면 도입하는 것이 맞다고 생각이 든다.

따라서, 현재의 쿼리가 단순한 조회를 목적으로 사용되기 때문에 JPA의 JPQL을 그대로 사용하고, 향후 복잡한 쿼리를 작성해야 하거나, 동적인 쿼리가 필요하다면(ex. WHERE 절의 조건이 여러 개 추가) QueryDSL로 전환하는 것을 고려해볼 계획이다.

▶️ JPA의 더티 체킹(Dirty Checking) 기능

Fetch JOIN을 하고, 쿼리를 보니까 SELECT 한 번, UPDATE 한 번 나가 익숙함에 잊고 있었던 더티 체킹에 대해 간단하게 정리해보려 한다.

판매 승인을 할 때, orders 객체에 상태를 업데이트 해줘야한다. 따라서 현재 쿼리가 SELECT 한 번, UPDATE 한 번 나간다.

Hibernate: 
    /* SELECT
        o 
    FROM
        Orders o 
    JOIN
        
    FETCH
        o.seller 
    JOIN
        
    FETCH
        o.product 
    WHERE
        o.id = :orderId */ select
            o1_0.id,
            o1_0.buyer_id,
            o1_0.create_date,
            o1_0.modify_date,
            o1_0.order_price,
            o1_0.order_status,
            p1_0.id,
            p1_0.create_date,
            p1_0.member_id,
            p1_0.modify_date,
            p1_0.name,
            p1_0.price,
            p1_0.product_status,
            p1_0.quantity,
            s1_0.id,
            s1_0.authority,
            s1_0.create_date,
            s1_0.email,
            s1_0.modify_date,
            s1_0.password,
            s1_0.username 
        from
            orders o1_0 
        join
            member s1_0 
                on s1_0.id=o1_0.seller_id 
        join
            product p1_0 
                on p1_0.id=o1_0.product_id 
        where
            o1_0.id=?
            
2024-06-05T23:15:59.548+09:00 TRACE 47655 --- [nio-9000-exec-2] org.hibernate.orm.jdbc.bind              : binding parameter (1:BIGINT) <- [3]
Hibernate: 
    /* update
        for com.chaewon.wanted.domain.orders.entity.Orders */update orders 
    set
        buyer_id=?,
        create_date=?,
        modify_date=?,
        order_price=?,
        order_status=?,
        product_id=?,
        seller_id=? 
    where
        id=?

예를 들어서, 만약 현재 판매 승인을 하는 메소드에 JPA의 더티 체킹 기능이 없었다면 제품과 주문의 상태 변경을 위해 개발자가 명시적으로 save 메소드를 호출해주어야 한다. 이 과정에서 엔티티를 다시 조회해서(select) 현재 상태 확인한 후, 변경 사항을 적용(update)해야 해서 추가 쿼리가 발생한다.

그러나 현재 JAP를 사용하고 있기 때문에 엔티티 상태가 변경되면 그 변경 사항이 트랜잭션이 종료될 때(즉, 커밋될 때) 자동으로 데이터베이스에 반영된다.

따라서, 덕분에 별도로 save 메소드를 호출하지 않아도, 엔티티의 상태 변경 사항이 데이터베이스에 반영된다. 이는 코드를 더욱 간결하게 만들고, 개발 과정에서 발생할 수 있는 실수를 줄여준다. 감사합니다-!


이상하게 꼭 한 번쯤 만나게 되는 무한의무한의 물음표,,,Fetch Join이 꼭 정답은 아니라고 생각한다. 우선 당장 문제는 해결되었기에 프리 온보딩 챌린지 일정을 소화하며 다른 방법도 알아보고 성능 개선을 꼭 해볼 것이다.

0개의 댓글