
과제에 QueryDSL 적용하면서 공부한 내용을 정리해두었다.
ORM(Object-Relational Mapping)을 구현하는 방법에는 JPA, QueryDSL 등 여러가지 방법이 있다.
JPA를 사용하다보면 다중 조건 검색과 같이 조건이 복잡한 쿼리는 @Query이나 JPQL을 사용하여 구현하게 된다.
하지만, JPQL은 문자열 기반이기 때문에 타입 안정성이 떨어지는 단점이 있다.
예를 들어 m.email을 m.eamil처럼 오타가 발생하거나 JPQL 문법에 오류가 있어도 컴파일 시점에 오류를 잡지않아 런타임 시점에 오류가 발생할 수 있다.
또한 JPA에서도 동적 쿼리 작성이 가능하지만 StringBuilder 등을 사용해야 하여 가독성과 유지보수성이 떨어진다.
QueryDSL은 자바 코드로 JPQL 쿼리를 타입 안정성 있게 작성할 수 있도록 도와주는 프레임워크이다.
QueryDSL을 사용하면 JPA에서의 복잡한 조건의 쿼리나 동적 쿼리를 보다 안전하고 유연하게 작성할 수 있다.
QueryDSL은 @Entity가 붙어 Spring boot의 관리를 받는 엔티티들의 메타 정보가 담긴 QClass를 만들고 이를 이용하여 query문을 작성한다. 해당 QueryDSL query를 작성하면 자동으로 JPQL로 번역이 된다.
✔️ 타입 안정성: 컴파일 시점에 JPQL 문법 오류, 오타를 발견할 수 있다.
✔️ 편리한 동적 쿼리 작성: 다중 조건 검색 시, 각 조건을 유동적으로 조합하여 JPQL 쿼리로 번역할 수 있다.
QueryDSL의 구현에는 여러가지 방법이 있다.
1. QueryDSL repository 만 사용 (JPA ❌)
1-1. QueryDSL 레포 사용, JpaQueryFactory 주입
JPARepository를 상속받지 않고 QueryDSL만 사용하여 구현하는 방식. JPA에 의존하고 싶지 않을 때 사용하면 적합하다.
대신 JPA를 상속받지 않으므로 기본 CRUD를 직접 구현해야하며 이에 따라 중복 코드가 늘어나게 된다.
2. QueryDSL+JPA
2-1. JPA레포, QueryDSL 레포 둘다 상속 (✅)
JPA와 QueryDSL을 별도의 클래스에 구현
2-2. JPA레포 사용, JpaQueryFactory 주입
JPA, QueryDSl 을 하나의 클래스에 구현
JPA의 장점과 QueryDSL의 장점을 적절하게 살릴 수 있는 방법을 선택한다. 관심사를 적절하게 분리하기 위해 2-1 방법을 선택했다.
// QueryDSL
implementation "com.querydsl:querydsl-jpa:5.0.0:jakarta"
annotationProcessor "com.querydsl:querydsl-apt:5.0.0:jakarta"
annotationProcessor "jakarta.persistence:jakarta.persistence-api:3.1.0"
annotationProcessor "jakarta.annotation:jakarta.annotation-api:2.1.1"
QueryDSL에서는 엔티티를 타입 안전하게 표현한 Q객체를 사용한다.
Q객체를 생성하는 방법을 IntelliJ 를 기준으로 설명하자면
오른쪽 Gradle 메뉴-Tasks-build 에서
clean를 실행하여 이전 빌드 결과물을 지우고, 다시 build를 실행하면 @Entity를 기반으로 Q객체가 자동 생성된다.
그러면 프로젝트 루트 기준 bulid-generated-sources-annotationProcessor 아래에 Q객체가 생성된 걸 확인할 수 있다.
@Configuration
public class QueryDslConfig {
@Bean
JPAQueryFactory jpaQueryFactory(EntityManager em) {
return new JPAQueryFactory(em);
}
}
구현해야하는 레포지토리
JPARepository : JPA 레포지토리. JpaRepository와 RepositoryCustom을 상속 받는다.RepositoryCustom : QueryDSL 로 작성할 쿼리 메소드를 정의만 하는 인터페이스.RepositoryImpl : RepositoryCustom의 구현체.JPAQueryFactory를 주입받아 커스텀 쿼리를 작성한다.레포지토리명+Impl 이어야 스프링이 자동인식할 수 있다. (커스텀 인터페이스의 구현체를 자동으로 찾아서 연결)TodoRepository ➡️ TodoRepositoryImpl@Repository
public interface TodoRepository extends JpaRepository<Todo, Long>, TodoRepositoryCustom {
...
}
안에 필요한 JPA 메소드를 선언한다.
public interface TodoRepositoryCustom {
Optional<Todo> findByIdWithUser(Long todoId);
Page<TodoSearchDto> findAllByCondition(
Pageable pageable, String keyword, String managerNickname,
LocalDate createdStart, LocalDate createdEnd);
}
QueryDSL 로 작성할 쿼리 메소드를 정의한다. 실제 메소드 구현은 Impl에서 이루어진다.
public class TodoRepositoryImpl implements TodoRepositoryCustom {
private final JPAQueryFactory queryFactory;
public TodoRepositoryImpl(JPAQueryFactory jpaQueryFactory) {
this.queryFactory = jpaQueryFactory;
}
@Override
public Optional<Todo> findByIdWithUser(Long todoId) {
return Optional.ofNullable(
queryFactory
.selectFrom(todo)
.leftJoin(todo.user, user).fetchJoin()
.where(todo.id.eq(todoId))
.fetchOne());
}
@Override
public Page<TodoSearchDto> findAllByCondition(
Pageable pageable, String keyword, String managerNickname,
LocalDate createdStart, LocalDate createdEnd) {
List<TodoSearchDto> todos = queryFactory
.select(new QTodoSearchDto(
todo.title,
todo.managers.size(),
todo.comments.size()))
.from(todo)
.leftJoin(todo.managers, manager)
.leftJoin(manager.user, user)
.where(
keywordContains(keyword),
createdAtBetween(createdStart, createdEnd),
nicknameContains(managerNickname)
)
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();
JPAQuery<Long> countQuery = queryFactory
.select(todo.count())
.from(todo)
.where(
keywordContains(keyword),
createdAtBetween(createdStart, createdEnd),
nicknameContains(managerNickname)
);
return PageableExecutionUtils.getPage(todos, pageable, countQuery::fetchOne);
}
private BooleanExpression keywordContains(String keyword) {
return keyword != null? todo.title.containsIgnoreCase(keyword):null;
}
private BooleanExpression createdAtBetween(LocalDate createdStart, LocalDate createdEnd) {
if(createdStart != null && createdEnd != null) {
return todo.createdAt.between(createdStart.atStartOfDay(), createdEnd.atTime(LocalTime.MAX));
}
if(createdStart != null) {
return todo.createdAt.goe(createdStart.atStartOfDay());
}
if(createdEnd != null) {
return todo.createdAt.loe(createdEnd.atTime(LocalTime.MAX));
}
return null;
}
private BooleanExpression nicknameContains(String nickname) {
return nickname != null? user.nickname.containsIgnoreCase(nickname) : null;
}
}
Custom에서 구현한 메소드를 실제 구현한다.
QueryDSL는 동적 쿼리를 편리하게 작성할 수 있는 장점이 있다. 예를 들어 다중 조건 검색을 JPA로 구현하면 메소드명이 지나치게 길어져 오히려 가독성이 떨어지기 쉽다.
반면 QueryDSL로 작성 시, 쿼리의 조건을 개별 메소드로 분리할 수 있어 메소드명을 직관적으로 유지할 수 있으며 조건을 재사용할 수 있다.
예시 코드에서는 키워드 일치 여부, 기간별 검색 조건을 keywordContains, createdAtBetween 메소드로 별도로 분리함으로써 가독성을 재사용성을 높였다.
@Query("SELECT t FROM Todo t LEFT JOIN FETCH t.user u "
+ "WHERE t.weather LIKE CONCAT('%', :weather, '%') "
+ "AND DATE(t.modifiedAt) <= :modifiedEnd "
+ "ORDER BY t.modifiedAt DESC")
Page<Todo> findAllByWeatherUntilModifiedAtAndOrder(
Pageable pageable, @Param("weather") String weather, @Param("modifiedEnd") LocalDate modifiedEnd);
@Query("SELECT t FROM Todo t LEFT JOIN FETCH t.user u "
+ "WHERE t.weather LIKE CONCAT('%', :weather, '%') "
+ "AND DATE(t.modifiedAt) >= :modifiedStart "
+ "ORDER BY t.modifiedAt DESC")
Page<Todo> findAllByWeatherBeginModifiedAtAndOrder(
Pageable pageable, @Param("weather") String weather, @Param("modifiedStart") LocalDate modifiedStart);
@Query("SELECT t FROM Todo t LEFT JOIN FETCH t.user u "
+ "WHERE t.weather LIKE CONCAT('%', :weather, '%') "
+ "AND DATE(t.modifiedAt) BETWEEN :modifiedStart AND :modifiedEnd "
+ "ORDER BY t.modifiedAt DESC")
Page<Todo> findAllByWeatherBetweenModifiedAtAndOrder(
Pageable pageable, @Param("weather") String weather,
@Param("modifiedStart") LocalDate modifiedStart, @Param("modifiedEnd") LocalDate modifiedEnd);
QueryDSL 없이 JPA 메서드만으로 다중 조건 검색을 구현할 경우, 조건 조합마다 별도의 메서드를 작성해야 한다.
예를 들어 검색 조건이 3가지라면, 이를 각각 조합한 모든 경우의 수에 대해 메서드를 정의해야 하므로 중복 코드가 많아지고, 메서드 이름도 지나치게 길어지는 문제가 발생한다.
반면, QueryDSL은 조건이 null이거나 주어지지 않으면 해당 조건을 자동으로 생략하고, 동적으로 JPQL 쿼리로 번역된다.
따라서 하나의 메서드로 다양한 조합의 다중 조건 검색을 처리할 수 있어, 코드의 유연성과 가독성, 유지보수성 측면에서 큰 장점을 가진다.