JPA로 애플리케이션을 개발할 때 발생할 수 있는 성능 문제들이 있다.
소위 N+1 문제
라고도 불리는 이 문제는 연관관계가 설정된 엔티티를 조회할 때 가장 주의 해야하는 부분이므로 개발자 기술 면접 단골 질문이기도 하다. (실제로 오늘 면접에서 받은 질문이다.)
이전에 공부했던 내용이고 면접 준비를 하면서도 생각했던 부분이라 답변을 하긴 했지만 다소 시원치 않았던 것 같다. 아직 내 것이 안됐다는 반증인 것 같다.
좀 더 정확히 공부해서 제대로 기억해두고자 한다.
@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;
}
회원과 주문 정보는 1:N, N:1 양방향 연관관계다. 회원이 참조하는 주문정보인 Member.orders은 즉시 로딩으로 설정했다.
특정 회원 하나를 em.find() 메소드로 조회하면 즉시 로딩으로 설정한 주문 정보도 함께 조회한다.
em.find(Member.class, id);
여기서 실행되는 SQL은 다음과 같다.
SELECT M.*, O.*
FROM MEMBER M
OUTER JOIN ORDERS O
ON M.ID = O.MEMBER_ID
이렇게 조인을 사용하여 한 번에 회원 정보와 주문 정보를 가져오게 되니 즉시 로딩이 좋아보이는데, 문제는 JPQL을 사용할 때 발생하게 된다.
JPQL을 실행하면 JPA는 이를 분석해서 SQL을 생성한다.
이 때는 즉시 로딩이나 지연 로딩을 전혀 신경쓰지 않고 JPQL 만 사용하여 SQL을 생성한다.
SELECT * FROM MEMBER
이 쿼리의 실행 결과로 먼저 회원 엔티티를 애플리케이션에 로딩한다.
그런데 회원 엔티티와 연관된 주문 컬렉션이 즉시 로딩으로 설정되어있으므로 JPA는 주문 컬렉션을 즉시 로딩하려고 다음 SQL을 추가로 실행한다.
SELECT * FROM ORDERS WHERE MEMBER_ID=?
조회된 회원이 하나면 이렇게 총 2번은 SQL이 실행하지만, 조회된 회원이 여러 명이면 어떻게 될까? 삼천명이면? 오천만명이면? ....
SELECT * FROM MEMBER // 1번 실행으로 N명의 회원 조회
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
...
..
회원 조회 SQL로 회원 엔티티를 조회하고 조회한 각각의 회원 엔티티와 연관된 주문 컬렉션을 즉시 조회하려고 총 N번의 SQL를 추가로 실행했다.
이렇게 처음 실행한 SQL의 결과 수만큼 추가로 SQL을 실행하는 것을 N+1 문제
라고 한다.
즉시로딩은 JPQL을 실행할 때 N+1 문제가 발생할 수 있다.
회원과 주문을 지연 로딩으로 설정해보자.
@Entity
public class Member {
@Id @GeneratedValue
private Long id;
@OneToMany(mappedBy = "member", fetch = FetchType.LAZY)
private List<Order> orders = new ArrayList<Order>();
}
지연로딩으로 설정하면 JPQL에서는 N+1 문제가 발생하지 않는다.
List<Member> members = em.createQuery("select m from Member m", Member.class).getResultList();
지연 로딩이므로 데이터 베이스에서 회원만 조회된다. 따라서 아래의 SQL만 실행되고 연관된 주문 컬렉션은 지연 로딩된다.
SELECT * FROM MEMBER
이후 비즈니스 로직에서 주문 컬렉션을 실제로 사용할 때 지연 로딩이 발생한다.
firstMember = members.get(0);
firstMember.getOrders().size(); // 지연 로딩 초기화
member.get(0)
으로 회원 하나만 조회해서 사용했으므로 firstMember.getOrders().size()
를 호출하면서 실행되는 SQL은 다음과 같다.
SELECT * FROM ORDERS WHERE MEMBER_ID=?
문제는 다음처럼 모든 회원에 대해 연관된 주문 컬렉션을 사용할 때 발생한다.
for (Member member : members) {
//지연 로딩 초기화
System.out.println("member = " + member.getOrders().size());
}
주문 컬렉션을 초기화하는 수만큼 다음 SQL 이 실행될 수 있다. 회원이 5명이면 회원에 따른 주문도 5번 조회된다.
SELECT * FROM MEMBER // 1번 실행으로 N명의 회원 조회
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
...
..
이것도 결국 같은 N+1 문제가 된다.
가장 일반적인 방법은 fetch join을 사용하는 것이다. fetch join은 SQL 조인을 사용해서 연관된 엔티티를 함께 조회하므로 N+1 문제가 발생하지 않는다.
select m from Member m join fetch m.orders
실행된 SQL은 다음과 같다.
SELECT M.*, O.*
FROM MEMBER M
INNER JOIN ORDERS O
ON M.ID = O.MEMBER_ID
그러나 fetch join 을 사용하게 되면 연관관계 설정해놓은 FetchType을 사용할 수 없으므로, FetchType을 Lazy로 설정해놓는 것이 무의미해진다.
또한 페이징 쿼리를 사용할 수 없어서 페이징 단위로 데이터를 가져오는 것이 불가능하다.
@EntityGraph(attributePaths = "orders")
@Query("select m from MEmber m")
List<Member> findAllEntityGraph();
@EntityGraph
의 attributePaths
에 쿼리 수행 시 가져올 필드명을 지정하면 Lazy가 아닌 Eager 조회로 가져오게 된다.
SELECT M.*, O.*
FROM MEMBER M
OUTER JOIN ORDERS O
ON M.ID = O.MEMBER_ID
fetch join은 inner join으로 Entity Graph는 outer join 으로 가져오는 것을 알 수 있다.
일대다 조인을 했으므로 중복된 결과가 나타날 수 있다.
일대다 필드의 타입을 Set
으로 선언할 수도 있다. Set
은 중복을 허용하지 않는 자료구조이므로 중복 등록이 되지 않는다.
@OneToMany(mappedBy = "member", fetch = FetchType.EAGER)
private Set<Order> orders = new LinkedHashSet<Order>();
Set
이 순서가 보장되지 않으므로 LinkedHashSet
을 사용하여 순서를 보장하면 된다.
Set
보다 List
가 더 적합하다고 판단된다면 DISTINCT
를 사용해서 중복을 제거하면 된다.
@BatchSize
하이버네이트가 제공하는 @BatchSize
어노테이션을 사용하면 연관된 엔티티를 조회할 때 지정한 size 만큼 SQL의 IN 절을 사용해서 조회한다.
만약 조회한 회원이 10명이면 size = 5로 지정하면 2번의 SQL 만 추가로 실행한다.
@Entity
public class Member {
@Id @GeneratedValue
private Long id;
@org.hibernate.annotations.BatchSize(size = 5)
@OneToMany(mappedBy = "member", fetch = FetchType.EAGER)
private List<Order> orders = new ArrayList<Order>();
}
즉시 로딩으로 설정하면 다음 SQL 이 2번 실행된다. 지연 로딩으로 설정하면 지연 로딩된 엔티티를 최초 사용하는 시점에 다음 SQL을 실행해서 5건의 데이터를 미리 로딩해둔다.
그리고 6번째를 사용하면 다음 SQL을 추가로 실행한다.
SELECT *
FROM ORDERS
WHERE MEMBER_ID IN (?, ?, ?, ?, ?
)
애플리케이션 전체에 기본으로 @BatchSize
를 지정할 수도 있다.
<property name="hibernate.default_batch_fetch_size" value="5" />
@Fetch(FetchMode.SUBSELECT)
하이버네이트가 제공하는 org.hibernate.annotations.Fetch 어노테이션에 FetchMode를 SUBSELECT 를 사용하면 연관된 데이터를 조회할 때 서브쿼리를 사용해서 N+1 문제를 해결할 수 있다.
@Entity
public class Member {
@Id @GeneratedValue
private Long id;
@org.hibernate.annotations.Fetch(FetchMode.SUBSELECT)
@OneToMany(mappedBy = "member", fetch = FetchType.EAGER)
private List<Order> orders = new ArrayList<Order>();
}
select m from Member m where m.id > 10
위와 같은 SQL로 id 값이 10 을 초과하는 회원을 조회한다고 했을 때,
즉시 로딩으로 설정하면 조회 시점에, 지연 로딩으로 설정하면 지연 로딩된 엔티티를 사용하는 시점에 다음 SQL이 실행된다.
SELECT O
FROM ORDERS O
WHERE O.MEMBEER_ID IN (
SELECT M.ID
FROM MEMBER M
WHERE M.ID > 10
)
Query를 실행하도록 지원해주는 다양한 QueryBuilder를 사용할 수 있다.
QueryDSL과 같은 오픈소스를 사용하여 최적화된 쿼리를 구현할 수 있다.
추천하는 방법은 즉시 로딩은 사용하지 않고 지연 로딩만 사용하는 것이다.
즉시 로딩 전략은 그럴듯해보이지만 N+1 문제 뿐 아니라 비즈니스 로직에 따라 필요하지 않은 엔티티를 로딩해야하는 상황이 발생할 수 있다.
즉시 로딩 방법의 큰 문제는 성능 최적화가 어렵다는 점이다.
엔티티를 조회하다보면 즉시 로딩이 연속으로 발생해서 전혀 예상하지 못한 SQL이 실행될 수도 있다.
따라서 모두 지연 로딩으로 설정하고, 성능 최적화가 꼭 필요한 곳에 JPQL fetch join을 사용하자.
@XToOne
: 기본 페치 전략 → 즉시 로딩
@XtoMany
: 기본 페치 전략 → 지연 로딩
@XtoOne
의 페치 전략을 fetch = FetchType.LAZY
로 설정하여 지연 로딩 전략을 사용하도록 하자.