Skip to content

Commit 2bdad02

Browse files
authored
Merge pull request #179 from sylee6529/round7
[volume-7] Decoupling with Event
2 parents 6b5e2c9 + 0f8f193 commit 2bdad02

File tree

48 files changed

+2500
-315
lines changed

Some content is hidden

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

48 files changed

+2500
-315
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,3 +42,4 @@ out/
4242

4343
.java-version
4444
CLAUDE.md
45+
.gradle-user-home

apps/commerce-api/build.gradle.kts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@ dependencies {
1717
implementation("io.github.resilience4j:resilience4j-micrometer:2.2.0")
1818
implementation("org.springframework.boot:spring-boot-starter-aop")
1919

20+
// spring retry
21+
implementation("org.springframework.retry:spring-retry")
22+
implementation("org.springframework:spring-aspects")
23+
2024
// querydsl
2125
annotationProcessor("com.querydsl:querydsl-apt::jakarta")
2226
annotationProcessor("jakarta.persistence:jakarta.persistence-api")
@@ -25,4 +29,7 @@ dependencies {
2529
// test-fixtures
2630
testImplementation(testFixtures(project(":modules:jpa")))
2731
testImplementation(testFixtures(project(":modules:redis")))
32+
33+
// awaitility for async testing
34+
testImplementation("org.awaitility:awaitility:4.2.0")
2835
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
package com.loopers.application.event.like;
2+
3+
import java.time.LocalDateTime;
4+
5+
public record ProductLikedEvent(
6+
Long memberId,
7+
Long productId,
8+
Long brandId,
9+
LocalDateTime likedAt
10+
) {
11+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
package com.loopers.application.event.like;
2+
3+
import java.time.LocalDateTime;
4+
5+
public record ProductUnlikedEvent(
6+
Long memberId,
7+
Long productId,
8+
Long brandId,
9+
LocalDateTime unlikedAt
10+
) {
11+
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
package com.loopers.application.event.listener;
2+
3+
import com.loopers.application.event.order.OrderCompletedEvent;
4+
import lombok.RequiredArgsConstructor;
5+
import lombok.extern.slf4j.Slf4j;
6+
import org.springframework.retry.annotation.Backoff;
7+
import org.springframework.retry.annotation.Recover;
8+
import org.springframework.retry.annotation.Retryable;
9+
import org.springframework.scheduling.annotation.Async;
10+
import org.springframework.stereotype.Component;
11+
import org.springframework.transaction.event.TransactionPhase;
12+
import org.springframework.transaction.event.TransactionalEventListener;
13+
14+
/**
15+
* 주문/결제 결과를 데이터 플랫폼으로 전송하는 비동기 리스너
16+
* - 외부 I/O 실패 시에도 본 트랜잭션에 영향 없음
17+
* - 일시적 장애 대응을 위한 재시도 메커니즘 포함
18+
*/
19+
@Slf4j
20+
@Component
21+
@RequiredArgsConstructor
22+
public class DataPlatformEventListener {
23+
24+
/**
25+
* 주문 완료 이벤트를 데이터 플랫폼으로 전송 (비동기 + 재시도)
26+
*
27+
* 재시도 전략:
28+
* - 최대 3회 재시도
29+
* - 초기 딜레이 1초, 지수 백오프 (1s → 2s → 4s)
30+
* - 외부 API 일시 장애 대응 (503, timeout 등)
31+
*/
32+
@Async
33+
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
34+
@Retryable(
35+
maxAttempts = 3,
36+
backoff = @Backoff(delay = 1000, multiplier = 2),
37+
recover = "recoverOrderCompleted"
38+
)
39+
public void handleOrderCompleted(OrderCompletedEvent event) {
40+
// TODO: 실제 데이터 플랫폼 API 연동. 현재는 더미 호출로 대체.
41+
log.info("[DataPlatform] 주문 완료 전송 시도 - orderNo: {}, memberId: {}, amount: {}",
42+
event.orderNo(), event.memberId(), event.totalPrice());
43+
44+
// 실제 구현 예시:
45+
// dataPlatformClient.sendOrderCompleted(event);
46+
47+
log.debug("[DataPlatform] 주문 완료 전송 성공 - orderNo: {}", event.orderNo());
48+
}
49+
50+
/**
51+
* 데이터 플랫폼 전송 최종 실패 시 복구 메서드
52+
* - 3회 재시도 후에도 실패한 경우 호출됨
53+
* - 데이터 유실 방지를 위해 DLQ 저장 필요
54+
*/
55+
@Recover
56+
public void recoverOrderCompleted(Exception ex, OrderCompletedEvent event) {
57+
log.error("[DataPlatform] 주문 완료 전송 최종 실패 - orderNo: {}, error: {}",
58+
event.orderNo(), ex.getMessage(), ex);
59+
60+
// TODO: Dead Letter Queue에 저장 (중요!)
61+
// deadLetterQueueService.save(event, ex);
62+
// → 나중에 배치 작업으로 재전송
63+
64+
// TODO: 알림 전송 (심각한 외부 시스템 장애)
65+
// alertService.sendAlert("데이터 플랫폼 전송 실패", event, ex);
66+
}
67+
}
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
package com.loopers.application.event.listener;
2+
3+
import com.loopers.application.event.like.ProductLikedEvent;
4+
import com.loopers.application.event.like.ProductUnlikedEvent;
5+
import com.loopers.infrastructure.cache.CacheInvalidationService;
6+
import com.loopers.infrastructure.cache.MemberLikesCache;
7+
import com.loopers.infrastructure.cache.ProductLikeCountCache;
8+
import lombok.RequiredArgsConstructor;
9+
import lombok.extern.slf4j.Slf4j;
10+
import org.springframework.retry.annotation.Backoff;
11+
import org.springframework.retry.annotation.Recover;
12+
import org.springframework.retry.annotation.Retryable;
13+
import org.springframework.scheduling.annotation.Async;
14+
import org.springframework.stereotype.Component;
15+
import org.springframework.transaction.event.TransactionPhase;
16+
import org.springframework.transaction.event.TransactionalEventListener;
17+
18+
/**
19+
* 좋아요 집계 이벤트 리스너
20+
*
21+
* - 좋아요/취소 이벤트를 수신하여 Redis 캐시를 비동기로 업데이트
22+
* - eventual consistency: DB 저장 후 Redis는 비동기로 업데이트
23+
* - Redis 실패 시에도 DB는 정상 저장됨
24+
*/
25+
@Slf4j
26+
@Component
27+
@RequiredArgsConstructor
28+
public class LikeAggregationEventListener {
29+
30+
private final ProductLikeCountCache productLikeCountCache;
31+
private final MemberLikesCache memberLikesCache;
32+
private final CacheInvalidationService cacheInvalidationService;
33+
34+
/**
35+
* 좋아요 이벤트 처리 (비동기 + 재시도)
36+
* - Redis 좋아요 카운트 증가
37+
* - 회원 좋아요 목록 캐시 업데이트
38+
* - 상품 캐시 무효화
39+
*
40+
* 재시도 전략:
41+
* - 최대 3회 재시도
42+
* - 초기 딜레이 100ms, 지수 백오프 (100ms → 200ms → 400ms)
43+
* - 일시적 Redis 네트워크 오류 대응
44+
*/
45+
@Async
46+
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
47+
@Retryable(
48+
maxAttempts = 3,
49+
backoff = @Backoff(delay = 100, multiplier = 2),
50+
recover = "recoverProductLiked"
51+
)
52+
public void handleProductLiked(ProductLikedEvent event) {
53+
log.info("[LikeAggregationEventListener] 좋아요 이벤트 처리 - memberId: {}, productId: {}",
54+
event.memberId(), event.productId());
55+
56+
// 재시도 가능하도록 try-catch 제거 (예외를 위로 전파)
57+
productLikeCountCache.increment(event.productId());
58+
memberLikesCache.add(event.memberId(), event.productId());
59+
cacheInvalidationService.invalidateOnLikeChange(event.productId(), event.brandId());
60+
61+
log.debug("[LikeAggregationEventListener] 좋아요 집계 완료 - productId: {}", event.productId());
62+
}
63+
64+
/**
65+
* 좋아요 취소 이벤트 처리 (비동기 + 재시도)
66+
* - Redis 좋아요 카운트 감소
67+
* - 회원 좋아요 목록 캐시 업데이트
68+
* - 상품 캐시 무효화
69+
*
70+
* 재시도 전략:
71+
* - 최대 3회 재시도
72+
* - 초기 딜레이 100ms, 지수 백오프 (100ms → 200ms → 400ms)
73+
* - 일시적 Redis 네트워크 오류 대응
74+
*/
75+
@Async
76+
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
77+
@Retryable(
78+
maxAttempts = 3,
79+
backoff = @Backoff(delay = 100, multiplier = 2),
80+
recover = "recoverProductUnliked"
81+
)
82+
public void handleProductUnliked(ProductUnlikedEvent event) {
83+
log.info("[LikeAggregationEventListener] 좋아요 취소 이벤트 처리 - memberId: {}, productId: {}",
84+
event.memberId(), event.productId());
85+
86+
// 재시도 가능하도록 try-catch 제거 (예외를 위로 전파)
87+
productLikeCountCache.decrement(event.productId());
88+
memberLikesCache.remove(event.memberId(), event.productId());
89+
cacheInvalidationService.invalidateOnLikeChange(event.productId(), event.brandId());
90+
91+
log.debug("[LikeAggregationEventListener] 좋아요 취소 집계 완료 - productId: {}", event.productId());
92+
}
93+
94+
/**
95+
* 좋아요 집계 최종 실패 시 복구 메서드
96+
* - 3회 재시도 후에도 실패한 경우 호출됨
97+
* - 로그 기록 및 향후 DLQ 저장 가능
98+
*/
99+
@Recover
100+
public void recoverProductLiked(Exception ex, ProductLikedEvent event) {
101+
log.error("[LikeAggregationEventListener] 좋아요 집계 최종 실패 - memberId: {}, productId: {}, error: {}",
102+
event.memberId(), event.productId(), ex.getMessage(), ex);
103+
104+
// TODO: Dead Letter Queue에 저장하여 나중에 재처리
105+
// deadLetterQueueService.save(event, ex);
106+
107+
// TODO: 알림 전송 (심각한 Redis 장애)
108+
// alertService.sendAlert("좋아요 집계 실패", event, ex);
109+
}
110+
111+
/**
112+
* 좋아요 취소 집계 최종 실패 시 복구 메서드
113+
* - 3회 재시도 후에도 실패한 경우 호출됨
114+
* - 로그 기록 및 향후 DLQ 저장 가능
115+
*/
116+
@Recover
117+
public void recoverProductUnliked(Exception ex, ProductUnlikedEvent event) {
118+
log.error("[LikeAggregationEventListener] 좋아요 취소 집계 최종 실패 - memberId: {}, productId: {}, error: {}",
119+
event.memberId(), event.productId(), ex.getMessage(), ex);
120+
121+
// TODO: Dead Letter Queue에 저장하여 나중에 재처리
122+
// deadLetterQueueService.save(event, ex);
123+
124+
// TODO: 알림 전송 (심각한 Redis 장애)
125+
// alertService.sendAlert("좋아요 취소 집계 실패", event, ex);
126+
}
127+
}
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
package com.loopers.application.event.listener;
2+
3+
import com.loopers.application.event.payment.PaymentCompletedEvent;
4+
import com.loopers.application.event.order.OrderCompletedEvent;
5+
import com.loopers.domain.order.Order;
6+
import com.loopers.domain.order.repository.OrderRepository;
7+
import com.loopers.domain.product.repository.ProductRepository;
8+
import com.loopers.support.error.CoreException;
9+
import com.loopers.support.error.ErrorType;
10+
import lombok.RequiredArgsConstructor;
11+
import lombok.extern.slf4j.Slf4j;
12+
import org.springframework.context.ApplicationEventPublisher;
13+
import org.springframework.stereotype.Component;
14+
import org.springframework.transaction.annotation.Propagation;
15+
import org.springframework.transaction.annotation.Transactional;
16+
import org.springframework.transaction.event.TransactionPhase;
17+
import org.springframework.transaction.event.TransactionalEventListener;
18+
19+
@Slf4j
20+
@Component
21+
@RequiredArgsConstructor
22+
public class OrderStatusEventListener {
23+
24+
private final OrderRepository orderRepository;
25+
private final ProductRepository productRepository;
26+
private final ApplicationEventPublisher eventPublisher;
27+
28+
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
29+
@Transactional(propagation = Propagation.REQUIRES_NEW)
30+
public void handlePaymentCompleted(PaymentCompletedEvent event) {
31+
log.info("[OrderStatusEventListener] 결제 완료 이벤트 처리 - orderNo: {}, status: {}",
32+
event.orderNo(), event.status());
33+
34+
Order order = orderRepository.findByOrderNo(event.orderNo())
35+
.orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND,
36+
"주문을 찾을 수 없습니다: " + event.orderNo()));
37+
38+
if (event.isSuccess()) {
39+
// 이미 결제 완료된 경우 스킵 (멱등성)
40+
if (order.isPaid()) {
41+
log.debug("[OrderStatusEventListener] 이미 결제 완료된 주문 - orderNo: {}", event.orderNo());
42+
return;
43+
}
44+
45+
order.markAsPaid();
46+
orderRepository.save(order);
47+
log.info("[OrderStatusEventListener] 주문 완료 처리 - orderNo: {}", event.orderNo());
48+
49+
eventPublisher.publishEvent(new OrderCompletedEvent(
50+
order.getOrderNo(),
51+
order.getMemberId(),
52+
order.getTotalPrice(),
53+
java.time.LocalDateTime.now()
54+
));
55+
56+
} else if (event.isFailed()) {
57+
log.warn("[OrderStatusEventListener] 결제 실패 - orderNo: {}, reason: {}",
58+
event.orderNo(), event.failureReason());
59+
60+
// 결제 실패 시 주문 취소 및 재고 복구
61+
handlePaymentFailed(order, event.failureReason());
62+
}
63+
}
64+
65+
/**
66+
* 결제 실패 처리
67+
* - 주문 취소
68+
* - 재고 복구
69+
* - 향후 알림 전송 가능
70+
*/
71+
private void handlePaymentFailed(Order order, String failureReason) {
72+
// 이미 취소된 경우 스킵 (멱등성)
73+
if (order.isCancelled()) {
74+
log.debug("[OrderStatusEventListener] 이미 취소된 주문 - orderNo: {}", order.getOrderNo());
75+
return;
76+
}
77+
78+
try {
79+
// 1. 주문 취소
80+
order.cancel();
81+
orderRepository.save(order);
82+
log.info("[OrderStatusEventListener] 주문 취소 완료 - orderNo: {}", order.getOrderNo());
83+
84+
// 2. 재고 복구
85+
order.getItems().forEach(item -> {
86+
try {
87+
productRepository.increaseStock(item.getProductId(), item.getQuantity());
88+
log.debug("[OrderStatusEventListener] 재고 복구 - productId: {}, quantity: {}",
89+
item.getProductId(), item.getQuantity());
90+
} catch (Exception e) {
91+
log.error("[OrderStatusEventListener] 재고 복구 실패 - productId: {}, quantity: {}",
92+
item.getProductId(), item.getQuantity(), e);
93+
// 재고 복구 실패는 계속 진행 (다른 상품 복구)
94+
}
95+
});
96+
97+
log.info("[OrderStatusEventListener] 결제 실패로 주문 취소 및 재고 복구 완료 - orderNo: {}, reason: {}",
98+
order.getOrderNo(), failureReason);
99+
100+
// TODO: 사용자에게 결제 실패 알림 전송
101+
// eventPublisher.publishEvent(new PaymentFailedNotificationEvent(
102+
// order.getMemberId(), order.getOrderNo(), failureReason
103+
// ));
104+
105+
} catch (Exception e) {
106+
log.error("[OrderStatusEventListener] 결제 실패 처리 중 오류 - orderNo: {}",
107+
order.getOrderNo(), e);
108+
// TODO: 심각한 오류 알림 (수동 처리 필요)
109+
}
110+
}
111+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
package com.loopers.application.event.listener;
2+
3+
import com.loopers.application.event.tracking.UserActionEvent;
4+
import lombok.extern.slf4j.Slf4j;
5+
import org.springframework.scheduling.annotation.Async;
6+
import org.springframework.stereotype.Component;
7+
import org.springframework.transaction.event.TransactionPhase;
8+
import org.springframework.transaction.event.TransactionalEventListener;
9+
10+
/**
11+
* 유저 행동 이벤트를 서버 레벨에서 로깅/추적한다.
12+
* 실패해도 본 트랜잭션에는 영향을 주지 않는다.
13+
*/
14+
@Slf4j
15+
@Component
16+
public class UserActionLoggingListener {
17+
18+
@Async
19+
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMPLETION)
20+
public void logUserAction(UserActionEvent event) {
21+
try {
22+
log.info("[UserAction] action={}, memberId={}, resourceType={}, resourceId={}, metadata={}, at={}",
23+
event.action(), event.memberId(), event.resourceType(), event.resourceId(), event.metadata(), event.occurredAt());
24+
} catch (Exception e) {
25+
log.error("[UserAction] 로그 기록 실패 - action={}, memberId={}", event.action(), event.memberId(), e);
26+
}
27+
}
28+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
package com.loopers.application.event.order;
2+
3+
import com.loopers.domain.common.vo.Money;
4+
import java.time.LocalDateTime;
5+
6+
public record OrderCompletedEvent(
7+
String orderNo,
8+
Long memberId,
9+
Money totalPrice,
10+
LocalDateTime completedAt
11+
) {
12+
}

0 commit comments

Comments
 (0)