본문 바로가기
Spring

Querydsl 관련정보

by 리승우 2022. 11. 21.

모를 때마다 꼭, 다시 한번씩 복기하면서 이해하자.

자주 사용해야 머리에 들어오는 법이다.

 

 

Querydsl vs JPQL

@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();
 assertThat(findMember.getUsername()).isEqualTo("member1");
}
@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();
 assertThat(findMember.getUsername()).isEqualTo("member1");
}

1. EntityManager 로 JPAQueryFactory 생성

2. Querydsl은 JPQL 빌더 JPQL: 문자(실행 시점 오류), Querydsl: 코드(컴파일 시점 오류)

3. JPQL: 파라미터 바인딩 직접, Querydsl: 파라미터 바인딩 자동 처리

 

기본 Q-Type 활용

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

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

기본 인스턴스를 static import와 함께 사용

import static 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");
}

다음 설정을 추가하면 실행되는 JPQL을 볼 수 있다.

 

spring.jpa.properties.hibernate.use_sql_comments: true

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

 

검색 조건 쿼리

기본 검색 쿼리

@Test
public void search() {
 Member findMember = queryFactory
 .selectFrom(member)
 .where(member.username.eq("member1")
 .and(member.age.eq(10)))
 .fetchOne();
 assertThat(findMember.getUsername()).isEqualTo("member1");
}

검색 조건은 .and() , . or() 를 메서드 체인으로 연결할 수 있다.

> 참고: select , from 을 selectFrom 으로 합칠 수 있음

 

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

member.username.eq("member1") // username = 'member1'
member.username.ne("member1") //username != 'member1'
member.username.eq("member1").not() // username != 'member1'
member.username.isNotNull() //이름이 is not null
member.age.in(10, 20) // age in (10,20)
member.age.notIn(10, 20) // age not in (10, 20)
member.age.between(10,30) //between 10, 30
member.age.goe(30) // age >= 30
member.age.gt(30) // age > 30
member.age.loe(30) // age <= 30
member.age.lt(30) // age < 30
member.username.like("member%") //like 검색
member.username.contains("member") // like ‘%member%’ 검색
member.username.startsWith("member") //like ‘member%’ 검색

 

AND 조건을 파라미터로 처리

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

where() 에 파라미터로 검색조건을 추가하면 AND 조건이 추가됨

이 경우 null 값은 무시 메서드 추출을 활용해서 동적 쿼리를 깔끔하게 만들 수 있음

 

결과 조회

- fetch() : 리스트 조회, 데이터 없으면 빈 리스트 반환

- fetchOne() : 단 건 조회 결과가 없으면 : null

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

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

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

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

 

페이징

조회 건수 제한

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

전체 조회 수가 필요하면?

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

> 주의: count 쿼리가 실행되니 성능상 주의!

참고: 실무에서 페이징 쿼리를 작성할 때, 데이터를 조회하는 쿼리는 여러 테이블을 조인해야 하지만, count 쿼리는 조인이 필요 없는 경우도 있다. 그런데 이렇게 자동화된 count 쿼리는 원본 쿼리와 같이 모두 조인을 해버리기 때문에 성능이 안나올 수 있다. count 쿼리에 조인이 필요없는 성능 최적화가 필요하다면, count 전용 쿼리를 별도로 작성해야 한다

 

프로젝션과 결과 반환 - DTO

조회 순수 JPA에서 DTO 조회

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

순수 JPA에서 DTO 조회 코드

List<MemberDto> result = em.createQuery(
 "select new study.querydsl.dto.MemberDto(m.username, m.age) " +
 "from Member m", MemberDto.class)
 .getResultList();

 

Querydsl 빈 생성(Bean population)

결과를 DTO 반환할 때 사용

다음 3가지 방법 지원

1. 프로퍼티 접근

2. 필드 직접 접근

3. 생성자 사용

 

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

생성자 + @QueryProjection

package study.querydsl.dto;
import com.querydsl.core.annotations.QueryProjection;
import lombok.Data;
@Data
public class MemberDto {
 private String username;
 private int age;
 public MemberDto() {
 }
 @QueryProjection
 public MemberDto(String username, int age) {
 this.username = username;
 this.age = age;
 }
}

 

@QueryProjection 활용

List<MemberDto> result = queryFactory
 .select(new QMemberDto(member.username, member.age))
 .from(member)
 .fetch();

방법은 컴파일러로 타입을 체크할 수 있으므로 가장 안전한 방법이다. 다만 DTO에 QueryDSL 어노테이션을 유지해야 하는 점과 DTO까지 Q 파일을 생성해야 하는 단점이 있다.

 

동적 쿼리 - BooleanBuilder 사용

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

1. BooleanBuilder

2. Where 다중 파라미터 사용

 

개인적으로 where 다중 파라미터가 내게 맞아 아래내용만 기술하였다.

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

@Test
public void 동적쿼리_WhereParam() throws Exception {
 String usernameParam = "member1";
 Integer ageParam = 10;
 List<Member> result = searchMember2(usernameParam, ageParam);
 Assertions.assertThat(result.size()).isEqualTo(1);
}
private List<Member> searchMember2(String usernameCond, Integer ageCond) {
 return queryFactory
 .selectFrom(member)
 .where(usernameEq(usernameCond), ageEq(ageCond))
 .fetch();
}
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;
}

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

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

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

 

조합 가능

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

null 체크는 주의해서 처리해야함

 

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

Where절에 파라미터를 사용한 예제

 

//회원명, 팀명, 나이(ageGoe, ageLoe)
public List<MemberTeamDto> search(MemberSearchCondition condition) {
 return 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()))
 .fetch();
}

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

 

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

스프링 데이터의 Page, Pageable을 활용해보자.

1. 전체 카운트를 한번에 조회하는 단순한 방법

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

 

전체 카운트를 한번에 조회하는 단순한 방법

searchPageSimple(), fetchResults() 사용

/**
 * 단순한 페이징, fetchResults() 사용
 */
@Override
public Page<MemberTeamDto> searchPageSimple(MemberSearchCondition condition,
Pageable pageable) {
 QueryResults<MemberTeamDto> results = 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())
 .fetchResults();
 List<MemberTeamDto> content = results.getResults();
 long total = results.getTotal();
 return new PageImpl<>(content, pageable, total);
}

Querydsl이 제공하는 fetchResults() 를 사용하면 내용과 전체 카운트를 한번에 조회할 수 있다.(실제 쿼리는 2번 호출) fetchResult() 는 카운트 쿼리 실행시 필요없는 order by 는 제거한다.

 

 

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

searchPageComplex()

/**
 * 복잡한 페이징
 * 데이터 조회 쿼리와, 전체 카운트 쿼리를 분리
 */
@Override
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();
         
 long total = queryFactory
 .select(member)
 .from(member)
 .leftJoin(member.team, team)
 .where(usernameEq(condition.getUsername()),
         teamNameEq(condition.getTeamName()),
         ageGoe(condition.getAgeGoe()),
         ageLoe(condition.getAgeLoe()))
         .fetchCount();
         
 return new PageImpl<>(content, pageable, total);
}

 

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

PageableExecutionUtils.getPage()로 최적화

JPAQuery<Member> countQuery = queryFactory
 	.select(member)
 		.from(member)
 		.leftJoin(member.team, team)
 		.where(usernameEq(condition.getUsername()),
 		teamNameEq(condition.getTeamName()),
 		ageGoe(condition.getAgeGoe()),
 		ageLoe(condition.getAgeLoe()));
        
// return new PageImpl<>(content, pageable, total);

	 return PageableExecutionUtils.getPage(content, pageable,
countQuery::fetchCount);

스프링 데이터 라이브러리가 제공

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

1. 페이지 시작이면서 컨텐츠 사이즈가 페이지 사이즈보다 작을 때

2. 마지막 페이지 일 때 (offset + 컨텐츠 사이즈를 더해서 전체 사이즈 구함)

 

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

실제 컨트롤러

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

댓글