diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandDeletedEventHandler.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandDeletedEventHandler.java index 0d7cd676c..5bb5e514e 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandDeletedEventHandler.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandDeletedEventHandler.java @@ -1,29 +1,38 @@ package com.loopers.application.brand; import com.loopers.domain.model.brand.event.BrandDeletedEvent; +import com.loopers.domain.model.brand.event.BrandProductsDeletedEvent; import com.loopers.domain.model.product.Product; import com.loopers.domain.repository.ProductRepository; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.context.event.EventListener; import org.springframework.stereotype.Component; +import java.util.ArrayList; import java.util.List; @Component public class BrandDeletedEventHandler { private final ProductRepository productRepository; + private final ApplicationEventPublisher eventPublisher; - public BrandDeletedEventHandler(ProductRepository productRepository) { + public BrandDeletedEventHandler(ProductRepository productRepository, + ApplicationEventPublisher eventPublisher) { this.productRepository = productRepository; + this.eventPublisher = eventPublisher; } @EventListener public void handle(BrandDeletedEvent event) { List products = productRepository.findAllByBrandId(event.brandId()); + List deletedProductIds = new ArrayList<>(); for (Product product : products) { if (!product.isDeleted()) { productRepository.save(product.delete()); + deletedProductIds.add(product.getId()); } } + eventPublisher.publishEvent(new BrandProductsDeletedEvent(event.brandId(), deletedProductIds)); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeEventHandler.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeEventHandler.java index f2361e13e..c417e7c5a 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeEventHandler.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeEventHandler.java @@ -2,10 +2,7 @@ import com.loopers.domain.model.like.event.ProductLikedEvent; import com.loopers.domain.model.like.event.ProductUnlikedEvent; -import com.loopers.domain.model.product.Product; import com.loopers.domain.repository.ProductRepository; -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; import org.springframework.context.event.EventListener; import org.springframework.stereotype.Component; @@ -20,17 +17,11 @@ public LikeEventHandler(ProductRepository productRepository) { @EventListener public void handle(ProductLikedEvent event) { - Product product = productRepository.findActiveByIdWithLock(event.productId()) - .orElseThrow(() -> new CoreException(ErrorType.PRODUCT_NOT_FOUND)); - Product updated = product.increaseLikeCount(); - productRepository.save(updated); + productRepository.incrementLikeCount(event.productId()); } @EventListener public void handle(ProductUnlikedEvent event) { - Product product = productRepository.findActiveByIdWithLock(event.productId()) - .orElseThrow(() -> new CoreException(ErrorType.PRODUCT_NOT_FOUND)); - Product updated = product.decreaseLikeCount(); - productRepository.save(updated); + productRepository.decrementLikeCount(event.productId()); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeService.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeService.java index 0988b6994..2cf6107ff 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeService.java @@ -2,7 +2,6 @@ import com.loopers.domain.model.common.DomainEventPublisher; import com.loopers.domain.model.like.Like; -import com.loopers.domain.model.product.Product; import com.loopers.domain.model.user.UserId; import com.loopers.domain.repository.LikeRepository; import com.loopers.domain.repository.ProductRepository; @@ -28,7 +27,7 @@ public LikeService(LikeRepository likeRepository, ProductRepository productRepos @Override public void like(UserId userId, Long productId) { - findProductWithLock(productId); + validateProductExists(productId); if (likeRepository.existsByUserIdAndProductId(userId, productId)) { return; @@ -41,7 +40,7 @@ public void like(UserId userId, Long productId) { @Override public void unlike(UserId userId, Long productId) { - findProductWithLock(productId); + validateProductExists(productId); likeRepository.findByUserIdAndProductId(userId, productId) .ifPresent(like -> { @@ -51,8 +50,8 @@ public void unlike(UserId userId, Long productId) { }); } - private Product findProductWithLock(Long productId) { - return productRepository.findActiveByIdWithLock(productId) + private void validateProductExists(Long productId) { + productRepository.findActiveById(productId) .orElseThrow(() -> new CoreException(ErrorType.PRODUCT_NOT_FOUND)); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderService.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderService.java index 4f6aec056..3ea71e944 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderService.java @@ -11,10 +11,17 @@ import com.loopers.domain.repository.OrderRepository; import com.loopers.domain.repository.ProductRepository; import com.loopers.domain.repository.UserCouponRepository; +import com.loopers.infrastructure.cache.CacheConfig; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.cache.Cache; +import org.springframework.cache.CacheManager; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; import java.math.BigDecimal; import java.util.Comparator; @@ -24,20 +31,24 @@ @Transactional public class OrderService implements CreateOrderUseCase, CancelOrderUseCase, UpdateDeliveryAddressUseCase { + private static final Logger log = LoggerFactory.getLogger(OrderService.class); + private final OrderRepository orderRepository; private final ProductRepository productRepository; private final CouponRepository couponRepository; private final UserCouponRepository userCouponRepository; private final DomainEventPublisher eventPublisher; + private final CacheManager cacheManager; public OrderService(OrderRepository orderRepository, ProductRepository productRepository, CouponRepository couponRepository, UserCouponRepository userCouponRepository, - DomainEventPublisher eventPublisher) { + DomainEventPublisher eventPublisher, CacheManager cacheManager) { this.orderRepository = orderRepository; this.productRepository = productRepository; this.couponRepository = couponRepository; this.userCouponRepository = userCouponRepository; this.eventPublisher = eventPublisher; + this.cacheManager = cacheManager; } @Override @@ -83,6 +94,35 @@ public void createOrder(UserId userId, OrderCommand command) { discountAmount, command.couponId()); orderRepository.save(order); + + // 4. 트랜잭션 커밋 후 영향받은 상품의 캐시만 개별 무효화 + List affectedProductIds = sortedItems.stream() + .map(CreateOrderUseCase.OrderItemCommand::productId) + .toList(); + registerAfterCommitCacheEviction(affectedProductIds); + } + + private void registerAfterCommitCacheEviction(List productIds) { + if (!TransactionSynchronizationManager.isSynchronizationActive()) { + Cache cache = cacheManager.getCache(CacheConfig.PRODUCT_DETAIL); + if (cache != null) { + productIds.forEach(cache::evict); + } + return; + } + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + try { + Cache cache = cacheManager.getCache(CacheConfig.PRODUCT_DETAIL); + if (cache != null) { + productIds.forEach(cache::evict); + } + } catch (RuntimeException e) { + log.warn("주문 후 캐시 무효화 실패 - productIds: {}, error: {}", productIds, e.getMessage()); + } + } + }); } private Money processCoupon(UserId userId, Long userCouponId, List orderLines) { diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductQueryService.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductQueryService.java index bedd077c7..7586dc888 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductQueryService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductQueryService.java @@ -5,8 +5,10 @@ import com.loopers.domain.model.product.Product; import com.loopers.domain.repository.BrandRepository; import com.loopers.domain.repository.ProductRepository; +import com.loopers.infrastructure.cache.CacheConfig; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; +import org.springframework.cache.annotation.Cacheable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.util.List; @@ -26,6 +28,7 @@ public ProductQueryService(ProductRepository productRepository, BrandRepository } @Override + @Cacheable(value = CacheConfig.PRODUCT_DETAIL, key = "#productId") public ProductDetailInfo getProduct(Long productId) { Product product = productRepository.findActiveById(productId) .orElseThrow(() -> new CoreException(ErrorType.PRODUCT_NOT_FOUND)); @@ -48,6 +51,7 @@ public ProductDetailInfo getProduct(Long productId) { } @Override + @Cacheable(value = CacheConfig.PRODUCT_LIST, key = "'brand:' + #brandId + ':sort:' + #sort + ':page:' + #page + ':size:' + #size") public PageResult getProducts(Long brandId, String sort, int page, int size) { PageResult products = productRepository.findAllActive(brandId, sort, page, size); 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 3cb2434fe..40f3cb59c 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 @@ -1,29 +1,38 @@ package com.loopers.application.product; -import com.loopers.application.product.CreateProductUseCase; -import com.loopers.application.product.DeleteProductUseCase; -import com.loopers.application.product.UpdateProductUseCase; import com.loopers.domain.model.product.Price; import com.loopers.domain.model.product.Product; import com.loopers.domain.model.product.ProductName; import com.loopers.domain.model.product.Stock; import com.loopers.domain.repository.BrandRepository; import com.loopers.domain.repository.ProductRepository; +import com.loopers.infrastructure.cache.CacheConfig; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.cache.Cache; +import org.springframework.cache.CacheManager; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; @Service @Transactional public class ProductService implements CreateProductUseCase, UpdateProductUseCase, DeleteProductUseCase { + private static final Logger log = LoggerFactory.getLogger(ProductService.class); + private final ProductRepository productRepository; private final BrandRepository brandRepository; + private final CacheManager cacheManager; - public ProductService(ProductRepository productRepository, BrandRepository brandRepository) { + public ProductService(ProductRepository productRepository, BrandRepository brandRepository, + CacheManager cacheManager) { this.productRepository = productRepository; this.brandRepository = brandRepository; + this.cacheManager = cacheManager; } @Override @@ -35,6 +44,8 @@ public void createProduct(ProductCreateCommand command) { Product product = Product.create(command.brandId(), ProductName.of(command.name()), Price.of(command.price()), salePriceVo, Stock.of(command.stock()), command.description()); productRepository.save(product); + + evictProductListAfterCommit(); } @Override @@ -44,6 +55,9 @@ public void updateProduct(ProductUpdateCommand command) { Product updated = product.update(ProductName.of(command.name()), Price.of(command.price()), salePriceVo, Stock.of(command.stock()), command.description()); productRepository.save(updated); + + evictProductAfterCommit(command.productId()); + evictProductListAfterCommit(); } @Override @@ -51,10 +65,51 @@ public void deleteProduct(Long productId) { Product product = findProduct(productId); Product deleted = product.delete(); productRepository.save(deleted); + + evictProductAfterCommit(productId); + evictProductListAfterCommit(); } private Product findProduct(Long productId) { return productRepository.findActiveById(productId) .orElseThrow(() -> new CoreException(ErrorType.PRODUCT_NOT_FOUND)); } + + private void evictProductAfterCommit(Long productId) { + if (!TransactionSynchronizationManager.isSynchronizationActive()) { + Cache cache = cacheManager.getCache(CacheConfig.PRODUCT_DETAIL); + if (cache != null) cache.evict(productId); + return; + } + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + try { + Cache cache = cacheManager.getCache(CacheConfig.PRODUCT_DETAIL); + if (cache != null) cache.evict(productId); + } catch (RuntimeException e) { + log.warn("상품 캐시 무효화 실패 - productId: {}, error: {}", productId, e.getMessage()); + } + } + }); + } + + private void evictProductListAfterCommit() { + if (!TransactionSynchronizationManager.isSynchronizationActive()) { + Cache cache = cacheManager.getCache(CacheConfig.PRODUCT_LIST); + if (cache != null) cache.clear(); + return; + } + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + try { + Cache cache = cacheManager.getCache(CacheConfig.PRODUCT_LIST); + if (cache != null) cache.clear(); + } catch (RuntimeException e) { + log.warn("상품 목록 캐시 무효화 실패 - error: {}", e.getMessage()); + } + } + }); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/model/brand/event/BrandProductsDeletedEvent.java b/apps/commerce-api/src/main/java/com/loopers/domain/model/brand/event/BrandProductsDeletedEvent.java new file mode 100644 index 000000000..b5310e80b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/model/brand/event/BrandProductsDeletedEvent.java @@ -0,0 +1,9 @@ +package com.loopers.domain.model.brand.event; + +import java.util.List; + +public record BrandProductsDeletedEvent( + Long brandId, + List deletedProductIds +) { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/repository/ProductRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/repository/ProductRepository.java index a157365c3..53cf8db8d 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/repository/ProductRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/repository/ProductRepository.java @@ -19,4 +19,8 @@ public interface ProductRepository { PageResult findAllActive(Long brandId, String sort, int page, int size); List findAllByBrandId(Long brandId); + + void incrementLikeCount(Long productId); + + void decrementLikeCount(Long productId); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/CacheConfig.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/CacheConfig.java new file mode 100644 index 000000000..e6a4c010c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/CacheConfig.java @@ -0,0 +1,80 @@ +package com.loopers.infrastructure.cache; + +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.databind.jsontype.BasicPolymorphicTypeValidator; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import io.micrometer.core.instrument.MeterRegistry; +import org.springframework.cache.CacheManager; +import org.springframework.cache.annotation.CachingConfigurer; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.cache.interceptor.CacheErrorHandler; +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; + +@Configuration +@EnableCaching +public class CacheConfig implements CachingConfigurer { + + public static final String PRODUCT_DETAIL = "product"; + public static final String PRODUCT_LIST = "products"; + public static final String BRAND_LIST = "brands"; + + private final MeterRegistry meterRegistry; + + public CacheConfig(MeterRegistry meterRegistry) { + this.meterRegistry = meterRegistry; + } + + @Bean + public CacheManager cacheManager(RedisConnectionFactory connectionFactory) { + ObjectMapper redisObjectMapper = new ObjectMapper(); + redisObjectMapper.registerModule(new JavaTimeModule()); + redisObjectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + redisObjectMapper.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES); + redisObjectMapper.activateDefaultTyping( + BasicPolymorphicTypeValidator.builder() + .allowIfSubType("com.loopers.") + .allowIfSubType("java.util.") + .build(), + ObjectMapper.DefaultTyping.EVERYTHING, + JsonTypeInfo.As.PROPERTY + ); + + GenericJackson2JsonRedisSerializer jsonSerializer = + new GenericJackson2JsonRedisSerializer(redisObjectMapper); + + RedisCacheConfiguration defaultConfig = RedisCacheConfiguration.defaultCacheConfig() + .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer())) + .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(jsonSerializer)) + .disableCachingNullValues(); + + Map cacheConfigs = Map.of( + PRODUCT_DETAIL, defaultConfig.entryTtl(Duration.ofMinutes(5)), + PRODUCT_LIST, defaultConfig.entryTtl(Duration.ofMinutes(1)), + BRAND_LIST, defaultConfig.entryTtl(Duration.ofMinutes(10)) + ); + + return RedisCacheManager.builder(connectionFactory) + .cacheDefaults(defaultConfig.entryTtl(Duration.ofMinutes(5))) + .withInitialCacheConfigurations(cacheConfigs) + .enableStatistics() + .build(); + } + + @Override + public CacheErrorHandler errorHandler() { + return new SafeCacheErrorHandler(meterRegistry); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/ProductCacheEvictHandler.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/ProductCacheEvictHandler.java new file mode 100644 index 000000000..68730008e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/ProductCacheEvictHandler.java @@ -0,0 +1,75 @@ +package com.loopers.infrastructure.cache; + +import com.loopers.domain.model.brand.event.BrandProductsDeletedEvent; +import com.loopers.domain.model.like.event.ProductLikedEvent; +import com.loopers.domain.model.like.event.ProductUnlikedEvent; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.cache.Cache; +import org.springframework.cache.CacheManager; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Component +public class ProductCacheEvictHandler { + + private static final Logger log = LoggerFactory.getLogger(ProductCacheEvictHandler.class); + + private final CacheManager cacheManager; + + public ProductCacheEvictHandler(CacheManager cacheManager) { + this.cacheManager = cacheManager; + } + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handleLiked(ProductLikedEvent event) { + evictProductDetail(event.productId()); + } + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handleUnliked(ProductUnlikedEvent event) { + evictProductDetail(event.productId()); + } + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handleBrandProductsDeleted(BrandProductsDeletedEvent event) { + event.deletedProductIds().forEach(this::evictProductDetail); + evictProductListCache(); + evictBrandList(); + } + + private void evictProductDetail(Long productId) { + try { + Cache cache = cacheManager.getCache(CacheConfig.PRODUCT_DETAIL); + if (cache != null) { + cache.evict(productId); + log.debug("캐시 무효화 [AFTER_COMMIT] - product::{}", productId); + } + } catch (RuntimeException e) { + log.warn("캐시 무효화 실패 [AFTER_COMMIT] - product::{}, error: {}", productId, e.getMessage()); + } + } + + private void evictProductListCache() { + try { + Cache listCache = cacheManager.getCache(CacheConfig.PRODUCT_LIST); + if (listCache != null) listCache.clear(); + log.debug("캐시 무효화 [AFTER_COMMIT] - products::*"); + } catch (RuntimeException e) { + log.warn("캐시 무효화 실패 [AFTER_COMMIT] - products, error: {}", e.getMessage()); + } + } + + private void evictBrandList() { + try { + Cache cache = cacheManager.getCache(CacheConfig.BRAND_LIST); + if (cache != null) { + cache.clear(); + log.debug("캐시 무효화 [AFTER_COMMIT] - brands::*"); + } + } catch (RuntimeException e) { + log.warn("캐시 무효화 실패 [AFTER_COMMIT] - brands, error: {}", e.getMessage()); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/SafeCacheErrorHandler.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/SafeCacheErrorHandler.java new file mode 100644 index 000000000..cb64a4a6b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/SafeCacheErrorHandler.java @@ -0,0 +1,51 @@ +package com.loopers.infrastructure.cache; + +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.MeterRegistry; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.cache.Cache; +import org.springframework.cache.interceptor.CacheErrorHandler; + +public class SafeCacheErrorHandler implements CacheErrorHandler { + + private static final Logger log = LoggerFactory.getLogger(SafeCacheErrorHandler.class); + + private final MeterRegistry meterRegistry; + + public SafeCacheErrorHandler(MeterRegistry meterRegistry) { + this.meterRegistry = meterRegistry; + } + + @Override + public void handleCacheGetError(RuntimeException exception, Cache cache, Object key) { + log.warn("Redis 캐시 조회 실패 - cache: {}", cache.getName(), exception); + incrementErrorCounter(cache.getName(), "get"); + } + + @Override + public void handleCachePutError(RuntimeException exception, Cache cache, Object key, Object value) { + log.warn("Redis 캐시 저장 실패 - cache: {}", cache.getName(), exception); + incrementErrorCounter(cache.getName(), "put"); + } + + @Override + public void handleCacheEvictError(RuntimeException exception, Cache cache, Object key) { + log.warn("Redis 캐시 삭제 실패 - cache: {}", cache.getName(), exception); + incrementErrorCounter(cache.getName(), "evict"); + } + + @Override + public void handleCacheClearError(RuntimeException exception, Cache cache) { + log.warn("Redis 캐시 초기화 실패 - cache: {}", cache.getName(), exception); + incrementErrorCounter(cache.getName(), "clear"); + } + + private void incrementErrorCounter(String cacheName, String operation) { + Counter.builder("cache.errors") + .tag("cache", cacheName) + .tag("operation", operation) + .register(meterRegistry) + .increment(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java index 0bd86dbd3..91727670a 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java @@ -5,6 +5,7 @@ import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -22,4 +23,12 @@ public interface ProductJpaRepository extends JpaRepository findByIdForUpdate(@Param("id") Long id); + + @Modifying(clearAutomatically = true) + @Query("UPDATE ProductJpaEntity p SET p.likeCount = p.likeCount + 1 WHERE p.id = :productId") + void incrementLikeCount(@Param("productId") Long productId); + + @Modifying(clearAutomatically = true) + @Query("UPDATE ProductJpaEntity p SET p.likeCount = p.likeCount - 1 WHERE p.id = :productId AND p.likeCount > 0") + int decrementLikeCount(@Param("productId") Long productId); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java index d905fb1e9..3cf38f563 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java @@ -81,6 +81,16 @@ private Sort resolveSort(String sort) { }; } + @Override + public void incrementLikeCount(Long productId) { + productJpaRepository.incrementLikeCount(productId); + } + + @Override + public void decrementLikeCount(Long productId) { + productJpaRepository.decrementLikeCount(productId); + } + private ProductJpaEntity toEntity(Product product) { return new ProductJpaEntity( product.getId(), diff --git a/apps/commerce-api/src/test/java/com/loopers/application/like/LikeServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeServiceTest.java index 829c6af66..343528cb9 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/like/LikeServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeServiceTest.java @@ -46,7 +46,7 @@ void like_success() { UserId userId = UserId.of("test1234"); Product product = createProduct(1L, 0); - when(productRepository.findActiveByIdWithLock(1L)).thenReturn(Optional.of(product)); + when(productRepository.findActiveById(1L)).thenReturn(Optional.of(product)); when(likeRepository.existsByUserIdAndProductId(userId, 1L)).thenReturn(false); // when @@ -64,7 +64,7 @@ void like_alreadyLiked_ignored() { UserId userId = UserId.of("test1234"); Product product = createProduct(1L, 1); - when(productRepository.findActiveByIdWithLock(1L)).thenReturn(Optional.of(product)); + when(productRepository.findActiveById(1L)).thenReturn(Optional.of(product)); when(likeRepository.existsByUserIdAndProductId(userId, 1L)).thenReturn(true); // when @@ -80,7 +80,7 @@ void like_alreadyLiked_ignored() { void like_fail_productNotFound() { // given UserId userId = UserId.of("test1234"); - when(productRepository.findActiveByIdWithLock(999L)).thenReturn(Optional.empty()); + when(productRepository.findActiveById(999L)).thenReturn(Optional.empty()); // when & then assertThatThrownBy(() -> service.like(userId, 999L)) @@ -100,7 +100,7 @@ void unlike_success() { Product product = createProduct(1L, 1); Like like = Like.reconstitute(1L, userId, 1L, LocalDateTime.now()); - when(productRepository.findActiveByIdWithLock(1L)).thenReturn(Optional.of(product)); + when(productRepository.findActiveById(1L)).thenReturn(Optional.of(product)); when(likeRepository.findByUserIdAndProductId(userId, 1L)).thenReturn(Optional.of(like)); // when @@ -118,7 +118,7 @@ void unlike_notLiked_ignored() { UserId userId = UserId.of("test1234"); Product product = createProduct(1L, 0); - when(productRepository.findActiveByIdWithLock(1L)).thenReturn(Optional.of(product)); + when(productRepository.findActiveById(1L)).thenReturn(Optional.of(product)); when(likeRepository.findByUserIdAndProductId(userId, 1L)).thenReturn(Optional.empty()); // when diff --git a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderServiceTest.java index 6915184e1..15922deaa 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderServiceTest.java @@ -13,6 +13,7 @@ import com.loopers.domain.repository.ProductRepository; import com.loopers.domain.repository.UserCouponRepository; import com.loopers.domain.model.common.DomainEventPublisher; +import org.springframework.cache.CacheManager; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; @@ -34,6 +35,7 @@ class OrderServiceTest { private CouponRepository couponRepository; private UserCouponRepository userCouponRepository; private DomainEventPublisher eventPublisher; + private CacheManager cacheManager; private OrderService service; @BeforeEach @@ -43,8 +45,9 @@ void setUp() { couponRepository = mock(CouponRepository.class); userCouponRepository = mock(UserCouponRepository.class); eventPublisher = mock(DomainEventPublisher.class); + cacheManager = mock(CacheManager.class); service = new OrderService(orderRepository, productRepository, - couponRepository, userCouponRepository, eventPublisher); + couponRepository, userCouponRepository, eventPublisher, cacheManager); } @Nested diff --git a/apps/commerce-api/src/test/java/com/loopers/application/product/ProductServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductServiceTest.java index 2003aa516..b4ee1c5d5 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/product/ProductServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductServiceTest.java @@ -9,6 +9,7 @@ import com.loopers.domain.repository.BrandRepository; import com.loopers.domain.repository.ProductRepository; import com.loopers.support.error.CoreException; +import org.springframework.cache.CacheManager; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; @@ -25,13 +26,15 @@ class ProductServiceTest { private ProductRepository productRepository; private BrandRepository brandRepository; + private CacheManager cacheManager; private ProductService service; @BeforeEach void setUp() { productRepository = mock(ProductRepository.class); brandRepository = mock(BrandRepository.class); - service = new ProductService(productRepository, brandRepository); + cacheManager = mock(CacheManager.class); + service = new ProductService(productRepository, brandRepository, cacheManager); } @Nested diff --git a/apps/commerce-api/src/test/java/com/loopers/concurrency/CouponConcurrencyTest.java b/apps/commerce-api/src/test/java/com/loopers/concurrency/CouponConcurrencyTest.java index 02645dfba..525a64868 100644 --- a/apps/commerce-api/src/test/java/com/loopers/concurrency/CouponConcurrencyTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/concurrency/CouponConcurrencyTest.java @@ -9,7 +9,7 @@ import com.loopers.interfaces.api.order.dto.OrderCreateRequest; import com.loopers.interfaces.api.product.dto.ProductCreateRequest; import com.loopers.interfaces.api.user.dto.UserRegisterRequest; -import com.loopers.testcontainers.MySqlTestContainersConfig; +import com.loopers.testcontainers.PostgreSQLTestContainersConfig; import com.loopers.utils.DatabaseCleanUp; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -33,7 +33,7 @@ import static org.assertj.core.api.Assertions.assertThat; @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) -@Import(MySqlTestContainersConfig.class) +@Import(PostgreSQLTestContainersConfig.class) class CouponConcurrencyTest { @Autowired diff --git a/apps/commerce-api/src/test/java/com/loopers/concurrency/LikeConcurrencyTest.java b/apps/commerce-api/src/test/java/com/loopers/concurrency/LikeConcurrencyTest.java index 1cc22a725..1049f4495 100644 --- a/apps/commerce-api/src/test/java/com/loopers/concurrency/LikeConcurrencyTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/concurrency/LikeConcurrencyTest.java @@ -4,7 +4,7 @@ import com.loopers.interfaces.api.product.dto.ProductCreateRequest; import com.loopers.interfaces.api.product.dto.ProductDetailResponse; import com.loopers.interfaces.api.user.dto.UserRegisterRequest; -import com.loopers.testcontainers.MySqlTestContainersConfig; +import com.loopers.testcontainers.PostgreSQLTestContainersConfig; import com.loopers.utils.DatabaseCleanUp; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -24,7 +24,7 @@ import static org.assertj.core.api.Assertions.assertThat; @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) -@Import(MySqlTestContainersConfig.class) +@Import(PostgreSQLTestContainersConfig.class) class LikeConcurrencyTest { @Autowired diff --git a/apps/commerce-api/src/test/java/com/loopers/concurrency/StockConcurrencyTest.java b/apps/commerce-api/src/test/java/com/loopers/concurrency/StockConcurrencyTest.java index 98f8d33fe..f5917fda6 100644 --- a/apps/commerce-api/src/test/java/com/loopers/concurrency/StockConcurrencyTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/concurrency/StockConcurrencyTest.java @@ -5,7 +5,7 @@ import com.loopers.interfaces.api.product.dto.ProductCreateRequest; import com.loopers.interfaces.api.product.dto.ProductDetailResponse; import com.loopers.interfaces.api.user.dto.UserRegisterRequest; -import com.loopers.testcontainers.MySqlTestContainersConfig; +import com.loopers.testcontainers.PostgreSQLTestContainersConfig; import com.loopers.utils.DatabaseCleanUp; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -26,7 +26,7 @@ import static org.assertj.core.api.Assertions.assertThat; @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) -@Import(MySqlTestContainersConfig.class) +@Import(PostgreSQLTestContainersConfig.class) class StockConcurrencyTest { @Autowired diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandApiE2ETest.java index 9f1fda322..e96fae5a4 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandApiE2ETest.java @@ -3,7 +3,7 @@ import com.loopers.interfaces.api.brand.dto.BrandCreateRequest; import com.loopers.interfaces.api.brand.dto.BrandResponse; import com.loopers.interfaces.api.brand.dto.BrandUpdateRequest; -import com.loopers.testcontainers.MySqlTestContainersConfig; +import com.loopers.testcontainers.PostgreSQLTestContainersConfig; import com.loopers.utils.DatabaseCleanUp; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -18,7 +18,7 @@ import static org.assertj.core.api.Assertions.assertThat; @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) -@Import(MySqlTestContainersConfig.class) +@Import(PostgreSQLTestContainersConfig.class) class BrandApiE2ETest { @Autowired diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandApiIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandApiIntegrationTest.java index 3c7c7df0c..ccf9c6c0c 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandApiIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandApiIntegrationTest.java @@ -3,7 +3,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.loopers.interfaces.api.brand.dto.BrandCreateRequest; import com.loopers.interfaces.api.brand.dto.BrandUpdateRequest; -import com.loopers.testcontainers.MySqlTestContainersConfig; +import com.loopers.testcontainers.PostgreSQLTestContainersConfig; import com.loopers.utils.DatabaseCleanUp; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -21,7 +21,7 @@ @SpringBootTest @AutoConfigureMockMvc -@Import(MySqlTestContainersConfig.class) +@Import(PostgreSQLTestContainersConfig.class) class BrandApiIntegrationTest { @Autowired diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/LikeApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/LikeApiE2ETest.java index 55a614978..20541d7b5 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/LikeApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/LikeApiE2ETest.java @@ -4,7 +4,7 @@ import com.loopers.interfaces.api.product.dto.ProductCreateRequest; import com.loopers.interfaces.api.product.dto.ProductDetailResponse; import com.loopers.interfaces.api.user.dto.UserRegisterRequest; -import com.loopers.testcontainers.MySqlTestContainersConfig; +import com.loopers.testcontainers.PostgreSQLTestContainersConfig; import com.loopers.utils.DatabaseCleanUp; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -21,7 +21,7 @@ import static org.assertj.core.api.Assertions.assertThat; @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) -@Import(MySqlTestContainersConfig.class) +@Import(PostgreSQLTestContainersConfig.class) class LikeApiE2ETest { @Autowired diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/LikeApiIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/LikeApiIntegrationTest.java index 54bec699a..040550568 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/LikeApiIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/LikeApiIntegrationTest.java @@ -4,7 +4,7 @@ import com.loopers.interfaces.api.brand.dto.BrandCreateRequest; import com.loopers.interfaces.api.product.dto.ProductCreateRequest; import com.loopers.interfaces.api.user.dto.UserRegisterRequest; -import com.loopers.testcontainers.MySqlTestContainersConfig; +import com.loopers.testcontainers.PostgreSQLTestContainersConfig; import com.loopers.utils.DatabaseCleanUp; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -24,7 +24,7 @@ @SpringBootTest @AutoConfigureMockMvc -@Import(MySqlTestContainersConfig.class) +@Import(PostgreSQLTestContainersConfig.class) class LikeApiIntegrationTest { @Autowired diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderApiE2ETest.java index d8de6be86..c8071fd16 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderApiE2ETest.java @@ -4,11 +4,10 @@ import com.loopers.interfaces.api.order.dto.DeliveryAddressUpdateRequest; import com.loopers.interfaces.api.order.dto.OrderCreateRequest; import com.loopers.interfaces.api.order.dto.OrderDetailResponse; -import com.loopers.interfaces.api.order.dto.OrderSummaryResponse; import com.loopers.interfaces.api.product.dto.ProductCreateRequest; import com.loopers.interfaces.api.product.dto.ProductDetailResponse; import com.loopers.interfaces.api.user.dto.UserRegisterRequest; -import com.loopers.testcontainers.MySqlTestContainersConfig; +import com.loopers.testcontainers.PostgreSQLTestContainersConfig; import com.loopers.utils.DatabaseCleanUp; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -18,7 +17,6 @@ import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.web.client.TestRestTemplate; import org.springframework.context.annotation.Import; -import org.springframework.core.ParameterizedTypeReference; import org.springframework.http.*; import java.time.LocalDate; @@ -27,7 +25,7 @@ import static org.assertj.core.api.Assertions.assertThat; @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) -@Import(MySqlTestContainersConfig.class) +@Import(PostgreSQLTestContainersConfig.class) class OrderApiE2ETest { @Autowired diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderApiIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderApiIntegrationTest.java index 3d8a18f38..6126e7191 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderApiIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderApiIntegrationTest.java @@ -6,7 +6,7 @@ import com.loopers.interfaces.api.order.dto.OrderCreateRequest; import com.loopers.interfaces.api.product.dto.ProductCreateRequest; import com.loopers.interfaces.api.user.dto.UserRegisterRequest; -import com.loopers.testcontainers.MySqlTestContainersConfig; +import com.loopers.testcontainers.PostgreSQLTestContainersConfig; import com.loopers.utils.DatabaseCleanUp; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -27,7 +27,7 @@ @SpringBootTest @AutoConfigureMockMvc -@Import(MySqlTestContainersConfig.class) +@Import(PostgreSQLTestContainersConfig.class) class OrderApiIntegrationTest { @Autowired diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductApiE2ETest.java index b2f6955ce..a4119a390 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductApiE2ETest.java @@ -1,10 +1,9 @@ package com.loopers.interfaces.api.product; import com.loopers.interfaces.api.brand.dto.BrandCreateRequest; -import com.loopers.interfaces.api.common.PageResponse; import com.loopers.interfaces.api.product.dto.ProductCreateRequest; import com.loopers.interfaces.api.product.dto.ProductDetailResponse; -import com.loopers.testcontainers.MySqlTestContainersConfig; +import com.loopers.testcontainers.PostgreSQLTestContainersConfig; import com.loopers.utils.DatabaseCleanUp; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -14,13 +13,12 @@ import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.web.client.TestRestTemplate; import org.springframework.context.annotation.Import; -import org.springframework.core.ParameterizedTypeReference; import org.springframework.http.*; import static org.assertj.core.api.Assertions.assertThat; @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) -@Import(MySqlTestContainersConfig.class) +@Import(PostgreSQLTestContainersConfig.class) class ProductApiE2ETest { @Autowired diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductApiIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductApiIntegrationTest.java index a7e4cf2f8..2508c8dec 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductApiIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductApiIntegrationTest.java @@ -4,7 +4,7 @@ import com.loopers.interfaces.api.brand.dto.BrandCreateRequest; import com.loopers.interfaces.api.product.dto.ProductCreateRequest; import com.loopers.interfaces.api.product.dto.ProductUpdateRequest; -import com.loopers.testcontainers.MySqlTestContainersConfig; +import com.loopers.testcontainers.PostgreSQLTestContainersConfig; import com.loopers.utils.DatabaseCleanUp; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -22,7 +22,7 @@ @SpringBootTest @AutoConfigureMockMvc -@Import(MySqlTestContainersConfig.class) +@Import(PostgreSQLTestContainersConfig.class) class ProductApiIntegrationTest { @Autowired diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/UserApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/UserApiE2ETest.java index 42accb243..a057ba011 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/UserApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/UserApiE2ETest.java @@ -3,7 +3,7 @@ import com.loopers.interfaces.api.user.dto.PasswordUpdateRequest; import com.loopers.interfaces.api.user.dto.UserInfoResponse; import com.loopers.interfaces.api.user.dto.UserRegisterRequest; -import com.loopers.testcontainers.MySqlTestContainersConfig; +import com.loopers.testcontainers.PostgreSQLTestContainersConfig; import com.loopers.utils.DatabaseCleanUp; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -20,7 +20,7 @@ import static org.assertj.core.api.Assertions.assertThat; @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) -@Import(MySqlTestContainersConfig.class) +@Import(PostgreSQLTestContainersConfig.class) class UserApiE2ETest { @Autowired diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/UserApiIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/UserApiIntegrationTest.java index fdc122bb3..faad3cdfc 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/UserApiIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/UserApiIntegrationTest.java @@ -3,7 +3,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.loopers.interfaces.api.user.dto.PasswordUpdateRequest; import com.loopers.interfaces.api.user.dto.UserRegisterRequest; -import com.loopers.testcontainers.MySqlTestContainersConfig; +import com.loopers.testcontainers.PostgreSQLTestContainersConfig; import com.loopers.utils.DatabaseCleanUp; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -23,7 +23,7 @@ @SpringBootTest @AutoConfigureMockMvc -@Import(MySqlTestContainersConfig.class) +@Import(PostgreSQLTestContainersConfig.class) class UserApiIntegrationTest { @Autowired diff --git a/build.gradle.kts b/build.gradle.kts index 914e50fef..2bcddf055 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -58,8 +58,8 @@ subprojects { annotationProcessor("org.projectlombok:lombok") // Test testRuntimeOnly("org.junit.platform:junit-platform-launcher") - // testcontainers:mysql 이 jdbc 사용함 - testRuntimeOnly("com.mysql:mysql-connector-j") + // testcontainers:postgresql 이 jdbc 사용함 + testRuntimeOnly("org.postgresql:postgresql") testImplementation("org.springframework.boot:spring-boot-starter-test") testImplementation("com.ninja-squad:springmockk:${project.properties["springMockkVersion"]}") testImplementation("org.mockito:mockito-core:${project.properties["mockitoVersion"]}") diff --git a/docker/infra-compose.yml b/docker/infra-compose.yml index 18e5fcf5f..3a24d8e3a 100644 --- a/docker/infra-compose.yml +++ b/docker/infra-compose.yml @@ -1,18 +1,20 @@ version: '3' services: - mysql: - image: mysql:8.0 + postgres: + image: postgres:16 ports: - - "3306:3306" + - "5432:5432" environment: - - MYSQL_ROOT_PASSWORD=root - - MYSQL_USER=application - - MYSQL_PASSWORD=application - - MYSQL_DATABASE=loopers - - MYSQL_CHARACTER_SET=utf8mb4 - - MYSQL_COLLATE=utf8mb4_general_ci + - POSTGRES_USER=application + - POSTGRES_PASSWORD=application + - POSTGRES_DB=loopers volumes: - - mysql-8-data:/var/lib/mysql + - postgres-data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U application -d loopers"] + interval: 5s + timeout: 3s + retries: 10 redis-master: image: redis:7.0 @@ -101,7 +103,7 @@ services: KAFKA_CLUSTERS_0_BOOTSTRAPSERVERS: kafka:9092 # kafka-ui 가 연겷할 브로커 주소 volumes: - mysql-8-data: + postgres-data: redis_master_data: redis_readonly_data: kafka-data: diff --git a/modules/jpa/build.gradle.kts b/modules/jpa/build.gradle.kts index e62a6a7ed..0b297430a 100644 --- a/modules/jpa/build.gradle.kts +++ b/modules/jpa/build.gradle.kts @@ -11,11 +11,11 @@ dependencies { annotationProcessor("com.querydsl:querydsl-apt::jakarta") annotationProcessor("jakarta.persistence:jakarta.persistence-api") annotationProcessor("jakarta.annotation:jakarta.annotation-api") - // jdbc-mysql - runtimeOnly("com.mysql:mysql-connector-j") + // jdbc-postgresql + runtimeOnly("org.postgresql:postgresql") - testImplementation("org.testcontainers:mysql") + testImplementation("org.testcontainers:postgresql") testFixturesImplementation("org.springframework.boot:spring-boot-starter-data-jpa") - testFixturesImplementation("org.testcontainers:mysql") + testFixturesImplementation("org.testcontainers:postgresql") } diff --git a/modules/jpa/src/main/java/com/loopers/config/jpa/DataSourceConfig.java b/modules/jpa/src/main/java/com/loopers/config/jpa/DataSourceConfig.java index 723ffa6a4..649cf4e3d 100644 --- a/modules/jpa/src/main/java/com/loopers/config/jpa/DataSourceConfig.java +++ b/modules/jpa/src/main/java/com/loopers/config/jpa/DataSourceConfig.java @@ -11,14 +11,14 @@ @Configuration class DataSourceConfig { @Bean - @ConfigurationProperties(prefix = "datasource.mysql-jpa.main") - HikariConfig mySqlMainHikariConfig() { + @ConfigurationProperties(prefix = "datasource.postgres-jpa.main") + HikariConfig postgresMainHikariConfig() { return new HikariConfig(); } @Primary @Bean - HikariDataSource mySqlMainDataSource(@Qualifier("mySqlMainHikariConfig") HikariConfig hikariConfig) { + HikariDataSource postgresMainDataSource(@Qualifier("postgresMainHikariConfig") HikariConfig hikariConfig) { return new HikariDataSource(hikariConfig); } } diff --git a/modules/jpa/src/main/resources/jpa.yml b/modules/jpa/src/main/resources/jpa.yml index 37f4fb1b0..d40ef6133 100644 --- a/modules/jpa/src/main/resources/jpa.yml +++ b/modules/jpa/src/main/resources/jpa.yml @@ -12,23 +12,21 @@ spring: jdbc.time_zone: UTC datasource: - mysql-jpa: + postgres-jpa: main: - driver-class-name: com.mysql.cj.jdbc.Driver - jdbc-url: jdbc:mysql://${MYSQL_HOST}:${MYSQL_PORT} - username: ${MYSQL_USER} - password: "${MYSQL_PWD}" - pool-name: mysql-main-pool + driver-class-name: org.postgresql.Driver + jdbc-url: jdbc:postgresql://${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB} + username: ${POSTGRES_USER} + password: "${POSTGRES_PWD}" + pool-name: postgres-main-pool maximum-pool-size: 40 minimum-idle: 30 - connection-timeout: 3000 # 커넥션 획득 대기시간(ms) ( default: 3000 = 3sec ) - validation-timeout: 5000 # 커넥션 유효성 검사시간(ms) ( default: 5000 = 5sec ) - keepalive-time: 0 # 커넥션 최대 생존시간(ms) ( default: 0 ) - max-lifetime: 1800000 # 커넥션 최대 생존시간(ms) ( default: 1800000 = 30min ) - leak-detection-threshold: 0 # 커넥션 누수 감지 (주어진 ms 내에 반환 안 하면 로그 경고) ( default: 0 = 비활성화 ) - initialization-fail-timeout: 1 # DB 연결 실패 시 즉시 예외 발생 ( default: -1 = 무한대기 ) - data-source-properties: - rewriteBatchedStatements: true + connection-timeout: 3000 + validation-timeout: 5000 + keepalive-time: 0 + max-lifetime: 1800000 + leak-detection-threshold: 0 + initialization-fail-timeout: 1 --- spring.config.activate.on-profile: local @@ -37,12 +35,12 @@ spring: jpa: show-sql: true hibernate: - ddl-auto: create + ddl-auto: create-drop datasource: - mysql-jpa: + postgres-jpa: main: - jdbc-url: jdbc:mysql://localhost:3306/loopers + jdbc-url: jdbc:postgresql://localhost:5432/loopers username: application password: application @@ -56,7 +54,7 @@ spring: ddl-auto: create datasource: - mysql-jpa: + postgres-jpa: main: maximum-pool-size: 10 minimum-idle: 5 @@ -69,9 +67,9 @@ spring: show-sql: true datasource: - mysql-jpa: + postgres-jpa: main: - jdbc-url: jdbc:mysql://localhost:3306/loopers + jdbc-url: jdbc:postgresql://localhost:5432/loopers username: application password: application @@ -79,9 +77,9 @@ datasource: spring.config.activate.on-profile: qa datasource: - mysql-jpa: + postgres-jpa: main: - jdbc-url: jdbc:mysql://localhost:3306/loopers + jdbc-url: jdbc:postgresql://localhost:5432/loopers username: application password: application @@ -89,8 +87,8 @@ datasource: spring.config.activate.on-profile: prd datasource: - mysql-jpa: + postgres-jpa: main: - jdbc-url: jdbc:mysql://localhost:3306/loopers + jdbc-url: jdbc:postgresql://localhost:5432/loopers username: application password: application diff --git a/modules/jpa/src/testFixtures/java/com/loopers/testcontainers/MySqlTestContainersConfig.java b/modules/jpa/src/testFixtures/java/com/loopers/testcontainers/MySqlTestContainersConfig.java index 9c41edacc..80e07b362 100644 --- a/modules/jpa/src/testFixtures/java/com/loopers/testcontainers/MySqlTestContainersConfig.java +++ b/modules/jpa/src/testFixtures/java/com/loopers/testcontainers/MySqlTestContainersConfig.java @@ -1,36 +1,23 @@ package com.loopers.testcontainers; import org.springframework.context.annotation.Configuration; -import org.testcontainers.containers.MySQLContainer; +import org.testcontainers.containers.PostgreSQLContainer; import org.testcontainers.utility.DockerImageName; @Configuration public class MySqlTestContainersConfig { - private static final MySQLContainer mySqlContainer; + private static final PostgreSQLContainer postgresContainer; static { - mySqlContainer = new MySQLContainer<>(DockerImageName.parse("mysql:8.0")) + postgresContainer = new PostgreSQLContainer<>(DockerImageName.parse("postgres:16")) .withDatabaseName("loopers") .withUsername("test") - .withPassword("test") - .withExposedPorts(3306) - .withCommand( - "--character-set-server=utf8mb4", - "--collation-server=utf8mb4_general_ci", - "--skip-character-set-client-handshake" - ); - mySqlContainer.start(); + .withPassword("test"); + postgresContainer.start(); - String mySqlJdbcUrl = String.format( - "jdbc:mysql://%s:%d/%s", - mySqlContainer.getHost(), - mySqlContainer.getFirstMappedPort(), - mySqlContainer.getDatabaseName() - ); - - System.setProperty("datasource.mysql-jpa.main.jdbc-url", mySqlJdbcUrl); - System.setProperty("datasource.mysql-jpa.main.username", mySqlContainer.getUsername()); - System.setProperty("datasource.mysql-jpa.main.password", mySqlContainer.getPassword()); + System.setProperty("datasource.postgres-jpa.main.jdbc-url", postgresContainer.getJdbcUrl()); + System.setProperty("datasource.postgres-jpa.main.username", postgresContainer.getUsername()); + System.setProperty("datasource.postgres-jpa.main.password", postgresContainer.getPassword()); } } diff --git a/modules/jpa/src/testFixtures/java/com/loopers/testcontainers/PostgreSQLTestContainersConfig.java b/modules/jpa/src/testFixtures/java/com/loopers/testcontainers/PostgreSQLTestContainersConfig.java new file mode 100644 index 000000000..bf7323f6b --- /dev/null +++ b/modules/jpa/src/testFixtures/java/com/loopers/testcontainers/PostgreSQLTestContainersConfig.java @@ -0,0 +1,23 @@ +package com.loopers.testcontainers; + +import org.springframework.context.annotation.Configuration; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.utility.DockerImageName; + +@Configuration +public class PostgreSQLTestContainersConfig { + + private static final PostgreSQLContainer postgresContainer; + + static { + postgresContainer = new PostgreSQLContainer<>(DockerImageName.parse("postgres:16")) + .withDatabaseName("loopers") + .withUsername("test") + .withPassword("test"); + postgresContainer.start(); + + System.setProperty("datasource.postgres-jpa.main.jdbc-url", postgresContainer.getJdbcUrl()); + System.setProperty("datasource.postgres-jpa.main.username", postgresContainer.getUsername()); + System.setProperty("datasource.postgres-jpa.main.password", postgresContainer.getPassword()); + } +} diff --git a/modules/jpa/src/testFixtures/java/com/loopers/utils/DatabaseCleanUp.java b/modules/jpa/src/testFixtures/java/com/loopers/utils/DatabaseCleanUp.java index 85c43ace9..33632af24 100644 --- a/modules/jpa/src/testFixtures/java/com/loopers/utils/DatabaseCleanUp.java +++ b/modules/jpa/src/testFixtures/java/com/loopers/utils/DatabaseCleanUp.java @@ -30,12 +30,9 @@ public void afterPropertiesSet() { @Transactional public void truncateAllTables() { entityManager.flush(); - entityManager.createNativeQuery("SET FOREIGN_KEY_CHECKS = 0").executeUpdate(); for (String table : tableNames) { - entityManager.createNativeQuery("TRUNCATE TABLE `" + table + "`").executeUpdate(); + entityManager.createNativeQuery("TRUNCATE TABLE " + table + " RESTART IDENTITY CASCADE").executeUpdate(); } - - entityManager.createNativeQuery("SET FOREIGN_KEY_CHECKS = 1").executeUpdate(); } } diff --git a/scripts/V5__create_product_indexes.sql b/scripts/V5__create_product_indexes.sql new file mode 100644 index 000000000..11b8bb593 --- /dev/null +++ b/scripts/V5__create_product_indexes.sql @@ -0,0 +1,29 @@ +-- ============================================ +-- V5: 상품 테이블 Partial Index 생성 +-- 목적: 상품 목록/상세 조회 성능 최적화 +-- Target: PostgreSQL 16 +-- ============================================ + +-- 1. 브랜드 필터 + 좋아요 순 정렬 +-- 용도: GET /api/v1/products?brandId={id}&sort=likes_desc +CREATE INDEX IF NOT EXISTS idx_products_active_brand_likes + ON products (brand_id, like_count DESC) + WHERE deleted_at IS NULL; + +-- 2. 가격순 정렬 +-- 용도: GET /api/v1/products?sort=price_asc / price_desc +CREATE INDEX IF NOT EXISTS idx_products_active_price + ON products (price ASC) + WHERE deleted_at IS NULL; + +-- 3. 좋아요 순 정렬 (브랜드 필터 없이) +-- 용도: GET /api/v1/products?sort=likes_desc +CREATE INDEX IF NOT EXISTS idx_products_active_likes + ON products (like_count DESC) + WHERE deleted_at IS NULL; + +-- 4. 최신순 정렬 (기본 정렬) +-- 용도: GET /api/v1/products (기본) +CREATE INDEX IF NOT EXISTS idx_products_active_created + ON products (created_at DESC) + WHERE deleted_at IS NULL; diff --git a/scripts/k6-like-concurrency-test.js b/scripts/k6-like-concurrency-test.js new file mode 100644 index 000000000..d46115ca5 --- /dev/null +++ b/scripts/k6-like-concurrency-test.js @@ -0,0 +1,309 @@ +import http from 'k6/http'; +import { check, sleep, group } from 'k6'; +import { Trend, Counter, Rate, Gauge } from 'k6/metrics'; +import { SharedArray } from 'k6/data'; + +// ============================================ +// 커스텀 메트릭 +// ============================================ +const likeDuration = new Trend('like_duration', true); +const unlikeDuration = new Trend('unlike_duration', true); +const likeListDuration = new Trend('like_list_duration', true); +const likeErrorRate = new Rate('like_errors'); +const concurrencyConflicts = new Counter('concurrency_conflicts'); +const slowLikeCount = new Counter('slow_likes'); + +const BASE_URL = __ENV.BASE_URL || 'http://localhost:8080'; +const SLOW_THRESHOLD = 500; +const USER_COUNT = 99; +const PASSWORD = 'Passw0rd11'; + +// ============================================ +// 1단계: Setup — 테스트용 유저 생성 +// ============================================ +export function setup() { + console.log('=== Setup: 테스트 유저 생성 ==='); + + const users = []; + for (let i = 1; i <= USER_COUNT; i++) { + const loginId = `likeuser${i}`; + const res = http.post( + `${BASE_URL}/api/v1/users`, + JSON.stringify({ + loginId: loginId, + password: PASSWORD, + name: `테스트유저${i}`, + birthday: '1990-06-20', + email: `k6user${i}@test.com`, + }), + { headers: { 'Content-Type': 'application/json' } } + ); + + if (res.status === 200 || res.status === 409) { + users.push(loginId); + } + } + + console.log(`생성된 유저 수: ${users.length}`); + return { users }; +} + +// ============================================ +// 테스트 시나리오 설정 +// ============================================ +export const options = { + scenarios: { + // 시나리오 1: 좋아요 부하 테스트 (다양한 유저 → 다양한 상품) + like_load: { + executor: 'ramping-vus', + startVUs: 0, + stages: [ + { duration: '10s', target: 20 }, // 워밍업 + { duration: '20s', target: 50 }, // 일반 부하 + { duration: '20s', target: 100 }, // 고부하 + { duration: '10s', target: 0 }, // 쿨다운 + ], + exec: 'likeLoadTest', + tags: { test_type: 'like_load' }, + }, + + // 시나리오 2: 동시성 테스트 (다수 유저 → 동일 상품에 동시 좋아요) + concurrency_test: { + executor: 'per-vu-iterations', + vus: 50, + iterations: 1, + startTime: '65s', + exec: 'concurrencyTest', + tags: { test_type: 'concurrency' }, + }, + + // 시나리오 3: 좋아요 + 취소 반복 (동시성 충돌 유발) + like_unlike_race: { + executor: 'per-vu-iterations', + vus: 30, + iterations: 5, + startTime: '75s', + exec: 'likeUnlikeRace', + tags: { test_type: 'race_condition' }, + }, + }, + thresholds: { + like_duration: ['p(95)<500', 'p(99)<1000'], + unlike_duration: ['p(95)<500', 'p(99)<1000'], + like_list_duration: ['p(95)<300', 'p(99)<500'], + like_errors: ['rate<0.05'], // 에러율 5% 미만 + }, +}; + +// ============================================ +// 헬퍼 함수 +// ============================================ +function authHeaders(loginId) { + return { + 'Content-Type': 'application/json', + 'X-Loopers-LoginId': loginId, + 'X-Loopers-LoginPw': PASSWORD, + }; +} + +function trackResponse(res, metricTrend, label) { + metricTrend.add(res.timings.duration); + if (res.timings.duration > SLOW_THRESHOLD) { + slowLikeCount.add(1); + console.warn(`🐢 SLOW [${res.timings.duration.toFixed(0)}ms] ${label} - status: ${res.status}`); + } + if (res.status >= 400 && res.status !== 409) { + likeErrorRate.add(1); + } else { + likeErrorRate.add(0); + } +} + +// ============================================ +// 시나리오 1: 좋아요 부하 테스트 +// ============================================ +export function likeLoadTest(data) { + const userIndex = (__VU % USER_COUNT) + 1; + const loginId = `likeuser${userIndex}`; + const headers = authHeaders(loginId); + const productId = Math.floor(Math.random() * 13000) + 1; + + // 1. 좋아요 등록 + group('좋아요 등록', () => { + const res = http.post(`${BASE_URL}/api/v1/products/${productId}/likes`, null, { headers }); + check(res, { 'like 200 OK': (r) => r.status === 200 }); + trackResponse(res, likeDuration, `POST /products/${productId}/likes`); + }); + + sleep(0.05); + + // 2. 좋아요 목록 조회 + group('좋아요 목록 조회', () => { + const res = http.get(`${BASE_URL}/api/v1/users/${loginId}/likes`, { headers }); + check(res, { 'like list 200': (r) => r.status === 200 }); + trackResponse(res, likeListDuration, `GET /users/${loginId}/likes`); + }); + + sleep(0.05); + + // 3. 좋아요 취소 + group('좋아요 취소', () => { + const res = http.del(`${BASE_URL}/api/v1/products/${productId}/likes`, null, { headers }); + check(res, { 'unlike 200 OK': (r) => r.status === 200 }); + trackResponse(res, unlikeDuration, `DELETE /products/${productId}/likes`); + }); + + sleep(0.1); +} + +// ============================================ +// 시나리오 2: 동시성 테스트 — 50명이 동일 상품에 동시 좋아요 +// ============================================ +export function concurrencyTest(data) { + const targetProductId = 1; // 모든 VU가 같은 상품 + const userIndex = ((__VU - 1) % USER_COUNT) + 1; + const loginId = `likeuser${userIndex}`; + const headers = authHeaders(loginId); + + group('동시 좋아요 (상품 #1)', () => { + const res = http.post(`${BASE_URL}/api/v1/products/${targetProductId}/likes`, null, { headers }); + + const ok = check(res, { + 'concurrent like success': (r) => r.status === 200, + }); + + if (!ok) { + concurrencyConflicts.add(1); + console.warn(`⚡ CONFLICT VU=${__VU} status=${res.status} body=${res.body}`); + } + + trackResponse(res, likeDuration, `CONCURRENT POST /products/${targetProductId}/likes`); + }); +} + +// ============================================ +// 시나리오 3: 좋아요/취소 레이스 컨디션 +// ============================================ +export function likeUnlikeRace(data) { + const targetProductId = 2; // 레이스 컨디션 타겟 + const userIndex = ((__VU - 1) % USER_COUNT) + 1; + const loginId = `likeuser${userIndex}`; + const headers = authHeaders(loginId); + + group('좋아요-취소 레이스', () => { + // 좋아요 + const likeRes = http.post(`${BASE_URL}/api/v1/products/${targetProductId}/likes`, null, { headers }); + trackResponse(likeRes, likeDuration, `RACE POST /products/${targetProductId}/likes`); + + // 즉시 취소 (레이스 유발) + const unlikeRes = http.del(`${BASE_URL}/api/v1/products/${targetProductId}/likes`, null, { headers }); + trackResponse(unlikeRes, unlikeDuration, `RACE DELETE /products/${targetProductId}/likes`); + + if (likeRes.status >= 500 || unlikeRes.status >= 500) { + concurrencyConflicts.add(1); + console.error(`🚨 RACE ERROR VU=${__VU} like=${likeRes.status} unlike=${unlikeRes.status}`); + } + }); +} + +// ============================================ +// 테스트 종료 후 검증 + 요약 +// ============================================ +export function teardown(data) { + console.log('=== Teardown: 동시성 검증 ==='); + + // 상품 #1의 likeCount 확인 (50명이 동시 좋아요 → 50이어야 정상) + const productRes = http.get(`${BASE_URL}/api/v1/products/1`); + if (productRes.status === 200) { + const product = JSON.parse(productRes.body); + const likeCount = product.likeCount !== undefined ? product.likeCount : 'N/A'; + console.log(`🔍 상품 #1 likeCount: ${likeCount} (기대값: 50)`); + + if (likeCount !== 50 && likeCount !== 'N/A') { + console.error(`🚨 동시성 문제 감지! 기대값=50, 실제값=${likeCount}`); + } + } + + // 상품 #2의 likeCount 확인 (레이스 후 0이어야 정상) + const product2Res = http.get(`${BASE_URL}/api/v1/products/2`); + if (product2Res.status === 200) { + const product2 = JSON.parse(product2Res.body); + const likeCount2 = product2.likeCount !== undefined ? product2.likeCount : 'N/A'; + console.log(`🔍 상품 #2 likeCount: ${likeCount2} (기대값: 0, 레이스 테스트 후)`); + + if (likeCount2 !== 0 && likeCount2 !== 'N/A') { + console.warn(`⚠️ 레이스 컨디션 후 likeCount 불일치: 기대값=0, 실제값=${likeCount2}`); + } + } + + // DB 직접 확인용 안내 + console.log(''); + console.log('=== DB 검증 쿼리 ==='); + console.log("SELECT id, like_count FROM products WHERE id IN (1, 2);"); + console.log("SELECT product_id, COUNT(*) FROM likes WHERE product_id IN (1, 2) GROUP BY product_id;"); +} + +// ============================================ +// 결과 요약 +// ============================================ +function getMetricP(data, name, percentile) { + var m = data.metrics[name]; + if (!m || !m.values) return 'N/A'; + var v = m.values['p(' + percentile + ')']; + return v ? v.toFixed(0) + 'ms' : 'N/A'; +} + +function getMetricCount(data, name) { + var m = data.metrics[name]; + if (!m || !m.values) return 0; + return m.values.count || 0; +} + +function getMetricAvg(data, name) { + var m = data.metrics[name]; + if (!m || !m.values) return 'N/A'; + return m.values.avg ? m.values.avg.toFixed(0) + 'ms' : 'N/A'; +} + +export function handleSummary(data) { + var totalReqs = getMetricCount(data, 'http_reqs'); + var duration = data.state ? data.state.testRunDurationMs / 1000 : 90; + var qps = totalReqs > 0 ? (totalReqs / duration).toFixed(1) : 'N/A'; + + var lines = [ + '', + '╔══════════════════════════════════════════════════╗', + '║ 좋아요 부하 + 동시성 테스트 결과 ║', + '╚══════════════════════════════════════════════════╝', + '', + '── 전체 지표 ──', + ' 총 요청 수: ' + totalReqs, + ' QPS: ' + qps + ' req/s', + ' 총 슬로우 (>500ms): ' + getMetricCount(data, 'slow_likes'), + ' 동시성 충돌: ' + getMetricCount(data, 'concurrency_conflicts'), + '', + '── 좋아요 등록 ──', + ' avg: ' + getMetricAvg(data, 'like_duration'), + ' p95: ' + getMetricP(data, 'like_duration', 95), + ' p99: ' + getMetricP(data, 'like_duration', 99), + '', + '── 좋아요 취소 ──', + ' avg: ' + getMetricAvg(data, 'unlike_duration'), + ' p95: ' + getMetricP(data, 'unlike_duration', 95), + ' p99: ' + getMetricP(data, 'unlike_duration', 99), + '', + '── 좋아요 목록 조회 ──', + ' avg: ' + getMetricAvg(data, 'like_list_duration'), + ' p95: ' + getMetricP(data, 'like_list_duration', 95), + ' p99: ' + getMetricP(data, 'like_list_duration', 99), + '', + '── Threshold 결과 ──', + ' 좋아요 p95 < 500ms: ' + (data.metrics.like_duration && data.metrics.like_duration.thresholds ? JSON.stringify(data.metrics.like_duration.thresholds) : 'N/A'), + ' 취소 p95 < 500ms: ' + (data.metrics.unlike_duration && data.metrics.unlike_duration.thresholds ? JSON.stringify(data.metrics.unlike_duration.thresholds) : 'N/A'), + ' 에러율 < 5%: ' + (data.metrics.like_errors && data.metrics.like_errors.thresholds ? JSON.stringify(data.metrics.like_errors.thresholds) : 'N/A'), + '', + ]; + + console.log(lines.join('\n')); + return {}; +} \ No newline at end of file diff --git a/scripts/k6-lost-update-test.js b/scripts/k6-lost-update-test.js new file mode 100644 index 000000000..5c1c8b150 --- /dev/null +++ b/scripts/k6-lost-update-test.js @@ -0,0 +1,189 @@ +import http from 'k6/http'; +import { check, sleep, group } from 'k6'; +import { Counter, Trend } from 'k6/metrics'; + +const BASE_URL = __ENV.BASE_URL || 'http://localhost:8080'; +const PASSWORD = 'Passw0rd11'; +const ADMIN_HEADER = 'X-Loopers-Ldap'; +const ADMIN_VALUE = 'loopers.admin'; +const TARGET_PRODUCT_ID = 3; // 테스트 대상 상품 + +const likeDuration = new Trend('like_duration', true); +const adminUpdateDuration = new Trend('admin_update_duration', true); +const lostUpdateDetected = new Counter('lost_updates_detected'); + +// ============================================ +// Setup: 테스트 유저 생성 + 초기 상태 기록 +// ============================================ +export function setup() { + // 상품 #3의 초기 상태 확인 + const productRes = http.get(`${BASE_URL}/api/v1/products/${TARGET_PRODUCT_ID}`); + const product = JSON.parse(productRes.body); + console.log(`초기 상태 - 상품 #${TARGET_PRODUCT_ID}: likeCount=${product.likeCount}, price=${product.price}`); + + // 테스트 유저 50명 생성 + const users = []; + for (let i = 1; i <= 50; i++) { + const loginId = `lostuser${i}`; + const res = http.post( + `${BASE_URL}/api/v1/users`, + JSON.stringify({ + loginId: loginId, + password: PASSWORD, + name: `losttest${i}`, + birthday: '1990-06-20', + email: `lost${i}@test.com`, + }), + { headers: { 'Content-Type': 'application/json' } } + ); + if (res.status === 200) users.push(loginId); + } + console.log(`생성된 유저: ${users.length}명`); + + return { + initialLikeCount: product.likeCount || 0, + initialPrice: product.price, + productName: product.name, + }; +} + +// ============================================ +// 시나리오 설정 +// ============================================ +export const options = { + scenarios: { + // 시나리오 1: 유저들이 좋아요를 동시에 누름 + concurrent_likes: { + executor: 'per-vu-iterations', + vus: 50, + iterations: 1, + exec: 'likeProduct', + tags: { test_type: 'like' }, + }, + + // 시나리오 2: 어드민이 동시에 상품 정보를 수정 (가격 변경) + admin_update: { + executor: 'per-vu-iterations', + vus: 5, + iterations: 3, + exec: 'adminUpdateProduct', + tags: { test_type: 'admin_update' }, + }, + }, +}; + +// ============================================ +// 시나리오 1: 좋아요 등록 +// ============================================ +export function likeProduct(data) { + const userIndex = ((__VU - 1) % 50) + 1; + const loginId = `lostuser${userIndex}`; + const headers = { + 'Content-Type': 'application/json', + 'X-Loopers-LoginId': loginId, + 'X-Loopers-LoginPw': PASSWORD, + }; + + group('좋아요 등록', () => { + const res = http.post( + `${BASE_URL}/api/v1/products/${TARGET_PRODUCT_ID}/likes`, + null, + { headers } + ); + check(res, { 'like 200': (r) => r.status === 200 }); + likeDuration.add(res.timings.duration); + }); +} + +// ============================================ +// 시나리오 2: 어드민 상품 수정 (가격만 변경) +// ============================================ +export function adminUpdateProduct(data) { + const headers = { + 'Content-Type': 'application/json', + [ADMIN_HEADER]: ADMIN_VALUE, + }; + + group('어드민 상품 수정', () => { + // 먼저 현재 상품 정보 조회 + const getRes = http.get(`${BASE_URL}/api/v1/products/${TARGET_PRODUCT_ID}`); + const product = JSON.parse(getRes.body); + + // 가격만 변경하여 수정 (다른 필드는 기존 값 유지) + const newPrice = product.price + 1000; + const updateBody = JSON.stringify({ + name: product.name, + price: newPrice, + salePrice: product.salePrice || null, + stock: product.stock, + description: product.description || 'updated', + }); + + const res = http.put( + `${BASE_URL}/api-admin/v1/products/${TARGET_PRODUCT_ID}`, + updateBody, + { headers } + ); + check(res, { 'admin update 200': (r) => r.status === 200 }); + adminUpdateDuration.add(res.timings.duration); + + if (res.status === 200) { + console.log( + `어드민 수정: price ${product.price} → ${newPrice}, 수정 시점 likeCount=${product.likeCount}` + ); + } + }); + + sleep(0.05); // 약간의 간격 +} + +// ============================================ +// Teardown: 결과 검증 +// ============================================ +export function teardown(data) { + console.log(''); + console.log('=== Lost Update 검증 ==='); + + // 최종 상품 상태 + const productRes = http.get(`${BASE_URL}/api/v1/products/${TARGET_PRODUCT_ID}`); + const product = JSON.parse(productRes.body); + + // likes 테이블의 실제 좋아요 수 + // (API로는 확인 불가하므로 likeCount와 비교) + const finalLikeCount = product.likeCount; + const expectedLikeCount = data.initialLikeCount + 50; // 50명이 좋아요 + + console.log(`초기 likeCount: ${data.initialLikeCount}`); + console.log(`기대 likeCount: ${expectedLikeCount} (초기 + 50명)`); + console.log(`실제 likeCount: ${finalLikeCount}`); + console.log(`최종 price: ${product.price}`); + console.log(''); + + if (finalLikeCount !== expectedLikeCount) { + const lost = expectedLikeCount - finalLikeCount; + console.log(`🚨 LOST UPDATE 감지! ${lost}건의 좋아요가 유실됨`); + console.log(` 원인: 어드민 수정이 좋아요 카운트를 덮어씀`); + } else { + console.log(`✅ likeCount 정상 (${finalLikeCount})`); + } + + console.log(''); + console.log('=== DB 검증 쿼리 ==='); + console.log(`SELECT like_count FROM products WHERE id = ${TARGET_PRODUCT_ID};`); + console.log(`SELECT COUNT(*) FROM likes WHERE product_id = ${TARGET_PRODUCT_ID};`); +} + +// ============================================ +// 결과 요약 +// ============================================ +export function handleSummary(data) { + var lines = [ + '', + '╔══════════════════════════════════════════════╗', + '║ Lost Update 동시성 테스트 결과 ║', + '╚══════════════════════════════════════════════╝', + '', + ]; + console.log(lines.join('\n')); + return {}; +} diff --git a/scripts/k6-slow-query-test.js b/scripts/k6-slow-query-test.js new file mode 100644 index 000000000..df6515228 --- /dev/null +++ b/scripts/k6-slow-query-test.js @@ -0,0 +1,213 @@ +import http from 'k6/http'; +import { check, sleep, group } from 'k6'; +import { Trend, Counter } from 'k6/metrics'; + +// ============================================ +// 커스텀 메트릭: 시나리오별 응답 시간 추적 +// ============================================ +const productListDuration = new Trend('product_list_duration', true); +const productListByBrandDuration = new Trend('product_list_by_brand_duration', true); +const productListSortedDuration = new Trend('product_list_sorted_duration', true); +const productDetailDuration = new Trend('product_detail_duration', true); +const productDeepPageDuration = new Trend('product_deep_page_duration', true); +const brandListDuration = new Trend('brand_list_duration', true); +const slowQueryCount = new Counter('slow_queries'); + +const BASE_URL = __ENV.BASE_URL || 'http://localhost:8080'; +const SLOW_THRESHOLD = 500; // 500ms 이상이면 slow query로 판정 + +// ============================================ +// 테스트 시나리오 설정 +// ============================================ +export const options = { + scenarios: { + // 1단계: 워밍업 (10 VU) + warmup: { + executor: 'constant-vus', + vus: 10, + duration: '15s', + startTime: '0s', + tags: { phase: 'warmup' }, + }, + // 2단계: 일반 부하 (50 VU) + normal_load: { + executor: 'constant-vus', + vus: 50, + duration: '30s', + startTime: '15s', + tags: { phase: 'normal' }, + }, + // 3단계: 고부하 (200 VU) + high_load: { + executor: 'constant-vus', + vus: 200, + duration: '30s', + startTime: '45s', + tags: { phase: 'high' }, + }, + // 4단계: 스파이크 (500 VU) + spike: { + executor: 'constant-vus', + vus: 500, + duration: '15s', + startTime: '75s', + tags: { phase: 'spike' }, + }, + }, + thresholds: { + // 전체 요청의 95%가 1초 이내 + http_req_duration: ['p(95)<1000'], + // 시나리오별 임계치 + product_list_duration: ['p(95)<800', 'p(99)<1500'], + product_list_by_brand_duration: ['p(95)<500', 'p(99)<1000'], + product_list_sorted_duration: ['p(95)<800', 'p(99)<1500'], + product_detail_duration: ['p(95)<300', 'p(99)<500'], + product_deep_page_duration: ['p(95)<1500', 'p(99)<3000'], + brand_list_duration: ['p(95)<300', 'p(99)<500'], + }, +}; + +// ============================================ +// 헬퍼: slow query 감지 및 기록 +// ============================================ +function trackResponse(res, metricTrend, label) { + metricTrend.add(res.timings.duration); + if (res.timings.duration > SLOW_THRESHOLD) { + slowQueryCount.add(1); + console.warn( + `🐢 SLOW [${res.timings.duration.toFixed(0)}ms] ${label} - status: ${res.status}` + ); + } +} + +// ============================================ +// 메인 테스트 함수 +// ============================================ +export default function () { + const brandIds = Array.from({ length: 20 }, (_, i) => i + 1); + const sorts = ['price_asc', 'price_desc', 'likes_desc', null]; + const randomBrandId = brandIds[Math.floor(Math.random() * brandIds.length)]; + const randomSort = sorts[Math.floor(Math.random() * sorts.length)]; + const randomProductId = Math.floor(Math.random() * 13000) + 1; + + // ── 1. 상품 목록 (필터 없음, 기본 정렬) ── + group('상품 목록 조회', () => { + const res = http.get(`${BASE_URL}/api/v1/products?page=0&size=20`); + check(res, { '200 OK': (r) => r.status === 200 }); + trackResponse(res, productListDuration, 'GET /products?page=0&size=20'); + }); + + // ── 2. 브랜드 필터 조회 ── + group('브랜드별 상품 조회', () => { + const res = http.get( + `${BASE_URL}/api/v1/products?brandId=${randomBrandId}&page=0&size=20` + ); + check(res, { '200 OK': (r) => r.status === 200 }); + trackResponse( + res, + productListByBrandDuration, + `GET /products?brandId=${randomBrandId}` + ); + }); + + // ── 3. 정렬 조회 (price, likes) ── + group('정렬 조회', () => { + const sortParam = randomSort ? `&sort=${randomSort}` : ''; + const res = http.get( + `${BASE_URL}/api/v1/products?page=0&size=20${sortParam}` + ); + check(res, { '200 OK': (r) => r.status === 200 }); + trackResponse( + res, + productListSortedDuration, + `GET /products?sort=${randomSort}` + ); + }); + + // ── 4. 깊은 페이지 조회 (offset 부하) ── + group('깊은 페이지 조회', () => { + const deepPage = Math.floor(Math.random() * 100) + 50; // page 50~149 + const res = http.get( + `${BASE_URL}/api/v1/products?page=${deepPage}&size=20` + ); + check(res, { '200 OK or empty': (r) => r.status === 200 }); + trackResponse( + res, + productDeepPageDuration, + `GET /products?page=${deepPage}&size=20` + ); + }); + + // ── 5. 상품 상세 조회 ── + group('상품 상세 조회', () => { + const res = http.get(`${BASE_URL}/api/v1/products/${randomProductId}`); + check(res, { '200 or 404': (r) => r.status === 200 || r.status === 404 }); + trackResponse( + res, + productDetailDuration, + `GET /products/${randomProductId}` + ); + }); + + // ── 6. 브랜드 목록 조회 ── + group('브랜드 목록 조회', () => { + const res = http.get(`${BASE_URL}/api-admin/v1/brands`); + check(res, { '200 OK': (r) => r.status === 200 }); + trackResponse(res, brandListDuration, 'GET /brands'); + }); + + // ── 7. 복합 시나리오: 브랜드 필터 + 정렬 + 큰 size ── + group('복합 조회 (필터+정렬+큰사이즈)', () => { + const res = http.get( + `${BASE_URL}/api/v1/products?brandId=${randomBrandId}&sort=price_asc&page=0&size=100` + ); + check(res, { '200 OK': (r) => r.status === 200 }); + trackResponse( + res, + productListSortedDuration, + `GET /products?brandId=${randomBrandId}&sort=price_asc&size=100` + ); + }); + + sleep(0.1); // 요청 간 100ms 간격 +} + +// ============================================ +// 테스트 종료 후 요약 +// ============================================ +function getMetricP(data, name, percentile) { + var m = data.metrics[name]; + if (!m || !m.values) return 'N/A'; + var v = m.values['p(' + percentile + ')']; + return v ? v.toFixed(0) + 'ms' : 'N/A'; +} + +function getMetricCount(data, name) { + var m = data.metrics[name]; + if (!m || !m.values) return 0; + return m.values.count || 0; +} + +export function handleSummary(data) { + var lines = [ + '', + '=== Slow Query 부하테스트 결과 ===', + ' 총 요청 수: ' + getMetricCount(data, 'http_reqs'), + ' 총 slow query 수: ' + getMetricCount(data, 'slow_queries'), + ' 전체 p95: ' + getMetricP(data, 'http_req_duration', 95), + ' 전체 p99: ' + getMetricP(data, 'http_req_duration', 99), + '', + '── 시나리오별 p95 ──', + ' 상품 목록: ' + getMetricP(data, 'product_list_duration', 95), + ' 브랜드 필터: ' + getMetricP(data, 'product_list_by_brand_duration', 95), + ' 정렬 조회: ' + getMetricP(data, 'product_list_sorted_duration', 95), + ' 상세 조회: ' + getMetricP(data, 'product_detail_duration', 95), + ' 깊은 페이지: ' + getMetricP(data, 'product_deep_page_duration', 95), + ' 브랜드 목록: ' + getMetricP(data, 'brand_list_duration', 95), + '', + ]; + + console.log(lines.join('\n')); + + return {}; +} diff --git a/scripts/mock-data-100k.sql b/scripts/mock-data-100k.sql new file mode 100644 index 000000000..f0518391c --- /dev/null +++ b/scripts/mock-data-100k.sql @@ -0,0 +1,293 @@ +-- ============================================ +-- Mock Data: 브랜드 20개 + 상품 100,000개 +-- Target: PostgreSQL 16 +-- 목적: 인덱스 최적화 및 성능 테스트용 대량 데이터 +-- ============================================ + +-- 기존 데이터 정리 +TRUNCATE TABLE likes RESTART IDENTITY CASCADE; +TRUNCATE TABLE products RESTART IDENTITY CASCADE; +TRUNCATE TABLE brands RESTART IDENTITY CASCADE; + +-- ============================================ +-- 1. 브랜드 20개 +-- ============================================ +INSERT INTO brands (name, description, created_at, updated_at) VALUES +('Nike', '미국 스포츠웨어 브랜드', NOW(), NOW()), +('Adidas', '독일 스포츠웨어 브랜드', NOW(), NOW()), +('Puma', '독일 스포츠 브랜드', NOW(), NOW()), +('New Balance', '미국 러닝화 전문 브랜드', NOW(), NOW()), +('ASICS', '일본 러닝화 전문 브랜드', NOW(), NOW()), +('Timberland', '미국 아웃도어 부츠 브랜드', NOW(), NOW()), +('Prada', '이탈리아 럭셔리 브랜드', NOW(), NOW()), +('Converse', '미국 캔버스 스니커즈 브랜드', NOW(), NOW()), +('Vans', '미국 스케이트보드 슈즈 브랜드', NOW(), NOW()), +('Reebok', '미국 피트니스 브랜드', NOW(), NOW()), +('FILA', '이탈리아 스포츠 브랜드', NOW(), NOW()), +('Skechers', '미국 컴포트 슈즈 브랜드', NOW(), NOW()), +('Under Armour', '미국 퍼포먼스 스포츠 브랜드', NOW(), NOW()), +('Salomon', '프랑스 트레일러닝 브랜드', NOW(), NOW()), +('Hoka', '프랑스 러닝화 브랜드', NOW(), NOW()), +('On Running', '스위스 러닝화 브랜드', NOW(), NOW()), +('Balenciaga', '프랑스 럭셔리 브랜드', NOW(), NOW()), +('Jordan', '나이키 산하 바스켓볼 브랜드', NOW(), NOW()), +('Mizuno', '일본 스포츠 브랜드', NOW(), NOW()), +('Dr. Martens', '영국 부츠 브랜드', NOW(), NOW()); + +-- ============================================ +-- 2. 상품 100,000개 (브랜드당 5,000개) +-- ============================================ +-- 분포 설계: +-- like_count: 멱법칙(power-law) — 60% 0~50, 25% 50~500, 10% 500~2000, 5% 2000~10000 +-- price: 브랜드 티어별 차등 (럭셔리/프리미엄/대중/일반) +-- sale_price: 40% 확률로 할인가 존재 +-- stock: 브랜드 특성별 차등 (럭셔리 소량, 대중 대량) +-- deleted_at: 5% 확률로 소프트 삭제 (WHERE deleted_at IS NULL 필터 테스트용) +-- created_at: 최근 1년에 걸쳐 분포 +-- ============================================ + +DO $$ +DECLARE + brand_rec RECORD; + i INT; + product_count INT := 0; + + -- 카테고리 10종 + categories TEXT[] := ARRAY['러닝화','스니커즈','워킹화','트레이닝화','농구화','축구화','슬리퍼','부츠','로퍼','샌들']; + + -- 컬러 20종 + colors TEXT[] := ARRAY[ + 'White','Black','Grey','Navy','Red', + 'Blue','Green','Beige','Brown','Pink', + 'Orange','Purple','Cream','Olive','Burgundy', + 'Sky Blue','Charcoal','Sand','Mint','Coral' + ]; + + -- 서픽스 (모델 변형) — 조합 다양성 확보 + suffixes TEXT[] := ARRAY[ + 'SE','LE','Pro','Elite','Plus','V2','V3','EVO','GTX','Premium', + 'Retro','OG','Boost','Lite','Max','Ultra','Neo','Flex','X','DX', + '2024','2025','Limited','Classic','Sport','Tech','Air','Wide','Slim','Mid' + ]; + + -- 사이즈 라벨 (추가 변형) + size_labels TEXT[] := ARRAY['240','245','250','255','260','265','270','275','280','285','290','295','300']; + + -- 브랜드별 모델 배열 (15개씩) + nike_models TEXT[] := ARRAY['Air Force 1','Air Max 90','Air Max 97','Dunk Low','Dunk High','Blazer Mid','Cortez','Pegasus 41','Vomero 18','Zoom Fly 6','React Infinity','Waffle One','Air Rift','Free Run 5.0','Structure 25']; + adidas_models TEXT[] := ARRAY['Samba OG','Gazelle','Ultraboost Light','NMD R1','Forum Low','Stan Smith','Superstar','Ozweego','4DFWD','Adizero Boston 12','Terrex Free Hiker','Continental 80','ZX 750','Campus 00s','Rivalry Low']; + puma_models TEXT[] := ARRAY['Suede Classic','RS-X','Clyde All-Pro','Palermo','Speedcat OG','Rider FV','Mayze','CA Pro','Morphic','Slipstream','Future Rider','Wild Rider','Deviate Nitro 2','Magnify Nitro 2','Mirage Sport']; + nb_models TEXT[] := ARRAY['530','993','990v6','2002R','574','327','550','1906R','Fresh Foam X','FuelCell Rebel v4','860v14','1080v13','9060','580','725']; + asics_models TEXT[] := ARRAY['Gel-Kayano 14','Gel-1130','Gel-Nimbus 26','Gel-NYC','GT-2160','Gel-Cumulus 26','Gel-Quantum 360','Noosa Tri 15','Gel-Sonoma 7','Gel-Venture 9','Gel-Excite 10','Metaspeed Sky+','Gel-Resolution 9','Gel-Trabuco 12','Japan S']; + timb_models TEXT[] := ARRAY['6-Inch Premium','Euro Sprint','Bradstreet Ultra','Solar Wave','Greyfield','Sprint Trekker','Timberloop','Brooklyn Side Zip','Maple Grove','Atwells Ave','Heritage Chukka','Courma Kid','Adventure 2.0','Euro Hiker','Treeline']; + prada_models TEXT[] := ARRAY['Cloudbust Thunder','Americas Cup','Monolith','Wheel','Adidas Luna Rossa','Downtown','Rev','Prax','Macro','District','Collision','Knit','Stratus','Linea Rossa','Soft Padded']; + conv_models TEXT[] := ARRAY['Chuck Taylor All Star','Chuck 70','One Star','Jack Purcell','Run Star Hike','All Star BB','Pro Leather','Weapon','Star Player','Lugged','CONS AS-1','Star Chevron','Fastbreak','ERX','Bishop']; + vans_models TEXT[] := ARRAY['Old Skool','Sk8-Hi','Authentic','Era','Slip-On','Ultrarange','Knu Skool','Sport 73','Rowley Classic','Style 36','Bold Ni','Varix WC','AVE Pro','Rowan Pro','Half Cab']; + reebok_models TEXT[] := ARRAY['Club C 85','Instapump Fury 95','Classic Leather','Nano X4','Zig Kinetica','Answer IV','Floatzig','LT Court','BB 4000 II','Club C Extra','Premier Road','Energen','Classic Nylon','Royal Glide','Vector']; + fila_models TEXT[] := ARRAY['Disruptor 2','Ray Tracer','Oakmont TR','Grant Hill 1','Cage','Renno','Fusion','Luminance','Overtake','Trail Panda','Wavelet','Acd','Dynamico','Grant Hill 2','Teratach 600']; + skechers_models TEXT[] := ARRAY['D Lites','Go Walk 7','Max Cushioning','Slip-ins','Arch Fit','Skech-Air','Track','Summits','Stamina','Equalizer 5','Flex Appeal','Energy','Relaxed Fit','Go Run','Ultra Flex']; + ua_models TEXT[] := ARRAY['Curry Flow 10','HOVR Phantom 3','Charged Assert 10','SlipSpeed','Surge 4','Flow Velociti','HOVR Machina 3','Charged Rogue 4','Flow Dynamic','Spawn 6','Lockdown 7','Drive Pro','Infinite Pro','Pursuit 3','Assert 10']; + salomon_models TEXT[] := ARRAY['XT-6','Speedcross 6','Ultra Glide 2','S/Lab Phantasm','ACS Pro','X Ultra 360','Sense Ride 5','Pulsar Trail','Aero Glide','Cross Hike 2','Alphacross 5','XA Pro 3D V9','Predict Hike','Outpulse','RX Slide 3.0']; + hoka_models TEXT[] := ARRAY['Clifton 9','Bondi 8','Speedgoat 5','Mach 6','Arahi 7','Kawana 2','Rincon 4','Carbon X 3','Challenger 7','Torrent 3','Gaviota 5','Transport','Tecton X 2','Ora Recovery','Hopara 2']; + on_models TEXT[] := ARRAY['Cloud 5','Cloudmonster','Cloudrunner 2','Cloudsurfer','Cloudflow 4','Cloudnova','Cloudswift 3','Cloudultra 2','Cloudventure','Roger Pro','Cloudace 3','Cloudgo','Cloudstratus 3','Cloudvista','The Roger Centre']; + balen_models TEXT[] := ARRAY['Track','Triple S','Speed','Runner','Defender','3XL','Phantom','Rally','Steroid','Tyrex','X-Pander','Drive','Sharkhead','Bouncer','Bulldozer']; + jordan_models TEXT[] := ARRAY['Air Jordan 1 High OG','Air Jordan 4 Retro','Air Jordan 3','Air Jordan 11','Air Jordan 5','Air Jordan 6','Air Jordan 12','Air Jordan 13','Jumpman MVP','Air Jordan 1 Low','Luka 2','Tatum 2','Zion 3','Why Not .7','Air Jordan 2']; + mizuno_models TEXT[] := ARRAY['Wave Rider 27','Contender','Wave Inspire 20','Wave Sky 7','Wave Rebellion Pro','Creation 25','Neo Wind','Rebellion Flash 2','Wave Exceed','Morelia Neo IV','Wave Lightning Z8','Cyclone Speed 4','Wave Stealth V','TC-01','Wave Luminous 2']; + dr_models TEXT[] := ARRAY['1460 8-Eye','1461 3-Eye','Jadon Platform','2976 Chelsea','1490 10-Eye','Sinclair','Audrick','1461 Quad','Combs Tech','1919 Steel Toe','Adrian Tassel','Ramsey','Tarik','Jorge','Bonny Tech']; + + models TEXT[]; + model_name TEXT; + color_name TEXT; + suffix TEXT; + full_name TEXT; + base_price INT; + has_sale BOOLEAN; + sale_p INT; + stock INT; + like_cnt INT; + desc_text TEXT; + cat TEXT; + is_deleted BOOLEAN; + rand_val DOUBLE PRECISION; + created_ts TIMESTAMP; + batch_size INT := 500; + batch_count INT := 0; + +BEGIN + FOR brand_rec IN SELECT id, name FROM brands ORDER BY id LOOP + + -- 브랜드별 모델 배열 선택 + CASE brand_rec.id + WHEN 1 THEN models := nike_models; + WHEN 2 THEN models := adidas_models; + WHEN 3 THEN models := puma_models; + WHEN 4 THEN models := nb_models; + WHEN 5 THEN models := asics_models; + WHEN 6 THEN models := timb_models; + WHEN 7 THEN models := prada_models; + WHEN 8 THEN models := conv_models; + WHEN 9 THEN models := vans_models; + WHEN 10 THEN models := reebok_models; + WHEN 11 THEN models := fila_models; + WHEN 12 THEN models := skechers_models; + WHEN 13 THEN models := ua_models; + WHEN 14 THEN models := salomon_models; + WHEN 15 THEN models := hoka_models; + WHEN 16 THEN models := on_models; + WHEN 17 THEN models := balen_models; + WHEN 18 THEN models := jordan_models; + WHEN 19 THEN models := mizuno_models; + WHEN 20 THEN models := dr_models; + END CASE; + + FOR i IN 1..5000 LOOP + -- 모델 + 컬러 + 서픽스 조합으로 상품명 생성 + model_name := models[1 + ((i - 1) % array_length(models, 1))]; + color_name := colors[1 + (((i - 1) / array_length(models, 1)) % array_length(colors, 1))]; + suffix := suffixes[1 + (((i - 1) / (array_length(models, 1) * array_length(colors, 1))) % array_length(suffixes, 1))]; + + -- 처음 300개(15x20)는 서픽스 없이, 이후는 서픽스 포함 + IF i <= 300 THEN + full_name := brand_rec.name || ' ' || model_name || ' ' || color_name; + ELSE + full_name := brand_rec.name || ' ' || model_name || ' ' || color_name || ' ' || suffix; + END IF; + + -- 카테고리 순환 + cat := categories[1 + (i % array_length(categories, 1))]; + + -- 브랜드 티어별 가격 설정 + CASE + WHEN brand_rec.id IN (7, 17) THEN -- Prada, Balenciaga (럭셔리) + base_price := 500000 + (floor(random() * 15) * 100000)::INT; + WHEN brand_rec.id IN (6, 20) THEN -- Timberland, Dr.Martens (부츠) + base_price := 150000 + (floor(random() * 20) * 10000)::INT; + WHEN brand_rec.id IN (14, 15, 16) THEN -- Salomon, Hoka, On (프리미엄 러닝) + base_price := 140000 + (floor(random() * 15) * 10000)::INT; + WHEN brand_rec.id IN (18) THEN -- Jordan (프리미엄 스포츠) + base_price := 150000 + (floor(random() * 25) * 10000)::INT; + WHEN brand_rec.id IN (8, 9, 11, 12) THEN -- Converse, Vans, FILA, Skechers (대중) + base_price := 59000 + (floor(random() * 12) * 10000)::INT; + ELSE -- 나머지 일반 스포츠 (Nike, Adidas, Puma, NB, ASICS, Reebok, UA, Mizuno) + base_price := 89000 + (floor(random() * 20) * 10000)::INT; + END CASE; + + -- 40% 확률로 세일가 존재 + has_sale := random() < 0.4; + IF has_sale THEN + sale_p := base_price - (floor(random() * 4 + 1) * 10000)::INT; + IF sale_p < 30000 THEN sale_p := 30000; END IF; + ELSE + sale_p := NULL; + END IF; + + -- 재고: 브랜드 특성 반영 + CASE + WHEN brand_rec.id IN (7, 17) THEN -- 럭셔리: 소량 + stock := 5 + floor(random() * 30)::INT; + WHEN brand_rec.id IN (8, 9, 11, 12) THEN -- 대중: 대량 + stock := 100 + floor(random() * 400)::INT; + ELSE -- 일반 + stock := 30 + floor(random() * 200)::INT; + END CASE; + + -- like_count: 멱법칙 분포 (현실적인 좋아요 분포) + -- 60% → 0~50, 25% → 50~500, 10% → 500~2000, 5% → 2000~10000 + rand_val := random(); + CASE + WHEN rand_val < 0.60 THEN -- 60%: 비인기 상품 + like_cnt := floor(random() * 51)::INT; + WHEN rand_val < 0.85 THEN -- 25%: 보통 인기 + like_cnt := 50 + floor(random() * 451)::INT; + WHEN rand_val < 0.95 THEN -- 10%: 인기 상품 + like_cnt := 500 + floor(random() * 1501)::INT; + ELSE -- 5%: 바이럴 상품 + like_cnt := 2000 + floor(random() * 8001)::INT; + END CASE; + + -- 설명 + desc_text := brand_rec.name || ' ' || model_name || ' ' || cat || ' - ' || color_name || ' 컬러'; + + -- created_at: 최근 1년에 걸쳐 분포 + created_ts := NOW() - (floor(random() * 365) || ' days')::INTERVAL + - (floor(random() * 24) || ' hours')::INTERVAL; + + -- 5% 확률로 소프트 삭제 (deleted_at IS NULL 조건 테스트용) + is_deleted := random() < 0.05; + + INSERT INTO products (brand_id, name, price, sale_price, stock_quantity, like_count, description, created_at, updated_at, deleted_at) + VALUES ( + brand_rec.id, + full_name, + base_price, + sale_p, + stock, + like_cnt, + desc_text, + created_ts, + created_ts + (floor(random() * 30) || ' days')::INTERVAL, + CASE WHEN is_deleted THEN NOW() - (floor(random() * 30) || ' days')::INTERVAL ELSE NULL END + ); + + product_count := product_count + 1; + END LOOP; + + RAISE NOTICE '브랜드 [%] 5,000개 삽입 완료 (누적: %건)', brand_rec.name, product_count; + END LOOP; + + RAISE NOTICE '===== 총 %개 상품 삽입 완료 =====', product_count; +END $$; + +-- ============================================ +-- 3. 검증 쿼리 +-- ============================================ + +-- 전체 요약 +SELECT '총 브랜드 수' AS label, COUNT(*) AS cnt FROM brands +UNION ALL +SELECT '총 상품 수', COUNT(*) FROM products +UNION ALL +SELECT '활성 상품 수 (deleted_at IS NULL)', COUNT(*) FROM products WHERE deleted_at IS NULL +UNION ALL +SELECT '삭제 상품 수 (deleted_at IS NOT NULL)', COUNT(*) FROM products WHERE deleted_at IS NOT NULL +UNION ALL +SELECT '세일 상품 수', COUNT(*) FROM products WHERE sale_price IS NOT NULL; + +-- 브랜드별 상품 수 및 가격 분포 +SELECT b.name AS brand, COUNT(p.id) AS product_count, + MIN(p.price) AS min_price, MAX(p.price) AS max_price, + AVG(p.price)::INT AS avg_price, + COUNT(*) FILTER (WHERE p.deleted_at IS NULL) AS active_count +FROM brands b +JOIN products p ON b.id = p.brand_id +GROUP BY b.name +ORDER BY b.name; + +-- like_count 분포 확인 +SELECT + CASE + WHEN like_count BETWEEN 0 AND 50 THEN '0-50 (비인기)' + WHEN like_count BETWEEN 51 AND 500 THEN '51-500 (보통)' + WHEN like_count BETWEEN 501 AND 2000 THEN '501-2000 (인기)' + ELSE '2001+ (바이럴)' + END AS like_range, + COUNT(*) AS cnt, + ROUND(COUNT(*)::NUMERIC / (SELECT COUNT(*) FROM products) * 100, 1) AS pct +FROM products +GROUP BY 1 +ORDER BY MIN(like_count); + +-- 가격대별 분포 +SELECT + CASE + WHEN price < 100000 THEN '~10만' + WHEN price < 200000 THEN '10~20만' + WHEN price < 300000 THEN '20~30만' + WHEN price < 500000 THEN '30~50만' + ELSE '50만+' + END AS price_range, + COUNT(*) AS cnt, + ROUND(COUNT(*)::NUMERIC / (SELECT COUNT(*) FROM products) * 100, 1) AS pct +FROM products +GROUP BY 1 +ORDER BY MIN(price); diff --git a/scripts/mock-data-10k.sql b/scripts/mock-data-10k.sql new file mode 100644 index 000000000..2a4a191fe --- /dev/null +++ b/scripts/mock-data-10k.sql @@ -0,0 +1,149 @@ +-- ============================================ +-- 추가 Mock Data: 상품 10,000개 추가 +-- 기존 3,000건에 더해 총 13,000건 +-- Target: PostgreSQL 16 +-- ============================================ + +DO $$ +DECLARE + brand_rec RECORD; + i INT; + product_count INT := 0; + + categories TEXT[] := ARRAY['러닝화','스니커즈','워킹화','트레이닝화','농구화','축구화','슬리퍼','부츠','로퍼','샌들']; + colors TEXT[] := ARRAY[ + 'White','Black','Grey','Navy','Red', + 'Blue','Green','Beige','Brown','Pink', + 'Orange','Purple','Cream','Olive','Burgundy', + 'Sky Blue','Charcoal','Sand','Mint','Coral' + ]; + + suffixes TEXT[] := ARRAY['SE','LE','Pro','Elite','Plus','V2','V3','EVO','GTX','Premium', + 'Retro','OG','Boost','Lite','Max','Ultra','Neo','Flex','X','DX']; + + nike_models TEXT[] := ARRAY['Air Force 1','Air Max 90','Air Max 97','Dunk Low','Dunk High','Blazer Mid','Cortez','Pegasus 41','Vomero 18','Zoom Fly 6','React Infinity','Waffle One','Air Rift','Free Run 5.0','Structure 25']; + adidas_models TEXT[] := ARRAY['Samba OG','Gazelle','Ultraboost Light','NMD R1','Forum Low','Stan Smith','Superstar','Ozweego','4DFWD','Adizero Boston 12','Terrex Free Hiker','Continental 80','ZX 750','Campus 00s','Rivalry Low']; + puma_models TEXT[] := ARRAY['Suede Classic','RS-X','Clyde All-Pro','Palermo','Speedcat OG','Rider FV','Mayze','CA Pro','Morphic','Slipstream','Future Rider','Wild Rider','Deviate Nitro 2','Magnify Nitro 2','Mirage Sport']; + nb_models TEXT[] := ARRAY['530','993','990v6','2002R','574','327','550','1906R','Fresh Foam X','FuelCell Rebel v4','860v14','1080v13','9060','580','725']; + asics_models TEXT[] := ARRAY['Gel-Kayano 14','Gel-1130','Gel-Nimbus 26','Gel-NYC','GT-2160','Gel-Cumulus 26','Gel-Quantum 360','Noosa Tri 15','Gel-Sonoma 7','Gel-Venture 9','Gel-Excite 10','Metaspeed Sky+','Gel-Resolution 9','Gel-Trabuco 12','Japan S']; + timb_models TEXT[] := ARRAY['6-Inch Premium','Euro Sprint','Bradstreet Ultra','Solar Wave','Greyfield','Sprint Trekker','Timberloop','Brooklyn Side Zip','Maple Grove','Atwells Ave','Heritage Chukka','Courma Kid','Adventure 2.0','Euro Hiker','Treeline']; + prada_models TEXT[] := ARRAY['Cloudbust Thunder','Americas Cup','Monolith','Wheel','Adidas Luna Rossa','Downtown','Rev','Prax','Macro','District','Collision','Knit','Stratus','Linea Rossa','Soft Padded']; + conv_models TEXT[] := ARRAY['Chuck Taylor All Star','Chuck 70','One Star','Jack Purcell','Run Star Hike','All Star BB','Pro Leather','Weapon','Star Player','Lugged','CONS AS-1','Star Chevron','Fastbreak','ERX','Bishop']; + vans_models TEXT[] := ARRAY['Old Skool','Sk8-Hi','Authentic','Era','Slip-On','Ultrarange','Knu Skool','Sport 73','Rowley Classic','Style 36','Bold Ni','Varix WC','AVE Pro','Rowan Pro','Half Cab']; + reebok_models TEXT[] := ARRAY['Club C 85','Instapump Fury 95','Classic Leather','Nano X4','Zig Kinetica','Answer IV','Floatzig','LT Court','BB 4000 II','Club C Extra','Premier Road','Energen','Classic Nylon','Royal Glide','Vector']; + fila_models TEXT[] := ARRAY['Disruptor 2','Ray Tracer','Oakmont TR','Grant Hill 1','Cage','Renno','Fusion','Luminance','Overtake','Trail Panda','Wavelet','Acd','Dynamico','Grant Hill 2','Teratach 600']; + skechers_models TEXT[] := ARRAY['D Lites','Go Walk 7','Max Cushioning','Slip-ins','Arch Fit','Skech-Air','Track','Summits','Stamina','Equalizer 5','Flex Appeal','Energy','Relaxed Fit','Go Run','Ultra Flex']; + ua_models TEXT[] := ARRAY['Curry Flow 10','HOVR Phantom 3','Charged Assert 10','SlipSpeed','Surge 4','Flow Velociti','HOVR Machina 3','Charged Rogue 4','Flow Dynamic','Spawn 6','Lockdown 7','Drive Pro','Infinite Pro','Pursuit 3','Assert 10']; + salomon_models TEXT[] := ARRAY['XT-6','Speedcross 6','Ultra Glide 2','S/Lab Phantasm','ACS Pro','X Ultra 360','Sense Ride 5','Pulsar Trail','Aero Glide','Cross Hike 2','Alphacross 5','XA Pro 3D V9','Predict Hike','Outpulse','RX Slide 3.0']; + hoka_models TEXT[] := ARRAY['Clifton 9','Bondi 8','Speedgoat 5','Mach 6','Arahi 7','Kawana 2','Rincon 4','Carbon X 3','Challenger 7','Torrent 3','Gaviota 5','Transport','Tecton X 2','Ora Recovery','Hopara 2']; + on_models TEXT[] := ARRAY['Cloud 5','Cloudmonster','Cloudrunner 2','Cloudsurfer','Cloudflow 4','Cloudnova','Cloudswift 3','Cloudultra 2','Cloudventure','Roger Pro','Cloudace 3','Cloudgo','Cloudstratus 3','Cloudvista','The Roger Centre']; + balen_models TEXT[] := ARRAY['Track','Triple S','Speed','Runner','Defender','3XL','Phantom','Rally','Steroid','Tyrex','X-Pander','Drive','Sharkhead','Bouncer','Bulldozer']; + jordan_models TEXT[] := ARRAY['Air Jordan 1 High OG','Air Jordan 4 Retro','Air Jordan 3','Air Jordan 11','Air Jordan 5','Air Jordan 6','Air Jordan 12','Air Jordan 13','Jumpman MVP','Air Jordan 1 Low','Luka 2','Tatum 2','Zion 3','Why Not .7','Air Jordan 2']; + mizuno_models TEXT[] := ARRAY['Wave Rider 27','Contender','Wave Inspire 20','Wave Sky 7','Wave Rebellion Pro','Creation 25','Neo Wind','Rebellion Flash 2','Wave Exceed','Morelia Neo IV','Wave Lightning Z8','Cyclone Speed 4','Wave Stealth V','TC-01','Wave Luminous 2']; + dr_models TEXT[] := ARRAY['1460 8-Eye','1461 3-Eye','Jadon Platform','2976 Chelsea','1490 10-Eye','Sinclair','Audrick','1461 Quad','Combs Tech','1919 Steel Toe','Adrian Tassel','Ramsey','Tarik','Jorge','Bonny Tech']; + + models TEXT[]; + model_name TEXT; + color_name TEXT; + suffix TEXT; + full_name TEXT; + base_price INT; + has_sale BOOLEAN; + sale_p INT; + stock INT; + desc_text TEXT; + cat TEXT; + +BEGIN + FOR brand_rec IN SELECT id, name FROM brands ORDER BY id LOOP + + CASE brand_rec.id + WHEN 1 THEN models := nike_models; + WHEN 2 THEN models := adidas_models; + WHEN 3 THEN models := puma_models; + WHEN 4 THEN models := nb_models; + WHEN 5 THEN models := asics_models; + WHEN 6 THEN models := timb_models; + WHEN 7 THEN models := prada_models; + WHEN 8 THEN models := conv_models; + WHEN 9 THEN models := vans_models; + WHEN 10 THEN models := reebok_models; + WHEN 11 THEN models := fila_models; + WHEN 12 THEN models := skechers_models; + WHEN 13 THEN models := ua_models; + WHEN 14 THEN models := salomon_models; + WHEN 15 THEN models := hoka_models; + WHEN 16 THEN models := on_models; + WHEN 17 THEN models := balen_models; + WHEN 18 THEN models := jordan_models; + WHEN 19 THEN models := mizuno_models; + WHEN 20 THEN models := dr_models; + END CASE; + + -- 브랜드당 500개 추가 (20 x 500 = 10,000) + FOR i IN 1..500 LOOP + model_name := models[1 + ((i - 1) % array_length(models, 1))]; + color_name := colors[1 + ((i - 1) / array_length(models, 1)) % array_length(colors, 1)]; + suffix := suffixes[1 + (i % array_length(suffixes, 1))]; + full_name := brand_rec.name || ' ' || model_name || ' ' || color_name || ' ' || suffix; + + cat := categories[1 + (i % array_length(categories, 1))]; + + CASE + WHEN brand_rec.id IN (7, 17) THEN + base_price := 500000 + (floor(random() * 15) * 100000)::INT; + WHEN brand_rec.id IN (6, 20) THEN + base_price := 150000 + (floor(random() * 20) * 10000)::INT; + WHEN brand_rec.id IN (14, 15, 16) THEN + base_price := 140000 + (floor(random() * 15) * 10000)::INT; + WHEN brand_rec.id IN (8, 9, 11, 12) THEN + base_price := 59000 + (floor(random() * 12) * 10000)::INT; + ELSE + base_price := 89000 + (floor(random() * 20) * 10000)::INT; + END CASE; + + has_sale := random() < 0.4; + IF has_sale THEN + sale_p := base_price - (floor(random() * 4 + 1) * 10000)::INT; + IF sale_p < 30000 THEN sale_p := 30000; END IF; + ELSE + sale_p := NULL; + END IF; + + CASE + WHEN brand_rec.id IN (7, 17) THEN + stock := 5 + floor(random() * 30)::INT; + WHEN brand_rec.id IN (8, 9, 11, 12) THEN + stock := 100 + floor(random() * 400)::INT; + ELSE + stock := 30 + floor(random() * 200)::INT; + END CASE; + + desc_text := brand_rec.name || ' ' || model_name || ' ' || suffix || ' ' || cat || ' - ' || color_name || ' 컬러'; + + INSERT INTO products (brand_id, name, price, sale_price, stock_quantity, like_count, description, created_at, updated_at) + VALUES (brand_rec.id, full_name, base_price, sale_p, stock, floor(random() * 500)::INT, desc_text, + NOW() - (floor(random() * 365) || ' days')::INTERVAL, + NOW() - (floor(random() * 30) || ' days')::INTERVAL); + + product_count := product_count + 1; + END LOOP; + END LOOP; + + RAISE NOTICE '추가 % 개 상품 삽입 완료', product_count; +END $$; + +-- 검증 +SELECT '총 브랜드 수' AS label, COUNT(*) AS cnt FROM brands +UNION ALL +SELECT '총 상품 수', COUNT(*) FROM products +UNION ALL +SELECT '세일 상품 수', COUNT(*) FROM products WHERE sale_price IS NOT NULL; + +SELECT b.name AS brand, COUNT(p.id) AS product_count, + MIN(p.price) AS min_price, MAX(p.price) AS max_price, + AVG(p.price)::INT AS avg_price +FROM brands b +JOIN products p ON b.id = p.brand_id +GROUP BY b.name +ORDER BY b.name; \ No newline at end of file diff --git a/scripts/mock-data.sql b/scripts/mock-data.sql new file mode 100644 index 000000000..66928cb0b --- /dev/null +++ b/scripts/mock-data.sql @@ -0,0 +1,190 @@ +-- ============================================ +-- Mock Data: 브랜드 20개 + 상품 3,000개 +-- Target: PostgreSQL 16 +-- ============================================ + +-- 기존 데이터 정리 +TRUNCATE TABLE products RESTART IDENTITY CASCADE; +TRUNCATE TABLE brands RESTART IDENTITY CASCADE; + +-- ============================================ +-- 1. 브랜드 20개 +-- ============================================ +INSERT INTO brands (name, description, created_at, updated_at) VALUES +('Nike', '미국 스포츠웨어 브랜드', NOW(), NOW()), +('Adidas', '독일 스포츠웨어 브랜드', NOW(), NOW()), +('Puma', '독일 스포츠 브랜드', NOW(), NOW()), +('New Balance', '미국 러닝화 전문 브랜드', NOW(), NOW()), +('ASICS', '일본 러닝화 전문 브랜드', NOW(), NOW()), +('Timberland', '미국 아웃도어 부츠 브랜드', NOW(), NOW()), +('Prada', '이탈리아 럭셔리 브랜드', NOW(), NOW()), +('Converse', '미국 캔버스 스니커즈 브랜드', NOW(), NOW()), +('Vans', '미국 스케이트보드 슈즈 브랜드', NOW(), NOW()), +('Reebok', '미국 피트니스 브랜드', NOW(), NOW()), +('FILA', '이탈리아 스포츠 브랜드', NOW(), NOW()), +('Skechers', '미국 컴포트 슈즈 브랜드', NOW(), NOW()), +('Under Armour', '미국 퍼포먼스 스포츠 브랜드', NOW(), NOW()), +('Salomon', '프랑스 트레일러닝 브랜드', NOW(), NOW()), +('Hoka', '프랑스 러닝화 브랜드', NOW(), NOW()), +('On Running', '스위스 러닝화 브랜드', NOW(), NOW()), +('Balenciaga', '프랑스 럭셔리 브랜드', NOW(), NOW()), +('Jordan', '나이키 산하 바스켓볼 브랜드', NOW(), NOW()), +('Mizuno', '일본 스포츠 브랜드', NOW(), NOW()), +('Dr. Martens', '영국 부츠 브랜드', NOW(), NOW()); + +-- ============================================ +-- 2. 상품 3,000개 (브랜드당 150개) +-- ============================================ + +-- 브랜드별 상품 라인/모델명 배열 +DO $$ +DECLARE + brand_rec RECORD; + i INT; + product_count INT := 0; + + -- 카테고리 + categories TEXT[] := ARRAY['러닝화','스니커즈','워킹화','트레이닝화','농구화','축구화','슬리퍼','부츠','로퍼','샌들']; + + -- 컬러 + colors TEXT[] := ARRAY[ + 'White','Black','Grey','Navy','Red', + 'Blue','Green','Beige','Brown','Pink', + 'Orange','Purple','Cream','Olive','Burgundy', + 'Sky Blue','Charcoal','Sand','Mint','Coral' + ]; + + -- 시리즈명 (브랜드별) + nike_models TEXT[] := ARRAY['Air Force 1','Air Max 90','Air Max 97','Dunk Low','Dunk High','Blazer Mid','Cortez','Pegasus 41','Vomero 18','Zoom Fly 6','React Infinity','Waffle One','Air Rift','Free Run 5.0','Structure 25']; + adidas_models TEXT[] := ARRAY['Samba OG','Gazelle','Ultraboost Light','NMD R1','Forum Low','Stan Smith','Superstar','Ozweego','4DFWD','Adizero Boston 12','Terrex Free Hiker','Continental 80','ZX 750','Campus 00s','Rivalry Low']; + puma_models TEXT[] := ARRAY['Suede Classic','RS-X','Clyde All-Pro','Palermo','Speedcat OG','Rider FV','Mayze','CA Pro','Morphic','Slipstream','Future Rider','Wild Rider','Deviate Nitro 2','Magnify Nitro 2','Mirage Sport']; + nb_models TEXT[] := ARRAY['530','993','990v6','2002R','574','327','550','1906R','Fresh Foam X','FuelCell Rebel v4','860v14','1080v13','9060','580','725']; + asics_models TEXT[] := ARRAY['Gel-Kayano 14','Gel-1130','Gel-Nimbus 26','Gel-NYC','GT-2160','Gel-Cumulus 26','Gel-Quantum 360','Noosa Tri 15','Gel-Sonoma 7','Gel-Venture 9','Gel-Excite 10','Metaspeed Sky+','Gel-Resolution 9','Gel-Trabuco 12','Japan S']; + timb_models TEXT[] := ARRAY['6-Inch Premium','Euro Sprint','Bradstreet Ultra','Solar Wave','Greyfield','Sprint Trekker','Timberloop','Brooklyn Side Zip','Maple Grove','Atwells Ave','Heritage Chukka','Courma Kid','Adventure 2.0','Euro Hiker','Treeline']; + prada_models TEXT[] := ARRAY['Cloudbust Thunder','Americas Cup','Monolith','Wheel','Adidas Luna Rossa','Downtown','Rev','Prax','Macro','District','Collision','Knit','Stratus','Linea Rossa','Soft Padded']; + conv_models TEXT[] := ARRAY['Chuck Taylor All Star','Chuck 70','One Star','Jack Purcell','Run Star Hike','All Star BB','Pro Leather','Weapon','Star Player','Lugged','CONS AS-1','Star Chevron','Fastbreak','ERX','Bishop']; + vans_models TEXT[] := ARRAY['Old Skool','Sk8-Hi','Authentic','Era','Slip-On','Ultrarange','Knu Skool','Sport 73','Rowley Classic','Style 36','Bold Ni','Varix WC','AVE Pro','Rowan Pro','Half Cab']; + reebok_models TEXT[] := ARRAY['Club C 85','Instapump Fury 95','Classic Leather','Nano X4','Zig Kinetica','Answer IV','Floatzig','LT Court','BB 4000 II','Club C Extra','Premier Road','Energen','Classic Nylon','Royal Glide','Vector']; + fila_models TEXT[] := ARRAY['Disruptor 2','Ray Tracer','Oakmont TR','Grant Hill 1','Cage','Renno','Fusion','Luminance','Overtake','Trail Panda','Wavelet','Acd','Dynamico','Grant Hill 2','Teratach 600']; + skechers_models TEXT[] := ARRAY['D Lites','Go Walk 7','Max Cushioning','Slip-ins','Arch Fit','Skech-Air','Track','Summits','Stamina','Equalizer 5','Flex Appeal','Energy','Relaxed Fit','Go Run','Ultra Flex']; + ua_models TEXT[] := ARRAY['Curry Flow 10','HOVR Phantom 3','Charged Assert 10','SlipSpeed','Surge 4','Flow Velociti','HOVR Machina 3','Charged Rogue 4','Flow Dynamic','Spawn 6','Lockdown 7','Drive Pro','Infinite Pro','Pursuit 3','Assert 10']; + salomon_models TEXT[] := ARRAY['XT-6','Speedcross 6','Ultra Glide 2','S/Lab Phantasm','ACS Pro','X Ultra 360','Sense Ride 5','Pulsar Trail','Aero Glide','Cross Hike 2','Alphacross 5','XA Pro 3D V9','Predict Hike','Outpulse','RX Slide 3.0']; + hoka_models TEXT[] := ARRAY['Clifton 9','Bondi 8','Speedgoat 5','Mach 6','Arahi 7','Kawana 2','Rincon 4','Carbon X 3','Challenger 7','Torrent 3','Gaviota 5','Transport','Tecton X 2','Ora Recovery','Hopara 2']; + on_models TEXT[] := ARRAY['Cloud 5','Cloudmonster','Cloudrunner 2','Cloudsurfer','Cloudflow 4','Cloudnova','Cloudswift 3','Cloudultra 2','Cloudventure','Roger Pro','Cloudace 3','Cloudgo','Cloudstratus 3','Cloudvista','The Roger Centre']; + balen_models TEXT[] := ARRAY['Track','Triple S','Speed','Runner','Defender','3XL','Phantom','Rally','Steroid','Tyrex','X-Pander','Drive','Sharkhead','Bouncer','Bulldozer']; + jordan_models TEXT[] := ARRAY['Air Jordan 1 High OG','Air Jordan 4 Retro','Air Jordan 3','Air Jordan 11','Air Jordan 5','Air Jordan 6','Air Jordan 12','Air Jordan 13','Jumpman MVP','Air Jordan 1 Low','Luka 2','Tatum 2','Zion 3','Why Not .7','Air Jordan 2']; + mizuno_models TEXT[] := ARRAY['Wave Rider 27','Contender','Wave Inspire 20','Wave Sky 7','Wave Rebellion Pro','Creation 25','Neo Wind','Rebellion Flash 2','Wave Exceed','Morelia Neo IV','Wave Lightning Z8','Cyclone Speed 4','Wave Stealth V','TC-01','Wave Luminous 2']; + dr_models TEXT[] := ARRAY['1460 8-Eye','1461 3-Eye','Jadon Platform','2976 Chelsea','1490 10-Eye','Sinclair','Audrick','1461 Quad','Combs Tech','1919 Steel Toe','Adrian Tassel','Ramsey','Tarik','Jorge','Bonny Tech']; + + models TEXT[]; + model_name TEXT; + color_name TEXT; + full_name TEXT; + base_price INT; + has_sale BOOLEAN; + sale_p INT; + stock INT; + desc_text TEXT; + cat TEXT; + +BEGIN + FOR brand_rec IN SELECT id, name FROM brands ORDER BY id LOOP + + -- 브랜드별 모델 배열 선택 + CASE brand_rec.id + WHEN 1 THEN models := nike_models; + WHEN 2 THEN models := adidas_models; + WHEN 3 THEN models := puma_models; + WHEN 4 THEN models := nb_models; + WHEN 5 THEN models := asics_models; + WHEN 6 THEN models := timb_models; + WHEN 7 THEN models := prada_models; + WHEN 8 THEN models := conv_models; + WHEN 9 THEN models := vans_models; + WHEN 10 THEN models := reebok_models; + WHEN 11 THEN models := fila_models; + WHEN 12 THEN models := skechers_models; + WHEN 13 THEN models := ua_models; + WHEN 14 THEN models := salomon_models; + WHEN 15 THEN models := hoka_models; + WHEN 16 THEN models := on_models; + WHEN 17 THEN models := balen_models; + WHEN 18 THEN models := jordan_models; + WHEN 19 THEN models := mizuno_models; + WHEN 20 THEN models := dr_models; + END CASE; + + FOR i IN 1..150 LOOP + -- 모델 + 컬러 조합으로 상품명 생성 + model_name := models[1 + ((i - 1) % array_length(models, 1))]; + color_name := colors[1 + ((i - 1) / array_length(models, 1)) % array_length(colors, 1)]; + full_name := brand_rec.name || ' ' || model_name || ' ' || color_name; + + -- 카테고리 + cat := categories[1 + (i % array_length(categories, 1))]; + + -- 브랜드 티어별 가격 설정 + CASE + WHEN brand_rec.id IN (7, 17) THEN -- Prada, Balenciaga (럭셔리) + base_price := 500000 + (floor(random() * 15) * 100000)::INT; + WHEN brand_rec.id IN (6, 20) THEN -- Timberland, Dr.Martens (부츠) + base_price := 150000 + (floor(random() * 20) * 10000)::INT; + WHEN brand_rec.id IN (14, 15, 16) THEN -- Salomon, Hoka, On (프리미엄 러닝) + base_price := 140000 + (floor(random() * 15) * 10000)::INT; + WHEN brand_rec.id IN (8, 9, 11, 12) THEN -- Converse, Vans, FILA, Skechers (대중) + base_price := 59000 + (floor(random() * 12) * 10000)::INT; + ELSE -- 나머지 일반 스포츠 + base_price := 89000 + (floor(random() * 20) * 10000)::INT; + END CASE; + + -- 40% 확률로 세일 + has_sale := random() < 0.4; + IF has_sale THEN + sale_p := base_price - (floor(random() * 4 + 1) * 10000)::INT; + IF sale_p < 30000 THEN sale_p := 30000; END IF; + ELSE + sale_p := NULL; + END IF; + + -- 재고: 럭셔리는 적게, 대중은 많게 + CASE + WHEN brand_rec.id IN (7, 17) THEN + stock := 5 + floor(random() * 30)::INT; + WHEN brand_rec.id IN (8, 9, 11, 12) THEN + stock := 100 + floor(random() * 400)::INT; + ELSE + stock := 30 + floor(random() * 200)::INT; + END CASE; + + -- 설명 + desc_text := brand_rec.name || ' ' || model_name || ' ' || cat || ' - ' || color_name || ' 컬러'; + + INSERT INTO products (brand_id, name, price, sale_price, stock_quantity, like_count, description, created_at, updated_at) + VALUES (brand_rec.id, full_name, base_price, sale_p, stock, 0, desc_text, + NOW() - (floor(random() * 180) || ' days')::INTERVAL, + NOW() - (floor(random() * 30) || ' days')::INTERVAL); + + product_count := product_count + 1; + END LOOP; + END LOOP; + + RAISE NOTICE '총 % 개 상품 삽입 완료', product_count; +END $$; + +-- ============================================ +-- 검증 쿼리 +-- ============================================ +SELECT '총 브랜드 수' AS label, COUNT(*) AS cnt FROM brands +UNION ALL +SELECT '총 상품 수', COUNT(*) FROM products +UNION ALL +SELECT '세일 상품 수', COUNT(*) FROM products WHERE sale_price IS NOT NULL; + +-- 브랜드별 상품 수 확인 +SELECT b.name AS brand, COUNT(p.id) AS product_count, + MIN(p.price) AS min_price, MAX(p.price) AS max_price, + AVG(p.price)::INT AS avg_price +FROM brands b +JOIN products p ON b.id = p.brand_id +GROUP BY b.name +ORDER BY b.name; diff --git a/scripts/mock-users.sql b/scripts/mock-users.sql new file mode 100644 index 000000000..b2e5a7f64 --- /dev/null +++ b/scripts/mock-users.sql @@ -0,0 +1,32 @@ +-- 테스트 유저 100명 생성 +-- 비밀번호: TestPass99! (SHA256 with salt) +-- Sha256PasswordEncoder 형식: salt:hash + +DO $$ +DECLARE + i INT; + login_id TEXT; + salt TEXT; + raw_password TEXT := 'TestPass99!'; + hash_input TEXT; + encoded TEXT; +BEGIN + FOR i IN 1..100 LOOP + login_id := 'k6_like_user_' || i; + + -- salt 생성 (16바이트 랜덤 → base64) + salt := encode(gen_random_bytes(16), 'base64'); + + -- SHA-256(rawPassword + salt) → base64 + hash_input := raw_password || salt; + encoded := salt || ':' || encode(digest(hash_input, 'sha256'), 'base64'); + + INSERT INTO users (user_id, encoded_password, username, birthday, email, wrong_password_count, created_at) + VALUES (login_id, encoded, '테스트유저' || i, '1990-06-20', 'k6user' || i || '@test.com', 0, NOW()) + ON CONFLICT (user_id) DO NOTHING; + END LOOP; + + RAISE NOTICE '테스트 유저 100명 생성 완료'; +END $$; + +SELECT COUNT(*) AS user_count FROM users WHERE user_id LIKE 'k6_like_user_%';