JPA를 사용하다보면, N+1 문제는 한 번쯤 겪을 것이다.
- N+1 문제 : 연관관계로 매핑된 엔티티를 조회할 때, 추가적으로 N개의 쿼리가 실행되는 상황 (1번의 메인 쿼리와 N번의 추가 쿼리)
개발을 하면서 알고는 있었지만, 제대로 정리를 해본 적은 없는 것 같아 이번에 정리해보려고 한다.
예시와 함께 살펴보자.
@Entity
public class Member {
@Id @GeneratedValue
private Long Id;
@OneToMany(mappedBy = "member", fetch = FetchType.EAGER)
private List<Order> orders = new ArrayList<Order>();
...
}
@Entity
@Table(name = "ORDERS")
public class Order {
@Id @GeneratedValue
private Long id;
@ManyToOne
private Member member;
...
}
- 위의 예시는 Member와 Order가 1 : N, N : 1로 양방향 연관관계이다.
- 회원이 참조하는 주문 정보인 Member.orders를 즉시 로딩으로 설정하였다.
| 즉시 로딩과 N + 1
위와 같이 설정했을 때, 특정 회원을 em.find()로 조회하면?
em.find(Member.class, id);
즉시로딩으로 설정되어 있기 때문에, 아래와 같이 주문 정보도 함께 조회하게 된다.
SELECT M.*, O.*
FROM MEMBER M
OUTER JOIN ORDERS O ON M.ID = O.MEMBER_ID;
위와 같이 JOIN을 사용하여 한 번의 SQL로 회원과 주문정보를 조회하게 된다.
하지만, 즉시로딩의 문제는 JPQL을 사용할 때 발생한다고 한다.
List<Member> members
= em.createQuery("select m from Member m", Member.class)
.getResultList();
- 이렇게 JPQL을 실행하면, JPA는 이것을 분석해서 SQL을 생성한다고 한다. 이때는 즉시 로딩, 지연 로딩 신경쓰지 않고 JPQL만 사용해서 쿼리를 발생시킨다.
- 따라서, List에 members가 n개가 있다면, member와 연관된 Order의 정보도 함께 가져오기 위해 n번의 쿼리가 추가적으로 발생하는 것이다.
| 지연 로딩과 N + 1
그럼 이번에는 회원이 참조하는 주문 정보를 LAZY로 변경해보자.
@OneToMany(mappedBy = "member", fetch = FetchType.LAZY)
private List<Order> orders = new ArrayList<Order>();
지연 로딩으로 설정하면, JPQL에서는 N+1 문제가 발생하지 않는다.
- 지연 로딩이기 때문에 데이터베이스에서 회원만 조회된다.
하지만, 비즈니스 로직에서 주문 컬렉션을 실제로 사용할 때 N+1이 발생한다.
for (Member member : members) {
member.getOrders().size(); // orders 데이터에 접근
}
member.getOrders().size()를 호출하면, 각 회원에 연관된 주문을 조회하기 위한 쿼리가 회원 수 만큼 발생한다.
SELECT * FROM orders WHERE member_id = ?;
+) 지연 로딩과 Proxy
지연로딩에서 N+1이 발생하는 이유는 프록시 객체 때문이다.
- 지연 로딩 기능을 사용하려면 실제 엔티티 객체 대신에 데이터베이스 조회를 지연할 수 있는 가짜 객체가 필요하다. 그리고 이것이 프록시 객체이다.
지연 로딩은 연관된 엔티티를 즉시 로드하지 않고, 실제로 필요할 때 쿼리를 실행한다.
- 이 과정에서 연관된 엔티티는 처음에 프록시 객체로 채워진다. 프록시 객체는 실제 데이터가 아닌, 데이터베이스 조회 요청을 대기 상태로 둔 가짜 객체이다.
- 연관 엔티티에 접근하는 순간, Hibernate는 프록시를 초기화하면서 데이터베이스에서 필요한 데이터를 조회합니다.
- 이때 연관된 엔티티에 접근할 때마다 각각의 프록시를 초기화하기 위해 추가 쿼리가 실행됩니다.
따라서, 지연로딩의 N+1 객체는 프록시 객체의 초기화를 위해서 발생하는 것이다.
그럼 어떻게 하면 N+1 문제를 피할 수 있을까?
| Fetch Join 사용
가장 일반적인 방법이다.
fetch join은 SQL join을 사용해서 연관된 엔티티를 함께 조회하므로 N+1 문제가 발생하지 않는다.
SELECT M.*, O.* FROM MEMBER M
INNER JOIN ORDERS O ON M.ID = O.MEMBER_ID
| 하이버네이트 @BatchSize
BatchSize 어노테이션은 연관된 엔티티를 조회할 때 지정한 Size 만큼 SQL의 IN 절을 사용해서 조회한다.
@OneToMany(mappedBy = "member", fetch = FetchType.LAZY)
@BatchSize(size = 5)
private List<Order> orders;
위와 같이 되었을 때, 다음과 같이 조회된다.
SELECT * FROM orders WHERE member_id IN (1, 2, 3, 4, 5);
SELECT * FROM orders WHERE member_id IN (6, 7, 8, 9, 10);
즉시 로딩이라면, 만약 회원이 10명이었을 때 10건의 데이터를 모두 가져와야하므로
size가 5이면 위와 같은 SQL문이 조회 시점에 2번 실행되는 것이다.
| 하이버네이트 @Fetch(FetchMode.SUBSELECT)
연관된 데이터를 조회할 때 서브 쿼리를 사용해 N+1 문제를 해결한다.
즉시 로딩이면 조회 시점에, 지연로딩이면 사용 시점에 서브쿼리가 있는 SQL이 실행된다.
즉시 로딩과 지연 로딩 중에서는 지연 로딩만 사용하고 성능 최적화가 필요하다면 JPQL Fetch Join을 사용하도록 하는 것이 좋다고 한다.
자바 ORM 표준 JPA 프로그래밍을 참고하였습니다.
'💻 개발 > Java' 카테고리의 다른 글
JCF와 스레드 - JCF 기초 (0) | 2024.12.04 |
---|---|
Java 모의 면접 후기 (0) | 2024.12.01 |
자바 기본 - System.out.println() (0) | 2024.11.07 |
자바 기본 - 어노테이션과 리플렉션 (0) | 2024.11.07 |
자바 기본 - 람다와 스트림 (0) | 2024.11.07 |