[Querydsl] Querydsl을 활용한 복잡한 동적 쿼리 구현기

자바 ORM 표준 JPA 프로그래밍 책도 봤겠다, 인프런에서 JPA, Spring Data JPA 관련 강의도 모두 봤겠다.

이제 Spring (boot)에서 ORM을 활용하여 쿼리를 짜려고 할 때 적어도 몰라서 못짜는 쿼리는 없을 것이라 생각했습니다.

적어도 아래의 예시를 보기까지는…😥

화면 기록 2021-10-07 14 26 22 mov

네이버 쇼핑몰

위와 같은 경우는 SQL 문에서 Where 조건에 해당하는 값들이 0 ~ N 개 까지 선택이 가능합니다.

더불어 각각의 조건들은 상호 복잡한 조건으로 얽힐수도 있습니다.


JPA, JPQL 혹은 Native Query로 어떻게 처리하면 좋을지 감이오지 않았습니다.

Query string을 바탕으로 처리하기에는 코드의 복잡도가 굉장히 올라갈 것이 분명하였습니다.


이후 동적 쿼리, Querydsl이라는 키워드에 대하여 알게되었고 문제 해결의 실마리가 보이기 시작했습니다.🤔

1. 동적 쿼리란?

실행 시점에 쿼리문장이 만들어져 실행되는 쿼리문을 의미합니다.

일반적으로 Spring Data JPA을 이용하여 활용하는 쿼리들은 아래의 예시와 같이 기본적인 CRUD를 JpaRepository<T, ID>를 이용하여 구현하고 그 외 필요한 경우 쿼리 메소드를 정의하여 사용하게 됩니다.

// extends JpaReository<~> 만으로 Spring에서 기본적인 CRUD 구현이 완료된 Proxy Repository DI 해줌.
public interface OfferRepository extends JpaRepository<Offer, Long> {

  // ~ Containing, findBy~ 등 함수 이름만으로 그에 맞는 JPQL을 생성해줌
  List<Offer> findOfferBy주택명Containing(@Param("주택명") String 주택명);
  
	// 그 외 직접 구현해야할 경우 @Query 어노테이션을 활용하여 직접 JPQL 정의
  @Query(value = "select o from Offer o where o.lon is null or o.lat is null")
  List<Offer> findOfferByCoordinateNotNull();
} 

위와 같은 JPA 코드의 공통점은 무엇일까요?

바로 쿼리가 파라미터 인자를 제외하고는 이미 결정이 되어 있다는 것 입니다.

네이버 쇼핑의 예제처럼 검색 조건이 여러 개이거나 아에 없는 경우는 어떻게 처리해야할까요?

그때 필요한 것이 바로 Querydsl입니다.

BooleanExpression 을 활용한 간단한 동적 쿼리 예제들은 많았지만

하드한 Querydsl 사용 예시가 없어 실제 구현을 하며 아쉬운 점들이 있었습니다.

이번 글에서는 저의 Querydsl을 활용한 복잡한 동적 쿼리 구현 경험을 소개해볼까 합니다.

2. 결과물

화면 기록 2021-10-07 14 19 31 mov

맞춤분양필터 앱🐸

구현 과정을 보기 전 우선 결과물을 보자면 Querydsl를 활용하여 동적 쿼리를 직접 구현한 결과물은 위와 같습니다.

처음에 보았던 네이버 쇼핑몰 예시와 같이 0 ~ n까지의 조건들이 쿼리가 날아가는 실행 시점에 결정되고 그렇게 생성된 각각의 조건들이 복합한 관계를 이루고 있음을 확인할 수 있습니다.

3. QueryDSL 사용하며 배운 것들

김영한님 인프런 QueryDSL강의, 이동욱님 QueryDSL 블로그 포스팅 등 이미 다른 블로그나 강의로 QueryDSL의 기초적인 사용법이나 BooleanExpression을 사용한 훌륭한 동적 쿼리 예제들은 많이 있는 것 같습니다.

아래에서는 QueryDSL을 사용하여 복잡한 필터쿼리문 사용하며 알게된 일종의 Tip?, 배운 점들을 정리하려고 합니다.📚


3-1. GET or POST?

REST API에서 조회는 GET이 기본이기에 GET으로 보내는 것이 타당해보이지만 위의 조건들을 모두 GET method의 요청 파라미터로 전송하기에는 다소 복잡해 보입니다.

네이버 쇼핑몰 같은 경우 필터 조건을 걸때마다 URL의 요청 파라미터가 바뀌는 것에서 알 수 있듯이 GET 방식으로 구현하고 있음을 알 수 있습니다. 왜일까요?

# 삼성전자, 황축 등 여러 조건을 선택했을 때 바뀐 URL Parameter
# 한국어가 encoding 되어 %ED와 같이 변환되었다.
https://search.shopping.naver.com/search/all?frm=NVSHATT&maker=194129%204772%203675%209883&origQuery=%ED%82%A4%EB%B3%B4%EB%93%9C&pagingIndex=1&pagingSize=40&productSet=total&query=%ED%82%A4%EB%B3%B4%EB%93%9C&sort=rel&spec=M10015647%7CM10785324&timestamp=&viewType=list

그것은 바로 공유하기 기능을 때문인데요.

REST API에서의 POST Method 활용 방식과 더불어 POST 방식의 경우 Request body를 공유하기 시에 공유 상대방에게 body를 전달하기 어려움으로 다소 장황하더라도 GET 방식을 이용하고 있는 것을 알 수 있습니다.

만약 공유하기 등의 기능이 절대 쓰일 것 같고 url이 정말 너무 길어진다면 POST 방식을 사용하는 것도 하나의 방법이라고 생각합니다.

네이버 쇼핑 사이트의 필터값 선택에 따른 url을 보면 제조사: 삼성전자, 앱코, COX와 같이 중복선택이 가능한 경우 space(%20) 띄어쓰기를 통해 요청 파라미터를 전달받고 이를 split을 통해 DTO로 전달받고 있음을 추측할 수 있습니다.

DTO의 setter를 활용하면 바로 쉽게 List type의 field 값으로 파라미터를 받을 수 있고 이외의 추가적인 조작 또한 가능합니다.

// Setter를 활용하여 요청 파라미터 List로 담기
public void set지역(String location) {
  this.지역 = new ArrayList<>(Arrays.asList(location.split(" ")));
}


3-2. QueryDSL을 활용한 동적쿼리 구현코드

public Page<SummarizedOfferDto> findOfferByFilter(FilterQueryDto condition, Pageable pageable) {
    
    // JPAQueryFactory 객체는 쿼리 마다 하나씩 new로 생성하여 활용. 
    JPAQueryFactory query = new JPAQueryFactory(em);
		
    QueryResults<SummarizedOfferDto> offerEntityQueryResults =
        query
      			 // @QueryProjection을 이용하여 DTO로 바로 결과값 받을 수 있음
            .select(new QSummarizedOfferDto(offer))
            .distinct()
            .from(offer)
            .leftJoin(offer.offerDetailList, offerDetail)
            .where(
      					// 모든 조건이 null이면 Null Point Exception 발생함으로 NPE 방지조건 필요
                공고번호isNotNull(),
                locationEq(condition.get지역()),
      					// BooleanBuilder를 BooleanExpression과 함께 활용 (사실 조상이 Predicate로 같음)
                areaBetween(condition.get평형()),
      					// BooleanExpression을 활용
                offerPriceLoe(condition.get분양가()),
                offerTypeEq(condition.get주택구분()),
                offerSupplyTargetExist(condition.get공급대상()),
                offerApplyPlanIn(condition.get청약일정()),
                offerSupplyTypeEq(condition.get공급유형()),
                offerSupplyNumGoe(condition.get세대수()),
                offerTransExist(condition.get교통()),
                offerResailEq(condition.get전매제한()),
                offerEduExist(condition.get학군()),
                offerMoveDateLoe(condition.get입주일()))
      			// OrderSpecifier, CaseBuilder를 활용하여 동적 정렬
            .orderBy(sortBy분양진행상태(), sortBy분양시작일())
      			// Spring의 pagaeable 객체를 활용하여 pagination 구현
            .offset(pageable.getOffset())
            .limit(pageable.getPageSize())
       			// count, select query 한번에 해결
            .fetchResults();

    List<SummarizedOfferDto> content = offerEntityQueryResults.getResults();
    long total = offerEntityQueryResults.getTotal();
		
   // PageImpl를 활용하여 page 객체 반환
    return new PageImpl<>(content, pageable, total);
  }


3-3. JPAQueryFactory 객체는 쿼리 마다 하나씩 new로 생성하여 활용

JPAQueryFactory 객체를 필드변수로 선언하거나 DI를 받아 Singleton으로 관리할 수 없을까하는 의문이 있었습니다.

JPAQueryFactory의 경우 하나의 QueryDSL 문법으로 하나의 쿼리를 생성해내는 query builder의 역활을 하기 때문에

쿼리를 하나를 생성할 때 마다 new를 통해 새로운 객체를 생성해주는 것이 바람직 합니다.

특히 동적 쿼리의 특성상 실행시마다 다른 쿼리문이 생성되기 때문에 요청에 대하여도 독립적인 JPAQueryFactory를 생성해야하는 것입니다.

이는 JPAQueryFactory 객체의 내부를 보면 더욱 명확해 집니다.

public class JPAQueryFactory implements JPQLQueryFactory {
    @Nullable
    private final JPQLTemplates templates;
    private final Provider<EntityManager> entityManager;

    public JPAQueryFactory(final EntityManager entityManager) {
        this.entityManager = new Provider<EntityManager>() {
            public EntityManager get() {
                return entityManager;
            }
        };
        this.templates = null;
    }

    public JPAQueryFactory(JPQLTemplates templates, final EntityManager entityManager) {
        this.entityManager = new Provider<EntityManager>() {
            public EntityManager get() {
                return entityManager;
            }
        };
        this.templates = templates;
    }

   /*
   ... 코드 중략 ...
   */
  
    public JPAQuery<?> query() {
        return this.templates != null ? new JPAQuery((EntityManager)this.entityManager.get(), this.templates) : new JPAQuery((EntityManager)this.entityManager.get());
    }

JPAQueryFactory 객체를 보면 알 수 있듯이 JPQLTemplates을 통해 JPQL을 빌드하고 EntityManager를 통해 DB에 Query를 날리는 구조라는 것을 알 수 있습니다. QueryDSL은 결국 JPQL의 Builder의 역활을 하게 되는 것입니다.


3-4. @QueryProjection를 통해 DTO로 프로젝션 결과값 받아오기

DTO의 constructor에 @QueryProjection만 추가해주면 Q 파일이 생성되며 DTO로 결과값을 바로 받을 수 있습니다.

Repository가 DTO에 의존성이 생긴다는 설계적인 문제점이 있으나 이후 에러가 발생하더라도 Compile Time에 발생합니다.

해당 쿼리가 특수한 경우에 특정 DTO에 강한 연관관계를 가지고 사용되기 때문에 @QueryProjection를 사용해도 괜찮을 것이라고 판단하였습니다.

  @SneakyThrows
  @QueryProjection
  public SummarizedOfferDto(Offer offer) {

    ObjectMapper mapper = new ObjectMapper();
    offer.getOfferDetailList().forEach(Hibernate::initialize);

    this.id = offer.get공고번호();
    this.주택명 = offer.get주택명();
    
    
    /* ... 이하 생략 ... */
 }
  

이외에도 Projections.constructor, Projections.fields를 통해 각각 생성자, 필드값을 통해 DTO로 프로젝션의 결과 값을 반환받을 수 있습니다.

설계적 측면에서 필드값 주입이 생성자 보다 선호되는 경우는 많지 않을 것 같기에 Projections.constructor 를 활용한 프로젝션 결과 값 반환 예시만 보면 다음과 같습니다.

List<UserDto> result = queryFactory
  .select(Projections
          .constructor(UserDto.class,
                       user.username,
                       user.age)) 
  .from(user) 
  .fetch();
}


3-5. where의 모든 조건이 null이면 Null Point Exception 발생함으로 NPE 방지조건 필요

함정카드 같다고 생각되는 부분 중 하나는 BooleanExpression를 통해 null로 동적 쿼리를 처리할 때

where 절의 모든 조건이 null일 경우 NPE 에러가 발생하게 됩니다.

때문에 모든 조건이 null이 될 수 있다면 NPE를 방지하기 위해 모든 결과 값에 대하여 항상 참인 조건을 추가해주어야 합니다.

위에서는 PK인 공고번호에 Not Null 조건을 추가해준 것을 확인 할 수 있습니다.

 private BooleanExpression 공고번호isNotNull() {
    return offer.공고번호.isNotNull();
 }


3-6. BooleanBuilder를 BooleanExpression과 함께 활용

본 포스팅의 핵심 부분인 BooleanExpression를 BooleanBuilder 함께 활용하기입니다.

BooleanBuilder, BooleanExpression 모두 => Predicate => Expression를 조상으로 상속받고 있어

결과적으로 Builder pattern를 사용하느냐 하지 않느나냐의 차이만 가지고 있습니다.

// predicate 상속받고 있다.
public abstract class BooleanExpression extends LiteralExpression<Boolean> implements Predicate {
   /* ... 이하 생략 ... */
}

// 마찬가지로 predicate 상속받고 있다.
public final class BooleanBuilder implements Predicate, Cloneable {
  /* ... 이하 생략 ... */
}

인터넷 상을 보면 BooleanExpression 활용해 null로 반환하게 되면 동적쿼리를 간편하게 처리할 수 있다는 예제들이 많은데요.

BooleanBuilder 또한 BooleanBuilder builder = new BooleanBuilder() 선언 후 아무런 조취를 취하지 않고

이를 반환하게 되면 BooleanExpression에서 null을 반환하는 것과 같은 효과를 가지고 있습니다.

BooleanExpression는 많은 예제에서 소개하고 있는 것과 같이 간단한 동적 쿼리 조건을 처리할 때 아주 유용하게 사용할 수 있습니다.

  // 3. 분양가 필터
  private BooleanExpression offerPriceLoe(List<String> offerPriceCondition) {
    if (offerPriceCondition.size() != 0) {
      int priceCondition = Integer.parseInt(offerPriceCondition.get(0).replaceAll("\\D+", ""));
      return offerDetail.공급금액.loe(priceCondition * 10000);
    }
    return null;
  }

BooleanBuilder의 경우 아래와 같이 or, and에 대하여 복잡한 처리를 하는 경우 유용하게 사용할 수 있습니다.

 // 2. 평형 필터
  private BooleanBuilder areaBetween(List<String> areaRangeCondition) {
    BooleanBuilder builder = new BooleanBuilder();

    if (areaRangeCondition.contains("10평대")) {
      builder.or(
          offerDetail
              .주택형
              .castToNum(Double.class)
              .multiply(1.3)
              .between(3.305785 * 10, 3.305785 * 20));
    }
    if (areaRangeCondition.contains("20평대")) {
      builder.or(
          offerDetail
              .주택형
              .castToNum(Double.class)
              .multiply(1.3)
              .between(3.305785 * 20, 3.305785 * 30));
    }
    /* ... 이하 생략 ... */

    return builder;
  }


3-7. Spring의 pagaeable 객체를 활용하여 pagination 구현

아래와 같이 offset, limit에 각각 countroller에서 받은 pageable 객체의 offset, pageSize를 넘겨주게 되면 pagination 또한 Spring Data Jpa를 사용할 때와 마찬가지로 간편하게 pagination query를 보낼 수 있습니다.

// Spring의 pagaeable 객체를 활용하여 pagination 구현
            .offset(pageable.getOffset())
            .limit(pageable.getPageSize())


3-8. CountQuery 최적화

Pagination을 위해 수 많은 Join 문이 걸려있는 CountQuery가 쿼리 때마다 한번씩 나간다면 어떨까요?

특히 많은 join으로 인하여 성능이 저하가 발생하고 있다면 이는 성능이슈의 원인이 될 수 있습니다.

위의 예시의 경우에는 데이터 양이 대단히 많지 않아 fetchResults를 통해 CountQuery와 실제 쿼리를 같이 날리고 있지만

Spring DataPageableExecutionUtils.getPage()를 이용하면 CountQuery를 실제 쿼리와 분리함으로서 성능 최적화를 할 수 있습니다.

JPAQuery<SummarizedOfferDto> countQuery =
  query
  .select(new QSummarizedOfferDto(offer))
  .distinct()
  .from(offer)
  // Join이 count 수에 영향을 주지않는다면 생략가능
  //.leftJoin(offer.offerDetailList, offerDetail)
  .where(
      공고번호isNotNull(),
      locationEq(condition.get지역()),
      areaBetween(condition.get평형()),
      offerPriceLoe(condition.get분양가()),
      offerTypeEq(condition.get주택구분()),
      offerSupplyTargetExist(condition.get공급대상()),
      offerApplyPlanIn(condition.get청약일정()),
      offerSupplyTypeEq(condition.get공급유형()),
      offerSupplyNumGoe(condition.get세대수()),
      offerTransExist(condition.get교통()),
      offerResailEq(condition.get전매제한()),
      offerEduExist(condition.get학군()),
      offerMoveDateLoe(condition.get입주일()));

// return new PageImpl<>(content, pageable, total); 
return PageableExecutionUtils.getPage(content, pageable, countQuery::fetchCount);


3-9. fetch join을 사용하기 어려운 경우

QueryDSL 또한 .fetchJoin() 을 join 절 다음에 추가해줌으로서 fetch join을 지원하지만 fetch join을 사용하여 쿼리를 날리게 되면 where 절에서 fetch join의 대상이 조건에 걸리는 경우 해당 값들이 드랍되고 나머지 값들만 프로젝션 받게됩니다.(Cartesian Product 후 Many 쪽을 where 조건에 맞게 드랍함으로)

SQL 문법상 당연한 결과지만 객체적 지향적 관점에서 우리는 때때로 oneToMany에서 Many의 조건에 따라 query를 통해 one을 찾고 이후 모든 Many값 들을 받아오고 싶을 수 있습니다.

이러한 경우에는 Hibernate를 JPA 구현체로 사용하고 있다면 아래와 같이 Hibernate.initialize()를 통해 Lazy loading을 걸어놔 Proxy로 주입받은 Many side의 객체들을 한번에 초기화 하여 N+1문제로 인한 성능저하 이슈를 조금은 해결할 수 있습니다.

긍극적으로는 Hibernate.initialize() 사용할 필요가 없도록 QueryDSL을 사용하는 것이 가장 바람직할 것 입니다.

offer.getOfferDetailList().forEach(Hibernate::initialize);


3-10. Q파일이 대상이 되는 class는 refactoring 후 반드시 다시 compile

QueryDSL의 경우 컴파일을 통해 Q 파일이라는 것을 생성하고 그것을 활용하여 쿼리를 빌드합니다.

이 과정에서 생길 수 있는 문제점이 바로 Q파일의 대상이되는 java 코드를 refactoring하더라도 Q파일을 그대로 남아 있다는 것입니다.

따라서 Q파일의 대상이되는 자바 코드를 리펙토링 할 경우 빌드결과 생성된 target 폴더 안의 Q 파일을 지우고 다시 컴파일을 해주어야 합니다.

그렇지 않을 경우 로컬에서는 문제 없이 돌아가는데 Docker 등으로 빌드해서 배포하려고 할 때 빌드에 실패하게 될 것 입니다.(어? 로컬에서는 되는데 왜 배포가 안되지?)

혹시 QueryDSL을 사용하고 있으실 경우 로컬에서는 빌드 문제가 없는데 배포하려고 docker image 빌드만 깨진다면 위와 같은 원인을 확인해보시면 좋을 것 같습니다.


3-11. 복잡한 쿼리를 QueryDSL로 어떻게 짜야할지 모르겠다면 SQL문을 먼저 작성하자

사실 저 또한 이번 프로젝트에서 복잡한 동적 쿼리를 짜면서 _‘이걸 어떻게 QueryDSL로 구현하지?’_라고 생각한 적이 있는데요.

구글링해도 원하는 문법이 생각보다 잘 나오지 않아 애를 먹었던 경험이 있습니다.

QueryDSL 자체가 java로 SQL문 자체를 쉽게 작성할 수 있도록 도와주는 라이브러리 입니다.

쿼리를 복합하게 짜야하는 경우에는 우선 SQL문으로 작성하신 후 해당 문법에 해당하는 QueryDSL 문을 찾는 방식으로 구현하신다면 참고자료를 찾으실 때 훨씬 수월하실 것입니다.


3-12. 테스트 DB 환경을 가급적이면 프로덕션과 통일하자

간단한 ANSI SQL 문 만을 사용하여 쿼리를 작성할 것이라면 H2 등 어떠한 DB를 사용하더라도 문제가 없을 것입니다.

하지만 JPA를 벗어나 QueryDSL을 사용한다면 특정 RDB에서만 지원하는 다양한 문법을 활용하여 복잡한 쿼리를 작성할 일이 많을 것이라고 생각됩니다.

이럴 경우에 프로덕션과 테스트 환경의 DB 세팅이 다를 경우 문제가 될 수 있습니다.

저 같은 경우 castToNum(Double.class) 이라는 문법을 활용하여 필터 쿼리를 작성할 일이 있었는데요.

프로덕션 DB에서는 아무런 문제가 없는데 테스트 DB만 연결하면 버저닝 에러가 났습니다.

알고보니 위 기능이 MySQL 8.0에서 새롭게 지원하는 기능이여서 버전이 5.2였던 테스트 DB에서만 에러가 나는 것이었습니다.

QueryDSL로 복잡한 쿼리 구현시 혹시 여력이 되신다면 테스트용 DB 인스턴스나 로컬서버를 프로덕션 환경과 동일하게 사용하시길 추천해드립니다.

4. 앞으로

처음으로 QueryDSL을 사용해서 구현을 해보아 아직 부족한 점이 많습니다.

이후에는 3-8, 3-9에서 언급한 부분을 제 프로젝트에 반영하여 개선하고자 합니다.

부족하지만 누군가에게는 도움이 되었으면 좋겠습니다.

잘못된 부분이 있다면 꼭 밑의 댓글로 알려주시면 정말 감사드리겠습니다.🙏

긴 글 끝까지 읽어주셔서 감사드립니다.

Leave a comment