SpringBoot - Querydsl이란? 설정방법(Querydsl 버전 5 업데이트)

devdo·2021년 12월 22일
2

SpringBoot

목록 보기
8/35
post-thumbnail

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);

Querydsl의 장점

Spring Data JPA가 기본적으로 제공해주는 CRUD 메서드 및 쿼리 메서드 기능을 사용하더라도, 원하는 조건의 데이터를 수집하기 위해서는 필연적으로 JPQL을 작성하게 됩니다. 간단한 로직을 작성하는데 큰 문제는 없으나, 복잡한 로직의 경우 개행이 포함된 쿼리 문자열이 상당히 길어집니다.

JPQL 문자열에 오타 혹은 문법적인 오류가 존재하는 경우, 정적 쿼리라면 어플리케이션 로딩 시점에 이를 발견할 수 있으나 그 외는 런타임 시점에서 에러가 발생합니다.

이러한 문제를 어느 정도 해소하는데 기여하는 프레임워크가 바로 Querydsl입니다. Querydsl은 정적 타입을 이용해서 SQL 등의 쿼리를 생성해주는 오픈소스 프레임워크입니다. Querydsl의 장점은 다음과 같습니다.

  1. 문자가 아닌 코드로 쿼리를 작성함으로써, 컴파일 시점에 문법 오류를 쉽게 확인할 수 있다.
  2. 자동 완성 등 IDE의 도움을 받을 수 있다.
  3. 동적인 쿼리 작성이 편리하다.
  4. 쿼리 작성 시 제약 조건 등을 메서드 추출을 통해 재사용할 수 있다.

물론 Querydsl을 사용하기 위해서는 다소 번거로운 Gradle 설정 및 사용법 등을 익혀야한다는 단점이 존재합니다. 하지만 JPQL이 익숙하다면 Querydsl을 이해하는데 큰 어려움이 없을 것으로 예상됩니다.


Querydsl 설정 참고 사이트

자세한 내용, 설정은 여기 블로그에 참고하면 됩니다.
https://velog.io/@juhyeon1114/Spring-QueryDsl-gradle-설정-Spring-boot-3.0-이상


SpringBoot3.X 설정(🌟Querydsl 5버전, 2024업데이트)

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클래스가 다시 생긴다.



SpringBoot 2.x 버전 설정

//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 추가 끝
    

compileQuerydsl로 Q객체 생성

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/


Querydsl 예제

// 기본 인스턴스를 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);
    }
    
 }

✅ QueryDslConfig

따로 EntityManager를 파라미터로 삼는 JPAQueryFactory를 Bean으로 등록하여 전역에서 쉽게 JPAQueryFactory를 사용할 수 있게 할 수 있다.

@Configuration
public class QueryDslConfig {

    @PersistenceContext
    private EntityManager entityManager;

    @Bean
    public JPAQueryFactory jpaQueryFactory() {
        return new JPAQueryFactory(entityManager);
    }
}

RepositoryCustomImpl 구현 (최종 Querydsl 예제)

참고 : 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 최적화란


...
// 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 vs BooleanBuilder 차이

BooleanExpression은 member.age.eq(xx) 같은 경우처럼 표현식의 결과로 반환되는 값입니다.

BooleanBuilder는 이런 표현식을 모아서 사용할 수 있도록 도와주는 도구로 이해하시면 됩니다.


결과 조회

  • fetch() : 리스트 조회, 데이터 없으면 빈 리스트 반환
  • fetchOne() : 단 건 조회
    • 결과가 없으면 : null
    • 결과가 둘 이상이면 : com.querydsl.core.NonUniqueResultException
  • fetchFirst() : limit(1), fetchOne()
  • fetchResults() : 페이징 정보 포함, total count 쿼리 추가 실행
  • fetchCount() : count 쿼리로 변경해서 count 수 조회

테스트 구성

JPAQueryFactory 로 조회하기 (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);
    }

    @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)
        );

    }
}

Paging 내 검색조건 처리

querydsl로 Paging 내 검색조건(Predicate)을 쉽게 처리할 수 있다.

방식은 크게 2가지가 있는데,
첫째, QuerydslPredicateExecutor
두번째, QuerydslRepositorySupport 방식이 있다.

1) QuerydslPredicateExecutor 인터페이스 지원

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()을 사용하여 검색쿼리와 카운트쿼리를 나눠야 한다.
쿼리의 효요성을 위해서다.

Test

@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)
        );

    }
}

2) 레포지토리 QuerydslRepositorySupport 지원

이 내용은 다음 블로그를 참고
https://kingchan223.tistory.com/m/391


Querydsl 내 OrderBy 메서드 구현(OrderSpecifier 클래스)

@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);
    }
}


출처

profile
배운 것을 기록합니다.

0개의 댓글