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와 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();
}
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);
}
그 이유는 쿼리를 실행하고 결과를 가져오는 hibernate 코드를 살펴보면 알수있다. QueryTranslatorImpl에 보면 limit이 주어진 쿼리인지 보고 주어진 쿼리일 경우면서 collectionFetches 쿼리일경우에 아래처럼 distinct가 필요한 쿼리로서 판단하게 된다.
continasCollectionFetches를 보면 알겠지만 join이 fetchjoin일때만 offset, limit 사용 시 distinct로직이 발생한다.
그럼 값을 가져오는 과정을 살펴 보자. distinct가 필요하다고 판단한 상태에서 우선 현재 쿼리로 데이터를 가지고 오게되는데 이는 offset, limit이 없던 원래 join 쿼리처럼 resultList는 6개의 결과를 가지고 오게된다.
하지만 그 다음 가져온 queryResultList를 가지고 최종 값을 산출하기 위한 QueryTranslatorImpl에서 needDistincting일 경우 IdentitySet을 사용해서 유일한 Classes의 값을 가지고 올수있도록 하고 있다.
위와 같은 이유로 jpa에서 join시에 중복으로 나올 수 있는 응답이 distinct된 결과를 가지고 오게 되는걸 확인할 수 있다.
이렇게 oneToMany관계에서 fetchjoin을 사용할 경우 offset, limit 또는 distinct를 사용해서 중복된 값을 제외하는것을 확인해봤다. 하지만 fetch join에서는 offset limit을 사용할 경우 쿼리 조회 결과에서 페이징을 할 수 없기 때문에 애플리케이션에 값을 가져와서 그 값을 메모리에서 페이징 처리하기 때문에 사용에 조심해야한다. 이 부분은 hibernate에서도 warn log로 아래와 같이 안내하고 있다.
firstResult/maxResults specified with collection fetch; applying in memory!
'web > JPA' 카테고리의 다른 글
query specified join fetching, but the owner of the fetched association was not present in the select list 설명과 문제해결 (0) | 2022.12.10 |
---|---|
MultipleBagFetchException 문제 발생 (0) | 2022.12.04 |
QueryDsl에서 delete limit 문법을 사용할 수 없는 이유 (1) | 2021.06.11 |
스프링 부트에서 사용하는 JPA 기능 정리 (3) | 2018.11.04 |
@MappedSuperclass를 이용한 부모 매핑정보 사용하기 (1) | 2018.11.03 |