JPA querydsl에서 oneToMany fetchjoin시 offset, limit를 사용할경우 result list가 distinct가 되는 이유
web/JPA

JPA querydsl에서 oneToMany fetchjoin시 offset, limit를 사용할경우 result list가 distinct가 되는 이유

반응형

Jpa join시 중복 엔티티 출력 현상

교실 (Classes 엔티티)

package com.wedul.jpa.school;

import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

import javax.persistence.*;
import java.util.List;

@Getter
@NoArgsConstructor
@Entity
@Table
public class Classes {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String className;

    @OneToMany(cascade = CascadeType.ALL, mappedBy = "classes", orphanRemoval = true)
    private List<Teacher> teachers;

    @Builder
    public Classes(String className, List<Teacher> teachers) {
        this.className = className;
        this.teachers = teachers;
    }

    public void updateTeachers(List<Teacher> teachers) {
        teachers.forEach(teacher -> teacher.updateClasses(this));
        this.teachers = teachers;
    }
}

 

선생님 (Teacher 엔티티)

package com.wedul.jpa.school;

import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

import javax.persistence.*;

@Getter
@NoArgsConstructor
@Entity
@Table
public class Teacher {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;

    private Integer age;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "classes_id", referencedColumnName = "id")
    private Classes classes;

    @Builder
    public Teacher(String name, Integer age, Classes classes) {
        this.name = name;
        this.age = age;
        this.classes = classes;
    }

    public void updateClasses(Classes classes) {
        this.classes = classes;
    }
}

 

조회용 querydsl

package com.wedul.jpa.school;

import com.querydsl.core.types.Projections;
import org.springframework.data.jpa.repository.support.QuerydslRepositorySupport;

import java.util.List;

import static com.wedul.jpa.school.QClasses.classes;
import static com.wedul.jpa.school.QTeacher.teacher;

public class ClassesCustomRepositoryImpl extends QuerydslRepositorySupport implements ClassesCustomRepository  {

    public ClassesCustomRepositoryImpl() {
        super(Classes.class);
    }


    @Override
    public List<Classes> findAllNoPageFetchJoin() {
        return from(classes)
                .leftJoin(classes.teachers).fetchJoin()
                .fetch();
    }

}

 

테스트 코드

@Test
void findAllNoPageFetchJoin() {
    // given
    List<InputRequest.TeacherRequest> teacherRequests = List.of(InputRequest.TeacherRequest.builder()
                    .age(12)
                    .name("name")
                    .build(), InputRequest.TeacherRequest.builder()
                    .age(23)
                    .name("name2")
                    .build(),
            InputRequest.TeacherRequest.builder()
                    .age(41)
                    .name("wedul")
                    .build()
    );

    List<InputRequest.TeacherRequest> teacherRequests2 = List.of(InputRequest.TeacherRequest.builder()
                    .age(22)
                    .name("name4")
                    .build(), InputRequest.TeacherRequest.builder()
                    .age(33)
                    .name("name5")
                    .build(),
            InputRequest.TeacherRequest.builder()
                    .age(44)
                    .name("name6")
                    .build()
    );

    // when
    sut.insert(InputRequest.builder()
            .className("1")
            .teacherRequestList(teacherRequests)
            .build());
    sut.insert(InputRequest.builder()
            .className("2")
            .teacherRequestList(teacherRequests2)
            .build());

    // then
    List<Classes> savedData = classesRepository.findAllNoPageFetchJoin();

    assertThat(savedData).hasSize(6);
}

카테시안 곱으로 중복된 classes row가 발생함

위의 코드에서 Classes와 Teacher은 oneToMany관계에 있고 쿼리를 조회할 시 join 카테시안 곱이 발생하기 때문에 위처럼 classes의 값이 2개 각 classes의 값마다 teacher이 3개씩 있다고 할때 총 6개의 collection을 반환한다. 이는 left join, inner join 상관없이 발생한다. 이를 해결하기 위해서는 Set collection을 사용하거나 아래와 같이 distinct()를 붙어서 해결할 수 있다.

 

@Override
public List<Classes> findAllPageWithJoinDistinct() {
    return from(classes)
            .leftJoin(classes.teachers, teacher)
            .distinct()
            .fetch();
}

중복 row가 distinct로 처리된 코드

 

반응형

 

Offset limt 사용 시 고려사항

근데 이상황에서 offset limit 구문을 사용하면 단순하게 생각했을 때 카테이션 곱으로 classes 2개 x teacher 3개 이기 때문에 그 중 상위 두개인 아래 파란색의 결과가 나올것이라고 생각할 수 있다.

[result]

  • class id : 1, List<> teach id : 1,2,3
  • class id : 1, List<> teach id : 1,2,3
  • class id : 1, List<> teach id : 1,2,3
  • class id : 2, List<> teach id : 4,5,6
  • class id : 2, List<> teach id : 4,5,6
  • class id : 2, List<> teach id : 4,5,6

 

하지만 결과는 아래처럼 classes가 distinct된 결과가 나오게 된다.,

[result]

  • class id : 1, List<> : teach id : 1,2,3
  • class id : 2, List<> : teach id : 4,5,6

 

querydsl

package com.wedul.jpa.school;

import com.querydsl.core.types.Projections;
import org.springframework.data.jpa.repository.support.QuerydslRepositorySupport;

import java.util.List;

import static com.wedul.jpa.school.QClasses.classes;
import static com.wedul.jpa.school.QTeacher.teacher;

public class ClassesCustomRepositoryImpl extends QuerydslRepositorySupport implements ClassesCustomRepository  {

    public ClassesCustomRepositoryImpl() {
        super(Classes.class);
    }

    @Override
    public List<Classes> findAllPageFetchJoin(int offset, int limit) {
        return from(classes)
                .leftJoin(classes.teachers).fetchJoin()
                .offset(offset)
                .limit(limit)
                .fetch();
    }
}

 

Test 코드

@Test
void selectFetchJoin() {
    // given
    List<InputRequest.TeacherRequest> teacherRequests = List.of(InputRequest.TeacherRequest.builder()
                    .age(12)
                    .name("name")
                    .build(), InputRequest.TeacherRequest.builder()
                    .age(23)
                    .name("name2")
                    .build(),
            InputRequest.TeacherRequest.builder()
                    .age(41)
                    .name("wedul")
                    .build()
    );

    List<InputRequest.TeacherRequest> teacherRequests2 = List.of(InputRequest.TeacherRequest.builder()
                    .age(22)
                    .name("name4")
                    .build(), InputRequest.TeacherRequest.builder()
                    .age(33)
                    .name("name5")
                    .build(),
            InputRequest.TeacherRequest.builder()
                    .age(44)
                    .name("name6")
                    .build()
    );

    // when
    sut.insert(InputRequest.builder()
            .className("1")
            .teacherRequestList(teacherRequests)
            .build());
    sut.insert(InputRequest.builder()
            .className("2")
            .teacherRequestList(teacherRequests2)
            .build());

    // then
    List<Classes> savedData = classesRepository.findAllPageFetchJoin(0, 10);

    assertThat(savedData).hasSize(2);
}

 

offset limit구문으로 distinct된 결과

 

 

그 이유는 쿼리를 실행하고 결과를 가져오는 hibernate 코드를 살펴보면 알수있다. QueryTranslatorImpl에 보면 limit이 주어진 쿼리인지 보고 주어진 쿼리일 경우면서 collectionFetches 쿼리일경우에 아래처럼 distinct가 필요한 쿼리로서 판단하게 된다.

offset, limit이 있을경우 distinct로 판단하는 로직

continasCollectionFetches를 보면 알겠지만 join이 fetchjoin일때만 offset, limit 사용 시 distinct로직이 발생한다.

continasCollectionFetches 조건 확인 내용

 

그럼 값을 가져오는 과정을 살펴 보자. distinct가 필요하다고 판단한 상태에서 우선 현재 쿼리로 데이터를 가지고 오게되는데 이는 offset, limit이 없던 원래 join 쿼리처럼 resultList는 6개의 결과를 가지고 오게된다.

중복 제거 전 결과

 

하지만 그 다음 가져온 queryResultList를 가지고 최종 값을 산출하기 위한 QueryTranslatorImpl에서 needDistincting일 경우 IdentitySet을 사용해서 유일한 Classes의 값을 가지고 올수있도록 하고 있다.

offset limit시 중복을 제거하는 로직

 

위와 같은 이유로 jpa에서 join시에 중복으로 나올 수 있는 응답이 distinct된 결과를 가지고 오게 되는걸 확인할 수 있다.

 

이렇게 oneToMany관계에서 fetchjoin을 사용할 경우 offset, limit 또는 distinct를 사용해서 중복된 값을 제외하는것을 확인해봤다. 하지만 fetch join에서는 offset limit을 사용할 경우 쿼리 조회 결과에서 페이징을 할 수 없기 때문에 애플리케이션에 값을 가져와서 그 값을 메모리에서 페이징 처리하기 때문에 사용에 조심해야한다. 이 부분은 hibernate에서도 warn log로 아래와 같이 안내하고 있다.

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

 

반응형