JPA의 한계에서 시작한 QueryDSL 도입기 : 설정부터 동적 쿼리까지
1. 왜 QueryDSL이 필요했나
Spring Data JPA를 사용하면 단순 조회 기능은 매우 빠르게 개발할 수 있습니다. 메서드 네이밍 규칙만으로도 findById, findByUserIdAndStatus 같은 조회 메서드를 손쉽게 생성할 수 있기 때문입니다. 초기 프로젝트에서는 이러한 방식만으로도 대부분의 요구사항을 충분히 처리할 수 있었습니다.
하지만 서비스가 커지고 데이터 관계가 복잡해지면서 점점 한계가 드러나기 시작했습니다. 특히 여러 테이블을 동시에 조회해야 하는 상황에서 문제가 발생했습니다. 예를 들어 주문 정보, 구독 정보, 설정 정보 등을 함께 조회해야 하는 경우 JPA Repository 메서드만으로는 처리하기 어려운 경우가 많았습니다.
처음에는 각 테이블을 별도로 조회한 뒤 Java 코드에서 직접 데이터를 조합하는 방식으로 구현하였습니다. 프로젝트 구조상 하나의 주요 데이터당 stage 설정 데이터가 연관되어 존재하는 구조였는데, 목록을 조회할 때마다 각 데이터의 stage 설정 데이터를 추가로 조회해야 했고 결과적으로 조회 횟수가 급격히 증가했습니다. 이 방식은 다음과 같은 치명적인 문제를 가지고 있었습니다.
-
DB 왕복 횟수(Round Trip) 증가
-
N+1 문제 발생으로 인한 쿼리 폭발
-
불필요한 메모리 사용 증가 및 조회 로직 복잡도 상승
처음에는 데이터 규모가 작아 큰 문제가 없었지만, 전체적인 시스템 데이터량이 증가하고 서비스 사용량이 늘어나면서 응답 속도가 눈에 띄게 저하되었습니다. 이를 해결하기 위해 여러 테이블을 하나의 JOIN 쿼리로 조회하는 구조가 시급했습니다.
물론 JPQL을 직접 사용하는 방식도 검토하였습니다. 하지만 JPQL은 문자열 기반으로 작성되기 때문에 오타를 컴파일 시점에 발견할 수 없고, 리팩토링 시 안정성이 떨어지며, 동적 조건 처리가 까다롭다는 명확한 한계가 있었습니다. 결국 타입 세이프(Type Safe) 하면서도 동적 쿼리를 유연하게 작성할 수 있는 QueryDSL 도입을 결정하게 되었습니다.
2. QueryDSL 설정하기
기존 프로젝트에는 QueryDSL 설정이 존재하지 않았기 때문에 처음부터 직접 환경 구성을 진행해야 했습니다. 처음에는 단순히 build.gradle에 의존성만 추가하면 바로 사용할 수 있을 것이라고 생각했지만, 실제로는 Spring Boot 3.x 환경에 맞는 세부 설정이 필요했습니다. 의존성만 넣고 실행했을 때 JPAQueryFactory를 찾지 못한다는 오류를 겪으며 구축한 설정법은 다음과 같습니다.
2-1. build.gradle 의존성 추가
dependencies {
implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta'
annotationProcessor "com.querydsl:querydsl-apt:5.0.0:jakarta"
annotationProcessor "jakarta.annotation:jakarta.annotation-api"
annotationProcessor "jakarta.persistence:jakarta.persistence-api"
}
sourceSets {
main {
java {
srcDirs = ['src/main/java',
'build/generated/sources/annotationProcessor/java/main']
}
}
}
Spring Boot 3.x 환경은 기존 javax가 아닌 jakarta EE 기반이기 때문에, querydsl-jpa와 querydsl-apt 의존성 뒤에 반드시 :jakarta classifier를 붙여주어야 합니다. 만약 이 옵션을 누락하면 내부적으로 javax 패키지를 참조하게 되어 컴파일 오류가 발생합니다.
또한 annotationProcessor 3종 세트와 함께 sourceSets 설정을 추가하여, Gradle이 자동 생성된 generated 디렉토리를 소스 경로로 정확히 인식하도록 해야 합니다. 이 설정이 누락되면 빌드는 성공해도 IDE에서 Q클래스를 import할 수 없는 현상이 발생합니다.
2-2. Q클래스와 JPAQueryFactory
성공적으로 빌드를 수행하면 @Entity 클래스를 기반으로 QOrderJpo, QMemberJpo 같은 Q클래스가 자동으로 지정된 경로에 생성됩니다.
기존 JPQL 문자열 방식과 QueryDSL 방식을 비교해보면 타입 세이프함이 주는 안정성의 차이가 확실히 드러납니다.
// 1. String-based JPQL approach: Syntax errors cannot be caught at compile time
String jpql = "SELECT o FROM Order o WHERE o.userId = :userId";
// 2. QueryDSL approach: Safe types and compiled validation
QOrderJpo order = QOrderJpo.orderJpo;
queryFactory
.selectFrom(order)
.where(order.userId.eq(userId))
.fetch();
이처럼 QueryDSL은 Q클래스를 통해 쿼리를 자바 코드로 작성하므로 IDE 자동완성과 컴파일러 검증 기능을 100% 활용할 수 있으며, 엔티티 필드명을 변경하는 리팩토링 시에도 매우 안전합니다.
이렇게 생성된 Q클래스 기반 쿼리를 실행하려면 JPAQueryFactory가 필요한데, 이는 스프링이 자동으로 빈을 등록해주지 않으므로 아래와 같이 별도의 Configuration 설정 클래스를 만들어 EntityManager를 주입받아 빈으로 등록해주어야 합니다.
@Configuration
public class QueryDslConfig {
@PersistenceContext
private EntityManager entityManager;
@Bean
public JPAQueryFactory jpaQueryFactory() {
return new JPAQueryFactory(entityManager);
}
}
설정이 완료된 이후에는 각 Repository에서 생성자 주입(@RequiredArgsConstructor) 방식으로 JPAQueryFactory를 주입받아 유연하게 사용할 수 있게 됩니다.
3. 기본 사용: JOIN 쿼리
QueryDSL 설정을 마친 후 가장 먼저 해결한 것은 복잡한 다중 테이블 JOIN 로직이었습니다. 주문 데이터 조회 시 회원 정보, 카테고리 정보 등을 함께 묶어 가져와야 하는 상황에 적용했습니다.
List result = queryFactory
.selectFrom(order)
.innerJoin(customer).on(order.customerId.eq(customer.id))
.innerJoin(category).on(order.categoryId.eq(category.id))
.where(
order.userId.eq(userId),
order.shopId.eq(shopId)
)
.fetch();
QueryDSL의 innerJoin().on()을 활용하면 연관관계 매핑(FK 구조)이 맺어져 있지 않은 독립적인 테이블이라도 직접 조인 조건을 지정하여 유연하게 결합할 수 있습니다. 또한 where() 절 내부에 쉼표(,)로 나열된 여러 조건들은 자동으로 AND 조건으로 묶이기 때문에 가독성 측면에서도 괄호 지옥에 빠지지 않고 아주 깔끔하게 표현됩니다.
이 조인 쿼리 하나 덕분에 기존에 여러 번 잘게 쪼개어 호출되던 DB 왕복 횟수가 단 1회로 통합되었고, 메모리 위에서 Java 코드로 복잡하게 엮던 매핑 로직이 사라지면서 서버의 부담이 줄어들고 응답 속도가 극적으로 개선되었습니다.
4. 심화: 동적 쿼리 + 페이징 처리
실무 환경의 검색 기능은 키워드, 가입일, 상태값 등 다양한 검색 조건이 선택적으로 들어오는 '동적 쿼리'인 경우가 대부분입니다. 기존 JPQL에서는 if문으로 문자열을 더덕더덕 이어 붙여야 해서 가독성이 파괴되곤 했지만, QueryDSL은 BooleanExpression을 활용해 마치 레고 블록을 조립하듯 깔끔하게 처리할 수 있습니다.
JPAQuery query = queryFactory
.selectFrom(member)
.join(subscription).on(member.id.eq(subscription.memberId))
.where(
subscription.shopId.eq(shopId),
keywordContains(keyword), // returns null if empty, ignored by where clause
statusEq(status) // returns null if empty, ignored by where clause
)
.offset(pageable.getOffset())
.limit(pageable.getPageSize());
JPAQuery countQuery = queryFactory
.select(member.count()).from(member)
.join(subscription).on(member.id.eq(subscription.memberId))
.where(
subscription.shopId.eq(shopId),
keywordContains(keyword),
statusEq(status)
);
return PageableExecutionUtils.getPage(query.fetch(), pageable, countQuery::fetchOne);
private BooleanExpression keywordContains(String keyword) {
if (!StringUtils.hasText(keyword)) return null;
String cleaned = keyword.toLowerCase().replace(" ", "");
StringExpression expr = Expressions.stringTemplate(
"replace({0}, ' ', '')", member.name.lower()
);
return expr.contains(cleaned);
}
private BooleanExpression statusEq(MemberStatus status) {
return status != null ? member.status.eq(status) : null;
}
-
동적 쿼리와 최적화의 핵심 포인트
1. Null 자동 제거를 통한 가독성: where()절에 들어가는 헬퍼 메서드가 null을 반환하면 QueryDSL이 해당 조건을 자동으로 지워줍니다. 덕분에 조건 항목이 늘어나도 where 본문 구조를 깨끗하게 유지할 수 있습니다.
2. PageableExecutionUtils를 통한 Count 쿼리 최적화: 페이징 처리 시 무조건 count 쿼리를 날리는 것은 대용량 테이블에서 큰 부하입니다. PageableExecutionUtils.getPage()를 사용하면 첫 페이지이면서 데이터가 총 개수보다 적을 때, 혹은 마지막 페이지일 때 불필요한 count 쿼리를 생략해 주어 실무 성능 최적화에 매우 유용합니다.
3. Expressions.stringTemplate을 통한 DB 함수 제어: 요구사항 중 공백을 제거하고 대소문자를 무시한 정밀 검색이 필요했습니다. 애플리케이션 메모리로 데이터를 다 끌고 와 가공하는 대신, Expressions.stringTemplate을 이용해 DB 레벨의 REPLACE 및 LOWER 함수를 QueryDSL 안에서 안전하게 결합해 내며 복잡한 문자열 가공 요구사항까지 깔끔하게 처리할 수 있었습니다.
5. 마치며
초기 설정 과정에서는 Spring Boot 3.x 환경의 Jakarta 패키지 전환 이슈와 Gradle의 sourceSets 경로 인식 등 놓치기 쉬운 포인트들이 있어 몇 차례 시행착오를 겪었습니다. 하지만 오류를 직접 해결하며 QueryDSL의 동작 원리와 내장 구조를 깊이 있게 이해할 수 있는 좋은 계기가 되었습니다.
인프라가 세팅되고 나니 타입 세이프함이 주는 안정성, 런타임 에러가 아닌 컴파일 에러 단계에서 오타가 잡히는 안정감, 그리고 동적 쿼리의 압도적인 편리함 등 단순한 쿼리 빌더 이상의 가치를 체감할 수 있었습니다. 특히 조회 중심의 비즈니스 로직이 고도화될수록 QueryDSL은 Spring Data JPA의 한계를 메워주는 가장 강력하고 필수적인 무기라는 생각이 듭니다.
참고 문헌
• QueryDSL 공식 문서. http://querydsl.com/static/querydsl/5.0.0/reference/html_single/
• Spring Data JPA 공식 문서. https://docs.spring.io/spring-data/jpa/docs/current/reference/html/
• Spring Boot 3.x 공식 문서 – Jakarta EE Migration. https://docs.spring.io/spring-boot/docs/current/reference/html/
eunice