Querydsl 제대로 사용하기

Chooooo·2024년 6월 25일
0

TIL

목록 보기
24/28
post-custom-banner

스프링부트 3.x에서 Querydsl 5.0 사용

최신 스프링 부트 3.x는 Querydsl 5.0을 사용.

그렇기에 스프링부트 3.x 버전 이상에서 사용 시 아래 부분 확인해야 함

  1. build.gradle 설정 변경
  2. PageableExecutionUtils 의 패키지가 Deprecated → 향후 미지원. → 패키지 변경만 하면 기존 기능 그대로 가능
  3. Querydsl fetchResults(), fetchCount() Deprecated → 향후 미지원

build.gradle 설정 방법

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

group = 'study'
version = '0.0.1-SNAPSHOT'
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'
   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')
}

PageableExecutionUtils Deprecated (패키지 향후 미지원)

클래스 사용 패키지 변경

→ 기능이 Deprecated된 것은 아니고, 사용 패키지 위치가 변경되었다. 기존 위치를 신규 위치로 변경하면 문제 없이 사용 가능.

기존: org.springframework.data.repository.support.PageableExecutionUtils 신규: org.springframework.data.support.PageableExecutionUtils

Querydsl fetchResults(), fetchCount() Deprecated

Querydsl의 fetchCount(), fetchResults()는 개발자가 작성한 select 쿼리를 기반으로 count용 쿼리를 내부에서 만들어서 실행한다.

그런데 이 기능은 select 구문을 단순히 count 처리하는 용도로 바꾸는 정도. 따라서 단순한 쿼리에서는 잘 동작하지만, 복잡한 쿼리에서는 제대로 동작하지 않는다.

따라서 count 쿼리가 필요하면 별도로 작성해야 한다.

count 쿼리 예제

@Test
 public void count() {
     Long totalCount = queryFactory
             //.select(Wildcard.count) //select count(*)
             .select(member.count()) //select count(member.id)
             .from(member)
             .fetchOne();
     System.out.println("totalCount = " + totalCount);
 }
  • count(*)를 사용하고 싶으면 예제의 주석처럼 Wildcard.count를 사용하면 된다.
  • member.count()를 사용하면 count(member.id)로 처리된다.
  • 응답 결과는 숫자 하나이므로 → fetchOne()을 사용한다.

select 쿼리와는 별도로 count 쿼리를 작성하고 fetch()를 사용.

→ 최신 버전에 맞춰 수정한 코드는 아래와 같다.

수정된 searchPageComplex 예제

import org.springframework.data.support.PageableExecutionUtils; //패키지 변경

 public Page<MemberTeamDto> searchPageComplex(MemberSearchCondition condition,
 Pageable pageable) {
     List<MemberTeamDto> content = queryFactory
             .select(new QMemberTeamDto(
                     member.id,
                     member.username,
                     member.age,
                     team.id,
                     team.name))
            .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();
    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);
}

Querydsl 제대로 사용하기

JPQL 대신 Querydsl을 이용하는 이유 중 하나가 type-safe(컴파일 시점에 알 수 있는) 쿼리를 날리기 위해서 사용한다.

이 말은 JPQL에서 쿼리에 오타가 발생해도 컴파일 시점에 알기 힘들다. 오로지 런타임에서만 체크가 가능하다. 하지만 Querydsl은 컴파일 시점에 오류를 잡아줄 수 있기 때문에 좋다.

Querydsl 사용 방법

현재는 총 3가지의 방법으로 querydsl을 구현 가능하다.

  1. CustomRepository 상속
  2. QuerydslRepositorySupport 상속
  3. JpaQueryFactory 주입해서 단독 사용 (추천)

3번이 best practice인 이유는 상속을 매번 받는 것도 불편하고 실질적으로 작업하는 JpaQueryFactory만 존재하면 되기 때문이다.

물론 하나의 레포지토리로 통합 관리를 하고 싶으면 1번 방법을 사용하면 된다.

Querydsl을 이용한 빈 생성

DTO를 반환하는 방법이 크게 3가지가 있다.

  • 프로퍼티로 접근하는 방식 (Setter 사용)
  • 필드 직접 접근
  • 생성자를 사용

Projection과 결과 반환 - @QueryProjection

생성자 + @QueryProjection

프로젝션을 이용한 방법 중에 가장 깔끔한 방법일 수 있다.

@QueryProjection을 이용해 DTO도 Q타입의 클래스를 만들어서 이를 이용해 바로 만드는 방법이다.

Q타입의 클래스를 제공해주니 type-safe하다는 장점이 있다.

@QueryProjection을 이용해 생성하는 방법

@Test
void projectionWithJpa(){
    //given

    //when
    List<MemberDto> result = em.createQuery(
            "select new com.study.querydsl.dto.MemberDto(m.username, m.age)" +
                    "from Member m", MemberDto.class
            )
            .getResultList();
    //then
    for (MemberDto memberDto : result){
        System.out.println(memberDto.toString());
    }
}
@Test
void findDtoByQueryProjection(){
    //given

    //when
    List<MemberDto> result = queryFactory
            .select(new QMemberDto(member.username, member.age))
            .from(member)
            .fetch();
    //then
    for (MemberDto memberDto : result) {
        System.out.println(memberDto.toString());
    }
}
  • 다만 이 방식의 문제점은 Querydsl에 대한 의존성을 가지게 된다는 점(DTO에 QueryDSL 어노테이션을 유지해야 하는 점과 DTO까지 Q파일을 생성해야 하는 단점). 라이브러리를 바꾸게 되면 고쳐야할 DTO가 많아진다는 단점이 있다.

Best Practice - 동적 쿼리

실행시에 쿼리 문장이 만들어져 실행되는 쿼리문을 동적 쿼리라고 하는데, 동적으로 변수를 받아서 쿼리가 완성되는 걸 말한다.

  1. Predicate를 파라미터로 이용하는 방법
  2. BooleanBuilder를 이용하는 방법
  3. Predicate를 상속받은 BooleanExpression을 쓰는 방법 (Where 다중 파라미터 사용 )
    1. 이 방법이 더 코드가 깔끔하게 나온다. 실무에서 좀 더 사용하기에 좋다.

where 파라미터 이용 예제 (BooleanExpression)

@Test
void dynamicQueryUsingWhereParameter(){
    //given
    String usernameParam = "member1";
    Integer ageParam = 10;
    //when
    List<Member> result = searchMember2(usernameParam, ageParam);
    //then
    assertEquals(1, result.size());
}

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() 메서드가 null을 리턴하게 되면 Where()에 null값이 들어가게 되는데 이는 무시가 된다. 그러므로 동적 쿼리가 될 수 있다.
  • BooleanBuilder를 보는 것보다 Where 절에 적절한 메서드를 넣음으로써 가독성을 높일 수 있다. BooleanBuilder는 객체를 또 봐야 한다.

where 파라미터 조립 예제

public List<MemberTeamDto> searchByWhere(MemberSearchCondition condition){
   return queryFactory
           .select(new QMemberTeamDto(
                   member.id.as("memberId"),
                   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;
}
private BooleanExpression ageBetween(Integer ageLoe, Integer ageGoe) {
   return ageLoe(ageLoe).and(ageGoe(ageGoe));
}
  • 조건 조립을 통해서 추상화를 적절히 할 수 있다는 장점과 재사용성이 높다는 장점이 있다.
  • 메서드는 많아졌지만, 해당 조건문이 어떤 쿼리문을 만들지 직관적이게 되었다.
  • 그리고 BooleanExpression은 null을 반환하게 되면 Where절에서 조건이 무시되고 안전하다.

결론

  1. 독립된 QuerydslRepository를 만들자.
  2. 동적 쿼리문을 짤 때 BooleanExpression을 사용하여 안전하게 where문을 넣자.

전체 코드 예시

Querydsl 설정 파일

@Configuration
@RequiredArgsConstructor
public class QuerydslConfig {
    private final EntityManager entityManager;

    @Bean
    public JPAQueryFactory jpaQueryFactory(){
        return new JPAQueryFactory(entityManager);
    }
}

예제 도메인 및 Dto

엔티티 클래스

Member, Team 엔티티 클래스 생성

Member

@Entity
@Getter @Setter
@NoArgsConstructor
public class Member {

    @Id @GeneratedValue
    private Long id;

    private String username;

    private int age;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "team_id")
    private Team team;
}

Team

@Entity
@Getter @Setter
@NoArgsConstructor
public class Team {

    @Id @GeneratedValue
    private Long id;

    private String name;
}

DTO 클래스

@Getter @Setter
public class MemberSearchCondition {
    private String username;
    private String teamName;
    private Integer ageGoe;
    private Integer ageLoe;
}

Querydsl 사용 예제

Repository 클래스

MemberRepositoryCustom 클래스에서 JPAQueryFactory를 주입받아 사용

  • 아래와 같이 하나의 querydsRepository를 통해서 짜자.
@Repository
@RequiredArgsConstructor
public class MemberRepositoryCustom {

    private final JPAQueryFactory queryFactory;

    public List<Member> findMembersByConditions(MemberSearchCondition condition) {
        QMember member = QMember.member;
        QTeam team = QTeam.team;

        return queryFactory
                .selectFrom(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 StringUtils.hasText(username) ? QMember.member.username.eq(username) : null;
    }

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

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

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

자바에서 null 체크를 할 때 메서드 내부에서 null 체크를 수행한다. → 내부에서 체크해주고 원하는 조건 걸면된다. BooleanExpression의 null 처리는 Querydsl에서 where 메서드는 null을 허용한다.

@QueryProjection을 사용해서 짜는 코드 예시

// TODO : 구(지역) 및 동(법정동) 전월세 정보 조회
    public List<AptRentSaleDTO> findRentSalesByConditions(SearchConditionDTO searchCondition) {
//        System.out.println("searchCondition: " + searchCondition);
        return queryFactory
                .select(new QAptRentSaleDTO(
                        aptRentSale.constructionYear, aptRentSale.contractType, aptRentSale.contractPeriod,
                        aptRentSale.year, aptRentSale.legalDong, aptRentSale.depositAmount, aptRentSale.apartmentName,
                        aptRentSale.month, aptRentSale.monthlyRent, aptRentSale.day, aptRentSale.exclusiveArea,
                        aptRentSale.previousContractDeposit, aptRentSale.previousContractRent, aptRentSale.regionCode,
                        aptRentSale.floor
                ))
                .from(aptRentSale)
                .where(buildBooleanExpression(searchCondition), inExclusiveAreaRanges(searchCondition.getSelectedPyeongRanges()))
//                .where(eqRegionCode(searchCondition.getRegionCode()), eqLegalDong(searchCondition.getLegalDong()))
                .offset(searchCondition.getOffset())
                .limit(searchCondition.getLimit())
                .fetch();
    }
  • 만약 select 내에서 가독성이 떨어진다면, Q타입 생성자를 미리 생성하여 삽입해주자.
public List<AptRentSaleDTO> findRentSalesByConditions(SearchConditionDTO searchCondition) {
    // Q타입 생성자를 미리 생성
    QAptRentSaleDTO qAptRentSaleDTO = new QAptRentSaleDTO(
        aptRentSale.constructionYear, aptRentSale.contractType, aptRentSale.contractPeriod,
        aptRentSale.year, aptRentSale.legalDong, aptRentSale.depositAmount, aptRentSale.apartmentName,
        aptRentSale.month, aptRentSale.monthlyRent, aptRentSale.day, aptRentSale.exclusiveArea,
        aptRentSale.previousContractDeposit, aptRentSale.previousContractRent, aptRentSale.regionCode,
        aptRentSale.floor
    );

    return queryFactory
            .select(qAptRentSaleDTO)
            .from(aptRentSale)
            .where(buildBooleanExpression(searchCondition), inExclusiveAreaRanges(searchCondition.getSelectedPyeongRanges()))
            .offset(searchCondition.getOffset())
            .limit(searchCondition.getLimit())
            .fetch();
}
profile
back-end, 지속 성장 가능한 개발자를 향하여
post-custom-banner

0개의 댓글