ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [백견불야일타] 6장 상품 등록 및 조회하기
    SpringBoot/쇼핑몰 프로젝트 with JPA 2022. 10. 8. 01:44

    6.1 상품 등록하기

     

     

    com.shop.entity.ItemImg.java

    package com.shop.entity;
    
    import lombok.Getter;
    import lombok.Setter;
    
    import javax.persistence.*;
    
    @Entity
    @Table(name="item_img")
    @Getter @Setter
    public class ItemImg extends BaseEntity{
        
        @Id
        @Column(name ="item_img_id")
        @GeneratedValue(strategy = GenerationType.AUTO)
        private Long id;
        
        private String imgName; // 이미지 파일명
        
        private String oriImgName; // 원본 이미지 파일명
        
        private String imgUrl; // 이미지 조회 경로
        
        private String repimgYn; // 대표 이미지 여부
        
        // 상품 엔티티와 다대일 단방향 관계로 매핑, 지연 로딩을 설정하여 매핑된 상품 엔티티 정보가 필요할 경우 데이터 조회
        @ManyToOne(fetch = FetchType.LAZY)
        @JoinColumn(name = "item_id")
        private Item item;
        
        // 원본 이미지 파일명, 업데이트할 이미지 파일명, 이미지 경로를 파라미터로 입력 받아서 이미지 정보를 업데이트
        public void updateItemImg(String oriImgName, String imgName, String imgUrl){
            this.oriImgName = oriImgName;
            this.imgName = imgName;
            this.imgUrl = imgUrl;
        }
    }
    • 상품의 이미지를 저장하는 상품 이미지 엔티티 작성
    • 이미지 파일명, 원본 이미지 파일명, 이미지 조회 경로, 대표 이미지 여부를 갖도록 설계
    • 대표 이미지 여부가 "Y"인 경우 메인 페이지에서 상품을 보여줄 때 사용

     

     

    pom.xml

    <dependency>
    			<groupId>org.modelmapper</groupId>
    			<artifactId>modelmapper</artifactId>
    			<version>2.3.9</version>
    </dependency>
    • 상품 등록 시 화면으로부터 전달받은 DTO 객체를 엔티티 객체로 변환하는 작업을 해야 하고, 상품을 조회할 때는 엔티티 객체를 DTO 객체로 바꿔주는 작업을 해야 한다. 이 작업은 반복적인 작업이고, 멤버 변수가 몇 개 없다면 금방 할 수도 있지만 멤버 변수가 많아진다면 상당한 시간을 소모한다. 이를 도와주는 라이브러리로 modelmapper 라이브러리가 있다. 서로 다른 클래스의 값을 필드의 이름과 자료형이 같으면 getter, setter를 통해 값을 복사해서 객체를 반환 해 준다.

     

     

    com.shop.dto.ItemImgDto.java

    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 name;
    
        private String oriImgName;
    
        private String imgUrl;
    
        private String repImgYn;
    
        // 멤버 변수로 ModelMapper 객체 추가
        private static ModelMapper modelMapper = new ModelMapper();
    
        // ItemImg 엔티티 객체를 파라미터로 받아서 ItemImg 객체의 자료형과 멤버 변수의 이름이 같을 때
        // ItemImgDto로 값을 복사해서 반환. static 메소드로 선언해 ItemImgDto 객체를 생성하지 않아도 호출할 수 있도록
        public static ItemImgDto of(ItemImg itemImg){
            return modelMapper.map(itemImg, ItemImgDto.class);
        }
    }
    • 상품 저장 후 상품 이미지에 대한 데이터를 전달한 DTO 클래스 작성

     

     

    com.shop.dto.ItemFormDto.java

    package com.shop.dto;
    
    import com.shop.constant.ItemSellStatus;
    import com.shop.entity.Item;
    import lombok.Getter;
    import lombok.Setter;
    import org.modelmapper.ModelMapper;
    
    import javax.validation.constraints.NotBlank;
    import javax.validation.constraints.NotNull;
    import java.time.LocalDateTime;
    import java.util.ArrayList;
    import java.util.List;
    
    @Getter @Setter
    public class ItemFormDto {
    
        private Long id;
    
        @NotBlank(message = "상품명은 필수 입력 값입니다.")
        private String itemNm;
    
        @NotNull(message =  "가격은 필수 입력 값입니다.")
        private Integer price;
    
        @NotBlank(message = "이름은 필수 입력 값입니다.")
        private String itemDetail;
    
        @NotNull(message =  "재고는 필수 입력 값입니다.")
        private Integer stockNumber;
    
        private ItemSellStatus itemSellStatus;
    
        // 상품 저장 후 수정할 때 상품 이미지 정보를 저장하는 리스트
        private List<ItemImgDto> itemImgDtoList = new ArrayList<>();
    
        // 상품의 이미지 아이디를 저장하는 리스트, 상품 등록 시에는 아직 상품의 이미지를 저장하지 않았기 때문에
        // 아무 값도 들어가 있지 않고 수정 시에 이미지 아이디를 담아둘 용도로 사용
        private List<Long> itemImgIds = new ArrayList<>();
    
        private static ModelMapper modelMapper = new ModelMapper();
    
        // modelMapper를 이용하여 엔티티 객체와 DTO 객체 간의 데이터를 복사하여 복사한 객체를 반환해주는 메소드
        public Item createItem(){
            return modelMapper.map(this, Item.class);
        }
    
        public static ItemFormDto of(Item item){
            return modelMapper.map(item, ItemFormDto.class);
        }
    
    }
    • 상품 데이터 정보를 전달하는 DTO

     

     

    com.shop.ItemController.java

    package com.shop.controller;
    
    import com.shop.dto.ItemFormDto;
    import org.springframework.stereotype.Controller;
    import org.springframework.ui.Model;
    import org.springframework.web.bind.annotation.GetMapping;
    
    @Controller
    public class ItemController {
    
        @GetMapping(value = "/admin/item/new")
        public String itemForm(Model model){
            model.addAttribute("itemFromDto", new ItemFormDto());
            return "/item/itemForm";
        }
    }
    • 상품 등록 페이지로 접근할 수 있도록 기존에 만들어 두었던 ItemController 클래스 수정
    • ItemFormDto를 model 객체에 담아서 뷰로 전달

     

     

    resource/templates/item/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(){
                $(".custom-file-input").on("change", function() {
                    var fileName = $(this).val().split("\\").pop();  //이미지 파일명
                    var fileExt = fileName.substring(fileName.lastIndexOf(".")+1); // 확장자 추출
                    fileExt = fileExt.toLowerCase(); //소문자 변환
    
                    if(fileExt != "jpg" && fileExt != "jpeg" && fileExt != "gif" && fileExt != "png" && fileExt != "bmp"){
                        alert("이미지 파일만 등록이 가능합니다.");
                        return;
                    }
    
                    $(this).siblings(".custom-file-label").html(fileName);
                });
            }
    
        </script>
    
    </th:block>
    
    <!-- 사용자 CSS 추가 -->
    <th:block layout:fragment="css">
        <style>
            .input-group {
                margin-bottom : 15px
            }
            .img-div {
                margin-bottom : 10px
            }
            .fieldError {
                color: #bd2130;
            }
        </style>
    </th:block>
    
    <div layout:fragment="content">
    
        <form role="form" method="post" enctype="multipart/form-data" th:object="${itemFormDto}">
    
            <p class="h2">
                상품 등록
            </p>
    
            <input type="hidden" th:field="*{id}">
    
            <div class="form-group">
                <select th:field="*{itemSellStatus}" class="custom-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="form-group" th:each="num: ${#numbers.sequence(1,5)}">
                    <div class="custom-file img-div">
                        <input type="file" class="custom-file-input" name="itemImgFile">
                        <label class="custom-file-label" th:text="상품이미지 + ${num}"></label>
                    </div>
                </div>
            </div>
    
            <div th:if = "${not #lists.isEmpty(itemFormDto.itemImgDtoList)}">
                <div class="form-group" th:each="itemImgDto, status: ${itemFormDto.itemImgDtoList}">
                    <div class="custom-file img-div">
                        <input type="file" class="custom-file-input" name="itemImgFile">
                        <input type="hidden" name="itemImgIds" th:value="${itemImgDto.id}">
                        <label class="custom-file-label" th:text="${not #strings.isEmpty(itemImgDto.oriImgName)} ? ${itemImgDto.oriImgName} : '상품이미지' + ${status.index+1}"></label>
                    </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:unless="${#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>
    • 상품 등록 페이지 또한 itemForm.html 파일 수정
    • 관리자 페이지에서 중요한 것은 데이터의 무결성을 보장하는 것
    • 데이터가 의도와 다르게 저장된다거나, 잘못된 값이 저장되지 않도록 벨리데이션(validation)을 해야 함

     

     

    위의 코드에서 script 코드 살펴보기 

    var errorMessage = [[${errorMessage}]];
                if(errorMessage != null){
                    alert(errorMessage);
    }
    • 상품 등록 시 실패 메시지를 받아서 상품 등록 페이지에 재진입 시 alert를 통해 실패 사유를 보여줌
    if(fileExt != "jpg" && fileExt != "jpeg" && fileExt != "gif" && fileExt != "png" && fileExt != "bmp"){
                        alert("이미지 파일만 등록이 가능합니다.");
                        return;
    }
    • 파일 첨부 시 이미지 파일인지 검사, 보통 데이터를 검증할 때는 스크립트에서 벨리데이션을 한 번 하고, 스크립트는 사용자가 변경이 가능하므로 서버에서 한 번 더 벨리데이션을 한다. 스크립트에서 벨리데이션을 하는 이유는 서버쪽으로 요청을 하면 네트워크를 통해 서버에 요청이 도착하고 다시 그 결과를 클라이언트에 반환하는 등 리소스를 소모하기 때문
     $(this).siblings(".custom-file-label").html(fileName);
    • label 태그 안의 내용을 jquery의 .html()을 이용하여 파일명을 입력해준다.
    <form role="form" method="post" enctype="multipart/form-data" th:object="${itemFormDto}">
    • 파일을 전송할 때는 form 태그에 enctype(인코딩 타입) 값으로 "multipart/form-data"를 입력한다. 모든 문자를 인코딩하지 않음을 명시한다. 이 속성은 method 속성값이 "post"인 경우에만 사용 가능
    <div class="form-group">
                <select th:field="*{itemSellStatus}" class="custom-select">
                    <option value="SELL">판매중</option>
                    <option value="SOLD_OUT">품절</option>
                </select>
    </div>
    • 상품 판매 상태의 경우 판매중과 품절 상태가 있다. 상품 주문이 많이 들어와서 재고가 없을 경우 주문 시 품절 상태로 바꿔준다. 또한 상품을 등록만 먼저 해놓고 팔지 않을 경우에도 이용 가능
     <!-- 상품 이미지 정보를 담고 있는 리스트가 비어 있다면 상품을 등록" -->
            <div th:if="${#lists.isEmpty(itemFormDto.itemImgDtoList)}">
                <!-- 타임리프의 유틸리티 객체 #numbers.sequence(start,end)를 이용하면 start부터 end까지 반복처리 가능 -->
                <!-- 상품 등록 시 이미지의 개수를 최대 5개로 함 -->
                <div class="form-group" th:each="num: ${#numbers.sequence(1,5)}">
                    <div class="custom-file img-div">
                        <input type="file" class="custom-file-input" name="itemImgFile">
                        <!-- label 태그에는 몇 번째 상품 이미지인지 표시를 한다 -->
                        <label class="custom-file-label" th:text="상품이미지 + ${num}"></label>
                    </div>
                </div>
            </div>
    
            <!-- 상품 이미지 정보를 담고 있는 리스트가 비어 있지 않다면 상품을 수정 -->
            <div th:if = "${not #lists.isEmpty(itemFormDto.itemImgDtoList)}">
                <div class="form-group" th:each="itemImgDto, status: ${itemFormDto.itemImgDtoList}">
                    <div class="custom-file img-div">
                        <input type="file" class="custom-file-input" name="itemImgFile">
                        <!-- 상품 수정 시 어떤 이미지가 수정됐는지를 알기 위해서 상품 이미지의 아이디를 hidden 값으로 숨겨둠 -->
                        <input type="hidden" name="itemImgIds" th:value="${itemImgDto.id}">
                        <!-- 타임리프의 유틸리티 객체인 #string.isEmpty(string)을 이용하여 저장된 이미지 정보가 있다면 -->
                        <!-- 파일의 이름을 보여주고, 없다면 '상품 이미지+번호'를 출력 -->
                        <label class="custom-file-label" th:text="${not #strings.isEmpty(itemImgDto.oriImgName)} ? ${itemImgDto.oriImgName} : '상품이미지' + ${status.index+1}"></label>
                    </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:unless="${#strings.isEmpty(itemFormDto.id)}" style="text-align: center">
                <button th:formaction="@{'/admin/item/' + ${itemFormDto.id} }" type="submit" class="btn btn-primary">수정</button>
    		</div>

     

     

    application.properties

    spring.jpa.hibernate.ddl-auto=validate
    • 애플리케이션 실행 시점에 테이블을 삭제한 후 재생성하지 않으며 엔티티와 테이블이 매핑이 정상적으로 되어있는지만 확인한다. 엔티티를 추가가 필요할 경우 create와 validate를 번갈아 가면서 사용하면 조금 편하게 개발 진행할 수 있다.

    application-test.properties

    spring.jpa.hibernate.ddl-auto=create
    •  테스트 환경에서 추가

     

     

    application.properties 설정 추가

    #파일 한 개당 최대 사이즈
    spring.servlet.multipart.maxFileSize=20MB
    
    #요청당 최대 파일 크기
    spring.servlet.multipart.maxRequestSize=100MB
    
    #상품 이미지 업로드 경로
    itemImgLocation=C:/shop/item
    
    #리소스 업로드 경로
    uploadPath=file:///C:/shop/
    • 이미지 파일을 등록할 때 서버에서 각 파일의 최대 사이즈와 한번에 다운 요청할 수 있는 파일의 크기를 지정할 수 있다. 또한 컴퓨터에서 어떤 경로에 저장할지를 관리하기 위해서 프로퍼티에 itemImgLocation을 추가
    • 프로젝트 내부가 아닌 자신의 컴퓨터에서 파일을 찾는 경로로 uploadPath 프로퍼티 추가

     

     

    com.shop.config.WebMvcConfig.java

    package com.shop.config;
    
    import org.springframework.beans.factory.annotation.Value;
    import org.springframework.context.annotation.Configuration;
    import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
    import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
    
    @Configuration
    public class WebMvcConfig implements WebMvcConfigurer {
    
        // application.properties에 설정한 "uploadPath" 프로퍼티 값을 읽어옴
        @Value("${uploadPath}")
        String uploadPath;
    
        @Override
        public void addResourceHandlers(ResourceHandlerRegistry registry){
            // 웹 브라우저에 입력하는 url에 /images로 시작하는 경우 uploadPath에 설정한 폴더를 기준으로 파일을 읽어오도록 설정
            registry.addResourceHandler("/images/**")
                    .addResourceLocations(uploadPath); // 로컬 컴퓨터에 저장된 파일을 읽어올 root 설정
        }
    }
    • WebMvcConfigure 인터페이스를 구현하는 파일 작성

     

     

    com.shop.service.FileService.java

    package com.shop.service;
    
    import lombok.extern.java.Log;
    import org.springframework.stereotype.Service;
    
    import java.io.File;
    import java.io.FileOutputStream;
    import java.util.UUID;
    
    @Service
    @Log
    public class FileService {
    
        public String uploadFile(String uploadPath, String originalFileName, byte[] fileData) throws Exception {
    
            // 서로 다른 개체들을 구별하기 위해서 이름을 부여할 때 사용. 실제 사용시 중복될 가능성이 거의 없기 때문에
            // 파일의 이름으로 사용하면 파일명 중복 문제 해결 가능
            UUID uuid = UUID.randomUUID();
            String extension = originalFileName.substring(originalFileName
                    .lastIndexOf("."));
            // UUID로 받은 값과 원래 파일의 이름의 확장자를 조합해서 저장될 파일 이름을 만듬
            String savedFileName = uuid.toString() + extension;
            String fileUploadFullUrl = uploadPath + "/" + savedFileName;
            // FileOutputStream 클래스는 바이트 단위의 출력을 내보내는 클래스
            // 생성자로 파일이 저장될 위치와 파일의 이름을 넘겨 파일에 쓸 파일 출력 스트림을 만듬
            FileOutputStream fos = new FileOutputStream(fileUploadFullUrl);
            fos.write(fileData); // fileData를 파일 출력 스트림에 입력
            fos.close();
            return savedFileName; // 업로드된 파일의 이름을 반환
        }
    
        public void deleteFile(String filePath) throws Exception {
            File deleteFile = new File(filePath); // 파일이 저장된 경로를 이용하여 파일 객체를 생성
    
            // 해당 파일이 존재하면 파일 삭제
            if (deleteFile.exists()) {
                deleteFile.delete();
                log.info("파일을 삭제하였습니다.");
            } else {
                log.info("파일이 존재하지 않습니다.");
            }
        }
    }
    • 파일을 처리하는 FileService 클래스에 파일을 업로드하는 메소드와 삭제하는 메소드 작성

     

     

    com.shop.repository.ItemImgRepository.java

    package com.shop.repository;
    
    import com.shop.entity.ItemImg;
    import org.springframework.data.jpa.repository.JpaRepository;
    
    public interface ItemImgRepository extends JpaRepository<ItemImg, Long> {
    }
    • 상품의 이미지 정보를 저장하기 위해서 repository 패키지 아래에 JpaRepository를 상속받는 ItemImgRepository 인터페이스 작성

     

     

    com.shop.service.ItemImgService.java

    package com.shop.service;
    
    import com.shop.entity.ItemImg;
    import com.shop.repository.ItemImgRepository;
    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 {
        
        // application.properties 파일에 등록한 itemImgLocation 값을 불러와서 변수에 넣어줌
        @Value("${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 = ""; 
            
            // 파일 업로드
            if(!StringUtils.isEmpty(oriImgName)){
                // 사용자가 상품의 이미지를 등록했다면 저장할 경로와 파일의 이름, 파일을 파일의 바이트 배열을 
                // 파일 업로드 파라미터로 uploadFile 메소드를 호출. 호출 결과 로컬에 저장된 파일의 이름을 imgName 변수에 저장
                imgName = fileService.uploadFile(itemImgLocation, oriImgName, itemImgFile.getBytes());
                
                // 저장한 상품 이미지를 불러올 경로 설정. 외부 리소르를 불러오는 urlPatterns로 webMvcConfig 클래스에서
                // "/images/**"를 설정, 또한 application.properties에서 설정한 uploadPath 프로퍼티 경로인 "C:/shop/"
                // 아래 item 폴더에 이미지를 저장하므로 상품 이미지를 불러오는 경로로 "/images/item/" 를 붙여줌
                imgUrl = "/images/item/" + imgName;
            }
            
            // 입력받은 상품 이미지 정보 저장
            // oriImgName 업로드했던 상품 이미지 파일의 원래 이름
            // imgName 실제 로컬에 저장된 상품 이미지 파일의 이름
            // imgUrl 업로드 결과 로컬에 저장된 상품 이미지 파일을 불러오는 경로
            itemImg.updateItemImg(oriImgName, imgName, imgUrl);  
            itemImgRepository.save(itemImg);
        }
        
    }
    • 상품 이미지를 업로드하고, 상품 이미지 정보를 저장하는 클래스 작성

     

     

    com.shop.service.ItemService.java

    package com.shop.service;
    
    
    import com.shop.dto.ItemFormDto;
    import com.shop.entity.Item;
    import com.shop.entity.ItemImg;
    import com.shop.repository.ItemImgRepository;
    import com.shop.repository.ItemRepository;
    import lombok.RequiredArgsConstructor;
    import org.springframework.stereotype.Service;
    import org.springframework.transaction.annotation.Transactional;
    import org.springframework.web.multipart.MultipartFile;
    
    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 item = itemFormDto.createItem();
            itemRepository.save(item); // 상품 데이터 저장
            
            //이미지 등록
            for(int i=0; i<itemImgFileList.size(); i++){
                ItemImg itemImg = new ItemImg();
                itemImg.setItem(item);
                
                // 첫 번째 이미지일 경우 대표 상품 이미지 여부 값을 "Y"로 세팅, 나머지 상품 이미지는 "N"으로 설정
                if(i==0){ 
                    itemImg.setRepimgYn("Y");
                } else {
                    itemImg.setRepimgYn("N");
                } 
                // 상품의 이미지 정보를 저장
                itemImgService.saveItemImg(itemImg, itemImgFileList.get(i));
                }
            return item.getId();
            }
    }
    • 상품을 등록하는 ItemService 클래스 작성
    • @RequiredArgsConstructor - 초기화되지 않은 Final, @NonNull 어노테이션이 붙은 필드에 대한 생성자 생성

     

     

    com.shop.controller.ItemController.java

    package com.shop.controller;
    
    import com.shop.dto.ItemFormDto;
    import com.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.PostMapping;
    import org.springframework.web.bind.annotation.RequestParam;
    import org.springframework.web.multipart.MultipartFile;
    
    import javax.validation.Valid;
    import java.util.List;
    
    @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{
                // 상품 저장 로직을 호출, 매게 변수로 상품 정보와 상품 이미지 정보를 담고 있는 itemImgFileList를 넘겨줌
                itemService.saveItem(itemFormDto, itemImgFileList);
            } catch (Exception e){
                model.addAttribute("errorMessage", "상품 등록 중 에러가 발생하였습니다.");
                return "item/itemForm";
            }
            
            // 상품이 정상적으로 등록되었다면 메인 페이지로 이동
            return "redirect:/";
        }
    }
    • 상품을 등록하는 url을 ItemController 클래스에 추가

     

     

    com.shop.repository.ItemImgRepository.java

    public interface ItemImgRepository extends JpaRepository<ItemImg, Long> {
        List<ItemImg> findByItemIdOrderByIdAsc(Long itemId);
    }
    • 이미지가 잘 저장됐는지 테스트 코드를 작성하기 위해서 ItemImgRepository 인터페이스에 findByItemIdOrderByIdAsc 메소드를 추가
    • 매개변수로 넘겨준 상품 아이디를 가지며, 상품 이미지 아이디의 오름차순으로 가져오는 쿼리 메소드

     

     

    com.shop.service.ItemServiceTest.java

    package com.shop.service;
    
    
    import com.shop.constant.ItemSellStatus;
    import com.shop.dto.ItemFormDto;
    import com.shop.entity.Item;
    import com.shop.entity.ItemImg;
    import com.shop.repository.ItemImgRepository;
    import com.shop.repository.ItemRepository;
    import org.junit.jupiter.api.DisplayName;
    import org.junit.jupiter.api.Test;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.boot.test.context.SpringBootTest;
    import org.springframework.mock.web.MockMultipartFile;
    import org.springframework.security.test.context.support.WithMockUser;
    import org.springframework.test.context.TestPropertySource;
    import org.springframework.transaction.annotation.Transactional;
    import org.springframework.web.multipart.MultipartFile;
    
    import javax.persistence.EntityNotFoundException;
    import java.util.ArrayList;
    import java.util.List;
    
    import static org.junit.jupiter.api.Assertions.assertEquals;
    
    @SpringBootTest
    @Transactional
    @TestPropertySource(locations = "classpath:application-test.properties")
    public class ItemServiceTest {
    
        @Autowired
        ItemService itemService;
    
        @Autowired
        ItemRepository itemRepository;
    
        @Autowired
        ItemImgRepository itemImgRepository;
    
        // MockMultipartFile 클래스를 이용하여 가짜 MultipartFile 리스트를 만들어서 반환
        List<MultipartFile> createMultipartFiles() throws Exception{
    
    
            List<MultipartFile> multipartFileList = new ArrayList<>();
    
            for(int i=0; i<5; i++){
                String path = "C:/shop/item/";
                String imageName = "image" + i + ".jpg";
                MockMultipartFile multipartFile =
                        new MockMultipartFile(path, imageName, "image/jpg", new byte[]{1,2,3,4});
                multipartFileList.add(multipartFile);
            }
            return multipartFileList;
        }
    
        @Test
        @DisplayName("상품 등록 테스트")
        @WithMockUser(username = "admin", roles = "ADMIN")
        void saveItem() throws Exception{
            // 상품 등록 화면에서 입력 받는 상품 데이터를 세팅해줍니다.
            ItemFormDto itemFormDto = new ItemFormDto();
            itemFormDto.setItemNm("테스트상품");
            itemFormDto.setItemSellStatus(ItemSellStatus.SELL);
            itemFormDto.setItemDetail("테스트 상품 입니다.");
            itemFormDto.setPrice(1000);
            itemFormDto.setStockNumber(100);
    
            List<MultipartFile> multipartFileList = createMultipartFiles();
            // 상품 데이터와 이미지 정보를 파라미터로 넘겨서 저장 후 저장된 상품의 아이디 값을 반환 값으로 리턴
            Long itemId = itemService.saveItem(itemFormDto, multipartFileList);
    
            List<ItemImg> itemImgList =
                    itemImgRepository.findByItemIdOrderByIdAsc(itemId);
            Item item = itemRepository.findById(itemId)
                    .orElseThrow(EntityNotFoundException::new);
    
            // 입력한 상품 데이터와 실제로 저장된 상품 데이터가 같은지 확인
            assertEquals(itemFormDto.getItemNm(), item.getItemNm());
            assertEquals(itemFormDto.getItemSellStatus(), item.getItemSellStatus());
            assertEquals(itemFormDto.getItemDetail(), item.getItemDetail());
            assertEquals(itemFormDto.getPrice(), item.getPrice());
            assertEquals(itemFormDto.getStockNumber(), item.getStockNumber());
            // 상품 이미지는 첫 번째 파일의 원본 이미지 파일 이름만 같은지 확인
            assertEquals(multipartFileList.get(0).getOriginalFilename(), itemImgList.get(0).getOriImgName());
        }
    
    
    }
    • 어플리케이션 실행 후 상품 등록 후 이미지 저장을 하면 경로(C:/shop/item)로 업로드가 되고 메인 페이지로 이동한다.

     

     

    6.2 상품 수정하기

     

     

    com.shop.service.ItemService.java

    // 상품 데이터를 읽어오는 트랜잭션을 읽기 전용을 설정, 이럴 경우 JPA가 더티체킹(변경감지)을 수행하지 않아서 성능향상
        @Transactional(readOnly = true)
        public ItemFormDto getItemDtl(Long itemId){
    
            // 해당 상품의 이미지를 조회한다. 등록순으로 가지고 오기 위해서 상품 이미지 아이디 오름차순으로 가지고 옴
            List<ItemImg> itemImgList =
                    itemImgRepository.findByItemIdOrderByIdAsc(itemId);
            List<ItemImgDto> itemImgDtoList = new ArrayList<>();
    
            // 조회한 ItemImg 엔티티를 ItemImgDto 객체로 만들어서 리스트에 추가
            for(ItemImg itemImg : itemImgList){
                ItemImgDto itemImgDto = ItemImgDto.of(itemImg);
                itemImgDtoList.add(itemImgDto);
            }
    
            // 상품의 아이디를 통해 상품 엔티티를 조회, 존재하지 않을 때는 EntityNotFoundException을 발생
            Item item = itemRepository.findById(itemId)
                    .orElseThrow(EntityNotFoundException::new);
            ItemFormDto itemFormDto = ItemFormDto.of(item);
            itemFormDto.setItemImgDtoList(itemImgDtoList);
            return itemFormDto;
        }
    • 등록된 상품을 불러오는 메소드를 ItemService 클래스에 추가

     

     

    com.shop.controller.ItemController.java

    @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";
        }
    • 상품 수정 페이지로 진입하기 위해서 ItemController 클래스에 코드 추가
    • 실무에서는 등록 수정을 할 때 서버에 전달하는 데이터가 많이 다르기 때문에 보통 등록용 페이지와 수정용 페이지를 나눠서 개발

     

    저장한 상품을 조회하기위해서 http://localhost/admin/item/(상품 등록때 insert 되는 상품 아이디) 입력 시 수정페이지로 진입

     

     

    com.shop.service.ItemImgService.java

    public void updateItemImg(Long itemImgId, MultipartFile itemImgFile) throws Exception{
            if(!itemImgFile.isEmpty()){ // 상품 이미지를 수정한 경우 상품 이미지를 업데이트한다.
                // 상품 이미지 아이디를 이용하여 기존에 저장했던 상품 이미지 엔티티를 조회
                ItemImg savedItemImg = itemImgRepository.findById(itemImgId).orElseThrow(EntityExistsException::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;
    
                // 변경된 상품 이미지 정보를 세팅
                savedItemImg.updateItemImg(oriImgName, imgName, imgUrl);
            }
        }
    • 상품 이미지 수정을 위해서 ItemImgService 클래스 수정, 상품 이미지 데이터를 수정할 때는 변경감지 기능 사용
    savedItemImg.updateItemImg(oriImgName, imgName, imgUrl);
    • 상품 등록 때처럼 itemImgRepository.save() 로직을 호출하지 않는다. savedItemImg 엔티티는 현재 영속 상태이므로 데이터를 변경하는 것만으로 변경 감지 기능이 동작하여 트랜잭션이 끝날 때 update 쿼리가 실행된다. 여기서 중요한 것은 엔티티가 영속 상태여야 한다는 것.

     

     

    com.shop.entity.Item.java

    package com.shop.entity;
    
    import com.shop.constant.ItemSellStatus;
    import com.shop.dto.ItemFormDto;
    import lombok.Getter;
    import lombok.Setter;
    import lombok.ToString;
    
    import javax.persistence.*;
    import java.time.LocalDateTime;
    
    @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(EnumType.STRING)
        private ItemSellStatus itemSellStatus; // 상품 판매 상태
        
        public void updateTime(ItemFormDto itemFormDto){
            this.itemNm = itemFormDto.getItemNm();
            this.price = itemFormDto.getPrice();;
            this.stockNumber = itemFormDto.getStockNumber();
            this.itemDetail = itemFormDto.getItemDetail();
            this.itemSellStatus = itemFormDto.getItemSellStatus();
        }
    }
    • 상품을 업데이트하는 로직 구현. 먼저 Item 클래스에 상품 데이터를 업데이트하는 로직을 만든다. 엔티티 클래스에 비즈니스 로직을 추가한다면 조금 더 객체지향적으로 코딩할 수 있고, 코드를 재활용 수 있다. 또한 데이터 변경 포인터를 한군데에서 관리할 수 있다.

     

     

    com.shop.service.ItemService.java

     public Long updateItem(ItemFormDto itemFormDto, List<MultipartFile> itemImgFileList) throws Exception{
    
            // 상품 수정
            // 상품 등록 화면으로부터 전달받은 상품 아이디를 이용하여 상품 엔티티를 조회
            Item item = itemRepository.findById(itemFormDto.getId()).orElseThrow(EntityNotFoundException::new);
            item.updateTime(itemFormDto); // 상품 등록 화면으로부터 전달받은 ItemFormDto를 통해 상품 엔티티를 업데이트
    
            // 상품 이미지 아이디 리스트를 조회
            List<Long> itemImgIds = itemFormDto.getItemImgIds();
            //이미지 등록
            for(int i=0; i<itemImgFileList.size(); i++){
                // 상품 이미지를 업데이트하기 위해서 updateItemImg() 메소드에 상품 이미지 아이디와 상품 이미지 파일 정보를 파라미터로 전달
                itemImgService.updateItemImg(itemImgIds.get(i), itemImgFileList.get(i));
            }
            return item.getId();
        }
    • 상품을 업데이트할 때도 마찬가지로 변경 감지 기능을 사용

     

     

    com.shop.controller.ItemController.java

        @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{
                // 상품 수정 로직을 호출, 매게 변수로 상품 정보와 상품 이미지 정보를 담고 있는 itemImgFileList를 넘겨줌
                itemService.updateItem(itemFormDto, itemImgFileList);
            } catch (Exception e){
                model.addAttribute("errorMessage", "상품 등록 중 에러가 발생하였습니다.");
                return "item/itemForm";
            }
    
            // 상품이 정상적으로 등록되었다면 메인 페이지로 이동
            return "redirect:/";
        }
    • 상품을 수정하는 URL을 ItemController 클래스에 추가
    • 상품 등록 때 추가했던 코드와 거의 비슷하다.

     

    • 변경 감지 기능으로 인해 업데이트 쿼리문이 실행된다.
    • 상품 데이터를 수정 후 저장을 클릭하고 업로드 폴더를 확인해보면 기존에 있던 스웨터 이미지는 삭제되고 새로운 이미지가 올라온 것을 볼 수 있다.

     

     

    6.3 상품 관리하기

     

     

    • Querydsl을 사용하기 위해서 QDomain을 생성
    • 메이븐 컴파일 실행

     

     

    com.shop.dto.ItemSearchDto.java

    package com.shop.dto;
    
    import com.shop.constant.ItemSellStatus;
    import lombok.Getter;
    import lombok.Setter;
    
    @Getter @Setter
    public class ItemSearchDto {
    
        // 현재 시간과 상품 등록일을 비교해서 상품 데이터 조회
        // all: 상품 등록일 전체, 1d: 최근 하루 동안 등록된 상품, 1w: 최근 일주일 동안 등록된 상품
        // 1m: 최근 한달 동안 등록된 상품, 6m: 최근 6개월 동안 등록된 상품
        private String searchDateType;
    
        // 상품의 판매상태를 기준으로 상품 데이터 조회
        private ItemSellStatus searchSellStatus;
        
        // 상품을 조회할 때 어떤 유형으로 조히할지 선택
        // itemNm: 상품명, createBy: 상품 등록자 아이디
        private String searchBy; 
        
        // 조회할 검색어 저장할 변수, searchBy가 itemNm일 경우 상품명을 기준으로 검색하고 createdBy일 경우 
        // 상품 등록자 아이디 기준으로 검색
        private String searchQuery = "";
        
    }
    • 상품 데이터 조회 시 상품 조회 조건을 가지고 있는 ItemSearchDto 클래스 생성

     

     

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

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

     

    com.shop.repository.ItemRepositoryCustom.java

    package com.shop.repository;
    
    import com.shop.dto.ItemSearchDto;
    import com.shop.entity.Item;
    import org.springframework.data.domain.Page;
    import org.springframework.data.domain.Pageable;
    
    public interface ItemRepositoryCustom {
        
        // 상품 조회 조건을 담고 있는 itemSearchDto 객체와 페이징 정보를 담고 있는 pageable 객체를 파라미터로
        // 받는 getAdminItemPage 메소드를 정의. 반환 데이터로 Page<Item> 객체를 반환
        Page<Item> getAdminItemPage(ItemSearchDto itemSearchDto, Pageable pageable);
    }
    • 1. 사용자 정의 인터페이스 작성

     

     

    com.shop.repository.ItemRepositoryCustomImpl.java

    package com.shop.repository;
    
    import com.querydsl.core.types.dsl.BooleanExpression;
    import com.querydsl.core.types.dsl.Wildcard;
    import com.querydsl.jpa.impl.JPAQueryFactory;
    import com.shop.constant.ItemSellStatus;
    import com.shop.dto.ItemSearchDto;
    import com.shop.entity.Item;
    import com.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;
    
    // ItemRepositoryCustom 상속 받음
    public class ItemRepositoryCustomImpl implements ItemRepositoryCustom{
    
        // 동적으로 쿼리를 생성하기 위해서  JpaQueryFactory 클래스 사용
        private JPAQueryFactory queryFactory;
    
        // JpaQueryFactory의 생성자로 EntityManger 클래스 사용
        public ItemRepositoryCustomImpl(EntityManager em){
            this.queryFactory = new JPAQueryFactory(em);
        }
    
        // 상품 판매 상태 조건이 전체(null)일 경우는 null을 리턴한다. 결과값이 null이면 where 절에서 해당 조건은 무시됨.
        // 상품 판매 상태 조건이 null 아니라 판매중 or 품절 상태라면 해당 조건의 상품만 조회
        private BooleanExpression searchSellStatusEq(ItemSellStatus searchSellStatus){
            return searchSellStatus == null ? null : QItem.item.itemSellStatus.eq(searchSellStatus);
        }
    
        // searchDateType 값에 따라서 dateTime의 값을 이전 시간의 값으로 세팅 후 해당 시간 이후로 등록된 상품만
        // 조회한다. 예를 들어 searchDateType 값이 "1m"인 경우 dateTime의 시간을 한 달 전으로 세팅 후 최근 한 달
        // 동안 등록된 상품만 조회하도록 조건값을 반환한다.
        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);
        }
    
        // searchBy의 값에 따라 상품명에 검색어를 포함하고 있는 상품 또는 상품 생성자의
        // 아이디에 검색어를 포함하고 있는 상품을 조회하도록 조건값을 반환
        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) {
    
            // 쿼리 팩토리를 이용해서 쿼리 생성
            List<Item> content = queryFactory
                    .selectFrom(QItem.item) // 상품 데이터를 조회하기 위해서 QItem의 item을 지정
                    .where(regDtsAfter(itemSearchDto.getSearchDateType()), // BooleanExpression 반환하는 조건문들 넣어줌(',' 단위로 넣어줄 경우 and조건으로 인식)
                            searchSellStatusEq(itemSearchDto.getSearchSellStatus()),
                            searchByLike(itemSearchDto.getSearchBy(),
                                    itemSearchDto.getSearchQuery()))
                    .orderBy(QItem.item.id.desc())
                    .offset(pageable.getOffset()) // 데이터를 가지고 올 시작 인덱스 지정
                    .limit(pageable.getPageSize()) // 한 번에 가지고 올 최대 개수 지정
                    .fetch();
    
            long total = queryFactory.select(Wildcard.count).from(QItem.item)
                    .where(regDtsAfter(itemSearchDto.getSearchDateType()),
                            searchSellStatusEq(itemSearchDto.getSearchSellStatus()),
                            searchByLike(itemSearchDto.getSearchBy(), itemSearchDto.getSearchQuery()))
                    .fetchOne()
                    ;
    
            // 조회한 데이터를 Page 클래스의 구현체인 PageImpl 객체로 반환
            return new PageImpl<>(content, pageable, total);
        }
    }
    • 2. ItemRepositoryCustom 인터페이스를 구현하는 ItemRepositoryCustomImpl 클래스 작성

    Querydsl 의 결과 조회 메소드

    • List<T> fetch() - 조회 대상 리스트 반환
    • T fetchOne() - 조회 대상이 1건이면 해당 타입 반환, 1건 이상이면 에러 발생
    • T fetchFirst() - 조회 대상이 1건 또는 1건 이상이면 1건만 반환
    • long fetchCount() - 해당 데이터 전체 개수 반환, count 쿼리 실행 

     

     

    com.shop.repository.ItemRepository.java

    public interface ItemRepository extends JpaRepository<Item, Long>, QuerydslPredicateExecutor<Item>, ItemRepositoryCustom {
    
    			...
    }
    • 3. ItemRepository 인터페이스에서 ItemRepositoryCustom 인터페이스 상속
    • 이제 ItemRepository에서 Querydsl로 구현한 상품 관리 페이지 목록을 불러오는 getAdminItemPage() 사용가능

     

     

    com.shop.service.ItemService.java

     @Transactional(readOnly = true)
        public Page<Item> getAdminItemPage(ItemSearchDto itemSearchDto, Pageable pageable){
            return itemRepository.getAdminItemPage(itemSearchDto, pageable);
        }
    • ItemService 클래스에 상품 조회 조건과 페이지 정보를 파라미터로 받아서 상품 데이터를 조회하는 getAdminItemPage() 메소드를 추가
    • 데이터의 수정이 일어나지 않으므로 최적화를 위해 @Transactional(readOnly=true) 어노테이션 설정

     

     

    com.shop.controller.ItemController.java

    // value에 상품 관리 화면 진입 시 URL에 페이지 번호가 없는 경우와 페이지 번호가 있는 경우 2가지 매핑
        @GetMapping(value = {"/admin/items", "/admin/items/{page}"})
        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> items =
                    // 조회 조건과 페이징 정보를 파라미터로 넘겨서 Page<Item> 객체를 반환받음
                    itemService.getAdminItemPage(itemSearchDto, pageable);
            model.addAttribute("items", items); // 조회한 상품 데이터 및 페이징 정보를 뷰에 전달
            model.addAttribute("itemSearchDto", itemSearchDto); // 페이지 전환 시 기존 검색을 유지한 채 이동할 수 있도록 뷰에 다시 전달
            model.addAttribute("maxPage", 5); // 상품 관리 메뉴 하단에 보여줄 페이지 번호의 최대 개수, 최대 5개의 이동할 페이지 번호만 보여줌
        
            return "item/itemMng";
        }
    • ItemController 클래스에 상품 관리 화면 이동 및 조회한 상품 데이터를 화면에 전달하는 로직 구현
    • 현재 상품 데이터가 많이 없기 때문에 한 페이지당 총 3개의 상품만 보여줌(페이지 번호는 0부터 시작)

     

     

    상품 관리 화면 페이지

    resources/templates/item/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();
                    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>
    
            <!-- th:with는 변수값을 정의할 때 사용. 페이지 시작 번호(start)와 페이지 끝 페이지 번호(end)를 구해서 저장 -->
            <!-- 시작 페이지와 끝과 페이지 번호를 구하는 방법이 조금 복잡해 보이는데 정리하면 다음과 같다 -->
            <!-- 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)})" >
                <ul class="pagination justify-content-center">
    
                    <!-- 첫 번째 페이지면 이전 페이지로 이동하는 <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">
                            <span aria-hidden='true'>Previous</span>
                        </a>
                    </li>
    
                    <!-- 현재 페이지면 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>
                    </li>
    
                    <!-- 마지막 페이지일 경우 다음 페이지로 이동하는 <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">
                            <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" placeholder="검색어를 입력해주세요">
                <button id="searchBtn" type="submit" class="btn btn-primary">검색</button>
            </div>
        </form>
    
    </div>
    
    </html>
    • 상품 데이터를 테이블로 그려주는 영역, 이동할 페이지를 선택하는 영역, 검색 조건을 세팅하는 영역
    $(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;
            }
    • 스크립트  쪽 소스
    • 상품 검색 시 주의할 점은 <검색> 버튼을 클릭할 때 조회할 페이지 번호를 0으로 설정해서 조회해야 한다는 점이다. 그래야 현재 조회 조건으로 다시 상품 데이터를 0페이지부터 조회
    • e.preventDefault() - <검색> 버튼 클릭시 form 태그의 전송을 막아줌
    • page(0) - <검색> 버튼을 클릭할 페이지 번호로 0번째 페이지를 조회하는 page 함수 호출
    • function page(page) - page 함수는 이동할 페이지 값을 받아서 현재 조회조건으로 설정된 상품 등록 기간, 판매 상태, 조회 유형, 검색어를 파라미터로 설정 후 상품 데이터를 조회 
    <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>
    • Table 영역에서는 조회한 상품 데이터를 그려줌
    • <tr th:each="item, status: ${items.getContent()}"> - items.getContent() 메소드를 호출하면 조회한 상품 데이터를 리스트를 얻을 수 있다. 해당 리스트를 th:each를 통해서 반복적으로 테이블의 row를 그려줌
    • <td th:text="${items.itemSellStatus == T(com.shop.constant.ItemSellStatus).SELL} ? '판매중' : '품절'"></td> - 현재 상품의 판매 상태가 "SELL" 이면 '판매중', 아니면 '품절'로 보여줌
    <!-- th:with는 변수값을 정의할 때 사용. 페이지 시작 번호(start)와 페이지 끝 페이지 번호(end)를 구해서 저장 -->
            <!-- 시작 페이지와 끝과 페이지 번호를 구하는 방법이 조금 복잡해 보이는데 정리하면 다음과 같다 -->
            <!-- 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)})" >
                <ul class="pagination justify-content-center">
    
                    <!-- 첫 번째 페이지면 이전 페이지로 이동하는 <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">
                            <span aria-hidden='true'>Previous</span>
                        </a>
                    </li>
    
                    <!-- 현재 페이지면 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>
                    </li>
    
                    <!-- 마지막 페이지일 경우 다음 페이지로 이동하는 <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">
                            <span aria-hidden='true'>Next</span>
                        </a>
                    </li>
    
                </ul>
            </div>

     

    • 하단의 페이지 번호를 보여주는 영역

     

     

     

    6.4 메인 화면

    • 등록한 상품을 메인 페이지에서 고객이 볼 수 있도록 구현
    • 상품 관리 메뉴 구현과 비슷하며, 동일하게 Querydsl을 사용하여 페이징 처리 및 네비게이션바에 있는 Search 버튼을 이용하여 상품명으로 검색이 가능하도록 구현
    • @QueryProjection을 이용하여 상품 조회시 DTO 객체로 결과값을 받는 방법, Item 객체로 값을 받은 후 DTO 클래스로 변환하는 과정 없이 바로  DTO 객체를 뽑아낼 수 있다.

     

     

    com.shop.dto.MainItemDto.java

    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;
        
        // Querydsl로 결과 조회 시 MainItemDto 객체로 바로 받아오도록 활용
        @QueryProjection
        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;
        }
    }
    • 메인 페이지에서 상품을 보여줄 때 사용할 MainItemDto 클래스 생성
    • 작성 후 maven compile을 실행해 QDto 파일 생성

     

     

    com.shop.repository.ItemRepositoryCustom.java

    Page<MainItemDto> getMainItemPage(ItemSearchDto itemSearchDto, Pageable pageable);
    • 사용자 정의 인터페이스 작성
    • ItemRepositoryCustom 클래스에 메인 페이지에 보여줄 상품 리스트를 가져오는 메소드 생성

     

     

    com.shop.repository.ItemRepositoryCustomImpl.java

    // 검색어가 null이 아니면 상품명에 해당 검색어가 포함되는 상품을 조회하는 조건을 반환
        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;
    
            List<MainItemDto> content = queryFactory
                    .select(
                            // QmainItemDto의 생성자에 반환할 값들을 넣어줌. @QueryProjection을 사용하면 DTO로 바로 조회가 가능
                            // 엔티티 조회후 DTO로 변환하는 과정을 줄일 수 있다.
                            new QMainItemDto(
                                    item.id,
                                    item.itemNm,
                                    item.itemDetail,
                                    itemImg.imgUrl,
                                    item.price)
                    )
                    .from(itemImg)
                    .join(itemImg.item, item) // itemImg와 item을 내부 조인
                    .where(itemImg.repimgYn.eq("Y")) // 상품 이미지의 경우 대표 상품 이미지만 불러옴
                    .where(itemNmLike(itemSearchDto.getSearchQuery()))
                    .orderBy(item.id.desc())
                    .offset(pageable.getOffset())
                    .limit(pageable.getPageSize())
                    .fetch();
    
            long total = queryFactory
                    .select(Wildcard.count)
                    .from(itemImg)
                    .join(itemImg.item, item)
                    .where(itemImg.repimgYn.eq("Y"))
                    .where(itemNmLike(itemSearchDto.getSearchQuery()))
                    .fetchOne()
                    ;
    
            return new PageImpl<>(content, pageable, total);
        }
    • getMainItemPage() 메소드를 ItemRepositoryCustomImpl 클래스에 구현

     

     

    com.shop.service.ItemService.java

    @Transactional(readOnly = true)
        public Page<MainItemDto> getMainItemPage(ItemSearchDto itemSearchDto, Pageable pageable) {
            return itemRepository.getMainItemPage(itemSearchDto, pageable);
        }
    • 메인 페이지 보여줄 상품 데이터를 조회하는 메소드를 ItemService 클래스에 추가

     

     

    com.shop.controller.MainController.java

    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, 6);
            Page<MainItemDto> items = itemService.getMainItemPage(itemSearchDto, pageable);
            model.addAttribute("items", items);
            model.addAttribute("itemSearchDto", itemSearchDto);
            model.addAttribute("maxPage", 5);
            
            return "main";
        }
    }
    • 메인 페이지에 상품 데이터를 보여주기 위해서 기존에 작성했던 MainController 클래스 수정

     

     

    resources/templates/main.html

    <!DOCTYPE html>
    <html xmlns:th="http://www.thymeleaf.org"
          xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
          layout:decorate="~{layouts/layout1}">
    
    <!-- 사용자 CSS 추가 -->
    <th:block layout:fragment="css">
        <style>
            .carousel-inner > .item {
                height: 350px;
            }
            .margin{
                margin-bottom:30px;
            }
            .banner{
                height: 300px;
                position: absolute; top:0; left: 0;
                width: 100%;
                height: 100%;
            }
            .card-text{
                text-overflow: ellipsis;
                white-space: nowrap;
                overflow: hidden;
            }
            a:hover{
                text-decoration:none;
            }
            .center{
                text-align:center;
            }
        </style>
    </th:block>
    
    <div layout:fragment="content">
        <!-- 부트스트랩의 슬라이드를 보여주는 Carousel 컴포넌트를 이용하여 배너를 만듬. 쇼핑몰의 경우 보통 현재 행사 중인 상품을 광고하는데 사용 -->
        <div id="carouselControls" class="carousel slide margin" data-ride="carousel">
            <div class="carousel-inner">
                <div class="carousel-item active item">
                    <!-- 이미지 태그의 src 속성에는 웹상에 존재하는 이미지 경로를 넣어주면 해당 이미지를 보여줌 -->
                    <img class="d-block w-100 banner" src="https://user-images.githubusercontent.com/13268420/112147492-1ab76200-8c20-11eb-8649-3d2f282c3c02.png" alt="First slide">
                </div>
            </div>
        </div>
    
        <!-- 쇼핑몰 오른쪽 상단의 Search 기능을 이용해서 상품을 검색할 때 페이징 처리 시 해당 검색어를 유지하기 위해 hidden 값으로 검색어 유지 -->
        <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="row">
            <!-- 조회한 메인 상품 데이터를 보여줌. 부트스트랩의 Card 컴포넌트를 이용했고, 사용자가 카드 형태로 상품의 이름, 내용, 가격 볼 수 있음 -->
            <th:block th:each="item, status: ${items.getContent()}">
                <div class="col-md-4 margin">
                    <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 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>
    • 메인 페이지에 상품 데이터를 보여주도록 main.html 파일을 수정

     

     

     

     

    6.5 상품 상세 페이지

     

     

    com.shop.controller.ItemController.java

    @GetMapping(value = "/item/{itemId}")
        public String itemDtl(Model model, @PathVariable("itemId") Long itemId){
            ItemFormDto itemFormDto = itemService.getItemDtl(itemId);
            model.addAttribute("item", itemFormDto);
            return "item/itemDtl";
        }
    • 상품 상세 페이지로 이동할 수 있도록 ItemController 클래스에 코드 추가
    • 기존에 상품 수정 페이지 구현에서 미리 만들어 둔 상품을 가지고 오는 로직을 똑같이 사용

     

     

    resources/templates/item/itemDtl.html

    <!DOCTYPE html>
    <html xmlns:th="http://www.thymeleaf.org"
          xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
          layout:decorate="~{layouts/layout1}">
    
    <head>
        <meta name="_csrf" th:content="${_csrf.token}"/>
        <meta name="_csrf_header" th:content="${_csrf.headerName}"/>
    </head>
    
    <!-- 사용자 스크립트 추가 -->
    <th:block layout:fragment="script">
        <script th:inline="javascript">
            $(document).ready(function(){
    
                calculateToalPrice();
    
                $("#count").change( function(){
                    calculateToalPrice();
                });
            });
    
            function calculateToalPrice(){
                var count = $("#count").val();
                var price = $("#price").val();
                var totalPrice = price*count;
                $("#totalPrice").html(totalPrice + '원');
            }
    
            function order(){
                var token = $("meta[name='_csrf']").attr("content");
                var header = $("meta[name='_csrf_header']").attr("content");
    
                var url = "/order";
                var paramData = {
                    itemId : $("#itemId").val(),
                    count : $("#count").val()
                };
    
                var param = JSON.stringify(paramData);
    
                $.ajax({
                    url      : url,
                    type     : "POST",
                    contentType : "application/json",
                    data     : param,
                    beforeSend : function(xhr){
                        /* 데이터를 전송하기 전에 헤더에 csrf값을 설정 */
                        xhr.setRequestHeader(header, token);
                    },
                    dataType : "json",
                    cache   : false,
                    success  : function(result, status){
                        alert("주문이 완료 되었습니다.");
                        location.href='/';
                    },
                    error : function(jqXHR, status, error){
    
                        if(jqXHR.status == '401'){
                            alert('로그인 후 이용해주세요');
                            location.href='/members/login';
                        } else{
                            alert(jqXHR.responseText);
                        }
    
                    }
                });
            }
    
            function addCart(){
                var token = $("meta[name='_csrf']").attr("content");
                var header = $("meta[name='_csrf_header']").attr("content");
    
                var url = "/cart";
                var paramData = {
                    itemId : $("#itemId").val(),
                    count : $("#count").val()
                };
    
                var param = JSON.stringify(paramData);
    
                $.ajax({
                    url      : url,
                    type     : "POST",
                    contentType : "application/json",
                    data     : param,
                    beforeSend : function(xhr){
                        /* 데이터를 전송하기 전에 헤더에 csrf값을 설정 */
                        xhr.setRequestHeader(header, token);
                    },
                    dataType : "json",
                    cache   : false,
                    success  : function(result, status){
                        alert("상품을 장바구니에 담았습니다.");
                        location.href='/';
                    },
                    error : function(jqXHR, status, error){
    
                        if(jqXHR.status == '401'){
                            alert('로그인 후 이용해주세요');
                            location.href='/members/login';
                        } else{
                            alert(jqXHR.responseText);
                        }
    
                    }
                });
            }
    
        </script>
    </th:block>
    
    <!-- 사용자 CSS 추가 -->
    <th:block layout:fragment="css">
        <style>
            .mgb-15{
                margin-bottom:15px;
            }
            .mgt-30{
                margin-top:30px;
            }
            .mgt-50{
                margin-top:50px;
            }
            .repImgDiv{
                margin-right:15px;
                height:auto;
                width:50%;
            }
            .repImg{
                width:100%;
                height:400px;
            }
            .wd50{
                height:auto;
                width:50%;
            }
        </style>
    </th:block>
    
    <div layout:fragment="content" style="margin-left:25%;margin-right:25%">
    
        <input type="hidden" id="itemId" th:value="${item.id}">
    
        <div class="d-flex">
            <div class="repImgDiv">
                <img th:src="${item.itemImgDtoList[0].imgUrl}" class = "rounded repImg" th:alt="${item.itemNm}">
            </div>
            <div class="wd50">
                <span th:if="${item.itemSellStatus == T(com.shop.constant.ItemSellStatus).SELL}" class="badge badge-primary mgb-15">
                    판매중
                </span>
                <span th:unless="${item.itemSellStatus == T(com.shop.constant.ItemSellStatus).SELL}" class="badge btn-danger mgb-15" >
                    품절
                </span>
                <div class="h4" th:text="${item.itemNm}"></div>
                <hr class="my-4">
    
                <div class="text-right">
                    <div class="h4 text-danger text-left">
                        <input type="hidden" th:value="${item.price}" id="price" name="price">
                        <span th:text="${item.price}"></span>원
                    </div>
                    <div class="input-group w-50">
                        <div class="input-group-prepend">
                            <span class="input-group-text">수량</span>
                        </div>
                        <input type="number" name="count" id="count" class="form-control" value="1" min="1">
                    </div>
                </div>
                <hr class="my-4">
    
                <div class="text-right mgt-50">
                    <h5>결제 금액</h5>
                    <h3 name="totalPrice" id="totalPrice" class="font-weight-bold"></h3>
                </div>
                <div th:if="${item.itemSellStatus == T(com.shop.constant.ItemSellStatus).SELL}" class="text-right">
                    <button type="button" class="btn btn-light border border-primary btn-lg" onclick="addCart()">장바구니 담기</button>
                    <button type="button" class="btn btn-primary btn-lg" onclick="order()">주문하기</button>
                </div>
                <div th:unless="${item.itemSellStatus == T(com.shop.constant.ItemSellStatus).SELL}" class="text-right">
                    <button type="button" class="btn btn-danger btn-lg">품절</button>
                </div>
            </div>
        </div>
    
        <div class="jumbotron jumbotron-fluid mgt-30">
            <div class="container">
                <h4 class="display-5">상품 상세 설명</h4>
                <hr class="my-4">
                <p class="lead" th:text="${item.itemDetail}"></p>
            </div>
        </div>
    
        <div th:each="itemImg : ${item.itemImgDtoList}" class="text-center">
            <img th:if="${not #strings.isEmpty(itemImg.imgUrl)}" th:src="${itemImg.imgUrl}" class="rounded mgb-15" width="800">
        </div>
    
    </div>
    
    </html>
    • 상품 상세 페이지 작성
    • 상품 데이터 출력

     

    function calculateToalPrice(){
        var count = $("#count").val();
        var price = $("#price").val();
        var totalPrice = price*count;
        $("#totalPrice").html(totalPrice + '원');
    }
    • 현재 주문할 수량과 상품 한 개당 가격을 곱해서 결제 금액을 구해주는 함수

     

    <div th:each="itemImg : ${item.itemImgDtoList}" class="text-center">
            <img th:if="${not #strings.isEmpty(itemImg.imgUrl)}" th:src="${itemImg.imgUrl}" class="rounded mgb-15" width="800">
        </div>
    • 등록된 상품 이미지를 반복 구문을 통해 보여주고 있음. 보통 실제 쇼핑몰에서는 상품에 대한 정보를 예쁘게 이미지로 만들어서 보여줌

     

    댓글

Designed by Tistory.