Medium

Spring Boot3 with QueryDSL - Part2

ppthejake 2023. 11. 6. 11:20

해당 포스트는 Meduim 의 아티클을 번역한 내용이다.

개요

이 글에서는 Spring Boot3 어플리케이션에서 QueryDSL을 설정하고 활용하는 방법을 설명합니다.

Set up

Dependency

메이븐 pom.xml 에 종속성을 추가하는 것부터 시작해 보겠습니다. Spring Boot 3 는 Jakarta EE 를 사용합니다. 따라서 querydsl-* 디펜던시에 <classifier>jakarta</classifier> 식별자를 추가해야 합니다.

또한, Spring Boot Starter Parent 에 querydsl.version 이 포함되어 있으므로 QueryDSL 버전에 대한 직접적인 사용이 가능합니다.

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.1.4</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>
    <groupId>io.jay</groupId>
    <artifactId>service</artifactId>
    <version>0.0.1-SNAPSHOT</version>

    <properties>
        <java.version>17</java.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-jpa</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <dependency>
            <groupId>com.h2database</groupId>
            <artifactId>h2</artifactId>
            <scope>runtime</scope>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-webflux</artifactId>
            <scope>test</scope>
        </dependency>

        <!-- QueryDSL -->
        <dependency>
            <groupId>com.querydsl</groupId>
            <artifactId>querydsl-jpa</artifactId>
            <version>${querydsl.version}</version>
            <classifier>jakarta</classifier>
        </dependency>
        <dependency>
            <groupId>com.querydsl</groupId>
            <artifactId>querydsl-apt</artifactId>
            <version>${querydsl.version}</version>
            <classifier>jakarta</classifier>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <configuration>
                    <excludes>
                        <exclude>
                            <groupId>org.projectlombok</groupId>
                            <artifactId>lombok</artifactId>
                        </exclude>
                    </excludes>
                </configuration>
            </plugin>
        </plugins>
    </build>

</project>

 

이 시점에서 코드를 컴파일하면 Q. 로 시작하는 클래스가 생성됩니다. 이렇게 생성된 클래스는 나중에 구현에서 사용됩니다.

 

Application Properties

다음은 데이터소스 및 h2 콘솔과 관련된 어플리케이션 속성입니다. application.yml

spring:
  datasource:
    url: jdbc:h2:mem:testdb
    username: sa
    password:

  h2:
    console:
      enabled: true

  jpa:
    show-sql: true

Configuration

@Configuration
class QueryDslConfiguration {

    @PersistenceContext
    private EntityManager em;

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

Entity Setup

이 예제에서 사용될 일부 엔티티는 다음과 같습니다.

Team

package io.jay.service.repository;

import jakarta.persistence.CascadeType;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.OneToMany;
import jakarta.persistence.Table;
import lombok.Data;
import lombok.NoArgsConstructor;

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

@Entity
@Data
@NoArgsConstructor
@Table(name = "teams")
public class Team {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;

    @OneToMany(mappedBy = "team", cascade = {CascadeType.PERSIST, CascadeType.MERGE}, orphanRemoval = true)
    private List<Member> members;

    @OneToMany(mappedBy = "team", cascade = {CascadeType.PERSIST, CascadeType.MERGE}, orphanRemoval = true)
    private List<Milestone> milestones;

    public Team(String name) {
        this.name = name;
        this.members = new ArrayList<>();
        this.milestones = new ArrayList<>();
    }

    public void addMember(Member member) {
        this.members.add(member);
        member.setTeam(this);
    }

    public void addMilestone(Milestone milestone) {
        this.milestones.add(milestone);
        milestone.setTeam(this);
    }
}

Member

package io.jay.service.repository;

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;

import java.time.LocalDateTime;

@Entity
@Data
@NoArgsConstructor
@Table(name = "members")
public class Member {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;
    private LocalDateTime since;

    @ManyToOne
    @JoinColumn(name = "team_id")
    @EqualsAndHashCode.Exclude
    private Team team;

    public Member(String name) {
        this.name = name;
        this.since = LocalDateTime.now();
    }
}

Milestone

이 엔티티는 데모에 사용되지 않습니다. 그러나 두 개의 하위 엔티티를 가진 Team 엔티티를 생성하기 위해 추가되었습니다.

package io.jay.service.repository;

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;

import java.time.LocalDateTime;

@Entity
@Data
@NoArgsConstructor
@Table(name = "milestones")
public class Milestone {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;
    private LocalDateTime celebratedAt;

    @ManyToOne
    @JoinColumn(name = "team_id")
    @EqualsAndHashCode.Exclude
    private Team team;

    public Milestone(String name) {
        this.name = name;
        this.celebratedAt = LocalDateTime.now();
    }
}

QueryDSL 사용법

Projection

우선, 쿼리 결과를 클래스에 매핑하는 방법을 살펴보겠습니다. QueryDSL에서 쿼리 결과를 프로젝션 하는 다양한 방법이 있습니다. 여러 옵션중에 생성자를 사용하는 것을 선호합니다.

결과를 클래스에 매핑하려면 생성자에 @QueryProjection 어노테이션이 필요합니다.

package io.jay.service.model;

import com.querydsl.core.annotations.QueryProjection;

public record MemberResponse(long id, String name) {

    @QueryProjection
    public MemberResponse {
    }
}

 

쿼리 결과를 @Entity 어노테이션을 가진 클래스에 매핑하려면, 생성자 프로젝션을 static 메소드를 통해 수행되어야 합니다.(자세한 사항은 공식 문서를 참조)

쿼리 결과가 MemberResponse 에 어떻게 매핑되는지 살펴보겠습니다. @QueryProjection 을 추가하고 코드를 컴파일하면 QMemberResponse 라는 클래스가 생성됩니다.

public List<MemberResponse> findMembersByTeamId(long teamId) {
        QMember memberTable = new QMember("member");

        return queryFactory
                .select(
                        new QMemberResponse(
                                memberTable.id,
                                memberTable.name
                        )
                )
                .from(memberTable)
                .where(
                        memberTable.team.id.eq(teamId)
                )
                .fetch();
    }

Dynamic Where

JpaRepository 에서 새로운 findBySomeColumn() 메소드를 계속 만드는 것에 지쳐 있는 사람이 있을까요? Spring Data JPA를 좋아하는 이유 중 하나는 사용자가 간편한 방식으로 사용자 정의 쿼리를 생성할 수 있는 추상화와 편의성을 제공하기 때문입니다. 문제는 사용자가 항상 다양한 조건으로 필터링을 원한다는 점입니다.

예를 들어, 사용자가 특정 팀에 속한 모든 회원을 가져오고자 하는 경우를 가정해 보겠습니다. 또한 사용자는 회원 이름으로 검색하기를 원합니다.

이 경우 team id 는 필수이며 search text 는 선택사항 입니다. 이를 위해 where 절의 두 개의 BooleanExpression 으로 구성할 수 있습니다. 이 구현의 좋은점은 QueryDSL이 null 을 반환하는 모든 BooleanExpression 은 무시한다는 것입니다. search text 값이 null 이거나 비어있는 경우, 이 쿼리는 team id 만 필터링 됩니다.

public List<MemberResponse> searchMembersByTeamId(long teamId, String searchText) {
        QMember memberTable = new QMember("member");

        return queryFactory
                .select(
                        new QMemberResponse(
                                memberTable.id,
                                memberTable.name
                        )
                )
                .from(memberTable)
                .where(
                        memberTable.team.id.eq(teamId),
                        nameLike(searchText)
                )
                .fetch();
    }

    private BooleanExpression nameLike(String searchText) {
        if (!StringUtils.hasText(searchText)) {
            return null;
        }

        QMember memberTable = new QMember("member");
        return memberTable.name.containsIgnoreCase(searchText);
    }

Pagination

마지막으로 페이징입니다. Spring DataPageable 을 그대로 사용할 수 있습니다. new PageImpl() 대신에  PageableExecutionUtils.getPage() 을 사용한다는 것에 유의하세요. 이는 count 호출 횟수를 줄이는 최적화 기법입니다.

public Page<MemberResponse> members(Pageable pageable) {
        QMember memberTable = new QMember("member");

        List<MemberResponse> members = queryFactory
                .select(
                        new QMemberResponse(
                                memberTable.id,
                                memberTable.name
                        )
                )
                .from(memberTable)
                .offset(pageable.getOffset())
                .limit(pageable.getPageSize())
                .fetch();

        return PageableExecutionUtils.getPage(members, pageable, () -> countQuery().fetchOne());
    }

    private JPAQuery<Long> countQuery() {
        QMember memberTable = new QMember("member");
        return queryFactory
                .select(memberTable.count())
                .from(memberTable);
    }

Test Code

이상적으로는 테스트 코드에서 사용하는 모든 데이터는 테스트에서 준비해야 합니다. 그러나 이 예제에서는 읽기 작업만을 포함하며 데모 목적으로 간단하게 유지하기 위하여 CommandLineRunner 를 사용하여 데이터를 생성할 것입니다.

MainApplication.java

package io.jay.service;

import com.querydsl.jpa.impl.JPAQueryFactory;
import io.jay.service.model.MemberResponse;
import io.jay.service.model.TeamResponse;
import io.jay.service.repository.DefaultTeamQueryRepository;
import io.jay.service.repository.Member;
import io.jay.service.repository.Team;
import io.jay.service.repository.TeamRepository;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import lombok.RequiredArgsConstructor;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Component;
import org.springframework.stereotype.Controller;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;

import java.util.List;


@SpringBootApplication
public class MainApplication {

    public static void main(String[] args) {
        SpringApplication.run(MainApplication.class, args);
    }
}

@Configuration
class QueryDslConfiguration {

    @PersistenceContext
    private EntityManager em;

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


@Component
@RequiredArgsConstructor
class DataInitializer implements CommandLineRunner {

    private final TeamRepository repository;

    @Override
    @Transactional
    public void run(String... args) {
        Team firstTeam = new Team("First Team");
        firstTeam.addMember(new Member("Jay"));
        firstTeam.addMember(new Member("Steve"));
        firstTeam.addMember(new Member("Jun"));
        firstTeam.addMember(new Member("Joel"));

        Team secondTeam = new Team("Second Team");
        secondTeam.addMember(new Member("Ats"));
        secondTeam.addMember(new Member("Ken"));
        secondTeam.addMember(new Member("Yu"));

        repository.saveAll(List.of(firstTeam, secondTeam));
    }
}

@Controller
@ResponseBody
@RequiredArgsConstructor
class TeamController {

    private final DefaultTeamQueryRepository query;

    @GetMapping("/v1/teams/{teamId}/members")
    public List<MemberResponse> members(@PathVariable long teamId) {
        return query.findMembersByTeamId(teamId);
    }

    @GetMapping("/v2/teams/{teamId}/members")
    public List<MemberResponse> searchMembers(@PathVariable long teamId, @RequestParam(required = false) String searchText) {
        return query.searchMembersByTeamId(teamId, searchText);
    }

    @GetMapping("/v3/members")
    public Page<MemberResponse> paginatedMembers(Pageable pageable) {
        return query.members(pageable);
    }
}

 

DefaultTeamQueryRepository.java

package io.jay.service.repository;

import com.querydsl.core.types.dsl.BooleanExpression;
import com.querydsl.jpa.impl.JPAQuery;
import com.querydsl.jpa.impl.JPAQueryFactory;
import io.jay.service.model.MemberResponse;
import io.jay.service.model.QMemberResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.support.PageableExecutionUtils;
import org.springframework.stereotype.Repository;
import org.springframework.util.StringUtils;

import java.util.List;

@Repository
@RequiredArgsConstructor
public class DefaultTeamQueryRepository {

    private final JPAQueryFactory queryFactory;

    public List<MemberResponse> findMembersByTeamId(long teamId) {
        QMember memberTable = new QMember("member");

        return queryFactory
                .select(
                        new QMemberResponse(
                                memberTable.id,
                                memberTable.name
                        )
                )
                .from(memberTable)
                .where(
                        memberTable.team.id.eq(teamId)
                )
                .fetch();
    }


    public List<MemberResponse> searchMembersByTeamId(long teamId, String searchText) {
        QMember memberTable = new QMember("member");

        return queryFactory
                .select(
                        new QMemberResponse(
                                memberTable.id,
                                memberTable.name
                        )
                )
                .from(memberTable)
                .where(
                        memberTable.team.id.eq(teamId),
                        nameLike(searchText)
                )
                .fetch();
    }

    private BooleanExpression nameLike(String searchText) {
        if (!StringUtils.hasText(searchText)) {
            return null;
        }

        QMember memberTable = new QMember("member");
        return memberTable.name.containsIgnoreCase(searchText);
    }


    public Page<MemberResponse> members(Pageable pageable) {
        QMember memberTable = new QMember("member");

        List<MemberResponse> members = queryFactory
                .select(
                        new QMemberResponse(
                                memberTable.id,
                                memberTable.name
                        )
                )
                .from(memberTable)
                .offset(pageable.getOffset())
                .limit(pageable.getPageSize())
                .fetch();

        return PageableExecutionUtils.getPage(members, pageable, () -> countQuery().fetchOne());
    }

    private JPAQuery<Long> countQuery() {
        QMember memberTable = new QMember("member");
        return queryFactory
                .select(memberTable.count())
                .from(memberTable);
    }
}

 

MainApplicationTests.java

package io.jay.service;

import io.jay.service.model.MemberResponse;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.web.reactive.server.WebTestClient;

import java.util.List;

import static org.assertj.core.api.Assertions.assertThat;

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class MainApplicationTests {

    @Autowired
    WebTestClient wtc;

    @Nested
    class Members {
        @Test
        void returnsMembers() {
            var response = wtc.get()
                    .uri("/v1/teams/1/members")
                    .exchange()
                    .expectStatus()
                    .isOk()
                    .expectBodyList(MemberResponse.class)
                    .returnResult()
                    .getResponseBody();


            assertThat(response).hasSize(4);
        }
    }

    @Nested
    class SearchMembers {
        @Test
        void returnsMembers() {
            var response = wtc.get()
                    .uri("/v2/teams/1/members")
                    .exchange()
                    .expectStatus()
                    .isOk()
                    .expectBodyList(MemberResponse.class)
                    .returnResult()
                    .getResponseBody();


            assertThat(response).hasSize(4);
        }

        @Test
        void returnsMembersWithMatchingName() {
            var response = wtc.get()
                    .uri("/v2/teams/1/members?searchText=jay")
                    .exchange()
                    .expectStatus()
                    .isOk()
                    .expectBodyList(MemberResponse.class)
                    .returnResult()
                    .getResponseBody();


            assertThat(response).hasSize(1);
            assertThat(response.get(0).name()).isEqualTo("Jay");
        }
    }

    @Nested
    class PaginatedMembers {
        @Test
        void returnsMembersWithPagination() {
            wtc.get()
                    .uri("/v3/members?page=0&size=2")
                    .exchange()
                    .expectStatus()
                    .isOk()
                    .expectBody()
                    .jsonPath("$.totalPages").isEqualTo(4)
                    .jsonPath("$.totalElements").isEqualTo(7)
                    .jsonPath("$.content.length()").isEqualTo(2);
        }
    }
}

 

전체 소스는 다음에서 확인할 수 있습니다.:

 

GitHub - jskim1991/spring-boot-querydsl-sample: Spring Boot 3 + QueryDSL: Projection, Dynamic Where, Pagination

Spring Boot 3 + QueryDSL: Projection, Dynamic Where, Pagination - GitHub - jskim1991/spring-boot-querydsl-sample: Spring Boot 3 + QueryDSL: Projection, Dynamic Where, Pagination

github.com