💡QueryDSL을 사용하는 이유가 뭘까??
우선 QueryDSL을 사용하는 이유를 알아보기 위해서는 Spring Data JPA와 비교를 해봐야합니다.
JPQL은 문자열로 작성되기 때문에 오타나 문법 오류를 컴파일 시점에 검출할 수 없고 이는 런타임 오류로 이어질 가능성이 높음.
*// JPQL 예시: 컴파일 시 오류 검출 불가*
String jpql = "SELECT m FROM Membr m WHERE m.username = :username"; *// 'Member' 오타*
List<Member> result = em.createQuery(jpql, Member.class)
.setParameter("username", "john")
.getResultList();
JPQL에서는 조건식을 재사용하기 어려워서 동일한 조건을 여러 쿼리에 반복적으로 작성해야 하며, 이로 인해서 코드 중복과 유지보수 비용 증가.
동적 검색 조건이 많아질 경우 JPQL은 코드가 복잡해지고 가독성이 떨어지고 조건에 따라 문자열을 조합하거나 if-else 문을 남발.
*// JPQL 동적 쿼리 예시*
String jpql = "SELECT m FROM Member m WHERE 1=1";
if (username != null) {
jpql += " AND m.username = :username";
}
if (age != null) {
jpql += " AND m.age > :age";
}
Query query = em.createQuery(jpql);
JPQL은 문자열 기반이므로 필드명 오타나 잘못된 타입 사용을 컴파일 시점에 검출할 수 없습니다.
QueryDSL은 Java 코드로 쿼리를 작성하므로, 필드명이나 메서드 호출에서 발생하는 오류를 컴파일 시점에 검출할 수 있습니다.
*// QueryDSL 예시: 타입 안전성과 오류 방지*
List<Member> result = queryFactory
.selectFrom(QMember.member)
.where(QMember.member.username.eq("john"))
.fetch();
*// QMember 클래스 예시 (자동 생성)*
public class QMember extends EntityPathBase<Member> {
public static final QMember member = new QMember("member");
public final StringPath username = createString("username");
public final NumberPath<Integer> age = createNumber("age", Integer.class);
...
}
StringPath와 NumberPath는 각각 문자열과 숫자 타입의 필드를 표현하기 위해 사용되는 클래스로 이들은 QueryDSL의 타입 안전성을 지원하는 중요한 요소로, 쿼리 작성 시 데이터 타입에 맞는 연산만 허용하여 오류를 방지함.member.age는 NumberPath<Integer> 타입으로 정의되어, 정수 비교만 허용..where(member.age.eq("20")) *// 컴파일 에러: String → Integer 타입 불일치*BooleanExpression을 사용하여 조건식을 메서드로 분리하고 재사용할 수 있습니다.
*// 조건식 메서드 분리*
private BooleanExpression usernameEq(String username) {
return username != null ? QMember.member.username.eq(username) : null;
}
private BooleanExpression ageGt(Integer age) {
return age != null ? QMember.member.age.gt(age) : null;
}
*// 재사용 가능한 쿼리*
List<Member> result = queryFactory
.selectFrom(QMember.member)
.where(usernameEq("john"), ageGt(20))
.fetch();
BooleanBuilder를 사용하여 조건을 유연하게 조합할 수 있습니다.
BooleanBuilder builder = new BooleanBuilder();
if (username != null) {
builder.and(QMember.member.username.eq(username));
}
if (age != null) {
builder.and(QMember.member.age.gt(age));
}
List<Member> result = queryFactory
.selectFrom(QMember.member)
.where(builder)
.fetch();
조건을 재사용 가능한 모듈로 만들어 쿼리를 구성하는 방식입니다.
장점: 코드 중복 방지, 가독성 향상, 타입 안전성 보장
*// 1. 조건을 레고 블록처럼 생성*
private BooleanExpression isAdult() {
return member.age.goe(20); *// 20세 이상*
}
private BooleanExpression isFromSeoul() {
return member.city.eq("서울");
}
private BooleanExpression hasEmail() {
return member.email.isNotNull();
}
*// 2. 블록 조립*
List<Member> result = queryFactory
.selectFrom(member)
.where(
isAdult(),
isFromSeoul(),
hasEmail()
)
.fetch();
실제 활용 사례
런타임에 동적으로 조건을 추가할 수 있는 빌더입니다.
장점: 유연한 조건 처리, 복잡한 분기 로직 수용
public List<Member> dynamicSearch(String name, Integer age, String city) {
BooleanBuilder builder = new BooleanBuilder();
*// 이름 검색 (부분 일치)*
if (StringUtils.hasText(name)) {
builder.and(member.name.contains(name));
}
*// 나이 범위 검색*
if (age != null) {
builder.and(member.age.between(age - 5, age + 5));
}
*// 지역 검색 (다중 선택 가능)*
if (StringUtils.hasText(city)) {
builder.and(member.city.in(city.split(",")));
}
return queryFactory
.selectFrom(member)
.where(builder)
.fetch();
}
실제 활용 사례
| 상황 | 추천 방식 | 예시 |
|---|---|---|
| 공통으로 재사용되는 조건 | BooleanExpression | 활성 사용자, 특정 권한 보유 |
| 3개 이상의 동적 조건 | BooleanBuilder | 관리자 검색 필터 |
| 간단한 정적 조건 | BooleanExpression | 나이 30세 이상 |
| 복잡한 분기 로직 | BooleanBuilder | A/B 테스트 그룹별 필터링 |
QueryDSL은 연관관계가 없는 테이블 간 조인이나 서브쿼리도 쉽게 처리할 수 있습니다.
*// 조인 예시*
List<Order> orders = queryFactory.selectFrom(QOrder.order)
.join(QOrder.order.member, QMember.member).fetchJoin()
.where(QMember.member.city.eq("Seoul"))
.fetch();
-> Order과 Member 엔티티를 조인하여 서울에 사는 회원의 주문 목록을 한 번의
쿼리로 효율적으로 조회하고 N+1 문제 방지를 위해 fetchJoin()을 사용해
연관된 Member 엔티티를 즉시 로딩합니다.
*// 서브쿼리 예시*
QMember subMember = new QMember("subMember");
List<Member> result = queryFactory.selectFrom(QMember.member)
.where(QMember.member.age.gt(
JPAExpressions.select(subMember.age.avg())
.from(subMember)
))
.fetch();
-> 전체 회원의 평균 나이보다 많은 회원을 조회하는 코드로
JPAEXpressions으로 서브쿼리를 생성해 복잡한 조건을 간결하게 표현합니다.
List<TripPlan> findByStatus(Status status);
@Query("SELECT tp FROM TripPlan tp WHERE tp.status = 'ONGOING'")
List<TripPlan> findAllOngoingTripPlans();
@Query("SELECT tp FROM TripPlan tp WHERE tp.status = 'ONGOING' OR (tp.status = 'COMPLETED' AND tp.tripPlanType = 'COURSE')")
List<TripPlan> findAllOngoingAndCompletedCourseTripPlans();
QueryDSL 도입
public interface TripPlanRepositoryCustom {
List<TripPlan> findAllOngoingAndCompletedCourseTripPlans();
}
public class TripPlanRepositoryImpl implements TripPlanRepositoryCustom {
private final JPAQueryFactory queryFactory;
public TripPlanRepositoryImpl(JPAQueryFactory queryFactory) {
this.queryFactory = queryFactory;
}
@Override
public List<TripPlan> findAllOngoingAndCompletedCourseTripPlans() {
QTripPlan tripPlan = QTripPlan.tripPlan;
*// 1. 조건 분리 (재사용 가능)*
BooleanExpression isOngoing = tripPlan.status.eq(Status.ONGOING);
BooleanExpression isCompletedCourse = tripPlan.status.eq(Status.COMPLETED)
.and(tripPlan.tripPlanType.eq(TripPlanType.COURSE));
*// 2. 조건 조합*
return queryFactory
.selectFrom(tripPlan)
.where(isOngoing.or(isCompletedCourse))
.fetch();
}
}
결론으로는 QueryDSL은 Spring Data JPA의 대체제가 아니라 함께 사용 가능한 보완재로 서로 부족한 점으로 상호보완하는 도구로 사용하면 좋을 거 같습니다.