QueryDSL - 소개 및 기본 문법 정리

박민수·2023년 11월 14일
0

JPA

목록 보기
6/24
post-thumbnail

QueryDSL

QueryDSL이란 정적 타입을 이용하여 SQL과 같은 쿼리를 생성할 수 있도록 해주는 프레임워크이다. 복잡한 쿼리와 작성하는 데에 도움을 줄 뿐만 아니라, 쿼리를 자바 코드로 작성할 수 있기 때문에 문법 오류를 컴파일 시점에 잡아낼 수 있다.

최신 자바 백엔드 기술은 주로 스프링 부트, JPA, SpringDataJpa라는 기술들을 조합해서 사용한다. 하지만 이러한 기술들을 조합해서 사용하더라도 복잡한 쿼리와 동적 쿼리를 작성하는 데에 있어 한계가 있다. 이러한 한계를 극복할 수 있는 것이 바로 QueryDsl이다.

최근에는 스프링 부트와 JPA라는 기반 위에 스프링 데이터 JPA를 많이 사용한다. 여기에 더해서 QueryDsl이란 프레임워크를 더하면 조금이라도 단순하고 반복이라고 생각했던 개발 코드들이 확연하게 줄어들고, 개발자는 핵심 비즈니스 로직을 개발하는 데 집중할 수 있다.

사용법

build.gradle 설정

// 1. queryDsl version 정보 추가
buildscript {
    ext {
        queryDslVersion = "5.0.0"
    }
}

plugins {
	id 'java'
	id 'org.springframework.boot' version '2.7.15'
	id 'io.spring.dependency-management' version '1.0.15.RELEASE'
    // 2. querydsl plugins 추가
    id "com.ewerk.gradle.plugins.querydsl" version "1.0.10"
}

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

java {
	sourceCompatibility = '11'
}

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.h2database:h2'
	annotationProcessor 'org.projectlombok:lombok'
	testImplementation 'org.springframework.boot:spring-boot-starter-test'

    // 3. querydsl dependencies 추가
    implementation "com.querydsl:querydsl-jpa:${queryDslVersion}"
    implementation "com.querydsl:querydsl-apt:${queryDslVersion}"
}

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

/*
 * queryDSL 설정 추가
 */
// querydsl에서 사용할 경로 설정
def querydslDir = "$buildDir/generated/querydsl"
// JPA 사용 여부와 사용할 경로를 설정
querydsl {
    jpa = true
    querydslSourcesDir = querydslDir
}
// build 시 사용할 sourceSet 추가
sourceSets {
    main.java.srcDir querydslDir
}
// querydsl 컴파일시 사용할 옵션 설정
compileQuerydsl{
    options.annotationProcessorPath = configurations.querydsl
}
// querydsl 이 compileClassPath 를 상속하도록 설정
configurations {
    compileOnly {
        extendsFrom annotationProcessor
    }
    querydsl.extendsFrom compileClasspath
}

gradle 컴파일

우측 상단의 Gradle 탭을 누르고 Tasks -> other -> compileQuerydsl을 누르면 컴파일이 진행된다.

Q타입 생성 확인

컴파일이 완료되면 아래와 같이 Q파일이 생성된다.

build -> generated -> querydsl -> 프로젝트 구조 -> Q클래스명

참고: Q타입은 컴파일 시점에 자동 생성되므로 버전관리(GIT)에 포함하지 않는 것이 좋다. 앞서 설정에서 생성 위치를 gradle build 폴더 아래 생성되도록 했기 때문에 이 부분도 자연스럽게 해결된다. (대부분 gradle build 폴더를 git에 포함하지 않는다.)

스프링 부트 설정 JPA, DB

application.yml에 JPA, DB 관련 설정을 추가한다.

spring:
  datasource:
    url: jdbc:h2:tcp://localhost/~/querydsl
    username: sa
    password:
    driver-class-name: org.h2.Driver
    
  jpa:
    hibernate:
      ddl-auto: create
    properties:
      hibernate:
        # show_sql: true 
        format_sql: true
        
logging.level:
  org.hibernate.SQL: debug 
  # org.hibernate.type: trace
show_sql : System.out 에 하이버네이트 실행 SQL을 남긴다.
org.hibernate.SQL : logger를 통해 하이버네이트 실행 SQL을 남긴다.
org.hibernate.type : SQL 실행 파라미터를 로그로 남긴다.

외부 라이브러리(p6spy) 사용

스프링 부트를 사용한다면 build.gradle에 아래 라이브러리만 추가해주면 된다.

implementation 'com.github.gavlyukovskiy:p6spy-spring-boot-starter:1.5.8'

문법

JPQL과 QueryDsl 비교

// JPQL
@Autowired EntityManager em;

@Test
public void startJPQL() {
    Member findMember = em.createQuery("select m from Member m where m.username = :username", Member.class)
        .setParameter("username", "member1")
        .getResultList();
    
    Assertions.assertThat(findMember.getUsername()).isEqualTo("member1");
}
// QueryDsl
@Autowired EntityManager em;

@Test
public void startQuerydsl() {
    JPAQueryFactory queryFactory = new JPAQueryFactory(em);
    QMember member = new QMember("member");
    
    List<Member> findMember = queryFactory
            .select(member)
            .from(member)
            .where(member
            .username.eq("HongGilDong"))
            .fetch();
     
     Assertions.assertThat(findMember.getUsername()).isEqualTo("member1");
}

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

QMember qMember = new QMember("m"); // 별칭 직접 지정
QMember qMember = QMember.member; // 기본 인스턴스 사용

검색 조건 쿼리

JPQL은 다양한 검색 조건을 제공한다.

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%' 검색
  • AND 조건 : where 절에 파라미터로 검색조건을 추가하면 자동으로 AND 조건이 붙는다. 참고로 select() 절과 from() 절을 합쳐서 selectFrom() 으로 적을 수 있다.
// 이름이 HongGilDong 이고, 나이가 52세인 사람을 찾는다
public void searchAndParam() {
    List<Member> findMember = queryFactory
        .selectFrom(member)
        .where(member.username.eq("HongGildong"), member.age.eq(52))
        .fetch();
}

결과 조회

QueryDsl은 결과를 조회할 때 사용할 수 있는 다양한 API를 제공한다.

  • fetch() : 리스트를 조회할 때 사용하며, 데이터가 없으면 빈 리스트를 반환한다.
List<Member> fetch = queryFactory
    .selectFrom(member)
    .fetch();
  • fetchOne() : 결과가 하나일 때 사용하며, 데이터가 없으면 null을 반환하고, 결과가 복수이면 NonUniqueResultException이 발생한다.
Member findMember = queryFactory
    .selectFrom(member)
    .fetchOne();
  • fetchFirst() : limit(1) 을 걸면서 fetchOne()을 사용하는 것과 같다.
Member findMember = queryFactory
    .selectFrom(member)
    .fetchFirst();
  • fetchResults() : 페이징 정보를 포함하는 total count 쿼리를 추가로 실행한다. (getResults(), getTotal(), getLimit(), getOffset() 메서드 제공)
QueryResults(Member) results = queryFactory
    .selectFrom(member)
    .fetchResults();

List<Member> content = results.getResults();
results.getTotal();
results.getLimit();
results.getOffset();
  • fetchCount() : count 쿼리로 변경해서 count 수를 조회한다.
long count = queryFactory
    .selectFrom(member)
    .fetchCount();

정렬

/**
 * 회원 정렬 순서
 * 1. 회원 나이 내림차순
 * 2. 회원 이름 올림차순
 * 단 2에서 회원 이름이 없으면 마지막에 출력
 */
List<Member> result = queryFactory
    .selectFrom(member)
    .where(member.age.eq(100))
    .orderBy(member.age.desc(), member.username.asc().nullsLast())
    .fetch();

페이징

List<Member> result = queryFactory
    .selectFrom(member)
    .orderBy(member.username.desc())
    .offset(1) // 0부터 시작
    .limit(2)
    .fetch();
// fetchResults() : count 쿼리 1번 + content 쿼리 1번 = 총 2번의 쿼리 나감
QueryResults<Member> queryResults = queryFactory
    .selectFrom(member)
    .orderBy(member.username.desc())
    .offset(1) // 0부터 시작
    .limit(2)
    .fetchResults();

집합

집합 함수 예시

// Tuple : 셀수있는 수량의 순서있는 열거 또는 어떤 순서를 따르는 요소들의 모음
@Test
public void test() 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);
    assertThat(tuple.get(member.count())).isEqualTo(4);
    assertThat(tuple.get(member.age.sum())).isEqualTo(100);
    assertThat(tuple.get(member.age.avg())).isEqualTo(25);
    assertThat(tuple.get(member.age.max())).isEqualTo(40);
    assertThat(tuple.get(member.age.min())).isEqualTo(10);
}

groupBy 예시

 /** 
  * 팀의 이름과 각 팀의 평균 연령을 구해라.
  */
@Test
public void group() throws Exception() {
    List<Tuple> result = queryFactory
        .select(team.name, member.age.avg())
        .from(member)
        .join(member.team, team) // member에 있는 team과 team을 조인
        .groupBy(team.name) // 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);
}

having 예시

.groupBy(item.price) // item의 가격으로 groupBy
.having(item.price.gt(1000)) // groupBy의 결과중에서 1000원이 넘는 것

조인

기본 조인

조인의 기본 문법은 첫 번째 파라미터로 조인 대상을 지정하고, 두 번째 파라미터로 별칭(alias)으로 사용할 Q타입을 지정하면 된다.

join(조인 대상, 별칭으로 사용할 Q타입)
/**
 * 팀 A에 소속된 모든 회원
 */ 
@Test
public void join() {
    List<Member> result = queryFactory
    	.selectFrom(member)
        .join(member.team, QTeam.team) // member의 team과 team을 조인
        .where(team.name.eq("teamA"))
        .fetch();
        
    assertThat(result)
    	.extracting("username")
        .containsExactly("member1", "member2");
}

세타 조인

연관 관계가 없는 필드로 조인이 가능하다. 이런 경우 일반적으로 외부 조인이 불가능하지만, on 절을 이용하면 외부 조인도 가능하다.

/**
 * 세타 조인
 * 회원의 이름이 팀 이름과 같은 회원 조회
 */
@Test
public void theta_join() {
	em.persist(new Member("teamA"));
	em.persist(new Member("teamB"));
    
    List<Member> result = queryFactory
    	.select(member)
        .from(member, team) // 서로 연관관계가 없어도 조인이 가능. from절 나열하는 것과 동일
        .where(member.username.eq(team.name))
        .fetch();
    
    assertThat(result)
    	.extracting("username")
        .containsExactly("teamA", "teamB");
}

조인 - ON 절

ON 절을 이용해서 2가지 일을 할 수 있다.

  1. 조인 대상 필터링
  2. 연관관계가 없는 엔티티를 외부 조인

조인 대상 필터링

/**
 * 회원과 팀을 조인하면서, 팀 이름이 teamA인 팀만 조인, 회원은 모두 조회
 * JPQL: select m, t from Member m left join m.team t on t.name = 'teamA'
 */
// select에 여러가지 타입이 있어서 Tuple로 반환
List<Tuple> result = queryFactory
    .select(member, team)
    .from(member)
    .leftJoin(member.team, team).on(team.name.eq("teamA"))
    .fetch();

참고 : on 절을 활용해 조인 대상을 필터링 할 때, 내부조인을 사용하면, where 절에서 필터링 하는 것과 기능이 동일하다. 따라서 on 절을 활용한 조인 대상 필터링을 사용할 때, 내부조인이면 익숙한 where 절로 해결하고, 정말 외부조인이 필요한 경우에만 이 기능을 사용하자.

List<Tuple> result = queryFactory
    	.select(member, team)
        .from(member)
        .join(member.team, team)
        .on(team.name.eq("teamA"))
        .fetch();

// 위 코드와 동일한 쿼리가 실행된다
List<Tuple> result = queryFactory
    	.select(member, team)
        .from(member)
        .join(member.team, team)
        // .on(team.name.eq("teamA"))
        .where(team.name.eq("teamA"))
        .fetch();

연관 관계 없는 엔티티 외부 조인

/**
 * 연관관계 없는  엔티티 외부 조인
 * 회원의 이름과 팀 이름이 같은 대상 외부 조인
 */
List<Tuple> result = queryFactory
    .select(member, team)
    .from(member)
    .leftJoin(team).on(member.username.eq(team.name))
    .fetch();

참고로 leftJoin() 부분에 일반 조인과 다르게 인티티 하나만 들어간다.

  • 일반조인 : leftJoin(member.team, team)
  • on 조인 : from(member).leftJoin(team).on(...)

조인 - 페치 조인

페치 조인은 SQL에서 제공하는 기능은 아니다. SQL 조인을 활용해서 연관된 엔티티를 SQL 한번에 조회하는 기능이다. 주로 성능 최적화에 사용하는 방법이다.

Member findMember = queryFactory
        .selectFrom(member)
        .join(member.team, team).fetchJoin()
        .where(member.username.eq("member1"))
        .fetchOne();

서브 쿼리

com.querydsl.jpa.JPAExpressions를 사용하면 서브 쿼리를 짤 수 있다.

서브 쿼리 eq 사용

// alias가 중복되면 안 되기 때문에 직접 새로 생성해야 함.
QMember memberSub = new QMember("memberSub");

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

서브 쿼리 goe 사용

// 나이가 평균 이상인 회원 조회
QMember memberSub = new QMember("memberSub");

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

서브 쿼리 in 사용

// 10세 이상의 회원 조회
QMember memberSub = new QMember("memberSub");

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

select 절에 subquery

QMember memberSub = new QMember("memberSub");

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

static import 활용

import static com.querydsl.jpa.JPAExpressions.select;

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

from 절의 서브쿼리 한계

JPA JPQL 서브쿼리의 한계점으로 from 절의 서브쿼리(인라인 뷰)는 지원하지 않는다. 당연히 Querydsl 도 지원하지 않는다. 하이버네이트 구현체를 사용하면 select 절의 서브쿼리는 지원한다. Querydsl도 하이버네이트 구현체를 사용하면 select 절의 서브쿼리를 지원한다.

from 절의 서브쿼리 해결방안

  1. 서브쿼리를 join으로 변경한다. (가능한 상황도 있고, 불가능한 상황도 있다.)
  2. 애플리케이션에서 쿼리를 2번 분리해서 실행한다.
  3. nativeSQL을 사용한다.

Case 문

Case문은 select, where 절 에서 사용이 가능하다.

단순한 조건

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

복잡한 조건

List<String> result = queryFactory
    .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();

orderBy에서 Case 문 함께 사용하기 예제

/**
 * 다음과 같은 임의의 순서로 회원을 출력
 * 1. 0 ~ 30살이 아닌 회원을 가장 먼저 출력
 * 2. 0 ~ 20살 회원 출력
 * 3. 21 ~ 30살 회원 출력
 */
NumberExpression<Integer> rankPath = new CaseBuilder()
    .when(member.age.between(0, 20)).then(2)
    .when(member.age.between(21, 30)).then(1)
    .otherwise(3);

List<Tuple> result = queryFactory
    .select(member.username, member.age, rankPath)
    .from(member)
    .orderBy(rankPath.desc())
    .fetch();
    
for (Tuple tuple : result) {
    String username = tuple.get(member.username);
    Integer age = tuple.get(member.age);
    Integer rank = tuple.get(rankPath);
    System.out.println("username = " + username + " age = " + age + " rank = " + rank);
}

Querydsl은 자바 코드로 작성하기 때문에 rankPath 처럼 복잡한 조건을 변수로 선언해서 select 절, orderBy 절에서 함께 사용할 수 있다.

상수, 문자 더하기

상수 더하기

상수가 필요하면 Expressions.constant(...)을 사용하면 된다.

Tuple result = queryFactory
    .select(member.username, Expressions.constant("A"))
    .from(member)
    .fetchFirst();

참고: 위와 같이 최적화가 가능하면 SQL에 constant 값을 넘기지 않는다. 상수를 더하는 것 처럼 최적화가 어려우면 SQL에 constant 값을 넘긴다.

문자 더하기 concat

// 원하는 결과 : {username}_{age}
String result = queryFactory
    .select(member.username.concat("_").concat(member.age.stringValue()))
    .from(member)
    .where(member.username.eq("member1"))
    .fetchOne();

참고: member.age.stringValue() 부분이 중요한데, 문자가 아닌 다른 타입들은 stringValue() 를 통해 문자로 변환할 수 있다. 이 방법은 ENUM을 처리할 때도 자주 사용한다


참조
https://www.inflearn.com/course/querydsl-%EC%8B%A4%EC%A0%84/dashboard

profile
안녕하세요 백엔드 개발자입니다.

0개의 댓글