From 05e7a54d1d4233acae108e83abd83df2de1291a7 Mon Sep 17 00:00:00 2001 From: joel-you Date: Sat, 6 Sep 2025 03:10:43 +0900 Subject: [PATCH 1/9] feat(25): Implement soft delete functionality for products with cache eviction and event publishing --- .../exception/GlobalExceptionHandler.java | 20 ++- .../common/exception/ValidationException.java | 20 +++ .../adapter/in/web/ProductController.java | 10 ++ .../out/cache/ProductCacheManagerImpl.java | 30 ++++ .../out/event/ProductEventPublisherImpl.java | 32 ++++ .../out/persistence/ProductJpaEntity.java | 5 + .../port/in/ProductDeleteUseCase.java | 7 + .../service/ProductCacheManager.java | 9 + .../service/ProductDeleteService.java | 65 ++++++++ .../service/ProductEventPublisher.java | 9 + .../monolith/product/domain/Product.java | 16 +- .../domain/event/ProductDeletedEvent.java | 22 +++ .../in/web/ProductControllerDeleteTest.java | 120 ++++++++++++++ .../in/web/ProductControllerSearchTest.java | 4 + .../adapter/in/web/ProductControllerTest.java | 6 +- .../in/web/ProductControllerUpdateTest.java | 16 +- .../out/persistence/ProductJpaEntityTest.java | 1 + .../service/ProductCreateServiceTest.java | 3 +- .../service/ProductDeleteServiceTest.java | 156 ++++++++++++++++++ .../service/ProductGetServiceTest.java | 3 +- .../service/ProductResponseMapperTest.java | 2 + .../service/ProductSearchServiceTest.java | 2 + .../service/ProductUpdateServiceTest.java | 5 +- .../product/domain/ProductSoftDeleteTest.java | 154 +++++++++++++++++ .../product/domain/ProductUpdateTest.java | 5 +- 25 files changed, 696 insertions(+), 26 deletions(-) create mode 100644 common/src/main/java/com/msa/commerce/common/exception/ValidationException.java create mode 100644 monolith/src/main/java/com/msa/commerce/monolith/product/adapter/out/cache/ProductCacheManagerImpl.java create mode 100644 monolith/src/main/java/com/msa/commerce/monolith/product/adapter/out/event/ProductEventPublisherImpl.java create mode 100644 monolith/src/main/java/com/msa/commerce/monolith/product/application/port/in/ProductDeleteUseCase.java create mode 100644 monolith/src/main/java/com/msa/commerce/monolith/product/application/service/ProductCacheManager.java create mode 100644 monolith/src/main/java/com/msa/commerce/monolith/product/application/service/ProductDeleteService.java create mode 100644 monolith/src/main/java/com/msa/commerce/monolith/product/application/service/ProductEventPublisher.java create mode 100644 monolith/src/main/java/com/msa/commerce/monolith/product/domain/event/ProductDeletedEvent.java create mode 100644 monolith/src/test/java/com/msa/commerce/monolith/product/adapter/in/web/ProductControllerDeleteTest.java create mode 100644 monolith/src/test/java/com/msa/commerce/monolith/product/application/service/ProductDeleteServiceTest.java create mode 100644 monolith/src/test/java/com/msa/commerce/monolith/product/domain/ProductSoftDeleteTest.java diff --git a/common/src/main/java/com/msa/commerce/common/exception/GlobalExceptionHandler.java b/common/src/main/java/com/msa/commerce/common/exception/GlobalExceptionHandler.java index 061fbe2..e7413c7 100644 --- a/common/src/main/java/com/msa/commerce/common/exception/GlobalExceptionHandler.java +++ b/common/src/main/java/com/msa/commerce/common/exception/GlobalExceptionHandler.java @@ -118,8 +118,26 @@ public ResponseEntity handleNoChangesProvidedException( return ResponseEntity.badRequest().body(errorResponse); } - @ExceptionHandler(MethodArgumentNotValidException.class) + @ExceptionHandler(ValidationException.class) public ResponseEntity handleValidationException( + ValidationException ex, HttpServletRequest request) { + + log.warn("Validation exception: {}", ex.getMessage()); + + ErrorResponse errorResponse = ErrorResponse.builder() + .message(ex.getMessage()) + .code(ex.getErrorCode()) + .timestamp(LocalDateTime.now()) + .path(request.getRequestURI()) + .status(HttpStatus.BAD_REQUEST.value()) + .debugInfo(isDebugMode() ? ex.getClass().getSimpleName() : null) + .build(); + + return ResponseEntity.badRequest().body(errorResponse); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleMethodArgumentNotValidException( MethodArgumentNotValidException ex, HttpServletRequest request) { log.warn("Validation exception occurred: {}", ex.getMessage()); diff --git a/common/src/main/java/com/msa/commerce/common/exception/ValidationException.java b/common/src/main/java/com/msa/commerce/common/exception/ValidationException.java new file mode 100644 index 0000000..acdd6c0 --- /dev/null +++ b/common/src/main/java/com/msa/commerce/common/exception/ValidationException.java @@ -0,0 +1,20 @@ +package com.msa.commerce.common.exception; + +public class ValidationException extends RuntimeException { + + private final String errorCode; + + public ValidationException(String message, String errorCode) { + super(message); + this.errorCode = errorCode; + } + + public ValidationException(String message, String errorCode, Throwable cause) { + super(message, cause); + this.errorCode = errorCode; + } + + public String getErrorCode() { + return errorCode; + } +} diff --git a/monolith/src/main/java/com/msa/commerce/monolith/product/adapter/in/web/ProductController.java b/monolith/src/main/java/com/msa/commerce/monolith/product/adapter/in/web/ProductController.java index db536be..c50774c 100644 --- a/monolith/src/main/java/com/msa/commerce/monolith/product/adapter/in/web/ProductController.java +++ b/monolith/src/main/java/com/msa/commerce/monolith/product/adapter/in/web/ProductController.java @@ -3,6 +3,7 @@ import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; @@ -12,6 +13,7 @@ import org.springframework.web.bind.annotation.RestController; import com.msa.commerce.monolith.product.application.port.in.ProductCreateUseCase; +import com.msa.commerce.monolith.product.application.port.in.ProductDeleteUseCase; import com.msa.commerce.monolith.product.application.port.in.ProductGetUseCase; import com.msa.commerce.monolith.product.application.port.in.ProductPageResponse; import com.msa.commerce.monolith.product.application.port.in.ProductResponse; @@ -32,6 +34,8 @@ public class ProductController { private final ProductUpdateUseCase productUpdateUseCase; + private final ProductDeleteUseCase productDeleteUseCase; + private final ProductMapper productMapper; @PostMapping @@ -56,4 +60,10 @@ public ResponseEntity updateProduct(@PathVariable("id") Long pr return ResponseEntity.ok(productUpdateUseCase.updateProduct(productMapper.toUpdateCommand(productId, request))); } + @DeleteMapping("/{id}") + public ResponseEntity deleteProduct(@PathVariable("id") Long productId) { + productDeleteUseCase.deleteProduct(productId); + return ResponseEntity.noContent().build(); + } + } diff --git a/monolith/src/main/java/com/msa/commerce/monolith/product/adapter/out/cache/ProductCacheManagerImpl.java b/monolith/src/main/java/com/msa/commerce/monolith/product/adapter/out/cache/ProductCacheManagerImpl.java new file mode 100644 index 0000000..80ed932 --- /dev/null +++ b/monolith/src/main/java/com/msa/commerce/monolith/product/adapter/out/cache/ProductCacheManagerImpl.java @@ -0,0 +1,30 @@ +package com.msa.commerce.monolith.product.adapter.out.cache; + +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.stereotype.Component; + +import com.msa.commerce.monolith.product.application.service.ProductCacheManager; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +public class ProductCacheManagerImpl implements ProductCacheManager { + + private static final String PRODUCT_CACHE = "products"; + + private static final String PRODUCT_LIST_CACHE = "productLists"; + + @Override + @CacheEvict(value = PRODUCT_CACHE, key = "#productId") + public void evictProduct(Long productId) { + log.debug("Evicting cache for product: {}", productId); + } + + @Override + @CacheEvict(value = PRODUCT_LIST_CACHE, allEntries = true) + public void evictProductLists() { + log.debug("Evicting all product list caches"); + } + +} diff --git a/monolith/src/main/java/com/msa/commerce/monolith/product/adapter/out/event/ProductEventPublisherImpl.java b/monolith/src/main/java/com/msa/commerce/monolith/product/adapter/out/event/ProductEventPublisherImpl.java new file mode 100644 index 0000000..c7a680c --- /dev/null +++ b/monolith/src/main/java/com/msa/commerce/monolith/product/adapter/out/event/ProductEventPublisherImpl.java @@ -0,0 +1,32 @@ +package com.msa.commerce.monolith.product.adapter.out.event; + +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Component; + +import com.msa.commerce.monolith.product.application.service.ProductEventPublisher; +import com.msa.commerce.monolith.product.domain.Product; +import com.msa.commerce.monolith.product.domain.event.ProductDeletedEvent; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +@RequiredArgsConstructor +public class ProductEventPublisherImpl implements ProductEventPublisher { + + private final ApplicationEventPublisher eventPublisher; + + @Override + public void publishProductDeletedEvent(Product product) { + ProductDeletedEvent event = new ProductDeletedEvent( + product.getId(), + product.getSku(), + product.getName(), + product.getDeletedAt() + ); + + eventPublisher.publishEvent(event); + } + +} diff --git a/monolith/src/main/java/com/msa/commerce/monolith/product/adapter/out/persistence/ProductJpaEntity.java b/monolith/src/main/java/com/msa/commerce/monolith/product/adapter/out/persistence/ProductJpaEntity.java index 7995e52..cff04d5 100644 --- a/monolith/src/main/java/com/msa/commerce/monolith/product/adapter/out/persistence/ProductJpaEntity.java +++ b/monolith/src/main/java/com/msa/commerce/monolith/product/adapter/out/persistence/ProductJpaEntity.java @@ -100,6 +100,9 @@ public class ProductJpaEntity { @Column(name = "updated_at", nullable = false) private LocalDateTime updatedAt; + @Column(name = "deleted_at") + private LocalDateTime deletedAt; + @Version @Column(nullable = false) private Long version = 1L; @@ -127,6 +130,7 @@ public static ProductJpaEntity fromDomainEntity(Product product) { jpaEntity.primaryImageUrl = product.getPrimaryImageUrl(); jpaEntity.createdAt = product.getCreatedAt(); jpaEntity.updatedAt = product.getUpdatedAt(); + jpaEntity.deletedAt = product.getDeletedAt(); jpaEntity.version = product.getVersion(); return jpaEntity; } @@ -177,6 +181,7 @@ public Product toDomainEntity() { this.primaryImageUrl, this.createdAt, this.updatedAt, + this.deletedAt, this.version ); } diff --git a/monolith/src/main/java/com/msa/commerce/monolith/product/application/port/in/ProductDeleteUseCase.java b/monolith/src/main/java/com/msa/commerce/monolith/product/application/port/in/ProductDeleteUseCase.java new file mode 100644 index 0000000..fb38b4b --- /dev/null +++ b/monolith/src/main/java/com/msa/commerce/monolith/product/application/port/in/ProductDeleteUseCase.java @@ -0,0 +1,7 @@ +package com.msa.commerce.monolith.product.application.port.in; + +public interface ProductDeleteUseCase { + + void deleteProduct(Long productId); + +} diff --git a/monolith/src/main/java/com/msa/commerce/monolith/product/application/service/ProductCacheManager.java b/monolith/src/main/java/com/msa/commerce/monolith/product/application/service/ProductCacheManager.java new file mode 100644 index 0000000..52f9d37 --- /dev/null +++ b/monolith/src/main/java/com/msa/commerce/monolith/product/application/service/ProductCacheManager.java @@ -0,0 +1,9 @@ +package com.msa.commerce.monolith.product.application.service; + +public interface ProductCacheManager { + + void evictProduct(Long productId); + + void evictProductLists(); + +} diff --git a/monolith/src/main/java/com/msa/commerce/monolith/product/application/service/ProductDeleteService.java b/monolith/src/main/java/com/msa/commerce/monolith/product/application/service/ProductDeleteService.java new file mode 100644 index 0000000..9bee4f5 --- /dev/null +++ b/monolith/src/main/java/com/msa/commerce/monolith/product/application/service/ProductDeleteService.java @@ -0,0 +1,65 @@ +package com.msa.commerce.monolith.product.application.service; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.msa.commerce.common.exception.ErrorCode; +import com.msa.commerce.common.exception.ResourceNotFoundException; +import com.msa.commerce.common.exception.ValidationException; +import com.msa.commerce.monolith.product.application.port.in.ProductDeleteUseCase; +import com.msa.commerce.monolith.product.application.port.out.ProductRepository; +import com.msa.commerce.monolith.product.domain.Product; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional +public class ProductDeleteService implements ProductDeleteUseCase { + + private final ProductRepository productRepository; + + private final ProductEventPublisher productEventPublisher; + + private final ProductCacheManager productCacheManager; + + @Override + public void deleteProduct(Long productId) { + Product product = productRepository.findById(productId) + .orElseThrow(() -> new ResourceNotFoundException("Product not found with id: " + productId, ErrorCode.PRODUCT_NOT_FOUND.getCode())); + + validateProductDeletable(product); + + product.softDelete(); + Product deletedProduct = productRepository.save(product); + + handleProductDeletion(productId); + + // TODO: 이거 확인 필요 + productCacheManager.evictProduct(productId); + productCacheManager.evictProductLists(); + + productEventPublisher.publishProductDeletedEvent(deletedProduct); + } + + private void validateProductDeletable(Product product) { + if (product.isDeleted()) { + throw new ValidationException("Product is already deleted", ErrorCode.PRODUCT_UPDATE_NOT_ALLOWED.getCode()); + } + + // TODO: 진행 중인 주문 확인 + + // TODO: 장바구니 포함 여부 확인 + } + + private void handleProductDeletion(Long productId) { + // TODO: 상품 이미지 상태 변경 (실제 파일은 유지, 상태만 변경) + // TODO: 이미지 서비스가 구현되면 호출 + + // TODO: 재고 정보 처리 (재고는 유지하되 상태를 비활성화) + // TODO: 재고 서비스가 구현되면 호출 + } + +} diff --git a/monolith/src/main/java/com/msa/commerce/monolith/product/application/service/ProductEventPublisher.java b/monolith/src/main/java/com/msa/commerce/monolith/product/application/service/ProductEventPublisher.java new file mode 100644 index 0000000..7113dff --- /dev/null +++ b/monolith/src/main/java/com/msa/commerce/monolith/product/application/service/ProductEventPublisher.java @@ -0,0 +1,9 @@ +package com.msa.commerce.monolith.product.application.service; + +import com.msa.commerce.monolith.product.domain.Product; + +public interface ProductEventPublisher { + + void publishProductDeletedEvent(Product product); + +} diff --git a/monolith/src/main/java/com/msa/commerce/monolith/product/domain/Product.java b/monolith/src/main/java/com/msa/commerce/monolith/product/domain/Product.java index ec5c690..60da02b 100644 --- a/monolith/src/main/java/com/msa/commerce/monolith/product/domain/Product.java +++ b/monolith/src/main/java/com/msa/commerce/monolith/product/domain/Product.java @@ -54,6 +54,8 @@ public class Product { private LocalDateTime updatedAt; + private LocalDateTime deletedAt; + private Long version; @Builder @@ -92,7 +94,7 @@ public static Product reconstitute(Long id, String sku, String name, String shor ProductStatus status, BigDecimal basePrice, BigDecimal salePrice, String currency, Integer weightGrams, Boolean requiresShipping, Boolean isTaxable, Boolean isFeatured, String slug, String searchTags, String primaryImageUrl, LocalDateTime createdAt, - LocalDateTime updatedAt, Long version) { + LocalDateTime updatedAt, LocalDateTime deletedAt, Long version) { Product product = new Product(); product.id = id; product.sku = sku; @@ -115,6 +117,7 @@ public static Product reconstitute(Long id, String sku, String name, String shor product.primaryImageUrl = primaryImageUrl; product.createdAt = createdAt; product.updatedAt = updatedAt; + product.deletedAt = deletedAt; product.version = version; return product; } @@ -206,12 +209,19 @@ public boolean canBeUpdatedBy(String userId) { public void deactivate() { this.status = ProductStatus.INACTIVE; - this.updatedAt = LocalDateTime.now(); } public void activate() { this.status = ProductStatus.ACTIVE; - this.updatedAt = LocalDateTime.now(); + } + + public void softDelete() { + this.status = ProductStatus.ARCHIVED; + this.deletedAt = LocalDateTime.now(); + } + + public boolean isDeleted() { + return this.deletedAt != null; } private void validateProduct(String sku, String name, BigDecimal basePrice) { diff --git a/monolith/src/main/java/com/msa/commerce/monolith/product/domain/event/ProductDeletedEvent.java b/monolith/src/main/java/com/msa/commerce/monolith/product/domain/event/ProductDeletedEvent.java new file mode 100644 index 0000000..73fd076 --- /dev/null +++ b/monolith/src/main/java/com/msa/commerce/monolith/product/domain/event/ProductDeletedEvent.java @@ -0,0 +1,22 @@ +package com.msa.commerce.monolith.product.domain.event; + +import java.time.LocalDateTime; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.ToString; + +@Getter +@AllArgsConstructor +@ToString +public class ProductDeletedEvent { + + private final Long productId; + + private final String sku; + + private final String productName; + + private final LocalDateTime deletedAt; + +} diff --git a/monolith/src/test/java/com/msa/commerce/monolith/product/adapter/in/web/ProductControllerDeleteTest.java b/monolith/src/test/java/com/msa/commerce/monolith/product/adapter/in/web/ProductControllerDeleteTest.java new file mode 100644 index 0000000..53bc683 --- /dev/null +++ b/monolith/src/test/java/com/msa/commerce/monolith/product/adapter/in/web/ProductControllerDeleteTest.java @@ -0,0 +1,120 @@ +package com.msa.commerce.monolith.product.adapter.in.web; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import com.msa.commerce.common.exception.ErrorCode; +import com.msa.commerce.common.exception.ResourceNotFoundException; +import com.msa.commerce.common.exception.ValidationException; +import com.msa.commerce.common.monitoring.MetricsCollector; +import com.msa.commerce.monolith.product.application.port.in.ProductCreateUseCase; +import com.msa.commerce.monolith.product.application.port.in.ProductDeleteUseCase; +import com.msa.commerce.monolith.product.application.port.in.ProductGetUseCase; +import com.msa.commerce.monolith.product.application.port.in.ProductUpdateUseCase; + +@WebMvcTest(ProductController.class) +@DisplayName("ProductController 삭제 API 단위 테스트") +class ProductControllerDeleteTest { + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private ProductCreateUseCase productCreateUseCase; + + @MockitoBean + private ProductGetUseCase productGetUseCase; + + @MockitoBean + private ProductUpdateUseCase productUpdateUseCase; + + @MockitoBean + private ProductDeleteUseCase productDeleteUseCase; + + @MockitoBean + private ProductMapper productMapper; + + @MockitoBean + private MetricsCollector metricsCollector; + + @Test + @DisplayName("DELETE /api/v1/products/{id} - 정상적으로 상품 삭제") + @WithMockUser + void deleteProduct_Success() throws Exception { + // given + Long productId = 1L; + doNothing().when(productDeleteUseCase).deleteProduct(productId); + + // when & then + mockMvc.perform(delete("/api/v1/products/{id}", productId) + .with(csrf())) + .andExpect(status().isNoContent()); + + verify(productDeleteUseCase, times(1)).deleteProduct(eq(productId)); + } + + @Test + @DisplayName("DELETE /api/v1/products/{id} - 존재하지 않는 상품 삭제 시 404 반환") + @WithMockUser + void deleteProduct_NotFound() throws Exception { + // given + Long productId = 999L; + doThrow(new ResourceNotFoundException("Product not found", ErrorCode.PRODUCT_NOT_FOUND.getCode())) + .when(productDeleteUseCase).deleteProduct(productId); + + // when & then + mockMvc.perform(delete("/api/v1/products/{id}", productId) + .with(csrf())) + .andExpect(status().isNotFound()); + + verify(productDeleteUseCase, times(1)).deleteProduct(eq(productId)); + } + + @Test + @DisplayName("DELETE /api/v1/products/{id} - 진행 중인 주문이 있는 상품 삭제 시 400 반환") + @WithMockUser + void deleteProduct_WithActiveOrders() throws Exception { + // given + Long productId = 1L; + doThrow(new ValidationException("Cannot delete product with active orders", + ErrorCode.PRODUCT_UPDATE_NOT_ALLOWED.getCode())) + .when(productDeleteUseCase).deleteProduct(productId); + + // when & then + mockMvc.perform(delete("/api/v1/products/{id}", productId) + .with(csrf())) + .andExpect(status().isBadRequest()); + + verify(productDeleteUseCase, times(1)).deleteProduct(eq(productId)); + } + + @Test + @DisplayName("DELETE /api/v1/products/{id} - 이미 삭제된 상품 삭제 시 400 반환") + @WithMockUser + void deleteProduct_AlreadyDeleted() throws Exception { + // given + Long productId = 1L; + doThrow(new ValidationException("Product is already deleted", + ErrorCode.PRODUCT_UPDATE_NOT_ALLOWED.getCode())) + .when(productDeleteUseCase).deleteProduct(productId); + + // when & then + mockMvc.perform(delete("/api/v1/products/{id}", productId) + .with(csrf())) + .andExpect(status().isBadRequest()); + + verify(productDeleteUseCase, times(1)).deleteProduct(eq(productId)); + } + +} diff --git a/monolith/src/test/java/com/msa/commerce/monolith/product/adapter/in/web/ProductControllerSearchTest.java b/monolith/src/test/java/com/msa/commerce/monolith/product/adapter/in/web/ProductControllerSearchTest.java index 8f33d79..82f0a61 100644 --- a/monolith/src/test/java/com/msa/commerce/monolith/product/adapter/in/web/ProductControllerSearchTest.java +++ b/monolith/src/test/java/com/msa/commerce/monolith/product/adapter/in/web/ProductControllerSearchTest.java @@ -21,6 +21,7 @@ import com.msa.commerce.common.monitoring.MetricsCollector; import com.msa.commerce.monolith.product.application.port.in.ProductCreateUseCase; +import com.msa.commerce.monolith.product.application.port.in.ProductDeleteUseCase; import com.msa.commerce.monolith.product.application.port.in.ProductGetUseCase; import com.msa.commerce.monolith.product.application.port.in.ProductPageResponse; import com.msa.commerce.monolith.product.application.port.in.ProductSearchCommand; @@ -46,6 +47,9 @@ class ProductControllerSearchTest { @MockitoBean private ProductUpdateUseCase productUpdateUseCase; + @MockitoBean + private ProductDeleteUseCase productDeleteUseCase; + @MockitoBean private ProductSearchUseCase productSearchUseCase; diff --git a/monolith/src/test/java/com/msa/commerce/monolith/product/adapter/in/web/ProductControllerTest.java b/monolith/src/test/java/com/msa/commerce/monolith/product/adapter/in/web/ProductControllerTest.java index 8305e69..e4bbcc8 100644 --- a/monolith/src/test/java/com/msa/commerce/monolith/product/adapter/in/web/ProductControllerTest.java +++ b/monolith/src/test/java/com/msa/commerce/monolith/product/adapter/in/web/ProductControllerTest.java @@ -22,9 +22,9 @@ import com.msa.commerce.common.exception.DuplicateResourceException; import com.msa.commerce.common.exception.ErrorCode; import com.msa.commerce.monolith.product.application.port.in.ProductCreateUseCase; +import com.msa.commerce.monolith.product.application.port.in.ProductDeleteUseCase; import com.msa.commerce.monolith.product.application.port.in.ProductGetUseCase; import com.msa.commerce.monolith.product.application.port.in.ProductResponse; -import com.msa.commerce.monolith.product.application.port.in.ProductSearchUseCase; import com.msa.commerce.monolith.product.application.port.in.ProductUpdateUseCase; import com.msa.commerce.monolith.product.domain.ProductStatus; @@ -44,7 +44,7 @@ class ProductControllerTest { private ProductUpdateUseCase productUpdateUseCase; @Mock - private ProductSearchUseCase productSearchUseCase; + private ProductDeleteUseCase productDeleteUseCase; @Mock private ProductMapper productMapper; @@ -53,7 +53,7 @@ class ProductControllerTest { void setUp() { mockMvc = MockMvcBuilders.standaloneSetup( new ProductController(productCreateUseCase, productGetUseCase, productUpdateUseCase, - productMapper)) + productDeleteUseCase, productMapper)) .setControllerAdvice(new com.msa.commerce.common.exception.GlobalExceptionHandler()) .build(); } diff --git a/monolith/src/test/java/com/msa/commerce/monolith/product/adapter/in/web/ProductControllerUpdateTest.java b/monolith/src/test/java/com/msa/commerce/monolith/product/adapter/in/web/ProductControllerUpdateTest.java index 3a5753d..1cd9c6c 100644 --- a/monolith/src/test/java/com/msa/commerce/monolith/product/adapter/in/web/ProductControllerUpdateTest.java +++ b/monolith/src/test/java/com/msa/commerce/monolith/product/adapter/in/web/ProductControllerUpdateTest.java @@ -22,9 +22,9 @@ import com.msa.commerce.common.exception.ErrorCode; import com.msa.commerce.common.exception.ResourceNotFoundException; import com.msa.commerce.monolith.product.application.port.in.ProductCreateUseCase; +import com.msa.commerce.monolith.product.application.port.in.ProductDeleteUseCase; import com.msa.commerce.monolith.product.application.port.in.ProductGetUseCase; import com.msa.commerce.monolith.product.application.port.in.ProductResponse; -import com.msa.commerce.monolith.product.application.port.in.ProductSearchUseCase; import com.msa.commerce.monolith.product.application.port.in.ProductUpdateCommand; import com.msa.commerce.monolith.product.application.port.in.ProductUpdateUseCase; import com.msa.commerce.monolith.product.domain.ProductStatus; @@ -45,25 +45,15 @@ class ProductControllerUpdateTest { private ProductUpdateUseCase productUpdateUseCase; @Mock - private ProductSearchUseCase productSearchUseCase; + private ProductDeleteUseCase productDeleteUseCase; @Mock private ProductMapper productMapper; - // private final ProductCreateUseCase productCreateUseCase; - // - // private final ProductGetUseCase productGetUseCase; - // - // private final ProductUpdateUseCase productUpdateUseCase; - // - // private final ProductSearchUseCase productSearchUseCase; - // - // private final ProductMapper productMapper; - @BeforeEach void setUp() { mockMvc = MockMvcBuilders.standaloneSetup( - new ProductController(productCreateUseCase, productGetUseCase, productUpdateUseCase, productMapper)) + new ProductController(productCreateUseCase, productGetUseCase, productUpdateUseCase, productDeleteUseCase, productMapper)) .setControllerAdvice(new com.msa.commerce.common.exception.GlobalExceptionHandler()) .build(); } diff --git a/monolith/src/test/java/com/msa/commerce/monolith/product/adapter/out/persistence/ProductJpaEntityTest.java b/monolith/src/test/java/com/msa/commerce/monolith/product/adapter/out/persistence/ProductJpaEntityTest.java index 357857e..52406a2 100644 --- a/monolith/src/test/java/com/msa/commerce/monolith/product/adapter/out/persistence/ProductJpaEntityTest.java +++ b/monolith/src/test/java/com/msa/commerce/monolith/product/adapter/out/persistence/ProductJpaEntityTest.java @@ -41,6 +41,7 @@ void fromDomainEntity_ShouldMapCorrectly() { "https://example.com/image.jpg", // primaryImageUrl now, // createdAt now, // updatedAt + null, // deletedAt 1L // version ); diff --git a/monolith/src/test/java/com/msa/commerce/monolith/product/application/service/ProductCreateServiceTest.java b/monolith/src/test/java/com/msa/commerce/monolith/product/application/service/ProductCreateServiceTest.java index 23553ad..77a5880 100644 --- a/monolith/src/test/java/com/msa/commerce/monolith/product/application/service/ProductCreateServiceTest.java +++ b/monolith/src/test/java/com/msa/commerce/monolith/product/application/service/ProductCreateServiceTest.java @@ -20,8 +20,8 @@ import com.msa.commerce.monolith.product.application.port.in.ProductResponse; import com.msa.commerce.monolith.product.application.port.out.ProductRepository; import com.msa.commerce.monolith.product.domain.Product; -import com.msa.commerce.monolith.product.domain.ProductType; import com.msa.commerce.monolith.product.domain.ProductStatus; +import com.msa.commerce.monolith.product.domain.ProductType; @ExtendWith(MockitoExtension.class) @DisplayName("ProductCreateService 테스트") @@ -75,6 +75,7 @@ void setUp() { null, // primaryImageUrl LocalDateTime.now(), // createdAt LocalDateTime.now(), // updatedAt + null, // deletedAt 1L // version ); } diff --git a/monolith/src/test/java/com/msa/commerce/monolith/product/application/service/ProductDeleteServiceTest.java b/monolith/src/test/java/com/msa/commerce/monolith/product/application/service/ProductDeleteServiceTest.java new file mode 100644 index 0000000..7d31220 --- /dev/null +++ b/monolith/src/test/java/com/msa/commerce/monolith/product/application/service/ProductDeleteServiceTest.java @@ -0,0 +1,156 @@ +package com.msa.commerce.monolith.product.application.service; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.Optional; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.msa.commerce.common.exception.ErrorCode; +import com.msa.commerce.common.exception.ResourceNotFoundException; +import com.msa.commerce.common.exception.ValidationException; +import com.msa.commerce.monolith.product.application.port.out.ProductRepository; +import com.msa.commerce.monolith.product.domain.Product; +import com.msa.commerce.monolith.product.domain.ProductStatus; +import com.msa.commerce.monolith.product.domain.ProductType; + +@ExtendWith(MockitoExtension.class) +@DisplayName("ProductDeleteService 단위 테스트") +class ProductDeleteServiceTest { + + @Mock + private ProductRepository productRepository; + + @Mock + private ProductEventPublisher productEventPublisher; + + @Mock + private ProductCacheManager productCacheManager; + + @InjectMocks + private ProductDeleteService productDeleteService; + + private Product testProduct; + + private Long productId; + + @BeforeEach + void setUp() { + productId = 1L; + testProduct = Product.reconstitute( + productId, + "TEST-SKU-001", + "Test Product", + "Short description", + "Detailed description", + 1L, + "Test Brand", + ProductType.PHYSICAL, + ProductStatus.ACTIVE, + new BigDecimal("10000"), + new BigDecimal("8000"), + "KRW", + 500, + true, + true, + false, + "test-product", + "test,product", + "http://example.com/image.jpg", + LocalDateTime.now().minusDays(10), + LocalDateTime.now().minusDays(1), + null, + 1L + ); + } + + @Test + @DisplayName("정상적으로 상품을 삭제할 수 있다") + void deleteProduct_Success() { + // given + when(productRepository.findById(productId)).thenReturn(Optional.of(testProduct)); + when(productRepository.save(any(Product.class))).thenReturn(testProduct); + + // when + productDeleteService.deleteProduct(productId); + + // then + verify(productRepository).findById(productId); + verify(productRepository).save(any(Product.class)); + verify(productCacheManager).evictProduct(productId); + verify(productCacheManager).evictProductLists(); + verify(productEventPublisher).publishProductDeletedEvent(any(Product.class)); + } + + @Test + @DisplayName("존재하지 않는 상품 삭제 시 ResourceNotFoundException 발생") + void deleteProduct_NotFound() { + // given + when(productRepository.findById(productId)).thenReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> productDeleteService.deleteProduct(productId)) + .isInstanceOf(ResourceNotFoundException.class) + .hasMessageContaining("Product not found with id: " + productId) + .extracting("errorCode") + .isEqualTo(ErrorCode.PRODUCT_NOT_FOUND.getCode()); + + verify(productRepository).findById(productId); + verify(productRepository, never()).save(any()); + verify(productCacheManager, never()).evictProduct(any()); + } + + @Test + @DisplayName("이미 삭제된 상품 삭제 시 ValidationException 발생") + void deleteProduct_AlreadyDeleted() { + // given + Product deletedProduct = Product.reconstitute( + productId, + "TEST-SKU-001", + "Test Product", + "Short description", + "Detailed description", + 1L, + "Test Brand", + ProductType.PHYSICAL, + ProductStatus.ARCHIVED, + new BigDecimal("10000"), + new BigDecimal("8000"), + "KRW", + 500, + true, + true, + false, + "test-product", + "test,product", + "http://example.com/image.jpg", + LocalDateTime.now().minusDays(10), + LocalDateTime.now().minusDays(1), + LocalDateTime.now().minusHours(1), // 이미 삭제됨 + 1L + ); + + when(productRepository.findById(productId)).thenReturn(Optional.of(deletedProduct)); + + // when & then + assertThatThrownBy(() -> productDeleteService.deleteProduct(productId)) + .isInstanceOf(ValidationException.class) + .hasMessageContaining("Product is already deleted") + .extracting("errorCode") + .isEqualTo(ErrorCode.PRODUCT_UPDATE_NOT_ALLOWED.getCode()); + + verify(productRepository).findById(productId); + verify(productRepository, never()).save(any()); + } + +} diff --git a/monolith/src/test/java/com/msa/commerce/monolith/product/application/service/ProductGetServiceTest.java b/monolith/src/test/java/com/msa/commerce/monolith/product/application/service/ProductGetServiceTest.java index adaf0ca..7d7eb21 100644 --- a/monolith/src/test/java/com/msa/commerce/monolith/product/application/service/ProductGetServiceTest.java +++ b/monolith/src/test/java/com/msa/commerce/monolith/product/application/service/ProductGetServiceTest.java @@ -3,7 +3,6 @@ import static org.assertj.core.api.Assertions.*; import static org.mockito.ArgumentMatchers.*; import static org.mockito.BDDMockito.*; -import static org.mockito.Mockito.*; import java.math.BigDecimal; import java.time.LocalDateTime; @@ -73,6 +72,7 @@ void setUp() { null, // primaryImageUrl now, // createdAt now, // updatedAt + null, // deletedAt 1L // version ); @@ -98,6 +98,7 @@ void setUp() { null, // primaryImageUrl now, // createdAt now, // updatedAt + null, // deletedAt 1L // version ); diff --git a/monolith/src/test/java/com/msa/commerce/monolith/product/application/service/ProductResponseMapperTest.java b/monolith/src/test/java/com/msa/commerce/monolith/product/application/service/ProductResponseMapperTest.java index e95039e..b01ee24 100644 --- a/monolith/src/test/java/com/msa/commerce/monolith/product/application/service/ProductResponseMapperTest.java +++ b/monolith/src/test/java/com/msa/commerce/monolith/product/application/service/ProductResponseMapperTest.java @@ -47,6 +47,7 @@ void setUp() { null, // primaryImageUrl LocalDateTime.now(), // createdAt LocalDateTime.now(), // updatedAt + null, // deletedAt 1L // version ); } @@ -108,6 +109,7 @@ void toResponse_ShouldHandleNullValues() { null, // primaryImageUrl null LocalDateTime.now(), // createdAt LocalDateTime.now(), // updatedAt + null, // deletedAt 1L // version ); diff --git a/monolith/src/test/java/com/msa/commerce/monolith/product/application/service/ProductSearchServiceTest.java b/monolith/src/test/java/com/msa/commerce/monolith/product/application/service/ProductSearchServiceTest.java index c7e3c84..854f2dd 100644 --- a/monolith/src/test/java/com/msa/commerce/monolith/product/application/service/ProductSearchServiceTest.java +++ b/monolith/src/test/java/com/msa/commerce/monolith/product/application/service/ProductSearchServiceTest.java @@ -75,6 +75,7 @@ void setUp() { null, // primaryImageUrl LocalDateTime.now().minusDays(1), // createdAt LocalDateTime.now(), // updatedAt + null, // deletedAt 1L // version ); @@ -100,6 +101,7 @@ void setUp() { null, // primaryImageUrl LocalDateTime.now().minusDays(2), // createdAt LocalDateTime.now(), // updatedAt + null, // deletedAt 1L // version ); diff --git a/monolith/src/test/java/com/msa/commerce/monolith/product/application/service/ProductUpdateServiceTest.java b/monolith/src/test/java/com/msa/commerce/monolith/product/application/service/ProductUpdateServiceTest.java index 23af267..ff1caa3 100644 --- a/monolith/src/test/java/com/msa/commerce/monolith/product/application/service/ProductUpdateServiceTest.java +++ b/monolith/src/test/java/com/msa/commerce/monolith/product/application/service/ProductUpdateServiceTest.java @@ -40,7 +40,7 @@ class ProductUpdateServiceTest { @Mock private ProductResponseMapper productResponseMapper; - + @Mock private Validator validator; @@ -77,6 +77,7 @@ void setUp() { null, // primaryImageUrl LocalDateTime.now().minusDays(1), // createdAt LocalDateTime.now().minusDays(1), // updatedAt + null, // deletedAt 1L // version ); @@ -155,7 +156,7 @@ void updateProduct_ProductNotUpdatable_ThrowsException() { 1L, null, ProductType.PHYSICAL, ProductStatus.ARCHIVED, new BigDecimal("10000"), null, "KRW", null, true, true, false, "archived-product", null, null, - LocalDateTime.now().minusDays(1), LocalDateTime.now().minusDays(1), 1L + LocalDateTime.now().minusDays(1), LocalDateTime.now().minusDays(1), null, 1L ); given(productRepository.findById(1L)).willReturn(Optional.of(archivedProduct)); diff --git a/monolith/src/test/java/com/msa/commerce/monolith/product/domain/ProductSoftDeleteTest.java b/monolith/src/test/java/com/msa/commerce/monolith/product/domain/ProductSoftDeleteTest.java new file mode 100644 index 0000000..d460e52 --- /dev/null +++ b/monolith/src/test/java/com/msa/commerce/monolith/product/domain/ProductSoftDeleteTest.java @@ -0,0 +1,154 @@ +package com.msa.commerce.monolith.product.domain; + +import static org.assertj.core.api.Assertions.*; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +@DisplayName("Product 소프트 삭제 단위 테스트") +class ProductSoftDeleteTest { + + private Product product; + + @BeforeEach + void setUp() { + product = Product.builder() + .sku("TEST-SKU-001") + .name("Test Product") + .shortDescription("Short description") + .description("Detailed description") + .categoryId(1L) + .brand("Test Brand") + .productType(ProductType.PHYSICAL) + .basePrice(new BigDecimal("10000")) + .salePrice(new BigDecimal("8000")) + .currency("KRW") + .weightGrams(500) + .requiresShipping(true) + .isTaxable(true) + .isFeatured(false) + .slug("test-product") + .searchTags("test,product") + .primaryImageUrl("http://example.com/image.jpg") + .build(); + } + + @Test + @DisplayName("softDelete 호출 시 상태가 ARCHIVED로 변경되고 deletedAt이 설정된다") + void softDelete_Success() { + // given + assertThat(product.getStatus()).isNotEqualTo(ProductStatus.ARCHIVED); + assertThat(product.getDeletedAt()).isNull(); + assertThat(product.isDeleted()).isFalse(); + + LocalDateTime beforeDelete = LocalDateTime.now(); + + // when + product.softDelete(); + + // then + assertThat(product.getStatus()).isEqualTo(ProductStatus.ARCHIVED); + assertThat(product.getDeletedAt()).isNotNull(); + assertThat(product.getDeletedAt()).isAfterOrEqualTo(beforeDelete); + assertThat(product.isDeleted()).isTrue(); + assertThat(product.getUpdatedAt()).isNotNull(); + } + + @Test + @DisplayName("이미 삭제된 상품도 다시 softDelete를 호출할 수 있다") + void softDelete_AlreadyDeleted() { + // given + product.softDelete(); + LocalDateTime firstDeletedAt = product.getDeletedAt(); + assertThat(product.isDeleted()).isTrue(); + + // when + product.softDelete(); // 다시 삭제 + + // then + assertThat(product.getStatus()).isEqualTo(ProductStatus.ARCHIVED); + assertThat(product.getDeletedAt()).isNotNull(); + assertThat(product.getDeletedAt()).isAfterOrEqualTo(firstDeletedAt); + assertThat(product.isDeleted()).isTrue(); + } + + @Test + @DisplayName("reconstitute로 생성한 삭제된 상품도 isDeleted가 true를 반환한다") + void reconstitute_WithDeletedAt() { + // given + LocalDateTime deletedAt = LocalDateTime.now().minusHours(1); + + // when + Product deletedProduct = Product.reconstitute( + 1L, + "TEST-SKU-001", + "Test Product", + "Short description", + "Detailed description", + 1L, + "Test Brand", + ProductType.PHYSICAL, + ProductStatus.ARCHIVED, + new BigDecimal("10000"), + new BigDecimal("8000"), + "KRW", + 500, + true, + true, + false, + "test-product", + "test,product", + "http://example.com/image.jpg", + LocalDateTime.now().minusDays(10), + LocalDateTime.now().minusDays(1), + deletedAt, + 1L + ); + + // then + assertThat(deletedProduct.isDeleted()).isTrue(); + assertThat(deletedProduct.getDeletedAt()).isEqualTo(deletedAt); + assertThat(deletedProduct.getStatus()).isEqualTo(ProductStatus.ARCHIVED); + } + + @Test + @DisplayName("reconstitute로 생성한 삭제되지 않은 상품은 isDeleted가 false를 반환한다") + void reconstitute_WithoutDeletedAt() { + // when + Product activeProduct = Product.reconstitute( + 1L, + "TEST-SKU-001", + "Test Product", + "Short description", + "Detailed description", + 1L, + "Test Brand", + ProductType.PHYSICAL, + ProductStatus.ACTIVE, + new BigDecimal("10000"), + new BigDecimal("8000"), + "KRW", + 500, + true, + true, + false, + "test-product", + "test,product", + "http://example.com/image.jpg", + LocalDateTime.now().minusDays(10), + LocalDateTime.now().minusDays(1), + null, // deletedAt이 null + 1L + ); + + // then + assertThat(activeProduct.isDeleted()).isFalse(); + assertThat(activeProduct.getDeletedAt()).isNull(); + assertThat(activeProduct.getStatus()).isEqualTo(ProductStatus.ACTIVE); + } + +} diff --git a/monolith/src/test/java/com/msa/commerce/monolith/product/domain/ProductUpdateTest.java b/monolith/src/test/java/com/msa/commerce/monolith/product/domain/ProductUpdateTest.java index 269f7c4..78cfdc5 100644 --- a/monolith/src/test/java/com/msa/commerce/monolith/product/domain/ProductUpdateTest.java +++ b/monolith/src/test/java/com/msa/commerce/monolith/product/domain/ProductUpdateTest.java @@ -42,6 +42,7 @@ void setUp() { null, // primaryImageUrl LocalDateTime.now().minusDays(1), // createdAt LocalDateTime.now().minusDays(1), // updatedAt + null, // deletedAt 1L // version ); } @@ -160,7 +161,7 @@ void isUpdatable_ActiveProduct_ReturnsTrue() { 1L, null, ProductType.PHYSICAL, ProductStatus.ACTIVE, new BigDecimal("10000"), null, "KRW", null, true, true, false, "active-product", null, null, - LocalDateTime.now(), LocalDateTime.now(), 1L + LocalDateTime.now(), LocalDateTime.now(), null, 1L ); // when & then @@ -176,7 +177,7 @@ void isUpdatable_ArchivedProduct_ReturnsFalse() { 1L, null, ProductType.PHYSICAL, ProductStatus.ARCHIVED, new BigDecimal("10000"), null, "KRW", null, true, true, false, "archived-product", null, null, - LocalDateTime.now(), LocalDateTime.now(), 1L + LocalDateTime.now(), LocalDateTime.now(), null, 1L ); // when & then From 37f7fb9d6432ec652388373939a91c71046c0d28 Mon Sep 17 00:00:00 2001 From: joel-you Date: Sat, 6 Sep 2025 03:30:46 +0900 Subject: [PATCH 2/9] feat(25): Add soft delete endpoint for products with validation and error handling --- docs/api-specs/monolith-openapi.yml | 79 +++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/docs/api-specs/monolith-openapi.yml b/docs/api-specs/monolith-openapi.yml index 0d9fc8a..0df309e 100644 --- a/docs/api-specs/monolith-openapi.yml +++ b/docs/api-specs/monolith-openapi.yml @@ -581,6 +581,85 @@ paths: path: "/api/v1/products/1" status: 500 + delete: + tags: + - Products + summary: Delete product + description: | + Delete a product using soft delete pattern. + + The product is not physically removed but marked as deleted with a timestamp. + Validates that the product doesn't have active orders or items in shopping carts. + + Business Rules: + - Product status changes to ARCHIVED + - Related data handling (images, inventory) + - Cache invalidation for deleted products + - Domain event publishing for product deletion + operationId: deleteProduct + parameters: + - name: id + in: path + required: true + description: Product ID to delete + schema: + type: integer + format: int64 + minimum: 1 + example: 1 + responses: + '204': + description: Product successfully deleted (soft delete) + '400': + description: Cannot delete product due to business constraints + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + examples: + product_in_use: + summary: Product has active orders + value: + message: "Product cannot be deleted due to active orders" + timestamp: "2024-01-15T14:25:00" + path: "/api/v1/products/1" + status: 400 + product_in_cart: + summary: Product exists in shopping carts + value: + message: "Product cannot be deleted as it exists in shopping carts" + timestamp: "2024-01-15T14:25:00" + path: "/api/v1/products/1" + status: 400 + '404': + description: Product not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + examples: + not_found: + summary: Product not found + value: + message: "Product not found with id: 1" + timestamp: "2024-01-15T14:25:00" + path: "/api/v1/products/1" + status: 404 + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + examples: + server_error: + summary: Server error + value: + message: "Internal server error occurred" + timestamp: "2024-01-15T14:25:00" + path: "/api/v1/products/1" + status: 500 + components: schemas: ProductCreateRequest: From 1b01bb489ab5293bac605ea02682c7992cb27db4 Mon Sep 17 00:00:00 2001 From: joel-you Date: Fri, 12 Sep 2025 18:40:29 +0900 Subject: [PATCH 3/9] =?UTF-8?q?RedisConfig=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../commerce/common/config/RedisConfig.java | 134 ++---------- .../common/config/cache/CacheDefinition.java | 94 --------- .../common/config/cache/CacheStrategy.java | 37 ---- .../cache/DomainCacheConfigurations.java | 154 -------------- .../config/cache/DomainCacheRegistry.java | 51 ----- .../common/config/RedisConfigTest.java | 191 +++++------------- .../config/cache/CacheDefinitionTest.java | 145 ------------- .../config/cache/CacheStrategyTest.java | 59 ------ .../config/cache/DomainCacheRegistryTest.java | 162 --------------- .../service/ProductGetService.java | 3 +- 10 files changed, 73 insertions(+), 957 deletions(-) delete mode 100644 common/src/main/java/com/msa/commerce/common/config/cache/CacheDefinition.java delete mode 100644 common/src/main/java/com/msa/commerce/common/config/cache/CacheStrategy.java delete mode 100644 common/src/main/java/com/msa/commerce/common/config/cache/DomainCacheConfigurations.java delete mode 100644 common/src/main/java/com/msa/commerce/common/config/cache/DomainCacheRegistry.java delete mode 100644 common/src/test/java/com/msa/commerce/common/config/cache/CacheDefinitionTest.java delete mode 100644 common/src/test/java/com/msa/commerce/common/config/cache/CacheStrategyTest.java delete mode 100644 common/src/test/java/com/msa/commerce/common/config/cache/DomainCacheRegistryTest.java diff --git a/common/src/main/java/com/msa/commerce/common/config/RedisConfig.java b/common/src/main/java/com/msa/commerce/common/config/RedisConfig.java index d7500ca..b3ab901 100644 --- a/common/src/main/java/com/msa/commerce/common/config/RedisConfig.java +++ b/common/src/main/java/com/msa/commerce/common/config/RedisConfig.java @@ -1,8 +1,7 @@ package com.msa.commerce.common.config; -import com.msa.commerce.common.config.cache.CacheDefinition; -import com.msa.commerce.common.config.cache.CacheStrategy; -import com.msa.commerce.common.config.cache.DomainCacheRegistry; +import java.time.Duration; + import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.cache.annotation.EnableCaching; import org.springframework.context.annotation.Bean; @@ -15,58 +14,25 @@ import org.springframework.data.redis.serializer.RedisSerializationContext; import org.springframework.data.redis.serializer.StringRedisSerializer; -import jakarta.annotation.PostConstruct; -import java.time.Duration; -import java.util.HashMap; -import java.util.Map; -import java.util.Set; - @Configuration @EnableCaching @ConditionalOnProperty(name = "spring.data.redis.host") public class RedisConfig { - public static final String DEFAULT_CACHE = "default"; - public static final String PRODUCT_CACHE = "product"; - public static final String PRODUCT_VIEW_COUNT_CACHE = "product-view-count"; - public static final String USER_CACHE = "user"; - public static final String ORDER_CACHE = "order"; - public static final String PAYMENT_CACHE = "payment"; - - @PostConstruct - public void registerDefaultCaches() { - DomainCacheRegistry.registerCaches( - CacheDefinition.of(PRODUCT_CACHE, CacheStrategy.LONG_TERM, - "Product basic information - master data that changes infrequently"), - CacheDefinition.of(PRODUCT_VIEW_COUNT_CACHE, CacheStrategy.SHORT_TERM, - "Product view count - real-time critical data"), - - CacheDefinition.of(USER_CACHE, CacheStrategy.MEDIUM_TERM, - "User profile information - session based data"), - - CacheDefinition.of(ORDER_CACHE, CacheStrategy.MEDIUM_TERM, - "Order information - transactional data with state changes"), - - CacheDefinition.of(PAYMENT_CACHE, CacheStrategy.SHORT_TERM, - "Payment information - rapidly changing status data"), - - CacheDefinition.of(DEFAULT_CACHE, CacheStrategy.DEFAULT, - "Default general purpose cache") - ); - } + private static final Duration DEFAULT_TTL = Duration.ofMinutes(30); @Bean public RedisTemplate redisTemplate(RedisConnectionFactory connectionFactory) { RedisTemplate template = new RedisTemplate<>(); template.setConnectionFactory(connectionFactory); - + template.setKeySerializer(new StringRedisSerializer()); template.setHashKeySerializer(new StringRedisSerializer()); - - GenericJackson2JsonRedisSerializer serializer = new GenericJackson2JsonRedisSerializer(); - template.setValueSerializer(serializer); - template.setHashValueSerializer(serializer); - + + GenericJackson2JsonRedisSerializer jsonSerializer = new GenericJackson2JsonRedisSerializer(); + template.setValueSerializer(jsonSerializer); + template.setHashValueSerializer(jsonSerializer); + template.afterPropertiesSet(); return template; } @@ -74,81 +40,17 @@ public RedisTemplate redisTemplate(RedisConnectionFactory connec @Bean public RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory) { RedisCacheConfiguration defaultConfig = RedisCacheConfiguration.defaultCacheConfig() - .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer())) - .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer())) - .entryTtl(CacheStrategy.DEFAULT.getTtl()) - .disableCachingNullValues(); - - Map cacheConfigurations = buildCacheConfigurations(defaultConfig); + .serializeKeysWith(RedisSerializationContext.SerializationPair + .fromSerializer(new StringRedisSerializer())) + .serializeValuesWith(RedisSerializationContext.SerializationPair + .fromSerializer(new GenericJackson2JsonRedisSerializer())) + .entryTtl(DEFAULT_TTL) + .disableCachingNullValues(); return RedisCacheManager.builder(connectionFactory) - .cacheDefaults(defaultConfig) - .withInitialCacheConfigurations(cacheConfigurations) - .transactionAware() - .build(); - } - - private Map buildCacheConfigurations(RedisCacheConfiguration defaultConfig) { - Map configurations = new HashMap<>(); - - DomainCacheRegistry.getAllCacheDefinitions().forEach((cacheName, cacheDefinition) -> { - RedisCacheConfiguration config = defaultConfig.entryTtl(cacheDefinition.getTtl()); - configurations.put(cacheName, config); - }); - - return configurations; + .cacheDefaults(defaultConfig) + .transactionAware() + .build(); } - public static class CacheNames { - public static final String DEFAULT = DEFAULT_CACHE; - public static final String PRODUCT = PRODUCT_CACHE; - public static final String PRODUCT_VIEW_COUNT = PRODUCT_VIEW_COUNT_CACHE; - public static final String USER = USER_CACHE; - public static final String ORDER = ORDER_CACHE; - public static final String PAYMENT = PAYMENT_CACHE; - - private CacheNames() { - } - } - - public static class CacheUtils { - - public static void registerCache(String cacheName, CacheStrategy strategy, String description) { - DomainCacheRegistry.registerCache( - CacheDefinition.of(cacheName, strategy, description) - ); - } - - public static CacheDefinition getCacheInfo(String cacheName) { - return DomainCacheRegistry.getCacheDefinition(cacheName); - } - - public static Set getAllCacheNames() { - return DomainCacheRegistry.getCacheNames(); - } - - public static boolean isCacheRegistered(String cacheName) { - return DomainCacheRegistry.isCacheRegistered(cacheName); - } - - public static String generateCacheName(String domain, String type) { - if (domain == null || domain.trim().isEmpty()) { - throw new IllegalArgumentException("Domain cannot be null or empty"); - } - if (type == null || type.trim().isEmpty()) { - throw new IllegalArgumentException("Type cannot be null or empty"); - } - return domain.toLowerCase().trim() + "-" + type.toLowerCase().trim(); - } - - public static String generateHierarchicalCacheName(String... parts) { - if (parts == null || parts.length == 0) { - throw new IllegalArgumentException("At least one part is required"); - } - return String.join(":", parts).toLowerCase(); - } - - private CacheUtils() { - } - } } diff --git a/common/src/main/java/com/msa/commerce/common/config/cache/CacheDefinition.java b/common/src/main/java/com/msa/commerce/common/config/cache/CacheDefinition.java deleted file mode 100644 index 38903a4..0000000 --- a/common/src/main/java/com/msa/commerce/common/config/cache/CacheDefinition.java +++ /dev/null @@ -1,94 +0,0 @@ -package com.msa.commerce.common.config.cache; - -import java.time.Duration; - -public class CacheDefinition { - - private final String name; - private final Duration ttl; - private final String description; - private final CacheStrategy strategy; - - private CacheDefinition(Builder builder) { - this.name = builder.name; - this.ttl = builder.ttl; - this.description = builder.description; - this.strategy = builder.strategy; - } - - public String getName() { - return name; - } - - public Duration getTtl() { - return ttl; - } - - public String getDescription() { - return description; - } - - public CacheStrategy getStrategy() { - return strategy; - } - - public static class Builder { - private String name; - private Duration ttl; - private String description; - private CacheStrategy strategy = CacheStrategy.DEFAULT; - - public Builder name(String name) { - this.name = name; - return this; - } - - public Builder strategy(CacheStrategy strategy) { - this.strategy = strategy; - this.ttl = strategy.getTtl(); - return this; - } - - public Builder ttl(Duration ttl) { - this.ttl = ttl; - return this; - } - - public Builder description(String description) { - this.description = description; - return this; - } - - public CacheDefinition build() { - if (name == null || name.trim().isEmpty()) { - throw new IllegalArgumentException("Cache name is required"); - } - if (ttl == null) { - ttl = strategy.getTtl(); - } - return new CacheDefinition(this); - } - } - - public static Builder builder() { - return new Builder(); - } - - public static CacheDefinition of(String name, CacheStrategy strategy, String description) { - return builder() - .name(name) - .strategy(strategy) - .description(description) - .build(); - } - - public static CacheDefinition of(String name, CacheStrategy strategy) { - return of(name, strategy, null); - } - - @Override - public String toString() { - return String.format("CacheDefinition{name='%s', ttl=%s, strategy=%s, description='%s'}", - name, ttl, strategy, description); - } -} diff --git a/common/src/main/java/com/msa/commerce/common/config/cache/CacheStrategy.java b/common/src/main/java/com/msa/commerce/common/config/cache/CacheStrategy.java deleted file mode 100644 index a4bdba3..0000000 --- a/common/src/main/java/com/msa/commerce/common/config/cache/CacheStrategy.java +++ /dev/null @@ -1,37 +0,0 @@ -package com.msa.commerce.common.config.cache; - -import java.time.Duration; - -public enum CacheStrategy { - - SHORT_TERM(Duration.ofMinutes(5)), - - MEDIUM_TERM(Duration.ofMinutes(15)), - - LONG_TERM(Duration.ofHours(1)), - - VERY_LONG_TERM(Duration.ofHours(6)), - - DAILY(Duration.ofDays(1)), - - DEFAULT(Duration.ofMinutes(30)); - - private final Duration ttl; - - CacheStrategy(Duration ttl) { - this.ttl = ttl; - } - - public Duration getTtl() { - return ttl; - } - - public long getTtlInMinutes() { - return ttl.toMinutes(); - } - - public long getTtlInSeconds() { - return ttl.getSeconds(); - } - -} diff --git a/common/src/main/java/com/msa/commerce/common/config/cache/DomainCacheConfigurations.java b/common/src/main/java/com/msa/commerce/common/config/cache/DomainCacheConfigurations.java deleted file mode 100644 index 6ad345f..0000000 --- a/common/src/main/java/com/msa/commerce/common/config/cache/DomainCacheConfigurations.java +++ /dev/null @@ -1,154 +0,0 @@ -package com.msa.commerce.common.config.cache; - -public class DomainCacheConfigurations { - - public static class ProductCaches { - - public static void register() { - DomainCacheRegistry.registerCaches( - CacheDefinition.of("product", CacheStrategy.LONG_TERM, - "Basic product information - master data"), - - CacheDefinition.of("product-view-count", CacheStrategy.SHORT_TERM, - "Product view count - real-time data"), - - CacheDefinition.of("product-category", CacheStrategy.VERY_LONG_TERM, - "Product category - rarely changed master data"), - - CacheDefinition.of("product-review-summary", CacheStrategy.MEDIUM_TERM, - "Product review summary - updated when reviews change"), - - CacheDefinition.of("product-stock-status", CacheStrategy.SHORT_TERM, - "Product stock status - real-time inventory data"), - - CacheDefinition.of("product:popular:by-brand", CacheStrategy.LONG_TERM, - "Popular products by brand - periodically updated aggregated data") - ); - } - } - - public static class UserCaches { - - public static void register() { - DomainCacheRegistry.registerCaches( - CacheDefinition.of("user", CacheStrategy.MEDIUM_TERM, - "User profile information - session based data"), - - CacheDefinition.of("user-permissions", CacheStrategy.LONG_TERM, - "User permissions - infrequently changed data"), - - CacheDefinition.of("user-session", CacheStrategy.MEDIUM_TERM, - "User session information - temporary session data"), - - CacheDefinition.of("user-preferences", CacheStrategy.LONG_TERM, - "User preferences - personal settings that change infrequently"), - - CacheDefinition.of("user:cart", CacheStrategy.MEDIUM_TERM, - "User cart - temporary session data"), - - CacheDefinition.of("user:activity:summary", CacheStrategy.SHORT_TERM, - "User activity summary - real-time activity data") - ); - } - } - - public static class OrderCaches { - - public static void register() { - DomainCacheRegistry.registerCaches( - CacheDefinition.of("order", CacheStrategy.MEDIUM_TERM, - "Order information - transactional data with state changes"), - - CacheDefinition.of("order-status", CacheStrategy.SHORT_TERM, - "Order status - rapidly changing status data"), - - CacheDefinition.of("order-delivery", CacheStrategy.SHORT_TERM, - "Order delivery information - real-time delivery status data"), - - CacheDefinition.of("order:stats:daily", CacheStrategy.DAILY, - "Daily order statistics - aggregated data refreshed daily"), - - CacheDefinition.of("order:recent:by-user", CacheStrategy.MEDIUM_TERM, - "Recent orders by user - user session based data") - ); - } - } - - public static class PaymentCaches { - - public static void register() { - DomainCacheRegistry.registerCaches( - CacheDefinition.of("payment", CacheStrategy.SHORT_TERM, - "Payment information - rapidly changing status data"), - - CacheDefinition.of("payment-status", CacheStrategy.SHORT_TERM, - "Payment status - real-time payment status data"), - - CacheDefinition.of("payment-methods", CacheStrategy.VERY_LONG_TERM, - "Payment methods - rarely changed configuration data"), - - CacheDefinition.of("payment:gateway:config", CacheStrategy.LONG_TERM, - "Payment gateway configuration - infrequently changed data"), - - CacheDefinition.of("payment:failure:stats", CacheStrategy.MEDIUM_TERM, - "Payment failure statistics - monitoring aggregated data") - ); - } - } - - public static class SystemCaches { - - public static void register() { - DomainCacheRegistry.registerCaches( - CacheDefinition.of("system-config", CacheStrategy.VERY_LONG_TERM, - "System configuration - rarely changed system settings"), - - CacheDefinition.of("country-codes", CacheStrategy.DAILY, - "Country codes - static master data"), - - CacheDefinition.of("exchange-rates", CacheStrategy.MEDIUM_TERM, - "Exchange rates - periodically updated external data"), - - CacheDefinition.of("api-rate-limits", CacheStrategy.MEDIUM_TERM, - "API rate limits - per-user API call limit data"), - - CacheDefinition.of("system:health", CacheStrategy.SHORT_TERM, - "System health status - real-time monitoring data") - ); - } - } - - public static void registerAllDomainCaches() { - ProductCaches.register(); - UserCaches.register(); - OrderCaches.register(); - PaymentCaches.register(); - SystemCaches.register(); - } - - public static class CacheNameHelpers { - - public static String userSpecific(String cacheType, Long userId) { - return String.format("user:%s:%d", cacheType, userId); - } - - public static String productSpecific(String cacheType, Long productId) { - return String.format("product:%s:%d", cacheType, productId); - } - - public static String orderSpecific(String cacheType, Long orderId) { - return String.format("order:%s:%d", cacheType, orderId); - } - - public static String dateSpecific(String cacheType, String date) { - return String.format("%s:date:%s", cacheType, date); - } - - public static String regionSpecific(String cacheType, String region) { - return String.format("%s:region:%s", cacheType, region); - } - - private CacheNameHelpers() { - } - } -} diff --git a/common/src/main/java/com/msa/commerce/common/config/cache/DomainCacheRegistry.java b/common/src/main/java/com/msa/commerce/common/config/cache/DomainCacheRegistry.java deleted file mode 100644 index 7ed8b3e..0000000 --- a/common/src/main/java/com/msa/commerce/common/config/cache/DomainCacheRegistry.java +++ /dev/null @@ -1,51 +0,0 @@ -package com.msa.commerce.common.config.cache; - -import java.util.HashMap; -import java.util.Map; -import java.util.Set; - -public class DomainCacheRegistry { - - private static final Map cacheDefinitions = new HashMap<>(); - - public static void registerCache(CacheDefinition cacheDefinition) { - if (cacheDefinition == null) { - throw new IllegalArgumentException("Cache definition cannot be null"); - } - cacheDefinitions.put(cacheDefinition.getName(), cacheDefinition); - } - - public static void registerCaches(CacheDefinition... cacheDefinitions) { - for (CacheDefinition definition : cacheDefinitions) { - registerCache(definition); - } - } - - public static CacheDefinition getCacheDefinition(String cacheName) { - return cacheDefinitions.get(cacheName); - } - - public static Map getAllCacheDefinitions() { - return new HashMap<>(cacheDefinitions); - } - - public static Set getCacheNames() { - return cacheDefinitions.keySet(); - } - - public static boolean isCacheRegistered(String cacheName) { - return cacheDefinitions.containsKey(cacheName); - } - - public static void unregisterCache(String cacheName) { - cacheDefinitions.remove(cacheName); - } - - public static void clearAll() { - cacheDefinitions.clear(); - } - - public static int size() { - return cacheDefinitions.size(); - } -} diff --git a/common/src/test/java/com/msa/commerce/common/config/RedisConfigTest.java b/common/src/test/java/com/msa/commerce/common/config/RedisConfigTest.java index 37330b6..d550b4d 100644 --- a/common/src/test/java/com/msa/commerce/common/config/RedisConfigTest.java +++ b/common/src/test/java/com/msa/commerce/common/config/RedisConfigTest.java @@ -1,161 +1,78 @@ package com.msa.commerce.common.config; -import com.msa.commerce.common.config.cache.CacheDefinition; -import com.msa.commerce.common.config.cache.CacheStrategy; -import com.msa.commerce.common.config.cache.DomainCacheRegistry; -import org.junit.jupiter.api.BeforeEach; +import static org.junit.jupiter.api.Assertions.*; + import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; - -import static org.junit.jupiter.api.Assertions.*; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.cache.annotation.EnableCaching; @DisplayName("Redis Configuration Tests") class RedisConfigTest { - @BeforeEach - void setUp() { - // Clear registry before each test to ensure clean state - DomainCacheRegistry.clearAll(); - } - - @Test - @DisplayName("Should define legacy cache names constants for backward compatibility") - void shouldDefineLegacyCacheNamesConstants() { - // Given & When & Then - assertEquals("default", RedisConfig.DEFAULT_CACHE); - assertEquals("product", RedisConfig.PRODUCT_CACHE); - assertEquals("product-view-count", RedisConfig.PRODUCT_VIEW_COUNT_CACHE); - assertEquals("user", RedisConfig.USER_CACHE); - assertEquals("order", RedisConfig.ORDER_CACHE); - assertEquals("payment", RedisConfig.PAYMENT_CACHE); - } - @Test - @DisplayName("Should define CacheNames utility class constants") - void shouldDefineCacheNamesUtilityClassConstants() { - // Given & When & Then - assertEquals("default", RedisConfig.CacheNames.DEFAULT); - assertEquals("product", RedisConfig.CacheNames.PRODUCT); - assertEquals("product-view-count", RedisConfig.CacheNames.PRODUCT_VIEW_COUNT); - assertEquals("user", RedisConfig.CacheNames.USER); - assertEquals("order", RedisConfig.CacheNames.ORDER); - assertEquals("payment", RedisConfig.CacheNames.PAYMENT); - } - - @Test - @DisplayName("Should ensure cache name consistency") - void shouldEnsureCacheNameConsistency() { - // Given & When & Then - assertEquals(RedisConfig.DEFAULT_CACHE, RedisConfig.CacheNames.DEFAULT); - assertEquals(RedisConfig.PRODUCT_CACHE, RedisConfig.CacheNames.PRODUCT); - assertEquals(RedisConfig.PRODUCT_VIEW_COUNT_CACHE, RedisConfig.CacheNames.PRODUCT_VIEW_COUNT); - assertEquals(RedisConfig.USER_CACHE, RedisConfig.CacheNames.USER); - assertEquals(RedisConfig.ORDER_CACHE, RedisConfig.CacheNames.ORDER); - assertEquals(RedisConfig.PAYMENT_CACHE, RedisConfig.CacheNames.PAYMENT); - } - - @Test - @DisplayName("Should validate conditional property annotation presence") - void shouldValidateConditionalPropertyAnnotationPresence() throws Exception { + @DisplayName("Should validate configuration annotations") + void shouldValidateConfigurationAnnotations() { // Given - RedisConfig config = new RedisConfig(); - - // When - Check if the class has ConditionalOnProperty annotation - boolean hasConditionalAnnotation = config.getClass().isAnnotationPresent( - org.springframework.boot.autoconfigure.condition.ConditionalOnProperty.class); - - // Then - assertTrue(hasConditionalAnnotation, "RedisConfig should have ConditionalOnProperty annotation"); - - // Verify the property name (annotation.name() returns an array) - org.springframework.boot.autoconfigure.condition.ConditionalOnProperty annotation = - config.getClass().getAnnotation( - org.springframework.boot.autoconfigure.condition.ConditionalOnProperty.class); - assertEquals("spring.data.redis.host", annotation.name()[0]); - } + Class configClass = RedisConfig.class; - @Test - @DisplayName("Should register default caches during PostConstruct") - void shouldRegisterDefaultCachesDuringPostConstruct() { - // Given - RedisConfig config = new RedisConfig(); - assertEquals(0, DomainCacheRegistry.size()); + // Then - Verify @Configuration annotation is present + assertTrue(configClass.isAnnotationPresent( + org.springframework.context.annotation.Configuration.class), + "RedisConfig should have @Configuration annotation"); + + // Verify @EnableCaching annotation is present + assertTrue(configClass.isAnnotationPresent(EnableCaching.class), + "RedisConfig should have @EnableCaching annotation"); - // When - config.registerDefaultCaches(); + // Verify @ConditionalOnProperty annotation + assertTrue(configClass.isAnnotationPresent(ConditionalOnProperty.class), + "RedisConfig should have @ConditionalOnProperty annotation"); - // Then - assertTrue(DomainCacheRegistry.size() > 0); - assertTrue(DomainCacheRegistry.isCacheRegistered("product")); - assertTrue(DomainCacheRegistry.isCacheRegistered("product-view-count")); - assertTrue(DomainCacheRegistry.isCacheRegistered("user")); - assertTrue(DomainCacheRegistry.isCacheRegistered("order")); - assertTrue(DomainCacheRegistry.isCacheRegistered("payment")); - assertTrue(DomainCacheRegistry.isCacheRegistered("default")); + ConditionalOnProperty conditionalAnnotation = + configClass.getAnnotation(ConditionalOnProperty.class); + assertEquals("spring.data.redis.host", conditionalAnnotation.name()[0], + "Should be conditional on spring.data.redis.host property"); } @Test - @DisplayName("Should verify default cache strategies") - void shouldVerifyDefaultCacheStrategies() { + @DisplayName("Should define RedisTemplate bean method") + void shouldDefineRedisTemplateBeanMethod() throws NoSuchMethodException { // Given - RedisConfig config = new RedisConfig(); - config.registerDefaultCaches(); - - // When & Then - CacheDefinition productCache = DomainCacheRegistry.getCacheDefinition("product"); - assertEquals(CacheStrategy.LONG_TERM, productCache.getStrategy()); - - CacheDefinition viewCountCache = DomainCacheRegistry.getCacheDefinition("product-view-count"); - assertEquals(CacheStrategy.SHORT_TERM, viewCountCache.getStrategy()); - - CacheDefinition userCache = DomainCacheRegistry.getCacheDefinition("user"); - assertEquals(CacheStrategy.MEDIUM_TERM, userCache.getStrategy()); - - CacheDefinition defaultCache = DomainCacheRegistry.getCacheDefinition("default"); - assertEquals(CacheStrategy.DEFAULT, defaultCache.getStrategy()); + Class configClass = RedisConfig.class; + + // Then - Verify redisTemplate method exists + assertDoesNotThrow(() -> + configClass.getDeclaredMethod("redisTemplate", + org.springframework.data.redis.connection.RedisConnectionFactory.class), + "RedisConfig should have redisTemplate method"); + + // Verify it has @Bean annotation + var method = configClass.getDeclaredMethod("redisTemplate", + org.springframework.data.redis.connection.RedisConnectionFactory.class); + assertTrue(method.isAnnotationPresent( + org.springframework.context.annotation.Bean.class), + "redisTemplate method should have @Bean annotation"); } @Test - @DisplayName("Should test CacheUtils helper methods") - void shouldTestCacheUtilsHelperMethods() { + @DisplayName("Should define CacheManager bean method") + void shouldDefineCacheManagerBeanMethod() throws NoSuchMethodException { // Given - RedisConfig config = new RedisConfig(); - config.registerDefaultCaches(); - - // When & Then - Test cache registration - RedisConfig.CacheUtils.registerCache("test-cache", CacheStrategy.LONG_TERM, "Test cache"); - assertTrue(RedisConfig.CacheUtils.isCacheRegistered("test-cache")); - assertNotNull(RedisConfig.CacheUtils.getCacheInfo("test-cache")); - - // Test cache name generation - assertEquals("user-profile", RedisConfig.CacheUtils.generateCacheName("User", "Profile")); - assertEquals("user:profile:basic", RedisConfig.CacheUtils.generateHierarchicalCacheName("User", "Profile", "Basic")); - - // Test all cache names retrieval - assertTrue(RedisConfig.CacheUtils.getAllCacheNames().size() > 0); - assertTrue(RedisConfig.CacheUtils.getAllCacheNames().contains("product")); + Class configClass = RedisConfig.class; + + // Then - Verify cacheManager method exists + assertDoesNotThrow(() -> + configClass.getDeclaredMethod("cacheManager", + org.springframework.data.redis.connection.RedisConnectionFactory.class), + "RedisConfig should have cacheManager method"); + + // Verify it has @Bean annotation + var method = configClass.getDeclaredMethod("cacheManager", + org.springframework.data.redis.connection.RedisConnectionFactory.class); + assertTrue(method.isAnnotationPresent( + org.springframework.context.annotation.Bean.class), + "cacheManager method should have @Bean annotation"); } - @Test - @DisplayName("Should test cache name generation edge cases") - void shouldTestCacheNameGenerationEdgeCases() { - // Given & When & Then - assertThrows(IllegalArgumentException.class, () -> - RedisConfig.CacheUtils.generateCacheName(null, "type")); - - assertThrows(IllegalArgumentException.class, () -> - RedisConfig.CacheUtils.generateCacheName("", "type")); - - assertThrows(IllegalArgumentException.class, () -> - RedisConfig.CacheUtils.generateCacheName("domain", null)); - - assertThrows(IllegalArgumentException.class, () -> - RedisConfig.CacheUtils.generateCacheName("domain", "")); - - assertThrows(IllegalArgumentException.class, () -> - RedisConfig.CacheUtils.generateHierarchicalCacheName()); - - assertThrows(IllegalArgumentException.class, () -> - RedisConfig.CacheUtils.generateHierarchicalCacheName(new String[]{})); - } } diff --git a/common/src/test/java/com/msa/commerce/common/config/cache/CacheDefinitionTest.java b/common/src/test/java/com/msa/commerce/common/config/cache/CacheDefinitionTest.java deleted file mode 100644 index a4b00ff..0000000 --- a/common/src/test/java/com/msa/commerce/common/config/cache/CacheDefinitionTest.java +++ /dev/null @@ -1,145 +0,0 @@ -package com.msa.commerce.common.config.cache; - -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; - -import java.time.Duration; - -import static org.junit.jupiter.api.Assertions.*; - -@DisplayName("Cache Definition Tests") -class CacheDefinitionTest { - - @Test - @DisplayName("Should create cache definition with builder") - void shouldCreateCacheDefinitionWithBuilder() { - // Given & When - CacheDefinition definition = CacheDefinition.builder() - .name("test-cache") - .strategy(CacheStrategy.LONG_TERM) - .description("Test cache") - .build(); - - // Then - assertEquals("test-cache", definition.getName()); - assertEquals(CacheStrategy.LONG_TERM.getTtl(), definition.getTtl()); - assertEquals(CacheStrategy.LONG_TERM, definition.getStrategy()); - assertEquals("Test cache", definition.getDescription()); - } - - @Test - @DisplayName("Should create cache definition with factory method") - void shouldCreateCacheDefinitionWithFactoryMethod() { - // Given & When - CacheDefinition definition = CacheDefinition.of("user-cache", CacheStrategy.MEDIUM_TERM, "User profile cache"); - - // Then - assertEquals("user-cache", definition.getName()); - assertEquals(CacheStrategy.MEDIUM_TERM.getTtl(), definition.getTtl()); - assertEquals(CacheStrategy.MEDIUM_TERM, definition.getStrategy()); - assertEquals("User profile cache", definition.getDescription()); - } - - @Test - @DisplayName("Should create cache definition without description") - void shouldCreateCacheDefinitionWithoutDescription() { - // Given & When - CacheDefinition definition = CacheDefinition.of("simple-cache", CacheStrategy.SHORT_TERM); - - // Then - assertEquals("simple-cache", definition.getName()); - assertEquals(CacheStrategy.SHORT_TERM.getTtl(), definition.getTtl()); - assertEquals(CacheStrategy.SHORT_TERM, definition.getStrategy()); - assertNull(definition.getDescription()); - } - - @Test - @DisplayName("Should use custom TTL when specified") - void shouldUseCustomTtlWhenSpecified() { - // Given - Duration customTtl = Duration.ofMinutes(45); - - // When - CacheDefinition definition = CacheDefinition.builder() - .name("custom-cache") - .strategy(CacheStrategy.LONG_TERM) - .ttl(customTtl) - .build(); - - // Then - assertEquals("custom-cache", definition.getName()); - assertEquals(customTtl, definition.getTtl()); - assertEquals(CacheStrategy.LONG_TERM, definition.getStrategy()); - } - - @Test - @DisplayName("Should use strategy TTL when custom TTL is not specified") - void shouldUseStrategyTtlWhenCustomTtlNotSpecified() { - // Given & When - CacheDefinition definition = CacheDefinition.builder() - .name("strategy-cache") - .strategy(CacheStrategy.VERY_LONG_TERM) - .build(); - - // Then - assertEquals("strategy-cache", definition.getName()); - assertEquals(CacheStrategy.VERY_LONG_TERM.getTtl(), definition.getTtl()); - assertEquals(CacheStrategy.VERY_LONG_TERM, definition.getStrategy()); - } - - @Test - @DisplayName("Should use default strategy when not specified") - void shouldUseDefaultStrategyWhenNotSpecified() { - // Given & When - CacheDefinition definition = CacheDefinition.builder() - .name("default-cache") - .build(); - - // Then - assertEquals("default-cache", definition.getName()); - assertEquals(CacheStrategy.DEFAULT.getTtl(), definition.getTtl()); - assertEquals(CacheStrategy.DEFAULT, definition.getStrategy()); - } - - @Test - @DisplayName("Should throw exception when name is null") - void shouldThrowExceptionWhenNameIsNull() { - // Given & When & Then - assertThrows(IllegalArgumentException.class, () -> - CacheDefinition.builder().build() - ); - } - - @Test - @DisplayName("Should throw exception when name is empty") - void shouldThrowExceptionWhenNameIsEmpty() { - // Given & When & Then - assertThrows(IllegalArgumentException.class, () -> - CacheDefinition.builder().name("").build() - ); - } - - @Test - @DisplayName("Should throw exception when name is blank") - void shouldThrowExceptionWhenNameIsBlank() { - // Given & When & Then - assertThrows(IllegalArgumentException.class, () -> - CacheDefinition.builder().name(" ").build() - ); - } - - @Test - @DisplayName("Should have meaningful toString representation") - void shouldHaveMeaningfulToStringRepresentation() { - // Given - CacheDefinition definition = CacheDefinition.of("test-cache", CacheStrategy.LONG_TERM, "Test description"); - - // When - String toString = definition.toString(); - - // Then - assertTrue(toString.contains("test-cache")); - assertTrue(toString.contains("LONG_TERM")); - assertTrue(toString.contains("Test description")); - } -} diff --git a/common/src/test/java/com/msa/commerce/common/config/cache/CacheStrategyTest.java b/common/src/test/java/com/msa/commerce/common/config/cache/CacheStrategyTest.java deleted file mode 100644 index 570d50d..0000000 --- a/common/src/test/java/com/msa/commerce/common/config/cache/CacheStrategyTest.java +++ /dev/null @@ -1,59 +0,0 @@ -package com.msa.commerce.common.config.cache; - -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; - -import java.time.Duration; - -import static org.junit.jupiter.api.Assertions.*; - -@DisplayName("Cache Strategy Tests") -class CacheStrategyTest { - - @Test - @DisplayName("Should define correct TTL values for each strategy") - void shouldDefineCorrectTtlValues() { - // Given & When & Then - assertEquals(Duration.ofMinutes(5), CacheStrategy.SHORT_TERM.getTtl()); - assertEquals(Duration.ofMinutes(15), CacheStrategy.MEDIUM_TERM.getTtl()); - assertEquals(Duration.ofHours(1), CacheStrategy.LONG_TERM.getTtl()); - assertEquals(Duration.ofHours(6), CacheStrategy.VERY_LONG_TERM.getTtl()); - assertEquals(Duration.ofDays(1), CacheStrategy.DAILY.getTtl()); - assertEquals(Duration.ofMinutes(30), CacheStrategy.DEFAULT.getTtl()); - } - - @Test - @DisplayName("Should convert TTL to minutes correctly") - void shouldConvertTtlToMinutesCorrectly() { - // Given & When & Then - assertEquals(5, CacheStrategy.SHORT_TERM.getTtlInMinutes()); - assertEquals(15, CacheStrategy.MEDIUM_TERM.getTtlInMinutes()); - assertEquals(60, CacheStrategy.LONG_TERM.getTtlInMinutes()); - assertEquals(360, CacheStrategy.VERY_LONG_TERM.getTtlInMinutes()); - assertEquals(1440, CacheStrategy.DAILY.getTtlInMinutes()); - assertEquals(30, CacheStrategy.DEFAULT.getTtlInMinutes()); - } - - @Test - @DisplayName("Should convert TTL to seconds correctly") - void shouldConvertTtlToSecondsCorrectly() { - // Given & When & Then - assertEquals(300, CacheStrategy.SHORT_TERM.getTtlInSeconds()); - assertEquals(900, CacheStrategy.MEDIUM_TERM.getTtlInSeconds()); - assertEquals(3600, CacheStrategy.LONG_TERM.getTtlInSeconds()); - assertEquals(21600, CacheStrategy.VERY_LONG_TERM.getTtlInSeconds()); - assertEquals(86400, CacheStrategy.DAILY.getTtlInSeconds()); - assertEquals(1800, CacheStrategy.DEFAULT.getTtlInSeconds()); - } - - @Test - @DisplayName("Should have ascending TTL values") - void shouldHaveAscendingTtlValues() { - // Given & When & Then - assertTrue(CacheStrategy.SHORT_TERM.getTtlInSeconds() < CacheStrategy.MEDIUM_TERM.getTtlInSeconds()); - assertTrue(CacheStrategy.MEDIUM_TERM.getTtlInSeconds() < CacheStrategy.DEFAULT.getTtlInSeconds()); - assertTrue(CacheStrategy.DEFAULT.getTtlInSeconds() < CacheStrategy.LONG_TERM.getTtlInSeconds()); - assertTrue(CacheStrategy.LONG_TERM.getTtlInSeconds() < CacheStrategy.VERY_LONG_TERM.getTtlInSeconds()); - assertTrue(CacheStrategy.VERY_LONG_TERM.getTtlInSeconds() < CacheStrategy.DAILY.getTtlInSeconds()); - } -} diff --git a/common/src/test/java/com/msa/commerce/common/config/cache/DomainCacheRegistryTest.java b/common/src/test/java/com/msa/commerce/common/config/cache/DomainCacheRegistryTest.java deleted file mode 100644 index c7881b5..0000000 --- a/common/src/test/java/com/msa/commerce/common/config/cache/DomainCacheRegistryTest.java +++ /dev/null @@ -1,162 +0,0 @@ -package com.msa.commerce.common.config.cache; - -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; - -import static org.junit.jupiter.api.Assertions.*; - -@DisplayName("Domain Cache Registry Tests") -class DomainCacheRegistryTest { - - @BeforeEach - void setUp() { - // Clear registry before each test - DomainCacheRegistry.clearAll(); - } - - @Test - @DisplayName("Should register cache definition") - void shouldRegisterCacheDefinition() { - // Given - CacheDefinition definition = CacheDefinition.of("test-cache", CacheStrategy.LONG_TERM); - - // When - DomainCacheRegistry.registerCache(definition); - - // Then - assertTrue(DomainCacheRegistry.isCacheRegistered("test-cache")); - assertEquals(definition, DomainCacheRegistry.getCacheDefinition("test-cache")); - assertEquals(1, DomainCacheRegistry.size()); - } - - @Test - @DisplayName("Should register multiple cache definitions") - void shouldRegisterMultipleCacheDefinitions() { - // Given - CacheDefinition cache1 = CacheDefinition.of("cache1", CacheStrategy.SHORT_TERM); - CacheDefinition cache2 = CacheDefinition.of("cache2", CacheStrategy.MEDIUM_TERM); - CacheDefinition cache3 = CacheDefinition.of("cache3", CacheStrategy.LONG_TERM); - - // When - DomainCacheRegistry.registerCaches(cache1, cache2, cache3); - - // Then - assertEquals(3, DomainCacheRegistry.size()); - assertTrue(DomainCacheRegistry.isCacheRegistered("cache1")); - assertTrue(DomainCacheRegistry.isCacheRegistered("cache2")); - assertTrue(DomainCacheRegistry.isCacheRegistered("cache3")); - } - - @Test - @DisplayName("Should return null for non-existent cache") - void shouldReturnNullForNonExistentCache() { - // Given & When - CacheDefinition definition = DomainCacheRegistry.getCacheDefinition("non-existent"); - - // Then - assertNull(definition); - assertFalse(DomainCacheRegistry.isCacheRegistered("non-existent")); - } - - @Test - @DisplayName("Should return all cache definitions") - void shouldReturnAllCacheDefinitions() { - // Given - CacheDefinition cache1 = CacheDefinition.of("cache1", CacheStrategy.SHORT_TERM); - CacheDefinition cache2 = CacheDefinition.of("cache2", CacheStrategy.MEDIUM_TERM); - DomainCacheRegistry.registerCaches(cache1, cache2); - - // When - var allDefinitions = DomainCacheRegistry.getAllCacheDefinitions(); - - // Then - assertEquals(2, allDefinitions.size()); - assertTrue(allDefinitions.containsKey("cache1")); - assertTrue(allDefinitions.containsKey("cache2")); - assertEquals(cache1, allDefinitions.get("cache1")); - assertEquals(cache2, allDefinitions.get("cache2")); - } - - @Test - @DisplayName("Should return all cache names") - void shouldReturnAllCacheNames() { - // Given - DomainCacheRegistry.registerCaches( - CacheDefinition.of("user-cache", CacheStrategy.MEDIUM_TERM), - CacheDefinition.of("product-cache", CacheStrategy.LONG_TERM) - ); - - // When - var cacheNames = DomainCacheRegistry.getCacheNames(); - - // Then - assertEquals(2, cacheNames.size()); - assertTrue(cacheNames.contains("user-cache")); - assertTrue(cacheNames.contains("product-cache")); - } - - @Test - @DisplayName("Should unregister cache") - void shouldUnregisterCache() { - // Given - CacheDefinition definition = CacheDefinition.of("temp-cache", CacheStrategy.SHORT_TERM); - DomainCacheRegistry.registerCache(definition); - assertTrue(DomainCacheRegistry.isCacheRegistered("temp-cache")); - - // When - DomainCacheRegistry.unregisterCache("temp-cache"); - - // Then - assertFalse(DomainCacheRegistry.isCacheRegistered("temp-cache")); - assertNull(DomainCacheRegistry.getCacheDefinition("temp-cache")); - assertEquals(0, DomainCacheRegistry.size()); - } - - @Test - @DisplayName("Should clear all caches") - void shouldClearAllCaches() { - // Given - DomainCacheRegistry.registerCaches( - CacheDefinition.of("cache1", CacheStrategy.SHORT_TERM), - CacheDefinition.of("cache2", CacheStrategy.MEDIUM_TERM), - CacheDefinition.of("cache3", CacheStrategy.LONG_TERM) - ); - assertEquals(3, DomainCacheRegistry.size()); - - // When - DomainCacheRegistry.clearAll(); - - // Then - assertEquals(0, DomainCacheRegistry.size()); - assertTrue(DomainCacheRegistry.getCacheNames().isEmpty()); - } - - @Test - @DisplayName("Should throw exception when registering null cache definition") - void shouldThrowExceptionWhenRegisteringNullCacheDefinition() { - // Given & When & Then - assertThrows(IllegalArgumentException.class, () -> - DomainCacheRegistry.registerCache(null) - ); - } - - @Test - @DisplayName("Should overwrite existing cache definition with same name") - void shouldOverwriteExistingCacheDefinitionWithSameName() { - // Given - CacheDefinition original = CacheDefinition.of("same-cache", CacheStrategy.SHORT_TERM, "Original"); - CacheDefinition updated = CacheDefinition.of("same-cache", CacheStrategy.LONG_TERM, "Updated"); - - DomainCacheRegistry.registerCache(original); - assertEquals(original, DomainCacheRegistry.getCacheDefinition("same-cache")); - - // When - DomainCacheRegistry.registerCache(updated); - - // Then - assertEquals(updated, DomainCacheRegistry.getCacheDefinition("same-cache")); - assertEquals(1, DomainCacheRegistry.size()); - assertEquals("Updated", DomainCacheRegistry.getCacheDefinition("same-cache").getDescription()); - } -} diff --git a/monolith/src/main/java/com/msa/commerce/monolith/product/application/service/ProductGetService.java b/monolith/src/main/java/com/msa/commerce/monolith/product/application/service/ProductGetService.java index d04a3ea..d80ee45 100644 --- a/monolith/src/main/java/com/msa/commerce/monolith/product/application/service/ProductGetService.java +++ b/monolith/src/main/java/com/msa/commerce/monolith/product/application/service/ProductGetService.java @@ -4,7 +4,6 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import com.msa.commerce.common.config.RedisConfig; import com.msa.commerce.common.exception.ResourceNotFoundException; import com.msa.commerce.monolith.product.application.port.in.ProductGetUseCase; import com.msa.commerce.monolith.product.application.port.in.ProductPageResponse; @@ -36,7 +35,7 @@ public ProductResponse getProduct(Long productId) { } @Override - @Cacheable(value = RedisConfig.PRODUCT_CACHE, key = "#productId", condition = "#increaseViewCount == false") + @Cacheable(value = "product", key = "#productId", condition = "#increaseViewCount == false") public ProductResponse getProduct(Long productId, boolean increaseViewCount) { Product product = productRepository.findById(productId) .orElseThrow( From e6c727c180a63cbd9cea9a9d180fdbdc42f3fa25 Mon Sep 17 00:00:00 2001 From: joel-you Date: Fri, 12 Sep 2025 19:09:40 +0900 Subject: [PATCH 4/9] =?UTF-8?q?Annotation=20=EA=B8=B0=EB=B0=98=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../out/cache/ProductCacheManagerImpl.java | 30 ------------------- .../service/ProductCacheManager.java | 9 ------ .../service/ProductCreateService.java | 2 ++ .../service/ProductDeleteService.java | 12 ++++---- .../service/ProductUpdateService.java | 11 +++---- .../service/ProductDeleteServiceTest.java | 6 ---- 6 files changed, 14 insertions(+), 56 deletions(-) delete mode 100644 monolith/src/main/java/com/msa/commerce/monolith/product/adapter/out/cache/ProductCacheManagerImpl.java delete mode 100644 monolith/src/main/java/com/msa/commerce/monolith/product/application/service/ProductCacheManager.java diff --git a/monolith/src/main/java/com/msa/commerce/monolith/product/adapter/out/cache/ProductCacheManagerImpl.java b/monolith/src/main/java/com/msa/commerce/monolith/product/adapter/out/cache/ProductCacheManagerImpl.java deleted file mode 100644 index 80ed932..0000000 --- a/monolith/src/main/java/com/msa/commerce/monolith/product/adapter/out/cache/ProductCacheManagerImpl.java +++ /dev/null @@ -1,30 +0,0 @@ -package com.msa.commerce.monolith.product.adapter.out.cache; - -import org.springframework.cache.annotation.CacheEvict; -import org.springframework.stereotype.Component; - -import com.msa.commerce.monolith.product.application.service.ProductCacheManager; - -import lombok.extern.slf4j.Slf4j; - -@Slf4j -@Component -public class ProductCacheManagerImpl implements ProductCacheManager { - - private static final String PRODUCT_CACHE = "products"; - - private static final String PRODUCT_LIST_CACHE = "productLists"; - - @Override - @CacheEvict(value = PRODUCT_CACHE, key = "#productId") - public void evictProduct(Long productId) { - log.debug("Evicting cache for product: {}", productId); - } - - @Override - @CacheEvict(value = PRODUCT_LIST_CACHE, allEntries = true) - public void evictProductLists() { - log.debug("Evicting all product list caches"); - } - -} diff --git a/monolith/src/main/java/com/msa/commerce/monolith/product/application/service/ProductCacheManager.java b/monolith/src/main/java/com/msa/commerce/monolith/product/application/service/ProductCacheManager.java deleted file mode 100644 index 52f9d37..0000000 --- a/monolith/src/main/java/com/msa/commerce/monolith/product/application/service/ProductCacheManager.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.msa.commerce.monolith.product.application.service; - -public interface ProductCacheManager { - - void evictProduct(Long productId); - - void evictProductLists(); - -} diff --git a/monolith/src/main/java/com/msa/commerce/monolith/product/application/service/ProductCreateService.java b/monolith/src/main/java/com/msa/commerce/monolith/product/application/service/ProductCreateService.java index 6ac4f87..6825388 100644 --- a/monolith/src/main/java/com/msa/commerce/monolith/product/application/service/ProductCreateService.java +++ b/monolith/src/main/java/com/msa/commerce/monolith/product/application/service/ProductCreateService.java @@ -1,5 +1,6 @@ package com.msa.commerce.monolith.product.application.service; +import org.springframework.cache.annotation.CacheEvict; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -25,6 +26,7 @@ public class ProductCreateService implements ProductCreateUseCase { @Override @ValidateCommand(errorPrefix = "Product creation validation failed") + @CacheEvict(value = "products", allEntries = true) public ProductResponse createProduct(ProductCreateCommand command) { validateCommand(command); validateDuplicateSku(command.getSku()); diff --git a/monolith/src/main/java/com/msa/commerce/monolith/product/application/service/ProductDeleteService.java b/monolith/src/main/java/com/msa/commerce/monolith/product/application/service/ProductDeleteService.java index 9bee4f5..3651c2a 100644 --- a/monolith/src/main/java/com/msa/commerce/monolith/product/application/service/ProductDeleteService.java +++ b/monolith/src/main/java/com/msa/commerce/monolith/product/application/service/ProductDeleteService.java @@ -1,5 +1,7 @@ package com.msa.commerce.monolith.product.application.service; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.Caching; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -23,9 +25,11 @@ public class ProductDeleteService implements ProductDeleteUseCase { private final ProductEventPublisher productEventPublisher; - private final ProductCacheManager productCacheManager; - @Override + @Caching(evict = { + @CacheEvict(value = "product", key = "#productId"), + @CacheEvict(value = "products", allEntries = true) + }) public void deleteProduct(Long productId) { Product product = productRepository.findById(productId) .orElseThrow(() -> new ResourceNotFoundException("Product not found with id: " + productId, ErrorCode.PRODUCT_NOT_FOUND.getCode())); @@ -37,10 +41,6 @@ public void deleteProduct(Long productId) { handleProductDeletion(productId); - // TODO: 이거 확인 필요 - productCacheManager.evictProduct(productId); - productCacheManager.evictProductLists(); - productEventPublisher.publishProductDeletedEvent(deletedProduct); } diff --git a/monolith/src/main/java/com/msa/commerce/monolith/product/application/service/ProductUpdateService.java b/monolith/src/main/java/com/msa/commerce/monolith/product/application/service/ProductUpdateService.java index 749cd86..917bc11 100644 --- a/monolith/src/main/java/com/msa/commerce/monolith/product/application/service/ProductUpdateService.java +++ b/monolith/src/main/java/com/msa/commerce/monolith/product/application/service/ProductUpdateService.java @@ -3,6 +3,8 @@ import java.util.Optional; import java.util.Set; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.Caching; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -36,6 +38,10 @@ public class ProductUpdateService implements ProductUpdateUseCase { @Override @ValidateCommand(errorPrefix = "Product update validation failed") + @Caching(evict = { + @CacheEvict(value = "product", key = "#command.productId"), + @CacheEvict(value = "products", allEntries = true) + }) public ProductResponse updateProduct(ProductUpdateCommand command) { validateCommand(command); Product existingProduct = findAndValidateProduct(command.getProductId()); @@ -103,7 +109,6 @@ private void updateProductData(Product product, ProductUpdateCommand command) { private void performPostUpdateOperations(Product updatedProduct, ProductUpdateCommand command, Product originalProduct) { - invalidateProductCache(updatedProduct.getId()); logProductChanges(originalProduct, updatedProduct, command); } @@ -154,10 +159,6 @@ private void appendFieldIfNotNull(StringBuilder description, Object field, Strin } } - private void invalidateProductCache(Long productId) { - log.debug("Invalidating cache for product ID: {}", productId); - log.info("Cache invalidation completed for product ID: {}", productId); - } private void validateCommand(ProductUpdateCommand command) { Set> violations = validator.validate(command); diff --git a/monolith/src/test/java/com/msa/commerce/monolith/product/application/service/ProductDeleteServiceTest.java b/monolith/src/test/java/com/msa/commerce/monolith/product/application/service/ProductDeleteServiceTest.java index 7d31220..2d79471 100644 --- a/monolith/src/test/java/com/msa/commerce/monolith/product/application/service/ProductDeleteServiceTest.java +++ b/monolith/src/test/java/com/msa/commerce/monolith/product/application/service/ProductDeleteServiceTest.java @@ -34,9 +34,6 @@ class ProductDeleteServiceTest { @Mock private ProductEventPublisher productEventPublisher; - @Mock - private ProductCacheManager productCacheManager; - @InjectMocks private ProductDeleteService productDeleteService; @@ -87,8 +84,6 @@ void deleteProduct_Success() { // then verify(productRepository).findById(productId); verify(productRepository).save(any(Product.class)); - verify(productCacheManager).evictProduct(productId); - verify(productCacheManager).evictProductLists(); verify(productEventPublisher).publishProductDeletedEvent(any(Product.class)); } @@ -107,7 +102,6 @@ void deleteProduct_NotFound() { verify(productRepository).findById(productId); verify(productRepository, never()).save(any()); - verify(productCacheManager, never()).evictProduct(any()); } @Test From 5903c463309e2320e44be26889f648d094c76b03 Mon Sep 17 00:00:00 2001 From: joel-you Date: Sat, 13 Sep 2025 03:25:02 +0900 Subject: [PATCH 5/9] feat(25): Integrate application event publishing for product creation, update, and deletion with cache eviction --- .../service/ProductCreateService.java | 21 +++-- .../service/ProductDeleteService.java | 8 +- .../service/ProductUpdateService.java | 13 ++- .../product/domain/event/ProductEvent.java | 70 ++++++++++++++++ .../event/ProductEventListener.java | 84 +++++++++++++++++++ .../event/ProductEventPublisherImpl.java | 27 ++++++ .../service/ProductCreateServiceTest.java | 4 + .../service/ProductDeleteServiceTest.java | 6 +- .../service/ProductUpdateServiceTest.java | 4 + 9 files changed, 226 insertions(+), 11 deletions(-) create mode 100644 monolith/src/main/java/com/msa/commerce/monolith/product/domain/event/ProductEvent.java create mode 100644 monolith/src/main/java/com/msa/commerce/monolith/product/infrastructure/event/ProductEventListener.java create mode 100644 monolith/src/main/java/com/msa/commerce/monolith/product/infrastructure/event/ProductEventPublisherImpl.java diff --git a/monolith/src/main/java/com/msa/commerce/monolith/product/application/service/ProductCreateService.java b/monolith/src/main/java/com/msa/commerce/monolith/product/application/service/ProductCreateService.java index 6825388..55cbf4f 100644 --- a/monolith/src/main/java/com/msa/commerce/monolith/product/application/service/ProductCreateService.java +++ b/monolith/src/main/java/com/msa/commerce/monolith/product/application/service/ProductCreateService.java @@ -1,6 +1,7 @@ package com.msa.commerce.monolith.product.application.service; import org.springframework.cache.annotation.CacheEvict; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -12,6 +13,7 @@ import com.msa.commerce.monolith.product.application.port.in.ProductResponse; import com.msa.commerce.monolith.product.application.port.out.ProductRepository; import com.msa.commerce.monolith.product.domain.Product; +import com.msa.commerce.monolith.product.domain.event.ProductEvent; import lombok.RequiredArgsConstructor; @@ -24,6 +26,8 @@ public class ProductCreateService implements ProductCreateUseCase { private final ProductResponseMapper productResponseMapper; + private final ApplicationEventPublisher applicationEventPublisher; + @Override @ValidateCommand(errorPrefix = "Product creation validation failed") @CacheEvict(value = "products", allEntries = true) @@ -55,6 +59,13 @@ private ProductResponse executeProductCreation(ProductCreateCommand command) { .build(); Product savedProduct = productRepository.save(product); + + // 통합 이벤트 발행 (트랜잭션 커밋 후 처리됨) + // 캐시 무효화를 한 번에 처리 + applicationEventPublisher.publishEvent( + ProductEvent.productCreated(savedProduct) + ); + return productResponseMapper.toResponse(savedProduct); } @@ -72,23 +83,23 @@ private void validateCommand(ProductCreateCommand command) { if (command.getSku() == null || command.getSku().trim().isEmpty()) { throw new IllegalArgumentException("SKU is required"); } - + if (command.getName() == null || command.getName().trim().isEmpty()) { throw new IllegalArgumentException("Product name is required."); } - + if (command.getBasePrice() == null) { throw new IllegalArgumentException("Base price must be greater than 0."); } - + if (command.getBasePrice().signum() <= 0) { throw new IllegalArgumentException("Base price must be greater than 0."); } - + if (command.getSlug() == null || command.getSlug().trim().isEmpty()) { throw new IllegalArgumentException("Slug is required"); } - + // Optional validation for categoryId - based on business logic, it might be required // The test expects exception when categoryId is null, but the annotation doesn't mark it as @NotNull // Let's check if this validation is needed based on the failing test diff --git a/monolith/src/main/java/com/msa/commerce/monolith/product/application/service/ProductDeleteService.java b/monolith/src/main/java/com/msa/commerce/monolith/product/application/service/ProductDeleteService.java index 3651c2a..a252be4 100644 --- a/monolith/src/main/java/com/msa/commerce/monolith/product/application/service/ProductDeleteService.java +++ b/monolith/src/main/java/com/msa/commerce/monolith/product/application/service/ProductDeleteService.java @@ -2,6 +2,7 @@ import org.springframework.cache.annotation.CacheEvict; import org.springframework.cache.annotation.Caching; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -11,6 +12,7 @@ import com.msa.commerce.monolith.product.application.port.in.ProductDeleteUseCase; import com.msa.commerce.monolith.product.application.port.out.ProductRepository; import com.msa.commerce.monolith.product.domain.Product; +import com.msa.commerce.monolith.product.domain.event.ProductEvent; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -23,7 +25,7 @@ public class ProductDeleteService implements ProductDeleteUseCase { private final ProductRepository productRepository; - private final ProductEventPublisher productEventPublisher; + private final ApplicationEventPublisher applicationEventPublisher; @Override @Caching(evict = { @@ -41,7 +43,9 @@ public void deleteProduct(Long productId) { handleProductDeletion(productId); - productEventPublisher.publishProductDeletedEvent(deletedProduct); + applicationEventPublisher.publishEvent( + ProductEvent.productDeleted(deletedProduct) + ); } private void validateProductDeletable(Product product) { diff --git a/monolith/src/main/java/com/msa/commerce/monolith/product/application/service/ProductUpdateService.java b/monolith/src/main/java/com/msa/commerce/monolith/product/application/service/ProductUpdateService.java index 917bc11..8d522bf 100644 --- a/monolith/src/main/java/com/msa/commerce/monolith/product/application/service/ProductUpdateService.java +++ b/monolith/src/main/java/com/msa/commerce/monolith/product/application/service/ProductUpdateService.java @@ -5,6 +5,7 @@ import org.springframework.cache.annotation.CacheEvict; import org.springframework.cache.annotation.Caching; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -18,6 +19,7 @@ import com.msa.commerce.monolith.product.application.port.in.ProductUpdateUseCase; import com.msa.commerce.monolith.product.application.port.out.ProductRepository; import com.msa.commerce.monolith.product.domain.Product; +import com.msa.commerce.monolith.product.domain.event.ProductEvent; import jakarta.validation.ConstraintViolation; import jakarta.validation.Validator; @@ -33,9 +35,11 @@ public class ProductUpdateService implements ProductUpdateUseCase { private final ProductRepository productRepository; private final ProductResponseMapper productResponseMapper; - + private final Validator validator; + private final ApplicationEventPublisher applicationEventPublisher; + @Override @ValidateCommand(errorPrefix = "Product update validation failed") @Caching(evict = { @@ -53,6 +57,12 @@ public ProductResponse updateProduct(ProductUpdateCommand command) { performPostUpdateOperations(updatedProduct, command, existingProduct); + // 통합 이벤트 발행 (트랜잭션 커밋 후 처리됨) + // 캐시 무효화를 한 번에 처리 + applicationEventPublisher.publishEvent( + ProductEvent.productUpdated(updatedProduct) + ); + log.info("Product updated successfully with ID: {}", updatedProduct.getId()); return productResponseMapper.toResponse(updatedProduct); } @@ -159,7 +169,6 @@ private void appendFieldIfNotNull(StringBuilder description, Object field, Strin } } - private void validateCommand(ProductUpdateCommand command) { Set> violations = validator.validate(command); if (!violations.isEmpty()) { diff --git a/monolith/src/main/java/com/msa/commerce/monolith/product/domain/event/ProductEvent.java b/monolith/src/main/java/com/msa/commerce/monolith/product/domain/event/ProductEvent.java new file mode 100644 index 0000000..5083c0f --- /dev/null +++ b/monolith/src/main/java/com/msa/commerce/monolith/product/domain/event/ProductEvent.java @@ -0,0 +1,70 @@ +package com.msa.commerce.monolith.product.domain.event; + +import java.util.EnumSet; +import java.util.Set; + +import com.msa.commerce.monolith.product.domain.Product; + +public record ProductEvent( + Product product, + Long productId, + EventType eventType, + Set actions +) { + + public enum EventType { + PRODUCT_CREATED, + PRODUCT_UPDATED, + PRODUCT_DELETED + } + + public enum EventAction { + EVICT_SINGLE_CACHE, // 단일 상품 캐시 무효화 + EVICT_ALL_CACHE, // 전체 상품 목록 캐시 무효화 + PUBLISH_EXTERNAL_EVENT, // 외부 시스템에 이벤트 발행 + ADDITIONAL_CLEANUP // 추가적인 캐시 정리 + } + + // 상품 삭제 이벤트 - 캐시 무효화 + 외부 이벤트 발행 + public static ProductEvent productDeleted(Product product) { + return new ProductEvent( + product, + product.getId(), + EventType.PRODUCT_DELETED, + EnumSet.of( + EventAction.EVICT_SINGLE_CACHE, + EventAction.EVICT_ALL_CACHE, + EventAction.PUBLISH_EXTERNAL_EVENT, + EventAction.ADDITIONAL_CLEANUP + ) + ); + } + + // 상품 생성 이벤트 - 목록 캐시 무효화 + public static ProductEvent productCreated(Product product) { + return new ProductEvent( + product, + product.getId(), + EventType.PRODUCT_CREATED, + EnumSet.of( + EventAction.EVICT_ALL_CACHE, + EventAction.ADDITIONAL_CLEANUP + ) + ); + } + + // 상품 수정 이벤트 - 캐시 무효화 + public static ProductEvent productUpdated(Product product) { + return new ProductEvent( + product, + product.getId(), + EventType.PRODUCT_UPDATED, + EnumSet.of( + EventAction.EVICT_SINGLE_CACHE, + EventAction.EVICT_ALL_CACHE, + EventAction.ADDITIONAL_CLEANUP + ) + ); + } + +} diff --git a/monolith/src/main/java/com/msa/commerce/monolith/product/infrastructure/event/ProductEventListener.java b/monolith/src/main/java/com/msa/commerce/monolith/product/infrastructure/event/ProductEventListener.java new file mode 100644 index 0000000..bdca15f --- /dev/null +++ b/monolith/src/main/java/com/msa/commerce/monolith/product/infrastructure/event/ProductEventListener.java @@ -0,0 +1,84 @@ +package com.msa.commerce.monolith.product.infrastructure.event; + +import org.springframework.cache.CacheManager; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +import com.msa.commerce.monolith.product.application.service.ProductEventPublisher; +import com.msa.commerce.monolith.product.domain.event.ProductEvent; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +@RequiredArgsConstructor +public class ProductEventListener { + + private static final String PRODUCT_CACHE = "product"; + + private static final String PRODUCTS_CACHE = "products"; + + private final CacheManager cacheManager; + + private final ProductEventPublisher productEventPublisher; + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handleProductEvent(ProductEvent event) { + for (ProductEvent.EventAction action : event.actions()) { + switch (action) { + case EVICT_SINGLE_CACHE: + evictSingleProduct(event.productId()); + break; + case EVICT_ALL_CACHE: + evictAllProducts(); + break; + case PUBLISH_EXTERNAL_EVENT: + publishExternalEvent(event); + break; + case ADDITIONAL_CLEANUP: + handleAdditionalCacheCleanup(event.productId()); + break; + } + } + } + + private void publishExternalEvent(ProductEvent event) { + switch (event.eventType()) { + case PRODUCT_DELETED: + productEventPublisher.publishProductDeletedEvent(event.product()); + break; + case PRODUCT_CREATED: + // TODO: 상품 생성 이벤트 발행 + break; + case PRODUCT_UPDATED: + // TODO: 상품 수정 이벤트 발행 + break; + } + } + + private void evictSingleProduct(Long productId) { + if (productId != null) { + var cache = cacheManager.getCache(PRODUCT_CACHE); + if (cache != null) { + cache.evict(productId); + log.debug("Evicted product cache for productId: {}", productId); + } + } + } + + private void evictAllProducts() { + var cache = cacheManager.getCache(PRODUCTS_CACHE); + if (cache != null) { + cache.clear(); + log.debug("Cleared all products cache"); + } + } + + private void handleAdditionalCacheCleanup(Long productId) { + // TODO: 추가적인 캐시 정리 로직 + // e.g. 카테고리별 상품 목록 캐시, 검색 결과 캐시 등 + } + +} diff --git a/monolith/src/main/java/com/msa/commerce/monolith/product/infrastructure/event/ProductEventPublisherImpl.java b/monolith/src/main/java/com/msa/commerce/monolith/product/infrastructure/event/ProductEventPublisherImpl.java new file mode 100644 index 0000000..6fb83ca --- /dev/null +++ b/monolith/src/main/java/com/msa/commerce/monolith/product/infrastructure/event/ProductEventPublisherImpl.java @@ -0,0 +1,27 @@ +package com.msa.commerce.monolith.product.infrastructure.event; + +import org.springframework.stereotype.Component; + +import com.msa.commerce.monolith.product.application.service.ProductEventPublisher; +import com.msa.commerce.monolith.product.domain.Product; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +public class ProductEventPublisherImpl implements ProductEventPublisher { + + @Override + public void publishProductDeletedEvent(Product product) { + // TODO: 실제 이벤트 발행 로직 구현 + // 예: Kafka, RabbitMQ, SNS 등으로 이벤트 전송 + log.info("Publishing product deleted event for productId: {}, sku: {}", + product.getId(), product.getSku()); + + // 외부 시스템으로 이벤트 전송 로직 + // 예시: + // kafkaProducer.send("product.deleted", productDeletedEvent); + // rabbitTemplate.convertAndSend("product.exchange", "product.deleted", event); + } + +} diff --git a/monolith/src/test/java/com/msa/commerce/monolith/product/application/service/ProductCreateServiceTest.java b/monolith/src/test/java/com/msa/commerce/monolith/product/application/service/ProductCreateServiceTest.java index 77a5880..ac799bb 100644 --- a/monolith/src/test/java/com/msa/commerce/monolith/product/application/service/ProductCreateServiceTest.java +++ b/monolith/src/test/java/com/msa/commerce/monolith/product/application/service/ProductCreateServiceTest.java @@ -14,6 +14,7 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; import com.msa.commerce.common.exception.DuplicateResourceException; import com.msa.commerce.monolith.product.application.port.in.ProductCreateCommand; @@ -33,6 +34,9 @@ class ProductCreateServiceTest { @Mock private ProductResponseMapper productResponseMapper; + @Mock + private ApplicationEventPublisher applicationEventPublisher; + @InjectMocks private ProductCreateService productCreateService; diff --git a/monolith/src/test/java/com/msa/commerce/monolith/product/application/service/ProductDeleteServiceTest.java b/monolith/src/test/java/com/msa/commerce/monolith/product/application/service/ProductDeleteServiceTest.java index 2d79471..7377e27 100644 --- a/monolith/src/test/java/com/msa/commerce/monolith/product/application/service/ProductDeleteServiceTest.java +++ b/monolith/src/test/java/com/msa/commerce/monolith/product/application/service/ProductDeleteServiceTest.java @@ -15,6 +15,7 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; import com.msa.commerce.common.exception.ErrorCode; import com.msa.commerce.common.exception.ResourceNotFoundException; @@ -23,6 +24,7 @@ import com.msa.commerce.monolith.product.domain.Product; import com.msa.commerce.monolith.product.domain.ProductStatus; import com.msa.commerce.monolith.product.domain.ProductType; +import com.msa.commerce.monolith.product.domain.event.ProductEvent; @ExtendWith(MockitoExtension.class) @DisplayName("ProductDeleteService 단위 테스트") @@ -32,7 +34,7 @@ class ProductDeleteServiceTest { private ProductRepository productRepository; @Mock - private ProductEventPublisher productEventPublisher; + private ApplicationEventPublisher applicationEventPublisher; @InjectMocks private ProductDeleteService productDeleteService; @@ -84,7 +86,7 @@ void deleteProduct_Success() { // then verify(productRepository).findById(productId); verify(productRepository).save(any(Product.class)); - verify(productEventPublisher).publishProductDeletedEvent(any(Product.class)); + verify(applicationEventPublisher).publishEvent(any(ProductEvent.class)); } @Test diff --git a/monolith/src/test/java/com/msa/commerce/monolith/product/application/service/ProductUpdateServiceTest.java b/monolith/src/test/java/com/msa/commerce/monolith/product/application/service/ProductUpdateServiceTest.java index ff1caa3..b0ddf60 100644 --- a/monolith/src/test/java/com/msa/commerce/monolith/product/application/service/ProductUpdateServiceTest.java +++ b/monolith/src/test/java/com/msa/commerce/monolith/product/application/service/ProductUpdateServiceTest.java @@ -17,6 +17,7 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; import com.msa.commerce.common.exception.DuplicateResourceException; import com.msa.commerce.common.exception.ProductUpdateNotAllowedException; @@ -44,6 +45,9 @@ class ProductUpdateServiceTest { @Mock private Validator validator; + @Mock + private ApplicationEventPublisher applicationEventPublisher; + @InjectMocks private ProductUpdateService productUpdateService; From 54317f7028b85afc2b522135a8f5a8db591cb696 Mon Sep 17 00:00:00 2001 From: joel-you Date: Mon, 15 Sep 2025 01:54:43 +0900 Subject: [PATCH 6/9] feat(25): Refactor product event handling and cache eviction logic in services --- .../service/ProductCreateService.java | 8 +-- .../service/ProductDeleteService.java | 6 -- .../service/ProductUpdateService.java | 10 +--- .../product/domain/event/ProductEvent.java | 56 +++---------------- .../event/ProductEventListener.java | 48 ++++++++-------- 5 files changed, 33 insertions(+), 95 deletions(-) diff --git a/monolith/src/main/java/com/msa/commerce/monolith/product/application/service/ProductCreateService.java b/monolith/src/main/java/com/msa/commerce/monolith/product/application/service/ProductCreateService.java index 55cbf4f..d107a40 100644 --- a/monolith/src/main/java/com/msa/commerce/monolith/product/application/service/ProductCreateService.java +++ b/monolith/src/main/java/com/msa/commerce/monolith/product/application/service/ProductCreateService.java @@ -1,6 +1,5 @@ package com.msa.commerce.monolith.product.application.service; -import org.springframework.cache.annotation.CacheEvict; import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -30,7 +29,6 @@ public class ProductCreateService implements ProductCreateUseCase { @Override @ValidateCommand(errorPrefix = "Product creation validation failed") - @CacheEvict(value = "products", allEntries = true) public ProductResponse createProduct(ProductCreateCommand command) { validateCommand(command); validateDuplicateSku(command.getSku()); @@ -60,8 +58,7 @@ private ProductResponse executeProductCreation(ProductCreateCommand command) { Product savedProduct = productRepository.save(product); - // 통합 이벤트 발행 (트랜잭션 커밋 후 처리됨) - // 캐시 무효화를 한 번에 처리 + // 통합 이벤트 발행 (트랜잭션 커밋 후 캐시 무효화 처리) applicationEventPublisher.publishEvent( ProductEvent.productCreated(savedProduct) ); @@ -100,9 +97,6 @@ private void validateCommand(ProductCreateCommand command) { throw new IllegalArgumentException("Slug is required"); } - // Optional validation for categoryId - based on business logic, it might be required - // The test expects exception when categoryId is null, but the annotation doesn't mark it as @NotNull - // Let's check if this validation is needed based on the failing test if (command.getCategoryId() == null) { throw new IllegalArgumentException("Category ID is required."); } diff --git a/monolith/src/main/java/com/msa/commerce/monolith/product/application/service/ProductDeleteService.java b/monolith/src/main/java/com/msa/commerce/monolith/product/application/service/ProductDeleteService.java index a252be4..9c00754 100644 --- a/monolith/src/main/java/com/msa/commerce/monolith/product/application/service/ProductDeleteService.java +++ b/monolith/src/main/java/com/msa/commerce/monolith/product/application/service/ProductDeleteService.java @@ -1,7 +1,5 @@ package com.msa.commerce.monolith.product.application.service; -import org.springframework.cache.annotation.CacheEvict; -import org.springframework.cache.annotation.Caching; import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -28,10 +26,6 @@ public class ProductDeleteService implements ProductDeleteUseCase { private final ApplicationEventPublisher applicationEventPublisher; @Override - @Caching(evict = { - @CacheEvict(value = "product", key = "#productId"), - @CacheEvict(value = "products", allEntries = true) - }) public void deleteProduct(Long productId) { Product product = productRepository.findById(productId) .orElseThrow(() -> new ResourceNotFoundException("Product not found with id: " + productId, ErrorCode.PRODUCT_NOT_FOUND.getCode())); diff --git a/monolith/src/main/java/com/msa/commerce/monolith/product/application/service/ProductUpdateService.java b/monolith/src/main/java/com/msa/commerce/monolith/product/application/service/ProductUpdateService.java index 8d522bf..8d3d442 100644 --- a/monolith/src/main/java/com/msa/commerce/monolith/product/application/service/ProductUpdateService.java +++ b/monolith/src/main/java/com/msa/commerce/monolith/product/application/service/ProductUpdateService.java @@ -3,8 +3,6 @@ import java.util.Optional; import java.util.Set; -import org.springframework.cache.annotation.CacheEvict; -import org.springframework.cache.annotation.Caching; import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -42,10 +40,6 @@ public class ProductUpdateService implements ProductUpdateUseCase { @Override @ValidateCommand(errorPrefix = "Product update validation failed") - @Caching(evict = { - @CacheEvict(value = "product", key = "#command.productId"), - @CacheEvict(value = "products", allEntries = true) - }) public ProductResponse updateProduct(ProductUpdateCommand command) { validateCommand(command); Product existingProduct = findAndValidateProduct(command.getProductId()); @@ -57,13 +51,11 @@ public ProductResponse updateProduct(ProductUpdateCommand command) { performPostUpdateOperations(updatedProduct, command, existingProduct); - // 통합 이벤트 발행 (트랜잭션 커밋 후 처리됨) - // 캐시 무효화를 한 번에 처리 + // 통합 이벤트 발행 (트랜잭션 커밋 후 캐시 무효화 처리) applicationEventPublisher.publishEvent( ProductEvent.productUpdated(updatedProduct) ); - log.info("Product updated successfully with ID: {}", updatedProduct.getId()); return productResponseMapper.toResponse(updatedProduct); } diff --git a/monolith/src/main/java/com/msa/commerce/monolith/product/domain/event/ProductEvent.java b/monolith/src/main/java/com/msa/commerce/monolith/product/domain/event/ProductEvent.java index 5083c0f..b604517 100644 --- a/monolith/src/main/java/com/msa/commerce/monolith/product/domain/event/ProductEvent.java +++ b/monolith/src/main/java/com/msa/commerce/monolith/product/domain/event/ProductEvent.java @@ -1,15 +1,11 @@ package com.msa.commerce.monolith.product.domain.event; -import java.util.EnumSet; -import java.util.Set; - import com.msa.commerce.monolith.product.domain.Product; public record ProductEvent( Product product, Long productId, - EventType eventType, - Set actions + EventType eventType ) { public enum EventType { @@ -18,53 +14,15 @@ public enum EventType { PRODUCT_DELETED } - public enum EventAction { - EVICT_SINGLE_CACHE, // 단일 상품 캐시 무효화 - EVICT_ALL_CACHE, // 전체 상품 목록 캐시 무효화 - PUBLISH_EXTERNAL_EVENT, // 외부 시스템에 이벤트 발행 - ADDITIONAL_CLEANUP // 추가적인 캐시 정리 - } - - // 상품 삭제 이벤트 - 캐시 무효화 + 외부 이벤트 발행 - public static ProductEvent productDeleted(Product product) { - return new ProductEvent( - product, - product.getId(), - EventType.PRODUCT_DELETED, - EnumSet.of( - EventAction.EVICT_SINGLE_CACHE, - EventAction.EVICT_ALL_CACHE, - EventAction.PUBLISH_EXTERNAL_EVENT, - EventAction.ADDITIONAL_CLEANUP - ) - ); - } - - // 상품 생성 이벤트 - 목록 캐시 무효화 public static ProductEvent productCreated(Product product) { - return new ProductEvent( - product, - product.getId(), - EventType.PRODUCT_CREATED, - EnumSet.of( - EventAction.EVICT_ALL_CACHE, - EventAction.ADDITIONAL_CLEANUP - ) - ); + return new ProductEvent(product, product.getId(), EventType.PRODUCT_CREATED); } - // 상품 수정 이벤트 - 캐시 무효화 public static ProductEvent productUpdated(Product product) { - return new ProductEvent( - product, - product.getId(), - EventType.PRODUCT_UPDATED, - EnumSet.of( - EventAction.EVICT_SINGLE_CACHE, - EventAction.EVICT_ALL_CACHE, - EventAction.ADDITIONAL_CLEANUP - ) - ); + return new ProductEvent(product, product.getId(), EventType.PRODUCT_UPDATED); } -} + public static ProductEvent productDeleted(Product product) { + return new ProductEvent(product, product.getId(), EventType.PRODUCT_DELETED); + } +} \ No newline at end of file diff --git a/monolith/src/main/java/com/msa/commerce/monolith/product/infrastructure/event/ProductEventListener.java b/monolith/src/main/java/com/msa/commerce/monolith/product/infrastructure/event/ProductEventListener.java index bdca15f..383bf08 100644 --- a/monolith/src/main/java/com/msa/commerce/monolith/product/infrastructure/event/ProductEventListener.java +++ b/monolith/src/main/java/com/msa/commerce/monolith/product/infrastructure/event/ProductEventListener.java @@ -26,21 +26,24 @@ public class ProductEventListener { @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) public void handleProductEvent(ProductEvent event) { - for (ProductEvent.EventAction action : event.actions()) { - switch (action) { - case EVICT_SINGLE_CACHE: - evictSingleProduct(event.productId()); - break; - case EVICT_ALL_CACHE: - evictAllProducts(); - break; - case PUBLISH_EXTERNAL_EVENT: - publishExternalEvent(event); - break; - case ADDITIONAL_CLEANUP: - handleAdditionalCacheCleanup(event.productId()); - break; - } + // 캐시 무효화 처리 + invalidateCache(event); + + // 외부 이벤트 발행 + publishExternalEvent(event); + } + + private void invalidateCache(ProductEvent event) { + switch (event.eventType()) { + case PRODUCT_CREATED: + evictProductsCache(); + break; + + case PRODUCT_UPDATED: + case PRODUCT_DELETED: + evictProductCache(event.productId()); + evictProductsCache(); + break; } } @@ -49,16 +52,18 @@ private void publishExternalEvent(ProductEvent event) { case PRODUCT_DELETED: productEventPublisher.publishProductDeletedEvent(event.product()); break; + case PRODUCT_CREATED: - // TODO: 상품 생성 이벤트 발행 + log.debug("Product created event for productId: {}", event.productId()); break; + case PRODUCT_UPDATED: - // TODO: 상품 수정 이벤트 발행 + log.debug("Product updated event for productId: {}", event.productId()); break; } } - private void evictSingleProduct(Long productId) { + private void evictProductCache(Long productId) { if (productId != null) { var cache = cacheManager.getCache(PRODUCT_CACHE); if (cache != null) { @@ -68,7 +73,7 @@ private void evictSingleProduct(Long productId) { } } - private void evictAllProducts() { + private void evictProductsCache() { var cache = cacheManager.getCache(PRODUCTS_CACHE); if (cache != null) { cache.clear(); @@ -76,9 +81,4 @@ private void evictAllProducts() { } } - private void handleAdditionalCacheCleanup(Long productId) { - // TODO: 추가적인 캐시 정리 로직 - // e.g. 카테고리별 상품 목록 캐시, 검색 결과 캐시 등 - } - } From e0e98310821412b926ce963ad0c5055d47c6bcdb Mon Sep 17 00:00:00 2001 From: joel-you Date: Mon, 15 Sep 2025 02:07:08 +0900 Subject: [PATCH 7/9] feat(25): Refactor product event handling and cache eviction logic in services --- .../MaterializedViewApplication.java | 13 +++++ .../service/ProductUpdateService.java | 58 +------------------ 2 files changed, 14 insertions(+), 57 deletions(-) create mode 100644 materialized-view/src/main/java/com/msa/commerce/materializedview/MaterializedViewApplication.java diff --git a/materialized-view/src/main/java/com/msa/commerce/materializedview/MaterializedViewApplication.java b/materialized-view/src/main/java/com/msa/commerce/materializedview/MaterializedViewApplication.java new file mode 100644 index 0000000..9cb27fb --- /dev/null +++ b/materialized-view/src/main/java/com/msa/commerce/materializedview/MaterializedViewApplication.java @@ -0,0 +1,13 @@ +package com.msa.commerce.materializedview; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class MaterializedViewApplication { + + public static void main(String[] args) { + SpringApplication.run(MaterializedViewApplication.class, args); + } + +} diff --git a/monolith/src/main/java/com/msa/commerce/monolith/product/application/service/ProductUpdateService.java b/monolith/src/main/java/com/msa/commerce/monolith/product/application/service/ProductUpdateService.java index 8d3d442..311b514 100644 --- a/monolith/src/main/java/com/msa/commerce/monolith/product/application/service/ProductUpdateService.java +++ b/monolith/src/main/java/com/msa/commerce/monolith/product/application/service/ProductUpdateService.java @@ -49,12 +49,8 @@ public ProductResponse updateProduct(ProductUpdateCommand command) { updateProductData(existingProduct, command); Product updatedProduct = productRepository.save(existingProduct); - performPostUpdateOperations(updatedProduct, command, existingProduct); - // 통합 이벤트 발행 (트랜잭션 커밋 후 캐시 무효화 처리) - applicationEventPublisher.publishEvent( - ProductEvent.productUpdated(updatedProduct) - ); + applicationEventPublisher.publishEvent(ProductEvent.productUpdated(updatedProduct)); return productResponseMapper.toResponse(updatedProduct); } @@ -109,58 +105,6 @@ private void updateProductData(Product product, ProductUpdateCommand command) { ); } - private void performPostUpdateOperations(Product updatedProduct, ProductUpdateCommand command, - Product originalProduct) { - logProductChanges(originalProduct, updatedProduct, command); - } - - private void logProductChanges(Product before, Product after, ProductUpdateCommand command) { - log.info("Product changes for ID: {} - Updated fields: {}", - after.getId(), getUpdatedFieldsDescription(command)); - } - - private String getUpdatedFieldsDescription(ProductUpdateCommand command) { - StringBuilder description = new StringBuilder(); - - appendBasicFieldUpdates(description, command); - appendPriceFieldUpdates(description, command); - appendMetadataFieldUpdates(description, command); - - return description.length() > 0 ? description.substring(0, description.length() - 1) : "none"; - } - - private void appendBasicFieldUpdates(StringBuilder description, ProductUpdateCommand command) { - appendFieldIfNotNull(description, command.getSku(), "sku"); - appendFieldIfNotNull(description, command.getName(), "name"); - appendFieldIfNotNull(description, command.getShortDescription(), "shortDescription"); - appendFieldIfNotNull(description, command.getDescription(), "description"); - appendFieldIfNotNull(description, command.getCategoryId(), "categoryId"); - appendFieldIfNotNull(description, command.getBrand(), "brand"); - appendFieldIfNotNull(description, command.getProductType(), "productType"); - } - - private void appendPriceFieldUpdates(StringBuilder description, ProductUpdateCommand command) { - appendFieldIfNotNull(description, command.getBasePrice(), "basePrice"); - appendFieldIfNotNull(description, command.getSalePrice(), "salePrice"); - appendFieldIfNotNull(description, command.getCurrency(), "currency"); - appendFieldIfNotNull(description, command.getWeightGrams(), "weightGrams"); - } - - private void appendMetadataFieldUpdates(StringBuilder description, ProductUpdateCommand command) { - appendFieldIfNotNull(description, command.getRequiresShipping(), "requiresShipping"); - appendFieldIfNotNull(description, command.getIsTaxable(), "isTaxable"); - appendFieldIfNotNull(description, command.getIsFeatured(), "isFeatured"); - appendFieldIfNotNull(description, command.getSlug(), "slug"); - appendFieldIfNotNull(description, command.getSearchTags(), "searchTags"); - appendFieldIfNotNull(description, command.getPrimaryImageUrl(), "primaryImageUrl"); - } - - private void appendFieldIfNotNull(StringBuilder description, Object field, String fieldName) { - if (field != null) { - description.append(fieldName).append(","); - } - } - private void validateCommand(ProductUpdateCommand command) { Set> violations = validator.validate(command); if (!violations.isEmpty()) { From 6d9023cbe8ebdc63495f5f52e10cbd0454890b3c Mon Sep 17 00:00:00 2001 From: joel-you Date: Mon, 15 Sep 2025 02:11:31 +0900 Subject: [PATCH 8/9] feat(25): Add main application class for Order Orchestrator --- .../orchestrator/OrderOrchestratorApplication.java | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/order-orchestrator/src/main/java/com/msa/commerce/orchestrator/OrderOrchestratorApplication.java b/order-orchestrator/src/main/java/com/msa/commerce/orchestrator/OrderOrchestratorApplication.java index e69de29..f64b9fa 100644 --- a/order-orchestrator/src/main/java/com/msa/commerce/orchestrator/OrderOrchestratorApplication.java +++ b/order-orchestrator/src/main/java/com/msa/commerce/orchestrator/OrderOrchestratorApplication.java @@ -0,0 +1,12 @@ +package com.msa.commerce.orchestrator; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class OrderOrchestratorApplication { + + public static void main(String[] args) { + SpringApplication.run(OrderOrchestratorApplication.class, args); + } +} \ No newline at end of file From beba7b2e9bbe15f182b9596ad6983acb4a5cf182 Mon Sep 17 00:00:00 2001 From: joel-you Date: Mon, 15 Sep 2025 14:45:57 +0900 Subject: [PATCH 9/9] feat(25): Simplify event publishing syntax in product creation and deletion services --- .../product/application/service/ProductCreateService.java | 4 +--- .../product/application/service/ProductDeleteService.java | 4 +--- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/monolith/src/main/java/com/msa/commerce/monolith/product/application/service/ProductCreateService.java b/monolith/src/main/java/com/msa/commerce/monolith/product/application/service/ProductCreateService.java index d107a40..a4a573b 100644 --- a/monolith/src/main/java/com/msa/commerce/monolith/product/application/service/ProductCreateService.java +++ b/monolith/src/main/java/com/msa/commerce/monolith/product/application/service/ProductCreateService.java @@ -59,9 +59,7 @@ private ProductResponse executeProductCreation(ProductCreateCommand command) { Product savedProduct = productRepository.save(product); // 통합 이벤트 발행 (트랜잭션 커밋 후 캐시 무효화 처리) - applicationEventPublisher.publishEvent( - ProductEvent.productCreated(savedProduct) - ); + applicationEventPublisher.publishEvent(ProductEvent.productCreated(savedProduct)); return productResponseMapper.toResponse(savedProduct); } diff --git a/monolith/src/main/java/com/msa/commerce/monolith/product/application/service/ProductDeleteService.java b/monolith/src/main/java/com/msa/commerce/monolith/product/application/service/ProductDeleteService.java index 9c00754..560c1f8 100644 --- a/monolith/src/main/java/com/msa/commerce/monolith/product/application/service/ProductDeleteService.java +++ b/monolith/src/main/java/com/msa/commerce/monolith/product/application/service/ProductDeleteService.java @@ -37,9 +37,7 @@ public void deleteProduct(Long productId) { handleProductDeletion(productId); - applicationEventPublisher.publishEvent( - ProductEvent.productDeleted(deletedProduct) - ); + applicationEventPublisher.publishEvent(ProductEvent.productDeleted(deletedProduct)); } private void validateProductDeletable(Product product) {