개요

인프런 강의를 듣던 중, N+1 의 문제를 알게되었다. 그 전부터 수 많은 블로그들이 해당 문제를 겪어보고, 해결한 내용을 다들 본인의 블로그에 작성했던 것을 살펴보던 찰나에 나도 내 블로그에 써보고 싶었다.

 

사실 N+1 의 문제는 내가 하는 실무에서도 나타났지만, 그 당시에는 그게 N+1 일거라 생각하지 못했다.

 

환경

 

엔티티 스키마 정의

  • Dish(접시) 와 OrderDish(주문접시) 는 다대일 관계이다.
    • Dish 는 여러 개의 OrderDish 를 가질 수 있다. 
    • OrderDish 는 하나의 Dish 만 가질 수 있다.
  • OrderDish(주문접시) 와 Order(주문) 는 일대다 관계이다.
    • OrderDish 는 하나의 Order 만 가질 수 있다.
    • Order 는 여러 개의 Order Dish 를 가질 수 있다.
    • 하나의 주문이 여러 개의 주문음식(접시) 를 주문할 수 있기 때문이다.
  • 원래는 주문과 주문상품은 다대다 관계이지만 다대다 관계는 실제로 존재하지 않는다. 따라서 다대다 관계를 일대다, 다대일 관계로 풀어내야 한다.

 

엔티티 실생활 예시

  • 인터넷에 떠도는 식당 영수증 예시이다.
  • 하나의 주문영수증 내에 주문한 음식의 메뉴가 하나씩, 여러개로 나열된다. Order 와 OrderDish 가 일대다 관계임을 생각해볼 수 있다. 그리고 주방에선 하나의 음식이 하나의 주문에만 나가는 것이 아니라 여러 주문의 음식으로 나가기 때문에 다대일 관계라고 생각할 수 있다. (수량은 생략한다.)

 

-

여기서 살펴볼 것은 Order 엔티티와 OrderDish 엔티티 두 개이다.

 

Order Entity

@Entity
@Table(name = "orders")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@ToString
public class Order {

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

    @Column(name = "order_time", nullable = false, columnDefinition = "DATETIME")
    private LocalDateTime orderTime;

    @OneToMany(mappedBy = "order", fetch = FetchType.LAZY, cascade = CascadeType.ALL)
    private List<OrderDish> orderDishes = new ArrayList<>();
}
  • fetchType 은 LAZY 로 되어있는 상태이다.

 

OrderDish Entity

@Entity
@Table(name = "order_dish")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class OrderDish {

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

    @Column(name = "name", nullable = false, columnDefinition = "VARCHAR(50)", length = 50)
    private String name;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "dish_id")
    private Dish dish;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "orders_id")
    private Order order;
}

 

N+1 은 어떤 문제인가?

  • 주체가 되는 엔티티(Order) 와 해당 엔티티 내 포함되는 요소 엔티티(OrderDish) 의 조회 시, SELECT 쿼리가 많이 나가는 문제 (컬렉션 엔티티 조회 시 해당 문제가 발생한다.)
  • 쿼리가 Order 엔티티 조회 시 1번, OrderDish 목록 조회시 주체가 되는 Order 엔티티의 갯수만큼 N번 나간다고 해서 N+1 문제라고 칭한다.
  • 결과를 확인하기 위해서 Order 엔티티에 데이터 3개의 데이터를 삽입했다. 그리고 각각의 Order 엔티티 안에 OrderDish 엔티티를 삽입했다.
    • 결국 주문은 3개가 있고 각각의 주문 내에는 여러 주문한 음식이 존재하는 상태가 있다는 가정하에 N+1 문제를 확인할 것이다.

결과확인

// ==> orders 의 전체 조회 api
http://localhost:8099/api/orders

  • 이렇게 하면 가장 처음에 Order 에 대한 SELECT 가 일어나고, 이후에 Order 엔티티 내의 OrderDish 에 대한 SELECT 가 일어난다.

  • order 는 3개를 만들었고, 각각의 order 안에는 orderDish 가 세팅되어 있다.
  • order 엔티티에 대한 select 쿼리가 1회 출력되었다.
  • orderDish 엔티티에 대한 select 쿼리가 3회 출력되었다.
  • 이것이 N+1 문제이다. 사실 나는 큰 신경은 안쓰고 있었는데 이러한 문제는 향후 Order 의 데이터 양이 1만건, 10만건 이후 100만건이나 된다고 가정하였을 때, 아래의 상황을 생각해볼 수 있다.
    • 1회 select + 1만건 select
    • 1회 select + 10만건 select
    • 1회 select + 100만건 select
    • 조회해야하는 N 의 숫자가 크면 클수록 서비스의 성능은 저하된다.

 

어떻게 해결할 것인가?

 

1. 페치조인 이용하기

  • Spring Data JPA 상에서 Repository 인터페이스에 @Query 애노테이션을 달아서 JPQL 로 직접적으로 쿼리를 작성하면 한 번의 쿼리로 조회할 수 있다.
  • distinct 절이 반드시 필요하다. 일대다 조인관계에서 Order 엔티티의 rows 수가 OrderDish 의 fk 로 자리잡고 있다. 이때 distinct 없이 조인하게 되면 중복이 허용된 상태로 데이터가 조회된다. 따라서 distinct 절은 중복을 제거하기 위함이다.

    h2 console 에서 ORDERS_ID 로 잡혀있는 컬럼 값에 rows 개수만큼 ORDER_DISH 를 들고오기 때문이다. 3 * 9 개의 Orders 엔티티 획득 및 내부에 OrderDIsh 가 9개가 존재한다.
/** repository **/
@Repository
public interface OrderRepository extends JpaRepository<Order, Long> {

    @Query(value = "select distinct o from Order o join fetch o.orderDishes")
    List<Order> findAllFirstOpt();
}

/** query **/
Hibernate: 
    select
        distinct order0_.id as id1_2_0_,
        orderdishe1_.id as id1_1_1_,
        order0_.order_time as order_ti2_2_0_,
        orderdishe1_.dish_id as dish_id3_1_1_,
        orderdishe1_.name as name2_1_1_,
        orderdishe1_.orders_id as orders_i4_1_1_,
        orderdishe1_.orders_id as orders_i4_1_0__,
        orderdishe1_.id as id1_1_0__ 
    from
        orders order0_ 
    inner join
        order_dish orderdishe1_ 
            on order0_.id=orderdishe1_.orders_id

페치조인에 대한 고려사항

  • 컬렉션 페치조인시 페이징이 불가하다.
  • 컬력션 페치조인은 1개만 사용할 수 있다.
    • 조졸두님 블로그에는 다수개의 컬렉션에 대해서 페치조인을 하고자 하는 사람들이 겪는 문제점을 작성해주었다. 링크

 

querydsl 을 이용하여 페치조인 수행하기

  • 기본적인 querydsl gradle 생성문법은 생략한다.
  • 연관관계가 맺어져있는 두 엔티티에 대해서 join(), fetchJoin(), distinct() 를 수행한다.
  • query 는 jpql 로 쿼리날라가는 것과 동일하게 날라간다.
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class OrderRepositoryCustomImpl implements OrderRepositoryCustom {

    private final JPAQueryFactory queryFactory;

    public List<Order> findAllByFetchJoin() {
        return queryFactory
                .selectFrom(order)
                .join(order.orderDishes, orderDish)
                .fetchJoin()
                .distinct()
                .fetch();
    }
}

 

 

 

추가할 것)

  • 컬렉션 조회 시 페이징을 포함한 상태로 조회하기
  • 그 밖에...

참고자료

Posted by doubler
,