QueryDSL의 페이징 카테시안 곱 문제 해결 방법
복잡한 일대다 연관관계를 가진 데이터를 페이징 처리할 때 어떤 어려움을 겪은 적이 있으신가요? 저는 실무에서 페이징을 처리하다 보면 목록을 조회할 때 단순하게 하나의 연관관계가 아니라 일대다의 연관관계를 한두 개가 아닌 여러 개를 같이 조회할 경우가 생기게 됬었습니다. 이때 평소와 같이 페이징을 구현했더니 결과는 제대로 나오지 않았습니다. 이유는 바로 1:N 관계에서 발생하는 카테시안 곱 문제 때문입니다. JPA를 사용한다면 다들 한번쯤 겪어볼 문제이기 때문에 해결하기 위한 전략과 팁을 공유하기 위해 작성했습니다.
카테시안 곱(Cartesian Product)이란?
JPA에서의 카테시안 곱은 데이터베이스 쿼리 수행시 여러 테이블 간의 조인을 잘못 사용하여 발생하는 문제로, 예상보다 훨씬 많은 수의 결과 레코드를 생성하는 현상을 말합니다. 예를 들어 카테시안 곱은 두 테이블 A와 B가 있을 때, A의 모든 행이 B의 모든 행과 결합하여 집합을 생성하고, 이로 인해 두 테이블의 행 수를 곱한 만큼 결과가 생겨나게 됩니다.
페이징 처리 시 카테시안 곱 발생 예시
한 게시글에 여러 개의 이미지를 등록할 수 있는 1:N 관계로 되어있고 이미지가 3개씩 저장되어 있는 게시글 목록을 조회한다고 가정해 봅시다.
@Entity
@Getter
@Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Post extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column
private Long id;
@Column(nullable = false)
private String title;
@Column(nullable = false)
private String content;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id", referencedColumnName = "id")
private Member member;
@OneToMany(mappedBy = "post", orphanRemoval = true, cascade = CascadeType.ALL)
private List<PostImage> postImages = new ArrayList<>();
@OneToMany(mappedBy = "post", orphanRemoval = true, cascade = CascadeType.ALL)
private List<Hashtag> hashtags = new ArrayList<>();
}
- limit 9, offset 0인 게시글 목록을 조회합니다.
- limit가 9으로 요청이 들어왔을 때 이 의미는 9개의 게시글 목록을 달라는 의미입니다.
- 하지만 카테시안 곱으로 인해 9개의 게시글 목록이 아닌 3개의 게시물 목록을 반환합니다.
- 이때 카테시안 곱이란 1:N 관계에서 한 테이블의 각 행이 다른 테이블의 여러 행과 결합할 때, 결과는 두 테이블의 행 수의 곱으로 늘어나게 되는 연산을 의미합니다.
- 그러므로 9개의 게시물을 조회 하려면 (9x3 = 27)만큼의 limit가 필요하게 됩니다.
페이징 카테시안 곱 해결 방법
페이징 처리 시 카테시안 곱 문제를 해결하는 가장 기본적인 방법은 fetchJoin과 batchSize를 활용하는 것입니다.
@Test
void whenUsedBatchSizeAndFetchJoin() {
List<Post> posts = jpaQueryFactory.selectFrom(post)
.leftJoin(post.postImages)
.fetchJoin()
.offset(0)
.limit(9)
.fetch();
for (Post post : posts) {
post.getPostImages().stream()
.map(PostImage::getImageUrl)
.forEach(System.out::println);
}
}
- 먼저 기준이 되는 엔티티를 조회하고, batchSize를 설정하여 1:N 관계에 있는 테이블들을 각각 조회함으로써, 정상적인 결괏값을 얻을 수 있습니다.
- 이 과정에서, fetchJoin을 사용하지 않으면, N+1문제는 발생하지 않지만, 카테시안 곱이 발생하여 예상보다 많은 수의 행을 얻게 되고 결과적으로 Post는 총 3개의 행만을 가져오게 됩니다.
- 하지만 fetchJoin을 적용함으로써, 원하는 Post의 9개의 데이터를 정확하게 얻을 수 있습니다. 이 방법을 통해, 효율적으로 데이터를 조회하면서 카테시안 곱으로 인한 데이터 중복 문제와 성능 저하를 방지할 수 있습니다.
다중 fetchJoin의 한계
한 대상에 여러 fetchJoin이 사용되게 되면 MultipleBagFetchException이 발생합니다. Post에서 카테고리와 해시태그도 같이 조회 하려고 하지만 에러가 발생하여 조회하지 못합니다.
@Test
void whenUsedBatchSizeAndMultipleFetchJoin() {
List<Post> posts = jpaQueryFactory.selectFrom(post)
.leftJoin(post.postImages)
.fetchJoin()
.leftJoin(post.hashtags)
.fetchJoin()
.offset(0)
.limit(9)
.fetch(); //MultipleBagFetchException 발생
for (Post post : posts) {
post.getPostImages().stream()
.map(PostImage::getImageUrl)
.forEach(System.out::println);
post.getHashtags().stream()
.map(Hashtag::getTag)
.forEach(System.out::println);
}
}
다중 fetchJoin 해결하는 두 가지 방법
많이 고민하고 고민했던 첫 번째 방법은, batchSize를 활용한 객체 그래프 조회를 하는 것입니다.
- batchsize를 설정하면 Post의 하위 엔티티를 불러올 때 IN 절로 한 테이블에 하나만 나가게 되어 1:N관계의 페이징을 풀어낼 수 있습니다.
- 불필요한 필드 값도 함께 조회되어 성능이 떨어지지만 단순 조회 목적으로 간편하게 사용할 수 있습니다.
@Test
void whenReadPostEntity() {
//Post page
List<Post> posts = jpaQueryFactory.selectFrom(post)
.offset(0)
.limit(9)
.fetch();
for (Post post : posts) {
//get images In (postIds)
post.getPostImages().stream()
.map(PostImage::getImageUrl)
.forEach(System.out::println);
//get hashtags In (postIds)
post.getHashtags().stream()
.map(Hashtag::getTag)
.forEach(System.out::println);
}
}
두 번째 방법은, 1:N 관계의 엔티티를 따로 조회 하는 것입니다. 저는 이 방법을 주로 사용해서 페이징을 처리합니다.
- DTO 방식으로 매핑하고, 연관관계의 테이블을 분기하여 조회하는 방법으로 최적의 성능을 나타낼 수 있습니다.
- batchSize와 쿼리 나가는 방식은 동일하지만, 필요한 필드 값만 얻을 수 있고, 코드 재사용이 가능합니다.
- Post의 PK값을 기준으로 연관되어 있는 Hashtag와 PostImage에 대해 다시 조회해서 하나로 나타낸다면, 페이징의 다중 1:N 관계 처리를 할 수 있습니다.
@Test
void whenReadPost() {
//Post page
List<PostResBody> posts = jpaQueryFactory.select(PostResBody.qBean(post))
.from(post)
.offset(0)
.limit(9)
.fetch();
//get postIds
List<Long> postIds = posts.stream().map(PostResBody::getPostId).toList();
//get images In (postIds)
List<PostImageResBody> images = jpaQueryFactory.select(PostImageResBody.qBean(postImage, post))
.from(postImage)
.where(postImage.post.id.in(postIds))
.fetch();
//get hashtags In (postIds)
List<HashtagResBody> hashtags = jpaQueryFactory.select(HashtagResBody.qBean(hashtag, post))
.from(hashtag)
.where(hashtag.post.id.in(postIds))
.fetch();
//add post <- hashtags,images
posts.forEach(postResBody -> {
postResBody.setPostImages(images.stream()
.filter(postImageResBody -> Objects.equals(postImageResBody.getPostId(), postResBody.getPostId()))
.toList());
postResBody.setHashtags(hashtags.stream()
.filter(hashtagResBody -> Objects.equals(hashtagResBody.getPostId(), postResBody.getPostId()))
.toList());
});
}
이러한 방법들을 통해 QueryDSL의 페이징 카테시안 곱 문제 해결 방법에 대해 알아보았습니다. 제가 제시하는 방법이 정확하게 맞는다는 것은 아닙니다. 하지만 복잡한 관계의 페이징을 처리할 때 상당히 고민을 많이 했던 문제라 도움이 되셨으면 좋겠고, 더 좋은 방법이 있다면 공유 부탁드립니다. 감사합니다.
'Spring' 카테고리의 다른 글
[JPA] Batch Insert로 대량의 데이터 처리하기 (0) | 2024.02.27 |
---|---|
[JPA] JPQL개념과 사용법 패턴 정리 (0) | 2024.02.22 |
[JPA] QueryDSL에서 JPA N+1 문제 해결하기 (0) | 2024.02.13 |
[JPA] JPA의 연관관계 예제 정리 (1) | 2024.02.05 |
[JPA] 영속성 컨텍스트의 특징과 프록시 이해하기 (1) | 2024.02.01 |