JPA QueryDSL를 사용하면서 연관관계에 있는 데이터를 가져와야할 상황이 많이 있습니다. 이때 보통 Join
을 사용하거나 Fetch Join
을 사용하여 데이터를 가져올 수 있지만 DTO가 아닌 Entity를 가져옵니다.
따라서 Entity 조회 후 -> DTO로 변환해야 하는데, 뿐만 아니라 연관 관계에 있는 데이터들도 DTO로 변환하게 된다면 시간 복잡도는 배로 들 수 있습니다.
또한 코드 부분도 복잡해져 QueryDSL를 이용하여 한번에 DTO에 연관관계까지 모두 넣을 수 있는 방법을 소개하겠습니다.
다소 비효율적인 방법이긴 한데 많이 사용하는 방법은 다음과 같습니다.
Fetch Join
을 사용하여 한번에 데이터를 가져온 후 for문을 통해 하나씩 DTO에 넣기 ( 2배 이상의시간 복잡도 )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
가 있는데, 해당 코드를 보면 다음과 같이 작동합니다.
관광 시설 ( attraction )
에 대해 left Join을 통해 관광 시설 태그 ( attractionTag )
데이터들을 전부 불러옵니다.
관광 시설 태그 ( 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()
를 보면 서브 쿼리를 통해 데이터를 추출하는 것을 볼 수 있습니다.
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