이 글은 Querydsl 적용 방법을 소개하는 글이 아닙니다. Querydsl 도입 과정과 얻을 수 있었던 이점을 소개합니다.
상황: 조건절 중복 + 가독성 저하
한방쿼리로 인한 성능 저하를 해결하기 위해 쿼리를 최적화했습니다.
그 결과 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
'⛳️ 공동구매 서비스 총대마켓' 카테고리의 다른 글
⚠️ 비동기 스레드 내부에서 발생한 LazyInitializationException 해결하기 (0) | 2025.01.19 |
---|---|
♻️ JPA N+1 문제 해결하여 성능 개선하기: LAZY 로딩 전략, fetch join (0) | 2025.01.17 |
@TransactionalEventListener: 학습테스트 만들어 동작 방식 확인해보기 (0) | 2024.12.23 |
⚠️ 트랜잭션이 롤백될 때 이미 발행된 이벤트를 어떻게 처리할까: @TransactionalEventListener (0) | 2024.12.23 |
⚠️ 동시성 이슈 해결 과정: 트랜잭션 격리 수준, 낙관적 잠금, 비관적 잠금 (2) | 2024.12.18 |