diff --git a/src/main/java/com/michelet/catalog/application/ProductViewQueryService.java b/src/main/java/com/michelet/catalog/application/ProductViewQueryService.java index a635f83..88215c5 100644 --- a/src/main/java/com/michelet/catalog/application/ProductViewQueryService.java +++ b/src/main/java/com/michelet/catalog/application/ProductViewQueryService.java @@ -57,6 +57,11 @@ public ProductViewResponse getProduct(UUID productId) { } // 내부 통신용 옵션 유효성 검증 로직 + @Cacheable( + value = "product_validate_cache", + key = "#optionId", + cacheManager = "catalogCacheManager" + ) public OptionValidationResponse validateOptionInternal(UUID optionId) { // 1. 해당 옵션을 포함하는 상품 뷰 조회 (없으면 예외) ProductView productView = productViewRepository.findByOptionId(optionId) diff --git a/src/main/java/com/michelet/catalog/infrastructure/config/RedisCacheConfig.java b/src/main/java/com/michelet/catalog/infrastructure/config/RedisCacheConfig.java index 0e0a79a..e1d3207 100644 --- a/src/main/java/com/michelet/catalog/infrastructure/config/RedisCacheConfig.java +++ b/src/main/java/com/michelet/catalog/infrastructure/config/RedisCacheConfig.java @@ -3,9 +3,11 @@ import com.fasterxml.jackson.annotation.JsonTypeInfo; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.jsontype.BasicPolymorphicTypeValidator; -import com.fasterxml.jackson.databind.jsontype.PolymorphicTypeValidator; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import com.michelet.catalog.presentation.dto.OptionValidationResponse; import java.time.Duration; +import java.util.HashMap; +import java.util.Map; import org.springframework.cache.CacheManager; import org.springframework.cache.annotation.EnableCaching; import org.springframework.context.annotation.Bean; @@ -14,6 +16,7 @@ import org.springframework.data.redis.cache.RedisCacheManager; import org.springframework.data.redis.connection.RedisConnectionFactory; import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer; import org.springframework.data.redis.serializer.RedisSerializationContext; import org.springframework.data.redis.serializer.StringRedisSerializer; @@ -23,41 +26,54 @@ public class RedisCacheConfig { @Bean public CacheManager catalogCacheManager(RedisConnectionFactory connectionFactory) { - // 역직렬화 시 날짜 포맷 깨짐 방지 및 다형성 타입 보존을 위한 커스텀 ObjectMapper - ObjectMapper objectMapper = new ObjectMapper(); - objectMapper.registerModule(new JavaTimeModule()); - - // 무방비 역직렬화 공격 차단을 위한 화이트리스트 보안 필터 적용 - PolymorphicTypeValidator ptv = BasicPolymorphicTypeValidator.builder() - .allowIfSubType("com.michelet.catalog") - .allowIfSubType("org.springframework.data.domain") - .allowIfSubType("java.util") - .allowIfSubType("java.time") - .allowIfSubType("java.math") - .allowIfBaseType(Object.class) - .build(); - objectMapper.activateDefaultTyping( - ptv, + // 다형성 타입이 필요한 캐시용 (products_cache 등) + ObjectMapper polymorphicMapper = new ObjectMapper(); + polymorphicMapper.registerModule(new JavaTimeModule()); + polymorphicMapper.activateDefaultTyping( + BasicPolymorphicTypeValidator.builder() + .allowIfSubType("com.michelet") + .allowIfSubType("org.springframework.data.domain") + .allowIfSubType("java.util") // 컬렉션 타입 허용 필수 + .allowIfSubType("java.time") // LocalDateTime 등 날짜 타입 + .allowIfSubType("java.math") // BigDecimal 등 + .build(), ObjectMapper.DefaultTyping.NON_FINAL, JsonTypeInfo.As.PROPERTY ); + GenericJackson2JsonRedisSerializer polymorphicSerializer = + new GenericJackson2JsonRedisSerializer(polymorphicMapper); - GenericJackson2JsonRedisSerializer serializer = new GenericJackson2JsonRedisSerializer(objectMapper); + // 타입 정보 불필요한 단순 DTO용 (product_validate_cache) + // OptionValidationResponse는 UUID/String/BigDecimal만 있어 @class 불필요 + Jackson2JsonRedisSerializer validateSerializer = + new Jackson2JsonRedisSerializer<>(OptionValidationResponse.class); - RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig() - // 캐시 유효 시간 설정 (기본 1시간) + // 기본 설정 (products_cache 등에 적용) + RedisCacheConfiguration defaultConfig = RedisCacheConfiguration.defaultCacheConfig() .entryTtl(Duration.ofHours(1)) - // 캐시 Key는 String으로 직렬화 - .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer())) - // 캐시 Value는 JSON 구조로 직렬화하여 가독성 및 호환성 보장 - .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(serializer)) - // 몽고DB의 데이터가 null일 경우 캐싱을 방지 (Cache Penetration 방어) + .serializeKeysWith( + RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer())) + .serializeValuesWith( + RedisSerializationContext.SerializationPair.fromSerializer(polymorphicSerializer)) .disableCachingNullValues(); + // product_validate_cache 전용 설정 + RedisCacheConfiguration validateConfig = RedisCacheConfiguration.defaultCacheConfig() + .entryTtl(Duration.ofHours(1)) + .serializeKeysWith( + RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer())) + .serializeValuesWith( + RedisSerializationContext.SerializationPair.fromSerializer(validateSerializer)) + .disableCachingNullValues(); + + Map cacheConfigs = new HashMap<>(); + cacheConfigs.put("product_validate_cache", validateConfig); + return RedisCacheManager.RedisCacheManagerBuilder .fromConnectionFactory(connectionFactory) - .cacheDefaults(config) + .cacheDefaults(defaultConfig) + .withInitialCacheConfigurations(cacheConfigs) .build(); } } diff --git a/src/main/java/com/michelet/catalog/infrastructure/messaging/ProductEventConsumer.java b/src/main/java/com/michelet/catalog/infrastructure/messaging/ProductEventConsumer.java index ec5dde9..880c806 100644 --- a/src/main/java/com/michelet/catalog/infrastructure/messaging/ProductEventConsumer.java +++ b/src/main/java/com/michelet/catalog/infrastructure/messaging/ProductEventConsumer.java @@ -7,6 +7,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.Caching; import org.springframework.kafka.annotation.KafkaListener; import org.springframework.stereotype.Component; @@ -33,7 +34,12 @@ public void consumeProductCreatedEvent(ProductCreatedEvent event) { topics = "${catalog.kafka.topic.product-updated:product.updated}", groupId = "${spring.kafka.consumer.group-id:catalog-service-consumer}" ) - @CacheEvict(value = "products_cache", allEntries = true, cacheManager = "catalogCacheManager") + @Caching( + evict = { + @CacheEvict(value = "products_cache", allEntries = true, cacheManager = "catalogCacheManager"), + @CacheEvict(value = "product_validate_cache", allEntries = true, cacheManager = "catalogCacheManager") + } + ) // 이벤트 유입 즉시 메인 화면 캐시 제거 public void consumeProductUpdatedEvent(ProductUpdatedEvent event) { log.info("product.updated 이벤트 수신: {}", event); @@ -45,13 +51,15 @@ public void consumeProductUpdatedEvent(ProductUpdatedEvent event) { topics = "${catalog.kafka.topic.status-changed:product.status-changed}", groupId = "${spring.kafka.consumer.group-id:catalog-service-consumer}" ) - @CacheEvict( - value = "products_cache", - allEntries = true, - condition = "#event.newStatus() != 'SOLDOUT'", - cacheManager = "catalogCacheManager" + @Caching( + evict = { + // SOLDOUT 포함 모든 상태 변경 시에만 products_cache evict (목록 캐시는 조건부 유지) + @CacheEvict(value = "products_cache", allEntries = true, condition = "#event.newStatus() != 'SOLDOUT'", cacheManager = "catalogCacheManager"), + // 상태가 SOLDOUT으로 바뀌어도 단건 상세 캐시는 항상 evict (stale 옵션 검증 방지) + @CacheEvict(value = "product_validate_cache", allEntries = true, cacheManager = "catalogCacheManager") + } ) - // SOLDOUT 이외 상태 변경 이벤트 유입 시 메인 화면 캐시 제거 + // SOLDOUT 이외 상태 변경 이벤트 유입 시 메인 화면 캐시 제거 / 상세 캐시는 항상 제거 public void consumeProductStatusChangedEvent(ProductStatusChangedEvent event) { log.info("product.status-changed 이벤트 수신: {}", event); productViewCommandService.updateProductStatus(event);