프로젝트에서 검색 기능을 구현할 때, 단순히 JPA Repository를 사용하는 것만으로는 복잡한 조건과 효율적인 페이징 처리, 그리고 동적 쿼리를 처리하기에 어려움이 있었다. 특히, 검색 조건이 여러 개 중복되고 일부는 선택적으로 적용되는 경우 JPQL로 관리하기엔 코드가 길어지고 가독성이 떨어졌다. 이를 해결하기 위해 QueryDSL을 도입하게 되었다.
검색 기능을 구현할 때 JPA의 @Query와 JPQL을 사용해도 충분히 가능하지만, 복잡한 조건을 처리하고 확장성을 고려하다 보면 한계가 드러난다. 특히, 다음과 같은 문제점들이 발생한다
- 조건이 많아질수록 가독성 저하
- 조건을 처리하기 위해 여러개의 if문과 복잡한 JPQL문자열을 생성해야 하며, 코드가 길어지고 유지보수가 어렵다
- 동적 쿼리 생성의 복잡성
- 동적으로 조건을 추가하거나 제거해야 할 경우, JPQL 문자열 조합은 가독성과 디버깅 측면에서 비효율적이다.
- 페이징 처리의 불편함
- JPQL을 활용한 페이징 처리에서는 데이터 쿼리와 카운트 쿼리를 각각 작성해야 하며, 두 쿼리 간의 동기화 문제가 발생할 수 있다.
이를 해결하기 위해 QueryDSL을 도입하였다. QueryDSL은 타입 세이프한 동적 쿼리 작성 도구로, 가독성과 유지보수성을 극대화하며, 복잡한 조건 처리를 깔끔하게 구현할 수 있도록 돕는다.
QueryDSL의 주요 특징
- 타입 세이프한 쿼리
- QueryDSL은 컴파일 시점에 오류를 감지할 수 있는 타입 세이프한 쿼리를 제공한다. 이를 통해 문자열 기반 JPQL의 오류 가능성을 줄이고 안정적인 코드를 작성할 수 있다.
- 동적 쿼리 작성
- BooleanExpression과 같은 표현식을 활용하여, 조건을 동적으로 추가하거나 조합할 수 있다. 불필요한 조건은 null로 처리되어 쿼리에서 제외된다.
- JPQL과의 완벽한 호환성
- QueryDSL은 JPA와 통합하여 JPQL로 작성되는 모든 쿼리를 대체할 수 있다. 또한 JPA의 페이징 처리와 결합하여 효율적인 데이터 조회가 가능하다.
- Projections과 DTO 매핑 지원
- Projections 을 사용해 DTO에 필요한 필드만 선택적으로 매핑할 수 있다.
- @Query를 사용한 JPQL에서 수동으로 DTO를 매핑해야 하는 번거로움을 줄인다.
구현한 검색 기능
검색 기능을 구현하면서 QueryDSL의 다양한 기능을 활용하였다.
1. 검색 조건은 다음과 같다
- 제목 키워드: contains를 사용하여 부분 일치 검색
- 생성일 범위: between을 사용하여 날짜 범위 검색
- 담당자 닉네임: 담당자의 닉네임으로 검색 가능
- 정렬: 최신 생성일 순으로 정렬
2. 페이징 처리
- Pageable 인터페이스를 활용해 QueryDSL에서 offset과 limit를 사용하여 페이징 처리
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
3. Projections 활용
- 필요한 데이터만 반환하기 위해 Projections.constructor를 사용하여 DTO에 직접 매핑
.select(Projections.constructor(
TodoSearchResponse.class,
QTodo.todo.title,
QManager.manager.count().as("managerCount"),
QComment.comment.count().as("commentCount")
))
4. 동적 조건 추가
- BooleanExpression 을 사용해 선택적으로 조건을 추가
private BooleanExpression titleContains(String keyword) {
return keyword != null ? QTodo.todo.title.contains(keyword) : null;
}
5. 최종 코드
@Override
public Page<TodoSearchResponse> searchTodos(String keyword, LocalDate startDate, LocalDate endDate, String managerNickname, Pageable pageable) {
List<TodoSearchResponse> results = queryFactory
.select(Projections.constructor(
TodoSearchResponse.class,
QTodo.todo.title,
QManager.manager.count().as("managerCount"),
QComment.comment.count().as("commentCount")
))
.from(QTodo.todo)
.leftJoin(QTodo.todo.managers, QManager.manager)
.leftJoin(QTodo.todo.comments, QComment.comment)
.leftJoin(QManager.manager.user, QUser.user)
.where(
titleContains(keyword),
createdBetween(startDate, endDate),
managerNicknameContains(managerNickname)
)
.groupBy(QTodo.todo.id)
.orderBy(QTodo.todo.createdAt.desc())
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();
long total = queryFactory
.select(QTodo.todo.count())
.from(QTodo.todo)
.where(
titleContains(keyword),
createdBetween(startDate, endDate),
managerNicknameContains(managerNickname)
)
.fetchOne();
return new PageImpl<>(results, pageable, total);
}
발생한 문제
1. 페이징 관련 성능 문제
- QueryDSL로 동적 쿼리를 작성할 때, 페이징 처리를 위해 count 쿼리를 별도로 작성해야 했는데, 이 과정에서 성능 문제가 발생했다.
- 특히 데이터 양이 많아질수록 count 쿼리의 실행 시간이 점점 증가하며 성능에 큰 영향을 미쳤다. fetchOne을 사용해 효율적으로 데이터를 가져오려고 했으나, count 쿼리 자체가 복잡한 조건이 포함되면 여전히 성능 병목이 발생했다.
- 해결과정
- 간단한 조건에서는 countQuery 생략: 데이터 양이 비교적 적거나, 간단한 필터 조건인 경우에는 count 쿼리를 생략하고 필요한 데이터만 조회하도록 처리했다. 이를 위해 전체 데이터 개수를 추정할 수 있는 별도의 메서드를 만들어 count를 수행하지 않는 옵션을 추가했다.
- 효율적인 페이징 처리: 복잡한 쿼리 조건을 사용하는 경우, countQuery를 최적화하기 위해 조건을 단순화하거나, 데이터베이스에서 EXPLAIN을 통해 실행 계획을 분석해 적절한 인덱스를 추가했다.
long total = queryFactory
.select(todo.count())
.from(todo)
.where(applyConditions())
.fetchOne(); // 필요할 경우에만 실행
- 추가 고려사항
- 데이터베이스 레벨에서 count 대신 별도의 캐싱된 값을 사용하는 방법도 검토했다. 예를 들어, 데이터가 자주 변경되지 않는 경우 전체 개수를 Redis나 Elasticsearch 같은 저장소에 캐싱하고 이를 기반으로 페이징했다.
2. 조건 추가 시 타입 에러
- BooleanExpression을 사용해 동적 조건을 생성할 때, 조건 값이 null로 반환되면 QueryDSL에서 예외가 발생하거나 예상치 못한 동작이 발생하는 문제가 있었다. 특히 동적 쿼리에서 조건을 선택적으로 추가하는 구조에서는 null 값을 처리하지 않으면 의도하지 않은 결과를 가져올 수 있었다.
- 해결과정
- 조건이 없을 경우에도 기본 조건을 반환하도록 defaultCondition 메서드를 추가해 null 대신 항상 BooleanExpression 타입을 반환하도록 했다.
private BooleanExpression defaultCondition() {
return QTodo.todo.isNotNull(); // 항상 true인 기본 조건
}
- 조건을 추가하기 전에 값이 null인지 확인한 후, 조건이 없을 경우 기본 조건을 반환하거나 null을 무시하도록 했다.
private BooleanExpression titleContains(String keyword) {
return keyword != null ? QTodo.todo.title.contains(keyword) : defaultCondition();
}
- 여러 조건을 조합할 때 BooleanExpression을 명시적으로 처리하도록 설정해 에러를 방지했다.
BooleanExpression conditions = defaultCondition()
.and(titleContains(keyword))
.and(createdBetween(startDate, endDate))
.and(managerNicknameContains(managerNickname));
이번 검색 기능 구현에서 QueryDSL을 활용한 동적 쿼리 작성과 최적화는 프로젝트의 확장성과 유지보수성을 크게 향상시켰다. 기존 JPQL 기반의 방식은 코드의 복잡성과 가독성 문제로 인해 한계를 보였으나, QueryDSL은 이를 효과적으로 해결할 수 있는 도구였다.
특히 Projections를 활용해 필요한 데이터만 반환함으로써 성능을 최적화하고, BooleanExpression을 사용해 조건을 동적으로 추가하며 다양한 검색 요구사항을 충족할 수 있었다. 페이징 처리 또한 효율적으로 구현하면서, 대규모 데이터에서도 안정적으로 작동하는 검색 시스템을 구축할 수 있었다.