Project/project-board

[Project] QueryDSL 적용

수수한개발자 2022. 7. 11.
728x90

저번 글에 API 설계까지 하였습니다.

하지만 기본적인 API는 Srping Data Rest가 자동으로 생성해줬지만 검색 기능은 구현해주지 않습니다.

지금부터 이 검색 기능을 QueryDSL을 사용하여 구현해보도록 하겠습니다.

QueryDSL

@Query("select a from Article a where a.id = :id")
Article findByID(@Param("id") Long id);

 

Spring Data JPA가 기본적으로 제공해주는 CRUD 메서드 및 쿼리 메서드 기능을 사용하더라도, 원하는 조건의 데이터를 수집하기 위해서는 필연적으로 JPQL을 작성하게 됩니다. 간단한 로직을 작성하는데 큰 문제는 없으나, 복잡한 로직의 경우 개행이 포함된 쿼리 문자열이 상당히 길어집니다. JPQL 문자열에 오타 혹은 문법적인 오류가 존재하는 경우, 정적 쿼리라면 애플리케이션 로딩 시점에 이를 발견할 수 있으나 그 외는 런타임 시점에서 에러가 발생합니다.

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

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

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

 

build.gradle

implementation 'com.querydsl:querydsl-core'
implementation '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) 발생 대응

 

위와 같이 디펜던시를 추가하면 querydsl을 사용할 수 있는 환경이 됩니다. 기존의 querydsl 설정을 하던 방식은

buildscript, plugins 이런 식으로 설정해주었지만 향로님의 querydsl글을 참고하여 적용하게 되었습니다.

https://jojoldu.tistory.com/372

 

그리고 저는 이로 인해 생성되는 Qclass들을  관리하기 위해 다음과 같이 추가를 더 해줍니다.

기본적인 경로에 생성하게 빌드 폴더에 들어가게 되는데 이러면 빌드 시점의 인텔리제이와 gradle이 동시에 빌드하면서 큐파일들이 중복 스캔되어 오류가 날 수도 있다는 말을 듣게 되어서 따로 경로를 지정해주기로 하였습니다.

// Querydsl 설정부
def generated = 'src/main/generated'

// querydsl QClass 파일 생성 위치를 지정
tasks.withType(JavaCompile) {
    options.getGeneratedSourceOutputDirectory().set(file(generated))
}

// java source set 에 querydsl QClass 위치 추가
sourceSets {
    main.java.srcDirs += [ generated ]
}

// gradle clean 시에 QClass 디렉토리 삭제
clean {
    delete file(generated)
}

 

빌드를 눌러주면 지정해준 위치에 Qclass들이 생성되는 것을 볼 수 있습니다.

그리고 이제 검색 기능을 구현해보도록 하겠습니다. 

 

Repository

@RepositoryRestResource
public interface ArticleRepository extends JpaRepository<Article, Long>, QuerydslPredicateExecutor<Article>, QuerydslBinderCustomizer<QArticle> {

    @Override
    default void customize(QuerydslBindings bindings, QArticle root) {
        bindings.excludeUnlistedProperties(true);
        bindings.including(root.title, root.content, root.hashtag, root.createdAt, root.createdBy);
        bindings.bind(root.title).first(StringExpression::containsIgnoreCase);
        bindings.bind(root.content).first(StringExpression::containsIgnoreCase);
        bindings.bind(root.hashtag).first(StringExpression::containsIgnoreCase);
        bindings.bind(root.createdAt).first(DateTimeExpression::eq);
        bindings.bind(root.createdBy).first(StringExpression::containsIgnoreCase);
    }
}
@RepositoryRestResource
public interface ArticleCommentRepository extends JpaRepository<ArticleComment, Long>, QuerydslPredicateExecutor<ArticleComment>, QuerydslBinderCustomizer<QArticleComment> {

    @Override
    default void customize(QuerydslBindings bindings, QArticleComment root) {
        bindings.excludeUnlistedProperties(true);
        bindings.including(root.content, root.createdAt, root.createdBy);
        bindings.bind(root.content).first(StringExpression::containsIgnoreCase);
        bindings.bind(root.createdAt).first(DateTimeExpression::eq);
        bindings.bind(root.createdBy).first(StringExpression::containsIgnoreCase);
    }
}

추가된 항목들을 보겠습니다.

QuerydslPredicateExecutor

스프링 데이터는 QuerydslPredicateExecutor라는 인터페이스를 제공합니다.

public interface QuerydslPredicateExecutor<T> {

    Optional<T> findById(Predicate predicate); // (1)

    Iterable<T> findAll(Predicate predicate); // (2)

    long count(Predicate predicate); // (3)

    boolean exists(Predicate predicate); // (4)

    // … more functionality omitted.
}

(1) Predicate에 매칭 되는 하나의 Entity를 반환합니다.
(2) Predicate에 매칭 되는 모든 Entity를 반환합니다.
(3) Predicate에 매칭 되는 Entity의 수를 반환합니다.
(4) Predicate에 매칭 되는 결과가 있는지 여부를 반환합니다.

 

이렇게만 해도 쿼리 스트링을 통해서 검색 기능이 구현되지만 일반적인 게시판에 대해서는 글을 수정한 사람이나, 글을 수정한 날짜 등은 보통 검색 기능을 사용하지 않습니다. 제목, 내용, 해시태그, 글쓴이 우리 프로젝트에서는 이렇게만 필요할 것 같아서 커스터마이징 하도록 하겠습니다.

 

 

QuerydslBinderCustomizer

@Override
    default void customize(QuerydslBindings bindings, QArticle root) {
        bindings.excludeUnlistedProperties(true);
        bindings.including(root.title, root.content, root.hashtag, root.createdAt, root.createdBy);
        bindings.bind(root.title).first(StringExpression::containsIgnoreCase);
        bindings.bind(root.content).first(StringExpression::containsIgnoreCase);
        bindings.bind(root.hashtag).first(StringExpression::containsIgnoreCase);
        bindings.bind(root.createdAt).first(DateTimeExpression::eq);
        bindings.bind(root.createdBy).first(StringExpression::containsIgnoreCase);
    }

상속받을 때는 Qclass를 넣어줍니다. 그리고 customize 메서드를 오버 라이딩하여 재정의 해줍시다.

  • bindings.excludeUnlistedProperties(true); 커스텀한 것에 대해서만 검색 기능을 사용하겠다 라는 옵션을 사용한다는 뜻입니다. 기본값 false
  • bindings.including(root.title, root.content, root.hashtag, root.createdAt, root.createdBy); 원하는 필드를 추가
  • bindings.bind(root.title). first(StringExpression::containsIgnoreCase); //like '${value}' containsIgnoreCase : 대소문자 구분 안 하겠다.
  • bindings.bind(root.title). first(StringExpression::likeIgnoreCase)  //like를 쓰면 내가 %를 써줘야 한다. 그래서 contains을 써준다.

나머지도 다 똑같은 방식이다. 자동으로 바인딩해주니 엄청 편한 기능이지만 단점도 존재한다.

 

사용 후기 

1. 일단 파라미터를 하나로밖에 받지 못한다. 그래서 first가 들어간다. 그래서 여러 개의 검색이나 조건을 못줍니다.

2. 지금 프로젝트의 규모가 크지 않아서 위에서는 검색 기능을 인터페이스를 통해 커스텀하여 코드 길이가 짧고 구현 시간이 적게 걸렸지만 프로젝트의 규모가 커졌을 때 많은 데이터를 검색하게 될 텐데. bindings.bind(root.title). first(StringExpression::containsIgnoreCase); 의 경우 like %% 로 들어가 인덱스를 타지 않게 되어 

100만 개의 조회건이라 하면 풀 스캔이 일어나게 됩니다. 이런 것은 나중에 대비를 해 생각을 해둬야 할 것 같습니다.

감사합니다:)

reference

 

 

 

 

728x90

댓글