실무에서 JPQL 대신 Querydsl을 많이 사용하는 이유
Programming

실무에서 JPQL 대신 Querydsl을 많이 사용하는 이유

JPQL로도 모든 쿼리문을 작성할 수 있는데, Querydsl을 사용한 이유가 무엇인가요?

우테코 당시 크루들 사이에서 이런 질문이 나왔다. 답은 동적쿼리 때문이다. 하지만 나는 동적쿼리가 왜 필요한지 이해할 수 없었다. 그래서 프로젝트를 할 때에도 모든 쿼리를 JPQL로만 작성했다.

public interface ReservationRepository extends JpaRepository<Reservation, Long> {

    @Query("SELECT r FROM Reservation AS r "
            + "INNER JOIN r.crew AS c "
            + "ON c.id = :crewId "
            + "INNER JOIN r.schedule AS s "
            + "ORDER BY s.localDateTime DESC")
    List<Reservation> findAllByCrewIdLatestOrder(Long crewId);

    @Query("SELECT r FROM Reservation AS r "
            + "JOIN FETCH r.schedule AS s "
            + "JOIN FETCH r.crew AS cr "
            + "INNER JOIN s.coach AS co "
            + "ON co.id = :coachId "
            + "WHERE r.reservationStatus NOT IN :status")
    List<Reservation> findAllByCoachIdAndStatusNot(Long coachId, ReservationStatus status);
}

 

며칠 전 실무에서 조회 API를 만들 일이 생겼다. 단순히 테이블 한 두개만을 조회하는 것이 목적이라 복잡한 JOIN문도 없었다. 그러나 나는 API 명세를 보자마자 내가 그동안 우테코 프로젝트에서 작성한 조회 API와의 차이점을 단번에 알 수 있었다. 바로 많은 조회 조건들이 쿼리스트링으로 넘어온다는 점이었다.

 

데이터의 양이 토이프로젝트와는 차원이 다르다.

실제 업무에서 다루는 서비스는 매일 쌓이는 데이터의 양이 토이 프로젝트와는 차원이 다를 정도로 많다. 따라서 조회할 때 페이징은 기본이고, 최신순/오래된순, 낮은가격순/높은가격순, 생성자, 생성기간 등 여러 조건들이 쿼리스트링으로 넘어오게 된다. 상품 페이지가 아닌 관리자를 위한 어드민 페이지일 지라도 최신순/오래된순을 포함한 조건을 포함한 최소한의 페이징이 필요하다.

 

퀸잇을 예로 들어 보자. 개발자 도구를 열어 상의 카테고리의 인기순, 5만원이하 조건을 건 뒤, 페이지에서 필요한 데이터를 가져오기 위해 서버로부터 전송하는 Request URL을 확인할 수 있다.

조건을 추가할 때마다 쿼리파라미터가 하나씩 추가되는 것을 확인할 수 있다.

 

인기순, 5만원 이하라는 조건을 거니 아래 url이 서버 쪽으로 전송되는 것을 확인할 수 있었다.

https://web.api.queenit.kr/web/products/refined?size=10&orderBy=POPULARITY&categoryId=RESERVED1_TOP&maxPrice=50000 

 

쇼핑몰에서 하루에 등록되는 상품의 수는 얼마나 될까? 아마 셀 수 없이 많을 것이다.

따라서 고객이 원하는 상품을 조건에 따라 적절히 보여주는 것이 필요한데 이 때 사용하는 것이 바로 동적쿼리이다. 동적 쿼리를 사용하지 않는다면 조건이 하나씩 늘어날 수록 분기문도 늘어나게 된다.

 

만약 이 모든 조건을 JPQL로 작성하게 된다면 아래와 같은 쿼리문이 나올 것이다. 한눈에 봐도 가독성이 굉장히 안좋다는 것을 알 수 있다.

public Slice<Member> findWithSearchConditions(final String categoryId, final String orderBy,
                                                  final Integer maxPrice,
                                                  final Pageable pageable) {
    // 조건에 따라서 where문을 다르게 가지는 JPQL 생성
    String jpql = "SELECT p FROM Prodcut p";
    String whereSql = " WHERE ";
    List<String> whereCondition = new ArrayList<>();
    if (StringUtils.hasText(categoryId)) {
        whereCondition.add("p.categoryId = :categoryId");
    }
    if (maxPrice != null) {
        whereCondition.add("p.maxPrice <= :maxPrice");
    }

    if (!whereCondition.isEmpty()) {
        jpql += whereSql;
        jpql += String.join(" AND ", whereCondition);
    }

    if (StringUtils.hasText(orderBy)) {
        jpql += " ORDER BY " + orderBy;
    }

    // 조건에 따라서 각각의 where문에 parameter 설정
    TypedQuery<Member> query = entityManager.createQuery(jpql, Member.class);
    if (StringUtils.hasText(categoryId)) {
        query.setParameter("categoryId", categoryId);
    }
    if (maxPrice != null) {
        query.setParameter("maxPrice", maxPrice);
    }
    List<Member> members = query.setFirstResult((int) pageable.getOffset())
            .setMaxResults(pageable.getPageSize() + 1)
            .getResultList();
    return toSlice(pageable, members);
}

 

가독성이 높고 확장가능한 동적쿼리를 작성하기 위해 Querydsl을 사용한다.

Querydsl에서 동적쿼리를 사용하는 방법에는 BooleanBuilder 와 Where 다중 파라미터 가 있다.

BooleanBuilder 방식

private List<Member> searchMember1(String usernameCondition, Integer ageCondition) {
    BooleanBuilder builder = new BooleanBuilder(); // 파라미터로 초기값을 넣을 수도 있다.
    if (usernameCondition != null) {
        builder.and(member.username.eq(usernameCondition));
    }

    if (ageCondition != null) {
        builder.and(member.age.eq(ageCondition))
     }

    return queryFactory
            .selectFrom(member)
            .where(builder)
            .fetch();
}​

Where 다중 파라미터 방식

private List<Member> searchMember2(String usernameCondition, Integer ageCondition) {
    return queryFactory
            .selectFrom(member)
            .where(allEq(usernameCondition, ageCondition))
            .fetch();
}

// where 동적 쿼리의 가장 큰 장점 : 조립이 가능하다.
private BooleanExpression allEq(String usernameCondition, Integer ageCondition) {
    return usernameEq(usernameCondition).and(ageEq(ageCondition));
}

private BooleanExpression usernameEq(String usernameCondition) {
    return usernameCondition != null ? member.username.eq(usernameCondition) : null;
}

private BooleanExpression ageEq(Integer ageCondition) {
    return ageCondition != null ? member.age.eq(ageCondition) : null;
}

한눈에 봐도 JPQL로 동적쿼리를 작성했을 때보다 가독성이 훨씬 좋아진 것을 알 수 있다. 컴파일 시점에 문법을 잡아주기 때문에 휴먼에러도 방지할 수 있다. 여기서 where 조건 안의 null 조건은 자동으로 무시된다.

 

BooleanBuilder방식과 비교했을 때 Where 다중 파라미터의 장점은 1)쿼리 자체의 가독성이 높고, 2)메서드 조합 및 재활용이 가능하다는 것이다. 따라서, 이 방법을 많이 사용한다.

 

모든 조건이 null일 경우를 주의해야 한다.

where 조건에서 null은 무시된다. 다만 모든 조건이 null이라면 내부적으로 findAll()처럼 동작하기에 주의해서 사용해야 한다.

 

토이프로젝트에서는 findAll()을 해서 모든 데이터를 가져온 다음 stream으로 필요한 데이터를 서버에서 처리해 클라이언트에게 내려줘도 아무 문제가 없었다. 그러나 실서비스는 다르다. findAll()을 할 경우 심각한 장애로 이어질 수 있다. 이를 예방하기 위해 페이지 size가 넘어오지 않을 경우 DEFAULT_PAGE_SIZE = 10과 같이 설정해 최소한의 페이징 조건으로 모든 조건이 null이 되는 상황을 방지할 수 있다.

 

출처