Skip to content

Commit d2b809f

Browse files
authored
Merge pull request #203 from sylee6529/round8
Round8
2 parents 2bdad02 + b67b119 commit d2b809f

File tree

52 files changed

+2447
-112
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

52 files changed

+2447
-112
lines changed

apps/commerce-api/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ dependencies {
22
// add-ons
33
implementation(project(":modules:jpa"))
44
implementation(project(":modules:redis"))
5+
implementation(project(":modules:kafka"))
56
implementation(project(":supports:jackson"))
67
implementation(project(":supports:logging"))
78
implementation(project(":supports:monitoring"))

apps/commerce-api/src/main/java/com/loopers/application/event/listener/OrderStatusEventListener.java

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,13 @@ public void handlePaymentCompleted(PaymentCompletedEvent event) {
5050
order.getOrderNo(),
5151
order.getMemberId(),
5252
order.getTotalPrice(),
53+
order.getItems().stream()
54+
.map(item -> new OrderCompletedEvent.OrderItemInfo(
55+
item.getProductId(),
56+
item.getQuantity(),
57+
item.getUnitPrice()
58+
))
59+
.toList(),
5360
java.time.LocalDateTime.now()
5461
));
5562

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,18 @@
22

33
import com.loopers.domain.common.vo.Money;
44
import java.time.LocalDateTime;
5+
import java.util.List;
56

67
public record OrderCompletedEvent(
78
String orderNo,
89
Long memberId,
910
Money totalPrice,
11+
List<OrderItemInfo> items,
1012
LocalDateTime completedAt
1113
) {
14+
public record OrderItemInfo(
15+
Long productId,
16+
int quantity,
17+
Money price
18+
) {}
1219
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
package com.loopers.application.event.product;
2+
3+
import java.time.LocalDateTime;
4+
5+
public record ProductViewedEvent(
6+
Long memberId, // nullable (비로그인 사용자도 추적)
7+
Long productId,
8+
Long brandId,
9+
LocalDateTime viewedAt
10+
) {
11+
}

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

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,9 @@
33
import com.loopers.application.event.like.ProductLikedEvent;
44
import com.loopers.application.event.like.ProductUnlikedEvent;
55
import com.loopers.application.event.tracking.UserActionEvent;
6+
import com.loopers.domain.like.repository.LikeRepository;
67
import com.loopers.domain.like.service.LikeService;
78
import com.loopers.domain.product.Product;
8-
import com.loopers.domain.product.repository.ProductRepository;
99
import lombok.RequiredArgsConstructor;
1010
import lombok.extern.slf4j.Slf4j;
1111
import org.springframework.context.ApplicationEventPublisher;
@@ -22,11 +22,17 @@
2222
public class LikeFacade {
2323

2424
private final LikeService likeService;
25-
private final ProductRepository productRepository;
25+
private final LikeRepository likeRepository;
2626
private final ApplicationEventPublisher eventPublisher;
2727

2828
public void likeProduct(Long memberId, Long productId) {
29-
// 1. DB에 좋아요 저장 (Redis 로직은 LikeService에서 제거됨)
29+
// 멱등성: 이미 좋아요한 경우 early return (좋아요가 존재하면 상품도 존재함)
30+
if (likeRepository.existsByMemberIdAndProductId(memberId, productId)) {
31+
log.debug("[LikeFacade] 이미 좋아요한 상품 - memberId: {}, productId: {}", memberId, productId);
32+
return;
33+
}
34+
35+
// 1. DB에 좋아요 저장
3036
Product product = likeService.like(memberId, productId);
3137

3238
// 2. 이벤트 발행 (Redis 업데이트는 비동기 리스너에서 처리)
@@ -49,7 +55,13 @@ public void likeProduct(Long memberId, Long productId) {
4955
}
5056

5157
public void unlikeProduct(Long memberId, Long productId) {
52-
// 1. DB에서 좋아요 삭제 (Redis 로직은 LikeService에서 제거됨)
58+
// 멱등성: 좋아요하지 않은 경우 early return
59+
if (!likeRepository.existsByMemberIdAndProductId(memberId, productId)) {
60+
log.debug("[LikeFacade] 좋아요하지 않은 상품 - memberId: {}, productId: {}", memberId, productId);
61+
return;
62+
}
63+
64+
// 1. DB에서 좋아요 삭제
5365
Product product = likeService.unlike(memberId, productId);
5466

5567
// 2. 이벤트 발행 (Redis 업데이트는 비동기 리스너에서 처리)

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

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

3+
import com.loopers.application.event.product.ProductViewedEvent;
34
import com.loopers.domain.like.service.LikeReadService;
5+
import com.loopers.domain.product.Product;
6+
import com.loopers.domain.product.repository.ProductRepository;
47
import com.loopers.domain.product.service.ProductReadService;
58
import com.loopers.domain.product.command.ProductSearchFilter;
69
import com.loopers.domain.product.enums.ProductSortCondition;
710
import com.loopers.infrastructure.cache.ProductDetailCache;
811
import com.loopers.infrastructure.cache.ProductListCache;
912
import lombok.RequiredArgsConstructor;
13+
import org.springframework.context.ApplicationEventPublisher;
1014
import org.springframework.data.domain.Page;
1115
import org.springframework.data.domain.PageRequest;
1216
import org.springframework.data.domain.Pageable;
1317
import org.springframework.stereotype.Component;
1418
import org.springframework.transaction.annotation.Transactional;
1519

20+
import java.time.LocalDateTime;
1621
import java.util.List;
1722

1823
@RequiredArgsConstructor
@@ -24,6 +29,8 @@ public class ProductFacade {
2429
private final LikeReadService likeReadService;
2530
private final ProductDetailCache productDetailCache;
2631
private final ProductListCache productListCache;
32+
private final ProductRepository productRepository;
33+
private final ApplicationEventPublisher eventPublisher;
2734

2835
@Transactional(readOnly = true)
2936
public Page<ProductSummaryInfo> getProducts(ProductSearchCommand command) {
@@ -93,16 +100,17 @@ public ProductDetailInfo getProductDetail(Long productId, Long memberIdOrNull) {
93100
return result;
94101
});
95102

96-
// 2. 로그인하지 않은 경우 바로 반환
97-
if (memberIdOrNull == null) {
98-
return cachedInfo; // isLikedByMember=false 그대로
99-
}
103+
// 2. Product 엔티티 조회 (brandId 획득용)
104+
Product product = productRepository.findById(productId)
105+
.orElseThrow(() -> new com.loopers.support.error.CoreException(
106+
com.loopers.support.error.ErrorType.NOT_FOUND,
107+
"상품을 찾을 수 없습니다."));
100108

101-
// 3. isLikedByMember만 동적 계산
102-
boolean isLiked = likeReadService.isLikedBy(memberIdOrNull, productId);
109+
// 3. isLikedByMember 동적 계산
110+
boolean isLiked = memberIdOrNull != null && likeReadService.isLikedBy(memberIdOrNull, productId);
103111

104112
// 4. isLikedByMember 필드만 교체해서 반환
105-
return ProductDetailInfo.builder()
113+
ProductDetailInfo result = ProductDetailInfo.builder()
106114
.id(cachedInfo.getId())
107115
.name(cachedInfo.getName())
108116
.description(cachedInfo.getDescription())
@@ -113,6 +121,16 @@ public ProductDetailInfo getProductDetail(Long productId, Long memberIdOrNull) {
113121
.likeCount(cachedInfo.getLikeCount())
114122
.isLikedByMember(isLiked) // ⭐ 동적 계산
115123
.build();
124+
125+
// 5. ProductViewedEvent 발행 (조회수 집계)
126+
eventPublisher.publishEvent(new ProductViewedEvent(
127+
memberIdOrNull, // 비로그인 사용자는 null
128+
productId,
129+
product.getBrandId(),
130+
LocalDateTime.now()
131+
));
132+
133+
return result;
116134
}
117135

118136
@Transactional(readOnly = true)

apps/commerce-api/src/main/java/com/loopers/config/AsyncConfig.java

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@
1010
import org.springframework.scheduling.annotation.Scheduled;
1111
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
1212

13-
import java.util.concurrent.Executor;
1413
import java.util.concurrent.ThreadPoolExecutor;
1514

1615
/**
@@ -35,7 +34,7 @@ public class AsyncConfig implements AsyncConfigurer {
3534
*/
3635
@Bean(name = "taskExecutor")
3736
@Override
38-
public Executor getAsyncExecutor() {
37+
public ThreadPoolTaskExecutor getAsyncExecutor() {
3938
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
4039
executor.setCorePoolSize(10); // 5 → 10
4140
executor.setMaxPoolSize(20); // 10 → 20

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

Lines changed: 6 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -28,22 +28,16 @@ public class LikeService {
2828
* - DB에는 Like 레코드만 저장
2929
* - 좋아요 카운트는 이벤트 리스너에서 Redis에 업데이트
3030
* - 스케줄러가 주기적으로 Redis → DB 동기화
31+
* - 멱등성은 Facade에서 보장 (중복 체크는 Facade 책임)
3132
*
3233
* @return 좋아요한 상품 (이벤트 발행용 brandId 포함)
3334
*/
3435
public Product like(Long memberId, Long productId) {
35-
// 중복 좋아요 방지 (멱등성)
36-
if (likeRepository.existsByMemberIdAndProductId(memberId, productId)) {
37-
log.debug("[LikeService] 이미 좋아요한 상품 - memberId: {}, productId: {}", memberId, productId);
38-
return productRepository.findById(productId)
39-
.orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다."));
40-
}
41-
42-
// 1. 상품 존재 확인 (비관적 락 제거)
36+
// 1. 상품 존재 확인
4337
Product product = productRepository.findById(productId)
4438
.orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다."));
4539

46-
// 2. DB에 Like 레코드만 저장 (카운트는 Redis에서 관리)
40+
// 2. DB에 Like 레코드 저장 (카운트는 Redis에서 관리)
4741
likeRepository.save(new Like(memberId, productId));
4842

4943
log.info("[LikeService] 좋아요 저장 완료 - memberId: {}, productId: {}", memberId, productId);
@@ -56,22 +50,16 @@ public Product like(Long memberId, Long productId) {
5650
* - DB에서 Like 레코드만 삭제
5751
* - 좋아요 카운트는 이벤트 리스너에서 Redis에 업데이트
5852
* - 스케줄러가 주기적으로 Redis → DB 동기화
53+
* - 멱등성은 Facade에서 보장 (존재 여부 체크는 Facade 책임)
5954
*
6055
* @return 좋아요 취소한 상품 (이벤트 발행용 brandId 포함)
6156
*/
6257
public Product unlike(Long memberId, Long productId) {
63-
// 좋아요 없으면 스킵 (멱등성)
64-
if (!likeRepository.existsByMemberIdAndProductId(memberId, productId)) {
65-
log.debug("[LikeService] 좋아요하지 않은 상품 - memberId: {}, productId: {}", memberId, productId);
66-
return productRepository.findById(productId)
67-
.orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다."));
68-
}
69-
70-
// 1. 상품 존재 확인 (비관적 락 제거)
58+
// 1. 상품 존재 확인
7159
Product product = productRepository.findById(productId)
7260
.orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다."));
7361

74-
// 2. DB에서 Like 레코드만 삭제 (카운트는 Redis에서 관리)
62+
// 2. DB에서 Like 레코드 삭제 (카운트는 Redis에서 관리)
7563
likeRepository.deleteByMemberIdAndProductId(memberId, productId);
7664

7765
log.info("[LikeService] 좋아요 취소 완료 - memberId: {}, productId: {}", memberId, productId);

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,17 @@
1111
import com.loopers.domain.order.repository.OrderRepository;
1212
import com.loopers.domain.product.Product;
1313
import com.loopers.domain.product.repository.ProductRepository;
14+
import com.loopers.infrastructure.cache.CacheInvalidationService;
1415
import com.loopers.support.error.CoreException;
1516
import com.loopers.support.error.ErrorType;
1617
import lombok.RequiredArgsConstructor;
18+
import lombok.extern.slf4j.Slf4j;
1719
import org.springframework.stereotype.Component;
1820

1921
import java.util.ArrayList;
2022
import java.util.List;
2123

24+
@Slf4j
2225
@RequiredArgsConstructor
2326
@Component
2427
public class OrderPlacementService {
@@ -27,6 +30,7 @@ public class OrderPlacementService {
2730
private final ProductRepository productRepository;
2831
private final MemberRepository memberRepository;
2932
private final MemberCouponRepository memberCouponRepository;
33+
private final CacheInvalidationService cacheInvalidationService;
3034

3135
public Order placeOrder(OrderPlacementCommand command) {
3236
validateMemberExists(command.getMemberId());
@@ -83,6 +87,13 @@ private List<OrderItem> processOrderLines(List<OrderLineCommand> orderLines) {
8387
throw new CoreException(ErrorType.BAD_REQUEST, "재고가 부족합니다.");
8488
}
8589

90+
// 재고 소진 시 캐시 무효화
91+
int remainingStock = productRepository.getStockQuantity(product.getId());
92+
if (remainingStock == 0) {
93+
log.info("[Order] Stock depleted for productId={}, invalidating cache", product.getId());
94+
cacheInvalidationService.invalidateOnStockDepletion(product.getId());
95+
}
96+
8697
items.add(new OrderItem(product.getId(), line.getQuantity(), product.getPrice()));
8798
}
8899

apps/commerce-api/src/main/java/com/loopers/domain/product/repository/ProductRepository.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ public interface ProductRepository {
2626

2727
int increaseStock(Long productId, int quantity);
2828

29+
int getStockQuantity(Long productId);
30+
2931
int incrementLikeCount(Long productId);
3032

3133
int decrementLikeCount(Long productId);

0 commit comments

Comments
 (0)