🤩 QueryDSL이 뭔지 모르시다면 !
2024.11.15 - [TIL] - JPA - QueryDSL
🤜 요구사항
일정을 검색하는 기능
검색 조건
- 일정 제목으로 검색할 수 있으며 부분적으로 일치해도 검색 가능
- 일정의 생성일 범위로 검색할 수 있다.
- 일정 담당자의 닉네임으로 검색 가능하며 부분적으로 일치해도 검색 가능
- 일정 제목, 해당 일정의 담당자 수, 해당 일정의 총 댓글 갯수를 페이징 처리되어 반환되도록한다.
🤗 QueryDSL의 동적쿼리 방법
QueryDSL의 동적쿼리 방법으로는 Boolean Builder 방법과 BooleanExpression 방식으로 나뉘는데 이번 검색 기능을 구현하며BooleanExpression을 이용하여 구현하였기에 Boolean Builder 방법은 간단하게 사용법만 작성하고 넘어가려고합니다.
1. BooleanBuilder
BooleanBuilder를 추가하여 파라미터의 상태에 따라 where절을 builder를 삽입해주는 방식이다.
이 방식은 로직을 따라가면서 봐야 쿼리문을 이해할 수 있으며, 어떤 쿼리가 나가는지 예측하기가 힘들다는 단점이 있다.
Public List<Team> searchTeams(SearchTeamFilterRequest searchTeamFilterRequest){
BooleanBuilder builder = new BooleanBuilder();
if(!CoolectionUtils.isEmpty(searchTeamFilterRequest.getTeamId())){
builder.and(team.Id.eq(searchTeamFilterRequest.getTeamId));
}
if(!CoolectionUtils.isEmpty(searchTeamFilterRequest.getTeamName())){
builder.and(team.name.contains(searchTeamFilterRequest.getTeamName()));
}
if(){
...
}
...
return queryFactory
.selectFrom(team)
.where(builder)
.fetch();
}
2. BooleanExpression
QueryDSL은 아래 2가지 기능을 제공한다.
- where()에 null이 들어오면 무시한다.
- where()에 ,(콤마)를 and 조건으로 사용한다.
두 가지 기능을 사용하여 BooleanExpression을 사용해봤다.
요구사항에서 Todo전체 정보가아닌 특정 필드들만 반환을 요구했기 때문에 ResponseDto를 새로 만들었다.
ResponseDto
@Getter
// 기본생성자는 아래에서 사용한 Projections.fields를 이용하기 위하여 달아주었다.
// Projections.fields을 사용할 때 기본 생성자를 생성해주지않으면 에러가 발생한다.
@NoArgsConstructor
public class TodoQueryDslSearchResponse {
private String title;
private Long mangerCnt;
private Long commentCnt;
public TodoQueryDslSearchResponse(String title, Long mangerCnt, Long commentCnt){
this.title = title;
this.mangerCnt = mangerCnt;
this.commentCnt = commentCnt;
}
}
Repository 전체 코드
public Page<TodoQueryDslSearchResponse> searchTodo(
String keyword,
LocalDateTime startDate,
LocalDateTime endDate,
String nickName,
Pageable pageable) {
List<TodoQueryDslSearchResponse> list = jpaQueryFactory.select(
Projections.fields(
TodoQueryDslSearchResponse.class,
todo.title,
manager.id.countDistinct().as("mangerCnt"),
comment.id.countDistinct().as("commentCnt")
))
.from(todo)
.leftJoin(todo.managers, manager)
.leftJoin(todo.comments, comment)
.where(keywordEq(keyword), dateEq(startDate, endDate), nickNameEq(nickName))
.orderBy(todo.createdAt.desc())
.groupBy(todo.id)
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();
JPAQuery<Long> count = getCnt(keyword, startDate, endDate, nickName);
return PageableExecutionUtils.getPage(list, pageable, count::fetchFirst);
}
private JPAQuery<Long> getCnt(String keyword,
LocalDateTime startDate,
LocalDateTime endDate,
String nickName){
return jpaQueryFactory.select(todo.count())
.from(todo)
.where(keywordEq(keyword), dateEq(startDate, endDate), nickNameEq(nickName));
}
private BooleanExpression keywordEq(String keyword) {
if (keyword == null) {
return null;
}
return todo.title.contains(keyword);
}
private BooleanExpression dateEq(LocalDateTime startDate, LocalDateTime endDate) {
if (startDate == null || endDate == null) {
return null;
}
return todo.createdAt.between(startDate, endDate);
}
private BooleanExpression nickNameEq(String nickName) {
if (nickName == null) {
return null;
}
return todo.managers.any().user.nickName.contains(nickName);
}
BooleanExpresion 메서드 부터 보자
위에 요구사항해서 말했듯이 검색은 일정의 제목, 생성일 기준, 그리고 일정 담당 유저의 닉네임으로 검색할 수 있다.
하지만 어떤 조건으로 검색을 할지 모르기 때문에 BooleanExpression을 이용하여 각각의 검색 조건에따라 조건 쿼리를 추가하도록 작성했다.
// title 파라미터 값이 유효하다면 쿼리에 포함
private BooleanExpression keywordEq(String keyword) {
if (keyword == null) {
return null;
}
return todo.title.contains(keyword);
}
// 생성일 범위 파라미터 값이 유효하다면 쿼리에 포함
private BooleanExpression dateEq(LocalDateTime startDate, LocalDateTime endDate) {
if (startDate == null || endDate == null) {
return null;
}
return todo.createdAt.between(startDate, endDate);
}
// 일정 담당유저의 닉네임 파라미터 값이 유효하다면 쿼리에 포함
private BooleanExpression nickNameEq(String nickName) {
if (nickName == null) {
return null;
}
return todo.managers.any().user.nickName.contains(nickName);
}
SearchTodo
public Page<TodoQueryDslSearchResponse> searchTodo(
String keyword,
LocalDateTime startDate,
LocalDateTime endDate,
String nickName,
Pageable pageable) {
List<TodoQueryDslSearchResponse> list = jpaQueryFactory.select(
Projections.fields(
TodoQueryDslSearchResponse.class,
todo.title,
manager.id.countDistinct().as("mangerCnt"),
comment.id.countDistinct().as("commentCnt")
))
.from(todo)
.leftJoin(todo.managers, manager)
.leftJoin(todo.comments, comment)
.where(keywordEq(keyword), dateEq(startDate, endDate), nickNameEq(nickName))
.orderBy(todo.createdAt.desc())
.groupBy(todo.id)
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();
JPAQuery<Long> count = getCnt(keyword, startDate, endDate, nickName);
return PageableExecutionUtils.getPage(list, pageable, count::fetchFirst);
}
// 페이징 처리를 위한 total수를 알기위한 메서드
private JPAQuery<Long> getCnt(String keyword,
LocalDateTime startDate,
LocalDateTime endDate,
String nickName){
return jpaQueryFactory.select(todo.count())
.from(todo)
.where(keywordEq(keyword), dateEq(startDate, endDate), nickNameEq(nickName));
}
🤔 Projection ?
이번 요구사항에서 일정 정보 전체 반환이아닌 원하는 정보만 반환하라는 요구사항이 있어서 어떻게 할지 찾아보던중 해당 방법을
알게 되었다.
- 프로젝션은 select절에서 어떤 컬럼들을 조회할 지 대상을 지정하는 것을 말한다.
- 대상이 하나인 경우에는 타입을 명확하게 지정할 수 있다.
- 대상이 여러개라면 Tuple과 DTO 형식으로 매핑하여 반환해야한다.
Tuple 사용하는 것은 외부에 DB를 그대로 노출 시키는 행위와 같기 때문에 실무에서는 대부분 DTO로 매핑한다고 한다.
DTO로 받는 방법
- Setter 접근법 Projections.bean
- 필드 직접 접근법 Projections.fields
- 생성자 사용법 Projections.constructor
- @QueryProjection
Projections.bean
- Setter 기반으로 동작.
- 객체의 불변성을 위해 별로 권장하지 않는 패턴이다.
Public List<TeamDto> searchTeams(SearchTeamFilterRequest searchTeamFilterRequest){
return queryFactory
.selectFrom(Projections.bean(TeamDto.class,
team.username,
team.region))
.from(team)
.fetch();
}
Projections.fields
- getter, setter 메서드 필요 없이 field에 값을 직접 주입해주는 방식이다.
- Type이 다를 경우 매칭이 되지 않으며, 컴파일 시점에서 에러를 잡지 못하고 런타임 시점에서 에러가 잡힌다.
- Entity의 필드명과 DTO의 필드명이 다를 경우 alias(별칭)을 사용하여 매핑 문제를 해결할 수 있다.
해당 방법은 위에서 사용하였으므로 예제 코드를 작성하지 않았다.
Projections.constructor
- 생성자를 기반으로 바인딩 > DTO 객체를 불변으로 가져갈 수 있음.
- 바인딩 시 객체 생성자를 바인딩 하는 것이 아니라 Expression<?> exprs 값을 넘기는 방식으로 진행됨.
- 값을 넘길 때 생성자와 순서를 일치시켜야 함.
- DTO에 기본 생성자와 모든 필드를 넣는 생성자를 만들어둬야 한다.
- 권장하지 않는 패턴
Public List<TeamDto> searchTeams(SearchTeamFilterRequest searchTeamFilterRequest){
return queryFactory
.selectFrom(Projections.constructor(TeamDto.class,
team.name,
team.region))
.from(team)
.fetch();
}
@QueryProjection
- 불변 객체 선언, 생성자 그대로 사용 가능.
- 가장 권장하는 패턴.
- ProjectDTO 가 아닌 QProjectDTO 생성 사용.
- Q클래스가 생성자의 변수명과 순서가 정확히 일치해 안전하고 편리하게 필요한 값을 바인딩 할 수 있음.
- 다만 DTO에 QueryDSL 어노테이션을 유지해야하는 점과 DTO까지 Q 파일을 생성 해야한다는 단점이 있다.
Public List<TeamDto> searchTeams(SearchTeamFilterRequest searchTeamFilterRequest){
return queryFactory
.selectFrom(new QTeamDto(team.name, team.region))
.from(team)
.fetch();
}
// 생성자에 달아주어야한다.
@QueryProjection
public TeamDto(String name, String region) {
this.name = name;
this.region = region;
}
'TIL' 카테고리의 다른 글
Docker - 자주 사용하는 명령어 (0) | 2024.11.19 |
---|---|
Docker란 ? (0) | 2024.11.18 |
JPA - QueryDSL (1) | 2024.11.15 |
Service와 ServiceImpl 왜 나누는거지? (1) | 2024.11.13 |
JPA - 복합키와 식별 관계 매핑 (0) | 2024.11.12 |