QueryDSL

김기수·2025년 9월 19일
0

QueryDSL이란?

  • QueryDSL은 SQL과 같은 형태의 쿼리를 Type-Safe하게 생성하고 관리할 수 있도록 도와주는 프레임워크이다.

사용하는 이유

  • 일반적으로 Spring Boot에서 데이터 베이스를 다루는 데 사용하는 JPA(Java Persistence API)는 복잡한 쿼리를 구현하는 데 한계가 있다.
    이러한 복잡한 쿼리를 해결하기 위해 JPQL(Java Persistence Query Language)나 네이티브SQL을 사용하는데, 이 방식은 문자열 기반이므로 오타나 문법 오류가 발생했을 때 컴파일 시점이 아닌 런타임에서 오류를 발견할 수 있다.

  • QueryDSL은 이러한 SQL문을 코드로 작성하게 함으로써 컴파일 시점에 오류를 잡아낼 수 있게 해준다.


특징

  • 타입 안정성
    쿼리를 작성할 때 타입 안정성을 보장하여 컴파일 타임에 오류를 검출할 수 있다.

  • 자동 생성된 QClass
    각 엔티티에 대해 QClass가 자동 생성되어, 쿼리 작성 시 이를 활용할 수 있다.

  • 동적 쿼리
    쿼리 작성 시 동적 조건을 추가하거나 변경할 때 편리하다.

  • 성능
    쿼리 DSL 사용 시 조인 등 복잡한 쿼리의 최적화가 가능하다.


QClass

  • 컴파일 단계에서 엔티티를 기반으로 생성되며, JPA_APT(JPAAnnotationProcessTool)가 @Entity와 같은 특정 어노테이션을 찾고 해당 클래스를 분석해 생성한다.

  • 엔티티 클래스의 메타 정보를 담고 있는 클래스로, QueryDSL은 이를 이용하여 Type-Safe를 보장하며 쿼리를 작성할 수 있게 된다.

  • 엔티티 속성의 타입을 정확하게 표현하므로, 타입에 맞지 않는 연산이나 비교를 시도하면 컴파일러가 오류를 감지할 수 있다.

  • 종합적으로 QClass를 사용하면 런타임에 발생할 수 있는 잠재적 오류를 컴파일 시점에 미리 잡아내어 코드의 안정성을 높혀준다.


QueryDSL 시작하기


build.gradle에 의존성 추가

    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"
  • 해당 의존성 추가 시 build/generated/sources/annotationProcessor/java/main에 Q클래스가 생성된다.
    (만약 QClass 폴더가 추가되지 않을 시 gradle에서 expert/tasks/other/compile.java를 실행시켜 보는것을 권장한다.)

JPAQueryFactory 등록

import com.querydsl.jpa.impl.JPAQueryFactory;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class JPAConfiguration {
    @PersistenceContext
    private EntityManager entityManager;

    @Bean
    public JPAQueryFactory jpaQueryFactory() {
        return new JPAQueryFactory(entityManager);
    }
}
  • JPAQueryFactory를 사용하기 위해 @Configuration을 통해 설정 클래스를 추가해 줄 필요가 있다.

  • QueryDSL이 JPA를 통해 엔티티를 조회하기 때문에 EntityManager를 JPAQueryFactory 생성자에 주입해 반환해준다.


사용

  • 인터페이스 없이 @Repository 구현체만을 생성해 사용 할 수 있지만, 기존 사용하던 JPARepository와 함께 사용하기 위해 인터페이스를 만들어 구현하는 방법을 사용했다.
package org.example.expert.domain.todo.repository;

import org.example.expert.domain.todo.entity.Todo;
import java.util.Optional;

public interface TodoRepositoryQuery {
    Optional<Todo> findByIdWithUser(Long todoId);
}
public interface TodoRepository extends JpaRepository<Todo, Long> ,TodoRepositoryQuery{
}
  • 구현된 TodoRepositoryQueryImpl 클래스에서 JPAQueryFactory를 주입받아 QueryDSL을 작성 할 수 있다.
package org.example.expert.domain.todo.repository;

import static org.example.expert.domain.todo.entity.QTodo.todo;
import static org.example.expert.domain.user.entity.QUser.user;

import com.querydsl.jpa.impl.JPAQueryFactory;
import lombok.RequiredArgsConstructor;
import org.example.expert.domain.todo.entity.Todo;

import java.util.Optional;

@RequiredArgsConstructor
public class TodoRepositoryQueryImpl implements TodoRepositoryQuery {
    private final JPAQueryFactory jpaQueryFactory;

    @Override
    public Optional<Todo> findByIdWithUser(Long todoId) {
        return Optional.ofNullable(
                jpaQueryFactory
                        .selectFrom(todo)
                        .leftJoin(todo.user, user).fetchJoin()
                        .where(todo.id.eq(todoId))
                        .fetchOne());
    }
}
  • 밑의 JPQL문은 QueryDSL을 적용하기 전 사용한 코드이다.
 @Query("SELECT t FROM Todo t " +
            "LEFT JOIN FETCH t.user " +
            "WHERE t.id = :todoId")
 Optional<Todo> findByIdWithUser(@Param("todoId") Long todoId);
  • QueryDSL문에서 마지막에 붙는 .fetchOne()은 조회한 엔티티를 반환하기 위한 옵션이다.

    • fetchOne : 하나의 결과를 가져오고 싶을 때 사용

    • fetch : 리스트 형태와 같이 여러 개의 결과를 가져오고 싶을 때 사용

    • fetchFirst : 여러개의 결과 중 첫 번째 결과만 가져오고 싶을 때 사용
      (limit(1).fetchOne()과 같은 결과)


그 외


BooleanBuilder

  • 여러 조건을 동적으로 추가 할 수 있는 클래스이다.
public List<User> findUsersByNameAndEmail(String name, String email, LocalDate startDate, LocalDate endDate)
  QUser user = QUser.user;

  BooleanBuilder builder = new BooleanBuilder();

  // WHERE :name IS NOT NULL AND user.name = :name
  if (name != null && !name.isEmpty()) {
      builder.and(user.name.eq(name));
  }

  // WHERE :email IS NOT NULL AND user.email LIKE CONCAT ('%', :email, '%')
  if (email != null && !email.isEmpty()) {
  				// user.name.like("'%"+name+"%'")과 동일한 기능
      builder.and(user.email.contains(email));
  }
  
  // startDate와 endDate를 LocalDateTime으로 변환 후
  // WHERE :startDate IS NOT NULL AND :endDate IS NOT NULL AND
  // user.createdAt >= :startDate AND uesr.createdAt <= :endDate
  if(startDate != null && endDate != null) {
      builder.and(user.createdAt.between(startDate.atStartOfDay(), endDate.atTime(LocalTime.MAX)));
  }

  return jpaQueryFactory
      .selectFrom(user)
      .where(builder)
      .fetch();
}

BooleanExpression

  • BooleanBuilder와 같은 동적 쿼리이다.

  • 조건문 쿼리들을 분리해 메서드 단위로 관리하기 때문에 조건문이 많아질 때 사용하기 적합하다.

public List<User> findUsersByName(String name)
  QUser user = QUser.user;
  
  ...

  return jpaQueryFactory
      .selectFrom(user)
      .where(usernameEq(name))
      .fetch();
}

private BooleanExpression usernameEq(String username) {
    if (username == null || username.isEmpty()) {
        return null;
    }
    // user.name.eq(username)와 동일한 BooleanExpression 객체를 반환
    return user.name.eq(username);
}
  • 여러 BooleanExpression 조건을 엮고 싶다면
BooleanExpression or = usernameEq().or(useremailEq())
BooleanExpression and = usernameEq().and(useremailEq())

Page 반환

List<User> users = queryFactory
    .selectFrom(user)
    .where(...)
    .offset(pageable.getOffset())
    .limit(pageable.getPageSize())
    .fetch();

// 전체 데이터 개수 (count 쿼리)
JPAQuery<Long> totalQuery = queryFactory
    .select(user.count())
    .from(user)
    .where(...);

// Page 객체 생성 및 반환
return PageableExecutionUtils.getPage(users, pageable, totalQuery::fetchOne);
  • .offset().limit()으로 List를 만든 뒤 Page를 생성해 반환한다.

  • PageableExecutionUtils은 Spring Data JPA에서 Page 객체를 최적화하여 반환하는 유틸리티 클래스이
    다.

  • PageableExecutionUtils 대신 new PageImpl()을 사용할 수도 있는데,
    new PageImpl()은 데이터를 조회하는 쿼리와 전체 개수를 조회하는 쿼리를 모두 호출해야 하므로 PageableExecutionUtils를 사용해 불필요한 카운트 쿼리를 줄일 수 있다.


DTO 반환 (Projections)

  • Projections는 엔티티에서 필요한 필드만 가져와서 DTO로 반환해 준다.
  • Projections.constructor() : 생성자 기반 매핑
List<TodoSearchResponse> list = jpaQueryFactory
  .select(Projections.constructor(
      TodoSearchResponse.class,
      todo.title,
      todo.managers.size(),
      todo.comments.size()
  ))

@QueryProjection

  • DTO에도 생성자에 @QueryProjection를 붙이면 QClass가 생성된다.

  • 장점

    • 컴파일 시점에 타입 체크를 할 수 있다.
    • DTO 필드 변경 시 쿼리에서 자동으로 감지할 수 있다.
  • 단점

    • DTO가 QueryDSL에 종속된다.
    • QDTO 클래스를 빌드할 때마다 생성해야 한다.
    • QueryDSL을 제거할 경우 DTO 수정이 필요하다.

참고 자료

https://turtle-codingstudy.tistory.com/54
https://3uomlkh.tistory.com/383

profile
백엔드 개발자

0개의 댓글