동적 쿼리로 조건 검색 기능 구현하기 (Spring Boot 3, Querydsl)

Introduction

저는 현재 주문제작 케이크 업체 탐색 서비스 “당도”의 백엔드 개발을 담당하고 있습니다.

당도에서는 사용자들이 원하는 업체를 빠르고 효율적으로 찾아낼 수 있도록 업체 검색 시 다양한 필터를 제공하고 있는데요.

제공되는 필터의 종류는 다음과 같습니다.

  • 업체가 만드는 케이크의 카테고리 (꽃, 캐릭터, 레터링 등)
  • 업체가 판매하는 메뉴의 최소/최대가
  • 업체가 제공하는 외부 서비스 링크의 종류 (카카오톡 채널, 인스타그램 등)
  • 케이크 수령 방식 (배송, 픽업)

업체 검색 API는 이러한 조건들을 모두 고려하여 제공할 데이터를 구성하게 되는데요. 이러한 조건을 적용한 쿼리는 Spring Data JPA의 Query Method 방식으로 생성하기에는 너무 많은 조건을 가지고 있었습니다.

그렇다면, JPQL이나 Native Query를 이용하면 될까요?

물론 직접 쿼리를 작성하는 방식으로도 잘 동작하는 기능을 구현할 수 있겠지만, 필터 조건에 변경이 생길 때 기존의 쿼리를 다시 수정해야 하니 확장성 면에서 좋지 않다고 느껴집니다.

거기다가, 필터의 중요한 한 가지 특성은 바로 옵셔널하다는 것인데요.

조건에 포함되지 않은 필터는 고려 대상이 아니니 쿼리에 포함될 필요가 없죠. 하지만 직접 쿼리를 작성하게 된다면 모든 필드를 옵셔널하게 관리하기 위해 두 가지 방향 중 하나를 선택해야 합니다.

  • where 조건에 포함시키되 결과 데이터에 영향을 미치지 않도록 조건을 설정
  • 각 필터별로 포함된 쿼리와 포함되지 않은 쿼리를 작성 (N개 필터에 대해 2^N개 쿼리가 생성됨)

전자는 쿼리의 효율성이 하락한다는 단점이 있고, 후자는 쿼리가 기하급수적으로 늘어나니 관리가 어려워진다는 단점이 있죠. 저는 두 가지 다 용납하기 어려웠습니다.

더 나은 길을 찾던 중 발견한 선택지가 바로 Querydsl의 동적 쿼리인데요. 이번 글에서는 바로 이 Querydsl의 동적 쿼리를 이용해 옵셔널한 여러 필터 값을 고려한 쿼리를 최대한 변경에 용이하고 효율적인 방향으로 생성되도록 하는 방법에 대해 알아보겠습니다.

 

 

Querydsl 이란?

Spring Data JPA에서 기본적으로 제공하는 제한적 메소드들로는 복잡한 쿼리를 생성하기가 쉽지 않은데요.

하지만, 대안으로 JPQL이나 Native Query를 선택하게 되면 타입 안정성을 손실하게 되어 쿼리에 문제가 있어도 추적이 어렵게 됩니다.

Querydsl JPA는 타입 안정성을 보장하며 복잡한 쿼리를 다룰 수 있도록 돕는 프레임워크입니다.

 

 

Querydsl 동적 쿼리를 이용한 검색 구현

해당 프로젝트에서는 애플리케이션 구축을 위해 SpringBoot 3, Java 17 버전을 이용하며, 빌드 도구로는 Gradle(Groovy) 7.6을 이용합니다. Querydsl은 java 17을 지원하는, 공식 Querydsl 마지막 릴리즈 버전인 5.0.0을 이용합니다.

Gradle 환경 구성

plugins {
    id 'java'
    id 'org.springframework.boot' version '3.0.2'
    id 'io.spring.dependency-management' version '1.1.0'
}

version = '0.0.1-SNAPSHOT'
sourceCompatibility = '17'

configurations {
    compileOnly {
        extendsFrom annotationProcessor
    }
}

jar {
    enabled = false
}

test {
    exclude '**/*'
}

repositories {
    mavenCentral()
}

dependencies {

        ...

    // queryDSL
    implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta'

    annotationProcessor "com.querydsl:querydsl-apt:${dependencyManagement.importedProperties['querydsl.version']}:jakarta"
    annotationProcessor "jakarta.annotation:jakarta.annotation-api"
    annotationProcessor "jakarta.persistence:jakarta.persistence-api"

}

tasks.named('test') {
    useJUnitPlatform()
}

본 글은 Querydsl의 동적 쿼리에 대한 글이기 때문에 gradle 세팅에 대한 자세한 설명은 생략합니다.

SpringBoot 버전 3으로 넘어오면서 java 변화에 따라 대부분의 패키지가 javax에서 jakarta로 변화된 것 이외에는 2버전과 비교했을 때 큰 의존성 설정 차이는 없습니다.

필터 조건을 담은 DTO 선언

public class StoreConditionDto {
    private List<String> categoryList;
    private int minPrice;
    private int maxPrice;
    private List<String> platformList;
    // ...
}

필터 조건은 StoreConditionDto라는 하나의 dto로 다룹니다. 각각을 단일 파라미터가 아닌 하나의 JSON으로 다루는 이유는 아래와 같습니다.

  • 필터 조건이라는 하나의 그룹으로 다룰 수 있음 (각각을 개별적으로 다루면 조건이 많아질수록 가독성이 저하됨)
  • 필터 조건 JSON 자체를 API 요청 파라미터로 받음으로써 validation을 간소화할 수 있음

필터 조건 자체를 JSON으로 다루기 때문에, API의 path는 아래와 같이 구성됩니다.

/stores?cond=encoding('{ minPrice: 0, maxPrice: 999999, ...}')

서버에서는 이와 같은 요청을 받으면 인코딩된 JSON을 cond라는 단일 request parameter로 취급하여, object mapper를 통해 미리 선언해 둔 condition class로 변환합니다.

이 과정 자체를 validation으로 취급하며, 변환에 실패하면 요청 조건이 잘못되었다는 의미이므로 400 응답을 반환합니다.

BooleanExpression을 이용한 동적 쿼리 생성

컨트롤러 계층에서 제대로 된 필터 조건을 받았다면, 이 condition dto는 서비스 계층을 거쳐 커스텀 리포지토리로 전달됩니다.

커스텀 리포지토리에서는 아래와 같은 방식으로 각 조건들을 만족하는지를 하나의 BooleanExpression 으로 나타냅니다.

BooleanExpression searchFiltering = searchFiltering(dto.getSearch());
BooleanExpression minPriceExpression = minPrice.goe(dto.getMinPrice());
BooleanExpression maxPriceExpression = maxPrice.loe(dto.getMaxPrice());
BooleanExpression platformFiltering = platformFiltering(dto.getPlatformList());

이러한 조건들을 where 또는 having 절에서 일괄 적용합니다.

List<Store> result = jpaQueryFactory
                        .selectFrom(store)
                        .join(...)
                        .where(
                            searchFiltering,
                            platformFiltering
                        )
                        .having(
                            minPriceExpression,
                            maxPriceExpression
                        )
                        .fetch();

BooleanExpression은 그 값이 null인 경우 where 절에 아무런 영향도 주지 않습니다. 따라서, 사용되지 않는 필터 조건의 BooleanExpression 값은 null로 설정되게 함으로써 최종 쿼리에서는 이를 제외할 수 있습니다.

또한, 새로운 필터 조건이 생겼을 때에도 기존의 필터 조건에 영향을 주지 않고 새로운 BooleanExpression을 반환하는 메소드를 구현하고, 이를 where과 having 중 적용되어야 할 조건에 적용하기만 하면 됩니다.

이제 쿼리에서 불필요한 condition을 제거하고, 코드에서도 변화에 의한 side-effect를 최소화할 수 있게 되었습니다.

 

 

우려점

필터 조건이 많아질수록 URL은 점점 길어지게 되는데요. 조건들을 하나의 json으로 받는 경우 각각을 하나의 파라미터로 받는 것보다 더 빠르게 URL 길이가 늘어납니다. 필터 조건이 많아져 결국 최대 URL 길이를 초과하게 된다면, 다른 방법을 강구해야 할 수도 있습니다.

그리고 현재 기준으로 Querydsl은 2021년 이후로 새 릴리즈가 없는, 멈춘 프로젝트에 해당하는데요. Java의 LTS 버전이 2023년 가을에 발표된 21임을 고려하면, 아직 Java 21 서포트가 반영되지 않았다는 것을 알 수 있죠.

현재 넷플릭스의 오픈 소스 커뮤니티인 OpenFeign에서 이후 버전을 지원하겠다고 발표해 많은 관심을 받고 있는데요. 프로젝트가 Java 21 이후 버전을 사용하게 된다면, 해당 프로젝트의 Querydsl 도입을 검토해봐야 할 수도 있겠습니다.