QueryDSL 사용기

QueryDSL을 사용해보자.

오픈 소스 프로젝트이자 type-safe한 쿼리를 위한 Domain Specific Language이다.

  • Type safe란?

    기존 JPQL은 String으로 쿼리 문자열을 작성하여 오타나 잘못된 부분이 있는지 확인이 어렵다. 프로그램을 실행하고 나서야 오류 확인이 가능한 것도 단점 중 하나.

    게다가 데이터베이스의 스키마가 변경된다면 프로그램 안에서 그 부분과 관련된 쿼리 문자열이 모두 수정되어야 한다. JPA의 criretia도 필드이름을 String으로 넘기는 방식이라 type safe하지 않다. 또한 문법이 복잡하고.

    QueryDSL은 QEntity를 이용하여 도메인 객체에 수정이 일어나면 쿼리 코드에 컴파일 에러가 발생하게 된다. 컴파일 에러가 발생하는 코드를 수정하면 적어도 소프트웨어 동작 중에 쿼리가 잘못되어 에러가 발생하는 경우는 없다고 봐도 된다. 이런 것을 Type safe하다고 한다.

특징

  • 컴파일 타임에 오류 체크 가능
  • 동적쿼리를 Criteria API 보다 직관적으로 표현 가능
  • 문자가 아니라 코드로 작성 가능
  • IDE의 도움을 받을 수 있다.
  • JPA가 지원해주진 않아서 별도로 의존성 추가 필요

Gradle 설정

plugins{
    id "com.ewerk.gradle.plugins.querydsl" version "1.0.10"
}

dependencies {
    implementation 'com.querydsl:querydsl-jpa'
}

//querydsl 추가 시작
def querydslDir = "$buildDir/generated/querydsl"

querydsl {
    jpa = true
    querydslSourcesDir = querydslDir
}

sourceSets {
    main.java.srcDir querydslDir
}
//querydsl 추가 끝

이렇게 설정해주면 된다. 다만 이 Gradle 플러그인은 1.0.10을 마지막으로 업데이트가 없다. Gradle 4.6에서 ‘Annotaion Processor’가 소개되고, 이를 반영한 Gradle 5.x가 출시됐을 때는 정상으로 작동하지 않는다. 그래서 이 부분이 따로 추가되었다.

compileQuerydsl {
    options.annotationProcessorPath = configurations.querydsl
}

querydsl-apt 에 있는 AnnotationProcessor 의 경로를 설정해준다.

Gradle 6.x 에서는 아래 코드를 추가해주면 작동한다고 한다.

configurations {
    //아래를 지정하지 않으면, compile 로 걸린 JPA 의존성에 접근하지 못한다.
    querydsl.extendsFrom compileClasspath
}

현재 주절주절 팀의 Gradle 설정은 이렇게 하나씩 추가된 설정이 다 들어가 있다.

최종 설정

plugins{
    id "com.ewerk.gradle.plugins.querydsl" version "1.0.10"
}

dependencies {
    implementation 'com.querydsl:querydsl-jpa'
}

//querydsl 추가 시작
def querydslDir = "$buildDir/generated/querydsl"

querydsl {
    jpa = true
    querydslSourcesDir = querydslDir
}

sourceSets {
    main.java.srcDir querydslDir
}

compileQuerydsl {
    options.annotationProcessorPath = configurations.querydsl
}

configurations {
    //아래를 지정하지 않으면, compile 로 걸린 JPA 의존성에 접근하지 못한다.
    querydsl.extendsFrom compileClasspath
}
//querydsl 추가 끝

2년 전의 Gradle 플러그인을 최신 버전에서 작동 시키려고 하나씩 설정을 추가하는 방법이다.

현재 잘 동작하고 있으니 다른 방법은 간단하게 소개만 하겠다.

  • Annotation Processor 설정(참고)

      configure(querydslProjects) {
          apply plugin: "io.spring.dependency-management"
        
          dependencies {
              compile("com.querydsl:querydsl-core")
              compile("com.querydsl:querydsl-jpa")
        
              annotationProcessor("com.querydsl:querydsl-apt:${dependencyManagement.importedProperties['querydsl.version']}:jpa") // querydsl JPAAnnotationProcessor 사용 지정
              annotationProcessor("jakarta.persistence:jakarta.persistence-api") // java.lang.NoClassDefFoundError(javax.annotation.Entity) 발생 대응 
              annotationProcessor("jakarta.annotation:jakarta.annotation-api") // java.lang.NoClassDefFoundError (javax.annotation.Generated) 발생 대응 
          }
        
          // clean 태스크와 cleanGeneatedDir 태스크 중 취향에 따라서 선택.
      		/** clean 태스크 실행시 QClass 삭제 */
          clean {
              delete file('src/main/generated') // 인텔리제이 Annotation processor 생성물 생성위치
          }
        
      		/**
           * 인텔리제이 Annotation processor 에 생성되는 'src/main/generated' 디렉터리 삭제
           */
          task cleanGeneatedDir(type: Delete) { // 인텔리제이 annotation processor 가 생성한 Q클래스가 clean 태스크로 삭제되는 게 불편하다면 둘 중에 하나를 선택 
              delete file('src/main/generated')
          }
      }
    

    변경된 Gradle Annotation Processor 설정을 이용하면 별다른 설정을 하지 않아도 annotationProcessor로 선언한 라이브러리의 적절한 AnnotationProcessor를 선택하여 사용

    lombok도 이와 유사하게 사용 가능함

    참고 : http://honeymon.io/tech/2020/07/09/gradle-annotation-processor-with-querydsl.html

설정한 뒤에 IntelliJ 우측의 Gradle을 열어 Tasks → other → compileJava 를 실행시키면 Q클래스가 생성된다. 주절주절 팀의 경우 build/generated/querydsl 패키지에 생성되었다. 혹은 Gradle Project (View -> Tool Windows -> Gradle Project)에서 Tasks -> other -> compileJava를 실행

(기본적으로 src/main/generated에 경로 설정)

querydsl01

querydsl02

Config 설정

설정값을 모아둔 패키지에 QuerydslConfiguration 생성.

querydsl03

@Configuration
public class QuerydslConfiguration {

    @PersistenceContext
    private EntityManager entityManager;

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

이 설정을 통해 JPAQueryFactory를 주입 받아 Querydsl을 사용 가능하게 됐다.

기본적인 사용법

현재 JPA Repository를 사용하고 있으므로 거기에 Querydsl을 추가하는 방법으로 가겠다.

public interface DrinkRepository extends JpaRepository<Drink, Long> {

	...

}

여기에 따로 Querydsl용 Repository를 따로 생성한다.

import static com.jujeol.drink.domain.QDrink.drink; // (2)

import com.jujeol.drink.domain.Drink;
import com.querydsl.jpa.impl.JPAQueryFactory;
import java.util.List;
import org.springframework.data.jpa.repository.support.QuerydslRepositorySupport;
import org.springframework.stereotype.Repository;

@Repository
public class DrinkRepositorySupport extends QuerydslRepositorySupport {

    private final JPAQueryFactory queryFactory;

    public DrinkRepositorySupport(JPAQueryFactory queryFactory) {
        super(Drink.class);
        this.queryFactory = queryFactory;
    }

    public List<Drink> findByName(String name) {
        return queryFactory
                .selectFrom(drink) // (1)
                .where(drink.name.name.eq(name)) //(3)
                .fetch();
    }
}
  • 설정에서 Bean으로 등록한 JPAQueryFactory를 주입 받아 사용

    (1) : 여기서 drink를 쓰면 오류가 날 수 있다. 그렇다면 옆에 Gradle에서 Tasks -> other -> compileQuerydsl를 실행. 끝나면 프로젝트에 있는 모든 Entity의 QClass 생성

    (2) : 생성된 QClass import 한 것

    (3) : sql의 where와 같다. 자바 코드처럼 .equals() 느낌이라고 보면 된다. 추후에 기능들에 대해 설명하겠다.

이제 JPA의 DrinkRepository처럼 DrinkRepositorySupport를 Service에서 필드로 가지고 사용하면 된다. 이 방법도 뭐 나쁘진 않지만 Repository 두 개를 선언해야 하는 것이 마음에 들지 않는다.

Spring Data Jpa Custom Repository 적용

두 개로 나뉜 Repository를 하나로 합치고 싶다. 다행히 Spring Data Jpa에서는 Custom Repository를 JpaRepository 상속 클래스에서 사용할 수 있도록 기능을 지원해준다. Spring Data 공식 문서

querydsl04

DrinkRepository 에서 DrinkRepositoryImpl 에 있는 코드도 사용 가능.

Custom이 붙은 인터페이스를 상속한 Impl이 붙은 클래스의 코드는 Custom 인터페이스를 상속한 JpaRepository에서 사용 할 수 있다.

Custom  Impl만 공식처럼 외우고 클래스를 만들어도 된다.

DrinkRepository와 같은 패키지에 DrinkCustomRepository 인터페이스와 DrinkRepositoryImpl 클래스를 생성해준다.

querydsl05

그 뒤에 DrinkCustomRepository에 만들고 싶은 메서드를 추가한다. Support 클래스와 동일한 메서드로 설명하겠다.

public interface DrinkCustomRepository {

    List<Drink> findByName(String name);
}

그리고 DrinkCustomRepository를 상속 받는 DrinkRepositoryImpl를 구현한다.

@RequiredArgsConstructor
@Transactional(readOnly = true)
public class DrinkRepositoryImpl extends DrinkCustomRepository {

    private final JPAQueryFactory queryFactory;

    public List<Drink> findByName(String name) {
        return queryFactory
                .selectFrom(drink) // (1)
                .where(drink.name.name.eq(name)) //(3)
                .fetch();
    }
}

DrinkRepositoryDrinkCustomRepository를 상속한다.

public interface DrinkRepository extends JpaRepository<Drink, Long>, DrinkCustomRepository {
		...
}

이렇게 설정해두면 Service에서 DrinkRepository 만 주입 받아도 DrinkCustomRepository에 있는 메서드들 까지 모두 사용 가능하다.

  • 상속/구현 없는 Repository → 현재 적용하진 않았지만 JpaRepository 없이 Querydsl만 이용하고 싶을 때 사용

    Querydsl만으로 Repository 구성하는 방법.

    JPAQueryFactory만 주입 받고 Querydsl 사용한다.

      @RequiredArgsConstructor
      @Repository
      public class DrinkQueryRepository {
          private final JPAQueryFactory queryFactory;
        
          public List<Academy> findByName(String name) {
              return queryFactory.selectFrom(drink)
                      .where(drink.name.name.eq(name))
                      .fetch();
          }
      }
    
    • Bean 등록을 위해 @Repository 선언
    • 특정 Entity만 사용해야 한다는 제약도 없다.
    • 별도의 상속이나 구현이 필요 없다.

이렇게 기본적인 세팅과 클래스 설정까지 끝이 났다. 다음으로는 queryFactory를 이용해서 Querydsl을 직접 사용하는 방법을 알아보겠다.

Query문 작성

기본적으로 sql문과 흡사한 형태라 사용하기 쉬울 것이다.

결과 반환

  • fetch : 조회 대상이 여러 건일 경우. 컬렉션 반환
  • fetchOne : 조회 대상이 1건일 경우(1건 이상일 경우 에러). Generic에 지정한 타입으로 반환
  • fetchFirst : 조회 대상이 1건이든 1건 이상이든 무조건 1건만 반환. 내부에 보면 return limit(1).fetchOne() 으로 되어있음
  • fetchCount : 개수 조회. long 타입 반환
  • fetchResults : 조회한 리스트 + 전체 개수를 포함한 QueryResults 반환. count 쿼리가 추가로 실행된다.

기본적으로 static import 하나가 들어가 있으니 drink에 혼동하지 않도록 주의 바람

import static com.jujeol.drink.domain.QDrink.drink;

프로젝션(select)

Drink findDrink = queryFactory.select(drink)
                .from(drink)
                .fetchOne();

select에 어떤 값을 찾을 것인지, from 절에는 어디서 찾을 것인지 인자로 넣으면 된다.

from

Drink findDrink = (Drink) queryFactory.from(drink)
                .fetchOne();

프로젝션을 지정하는 select 문이 빠졌지만 위와 동일하게 from의 처음에 나오는 Entity를 사용한다.

다만 이 경우엔 Object를 반환해서 타입 캐스팅이 필요하다.

selectFrom

Drink findDrink = queryFactory.selectFrom(drink)
                .fetchOne();

위 두가지를 합친 것. 이걸 사용하는 것이 가장 좋다. 타입 캐스팅도 필요 없다.

Join

join, innerJoin, leftJoin, rightJoin을 지원한다.

Drink findDrink = queryFactory.selectFrom(drink)
                .join(drink.viewCount, QViewCount.viewCount1)
                .fetchOne();

join에 첫번째 인자로 join할 대상, 두번째 인자로는 join 할 대상의 쿼리 타입을 주면 된다. on 절은 자동으로 붙는다.

  • 추가적인 on 절도 사용 가능
Drink findDrink = queryFactory.selectFrom(drink)
                .join(drink.viewCount, QViewCount.viewCount1)
                .on(drink.name.name.eq("테라"))  // (1)
                .fetchOne();

(1) : Drink Entity의 name이 Embbeded라 name.name으로 가야 원시값으로 접근 가능하다보니 생기는 현상

조건

Entity에 대해 조건을 걸 수 있다.

String 테라 = "테라";

Drink findDrink = queryFactory.selectFrom(drink)
            .where(drink.name.name.likeIgnoreCase("%" + 테라 + "%"))
            .fetchFirst();
  • (조건1 or 조건2) and (조건3 or 조건4) 이런 식으로 괄호와 and, or을 이용해서 여러 조건을 걸 수 있다.

조건문에 사용되는 메서드

더 많은 메서드를 확인하고 싶으면 여기

BooleanBuilder를 사용해서 조건을 동적으로 만들 수 있다.

@Override
public Page<Drink> findByRecommendation(
        RecommendationTheme recommendationTheme,
        Pageable pageable
) {
    QueryResults<Drink> result = queryFactory.selectFrom(drink)
            .where(recommendationCondition(recommendationTheme)) // (1)
            .orderBy(recommendationOrder(recommendationTheme))
            .offset(pageable.getOffset())
            .limit(pageable.getPageSize())
            .fetchResults();     
		return new PageImpl<>(result.getResults(), pageable, result.getTotal());
 }

(1) :

private BooleanBuilder recommendationCondition(RecommendationTheme recommendationTheme) {
        BooleanBuilder builder = new BooleanBuilder();
        if (recommendationTheme.isPreference()) {
            builder.and(drink.preferenceAvg.gt(0));
        }
        if (recommendationTheme.isViewCount()) {
            builder.and(drink.viewCount.viewCount.gt(0));
        }
        if (recommendationTheme.isBest()) {
            builder.and(drink.preferenceAvg.gt(0));
            builder.and(drink.viewCount.viewCount.gt(0));
        }
        return builder;
    }

builder에 자바 코드처럼 분기 처리를 통해 다양한 경우에 대해 조건을 설정해서 where절에 메서드로 주입할 수 있다.

또는 각 조건마다 BooleanExpression 메소드화시켜 where절에 and나 or로 연결해줄 수도 있다.

동일한 findByRecommendation 메서드를 변경해보겠다.

@Override
public Page<Drink> findByRecommendation(
        RecommendationTheme recommendationTheme,
        Pageable pageable
) {
    QueryResults<Drink> result = queryFactory.selectFrom(drink)
            .where(isPreference(recommendationTheme),
                   isViewCount(recommendationTheme),
				   isBest(recommendationTheme)) 
            .orderBy(recommendationOrder(recommendationTheme))
            .offset(pageable.getOffset())
            .limit(pageable.getPageSize())
            .fetchResults();     
		return new PageImpl<>(result.getResults(), pageable, result.getTotal());
 }
private BooleanExpression isPreference(RecommendationTheme recommendationTheme) {
		return drink.preferenceAvg.gt(0);
}

private BooleanExpression isViewCount(RecommendationTheme recommendationTheme) {
		return drink.viewCount.viewCount.gt(0);
}

private BooleanExpression isBest(RecommendationTheme recommendationTheme) {
		return drink.preferenceAvg.gt(0).and(drink.viewCount.viewCount.gt(0));
}

그룹핑

group by 이용해서 그룹핑도 가능하다.

List<String> drinkNames = queryFactory.from(drink)
				.select(drink.name.name)
				.groupBy(drink.name.name)
				.fetch();

정렬

List<Drink> drinks = queryFactory.selectFrom(drink)
				.orderBy(drink.name.asc(), drink.viewCount.viewCount.desc())
				.fetch();

페이징

@Override
public Page<Drink>findBySearch(Search search, Pageable pageable) {
		QueryResults<Drink> result = queryFactory.selectFrom(drink)
						.where(searchCondition(search))
						.offset(pageable.getOffset())
						.limit(pageable.getPageSize())
						.fetchResults();
    return new PageImpl<>(result.getResults(), pageable, result.getTotal());
}
  • 시작 인덱스를 정하는 offset
  • 조회할 개수를 지정하는 limit

fetchResults()를 이용해서 QueryResults를 반환 받은 후 PageImpl 에 인자로 위 코드와 같이 넘기면 외부에서 받은 Pageable 에 해당하는 페이징 처리를 할 수 있다.

정리

서브쿼리와 수정, 삭제 등 더 많은 코드가 있으므로 필요할 때마다 참고해서 사용하면 될 것 같습니다. 모두들 화이팅!

참고 자료

http://honeymon.io/tech/2020/07/09/gradle-annotation-processor-with-querydsl.html

https://jojoldu.tistory.com/372#recentEntries

https://sup2is.github.io/2020/10/20/what-is-jpa-query-dsl.html

https://joont92.github.io/jpa/QueryDSL/

https://sas-study.tistory.com/393


© 2017. All rights reserved.

Powered by Hydejack v7.5.2