개요
최근 프로젝트를 진행하면서, Fetch Join
과 Pagination
을 함께 사용했다.
이때 발생한 성능 저하 및 자원 낭비 이슈가 발생했는데, 이에 대해 포스팅을 하고자 한다.
이슈 설명
- 특정 엔티티가 컬렉션 필드를 가지고 있을 때, 즉 1:N 관계일 때
Fetch Join
과Pagination
을 사용하여 문제가 발생했다. - 아래는 동일한 문제가 발생하는 예시 코드이다.
엔티티A 클래스
public class EntityA {
@Id @GeneratedValue
private Long id;
@OneToMany(mappedBy = "entityA")
private List<EntityB> entityBList = new ArrayList<>();
}
엔티티B 클래스
public class EntityB {
@Id @GeneratedValue
private Long id;
@ManyToOne
private EntityA entityA;
}
엔티티B : 엔티티A = N : 1
Repository 클래스
@Repository
public class Repository {
@PersistenceContext
private EntityManager em;
public List<EntityA> findAll(int size) {
String jpql = "SELECT a FROM EntityA a" +
" JOIN FETCH a.entityBList";
return em.createQuery(jpql, EntityA.class)
.setMaxResults(size)
.getResultList();
}
}
이슈 발생 절차
-
EntityA a1
엔티티의entityBList
필드에Entity b1
,Entity b2
,Entity b3
를 Add 한다. -
EntityA a1
엔티티를 DB에 저장한다. -
EntityA a2
엔티티를 DB에 저장한다. -
Repository.findAll(0, 2)
를 호출한다. -
firstResult/maxResults specified with collection fetch; applying in memory!
라는 문구가 콘솔에 출력되지만, 정상적으로List<EntityA>
가 반환된 것 같이 보인다. -
하지만 여기에는 심각한 성능 저하 문제가 있다.
원하는 결과
firstResult/maxResults specified with collection fetch; applying in memory!
라는 경고 메시지를 없애고, 성능저하 문제를 해결한다.
문제 분석
발생 원인
N:1
관계에서Fetch Join
과Pagination API
를 함께 사용할 때 조회하는 대상이1
이라면,firstResult/maxResults specified with collection fetch; applying in memory!
가 발생한다.- 위와 같이
Repository.findAll(0, 2)
를 호출했을 때, 실행되는 실제 SQL 은 아래와 같다.
SELECT A.ID FROM EntityA as A
INNER JOIN EntityB as B
ON A.ID = B.EntityA_ID;
-
LIMIT
이나OFFSET
절을 전혀 사용하지 않았음을 알 수 있다.
왜 그렇게 동작하는지 계속해서 알아보자. -
만약 위 이슈 발생 절차대로 작업을 한다면 실제 조회 결과는 아래와 같다.
A.ID | B.ID | B.EntityA_ID |
---|---|---|
1 | 1 | 1 |
1 | 2 | 1 |
1 | 3 | 1 |
- 일반
Fetch Join
으로 조회를 하게 되면INNER JOIN
으로 쿼리를 실행하고, 동일한EntityA
가 연관된EntityB
의 개수만큼 중복되어 출력된다. - 따라서 정확하게 Pagination 을 할 수 없다.
- 그렇다면 왜 반환값
List<EntityA>
가 정확하게 기대하는 바와 같이 반환되었을까?- 그 이유는 JPA 가 모든 EntityA 를 INNER JOIN 으로 조회하고, 서버 메모리를 사용하여 직접 페이징 처리를 했기 때문이다.
- 따라서 성능 저하가 발생하고, 자원을 낭비하게 된다.
해결방법
해결 방법에는 크게 두 가지가 있다.
- 하나의 쿼리를 두 개로 쪼개는 방법
- 1 -> N 으로 조회하지 않고, N -> 1 로 조회하는 방법
하나씩 설명하겠다.
1. 하나의 쿼리를 두 개로 쪼개기
기존 Repository.findAll
메서드의 로직은 아래와 같다.
public List<EntityA> findAll(int size) {
String jpql = "SELECT a FROM EntityA a" +
" JOIN FETCH a.entityBList";
return em.createQuery(jpql, EntityA.class)
.setMaxResults(size)
.getResultList();
}
이것을 아래처럼 변경하여 문제를 해결할 수 있다.
public List<EntityA> findAll(int size) {
//먼저 EntityA 의 ID를 조회한다. 이때, Pagination API를 사용한다.
String jpql = "SELECT a.id FROM EntityA a"
List<Long> idResult = em.createQuery(jpql, Long.class)
.setMaxResults(size)
.getResultList();
//추출된 ID를 IN 조건으로 사용하고 Fetch Join을 사용해, 원하는 결과를 얻는다.
jpql = "SELECT DISTINCT a FROM EntityA a" +
" JOIN FETCH a.entityBList" +
" WHERE a.id IN (:ids)";
List<EntityA> finalResult = em.createQuery(jpql, EntityA.class)
.setParameter("ids", idResult)
.getResultList();
return finalResult;
}
다만, DISTINCT
키워드를 두번째 쿼리에서 사용해줘야 한다.
왜냐하면, 두번째 쿼리의 Fetch Join에 의해서 EntityA가 여전히 중복되어 등장하기 때문이다.
DISTINCT
를 사용하는 대신, Set
자료구조를 사용해도 된다.
2. 1 -> N 으로 조회하지 않고, N -> 1 로 조회하기
애초에 EntityB 에서 조회를 한다면 문제가 될 것이 없다.
아래는 그 예시 코드이다.
public List<EntityB> findAll(int size) {
String jpql = "SELECT b FROM EntityB b" +
" JOIN FETCH b.entityA";
return em.createQuery(jpql, EntityA.class)
.setMaxResults(size)
.getResultList();
}
-
여기에서도
Fetch Join
과Pagination API
를 사용한다. -
하지만 해당 메서드를 실행했을 때,
firstResult/maxResults specified with collection fetch; applying in memory!
메시지는 출력되지 않는다! -
그 이유는 성능상 크게 문제되는 부분이 없기 때문이다.
-
해당 메서드를 호출했을 때, 실제로 실행되는 SQL 쿼리는 아래와 같다.
SELECT B.ID, B.EntityA_ID FROM EntityB as B
INNER JOIN EntityA as A
ON B.EntityA_ID = A.ID;
- 그 결과는 아래와 같다.
B.ID | B.EntityA_ID | A.ID |
---|---|---|
1 | 1 | 1 |
2 | 1 | 1 |
3 | 1 | 1 |
-
결과를 보면
Pagination
을 했을 때 전혀 문제가 될 것이 없다. -
왜냐하면 어차피
Pagination
의 대상이EntityB
이고, 결과적으로 중복되는EntityB
는 없기 때문이다.
물론 EntityA 는 중복된다.
정리
- 나의 경우, 첫번째 방법이 더 적합했다.
- 왜냐하면
EntityA
를 깔끔하게 구하고 싶었기 때문이다.- 두 번째 방법을 사용하면,
EntityA
를 중복되지 않게 구하기 위한 로직이 별도로 필요하다.
- 두 번째 방법을 사용하면,