JPQL 예시코드
@Query("select p from Post p join fetch p.user u "
+ "where u in "
+ "(select t from Follow f inner join f.target t on f.source = :user) "
+ "or u = :user "
+ "order by p .createdAt desc")
List<Post> findAllAssociatedPostsByUser(@Param("user") User user, Pageable pageable);
Spring Data JPA가 기본적으로 제공해주는 CRUD 메서드 및 쿼리 메서드 기능을 사용하더라도, 원하는 조건의 데이터를 수집하기 위해서는 필연적으로 JPQL을 작성하게 됩니다. 간단한 로직을 작성하는데 큰 문제는 없으나, 복잡한 로직의 경우 개행이 포함된 쿼리 문자열이 상당히 길어집니다.
JPQL 문자열에 오타 혹은 문법적인 오류가 존재하는 경우, 정적 쿼리라면 어플리케이션 로딩 시점에 이를 발견할 수 있으나 그 외는 런타임 시점에서 에러가 발생합니다.
이러한 문제를 어느 정도 해소하는데 기여하는 프레임워크가 바로 Querydsl입니다. Querydsl은 정적 타입을 이용해서 SQL 등의 쿼리를 생성해주는 오픈소스 프레임워크입니다. Querydsl의 장점은 다음과 같습니다.
- 문자가 아닌 코드로 쿼리를 작성함으로써, 컴파일 시점에 문법 오류를 쉽게 확인할 수 있다.
- 자동 완성 등 IDE의 도움을 받을 수 있다.
- 동적인 쿼리 작성이 편리하다.
- 쿼리 작성 시 제약 조건 등을 메서드 추출을 통해 재사용할 수 있다.
물론 Querydsl을 사용하기 위해서는 다소 번거로운 Gradle 설정 및 사용법 등을 익혀야한다는 단점이 존재합니다. 하지만 JPQL이 익숙하다면 Querydsl을 이해하는데 큰 어려움이 없을 것으로 예상됩니다.
자세한 내용, 설정은 여기 블로그에 참고하면 됩니다.
https://velog.io/@juhyeon1114/Spring-QueryDsl-gradle-설정-Spring-boot-3.0-이상
build.gradle
// QueryDSL 버전정보 추가
buildscript {
ext {
queryDslVersion = "5.0.0"
}
}
plugins {
id 'java'
...
// id "com.ewerk.gradle.plugins.querydsl" version "1.0.10" // ⭐ Querydsl 플러그인 사용 X
}
dependencies {
...
// ⭐ Spring boot 3.x이상에서 QueryDsl 패키지를 정의하는 방법
implementation "com.querydsl:querydsl-jpa:${queryDslVersion}:jakarta"
annotationProcessor(
"jakarta.persistence:jakarta.persistence-api",
"jakarta.annotation:jakarta.annotation-api",
"com.querydsl:querydsl-apt:${queryDslVersion}:jakarta")
// === ⭐ QueryDsl 빌드 옵션 (선택) ===
def querydslDir = "$buildDir/generated/querydsl"
sourceSets {
main.java.srcDirs += [ querydslDir ]
}
tasks.withType(JavaCompile) {
options.annotationProcessorGeneratedSourcesDirectory = file(querydslDir)
}
clean.doLast {
file(querydslDir).deleteDir()
}
✳️신기하게 src/main 내 generated 파일에도 Q클래스들이 생긴다!
💥 Attempt to recreate a file for type 오류
build clean을 해주면 오류 해결!
build -> clean을 하게되면 generated 폴더가 사라진다.
다시 compileJava or build 를 해주면 Q클래스가 다시 생긴다.
//plugins에 추가
plugins {
id 'org.springframework.boot' version '2.4.5'
id 'io.spring.dependency-management' version '1.0.11.RELEASE'
id 'java'
//querydsl 추가
id 'com.ewerk.gradle.plugins.querydsl' version '1.0.10'
}
// dependencies 에 추가
//querydsl 추가
implementation 'com.querydsl:querydsl-jpa'
implementation 'com.querydsl:querydsl-apt'
//def querydslDir = 'src/main/generated'
def querydslDir = "$buildDir/generated/querydsl"
querydsl {
library = "com.querydsl:querydsl-apt"
jpa = true
querydslSourcesDir = querydslDir
}
sourceSets {
main {
java {
srcDirs = ['src/main/java', querydslDir]
}
}
}
compileQuerydsl{
options.annotationProcessorPath = configurations.querydsl
}
configurations {
querydsl.extendsFrom compileClasspath
}
스프링 부트 2.6 이상, Querydsl 5.0부터 설정 방법이 달라졌습니다.
: querydsl-jpa , querydsl-apt 를 추가하고 버전을 명시해야 합니다.
buildscript {
ext {
queryDslVersion = "5.0.0"
}
}
plugins {
id 'java'
id 'org.springframework.boot' version '2.7.7' // 2.6 이상일 때 적용
id 'io.spring.dependency-management' version '1.0.15.RELEASE'
//querydsl 추가
id "com.ewerk.gradle.plugins.querydsl" version "1.0.10"
}
...
//querydsl 추가
implementation "com.querydsl:querydsl-jpa:${queryDslVersion}"
annotationProcessor "com.querydsl:querydsl-apt:${queryDslVersion}"
//querydsl 추가 시작
def querydslDir = "$buildDir/generated/querydsl"
querydsl {
jpa = true
querydslSourcesDir = querydslDir
}
sourceSets {
main.java.srcDir querydslDir
}
configurations {
querydsl.extendsFrom compileClasspath
}
compileQuerydsl {
options.annotationProcessorPath = configurations.querydsl
}
//querydsl 추가 끝
Gradle > Tasks > other > compileQuerydsl 클릭 -> Q 객체
(build내 generated 폴더)가 생성!
or
./gradlew clean compileQuerydsl
만약 안보인다면 compileQuerydsl > generated 폴더
Show Excluded Files
클릭
Q 타입 생성된 거 확인!
build > generated > querydsl
Q 타입 객체 Code
❗ Q타입 객체는 git에 등록되면 안된다.
.gitignore
에 등록되어야 한다. 디폴트로 등록되어 있을 것!
앞서 설정에서 생성 위치를 gradle build 폴더 아래 생성되도록 했기 때문에 이 부분도 자연스럽게 해결된다. (대부분 gradle build 폴더를 git에 포함하지 않는다.)
그래서 프로젝트를 git으로 새로 열면 Q객체가 없을 것이고 compileQuerydsl을 따로 해줘야 한다.
querydsl join이 정말 쉽다.
참고 https://hjhng125.github.io/querydsl/querydsl-join/
// 기본 인스턴스를 static import와 함께 사용
import static com.study.querydsl.entity.QOrder.*;
import static com.study.querydsl.entity.QMember.*;
@Repository
public class MemberJpaRepository {
private final EntityManager em;
private final JPAQueryFactory queryFactory;
public MemberJpaRepository(EntityManager em) {
this.em = em;
this.queryFactory = new JPAQueryFactory(em);
}
// querydsl
public List<Order> findAllQuerydsl(OrderSearch orderSearch) {
// Q객체들은 static import도 가능해
// QOrder order = QOrder.order;
// QMember member = QMember.member;
// condition 추가 -> where()
// JPAQueryFactory query = new JPAQueryFactory(em);
return queryFactory
.select(order)
.from(order)
.join(order.member, member)
.where(statusEq(orderSearch.getOrderStatus()), namelike(orderSearch.getMemberName())) // 동적 쿼리
// .where(order.status.eq(orderSearch.getOrderStatus())) // 정적쿼리
.limit(1000)
.fetch();
}
// member.name.like(orderSearch.getMemberName() 대신 메서드로 정리
private BooleanExpression namelike(String memberName) {
if (!StringUtils.hasText(memberName)) {
return null;
}
return member.name.like(memberName);
}
private BooleanExpression statusEq(OrderStatus statusCond) {
if (statusCond == null) {
return null;
}
return order.status.eq(statusCond);
}
}
따로 EntityManager를 파라미터로 삼는 JPAQueryFactory를 Bean으로 등록하여 전역에서 쉽게 JPAQueryFactory를 사용할 수 있게 할 수 있다.
@Configuration
public class QueryDslConfig {
@PersistenceContext
private EntityManager entityManager;
@Bean
public JPAQueryFactory jpaQueryFactory() {
return new JPAQueryFactory(entityManager);
}
}
참고 : https://github.com/mooh2jj/roadbook2__shop.git
ItemRepository
public interface ItemRepository extends JpaRepository<Item, Long>,
QuerydslPredicateExecutor<Item>, ItemRepositoryCustom {
List<Item> findByName(String name);
}
ItemRepositoryCustom
public interface ItemRepositoryCustom {
Page<Item> getAdminItemPage(ItemSearchDto itemSearchDto, Pageable pageable);
Page<MainItemDto> getMainItemPage(ItemSearchDto itemSearchDto, Pageable pageable);
}
ItemRepositoryCustomImpl
@RequiredArgsConstrctor
public class ItemRepositoryCustomImpl implements ItemRepositoryCustom {
private final JPAQueryFactory queryFactory;
// public ItemRepositoryCustomImpl(EntityManager em) {
// this.queryFactory = new JPAQueryFactory(em);
// }
private BooleanExpression searchShellStatusEq(ItemSellStatus searchSellStatus) {
return searchSellStatus == null ? null : QItem.item.status.eq(searchSellStatus);
}
private BooleanExpression regDtsAfter(String searchDateType){
LocalDateTime dateTime = LocalDateTime.now();
if(StringUtils.equals("all", searchDateType) || searchDateType == null){
return null;
} else if(StringUtils.equals("1d", searchDateType)){
dateTime = dateTime.minusDays(1);
} else if(StringUtils.equals("1w", searchDateType)){
dateTime = dateTime.minusWeeks(1);
} else if(StringUtils.equals("1m", searchDateType)){
dateTime = dateTime.minusMonths(1);
} else if(StringUtils.equals("6m", searchDateType)){
dateTime = dateTime.minusMonths(6);
}
return QItem.item.createdAt.after(dateTime);
}
private BooleanExpression searchByLike(String searchBy, String searchQuery){
if(StringUtils.equals("itemNm", searchBy)){
return QItem.item.name.like("%" + searchQuery + "%");
} else if(StringUtils.equals("createdBy", searchBy)){
return QItem.item.createdBy.like("%" + searchQuery + "%");
}
return null;
}
@Override
public Page<Item> getAdminItemPage(ItemSearchDto itemSearchDto, Pageable pageable) {
QueryResults<Item> results = queryFactory
.selectFrom(QItem.item)
.where(regDtsAfter(itemSearchDto.getSearchDateType()),
searchShellStatusEq(itemSearchDto.getSearchSellStatus()),
searchByLike(itemSearchDto.getSearchBy(),
itemSearchDto.getSearchQuery()))
.orderBy(QItem.item.id.desc())
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetchResults(); // 조회 대상 리스트 및 전체 개수를 포함하는 QueryResults 반환
List<Item> content = results.getResults();
long total = results.getTotal();
return new PageImpl<>(content, pageable, total);
}
private BooleanExpression itemNmLike(String searchQuery) {
return StringUtils.isEmpty(searchQuery) ? null : QItem.item.name.like("%" + searchQuery + "%");
}
// Dto로 바로 변환하는 방식, 비추... Dto변환은 비즈니스 로직에서
@Override
public Page<MainItemDto> getMainItemPage(ItemSearchDto itemSearchDto, Pageable pageable) {
QItem item = QItem.item;
QItemImg itemImg = QItemImg.itemImg;
QueryResults<MainItemDto> results = queryFactory
.select(
new QMainItemDto(
item.id,
item.name,
item.description,
itemImg.imgUrl,
item.price
)
)
.from(itemImg)
.join(itemImg.item, item)
.where(itemImg.regImgYn.eq("Y"))
.where(itemNmLike(itemSearchDto.getSearchQuery()))
.orderBy(item.id.desc())
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetchResults();
List<MainItemDto> content = results.getResults();
long total = results.getTotal();
return new PageImpl<>(content, pageable, total);
}
}
...
// PageableExecutionUtils로 최적화 && countQuery
@Override
public Page<MainItemDto> getMainItemPage(ItemSearchDto itemSearchDto, Pageable pageable) {
QItem item = QItem.item;
QItemImg itemImg = QItemImg.itemImg;
List<MainItemDto> mainItemDtoList = queryFactory.select(new QMainItemDto(
item.id,
item.name,
item.description,
itemImg.imgUrl,
item.price))
.from(itemImg)
.join(itemImg.item, item)
.where(itemImg.regImgYn.eq("Y"))
.where(itemNmLike(itemSearchDto.getSearchQuery()))
.orderBy(item.id.desc())
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();
JPAQuery<Item> countQuery = queryFactory
.select(item)
.from(itemImg)
.join(itemImg.item, item)
.where(itemImg.regImgYn.eq("Y"))
.where(itemNmLike(itemSearchDto.getSearchQuery()));
return PageableExecutionUtils.getPage(mainItemDtoList, pageable, countQuery::fetchCount);
}
PageableExecutionUtils 최적화 방식
- count 쿼리가 생략 가능한 경우 생략해서 처리
1) 페이지 시작이면서 컨텐츠 사이즈가 페이지 사이즈보다 작을 때
2) 마지막 페이지 일 때 (offset + 컨텐츠 사이즈를 더해서 전체 사이즈 구함)
BooleanExpression
은 member.age.eq(xx) 같은 경우처럼 표현식의 결과로 반환되는 값입니다.
BooleanBuilder
는 이런 표현식을 모아서 사용할 수 있도록 도와주는 도구로 이해하시면 됩니다.
fetch()
: 리스트 조회, 데이터 없으면 빈 리스트 반환fetchOne()
: 단 건 조회fetchFirst()
: limit(1), fetchOne()fetchResults()
: 페이징 정보 포함, total count 쿼리 추가 실행fetchCount()
: count 쿼리로 변경해서 count 수 조회@Slf4j
@Import(JpaAuditConfig.class)
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
//@Rollback(value = false)
class ItemTest {
@Autowired
EntityManager em;
JPAQueryFactory queryFactory;
@Autowired
private ItemRepository itemRepository;
@BeforeEach
public void setup() {
queryFactory = new JPAQueryFactory(em);
}
@Test
public void saveTest() {
LongStream.rangeClosed(1, 10).forEach(i -> {
Item item = Item.builder()
.id(i)
.name("test 상품_" + i)
.price(10000 + (int) i)
.description("test_상품_상세_설명" + i)
.status(ItemSellStatus.SELL)
.stock(100)
.build();
itemRepository.save(item);
});
}
@Test
@Transactional
@DisplayName("querydsl 테스트")
public void queryTest() {
saveTest();
// QItem qItem = new QItem("i");
QItem qItem = QItem.item; // static import해서 item으로 바꿔서 쓸 수 있음.
List<Item> queryList = queryFactory
.select(qItem)
.from(qItem) // selectFrom(qItem)
.where(qItem.status.eq(ItemSellStatus.SELL)
.and(qItem.price.gt(10007))
.and(qItem.name.like("%" + "10" +"%"))
)
.orderBy(qItem.id.desc())
.fetch();
queryList.forEach( q ->
log.info("queryList: q= {}", q)
);
}
}
querydsl로 Paging 내 검색조건(Predicate)을 쉽게 처리할 수 있다.
방식은 크게 2가지가 있는데,
첫째, QuerydslPredicateExecutor
두번째, QuerydslRepositorySupport 방식이 있다.
QuerydslPredicateExecutor 인터페이스에서 정의한 find 관련 메서드를 이용해 조건에 맞는 데이터를 Page 객체로 받아오는 방식이다.
public interface QuerydslPredicateExecutor<T> {
// QuerydslPredicateExecutor 메서드들
Optional<T> findById(Predicate predicate);
Iterable<T> findAll(Predicate predicate);
long count(Predicate predicate);
boolean exists(Predicate predicate);
// _ more functionality omitted.
}
QuerydslPredicateExecutor<T>
추가
public interface ItemRepository extends JpaRepository<Item, Long>,
// ItemRepositoryCustom,
QuerydslPredicateExecutor<Item> {
}
이를 implements하면 QuerydslPredicateExecutor 인터페이스에 정의된 메서드를 모두 사용할 수 있다.
특히 메서드의 인자로 Predicate 타입을 직접 넘겨 주는 데, 이는 querydsl 사용시 where조건문에 넣었던 타입이다.
✅ 그중
Page<T> findAll(Predicate predicate, Pageable pageable);
이 메서드를 가져오기 위해 사용하는 것이다.
1) Page 객체를 불러올 때 JpaFactory 메서드로 offset(pageable.getOffset()), limit(pageable.getPageSize()) 를 꼭 같이 넣어줘야 한다. 안그럼 pagination 정보가 정확하게 떨어지지 않게 된다!
2) fetchJoin()은 연관된 테이블 당 각 1개로 설정해줘야 한다! 그래야 한방쿼리로서 매핑한 연관테이블이 들어가진다.
3) Page 처리를 할때 fetch(), fetchOne()을 사용하여 검색쿼리와 카운트쿼리를 나눠야 한다.
쿼리의 효요성을 위해서다.
@Slf4j
@Import(JpaAuditConfig.class)
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
//@Rollback(value = false)
class ItemTest {
@Autowired
EntityManager em;
JPAQueryFactory queryFactory;
@Autowired
private ItemRepository itemRepository;
@BeforeEach
public void setup() {
queryFactory = new JPAQueryFactory(em);
}
// page BooleanBuilder 사용하기 -> findAll(builder, pageable)을 쓸려면 필수!
@Test
@Transactional
@DisplayName("querydsl2- paging 테스트")
public void queryTest2() {
saveTest();
QItem qItem = QItem.item; // static import해서 item으로 바꿔서 쓸 수 있음.
// pageable 을 가져와서 buillder 파라미터로 처리
BooleanBuilder builder = new BooleanBuilder();
builder.and(qItem.status.eq(ItemSellStatus.SELL));
Pageable pageable = PageRequest.of(1, 5); // page, pageSize
Page<Item> itemPage = itemRepository.findAll(builder, pageable);
long totalElements = itemPage.getTotalElements();
log.info("totalElements: {}", totalElements); // total
List<Item> content = itemPage.getContent();
content.forEach( c ->
log.info("content: c= {}", c)
);
}
}
레포지토리 QuerydslRepositorySupport 지원
이 내용은 다음 블로그를 참고
https://kingchan223.tistory.com/m/391
@Repository
@RequiredArgsConstructor
public class ProductQuerydslRepository {
private final JPAQueryFactory queryFactory;
// @Query("select p, pi from Product p left join p.imageList pi where pi.ord = 0 and p.delFlag = false and p.pname like %:pname%")
// Page<Object[]> selectListBy(Pageable pageable, @Param("pname") String pname);
public Page<Product> findAllBy(Pageable pageable, String pname) {
QProduct product = QProduct.product;
QProductImage productImage = QProductImage.productImage;
// pageable의 sort 정보를 적용
OrderSpecifier[] orderSpecifiers = createOrderSpecifier(pageable.getSort());
List<Product> list = queryFactory
.select(product)
.from(product)
.leftJoin(product.imageList, productImage).on(productImage.ord.eq(0))
.where(product.delFlag.eq(false).and(product.pname.like("%" + pname + "%")))
.orderBy(orderSpecifiers)
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();
// pageable의 sort 정보를 querydsl에 적용
JPAQuery<Product> countQuery = queryFactory
.select(product)
.from(product)
.leftJoin(product.imageList, productImage).on(productImage.ord.eq(0))
.where(product.delFlag.eq(false).and(product.pname.like("%" + pname + "%"))); // contains NPE 발생
return PageableExecutionUtils.getPage(list, pageable, countQuery::fetchCount);
}
/** 🌟
* Sort 정보를 OrderSpecifier 배열로 변환
* @param sort Sort 정보
* @return OrderSpecifier 배열
*/
private OrderSpecifier [] createOrderSpecifier(Sort sort) {
return sort.stream()
.map(order -> new OrderSpecifier(
order.isAscending() ? Order.ASC : Order.DESC,
new PathBuilder<>(Product.class, "product").get(order.getProperty())
))
.toArray(OrderSpecifier[]::new);
}
}