diff --git a/pom.xml b/pom.xml
index 70c79e5..719d894 100644
--- a/pom.xml
+++ b/pom.xml
@@ -41,6 +41,11 @@
spring-boot-starter-thymeleaf
+
+ org.springframework.boot
+ spring-boot-starter-validation
+
+
org.thymeleaf.extras
diff --git a/src/main/java/kr/ac/hansung/DataInitializer.java b/src/main/java/kr/ac/hansung/DataInitializer.java
index f31ff36..c5a6ad3 100644
--- a/src/main/java/kr/ac/hansung/DataInitializer.java
+++ b/src/main/java/kr/ac/hansung/DataInitializer.java
@@ -46,7 +46,25 @@ public void run(ApplicationArguments args) {
productRepository.save(new Product("Spring Security 7 핵심 원리", 28000, "세션·JWT·OAuth2 기반 보안 구현", 30));
productRepository.save(new Product("JPA 프로그래밍 실전", 32000, "Hibernate 7 기반 ORM 마스터", 25));
productRepository.save(new Product("Thymeleaf 완전 정복", 22000, "서버사이드 템플릿 엔진 가이드", 40));
- log.info("샘플 상품 4건 생성 완료");
+ productRepository.save(new Product("React 입문", 27000, "컴포넌트 기반 프론트엔드 개발", 0));
+ productRepository.save(new Product("Docker & Kubernetes", 38000, "컨테이너 오케스트레이션 실전", 15));
+ productRepository.save(new Product("Git & GitHub 협업", 18000, "브랜치 전략과 Pull Request 가이드", 60));
+ productRepository.save(new Product("MySQL 성능 최적화", 33000, "인덱스·쿼리 튜닝 실전 가이드", 20));
+ productRepository.save(new Product("Redis 캐싱 전략", 29000, "캐시 설계와 세션 관리 실습", 10));
+ productRepository.save(new Product("AWS 클라우드 입문", 42000, "EC2·S3·RDS 실전 배포 가이드", 35));
+
+ productRepository.save(new Product("삼성전자 갤럭시 S25", 1290000, "최신 플래그십 스마트폰", 100));
+ productRepository.save(new Product("삼성전자 갤럭시 탭 S10", 890000, "고해상도 AMOLED 태블릿", 45));
+ productRepository.save(new Product("LG 그램 17 노트북", 1890000, "초경량 17인치 업무용 노트북", 8));
+ productRepository.save(new Product("애플 맥북 프로 M4", 2990000, "M4 칩 탑재 고성능 노트북", 0));
+ productRepository.save(new Product("소니 WH-1000XM6", 420000, "업계 최고 노이즈 캔슬링 헤드폰", 22));
+ productRepository.save(new Product("로지텍 MX Master 3S", 129000, "무선 고정밀 업무용 마우스", 75));
+ productRepository.save(new Product("삼성전자 65인치 QLED TV", 1590000, "Neo QLED 4K 스마트 TV", 5));
+ productRepository.save(new Product("다이슨 V15 청소기", 890000, "레이저 먼지 감지 무선 청소기", 18));
+ productRepository.save(new Product("닌텐도 스위치 2", 459000, "휴대·거치 겸용 게임 콘솔", 0));
+ productRepository.save(new Product("애플 에어팟 프로 3", 359000, "공간 음향 지원 무선 이어폰", 55));
+
+ log.info("샘플 상품 20건 생성 완료");
}
}
}
diff --git a/src/main/java/kr/ac/hansung/config/SecurityConfig.java b/src/main/java/kr/ac/hansung/config/SecurityConfig.java
index bc04eca..606b62a 100644
--- a/src/main/java/kr/ac/hansung/config/SecurityConfig.java
+++ b/src/main/java/kr/ac/hansung/config/SecurityConfig.java
@@ -32,7 +32,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
.requestMatchers("/", "/login", "/signup",
"/css/**", "/js/**", "/images/**", "/favicon.ico").permitAll()
.requestMatchers("/admin/**").hasRole("ADMIN")
- .requestMatchers("/products/add", "/products/*/delete").hasRole("ADMIN")
+ .requestMatchers("/products/add", "/products/*/delete", "/products/*/edit").hasRole("ADMIN")
.requestMatchers(HttpMethod.POST, "/products").hasRole("ADMIN")
.anyRequest().authenticated()
)
diff --git a/src/main/java/kr/ac/hansung/controller/ProductController.java b/src/main/java/kr/ac/hansung/controller/ProductController.java
index 3009fc2..4f03291 100644
--- a/src/main/java/kr/ac/hansung/controller/ProductController.java
+++ b/src/main/java/kr/ac/hansung/controller/ProductController.java
@@ -1,11 +1,16 @@
package kr.ac.hansung.controller;
import kr.ac.hansung.dto.ProductDto;
+import kr.ac.hansung.entity.Product;
import kr.ac.hansung.service.ProductService;
import lombok.RequiredArgsConstructor;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.PageRequest;
+import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;
+import org.springframework.web.servlet.mvc.support.RedirectAttributes;
@Controller
@RequestMapping("/products")
@@ -15,15 +20,27 @@ public class ProductController {
private final ProductService productService;
@GetMapping
- public String list(Model model) {
- model.addAttribute("products", productService.findAll());
- return "products/list";
- }
+ public String list(@RequestParam(defaultValue = "0") int page,
+ @RequestParam(defaultValue = "5") int size,
+ @RequestParam(required = false) String keyword,
+ Model model) {
- @GetMapping("/{id}")
- public String detail(@PathVariable Long id, Model model) {
- model.addAttribute("product", productService.findById(id));
- return "products/detail";
+ PageRequest pageRequest = PageRequest.of(page, size, Sort.by("id"));
+
+ String normalizedKeyword = (keyword != null && !keyword.isBlank()) ? keyword : null;
+
+ Page productPage;
+
+ if (normalizedKeyword != null) {
+ productPage = productService.searchProducts(normalizedKeyword, pageRequest);
+ } else {
+ productPage = productService.getProducts(pageRequest);
+ }
+
+ model.addAttribute("productPage", productPage);
+ model.addAttribute("keyword", normalizedKeyword);
+
+ return "products/list";
}
@GetMapping("/add")
@@ -38,6 +55,37 @@ public String save(@ModelAttribute ProductDto dto) {
return "redirect:/products";
}
+ @GetMapping("/{id}/edit")
+ public String editForm(@PathVariable Long id, Model model) {
+ Product product = productService.findById(id);
+
+ ProductDto dto = new ProductDto();
+ dto.setName(product.getName());
+ dto.setPrice(product.getPrice());
+ dto.setDescription(product.getDescription());
+ dto.setStock(product.getStock());
+
+ model.addAttribute("product", dto);
+ model.addAttribute("productId", id);
+
+ return "products/edit";
+ }
+
+ @PostMapping("/{id}/edit")
+ public String edit(@PathVariable Long id,
+ @ModelAttribute ProductDto dto,
+ RedirectAttributes ra) {
+ productService.updateProduct(id, dto);
+ ra.addFlashAttribute("successMessage", "상품이 수정되었습니다.");
+ return "redirect:/products";
+ }
+
+ @GetMapping("/{id}")
+ public String detail(@PathVariable Long id, Model model) {
+ model.addAttribute("product", productService.findById(id));
+ return "products/detail";
+ }
+
@PostMapping("/{id}/delete")
public String delete(@PathVariable Long id) {
productService.deleteById(id);
diff --git a/src/main/java/kr/ac/hansung/controller/UserController.java b/src/main/java/kr/ac/hansung/controller/UserController.java
new file mode 100644
index 0000000..d63254e
--- /dev/null
+++ b/src/main/java/kr/ac/hansung/controller/UserController.java
@@ -0,0 +1,71 @@
+package kr.ac.hansung.controller;
+
+import jakarta.validation.Valid;
+import kr.ac.hansung.dto.PasswordChangeDto;
+import kr.ac.hansung.service.UserService;
+import lombok.RequiredArgsConstructor;
+import org.springframework.security.core.annotation.AuthenticationPrincipal;
+import org.springframework.security.core.userdetails.UserDetails;
+import org.springframework.stereotype.Controller;
+import org.springframework.validation.BindingResult;
+import org.springframework.web.bind.annotation.*;
+import org.springframework.web.servlet.mvc.support.RedirectAttributes;
+
+@Controller
+@RequiredArgsConstructor
+public class UserController {
+
+ private final UserService userService;
+
+ @GetMapping("/user/password")
+ public String passwordForm(org.springframework.ui.Model model) {
+ model.addAttribute("passwordChangeDto", new PasswordChangeDto());
+ return "user/password";
+ }
+
+ @PostMapping("/user/password")
+ public String changePassword(
+ @AuthenticationPrincipal UserDetails userDetails,
+ @Valid @ModelAttribute("passwordChangeDto") PasswordChangeDto dto,
+ BindingResult bindingResult,
+ RedirectAttributes ra) {
+
+ if (bindingResult.hasErrors()) {
+ return "user/password";
+ }
+
+ if (!dto.getNewPassword().equals(dto.getConfirmPassword())) {
+ bindingResult.rejectValue(
+ "confirmPassword",
+ "mismatch",
+ "새 비밀번호가 일치하지 않습니다"
+ );
+ return "user/password";
+ }
+
+ try {
+ userService.changePassword(
+ userDetails.getUsername(),
+ dto.getCurrentPassword(),
+ dto.getNewPassword()
+ );
+
+ ra.addFlashAttribute(
+ "successMessage",
+ "비밀번호가 변경되었습니다"
+ );
+
+ } catch (IllegalArgumentException e) {
+
+ bindingResult.rejectValue(
+ "currentPassword",
+ "wrong",
+ e.getMessage()
+ );
+
+ return "user/password";
+ }
+
+ return "redirect:/home";
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/kr/ac/hansung/dto/PasswordChangeDto.java b/src/main/java/kr/ac/hansung/dto/PasswordChangeDto.java
new file mode 100644
index 0000000..394d0d9
--- /dev/null
+++ b/src/main/java/kr/ac/hansung/dto/PasswordChangeDto.java
@@ -0,0 +1,21 @@
+package kr.ac.hansung.dto;
+
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Size;
+import lombok.Getter;
+import lombok.Setter;
+
+@Getter
+@Setter
+public class PasswordChangeDto {
+
+ @NotBlank(message = "현재 비밀번호를 입력하세요")
+ private String currentPassword;
+
+ @NotBlank
+ @Size(min = 8, message = "새 비밀번호는 8자 이상이어야 합니다")
+ private String newPassword;
+
+ @NotBlank(message = "새 비밀번호 확인을 입력하세요")
+ private String confirmPassword;
+}
\ No newline at end of file
diff --git a/src/main/java/kr/ac/hansung/repository/ProductRepository.java b/src/main/java/kr/ac/hansung/repository/ProductRepository.java
index f171d47..589a7d5 100644
--- a/src/main/java/kr/ac/hansung/repository/ProductRepository.java
+++ b/src/main/java/kr/ac/hansung/repository/ProductRepository.java
@@ -1,8 +1,15 @@
package kr.ac.hansung.repository;
import kr.ac.hansung.entity.Product;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
public interface ProductRepository extends JpaRepository {
long countByStockEquals(int stock);
-}
+
+ @Query("SELECT p FROM Product p WHERE p.name LIKE %:keyword%")
+ Page findByNameContaining(@Param("keyword") String keyword, Pageable pageable);
+}
\ No newline at end of file
diff --git a/src/main/java/kr/ac/hansung/service/ProductService.java b/src/main/java/kr/ac/hansung/service/ProductService.java
index 35c7f05..adecd53 100644
--- a/src/main/java/kr/ac/hansung/service/ProductService.java
+++ b/src/main/java/kr/ac/hansung/service/ProductService.java
@@ -4,6 +4,8 @@
import kr.ac.hansung.entity.Product;
import kr.ac.hansung.repository.ProductRepository;
import lombok.RequiredArgsConstructor;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -21,6 +23,16 @@ public List findAll() {
return productRepository.findAll();
}
+ @Transactional(readOnly = true)
+ public Page getProducts(Pageable pageable) {
+ return productRepository.findAll(pageable);
+ }
+
+ @Transactional(readOnly = true)
+ public Page searchProducts(String keyword, Pageable pageable) {
+ return productRepository.findByNameContaining(keyword, pageable);
+ }
+
@Transactional(readOnly = true)
public Product findById(Long id) {
return productRepository.findById(id)
@@ -39,4 +51,16 @@ public Product save(ProductDto dto) {
public void deleteById(Long id) {
productRepository.deleteById(id);
}
+
+ @Transactional
+ public Product updateProduct(Long id, ProductDto dto) {
+ Product product = findById(id);
+
+ product.setName(dto.getName());
+ product.setPrice(dto.getPrice());
+ product.setDescription(dto.getDescription());
+ product.setStock(dto.getStock());
+
+ return product;
+ }
}
diff --git a/src/main/java/kr/ac/hansung/service/UserService.java b/src/main/java/kr/ac/hansung/service/UserService.java
index 13cdfb5..9e55a0a 100644
--- a/src/main/java/kr/ac/hansung/service/UserService.java
+++ b/src/main/java/kr/ac/hansung/service/UserService.java
@@ -7,6 +7,7 @@
import kr.ac.hansung.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.security.crypto.password.PasswordEncoder;
+import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -38,4 +39,20 @@ public void signup(UserDto dto) {
public boolean existsByEmail(String email) {
return userRepository.existsByEmail(email);
}
+
+ @Transactional
+ public void changePassword(String email,
+ String currentPassword,
+ String newPassword) {
+
+ User user = userRepository.findByEmail(email)
+ .orElseThrow(() -> new UsernameNotFoundException(email));
+
+ if (!passwordEncoder.matches(currentPassword, user.getPassword())) {
+ throw new IllegalArgumentException("현재 비밀번호가 일치하지 않습니다");
+ }
+
+ user.setPassword(passwordEncoder.encode(newPassword));
+ userRepository.save(user);
+ }
}
diff --git a/src/main/resources/templates/admin/dashboard.html b/src/main/resources/templates/admin/dashboard.html
index c062c3b..ccddbdc 100644
--- a/src/main/resources/templates/admin/dashboard.html
+++ b/src/main/resources/templates/admin/dashboard.html
@@ -96,5 +96,8 @@ 접속 계정
+