Skip to content
28 changes: 24 additions & 4 deletions src/main/kotlin/com/dh/baro/cart/application/CartFacade.kt
Original file line number Diff line number Diff line change
@@ -1,22 +1,42 @@
package com.dh.baro.cart.application

import com.dh.baro.cart.domain.CartItem
import com.dh.baro.cart.domain.CartService
import com.dh.baro.cart.presentation.dto.AddItemRequest
import com.dh.baro.core.ErrorMessage
import com.dh.baro.identity.domain.service.UserService
import com.dh.baro.product.domain.service.ProductQueryService
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional

@Service
class CartFacade(
private val cartService: CartService,
private val userService: UserService,
private val productQueryService: ProductQueryService,
) {

fun getCartItems(userId: Long): List<CartItem> =
cartService.getItems(userId)
@Transactional(readOnly = true)
fun getCartItems(userId: Long): List<CartItemBundle> {
userService.checkUserExists(userId)
val cartItems = cartService.getItems(userId)
val productIds = cartItems.map { it.productId }
val products = productQueryService.getAllByIds(productIds).associateBy { it.id }

return cartItems.map { cartItem ->
val product = products[cartItem.productId]
?: throw IllegalStateException(ErrorMessage.PRODUCT_NOT_FOUND.format(cartItem.productId))

CartItemBundle(cartItem, product)
}
}

@Transactional
fun addItem(userId: Long, request: AddItemRequest) {
userService.checkUserExists(userId)
productQueryService.checkProductsExists(listOf(request.productId))
cartService.addItem(
userId = userId,
productId = request.productId.toLong(),
productId = request.productId,
quantity = request.quantity,
)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.dh.baro.cart.application

import com.dh.baro.cart.domain.CartItem
import com.dh.baro.product.domain.Product

data class CartItemBundle(
val cartItem: CartItem,
val product: Product,
)
18 changes: 7 additions & 11 deletions src/main/kotlin/com/dh/baro/cart/domain/CartItem.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@ package com.dh.baro.cart.domain

import com.dh.baro.core.BaseTimeEntity
import com.dh.baro.core.IdGenerator
import com.dh.baro.identity.domain.User
import com.dh.baro.product.domain.Product
import jakarta.persistence.*

@Entity
Expand All @@ -16,13 +14,11 @@ class CartItem(
@Column(name = "id")
val id: Long,

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
val user: User,
@Column(name = "user_id", nullable = false)
val userId: Long,

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "product_id", nullable = false)
val product: Product,
@Column(name = "product_id", nullable = false)
val productId: Long,

@Column(name = "quantity", nullable = false)
var quantity: Int = 1
Expand All @@ -39,11 +35,11 @@ class CartItem(
}

companion object {
fun newCartItem(user: User, product: Product, quantity: Int): CartItem {
fun newCartItem(userId: Long, productId: Long, quantity: Int): CartItem {
return CartItem(
id = IdGenerator.generate(),
user = user,
product = product,
userId = userId,
productId = productId,
quantity = quantity,
)
}
Expand Down
2 changes: 0 additions & 2 deletions src/main/kotlin/com/dh/baro/cart/domain/CartItemRepository.kt
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
package com.dh.baro.cart.domain

import org.springframework.data.jpa.repository.EntityGraph
import org.springframework.data.jpa.repository.JpaRepository

interface CartItemRepository : JpaRepository<CartItem, Long> {

@EntityGraph(attributePaths = ["product"])
fun findByUserId(userId: Long): List<CartItem>

fun findByUserIdAndProductId(userId: Long, productId: Long): CartItem?
Expand Down
24 changes: 10 additions & 14 deletions src/main/kotlin/com/dh/baro/cart/domain/CartService.kt
Original file line number Diff line number Diff line change
@@ -1,17 +1,13 @@
package com.dh.baro.cart.domain

import com.dh.baro.core.ErrorMessage
import com.dh.baro.identity.domain.repository.UserRepository
import com.dh.baro.product.domain.repository.ProductRepository
import org.springframework.data.repository.findByIdOrNull
import org.springframework.dao.DataIntegrityViolationException
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional

@Service
class CartService(
private val cartItemRepository: CartItemRepository,
private val userRepository: UserRepository,
private val productRepository: ProductRepository,
private val cartPolicy: CartPolicy,
) {

Expand All @@ -23,20 +19,20 @@ class CartService(
fun addItem(userId: Long, productId: Long, quantity: Int): CartItem {
cartItemRepository.findByUserIdAndProductId(userId, productId)
?.also { it.addQuantity(quantity); return it }

validateCartLimit(userId)

val user = userRepository.findByIdOrNull(userId)
?: throw IllegalArgumentException(ErrorMessage.USER_NOT_FOUND.format(userId))
val product = productRepository.findByIdOrNull(productId)
?: throw IllegalArgumentException(ErrorMessage.PRODUCT_NOT_FOUND.format(productId))

return cartItemRepository.save(CartItem.newCartItem(user, product, quantity))
return try {
cartItemRepository.save(CartItem.newCartItem(userId, productId, quantity))
} catch (e: DataIntegrityViolationException) {
val existingItem = cartItemRepository.findByUserIdAndProductId(userId, productId)?: throw e
existingItem.addQuantity(quantity)
existingItem
}
}

private fun validateCartLimit(userId: Long) {
val currentCnt = cartItemRepository.countByUserId(userId)
if (!cartPolicy.canAddMoreItems(currentCnt)) {
val currentItemCnt = cartItemRepository.countByUserId(userId)
if (!cartPolicy.canAddMoreItems(currentItemCnt)) {
throw IllegalStateException(ErrorMessage.CART_ITEM_LIMIT_EXCEEDED.message)
}
}
Comment on lines +34 to 38
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

장바구니 최대 개수 정책, 동시성에 취약.

count→insert 사이 창(window)로 초과 삽입이 가능합니다. 사용자별 아이템 행을 비관적 락으로 선점하거나(예: findByUserId with PESSIMISTIC_WRITE) DB 측에서 보장(별도 집계/트리거, 제한 컬럼)하는 방식을 검토하세요.

원치 않으면 최소한 limit 체크를 "신규 생성 시도 직후" 재검증하는 방어 로직을 추가하세요.

🤖 Prompt for AI Agents
In src/main/kotlin/com/dh/baro/cart/domain/CartService.kt around lines 27-31,
the current count→insert flow is racy and allows exceeding the cart item limit
under concurrency; update the logic to either (a) obtain a pessimistic write
lock on the user's cart row(s) (e.g., use repository.findByUserId(...,
PESSIMISTIC_WRITE)) and re-check the count under that lock before inserting, or
(b) enforce the limit in the DB (unique/aggregate constraint, trigger or a
dedicated counter column updated transactionally) so the DB rejects excess
inserts; if you choose not to change locking/DB, add a defensive re-validation
immediately after attempting to persist the new item and rollback/throw if the
limit is exceeded so no over-limit state is committed.

Expand Down
14 changes: 8 additions & 6 deletions src/main/kotlin/com/dh/baro/cart/presentation/CartController.kt
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,10 @@ class CartController(

@GetMapping
@ResponseStatus(HttpStatus.OK)
override fun getCart(@CurrentUser userId: Long): CartResponse =
CartResponse.from(cartFacade.getCartItems(userId))
override fun getCart(@CurrentUser userId: Long): CartResponse {
val cartItemBundles = cartFacade.getCartItems(userId)
return CartResponse.from(cartItemBundles)
}

@PostMapping("/items")
@ResponseStatus(HttpStatus.CREATED)
Expand All @@ -35,14 +37,14 @@ class CartController(
@ResponseStatus(HttpStatus.NO_CONTENT)
override fun updateQuantity(
@CurrentUser userId: Long,
@PathVariable itemId: String,
@PathVariable itemId: Long,
@Valid @RequestBody request: UpdateQuantityRequest,
) = cartFacade.updateQuantity(userId, itemId.toLong(), request.quantity)
) = cartFacade.updateQuantity(userId, itemId, request.quantity)

@DeleteMapping("/items/{itemId}")
@ResponseStatus(HttpStatus.NO_CONTENT)
override fun removeItem(
@CurrentUser userId: Long,
@PathVariable itemId: String,
) = cartFacade.removeItem(userId, itemId.toLong())
@PathVariable itemId: Long,
) = cartFacade.removeItem(userId, itemId)
}
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
package com.dh.baro.cart.presentation.dto

import jakarta.validation.constraints.Min
import jakarta.validation.constraints.NotBlank
import jakarta.validation.constraints.NotNull

data class AddItemRequest(
@field:NotBlank(message = "상품 ID를 입력해주세요.")
val productId: String,
@field:NotNull(message = "상품 ID를 입력해주세요.")
val productId: Long,
@field:Min(value = 1, message = "수량은 1개 이상이어야 합니다.")
val quantity: Int,
)
Original file line number Diff line number Diff line change
@@ -1,13 +1,41 @@
package com.dh.baro.cart.presentation.dto

import com.dh.baro.cart.application.CartItemBundle
import com.dh.baro.core.LongToStringSerializer
import com.fasterxml.jackson.databind.annotation.JsonSerialize
import java.math.BigDecimal
import java.math.RoundingMode

data class CartItemResponse(
val itemId: String,
val productId: String,
@JsonSerialize(using = LongToStringSerializer::class)
val itemId: Long,
@JsonSerialize(using = LongToStringSerializer::class)
val productId: Long,
val productName: String,
val productThumbnailUrl: String?,
val price: BigDecimal,
val quantity: Int,
val subtotal: BigDecimal,
)
) {

companion object {
private const val SCALE_NONE = 0

fun from(bundle: CartItemBundle): CartItemResponse {
val cartItem = bundle.cartItem
val product = bundle.product

return CartItemResponse(
itemId = cartItem.id,
productId = product.id,
productName = product.getName(),
productThumbnailUrl = product.getThumbnailUrl(),
price = product.getPrice(),
quantity = cartItem.quantity,
subtotal = product.getPrice()
.multiply(BigDecimal(cartItem.quantity))
.setScale(SCALE_NONE, RoundingMode.HALF_UP)
)
}
}
}
29 changes: 11 additions & 18 deletions src/main/kotlin/com/dh/baro/cart/presentation/dto/CartResponse.kt
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
package com.dh.baro.cart.presentation.dto

import com.dh.baro.cart.domain.CartItem
import com.dh.baro.cart.application.CartItemBundle
import java.math.BigDecimal
import java.math.RoundingMode

Expand All @@ -11,23 +11,16 @@ data class CartResponse(
companion object {
private const val SCALE_NONE = 0

fun from(cartItems: List<CartItem>): CartResponse {
val responses = cartItems.map { it.toResponse() }
val total = responses.fold(BigDecimal.ZERO) { sum, item -> sum + item.subtotal }
.setScale(SCALE_NONE, RoundingMode.HALF_UP)
return CartResponse(responses, total)
}
fun from(bundles: List<CartItemBundle>): CartResponse {
val items = bundles.map { bundle ->
CartItemResponse.from(bundle)
}

val totalPrice = items.fold(BigDecimal.ZERO) { sum, item ->
sum + item.subtotal
}.setScale(SCALE_NONE, RoundingMode.HALF_UP)

private fun CartItem.toResponse() = CartItemResponse(
itemId = id.toString(),
productId = product.id.toString(),
productName = product.getName(),
productThumbnailUrl = product.getThumbnailUrl(),
price = product.getPrice(),
quantity = quantity,
subtotal = product.getPrice()
.multiply(BigDecimal(quantity))
.setScale(SCALE_NONE, RoundingMode.HALF_UP)
)
return CartResponse(items, totalPrice)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ interface CartSwagger {
@PatchMapping("/items/{itemId}")
fun updateQuantity(
@Parameter(hidden = true) userId: Long,
@PathVariable itemId: String,
@PathVariable itemId: Long,
@RequestBody request: UpdateQuantityRequest
)

Expand All @@ -145,6 +145,6 @@ interface CartSwagger {
@DeleteMapping("/items/{itemId}")
fun removeItem(
@Parameter(hidden = true) userId: Long,
@PathVariable itemId: String
@PathVariable itemId: Long
)
}
5 changes: 4 additions & 1 deletion src/main/kotlin/com/dh/baro/core/Cursor.kt
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
package com.dh.baro.core

import com.fasterxml.jackson.databind.annotation.JsonSerialize

class Cursor (
val id: String,
@JsonSerialize(using = LongToStringSerializer::class)
val id: Long,
)
15 changes: 15 additions & 0 deletions src/main/kotlin/com/dh/baro/core/LongToStringSerializer.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.dh.baro.core

import com.fasterxml.jackson.core.JsonGenerator
import com.fasterxml.jackson.databind.JsonSerializer
import com.fasterxml.jackson.databind.SerializerProvider

class LongToStringSerializer : JsonSerializer<Long>() {
override fun serialize(value: Long?, gen: JsonGenerator, serializers: SerializerProvider) {
if (value != null) {
gen.writeString(value.toString())
} else {
gen.writeNull()
}
}
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
package com.dh.baro.identity.presentation.dto

import com.dh.baro.core.LongToStringSerializer
import com.dh.baro.identity.domain.User
import com.dh.baro.identity.domain.UserRole
import com.fasterxml.jackson.databind.annotation.JsonSerialize

data class UserProfileResponse(
val id: String,
@JsonSerialize(using = LongToStringSerializer::class)
val id: Long,
val name: String,
val email: String,
val phoneNumber: String?,
Expand All @@ -14,7 +17,7 @@ data class UserProfileResponse(

companion object {
fun from(user: User) = UserProfileResponse(
id = user.id.toString(),
id = user.id,
name = user.getName(),
email = user.getEmail(),
phoneNumber = user.getPhoneNumber(),
Expand Down
Loading