-
Notifications
You must be signed in to change notification settings - Fork 44
[volume-7] ApplicationEvent 분리 + Transactional Outbox + Kafka 기반 비동기 처리 #270
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
base: juoklee
Are you sure you want to change the base?
Changes from all commits
d48730d
cd1384c
529a2b7
b30d950
af87a41
617b996
6086feb
ab15125
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,93 @@ | ||
| package com.loopers.application.coupon; | ||
|
|
||
| import com.loopers.domain.coupon.CouponIssueRequest; | ||
| import com.loopers.domain.coupon.CouponIssueRequestReader; | ||
| import com.loopers.domain.coupon.CouponIssueRequestRepository; | ||
| import com.loopers.domain.coupon.CouponIssueRequestStatus; | ||
| import com.loopers.domain.coupon.CouponService; | ||
| import com.loopers.domain.coupon.MemberCoupon; | ||
| import com.loopers.domain.member.Member; | ||
| import com.loopers.domain.member.MemberService; | ||
| import com.loopers.domain.outbox.OutboxEventPublisher; | ||
| import com.loopers.support.error.CoreException; | ||
| import com.loopers.support.error.ErrorType; | ||
| import lombok.RequiredArgsConstructor; | ||
| import lombok.extern.slf4j.Slf4j; | ||
| import org.springframework.stereotype.Component; | ||
| import org.springframework.transaction.annotation.Transactional; | ||
|
|
||
| import java.util.List; | ||
| import java.util.Map; | ||
| import java.util.UUID; | ||
|
|
||
| @Slf4j | ||
| @RequiredArgsConstructor | ||
| @Component | ||
| public class CouponIssueFacade { | ||
|
|
||
| private final MemberService memberService; | ||
| private final CouponService couponService; | ||
| private final CouponIssueRequestRepository couponIssueRequestRepository; | ||
| private final CouponIssueRequestReader couponIssueRequestReader; | ||
| private final OutboxEventPublisher outboxEventPublisher; | ||
|
|
||
| @Transactional | ||
| public CouponIssueInfo requestAsyncIssuance(String loginId, Long couponId) { | ||
| Long memberId = getMemberId(loginId); | ||
|
|
||
| boolean alreadyRequested = couponIssueRequestReader.existsByMemberIdAndCouponIdAndStatusIn( | ||
| memberId, couponId, | ||
| List.of(CouponIssueRequestStatus.PENDING, CouponIssueRequestStatus.COMPLETED) | ||
| ); | ||
| if (alreadyRequested) { | ||
| throw new CoreException(ErrorType.CONFLICT, "이미 쿠폰 발급 요청이 존재합니다."); | ||
| } | ||
|
|
||
| String requestId = UUID.randomUUID().toString(); | ||
| CouponIssueRequest request = CouponIssueRequest.create(requestId, memberId, couponId); | ||
| couponIssueRequestRepository.save(request); | ||
|
|
||
| outboxEventPublisher.publish( | ||
| "COUPON", couponId, "COUPON_ISSUE_REQUESTED", | ||
| "coupon-issue-requests", String.valueOf(couponId), | ||
| Map.of("requestId", requestId, "memberId", memberId, "couponId", couponId) | ||
| ); | ||
|
|
||
| return CouponIssueInfo.of(request); | ||
| } | ||
|
|
||
| @Transactional | ||
| public void processIssuance(String requestId) { | ||
| CouponIssueRequest request = couponIssueRequestReader.findByRequestId(requestId) | ||
| .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "쿠폰 발급 요청을 찾을 수 없습니다.")); | ||
|
|
||
| if (request.isProcessed()) { | ||
| log.info("이미 처리된 쿠폰 발급 요청: requestId={}, status={}", requestId, request.getStatus()); | ||
| return; | ||
| } | ||
|
|
||
| try { | ||
| MemberCoupon memberCoupon = couponService.issueCoupon(request.getCouponId(), request.getMemberId()); | ||
| request.complete(memberCoupon.getId()); | ||
| log.info("쿠폰 발급 성공: requestId={}, memberCouponId={}", requestId, memberCoupon.getId()); | ||
| } catch (CoreException e) { | ||
| request.fail(e.getMessage()); | ||
| log.warn("쿠폰 발급 실패: requestId={}, reason={}", requestId, e.getMessage()); | ||
| } catch (Exception e) { | ||
| log.error("쿠폰 발급 중 예기치 않은 오류: requestId={}, error={}", requestId, e.getMessage(), e); | ||
| throw e; | ||
| } | ||
| } | ||
|
|
||
| @Transactional(readOnly = true) | ||
| public CouponIssueInfo getIssueRequest(String requestId) { | ||
| CouponIssueRequest request = couponIssueRequestReader.findByRequestId(requestId) | ||
| .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "쿠폰 발급 요청을 찾을 수 없습니다.")); | ||
| return CouponIssueInfo.of(request); | ||
| } | ||
|
|
||
| private Long getMemberId(String loginId) { | ||
| Member member = memberService.getMemberByLoginId(loginId); | ||
| return member.getId(); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,23 @@ | ||
| package com.loopers.application.coupon; | ||
|
|
||
| import com.loopers.domain.coupon.CouponIssueRequest; | ||
|
|
||
| public record CouponIssueInfo( | ||
| String requestId, | ||
| Long memberId, | ||
| Long couponId, | ||
| String status, | ||
| String failReason, | ||
| Long memberCouponId | ||
| ) { | ||
| public static CouponIssueInfo of(CouponIssueRequest request) { | ||
| return new CouponIssueInfo( | ||
| request.getRequestId(), | ||
| request.getMemberId(), | ||
| request.getCouponId(), | ||
| request.getStatus().name(), | ||
| request.getFailReason(), | ||
| request.getMemberCouponId() | ||
| ); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,42 @@ | ||
| package com.loopers.application.event; | ||
|
|
||
| import com.loopers.domain.brand.BrandService; | ||
| import com.loopers.domain.event.LikeToggledEvent; | ||
| import com.loopers.domain.like.LikeTargetType; | ||
| import com.loopers.domain.product.ProductService; | ||
| import lombok.RequiredArgsConstructor; | ||
| import lombok.extern.slf4j.Slf4j; | ||
| import org.springframework.scheduling.annotation.Async; | ||
| import org.springframework.stereotype.Component; | ||
| import org.springframework.transaction.event.TransactionPhase; | ||
| import org.springframework.transaction.event.TransactionalEventListener; | ||
|
|
||
| @Slf4j | ||
| @RequiredArgsConstructor | ||
| @Component | ||
| public class LikeEventListener { | ||
|
|
||
| private final ProductService productService; | ||
| private final BrandService brandService; | ||
|
|
||
| @Async("eventExecutor") | ||
| @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) | ||
| public void handleLikeToggled(LikeToggledEvent event) { | ||
| log.info("[이벤트] 좋아요 토글 - memberId={}, targetType={}, targetId={}, liked={}", | ||
| event.memberId(), event.targetType(), event.targetId(), event.liked()); | ||
|
|
||
| if (event.targetType() == LikeTargetType.PRODUCT) { | ||
| if (event.liked()) { | ||
| productService.increaseLikeCount(event.targetId()); | ||
| } else { | ||
| productService.decreaseLikeCount(event.targetId()); | ||
| } | ||
| } else if (event.targetType() == LikeTargetType.BRAND) { | ||
| if (event.liked()) { | ||
| brandService.increaseLikeCount(event.targetId()); | ||
| } else { | ||
| brandService.decreaseLikeCount(event.targetId()); | ||
| } | ||
| } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,28 @@ | ||
| package com.loopers.application.event; | ||
|
|
||
| import com.loopers.domain.event.OrderCancelledEvent; | ||
| import com.loopers.domain.event.OrderCreatedEvent; | ||
| import lombok.extern.slf4j.Slf4j; | ||
| import org.springframework.scheduling.annotation.Async; | ||
| import org.springframework.stereotype.Component; | ||
| import org.springframework.transaction.event.TransactionPhase; | ||
| import org.springframework.transaction.event.TransactionalEventListener; | ||
|
|
||
| @Slf4j | ||
| @Component | ||
| public class OrderEventListener { | ||
|
|
||
| @Async("eventExecutor") | ||
| @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) | ||
| public void handleOrderCreated(OrderCreatedEvent event) { | ||
| log.info("[이벤트] 주문 생성 - orderId={}, memberId={}, totalAmount={}, itemCount={}", | ||
| event.orderId(), event.memberId(), event.totalAmount(), event.items().size()); | ||
| } | ||
|
|
||
| @Async("eventExecutor") | ||
| @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) | ||
| public void handleOrderCancelled(OrderCancelledEvent event) { | ||
| log.info("[이벤트] 주문 취소 - orderId={}, memberId={}", | ||
| event.orderId(), event.memberId()); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,28 @@ | ||
| package com.loopers.application.event; | ||
|
|
||
| import com.loopers.domain.event.PaymentCompletedEvent; | ||
| import com.loopers.domain.event.PaymentFailedEvent; | ||
| import lombok.extern.slf4j.Slf4j; | ||
| import org.springframework.scheduling.annotation.Async; | ||
| import org.springframework.stereotype.Component; | ||
| import org.springframework.transaction.event.TransactionPhase; | ||
| import org.springframework.transaction.event.TransactionalEventListener; | ||
|
|
||
| @Slf4j | ||
| @Component | ||
| public class PaymentEventListener { | ||
|
|
||
| @Async("eventExecutor") | ||
| @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) | ||
| public void handlePaymentCompleted(PaymentCompletedEvent event) { | ||
| log.info("[이벤트] 결제 완료 - paymentId={}, orderId={}, memberId={}, amount={}", | ||
| event.paymentId(), event.orderId(), event.memberId(), event.amount()); | ||
| } | ||
|
|
||
| @Async("eventExecutor") | ||
| @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) | ||
| public void handlePaymentFailed(PaymentFailedEvent event) { | ||
| log.info("[이벤트] 결제 실패 - paymentId={}, orderId={}, memberId={}, reason={}", | ||
| event.paymentId(), event.orderId(), event.memberId(), event.reason()); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,20 @@ | ||
| package com.loopers.application.event; | ||
|
|
||
| import com.loopers.domain.event.ProductViewedEvent; | ||
| import lombok.extern.slf4j.Slf4j; | ||
| import org.springframework.scheduling.annotation.Async; | ||
| import org.springframework.stereotype.Component; | ||
| import org.springframework.transaction.event.TransactionPhase; | ||
| import org.springframework.transaction.event.TransactionalEventListener; | ||
|
|
||
| @Slf4j | ||
| @Component | ||
| public class UserActivityEventListener { | ||
|
|
||
| @Async("eventExecutor") | ||
| @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) | ||
| public void handleProductViewed(ProductViewedEvent event) { | ||
| log.info("[이벤트] 상품 조회 - memberId={}, productId={}", | ||
| event.memberId(), event.productId()); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -12,7 +12,11 @@ | |
| import com.loopers.domain.order.OrderService; | ||
| import com.loopers.domain.product.Product; | ||
| import com.loopers.domain.product.ProductService; | ||
| import com.loopers.domain.event.OrderCancelledEvent; | ||
| import com.loopers.domain.event.OrderCreatedEvent; | ||
| import com.loopers.domain.outbox.OutboxEventPublisher; | ||
| import lombok.RequiredArgsConstructor; | ||
| import org.springframework.context.ApplicationEventPublisher; | ||
| import org.springframework.stereotype.Component; | ||
| import org.springframework.transaction.annotation.Transactional; | ||
|
|
||
|
|
@@ -31,6 +35,8 @@ public class OrderFacade { | |
| private final OrderService orderService; | ||
| private final AddressService addressService; | ||
| private final CouponService couponService; | ||
| private final ApplicationEventPublisher eventPublisher; | ||
| private final OutboxEventPublisher outboxEventPublisher; | ||
|
|
||
| @Transactional | ||
| public OrderInfo createOrder(String loginId, Long addressId, Long memberCouponId, | ||
|
|
@@ -89,6 +95,17 @@ public OrderInfo createOrder(String loginId, Long addressId, Long memberCouponId | |
| // 5. 주문 항목 생성 | ||
| List<OrderItem> items = orderService.createOrderItems(order.getId(), commands); | ||
|
|
||
| // 6. 이벤트 발행 | ||
| List<OrderCreatedEvent.OrderItemSnapshot> itemSnapshots = commands.stream() | ||
| .map(cmd -> new OrderCreatedEvent.OrderItemSnapshot( | ||
| cmd.productId(), cmd.productName(), cmd.productPrice(), cmd.quantity())) | ||
| .toList(); | ||
| OrderCreatedEvent orderCreatedEvent = new OrderCreatedEvent( | ||
| order.getId(), memberId, order.getTotalAmount(), itemSnapshots); | ||
| eventPublisher.publishEvent(orderCreatedEvent); | ||
| outboxEventPublisher.publish("ORDER", order.getId(), "ORDER_CREATED", | ||
| "order-events", String.valueOf(order.getId()), orderCreatedEvent); | ||
|
Comment on lines
+99
to
+107
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. 할인 주문의 이벤트 금액 기준이 서로 달라 downstream 집계가 어긋날 수 있다. 여기서 담는 🤖 Prompt for AI Agents |
||
|
|
||
| return OrderInfo.of(order, items); | ||
| } | ||
|
|
||
|
|
@@ -113,6 +130,9 @@ public void cancelOrder(String loginId, Long orderId) { | |
| if (result.order().getMemberCouponId() != null) { | ||
| couponService.restoreCoupon(result.order().getMemberCouponId()); | ||
| } | ||
|
|
||
| // 이벤트 발행 | ||
| eventPublisher.publishEvent(new OrderCancelledEvent(orderId, memberId)); | ||
| } | ||
|
|
||
| public OrderInfo updateShippingAddress(String loginId, Long orderId, | ||
|
|
||
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.
상품 조회 이벤트를 INFO 로그로 남기지 않는 편이 안전하다.
상품 조회는 고트래픽 경로라서 현재처럼
memberId까지 INFO로 남기면 로그 비용이 빠르게 커지고, 사용자 식별자가 장기 보관 로그에 남는다. 운영 로그는 DEBUG로 낮추거나 샘플링하고, 꼭 필요하면 식별자는 제거한 별도 메트릭/감사 경로로 보내는 편이 안전하다. 추가 테스트로는 appender 기반 테스트를 넣어 INFO 레벨 로그에 사용자 식별자가 출력되지 않는지 검증해야 한다.수정 예시
As per coding guidelines
**/*.java: 로깅 시 민감정보 노출 가능성을 점검한다.📝 Committable suggestion
🤖 Prompt for AI Agents