ERD

 

 

상품이미지 엔티티 구현


  • ItemImg 클래스 생성
@Entity
@Table(name = "item_img")
@Data
public class ItemImg extends BaseEntity{

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "item_img_id")
    private Long id;

    @Column(name = "image_name", nullable = false)
    private String imgName;  // 이미지 파일명

    @Column(name = "original_name")
    private String oriImgName;  // 원본 이미지 파일명

    @Column(name = "image_url")
    private String imgUrl;  // 이미지 조회 경로

    @Column(name = "represent_img")
    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;
    }
}

 

  ✔️  updateItemImg: 이미지의 원본 파일명, 이미지 파일명, 이미지 URL을 업데이트. ItemImg 객체의 해당 필드들을 수정하는 데 사용됨.

 

 

 

DTO 구현


  • 상품 등록시에는 화면에서 전달받은 DTO 객체를 엔티티 객체로 변환하는 작업을 해야 함
  • modelmapper 라이브러리 사용하여 DTO(Data Transfer Object)와 엔티티(Entity) 간의 변환을 효율적으로 처리

 

1.  상품 저장 후 상품 이미지에 대한 데이터를 전달할 DTO 클래스

@Data
public class ItemImgDTO {

    private Long id;

    private String imgName;  // 이미지 파일명

    private String oriImgName;  // 원본 이미지 파일명

    private String imgUrl;  // 이미지 조회 경로

    private String repImgYn;  // 대표 이미지 여부

    private static ModelMapper modelMapper = new ModelMapper();

    public static ItemImgDTO of(ItemImg itemImg) {
        return modelMapper.map(itemImg, ItemImgDTO.class);
    }

}

 

  ✔️  멤버 변수로 ModelMapper 객체 추가  ➡️  ItemImg 객체를 파라미터로 받아 ItemImgDTO 로 값을 복사해서 반환

  ✔️  of 메소드는 주로 컨트롤러에서 데이터 전송을 위해 ItemImgDTO 객체를 생성할 때 사용됨.

  ✔️  static으로 선언된 of 메소드는 ItemImgDTO 클래스의 인스턴스를 생성하지 않고도 호출할 수 있다

        ➡️  static으로 선언된 이유DTO 변환 로직이 인스턴스의 상태와 관계없이 동작하므로, static 메소드로 정의하여 클래스 수준에서 직접 호출할 수 있도록함. 이로 인해 매핑 로직을 재사용할 수 있고, 코드가 간결해짐.

 

 

 

2.  상품 데이터 정보를 전달하는 DTO

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ItemFormDTO {

    private Long id;

    @NotBlank(message = "☑️ Item Name is required. ")
    private String itemNm;

    @NotNull(message = "☑️ Price is required. ")
    @Positive(message = "Price must be a positive number.")
    private Integer price;

    @NotBlank(message = "☑️ Item Detail is required. ")
    private String itemDetail;

    @NotNull(message = "☑️ Stock Number is required. ")
    @Min(value = 0, message = "☑️ Stock Number must be zero or positive.")
    private Integer stockNum;

    private ItemSellStatus itemSellStatus;

    // 상품 저장 후 수정할 때 상품 이미지 정보를 저장하는 리스트
    private List<ItemImgDTO> itemImgDTOList = new ArrayList<>();

    // 수정 시에 아이디를 담아둘 용도
    private List<Long> itemImgIds = new ArrayList<>();

    private static ModelMapper modelMapper = new ModelMapper();

    // ItemFormDTO의 필드를 Item 엔티티로 매핑
    public Item createItem() {
        return modelMapper.map(this, Item.class);
    }

    // Item 엔티티를 ItemFormDTO로 매핑하는 정적 메서드
    public static ItemFormDTO of(Item item) {
        return modelMapper.map(item, ItemFormDTO.class);
    }
}

 

  ✔️  문자열이아닌 Integer에서는 @Length가 아닌 @Min, @Max를 사용

  ✔️  createItem(): ItemFormDTO 객체의 데이터를 Item 엔티티 객체로 변환. 주로 상품을 데이터베이스에 저장하기 위해 사용

  ✔️  of(Item item): Item 엔티티 객체를 ItemFormDTO로 변환하는 정적 메소드. 주로 엔티티를 DTO로 변환하여 사용자에게 반환할 때 사용

 

 

기존 ItemController 페이지 수정


@Controller
@RequiredArgsConstructor
@Data
public class ItemController {

    @GetMapping("/admin/item/new")
    public String itemForm(Model model){
        model.addAttribute("itemFormDTO", new ItemFormDTO());
        return "item/itemForm";
    }
}

 

  • 상품 등록과 같은 관리자 페이지에서 중요한 것은 데이터의 무결성을 보장해야 한다는 것!
  • 데이터가 의도와 다르게 저장되거나 잘못된 값이 저장되지 않도록 validation을 해야 함.
  • 특히 데이터끼리 서로 연관이 있으면 어떤 데이터가 변함에 따라 다른 데이터도 함께 체크를 해야하는 경우 빈번.

 

 

상품 등록 페이지 구현


  • ItemForm.html 생성 (코드가 길기 때문에 깃허브 소스 첨부)

https://github.com/SominY/Dora-Flower-Shop/blob/main/src/main/resources/templates/item/itemForm.html

 

Dora-Flower-Shop/src/main/resources/templates/item/itemForm.html at main · SominY/Dora-Flower-Shop

쇼핑몰 프로젝트 (개인 프로젝트). Contribute to SominY/Dora-Flower-Shop development by creating an account on GitHub.

github.com

 

 

* 앞에 memberForm 페이지 참고해서 부트스트랩 코드 작성함..! 아쉬운 점은 부트스트랩 파일 업로드 형식이 너무 제한적.. ㅠ 저런 디자인 말고 다른거 하고 싶은데 버전별로 다른듯..😣

 

        • 폼 전송할 때 enctype(인코딩 타입) 값으로 'multipart/form-data' 입력 - 웹 폼에서 파일을 업로드할 때 사용되는 인코딩 방식. 이 방식은 데이터와 파일을 서버로 전송할 때 각각의 데이터를 구분하고, 파일을 포함한 복합적인 데이터 전송을 가능하게 함

 

상품 등록 시

   

    - itemImgDTOList가 비어 있는 경우(새 상품 등록 시) 1~5개의 이미지 업로드 필드를 동적으로 생성.

    - itemFormDTO.id가 비어 있을 경우, Register 버튼을 표시

 

상품 수정 시

   

    - itemImgDTOList가 비어 있지 않은 경우(기존 상품 수정 시) 기존 이미지들을 수정할 수 있도록 이미지 업로드 필드를 생성.

    - itemImgIds는 숨겨진 필드로 기존 이미지 ID를 전달.

    - itemFormDTO.id가 존재할 경우, Update 버튼을 표시

 

#lists.isEmpty(...)

 

  • #lists는 Thymeleaf의 내장 객체로, 리스트와 관련된 유틸리티 메소드를 제공

  • isEmpty(...)#lists 객체의 메소드로, 리스트가 비어 있는지를 확인하는 기능

 

#strings.isEmpty(...)

 

  • 특정 문자열이 빈 문자열("")인지, 또는 null인지 여부를 검사

 

#numbers.sequence(start, end)

 

  • 지정된 시작 값과 끝 값 사이의 정수 시퀀스를 생성하여 반복할 수 있게 함

  • Thymeleaf 템플릿에서 주로 반복문을 생성할 때 유용

 

th:formaction

 

  • 폼의 제출 URL을 동적으로 설정

  • 등록과 수정 작업에 따라 서로 다른 URL로 폼을 제출할 수 있도록 함

 

 

자바스크립트 코드

⚡️ 외부로 파일 빼고 싶으면 부트스트랩 jquery 코드 html에 넣어줘야함!!!!!

더보기

1. $(".custom-file-input").on("change", function () { ... });

  • custom-file-input 클래스를 가진 HTML 요소에 change 이벤트 리스너를 추가. 사용자가 파일 선택 필드에서 파일을 선택할 때마다 이 함수가 실행.

 

2. var fileName = $(this).val().split("\\").pop();

  • 사용자가 선택한 파일의 이름을 가져옴

  • 작동 방식: $(this).val() - 현재 변경된 input 요소의 값을 가져옴. 이 값은 선택된 파일의 경로.

                     split("\\") - 경로를 역슬래시(\)를 기준으로 분리하여 파일 이름을 포함한 배열을 생성.

                     pop() - 배열의 마지막 요소, 즉 파일 이름을 가져옴.

 

3. var fileExt = fileName.substring(fileName.lastIndexOf(".") + 1);

  • 파일의 확장자를 추출

  • 작동 방식 : fileName.lastIndexOf(".") + 1 - 파일 이름에서 마지막 점(.)의 위치를 찾고, 이 위치 다음 문자부터 문자열의 끝까지를 가져옴.

                     substring(...) - 파일 확장자를 추출

 

4. fileExt = fileExt.toLowerCase();

  • 파일 확장자를 소문자로 변환하여 대소문자 구분 없이 파일 형식을 검사할 수 있도록 함

 

5. if (fileExt != "jpg" && fileExt != "jpeg" && fileExt != "gif" && fileExt != "png" && fileExt != "bmp") { ... }

  • 파일 확장자가 허용된 이미지 파일 형식인지 확인

  • 허용되지 않는 파일 형식인 경우 사용자에게 알림을 띄우고 함수 실행을 종료 (return)

 

6. $(this).siblings(".custom-file-label").html(fileName);

  • 선택한 파일의 이름을 .custom-file-label 클래스를 가진 형제 요소에 표시

  • 작동 방식 : $(this).siblings(".custom-file-label") - 현재 파일 입력 요소의 형제 요소 중 .custom-file-label 클래스를 가진 요소를 선택.

                      .html(fileName) - 선택된 파일 이름을 해당 요소의 HTML로 설정

 

 

application.properties 설정 추가


# 파일 한 개당 최대 사이즈
spring.servlet.multipart.max-file-size=20MB
# 요청당 최대 파일 크기
spring.servlet.multipart.max-request-size=100MB
# 상품 이미지 업로드 경로
itemImgLocation=/Users/Desktop/e-commerce project/item
# 리소스 업로드 경로
uploadPath=file:/Users/Desktop/e-commerce project

 

 

 

WebMvcConfigurer 인터페이스 구현


@Configuration
public class WebMvcConfig implements WebMvcConfigurer {

    @Value("${uploadPath}")
    String uploadPath;

    @Override
    public void addResourceHandlers(ResourceHandlerRegistry registry) {
        registry.addResourceHandler("/images/**")
                .addResourceLocations(uploadPath);
    }

}
  • addResourceHandlers(ResourceHandlerRegistry registry) : Spring MVC의 리소스 핸들러를 추가하는 메소드. 이 메소드를 통해 리소스(예: 이미지, CSS, JS 파일)의 요청을 처리하는 방법을 설정.
  • registry.addResourceHandler("/images/**") : /images/** 패턴의 URL 요청을 리소스 핸들러로 등록. 즉, 클라이언트가 /images/로 시작하는 URL을 요청할 때, 이 핸들러가 요청을 처리함
  • addResourceLocations(uploadPath) : uploadPath에 설정된 경로에서 리소스를 제공하도록 지정. 이 경로는 파일 시스템의 위치를 나타내며, 이 경로에서 요청된 파일을 제공하게 됨. 예를 들어, /images/example.jpg 요청이 들어오면 uploadPath로 지정된 경로에서 example.jpg 파일을 찾아서 반환.

 

https://jake-seo-dev.tistory.com/605

 

스프링 WebMvcConfigurer 인터페이스란?

WebMvcConfigurer 란? 스프링 프레임워크에서 제공하는 인터페이스이다. 보일러플레이트 코드 없이 요구사항에 맞게 프레임워크를 조정할 수 있게 해준다. 특정한 스프링 클래스를 구현하거나 상속

jake-seo-dev.tistory.com

 

 

 

파일을 처리하는 Service 구현


public String uploadFile(String uploadPath, String originalFileName, 
    byte[] fileData) throws Exception {
    
    // 서로 다른 개체들을 구별하기 위해서 이름을 부여할 때 사용
    UUID uuid = UUID.randomUUID();
    
    String extension = originalFileName.substring(originalFileName.lastIndexOf("."));
    String savedFileName = uuid + extension;
    String fileUploadFullUrl = uploadPath + "/" + savedFileName;

    FileOutputStream fos = new FileOutputStream(fileUploadFullUrl);
    fos.write(fileData);
    fos.close();

    return savedFileName;
}

 

 

UUID 생성 : UUID.randomUUID()를 사용하여 파일 이름 충돌을 방지. UUID는 파일 이름의 일부분으로 사용되며, 파일의 유일성을 보장.

파일 확장자 추출 : originalFileName에서 파일 확장자를 추출. 예를 들어, .jpg, .png와 같은 확장자를 추출.

파일 이름 생성 : UUID와 파일 확장자를 결합하여 저장할 파일 이름을 생성.

파일 경로 구성 : uploadPathsavedFileName을 결합하여 파일의 전체 경로를 생성.

파일 저장

    -  FileOutputStream 객체를 생성하여 지정된 파일 경로 (fileUploadFullUrl)로 데이터를 쓸 수 있는 출력 스트림을 준비

    -  바이트 배열 fileData를 파일에 기록. fileData는 파일의 실제 데이터이며, 이 데이터가 FileOutputStream을 통해 파일에 기록

    -  스트림을 닫아 파일 출력을 완료하고 자원을 해제

예외 처리 : throws Exception을 선언하여 예외 발생 시 호출자에게 전달. 예를 들어, 파일 저장 중 IO 오류가 발생할 수 있음.

 

UUID는 'Universally Unique Identifier'의 약자로 128-bit의 고유 식별자. 다른 고유 ID 생성 방법과 다르게 UUID는 중앙 시스템에 등록하고 발급하는 과정이 없어서 상대적으로 더 빠르고 간단하게 만들 수 있다는 장점이 있다.

 

https://docs.tosspayments.com/resources/glossary/uuid

 

UUID(Universally Unique Identifier) | 토스페이먼츠 개발자센터

UUID는 128-bit의 고유 식별자에요. UUID는 중앙 시스템에 등록하고 발급하는 과정이 없어서 상대적으로 빠르고 간단하게 만들 수 있어요.

docs.tosspayments.com

 

 

    public void deleteFile(String filePath) throws Exception{

        File deleteFile = new File(filePath);

        if(deleteFile.exists()) {
            deleteFile.delete();
            log.info("파일을 삭제하였습니다.");
        } else {
            log.info("파일이 존재하지 않습니다.");
        }

    }

 

File 클래스 : java.io.File 클래스는 파일 및 디렉토리에 대한 경로를 나타내며, 파일 시스템에서 파일이나 디렉토리에 접근하고 조작하는 기능을 제공

 

 

ItemImgRepository 구현


public interface ItemImgRepository extends JpaRepository<ItemImg, Long> {

}

 

 

 

ItemImgService, ItemImgServiceImpl 구현


public interface ItemImgService {

    void saveItemImg(ItemImg itemImg, MultipartFile multipartFile)
            throws Exception;
}
@Service
@Transactional
@RequiredArgsConstructor
public class ItemImgServiceImpl implements ItemImgService {

    @Value("${itemImgLocation}")
    private String itemImgLocation;

    private final ItemImgRepository itemImgRepository;

    private final FileService fileService;

    @Override
    public void saveItemImg(ItemImg itemImg, MultipartFile itemImgFile) throws Exception {

        String oriImgName = itemImgFile.getOriginalFilename();
        String imgName = "";
        String imgUrl = "";

        // 파일 업로드
        if(!StringUtils.isEmpty(oriImgName)) {
            imgName = fileService.uploadFile(itemImgLocation, oriImgName,
                    itemImgFile.getBytes());
            imgUrl = "/images/item/" + imgName;
        }

        // 상품 이미지 정보 저장
        itemImg.updateItemImg(oriImgName, imgName, imgUrl);
        itemImgRepository.save(itemImg);

    }
}

 

 

더보기

MultipartFile 

 

Spring Framework에서 파일 업로드를 처리하기 위해 사용되는 인터페이스. 주로 웹 애플리케이션에서 클라이언트가 서버로 파일을 전송할 때 사용. 이 인터페이스는 파일 업로드와 관련된 다양한 기능을 제공하며, 파일의 메타데이터와 내용을 쉽게 처리할 수 있게 도와줌.

 

주요 기능

 

  1. 파일 정보 접근

    • getName() : 파일 필드의 이름을 반환

    • getOriginalFilename() : 클라이언트가 업로드한 원본 파일 이름을 반환

    • getContentType() : 파일의 MIME 타입을 반환 (예: image/png, text/plain 등)

 

  2. 파일 내용 처리

    • getBytes() : 파일의 내용을 바이트 배열로 반환

    • getInputStream() : 파일 내용을 InputStream 형태로 반환. 이를 통해 파일 내용을 직접 스트리밍하거나 처리할 수 있다.

 

  3. 파일 저장

    • transferTo(File dest): 파일을 지정된 경로에 저장. 이 메소드는 파일을 실제 파일 시스템에 저장하는 데 사용.

 

 

조건 확인 : oriImgName이 비어있지 않은 경우에만 파일 업로드를 수행

파일 업로드 : fileService.uploadFile() 메소드를 호출하여 파일을 서버에 업로드. 이 메소드의 결과로 저장된 파일 이름(imgName)을 반환받는다.

이미지 URL 생성 : 파일의 접근 경로를 기반으로 URL을 생성. 이 URL은 클라이언트가 이미지에 접근하는 데 사용.

상품 이미지 정보 업데이트 : ItemImg 객체의 updateItemImg 메소드를 호출하여 이미지의 원본 이름, 저장된 파일 이름, URL을 업데이트.

데이터베이스 저장 : itemImgRepository.save(itemImg)를 호출하여 ItemImg 객체를 데이터베이스에 저장.

 

 

 

ItemService, ItemServiceImpl 구현


public interface ItemService {

    Long saveItem(ItemFormDTO itemFormDTO, List<MultipartFile> itemImgFileList)
        throws Exception;
}
@Service
@Transactional
@RequiredArgsConstructor
public class ItemServiceImpl implements ItemService {

    private final ItemRepository itemRepository;

    private final ItemImgService itemImgService;

    private final ItemImgRepository itemImgRepository;


    @Override
    public Long saveItem(ItemFormDTO itemFormDTO, List<MultipartFile> itemImgFileList) throws Exception {

        //상품 등록
        Item item = itemFormDTO.createItem();
        itemRepository.save(item);

        //이미지 등록
        for(int i=0; i<itemImgFileList.size(); i++){
            ItemImg itemImg = new ItemImg();
            itemImg.setItem(item);

            if(i == 0)
                itemImg.setRepImgYn("Y");
            else
                itemImg.setRepImgYn("N");

            itemImgService.saveItemImg(itemImg, itemImgFileList.get(i));
        }

        return item.getId();

    }
}

 

          • itemRepository.save(item)를 호출하여 Item 객체를 데이터베이스에 저장
          • itemImgFileList에 있는 모든 이미지 파일에 대해 반복.
          • ItemImg객체 생성 후itemImg.setItem(item)을 호출하여 현재 Item 객체와 ItemImg 객체를 연결
          • 첫 번째 이미지 파일 (i == 0)은 대표 이미지로 설정 (itemImg.setRepImgYn("Y"))
          • itemImgService.saveItemImg(itemImg, itemImgFileList.get(i))를 호출하여 ItemImg 객체와 이미지 파일을 저장
          • 저장된 Item 객체의 ID를 반환
List<MultipartFile> itemImgFileList클라이언트에서 전송된 파일들로 구성된 리스트로, MultipartFile 객체는 Spring MVC가 파일 업로드 요청을 처리할 때 자동으로 생성. 이 리스트는 컨트롤러 메소드에서 @RequestParam을 사용하여 수신되며, 이후 서비스 계층에서 파일을 저장하거나 처리하는 데 사용.

 

 

 

ItemController 추가 코드


    private final ItemService itemService;
    
    @PostMapping("/admin/item/new")
    public String itemNew(@Valid ItemFormDTO itemFormDTO, BindingResult bindingResult,
                          Model model, @RequestParam("itemImgFile") List<MultipartFile> itemImgFileList){

        if(bindingResult.hasErrors()){
            return "item/itemForm";
        }

        if(itemImgFileList.get(0).isEmpty() && itemFormDTO.getId() == null){
            model.addAttribute("errorMessage", "첫번째 상품 이미지는 필수 입력 값 입니다.");
            return "item/itemForm";
        }

        try {
            itemService.saveItem(itemFormDTO, itemImgFileList);
        } catch (Exception e){
            model.addAttribute("errorMessage", "상품 등록 중 에러가 발생하였습니다.");
            return "item/itemForm";
        }

        return "redirect:/";
    }

 

 

 

테스트코드 작성


  • ItemImgRepository 인터페이스에 findByItemOrderByIdAsc 메소드 추가
public interface ItemImgRepository extends JpaRepository<ItemImg, Long> {

    List<ItemImg> findByItemIdOrderByIdAsc(Long itemId);
}
@SpringBootTest
@Transactional
@TestPropertySource(locations = "classpath:application-test.properties")
class ItemServiceImplTest {

    @Autowired
    ItemService itemService;

    @Autowired
    ItemRepository itemRepository;

    @Autowired
    ItemImgRepository itemImgRepository;

    List<MultipartFile> createMultipartFiles() throws Exception {

        List<MultipartFile> multipartFileList = new ArrayList<>();

        for (int i=0; i<5; i++) {

            String path = "/Users/Desktop/e-commerce project/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 = ItemFormDTO.builder()
                .itemNm("테스트상품")
                .itemSellStatus(ItemSellStatus.SELL)
                .itemDetail("테스트 상품입니다.")
                .price(1000)
                .stockNum(100)
                .build();

        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.getStockNum(), item.getStockNum());
        assertEquals(multipartFileList.get(0).getOriginalFilename(),
                itemImgList.get(0).getOriImgName());

    }

}

 

  • MockMultipartFile : Spring의 MockMultipartFile 클래스는 테스트 중 파일 업로드를 시뮬레이션하는 데 사용된다. 실제 파일이 아닌 가상의 파일을 생성할 수 있음. path, imageName, contentType, content를 매개변수로 받아 파일의 메타데이터와 내용을 설정함.

https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/mock/web/MockMultipartFile.html

 

MockMultipartFile (Spring Framework 6.1.11 API)

getContentType Return the content type of the file. Specified by: getContentType in interface MultipartFile Returns: the content type, or null if not defined (or no file has been chosen in the multipart form)

docs.spring.io

 

 

 

 

 

 

내용 참고 : 책 "스프링 부트 쇼핑몰 프로젝트 with JPA"

+ Recent posts