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
29 changes: 20 additions & 9 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -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
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 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
68 changes: 68 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,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";
}
}
24 changes: 21 additions & 3 deletions src/main/java/kr/ac/hansung/cse/controller/ProductController.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}


Expand All @@ -48,9 +51,24 @@ public ProductController(ProductService productService) {
// ─────────────────────────────────────────────────────────────────

@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,7 @@
package kr.ac.hansung.cse.exception;

public class DuplicateCategoryException extends RuntimeException {
public DuplicateCategoryException(String name) {
super("이미 존재하는 카테고리입니다: " + name);
}
}
13 changes: 13 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,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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ public Optional<Category> 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<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,20 @@ 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 c = em.find(Category.class, id);
if (c != null) em.remove(c);
}

}

17 changes: 17 additions & 0 deletions src/main/java/kr/ac/hansung/cse/repository/ProductRepository.java
Original file line number Diff line number Diff line change
Expand Up @@ -131,4 +131,21 @@ public void delete(Long id) {
entityManager.remove(product);
}
}

// 상품 키워드 검색
public List<Product> 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<Product> 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();
}

}
41 changes: 41 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,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<Category> 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);
}
}
10 changes: 10 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 @@ -79,6 +79,16 @@ public List<Product> getAllProducts() {
return productRepository.findAll();
}


public List<Product> searchByName(String keyword) {
return productRepository.findByNameContaining(keyword);
}


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

/**
* ID로 상품 조회
* Optional을 그대로 반환하여 Controller가 null 처리를 명시적으로 하도록 강제합니다.
Expand Down
21 changes: 21 additions & 0 deletions src/main/webapp/WEB-INF/views/categoryForm.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head><title>카테고리 등록</title></head>
<body>
<h1>카테고리 등록</h1>

<form th:action="@{/categories/create}" th:object="${categoryForm}" method="post">
<label>카테고리 이름:</label>
<input type="text" th:field="*{name}" placeholder="카테고리 이름"/>
<span th:errors="*{name}" style="color:red"></span>
<br/>
<button type="submit">등록</button>
</form>

<a href="/categories">← 목록으로</a>

<footer style="margin-top:30px; border-top:1px solid #ccc; padding:10px; font-size:12px;">
<p th:text="${'실습 수행 일시: ' + #temporals.format(#temporals.createNow(), 'yyyy년 MM월 dd일 HH:mm:ss') + ' | 학번: 2271520 | 성명: 노영민'}"></p>
</footer>
</body>
</html>
30 changes: 30 additions & 0 deletions src/main/webapp/WEB-INF/views/categoryList.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head><title>카테고리 목록</title></head>
<body>
<h1>카테고리 목록</h1>

<!-- 성공/오류 메시지 -->
<p th:if="${successMessage}" th:text="${successMessage}" style="color:green"></p>
<p th:if="${errorMessage}" th:text="${errorMessage}" style="color:red"></p>

<a href="/categories/create">+ 카테고리 등록</a>

<table border="1">
<tr><th>ID</th><th>이름</th><th>삭제</th></tr>
<tr th:each="cat : ${categories}">
<td th:text="${cat.id}"></td>
<td th:text="${cat.name}"></td>
<td>
<form th:action="@{/categories/{id}/delete(id=${cat.id})}" method="post">
<button type="submit">삭제</button>
</form>
</td>
</tr>
</table>

<footer style="margin-top:30px; border-top:1px solid #ccc; padding:10px; font-size:12px;">
<p th:text="${'실습 수행 일시: ' + #temporals.format(#temporals.createNow(), 'yyyy년 MM월 dd일 HH:mm:ss') + ' | 학번: 2271520 | 성명: 노영민'}"></p>
</footer>
</body>
</html>
Loading