사내 프로젝트에 Dynamic Query를 위한 Querydsl 적용기

kangsan·2021년 5월 4일
3

refactoring

목록 보기
1/1

계기

현 회사의 프로젝트는 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

직접 String을 조립

가장 단순하게 생각할 수 있는 방법이다. 동적으로 받은 조건으로 Where 절과 Orderby를 조립하면 되니까
예를들면 아래와 같은 식이다.

if(centreId != null) {
	sb.append(" and centreId=:centreId");
}

하지만 Reservation 엔티티에는 수 많은 컬럼명이 있어 실수할 가능성이 있고, 결정적으로 쿼리가 실제 조립되어 SQL이 생성되기 전 알 수가 없다는 점이었다. 기왕이면 컴파일 타임에 문제점을 찾고 IDE의 자동완성 기능을 이용하고 싶었다.

JPA Criteria

java 코드로서 JPQL을 생성할 수 있는 JPA 표준이다.
동적 쿼리를 생성할 수 있지만, 실제 사용 시 코드가 복잡하고 직관적인 이해가 어려워 대부분 추천하지 않는다.

Querydsl

JPA Criteria 와 같이 java 코드로 안전하게 쿼리를 만들 수 있는 JPQL 빌더
JPA Criteria에 비해 사용이 편하고, 빌더의 모양도 쿼리와 비슷하다.
오픈소스이며 동적쿼리 및 복잡한 쿼리를 만들어야 할 시 JPQL을 직접 쓰는 대신 많이 사용하는 도구이다.

Querydsl?

개요

그러면 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을 만들 수 있다.

Querydsl 설정

사내 프로젝트 기준(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 스크립트를 만들어주면 된다)

  • gralde 기본 경로
    • build/gradle/sources/annotationProcessor/java/main
  • intellij 기본 경로
    • 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 와 같이 사용하기

사내 프로젝트에서 사용하는 Spring Data JPA 에 Querydsl 을 활용한 동적 쿼리 생성 기능을 추가해보자.
현재 ReservationRepositoriy 인터페이스는 Spring Data JPA를 사용하기 위해 JpaRepository를 상속받고 있다. 여기에 추가적으로 동적 쿼리 생성용 메서드가 정의된 ReservationRepositoryCustom 를 추가로 상속받아 ReservationRepository 를 확장하는 방식으로 구현했다. 서비스에서 사용을 간단하게 하기 위함이다.

ReservationSearchCondition.java

@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;
  // ...
}
  • 동적 쿼리에서 넘길 검색 조건 클래스
  • where절에 eq, ne, between 조건으로 쓸 컬럼들을 담고있는 예시이다
  • startTime이 특정 시간대 (예 11시~13시) 에 포함되는지 검색할 쿼리를 위해 start, end를 가진 TimeBetween 클래스를 추가적으로 정의했다
  • Builder 를 통해 동적으로 생성하기 좋은 형태로 만들었다

ReservationRepositoryCustom.java

public interface ReservationRepositoryCustom {
  List<Reservation> search(ReservationSearchCondition condition);
  List<Reservation> search(ReservationSearchCondition condition, Sort orderCondition);
}
  • 동적 쿼리용 search 메서드를 정의, 동적 Sort가 없는 것, 있는 것 각각 만들어주었다
  • Sort (org.springframework.data.domain) 객체를 통해 동적 Order를 할 것이다

ReservationRepositoryCustomImpl.java

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;
  }
  
  // ...
}
  • SpringDataJPA의 Custom Repository 구현체의 이름은 -Impl 로 끝나야 한다 - 참고
  1. Querydsl에서 동적 Sort를 만들기 위해 Sort 객체를 OrderSpecifier 객체로 변환해주는 과정이 있다.
  2. getReservationsByCondtion에서 where 절 까지 조립을, OrderBy가 필요한 경우 search(condition, orderCondition) 에서 붙여준다. 필요한 부분을 조립할 수 있는게 장점이다.
  3. 동적 where 조건을 만드는 곳
  4. 각 조건마다 조건이 있는경우 비교 후 BooleanExpression을 리턴, 아닌경우 null을 반환하는 메서드를 정의한다.
  5. 특정 조건에 customized 된 메서드도 정의할 수 있다.

서비스 단 사용

만든 동적 쿼리용 메서드는 다음과 같이 대체한다

// 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... 메서드들을 여기저기서 끌어다 쓰고있는 쿼리가 많아 동적 쿼리로 한방에 변경 시 문제가 없음을 보여야 했고
아래와 같이 테스트 코드를 통해 기존 쿼리를 대체할 수 있는 것을 보였다.

쿼리 검증 테스트

결과

  • 복잡한 조건의 findAll... 메서드들을 모두 제거 할 수 있게 되었다.
  • 긴 메서드명과 파라미터 대신 빌더를 사용하여 서비스 단 가독성이 좋아졌다.
  • 동적 쿼리를 만들기 위한 비용이 생각보다 커 동적쿼리의 수요가 확실한 경우에 만드는 것이 좋을 것 같다.

2개의 댓글

comment-user-thumbnail
2022년 1월 20일

덕분에 querydsl을 처음 접하는데에도 쉽게 접했네요 감사합니다.

답글 달기
comment-user-thumbnail
2022년 1월 29일

생각보다 적용이 쉽네요? 저도 써봐야겠슴다!

답글 달기