QueryDSL 적용

이규정·2025년 3월 12일

1. QueryDSL 기술이 나온 배경


  • 전통적으로 JPA에서 동적 쿼리를 작성하기 위해 JPQL이나 Criteria API를 사용했음
  • JPQL은 문자열 기반으로 작성되므로 런타임 시점에 오류를 발견해야 해서 유지보수 어려움
  • Criteria API는 코드가 복잡하고 가독성이 떨어지는 문제점이 있음
  • 이러한 문제를 해결하기 위해 Querydsl이 등장함
  • Querydsl은 정적 타입을 이용하여 SQL과 같은 쿼리를 안전하게 생성 및 관리할 수 있도록 도와줌

2. QueryDSL 프로젝트 적용 과정


2.1. 프로젝트에 QueryDSL 의존성 추가

  • build.gradle 파일 수정하여 Querydsl 의존성 추가해야 함
  • 주의 : querydsl-apt 와 querydsl-jpa 의 버전이 같은 지 확인 - [트러블 슈팅]
plugins {
    id 'java'
    id 'org.springframework.boot' version '3.3.3'
    id 'io.spring.dependency-management' version '1.1.6'
}

group = 'org.example'
version = '0.0.1-SNAPSHOT'

java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(17)
    }
}

configurations {
    compileOnly {
        extendsFrom annotationProcessor
    }
}

repositories {
    mavenCentral()
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-validation'
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    compileOnly 'org.projectlombok:lombok'
    runtimeOnly 'com.h2database:h2'
    runtimeOnly 'com.mysql:mysql-connector-j'
    annotationProcessor 'org.projectlombok:lombok'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testRuntimeOnly 'org.junit.platform:junit-platform-launcher'

    // bcrypt
    implementation 'at.favre.lib:bcrypt:0.10.2'

    // jwt
    compileOnly group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.11.5'
    runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-impl', version: '0.11.5'
    runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-jackson', version: '0.11.5'

    // QueryDSL
    implementation 'com.querydsl:querydsl-jpa:5.1.0:jakarta'
    annotationProcessor "com.querydsl:querydsl-apt:${dependencyManagement.importedProperties['querydsl.version']}:jakarta"
    annotationProcessor "jakarta.annotation:jakarta.annotation-api"
    annotationProcessor "jakarta.persistence:jakarta.persistence-api"
}

tasks.named('test') {
    useJUnitPlatform()
}

2.2. Querydsl 설정 클래스 작성

  • JPAQueryFactory를 빈으로 등록하여 프로젝트 전반에서 사용할 수 있도록 설정해야 함
package org.example.expert.config;

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 QueryDSLConfig {

    @PersistenceContext
    private EntityManager entityManager;

    @Bean
    public JPAQueryFactory jpaQueryFactory(){
        return new JPAQueryFactory(entityManager);
    }
}

2.3. 엔티티 클래스 작성

  • QueryDSL에서 사용할 Todo 엔티티를 작성해야 함
package org.example.expert.domain.todo.entity;

import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.example.expert.domain.comment.entity.Comment;
import org.example.expert.domain.common.entity.Timestamped;
import org.example.expert.domain.manager.entity.Manager;
import org.example.expert.domain.user.entity.User;

import java.util.ArrayList;
import java.util.List;

@Getter
@Entity
@NoArgsConstructor
@Table(name = "todos")
public class Todo extends Timestamped {

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String title;
    private String contents;
    private String weather;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_id", nullable = false)
    private User user;

    @OneToMany(mappedBy = "todo", cascade = CascadeType.REMOVE)
    private List<Comment> comments = new ArrayList<>();

    // 매니저 객체를 영속성 컨텍스트에서 관리하기위해 CascadeType.PERSIST 설정
    // Todo 를 save 할 시, managers도 같이 Persist 됨.
    @OneToMany(mappedBy = "todo", cascade = CascadeType.PERSIST)
    private List<Manager> managers = new ArrayList<>();

    public Todo(String title, String contents, String weather, User user) {
        this.title = title;
        this.contents = contents;
        this.weather = weather;
        this.user = user;
        this.managers.add(new Manager(user, this));
    }
}

2.4. QueryDSL Q 클래스 생성

  • ./gradlew build 실행하면 QueryDLS이 QTodo 클래스를 자동으로 생성해줌 (/build/Generated 에서 확인)

2.5. 레포지토리 인터페이스 작성

  • TodoRepository 인터페이스 작성하여 JPA와 QueryDSL을 함께 사용할 수 있도록 해야 함
package org.example.expert.domain.todo.repository;

import org.example.expert.domain.todo.entity.Todo;
import org.springframework.data.jpa.repository.JpaRepository;

public interface TodoRepository extends JpaRepository<Todo, Long>, TodoRepositoryQuery {
}

2.6. 커스텀 레포지토리 인터페이스 작성

  • 동적 쿼리를 작성하기 위해 커스텀 레포지토리 인터페이스 작성해야 함
package org.example.expert.domain.todo.repository;

import org.example.expert.domain.todo.dto.response.TodoResponse;
import org.example.expert.domain.todo.entity.Todo;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;

import java.time.LocalDateTime;

public interface TodoRepositoryQuery {

    Page<TodoResponse> findTodosByWeatherAndModifiedAtWithPages(String weather, LocalDateTime startTime, LocalDateTime endTime, Pageable pageable);
}

2.7. 커스텀 레포지토리 구현 클래스 작성

  • 커스텀 레포지토리 인터페이스 구현하여 Querydsl을 활용한 동적 쿼리 작성해야 함
package org.example.expert.domain.todo.repository;

import com.querydsl.core.types.Projections;
import com.querydsl.jpa.impl.JPAQueryFactory;
import lombok.RequiredArgsConstructor;
import org.example.expert.domain.todo.dto.response.TodoResponse;
import org.example.expert.domain.todo.entity.Todo;
import org.example.expert.domain.user.dto.response.UserResponse;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.Pageable;

import java.time.LocalDateTime;
import java.util.List;

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

@RequiredArgsConstructor
public class TodoRepositoryQueryImpl implements TodoRepositoryQuery {

    private final JPAQueryFactory jpaQueryFactory;

    @Override
    public Page<TodoResponse> findTodosByWeatherAndModifiedAtWithPages(String weather, LocalDateTime startTime, LocalDateTime endTime, Pageable pageable) {
        List<TodoResponse> result = jpaQueryFactory
                .select(
                        Projections.constructor(
                                TodoResponse.class,
                                todo.id,
                                todo.title,
                                todo.contents,
                                todo.weather,
                                Projections.constructor(UserResponse.class, user.id, user.email),
                                todo.createdAt,
                                todo.modifiedAt
                        )
                )
                .from(todo)
                .leftJoin(todo.user, user)
                .where(
                        weather != null ? todo.weather.eq(weather) : null,
                        startTime != null ? todo.modifiedAt.goe(startTime) : null,
                        endTime != null ? todo.modifiedAt.loe(endTime) : null
                )
                .offset(pageable.getOffset())
                .limit(pageable.getPageSize())
                .fetch();

        Long total = jpaQueryFactory
                .select(todo.count())
                .from(todo)
                .where(
                        weather != null ? todo.weather.eq(weather) : null,
                        startTime != null ? todo.modifiedAt.goe(startTime) : null,
                        endTime != null ? todo.modifiedAt.loe(endTime) : null
                )
                .fetchOne();

        return new PageImpl<>(result, pageable, total != null ? total : 0L);
    }
}

2.8. 서비스 클래스 적용

  • 추상화된 TodoRepository를 이용해 구현
package org.example.expert.domain.todo.service;

import lombok.RequiredArgsConstructor;
import org.example.expert.domain.todo.dto.response.TodoResponse;
import org.example.expert.domain.todo.entity.Todo;
import org.example.expert.domain.todo.repository.TodoRepository;
import org.example.expert.domain.user.dto.response.UserResponse;
import org.example.expert.domain.user.entity.User;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.time.LocalDateTime;

@Service
@RequiredArgsConstructor
public class TodoService {

    private final TodoRepository todoRepository;

    @Transactional(readOnly = true) // 조회는 readOnry = true 설정
    public Page<TodoResponse> getTodos(int page, int size, String weather, LocalDateTime startTime, LocalDateTime endTime) {
        Pageable pageable = PageRequest.of(page - 1, size);

        return todoRepository.findTodosByWeatherAndModifiedAtWithPages(weather, startTime, endTime, pageable);
    }
}

3. Querydsl과 다른 기술 간의 장단점 비교


  • Querydsl의 장점:

    • 타입 안전성: 컴파일 시점에 문법 오류를 발견할 수 있어 안정성이 높음.
    • 가독성: 자바 코드로 쿼리를 작성하므로 가독성이 높고 유지보수가 용이함.
  • Querydsl의 단점:

    • 러닝 커브: 초기에 학습해야 할 내용이 많을 수 있음.
    • 의존성: 프로젝트에 추가적인 라이브러리를 도입해야 함.
  • 다른 기술과의 비교:

    • JPQL: 문자열 기반으로 쿼리를 작성하므로 런타임 시점에 오류를 발견하게 되어 유지보수가 어려움.
    • Criteria API: 자바 코드로 쿼리를 작성할 수 있지만, 코드가 장황해지고 가독성이 떨어지는 단점이 있음.
    • Spring Data JPA의 Query Method: 메서드 이름으로 쿼리를 생성할 수 있어 간편하지만, 복잡한 동적 쿼리 작성에는 한계가 있음.
profile
반갑습니다. 백엔드 개발자가 되기 위해 노력중입니다.

0개의 댓글