[ Java ] QueryDSL transform을 사용하여 복잡한 연관관계 한번에 가져와 DTO에 넣기

5tr1ker·2024년 6월 23일
0

Java

목록 보기
8/8
post-thumbnail

개요

JPA QueryDSL를 사용하면서 연관관계에 있는 데이터를 가져와야할 상황이 많이 있습니다. 이때 보통 Join을 사용하거나 Fetch Join을 사용하여 데이터를 가져올 수 있지만 DTO가 아닌 Entity를 가져옵니다.

따라서 Entity 조회 후 -> DTO로 변환해야 하는데, 뿐만 아니라 연관 관계에 있는 데이터들도 DTO로 변환하게 된다면 시간 복잡도는 배로 들 수 있습니다.

또한 코드 부분도 복잡해져 QueryDSL를 이용하여 한번에 DTO에 연관관계까지 모두 넣을 수 있는 방법을 소개하겠습니다.

transform 없이 구현 방법

다소 비효율적인 방법이긴 한데 많이 사용하는 방법은 다음과 같습니다.

  1. Fetch Join 을 사용하여 한번에 데이터를 가져온 후 for문을 통해 하나씩 DTO에 넣기 ( 2배 이상의시간 복잡도 )
  2. 부모 객체를 우선 조회 후 하나씩 연관 객체를 검색 ( 1 : N 문제 )

비 효율적인 방법 예시

List<AttractionResponse> result = jpaQueryFactory.select(Projections.constructor(AttractionResponse.class,
                        attraction.attractionId,
                        attraction.imageUrl,
                        Projections.list(
                                attractionTag.tagContent
                        ),
                        attraction.description
                ))
                .from(attraction)
                .leftJoin(attraction.attractionTagRelationShips, attractionTagRelationShip)
                .leftJoin(attractionTagRelationShip.attractionTag, attractionTag)
                .fetch();

위의 코드는 관광 시설 ( attraction )관광 시설 태그 ( attractionTag ) 가 N:N 구조로 되어있습니다. 그 사이에는 N : N 구조를 연결하는 attractionTagRelationShips 가 있는데, 해당 코드를 보면 다음과 같이 작동합니다.

  1. 관광 시설 ( attraction ) 에 대해 left Join을 통해 관광 시설 태그 ( attractionTag ) 데이터들을 전부 불러옵니다.

  2. 관광 시설 태그 ( attractionTag ) 를 List로 모두 받기 위해서 Projections.list 를 통해 데이터들을 전부 가져옵니다.

{
	"attractionId" : 1,
    "imageUrl" : "imageUrl",
    ["tagContent" : "content1"],
    "description" : "description"
},
{
	"attractionId" : 1,
    "imageUrl" : "imageUrl",
    ["tagContent" : "content2"],
    "description" : "description"
},
{
	"attractionId" : 2,
    "imageUrl" : "imageUrl",
    ["tagContent" : "content1"],
    "description" : "description"
}
{
	"attractionId" : 2,
    "imageUrl" : "imageUrl",
    ["tagContent" : "content2"],
    "description" : "description"
}

결과 값은 다음과 같습니다.
Attraction Id 1 에는 태그가 각각 content1 , content2 가 있고
Attraction Id 2 에는 태그가 각각 content1 , content2 가 있습니다.

원래대로라면 한번에 나와야하는데, 연관관계 갯수만큼 N개의 데이터가 추가된 것을 볼 수 있습니다.

이는 다음 코드와 같이 후 처리를 해야 정상적으로 데이터가 출력되는 것을 볼 수 있습니다.

Map<Long, AttractionResponse> attractionMap = new HashMap<>();
        for (AttractionResponse response : result) {
            if (!attractionMap.containsKey(response.getAttractionId())) {
                AttractionResponse newResponse = new AttractionResponse(
                        response.getAttractionId(),
                        response.getImageUrl(),
                        new ArrayList<>(),
                        response.getDescription()
                );
                attractionMap.put(response.getAttractionId(), newResponse);
            }
            
            attractionMap.get(response.getAttractionId()).getTag().add(response.getTag().get(0));
        }

        return new ArrayList<>(attractionMap.values());

비 효율적인 방법 코드

// queryDSL
public List<PartnerRequestResponse> findAllEntity(Pageable pageable) {

        List<PartnerRequest> result = jpaQueryFactory
                .select(partnerRequest)
                .from(partnerRequest)
                .innerJoin(partnerRequest.requestUser)
                .leftJoin(partnerRequest.partnerRecommenders)
                .leftJoin(partnerRequest.partnerComment)
                .offset(pageable.getOffset())
                .limit(pageable.getPageSize())
                .fetch();

        return result.stream()
                .map(PartnerRequestResponse::createResponse)
                .collect(Collectors.toList());
    }
    
    
// PartnerRequestResponse
public static PartnerRequestResponse createResponse(PartnerRequest request) {
        List<PartnerCommentResponse> messageList = request.getPartnerComment().stream()
                .map(PartnerCommentResponse::createResponseData)
                .collect(Collectors.toList());

        return PartnerRequestResponse.builder()
                .partnerRequestId(request.getPartnerRequestId())
                .requestMarketName(request.getRequestMarketName())
                .marketAddress(request.getMarketAddress())
                .requestUserKey(request.getRequestUser().getAccountId())
                .requestUserEmail(request.getRequestUser().getEmail())
                .writeTime(request.getWriteTime())
                .recommendCount(request.getPartnerRecommenders().size())
                .resultMessage(messageList)
                .build();
    }

다음과 같은 코드는 검색 대상의 모든 엔티티를 조회하고 해당 연관된 데이터를 Stream 객체를 이용해 하나씩 조회하는 코드를 보실 수 있습니다.
순회를 통해 불필요한 조회가 더 발생하는 것을 확인할 수 있습니다.

해결 방법

 public List<PartnerRequestResponse> findAllEntity(Pageable pageable) {
        QAccount commentAccount = new QAccount("commentAccount");

        return jpaQueryFactory.selectFrom(partnerRequest)
                .innerJoin(partnerRequest.requestUser, account)
                .leftJoin(partnerRequest.partnerComment, partnerComment)
                .leftJoin(partnerComment.writer, commentAccount)
                .offset(pageable.getOffset())
                .limit(pageable.getPageSize())
                .transform(groupBy(partnerRequest.partnerRequestId).list(
                        Projections.constructor(
                                PartnerRequestResponse.class,
                                partnerRequest.partnerRequestId,
                                partnerRequest.requestMarketName,
                                partnerRequest.marketAddress,
                                account.accountId,
                                account.email,
                                partnerRequest.writeTime,
                                select(partnerRecommender.count()).from(partnerRecommender).where(partnerRecommender.partnerRequest.eq(partnerRequest)),
                                list(Projections.constructor(
                                        PartnerCommentResponse.class,
                                        partnerComment.partnerCommentId,
                                        partnerComment.message,
                                        commentAccount.email,
                                        partnerComment.writeTime
                                ))
                        )
                ));
    }

위와 아래는 같은 결과값을 도출합니다. 한눈에 봐도 한번의 쿼리로 데이터들을 한번에 가져올 수 있습니다. select(partnerRecommender.count() 부분은 groupBy 가 집계함수와 연관되어 PartnerComment 를 1개만 가져오게 되는 현상을 방지하기 위해 서브 쿼리를 작성했습니다.

해결법

public List<AttractionResponse> findByRecommend(Pageable pageable) {
        return jpaQueryFactory
                .selectFrom(attraction)
                .leftJoin(attraction.attractionTagRelationShips, attractionTagRelationShip)
                .leftJoin(attractionTagRelationShip.attractionTag, attractionTag)
                .transform(groupBy(attraction.attractionId).list(
                        Projections.constructor(
                                AttractionResponse.class,
                                attraction.attractionId,
                                attraction.imageUrl,
                                list(
                                        attractionTag.tagContent
                                ),
                                attraction.description
                        )
                ));
    }

위의 코드는 그 위에 코드에서 개선한 사항입니다. transform 객체를 사용한 것을 볼 수 있습니다.
먼저 groupBy 를 통해 기준인 부모 엔티티를 기준으로 설정 후에 list 객체를 통해 여러 데이터들을 직접 DTO에 담아주는 로직을 작성할 수 있습니다. attractionTag.tagContent 데이터는 List<String> 타입으로 반환하기 때문에 Projections.constructor 를 사용하지 않았지만 추후 필요에 따라 사용하셔도 무방합니다.

이때 groupBy 와 list 는 다음과 같은 패키지에서 import 해야 합니다.
import static com.querydsl.core.group.GroupBy.groupBy;
import static com.querydsl.core.group.GroupBy.list;

활용 방안

jpaQueryFactory.selectFrom(review)
                .innerJoin(review.market, market)
                .innerJoin(review.reviewer, account)
                .leftJoin(review.reviewImage, reviewImage).on(reviewImage.isDelete.eq(false))
                .orderBy(new OrderSpecifier<>(Order.DESC, jpaQueryFactory.select(reviewRecommender.count())
                                .from(reviewRecommender)
                                .where(reviewRecommender.review.eq(review))),
                        review.content.length().desc())
                .offset(pageable.getOffset())
                .limit(pageable.getPageSize())
                .where(review.isDelete.eq(false))
                .transform(groupBy(review.reviewId)
                        .list(Projections.constructor(
                                ReviewResponse.class,
                                review.reviewId,
                                account.accountId,
                                account.email,
                                review.content,
                                review.writeTime,
                                market.marketId,
                                market.marketName,
                                review.score,
                                jpaQueryFactory.select(reviewRecommender.count()).from(reviewRecommender).where(reviewRecommender.review.eq(review)),
                                list(Projections.constructor(ReviewImageResponse.class,
                                        reviewImage.reviewImageId,
                                        reviewImage.imageUrl))
                        ))
                );

위의 코드는 Review 데이터를 가져오고 연관 관계에 있는 리뷰 이미지 ( ReviewImage ) 와 리뷰 추천 횟수 ( reviewRecommender ) 를 가져오는 로직입니다.

orderBy 부분에서 왜 새로운 select 문을 사용했냐면 한 review 데이터 별로 reviewImage 를 가져오기 위해 groupBy를 사용하게 된 것이, 집계 함수가 reviewRecommender 로 설정이 되어 reviewImage 데이터가 하나만 불러오게 되어 서브 쿼리로 따로 작성했습니다.

같은 이유로 맨 하단에 reviewRecommender.count() 를 보면 서브 쿼리를 통해 데이터를 추출하는 것을 볼 수 있습니다.

transform 발생 시 오류

Caused by: java.lang.NoSuchMethodError: 'java.lang.Object org.hibernate.ScrollableResults.get(int)'

위의 예외는 Spring Boot 3.x 버전 이후로 부터 transform을 사용할 수 없어 발생하는 예외입니다.

이를 해결하기 위해서는 QueryDslConfig 를 Default 모드로 변경해주어야 합니다.
따라서 위와 같이 수정하게 되면 문제를 해결할 수 있습니다.

import com.querydsl.jpa.JPQLTemplates
import com.querydsl.jpa.impl.JPAQueryFactory
import jakarta.persistence.EntityManager
import jakarta.persistence.PersistenceContext
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration

@Configuration
@RequireArgsConstructors
class QueryDslConfig {
    
    private final entityManager EntityManager

    @Bean
    function jpaQueryFactory JPAQueryFactory {
        return JPAQueryFactory(JPQLTemplates.DEFAULT, entityManager); // 해당 부분 
    }
}

참고

참고 블로그 1 : https://velog.io/@dktlsk6/QueryDSL-transform-%EC%97%90%EB%9F%AC
참고 블로그 2 : https://velog.io/@songunnie/Spring-QueryDSL-transform%EC%9D%84-%EC%82%AC%EC%9A%A9%ED%95%98%EB%8A%94-%EC%9D%B4%EC%9C%A0

profile
https://github.com/5tr1ker

0개의 댓글