Skip to content

Commit 9b09a7d

Browse files
authored
Merge pull request #97 from rnqhstmd/round4
[volume-4] 트랜잭션 및 동시성 구현
2 parents be7a2e8 + 3f3ecab commit 9b09a7d

35 files changed

Lines changed: 1558 additions & 52 deletions

apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
package com.loopers.application.order;
22

3+
import com.loopers.domain.coupon.Coupon;
4+
import com.loopers.domain.coupon.CouponService;
35
import com.loopers.domain.order.Order;
46
import com.loopers.domain.order.OrderService;
57
import com.loopers.domain.point.PointService;
@@ -10,6 +12,7 @@
1012
import com.loopers.support.error.CoreException;
1113
import com.loopers.support.error.ErrorType;
1214
import lombok.RequiredArgsConstructor;
15+
import org.springframework.orm.ObjectOptimisticLockingFailureException;
1316
import org.springframework.stereotype.Component;
1417
import org.springframework.transaction.annotation.Transactional;
1518

@@ -27,16 +30,18 @@ public class OrderFacade {
2730
private final UserService userService;
2831
private final ProductService productService;
2932
private final PointService pointService;
33+
private final CouponService couponService;
3034

3135
@Transactional
3236
public OrderInfo placeOrder(OrderPlaceCommand command) {
3337
User user = userService.getUserByUserId(command.userId());
3438

3539
List<Long> productIds = command.items().stream()
3640
.map(OrderPlaceCommand.OrderItemCommand::productId)
41+
.sorted()
3742
.toList();
3843

39-
List<Product> products = productService.getProductsByIds(productIds);
44+
List<Product> products = productService.getProductsByIdsWithPessimisticLock(productIds);
4045
Map<Long, Product> productMap = products.stream()
4146
.collect(Collectors.toMap(Product::getId, Function.identity()));
4247

@@ -50,19 +55,27 @@ public OrderInfo placeOrder(OrderPlaceCommand command) {
5055
}
5156

5257
Long totalAmount = order.getTotalAmountValue();
53-
pointService.usePoint(user.getUserIdValue(), totalAmount);
58+
Long discountAmount = 0L;
59+
Long couponId = command.couponId();
5460

55-
order.completePayment();
61+
if (couponId != null) {
62+
Coupon coupon = couponService.getCouponWithOptimisticLock(couponId);
63+
couponService.validateCouponUsable(coupon, user);
64+
65+
discountAmount = coupon.calculateDiscount(totalAmount);
66+
coupon.use();
67+
couponService.save(coupon);
68+
}
69+
long finalAmount = totalAmount - discountAmount;
70+
pointService.usePointWithLock(user.getUserIdValue(), finalAmount);
5671

72+
order.completePayment();
5773
Order savedOrder = orderService.save(order);
5874

5975
return OrderInfo.from(savedOrder);
6076
}
6177

62-
private void validateAndDecreaseStock(
63-
List<OrderPlaceCommand.OrderItemCommand> items,
64-
Map<Long, Product> productMap
65-
) {
78+
private void validateAndDecreaseStock(List<OrderPlaceCommand.OrderItemCommand> items, Map<Long, Product> productMap) {
6679
for (OrderPlaceCommand.OrderItemCommand item : items) {
6780
Product product = productMap.get(item.productId());
6881

apps/commerce-api/src/main/java/com/loopers/application/order/OrderPlaceCommand.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@
44

55
public record OrderPlaceCommand(
66
String userId,
7-
List<OrderItemCommand> items
7+
List<OrderItemCommand> items,
8+
Long couponId
89
) {
910
public record OrderItemCommand(
1011
Long productId,
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
package com.loopers.domain.coupon;
2+
3+
import com.loopers.domain.BaseEntity;
4+
import com.loopers.domain.user.User;
5+
import com.loopers.support.error.CoreException;
6+
import com.loopers.support.error.ErrorType;
7+
import jakarta.persistence.*;
8+
import lombok.AccessLevel;
9+
import lombok.Getter;
10+
import lombok.NoArgsConstructor;
11+
12+
@Entity
13+
@Getter
14+
@Table(name = "coupons")
15+
@NoArgsConstructor(access = AccessLevel.PROTECTED)
16+
public class Coupon extends BaseEntity {
17+
18+
@ManyToOne(fetch = FetchType.LAZY)
19+
@JoinColumn(name = "user_id", nullable = false)
20+
private User user;
21+
22+
@Column(name = "name", nullable = false)
23+
private String name;
24+
25+
@Enumerated(EnumType.STRING)
26+
@Column(name = "discount_type", nullable = false)
27+
private DiscountType discountType;
28+
29+
@Column(name = "discount_value", nullable = false)
30+
private Long discountValue;
31+
32+
@Column(name = "is_used", nullable = false)
33+
private Boolean isUsed = false;
34+
35+
@Version
36+
private Long version;
37+
38+
private Coupon(User user, String name, DiscountType discountType, Long discountValue) {
39+
validateFields(user, name, discountType, discountValue);
40+
this.user = user;
41+
this.name = name;
42+
this.discountType = discountType;
43+
this.discountValue = discountValue;
44+
this.isUsed = false;
45+
}
46+
47+
public static Coupon create(User user, String name, DiscountType discountType, Long discountValue) {
48+
return new Coupon(user, name, discountType, discountValue);
49+
}
50+
51+
public Long calculateDiscount(Long originalAmount) {
52+
if (Boolean.TRUE.equals(this.isUsed)) {
53+
throw new CoreException(ErrorType.BAD_REQUEST, "이미 사용된 쿠폰입니다.");
54+
}
55+
56+
return switch (discountType) {
57+
case FIXED_AMOUNT -> Math.min(discountValue, originalAmount);
58+
case PERCENTAGE -> (originalAmount * discountValue) / 100;
59+
};
60+
}
61+
62+
public void use() {
63+
if (Boolean.TRUE.equals(this.isUsed)) {
64+
throw new CoreException(ErrorType.BAD_REQUEST, "이미 사용된 쿠폰입니다.");
65+
}
66+
this.isUsed = true;
67+
}
68+
69+
public boolean canUse() {
70+
return !this.isUsed;
71+
}
72+
73+
private void validateFields(User user, String name, DiscountType discountType, Long discountValue) {
74+
if (user == null) {
75+
throw new CoreException(ErrorType.BAD_REQUEST, "사용자는 필수입니다.");
76+
}
77+
if (name == null || name.isBlank()) {
78+
throw new CoreException(ErrorType.BAD_REQUEST, "쿠폰명은 필수입니다.");
79+
}
80+
if (discountType == null) {
81+
throw new CoreException(ErrorType.BAD_REQUEST, "할인 타입은 필수입니다.");
82+
}
83+
if (discountValue == null || discountValue <= 0) {
84+
throw new CoreException(ErrorType.BAD_REQUEST, "할인 값은 0보다 커야 합니다.");
85+
}
86+
if (discountType == DiscountType.PERCENTAGE && discountValue > 100) {
87+
throw new CoreException(ErrorType.BAD_REQUEST, "정률 할인은 100% 이하여야 합니다.");
88+
}
89+
}
90+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package com.loopers.domain.coupon;
2+
3+
import com.loopers.domain.user.User;
4+
5+
import java.util.Optional;
6+
7+
public interface CouponRepository {
8+
Coupon save(Coupon coupon);
9+
Optional<Coupon> findById(Long id);
10+
Optional<Coupon> findByIdWithOptimisticLock(Long id);
11+
Optional<Coupon> findByIdWithPessimisticLock(Long id);
12+
Optional<Coupon> findByIdAndUser(Long id, User user);
13+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
package com.loopers.domain.coupon;
2+
3+
import com.loopers.domain.user.User;
4+
import com.loopers.support.error.CoreException;
5+
import com.loopers.support.error.ErrorType;
6+
import lombok.RequiredArgsConstructor;
7+
import org.springframework.stereotype.Service;
8+
import org.springframework.transaction.annotation.Transactional;
9+
10+
@Service
11+
@RequiredArgsConstructor
12+
@Transactional(readOnly = true)
13+
public class CouponService {
14+
15+
private final CouponRepository couponRepository;
16+
17+
@Transactional
18+
public Coupon getCouponWithOptimisticLock(Long id) {
19+
return couponRepository.findByIdWithOptimisticLock(id)
20+
.orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "쿠폰을 찾을 수 없습니다."));
21+
}
22+
23+
@Transactional
24+
public Coupon getCouponWithPessimisticLock(Long id) {
25+
return couponRepository.findByIdWithPessimisticLock(id)
26+
.orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "쿠폰을 찾을 수 없습니다."));
27+
}
28+
29+
public void validateCouponUsable(Coupon coupon, User user) {
30+
if (!coupon.getUser().equals(user)) {
31+
throw new CoreException(ErrorType.BAD_REQUEST, "본인의 쿠폰만 사용할 수 있습니다.");
32+
}
33+
if (!coupon.canUse()) {
34+
throw new CoreException(ErrorType.BAD_REQUEST, "사용할 수 없는 쿠폰입니다.");
35+
}
36+
}
37+
38+
@Transactional
39+
public Coupon save(Coupon coupon) {
40+
return couponRepository.save(coupon);
41+
}
42+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
package com.loopers.domain.coupon;
2+
3+
public enum DiscountType {
4+
FIXED_AMOUNT, // 정액
5+
PERCENTAGE // 정률
6+
}

apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,12 @@
1010

1111
@Entity
1212
@Getter
13-
@Table(name = "likes")
13+
@Table(name = "likes", uniqueConstraints = {
14+
@UniqueConstraint(
15+
name = "uk_user_product",
16+
columnNames = {"user_id", "product_id"}
17+
)
18+
})
1419
@NoArgsConstructor(access = AccessLevel.PROTECTED)
1520
public class Like extends BaseEntity {
1621

@@ -26,6 +31,7 @@ private Like(User user, Product product) {
2631
this.user = user;
2732
this.product = product;
2833
}
34+
2935
public static Like create(User user, Product product) {
3036
return new Like(user, product);
3137
}

apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import com.loopers.domain.product.Product;
44
import com.loopers.domain.user.User;
55
import lombok.RequiredArgsConstructor;
6+
import org.springframework.dao.DataIntegrityViolationException;
67
import org.springframework.stereotype.Service;
78
import org.springframework.transaction.annotation.Transactional;
89

@@ -18,11 +19,12 @@ public class LikeService {
1819

1920
@Transactional
2021
public void addLike(User user, Product product) {
21-
if (likeRepository.existsByUserAndProduct(user, product)) {
22-
return;
23-
}
2422
Like like = Like.create(user, product);
25-
likeRepository.save(like);
23+
// 중복 좋아요 방지를 위해 예외 무시
24+
try {
25+
likeRepository.save(like);
26+
} catch (DataIntegrityViolationException ignored) {
27+
}
2628
}
2729

2830
@Transactional
Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,11 @@
11
package com.loopers.domain.order;
22

3-
import com.loopers.domain.user.User;
43

54
import java.util.List;
65
import java.util.Optional;
76

87
public interface OrderRepository {
98
Order save(Order order);
10-
Optional<Order> findByIdAndUser(Long orderId, User user);
11-
List<Order> findAllByUser(User user);
9+
Optional<Order> findByIdAndUser(Long orderId, Long userId);
10+
List<Order> findAllByUser(Long userId);
1211
}

apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,11 @@ public Order save(Order order) {
2121
}
2222

2323
public List<Order> getOrdersByUser(User user) {
24-
return orderRepository.findAllByUser(user);
24+
return orderRepository.findAllByUser(user.getId());
2525
}
2626

2727
public Order getOrderByIdAndUser(Long orderId, User user) {
28-
return orderRepository.findByIdAndUser(orderId, user)
28+
return orderRepository.findByIdAndUser(orderId, user.getId())
2929
.orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "주문을 찾을 수 없습니다."));
3030
}
3131
}

0 commit comments

Comments
 (0)