JPA OneTomany 관계에서 N+1 문제 해결

김도훈 - DevOps Engineer·2022년 5월 20일
0

JPA

목록 보기
5/5
post-thumbnail

상황 🥊

  • 회원(Member)은 여러 주문(Orders)을 가질 수 있으며, 하나의 주문(Orders)은 여러 개의 주문상품(OrderItem)을 가질 수 있습니다. 주문상품은 해당 상품의 수량과 가격을 가지고 있는 엔티티이며, 상품은 단순히 상품 정보만을 가진 엔티티입니다.

여기서 회원(나)의 모든 주문 정보를 가져오는 Query를 작성하려면 회원의 Id를 기반으로 모든 정보를 가져올 수있어야 한다.

기존 작성 N+1 쿼리 🕹

 public MyPurchaseItemResponse getMyItem() {
        User user = userUtil.findCurrentUser();

        List<Order> orders = orderRepository.findByUser(user);

        List<MyPurchaseResponse> myPurchaseItems = getMyPurchaseResponses(orders);

        return new MyPurchaseItemResponse(myPurchaseItems);
    } 
  1. userUtil.findCurrentUser() : 해당 회원의 정보를 Json Web Token으로 인증하여 회원의 정보를 찾아온다.

  2. orderRepository.findByUser(user) : userorder의 관계는 1:N 양방향 관계이므로 order에서 회원을 참조하여 해당 회원이 주문한 주문 목록을 가져온다.

  3. geyMyPurChaseResponses : 위에서 가져온 주문 목록으로 해당 상품의 정보를 가져온다.

  private List<MyPurchaseResponse> getMyPurchaseResponses(List<Order> orders) {

        List<MyPurchaseResponse> myPurchaseItems = new ArrayList<>();

        for (Order order : orders) {
            for (OrderItem orderItem : order.getOrderItems()) {
                MyPurchaseResponse items = new MyPurchaseResponse(order.getId(), orderItem.getItem().getId(), orderItem.getItem().getName(),
                        orderItem.getTotalPrice(), orderItem.getCount(), orderItem.getPaymentMethod());
                myPurchaseItems.add(items);
            }
        }

        return myPurchaseItems;
    }
  • 위의 코드를 보면 order를 2중 for문으로 돌려서 Dto에 값을 넣어주게 된다. 해당 로직은 LAZY 로 동작한다.
{
    "myPurchaseResponses": [
        {
            "orderId": 3,
            "itemId": 1,
            "itemName": "계란",
            "totalPrice": 20000,
            "count": 2,
            "paymentMethod": "MONEY"
        },
        {
            "orderId": 3,
            "itemId": 2,
            "itemName": "식빵",
            "totalPrice": 30000,
            "count": 3,
            "paymentMethod": "MONEY"
        },
        {
            "orderId": 3,
            "itemId": 3,
            "itemName": "새우깡",
            "totalPrice": 4000,
            "count": 4,
            "paymentMethod": "MONEY"
        },
        {
            "orderId": 5,
            "itemId": 1,
            "itemName": "계란",
            "totalPrice": 30000,
            "count": 3,
            "paymentMethod": "MONEY"
        },
        {
            "orderId": 5,
            "itemId": 2,
            "itemName": "식빵",
            "totalPrice": 10000,
            "count": 1,
            "paymentMethod": "MONEY"
        },
        {
            "orderId": 6,
            "itemId": 1,
            "itemName": "계란",
            "totalPrice": 20000,
            "count": 2,
            "paymentMethod": "MONEY"
        },
        {
            "orderId": 6,
            "itemId": 2,
            "itemName": "식빵",
            "totalPrice": 30000,
            "count": 3,
            "paymentMethod": "MONEY"
        },
        {
            "orderId": 6,
            "itemId": 3,
            "itemName": "새우깡",
            "totalPrice": 4000,
            "count": 4,
            "paymentMethod": "MONEY"
        }
    ]
}
  • 다음과 같은 데이터가 존재한다고 가정하면 user[1], order[1], orderItem[3], item[3] 총 8번의 쿼리가 나가게 된다. 이렇게 되면 OrderItem 의 수가 증가하면 그만큼 쿼리가 더 나가게 된다. (N+1)

해결 방법 🐳

  1. Orderfetch join 으로 가져오는 방법
    @Query("select o from Order o join fetch o.user u join fetch o.orderItems oi join fetch oi.item i where u.id = :userId")
  • 이렇게 Fetch join으로 한번에 모든 정보를 가져올 수 있다. 하지만 OneToMany 관계 에서는 fetch join 으로 데이터를 가져올 경우 데이터가 Many의 수만큼 뻥튀기가 되어 distinct를 넣어주어야한다.

  • OneToMany 컬렉션 페치 조인을 사용하면 페이징이 불가능하다. DB에 있는 Data를 기준으로 페이징을 하기 때문에 1:다 관계에서는 페이징을 하면안된다.

  • 컬렉션 페치 조인은 1개만 사용해야 한다.

  1. new 명령어를 사용해서 객체로 바로 받아오기
@Query("select new com.backend.pointsystem.dto.response.MyPurchaseResponse(o.id, i.id, i.name, oi.totalPrice, oi.count, oi.paymentMethod) from Order o inner join o.user u inner join o.orderItems oi inner join oi.item i where u.id = :userId")
    List<MyPurchaseResponse> findMyOrders(@Param("userId") Long userId);

위와 같이 new 명령어를 통해 Dto로 필요한 정보만 가져올 수 있다. 테이블의 Join은 dto로 가져오는 것 자체가 fetch join 결과이므로 inner join을 사용하였다.

NEW 명령어를 사용하려면 아래 2가지를 주의해야 한다.

  • 패키지명을 포함한 클래스명을 입력해야 한다.
  • 순서와 타입이 일치하는 생성자가 필요하다.
public MyPurchaseItemResponse getMyItem() {
        User user = userUtil.findCurrentUser();

       return new MyPurchaseItemResponse(orderRepository.findMyOrders(user.getId()));
    }

위와 같이 그냥 쿼리 자체에서 모든 데이터를 가져와서 DTO로 반환해주기 때문에 전의 코드에 비해서 엄청나게 코드가 간소해졌다.

select
        user0_.user_id as user_id1_5_,
        user0_.created_at as created_2_5_,
        user0_.updated_at as updated_3_5_,
        user0_.asset as asset4_5_,
        user0_.name as name5_5_,
        user0_.password as password6_5_,
        user0_.point as point7_5_,
        user0_.username as username8_5_ 
    from
        user user0_ 
    where
        user0_.username=?
select
       order0_.order_id as col_0_0_,
       item3_.item_id as col_1_0_,
       item3_.name as col_2_0_,
       orderitems2_.total_price as col_3_0_,
       orderitems2_.count as col_4_0_,
       orderitems2_.payment_method as col_5_0_ 
   from
       orders order0_ 
   inner join
       user user1_ 
           on order0_.user_id=user1_.user_id 
   inner join
       order_item orderitems2_ 
           on order0_.order_id=orderitems2_.order_id 
   inner join
       item item3_ 
           on orderitems2_.item_id=item3_.item_id 
   where
       user1_.user_id=?
  • 위와 같이 같은 데이터를 가져오는데 user[1], order[1] 총 2번의 select 쿼리가 나가게 된다.

    결론 🥲

  • fetch join 이나 new 명령어로 N+1 문제를 해결하자.

profile
Email:ehgns5669@gmail.com

0개의 댓글