Hibernate 5와 6에서 fetch join, @BatchSize 그리고 페이징 처리
hibernate 6 버전에서는 아래와 같이 fetch join을 할 경우 자동으로 중복된 데이터(Nationality의 id값이 같은 경우)는 제거해준다고 한다. 참고자료(https://docs.jboss.org/hibernate/orm/current/userguide/html_single/Hibernate_User_Guide.html#hql-distinct)
@Query("select n from Nationality n join fetch n.players p left join fetch p.club")
List<Nationality> findAllByFetchJoin();
하지만 그 미만의 버전에서는 이런 기능이 없었기 때문에 distinct를 붙여줘야 한다.
@Query("select distinct n from Nationality n join fetch n.players p left join fetch p.club")
List<Nationality> findAllByFetchJoin();
그러면 DB에 쿼리할 때도 distinct가 붙어서 나가게 된다. 하지만 실제로 받아오는 데이터는 중복값(엔터티의 id값이 같은 경우)이 제거된 상태이다. 중복값을 제거한 상태로 컬렉션(List)에 담아준다. 하지만 페이징 처리는 불가능하다. 다음의 쿼리를 했을 때의 결과를 생각하면 왜 페이징 처리가 안되는지 알 수 있다.
SELECT DISTINCT n.*, p.*
FROM nationality n
JOIN player p ON n.id = p.nationality_id
LEFT JOIN club c ON c.id = p.club_id
LIMIT 3;
+--+-----+---------+-------+--+--------------+------+------+
# |id|name |continent|club_id|id|nationality_id|name |status|
# +--+-----+---------+-------+--+--------------+------+------+
# |1 |Korea|ASIA |1 |1 |1 |Son |ACTIVE|
# |1 |Korea|ASIA |1 |20|1 |Hwang |ACTIVE|
# |2 |Japan|ASIA |2 |3 |2 |Kagawa|ACTIVE|
# +--+-----+---------+-------+--+--------------+------+------+
그리고 fetch join으로 페이징 처리를 하면 모든 결과를 메모리에 담아서 페이징 처리를 시도하려고 한다.
HHH90003004: firstResult/maxResults specified with collection fetch; applying in memory
따라서 이에 대한 해결책은?
ToOne 관계인 것들은 fetch join 해도 상관없다. 왜냐면 ToOne 관계는 row 수를 증가시키지 않으므로
그리고 컬랙션(ToMany)인 엔터티는 batch size를 설정해서 가져온다.
batch size 설정 방법
- application.yaml의 hibernate 설정(글로벌하게 적용)
spring:
jpa:
properties:
hibernate:
default_batch_fetch_size : 100
- @BatchSize 이용(특정 엔터티에 적용)
@OneToMany(mappedBy = "nationality")
@BatchSize(size = 10)
private List<Player> players;
이렇게 적용하면 컬렉션이나 프록시 객체를 설정한 batchSize만큼 In 쿼리로 조회한다.
근데 테스트를 하다보니 다음과 같은 문제가 발생한다.
Nationality : Player = 1 : N
Player : Club = N : 1
Nationality를 조회하여 Player를 batchSize만큼 가져오는 쿼리는 잘 작동하는데
select
n1_0.id,
n1_0.continent,
n1_0.name
from
nationality n1_0
limit
?
select
p1_0.nationality_id,
p1_0.id,
p1_0.club_id,
p1_0.name,
p1_0.status
from
player p1_0
where
p1_0.nationality_id in (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
Club을 가져오는 쿼리는 Player 수만큼(물론 겹치면 나가지 않겠지만) 쿼리가 나간다.
select c1_0.id,c1_0.name from club c1_0 where c1_0.id=5;
select c1_0.id,c1_0.name from club c1_0 where c1_0.id=3;
select c1_0.id,c1_0.name from club c1_0 where c1_0.id=1;
select c1_0.id,c1_0.name from club c1_0 where c1_0.id=8;
select c1_0.id,c1_0.name from club c1_0 where c1_0.id=7;
...
어떻게 해결해야 할까?
- EAGER를 쓰는 방법
public class Player {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.EAGER)
private Club club;
}
이렇게 되면 batchSize만큼 조회를 할 때 Club도 함께 조인하여 가지고 오므로 N+1은 해결이 된다.
하지만 default값이 EAGER인 ManyToOne에서조차 EAGER는 안쓰는 것이 좋다고 하니 다른 방법은 없는지 찾아보자.
Lazy Loading을 써야하는 이유?
Lazy 로딩은 관련 데이터를 필요한 시점에만 가져오도록 하여, 메모리 사용을 줄이고 불필요한 데이터 로딩을 피함으로써 성능을 향상시킬 수 있기 때문에 권장됩니다.
@ManyToOne의 기본 설정은 Eager 로딩이지만, 특별한 이유가 없다면 Lazy 로딩으로 설정하는 것이 일반적으로 더 좋습니다.
- application.yaml에 hibernate 설정
jpa:
properties:
hibernate:
default_batch_fetch_size: 10
글로벌하게 batch_fetch_size를 설정해주니 원하는대로 조회 쿼리가 나간다.
select c1_0.id,c1_0.name from club c1_0 where c1_0.id in (1,2,7,4,8,9,10,3,5,NULL);
- Entity에 적용하는 방법 - 글로벌한 설정 없이도 위의 조회 쿼리가 나간다.
@BatchSize(size = 10)
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Entity
public class Club {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@OneToMany(mappedBy = "club")
private List<Player> players;
}