Fetch Join의 한계

이윤준·2022년 1월 25일
7

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으로 끌어올 수 없다.

@EnableJpaRepositories
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문으로 해결하지 않고 그냥 메모리에 데이터를 전부 올려버리고 페이지네이션하는 방법으로 해결하고 있는 것이다.

아래 메소드 같은 경우는 hashTagsjoins 를 동시에 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=?

쿼리가 연결된 컬랙션의 갯수만큼 더 나가긴 하지만, 데이터를 깔끔하게 가져옴을 알 수 있다.

profile
욕심쟁이 개발자

0개의 댓글