Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .idea/compiler.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion .idea/misc.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ services:
# 호스트포트:컨테이너포트
# 호스트(개발 PC)에서 MySQL Workbench 등으로 직접 접속 가능
# 호스트 3306 포트를 컨테이너 3306 포트로 직접 연결
- "3306:3306"
- "3307:3306"

volumes:
# 1. 데이터 영속성: 프로젝트 폴더 안의 mysql-data/ 디렉터리에 DB 파일이 저장됩니다.
Expand Down
2 changes: 1 addition & 1 deletion src/main/java/kr/ac/hansung/cse/config/DbConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -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" +
Expand Down
70 changes: 70 additions & 0 deletions src/main/java/kr/ac/hansung/cse/controller/CategoryController.java
Original file line number Diff line number Diff line change
@@ -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";
}
}
31 changes: 25 additions & 6 deletions src/main/java/kr/ac/hansung/cse/controller/ProductController.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<Product> products = productService.getAllProducts();
public String listProducts(
@RequestParam(required = false) String keyword,
@RequestParam(required = false) Long categoryId,
Model model) {

List<Product> 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";
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package kr.ac.hansung.cse.exception;

public class DuplicateCategoryException extends RuntimeException {

public DuplicateCategoryException(String name) {
super("이미 존재하는 카테고리입니다: " + name);
}
}
15 changes: 15 additions & 0 deletions src/main/java/kr/ac/hansung/cse/model/CategoryForm.java
Original file line number Diff line number Diff line change
@@ -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;
}
22 changes: 18 additions & 4 deletions src/main/java/kr/ac/hansung/cse/repository/CategoryRepository.java
Original file line number Diff line number Diff line change
Expand Up @@ -28,16 +28,16 @@ public List<Category> findAll() {
.getResultList();
}

// 이름으로 카테고리 조회 (폼에서 선택한 카테고리명 → Category 엔티티 변환 시 사용)
public Optional<Category> findByName(String name) {
List<Category> 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<Category> findByIdWithProducts(Long id) {
List<Category> result = em.createQuery(
"SELECT DISTINCT c FROM Category c JOIN FETCH c.products WHERE c.id = :id",
Expand All @@ -46,5 +46,19 @@ public Optional<Category> 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);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@
*/
@Repository
public class ProductRepository {
@PersistenceContext
private EntityManager entityManager;

/**
* @PersistenceContext : Spring이 EntityManager를 주입해 주는 어노테이션입니다.
Expand All @@ -50,8 +52,9 @@ public class ProductRepository {
* → 실제 EntityManager는 현재 트랜잭션에 바인딩된 것을 사용합니다.
* → 덕분에 멀티스레드 환경에서도 안전하게 사용 가능합니다.
*/

@PersistenceContext
private EntityManager entityManager;
private EntityManager em;

/**
* 모든 상품 목록 조회
Expand Down Expand Up @@ -131,4 +134,23 @@ public void delete(Long id) {
entityManager.remove(product);
}
}

// 이름 검색: JPQL의 LIKE로 키워드 포함 여부 검사
public List<Product> 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<Product> 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();
}
}
47 changes: 47 additions & 0 deletions src/main/java/kr/ac/hansung/cse/service/CategoryService.java
Original file line number Diff line number Diff line change
@@ -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<Category> 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);
}
}
9 changes: 9 additions & 0 deletions src/main/java/kr/ac/hansung/cse/service/ProductService.java
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,15 @@ public Optional<Product> getProductById(Long id) {
return productRepository.findById(id);
}

// readOnly = true 상속: 검색은 읽기 전용 트랜잭션으로 충분
public List<Product> searchByName(String keyword) {
return productRepository.findByNameContaining(keyword);
}

public List<Product> searchByCategory(Long categoryId) {
return productRepository.findByCategoryId(categoryId);
}

/**
* 새 상품 등록
*
Expand Down
32 changes: 32 additions & 0 deletions src/main/webapp/WEB-INF/views/categoryForm.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<!DOCTYPE html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>카테고리 등록</title>
<style>
body { font-family: 'Malgun Gothic', Arial, sans-serif; max-width: 700px; margin: 40px auto; padding: 0 20px; color: #333; }
h1 { border-bottom: 2px solid #4a90d9; padding-bottom: 10px; color: #2c3e50; }
label { display: block; margin-top: 16px; margin-bottom: 6px; font-weight: bold; }
input { width: 100%; padding: 10px; box-sizing: border-box; }
.error { color: red; font-size: 0.9em; margin-top: 5px; display: block; }
.btn { margin-top: 20px; padding: 10px 18px; background: #4a90d9; color: white; border: none; border-radius: 4px; cursor: pointer; }
.btn:hover { background: #357abd; }
</style>
</head>
<body>

<h1>카테고리 등록</h1>

<form th:action="@{/categories/create}" th:object="${categoryForm}" method="post">
<label for="name">카테고리 이름</label>
<input type="text" th:field="*{name}" placeholder="카테고리 이름">
<span class="error" th:errors="*{name}"></span>

<button type="submit" class="btn">등록</button>
</form>

<hr>
<p th:text="${'실습 수행 일시: ' + #temporals.format(#temporals.createNow(), 'yyyy년 MM월 dd일 HH:mm:ss') + ' | 학번: 2371341 | 성명: 김가람'}"></p>

</body>
</html>
Loading