본문 바로가기

Web Sever 개발과 CS 기초/스프링

Querydsl 이해와 사용법

관련 내용

<Spring Data JPA와 Querydsl 완전히 이해하기>

  • SpringDataJPA 이해와 사용법

Spring Data JPA 이해와 사용법

 

  • (현재글)Querydsl 이해와 사용법

Querydsl 이해와 사용법

  • Querydsl JOIN((INNER, LEFT, RIGHT, THETA, FETCH) 사용법

Querydsl 의 JOIN (INNER, LEFT, RIGHT, THETA, FETCH) 사용법

개요 목적

이번 시간에는 Querydsl에 대해서 알아본다.

먼저 JPA의 jpql과 차이를 통해서 Querydsl 기본 개념을 이해한다.

그리고 Querydsl이 제공하는 다양한 기능들을 코드를 통해서 사용 방법을 알아본다.

Querydsl 이란

Query DSL은 오픈소스 프로젝트로 JPQL을 Java 코드로 작성할 수 있도록 하는 라이브러리다.

JPQL vs Querydsl

가장 기본적인 Querydsl의 사용 코드를 보면서, jpql과 다른 Querydsl의 장점을 알아보자.

spring.jpa.properties.hibernate.use_sql_comments: true
//다음 설정을 추가하면 JPQL 코드를 함께 확인할 수 있다. 
@Test
public void startQuerydsl() {
    //EntityManager 로 JPAQueryFactory 생성한다.
    //즉 Querydsl은 JPQL 빌더 역할을 한다. 
    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");
}

JPQL는 문자로 쿼리를 작성해서 실행 시점 오류를 발견하고,

Querydsl는 코드이기 때문에 컴파일 시점 오류를 잡아낼 수 있다.

JPQL는 파라미터 바인딩 직접 해야 하지만,

String qlString =
	 "select m from Member m " +
	 "where m.username = :username";
 Member findMember = em.createQuery(qlString, Member.class)
//이렇게 위치 쓰고, 파라미터 값 설정해야하는 불편함 발생
 .setParameter("username", "member1") /
 .getSingleResult();

Querydsl은 파라미터 바인딩 자동 처리를 해준다.

.where(m.username.eq("member1"))

아렇게 따로 입력하지 않아도 되고, username 옆에 바로 입력해서 파라미터 처리가 가능하다.

Querydsl 기능 알아보기

이 아래 내용부터 Querydsl의 기능과 사용 방법에 대해서 알아보자.

앞으로 나올 예제 쿼리들은 밑에 그림 테이블, 엔티티 구조를 사용한다.

Q-Type 활용 Q클래스 인스턴스 활용하는 2가지 방법

엔티티 바탕으로 Q타입으로 만들어야, 문자열 형식의 JPQL을 자바 코드로 변경이 가능하다.

참고: 같은 테이블을 조인해야 하는 경우가 아니면 기본 인스턴스를 사용하자

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

Where 절 검색 조건 알아보기

.and() , . or() 를 메서드 체인 연결

//and()
Member findMember = queryFactory
            .selectFrom(member)
            .where(member.username.eq("member1")
                .and(member.age.eq(10)))
            .fetchOne();
//and 조건은 단순 추가로 대체할 수 있다.
.where(member.username.eq("member1"), member.age.eq(10))
//or()
where(member.username.eq("member1")
                .or(member.age.eq(10)))

참고: select , from 을 selectFrom 으로 합칠 수 있다.

.select(member)
.from(member)
->
.selectFrom(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%’ 검색

결과 조회

fetch() :

리스트 조회, 데이터 없으면 빈 리스트 반환

fetchOne() :

단 건 조회 결과가 없으면 : null

결과가 둘 이상이면 : com.querydsl.core.NonUniqueResultException

fetchFirst() : limit(1).fetchOne()

fetchResults(), fetchCount() 향후 미 지원 대안 알아보기

fetchCount() : count 쿼리로 변경해서 count 수 조회

fetchResults() : 페이징 정보 포함, total count 쿼리 추가 실행

이 두 개의 메소드는, select 쿼리를 기반으로 count용 쿼리를 내부에서 만들어서 실행한다. 단순한 쿼리에서는 잘 동작하지만, 복잡한 쿼리에서는 제대로 동작하지 않는 문제가 발생한다. 그래서 대안이 필요하다.

count 쿼리 예제

아래 코드로 변경해서 사용하면 된다.

//select count(*) from Member member
Long totalCount = queryFactory
        .select(Wildcard.count)
        .from(member)
        //fetchOne()을 사용해 개수 응답 결과를 받는다.
        .fetchOne();

///select count(member.id) from Member member
Long totalCount = queryFactory
        .select(member.count())
        .from(member)
        .fetchOne();

fetchResults() : 페이징 정보 포함, total count 쿼리 추가 실행

//기존 fetcResults 사용 코드
@Test
void test() {
    Pageable pageable = PageRequest.of(0, 2, Sort.by(Sort.Direction.DESC,
        "username"));

    QueryResults<Member> results = queryFactory
        .select(member).from(member)
        .offset(pageable.getOffset())
        .limit(pageable.getPageSize())
        //콘텐츠와 카운트 쿼리 알아서 두 번 날려준다.
        .fetchResults();
    List<Member> content = results.getResults();
    //Member(id=3, username=member1, age=10)
    //Member(id=4, username=member2, age=20) 결과 잘 출력된다.

    //gettoTal에서 전체 count를 얻을 수 있다.
    long total = results.getTotal();

    //이런 식으로 page 관련 정보를 생성하면된다. 콘텐츠 개수 pageable
    PageImpl page = new PageImpl(content, pageable, total);
}

//코드를 나누어서실행 하면 된다.
@Test
void test() {
    Pageable pageable = PageRequest.of(0, 2, Sort.by(Sort.Direction.DESC,
        "username"));
    List<Member> content = queryFactory
        .select(member)
        .from(member)
        .offset(pageable.getOffset())
        .limit(pageable.getPageSize())
        //fetch로 content만 조회
        .fetch();

    long total = queryFactory
        .select(member)
        .from(member)
        //count 따로 조회하기
        .fetchCount();

    for (Member member1 : content) {
        System.out.println(member1);
    }
    //Member(id=3, username=member1, age=10)
    //Member(id=4, username=member2, age=20) 결과 잘 출력된다.

    //똑같이 얻은 정보로 page 관련 정보 생성하면 된다. 
    PageImpl page = new PageImpl(content, pageable, total);
}

정렬

desc() , asc() : 일반 정렬
nullsLast() , nullsFirst() : null 데이터 순서 부여

아래 조건을 정렬하는 코드를 통해서 정렬 방법에 대해 알아보자.

  • 회원 나이 내림차순(desc)
  • 회원 이름 올림차순(asc)
  • 단 2에서 회원 이름이 없으면 마지막에 출력(nulls last)
@Test
    void sort() {
        em.persist(new Member(null, 100));
        em.persist(new Member("member5", 100));
        em.persist(new Member("member6", 100));
        em.persist(new Member("member7", 110));
        List<Member> result = queryFactory.selectFrom(member)
            .orderBy(member.age.desc(), member.username.asc().nullsLast())
            .fetch();
        for (Member member1 : result) {
            System.out.println(member1.toString());
        }
}
//Member(id=10, username=member7, age=110) - 가장 먼저 나이 내림차순
//Member(id=8, username=member5, age=100)  - 나이 같다면 이름 올림찬순
//Member(id=9, username=member6, age=100)
//Member(id=7, username=null, age=100)     - 이름 없다면 가장 나중에 출력

//.nullsLast()이 없다면 
//desc는 null 값 가장 나중에 출력
//asc는 null 값 가장 먼저 출력

페이징

조회 건수 제한

@Test
public void paging1() {
		List<Member> result = queryFactory
			.selectFrom(member)
			.orderBy(member.username.desc())
			.offset(1) //0부터 시작(zero index)
			.limit(2) //최대 2건 조회
			.fetch();
		assertThat(result.size()).isEqualTo(2);
}

멤버의 전체 수(total count) 필요할 때 - 페이징 처리 위해서

@Test
    public void paging2() {
        QueryResults<Member> queryResults = queryFactory
            .selectFrom(member)
            .orderBy(member.username.desc())
            .offset(1)
            .limit(2)
            .fetchResults();
        //내부에 해당 offset limit 처리된 결과 포함
        List<Member> results = queryResults.getResults();
        //이런 식으로 해당 select의 전체 수가 포함된 queryResults를 반환한다.
        assertThat(queryResults.getTotal()).isEqualTo(4);
        assertThat(queryResults.getLimit()).isEqualTo(2);
        assertThat(queryResults.getOffset()).isEqualTo(1);

실무에서 페이징 쿼리를 작성할 때, 데이터를 조회하는 쿼리는 여러 테이블을 조인해야 하지만,count 쿼리는 조인이 필요 없는 경우도 있다.

그런데 이렇게 자동화된 count 쿼리는 원본 쿼리와 같이 모두 조인을 해버리기 때문에 성능이 안나올 수 있다.

count 쿼리에 조인이 필요없는 성능 최적화가 필요하다면, count 전용 쿼리를 별도로 작성해야 한다.

집합 - count, sum, avg - GroupBy 사용

멤버의 전체 수, 나이의 합, 나이의 평균, 나이의 최대 값, 나의 최솟 값 구하는 쿼리를 작성해보자.

  • COUNT(m), //회원수
  • SUM(m.age), //나이 합
  • AVG(m.age), //평균 나이
  • MAX(m.age), //최대 나이
  • MIN(m.age) //최소 나이
@Test
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);
    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
    @Rollback(value = false)
    public void group() throws Exception {
        List<Tuple> result = queryFactory
            .select(team.name, member.age.avg())
            .from(member)
            .join(member.team, team) //team 또한 Q멤버이다.
            .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() 사용 가능하다.

.groupBy(item.price)
.having(item.price.gt(1000))

Distinct - 중복 데이터 제거

List<String> result = queryFactory
		//여기에 그냥 disticnt() 붙여주면 된다. 
		.select(member.username).distinct()
		.from(member)
		.fetch()

조인 사용하기

Querydsl의 JOIN 사용법은 아래 블로그 글에서 확인할 수 있다.

Querydsl 의 JOIN (INNER, LEFT, RIGHT, THETA, FETCH) 사용법

서브 쿼리

com.querydsl.jpa.JPAExpressions 사용하여, 쿼리 안에 쿼리를 넣을 수 있다.

주의점으로는 서브쿼리에 주쿼리와 같은 엔티티를 사용할 경우 새로운 Q를 만들어줘야 한다.

(where)서브 쿼리 eq사용(나이가 가장 많은 회원 찾기)

@Test
public void subQuery() throws Exception {
    //같은 멤버가 들어가기 때문에 따로 하나 만들어줘야한다!!
    QMember memberSub = new QMember("memberSub");

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

(where)서브 쿼리 in 사용( 서브 쿼리 여러건)

@Test
public void subQueryIn() throws Exception {
    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();
    assertThat(result).extracting("age")
        .containsExactly(20, 30, 40);
}

select 절에 subquery(멤버 평균 나이 select추가하기)

@Test
public void subQuerySelect() throws Exception {
    QMember memberSub = new QMember("memberSub");
    List<Tuple> fetch = queryFactory
        .select(member.username,
            JPAExpressions
                .select(memberSub.age.avg())
                .from(memberSub)
        ).from(member)
        .fetch();
    for (Tuple tuple : fetch) {
        System.out.println("username = " + tuple.get(member.username));
        System.out.println("age = " +
            tuple.get(JPAExpressions.select(memberSub.age.avg())
                .from(memberSub)));
    }
}
//결과
username = member1
age = 25.0
username = member2
age = 25.0
username = member3
age = 25.0
username = member4
age = 25.0

서브쿼리 한계 - from 절에서는 사용하지 못한다.

JPA JPQL 서브쿼리의 한계점으로 from 절의 서브쿼리(인라인 뷰)는 지원하지 않는다. 그래서 Querydsl에서도 사용할 수 없다.

해결 방안

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

Case 문

case에 맞는 값을 보낼 수 있다.

주의 사항, 그런데 이러한 DB에서 복잡한 처리를 맞기는 것은 비 추천한다.

DB에서는 생 데이터를 받아오는 것을 목표로 하고 데이터 변환은 어플리케이션에서 하는 것이 좋다.

@Test
void caseTest() {
    List<Tuple> result = queryFactory
        .select(member.username, member.age
            .when(10).then("열살")
            .when(20).then("스무살")
            .otherwise("기타"))
        .from(member)
        .fetch();
    for (Tuple tuple : result) {
        System.out.println(tuple.toString());
    }
}
//결과
[member1, 열살]
[member2, 스무살]
[member3, 기타]
[member4, 기타]

상수, 문자 더하기

상수 추가하기

@Test
void cTest() {
    List<Tuple> result = queryFactory
        .select(member.username, Expressions.constant("A"))
        .from(member)
        .fetch();
    for (Tuple tuple : result) {
        System.out.println(tuple.toString());
    }
}
//결과
[member1, A]
[member2, A]
[member3, A]
[member4, A]

문자 더하기

member.age.stringValue() 부분이 중요한데,

문자가 아닌 다른 타입들은 stringValue() 로문자로 변환할 수 있다.

이 방법은 ENUM을 처리할 때도 자주 사용한다.

@Test
void Test() {
    List<String> result = queryFactory
        .select(member.username.concat("_").concat(member.age.stringValue()))
        .from(member)
        .fetch();
    for (String string : result) {
        System.out.println(string);
    }
}
//결과
member1_10
member2_20
member3_30
member4_40

프로젝션과 결과 반환 - select 대상 지정

  1. 프로젝션 대상이 하나면 타입을 명확하게 지정할 수 있음
  2. 프로젝션 대상이 둘 이상이면 튜플이나 DTO로 조회

1 프로젝션 대상이 하나 명확한 타입

List<String> result = queryFactory
		.select(member.username)
		.from(member)
		.fetch();

2-1 튜플 조회

com.querydsl.core.Tuple를 사용한다.

List<Tuple> result = queryFactory
		//select절에 두개 이상의 값이 있을 떼
		.select(member.username, member.age)
		.from(member)
		.fetch();

for (Tuple tuple : result) {
		//select 절에 쓴 엔티티 필드를 작성해주면 된다.
		String username = tuple.get(member.username);
		Integer age = tuple.get(member.age);
		System.out.println("username=" + username);
		System.out.println("age=" + age);
}

2-2 프로젝션 DTO 조회

순수 JPA로 결과를 DTO로 반환 할 때, 모든 패키지 명을 써줘야 하는 불편함이 있었다. Querydsl은 그 문제를 해결할 수 있다.

DTO로 죄하는 방법은 4 가지가 있다. (프로퍼티 접근, 필드 직접 접근, 생성자 사용, @QueryProjection 사용) 각 방법의 사용법과 주의점에 대해서 알아보자.

사용할 DTO

public class MemberDto {
    private String username;
    private int age;
}

1 프로퍼티 접근 - setter

DTO에 Setter가 있어야 한다.

@Test
void property() {
    List<MemberDto> result = queryFactory
        .select(Projections.bean(MemberDto.class,
            member.username,
            member.age))
        .from(member)
        .fetch();
}

2 필드 직접 접근

Setter가 필요 없지만, 필드 이름과 넣을 값의 별칭이 같아야 한다.

@Test
void field() {
    List<MemberDto> result = queryFactory
        .select(Projections.fields(MemberDto.class,
            member.username,
            member.age))
        .from(member)
        .fetch();
}

//별칭이 다를 때
public class UserDto {
		//별칭 username과 다를 때
		private String name;
		private int age;
}

//as로 별칭을 맞춰 줘야 한다.
@Test
void field() {
    QMember memberSub = new QMember("memberSub");
    List<UserDto> result = queryFactory
				//별칭을 맞춰줘야 한다.
        .select(Projections.fields(UserDto.class,
                member.username.as("name"),
                ExpressionUtils.as(
                    JPAExpressions
                        .select(memberSub.age.max())
                        .from(memberSub), "age")
            )
        ).from(member)
        .fetch();
}

3 생성자 사용

DTO에 생성자가 있어야 한다.

@Test
void constructor() {
    List<MemberDto> result = queryFactory
        .select(Projections.constructor(MemberDto.class,
            member.username,
            member.age))
        .from(member)
        .fetch();
}

4 @QueryProjection 활용

이 방법은 컴파일러로 타입을 체크할 수 있으므로 가장 안전한 방법이다.

다만 DTO에 QueryDSL 어노테이션을 유지해야 하는 점과 DTO까지 Q 파일을 생성해야 하는 단점이 있다. DTO는 컨트롤러 서비스 등 다양한 레이어에서 활용 되는데 Querydsl에 의존성이 생겨서 사용에 주의를 해야 한다.

//DTO에 @QueryProjection 붙여주기
@QueryProjection
public MemberDto(String username, int age) {
    this.username = username;
    this.age = age;
}
@Test
void queryProjection() {
    List<MemberDto> result = queryFactory
        //이런식으로 QMemberDto 생성자에 넣어주면 된다.
        .select(new QMemberDto(member.username, member.age))
        .from(member)
        .fetch();
}

동적 쿼리

코드로 쿼리를 작성할 수 있는 Querydsl은 동적 쿼리를 작성하기 유리하다. 동적 쿼리 작성하는 법 BooleanBuilder와 Where 다중 파라미터 방법 두 가지를 알아보자.

BooleanBuilder 사용

@Test
public void 동적쿼리_BooleanBuilder() throws Exception {
    String usernameParam = "member1";
    Integer ageParam = 10;
    //username은 null처리롤 조건 등록 안되게 설정
    List<Member> result = searchMember1(null, ageParam);
    for (Member member1 : result) {
        System.out.println(member1);
    }
}

private List<Member> searchMember1(String usernameCond, Integer ageCond) {
    BooleanBuilder builder = new BooleanBuilder();
    if (usernameCond != null) {
        builder.and(member.username.eq(usernameCond));
    }
    if (ageCond != null) {
        builder.and(member.age.gt(ageCond));
    }
    return queryFactory
        .selectFrom(member)
        .where(builder)
        .fetch();
}
//결과 유저이름 없이 나이가 10보다 큰 데이터 출력
Member(id=4, username=member2, age=20)
Member(id=5, username=member3, age=30)
Member(id=6, username=member4, age=40)

Where 다중 파라미터 사용

@Test
public void 동적쿼리_WhereParam() throws Exception {
    String usernameParam = "member1";
    Integer ageParam = 10;
    List<Member> result = searchMember2(usernameParam, ageParam);
    Assertions.assertThat(result.size()).isEqualTo(1);
}

private List<Member> searchMember2(String usernameCond, Integer ageCond) {
    return queryFactory
        .selectFrom(member)
        .where(usernameEq(usernameCond), ageEq(ageCond))
        .fetch();
}
//BooleanExpression 따로 지정해주기.
private BooleanExpression usernameEq(String usernameCond) {
    return usernameCond != null ? member.username.eq(usernameCond) : null;
}
private BooleanExpression ageEq(Integer ageCond) {
    return ageCond != null ? member.age.eq(ageCond) : null;
}

수정, 삭제 벌크 연산

JPQL 배치와 마찬가지로, 영속성 컨텍스트에 있는 엔티티를 무시하고 실행되기 때문에 배치 쿼리를 실행하고 나면 영속성 컨텍스트를 초기화 하는 것이 안전하다.

쿼리 한번으로 대량 데이터 수정

long count = queryFactory
		.update(member)
		.set(member.username, "비회원")
		.where(member.age.lt(28))
		.execute();

기존 숫자에 1 더하기

long count = queryFactory
		.update(member)
		.set(member.age, member.age.add(1))
		.execute();

쿼리 한번으로 대량 데이터 삭제

long count = queryFactory
		.delete(member)
		.where(member.age.gt(18))
		.execute();

SQL function 호출하기

SQL function은 JPA와 같이 Dialect에 등록된 내용만 호출할 수 있다.

member →M으로 변경하는 replace 함수 사용

String result = queryFactory
		.select(Expressions.stringTemplate("function('replace', {0}, {1}, 
		{2})", member.username, "member", "M"))
		.from(member)
		.fetchFirst();