김코딩

QueryDSL 기본 가이드 본문

스프링

QueryDSL 기본 가이드

김코딩딩 2025. 6. 30. 19:53

QueryDSL 초기 세팅은 스프링 부트 3.x 버전을 기준으로 세팅합니다.

plugins {
    id 'java'
    id 'org.springframework.boot' version '3.5.3'
    id 'io.spring.dependency-management' version '1.1.7'
}

group = 'study'
version = '0.0.1-SNAPSHOT'

java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(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'
    implementation 'com.github.gavlyukovskiy:p6spy-spring-boot-starter:1.9.0'
    compileOnly 'org.projectlombok:lombok'
    runtimeOnly 'com.h2database:h2'
    annotationProcessor 'org.projectlombok:lombok'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testRuntimeOnly 'org.junit.platform:junit-platform-launcher'

    //test 롬복 사용
    testCompileOnly 'org.projectlombok:lombok'
    testAnnotationProcessor 'org.projectlombok:lombok'

    //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"
}

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

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

 

  • build.gradle에 들어가서 QueryDSL 의존성을 추가해줍니다. 주의 : 스프링 부트 3.x 부터는 :jakarta를 붙혀야합니다.

annotationProcessor "com.querydsl:querydsl-apt:${dependencyManagement.importedProperties['querydsl.version']}:jakarta

  • 컴파일 시 @Entity가 붙은 JPA 엔터티를 기반으로 Q-타입 클래스를 자동으로 생성해줍니다.
  • 이게 없으면 Q클래스가 생성되지 않아서 QueryDSL을 사용할 수 없습니다.

 

annotationProcessor "jakarta.annotation:jakarta.annotation-api

 

  • APT 처리 과정에서 필요한 @Generated 등의 어노테이션을 제공하는 Jakarta 표준 API입니다.
  • QueryDSL APT가 작동하기 위해 필요합니다.

APT ( Annotation Processing Tool )? 

-> 컴파일 타임에 어노테이션을 분석해서 코드를 자동 생성해주는 도구

-> 결국에는 엔터티를 분석해서 Q클래스로 만들어주는 도구입니다.

 

annotationProcessor "jakarta.persistence:jakarta.persistence-api

 

  • JPA의 기본 어노테이션들(@Entity, @Id, @OneToMany, 등등)을 제공하는 Jakarta Persistence API입니다.
  • APT가 Q타입을 만들 때 이 JPA 어노테이션들을 인식해야 하므로 processor로 지정합니다.

# 엔터티 생성

예제 코드로 사용할 Member 엔티티와 Team 엔티티를 생성하겠습니다.

@Entity
@Getter
@Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Member {

    @Id
    @GeneratedValue
    @Column(name = "member_id")
    private Long id;
    private String username;
    private int age;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "team_id")
    private Team team;
}

 

 

@Entity
@Getter
@Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Team {

    @Id
    @GeneratedValue
    @Column(name = "team_id")
    private Long id;
    private String name;

    @OneToMany(mappedBy = "team")
    private List<Member> members = new ArrayList<>();
}

# Q클래스 생성

1. settings -> gradle -> 모두 gradle로 설정해줍니다.

 

2. 인텔리제이 우측 사이드바에 있는 gradle을 클릭합니다. 아래 사진이 보이는 순서대로 클릭해줍니다.

 

3. 루트 패키지/build/generated/ 경로로 들어가서 Q클래스 생성을 확인해줍니다.


# QueryDSL 기본문법

(기본 문법은 테스트 환경에서 진행하였습니다.)

(테스트 환경을 돌리기 전에 데이터베이스를 먼저 연결해야합니다.)

 

테스트 환경 세팅

@SpringBootTest
@Transactional
public class QueryDslTest {

    @PersistenceContext
    EntityManager em;

    JPAQueryFactory queryFactory;

    @BeforeEach
    void beforeEach() {
        queryFactory = new JPAQueryFactory(em);
        Team teamA = new Team("teamA");
        Team teamB = new Team("teamB");

        em.persist(teamA);
        em.persist(teamB);

        Member member1 = new Member("member1", 10, teamA);
        Member member2 = new Member("member2", 20, teamA);
        
        Member member3 = new Member("member3", 30, teamA);
        Member member4 = new Member("member4", 40, teamA);
        em.persist(member1);
        em.persist(member2);
        em.persist(member3);
        em.persist(member4);
    }
}
  • JPAQueryFactory는 QueryDSL 쿼리를 만들기 위한 핵심 객체이며, 내부적으로 EntityManager를 사용한다.
  • 예제로 사용하기 위한 Team과 Member를 생성하였다.

# QueryDSL vs JPQL

/**
 * member1을 찾아라
 */
@Test
void startJPQL() {
    String sql = "select m from Member m " +
            "where m.username = :username";

    Member findMember = em.createQuery(sql, Member.class)
            .setParameter("username", "member1")
            .getSingleResult();

    Assertions.assertThat(findMember.getUsername()).isEqualTo("member1");
}
  • JPQL만으로도 충분히 쿼리를 생성하여 조건에 맞는 결과를 찾을 수 있습니다.
  • 하지만 JPQL은 쿼리를 직접 만들어야하기 때문에 오타가 날 수도 있고, 컴파일 에러 시점이 아닌 런타임 에러 시점에서 오류가 발생하여 오류를 찾기가 어렵습니다.
  • 이러한 단점을 보완하기 위해서 나온게 QueryDSL입니다.
    import static study.querydsl.entity.QMember.*;
    
    @Test
    void startQueryDsl() {
//        QMember m = new QMember("m");
//        QMember m = QMember.member;

        Member findMember = queryFactory
                .select(member)
                .from(member)
                .where(member.username.eq("member1"))
                .fetchOne();

        Assertions.assertThat(findMember.getUsername()).isEqualTo("member1");
    }
  • Q클래스는 위에 주석처리된 방식으로 객체를 생성해서 사용해도 되고, static import를 사용하여 바로 사용하셔도 됩니다.
  • QueryDSL은 JPQL을 자바 코드로 타입 안정성 있게 작성할 수 있도록 도와주는 JPQL 빌더입니다.
  • QueryDSL은 JPQL과 다르게 컴파일 시점에서 오류를 잡아줍니다.

 


# 검색 조건 쿼리

기본 검색 쿼리

@Test
public void search() {
    Member findMember = queryFactory
            .selectFrom(member)
            .where(member.username.eq("member1")
                    .and(member.age.eq(10)))
            .fetchOne();

    Assertions.assertThat(findMember.getUsername()).isEqualTo("member1");
    Assertions.assertThat(findMember.getAge()).isEqualTo(10);
}
  • 검색 조건은 .and(), or() 로 메서드 체인으로 연결할 수 있습니다.
  • where(member.username.eq("member1"), member.age.eq(10)) 와 같이 and조건을 ,로 바로 사용할 수 있습니다.
  • select, from을 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%’ 검색

# 결과 조회

@Test
void resultFetch() {
    List<Member> fetch = queryFactory
            .selectFrom(member)
            .fetch();

    Member member1 = queryFactory
            .selectFrom(member)
            .fetchOne();

    Member member2 = queryFactory
            .selectFrom(member)
            .fetchFirst();

    QueryResults<Member> results = queryFactory
            .selectFrom(member)
            .fetchResults();

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

 


# 정렬

/**
 * 회원 정렬 순서
 * 1. 회원 나이 내림차순(desc)
 * 2. 회원 이름 올림차순(asc)
 * 단 2에서 회원 이름이 없으면 마지막에 출력(nulls last)
 */
@Test
public void sort() {
    em.persist(new Member(null, 100));
    em.persist(new Member("member5", 100));
    em.persist(new Member("member6", 100));

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

    for (Member member1 : result) {
        System.out.println("member1 = " + member1);
    }
}
  • orderBy() : 정렬
  • desc() : 내림차순, asc() : 오름차순
  • nullsLast() : null을 마지막에 출력

# 페이징

조회 건수 제한

@Test
void paging1() {
    List<Member> result = queryFactory
            .selectFrom(member)
            .orderBy(member.username.desc())
            .offset(1)
            .limit(2)
            .fetch();

    Assertions.assertThat(result.size()).isEqualTo(2);
}
  • offset(n) : n부터 시작 (index)
  • limit(n) : 최대 n건 조회
  • 페이징을 할 때에는 orderBy 사용해 정렬 후 사용합니다.( orderBy 없이 페이징하면 페이지마다 결과 순서가 달라질 수 있음)

#집합

/**
 * JPQL
 * select
 * COUNT(m), //회원수
 * SUM(m.age), //나이 합
 * AVG(m.age), //평균 나이
 * MAX(m.age), //최대 나이
 * MIN(m.age) //최소 나이
 * from Member m
 */
@Test
public void aggregation() {
    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);
    Long count = tuple.get(member.count());
    Integer sum = tuple.get(member.age.sum());
    Double avg = tuple.get(member.age.avg());
    Integer max = tuple.get(member.age.max());
    Integer min = tuple.get(member.age.min());
}
  • 여러 개의 값을 동시에 조회하고 있기 때문에 Tuple이라는 타입으로 반환된다.
  • tuple은 result.get(0)으로 꺼낼 수 있다. (List의 size가 1이기 때문에 get(0)를 사용해 튜플을 꺼낸다.)
  • tuple.get을 사용하여 조회 결과를 꺼낼 수 있다.

#GroupBy

/**
 * 팀의 이름과 각 팀의 평균 연령을 구해라.
 */
@Test
public void group() {
    List<Tuple> result = queryFactory
            .select(team.name,
                    member.age.avg())
            .from(member)
            .join(member.team, team)
            .groupBy(team.name)
            .fetch();

    Tuple teamA = result.get(0);
    Tuple teamB = result.get(1);

    assertThat(teamA.get(team.name)).isEqualTo("teamA");
    assertThat(teamA.get(member.age.avg())).isEqualTo(15);
    assertThat(teamB.get(team.name)).isEqualTo("teamB");
    assertThat(teamB.get(member.age.avg())).isEqualTo(35);
}
  • groupBy : 그룹화된 결과에 조건을 추가하고싶으면 having()을 사용하면 된다.

#조인

기본 조인

/**
 * 팀 A에 소속된 모든 회원
 */
@Test
public void join() {
    List<Member> result = factory
            .selectFrom(member)
            .join(member.team, team)
            .where(team.name.eq("teamA"))
            .fetch();

    assertThat(result)
            .extracting("username")
            .containsExactly("member1", "member2");
}
  • join(조인 대상, 별칭으로 사용할 Q타입)
  • join() , innerJoin() : 내부 조인(inner join)
  • leftJoin() : left 외부 조인(left outer join)
  • rightJoin() : rigth 외부 조인(rigth outer join)

세타 조인(막 조인)

/**
 * 세타 조인
 * 회원의 이름이 팀 이름과 같은 회원 조회
 */
@Test
public void theta_join() {
    em.persist(new Member("teamA"));
    em.persist(new Member("teamB"));

    List<Member> result = factory
            .select(member)
            .from(member, team)
            .where(member.username.eq(team.name))
            .fetch();

    assertThat(result)
            .extracting("username")
            .containsExactly("teamA", "teamB");
}
  • 연관관계가 없는 필드로 조인
  • from 절에 여러 엔터티 선택해서 세타 조인
  • 외부 조인 불가능(left, right)

조인 - on절

/**
 * 예) 회원과 팀을 조인하면서, 팀 이름이 teamA인 팀만 조인, 회원은 모두 조회
 * JPQL: SELECT m, t FROM Member m LEFT JOIN m.team t on t.name = 'teamA'
 * SQL: SELECT m.*, t.* FROM Member m LEFT JOIN Team t ON m.TEAM_ID=t.id and
 t.name='teamA'
 */
@Test
public void join_on_filtering() {
    List<Tuple> result = factory
            .select(member, team)
            .from(member)
            .leftJoin(member.team, team)
            .on(team.name.eq("teamA"))
            .where(team.name.eq("teamA"))
            .fetch();

    for (Tuple tuple : result) {
        System.out.println("tuple = " + tuple);
    }
}
  • on 절을 활용해 조인 대상을 필터링 할 때, 외부조인이 아니라 내부조인(inner join)을 사용하면, where절에서 필터링 하는 것과 기능이 동일하다.

페치 조인(fetch join)

 

fetch join을 사용하지 않았을 경우

@Test
public void fetchJoinNo() {
    em.flush();
    em.clear();

    Member findMember = factory
            .selectFrom(member)
            .where(member.username.eq("member1"))
            .fetchOne();
    boolean loaded = emf.getPersistenceUnitUtil().isLoaded(findMember.getTeam());
    assertThat(loaded).as("페치 조인 미적용").isFalse();
}

  • 지연로딩으로 인하여 team은 아직 불러오지 않음.

fetch join을 사용했을 경우

@Test
public void fetchJoinUse() throws Exception {
    em.flush();
    em.clear();
    Member findMember = factory
            .selectFrom(member)
            .join(member.team, team).fetchJoin()
            .where(member.username.eq("member1"))
            .fetchOne();
    boolean loaded = emf.getPersistenceUnitUtil().isLoaded(findMember.getTeam());
    assertThat(loaded).as("페치 조인 적용").isTrue();
}

  • member와 team을 한 쿼리로 함께 조회

#서브쿼리

서브쿼리 eq 사용

/**
 * 나이가 가장 많은 회원 조회
 */
@Test
public void subQuery() {

    QMember memberSub = new QMember("memberSub");

    List<Member> result = factory
            .selectFrom(member)
            .where(member.age.eq(
                    JPAExpressions.select(memberSub.age.max())
                            .from(memberSub)
            ))
            .fetch();

    assertThat(result).extracting("age").containsExactly(40);
}

서브쿼리 goe 사용

/**
 * 나이가 평균 나이 이상인 회원
 */
@Test
public void subQueryGoe() {
    QMember memberSub = new QMember("memberSub");

    List<Member> result = factory
            .selectFrom(member)
            .where(member.age.goe(
                    JPAExpressions.select(memberSub.age.avg())
                            .from(memberSub)
            ))
            .fetch();

    assertThat(result).extracting("age")
            .containsExactly(30, 40);
}

서브쿼리 여러 건 처리 in 사용

/**
 * 서브쿼리 여러 건 처리, in 사용
 */
@Test
public void subQueryIn() {
    QMember memberSub = new QMember("memberSub");

    List<Member> result = factory
            .selectFrom(member)
            .where(member.age.in(
                    JPAExpressions.select(memberSub.age)
                            .from(memberSub)
                            .where(memberSub.age.gt(10))
            ))
            .fetch();

    assertThat(result).extracting("age")
            .containsExactly(20,30, 40);
}

 select 절에 subquery

@Test
public void selectSubQuery() {
    QMember memberSub = new QMember("memberSub");

    List<Tuple> result = factory
            .select(member.username,
                    JPAExpressions.select(memberSub.age.avg())
                            .from(memberSub))
            .from(member)
            .fetch();

    for (Tuple tuple : result) {
        System.out.println("tuple = " + tuple);
    }
}
  • JPAExpressions를 활용하여 서브쿼리 작성 가능
  • JPAExperssions는 static import 가능
  • from절에는 서브쿼리 불가능

CASE문

단순 조건

@Test
public void basicCase() {
    List<String> result = factory
            .select(member.age
                    .when(10).then("열살")
                    .when(20).then("스무살")
                    .otherwise("기타"))
            .from(member)
            .fetch();

    for (String s : result) {
        System.out.println("s = " + s);
    }
}

복잡 조건

@Test
public void complexCase() {
    List<String> result = factory
            .select(new CaseBuilder()
                    .when(member.age.between(0, 20)).then("0~20살")
                    .when(member.age.between(21, 30)).then("21~30살")
                    .otherwise("기타"))
            .from(member)
            .fetch();

    for (String s : result) {
        System.out.println("s = " + s);
    }
}

정리

  • QueryDSL은 JPQL을 사용할 때, 휴먼 에러(오타) 를 방지하기 위해서 자주 사용한다.
  • QueryDSL을 사용하면 컴파일 에러가 발생하여 오류를 발견하기 쉽다.
  • Join에 대해서 조금 더 공부를 해야할 것 같다.