JPA로 엔티티를 설계하고, 쿼리를 짜다 보면
연관관계가 굉장히 복잡해지는 경우를 볼 수 있다.
보통 그럴 때, fetch join이 만능 해결책처럼 사용되곤 하는데
fetch join도 한계가 있다.
@Entity
public class Study {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "host_id")
private User host;
@OneToMany(mappedBy = "study")
private List<Joins> joins = new ArrayList<>();
@OneToMany(mappedBy = "study")
@Builder.Default
private List<StudyHashTag> hashTags = new ArrayList<>();
위 객체 처럼, 일대다로 묶인 여러 컬랙션은 fetch join으로 끌어올 수 없다.
public interface StudyRepository extends JpaRepository<Study, Long> {
@Query(value = "select s from Study s " +
"left join fetch s.host " +
"left join fetch s.hashTags",
countQuery = "select count(s.id) from Study s")
public Page<Study> findAllWithUser(Pageable pageable);
@Query("select s " +
"from Study s " +
"left join s.hashTags " +
"left join fetch s.joins " +
"where s.id = :studyId ")
public Study findFetchJoinStudyById(Long studyId);
}
이렇게 메소드를 2개 만들었을 때,
위 findAllWithUser
에서는 일대다로 연결된 컬랙션이 hashTags
밖에 없으므로 쿼리가 잘 나가고, JPA가 내가 원하는 것처럼 이쁘게 List에 담아준다.
2022-01-25 21:54:38.006 WARN 33320 --- [nio-8080-exec-4] o.h.h.internal.ast.QueryTranslatorImpl : HHH000104: firstResult/maxResults specified with collection fetch; applying in memory!
Hibernate:
select
study0_.id as id1_6_0_,
user1_.user_id as user_id1_8_1_,
hashtags2_.id as id1_7_2_,
study0_.created_at as created_2_6_0_,
study0_.description as descript3_6_0_,
study0_.goal as goal4_6_0_,
study0_.host_id as host_id10_6_0_,
study0_.last_access_time as last_acc5_6_0_,
study0_.name as name6_6_0_,
study0_.number as number7_6_0_,
study0_.open_kakao as open_kak8_6_0_,
study0_.total_time as total_ti9_6_0_,
user1_.email as email2_8_1_,
user1_.image_url as image_ur3_8_1_,
user1_.nickname as nickname4_8_1_,
user1_.password as password5_8_1_,
user1_.provider as provider6_8_1_,
user1_.provider_id as provider7_8_1_,
user1_.role as role8_8_1_,
hashtags2_.hash_tag as hash_tag2_7_2_,
hashtags2_.study_id as study_id3_7_2_,
hashtags2_.study_id as study_id3_7_0__,
hashtags2_.id as id1_7_0__
from
study study0_
left outer join
user user1_
on study0_.host_id=user1_.user_id
left outer join
study_hash_tag hashtags2_
on study0_.id=hashtags2_.study_id
Hibernate:
select
count(study0_.id) as col_0_0_
from
study study0_
하지만, SQL문이 조금 이상하다. 페이지네이션을 위해 count 쿼리가 한번 더 나가는 건 이상한 것이 아니지만, 그 전에 나간 select 쿼리에 limit문이 없다.
분명 요구한 page와 size에 맞춰서 limit문이 생성되야, 적절한 페이지네이션을 할 수 있을텐데, 저기선 찾아볼 수 없다. 즉, 테이블을 FULL SCAN해서 애플리케이션 메모리에 올리고, 애플리케이션에서 요청한 페이지에 맞춰서 잘라내고 있는 것이다.
2022-01-25 21:54:38.006 WARN 33320 --- [nio-8080-exec-4] o.h.h.internal.ast.QueryTranslatorImpl : HHH000104: firstResult/maxResults specified with collection fetch; applying in memory!
로그를 잘 읽어보면, 메모리에서 처리할 것이라고 경고도 해주고 있다.
fetch join을 한다해도, 결국 DB 입장에서는 join문이 나가게 되는데, 그러면 일대다 관계에서는 각 row마다, 연결된 테이블 row 수만큼 늘어나게 되는데, 그렇게 되면 Hibernate 입장에서는 limit를 중복으로 생긴 row를 고려해서 걸어야할지, 아니면 중복으로 생긴 row를 무시하고 그냥 limit를 날려할지 고민하게되는데, 이를 sql문으로 해결하지 않고 그냥 메모리에 데이터를 전부 올려버리고 페이지네이션하는 방법으로 해결하고 있는 것이다.
아래 메소드 같은 경우는 hashTags
와 joins
를 동시에 fetch join하게 되는데 그럼
org.hibernate.loader.MultipleBagFetchException: cannot simultaneously fetch multiple bags
이런 에러가 던져진다.
@Query("select s " +
"from Study s " +
"left join s.hashTags " +
"left join fetch s.joins " +
"where s.id = :studyId ")
public Study findFetchJoinStudyById(Long studyId);
컬렉션의 카테시안 곱이 만들어지므로, 너무 많은 row가 생성되버릴 수 있으므로, 에러를 발생시키는 것이다.
일대일 관계는 join해도 row수를 늘리지 않으므로, 여러개를 fetch join해도 된다.
그럼 일대다 관계는 어떻게 최적화 하는가!
완벽한 정답이라고 할 수는 없지만, 대부분의 환경에서의 문제를 해결할 수 있는 방법이 있다.
그것은 BatchSize
옵션을 이용하여, 컬랙션들을 지연로딩하는 것이다.
BatchSize를 사용하면, 설정한 사이즈만큼 데이터를 끌어와서, 컬랙션이나, 프록시 객체를 한꺼번에 IN쿼리를 이용해서 조회한다.
그냥 지연로딩을 이용하면 1+N문제가 발생해서 DB에 과부하가 걸릴 수 있지만.
BatchSize 옵션은 설정한 크기만큼 미리 끌어오기 때문에, 쿼리를 한방만 더 날리면 된다.
1+N에서 1+1이 된 것이니, 왠만한 성능최적화 문제는 이 옵션으로 잡을 수 있다.
Global하게 적용하려면 application.yaml파일에 아래처럼 적으면 된다.
spring:
jpa:
properties:
hibernate:
default_batch_fetch_size: 1000
사이즈는 보통 100~1000정도가 적당하다고 한다. 몇몇 DB에서는 한번에 끌어올 수 있는 쿼리가 1000이 넘어가면 에러를 일으킬 수 도 있으니, 웬만하면 1000을 넘지 않게 작성하는게 좋다
개별적으로 적용하려면 ToMany관계일 때는
@BatchSize(size = 1000)
@OneToMany(mappedBy = "study")
private List<StudyHashTag> hashTags = new ArrayList<>();
해당 필드 위에 달아주면 되고
ToOne관계일 때는
@BatchSize(size=100)
@Entity
public class Locker{
@Id @GeneratedValue
private Long id;
}
클래스 명위에 작성하면 된다.
인제 아까 두 메소드에 적용해보자
@Query(value = "select s from Study s " +
"left join fetch s.host " +
"left join fetch s.hashTags",
countQuery = "select count(s.id) from Study s")
public Page<Study> findAllWithUser(Pageable pageable);
@Query("select s " +
"from Study s " +
"left join s.hashTags " +
"left join fetch s.joins " +
"where s.id = :studyId ")
public Study findFetchJoinStudyById(Long studyId);
이랬던 메소드를
@Query(value = "select s from Study s "+
"left join fetch s.host ",
countQuery = "select count(s.id) from Study s")
public Page<Study> findAllWithUser(Pageable pageable);
@Query("select s " +
"from Study s " +
"left join fetch s.host " +
"where s.id = :studyId ")
public Study findStudyById(Long studyId);
이렇게 고쳐준다.
첫번째 메소드는 이런식으로
Hibernate:
select
study0_.id as id1_6_0_,
user1_.user_id as user_id1_8_1_,
study0_.created_at as created_2_6_0_,
study0_.description as descript3_6_0_,
study0_.goal as goal4_6_0_,
study0_.host_id as host_id10_6_0_,
study0_.last_access_time as last_acc5_6_0_,
study0_.name as name6_6_0_,
study0_.number as number7_6_0_,
study0_.open_kakao as open_kak8_6_0_,
study0_.total_time as total_ti9_6_0_,
user1_.email as email2_8_1_,
user1_.image_url as image_ur3_8_1_,
user1_.nickname as nickname4_8_1_,
user1_.password as password5_8_1_,
user1_.provider as provider6_8_1_,
user1_.provider_id as provider7_8_1_,
user1_.role as role8_8_1_
from
study study0_
left outer join
user user1_
on study0_.host_id=user1_.user_id limit ?
Hibernate:
select
count(study0_.id) as col_0_0_
from
study study0_
Hibernate:
select
hashtags0_.study_id as study_id3_7_1_,
hashtags0_.id as id1_7_1_,
hashtags0_.id as id1_7_0_,
hashtags0_.hash_tag as hash_tag2_7_0_,
hashtags0_.study_id as study_id3_7_0_
from
study_hash_tag hashtags0_
where
hashtags0_.study_id in (
?, ?, ?, ?, ?, ?, ?, ?, ?, ?
)
limit 쿼리가 제대로 들어가고, StudyHashTag 객체들은 IN쿼리로 한번에 조회함을 알 수 있다.
2번째 메소드는 페이지네이션을 하지 않지만, 컬랙션 fetch join을 2개를 한꺼번해서 메소드가 실행안되는 이슈가 있었지만 이렇게
Hibernate:
select
study0_.id as id1_6_0_,
user1_.user_id as user_id1_8_1_,
study0_.created_at as created_2_6_0_,
study0_.description as descript3_6_0_,
study0_.goal as goal4_6_0_,
study0_.host_id as host_id10_6_0_,
study0_.last_access_time as last_acc5_6_0_,
study0_.name as name6_6_0_,
study0_.number as number7_6_0_,
study0_.open_kakao as open_kak8_6_0_,
study0_.total_time as total_ti9_6_0_,
user1_.email as email2_8_1_,
user1_.image_url as image_ur3_8_1_,
user1_.nickname as nickname4_8_1_,
user1_.password as password5_8_1_,
user1_.provider as provider6_8_1_,
user1_.provider_id as provider7_8_1_,
user1_.role as role8_8_1_
from
study study0_
left outer join
user user1_
on study0_.host_id=user1_.user_id
where
study0_.id=?
Hibernate:
select
joins0_.study_id as study_id2_1_1_,
joins0_.id as id1_1_1_,
joins0_.id as id1_1_0_,
joins0_.study_id as study_id2_1_0_,
joins0_.user_id as user_id3_1_0_
from
joins joins0_
where
joins0_.study_id=?
Hibernate:
select
user0_.user_id as user_id1_8_0_,
user0_.email as email2_8_0_,
user0_.image_url as image_ur3_8_0_,
user0_.nickname as nickname4_8_0_,
user0_.password as password5_8_0_,
user0_.provider as provider6_8_0_,
user0_.provider_id as provider7_8_0_,
user0_.role as role8_8_0_
from
user user0_
where
user0_.user_id in (
?, ?, ?, ?, ?
)
Hibernate:
select
hashtags0_.study_id as study_id3_7_1_,
hashtags0_.id as id1_7_1_,
hashtags0_.id as id1_7_0_,
hashtags0_.hash_tag as hash_tag2_7_0_,
hashtags0_.study_id as study_id3_7_0_
from
study_hash_tag hashtags0_
where
hashtags0_.study_id=?
쿼리가 연결된 컬랙션의 갯수만큼 더 나가긴 하지만, 데이터를 깔끔하게 가져옴을 알 수 있다.