Skip to content

Commit ed219b7

Browse files
authored
Merge pull request #181 from yeonjiyeon/feature/week7
[volume-7] Decoupling with Event
2 parents f8dc399 + b19232f commit ed219b7

38 files changed

Lines changed: 939 additions & 67 deletions
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
package com.loopers.application.coupon;
2+
3+
import com.loopers.application.event.FailedEventStore;
4+
import com.loopers.application.payment.PaymentEvent.PaymentCompletedEvent;
5+
import com.loopers.domain.coupon.CouponService;
6+
import lombok.RequiredArgsConstructor;
7+
import org.springframework.orm.ObjectOptimisticLockingFailureException;
8+
import org.springframework.stereotype.Component;
9+
import org.springframework.transaction.annotation.Transactional;
10+
import org.springframework.transaction.event.TransactionPhase;
11+
import org.springframework.transaction.event.TransactionalEventListener;
12+
13+
@Component
14+
@RequiredArgsConstructor
15+
public class CouponUsageEventListener {
16+
private final CouponService couponService;
17+
private final FailedEventStore failedEventStore;
18+
19+
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
20+
@Transactional
21+
public void handlePaymentCompletedEvent(PaymentCompletedEvent event) {
22+
23+
if (event.couponId() == null) return;
24+
25+
try {
26+
couponService.confirmCouponUsage(event.couponId());
27+
28+
} catch (ObjectOptimisticLockingFailureException e) {
29+
failedEventStore.scheduleRetry(event, "Coupon Lock Conflict on Confirmation");
30+
31+
} catch (Exception e) {
32+
failedEventStore.scheduleRetry(event, "Coupon confirmation error: " + e.getMessage());
33+
}
34+
}
35+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
package com.loopers.application.dataplatform;
2+
3+
import com.loopers.application.payment.PaymentEvent.PaymentCompletedEvent;
4+
import com.loopers.domain.dataplatform.DataPlatformGateway;
5+
import lombok.RequiredArgsConstructor;
6+
import org.springframework.scheduling.annotation.Async;
7+
import org.springframework.stereotype.Component;
8+
import org.springframework.transaction.event.TransactionPhase;
9+
import org.springframework.transaction.event.TransactionalEventListener;
10+
11+
@Component
12+
@RequiredArgsConstructor
13+
public class DataPlatformEventListener {
14+
15+
private final DataPlatformGateway dataPlatformGateway;
16+
17+
@Async
18+
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
19+
public void handleOrderCreatedEvent(PaymentCompletedEvent event) {
20+
dataPlatformGateway.sendPaymentData(event.orderId(), event.paymentId());
21+
}
22+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
package com.loopers.application.event;
2+
3+
import com.fasterxml.jackson.databind.ObjectMapper;
4+
import com.loopers.domain.event.DomainEvent;
5+
import com.loopers.domain.event.FailedEvent;
6+
import com.loopers.infrastructure.event.FailedEventRepository;
7+
import lombok.RequiredArgsConstructor;
8+
import lombok.extern.slf4j.Slf4j;
9+
import org.springframework.context.ApplicationEventPublisher;
10+
import org.springframework.scheduling.annotation.Scheduled;
11+
import org.springframework.stereotype.Component;
12+
import org.springframework.transaction.annotation.Transactional;
13+
14+
import java.util.List;
15+
16+
@Component
17+
@RequiredArgsConstructor
18+
public class DeadLetterQueueProcessor {
19+
20+
private final FailedEventRepository failedEventRepository;
21+
private final ApplicationEventPublisher eventPublisher;
22+
private final ObjectMapper objectMapper;
23+
24+
private static final int MAX_RETRY_COUNT = 5;
25+
26+
@Scheduled(fixedRate = 300000)
27+
@Transactional
28+
public void retryFailedEvents() {
29+
30+
List<FailedEvent> eventsToRetry = failedEventRepository.findByRetryCountLessThan(MAX_RETRY_COUNT);
31+
32+
if (eventsToRetry.isEmpty()) return;
33+
34+
for (FailedEvent failedEvent : eventsToRetry) {
35+
try {
36+
Class<?> eventClass = Class.forName(failedEvent.getEventType());
37+
DomainEvent originalEvent = (DomainEvent) objectMapper.readValue(
38+
failedEvent.getEventPayload(), eventClass);
39+
40+
eventPublisher.publishEvent(originalEvent);
41+
failedEventRepository.delete(failedEvent);
42+
43+
} catch (Exception e) {
44+
failedEvent.incrementRetryCount();
45+
failedEventRepository.save(failedEvent);
46+
}
47+
}
48+
}
49+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
package com.loopers.application.event;
2+
3+
import com.loopers.domain.event.DomainEvent;
4+
5+
public interface FailedEventScheduler {
6+
7+
<T extends DomainEvent> void scheduleRetry(T event, String reason);
8+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
package com.loopers.application.event;
2+
3+
import com.fasterxml.jackson.core.JsonProcessingException;
4+
import com.fasterxml.jackson.databind.ObjectMapper;
5+
import com.loopers.application.monitoring.port.MonitoringService;
6+
import com.loopers.domain.event.DomainEvent;
7+
import com.loopers.domain.event.FailedEvent;
8+
import com.loopers.infrastructure.event.FailedEventRepository;
9+
import java.time.LocalDateTime;
10+
import lombok.RequiredArgsConstructor;
11+
import org.springframework.stereotype.Component;
12+
import org.springframework.transaction.annotation.Transactional;
13+
14+
@Component
15+
@RequiredArgsConstructor
16+
public class FailedEventStore implements FailedEventScheduler {
17+
18+
private final FailedEventRepository failedEventRepository;
19+
private final ObjectMapper objectMapper;
20+
private final MonitoringService monitoringService;
21+
22+
@Transactional
23+
@Override
24+
public <T extends DomainEvent> void scheduleRetry(T event, String reason) {
25+
String payload;
26+
27+
try {
28+
payload = objectMapper.writeValueAsString(event);
29+
} catch (JsonProcessingException e) {
30+
return;
31+
}
32+
33+
FailedEvent failedEvent = new FailedEvent(
34+
event.getClass().getName(),
35+
payload,
36+
reason,
37+
0,
38+
LocalDateTime.now()
39+
);
40+
41+
try {
42+
failedEventRepository.save(failedEvent);
43+
monitoringService.incrementMetric("dlq.event_saved_total", "type:" + failedEvent.getEventType());
44+
45+
} catch (Exception e) {
46+
monitoringService.sendCriticalAlert(
47+
"CRITICAL: DLQ 저장소 장애. 이벤트 유실 위험 발생! 사유: " + e.getMessage(), e);
48+
}
49+
}
50+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
package com.loopers.application.like;
2+
3+
import com.loopers.domain.event.UserActionTrackEvent;
4+
import java.time.ZonedDateTime;
5+
import java.util.Map;
6+
7+
public record LikeActionTrackEvent(
8+
Long userId,
9+
Long productId,
10+
String action,
11+
12+
ZonedDateTime eventTime,
13+
Map<String, Object> properties
14+
15+
) implements UserActionTrackEvent {
16+
17+
public LikeActionTrackEvent(Long userId, Long productId, String action) {
18+
this(userId, productId, action, ZonedDateTime.now(), Map.of());
19+
}
20+
21+
@Override
22+
public String eventType() {
23+
return "LIKE_ACTION";
24+
}
25+
26+
@Override
27+
public Map<String, Object> getProperties() {
28+
return properties;
29+
}
30+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
package com.loopers.application.like;
2+
3+
import com.loopers.domain.event.DomainEvent;
4+
5+
public record LikeCreatedEvent(
6+
long productId,
7+
int increment
8+
) implements DomainEvent {
9+
10+
}

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

Lines changed: 25 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -6,75 +6,55 @@
66
import com.loopers.domain.product.ProductService;
77
import java.util.Optional;
88
import lombok.RequiredArgsConstructor;
9-
import org.springframework.orm.ObjectOptimisticLockingFailureException;
9+
import org.springframework.context.ApplicationEventPublisher;
1010
import org.springframework.stereotype.Component;
11-
import org.springframework.transaction.support.TransactionTemplate;
11+
import org.springframework.transaction.annotation.Transactional;
1212

1313
@RequiredArgsConstructor
1414
@Component
1515
public class LikeFacade {
1616

1717
private final ProductService productService;
1818
private final LikeService likeService;
19-
private final TransactionTemplate transactionTemplate;
20-
21-
private static final int RETRY_COUNT = 30;
19+
private final ApplicationEventPublisher eventPublisher;
2220

21+
@Transactional
2322
public LikeInfo like(long userId, long productId) {
2423
Optional<Like> existingLike = likeService.findLike(userId, productId);
24+
Product product = productService.getProduct(productId);
2525

2626
if (existingLike.isPresent()) {
27-
Product product = productService.getProduct(productId);
2827
return LikeInfo.from(existingLike.get(), product.getLikeCount());
2928
}
3029

31-
for (int i = 0; i < RETRY_COUNT; i++) {
32-
try {
33-
return transactionTemplate.execute(status -> {
34-
Like newLike = likeService.save(userId, productId);
35-
int updatedLikeCount = productService.increaseLikeCount(productId);
36-
return LikeInfo.from(newLike, updatedLikeCount);
37-
});
38-
39-
} catch (ObjectOptimisticLockingFailureException e) {
40-
if (i == RETRY_COUNT - 1) {
41-
throw e;
42-
}
43-
sleep(50);
44-
}
45-
}
46-
throw new IllegalStateException("좋아요 처리 재시도 횟수를 초과했습니다.");
30+
Like newLike = likeService.save(userId, productId);
31+
32+
eventPublisher.publishEvent(new LikeCreatedEvent(productId, 1));
33+
34+
eventPublisher.publishEvent(new LikeActionTrackEvent(
35+
userId,
36+
productId,
37+
"LIKE"
38+
));
39+
40+
return LikeInfo.from(newLike, product.getLikeCount());
4741
}
4842

43+
@Transactional
4944
public int unLike(long userId, long productId) {
50-
for (int i = 0; i < RETRY_COUNT; i++) {
51-
try {
5245

53-
return transactionTemplate.execute(status -> {
46+
likeService.unLike(userId, productId);
5447

55-
likeService.unLike(userId, productId);
48+
eventPublisher.publishEvent(new LikeCreatedEvent(productId, -1));
5649

57-
return productService.decreaseLikeCount(productId);
50+
eventPublisher.publishEvent(new LikeActionTrackEvent(
51+
userId,
52+
productId,
53+
"UNLIKE"
54+
));
5855

59-
});
56+
return productService.getProduct(productId).getLikeCount();
6057

61-
} catch (ObjectOptimisticLockingFailureException e) {
62-
63-
if (i == RETRY_COUNT - 1) {
64-
throw e;
65-
}
66-
sleep(50);
67-
}
68-
}
69-
throw new IllegalStateException("싫어요 처리 재시도 횟수를 초과했습니다.");
7058
}
7159

72-
private void sleep(long millis) {
73-
try {
74-
Thread.sleep(millis);
75-
} catch (InterruptedException e) {
76-
Thread.currentThread().interrupt();
77-
throw new RuntimeException(e);
78-
}
79-
}
8060
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
package com.loopers.application.monitoring.port;
2+
3+
import lombok.extern.slf4j.Slf4j;
4+
import org.springframework.stereotype.Component;
5+
6+
@Slf4j
7+
@Component
8+
public class DummyMonitoringAdapter implements MonitoringService {
9+
@Override
10+
public void incrementMetric(String name, String tag) {
11+
log.info("[METRIC_DUMMY] Increment | Name: {} | Tag: {}", name, tag);
12+
}
13+
14+
@Override
15+
public void sendCriticalAlert(String message, Throwable t) {
16+
log.error("[ALERT_DUMMY] CRITICAL ALERT: {}", message, t);
17+
}
18+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
package com.loopers.application.monitoring.port;
2+
3+
public interface MonitoringService {
4+
5+
void incrementMetric(String name, String tag);
6+
7+
void sendCriticalAlert(String message, Throwable t);
8+
}

0 commit comments

Comments
 (0)