ACHO.pk devlog

[Springboot] 상품 관리하기 본문

프레임워크/Springboot

[Springboot] 상품 관리하기

Acho 2023. 3. 14. 01:54

1. 상품 관리하기 

상품 관리 화면에서는 상품을 조회하는 조건을 설정 후 선택한 상품 상세 페이지로 이동할 수 있는 기능까지 구현해보자. 

조회조건

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

1-1. Querydsl 이용

조회 조건이 복잡한 화면은 Querydsl을 이용해 조건에 맞는 쿼리를 동적으로 쉽게 생성할 수 있다. 

비슷한 쿼리를 재활용할 수 있다는 장점을 이용해보자.

Querydsl을 사용하기 위해서는 Qdomain을 생성해야 한다. Qdomain을 생성하기 위해서 메이븐의 컴파일 명령을 실행한다.

정상적으로 실행이 완료되면 지금까지 생성한 엔티티 클래스들이 Qdomain 클래스로 생성된 것을 볼 수 있다. 

 

1-2. 상품 데이터 조회 시 상품 조회 조건 클래스 생성

dto > ItemSearchDto

package com.shop.shop.dto;

import com.shop.shop.constant.ItemSellStatus;
import lombok.Getter;
import lombok.Setter;

@Getter
@Setter
public class ItemSearchDto {

    private String searchDateType;

    private ItemSellStatus searchSellStatus;

    private String searchBy;

    private String searchQuery = "";

}

▹현재 시간과 상품 등록일을 비교해서 상품 데이터를 조회한다.

private String searchDateType;

 

▹상품의 판매 상태를 기준으로 상품 데이터를 조회한다.

private ItemSellStatus searchSellStatus;

 

▹상품을 조회할 때  어떤 유형으로 조회할지 선택한다.

private String searchBy;

 

▹조회할 검색어 저장할 변수이다. searchBy가 itemNm일 경우 상품명을 기준으로 검색하고, createdBy일 경우 상품 등록자 아이디 기준으로 검색한다.

private String searchQuery = "";

 

 

1-3. 사용자 정의 레포지토리

Querydsl을 Spring Data Jpa와 함께 사용하기 위해서는 사용자 정의 레포지토리를 정의해야 한다.

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

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

3) Spring Data Jpa 레포지토리에서 사용자 정의 인터페이스 상속

 

1-3-1. 사용자 정의 인터페이스 작성

repository > ItemRepositoryCustom

package com.shop.shop.repository;

import com.shop.shop.dto.ItemSearchDto;
import com.shop.shop.entity.Item;
import org.springframework.data.domain.Page;

import java.awt.print.Pageable;

public interface ItemRepositoryCustom {
    Page<Item> getAdminItemPage(ItemSearchDto itemSearchDto, Pageable pageable);
}

▹ 상품 조회 조건을 담고 있는 itemSearchDto 객체와 페이징 정보를 담고 있는 pageable 객체를 파라미터로 받는 getAdminItemPage 메소드를 정의한다. 반환 데이터로 Page<Item> 객체를 반환한다.

Page<Item> getAdminItemPage(ItemSearchDto itemSearchDto, Pageable pageable);

 

 

1-3-2. 사용자 정의 인터페이스 작성

ItemRepositoryCustom 인터페이스를 구현하는 ItemRepositoryCustomImpl 클래스를 작성한다.

끝에 "Impl"를 붙여주어야 정상적으로 작동한다. Querydsl에서는 BooleanExpression이라는 where 절에서 사용할 수 있는 값을 지원한다. BooleanExpression을 반환하는 메소드를 만들고 해당 조건들을 다른 쿼리를 생성할 때 사용할 수 있기 때문에 중복 코드를 줄일 수 있다는 장점이 있다. 

repository > ItemRepositoryCustomImpl

package com.shop.shop.repository;

import com.querydsl.core.QueryResults;
import com.querydsl.core.types.dsl.BooleanExpression;
import com.querydsl.jpa.impl.JPAQueryFactory;
import com.shop.shop.constant.ItemSellStatus;
import com.shop.shop.dto.ItemSearchDto;
import com.shop.shop.entity.Item;
import com.shop.shop.entity.QItem;
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();

        if(StringUtils.equals("all", searchDateType) || searchDateType == null){
            return null;
        } else if(StringUtils.equals("1d", searchDateType)){
            dateTime = dateTime.minusDays(1);
        } else if(StringUtils.equals("1w", searchDateType)){
            dateTime = dateTime.minusWeeks(1);
        } else if(StringUtils.equals("1m", searchDateType)){
            dateTime = dateTime.minusMonths(1);
        } else if(StringUtils.equals("6m", searchDateType)){
            dateTime = dateTime.minusMonths(6);
        }

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

    private BooleanExpression searchByLike(String searchBy, String searchQuery){

        if(StringUtils.equals("itemNm", searchBy)){
            return QItem.item.itemNm.like("%" + searchQuery + "%");
        } else if(StringUtils.equals("createdBy", searchBy)){
            return QItem.item.createdBy.like("%" + searchQuery + "%");
        }

        return null;
    }

    @Override
    public Page<Item> getAdminItemPage(ItemSearchDto itemSearchDto, Pageable pageable) {
        QueryResults<Item> results = 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())
                .fetchResults();

        List<Item> content = results.getResults();
        long total = results.getTotal();
        return new PageImpl<>(content, pageable, total);
    }
}

▹ItemRepositoryCustom 상속받기

public class ItemRepositoryCustomImpl implements ItemRepositoryCustom{

 

▹동적으로 쿼리를 생성하기 위해서 JPAQueryFactory 클래스를 사용한다.

private JPAQueryFactory queryFactory;

 

▹JPAQueryFactory의 생성자로 EntityManager 객체를 넣어준다.

public ItemRepositoryCustomImpl(EntityManager em){

 

▹상품 판매 상태 조건이 전체(null)일 경우는 null를 리턴한다. 결과값이 null이면 where절에서 해당 조건은 무시된다. 상품 판매 상태 조건이 null이 아니라 판매중 or 품절 상태라면 해당 조건의 상품만 조회한다.

private BooleanExpression searchSellStatusEq(ItemSellStatus searchSellStatus){

 

▹searchDataType의 값에 따라서 dataTime의 값을 이전 시간의 값으로 세팅 후 해당 시간 이후로 등록된 상품만 조회한다. 

return searchSellStatus == null ? null : QItem.item.itemSellStatus.eq(searchSellStatus);

searchDataType 값이 "1m"인 경우 dateTime의 시간을 한 달 전으로 세팅 후 최근 한 달 동안 등록된 상품만 조회하도록 조건값을 반환한다.

private BooleanExpression regDtsAfter(String searchDateType){

 

▹searchBy의 값에 따라서 상품명에 검색어를 포함하고 있는 상품 또는 상품 생성자의 아이디에 검색어를 포함하고 있는 상품을 조회하도록 조건값을 반환한다.

private BooleanExpression searchByLike(String searchBy, String searchQuery){

 

▹이제 queryFactory를 이용해서 쿼리를 생성한다. 쿼리문을 직접 작성할 때의 형태와 문법이 비슷한 것을 볼 수 있다. 

QueryResults<Item> results = 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())
                .fetchResults();
  • selectForm(Qitem.item) : 상품 데이터를 조회하기 위해서 Qitem의 item을 지정한다.
  • where 조건절 : BooleanExpression 반환하는 조건문을 넣어준다. ',' 단위로 넣어줄 경우 and 조건으로 인식한다.
  • offset : 데이터를 가지고 올 시작 인덱스를 지정한다.
  • limit : 한 번에 가지고 올 최대 개수를 지정한다.
  • fetchResult() : 조회한 리스트 및 전체 개수를 포함하는 QueryResults를 반환한다. 상품 데이터 리스트 조회 및 상품 데이터 전체 개수를 조회하는 2번의 쿼리문이 실행된다.

 

▹조회한 데이터를 Page 클래스의 구현체인 PageImpl 객체로 반환한다.

return new PageImpl<>(content, pageable, total);

 

 

1-3-3. Spring Data Jpa 레포지토리에서 사용자 정의 인터페이스 상속

ItemRepository 인터페이스에서 ItemRepositoryCustom 인터페이스를 상속한다. 이제 ItemRepository에서 Querydsl로 구현한 상품 관리 페이지 목록을 불러오는 getAdminItemPage() 메소드를 사용할 수 있다.

repository > ItemRepository 

package com.shop.shop.repository;

import com.shop.shop.entity.Item;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.querydsl.QuerydslPredicateExecutor;
import org.springframework.data.repository.query.Param;

import java.util.List;

public interface ItemRepository extends JpaRepository<Item, Long>, QuerydslPredicateExecutor<Item>, ItemRepositoryCustom {

    List<Item> findByItemNm(String itemNm);

    List<Item> findByItemNmOrItemDetail(String itemNm, String itemDetail);

    List<Item> findByPriceLessThan(Integer price);

    List<Item> findByPriceLessThanOrderByPriceDesc(Integer price);

    @Query("select i from Item i where i.itemDetail like " +
            "%:itemDetail% order by i.price desc")
    List<Item> findByItemDetail(@Param("itemDetail") String itemDetail);

    @Query(value="select * from item i where i.item_detail like " +
            "%:itemDetail% order by i.price desc", nativeQuery = true)
    List<Item> findByItemDetailByNative(@Param("itemDetail") String itemDetail);

}

 

ItemService 클래스에 상품 조회 조건과 페이지 정보를 파라미터로 받아서 상품 데이터를 조회하는 getAdminItemPage() 메소드를 추가한다. 데이터의 수정이 일어나지 않으므로 최적화를 위해 @Transactional(readOnly =true) 어노테이션을 설정한다.

service > ItemService

package com.shop.shop.service;


import com.shop.shop.dto.ItemFormDto;
import com.shop.shop.dto.ItemImgDto;
import com.shop.shop.dto.ItemSearchDto;
import com.shop.shop.entity.Item;
import com.shop.shop.entity.ItemImg;
import com.shop.shop.repository.ItemImgRepository;
import com.shop.shop.repository.ItemRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;


import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;

import javax.persistence.EntityNotFoundException;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.List;

@Service
@Transactional
@RequiredArgsConstructor
public class ItemService {
    private final ItemRepository itemRepository;

    private final ItemImgService itemImgService;

    private final ItemImgRepository itemImgRepository;

    public Long saveItem(ItemFormDto itemFormDto, List<MultipartFile> itemImgFileList) throws Exception{

        //상품 등록
        Item item = itemFormDto.createItem();
        itemRepository.save(item);

        //이미지 등록
        for(int i=0;i<itemImgFileList.size();i++){
            ItemImg itemImg = new ItemImg();
            itemImg.setItem(item);

            if(i == 0)
                itemImg.setRepimgYn("Y");
            else
                itemImg.setRepimgYn("N");

            itemImgService.saveItemImg(itemImg, itemImgFileList.get(i));
        }

        return item.getId();
    }

    @Transactional(readOnly = true)
    public ItemFormDto getItemDtl(Long itemId){
        List<ItemImg> itemImgList = itemImgRepository.findByItemIdOrderByIdAsc(itemId);
        List<ItemImgDto> itemImgDtoList = new ArrayList<>();
        for (ItemImg itemImg : itemImgList) {
            ItemImgDto itemImgDto = ItemImgDto.of(itemImg);
            itemImgDtoList.add(itemImgDto);
        }

        Item item = itemRepository.findById(itemId)
                .orElseThrow(EntityNotFoundException::new);
        ItemFormDto itemFormDto = ItemFormDto.of(item);
        itemFormDto.setItemImgDtoList(itemImgDtoList);
        return itemFormDto;
    }
    public Long updateItem(ItemFormDto itemFormDto, List<MultipartFile> itemImgFileList) throws Exception{
        //상품 수정
        Item item = itemRepository.findById(itemFormDto.getId())
                .orElseThrow(EntityNotFoundException::new);
        item.updateItem(itemFormDto);
        List<Long> itemImgIds = itemFormDto.getItemImgIds();

        //이미지 등록
        for(int i=0;i<itemImgFileList.size();i++){
            itemImgService.updateItemImg(itemImgIds.get(i),
                    itemImgFileList.get(i));
        }

        return item.getId();
    }

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

 

 

ItemController 클래스에 상품 관리 화면 이동 및 조회한 상품 데이터를 화면에 전달하는 로직을 구현해보자. 현재 상품 데이터가 많이 없는 관계로 한 페이지당 총 3개의 상품만 보여주도록 한다. 페이지 번호는 0부터 시작한다.

controller > ItemController

package com.shop.shop.controller;

import com.shop.shop.dto.ItemFormDto;
import com.shop.shop.dto.ItemSearchDto;
import com.shop.shop.entity.Item;
import com.shop.shop.service.ItemService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.multipart.MultipartFile;

import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;

import javax.persistence.EntityNotFoundException;
import javax.validation.Valid;
import java.util.List;
import java.util.Optional;

@Controller
@RequiredArgsConstructor
public class ItemController {
    private final ItemService itemService;
    @GetMapping(value = "/admin/item/new")
    public String itemForm(Model model){
        model.addAttribute("itemFormDto", new ItemFormDto());
        return "item/itemForm";
    }

    @PostMapping(value = "/admin/item/new")
    public String itemNew(@Valid ItemFormDto itemFormDto, BindingResult bindingResult,
                          Model model, @RequestParam("itemImgFile") List<MultipartFile> itemImgFileList){

        if(bindingResult.hasErrors()){
            return "item/itemForm";
        }

        if(itemImgFileList.get(0).isEmpty() && itemFormDto.getId() == null){
            model.addAttribute("errorMessage", "첫번째 상품 이미지는 필수 입력 값 입니다.");
            return "item/itemForm";
        }

        try {
            itemService.saveItem(itemFormDto, itemImgFileList);
        } catch (Exception e){
            model.addAttribute("errorMessage", "상품 등록 중 에러가 발생하였습니다.");
            return "item/itemForm";
        }

        return "redirect:/";
    }

    @GetMapping(value = "/admin/item/{itemId}")
    public String itemDtl(@PathVariable("itemId") Long itemId, Model model){

        try {
            ItemFormDto itemFormDto = itemService.getItemDtl(itemId);
            model.addAttribute("itemFormDto", itemFormDto);
        } catch(EntityNotFoundException e){
            model.addAttribute("errorMessage", "존재하지 않는 상품 입니다.");
            model.addAttribute("itemFormDto", new ItemFormDto());
            return "item/itemForm";
        }

        return "item/itemForm";
    }

    @PostMapping(value = "/admin/item/{itemId}")
    public String itemUpdate(@Valid ItemFormDto itemFormDto, BindingResult bindingResult,
                             @RequestParam("itemImgFile") List<MultipartFile> itemImgFileList, Model model){
        if(bindingResult.hasErrors()){
            return "item/itemForm";
        }

        if(itemImgFileList.get(0).isEmpty() && itemFormDto.getId() == null){
            model.addAttribute("errorMessage", "첫번째 상품 이미지는 필수 입력 값 입니다.");
            return "item/itemForm";
        }

        try {
            itemService.updateItem(itemFormDto, itemImgFileList);
        } catch (Exception e){
            model.addAttribute("errorMessage", "상품 수정 중 에러가 발생하였습니다.");
            return "item/itemForm";
        }

        return "redirect:/";
    }

    @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/itemMng";
    }

}

▹value에 상품 관리 화면 진입 시 URL에 페이지 번호가 없는 경우와 페이지 번호가 있는 경우 2가지를 매핑한다.

public String itemManage(ItemSearchDto itemSearchDto, @PathVariable("page") Optional<Integer> page, Model model){

 

▹페이징을 위해서 PageRequest.of 메소드를 통해 Pageable 객체를 생성한다. 첫 번째 파라미터로는 조회할 페이지 번호, 두 번째 파라미터로는 한 번에 가지고 올 데이터 수를 넣어준다. URL 경로에 페이지 번호가 있으면 해당 페이지를 조회하도록 세팅하고, 페이지 번호가 없으면 0페이지를 조회하도록 한다.

Pageable pageable = PageRequest.of(page.isPresent() ? page.get() : 0, 3);

 

▹조회 조건과 페이징 정보를 파라미터로 넘겨서 Page<Item> 객체를 반환 받는다.

Page<Item> items = itemService.getAdminItemPage(itemSearchDto, pageable);

 

▹조회한 상품 데이터 및 페이징 정보를 뷰에 전달한다.

model.addAttribute("items", items);

 

▹페이지 전환 시 기존 검색 조건을 유지한 채 이동할 수 있도록 뷰에 다시 전달한다.

model.addAttribute("itemSearchDto", itemSearchDto);

 

▹상품 관리 메뉴 하단에 보여줄 페이지 번호의 최대 개수이다. 5로 설정했으므로 최대 5개의 이동할 페이지 번호만 보여준다.

model.addAttribute("maxPage", 5);

 

 

1-3. 상품 관리 화면 페이지 

상품 데이터를 페이징을 통해 가지고 오는 것을 확인해보자. 

template/item > itemMng.html

https://github.com/roadbook2/shop/blob/master/src/main/resources/templates/item/itemMng.html

위의 사이트에서 코드를 가져와서 쓰면 된다.

 

▹<검색> 버튼 클릭 시 form 태그의 전송을 막아준다.

e.preventDefault();

 

▹<검색> 버튼을 클릭할 페이지 번호로 0번째 페이지를 조회하는 page 함수를 호출한다. 

page(0);

 

▹page 함수는 이동할 페이지 값을 받아서 현재 조회 조건으로 설정된 상품 등록 기간, 판매 상태, 조회 유형, 검색어를 파라미터로 설정 후 상품 데이터를 조회한다.

function page(page){

 

▹items.getContent() 메소드를 호출하면 조회한 상품 데이터를 리스트로 얻을 수 있다. 해당 리스트를 th:each를 통해서 반복적으로 테이블의 row를 그려준다.

<tr th:each="item, status: ${items.getContent()}">

 

▹현재 상품의 판매 상태가  "SELL"이면 "판매 중"으로, 같지 않으면 "품절"로 보여준다.

<td th:text="${item.itemSellStatus == T(com.shop.constant.ItemSellStatus).SELL} ? '판매중' : '품절'"></td>

 

▹th:with는 변수 값을 정의할 때 사용한다. 페이지 시작 번호와 페이지 끝 번호를 구해서 저장한다. 시작 페이지와 끝과 페이지 번호를 구하는 방법은 아래와 같다.

  • start = (현재 페이지 번호/보여줄 페이지 수) + 1
  • end = start + (보여줄 페이지 수) - 1
<div th:with="start=${(items.number/maxPage)*maxPage + 1}, end=(${(items.totalPages == 0) ? 1 : (start + (maxPage - 1) < items.totalPages ? start + (maxPage - 1) : items.totalPages)})" >

 

▹첫 번째 페이지면 이전 페이지로 이동하는 <Previous> 버튼을 선택 불가능하도록 disabled 클래스를 추가한다.

<li class="page-item" th:classappend="${items.first}?'disabled'">

 

▹<Previous> 버튼 클릭 시 현재 페이지에서 이전 페이지로 이동하도록 page 함수를 호출한다.

<a th:onclick="'javascript:page(' + ${items.number - 1} + ')'" aria-label='Previous' class="page-link">

 

▹현재 페이지이면 active 클래스를 추가한다.

<li class="page-item" th:each="page: ${#numbers.sequence(start, end)}" th:classappend="${items.number eq page-1}?'active':''">

 

▹페이지 번호 클릭 시 해당 페이지로 이동하도록 page 함수를 호출한다.

<a th:onclick="'javascript:page(' + ${page - 1} + ')'" th:inline="text" class="page-link">[[${page}]]</a>

 

▹마지막 페이지일 경우 다음 페이지로 이동하는 <Next> 버튼을 선택 불가능하도록 disabled 클래스를 추가한다.

<li class="page-item" th:classappend="${items.last}?'disabled'">

 

▹<Next> 버튼 클릭 시 현재 페이지에서 다음 페이지로 이동하도록 page 함수를 호출한다.

<a th:onclick="'javascript:page(' + ${items.number + 1} + ')'" aria-label='Next' class="page-link">

 

 

우리의 웹사이트에서 상품 관리 메뉴를 클릭하면 구현한 화면을 볼 수 있다. 

상품 조회 조건도 다양하게 선택해서 출력되는지 확인해보자.

 

 

 

Comments