# Upload파일 설정
# 파일 하나의 최대사이즈 10MB (-1 이면 무제한)
spring.servlet.multipart.file-size-threshold = 10MB
# 여러 파일 업로드 시 전체 합 30MB (-1 이면 무제한)
spring.servlet.multipart.max-request-size = 30MB
package com.myapp.shoppingmall.entities;
import java.time.LocalDateTime;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.Table;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.Pattern;
import javax.validation.constraints.Size;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;
import lombok.Data;
//실제 Table과 매칭
@Entity
@Table(name = "products")
@Data // Get, Set, Construct, toSring 생성됨
public class Product {
	@Id
	@GeneratedValue(strategy = GenerationType.IDENTITY)
	private int id;
	
	@NotBlank(message = "상품명을 입력해주세요.")
	@Size(min = 2, message = "최소 2글자 이상 입력해주세요.")
	private String name;
	
	private String slug;
	
	@Size(min = 5, message = "최소 5자 이상 입력해주세요.")
	private String description;	// 상품 설명
	private String image;	// 상품 이미지 파일 이름
	
	@Pattern(regexp = "^[1-9][0-9]*") // 맨앞자리 1~9만 사용가능, 나머지는 0~9 사용가능 숫자만 1 ~ 99999999 이런 식으로 표현 가능
	private String price; // 문자열로 하고 int로 변환해서 사용
	
	@Pattern(regexp = "^[1-9][0-9]*", message = "카테고리를 선택해주세요") // 생성 후 Update는 안됨
	@Column(name = "category_id", updatable = false)
	private String categoryId;	// 상품의 카테고리 id
	
	@Column(name = "created_at")
	@CreationTimestamp			// Insert 시 자동으로 날짜 입력
	private LocalDateTime createdAt;	// 상품 등록 날짜
	
	@Column(name = "updated_at")
	@UpdateTimestamp			// Update 시 자동으로 날짜 입력
	private LocalDateTime updatedAt;	// 상품 수정 날짜
	
}
https://velog.io/@koo8624/Spring-CreationTimestamp-UpdateTimestamp
	@GetMapping
	public String index(Model model) {
		List<Product> products = productRepo.findAll();
		List<Category> categories = categoryRepo.findAll();
		// Category id와 name을 map에 담아 index페이지에 전송
		HashMap<Integer, String> cateIdAndName = new HashMap<>();
		
		for (Category category : categories) {
			cateIdAndName.put(category.getId(), category.getName());
		}
		model.addAttribute("products", products);
		model.addAttribute("cateIdAndName", cateIdAndName);
		
		return "/admin/products/index";
	}
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
  <head th:replace="/fragments/head :: head-admin"></head>
  <body>
    <nav th:replace="/fragments/nav :: nav-admin"></nav>
    <main role="main" class="container">
      <div class="display-2">Products</div>
      <a th:href="@{/admin/products/add}" class="btn btn-primary my-3">추가하기</a>
      <div th:if="${message}" th:object="${message}" th:text="${message}" th:class="'alert ' + ${alertClass}"></div>
      <div th:if="${!products.empty}">
        <table class="table sorting" id="products">
          <tr>
            <th>상품명</th>
            <th>이미지</th>
            <th>카테고리</th>
            <th>가 격</th>
            <th>수 정</th>
            <th>삭 제</th>
          </tr>
          <tr th:each="product : ${products}">
            <td th:text="${product.name}"></td>
            <td>
              <!-- 이미지 파일이름이 한글이면 이미지 파일을 찾지를 못 함, 영어로 해야함-->
              <img th:src="@{'/media/' + ${product.image}}" style="height: 2em" />
            </td>
            <!-- Thymeleaf문법에서 ${}안에 한번 더 ${} 사용하려면 중복사용하는 곳 앞,뒤에 __(언더바) 2번 앞에다가 입력해줘야함-->
            <td th:text="${cateIdAndName[__${product.categoryId}__]}"></td>
            <td th:text="${product.price} + '원'"></td>
            <td><a th:href="@{'/admin/products/edit/' + ${product.id}}">수정</a></td>
            <td><a th:href="@{'/admin/products/delete/' + ${product.id}}" class="deleteConfirm">삭제</a></td>
          </tr>
        </table>
      </div>
      <div th:if="${products.empty}">
        <div class="display-4">현재 페이지가 없습니다.</div>
      </div>
    </main>
    <footer th:replace="/fragments/footer :: footer"></footer>
  </body>
</html>
	@GetMapping("/add")
	public String add(@ModelAttribute Product product, Model model) {
		List<Category> categories = categoryRepo.findAll();
		model.addAttribute("categories", categories);
//		model.addAttribute("product", new Product()); // @ModelAttribute Product product 동일함
		// 상품을 추가하는 add 페이지에 product 객체와 product의 category를 선택할 수 있게 리스트 전달
		return "/admin/products/add";
	}
	@PostMapping("/add")
	public String add(@Valid Product product, BindingResult bindingResult, MultipartFile file, RedirectAttributes attr,
			Model model) throws IOException { // 유효성검사 실패시 bindingResult로 데이터 넘어옴, 파일은 따로 받아서 정리
		if (bindingResult.hasErrors()) {
			List<Category> categories = categoryRepo.findAll();
			model.addAttribute("categories", categories);
			return "/admin/products/add";
		}
		boolean fileOk = false;
		byte[] bytes = file.getBytes(); // Upload된 이미지 파일의 데이터
		String fileName = file.getOriginalFilename(); // Upload된 파일의 이름
		Path path = Paths.get("src/main/resources/static/media/" + fileName); // 파일을 저장할 위치와 이름까지
		if (fileName.endsWith("jpg") || fileName.endsWith("png")) {
			fileOk = true; // 확장자가 .jpg, .png 만 등록 가능
		}
		// 성공적으로 추가됨
		attr.addFlashAttribute("message", "상품이 성공적으로 등록되었습니다.");
		attr.addFlashAttribute("alertClass", "alert-success");
		// slug 만들기
		String slug = product.getName().toLowerCase().replace(" ", "-");
		// 똑같은 상품명이 있는지 확인
		Product prductExists = productRepo.findByName(product.getName());
		if (!fileOk) { // 파일 Upload가 안됬거나 확장자가 jpg, png가 아님
			attr.addFlashAttribute("message", "이미지는 jpg나 png 확장자를 사용해주세요");
			attr.addFlashAttribute("alertClass", "alert-danger");
			attr.addFlashAttribute("product", product);
		} else if (prductExists != null) {
			attr.addFlashAttribute("message", "이미 등록된 상품입니다. 상품명을 변경해주세요.");
			attr.addFlashAttribute("alertClass", "alert-danger");
			attr.addFlashAttribute("product", product);
		} else { // product와 image를 저장한다.
			product.setSlug(slug);
			product.setImage(fileName); // 이미지는 파일의 이름만 입력(주소는 /media/폴더 이므로 동일)
			productRepo.save(product); // product 저장
			Files.write(path, bytes); // (이미지파일이 저장될 이미지파일 이름이 포함된 주소, 파일데이터)
		}
		return "redirect:/admin/products/add";
	}
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
  <head th:replace="/fragments/head :: head-admin"></head>
  <body>
    <nav th:replace="/fragments/nav :: nav-admin"></nav>
    <main role="main" class="container">
      <div class="display-2">상품 등록</div>
      <a th:href="@{/admin/products}" class="btn btn-primary my-3">돌아가기</a>
      <!-- enctype="multipart/form-data" 파일이나 이미지전송에 필요-->
      <form method="post" enctype="multipart/form-data" th:object="${product}" th:action="@{/admin/products/add}">
        <div th:if="${#fields.hasErrors('*')}" class="alert alert-danger">에러 발생</div>
        <div th:if="${message}" th:class="${'alert ' + alertClass}" th:text="${message}"></div>
        <div class="form-group">
          <label for="">상품명</label>
          <input type="text" class="form-control" th:field="*{name}" placeholder="상품명" />
          <span class="error" th:if="${#fields.hasErrors('name')}" th:errors="*{name}"></span>
        </div>
        <div class="form-group">
          <label for="">상품설명</label>
          <input type="text" class="form-control" th:field="*{description}" placeholder="상품설명" />
          <span class="error" th:if="${#fields.hasErrors('description')}" th:errors="*{description}"></span>
        </div>
        <label for="">이미지</label>
        <div class="form-group">
          <img src="#" id="imgPreview" />
          <input class="form-control" type="file" th:id="file" th:name="file" />
        </div>
        <div class="form-group">
          <label for="">가 격</label>
          <input type="text" class="form-control" th:field="*{price}" placeholder="가격(원)" />
          <span class="error" th:if="${#fields.hasErrors('price')}" th:errors="*{price}"></span>
        </div>
        <div class="form-group">
          <label for="">카테고리</label>
          <select th:field="*{categoryId}" class="form-control">
            <option value="0">카테고리 선택</option>
            <option th:each="category : ${categories}" th:value="${category.id}" th:text="${category.name}"></option>
          </select>
          <span class="error" th:if="${#fields.hasErrors('categoryId')}" th:errors="*{categoryId}"></span>
        </div>
        <button type="submit" class="btn btn-danger">추 가</button>
      </form>
    </main>
    <footer th:replace="/fragments/footer :: footer"></footer>
    <script>
      $(function () {
        $('#imgPreview').hide(); // 처음엔 숨김
        $('#file').change(function () {
          // 파일이 변경되었을 때(새 등록 or 파일교체)
          readURL(this); // readURL 함수 실행
        });
      });
      function readURL(input) {
        // 파일(이미지) 있을 경우에 실행
        if (input.files && input.files[0]) {
          let reader = new FileReader(); // 파일리더 객체 생성
          reader.readAsDataURL(input.files[0]); // 파일리더로 첫번째 파일 경로 읽기
          // reader가 주소를 다읽으면 onload 이벤트가 발생하고 이 때 화면에 사진을 표시한다.
          reader.onload = function (e) {
            $('#imgPreview').attr('src', e.target.result).width(50).height(50).show();
          };
        }
      }
    </script>
  </body>
</html>

	@GetMapping("/edit/{id}")
	public String edit(@PathVariable("id") int id, Model model) {
		Product product = productRepo.getById(id);
		
		List<Category> categories = categoryRepo.findAll();
		
		model.addAttribute("product",product);
		model.addAttribute("categories",categories);
		
		return "admin/products/edit";
	}
@PostMapping("/edit")
	public String edit(@Valid Product product, BindingResult bindingResult, 
						MultipartFile file, RedirectAttributes attr, Model model) throws IOException { // 유효성검사 실패시 bindingResult로 데이터 넘어옴, 파일은 따로 받아서 정리
		// 미리 id로 수정하기전의 상품 정보를 불러옴
		Product currentProduct = productRepo.getById(product.getId());
		
		if (bindingResult.hasErrors()) {
			List<Category> categories = categoryRepo.findAll();
			model.addAttribute("categories", categories);
			product.setImage(currentProduct.getImage());
			return "/admin/products/edit";
		}
		boolean fileOk = false;
		byte[] bytes = file.getBytes(); // Upload된 이미지 파일의 데이터
		String fileName = file.getOriginalFilename(); // Upload된 파일의 이름
		Path path = Paths.get("src/main/resources/static/media/" + fileName); // 파일을 저장할 위치와 이름까지
		if(!file.isEmpty()) { // 이미지 파일이 존재하면
			if (fileName.endsWith("jpg") || fileName.endsWith("png")) {
				fileOk = true; // 확장자가 .jpg, .png 만 등록 가능
			}
		} else { // 이미지는 수정 안함
			fileOk = true; // 기존 이미지 사용함
		}
		
		// 성공적으로 수정됨
		attr.addFlashAttribute("message", "상품이 성공적으로 수정되었습니다.");
		attr.addFlashAttribute("alertClass", "alert-success");
		String slug = product.getName().toLowerCase().replace(" ", "-");
		// 이름으로 찾고 현재 product의 id 값을 제외한 데이터만 검색
		Product prductExists = productRepo.findByNameAndIdNot(product.getName(), product.getId());
		if (!fileOk) {
			attr.addFlashAttribute("message", "이미지는 jpg나 png 확장자를 사용해주세요");
			attr.addFlashAttribute("alertClass", "alert-danger");
			attr.addFlashAttribute("product", product);
		} else if (prductExists != null) {
			attr.addFlashAttribute("message", "이미 등록된 상품입니다. 상품명을 변경해주세요.");
			attr.addFlashAttribute("alertClass", "alert-danger");
			attr.addFlashAttribute("product", product);
		} else { // product와 image를 저장한다.
			product.setSlug(slug);
			
			if(!file.isEmpty()) {
				Path currentpath = Paths.get("src/main/resources/static/media/" + currentProduct.getImage());
				Files.delete(currentpath); // 새로운 이미지파일이 있기 때문에 기존 파일을 삭제
				product.setImage(fileName);
				Files.write(path, bytes); // 새 이미지 파일 저장
			} else {
				product.setImage(currentProduct.getImage());
			}
			productRepo.save(product);
		}
		return "redirect:/admin/products/edit/" + product.getId();
	}
	@GetMapping("delete/{id}")
	public String delete(@PathVariable("id") int id, RedirectAttributes attr) throws IOException {
		// id로 상품을 삭제하기 전에 먼저 id로 제품객체를 불러와서 이미지 파일을 삭제한 후 제품 삭제
		Product currentProduct = productRepo.getById(id);
		
		Path currentPath = Paths.get("src/main/resources/static/media/" + currentProduct.getImage());
		
		Files.delete(currentPath);
		
		productRepo.deleteById(id);
		
		attr.addAttribute("message", "성공적으로 삭제되었습니다.");
		attr.addAttribute("alertClass", "alert-success");
		
		return "redirect:/admin/products";
	}