챕터7 안에 시리즈를 마무리하고 싶었는데....
정리하다 보니 점점 길어지네요
그래도 잘 마무리 해보겠습니다.
상품 관리 화면에서는 상품 조회하는 조건을 설정 후 페이징 기능을 통해 일정 개수의 상품만 불러오며, 선택한 상품 상세 페이지로 이동할 수 있는 기능까지 구현 해보겠습니다. 조회 조건으로 설정할 값은 다음과 같습니다.
조회 조건
이렇게 조회 조건이 복잡한 화면은 Querydsl
을 이용해서 조건에 맞는 쿼리를 동적으로 쉽게 생성할 수 있습니다. Querydsl
을 사용하면 비슷한 쿼리를 재활용할 수 있다는 장점이 있습니다.
Querydsl을 사용하기 위해서는 QDomain
을 생성해야 합니다. QDomain
을 생성하기 위해서 gradle의 컴파일 명령을 실행합니다.
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
: 현재 시간과 상품 등록일을 비교해서 상품 데이터를 조회합니다. 조회 시간 기준을 아래와 같습니다.searchBy
: 상품을 조회할 때 어떤 유형으로 조회할지 선택합니다.searchQuery
: 조회할 검색어를 저장할 변수입니다.Querydsl을 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);
}
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()
: 조회 대상 리스트를 반환합니다.packageme.jincrates.gobook.domain.items;
importorg.springframework.data.jpa.repository.JpaRepository;
importorg.springframework.data.querydsl.QuerydslPredicateExecutor;
public interfaceItemRepositoryextendsJpaRepository<Item, Long>,
QuerydslPredicateExecutor<Item>, ItemRepositoryCustom {
}
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);
}
}
상품 관리 화면 이동 및 조회한 상품 데이터를 화면에 전달하는 로직을 구현하겠습니다. 페이지 번호는 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";
}
}
상품 관리 화면 페이지입니다. 상품 데이터를 테이블로 그려주는 영역과 이동할 페이지를 선택하는 영역, 검색 조건을 셋팅 후 검색하는 영역 총 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를 그려줍니다.
안녕하세요 jpa 로 게시판 만들어보고싶어서 블로그 보면서 따라하는 중에 QItem.item.regItem <- 이부분이 에러가 나서 댓글남깁니당... ㅠㅠ.. item 부분에 따로 수정하신게 있으실까요..?