QueryDSL

  • JPQL을 생성하는 빌더 API
  • QueryDSL은 보통 동적 쿼리를 위해 사용한다

  • 특징
    • 쿼리를 자바 코드로 작성해서 에러를 컴파일 시점에 잡을 수 있다
    • 문법이 SQL과 비슷해 가독성이 좋다
    • BooleanExpression, BooleanBuilder를 통해 동적쿼리를 편하게 작성할 수 있다
    • PreparedStatement가 동작하여 자동으로 파라미터를 바인딩 해준다

Spring 프로젝트 설정

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

...

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

...

# gradle  밑부분에 QueryDSL task 설정부분도 추가
def querydslDir = "$buildDir/generated/querydsl"

querydsl {
    jpa = true
    querydslSourcesDir = querydslDir
}

sourceSets {
    main.java.srcDir querydslDir
}

configurations {
    querydsl.extendsFrom compileClasspath
}

compileQuerydsl {
    options.annotationProcessorPath = configurations.querydsl
}
@Configuration
public class QuerydslConfiguration {

    @PersistenceContext
    private EntityManager entityManager;

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

}
  • EntityManagerJPAQueryFactory를 설정하여 프로젝트 어디서든 사용 가능

쿼리타입(Q) = Q도메인

  • QueryDSL을 사용 하려면 쿼리 타입이라는 쿼리용 클래스가 있어야 한다.
  • 쿼리 타입은 기본 인스턴스를 보관하고 있다. 하지만, 같은 엔티티를 서브쿼리에 사용하면 같은 별칭이 사용되므로 별칭을 직접 지정해야 한다.

  • gradle -> Tasks -> build -> clean : 빌드 초기화
  • gradle -> Tasks -> other -> compileQuerydsl : Q도메인 빌드
  • 빌드를 하면 build/generated/querydsl 아래에 Q도메인 객체가 추가된다.
@Entity
@Table(name = "member")
public class MemberEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private int id;
    
    @Column(nullable = false)
    private String name;
}

→ QueryDSL은 MemberEntity 동일한 패키지에 QMemberEntity라는 이름을 가진 쿼리 타입을 생성한다.

// 기본 인스턴스를 사용하여 쿼리 타입에 접근
QMemberEntity qMember = QMemberEntity.memberEntity;

// 별칭을 직접 지정하여 쿼리 타입에 접근
QMemberEntity qMember = new QMemberEntity("m");      // 생성되는 JPQL의 별칭 = m

문법

SELECT문

public void queryDSL() {
    // 1. 직접 생성
    // EntityManager em = emf.createEntityManager();
    // JPAQuery query = new JPAQuery(em);

    // 2. Bean 사용하기
    @Autowired
    private final JPAQueryFactory queryFactory;

    QMemberEntity qMember = QMemberEntity.memberEntity;     // 기본 인스턴스 사용

    // SELECT qMember FROM QMemberEntity qMember WHERE qMember.name=?1
    List<MemberEntity> list = queryFactory
                .selectFrom(qMember)
                .where(qMember.name.eq("europani"))
                .fetch();
    
    return list;
}

결과반환

메서드 설명
List<.T> fetch() 조회 결과 리스트 반환
T fetchFirst() 조회 결과 첫번째 것만 반환
T fetchOne() 조회 결과 1건 반환 (여러건일 경우 에러)
Long fetchCount() 조회 갯수 반환
QueryResult<.T> fetchResults() 조회 리스트와 전체 갯수를 포함한 QueryResult 객체 반환
(조회쿼리, count쿼리 총 2번실행)

→ QueryDSL 5.0부터 fetchCount(), fetchResults() deprecated 되어짐

where 조건

검색조건 설명
member.username.eq(“a”) username = ‘a’
member.username.ne(“a”) username != ‘a’
member.username.isNotNull() username is not null
member.age.in(10, 20) age in (10, 20)
member.age.notIn(10, 20) age not in (10, 20)
member.age.between(10, 20) age between 10 and 20)
member.age.goe(15) age >= 15
member.age.gt(15) age > 15
member.age.loe(15) age <= 15
member.age.lt(15) age < 15
member.username.like(“member%”) username like ‘member%’
member.username.contains(“member”) username like ‘%member%’
member.username.startWith(“member”) username like ‘member%’

and, or

// and
List<MemberEntity> findMember = queryFactory
            .selectFrom(qMember)
            .where(qMember.username.eq("europani"), qMember.age.eq(20))
            .fetch();

// or
List<MemberEntity> findMember = queryFactory
            .selectFrom(qMember)
            .where(qMember.username.eq("europani")
                .or(qMember.username.eq("acaka")))
            .fetch();

정렬

List<MemberEntity> result = queryFactory
            .selectFrom(qMember)
            .where(qMember.age.eq(100))
            .orderBy(qMember.age.desc(), qMember.username.asc().nullsLast())
            .fetch();

nullsFirst(), nullsLast() : null 데이터 순서부여

페이징

List<MemberEntity> result = queryFactory
            .selectFrom(qMember)
            .where(qMember.age.eq(100))
            .orderBy(qMember.age.desc())
            .offset(1)      // index 1번부터
            .limit(2)       // 2개
            .fetch();

집합

List<Tuple> result = queryFactory
            .select(
                    qMember.count(),     //회원수
                    qMember.age.sum(),   //나이 합
                    qMember.age.avg(),   //나이 평균
                    qMember.age.max(),   //최대 나이
                    qMember.age.min()    //최소 나이
            )
            .from(qMember)
            .fetch();

조인

조인 설명
join(), innerJoin() 내부 조인
leftJoin() Left 외부 조인
rightJoin() Right 외부 조인
조인.fetchJoin() 해당 조인에 패치조인 적용
List<Tuple> result = queryFactory
            .select(qMember.usename, qTeam.name)
            .from(qMember)
            .join(qMember.team, qTeam)
            .fetch();

fetch 조인

public List<User> findAllByQuerydsl() {
    QMemberEntity qMember = QMemberEntity.memberEntity;
    QTeam qTeam = QTeamEntity.teamEntity;	
    QArticle qArticle = QArticleEntity.articleEntity;

    return queryFactory.selectFrom(qMember)
        .join(qMember.team, qTeam).fetchJoin()
        .join(qMember.article, qArticle).fetchJoin()
        .distinct()
        .fetch();
}

→ 패치조인 과정에서 중복 데이터가 발생할 수 있기 때문에 distinct를 추가했다

연관관계 없는 조인

join(테이블).on(조건)

@Setter
public class RateStatsDTO {
    private Long eqAreaId;
    private Long fieldId;
    private Double average;
}

public List<RateStatsDTO> getStats() {
    return queryFactory.select(
                Projections.bean(RateStatsDTO.class,
                        equipment.equipArea.id.as("eqAreaId"),
                        equipment.field.id.as("fieldId"),
                        rate.workRate.avg().as("average"))
            )
            .from(rate)
            .join(equipment).on(equipment.id.eq(rate.equipmentId)).fetchJoin()
            .groupBy(equipment.equipArea.id, equipment.field.id)
            .fetch();
}

→ setter를 통해 맵핑하기 때문에 setter가 반드시 필요하다

Group By, Having

// 팀의 이름과 각 팀의 25세 이상 멤버의 평균 연령을 구해라
List<Tuple> result = queryFactory
            .select(qTeam.name, qMember.age.avg())
            .from(qMember)
            .join(qMember.team, qTeam)
            .groupBy(qTeam.name)
            .having(qMember.age.gt(25))
            .fetch();

Case문

List<String> result = queryFactory
            .select(member.age 
                .when(10).then("열살")
                .when(20).then("스무살")
                .otherwise("기타")) 
            .from(member)
            .fetch();

결과 DTO 반환

  • 쿼리 결과를 바로 DTO에 담아서 반환할 수 있다

1. DTO 반환

  • setter, field, constructor 방식 등
// setter
List<MemberDto> result = queryFactory
            .select(Projections.bean(MemberDto.class,
                    member.username,
                    member.age))
            .from(member)
            .fetch();

// field
List<MemberDto> result = queryFactory
            .select(Projections.fields(MemberDto.class,
                    member.username,
                    member.age))
            .from(member)
            .fetch();

// constructor
List<MemberDto> result = queryFactory
            .select(Projections.Projections.constructor(MemberDto.class,
                    member.username,
                    member.age))
            .from(member)
            .fetch();
  • 필드 명이 다를 땐 맵핑이 바로 안되기 때문에 alias를 사용해야 한다
List<MemberDto> result = queryFactory
            .select(Projections.fields(UserDto.class,
                    member.username.as("name"),
                    member.age))
            .from(member)
            .fetch();

2. @QueryProjection

  • DTO의 생성자에 사용하면 해당 DTO의 Q도메인을 생성해 맵핑
  • 맵핑시 생기는 오류를 컴파일 시점에 찾을 수 있다
    • 생성자 방식 : 런타임 시점에 오류발생
@Data
public class MemberDto {
    private String username;
    private int age;

    public MemberDto() {}

    @QueryProjection
    public MemberDto(String username, int age) {
        this.username = username;
        this.age = age;
    }
}
List<MemberDto> result = queryFactory
            .select(new QMemberDto(member.username, member.age))
            .from(member)
            .fetch();

동적쿼리

1. BooleanBuilder

  • where문에 들어가는 조건들을 넣어주는 컨테이너
  • where 조건문을 한눈에 파악하기 어려움
@Override
public List<Academy> findDynamicQuery(String name, String address, String phoneNumber) {

    BooleanBuilder builder = new BooleanBuilder();      // BooleanBuilder 생성

    if (!ObjectUtils.isEmpty(name)) {           // name 조건
        builder.and(academy.name.eq(name));
    }
    if (!ObjectUtils.isEmpty(address)) {        // address 조건
        builder.and(academy.address.eq(address));
    }
    if (!ObjectUtils.isEmpty(phoneNumber)) {    // phoneNumber 조건
        builder.and(academy.phoneNumber.eq(phoneNumber));
    }

    return queryFactory
            .selectFrom(academy)
            .where(builder)     // 조건들을 넣은 builder 주입
            .fetch();
}

2. BooleanExpression

  • where문의 조건을 표현하는 표현식
  • where 조건문을 표현식으로 리팩토링하여 사용하기 때문에 가독성이 좋다
  • 또한, 파라미터가 null인 조건은 생략되게 만들수 있다
@Override
public List<Academy> findDynamicQuery(String name, String address, String phoneNumber) {
    return queryFactory
            .selectFrom(academy)
            .where(eqName(name),                // expression들 주입
                    eqAddress(address),
                    eqPhoneNumber(phoneNumber))
            .fetch();
}

private BooleanExpression eqName(String name) {             // name 조건 
    if (ObjectUtils.isEmpty(name)) {
        return null;
    }
    return academy.name.eq(name);
}

private BooleanExpression eqAddress(String address) {       // address 조건
    if (ObjectUtils.isEmpty(address)) {
        return null;
    }
    return academy.address.eq(address);
}

private BooleanExpression eqPhoneNumber(String phoneNumber) { // phoneNumber 조건
    if (ObjectUtils.isEmpty(phoneNumber)) {
        return null;
    }
    return academy.phoneNumber.eq(phoneNumber);
}

Spring Data JPA와 QueryDSL 함께 사용하기

  • QuerydslRepository를 사용하여 QueryDSL의 역할을 정의
  • RepositoryJpaRepositoryQuerydslRepository를 같이 상속시키면 JPA와 QueryDSL을 모두 사용 할 수 있다

(1) QueryDslRepositorySupport 사용

1. Repository 인터페이스

public interface MemberRepository extends JpaRepository<MemberEntity, Integer>, MemberQuerydslRepository {
    
}

2. Querydsl Repository 인터페이스

  • QueryDSL로 만들어 쓸 메서드의 인터페이스
public interface MemberQuerydslRepository {
    List<MemberEntity> findByName(String name);
}

3. Querydsl Repository 구현체

  • QuerydslRepositorySupport 상속
  • QuerydslRepository 인터페이스 구현
@Repository
public class MemberQuerydslRepositoryImpl extends QuerydslRepositorySupport implements MemberQuerydslRepository {

    QMemberEntity member = QMemberEntity.memberEntity;
    
    public MemberQuerydslRepositoryImpl() {
        super(MemberEntity.class);
    }

    public List<MemberEntity> findByName(String name) {
        QMemberDto projection = new QMemberDto(member.username, member.age);

        return getQuerydsl()
                .createQuery()
                .select(projection)
                .from(member)
                .where(member.name.eq(name))
                .fetch();
    }
}

(2) JPAQueryFactory 사용

QueryDSL은 JPAQueryFactory 를 통해 동작한다
JPAQueryFactory를 사용하면 EntityManager를 통해 질의가 처리되고 JPQL을 사용한다

1. Repository 인터페이스

  • 인터페이스에서 JPA뿐만 아니라 QueryDSL을 위해 만든 QuerydslRepository 인터페이스까지 상속 받는다
public interface MemberRepository extends JpaRepository<MemberEntity, Integer>, MemberQuerydslRepository {
    
}

2. Querydsl Repository 인터페이스

  • QueryDSL로 만들어 쓸 메서드의 인터페이스
public interface MemberQuerydslRepository {
    List<MemberEntity> findByName(String name);
}

3. Querydsl Repository 구현체

  • Querydsl Repository 인터페이스를 구현
@RequiredArgsConstructor
public class MemberQuerydslRepositoryImpl implements MemberQuerydslRepository {
    private final JPAQueryFactory queryFactory;
    QMemberEntity member = QMemberEntity.memberEntity;

    @Override
    public List<MemberEntity> findByName(String name) {
        return queryFactory.selectFrom(member)
                    .where(member.name.eq(name))
                    .fetch();
    }
}

페이징

  • Spring Data JPA의 Page, Pageable 객체를 활용 할수 있다
  • fetchResults()를 통해 조회쿼리, count쿼리 총 2번의 쿼리실행(deprecated)
public Page<MemberEntity> findByName(String name, Pageable pageable) {
    QueryResults<MemberEntity> results = queryFactory.selectFrom(member)
                .where(member.name.eq(name))
                .offset(pageable.getOffset())
                .limit(pageable.getPageSize())
                .fetchResults();

    List<MemberEntity> content = results.getResults();
    long total = results.getTotal();

    return new PageImpl<>(content, pageable, total);
}

PageImpl : Page 구현체

  • 최적화를 위한 count쿼리 분리
public Page<MemberEntity> findByName(String name, Pageable pageable) {
    List<MemberEntity> content = queryFactory.selectFrom(member)
                .where(member.name.eq(name))
                .offset(pageable.getOffset())
                .limit(pageable.getPageSize())
                .fetch();

    Long total = queryFactory.select(member.id.count())
                .from(member)
                .where(member.name.eq(name))
                .fetchFirst();

    return new PageImpl<>(content, pageable, total);
}
  • PageableExecutionUtils.getPage(content, pageable, function)
    • count쿼리 생략을 통해 최적화 가능
    • contentpageable 사이즈를 확인 후 생략 가능한 경우 function을 실행하지 않음
      1) 처음 페이지만 존재하는 경우 (데이터 갯수 < 페이지 사이즈이면 total = 데이터갯수)
      2) 마지막 페이지일 경우 (total = offset + 현재 페이지 데이터갯수)
public Page<MemberEntity> findByName(String name, Pageable pageable) {
    List<MemberEntity> content = queryFactory.selectFrom(member)
                .where(member.name.eq(name))
                .offset(pageable.getOffset())
                .limit(pageable.getPageSize())
                .fetch();

    JPAQuery<MemberEntity> countQuery = queryFactory.select(member.id.count())
                .from(member)
                .where(member.name.eq(name));

    return PageableExecutionUtils.getPage(content, pageable, () -> countQuery.fetchFirst());
}
  • QuerydslRepositorySupport을 사용하면 applyPagination()로 적용 가능
public <T> JPQLQuery<T> applyPagination(Pageable pageable, JPQLQuery<T> query) {
    ...
    query.offset(pageable.getOffset());
    query.limit(pageable.getPageSize());

    return applySorting(pageable.getSort(), query);
}
public Page<MemberEntity> findByName(String name, Pageable pageable) {
    JPAQuery<MemberEntity> query = getQuerydsl()
            .createQuery()
            .select()
            .from(member)
            .where(member.name.eq(name))
            .fetch();
    
    JPAQuery<MemberEntity> pageQuery = getQuerydsl()
            .applyPagination(pageable, query);

    JPAQuery<MemberEntity> countQuery = getQuerydsl()
            .createQuery()
            .select(member.id.count())
            .from(member)
            .where(member.name.eq(name));

    return PageableExecutionUtils.getPage(pageQuery.fetch(), pageable, () -> countQuery.fetchFirst());
}

QuertySQL 레퍼런스(링크)