이번 포스팅에서는 QueryDSL의 동작 원리와 기본적인 사용법에 대해 알아본다.
“자바 코드로 쿼리를 작성한다.”
JpaRepository 를 상속 받아 기본 CRUD 기능을 제공 받는다.UserCustomRepository 를 상속 받아 사용자 정의 메소드를 포함한다.UserCustomRepository 인터페이스를 구현하며, 실제 QueryDSL 쿼리 로직을 작성하는 곳이다.UserRepository 가 가지지 못한(자동으로 구현하지 못하는) 기능을 담는 실제 구현체이다.UserRepository 인터페이스를 구현하는 프록시(Proxy) 객체를 자동으로 생성한다.JpaRepository 메소드들은 직접 처리하고, UserCustomRepository 에서 상속 받은 메소드들은 이름 규칙에 따라 찾은 UserCustomeRepositoryImpl 인스턴스에 위임하여 실행한다.UserRepository 가 JpaRepository 를 상속 받는 순간, Spring Data JPA는 런타임에 이 인터페이스를 구현하는 프록시 클래스를 자동으로 만들어서 빈으로 등록한다.UserCustomRepository 는 개발자가 구현해야 한다.EntityManager를 주입받아 생성하고, 이 EntityManager 를 통해 실제 DB와의 통신을 수행한다.package org.example.plus.common.entity;
import static com.querydsl.core.types.PathMetadataFactory.*;
import com.querydsl.core.types.dsl.*;
import com.querydsl.core.types.PathMetadata;
import javax.annotation.processing.Generated;
import com.querydsl.core.types.Path;
/**
* QUser is a Querydsl query type for User
*/
@Generated("com.querydsl.codegen.DefaultEntitySerializer")
public class QUser extends EntityPathBase<User> {
private static final long serialVersionUID = 1394218201L;
public static final QUser user = new QUser("user");
public final StringPath email = createString("email");
public final NumberPath<Long> id = createNumber("id", Long.class);
public final StringPath password = createString("password");
public final EnumPath<org.example.plus.common.enums.UserRoleEnum> roleEnum = createEnum("roleEnum", org.example.plus.common.enums.UserRoleEnum.class);
public final StringPath username = createString("username");
public QUser(String variable) {
super(User.class, forVariable(variable));
}
public QUser(Path<? extends User> path) {
super(path.getType(), path.getMetadata());
}
public QUser(PathMetadata metadata) {
super(User.class, metadata);
}
}
| QueryDSL 타입 | 설명 | 사용 예시 |
|---|---|---|
| StringPath | 문자열 필드 | QUser.user.name.eq(”Jane”) |
| NumberPath | 숫자 필드(Integer, Double, etc.) | QUser.user.age.gt(18) |
| BooleanPath | Boolean 타입 필드 | QUser.user.active.isTrue() |
| DatePath | java.sql.Date 타입 날짜 필드 | QOrder.order.date.eq(LocalDate.of(2025, 12, 25)) |
| DateTimePath | java.time.LocalDateTime 등의 필드 | QOrder.order.createdAt.before(LocalDateTime.now()) |
| TimePath | java.sql.Time 타입 필드 | QEvent.event.startTime.after(LocalTime.of(14, 0)) |
| SimplePath | 임의의 객체 타입 필드 | QUser.user.customField.eq(customObject) |
| EnumPath | Enum 타입 필드 | QUser.user.role.eq(Role.ADMIN) |
| ComparablePath | Comparable 인터페이스를 구현한 객체 | QUser.user.someComparableField.between(a, b) |
| BeanPath | Java Bean 객체 타입 | QUser.user.address.city.eq(”Seoul”) |
| ArrayPath<T, A> | 배열 필드 (T[] 타입) | QUser.user.tags.contains(”Spring”) |
| CollectionPath<E, Q> | Collection 타입 필드 | QUser.user.roles.contains(Role.ADMIN) |
| SetPath<E, Q> | Set 타입 필드 | QUser.user.permissions.contains(Permission.READ) |
| ListPath<E, Q> | List 타입 필드 | QUser.user.friends.contains(QUser.user) |
| MapPath<K, V, Q> | Map<K, V> 타입 필드 | QUser.user.attributes.get(”nickname”).eq(”Jane”) |
| 메소드 | 설명 |
|---|---|
| fetchOne() | 단일 결과 조회 (없으면 null, 여러 개면 예외) |
| fetchFirst() | 첫 번째 결과만 조회 |
| fetch() | 리스트 조회 (비어 있으면 빈 리스트) |
| count(), sum(), avg(), max(), min() | 집계 함수 |
| groupBy(), having() | 그룹화 및 조건 |
| orderBy() | 정렬 |
| limit(), offset() | 페이징 |
| eq(”A”), ne(”A”) | Equal (=), Not Equal (!=) |
| goe(10), loe(10) | Greater Or Equal (>= 10), Less Or Equal (<= 10) |
| gt(10), lt(10) | Greater Than (> 10), Less Than (< 10) |
| like(”A%”) | Like (LIKE ‘A%’) |
| contains(”A”) | Like (LIKE ‘%A%’) |
| in(”A”, “B”) | IN (’A’, ‘B’) |
| between(10, 20) | Between (BETWEEN 10 AND 20) |
QueryDSL의 꽃인 BooleanBuilder와 BooleanExpression 에 대해 알아보자.
이들은 “검색 조건이 있을 수도 있고, 없을 수도 있을 때 사용한다.”
있을 수도 있고, 없을 수도 있다는 것이 "동적 쿼리"를 뜻한다.
StringBuilder를 쓰는 것과 같다.BooleanBuilder condBuilder = new BooleanBuilder();
if (username != null) {
condBuilder.and(user.username.eq(username));
}
if (age != null) {
condBuilder.and(user.age.eq(age));
}
...
// .where(condBuilder)로 사용
// 메인 비즈니스 로직
public List<UserSearchResponse> search(String usernameCond, Integer ageCond) {
return queryFactory
.selectFrom(user)
.where(
usernameEq(usernameCond), // 콤마(,)는 AND로 동작
ageEq(ageCond))
.fetch();
}
// 조건 메소드 추출
private BooleanExpression usernameEq(String usernameCond) {
return usernameCond != null ? user.username.eq(usernameCond) : null;
}
private BooleanExpression ageEq(Integer ageCond) {
return ageCond != null ? user.age.eq(ageCond) : null;
}
✅ BooleanExpression 을 사용하면 isValid() 처럼 여러 조건을 조립(and(), or()) 하여 새로운 의미 있는 조건을 만들 수 있다.
select 절에 무엇을 가져올지 지정하는 행위이다.select(user))select(user.username))Tuple 타입으로 반환된다.| Projections 메소드 | 동작 방식 | 특징 |
|---|---|---|
| Projections.bean(Class) | Setter 기반 | 기본 생성자 필요, Setter를 통해 값 주입 |
| Projections.fields(Class) | 필드 직접 접근 기반 | 기본 생성자 필요, 리플렉션을 통해 필드에 바로 값 주입 (Setter 없어도 됨) |
| Projections.constructor(Class, DTO 생성에 필요한 값) | 생성자 기반 | 파라미터 타입과 순서가 정확히 일치해야 함 |
| @QueryProjection | 컴파일러 기반 (가장 안전) | DTO 클래스의 생성자에 @QueryProjection 어노테이션 사용. Q-DTO를 생성하여 타입 불일치 오류를 컴파일 시점에 잡아주므로 가장 추천되는 방식. 그러나 QueryDSL에 종속됨(해당 DTO가 QueryDSL 전용 DTO가 됨) |
Projections.constructor 는 QueryDSL의 의존성 없는 DTO를 사용할 수 있고, 기본 생성자가 필요 없으며, 필드의 순서와 타입이 일치하면 사용할 수 있다. 하지만 런타임 오류 발생 가능성이 있다.@QueryProjection 은 타입 안정성을 보장하며, 컴파일 타입 검증이 가능해 유지보수성이 높지만, QueryDSL에 대한 의존성이 추가되므로 코드 생성 및 설정이 필요하다.username 과 age 만 뽑아서 UserDto로 즉시 반환한다.id, username, ageid, name, userIdpublic List<UserDto> search(SearchRequest request) {
return queryFactory
.select(Projections.constructor(UserDto.class,
user.username,
user.age))
.from(user)
.join(team).on(user.id.eq(team.userId))
.where(
team.name.eq("TeamA"),
nameContains(request.getUsername()),
ageEq(request.getAge()))
.orderBy(user.age.desc())
.fetch();
}
public BooleanExpression nameContains(String nameCond) {
return nameCond != null ? user.username.contains(nameCond) : null;
}
public BooleanExpression ageEq(Integer ageCond) {
return ageCond != null ? user.age.eq(ageCond) : null;
}
return queryFactory
.select(
member.username,
member.age,
// 여기서 stringTemplate을 사용하여 윈도우 함수를 호출
numberTemplate(
Integer.class, // 반환 타입 지정
"row_number() over (partition by {0} order by {1} desc)",
QTeam.team.name, // 첫 번째 Placeholder {0}에 들어갈 값 (Partition By)
member.age // 두 번째 Placeholder {1}에 들어갈 값 (Order By)
).as("team_rank")
)
.from(member)
.join(member.team, QTeam.team)
.fetch();
CASE 문을 QueryDSL로 작성할 수 있다.return queryFactory
.select(member.age,
Expressions.cases()
.when(member.age.goe(60)).then("노인")
.when(member.age.goe(30)).then("성인")
.otherwise("어린이"))
.from(member)
.fetch();
흡족🥕