[Spring] QueryDSL 동적 쿼리 생성하기

말하는 감자·2025년 5월 7일

내일배움캠프

목록 보기
53/73
post-thumbnail

과제에서 QueryDSL 을 사용해서 검색기능을 만드는 문제가 잇다.

입력조건 > 제목, 닉네임, 생성자기준으로 범위검색
출력 조건 > 제목, 담당자 "수", 댓글 "개수" + order by 생성일 최신순 , 페이징 처리

쿼리 최적화 조건 : Projections 기능사용







DTO 준비

검색 은 보통 RequestParams 많이 사용했는데, 이번에는 조건도 많고 페이징도 들어가서
검색 조건들은 RequestBody로 넣어주는 게 좋겠다는 생각을했다.

그래서 RequestDto랑 ResponseDto를 따로 만들어줫음.
ResponseDto 같은 경우는 매니저나 덧글 테이블을 조인하는데, 객체 전체가 필요없고 count만 세면된다.

@Getter
@NoArgsConstructor
@AllArgsConstructor
public class TodoSearchRequest {
    private String nickname;
    private String title;

    @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
    private LocalDateTime startDate;

    @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
    private LocalDateTime endDate;
}
@Getter
public class TodoSearchResponse {

    private final String title;
    private final Long managerCount;
    private final Long commentCount;

    public TodoSearchResponse(String title, Long managerCount, Long commentCount) {
        this.title = title;
        this.managerCount = managerCount;
        this.commentCount = commentCount;
    }
}







Projections

QueryDSL 을 이용해 entity 전체를 가져오는 것이 아니라 조회 대상을 지정해 원하는 값만 조회하는 것을 말한다.

앞에서 만들어둿떤 TodoSearchResponse 값만 받아오면 되기 떄문에
.fields 함수를 사용해서 select문안에서 가져올 형태를 지정해준다.

countDistinct()

QueryDSL에서 countDistinct()는 중복을 제거한 후 개수를 세는 함수다.

qManager.countDistinct().as("managerCount")
이걸 SQL문으로 바꿔쓰면
SELECT COUNT(DISTINCT *) FROM managers 이런식이 된다.

count와의 차이점이라면 count() 안에는 파라미터가 들어가지 못하는데,
select count(*) from managers 가 돼서 테이블 전체 개수만 계속 들고오게됨.

                .select(Projections.fields(TodoSearchResponse.class,
                        qTodo.title.as("title"),
                        qManager.countDistinct().as("managerCount"),
                        qComment.countDistinct().as("commentCount")
                ))

셀렉트문이 이렇게됨







일전에 JPQL으로 동적쿼리를 생성했는데, QueryDSL 도 .where문 안에서 비슷하게 if분기를 함수처럼 만들어서 넣으면 될 것 같았다.
.offset(pageable.getOffset())
.limit(pageable.getPageSize()) 얘네들은 QueryDSL 강의자료에 튜터님이 이렇게 적용해놨길래 따라서 적어봣음.

        List<TodoSearchResponse> result = jpaQueryFactory
                .select(Projections.fields(TodoSearchResponse.class,
                        qTodo.title.as("title"),
                        qManager.countDistinct().as("managerCount"),
                        qComment.countDistinct().as("commentCount")
                ))
                .from(qTodo)
                .leftJoin(qTodo.managers, qManager)
                .leftJoin(qTodo.comments, qComment)
                .groupBy(qTodo.id)
                .where(
                        qTodo.title.contains(title),
                        qTodo.user.nickname.contains(nickname),
                        qTodo.createdAt.after(startDate),
                        qTodo.createdAt.before(endDate)
                )
                .offset(pageable.getOffset())
                .limit(pageable.getPageSize())
                .fetch();

제목, 닉네임, 생성시간을 기준으로 시작/끝 날짜 범위가 검색조건인데
만약에 값이 다 있다면 이런식으로 들어갈 것이고

아니면

        List<TodoSearchResponse> result = jpaQueryFactory
                .select(Projections.fields(TodoSearchResponse.class,
                        qTodo.title.as("title"),
                        qManager.countDistinct().as("managerCount"),
                        qComment.countDistinct().as("commentCount")
                ))
                .from(qTodo)
                .leftJoin(qTodo.managers, qManager)
                .leftJoin(qTodo.comments, qComment)
                .groupBy(qTodo.id)
                .where(
                        qTodo.title.contains(title)
                )
                .offset(pageable.getOffset())
                .limit(pageable.getPageSize())
                .fetch();

이런식으로 1개 이상의 조건이 존재하면된다
검색이기 때문에 조건문이 아무것도 없을 경우는 서비스단에서 그냥 getTodos로 갈겨버릴생각이다..

근데 여기서 고민인게
qTodo.title.contains(title)은 뭘 반환하느냐

BooleanExpression 을 반환한다.
이를 반환형태로 가지는 함수를 만들어주겠다

문제

    @Override
    public Page<TodoSearchResponse> searchTodos(Pageable pageable, String nickname, String title, LocalDateTime startDate, LocalDateTime endDate) {
        QTodo qTodo = QTodo.todo;
        QManager qManager = QManager.manager;
        QComment qComment = QComment.comment;

        List<TodoSearchResponse> result = jpaQueryFactory
                .select(Projections.fields(TodoSearchResponse.class,
                        qTodo.title.as("title"),
                        qManager.countDistinct().as("managerCount"),
                        qComment.countDistinct().as("commentCount")
                ))
                .from(qTodo)
                .leftJoin(qTodo.managers, qManager)
                .leftJoin(qTodo.comments, qComment)
                .groupBy(qTodo.id)
                .where(
                        StringUtils.hasText(title) ? qTodo.title.contains(title) : null,
                        StringUtils.hasText(nickname) ? qTodo.user.nickname.contains(nickname) : null,
                        dateBetween(startDate, endDate, qTodo)
                )
                .offset(pageable.getOffset())
                .limit(pageable.getPageSize())
                .fetch();

        return new PageImpl<>(result, pageable, result.size());
    }

    private BooleanExpression dateBetween(LocalDateTime start, LocalDateTime end, QTodo qTodo) {
        if (start != null && end != null) {
            return qTodo.modifiedAt.between(start, end);
        } else if (start != null) {
            return qTodo.modifiedAt.goe(start);
        } else if (end != null) {
            return qTodo.modifiedAt.loe(end);
        } else {
            return null;
        }
    }

여기까지 잘 됐는데 PageImpl부분에서 문제가 생겼따.

PageImpl 객체에서는 마지막 인자에 "전체 데이터 개수"가 들어가야하는데 내가 쿼리부분에 offset이랑 limit를 미리 집어넣어버리는 바람에 최대 개수가 무조건 limit 값이 돼버렸다.

해결?

흠.................
만약에 result의 대상이 많아지면 많아질수록 pageable을 쿼리문에 넣는게 이득이라고 생각해서
따로 총 합계수를 찍어오는 쿼리문을 만들어줘야겠다.

        List<TodoSearchResponse> result = jpaQueryFactory
                .select(Projections.fields(TodoSearchResponse.class,
                        qTodo.title.as("title"),
                        qManager.countDistinct().as("managerCount"),
                        qComment.countDistinct().as("commentCount")
                ))
                .from(qTodo)
                .leftJoin(qTodo.managers, qManager)
                .leftJoin(qTodo.comments, qComment)
                .groupBy(qTodo.id)
                .where(
                        StringUtils.hasText(title) ? qTodo.title.contains(title) : null,
                        StringUtils.hasText(nickname) ? qTodo.user.nickname.contains(nickname) : null,
                        dateBetween(startDate, endDate, qTodo)
                )
                .offset(pageable.getOffset())
                .limit(pageable.getPageSize())
                .fetch();

        Long totalSize = jpaQueryFactory
                .select(qTodo.countDistinct())
                .from(qTodo)
                .leftJoin(qTodo.managers, qManager)
                .leftJoin(qTodo.comments, qComment)
                .where(
                        StringUtils.hasText(title) ? qTodo.title.contains(title) : null,
                        StringUtils.hasText(nickname) ? qTodo.user.nickname.contains(nickname) : null,
                        dateBetween(startDate, endDate, qTodo)
                )
                .fetchOne();

        return new PageImpl<>(result, pageable, totalSize);

그럼 select를 제외한 나머지가 대부분 중복이 되긴하는데 BaseQuery로 빼자니 또 select부분은 중복이 아니라서 이렇게 쓰기로했다.

테스트 코드 문제

이 에러 메시지:

com.querydsl.core.types.ExpressionException: org.example.expert.domain.todo.dto.response.TodoSearchResponse
는 QueryDSL이 TodoSearchResponse 객체를 만들려고 했는데 실패했음을 의미한다고한다.
즉, DTO 필드 매핑이 제대로 안 되었거나, 생성자/필드/Setter에 문제가 있음

Projections.fields 사용하려면 해당 클래스에 기본생성자 + 세터필수 필요하다고한다..
고쳐준다

@Getter
@AllArgsConstructor
@NoArgsConstructor
public class TodoSearchResponse {

    private String title;
    private Long managerCount;
    private Long commentCount;

    public void setTitle(String title) {
        this.title = title;
    }
    public void setManagerCount(Long managerCount) {
        this.managerCount = managerCount;
    }
    public void setCommentCount(Long commentCount) {
        this.commentCount = commentCount;
    }

}

ㅊㅊ
https://velog.io/@seho100/QueryDSL-BooleanExpression%EC%9D%84-%ED%86%B5%ED%95%9C-%EB%8F%99%EC%A0%81%EC%BF%BC%EB%A6%AC
https://pyoungt.tistory.com/215
https://velog.io/@songunnie/QueryDSL-연관관계-없는-where-조건절을-쓸-수-없는-테이블의-쿼리가-필요할-때-.join-없이-count쿼리-쓰기
https://velog.io/@god1hyuk/QueryDSLcount-값-Integer-타입으로-return-하기

profile
대충 데굴데굴 굴러가는 개발?자

0개의 댓글