[From Hello To QueryDSL] Complex Search (5/12)

By | 2020년 3월 13일
Table of Contents

Complex Search

검색조건이 하나 또는 두개로 고정일 경우, JPA 의 쿼리자동생성 기능으로 처리가 가능합니다.

하지만, 실무에서는 1 ~ N 개의 복합 검색조건을 요구하는 경우가 매우 많아 JPA 만으로 감당이 안됩니다. 그래서 쓰이는 라이브러리 중에 querydsl 을 이용한 다이나믹 쿼리 생성을 해봅니다.

개발환경

  • Spring Boot 2.1.x
  • Gradle 4.10.2

프로젝트는 이전 글에서 작성된 프로젝트에 파일을 추가 또는 수정하는 방식으로 진행됩니다. 이전 글을 따라 하지 않은 경우, 먼저 이전 글대로 프로젝트를 구성하시기 바랍니다.

파일추가 및 수정

querydsl 의 정상적인 컴파일을 위해 build.gradle 을 아래와 같이 수정합니다.

build.gradle

buildscript {
    ext {
        springBootVersion = '2.1.13.RELEASE'
    }
    repositories {
        mavenCentral()
        jcenter()
    }
    dependencies {
        classpath("org.springframework.boot:spring-boot-gradle-plugin:${springBootVersion}")
    }
}

apply plugin: 'java'
apply plugin: 'war'
apply plugin: 'org.springframework.boot'
apply plugin: 'io.spring.dependency-management'

group 'kr.co.episode.example'
version '0.0.1-SNAPSHOT'
sourceCompatibility = 11

repositories {
    mavenCentral()
    jcenter()
}

dependencies {
    compile('org.springframework.boot:spring-boot-starter-web')
    compile('org.projectlombok:lombok')
    compile('org.springframework.boot:spring-boot-starter-thymeleaf')
    compile('com.querydsl:querydsl-jpa')
    compile('com.querydsl:querydsl-apt')
    compile('org.springframework.boot:spring-boot-starter-data-jpa')
    compile('org.springframework.session:spring-session-jdbc')
    testCompile('org.springframework.boot:spring-boot-starter-test')
    testCompile("org.springframework.security:spring-security-test")
}

/** QueryDSL Class Generate Script */
def generatedJavaSrcDir = 'src/main/generated'
def queryDslOutput = file(generatedJavaSrcDir)

sourceSets {
    main {
        java {
            srcDirs = ['src/main/java', generatedJavaSrcDir]
        }
    }
}

/** QClass 생성 */
task generateQueryDSL(type: JavaCompile, group: 'build') {
    doFirst {
        delete queryDslOutput
        queryDslOutput.mkdirs()
    }
    source = sourceSets.main.java
    classpath = configurations.compile
    destinationDir = queryDslOutput
    options.compilerArgs = [
            '-proc:only',
            '-processor',
            'com.querydsl.apt.jpa.JPAAnnotationProcessor'
    ]
}
compileJava.dependsOn(generateQueryDSL)

/** clean 태스크 실행시 QClass 삭제 */
clean {
    delete queryDslOutput
}

두개의 검색조건과 한개의 정렬순서를 전달할 클래스를 생성합니다.

src/main/java/kr/co/episode/example/domain/posts/PostsSearch.java

@Getter
public class PostsSearch {

    private String title;
    private String author;
    private boolean orderBy;

    public PostsSearch(String title, String author, boolean orderBy) {
        this.title = title;
        this.author = author;
        this.orderBy = orderBy;
    }
}

우선 커스텀 인터페이스를 생성합니다.

src/main/java/kr/co/episode/example/domain/posts/PostsPagingRepositoryCustom.java

public interface PostsPagingRepositoryCustom {

    Page<Posts> search(Pageable pageable, PostsSearch postsSearch);
}

QClass 를 찾지 못하지만 프로젝트는 정상적으로 실행됩니다. 그래도 오류를 수정하고 싶으면 메뉴에서 Build > Rebuild Project 하면 오류가 사라집니다.

src/main/java/kr/co/episode/example/domain/posts/PostsPagingRepositoryImpl.java

public class PostsPagingRepositoryImpl extends QuerydslRepositorySupport implements PostsPagingRepositoryCustom {

    public PostsPagingRepositoryImpl() {
        super(Posts.class);
    }

    @Override
    public Page<Posts> search(Pageable pageable, PostsSearch postsSearch) {

        QPosts posts = QPosts.posts;

        JPQLQuery query = from(posts);

        if (!"".equals(postsSearch.getTitle())) {
            query.where(posts.title.contains(postsSearch.getTitle()));
        }

        if (!"".equals(postsSearch.getAuthor())) {
            query.where(posts.author.contains(postsSearch.getAuthor()));
        }

        if (postsSearch.isOrderBy()) {
            query.orderBy(posts.id.asc());
        } else {
            query.orderBy(posts.id.desc());
        }

        long totalCount = query.fetchCount();
        List<Posts> results = getQuerydsl().applyPagination(pageable, query).fetch();

        return new PageImpl<>(results, pageable, totalCount);
    }
}

src/main/java/kr/co/episode/example/domain/posts/PostsPagingRepository.java

public interface PostsPagingRepository extends JpaRepository<Posts, Long>, PostsPagingRepositoryCustom {

    // ......

    Page<Posts> search(Pageable pageable, PostsSearch postsSearch);
}

src/main/java/kr/co/episode/example/web/dto/PostsSearchDto.java

@Getter
public class PostsSearchDto {

    private String title;
    private String author;
    private boolean orderBy;

    @Builder
    public PostsSearchDto(String title, String author, boolean orderBy) {
        this.title = title;
        this.author = author;
        this.orderBy = orderBy;
    }

    public PostsSearchDto(Map<String, String> params) {
        this.title = params.get("title") == null ? "" : params.get("title");
        this.author = params.get("author") == null ? "" : params.get("author");
        this.orderBy = params.get("orderBy") != null;
    }

    public PostsSearch toEntity() {
        return new PostsSearch(title, author, orderBy);
    }
}

src/main/java/kr/co/episode/example/service/posts/PostsPagingService.java

@RequiredArgsConstructor
@Service
public class PostsPagingService {

    private final PostsPagingRepository postsPagingRepository;

    // ......

    @Transactional(readOnly = true)
    public Page<PostsListResponseDto> search(Pageable pageable, PostsSearchDto postsSearchDto) {
        Page<Posts> posts = postsPagingRepository.search(pageable, postsSearchDto.toEntity());
        List<PostsListResponseDto> results = posts.getContent().stream()
                .map(PostsListResponseDto::new)
                .collect(Collectors.toList());
        long totalCount = posts.getTotalElements();

        return new PageImpl<>(results, pageable, totalCount);
    }
}

src/main/resources/templates/index.html

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>게시판</title>
    <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.2/css/bootstrap.min.css">
    <style>
        .container p { display: inline }
    </style>
</head>
<body class="container">

<h1>스프링 부트 게시판</h1>

<div class="col-md-12">
    <div class="row">
        <div class="col-md-6">
            <a href="/posts/save" role="button" class="btn" btn-primary>글 등록</a>
        </div>
    </div>
</div>

<div style="height: 80px;">

</div>

<table class="table">
    <tr>
        <th>글 번호</th>
        <th>글쓴이</th>
        <th>글 제목</th>
        <th>최종수정</th>
    </tr>
    <tr th:each="posts: ${posts}">
        <td th:text="${posts.id}"></td>
        <td>
            <a th:href="${posts.link}" th:text="${posts.author}" />
        </td>
        <td>
            <a th:href="${posts.link}" th:text="${posts.title}" />
        </td>
        <td th:text="${posts.modifiedDate}"></td>
    </tr>
</table>

<div class="col-md-12">
    <div class="col-md-4">
        <form name="frm">
            <input type="hidden" name="page" value="0">
            <div class="form-group">
                <label for="title">제목</label>
                <input type="text" class="form-control" id="title" placeholder="제목을 입력하세요." name="title" th:value="${search.title}" />
            </div>
            <div class="form-group">
                <label for="author">작성자</label>
                <input type="text" class="form-control" id="author" name="author" placeholder="작성자를 입력하세요." th:value="${search.author}" />
            </div>
            <div class="custom-control custom-checkbox">
                <input type="checkbox" class="custom-control-input" id="orderBy" name="orderBy" th:checked="${search.orderBy}" />
                <label class="custom-control-label" for="orderBy">글번호 순으로</label>
            </div>
        </form>

        <button type="button" class="btn btn-primary" id="btn-search">검색</button>
    </div>
</div>

<nav style="text-align: center;">
    <ul class="pagination"
        th:with="start=${T(Math).floor(posts.number/10)*10},
                    last=(${start + 9 < posts.totalPages-1 ? start + 9 : posts.totalPages-1})">
        <li>
            <a th:onclick="|javascript:gotoPage(0)|" aria-label="First">
                <span aria-hidden="true">First</span>
            </a>
        </li>

        <li th:class="${posts.first} ? 'disabled'">
            <a th:onclick="|javascript:gotoPage(${posts.number-1})|" aria-label="Previous">
                <span aria-hidden="true"><</span>
            </a>
        </li>

        <li th:each="page: ${#numbers.sequence(start, last)}" th:class="${page == posts.number} ? 'active'">
            <a th:text="${page+1}" th:onclick="|javascript:gotoPage(${page})|" ></a>
        </li>

        <li th:class="${posts.last} ? 'disabled'">
            <a th:onclick="|javascript:gotoPage(${posts.number+1})|" aria-label="Next">
                <span aria-hidden="true">></span>
            </a>
        </li>

        <li>
            <a th:onclick="|javascript:gotoPage(${posts.totalPages-1})|" aria-label="Last">
                <span aria-hidden="true">Last</span>
            </a>
        </li>
    </ul>
</nav>

<script src="https://code.jquery.com/jquery-3.3.1.min.js"></script>
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/js/bootstrap.min.js"></script>

<script src="/js/app/index.js"></script>
<script>
    function gotoPage(pg) {
        document.frm.page.value = pg;
        document.frm.submit();
    }
</script>
</body>
</html>

src/main/java/kr/co/episode/example/web/IndexController.java

    // ......
    @GetMapping("/")
    public String index(Model model, @PageableDefault Pageable pageable, @RequestParam Map<String, String> params) {
        pageable = PageRequest.of(pageable.getPageNumber(), 3);
        PostsSearchDto postsSearchDto = new PostsSearchDto(params);

        model.addAttribute("posts", postsPagingService.search(pageable, postsSearchDto));
        model.addAttribute("search", postsSearchDto);

        return "index";
    }
    // ......

답글 남기기