Querydsl은 안전한 방식으로 JPA 쿼리를 작성할 수 있게 해주는 프레임워크다. JPQL을 문자열로 작성하는 대신, 자바 코드로 쿼리를 작성할 수 있어 다음과 같은 장점이 있다:
컴파일 타임에 오류 검출: 문자열 기반 쿼리는 런타임에 오류가 발견되지만, Querydsl은 컴파일 타임에 오류를 잡아준다.
리팩토링 용이성: 엔티티 필드명이 변경되면 관련 쿼리도 자동으로 반영된다.
동적 쿼리 작성 용이: 조건에 따라 쿼리를 동적으로 구성하기 쉽다.(동적 쿼리(Dynamic Query)란 실행 시점에 조건에 따라 쿼리의 구조가 변하는 것을 의미)
내가 Querydsl을 처음 접했을 때 느낀 것은 마치 SQL 쿼리문을 Java로 옮겨놓은 듯한 직관적인 문법이었다. 코드를 보는 것만으로도 어떤 쿼리가 실행될지 쉽게 예측할 수 있어서 좋았다.
▶️프로젝트 구현 사례: 헬스장 검색 기능
요구사항
헬스장 정보 플랫폼에서 다음과 같은 검색 기능이 필요했다:
지역명으로 검색
헬스장 이름, 주소, 설명에 포함된 키워드로 검색
일일 이용권 가격 범위로 검색
월 이용권 가격 범위로 검색
인기 헬스장 필터링
페이지네이션 지원
먼저 레포지토리 인터페이스를 정의했다:
public interface GymRepositoryCustom {
List<Gym> findByGym(GymSearchRequestDTO gymSearchRequestDTO, Pageable pageable);
}
그리고 이 인터페이스를 구현하는 클래스를 만들었다:
@RequiredArgsConstructor
@Slf4j
public class GymRepositoryCustomImpl implements GymRepositoryCustom {
private final JPAQueryFactory jpaQueryFactory;
@Override
public List<Gym> findByGym(GymSearchRequestDTO gymSearchRequestDTO, Pageable pageable) {
return jpaQueryFactory
.selectFrom(gym)
.where(
eqLocal(gymSearchRequestDTO.getLocalName()),
containsSearchKeyword(gymSearchRequestDTO.getSearchKeyword()),
betweenDailyPrice(gymSearchRequestDTO.getMinDailyPrice(),
gymSearchRequestDTO.getMaxDailyPrice()),
betweenMonthlyPrice(gymSearchRequestDTO.getMinMonthlyPrice(),
gymSearchRequestDTO.getMaxMonthlyPrice()),
filterByPopular(gymSearchRequestDTO.getPopular())
)
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.orderBy(gym.id.desc())
.fetch();
}
}
여기서 핵심은 where 절 안에 여러 조건 메서드를 나열하는 방식이다. 각 메서드는 BooleanExpression을 반환하며, null이 반환되면 해당 조건은 무시된다. 이렇게 하면 조건이 있을 때만 쿼리에 적용되는 동적 쿼리를 구현할 수 있다.
각 검색 조건에 대한 메서드를 어떻게 구현했는지 살펴보자:
private BooleanExpression eqLocal(String localName) {
if (localName == null) {
return null;
}
return gym.local.localName.contains(localName).or(gym.address.contains(localName));
}
지역명이 입력되면 헬스장의 지역명에 포함되거나 주소에 포함되는 경우를 찾는다. 지역명이 없으면 조건을 무시한다.
private BooleanExpression containsSearchKeyword(String searchKeyword) {
if (searchKeyword == null || searchKeyword.trim().isEmpty()) {
return null;
}
return gym.gymName.contains(searchKeyword)
.or(gym.address.contains(searchKeyword))
.or(gym.description.contains(searchKeyword));
}
검색 키워드가 입력되면 헬스장 이름, 주소, 설명 중 어디에든 포함되는 경우를 찾는다. 키워드가 없거나 공백만 있으면 조건을 무시한다.
private BooleanExpression betweenMonthlyPrice(Long minPrice, Long maxPrice) {
if (minPrice == null && maxPrice == null) {
return null;
}
if (minPrice == null) {
return gym.monthlyPrice.loe(maxPrice);
}
if (maxPrice == null) {
return gym.monthlyPrice.goe(minPrice);
}
return gym.monthlyPrice.between(minPrice, maxPrice);
}
최소가격과 최대가격 조건에 따라 다른 쿼리를 생성한다. 둘 다 있으면 between, 하나만 있으면 이상/이하 조건을 사용한다.
private BooleanExpression betweenDailyPrice(Long minPrice, Long maxPrice) {
if (minPrice == null && maxPrice == null) {
return null;
}
if (minPrice == null) {
return gym.dailyPrice.loe(maxPrice);
}
if (maxPrice == null) {
return gym.dailyPrice.goe(minPrice);
}
return gym.dailyPrice.between(minPrice, maxPrice);
}
월 이용권과 동일한 로직으로 일일 이용권 가격 범위 검색을 구현했다.
private BooleanExpression filterByPopular(Popular isPopular) {
return isPopular != null ? gym.popular.eq(isPopular) : null;
}
인기 헬스장 필터가 선택되면 해당 조건을 추가한다.