N+1은 JPA 사용시 성능 최적화를 위해 반드시 해결해야 하는 문제이다.
N+1 문제를 예시를 통해 알아보면
Member 과 Order가 1:N 으로 매핑되어 있고 Member를 다건 조회했을 때,
Member의 개수 만큼 연관된 Order를 조회하는 N 개의 쿼리가 추가적으로 발생하는 것이다.
@Entity
@Table(name = "member")
public class Member {
@Id
GeneratedValue
private Long id;
@OneToMany(mappedBy = "member", fetch = FetchType.EAGER)
private List<Order> orders = new ArrayList<Order>();
}
@Entity
@Table(name = "order")
public class Order {
@Id
GeneratedValue
private Long id;
@ManyToOne
private Member member;
}
예를 들어 5명의 Member가 저장되어 있을 때
memberRepository.findAll();
위 메서드를 실행했을 때 다음 쿼리가 발생한다.
SELECT * FROM MEMBERS
SELECT * FROM ORDERS WHERE MEMBER_ID=1
SELECT * FROM ORDERS WHERE MEMBER_ID=2
SELECT * FROM ORDERS WHERE MEMBER_ID=3
SELECT * FROM ORDERS WHERE MEMBER_ID=4
SELECT * FROM ORDERS WHERE MEMBER_ID=5
만약 실무에서 하나의 테이블의 다건조회로 인해
연관된 테이블 마다 N개의 쿼리가 추가로 발생하게 되면 애플리케이션에 큰 부하를 줄 수 있다.
만약 Member 엔티티의 Orders 페지 전략이 EAGER가 아닌, LAZY라면 당장의 Member 다건 조회시에는 다음 쿼리만이 발생한다.
@Entity
@Table(name = "member")
public class Member {
@Id
GeneratedValue
private Long id;
@OneToMany(mappedBy = "member", fetch = FetchType.LAZY)
private List<Order> orders = new ArrayList<Order>();
}
```java
memberRepository.findAll();
SELECT * FROM MEMBERS
하지만 각 Member의 orders를 get 하는 순간 별개의 쿼리가 발생하는 것을 피할 수 없다.
List<Member> members = memberRepository.findAll();
members.get(0).getOrders();
members.get(1).getOrders();
SELECT * FROM MEMBERS
SELECT * FROM ORDERS WHERE MEMBER_ID=1
SELECT * FROM ORDERS WHERE MEMBER_ID=2
Members의 다건 조회시 Order 데이터가 반드시 필요하다면 별도의 쿼리 발생을 막을 수 없다.
결국 페치 전략 변경만으로는 근본적으로 N+1 문제를 해결할 수 없다.
N+1 문제를 해결하기 위한 대표적인 방법으로 Fetch Join과 BatchSize 설정이 있다.
N+1을 해결하는 가장 일반적인 방법이다.
String jpql = "SELECT m FROM Member m JOIN FETCH m.orders";
List<Member> members = entityManager.createQuery(jpql, Member.class).getResultList();
JPAQueryFactory queryFactory = new JPAQueryFactory(entityManager);
QMember qMember = QMember.member;
QOrder qOrder = QOrder.order;
List<Member> members = queryFactory
.selectFrom(qMember)
.leftJoin(qMember.orders, qOrder)
.fetchJoin()
.fetch();
SELECT MEMBERS.*, ORDERS.*
FROM MEMBERS
LEFT JOIN ORDERS ON MEMBERS.ID = ORDERS.MEMBER_ID
N개의 Members 엔티티 수 만큼 추가적으로 발생한 쿼리가 발생하지 않고,
한 번의 쿼리만으로 다건조회를 수행한다.
1:N 관계에서 발생하는 주로 발생하는 문제이며 예시를 보자.
아래와 같이 데이터가 저장되어 있다고 가정해보자.
(member_id, order_id)
(1, 1)
(1, 2)
members 다건조회시 하나의 members 데이터만 반환되어야 하지만, fetchJoin을 이용할 경우 각 데이터가 고유한 것으로 간주되어 두 개의 members 데이터가 반환된다.
이를 해결하기 위해 쿼리에 distinct를 적절히 적용해 주어야 한다.
JPAQueryFactory queryFactory = new JPAQueryFactory(entityManager);
QMember qMember = QMember.member;
QOrder qOrder = QOrder.order;
List<Member> members = queryFactory
.selectFrom(qMember)
.distinct()
.leftJoin(qMember.orders, qOrder)
.fetchJoin()
.fetch();
BatchSize 설정을 통해 하이버네이트가 생성한 프록시 객체를 조회할 때, Where 절이 같은 쿼리를 하나의 IN 쿼리로 만들어준다.
지정된 Batch Size 에 따라 IN 절에 들어가는 요소의 개수가 정해진다.
만약 Batch Size가 100이고, IN 절외 포함되는 요소가 250개라면
(100 + 100 + 50) 총 3번의 쿼리가 나가게 된다.
데이터에 따라 적절한 Batch Size를 적용하는 것은 성능 개선에 영향을 줄 수 있다.
@BatchSize(size = 10) // 1. 엔티티에 Batch Size 지정
@Entity
@Table(name = "member")
public class Member {
@Id
GeneratedValue
private Long id;
@BatchSize(size = 10) // 2. 필드 Batch Size 지정
@OneToMany(mappedBy = "member", fetch = FetchType.LAZY)
private List<Order> orders = new ArrayList<Order>();
}
spring:
properties:
hibernate:
default_batch_fetch_size: 10
List<Member> members = memberRepository.findAll();
members.get(0).getOrders();
members.get(1).getOrders();
members.get(2).getOrders();
members.get(3).getOrders();
members.get(4).getOrders();
SELECT *
FROM MEMBERS
SELECT *
FROM ORDERS
WHERE MEMBER_ID IN (1, 2, 3, 4, 5)