최신 스프링 부트 3.x는 Querydsl 5.0을 사용.
그렇기에 스프링부트 3.x 버전 이상에서 사용 시 아래 부분 확인해야 함
PageableExecutionUtils
의 패키지가 Deprecated → 향후 미지원. → 패키지 변경만 하면 기존 기능 그대로 가능 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')
}
클래스 사용 패키지 변경
→ 기능이 Deprecated된 것은 아니고, 사용 패키지 위치가 변경되었다. 기존 위치를 신규 위치로 변경하면 문제 없이 사용 가능.
기존: org.springframework.data.repository.support.PageableExecutionUtils
신규: org.springframework.data.support.PageableExecutionUtils
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);
}
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);
}
JPQL 대신 Querydsl을 이용하는 이유 중 하나가 type-safe(컴파일 시점에 알 수 있는) 쿼리를 날리기 위해서 사용한다.
이 말은 JPQL에서 쿼리에 오타가 발생해도 컴파일 시점에 알기 힘들다. 오로지 런타임에서만 체크가 가능하다. 하지만 Querydsl은 컴파일 시점에 오류를 잡아줄 수 있기 때문에 좋다.
현재는 총 3가지의 방법으로 querydsl을 구현 가능하다.
3번이 best practice인 이유는 상속을 매번 받는 것도 불편하고 실질적으로 작업하는 JpaQueryFactory만 존재하면 되기 때문이다.
물론 하나의 레포지토리로 통합 관리를 하고 싶으면 1번 방법을 사용하면 된다.
DTO를 반환하는 방법이 크게 3가지가 있다.
생성자 + @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());
}
}
실행시에 쿼리 문장이 만들어져 실행되는 쿼리문을 동적 쿼리라고 하는데, 동적으로 변수를 받아서 쿼리가 완성되는 걸 말한다.
Predicate
를 파라미터로 이용하는 방법BooleanBuilder
를 이용하는 방법BooleanExpression
을 쓰는 방법 (Where 다중 파라미터 사용 )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);
}
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));
}
@Configuration
@RequiredArgsConstructor
public class QuerydslConfig {
private final EntityManager entityManager;
@Bean
public JPAQueryFactory jpaQueryFactory(){
return new JPAQueryFactory(entityManager);
}
}
엔티티 클래스
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;
}
Repository 클래스
MemberRepositoryCustom 클래스에서 JPAQueryFactory를 주입받아 사용
@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();
}
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();
}