자바 백엔드 입문 50편. JPQL 문자열의 함정을 자바 코드로 풀어내는 QueryDSL의 타입 안전 동적 쿼리 표준 패턴을 풀어쓴 학습 노트.
이 글은 자바 백엔드 입문 시리즈 59편 중 50편이에요. 46편 쿼리 메서드 + 49편 JPA 관계 까지 다루면 — "이름만으로 쿼리 자동 생성" 의 한계가 등장. 검색 조건이 10개+ 인 화면, 어떤 조건이 있고 없는지 동적인 쿼리 — 이때 등장하는 QueryDSL 을 풀어 가요.
JPQL 문자열의 함정
복잡한 검색 쿼리를 JPQL로 짜면:
@Query("""
SELECT o FROM Order o
WHERE (:status IS NULL OR o.status = :status)
AND (:startDate IS NULL OR o.createdAt >= :startDate)
AND (:endDate IS NULL OR o.createdAt <= :endDate)
AND (:minAmount IS NULL OR o.amount >= :minAmount)
AND (:userId IS NULL OR o.user.id = :userId)
ORDER BY o.createdAt DESC
""")
List<Order> search(@Param("status") String status,
@Param("startDate") LocalDateTime startDate,
@Param("endDate") LocalDateTime endDate,
@Param("minAmount") Long minAmount,
@Param("userId") Long userId);
문제:
- 문자열 — IDE 오타 검사 X, 컬럼명 바꾸면 런타임에 폭발
- 모든 조건 OR 패턴 — 6필드면 6개의 IS NULL OR ...
- 정렬 동적 변경 = 거의 불가
- 페이징도 별도 처리
- 검색 화면 하나 만들기에 200줄
해결 = QueryDSL. 자바 코드로 SQL 짜는 라이브러리.
QueryDSL — 자바로 SQL 짜기
List<Order> orders = queryFactory
.selectFrom(order)
.where(
statusEq(status),
createdAtBetween(startDate, endDate),
amountGoe(minAmount),
userIdEq(userId))
.orderBy(order.createdAt.desc())
.fetch();
깔끔하지 않나요? where 안의 매개변수가 null이면 자동으로 무시 — "동적 조건" 이 자연스럽게 표현.
설정 — Spring Boot 3 + 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"
}
10편 Maven·Gradle 의 annotationProcessor 가 빌드 시 Q 클래스 자동 생성.
./gradlew compileJava → build/generated/sources/annotationProcessor/ 에 QOrder·QUser 같은 "메타 클래스" 생성.
Q 클래스 — 컴파일 타임 타입 안전
엔티티마다 자동 생성되는 Q 클래스:
// 자동 생성
public class QOrder extends EntityPathBase<Order> {
public static final QOrder order = new QOrder("order");
public final NumberPath<Long> id = createNumber("id", Long.class);
public final StringPath status = createString("status");
public final DateTimePath<LocalDateTime> createdAt = createDateTime("createdAt", LocalDateTime.class);
public final NumberPath<Long> amount = createNumber("amount", Long.class);
public final QUser user;
}
이 Q 클래스로 — order.status·order.createdAt 같은 표현이 "엔티티 필드를 자바 코드로 참조". IDE 자동완성·타입 검사 모두 작동.
빈 등록 — JPAQueryFactory
@Configuration
public class QueryDslConfig {
@PersistenceContext
private EntityManager em;
@Bean
public JPAQueryFactory queryFactory() {
return new JPAQueryFactory(em);
}
}
또는 클래스 안에서 직접:
@Repository
@RequiredArgsConstructor
public class OrderQueryRepository {
private final JPAQueryFactory queryFactory;
public List<Order> search(...) {
return queryFactory.selectFrom(order).fetch();
}
}
기본 쿼리 — select·from·where·orderBy
import static com.example.QOrder.order;
public List<Order> findActiveOrders() {
return queryFactory
.selectFrom(order)
.where(order.status.eq("ACTIVE"))
.orderBy(order.createdAt.desc())
.fetch();
}
JPQL과 비슷한 구조지만 — "문자열이 자바 표현식" 으로.
자주 쓰는 조건 표현
| 표현 | SQL |
|---|---|
order.status.eq("ACTIVE") |
status = 'ACTIVE' |
order.status.ne("CANCELED") |
status <> 'CANCELED' |
order.amount.goe(1000) |
amount >= 1000 |
order.amount.lt(5000) |
amount < 5000 |
order.amount.between(1000, 5000) |
BETWEEN ... |
order.status.in("A", "B") |
IN ('A', 'B') |
order.createdAt.after(date) |
> date |
order.name.contains("foo") |
LIKE '%foo%' |
order.name.startsWith("A") |
LIKE 'A%' |
order.deletedAt.isNull() |
IS NULL |
condition1.and(condition2) |
AND |
condition1.or(condition2) |
OR |
자바 메서드 체이닝 — IDE 자동완성·타입 검사 다 작동.
동적 쿼리 — 진짜 매력
QueryDSL의 진수 — null이면 자동 무시되는 동적 조건.
public List<Order> search(String status, LocalDateTime startDate, Long userId) {
return queryFactory
.selectFrom(order)
.where(
statusEq(status), // null이면 무시
createdAtAfter(startDate), // null이면 무시
userIdEq(userId)) // null이면 무시
.orderBy(order.createdAt.desc())
.fetch();
}
private BooleanExpression statusEq(String status) {
return status == null ? null : order.status.eq(status);
}
private BooleanExpression createdAtAfter(LocalDateTime dt) {
return dt == null ? null : order.createdAt.after(dt);
}
private BooleanExpression userIdEq(Long userId) {
return userId == null ? null : order.user.id.eq(userId);
}
.where(...) 안에 null을 박으면 — QueryDSL이 자동으로 조건에서 제외. 검색 화면 백엔드의 한국 회사 표준 패턴.
BooleanBuilder — 옛 스타일
위 패턴 이전엔 BooleanBuilder 도 자주 사용:
BooleanBuilder builder = new BooleanBuilder();
if (status != null) builder.and(order.status.eq(status));
if (startDate != null) builder.and(order.createdAt.after(startDate));
if (userId != null) builder.and(order.user.id.eq(userId));
return queryFactory.selectFrom(order).where(builder).fetch();
작동은 하지만 — null 자동 무시 패턴이 더 깔끔. 신규는 후자 권장.
DTO 직접 조회 — Projection
엔티티 전체 가져오지 않고 — "필요한 컬럼만" DTO로.
public record OrderSummary(Long id, String status, Long amount) { }
public List<OrderSummary> findSummaries() {
return queryFactory
.select(Projections.constructor(OrderSummary.class,
order.id, order.status, order.amount))
.from(order)
.fetch();
}
Projections.constructor — DTO 생성자 호출. 자바 17+ record와 좋은 조합.
JOIN — 깔끔한 표현
import static com.example.QOrder.order;
import static com.example.QUser.user;
public List<Order> findWithUser() {
return queryFactory
.selectFrom(order)
.join(order.user, user).fetchJoin() // ← Lazy 회피 (N+1 방지)
.where(user.status.eq("ACTIVE"))
.fetch();
}
47편 영속성 컨텍스트 의 N+1 문제 — fetchJoin() 으로 해결.
페이징 — Spring Data 연동
public Page<Order> search(SearchCondition cond, Pageable pageable) {
List<Order> content = queryFactory
.selectFrom(order)
.where(buildWhere(cond))
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.orderBy(order.createdAt.desc())
.fetch();
Long total = queryFactory
.select(order.count())
.from(order)
.where(buildWhere(cond))
.fetchOne();
return new PageImpl<>(content, pageable, total);
}
검색 화면 페이징 표준 패턴.
동적 정렬
public List<Order> search(SearchCondition cond, String sortBy) {
OrderSpecifier<?> orderSpec = "amount".equals(sortBy)
? order.amount.desc()
: order.createdAt.desc();
return queryFactory
.selectFrom(order)
.where(buildWhere(cond))
.orderBy(orderSpec)
.fetch();
}
JPQL로는 거의 불가능한 동적 정렬 — QueryDSL은 자연스러움.
Spring Data JPA + QueryDSL 통합
JPA Repository에 QueryDSL 메서드를 끼우는 표준 패턴.
public interface OrderRepository extends JpaRepository<Order, Long>, OrderQueryRepository {
// 기본 메서드 + 쿼리 메서드 + QueryDSL 메서드
}
public interface OrderQueryRepository {
List<Order> search(SearchCondition cond);
}
@Repository
@RequiredArgsConstructor
public class OrderQueryRepositoryImpl implements OrderQueryRepository {
private final JPAQueryFactory queryFactory;
@Override
public List<Order> search(SearchCondition cond) {
return queryFactory.selectFrom(order)
.where(buildWhere(cond))
.fetch();
}
}
이름Impl 규칙으로 Spring Data가 자동 연결. 한국 회사 모든 신규 프로젝트의 표준 구조.
함정 5가지
(1) Q 클래스 빌드 안 됨
QOrder cannot be resolved to a type
annotationProcessor 빠짐 또는 빌드 안 함. ./gradlew compileJava 한 번 돌리면 해결.
(2) select(...) 빠뜨림
queryFactory.selectFrom(order)... // ✅ Order 전체
queryFactory.select(order)... // ⚠️ from 누락 (가능은 하지만 .from 필수)
selectFrom(...) 한 줄이 select + from 단축. 헷갈리기 쉬움.
(3) fetch() 누락
List<Order> orders = queryFactory.selectFrom(order).where(...); // ❌ 컴파일 에러 또는 JPAQuery 반환
.fetch() 호출해야 비로소 결과 얻음. JPQL의 getResultList() 와 비슷.
(4) fetchOne() vs fetchFirst()
fetchOne()— 결과가 2개 이상이면NonUniqueResultExceptionfetchFirst()— 첫 결과만, 안전
단건 조회 의도면 — fetchOne() (검증 보너스). "있을 수도 없을 수도" 면 — Optional 로 감싸기.
(5) N+1 발생
List<Order> orders = queryFactory.selectFrom(order).fetch();
orders.forEach(o -> log.info(o.getUser().getName())); // ← user 매 건 조회 (N+1!)
fetchJoin() 박기. 47편 영속성 컨텍스트 참고.
검색 화면 백엔드 = QueryDSL 거의 100%. JPA 쿼리 메서드는 단순 조회, JPQL은 가끔, QueryDSL은 복잡한 동적 쿼리. 자바 백엔드 면접에서 가장 자주 묻는 도구.
한 줄 정리 — QueryDSL = 타입 안전 동적 쿼리 라이브러리. annotationProcessor가 Q 클래스 자동 생성. .where(...) 안 null 자동 무시 패턴이 한국 회사 검색 백엔드 표준. Spring Data JPA + QueryDSL 통합 = 모던 자바 백엔드 사실상 표준.
시험 직전 한 번 더 — QueryDSL 입문자가 매번 헷갈리는 것
- QueryDSL = 자바 코드로 SQL 짜기 라이브러리
- 타입 안전 — 컴파일 시점 검증, IDE 자동완성
- 의존성 =
querydsl-jpa:jakarta+ annotationProcessor - Q 클래스 = 엔티티마다 자동 생성 (
QOrder·QUser) - 빌드 =
./gradlew compileJava→build/generated/sources/... - JPAQueryFactory = 쿼리 시작점 빈
- 기본 =
selectFrom(...).where(...).orderBy(...).fetch() selectFrom(order)=select + from단축- 조건 표현 =
.eq·.ne·.goe·.lt·.between·.in·.contains등 풍부 - 동적 조건 =
.where(null)자동 무시 (한국 회사 표준 패턴) - BooleanExpression 메서드 분리 =
private BooleanExpression statusEq(...)패턴 - BooleanBuilder = 옛 스타일 (신규는 null 무시 패턴 권장)
- Projection =
Projections.constructor(DTO.class, ...)— DTO 직접 조회 - record + Projection 조합 = 모던 표준
- fetchJoin() = N+1 회피 (Lazy 즉시 로드)
- 페이징 =
.offset(...).limit(...)+ 카운트 쿼리 별도 - 동적 정렬 =
OrderSpecifier<?>변수로 분기 - Spring Data 통합 =
OrderRepository extends JpaRepository<Order, Long>, OrderQueryRepository - 구현체 =
OrderQueryRepositoryImpl("Impl" 규칙) fetch()= 리스트,fetchOne()= 단건 (2개+ 시 예외),fetchFirst()= 첫 건- 검색 화면 백엔드 = QueryDSL 거의 100%
- 자바 백엔드 면접 빈출 도구
시리즈 다른 편 (앞뒤 글 모음)
이전 글:
- 45편 — @Entity Repository JPA 두 축
- 46편 — JPA 연관관계 @OneToMany @ManyToOne
- 47편 — JPA @Embedded @Embeddable 값 객체
- 48편 — JPA Auditing @CreatedDate @LastModifiedDate
- 49편 — JPA 메서드 이름 쿼리 @Query
다음 글: