개요
인프런 강의를 듣던 중, N+1 의 문제를 알게되었다. 그 전부터 수 많은 블로그들이 해당 문제를 겪어보고, 해결한 내용을 다들 본인의 블로그에 작성했던 것을 살펴보던 찰나에 나도 내 블로그에 써보고 싶었다.
사실 N+1 의 문제는 내가 하는 실무에서도 나타났지만, 그 당시에는 그게 N+1 일거라 생각하지 못했다.
환경
- window10
- intellij, springboot 2.3.0
- 관련 소스코드
엔티티 스키마 정의
- 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();
}
}
추가할 것)
- 컬렉션 조회 시 페이징을 포함한 상태로 조회하기
- 그 밖에...