From 4c890f51414808c14478991006e00f006f38b7dc Mon Sep 17 00:00:00 2001 From: Younggil An Date: Thu, 31 Jul 2025 01:17:34 +0900 Subject: [PATCH 1/7] =?UTF-8?q?feat:=20=EC=A3=BC=EB=AC=B8=20=EB=8F=84?= =?UTF-8?q?=EB=A9=94=EC=9D=B8=20=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle.kts | 3 +- .../kotlin/com/nilgil/commerce/order/Order.kt | 24 +++++++++++++- .../com/nilgil/commerce/order/OrderItem.kt | 5 ++- .../order/OrderItemOptionMapConverter.kt | 22 +++++++++++++ .../com/nilgil/commerce/order/OrderLine.kt | 26 ++++++++++++--- .../com/nilgil/commerce/order/OrderStatus.kt | 11 +++++++ .../nilgil/commerce/order/OrderFixtures.kt | 10 ++---- .../nilgil/commerce/order/OrderLineTest.kt | 16 ++++++++-- .../com/nilgil/commerce/order/OrderTest.kt | 32 +++++++++++++++++++ 9 files changed, 130 insertions(+), 19 deletions(-) create mode 100644 src/main/kotlin/com/nilgil/commerce/order/OrderItemOptionMapConverter.kt diff --git a/build.gradle.kts b/build.gradle.kts index bc856b1..de19882 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -36,8 +36,9 @@ dependencies { annotationProcessor("org.springframework.boot:spring-boot-configuration-processor") developmentOnly("org.springframework.boot:spring-boot-docker-compose") testImplementation("org.springframework.boot:spring-boot-starter-test") - testImplementation("io.kotest:kotest-runner-junit5:5.8.0") + testImplementation("io.kotest:kotest-runner-junit5:5.9.1") testImplementation("com.appmattus.fixture:fixture:1.2.0") + testImplementation("io.mockk:mockk:1.14.5") } kotlin { diff --git a/src/main/kotlin/com/nilgil/commerce/order/Order.kt b/src/main/kotlin/com/nilgil/commerce/order/Order.kt index 5a4ba40..30f5648 100644 --- a/src/main/kotlin/com/nilgil/commerce/order/Order.kt +++ b/src/main/kotlin/com/nilgil/commerce/order/Order.kt @@ -2,10 +2,13 @@ package com.nilgil.commerce.order import com.nilgil.commerce.common.BaseEntity import com.nilgil.commerce.common.error.CoreException +import jakarta.persistence.CascadeType import jakarta.persistence.Column import jakarta.persistence.Entity import jakarta.persistence.EnumType import jakarta.persistence.Enumerated +import jakarta.persistence.FetchType +import jakarta.persistence.OneToMany import jakarta.persistence.Table @Entity @@ -13,7 +16,8 @@ import jakarta.persistence.Table class Order( @Column(unique = true) val code: String, - val totalAmount: Int, + @OneToMany(mappedBy = "order", fetch = FetchType.LAZY, cascade = [CascadeType.ALL], orphanRemoval = true) + val lines: List = listOf(), val userId: Long, ) : BaseEntity() { @Enumerated(EnumType.STRING) @@ -21,6 +25,24 @@ class Order( var paymentId: Long? = null + val totalAmount: Int = lines.sumOf { it.totalPrice } + + init { + require(lines.isNotEmpty()) { + "주문 항목은 비어있을 수 없습니다." + } + + require(lines.size == lines.distinctBy { it.productItemId }.size) { + "주문 항목에 중복된 상품이 존재할 수 없습니다." + } + + require(totalAmount >= 0) { + "총 금액은 0 이상이어야 합니다." + } + + lines.forEach { it.linkOrder(this) } + } + fun pay(paymentId: Long) { if (this.status != OrderStatus.CREATED) { throw CoreException( diff --git a/src/main/kotlin/com/nilgil/commerce/order/OrderItem.kt b/src/main/kotlin/com/nilgil/commerce/order/OrderItem.kt index 837fc99..c8b8673 100644 --- a/src/main/kotlin/com/nilgil/commerce/order/OrderItem.kt +++ b/src/main/kotlin/com/nilgil/commerce/order/OrderItem.kt @@ -1,6 +1,5 @@ package com.nilgil.commerce.order -import com.nilgil.commerce.common.StringListConverter import jakarta.persistence.Column import jakarta.persistence.Convert import jakarta.persistence.Embeddable @@ -9,9 +8,9 @@ import jakarta.persistence.Embeddable data class OrderItem( @Column(name = "item_title") val title: String, - @Convert(converter = StringListConverter::class) + @Convert(converter = OrderItemOptionMapConverter::class) @Column(name = "item_options") - val options: List = listOf(), + val options: Map = emptyMap(), @Column(name = "item_price") val price: Int, @Column(name = "item_thumbnail_image_url") diff --git a/src/main/kotlin/com/nilgil/commerce/order/OrderItemOptionMapConverter.kt b/src/main/kotlin/com/nilgil/commerce/order/OrderItemOptionMapConverter.kt new file mode 100644 index 0000000..463132b --- /dev/null +++ b/src/main/kotlin/com/nilgil/commerce/order/OrderItemOptionMapConverter.kt @@ -0,0 +1,22 @@ +package com.nilgil.commerce.order + +import com.fasterxml.jackson.core.type.TypeReference +import com.fasterxml.jackson.databind.ObjectMapper +import jakarta.persistence.AttributeConverter +import jakarta.persistence.Converter + +@Converter +class OrderItemOptionMapConverter : AttributeConverter, String> { + private val objectMapper = ObjectMapper() + + override fun convertToDatabaseColumn(attribute: Map?): String? = + attribute?.let { + // Map 요소 순서 변경에 의한 Update 방지를 위해 sortedMap 사용 + objectMapper.writeValueAsString(attribute.toSortedMap()) + } + + override fun convertToEntityAttribute(dbData: String?): Map? = + dbData?.let { + objectMapper.readValue(it, object : TypeReference>() {}) + } ?: emptyMap() +} diff --git a/src/main/kotlin/com/nilgil/commerce/order/OrderLine.kt b/src/main/kotlin/com/nilgil/commerce/order/OrderLine.kt index 9c40ce0..361e716 100644 --- a/src/main/kotlin/com/nilgil/commerce/order/OrderLine.kt +++ b/src/main/kotlin/com/nilgil/commerce/order/OrderLine.kt @@ -1,9 +1,12 @@ package com.nilgil.commerce.order import com.nilgil.commerce.common.BaseEntity +import jakarta.persistence.ConstraintMode import jakarta.persistence.Embedded import jakarta.persistence.Entity import jakarta.persistence.FetchType +import jakarta.persistence.ForeignKey +import jakarta.persistence.JoinColumn import jakarta.persistence.ManyToOne @Entity @@ -11,12 +14,27 @@ class OrderLine( @Embedded val item: OrderItem, val quantity: Int, - @ManyToOne(fetch = FetchType.LAZY) - val order: Order, ) : BaseEntity() { + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "order_id", foreignKey = ForeignKey(ConstraintMode.NO_CONSTRAINT)) + lateinit var order: Order + + val totalPrice: Int + get() = item.price * quantity + + val productItemId: Long + get() = item.productItemId + + companion object { + const val MIN_QUANTITY = 1 + const val MAX_QUANTITY = 100 + } + init { - require(quantity >= 1) { "수량은 1개 이상이어야 합니다." } + require(quantity in MIN_QUANTITY..MAX_QUANTITY) { "주문 수량은 $MIN_QUANTITY ~ $MAX_QUANTITY 사이여야 합니다." } } - fun getTotalPrice(): Int = item.price * quantity + internal fun linkOrder(order: Order) { + this.order = order + } } diff --git a/src/main/kotlin/com/nilgil/commerce/order/OrderStatus.kt b/src/main/kotlin/com/nilgil/commerce/order/OrderStatus.kt index aa8f322..01479df 100644 --- a/src/main/kotlin/com/nilgil/commerce/order/OrderStatus.kt +++ b/src/main/kotlin/com/nilgil/commerce/order/OrderStatus.kt @@ -6,4 +6,15 @@ enum class OrderStatus { COMPLETED, CANCELLED, RETURNED, + ; + + companion object { + val activeStatuses = listOf(CREATED, PAID) + + val terminalStatuses = listOf(COMPLETED, CANCELLED, RETURNED) + } + + fun isActive() = activeStatuses.contains(this) + + fun isTerminal() = terminalStatuses.contains(this) } diff --git a/src/test/kotlin/com/nilgil/commerce/order/OrderFixtures.kt b/src/test/kotlin/com/nilgil/commerce/order/OrderFixtures.kt index bb4e927..4d205bd 100644 --- a/src/test/kotlin/com/nilgil/commerce/order/OrderFixtures.kt +++ b/src/test/kotlin/com/nilgil/commerce/order/OrderFixtures.kt @@ -1,33 +1,29 @@ package com.nilgil.commerce.order -import org.mockito.Mockito.mock - object OrderFixtures { fun anOrder( code: String = "TEST-ORDER-CODE-12345", - totalAmount: Int = 0, + lines: List = listOf(anOrderLine()), userId: Long = 1L, ): Order = Order( code = code, - totalAmount = totalAmount, + lines = lines, userId = userId, ) fun anOrderLine( - order: Order = mock(Order::class.java), quantity: Int = 1, item: OrderItem = anOrderItem(), ): OrderLine = OrderLine( - order = order, quantity = quantity, item = item, ) fun anOrderItem( title: String = "테스트 상품", - options: List = listOf("옵션1", "옵션2"), + options: Map = mapOf(Pair("옵션1", "값1"), Pair("옵션2", "값2")), price: Int = 10000, productItemId: Long = 1L, ): OrderItem = diff --git a/src/test/kotlin/com/nilgil/commerce/order/OrderLineTest.kt b/src/test/kotlin/com/nilgil/commerce/order/OrderLineTest.kt index 350a1f9..a3b3a4a 100644 --- a/src/test/kotlin/com/nilgil/commerce/order/OrderLineTest.kt +++ b/src/test/kotlin/com/nilgil/commerce/order/OrderLineTest.kt @@ -22,9 +22,9 @@ class OrderLineTest { } } - @CsvSource("1", Integer.MAX_VALUE.toString()) + @CsvSource("1", "50", "100") @ParameterizedTest - fun `수량이 양수이면 정상적으로 생성된다`(quantity: Int) { + fun `수량이 1 이상 100 이하이면 정상적으로 생성된다`(quantity: Int) { // when val orderLine = OrderFixtures.anOrderLine(quantity = quantity) @@ -32,6 +32,16 @@ class OrderLineTest { assertThat(orderLine).isNotNull assertThat(orderLine.quantity).isEqualTo(quantity) } + + @CsvSource("101", Integer.MAX_VALUE.toString()) + @ParameterizedTest + fun `수량이 100 초과이면 IllegalArgumentException이 발생한다`(quantity: Int) { + // when, then + assertThatIllegalArgumentException() + .isThrownBy { + OrderFixtures.anOrderLine(quantity = quantity) + } + } } @Nested @@ -47,7 +57,7 @@ class OrderLineTest { val orderLine = OrderFixtures.anOrderLine(item = item, quantity = quantity) // when - val totalPrice = orderLine.getTotalPrice() + val totalPrice = orderLine.totalPrice // then assertThat(totalPrice).isEqualTo(price * quantity) diff --git a/src/test/kotlin/com/nilgil/commerce/order/OrderTest.kt b/src/test/kotlin/com/nilgil/commerce/order/OrderTest.kt index 3fa7930..56c10d2 100644 --- a/src/test/kotlin/com/nilgil/commerce/order/OrderTest.kt +++ b/src/test/kotlin/com/nilgil/commerce/order/OrderTest.kt @@ -1,6 +1,8 @@ package com.nilgil.commerce.order import com.nilgil.commerce.common.error.CoreException +import io.mockk.every +import io.mockk.mockk import org.assertj.core.api.Assertions.assertThat import org.assertj.core.api.Assertions.assertThatThrownBy import org.junit.jupiter.api.DisplayName @@ -19,6 +21,36 @@ class OrderTest { // then assertThat(order.status).isEqualTo(OrderStatus.CREATED) } + + @Test + fun `주문 항목에 중복된 상품이 존재하면 IllegalArgumentException이 발생한다`() { + // given + val productItemId = 1L + val duplicateProductItemLines = + listOf( + OrderFixtures.anOrderLine(item = OrderFixtures.anOrderItem(productItemId = productItemId)), + OrderFixtures.anOrderLine(item = OrderFixtures.anOrderItem(productItemId = productItemId)), + ) + + // when, then + assertThatThrownBy { + OrderFixtures.anOrder(lines = duplicateProductItemLines) + }.isInstanceOf(IllegalArgumentException::class.java) + } + + @Test + fun `주문 총 금액이 0 미만이면 IllegalArgumentException이 발생한다`() { + val mockOrderLine = mockk() + val mockItem = mockk() + + every { mockOrderLine.item } returns mockItem + every { mockOrderLine.productItemId } returns 1L + every { mockOrderLine.totalPrice } returns -1000 + + assertThatThrownBy { + OrderFixtures.anOrder(lines = listOf(mockOrderLine)) + }.isInstanceOf(IllegalArgumentException::class.java) + } } @Nested From ca3c5e90a9dbd5556d4a9d69f56f11bfa5ecd5af Mon Sep 17 00:00:00 2001 From: Younggil An Date: Thu, 31 Jul 2025 06:01:41 +0900 Subject: [PATCH 2/7] =?UTF-8?q?feat:=20=EC=A3=BC=EB=AC=B8=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=83=9D=EC=84=B1=EA=B8=B0=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/nilgil/commerce/order/OrderCodeGenerator.kt | 5 +++++ .../order/SystemTimeBasedOrderCodeGenerator.kt | 12 ++++++++++++ 2 files changed, 17 insertions(+) create mode 100644 src/main/kotlin/com/nilgil/commerce/order/OrderCodeGenerator.kt create mode 100644 src/main/kotlin/com/nilgil/commerce/order/SystemTimeBasedOrderCodeGenerator.kt diff --git a/src/main/kotlin/com/nilgil/commerce/order/OrderCodeGenerator.kt b/src/main/kotlin/com/nilgil/commerce/order/OrderCodeGenerator.kt new file mode 100644 index 0000000..3a90165 --- /dev/null +++ b/src/main/kotlin/com/nilgil/commerce/order/OrderCodeGenerator.kt @@ -0,0 +1,5 @@ +package com.nilgil.commerce.order + +interface OrderCodeGenerator { + fun generate(): String +} diff --git a/src/main/kotlin/com/nilgil/commerce/order/SystemTimeBasedOrderCodeGenerator.kt b/src/main/kotlin/com/nilgil/commerce/order/SystemTimeBasedOrderCodeGenerator.kt new file mode 100644 index 0000000..f825c2e --- /dev/null +++ b/src/main/kotlin/com/nilgil/commerce/order/SystemTimeBasedOrderCodeGenerator.kt @@ -0,0 +1,12 @@ +package com.nilgil.commerce.order + +import org.springframework.stereotype.Component + +@Component +class SystemTimeBasedOrderCodeGenerator : OrderCodeGenerator { + /** + * 밀리 초 까지 겹치는 경우는 낙관적으로 처리한다. + * 추후 트래픽 증가에 따라 공유 시퀀스 사용 등 검토가 필요하다. + */ + override fun generate(): String = "ORDER-${System.currentTimeMillis()}" +} From 6dac5e97b1ae2a150e1b065460f41c0615cc334a Mon Sep 17 00:00:00 2001 From: Younggil An Date: Fri, 1 Aug 2025 03:48:20 +0900 Subject: [PATCH 3/7] =?UTF-8?q?feat:=20=EC=A3=BC=EB=AC=B8=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80=20=EB=B0=8F?= =?UTF-8?q?=20=EC=9C=A0=ED=9A=A8=EC=84=B1=20=EA=B2=80=EC=A6=9D=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../nilgil/commerce/order/OrderController.kt | 22 ++++++++ .../com/nilgil/commerce/order/OrderDto.kt | 19 +++++++ .../com/nilgil/commerce/order/OrderError.kt | 12 ++++ .../nilgil/commerce/order/OrderRepository.kt | 10 ++++ .../com/nilgil/commerce/order/OrderService.kt | 56 +++++++++++++++++++ .../nilgil/commerce/order/OrderValidator.kt | 48 ++++++++++++++++ 6 files changed, 167 insertions(+) create mode 100644 src/main/kotlin/com/nilgil/commerce/order/OrderController.kt create mode 100644 src/main/kotlin/com/nilgil/commerce/order/OrderDto.kt create mode 100644 src/main/kotlin/com/nilgil/commerce/order/OrderRepository.kt create mode 100644 src/main/kotlin/com/nilgil/commerce/order/OrderService.kt create mode 100644 src/main/kotlin/com/nilgil/commerce/order/OrderValidator.kt diff --git a/src/main/kotlin/com/nilgil/commerce/order/OrderController.kt b/src/main/kotlin/com/nilgil/commerce/order/OrderController.kt new file mode 100644 index 0000000..210e163 --- /dev/null +++ b/src/main/kotlin/com/nilgil/commerce/order/OrderController.kt @@ -0,0 +1,22 @@ +package com.nilgil.commerce.order + +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RestController +import java.net.URI + +@RestController +class OrderController( + private val service: OrderService, +) { + @PostMapping("/orders") + fun createOrder( + userId: Long, + @RequestBody request: CreateOrderRequest, + ): ResponseEntity { + val response = service.createOrder(userId, request) + val location = URI.create("/orders/${response.code}") + return ResponseEntity.created(location).body(response) + } +} diff --git a/src/main/kotlin/com/nilgil/commerce/order/OrderDto.kt b/src/main/kotlin/com/nilgil/commerce/order/OrderDto.kt new file mode 100644 index 0000000..deaf53e --- /dev/null +++ b/src/main/kotlin/com/nilgil/commerce/order/OrderDto.kt @@ -0,0 +1,19 @@ +package com.nilgil.commerce.order + +data class CreateOrderRequest( + val lines: List = listOf(), +) { + init { + val distinctItemIds = lines.distinctBy { it.productItemId } + require(lines.size == distinctItemIds.size) { "주문 항목에 중복된 상품이 존재할 수 없습니다." } + } +} + +data class CreateOrderLineRequest( + val productItemId: Long, + val quantity: Int, +) + +data class CreateOrderResponse( + val code: String, +) diff --git a/src/main/kotlin/com/nilgil/commerce/order/OrderError.kt b/src/main/kotlin/com/nilgil/commerce/order/OrderError.kt index 744b1f1..3dbfc0c 100644 --- a/src/main/kotlin/com/nilgil/commerce/order/OrderError.kt +++ b/src/main/kotlin/com/nilgil/commerce/order/OrderError.kt @@ -9,4 +9,16 @@ enum class OrderError( override val message: String, ) : ErrorType { INVALID_STATUS_TRANSITION(HttpStatus.CONFLICT, "O001", "주문 상태를 변경할 수 없습니다."), + ALREADY_HAS_ACTIVE_ORDER(HttpStatus.CONFLICT, "C002", "사용자에게 이미 활성화된 주문이 존재합니다."), + INVALID_ITEM(HttpStatus.BAD_REQUEST, "C003", "유효하지 않은 상품입니다."), +} + +data class InvalidLineInfo( + val id: Long, + val code: InvalidType, +) + +enum class InvalidType { + NOT_FOUND, + STOCK_NOT_ENOUGH, } diff --git a/src/main/kotlin/com/nilgil/commerce/order/OrderRepository.kt b/src/main/kotlin/com/nilgil/commerce/order/OrderRepository.kt new file mode 100644 index 0000000..dff0108 --- /dev/null +++ b/src/main/kotlin/com/nilgil/commerce/order/OrderRepository.kt @@ -0,0 +1,10 @@ +package com.nilgil.commerce.order + +import org.springframework.data.jpa.repository.JpaRepository + +interface OrderRepository : JpaRepository { + fun findByUserIdAndStatusIn( + userId: Long, + statuses: List, + ): Order? +} diff --git a/src/main/kotlin/com/nilgil/commerce/order/OrderService.kt b/src/main/kotlin/com/nilgil/commerce/order/OrderService.kt new file mode 100644 index 0000000..5c12da4 --- /dev/null +++ b/src/main/kotlin/com/nilgil/commerce/order/OrderService.kt @@ -0,0 +1,56 @@ +package com.nilgil.commerce.order + +import com.nilgil.commerce.product.ProductItemResponse +import com.nilgil.commerce.product.ProductItemService +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class OrderService( + private val orderRepository: OrderRepository, + private val orderCodeGenerator: OrderCodeGenerator, + private val orderValidator: OrderValidator, + private val productItemService: ProductItemService, +) { + @Transactional + fun createOrder( + userId: Long, + request: CreateOrderRequest, + ): CreateOrderResponse { + orderValidator.validateHasActiveOrder(userId) + + val orderLines = prepareOrderLines(request) + + val code = orderCodeGenerator.generate() + val order = Order(code, orderLines, userId) + orderRepository.save(order) + + return CreateOrderResponse(order.code) + } + + private fun prepareOrderLines(request: CreateOrderRequest): List { + val itemIds = request.lines.map { it.productItemId } + val itemsMap = productItemService.getProductItems(itemIds).associateBy { it.id } + + orderValidator.validateItems(request, itemsMap) + + return request.lines.map { lineRequest -> + val productItem = itemsMap[lineRequest.productItemId]!! + toOrderLine(productItem, lineRequest.quantity) + } + } + + private fun toOrderLine( + item: ProductItemResponse, + quantity: Int, + ) = OrderLine( + OrderItem( + title = item.productName, + options = item.options, + price = item.price, + thumbnailImageUrl = item.thumbnailImageUrl, + productItemId = item.id, + ), + quantity, + ) +} diff --git a/src/main/kotlin/com/nilgil/commerce/order/OrderValidator.kt b/src/main/kotlin/com/nilgil/commerce/order/OrderValidator.kt new file mode 100644 index 0000000..1638bcc --- /dev/null +++ b/src/main/kotlin/com/nilgil/commerce/order/OrderValidator.kt @@ -0,0 +1,48 @@ +package com.nilgil.commerce.order + +import com.nilgil.commerce.common.error.CoreException +import com.nilgil.commerce.product.ProductItemResponse +import org.springframework.stereotype.Component + +@Component +class OrderValidator( + private val orderRepository: OrderRepository, +) { + fun validateHasActiveOrder(userId: Long) { + val activeOrder = orderRepository.findByUserIdAndStatusIn(userId, OrderStatus.activeStatuses) + + if (activeOrder != null) { + throw CoreException(OrderError.ALREADY_HAS_ACTIVE_ORDER, detail = "code: ${activeOrder.code}") + } + } + + fun validateItems( + request: CreateOrderRequest, + itemsMap: Map, + ) { + val invalidItems = + request.lines.mapNotNull { line -> + checkAndGetInvalid(line, itemsMap) + } + + if (invalidItems.isNotEmpty()) { + throw CoreException(OrderError.INVALID_ITEM, detail = invalidItems) + } + } + + private fun checkAndGetInvalid( + line: CreateOrderLineRequest, + itemsMap: Map, + ): InvalidLineInfo? { + val item = itemsMap[line.productItemId] + return when { + item == null -> + InvalidLineInfo(line.productItemId, InvalidType.NOT_FOUND) + + line.quantity > item.stock -> + InvalidLineInfo(line.productItemId, InvalidType.STOCK_NOT_ENOUGH) + + else -> null + } + } +} From d86ae1458e4166663db6afdf6e7324deca3c2489 Mon Sep 17 00:00:00 2001 From: Younggil An Date: Sat, 2 Aug 2025 04:47:25 +0900 Subject: [PATCH 4/7] =?UTF-8?q?feat:=20=EB=9D=BD=20=EA=B5=AC=ED=98=84?= =?UTF-8?q?=EC=9D=84=20=EC=9C=84=ED=95=9C=20Redis=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EB=9D=BD=20=EC=A0=81=EC=9A=A9?= =?UTF-8?q?=20AOP=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle.kts | 3 + compose.yaml | 9 +++ .../nilgil/commerce/common/DistributedLock.kt | 12 ++++ .../commerce/common/DistributedLockAop.kt | 57 +++++++++++++++++++ .../com/nilgil/commerce/common/LockConfig.kt | 19 +++++++ .../nilgil/commerce/common/error/CoreError.kt | 1 + src/main/resources/application.yml | 4 ++ 7 files changed, 105 insertions(+) create mode 100644 src/main/kotlin/com/nilgil/commerce/common/DistributedLock.kt create mode 100644 src/main/kotlin/com/nilgil/commerce/common/DistributedLockAop.kt create mode 100644 src/main/kotlin/com/nilgil/commerce/common/LockConfig.kt diff --git a/build.gradle.kts b/build.gradle.kts index de19882..7bdaded 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -29,6 +29,9 @@ repositories { dependencies { implementation("org.springframework.boot:spring-boot-starter-web") implementation("org.springframework.boot:spring-boot-starter-data-jpa") + implementation("org.springframework.boot:spring-boot-starter-integration") + implementation("org.springframework.integration:spring-integration-redis") + implementation("org.springframework.boot:spring-boot-starter-data-redis") implementation("com.fasterxml.jackson.module:jackson-module-kotlin") implementation("org.jetbrains.kotlin:kotlin-reflect") runtimeOnly("com.h2database:h2") diff --git a/compose.yaml b/compose.yaml index 7a643e4..4f34477 100644 --- a/compose.yaml +++ b/compose.yaml @@ -8,3 +8,12 @@ services: - MYSQL_PASSWORD=${MYSQL_PASSWORD} ports: - '3306:3306' + + redis: + image: 'redis:latest' + ports: + - '6379:6379' + redis-insight: + image: 'redis/redisinsight:latest' + ports: + - '5540:5540' diff --git a/src/main/kotlin/com/nilgil/commerce/common/DistributedLock.kt b/src/main/kotlin/com/nilgil/commerce/common/DistributedLock.kt new file mode 100644 index 0000000..50ae66d --- /dev/null +++ b/src/main/kotlin/com/nilgil/commerce/common/DistributedLock.kt @@ -0,0 +1,12 @@ +package com.nilgil.commerce.common + +import java.util.concurrent.TimeUnit + +@Target(AnnotationTarget.FUNCTION) +@Retention(AnnotationRetention.RUNTIME) +annotation class DistributedLock( + val prefix: String = "", + val key: String, + val waitTime: Long = 5, + val timeUnit: TimeUnit = TimeUnit.SECONDS, +) diff --git a/src/main/kotlin/com/nilgil/commerce/common/DistributedLockAop.kt b/src/main/kotlin/com/nilgil/commerce/common/DistributedLockAop.kt new file mode 100644 index 0000000..64cabf4 --- /dev/null +++ b/src/main/kotlin/com/nilgil/commerce/common/DistributedLockAop.kt @@ -0,0 +1,57 @@ +package com.nilgil.commerce.common + +import com.nilgil.commerce.common.error.CoreError +import com.nilgil.commerce.common.error.CoreException +import org.aspectj.lang.ProceedingJoinPoint +import org.aspectj.lang.annotation.Around +import org.aspectj.lang.annotation.Aspect +import org.aspectj.lang.reflect.MethodSignature +import org.springframework.expression.spel.standard.SpelExpressionParser +import org.springframework.expression.spel.support.StandardEvaluationContext +import org.springframework.integration.support.locks.LockRegistry +import org.springframework.stereotype.Component + +@Aspect +@Component +class DistributedLockAop( + private val lockRegistry: LockRegistry, +) { + @Around("@annotation(distributedLock)") + fun lock( + joinPoint: ProceedingJoinPoint, + distributedLock: DistributedLock, + ): Any? { + val dynamicKey = resolveSpelKey(joinPoint, distributedLock.key) + val lockKey = "${distributedLock.prefix}:$dynamicKey" + + val lock = lockRegistry.obtain(lockKey) + + if (!lock.tryLock(distributedLock.waitTime, distributedLock.timeUnit)) { + throw CoreException(CoreError.FAIL_TO_GET_LOCK, detail = "key: ${distributedLock.key}") + } + + try { + return joinPoint.proceed() + } finally { + lock.unlock() + } + } + + private fun resolveSpelKey( + joinPoint: ProceedingJoinPoint, + key: String, + ): String { + val methodSignature = joinPoint.signature as MethodSignature + val parameterNames = methodSignature.parameterNames + val args = joinPoint.args + + val context = StandardEvaluationContext() + parameterNames.zip(args).forEach { (name, value) -> + context.setVariable(name, value) + } + + val expressionParser = SpelExpressionParser() + return expressionParser.parseExpression(key).getValue(context, String::class.java) + ?: throw CoreException(CoreError.FAIL_TO_GET_LOCK, detail = "락 키를 파싱하는 데 실패했습니다: $key") + } +} diff --git a/src/main/kotlin/com/nilgil/commerce/common/LockConfig.kt b/src/main/kotlin/com/nilgil/commerce/common/LockConfig.kt new file mode 100644 index 0000000..99480b0 --- /dev/null +++ b/src/main/kotlin/com/nilgil/commerce/common/LockConfig.kt @@ -0,0 +1,19 @@ +package com.nilgil.commerce.common + +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.data.redis.connection.RedisConnectionFactory +import org.springframework.integration.redis.util.RedisLockRegistry +import org.springframework.integration.support.locks.LockRegistry + +@Configuration +class LockConfig { + companion object { + const val LOCK_REGISTRY_KEY = "lock" + } + + @Bean + fun redisLockRegistry(redisConnectionFactory: RedisConnectionFactory): LockRegistry = + RedisLockRegistry(redisConnectionFactory, LOCK_REGISTRY_KEY) + .apply { setRedisLockType(RedisLockRegistry.RedisLockType.PUB_SUB_LOCK) } +} diff --git a/src/main/kotlin/com/nilgil/commerce/common/error/CoreError.kt b/src/main/kotlin/com/nilgil/commerce/common/error/CoreError.kt index 96d53cc..205991e 100644 --- a/src/main/kotlin/com/nilgil/commerce/common/error/CoreError.kt +++ b/src/main/kotlin/com/nilgil/commerce/common/error/CoreError.kt @@ -13,4 +13,5 @@ enum class CoreError( AUTHORIZATION_ERROR(HttpStatus.FORBIDDEN, "C004", "접근 권한이 없습니다."), NOT_FOUND_ERROR(HttpStatus.NOT_FOUND, "C005", "리소스를 찾을 수 없습니다."), CONFLICT_ERROR(HttpStatus.CONFLICT, "C006", "충돌되는 리소스가 존재합니다."), + FAIL_TO_GET_LOCK(HttpStatus.CONFLICT, "C007", "락을 휙득하는데 실패했습니다."), } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 87edbe8..d755799 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,5 +1,9 @@ spring: application.name: generic-commerce + data: + redis: + host: localhost + port: 6379 jpa: open-in-view: false hibernate: From fd50b5ac0377dfea3a10ec7423da21defe2bbbcf Mon Sep 17 00:00:00 2001 From: Younggil An Date: Sun, 3 Aug 2025 01:53:57 +0900 Subject: [PATCH 5/7] =?UTF-8?q?feat:=20Facade=20=EB=8F=84=EC=9E=85=20?= =?UTF-8?q?=EB=B0=8F=20=EB=9D=BD=20=EC=82=AC=EC=9A=A9=20=EB=B0=A9=EB=B2=95?= =?UTF-8?q?=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../nilgil/commerce/common/DistributedLock.kt | 12 ---- .../commerce/common/DistributedLockAop.kt | 57 ------------------- .../nilgil/commerce/common/LockExecutor.kt | 31 ++++++++++ .../nilgil/commerce/order/OrderController.kt | 4 +- .../com/nilgil/commerce/order/OrderDto.kt | 18 +++--- .../com/nilgil/commerce/order/OrderFacade.kt | 43 ++++++++++++++ .../com/nilgil/commerce/order/OrderItem.kt | 11 ++++ .../com/nilgil/commerce/order/OrderLine.kt | 5 ++ .../com/nilgil/commerce/order/OrderService.kt | 42 +++++--------- .../nilgil/commerce/order/OrderValidator.kt | 17 +++--- 10 files changed, 125 insertions(+), 115 deletions(-) delete mode 100644 src/main/kotlin/com/nilgil/commerce/common/DistributedLock.kt delete mode 100644 src/main/kotlin/com/nilgil/commerce/common/DistributedLockAop.kt create mode 100644 src/main/kotlin/com/nilgil/commerce/common/LockExecutor.kt create mode 100644 src/main/kotlin/com/nilgil/commerce/order/OrderFacade.kt diff --git a/src/main/kotlin/com/nilgil/commerce/common/DistributedLock.kt b/src/main/kotlin/com/nilgil/commerce/common/DistributedLock.kt deleted file mode 100644 index 50ae66d..0000000 --- a/src/main/kotlin/com/nilgil/commerce/common/DistributedLock.kt +++ /dev/null @@ -1,12 +0,0 @@ -package com.nilgil.commerce.common - -import java.util.concurrent.TimeUnit - -@Target(AnnotationTarget.FUNCTION) -@Retention(AnnotationRetention.RUNTIME) -annotation class DistributedLock( - val prefix: String = "", - val key: String, - val waitTime: Long = 5, - val timeUnit: TimeUnit = TimeUnit.SECONDS, -) diff --git a/src/main/kotlin/com/nilgil/commerce/common/DistributedLockAop.kt b/src/main/kotlin/com/nilgil/commerce/common/DistributedLockAop.kt deleted file mode 100644 index 64cabf4..0000000 --- a/src/main/kotlin/com/nilgil/commerce/common/DistributedLockAop.kt +++ /dev/null @@ -1,57 +0,0 @@ -package com.nilgil.commerce.common - -import com.nilgil.commerce.common.error.CoreError -import com.nilgil.commerce.common.error.CoreException -import org.aspectj.lang.ProceedingJoinPoint -import org.aspectj.lang.annotation.Around -import org.aspectj.lang.annotation.Aspect -import org.aspectj.lang.reflect.MethodSignature -import org.springframework.expression.spel.standard.SpelExpressionParser -import org.springframework.expression.spel.support.StandardEvaluationContext -import org.springframework.integration.support.locks.LockRegistry -import org.springframework.stereotype.Component - -@Aspect -@Component -class DistributedLockAop( - private val lockRegistry: LockRegistry, -) { - @Around("@annotation(distributedLock)") - fun lock( - joinPoint: ProceedingJoinPoint, - distributedLock: DistributedLock, - ): Any? { - val dynamicKey = resolveSpelKey(joinPoint, distributedLock.key) - val lockKey = "${distributedLock.prefix}:$dynamicKey" - - val lock = lockRegistry.obtain(lockKey) - - if (!lock.tryLock(distributedLock.waitTime, distributedLock.timeUnit)) { - throw CoreException(CoreError.FAIL_TO_GET_LOCK, detail = "key: ${distributedLock.key}") - } - - try { - return joinPoint.proceed() - } finally { - lock.unlock() - } - } - - private fun resolveSpelKey( - joinPoint: ProceedingJoinPoint, - key: String, - ): String { - val methodSignature = joinPoint.signature as MethodSignature - val parameterNames = methodSignature.parameterNames - val args = joinPoint.args - - val context = StandardEvaluationContext() - parameterNames.zip(args).forEach { (name, value) -> - context.setVariable(name, value) - } - - val expressionParser = SpelExpressionParser() - return expressionParser.parseExpression(key).getValue(context, String::class.java) - ?: throw CoreException(CoreError.FAIL_TO_GET_LOCK, detail = "락 키를 파싱하는 데 실패했습니다: $key") - } -} diff --git a/src/main/kotlin/com/nilgil/commerce/common/LockExecutor.kt b/src/main/kotlin/com/nilgil/commerce/common/LockExecutor.kt new file mode 100644 index 0000000..102fd53 --- /dev/null +++ b/src/main/kotlin/com/nilgil/commerce/common/LockExecutor.kt @@ -0,0 +1,31 @@ +package com.nilgil.commerce.common + +import com.nilgil.commerce.common.error.CoreError +import com.nilgil.commerce.common.error.CoreException +import org.springframework.integration.support.locks.LockRegistry +import org.springframework.stereotype.Component +import java.util.concurrent.TimeUnit + +@Component +class LockExecutor( + private val lockRegistry: LockRegistry, +) { + fun execute( + key: String, + waitTime: Long = 5, + timeUnit: TimeUnit = TimeUnit.SECONDS, + callable: () -> T, + ): T { + val lock = lockRegistry.obtain(key) + + if (!lock.tryLock(waitTime, timeUnit)) { + throw CoreException(CoreError.FAIL_TO_GET_LOCK, detail = "key: $key") + } + + try { + return callable() + } finally { + lock.unlock() + } + } +} diff --git a/src/main/kotlin/com/nilgil/commerce/order/OrderController.kt b/src/main/kotlin/com/nilgil/commerce/order/OrderController.kt index 210e163..c6e4a4a 100644 --- a/src/main/kotlin/com/nilgil/commerce/order/OrderController.kt +++ b/src/main/kotlin/com/nilgil/commerce/order/OrderController.kt @@ -8,14 +8,14 @@ import java.net.URI @RestController class OrderController( - private val service: OrderService, + private val orderFacade: OrderFacade, ) { @PostMapping("/orders") fun createOrder( userId: Long, @RequestBody request: CreateOrderRequest, ): ResponseEntity { - val response = service.createOrder(userId, request) + val response = orderFacade.createOrder(userId, request) val location = URI.create("/orders/${response.code}") return ResponseEntity.created(location).body(response) } diff --git a/src/main/kotlin/com/nilgil/commerce/order/OrderDto.kt b/src/main/kotlin/com/nilgil/commerce/order/OrderDto.kt index deaf53e..5886e5b 100644 --- a/src/main/kotlin/com/nilgil/commerce/order/OrderDto.kt +++ b/src/main/kotlin/com/nilgil/commerce/order/OrderDto.kt @@ -1,13 +1,8 @@ package com.nilgil.commerce.order data class CreateOrderRequest( - val lines: List = listOf(), -) { - init { - val distinctItemIds = lines.distinctBy { it.productItemId } - require(lines.size == distinctItemIds.size) { "주문 항목에 중복된 상품이 존재할 수 없습니다." } - } -} + val lines: List, +) data class CreateOrderLineRequest( val productItemId: Long, @@ -17,3 +12,12 @@ data class CreateOrderLineRequest( data class CreateOrderResponse( val code: String, ) + +data class ProductItemInfo( + val title: String, + val options: Map, + val price: Int, + val stock: Int, + val thumbnailImageUrl: String?, + val productItemId: Long, +) diff --git a/src/main/kotlin/com/nilgil/commerce/order/OrderFacade.kt b/src/main/kotlin/com/nilgil/commerce/order/OrderFacade.kt new file mode 100644 index 0000000..b0910e9 --- /dev/null +++ b/src/main/kotlin/com/nilgil/commerce/order/OrderFacade.kt @@ -0,0 +1,43 @@ +package com.nilgil.commerce.order + +import com.nilgil.commerce.common.LockExecutor +import com.nilgil.commerce.product.ProductItemResponse +import com.nilgil.commerce.product.ProductItemService +import org.springframework.stereotype.Service + +@Service +class OrderFacade( + private val orderService: OrderService, + private val productItemService: ProductItemService, + private val lockExecutor: LockExecutor, +) { + fun createOrder( + userId: Long, + request: CreateOrderRequest, + ): CreateOrderResponse { + val productItems = getProductItems(request) + + val lockKey = "order:user:$userId" + return lockExecutor.execute(lockKey) { + orderService.createOrder(userId, request, productItems) + } + } + + private fun getProductItems(request: CreateOrderRequest): List { + val itemIds = request.lines.map { it.productItemId } + + return productItemService.getProductItems(itemIds).map { + it.toInternalItemInfo() + } + } +} + +private fun ProductItemResponse.toInternalItemInfo() = + ProductItemInfo( + title = this.productName, + options = this.options, + price = this.price, + stock = this.stock, + thumbnailImageUrl = this.thumbnailImageUrl, + productItemId = this.id, + ) diff --git a/src/main/kotlin/com/nilgil/commerce/order/OrderItem.kt b/src/main/kotlin/com/nilgil/commerce/order/OrderItem.kt index c8b8673..3418a5a 100644 --- a/src/main/kotlin/com/nilgil/commerce/order/OrderItem.kt +++ b/src/main/kotlin/com/nilgil/commerce/order/OrderItem.kt @@ -18,6 +18,17 @@ data class OrderItem( @Column(name = "product_item_id") val productItemId: Long, ) { + companion object { + fun from(itemInfo: ProductItemInfo): OrderItem = + OrderItem( + title = itemInfo.title, + options = itemInfo.options, + price = itemInfo.price, + thumbnailImageUrl = itemInfo.thumbnailImageUrl, + productItemId = itemInfo.productItemId, + ) + } + init { require(price >= 0) { "가격은 0 이상이어야 합니다." } } diff --git a/src/main/kotlin/com/nilgil/commerce/order/OrderLine.kt b/src/main/kotlin/com/nilgil/commerce/order/OrderLine.kt index 361e716..77bd5be 100644 --- a/src/main/kotlin/com/nilgil/commerce/order/OrderLine.kt +++ b/src/main/kotlin/com/nilgil/commerce/order/OrderLine.kt @@ -28,6 +28,11 @@ class OrderLine( companion object { const val MIN_QUANTITY = 1 const val MAX_QUANTITY = 100 + + fun from( + itemInfo: ProductItemInfo, + quantity: Int, + ): OrderLine = OrderLine(OrderItem.from(itemInfo), quantity) } init { diff --git a/src/main/kotlin/com/nilgil/commerce/order/OrderService.kt b/src/main/kotlin/com/nilgil/commerce/order/OrderService.kt index 5c12da4..7da11aa 100644 --- a/src/main/kotlin/com/nilgil/commerce/order/OrderService.kt +++ b/src/main/kotlin/com/nilgil/commerce/order/OrderService.kt @@ -1,7 +1,5 @@ package com.nilgil.commerce.order -import com.nilgil.commerce.product.ProductItemResponse -import com.nilgil.commerce.product.ProductItemService import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -10,47 +8,33 @@ class OrderService( private val orderRepository: OrderRepository, private val orderCodeGenerator: OrderCodeGenerator, private val orderValidator: OrderValidator, - private val productItemService: ProductItemService, ) { @Transactional fun createOrder( userId: Long, request: CreateOrderRequest, + productItems: List, ): CreateOrderResponse { + orderValidator.validateOrderItems(request, productItems) orderValidator.validateHasActiveOrder(userId) - val orderLines = prepareOrderLines(request) - val code = orderCodeGenerator.generate() - val order = Order(code, orderLines, userId) + val lines = createOrderLines(request, productItems) + val order = Order(code, lines, userId) + orderRepository.save(order) return CreateOrderResponse(order.code) } - private fun prepareOrderLines(request: CreateOrderRequest): List { - val itemIds = request.lines.map { it.productItemId } - val itemsMap = productItemService.getProductItems(itemIds).associateBy { it.id } - - orderValidator.validateItems(request, itemsMap) - - return request.lines.map { lineRequest -> - val productItem = itemsMap[lineRequest.productItemId]!! - toOrderLine(productItem, lineRequest.quantity) + private fun createOrderLines( + request: CreateOrderRequest, + productItems: List, + ): List { + val productItemMap = productItems.associateBy { it.productItemId } + return request.lines.map { + val productItem = productItemMap[it.productItemId]!! + OrderLine.from(productItem, it.quantity) } } - - private fun toOrderLine( - item: ProductItemResponse, - quantity: Int, - ) = OrderLine( - OrderItem( - title = item.productName, - options = item.options, - price = item.price, - thumbnailImageUrl = item.thumbnailImageUrl, - productItemId = item.id, - ), - quantity, - ) } diff --git a/src/main/kotlin/com/nilgil/commerce/order/OrderValidator.kt b/src/main/kotlin/com/nilgil/commerce/order/OrderValidator.kt index 1638bcc..4097d87 100644 --- a/src/main/kotlin/com/nilgil/commerce/order/OrderValidator.kt +++ b/src/main/kotlin/com/nilgil/commerce/order/OrderValidator.kt @@ -1,7 +1,6 @@ package com.nilgil.commerce.order import com.nilgil.commerce.common.error.CoreException -import com.nilgil.commerce.product.ProductItemResponse import org.springframework.stereotype.Component @Component @@ -16,13 +15,15 @@ class OrderValidator( } } - fun validateItems( + fun validateOrderItems( request: CreateOrderRequest, - itemsMap: Map, + productItems: List, ) { + val itemInfoMap = productItems.associateBy { it.productItemId } + val invalidItems = - request.lines.mapNotNull { line -> - checkAndGetInvalid(line, itemsMap) + request.lines.mapNotNull { lineRequest -> + getInvalidOrNull(lineRequest, itemInfoMap) } if (invalidItems.isNotEmpty()) { @@ -30,16 +31,16 @@ class OrderValidator( } } - private fun checkAndGetInvalid( + private fun getInvalidOrNull( line: CreateOrderLineRequest, - itemsMap: Map, + itemsMap: Map, ): InvalidLineInfo? { val item = itemsMap[line.productItemId] return when { item == null -> InvalidLineInfo(line.productItemId, InvalidType.NOT_FOUND) - line.quantity > item.stock -> + item.stock < line.quantity -> InvalidLineInfo(line.productItemId, InvalidType.STOCK_NOT_ENOUGH) else -> null From 8c648025479737cad3948a1507a0f7076bafbece Mon Sep 17 00:00:00 2001 From: Younggil An Date: Sun, 3 Aug 2025 01:57:08 +0900 Subject: [PATCH 6/7] =?UTF-8?q?chore:=20=EC=83=81=EC=88=98=20=EC=A0=91?= =?UTF-8?q?=EA=B7=BC=20=EC=A0=9C=ED=95=9C=EC=9E=90=EB=A5=BC=20private?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/kotlin/com/nilgil/commerce/common/LockConfig.kt | 2 +- .../kotlin/com/nilgil/commerce/common/StringListConverter.kt | 2 +- src/main/kotlin/com/nilgil/commerce/order/OrderLine.kt | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/main/kotlin/com/nilgil/commerce/common/LockConfig.kt b/src/main/kotlin/com/nilgil/commerce/common/LockConfig.kt index 99480b0..2a34761 100644 --- a/src/main/kotlin/com/nilgil/commerce/common/LockConfig.kt +++ b/src/main/kotlin/com/nilgil/commerce/common/LockConfig.kt @@ -9,7 +9,7 @@ import org.springframework.integration.support.locks.LockRegistry @Configuration class LockConfig { companion object { - const val LOCK_REGISTRY_KEY = "lock" + private const val LOCK_REGISTRY_KEY = "lock" } @Bean diff --git a/src/main/kotlin/com/nilgil/commerce/common/StringListConverter.kt b/src/main/kotlin/com/nilgil/commerce/common/StringListConverter.kt index 25cc2d6..0148df0 100644 --- a/src/main/kotlin/com/nilgil/commerce/common/StringListConverter.kt +++ b/src/main/kotlin/com/nilgil/commerce/common/StringListConverter.kt @@ -6,7 +6,7 @@ import jakarta.persistence.Converter @Converter class StringListConverter : AttributeConverter, String> { companion object { - const val DELIMITER = "," + private const val DELIMITER = "," } override fun convertToDatabaseColumn(attribute: List?): String? { diff --git a/src/main/kotlin/com/nilgil/commerce/order/OrderLine.kt b/src/main/kotlin/com/nilgil/commerce/order/OrderLine.kt index 77bd5be..227ad11 100644 --- a/src/main/kotlin/com/nilgil/commerce/order/OrderLine.kt +++ b/src/main/kotlin/com/nilgil/commerce/order/OrderLine.kt @@ -26,8 +26,8 @@ class OrderLine( get() = item.productItemId companion object { - const val MIN_QUANTITY = 1 - const val MAX_QUANTITY = 100 + private const val MIN_QUANTITY = 1 + private const val MAX_QUANTITY = 100 fun from( itemInfo: ProductItemInfo, From a7532d655d911317abdbda5b8777690d9dad1e75 Mon Sep 17 00:00:00 2001 From: Younggil An Date: Sun, 3 Aug 2025 02:09:02 +0900 Subject: [PATCH 7/7] =?UTF-8?q?feat:=20=EC=BD=94=EB=93=9C=20=EA=B0=80?= =?UTF-8?q?=EB=8F=85=EC=84=B1=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/nilgil/commerce/common/LockConfig.kt | 28 +++++++++++++++++-- .../com/nilgil/commerce/order/OrderFacade.kt | 8 +++--- .../com/nilgil/commerce/order/OrderService.kt | 3 +- 3 files changed, 31 insertions(+), 8 deletions(-) diff --git a/src/main/kotlin/com/nilgil/commerce/common/LockConfig.kt b/src/main/kotlin/com/nilgil/commerce/common/LockConfig.kt index 2a34761..b5068ec 100644 --- a/src/main/kotlin/com/nilgil/commerce/common/LockConfig.kt +++ b/src/main/kotlin/com/nilgil/commerce/common/LockConfig.kt @@ -5,15 +5,37 @@ import org.springframework.context.annotation.Configuration import org.springframework.data.redis.connection.RedisConnectionFactory import org.springframework.integration.redis.util.RedisLockRegistry import org.springframework.integration.support.locks.LockRegistry +import org.springframework.scheduling.TaskScheduler +import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler +import kotlin.time.Duration.Companion.seconds @Configuration class LockConfig { companion object { private const val LOCK_REGISTRY_KEY = "lock" + private const val LOCK_LEASE_TIME_SECONDS = 30L + private const val LOCK_WATCHDOG_COUNT = 4 } @Bean - fun redisLockRegistry(redisConnectionFactory: RedisConnectionFactory): LockRegistry = - RedisLockRegistry(redisConnectionFactory, LOCK_REGISTRY_KEY) - .apply { setRedisLockType(RedisLockRegistry.RedisLockType.PUB_SUB_LOCK) } + fun redisLockRegistry( + redisConnectionFactory: RedisConnectionFactory, + lockWatchdogScheduler: TaskScheduler, + ): LockRegistry = + RedisLockRegistry( + redisConnectionFactory, + LOCK_REGISTRY_KEY, + LOCK_LEASE_TIME_SECONDS.seconds.inWholeMilliseconds, + ).apply { + setRedisLockType(RedisLockRegistry.RedisLockType.PUB_SUB_LOCK) + setRenewalTaskScheduler(lockWatchdogScheduler) + } + + @Bean + fun lockWatchdogScheduler(): TaskScheduler = + ThreadPoolTaskScheduler().apply { + poolSize = LOCK_WATCHDOG_COUNT + setThreadNamePrefix("lock-watchdog-") + initialize() + } } diff --git a/src/main/kotlin/com/nilgil/commerce/order/OrderFacade.kt b/src/main/kotlin/com/nilgil/commerce/order/OrderFacade.kt index b0910e9..21a10a1 100644 --- a/src/main/kotlin/com/nilgil/commerce/order/OrderFacade.kt +++ b/src/main/kotlin/com/nilgil/commerce/order/OrderFacade.kt @@ -24,11 +24,11 @@ class OrderFacade( } private fun getProductItems(request: CreateOrderRequest): List { - val itemIds = request.lines.map { it.productItemId } + val itemIds = request.lines.map(CreateOrderLineRequest::productItemId) - return productItemService.getProductItems(itemIds).map { - it.toInternalItemInfo() - } + return productItemService + .getProductItems(itemIds) + .map(ProductItemResponse::toInternalItemInfo) } } diff --git a/src/main/kotlin/com/nilgil/commerce/order/OrderService.kt b/src/main/kotlin/com/nilgil/commerce/order/OrderService.kt index 7da11aa..548afac 100644 --- a/src/main/kotlin/com/nilgil/commerce/order/OrderService.kt +++ b/src/main/kotlin/com/nilgil/commerce/order/OrderService.kt @@ -31,7 +31,8 @@ class OrderService( request: CreateOrderRequest, productItems: List, ): List { - val productItemMap = productItems.associateBy { it.productItemId } + val productItemMap = productItems.associateBy(ProductItemInfo::productItemId) + return request.lines.map { val productItem = productItemMap[it.productItemId]!! OrderLine.from(productItem, it.quantity)