Study/spring

QueryDSL(2) - 쿼리 생성 방법, 기본 문법

유경호 2021. 1. 25. 20:43
반응형

기본 Q-Type 활용

쿼리 타입(Q)은 사용하기 편리하도록 기본 인스턴스를 보관하고 있다. 하지만 같은 엔티티를 조인하거나 같은 엔티티를 서브쿼리에 사용할 때 사용하면 별칭이 겹치기 때문에 따로 별칭을 주입하여 생성해줘야 한다.

쿼리 타입(Q) 사용 예제

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

아래와 같이 쿼리 타입의 기본 인스턴스를 import static을 활용해서 사용하면 코드를 더 간결하게 작성할 수 있다.

import static ...Qmember.member; // 기본 인스턴스 static import

...

    public void qtype() {
        EntityManager em = emf.createEntityManager();

        JPAQueryFactory query = new JPAQueryFactory(em);    
        List<Member> result =
                query.selectFrom(member)
                        .where(member.name.eq("member1"))
                        .orderBy(member.name.desc())
                        .fetch();
    }

...

쿼리 만들기

QureyDSL을 사용하기 위해선 먼저 QueryDSL이 제공하는 JPAQueryFactory 클래스의 인스턴스를 생성해야 한다.

JPAQueryFactory queryFactory = new JPAQueryFactory(em);
  • EntityManager를 주입하여 인스턴스를 생성해야 함

Srping Boot를 사용하면 다음과 같이 QueryFactory를 각 로직마다 주입해 줄 필요 없이 필드로 빼도 동시성 문제 없이 각각 별도의 영속성 컨텍스트를 제공 받아 작동한다.

@SpringBootTest
@Transactional
public class QueryDslBasicTest {

    @Autowired
    EntityManager em;

    JPAQueryFactory queryFactory;

    public void example() {
        queryFactory = new JPAQueryFactory(em)
        ...
    }

이제 일반적인 JPQL과 QueryDSL을 비교해보겠다.

public void startJPQL() {
        // memeber1을 찾아라
        Member result = em.createQuery(
                "select m from Member m " + 
                "where m.username = :username", Member.class)
                .setParameter("username", "member1")
                .getSingleResult();
    }
import static me.kyeongho.entity.QMember.member;

public void startQueryDsl() {
        // memeber1을 찾아라
        Member result = queryFactory
                .select(member)
                .from(member)
                .where(member.username.eq("member1"))
                .fetchOne();
    }

위 두 코드는 동일한 JPQL로 데이터베이스에 조회 쿼리를 날린다. QueryDSL은 Criteria와 같이 JPQL 빌더 역할을 하기 때문에 결론적으로 JPQL을 생성하여 조회한다.

하지만 둘의 차이는 명확하다. JPQL은 문자로 작성이 되며 파라미터 바인딩을 직접 기입해줘야 한다. 반면에 QueryDSL은 쿼리 메소드와 쿼리 타입(Q)을 통해 자바 코드로 작성이 되며 파라미터 바인딩도 자동으로 처리한다.

하여 QueryDSL은 쿼리에 오류가 있다면 컴파일 시점에서 오류를 발견할 수 있고, 자바 코드 작성 시 사용할 수 있는 IDE의 막강한 기능들을 이용해 JPQL 쿼리를 생성할 수 있다.

QueryDSL이 제공하는 기본적인 쿼리 메소드

  • select()
  • from()
  • selectFrom()
    → select하는 엔티티와 from의 엔티티가 일치할 경우 합칠 수 있다.
  • where()
  • update()
  • set()
  • delete()

위와 같은 메소드를 체인으로 연결해 조합하여 마치 JPQL을 직접 짜듯이 메소드를 이용하여 쿼리를 짤 수 있다. 세세하게 들어가면 편의성이 높은 다양한 메소드들을 추가적으로 제공한다.


이제 다양한 예제를 통해 QueryDSL로 로직을 짜보도록 하자. 참고로 Spring Boot와 Junit5를 활용한 테스트 코드로 예제를 작성했다.

 

검색 조건 쿼리

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

        assertThat(result.getUsername()).isEqualTo("member1");
    }
  • 검색 조건은 .and(), or() 메서드를 체인으로 연결할 수 있다.

JPQL이 제공하는 모든 검색 조건을 QueryDSL이 아래와 같은 메소드로 지원한다.

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() 조건을 파라미터로 처리

    @Test
    public void searchAndParam() {
        Member result = queryFactory
                .selectFrom(member)
                .where( // and만 있는 경우엔 ,로 넣어도 된다.
                        member.username.eq("member1"),
                        member.age.eq(10)
                )
                .fetchOne();

        assertThat(result.getUsername()).isEqualTo("member1");
    }
  • where()에 파라미터로 검색조건을 나열하면 and 조건으로 연결됨
  • 파라미터가 null 값으로 들어오면 무시함 → 메소드 추출을 활용하여 동적 쿼리를 깔끔하고 간결하게 구현할 수 있다.

결과 조회

쿼리 완성 후 결과 조회 시에는 다음과 같은 메소드를 이용할 수 있다.

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

결과 조회 메소드 사용 예시

    @Test
    public void getResult() {
        // List로 조회
        List<Member> result1 = queryFactory
                .selectFrom(member)
                .fetch();

        // 단건 조회
        Member result2 = queryFactory
                .selectFrom(member)
                .where(member.username.eq("member1"))
                .fetchOne();

        // == (...).limit(1).fetchOne()
        Member result3 = queryFactory
                .selectFrom(member)
                .fetchFirst();

        // 페이징 정보랑 같이줌
        QueryResults<Member> result4 = queryFactory
                .selectFrom(member)
                .fetchResults();

        result4.getTotal();

        List<Member> result4List = result4.getResults();

        // 토탈 카운트 반환
        long result5 = queryFactory
                .selectFrom(member)
                .fetchCount();
    }

 

정렬

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

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

        assertThat(result.get(0).getUsername()).isEqualTo("member5");
        assertThat(result.get(1).getUsername()).isEqualTo("member6");
        assertThat(result.get(2).getUsername()).isNull();
    }
  • orderBy() 메소드를 사용하여 정렬 쿼리 생성
  • desc(), asc(): 일반 정렬
  • nullsLast(), nullsFirst(): null 데이터 순서 부여

 

페이징

단순 조회 건수 제한

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

        assertThat(result.size()).isEqualTo(2);

    }

페이징 정보 받을 경우

    @Test
    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);
        assertThat(result.getOffset()).isEqualTo(1);
        assertThat(result.getResults().size()).isEqualTo(2);
    }
  • count 쿼리가 같이 실행되므로 카운터 쿼리를 따로 짜야 되는 경우에 분리해 사용한다.
  • QueryResults는 다음과 같은 페이징 정보를 얻을 수 있는 메소드를 지원한다.
    • getTotal(): total count를 얻을 수 있다.
    • getLimit()
    • getOffset()

 

집합

집합 함수 사용 예시

    @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);

        assertThat(tuple.get(member.count())).isEqualTo(4);
        assertThat(tuple.get(member.age.sum())).isEqualTo(52);
        assertThat(tuple.get(member.age.avg())).isEqualTo(13);
        assertThat(tuple.get(member.age.max())).isEqualTo(15);
        assertThat(tuple.get(member.age.min())).isEqualTo(10);
    }
  • JPQL이 제공하는 모든 집합 함수를 제공한다.
    • count()
    • sum()
    • avg()
    • max()
    • min()

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(teamB.get(team.name)).isEqualTo("teamB");
    }
  • groupBy() 메소드를 체인하여 사용할 수 있다.
  • 그룹화된 결과를 제한하려면 having() 메소드를 체인하여 사용하면 된다.

 

조인

기본 조인

조인은 첫 번째 파라미터에 조인 대상을 지정하고 두 번째 파라미터에 별칭으로 사용할 Q타입을 주입하면 된다.

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

기본 조인 예시

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

        assertThat(result)
                .extracting("username")
                .containsExactly("member1", "member3");
    }
  • join(), innerjoin(): 내부 조인
  • leftJoin(): left outer join
  • rightJoin(): right outer join
  • on(): on() 메소드를 체인하여 on 절을 사용할 수도 있음

 

세타 조인

연관관계가 없는 필드로 조인

    @Test
    public void theta_join() {
        em.persist(new Member("teamA"));
        em.persist(new Member("teamB"));
        em.persist(new Member("teamC"));

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

        assertThat(result)
                .extracting("username")
                .containsExactly("teamA", "teamB");
    }
  • from 절에 여러 엔티티를 선택해서 세타 조인
  • 외부 조인 불가능 → on절을 사용하면 외부 조인 가능

 

on절

조인 대상 필터링

예시) 회원과 팀을 조인하면서, 팀 이름이 teamA인 팀만 조인, 회원은 모두 조회

JPQL : select m, t from Member m left join m.team t on t.name = 'teamA'

    @Test
    public void join_on_filtering() {
        List<Tuple> result = queryFactory
                .select(member, team)
                .from(member)
                .leftJoin(member.team, team) // 이런식으로 하면 FK를 대조하여 조인 on member0_.team_id=team1_.team_id
                .on(team.name.eq("teamA"))
                .fetch();
    }
  • on 절에 검색 조건 쿼리를 넣어 조인 대상으로 필터링할 수 있다.

 

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

 

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

예시)회원의 이름이 팀 이름과 같은 대상 외부 조인

    @Test
    public void theta_on_no_relation() {
        List<Tuple> result = queryFactory
                .select(member, team) // 이런식으로 하면 FK를 대조하지 않고 조인함 member.team_id와 team.team_id를 대조하지 않음
                .from(member)
                .leftJoin(team).on(member.username.eq(team.name))
                .fetch();
    }
  • 하이버네이트 5.1부터 on을 사용하여 서로 관계가 없는 필드로 외부 조인하는 기능이 추가 되었음.
  • 문법이 일반 조인과는 차이가 있으니 주의 깊게 써야한다. leftJoin.() 부분에 일반 조인과 다르게 엔티티 하나만 들어감.
    • 일반 조인: leftJoin(member.team, team)
    • on조인: from(member).leftJoin(team).on(...)

 

페치 조인

페치 조인은 JPA가 제공하는 조인 기능으로 연관관계가 있는 엔티티들을 한꺼번에 조회해주는 조인 기능이다.

    @PersistenceUnit
    EntityManagerFactory emf;

    @Test
    public void fetchJoinNo() {
        em.flush();
        em.clear();
        Member result = queryFactory
                .selectFrom(member)
                .join(member.team, team).fetchJoin()
                .where(member.username.eq("member1"))
                .fetchOne();

        boolean loaded = emf.getPersistenceUnitUtil().isLoaded(result.getTeam());

        assertThat(loaded).as("페치 조인 적용").isTrue();
    }
  • join(), leftJoin()등 조인 기능 뒤에 .fetchJoin()을 추가하면 된다.

 

서브쿼리

com.querydsl.jpa.JPAExperssions를 사용하여 서브쿼리를 작성할 수 있다.

    /**
     * 나이가 가장 많은 회원 조회
     * eq()에 서브쿼리 사용
     */
    @Test
    public void subQeury() {
        QMember memberSub = new QMember("memberSub");

        Member result = queryFactory
                .selectFrom(member)
                .where(member.age.eq(
                        JPAExpressions
                                .select(memberSub.age.max())
                                .from(memberSub))
                        )
                .fetchOne();

        assertThat(result.getUsername()).isEqualTo("member4");

    }

    /**
     * 나이가 평균 이상인 회원 조회
     * goe()에 서브쿼리 사용
     */
    @Test
    public void subQeuryGoe() {
        QMember memberSub = new QMember("memberSub");

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

        assertThat(result).extracting("age")
                .containsExactly(13, 14, 15);

    }

    /**
     * 서브쿼리 여러 건 in() 사용하여 처리
     */
    @Test
    public void subQeuryIn() {
        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(13))))
                .orderBy(member.age.asc())
                .fetch();

        assertThat(result).extracting("age")
                .containsExactly(14, 15);
    }

    /**
     * select절에 서브쿼리 사용
     */
    @Test
    public void selectSubQeury() {
        QMember memberSub = new QMember("memberSub");

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

    }
  • 조회하는 엔티티와 서브 쿼리의 엔티티가 겹치면 안 되기 때문에 따로 별칭을 주입하여 새로운 쿼리 타입 인스턴스를 생성해 사용해야 함.
  • JPA JPQL의 서브쿼리 한계점으로 from 절에서 서브쿼리는 지원하지 않는다.
  • 하이버네이트 구현체를 사용하면 select 절의 서브쿼리를 지원한다.

 

Case 식

Case식은 특정 조건에 따라 분기할 때 사용하는 JPQL에서 지원하는 문법으로 QueryDSL도 메소드로 Case식을 짤 수 있게 지원한다.

 

Simple CASE 작성 예시

    @Test
    public void basicCase() {
        List<String> result = queryFactory
                .select(member.age
                            .when(10).then("열살")
                            .when(15).then("열다섯살")
                            .otherwise("기타"))
                .from(member)
                .fetch();
    }

 

Searcg CASE 작성 예시

    @Test
    public void complexCase() {
        List<String> result = queryFactory
                .select(new CaseBuilder()
                            .when(member.age.between(0, 13)).then("0 ~ 13살")
                            .when(member.age.between(14, 15)).then("14 ~ 15살")
                            .otherwise("기타"))
                .from(member)
                .fetch();
    }

 

상수, 문자 더하기

상수 사용 예시

    @Test
    public void constant() {
        List<Tuple> result = queryFactory
                .select(member.username, Expressions.constant("A"))
                .from(member)
                .fetch();
    }
  • 상수가 필요하면 Expressions.constant(xxx) 사용

 

문자 더하기 예시

    @Test
    public void concat() {
        // {username}_{age}
        List<String> result = queryFactory
                .select(member.username.concat("_").concat(member.age.stringValue()))
                .from(member)
                .fetch();
    }
  • concat() 이용시 타입이 다른 value들은 .stringValue()를 사용하면 문자 타입으로 변환하여 사용할 수 있다.
반응형