실전 Querydsl

유요한·2023년 5월 7일
0

JPA

목록 보기
10/10
post-thumbnail

세팅

plugins {
    id 'java'
    id 'org.springframework.boot' version '3.1.7'
    id 'io.spring.dependency-management' version '1.1.4'
    //querydsl 추가
    id "com.ewerk.gradle.plugins.querydsl" version "1.0.10"
}

group = 'com.example'
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'
    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'
}

tasks.named('bootBuildImage') {
    builder = 'paketobuildpacks/builder-jammy-base:latest'
}

tasks.named('test') {
    useJUnitPlatform()
}
//querydsl 추가 시작
def querydslDir = "$buildDir/generated/querydsl"
querydsl {
    jpa = true
    querydslSourcesDir = querydslDir
}
sourceSets {
    main.java.srcDir querydslDir
}
configurations {
    querydsl.extendsFrom compileClasspath
}
compileQuerydsl {
    options.annotationProcessorPath = configurations.querydsl
}

querydsl 세팅을 한 다음

여기서 보면 이게 생성되는데 이거를 클릭하면 현재 저는 에러가 발생했습니다.

알고보니 저위의 설정은 2.xx버전의 설정이고 3.xx버전에서 querydsl을 사용할 경우 변화가 되었습니다.

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

group = 'com.example'
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'
    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'
    // ⭐ Spring boot 3.x이상에서 QueryDsl 패키지를 정의하는 방법
    implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta'
    implementation 'com.querydsl:querydsl-apt'
    annotationProcessor "com.querydsl:querydsl-apt:5.0.0:jakarta"
    annotationProcessor "jakarta.annotation:jakarta.annotation-api"
    annotationProcessor "jakarta.persistence:jakarta.persistence-api"

}

tasks.named('bootBuildImage') {
    builder = 'paketobuildpacks/builder-jammy-base:latest'
}

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

clean {
    delete file('src/main/generated')
}

이렇게 할 경우

성공하는 것을 볼 수 있습니다.

이렇게 생성이 되는데 이거는 현재 테스트삼아서 Hello 엔티티를 만들어준게 반영이 된겁니다.

package com.example.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 = -932168945L;

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

}

테스트

package com.example.querydsl;

import com.example.querydsl.entity.Hello;
import com.example.querydsl.entity.QHello;
import com.querydsl.jpa.impl.JPAQueryFactory;
import jakarta.persistence.EntityManager;
import org.assertj.core.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.transaction.annotation.Transactional;

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

@SpringBootTest
@Transactional
class QuerydslApplicationTests {

    @Autowired
    EntityManager em;

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

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

        Hello result = queryFactory
                // 쿼리와 관련된 것은 q타입을 넣어야 합니다.
                .selectFrom(qHello)
                .fetchOne();

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

}

도메인 모델

package com.example.querydsl.entity;

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

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Builder
@AllArgsConstructor
@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 void changeTeam(Team team) {
        this.team = team;
        team.getMembers().add(this);
    }
}
  • @Setter: 실무에서 가급적 Setter는 사용하지 않기

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

  • @ToString은 가급적 내부 필드만(연관관계 없는 필드만)

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

    cascade를 사용하지 않을 경우

package com.example.querydsl.entity;

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

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

@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Builder
@Getter
@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<>();
}

Member와 Team은 양방향 연관관계, Member.team 이 연관관계의 주인, Team.members 는 연관관계의 주인이 아님, 따라서 Member.team 이 데이터베이스 외래키 값을 변경, 반대편은 읽기만 가능

  @Test
    public void startJPQL() {
        String q1String = "select m from query_members m " +
                "where m.userName = :userName";

        Member findMember = em.createQuery(q1String, Member.class)
                .setParameter("userName", "member1")
                .getSingleResult();

        log.info("findMember : " + findMember);
        assertThat(findMember.getUserName()).isEqualTo("member1");
    }

    @Test
    @DisplayName(value = "querydsl 테스트")
    void startQueryDsl() {
        JPAQueryFactory queryFactory = new JPAQueryFactory(em);
        QMember qMember = new QMember("m");

        Member findMember = queryFactory
                .select(qMember)
                .from(qMember)
                .where(qMember.userName.eq("member1"))
                .fetchOne();

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

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


기본 Q-Type 활용


import static com.example.querydsl.entity.QMember.member;

   @Test
    void testQueryDsl() {
        Member findMember = queryFactory
                .select(member)
                .from(member)
                .where(member.userName.eq("member1"))
                .fetchOne();
        assertThat(findMember.getUserName()).isEqualTo("member1");
    }

QMember.member 이렇게 직접 사용할 수 있는데 예를들어, .select(QMember.member)그러면 지저분하니 import static으로 해놓고 member만 사용하는 것입니다.


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

// username이 'member1'인 경우를 검색합니다.
member.username.eq("member1") // username = 'member1'
// username이 'member1'이 아닌 경우를 검색합니다.
member.username.ne("member1") //username != 'member1'
// 앞선 조건의 부정으로, username이 'member1'이 아닌 경우를 검색합니다.
member.username.eq("member1").not() // username != 'member1'
// username이 NULL이 아닌 경우를 검색합니다.
member.username.isNotNull() //이름이 is not null
// age가 10 또는 20인 경우를 검색합니다.
member.age.in(10, 20) // age in (10,20)
// age가 10이나 20이 아닌 경우를 검색합니다.
member.age.notIn(10, 20) // age not in (10, 20)
// age가 10부터 30 사이인 경우를 검색합니다.
member.age.between(10,30) //between 10, 30
member.age.goe(30) // age >= 30
member.age.gt(30) // age > 30
member.age.loe(30) // age <= 30
member.age.lt(30) // age < 30
// username이 'member'로 시작하는 경우를 검색합니다.
member.username.like("member%") //like 검색
// username에 'member' 문자열이 포함된 경우를 검색합니다. 
member.username.contains("member") // like ‘%member%’ 검색
// username이 'member'로 시작하는 경우를 검색합니다.
member.username.startsWith("member") //like ‘member%’ 검색

AND 조건을 파라미터로 처리

    @Test
    void search2() {
        List<Member> result = queryFactory
                .selectFrom(member)
                .where(member.userName.eq("member1")
                        , member.age.eq(10))
                .fetch();
        assertThat(result.size()).isEqualTo(1);
    }

결과 조회

  1. fetch(): 쿼리문을 처리 한 후, 반환되는 값들을 그대로 리스트로 가져옵니다. 이떄, 반환되는 데이터가 없으면 빈 리스트(Empty List)가 반환됩니다.
  2. fetchOne(): 단 하나의 데이터를 조회합니다. 쿼리문에 의해 반환되는 데이터가 0개 이면 NULL 반환, 1개이면 정상, 그리고 2개 이상일 경우에는 NonUniqueResultException가 발생합니다.
  3. fetchFirst(): limit(1).fetchOne()과 동일하며, 항상 한개의 데이터만 조회됩니다.
  4. fetchResult(): 페이징 정보 포함 & count 쿼리도 같이 실행됩니다. But, deprecated.
  5. fetchCount(): 쿼리를 count 쿼리로 변경해서, count 값일 반환합니다. But, deprecated.

정렬


GroupBy

그룹화된 결과를 제한하려면 having


groupBy한 결과중에서 1000원이 넘는거만 뽑아라


기본 조인

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

여기서 보면 joinleftJoin이 있는데 Inner Join은 두 테이블 간에 매칭되는 데이터만을 가져옵니다. 즉, 양쪽 테이블에서 매칭되는 행만을 결과로 반환합니다. Left Join은 왼쪽 테이블의 모든 데이터를 가져오면서, 오른쪽 테이블과 매칭되는 데이터가 있는 경우에는 매칭되는 데이터도 함께 가져옵니다.

theta join

세타 조인을 사용하면 외부 조인이 불가능하지만 on을 사용하면 외부 조인이 가능하다.

조인 - on절

on절을 활용한 조인

  1. 조인 대상 필터링
    on 절을 활용해 조인 대상을 필터링 할 때, 외부조인이 아니라 내부조인(inner join)을 사용하면, where 절에서 필터링 하는 것과 기능이 동일하다. 따라서 on 절을 활용한 조인 대상 필터링을 사용할 때, 내부조인 이면 익숙한 where 절로 해결하고, 정말 외부조인이 필요한 경우에만 이 기능을 사용하자.
t=[Member(id=3, username=member1, age=10), Team(id=1, name=teamA)]
t=[Member(id=4, username=member2, age=20), Team(id=1, name=teamA)]
t=[Member(id=5, username=member3, age=30), null]
t=[Member(id=6, username=member4, age=40), null]
  1. 연관관계 없는 엔티티 외부조인
    하이버네이트 5.1부터 on 을 사용해서 서로 관계가 없는 필드로 외부 조인하는 기능이 추가되었다. 물론 내부 조인도 가능하다.

조인 - 페치 조인

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

페치조인 미적용

여기서 페치조인이 미적용이기 때문에 false가 나오는 것이 맞다.

페치조인 적용


서브 쿼리

여기서 JPAExpressions는 static import를 할 수 있다.

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

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

사용이유
DB에서 너무 쿼리에서 기능을 많이 제공하니까 SQL에 기능을 사용하기 위해서 복잡한 쿼리를 사용하니 프롬절 안에 프롬절 안에 들어가는 경우가 있다.


Case 문

단순한 조건

복잡한 조건


상수 & 문자 더하기

여기서 .stringValue() 부분이 중요한데 문자가 아닌 다른 타입들은 이거를 통해 문자로 변환할 수 있다. 이 방법은 ENUM을 처리할 때도 자주 사용한다.


프로젝션과 결과 반환

기본

프로젝션 : select 대상 지정

프로젝션 대상 하나

프로젝션 여러개

Tuple은 레포지토리에서 사용하는 것은 괜찮지만 이거를 서비스 계층이나 컨트롤러까지 넘어가는 건 좋은 설계가 아니다.

DTO 조회

DTO

JPA에서 DTO로 조회

  • 순수 JPA에서 DTO를 조회할 때는 new 명령어를 사용해야함
  • DTO의 package 이름을 다 적어줘야해서 지저분함
  • 생성자 방식만 지원함

Querydsl 빈 생성
3가지 방법 지원

  • 프로퍼티 접근

    setter 지원 방법

  • 필드 직접 접근

  • 생성자 사용

여기서 DTO 이름과 엔티티의 이름이 다를 수 있다. 그럴때 .as를 사용해야 합니다.

이렇게 하면 제대로 찾아오는 것을 확인할 수 있습니다.

@QueryProjection

MemberDTO에서 생성자에

이렇게 추가하고 grdale에서 comepileQuerydsl을 해야지만 스프링부트가 업데이트 되면서 complileJava를 해야 합니다.

그러면 DTO도 Q파일로 생성이 됩니다.

그렇다면 constructor(생성자) 와 무슨 차이인가 할 수 있지만 이거는 문제가 있을 경우 컴파일에서 오류가 발생하지만 constructor(생성자) 런타임에서 오류가 발생합니다. 그렇기 때문에 @QueryProjection이 안정적으로 사용할 수 있습니다.

단점

  1. Q파일 생성
  2. 의존관계 문제

    DTO가 querydsl에 의존성을 가지게 된다.


동적 쿼리 - BooleanBuilder, BooleanExpression 사용

동적 쿼리를 해결하는 두가지 방법

  • BooleanBuilder

    쿼리의 조건 설정인 where뒤의 조건을 생성해주는 것이라고 생각하면 된다.

    이렇게 사용해서 null에러 방지할 수 있다.

  • Where 다중 파라미터 사용

    • where 조건에 null 값은 무시된다.
    • 메서드를 다른 쿼리에서도 재활용할 수 있다.
    • 쿼리 자체의 가독성이 높아진다.

    여기서 BooleanExpression 을 사용하는 이유로는 BooleanExpression 은 and 와 or 같은 메소드들을 이용해서 BooleanExpression 을 조합해서 새로운 BooleanExpression 을 만들 수 있다는 장점이 있다. 그러므로 재사용성이 높다. 그리고 BooleanExpression 은 null 을 반환하게 되면 Where 절에서 조건이 무시되기 때문에 안전하다. 전부 null이면 findAll() 처럼 전부 조회한다. 조건을 넣었는데 조건에 맞지 않는 값만 있으면 fetch()가 빈 리스트를 반환해준다.


수정, 삭제 배치 쿼리

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

기존 숫자에 1 더하기

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


SQL function 호출하기

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


실무 활용 - 순수 JPA와 Querydsl


동적 쿼리와 성능 최적화 조회

Builder 사용

한번에 DTO로 처리하면서 최적화를 사용하고 Builder를 사용하는 방법

레포지토리

조건이 없으면 전부 가져오므로 기본적인 조건이나 리미트가 있는게 좋고 동적쿼리를 처리할 때 페이지 처리를 하는 것이 좋다.

where절 사용

레포지토리

이거의 장점은 이 코드를 재사용할 수 있다는 점입니다. 예를들어 엔티티 조회로 변환하려고 할 때 재사용해서 사용할 수 있습니다


실무 활용 - Spring Data JPA와 Querydsl

여기서 사용하는 방법은 전에 공부했던 레포지토리를 커스텀으로 구현하는 방법과 동일합니다. 엔티티 조회가 아니라 동적쿼리 DTO 조회입니다.

모든 것을 커스텀에 구현하는 것도 좋은 설계는 아니다. 핵심 비지니스 로직, 잘 사용하는 로직, 그리고 엔티티 조회라든지 공용적인 것들은 MemberRepository에 구현하고 공용성이 없고 특정 기능에 특화된 것이면 별도로 빼서 구현해준다.


스프링 데이터 페이징 활용

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

count 쿼리가 생략 가능한 경우 생략해서 처리

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

이거를 검색도 되게 수정하였습니다.

스프링 데이터 정렬(Sort)

정렬(Sort)은 조건이 조금만 복잡해져도 Pageable의 Sort 기능을 사용하기 어렵다. 루트 엔티티 범위를 넘어가는 동적 정렬이 필요하면 스프링 데이터 페이징이 제공하는 Sort를 사용하기 보다는 파라미터를 받아서 직접 처리하는 것을 권장한다.

이 말은 파라미터를 받아서 직접 처리하는 방법은 MemberSearchCondition 같은 곳에 정렬할 컬럼과 정렬 방향을 담아 요청을 받아서 그걸로 처리하면 됩니다. 주로 orderBy를 동적쿼리로 처리할 때 사용하면 됩니다. BooleanExpression을 반환하는 메서드를 따로 만들어 사용했던 것처럼, 정렬 컬럼명과 정렬 방향을 받으면 그거에 맞춰 OrderSpecifier를 반환하는 메서드를 만들어 order by를 추가해 적용하면 될 것 같습니다. 다만 querydsl-jpa 의 orderBy() 에는 null을 넣을 수 없어 정렬 조건이 없을 때의 처리를 어떻게 할 것이냐도 추가로 고민해야 합니다.

루트 엔티티 범위

"루트 엔티티 범위"란 일반적으로 JPA 쿼리에서 사용되는 from 구문에서 지정되는 엔티티의 범위를 의미합니다. 스프링 데이터 JPA의 정렬 (Sort)은 엔티티의 프로퍼티를 기반으로 이루어지며, 기본적으로 루트 엔티티 범위 내에서만 동작합니다.

동적 정렬

동적 정렬은 실행 시점에 정렬 조건이나 정렬 대상을 동적으로 결정하는 것을 의미합니다. 예를 들어, 사용자가 클라이언트에서 전달한 요청에 따라 정렬 기준이나 방향을 변경해야 하는 경우를 말합니다.

여기서 알아야 할 것은 Page<ItemEntity> findByItemNameContaining(Pageable pageable, String searchKeyword); Spring Data JPA를 사용할 때는 @PageableDefault(sort = "boardId", direction = Sort.Direction.DESC)가 적용이 된다.

하지만 JPQL이나 Querydsl은 직접 쿼리를 작성하게 되므로, 정렬 순서를 명시적으로 설정해주어야 합니다. JPQL에서는 ORDER BY 구문을 사용하고, QueryDSL에서는 orderBy 메서드 등을 사용하여 정렬 조건을 추가해야 합니다.

여기서 동적으로 처리할 때 위에서 배운 동적쿼리 방법으로 사용하면 됩니다. 대신 BooleanExpression이 아니라 OrderSpecifier를 사용하면 됩니다.

여기서 바인딩이 안되는 문제가 있었는데 컨트롤러에서 웹의 값을 바인딩 받을 때 안되는 문제는 다음과 같다.

  • 빈 생성자가 있는 경우 Setter가 있어야 값이 설정됩니다.

  • 빈 생성자가 없다면 Setter를 생략하고 생성자 만으로 값을 주입할 수 있습니다.

저는 주로 @Builder 패턴을 사용하는데 setter은 없고 빈 생성자가 있어서 바인딩이 안되었습니다. 그래서 빈 생성자를 없에주고 생성자로 값을 주입했습니다.

여기서 보면 offsetlimit가 있는데 querydsl에는 왜 직접 설정해야 할까?

네이밍쿼리나 JPQL은 JPA를 따르기 때문에 yml에서 설정하면 Hibernate와 같은 JPA 구현체가 쿼리를 자동으로 생성하여 실행하므로 개발자가 직접 offset 및 limit을 설정하지 않아도 됩니다. 그리고 Hibernate는 데이터베이스에 따라서 페이징 쿼리를 적절하게 최적화합니다. 하지만 Querydsl은 JPA를 단순히 확장하는 라이브러리라 별개로 동작하기 때문에 설정에 따르지 않아서 offset과 limit를 설정해야 한다.

import org.springframework.data.domain.Sort.Order;
import com.querydsl.core.types.Order;

이렇게 하면 단점이 있습니다. 여러 컬럼의 정렬을 지원할 순 없습니다.

http://localhost:9090/v2/members?page=1&sort=userName,desc

http://localhost:9090/v2/members?page=1&sort=userName,asc

http://localhost:9090/v2/members?page=1&sort=memberId,asc

http://localhost:9090/v2/members?page=1&sort=memberId,desc

파라미터로 받았을 때 제대로 받아오는 것을 확인할 수 있습니다.
하지만 이거는 Spring Data Jpa 방식입니다.

스프링 데이터 Sort를 Querydsl의 OrderSpecifier로 변환


스프링 데이터 JPA가 제공하는 Querydsl 기능

여기서 소개하는 기능은 제약이 커서 복잡한 실무 환경에서 사용하기에는 많이 부족하다. 그래도 스프링 데이터에서 제공하는 기능이므로 간단히 소개하고 왜 부족한지 설명하겠다.

QuerydslPredicateExecutor

한계점

  • 조인X

    묵시적 조인은 가능하지만 left join이 불가능하다.

  • 클라이언트가 Querydsl에 의존해야 한다. 서비스 클래스가 Querydsl이라는 구현 기술에 의존해야 한다.
  • 복잡한 실무환경에서는 사용하기에는 한계가 명확하다.

Querydsl 지원 클래스 직접 만들기

스프링 데이터가 제공하는 QuerydslRepositorySupport가 지닌 한계를 극복하기 위해 직접 Querydsl 지원 클래스를 만들자.

장점

  • 스프링 데이터가 제공하는 페이징을 편히하게 변환
  • 페이징과 카운트 쿼리 분리 가능
  • 스프링 데이터 Sort 지원
  • select(), selectForm()으로 시작 가능
  • EntityManager, QueryFactory 제공
package com.example.querydsl.repository.support;

import com.querydsl.core.types.EntityPath;
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 com.querydsl.core.types.Expression;


import java.util.List;
import java.util.function.Function;

/**
 * Querydsl 4.x 버전에 맞춘 Querydsl 지원 라이브러리
 *
 * @author Younghan Kim
 * @see
org.springframework.data.jpa.repository.support.QuerydslRepositorySupport
 */
@Repository
public abstract class Querydsl4RepositorySupport {
    // 이 클래스가 다루는 도메인(엔터티)의 클래스
    private final Class domainClass;
    // 도메인 엔터티에 대한 Querydsl 쿼리를 생성하고 실행
    private Querydsl querydsl;
    // 데이터베이스와의 상호 작용을 담당하는 JPA의 핵심 객체
    private EntityManager entityManager;
    // queryFactory를 통해 Querydsl 쿼리를 생성하고 실행합니다.
    private JPAQueryFactory queryFactory;

    public Querydsl4RepositorySupport(Class<?> domainClass) {
        Assert.notNull(domainClass, "Domain class must not be null!");
        this.domainClass = domainClass;
    }

    // Pageable안에 있는 Sort를 사용할 수 있도록 설정한 부분
    @Autowired
    public void setEntityManager(EntityManager entityManager) {
        Assert.notNull(entityManager, "EntityManager must not be null!");
        // JpaEntityInformation을 얻기 위해 JpaEntityInformationSupport를 사용합니다.
        // 이 정보는 JPA 엔터티에 대한 메타데이터 및 정보를 제공합니다.
        JpaEntityInformation entityInformation =
                JpaEntityInformationSupport.getEntityInformation(domainClass, entityManager);
        // 이는 Querydsl에서 엔터티의 경로를 생성하는 데 사용됩니다.
        SimpleEntityPathResolver resolver = SimpleEntityPathResolver.INSTANCE;
        // entityInformation을 기반으로 엔티티의 경로를 생성합니다.
        EntityPath path = resolver.createPath(entityInformation.getJavaType());
        this.entityManager = entityManager;
        // querydsl 객체를 생성합니다.
        // 이 객체는 Querydsl의 핵심 기능을 사용할 수 있도록 도와줍니다.
        // 엔터티의 메타모델 정보를 이용하여 Querydsl의 PathBuilder를 생성하고, 이를 이용하여 Querydsl 객체를 초기화합니다.
        this.querydsl = new Querydsl(entityManager, new
                PathBuilder<>(path.getType(), path.getMetadata()));
        this.queryFactory = new JPAQueryFactory(entityManager);
    }

    // 해당 클래스의 빈(Bean)이 초기화될 때 자동으로 실행되는 메서드
    @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!");
    }
    // 이 팩토리는 JPA 쿼리를 생성하는 데 사용됩니다.
    protected JPAQueryFactory getQueryFactory() {
        return queryFactory;
    }
    // 이 객체는 Querydsl의 핵심 기능을 사용하는 데 도움이 됩니다.
    protected Querydsl getQuerydsl() {
        return querydsl;
    }
    // EntityManager는 JPA 엔터티를 관리하고 JPA 쿼리를 실행하는 데 사용됩니다.
    protected EntityManager getEntityManager() {
        return entityManager;
    }
    // Querydsl을 사용하여 쿼리의 SELECT 절을 생성하는 메서드입니다.
    // expr은 선택할 엔터티나 엔터티의 속성에 대한 표현식입니다.
    protected <T> JPAQuery<T> select(Expression<T> expr) {
        return getQueryFactory().select(expr);
    }
    // Querydsl을 사용하여 쿼리의 FROM 절을 생성하는 메서드입니다.
    // from은 엔터티에 대한 경로 표현식입니다.
    protected <T> JPAQuery<T> selectFrom(EntityPath<T> from) {
        return getQueryFactory().selectFrom(from);
    }

    // 이 메서드는 주어진 contentQuery를 사용하여 Querydsl을 통해 JPA 쿼리를 생성하고 실행하고,
    // 그 결과를 Spring Data의 Page 객체로 변환하는 기능을 제공
    protected <T> Page<T> applyPagination(Pageable pageable,
                                          Function<JPAQueryFactory, JPAQuery> contentQuery) {
        // 1. contentQuery를 사용하여 JPAQuery 객체를 생성
        JPAQuery jpaQuery = contentQuery.apply(getQueryFactory());
        // 2. Querydsl을 사용하여 페이징 및 정렬된 결과를 가져옴
        List<T> content = getQuerydsl().applyPagination(pageable,
                jpaQuery).fetch();
        // 3. contentQuery를 다시 사용하여 countQuery를 생성
        JPAQuery<Long> countQuery = contentQuery.apply(getQueryFactory());
        // 4. countQuery를 실행하고 총 레코드 수를 얻음
        long total = countQuery.fetchOne();
        // 5. content와 pageable 정보를 사용하여 Spring Data의 Page 객체를 생성하고 반환
        return PageableExecutionUtils.getPage(content, pageable,
                () -> total);
    }
    // 이 메서드는 contentQuery와 함께 countQuery를 인자로 받아서 사용합니다.
    // contentQuery를 사용하여 페이징된 결과를 가져오고, countQuery를 사용하여 전체 레코드 수를 얻습니다.
    
    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<Long> countResult = countQuery.apply(getQueryFactory());
        Long total = countResult.fetchOne();
        return PageableExecutionUtils.getPage(content, pageable,
                () -> total);
    }
}
  • domainClass: 이 클래스가 다루는 도메인(엔터티)의 클래스를 나타냅니다.

  • querydsl: Querydsl의 핵심 객체로, 도메인 엔터티에 대한 Querydsl 쿼리를 생성하고 실행하는 데 사용됩니다.

  • entityManager: JPA의 EntityManager를 나타냅니다. 데이터베이스와의 상호 작용을 담당하는 JPA의 핵심 객체입니다.

  • queryFactory: Querydsl에서 쿼리를 생성하는 데 사용되는 JPAQueryFactory입니다. queryFactory를 통해 Querydsl 쿼리를 생성하고 실행합니다.

  • setEntityManager(EntityManager entityManager) : EntityManager를 주입받아 해당 클래스의 필드에 설정하는 역할을 합니다. Pageable안에 있는 Sort를 사용할 수 있도록 설정한 것

    • Assert.notNull(entityManager, "EntityManager must not be null!");: 주어진 entityManager이 null이 아닌지 확인합니다.

    • JpaEntityInformation entityInformation = JpaEntityInformationSupport.getEntityInformation(domainClass, entityManager);: JpaEntityInformation을 얻기 위해 JpaEntityInformationSupport를 사용합니다. 이 정보는 JPA 엔터티에 대한 메타데이터 및 정보를 제공합니다.

    • SimpleEntityPathResolver resolver = SimpleEntityPathResolver.INSTANCE;: SimpleEntityPathResolver를 생성합니다. 이는 Querydsl에서 엔터티의 경로를 생성하는 데 사용됩니다.

    • EntityPath path = resolver.createPath(entityInformation.getJavaType());: 주어진 entityInformation을 기반으로 엔터티의 경로를 생성합니다.

    • this.entityManager = entityManager;: 주입받은 entityManager을 클래스의 필드에 설정합니다.

    • this.querydsl = new Querydsl(entityManager, new PathBuilder<>(path.getType(), path.getMetadata()));: Querydsl 객체를 생성합니다. 이 객체는 Querydsl의 핵심 기능을 사용할 수 있도록 도와줍니다. 엔터티의 메타모델 정보를 이용하여 Querydsl의 PathBuilder를 생성하고, 이를 이용하여 Querydsl 객체를 초기화합니다.

    • this.queryFactory = new JPAQueryFactory(entityManager);: JPAQueryFactory를 생성합니다. 이 객체는 JPA 쿼리를 생성하는 데 사용됩니다.

  • validate() : @PostConstruct 어노테이션이 붙은 메서드로, 해당 클래스의 빈(Bean)이 초기화될 때 자동으로 실행되는 메서드입니다.

    메서드 내용은 주어진 조건에 따라 필드들이 null인지 여부를 확인하고, 만약 null이라면 예외를 발생시키는 것입니다.

  • protected JPAQueryFactory getQueryFactory(): 이 팩토리는 JPA 쿼리를 생성하는 데 사용됩니다.

  • protected Querydsl getQuerydsl(): Querydsl 객체를 반환하는 메서드입니다. 이 객체는 Querydsl의 핵심 기능을 사용하는 데 도움이 됩니다.

  • protected EntityManager getEntityManager(): EntityManager 객체를 반환하는 메서드입니다. EntityManager는 JPA 엔터티를 관리하고 JPA 쿼리를 실행하는 데 사용됩니다.

  • protected <T> JPAQuery<T> select(Expression<T> expr): Querydsl을 사용하여 쿼리의 SELECT 절을 생성하는 메서드입니다. expr은 선택할 엔터티나 엔터티의 속성에 대한 표현식입니다.

  • protected <T> JPAQuery<T> selectFrom(EntityPath<T> from): Querydsl을 사용하여 쿼리의 FROM 절을 생성하는 메서드입니다. from은 엔터티에 대한 경로 표현식입니다.

  • applyPagination(Pageable pageable, Function<JPAQueryFactory, JPAQuery> contentQuery) : 이 메서드는 주어진 contentQuery를 사용하여 Querydsl을 통해 JPA 쿼리를 생성하고 실행하고, 그 결과를 Spring Data의 Page 객체로 변환하는 기능을 제공합니다.

@Repository
public class MemberTestRepository extends Querydsl4RepositorySupport {
    public MemberTestRepository() {
        super(Member.class);
    }
    
     JPAQuery<Member> query = selectFrom(member)
                .leftJoin(member.team, team)
                .where(userNameEq(condition.getUserName()),
                        teamNameEq(condition.getTeamName()),
                        ageGoe(condition.getAgeGoe()),
                        ageLoe(condition.getAgeLoe()));

        // count 쿼리 (조건에 부합하는 로우의 총 개수를 얻는 것이기 때문에 페이징 미적용)
        JPAQuery<Long> countQuery =
                // SQL 상으로는 count(member.id)와 동일
                select(member.count())
                        .from(member)
                        .leftJoin(member.team, team)
                        .where(userNameEq(condition.getUserName()),
                                teamNameEq(condition.getTeamName()),
                                ageGoe(condition.getAgeGoe()),
                                ageLoe(condition.getAgeLoe()));

        // 페이징이랑 sort 지원
        List<Member> content = getQuerydsl().applyPagination(pageable, query).fetch();
        return PageableExecutionUtils.getPage(content, pageable, countQuery::fetchOne);
    }
      // count처리 까지 한것
    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
                .select(member.id)
                .from(member)
                .where(userNameEq(condition.getUserName()),
                        teamNameEq(condition.getTeamName()),
                        ageGoe(condition.getAgeGoe()),
                        ageLoe(condition.getAgeLoe()))
        );
    }

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

여기서 보면 Querydsl4RepositorySupport를 사용해서

 // 페이징이랑 sort 지원
 List<Member> content = getQuerydsl().applyPagination(pageable, query).fetch();

반환값이 List인 것을 볼 수 있는데 페이징 및 정렬이 적용된 결과를 List로 가져오기 때문에, 이를 Spring Data JPA의 Page로 변환하기 위해 PageableExecutionUtils.getPage를 사용합니다.

그리고

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
                .select(member.count())
                .from(member)
                .where(userNameEq(condition.getUserName()),
                        teamNameEq(condition.getTeamName()),
                        ageGoe(condition.getAgeGoe()),
                        ageLoe(condition.getAgeLoe()))
        );

이거를 사용하면 contentQuery를 사용하여 페이징된 결과를 가져오고, countQuery를 사용하여 전체 레코드 수를 얻습니다.

package com.example.querydsl.service;

import com.example.querydsl.domain.MemberSearchCondition;
import com.example.querydsl.domain.MemberTeamDTO;
import com.example.querydsl.entity.Member;
import com.example.querydsl.repository.MemberTestRepository;
import com.querydsl.core.types.Order;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Service;


@Service
@RequiredArgsConstructor
public class MemberService {
    private final MemberTestRepository memberTestRepository;

    public Page<MemberTeamDTO> search(MemberSearchCondition condition, Pageable pageable) {

        Sort sort = pageable.getSort();
        PageRequest pageRequest = PageRequest.of(
                (int) pageable.getOffset(),
                pageable.getPageSize(),
                sort
        );

        Page<Member> resultPage = memberTestRepository.applyPagination2(condition, pageRequest);
        return resultPage.map(member -> MemberTeamDTO.builder()
                .memberId(member.getId())
                .age(member.getAge())
                .userName(member.getUserName())
                .teamId(member.getTeam().getId())
                .teamName(member.getTeam().getName())
                .build());
    }

    public Page<MemberTeamDTO> search2(MemberSearchCondition condition, Pageable pageable) {
        Sort sort = pageable.getSort();
        PageRequest pageRequest = PageRequest.of(
                (int) pageable.getOffset(),
                pageable.getPageSize(),
                sort
        );
        Page<Member> members = memberTestRepository.searchPageByApplyPage(condition, pageable);
        return members.map(member -> MemberTeamDTO.builder()
                .memberId(member.getId())
                .age(member.getAge())
                .userName(member.getUserName())
                .teamId(member.getTeam().getId())
                .teamName(member.getTeam().getName())
                .build());
    }
}

Sort 조건을 pageable에 포함해서 던지면 해당 조건이 함께 적용됩니다. 별도로 orderBy에 적용하지 않아도 됩니다.

Querydsl4RepositorySupport Pageable안에 있는 Sort를 사용할 수 있도록 설정한 부분

이게 가장 먼저 동작할 수 있는 이유는 여기서 보면 @Autowired를 한 것을 볼 수 있는데 스프링은 애플리케이션 컨텍스트(ApplicationContext)를 구성하는 동안 빈(Bean)을 로드하고 초기화하는 라이프사이클을 가집니다. @Autowired 어노테이션이 붙은 메서드는 해당 빈이 생성될 때 자동으로 호출됩니다.

setEntityManager() 메서드는 EntityManager를 의존성 주입(Dependency Injection)을 통해 주입받습니다. @Autowired 어노테이션이 붙은 메서드에 의해 해당 빈이 생성될 때 EntityManager 객체가 주입되고, 메서드가 호출됩니다.

스프링 컨테이너는 애플리케이션을 시작할 때 빈을 로드하고, 의존성 주입을 통해 필요한 빈을 설정합니다. @Autowired 어노테이션이 붙은 메서드는 빈을 생성할 때 자동으로 호출되므로, 빈이 생성될 때 맨 처음 호출되는 메서드 중 하나입니다.

따라서, setEntityManager() 메서드는 스프링 컨테이너가 빈을 생성하고 초기화하는 과정에서 빈이 생성될 때 호출되는 메서드로, @Autowired 어노테이션에 의해 해당 빈이 주입받는 EntityManager 객체를 설정해주는 역할을 하므로 다음과 같이 동작합니다.

이 메소드가 동작하고 querydslqueryFactory에 필드에 넣어주고

    protected JPAQueryFactory getJpaQueryFactory() {
        return jpaQueryFactory;
    }
    // 이 객체는 Querydsl의 핵심 기능을 사용하는 데 도움이 됩니다.
    protected Querydsl getQuerydsl() {
        return querydsl;
    }

메소드에 리턴해주고 있습니다. 그것을

밑줄친 부분에서 사용하고 있기 때문에 정렬을 해당 엔티티의 아무 필드나 오름차순(asc), 내림차순(desc)으로 동적으로 정렬할 수 있습니다.

엔티티가 여러개가 있는 경우 예를들어, 장바구니는 상품과 연관이 있지만 유저와 연관이 없고 상품은 유저와 연관이 있을 때

상품은 연관이 있으니 페치조인 그리고 유저는 장바구니와 직접적인 연관이 없으니 외부조인(LeftJoin)을 하면 됩니다. 그리고 외부 조인에서 on을 하면 그 조건에 해당되는 정보만을 가져오고 보여주는 것은 where의 조건으로 보여줍니다.

    @GetMapping("/v3/members")
   public ResponseEntity<?> searchMemberV3(MemberSearchCondition condition,
                                           Pageable pageable) {
       Page<MemberTeamDTO> search = memberService.search(condition, pageable);
       return ResponseEntity.ok().body(search);
   }

   @GetMapping("/v4/members")
   public ResponseEntity<?> searchMemberV4(MemberSearchCondition condition,
                                           Pageable pageable) {
       Page<MemberTeamDTO> search2 = memberService.search2(condition, pageable);
       return ResponseEntity.ok().body(search2);
   }

이제 프론트가 http://localhost:8080/api/v1/items/search?name=당근&endP=50000&page=1&sort=itemId,asc 이런식으로 보내면 페이징과 정렬처리가 됩니다.

주의

sort=?,asc에서 ?는 무엇을 기준으로 정렬할지 인데 이거는 엔티티에 있는 이름을 그대로 적어줘야 찾을 수 있습니다.

현재
엔티티에서 id니 위에서 ?부분을 id로 처리한 것입니다.

여기서 보면 Pageable에 정렬할거와 어떻게 정렬할지 보내준다면 applyPagination에 pageable을 그냥 넘겨서 처리하고 있습니다.

profile
발전하기 위한 공부

0개의 댓글