-
[백견불야일타] 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와 함께 사용하기 위해서는 사용자 정의 리포지토리를 정의해야 함
- 사용자 정의 인터페이스 작성
- 사용자 정의 인터페이스 구현
- 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>
- 등록된 상품 이미지를 반복 구문을 통해 보여주고 있음. 보통 실제 쇼핑몰에서는 상품에 대한 정보를 예쁘게 이미지로 만들어서 보여줌
'SpringBoot > 쇼핑몰 프로젝트 with JPA' 카테고리의 다른 글
[백견불야일타] 5장 연간 관계 매핑 (1) 2022.09.20 [백견불야일타] 4장 스프링 시큐리티를 이용한 회원 가입 및 로그인 (0) 2022.09.07 [백견불야일타] 3장 Thymeleaf 학습하기 (0) 2022.08.26 [백견불야일타] 2장 Spring Data JPA (0) 2022.08.19 [백견불여일타] 1장 개발환경구축 (0) 2022.08.16