Post

[JPA] QueryDSL

[JPA] QueryDSL

📌 QueryDSL이란?

QueryDSLHQL(Hibernate Query Language)타입에 안전하게(type-safe) 생성 및 관리하는 프레임워크이다. 정적 타입을 통해 SQL이나 JPQL 쿼리를 자바 코드로 작성할 수 있다.

정적 타입이란 컴파일 시점에 변수나 객체의 데이터 타입이 미리 결정되는 것을 말한다.

‘type-safe’라는 것은 컴파일러가 프로그램 실행 전 데이터 타입을 확인하여 타입 오류를 방지하는 것을 의미한다.

DSL은 Domain-Specific Language의 약자로, 특정 도메인에 초점을 맞춘 소프트웨어 언어이다. QueryDSL에서 도메인이란 데이터베이스라고 볼 수 있다.

📌 등장 배경

QueryDSL 이전에는 Mybatis, JPQL, Criteria과 같이 문자열 형태로 쿼리문을 작성하였다. 따라서 컴파일 시점에 오류를 발견하는 것이 불가능했고, 유지보수가 어려워졌다. 공식 JPA에서 제공하는 Criteria API는 쿼리를 자바 코드로 작성할 수 있도록 지원하지만, 코드가 난해하고 직관성이 떨어진다는 단점이 있었다. 또한 실무에서는 다양한 조건의 동적 쿼리의 필요성이 증가하였다. 이러한 문제들을 해결하기 위해 QueryDSL이 등장하였다.

📌 장점

  • 문자열이 아닌 코드로 쿼리를 작성하여 컴파일 시점에 오류를 찾아낼 수 있다.
  • Q-Class, 메서드를 사용하여 복잡한 쿼리나 동적 쿼리 작성을 작성할 수 있다.
  • JPQL 문법과 유사한 형태로 작성할 수 있다.

📌 Q-Class

Q-Class 는 QueryDSL이 제공하는 메타 모델 클래스이다. 엔티티 클래스의 구조와 필드를 기반으로 자동으로 생성되며, 각 엔티티의 필드 정보를 변수로 가진다.

메타 모델 클래스는 특정 모델의 구조를 컴파일 시점에 정적으로 표현하는 클래스이다. 이를 통해 필드명 오타, 타입 불일치 등과 같은 오류를 실행 전에 발견할 수 있게 된다.

엔티티 클래스에 @Entity 어노테이션이 붙어있다면 Q-Class는 빌드 시점에 JPAAnnotationProcessor 에 의해 자동으로 생성된다. Q-Class는 엔티티 이름 앞에 ‘Q’가 붙어 생성된다. 만약 엔티티 이름이 User 라면 해당 엔티티의 Q-Class는 QUser 가 된다.

JPAAnnotationProcessor는 APT(Annotation Processing Tool) 이다. APT는 Java 컴파일러의 일부로, 컴파일 시점에 어노테이션을 분석하고 추가적인 코드를 생성하는 도구이다. 다른 예시로 Lombok의 @Getter 가 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Entity
public class Member {
    @Id
    private Long id;
    private String username;
    private int age;
}

public class QMember extends EntityPathBase<Member> {
    public static final QMember member = new QMember("member");
    public final StringPath username = createString("username");
    public final NumberPath<Integer> age = createNumber("age", Integer.class);
    public final NumberPath<Long> id = createNumber("id", Long.class);
}

컴파일 시점에 위와 같은 Q-Class가 생성된다.

JPAQueryFactory 를 사용하여 Q-Class 기반으로 쿼리를 빌더 패턴으로 작성할 수 있다.

1
2
3
4
5
6
7
JPAQueryFactory queryFactory = new JPAQueryFactory(em);
QMember member = QMember.member;
Member result = queryFactory
    .selectFrom(member)
    .where(member.username.eq("whqtker"))
    .fetchOne();

1
2
3
4
5
6
7
8
@Configuration
public class QueryDSLConfig {
    @Bean
    JPAQueryFactory jpaQueryFactory(EntityManager em) {
        return new JPAQueryFactory(em);
    }
}

📌 QueryDSL 설정

1
2
3
4
5
    // 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"

dependencies 에 QueryDSL 의존성을 추가한다.

1
2
3
clean {
    delete file('src/main/generated')
}

위 코드를 추가하면 gradle clean 명령으로 생성된 Q-Class를 자동으로 삭제할 수 있다.

📌 기본 문법

1
2
3
4
5
6
QMember member = QMember.member;
Member result = queryFactory
    .selectFrom(member)
    .where(member.username.eq("member1"))
    .fetchOne();

QueryDSL은 기본적으로 메서드 체이닝을 통해 쿼리를 작성한다.

selectFrom() : 조회할 엔티티를 지정한다.

where() : 조건절을 추가한다.

fetchOne() : 단일 결과를 리턴한다.

조건

1
2
3
4
5
.where(
    member.age.between(10, 30)
    .and(member.username.like("%user%"))
)

where절에 사용 가능한 메서드 중 일부만 살펴보자.

eq(): 값이 같은지 비교한다.

ne(): 값이 다른지 비교한다.

goe(): 이상(≥)

lt(): 미만(<)

like(): 문자열 패턴을 매칭할 때 사용한다.

조건을 null로 설정하는 경우 해당 조건은 무시된다.

조회

1
2
3
4
QueryResults<Member> results = queryFactory
    .selectFrom(member)
    .fetchResults();

fetch(): 결과 리스트를 리턴한다. 리턴 타입은 List<T> 이다.

fetchOne(): 단일 결과 객체를 리턴한다. 결과가 없다면 null, 결과가 둘 이상이라면 NonUniqueResultException을 발생시킨다.

fetchFirst(): 쿼리 결과에서 첫 번째 결과만 리턴한다. limit(1).fetchOne() 과 동일한 동작을 수행한다. 결과가 없다면 null 을 리턴한다.

fetchResults(): 쿼리 결과 리스트와 전체 개수를 리턴한다. 리턴 타입은 QueryResults<T> 이며, 내부적으로 getResults(), getTotal() 이 수행된다. 단, 성능적으로 문제가 있으므로 사용을 지양한다.

그룹화를 사용한 복잡한 쿼리에서 정확한 전체 개수를 파악하기 어렵고, 전체 결과를 메모리에 로드한 후 size() 로 개수를 계산하므로 성능이 저하될 가능성이 존재하기 때문이다. fetch() 후 별도의 count 쿼리를 작성하는 것을 권장한다.

fetchCount() : 조회 쿼리를 count 쿼리로 변환하여 개수를 리턴한다.

내부적으로 fetch().size() 로 처리될 가능성이 있어 성능 문제가 발생한다. 이유는 동일하다.

정렬과 페이징

1
2
3
4
5
6
7
List<Member> members = queryFactory
    .selectFrom(member)
    .orderBy(member.age.desc(), member.username.asc().nullsLast())
    .offset(10)
    .limit(5)
    .fetch();

orderBy() : 정렬 순서를 지정한다.

nullsLast(): null 값을 마지막에 배치한다.

offset(): 쿼리 결과에서 몇 번째 행부터 리턴할지 지정한다.

limit(): 최대 몇 개의 행(페이지 크기)을 리턴할지 지정한다.

집계 함수와 그룹화

1
2
3
4
5
6
7
8
9
10
11
List<Tuple> result = queryFactory
    .select(
        team.name,
        member.age.avg()
    )
    .from(member)
    .join(member.team, team)
    .groupBy(team.name)
    .having(member.age.avg().gt(20))
    .fetch();

groupBy(), having(): 그룹화할 때 사용하는 메서드이다. avg() 과 같이 다양한 집계 함수를 사용할 수 있다.

Projections

1
2
3
4
List<Tuple> result = queryFactory
    .select(member.username, member.age)
    .from(member)
    .fetch();

전체 엔티티가 아닌 필요한 필드만 선택하여 결과를 리턴하는 경우 Projections 를 사용한다. QueryDSL의 Tuple 객체를 통해 필드를 조회한다.

결과를 DTO로 변환하는 방법은 크게 4가지가 존재한다.

1. Projections.constructor()

1
2
3
4
5
6
7
8
9
List<MemberDto> result = queryFactory
    .select(Projections.constructor(
        MemberDto.class,
        member.username,
        member.age
    ))
    .from(member)
    .fetch();

DTO의 생성자를 활용하여 쿼리 결과를 매핑하는 방법이다. 생성자를 기반으로 매핑하므로 불변 DTO를 생성할 수 있다. 단, 컴파일 타임에 오류 검출이 어렵고, 매개변수 순서를 일치시켜야 한다. 순서가 일치하지 않으면 IllegalArgumentException 이 발생한다.

불변 DTO는 한 번 생성된 후 내부 상태를 변경할 수 없는 DTO를 말한다. 모든 필드는 final 로 선언된다.

2. Projections.fields()

1
2
3
4
5
6
7
8
9
List<MemberDto> result = queryFactory
    .select(Projections.fields(
        MemberDto.class,
        member.username,
        member.age
    ))
    .from(member)
    .fetch();

DTO의 필드에 직접 접근하여 쿼리 결과를 매핑하는 방법이다. DTO의 필드명과 쿼리 결과의 필드명이 일치하면 자동으로 매핑된다. 직접 값을 주입하므로 Setter가 필요 없다. 단, DTO에 @NoArgsConstructor 가 필수로 명시되어야 한다.

3. Projections.bean()

1
2
3
4
5
6
7
8
9
List<MemberDto> result = queryFactory
    .select(Projections.bean(
        MemberDto.class,
        member.username,
        member.age
    ))
    .from(member)
    .fetch();

DTO의 Setter를 통해 쿼리 결과를 매핑하는 방법이다. 따라서 Setter와 기본 생성자가 필수로 명시되어야 한다.

4. @QueryProjection

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Getter
public class UserDTO {
    private String username;
    private int age;

    @QueryProjection
    public UserDTO(String username, int age) {
        this.username = username;
        this.age = age;
    }
}

List<UserDTO> dtos = queryFactory
    .select(new QUserDTO(member.username, member.age))
    .from(member)
    .fetch();

DTO의 생성자에 @QueryProjection 을 추가하면 QueryDSL이 해당 DTO에 대한 Q-Class를 자동으로 생성하여 쿼리 결과를 type-safe하게 매핑할 수 있다. 생성자 기반이므로 Setter 없이 불변 DTO를 생성할 수 있다. 일반적으로 가장 추천하는 방법이다.

BooleanBuilder

1
2
3
4
5
6
7
8
9
10
11
12
13
BooleanBuilder builder = new BooleanBuilder();
if (username != null) {
    builder.and(member.username.eq(username));
}
if (minAge != null) {
    builder.and(member.age.goe(minAge));
}

List<Member> result = queryFactory
    .selectFrom(member)
    .where(builder)
    .fetch();

조건을 유동적으로 조합하고 싶은 경우 BooleanBuilder 를 사용하면 된다. and()or() 을 통한 메서드 체이닝 방법으로 작성할 수 있다.

1
2
BooleanBuilder builder = new BooleanBuilder(Expressions.TRUE);

조건이 하나도 추가되지 않는 경우 위와 같이 작성한다. WHERE 1=1 과 동일한 효과를 가진다.

성능을 최적화하기 위해 자주 걸러지는 조건을 먼저 작성하여 데이터의 범위를 빠르게 축소하는 것이 좋다.

조인

1
2
3
4
5
6
7
List<Order> orders = queryFactory
    .selectFrom(order)
    .join(order.member, member)
    .leftJoin(order.product, product)
    .where(member.age.gt(30))
    .fetch();

QueryDSL에서 조인은 JPQL과 유사한 방식으로 사용된다. join(), innerjoin(), leftjoin(), fetchJoin()등과 같은 조인 메서드가 존재하며, on() 을 통해 조인 조건을 추가할 수 있다.

서브쿼리

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// SELECT 절 서브쿼리
List<Tuple> result = queryFactory
    .select(
        member.username,
        JPAExpressions.select(memberSub.age.max())
                     .from(memberSub)
    )
    .from(member)
    .fetch();

// WHERE 절 서브쿼리
List<Member> result = queryFactory
    .selectFrom(member)
    .where(member.age.eq(
        JPAExpressions.select(memberSub.age.max())
                     .from(memberSub)
    ))
    .fetch();

// FROM 절 서브쿼리
QMember memberSub = new QMember("memberSub");
List<Member> result = queryFactory
    .selectFrom(member)
    .from(JPAExpressions.selectFrom(memberSub)
                       .where(memberSub.age.gt(10)), member)
    .fetch();

JPAExpressions 를 통해 서브쿼리를 작성할 수 있다. 서브쿼리는 SELECT, WHERE, FROM 절에 작성될 수 있다.

단, FROM 절 서브쿼리는 Hibernate 6 미만에서는 지원하지 않는다.

조건 분기

1
2
3
4
5
6
7
8
List<String> result = queryFactory
    .select(new CaseBuilder()
        .when(member.age.between(10, 20)).then("10대")
        .when(member.age.between(20, 30)).then("20대")
        .otherwise("기타"))
    .from(member)
    .fetch();

CaseBuilder 를 통해 복잡한 조건 분기를 처리할 수 있다. SQL문에서 CASE WHEN 구문과 같은 기능을 수행한다.

수정 및 삭제

1
2
3
4
5
6
long count = queryFactory
        .update(member)
        .set(member.name, newName)
        .where(member.name.eq(oldName))
        .execute();

update()delete() 를 통해 데이터를 수정 및 삭제할 수 있다. execute() 를 통해 메서드에 영향을 받은 행의 수를 리턴한다.

📌 참고

https://velog.io/@evan523/JPA-QueryDSL

This post is licensed under CC BY 4.0 by the author.