Skip to content

Commit ff2a269

Browse files
committed
feat: 이벤트 리스너에 재시도 메커니즘 및 복구 핸들러 추가
- LikeAggregationEventListener: @retryable 추가 (3회, 100ms 지수 백오프) - DataPlatformEventListener: @retryable 추가 (3회, 1s 지수 백오프) - OrderStatusEventListener: 결제 실패 시 주문 취소 및 재고 복구 로직 구현 - 모든 리스너에 @recover 메서드 추가 (최종 실패 처리, TODO: DLQ 연동) - 재시도 동작을 반영하도록 관련 테스트 업데이트
1 parent 3dba5e3 commit ff2a269

5 files changed

Lines changed: 191 additions & 62 deletions

File tree

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

Lines changed: 45 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,29 +3,65 @@
33
import com.loopers.application.event.order.OrderCompletedEvent;
44
import lombok.RequiredArgsConstructor;
55
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;
69
import org.springframework.scheduling.annotation.Async;
710
import org.springframework.stereotype.Component;
811
import org.springframework.transaction.event.TransactionPhase;
912
import org.springframework.transaction.event.TransactionalEventListener;
1013

1114
/**
12-
* 주문/결제 결과를 데이터 플랫폼으로 전송하는 비동기 리스너.
13-
* 외부 I/O 실패 시에도 본 트랜잭션에 영향을 주지 않도록 예외를 삼킨다.
15+
* 주문/결제 결과를 데이터 플랫폼으로 전송하는 비동기 리스너
16+
* - 외부 I/O 실패 시에도 본 트랜잭션에 영향 없음
17+
* - 일시적 장애 대응을 위한 재시도 메커니즘 포함
1418
*/
1519
@Slf4j
1620
@Component
1721
@RequiredArgsConstructor
1822
public class DataPlatformEventListener {
1923

24+
/**
25+
* 주문 완료 이벤트를 데이터 플랫폼으로 전송 (비동기 + 재시도)
26+
*
27+
* 재시도 전략:
28+
* - 최대 3회 재시도
29+
* - 초기 딜레이 1초, 지수 백오프 (1s → 2s → 4s)
30+
* - 외부 API 일시 장애 대응 (503, timeout 등)
31+
*/
2032
@Async
2133
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
34+
@Retryable(
35+
maxAttempts = 3,
36+
backoff = @Backoff(delay = 1000, multiplier = 2),
37+
recover = "recoverOrderCompleted"
38+
)
2239
public void handleOrderCompleted(OrderCompletedEvent event) {
23-
try {
24-
// TODO: 실제 데이터 플랫폼 API 연동. 현재는 더미 호출로 대체.
25-
log.info("[DataPlatform] 주문 완료 전송 - orderNo: {}, memberId: {}, amount: {}",
26-
event.orderNo(), event.memberId(), event.totalPrice());
27-
} catch (Exception e) {
28-
log.error("[DataPlatform] 주문 완료 전송 실패 - orderNo: {}", event.orderNo(), e);
29-
}
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);
3066
}
3167
}

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

Lines changed: 66 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@
77
import com.loopers.infrastructure.cache.ProductLikeCountCache;
88
import lombok.RequiredArgsConstructor;
99
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;
1013
import org.springframework.scheduling.annotation.Async;
1114
import org.springframework.stereotype.Component;
1215
import org.springframework.transaction.event.TransactionPhase;
@@ -29,72 +32,96 @@ public class LikeAggregationEventListener {
2932
private final CacheInvalidationService cacheInvalidationService;
3033

3134
/**
32-
* 좋아요 이벤트 처리 (비동기)
35+
* 좋아요 이벤트 처리 (비동기 + 재시도)
3336
* - Redis 좋아요 카운트 증가
3437
* - 회원 좋아요 목록 캐시 업데이트
3538
* - 상품 캐시 무효화
39+
*
40+
* 재시도 전략:
41+
* - 최대 3회 재시도
42+
* - 초기 딜레이 100ms, 지수 백오프 (100ms → 200ms → 400ms)
43+
* - 일시적 Redis 네트워크 오류 대응
3644
*/
3745
@Async
3846
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
47+
@Retryable(
48+
maxAttempts = 3,
49+
backoff = @Backoff(delay = 100, multiplier = 2),
50+
recover = "recoverProductLiked"
51+
)
3952
public void handleProductLiked(ProductLikedEvent event) {
4053
log.info("[LikeAggregationEventListener] 좋아요 이벤트 처리 - memberId: {}, productId: {}",
4154
event.memberId(), event.productId());
4255

43-
// 각 캐시 작업을 독립적으로 처리 (하나 실패해도 나머지 계속 진행)
44-
try {
45-
productLikeCountCache.increment(event.productId());
46-
} catch (Exception e) {
47-
log.error("[LikeAggregationEventListener] 좋아요 카운트 증가 실패 - productId: {}", event.productId(), e);
48-
}
49-
50-
try {
51-
memberLikesCache.add(event.memberId(), event.productId());
52-
} catch (Exception e) {
53-
log.error("[LikeAggregationEventListener] 회원 좋아요 캐시 업데이트 실패 - memberId: {}, productId: {}",
54-
event.memberId(), event.productId(), e);
55-
}
56-
57-
try {
58-
cacheInvalidationService.invalidateOnLikeChange(event.productId(), event.brandId());
59-
} catch (Exception e) {
60-
log.error("[LikeAggregationEventListener] 캐시 무효화 실패 - productId: {}", event.productId(), e);
61-
}
56+
// 재시도 가능하도록 try-catch 제거 (예외를 위로 전파)
57+
productLikeCountCache.increment(event.productId());
58+
memberLikesCache.add(event.memberId(), event.productId());
59+
cacheInvalidationService.invalidateOnLikeChange(event.productId(), event.brandId());
6260

6361
log.debug("[LikeAggregationEventListener] 좋아요 집계 완료 - productId: {}", event.productId());
6462
}
6563

6664
/**
67-
* 좋아요 취소 이벤트 처리 (비동기)
65+
* 좋아요 취소 이벤트 처리 (비동기 + 재시도)
6866
* - Redis 좋아요 카운트 감소
6967
* - 회원 좋아요 목록 캐시 업데이트
7068
* - 상품 캐시 무효화
69+
*
70+
* 재시도 전략:
71+
* - 최대 3회 재시도
72+
* - 초기 딜레이 100ms, 지수 백오프 (100ms → 200ms → 400ms)
73+
* - 일시적 Redis 네트워크 오류 대응
7174
*/
7275
@Async
7376
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
77+
@Retryable(
78+
maxAttempts = 3,
79+
backoff = @Backoff(delay = 100, multiplier = 2),
80+
recover = "recoverProductUnliked"
81+
)
7482
public void handleProductUnliked(ProductUnlikedEvent event) {
7583
log.info("[LikeAggregationEventListener] 좋아요 취소 이벤트 처리 - memberId: {}, productId: {}",
7684
event.memberId(), event.productId());
7785

78-
// 각 캐시 작업을 독립적으로 처리 (하나 실패해도 나머지 계속 진행)
79-
try {
80-
productLikeCountCache.decrement(event.productId());
81-
} catch (Exception e) {
82-
log.error("[LikeAggregationEventListener] 좋아요 카운트 감소 실패 - productId: {}", event.productId(), e);
83-
}
86+
// 재시도 가능하도록 try-catch 제거 (예외를 위로 전파)
87+
productLikeCountCache.decrement(event.productId());
88+
memberLikesCache.remove(event.memberId(), event.productId());
89+
cacheInvalidationService.invalidateOnLikeChange(event.productId(), event.brandId());
8490

85-
try {
86-
memberLikesCache.remove(event.memberId(), event.productId());
87-
} catch (Exception e) {
88-
log.error("[LikeAggregationEventListener] 회원 좋아요 캐시 업데이트 실패 - memberId: {}, productId: {}",
89-
event.memberId(), event.productId(), e);
90-
}
91+
log.debug("[LikeAggregationEventListener] 좋아요 취소 집계 완료 - productId: {}", event.productId());
92+
}
9193

92-
try {
93-
cacheInvalidationService.invalidateOnLikeChange(event.productId(), event.brandId());
94-
} catch (Exception e) {
95-
log.error("[LikeAggregationEventListener] 캐시 무효화 실패 - productId: {}", event.productId(), e);
96-
}
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);
97103

98-
log.debug("[LikeAggregationEventListener] 좋아요 취소 집계 완료 - productId: {}", event.productId());
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);
99126
}
100127
}

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

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import com.loopers.application.event.order.OrderCompletedEvent;
55
import com.loopers.domain.order.Order;
66
import com.loopers.domain.order.repository.OrderRepository;
7+
import com.loopers.domain.product.repository.ProductRepository;
78
import com.loopers.support.error.CoreException;
89
import com.loopers.support.error.ErrorType;
910
import lombok.RequiredArgsConstructor;
@@ -21,6 +22,7 @@
2122
public class OrderStatusEventListener {
2223

2324
private final OrderRepository orderRepository;
25+
private final ProductRepository productRepository;
2426
private final ApplicationEventPublisher eventPublisher;
2527

2628
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
@@ -54,7 +56,56 @@ public void handlePaymentCompleted(PaymentCompletedEvent event) {
5456
} else if (event.isFailed()) {
5557
log.warn("[OrderStatusEventListener] 결제 실패 - orderNo: {}, reason: {}",
5658
event.orderNo(), event.failureReason());
57-
// TODO: 결제 실패 처리 (알림, 주문 취소 등)
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: 심각한 오류 알림 (수동 처리 필요)
58109
}
59110
}
60111
}

apps/commerce-api/src/test/java/com/loopers/application/event/listener/LikeAggregationEventListenerTest.java

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
import java.time.LocalDateTime;
1313

14+
import static org.assertj.core.api.Assertions.assertThat;
1415
import static org.mockito.Mockito.*;
1516

1617
@DisplayName("좋아요 집계 이벤트 리스너 테스트")
@@ -75,7 +76,7 @@ void setUp() {
7576
}
7677

7778
@Test
78-
@DisplayName("Redis 업데이트 실패 시 예외가 발생하지 않는다")
79+
@DisplayName("Redis 업데이트 실패 시 재시도 후 예외가 발생한다")
7980
void Redis_업데이트_실패_시_예외_처리() {
8081
// given
8182
ProductLikedEvent event = new ProductLikedEvent(
@@ -86,12 +87,17 @@ void setUp() {
8687
doThrow(new RuntimeException("Redis connection failed"))
8788
.when(productLikeCountCache).increment(anyLong());
8889

89-
// when & then - 예외가 전파되지 않아야 함 (로그만 남김)
90-
// 실제로는 listener 내부에서 try-catch로 처리하므로 예외가 발생하지 않음
91-
listener.handleProductLiked(event);
92-
93-
// Redis 실패해도 나머지 캐시는 업데이트 시도
94-
verify(memberLikesCache, times(1)).add(1L, 100L);
95-
verify(cacheInvalidationService, times(1)).invalidateOnLikeChange(100L, 10L);
90+
// when & then - @Retryable로 인해 재시도 후 예외 발생
91+
try {
92+
listener.handleProductLiked(event);
93+
// 예외가 발생해야 함
94+
assert false : "Exception should be thrown after retries";
95+
} catch (RuntimeException e) {
96+
// 예외가 발생하는 것이 정상
97+
assertThat(e.getMessage()).contains("Redis connection failed");
98+
}
99+
100+
// increment에서 예외 발생하므로 나머지는 실행되지 않음
101+
verify(productLikeCountCache, atLeastOnce()).increment(100L);
96102
}
97103
}

apps/commerce-api/src/test/java/com/loopers/application/event/listener/OrderStatusEventListenerTest.java

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ class OrderStatusEventListenerTest {
2626

2727
private OrderStatusEventListener orderStatusEventListener;
2828
private InMemoryOrderRepository orderRepository;
29+
private InMemoryProductRepository productRepository;
2930
private ApplicationEventPublisher eventPublisher;
3031

3132
private Order testOrder;
@@ -34,15 +35,18 @@ class OrderStatusEventListenerTest {
3435
@BeforeEach
3536
void setUp() {
3637
orderRepository = new InMemoryOrderRepository();
38+
productRepository = new InMemoryProductRepository();
3739
eventPublisher = mock(ApplicationEventPublisher.class);
3840

3941
orderStatusEventListener = new OrderStatusEventListener(
4042
orderRepository,
43+
productRepository,
4144
eventPublisher
4245
);
4346

4447
// 테스트 상품 생성
4548
testProduct = new Product(1L, "테스트상품", null, Money.of(10000), Stock.of(100));
49+
productRepository.save(testProduct);
4650

4751
// 테스트 주문 생성 (결제 대기 상태)
4852
OrderItem orderItem = new OrderItem(testProduct.getId(), 1, testProduct.getPrice());
@@ -74,9 +78,11 @@ void setUp() {
7478
}
7579

7680
@Test
77-
@DisplayName("결제 실패 시 주문 상태는 변경되지 않는다")
78-
void 결제_실패_시_주문_상태_유지() {
81+
@DisplayName("결제 실패 시 주문이 취소되고 재고가 복구된다")
82+
void 결제_실패_시_주문_취소_및_재고_복구() {
7983
// given
84+
int originalStock = testProduct.getStock().getQuantity();
85+
8086
PaymentCompletedEvent event = new PaymentCompletedEvent(
8187
testOrder.getOrderNo(),
8288
1L,
@@ -85,14 +91,17 @@ void setUp() {
8591
LocalDateTime.now()
8692
);
8793

88-
OrderStatus originalStatus = testOrder.getStatus();
89-
9094
// when
9195
orderStatusEventListener.handlePaymentCompleted(event);
9296

9397
// then
9498
Order order = orderRepository.findByOrderNo(testOrder.getOrderNo()).orElseThrow();
95-
assertThat(order.getStatus()).isEqualTo(originalStatus);
99+
assertThat(order.getStatus()).isEqualTo(OrderStatus.CANCELLED);
100+
assertThat(order.isCancelled()).isTrue();
101+
102+
// 재고가 복구되어야 함
103+
Product product = productRepository.findById(testProduct.getId()).orElseThrow();
104+
assertThat(product.getStock().getQuantity()).isEqualTo(originalStock + 1);
96105
}
97106

98107
@Test

0 commit comments

Comments
 (0)