본문 바로가기

⛳️ 공동구매 서비스 총대마켓

♻️ QueryDSL을 도입하면 무엇이 좋을까 (from JPQL)

이 글은 Querydsl 적용 방법을 소개하는 글이 아닙니다. Querydsl 도입 과정과 얻을 수 있었던 이점을 소개합니다.

 

 

상황: 조건절 중복 + 가독성 저하

한방쿼리로 인한 성능 저하를 해결하기 위해 쿼리를 최적화했습니다.

 

쿼리 최적화와 인덱스로 API Latency 30배 개선하기

문제 총대마켓 서비스의 메인페이지 필터링 및 검색 조회 API Latency가 100만 건 더미데이터 기준, 약 25초에 근접했습니다. 사용자가 메인페이지에서 30초간 아무런 데이터도 확인할 수 없는 것과

helenason.tistory.com

 

그 결과 API 속도는 약 30배 개선되었지만, 한방쿼리를 잘게 분리하는 과정에서 코드가 중복되었습니다. 더불어 특정 변수가 존재하는지에 따라 호출되는 쿼리도 달라졌습니다. 따라서 불필요한 쿼리를 제거하고 코드의 가독성을 높이기 위해, 동적으로 쿼리를 생성하는 라이브러리 도입을 팀에 제안하였습니다.

 

 

도구 선택: QueryDSL

동적 쿼리 생성 라이브러리는 대표적으로 QueryDSL과 JOOQ가 언급됩니다. 저는 아래와 같은 이유로 QueryDSL을 선택했습니다.

  • 초기 환경 설정 간결 + 낮은 러닝 커브
  • 현업에서 많이 사용
  • 다양한 레퍼런스
  • JPA와의 호환성
  • 추후 NoSQL 도입 가능성

 

리팩터링 과정

리팩터링은, 기존 JPQL을 사용했을 때 발생했던 쿼리와 QueryDSL을 도입했을 때 발생하는 쿼리를 비교하여, 동일한 쿼리가 발생하는지 확인하며 서비스 테스트를 보충하는 과정으로 진행했습니다. 도입 과정에서 개선된 세가지 포인트를 중점으로 소개하겠습니다.

 

개선1) 조건절을 동적으로 생성

 

BEFORE

기존에는 아래와 같이 검색어 존재 여부에 따라 다른 쿼리를 호출했습니다. 검색어가 없으면 첫번째 쿼리를, 검색어가 있으면 두번째 쿼리를 호출했죠. 두 쿼리를 비교하면, 단 한 줄을 제외한 모든 코드가 중복입니다.

// repository
@Query("""
        SELECT o
        FROM OfferingEntity o
        WHERE o.id < :lastId
        ORDER BY o.id DESC
        """)
List<OfferingEntity> findRecentOfferingsWithoutKeyword(Long lastId, Pageable pageable);

@Query("""
        SELECT o
        FROM OfferingEntity o
        WHERE o.id < :lastId
            AND (o.title LIKE :keyword% OR o.meetingAddress LIKE :keyword%)
        ORDER BY o.id DESC
        """)
List<OfferingEntity> findRecentOfferingsWithKeyword(Long lastId, String keyword, Pageable pageable);

 

한 기능에 두 쿼리를 사용하기 때문에 위 쿼리를 사용하는 서비스 계층에서의 분기 처리도 필요했습니다. 아래와 같이 검색어(searchKeyword)가 존재하는지 확인한 후 해당하는 쿼리를 호출합니다.

// service
private List<OfferingEntity> fetchOfferings(Long lastOfferingId, String searchKeyword, Pageable pageable) {
    if (searchKeyword == null) {
        return offeringRepository.findRecentOfferingsWithoutKeyword(lastOfferingId, pageable);
    }
    return offeringRepository.findRecentOfferingsWithKeyword(lastOfferingId, searchKeyword, pageable);
}

 

 

AFTER

불필요한 쿼리 중복과 불필요한 분기 처리를 제거하기 위해, 검색어의 존재 여부에 따라 동적으로 조건절이 생성되도록 하였습니다. 변수 keyword가 null이면 검색 로직을 제거하고, null이 아니면 검색 로직을 포함시켰습니다. 이 과정은 동적 쿼리 생성 라이브러리를 설명할 때 흔히 소개되는 장점이기도 하죠.

 

결과적으로, 위에서 언급한 두 메서드를 아래와 같이 하나의 메서드로 줄일 수 있었습니다.

// repository
@Override
public List<OfferingEntity> findRecentOfferings(Long lastId, String keyword, Pageable pageable) {
    return queryFactory.selectFrom(offeringEntity)
            .where(offeringEntity.id.lt(lastId), likeTitleOrMeetingAddress(keyword))
            .orderBy(offeringEntity.id.desc())
            .offset(pageable.getOffset())
            .limit(pageable.getPageSize())
            .fetch();
}

private BooleanExpression likeTitleOrMeetingAddress(String keyword) {
    if (keyword == null) {
        return null;
    }
    return likeTitle(keyword).or(likeMeetingAddress(keyword));
}

 

서비스 계층에서의 분기 처리도 불필요해졌죠.

// service
@Override
protected List<OfferingEntity> fetchWithLast(OfferingEntity lastOffering, String searchKeyword, Pageable pageable) {
    Long lastOfferingId = lastOffering.getId();
    return offeringRepository.findRecentOfferings(lastOfferingId, searchKeyword, pageable);
}

 

 

개선2) 중복된 쿼리를 메서드로 추출

 

BEFORE

두 쿼리를 비교하면, LIKE 조건을 제외한 모든 쿼리가 중복됩니다.

@Query("""
        SELECT o
        FROM OfferingEntity o
        WHERE (o.meetingAddress LIKE :keyword%)
            AND (o.offeringStatus = 'IMMINENT')
            AND (o.meetingDate > :lastMeetingDate OR (o.meetingDate = :lastMeetingDate AND o.id < :lastId))
        ORDER BY o.meetingDate ASC, o.id DESC
        """)
List<OfferingEntity> findImminentOfferingsWithMeetingAddressKeyword(
        LocalDateTime lastMeetingDate, Long lastId, String keyword, Pageable pageable);

@Query("""
        SELECT o
        FROM OfferingEntity o
        WHERE (o.title LIKE :keyword%)
            AND (o.offeringStatus = 'IMMINENT')
            AND (o.meetingDate > :lastMeetingDate OR (o.meetingDate = :lastMeetingDate AND o.id < :lastId))
        ORDER BY o.meetingDate ASC, o.id DESC
        """)
List<OfferingEntity> findImminentOfferingsWithTitleKeyword(
        LocalDateTime lastMeetingDate, Long lastId, String keyword, Pageable pageable);

 

 

AFTER

아래와 같이 중복된 로직을 메서드로 추출하여 가독성을 높였습니다. 기존 JPQL은 문자열 기반이기 때문에 중복된 쿼리를 추출하지 못했지만, QueryDSL의 경우 메서드 기반이기 때문에 가능했죠.

@Override
public List<OfferingEntity> findImminentOfferingsWithTitleKeyword(
        LocalDateTime lastMeetingDate, Long lastId, String keyword, Pageable pageable) {
    return findImminentOfferings(lastMeetingDate, lastId, pageable, likeTitle(keyword));
}

@Override
public List<OfferingEntity> findImminentOfferingsWithMeetingAddressKeyword(
        LocalDateTime lastMeetingDate, Long lastId, String keyword, Pageable pageable) {
    return findImminentOfferings(lastMeetingDate, lastId, pageable, likeMeetingAddress(keyword));
}

private List<OfferingEntity> findImminentOfferings(
        LocalDateTime lastMeetingDate, Long lastId, Pageable pageable, BooleanExpression keywordCondition) {
    return /* 중복 로직 */
}

 

 

개선3) 중복된 조건절을 메서드로 추출해 재사용

 

BEFORE

아래와 같이 offeringStatus 필드가 원하는 값에 해당되는지 확인하는 조건절이 자주 사용되었습니다. 역시나 중복됩니다.

offeringStatus = 'IMMINENT'
offeringStatus IN ('AVAILABLE', 'IMMINENT')
offeringStatus IN ('AVAILABLE', 'FULL', 'IMMINENT')

 

 

AFTER

위 세가지 로직도 QueryDSL을 통해 메서드로 추출해 재사용할 수 있었습니다.

private BooleanExpression inOfferingStatus(OfferingStatus... offeringStatus) {
    return offeringEntity.offeringStatus.in(offeringStatus);
}

 

그렇다면 여기서 등호(equal절)는 어떻게 in절로 추출이 가능했을까요? QueryDSL의 in 메서드를 열어보니 아래와 같이 파라미터가 한 개일 경우 eq 메서드를 실행하는 것을 알 수 있었습니다. 따라서 equal절과 in절을 하나의 in 메서드로 추출하였습니다.

 

 

개선4) 문자열 기반을 코드 기반으로 변경하여 휴먼 에러 방지 및 컴파일 타임 에러 발견

 

BEFORE

JPQL은 문자열 기반이기 때문에 아래와 같이 enum으로 관리되는 값들(AVAILABLE, IMMINENT)도 문자열로 적어야 했습니다. 즉, DB에 데이터가 어떻게 저장되는지를 알아야 코드를 작성할 수 있었습니다. 문자열 기반이기 때문에 휴먼 에러가 발생할 가능성도 높았죠.

SELECT o
FROM OfferingEntity o
WHERE (o.offeringStatus IN ('AVAILABLE', 'IMMINENT'))
   AND (o.id < :lastId)
   AND (o.title LIKE :keyword% OR o.meetingAddress LIKE :keyword%)
ORDER BY o.id DESC

 

더불어, IntelliJ가 이 데이터들을 인식하지 못해 정상적인 쿼리임에도 빨간줄을 띄우기도 했습니다 🥲

 

 

AFTER

QueryDSL을 도입하니 자바의 enum 객체를 기반으로 코드를 작성할 수 있었습니다. 이 경우 휴먼 에러를 런타임이 아닌 컴파일 타임에 알아챌 수도 있겠죠.

@Override
public List<OfferingEntity> findJoinableOfferings(Long lastId, String keyword, Pageable pageable) {
    return queryFactory.selectFrom(offeringEntity)
            .where(offeringEntity.id.lt(lastId),
                    inOfferingStatus(OfferingStatus.AVAILABLE, OfferingStatus.IMMINENT),
                    likeTitleOrMeetingAddress(keyword))
            .orderBy(offeringEntity.id.desc())
            .offset(pageable.getOffset())
            .limit(pageable.getPageSize())
            .fetch();
}

 

 

체감한 장점

기존 JPQL 기반 코드를 QueryDSL을 통해 개선해보면서 체감한 장점은 아래와 같습니다.

  • 타입 안전하여 컴파일 타임에 에러 발견 가능
  • 문자열로 인한 휴먼 에러 방지
  • 중복 조건문 재사용 가능
  • 상황에 따라 동적으로 쿼리 생성 가능

위 장점 중에서도, 상황에 따라 동적으로 쿼리를 생성할 수 있는 것에 큰 매력을 느꼈습니다. 더욱 깔끔해진 코드에 마음이 편안해지네요 :)

 

참고

QueryDSL 개요

https://www.youtube.com/watch?v=CtvMe7xP0gY

QueryDSL JPA 구현 방법

https://www.youtube.com/watch?v=Dz-46mPfkGo

관련 PR

https://github.com/woowacourse-teams/2024-chongdae-market/pull/684