sol 개발 블로그 로고
Published on

복잡한 쿼리를 QueryDSL로 바꾸기 (1)

Authors
  • avatar
    Name
    Chan Sol OH
    Twitter

목차

이번 포스팅에서는 QueryDSL의 사용법을 익히고 여기어때 프로젝트에 사용된 복잡한 조건 검색 API의 MySQL 네비티브 쿼리를 QueryDSL로 변환하는 과정을 진행한다.

QueryDSL 설정

QueryDSL은 Spring Data JPA의 커스텀 Query Method를 보안하기 위해 사용되고, QueryDSL 문법에 따라서 쿼리를 작성하면 자동으로 JPQL 쿼리로 바꿔주는 프레임 워크기 때문에 빌드 파일에 적절한 세팅을 해줘야한다.

아래와 같이 세팅하면 컴파일하는 과정에서 자동으로 Spring Data JPA의 Entity들로 Q타입을 생성하고, Q타입은 쿼리를 작성할 때 사용한다.

build.gradle
plugins {
	id 'java'
	id 'org.springframework.boot' version '3.1.5'
	id 'io.spring.dependency-management' version '1.1.3'
}
group = 'com.example'
version = '0.0.1-SNAPSHOT'
java {
	sourceCompatibility = '17'
}
configurations {
	compileOnly {
		extendsFrom annotationProcessor
	}
}
repositories {
	mavenCentral()
}
dependencies {
	// https://mvnrepository.com/artifact/com.fasterxml.jackson.core/jackson-databind
	// jackson은 json, xml, csv 같은 입력을 객체로 (역)직렬화 할 수 있다.
	implementation group: 'com.fasterxml.jackson.core', name: 'jackson-databind', version: '2.12.7.1'
	implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
	implementation 'org.springframework.boot:spring-boot-starter-web'
	compileOnly 'org.projectlombok:lombok'
	runtimeOnly 'com.mysql:mysql-connector-j'
	annotationProcessor 'org.projectlombok:lombok'
	testImplementation 'org.springframework.boot:spring-boot-starter-test'
	//Querydsl 추가
	implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta'
	annotationProcessor "com.querydsl:querydsl-apt:${dependencyManagement.importedProperties['querydsl.version']}:jakarta"
	annotationProcessor "jakarta.annotation:jakarta.annotation-api"
	annotationProcessor "jakarta.persistence:jakarta.persistence-api"
  // 쿼리 파라미터를 로그로 남기는 외부 라이브러리
  implementation 'com.github.gavlyukovskiy:p6spy-spring-boot-starter:1.9.0'
}
clean {
	delete file('src/main/generated')
}
tasks.named('test') {
	useJUnitPlatform()
}
QuerydslConfiguration.java
// Bean으로 JPAQueryFactory를 관리하기 때문에 WAS 어디서든 JPAQueryFactory를 이용할 수 있게한다.
@Configuration
public class QuerydslConfiguration {
    @PersistenceContext
    private EntityManager entityManager;
    @Bean
    public JPAQueryFactory jpaQueryFactory() {
        return new JPAQueryFactory(entityManager);
    }
}

기본 문법

import com.example.solcoupang.product.domain.QProduct;
@RequiredArgsConstructor
class ProductRepositoryCustomImpl implements ProductRepositoryCustom{
    private final JPAQueryFactory jpaQueryFactory;
    @Override
    public List<Product> findBetweenWeight(Long min, Long max) {
        QProduct p = QProduct.product;
        return jpaQueryFactory.selectFrom(p)
                .where(p.weight.between(min, max))
                .fetch();
    }
}

Q타입 객체를 임포트하고, jpaQueryFactory로 QueryDSL의 쿼리를 JPQL로 변환한다. QueryDSL은 일반적으로 네티브 쿼리가 지원하는 between,and, or,=(eq), 등등 문법을 전부 지원한다. 물론 N+1 문제를 위한 Fetch Join도 할 수 있다. 기본 문법은 다음과 같다.

// 보통 왼쪽에 row 데이터가 들어가고 오른쪽에 기준 값이 들어간다.
eq ( A = B), ne ( A != B), not ( !A )
isNotNull ( A is Not Null)
in ( A in (10, 20)), notIn (A not in (10, 20)), between ( between A and B)
goe ( A >= 30), gt (A > 30), loe (A <= 30), lt (A < 30)
like ( A like "member"), contains (A like "%member%"), startWith (A like "member&")

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

// 페이징
~~~.offset(1) // 0부터 시작 (zero index)
  .limit(2)   // 최대 2건 조회
  .fetch();

// 집합
m.count() // 개수
m.sum()   // 합
m.avg()   // 평균
m.max()   // 최대 값
m.min()   // 최소 값

~~~.groupBy(m.name) // name을 기준으로 GROUPBY
  .having(m.age.avg().gt(40)) // 그룹 선택 기준 설정

Join

join.java
// static으로 Q타입 import
import static com.example.solcoupang.product.domain.QProduct.*;
@RequiredArgsConstructor
class ProductRepositoryCustomImpl{
    @Override
    public List<Product> findByProductIdFetchImpl(Long id) {
        return jpaQueryFactory.selectFrom(product)
                .join(product.productContents).fetchJoin() // OneToMany
                .join(product.seller).fetchJoin() // ManyToOne
                .where(productIdEq(id))
                .fetch();
    }
}

fetch join은 SQL에서 제공하는 기능이 아니고, SQL의 join을 이용해서 연관된 엔티티를 SQL 한번에 조회하는 기능이다. 연관관계가 없는 테이블끼리는 fetchJoin을 쓰지 않고 세타조인(theta join)ON을 이용해서 조건을 만족시키는 row를 출력시킬 수 있다.

동적 쿼리

쿼리를 동적으로 수정하는 방법은 BooleanBuilder를 사용하는 방법과 Where 다중 파라미터를 사용하는 두가지 방법이 있다.

BuilderQuery.java
@RequiredArgsConstructor
class ProductRepositoryCustomImpl implements ProductRepositoryCustom{
    private final JPAQueryFactory jpaQueryFactory;
    @Override
    public List<Product> findByProductIdBuild(Long id) {
        BooleanBuilder builder = new BooleanBuilder();
        if(id != null){
            builder.and(product.productId.eq(id));
        }
        return jpaQueryFactory.selectFrom(product)
                .join(product.productContents).fetchJoin() // OneToMany
                .join(product.seller).fetchJoin() // ManyToOne
                .where(builder)
                .fetch();
    }
}
whereQuery.java
// builder는 조건과 쿼리가 떨어져있기에 가독성이 안 좋다. 대신 where는 추상화를 잘 할 수 있어 더 좋다.
@RequiredArgsConstructor
class ProductRepositoryCustomImpl implements ProductRepositoryCustom{
    private final JPAQueryFactory jpaQueryFactory;
    private BooleanExpression productIdEq(Long id){
        return id != null ? product.productId.eq(id) : null;
    }
    @Override
    public List<Product> findByProductIdFetchImpl(Long id) {
        return jpaQueryFactory.selectFrom(product)
                .join(product.productContents).fetchJoin() // OneToMany
                .join(product.seller).fetchJoin() // ManyToOne
                .where(productIdEq(id))
                .fetch();
    }
}

where 조건에 Null 값은 무시되며 productIdEq 같은 메서드를 재활용할 수 있고, 쿼리 자체의 가독성이 올라간다. 따라서 BooleanBuilder 보다는 다중 where절을 사용하는 것이 좋아보인다.

지금까지 많은 QueryDSL의 설정과 문법을 정리했다. 모든 것을 다루진 못했지만, 프로젝트에 적용하면서 더 많은 것을 기록하겠다.