Skip to content

Commit 3fa4863

Browse files
authored
Merge pull request #107 from yeonjiyeon/feature/week4
round4 - 트랜잭션 및 동시성 구현
2 parents 3eb431e + 55a31df commit 3fa4863

35 files changed

Lines changed: 1134 additions & 121 deletions

apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java

Lines changed: 61 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,27 +2,80 @@
22

33
import com.loopers.domain.like.Like;
44
import com.loopers.domain.like.LikeService;
5+
import com.loopers.domain.product.Product;
56
import com.loopers.domain.product.ProductService;
6-
import com.loopers.domain.user.UserService;
7+
import java.util.Optional;
78
import lombok.RequiredArgsConstructor;
9+
import org.springframework.orm.ObjectOptimisticLockingFailureException;
810
import org.springframework.stereotype.Component;
11+
import org.springframework.transaction.support.TransactionTemplate;
912

1013
@RequiredArgsConstructor
1114
@Component
1215
public class LikeFacade {
1316

14-
private final UserService userService;
1517
private final ProductService productService;
1618
private final LikeService likeService;
19+
private final TransactionTemplate transactionTemplate;
20+
21+
private static final int RETRY_COUNT = 30;
1722

1823
public LikeInfo like(long userId, long productId) {
19-
Like like = likeService.like(userId, productId);
20-
long totalLikes = likeService.countLikesByProductId(productId);
21-
return LikeInfo.from(like, totalLikes);
24+
Optional<Like> existingLike = likeService.findLike(userId, productId);
25+
26+
27+
if (existingLike.isPresent()) {
28+
Product product = productService.getProduct(productId);
29+
return LikeInfo.from(existingLike.get(), product.getLikeCount());
30+
}
31+
32+
for (int i = 0; i < RETRY_COUNT; i++) {
33+
try {
34+
35+
Like newLike = likeService.save(userId, productId);
36+
int updatedLikeCount = productService.increaseLikeCount(productId);
37+
38+
return LikeInfo.from(newLike, updatedLikeCount);
39+
} catch (ObjectOptimisticLockingFailureException e) {
40+
if (i == RETRY_COUNT - 1) {
41+
throw e;
42+
}
43+
sleep(50);
44+
}
45+
}
46+
47+
throw new IllegalStateException("좋아요 처리 재시도 횟수를 초과했습니다.");
48+
}
49+
50+
public int unLike(long userId, long productId) {
51+
for (int i = 0; i < RETRY_COUNT; i++) {
52+
try {
53+
54+
return transactionTemplate.execute(status -> {
55+
56+
likeService.unLike(userId, productId);
57+
58+
return productService.decreaseLikeCount(productId);
59+
60+
});
61+
62+
} catch (ObjectOptimisticLockingFailureException e) {
63+
64+
if (i == RETRY_COUNT - 1) {
65+
throw e;
66+
}
67+
sleep(50);
68+
}
69+
}
70+
throw new IllegalStateException("싫어요 처리 재시도 횟수를 초과했습니다.");
2271
}
2372

24-
public long unLike(long userId, long productId) {
25-
likeService.unLike(userId, productId);
26-
return likeService.countLikesByProductId(productId);
73+
private void sleep(long millis) {
74+
try {
75+
Thread.sleep(millis);
76+
} catch (InterruptedException e) {
77+
Thread.currentThread().interrupt();
78+
throw new RuntimeException(e);
79+
}
2780
}
2881
}

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

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

33
import com.loopers.domain.order.Order;
4+
import com.loopers.domain.order.OrderCommand.Item;
5+
import com.loopers.domain.order.OrderCommand.PlaceOrder;
46
import com.loopers.domain.order.OrderItem;
57
import com.loopers.domain.order.OrderService;
68
import com.loopers.domain.point.PointService;
79
import com.loopers.domain.product.Product;
810
import com.loopers.domain.product.ProductService;
911
import com.loopers.domain.user.User;
1012
import com.loopers.domain.user.UserService;
11-
import com.loopers.interfaces.order.OrderV1Dto.OrderItemRequest;
12-
import com.loopers.interfaces.order.OrderV1Dto.OrderRequest;
1313
import com.loopers.support.error.CoreException;
1414
import com.loopers.support.error.ErrorType;
1515
import java.util.List;
@@ -21,40 +21,37 @@
2121

2222
@RequiredArgsConstructor
2323
@Component
24+
@Transactional
2425
public class OrderFacade {
26+
2527
private final ProductService productService;
2628
private final UserService userService;
2729
private final OrderService orderService;
2830
private final PointService pointService;
2931

3032
@Transactional
31-
public OrderInfo placeOrder(String userId, OrderRequest request) {
32-
33-
if (userId == null) {
34-
throw new CoreException(ErrorType.BAD_REQUEST, "사용자 ID는 필수입니다.");
35-
}
33+
public OrderInfo placeOrder(PlaceOrder command) {
3634

37-
User user = userService.getUser(userId);
35+
User user = userService.findByIdWithLock(command.userId())
36+
.orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "유저를 찾을 수 없습니다."));
3837

39-
List<Long> productIds = request.items().stream()
40-
.map(OrderItemRequest::productId)
38+
List<Long> productIds = command.items().stream()
39+
.map(Item::productId)
4140
.toList();
4241

4342
List<Product> products = productService.getProducts(productIds);
44-
long totalAmount = orderService.calculateTotal(products, request.items());
45-
4643

47-
productService.deductStock(products, request.items());
48-
49-
pointService.deductPoint(user.getId(), totalAmount);
50-
51-
List<OrderItem> orderItems = buildOrderItems(products, request.items());
44+
List<OrderItem> orderItems = buildOrderItems(products, command.items());
5245
Order order = orderService.createOrder(user.getId(), orderItems);
46+
long totalAmount = order.getTotalAmount().getValue();
47+
48+
productService.deductStock(products, orderItems);
49+
pointService.deductPoint(user, totalAmount);
5350

5451
return OrderInfo.from(order);
5552
}
5653

57-
private List<OrderItem> buildOrderItems(List<Product> products, List<OrderItemRequest> items) {
54+
private List<OrderItem> buildOrderItems(List<Product> products, List<Item> items) {
5855

5956
Map<Long, Product> productMap = products.stream()
6057
.collect(Collectors.toMap(Product::getId, p -> p));

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ public static OrderInfo from(Order order) {
1717
order.getId(),
1818
order.getUserId(),
1919
order.getOrderItems().stream().map(OrderItemInfo::from).toList(),
20-
order.calculateTotalAmount()
20+
order.getTotalAmount().getValue()
2121
);
2222
}
2323

apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,16 @@ public record ProductInfo(
77
Long id,
88
String name,
99
Money price,
10-
String brandName
10+
String brandName,
11+
int likeCount
1112
) {
1213
public static ProductInfo from(Product product) {
1314
return new ProductInfo(
1415
product.getId(),
1516
product.getName(),
1617
product.getPrice(),
17-
null
18+
null,
19+
product.getLikeCount()
1820
);
1921
}
2022

@@ -23,7 +25,8 @@ public static ProductInfo from(Product product, String brandName) {
2325
product.getId(),
2426
product.getName(),
2527
product.getPrice(),
26-
brandName
28+
brandName,
29+
product.getLikeCount()
2730
);
2831
}
2932
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
package com.loopers.domain.coupon;
2+
3+
import com.loopers.domain.BaseEntity;
4+
import com.loopers.support.error.CoreException;
5+
import com.loopers.support.error.ErrorType;
6+
import jakarta.persistence.Column;
7+
import jakarta.persistence.Entity;
8+
import jakarta.persistence.EnumType;
9+
import jakarta.persistence.Enumerated;
10+
import jakarta.persistence.Table;
11+
import lombok.AccessLevel;
12+
import lombok.NoArgsConstructor;
13+
14+
@Entity
15+
@Table(name = "coupon")
16+
@NoArgsConstructor(access = AccessLevel.PROTECTED)
17+
public class Coupon extends BaseEntity {
18+
19+
@Column(name = "ref_user_id", nullable = false)
20+
private long userId;
21+
22+
@Enumerated(EnumType.STRING)
23+
@Column(nullable = false)
24+
private CouponType type;
25+
26+
private long discountValue;
27+
28+
private boolean used;
29+
30+
public Coupon(long userId, CouponType type, long discountValue) {
31+
if (type == CouponType.PERCENTAGE && (discountValue < 0 || discountValue > 100)) {
32+
throw new CoreException(ErrorType.BAD_REQUEST, "할인율은 0~100% 사이여야 합니다.");
33+
}
34+
this.userId = userId;
35+
this.type = type;
36+
this.discountValue = discountValue;
37+
this.used = false;
38+
}
39+
40+
public boolean isUsed() {
41+
return used;
42+
}
43+
44+
public long calculateDiscountAmount(long totalOrderAmount) {
45+
return this.type.calculate(totalOrderAmount, this.discountValue);
46+
}
47+
48+
public void use() {
49+
if (this.used) {
50+
throw new CoreException(ErrorType.BAD_REQUEST, "이미 사용된 쿠폰입니다.");
51+
}
52+
this.used = true;
53+
}
54+
55+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
package com.loopers.domain.coupon;
2+
3+
import java.util.Optional;
4+
5+
public interface CouponRepository {
6+
7+
Coupon save(Coupon coupon);
8+
9+
Optional<Coupon> findById(Long id);
10+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package com.loopers.domain.coupon;
2+
3+
import com.loopers.support.error.CoreException;
4+
import com.loopers.support.error.ErrorType;
5+
import lombok.RequiredArgsConstructor;
6+
import org.springframework.stereotype.Component;
7+
8+
@RequiredArgsConstructor
9+
@Component
10+
public class CouponService {
11+
12+
private final CouponRepository couponRepository;
13+
14+
15+
public Coupon getCoupon(Long id) {
16+
return couponRepository.findById(id)
17+
.orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "쿠폰을 찾을 수 없습니다."));
18+
}
19+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
package com.loopers.domain.coupon;
2+
3+
public enum CouponType {
4+
FIXED_AMOUNT {
5+
@Override
6+
public long calculate(long totalAmount, long discountValue) {
7+
return Math.min(totalAmount, discountValue);
8+
}
9+
},
10+
PERCENTAGE {
11+
@Override
12+
public long calculate(long totalAmount, long discountValue) {
13+
return (long) (totalAmount * (discountValue / 100.0));
14+
}
15+
};
16+
17+
public abstract long calculate(long totalAmount, long discountValue);
18+
}
Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,26 @@
11
package com.loopers.domain.like;
22

3+
import java.util.Optional;
34
import lombok.RequiredArgsConstructor;
45
import org.springframework.stereotype.Component;
6+
import org.springframework.transaction.annotation.Transactional;
57

68
@Component
79
@RequiredArgsConstructor
810
public class LikeService {
911

1012
private final LikeRepository likeRepository;
1113

12-
public Like like(long userId, long productId) {
13-
return likeRepository.findByUserIdAndProductId(userId, productId)
14-
.orElseGet(() -> likeRepository.save(new Like(userId, productId)));
14+
public Like save(long userId, long productId) {
15+
return likeRepository.save(new Like(userId, productId));
1516
}
1617

17-
public void unLike(Long userId, Long productId) {
18-
likeRepository.deleteByUserIdAndProductId(userId, productId);
18+
public Optional<Like> findLike(long userId, long productId) {
19+
return likeRepository.findByUserIdAndProductId(userId, productId);
1920
}
2021

21-
public long countLikesByProductId(Long productId) {
22-
return likeRepository.countByProductId(productId);
22+
@Transactional
23+
public void unLike(Long userId, Long productId) {
24+
likeRepository.deleteByUserIdAndProductId(userId, productId);
2325
}
2426
}

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

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

33
import com.loopers.domain.BaseEntity;
4+
import com.loopers.domain.money.Money;
45
import com.loopers.domain.product.Product;
56
import com.loopers.support.error.CoreException;
67
import com.loopers.support.error.ErrorType;
8+
import jakarta.persistence.AttributeOverride;
79
import jakarta.persistence.CascadeType;
810
import jakarta.persistence.Column;
11+
import jakarta.persistence.Embedded;
912
import jakarta.persistence.Entity;
1013
import jakarta.persistence.JoinColumn;
1114
import jakarta.persistence.OneToMany;
@@ -19,11 +22,15 @@
1922
@NoArgsConstructor(access = AccessLevel.PROTECTED)
2023
public class Order extends BaseEntity {
2124

22-
@Column(nullable = false)
25+
@Column(name = "ref_user_id", nullable = false)
2326
private Long userId;
2427

28+
@Embedded
29+
@AttributeOverride(name = "value", column = @Column(name = "total_amount"))
30+
private Money totalAmount;
31+
2532
@OneToMany(cascade = CascadeType.ALL, orphanRemoval = true)
26-
@JoinColumn(name = "order_id")
33+
@JoinColumn(name = "order_id", nullable = false)
2734
private List<OrderItem> orderItems = new java.util.ArrayList<>();
2835

2936
public Order(Long userId, List<OrderItem> orderItems) {
@@ -36,6 +43,7 @@ public Order(Long userId, List<OrderItem> orderItems) {
3643
}
3744
this.userId = userId;
3845
this.orderItems = orderItems;
46+
this.totalAmount = new Money(calculateTotalAmount());
3947
}
4048

4149
public Long getUserId() {
@@ -64,4 +72,8 @@ public long calculateTotalAmount() {
6472
.mapToLong(OrderItem::calculateAmount)
6573
.sum();
6674
}
75+
76+
public Money getTotalAmount() {
77+
return totalAmount;
78+
}
6779
}

0 commit comments

Comments
 (0)