상품 관리하기(Querydsl)

진크·2022년 3월 3일
0
post-thumbnail

챕터7 안에 시리즈를 마무리하고 싶었는데....
정리하다 보니 점점 길어지네요
그래도 잘 마무리 해보겠습니다.

상품 관리 화면에서는 상품 조회하는 조건을 설정 후 페이징 기능을 통해 일정 개수의 상품만 불러오며, 선택한 상품 상세 페이지로 이동할 수 있는 기능까지 구현 해보겠습니다. 조회 조건으로 설정할 값은 다음과 같습니다.

조회 조건

  • 상품 등록일
  • 상품 판매 상태
  • 상품명 또는 상품 등록자 아이디

이렇게 조회 조건이 복잡한 화면은 Querydsl을 이용해서 조건에 맞는 쿼리를 동적으로 쉽게 생성할 수 있습니다. Querydsl을 사용하면 비슷한 쿼리를 재활용할 수 있다는 장점이 있습니다.

Querydsl을 사용하기 위해서는 QDomain을 생성해야 합니다. QDomain을 생성하기 위해서 gradle의 컴파일 명령을 실행합니다.

ItemSearchDto 생성

package me.jincrates.gobook.web.dto;

import lombok.Data;
import me.jincrates.gobook.domain.items.ItemSellStatus;

@Data
public class ItemSearchDto {

    private String searchDateType;

    private ItemSellStatus searchSellStatus;

    private String searchBy;

    private String searchQuery = "";
}
  • searchDateType : 현재 시간과 상품 등록일을 비교해서 상품 데이터를 조회합니다. 조회 시간 기준을 아래와 같습니다.
    • all : 상품 등록일 전체
    • 1d : 최근 하루 동안 등록된 상품
    • 1w : 최근 일주일 동안 등록된 상품
    • 1m : 최근 한달 동안 등록된 상품
    • 6m : 최근 6개월 동안 등록된 상품
  • searchBy : 상품을 조회할 때 어떤 유형으로 조회할지 선택합니다.
    • itemNm : 상품명
    • createdBy : 상품 등록자 아이디
  • searchQuery : 조회할 검색어를 저장할 변수입니다.

Querydsl을 Spring Data Jpa와 함께 사용하기 위해서는 사용자 정의 Repository를 정의해야 합니다.

  1. 사용자 정의 인터페이스 작성
  2. 사용자 정의 인터페이스 구현
  3. Spring Data Jpa Repository에서 사용자 정의 인터페이스 상속

사용자 정의 인터페이스 작성

package me.jincrates.gobook.domain.items;

import me.jincrates.gobook.web.dto.ItemSearchDto;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;

public interface ItemRepositoryCustom {

    Page<Item> getAdminItemPage(ItemSearchDto itemSearchDto, Pageable pageable);
}
  • 상품 조회 조건을 담고 있는 itemSearchDto 객체와 페이징 정보를 담고 있는 pageable 객체를 파라미터로 받는 getAdminItemPage 메소드를 정의합니다.

사용자 정의 인터페이스 구현

ItemRepositoryCustom 인터페이스를 구현하는 ItemRepositoryCustomImpl 클래스를 작성합니다. 이때 주의할 점으로 클래스명 끝에 “Impl”을 붙여주어야 정상적으로 동작합니다.

Querydsl에서는 BooleanExpression이라는 where절에서 사용할 수 있는 값을 지원합니다.

package me.jincrates.gobook.domain.items;

import com.querydsl.core.QueryResults;
import com.querydsl.core.types.dsl.BooleanExpression;
import com.querydsl.jpa.impl.JPAQueryFactory;
import me.jincrates.gobook.web.dto.ItemSearchDto;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.Pageable;
import org.thymeleaf.util.StringUtils;

import javax.persistence.EntityManager;
import java.time.LocalDateTime;
import java.util.List;

public class ItemRepositoryCustomImpl implements ItemRepositoryCustom {

    private JPAQueryFactory queryFactory;

    public ItemRepositoryCustomImpl(EntityManager em) {
        this.queryFactory = new JPAQueryFactory(em);
    }

    private BooleanExpression searchSellStatusEq(ItemSellStatus searchSellStatus) {
        return searchSellStatus == null ? null : QItem.item.itemSellStatus.eq(searchSellStatus);
    }

    private BooleanExpression regDtsAfter(String searchDateType) {
        LocalDateTime dateTime = LocalDateTime.now();

        switch (searchDateType) {
            case "1d" : dateTime = dateTime.minusDays(1); break;
            case "1w" : dateTime = dateTime.minusWeeks(1); break;
            case "1m" : dateTime = dateTime.minusMonths(1); break;
            case "6m" : dateTime = dateTime.minusMonths(6); break;
            default: return null; //all, null
        }

        return QItem.item.regTime.after(dateTime);
    }

    private BooleanExpression searchByLike(String searchBy, String searchQuery) {
        switch (searchBy) {
            case "itemNm"   : return QItem.item.itemNm.like("%" + searchQuery + "%"); break;
            case "createBy" : return QItem.item.createdBy.like("%" + searchQuery + "%"); break;
            default: return null;
        }
    }

    @Override
    public Page<Item> getAdminItemPage(ItemSearchDto itemSearchDto, Pageable pageable) {
        List<Item> content = queryFactory
                .selectFrom(QItem.item)
                .where(regDtsAfter(itemSearchDto.getSearchDateType()),
                        searchSellStatusEq(itemSearchDto.getSearchSellStatus()),
                        searchByLike(itemSearchDto.getSearchBy(), itemSearchDto.getSearchQuery()))
                .orderBy(QItem.item.id.desc())
                .offset(pageable.getOffset())
                .limit(pageable.getPageSize())
                .fetch();
        long total = content.size();
        return new PageImpl<>(content, pageable, total);
    }
}
  • implements ItemRepositoryCustom : ItemRepositoryCustom를 구현합니다.
  • private JPAQueryFactory queryFactory : 동적으로 쿼리를 생성하기 위해 JPAQueryFactory 클래스를 사용합니다.
  • JPAQueryFactory의 생성자로 EntityManager 객체를 넣어줍니다.
  • searchSellStatusEq(): 상품 판매 조건이 전체(null)일 경우는 null를 리턴합니다. 결과값이 null이면 where절에서 해당 조건은 무시됩니다.
  • regDtsAfter(String searchDateType) : searchDateType의 값에 따라서 dateTime의 값을 이전의 시간으로 셋팅 후 해당 시간 이후로 등록된 상품만 조회합니다.
  • searchByLike(String searchBy, String searchQuery) : searchBy의 값에 따라서 상품명에 검색어를 포함하고 있는 상품 또는 상품 생성자의 아이디에 검색어를 포함하고 있는 상품을 조회하도록 조건값을 반환합니다.
  • getAdminItemPage(ItemSearchDto itemSearchDto, Pageable pageable) : queryFactory를 이용해서 쿼리를 생성합니다. 쿼리문을 직접 작성할 때의 형태와 문법이 비슷한 것을 볼 수 있습니다.
    • selectFrom(Qitem.item) : 상품 데이터를 조회하기 위해서 QItem의 item을 지정합니다.
    • where 조건절 : BooleanExpression 반환하는 조건문들을 넣어줍니다. ‘,’ 단위로 넣어줄 경우 and 조건으로 인식합니다.
    • offset : 데이터를 가지고 올 시작 인덱스를 지정합니다.
    • limit : 한 번에 가지고 올 최대 개수를 지정합니다.
    • fetch() : 조회 대상 리스트를 반환합니다.

Spring Data Jpa Repository에서 사용자 정의 인터페이스 상속

packageme.jincrates.gobook.domain.items;

importorg.springframework.data.jpa.repository.JpaRepository;
importorg.springframework.data.querydsl.QuerydslPredicateExecutor;

public interfaceItemRepositoryextendsJpaRepository<Item, Long>,
        QuerydslPredicateExecutor<Item>, ItemRepositoryCustom {
}

ItemService 메소드 추가

ItemService 클래스에 상품 조회 조건과 페이지 정보를 파라미터로 받아서 상품 데이터를 조회하는 getAdminItemPage() 메소드를 추가합니다.

package me.jincrates.gobook.service;

//...기존 임포트 생략

@RequiredArgsConstructor
@Transactional
@Service
public class ItemService {

    //...코드 생략

    @Transactional(readOnly = true)
    public Page<Item> getAdminItemPage(ItemSearchDto itemSearchDto, Pageable pageable) {
        return itemRepository.getAdminItemPage(itemSearchDto, pageable);
    } 
}

ItemController 수정

상품 관리 화면 이동 및 조회한 상품 데이터를 화면에 전달하는 로직을 구현하겠습니다. 페이지 번호는 0부터 시작하는 것에 유의합니다.

package me.jincrates.gobook.web;

//..기존 임포트 생략
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import java.util.Optional;

@RequiredArgsConstructor
@Controller
public class ItemController {

    //...
    @GetMapping(value = {"/admin/items", "/admin/items/{page}"})
    public String itemManage(ItemSearchDto itemSearchDto, @PathVariable("page") Optional<Integer> page, Model model) {
        Pageable pageable = PageRequest.of(page.isPresent() ? page.get() :  0, 3);
        Page<Item> items = itemService.getAdminItemPage(itemSearchDto, pageable);
        model.addAttribute("items", items);
        model.addAttribute("itemSearchDto", itemSearchDto);
        model.addAttribute("maxPage", 5);

        return "item/itemManage";
    }
}
  • value에 상품 관리 화면 진입시 번호가 없는 경우와 페이지 번호가 있는 경우 2가지를 매핑합니다.
  • 등록된 상품이 많이 없어서 한 페이지당 총 3개의 상품만 보여주도록 하겠습니다.

itemMange.html 생성

상품 관리 화면 페이지입니다. 상품 데이터를 테이블로 그려주는 영역과 이동할 페이지를 선택하는 영역, 검색 조건을 셋팅 후 검색하는 영역 총 3가지의 영역으로 이루어져 있습니다.

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
      xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
      layout:decorate="~{layouts/default}">

<!-- 사용자 스크립트 추가 -->
<th:block layout:fragment="script">
    <script th:inline="javascript">

        $(document).ready(function(){
            $("#searchBtn").on("click",function(e) {
                e.preventDefault();
                page(0);
            });
        });

        function page(page){
            var searchDateType = $("#searchDateType").val();
            var searchSellStatus = $("#searchSellStatus").val();
            var searchBy = $("#searchBy").val();
            var searchQuery = $("#searchQuery").val();

            location.href="/admin/items/" + page + "?searchDateType=" + searchDateType
            + "&searchSellStatus=" + searchSellStatus
            + "&searchBy=" + searchBy
            + "&searchQuery=" + searchQuery;
        }
    </script>
</th:block>

<!-- 사용자 CSS 추가 -->
<th:block layout:fragment="css">
    <style>

    </style>
</th:block>

<div layout:fragment="content">

    <form th:action="@{'/admin/items/' + ${items.number}}" role="form" method="get" th:object="${items}">
        <table class="table table-hover">
            <thead>
            <tr>
                <td>상품아이디</td>
                <td>상품명</td>
                <td>상태</td>
                <td>등록자</td>
                <td>등록일</td>
            </tr>
            </thead>
            <tbody>
            <tr th:each="item, status: ${items.getContent()}">
                <td th:text="${item.id}"></td>
                <td>
                    <a th:href="'/item/'+${item.id}" th:text="${item.itemNm}"></a>
                </td>
                <td th:text="${item.itemSellStatus.toString().equals('SELL')} ? '판매중' : '품절'"></td>
                <td th:text="${item.createdBy}"></td>
                <td th:text="${item.regTime}"></td>
            </tr>
            </tbody>
        </table>

        <div th:with="start=${(items.number/maxPage)*maxPage + 1}, end=(${(items.totalPages == 0) ? 1 : (start + (maxPage - 1) < items.totalPages ? start + (maxPage - 1) : items.totalPages)})" >
            <ul class="pagination justify-content-center">

                <li class="page-item" th:classappend="${items.first}?'disabled'">
                    <a th:onclick="'javascript:page(' + ${items.number - 1} + ')'" aria-label='Previous' class="page-link">
                        <span aria-hidden='true'>Previous</span>
                    </a>
                </li>

                <li class="page-item" th:each="page: ${#numbers.sequence(start, end)}" th:classappend="${items.number eq page-1}?'active':''">
                    <a th:onclick="'javascript:page(' + ${page - 1} + ')'" th:inline="text" class="page-link">[[${page}]]</a>
                </li>

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

            </ul>
        </div>

        <div class="form-inline justify-content-center" th:object="${itemSearchDto}">
            <select th:field="*{searchDateType}" class="form-control" style="width:auto;">
                <option value="all">전체기간</option>
                <option value="1d">1일</option>
                <option value="1w">1주</option>
                <option value="1m">1개월</option>
                <option value="6m">6개월</option>
            </select>
            <select th:field="*{searchSellStatus}" class="form-control" style="width:auto;">
                <option value="">판매상태(전체)</option>
                <option value="SELL">판매</option>
                <option value="SOLD_OUT">품절</option>
            </select>
            <select th:field="*{searchBy}" class="form-control" style="width:auto;">
                <option value="itemNm">상품명</option>
                <option value="createdBy">등록자</option>
            </select>
            <input th:field="*{searchQuery}" type="text" class="form-control w-25" placeholder="검색어를 입력해주세요">
            <button id="searchBtn" type="submit" class="btn btn-outline-dark">검색</button>
        </div>
    </form>

</div>

</html>
  • 상품을 검색할 때 주의해야 할 점은 검색 버튼을 클릭할 때 조회할 페이지 번호를 0으로 설정해서 조회해야 한다는 점입니다. 그래야 현재 조회 조건으로 다시 상품 데이터를 0페이지부터 조회할 수 있습니다.
  • items.getContent() : 조회한 상품 데이터 리스트를 얻을 수 있습니다. 해당 리스트를 th:each를 통해서 반복적으로 테이블의 row를 그려줍니다.
profile
철학있는 개발자 - 내가 무지하다는 것을 인정할 때 비로소 배움이 시작된다.

2개의 댓글

comment-user-thumbnail
2022년 9월 6일

안녕하세요 jpa 로 게시판 만들어보고싶어서 블로그 보면서 따라하는 중에 QItem.item.regItem <- 이부분이 에러가 나서 댓글남깁니당... ㅠㅠ.. item 부분에 따로 수정하신게 있으실까요..?

답글 달기
comment-user-thumbnail
2024년 4월 25일

안녕하세요! 클론코딩하고 있었는데 regDtsAfter, searchByLike 이 부분의 switch문에서 NullPointerException이 발생해서 if문으로 변경해봤지만 searchDateType에 null이 들어가서 같은 에러가 나네요.. 혹시 해결방법을 아신다면 댓글 부탁드리겠습니다! 3시간동안 해결해보려 했지만 안되서 우선 두 메서드를 주석처리하고 다시 진행해보겠습니다! 감사합니다!

답글 달기