Spring Example: Community #8 검색 feat Querydsl

함형주·2023년 1월 10일
0

질문, 피드백 등 모든 댓글 환영합니다.

지난 블로그에서 다뤘던 정렬, 페이징에 이어 검색 기능을 개발합니다.

기존의 Spring data JPA 만을 사용하여 검색 기능을 구현하게 되면 코드가 매우 복잡해집니다. 특히 검색 조건이 여러개일 경우(제목, 내용, 작성자 등) 수많은 if문과 쿼리 메서드가 필요하게 되므로 이를 구현하는 것이 쉽지 않습니다.

때문에 Querydsl을 도입하여 검색 기능을 구현하겠습니다.

이 블로그에선 Querydsl 상세 사용법에 대한 설명은 생략하겠습니다.

Querydsl

Querdsl은 자바 코드로 sql(jpql) 생성할 수 있도록 하는 오픈소스 라이브러리입니다.

스프링 데이터 JPA 만을 이용하여 특히 복잡한 쿼리를 생성하게 되면 몇 가지 애로사항이 존재합니다.
쿼리 메서드를 사용하게 되면 메서드 이름이 너무 길어지게 되거나 @Query를 사용하여 직접 jpql을 사용하면 이 또한 쿼리가 길어지고 String으로 sql을 작성하게 되므로 오류를 판단하기도 힘듭니다. 그리고 동적 쿼리의 경우 컴파일 시점에 오류를 잡지 못하고 런타임에러를 발생시킬 수도 있습니다.

Querdsl은 자바 코드로 쿼리를 작성하므로 컴파일 시점에 문법 오류를 쉽게 확인할 수 있고 자체적으로 제공하는 기능 덕에 동적 쿼리 작성이 매우 쉬워집니다. 또한 자주 사용되는 부분을 메서드로 분리할 수 있고 자바 코드기에 IDE의 자동 완성 기능을 이용할 수도 있습니다.

Querdsl을 사용하기 위해선 gradle에서 의존성을 추가해 주어야 합니다. 스프링부트 2.6 이상의 버전에선 Querdsl 5.0 이상만 지원됩니다.

build.gradle

buildscript {
	ext {
		queryDslVersion = "5.0.0" // 추가
	}
}

plugins {
	id 'java'
	id 'org.springframework.boot' version '2.7.6'
	id 'io.spring.dependency-management' version '1.0.15.RELEASE'
	id "com.ewerk.gradle.plugins.querydsl" version "1.0.10" // 추가

}

apply plugin: 'io.spring.dependency-management'
apply plugin: "com.ewerk.gradle.plugins.querydsl" // 추가

dependencies {
	implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
	implementation 'org.springframework.boot:spring-boot-starter-security'
	implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
	implementation 'org.springframework.boot:spring-boot-starter-web'
	implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity5'

	//querydsl 추가
	implementation 'com.querydsl:querydsl-jpa'
	//querydsl 추가
	implementation 'com.querydsl:querydsl-apt'

	compileOnly 'org.projectlombok:lombok'
	runtimeOnly 'com.h2database:h2'
	annotationProcessor 'org.projectlombok:lombok'
	testImplementation 'org.springframework.boot:spring-boot-starter-test'
	testImplementation 'org.springframework.security:spring-security-test'
}

//querydsl 추가
def querydslDir = "$buildDir/generated/querydsl"

querydsl {
	library = "com.querydsl:querydsl-apt"
	jpa = true
	querydslSourcesDir = querydslDir
}
sourceSets {
	main {
		java {
			srcDirs = ['src/main/java', querydslDir]
		}
	}
}

compileQuerydsl {
	options.annotationProcessorPath = configurations.querydsl
}

configurations {
	querydsl.extendsFrom compileClasspath
}

추가된 부분만 기재했습니다.

의존성 추가 이후 터미널에 ./gradlew clean compileQuerydsl을 입력하여 Querydsl에서 사용하는 QType 을 생성해줍니다.

커스텀 JPARepository

지금까지는 스프링 데이터 JPA의 도움을 받아 JPARepository를 상속 받은 인터페이스를 사용했습니다. 스프링부트가 구동되어 해당 인터페이스 구현체를 생성해 주었기 때문에 인터페이스를 그대로 사용할 수 있었습니다.

하지만 Querydsl을 사용하기 위해선 JPARepository 구현체를 직접 생성해 주어야 합니다.

JPARepository 구현체를 생성하는 방법은 아래 사진과 같습니다.

김영한님의 실전! Querydsl 강의에서 발췌했습니다.

위 사진에서 Member 대신 Post를 사용하여 생성해주겠습니다. (생성하는 과정은 생략하겠습니다.)

검색

먼저 검색에 사용될 클래스를 만들어주겠습니다.

PostSearch

@Getter @Setter
public class PostSearch {
    private SearchType searchType;
    private String searchWord;
    
    public boolean isEmpty() {
        return searchType == null || searchWord == null;
    }

}

SearchType(검색 조건, ENUM)과 searchWord(검색어)를 필드로 가집니다.

isEmpty() 메서드는 이후 검색 조건을 판별할 때 사용합니다. 값이 true면 쿼리에서 조건절(where)이 생성되지 않습니다.

SearchType

public enum SearchType {
    title,body,member
}

ENUM 값으로 title(게시글 제목), body(내용), member(작성자)를 사용합니다.

PostRepositoryCustom

public interface PostRepositoryCustom {

    Page<Post> findAllPageAndSearch(Pageable pageable, PostSearch postSearch);
}

이제 본격적으로 검색 기능을 구현하겠습니다.

PostRepositoryImpl

import static example.community.domain.QMember.member;
import static example.community.domain.QPost.post;

public class PostRepositoryImpl implements PostRepositoryCustom {

    private final JPAQueryFactory querydsl;

    public PostRepositoryImpl(EntityManager em) {
        this.querydsl = new JPAQueryFactory(em);
    }

    @Override
    public Page<Post> findAllPageAndSearch(Pageable pageable, PostSearch postSearch) {
        List<Post> result = querydsl.selectFrom(post).join(post.member, member).fetchJoin()
                .where(searchTypeAndWord(postSearch))
                .offset(pageable.getOffset()).limit(pageable.getPageSize())
                .orderBy(post.id.desc())
                .fetch();

        JPAQuery<Long> count = querydsl.select(post.count()).from(post).join(post.member, member)
                .where(searchTypeAndWord(postSearch));

        return PageableExecutionUtils.getPage(result, pageable, count::fetchOne);
    }

    private BooleanExpression searchTypeAndWord(PostSearch postSearch) {

        if (postSearch.isEmpty()) return null;

        switch (postSearch.getSearchType()) {
            case member: return post.member.name.eq(postSearch.getSearchWord());

            case title: return post.title.contains(postSearch.getSearchWord());

            case body: return post.body.contains(postSearch.getSearchWord());

            default: return null;
        }
    }
}

Querydsl 문법을 정말 간단하게 설명하자면,

QType.select() : 조회 할 QTpye 엔티티
.from() : 엔티티를 조회 할 테이블
.join() : join 할 테이블
.fetchJoin() : JPQL의 페치 조인
.where() : 검색 조건, and() or() 메서드 체인 사용 가능
.offset() : offset
.limit() : limit
.orderBy() : 정렬
.fetch() : 리스트 조회, 단건 조회는 fetchOne()
fetchCount() : count 쿼리

where() 같은 경우에는 null 값이 들어온다면 메서드가 무시됩니다.(조건절 생성 x)
또한 위에서 언급했듯이 where 인자를 메서드로 생성하여 사용할 수 있기에 동적 쿼리를 만들기에 매우 유용합니다.

searchTypeAndWord() 메서드를 생성하여 postSearch 값의 상태의 따라 다른 값을 where()의 인자로 반환하여 동적 쿼리를 생성하였습니다.

findAllPageAndSearch() 파라미터로 넘어온 pageable을 이용하여 정렬 및 페이징 메서드를 생성했습니다.

추가로 select()에 인자로 QType.count()를 사용한 count 쿼리를 따로 생성했습니다.
PageableExecutionUtils를 사용해 count 쿼리와 메인 조회 쿼리를 Page 객체로 만들어 반환했습니다.

PostService

public class PostService {

    public Page<PostListDto> findList(Pageable pageable, PostSearch postSearch) {
        Page<Post> find = postRepository.findAllPageAndSearch(pageable, postSearch);
        return find.map(PostListDto::new);
    }
}

PostController

public class PostController {

    @GetMapping("/post")
    public String postList(Model model, @ModelAttribute("postSearch") PostSearch postSearch,
                           @PageableDefault(size = 10, sort = "id", direction = Sort.Direction.DESC) Pageable pageable) {
        Page<PostListDto> postList = postService.findList(pageable, postSearch);
        model.addAttribute("postListDto", postList.getContent());

        return "post/list";
    }
}

Service와 Controller에도 변경사항을 적용해줍니다.

화면 제작

지금까지 구현한 검색, 페이징 기능을 post/list.html에 적용시킵니다. 먼저 화면 하단에 보여줄 페이지 컴포넌트(?) 를 만들어주겠습니다.

요렇게 생긴거... (생각보다 까다롭습니다.)

PostController

public class PostController {
    private static int getStartPage(Page<PostListDto> postList) {
        return ((postList.getNumber() / 5) * 5) + 1;
    }

    private static int getEndPage(Page<PostListDto> postList, int startPage) {
        if (postList.getTotalPages() == 0) return 1;
        return Math.min(postList.getTotalPages(), startPage + 4);
    }

    @GetMapping("/post")
    public String postList(Model model, @ModelAttribute("postSearch") PostSearch postSearch,
                           @PageableDefault(size = 10, sort = "id", direction = Sort.Direction.DESC) Pageable pageable) {
        Page<PostListDto> postList = postService.findList(pageable, postSearch);
        model.addAttribute("postListDto", postList.getContent());

        int startPage = getStartPage(postList);

        model.addAttribute("startPage", getStartPage(postList));
        model.addAttribute("currentPage", postList.getNumber() + 1);
        model.addAttribute("endPage", getEndPage(postList, startPage));
        model.addAttribute("hasPrevious", postList.hasPrevious());
        model.addAttribute("hasNext", postList.hasNext());
        model.addAttribute("searchTypes", SearchType.values());

        return "post/list";
    }
}

위의 페이지 처럼 만들기 위해선 5가지의 값이 필요합니다.

저는 1~5, 6~10.. 처럼 한 번에 5개의 페이지를 이동할 수 있도록 만들겠습니다.

Page에서 제공하는 값을 토대로 startPage, currentPage, endPage, hasPrevious, hasNext를 만들어 주었습니다.

우선 currentPage 같은 경우는 Page에서 제공하는 page 값이 0부터 시작하기에 + 1을 해주었습니다.

getStartPage(), getEndPage()를 생성하여 페이지 시작 번호와 끝 번호를 생성했습니다. (currentPage가 7이고 10 이상의 페이지가 있다면 startPage=6, endPage=10,
currentPage가 7이고 8 페이지까지 있다면 startPage=6, endPage=8)

이와 더불어 SearchType 값과 함께 model에 포함하여 view로 전달하겠습니다.

list.html

<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="utf-8">
    <link rel="stylesheet" type="text/css" href="/css/bootstrap.css">
</head>
<body>
<div th:replace="~{header/logout :: logout}"></div>

<br>
<div class="px-5 gap-2 col-10 mx-auto">
    <div class="d-grid gap-2 mx-auto">
        <button type="button" class="btn btn-primary btn-lg"
                th:onclick="|location.href='@{/post/add}'|">글쓰기
        </button>
<!--        여기부터 추가-->
        <form th:action="@{/post}" method="get" th:object="${postSearch}">
            <div class="row">
                <div class="col"></div>
                <div class="col-2">
                    <select name="searchType" class="form-select">
                        <option value="">==검색 조건==</option>
                        <option th:if="*{searchType}" th:value="*{searchType}" th:text="#{*{searchType}}" selected></option>
                        <option th:unless="${type} == *{searchType}" th:each="type : ${searchTypes}" th:value="${type}"
                                th:text="#{${type}}"></option>
                    </select>
                </div>
                <div class="col-3">
                    <input type="text" id="searchWord" class="w-100 form-control" placeholder="검색어"
                           th:field="*{searchWord}">
                </div>
                <div class="col-1">
                    <button type="submit" class="btn btn-primary btn-lg">검색
                    </button>
                </div>
            </div>

            <br>

            <div class="row">
                <div class="col">
                    <table class="table">
                        <thead>
                        <tr>
                            <th>작성자</th>
                            <th>제목 [댓글]</th>
                            <th>작성일</th>
                            <th>좋아요</th>
                        </tr>
                        </thead>

                        <tbody>
                        <tr th:each="post : ${postListDto}">
                            <td th:text="${post.membername}"></td>
                            <td>
                                <a th:href="@{/post/{post_id}(post_id=${post.id})}"
                                   th:text="|${post.title} [${post.commentNum}]|"></a>
                            </td>
                            <td th:text="${#temporals.format(post.createdDate)}"></td>
                            <td th:text="${post.heartNum}"></td>
                        </tr>
                        </tbody>
                    </table>

                    <button th:unless="${hasPrevious}" type="button" class="btn btn-outline-dark btn-sm" disabled>이전
                    </button>
                    <button th:if="${hasPrevious}" type="submit" name="page" th:value="${currentPage - 2}"
                            class="btn btn-outline-dark btn-sm">이전
                    </button>

                    <th:block th:each="page : ${#numbers.sequence(startPage, endPage)}">
                        <button th:if="${page == currentPage}" name="page" th:value="${page - 1}" type="submit"
                                th:text="|[ ${page} ]|" class="btn btn-link btn-sm"></button>
                        <button th:if="${page != currentPage}" name="page" th:value="${page - 1}" type="submit"
                                th:text="${page}" class="btn btn-link btn-sm"></button>
                    </th:block>

                    <button th:if="${hasNext}" type="submit" name="page" th:value="${currentPage}"
                            class="btn btn-outline-dark btn-sm">다음
                    </button>
                    <button th:unless="${hasNext}" type="button" class="btn btn-outline-dark btn-sm" disabled>다음
                    </button>
<!--            여기까지 추가-->
                </div>
            </div>
        </form>
    </div>
</div>
</div>
</body>
</html>

검색의 경우 <select> 태그를 사용하여 검색 조건을 선택하고 <input> 태그를 사용하여 검색어를 입력받았습니다.
<option> 태그에서 표시될 텍스트는 의미를 정확히 전달하기 위해 메시지 소스로 제공하였습니다.(아래에서 메시지 소스를 다룹니다)

페이지는 컨트롤러로부터 넘겨받은 값을 기반으로 페이지 이동 버튼을 생성했습니다.

hasPrevious, hasNext를 사용하여 [이전], [다음] 버튼을 활성화(type=subit) 여부를 결정하였습니다.

타임리프가 제공하는 #numbers 문법을 통해 startPage부터 endPage까지 반복하여 페이지 버튼을 생성했고 만약 그 값이 currentPage 일 경우 대괄호를 포함시켰습니다.

검색과 페이지 이동 모두 하나의 <form> 태그 안에 submit 타입의 버튼으로 생성하고 name, value 속성을 구현하여 해당 값이 쿼리 파라미터로 전송되도록 구현했습니다.
이유는 검색 후 페이지 이동을 하면 이전에 검색한 데이터가 유지가 안되는 문제가 발생하여 검색 및 페이지 이동 시 해당 값이 쿼리 파라미터로 서버에 전송되도록 만들었습니다.

이렇게 하면 페이지 이동 시 검색 및 페이지 이동 시 필요없는 데이터에는 "" 가 전송되지만 순수 Html + Thymeleaf 조합과 제 실력으로는 이 정도가 최선이었습니다..


page는 null이 전송되면 default 값인 0으로 생성되고 검색의 경우는 미리 PostSearch 클래스에서 isEmpty로 값이 하나라도 없을 경우 검색하지 않도록 구현했었습니다.

messages.properties / 메시지 소스

title=글제목
body=내용
member=작성자

메시지 소스에 관련한 자세한 사항은 이전에 작성한 블로그 참고해 주세요.

화면 캡처

글제목, 내용, 작성자로 각각 검색해보겠습니다.

글제목으로 검색

내용으로 검색


작성자로 검색

검색 후 페이지 이동

글제목으로 검색 이후 페이지 이동한 결과 (다음 버튼 클릭) :

검색 조건이 잘 유지되는 것을 확인할 수 있습니다.

다음으로

되돌아보니 해당 블로그가 참 정신없이 작성되었다고 느끼는데 개발 과정은 더욱 정신없었습니다... Querydsl을 도입하고 화면을 만들고.. 특히 화면을 제작할 때 페이지 이동 시 검색 조건을 유지하도록 만드는 것에 시간을 정말 많이 사용했습니다.

단순한 프로젝트임에도 참으로 다사다난하게 프로젝트가 진행되었습니다. (어디 물어볼 곳도 마땅치 않은 비전공자의 숙명인가요 ㅜㅜ) 이제 프로젝트를 배포 후 마무리 하려 합니다.

github , 배포 URL (첫 접속 시 로딩이 걸릴 수 있습니다.)

profile
평범한 대학생의 공부 일기?

0개의 댓글