이번 글은 JPA를 공부 후 프로젝트에 적용하며 JPA 쿼리가 비효율적으로 나가는 상황이 많이 발생 해
블로그 , 책을 참고하여 학습 후 개인 프로젝트에서 테스트 코드를 작성하며 정리합니다.
☀️ N + 1 이란?
N + 1 문제는 연관관계가 설정된 엔티티 사이에서 한 엔티티를 조회 시 ,
조회된 엔티티의 개수(N개)만큼 연관된 엔티티를 조회하기 위해 추가적인 쿼리가 발생하는 문제를 의미합니다.
엔티티 조회 쿼리(1번) + 조회된 엔티티의 개수(N개)만큼 연관된 엔티티를 조회하기 위한 추가 쿼리(N 번) = N + 1 또는 1 + N
☀️ 테스트 상황 설정
N + 1 문제가 발생하는 상황들을 알아보기 위해 저의 개인 프로젝트 코드를 예시로 설명 하겠습니다.
하나의 카테고리에는 여러개의 상품이 달릴 수 있습니다(카데고리 기준 일대다 관계).
@Entity // 생성자와 Getter 롬복 어노테이션은 생략
public class Category {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "category_id")
private long id;
@Column(unique = true , nullable = false , length = 50)
private String name;
@JsonIgnore
@Builder.Default
@OneToMany(mappedBy = "category", cascade = CascadeType.ALL)
private List<Item> items = new ArrayList<Item>();
//N + 1 테스트용 코드
public Item createItem(String name) {
Item item = Item.builder()
.name(name)
.build();
item.setCategory(this);
this.items.add(item);
return item;
}
}
@Entity
public class Item {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "item_id")
private long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "category_id")
private Category category;
@Column(unique = true)
private String name;
@OneToMany(mappedBy = "item")
@Builder.Default
private List<OrderItem> orderItems = new ArrayList<>();
private long price;
private long stockQuantity;
private String content;
위의 두 엔티티를 이용해 N + 1 문제를 발생시키는 테스트 코드를 작성해보겠습니다.
@SpringBootTest
@Transactional
public class CategoryRepositoryTest {
@Autowired
private EntityManager em;
@Autowired
private CategoryRepository categoryRepository;
@Test
@DisplayName("N + 1 발생 테스트")
void test() {
saveSampleData(); // 10개 카테고리와 각 카테고리별 3개의 item
em.flush();
em.clear();
System.out.println("-------------- 영속성 컨텍스트 비우기------------------\n\n ");
System.out.println("-------------- Category 전체 조회------------------ ");
List<Category> categorys = categoryRepository.findAll();
System.out.println("-------------- Category 전체 조회 완료. [쿼리 1개 발생]-----------------\n\n ");
System.out.println("------------ Category 이름 조회 요청 ------------");
categorys.forEach(ca -> System.out.println("CATEGORY 이름: [%s]".formatted(ca.getName())));
System.out.println("------------ Category 이름 조회 완료. [추가적인 쿼리 발생하지 않음]------------\n\n");
System.out.println("------------ CATEGORY에 달린 Item 이름 조회 요청 [조회된 CATEGORY의 개수(N=10) 만큼 추가적인 쿼리 발생]------------");
categorys.forEach(category -> {
category.getItems().forEach(item -> {
System.out.println("Category 이름 : [%s] , ITEM 내용 : [%s]".formatted(category.getName(),item.getName()));
});
});
System.out.println("------------ CATEGORY에 달린 ITEM 내용 조회 완료 ------------\n\n");
}
private void saveSampleData() {
final String categoryNameFormat = "[%d] category-name";
final String itemNameFormat = "[%d] item-name";
IntStream.rangeClosed(1,10).forEach(i -> {
Category category = Category.builder()
.name(format(categoryNameFormat , i))
.build();
IntStream.rangeClosed(1,3).forEach(j -> {
category.createItem(format(itemNameFormat,j) + format(categoryNameFormat , i));
});
categoryRepository.save(category);
});
}
}
테스트 결과 아래 이미지와 같이 Category에 연관관계인 Item을 호출할 때 Category의 개수인
10개만큼의 추가적인 쿼리가 발생하는 것을 알 수 있습니다.(로그가 너무 길어져 하단 쿼리 2개만 보여드립니다.)
이러한 문제를 바로 N + 1 문제라고 부릅니다.
------------ CATEGORY에 달린 Item 이름 조회 요청 [조회된 CATEGORY의 개수(N=10) 만큼 추가적인 쿼리 발생]------------
Hibernate:
select
i1_0.category_id,
i1_0.item_id,
i1_0.content,
i1_0.name,
i1_0.price,
i1_0.stock_quantity
from
item i1_0
where
i1_0.category_id=?
Hibernate:
select
i1_0.category_id,
i1_0.item_id,
i1_0.content,
i1_0.name,
i1_0.price,
i1_0.stock_quantity
from
item i1_0
where
i1_0.category_id=?
------------ CATEGORY에 달린 ITEM 내용 조회 완료 ------------
이제부터는 N + 1 문제가 발생할 수 있는 여러 상황과 상황별 해결방법을 정리하도록 하겠습니다.
이때 모든 경우에 지연 로딩을 사용하는 걸 가정합니다.
☀️ @OneToOne 관계에서 발생하는 N + 1
주로 fetch join , @EntityGraph로 해결 합니다.
@OneToOne 연관관계에서는 주의해야 할 점이 있는데 , 연관관계의 주인이 아닌 엔티티를 조회하는 경우 , 지연 로딩으로 설정되어 있더라도 연관된 엔티티를 즉시 로딩으로 조회합니다.
OneToOne 연관관계를 예를 들기 위해 제 프로젝트에서의 Order 와 Delivery 로 설명하겠습니다.
@Entity
public class Order {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "order_id")
private long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id")
private User user;
@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "delivery_id")
private Delivery delivery;
@Builder.Default
@OneToMany(mappedBy = "order",cascade = CascadeType.ALL)
private List<OrderItem> orderItems = new ArrayList<OrderItem>();
@Enumerated(EnumType.STRING)
private OrderStatus status;
private long totalPrice;
private LocalDateTime createDate;
private LocalDateTime updateDate;
public void register(Delivery delivery) {
this.delivery = delivery;
delivery.setOrder(this);
}
//프로젝트 내 비즈니스 로직은 생략
}
@Entity
public class Delivery {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "delivery_id")
private long id;
@Enumerated(EnumType.STRING)
private DeliveryStatus status;
@Embedded
private Address address;
@OneToOne(mappedBy = "delivery")
private Order order;
}
⚒️ N + 1 발생하는 상황
@SpringBootTest
@Transactional
public class OrderRepositoryTest {
@Autowired
EntityManager em;
@Autowired
OrderRepository orderRepository;
@Autowired
DeliveryRepository deliveryRepository;
@BeforeEach
public void init() {
IntStream.rangeClosed(1 , 3).forEach(i -> {
Order order = Order.builder()
.totalPrice(i)
.build();
Delivery delivery = Delivery.builder().DeliveryTest(i).build();
order.register(delivery);
orderRepository.save(order);
deliveryRepository.save(delivery); // order만 등록하면 같이 등록되는 지 테스트해보자 -> 맞다! cascade.All 안했기 때문에 연관관계 엔티티 저장 자동으로 안됨
});
em.flush();;
em.clear();
}
@Test
@DisplayName("OneToOne 연관관계 주인으로 조회")
void test1() {
System.out.println("============ ORDER 조회 ==========");
List<Order> orders = orderRepository.findAll();
System.out.println("============ ORDER 조회 완료 ==========\n\n");
orders.forEach(order -> {
System.out.println("[%d]번 ORDER 사용하는 Delivery 번호 : [%d] \n".formatted(order.getTotalPrice(),order.getDelivery().getDeliveryTest()));
});
}
@Test
@DisplayName("OneToOne 연관관계 주인이 아닌 엔티티로 조회 - 지연 로딩으로 설정하더라도 즉시 로딩된다.")
void test2() {
System.out.println("============ DELIVERY 조회 ==========");
List<Delivery> deliveries = deliveryRepository.findAll();
System.out.println("============ DELIVERY 조회 완료 ==========\n\n");
deliveries.forEach(del -> {
System.out.println("[%d] 번 Delivery 사용하는 ORDER 번호 : [%d] \n".formatted(del.getDeliveryTest(),del.getOrder().getTotalPrice()));
});
}
}
test1 , test2 두 테스트 다 결과 쿼리 개수는 findAll 조회 쿼리(1번) + 연관관계 호출 시 쿼리(findAll 조회 결과 갯수=3번) 으로 총 4번의 쿼리가 발생하며 두 테스트의 차이점은 쿼리 발생 시기의 차이가 있다.
test1은 지연로딩이므로 연관관계 호출 시 쿼리 발생 , test2는 연관관계의 주인이 아니면 1:1 관계에서는
지연로딩 설정이여도 즉시 로딩 되므로 첫 조회 시 쿼리 발생.
해결1) fetch join 사용
@Query를 통해 fetch join을 사용하여 N + 1 문제 해결
public interface OrderRepository extends JpaRepository<Order, Long> {
@Override
@Query("select o from Order o join fetch o.delivery")
List<Order> findAll();
}
public interface DeliveryRepository extends JpaRepositor<Delivery, Long> {
@Override
@Query("select d from Delivery d join fetch d.order")
List<Delivery> findAll();
}
위와 같이 fetch join을 사용하면 Order를 조회한다고 가정하면
이전에는 Order 조회쿼리 1번 + Delivery 조회 쿼리 N 번 으로 N + 1 이였다면
fetch join 을 통해 Order 조회(Delivery 조인) 쿼리 1번으로 해결 된 것을 알 수 있습니다.
해결2) EntityGraph 사용
@EntityGraph는 fetch join을 쿼리 없이 편하게 사용하도록 도와주는 기능이다.
public interface OrderRepository extends JpaRepository<Order, Long> {
//@Query("select o from Order o join fetch o.delivery")
@Override
@EntityGraph(attributePaths = {"delivery"})
List<Locker> findAll();
}
public interface DeliveryRepository extends JpaRepositor<Delivery, Long> {
//@Query("select d from Delivery d join fetch d.order")
@Override
@EntityGraph(attributePaths = {"order"})
List<Delivery> findAll();
}
결과 쿼리는 fetch join 쿼리와 동일합니다.
☀️ @ManyToOne 관계로 연관된 엔티티가 조회되는 경우
주로 fetch join , @EntityGraph로 해결 합니다.
쿼리가 한번 더 발생하지만 @BatchSize로도 해결 가능합니다.
@ManyToOne 연관관계를 예를 들기 위해 제 프로젝트에서의 Category 와 Item 객체로 설명하겠습니다.
@Entity // 생성자와 Getter 롬복 어노테이션은 생략
public class Category {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "category_id")
private long id;
@Column(unique = true , nullable = false , length = 50)
private String name;
@JsonIgnore
@Builder.Default
@OneToMany(mappedBy = "category", cascade = CascadeType.ALL)
private List<Item> items = new ArrayList<Item>();
//N + 1 테스트용 코드
public Item createItem(String name) {
Item item = Item.builder()
.name(name)
.build();
item.setCategory(this);
this.items.add(item);
return item;
}
}
@Entity
public class Item {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "item_id")
private long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "category_id")
private Category category;
@Column(unique = true)
private String name;
@OneToMany(mappedBy = "item")
@Builder.Default
private List<OrderItem> orderItems = new ArrayList<>();
private long price;
private long stockQuantity;
private String content;
⚒️ N + 1 발생하는 상황
@SpringBootTest
@Transactional
public class ManyToOneTest {
@Autowired
EntityManager em;
@Autowired
private CategoryRepository categoryRepository;
@Autowired
private ItemRepository itemRepository;
@Test
@DisplayName("ManyToOne 관계로 조회 시 N + 1 테스트")
void test() {
saveSampleData(); // category 3개와 , 각각의 category마다 item 2개씩 저장
em.flush();
em.clear();
System.out.println("------------ 영속성 컨텍스트 비우기 -----------\n\n");
System.out.println("------------ ITEM 전체 조회 요청 [1번]------------");
List<Item> items = itemRepository.findAll();
System.out.println("------------ ITEM 전체 조회 완료 ------------\n\n");
System.out.println("------------ ITEM와 연관된 CATEGORY 조회 [ N + 1 문제 발생 ] ------------");
items.forEach(item -> {
System.out.println("CATEGORY 이름 : [%s], ITEM 이름 : [%s]".formatted(item.getCategory().getName(),item.getName()));
});
System.out.println("------------ ITEM 연관된 CATEGORY 조회 완료 ------------\n\n");
}
private void saveSampleData() {
final String categoryNameFormat = "[%d] category-name";
final String itemNameFormat = "[%d] item-name";
IntStream.rangeClosed(1 , 3).forEach(i -> {
Category category = Category.builder()
.name(categoryNameFormat.formatted(i))
.build();
IntStream.rangeClosed(1 , 2).forEach(j -> {
category.createItem(format(categoryNameFormat,i) + format(itemNameFormat,j));
});
//category와 item은 연관관계에 cascade.all 이므로 저장 시 연관관계 엔티티도 같이 저장됨
categoryRepository.save(category);
});
}
}
------------ ITEM 전체 조회 요청 [1번]------------
Hibernate:
/* <criteria> */ select
i1_0.item_id,
i1_0.category_id,
i1_0.content,
i1_0.name,
i1_0.price,
i1_0.stock_quantity
from
item i1_0
------------ ITEM 전체 조회 완료 ------------
------------ ITEM와 연관된 CATEGORY 조회 [ N + 1 문제 발생 ] ------------
Hibernate:
select
c1_0.category_id,
c1_0.name
from
category c1_0
where
c1_0.category_id=?
CATEGORY 이름 : [[1] category-name], ITEM 이름 : [[1] category-name[1] item-name]
CATEGORY 이름 : [[1] category-name], ITEM 이름 : [[1] category-name[2] item-name]
Hibernate:
select
c1_0.category_id,
c1_0.name
from
category c1_0
where
c1_0.category_id=?
CATEGORY 이름 : [[2] category-name], ITEM 이름 : [[2] category-name[1] item-name]
CATEGORY 이름 : [[2] category-name], ITEM 이름 : [[2] category-name[2] item-name]
Hibernate:
select
c1_0.category_id,
c1_0.name
from
category c1_0
where
c1_0.category_id=?
CATEGORY 이름 : [[3] category-name], ITEM 이름 : [[3] category-name[1] item-name]
CATEGORY 이름 : [[3] category-name], ITEM 이름 : [[3] category-name[2] item-name]
------------ ITEM 연관된 CATEGORY 조회 완료 ------------
위와 같이 Many(ITEM)ToOne(CATEGORY) 관계에서 조회 시 ITEM과 연관되어 있는 CATEGORY 수만큼 쿼리가 추가 발생합니다.
여기서 CATEGORY가 동일할 때는 쿼리 생략 됨
이제 해결방법을 정리해보겠습니다.
해결1) fetch join 사용
public interface ItemRepository extends JpaRepository<Item, Long> {
@Override
@Query("select i from Item i join fetch i.category")
List<Item> findAll();
}
------------ ITEM 전체 조회 요청 [1번]------------
Hibernate:
/* select
i
from
Item i
join
fetch
i.category */ select
i1_0.item_id,
c1_0.category_id,
c1_0.name,
i1_0.content,
i1_0.name,
i1_0.price,
i1_0.stock_quantity
from
item i1_0
join
category c1_0
on c1_0.category_id=i1_0.category_id
------------ ITEM 전체 조회 완료 ------------
------------ ITEM와 연관된 CATEGORY 조회 [ N + 1 문제 발생 ] ------------
CATEGORY 이름 : [[1] category-name], ITEM 이름 : [[1] category-name[1] item-name]
CATEGORY 이름 : [[1] category-name], ITEM 이름 : [[1] category-name[2] item-name]
CATEGORY 이름 : [[2] category-name], ITEM 이름 : [[2] category-name[1] item-name]
CATEGORY 이름 : [[2] category-name], ITEM 이름 : [[2] category-name[2] item-name]
CATEGORY 이름 : [[3] category-name], ITEM 이름 : [[3] category-name[1] item-name]
CATEGORY 이름 : [[3] category-name], ITEM 이름 : [[3] category-name[2] item-name]
------------ ITEM 연관된 CATEGORY 조회 완료 ------------
해결2) @EntityGraph 사용
public interface ItemRepository extends JpaRepository<Item, Long> {
//@Query("select i from Item i join fetch i.category")
@Override
@EntityGraph(attributePaths = {"category"})
List<Item> findAll();
}
발생 쿼리는 fetch join 쿼리가 동일합니다.
해결3) @BatchSize
@BatchSize 는 첫번쨰 조회 쿼리 1번으로 해결되지 않고 , 2번으로 나누어 해결합니다.
@Entity
@BatchSize(size = 100)//추가 조회가 필요한 연관관계 엔티티에 설정
public class Category {
//생략
}
위와 같이 설정하거나, 혹은 application.yml 파일에서 다음 속성을 설정하여 적용할 수 있습니다.
(하지만 저는 글로벌 설정보단 그때그떄 필요한 엔티티에만 적용하는 걸 추구하기 때문에 위의 설정을 주로 사용할 생각입니다.)
spring.jpa.properties.hibernate.default_batch_fetch_size
------------ ITEM 전체 조회 요청 [1번]------------
Hibernate:
/* <criteria> */ select
i1_0.item_id,
i1_0.category_id,
i1_0.content,
i1_0.name,
i1_0.price,
i1_0.stock_quantity
from
item i1_0
------------ ITEM 전체 조회 완료 ------------
------------ ITEM와 연관된 CATEGORY 조회 [ N + 1 문제 발생 ] ------------
Hibernate:
select
c1_0.category_id,
c1_0.name
from
category c1_0
where
c1_0.category_id in (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
CATEGORY 이름 : [[1] category-name], ITEM 이름 : [[1] category-name[1] item-name]
CATEGORY 이름 : [[1] category-name], ITEM 이름 : [[1] category-name[2] item-name]
CATEGORY 이름 : [[2] category-name], ITEM 이름 : [[2] category-name[1] item-name]
CATEGORY 이름 : [[2] category-name], ITEM 이름 : [[2] category-name[2] item-name]
CATEGORY 이름 : [[3] category-name], ITEM 이름 : [[3] category-name[1] item-name]
CATEGORY 이름 : [[3] category-name], ITEM 이름 : [[3] category-name[2] item-name]
------------ ITEM 연관된 CATEGORY 조회 완료 ------------
위와 같이 category의 id를 IN 절에 넣어 한 번에 가져오는 쿼리를 추가합니다.
이때 IN 절에 한번에 들어가는 크기를 BatchSize의 size 옵션으로 설정 가능합니다.
☀️ @OneToMany 관계로 연관된 엔티티가 조회되는 경우
fetch join , @EntityGraph로 해결 할 수 있습니다.
(그러나 페이징은 진행할 수 없으 , 둘 이상의 컬렉션을 fetch join 하는 데이터가 부정합하게 조회되기 때문에 이를 사용하지 않는 것이 좋습니다.)
위의 @ManyToOne 예시로 든 Item 과 Category 를 똑같이 예시로 하겠습니다. 물론 이번 경우는 Category를 조회하는 경우입니다.
@SpringBootTest
@Transactional
public class OneToManyTest {
@Autowired
EntityManager em;
@Autowired
private CategoryRepository categoryRepository;
@Autowired
private ItemRepository itemRepository;
@Test
@DisplayName("OneToMany 관계로 조회 시 N + 1 테스트")
void test() {
saveSampleData(); // category 3개와 , 각각의 category마다 item 2개씩 저장
em.flush();
em.clear();
System.out.println("------------ 영속성 컨텍스트 비우기 -----------\n\n");
System.out.println("------------ CATEGORY 전체 조회 요청 [1번]------------");
List<Category> categories = categoryRepository.findAll();
System.out.println("------------ CATEGORY 전체 조회 완료 ------------\n\n");
System.out.println("------------ CATEGORY와 연관된 ITEM 조회 [ N + 1 문제 발생 ] ------------");
categories.forEach(category -> {
category.getItems().forEach(item -> {
System.out.println("CATEGORY 이름 : [%s], ITEM 이름 : [%s]".formatted(category.getName(),item.getName()));
});
});
System.out.println("------------ CATEGORY 연관된 ITEM 조회 완료 ------------\n\n");
}
private void saveSampleData() {
final String categoryNameFormat = "[%d] category-name";
final String itemNameFormat = "[%d] item-name";
IntStream.rangeClosed(1 , 3).forEach(i -> {
Category category = Category.builder()
.name(categoryNameFormat.formatted(i))
.build();
IntStream.rangeClosed(1 , 2).forEach(j -> {
category.createItem(format(categoryNameFormat,i) + format(itemNameFormat,j));
});
//category와 item은 연관관계에 cascade.all 이므로 저장 시 연관관계 엔티티도 같이 저장됨
categoryRepository.save(category);
});
}
}
------------ 영속성 컨텍스트 비우기 -----------
------------ CATEGORY 전체 조회 요청 [1번]------------
Hibernate:
/* <criteria> */ select
c1_0.category_id,
c1_0.name
from
category c1_0
------------ CATEGORY 전체 조회 완료 ------------
------------ CATEGORY와 연관된 ITEM 조회 [ N + 1 문제 발생 ] ------------
Hibernate:
select
i1_0.category_id,
i1_0.item_id,
i1_0.content,
i1_0.name,
i1_0.price,
i1_0.stock_quantity
from
item i1_0
where
i1_0.category_id=?
CATEGORY 이름 : [[1] category-name], ITEM 이름 : [[1] category-name[1] item-name]
CATEGORY 이름 : [[1] category-name], ITEM 이름 : [[1] category-name[2] item-name]
Hibernate:
select
i1_0.category_id,
i1_0.item_id,
i1_0.content,
i1_0.name,
i1_0.price,
i1_0.stock_quantity
from
item i1_0
where
i1_0.category_id=?
CATEGORY 이름 : [[2] category-name], ITEM 이름 : [[2] category-name[1] item-name]
CATEGORY 이름 : [[2] category-name], ITEM 이름 : [[2] category-name[2] item-name]
Hibernate:
select
i1_0.category_id,
i1_0.item_id,
i1_0.content,
i1_0.name,
i1_0.price,
i1_0.stock_quantity
from
item i1_0
where
i1_0.category_id=?
CATEGORY 이름 : [[3] category-name], ITEM 이름 : [[3] category-name[1] item-name]
CATEGORY 이름 : [[3] category-name], ITEM 이름 : [[3] category-name[2] item-name]
------------ CATEGORY 연관된 ITEM 조회 완료 ------------
해결1) fetch. join 사용 - 권장 하지 않음
public interface CategoryRepository extends JpaRepository<Category, Long> {
@Override
@Query("select c from Category c join fetch c.items")
List<Category> findAll();
}
------------ 영속성 컨텍스트 비우기 -----------
------------ CATEGORY 전체 조회 요청 [1번]------------
Hibernate:
/* select
c
from
Category c
join
fetch
c.items */ select
c1_0.category_id,
i1_0.category_id,
i1_0.item_id,
i1_0.content,
i1_0.name,
i1_0.price,
i1_0.stock_quantity,
c1_0.name
from
category c1_0
join
item i1_0
on c1_0.category_id=i1_0.category_id
------------ CATEGORY 전체 조회 완료 ------------
------------ CATEGORY와 연관된 ITEM 조회 [ N + 1 문제 발생 ] ------------
CATEGORY 이름 : [[1] category-name], ITEM 이름 : [[1] category-name[1] item-name]
CATEGORY 이름 : [[1] category-name], ITEM 이름 : [[1] category-name[2] item-name]
CATEGORY 이름 : [[2] category-name], ITEM 이름 : [[2] category-name[1] item-name]
CATEGORY 이름 : [[2] category-name], ITEM 이름 : [[2] category-name[2] item-name]
CATEGORY 이름 : [[3] category-name], ITEM 이름 : [[3] category-name[1] item-name]
CATEGORY 이름 : [[3] category-name], ITEM 이름 : [[3] category-name[2] item-name]
------------ CATEGORY 연관된 ITEM 조회 완료 ------------
참고로 이 경우 일대다 조인을 했기 때문에 결과가 늘어나서 중복된 결과가 나타날 수 있었지만
하이버네이트 6 이후부터는 자동으로 distinct를 해주기 때문에 문제가 발생하지 않습니다.
해결2 ) @EntityGraph 사용 - 권장하지 않음
public interface CategoryRepository extends JpaRepository<Category, Long> {
@Override
//@Query("select c from Category c join fetch c.items")
@EntityGraph(attributePaths = {"items"})
List<Category> findAll();
}
결과 쿼리는 fetch join 쿼리와 동일합니다.
해결3 ) @BatchSize - 권장
spring.jpa.properties.hibernate.default_batch_fetch_size = 100
또는
@Entity
public class Category {
@BatchSize(size = 100)
@OneToMany(mappedBy = "category", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Item> items = new ArrayList<>();
}
발생 쿼리는 첫 전체 조회 1번 + batchSize 적용된 연관관계 IN절 쿼리 1번 = 총 2번 발생합니다.
해결4) @Fetch(FetchMode.SUBSELECT)
@Entity
public class Category {
//@BatchSize(size = 100)
@Fetch(FetctMode.SUBSELECT)
@OneToMany(mappedBy = "category", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Item> items = new ArrayList<>();
}
------------ CATEGORY 전체 조회 요청 [1번]------------
Hibernate:
/* <criteria> */ select
c1_0.category_id,
c1_0.name
from
category c1_0
------------ CATEGORY 전체 조회 완료 ------------
------------ CATEGORY와 연관된 ITEM 조회 [ N + 1 문제 발생 ] ------------
Hibernate:
select
i1_0.category_id,
i1_0.item_id,
i1_0.content,
i1_0.name,
i1_0.price,
i1_0.stock_quantity
from
item i1_0
where
i1_0.category_id in (select
c1_0.category_id
from
category c1_0)
CATEGORY 이름 : [[1] category-name], ITEM 이름 : [[1] category-name[1] item-name]
CATEGORY 이름 : [[1] category-name], ITEM 이름 : [[1] category-name[2] item-name]
CATEGORY 이름 : [[2] category-name], ITEM 이름 : [[2] category-name[1] item-name]
CATEGORY 이름 : [[2] category-name], ITEM 이름 : [[2] category-name[2] item-name]
CATEGORY 이름 : [[3] category-name], ITEM 이름 : [[3] category-name[1] item-name]
CATEGORY 이름 : [[3] category-name], ITEM 이름 : [[3] category-name[2] item-name]
------------ CATEGORY 연관된 ITEM 조회 완료 ------------
☀️ N + 1 해결방법 정리
@XToOne의 경우 : fetch join을 통해 해결 (또는 @EntityGraph)
@XToMany의 경우 : BatchSize를 사용하여 해결(또는 @Fectch(FectchMode.SUBSELECT))
저는 현재까지는 프로젝트에서 JPA를 사용할 때 연관관계를
최대한 @XToOne 관계로만 진행을 하기 때문에 주로 fetch join으로 해결하고 있습니다.
N + 1 문제에 관해 정리하고 학습해보니 앞으로 개인 프로젝트 뿐만 아니라
JPA 관련 프로젝트들을 많이 경험해봤으면 좋겠고 개념 학습을 추후에 정리가 필요한 주제는
정리해봐야겠다.
'Spring > JPA' 카테고리의 다른 글
| [JPA] 프록시와 연관관계에 대해(즉시로딩과 지연로딩) (0) | 2023.03.08 |
|---|---|
| [JPA] 상속관계 매핑 (0) | 2023.03.05 |
| [JPA] 연관관계 매핑의 종류 (0) | 2023.03.05 |
| [JPA] EntityMapping / 연관관계 매핑2 (0) | 2023.03.04 |
| [JPA] EntityMapping / 연관관계 매핑 1 (0) | 2023.03.04 |