현 회사의 프로젝트는 SpringDataJPA 를 사용하여 필요한 쿼리를 생성한다.
문제는 중요한 도메인일수록 쿼리가 복잡해지고 너무 긴 메서드명과 너무 많은 파라미터 명이 생성되어 관리가 어려워졌다.
고객의 예약 정보를 담고있는 Reservation 테이블에 수행할 쿼리가 적힌 ReservationRepository에는 수 많은 추상 메서드가 있었고, 그 결과 아래와 같은 불편한 점이 있었다.
public interface ReservationRepository extends JpaRepository<Reservation, Long> {
// ...
List<Reservation> findAllByCentreId(String centreId);
List<Reservation> findAllByCentreIdAndStartTimeBetweenOrderByCreatedDesc(String centreId, String after, String before);
List<Reservation> findAllByCentreIdAndResrvStateNotInAndStartTimeBetweenOrderByStartTime(String centreId, Collection<Configure> resrvState, String after, String before);
List<Reservation> findAllByCentreIdAndStartTimeBetweenAndResrvStateAndSendSmsToClientIs(String centreId, String after, String before, Configure resrvState, int sendSmsToClient);
List<Reservation> findAllByCentreIdAndStartTimeBetweenAndResrvStateAndSendPushToClientIsAndDriverIdIsNotNullAndVehicleIdIsNotNull(String centreId, String after, String before, Configure resrvState, int sendPushToClient);
List<Reservation> findAllByStartTimeBetweenAndResrvStateAndSendPushToDriverIs(String after, String before, Configure resrvState, int sendPushToDriver);
List<Reservation> findAllByDriverIdAndVehicleIdAndResrvState(Driver driver, Vehicle vehicle,Configure resrvStatus);
List<Reservation> findAllByDriverIdAndVehicleIdAndStartTimeStartingWithAndResrvStateIsNot(Driver driverId,Vehicle vehicle, String startTime, Configure resrvState);
List<Reservation> findAllByCentreIdAndDriverIdAndResrvStateInAndStartTimeLessThanEqualAndEndTimeGreaterThanEqual(String centreId, Driver driver, Collection<Configure> resrvState, String startTime, String endTime);
List<Reservation> findByStartTimeBetweenOrEndTimeBetween(String startTimeStart,String startTimeEnd, String endtimeStart,String endtimeEnd);
List<Reservation> findAllByCentreIdAndClientIdAndStartTimeAfterAndResrvStateIsNotIn(String centreId, Client client, String startTime, Collection<Configure> resrvState);
//...
}
일부만 나타내자면 Repository는 위와 같은 상태였다.
대부분findAll...
에 해당하는 조건만 다른 쿼리가 수십개 만들어져 있었다.
그때그떄 필요한 인자만 넘겨서 findAll을 동적으로 만들어주면 해당 리포지토리의 복잡도가 많이 줄어들 것으로 생각했다.
그럼 어떤 방식으로 동적 쿼리를 만들어야 할까? 3가지 방법 정도를 찾았다.
1. 직접 String 조립
2. JPA Criteria
3. Querydsl
가장 단순하게 생각할 수 있는 방법이다. 동적으로 받은 조건으로 Where 절과 Orderby를 조립하면 되니까
예를들면 아래와 같은 식이다.
if(centreId != null) {
sb.append(" and centreId=:centreId");
}
하지만 Reservation 엔티티에는 수 많은 컬럼명이 있어 실수할 가능성이 있고, 결정적으로 쿼리가 실제 조립되어 SQL이 생성되기 전 알 수가 없다는 점이었다. 기왕이면 컴파일 타임에 문제점을 찾고 IDE의 자동완성 기능을 이용하고 싶었다.
java 코드로서 JPQL을 생성할 수 있는 JPA 표준이다.
동적 쿼리를 생성할 수 있지만, 실제 사용 시 코드가 복잡하고 직관적인 이해가 어려워 대부분 추천하지 않는다.
JPA Criteria 와 같이 java 코드로 안전하게 쿼리를 만들 수 있는 JPQL 빌더
JPA Criteria에 비해 사용이 편하고, 빌더의 모양도 쿼리와 비슷하다.
오픈소스이며 동적쿼리 및 복잡한 쿼리를 만들어야 할 시 JPQL을 직접 쓰는 대신 많이 사용하는 도구이다.
그러면 Querydsl은 어떻게 java 코드로 쿼리를 작성할 수 있게 할까?
querydsl 은 @Entity
(java.persistence.Entity) 를 이용해 정의된 Entity 클래스들을 찾아 AnnotationProcessor를 이용해 Q Type class를 생성한다.
예를들어 Entity 이름이 Reservation이라면 QReservation 클래스를 새로 만든다.
생성된 QType class를 들어가보면 기존 엔티티의 필드들이 타입에 맞게 ...Path
타입으로 생성되어 있다.
public class QReservation extends EntityPathBase<Reservation> {
public static final QReservation reservation = new QReservation("reservation");
public final StringPath centreId = createString("centreId");
public final StringPath clientName = createString("clientName");
public final NumberPath<Integer> fee = createNumber("fee", Integer.class);
//...
}
해당 Path 클래스는 Experession 클래스를 상속받는데 Expression 클래스 내부에는 우리가 where이나 orderby 시 조건으로 주는 문법들을 메서드로 표현가능 하게 만들어져 있었다.
예를들면 StringExpression 에는 like, notlike, contains, startsWith 와 같은 string 을 비교하기 위한 메서드들이,
ComparableExpression 에는 gt, goe, lt, loe, between 같은 메서드들이,
SimpleExpression 에는 eq, in, ne, notIn 같은 메서드가 만들어져 있는 식이다.
우리는 이런 Q Type 클래스에서 생성해준 필드와 메서드들을 통해 type safe한 JPQL을 만들 수 있다.
사내 프로젝트 기준(Gradle 6.0.1) build.gradle은 다음과 같이 같이 설정했다.
설정에 참고한 블로그
honeymon님 블로그
// Querydsl
apply plugin: "io.spring.dependency-management"
implementation("com.querydsl:querydsl-jpa")
annotationProcessor("com.querydsl:querydsl-apt:${dependencyManagement.importedProperties['querydsl.version']}:jpa")
annotationProcessor("jakarta.persistence:jakarta.persistence-api")
annotationProcessor("jakarta.annotation:jakarta.annotation-api")
인텔리제이 Build Tools는 다음과 같이 설정했다
Preferences > Build,Execution,Development > Build Tools > Gradle
Build and run using : Gradle
Run tests using : Intellij
빌드 툴을 Gradle 로 한 이유는 Annotation Processor의 생성물 기본 경로가 달라서 소스 내부에 안생기는 gradle이 조금 더 편하다고 생각했었고, gradle 기본 스크립트의 clean 등을 활용할 수 있어서였다.
(intellij 로 설정한 경우 해당 경로를 날려주는 clean 스크립트를 만들어주면 된다)
build/gradle/sources/annotationProcessor/java/main
src/main/generated
Querydsl 설정 후 빌드 시 Q타입 클래스들을 만들어준다. Q타입 클래스를 생성하기 위해 gradle build
를 먼저 한번 수행한다.
Q Type 클래스를 사용하기 위해서는 JPAQueryFactory
를 주입받아야 한다.
@Autowired private JPAQueryFactory qf;
// Q 타입 클래스는 QReservation.reservation 으로 쓰면 되나
// 간단하게 static import를 수행하였다.
@Test
public void testQdsl() {
List<Reservation> 김민수들 = qf.
select(reservation)
.from(reservation)
.where(
reservation.clientName.eq("김민수")
)
.fetch();
}
사내 프로젝트에서 사용하는 Spring Data JPA 에 Querydsl 을 활용한 동적 쿼리 생성 기능을 추가해보자.
현재 ReservationRepositoriy
인터페이스는 Spring Data JPA를 사용하기 위해 JpaRepository를 상속받고 있다. 여기에 추가적으로 동적 쿼리 생성용 메서드가 정의된 ReservationRepositoryCustom
를 추가로 상속받아 ReservationRepository
를 확장하는 방식으로 구현했다. 서비스에서 사용을 간단하게 하기 위함이다.
@NoArgsConstructor
@AllArgsConstructor
@Getter
@Builder
public class ReservationSearchCondition {
private String centreId;
private Client client;
private String state;
private String stateNot;
private TimeBetween startTimeBetween;
private TimeBetween endTimeBetween;
// ...
}
public interface ReservationRepositoryCustom {
List<Reservation> search(ReservationSearchCondition condition);
List<Reservation> search(ReservationSearchCondition condition, Sort orderCondition);
}
public class ReservationRepositoryImpl implements ReservationRepositoryCustom {
private final JPAQueryFactory queryFactory;
public ReservationRepositoryImpl(JPAQueryFactory queryFactory) {
this.queryFactory = queryFactory;
}
@Override
List<Reservation> search(ReservationSearchCondition condition) {
return getReservationsByCondition(condition).fetch();
}
@Override
List<Reservation> search(ReservationSearchCondition condition, Sort orderCondition) {
// 1.
OrderSpecifier<?>[] orders = orderConditions.stream().map(order -> {
Order direction = order.getDirection().isAscending() ? ASC : DESC;
Path<Reservation> fieldPath = Expressions.path(Reservation.class, reservation, order.getProperty());
return new OrderSpecifier<>(direction, fieldPath);
}).toArray(OrderSpecifier[]::new);
// 2.
return getReservationsByCondition(condition).orderBy(orders).fetch();
}
private JPAQuery<Reservation> getReservationsByCondition(ReservationSearchCondition condition) {
return queryFactory
.select(reservation)
.from(reservation)
.where(
// 3.
eqCentreId(condition.getCentreId()),
eqClient(condition.getClient()),
eqState(condition.getState()),
neState(condition.getStateNot()),
betweenStartTime(condition.getStartTimeBetween()),
breakTimeOnly(condition.getBreakTimeOnly()),
// ...
);
}
// 4.
private BooleanExpression eqCentreId(String centreId) {
return isEmpty(centreId) ? null : reservation.centreId.eq(centreId);
}
private BooleanExpression eqClient(Client client) {
return client == null ? null : reservation.client.eq(client);
}
private BooleanExpression eqStartTimeBetween(TimeBetween startTimeBetween) {
return startTimeBetween == null ? null : reseration.startTime.between(startTimeBetween.getStart(), startTimeBetween.getEnd());
}
// 5.
private BooleanExpression breakTimeOnly(Boolean breakTimeOnly) {
return breakTimeOnly ? reservation.departure.eq("휴식").and(reservation.destination.eq("휴식")) : null;
}
// ...
}
-Impl
로 끝나야 한다 - 참고만든 동적 쿼리용 메서드는 다음과 같이 대체한다
// Origin
List<Reservation> oldReservations = reserationRepository.findAllByDriverIdAndVehicleIdAndDepartureAndDestinationAndEndTimeGreaterThanEqual(driver, vehicle, "휴식", "휴식", targetTime);
// with Dynamic Query
List<Reservation newReservations = reservationRepository.search(
ReservationSearchCondtion.builder()
.driver(driver)
.vehicle(vehicle)
.breakTimeOnly(true)
.endTimeGoe(targetTime)
.build());
기존 find...
메서드들을 여기저기서 끌어다 쓰고있는 쿼리가 많아 동적 쿼리로 한방에 변경 시 문제가 없음을 보여야 했고
아래와 같이 테스트 코드를 통해 기존 쿼리를 대체할 수 있는 것을 보였다.
쿼리 검증 테스트
덕분에 querydsl을 처음 접하는데에도 쉽게 접했네요 감사합니다.