[QueryDSL] 1. QueryDSL 기본 문법

HJ·2024년 3월 11일
0

QueryDSL

목록 보기
1/4
post-thumbnail

김영한 님의 실전! Querydsl 강의를 보고 작성한 내용입니다.


1. JPQL vs QueryDSL

1-1. 비교하기

[ JPQL ]

@Test
public void jpql() {
    String jpql = "select m from Member m where m.username = :username";
    Member findMember = em.createQuery(jpql, Member.class)
                .setParameter("username", "member1")
                .getSingleResult();

    assertThat(findMember.getUsername()).isEqualTo("member1");
}

[ QueryDSL ]

@Test
void queryDSL() {
    JPAQueryFactory queryFactory = new JPAQueryFactory(em);
    QMember m = new QMember("m");

    Member findMember = queryFactory
                .select(m)
                .from(m)
                .where(m.username.eq("member1"))
                .fetchOne();
    assertThat(findMember.getUsername()).isEqualTo("member1");
}

JpaQueryFactory 의 생성자에 EntityManager 를 넘겨줍니다. 그러면 QueryFactory 가 Entitymanager 를 가지고 데이터를 찾거나 하는 등의 작업을 수행합니다.

쿼리는 일반적인 sql 처럼 select, from, where 를 사용하여 작성하면 되고, QueryDSL 은 자동으로 preparedStatement 와 파라미터 바인딩 방식을 사용해서 기존에 setParameter 로 수행했던 과정을 자동으로 수행해줍니다.

쉽게 생각하면 Querydsl은 JPQL 빌더이며, 가장 큰 차이점으로는 JPQL 은 문자로 작성해야 하지만 QueryDSL 은 코드로 작성하기 때문에 컴파일 시점에 오류를 잡아낼 수 있습니다.


1-2. JPAQueryFactory를 필드로

public class QuerydslBasicTest {
    
    @PersistenceContext
    EntityManager em;

    JPAQueryFactory queryFactory;

    @BeforeEach
    public void before() {
        queryFactory = new JPAQueryFactory(em);
        ...
    }
}

JPAQueryFactory 를 필드로 빼고, 이를 생성할 때 entityManager 를 넘겨주는 방식으로 사용해도 됩니다.

스프링 프레임워크는 여러 쓰레드에서 동시에 같은 EntityManager 에 접근해도, 트랜잭션 마다 별도의 영속성 컨텍스트를 제공하기 때문에 JPAQueryFacytory 는 멀티 스레드 환경에서 동시성 문제 없이 동작합니다.




2. Q-Type

Q 클래스 인스턴스를 사용하는 방법에는 2가지가 존재합니다.

QMember m = new QMember("m");   // 별칭 직접 사용
QMember qMember = QMember.member; // Q 클래스 내부에 자동으로 생성된 인스턴스를 사용

2-1. 인스턴스 사용

import static study.querydsl.entity.QMember.*;

@Test
void queryDSL() {
    Member findMember = queryFactory
                .select(member)
                .from(member)
                .where(member.username.eq("member1"))
                .fetchOne();
    assertThat(findMember.getUsername()).isEqualTo("member1");
}

위의 코드는 인스턴스를 사용 + static import 를 사용하는 방식입니다. select 내부에 QMember.member 를 작성한 후 QMember 를 static import 를 하면 위처럼 사용할 수 있습니다.


2-2. 별칭 사용

QueryDSL 은 JPQL 의 빌더 역할을 하며, QueryDSL 로 작성된 것은 JPQL 로 변환되어 실행됩니다. 실행되는 JPQL 을 확인하고 싶다면 아래 설정을 추가하면 됩니다.

spring:
  jpa:
    properties:
      hibernate:
        use_sql_comments: true

설정을 추가하고 실행되는 JPQL 을 살펴보면 아래와 같습니다.

select member1
from Member member1
where member1.username = ?1 

현재 별칭이 member1 이라고 지정된 것을 볼 수 있습니다. 이는 QMember 를 들어가보면 왜 member1 이라고 지정되는지 알 수 있습니다.


QMember 에서 인스턴스 (member )를 만들 때 member1 이라는 이름으로 생성하기 때문에 JPQL 에서 member1 이라고 표시됩니다.


@Test
void queryDSL() {
    QMember m = new QMember("m");

    Member findMember = queryFactory
                .select(m)
                .from(m)
                .where(m.username.eq("member1"))
                .fetchOne();
    assertThat(findMember.getUsername()).isEqualTo("member1");
}
select m
from Member m
where m.username = ?1

만약 별칭을 사용하도록 테스트 코드를 수정하고 실행하면 member1 으로 나오던 것이 별칭으로 지정한 m 으로 변경됩니다.

하지만 같은 테이블을 조인해야 하는 경우가 아니면 기본 인스턴스를 사용하는 것을 권장하신다고 합니다.




3. QueryDSL 기본 문법

3-1. 검색 조건

QueryDSL 은 JPQL 이 지원하는 모든 검색 조건을 제공합니다. 아래 예시 몇 가지가 있습니다.

member.username.isNotNull()             // 이름이 is not null

member.age.in(10, 20)                   // age in (10,20)

member.age.goe(30)                      // age >= 30
member.age.gt(30)                       // age > 30

member.username.like("member%")         // like 검색
member.username.contains("member")      // like ‘%member%’ 검색
member.username.startsWith("member")    // like ‘member%’ 검색

이때 여러 조건을 한 번에 사용하기 위해 and() 와 파라미터 처리를 제공합니다.


[ and ]

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

where 안에 여러 개의 조건을 and 로 묶을 수 있습니다. 참고로 and 말고 or 도 가능하며, select 와 from 을 selectFrom() 으로 줄여서 사용할 수 있습니다.


[ 파라미터 ]

@Test
void search() {
    Member findMember = queryFactory
            .selectFrom(member)
            .where(member.username.eq("member1"), (member.age.eq(10)))
            .fetchOne();
    assertThat(findMember.getUsername()).isEqualTo("member1");
    assertThat(findMember.getAge()).isEqualTo(10);
}

and 로 묶지 않고 쉼표를 통해 끊어서 사용할 수 있습니다. 이 경우에는 AND 조건으로 묶이게 되며 null 값은 무시됩니다. 이 기능과 메서드 추출을 활용해서 동적 쿼리를 깔끔하게 만들 수 있습니다.



3-2. 결과 조회

fetch : 리스트를 조회, 없으면 빈 리스트가 반환

fetchOne : 단건 조회, 결과가 없으면 null, 둘 이상이면 NonUniqueResultException

fetchFirst : limit(1).fetchOne() 을 실행

fetchResults : 페이징 정보 포함한 결과 반환, count 쿼리가 추가로 실행됨

fetchCount : count 를 조회


//페이징에서 사용
QueryResults<Member> results = queryFactory
                .selectFrom(member)
                .fetchResults();

long limit = results.getLimit();
long offset = results.getOffset();
long total = results.getTotal();
List<Member> content = results.getResults();

fetchResult 의 결과에서 getTotal() 로 count 쿼리의 결과를 얻을 수 있고, getResult() 로 데이터를 가져올 수 있습니다. 이때 쿼리는 count 쿼리와 데이터를 가져오는 쿼리 총 2번이 실행됩니다.


fetchCountfetchResult 는 개발자가 작성한 select 쿼리를 기반으로 count 용 쿼리를 내부에서 만들어서 실행합니다.

그런데 이 기능은 단순히 select 구문을 count 처리하는 용도로 바꾸는 정도입니다. 따라서 단순한 쿼리에서는 잘 동작하지만, 복잡한 쿼리에서는 제대로 동작하지 않는다고 합니다.

fetchResults 와 fetchCount 는 Querydsl 5.0 부터 Deprecated 되었습니다. 그 대안으로 fetch 를 사용하고, count 쿼리는 필요한 경우 직접 작성하며, fetchOne 을 실행하면 됩니다.



3-3. 정렬

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

위의 예시는 나이 내림차순, 이름 오름차순 + null 은 가장 마지막에 위치를 기준으로 정렬한 예시입니다.

정렬은 orderBy() 에 지정할 수 있으며, 쉼표를 통해 여러 개를 지정할 수 있습니다. 또 null 데이터의 순서를 지정할 수 있는데 nullsLast(), nullsFirst() 가 있습니다.



3-4. 페이징

@Test
void paging1() {
    List<Member> result = queryFactory
            .selectFrom(member)
            .orderBy(member.username.desc())
            .offset(0)  // 0부터 시작
            .limit(2)
            .fetch();
}

offset 과 limit 를 사용해서 페이징 쿼리를 작성할 수 있으며, offset 은 0 부터 시작합니다.

페이징 쿼리를 작성할 때, 데이터를 조회하는 쿼리는 여러 테이블을 조인해야 하지만, count 쿼리는 조인이 필요 없는 경우도 있습니다. 만약 fetchResult() 를 사용한다면 자동화된 count 쿼리가 원본 쿼리처럼 모두 조인을 해버리기 때문에 성능이 좋지 않을 수도 있습니다. 따라서 count 쿼리에 조인이 필요없는 성능 최적화가 필요하다면, count 전용 쿼리를 별도로 작성해야 합니다.



3-5. 집합 함수

@Test
void group() {
    List<Tuple> result = queryFactory
            .select(team.name, 
                    member.age.avg(),
                    member.age.sum(),
                    member.age.avg(),
                    member.age.max(),
                    member.age.min()
                    )
            .from(member)
            .join(member.team, team)
            .groupBy(team.name)
            .fetch();

Tuple teamA = result.get(0);

assertThat(teamA.get(team.name)).isEqualTo("teamA");
assertThat(teamA.get(member.age.avg())).isEqualTo(15);
}

기본적인 집계 함수들을 다 사용할 수 있으며, Tuple 이 반환됩니다. tuple 안에 담긴 데이터를 가져올 때는 get() 내부에 조회한 것을 넣어주면 됩니다. 추가로 having() 을 통해 그룹된 결과를 제한할 수 있습니다.




4. 조인

4-1. 기본 조인

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

@Test
void join() {
    List<Member> result = queryFactory
            .selectFrom(member)
            .join(member.team, team)
            .where(team.name.eq("teamA"))
            .fetch();
}

JPQL 에서 join m.team t 로 조인을 사용했는데 QueryDSL 도 조인대상을 지정하고 별칭처럼 Q 타입을 지정합니다. join 외에도 innerJoin, leftJoin 과 같은 다른 조인도 사용할 수 있습니다.


select
    member1 
from
    Member member1   
inner join
    member1.team as team 
where
    team.name = ?1

테스트 코드 실행 결과 위와 같은 JPQL 이 생성되고 실행됩니다.



4-2. 세타 조인

@Test
void theta_join() {
    List<Member> result = queryFactory
            .select(member)
            .from(member, team)
            .where(member.username.eq(team.name))
            .fetch();
}

연관관계가 없어도 세타 조인을 통해 조인을 실행할 수 있습니다. 이때는 from 절에 여러 개의 엔티티를 사용하면 됩니다. 하지만 세타 조인은 외부 조인 불가능한데 뒤에 나오는 조인 on 을 사용하면 외부 조인이 가능합니다.



4-3. 조인 ON 절

ON 절을 활용하면 아래 두 가지를 수행할 수 있습니다

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

[ 조인 대상 필터링 ]

@Test
void join_on_filtering() {
    List<Tuple> result = queryFactory
            .select(member, team)
            .from(member)
            .leftJoin(member.team, team)
            .on(team.name.eq("teamA"))
            .fetch();
}

tuple = [Member(id=1, username=member1, age=10), Team(id=1, name=teamA)]
tuple = [Member(id=2, username=member2, age=20), Team(id=1, name=teamA)]
tuple = [Member(id=3, username=member3, age=30), null]
tuple = [Member(id=4, username=member4, age=40), null]

테스트 코드를 수행한 결과는 위와 같습니다. ON 절을 통해 team.name 에 조건을 걸었기 때문에 teamA 만 출력되었고, left join 이기 때문에 teamB 에 해당하는 Member3, Member4 는 team 이 null 로 출력되었습니다.

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


[ 연관관계가 없는 엔티티 외부 조인 ]

@Test
void join_on_no_relation() {
    List<Tuple> result = queryFactory
            .select(member, team)
            .from(member)
            .leftJoin(team)
            .on(member.username.eq(team.name))
            .fetch();

    for (Tuple tuple : result) {
        System.out.println("tuple = " + tuple);
    }
}

하이버네이트 5.1부터 on 을 사용해서 서로 관계가 없는 필드로 내부 조인, 외부조인을 하는 기능이 추가되었습니다.


select
    m1_0.member_id, m1_0.age, m1_0.team_id, m1_0.username, t1_0.team_id, t1_0.name 
from
    member m1_0 
left join
    team t1_0 
on m1_0.username=t1_0.name

기존에는 leftJoin(member.team, team) 을 사용했는데 이렇게 하면 조인의 on 절에 id 값이 들어가게 됩니다.

하지만 이번에는 leftJoin(team) 하나 밖에 없는 것을 볼 수 있습니다. 이렇게 하면 id 매칭이 없어지기 때문에 on 절에 적힌 것처럼 username 와 name 만으로 매칭됩니다.



4-4. 패치 조인

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

기존과 동일하게 join 을 사용하고, 뒤에 fetchJoin() 을 사용하면 연관된 엔티티까지 한 번에 조회할 수 있습니다.




5. 서브쿼리

5-1. where 절 서브쿼리

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

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

QueryDSL 에서 서브쿼리를 사용하려면 JPAExpressions 를 사용합니다. 이때 서브쿼리와 메인쿼리의 alias 가 겹치면 안되기 때문에 Q 객체를 새로 생성해서 사용해야 합니다. 일반적인 SQL 에서 별칭을 다르게 하는 것과 동일한 원리입니다.

eq 외에도 앞에서 보았던 in, goe 와 같은 것들도 사용할 수 있습니다.



5-2. select 절 서브쿼리

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

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

하이버네이트 구현체를 사용하면 select 절의 서브쿼리를 지원합니다. 따라서 Querydsl도 하이버네이트 구현체를 사용하면 select 절의 서브쿼리를 지원합니다.

JPA JPQL 서브쿼리의 한계점으로 from 절의 서브쿼리는 지원하지 않는데 Hibernate 6 부터 from 절에서의 서브쿼리를 지원합니다. 하지만 QueryDSL 에서는 아직 지원하지 않는 것 같다고 합니다.




6. 기타 문법

6-1. Case 문

case 문은 select, where, order by 에서 사용할 수 있습니다.

@Test
void queryDSLCase() {
    // 단순 조건
    List<String> result1 = queryFactory
                .select(member.age
                        .when(10).then("열살")
                        .when(20).then("스무살")
                        .otherwise("기타"))
                .from(member)
                .fetch();

    // 복잡한 조건
    List<String> result1 = 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();
}

복잡한 조건을 사용할 때는 CaseBuilder 를 사용해서 case 조건을 줄 수 있습니다. then 에서 문자가 아닌 숫자를 반환한다면 CaseBuilder 의 반환형을 따로 뽑아 정렬 기준으로 사용할 수 있습니다.



6-2. 상수

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

상수가 필요한 경우 Expressions.constant() 를 사용합니다.



6-3. 문자

String result = queryFactory
        .select(member.username.concat("_").concat(member.age.stringValue()))
        .from(member)
        .where(member.username.eq("member1"))
        .fetchOne();

문자를 더할 때는 concat() 을 사용합니다. 또 문자가 아닌 다른 타입을 문자로 변환할 때는 stringValue() 를 사용하는데 특히 ENUM 을 사용할 때 주로 사용됩니다.

profile
공부한 내용을 정리해서 기록하고 다시 보기 위한 공간

0개의 댓글