Skip to content

Commit fb769b2

Browse files
authored
[volume-7] Decoupling with Event (#171)
* Feature/refactor purchasing (#30) * feat: PG 모듈 추가 (#24) * Feature/pg client (#27) * test: PG 호출 테스트 코드 추가 * feat: PG 호출 로직 구현 * test: PG CircuitBreaker 테스트 코드 추가 * feat: CircuitBreaker 로직 구현 * test: payment 도메인 단위 테스트 추가 * feat: payment 도메인 구현 * test: PaymentService 도메인 서비스 테스트 코드 추가 * feat: PaymentService 도메인 서비스 구현 * refactor: payment 도메인의 책임이지만 order 도메인에 있던 로직들 이동 * test: Order 도메인 서비스 로직 테스트 코드 작성 * refactor: Order 도메인의 도메인 서비스 로직 재구성 * refactor: purchasing facade에서 처리하고 있던 내용 중 도메인 레이어로 위임할 수 있는 내용들 재배치 * refactor: DIP 원칙에 맞춰 PG 로직에서 infra 레이어가 domain 레이어를 의존하도록 재구성 * refactor: payment 관련 스케줄링 로직들은 infra 레이어로 이동 * refactor: PurchasingFacade가 repository를 직접 사용하지 않도록 도메인 서비스 로직 재구성 * refactor: PuhrchasingFacade가 도메인 서비스를 조합해서 사용하는 역할만 담당하도록 재구성 * refactor: 재구성한 도메인 서비스 로직에 맞춰 테스트 코드 재구성 * refactor: 주문 결제시 포인트 또는 카드만을 사용하여 결제 할 수 있도록 수정 * refactor: 포인트 또는 카드를 사용하여 결제할 수 있도록 테스트 코드 재구성 * refactor: 다른 application레이어와 동일하게 command의 위치를 domain에서 application으로 이동 * refactor: Order도메인 서비스에 대한 테스트 코드 중 application 레이어에 더 적합한 부분 분리 * refactor: Order 도메인 서비스에 있던 내용 중 어플리케이션 서비스에 해당하는 부분들은 application 레이어로 이동시킴 * refactor: infrastructure 레이어에 domain레이어와 달리 payment와 paymentgateway가 구분되어있어 통일함 * chore: 중복되는 로그 정리, 불필요하게 높은 level인 로그는 debug 로그로 전환 * Feature/refactor application (#31) * refactor: application 레이어를 도메인별 어플리케이션 서비스와 어플리케이션의 조합인 facade로 분리하여 구성 * refactor: application가 도메인별 어플리케이션 서비스와 파사드로 구분된 것에 맞춰 테스트 코드 수정 * refactor: scheduler를 infrastructure 레이어로 이동 * refactor: 도메인의 캡슐화가 부족한 부분 개선 * refactor: 캐시 사용할 때 dip 적용하여 application레이어가 infrastructure 레이어를 참조하지 않도록 수정 (#32) * Feature/event (#34) * feat: 도메인별 event 추가 * feat: DIP 적용하여 event publisher 구현 * refactor: 좋아요 수 집계를 스케줄 기반 처리하는 것에서 이벤트 기반 처리하는 것으로 변경 * feat: 쿠폰 이벤트 처리하는 로직 추가 * feat: order 도메인의 이벤트 처리 로직 추가 * feat: payment 도메인의 이벤트 처리 로직 추가 * feat: point 도메인의 이벤트 처리 로직 추가 * feat: product 도메인의 이벤트 처리 로직 추가 * 도메인이벤트 * refactor: HeartFacade에서 좋아요 처리시 Product 어플리케이션 서비스를 직접호출하지 않고 이벤트 사용하는 방식으로 재구성 * refactor: PurchasingFacade에서 주문 처리시 어플리케이션 서비스를 직접호출하지 않고 이벤트 사용하는 방식으로 재구성 * test: eventhandler에 대한 테스트 추가 * refactor: event handler 테스트에 맞춰 코드 수정 * feat: 데이터 플랫폼으로 주문 데이터 전송하는 로직 추가 * feat: event 및 command 재구성 (#35) * feat: event 및 command 재구성 (#35) event * code rabbit
1 parent d928734 commit fb769b2

132 files changed

Lines changed: 8649 additions & 2854 deletions

File tree

Some content is hidden

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

apps/commerce-api/src/main/java/com/loopers/CommerceApiApplication.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,14 @@
55
import org.springframework.boot.autoconfigure.SpringBootApplication;
66
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
77
import org.springframework.cloud.openfeign.EnableFeignClients;
8+
import org.springframework.scheduling.annotation.EnableAsync;
89
import org.springframework.scheduling.annotation.EnableScheduling;
910
import java.util.TimeZone;
1011

1112
@ConfigurationPropertiesScan
1213
@SpringBootApplication
1314
@EnableScheduling
15+
@EnableAsync
1416
@EnableFeignClients
1517
public class CommerceApiApplication {
1618

apps/commerce-api/src/main/java/com/loopers/application/catalog/CatalogBrandFacade.java renamed to apps/commerce-api/src/main/java/com/loopers/application/brand/BrandService.java

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
1-
package com.loopers.application.catalog;
1+
package com.loopers.application.brand;
22

33
import com.loopers.domain.brand.Brand;
44
import com.loopers.domain.brand.BrandRepository;
55
import com.loopers.support.error.CoreException;
66
import com.loopers.support.error.ErrorType;
77
import lombok.RequiredArgsConstructor;
88
import org.springframework.stereotype.Component;
9+
import org.springframework.transaction.annotation.Transactional;
10+
11+
import java.util.List;
912

1013
/**
1114
* 브랜드 조회 파사드.
@@ -18,19 +21,45 @@
1821
*/
1922
@RequiredArgsConstructor
2023
@Component
21-
public class CatalogBrandFacade {
24+
public class BrandService {
2225
private final BrandRepository brandRepository;
2326

27+
/**
28+
* 브랜드 ID로 브랜드를 조회합니다.
29+
*
30+
* @param brandId 브랜드 ID
31+
* @return 조회된 브랜드
32+
* @throws CoreException 브랜드를 찾을 수 없는 경우
33+
*/
34+
@Transactional(readOnly = true)
35+
public Brand getBrand(Long brandId) {
36+
return brandRepository.findById(brandId)
37+
.orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "브랜드를 찾을 수 없습니다."));
38+
}
39+
40+
/**
41+
* 브랜드 ID 목록으로 브랜드 목록을 조회합니다.
42+
* <p>
43+
* 배치 조회를 통해 N+1 쿼리 문제를 해결합니다.
44+
* </p>
45+
*
46+
* @param brandIds 조회할 브랜드 ID 목록
47+
* @return 조회된 브랜드 목록
48+
*/
49+
@Transactional(readOnly = true)
50+
public List<Brand> getBrands(List<Long> brandIds) {
51+
return brandRepository.findAllById(brandIds);
52+
}
53+
2454
/**
2555
* 브랜드 정보를 조회합니다.
2656
*
2757
* @param brandId 브랜드 ID
2858
* @return 브랜드 정보
2959
* @throws CoreException 브랜드를 찾을 수 없는 경우
3060
*/
31-
public BrandInfo getBrand(Long brandId) {
32-
Brand brand = brandRepository.findById(brandId)
33-
.orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "브랜드를 찾을 수 없습니다."));
61+
public BrandInfo getBrandInfo(Long brandId) {
62+
Brand brand = getBrand(brandId);
3463
return BrandInfo.from(brand);
3564
}
3665

apps/commerce-api/src/main/java/com/loopers/application/catalog/CatalogProductFacade.java renamed to apps/commerce-api/src/main/java/com/loopers/application/catalog/CatalogFacade.java

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

3+
import com.loopers.application.brand.BrandService;
4+
import com.loopers.application.product.ProductCacheService;
5+
import com.loopers.application.product.ProductService;
36
import com.loopers.domain.brand.Brand;
4-
import com.loopers.domain.brand.BrandRepository;
57
import com.loopers.domain.product.Product;
68
import com.loopers.domain.product.ProductDetail;
7-
import com.loopers.domain.product.ProductRepository;
89
import com.loopers.support.error.CoreException;
910
import com.loopers.support.error.ErrorType;
1011
import lombok.RequiredArgsConstructor;
@@ -25,9 +26,9 @@
2526
*/
2627
@RequiredArgsConstructor
2728
@Component
28-
public class CatalogProductFacade {
29-
private final ProductRepository productRepository;
30-
private final BrandRepository brandRepository;
29+
public class CatalogFacade {
30+
private final BrandService brandService;
31+
private final ProductService productService;
3132
private final ProductCacheService productCacheService;
3233

3334
/**
@@ -54,8 +55,8 @@ public ProductInfoList getProducts(Long brandId, String sort, int page, int size
5455
}
5556

5657
// 캐시에 없으면 DB에서 조회
57-
long totalCount = productRepository.countAll(brandId);
58-
List<Product> products = productRepository.findAll(brandId, normalizedSort, page, size);
58+
long totalCount = productService.countAll(brandId);
59+
List<Product> products = productService.findAll(brandId, normalizedSort, page, size);
5960

6061
if (products.isEmpty()) {
6162
ProductInfoList emptyResult = new ProductInfoList(List.of(), totalCount, page, size);
@@ -72,7 +73,7 @@ public ProductInfoList getProducts(Long brandId, String sort, int page, int size
7273
.toList();
7374

7475
// 브랜드 배치 조회 및 Map으로 변환 (O(1) 조회를 위해)
75-
Map<Long, Brand> brandMap = brandRepository.findAllById(brandIds).stream()
76+
Map<Long, Brand> brandMap = brandService.getBrands(brandIds).stream()
7677
.collect(Collectors.toMap(Brand::getId, brand -> brand));
7778

7879
// 상품 정보 변환 (이미 조회한 Product 재사용)
@@ -116,12 +117,10 @@ public ProductInfo getProduct(Long productId) {
116117
}
117118

118119
// 캐시에 없으면 DB에서 조회
119-
Product product = productRepository.findById(productId)
120-
.orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다."));
120+
Product product = productService.getProduct(productId);
121121

122122
// 브랜드 조회
123-
Brand brand = brandRepository.findById(product.getBrandId())
124-
.orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "브랜드를 찾을 수 없습니다."));
123+
Brand brand = brandService.getBrand(product.getBrandId());
125124

126125
// ✅ Product.likeCount 필드 사용 (비동기 집계된 값)
127126
Long likesCount = product.getLikeCount();
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
package com.loopers.application.coupon;
2+
3+
/**
4+
* 쿠폰 적용 명령.
5+
* <p>
6+
* 쿠폰 적용을 위한 명령 객체입니다.
7+
* </p>
8+
*
9+
* @param userId 사용자 ID
10+
* @param couponCode 쿠폰 코드
11+
* @param subtotal 주문 소계 금액
12+
*/
13+
public record ApplyCouponCommand(
14+
Long userId,
15+
String couponCode,
16+
Integer subtotal
17+
) {
18+
public ApplyCouponCommand {
19+
if (userId == null) {
20+
throw new IllegalArgumentException("userId는 필수입니다.");
21+
}
22+
if (couponCode == null || couponCode.isBlank()) {
23+
throw new IllegalArgumentException("couponCode는 필수입니다.");
24+
}
25+
if (subtotal == null || subtotal < 0) {
26+
throw new IllegalArgumentException("subtotal은 0 이상이어야 합니다.");
27+
}
28+
}
29+
}
30+
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
package com.loopers.application.coupon;
2+
3+
import com.loopers.domain.coupon.CouponEvent;
4+
import com.loopers.domain.coupon.CouponEventPublisher;
5+
import com.loopers.domain.order.OrderEvent;
6+
import com.loopers.support.error.CoreException;
7+
import lombok.RequiredArgsConstructor;
8+
import lombok.extern.slf4j.Slf4j;
9+
import org.springframework.orm.ObjectOptimisticLockingFailureException;
10+
import org.springframework.stereotype.Component;
11+
import org.springframework.transaction.annotation.Transactional;
12+
13+
/**
14+
* 쿠폰 이벤트 핸들러.
15+
* <p>
16+
* 주문 생성 이벤트를 받아 쿠폰 사용 처리를 수행하는 애플리케이션 로직을 처리합니다.
17+
* </p>
18+
* <p>
19+
* <b>DDD/EDA 관점:</b>
20+
* <ul>
21+
* <li><b>책임 분리:</b> CouponService는 쿠폰 도메인 비즈니스 로직, CouponEventHandler는 이벤트 처리 로직</li>
22+
* <li><b>이벤트 핸들러:</b> 이벤트를 받아서 처리하는 역할을 명확히 나타냄</li>
23+
* <li><b>도메인 경계 준수:</b> 쿠폰 도메인은 쿠폰 적용 이벤트만 발행하고, 주문 도메인은 자신의 상태를 관리</li>
24+
* </ul>
25+
* </p>
26+
*
27+
* @author Loopers
28+
* @version 1.0
29+
*/
30+
@Slf4j
31+
@Component
32+
@RequiredArgsConstructor
33+
public class CouponEventHandler {
34+
35+
private final CouponService couponService;
36+
private final CouponEventPublisher couponEventPublisher;
37+
38+
/**
39+
* 주문 생성 이벤트를 처리하여 쿠폰을 사용하고 쿠폰 적용 이벤트를 발행합니다.
40+
* <p>
41+
* 쿠폰 코드가 있는 경우에만 쿠폰 사용 처리를 수행합니다.
42+
* 쿠폰 적용 후 CouponApplied 이벤트를 발행하여 주문 도메인이 자신의 상태를 업데이트하도록 합니다.
43+
* </p>
44+
*
45+
* @param event 주문 생성 이벤트
46+
*/
47+
@Transactional
48+
public void handleOrderCreated(OrderEvent.OrderCreated event) {
49+
// 쿠폰 코드가 없는 경우 처리하지 않음
50+
if (event.couponCode() == null || event.couponCode().isBlank()) {
51+
log.debug("쿠폰 코드가 없어 쿠폰 사용 처리를 건너뜁니다. (orderId: {})", event.orderId());
52+
return;
53+
}
54+
55+
try {
56+
// ✅ OrderEvent.OrderCreated를 구독하여 쿠폰 적용 Command 실행
57+
// 쿠폰 사용 처리 (쿠폰 사용 마킹 및 할인 금액 계산)
58+
Integer discountAmount = couponService.applyCoupon(
59+
new ApplyCouponCommand(
60+
event.userId(),
61+
event.couponCode(),
62+
event.subtotal()
63+
)
64+
);
65+
66+
// ✅ 도메인 이벤트 발행: 쿠폰이 적용되었음 (과거 사실)
67+
// 주문 도메인이 이 이벤트를 구독하여 자신의 상태를 업데이트함
68+
couponEventPublisher.publish(CouponEvent.CouponApplied.of(
69+
event.orderId(),
70+
event.userId(),
71+
event.couponCode(),
72+
discountAmount
73+
));
74+
75+
log.info("쿠폰 사용 처리 완료. (orderId: {}, couponCode: {}, discountAmount: {})",
76+
event.orderId(), event.couponCode(), discountAmount);
77+
} catch (CoreException e) {
78+
// 비즈니스 예외 발생 시 실패 이벤트 발행
79+
String failureReason = e.getMessage() != null ? e.getMessage() : "쿠폰 적용 실패";
80+
log.error("쿠폰 적용 실패. (orderId: {}, couponCode: {})",
81+
event.orderId(), event.couponCode(), e);
82+
couponEventPublisher.publish(CouponEvent.CouponApplicationFailed.of(
83+
event.orderId(),
84+
event.userId(),
85+
event.couponCode(),
86+
failureReason
87+
));
88+
throw e;
89+
} catch (ObjectOptimisticLockingFailureException e) {
90+
// 낙관적 락 충돌: 다른 트랜잭션이 먼저 쿠폰을 사용함
91+
String failureReason = "쿠폰이 이미 사용되었습니다. (동시성 충돌)";
92+
log.warn("쿠폰 사용 중 낙관적 락 충돌 발생. (orderId: {}, couponCode: {})",
93+
event.orderId(), event.couponCode());
94+
couponEventPublisher.publish(CouponEvent.CouponApplicationFailed.of(
95+
event.orderId(),
96+
event.userId(),
97+
event.couponCode(),
98+
failureReason
99+
));
100+
throw e;
101+
} catch (Exception e) {
102+
// 예상치 못한 오류 발생 시 실패 이벤트 발행
103+
String failureReason = e.getMessage() != null ? e.getMessage() : "쿠폰 적용 처리 중 오류 발생";
104+
log.error("쿠폰 적용 처리 중 오류 발생. (orderId: {}, couponCode: {})",
105+
event.orderId(), event.couponCode(), e);
106+
couponEventPublisher.publish(CouponEvent.CouponApplicationFailed.of(
107+
event.orderId(),
108+
event.userId(),
109+
event.couponCode(),
110+
failureReason
111+
));
112+
throw e;
113+
}
114+
}
115+
}
116+

0 commit comments

Comments
 (0)