-
Notifications
You must be signed in to change notification settings - Fork 44
Volume 7: 이벤트 파이프라인 + 선착순 쿠폰 발급 - 최숙희 #299
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
9e9493e
61ac85a
df5fb13
196bce9
dafedc1
10c99ed
539bc02
2c7cb6a
541233f
5791dda
723a328
213ca06
204542c
b9c4b7e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,40 @@ | ||
| package com.loopers.application.event; | ||
|
|
||
| import com.fasterxml.jackson.core.JsonProcessingException; | ||
| import com.fasterxml.jackson.databind.ObjectMapper; | ||
| import com.loopers.domain.event.ProductViewedEvent; | ||
| import lombok.RequiredArgsConstructor; | ||
| import lombok.extern.slf4j.Slf4j; | ||
| import org.springframework.kafka.core.KafkaTemplate; | ||
| import org.springframework.scheduling.annotation.Async; | ||
| import org.springframework.stereotype.Component; | ||
| import org.springframework.transaction.event.TransactionPhase; | ||
| import org.springframework.transaction.event.TransactionalEventListener; | ||
|
|
||
| import java.util.Map; | ||
| import java.util.UUID; | ||
|
|
||
| @Slf4j | ||
| @Component | ||
| @RequiredArgsConstructor | ||
| public class ProductViewKafkaPublisher { | ||
|
|
||
| private final KafkaTemplate<Object, Object> kafkaTemplate; | ||
| private final ObjectMapper objectMapper; | ||
|
|
||
| @Async("eventExecutor") | ||
| @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true) | ||
| public void handle(ProductViewedEvent event) { | ||
| try { | ||
| String payload = objectMapper.writeValueAsString(Map.of( | ||
| "eventId", UUID.randomUUID().toString(), | ||
| "eventType", "PRODUCT_VIEWED", | ||
| "productId", event.productId(), | ||
| "memberId", event.memberId() | ||
| )); | ||
| kafkaTemplate.send("catalog-events", String.valueOf(event.productId()), payload); | ||
| } catch (JsonProcessingException e) { | ||
| log.warn("조회수 이벤트 Kafka 발행 실패 — productId={}", event.productId(), e); | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -4,6 +4,10 @@ | |
| import com.loopers.application.coupon.CouponFacade; | ||
| import com.loopers.domain.brand.Brand; | ||
| import com.loopers.domain.brand.BrandRepository; | ||
| import com.loopers.domain.event.DomainEventPublisher; | ||
| import com.loopers.domain.event.OrderCancelledEvent; | ||
| import com.loopers.domain.event.OrderCreatedEvent; | ||
| import com.loopers.domain.event.OrderItemSnapshot; | ||
| import com.loopers.domain.order.Order; | ||
| import com.loopers.domain.order.OrderItem; | ||
| import com.loopers.domain.order.OrderRepository; | ||
|
|
@@ -32,6 +36,7 @@ public class OrderFacade { | |
| private final ProductRepository productRepository; | ||
| private final BrandRepository brandRepository; | ||
| private final CouponFacade couponFacade; | ||
| private final DomainEventPublisher domainEventPublisher; | ||
|
|
||
| @Transactional | ||
| public Order createOrder(Long memberId, List<OrderItemRequest> itemRequests) { | ||
|
|
@@ -109,6 +114,16 @@ public Order createOrder(Long memberId, List<OrderItemRequest> itemRequests, Lon | |
| couponFacade.linkCouponToOrder(resolvedCouponIssueId, order.getId()); | ||
| } | ||
|
|
||
| // 8. Outbox INSERT + 이벤트 발행 | ||
| List<OrderItemSnapshot> eventItems = order.getItems().stream() | ||
| .map(item -> new OrderItemSnapshot(item.getProductId(), item.getQuantity(), item.getProductPrice())) | ||
| .toList(); | ||
|
|
||
| domainEventPublisher.publish("order", String.valueOf(order.getId()), | ||
| "ORDER_CREATED", | ||
| Map.of("orderId", order.getId(), "memberId", memberId, "items", eventItems), | ||
| new OrderCreatedEvent(order.getId(), memberId, eventItems)); | ||
|
|
||
|
Comment on lines
+117
to
+126
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: #!/bin/bash
# DomainEventPublisherImpl 구현 확인
ast-grep --pattern $'class DomainEventPublisherImpl {
$$$
publish($$$) {
$$$
}
$$$
}'
# Kafka 전송이 AFTER_COMMIT 리스너에서 발생하는지 확인
rg -n "@TransactionalEventListener" --type=java -A 5Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 4583 🏁 Script executed: # Find DomainEventPublisher interface and implementations
rg -n "interface DomainEventPublisher|class.*DomainEventPublisher" --type=java -A 3
# Find OrderCreatedEvent and its listener
rg -n "class OrderCreatedEvent|@TransactionalEventListener.*OrderCreatedEvent" --type=java -A 5
# Check the publish method signature
rg -n "void publish\(" --type=java -B 2 -A 10Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 3992 🏁 Script executed: # Find OrderCreatedEvent listener
rg -n "OrderCreatedEvent" --type=java -B 2 -A 8
# Check if there's a listener for OrderCreatedEvent
rg -n "@TransactionalEventListener.*Order|handle.*OrderCreatedEvent" --type=java -A 10
# Look at the full DomainEventPublisherImpl
cat apps/commerce-api/src/main/java/com/loopers/infrastructure/event/DomainEventPublisherImpl.javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 4631 🏁 Script executed: # Search for any listener that might handle OrderCreatedEvent
rg -n "OrderCreatedEvent" --type=java
# Get the complete createOrder method from OrderFacade
sed -n '90,128p' apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java
# Look for Kafka publishers in the codebase
rg -n "KafkaTemplate|kafkaTemplate|sendMessage" --type=java -B 2 -A 3 | head -50Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 7452 🏁 Script executed: # Search for EventOutbox processing/polling
rg -n "EventOutbox|eventOutbox" --type=java | grep -i "process\|poll\|schedule\|publish" | head -20
# Look for scheduled methods that might process outbox
rg -n "@Scheduled|@Async" --type=java -A 5 | grep -i "outbox\|event" -B 2 -A 5
# Search for any processor or poller
rg -n "class.*Outbox.*|class.*Poller" --type=java
# Check test for DomainEventPublisher to understand expected behavior
cat apps/commerce-api/src/test/java/com/loopers/infrastructure/event/DomainEventPublisherImplTest.javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 8951 🏁 Script executed: # Search for any listener that handles Order-related events
rg -n "handle.*Order|Order.*Event" --type=java -B 2 -A 5
# Check if EventOutbox has any processor/consumer
rg -n "EventOutbox" --type=java | grep -v "test\|Repository\|domain\|event/DomainEventPublisher"
# Look for all `@TransactionalEventListener` handlers
rg -n "@TransactionalEventListener" --type=java -B 1 | grep "public void\|EventListener" | head -20Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 6468 OrderCreatedEvent 리스너 구현이 누락되었다.
코드베이스의 다른 도메인 이벤트( 필수 구현:
🤖 Prompt for AI Agents |
||
| return order; | ||
| } | ||
|
|
||
|
|
@@ -152,6 +167,16 @@ public void cancelOrder(Long orderId, Long memberId) { | |
| if (order.getCouponIssueId() != null) { | ||
| couponFacade.restoreCoupon(order.getCouponIssueId()); | ||
| } | ||
|
|
||
| // Outbox INSERT + 이벤트 발행 | ||
| List<OrderItemSnapshot> eventItems = order.getItems().stream() | ||
| .map(item -> new OrderItemSnapshot(item.getProductId(), item.getQuantity(), item.getProductPrice())) | ||
| .toList(); | ||
|
|
||
| domainEventPublisher.publish("order", String.valueOf(orderId), | ||
| "ORDER_CANCELLED", | ||
| Map.of("orderId", orderId, "memberId", memberId, "items", eventItems), | ||
| new OrderCancelledEvent(orderId, memberId, eventItems)); | ||
| } | ||
|
|
||
| public List<Order> getOrdersByMemberId(Long memberId, ZonedDateTime startAt, ZonedDateTime endAt) { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 1887
🏁 Script executed:
Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 2072
🏁 Script executed:
Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 1257
🏁 Script executed:
Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 1945
Kafka 전송 실패가 조용히 유실될 수 있다.
kafkaTemplate.send()의 반환값인ListenableFuture를 캡처하지 않아 전송 실패가 감지되지 않는다. 직렬화 실패는 warn으로 삼키고, Kafka 브로커 오류나 타임아웃 같은 send 실패는 완전히 무시된다. 결과적으로 조회수 이벤트가 손실되어도 운영자는 감지하기 어렵고, 다운스트림 조회수 집계와 상품 순위에 오차가 누적된다.다음을 적용하라:
send()결과에 콜백을 붙여 전송 실패를 메트릭으로 기록하거나 실패 큐에 적재한다.JsonProcessingException외에send()메서드가 던질 수 있는 다른 예외도 처리한다.🤖 Prompt for AI Agents