From 378b3f979e2c5386e1ba30b8b8fbe4093ff36c1a Mon Sep 17 00:00:00 2001 From: hey-sion Date: Tue, 10 Mar 2026 18:58:48 +0900 Subject: [PATCH 1/7] =?UTF-8?q?chore:=20=EC=84=B1=EB=8A=A5=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=EB=A5=BC=20=EC=9C=84=ED=95=9C=20brand,=20pro?= =?UTF-8?q?duct=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=83=9D=EC=84=B1=20?= =?UTF-8?q?=EA=B4=80=EB=A0=A8=20=EB=AC=B8=EC=84=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/data-initializer.md | 52 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 docs/data-initializer.md diff --git a/docs/data-initializer.md b/docs/data-initializer.md new file mode 100644 index 000000000..2ba8f41e6 --- /dev/null +++ b/docs/data-initializer.md @@ -0,0 +1,52 @@ +# 성능 테스트용 더미 데이터 구성 + +## 상품 공통 스펙 + +| 항목 | 값 | +|------|----| +| price | 1,000 ~ 100,000원 (브랜드 무관, 무작위) | +| stockQuantity | 100 | +| visibility | VISIBLE | + +## 브랜드 그룹 + +| 그룹 | 브랜드 | 브랜드당 상품 수 | 소계 | +|------|--------|----------------|------| +| TOP | 브랜드-1 ~ 브랜드-10 | 10,000 | 100,000 | +| REST | 브랜드-11 ~ 브랜드-50 | 2,500 | 100,000 | +| **합계** | | | **200,000** | + +## likeCount 분포 + +TOP 브랜드 상품은 상대적으로 높은 likeCount를 갖도록 그룹별 범위를 다르게 설정했다. +각 상품의 likeCount는 해당 범위 내에서 무작위로 설정된다. + +| 그룹 | likeCount 범위 | +|------|----------------| +| TOP | 500 ~ 3,000 | +| REST | 0 ~ 2,000 | + +## 검증 쿼리 + +```sql +-- 전체 상품 수 확인 +SELECT COUNT(*) FROM products; + +-- 브랜드별 상품 수 확인 (TOP 10개 → 10,000 / REST 40개 → 2,500) +SELECT brand_id, COUNT(*) FROM products GROUP BY brand_id ORDER BY brand_id; + +-- likeCount 분포 확인 +SELECT brand_id, MIN(like_count), MAX(like_count), AVG(like_count) +FROM products +GROUP BY brand_id +ORDER BY brand_id; +``` + +## 브랜드별 상품 수 조정 (선택) + +초기 적재 후 특정 브랜드에 상품을 몰아주고 싶다면 직접 SQL로 조정한다. + +```sql +-- 예: 브랜드-2, 3, 4의 상품을 브랜드-1로 이전 +UPDATE products SET brand_id = 1 WHERE brand_id IN (2, 3, 4); +``` From 89d8ea66c3a9844ac3eaf9770927ca3e20067b75 Mon Sep 17 00:00:00 2001 From: hey-sion Date: Thu, 12 Mar 2026 17:09:57 +0900 Subject: [PATCH 2/7] =?UTF-8?q?feat:=20=EC=83=81=ED=92=88=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=20Redis=20=EC=BA=90=EC=8B=9C=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/product/ProductFacade.java | 5 +++ .../application/product/ProductService.java | 3 ++ .../java/com/loopers/config/CacheConfig.java | 40 +++++++++++++++++++ 3 files changed, 48 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/config/CacheConfig.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java index ae705bb43..6cf7ce24e 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java @@ -2,7 +2,10 @@ import com.loopers.application.brand.BrandService; import com.loopers.application.like.LikeService; +import com.loopers.config.CacheConfig; import lombok.RequiredArgsConstructor; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.Cacheable; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; @@ -26,6 +29,7 @@ public ProductInfo register(ProductCreateCommand command) { return productService.register(command); } + @Cacheable(cacheNames = CacheConfig.PRODUCT, key = "#id") public ProductInfo getActiveProduct(Long id) { ProductInfo product = productService.getActiveProduct(id); String brandName = brandService.getBrandNameMap(List.of(product.brand().id())) @@ -42,6 +46,7 @@ public Page getActiveProducts(Long brandId, ProductSort sort, Pagea )); } + @CacheEvict(cacheNames = CacheConfig.PRODUCT, key = "#id") @Transactional public void delete(Long id) { likeService.deleteAllByProductIds(List.of(id)); diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java index e0bbc7dcb..a52b9cfd5 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java @@ -3,9 +3,11 @@ import com.loopers.domain.product.Product; import com.loopers.domain.product.ProductRepository; import com.loopers.application.order.OrderItemCommand; +import com.loopers.config.CacheConfig; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; +import org.springframework.cache.annotation.CacheEvict; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; @@ -55,6 +57,7 @@ public Page getProducts(Long brandId, Pageable pageable) { return productRepository.findAllProducts(brandId, pageable).map(ProductInfo::from); } + @CacheEvict(cacheNames = CacheConfig.PRODUCT, key = "#id") @Transactional public ProductInfo update(Long id, ProductUpdateCommand command) { Product product = findById(id); diff --git a/apps/commerce-api/src/main/java/com/loopers/config/CacheConfig.java b/apps/commerce-api/src/main/java/com/loopers/config/CacheConfig.java new file mode 100644 index 000000000..e03d1a13b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/config/CacheConfig.java @@ -0,0 +1,40 @@ +package com.loopers.config; + +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.cache.RedisCacheConfiguration; +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.RedisSerializationContext; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +import java.time.Duration; +import java.util.Map; + +@EnableCaching +@Configuration +public class CacheConfig { + + public static final String PRODUCT = "product"; + public static final String PRODUCTS = "products"; + + @Bean + public RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory) { + RedisCacheConfiguration defaultConfig = RedisCacheConfiguration.defaultCacheConfig() + .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer())) + .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer())) + .disableCachingNullValues(); + + Map cacheConfigurations = Map.of( + PRODUCT, defaultConfig.entryTtl(Duration.ofMinutes(30)), + PRODUCTS, defaultConfig.entryTtl(Duration.ofMinutes(10)) + ); + + return RedisCacheManager.builder(connectionFactory) + .cacheDefaults(defaultConfig) + .withInitialCacheConfigurations(cacheConfigurations) + .build(); + } +} From a0ded2b82b4ad04029a6cfcb23842972485d0503 Mon Sep 17 00:00:00 2001 From: hey-sion Date: Thu, 12 Mar 2026 17:37:07 +0900 Subject: [PATCH 3/7] =?UTF-8?q?feat:=20=EC=83=81=ED=92=88=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20Redis=20=EC=BA=90=EC=8B=9C=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/loopers/application/product/ProductFacade.java | 7 ++++++- .../com/loopers/application/product/ProductService.java | 6 +++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java index 6cf7ce24e..34d3e4d18 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java @@ -6,6 +6,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.cache.annotation.CacheEvict; import org.springframework.cache.annotation.Cacheable; +import org.springframework.cache.annotation.Caching; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; @@ -37,6 +38,7 @@ public ProductInfo getActiveProduct(Long id) { return product.withBrand(new ProductInfo.BrandSummary(product.brand().id(), brandName)); } + @Cacheable(cacheNames = CacheConfig.PRODUCTS, key = "#brandId + ':' + #sort + ':' + #pageable.pageNumber + ':' + #pageable.pageSize") public Page getActiveProducts(Long brandId, ProductSort sort, Pageable pageable) { Page products = productService.getActiveProducts(brandId, sort, pageable); Set brandIds = products.stream().map(p -> p.brand().id()).collect(Collectors.toSet()); @@ -46,7 +48,10 @@ public Page getActiveProducts(Long brandId, ProductSort sort, Pagea )); } - @CacheEvict(cacheNames = CacheConfig.PRODUCT, key = "#id") + @Caching(evict = { + @CacheEvict(cacheNames = CacheConfig.PRODUCT, key = "#id"), + @CacheEvict(cacheNames = CacheConfig.PRODUCTS, allEntries = true) + }) @Transactional public void delete(Long id) { likeService.deleteAllByProductIds(List.of(id)); diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java index a52b9cfd5..fe4541368 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java @@ -8,6 +8,7 @@ import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.Caching; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; @@ -57,7 +58,10 @@ public Page getProducts(Long brandId, Pageable pageable) { return productRepository.findAllProducts(brandId, pageable).map(ProductInfo::from); } - @CacheEvict(cacheNames = CacheConfig.PRODUCT, key = "#id") + @Caching(evict = { + @CacheEvict(cacheNames = CacheConfig.PRODUCT, key = "#id"), + @CacheEvict(cacheNames = CacheConfig.PRODUCTS, allEntries = true) + }) @Transactional public ProductInfo update(Long id, ProductUpdateCommand command) { Product product = findById(id); From 46988993121d8594512ce6e30cac61bb16a2c6db Mon Sep 17 00:00:00 2001 From: hey-sion Date: Thu, 12 Mar 2026 18:01:42 +0900 Subject: [PATCH 4/7] =?UTF-8?q?fix:=20Redis=20=EC=BA=90=EC=8B=9C=20?= =?UTF-8?q?=EC=A7=81=EB=A0=AC=ED=99=94=20=EC=98=A4=EB=A5=98=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20(ZonedDateTime=20JavaTimeModule=20=EB=AF=B8?= =?UTF-8?q?=EB=93=B1=EB=A1=9D)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/com/loopers/config/CacheConfig.java | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/config/CacheConfig.java b/apps/commerce-api/src/main/java/com/loopers/config/CacheConfig.java index e03d1a13b..b1b1ac534 100644 --- a/apps/commerce-api/src/main/java/com/loopers/config/CacheConfig.java +++ b/apps/commerce-api/src/main/java/com/loopers/config/CacheConfig.java @@ -1,5 +1,8 @@ package com.loopers.config; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import org.springframework.cache.annotation.EnableCaching; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -22,9 +25,17 @@ public class CacheConfig { @Bean public RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory) { + ObjectMapper objectMapper = new ObjectMapper() + .registerModule(new JavaTimeModule()) + .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) + .activateDefaultTyping( + new ObjectMapper().getPolymorphicTypeValidator(), + ObjectMapper.DefaultTyping.NON_FINAL + ); + RedisCacheConfiguration defaultConfig = RedisCacheConfiguration.defaultCacheConfig() .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer())) - .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer())) + .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer(objectMapper))) .disableCachingNullValues(); Map cacheConfigurations = Map.of( From 59c0d7d76fef2d5cab5f2ebed7b5f9762beca7cf Mon Sep 17 00:00:00 2001 From: hey-sion Date: Fri, 13 Mar 2026 15:24:00 +0900 Subject: [PATCH 5/7] =?UTF-8?q?fix:=20PageImpl=20=EC=BA=90=EC=8B=9C=20?= =?UTF-8?q?=EC=97=AD=EC=A7=81=EB=A0=AC=ED=99=94=20=EC=98=A4=EB=A5=98=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20(PageResult=20=EB=8F=84=EC=9E=85),=20E2E?= =?UTF-8?q?=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EB=82=B4=20RedisCleanUp=20@Af?= =?UTF-8?q?terEach=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/loopers/application/PageResult.java | 34 +++++++++++++++++++ .../application/product/ProductFacade.java | 9 ++--- .../loopers/interfaces/api/PageResponse.java | 11 ++++++ .../api/product/ProductV1Controller.java | 8 ++--- .../product/ProductFacadeTest.java | 10 +++--- .../interfaces/api/ProductV1ApiE2ETest.java | 7 +++- 6 files changed, 65 insertions(+), 14 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/PageResult.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/PageResult.java b/apps/commerce-api/src/main/java/com/loopers/application/PageResult.java new file mode 100644 index 000000000..d9d4008c5 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/PageResult.java @@ -0,0 +1,34 @@ +package com.loopers.application; + +import org.springframework.data.domain.Page; + +import java.util.List; +import java.util.function.Function; + +public record PageResult( + List items, + int page, + int size, + long totalElements, + int totalPages +) { + public static PageResult from(Page page) { + return new PageResult<>( + page.getContent(), + page.getNumber(), + page.getSize(), + page.getTotalElements(), + page.getTotalPages() + ); + } + + public PageResult map(Function mapper) { + return new PageResult<>( + items.stream().map(mapper).toList(), + page, + size, + totalElements, + totalPages + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java index 34d3e4d18..a669e254f 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java @@ -2,12 +2,13 @@ import com.loopers.application.brand.BrandService; import com.loopers.application.like.LikeService; +import com.loopers.application.PageResult; import com.loopers.config.CacheConfig; +import org.springframework.data.domain.Page; import lombok.RequiredArgsConstructor; import org.springframework.cache.annotation.CacheEvict; import org.springframework.cache.annotation.Cacheable; import org.springframework.cache.annotation.Caching; -import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -39,13 +40,13 @@ public ProductInfo getActiveProduct(Long id) { } @Cacheable(cacheNames = CacheConfig.PRODUCTS, key = "#brandId + ':' + #sort + ':' + #pageable.pageNumber + ':' + #pageable.pageSize") - public Page getActiveProducts(Long brandId, ProductSort sort, Pageable pageable) { + public PageResult getActiveProducts(Long brandId, ProductSort sort, Pageable pageable) { Page products = productService.getActiveProducts(brandId, sort, pageable); Set brandIds = products.stream().map(p -> p.brand().id()).collect(Collectors.toSet()); Map brandNameMap = brandService.getBrandNameMap(brandIds); - return products.map(p -> p.withBrand( + return PageResult.from(products.map(p -> p.withBrand( new ProductInfo.BrandSummary(p.brand().id(), brandNameMap.getOrDefault(p.brand().id(), null)) - )); + ))); } @Caching(evict = { diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/PageResponse.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/PageResponse.java index 1b8a87cde..1dd6d243d 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/PageResponse.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/PageResponse.java @@ -1,5 +1,6 @@ package com.loopers.interfaces.api; +import com.loopers.application.PageResult; import org.springframework.data.domain.Page; import java.util.List; @@ -20,4 +21,14 @@ public static PageResponse from(Page page) { page.getTotalPages() ); } + + public static PageResponse from(PageResult result) { + return new PageResponse<>( + result.items(), + result.page(), + result.size(), + result.totalElements(), + result.totalPages() + ); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java index f7d7720cc..ee0f50dab 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java @@ -1,5 +1,6 @@ package com.loopers.interfaces.api.product; +import com.loopers.application.PageResult; import com.loopers.application.product.ProductFacade; import com.loopers.application.product.ProductInfo; import com.loopers.application.product.ProductSort; @@ -7,7 +8,6 @@ import com.loopers.interfaces.api.PageResponse; import com.loopers.interfaces.api.product.dto.ProductV1Dto; import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.web.PageableDefault; import org.springframework.web.bind.annotation.GetMapping; @@ -35,9 +35,9 @@ public ApiResponse> getProducts( @RequestParam(defaultValue = "LATEST") ProductSort sort, @PageableDefault(size = 20) Pageable pageable ) { - Page page = productFacade.getActiveProducts(brandId, sort, pageable) - .map(ProductV1Dto.ProductResponse::from); + PageResult result = productFacade.getActiveProducts(brandId, sort, pageable) + .map(ProductV1Dto.ProductResponse::from); - return ApiResponse.success(PageResponse.from(page)); + return ApiResponse.success(PageResponse.from(result)); } } diff --git a/apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeTest.java index c2de9360a..a2ae0126a 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeTest.java @@ -6,13 +6,13 @@ import com.loopers.domain.brand.InMemoryBrandRepository; import com.loopers.domain.like.InMemoryLikeRepository; import com.loopers.domain.product.InMemoryProductRepository; +import com.loopers.application.PageResult; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; -import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import static org.assertj.core.api.Assertions.assertThat; @@ -68,11 +68,11 @@ void returnsBrandName_forEachProduct() { productService.register(new ProductCreateCommand(adidas.id(), "슈퍼스타", "신발", 120000, 8)); // act - Page result = productFacade.getActiveProducts(null, ProductSort.LATEST, PageRequest.of(0, 20)); + PageResult result = productFacade.getActiveProducts(null, ProductSort.LATEST, PageRequest.of(0, 20)); // assert assertAll( - () -> assertThat(result.getContent()).extracting(p -> p.brand().name()) + () -> assertThat(result.items()).extracting(p -> p.brand().name()) .containsExactlyInAnyOrder("나이키", "아디다스") ); } @@ -86,10 +86,10 @@ void returnsSameBrandName_whenMultipleProductsOfSameBrand() { productService.register(new ProductCreateCommand(brand.id(), "조던", "농구화", 200000, 5)); // act - Page result = productFacade.getActiveProducts(null, ProductSort.LATEST, PageRequest.of(0, 20)); + PageResult result = productFacade.getActiveProducts(null, ProductSort.LATEST, PageRequest.of(0, 20)); // assert - assertThat(result.getContent()).extracting(p -> p.brand().name()) + assertThat(result.items()).extracting(p -> p.brand().name()) .containsOnly("나이키"); } } diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ProductV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ProductV1ApiE2ETest.java index 995ca504c..d026d7398 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ProductV1ApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ProductV1ApiE2ETest.java @@ -7,6 +7,7 @@ import com.loopers.infrastructure.product.ProductJpaRepository; import com.loopers.interfaces.api.product.dto.ProductV1Dto; import com.loopers.utils.DatabaseCleanUp; +import com.loopers.utils.RedisCleanUp; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; @@ -34,6 +35,7 @@ class ProductV1ApiE2ETest { private final ProductJpaRepository productJpaRepository; private final ProductService productService; private final DatabaseCleanUp databaseCleanUp; + private final RedisCleanUp redisCleanUp; @Autowired public ProductV1ApiE2ETest( @@ -41,18 +43,21 @@ public ProductV1ApiE2ETest( BrandJpaRepository brandJpaRepository, ProductJpaRepository productJpaRepository, ProductService productService, - DatabaseCleanUp databaseCleanUp + DatabaseCleanUp databaseCleanUp, + RedisCleanUp redisCleanUp ) { this.testRestTemplate = testRestTemplate; this.brandJpaRepository = brandJpaRepository; this.productJpaRepository = productJpaRepository; this.productService = productService; this.databaseCleanUp = databaseCleanUp; + this.redisCleanUp = redisCleanUp; } @AfterEach void tearDown() { databaseCleanUp.truncateAllTables(); + redisCleanUp.truncateAll(); } private Brand saveBrand(String name) { From 0bae0f6abc56fddeeaed92cc4e1dae7e453f055b Mon Sep 17 00:00:00 2001 From: hey-sion Date: Fri, 13 Mar 2026 16:04:30 +0900 Subject: [PATCH 6/7] =?UTF-8?q?fix:=20=EC=BA=90=EC=8B=9C=20=EC=A7=81?= =?UTF-8?q?=EB=A0=AC=ED=99=94=20=EC=84=A4=EC=A0=95=20=EC=88=98=EC=A0=95=20?= =?UTF-8?q?=EB=B0=8F=20=EC=BA=90=EC=8B=9C=20E2E=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/loopers/config/CacheConfig.java | 42 ++-- .../interfaces/api/ProductCacheE2ETest.java | 233 ++++++++++++++++++ 2 files changed, 258 insertions(+), 17 deletions(-) create mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/api/ProductCacheE2ETest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/config/CacheConfig.java b/apps/commerce-api/src/main/java/com/loopers/config/CacheConfig.java index b1b1ac534..5e0ed8dca 100644 --- a/apps/commerce-api/src/main/java/com/loopers/config/CacheConfig.java +++ b/apps/commerce-api/src/main/java/com/loopers/config/CacheConfig.java @@ -1,20 +1,22 @@ package com.loopers.config; +import com.fasterxml.jackson.databind.JavaType; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.SerializationFeature; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import com.loopers.application.PageResult; +import com.loopers.application.product.ProductInfo; import org.springframework.cache.annotation.EnableCaching; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.data.redis.cache.RedisCacheConfiguration; 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; import java.time.Duration; -import java.util.Map; @EnableCaching @Configuration @@ -27,25 +29,31 @@ public class CacheConfig { public RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory) { ObjectMapper objectMapper = new ObjectMapper() .registerModule(new JavaTimeModule()) - .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) - .activateDefaultTyping( - new ObjectMapper().getPolymorphicTypeValidator(), - ObjectMapper.DefaultTyping.NON_FINAL - ); - - RedisCacheConfiguration defaultConfig = RedisCacheConfiguration.defaultCacheConfig() - .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer())) - .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer(objectMapper))) + .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + + StringRedisSerializer keySerializer = new StringRedisSerializer(); + + RedisCacheConfiguration productConfig = RedisCacheConfiguration.defaultCacheConfig() + .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(keySerializer)) + .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer( + new Jackson2JsonRedisSerializer<>(objectMapper, ProductInfo.class) + )) + .entryTtl(Duration.ofMinutes(30)) .disableCachingNullValues(); - Map cacheConfigurations = Map.of( - PRODUCT, defaultConfig.entryTtl(Duration.ofMinutes(30)), - PRODUCTS, defaultConfig.entryTtl(Duration.ofMinutes(10)) - ); + JavaType pageResultType = objectMapper.getTypeFactory() + .constructParametricType(PageResult.class, ProductInfo.class); + RedisCacheConfiguration productsConfig = RedisCacheConfiguration.defaultCacheConfig() + .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(keySerializer)) + .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer( + new Jackson2JsonRedisSerializer<>(objectMapper, pageResultType) + )) + .entryTtl(Duration.ofMinutes(10)) + .disableCachingNullValues(); return RedisCacheManager.builder(connectionFactory) - .cacheDefaults(defaultConfig) - .withInitialCacheConfigurations(cacheConfigurations) + .withCacheConfiguration(PRODUCT, productConfig) + .withCacheConfiguration(PRODUCTS, productsConfig) .build(); } } diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ProductCacheE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ProductCacheE2ETest.java new file mode 100644 index 000000000..838cf9bb9 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ProductCacheE2ETest.java @@ -0,0 +1,233 @@ +package com.loopers.interfaces.api; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.product.Product; +import com.loopers.infrastructure.brand.BrandJpaRepository; +import com.loopers.infrastructure.product.ProductJpaRepository; +import com.loopers.interfaces.api.product.dto.ProductV1Dto; +import com.loopers.utils.DatabaseCleanUp; +import com.loopers.utils.RedisCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class ProductCacheE2ETest { + + private static final String PRODUCT_ENDPOINT = "/api/v1/products"; + private static final String ADMIN_PRODUCT_ENDPOINT = "/api-admin/v1/products"; + private static final String LDAP_HEADER = "X-Loopers-Ldap"; + private static final String LDAP_VALUE = "loopers.admin"; + + private final TestRestTemplate testRestTemplate; + private final BrandJpaRepository brandJpaRepository; + private final ProductJpaRepository productJpaRepository; + private final DatabaseCleanUp databaseCleanUp; + private final RedisCleanUp redisCleanUp; + + @Autowired + public ProductCacheE2ETest( + TestRestTemplate testRestTemplate, + BrandJpaRepository brandJpaRepository, + ProductJpaRepository productJpaRepository, + DatabaseCleanUp databaseCleanUp, + RedisCleanUp redisCleanUp + ) { + this.testRestTemplate = testRestTemplate; + this.brandJpaRepository = brandJpaRepository; + this.productJpaRepository = productJpaRepository; + this.databaseCleanUp = databaseCleanUp; + this.redisCleanUp = redisCleanUp; + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + redisCleanUp.truncateAll(); + } + + private HttpHeaders adminHeaders() { + HttpHeaders headers = new HttpHeaders(); + headers.set(LDAP_HEADER, LDAP_VALUE); + return headers; + } + + private Brand saveBrand(String name) { + return brandJpaRepository.save(Brand.create(name, null)); + } + + private Product saveProduct(Long brandId, String name, int price, int stock) { + return productJpaRepository.save(Product.create(brandId, name, null, price, stock)); + } + + @DisplayName("상품 상세 캐시") + @Nested + class ProductDetailCache { + + @DisplayName("캐시 히트: 캐시된 상품은 DB가 변경되어도 캐시된 데이터를 반환한다.") + @Test + void returnsCachedProduct_evenAfterDbSoftDelete() { + // arrange + Brand brand = saveBrand("나이키"); + Product saved = saveProduct(brand.getId(), "에어맥스", 150000, 10); + + // 첫 번째 조회 - 캐시 미스 → Redis에 저장 + testRestTemplate.exchange( + PRODUCT_ENDPOINT + "/" + saved.getId(), + HttpMethod.GET, null, + new ParameterizedTypeReference>() {} + ); + + // DB에서 직접 soft-delete (캐시 eviction 없이 DB 상태 변경) + saved.delete(); + productJpaRepository.save(saved); + + // act - 두 번째 조회: 캐시 히트 → Redis에서 반환 + ResponseEntity> response = + testRestTemplate.exchange( + PRODUCT_ENDPOINT + "/" + saved.getId(), + HttpMethod.GET, null, + new ParameterizedTypeReference<>() {} + ); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().id()).isEqualTo(saved.getId()), + () -> assertThat(response.getBody().data().name()).isEqualTo("에어맥스") + ); + } + + @DisplayName("캐시 무효화: 상품 삭제 API 호출 후, 동일 상품 조회 시 404를 반환한다.") + @Test + void returnsNotFound_afterCacheEvictedByDeleteApi() { + // arrange + Brand brand = saveBrand("나이키"); + Product saved = saveProduct(brand.getId(), "에어맥스", 150000, 10); + + // 첫 번째 조회 - 캐시 미스 → Redis에 저장 + testRestTemplate.exchange( + PRODUCT_ENDPOINT + "/" + saved.getId(), + HttpMethod.GET, null, + new ParameterizedTypeReference>() {} + ); + + // act - Admin API로 삭제 → @CacheEvict 발동 + testRestTemplate.exchange( + ADMIN_PRODUCT_ENDPOINT + "/" + saved.getId(), + HttpMethod.DELETE, + new HttpEntity<>(adminHeaders()), + new ParameterizedTypeReference>() {} + ); + + // 두 번째 조회: 캐시 무효화 → DB 재조회 → 삭제된 상품 → 404 + ResponseEntity> response = + testRestTemplate.exchange( + PRODUCT_ENDPOINT + "/" + saved.getId(), + HttpMethod.GET, null, + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + } + + @DisplayName("상품 목록 캐시") + @Nested + class ProductListCache { + + @DisplayName("캐시 히트: 캐시된 목록은 DB가 변경되어도 캐시된 데이터를 반환한다.") + @Test + void returnsCachedProducts_evenAfterDbSoftDelete() { + // arrange + Brand brand = saveBrand("나이키"); + Product saved = saveProduct(brand.getId(), "에어맥스", 150000, 10); + + // 첫 번째 조회 - 캐시 미스 → Redis에 저장 + testRestTemplate.exchange( + PRODUCT_ENDPOINT, + HttpMethod.GET, null, + new ParameterizedTypeReference>>() {} + ); + + // DB에서 직접 soft-delete (캐시 eviction 없이 DB 상태 변경) + saved.delete(); + productJpaRepository.save(saved); + + // act - 두 번째 조회: 캐시 히트 → Redis에서 반환 + ResponseEntity>> response = + testRestTemplate.exchange( + PRODUCT_ENDPOINT, + HttpMethod.GET, null, + new ParameterizedTypeReference<>() {} + ); + + // assert + List ids = response.getBody().data().content().stream() + .map(ProductV1Dto.ProductResponse::id) + .toList(); + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(ids).contains(saved.getId()) + ); + } + + @DisplayName("캐시 무효화: 상품 삭제 API 호출 후, 목록 재조회 시 해당 상품이 제외된다.") + @Test + void excludesDeletedProduct_afterCacheEvictedByDeleteApi() { + // arrange + Brand brand = saveBrand("나이키"); + Product toDelete = saveProduct(brand.getId(), "에어맥스", 150000, 10); + Product remaining = saveProduct(brand.getId(), "조던", 200000, 5); + + // 첫 번째 조회 - 캐시 미스 → Redis에 저장 + testRestTemplate.exchange( + PRODUCT_ENDPOINT, + HttpMethod.GET, null, + new ParameterizedTypeReference>>() {} + ); + + // act - Admin API로 삭제 → @CacheEvict(allEntries=true) 발동 + testRestTemplate.exchange( + ADMIN_PRODUCT_ENDPOINT + "/" + toDelete.getId(), + HttpMethod.DELETE, + new HttpEntity<>(adminHeaders()), + new ParameterizedTypeReference>() {} + ); + + // 두 번째 조회: 캐시 무효화 → DB 재조회 + ResponseEntity>> response = + testRestTemplate.exchange( + PRODUCT_ENDPOINT, + HttpMethod.GET, null, + new ParameterizedTypeReference<>() {} + ); + + // assert + List ids = response.getBody().data().content().stream() + .map(ProductV1Dto.ProductResponse::id) + .toList(); + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(ids).doesNotContain(toDelete.getId()), + () -> assertThat(ids).contains(remaining.getId()) + ); + } + } +} From 88b8e10f1c37944ab65ac8b11e6d7ae6430aca34 Mon Sep 17 00:00:00 2001 From: hey-sion Date: Fri, 13 Mar 2026 17:40:17 +0900 Subject: [PATCH 7/7] =?UTF-8?q?feat:=20=EC=84=B1=EB=8A=A5=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=EC=9A=A9=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20?= =?UTF-8?q?=EC=A0=81=EC=9E=AC=EB=A5=BC=20=EC=9C=84=ED=95=9C=20DataInitiali?= =?UTF-8?q?zer=20=ED=81=B4=EB=9E=98=EC=8A=A4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/loopers/DataInitializer.java | 94 +++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 apps/commerce-api/src/test/java/com/loopers/DataInitializer.java diff --git a/apps/commerce-api/src/test/java/com/loopers/DataInitializer.java b/apps/commerce-api/src/test/java/com/loopers/DataInitializer.java new file mode 100644 index 000000000..9be8cd764 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/DataInitializer.java @@ -0,0 +1,94 @@ +package com.loopers; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.product.Product; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.transaction.support.TransactionTemplate; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.ThreadLocalRandom; + +@SpringBootTest +@ActiveProfiles("local") +public class DataInitializer { + + // TestContainers(MySqlTestContainersConfig)가 System.setProperty로 DB URL을 덮어쓰므로 + // DynamicPropertySource로 로컬 MySQL로 재설정 (우선순위: DynamicPropertySource > System.setProperty) + @DynamicPropertySource + static void overrideDataSource(DynamicPropertyRegistry registry) { + registry.add("datasource.mysql-jpa.main.jdbc-url", () -> "jdbc:mysql://localhost:3306/loopers"); + registry.add("datasource.mysql-jpa.main.username", () -> "application"); + registry.add("datasource.mysql-jpa.main.password", () -> "application"); + } + + @PersistenceContext + EntityManager entityManager; + + @Autowired + TransactionTemplate transactionTemplate; + + @Test + void initialize() { + List brandIds = saveBrands(50); + + for (int i = 0; i < brandIds.size(); i++) { + Long brandId = brandIds.get(i); + + boolean isTopBrand = i < 10; // 앞 10개 브랜드만 top + int productCount = isTopBrand ? 10_000 : 2_500; + int likeMin = isTopBrand ? 300 : 0; + int likeMax = isTopBrand ? 3_000 : 2_000; + + insertProducts(brandId, productCount); + updateLikeCount(brandId, likeMin, likeMax); + } + } + + List saveBrands(int count) { + return transactionTemplate.execute(status -> { + List ids = new ArrayList<>(); + for (int i = 1; i <= count; i++) { + Brand brand = Brand.create("브랜드-" + i, null); + entityManager.persist(brand); + entityManager.flush(); + ids.add(brand.getId()); + } + + return ids; + }); + } + + void insertProducts(Long brandId, int count) { + transactionTemplate.executeWithoutResult(status -> { + for (int i = 0; i < count; i++) { + int price = ThreadLocalRandom.current().nextInt(1_000, 100_001); + entityManager.persist(Product.create(brandId, + "상품_" + UUID.randomUUID().toString() + .substring(0, 8), null, price, 100)); + if (i % 500 == 499) { + entityManager.flush(); + entityManager.clear(); + } + } + }); + } + + void updateLikeCount(Long brandId, int min, int max) { + transactionTemplate.executeWithoutResult(status -> + entityManager.createNativeQuery("UPDATE products SET like_count = FLOOR(:min + RAND() * (:max - :min + 1)) WHERE brand_id = :brandId") + .setParameter("min", min) + .setParameter("max", max) + .setParameter("brandId", brandId) + .executeUpdate() + ); + } +}