Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions ecommerce-adapter/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ class ItemController(
fun getPopularItems(
@RequestParam(value = "period", defaultValue = "3") period: Long
): ResponseEntity<List<PopularItemResponse>> {
val popularItems = itemUseCase.getPopularItemsOnTop10(period)
val popularItems = itemUseCase.getPopularItems(period)

return ResponseEntity.ok(
popularItems.map { PopularItemResponse.of(it) }.toList()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,13 @@ import com.ecommerce.domain.item.PopularItem

data class PopularItemResponse(
val rank: Int,
val cumulativeSales: Long,
val item: ItemResponse
) {

companion object {
fun of(popularItem: PopularItem): PopularItemResponse {
return PopularItemResponse(
rank = popularItem.rank,
cumulativeSales = popularItem.cumulativeSales,
item = ItemResponse.of(popularItem.item)
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, String>
): ItemPort {

companion object {
const val POPULAR_ITEMS = "popular-items-"
}

override fun getItemsByPage(page: Int, size: Int): Page<Item> {
val items = jpaRepository.findAll(PageRequest.of(page, size))

Expand All @@ -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<Long> {
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<Long>) {
val key = POPULAR_ITEMS.plus(period)

val stringItemIds = itemIds.toString()

redisTemplate.opsForValue().set(key, stringItemIds)
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, String>
): IntegrateTestSupport() {

@BeforeEach
Expand Down Expand Up @@ -55,4 +59,32 @@ class ItemPersistenceAdapterTest @Autowired constructor(
assertThat(result.content).hasSize(1)
}

@DisplayName("인기 상품 순위 업데이트 & 조회 테스트")
@TestFactory
fun popularItemRankCommandAndQueryTest(): List<DynamicTest> {
// 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)
}
)
}

}
Original file line number Diff line number Diff line change
@@ -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)
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,15 @@ 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
import org.springframework.beans.factory.annotation.Autowired

class ItemUseCaseTest @Autowired constructor(
private val sut: ItemUseCase,
private val rankScheduler: ItemRankSchedule,
private val orderFixture: OrderFixture
): IntegrateTestSupport() {

Expand All @@ -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")
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,6 @@ interface ItemUseCase {

fun getItems(page: Int, size: Int): Page<Item>

fun getPopularItemsOnTop10(period: Long): List<PopularItem>
fun getPopularItems(period: Long): List<PopularItem>

}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@ interface ItemPort {

fun getItemsIn(itemIds: List<Long>): List<Item>

fun getItems(itemId: Long): Item

fun updateItem(item: Item)

fun getPopularItemIds(period: Long): List<Long>

fun updatePopularItemRank(period: Long, itemIds: List<Long>)

}
Original file line number Diff line number Diff line change
@@ -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<OrderItem>) =
orderItemsByPeriod
.groupBy { it.itemId }
.map { (itemId, items) ->
val totalQuantity = items.sumOf { it.quantity }
OrderItem(null, itemId, totalQuantity)
}
.sortedByDescending { it.quantity }
.map { it.itemId }

}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -25,44 +24,21 @@ class ItemService(
return itemPort.getItemsByPage(page, size)
}

override fun getPopularItemsOnTop10(period: Long): List<PopularItem> {
// period 만큼 주문 상품 전체 조회
val orderItemsByPeriod = orderItemPort.getOrderItemsByPeriod(period)
override fun getPopularItems(period: Long): List<PopularItem> {
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<Item>,
orderItems: List<OrderItem>
): List<PopularItem> {
val itemOfId = items.associateBy { it.id }
return orderItems.mapIndexed { index, item ->
private fun convertPopularItems(items: List<Item>): List<PopularItem> {
return items.mapIndexed { index, item ->
PopularItem(
rank = index + 1,
cumulativeSales = item.quantity,
item = itemOfId[item.itemId]!!
item = item
)
}
}

private fun deduplicateAfterCalculateTotalQuantity(orderItemsByPeriod: List<OrderItem>) =
orderItemsByPeriod
.groupBy { it.itemId }
.map { (itemId, items) ->
val totalQuantity = items.sumOf { it.quantity }
OrderItem(null, itemId, totalQuantity)
}
.sortedByDescending { it.quantity }
.take(ITEM_LIMIT)

}
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import org.springframework.stereotype.Component

@Component
class RedisCleanUp(
private val redisTemplate: RedisTemplate<String, Any>
private val redisTemplate: RedisTemplate<String, String>
) {

private val log = LoggerFactory.getLogger(RedisCleanUp::class.java)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -24,11 +23,11 @@ class RedisConfig(
fun redisConnectionFactory(): RedisConnectionFactory = LettuceConnectionFactory(host, port)

@Bean
fun redisTemplate(): RedisTemplate<String, Any> {
return RedisTemplate<String, Any>().apply {
fun redisTemplate(): RedisTemplate<String, String> {
return RedisTemplate<String, String>().apply {
connectionFactory = redisConnectionFactory()
keySerializer = StringRedisSerializer()
valueSerializer = GenericJackson2JsonRedisSerializer()
valueSerializer = StringRedisSerializer()
}
}

Expand Down
Loading