-
Notifications
You must be signed in to change notification settings - Fork 0
[#15] 주문 생성 기능 구현 #23
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: feat/16
Are you sure you want to change the base?
Changes from all commits
4c890f5
ca3c5e9
6dac5e9
d86ae14
fd50b5a
8c64802
a7532d6
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 | ||
| 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) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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() | ||
| } | ||
| } | ||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. obtain 으로 락을 얻어오는 과정에서 ReentrantLock(lock Lock) 을 사용하는 등 무언가를 많이 하는 것 같고, 영길님이 이해하신대로 동작을 자세히 주석으로 남겨놔주시면 좋을 것 같습니다. |
||
| 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 |
|---|---|---|
|
|
@@ -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", "락을 휙득하는데 실패했습니다."), | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. *휙득 -> 획득 |
||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 } | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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( | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| package com.nilgil.commerce.order | ||
|
|
||
| interface OrderCodeGenerator { | ||
| fun generate(): String | ||
| } |
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 오우 이건 HATEOS 를 준수하시려 하신건가요? |
||
| } | ||
| } | ||
| 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, | ||
| ) |
| 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) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 보편적으로 DB 를 다녀오는 연산과 Lock 은 굉장히 비싼 연산입니다. |
||
|
|
||
| val lockKey = "order:user:$userId" | ||
|
Comment on lines
+19
to
+20
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. lockKey 에 request 정보가 없고 오직 유저단위라는 것은 유저 단위로 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, | ||
| ) | ||
| 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() | ||
| } |
| 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 | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
LockRegistry 는 저도 사용해본적이 없어서 배워갑니다 👍👍👍