본문 바로가기
🛠 백엔드/쇼핑몰 클론코딩

[6] Item 등록 및 조회

by meteorfish 2023. 1. 4.
728x90

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

  • 상품 등록 시, 화면으로 받은 DTOEntity로 변환 작업 필요

  • 상품 조회 시, EntityDTO로 변환 작업 필요

이를 해결하기 위해 modelmapper를 이용한다.

modelmapper

서로 다른 클래스의 값을 (필드의 이름과 자료형이 같으면) getter, setter를 통해 값을 복사해서 그 객체를 반환함.

Gradle에서 ModelMapper 사용하기


상품 저장 후 상품 이미지에 대한 데이터를 전달하는 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와 함께 사용하기

사용자 정의 레포지터리를 정의한다.

  1. 사용자 정의 인터페이스 작성
    1. 사용자 정의 인터페이스 구현
    2. 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. 상품 상세 페이지

메인 페이지의 상품 클릭 시 상셍 정보를 보여주는 페이지를 구현해보자

✨ 상품 컨트롤러에 상품 상세 페이지 주소 매핑

✨ 상품 상세 페이지 뷰를 생성

728x90

'🛠 백엔드 > 쇼핑몰 클론코딩' 카테고리의 다른 글

[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