본문 바로가기
42

QueryDSL 도입기

by jay-choe 2021. 11. 9.

필요성에 관해서

Spring Data JPA를 사용하다보면 테이블을 생성해주고, 쿼리도 자동으로 생성해주기 때문에 매우 편리하지만 유연하게 데이터를 가져오는 것이 힘듭니다.

예를들어 날짜별로 그룹핑 후에 해당 그룹의 로우의 갯수를 찾는 쿼리를 날리려고 할 때, GROUP BY라는 것이 존재하지 않기 때문에 Spring Data JPA 안에서 @Query 어노테이션을 사용해서 직접 쿼리를 만들어주고, 응답받는 데이터를 매핑 해주어야합니다.(혹은 GROUP BY를 사용하지 않고, 데이터를 불러와서, 자바 코드 내부에서 리스트를 가공해서 처리 해주는 경우도 있다고 합니다.)

또한, 입장한 사람들의 날짜를 가공해서, 월별로 조회를 해서 통계를 내고 싶은경우를 예를 들면

SELECT DATE(v.check_in, ‘%Y-%m’) as Month,

v.check_in as CheckIn

FROM visitor v

...

 

단순히 JPA만으로는 어떻게 양식에 맞춰서 가져올지 생각이 들지 않습니다.

이런 불편한점을 개선하기 위한 방법으로 Spring Data JPA에서는 Native Query를 쓸 수 있도록 지원을 해주는데..

조금 불편합니다.

방문 예약 서비스에서, 요일별로 방문한 방문자와 연관된 모든 정보를 페이징 처리해서 보내주는 쿼리를 Native Query로 짰을때

@Query(nativeQuery = true,
        value = "SELECT DATE_FORMAT(v.check_in_time, '%Y-%m-%d') AS checkInDate, "
            + "v.check_in_time AS checkIn, "
            + "v.name AS name, "
            + "v.phone AS phone, "
            + "s.name AS staffName, "
            + "s.phone AS staffPhone, "
            + "s.department AS staffDepartment, "
            + "r.purpose AS purpose, "
            + "r.place AS place "
            + "FROM visitor v "
            + "INNER JOIN reserve r "
            + "ON v.reserve_id = r.id "
            + "INNER JOIN staff s "
            + "ON r.target_staff = s.id "
            + "WHERE v.check_in_time IS NOT NULL "
            + "AND v.check_in_time BETWEEN :start AND :end "
            + "ORDER BY checkInDate, checkIn DESC"),
           countQuery = "SELECT COUNT(*) FROM visitor v "
            + "WHERE v.check_in_time IS NOT NULL "
            + "AND v.check_in_time BETWEEN :start AND :end")
    Page<CheckInVisitor> findCheckInBetweenDate(@Param(value = "start") LocalDate start,
        @Param(value = "end") LocalDate end, Pageable pageable);

이런 모양이 나옵니다.

가장 눈에 띄는 점은 + 입니다.

가독성을 좋게 하기 위해서, 여러 줄로 분리를 하는데, 이때 저 쿼리는 문자열이기 때문에 +연산으로 결합시키게 됩니다.

그래서 어떤점이 불편한가요?

  • 문자열타입

쿼리내용이 문자열 연산이기 때문에 가령

             "INNER JOIN reserve r"
            + "ON v.reserve_id = r.id "

이런 코드를 봤을 때, 실행이 될까요?

정답은 실행이 안 됩니다. 문자열 연산의 결과를 생각해보면, reserve rOn v. re…

이렇게 키워드에 Alias가 붙어버리기 때문에, 인식하지 못합니다. 그런데 이 쿼리 자체가 (문자열 전부가) String 타입이기 때문에, 컴파일 타임에서 이런 점을 잡아줄 수 가 없습니다. 실제 해당 쿼리가 나가서야 DB에서 에러를 던지고, 이를 받아서 에러가 나는데

에러 내용도 ‘Syntax Error’로 나오지만, 실제 문법에서 실수 한 것이 없는데.. 어디가 잘못 됐는지 하고 저 23줄짜리 쿼리를 본다면, 금방 에러를 찾을 수 있을까요??

 

  • 동적쿼리

이번에 도입을 생각했던 가장 결정적인 계기입니다.

프론트엔드에서 요청을 할 때, 조건별로 다르게 데이터를 주고싶었습니다. 그런데 조건은 8개였습니다.

1개의 조건은 고정이며, 3가지 케이스가 있었습니다. (A or B or (A and B))

나머지 7개의 조건은 단순하게 true or false로 처리할 수 있었습니다.

그렇다면 총 경우의수는 3 * 7 → 21가지가 나옵니다.

단순 JPA만으로 생각해본다면, if를 사용한 분기문이 엄청 많이 필요합니다. 그리고 조건을 받는데, 필요 없어서 조건을 넣지않는경우 어떻게 처리할까였습니다.

간단하게 코드로 표현을 해본다면,

if (data.equals("서초")) {
  sql = "place = '서초'";
} else if (data.equals("개포")) {
    sql = "place = '개포'";
} else {
      sql = null;
}

선택을 안 한경우 default로 둘 다를 채택하는 식의 로직이었는데, 만약 모든 내용을 선택하지 않는다면

where (null) .. where가 비어있게 되고, 이는 문법 에러로 이어집니다.

그렇다고 조건을 설정하지 않았을 때 where 1 = 1을 넣어주기엔..

너무 if문이 많아 질 것 같고, 조건이 더 추가되거나 로직이 변경됐을 때 대응하기가 매우 어려울 것 같다고 생각했습니다.

 

QueryDSL

Querydsl이란 정적 타입을 이용해서 SQL과 같은 쿼리를 생성할 수 있도록 해 주는 프레임워크다. 문자열로 작성하거나 XML 파일에 쿼리를 작성하는 대신, Querydsl이 제공하는 플루언트(Fluent) API를 이용해서 쿼리를 생성할 수 있다.

참고

 

 결론적으로 동적인 쿼리를 날리기 위한 필요성에 의해 QueryDSL을 도입했습니다. 도입으로 인해서 위의 문제점들이 어떻게 해결된 지 보면

 

 public Page<CheckInVisitorDecrypt> findVisitorByCriteria(VisitorSearchCriteria criteria) {

        DateTemplate<String> formattedDate = Expressions.dateTemplate(String.class, "DATE_FORMAT({0}, {1})", reserve.date, "%Y-%m-%d");
        
        QueryResults<CheckInVisitorDecrypt> results = jpaQueryFactory
            .select(checkInVisitorByDateColumns())
            .from(visitor)
            .join(reserve).on(visitor.reserveId.eq(reserve.id))
            .join(staff).on(reserve.targetStaff.eq(staff.id))
            .where(placeCondition(criteria.getPlace())
                ,searchCriteria(criteria),
                reserveDateCondition(criteria.getStart().atStartOfDay(), criteria.includeEnd().atStartOfDay()))
            .orderBy(formattedDate.desc(),reserve.date.desc())
            .offset(criteria.getPage().getOffset())
            .limit(criteria.getPage().getPageSize())
            .fetchResults();
        return new PageImpl<>(results.getResults(), criteria.getPage(), results.getTotal());
    }

문자열 처리

우선 위의 메서드는 기존과 동일한 쿼리를 나타내는 매서드입니다.

→ 문자열로 처리가 되던 로직에서 메서드들로 연결되어 있어서, 컴파일 시점에서 에러를 체크할수 있게 되었습니다.

동적 쿼리

제가 느꼈던 QueryDSL의 가장 컸던 장점은

where method에 null로 인자가 들어온다면, 신경쓰지 않는다는 것 입니다.

그래서 기존 우리의 로직에서 place를 선택하지 않는다면 where 조건이 들어가면 안되기 때문에

 

private BooleanExpression placeCondition(ReservePlace place) {
    if (place == null) {
        return null;
    }
    return reserve.place.eq(place.toString());
}

해당 메서드로 만들었습니다.

마찬가지로 나머지 조건들도 BooleanBuilder라는 클래스에 and로 조건들을 붙여주면, 쿼리가 완성됩니다!

public BooleanBuilder searchCriteria(VisitorSearchCriteria criteria) {

    if (criteria.getSearchCriteria() == null) {
        return null;
    }

    BooleanBuilder booleanBuilder = new BooleanBuilder();

     criteria
        .getSearchCriteria()
         .stream()
         .filter(c -> c.getCriteria().getEntityName().equals("Visitor"))
         .forEach(c -> {
             PathBuilder<Visitor> entityPath = new PathBuilder<>(Visitor.class, "visitor");
             PathBuilder<String> column = entityPath.get(c.getCriteria().getKey(), String.class);
             booleanBuilder.and(column.eq(c.getValue()));
         });

    criteria
        .getSearchCriteria()
        .stream()
        .filter(c -> c.getCriteria().getEntityName().equals("Staff"))
        .forEach(c -> {
            PathBuilder<Staff> entityPath = new PathBuilder<>(Staff.class, "staff");
            PathBuilder<String> column = entityPath.get(c.getCriteria().getKey(), String.class);
            booleanBuilder.and(column.eq(c.getValue()));
        });
    return booleanBuilder;
}

 

따라서 요청시, 해당 조건을 포함하고싶지 않다면, 요청자체에서 데이터를 주지 않아도 되고, 이 모든것이 한 메서드에서 이루어지기 때문에 필요한 조건만 필터링하고싶으면 해당 조건만 보내면 처리될 수 있게 되었습니다.

 

참고 - 공식문서가 정말 잘 나와있습니다!

[Querydsl] 다이나믹 쿼리 사용하기

Querydsl - 레퍼런스 문서

'42' 카테고리의 다른 글

Java Out Of Memory Error: Java heap space  (0) 2021.09.19
Api key 적용하기  (0) 2021.08.22
URL Shortener 개발기  (0) 2021.08.06