JAVA/JPA 학습기록

[Hibernate] fetchJoin 시에 중복문제, 페이징을 사용하는 경우엔 미발생?

kth990303 2024. 8. 3. 14:51
반응형

fetchJoin을 하는 경우, 카테시안 곱으로 인해 중복 문제가 발생한다.

아래와 같이 클라이밍 팀 관련 데이터가 존재한다고 해보자.

1팀- 인원 2명, 2팀- 인원 3명

1팀, 2팀은 클라이밍고수 라는 동일한 팀 이름을 가지고 있다.

(2팀에 계신 분들은 국가대표 선수들이라 진짜 고수고, 1팀은 클라이밍 고수가 되고 싶은 팀이라고 하자 ㅎㅎ)

 

querydsl 메서드

아래 querydsl 코드로 '팀이름으로 해당 팀의 회원 명단을 반환'하는 메서드를 생성했다.

1
2
3
4
5
6
7
8
@Override
public List<Team> findAllMembersByTeamName(String teamName) {
    return jpaQueryFactory.selectFrom(team)
                          .join(team.members, member).fetchJoin()
                          .where(team.teamName.eq(teamName))
                          .fetch();
}
 
cs

 

지연로딩으로 인한 성능이슈를 막기 위해 fetchJoin으로 한번에 가져왔다.

 

좋아, 이제 위와 같이 팀2개와 회원5명이 뜨리라 기대되는군!

그래도 혹시 모르니 테스트코드를 짜볼까~? 

 

테스트코드

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Test
@DisplayName("해당 팀에 속한 모든 멤버 명단을 반환한다.")
void findAllMembersByTeamName() {
    setUpMembers();
 
    List<Team> teams = teamRepository.findAllMembersByTeamName("클라이밍고수");
    List<Member> members = teams.stream()
                                .map(Team::getMembers)
                                .flatMap(Collection::stream)
                                .collect(Collectors.toList());
 
    assertAll(
            () -> assertThat(teams).hasSize(2),
            () -> assertThat(members).hasSize(5)
             );
}
cs

 

위의 메서드를 테스트하는 코드를 만들었다.

JUnit 문법으로 팀은 2개, 회원은 5개의 결과 사이즈가 반환되길 기대하는 상황.


하지만 fetchjoin의 카테시안 곱 중복이슈로 인해 원하는 결과가 나오지 않는다.

 

테스트 Failed

 

팀 5개, 회원 13개.

팀의 회원명단까지 한번에 가져오는 fetchjoin이기 때문에, 회원 명수만큼 팀 row가 생성된다.

따라서 팀 2개가 아닌 5개가 뜬다. 

그리고 회원 수는 2*2 + 3*3 = 13개나 뜨게 된다!!

 

이 포스팅은 카테시안곱 (fetchjoin 중복이슈) 에 대한 내용을 알고 있다는 가정 하에 작성하고 있다.

따라서 자세한 이유는 생략.


fetchJoin + offset/limit 을 같이 적용한다면?

하지만 fetchJoin 과 페이징을 같이 적용한다면, 이상하게 위 중복문제가 뜨지 않는다.

 

querydsl 메서드

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Override
public Page<Team> findAllMembersByTeamNamePaging(String teamName, Pageable pageRequest) {
    List<Team> result = jpaQueryFactory.selectFrom(team)
                                       .join(team.members, member).fetchJoin()
                                       .where(team.teamName.eq(teamName))
                                       .offset(pageRequest.getOffset())
                                       .limit(pageRequest.getPageSize())
                                       .fetch();
    Long counts = jpaQueryFactory.select(team.count())
                                 .from(team)
                                 .join(team.members, member)
                                 .where(team.teamName.eq(teamName))
                                 .fetchOne();
 
    return PageableExecutionUtils.getPage(result, pageRequest, () -> counts);
}
cs

 

페이징 적용을 위해 offset, limit 을 추가한 코드이다.

(counts 메서드는 여기서 중요하지 않으므로 무시해도 된다.)

페이징 적용 시 테스트 통과

분명 페이징을 적용하지 않은 케이스는 테스트코드가 실패했으나,

페이징을 적용한 케이스는 팀2개, 회원5개가 제대로 뜨는 것을 확인할 수 있다.

 

페이징을 적용하면 distinct를 해주는걸까?

그렇다!

 

하이버네이트 5 의 QueryTranslatorImpl

 

하이버네이트 내부구현체 클래스인 QueryTranslatorImpl 를 살펴보면 알 수 있다.

needsDistincting 여부로 distinct를 결과에 적용하는데, 이 여부를 entityGraph에 쿼리힌트가 적용돼있는지 or limit이 있는지 여부로 판단하고 있다. (참고로 HINT_PASS_DISTINCT_THROUGH 옵션을 application.yml 또는 properties 에 적용하는 방법으로도 distinct를 해주게 할 수 있다.)

needsDistinct인 경우, 값이 중복되지 않도록 데이터를 add하지 않도록 하는 코드가 존재한다.

 

더욱 자세한 부분은 https://wedul.site/716 님의 블로그를 참고하자.

 

 

그렇지만 fetchjoin 이랑 페이징을 repository 단에서 같이 적용하는건 매우매우 비추한다.

o.h.h.internal.ast.QueryTranslatorImpl   : 

HHH000104: firstResult/maxResults specified with collection fetch; applying in memory!

 

위 경고가 함께 뜨는데, 이 이유는 fetchJoin 과 페이징을 같이 적용하는 경우에 

페이징이 적용되지 않고 전체 결과를 메모리에 올려지기 때문이다. 사실상 페이징의 장점을 버리는 셈.

 

https://jojoldu.tistory.com/737

 

Hibernate Fetch Join시 메모리에서 페이징 처리 사전 차단하기

Hibernate (Spring Data JPA) 를 사용하다보면 종종 HHH000104: firstResult/maxResults specified with collection fetch; applying in memory! 의 WARN (경고) 로그 메세지를 만난다. 해당 로그는 페이징 처리할때 여러 엔티티를 Fe

jojoldu.tistory.com

를 참고하면 더욱 도움이 될 듯하다.


Hibernate 6 (스프링부트 3점대) 부터는 위 이슈가 뜨지 않는다!

하지만 아래와 같이 버전이 높은 환경이라면?

build.gradle

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
plugins {
    id 'java'
    id 'org.springframework.boot' version '3.3.2'
    id 'io.spring.dependency-management' version '1.1.6'
}
 
dependencies {
    // 생략
 
    implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta'
    annotationProcessor "com.querydsl:querydsl-apt:5.0.0:jakarta"
    annotationProcessor "jakarta.annotation:jakarta.annotation-api"
    annotationProcessor "jakarta.persistence:jakarta.persistence-api"
}
 
tasks.named('test') {
    useJUnitPlatform()
}
 
cs

 

 

Spring Boot 3.3.2

querydsl 5.0.0

Hibernate 6 (스프링부트3점대 버전은 하이버네이트 6을 지원한다.)

 

(하... 스프링부트 3으로 바꾸면서 javax 에서 jakarta로 변경하고, 빌드 캐시있어서 entitymanager 및 QClass 생성 안되는 거 때문에 매우 힘들었다. 블로그 작성하기 힘든 이유다 흑흑흑...)

동일한 테스트를 돌리면 결과가 성공한다.

 

공식 문서 (https://docs.jboss.org/hibernate/orm/6.0/migration-guide/migration-guide.html#query-sqm-distinct) 에도 안내가 되어 있다. 

Starting with Hibernate ORM 6 it is no longer necessary to use  distinct in JPQL and HQL to filter out the same parent entity references when join fetching a child collection. The returning duplicates of entities are now always filtered by Hibernate.
Hibernate ORM 6부터는 자식 컬렉션을 조인 페칭할 때 동일한 부모 엔터티 참조를 필터링하기 위해 JPQL 및 HQL에서 
distinct를 사용할 필요가 더 이상 없습니다 . 반환되는 엔터티 중복은 이제 항상 Hibernate에서 필터링됩니다.

 

hibernate 6 부터는 SQM(Semantic Query Model) 이 지원된다.

이 친구가 똑똑하게 결과를 항상 distinct 해주므로 org.hibernate.jpa.QueryHints.HINT_PASS_DISTINCT_THROUGH 옵션을 붙이지 않아도 fetchjoin 중복 문제가 해결된다.

 

 

(역시 버전업은 중요해 허허..)

반응형