From d6be696fb1705db4be70728788545c74a5a168e2 Mon Sep 17 00:00:00 2001 From: han97901 Date: Sat, 27 Sep 2025 15:39:26 +0900 Subject: [PATCH 1/2] =?UTF-8?q?=EA=B3=B5=ED=86=B5=20=ED=97=A4=EB=8D=94=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 2 +- .../shoppay/Controller/ProductController.java | 46 +-- .../JmeterTest/StockTestConroller.java | 2 +- .../shoppay/config/DataInitializer.java | 101 ++++++ .../shoppay/config/RequestLoggingFilter.java | 30 ++ .../org/zerock/shoppay/config/WebConfig.java | 12 +- .../org/zerock/shoppay/dto/ErrorResponse.java | 2 +- .../handler/GlobalExceptionHandler.java | 2 + .../shoppay/repository/ProductRepository.java | 10 +- .../repository/ProductSpecification.java | 32 ++ .../shoppay/service/ProductService.java | 23 +- .../templates/fragments/common-head.html | 0 .../resources/templates/fragments/header.html | 0 src/main/resources/templates/home/index.html | 8 +- .../resources/templates/product/category.html | 12 +- .../resources/templates/product/detail.html | 59 +--- .../resources/templates/product/list.html | 298 +++++++++--------- 17 files changed, 379 insertions(+), 260 deletions(-) rename src/main/java/org/zerock/shoppay/{Controller => }/JmeterTest/StockTestConroller.java (96%) create mode 100644 src/main/java/org/zerock/shoppay/config/DataInitializer.java create mode 100644 src/main/java/org/zerock/shoppay/config/RequestLoggingFilter.java create mode 100644 src/main/java/org/zerock/shoppay/repository/ProductSpecification.java create mode 100644 src/main/resources/templates/fragments/common-head.html create mode 100644 src/main/resources/templates/fragments/header.html diff --git a/build.gradle b/build.gradle index f65b221..54fdabf 100644 --- a/build.gradle +++ b/build.gradle @@ -1,6 +1,6 @@ plugins { id 'java' - id 'org.springframework.boot' version '3.5.5' + id 'org.springframework.boot' version '3.2.5' id 'io.spring.dependency-management' version '1.1.7' id 'org.asciidoctor.jvm.convert' version '3.3.2' } diff --git a/src/main/java/org/zerock/shoppay/Controller/ProductController.java b/src/main/java/org/zerock/shoppay/Controller/ProductController.java index 217e981..9f95b7f 100644 --- a/src/main/java/org/zerock/shoppay/Controller/ProductController.java +++ b/src/main/java/org/zerock/shoppay/Controller/ProductController.java @@ -21,14 +21,7 @@ public class ProductController { private final ProductService productService; - // 상품 목록 페이지 - @GetMapping - public String listProducts(Model model) { - List products = productService.getAllActiveProducts(); - model.addAttribute("products", products); - return "product/list"; - } - + // 상품 상세 페이지 @GetMapping("/{id}") public String productDetail(@PathVariable Long id, Model model) { @@ -38,49 +31,42 @@ public String productDetail(@PathVariable Long id, Model model) { return "product/detail"; } -// 카테고리별 상품 목록 (ID로 조회) -// @GetMapping("/category/{categoryId}") -// public String productsByCategory(@PathVariable Long categoryId, Model model) { -// List products = productService.getProductsByCategoryId(categoryId); -// model.addAttribute("products", products); -// model.addAttribute("categoryId", categoryId); -// return "product/list"; -// } - @GetMapping("/category/{categoryName}") - public String productsByCategory( - @PathVariable String categoryName, + @GetMapping + public String listProducts( + @RequestParam(required = false) String category, @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "12") int size, @RequestParam(defaultValue = "createdAt") String sort, @RequestParam(defaultValue = "DESC") String direction, Model model) { - + // 정렬 방향 설정 Sort.Direction sortDirection = direction.equalsIgnoreCase("ASC") ? Sort.Direction.ASC : Sort.Direction.DESC; - + // Pageable 객체 생성 Pageable pageable = PageRequest.of(page, size, Sort.by(sortDirection, sort)); - - // 페이지네이션된 상품 조회 - Page productPage = productService.getProductsByCategoryWithPagination(categoryName, pageable); - + + // 통합된 서비스 메서드 호출 + Page productPage = productService.findProducts(category, pageable); + model.addAttribute("products", productPage.getContent()); model.addAttribute("productPage", productPage); - model.addAttribute("category", categoryName); + // category가 null일 경우 'All'을, 아니면 해당 카테고리 이름을 모델에 추가 + model.addAttribute("category", category != null ? category : "All"); model.addAttribute("currentPage", page); model.addAttribute("totalPages", productPage.getTotalPages()); model.addAttribute("totalItems", productPage.getTotalElements()); model.addAttribute("size", size); model.addAttribute("sort", sort); model.addAttribute("direction", direction); - - // 페이지 번호 목록 생성 (현재 페이지 기준 앞뒤 5개) + + // 페이지 번호 목록 생성 int startPage = Math.max(0, page - 5); int endPage = Math.min(productPage.getTotalPages() - 1, page + 5); model.addAttribute("startPage", startPage); model.addAttribute("endPage", endPage); - - return "product/category"; // IKEA 스타일 카테고리 페이지 + + return "product/category"; // 템플릿 재사용 } // 상품 검색 diff --git a/src/main/java/org/zerock/shoppay/Controller/JmeterTest/StockTestConroller.java b/src/main/java/org/zerock/shoppay/JmeterTest/StockTestConroller.java similarity index 96% rename from src/main/java/org/zerock/shoppay/Controller/JmeterTest/StockTestConroller.java rename to src/main/java/org/zerock/shoppay/JmeterTest/StockTestConroller.java index bc38fb8..b6bb72d 100644 --- a/src/main/java/org/zerock/shoppay/Controller/JmeterTest/StockTestConroller.java +++ b/src/main/java/org/zerock/shoppay/JmeterTest/StockTestConroller.java @@ -1,4 +1,4 @@ -package org.zerock.shoppay.Controller.JmeterTest; +package org.zerock.shoppay.JmeterTest; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; diff --git a/src/main/java/org/zerock/shoppay/config/DataInitializer.java b/src/main/java/org/zerock/shoppay/config/DataInitializer.java new file mode 100644 index 0000000..83b3bdb --- /dev/null +++ b/src/main/java/org/zerock/shoppay/config/DataInitializer.java @@ -0,0 +1,101 @@ +package org.zerock.shoppay.config; + +import lombok.RequiredArgsConstructor; +import org.springframework.boot.CommandLineRunner; +import org.springframework.stereotype.Component; +import org.zerock.shoppay.Entity.Category; +import org.zerock.shoppay.Entity.Product; +import org.zerock.shoppay.repository.CategoryRepository; +import org.zerock.shoppay.repository.ProductRepository; + +@Component +@RequiredArgsConstructor +public class DataInitializer implements CommandLineRunner { + + private final ProductRepository productRepository; + private final CategoryRepository categoryRepository; + + @Override + public void run(String... args) throws Exception { + // 카테고리 먼저 초기화 + if (categoryRepository.count() == 0) { + Category bed = Category.builder().name("Bed").build(); + Category kitchen = Category.builder().name("Kitchen").build(); + Category living = Category.builder().name("Living").build(); + + // 카테고리 먼저 저장! + bed = categoryRepository.save(bed); + kitchen = categoryRepository.save(kitchen); + living = categoryRepository.save(living); + + System.out.println("카테고리 3개가 추가되었습니다!"); + } + + // 그 다음 상품 초기화 + if (productRepository.count() == 0) { + // 저장된 카테고리 조회 + Category bed = categoryRepository.findByName("Bed").orElse(null); + Category kitchen = categoryRepository.findByName("Kitchen").orElse(null); + Category living = categoryRepository.findByName("Living").orElse(null); + + // IKEA 상품 추가 + Product ikea1 = Product.builder() + .name("NÅLBLECKA 놀블레카") + .description("주방 조리대 정리용품, 38x13x28 cm") + .price(19900) + .stock(20) + .category(kitchen) + .imageUrl("https://www.ikea.com/kr/ko/images/products/nalblecka-spice-rack-bamboo__1152034_pe884774_s5.jpg") + .isActive(true) + .build(); + + Product ikea2 = Product.builder() + .name("VEVELSTAD 베벨스타드") + .description("침대프레임, 화이트, 120x200 cm") + .price(119000) + .stock(5) + .category(bed) + .imageUrl("https://www.ikea.com/kr/ko/images/products/vevelstad-bed-frame-white__0749531_pe745501_s5.jpg") + .isActive(true) + .build(); + + Product ikea3 = Product.builder() + .name("SMASKA 스마스카") + .description("도시락통, 플라스틱/그린") + .price(3900) + .stock(50) + .category(kitchen) + .imageUrl("https://www.ikea.com/kr/ko/images/products/smaska-lunch-box-green__1153271_pe885950_s5.jpg") + .isActive(true) + .build(); + + Product ikea4 = Product.builder() + .name("VÄGSKYLT 벡쉴트") + .description("평직러그, 80x150 cm") + .price(34900) + .stock(8) + .category(living) + .imageUrl("https://www.ikea.com/kr/ko/images/products/vagskylt-rug-flatwoven-multicolour__1117853_pe872668_s5.jpg") + .isActive(true) + .build(); + + Product ikea5 = Product.builder() + .name("KUNGSFORS 쿵스포르스") + .description("주방카트, 스테인리스/애쉬, 60x40 cm") + .price(149000) + .stock(3) + .category(kitchen) + .imageUrl("https://www.ikea.com/kr/ko/images/products/kungsfors-kitchen-trolley-stainless-steel-ash__0714236_pe730090_s5.jpg") + .isActive(true) + .build(); + + productRepository.save(ikea1); + productRepository.save(ikea2); + productRepository.save(ikea3); + productRepository.save(ikea4); + productRepository.save(ikea5); + + System.out.println("초기 상품 데이터 10개가 추가되었습니다! (일반 5개 + IKEA 5개)"); + } + } +} \ No newline at end of file diff --git a/src/main/java/org/zerock/shoppay/config/RequestLoggingFilter.java b/src/main/java/org/zerock/shoppay/config/RequestLoggingFilter.java new file mode 100644 index 0000000..e3b4804 --- /dev/null +++ b/src/main/java/org/zerock/shoppay/config/RequestLoggingFilter.java @@ -0,0 +1,30 @@ +package org.zerock.shoppay.config; + +import jakarta.servlet.*; +import jakarta.servlet.http.HttpServletRequest; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@Component +@Order(1) // 모든 필터 중에서 가장 먼저 실행되도록 순서를 1로 지정 +public class RequestLoggingFilter implements Filter { + + private static final Logger log = LoggerFactory.getLogger(RequestLoggingFilter.class); + + @Override + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) + throws IOException, ServletException { + + HttpServletRequest httpRequest = (HttpServletRequest) request; + + // 요청이 서버에 도착한 바로 그 순간의 URI를 로그로 출력합니다. + log.info(">>>>> INCOMING REQUEST URI: " + httpRequest.getRequestURI()); + + // 다음 필터로 요청을 전달합니다. + chain.doFilter(request, response); + } +} diff --git a/src/main/java/org/zerock/shoppay/config/WebConfig.java b/src/main/java/org/zerock/shoppay/config/WebConfig.java index 8cf0edf..9b97c8a 100644 --- a/src/main/java/org/zerock/shoppay/config/WebConfig.java +++ b/src/main/java/org/zerock/shoppay/config/WebConfig.java @@ -1,12 +1,22 @@ package org.zerock.shoppay.config; import org.springframework.context.annotation.Configuration; +import org.springframework.http.MediaType; +import org.springframework.web.servlet.config.annotation.ContentNegotiationConfigurer; import org.springframework.web.servlet.config.annotation.ViewControllerRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; @Configuration public class WebConfig implements WebMvcConfigurer { - + + @Override + public void configureContentNegotiation(ContentNegotiationConfigurer configurer) { + configurer.favorPathExtension(false) + .favorParameter(false) + .ignoreAcceptHeader(false) + .defaultContentType(MediaType.APPLICATION_JSON); + } + @Override public void addViewControllers(ViewControllerRegistry registry) { // 결제 관련 경로 diff --git a/src/main/java/org/zerock/shoppay/dto/ErrorResponse.java b/src/main/java/org/zerock/shoppay/dto/ErrorResponse.java index 5753e21..ca1703b 100644 --- a/src/main/java/org/zerock/shoppay/dto/ErrorResponse.java +++ b/src/main/java/org/zerock/shoppay/dto/ErrorResponse.java @@ -1,5 +1,5 @@ package org.zerock.shoppay.dto; -// Java 14 이상에서 사용 가능한 record를 사용하여 불변 DTO를 간결하게 정의합니다. +//record를 이용한 에러 DTO public record ErrorResponse(String message) { } diff --git a/src/main/java/org/zerock/shoppay/handler/GlobalExceptionHandler.java b/src/main/java/org/zerock/shoppay/handler/GlobalExceptionHandler.java index b277787..8a3bccc 100644 --- a/src/main/java/org/zerock/shoppay/handler/GlobalExceptionHandler.java +++ b/src/main/java/org/zerock/shoppay/handler/GlobalExceptionHandler.java @@ -35,6 +35,8 @@ public ResponseEntity handleOptimisticLockConflict(OptimisticLock return ResponseEntity.status(HttpStatus.CONFLICT).body(errorResponse); } + //Global Catch-All handler로 포괄적인 예외 처리를 진행 + //GlobalExceptionHandler에서 명시되지 않는 예외 이외의 모든 예외는 해당 메서드를 통해 처리 - Exception.class가 모든 예외의 최위 클래스 @ExceptionHandler(Exception.class) public ResponseEntity handleGlobalException(Exception e) { // 중요한 정보: 실제 운영 환경에서는 전체 예외 스택 트레이스를 로깅하여 디버깅에 사용해야 합니다. diff --git a/src/main/java/org/zerock/shoppay/repository/ProductRepository.java b/src/main/java/org/zerock/shoppay/repository/ProductRepository.java index 3ed7b61..d628f6b 100644 --- a/src/main/java/org/zerock/shoppay/repository/ProductRepository.java +++ b/src/main/java/org/zerock/shoppay/repository/ProductRepository.java @@ -4,6 +4,7 @@ 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.JpaSpecificationExecutor; import org.springframework.data.jpa.repository.Query; import org.springframework.data.jpa.repository.Lock; import org.springframework.data.jpa.repository.Modifying; @@ -16,7 +17,7 @@ import java.util.Optional; @Repository -public interface ProductRepository extends JpaRepository { +public interface ProductRepository extends JpaRepository, JpaSpecificationExecutor { /* SELECT * FROM products WHERE is_active = true @@ -39,17 +40,12 @@ public interface ProductRepository extends JpaRepository { */ @Query("SELECT p FROM Product p WHERE p.category.name = :categoryName AND p.isActive = true") List findByCategoryNameAndIsActiveTrue(@Param("categoryName") String categoryName); - - /* - 카테고리명으로 활성 상품 조회 (페이지네이션) - */ - @Query("SELECT p FROM Product p WHERE p.category.name = :categoryName AND p.isActive = true") - Page findByCategoryAndActiveTrue(@Param("categoryName") String categoryName, Pageable pageable); @Modifying @Query(value = "UPDATE products SET stock = stock - 1 WHERE id = :id AND stock > 0", nativeQuery = true) int decreaseStockNative(@Param("id") Long id); + //재고 감소 비관적 락 @Lock(LockModeType.PESSIMISTIC_WRITE) @Query("select p from Product p where p.id = :id") Optional findByIdWithPessimisticLock(@Param("id") Long id); diff --git a/src/main/java/org/zerock/shoppay/repository/ProductSpecification.java b/src/main/java/org/zerock/shoppay/repository/ProductSpecification.java new file mode 100644 index 0000000..9b22647 --- /dev/null +++ b/src/main/java/org/zerock/shoppay/repository/ProductSpecification.java @@ -0,0 +1,32 @@ +package org.zerock.shoppay.repository; + +import org.springframework.data.jpa.domain.Specification; +import org.springframework.util.StringUtils; +import org.zerock.shoppay.Entity.Product; + +public class ProductSpecification { + + /** + * 항상 is_active = true 조건을 포함하기 위한 기본 Specification + */ + public static Specification isActive() { + return (root, query, criteriaBuilder) -> + criteriaBuilder.isTrue(root.get("isActive")); + } + + /** + * 카테고리 이름으로 필터링하는 Specification + * @param categoryName 필터링할 카테고리 이름 + * @return Specification + */ + public static Specification hasCategory(String categoryName) { + return (root, query, criteriaBuilder) -> { + // categoryName 파라미터가 없거나, 비어있거나, "All"이면 이 조건은 무시됩니다. + if (!StringUtils.hasText(categoryName) || "All".equalsIgnoreCase(categoryName)) { + return null; + } + // p.category.name = :categoryName 에 해당하는 조건입니다. + return criteriaBuilder.equal(root.get("category").get("name"), categoryName); + }; + } +} diff --git a/src/main/java/org/zerock/shoppay/service/ProductService.java b/src/main/java/org/zerock/shoppay/service/ProductService.java index 05da994..7072320 100644 --- a/src/main/java/org/zerock/shoppay/service/ProductService.java +++ b/src/main/java/org/zerock/shoppay/service/ProductService.java @@ -4,6 +4,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.domain.Specification; import org.springframework.orm.ObjectOptimisticLockingFailureException; import org.springframework.retry.annotation.Backoff; import org.springframework.retry.annotation.Recover; @@ -15,6 +16,7 @@ import org.zerock.shoppay.exception.OptimisticLockConflictException; import org.zerock.shoppay.exception.ProductNotFoundException; import org.zerock.shoppay.repository.ProductRepository; +import org.zerock.shoppay.repository.ProductSpecification; import java.util.List; import java.util.Optional; @@ -92,7 +94,7 @@ public void decreaseStock(Long productId, Integer quantity) { System.out.println("재고 감소 시도: " + productId); Product product = productRepository.findById(productId) .orElseThrow(() -> new ProductNotFoundException("상품을 찾을 수 없습니다: " + productId)); - //재고가 처음부터 부족한 로 - 재시도 안됨 + //재고가 처음부터 부족한 로직 - 재시도 안됨 if (product.getStock() < quantity) { throw new InsufficientStockException("재고가 부족합니다."); } @@ -100,13 +102,14 @@ public void decreaseStock(Long productId, Integer quantity) { product.setStock(product.getStock() - quantity); } - // decreaseStock 메소드의 모든 재시도가 실패했을 때 호출될 메소드 + //재고 감소 전부 실패시 자동 호출 @Recover public void recoverDecreaseStock(RuntimeException e, Long productId, Integer quantity) { System.err.println("최대 재시도 횟수 초과: " + productId + ", 이유: " + e.getMessage()); throw new OptimisticLockConflictException("다른 사용자와의 충돌로 인해 요청을 처리할 수 없습니다. 잠시 후 다시 시도해주세요."); } + //native SQL을 이용한 재고 감소 @Transactional public void decreaseStockWithNativeQuery(Long productId, Integer quantity) { int updatedRows = productRepository.decreaseStockNative(productId); @@ -121,14 +124,22 @@ public void decreaseStockWithPessimisticLock(Long productId, Integer quantity) { .orElseThrow(() -> new RuntimeException("Product not found")); if (product.getStock() < quantity) { - throw new RuntimeException("Insufficient stock"); + throw new InsufficientStockException("재고가 부족합니다."); } product.setStock(product.getStock() - quantity); } @Transactional(readOnly = true) - public Page getProductsByCategoryWithPagination(String categoryName, Pageable pageable) { - return productRepository.findByCategoryAndActiveTrue(categoryName, pageable); + public Page findProducts(String category, Pageable pageable) { + // 1. 기본적으로 is_active = true 조건을 설정합니다. + Specification spec = Specification.where(ProductSpecification.isActive()); + + // 2. category 파라미터가 있으면, 카테고리 필터 조건을 추가합니다. + // ProductSpecification.hasCategory가 null 또는 빈 문자열을 안전하게 처리합니다. + spec = spec.and(ProductSpecification.hasCategory(category)); + + // 3. 최종 조합된 조건으로 Repository에 쿼리를 요청합니다. + return productRepository.findAll(spec, pageable); } -} \ No newline at end of file +} diff --git a/src/main/resources/templates/fragments/common-head.html b/src/main/resources/templates/fragments/common-head.html new file mode 100644 index 0000000..e69de29 diff --git a/src/main/resources/templates/fragments/header.html b/src/main/resources/templates/fragments/header.html new file mode 100644 index 0000000..e69de29 diff --git a/src/main/resources/templates/home/index.html b/src/main/resources/templates/home/index.html index b59acc0..c100d13 100644 --- a/src/main/resources/templates/home/index.html +++ b/src/main/resources/templates/home/index.html @@ -75,7 +75,9 @@
- 모든 제품 + + 모든 제품 +
공간별 쇼핑하기 @@ -103,7 +105,7 @@
@@ -164,4 +166,4 @@

오늘의 인기 상품

- \ No newline at end of file + diff --git a/src/main/resources/templates/product/category.html b/src/main/resources/templates/product/category.html index 9a5cf9a..f2f8aab 100644 --- a/src/main/resources/templates/product/category.html +++ b/src/main/resources/templates/product/category.html @@ -438,21 +438,21 @@

침대