diff --git a/ecommerce-adapter/build.gradle.kts b/ecommerce-adapter/build.gradle.kts index 600a427..7c57825 100644 --- a/ecommerce-adapter/build.gradle.kts +++ b/ecommerce-adapter/build.gradle.kts @@ -17,6 +17,9 @@ dependencies { // Mysql runtimeOnly("com.mysql:mysql-connector-j") + // Redis + implementation("org.springframework.boot:spring-boot-starter-data-redis") + // TestContainer testImplementation ("org.testcontainers:mysql") testImplementation ("com.redis:testcontainers-redis") diff --git a/ecommerce-adapter/src/main/kotlin/com/ecommerce/adapter/in/controller/ItemController.kt b/ecommerce-adapter/src/main/kotlin/com/ecommerce/adapter/in/controller/ItemController.kt index 46a197d..3154d70 100644 --- a/ecommerce-adapter/src/main/kotlin/com/ecommerce/adapter/in/controller/ItemController.kt +++ b/ecommerce-adapter/src/main/kotlin/com/ecommerce/adapter/in/controller/ItemController.kt @@ -32,7 +32,7 @@ class ItemController( fun getPopularItems( @RequestParam(value = "period", defaultValue = "3") period: Long ): ResponseEntity> { - val popularItems = itemUseCase.getPopularItemsOnTop10(period) + val popularItems = itemUseCase.getPopularItems(period) return ResponseEntity.ok( popularItems.map { PopularItemResponse.of(it) }.toList() diff --git a/ecommerce-adapter/src/main/kotlin/com/ecommerce/adapter/in/dto/response/PopularItemResponse.kt b/ecommerce-adapter/src/main/kotlin/com/ecommerce/adapter/in/dto/response/PopularItemResponse.kt index 1a5fb9e..ac4dd08 100644 --- a/ecommerce-adapter/src/main/kotlin/com/ecommerce/adapter/in/dto/response/PopularItemResponse.kt +++ b/ecommerce-adapter/src/main/kotlin/com/ecommerce/adapter/in/dto/response/PopularItemResponse.kt @@ -4,7 +4,6 @@ import com.ecommerce.domain.item.PopularItem data class PopularItemResponse( val rank: Int, - val cumulativeSales: Long, val item: ItemResponse ) { @@ -12,7 +11,6 @@ data class PopularItemResponse( fun of(popularItem: PopularItem): PopularItemResponse { return PopularItemResponse( rank = popularItem.rank, - cumulativeSales = popularItem.cumulativeSales, item = ItemResponse.of(popularItem.item) ) } diff --git a/ecommerce-adapter/src/main/kotlin/com/ecommerce/adapter/out/persistence/core/ItemPersistenceAdapter.kt b/ecommerce-adapter/src/main/kotlin/com/ecommerce/adapter/out/persistence/core/ItemPersistenceAdapter.kt index 5257818..caa9345 100644 --- a/ecommerce-adapter/src/main/kotlin/com/ecommerce/adapter/out/persistence/core/ItemPersistenceAdapter.kt +++ b/ecommerce-adapter/src/main/kotlin/com/ecommerce/adapter/out/persistence/core/ItemPersistenceAdapter.kt @@ -8,14 +8,20 @@ import com.ecommerce.common.exception.ErrorCode import com.ecommerce.domain.item.Item import org.springframework.data.domain.Page import org.springframework.data.domain.PageRequest +import org.springframework.data.redis.core.RedisTemplate import org.springframework.stereotype.Repository @Repository class ItemPersistenceAdapter( private val itemMapper: ItemMapper, - private val jpaRepository: ItemJpaRepository + private val jpaRepository: ItemJpaRepository, + private val redisTemplate: RedisTemplate ): ItemPort { + companion object { + const val POPULAR_ITEMS = "popular-items-" + } + override fun getItemsByPage(page: Int, size: Int): Page { val items = jpaRepository.findAll(PageRequest.of(page, size)) @@ -30,8 +36,36 @@ class ItemPersistenceAdapter( return items.map { itemMapper.toItem(it) } } + override fun getItems(itemId: Long): Item { + val item = jpaRepository.findById(itemId) + .orElseThrow { CustomException(ErrorCode.ITEM_NOT_FOUND) } + + return itemMapper.toItem(item) + } + override fun updateItem(item: Item) { jpaRepository.save(itemMapper.toItemEntity(item)) } + override fun getPopularItemIds(period: Long): List { + val key = POPULAR_ITEMS.plus(period) + + val json = redisTemplate.opsForValue().get(key) + + return if (json != null) { + val trimmed = json.removePrefix("[").removeSuffix("]") + trimmed.split(",").map { it.trim().toLong() } + } else { + emptyList() + } + } + + override fun updatePopularItemRank(period: Long, itemIds: List) { + val key = POPULAR_ITEMS.plus(period) + + val stringItemIds = itemIds.toString() + + redisTemplate.opsForValue().set(key, stringItemIds) + } + } \ No newline at end of file diff --git a/ecommerce-adapter/src/test/kotlin/com/ecommerce/adapter/persistence/ItemPersistenceAdapterTest.kt b/ecommerce-adapter/src/test/kotlin/com/ecommerce/adapter/persistence/ItemPersistenceAdapterTest.kt index aa86161..79a0678 100644 --- a/ecommerce-adapter/src/test/kotlin/com/ecommerce/adapter/persistence/ItemPersistenceAdapterTest.kt +++ b/ecommerce-adapter/src/test/kotlin/com/ecommerce/adapter/persistence/ItemPersistenceAdapterTest.kt @@ -10,14 +10,18 @@ import com.ecommerce.domain.item.Item import org.assertj.core.api.Assertions.* import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.DynamicTest import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestFactory import org.springframework.beans.factory.annotation.Autowired +import org.springframework.data.redis.core.RedisTemplate import java.math.BigDecimal class ItemPersistenceAdapterTest @Autowired constructor( private val sut: ItemPort, private val itemJpaRepository: ItemJpaRepository, - private val stockJpaRepository: StockJpaRepository + private val stockJpaRepository: StockJpaRepository, + private val redisTemplate: RedisTemplate ): IntegrateTestSupport() { @BeforeEach @@ -55,4 +59,32 @@ class ItemPersistenceAdapterTest @Autowired constructor( assertThat(result.content).hasSize(1) } + @DisplayName("인기 상품 순위 업데이트 & 조회 테스트") + @TestFactory + fun popularItemRankCommandAndQueryTest(): List { + // given + val period = 3L + val redisKey = "popular-items-".plus(period) + val itemIds = listOf(1L, 2L, 3L) + + return listOf( + DynamicTest.dynamicTest("인기 상품 순위 업데이트") { + // when + sut.updatePopularItemRank(period, itemIds) + + // then + val result = redisTemplate.opsForValue().get(redisKey) + assertThat(result).isEqualTo("[1, 2, 3]") + }, + DynamicTest.dynamicTest("인기 상품 조회") { + // when + val result = sut.getPopularItemIds(period) + + // then + assertThat(result).hasSize(3) + .containsExactly(1L, 2L, 3L) + } + ) + } + } \ No newline at end of file diff --git a/ecommerce-adapter/src/test/kotlin/com/ecommerce/adapter/service/ItemRankScheduleTest.kt b/ecommerce-adapter/src/test/kotlin/com/ecommerce/adapter/service/ItemRankScheduleTest.kt new file mode 100644 index 0000000..b853da7 --- /dev/null +++ b/ecommerce-adapter/src/test/kotlin/com/ecommerce/adapter/service/ItemRankScheduleTest.kt @@ -0,0 +1,39 @@ +package com.ecommerce.adapter.service + +import com.ecommerce.adapter.config.IntegrateTestSupport +import com.ecommerce.adapter.fixture.OrderFixture +import com.ecommerce.application.port.out.ItemPort +import com.ecommerce.application.schedule.ItemRankSchedule +import org.assertj.core.api.Assertions.* +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired + +class ItemRankScheduleTest @Autowired constructor( + private val sut: ItemRankSchedule, + private val itemPort: ItemPort, + private val orderFixture: OrderFixture +): IntegrateTestSupport() { + + @BeforeEach + fun setUp() { + orderFixture.placeOrder(OrderFixture.OrderFixtureStatus.BULK) + } + + @DisplayName("스케줄러를 통해 특정 기간동안 주문된 상품을 집계하여 인기 상품 순위를 결정한다.") + @Test + fun whenExecuteScheduler_thenAggregateOrderStatistics() { + // given + val period = 3L + + // when + sut.aggregateOrderStatistics() + + // then + val result = itemPort.getPopularItemIds(period) + assertThat(result).hasSize(5) + .containsExactly(5L, 2L, 1L, 4L, 3L) + } + +} \ No newline at end of file diff --git a/ecommerce-adapter/src/test/kotlin/com/ecommerce/adapter/usecase/ItemUseCaseTest.kt b/ecommerce-adapter/src/test/kotlin/com/ecommerce/adapter/usecase/ItemUseCaseTest.kt index b91e07c..e34094a 100644 --- a/ecommerce-adapter/src/test/kotlin/com/ecommerce/adapter/usecase/ItemUseCaseTest.kt +++ b/ecommerce-adapter/src/test/kotlin/com/ecommerce/adapter/usecase/ItemUseCaseTest.kt @@ -3,6 +3,7 @@ package com.ecommerce.adapter.usecase import com.ecommerce.adapter.config.IntegrateTestSupport import com.ecommerce.adapter.fixture.OrderFixture import com.ecommerce.application.port.`in`.ItemUseCase +import com.ecommerce.application.schedule.ItemRankSchedule import org.assertj.core.api.Assertions.* import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test @@ -10,6 +11,7 @@ import org.springframework.beans.factory.annotation.Autowired class ItemUseCaseTest @Autowired constructor( private val sut: ItemUseCase, + private val rankScheduler: ItemRankSchedule, private val orderFixture: OrderFixture ): IntegrateTestSupport() { @@ -19,22 +21,23 @@ class ItemUseCaseTest @Autowired constructor( } @Test - fun getPopularItemsOnTop10() { + fun getPopularItems() { // given val period = 3L + rankScheduler.aggregateOrderStatistics() // when - val popularItems = sut.getPopularItemsOnTop10(period) + val result = sut.getPopularItems(period) // then - assertThat(popularItems).hasSize(5) - .extracting("rank", "cumulativeSales", "item.id", "item.name") + assertThat(result).hasSize(5) + .extracting("rank", "item.id", "item.name") .containsExactly( - tuple(1, 20L, 5L, "상품 E"), - tuple(2, 18L, 2L, "상품 B"), - tuple(3, 15L, 1L, "상품 A"), - tuple(4, 12L, 4L, "상품 D"), - tuple(5, 10L, 3L, "상품 C") + tuple(1, 5L, "상품 E"), + tuple(2, 2L, "상품 B"), + tuple(3, 1L, "상품 A"), + tuple(4, 4L, "상품 D"), + tuple(5, 3L, "상품 C") ) } diff --git a/ecommerce-application/src/main/kotlin/com/ecommerce/application/port/in/ItemUseCase.kt b/ecommerce-application/src/main/kotlin/com/ecommerce/application/port/in/ItemUseCase.kt index 5ecbb06..4d4227a 100644 --- a/ecommerce-application/src/main/kotlin/com/ecommerce/application/port/in/ItemUseCase.kt +++ b/ecommerce-application/src/main/kotlin/com/ecommerce/application/port/in/ItemUseCase.kt @@ -8,6 +8,6 @@ interface ItemUseCase { fun getItems(page: Int, size: Int): Page - fun getPopularItemsOnTop10(period: Long): List + fun getPopularItems(period: Long): List } \ No newline at end of file diff --git a/ecommerce-application/src/main/kotlin/com/ecommerce/application/port/out/ItemPort.kt b/ecommerce-application/src/main/kotlin/com/ecommerce/application/port/out/ItemPort.kt index 3f01d57..d9f3b9f 100644 --- a/ecommerce-application/src/main/kotlin/com/ecommerce/application/port/out/ItemPort.kt +++ b/ecommerce-application/src/main/kotlin/com/ecommerce/application/port/out/ItemPort.kt @@ -9,6 +9,12 @@ interface ItemPort { fun getItemsIn(itemIds: List): List + fun getItems(itemId: Long): Item + fun updateItem(item: Item) + fun getPopularItemIds(period: Long): List + + fun updatePopularItemRank(period: Long, itemIds: List) + } \ No newline at end of file diff --git a/ecommerce-application/src/main/kotlin/com/ecommerce/application/schedule/ItemRankSchedule.kt b/ecommerce-application/src/main/kotlin/com/ecommerce/application/schedule/ItemRankSchedule.kt new file mode 100644 index 0000000..41fe6ad --- /dev/null +++ b/ecommerce-application/src/main/kotlin/com/ecommerce/application/schedule/ItemRankSchedule.kt @@ -0,0 +1,43 @@ +package com.ecommerce.application.schedule + +import com.ecommerce.application.port.out.ItemPort +import com.ecommerce.application.port.out.OrderItemPort +import com.ecommerce.domain.order.OrderItem +import org.springframework.scheduling.annotation.Scheduled +import org.springframework.stereotype.Component + +@Component +class ItemRankSchedule( + private val orderItemPort: OrderItemPort, + private val itemPort: ItemPort +) { + + companion object { + val PERIOD_LIST = listOf(3L, 7L) + } + + @Scheduled(cron = "0 0 0 * * *") + fun aggregateOrderStatistics() { + PERIOD_LIST.forEach { + // 특정 기간동안 주문된 상품 조회 + val orderItemsByPeriod = orderItemPort.getOrderItemsByPeriod(it) + + // 주문 수량이 많은 순으로 정렬 + val sortedPopularItemIds = deduplicateAfterCalculateTotalQuantity(orderItemsByPeriod) + + // 인기 상품 ID 저장 + itemPort.updatePopularItemRank(it, sortedPopularItemIds) + } + } + + private fun deduplicateAfterCalculateTotalQuantity(orderItemsByPeriod: List) = + orderItemsByPeriod + .groupBy { it.itemId } + .map { (itemId, items) -> + val totalQuantity = items.sumOf { it.quantity } + OrderItem(null, itemId, totalQuantity) + } + .sortedByDescending { it.quantity } + .map { it.itemId } + +} \ No newline at end of file diff --git a/ecommerce-application/src/main/kotlin/com/ecommerce/application/service/ItemService.kt b/ecommerce-application/src/main/kotlin/com/ecommerce/application/service/ItemService.kt index 2f64628..3a0126e 100644 --- a/ecommerce-application/src/main/kotlin/com/ecommerce/application/service/ItemService.kt +++ b/ecommerce-application/src/main/kotlin/com/ecommerce/application/service/ItemService.kt @@ -5,7 +5,6 @@ import com.ecommerce.application.port.out.ItemPort import com.ecommerce.application.port.out.OrderItemPort import com.ecommerce.domain.item.Item import com.ecommerce.domain.item.PopularItem -import com.ecommerce.domain.order.OrderItem import org.springframework.data.domain.Page import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -25,44 +24,21 @@ class ItemService( return itemPort.getItemsByPage(page, size) } - override fun getPopularItemsOnTop10(period: Long): List { - // period 만큼 주문 상품 전체 조회 - val orderItemsByPeriod = orderItemPort.getOrderItemsByPeriod(period) + override fun getPopularItems(period: Long): List { + val itemIds = itemPort.getPopularItemIds(period) - // 중복 제거 후, 주문 수량 합 계산 - val orderItems = deduplicateAfterCalculateTotalQuantity(orderItemsByPeriod) + val items = itemIds.map { itemPort.getItems(it) }.toList() - // 정렬된 주문 상품의 id로 실제 상품 조회 - val items = itemPort.getItemsIn( - orderItems.map { it.itemId } - ) - - // 인기 상품 변환 - return convertPopularItems(items, orderItems) + return convertPopularItems(items) } - private fun convertPopularItems( - items: List, - orderItems: List - ): List { - val itemOfId = items.associateBy { it.id } - return orderItems.mapIndexed { index, item -> + private fun convertPopularItems(items: List): List { + return items.mapIndexed { index, item -> PopularItem( rank = index + 1, - cumulativeSales = item.quantity, - item = itemOfId[item.itemId]!! + item = item ) } } - private fun deduplicateAfterCalculateTotalQuantity(orderItemsByPeriod: List) = - orderItemsByPeriod - .groupBy { it.itemId } - .map { (itemId, items) -> - val totalQuantity = items.sumOf { it.quantity } - OrderItem(null, itemId, totalQuantity) - } - .sortedByDescending { it.quantity } - .take(ITEM_LIMIT) - } \ No newline at end of file diff --git a/ecommerce-common/src/main/kotlin/com/ecommerce/common/config/RedisCleanUp.kt b/ecommerce-common/src/main/kotlin/com/ecommerce/common/config/RedisCleanUp.kt index 9f6469f..f28e226 100644 --- a/ecommerce-common/src/main/kotlin/com/ecommerce/common/config/RedisCleanUp.kt +++ b/ecommerce-common/src/main/kotlin/com/ecommerce/common/config/RedisCleanUp.kt @@ -6,7 +6,7 @@ import org.springframework.stereotype.Component @Component class RedisCleanUp( - private val redisTemplate: RedisTemplate + private val redisTemplate: RedisTemplate ) { private val log = LoggerFactory.getLogger(RedisCleanUp::class.java) diff --git a/ecommerce-common/src/main/kotlin/com/ecommerce/common/config/RedisConfig.kt b/ecommerce-common/src/main/kotlin/com/ecommerce/common/config/RedisConfig.kt index a88b6c3..a2572c7 100644 --- a/ecommerce-common/src/main/kotlin/com/ecommerce/common/config/RedisConfig.kt +++ b/ecommerce-common/src/main/kotlin/com/ecommerce/common/config/RedisConfig.kt @@ -9,7 +9,6 @@ import org.springframework.context.annotation.Configuration import org.springframework.data.redis.connection.RedisConnectionFactory import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory import org.springframework.data.redis.core.RedisTemplate -import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer import org.springframework.data.redis.serializer.StringRedisSerializer @Configuration @@ -24,11 +23,11 @@ class RedisConfig( fun redisConnectionFactory(): RedisConnectionFactory = LettuceConnectionFactory(host, port) @Bean - fun redisTemplate(): RedisTemplate { - return RedisTemplate().apply { + fun redisTemplate(): RedisTemplate { + return RedisTemplate().apply { connectionFactory = redisConnectionFactory() keySerializer = StringRedisSerializer() - valueSerializer = GenericJackson2JsonRedisSerializer() + valueSerializer = StringRedisSerializer() } } diff --git a/ecommerce-domain/src/main/kotlin/com/ecommerce/domain/item/PopularItem.kt b/ecommerce-domain/src/main/kotlin/com/ecommerce/domain/item/PopularItem.kt index 5048f5b..4010818 100644 --- a/ecommerce-domain/src/main/kotlin/com/ecommerce/domain/item/PopularItem.kt +++ b/ecommerce-domain/src/main/kotlin/com/ecommerce/domain/item/PopularItem.kt @@ -2,6 +2,5 @@ package com.ecommerce.domain.item class PopularItem( val rank: Int, - val cumulativeSales: Long, val item: Item ) \ No newline at end of file