쌩로그

실전! Queydsl (feat. JPA 로드맵의 끝 + 앞으로의 계획살짝) 본문

Spring/JPA

실전! Queydsl (feat. JPA 로드맵의 끝 + 앞으로의 계획살짝)

.쌩수. 2024. 3. 3. 09:25
반응형

목록

  1. 포스팅 개요
  2. 본론
      2-1. 프로젝트 환경설정
      2-2. 예제 도메인 모델
      2-3. 기본 문법
      2-4. 중급 문법
      2-5. 실무 활용 - 순수 JPA와 Querydsl
      2-6. 실무 활용 - 스프링 데이터 JPA와 Querydsl
      2-7. 스프링 데이터 JPA가 제공하는 Querydsl 기능
  3. 요약
  4. JPA로드맵을 끝내며.

1. 포스팅 개요

참고로 필자는 부트 3.2를 기준으로 강의를 듣는다.

2. 본론

2-1. 프로젝트 환경설정

build.gradle은 다음과 같다.

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

group = 'younghan'
version = '0.0.1-SNAPSHOT'

java {
    sourceCompatibility = '21'
}

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'

    //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')
}

테스트요 Entity를 다음과 같이 만들고 컴파일 해보자.

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;

import lombok.Getter;
import lombok.Setter;


@Entity
@Getter @Setter
public class Hello {

    @Id
    @GeneratedValue
    private Long id;
}

컴파일을 하면 다음과 같이 build/generated/sources 하위로 QHello라는 클래스가 생긴다.

Querydsl이 Hello라는 Entity를 보고 QHello라는 Entity를 만들어준다.
(사실 build만 해도 그냥 생긴다.)

Q파일을 잘 확인해줘야 한다.

참고로 Q파일 같은 경우 git에 올리면 안 된다...!

테스트를 통해서 동작이 되는지 보면 잘 동작한다.

@SpringBootTest
@Transactional
class QuerydslApplicationTests {

    @Autowired
    EntityManager em;

    @Test
    void contextLoads() {
        Hello hello = new Hello();
        em.persist(hello);

        JPAQueryFactory query = new JPAQueryFactory(em);
        QHello qHello = new QHello("h");

        Hello result = query
                .selectFrom(qHello)
                .fetchOne();

        assertThat(result).isEqualTo(hello);
        assertThat(result.getId()).isEqualTo(hello.getId());
    }

}

다음은 태스트 결과이다.

H2 설정과 라이브러리 설명은 skip한다.

yml설정은 다음과 같이 한다.

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

위의 그림은 테스트시 나오는 SQL인데, 다음과 같이 보면 끝에 물음표(?)가 나오는데, 이 값을 보고싶을 때 다음 build.gradle에 다음 라이브러리를 추가하면 된다.

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

참고로 부트 3 이상 기준이다.

아래 쪽에 잘 나오는 것을 확인할 수 있다.

참고로 쿼리 파라미터를 로그로 남기는 외부 라이브러리는 시스템 자원을 사용하므로, 개발 단계에서는 편하게 사용해도 된다.
하지만 운영에 적용하려면 꼭 성능테스트를 하고 사용하는 것이 좋다...!!!

2-2. 예제 도메인 모델

예제 도메인 모델은 다음과 같다.

Member와 Team의 관계는 일대다 다대일 관계이다.

Entity는 다음과 같이 작성한다.

먼저 Member다.

import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

@Entity
@Getter
@Setter // 실무에선 쓰지 않는다.
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@ToString(of = {"id", "username", "age"})
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;

    public Member(String username) {
        this(username, 0, null);
    }

    public Member(String username, int age) {
        this(username, age, null);
    }

    public Member(String username, int age, Team team) {
        this.username = username;
        this.age = age;
        if(team != null) {
            changeTeam(team);
        }
    }

    // 연관관계 메서드
    private void changeTeam(Team team) {
        this.team = team;
        team.getMembers().add(this);
    }
}

다음은 Team이다.

import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

import java.util.ArrayList;
import java.util.List;

@Entity
@Getter
@Setter // 실무에선 쓰지 않는다.
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@ToString(of = {"id", "name"})
public class Team {

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

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

    public Team(String name) {
        this.name = name;
    }

}

이제 만들었으니 테스트 해보자.

@SpringBootTest
@Transactional
@Commit // 지우기
class MemberTest {

    @Autowired
    EntityManager em;

    @Test
    void testEntity() {
        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, teamB);
        Member member4 = new Member("member4", 40, teamB);
        em.persist(member1);
        em.persist(member2);
        em.persist(member3);
        em.persist(member4);

        // 초기화
        em.flush();
        em.clear();

        List<Member> members = em.createQuery("select m from Member m", Member.class)
                .getResultList();

        for(Member member: members) {
            System.out.println("member = " + member);
            System.out.println("-> member.team = " + member.getTeam());
        }
    }
}

잘 들어가고 잘 출력됨을 알 수 있다.
(참고로 @Commit 은 DB에서만 확인하고 지워준다. 왜냐하면 다른 테스트와 데이터가 꼬여버린다.)

2024-02-20T07:49:13.998+09:00 DEBUG 24784 --- [    Test worker] org.hibernate.SQL                        :
    select
        next value for team_seq
2024-02-20T07:49:14.002+09:00  INFO 24784 --- [    Test worker] p6spy                                    : #1708382954002 | took 1ms | statement | connection 4| url jdbc:h2:tcp://localhost/~/querydsl
select next value for team_seq
select next value for team_seq;
2024-02-20T07:49:14.032+09:00 DEBUG 24784 --- [    Test worker] org.hibernate.SQL                        :
    select
        next value for team_seq
2024-02-20T07:49:14.033+09:00  INFO 24784 --- [    Test worker] p6spy                                    : #1708382954033 | took 0ms | statement | connection 4| url jdbc:h2:tcp://localhost/~/querydsl
select next value for team_seq
select next value for team_seq;
2024-02-20T07:49:14.034+09:00 DEBUG 24784 --- [    Test worker] org.hibernate.SQL                        :
    select
        next value for member_seq
2024-02-20T07:49:14.036+09:00  INFO 24784 --- [    Test worker] p6spy                                    : #1708382954036 | took 0ms | statement | connection 4| url jdbc:h2:tcp://localhost/~/querydsl
select next value for member_seq
select next value for member_seq;
2024-02-20T07:49:14.038+09:00 DEBUG 24784 --- [    Test worker] org.hibernate.SQL                        :
    select
        next value for member_seq
2024-02-20T07:49:14.040+09:00  INFO 24784 --- [    Test worker] p6spy                                    : #1708382954040 | took 0ms | statement | connection 4| url jdbc:h2:tcp://localhost/~/querydsl
select next value for member_seq
select next value for member_seq;
2024-02-20T07:49:14.060+09:00 DEBUG 24784 --- [    Test worker] org.hibernate.SQL                        :
    insert
    into
        team
        (name, team_id)
    values
        (?, ?)
2024-02-20T07:49:14.066+09:00  INFO 24784 --- [    Test worker] p6spy                                    : #1708382954066 | took 0ms | statement | connection 4| url jdbc:h2:tcp://localhost/~/querydsl
insert into team (name,team_id) values (?,?)
insert into team (name,team_id) values ('teamA',1);
2024-02-20T07:49:14.069+09:00 DEBUG 24784 --- [    Test worker] org.hibernate.SQL                        :
    insert
    into
        team
        (name, team_id)
    values
        (?, ?)
2024-02-20T07:49:14.070+09:00  INFO 24784 --- [    Test worker] p6spy                                    : #1708382954070 | took 0ms | statement | connection 4| url jdbc:h2:tcp://localhost/~/querydsl
insert into team (name,team_id) values (?,?)
insert into team (name,team_id) values ('teamB',2);
2024-02-20T07:49:14.071+09:00 DEBUG 24784 --- [    Test worker] org.hibernate.SQL                        :
    insert
    into
        member
        (age, team_id, username, member_id)
    values
        (?, ?, ?, ?)
2024-02-20T07:49:14.074+09:00  INFO 24784 --- [    Test worker] p6spy                                    : #1708382954074 | took 1ms | statement | connection 4| url jdbc:h2:tcp://localhost/~/querydsl
insert into member (age,team_id,username,member_id) values (?,?,?,?)
insert into member (age,team_id,username,member_id) values (10,1,'member1',1);
2024-02-20T07:49:14.076+09:00 DEBUG 24784 --- [    Test worker] org.hibernate.SQL                        :
    insert
    into
        member
        (age, team_id, username, member_id)
    values
        (?, ?, ?, ?)
2024-02-20T07:49:14.081+09:00  INFO 24784 --- [    Test worker] p6spy                                    : #1708382954081 | took 3ms | statement | connection 4| url jdbc:h2:tcp://localhost/~/querydsl
insert into member (age,team_id,username,member_id) values (?,?,?,?)
insert into member (age,team_id,username,member_id) values (20,1,'member2',2);
2024-02-20T07:49:14.083+09:00 DEBUG 24784 --- [    Test worker] org.hibernate.SQL                        :
    insert
    into
        member
        (age, team_id, username, member_id)
    values
        (?, ?, ?, ?)
2024-02-20T07:49:14.086+09:00  INFO 24784 --- [    Test worker] p6spy                                    : #1708382954085 | took 1ms | statement | connection 4| url jdbc:h2:tcp://localhost/~/querydsl
insert into member (age,team_id,username,member_id) values (?,?,?,?)
insert into member (age,team_id,username,member_id) values (30,2,'member3',3);
2024-02-20T07:49:14.086+09:00 DEBUG 24784 --- [    Test worker] org.hibernate.SQL                        :
    insert
    into
        member
        (age, team_id, username, member_id)
    values
        (?, ?, ?, ?)
2024-02-20T07:49:14.089+09:00  INFO 24784 --- [    Test worker] p6spy                                    : #1708382954089 | took 1ms | statement | connection 4| url jdbc:h2:tcp://localhost/~/querydsl
insert into member (age,team_id,username,member_id) values (?,?,?,?)
insert into member (age,team_id,username,member_id) values (40,2,'member4',4);
2024-02-20T07:49:14.522+09:00 DEBUG 24784 --- [    Test worker] org.hibernate.SQL                        :
    select
        m1_0.member_id,
        m1_0.age,
        m1_0.team_id,
        m1_0.username
    from
        member m1_0
2024-02-20T07:49:14.524+09:00  INFO 24784 --- [    Test worker] p6spy                                    : #1708382954524 | took 0ms | statement | connection 4| url jdbc:h2:tcp://localhost/~/querydsl
select m1_0.member_id,m1_0.age,m1_0.team_id,m1_0.username from member m1_0
select m1_0.member_id,m1_0.age,m1_0.team_id,m1_0.username from member m1_0;
member = Member(id=1, username=member1, age=10)
2024-02-20T07:49:14.550+09:00 DEBUG 24784 --- [    Test worker] org.hibernate.SQL                        :
    select
        t1_0.team_id,
        t1_0.name
    from
        team t1_0
    where
        t1_0.team_id=?
2024-02-20T07:49:14.552+09:00  INFO 24784 --- [    Test worker] p6spy                                    : #1708382954552 | took 0ms | statement | connection 4| url jdbc:h2:tcp://localhost/~/querydsl
select t1_0.team_id,t1_0.name from team t1_0 where t1_0.team_id=?
select t1_0.team_id,t1_0.name from team t1_0 where t1_0.team_id=1;
-> member.team = Team(id=1, name=teamA)
member = Member(id=2, username=member2, age=20)
-> member.team = Team(id=1, name=teamA)
member = Member(id=3, username=member3, age=30)
2024-02-20T07:49:14.556+09:00 DEBUG 24784 --- [    Test worker] org.hibernate.SQL                        :
    select
        t1_0.team_id,
        t1_0.name
    from
        team t1_0
    where
        t1_0.team_id=?
2024-02-20T07:49:14.558+09:00  INFO 24784 --- [    Test worker] p6spy                                    : #1708382954558 | took 0ms | statement | connection 4| url jdbc:h2:tcp://localhost/~/querydsl
select t1_0.team_id,t1_0.name from team t1_0 where t1_0.team_id=?
select t1_0.team_id,t1_0.name from team t1_0 where t1_0.team_id=2;
-> member.team = Team(id=2, name=teamB)
member = Member(id=4, username=member4, age=40)
-> member.team = Team(id=2, name=teamB)
2024-02-20T07:49:14.570+09:00  INFO 24784 --- [    Test worker] p6spy                                    : #1708382954570 | took 0ms | commit | connection 4| url jdbc:h2:tcp://localhost/~/querydsl

;

2-3. 기본 문법

시작 - JPQL vs Querydsl

먼저 JPQL과 Querydsl로 member를 찾아오는 것을 비교해보자.

@DisplayName("JPQL로 member1 찾기")
    @Test
    void startJPQL() {
        String qlString =
                "select m from Member m " +
                "where m.username = :username";

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

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

    @Test
    @DisplayName("Querydsl로 member1 찾기")
    void startQuerydsl() {
        JPAQueryFactory queryFactory = new JPAQueryFactory(em);
        QMember m = new QMember("m");

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

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

보다시피 Querydsl의 코드는 간결하다.
그리고 JPQL에서는 파라미터를 바인딩을 해줬지만,
Querydsl은 .where(m.username.eq("member1"))와 같이 자동으로 jdbc의 Prepare Statement로 실행해준다.

또한 둘의 큰 차이는 JPQL은 문자열로 받기 때문에 오타를 치더라도 컴파일과정에서 통과받고, 실행 중에 오류를 발견해야하지만,
Querydsl은 자바코드로 작성하기 때문에, 오타를 내더라도 컴파일시에 오류를 발견할 수 있는 것이 큰 장점이고, 파라미터 바인딩을 자동으로 해결해준다.

다음과 같이 필드로 빼둘 수 있다.

@Autowired
EntityManager em;

JPAQueryFactory queryFactory = new JPAQueryFactory(em);

동시성 문제에 대해 생가할 수 있지만,
EntityManager 같은 경우 동시성에 문제없이 셜계되어있고,
스프링 프레임워크가 주입해주는 EntityManager 자체가 멀티쓰레드에 아무런 문제없이 설계되어있다.

멀티스레드에서 들어오더라도 트랜잭션이 어디에 걸려있는지에 따라서 트랜잰션에 바인딩되도록 분배해준다.
따라서 멀티스레드 환경에서 동시성 문제없이 동작하도록 되어있다.

기본 Q-Type 활용

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

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

위처럼 별칭을 직접 지정할 수도 있고,
아래처럼 기본적으로 생성되어있는 인스턴스가 있다. 이 인스턴스를 쓰는 방법이 있다.

코드에서는 다음과 같이 사용한다.


//        QMember m = new QMember("m"); //
        QMember m = QMember.member;     // 위와 둘중 하나.

        Member findMember = queryFactory
                .select(m)
                .from(m)
                ...

그런데 이를 더 줄일 수 있는데 바로 static import를 사용하는 것이다.

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

...
...

@Test
@DisplayName("Querydsl로 member1 찾기")
void startQuerydsl() {

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

    ...

위와 같이 사용할 수 있다.
(영한님은 이 방식을 권장한다.)

참고로 Querydsl의 결국은 JPQL의 빌더 역할을 하는 것이다.
즉 Querydsl로 작성한 코드는 다 JPQL이 된다고 보면 된다.

그리고 만약 Querydsl에서 실행되는 JPQL을 보고싶다면 yml에 다음과 같이 설정하면된다.

spring.jpa.properties.hibernate.use_sql_comments = true 로 설정하면 된다.

이처럼 주석 쳐진 이 부분이 jpql이다.
(근데 맘에 안 드는 게 파라미터 바인딩이 위치기반으로 잡히네..;;)
참고로 member1이라고 나오는 이유는 QMember의 member가 다음과 같다.

public static final QMember member = new QMember("member1");

바꾸고 싶으면 Q클래스를 새로생성해서 별칭을 직접 지정해주면 된다.

QMember m1 = new QMember("m1");

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

이렇게 하면 실행되는 JPQL은 다음과 같아진다.

/* select
    m1
from
    Member m1
where
    m1.username = ?1 */ select
        m1_0.member_id,
        m1_0.age,
        m1_0.team_id,
        m1_0.username
    from
        member m1_0
    where
        m1_0.username=?

따로 Q클래스를 생성해서 사용하는 경우는 거의 없다.
따로 Q클래스를 생성해서 사용하는 경우는
같은 테이블을 조인해야할 때 alias가 같으면 안 되기 때문에 그럴 때만 선언해서 쓰면 된다.

검색 조건 쿼리

@Test
@DisplayName("검색 조건 쿼리")
void search() {
    Member findMember = queryFactory
            .selectFrom(member)
            .where(member.username.eq("member1")
                    .and(member.age.eq(10)))
            .fetchOne();

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

}

where에서 and를 통해 조건절들을 계속 추가해줄 수 있다.

실행된 JPQL와 SQL은 다음과 같다.

/* select
        member1
from
    Member member1
where
    member1.username = ?1
    and member1.age = ?2 */ select
        m1_0.member_id,
        m1_0.age,
        m1_0.team_id,
        m1_0.username
    from
        member m1_0
    where
        m1_0.username=?
        and m1_0.age=?

위에는 and를 사용했지만, or도 있다.

아래의 표를 참고하자.

Member findMember = queryFactory
        .selectFrom(member)
        .where(member.username.eq("member1")
                .and(member.age.between(10,30)))
        .fetchOne();

between을 사용한 예다.

실행되는 sql은 다음과 같다.

/* select
        member1
from
    Member member1
where
    member1.username = ?1
    and member1.age between ?2 and ?3 */ select
        m1_0.member_id,
        m1_0.age,
        m1_0.team_id,
        m1_0.username
    from
        member m1_0
    where
        m1_0.username=?
        and m1_0.age between ? and ?

그리고 참고로 where절에 and를 사용할 때 다음과 같이 연쇄체인같이 작성했다.

.where(member.username.eq("member1")
            .and(member.age.eq(10)))

이를 다음과 같이 쉼표로 구분할 수 있다.

.where(
        member.username.eq("member1"),
        member.age.eq(10)
)

개인적으론 쉼표가 더 코드가 깔끔해보인다.

단 쉼표는 and로 들어간다는 점을 유의해야 한다.

또한 중간에 다음과 같이 null이 들어갈 수 있다.

.where(
    member.username.eq("member1"),
    member.age.eq(10),
    null
)

이때 null은 무시를 한다.
이로 인해 동적쿼리가 깔끔하게 들어갈 수 있는데, 이는 나중에 알아본다.

결과 조회

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

결과 조회 test 코드다.

@Test
@DisplayName("결과 조회")
void resultFetch() {
    List<Member> fetch = queryFactory
            .selectFrom(member)
            .fetch();

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

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

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

    // deprecate
    long total = queryFactory
            .selectFrom(member)
            .fetchCount();

}

fetchFirst() 참고

fetchFirst()에 대해서는 아래의 클래스에서 확인할 수 있다.

com.querydsl.core.support.FetchableQueryBase // 패키지.클래스

...

@Override
public final T fetchFirst() {
    return limit(1).fetchOne();
}

fetchResults() 참고

fetchResults()count()기능과 동시에 fetch() 기능도 함께 포함한다.

강의 자료 중 일부다.

QuerydslfetchCount(), fetchResult()는 개발자가 작성한 select 쿼리를 기반으로 count용 쿼리를 내부에서 만들어서 실행합니다.
그런데 이 기능은 강의에서 설명드린 것 처럼 select 구문을 단순히 count 처리하는 용도로 바꾸는 정도입니다.
따라서 단순한 쿼리에서는 잘 동작하지만, 복잡한 쿼리에서는 제대로 동작하지 않습니다.
Querydsl은 향후 fetchCount() , fetchResult()를 지원하지 않기로 결정했습니다.
참고로 Querydsl의 변화가 빠르지는 않기 때문에 당장 해당 기능을 제거하지는 않을 것입니다.
따라서 count 쿼리가 필요하면 다음과 같이 별도로 작성해야 합니다.

라고 되어있다.

참고로 count쿼리의 예제는 다음과 같다.


    Long totalCount = queryFactory
    //.select(Wildcard.count) //select count(*)
    .select(member.count()) //select count(member.id)
    .from(member)
    .fetchOne();
System.out.println("totalCount = " + totalCount);
}

정렬

회원 정렬 순서는 다음과 같다고 가정하자.

    1. 회원 이름 내림차순(desc)
    1. 회원 이름 올림차순(asc)

단 2에서 회원 이름이 없으면 마지막에 출력(nulls last)

코드는 다음과 같다.


@Test
@DisplayName("정렬")
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();

    Member member5 = result.get(0);
    Member member6 = result.get(1);
    Member memberNull = result.get(2);
    assertThat(member5.getUsername()).isEqualTo("member5");
    assertThat(member6.getUsername()).isEqualTo("member6");
    assertThat(memberNull.getUsername()).isNull();
}

실행된 JPQL과 SQL은 다음과 같다.

/* select
        member1
    from
        Member member1
    where
        member1.age = ?1
    order by
        member1.age desc,
        member1.username asc nulls last */

select
    m1_0.member_id,
    m1_0.age,
    m1_0.team_id,
    m1_0.username
from
    member m1_0
where
    m1_0.age=?
order by
    m1_0.age desc,
    m1_0.username asc nulls last

위의 nullsLast()말고도 nullsFirst()도 있다.
그러면 이름이 null값인 것이 첫번째로 간다.

Member memberNull = result.get(0);
assertThat(memberNull.getUsername()).isNull();

JPQL과 SQL 결과는 다음과 같다.

// jpql
/* select
        member1
    from
        Member member1
    where
        member1.age = ?1
    order by
        member1.age desc,
        member1.username asc nulls first */

//sql
select
    m1_0.member_id,
    m1_0.age,
    m1_0.team_id,
    m1_0.username
from
    member m1_0
where
    m1_0.age=?
order by
    m1_0.age desc,
    m1_0.username

페이징

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

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

실행되는 JPQL와 SQL은 다음과 같다.

// jpql
/* select
        member1
    from
        Member member1
    order by
        member1.username desc */

// sql
select
    m1_0.member_id,
    m1_0.age,
    m1_0.team_id,
    m1_0.username
from
    member m1_0
order by
    m1_0.username desc
offset
    ? rows
fetch
    first ? rows only

위에 언급한 Deprecated되었던 fetchResults()를 사용하면 다음과 같다.

@Test
@DisplayName("paging2")
void paging2() {
    QueryResults<Member> queryResults = queryFactory
            .selectFrom(member)
            .orderBy(member.username.desc())
            .offset(1)
            .limit(2)
            .fetchResults();

    assertThat(queryResults.getTotal()).isEqualTo(4);       // 카운트 쿼리에 대한
    assertThat(queryResults.getLimit()).isEqualTo(2);       // limit(2)에 대한 값
    assertThat(queryResults.getOffset()).isEqualTo(1);      // offset(1)에 대한 값
    assertThat(queryResults.getResults().size()).isEqualTo(2);

}

실행된 JPQL과 SQL은 다음과 같다.
순서는 전체 member에 대한 count 쿼리를 날리고,
후에 offset, limit에 대한 쿼리를 날려서 정보를 얻는다.

// count 쿼리 JPQL
/* select
    count(member1)
from
    Member member1 */

// count 쿼리 SQL
select
    count(m1_0.member_id)
from
    member m1_0

// member JPQL
/* select
        member1
    from
        Member member1
    order by
        member1.username desc */

// member SQL
select
    m1_0.member_id,
    m1_0.age,
    m1_0.team_id,
    m1_0.username
from
    member m1_0
order by
    m1_0.username desc
offset
    ? rows
fetch
    first ? rows only

paging 쿼리가 단순하면 이렇게 쓸 수 있지만,
만약 컨텐츠(위의 예제에선 member) 쿼리는 되게 복잡한데 카운트(진짜 count) 쿼리는 단순하게 작성할 수 있을 때가 있다. 그때는 쿼리를 따로 작성하는 것이 좋다.

집합

queryFactory
    .select(
            member.count(),     // 총개수
            member.age.sum(),   // 총합
            member.age.avg(),   // 평균
            member.age.max(),   // 최댓값
            member.age.min()    // 최솟값
    )
    .from(member)
    .fetch();

위와 같이 멤버들의 나이의 총합, 평균, 최댓값, 최솟값을 이와 같이 구할 수 있다.

그리고 인텔리제인의 ctrl + alt + v 을 쓰면 해당 값의 결과는 List<Tuple> 타입이다..

Tuple을 통해서 데이터를 같이 꺼낼 수 있다.

참고로 여기서의 Tuple은 쿼리디에스엘의 Tuple이다.
com.querydsl.core.Tuple

데이터는 다음과 같이 꺼낼 수 있다.
그냥 select 절에 적은 그대로다.

tuple.get(member.count())
tuple.get(member.age.sum())
tuple.get(member.age.avg())
tuple.get(member.age.max())
tuple.get(member.age.min())

테스트 코드는 다음과 같다.

@Test
@DisplayName("집합")
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(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);
}

실행된 JPQL과 SQL은 다음과 같다.

// JPQL
/* select
        count(member1),
        sum(member1.age),
        avg(member1.age),
        max(member1.age),
        min(member1.age)
    from
        Member member1 */

// SQL
select
    count(m1_0.member_id),
    sum(m1_0.age),
    avg(cast(m1_0.age as float(53))),
    max(m1_0.age),
    min(m1_0.age)
from
    member m1_0

여기서 Tuple로 조회하는 이유는
member 단일 타입을 조회하는 것이 아니라, 여러 타입으로 조회하기 때문이다.
참고로 실무에서 Tuple을 많이 쓰진 않는다.
실무에서는 DTO를 통해서 데이터를 뽑아오는 방식을 사용한다.

다음은 groupBy를 보자.

/**
 * 팀의 이름과 각 팀의 평균 연령을 구해라.
 */
@Test
@DisplayName("groupBy")
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);

}

실행된 JPQL과 SQL은 다음과 같다.

// JPQL
 /* select
        team.name,
        avg(member1.age)
    from
        Member member1
    inner join
        member1.team as team
    group by
        team.name */

// SQL
select
    t1_0.name,
    avg(cast(m1_0.age as float(53)))
from
    member m1_0
join
    team t1_0
        on t1_0.team_id=m1_0.team_id
group by
    t1_0.name

참고로 having도 사용할 수 있다.

-
.groupBy(team.name)
.having(team.name.eq("teamA"))
-

조인 - 기본 조인

기본 조인

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

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

테스트 코드는 다음과 같다.

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

    assertThat(result)
            .extracting("username")
            .containsExactly("member1", "member2");
}

실행된 JPQL과 SQL은 다음과 같다.

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

// SQL
select
    m1_0.member_id,
    m1_0.age,
    m1_0.team_id,
    m1_0.username
from
    member m1_0
join
    team t1_0
        on t1_0.team_id=m1_0.team_id
where
    t1_0.name=?

참고로 left join과 right join 도 가능하다.

// left join
.selectFrom(member)
.leftJoin(member.team, team)

// right join
.selectFrom(member)
.rightJoin(member.team, team)

left 조인의 JQPL과 SQL은 다음과 같다.

// JPQL
/* select
        member1
    from
        Member member1
    left join
        member1.team as team
    where
        team.name = ?1 */

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

참고로 join에 on을 줄 수 있는데, 이 부분은 뒤에 다룬다.

참고로 연관관계가 없이 조인하는 세타 조인도 가능하다.

다음 코드를 보자.

/**
 * 억지성 예제
 * 회원의 이름이 팀 이름과 같은 회원 조회
 */
@Test
@DisplayName("theta join")
void thetaJoin() {
    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");
}

teamC는 없기때문에 나오지 않는다.

참고로 이런 조인을 하면 모든 멤버와 모든 팀을 가져와서 다 조인한 다음 where절에서 필터링한다.
(물론 DB가 다 최적화를 한다.)
이처럼 막 조인을 하는 것을 세타조인이라고 한다.
이 세타조인도 가능하다!

세타조인의 주의할 점이 있다..!!
제약이 있는데, 외부조인(left outer, right outer 조인)이 되지 않는다.

그런데 개발을 하다보면, 막조인을 하면서 외부조인을 할 때가 있다.
예전에는 안 되었지만, 하이버네이트가 업데이트되면서 연관관게가 없는 테이블도 외부조인을 할 수 있게 되었는데, 다음에 설명할 조인 on을 사용하면 외부 조인이 가능하다.

세타조인읜 JPQL과 SQL은 다음과 같다.

// JPQL
/* select
        member1
    from
        Member member1,
        Team team
    where
        member1.username = team.name */

// SQL
select
    m1_0.member_id,
    m1_0.age,
    m1_0.team_id,
    m1_0.username
from
    member m1_0,
    team t1_0
where
    m1_0.username=t1_0.name

(강의에서는 cross 조인이 일어났지만, 현재는 join 없이 그냥 조회한다.)

조인 - on절

(조인 on절은 JPA 2.1부터 지원하는데, 강의 시점은 참고로 JPA 2.2다.)

on절은 두 가지 기능을 한다.

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

먼저 조인 대상 필터링을 보자.

팀이름이 teamA인 팀만 조, 회원은 모두 조인하는 경우를 코드로 보자.

/**
 * 예) 회원과 팀을 조인하면서, 팀 이름이 teamA인 팀만 조인, 회원은 모두 조회
 * JPQL : select m, t from Member m left join m.team t on t.name = 'teamA'
 */
@Test
@DisplayName("join on 절")
void join_on_filtering() {
    List<Tuple> result = queryFactory
            .select(member, team)
            .from(member)
            .leftJoin(member.team, team).on(team.name.eq("teamA"))
            .fetch();

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

실행된 JPQL, SQL, 결과는 다음과 같다.

// JPQL
/* select
        member1,
        team
    from
        Member member1
    left join
        member1.team as team with team.name = ?1 */

// SQL
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 t1_0.team_id=m1_0.team_id
        and t1_0.name=?

// result
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]

만약에 left join이 아니라 그냥 join이라면?
위의 tuple에서 출력된 id가 3,4 인 member는 담기지 않는다.
왜냐하면 team의 이름이 teamA가 아니기 때문이다.

.join(member.team, team).on(team.name.eq("teamA"))

다음은 결과다.

// JPQL
/* select
        member1,
        team
    from
        Member member1
    inner join
        member1.team as team with team.name = ?1 */

// SQL
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
join
    team t1_0
        on t1_0.team_id=m1_0.team_id
        and t1_0.name=?


// result
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)]

참고

on 절을 활용해 조인 대상을 필터링 할 때, 외부조인이 아니라 내부조인(inner join)을 사용하면, where 절에서 필터링 하는 것과 기능이 동일하다.

.join(member.team, team)
// .on(team.name.eq("teamA"))
.where(team.name.eq("teamA"))

이와 같이 on을 사용하나, where을 사용하나 결과가 같다는 의미다.
따라서 on 절을 활용한 조인 대상 필터링을 사용할 때, 내부조인이면 where 절로 해결하고, 정말 외부조인이 필요한 경우에만 이 기능(on절)을 사용하자.

다음은 연관관계 없는 엔티티 외부 조인이다.

/**
 * 연관관계 없는 엔티티 외부 조인
 * 회원의 이름이 팀 이름과 같은 대상 외부 조인
 */
@Test
@DisplayName("연관관계 없는 엔티티 외부 join")
void join_on_no_relation() {
    em.persist(new Member("teamA"));
    em.persist(new Member("teamB"));
    em.persist(new Member("teamC"));

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

위에서 .leftJoin(team).on(member.username.eq(team.name)) 이 부분을 보면,

leftJoin(member.team, team) 같은 경우는 memner의 team의 idteam의 id가 매칭된다.

그런데, .leftJoin(team).on(member.username.eq(team.name)) 이렇게 되면,
team.namemember의 name이 매칭된다.

실행된 JQPL, SQL 결과는 다음과 같다.

// JPQL
/* select
        member1,
        team
    from
        Member member1
    left join
        Team team with member1.username = team.name */

// SQL
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


// result
tuple = [Member(id=1, username=member1, age=10), null]
tuple = [Member(id=2, username=member2, age=20), null]
tuple = [Member(id=3, username=member3, age=30), null]
tuple = [Member(id=4, username=member4, age=40), null]
tuple = [Member(id=5, username=teamA, age=0), Team(id=1, name=teamA)]
tuple = [Member(id=6, username=teamB, age=0), Team(id=2, name=teamB)]
tuple = [Member(id=7, username=teamC, age=0), null]

이처럼 팀의 이름과 멤버의 이름이 같은 조건을 통해서 찾아온다.
그리고 결과는 외부 조인이기 때문에 위와 같이 member가 다 나오는 것이다.
만약 그냥 조인으로 하면 다음과 같다.

.join(team).on(member.username.eq(team.name))

실행된 JPQL, SQL, result의 결과다.

// JPQL
/* select
        member1,
        team
    from
        Member member1
    inner join
        Team team with member1.username = team.name */


// SELECT
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
join
    team t1_0
        on m1_0.username=t1_0.name

// result
tuple = [Member(id=5, username=teamA, age=0), Team(id=1, name=teamA)]
tuple = [Member(id=6, username=teamB, age=0), Team(id=2, name=teamB)]
  • 하이버네이트 5.1부터 on 을 사용해서 서로 관계가 없는 필드로 외부 조인하는 기능이 추가되었다. (물론 내부 조인도 가능하다.)
  • 주의! 문법을 잘 봐야 한다. leftJoin() 부분에 일반 조인과 다르게 엔티티 하나만 들어간다.
    • 일반조인: leftJoin(member.team, team)
    • on조인: from(member).leftJoin(team).on(xxx)

조인 - 페치 조인

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

코드를 보자. 참고로 영속성 컨텍스트를 날리고 시작한다.

@Test  
@DisplayName("페치 조인 No")  
void fetchJoinNo() {  
    em.flush();  
    em.clear();  

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


}

``

Member와 Team은 Lazy 로딩으로 되게 했다.
그래서 Member는 조회되더라도 팀은 조회되지 않아야 한다.

실행된 JPQL, SQL은 다음과 같다.

// JPQL
/* select
        member1 
    from
        Member member1 
    where
        member1.username = ?1 */ 

// SQL
select
    m1_0.member_id,
    m1_0.age,
    m1_0.team_id,
    m1_0.username 
from
    member m1_0 
where
    m1_0.username=?

member의 팀이 로딩되었는지 확인 가능한데, 다음과 같이 하면된다.

@PersistenceUnit  
EntityManagerFactory emf;  

@Test  
@DisplayName("페치 조인 No")  
void fetchJoinNo() {  
    em.flush();  
    em.clear();  

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

    boolean loaded = emf.getPersistenceUnitUtil().isLoaded(findMember.getTeam());  
    assertThat(loaded).as("패치 조인 미적용").isFalse();  

}

@PersistenceUnit으로 EntityManagerFactory를 불러올 수 있는데,
이 EntityManager를 통해서 .getPersistenceUnitUtil().isLoaded({Object})를 하면 해당 엔티티가 로딩되었는지 아닌지 판별할 수 있다.
현재 페치 조인을 적용하지 않았으므로 로딩이 안 되었다는 것이 맞다.

이제 페치 조인을 적용해보자.

@Test  
@DisplayName("페치 조인 Use")  
void fetchJoinUse() {  
    em.flush();  
    em.clear();  

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

    boolean loaded = emf.getPersistenceUnitUtil().isLoaded(findMember.getTeam());  
    assertThat(loaded).as("패치 조인 미적용").isTrue();  
}

다음은 실행된 JPQL과 SQL이다.

// JPQL
/* select
        member1 
    from
        Member member1   
    inner join

    fetch
        member1.team as team 
    where
        member1.username = ?1 */ 

// SQL
select
    m1_0.member_id,
    m1_0.age,
    t1_0.team_id,
    t1_0.name,
    m1_0.username 
from
    member m1_0 
join
    team t1_0 
        on t1_0.team_id=m1_0.team_id 
where
    m1_0.username=?

.join(member.team, team).fetchJoin() 이와 같이 적용할 수 있다.

서브 쿼리

서브 쿼리는 com.querydsl.jpa.JPAExpressions을 사용하면 된다.

코드를 보자.

/**  
 * 나이가 가장 많은 회원 조회  
 */  
@Test  
@DisplayName("서브 쿼리")  
void subQuery() {  

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

}

실행된 JPQL과 SQL은 다음과 같다.

// JPQL
/* select
        member1 
    from
        Member member1 
    where
        member1.age = (
            select
                max(memberSub.age) 
            from
                Member memberSub
        ) */ 

// SQL        
select
    m1_0.member_id,
    m1_0.age,
    m1_0.team_id,
    m1_0.username 
from
    member m1_0 
where
    m1_0.age=(
        select
            max(m2_0.age) 
        from
            member m2_0
    )

서브 쿼리를 사용할 때는 서브 쿼리와 메인 쿼리의 alias가 달라야 하므로, Q 클래스를 다른 별칭으로 만들어줬다.

해당 쿼리의 결과는 나이가 가장 많은 회원 나이가 40이므로, 나이가 40인 회원을 찾는 것이다.

이번에는 다른 예제를 보자.

/**  
 * 나이가 평균 이상인 회원  
 */  
@Test  
@DisplayName("서브 쿼리")  
void subQueryGoe() {  

    QMember memberSub = new QMember("memberSub");  

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

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

}

나이가 평균 이상인 회원을 조회하는 것이다.
실행된 JPQL과 SQL은 다음과 같다.

// JPQL
/* select
        member1 
    from
        Member member1 
    where
        member1.age >= (
            select
                avg(memberSub.age) 
            from
                Member memberSub
        ) */ 

// SQL
select
    m1_0.member_id,
    m1_0.age,
    m1_0.team_id,
    m1_0.username 
from
    member m1_0 
where
    m1_0.age>=(
        select
            avg(cast(m2_0.age as float(53))) 
        from
            member m2_0
    )

다음은 in절이다.
10살을 초과하는 값을 subQuery로 구한다음
in절을 통해서 나이가 동일한(20, 30, 40) 회원을 조회한다.

/**  
 * 10살을 초과하는 회원  
 */  
@Test  
@DisplayName("서브 쿼리 in")  
void subQueryIn() {  

    QMember memberSub = new QMember("memberSub");  

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

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

}

실행된 JPQL과 SQL은 다음과 같다.

// JPQL
/* select
        member1 
    from
        Member member1 
    where
        member1.age in (select
            memberSub.age 
        from
            Member memberSub 
        where
            memberSub.age > ?1) */ 

// SQL            
select
    m1_0.member_id,
    m1_0.age,
    m1_0.team_id,
    m1_0.username 
from
    member m1_0 
where
    m1_0.age in (select
        m2_0.age 
    from
        member m2_0 
    where
        m2_0.age>?)

다음은 Select SubQuery다.

@Test  
@DisplayName("서브 쿼리 select")  
void selectSubQuery() {  

    QMember memberSub = new QMember("memberSub");  

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

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

}

실행된 JPQL과 SQL은 다음과 같다.

// JPQL
/* select
        member1.username,
        (select
            avg(memberSub.age) 
        from
            Member memberSub) 
    from
        Member member1 */ 


// SQL
select
    m1_0.username,
    (select
        avg(cast(m2_0.age as float(53))) 
    from
        member m2_0) 
from
    member m1_0

다음은 출력결과다.

tuple = [member1, 25.0]
tuple = [member2, 25.0]
tuple = [member3, 25.0]
tuple = [member4, 25.0]

회원의 이름과 평균나이를 구하는 것이다.

그리고, subQuery에 사용되는 JPAExpressionsstatic import가 가능하다.

이 서브 쿼리에는 한계가 있다.
from 절의 서브 쿼리에 한계가 있다.

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

from절의 서브쿼리 해결방안은 다음과 같다.

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

번외의 이야기(from절의 서브쿼리)

번외의 이야기지만, from절의 서브 쿼리를 쓰는 굉장히 많은 이유가 있다.
그 중엔 대게 안 좋은 경우가 많다.
DB는 쿼리에 많은 기능을 지원하기 때문에,
SQL에 화면과 관련된 로직도 넣고, 이쁘게 보여주기 위한 기능도 넣는 등 쿼리를 엄청 복잡하게 짜는 경우가 있다.
그런데 이렇게 되면 어쩔 수 없이 from절이 중첩되는 경우가 많다.

SQL은 데이터를 가져오는 것에 집중을 하고,
필요하면 중간에 애플리케이션에서 어느 정도 로직을 타고,
화면에서 렌더링 될 이쁜 데이터 포맷이나 같은 경우(날씨 포맷을 이쁘게 해준다던가)는 화면단에서 해줘야한다.

그렇게 해야 DB 쿼리도 재사용성이 생긴다..
그런데 화면에 맞춰서 쿼리를 맞추려고 하다보니 from절이 중첩되는 경우가 되게 많다.

그런데 요즘 애플리케이션들은 애플리케이션 로직이 있고, 뷰는 프레젠테이션 로직이 있는 경우가 많다.
그런데 그냥 쿼리를 통해서만 다 하려다 보니 쿼리가 복잡해지고 서브 쿼리가 어쩔 수 없이 들어가야 하는 상황들이 생긴다.

따라서 DB는 데이터만 필터링 혹은 그룹핑을 하고,
애플리케이션 로직은 애플리케이션, 프레젠테이션 로직은 프레젠테이션에서 작성하는 것이 좋다.
(DB는 정말 데이터를 퍼올리는 용도로만 쓰는 것이다.)

따라서 데이터를 최소화해서 가져오는 역할에 집중하면 from 절의 서브 쿼리를 많이 줄일 수 있다.
또한 복잡한 쿼리도 많이 줄일 수 있다.

그리고 한방 쿼리가 나간다 해서 좋은 것은 아니다.
만약 실시간 트래픽이 중요하다면 쿼리 한방 한방이 되게 아깝다.
그런데 이런 경우는 화면에 맞춘 캐시에 많이 발라서 쓴다.

그런데 만약 백단의 어드민 같은 경우는 쪼금은 느려도 되고, 또한 복잡하다.
이때 쿼리를 한방으로 날리기위해서 복잡하게 쿼리를 길게 만드는 경우가 있는데,
그런 경우보단 차라리 쿼리를 두 번, 세 번 나눠서 호출하는 것이 훨씬 좋을 수도 있다.

SQL은 집합적으로 사고를 해서 쿼리를 만들어야 되기 때문에 복잡하지만,
애플리케이션 로직은 시퀀스하게 로직을 만들어서 순차적으로 풀 수 있다.

따라서 SQL AntiPatterns 책에서는 정말 복잡한 쿼리는 쪼개서 호출하는 것이 훨씬 분량을 줄일 수 있다고 한다.

결론은 from절의 서브 쿼리를 줄이기 위해서는 생각의 전환이 필요하다.

Case 문

기본적으로 JPQL에서 지원하는 case는 Querydsl도 지원한다.

정확한 조건을 만들 수 있는 경우가 있고,
케이스 빌더를 통해서 복잡하게 조건들을 사용할 수 있는 방법 등 두 가지의 방법이 있다.

코드를 보자.

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

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

실행된 JPQL과 SQL은 다음과 같다.

// JPQL
/* select
        case 
            when member1.age = ?1 
                then ?2 
            when member1.age = ?3 
                then ?4 
            else '기타' 
        end 
    from
        Member member1 */ 

// SQL
select
    case 
        when m1_0.age=? 
            then cast(? as varchar) 
        when m1_0.age=? 
            then cast(? as varchar) 
        else '기타' 
    end 
from
    member m1_0

출력 결과는 다음과 같다.

s = 열살
s = 스무살
s = 기타
s = 기타

다음은 복잡한 조건이다.

@Test  
@DisplayName("복잡한 case 조건")  
void complexCase() {  
    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();  

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

실행된 JPQL과 SQL은 다음과 같다.

// JPQL
/* select
        case 
            when (member1.age between ?1 and ?2) 
                then ?3 
            when (member1.age between ?4 and ?5) 
                then ?6 
            else '기타' 
        end 
    from
        Member member1 */ 

// SQL        
select
    case 
        when (m1_0.age between ? and ?) 
            then cast(? as varchar) 
        when (m1_0.age between ? and ?) 
            then cast(? as varchar) 
        else '기타' 
    end 
from
    member m1_0

참고로 영한님의 말에 따르면 고민을 해야된다고 한다.
"이거 정말 써야되?" 라는 고민을 해볼 필요가 있다.

권장하시는 것은 가급적이면 DB에서 이런 문제를 해결하지 않는 것이다.
DB에선 로우 데이터를 필터링하거나 그룹핑을 하게 한다.
(물론 필요하면 계산까지 할 수 있겠지만...)

그러나 최소한의 필터링과 그룹핑을 통해 데이터를 줄이는 일만 하고,
실제로 값을 전환하고 바꾸고 보여주는 로직이 들어가는 부분은 DB에서 하면 안 된다.

물론 꼭 필요한 경우도 있다. 그래서 효율이 좋아지는 경우가 있지만,

현재와 같이 간단한 경우는 애플리케이션이나 프레젠테이션에서 로직들을 해결해야 한다.
DB에서 이와 같은 연산을 하는 경우는 좋지 않다고 하신다.

상수, 문자 더하기

상수가 필요하거나, 문자를 더하는 경우가 필요할 수 있다.

먼저 상수를 보자.

@Test  
@DisplayName("상수")  
void constant() {  
    List<Tuple> result = queryFactory  
            .select(member.username, Expressions.constant("A"))  
            .from(member)  
            .fetch();  

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

실행된 JPQL과 SQL은 다음과 같다.

// JPQL
/* select
    member1.username 
from
    Member member1 */ 

// SQL
select
    m1_0.username 
from
    member m1_0

참고로 JPQL에서는 상수가 나가지 않는다.
결과에서만 상수를 받게 된다.
다음은 출력결과다.

tuple = [member1, A]
tuple = [member2, A]
tuple = [member3, A]
tuple = [member4, A]

다음은 문자 더하기이다.

코드를 보자.

@Test  
@DisplayName("concat")  
void concat() {  
    //{username}_{age}  
    List<String> result = queryFactory  
            .select(member.username.concat("_").concat(member.age.stringValue()))  
            .from(member)  
            .where(member.username.eq("member1"))  
            .fetch();  

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

실행된 JPQL과 SQL은 다음과 같다.

// JPQL
/* select
        concat(concat(member1.username, ?1), str(member1.age)) 
    from
        Member member1 
    where
        member1.username = ?2 */ 

// SQL
select
    ((m1_0.username||?)||cast(m1_0.age as varchar)) 
from
    member m1_0 
where
    m1_0.username=?

출력결과는 다음과 같다.

s = member1_10
  • stringValue() : 문자열로 만들어준다.
    • concat(member.age) 부분에서 해당 메서드가 없다면 타입이 맞지 않다는 이유로 에러가 날 것이다.
    • ENUM 같은 경우를 해당 메서드를 통해 문자열로 처리할 수 있다.

2-4. 중급 문법

프로젝션과 결과 반환 - 기본

프로젝션 : select의 대상 지정을 의미한다.

프로젝션 대상이 하나라면 다음과 같이 한다.

List<String> result = queryFactory
        .select(member.username),
        .from(member)
        .fetch();
  • 프로젝션 대상이 하나면 타입을 명확하게 지정할 수 있다.
  • 프로젝션 대상이 둘 이상이면 튜플이나 DTO로 조회해야한다.

튜플 조회는 프로젝션 대상이 둘 이상일 때 사용한다.

참고

참고로 튜플은 Querydsl이 여러 개를 조회할 때를 대비해서 만들어 놓은 타입이다.
그래서 패키지이름까지 확인해보면 com.querydsl.core.Tuple이다.

튜플 조회 예시는 다음과 같다.

@Test  
@DisplayName("프로젝션 대상이 하나")  
void simpleProjectionTest1() {  
    List<String> result = queryFactory  
            .select(member.username)  
            .from(member)  
            .fetch();  

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

실행된 JPQL과 SQL은 다음과 같다.

// JPQL
/* select
    member1.username 
from
    Member member1 */ 

// SQL
select
    m1_0.username 
from
    member m1_0

다음은 출력결과다.

s = member1
s = member2
s = member3
s = member4

참고로 member.name이 아니라 member로만 해도 대상타입은 하나다.
코드예시는 다음과 같다.

@Test  
@DisplayName("프로젝션 대상이 하나")  
void simpleProjectionTest1() {  
    List<Member> result = queryFactory  
            .select(member)  
            .from(member)  
            .fetch();  

    for(Member m : result) {  
        System.out.println("m = " + m);  
    }  
}

결과는 다음과 같다.

// JPQL
/* select
    member1 
from
    Member member1 */ 

// SQL
select
    m1_0.member_id,
    m1_0.age,
    m1_0.team_id,
    m1_0.username 
from
    member m1_0
m = Member(id=1, username=member1, age=10)
m = Member(id=2, username=member2, age=20)
m = Member(id=3, username=member3, age=30)
m = Member(id=4, username=member4, age=40)

다음은 튜플로 조회해보자.

@Test  
@DisplayName("Tuple 조회")  
void tupleProjectionTest() {  
    List<Tuple> result = queryFactory  
            .select(member.username, member.age)  
            .from(member)  
            .fetch();  

    for(Tuple tuple : result) {  
        String username = tuple.get(member.username);  
        Integer age = tuple.get(member.age);  
        System.out.println("username = " + username);  
        System.out.println("age = " + age);  
    }  
}

결과는 다음과 같다.

// JPQL
/* select
    member1.username,
    member1.age 
from
    Member member1 */ 

// SQL
select
    m1_0.username,
    m1_0.age 
from
    member m1_0

참고로 방금 Tuple은 querydsl의 패키지에 있다고 했는데,
이 튜플을 레포지토리 계층에서 쓰는 것은 괜찮으나, 이 튜플을 통해서
서비스나 컨트롤러 계층까지 끌고 가는 것은 좋은 설계가 아니다.

QueryDSL의 종속적인 타입이기 때문에, 리포지토리 계층 안에서만 사용하고,
서비스 계층으로 갈 때는 DTO로 변환해서 나가는 것을 권장한다.(고 하신다.!)

프로젝션과 결과 반환 - DTO 조회

먼저 순수 JPA에서 DTO를 조회할 때를 보고, Querydsl에서 어떻게 DTO를 조회하는지 본다.

먼저 MemberDto를 만들자.

@Data  
public class MemberDto {  

    private String username;  
    private int age;  

    public MemberDto(String username, int age) {  
        this.username = username;  
        this.age = age;  
    }  
}

그리고 조회해보자.

@DisplayName("JPQL로 DTO 조회")  
@Test  
public void findDtoByJPQL() {  
    List<MemberDto> result = em.createQuery("select new younghan.querydsl.dto.MemberDto(m.username, m.age) from Member m", MemberDto.class)  
            .getResultList();  

    for(MemberDto memberDto : result) {  
        System.out.println("memberDto = " + memberDto);  
    }  
}

결과는 다음과 같다.
(이후부터 결과는 JPQL, SQL, 출력 결과를 포함한다.)

// JPQL
/* select
        new younghan.querydsl.dto.MemberDto(m.username, m.age) 
    from
        Member m */ 
// SQL        
select
    m1_0.username,
    m1_0.age 
from
    member m1_0

// 실행 결과
memberDto = MemberDto(username=member1, age=10)
memberDto = MemberDto(username=member2, age=20)
memberDto = MemberDto(username=member3, age=30)
memberDto = MemberDto(username=member4, age=40)

이처럼 DTO를 JPQL로 조회할 때는 new 부터 패키지까지 다 적어줘야 한다.

또한 참 아쉬운 게 생성자 방식만 지원한다.
즉 (사용은 최대한 안 해야하지만) Setter를 이용하거나, 필드에 바로 값을 주입해주거나 하는 방식은 안 된다.

그런데 Querydsl은 이런 JPQL의 아쉬운 점을 다 극복하여 기능을 제공해준다.

Querydsl은 DTO 반환할 때 3가지의 방법을 지원한다.

  • 프로퍼티 접근
  • 필드 직접 접근
  • 생성자 사용

순수 JPA로 작성했던 아까와의 똑같은 기능을 Querydsl로 구현해보면 다음과 같다.

@Test  
@DisplayName("프로퍼티접근 사용")  
void findDtoBySetter() {  
    List<MemberDto> result = queryFactory  
        .select(Projections.bean(MemberDto.class,  
                member.username,  
                member.age  
        ))  
        .from(member)  
        .fetch();  

    for(MemberDto memberDto : result) {  
            System.out.println("memberDto = " + memberDto);  
}

여기서 Projections.bean()을 통해서 MemberDto.class에 Setter로 username과 age를 인젝션해준다.

결과는 다음과 같다.

// JPQL
/* select
    member1.username,
    member1.age 
from
    Member member1 */ 

// SQL
select
    m1_0.username,
    m1_0.age 
from
    member m1_0

// 실행 결과
memberDto = MemberDto(username=member1, age=10)
memberDto = MemberDto(username=member2, age=20)
memberDto = MemberDto(username=member3, age=30)
memberDto = MemberDto(username=member4, age=40)

다음은 필드 조회다.

@Test  
@DisplayName("필드 접근 사용")  
void findDtoByField() {  
    List<MemberDto> result = queryFactory  
            .select(Projections.fields(MemberDto.class,  
                    member.username,  
                    member.age  
            ))  
            .from(member)  
            .fetch();  

    for(MemberDto memberDto : result) {  
        System.out.println("memberDto = " + memberDto);  
    }  
}

Setter 으로 bean을 사용했는데, fileds 메서드를 통해서 위와 같이 작성 가능하다.

결과는 다음과 같다.

// JPQL
/* select
    member1.username,
    member1.age 
from
    Member member1 */ 

// SQL
select
    m1_0.username,
    m1_0.age 
from
    member m1_0

// 실행 결과
memberDto = MemberDto(username=member1, age=10)
memberDto = MemberDto(username=member2, age=20)
memberDto = MemberDto(username=member3, age=30)
memberDto = MemberDto(username=member4, age=40)

참고로 Setter 주입 방법은 MemberDto에 @Data를 통해서 Setter가 있으므로 Setter 주입 방법이 가능한 것이다.

다음은 생성자 주입 방법이다.
참고로 생성자가 있어야 한다.

@Test  
@DisplayName("생성자 접근 사용")  
void findDtoByConstructor() {  
    List<MemberDto> result = queryFactory  
            .select(Projections.constructor(MemberDto.class,  
                    member.username,  
                    member.age  
            ))  
            .from(member)  
            .fetch();  

    for(MemberDto memberDto : result) {  
        System.out.println("memberDto = " + memberDto);  
    }  
}

다음은 실행 결과다.

// JPQL
/* select
    member1.username,
    member1.age 
from
    Member member1 */ 

// SQL
select
    m1_0.username,
    m1_0.age 
from
    member m1_0

// 실행결과
memberDto = MemberDto(username=member1, age=10)
memberDto = MemberDto(username=member2, age=20)
memberDto = MemberDto(username=member3, age=30)
memberDto = MemberDto(username=member4, age=40)

만약에 별칭이 다를 수 는 경우가 있다.
UserDto라는 클래스가 다음과 같이 있다.

@Data  
public class UserDto {  

    private String name;  
    private int age;  

}

UserDto에는 MemberDto에 username으로 되어있는 필드가 name으로 되어있다.

실행결과를 보자.

// JPQL
/* select
    member1.username,
    member1.age 
from
    Member member1 */ 

// SQL
select
    m1_0.username,
    m1_0.age 
from
    member m1_0

// 실행 결과
userDto = UserDto(name=null, age=10)
userDto = UserDto(name=null, age=20)
userDto = UserDto(name=null, age=30)
userDto = UserDto(name=null, age=40)

실행 결과를 보면 필드이름이 다르기 때문에 null이 들어간다.
따라서 다음과 같이 member.username 쪽에 별칭을 줘야한다.

.member.username.as("name"),

다시 실행 결과를 보자

userDto = UserDto(name=member1, age=10)
userDto = UserDto(name=member2, age=20)
userDto = UserDto(name=member3, age=30)
userDto = UserDto(name=member4, age=40)

잘 들어감을 볼 수 있다.

또 다른 예로 억지성 예시지만,
이름은 그대로 가져가되, 나이는 10,20,30,40 이 아닌 최댓값이 40으로만 찍히도록 하고싶을 때.
예를 들면 실행 결과는 다음과 같이 된다.

userDto = UserDto(name=member1, age=40)
userDto = UserDto(name=member2, age=40)
userDto = UserDto(name=member3, age=40)
userDto = UserDto(name=member4, age=40)

이처럼 40으로만 찍고 싶을 때이다.

이 때 이제 서브쿼리를 통해서 사용하게 되는데,
서브쿼리의 이름을 ExpressionUtils와 alias를 통해서 줌으로써
결과가 매칭되게 할 수 있다.

@Test  
@DisplayName("UserDto")  
void findUserDto() {  
    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();  

    for (UserDto userDto : result) {  
        System.out.println("userDto = " + userDto);  
    }  
}

서브쿼리 결과와 "age"가 매칭되어 들어간다.

  • 프로퍼티나, 필드 접근 생성 방식에서 이름이 다를 때의 해결 방안이다.
  • ExpressionUtils.as(source,alias) : 필드나, 서브 쿼리에 별칭 적용할 수 있다.
  • username.as("memberName") : 필드에 별칭 적용

서브쿼리 같은 경우엔 ExpressionUtils로 감싸야 한다.

그리고 참고로 생성자 같은 경우에는 이름이 아니라, 타입을 보고 들어간다.

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

    for (UserDto userDto : result) {  
        System.out.println("userDto = " + userDto);  
    }  
}

참고로 UserDto에 생성자가 없다면 만들어주자.

public UserDto(String name, int age) {  
    this.name = name;  
    this.age = age;  
}

실행결과다.

// JPQL
    /* select
        member1.username,
        member1.age 
    from
        Member member1 */ 

// SQL        
select
    m1_0.username,
    m1_0.age 
from
    member m1_0

// 실행결과
userDto = UserDto(name=member1, age=10)
userDto = UserDto(name=member2, age=20)
userDto = UserDto(name=member3, age=30)
userDto = UserDto(name=member4, age=40)

프로젝션과 결과 반환 - @QueryProjection

앞에서 만들었던 MemberDto의 생성자에 @QueryProjection을 쓰면 된다.

import com.querydsl.core.annotations.QueryProjection;  
import lombok.Data;  
import lombok.NoArgsConstructor;  

@Data  
@NoArgsConstructor  
public class MemberDto {  

    private String username;  
    private int age;  

    @QueryProjection  
    public MemberDto(String username, int age) {  
        this.username = username;  
        this.age = age;  
    }  

}

그리고 컴파일을 하면 DTO에 Q클래스가 생긴다.

위치는 build가 될 수도 있고, genertated가 될 수도 있다.

사용은 다음과 같이 하면 된다.

@Test  
@DisplayName("QueryProjection")  
void Test() {  
    List<MemberDto> result = queryFactory  
            .select(new QMemberDto(  
                    member.username,  
                    member.age  
            ))  
            .from(member)  
            .fetch();  
        for(MemberDto memberDto : result) {  
        System.out.println("memberDto = " + memberDto);  
    }  

}

실행 결과는 다음과 같다.

// JPQL
/* select
    member1.username,
    member1.age 
from
    Member member1 */ 

// SQL
select
    m1_0.username,
    m1_0.age 
from
    member m1_0

// 실행 결과
memberDto = MemberDto(username=member1, age=10)
memberDto = MemberDto(username=member2, age=20)
memberDto = MemberDto(username=member3, age=30)
memberDto = MemberDto(username=member4, age=40)

그럼 위에서 봤던 생성자 방식과 다른 점은 무엇일까..?

만약에 DTO에 없는 필드를 잘 못 넣었다고 가정할 때 컴파일은 잘 된다.

@Test  
@DisplayName("UserDto")  
void findUserDto() {  
    List<UserDto> result = queryFactory  
            .select(Projections.constructor(UserDto.class,  
                    member.username,  
                    member.age,  
                    member.id  //  DTO에 없는 값
            ))  
            .from(member)  
            .fetch();  

    for (UserDto userDto : result) {  
        System.out.println("userDto = " + userDto);  
    }  
}

그러나 실행 시점에 오류가 난다.

그러나 @QueryProjection을 사용하면 컴파일 시점에서 오류를 볼 수 있는 효과가 있다.

또한 실제 생성자가 호출되는 것을 확인할 수 있다.

참고

생성자에 println을 영한님이 살짝 넣다가 지우셨지만, 궁금증이 생겨서 확인해봤다.

@QueryProjection  
public MemberDto(String username, int age) {  
    System.out.println("========호출??? ㄹㅇ????============");  
    System.out.println("========호출??? ㄹㅇ????============");  
    System.out.println("========호출??? ㄹㅇ????============");  
    System.out.println("========호출??? ㄹㅇ????============");  
    System.out.println("========호출??? ㄹㅇ????============");  
    System.out.println("========호출??? ㄹㅇ????============");  
    this.username = username;  
    this.age = age;  
}

위와 같이 @QueryProjection이 붙은 생성자가 있고,
실행해보니..

위와 같이 나온다.
4배로 나온 거 같은데 아마 4개의 MemberDto가 만들어지나보다...

다시 돌아와서.. @QueryProjection의 단점이 있다.
먼저는 컴파일을 해서 Q클래스를 만들어줘야 한다는 점. 그리고,
의존 관계 문제가 있다.
원래는 MemberDto는 QueryDSL와의 의존관계가 없었다.
그러나 @QueryProjection을 사용함으로써 QueryDSL과의 의존관계가 생긴다.

(강의듣는 현 시점에서 회사에서 프로젝트 중인데, List를 반환하는 부분에서 Repository 계층에서 QueryListDto를 Controller계층의 ListDto로 변환 후에 반환했는데, 이 부분에 대해 생각은 해보지 않았다만...역시.. 영한님이다..)

distinct

distinct를 사용할 수 있는데, 다음과 같이 사용할 수 있다.

List result = queryFactory
    .select(member.username).distinct() 
    .from(member) 
    .fetch();

동적 쿼리 - BooleanBuilder 사용

동적 쿼리는 두가지 방식으로 해결 할 수 있다.

  • BooleanBuilder
  • Where 다중 파라미터 사용

이번 시간에는 BooleanBuilder를 알아본다.

일단 예시 코드는 다음과 같다.

@Test  
@DisplayName("동적 쿼리 BooleanBuilder")  
void dynamicQuery_BooleanBuilder() {  
    String usernameParam = "member1";  
    Integer ageParam = 10;  

    List<Member> result = searchMember1(usernameParam, ageParam);  
    assertThat(result.size()).isEqualTo(1);  

}

예시는 username이 "member1"이고, 나이가 10살인 회원을 조회하는 것이다.

만약 ageParam이 null이라면
username이 "member1"인 조건만 검색하게 되고,

둘 다 null이라면 where 문에서 이 조건식은 무시가 된다.
(즉 없는 걸로 친다는 것. 굉장한 장점이다!!)

그리고 searchMember1에서는 다음과 같이 메서드를 작성했다.

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.eq(ageCond));  
    }  

    return queryFactory  
            .selectFrom(member)  
            .where(builder)  
            .fetch();  
}

결과는 다음과 같다.

// JPQL
/* select
    member1 
from
    Member member1 
where
    member1.username = ?1 
    and member1.age = ?2 */ 

// SQL
select
    m1_0.member_id,
    m1_0.age,
    m1_0.team_id,
    m1_0.username 
from
    member m1_0 
where
    m1_0.username=? 
    and m1_0.age=?


// 실행 결과 then절은 통과

BooleanBuilder를 생성해서
조건이 null인지 아닌지에 따라 BooleanBuilder 인스턴스에 조건을 넣어주었고,
두 if문을 지나 name, age의 조건문이 완성된 builder를 단지 where문에 넣어주었다.

그래서 위와 같은 결과가 실행되었다.
또한 new BooleanBuilder(); 의 파라미터에 기본 초기 조건을 넣을 수 있다.

그리고 다음과 같이 이번에 ageParam이 null 인 경우를 보자.

String usernameParam = "member1";  
Integer ageParam = null;

실행 결과는 다음과 같다.

// JPQL
/* select
        member1 
    from
        Member member1 
    where
        member1.username = ?1 */ 

// SQL        
select
    m1_0.member_id,
    m1_0.age,
    m1_0.team_id,
    m1_0.username 
from
    member m1_0 
where
    m1_0.username=?

위와는 다르게 age의 조건이 빠졌다.

그리고 BooleanBuilder에 초기 조건을 넣는 방법은 다음과 같다.

BooleanBuilder builder = new BooleanBuilder(member.username.eq(usernameCond)); // 매개변수에 기본 초기 조건을 넣을 수 있다.

그리고 where절에 넣었던 builder에는 당연히 and, or, not 같은 조건이 계속 붙을 수 있다.

동적 쿼리 - Where 다중 파라미터 사용

이 방법은 영한님이 실무에서 정말 좋아하는 방법이라고 한다.

진짜 개멋있다..

@DisplayName("다중 WhereParam")  
@Test  
void dynamic_WhereParam() {  
    String usernameParam = "member1";  
    Integer ageParam = 10;  

    List<Member> result = searchMember2(usernameParam, ageParam);  
    assertThat(result.size()).isEqualTo(1);  

}  

private List<Member> searchMember2(String usernameCond, Integer ageCond) {  

    return queryFactory  
            .selectFrom(member)  
            .where(usernameEq(usernameCond), ageEq(ageCond))  
            .fetch();  
}  

private Predicate usernameEq(String usernameCond) {  
    if(usernameCond == null) {  
        return null;  
    }  
    return member.username.eq(usernameCond);  
}  

private Predicate ageEq(Integer ageCond) {  
    if(ageCond == null) {  
        return null;  
    }  
    return member.age.eq(ageCond);  
}

아래의 usernameEq 와 ageEq 같은 경우
만약 값이 없으면 null로 인해서 조건문이 붙지않고,
값이 있다면 eq 조건이 붙어 나온다.

그리고 만약 정말 간단한 경우라면 영한님은 아래와 같이 삼항 연산자를 사용한다고 하신다.

private Predicate usernameEq(String usernameCond) {  
    return usernameCond != null ? member.username.eq(usernameCond) : null;  
}  

private Predicate ageEq(Integer ageCond) {  
    return ageCond != null ? member.age.eq(ageCond) : null;  
}

BooleanBuilder는 where 안에 builder로만 선언해놨기 때문에 메서드를 확인해야 builder가 어떻게 구성되어있는지 확인해봐야 하지만,
Where 다중 파라미터 같은 경우

.where(usernameEq(usernameCond), ageEq(ageCond))

이런 코드를 보고, username과 age의 eq조건에 동적으로 들어가는지 바로 알 수 있다.

참고로 실행 결과는 다음과 같다.

// JPQL
/* select
        member1 
    from
        Member member1 
    where
        member1.username = ?1 
        and member1.age = ?2 */ 


// SQL
select
    m1_0.member_id,
    m1_0.age,
    m1_0.team_id,
    m1_0.username 
from
    member m1_0 
where
    m1_0.username=? 
    and m1_0.age=?

그리고 이번에도 age에 null을 주고 실행해보자.

String usernameParam = "member1";  
Integer ageParam = null;

실행 결과는 다음과 같다.

// JPQL
/* select
    member1 
from
    Member member1 
where
    member1.username = ?1 */ 


// SQL        
select
    m1_0.member_id,
    m1_0.age,
    m1_0.team_id,
    m1_0.username 
from
    member m1_0 
where
    m1_0.username=?

age조건은 빠졌다.

또한 이 기능의 또 다른 장점이 있다.

아래의 두 조건이 메서드로 빠졌다.

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;  
}

참고로 Predicate 반환타입이 BooleanExpression으로 바꼈다.

그리고 이 두 메서드를 조립하여 다음과 같은 메서드로 만들 수 있다.

private BooleanExpression allEq(String usernameCond, Integer ageCond) {  
    return usernameEq(usernameCond).and(ageEq(ageCond));  
}

그리고 쿼리문을 다음과 같이 구성할 수 있다.

return queryFactory  
        .selectFrom(member)  
//      .where(usernameEq(usernameCond), ageEq(ageCond))  
        .where(allEq(usernameCond, ageCond))  
        .fetch();

또한 메서드로 빠졌기 때문에 코드가 재활용이 가능하다.

장점을 정리하자면 다음과 같다.

  • 메서드를 다른 쿼리에서도 재활용 할 수 있다.
  • 쿼리 자체의 가독성이 높아진다.
  • 조합이 가능하다.
    • 단 null 체크는 주의해서 처리해야한다.

수정, 삭제 벌크 연산

다음은 벌크 수정 예시이다.

@Test  
@Commit  
@DisplayName("벌크 수정")  
void bulkUpdate() {  

    // member1 10살 -> 비회원  
    // member2 20살 -> 비회원  
    // member3 30살 -> 유지  
    // member4 40살 -> 유지  

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

참고로 count는 반영된 데이터 개수를 의미한다.
실행 결과는 다음과 같다.

// JPQL
/* update
    Member member1 
set
    member1.username = ?1 
where
    member1.age < ?2 */ 

// SQL        
update member 
set
    username=? 
where
    age<?

그런데 bulk 연산을 하면 영속성 컨텍스트를 무시하고
DB에 바로 쿼리를 실행하기 때문에 DB의 상태와 영속성 컨텍스트의 상태가 다르다.

따라서 위에서 update 쿼리를 날리고,
DB에서 다시 select를 해야하는데,
select 결과의 Entity가 영속성 컨텍스트에 이미 있는 Entity로 판단하여, select의 결과가 무시해버린다.
즉 값이 다르더라도 영속성 컨텍스트의 엔티티를 그대로 가져가는 것이다.

참고로 영속성 컨텍스트가 항상 우선권을 가진다.

그래서 다음과 같이 코드를 실행해보자.

@Test  
@Commit  
@DisplayName("벌크 수정")  
void bulkUpdate() {  

    // member1 10살 -> DB member1    // member2 20살 -> DB member2    // member3 30살 -> DB member3    // member4 40살 -> DB member4  
    long count = queryFactory  
            .update(member)  
            .set(member.username, "비회원")  
            .where(member.age.lt(28))  
            .execute();  

    // member1 10살 -> DB 비회원  
    // member2 20살 -> DB 비회원  
    // member3 30살 -> DB member3    // member4 40살 -> DB member4  
    List<Member> result = queryFactory  
            .selectFrom(member)  
            .fetch();  

    for(Member member : result) {  
        System.out.println(member);  
    }  
}

DB에는 위와 같이 저장되어있지만,
println의 결과는 아래와 같다.

Member(id=1, username=member1, age=10)
Member(id=2, username=member2, age=20)
Member(id=3, username=member3, age=30)
Member(id=4, username=member4, age=40)

이러한 문제를 REAPEATABL READ라고 한다.

그래서 벌크성 쿼리를 실행할 때는 항상 이런 영속성 컨텍스트와 DB의 데이터가 맞지 않는 문제가 발생하는데, 그냥 깔끔하게 영속성 컨텍스트의 SQL 지연 저장소에 있는 쿼리를 flush 하고 영속성컨텍스트를 비워주면 된다.

em.flush();
em.clear();

그래서 초기화를 하고, 다시 select를 해주면 돤다.


...

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

em.flush();  
em.clear();

...

flush()clear()를 추가하고 다시 실행해보면, 결과는 다음과 같다.

Member(id=1, username=비회원, age=10)
Member(id=2, username=비회원, age=20)
Member(id=3, username=member3, age=30)
Member(id=4, username=member4, age=40)

만약 수를 더하거나 곱할 경우는 다음과 같이 하면 된다.

@Test  
@DisplayName("기존 숫자 곱하기 혹은 더하기")  
void bulkAdd() {  
    long count = queryFactory  
            .update(member)  
            .set(member.age, member.age.add(1))  
            .execute();  
}

실행결과다.

// JPQL
/* update
    Member member1 
set
    member1.age = member1.age + ?1 */ 

// SQL
update member 
set
age=(age+cast(? as integer))

참고로 값을 빼려면 add안에 -n을 넣어주면 된다.

.set(member.age, member.age.add(-1)) 

곱하는 경우는 다음과 같다.

set(member.age, member.age.multiply(2))

multiply를 사용하면 된다.
곱하기 실행결과다.

// JPQL
    /* update
        Member member1 
    set
        member1.age = member1.age * ?1 */ 

// SQL
update member 
set
age=(age*cast(? as integer))

다음은 삭제 쿼리다.

@Test  
@DisplayName("삭제")  
void bulkDelete() {  
    long count = queryFactory  
            .delete(member)  
            .where(member.age.gt(18))  
            .execute();  
}

나이가 18살 미만인 회원을 삭제하는 쿼리다.

실행 결과는 다음과 같다.

// JPQL
/* delete 
from
    Member member1 
where
    member1.age > ?1 */ 

// SQL
delete 
from
    member 
where
    age>?

SQL function 호출하기

SQL function은 JPA와 같이 Dialect에 등록된 내용만 호출할 수 있다.(하이버네이트 구현체 사용시)

다음과 같이 사용할 수 있다.

@Test  
@DisplayName("SQL Function")  
void sqlFunction() {  

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

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

member의 이름은 M으로 바꿨다.
실행 결과는 다음과 같다.

// JPQL
/* select
    function('replace', member1.username, ?1, ?2) 
from
    Member member1 */ 

// SQL
select
    replace(m1_0.username, ?, ?) 
from
    member m1_0

// 실행 결과
s = M1
s = M2
s = M3
s = M4

참고로 SQL Function을 직접 등록하려면 SQLFunction이 정의된 Dialect 객체를 상속받고,
사용할 수 있도록 등록해주면 된다.
JPA 기본편에서 확인할 수 있다.

다음 예제 코드는 쿼리를 보려고 하는 용도(?)다..

@Test  
@DisplayName("SQL Function2")  
void sqlFunction2() {  
    List<String> result = queryFactory  
            .select(member.username)  
            .from(member)  
            .where(member.username.eq(Expressions.stringTemplate("function('lower', {0})", member.username)))  
            .fetch();  

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

실행 결과다.

// JPQL
/* select
    member1.username 
from
    Member member1 
where
    member1.username = function('lower', member1.username) */ 


// SQL
select
    m1_0.username 
from
    member m1_0 
where
    m1_0.username=lower(m1_0.username)

위와 같이 나간다.

그리고 참고로 일반적으로 자주쓰는 것들은 Querydsl이 어느 정도 내장하고 있다.
따라서 다음과 같이 사용할 수 있다.
(참고로 예제가 별로라서 예제보단 기능에 중점을 두고 보기바람!)

@Test  
@DisplayName("SQL Function2")  
void sqlFunction2() {  
    List<String> result = queryFactory  
            .select(member.username)  
            .from(member)  
//          .where(member.username.eq(  
//                        Expressions.stringTemplate("function('lower', {0})", member.username)))  
            .where(member.username.eq(member.username.lower()))  
            .fetch();  

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

방금 적용한 lower다.

실행 결과는 다음과 같다.

// JPQL
/* select
    member1.username 
from
    Member member1 
where
    member1.username = function('lower', member1.username) */ 

// SQL        
select
    m1_0.username 
from
    member m1_0 
where
    m1_0.username=lower(m1_0.username)

2-5. 실무 활용 - 순수 JPA와 Querydsl

순수 JPA 리포지토리와 Querydsl

먼저 순수 JPA 리포티토리와 Querydsl을 살펴보자.

먼저 Repository를 다음과 같이 만든다.

package younghan.querydsl.repository;  

import com.querydsl.jpa.impl.JPAQueryFactory;  
import jakarta.persistence.EntityManager;  
import org.springframework.stereotype.Repository;  
import younghan.querydsl.entity.Member;  

import java.util.List;  
import java.util.Optional;  

@Repository  
public class MemberJpaRepository {  

    private final EntityManager em;  
    private final JPAQueryFactory queryFactory;  

    public MemberJpaRepository(EntityManager em) {  
        this.em = em;  
        this.queryFactory = new JPAQueryFactory(em);  
    }  

    public void save(Member member) {  
        em.persist(member);  
    }  

    public Optional<Member> findById(Long id) {  
        Member findMember = em.find(Member.class, id);  
        return Optional.ofNullable(findMember);  
    }  

    public List<Member> findALl() {  
        return em.createQuery("select m from Member m", Member.class)  
                .getResultList();  
    }  

    public List<Member> findByUsername(String username) {  
        return em.createQuery("select m from Member m where m.username = :username", Member.class)  
                .setParameter("username", username)  
                .getResultList();  
    }  
}

다음은 Test코드다.

import jakarta.persistence.EntityManager;  
import org.assertj.core.api.Assertions;  
import org.junit.jupiter.api.DisplayName;  
import org.junit.jupiter.api.Test;  
import org.springframework.beans.factory.annotation.Autowired;  
import org.springframework.boot.test.context.SpringBootTest;  
import org.springframework.transaction.annotation.Transactional;  
import younghan.querydsl.entity.Member;  

import java.util.List;  

import static org.assertj.core.api.Assertions.*;  
import static org.junit.jupiter.api.Assertions.*;  

@SpringBootTest  
@Transactional  
class MemberJpaRepositoryTest {  


    @Autowired  
    EntityManager em;  

    @Autowired  
    MemberJpaRepository memberJpaRepository;  


    @Test  
    @DisplayName("basicTest")  
    void basicTest() {  
        Member member = new Member("member1", 10);  
        memberJpaRepository.save(member);  

        Member findMember = memberJpaRepository.findById(member.getId()).get();  
        assertThat(findMember).isEqualTo(member);  

        List<Member> result = memberJpaRepository.findAll();  
        assertThat(result).containsExactly(member);  

        List<Member> result2 = memberJpaRepository.findByUsername("member1");  
        assertThat(result2).containsExactly(member);  
    }  


}

그리고 일부 코드를 Querydsl로 바꿔보자.

먼저 findAll()이다.

public List<Member> findAll() {  
    return em.createQuery("select m from Member m", Member.class)  
            .getResultList();  
}  

public List<Member> findAll_Querydsl() {  
    return queryFactory  
            .selectFrom(member)  
            .fetch();  
}

이제는 findByUsername이다.

public List<Member> findByUsername(String username) {  
    return em.createQuery("select m from Member m where m.username = :username", Member.class)  
            .setParameter("username", username)  
            .getResultList();  
}  

public List<Member> findByUsername_Querydsl(String username) {  
    return queryFactory  
            .selectFrom(member)  
            .where(member.username.eq(username))  
            .fetch();  
}

이렇게 추가한 쿼리가 잘 동작하는지 테스트해본다.

@Test  
@DisplayName("basicTest")  
void basicTest() {  
    Member member = new Member("member1", 10);  
    memberJpaRepository.save(member);  

    Member findMember = memberJpaRepository.findById(member.getId()).get();  
    assertThat(findMember).isEqualTo(member);  

    List<Member> result = memberJpaRepository.findAll_Querydsl();  
    assertThat(result).containsExactly(member);  

    List<Member> result2 = memberJpaRepository.findByUsername_Querydsl("member1");  
    assertThat(result2).containsExactly(member);  
}

실행결과는 다음과 같다.

// JPQL
/* select
    member1 
from
    Member member1 
where
    member1.username = ?1 */ 

// SQL
select
    m1_0.member_id,
    m1_0.age,
    m1_0.team_id,
    m1_0.username 
from
    member m1_0 
where
    m1_0.username=?

JPQL을 작성할 때는 문자열로 넣기 때문에 오타가 나더라도 실행은 되지만,
잘 못된 경우 런타임 오류가 발생한다.
또한 코드가 간결하고, 파라미터 같은 경우도 쉽게 넣을 수 있다.

참고로 선택사항이지만,

public MemberJpaRepository(EntityManager em) {  
    this.em = em;  
    this.queryFactory = new JPAQueryFactory(em);  
}

해당 코드에서 JPAQueryFactory를 빈으로 등록하게 할 수도 있다.

@SpringBootApplication  
public class QuerydslApplication {  

    public static void main(String[] args) {  
        SpringApplication.run(QuerydslApplication.class, args);  
    }  

    @Bean  
    JPAQueryFactory jpaQueryFactory(EntityManager em) {  
        return new JPAQueryFactory(em);  
    }  

}

그리고 MemberRepository의 경우 다음과 같이 해주면 돤다.

@Repository  
public class MemberJpaRepository {  

    private final EntityManager em;  
    private final JPAQueryFactory queryFactory;  

    public MemberJpaRepository(EntityManager em, JPAQueryFactory queryFactory) {  
        this.em = em;  
        this.queryFactory = queryFactory;  
    }

후자 같은 경우 외부로 주입을 따로 받아야되고,
테스트 코드를 작성할 때도 외부에서 주입받아야 된다.
따라서 처음 했던 방식으로 진행한다.
애플리케이션 시작 코드에 작성했던 @Bean을 지우고 아래와 같이 한다.

@Repository  
public class MemberJpaRepository {  

    private final EntityManager em;  
    private final JPAQueryFactory queryFactory;  

    public MemberJpaRepository(EntityManager em) {  
        this.em = em;  
        this.queryFactory = new JPAQueryFactory(em);  
    }

    ...
}

JPAQueryFactory는 엔티티 매니저에 다 의존한다.
그런데 EntityManager가 스프링을 엮어서 쓰면 동시성 문제와 관계없이 트랜잭션 단위로 따로따로 분리해서 동작한다.
스프링에서는 엔티티 매니저의 진짜 영속성 컨텍스트 매니저가 아니라 프록시 객체를 주입해준다.
그리고 트랜잭션 단위로 다른 곳으로 바인딩되도록 라우팅만 해준다.

결론은 동시성 문제는 없다.

동적 쿼리와 성능 최적화 조회 - Builder 사용

앞의 예제에 이어 동적 쿼리를 어떻게 해결하는지 보고,
리포지토리에서 DTO로 한 번에 조회해서 어떻게 성능 최적화 조회를 할 것인지, Builder를 통해서 확인해보자.

먼저 생성한 DTO는 다음과 같다.

import com.querydsl.core.annotations.QueryProjection;  
import lombok.Data;  

@Data  
public class MemberTeamDto {  

    private Long memberId;  
    private String username;  
    private int age;  
    private Long teamId;  
    private String teamName;  


    @QueryProjection  
    public MemberTeamDto(Long memberId, String username, int age, Long teamId, String teamName) {  
        this.memberId = memberId;  
        this.username = username;  
        this.age = age;  
        this.teamId = teamId;  
        this.teamName = teamName;  
    }  
}

그리고 build를 clean하고, compile 하면 된다.
그럼 Q클래스가 생긴다.

참고로 QueryProjection의 단점은 DTO가 Querydsl 라이브러리에 종속된다.

만약 이런 단점이 별로라면, 프로젝션과 결과 반환에서 보았던 세 가지 방법을 사용하면 된다.

다음은 검색 조건을 받는 클래스다.

import lombok.Data;  

@Data  
public class MemberSearchCondition {  

    // 회원명, 팀명, 나이(ageGoe, ageLoe)  
    private String username;  
    private String teamName;  
    private Integer ageGoe;  
    private Integer ageLoe;  

    }

위의 검색 조건을 통해서 MemberTeamDto를 검색하는 메서드를 보자.

public List<MemberTeamDto> searchByBuilder(MemberSearchCondition condition) {  

    BooleanBuilder builder = new BooleanBuilder();  
    if (hasText(condition.getUsername())) { // StringUtils => 요청으로 "" 이나, null이 들어오는 두 가지의 경우를 다 검증  
        builder.and(member.username.eq(condition.getUsername()));  
    }  
    if (hasText(condition.getTeamName())) {  
        builder.and(team.name.eq(condition.getTeamName()));  
    }  
    if(condition.getAgeGoe() != null) {  
        builder.and(member.age.goe(condition.getAgeGoe()));  
    }  
    if(condition.getAgeLoe() != null) {  
        builder.and(member.age.loe(condition.getAgeLoe()));  
    }  

    return queryFactory  
            .select(new QMemberTeamDto(  
                    member.id,  
                    member.username,  
                    member.age,  
                    team.id.as("teamId"),  
                    team.name.as("teamName")))  
            .from(member)  
            .leftJoin(member.team, team)  
            .where(builder)  
            .fetch();  
}

StringUtils.hasText()같은 경우 요청 값이 null이나 혹은 ""으로 들어오는 경우가 많은데, 이 두 경우를 한번에 확인할 수 있다.
참고로 위의 코드에는 StringUtils가 표시되지 않는데, 다음과 같이 static import로 선언되어있다.

import static org.springframework.util.StringUtils.*;

테스트 코드는 다음과 같다.

@Test  
void searchTest() {  
    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, teamB);  
    Member member4 = new Member("member4", 40, teamB);  
    em.persist(member1);  
    em.persist(member2);  
    em.persist(member3);  
    em.persist(member4);  

    MemberSearchCondition condition = new MemberSearchCondition();  
    condition.setAgeGoe(35);  
    condition.setAgeLoe(40);  
    condition.setTeamName("teamB");  

    List<MemberTeamDto> result = memberJpaRepository.searchByBuilder(condition);  

    assertThat(result).extracting("username").containsExactly("member4");  

}

실행 결과는 다음과 같다.

// JPQL
/* select
        member1.id,
        member1.username,
        member1.age,
        team.id as teamId,
        team.name as teamName 
    from
        Member member1   
    left join
        member1.team as team 
    where
        team.name = ?1 
        and member1.age >= ?2 
        and member1.age <= ?3 */ 

// SQL
select
    m1_0.member_id,
    m1_0.username,
    m1_0.age,
    t1_0.team_id,
    t1_0.name 
from
    member m1_0 
left join
    team t1_0 
        on t1_0.team_id=m1_0.team_id 
where
    t1_0.name=? 
    and m1_0.age>=? 
    and m1_0.age<=?

만약 다음과 같이 team이 B인 member만 조회하게 하면 다음과 같다.

@Test  
    void searchTest() {  
        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, teamB);  
        Member member4 = new Member("member4", 40, teamB);  
        em.persist(member1);  
        em.persist(member2);  
        em.persist(member3);  
        em.persist(member4);  

        MemberSearchCondition condition = new MemberSearchCondition();   
        condition.setTeamName("teamB");  

        List<MemberTeamDto> result = memberJpaRepository.searchByBuilder(condition);  

        assertThat(result).extracting("username").containsExactly("member3","member4");  

    }

다음은 실행 결과다.

// JPQL
/* select
        member1.id,
        member1.username,
        member1.age,
        team.id as teamId,
        team.name as teamName 
    from
        Member member1   
    left join
        member1.team as team 
    where
        team.name = ?1 */ 


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

참고로 실무에서 이런 조건들이 없다고 가정하자.
만약 하루에 1천 건씩 쌓이면 1달이면 3만건이다.

만약 검색 조건이 붙지 않는다고 가정하면 모든 데이터를 조회하는데, 이때 3만건의 데이터를 다 퍼올리게 된다.

따라서 검색조건이나, 페이징 쿼리를 날리게 해서 성능을 최적화해주는 것이 좋다.

동적 쿼리와 성능 최적화 조회 - Where절 파라미터 사용

이번에는 Where절 파라미터 사용해서 동적 쿼리와 성능 최적화 조회를 해본다.
(기존과 코드는 거의 비슷하다.)

메서드는 다음과 같다.

public List<MemberTeamDto> search(MemberSearchCondition condition) {  
    return queryFactory  
            .select(new QMemberTeamDto(  
                    member.id,  
                    member.username,  
                    member.age,  
                    team.id.as("teamId"),  
                    team.name.as("teamName")))  
            .from(member)  
            .leftJoin(member.team, team)  
            .where(  
                    usernameEq(condition.getUsername()),  
                    teamNameEq(condition.getTeamName()),  
                    ageGoe(condition.getAgeGoe()),  
                    ageLoe(condition.getAgeLoe())  
            )  
            .fetch();  
}  

private BooleanExpression usernameEq(String username) {  
    return hasText(username) ? member.username.eq(username) : null;  
}  

private BooleanExpression teamNameEq(String teamName) {  
    return hasText(teamName) ? member.team.name.eq(teamName) : null;  
}  

private BooleanExpression ageGoe(Integer ageGoe) {  
    return ageGoe != null ? member.age.goe(ageGoe) : null;  
}  

private BooleanExpression ageLoe(Integer ageLoe) {  
    return ageLoe != null ?  member.age.loe(ageLoe) : null;  
}

참고로 반환타입을 Predicate가 아닌 BooleanExpression으로 하는 것이 추후 조합이 가능하다.

Builder를 사용하면 Builder가 어떻게 구성되어 있는지 확인해야 하는데,
이렇게 where로 넣어두면 코드를 보고도 짐작할 수 있다.

다음은 테스트 코드다.

@Test  
void searchTest() {  
    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, teamB);  
    Member member4 = new Member("member4", 40, teamB);  
    em.persist(member1);  
    em.persist(member2);  
    em.persist(member3);  
    em.persist(member4);  

    MemberSearchCondition condition = new MemberSearchCondition();  
    condition.setAgeGoe(35);  
    condition.setAgeLoe(40);  
    condition.setTeamName("teamB");  

    List<MemberTeamDto> result = memberJpaRepository.search(condition);  

    assertThat(result).extracting("username").containsExactly("member4");  

}

참고로 이전 테스트에서 searchByBuilder 부분을 search로 변경해주면 된다.

다음은 실행 결과이다.

// JPQL
/* select
        member1.id,
        member1.username,
        member1.age,
        team.id as teamId,
        team.name as teamName 
    from
        Member member1   
    left join
        member1.team as team 
    where
        team.name = ?1 
        and member1.age >= ?2 
        and member1.age <= ?3 */ 

// SQL
select
    m1_0.member_id,
    m1_0.username,
    m1_0.age,
    t1_0.team_id,
    t1_0.name 
from
    member m1_0 
left join
    team t1_0 
        on t1_0.team_id=m1_0.team_id 
where
    t1_0.name=? 
    and m1_0.age>=? 
    and m1_0.age<=?

이 방식은 영한님이 가장 선호하는 방식이라고 한다.

그리고 정말 큰 장점은 만약 Projection이 지금처럼 MemberTeamDto가 아니라, Member로 가져와야 한다고 가정하더라도 eq 혹은 goe, loe 메서드를 재사용할 수 있다.

재사용이 가능하기 때문에 조합도 가능하다.

조회 API 컨트롤러 개발

조회용 API를 만들어서 실제 API에서 되는 것을 확인해보자.
(참고로 조회용만 한다.)

먼저 샘플용 데이터를 다음과 같이 만든다.

참고로 샘플 데이터를 넣을 때, 프로파일을 나눠서 테스트에 영향이 없도록 한다.
그래서 테스트를 실행할 때로컬에서 스프링 부트-톰캣을 띄워서 실행할 때 이 두 가지의 경우를 다른 상황으로 프로파일을 설정한다.

따라서 톰캣으로 돌리게 되면 샘플 데이터를 추가하는 로직이 동작하게 되고,
테스트 케이스를 돌릴 때는 샘플 데이터를 넣는 로직이 동작하지 않게 한다.

왜냐하면 테스트는 DB의 데이터를 테스트에 맞춰서 세팅했는데,
이 샘플 데이터가 테스트 케이스에서도 실행이 되면 테스트가 깨진다.

그래서 프로파일을 분리한다.

main/resources/application.yml이다.

spring:  
  profiles:  
    active: local  
  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  
        use_sql_comments: true  

logging.level:  
  org.hibernate.SQL: debug  
#  org.hibernate.type: trace

다음은 test/resources/application.yml이다.

spring:  
  profiles:  
    active: test  
  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  
        use_sql_comments: true  

logging.level:  
  org.hibernate.SQL: debug  
#  org.hibernate.type: trace

다음은 샘플 데이터를 만드는 코드다.

import jakarta.annotation.PostConstruct;  
import jakarta.persistence.EntityManager;  
import jakarta.persistence.PersistenceContext;  
import lombok.RequiredArgsConstructor;  
import org.springframework.context.annotation.Profile;  
import org.springframework.stereotype.Component;  
import org.springframework.transaction.annotation.Transactional;  
import younghan.querydsl.entity.Member;  
import younghan.querydsl.entity.Team;  

@Profile("local")  
@Component  
@RequiredArgsConstructor  
public class InitMember {  

    private final InitMemberService initMemberService;  

    @PostConstruct  
    public void init() {  
        initMemberService.init();  
    }  

    @Component
    static class InitMemberService {  
        @PersistenceContext  
        private EntityManager em;  

        @Transactional  
        public void init() {  
            Team teamA = new Team("teamA");  
            Team teamB = new Team("teamB");  
            em.persist(teamA);  
            em.persist(teamB);  

            for (int i = 0; i < 100; i++) {  
                Team selectedTeam = i % 2 == 0 ? teamA : teamB;  
                em.persist(new Member("member" + i, i, selectedTeam));  
            }  
        }  
    }  
}

이렇게 한 후, 스프링을 띄우면 main/resources/application.yml 설정이 local이기 때문에
InitMember 클래스의 @Profile("local")에 의해서 InitMember는 스프링 빈으로 올라가고,
@PostConstruct 가 실행된다.

그렇게 되면 일단 DB에 데이터를 넣고 시작하는 것이다.

참고로 init() 메서드에 모든 로직을 넣으면 되지 않느냐고 할 수 있지만, @PostConstruct@Transactional은 같이 쓰지 못 한다.
그래서 분리가 필요하다.

그리고 스프링 부트를 실행하면 insert 문이 되게 많이 보일 것이다.

JPQL, SQL, log까지 찍히니 600개가 넘게 찍힌다.

그리고 이와 같이 local profile로 띄어진다.

이제 조회용 API를 만들어보자.

MemberContoller 라는 컨트롤러를 다음과 같이 만들었다.

import lombok.RequiredArgsConstructor;  
import org.springframework.web.bind.annotation.GetMapping;  
import org.springframework.web.bind.annotation.RestController;  
import younghan.querydsl.dto.MemberSearchCondition;  
import younghan.querydsl.dto.MemberTeamDto;  
import younghan.querydsl.repository.MemberJpaRepository;  

import java.util.List;  

@RestController  
@RequiredArgsConstructor  
public class MemberController {  

    private final MemberJpaRepository memberJpaRepository;  

    @GetMapping("/v1/members")  
    public List<MemberTeamDto> searchMemberV1(MemberSearchCondition condition) {  
        return memberJpaRepository.search(condition);  
    }  
}

그리고 실행 후,

postman을 켜서 url http://localhost:8080/v1/members 을 호출한다.

그러면 다음과 같이 나온다.

그리고 만약 팀이름이 teamB인 회원을 조회하고 싶다면

http://localhost:8080/v1/members?teamName=teamB 로 조회한다.

그럼 아래와 같이 team이름이 teamB인 결과만 나온다.

거기에 나이가 31이상, 35이하인 회원을 조회하려면
http://localhost:8080/v1/members?teamName=teamB&ageGoe=31&ageLoe=35 로 조회한다.

username이 member31인 조건을 붙이고 싶다면

http://localhost:8080/v1/members?teamName=teamB&ageGoe=31&ageLoe=35&username=member31로 조회한다.

결과는 다음과 같다.

그리고 이 조건에 대한 쿼리 결과는 다음과 같다.

// JPQL
/* select
        member1.id,
        member1.username,
        member1.age,
        team.id as teamId,
        team.name as teamName 
    from
        Member member1   
    left join
        member1.team as team 
    where
        member1.username = ?1 
        and team.name = ?2 
        and member1.age >= ?3 
        and member1.age <= ?4 */ 

// SQL        
select
    m1_0.member_id,
    m1_0.username,
    m1_0.age,
    t1_0.team_id,
    t1_0.name 
from
    member m1_0 
left join
    team t1_0 
        on t1_0.team_id=m1_0.team_id 
where
    m1_0.username=? 
    and t1_0.name=? 
    and m1_0.age>=? 
    and m1_0.age<=?

그리고!!! 잠시 테스트를 돌려보면

샘플 데이터와는 상관없이 test 프로파일을 사용해서 동작한다.

2-6. 실무 활용 - 스프링 데이터 JPA와 Querydsl

스프링 데이터 JPA 리포지토리로 변경

이제는 순수 JPA가 아니라, 이전에 만들었던 걸 스프링 데이터 JPA로 변경한다.

스프링 데이터 JPA Repository를 만들어준다.

import org.springframework.data.jpa.repository.JpaRepository;  
import younghan.querydsl.entity.Member;  

import java.util.List;  

public interface MemberRepository extends JpaRepository<Member, Long> {  

    // select m from Member m where m.username = ?  
    List<Member> findByUsername(String username);  


}

다음은 이 Repository로 테스트를 만든다.

import jakarta.persistence.EntityManager;  
import org.junit.jupiter.api.DisplayName;  
import org.junit.jupiter.api.Test;  
import org.springframework.beans.factory.annotation.Autowired;  
import org.springframework.boot.test.context.SpringBootTest;  
import org.springframework.transaction.annotation.Transactional;  
import younghan.querydsl.entity.Member;  

import java.util.List;  

import static org.assertj.core.api.Assertions.assertThat;  

@SpringBootTest  
@Transactional  
class MemberRepositoryTest {  

    @Autowired  
    EntityManager em;  

    @Autowired  
    MemberRepository memberRepository;  


    @Test  
    @DisplayName("basicTest")  
    void basicTest() {  
        Member member = new Member("member1", 10);  
        memberRepository.save(member);  

        Member findMember = memberRepository.findById(member.getId()).get();  
        assertThat(findMember).isEqualTo(member);  

        List<Member> result = memberRepository.findAll();  
        assertThat(result).containsExactly(member);  

        List<Member> result2 = memberRepository.findByUsername("member1");  
        assertThat(result2).containsExactly(member);  
    }  

}

참고로 테스트는 통과한다.

사용자 정의 리포지토리

스프링 데이터 JPA를 쓰면서 복잡한 구현을 하거나 커스텀한 기능이 필요할 때 사용하는 사용자 정의 리포지토리에 대해 알아보자.

Querydsl을 쓰려면 결국 구현 코드를 만들어야 하는데 Spring Data JPA는 인터페이스를 사용하기 때문에 원하는 구현 코드를 넣으려면 사용자 정의 리포지토리라는 복잡한 방법을 써야한다.

사용법은 다음과 같다.

  1. 사용자 정의 인터페이스 작성
  2. 사용자 정의 인터페이스 구현
  3. 스프링 데이터 리포지토리에 사용자 정의 인터페이스 상속

그림은 일단 위와 같다.

먼저 인터페이스를 만들자.

public interface MemberRepositoryCustom {  

    List<MemberTeamDto> search(MemberSearchCondition condition);  

}

이제 이를 구현해줘야 한다.


import com.querydsl.core.types.dsl.BooleanExpression;  
import com.querydsl.jpa.impl.JPAQueryFactory;  
import jakarta.persistence.EntityManager;  
import younghan.querydsl.dto.MemberSearchCondition;  
import younghan.querydsl.dto.MemberTeamDto;  
import younghan.querydsl.dto.QMemberTeamDto;  

import java.util.List;  

import static org.springframework.util.StringUtils.hasText;  
import static younghan.querydsl.entity.QMember.member;  
import static younghan.querydsl.entity.QTeam.team;  

public class MemberRepositoryCustomImpl implements MemberRepositoryCustom {  

    private final JPAQueryFactory queryFactory;  

    public MemberRepositoryCustomImpl(EntityManager em) {  
        this.queryFactory = new JPAQueryFactory(em);  
    }  

    @Override  
    public List<MemberTeamDto> search(MemberSearchCondition condition) {  
        return queryFactory  
                .select(new QMemberTeamDto(  
                        member.id,  
                        member.username,  
                        member.age,  
                        team.id.as("teamId"),  
                        team.name.as("teamName")))  
                .from(member)  
                .leftJoin(member.team, team)  
                .where(  
                        usernameEq(condition.getUsername()),  
                        teamNameEq(condition.getTeamName()),  
                        ageGoe(condition.getAgeGoe()),  
                        ageLoe(condition.getAgeLoe())  
                )  
                .fetch();  
    }  

    private BooleanExpression usernameEq(String username) {  
        return hasText(username) ? member.username.eq(username) : null;  
    }  

    private BooleanExpression teamNameEq(String teamName) {  
        return hasText(teamName) ? team.name.eq(teamName) : null;  
    }  

    private BooleanExpression ageGoe(Integer ageGoe) {  
        return ageGoe != null ? member.age.goe(ageGoe) : null;  
    }  

    private BooleanExpression ageLoe(Integer ageLoe) {  
        return ageLoe != null ?  member.age.loe(ageLoe) : null;  
    }  

}

참고로 구현 클래스의 이름은 MemberRepositoryCustomImpl인데 Impl이라는 부분은 반드시 넣어줘야한다.

이 부분은 규칙이 있는 부분이다.

그리고 MemberRepositoryCustom 인터페이스를 MemberRepository 인터페이스에서 상속받게 하면된다.

import org.springframework.data.jpa.repository.JpaRepository;  
import younghan.querydsl.entity.Member;  

import java.util.List;  

public interface MemberRepository extends JpaRepository<Member, Long>, MemberRepositoryCustom {  

    // select m from Member m where m.username = ?  
    List<Member> findByUsername(String username);  

}

그리고 이전에 테스트했던 코드를 MemberRepository로 돌려보면 테스트가 성공한다.

@Test  
void searchTest() {  
    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, teamB);  
    Member member4 = new Member("member4", 40, teamB);  
    em.persist(member1);  
    em.persist(member2);  
    em.persist(member3);  
    em.persist(member4);  

    MemberSearchCondition condition = new MemberSearchCondition();  
    condition.setAgeGoe(35);  
    condition.setAgeLoe(40);  
    condition.setTeamName("teamB");  

    List<MemberTeamDto> result = memberRepository.search(condition);  

    assertThat(result).extracting("username").containsExactly("member4");  

}

그리고 번외로
영한님은 조회쿼리가 너무 복잡하고 특정한 기능에 맞춰진 조회 기능이라면 이런 방식으로 MemberRepository라고 안하고 별도로 다른 클래스를 다음과 같이 만들어서 사용한다고 하신다..!!
(이전에 썻던 코드지만,,)

import com.querydsl.core.types.dsl.BooleanExpression;  
import com.querydsl.jpa.impl.JPAQueryFactory;  
import jakarta.persistence.EntityManager;  
import org.springframework.stereotype.Repository;  
import younghan.querydsl.dto.MemberSearchCondition;  
import younghan.querydsl.dto.MemberTeamDto;  
import younghan.querydsl.dto.QMemberTeamDto;  

import java.util.List;  

import static org.springframework.util.StringUtils.hasText;  
import static younghan.querydsl.entity.QMember.member;  
import static younghan.querydsl.entity.QTeam.team;  

@Repository  
public class MemberQueryRepository {  

    private final JPAQueryFactory queryFactory;  

    public MemberQueryRepository(EntityManager em) {  
        this.queryFactory = new JPAQueryFactory(em);  
    }  

    public List<MemberTeamDto> search(MemberSearchCondition condition) {  
        return queryFactory  
                .select(new QMemberTeamDto(  
                        member.id,  
                        member.username,  
                        member.age,  
                        team.id.as("teamId"),  
                        team.name.as("teamName")))  
                .from(member)  
                .leftJoin(member.team, team)  
                .where(  
                        usernameEq(condition.getUsername()),  
                        teamNameEq(condition.getTeamName()),  
                        ageGoe(condition.getAgeGoe()),  
                        ageLoe(condition.getAgeLoe())  
                )  
                .fetch();  
    }  

    private BooleanExpression usernameEq(String username) {  
        return hasText(username) ? member.username.eq(username) : null;  
    }  

    private BooleanExpression teamNameEq(String teamName) {  
        return hasText(teamName) ? team.name.eq(teamName) : null;  
    }  

    private BooleanExpression ageGoe(Integer ageGoe) {  
        return ageGoe != null ? member.age.goe(ageGoe) : null;  
    }  

    private BooleanExpression ageLoe(Integer ageLoe) {  
        return ageLoe != null ?  member.age.loe(ageLoe) : null;  
    }  

}

즉 굳이 커스텀에 모든 걸 때려밖는 방식으로 사용하는 것도 좋은 설계가 아니다..!

아키텍처 측면에서 좀 더 유연하게 가져가거나, 프로젝트가 너무 커지면 조회용을 분리해내는 것도 괜찮은 방법이다...!!

스프링 데이터 페이징 활용1 - Querydsl 페이징 연동

스프링 데이터 JPA와 Querydsl을 어떻게 조합해서 페이징을 할 수 있는지 알아보자.

  • 스프링 데이터의 Page, Pageable을 활용한다.
  • 전체 카운트를 한번에 조회하는 단순한 방법을 알아본다.
  • 데이터 내용과 전체 카운트를 별도로 조회하는 방법을 알아본다.

이전에 만들었던 MemberRepositoryCustom에 두 개의 메서드를 추가한다.

import org.springframework.data.domain.Pageable;  
import younghan.querydsl.dto.MemberSearchCondition;  
import younghan.querydsl.dto.MemberTeamDto;  

import java.util.List;  

public interface MemberRepositoryCustom {  

    List<MemberTeamDto> search(MemberSearchCondition condition);  

    // 단순한 쿼리용이다.  
    List<MemberTeamDto> searchPageSimple(MemberSearchCondition condition, Pageable pageable);  

    // 카운트 쿼리인 것과 아닌 것을 분리해서 별도의 쿼리로 나가게 한다.  
    List<MemberTeamDto> searchPageCOmplex(MemberSearchCondition condition, Pageable pageable);  


}

searchPageSimple는 단순한 쿼리용이고,
searchPageCOmplex는 카운트 쿼리인 것과 아닌 것을 분리해서 별도의 쿼리로 나가게 한다.

먼저 searchPageSimple을 구현해보자.
(참고로 이전의 코드와는 똑같은데, 페이징 쿼리가 들어간다.)

@Override  
public Page<MemberTeamDto> searchPageSimple(MemberSearchCondition condition, Pageable pageable) {  

    QueryResults<MemberTeamDto> result = queryFactory  
            .select(new QMemberTeamDto(  
                    member.id,  
                    member.username,  
                    member.age,  
                    team.id.as("teamId"),  
                    team.name.as("teamName")))  
            .from(member)  
            .leftJoin(member.team, team)  
            .where(  
                    usernameEq(condition.getUsername()),  
                    teamNameEq(condition.getTeamName()),  
                    ageGoe(condition.getAgeGoe()),  
                    ageLoe(condition.getAgeLoe())  
            )  
            .offset(pageable.getOffset())  
            .limit(pageable.getPageSize())  
            .fetchResults();  


    List<MemberTeamDto> content = result.getResults();  
    long total = result.getTotal();  

    return new PageImpl<>(content, pageable, total);
}

테스트를 해보자.

@Test  
void searchPageSimpleTest() {  
    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, teamB);  
    Member member4 = new Member("member4", 40, teamB);  
    em.persist(member1);  
    em.persist(member2);  
    em.persist(member3);  
    em.persist(member4);  

    MemberSearchCondition condition = new MemberSearchCondition();  
    PageRequest pageRequest = PageRequest.of(0, 3);  

    Page<MemberTeamDto> result = memberRepository.searchPageSimple(condition, pageRequest);  

    assertThat(result.getSize()).isEqualTo(3);  
    assertThat(result.getContent())  
            .extracting("username")  
            .containsExactly("member1", "member2", "member3");  

}

결과는 다음과 같다.

//  JPQL
/* select
        count(member1) 
    from
        Member member1   
    left join
        member1.team as team */ 

// SQL        
select
    count(m1_0.member_id) 
from
    member m1_0


// JPQL
/* select
        member1.id,
        member1.username,
        member1.age,
        team.id as teamId,
        team.name as teamName 
    from
        Member member1   
    left join
        member1.team as team */ 

// SQL        
select
    m1_0.member_id,
    m1_0.username,
    m1_0.age,
    t1_0.team_id,
    t1_0.name 
from
    member m1_0 
left join
    team t1_0 
        on t1_0.team_id=m1_0.team_id 
offset
    ? rows 
fetch
    first ? rows only

이처럼 카운트 쿼리와 함께 조회 쿼리가 나가는 걸 확인할 수 있다.
총 2번의 쿼리가 실행된다.

참고로 fetchResults()deprecated되었기 때문에 다음과 같이 해야한다...

@Override  
public Page<MemberTeamDto> searchPageSimple(MemberSearchCondition condition, Pageable pageable) {  



        List<MemberTeamDto> result = queryFactory  
                .select(new QMemberTeamDto(  
                        member.id,  
                        member.username,  
                        member.age,  
                        team.id.as("teamId"),  
                        team.name.as("teamName")))  
                .from(member)  
                .leftJoin(member.team, team)  
                .where(  
                        usernameEq(condition.getUsername()),  
                        teamNameEq(condition.getTeamName()),  
                        ageGoe(condition.getAgeGoe()),  
                        ageLoe(condition.getAgeLoe())  
                )  
                .offset(pageable.getOffset())  
                .limit(pageable.getPageSize())  
                .fetch();  

        Long total = queryFactory  
                .select(member.count())  
                .from(member)  
                .leftJoin(member.team, team)  
                .where(  
                        usernameEq(condition.getUsername()),  
                        teamNameEq(condition.getTeamName()),  
                        ageGoe(condition.getAgeGoe()),  
                        ageLoe(condition.getAgeLoe())  
                )  
                .offset(pageable.getOffset())  
                .limit(pageable.getPageSize())  
                .fetchOne();  


        return new PageImpl<>(result, pageable, total);  

    }

결과는 다음과 같다.

// JPQL
/* select
        member1.id,
        member1.username,
        member1.age,
        team.id as teamId,
        team.name as teamName 
    from
        Member member1   
    left join
        member1.team as team */ 


// SQL
select
    m1_0.member_id,
    m1_0.username,
    m1_0.age,
    t1_0.team_id,
    t1_0.name 
from
    member m1_0 
left join
    team t1_0 
        on t1_0.team_id=m1_0.team_id 
offset
    ? rows 
fetch
    first ? rows only

// JPQL
/* select
        count(member1) 
    from
        Member member1   
    left join
        member1.team as team */ 


// SQL
select
    count(m1_0.member_id) 
from
    member m1_0 
offset
    ? rows 
fetch
    first ? rows only

참고로 fetchResult()를 사용하지 않고, 쿼리를 분리한 코드를 작성해도 테스트는 통과한다.

fetchResults()orderBy를 넣을 수 있긴한데, totalCount 쿼리와는 관련이 없기 때문에,
count쿼리에서는 orderBy가 들어간 부분을 다 지운다.

참고로 다시 말하지만,,.

fetchResults()는 deprecated 되었다..!!

이제 데이터 내용과 전체 카운트를 별도로 조회하는 방법이다.

이 부분은 앞에서 내가 fetchResult()를 사용하지 않고, 쿼리를 분리한 코드와 거의 똑같다.
카운트 쿼리에서 fetchCount()를 사용하셨는데,, fetchCount()deprecated되었다...

@Override  
public Page<MemberTeamDto> searchPageComplex(MemberSearchCondition condition, Pageable pageable) {  
    List<MemberTeamDto> content = queryFactory  
            .select(new QMemberTeamDto(  
                    member.id,  
                    member.username,  
                    member.age,  
                    team.id.as("teamId"),  
                    team.name.as("teamName")))  
            .from(member)  
            .leftJoin(member.team, team)  
            .where(  
                    usernameEq(condition.getUsername()),  
                    teamNameEq(condition.getTeamName()),  
                    ageGoe(condition.getAgeGoe()),  
                    ageLoe(condition.getAgeLoe())  
            )  
            .offset(pageable.getOffset())  
            .limit(pageable.getPageSize())  
            .fetch();  

    Long total = queryFactory  
            .select(member.count())  
            .from(member)  
            .leftJoin(member.team, team)  
            .where(  
                    usernameEq(condition.getUsername()),  
                    teamNameEq(condition.getTeamName()),  
                    ageGoe(condition.getAgeGoe()),  
                    ageLoe(condition.getAgeLoe())  
            )  
            .fetchOne();  


    return new PageImpl<>(content, pageable, total);  
}

fetchCount()를 사용하면 위의 코드와 차이점은

  1. select()member.count()가 아니라, member가 들어가는 점.
  2. fetchOne()이 아니라, fetchCount()가 들어가는 것. (deprecated됨)

그리고 count 쿼리 쪽에 다음과 같은 코드가 없다.

.offset(pageable.getOffset())  
.limit(pageable.getPageSize())  

그래도 test는 통과한다.

스프링 데이터 페이징 활용2 - CountQuery 최적화

countQuery를 최적화해보자.

때에 따라서 countQuery를 생략할 수도 있다.

count 쿼리가 생략 가능한 경우는 다음과 같다.

  • 페이지 시작이면서 컨텐츠 사이즈가 페이지 사이즈보다 작을 때
  • 마지막 페이지 일 때 (offset + 컨텐츠 사이즈를 더해서 전체 사이즈 구함
    • 더 정확히는 마지막 페이지이면 서 컨텐츠 사이즈가 페이지 사이즈보다 작을 때

이러한 기능을 스프링 데이터 라이브러리가 기막히게 제공한다.

searchPageComplex에서 다음과 같이 수정해보자.

JPAQuery<Long> countQuery = queryFactory  
                .select(member.count())  
                .from(member)  
                .leftJoin(member.team, team)  
                .where(  
                        usernameEq(condition.getUsername()),  
                        teamNameEq(condition.getTeamName()),  
                        ageGoe(condition.getAgeGoe()),  
                        ageLoe(condition.getAgeLoe())  
                );  

return PageableExecutionUtils.getPage(content, pageable, () -> countQuery.fetchOne());
// return PageableExecutionUtils.getPage(content, pageable, countQuery::fetchOne); // 메서드 참조 사용

이렇게되면 마지막 페이지의 시작이면서 컨텐츠 사이즈가 페이지의 사이즈보다 작거나, 혹은 마지막 페이지라면 countQuery를 호출하지 않는다.

PageableExecutionUtils.getPage()의 코드를 보면 다음과 같다.

public static <T> Page<T> getPage(List<T> content, Pageable pageable, LongSupplier totalSupplier) {  

    Assert.notNull(content, "Content must not be null");  
    Assert.notNull(pageable, "Pageable must not be null");  
    Assert.notNull(totalSupplier, "TotalSupplier must not be null");  

    if (pageable.isUnpaged() || pageable.getOffset() == 0) {  

       if (pageable.isUnpaged() || pageable.getPageSize() > content.size()) {  
          return new PageImpl<>(content, pageable, content.size());  
       }  

       return new PageImpl<>(content, pageable, totalSupplier.getAsLong());  
    }  

    if (content.size() != 0 && pageable.getPageSize() > content.size()) {  
       return new PageImpl<>(content, pageable, pageable.getOffset() + content.size());  
    }  

    return new PageImpl<>(content, pageable, totalSupplier.getAsLong());  
}

마지막 페이지이거나,
마지막 페이지의 시작이면서 컨텐츠 사이즈가 페이지의 사이즈보다 작을 때 count 쿼리를 실행한다.
이렇게 하면 최적화된다.

스프링 데이터 페이징 활용3 - 컨트롤러 개발

컨트롤러를 만들어서 간단하게 확인해본다.

컨트롤러다.

import lombok.RequiredArgsConstructor;  
import org.springframework.data.domain.Page;  
import org.springframework.data.domain.Pageable;  
import org.springframework.web.bind.annotation.GetMapping;  
import org.springframework.web.bind.annotation.RestController;  
import younghan.querydsl.dto.MemberSearchCondition;  
import younghan.querydsl.dto.MemberTeamDto;  
import younghan.querydsl.repository.MemberJpaRepository;  
import younghan.querydsl.repository.MemberRepository;  

import java.util.List;  

@RestController  
@RequiredArgsConstructor  
public class MemberController {  

    private final MemberJpaRepository memberJpaRepository;  
    private final MemberRepository memberRepository;    // 추가  

    @GetMapping("/v1/members")  
    public List<MemberTeamDto> searchMemberV1(MemberSearchCondition condition) {  
        return memberJpaRepository.search(condition);  
    }  

    // 추가 searchPageSimple    @GetMapping("/v2/members")  
    public Page<MemberTeamDto> searchMemberV2(MemberSearchCondition condition, Pageable pageable) {  
        return memberRepository.searchPageSimple(condition, pageable);  
    }  

    // 추가 searchPageComplex    @GetMapping("/v3/members")  
    public Page<MemberTeamDto> searchMemberV3(MemberSearchCondition condition, Pageable pageable) {  
        return memberRepository.searchPageComplex(condition, pageable);  
    }  
}

V2는 (deprecated된) fetchResults()를 사용한다.
V3는 분리된 query다.

먼저 V2다.
다음과 같이 나온다.

 "pageable": {

        "pageNumber": 0,

        "pageSize": 20,

        "sort": {

            "empty": true,

            "sorted": false,

            "unsorted": true

        },

        "offset": 0,

        "unpaged": false,

        "paged": true

    },

    "last": false,

    "totalPages": 5,

    "totalElements": 100,

    "size": 20,

    "number": 0,

    "sort": {

        "empty": true,

        "sorted": false,

        "unsorted": true

    },

    "first": true,

    "numberOfElements": 20,

    "empty": false

다음은 page 는 0 size는 5다.

그 다음 페이지다.

이처럼 페이지 정보가 다 나온다.

쿼리 결과는 다음과 같다.

// JPQL
/* select
        count(member1) 
    from
        Member member1   
    left join
        member1.team as team */ 

// SQL
select
    count(m1_0.member_id) 
from
    member m1_0


// JPQL
/* select
        member1.id,
        member1.username,
        member1.age,
        team.id as teamId,
        team.name as teamName 
    from
        Member member1   
    left join
        member1.team as team */ 

// SQL        
select
    m1_0.member_id,
    m1_0.username,
    m1_0.age,
    t1_0.team_id,
    t1_0.name 
from
    member m1_0 
left join
    team t1_0 
        on t1_0.team_id=m1_0.team_id 
offset
    ? rows 
fetch
    first ? rows only

다음은 V3 다.
전체 데이터가 100인데, size를 그보다 많은 110개로 날리면 countQuery가 나가지 않는다.

로그를 보자.

countQuery는 select 해서 가져온 결과가 카운트 쿼리 개수보다 작기 때문에 첫 페이지 데이터가 두 번째 페이지로 넘어갈 데이터가 없는 것이다.
이런 부분을 판별해서 countQuery를 날리지 않는다.

그런데 그보다 작은 size를 20으로 줬을 때는 count 쿼리가 나간다.

참고로 Sort도 가능한데 스프링 데이터의 Sort를 Querydsl에서 맞춰서 쓰기가 어렵다.

스프링 데이터 JPA는 자신의 정렬(Sort)을 Querydsl의 정렬(OrderSpecifier)로 편리하게 변경하는 기능을 제공한 다.
이 부분은 뒤에 스프링 데이터 JPA가 제공하는 Querydsl 기능에서 살펴보겠다

스프링 데이터의 정렬을 Querydsl의 정렬로 직접 전환하는 방법은 다음 코드를 참고하자.

JPAQuery query = queryFactory
        .selectFrom(member); 

for (Sort.Order o : pageable.getSort()) { 
    PathBuilder pathBuilder = new PathBuilder(member.getType(), member.getMetadata()); 
    query.orderBy(new OrderSpecifier(o.isAscending() ? Order.ASC : Order.DESC,pathBuilder.get(o.getProperty()))); 
} 

List result = query.fetch();

참고

정렬( Sort )은 조건이 조금만 복잡해져도 Pageable 의 Sort 기능을 사용하기 어렵다.
루트 엔티티 범위를 넘어가는 동적 정렬 기능이 필요하면 스프링 데이터 페이징이 제공하는 Sort 를 사용하기 보다는 파라미터 를 받아서 직접 처리하는 것을 권장한다.
단순한 Entity 하나만 조회할 때는 되는데, 조인이 들어가면 복잡해지면서 동작을 잘 하지 않는다.

2-7. 스프링 데이터 JPA가 제공하는 Querydsl 기능

Spring Data JPA가 Quurydsl을 위해서 몇 가지 기능을 제공하지만, 제약이 커서 복잡한 실무 환경에서 사용하기에는 많이 부족하다.
그래도 스프링 데이터에서 제 공하는 기능이므로 간단히 소개하고, 왜 부족한지 설명하겠다

인터페이스 지원 - QuerydslPredicateExecutor

QuerydslPredicateExecutor라는 인터페이스를 제공하는데, 공식 문서에서 확인할 수 있다.

코드를 확인해보자.

이전에 만들었던 MemberRepository 인터페이스의 extends 요소에 QuerydslPredicateExecutor<Member>를 추가한다.

테스트코드는 다음과 같다.

@Test  
void querydslPredicateExecutorTest() {  
    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, teamB);  
    Member member4 = new Member("member4", 40, teamB);  
    em.persist(member1);  
    em.persist(member2);  
    em.persist(member3);  
    em.persist(member4);  

    QMember member = QMember.member;  
    Iterable<Member> result = memberRepository.findAll(member.age.between(10, 40).and(member.username.eq("member1")));  
    for(Member findMember : result) {  
        System.out.println("findMember = " + findMember);  
    }  
}

위와 같이 QuerydslPredicateExecutorfindAll을 사용한다.
그리고 파라미터에 member.age.between(20, 40).and(member.username.eq("member1")) 조건을 넣을 수 있다.

결과는 다음과 같다.

// JPQL
/* select
        member1 
    from
        Member member1 
    where
        member1.age between ?1 and ?2 
        and member1.username = ?3 */ 

// SQL
select
    m1_0.member_id,
    m1_0.age,
    m1_0.team_id,
    m1_0.username 
from
    member m1_0 
where
    m1_0.age between ? and ? 
    and m1_0.username=?


// 출력 결과
findMember = Member(id=1, username=member1, age=10)

다양한 기능을 제공하지만, 한계가 명확하다.

  • 조인이 되지 않는다. (묵시적 조인은 가능하지만 left join이 불가능하다.)
    • RDB를 사용하기 때문에 join을 피할 수가 없다.
  • 클라이언트 코드가 Querydsl에 의존해야 한다.
  • 서비스 클래스가 Querydsl이라는 구현 기술에 의존해야 한다.
  • 복잡한 실무환경에서 사용하기에는 한계가 명확하다.

참고로 QuerydslPredicateExecutor 도 Pagable과 Sort를 모두 지원하고 정상 동작한다.
하지만 한계가 명확해서 영한님이 실무에서 권장하진 않는다고 한다!

Querydsl Web 지원

Queydsl에서 web을 지원하는데, 공식 문서를 보면

@QuerydslPredicate(root = User.class)를 넣으면 파라미터 바인딩을 Predicate 조건으로 받아준다.

그래서 ?firstname=Dave&lastname=Matthews 이런 식으로 조건을 넣으면
아래에 나와있는 것처럼 QUser.user.firstname.eq("Dave").and(QUser.user.lastname.eq("Matthews")) 이런 식으로 조건을 만들어서 방금 봤던 @QuerydslPredicate(root = User.class) Predicate predicate쪽에 바인딩을 해준다.

이렇게 되면 코드 한줄로 findAll을 컨트롤러에서 바로 편하게 Repository 호출을 할 수가 있다.
그렇지만, 이전의 말했던 한계로 인해서 join이 어렵고 한계가 명확해서 실무에서 사용하기 어렵다.
그리고 eq, contains, in 을 사용하는데, 사실상 eq만 된다.

또한 수정할 때

QuerydslBinderCustomizer를 넣고, 아래의 조건에 대해 작업을 해줘야된다.
(뭐...username이면 조건은 통과시키고,,, 합격하고,,,, 패스워드는 조건에 넣지 말고,,, 어쩌고 저쩌고 등...)
복잡하게 해줘야 한다.
(영한님도 한 번 해보려다가 때려쳤다고 하신다(?))

  • 단순한 조건만 가능하다.
  • 조건을 커스텀하는 기능이 복잡하고 명시적이지 않다.
  • 컨트롤러가 Querydsl에 의존한다.
  • 복잡한 실무환경에서 사용하기에는 한계가 명확

결론은 안 쓰는 걸 권장한다...

리포지토리 지원 - QuerydslRepositorySupport

이 부분은 일단 코드를 보자.
(참고로 매뉴얼엔 없다.)

QuerydslRepositorySupport 추상 클래스를 보자.

설명이 이렇게 되어있다.
"Base class for implementing repositories using Querydsl library."
Qeurydsl 라이브러리를 사용하기 위해 Repository 구현체가 받으면 편리하다는 것인데,

이전에 만들었던 MemberRepositoryCustomImpl에서 다음과 같이 extends에 추가하자.

public class MemberRepositoryCustomImpl extends QuerydslRepositorySupport implements MemberRepositoryCustom { ...}

그럼 QuerydslRepositorySupport기능을 사용할 수 있다.

그리고 JPAQueryFactory를 주입받아서 아래와 같이 사용했었다.

private final JPAQueryFactory queryFactory;  

public MemberRepositoryCustomImpl(EntityManager em) {  
    this.queryFactory = new JPAQueryFactory(em);  
}

그런데 QuerydslRepositorySupport를 상속받으면 위의 코드는 지우고(나는 주석처리), 다음과 코드를 추가하면 된다.

public MemberRepositoryCustomImpl() {  
    super(Member.class);  
}

이렇게 되면 많은 기능을 제공하는데,

QuerydslRepositorySupport가 EntityManager를 직접 받고 있기 때문에 EntityManager를 사용할 수 있고,
Queydsl이라는 유성 클래스를 사용할 수 있다.

(@Repository가 있다..?!)

얘를 상속 받으면 검색에 대한 코드 스타일이 바뀐다.

List<MemberTeamDto> result = from(member)  
        .leftJoin(member.team, team)  
        .where(  
                usernameEq(condition.getUsername()),  
                teamNameEq(condition.getTeamName()),  
                ageGoe(condition.getAgeGoe()),  
                ageLoe(condition.getAgeLoe())  
        )  
        .select(new QMemberTeamDto(  
                member.id,  
                member.username,  
                member.age,  
                team.id.as("teamId"),  
                team.name.as("teamName")))  
        .fetch();

queryFactory를 사용하는 방식은 Querydsl 4버전부터 생겼는데,
위의 방식은 Querydsl 3버전에서부터 사용하던 방식이다.

그리고 EntityManager를 가지고 있기 때문에 끌어다 사용할 수 있다.

또한 Paging 을 편리하게 해준다.

JPQLQuery<MemberTeamDto> jpaQuery = from(member)  
        .leftJoin(member.team, team)  
        .where(  
                usernameEq(condition.getUsername()),  
                teamNameEq(condition.getTeamName()),  
                ageGoe(condition.getAgeGoe()),  
                ageLoe(condition.getAgeLoe())  
        )  
        .select(new QMemberTeamDto(  
                member.id,  
                member.username,  
                member.age,  
                team.id.as("teamId"),  
                team.name.as("teamName")));  

JPQLQuery<MemberTeamDto> query = getQuerydsl().applyPagination(pageable, jpaQuery);  

query.fetch(); // 혹은  query.fetchResults();

from 절부터 시작하는 코드에 paging(offset이나 limit)은 없다.
jpaQuery로 만들고 난 후, getQuerydsl()을 통해서 스프링 데이터 JPA에서 제공하는 Querydsl용 유틸리티 클래스를 사용해서 paging을 처리한다.

참고로 Querydsl 클래스는 다음과 같다.

스프링 데이터 JPA에서 제공하는 Querydsl용 유틸리티 클래스 같은 것인데,
Helper instance라고 되어있는데, 쉽게 Querydsld API에 접근할 수 있게 한다.

그리고 applyPagination()을 사용해서 파라미터에 pageable을 넣어준다.
그러면 해당 메서드가 알아서 paging처리를 해준다.

그렇지만, 그렇게 큰 도움은 되지 않는 것 같다....(?)

장단점은 다음과 같다.

장점

  • getQuerydsl().applyPagination() 스프링 데이터가 제공하는 페이징을 Querydsl로 편리하게 변환 가능하다.
    • 단! Sort는 오류가 발생한다.
  • from() 으로 시작 가능하다
    • 최근에는 QueryFactory를 사용해서 select()로 시작하는 것이 더 명시적이다...
  • EntityManager를 제공한다.

단점

  • Querydsl 3.x 버전을 대상으로 만들었다.
  • Querydsl 4.x에 나온 JPAQueryFactory로 시작할 수 없다.
    • select로 시작할 수 없다. from으로 시작해야한다.
  • QueryFactory 를 제공하지 않는다.
  • 스프링 데이터 Sort 기능이 정상적으로 동작하지 않는다.

Querydsl 지원 클래스 직접 만들기

스프링 데이터가 제공하는 QuerydslRepositorySupport 가 지닌 한계를 극복하기 위해 직접 Querydsl지원 클래스를 만들본다.

그냥 이런 식으로 만들구나~ 하고 보면 될 거 같다.

다음 코드들의 장점은 다음과 같다.

  • 스프링 데이터가 제공하는 페이징을 편리하게 변환한다.
  • 페이징과 카운트 쿼리 분리 가능하다.
  • 스프링 데이터 Sort를 지원한다.
  • select(), selectFrom() 으로 시작 가능하다.
  • EntityManager , QueryFactory를 제공한다.
import com.querydsl.core.types.EntityPath;  
import com.querydsl.core.types.Expression;  
import com.querydsl.core.types.dsl.PathBuilder;  
import com.querydsl.jpa.impl.JPAQuery;  
import com.querydsl.jpa.impl.JPAQueryFactory;  
import jakarta.annotation.PostConstruct;  
import jakarta.persistence.EntityManager;  
import org.springframework.beans.factory.annotation.Autowired;  
import org.springframework.data.domain.Page;  
import org.springframework.data.domain.Pageable;  
import org.springframework.data.jpa.repository.support.JpaEntityInformation;  
import org.springframework.data.jpa.repository.support.JpaEntityInformationSupport;  
import org.springframework.data.jpa.repository.support.Querydsl;  
import org.springframework.data.querydsl.SimpleEntityPathResolver;  
import org.springframework.data.support.PageableExecutionUtils;  
import org.springframework.stereotype.Repository;  
import org.springframework.util.Assert;  
import java.util.List;  
import java.util.function.Function;  

@Repository  
public abstract class Querydsl4RepositorySupport {  
    private final Class domainClass;  
    private Querydsl querydsl;  
    private EntityManager entityManager;  
    private JPAQueryFactory queryFactory;  
    public Querydsl4RepositorySupport(Class<?> domainClass) {  
        Assert.notNull(domainClass, "Domain class must not be null!");  
        this.domainClass = domainClass;  
    }  
    @Autowired  
    public void setEntityManager(EntityManager entityManager) {  
        Assert.notNull(entityManager, "EntityManager must not be null!");  
        JpaEntityInformation entityInformation =  
                JpaEntityInformationSupport.getEntityInformation(domainClass, entityManager);  
        SimpleEntityPathResolver resolver = SimpleEntityPathResolver.INSTANCE;  
        EntityPath path = resolver.createPath(entityInformation.getJavaType());  
        this.entityManager = entityManager;  
        this.querydsl = new Querydsl(entityManager, new  
                PathBuilder<>(path.getType(), path.getMetadata()));  
        this.queryFactory = new JPAQueryFactory(entityManager);  
    }  
    @PostConstruct  
    public void validate() {  
        Assert.notNull(entityManager, "EntityManager must not be null!");  
        Assert.notNull(querydsl, "Querydsl must not be null!");  
        Assert.notNull(queryFactory, "QueryFactory must not be null!");  
    }  
    protected JPAQueryFactory getQueryFactory() {  
        return queryFactory;  
    }  
    protected Querydsl getQuerydsl() {  
        return querydsl;  
    }  
    protected EntityManager getEntityManager() {  
        return entityManager;  
    }  
    protected <T> JPAQuery<T> select(Expression<T> expr) {  
        return getQueryFactory().select(expr);  
    }  
    protected <T> JPAQuery<T> selectFrom(EntityPath<T> from) {  
        return getQueryFactory().selectFrom(from);  
    }  
    protected <T> Page<T> applyPagination(Pageable pageable,  
                                          Function<JPAQueryFactory, JPAQuery> contentQuery) {  
        JPAQuery jpaQuery = contentQuery.apply(getQueryFactory());  
        List<T> content = getQuerydsl().applyPagination(pageable,  
                jpaQuery).fetch();  
        return PageableExecutionUtils.getPage(content, pageable,  
                jpaQuery::fetchCount);  
    }  
    protected <T> Page<T> applyPagination(Pageable pageable,  
                                          Function<JPAQueryFactory, JPAQuery> contentQuery, Function<JPAQueryFactory,  
            JPAQuery> countQuery) {  
        JPAQuery jpaContentQuery = contentQuery.apply(getQueryFactory());  
        List<T> content = getQuerydsl().applyPagination(pageable,  
                jpaContentQuery).fetch();  
        JPAQuery countResult = countQuery.apply(getQueryFactory());  
        return PageableExecutionUtils.getPage(content, pageable,  
                countResult::fetchCount);  
    }  
}

아래는 사용하는 코드다.

import com.querydsl.core.types.dsl.BooleanExpression;  
import com.querydsl.jpa.impl.JPAQuery;  
import org.springframework.data.domain.Page;  
import org.springframework.data.domain.Pageable;  
import org.springframework.data.support.PageableExecutionUtils;  
import org.springframework.stereotype.Repository;  
import younghan.querydsl.dto.MemberSearchCondition;  
import younghan.querydsl.entity.Member;  
import younghan.querydsl.repository.support.Querydsl4RepositorySupport;  

import java.util.List;  
import static org.springframework.util.StringUtils.isEmpty;  
import static younghan.querydsl.entity.QMember.member;  
import static younghan.querydsl.entity.QTeam.team;  

@Repository  
public class MemberTestRepository extends Querydsl4RepositorySupport {  
    public MemberTestRepository() {  
        super(Member.class);  
    }  
    public List<Member> basicSelect() {  
        return select(member)  
                .from(member)  
                .fetch();  
    }  
    public List<Member> basicSelectFrom() {  
        return selectFrom(member)  
                .fetch();  
    }  
    public Page<Member> searchPageByApplyPage(MemberSearchCondition condition,  
                                              Pageable pageable) {  
        JPAQuery<Member> query = selectFrom(member)  
                .leftJoin(member.team, team)  
                .where(usernameEq(condition.getUsername()),  
                        teamNameEq(condition.getTeamName()),  
                        ageGoe(condition.getAgeGoe()),  
                        ageLoe(condition.getAgeLoe()));  
        List<Member> content = getQuerydsl().applyPagination(pageable, query)  
                .fetch();  
        return PageableExecutionUtils.getPage(content, pageable,  
                query::fetchCount);  
    }  
    public Page<Member> applyPagination(MemberSearchCondition condition,  
                                        Pageable pageable) {  
        return applyPagination(pageable, contentQuery -> contentQuery  
                .selectFrom(member)  
                .leftJoin(member.team, team)  
                .where(usernameEq(condition.getUsername()),  
                        teamNameEq(condition.getTeamName()),  
                        ageGoe(condition.getAgeGoe()),  
                        ageLoe(condition.getAgeLoe())));  
    }  
    public Page<Member> applyPagination2(MemberSearchCondition condition,  
                                         Pageable pageable) {  
        return applyPagination(pageable, contentQuery -> contentQuery  
                        .selectFrom(member)  
                        .leftJoin(member.team, team)  
                        .where(usernameEq(condition.getUsername()),  
                                teamNameEq(condition.getTeamName()),  
                                ageGoe(condition.getAgeGoe()),  
                                ageLoe(condition.getAgeLoe())),  
                countQuery -> countQuery  
                        .selectFrom(member)  
                        .leftJoin(member.team, team)  
                        .where(usernameEq(condition.getUsername()),  
                                teamNameEq(condition.getTeamName()),  
                                ageGoe(condition.getAgeGoe()),  
                                ageLoe(condition.getAgeLoe()))  
        );  
    }  
    private BooleanExpression usernameEq(String username) {  
        return isEmpty(username) ? null : member.username.eq(username);  
    }  
    private BooleanExpression teamNameEq(String teamName) {  
        return isEmpty(teamName) ? null : team.name.eq(teamName);  
    }  
    private BooleanExpression ageGoe(Integer ageGoe) {  
        return ageGoe == null ? null : member.age.goe(ageGoe);  
    }  
    private BooleanExpression ageLoe(Integer ageLoe) {  
        return ageLoe == null ? null : member.age.loe(ageLoe);  
    }  
}

3. 요약

인프런 영한님의 Querydsl의 강의에 대한 내용들을 요약 정리했다.

기본 문법, 중급 문법, 그리고 순수 JPA와 Querydsl 을 사용하는 법,
스프링 데이터 JPA를 사용하며 Qeurydsl을 사용하는 법,
실무에서 사용할 수 있는 팁 등 굉장히 다양한 내용을 배울 수 있었다.

4. JPA로드맵을 끝내며

스프링 데이터 JPA까지는 현재 시점 회사에서 프로젝트를 진행 중인데, 잘 풀어내고 있지만, 쿼리dsl을 적용할 시점에서는 조금 자신감이 없었다.

이전에 급하게 듣고 적용하긴 했지만, 이번에 천천히 정리하면서 보게 되면서 자신감이 조금 생긴거 같다.

해당 포스팅 이후에는 Querydsl에 대한 책 내용을 보고 내용이 중복된다면, 추가 포스팅을 안 할 것이고, 필요한 부분이 있다면 추가 포스팅을 할 예정이다.

그리고.. 이로써 영한님의 JPA 로드맵을 드뎌 끝냈다.

그리고 영한님이 쓰신 자바 ORM 표준 JPA 프로그래밍 책도 다 보면서 정리까지 했다.
(거의 받아쓰기였지만....)
원래 작년에 끝내려했지만, 취업과 동시에 이번 해로 넘어오게 되면서 2월달에 끝내려했지만, 예정보다 조금 길어졌다...

이후의 계획은 일단 지금 하고 있는 아토믹 Kotlin 을 후딱 털 것이다.
그리고 이번 해에 입학한 방송통신대, 그리고 봐야될 서적들 예를 들면, 객체지향의 사실과 오해, 모던 자바 인 액션, 도커, 영한님의 스프링 로드맵... (할 거 진짜 겁나 많네..;;) 등등...
그리고 이전에 진행했던 프로젝트들의 코드도 개선해보려고 한다....

여하튼 나는 갈 길이 많이 멀다.

하나의 목표를 완성했다는 건 언제나 기분이 좋다.. 또한 홀가분해졌다.

여하튼 계속 달려보도록 하겠다...!!

강의 내용으로도 충분하기 때문에 추가 포스팅 없음.

728x90
Comments