🔍 QueryDSL 완벽 가이드
타입 안전한 자바 코드로 복잡한 쿼리를 작성하는 최고의 방법
💡 QueryDSL이란?
QueryDSL: 자바 코드로 타입 안전한(Type-Safe) 쿼리를 작성하는 프레임워크
핵심 개념
문자열 JPQL ❌ → 자바 코드 ✅
런타임 오류 ❌ → 컴파일 오류 ✅
오타 가능 ❌ → IDE 자동완성 ✅
간단한 비교
// ❌ JPQL (문자열 기반)
String jpql = "SELECT m FROM Member m WHERE m.age >= :age";
List<Member> result = em.createQuery(jpql, Member.class)
.setParameter("age", 20)
.getResultList();
// 오타 → 런타임 에러!
// ✅ QueryDSL (자바 코드)
QMember member = QMember.member;
List<Member> result = queryFactory
.selectFrom(member)
.where(member.age.goe(20)) // greater or equal
.fetch();
// 오타 → 컴파일 에러! (IDE에서 빨간 밑줄)
🎯 QueryDSL의 4가지 핵심 특징
1️⃣ 타입 안전성 (Type Safety)
// ❌ JPQL - 런타임 오류
String jpql = "SELECT m FROM Member m WHERE m.agee = 20"; // 오타!
// 실행하기 전까지 모름 → 프로덕션에서 터질 수 있음
// ✅ QueryDSL - 컴파일 오류
queryFactory
.selectFrom(member)
.where(member.agee.eq(20)) // 컴파일 에러! IDE가 즉시 알려줌
.fetch();
2️⃣ IDE 자동완성
QMember member = QMember.member;
queryFactory
.selectFrom(member)
.where(member. // <-- 여기서 Ctrl+Space
// age ✅
// name ✅
// email ✅
// ... 모든 필드 자동완성!
)
3️⃣ 객체 지향적 쿼리
// 메서드 체이닝으로 읽기 쉬운 코드
List<Member> result = queryFactory
.selectFrom(member)
.where(
member.age.between(20, 30),
member.status.eq(MemberStatus.ACTIVE)
)
.orderBy(member.name.asc())
.limit(10)
.fetch();
4️⃣ 동적 쿼리에 강력함
// ✅ 조건이 있을 때만 추가 (null 안전)
public List<Member> searchMembers(String name, Integer age, MemberStatus status) {
return queryFactory
.selectFrom(member)
.where(
nameEq(name), // null이면 무시
ageGoe(age), // null이면 무시
statusEq(status) // null이면 무시
)
.fetch();
}
private BooleanExpression nameEq(String name) {
return name != null ? member.name.eq(name) : null;
}
private BooleanExpression ageGoe(Integer age) {
return age != null ? member.age.goe(age) : null;
}
private BooleanExpression statusEq(MemberStatus status) {
return status != null ? member.status.eq(status) : null;
}
🛠️ QueryDSL 설정하기
1️⃣ Gradle 의존성 추가
// 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"
}
// Q타입 클래스 생성 위치 지정
def querydslDir = "$buildDir/generated/querydsl"
sourceSets {
main.java.srcDirs += [querydslDir]
}
tasks.withType(JavaCompile) {
options.generatedSourceOutputDirectory = file(querydslDir)
}
clean {
delete file(querydslDir)
}
2️⃣ Config 설정
@Configuration
public class QueryDslConfig {
@PersistenceContext
private EntityManager entityManager;
@Bean
public JPAQueryFactory jpaQueryFactory() {
return new JPAQueryFactory(entityManager);
}
}
3️⃣ Q타입 클래스 생성
# Gradle 빌드 실행
./gradlew clean build
# build/generated/querydsl 디렉토리에 Q타입 클래스 생성됨
# QMember.java
# QOrder.java
# QProduct.java
# ...
📝 실무 사용 사례
사례 1️⃣: 동적 검색 조건 (검색 필터)
@Repository
@RequiredArgsConstructor
public class MemberRepositoryImpl implements MemberRepositoryCustom {
private final JPAQueryFactory queryFactory;
@Override
public Page<Member> searchMembers(MemberSearchCondition condition, Pageable pageable) {
QMember member = QMember.member;
List<Member> content = queryFactory
.selectFrom(member)
.where(
nameContains(condition.getName()),
emailContains(condition.getEmail()),
ageBetween(condition.getMinAge(), condition.getMaxAge()),
statusEq(condition.getStatus()),
createdDateBetween(condition.getStartDate(), condition.getEndDate())
)
.orderBy(getOrderSpecifier(pageable.getSort()))
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();
Long total = queryFactory
.select(member.count())
.from(member)
.where(
nameContains(condition.getName()),
emailContains(condition.getEmail()),
ageBetween(condition.getMinAge(), condition.getMaxAge()),
statusEq(condition.getStatus()),
createdDateBetween(condition.getStartDate(), condition.getEndDate())
)
.fetchOne();
return new PageImpl<>(content, pageable, total);
}
// 조건 메서드들 (null 안전)
private BooleanExpression nameContains(String name) {
return hasText(name) ? member.name.contains(name) : null;
}
private BooleanExpression emailContains(String email) {
return hasText(email) ? member.email.contains(email) : null;
}
private BooleanExpression ageBetween(Integer minAge, Integer maxAge) {
if (minAge != null && maxAge != null) {
return member.age.between(minAge, maxAge);
}
if (minAge != null) {
return member.age.goe(minAge);
}
if (maxAge != null) {
return member.age.loe(maxAge);
}
return null;
}
private BooleanExpression statusEq(MemberStatus status) {
return status != null ? member.status.eq(status) : null;
}
private BooleanExpression createdDateBetween(LocalDate start, LocalDate end) {
if (start != null && end != null) {
return member.createdDate.between(
start.atStartOfDay(),
end.atTime(23, 59, 59)
);
}
return null;
}
}
사용:
@Service
@RequiredArgsConstructor
public class MemberService {
private final MemberRepository memberRepository;
public Page<MemberResponse> searchMembers(
MemberSearchCondition condition,
Pageable pageable
) {
Page<Member> members = memberRepository.searchMembers(condition, pageable);
return members.map(MemberResponse::from);
}
}
사례 2️⃣: 복잡한 조인 쿼리
@Repository
@RequiredArgsConstructor
public class OrderRepositoryImpl implements OrderRepositoryCustom {
private final JPAQueryFactory queryFactory;
@Override
public List<OrderDto> findOrdersWithMemberAndProduct() {
QOrder order = QOrder.order;
QMember member = QMember.member;
QOrderItem orderItem = QOrderItem.orderItem;
QProduct product = QProduct.product;
return queryFactory
.select(Projections.constructor(OrderDto.class,
order.id,
member.name,
order.orderDate,
order.status,
product.name,
orderItem.quantity,
orderItem.orderPrice
))
.from(order)
.join(order.member, member)
.join(order.orderItems, orderItem)
.join(orderItem.product, product)
.where(
order.status.eq(OrderStatus.COMPLETED),
order.orderDate.after(LocalDateTime.now().minusDays(30))
)
.orderBy(order.orderDate.desc())
.fetch();
}
}
사례 3️⃣: 집계 쿼리 (통계)
@Repository
@RequiredArgsConstructor
public class OrderStatisticsRepository {
private final JPAQueryFactory queryFactory;
public OrderStatistics getMonthlyStatistics(int year, int month) {
QOrder order = QOrder.order;
QOrderItem orderItem = QOrderItem.orderItem;
Tuple result = queryFactory
.select(
order.count(),
orderItem.orderPrice.sum(),
orderItem.orderPrice.avg(),
orderItem.orderPrice.max(),
orderItem.orderPrice.min()
)
.from(order)
.join(order.orderItems, orderItem)
.where(
order.orderDate.year().eq(year),
order.orderDate.month().eq(month),
order.status.eq(OrderStatus.COMPLETED)
)
.fetchOne();
return OrderStatistics.builder()
.totalOrders(result.get(order.count()))
.totalRevenue(result.get(orderItem.orderPrice.sum()))
.averageOrderValue(result.get(orderItem.orderPrice.avg()))
.maxOrderValue(result.get(orderItem.orderPrice.max()))
.minOrderValue(result.get(orderItem.orderPrice.min()))
.build();
}
public List<DailyStatistics> getDailyStatistics(LocalDate start, LocalDate end) {
QOrder order = QOrder.order;
return queryFactory
.select(Projections.constructor(DailyStatistics.class,
order.orderDate.yearMonth().stringValue(),
order.orderDate.dayOfMonth(),
order.count(),
order.totalPrice.sum()
))
.from(order)
.where(
order.orderDate.between(
start.atStartOfDay(),
end.atTime(23, 59, 59)
)
)
.groupBy(
order.orderDate.yearMonth(),
order.orderDate.dayOfMonth()
)
.orderBy(order.orderDate.asc())
.fetch();
}
}
사례 4️⃣: 서브쿼리
@Repository
@RequiredArgsConstructor
public class ProductRepository {
private final JPAQueryFactory queryFactory;
// 평균 가격보다 비싼 상품 조회
public List<Product> findProductsAboveAveragePrice() {
QProduct product = QProduct.product;
QProduct productSub = new QProduct("productSub");
return queryFactory
.selectFrom(product)
.where(
product.price.gt(
JPAExpressions
.select(productSub.price.avg())
.from(productSub)
)
)
.fetch();
}
// 주문이 한 번도 없는 상품
public List<Product> findNeverOrderedProducts() {
QProduct product = QProduct.product;
QOrderItem orderItem = QOrderItem.orderItem;
return queryFactory
.selectFrom(product)
.where(
product.id.notIn(
JPAExpressions
.select(orderItem.product.id)
.from(orderItem)
)
)
.fetch();
}
}
🆚 비교: JPQL vs QueryDSL vs Spring Data JPA
JPQL
// ❌ 문자열 기반 - 오타 위험
String jpql = """
SELECT m FROM Member m
JOIN FETCH m.orders o
WHERE m.age >= :age
AND m.status = :status
ORDER BY m.name ASC
""";
List<Member> members = em.createQuery(jpql, Member.class)
.setParameter("age", 20)
.setParameter("status", MemberStatus.ACTIVE)
.getResultList();
QueryDSL
// ✅ 자바 코드 - 타입 안전
QMember member = QMember.member;
QOrder order = QOrder.order;
List<Member> members = queryFactory
.selectFrom(member)
.join(member.orders, order).fetchJoin()
.where(
member.age.goe(20),
member.status.eq(MemberStatus.ACTIVE)
)
.orderBy(member.name.asc())
.fetch();
Spring Data JPA
// 🔶 단순한 경우에만 가능
public interface MemberRepository extends JpaRepository<Member, Long> {
// 메서드 이름으로 쿼리 생성 - 길어지면 복잡함
List<Member> findByAgeGreaterThanEqualAndStatus(
int age,
MemberStatus status
);
// @Query - JPQL 문자열 (오타 위험)
@Query("""
SELECT m FROM Member m
WHERE m.age >= :age AND m.status = :status
""")
List<Member> findMembers(
@Param("age") int age,
@Param("status") MemberStatus status
);
}
📊 언제 무엇을 사용할까?
| 상황 | 추천 도구 | 이유 |
|---|---|---|
| 단순 CRUD | Spring Data JPA | 코드 간결 |
| 단순 조회 (조건 1~2개) | Spring Data JPA | 메서드 이름으로 충분 |
| 복잡한 조회 (조건 3개↑) | QueryDSL | 동적 쿼리 우수 |
| 여러 테이블 조인 | QueryDSL | 가독성↑ |
| 집계/통계 쿼리 | QueryDSL | 타입 안전한 집계 |
| 관리자 검색 기능 | QueryDSL | 필터 조건 다수 |
| 네이티브 SQL 필요 | @Query(nativeQuery) | DB 전용 기능 |
🎯 QueryDSL 사용이 필수인 경우
1️⃣ 검색 조건이 많은 경우
// ❌ JPQL로 동적 쿼리 (if/else 지옥)
public List<Member> searchMembers(String name, Integer age, MemberStatus status) {
StringBuilder jpql = new StringBuilder("SELECT m FROM Member m WHERE 1=1");
if (name != null) {
jpql.append(" AND m.name = :name");
}
if (age != null) {
jpql.append(" AND m.age >= :age");
}
if (status != null) {
jpql.append(" AND m.status = :status");
}
TypedQuery<Member> query = em.createQuery(jpql.toString(), Member.class);
if (name != null) query.setParameter("name", name);
if (age != null) query.setParameter("age", age);
if (status != null) query.setParameter("status", status);
return query.getResultList();
}
// ✅ QueryDSL (깔끔!)
public List<Member> searchMembers(String name, Integer age, MemberStatus status) {
return queryFactory
.selectFrom(member)
.where(
nameEq(name),
ageGoe(age),
statusEq(status)
)
.fetch();
}
2️⃣ 관리자 페이지 / 대시보드
실무 필터 조건 예시:
@Data
public class ProductSearchCondition {
private String name; // 상품명
private String category; // 카테고리
private Integer minPrice; // 최소 가격
private Integer maxPrice; // 최대 가격
private ProductStatus status; // 판매 상태
private LocalDate startDate; // 등록일 시작
private LocalDate endDate; // 등록일 종료
private String manufacturer; // 제조사
private Boolean inStock; // 재고 여부
}
// QueryDSL로 모든 조건 우아하게 처리
public Page<Product> searchProducts(
ProductSearchCondition condition,
Pageable pageable
) {
return queryFactory
.selectFrom(product)
.where(
nameContains(condition.getName()),
categoryEq(condition.getCategory()),
priceBetween(condition.getMinPrice(), condition.getMaxPrice()),
statusEq(condition.getStatus()),
registeredDateBetween(condition.getStartDate(), condition.getEndDate()),
manufacturerEq(condition.getManufacturer()),
inStockEq(condition.getInStock())
)
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();
}
3️⃣ 대규모·장기 운영 프로젝트
// 엔티티 필드명 변경 시
// Before
private String userName;
// After
private String name;
// ❌ JPQL: 모든 문자열을 수동으로 찾아서 수정
"SELECT m FROM Member m WHERE m.userName = :name" // 오타 위험!
// ✅ QueryDSL: 자동으로 컴파일 에러 발생
member.userName // IDE가 즉시 에러 표시
member.name // 자동완성으로 수정
⚡ QueryDSL 성능 최적화 팁
1️⃣ Fetch Join으로 N+1 방지
// ❌ N+1 문제 발생
List<Member> members = queryFactory
.selectFrom(member)
.fetch();
for (Member m : members) {
m.getOrders().size(); // 각 Member마다 쿼리 실행!
}
// ✅ Fetch Join 사용
List<Member> members = queryFactory
.selectFrom(member)
.join(member.orders, order).fetchJoin()
.fetch();
// 단 1번의 쿼리로 해결!
2️⃣ DTO로 직접 조회 (필요한 컬럼만)
// ❌ 엔티티 전체 조회 (불필요한 컬럼도 포함)
List<Member> members = queryFactory
.selectFrom(member)
.fetch();
// ✅ DTO Projection (필요한 컬럼만)
List<MemberDto> members = queryFactory
.select(Projections.constructor(MemberDto.class,
member.id,
member.name,
member.email
))
.from(member)
.fetch();
3️⃣ Count 쿼리 최적화
// ❌ 전체 데이터 조회 후 count (비효율)
List<Member> content = queryFactory
.selectFrom(member)
.fetch();
long total = content.size();
// ✅ count 쿼리 분리
List<Member> content = queryFactory
.selectFrom(member)
.offset(0)
.limit(10)
.fetch();
Long total = queryFactory
.select(member.count())
.from(member)
.fetchOne();
🚫 QueryDSL이 필요 없는 경우
❌ 단순 CRUD만 있는 프로젝트
// Spring Data JPA로 충분
public interface MemberRepository extends JpaRepository<Member, Long> {
Optional<Member> findByEmail(String email);
List<Member> findByStatus(MemberStatus status);
}
❌ 조건 없는 단순 조회
// 복잡도가 낮으면 Spring Data JPA가 더 간단
List<Member> findAll();
Optional<Member> findById(Long id);
❌ 아주 작은 토이 프로젝트
- 설정 비용 > 얻는 이익
- 학습 비용 고려
📚 실무 패턴: Custom Repository
Repository 구조
// 1. 기본 Repository
public interface MemberRepository
extends JpaRepository<Member, Long>, MemberRepositoryCustom {
// 단순 쿼리는 Spring Data JPA
Optional<Member> findByEmail(String email);
}
// 2. Custom Interface
public interface MemberRepositoryCustom {
Page<Member> searchMembers(MemberSearchCondition condition, Pageable pageable);
List<MemberStatistics> getMonthlyStatistics(int year, int month);
}
// 3. QueryDSL 구현체
@Repository
@RequiredArgsConstructor
public class MemberRepositoryImpl implements MemberRepositoryCustom {
private final JPAQueryFactory queryFactory;
@Override
public Page<Member> searchMembers(
MemberSearchCondition condition,
Pageable pageable
) {
// QueryDSL 구현
QMember member = QMember.member;
List<Member> content = queryFactory
.selectFrom(member)
.where(/* 동적 조건 */)
.fetch();
// ...
}
}
🎯 핵심 요약
| 항목 | 내용 |
|---|---|
| 정의 | 타입 안전한 자바 코드 기반 쿼리 빌더 |
| 핵심 장점 | 컴파일 오류 검출, IDE 자동완성, 동적 쿼리 |
| 주요 사용처 | 검색 필터, 관리자 페이지, 복잡한 조회 |
| 권장 조합 | Spring Data JPA (단순) + QueryDSL (복잡) |
| 대체 기술 | JPQL, Criteria API, MyBatis |
💡 한 줄 요약
QueryDSL은 타입 안전한 자바 코드로 동적이고 복잡한 쿼리를 작성하기 위해 사용되며, 검색 조건이 많은 실무 프로젝트에서 필수입니다.
🚀 학습 로드맵
1단계: 기본 문법 익히기
├─ select, from, where
├─ join, fetchJoin
└─ orderBy, limit, offset
2단계: 동적 쿼리 마스터
├─ BooleanExpression
├─ BooleanBuilder
└─ null 안전 처리
3단계: 고급 기능
├─ Projection (DTO 조회)
├─ 서브쿼리
└─ 집계 함수
4단계: 실무 적용
├─ Custom Repository 패턴
├─ 페이징 처리
└─ N+1 문제 해결
📌 추가 자료
공식 문서: http://www.querydsl.com/ 실무 예제: Spring Data JPA + QueryDSL 조합 패턴
Backend 개발자라면 QueryDSL은 필수 스킬입니다! 동적 쿼리가 많은 실무에서 생산성을 획기적으로 높여줍니다. 💪