풀스택 개발자 과정 64일차

너구·2026년 8월 7일

풀스택 성장과정

목록 보기
67/79

Spring

ItemRepositoryCustom

package com.shop.repository;

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

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

ItemRepositoryCustomImpl

package com.shop.repository;

import com.querydsl.core.QueryResults;
import com.querydsl.core.types.dsl.BooleanExpression;
import com.querydsl.jpa.impl.JPAQueryFactory;
import com.shop.constant.ItemSellStatus;
import com.shop.dto.ItemSearchDto;
import com.shop.dto.MainItemDto;
import com.shop.dto.QMainItemDto;
import com.shop.entity.Item;
import com.shop.entity.QItem;
import com.shop.entity.QItemImg;
import jakarta.persistence.EntityManager;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.Pageable;
import org.thymeleaf.util.StringUtils;

import java.time.LocalDateTime;
import java.util.List;

public class ItemRepositoryCustomImpl implements ItemRepositoryCustom{
    private JPAQueryFactory queryFactory; // 동적쿼리 사용하기 위해 JPAQueryFactory 변수 선언
    // 생성자
    public ItemRepositoryCustomImpl(EntityManager em){
        this.queryFactory = new JPAQueryFactory(em); // JPAQueryFactory 실질적인 객체가 만들어 집니다.
    }
    private BooleanExpression searchSellStatusEq(ItemSellStatus searchSellStatus){
        return searchSellStatus == null ?
                null : QItem.item.itemSellStatus.eq(searchSellStatus);
        //ItemSellStatus null이면 null 리턴 null 아니면 SELL, SOLD 둘중 하나 리턴
    }
    private  BooleanExpression regDtsAfter(String searchDateType){ // all, 1d, 1w, 1m 6m
        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);
        //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);
    }

    private BooleanExpression itemNmLike(String searchQuery) {
        return StringUtils.isEmpty(searchQuery) ? null : QItem.item.itemNm.like("%" + searchQuery + "%");
    }

    @Override
    public Page<MainItemDto> getMainItemPage(ItemSearchDto itemSearchDto, Pageable pageable) {
        QItem item = QItem.item;
        QItemImg itemImg = QItemImg.itemImg;
        //QMainItemDto @QueryProjection을 허용하면 DTO로 바로 조회 가능
        QueryResults<MainItemDto> results = queryFactory.select(new QMainItemDto(item.id, item.itemNm,
                                            item.itemDetail, itemImg.imgUrl, item.price))
                // join 내부조인 .repImgYn.eq("Y") 대표이미지만 가져온다.
                .from(itemImg).join(itemImg.item, item).where(itemImg.repImgYn.eq("Y"))
                .where(itemNmLike(itemSearchDto.getSearchQuery()))
                .orderBy(item.id.desc()).offset(pageable.getOffset()).
                limit(pageable.getPageSize()).fetchResults();
        List<MainItemDto> content = results.getResults();
        long total = results.getTotal();
        return new PageImpl<>(content, pageable, total);
    }
}

MainController

package com.shop.controller;

import com.shop.dto.ItemSearchDto;
import com.shop.dto.MainItemDto;
import com.shop.service.ItemService;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;

import java.util.Optional;

@Controller
@RequiredArgsConstructor
public class MainController {
    private final ItemService itemService;
    @GetMapping(value = "/")
    public String main(ItemSearchDto itemSearchDto, Optional<Integer> page, Model model) {
        Pageable pageable = PageRequest.of(page.isPresent() ? page.get() : 0, 5);
        Page<MainItemDto> items = itemService.getMainItemPage(itemSearchDto, pageable);
        System.out.println(items.getNumber() + "!!!!!!!!!!!!!");
        System.out.println(items.getTotalPages() + "#############");
        model.addAttribute("items", items);
        model.addAttribute("itemSearchDto", itemSearchDto);
        model.addAttribute("maxPage", 5);
        return "main";
    }
}

ItemImgDto

package com.shop.dto;

import com.shop.entity.ItemImg;
import lombok.Getter;
import lombok.Setter;
import org.modelmapper.ModelMapper;

@Getter
@Setter
public class ItemImgDto {
    private Long id;
    private String imgName;
    private String oriImgName;
    private String imgUrl;
    private String repImgYn;
    private static ModelMapper modelMapper = new ModelMapper();
    public static ItemImgDto of(ItemImg itemImg) {
        return modelMapper.map(itemImg, ItemImgDto.class);
    }
}

ItemImgService

package com.shop.service;

import com.shop.entity.ItemImg;
import com.shop.repository.ItemImgRepository;
import jakarta.persistence.EntityNotFoundException;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import org.thymeleaf.util.StringUtils;

@Service
@RequiredArgsConstructor
@Transactional
public class ItemImgService {
    @Value("${itemImgLocation}") // application.properties에 itemImgLocation
    private String itemImgLocation;

    private final ItemImgRepository itemImgRepository;
    private final FileService fileService;
    public void saveItemImg(ItemImg itemImg, MultipartFile itemImgFile) throws Exception {

        String oriImgName = itemImgFile.getOriginalFilename(); // 오리지널 이미지 경로
        String imgName = "";
        String imgUrl = "";
        System.out.println(oriImgName);
        // 파일 업로드
        if (!StringUtils.isEmpty(oriImgName)) { // oriImgName 문자열로 비어있지 않으면 실행
            System.out.println("******");
            imgName = fileService.uploadFile(itemImgLocation, oriImgName,
                    itemImgFile.getBytes());
            System.out.println(imgName);
            imgUrl = "/images/item/" + imgName;
        }
        System.out.println("1111");
        // 상품 이미지 정보 저장
        // oriImgName: 상품 이미지 파일의 원래 이름
        // imgName: 실제로 로컬에 저장된 상품 이미지 파일의 이름
        // imgUrl: 로컬에 저장된 상품 이미지 파일을 불러오는 경로
        itemImg.updateItemImg(oriImgName, imgName, imgUrl);
        System.out.println("(((((");
        itemImgRepository.save(itemImg);
    }

    public void updateItemImg(Long itemImgId, MultipartFile itemImgFile) throws Exception {
        if (!itemImgFile.isEmpty()) { // 상품의 이미지를 수정한 경우 상품 이미지 업데이트
            ItemImg savedItemImg = itemImgRepository.findById(itemImgId)
                    .orElseThrow(EntityNotFoundException::new); // 기존 엔티티 조회
            // 기존에 등록된 상품 이미지 파일이 있는 경우 삭제
            if (!StringUtils.isEmpty(savedItemImg.getImgName())) {
                fileService.deleteFile(itemImgLocation + "/" + savedItemImg.getImgName());
            }
            String oriImgName = itemImgFile.getOriginalFilename();
            String imgName = fileService.uploadFile(itemImgLocation, oriImgName,
                    itemImgFile.getBytes()); // 파일 업로드
            String imgUrl = "/images/item/" + imgName;
            // 변경된 상품 이미지 정보를 세팅
            // 상품 등록을 하는 경우에는 ItemImgRepository.save() 로직을 호출하지만
            // 호출을 하지 않았다.
            // savedItemImg 엔티티는 영속성 상태이다.
            // 그래서 데이터를 변경하는 것만으로 변경을 감지하는 기능이 동작
            // 트랜잭션이 끝날 때 update 쿼리가 실행된다.
            // 영속성 상태여야만 사용 가능
            savedItemImg.updateItemImg(oriImgName, imgName, imgUrl);
        }
    }
}

MainItemDto

package com.shop.dto;

import com.querydsl.core.annotations.QueryProjection;
import lombok.Getter;
import lombok.Setter;

@Getter
@Setter
public class MainItemDto {
    private Long id;
    private String itemNm;
    private String itemDetail;
    private String imgUrl;
    private Integer price;

    @QueryProjection // Querydsl 결과 조회시 MainItemDto 객체로 바로 오도록 활용
    public MainItemDto(Long id, String itemNm, String itemDetail, String imgUrl, Integer price) {
        this.id = id;
        this.itemNm = itemNm;
        this.itemDetail = itemDetail;
        this.imgUrl = imgUrl;
        this.price = price;
    }
}

Item

package com.shop.entity;

import com.shop.constant.ItemSellStatus;
import com.shop.dto.ItemFormDto;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;

import java.time.LocalDateTime;
import java.util.List;

@Entity
@Table(name = "item")
@Getter
@Setter
@ToString
public class Item extends BaseEntity{
    @Id
    @Column(name = "item_id")
    @GeneratedValue(strategy = GenerationType.AUTO)
    private Long id; // 상품 코드

    @Column(nullable = false, length = 50)
    private String itemNm; // 상품명

    @Column(name = "price", nullable = false)
    private int price; // 가격

    @Column(nullable = false)
    private int stockNumber; // 수량

    @Lob
    @Column(nullable = false)
    private String itemDetail; // 상품 상세 설명

    @Enumerated
    private ItemSellStatus itemSellStatus; // 상품 판매 상태

//    private LocalDateTime regTime; // 등록 시간
//
//    private LocalDateTime updateTime; // 수정 시간

    @ManyToMany(fetch = FetchType.LAZY)
    @JoinTable(
            name = "member_item",
            joinColumns = @JoinColumn(name = "member_id"),
            inverseJoinColumns = @JoinColumn(name = "item_id")
    )
    private List<Member> member;

    public void updateItem(ItemFormDto itemFormDto) {
        this.itemNm = itemFormDto.getItemNm();
        this.price = itemFormDto.getPrice();
        this.stockNumber = itemFormDto.getStockNumber();
        this.itemDetail = itemFormDto.getItemDetail();
        this.itemSellStatus = itemFormDto.getItemSellStatus();
    }
}

ItemRepository

package com.shop.repository;

import com.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 {
    // select*from item where itemNm = itemNm;
    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> findByItemDetailNative(@Param("itemDetail") String itemDetail);
}

ItemService

package com.shop.service;

import com.shop.dto.ItemFormDto;
import com.shop.dto.ItemImgDto;
import com.shop.dto.ItemSearchDto;
import com.shop.dto.MainItemDto;
import com.shop.entity.Item;
import com.shop.entity.ItemImg;
import com.shop.repository.ItemImgRepository;
import com.shop.repository.ItemRepository;
import jakarta.persistence.EntityNotFoundException;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;

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) {
        // Entity
        List<ItemImg> itemImgList = itemImgRepository.findByItemIdOrderByIdAsc(itemId);
        // DB에서 데이터를 가지고 온다.
        // DTO
        List<ItemImgDto> itemImgDtoList = new ArrayList<>();

        for (ItemImg itemimg : itemImgList) {
            // Entity -> Dto
            ItemImgDto itemImgDto = ItemImgDto.of(itemimg);
            itemImgDtoList.add(itemImgDto);
        }

        Item item = itemRepository.findById(itemId).orElseThrow(EntityNotFoundException::new);
        // Item -> ItemFormDto modelMapper
        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);
    }
    @Transactional(readOnly = true)
    public Page<MainItemDto> getMainItemPage(ItemSearchDto itemSearchDto, Pageable pageable) {
        return itemRepository.getMainItemPage(itemSearchDto, pageable);
    }
}

main.html

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


<th:block layout:fragment="css">
    <style>
        /* . item -> .item 띄어쓰기 수정 */
        .carousel-inner > .item {
            height : 350px;
        }
        .margin{
            margin-bottom : 30px;
        }
        .banner{
            height : 300px;
            position : absolute; top:0; left:0;
            width: 100%;
        }
        .card-text{
            text-overflow : ellipsis;
            white-space : nowrap;
            overflow : hidden;
        }
        a:hover{
            text-decoration:none;
        }
        /* text5-align -> text-align 오타 수정 */
        .center{
            text-align:center;
        }
    </style>
</th:block>


<div layout:fragment="content">

    <div id="carouselControls" class="carousel slide margin" data-ride="carousel">
        <div class="carousel-inner">
            <div class="carousel-item active item">
                <img src="https://user-images.githubusercontent.com/13268420/112147492-1ab76200-8c20-11eb-8649-3d2f282c3c02.png" class="d-block w-100 banner" alt="First slide">
            </div>
        </div>
    </div>

    <input type="hidden" name="searchQuery" th:value="${itemSearchDto.searchQuery}">


    <div th:if="${not #strings.isEmpty(itemSearchDto.searchQuery)}" class="center">
        <p class="h3 font-weight-bold" th:text="${itemSearchDto.searchQuery} + ' 검색결과'"></p>
    </div>

    <div class="container text-center">
        <div class="row row-cols-5">

            <th:block th:each="item, status: ${items.getContent()}">
                <div class="col">
                    <div class="card">
                        <a th:href="'/item/' + ${item.id}" class="text-dark">
                            <img th:src="${item.imgUrl}" class="card-img-top" th:alt="${item.itemNm}" height="400">
                            <div class="card-body">
                                <h4 class="card-title">[[${item.itemNm}]]</h4>
                                <p class="card-text">[[${item.itemDetail}]]</p>
                                <h3 class="card-title text-danger">[[${item.price}]]원</h3>
                            </div>
                        </a>
                    </div>
                </div>
            </th:block>
        </div>
    </div>


    <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.number eq 0} ? 'disabled'">

                <a th:href="@{'/' + '?searchQuery=' + ${itemSearchDto.searchQuery} + '&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:href="@{'/' + '?searchQuery=' + ${itemSearchDto.searchQuery} + '&page=' + ${page - 1}}"
                   th:inline="text" class="page-link">[[${page}]]</a>
            </li>
            <li class="page-item" th:classappend="${items.number + 1 ge items.totalPages} ? 'disabled'">
                <a th:href="@{'/' + '?searchQuery=' + ${itemSearchDto.searchQuery} + '&page=' + ${items.number + 1}}"
                   aria-label="Next" class="page-link">
                    <span aria-hidden="true">Next</span>
                </a>
            </li>
        </ul>
    </div>
</div>
</html>

ItemController

package com.shop.controller;

import com.shop.dto.ItemFormDto;
import com.shop.dto.ItemSearchDto;
import com.shop.entity.Item;
import com.shop.service.ItemService;
import jakarta.persistence.EntityNotFoundException;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
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.Pageable;
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/item", "/admin/items/{page}"})
    public String itemManage(ItemSearchDto itemSearchDto, @PathVariable("page")Optional<Integer> page,
                             Model model) {
        Pageable pageable = PageRequest.of(page.isPresent() ? page.get() : 0, 5);
        Page<Item> items = itemService.getAdminItemPage(itemSearchDto, pageable);
        model.addAttribute("items", items);
        model.addAttribute("itemSearchDto", itemSearchDto);
        model.addAttribute("maxPage", 5);
        return "item/itemMng";
    }
}

itemMng.html

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
      xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
      layout:decorate="~{layouts/layout1}">
<!-- 사용자 스크립트 추가 -->
<th:block layout:fragment="script">
    <script th:inline="javascript">
        $(document).ready(function(){
            $("#searchBtn").on("click",function(e){
                e.preventDefault(); //검색버튼 클릭시 form 태그 전송을 막습니다.
                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>
        select{
            margin-right : 10px
        }
    </style>
</th:block>

<div layout:fragment="content">
    <form th:action="@{'/admin/items/'+${items.number}}" role="form" method="get" th:object="${items}">
        <table class="table">
            <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="'/admin/item/'+${item.id}" th:text="${item.itemNm}"></a>
                </td>
                <td th:text="${item.itemSellStatus == T(com.shop.constant.ItemSellStatus).SELL} ? '판매중' : '품절'"></td>
                <td th:text="${item.createdBy}"></td>
                <td th:text="${item.regTime}"></td>
            </tr>
            </tbody>
        </table>

        <p th:text="${items.number}"></p>
        <p th:text="${items.totalPages}"></p>

        <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="row row-cols-lg-auto g-3 align-items-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" style="width:auto; margin-right: 15px;" placeholder="검색어를 입력해주세요">
            <button id="searchBtn" type="submit" class="form-control btn btn-primary" style="width:auto">검색</button>
        </div>
    </form>
</div>
</html>

itemForm.html

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

<!--사용자 스크립트 추가-->
<th:block layout:fragment="script">
    <script th:inline="javascript">
        $(document).ready(function() {
            var errorMessage = [[${errorMessage}]];
            if (errorMessage != null) {
                alert(errorMessage);
            }
            bindDomEvent();
        });

        function bindDomEvent() {
            $(".imgFile.form-control").on("change", function() {
                // a.jpg
                var fileName = $(this).val().split("\\").pop();
                // . 기준으로 인덱스 +1 -> jpg or gif 등등
                var fileExt = fileName.substring(fileName.lastIndexOf(".") + 1);
                // 확장자 추출
                fileExt = fileExt.toLowerCase(); // 소문자 소환

                if (fileExt != "jpg" && fileExt != "jpeg" && fileExt != "gif"
                    && fileExt != "png" && fileExt != "bmp") {
                    alert("이미지 파일만 등록이 가능합니다.");
                    $(this).val("");
                    return;
                }
            });
        }
    </script>
</th:block>

<!--사용자 CSS 추가-->
<th:block layout:fragment="css">
    <style>
        .input-group {
            margin-bottom: 15px;
        }

        .img-div {
            margin-bottom: 10px;
        }

        .fieldError {
            color: #db2130;
        }
    </style>
</th:block>

<div layout:fragment="content">
    <form role="form" class="container" method="post" enctype="multipart/form-data" th:object="${itemFormDto}">
        <p class="h2">상품 등록</p>

        <input type="hidden" th:field="*{id}">

        <div class="row mb-4">
            <select th:field="*{itemSellStatus}" class="form-select">
                <option value="SELL">판매중</option>
                <option value="SOLD_OUT">품절</option>
            </select>
        </div>

        <div class="input-group">
            <div class="input-group-prepend">
                <span class="input-group-text">상품명</span>
            </div>
            <input type="text" th:field="*{itemNm}" class="form-control" placeholder="상품명을 입력해주세요.">
        </div>
        <p th:if="${#fields.hasErrors('itemNm')}" th:errors="*{itemNm}" class="fieldError">Incorrect data</p>

        <div class="input-group">
            <div class="input-group-prepend">
                <span class="input-group-text">가격</span>
            </div>
            <input type="number" th:field="*{price}" class="form-control" placeholder="상품의 가격을 입력해주세요.">
        </div>
        <p th:if="${#fields.hasErrors('price')}" th:errors="*{price}" class="fieldError">Incorrect data</p>

        <div class="input-group">
            <div class="input-group-prepend">
                <span class="input-group-text">재고</span>
            </div>
            <input type="number" th:field="*{stockNumber}" class="form-control" placeholder="상품의 재고를 입력해주세요.">
        </div>
        <p th:if="${#fields.hasErrors('stockNumber')}" th:errors="*{stockNumber}" class="fieldError">Incorrect data</p>

        <div class="input-group">
            <div class="input-group-prepend">
                <span class="input-group-text">상품 상세 내용</span>
            </div>
            <textarea class="form-control" aria-label="With textarea" th:field="*{itemDetail}"></textarea>
        </div>
        <p th:if="${#fields.hasErrors('itemDetail')}" th:errors="*{itemDetail}" class="fieldError">Incorrect data</p>

        <div th:if="${#lists.isEmpty(itemFormDto.itemImgDtoList)}">
            <div class="row" th:each="num : ${#numbers.sequence(1,5)}">
                <div class="input-group mb-3">
                    <label class="imgFile input-group-text" th:text="'상품이미지' + ${num}"></label>
                    <input type="file" class="imgFile form-control" name="itemImgFile">
                </div>
            </div>
        </div>

        <div th:if="${not #lists.isEmpty(itemFormDto.itemImgDtoList)}">
            <div class="row" th:each="itemImgDto, status : ${itemFormDto.itemImgDtoList}">
                <div class="input-group mb-3">
                    <label class="imgFile input-group-text"
                           th:text="${not #strings.isEmpty(itemImgDto.oriImgName) ? itemImgDto.oriImgName : '상품이미지' + (status.index + 1)}"></label>

                    <input type="file" class="imgFile form-control" name="itemImgFile">
                    <input type="hidden" name="itemImgIds" th:value="${itemImgDto.id}">
                </div>
            </div>
        </div>

        <div th:if="${#strings.isEmpty(itemFormDto.id)}" style="text-align : center">
            <button th:formaction="@{/admin/item/new}" type="submit" class="btn btn-primary">저장</button>
        </div>

        <div th:if="${not #strings.isEmpty(itemFormDto.id)}" style="text-align : center">
            <button th:formaction="@{'/admin/item/' + ${itemFormDto.id}}" type="submit" class="btn btn-primary">수정</button>
        </div>

        <input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
    </form>
</div>

</html>

마무리

Querydsl을 활용해 상품 관리 페이지와 메인 상품 조회 기능을 구현하고, 동적 검색 조건과 페이징 처리를 학습했다.
사실 아직 어려워서 무슨 소리인지 반절은 이해 못한 거 같다.
또한 Thymeleaf에서 페이지 이동, 검색 조건 유지, 상품 목록 출력 구조를 구현했다.
이미지 조회 오류를 디버깅하며 DB → DTO → View → Resource 설정 흐름을 확인하였는데 생각보다 오타가 많아서 애를 많이 먹었다.
생소한 기호들을 많이 쓰다보니 한두개씩 빼먹었는데 그게 생각보다 찾기가 정말 힘들었다.
그래도 오류 찾고 제대로 실행되는 것들을 보니 뿌듯하고 재밌었다.

0개의 댓글