Skip to content
Draft
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
6 changes: 5 additions & 1 deletion build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -29,15 +29,19 @@ 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")
runtimeOnly("com.mysql:mysql-connector-j")
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 {
Expand Down
9 changes: 9 additions & 0 deletions compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
41 changes: 41 additions & 0 deletions src/main/kotlin/com/nilgil/commerce/common/LockConfig.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
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
Comment on lines +6 to +7
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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,
lockWatchdogScheduler: TaskScheduler,
): LockRegistry =
RedisLockRegistry(
redisConnectionFactory,
LOCK_REGISTRY_KEY,
LOCK_LEASE_TIME_SECONDS.seconds.inWholeMilliseconds,
).apply {
setRedisLockType(RedisLockRegistry.RedisLockType.PUB_SUB_LOCK)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SPIN_LOCK 이 좋은 케이스도 있을까요?

setRenewalTaskScheduler(lockWatchdogScheduler)
}

@Bean
fun lockWatchdogScheduler(): TaskScheduler =
ThreadPoolTaskScheduler().apply {
poolSize = LOCK_WATCHDOG_COUNT
setThreadNamePrefix("lock-watchdog-")
initialize()
}
}
31 changes: 31 additions & 0 deletions src/main/kotlin/com/nilgil/commerce/common/LockExecutor.kt
Original file line number Diff line number Diff line change
@@ -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 <T> execute(
key: String,
waitTime: Long = 5,
timeUnit: TimeUnit = TimeUnit.SECONDS,
callable: () -> T,
): T {
Comment on lines +9 to +18
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

정말 좋네요 👍👍👍

val lock = lockRegistry.obtain(key)

if (!lock.tryLock(waitTime, timeUnit)) {
Comment on lines +19 to +21
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

obtain 으로 락을 얻어오는 과정에서 ReentrantLock(lock Lock) 을 사용하는 등 무언가를 많이 하는 것 같고,
tryLock 도 로컬락, Redis 락 모두 잡고, WatchDog 스케쥴러 등록하는 등 작업이 많이 수행되는 것 같아요.

영길님이 이해하신대로 동작을 자세히 주석으로 남겨놔주시면 좋을 것 같습니다.

throw CoreException(CoreError.FAIL_TO_GET_LOCK, detail = "key: $key")
}

try {
return callable()
} finally {
lock.unlock()
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import jakarta.persistence.Converter
@Converter
class StringListConverter : AttributeConverter<List<String>, String> {
companion object {
const val DELIMITER = ","
private const val DELIMITER = ","
}

override fun convertToDatabaseColumn(attribute: List<String>?): String? {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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", "락을 휙득하는데 실패했습니다."),
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

*휙득 -> 획득

}
24 changes: 23 additions & 1 deletion src/main/kotlin/com/nilgil/commerce/order/Order.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,25 +2,47 @@ 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
@Table(name = "orders")
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<OrderLine> = listOf(),
val userId: Long,
) : BaseEntity() {
@Enumerated(EnumType.STRING)
var status: OrderStatus = OrderStatus.CREATED

var paymentId: Long? = null

val totalAmount: Int = lines.sumOf { it.totalPrice }
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍


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(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package com.nilgil.commerce.order

interface OrderCodeGenerator {
fun generate(): String
}
22 changes: 22 additions & 0 deletions src/main/kotlin/com/nilgil/commerce/order/OrderController.kt
Original file line number Diff line number Diff line change
@@ -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 orderFacade: OrderFacade,
) {
@PostMapping("/orders")
fun createOrder(
userId: Long,
@RequestBody request: CreateOrderRequest,
): ResponseEntity<CreateOrderResponse> {
val response = orderFacade.createOrder(userId, request)
val location = URI.create("/orders/${response.code}")
return ResponseEntity.created(location).body(response)
Comment on lines +19 to +20
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

오우 이건 HATEOS 를 준수하시려 하신건가요?

}
}
23 changes: 23 additions & 0 deletions src/main/kotlin/com/nilgil/commerce/order/OrderDto.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package com.nilgil.commerce.order

data class CreateOrderRequest(
val lines: List<CreateOrderLineRequest>,
)

data class CreateOrderLineRequest(
val productItemId: Long,
val quantity: Int,
)

data class CreateOrderResponse(
val code: String,
)

data class ProductItemInfo(
val title: String,
val options: Map<String, String>,
val price: Int,
val stock: Int,
val thumbnailImageUrl: String?,
val productItemId: Long,
)
12 changes: 12 additions & 0 deletions src/main/kotlin/com/nilgil/commerce/order/OrderError.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
43 changes: 43 additions & 0 deletions src/main/kotlin/com/nilgil/commerce/order/OrderFacade.kt
Original file line number Diff line number Diff line change
@@ -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)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

보편적으로 DB 를 다녀오는 연산과 Lock 은 굉장히 비싼 연산입니다.
request 의 lines 가 empty 일 때 조금 더 빠르게 실패시키면 많은 자원을 절약할 수 있을 것 같아요 😄


val lockKey = "order:user:$userId"
Comment on lines +19 to +20
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lockKey 에 request 정보가 없고 오직 유저단위라는 것은
같은 유저가 아주 짧은 시간 간격으로 서로 다른 2개의 주문을 요청했을 때에도 lock 으로 제어되어 처리될 것 같은데요.

유저 단위로 Lock 을 잡는게 필요하다고 생각하신 의도가 궁금합니다.
가능하면 주석으로 적어둬도 좋을 것 같아요.

return lockExecutor.execute(lockKey) {
orderService.createOrder(userId, request, productItems)
}
}

private fun getProductItems(request: CreateOrderRequest): List<ProductItemInfo> {
val itemIds = request.lines.map(CreateOrderLineRequest::productItemId)

return productItemService
.getProductItems(itemIds)
.map(ProductItemResponse::toInternalItemInfo)
}
}

private fun ProductItemResponse.toInternalItemInfo() =
ProductItemInfo(
title = this.productName,
options = this.options,
price = this.price,
stock = this.stock,
thumbnailImageUrl = this.thumbnailImageUrl,
productItemId = this.id,
)
16 changes: 13 additions & 3 deletions src/main/kotlin/com/nilgil/commerce/order/OrderItem.kt
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -9,16 +8,27 @@ 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<String> = listOf(),
val options: Map<String, String> = emptyMap(),
@Column(name = "item_price")
val price: Int,
@Column(name = "item_thumbnail_image_url")
val thumbnailImageUrl: String? = null,
@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 이상이어야 합니다." }
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Map<String, String>, String> {
private val objectMapper = ObjectMapper()

override fun convertToDatabaseColumn(attribute: Map<String, String>?): String? =
attribute?.let {
// Map 요소 순서 변경에 의한 Update 방지를 위해 sortedMap 사용
objectMapper.writeValueAsString(attribute.toSortedMap())
}

override fun convertToEntityAttribute(dbData: String?): Map<String, String>? =
dbData?.let {
objectMapper.readValue(it, object : TypeReference<Map<String, String>>() {})
} ?: emptyMap()
}
31 changes: 27 additions & 4 deletions src/main/kotlin/com/nilgil/commerce/order/OrderLine.kt
Original file line number Diff line number Diff line change
@@ -1,22 +1,45 @@
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
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 {
private const val MIN_QUANTITY = 1
private const val MAX_QUANTITY = 100

fun from(
itemInfo: ProductItemInfo,
quantity: Int,
): OrderLine = OrderLine(OrderItem.from(itemInfo), quantity)
}

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
}
}
Loading
Loading