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 ae705bb43..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,8 +2,13 @@ import com.loopers.application.brand.BrandService; import com.loopers.application.like.LikeService; -import lombok.RequiredArgsConstructor; +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.Pageable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -26,6 +31,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())) @@ -33,15 +39,20 @@ public ProductInfo getActiveProduct(Long id) { return product.withBrand(new ProductInfo.BrandSummary(product.brand().id(), brandName)); } - public Page getActiveProducts(Long brandId, ProductSort sort, Pageable pageable) { + @Cacheable(cacheNames = CacheConfig.PRODUCTS, key = "#brandId + ':' + #sort + ':' + #pageable.pageNumber + ':' + #pageable.pageSize") + 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 = { + @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 e0bbc7dcb..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 @@ -3,9 +3,12 @@ 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.cache.annotation.Caching; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; @@ -55,6 +58,10 @@ public Page getProducts(Long brandId, Pageable pageable) { return productRepository.findAllProducts(brandId, pageable).map(ProductInfo::from); } + @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); 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..5e0ed8dca --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/config/CacheConfig.java @@ -0,0 +1,59 @@ +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.Jackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.RedisSerializationContext; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +import java.time.Duration; + +@EnableCaching +@Configuration +public class CacheConfig { + + public static final String PRODUCT = "product"; + public static final String PRODUCTS = "products"; + + @Bean + public RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory) { + ObjectMapper objectMapper = new ObjectMapper() + .registerModule(new JavaTimeModule()) + .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(); + + 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) + .withCacheConfiguration(PRODUCT, productConfig) + .withCacheConfiguration(PRODUCTS, productsConfig) + .build(); + } +} 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/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() + ); + } +} 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/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()) + ); + } + } +} 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) { 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); +```