스프링수업 10일차

한 일

  • category sorting
  • 상품페이지 만들기
  • 상품추가
  • 상품 수정/삭제

category sorting

- AdminCategoryController -

@GetMapping
private String index(Model model) {
	List<Category> categories = categoryRepo.findAllByOrderBySortingAsc(); 
	model.addAttribute("categories", categories);
	return "admin/categories/index";
}
	...
@PostMapping("/reorder")
public @ResponseBody String reorder(@RequestParam("id[]") int[] id) {
    
    int count = 1;
    Category category;

    for (int categoryId : id) {
    	category = categoryRepo.getById(categoryId);	// DB에서 id로 category객체 검색
    	category.setSorting(count);				// setSorting에 count값을 넣어줌
    	categoryRepo.save(category); 	//sorting 값을 순서대로 저장
        count++;
    }
    return "ok";	// view페이지가 아니라 문자열 ok로 리턴
}

- CategoryRepository.inferface -

List<Category> findAllByOrderBySortingAsc();

=> 이름이 명시되어있는 메서드이므로 메서드명의 변경될부분과 변경되면 안되는 부분을 유의할것.
처음에 List<Category> findAllOrderBySortingAsc();로 작성해서 오류발생함.



순서변경시 sorting에 저장되어 그대로 출력됨.



상품페이지 만들기

products테이블 생성

CREATE TABLE IF NOT EXISTS products (
	id int not null aUTO_INCREMENT,
    name VARCHAR(45) not null,
    slug VARCHAR(45) not null,
    description text not null,
    image VARCHAR(45) not null,
    price INT not null,
    category_id int not null,
    create_at timestamp not null,
    update_at timestamp not null,
	PRIMARY KEY (id)
);

text: 용량이 큰 문자열자료형
decimal(x,y): 소수점까지 나타내는 숫자형. 정수 x자리, 소수 y자리까지 표현
timestamp: 날짜, 시간까지 표현하는 자료형

MySQL 날짜타입 참고
java 날짜타입 참고


새 클래스 생성

- Product -

@Entity
@Table(name = "products")
@Data  //겟,셋 생성자, toString 생성됨
public class Product {
	@Id
	@GeneratedValue(strategy = GenerationType.IDENTITY)
	private int id;

	@NotBlank(message = "품명을 입력해 주세요.")
	@Size(min = 2, message = "품명은 2자 이상")
	private String name;
	
	private String slug;	
	private String description;	// 상품설명
	private String image;		// 상품이미지 주소
	private String price;		// 문자열로 지정 후 변환해서 사용
	
	@Column(name = "category_id")	// DB의 테이블명과 다를경우 @column을 이용해 매핑
	private String categoryId;		// 상품의 카테고리ID
	
	@Column(name = "create_at")
	@CreationTimestamp				// insert시 자동으로 시각이 입력됨
	private LocalDateTime createAt;	// 상품 등록 시간
	
	@Column(name = "update_at")		// update시 자동으로 시각이 입력됨
	private LocalDateTime updateAt;	// 상품 업데이트 시간
}


새 인터페이스 생성

- ProductRepository -

public interface ProductRepository extends JpaRepository<Product, Integer>{	
}


새 클래스 생성

- AdminProductController -

@Controller
@RequestMapping("/admin/products")
public class AdminProductController {
	@Autowired
	private ProductRepository productRepo;
	
	@GetMapping
	public String index(Model model) {
		List<Product> products = productRepo.findAll();
		model.addAttribute("products", products);
		
		return "admin/products/index";
	}
}

인덱스 페이지 생성

- index.html -

<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:class="${'alert ' + alertClass}" th:text="${message}"></div>

  <div th:if="${!products.empty}">
    <table class="table" 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 th:text="${product.image}"></td>
        <td th:text="${product.categoryId}"></td>
        <td th:text="${product.price}"></td>
        <td><a th:href="@{'/admin/products/edit/' + ${product.id}}">수정</a></td>
        <td><a class="deleteConfirm" th:href="@{'/admin/products/delete/' + ${product.id}}">삭제</a></td>
      </tr>
    </table>
  </div>
  <div th:if="${products.empty}">
    <div class="display-4">등록된 상품이 없습니다...</div>
  </div>
</main>

http://localhost:8080/admin/products

상품페이지 정상출력 확인.



상품추가

상품추가 페이지

앞서 만든 admin categorycontroller를 가져와 수정.

- AdminProductController -

@Autowired
private CategoryRepository categoryRepo;
	...
@GetMapping("/add")
public String add(@ModelAttribute Product product, Model model) {
	List<Category> categories = categoryRepo.findAll();
	model.addAttribute("categories", categories);
	// 상품을 추가하는 add 페이지에 상품객체와 상품의 카테고리를 선택할수있도록 카테고리 리스트도 전달
	return "admin/products/add";
}

=> 상품을 추가할 때 카테고리정보도 필요하므로 CategoryRepository도 가져옴.

- add.html -

<div class="container">
  <div class="display-2">카테고리 추가</div>
  <a th:href="@{/admin/products}" class="btn btn-primary my-3">돌아가기</a>

  <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>
    <div class="form-group">
      <label for="file">이미지</label>
      <input type="file" class="form-control" th:id="file" placeholder="상품 이미지" />
      <!-- 미리 올릴 이미지를 표시 -->
      <img src="#" id="imgPreview" />
    </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:th:text="${category.name}"></option>
      </select>
      <span class="error" th:if="${#fields.hasErrors('categoryId')}" th:error="*{categoryId}"></span>
    </div>
    <button type="submit" class="btn btn-danger">추 가</button>
  </form>
</div>

<form enctype="multipart/form-data">: 서버전송시 데이터 인코딩, 변환을 명시할때 사용. 파일, 이미지 서버전송시 multipart/form-data 사용.

http://localhost:8080/admin/products/add


유효성 검사

- Product -

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private int id;

@NotBlank(message = "품명을 입력해 주세요.")
@Size(min = 2, message = "품명은 2자 이상")
private String name;

private String slug;	

@NotBlank(message = "상품설명을 입력해 주세요.")
@Size(min = 2, message = "설명은 2자 이상")
private String description;	// 상품설명
private String image;		// 상품이미지 주소

@Pattern(regexp = "^[1-9][0-9]*")	// 1 ~ 999999999 까지 사용가능
private String price;		// 문자열로 지정 후 변환해서 사용

@Pattern(regexp = "^[1-9][0-9]*", message = "카테고리를 선택해주세요")
@Column(name = "category_id")	// DB의 테이블명과 다를경우 @column을 이용해 매핑
private String categoryId;		// 상품의 카테고리ID

@Column(name = "create_at", updatable = false)
@CreationTimestamp				// insert시 자동으로 시각이 입력됨
private LocalDateTime createAt;	// 상품 등록 시간

@Column(name = "update_at")		
@UpdateTimestamp				// update시 자동으로 시각이 입력됨 
private LocalDateTime updateAt;	// 상품 업데이트 시간

@Pattern(regexp = "^[1-9][0-9]*"): 첫 문자는 1 ~ 9중 하나만 사용가능. 두번째 문자부터는 0 ~ 9까지의 문자 중 하나 사용가능. * 는 여러번 반복을 허용한다는 의미.
이외에는 모두 에러처리.
[자바 정규표현식://coding-factory.tistory.com/529)

@Column(name = "create_at", updatable = false): 등록했을때 최초 한번의 시각만 입력하도록 함. 즉, 시각은 업데이트x.

- add.html -
script태그 추가

<script>
  $(function () {
    $('#imgPreview').hide(); // 처음에 숨김
    $('#file').change(function () {
      // 파일 변경시(새로올림, 교체) 이벤트 발생
      readURL(this); // readURL 함수 실행
    });
  });

  function readURL(input) {
    // 파일(이미지)이 있을 경우 실행
    if (input.files && input.files[0]) {
      let reader = new FileReader(); // 파일리더 객체생성

      reader.readAsDataURL(input.files[0]); // 파일리더로 첫번째 파일경로 읽기

      // 파일리더가 주소를 다 읽으면 onload 이벤트가 발생하고 이때 화면에 img를 출력
      reader.onload = function (e) {
        $('imgPreview').attr('src', e.target.result).width(200).show();
      };
    }
  }
</script>


상품 추가하기

- AdminProductController -

@PostMapping("/add") 
public String add(@Valid Product product, BindingResult bindingResult, MultipartFile file,
										RedirectAttributes attr, Model model) throws IOException {
	if (bindingResult.hasErrors()) {
		List<Category> categories = categoryRepo.findAll();
		model.addAttribute("categories", categories);
		return "admin/products/add";	// 유효성검사 에러발생시 되돌아감
	}
	
	boolean fileOk = false;
	byte[] bytes = file.getBytes();	// 업로드된 img파일의 데이터
	String fileName = file.getOriginalFilename();	// 파일의 이름
	Path path = Paths.get("src/main/resources/static/media/" + fileName); // 파일을 저장할 위치와 이름까지
	
	if (fileName.endsWith("jpg") || fileName.endsWith("png")) {
		fileOk = true;	// 확장자가 jpg, png인 파일만 true
	}
	// 성공적으로 추가됨
	attr.addFlashAttribute("message", "상품이 성공적으로 추가됨!");
	attr.addFlashAttribute("alertClass", "alert-success");
	// 슬러그 만들기
	String slug = product.getName().toLowerCase().replace(" ", "-");
	// 동일한 상품명이 있는지 검사
	Product productExists = productRepo.findByName(product.getName());
	
	if(!fileOk) {	// 파일 업로드가 안됐거나 확장자가 jpg, png가 아닌경우
		attr.addFlashAttribute("message", "이미지는 jpg나 png파일을 사용해주세요!");
		attr.addFlashAttribute("alertClass", "alert-danger");
		attr.addFlashAttribute("product", product);
		
	} else if (productExists != null) {	// 동일한 상품명이 DB에 존재
		attr.addFlashAttribute("message", "이미 존재하는 상품명입니다.");
		attr.addFlashAttribute("alertClass", "alert-danger");
		attr.addFlashAttribute("product", product);
		
	} else {	// 상품과 이미지 파일을 저장함
		product.setSlug(slug);
		product.setImage(fileName);	// img는 파일의 이름만 입력(주소는 /media/폴더 이므로 동일)
		productRepo.save(product);
		
		Files.write(path, bytes);	// (저장주소, 데이터)
	}
	return "redirect:/admin/products/add";
}

매개변수 MultipartFile: 파일을 전달받으므로 필요
String fileName = file.getOriginalFilename();: 매개변수로 받아온 MultipartFile의 객체 file의 이름을 fileName에 저장
fileName.endsWith("jpg")||fileName.endsWith("png"): 파일의 확장자가 .jpg, .png인 파일만 통과

- ProductRepository -

Product findByName(String name);

여기서 상품의 이미지를 영문으로 준비해야 아래 이미지를 불러오는 작업에서 에러가 발생하지 않음.


저장된 이미지를 불러올 경로설정

- WebConfig -

@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
	// 저장된 이미지파일을 불러올 경로 지정
	registry.addResourceHandler("/media/**")
			.addResourceLocations("file:///C:/java502/SpringWorkspace/shoppingmall/src/main/resources/static/media/");
}

- application.properties -
서버부하를 막기위해 최대 파일크기, (이미지를 여러개 올릴 때) 총 파일 크기를 지정해줌.

# File upload setting
spring.servlet.multipart.max-file-size = 10MB
spring.servlet.multipart.max-request-size = 30MB

- index.html -

<table class="table" 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>
    <td th:text="${product.categoryId}"></td>
    <td th:text="${product.price}"></td>
    <td><a th:href="@{'/admin/products/edit/' + ${product.id}}">수정</a></td>
    <td><a class="deleteConfirm" th:href="@{'/admin/products/delete/' + ${product.id}}">삭제</a></td>
  </tr>
</table>

상품의 이미지를 상품전체 테이블에서 미리보기로 볼 수 있도록 img부분 수정.


상품전체 리스트의 카테고리 이름변경

DB에 외래키설정을 해두지 않았으므로 product의 id와 category를 map으로 담아 매칭시킴

- AdminProductController -
index메서드 수정

@GetMapping
public String index(Model model) {
	List<Product> products = productRepo.findAll();
	List<Category> categories = categoryRepo.findAll();
	
	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";
}

- index.html -

<table class="table" 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>
    <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 class="deleteConfirm" th:href="@{'/admin/products/delete/' + ${product.id}}">삭제</a></td>
  </tr>
</table>

${}안에 ${}가 중복으로 들어가야할 경우 내부의 ${}에 앞뒤로 __을 추가하여 ${__${}__} 의 형태로 사용한다.
=> category_id를 넣으면 그 id에 해당하는 카테고리 이름이 출력됨.

@ModelAttribute에 대한 개념정리 필요



상품 수정/삭제

상품수정

- AdminProductController -

@GetMapping("/edit/{id}")
public String edit(@PathVariable int id, Model model) {
	
	Product product = productRepo.getById(id);
	List<Category> categories = categoryRepo.findAll(); 
	
	model.addAttribute("categories", categories);
	model.addAttribute("product", product);
	return "admin/products/edit";
}

@PostMapping("/edit")
public String edit(@Valid Product product, BindingResult bindingResult, MultipartFile file,
		RedirectAttributes redirectAttributes, Model model) throws IOException {
    //우선 수정하기전의 상품의 객체를 DB에서 읽어오기 ( id 로 검색 )
	Product currentProduct = productRepo.getById(product.getId());

	if (bindingResult.hasErrors()) {
		List<Category> categories = categoryRepo.findAll();
		model.addAttribute("categories", categories);
        if(product.getImage() == null)	product.setImage(currentProduct.getImage());	// 저장된 이미지 불러오기
		return "/admin/products/edit";
	}

	boolean fileOk = false;
	byte[] bytes = file.getBytes(); // 업로드한 파일의 데이터
	String fileName = file.getOriginalFilename(); // 업로드한 파일의 이름
	Path path = Paths.get("src/main/resources/static/media/" + fileName); // 파일을 저장할 컨텍스트 안의 경로

	if (!file.isEmpty()) { // 새 이미지 파일이 있을경우
		if (fileName.endsWith("jpg") || fileName.endsWith("png")) { // 파일의 확장자 jpg , png
			fileOk = true;
		}
	} else { // 파일이 없을경우 ( 수정 이므로 이미지 파일이 없어도 OK )
		fileOk = true;
	}

	// 성공적으로 product 수정 되는 경우
	redirectAttributes.addFlashAttribute("message", "상품이 수정됨");
	redirectAttributes.addFlashAttribute("alertClass", "alert-success");

	String slug = product.getName().toLowerCase().replace(" ", "-");
                         //제품이름을 수정했을 경우에 slug가 다름 제품과 같지 않는지 검사
	Product productExists = productRepo.findBySlugAndIdNot(slug, product.getId());

	if (!fileOk) { // file 업로드 안되거나 확장자가 틀림

		redirectAttributes.addFlashAttribute("message", "이미지는 jpg나 png를 사용해 주세요");
		redirectAttributes.addFlashAttribute("alertClass", "alert-danger");
		redirectAttributes.addFlashAttribute("product", product);

	} else if (productExists != null) { // 이미 등록된 상품 있음

		redirectAttributes.addFlashAttribute("message", "상품이 이미 있습니다. 다른것을 고르세요");
		redirectAttributes.addFlashAttribute("alertClass", "alert-danger");
		redirectAttributes.addFlashAttribute("product", product);

	} else { // 상품과 이미지 파일을 저장한다.

		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);	//Files 클래스를 사용해 파일을 저장		
		} else {
			product.setImage(currentProduct.getImage());	
		}
		
		productRepo.save(product);
		
	}
	return "redirect:/admin/products/edit/" + product.getId();
}

- ProductRepository -

Product findBySlugAndIdNot(String slug, int id);

- edit.html -
나머지는 add.html과 동일

<div class="form-group">
  <label for="">이미지</label>
  <input type="file" class="form-control" th:id="file" th:name="file" />
  <img src="#" id="imgPreview" />
  <br /><br />
  <label for="">현재 이미지</label>
  <img th:src="@{'/media/'+${product.image}}" style="width: 200px" />
</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>
<input type="hidden" th:field="*{id}" th:value="${product.id}" />
<button class="btn btn-danger">수정 하기</button>


상품 삭제

- AdminProductController -

@GetMapping("/delete/{id}")
public String delete(@PathVariable int id, RedirectAttributes redirectAttributes) 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);		// 상품 삭제
	
	redirectAttributes.addFlashAttribute("message", "성공적으로 삭제 되었습니다.");
	redirectAttributes.addFlashAttribute("alertClass", "alert-success");
	
	return "redirect:/admin/products";
}

좋은 웹페이지 즐겨찾기