diff --git a/.gitignore b/.gitignore
index 98049e22..6260fa6e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,28 +1,39 @@
# =====================================================================
# Maven 빌드 산출물
# =====================================================================
-target/ # 컴파일 결과, WAR, 테스트 리포트 등 모든 빌드 산출물
-*.class # target 밖에 생성된 .class 파일 (안전망)
+# 컴파일 결과, WAR, 테스트 리포트 등 모든 빌드 산출물
+target/
+
+# target 밖에 생성된 .class 파일 (안전망)
+*.class
# =====================================================================
# Docker 데이터
# =====================================================================
-mysql-data/ # MySQL 컨테이너 데이터 볼륨 (로컬 바인드 마운트)
+# MySQL 컨테이너 데이터 볼륨 (로컬 바인드 마운트)
+mysql-data/
# =====================================================================
# Claude Code
# =====================================================================
-.claude/ # Claude Code 설정 및 메모리 파일
+# Claude Code 설정 및 메모리 파일
+.claude/
# =====================================================================
# IntelliJ IDEA
# =====================================================================
-.idea/ # IDE 설정 디렉터리 전체
-*.iml # 모듈 설정 파일
-*.iws # 워크스페이스 파일
+# IDE 설정 디렉터리 전체
+.idea/
+# 모듈 설정 파일
+*.iml
+# 워크스페이스 파일
+*.iws
# =====================================================================
# OS 생성 파일
# =====================================================================
-.DS_Store # macOS
-Thumbs.db # Windows
+# macOS
+.DS_Store
+
+# Windows
+Thumbs.db
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..0ef1e0e5 100644
--- a/.idea/misc.xml
+++ b/.idea/misc.xml
@@ -8,5 +8,5 @@
-
+
\ No newline at end of file
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..488e3688
--- /dev/null
+++ b/src/main/java/kr/ac/hansung/cse/controller/CategoryController.java
@@ -0,0 +1,68 @@
+package kr.ac.hansung.cse.controller;
+
+import kr.ac.hansung.cse.exception.DuplicateCategoryException;
+import kr.ac.hansung.cse.model.CategoryForm;
+import kr.ac.hansung.cse.service.CategoryService;
+import jakarta.validation.Valid;
+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;
+ }
+
+ // GET /categories → 목록 조회
+ @GetMapping
+ public String listCategories(Model model) {
+ model.addAttribute("categories", categoryService.getAllCategories());
+ return "categoryList";
+ }
+
+ // GET /categories/create → 등록 폼
+ @GetMapping("/create")
+ public String showCreateForm(Model model) {
+ model.addAttribute("categoryForm", new CategoryForm());
+ return "categoryForm";
+ }
+
+ // POST /categories/create → 등록 처리
+ @PostMapping("/create")
+ public String createCategory(
+ @Valid @ModelAttribute 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";
+ }
+
+ // POST /categories/{id}/delete → 삭제
+ @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..bf395d4e 100644
--- a/src/main/java/kr/ac/hansung/cse/controller/ProductController.java
+++ b/src/main/java/kr/ac/hansung/cse/controller/ProductController.java
@@ -4,6 +4,7 @@
import kr.ac.hansung.cse.exception.ProductNotFoundException;
import kr.ac.hansung.cse.model.Product;
import kr.ac.hansung.cse.model.ProductForm;
+import kr.ac.hansung.cse.service.CategoryService;
import kr.ac.hansung.cse.service.ProductService;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
@@ -37,9 +38,11 @@
public class ProductController {
private final ProductService productService;
+ private final CategoryService categoryService;
- public ProductController(ProductService productService) {
+ public ProductController(ProductService productService, CategoryService categoryService) {
this.productService = productService;
+ this.categoryService = categoryService;
}
@@ -48,9 +51,24 @@ public ProductController(ProductService productService) {
// ─────────────────────────────────────────────────────────────────
@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..71cf57f5
--- /dev/null
+++ b/src/main/java/kr/ac/hansung/cse/exception/DuplicateCategoryException.java
@@ -0,0 +1,7 @@
+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..00e2bc54
--- /dev/null
+++ b/src/main/java/kr/ac/hansung/cse/model/CategoryForm.java
@@ -0,0 +1,13 @@
+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..b01420e9 100644
--- a/src/main/java/kr/ac/hansung/cse/repository/CategoryRepository.java
+++ b/src/main/java/kr/ac/hansung/cse/repository/CategoryRepository.java
@@ -37,7 +37,7 @@ public Optional findByName(String name) {
return result.isEmpty() ? Optional.empty() : Optional.of(result.get(0));
}
- // JOIN FETCH: N+1 문제 방지 (Category + Products 한 번에 로드)
+ // 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,20 @@ 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 c = em.find(Category.class, id);
+ if (c != null) em.remove(c);
+ }
+
}
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..f518f59b 100644
--- a/src/main/java/kr/ac/hansung/cse/repository/ProductRepository.java
+++ b/src/main/java/kr/ac/hansung/cse/repository/ProductRepository.java
@@ -131,4 +131,21 @@ public void delete(Long id) {
entityManager.remove(product);
}
}
+
+ // 상품 키워드 검색
+ public List findByNameContaining(String keyword) {
+ return entityManager.createQuery(
+ "SELECT p FROM Product p LEFT JOIN FETCH p.category WHERE p.name LIKE :keyword", Product.class)
+ .setParameter("keyword", "%" + keyword + "%")
+ .getResultList();
+ }
+
+ // 카테고리 ID로 필터
+ public List findByCategoryId(Long categoryId) {
+ return entityManager.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..2204b7d6
--- /dev/null
+++ b/src/main/java/kr/ac/hansung/cse/service/CategoryService.java
@@ -0,0 +1,41 @@
+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) {
+ categoryRepository.findByName(name)
+ .ifPresent(c -> { throw new DuplicateCategoryException(name); });
+ return categoryRepository.save(new Category(name));
+ }
+
+ @Transactional
+ public void deleteCategory(Long id) {
+ long count = categoryRepository.countProductsByCategoryId(id);
+ if (count > 0) {
+ throw new IllegalStateException("상품 " + count + "개가 연결되어 있어 삭제할 수 없습니다.");
+ }
+ categoryRepository.delete(id);
+ }
+}
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..82489d75 100644
--- a/src/main/java/kr/ac/hansung/cse/service/ProductService.java
+++ b/src/main/java/kr/ac/hansung/cse/service/ProductService.java
@@ -79,6 +79,16 @@ public List getAllProducts() {
return productRepository.findAll();
}
+
+ public List searchByName(String keyword) {
+ return productRepository.findByNameContaining(keyword);
+ }
+
+
+ public List searchByCategory(Long categoryId) {
+ return productRepository.findByCategoryId(categoryId);
+ }
+
/**
* ID로 상품 조회
* Optional을 그대로 반환하여 Controller가 null 처리를 명시적으로 하도록 강제합니다.
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..8b780015
--- /dev/null
+++ b/src/main/webapp/WEB-INF/views/categoryForm.html
@@ -0,0 +1,21 @@
+
+
+카테고리 등록
+
+카테고리 등록
+
+
+
+← 목록으로
+
+
+
+
\ 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..983f77a0
--- /dev/null
+++ b/src/main/webapp/WEB-INF/views/categoryList.html
@@ -0,0 +1,30 @@
+
+
+카테고리 목록
+
+카테고리 목록
+
+
+
+
+
++ 카테고리 등록
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/main/webapp/WEB-INF/views/error.html b/src/main/webapp/WEB-INF/views/error.html
index 0404fadc..bb437a22 100644
--- a/src/main/webapp/WEB-INF/views/error.html
+++ b/src/main/webapp/WEB-INF/views/error.html
@@ -30,6 +30,9 @@
+
⚠️
diff --git a/src/main/webapp/WEB-INF/views/productList.html b/src/main/webapp/WEB-INF/views/productList.html
index 775e8b2b..0bd14a1e 100644
--- a/src/main/webapp/WEB-INF/views/productList.html
+++ b/src/main/webapp/WEB-INF/views/productList.html
@@ -53,6 +53,24 @@ 상품 목록
등록된 상품이 없습니다. 새 상품을 등록해 주세요.
+
+
+
+
+
+ 검색 결과가 없습니다.
+
+
@@ -120,5 +138,9 @@ 상품 목록
+
+