Spring
JPA
QueryDSL

쿼리 DSL 설정

다음과 같이 build.gradle에 Dependency를 추가하면

buildscript {
   ext {
      queryDslVersion = "5.0.0"
   }
}

plugins {
    id 'java'
    id 'org.springframework.boot' version '3.2.4'
    id 'io.spring.dependency-management' version '1.1.4'

    //querydsl 추가
   id "com.ewerk.gradle.plugins.querydsl" version "1.0.10"
}

group = 'org'
version = '0.0.1-SNAPSHOT'

java {
    sourceCompatibility = '17'
}

configurations {
    compileOnly {
        extendsFrom annotationProcessor
    }
}

repositories {
    mavenCentral()
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    compileOnly 'org.projectlombok:lombok'
    annotationProcessor 'org.projectlombok:lombok'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    runtimeOnly 'com.h2database:h2'

    //querydsl 추가
    implementation "com.querydsl:querydsl-jpa:${queryDslVersion}:jakarta"
    annotationProcessor "com.querydsl:querydsl-apt:${queryDslVersion}:jakarta"
    annotationProcessor "jakarta.annotation:jakarta.annotation-api"
    annotationProcessor "jakarta.persistence:jakarta.persistence-api"
}

tasks.named('test') {
    useJUnitPlatform()
}

//querydsl 추가 시작
def querydslDir = "$buildDir/generated/querydsl"

querydsl {
   jpa = true
   querydslSourcesDir = querydslDir
}
sourceSets {
   main.java.srcDir querydslDir
}
compileQuerydsl{
   options.annotationProcessorPath = configurations.querydsl
}
configurations {
   compileOnly {
      extendsFrom annotationProcessor
   }
   querydsl.extendsFrom compileClasspath
}
//querydsl 추가 끝

gradle에서 compileQuerydsl을 클릭하거나 명령행에 ./gradlew clean compileQuerydsl 입력시 Entity 클래스에 대한 QType이 생성된다.


기본 Q-Type 활용

Q클래스 인스턴스 사용하는 3가지 방법

  1. 별칭 지정
QMember qMember = new QMember("m"); // 별칭 직접 지정
  1. 기본 인스턴스 사용
QMember qMember = QMember.member; // 기본 인스턴스 사용
  1. static import 사용
import static org.querydsl.entity.QMember.*;
public class QueryDslBasicTest2 {
    ...
    @Test
    public void startQueryDsl3() {
        Member findMember = queryFactory
                .select(member)
                .from(member)
                .where(member.username.eq("member1"))
                .fetchOne();
 
        System.out.println("findMember = " + findMember);
    }
}

검색 조건

@Test
public void startQueryDsl3() {
    Member findMember = queryFactory.selectFrom(member).where(
            member.username.eq("member1")
                    .and(member.age.eq(10))
    ).fetchOne();
 
    assertThat(findMember.getUsername()).isEqualTo("member1");
}
  • 검색 조건은 and나 or로 조합할 수 있다.
  • select, fro을 selectFrom으로 합칠 수 있다.

검색 조건

member.username.eq("member1") // username = 'member1' 
member.username.ne("member1") //username != 'member1' 
member.username.eq("member1").not() // username != 'member1'
member.username.isNotNull() //이름이 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,30) //between 10, 30
member.age.goe(30) // age >= 30 
member.age.gt(30) // age > 30 
member.age.loe(30) // age <= 30 member.age.lt(30) // age < 30
member.username.like("member%") //like 검색 
member.username.contains("member") // like ‘%member%’ 검색 
member.username.startsWith("member") //like ‘member%’ 검색 

결과 조회

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

정렬

@Test
public void sort() {
    Team teamA = queryFactory.selectFrom(team)
            .where(team.name.eq("teamA"))
            .fetchOne();
    em.persist(new Member(null, 100, teamA));
    em.persist(new Member("member5", 100, teamA));
    em.persist(new Member("member6", 100, teamA));
 
    List<Member> result = queryFactory.selectFrom(member).where(member.age.eq(100))
            .orderBy(member.age.desc(), member.username.asc().nullsLast())
            .fetch();
 
    Member member5 = result.get(0);
    Member member6 = result.get(1);
    Member memberNull = result.get(2);
 
    assertThat(member5.getUsername()).isEqualTo("member5");
    assertThat(member6.getUsername()).isEqualTo("member6");
    assertThat(memberNull.getUsername()).isNull();
}

페이징

fetchResults는 deprecated 되었으므로 limit, offset을 사용하자

@Test
@DisplayName("조회 건수 제한")
public void paging1() {
    List<Member> result = queryFactory.selectFrom(member)
            .orderBy(member.username.desc())
            .offset(1)
            .limit(2)
            .fetch();
    assertThat(result.size()).isEqualTo(2);
}
 
@Test
@DisplayName("조회 건수 조회 deprecated")
public void paging2() {
    QueryResults<Member> result = queryFactory.selectFrom(member)
            .orderBy(member.username.desc())
            .offset(1)
            .limit(2)
            .fetchResults();
    assertThat(result.getTotal()).isEqualTo(4);
    assertThat(result.getLimit()).isEqualTo(2);
}

집합 함수

/**
 * select
 *  COUNT(m) // 회원수
 *  SUM(m.age) // 나이합
 *  AVG(m.age) // 평균나이
 *  MAX(m.age) // 최대나이
 *  MIN(m.age) // 최소나이
 */
@Test
@DisplayName("Tuple로 집계 함수 조회")
public void aggregation() throws Exception {
    List<Tuple> result = queryFactory
            .select(member.count(),
                    member.age.sum(),
                    member.age.avg(),
                    member.age.max(),
                    member.age.min())
            .from(member)
            .fetch();
 
    Tuple tuple = result.get(0);
    assertEquals(tuple.get(member.count()), 4);
    assertEquals(tuple.get(member.age.sum()), 100);
    assertEquals(tuple.get(member.age.avg()), 25);
    assertEquals(tuple.get(member.age.min()), 10);
}

Group By를 이용한 집계

List<Tuple> result = queryFactory
        .select(team.name, member.age.avg())
        .from(member)
        .join(member.team, team)
        .groupBy(team.name)
        .fetch();

조인

join(조인 대상, 별칭으로 사용할 Q타입)

예시 👇

queryFactory.selectFrom(member)
        .join(member.team, team)
        .fetch();

or

@Test
@DisplayName("Tuple로 집계 함수 조회")
public void aggregation() throws Exception {
    List<Member> members = queryFactory
            .selectFrom(member)
            .join(member.team, team)
            .fetch();
    List<Member> members2= queryFactory
            .selectFrom(member)
            .join(member.team, team)
            .fetchJoin()
            .fetch();
 
    for (int i = 0; i < members.size(); i++) {
        assertEquals(members.get(i).getUsername(), members2.get(i).getUsername());
    }
}

fetchJoin()

다음 예제를 보면 joinleftJoin 뒤에 fetchJoin()을 붙이고 있다.

val questionsQuery = queryFactory.selectFrom(qQuestion)
        .leftJoin(qQuestion.comments, qComment)
        .fetchJoin()
        .join(qQuestion.categories, qQuestionCategory)
        .fetchJoin()
        .where(qQuestionCategory.category.id.`in`(categories))
        .orderBy(qQuestion.likeCount.desc())
        .offset(pageable.offset)
        .limit(pageable.pageSize.toLong())

해당 쿼리에서 발생할 수 있는 장점은 이 쿼리의 뒤에 DTO를 생성하는 메서드에서 Quetion의 comments의 수를 세는 메서드가 호출이 된다.

fun toSimpleResponse(question: Question): SimpleQuestionResponse {
    val categories = mutableListOf<CategoryResponse>()
    question.categories.forEach {
        it.category?.let { entity -> categories.add(categoryConverter.toResponse(entity)) }
    }
 
    val response: SimpleQuestionResponse = SimpleQuestionResponse(
            id = question.id ?: 0,
            title = question.title ?: "",
            content = question.content ?: "",
            createdAt = question.createdAt,
            likeCount = question.likeCount,
            viewCount = question.viewCount,
            commentCount = question.comments.size.toLong(),
            categories = categories,
    )
 
    return response
}

그렇다면 leftJoin으로 조회 된 qQuestion.comments가 Lazy Loading이 되면서 Comment 조회하는 쿼리를 다시 수행하는데 이 때, 그 짧은 사이에 다른 쿼리가 DB에서 실행중이라면 Comment를 불러오는 쿼리가 대기를 하게 되고 Page<QuestionDto>를 조회하는 API의 전체 호출 시간이 길어진다. 이를 방지하기 위해서 아래 쿼리를 수행할 당시에 즉시 로딩을 하겠다는 뜻이다.
만약에 예제처럼 join이 여러개 걸려있다면 각각의 join case마다 fetchJoin을 붙여주어야 한다.

외부조인

leftJoin(조인 대상, 별칭으로 사용할 Q타입)
rightJoin(조인 대상, 별칭으로 사용할 Q타입)

서브쿼리

서브 쿼리를 사용할 때는 JPAExpressions를 사용하자!

@Test
void subQuery() {
    QMember member = QMember.member;
    QMember subMember = new QMember("subMember");
 
    List<Member> result = queryFactory
            .selectFrom(member)
            .where(member.age.eq(
                    JPAExpressions
                            .select(subMember.age.max())
                            .from(subMember)
            ))
            .fetch();
 
    assertThat(result).extracting("age").containsExactly(40);
}

프로젝션

QueryDsl에서 프로젝션이랑 SELECT 절에서 원하는 컬럼이나 표현식을 선택하는 것을 의미한다.
예를 들어서 다음과 같은 DTO 클래스가 있다고 가정할 때

@Data
@AllArgsConstructor
@NoArgsConstructor
public class MemberDto {
    private String username;
    private int age;
}

MemberEntity를 조회할 때 해당 DTO로 값을 반환 받으려면 크게 4가지 프로젝션 방법이 있다.

  1. 프로퍼티 접근
@Test
void useSetter() {
    List<MemberDto> result = queryFactory.select(Projections.bean(MemberDto.class,
                    member.username,
                    member.age))
            .from(member)
            .fetch();
    for(MemberDto memberDto : result) {
        System.out.println("memberDto = " + memberDto);
    }
}
  1. 필드 직접 접근
@Test
void useField() {
    List<MemberDto> result = queryFactory.select(Projections.fields(MemberDto.class,
                    member.username,
                    member.age))
            .from(member)
            .fetch();
    for(MemberDto memberDto : result) {
        System.out.println("memberDto = " + memberDto);
    }
}
  1. 별칭이 다를 때

만약 이런식으로 MemberEntity와 MemberDto의 필드명이 다르다면

@Data
@AllArgsConstructor
@NoArgsConstructor
public class MemberDto {
    private String name;
    private int age;
}

아래와 같이 as를 사용해서 별칭을 지정해주면 된다.

@Test
void useAsKeyword() {
    List<MemberDto> result = queryFactory.select(Projections.fields(MemberDto.class,
                    member.username.as("name"),
                    member.age.as("age")))
            .from(member)
            .fetch();
    for (MemberDto memberDto : result) {
        System.out.println("memberDto = " + memberDto);
    }
}
  1. 생성자 사용

Constructor를 사용해서 생성자를 통해 값을 넣어주기 때문에 순서만 정확하다면 필드명이 달라도 된다.

@Test
void useConstructor() {
    List<MemberDto> result = queryFactory.select(Projections.constructor(MemberDto.class,
                    member.username,
                    member.age))
            .from(member)
            .fetch();
    for (MemberDto memberDto : result) {
        System.out.println("memberDto = " + memberDto);
    }
}

QueryProjection

애초에 Dto의 Constructor를 QueryProjection으로 지정해서 활용하는 방법이 있다.

@Data
@NoArgsConstructor
public class MemberDto {
    private String name;
    private int age;
 
    @QueryProjection
    public MemberDto(String name, int age) {
        this.name = name;
        this.age = age;
    }
}

위와 같이 QueryProjection을 사용하면 Dto의 QType이 생성되어서 사용할 수 있다.

@Test
@DisplayName("Query Projection 테스트")
void queryProjection() {
    List<MemberDto> result= queryFactory.select(
              new QMemberDto(member.username, member.age)
            )
            .from(member)
            .fetch();
}

Pagination with QueryDSL

Pageable 상세 정보 가져오는 방법 (opens in a new tab)은 여기를 참조하고 QueryDsl과 Pageable을 함께 사용하는 방법은 다음과 같다.

@Override
public Page<MemberTeamDto> searchPage(MemberSearchCondition condition, Pageable pageable) {
    List<MemberTeamDto> content = queryFactory
            .select(new QMemberTeamDto(
                    member.id,
                    member.username,
                    member.age,
                    team.id,
                    team.name
            ))
            .from(member)
            .join(member.team, team)
            .where(
                    teamNameEq(condition.getTeamName()),
                    ageGoe(condition.getAgeGoe()),
                    ageLoe(condition.getAgeLoe())
            )
            .offset(pageable.getOffset())
            .limit(pageable.getPageSize())
            .fetch();
 
    JPAQuery<Long> contentQuery = queryFactory
            .select(member.count())
            .from(member)
            .leftJoin(member.team, team)
            .where(
                    teamNameEq(condition.getTeamName()),
                    ageGoe(condition.getAgeGoe()),
                    ageLoe(condition.getAgeLoe())
            );
 
    return PageableExecutionUtils.getPage(content, pageable, contentQuery::fetchOne);
}

Error

UnknownEntityException: Could not resolve root entity

Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed: org.springframework.dao.InvalidDataAccessApiUsageException: org.hibernate.query.sqm.UnknownEntityException: Could not resolve root entity 'question.categories'] with root cause

아래 count를 조회하는 중에 발생한 에러인데 QueryDSL에서는 join을 할 때 join 앞에 from이 먼저 와야한다.

val countQuery= queryFactory
        .select(qQuestion.count())
        .join(qQuestion.categories, qQuestionCategory)
        .from(qQuestion)
        .where(qQuestionCategory.category.id.`in`(categories))
val count: Long = countQuery.fetchOne()?:0L

MultipleBagFetchException

위 방식으로 해결이 안되어서 Entity의 List 부분을 Set으로 변경해줬다.

Hibernate: select c1_0.id,c1_0.count,c1_0.name from category c1_0
Hibernate: select q1_0.id,q1_0.code,q1_0.content,q1_0.created_at,q1_0.github_url,q1_0.like_count,q1_0.purpose,q1_0.title,q1_0.view_count from question q1_0 left join comment c1_0 on q1_0.id=c1_0.question_id join question_category c2_0 on q1_0.id=c2_0.question_id where c2_0.category_id in (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) order by q1_0.created_at desc limit ?,?
Hibernate: select c1_0.question_id,c1_0.id,c2_0.id,c2_0.count,c2_0.name from question_category c1_0 left join category c2_0 on c2_0.id=c1_0.category_id where c1_0.question_id=?
Hibernate: select c1_0.question_id,c1_0.id,c2_0.id,c2_0.count,c2_0.name from question_category c1_0 left join category c2_0 on c2_0.id=c1_0.category_id where c1_0.question_id=?
Hibernate: select c1_0.question_id,c1_0.id,c2_0.id,c2_0.count,c2_0.name from question_category c1_0 left join category c2_0 on c2_0.id=c1_0.category_id where c1_0.question_id=?
Hibernate: select c1_0.question_id,c1_0.id,c2_0.id,c2_0.count,c2_0.name from question_category c1_0 left join category c2_0 on c2_0.id=c1_0.category_id where c1_0.question_id=?
Hibernate: select c1_0.question_id,c1_0.id,c1_0.content,c1_0.created_at,c1_0.like_count from comment c1_0 where c1_0.question_id=?
Hibernate: select c1_0.question_id,c1_0.id,c1_0.content,c1_0.created_at,c1_0.like_count from comment c1_0 where c1_0.question_id=?
Hibernate: select c1_0.question_id,c1_0.id,c1_0.content,c1_0.created_at,c1_0.like_count from comment c1_0 where c1_0.question_id=?
Hibernate: select c1_0.question_id,c1_0.id,c1_0.content,c1_0.created_at,c1_0.like_count from comment c1_0 where c1_0.question_id=?
Hibernate: select count(q1_0.id) from question q1_0 left join comment c1_0 on q1_0.id=c1_0.question_id join question_category c2_0 on q1_0.id=c2_0.question_id where c2_0.category_id in (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)

Query specified join fetching, but the owner of the fetched association was not present in the select list

에러메시지 : Query specified join fetching, but the owner of the fetched association was not present in the select list [SqmSetJoin(org.ccondaeapi.entity.Question(question).comments(comment))]] with root cause

fetchJoin을 사용하기 위해서는 select문의 해당 Entity가 있어야 한다. 즉,

val countQuery = queryFactory.select(qQuestion.count())
        .from(qQuestion)
        .join(qQuestion.comments, qComment)
        .fetchJoin()
        .join(qQuestion.categories, qQuestionCategory)
        .fetchJoin()
        .where(qQuestionCategory.category.id.`in`(categories))

아래와 같이 집계함수를 사용할 때는 fetchJoin을 사용할 수 없다.

Attempt to recreate a file for type

간략하게 설명하자면 QueryDSL 플러그인을 사용하면 안된다. 아래 코드와 같이 plugins을 사용하지 않는 코드로 변경해주면 된다.

plugins {
	id 'java'
	id 'org.springframework.boot' version '3.0.3'
	id 'io.spring.dependency-management' version '1.1.0'
	//	id "com.ewerk.gradle.plugins.querydsl" version "1.0.10" // ⭐ Querydsl 플러그인 사용 X
}

group = 'sample'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '17'

configurations {
	compileOnly {
		extendsFrom annotationProcessor
	}
}

repositories {
	mavenCentral()
}

dependencies {
	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'

    // ⭐ Spring boot 3.x이상에서 QueryDsl 패키지를 정의하는 방법
	implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta'
	annotationProcessor "com.querydsl:querydsl-apt:5.0.0:jakarta"
	annotationProcessor "jakarta.annotation:jakarta.annotation-api"
	annotationProcessor "jakarta.persistence:jakarta.persistence-api"
}

tasks.named('test') {
	useJUnitPlatform()
}

// === ⭐ QueryDsl 빌드 옵션 (선택) ===
def querydslDir = "$buildDir/generated/querydsl"

sourceSets {
	main.java.srcDirs += [ querydslDir ]
}

tasks.withType(JavaCompile) {
	options.annotationProcessorGeneratedSourcesDirectory = file(querydslDir)
}

clean.doLast {
	file(querydslDir).deleteDir()
}

Reference

https://velog.io/@juhyeon1114/Spring-QueryDsl-gradle-%EC%84%A4%EC%A0%95-Spring-boot-3.0-%EC%9D%B4%EC%83%81#-%EC%A4%91%EC%9A%94-querydsl-%ED%94%8C%EB%9F%AC%EA%B7%B8%EC%9D%B8-%EA%B4%80%EB%A0%A8-%EC%9D%B4%EC%8A%88 (opens in a new tab)

Reference

Error 발생시 해결요령

  1. Build > Build Tools > Gradle에서 Build IntelliJ로 선택되어 있는지 확인
  2. annotationProcessor "com.querydsl:querydsl-apt:${queryDslVersion}:jakarta" 부분 삭제해보기
  3. 만들어진 /build/* 경로 삭제 후 compileQuerydsl 없이 코드 실행해보기