1. 상품 등록하기
✨ Item의 Image를 저장하는 상품 이미지 엔티티를 만들어보자.
- Item과 Item_img는 일대다 관계이다.
_ItemImg_
@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;
}
}
✨ 상품 등록 및 수정시 사용할 데이터 전달용 DTO
- 상품 등록 시, 화면으로 받은
DTO
를Entity
로 변환 작업 필요
- 상품 조회 시,
Entity
를DTO
로 변환 작업 필요
이를 해결하기 위해 modelmapper
를 이용한다.
modelmapper
서로 다른 클래스의 값을 (필드의 이름과 자료형이 같으면) getter, setter를 통해 값을 복사해서 그 객체를 반환함.
상품 저장 후 상품 이미지에 대한 데이터를 전달하는 DTO
_ItemImgDto_
@Getter @Setter
public class ItemImgDto {
private Long id;
private String imgName;
private String oriImgName;
private String imgUrl;
private String repImgYn;
private static ModelMapper modelMapper = new ModelMapper(); // 멤버변수로 ModelMapper 객체를 추가
public static ItemImgDto of(ItemImg itemImg){ // itemImg와 멤버변수 이름이 같으면 ItemImgDto로 값을 복사해서 반환
return modelMapper.map(itemImg, ItemImgDto.class);
}
}
of()#map()
: 파라미터인 ItemImg의 자료형과 멤버변수 이름이 같으면, ItemImgDto로 값을 복사하고 반환.
상품 데이터 정보를 전달하는 DTO
_ItemFormDto_
@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();
// 엔티티 <--> DTO 데이터를 복사하여 반환
public Item createItem(){
return modelMapper.map(this, Item.class);
}
public static ItemFormDto of(Item item){
return modelMapper.map(item, ItemFormDto.class);
}
}
✨ 상품 등록 페이지 접근하는 ItemController를 수정한다.
상품 등록같은 관리자 페이지는
데이터의 무결성
을 보장해야한다.
한 상품의 내용이 수정되면 연관된 다른 데이터도 수정되어야 한다.
✨ 상품 등록 페이지 itemForm.html을 작성한다.
✨ 실행 전, 프로퍼티 파일 설정
상품 등록은 ADMIN만 가능하기 때문에 서버 재실행 시, 회원 정보가 사라지는 것을 막기위해 프로퍼티 파일을 수정한다.
application.properties
spring.jpa.hibernate.ddl-auto=validate
application-test.properties
spring.jpa.hibernate.ddl-auto=create
validate
: 서버 재실행 해도 테이블이 재생성되지 않음
이미지 파일 등록 시, 파일의 크기 및 다운 요청할 수 있는 파일의 크기 등이 설정 가능하다.
# File Max Size
spring.servlet.multipart.maxFileSize=20MB
# Request File Max Size
spring.servlet.multipart.maxRequestSize=100MB
# Upload Image Location
itemImgLocation=C:/shop/item
# Resource Upload Location
uploadPath=file:///C:/shop/
✨ 업로드한 파일을 읽어올 경로 설정
WebMvcConfig
public class WebMvcConfig implements WebMvcConfigurer {
@Value("${uploadPath}") // 프로퍼티에 설정한 uploadPath 프로퍼티 값 불러옴
String uploadPath;
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/images/**") // url에 /images로 시작하는 경우, uploadPath에 설정한 폴더를 기준으로 파일 읽어옴
.addResourceLocations(uploadPath); // 로컬 컴퓨터에 저장된 파일을 읽어올 root 경로 설정
}
}
✨ 파일을 처리하는 FileService 클래스
FileService
@Service
@Log
public class FileService {
public String uploadFile(String uploadPath, String originalFileName,
byte[] fileData) throws Exception {
UUID uuid = UUID.randomUUID(); // 1. UUID : 객체 구별을 위해 유일한 이름 부여
String extension = originalFileName.substring(originalFileName.lastIndexOf("."));
String savedFileName = uuid.toString() + extension; // 2. UUID와 조합해서 저장할 파일 이름 생성
String fileUploadFullUrl = uploadPath + "/" +savedFileName;
FileOutputStream fos = new FileOutputStream(fileUploadFullUrl); // 3. FileOutputStream : 바이트 단위의 출력을 내보냄. 생성자에 저장될 위치와 파일 이름 넘겨서 출력 스트림 만듬
fos.write(fileData); // 4. fuleData를 파일 출력 스트림에 입력
fos.close();
return savedFileName; // 5. 업로드된 파일의 이름을 반환
}
public void deleteFile(String filePath) throws Exception{
File deleteFile = new File(filePath); // 6. 파일이 저장된 경로를 이용해서 파일 객체 생성
if(deleteFile.exists()){ // 7. 해당 파일이 존재하면 파일을 삭제
deleteFile.delete();
log.info("파일을 삭제하였습니다.");
}
else{
log.info("파일이 존재하지 않습니다.");
}
}
}
상품 이미지 정보를 저장하는 ItemImgRepository 인터페이스를 생성.
상품이미지 업로드 및 정보를 저장하는 ItemImgService
생성
ItemImgService
@Service
@RequiredArgsConstructor
@Transactional
public class ItemImgService {
@Value("${itemImgLocation}") // 1. 프로퍼티에 등록한 itemImgLocation을 String 객체에 저장
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)){
// 2. 상품의 이미지를 등록했다면, uploadFile을 통해 로컬에 저장된 파일의 이름을 받아옴
imgName = fileService.uploadFile(itemImgLocation, oriImgName, itemImgFile.getBytes());
// 3. 저장한 상품 이미지를 불러올 경로 / 프로퍼티의 uploadPath(C:/shop) 아래의 /images/item/imgName을 불러옴
imgUrl = "/images/item/"+imgName;
}
// 상품 이미지 정보 저장
// 4.5.
// imgName : 실제 로컬에 저자오딘 이미지 파일 이름
// oriImgName : 업로드했던 이미지 파일의 원래 이름
// imgUrl : 업로드 결과 로컬에 저장된 이미지 파일을 불러오는 경로
itemImg.updateItemImg(oriImgName, imgName, imgUrl);
itemImgRepository.save(itemImg);
}
}
✨ 상품을 등록하는 클래스
ItemService
@Service
@Transactional
@RequiredArgsConstructor
public class ItemService {
private final ItemRepository itemRepository;
private final ItemImgService itemImgService;
private final ItemImgRepository itemImgRepository;
public Long saveItem(ItemFormDto itemFormDto, List<MultipartFile> itemImgFileList) throws Exception{
// 상품 등록
Item item = itemFormDto.createItem(); // 1. 상품 등록 폼의 데이터로 item 생성
itemRepository.save(item); // 2. 상품 데이터 저장
// 이미지 등록
for(int i=0;i<itemImgFileList.size()> ; i++) {
ItemImg itemImg = new ItemImg();
itemImg.setItem(item);
if(i==0){ // 3. 첫번째 이미지면 대표 이미지 설정
itemImg.setRepImgYn("Y");
}else{
itemImg.setRepImgYn("N");
}
itemImgService.saveItemImg(itemImg, itemImgFileList.get(i)); // 4. 상품의 이미지 정보 저장
}
return item.getId();
}
}
✨ 상품을 등록하는 URL을 컨트롤러에 추가하면 끝
이제 테스트 코드를 작성해서 정상적으로 이미지가 등록되는지 확인한다.
2. 상품 수정하기
등록한 상품의 내용을 수정하는 페이지를 만들어보자.
페이지는 기존 등록 페이지를 재활용해서 사용한다.
✨ 등록된 상품을 불러오는 메소드를 추가
ItemService
@Transactional(readOnly = true) // 읽기전용으로 하면 JPA가 더티체킹(변경 감지) 안해서 성능향상
public ItemFormDto getItemDtl(Long itemId){
List<ItemImg> itemImgList =
itemImgRepository.findByItemIdOrderByIdAsc(itemId); // 해당 상품 이미지 조회 / 등록순으로 가져옴
List<ItemImgDto> itemImgDtoList = new ArrayList<>();
for(ItemImg itemImg : itemImgList){ // 조회한 ItemImg 엔티티를 ItemImgDto객체로 만들어서 List에 추가
ItemImgDto itemImgDto = ItemImgDto.of(itemImg);
itemImgDtoList.add(itemImgDto);
}
Item item = itemRepository.findById(itemId) // 상품 아이디로 상품 엔티티 조회 / 없으면 익센션
.orElseThrow(EntityNotFoundException::new);
ItemFormDto itemFormDto = ItemFormDto.of(item);
itemFormDto.setItemImgDtoList(itemImgDtoList);
return itemFormDto;
}
- 실무에선 데이터가 많아 등록용 페이지와 수정용 페이지를 나눠서 개발
수정 페이지로 이동하도록 컨트롤러에 매핑을 한다.
✨ 상품 이미지 수정을 위한 서비스 클래스 수정
ItemImgService
public Long updateItem(ItemFormDto itemFormDto, List<MultipartFile> itemImgFileList) throws Exception {
// 상품 수정
Item item = itemRepository.findById(itemFormDto.getId()) // 멤버 폼으로 상품 불러옴
.orElseThrow(EntityNotFoundException::new);
item.updateItem(itemFormDto); // 상품을 상품 폼 데이터로 변경
List<Long> itemImgIds = itemFormDto.getItemImgIds(); // 폼에서 이미지정보들을 받아옴
// 이미지 등록
for(int i=0;i<itemImgFileList.size()>;i++){
itemImgService.updateItemImg(itemImgIds.get(i), itemImgFileList.get(i)); // 이미지를 업데이트하기 위해 (이미지 id, 이미지 파일 정보)를 파라미터로 전달
}
return item.getId();
}
- 이미지 세팅시, itemImgRepository.save()를 하지 않는다!
- 그 이유는 savedItemImg는 현재 영속 상태이기 때문에 변경감지시 자동으로 DB에 반영되기 때문에 할 필요가 없다!
✨ 상품 업데이트하는 로직 구현
Item 엔티티에 상품 정보를 업데이트하는 로직을 만든다.
그 이유는 비즈니스 로직에 추가를 해야 좀 더 객체지향적으로 코딩 가능하고, 재활용도 가능하기 때문
Item
public void updateItem(ItemFormDto itemFormDto){
this.itemNm = itemFormDto.getItemNm();
this.price = itemFormDto.getPrice();
this.stockNumber = itemFormDto.getStockNumber();
this.itemDetail = itemFormDto.getItemDetail();
this.itemSellStatus = itemFormDto.getItemSellStatus();
}
ItemService
public Long updateItem(ItemFormDto itemFormDto, List<MultipartFile> itemImgFileList) throws Exception {
// 상품 수정
Item item = itemRepository.findById(itemFormDto.getId()) // 멤버 폼으로 상품 불러옴
.orElseThrow(EntityNotFoundException::new);
item.updateItem(itemFormDto); // 상품을 상품 폼 데이터로 변경
List<Long> itemImgIds = itemFormDto.getItemImgIds(); // 폼에서 이미지정보들을 받아옴
// 이미지 등록
for(int i=0;i<itemImgFileList.size()>;i++){
itemImgService.updateItemImg(itemImgIds.get(i), itemImgFileList.get(i)); // 이미지를 업데이트하기 위해 (이미지 id, 이미지 파일 정보)를 파라미터로 전달
}
return item.getId();
}
상품 수정하는 페이지를 컨트롤러에 매핑하자
3. 상품 관리하기
✨ 상품 리스트를 조회할 수 있는 화면
상품 조회 조건을 설정 후 페이징 기능을 통해 일정 개수만 볼 수 있도록 구현
조회 조건
- 상품 등록일
- 상품 판매 기간
- 상품명 또는 상품 등록자 아이디
Querydsl을 이용해 QDomain 클래스를 생성한다.
✨ 상품 데이터 조회 시 상품 조회 조건을 가지는 Dto
ItemSearchDto
@Getter
@Setter
public class ItemSearchDto {
/*
현재 시간과 상품 등록일을 비교해서 조회
- all : 상품 등록일 전체
- 1d : 최근 하루 동안 등록된 상품
- 1w : 최근 일주일 동안 등록된 상품
- 1m : 최근 한달 동안 등록된 상품
- 6m : 최근 6개월 동안 등록된 상품
*/
private String searchDateType;
// 상품의 판매상태를 기준으로 조회
private ItemSellStatus searchSellStatus;
/* 아딴 유형으로 조회할지 선택
- itemNm
- createdBy
*/
private String searchBy;
// 조회할 검색어 저장할 변수
private String searchQuery = "";
}
✨ Querydsl을 SpringDataJPA와 함께 사용하기
사용자 정의 레포지터리를 정의한다.
- 사용자 정의 인터페이스 작성
- 사용자 정의 인터페이스 구현
- Spring Data Jpa의 Repository에서 사용자 정의 interface 상속
1. 사용자 정의 인터페이스 작성
-> ItemRepositoryCustom
public interface ItemRepositoryCustom {
// 상품 조회 조건을 담는 itemSearchDto, 페이징 정보를 담는 Pageable
// Page<Item> 객체 반환
Page<Item> getAdminItemPage(ItemSearchDto itemSearchDto, Pageable pageable);
}
2. 사용자 정의 인터페이스 구현
Querydsl에서 BooleanExpression
(where절에서 사용할 수 있는 값)을 지원
-> ItemRepositoryCustomImpl
public class ItemRepositoryCustomImpl implements ItemRepositoryCustom {
// 2. 동적 쿼리를 생성하기 위해 JPAQueryFactory 사용
private JPAQueryFactory queryFactory;
// 3.JpaQueryFactory 생성자에 EntityManager 넣음
public ItemRepositoryCustomImpl(EntityManager em){
this.queryFactory = new JPAQueryFactory(em);
}
// 4. 상품 판매 상태가 전체(null)이면 null 반환 / 아니면 해당 조건의 상품만 조회
private BooleanExpression searchSellStatusEq(ItemSellStatus searchSellStatus){
return searchSellStatus == null ? null : QItem.item.itemSellStatus.eq(searchSellStatus);
}
// 5. 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);
}
// 6. 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) {
// 7. queryFactory로 쿼리를 생성
QueryResults<Item> results = queryFactory.selectFrom(QItem.item) // selectForm() : 상품 데이터를 조회하기 위해 QItem의 item을 지정
.where(regDtsAfter(itemSearchDto.getSearchDateType()), // where : BooleanExpression 반환하는 조건문들 넣어줌
searchSellStatusEq(itemSearchDto.getSearchSellStatus()),
searchByLike(itemSearchDto.getSearchBy(),
itemSearchDto.getSearchQuery()))
.orderBy(QItem.item.id.desc()) // 내림차순
.offset(pageable.getOffset()) // offset : 데이터를 가지고 올 시작 인덱스 지정
.limit(pageable.getPageSize()) // limit : 한번에 가져올 최대 개수 지정
.fetchResults(); // fetchResults : 조회한 리스트 및 전체 개수를 포함하는 QueryResults를 반환 (상품 데이터 리스트 조회, 상품 데이터 전체 개수 조회 실행)
List<Item> content = results.getResults();
long total = results.getTotal();
// 8. 조회 후 데이터를 PageImpl 객체로 반환
return new PageImpl<>(content, pageable, total);
}
// 검색어가 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;
QueryResults<MainItemDto> results = queryFactory
.select(
new QMainItemDto( // 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())
.fetchResults();
List<MainItemDto> content = results.getResults();
long total = results.getTotal();
return new PageImpl<>(content, pageable, total);
}
}
3. Spring Data Jpa의 Repository에서 사용자 정의 interface 상속
마지막으로 ItemRepository 인터페이스에서 ItemRepositoryCustom 상속
✨ 상품 조회 조건, 페이지 저보로 상품 데이터 조회하는 메소드 사용하기
getAdminItemPage()를 ItemService에서 사용해보자
-> ItemService
@Transactional(readOnly = true)
public Page<Item> getAdminItemPage(ItemSearchDto itemSearchDto, Pageable pageable){
return itemRepository.getAdminItemPage(itemSearchDto, pageable);
}
✨ 컨트롤러에 상품 관리 화면 이동 및 조회한 상품 데이터를 화면에 전달
✨ 상품 관리 목록 뷰를 만들어준다.
4. 메인화면
@QueryProjection으로 DTO 객체로 결과를 받는 방법을 사용
위 어노테이션으로 Item 객체로 받은 값을 DTO 객체로 바로 뽑아 사용
✨ 메인페이지에서 상품 보여줄 DTO
-> MainItemDto
@Getter @Setter
public class MainItemDto {
private Long id;
private String itemNm;
private String itemDetail;
private String imgUrl;
private Integer price;
@QueryProjection // Querydsl로 조회 시 MainItemDto로 바로 받음
public MainItemDto(Long id, String itemNm, String itemDetail, String imgUrl,Integer price){
this.id = id;
this.itemNm = itemNm;
this.itemDetail = itemDetail;
this.imgUrl = imgUrl;
this.price = price;
}
}
해당 Dto에 대한 QDto 파일을 생성한다.
✨ 메인 페이지에 보여줄 상품 리스트 가져오는 메소드
-> ItemRepositoryCustom
Page<MainItemDto> getMainItemPage(ItemSearchDto itemSearchDto, Pageable pageable);
위 커스텀 레포지토리를 구현하는 구현체를 작성한다.
-> ItemRepositoryCustomImpl
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;
QueryResults<MainItemDto> results = queryFactory
.select(
new QMainItemDto(
item.id,
item.itemNm,
item.itemDetail,
itemImg.imgUrl,
item.price)
)
.from(itemImg)
.join(itemImg.item, item)
.where(itemImg.repimgYn.eq("Y"))
.where(itemNmLike(itemSearchDto.getSearchQuery()))
.orderBy(item.id.desc())
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();
List<MainItemDto> content = results.getResults();
long total = results.getTotal();
return new PageImpl<>(content, pageable, total);
}
✨ 메인 페이지 보여줄 상품 데이터를 조회하는 메소드 추가
✨ 메인 페이지에 상품 보여주는 주소 컨틀롤러에 매핑
5. 상품 상세 페이지
메인 페이지의 상품 클릭 시 상셍 정보를 보여주는 페이지를 구현해보자
✨ 상품 컨트롤러에 상품 상세 페이지 주소 매핑
✨ 상품 상세 페이지 뷰를 생성
'🛠 백엔드 > 쇼핑몰 클론코딩' 카테고리의 다른 글
[2] JPA (0) | 2023.01.08 |
---|---|
[7] Order (0) | 2023.01.07 |
[5] 연관매핑 (0) | 2022.12.30 |
[4] Spring Security (1) | 2022.12.30 |
[3] Thymeleaf (0) | 2022.12.25 |