SpringBoot - queryDSL Study1

 

1. Spring Boot 개발 환경


  • 1) spring.io : Spring Web, jpa, h2, lombok, devtools를 설정하여 프로젝트 설정하기
    • queryDSL은 라이브러리가 아니라 추가로 설정해주어야 한다.
    • springboot 3.0 이상은 jdk 17, h2 2.1.214 버전 이상
    • javax 패키지는 jakarta로 변경


  • 2) SpringBoot JPA는 EntityManager에 종속적이라서 싱글톤 패턴으로 멀티스레드 환경에서 문제가 될 것 같지만 SpringBoot에서 EntityManager를 사용하게 되면, 멀티스레드 환경에서도 문제가 없다.
    • 그 이유는 SpringBoot에서는 EntityManager가 프록시에서 만들어지는데, 실제 EntityManager가 주입되는 것이 아니라 가짜 EntityManager가 주입되기 때문에
    • EntityManager를 호출하면, 현재 데이터베이스 트랜잭션과 관련된 실제 EntityManager를 호출하게 되어 싱글톤이라서 동시성 문제가 되는 문제가 사라지게 된다.
    • 기본적으로 Spring도 싱글톤 패턴


  • 3) JPA는 엔티티 객체를 생성할 때, 기본 생성자를 이용해서 만들고 있다. 그리고 엔티티 객체를 외부에서 만들고 있기 때문에, private 접근제어자는 사용할 수 없다. 또한 default 접근 제어자는 해당 클래스와 같은 패키지에서만 사용할 수 있기 때문에 일반적으로 protected 생성자를 사용하는 것을 권장하고 있는 것이다.


  • 4) @Autowired가 스프링 빈을 주입한다면, @PersistenceContext는 JPA 스펙에서 제공하는 기능인데, 영속성 컨텍스트를 주입하는 표준 애노테이션이다.
    • LocalContainerEntityManagerFactoryBean 살펴보기


  • 5) 중요** :
    • H2를 이용하여 테스트 진행 시, Error creating bean with name 'entityManagerFactory' defined in class path resource 에러가 발생하면, H2 SQL 편집기에서 drop all objects; 명령어를 실행하기!
      • h2 데이터베이스에서 drop all objects 명령어를 입력하면 저장되어있던 테이블이 싹 다 날라가게 된다.
      • h2 데이터베이스도 Intellij Build 와 동일하게 증분 빌드를 하는 과정에서 삭제 된 데이터(테이블)에 대해서는 관리를 하지 않는 것처럼 보였다.
      • 이러한 이슈는 일대다 읽기 전용 테이블이 생성되었다가 해당 연관관계를 삭제하더라도 테이블이 계속해서 h2 데이터베이스에 남아있을 때에도 동일하게 적용되었다.



2. queryDSL 개발 환경 테스트

  • buiild.gradle 설정
    • SpringBoot 3.0 버전 이상에서 Querydsl 사용하는 방법 : Querydsl 추가 설정 부분 주의!
plugins {
	id 'java'
	id 'org.springframework.boot' version '3.1.2'
	id 'io.spring.dependency-management' version '1.1.2'
}

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

java {
	sourceCompatibility = '17'
}

configurations {
	compileOnly {
		extendsFrom annotationProcessor
	}
}

repositories {
	mavenCentral()
}

dependencies {
	implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
	implementation 'org.springframework.boot:spring-boot-starter-web'
	testImplementation 'junit:junit:4.13.1'
	compileOnly 'org.projectlombok:lombok'
	developmentOnly 'org.springframework.boot:spring-boot-devtools'
	runtimeOnly 'com.h2database:h2'
	annotationProcessor 'org.projectlombok:lombok'
	testImplementation 'org.springframework.boot:spring-boot-starter-test'

	// Querydsl 추가
	implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta'
	annotationProcessor "com.querydsl:querydsl-apt:5.0.0:jakarta"
	annotationProcessor "jakarta.annotation:jakarta.annotation-api"
	annotationProcessor "jakarta.persistence:jakarta.persistence-api"

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


	// JUnit4 추가
	testImplementation("org.junit.vintage:junit-vintage-engine") {
		exclude group: "org.hamcrest", module: "hamcrest-core"
	}
}

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



  • QuerydslApplicationTests.java
    • queryDSL 개발 환경 테스트 코드
package com.study.querydsl;

import com.querydsl.jpa.impl.JPAQueryFactory;
import com.study.querydsl.entity.Hello;
import com.study.querydsl.entity.QHello;
import jakarta.persistence.EntityManager;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.annotation.Commit;
import org.springframework.transaction.annotation.Transactional;

@SpringBootTest
@Transactional
@Commit
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"); // 이것보단 아래 코드가 더 편리하다.
		QHello qHello = QHello.hello;

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

		Assertions.assertEquals(result, hello);
		Assertions.assertEquals(result.getId(), hello.getId());
	}

}


  • 이렇게 테스트도 가능하다.
    • JUnit4 : 어노테이션 확인
package com.study.querydsl;

import com.querydsl.jpa.impl.JPAQueryFactory;
import com.study.querydsl.entity.Hello;
import com.study.querydsl.entity.QHello;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.annotation.Commit;
import org.springframework.test.context.junit4.SpringRunner;
import org.springframework.transaction.annotation.Transactional;

@RunWith(SpringRunner.class)
@SpringBootTest
class QuerydslApplicationTests {

	@Autowired
	EntityManager em;

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

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

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

		Assertions.assertEquals(result, hello);
		Assertions.assertEquals(result.getId(), hello.getId());
	}

}



  • Hello.java
package com.study.querydsl.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;
}



  • QHello.java
    • gradle 탭에서 Tasks/other/compileJava을 클릭하면, Hello에서 QHello를 생성하여 queryDSL이 제대로 동작하는지 확인할 수 있다.
package com.study.querydsl.entity;

import static com.querydsl.core.types.PathMetadataFactory.*;

import com.querydsl.core.types.dsl.*;

import com.querydsl.core.types.PathMetadata;
import javax.annotation.processing.Generated;
import com.querydsl.core.types.Path;


/**
 * QHello is a Querydsl query type for Hello
 */
@Generated("com.querydsl.codegen.DefaultEntitySerializer")
public class QHello extends EntityPathBase<Hello> {

    private static final long serialVersionUID = -1353511186L;

    public static final QHello hello = new QHello("hello");

    public final NumberPath<Long> id = createNumber("id", Long.class);

    public QHello(String variable) {
        super(Hello.class, forVariable(variable));
    }

    public QHello(Path<? extends Hello> path) {
        super(path.getType(), path.getMetadata());
    }

    public QHello(PathMetadata metadata) {
        super(Hello.class, metadata);
    }

}




  • application.yml
    • 중요** : H2를 이용하여 테스트 진행 시, Error creating bean with name 'entityManagerFactory' defined in class path resource 에러가 발생하면, H2 SQL 편집기에서 drop all objects; 명령어를 실행하기!
      • h2 데이터베이스에서 drop all objects 명령어를 입력하면 저장되어있던 테이블이 싹 다 날라가게 된다.
        • h2 데이터베이스도 Intellij Build 와 동일하게 증분 빌드를 하는 과정에서 삭제 된 데이터(테이블)에 대해서는 관리를 하지 않는 것처럼 보였습니다.
        • 이러한 이슈는 일대다 읽기 전용 테이블이 생성되었다가 해당 연관관계를 삭제하더라도 테이블이 계속해서 h2 데이터베이스에 남아있을 때에도 동일하게 적용되었습니다!
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.orm.jdbc.bind: trace


  • 마지막 방법으로 H2가 제대로 동작 안 하면, 일단, 인메모리 설정으로 테스트하기!
spring:
##  datasource:
##    url: jdbc:h2:tcp://localhost/~/jpashop
##    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.orm.jdbc.bind: trace



3. JPA 도메인 모델 테스트

1) 설계 방식

  • 이번에는 queryDSL 테스트가 아니라 JPA Native query로 도메인 모델 테스트 진행하고 이를 기반으로 추후 queryDSL를 이용할 예정

  • @NoArgsConstructor(access = AccessLevel.PROTECTED) : 기본 생성자 막고 싶은데, JPA 스팩상 PROTECTED로 열어두어야 함.

    • 중요 : JPA는 엔티티 객체를 생성할 때, 기본 생성자를 이용해서 만들고 있다. 그리고 엔티티 객체를 외부에서 만들고 있기 때문에, private 접근제어자는 사용할 수 없다. 또한 default 접근 제어자는 해당 클래스와 같은 패키지에서만 사용할 수 있기 때문에 일반적으로 protected 생성자를 사용하는 것을 권장하고 있는 것이다.


  • @ToString(of = {"id", "username", "age"}) : 가급적 내부 필드만(연관관계 없는 필드만) ToString에 이용하기.


  • changeTeam() : 양방향 연관관계 한번에 처리(연관관계 편의 메소드)



2) 테스트 코드

  • Member.java
package com.study.querydsl.entity;

import jakarta.persistence.*;
import lombok.*;

@Entity
@Getter @Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)  // 기본 생성자 막고 싶은데, JPA 스팩상 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);
    }

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

    // changeTeam() : 양방향 연관관계 한번에 처리(연관관계 편의 메소드)
    public void changeTeam(Team team) {
        this.team = team;
        team.getMembers().add(this);
    }
}




  • Team.java
package com.study.querydsl.entity;

import jakarta.persistence.*;
import lombok.*;

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


}




  • MemberTest.java
    • 이번에는 queryDSL 테스트가 아니라 JPA Native query로 도메인 모델 테스트 진행하고 이를 기반으로 추후 queryDSL를 이용할 예정
package com.study.querydsl.entity;

import jakarta.persistence.Entity;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.annotation.Commit;
import org.springframework.test.context.junit4.SpringRunner;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;

@RunWith(SpringRunner.class)
@SpringBootTest
@Transactional
@Commit
public class MemberTest {

    @PersistenceContext
    EntityManager em;

    @Test
    public 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());
        }

    }
}


  • 테스트 결과 :
member = Member(id=1, username=member1, age=10)
-> 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)
-> member.team = Team(id=2, name=teamB)
member = Member(id=4, username=member4, age=40)
-> member.team = Team(id=2, name=teamB)



4. queryDSL 기본 문법 정리

1) JPQL vs Querydsl**

  • 중요** : JPQL는 문자(실행(런타임) 시점에서 오류 발견)에서 오류, Querydsl는 코드(컴파일 시점에서 오류 발견)에서 오류
    • 또한, JPQL은 파라미터 바인딩을 직접하고 Querydsl은 파라미터 바인딩을 자동 처리해준다.
  • QMember m = new QMember("m"); : 이렇게하면, 별칭 가능
    • 원래는 QMember member = QMember.member;로 이용했었다.
  • Querydsl은 기본적으로 JPQL 빌더 개념이다. 그래서 코드가 직관적이다.



a. 실습 코드 :


  • No result found for query 에러와 findMember is null 에러 해결 방법** :
    • @Before(JUnit 4)나 @BeforeEach(JUnit 5)를 이용 시, JUnit 테스트 버전 주의!
    • @Before(JUnit 4)나 @BeforeEach(JUnit 5)는 테스트 전에 실행해야하는 것을 미리 실행시켜준다.


  • 나는 아래 테스트 코드를 하나씩 실행해야 되더라!


package com.study.querydsl;

import com.querydsl.jpa.impl.JPAQueryFactory;
import com.study.querydsl.entity.Member;
import com.study.querydsl.entity.QMember;
import com.study.querydsl.entity.Team;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import org.junit.Before;
import org.junit.Test;
import org.junit.jupiter.api.BeforeEach;
import org.junit.runner.RunWith;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.annotation.Commit;
import org.springframework.test.context.junit4.SpringRunner;
import org.springframework.transaction.annotation.Transactional;

import static org.junit.jupiter.api.Assertions.assertEquals;


@RunWith(SpringRunner.class)
@SpringBootTest
@Transactional
@Commit
public class QuerydslBasicTest {

    @PersistenceContext
    EntityManager em;

    // JUnit5에서는 @BeforeEach를 이용
    @Before	// JUnit4에서 이렇게 이용함
    public void before(){
        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);
    }

    @Test
    public void startJPQL(){
        // member1를 찾아라.
        String qlString =
                "select m from Member m where m.username = :username";

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

        assertEquals(findMember.getUsername(), "member1");  // JUnit4
//         assertThat(findMember.getUsername()).isEqualTo("member1"); // JUnit5
    }


    // JPQL: 문자(실행 시점 오류), Querydsl: 코드(컴파일 시점 오류)
    @Test
    public void startQuerydsl(){
        // member1을 찾아라
        JPAQueryFactory queryFactory = new JPAQueryFactory(em);
        QMember m = new QMember("m");   // 이렇게하면, 별칭 가능

        Member findMember = queryFactory
                .select(m)
                .from(m)
                .where(m.username.eq("member1")) // 파라미터 바인딩 처리
                .fetchOne();

        assertEquals(findMember.getUsername(), "member1");  // JUnit4
//        assertThat(findMember.getUsername()).isEqualTo("member1");  // JUnit5
    }	

}




b. JPAQueryFactory를 필드로 :

  • JPAQueryFactory를 필드로 제공하면 동시성 문제는 어떻게 될까? 동시성 문제는 JPAQueryFactory를 생성할 때 제공하는 EntityManager(em)에 달려있다. 스프링 프레임워크는 여러 쓰레드에서 동시에 같은 EntityManager에 접근해도, 트랜잭션 마다 별도의 영속성 컨텍스트를 제공하기 때문에, 동시성 문제는 걱 정하지 않아도 된다.


a) 변경된 테스트 코드 :
  • 필드에서 작성하고 @Before에서 생성해서 사용한다.
package com.study.querydsl;

import com.querydsl.jpa.impl.JPAQueryFactory;
import com.study.querydsl.entity.Member;
import com.study.querydsl.entity.QMember;
import com.study.querydsl.entity.Team;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import org.junit.Before;
import org.junit.Test;
import org.junit.jupiter.api.BeforeEach;
import org.junit.runner.RunWith;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.annotation.Commit;
import org.springframework.test.context.junit4.SpringRunner;
import org.springframework.transaction.annotation.Transactional;

import static org.junit.jupiter.api.Assertions.assertEquals;


@RunWith(SpringRunner.class)
@SpringBootTest
@Transactional
@Commit
public class QuerydslBasicTest {

    @PersistenceContext
    EntityManager em;

    JPAQueryFactory queryFactory;

    // @BeforeEach // JUnit5
    @Before // JUnit4
    public void before(){
        queryFactory = new JPAQueryFactory(em);

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


    @Test
    public void startQuerydsl2(){
        // member1을 찾아라

        QMember m = new QMember("m");   // 이렇게하면, 별칭 가능

        Member findMember = queryFactory
                .select(m)
                .from(m)
                .where(m.username.eq("member1")) // 파라미터 바인딩 처리
                .fetchOne();

        assertEquals(findMember.getUsername(), "member1"); // JUnit4
//        assertThat(findMember.getUsername()).isEqualTo("member1"); // JUnit5
    }

}





2) 기본 Q-Type 활용

a. 실습 코드

QMember qMember = new QMember("m");  // 별칭 이용 가능
QMember qMember = QMember.member;  // 기본 인스턴스로 편하게 이용 가능 



3) 기본 인스턴스를 static import와 함께 사용 가능

  • 훨씬 더 사용이 간단해진다.
    • 같은 테이블을 조인해야 하는 경우가 아니면 기본 인스턴스를 사용하자
import static com.study.querydsl.entity.QMember.*;

@Test
public void startQuerydsl3() {
	// member1을 찾아라.
	Member findMember = queryFactory
		.select(member)
		.from(member)
		.where(member.username.eq("member1"))
		.fetchOne();
		
	assertThat(findMember.getUsername()).isEqualTo("member1");
}


a. 실행되는 JPQL을 볼 수 있는 방법

  • application.yml에 다음 설정을 추가하면, 쿼리 실행 시, 실행되는 JPQL을 볼 수 있다.
spring.jpa.properties.hibernate.use_sql_comments: true



4) 검색 조건 쿼리

  • 기본 검색 쿼리는 .and(), .or() 쿼리로 진행하기

  • select(), from()은 selectFrom()으로 하나로 합칠 수 도 있다.

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

@Test
public void startQuerydsl3() {
	// member1을 찾아라.
	Member findMember = queryFactory
		.select(member)
		.from(member)
		.where(member.username.eq("member1"))
			.and(member.age.eq(10))
		.fetchOne();
		
	assertThat(findMember.getUsername()).isEqualTo("member1");
}


a. JPQL이 제공하는 모든 검색 조건 제공

  • eq(), ne() : ‘=’, ‘!=’로 해석


  • isNotNull() : isNotNull을 의미한다.


  • in(), notIn(), between() : 값이 포함되는지 범위로 표현


  • goe(), gt(), loe(), lt() : 부등호 범위 표시 가능


  • like(), contains(), `startswith() : like 조회


b. AND 조건을 파라미터로 처리 가능**

  • 이 경우에 null값을 무시해서, 메서드 추출 시, 동적 쿼리를 간편히 만들 수 있다.
import static com.study.querydsl.entity.QMember.*;

@Test
public void startQuerydsl3() {
	// member1을 찾아라.
	Member findMember = queryFactory
		.select(member)
		.from(member)
		.where(member.username.eq("member1")),
			member.age.eq(10))
		.fetch();
		
	assertThat(findMember.size()).isEqualTo(1);
}



5) querydsl 결과 조회 방식

  • fetch() : 리스트 조회**


  • fetchOne() : 단건 조회**


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


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


  • fetchCount() : count 쿼리로 변경하여 count 수 조회**



6) querydsl 정렬 방식

  • desc(), asc()를 이용하기

  • 회원 이름이 없으면, 마지막에 출력(nullsLast()) vs nullsFirst()

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

@Test
public void startQuerydsl3() {
	// member1을 찾아라.
	Member findMember = queryFactory
		.select(member)
		.from(member)
		.where(member.age.eq(100))
			.orderBy(member.age.desc(), member.username.asc().nullsLast())
		.fetch();
		
	assertThat(findMember.size()).isEqualTo(1);
}



7) 페이징

  • MySQL처럼 offset과 limit을 이용하기
import static com.study.querydsl.entity.QMember.*;

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

<>

a. 전체 조회 수를 페이징**

  • fetchResults 이용하기
  • count 쿼리에 조인이 필요없는 성능 최적화가 필요하다면, count 전용 쿼리를 별도로 작성해야 한다.
import static com.study.querydsl.entity.QMember.*;

@Test
public 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);
	assertThat(queryResults.getOffset()).isEqualTo(1);
	assertThat(queryResults.getResults().size()).isEqualTo(2);
}



8) 집합 함수

a. COUNT, SUM, AVG, MAX, MIN 이용

  • 결과 집합은 Tuple에서 값을 꺼낸다.
import static com.study.querydsl.entity.QMember.*;

@Test
public void aggregation() throws Exception {
	List<Tuple> result = queryFactory
		.select(member.count(),
			member.age.sum(),
			member.age.avg(),
			member.age.max(),
			member.age.min()) 
		.from(member)
		.fetch();

	Tuple tuple = result.get(0);
	assertThat(tuple.get(member.count())).isEqualTo(4);
	assertThat(tuple.get(member.age.sum())).isEqualTo(100);
	assertThat(tuple.get(member.age.avg())).isEqualTo(25);
	assertThat(tuple.get(member.age.max())).isEqualTo(40);
	assertThat(tuple.get(member.age.min())).isEqualTo(10);
}



b. GroupBy 사용

  • having : 그룹화된 결과를 제한하기
.groupBy(item.price)
.having(item.price.gt(1000))



9) 기본 조인

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


  • join(), innerjoin(), leftjoin(), rightjoin()



	QMember member = QMember.member; 
	QTeam team = QTeam.team;
	
	List<Member> result = queryFactory
		.selectFrom(member)
		.join(member.team, team)
		.where(team.name.eq("teamA"))
		.fetch();



10) 세타 조인

  • 연관관계가 없는 필드로 조인
    • 예시) 회원의 이름이 팀 이름과 같은 회원 조회을 조회할 수 있다.


  • from 절에 여러 엔티티를 선택해서 세타 조인을 할 수 있다.


  • 세타 조인은 외부 조인 불가능하지만, 조인 on을 사용하면 외부 조인 가능하다.
import static com.study.querydsl.entity.QMember.*;

	em.persist(new Member("teamA"));
	em.persist(new Member("teamB"));
	 
	List<Member> result = queryFactory
		.select(member)
		.from(member, team)
		.where(member.username.eq(team.name))
		.fetch();



11) 조인 - on절

a. 조인 대상 필터링


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


  • on절을 기존의 SQL과 달리 조인 대상 필터링만 하면 되기 때문에 훨씬 더 간단해졌다.
    • inner join을 사용하면, where 절에서 필터링 하는 것과 기능이 동일하다. 따라서, on 절을 활용한 조인 대상 필터링을 사용할 때, inner join이면 익숙한 where 절로 해결하고, 정말 외부조인이 필요한 경우에만 이 기능을 사용하자.
import static com.study.querydsl.entity.QMember.*;

// 회원과 팀을 조인하면서, 팀 이름이 'teamA'인 팀만 조인, 회원은 모두 조회
	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);
	}



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

  • 실습 코드 :
    • 하이버네이트 5.1부터 on 을 사용해서 서로 관계가 없는 필드로 외부 조인하는 기능이 추가
import static com.study.querydsl.entity.QMember.*;

// 회원과 팀을 조인하면서, 팀 이름이 'teamA'인 팀만 조인, 회원은 모두 조회
	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() 부분에 일반 조인과 다르게 엔티티 하나만 들어간다
일반조인: leftJoin(member.team, team)
on조인: from(member).leftJoin(team).on(xxx)



12) 조인 - 페치 조인**

  • 성능 최적화에 사용하는 방법이며 자세한 내용은 기본편이나 활용2 강의 확인하기

a. 페치 조인 미적용

  • 지연로딩으로 Member, Team SQL 쿼리 각각 실행
    • getPersistenceUnitUtil 찾아보기!!
import static com.study.querydsl.entity.QMember.*;

	@PersistenceUnit
	EntityManagerFactory emf;

	@Test
	public void fetchJoinNo() throws Exception {
		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();
	}



b. 페치 조인 적용

  • 즉시로딩으로 Member, Team SQL 쿼리 조인으로 한번에 조회
    • .join(member.team, team).fetchJoin()
import static com.study.querydsl.entity.QMember.*;

	@Test
	public void fetchJoinNo() throws Exception {
		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("페치 조인 미적용").isFalse();
	}



13) 서브 쿼리**

a. JPAExpressions 사용!

  • eq, goe, in, select 절에 이용 가능
import static com.study.querydsl.entity.QMember.*;

QMember memberSub = new QMember("memberSub");

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


b. static import 활용**

  • JPAExpressions를 직접 사용하지 않아서 JPA Native query처럼 구조가 더 간단해진다.
import static com.study.querydsl.entity.QMember.*;
import static com.querydsl.jpa.JPAExpressions.select;


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


c. from 절의 서브쿼리 한계**

a) JPA JPQL 서브쿼리의 한계점으로 from 절의 서브쿼리(인라인 뷰)는 지원하지 않는다. 당연히 Querydsl도 지원하지 않는다.


b) from 절의 서브쿼리 해결방안**
  • 서브쿼리를 join으로 변경한다.(하지만, 가능한 상황도 있고, 불가능한 상황도 있다.)

  • 애플리케이션에서 쿼리를 2번 분리해서 실행한다.

  • nativeSQL을 사용




14) Case 문

  • CaseBuilder :
    • select, 조건절(where), order by에서 사용 가능

a. 예시 1

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


b. 예시 2

NumberExpression<Integer> rankPath = new CaseBuilder()
	.when(member.age.between(0, 20)).then(2)
	.when(member.age.between(21, 30)).then(1)
	.otherwise(3);

List<Tuple> result = queryFactory
	.select(member.username, member.age, rankPath)
	.from(member)
	.orderBy(rankPath.desc())
	.fetch();

for (Tuple tuple : result) {
	String username = tuple.get(member.username);
	Integer age = tuple.get(member.age);
	Integer rank = tuple.get(rankPath);
	System.out.println("username = " + username + " age = " + age + " rank = " + rank);
}
username = member4 age = 40 rank = 3
username = member1 age = 10 rank = 2
username = member2 age = 20 rank = 2
username = member3 age = 30 rank = 1



15) 상수, 문자 더하기

a. 상수 더하기 : Expressions.constant(xxx)

Tuple result = queryFactory
 .select(member.username, Expressions.constant("A"))
 .from(member)
 .fetchFirst();
  • 최적화가 가능하면 SQL에 constant 값을 넘기지 않는다. 상수를 더하는 것 처럼 최적화가 어려우면 SQL에 constant 값을 넘긴다.


b. 문자 더하기 : concat

String result = queryFactory
	.select(member.username.concat("_").concat(member.age.stringValue()))
	.from(member)
	.where(member.username.eq("member1"))
	.fetchOne();
  • member.age.stringValue() 부분이 중요한데, 문자가 아닌 다른 타입들은 stringValue() 로 문 자로 변환할 수 있다.
    • 이 방법은 ENUM을 처리할 때도 자주 사용한다.**



5. 중급 문법 정리

1) 프로젝션 결과 반환: 기본

a. Projection 개념 :

  • select 절에 대상을 지정하면, 원하는 값만 뽑아오는 것
    • Projection 대상이 하나면 타입을 명확하게 지정할 수 있음
    • Projection 대상이 둘 이상이면 타입을 명확하게 지정할 수 없으므로 튜플이나 DTO로 조회**


  • Repository 내부에서 데이터를 조회할 때, Entity 외의 값 ( ex) DTO )으로 편리하게 리턴받아 사용할 수 있도록 하기 위해서 튜플을 사용한다.
    • 하지만, 튜플 타입은 queryDSL에서만 제공하는 타입이기 때문에 서비스 또는 API 계층인 Repository 외부에서는 사용할 수 없어서 다른 방식으로 DTO를 사용해야만 한다.**


  • 결론** : queryDSL에서 DTO를 사용한다면, 웬만하면 Projection을 사용하자! 영속성 컨텍스트 관리를 해야할지 항상 고려해야 하는가?
    • DTO를 무조건 사용해야 한다. API에 직접 노출 시, 설계를 직접 보여주는 꼴이며, API 스펙 변경 시, 리소스가 많이 소요되고 원인을 알 수 없는 에러가 발생한다.(JPA 실무 활용 2편 참고)



2) 프로젝션 결과 반환: DTO 조회

a. DTO 준비

package com.study.querydsl.dto;
import lombok.Data;

	@Data
	public class MemberDto {
		private String username;
		private int age;

	public MemberDto() {

	}

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


b. JPA Native query 이용하여 조회하는 방식

  • 다음 내용은 Native query나 JPQL로 조회하는 방식에서 이를 queryDSL 동적 쿼리로 변경하는 과정이다.
    • Native query나 JPQL을 이용하여 queryDSL을 이용하면, 항상 package 경로를 적어줘야 하는 번거로움과 생성자 방식만 사용할 수 있다는 제약이 있다.
    • queryDsl을 사용하면 훨씬 편하고 안정적으로 DTO projection이 가능하다.
List<MemberDto> result = em.createQuery(
	"select new study.querydsl.dto.MemberDto(m.username, m.age) " +
		"from Member m", MemberDto.class)
	.getResultList();



3) Querydsl 빈 생성(Bean population)**

  • 결과를 DTO 반환할 때, 사용하며 방식에서는 4가지 방식이 있다.**


a. 프로퍼티 접근 : setter 방식

List<MemberDto> result = queryFactory
	.select(Projections.bean(MemberDto.class,
		member.username,
		member.age))
	.from(member)
	.fetch();


b. 필드 직접 접근

  • Projections에 fields 사용하기
a) 속성명 직접 사용하기
List<MemberDto> result = queryFactory
	.select(Projections.fields(MemberDto.class,
		member.username,
		member.age))
	.from(member)
	.fetch();


b) DTO 속성에 별칭을 사용하는 경우
@Data
public class UserDto {
	private String name;
	private int age;
}
List<UserDto> fetch = queryFactory
	.select(Projections.fields(UserDto.class,
		member.username.as("name"),
		ExpressionUtils.as(
		JPAExpressions
			.select(memberSub.age.max())
			.from(memberSub), "age")
			)
	).from(member)
	.fetch();


c. 생성자 사용**

  • Projections에 constructor 사용하기
    • 이것을 가장 많이 사용한다.
List<MemberDto> result = queryFactory
	.select(Projections.constructor(MemberDto.class,
		member.username,
		member.age))
	.from(member)
	.fetch();
}


d. QueryProjection

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

}


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



4) @QueryProjection 추가 설명**

  • 컴파일러로 타입을 체크할 수 있으므로 가장 안전한 방법이다. 다만, DTO에 QueryDSL 어노테이션을 유지해야 하는 점과 DTO까지 Q 파일을 생성해야 하는 단점이 있다.
    • 그래서, @QueryProjection은 종속적으로 결합된다. 그래서, 문제가 많다.


  • 하지만, 엔티티로 직접 조회할 때에는 영속성 컨텍스트의 관리를 받지만, DTO로 조회하게 되면 영속성 컨텍스트에서 관리되지 않는다.


  • 따라서, 엔티티로 조회할 수 있는 경우 또는 영속성 컨텍스트 내에서 관리되어야 하는 경우에는 엔티티로 조회 하되, projection 또는 성능 최적화가 필요한 경우에는 DTO projection을 사용해 조회하면 된다.



5) 동적 쿼리 - BooleanBuilder 사용**

a. 동적 쿼리를 해결하는 두가지 방식

  • BooleanBuilder

  • Where 다중 파라미터 사용

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

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



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

a. Where 다중 파라미터 사용

  • 조건마다 메서드 생성하기!**

  • where 조건에 null 값은 무시된다.

  • 메서드를 다른 쿼리에서도 재활용할 수 있다.

  • 쿼리 자체의 가독성이 높아진다


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

private List<Member> searchMember2(String usernameCond, Integer ageCond) {
	return queryFactory
		.selectFrom(member)
		.where(usernameEq(usernameCond), ageEq(ageCond))
		.fetch();	
}

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


b. 조합 가능**

  • null 체크는 주의해서 처리해야함!
private BooleanExpression allEq(String usernameCond, Integer ageCond) {
	return usernameEq(usernameCond).and(ageEq(ageCond));
}



7) 수정, 삭제 벌크 연산

  • delete(), update()를 이용하기

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




8) SQL function 호출하기

  • SQL function은 JPA와 같이 Dialect(방언)에 등록된 내용만 호출할 수 있다
    • {0}, {1}, {2}는 replace할 변수의 갯수

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