QueryDSL

2025. 3. 14. 11:57Develop/Spring

 

처음엔 JDBC Template 그 다음 MyBatis 그 다음 JPA로 쿼리를 작성해왔다.

하지만 JPA의 메서드 작성규칙만으로는 어렵고 복잡한 쿼리를 작성하기 어렵다.

그래서 JPQL로 쿼리를 더 자세하게 쓰기 시작했다.

하지만 JPQL은 문자열로 쿼리를 작성한다는 단점이 있고 개발자가 오타를 낸다면 버그를 발생한다는 문제점이 있다.

그 해결책으로 나온것이 바로 QueryDSL!

 

QueryDSL이란?

QueryDSL은 하이버네이트 쿼리 언어(HQL: Hibernate Query Language)의 쿼리를 타입에 안전하게 생성 및 관리해주는 프레임워크다.
QueryDSL은 정적 타입을 이용하여 SQL과 같은 쿼리를 생성할 수 있게 해준다.

자바 백엔드 기술은 Spring Boot와 Spring Data JPA를 함께 사용한다. 하지만 복잡한 쿼리, 동적 쿼리를 구현하는 데 있어 한계가 있다. 이러한 문제점을 해결할 수 있는 것이 QueryDSL이다.

 

JPA를 이용한 코드

@Query("SELECT t FROM Todo t " +
            "LEFT JOIN t.user " +
            "WHERE t.id = :todoId")
Optional<Todo> findByIdWithUser(@Param("todoId") Long todoId);

 JPQL을 이용해서 User필드를 바로가져오기 때문에 N+1문제를 해결하는 모습이다. 하지만 문자열안에 쿼리가 작성되어 있어서 오타를 낼 수 있는 상황이다. 

 

QueryDSL을 이용한 코드

@Override
public Optional<Todo> findByIdWithUser(Long todoId) {
    QTodo todo = QTodo.todo;
    QUser user = QUser.user;

    Todo result = jpaQueryFactory
            .selectFrom(todo)
            .leftJoin(todo.user, user).fetchJoin()
            .where(todo.id.eq(todoId))
            .fetchOne();

    return Optional.ofNullable(result);
}

QueryDSL을 이용하면 selectFrom, where과 같은 메서드를 이용해서 타입 안전하게 쿼리를 작성할 수 있다. 

 

QueryDSL의 장점

  • 문자가 아닌 코드로 쿼리를 작성할 수 있어 컴파일 시점에 문법 오류를 확인할 수 있다.
  • 인텔리제이와 같은 IDE의 자동 완성 기능의 도움을 받을 수 있다.
  • 복잡한 쿼리나 동적 쿼리 작성이 편리하다.
  • 쿼리 작성 시 제약 조건 등을 메서드 추출을 통해 재사용할 수 있다.
  • JPQL 문법과 유사한 형태로 작성할 수 있어 쉽게 적응할 수 있다.

 

그럼 QueryDSL을 어떻게 적용하는가??

 

먼저 의존성 추가하기

// QueryDSL 적용을 위한 의존성 (SpringBoot3.0 부터는 jakarta 사용해야함)
implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta'
annotationProcessor "com.querydsl:querydsl-apt:${dependencyManagement.importedProperties['querydsl.version']}:jakarta"
annotationProcessor "jakarta.annotation:jakarta.annotation-api"
annotationProcessor "jakarta.persistence:jakarta.persistence-api"

 

 

  • querydsl-jpa → QueryDSL의 JPA 연동
  • querydsl-apt → QueryDSL 코드 자동 생성
  • jakarta.annotation-api → QueryDSL 코드 생성 시 필요한 Jakarta 어노테이션
  • jakarta.persistence-api → JPA 어노테이션 제공

 

다시 빌드 후에 디렉토리의 build -> generated -> sources -> annotaionProcessor -> ... -> 각 Entity별로 Q클래스가 생성되었는지 확인한다. 

 

그 다음 Custom 리포지토리 인터페이스를 만든다. Custom suffix를 가지도록 인터페이스명을 작성해준다. 

public interface TodoRepositoryCustom {
    Optional<Todo> findByIdWithUser(Long todoId);
}

 

인터페이스를 만들었으면 내가 작성할 QueryDSL 메서드를 작성해준다. 

 

이제 이 Custom repository를 implement 하는 CustomRepositoryImpl을 만들어준다.

@RequiredArgsConstructor
public class TodoRepositoryImpl implements TodoRepositoryCustom {
    private final JPAQueryFactory jpaQueryFactory;

    @Override
    public Optional<Todo> findByIdWithUser(Long todoId) {
        QTodo todo = QTodo.todo;
        QUser user = QUser.user;

        Todo result = jpaQueryFactory
                .selectFrom(todo)
                .leftJoin(todo.user, user).fetchJoin()
                .where(todo.id.eq(todoId))
                .fetchOne();

        return Optional.ofNullable(result);
    }
}

Impl 클래스에서는 이전에 Custom repository 인터페이스에서 명세해놓았던 메서드를 override한다. findByIdWithUser를 명세해 놓았으니 그 메서드를 구현한다.

 

private final JPAQueryFactory jpaQueryFactory;

이 필드는 JPAQueryFactory를 생성자를 통해 주입받은 것이다. 그래서 @RequiredArgsConstructor와 함께 사용한다. 

JPAQueryFactory는 말그대로 쿼리공장,  QueryDSL에서 SQL 쿼리를 생성하는 핵심 객체이다. 

 

@Override
public Optional<Todo> findByIdWithUser(Long todoId) {

이 쿼리의 실행결과가 null일 수도 있으니 Optional로 감싸준다. 

 

QTodo todo = QTodo.todo;
QUser user = QUser.user;

 

QueryDSL에서는 엔티티를 사용할 때 QClass(QueryDSL 전용 클래스)를 활용해야 한다.

QTodo와 QUser는 각각 Todo와 User 엔티티를 QueryDSL에서 사용할 수 있도록 변환한 객체이다. 이전에 봤듯 Q클래스는 빌드하면 자동으로 생성된다. 

 

Todo result = jpaQueryFactory
        .selectFrom(todo)
        .leftJoin(todo.user, user).fetchJoin()
        .where(todo.id.eq(todoId))
        .fetchOne();

return Optional.ofNullable(result);

 

JPAQueryFactory 메서드들로 쿼리를 작성한다. 

  • selectFrom(todo) : Todo 엔티티 테이블에서 select 해오겠다는 의미.
  • .leftJoin(todo.user, user).fetchJoin() : Todo 엔티티와 연관된 User 엔티티를 LEFT JOIN으로 함께 가져오겠다는 의미. 또한 fetchJoin()을 사용해서 N+1 문제를 해결
  • .where(todo.id.eq(todoId)) : todo.id가 todoId와 같은 데이터를 찾는다는 조건.
  • .fetchOne(); : 조회된 결과가 단일 객체일 때 사용. 만약 여러 개의 결과가 나올 수 있다면 .fetchOne()이 아니라 .fetch()를 사용해야 한다.
  • return Optional.ofNullable(result); : result가 null일 수도 있기 때문에, Optional.ofNullable()로 감싸서 반환한다. 그럼 null일 경우 Optional.empty()를 반환하여 NullPointerException 방지 역할을 한다.

 

이제 QueryDSL로 쿼리 메서드를 구현을 했으니 어떻게 사용할까?

public interface TodoRepository extends JpaRepository<Todo, Long>, TodoRepositoryCustom {

 그 전에 계속 사용하고 있었던 JpaRepository를 상속받는 TodoRepository에 TodoRepositoryCustom도 같이 상속받아서 쓰면 된다.

아니 자바에서는 다중상속 지원 안한다면서 이거 다중상속 아닌가요? 하고 생각할 수 있겠지만 (내가 그랬다) TodoRepository, JpaRepository, TodoRepositoryCustom 모두 인터페이스이기 때문에 다중 상속이 가능하다.

인터페이스는 추상 클래스보다 더 추상적이므로 여러 인터페이스를 상속받는 다중 상속을 지원한다. 

 

@Transactional(readOnly = true)
public TodoResponse getTodo(long todoId) {
    
    Optional<Todo> foundTodo = todoRepository.findByIdWithUser(todoId);

 

 

 

그럼 이제 이렇게 서비스 단에서 todoRepository에서 바로 findByIdWithUser를 사용할 수 있게 된다!

 

 

QueryDSL 처음엔 의존성을 추가하고, 뭘 implement하고 상속받으라는 건지 도통 모르겠어서 어떻게 쓰는거야!! 했는데 이렇게 쭉 정리해보니 이제 쓰는 법을 알겠다. 이제 QueryDSL이용해서 타입안전하게 쿼리를 작성해보자~!

 

'Develop > Spring' 카테고리의 다른 글

스프링에서 예외를 처리하기  (0) 2025.03.21
QueryDSL의 BooleanExpression, Projections, Pagination  (0) 2025.03.14
AOP?  (0) 2025.03.12
테스트 코드  (0) 2025.03.12
Proxy가 도대체 뭔데  (0) 2025.03.11