QueryDSL 동작 원리와 사용법

Jiwon Jung·2025년 12월 17일

Spring

목록 보기
21/22

이번 포스팅에서는 QueryDSL의 동작 원리와 기본적인 사용법에 대해 알아본다.

🌱 QueryDSL의 핵심

“자바 코드로 쿼리를 작성한다.”


🌱 QueryDSL 사용 시 Repository 구조 파헤치기

🥕 UserRepository

  • JpaRepository 를 상속 받아 기본 CRUD 기능을 제공 받는다.
  • UserCustomRepository 를 상속 받아 사용자 정의 메소드를 포함한다.

🥕 UserCustomRepository

  • QueryDSL로 구현할 사용자 정의 메소드의 선언부만 정의한다.

🥕 UserCustomRepositoryImpl

  • UserCustomRepository 인터페이스를 구현하며, 실제 QueryDSL 쿼리 로직을 작성하는 곳이다.
  • UserRepository 가 가지지 못한(자동으로 구현하지 못하는) 기능을 담는 실제 구현체이다.

🌱 Spring Data JPA와 QueryDSL의 동작 방식

🥕 Spring Data JPA

  • Spring Data JPA는 런타임 시 UserRepository 인터페이스를 구현하는 프록시(Proxy) 객체를 자동으로 생성한다.
  • 이 프록시 객체는 JpaRepository 메소드들은 직접 처리하고, UserCustomRepository 에서 상속 받은 메소드들은 이름 규칙에 따라 찾은 UserCustomeRepositoryImpl 인스턴스에 위임하여 실행한다.
  • UserRepositoryJpaRepository 를 상속 받는 순간, Spring Data JPA는 런타임에 이 인터페이스를 구현하는 프록시 클래스를 자동으로 만들어서 빈으로 등록한다.

🥕 CustomRepository와 CustomRepositoryImpl

  • 그러나 UserCustomRepository 는 개발자가 구현해야 한다.
  • 이 인터페이스에 정의된 메소드들은 Spring이 자동으로 쿼리를 생성하기에는 너무 복잡하다.
  • 이들은 보통 QueryDSL 같은 도구를 사용해 개발자가 직접 동적 쿼리 로직을 짜야 한다.

🌱 Q-Class

  • Q-Class는 QueryDSL을 사용할 시 자동 생성되는 Entity 기반 정적 메타 모델 클래스이다.
  • JPA Entity를 기반으로 동적으로 쿼리를 작성할 수 있도록 지원한다.
  • JPAQueryFactory와 함께 사용하면 가독성이 좋고 직관적인 쿼리 작성이 가능하다.
  • 보통 static import 해서 사용한다.

🌱 JPAQueryFactory

  • 쿼리를 시작하는 진입점이고, 실제 쿼리 실행을 담당하는 엔진이다.
  • QueryDSL에서 JPA 기반 쿼리를 쉽게 생성할 수 있도록 도와주는 객체이다.
  • JPQL이나 Criteria API 보다 가독성이 좋고 타입 안정성이 보장된다.
  • Entity 기반으로 동작하며, Q-Class 기반의 타입 안정성을 보장한다.
  • 동적 쿼리 작성을 가능하게 하고, 다양한 메소드를 지원한다.
  • EntityManager를 주입받아 생성하고, 이 EntityManager 를 통해 실제 DB와의 통신을 수행한다.

🌱 Path 클래스 메소드

  • Q-Class 내부에서 Entity의 필드들을 표현하는 경로 객체이다.
  • 즉, Q-Class가 Entity 전체를 대표하는 클래스라면, Path 클래스는 그 안에 있는 필드들을 타입에 맞게 다루는 객체이다.

🌱 Q-Class 예시

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);
    }

}

🌱 Path 타입 종류와 사용법

QueryDSL 타입설명사용 예시
StringPath문자열 필드QUser.user.name.eq(”Jane”)
NumberPath숫자 필드(Integer, Double, etc.)QUser.user.age.gt(18)
BooleanPathBoolean 타입 필드QUser.user.active.isTrue()
DatePathjava.sql.Date 타입 날짜 필드QOrder.order.date.eq(LocalDate.of(2025, 12, 25))
DateTimePathjava.time.LocalDateTime 등의 필드QOrder.order.createdAt.before(LocalDateTime.now())
TimePathjava.sql.Time 타입 필드QEvent.event.startTime.after(LocalTime.of(14, 0))
SimplePath임의의 객체 타입 필드QUser.user.customField.eq(customObject)
EnumPathEnum 타입 필드QUser.user.role.eq(Role.ADMIN)
ComparablePathComparable 인터페이스를 구현한 객체QUser.user.someComparableField.between(a, b)
BeanPathJava 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”)

🌱 QueryDSL 기본 문법

메소드설명
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)

🌱 동적 쿼리: BooleanBuilder vs BooleanExpression

QueryDSL의 꽃인 BooleanBuilderBooleanExpression 에 대해 알아보자.

이들은 “검색 조건이 있을 수도 있고, 없을 수도 있을 때 사용한다.

있을 수도 있고, 없을 수도 있다는 것이 "동적 쿼리"를 뜻한다.

🥕 BooleanBuilder

  • 직관적이지만 코드가 다소 지저분해질 수 있다. 마치 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)로 사용

🥕 BooleanExpression

  • 조건을 별도의 메소드로 분리하여 재사용이 가능하고 가독성이 훨씬 좋다.
  • null을 반환하면 where 절에서 자동으로 무시된다.
// 메인 비즈니스 로직
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()) 하여 새로운 의미 있는 조건을 만들 수 있다.


🌱 Projection

🥕 프로젝션 (Projection)

  • 프로젝션은 select 절에 무엇을 가져올지 지정하는 행위이다.
  • QueryDSL을 사용해서 엔티티 전체를 조회하는 것이 아니라 특정 대상만 조회하는 것을 말한다.
  • 프로젝션 종류
    • 기본 프로젝션: 엔티티 전체를 조회 (select(user))
    • 값 타입 프로젝션: 단일 컬럼 조회 (select(user.username))
    • 튜플 프로젝션: 여러 값을 한 번에 조회. QueryDSL이 제공하는 Tuple 타입으로 반환된다.
    • DTO 프로젝션: 조회 결과를 바로 원하는 DTO 객체로 변환한다.

🥕 DTO 프로젝션 (DTO Projection)

  • 실무에서 가장 많이 사용되는 방식이므로 따로 한 번 더 설명한다.
  • 조회 결과를 바로 원하는 DTO 객체로 변환한다.
Projections 메소드동작 방식특징
Projections.bean(Class)Setter 기반기본 생성자 필요, Setter를 통해 값 주입
Projections.fields(Class)필드 직접 접근 기반기본 생성자 필요, 리플렉션을 통해 필드에 바로 값 주입 (Setter 없어도 됨)
Projections.constructor(Class, DTO 생성에 필요한 값)생성자 기반파라미터 타입과 순서가 정확히 일치해야 함
@QueryProjection컴파일러 기반 (가장 안전)DTO 클래스의 생성자에 @QueryProjection 어노테이션 사용. Q-DTO를 생성하여 타입 불일치 오류를 컴파일 시점에 잡아주므로 가장 추천되는 방식. 그러나 QueryDSL에 종속됨(해당 DTO가 QueryDSL 전용 DTO가 됨)

🤔 생성자 방식 vs 어노테이션 방식

  • 사실상 이 둘의 싸움이다.
  • Projections.constructor 는 QueryDSL의 의존성 없는 DTO를 사용할 수 있고, 기본 생성자가 필요 없으며, 필드의 순서와 타입이 일치하면 사용할 수 있다. 하지만 런타임 오류 발생 가능성이 있다.
  • @QueryProjection 은 타입 안정성을 보장하며, 컴파일 타입 검증이 가능해 유지보수성이 높지만, QueryDSL에 대한 의존성이 추가되므로 코드 생성 및 설정이 필요하다.

🌱 실전 예제

🥕 문제

  • “TeamA”라는 이름을 가진 팀에 소속된 회원 중, 나이가 20살 이상인 회원들을 찾아서 나이를 기준으로 내림차순 정렬해서 조회해라.
  • 사용자가 검색 필터에서 이름(username)과 나이(age)를 입력할 수도 있고, 안 할 수도 있다.
  • 엔티티(User)를 그대로 조회하면 불필요한 데이터까지 가져오게 되니, 회원의 usernameage 만 뽑아서 UserDto로 즉시 반환한다.

🥕 환경

  • User: id, username, age
  • Team: id, name, userId

🥕 BooleanExpression을 활용한 동적 쿼리 생성

public 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;
	}

🌱 심화 문법

🥕 Function 호출 (SQL Function)

  • DB 고유의 함수나 표준 함수를 호출할 수 있다.
  • 그러나 복잡한 함수의 문법 구조들을 완벽하게 타입 안전하게 지원하지는 않는다.
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 문

  • SQL의 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();

🌱 함께 보면 좋을 자료

https://velog.io/@youngerjesus/우아한-형제들의-Querydsl-활용법

1개의 댓글

comment-user-thumbnail
2025년 12월 17일

흡족🥕

답글 달기