diff --git a/.idea/compiler.xml b/.idea/compiler.xml index a6fb3410..5deb4823 100644 --- a/.idea/compiler.xml +++ b/.idea/compiler.xml @@ -18,10 +18,20 @@ + + + + + + + + + diff --git a/.idea/misc.xml b/.idea/misc.xml index b83499c7..f24c79d1 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -8,5 +8,5 @@ - + \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 81b345e5..0c26089c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -39,7 +39,7 @@ services: # 호스트포트:컨테이너포트 # 호스트(개발 PC)에서 MySQL Workbench 등으로 직접 접속 가능 # 호스트 3306 포트를 컨테이너 3306 포트로 직접 연결 - - "3306:3306" + - "3307:3306" volumes: # 1. 데이터 영속성: 프로젝트 폴더 안의 mysql-data/ 디렉터리에 DB 파일이 저장됩니다. diff --git a/src/main/java/kr/ac/hansung/cse/config/DbConfig.java b/src/main/java/kr/ac/hansung/cse/config/DbConfig.java index 857700b8..36d7b45c 100644 --- a/src/main/java/kr/ac/hansung/cse/config/DbConfig.java +++ b/src/main/java/kr/ac/hansung/cse/config/DbConfig.java @@ -39,7 +39,7 @@ public class DbConfig { public DataSource dataSource() { DriverManagerDataSource ds = new DriverManagerDataSource(); ds.setDriverClassName("com.mysql.cj.jdbc.Driver"); - ds.setUrl("jdbc:mysql://localhost:3306/productdb" + + ds.setUrl("jdbc:mysql://mysql:3306/productdb" + "?useSSL=false" + "&allowPublicKeyRetrieval=true" + "&serverTimezone=Asia/Seoul" + diff --git a/src/main/java/kr/ac/hansung/cse/controller/CategoryController.java b/src/main/java/kr/ac/hansung/cse/controller/CategoryController.java new file mode 100644 index 00000000..721341df --- /dev/null +++ b/src/main/java/kr/ac/hansung/cse/controller/CategoryController.java @@ -0,0 +1,70 @@ +package kr.ac.hansung.cse.controller; + +import jakarta.validation.Valid; +import kr.ac.hansung.cse.exception.DuplicateCategoryException; +import kr.ac.hansung.cse.model.CategoryForm; +import kr.ac.hansung.cse.service.CategoryService; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.validation.BindingResult; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.servlet.mvc.support.RedirectAttributes; + +@Controller +@RequestMapping("/categories") +public class CategoryController { + + private final CategoryService categoryService; + + public CategoryController(CategoryService categoryService) { + this.categoryService = categoryService; + } + + @GetMapping + public String listCategories(Model model) { + model.addAttribute("categories", categoryService.getAllCategories()); + return "categoryList"; + } + + @GetMapping("/create") + public String showCreateForm(Model model) { + model.addAttribute("categoryForm", new CategoryForm()); + return "categoryForm"; + } + + @PostMapping("/create") + public String createCategory( + @Valid @ModelAttribute("categoryForm") CategoryForm categoryForm, + BindingResult bindingResult, + RedirectAttributes redirectAttributes) { + + if (bindingResult.hasErrors()) { + return "categoryForm"; + } + + try { + categoryService.createCategory(categoryForm.getName()); + redirectAttributes.addFlashAttribute("successMessage", "등록 완료"); + } catch (DuplicateCategoryException e) { + bindingResult.rejectValue("name", "duplicate", e.getMessage()); + return "categoryForm"; + } + + return "redirect:/categories"; + } + + @PostMapping("/{id}/delete") + public String deleteCategory( + @PathVariable Long id, + RedirectAttributes redirectAttributes) { + + try { + categoryService.deleteCategory(id); + redirectAttributes.addFlashAttribute("successMessage", "삭제 완료"); + } catch (IllegalStateException e) { + redirectAttributes.addFlashAttribute("errorMessage", e.getMessage()); + } + + return "redirect:/categories"; + } +} \ No newline at end of file diff --git a/src/main/java/kr/ac/hansung/cse/controller/ProductController.java b/src/main/java/kr/ac/hansung/cse/controller/ProductController.java index fdf7d790..af8c2054 100644 --- a/src/main/java/kr/ac/hansung/cse/controller/ProductController.java +++ b/src/main/java/kr/ac/hansung/cse/controller/ProductController.java @@ -5,6 +5,7 @@ import kr.ac.hansung.cse.model.Product; import kr.ac.hansung.cse.model.ProductForm; import kr.ac.hansung.cse.service.ProductService; +import kr.ac.hansung.cse.service.CategoryService; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.validation.BindingResult; @@ -37,20 +38,38 @@ public class ProductController { private final ProductService productService; - - public ProductController(ProductService productService) { + private final CategoryService categoryService; + public ProductController(ProductService productService, CategoryService categoryService) { this.productService = productService; + this.categoryService = categoryService; } - // ───────────────────────────────────────────────────────────────── // GET /products - 상품 목록 조회 // ───────────────────────────────────────────────────────────────── - + // 기존 listProducts() 메서드에 @RequestParam 두 개를 추가 @GetMapping - public String listProducts(Model model) { - List products = productService.getAllProducts(); + public String listProducts( + @RequestParam(required = false) String keyword, + @RequestParam(required = false) Long categoryId, + Model model) { + + List products; + + if (keyword != null && !keyword.isBlank()) { + products = productService.searchByName(keyword); + } else if (categoryId != null) { + products = productService.searchByCategory(categoryId); + } else { + products = productService.getAllProducts(); + } + + // 카테고리 드롭다운 목록 + 현재 검색 조건 유지 model.addAttribute("products", products); + model.addAttribute("categories", categoryService.getAllCategories()); + model.addAttribute("keyword", keyword); + model.addAttribute("categoryId", categoryId); + return "productList"; } diff --git a/src/main/java/kr/ac/hansung/cse/exception/DuplicateCategoryException.java b/src/main/java/kr/ac/hansung/cse/exception/DuplicateCategoryException.java new file mode 100644 index 00000000..15324530 --- /dev/null +++ b/src/main/java/kr/ac/hansung/cse/exception/DuplicateCategoryException.java @@ -0,0 +1,8 @@ +package kr.ac.hansung.cse.exception; + +public class DuplicateCategoryException extends RuntimeException { + + public DuplicateCategoryException(String name) { + super("이미 존재하는 카테고리입니다: " + name); + } +} \ No newline at end of file diff --git a/src/main/java/kr/ac/hansung/cse/model/CategoryForm.java b/src/main/java/kr/ac/hansung/cse/model/CategoryForm.java new file mode 100644 index 00000000..6bd064ec --- /dev/null +++ b/src/main/java/kr/ac/hansung/cse/model/CategoryForm.java @@ -0,0 +1,15 @@ +package kr.ac.hansung.cse.model; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class CategoryForm { + + @NotBlank(message = "카테고리 이름을 입력하세요") + @Size(max = 50, message = "50자 이내로 입력하세요") + private String name; +} \ No newline at end of file diff --git a/src/main/java/kr/ac/hansung/cse/repository/CategoryRepository.java b/src/main/java/kr/ac/hansung/cse/repository/CategoryRepository.java index 13b24c25..7faad407 100644 --- a/src/main/java/kr/ac/hansung/cse/repository/CategoryRepository.java +++ b/src/main/java/kr/ac/hansung/cse/repository/CategoryRepository.java @@ -28,16 +28,16 @@ public List findAll() { .getResultList(); } - // 이름으로 카테고리 조회 (폼에서 선택한 카테고리명 → Category 엔티티 변환 시 사용) public Optional findByName(String name) { List result = em.createQuery( - "SELECT c FROM Category c WHERE c.name = :name", Category.class) + "SELECT c FROM Category c WHERE lower(trim(c.name)) = lower(trim(:name))", + Category.class) .setParameter("name", name) .getResultList(); + return result.isEmpty() ? Optional.empty() : Optional.of(result.get(0)); } - // JOIN FETCH: N+1 문제 방지 (Category + Products 한 번에 로드) public Optional findByIdWithProducts(Long id) { List result = em.createQuery( "SELECT DISTINCT c FROM Category c JOIN FETCH c.products WHERE c.id = :id", @@ -46,5 +46,19 @@ public Optional findByIdWithProducts(Long id) { .getResultList(); return result.isEmpty() ? Optional.empty() : Optional.of(result.get(0)); } -} + public long countProductsByCategoryId(Long categoryId) { + return em.createQuery( + "SELECT COUNT(p) FROM Product p WHERE p.category.id = :id", + Long.class) + .setParameter("id", categoryId) + .getSingleResult(); + } + + public void delete(Long id) { + Category category = em.find(Category.class, id); + if (category != null) { + em.remove(category); + } + } +} \ No newline at end of file diff --git a/src/main/java/kr/ac/hansung/cse/repository/ProductRepository.java b/src/main/java/kr/ac/hansung/cse/repository/ProductRepository.java index 791e3217..25f0aa70 100644 --- a/src/main/java/kr/ac/hansung/cse/repository/ProductRepository.java +++ b/src/main/java/kr/ac/hansung/cse/repository/ProductRepository.java @@ -40,6 +40,8 @@ */ @Repository public class ProductRepository { + @PersistenceContext + private EntityManager entityManager; /** * @PersistenceContext : Spring이 EntityManager를 주입해 주는 어노테이션입니다. @@ -50,8 +52,9 @@ public class ProductRepository { * → 실제 EntityManager는 현재 트랜잭션에 바인딩된 것을 사용합니다. * → 덕분에 멀티스레드 환경에서도 안전하게 사용 가능합니다. */ + @PersistenceContext - private EntityManager entityManager; + private EntityManager em; /** * 모든 상품 목록 조회 @@ -131,4 +134,23 @@ public void delete(Long id) { entityManager.remove(product); } } + + // 이름 검색: JPQL의 LIKE로 키워드 포함 여부 검사 + public List findByNameContaining(String keyword) { + return em.createQuery( + "SELECT p FROM Product p LEFT JOIN FETCH p.category WHERE p.name LIKE :keyword", + Product.class) + // "%" + keyword + "%" → 부분 일치 검색 (앞뒤에 % 붙이는 것이 핵심!) + .setParameter("keyword", "%" + keyword + "%") + .getResultList(); + } + + // 카테고리 필터: Product의 category.id 로 조회 (p.category.id = JPQL 경로 표현식) + public List findByCategoryId(Long categoryId) { + return em.createQuery( + "SELECT p FROM Product p LEFT JOIN FETCH p.category WHERE p.category.id = :cid", + Product.class) + .setParameter("cid", categoryId) + .getResultList(); + } } diff --git a/src/main/java/kr/ac/hansung/cse/service/CategoryService.java b/src/main/java/kr/ac/hansung/cse/service/CategoryService.java new file mode 100644 index 00000000..bdd124b6 --- /dev/null +++ b/src/main/java/kr/ac/hansung/cse/service/CategoryService.java @@ -0,0 +1,47 @@ +package kr.ac.hansung.cse.service; + +import kr.ac.hansung.cse.exception.DuplicateCategoryException; +import kr.ac.hansung.cse.model.Category; +import kr.ac.hansung.cse.repository.CategoryRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@Transactional(readOnly = true) +public class CategoryService { + + private final CategoryRepository categoryRepository; + + public CategoryService(CategoryRepository categoryRepository) { + this.categoryRepository = categoryRepository; + } + + public List getAllCategories() { + return categoryRepository.findAll(); + } + + @Transactional + public Category createCategory(String name) { + String normalizedName = name == null ? "" : name.trim(); + + categoryRepository.findByName(normalizedName) + .ifPresent(c -> { + throw new DuplicateCategoryException(normalizedName); + }); + + return categoryRepository.save(new Category(normalizedName)); + } + + @Transactional + public void deleteCategory(Long id) { + long count = categoryRepository.countProductsByCategoryId(id); + + if (count > 0) { + throw new IllegalStateException("상품 " + count + "개가 연결되어 있어 삭제할 수 없습니다."); + } + + categoryRepository.delete(id); + } +} \ No newline at end of file diff --git a/src/main/java/kr/ac/hansung/cse/service/ProductService.java b/src/main/java/kr/ac/hansung/cse/service/ProductService.java index 0be50653..5f3a724d 100644 --- a/src/main/java/kr/ac/hansung/cse/service/ProductService.java +++ b/src/main/java/kr/ac/hansung/cse/service/ProductService.java @@ -87,6 +87,15 @@ public Optional getProductById(Long id) { return productRepository.findById(id); } + // readOnly = true 상속: 검색은 읽기 전용 트랜잭션으로 충분 + public List searchByName(String keyword) { + return productRepository.findByNameContaining(keyword); + } + + public List searchByCategory(Long categoryId) { + return productRepository.findByCategoryId(categoryId); + } + /** * 새 상품 등록 * diff --git a/src/main/webapp/WEB-INF/views/categoryForm.html b/src/main/webapp/WEB-INF/views/categoryForm.html new file mode 100644 index 00000000..df12913b --- /dev/null +++ b/src/main/webapp/WEB-INF/views/categoryForm.html @@ -0,0 +1,32 @@ + + + + + 카테고리 등록 + + + + +

카테고리 등록

+ +
+ + + + + +
+ +
+

+ + + \ No newline at end of file diff --git a/src/main/webapp/WEB-INF/views/categoryList.html b/src/main/webapp/WEB-INF/views/categoryList.html new file mode 100644 index 00000000..fabfd116 --- /dev/null +++ b/src/main/webapp/WEB-INF/views/categoryList.html @@ -0,0 +1,53 @@ + + + + + 카테고리 목록 + + + + +

카테고리 목록

+ +

+

+ ++ 새 카테고리 등록 + + + + + + + + + + + + + + + + +
ID카테고리명작업
1전자제품 +
+ +
+
+ +
+

+ + + \ No newline at end of file diff --git a/src/main/webapp/WEB-INF/views/productList.html b/src/main/webapp/WEB-INF/views/productList.html index 775e8b2b..57c7d848 100644 --- a/src/main/webapp/WEB-INF/views/productList.html +++ b/src/main/webapp/WEB-INF/views/productList.html @@ -13,7 +13,7 @@ 상품 목록 - Product Management - - - -

상품 상세 정보

- - ← 목록으로 돌아가기 - - -
- -
- 상품 ID - - #1 - -
- -
- 상품명 - 상품명 -
- -
- 카테고리 - 카테고리 -
- -
- 가격 - - 15,000원 - -
- -
- 상품 설명 - - 상품 설명이 여기에 표시됩니다. -
- -
- - -
- - 수정 - - -
- -
-
- - - diff --git a/target/ROOT/WEB-INF/views/productList.html b/target/ROOT/WEB-INF/views/productList.html index b15104a2..57c7d848 100644 --- a/target/ROOT/WEB-INF/views/productList.html +++ b/target/ROOT/WEB-INF/views/productList.html @@ -13,7 +13,7 @@ 상품 목록 - Product Management