Skip to content

Commit bf4fd3c

Browse files
committed
feature: 좋아요 이벤트 처리 및 실패 이벤트 재시도 로직 추가
- `LikeCreatedEvent`를 통해 좋아요 이벤트 수집 및 비동기 처리 도입 - `FailedEvent` 및 관련 저장소/스케줄러 추가로 실패한 이벤트 관리 및 재시도 가능 - `LikeCountAggregateListener`로 좋아요 수 상태 비동기 업데이트 처리 - `DeadLetterQueueProcessor`를 통해 실패 이벤트 주기적 재 처리 - 트랜잭션 관리 및 이벤트 기반 구조로 서비스 안정성 강화
1 parent f311ef8 commit bf4fd3c

9 files changed

Lines changed: 229 additions & 45 deletions

File tree

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
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 java.util.List;
8+
import lombok.RequiredArgsConstructor;
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+
@Component
15+
@RequiredArgsConstructor
16+
public class DeadLetterQueueProcessor {
17+
18+
private final FailedEventRepository failedEventRepository;
19+
private final ApplicationEventPublisher eventPublisher;
20+
private final ObjectMapper objectMapper;
21+
22+
private static final int MAX_RETRY_COUNT = 5;
23+
24+
@Scheduled(fixedRate = 300000)
25+
@Transactional
26+
public void retryFailedEvents() {
27+
28+
List<FailedEvent> eventsToRetry = failedEventRepository.findByRetryCountLessThan(MAX_RETRY_COUNT);
29+
30+
if (eventsToRetry.isEmpty()) return;
31+
32+
for (FailedEvent failedEvent : eventsToRetry) {
33+
try {
34+
Class<?> eventClass = Class.forName(failedEvent.getEventType());
35+
DomainEvent originalEvent = (DomainEvent) objectMapper.readValue(
36+
failedEvent.getEventPayload(), eventClass);
37+
38+
eventPublisher.publishEvent(originalEvent);
39+
failedEventRepository.delete(failedEvent);
40+
41+
} catch (Exception e) {
42+
failedEvent.incrementRetryCount();
43+
failedEventRepository.save(failedEvent);
44+
}
45+
}
46+
}
47+
}
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: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
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.domain.event.DomainEvent;
6+
import com.loopers.domain.event.FailedEvent;
7+
import com.loopers.infrastructure.event.FailedEventRepository;
8+
import java.time.LocalDateTime;
9+
import lombok.RequiredArgsConstructor;
10+
import org.springframework.stereotype.Component;
11+
import org.springframework.transaction.annotation.Transactional;
12+
13+
@Component
14+
@RequiredArgsConstructor
15+
public class FailedEventStore implements FailedEventScheduler {
16+
17+
private final FailedEventRepository failedEventRepository;
18+
private final ObjectMapper objectMapper;
19+
20+
@Transactional
21+
@Override
22+
public <T extends DomainEvent> void scheduleRetry(T event, String reason) {
23+
String payload;
24+
25+
try {
26+
payload = objectMapper.writeValueAsString(event);
27+
} catch (JsonProcessingException e) {
28+
return;
29+
}
30+
31+
FailedEvent failedEvent = new FailedEvent(
32+
event.getClass().getName(),
33+
payload,
34+
reason,
35+
0,
36+
LocalDateTime.now()
37+
);
38+
39+
try {
40+
failedEventRepository.save(failedEvent);
41+
} catch (Exception e) {
42+
}
43+
}
44+
}
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: 13 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -6,75 +6,43 @@
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-
});
30+
Like newLike = likeService.save(userId, productId);
3831

39-
} catch (ObjectOptimisticLockingFailureException e) {
40-
if (i == RETRY_COUNT - 1) {
41-
throw e;
42-
}
43-
sleep(50);
44-
}
45-
}
46-
throw new IllegalStateException("좋아요 처리 재시도 횟수를 초과했습니다.");
32+
eventPublisher.publishEvent(new LikeCreatedEvent(productId, 1));
33+
34+
return LikeInfo.from(newLike, product.getLikeCount());
4735
}
4836

37+
@Transactional
4938
public int unLike(long userId, long productId) {
50-
for (int i = 0; i < RETRY_COUNT; i++) {
51-
try {
52-
53-
return transactionTemplate.execute(status -> {
5439

55-
likeService.unLike(userId, productId);
40+
likeService.unLike(userId, productId);
5641

57-
return productService.decreaseLikeCount(productId);
42+
eventPublisher.publishEvent(new LikeCreatedEvent(productId, -1));
5843

59-
});
44+
return productService.getProduct(productId).getLikeCount();
6045

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

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-
}
8048
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
package com.loopers.application.product;
2+
3+
import com.loopers.application.event.FailedEventStore;
4+
import com.loopers.application.like.LikeCreatedEvent;
5+
import com.loopers.domain.product.ProductService;
6+
import lombok.RequiredArgsConstructor;
7+
import org.springframework.orm.ObjectOptimisticLockingFailureException;
8+
import org.springframework.scheduling.annotation.Async;
9+
import org.springframework.stereotype.Component;
10+
import org.springframework.transaction.annotation.Propagation;
11+
import org.springframework.transaction.annotation.Transactional;
12+
import org.springframework.transaction.event.TransactionPhase;
13+
import org.springframework.transaction.event.TransactionalEventListener;
14+
15+
@Component
16+
@RequiredArgsConstructor
17+
public class LikeCountAggregateListener {
18+
19+
private final ProductService productService;
20+
private final FailedEventStore failedEventStore;
21+
22+
23+
@Async
24+
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
25+
public void handleLikeCreatedEvent(LikeCreatedEvent event) {
26+
27+
try {
28+
performAggregation(event);
29+
30+
} catch (ObjectOptimisticLockingFailureException e) {
31+
32+
failedEventStore.scheduleRetry(event, "Optimistic Lock Conflict");
33+
34+
} catch (Exception e) {
35+
36+
failedEventStore.scheduleRetry(event, "Unexpected Error: " + e.getMessage());
37+
}
38+
}
39+
40+
@Transactional(propagation = Propagation.REQUIRES_NEW)
41+
public void performAggregation(LikeCreatedEvent event) {
42+
if (event.increment() > 0) {
43+
productService.increaseLikeCount(event.productId());
44+
} else {
45+
productService.decreaseLikeCount(event.productId());
46+
}
47+
}
48+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
package com.loopers.domain.event;
2+
3+
public interface DomainEvent {
4+
5+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
package com.loopers.domain.event;
2+
3+
import jakarta.persistence.Entity;
4+
import jakarta.persistence.GeneratedValue;
5+
import jakarta.persistence.GenerationType;
6+
import jakarta.persistence.Id;
7+
import jakarta.persistence.Lob;
8+
import java.time.LocalDateTime;
9+
import lombok.AccessLevel;
10+
import lombok.Getter;
11+
import lombok.NoArgsConstructor;
12+
13+
@Entity
14+
@Getter
15+
@NoArgsConstructor(access = AccessLevel.PROTECTED)
16+
public class FailedEvent {
17+
18+
@Id
19+
@GeneratedValue(strategy = GenerationType.IDENTITY)
20+
private Long id;
21+
22+
private String eventType;
23+
24+
@Lob
25+
private String eventPayload;
26+
27+
private String failureReason;
28+
private int retryCount;
29+
private LocalDateTime createdAt;
30+
31+
public FailedEvent(String eventType, String eventPayload, String failureReason, int retryCount, LocalDateTime createdAt) {
32+
this.eventType = eventType;
33+
this.eventPayload = eventPayload;
34+
this.failureReason = failureReason;
35+
this.retryCount = retryCount;
36+
this.createdAt = createdAt;
37+
}
38+
39+
public void incrementRetryCount() {
40+
this.retryCount++;
41+
}
42+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
package com.loopers.infrastructure.event;
2+
3+
import com.loopers.domain.event.FailedEvent;
4+
import java.util.List;
5+
import org.springframework.data.jpa.repository.JpaRepository;
6+
import org.springframework.stereotype.Repository;
7+
8+
@Repository
9+
public interface FailedEventRepository extends JpaRepository<FailedEvent, Long> {
10+
11+
List<FailedEvent> findByRetryCountLessThan(int maxRetries);
12+
}

0 commit comments

Comments
 (0)