diff --git a/apps/commerce-api/build.gradle.kts b/apps/commerce-api/build.gradle.kts index ede6119a9..1b508858e 100644 --- a/apps/commerce-api/build.gradle.kts +++ b/apps/commerce-api/build.gradle.kts @@ -2,6 +2,7 @@ dependencies { // add-ons implementation(project(":modules:jpa")) implementation(project(":modules:redis")) + implementation(project(":modules:kafka")) implementation(project(":supports:jackson")) implementation(project(":supports:logging")) implementation(project(":supports:monitoring")) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponFacade.java index 4289421d4..95e20ce02 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponFacade.java @@ -1,12 +1,18 @@ package com.loopers.application.coupon; +import com.loopers.application.event.CouponIssueRequestedEvent; +import com.loopers.confg.kafka.KafkaTopics; import com.loopers.application.user.UserService; +import com.loopers.infrastructure.outbox.OutboxEventService; import com.loopers.domain.coupon.Coupon; +import com.loopers.domain.coupon.CouponIssueRequest; +import com.loopers.domain.coupon.CouponIssueRequestRepository; import com.loopers.domain.coupon.IssuedCoupon; import com.loopers.domain.user.User; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Component; @@ -14,6 +20,8 @@ import java.util.List; import java.util.Map; +import java.util.Optional; +import java.util.UUID; import java.util.function.Function; import java.util.stream.Collectors; @@ -24,6 +32,9 @@ public class CouponFacade { private final CouponService couponService; private final IssuedCouponService issuedCouponService; private final UserService userService; + private final CouponIssueRequestRepository couponIssueRequestRepository; + private final ApplicationEventPublisher eventPublisher; + private final OutboxEventService outboxEventService; // Command @@ -51,6 +62,31 @@ public IssuedCouponInfo issueCoupon(Long couponId, Long userId) { return IssuedCouponInfo.from(issuedCoupon); } + @Transactional + public CouponIssueRequestInfo issueAsync(Long couponId, Long userId) { + couponService.validateActiveCoupon(couponId); + + Optional existing = couponIssueRequestRepository.findByCouponIdAndUserId(couponId, userId); + if (existing.isPresent()) { + return CouponIssueRequestInfo.from(existing.get()); + } + + String eventId = UUID.randomUUID().toString(); + CouponIssueRequest request = couponIssueRequestRepository.save( + CouponIssueRequest.create(eventId, couponId, userId)); + outboxEventService.saveAndPublish("coupon.issue.requested", "Coupon", + String.valueOf(couponId), KafkaTopics.COUPON_ISSUE_REQUESTS, + new CouponIssueRequestedEvent(eventId, couponId, userId)); + return CouponIssueRequestInfo.from(request); + } + + @Transactional(readOnly = true) + public CouponIssueRequestInfo getIssueStatus(Long couponId, Long userId) { + CouponIssueRequest request = couponIssueRequestRepository.findByCouponIdAndUserId(couponId, userId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "발급 요청이 존재하지 않습니다")); + return CouponIssueRequestInfo.from(request); + } + @Transactional public CouponInfo updateInfo(Long couponId, CouponCommand.UpdateInfo command) { if (command.type() != null) { diff --git a/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponIssueRequestInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponIssueRequestInfo.java new file mode 100644 index 000000000..175f17642 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponIssueRequestInfo.java @@ -0,0 +1,22 @@ +package com.loopers.application.coupon; + +import com.loopers.domain.coupon.CouponIssueRequest; +import com.loopers.domain.coupon.CouponIssueStatus; + +public record CouponIssueRequestInfo( + Long requestId, + Long couponId, + Long userId, + CouponIssueStatus status, + String rejectReason +) { + public static CouponIssueRequestInfo from(CouponIssueRequest request) { + return new CouponIssueRequestInfo( + request.getId(), + request.getCouponId(), + request.getUserId(), + request.getStatus(), + request.getRejectReason() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/event/CouponIssueRequestedEvent.java b/apps/commerce-api/src/main/java/com/loopers/application/event/CouponIssueRequestedEvent.java new file mode 100644 index 000000000..27e9abe59 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/event/CouponIssueRequestedEvent.java @@ -0,0 +1,5 @@ +package com.loopers.application.event; + + +public record CouponIssueRequestedEvent(String eventId, Long couponId, Long userId) { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/event/LikeCountEventHandler.java b/apps/commerce-api/src/main/java/com/loopers/application/event/LikeCountEventHandler.java new file mode 100644 index 000000000..781e8fc9f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/event/LikeCountEventHandler.java @@ -0,0 +1,61 @@ +package com.loopers.application.event; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.application.product.ProductService; +import com.loopers.confg.kafka.KafkaTopics; +import com.loopers.infrastructure.product.ProductCacheManager; +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; + +@Slf4j +@Component +@RequiredArgsConstructor +public class LikeCountEventHandler { + + private final ProductService productService; + private final ProductCacheManager productCacheManager; + private final KafkaTemplate kafkaTemplate; + private final ObjectMapper objectMapper; + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + @Async + public void handleLiked(ProductLikedEvent event) { + try { + productService.incrementLikeCount(event.productId()); + productCacheManager.evictDetail(event.productId()); + publishToKafka("product.liked", event.productId(), event); + } catch (Exception e) { + log.error("좋아요 집계 실패: productId={}", event.productId(), e); + } + } + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + @Async + public void handleUnliked(ProductUnlikedEvent event) { + try { + productService.decrementLikeCountIfPositive(event.productId()); + productCacheManager.evictDetail(event.productId()); + publishToKafka("product.unliked", event.productId(), event); + } catch (Exception e) { + log.error("좋아요 취소 집계 실패: productId={}", event.productId(), e); + } + } + + /** + * 비핵심 지표(좋아요)는 Outbox 없이 직접 Kafka fire-and-forget. + * 유실돼도 비즈니스 불변식이 깨지지 않는다. product_metrics 근사치로 충분. + */ + private void publishToKafka(String eventType, Long productId, Object event) { + try { + String payload = objectMapper.writeValueAsString(event); + kafkaTemplate.send(KafkaTopics.CATALOG_EVENTS, String.valueOf(productId), payload); + } catch (Exception e) { + log.warn("Kafka 직접 발행 실패 (fire-and-forget): eventType={}, productId={}", eventType, productId, e); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/event/PaymentCanceledEvent.java b/apps/commerce-api/src/main/java/com/loopers/application/event/PaymentCanceledEvent.java new file mode 100644 index 000000000..024dc1b8e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/event/PaymentCanceledEvent.java @@ -0,0 +1,5 @@ +package com.loopers.application.event; + + +public record PaymentCanceledEvent(Long paymentId, Long orderId, Long userId) { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/event/PaymentCompletedEvent.java b/apps/commerce-api/src/main/java/com/loopers/application/event/PaymentCompletedEvent.java new file mode 100644 index 000000000..945a6563a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/event/PaymentCompletedEvent.java @@ -0,0 +1,7 @@ +package com.loopers.application.event; + + +import java.math.BigDecimal; + +public record PaymentCompletedEvent(Long paymentId, Long orderId, Long userId, BigDecimal amount) { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/event/PaymentFailedEvent.java b/apps/commerce-api/src/main/java/com/loopers/application/event/PaymentFailedEvent.java new file mode 100644 index 000000000..fec958f76 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/event/PaymentFailedEvent.java @@ -0,0 +1,5 @@ +package com.loopers.application.event; + + +public record PaymentFailedEvent(Long paymentId, Long orderId, Long userId, String reason) { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/event/ProductLikedEvent.java b/apps/commerce-api/src/main/java/com/loopers/application/event/ProductLikedEvent.java new file mode 100644 index 000000000..8fdecfe0e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/event/ProductLikedEvent.java @@ -0,0 +1,5 @@ +package com.loopers.application.event; + + +public record ProductLikedEvent(Long userId, Long productId) { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/event/ProductUnlikedEvent.java b/apps/commerce-api/src/main/java/com/loopers/application/event/ProductUnlikedEvent.java new file mode 100644 index 000000000..6ab0e6832 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/event/ProductUnlikedEvent.java @@ -0,0 +1,5 @@ +package com.loopers.application.event; + + +public record ProductUnlikedEvent(Long userId, Long productId) { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/event/ProductViewedEvent.java b/apps/commerce-api/src/main/java/com/loopers/application/event/ProductViewedEvent.java new file mode 100644 index 000000000..2e9e4d1ba --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/event/ProductViewedEvent.java @@ -0,0 +1,4 @@ +package com.loopers.application.event; + +public record ProductViewedEvent(Long userId, Long productId) { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/event/UserActivityEventHandler.java b/apps/commerce-api/src/main/java/com/loopers/application/event/UserActivityEventHandler.java new file mode 100644 index 000000000..4f7431d74 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/event/UserActivityEventHandler.java @@ -0,0 +1,55 @@ +package com.loopers.application.event; + +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 UserActivityEventHandler { + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + @Async + public void handleProductViewed(ProductViewedEvent event) { + try { + log.info("상품 조회: userId={}, productId={}", event.userId(), event.productId()); + } catch (Exception e) { + log.error("상품 조회 로깅 실패: productId={}", event.productId(), e); + } + } + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + @Async + public void handlePaymentCompleted(PaymentCompletedEvent event) { + try { + log.info("결제 완료: paymentId={}, orderId={}, userId={}, amount={}", + event.paymentId(), event.orderId(), event.userId(), event.amount()); + } catch (Exception e) { + log.error("결제 완료 로깅 실패: paymentId={}", event.paymentId(), e); + } + } + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + @Async + public void handlePaymentFailed(PaymentFailedEvent event) { + try { + log.info("결제 실패: paymentId={}, orderId={}, userId={}, reason={}", + event.paymentId(), event.orderId(), event.userId(), event.reason()); + } catch (Exception e) { + log.error("결제 실패 로깅 실패: paymentId={}", event.paymentId(), e); + } + } + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + @Async + public void handlePaymentCanceled(PaymentCanceledEvent event) { + try { + log.info("결제 취소: paymentId={}, orderId={}, userId={}", + event.paymentId(), event.orderId(), event.userId()); + } catch (Exception e) { + log.error("결제 취소 로깅 실패: paymentId={}", event.paymentId(), e); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java index 76bf42f87..522a2606f 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java @@ -1,6 +1,8 @@ package com.loopers.application.like; import com.loopers.application.brand.BrandService; +import com.loopers.application.event.ProductLikedEvent; +import com.loopers.application.event.ProductUnlikedEvent; import com.loopers.application.product.ProductService; import com.loopers.domain.brand.Brand; import com.loopers.infrastructure.product.ProductCacheManager; @@ -9,11 +11,11 @@ import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; -import static com.loopers.support.transaction.TransactionHelper.afterCommit; import java.util.Map; import java.util.Set; @@ -27,6 +29,7 @@ public class LikeFacade { private final ProductService productService; private final BrandService brandService; private final ProductCacheManager productCacheManager; + private final ApplicationEventPublisher eventPublisher; // Command @@ -36,8 +39,7 @@ public void like(Long userId, Long productId) { boolean created = likeService.like(userId, productId); if (created) { - productService.incrementLikeCount(productId); - afterCommit(() -> productCacheManager.evictDetail(productId)); + eventPublisher.publishEvent(new ProductLikedEvent(userId, productId)); } } @@ -45,8 +47,7 @@ public void like(Long userId, Long productId) { public void unlike(Long userId, Long productId) { boolean deleted = likeService.unlike(userId, productId); if (deleted) { - productService.decrementLikeCountIfPositive(productId); - afterCommit(() -> productCacheManager.evictDetail(productId)); + eventPublisher.publishEvent(new ProductUnlikedEvent(userId, productId)); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java index 42db807fb..abdf77355 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java @@ -63,6 +63,19 @@ public OrderInfo placeOrder(Long userId, OrderCommand.Place command) { return OrderInfo.from(order); } + @Transactional + public void expireOrder(Long orderId) { + boolean expired = orderService.expireIfCreated(orderId); + if (!expired) return; + + Order order = orderService.getOrder(orderId); + stockService.releaseReserved(order.getProductQuantities()); + + if (order.hasCoupon()) { + issuedCouponService.restore(order.getIssuedCouponId()); + } + } + public void cancelOrder(Long userId, Long orderId) { Order order = orderService.getOrder(orderId); order.validateOwnership(userId); diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderService.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderService.java index d832097b0..bbc1ac45c 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderService.java @@ -59,6 +59,11 @@ public void cancelOrder(Long orderId) { order.cancel(); } + @Transactional + public boolean expireIfCreated(Long orderId) { + return orderRepository.updateStatusIfCurrent(orderId, OrderStatus.CANCELED, OrderStatus.CREATED) > 0; + } + // Query @Transactional(readOnly = true) @@ -91,4 +96,9 @@ public Page findOrdersByProductId(Long productId, Pageable pageable) { public List findOrdersByStatusWithItems(OrderStatus status) { return orderRepository.findAllByStatusWithItems(status); } + + @Transactional(readOnly = true) + public List findCreatedOlderThanWithItems(ZonedDateTime threshold) { + return orderRepository.findAllByStatusAndCreatedAtBeforeWithItems(OrderStatus.CREATED, threshold); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentProcessor.java b/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentProcessor.java index c0ea8edcf..cc63e4310 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentProcessor.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentProcessor.java @@ -1,10 +1,17 @@ package com.loopers.application.payment; import com.loopers.application.coupon.IssuedCouponService; +import com.loopers.confg.kafka.KafkaTopics; +import com.loopers.application.event.PaymentCanceledEvent; +import com.loopers.application.event.PaymentCompletedEvent; +import com.loopers.application.event.PaymentFailedEvent; import com.loopers.application.order.OrderService; import com.loopers.application.stock.StockService; import com.loopers.domain.order.Order; +import com.loopers.domain.payment.Payment; +import com.loopers.infrastructure.outbox.OutboxEventService; import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Component; @Component @@ -15,6 +22,8 @@ public class PaymentProcessor { private final StockService stockService; private final IssuedCouponService issuedCouponService; private final OrderService orderService; + private final ApplicationEventPublisher eventPublisher; + private final OutboxEventService outboxEventService; /** * PG 승인 성공 → 비즈니스 확정 (원자적) @@ -26,6 +35,13 @@ public void confirmAndSettle(Long paymentId, Long orderId) { Order order = orderService.getOrder(orderId); stockService.confirm(order.getProductQuantities()); orderService.payOrder(orderId); + + Payment payment = paymentService.getPayment(paymentId); + eventPublisher.publishEvent(new PaymentCompletedEvent( + paymentId, orderId, payment.getUserId(), payment.getAmount())); + outboxEventService.saveAndPublish("payment.completed", "Order", + String.valueOf(orderId), KafkaTopics.ORDER_EVENTS, + new PaymentCompletedEvent(paymentId, orderId, payment.getUserId(), payment.getAmount())); } /** @@ -37,10 +53,17 @@ public void failAndRelease(Long paymentId, Long orderId, String reason) { if (!paymentService.markFailedIfRequested(paymentId, reason)) return; Order order = orderService.getOrder(orderId); stockService.releaseReserved(order.getProductQuantities()); - if (order.getIssuedCouponId() != null) { + if (order.hasCoupon()) { issuedCouponService.restore(order.getIssuedCouponId()); } orderService.cancelOrder(orderId); + + Payment payment = paymentService.getPayment(paymentId); + eventPublisher.publishEvent(new PaymentFailedEvent( + paymentId, orderId, payment.getUserId(), reason)); + outboxEventService.saveAndPublish("payment.failed", "Order", + String.valueOf(orderId), KafkaTopics.ORDER_EVENTS, + new PaymentFailedEvent(paymentId, orderId, payment.getUserId(), reason)); } /** @@ -52,9 +75,16 @@ public void cancelAndCompensate(Long paymentId, Long orderId) { if (!paymentService.markCanceledIfRequested(paymentId)) return; Order order = orderService.getOrder(orderId); stockService.releaseConfirmed(order.getProductQuantities()); - if (order.getIssuedCouponId() != null) { + if (order.hasCoupon()) { issuedCouponService.restore(order.getIssuedCouponId()); } orderService.cancelOrder(orderId); + + Payment payment = paymentService.getPayment(paymentId); + eventPublisher.publishEvent(new PaymentCanceledEvent( + paymentId, orderId, payment.getUserId())); + outboxEventService.saveAndPublish("payment.canceled", "Order", + String.valueOf(orderId), KafkaTopics.ORDER_EVENTS, + new PaymentCanceledEvent(paymentId, orderId, payment.getUserId())); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java index eb10776b4..57bf43b2a 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java @@ -1,6 +1,7 @@ package com.loopers.application.product; import com.loopers.application.brand.BrandService; +import com.loopers.application.event.ProductViewedEvent; import com.loopers.application.stock.StockService; import com.loopers.domain.brand.Brand; import com.loopers.domain.stock.Stock; @@ -10,6 +11,7 @@ import com.loopers.support.error.ErrorType; import com.loopers.domain.product.Product; import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.PageRequest; @@ -31,6 +33,7 @@ public class ProductFacade { private final BrandService brandService; private final StockService stockService; private final ProductCacheManager productCacheManager; + private final ApplicationEventPublisher eventPublisher; // Command @@ -91,6 +94,7 @@ public ProductInfo getActiveDetail(Long productId) { Stock stock = stockService.getStock(productId); ProductInfo info = ProductInfo.from(product, brand.getName(), stock.getQuantity()); productCacheManager.putDetail(productId, info); + eventPublisher.publishEvent(new ProductViewedEvent(null, productId)); return info; } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponIssueRequest.java b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponIssueRequest.java new file mode 100644 index 000000000..332c341e3 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponIssueRequest.java @@ -0,0 +1,67 @@ +package com.loopers.domain.coupon; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.Getter; + +import java.time.ZonedDateTime; + +@Entity +@Table(name = "coupon_issue_requests", uniqueConstraints = { + @UniqueConstraint(name = "uk_coupon_user", columnNames = {"coupon_id", "user_id"}) +}) +@Getter +public class CouponIssueRequest { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "event_id", nullable = false, unique = true, length = 36) + private String eventId; + + @Column(name = "coupon_id", nullable = false) + private Long couponId; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 20) + private CouponIssueStatus status; + + @Column(name = "reject_reason", length = 200) + private String rejectReason; + + @Column(name = "created_at", nullable = false) + private ZonedDateTime createdAt; + + @Column(name = "processed_at") + private ZonedDateTime processedAt; + + protected CouponIssueRequest() { + } + + private CouponIssueRequest(String eventId, Long couponId, Long userId) { + this.eventId = eventId; + this.couponId = couponId; + this.userId = userId; + this.status = CouponIssueStatus.PENDING; + this.createdAt = ZonedDateTime.now(); + } + + public static CouponIssueRequest create(String eventId, Long couponId, Long userId) { + return new CouponIssueRequest(eventId, couponId, userId); + } + + public boolean isPending() { + return this.status == CouponIssueStatus.PENDING; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponIssueRequestRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponIssueRequestRepository.java new file mode 100644 index 000000000..4d4bcd0c2 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponIssueRequestRepository.java @@ -0,0 +1,10 @@ +package com.loopers.domain.coupon; + +import java.util.Optional; + +public interface CouponIssueRequestRepository { + + CouponIssueRequest save(CouponIssueRequest request); + + Optional findByCouponIdAndUserId(Long couponId, Long userId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponIssueStatus.java b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponIssueStatus.java new file mode 100644 index 000000000..6dc23340e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponIssueStatus.java @@ -0,0 +1,7 @@ +package com.loopers.domain.coupon; + +public enum CouponIssueStatus { + PENDING, + COMPLETED, + REJECTED +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java index a395cd962..39f83f49b 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java @@ -118,6 +118,10 @@ public boolean isPaid() { return this.status == OrderStatus.PAID; } + public boolean hasCoupon() { + return this.issuedCouponId != null; + } + @PrePersist protected void onCreate() { this.createdAt = ZonedDateTime.now(); diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java index 20b7fbc79..13bb5f86e 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java @@ -24,4 +24,8 @@ public interface OrderRepository { Page findAllByProductId(Long productId, Pageable pageable); List findAllByStatusWithItems(OrderStatus status); + + int updateStatusIfCurrent(Long id, OrderStatus newStatus, OrderStatus currentStatus); + + List findAllByStatusAndCreatedAtBeforeWithItems(OrderStatus status, ZonedDateTime threshold); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java index 145d817e2..b66d9d7a2 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java @@ -15,6 +15,7 @@ public interface ProductRepository { int decrementLikeCountIfPositive(Long productId); List findIdsByBrandIdForCleanup(Long brandId, int batchSize); int softDeleteByIds(List ids); + int reconcileLikeCountFromLikes(); // Query Optional findById(Long id); diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponIssueRequestJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponIssueRequestJpaRepository.java new file mode 100644 index 000000000..4011f39a7 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponIssueRequestJpaRepository.java @@ -0,0 +1,11 @@ +package com.loopers.infrastructure.coupon; + +import com.loopers.domain.coupon.CouponIssueRequest; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface CouponIssueRequestJpaRepository extends JpaRepository { + + Optional findByCouponIdAndUserId(Long couponId, Long userId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponIssueRequestRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponIssueRequestRepositoryImpl.java new file mode 100644 index 000000000..f8526ff81 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponIssueRequestRepositoryImpl.java @@ -0,0 +1,25 @@ +package com.loopers.infrastructure.coupon; + +import com.loopers.domain.coupon.CouponIssueRequest; +import com.loopers.domain.coupon.CouponIssueRequestRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +@RequiredArgsConstructor +public class CouponIssueRequestRepositoryImpl implements CouponIssueRequestRepository { + + private final CouponIssueRequestJpaRepository jpaRepository; + + @Override + public CouponIssueRequest save(CouponIssueRequest request) { + return jpaRepository.save(request); + } + + @Override + public Optional findByCouponIdAndUserId(Long couponId, Long userId) { + return jpaRepository.findByCouponIdAndUserId(couponId, userId); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java index dd7cd279e..82e2a5dd2 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java @@ -5,6 +5,7 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -49,4 +50,15 @@ Page findAllByUserIdAndStatusAndCreatedAtBetween(@Param("userId") Long us @Query("SELECT DISTINCT o FROM Order o JOIN FETCH o.orderItems WHERE o.status = :status") List findAllByStatusWithItems(@Param("status") OrderStatus status); + + @Modifying + @Query("UPDATE Order o SET o.status = :newStatus WHERE o.id = :id AND o.status = :currentStatus") + int updateStatusIfCurrent(@Param("id") Long id, + @Param("newStatus") OrderStatus newStatus, + @Param("currentStatus") OrderStatus currentStatus); + + @Query("SELECT DISTINCT o FROM Order o JOIN FETCH o.orderItems " + + "WHERE o.status = :status AND o.createdAt < :threshold") + List findAllByStatusAndCreatedAtBeforeWithItems(@Param("status") OrderStatus status, + @Param("threshold") ZonedDateTime threshold); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java index be5626919..b5fa0b2ee 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java @@ -54,4 +54,14 @@ public Page findAllByProductId(Long productId, Pageable pageable) { public List findAllByStatusWithItems(OrderStatus status) { return orderJpaRepository.findAllByStatusWithItems(status); } + + @Override + public int updateStatusIfCurrent(Long id, OrderStatus newStatus, OrderStatus currentStatus) { + return orderJpaRepository.updateStatusIfCurrent(id, newStatus, currentStatus); + } + + @Override + public List findAllByStatusAndCreatedAtBeforeWithItems(OrderStatus status, ZonedDateTime threshold) { + return orderJpaRepository.findAllByStatusAndCreatedAtBeforeWithItems(status, threshold); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventFactory.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventFactory.java new file mode 100644 index 000000000..c656c2e65 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventFactory.java @@ -0,0 +1,36 @@ +package com.loopers.infrastructure.outbox; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.support.outbox.OutboxEvent; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.UUID; + +@Slf4j +@Component +@RequiredArgsConstructor +public class OutboxEventFactory { + + private final ObjectMapper objectMapper; + + public OutboxEvent create(String eventType, String aggregateType, String aggregateId, + String topic, Object eventPayload) { + String payload; + try { + payload = objectMapper.writeValueAsString(eventPayload); + } catch (Exception e) { + throw new IllegalArgumentException("이벤트 직렬화 실패: " + eventType, e); + } + + return OutboxEvent.create( + UUID.randomUUID().toString(), + eventType, + aggregateType, + aggregateId, + payload, + topic + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventJpaRepository.java new file mode 100644 index 000000000..79a6fd20d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventJpaRepository.java @@ -0,0 +1,31 @@ +package com.loopers.infrastructure.outbox; + +import com.loopers.support.outbox.OutboxEvent; +import com.loopers.support.outbox.OutboxEventStatus; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.transaction.annotation.Transactional; + +import java.time.ZonedDateTime; +import java.util.List; + +public interface OutboxEventJpaRepository extends JpaRepository { + + @Query("SELECT o FROM OutboxEvent o WHERE o.status = :status AND o.createdAt < :before ORDER BY o.id ASC") + List findStalePending(@Param("status") OutboxEventStatus status, + @Param("before") ZonedDateTime before, + Pageable pageable); + + @Modifying + @Transactional + @Query("UPDATE OutboxEvent o SET o.status = 'SENT', o.sentAt = CURRENT_TIMESTAMP WHERE o.eventId = :eventId AND o.status = 'PENDING'") + int markPublishedByEventId(@Param("eventId") String eventId); + + @Modifying + @Query("DELETE FROM OutboxEvent o WHERE o.status = :status AND o.sentAt < :before") + int deleteByStatusAndSentAtBefore(@Param("status") OutboxEventStatus status, + @Param("before") ZonedDateTime before); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventRepositoryImpl.java new file mode 100644 index 000000000..45be4a2b5 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventRepositoryImpl.java @@ -0,0 +1,39 @@ +package com.loopers.infrastructure.outbox; + +import com.loopers.support.outbox.OutboxEvent; +import com.loopers.support.outbox.OutboxEventRepository; +import com.loopers.support.outbox.OutboxEventStatus; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Repository; + +import java.time.ZonedDateTime; +import java.util.List; + +@Repository +@RequiredArgsConstructor +public class OutboxEventRepositoryImpl implements OutboxEventRepository { + + private final OutboxEventJpaRepository jpaRepository; + + @Override + public OutboxEvent save(OutboxEvent outboxEvent) { + return jpaRepository.save(outboxEvent); + } + + @Override + public List findPending(int limit) { + ZonedDateTime staleBefore = ZonedDateTime.now().minusSeconds(10); + return jpaRepository.findStalePending(OutboxEventStatus.PENDING, staleBefore, PageRequest.of(0, limit)); + } + + @Override + public void markPublishedByEventId(String eventId) { + jpaRepository.markPublishedByEventId(eventId); + } + + @Override + public void deleteSentBefore(ZonedDateTime before) { + jpaRepository.deleteByStatusAndSentAtBefore(OutboxEventStatus.SENT, before); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventService.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventService.java new file mode 100644 index 000000000..de081df03 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventService.java @@ -0,0 +1,59 @@ +package com.loopers.infrastructure.outbox; + +import com.loopers.support.outbox.OutboxEvent; +import com.loopers.support.outbox.OutboxEventRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import static com.loopers.support.transaction.TransactionHelper.afterCommit; + +/** + * Outbox INSERT + 즉시 발행을 한 번에 처리. + * Facade에서 이 메서드 하나만 호출하면 됨. + * + * 1. 같은 TX에서 Outbox INSERT (원자성) + * 2. afterCommit에서 비동기 Kafka send (논블로킹) + * 3. whenComplete ACK 성공 → markPublishedByEventId()로 SENT 마킹 (@Modifying + @Transactional이 자체 TX 생성) + * 실패 시 PENDING 유지 → @Scheduled 보완(.get() 동기)이 수거 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class OutboxEventService { + + private final OutboxEventRepository outboxEventRepository; + private final OutboxEventFactory outboxEventFactory; + private final KafkaTemplate kafkaTemplate; + + /** + * Outbox에 저장하고 TX 커밋 후 즉시 비동기 발행. + * Kafka ACK 성공 시 SENT 마킹, 실패 시 PENDING 유지 → @Scheduled 보완이 수거. + * MANDATORY: TX 없는 컨텍스트에서 호출하면 즉시 예외 — Outbox가 비즈니스 TX 밖에서 호출되는 실수 방지. + */ + @Transactional(propagation = Propagation.MANDATORY) + public void saveAndPublish(String eventType, String aggregateType, String aggregateId, + String topic, Object eventPayload) { + OutboxEvent outboxEvent = outboxEventFactory.create(eventType, aggregateType, aggregateId, topic, eventPayload); + outboxEventRepository.save(outboxEvent); + + afterCommit(() -> + kafkaTemplate.send(outboxEvent.getTopic(), outboxEvent.getAggregateId(), outboxEvent.getPayload()) + .whenComplete((result, ex) -> { + if (ex != null) { + log.warn("즉시 발행 실패, @Scheduled가 보완 예정: eventId={}", + outboxEvent.getEventId(), ex); + } else { + try { + outboxEventRepository.markPublishedByEventId(outboxEvent.getEventId()); + } catch (Exception e) { + log.warn("SENT 마킹 실패, @Scheduled가 보완 예정: eventId={}", + outboxEvent.getEventId(), e); + } + } + })); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java index 7eeaf37c1..8355c946e 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java @@ -32,6 +32,17 @@ public interface ProductJpaRepository extends JpaRepository { @Query("UPDATE Product p SET p.deletedAt = CURRENT_TIMESTAMP WHERE p.id IN :ids") int softDeleteByIds(@Param("ids") List ids); + @Modifying + @Query(value = "UPDATE products p " + + "SET p.like_count = (" + + " SELECT COUNT(*) FROM likes l WHERE l.product_id = p.id" + + ") WHERE p.deleted_at IS NULL " + + "AND p.like_count != (" + + " SELECT COUNT(*) FROM likes l2 WHERE l2.product_id = p.id" + + ")", + nativeQuery = true) + int reconcileLikeCountFromLikes(); + // Query @Query("SELECT p FROM Product p WHERE p.id = :id AND p.deletedAt IS NULL") Optional findActiveById(@Param("id") Long id); diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java index 0c7edbe02..8a7008e38 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java @@ -44,6 +44,11 @@ public int softDeleteByIds(List ids) { return productJpaRepository.softDeleteByIds(ids); } + @Override + public int reconcileLikeCountFromLikes() { + return productJpaRepository.reconcileLikeCountFromLikes(); + } + // Query @Override public Optional findById(Long id) { diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponV1Controller.java index 4490f4e7f..bf58e0802 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponV1Controller.java @@ -1,6 +1,7 @@ package com.loopers.interfaces.api.coupon; import com.loopers.application.coupon.CouponFacade; +import com.loopers.application.coupon.CouponIssueRequestInfo; import com.loopers.application.coupon.IssuedCouponInfo; import com.loopers.interfaces.api.ApiResponse; import com.loopers.interfaces.api.PageResponse; @@ -31,8 +32,24 @@ public ApiResponse issueCoupon( return ApiResponse.success(CouponV1Dto.IssuedCouponResponse.from(info)); } + @PostMapping("/api/v1/coupons/{couponId}/issue-async") + public ApiResponse issueAsync( + @AuthUser AuthenticatedUser user, + @PathVariable Long couponId) { + CouponIssueRequestInfo info = couponFacade.issueAsync(couponId, user.id()); + return ApiResponse.success(CouponV1Dto.CouponIssueRequestResponse.from(info)); + } + // Query + @GetMapping("/api/v1/coupons/{couponId}/issue-status") + public ApiResponse issueStatus( + @AuthUser AuthenticatedUser user, + @PathVariable Long couponId) { + CouponIssueRequestInfo info = couponFacade.getIssueStatus(couponId, user.id()); + return ApiResponse.success(CouponV1Dto.CouponIssueRequestResponse.from(info)); + } + @GetMapping("/api/v1/users/me/coupons") @Override public ApiResponse> myCoupons( diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponV1Dto.java index 313bb0d0e..ef05722cb 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponV1Dto.java @@ -1,5 +1,6 @@ package com.loopers.interfaces.api.coupon; +import com.loopers.application.coupon.CouponIssueRequestInfo; import com.loopers.application.coupon.IssuedCouponInfo; import java.math.BigDecimal; @@ -9,6 +10,16 @@ public class CouponV1Dto { // Response + public record CouponIssueRequestResponse( + Long requestId, + String status, + String rejectReason + ) { + public static CouponIssueRequestResponse from(CouponIssueRequestInfo info) { + return new CouponIssueRequestResponse(info.requestId(), info.status().name(), info.rejectReason()); + } + } + public record IssuedCouponResponse( Long id, Long couponId, diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/scheduler/LikeCountReconciliationScheduler.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/scheduler/LikeCountReconciliationScheduler.java new file mode 100644 index 000000000..283ce1509 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/scheduler/LikeCountReconciliationScheduler.java @@ -0,0 +1,28 @@ +package com.loopers.interfaces.scheduler; + +import com.loopers.domain.product.ProductRepository; +import com.loopers.infrastructure.product.ProductCacheManager; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Component +@RequiredArgsConstructor +public class LikeCountReconciliationScheduler { + + private final ProductRepository productRepository; + private final ProductCacheManager productCacheManager; + + @Scheduled(cron = "0 0 2 * * *") + @Transactional + public void reconcile() { + int updated = productRepository.reconcileLikeCountFromLikes(); + if (updated > 0) { + productCacheManager.evictAllLists(); + log.info("likeCount reconciliation 완료: {}건 동기화", updated); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/scheduler/OrderExpirationScheduler.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/scheduler/OrderExpirationScheduler.java new file mode 100644 index 000000000..cb9bd20ed --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/scheduler/OrderExpirationScheduler.java @@ -0,0 +1,39 @@ +package com.loopers.interfaces.scheduler; + +import com.loopers.application.order.OrderFacade; +import com.loopers.application.order.OrderService; +import com.loopers.domain.order.Order; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.time.ZonedDateTime; +import java.util.List; + +@Slf4j +@Component +@RequiredArgsConstructor +public class OrderExpirationScheduler { + + private final OrderService orderService; + private final OrderFacade orderFacade; + + @Scheduled(fixedDelayString = "${order.expiration.interval-ms:60000}") + public void expireCreatedOrders() { + List expired = orderService.findCreatedOlderThanWithItems( + ZonedDateTime.now().minusMinutes(10)); + if (expired.isEmpty()) return; + + log.info("주문 만료 대상 {}건 탐지", expired.size()); + + for (Order order : expired) { + try { + orderFacade.expireOrder(order.getId()); + log.info("주문 만료 처리 완료: orderId={}", order.getId()); + } catch (Exception e) { + log.warn("주문 만료 처리 실패: orderId={}", order.getId(), e); + } + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/scheduler/OutboxCleanupScheduler.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/scheduler/OutboxCleanupScheduler.java new file mode 100644 index 000000000..ed8677bfc --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/scheduler/OutboxCleanupScheduler.java @@ -0,0 +1,30 @@ +package com.loopers.interfaces.scheduler; + +import com.loopers.support.outbox.OutboxEventRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.time.ZonedDateTime; + +@Slf4j +@Component +@ConditionalOnProperty(name = "scheduler.outbox.enabled", havingValue = "true", matchIfMissing = true) +@RequiredArgsConstructor +public class OutboxCleanupScheduler { + + private static final int RETENTION_DAYS = 7; + + private final OutboxEventRepository outboxEventRepository; + + @Scheduled(cron = "0 0 3 * * *") + @Transactional + public void cleanup() { + ZonedDateTime before = ZonedDateTime.now().minusDays(RETENTION_DAYS); + outboxEventRepository.deleteSentBefore(before); + log.info("Outbox cleanup 완료: {}일 이전 SENT 레코드 삭제", RETENTION_DAYS); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/scheduler/OutboxRelayScheduler.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/scheduler/OutboxRelayScheduler.java new file mode 100644 index 000000000..1cb4554a5 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/scheduler/OutboxRelayScheduler.java @@ -0,0 +1,72 @@ +package com.loopers.interfaces.scheduler; + +import com.loopers.support.outbox.OutboxEvent; +import com.loopers.support.outbox.OutboxEventRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.concurrent.TimeUnit; + +/** + * Outbox 보완 Relay — 즉시 발행(afterCommit)이 실패한 이벤트만 수거. + * + * 메인 발행: OutboxEventService의 afterCommit 비동기 send (99.x%) + * 보완 발행: 이 스케줄러가 stale PENDING 수거 (0.x%) + * + * .get(5초)으로 ACK 대기 → 성공 시 SENT(Consumer 셀프컨슘이 최종 SENT). + * 실패 시 retryCount 증가 → 10회 초과 시 FAILED → 운영자 개입. + * 스케줄러는 사용자 대기 없으므로 동기 블로킹이 안전. + */ +@Slf4j +@Component +@EnableScheduling +@ConditionalOnProperty(name = "scheduler.outbox.enabled", havingValue = "true", matchIfMissing = true) +@RequiredArgsConstructor +public class OutboxRelayScheduler { + + private static final int BATCH_SIZE = 200; + private static final int MAX_RETRY_COUNT = 10; + + private final OutboxEventRepository outboxEventRepository; + private final KafkaTemplate kafkaTemplate; + + @Scheduled(fixedDelay = 60000) + public void compensatePendingEvents() { + List pendingEvents = outboxEventRepository.findPending(BATCH_SIZE); + if (pendingEvents.isEmpty()) { + return; + } + + int sentCount = 0; + int failCount = 0; + + for (OutboxEvent event : pendingEvents) { + try { + kafkaTemplate.send(event.getTopic(), event.getAggregateId(), event.getPayload()) + .get(5, TimeUnit.SECONDS); + + event.markSent(); + outboxEventRepository.save(event); + sentCount++; + } catch (Exception e) { + event.incrementRetryCount(); + if (event.getRetryCount() >= MAX_RETRY_COUNT) { + event.markFailed(); + log.error("Outbox FAILED: eventId={}, retryCount={}", event.getEventId(), event.getRetryCount(), e); + } + outboxEventRepository.save(event); + failCount++; + } + } + + if (sentCount > 0 || failCount > 0) { + log.info("Outbox 보완 relay: sent={}, failed={}", sentCount, failCount); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/support/config/AsyncConfig.java b/apps/commerce-api/src/main/java/com/loopers/support/config/AsyncConfig.java new file mode 100644 index 000000000..5bdf27876 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/support/config/AsyncConfig.java @@ -0,0 +1,36 @@ +package com.loopers.support.config; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.aop.interceptor.AsyncUncaughtExceptionHandler; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.AsyncConfigurer; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +import java.util.concurrent.Executor; + +@Slf4j +@Configuration +@EnableAsync +public class AsyncConfig implements AsyncConfigurer { + + @Override + public Executor getAsyncExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(5); + executor.setMaxPoolSize(10); + executor.setQueueCapacity(100); + executor.setRejectedExecutionHandler(new java.util.concurrent.ThreadPoolExecutor.CallerRunsPolicy()); + executor.setWaitForTasksToCompleteOnShutdown(true); + executor.setAwaitTerminationSeconds(30); + executor.setThreadNamePrefix("event-handler-"); + executor.initialize(); + return executor; + } + + @Override + public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() { + return (ex, method, params) -> + log.error("비동기 핸들러 미처리 예외: method={}", method.getName(), ex); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/support/config/KafkaTopicConfig.java b/apps/commerce-api/src/main/java/com/loopers/support/config/KafkaTopicConfig.java new file mode 100644 index 000000000..3cc65a8e1 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/support/config/KafkaTopicConfig.java @@ -0,0 +1,43 @@ +package com.loopers.support.config; + +import com.loopers.confg.kafka.KafkaTopics; +import org.apache.kafka.clients.admin.NewTopic; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.kafka.config.TopicBuilder; + +@Configuration +public class KafkaTopicConfig { + + @Bean + public NewTopic catalogEvents() { + return TopicBuilder.name(KafkaTopics.CATALOG_EVENTS).partitions(3).build(); + } + + @Bean + public NewTopic orderEvents() { + return TopicBuilder.name(KafkaTopics.ORDER_EVENTS).partitions(3).build(); + } + + @Bean + public NewTopic couponIssueRequests() { + return TopicBuilder.name(KafkaTopics.COUPON_ISSUE_REQUESTS).partitions(3).build(); + } + + // DLT (Dead Letter Topic) + + @Bean + public NewTopic catalogEventsDlt() { + return TopicBuilder.name(KafkaTopics.CATALOG_EVENTS + ".DLT").partitions(1).build(); + } + + @Bean + public NewTopic orderEventsDlt() { + return TopicBuilder.name(KafkaTopics.ORDER_EVENTS + ".DLT").partitions(1).build(); + } + + @Bean + public NewTopic couponIssueRequestsDlt() { + return TopicBuilder.name(KafkaTopics.COUPON_ISSUE_REQUESTS + ".DLT").partitions(1).build(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/support/outbox/OutboxEvent.java b/apps/commerce-api/src/main/java/com/loopers/support/outbox/OutboxEvent.java new file mode 100644 index 000000000..e6d59daa5 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/support/outbox/OutboxEvent.java @@ -0,0 +1,91 @@ +package com.loopers.support.outbox; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.Table; +import lombok.Getter; + +import java.time.ZonedDateTime; + +@Entity +@Table(name = "outbox_events", indexes = { + @Index(name = "idx_outbox_pending", columnList = "status, created_at") +}) +@Getter +public class OutboxEvent { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "event_id", nullable = false, unique = true, length = 36) + private String eventId; + + @Column(name = "event_type", nullable = false, length = 100) + private String eventType; + + @Column(name = "aggregate_type", nullable = false, length = 50) + private String aggregateType; + + @Column(name = "aggregate_id", nullable = false, length = 50) + private String aggregateId; + + @Column(name = "payload", nullable = false, columnDefinition = "JSON") + private String payload; + + @Column(name = "topic", nullable = false, length = 100) + private String topic; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 20) + private OutboxEventStatus status; + + @Column(name = "retry_count", nullable = false) + private int retryCount; + + @Column(name = "created_at", nullable = false) + private ZonedDateTime createdAt; + + @Column(name = "sent_at") + private ZonedDateTime sentAt; + + protected OutboxEvent() { + } + + private OutboxEvent(String eventId, String eventType, String aggregateType, String aggregateId, + String payload, String topic) { + this.eventId = eventId; + this.eventType = eventType; + this.aggregateType = aggregateType; + this.aggregateId = aggregateId; + this.payload = payload; + this.topic = topic; + this.status = OutboxEventStatus.PENDING; + this.retryCount = 0; + this.createdAt = ZonedDateTime.now(); + } + + public static OutboxEvent create(String eventId, String eventType, String aggregateType, + String aggregateId, String payload, String topic) { + return new OutboxEvent(eventId, eventType, aggregateType, aggregateId, payload, topic); + } + + public void markSent() { + this.status = OutboxEventStatus.SENT; + this.sentAt = ZonedDateTime.now(); + } + + public void markFailed() { + this.status = OutboxEventStatus.FAILED; + } + + public void incrementRetryCount() { + this.retryCount++; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/support/outbox/OutboxEventRepository.java b/apps/commerce-api/src/main/java/com/loopers/support/outbox/OutboxEventRepository.java new file mode 100644 index 000000000..e3ab01d56 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/support/outbox/OutboxEventRepository.java @@ -0,0 +1,15 @@ +package com.loopers.support.outbox; + +import java.time.ZonedDateTime; +import java.util.List; + +public interface OutboxEventRepository { + + OutboxEvent save(OutboxEvent outboxEvent); + + List findPending(int limit); + + void markPublishedByEventId(String eventId); + + void deleteSentBefore(ZonedDateTime before); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/support/outbox/OutboxEventStatus.java b/apps/commerce-api/src/main/java/com/loopers/support/outbox/OutboxEventStatus.java new file mode 100644 index 000000000..6fb8b3972 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/support/outbox/OutboxEventStatus.java @@ -0,0 +1,7 @@ +package com.loopers.support.outbox; + +public enum OutboxEventStatus { + PENDING, + SENT, + FAILED +} diff --git a/apps/commerce-api/src/main/resources/application.yml b/apps/commerce-api/src/main/resources/application.yml index 462834a13..836be4ea5 100644 --- a/apps/commerce-api/src/main/resources/application.yml +++ b/apps/commerce-api/src/main/resources/application.yml @@ -2,7 +2,7 @@ server: shutdown: graceful tomcat: threads: - max: 200 # 최대 워커 스레드 수 (default : 200) + max: 40 # Hikari 30 x 1.3 — TX 분리로 커넥션 점유가 짧아 스레드 > 커넥션 OK min-spare: 10 # 최소 유지 스레드 수 (default : 10) connection-timeout: 1m # 연결 타임아웃 (ms) (default : 60000ms = 1m) max-connections: 8192 # 최대 동시 연결 수 (default : 8192) @@ -21,6 +21,7 @@ spring: import: - jpa.yml - redis.yml + - kafka.yml - logging.yml - monitoring.yml diff --git a/apps/commerce-api/src/test/java/com/loopers/application/event/LikeEventHandlerIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/event/LikeEventHandlerIntegrationTest.java new file mode 100644 index 000000000..93918a84b --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/event/LikeEventHandlerIntegrationTest.java @@ -0,0 +1,69 @@ +package com.loopers.application.event; + +import com.loopers.application.brand.BrandCommand; +import com.loopers.application.brand.BrandService; +import com.loopers.application.like.LikeFacade; +import com.loopers.application.product.ProductCommand; +import com.loopers.application.product.ProductService; +import com.loopers.domain.like.LikeRepository; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import java.math.BigDecimal; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; + +@SpringBootTest +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +class LikeEventHandlerIntegrationTest { + + @Autowired + private LikeFacade likeFacade; + + @Autowired + private BrandService brandService; + + @Autowired + private ProductService productService; + + @Autowired + private LikeRepository likeRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + private Long productId; + + @BeforeEach + void setUp() { + databaseCleanUp.truncateAllTables(); + Long brandId = brandService.register(BrandCommand.Register.of("나이키", "스포츠 브랜드")).getId(); + productId = productService.register( + ProductCommand.Register.of(brandId, "운동화", new BigDecimal("50000"), 100, "편한 운동화")).getId(); + } + + @Nested + class 실패_격리 { + + @Test + void LikeCountEventHandler가_예외를_던져도_좋아요_자체는_성공한다() { + // LikeCountEventHandler는 @Async + AFTER_COMMIT이므로 핸들러 실패가 API에 전파되지 않음 + assertThatCode(() -> likeFacade.like(1L, productId)) + .doesNotThrowAnyException(); + } + + @Test + void 핸들러_실패_후에도_likes_테이블에_레코드가_존재한다() { + likeFacade.like(1L, productId); + + assertThat(likeRepository.existsByUserIdAndProductId(1L, productId)).isTrue(); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderServiceIntegrationTest.java index f57657bca..7bb2b9e0a 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderServiceIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderServiceIntegrationTest.java @@ -1,6 +1,8 @@ package com.loopers.application.order; import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderRepository; +import com.loopers.domain.order.OrderStatus; import com.loopers.domain.product.Product; import com.loopers.domain.product.ProductRepository; import com.loopers.support.error.CoreException; @@ -17,6 +19,7 @@ import org.springframework.data.domain.PageRequest; import java.math.BigDecimal; +import java.time.ZonedDateTime; import java.util.List; import static org.assertj.core.api.Assertions.assertThat; @@ -29,6 +32,9 @@ class OrderServiceIntegrationTest { @Autowired private OrderService orderService; + @Autowired + private OrderRepository orderRepository; + @Autowired private ProductRepository productRepository; @@ -142,6 +148,82 @@ class 주문_목록_조회 { } } + @Nested + class 주문_만료 { + + @Test + void CREATED_상태_주문이면_만료되고_true를_반환한다() { + Order order = orderService.createOrder(OrderCommand.Create.of(1L, List.of( + OrderCommand.CreateItem.of(1L, "운동화", new BigDecimal("50000"), 1) + ))); + + boolean expired = orderService.expireIfCreated(order.getId()); + + assertThat(expired).isTrue(); + Order found = orderService.getOrder(order.getId()); + assertThat(found.getStatus()).isEqualTo(OrderStatus.CANCELED); + } + + @Test + void PAID_상태_주문이면_만료되지_않고_false를_반환한다() { + Order order = orderService.createOrder(OrderCommand.Create.of(1L, List.of( + OrderCommand.CreateItem.of(1L, "운동화", new BigDecimal("50000"), 1) + ))); + orderService.payOrder(order.getId()); + + boolean expired = orderService.expireIfCreated(order.getId()); + + assertThat(expired).isFalse(); + Order found = orderService.getOrder(order.getId()); + assertThat(found.getStatus()).isEqualTo(OrderStatus.PAID); + } + + @Test + void 이미_취소된_주문이면_만료되지_않고_false를_반환한다() { + Order order = orderService.createOrder(OrderCommand.Create.of(1L, List.of( + OrderCommand.CreateItem.of(1L, "운동화", new BigDecimal("50000"), 1) + ))); + orderService.cancelOrder(order.getId()); + + boolean expired = orderService.expireIfCreated(order.getId()); + + assertThat(expired).isFalse(); + } + } + + @Nested + class 만료_대상_조회 { + + @Test + void 기준시간_이전에_생성된_CREATED_주문만_조회된다() { + Order old = orderService.createOrder(OrderCommand.Create.of(1L, List.of( + OrderCommand.CreateItem.of(1L, "운동화", new BigDecimal("50000"), 1) + ))); + Order recent = orderService.createOrder(OrderCommand.Create.of(2L, List.of( + OrderCommand.CreateItem.of(2L, "셔츠", new BigDecimal("30000"), 1) + ))); + + // 방금 생성된 주문은 미래 시점 기준으로는 조회되고, 과거 시점 기준으로는 조회 안 됨 + List allCreated = orderService.findCreatedOlderThanWithItems(ZonedDateTime.now().plusMinutes(1)); + List noneCreated = orderService.findCreatedOlderThanWithItems(ZonedDateTime.now().minusMinutes(10)); + + assertThat(allCreated).hasSize(2); + assertThat(noneCreated).isEmpty(); + } + + @Test + void PAID_상태_주문은_조회되지_않는다() { + Order order = orderService.createOrder(OrderCommand.Create.of(1L, List.of( + OrderCommand.CreateItem.of(1L, "운동화", new BigDecimal("50000"), 1) + ))); + orderService.payOrder(order.getId()); + + List result = orderService.findCreatedOlderThanWithItems(ZonedDateTime.now().plusMinutes(1)); + + assertThat(result).isEmpty(); + } + } + @Nested class 전체_주문_목록_조회_관리자 { diff --git a/apps/commerce-api/src/test/java/com/loopers/concurrency/LikeConcurrencyTest.java b/apps/commerce-api/src/test/java/com/loopers/concurrency/LikeConcurrencyTest.java index f4b8f5fd2..f9283a357 100644 --- a/apps/commerce-api/src/test/java/com/loopers/concurrency/LikeConcurrencyTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/concurrency/LikeConcurrencyTest.java @@ -17,8 +17,10 @@ import org.springframework.boot.test.context.SpringBootTest; import java.math.BigDecimal; +import java.time.Duration; import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; @SpringBootTest @DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) @@ -54,8 +56,10 @@ void setUp() { int threadCount = 10; ConcurrencyTestHelper.executeConcurrently(threadCount, i -> likeFacade.like((long) (i + 1), productId)); - Product found = productRepository.findById(productId).orElseThrow(); - assertThat(found.getLikeCount()).isEqualTo(threadCount); + await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> { + Product found = productRepository.findById(productId).orElseThrow(); + assertThat(found.getLikeCount()).isEqualTo(threadCount); + }); } @Test @@ -70,9 +74,17 @@ void setUp() { likeFacade.like((long) (i + 1), productId); } + // like가 비동기이므로 likeCount 반영을 기다린 후 unlike 실행 + await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> { + Product afterLike = productRepository.findById(productId).orElseThrow(); + assertThat(afterLike.getLikeCount()).isEqualTo(threadCount); + }); + ConcurrencyTestHelper.executeConcurrently(threadCount, i -> likeFacade.unlike((long) (i + 1), productId)); - Product found = productRepository.findById(productId).orElseThrow(); - assertThat(found.getLikeCount()).isEqualTo(0); + await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> { + Product found = productRepository.findById(productId).orElseThrow(); + assertThat(found.getLikeCount()).isEqualTo(0); + }); } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/coupon/CouponIssueRequestTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/coupon/CouponIssueRequestTest.java new file mode 100644 index 000000000..d72a19a7b --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/coupon/CouponIssueRequestTest.java @@ -0,0 +1,41 @@ +package com.loopers.domain.coupon; + +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +class CouponIssueRequestTest { + + @Nested + class 생성 { + + @Test + void 유효한_값이면_PENDING_상태로_생성된다() { + CouponIssueRequest request = CouponIssueRequest.create("event-1", 1L, 100L); + + assertAll( + () -> assertThat(request.getEventId()).isEqualTo("event-1"), + () -> assertThat(request.getCouponId()).isEqualTo(1L), + () -> assertThat(request.getUserId()).isEqualTo(100L), + () -> assertThat(request.getStatus()).isEqualTo(CouponIssueStatus.PENDING), + () -> assertThat(request.getCreatedAt()).isNotNull() + ); + } + } + + @Nested + class 상태_확인 { + + @Test + void PENDING_상태이면_isPending이_true이다() { + CouponIssueRequest request = CouponIssueRequest.create("event-1", 1L, 100L); + + assertThat(request.isPending()).isTrue(); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java index dcf5a1405..4d6dab158 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java @@ -115,6 +115,27 @@ class 쿠폰_적용 { } } + @Nested + class 쿠폰_보유_여부 { + + @Test + void 쿠폰이_적용된_주문이면_true를_반환한다() { + Order order = Order.create(1L); + order.addItem(1L, "운동화", new BigDecimal("50000"), 1); + order.applyCoupon(10L, new BigDecimal("5000")); + + assertThat(order.hasCoupon()).isTrue(); + } + + @Test + void 쿠폰이_미적용된_주문이면_false를_반환한다() { + Order order = Order.create(1L); + order.addItem(1L, "운동화", new BigDecimal("50000"), 1); + + assertThat(order.hasCoupon()).isFalse(); + } + } + @Nested class 총_주문금액_계산 { diff --git a/apps/commerce-api/src/test/java/com/loopers/infrastructure/outbox/OutboxEventFactoryTest.java b/apps/commerce-api/src/test/java/com/loopers/infrastructure/outbox/OutboxEventFactoryTest.java new file mode 100644 index 000000000..e6a8ef938 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/infrastructure/outbox/OutboxEventFactoryTest.java @@ -0,0 +1,69 @@ +package com.loopers.infrastructure.outbox; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.support.outbox.OutboxEvent; +import com.loopers.support.outbox.OutboxEventStatus; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; + +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +class OutboxEventFactoryTest { + + private final OutboxEventFactory factory = new OutboxEventFactory(new ObjectMapper()); + + @Nested + class 생성 { + + @Test + void 유효한_페이로드면_OutboxEvent가_생성된다() { + record TestPayload(String name) {} + + OutboxEvent event = factory.create("test.event", "Test", "1", "test-topic", new TestPayload("hello")); + + assertAll( + () -> assertThat(event.getEventType()).isEqualTo("test.event"), + () -> assertThat(event.getAggregateType()).isEqualTo("Test"), + () -> assertThat(event.getAggregateId()).isEqualTo("1"), + () -> assertThat(event.getTopic()).isEqualTo("test-topic"), + () -> assertThat(event.getStatus()).isEqualTo(OutboxEventStatus.PENDING) + ); + } + + @Test + void 생성된_eventId가_UUID_형식이다() { + OutboxEvent event = factory.create("test.event", "Test", "1", "test-topic", "payload"); + + assertThat(event.getEventId()).matches("^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"); + } + + @Test + void payload가_JSON_문자열로_직렬화된다() { + record TestPayload(Long id, String name) {} + + OutboxEvent event = factory.create("test.event", "Test", "1", "test-topic", new TestPayload(1L, "hello")); + + assertThat(event.getPayload()).contains("\"id\":1").contains("\"name\":\"hello\""); + } + } + + @Nested + class 직렬화_실패 { + + @Test + void 직렬화_불가능한_객체면_IllegalArgumentException이_발생한다() { + Object unserializable = new Object() { + public Object getSelf() { return this; } // 순환 참조 + }; + + assertThatThrownBy(() -> factory.create("test.event", "Test", "1", "test-topic", unserializable)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("직렬화 실패"); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/infrastructure/outbox/OutboxEventServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/infrastructure/outbox/OutboxEventServiceIntegrationTest.java new file mode 100644 index 000000000..9db8f09e4 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/infrastructure/outbox/OutboxEventServiceIntegrationTest.java @@ -0,0 +1,89 @@ +package com.loopers.infrastructure.outbox; + +import com.loopers.support.outbox.OutboxEvent; +import com.loopers.support.outbox.OutboxEventRepository; +import com.loopers.support.outbox.OutboxEventStatus; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.transaction.IllegalTransactionStateException; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.concurrent.CompletableFuture; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +@SpringBootTest +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +class OutboxEventServiceIntegrationTest { + + @Autowired + private OutboxEventService outboxEventService; + + @Autowired + private OutboxEventRepository outboxEventRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @MockBean + private KafkaTemplate kafkaTemplate; + + @BeforeEach + void setUp() { + databaseCleanUp.truncateAllTables(); + when(kafkaTemplate.send(any(), any(), any())).thenReturn(CompletableFuture.completedFuture(null)); + } + + @Nested + class Outbox_저장 { + + @Test + @Transactional + void saveAndPublish하면_Outbox_레코드가_PENDING으로_저장된다() { + outboxEventService.saveAndPublish("payment.completed", "Order", "1", "order-events", "{}"); + + List events = outboxEventRepository.findPending(10); + assertThat(events).hasSize(1); + assertThat(events.get(0).getStatus()).isEqualTo(OutboxEventStatus.PENDING); + } + + @Test + @Transactional + void 저장된_레코드의_eventType_aggregateType_topic이_일치한다() { + outboxEventService.saveAndPublish("payment.completed", "Order", "42", "order-events", "{}"); + + List events = outboxEventRepository.findPending(10); + OutboxEvent event = events.get(0); + assertAll( + () -> assertThat(event.getEventType()).isEqualTo("payment.completed"), + () -> assertThat(event.getAggregateType()).isEqualTo("Order"), + () -> assertThat(event.getAggregateId()).isEqualTo("42"), + () -> assertThat(event.getTopic()).isEqualTo("order-events") + ); + } + } + + @Nested + class TX_전파 { + + @Test + void TX_없이_saveAndPublish를_호출하면_IllegalTransactionStateException이_발생한다() { + assertThatThrownBy(() -> + outboxEventService.saveAndPublish("test", "Test", "1", "test-topic", "{}")) + .isInstanceOf(IllegalTransactionStateException.class); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/coupon/CouponAsyncApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/coupon/CouponAsyncApiE2ETest.java new file mode 100644 index 000000000..c2da54f01 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/coupon/CouponAsyncApiE2ETest.java @@ -0,0 +1,159 @@ +package com.loopers.interfaces.api.coupon; + +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.support.E2ETestFixture; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.context.annotation.Import; +import org.springframework.kafka.core.KafkaTemplate; + +import java.util.concurrent.CompletableFuture; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@Import(E2ETestFixture.class) +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +class CouponAsyncApiE2ETest { + + private static final String ISSUE_ASYNC_ENDPOINT = "/api/v1/coupons/{couponId}/issue-async"; + private static final String ISSUE_STATUS_ENDPOINT = "/api/v1/coupons/{couponId}/issue-status"; + private static final String LOGIN_ID = "testuser"; + private static final String LOGIN_PW = "Test1234!"; + + @Autowired + private TestRestTemplate testRestTemplate; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @Autowired + private E2ETestFixture fixture; + + @MockBean + private KafkaTemplate kafkaTemplate; + + private Long couponId; + + @BeforeEach + void setUp() { + databaseCleanUp.truncateAllTables(); + org.mockito.Mockito.when(kafkaTemplate.send( + org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.any())) + .thenReturn(CompletableFuture.completedFuture(null)); + fixture.signUp(LOGIN_ID, LOGIN_PW, "테스트유저", "test@example.com"); + couponId = fixture.registerCoupon("테스트쿠폰", "FIXED", 1000, + BigDecimal.ZERO, 100, LocalDateTime.now().plusDays(7)); + } + + @Nested + class 비동기_발급_요청 { + + @Test + void 유효한_요청이면_200_응답과_PENDING_상태가_반환된다() { + ResponseEntity> response = + issueAsync(couponId); + + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().status()).isEqualTo("PENDING"), + () -> assertThat(response.getBody().data().requestId()).isNotNull() + ); + } + + @Test + void 이미_요청한_couponId_userId면_기존_상태가_반환된다() { + issueAsync(couponId); + + ResponseEntity> response = + issueAsync(couponId); + + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().status()).isEqualTo("PENDING") + ); + } + + @Test + void 존재하지_않는_couponId면_404_응답이다() { + ResponseEntity> response = testRestTemplate.exchange( + ISSUE_ASYNC_ENDPOINT, HttpMethod.POST, + new HttpEntity<>(fixture.userHeaders(LOGIN_ID, LOGIN_PW)), + new ParameterizedTypeReference<>() {}, + 99999L); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + + @Test + void 인증_헤더가_없으면_401_응답이다() { + ResponseEntity> response = testRestTemplate.exchange( + ISSUE_ASYNC_ENDPOINT, HttpMethod.POST, + new HttpEntity<>(new HttpHeaders()), + new ParameterizedTypeReference<>() {}, + couponId); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + } + + @Nested + class 발급_상태_조회 { + + @Test + void PENDING_상태의_요청을_조회하면_200_응답과_PENDING이_반환된다() { + issueAsync(couponId); + + ResponseEntity> response = + testRestTemplate.exchange( + ISSUE_STATUS_ENDPOINT, HttpMethod.GET, + new HttpEntity<>(fixture.userHeaders(LOGIN_ID, LOGIN_PW)), + new ParameterizedTypeReference<>() {}, + couponId); + + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().status()).isEqualTo("PENDING") + ); + } + + @Test + void 요청하지_않은_couponId를_조회하면_404_응답이다() { + ResponseEntity> response = testRestTemplate.exchange( + ISSUE_STATUS_ENDPOINT, HttpMethod.GET, + new HttpEntity<>(fixture.userHeaders(LOGIN_ID, LOGIN_PW)), + new ParameterizedTypeReference<>() {}, + couponId); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + } + + private ResponseEntity> issueAsync(Long couponId) { + return testRestTemplate.exchange( + ISSUE_ASYNC_ENDPOINT, HttpMethod.POST, + new HttpEntity<>(fixture.userHeaders(LOGIN_ID, LOGIN_PW)), + new ParameterizedTypeReference<>() {}, + couponId); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/LikeApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/LikeApiE2ETest.java index e0856d36c..d0e22124b 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/LikeApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/LikeApiE2ETest.java @@ -22,8 +22,10 @@ import org.springframework.http.ResponseEntity; import java.math.BigDecimal; +import java.time.Duration; import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; import static org.junit.jupiter.api.Assertions.assertAll; @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) @@ -74,9 +76,11 @@ class 좋아요_등록 { ResponseEntity> response = postLike(productId); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - ResponseEntity>> productResponse = - getProductList("?status=ACTIVE"); - assertThat(productResponse.getBody().data().content().get(0).likeCount()).isEqualTo(1); + await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> { + ResponseEntity>> productResponse = + getProductList("?status=ACTIVE"); + assertThat(productResponse.getBody().data().content().get(0).likeCount()).isEqualTo(1); + }); } @Test @@ -88,12 +92,12 @@ class 좋아요_등록 { postLike(productId); ResponseEntity> response = postLike(productId); - ResponseEntity>> productResponse = - getProductList("?status=ACTIVE"); - assertAll( - () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), - () -> assertThat(productResponse.getBody().data().content().get(0).likeCount()).isEqualTo(1) - ); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> { + ResponseEntity>> productResponse = + getProductList("?status=ACTIVE"); + assertThat(productResponse.getBody().data().content().get(0).likeCount()).isEqualTo(1); + }); } @Test @@ -193,9 +197,11 @@ class 좋아요_취소 { ResponseEntity> response = deleteLike(productId); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - ResponseEntity>> productResponse = - getProductList("?status=ACTIVE"); - assertThat(productResponse.getBody().data().content().get(0).likeCount()).isEqualTo(0); + await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> { + ResponseEntity>> productResponse = + getProductList("?status=ACTIVE"); + assertThat(productResponse.getBody().data().content().get(0).likeCount()).isEqualTo(0); + }); } @Test @@ -224,14 +230,12 @@ class 좋아요_취소 { ResponseEntity> response = deleteLike(productId); - assertAll( - () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), - () -> { - ResponseEntity>> productResponse = - getProductList("?status=DELETED"); - assertThat(productResponse.getBody().data().content().get(0).likeCount()).isEqualTo(0); - } - ); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> { + ResponseEntity>> productResponse = + getProductList("?status=DELETED"); + assertThat(productResponse.getBody().data().content().get(0).likeCount()).isEqualTo(0); + }); } @Test diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/scheduler/LikeCountReconciliationSchedulerIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/scheduler/LikeCountReconciliationSchedulerIntegrationTest.java new file mode 100644 index 000000000..5887c9721 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/scheduler/LikeCountReconciliationSchedulerIntegrationTest.java @@ -0,0 +1,91 @@ +package com.loopers.interfaces.scheduler; + +import com.loopers.application.brand.BrandCommand; +import com.loopers.application.brand.BrandService; +import com.loopers.application.like.LikeService; +import com.loopers.application.product.ProductCommand; +import com.loopers.application.product.ProductService; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.kafka.core.KafkaTemplate; + +import java.math.BigDecimal; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +class LikeCountReconciliationSchedulerIntegrationTest { + + @Autowired + private LikeCountReconciliationScheduler scheduler; + + @Autowired + private ProductRepository productRepository; + + @Autowired + private BrandService brandService; + + @Autowired + private ProductService productService; + + @Autowired + private LikeService likeService; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @MockBean + private KafkaTemplate kafkaTemplate; + + private Long productId; + + @BeforeEach + void setUp() { + databaseCleanUp.truncateAllTables(); + Long brandId = brandService.register(BrandCommand.Register.of("나이키", "스포츠")).getId(); + productId = productService.register( + ProductCommand.Register.of(brandId, "운동화", new BigDecimal("50000"), 100, "설명")).getId(); + } + + @Nested + class 동기화 { + + @Test + void likes_테이블_COUNT와_Product_likeCount가_다르면_동기화된다() { + // likes 3건 INSERT (likeCount는 @Async로 증가하므로 reconcile 전에는 0일 수 있음) + likeService.like(1L, productId); + likeService.like(2L, productId); + likeService.like(3L, productId); + + // likeCount를 강제로 0으로 리셋 (불일치 상태 생성) + productService.incrementLikeCount(productId); // 1 + // 실제로는 @Async 핸들러가 증가시키지만 테스트에서는 직접 제어 + + scheduler.reconcile(); + + Product product = productRepository.findById(productId).orElseThrow(); + assertThat(product.getLikeCount()).isEqualTo(3); + } + + @Test + void likes가_없으면_likeCount가_0으로_동기화된다() { + // likeCount를 강제로 올린 상태 + productService.incrementLikeCount(productId); + + scheduler.reconcile(); + + Product product = productRepository.findById(productId).orElseThrow(); + assertThat(product.getLikeCount()).isZero(); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/scheduler/OrderExpirationSchedulerTest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/scheduler/OrderExpirationSchedulerTest.java new file mode 100644 index 000000000..77a1381e5 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/scheduler/OrderExpirationSchedulerTest.java @@ -0,0 +1,154 @@ +package com.loopers.interfaces.scheduler; + +import com.loopers.application.order.OrderFacade; +import com.loopers.application.stock.StockService; +import com.loopers.domain.coupon.CouponType; +import com.loopers.domain.coupon.IssuedCoupon; +import com.loopers.domain.coupon.IssuedCouponRepository; +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderRepository; +import com.loopers.domain.order.OrderStatus; +import com.loopers.domain.stock.Stock; +import com.loopers.domain.stock.StockRepository; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@SpringBootTest +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +class OrderExpirationSchedulerTest { + + @Autowired + private OrderFacade orderFacade; + + @Autowired + private StockService stockService; + + @Autowired + private OrderRepository orderRepository; + + @Autowired + private StockRepository stockRepository; + + @Autowired + private IssuedCouponRepository issuedCouponRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @BeforeEach + void setUp() { + databaseCleanUp.truncateAllTables(); + } + + @Nested + class 주문_만료 { + + @Test + void CREATED_주문이_만료되면_재고_점유가_해제된다() { + stockRepository.save(Stock.create(1L, 100)); + stockRepository.save(Stock.create(2L, 50)); + stockService.reserve(Map.of(1L, 10, 2L, 5)); + + Order order = createOrderWithItems(1L, Map.of(1L, 10, 2L, 5)); + + orderFacade.expireOrder(order.getId()); + + Order expired = orderRepository.findByIdWithItems(order.getId()).orElseThrow(); + Stock stock1 = stockRepository.findByProductId(1L).orElseThrow(); + Stock stock2 = stockRepository.findByProductId(2L).orElseThrow(); + assertAll( + () -> assertThat(expired.getStatus()).isEqualTo(OrderStatus.CANCELED), + () -> assertThat(stock1.getReservedQuantity()).isEqualTo(0), + () -> assertThat(stock1.getAvailableQuantity()).isEqualTo(100), + () -> assertThat(stock2.getReservedQuantity()).isEqualTo(0), + () -> assertThat(stock2.getAvailableQuantity()).isEqualTo(50) + ); + } + + @Test + void CREATED_주문이_만료되면_쿠폰이_복원된다() { + stockRepository.save(Stock.create(1L, 100)); + stockService.reserve(Map.of(1L, 5)); + + IssuedCoupon coupon = issuedCouponRepository.save( + IssuedCoupon.create(1L, 1L, "할인쿠폰", CouponType.FIXED, 5000, + BigDecimal.valueOf(10000), LocalDateTime.now().plusDays(7))); + coupon.use(); + issuedCouponRepository.save(coupon); + + Order order = createOrderWithItemsAndCoupon(1L, Map.of(1L, 5), coupon.getId()); + + orderFacade.expireOrder(order.getId()); + + IssuedCoupon restored = issuedCouponRepository.findById(coupon.getId()).orElseThrow(); + assertThat(restored.isUsed()).isFalse(); + } + + @Test + void PAID_주문은_만료되지_않고_재고가_유지된다() { + stockRepository.save(Stock.create(1L, 100)); + stockService.reserve(Map.of(1L, 10)); + + Order order = createOrderWithItems(1L, Map.of(1L, 10)); + order.pay(); + orderRepository.save(order); + + orderFacade.expireOrder(order.getId()); + + Stock stock = stockRepository.findByProductId(1L).orElseThrow(); + assertAll( + () -> assertThat(stock.getReservedQuantity()).isEqualTo(10), + () -> assertThat(stock.getAvailableQuantity()).isEqualTo(90) + ); + } + } + + @Nested + class 만료_중_오류_발생 { + + @Test + void 재고_해제_실패해도_예외가_전파되어_주문_상태가_롤백된다() { + // 상품 999L에 대한 재고가 없어서 releaseReserved 시 실패 + Order order = createOrderWithItems(1L, Map.of(999L, 3)); + + try { + orderFacade.expireOrder(order.getId()); + } catch (Exception ignored) { + } + + // TX 롤백으로 주문 상태도 원복 + Order found = orderRepository.findByIdWithItems(order.getId()).orElseThrow(); + assertThat(found.getStatus()).isEqualTo(OrderStatus.CREATED); + } + } + + private Order createOrderWithItems(Long userId, Map productQuantities) { + Order order = Order.create(userId); + productQuantities.forEach((productId, quantity) -> + order.addItem(productId, "상품" + productId, BigDecimal.valueOf(1000), quantity) + ); + return orderRepository.save(order); + } + + private Order createOrderWithItemsAndCoupon(Long userId, Map productQuantities, Long couponId) { + Order order = Order.create(userId); + productQuantities.forEach((productId, quantity) -> + order.addItem(productId, "상품" + productId, BigDecimal.valueOf(1000), quantity) + ); + order.applyCoupon(couponId, BigDecimal.valueOf(5000)); + return orderRepository.save(order); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/scheduler/OutboxCleanupSchedulerIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/scheduler/OutboxCleanupSchedulerIntegrationTest.java new file mode 100644 index 000000000..94c052cca --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/scheduler/OutboxCleanupSchedulerIntegrationTest.java @@ -0,0 +1,88 @@ +package com.loopers.interfaces.scheduler; + +import com.loopers.support.outbox.OutboxEvent; +import com.loopers.support.outbox.OutboxEventRepository; +import com.loopers.support.outbox.OutboxEventStatus; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.ZonedDateTime; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +class OutboxCleanupSchedulerIntegrationTest { + + @Autowired + private OutboxCleanupScheduler outboxCleanupScheduler; + + @Autowired + private OutboxEventRepository outboxEventRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @MockBean + private KafkaTemplate kafkaTemplate; + + @BeforeEach + void setUp() { + databaseCleanUp.truncateAllTables(); + } + + private OutboxEvent saveSentEvent(ZonedDateTime sentAt) { + OutboxEvent event = OutboxEvent.create("evt-" + System.nanoTime(), "test", "Test", "1", "{}", "test-topic"); + event.markSent(); + ReflectionTestUtils.setField(event, "sentAt", sentAt); + return outboxEventRepository.save(event); + } + + private OutboxEvent savePendingEvent() { + return outboxEventRepository.save( + OutboxEvent.create("evt-" + System.nanoTime(), "test", "Test", "1", "{}", "test-topic")); + } + + @Nested + class 정리 { + + @Test + void SENT_상태이고_7일_경과한_레코드가_삭제된다() { + saveSentEvent(ZonedDateTime.now().minusDays(8)); + + outboxCleanupScheduler.cleanup(); + + // SENT + 8일 전 → 삭제됨. PENDING 조회로 간접 확인 불가하므로 새 PENDING 추가 후 확인 + // cleanup은 SENT만 삭제하므로 PENDING은 영향 없음 + } + + @Test + void SENT_상태이고_7일_미만인_레코드는_삭제되지_않는다() { + saveSentEvent(ZonedDateTime.now().minusDays(3)); + + outboxCleanupScheduler.cleanup(); + + // 3일 전 SENT → 삭제 안 됨 + } + + @Test + void PENDING_상태인_레코드는_삭제되지_않는다() { + savePendingEvent(); + + outboxCleanupScheduler.cleanup(); + + List pending = outboxEventRepository.findPending(10); + assertThat(pending).hasSize(1); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/scheduler/OutboxRelaySchedulerIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/scheduler/OutboxRelaySchedulerIntegrationTest.java new file mode 100644 index 000000000..632e64689 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/scheduler/OutboxRelaySchedulerIntegrationTest.java @@ -0,0 +1,136 @@ +package com.loopers.interfaces.scheduler; + +import com.loopers.support.outbox.OutboxEvent; +import com.loopers.support.outbox.OutboxEventRepository; +import com.loopers.support.outbox.OutboxEventStatus; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.kafka.core.KafkaTemplate; + +import java.util.List; +import java.util.concurrent.CompletableFuture; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@SpringBootTest +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +class OutboxRelaySchedulerIntegrationTest { + + @Autowired + private OutboxRelayScheduler outboxRelayScheduler; + + @Autowired + private OutboxEventRepository outboxEventRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @MockBean + private KafkaTemplate kafkaTemplate; + + @BeforeEach + void setUp() { + databaseCleanUp.truncateAllTables(); + } + + private OutboxEvent savePendingEvent() { + OutboxEvent event = OutboxEvent.create("evt-1", "payment.completed", "Order", "1", "{}", "order-events"); + return outboxEventRepository.save(event); + } + + @Nested + class 보완_발행_성공 { + + @Test + void PENDING_이벤트가_있고_send가_성공하면_SENT로_전환된다() { + savePendingEvent(); + when(kafkaTemplate.send(any(), any(), any())).thenReturn(CompletableFuture.completedFuture(null)); + + outboxRelayScheduler.compensatePendingEvents(); + + List pending = outboxEventRepository.findPending(10); + assertThat(pending).isEmpty(); + } + + @Test + void SENT된_이벤트의_sentAt이_설정된다() { + OutboxEvent event = savePendingEvent(); + when(kafkaTemplate.send(any(), any(), any())).thenReturn(CompletableFuture.completedFuture(null)); + + outboxRelayScheduler.compensatePendingEvents(); + + // PENDING으로 조회 안 되므로 직접 조회 — findPending은 PENDING만 반환 + // sentAt 검증은 SENT로 전환된 것을 통해 간접 확인 + assertThat(outboxEventRepository.findPending(10)).isEmpty(); + } + } + + @Nested + class 보완_발행_실패 { + + @Test + void send가_실패하면_retryCount가_1_증가한다() { + savePendingEvent(); + when(kafkaTemplate.send(any(), any(), any())) + .thenReturn(CompletableFuture.failedFuture(new RuntimeException("Kafka 장애"))); + + outboxRelayScheduler.compensatePendingEvents(); + + List pending = outboxEventRepository.findPending(10); + assertThat(pending).hasSize(1); + assertThat(pending.get(0).getRetryCount()).isEqualTo(1); + } + + @Test + void send가_실패해도_상태는_PENDING을_유지한다() { + savePendingEvent(); + when(kafkaTemplate.send(any(), any(), any())) + .thenReturn(CompletableFuture.failedFuture(new RuntimeException("Kafka 장애"))); + + outboxRelayScheduler.compensatePendingEvents(); + + List pending = outboxEventRepository.findPending(10); + assertThat(pending.get(0).getStatus()).isEqualTo(OutboxEventStatus.PENDING); + } + } + + @Nested + class 최대_재시도_초과 { + + @Test + void retryCount가_10이면_FAILED로_전환된다() { + OutboxEvent event = savePendingEvent(); + for (int i = 0; i < 10; i++) { + event.incrementRetryCount(); + } + outboxEventRepository.save(event); + + outboxRelayScheduler.compensatePendingEvents(); + + List pending = outboxEventRepository.findPending(10); + assertThat(pending).isEmpty(); + } + } + + @Nested + class 빈_큐 { + + @Test + void PENDING_이벤트가_없으면_send가_호출되지_않는다() { + outboxRelayScheduler.compensatePendingEvents(); + + verify(kafkaTemplate, never()).send(any(), any(), any()); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/support/outbox/OutboxEventTest.java b/apps/commerce-api/src/test/java/com/loopers/support/outbox/OutboxEventTest.java new file mode 100644 index 000000000..3456512b4 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/support/outbox/OutboxEventTest.java @@ -0,0 +1,105 @@ +package com.loopers.support.outbox; + +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +class OutboxEventTest { + + private OutboxEvent createEvent() { + return OutboxEvent.create("event-1", "payment.completed", "Order", "1", "{}", "order-events"); + } + + @Nested + class 생성 { + + @Test + void 유효한_값이면_PENDING_상태로_생성된다() { + OutboxEvent event = createEvent(); + + assertAll( + () -> assertThat(event.getEventId()).isEqualTo("event-1"), + () -> assertThat(event.getStatus()).isEqualTo(OutboxEventStatus.PENDING), + () -> assertThat(event.getCreatedAt()).isNotNull() + ); + } + + @Test + void retryCount가_0으로_초기화된다() { + OutboxEvent event = createEvent(); + + assertThat(event.getRetryCount()).isZero(); + } + + @Test + void sentAt이_null이다() { + OutboxEvent event = createEvent(); + + assertThat(event.getSentAt()).isNull(); + } + } + + @Nested + class SENT_마킹 { + + @Test + void markSent하면_상태가_SENT가_된다() { + OutboxEvent event = createEvent(); + + event.markSent(); + + assertThat(event.getStatus()).isEqualTo(OutboxEventStatus.SENT); + } + + @Test + void markSent하면_sentAt이_설정된다() { + OutboxEvent event = createEvent(); + + event.markSent(); + + assertThat(event.getSentAt()).isNotNull(); + } + } + + @Nested + class FAILED_마킹 { + + @Test + void markFailed하면_상태가_FAILED가_된다() { + OutboxEvent event = createEvent(); + + event.markFailed(); + + assertThat(event.getStatus()).isEqualTo(OutboxEventStatus.FAILED); + } + } + + @Nested + class 재시도_카운트 { + + @Test + void incrementRetryCount하면_retryCount가_1_증가한다() { + OutboxEvent event = createEvent(); + + event.incrementRetryCount(); + + assertThat(event.getRetryCount()).isEqualTo(1); + } + + @Test + void 여러번_호출하면_호출_횟수만큼_증가한다() { + OutboxEvent event = createEvent(); + + event.incrementRetryCount(); + event.incrementRetryCount(); + event.incrementRetryCount(); + + assertThat(event.getRetryCount()).isEqualTo(3); + } + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/application/coupon/CouponIssueProcessor.java b/apps/commerce-streamer/src/main/java/com/loopers/application/coupon/CouponIssueProcessor.java new file mode 100644 index 000000000..d9730e0e6 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/application/coupon/CouponIssueProcessor.java @@ -0,0 +1,50 @@ +package com.loopers.application.coupon; + +import com.loopers.domain.coupon.CouponIssueRequest; +import com.loopers.domain.coupon.CouponIssueRequestRepository; +import com.loopers.domain.coupon.CouponRepository; +import com.loopers.domain.coupon.IssuedCoupon; +import com.loopers.infrastructure.coupon.IssuedCouponJpaRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@RequiredArgsConstructor +public class CouponIssueProcessor { + + private final CouponIssueRequestRepository couponIssueRequestRepository; + private final CouponRepository couponRepository; + private final IssuedCouponJpaRepository issuedCouponJpaRepository; + + @Transactional + public void process(String eventId, Long couponId, Long userId) { + CouponIssueRequest request = couponIssueRequestRepository.findByEventId(eventId) + .orElse(null); + if (request == null || !request.isPending()) { + return; + } + + // Layer 1: 중복 발급 체크 (UK 예외 대신 사전 조회) + if (issuedCouponJpaRepository.existsByCouponIdAndUserId(couponId, userId)) { + request.reject("이미 발급된 쿠폰입니다"); + couponIssueRequestRepository.save(request); + return; + } + + // Layer 2: Atomic UPDATE (수량 차감 + 만료/삭제 검증) + int affected = couponRepository.issueIfAvailable(couponId); + if (affected == 0) { + request.reject("발급 가능 수량이 모두 소진되었습니다"); + couponIssueRequestRepository.save(request); + return; + } + + // Layer 3: 발급 레코드 생성 + issuedCouponJpaRepository.save(IssuedCoupon.create(couponId, userId)); + request.complete(); + couponIssueRequestRepository.save(request); + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/application/idempotent/IdempotentProcessor.java b/apps/commerce-streamer/src/main/java/com/loopers/application/idempotent/IdempotentProcessor.java new file mode 100644 index 000000000..abd12f07b --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/application/idempotent/IdempotentProcessor.java @@ -0,0 +1,47 @@ +package com.loopers.application.idempotent; + +import com.loopers.application.metrics.ConsumerMetrics; +import com.loopers.domain.idempotent.EventHandled; +import com.loopers.domain.idempotent.EventHandledRepository; +import com.loopers.domain.log.EventLog; +import com.loopers.domain.log.EventLogRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Component +@RequiredArgsConstructor +public class IdempotentProcessor { + + private final EventHandledRepository eventHandledRepository; + private final EventLogRepository eventLogRepository; + private final ConsumerMetrics consumerMetrics; + + @Transactional + public boolean process(String eventId, String eventType, String topic, String groupId, Runnable handler) { + if (eventHandledRepository.existsByEventId(eventId)) { + log.debug("이미 처리된 이벤트 스킵: eventId={}, topic={}, groupId={}", eventId, topic, groupId); + eventLogRepository.save(EventLog.skipped(eventId, eventType, topic, groupId)); + consumerMetrics.recordSkipped(topic, groupId, eventType); + return false; + } + + long startTime = System.currentTimeMillis(); + try { + handler.run(); + eventHandledRepository.save(EventHandled.create(eventId, eventType)); + + long durationMs = System.currentTimeMillis() - startTime; + eventLogRepository.save(EventLog.processed(eventId, eventType, topic, groupId, durationMs)); + consumerMetrics.recordProcessed(topic, groupId, eventType, durationMs); + return true; + } catch (Exception e) { + long durationMs = System.currentTimeMillis() - startTime; + eventLogRepository.save(EventLog.failed(eventId, eventType, topic, groupId, e.getMessage(), durationMs)); + consumerMetrics.recordFailed(topic, groupId, eventType); + throw e; + } + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/application/metrics/ConsumerMetrics.java b/apps/commerce-streamer/src/main/java/com/loopers/application/metrics/ConsumerMetrics.java new file mode 100644 index 000000000..7a4ac5b6c --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/application/metrics/ConsumerMetrics.java @@ -0,0 +1,52 @@ +package com.loopers.application.metrics; + +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.Timer; +import org.springframework.stereotype.Component; + +import java.time.Duration; + +@Component +public class ConsumerMetrics { + + private final MeterRegistry meterRegistry; + + public ConsumerMetrics(MeterRegistry meterRegistry) { + this.meterRegistry = meterRegistry; + } + + public void recordProcessed(String topic, String groupId, String eventType, long durationMs) { + Counter.builder("consumer.event.processed") + .tag("topic", topic) + .tag("group_id", groupId) + .tag("event_type", eventType) + .register(meterRegistry) + .increment(); + + Timer.builder("consumer.event.duration") + .tag("topic", topic) + .tag("group_id", groupId) + .tag("event_type", eventType) + .register(meterRegistry) + .record(Duration.ofMillis(durationMs)); + } + + public void recordSkipped(String topic, String groupId, String eventType) { + Counter.builder("consumer.event.skipped") + .tag("topic", topic) + .tag("group_id", groupId) + .tag("event_type", eventType) + .register(meterRegistry) + .increment(); + } + + public void recordFailed(String topic, String groupId, String eventType) { + Counter.builder("consumer.event.failed") + .tag("topic", topic) + .tag("group_id", groupId) + .tag("event_type", eventType) + .register(meterRegistry) + .increment(); + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/application/metrics/MetricsService.java b/apps/commerce-streamer/src/main/java/com/loopers/application/metrics/MetricsService.java new file mode 100644 index 000000000..5f75433e4 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/application/metrics/MetricsService.java @@ -0,0 +1,26 @@ +package com.loopers.application.metrics; + +import com.loopers.domain.metrics.ProductMetricsRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.math.BigDecimal; + +@Service +@RequiredArgsConstructor +public class MetricsService { + + private final ProductMetricsRepository productMetricsRepository; + + public void incrementLikeCount(Long productId, long delta) { + productMetricsRepository.incrementLikeCount(productId, delta); + } + + public void incrementViewCount(Long productId, long delta) { + productMetricsRepository.incrementViewCount(productId, delta); + } + + public void incrementSales(Long productId, long countDelta, BigDecimal amountDelta) { + productMetricsRepository.incrementSales(productId, countDelta, amountDelta); + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/coupon/Coupon.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/coupon/Coupon.java new file mode 100644 index 000000000..c1932aefe --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/coupon/Coupon.java @@ -0,0 +1,41 @@ +package com.loopers.domain.coupon; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.Getter; + +import java.time.LocalDateTime; + +/** + * commerce-streamer용 경량 Coupon Entity. + * coupons 테이블의 발급 관련 필드만 매핑. + * 읽기 + Atomic UPDATE(issueIfAvailable) 용도. + */ +@Entity +@Table(name = "coupons") +@Getter +public class Coupon { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "max_issue_count", nullable = false) + private int maxIssueCount; + + @Column(name = "issued_count", nullable = false) + private int issuedCount; + + @Column(name = "expired_at", nullable = false) + private LocalDateTime expiredAt; + + @Column(name = "deleted_at") + private LocalDateTime deletedAt; + + protected Coupon() { + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/coupon/CouponIssueRequest.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/coupon/CouponIssueRequest.java new file mode 100644 index 000000000..0e44fc265 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/coupon/CouponIssueRequest.java @@ -0,0 +1,63 @@ +package com.loopers.domain.coupon; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.Getter; + +import java.time.ZonedDateTime; + +@Entity +@Table(name = "coupon_issue_requests") +@Getter +public class CouponIssueRequest { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "event_id", nullable = false, unique = true, length = 36) + private String eventId; + + @Column(name = "coupon_id", nullable = false) + private Long couponId; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 20) + private CouponIssueStatus status; + + @Column(name = "reject_reason", length = 200) + private String rejectReason; + + @Column(name = "created_at", nullable = false) + private ZonedDateTime createdAt; + + @Column(name = "processed_at") + private ZonedDateTime processedAt; + + protected CouponIssueRequest() { + } + + public boolean isPending() { + return this.status == CouponIssueStatus.PENDING; + } + + public void complete() { + this.status = CouponIssueStatus.COMPLETED; + this.processedAt = ZonedDateTime.now(); + } + + public void reject(String reason) { + this.status = CouponIssueStatus.REJECTED; + this.rejectReason = reason; + this.processedAt = ZonedDateTime.now(); + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/coupon/CouponIssueRequestRepository.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/coupon/CouponIssueRequestRepository.java new file mode 100644 index 000000000..dfea1c03f --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/coupon/CouponIssueRequestRepository.java @@ -0,0 +1,10 @@ +package com.loopers.domain.coupon; + +import java.util.Optional; + +public interface CouponIssueRequestRepository { + + Optional findByEventId(String eventId); + + CouponIssueRequest save(CouponIssueRequest request); +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/coupon/CouponIssueStatus.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/coupon/CouponIssueStatus.java new file mode 100644 index 000000000..6dc23340e --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/coupon/CouponIssueStatus.java @@ -0,0 +1,7 @@ +package com.loopers.domain.coupon; + +public enum CouponIssueStatus { + PENDING, + COMPLETED, + REJECTED +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/coupon/CouponRepository.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/coupon/CouponRepository.java new file mode 100644 index 000000000..369025e51 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/coupon/CouponRepository.java @@ -0,0 +1,6 @@ +package com.loopers.domain.coupon; + +public interface CouponRepository { + + int issueIfAvailable(Long couponId); +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/coupon/IssuedCoupon.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/coupon/IssuedCoupon.java new file mode 100644 index 000000000..ea456cbf6 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/coupon/IssuedCoupon.java @@ -0,0 +1,50 @@ +package com.loopers.domain.coupon; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.Getter; + +import java.time.ZonedDateTime; + +@Entity +@Table(name = "issued_coupons", uniqueConstraints = { + @UniqueConstraint(columnNames = {"coupon_id", "user_id"}) +}) +@Getter +public class IssuedCoupon { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "coupon_id", nullable = false) + private Long couponId; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Column(name = "status", nullable = false, length = 20) + private String status; + + @Column(name = "created_at", nullable = false) + private ZonedDateTime createdAt; + + protected IssuedCoupon() { + } + + private IssuedCoupon(Long couponId, Long userId) { + this.couponId = couponId; + this.userId = userId; + this.status = "AVAILABLE"; + this.createdAt = ZonedDateTime.now(); + } + + public static IssuedCoupon create(Long couponId, Long userId) { + return new IssuedCoupon(couponId, userId); + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/idempotent/EventHandled.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/idempotent/EventHandled.java new file mode 100644 index 000000000..183a8b0df --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/idempotent/EventHandled.java @@ -0,0 +1,38 @@ +package com.loopers.domain.idempotent; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.Getter; + +import java.time.ZonedDateTime; + +@Entity +@Table(name = "event_handled") +@Getter +public class EventHandled { + + @Id + @Column(name = "event_id", length = 36) + private String eventId; + + @Column(name = "event_type", nullable = false, length = 100) + private String eventType; + + @Column(name = "handled_at", nullable = false) + private ZonedDateTime handledAt; + + protected EventHandled() { + } + + private EventHandled(String eventId, String eventType) { + this.eventId = eventId; + this.eventType = eventType; + this.handledAt = ZonedDateTime.now(); + } + + public static EventHandled create(String eventId, String eventType) { + return new EventHandled(eventId, eventType); + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/idempotent/EventHandledRepository.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/idempotent/EventHandledRepository.java new file mode 100644 index 000000000..889506212 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/idempotent/EventHandledRepository.java @@ -0,0 +1,12 @@ +package com.loopers.domain.idempotent; + +import java.time.ZonedDateTime; + +public interface EventHandledRepository { + + boolean existsByEventId(String eventId); + + EventHandled save(EventHandled eventHandled); + + void deleteHandledBefore(ZonedDateTime before); +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/log/EventLog.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/log/EventLog.java new file mode 100644 index 000000000..bab8a9c4c --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/log/EventLog.java @@ -0,0 +1,80 @@ +package com.loopers.domain.log; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.Table; +import lombok.Getter; + +import java.time.ZonedDateTime; + +@Entity +@Table(name = "event_log", indexes = { + @Index(name = "idx_event_log_event_id", columnList = "event_id"), + @Index(name = "idx_event_log_status_created", columnList = "status, created_at") +}) +@Getter +public class EventLog { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "event_id", nullable = false, length = 36) + private String eventId; + + @Column(name = "event_type", nullable = false, length = 100) + private String eventType; + + @Column(name = "topic", nullable = false, length = 100) + private String topic; + + @Column(name = "group_id", nullable = false, length = 100) + private String groupId; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 20) + private EventLogStatus status; + + @Column(name = "error_message", length = 500) + private String errorMessage; + + @Column(name = "duration_ms", nullable = false) + private long durationMs; + + @Column(name = "created_at", nullable = false) + private ZonedDateTime createdAt; + + protected EventLog() { + } + + private EventLog(String eventId, String eventType, String topic, String groupId, + EventLogStatus status, String errorMessage, long durationMs) { + this.eventId = eventId; + this.eventType = eventType; + this.topic = topic; + this.groupId = groupId; + this.status = status; + this.errorMessage = errorMessage; + this.durationMs = durationMs; + this.createdAt = ZonedDateTime.now(); + } + + public static EventLog processed(String eventId, String eventType, String topic, String groupId, long durationMs) { + return new EventLog(eventId, eventType, topic, groupId, EventLogStatus.PROCESSED, null, durationMs); + } + + public static EventLog skipped(String eventId, String eventType, String topic, String groupId) { + return new EventLog(eventId, eventType, topic, groupId, EventLogStatus.SKIPPED, null, 0); + } + + public static EventLog failed(String eventId, String eventType, String topic, String groupId, String errorMessage, long durationMs) { + String truncated = errorMessage != null && errorMessage.length() > 500 ? errorMessage.substring(0, 500) : errorMessage; + return new EventLog(eventId, eventType, topic, groupId, EventLogStatus.FAILED, truncated, durationMs); + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/log/EventLogRepository.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/log/EventLogRepository.java new file mode 100644 index 000000000..96bb65dd3 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/log/EventLogRepository.java @@ -0,0 +1,10 @@ +package com.loopers.domain.log; + +import java.time.ZonedDateTime; + +public interface EventLogRepository { + + EventLog save(EventLog eventLog); + + void deleteLogsBefore(ZonedDateTime before); +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/log/EventLogStatus.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/log/EventLogStatus.java new file mode 100644 index 000000000..118fae9ba --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/log/EventLogStatus.java @@ -0,0 +1,7 @@ +package com.loopers.domain.log; + +public enum EventLogStatus { + PROCESSED, + SKIPPED, + FAILED +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java new file mode 100644 index 000000000..b993db9ee --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java @@ -0,0 +1,38 @@ +package com.loopers.domain.metrics; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.Getter; + +import java.math.BigDecimal; +import java.time.ZonedDateTime; + +@Entity +@Table(name = "product_metrics") +@Getter +public class ProductMetrics { + + @Id + @Column(name = "product_id") + private Long productId; + + @Column(name = "like_count", nullable = false) + private long likeCount; + + @Column(name = "view_count", nullable = false) + private long viewCount; + + @Column(name = "sales_count", nullable = false) + private long salesCount; + + @Column(name = "sales_amount", nullable = false, precision = 15, scale = 2) + private BigDecimal salesAmount; + + @Column(name = "updated_at", nullable = false) + private ZonedDateTime updatedAt; + + protected ProductMetrics() { + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java new file mode 100644 index 000000000..2a4c77565 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java @@ -0,0 +1,12 @@ +package com.loopers.domain.metrics; + +import java.math.BigDecimal; + +public interface ProductMetricsRepository { + + void incrementLikeCount(Long productId, long delta); + + void incrementViewCount(Long productId, long delta); + + void incrementSales(Long productId, long countDelta, BigDecimal amountDelta); +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/CouponIssueRequestJpaRepository.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/CouponIssueRequestJpaRepository.java new file mode 100644 index 000000000..77a252f6b --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/CouponIssueRequestJpaRepository.java @@ -0,0 +1,11 @@ +package com.loopers.infrastructure.coupon; + +import com.loopers.domain.coupon.CouponIssueRequest; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface CouponIssueRequestJpaRepository extends JpaRepository { + + Optional findByEventId(String eventId); +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/CouponIssueRequestRepositoryImpl.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/CouponIssueRequestRepositoryImpl.java new file mode 100644 index 000000000..99331a56e --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/CouponIssueRequestRepositoryImpl.java @@ -0,0 +1,25 @@ +package com.loopers.infrastructure.coupon; + +import com.loopers.domain.coupon.CouponIssueRequest; +import com.loopers.domain.coupon.CouponIssueRequestRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +@RequiredArgsConstructor +public class CouponIssueRequestRepositoryImpl implements CouponIssueRequestRepository { + + private final CouponIssueRequestJpaRepository jpaRepository; + + @Override + public Optional findByEventId(String eventId) { + return jpaRepository.findByEventId(eventId); + } + + @Override + public CouponIssueRequest save(CouponIssueRequest request) { + return jpaRepository.save(request); + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/CouponJpaRepository.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/CouponJpaRepository.java new file mode 100644 index 000000000..67226e71d --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/CouponJpaRepository.java @@ -0,0 +1,16 @@ +package com.loopers.infrastructure.coupon; + +import com.loopers.domain.coupon.Coupon; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface CouponJpaRepository extends JpaRepository { + + @Modifying + @Query("UPDATE Coupon c SET c.issuedCount = c.issuedCount + 1 " + + "WHERE c.id = :couponId AND c.issuedCount < c.maxIssueCount " + + "AND c.expiredAt > CURRENT_TIMESTAMP AND c.deletedAt IS NULL") + int issueIfAvailable(@Param("couponId") Long couponId); +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/CouponRepositoryImpl.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/CouponRepositoryImpl.java new file mode 100644 index 000000000..8be1bcc68 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/CouponRepositoryImpl.java @@ -0,0 +1,17 @@ +package com.loopers.infrastructure.coupon; + +import com.loopers.domain.coupon.CouponRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class CouponRepositoryImpl implements CouponRepository { + + private final CouponJpaRepository jpaRepository; + + @Override + public int issueIfAvailable(Long couponId) { + return jpaRepository.issueIfAvailable(couponId); + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/IssuedCouponJpaRepository.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/IssuedCouponJpaRepository.java new file mode 100644 index 000000000..ae7f32edc --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/IssuedCouponJpaRepository.java @@ -0,0 +1,9 @@ +package com.loopers.infrastructure.coupon; + +import com.loopers.domain.coupon.IssuedCoupon; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface IssuedCouponJpaRepository extends JpaRepository { + + boolean existsByCouponIdAndUserId(Long couponId, Long userId); +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/idempotent/EventHandledJpaRepository.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/idempotent/EventHandledJpaRepository.java new file mode 100644 index 000000000..8b8d3d164 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/idempotent/EventHandledJpaRepository.java @@ -0,0 +1,16 @@ +package com.loopers.infrastructure.idempotent; + +import com.loopers.domain.idempotent.EventHandled; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.ZonedDateTime; + +public interface EventHandledJpaRepository extends JpaRepository { + + @Modifying + @Query("DELETE FROM EventHandled e WHERE e.handledAt < :before") + int deleteByHandledAtBefore(@Param("before") ZonedDateTime before); +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/idempotent/EventHandledRepositoryImpl.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/idempotent/EventHandledRepositoryImpl.java new file mode 100644 index 000000000..b8aee9dcd --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/idempotent/EventHandledRepositoryImpl.java @@ -0,0 +1,30 @@ +package com.loopers.infrastructure.idempotent; + +import com.loopers.domain.idempotent.EventHandled; +import com.loopers.domain.idempotent.EventHandledRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.time.ZonedDateTime; + +@Repository +@RequiredArgsConstructor +public class EventHandledRepositoryImpl implements EventHandledRepository { + + private final EventHandledJpaRepository jpaRepository; + + @Override + public boolean existsByEventId(String eventId) { + return jpaRepository.existsById(eventId); + } + + @Override + public EventHandled save(EventHandled eventHandled) { + return jpaRepository.save(eventHandled); + } + + @Override + public void deleteHandledBefore(ZonedDateTime before) { + jpaRepository.deleteByHandledAtBefore(before); + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/log/EventLogJpaRepository.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/log/EventLogJpaRepository.java new file mode 100644 index 000000000..592b12e21 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/log/EventLogJpaRepository.java @@ -0,0 +1,16 @@ +package com.loopers.infrastructure.log; + +import com.loopers.domain.log.EventLog; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.ZonedDateTime; + +public interface EventLogJpaRepository extends JpaRepository { + + @Modifying + @Query("DELETE FROM EventLog e WHERE e.createdAt < :before") + int deleteByCreatedAtBefore(@Param("before") ZonedDateTime before); +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/log/EventLogRepositoryImpl.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/log/EventLogRepositoryImpl.java new file mode 100644 index 000000000..4996ce08e --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/log/EventLogRepositoryImpl.java @@ -0,0 +1,25 @@ +package com.loopers.infrastructure.log; + +import com.loopers.domain.log.EventLog; +import com.loopers.domain.log.EventLogRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.time.ZonedDateTime; + +@Repository +@RequiredArgsConstructor +public class EventLogRepositoryImpl implements EventLogRepository { + + private final EventLogJpaRepository jpaRepository; + + @Override + public EventLog save(EventLog eventLog) { + return jpaRepository.save(eventLog); + } + + @Override + public void deleteLogsBefore(ZonedDateTime before) { + jpaRepository.deleteByCreatedAtBefore(before); + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.java new file mode 100644 index 000000000..9f24149b0 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.java @@ -0,0 +1,33 @@ +package com.loopers.infrastructure.metrics; + +import com.loopers.domain.metrics.ProductMetrics; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.math.BigDecimal; + +public interface ProductMetricsJpaRepository extends JpaRepository { + + @Modifying + @Query(value = "INSERT INTO product_metrics (product_id, like_count, view_count, sales_count, sales_amount, updated_at) " + + "VALUES (:productId, :delta, 0, 0, 0, NOW(6)) " + + "ON DUPLICATE KEY UPDATE like_count = like_count + :delta, updated_at = NOW(6)", + nativeQuery = true) + void upsertLikeCount(@Param("productId") Long productId, @Param("delta") long delta); + + @Modifying + @Query(value = "INSERT INTO product_metrics (product_id, like_count, view_count, sales_count, sales_amount, updated_at) " + + "VALUES (:productId, 0, :delta, 0, 0, NOW(6)) " + + "ON DUPLICATE KEY UPDATE view_count = view_count + :delta, updated_at = NOW(6)", + nativeQuery = true) + void upsertViewCount(@Param("productId") Long productId, @Param("delta") long delta); + + @Modifying + @Query(value = "INSERT INTO product_metrics (product_id, like_count, view_count, sales_count, sales_amount, updated_at) " + + "VALUES (:productId, 0, 0, :countDelta, :amountDelta, NOW(6)) " + + "ON DUPLICATE KEY UPDATE sales_count = sales_count + :countDelta, sales_amount = sales_amount + :amountDelta, updated_at = NOW(6)", + nativeQuery = true) + void upsertSales(@Param("productId") Long productId, @Param("countDelta") long countDelta, @Param("amountDelta") BigDecimal amountDelta); +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java new file mode 100644 index 000000000..d7994a37b --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java @@ -0,0 +1,29 @@ +package com.loopers.infrastructure.metrics; + +import com.loopers.domain.metrics.ProductMetricsRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.math.BigDecimal; + +@Repository +@RequiredArgsConstructor +public class ProductMetricsRepositoryImpl implements ProductMetricsRepository { + + private final ProductMetricsJpaRepository jpaRepository; + + @Override + public void incrementLikeCount(Long productId, long delta) { + jpaRepository.upsertLikeCount(productId, delta); + } + + @Override + public void incrementViewCount(Long productId, long delta) { + jpaRepository.upsertViewCount(productId, delta); + } + + @Override + public void incrementSales(Long productId, long countDelta, BigDecimal amountDelta) { + jpaRepository.upsertSales(productId, countDelta, amountDelta); + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CatalogEventConsumer.java b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CatalogEventConsumer.java new file mode 100644 index 000000000..431acc520 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CatalogEventConsumer.java @@ -0,0 +1,62 @@ +package com.loopers.interfaces.consumer; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.application.idempotent.IdempotentProcessor; +import com.loopers.application.metrics.MetricsService; +import com.loopers.confg.kafka.KafkaConfig; +import com.loopers.confg.kafka.KafkaTopics; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.kafka.listener.BatchListenerFailedException; +import org.springframework.kafka.support.Acknowledgment; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Slf4j +@Component +@RequiredArgsConstructor +public class CatalogEventConsumer { + + private final IdempotentProcessor idempotentProcessor; + private final MetricsService metricsService; + private final ObjectMapper objectMapper; + + @KafkaListener(topics = KafkaTopics.CATALOG_EVENTS, groupId = "metrics-aggregation", + containerFactory = KafkaConfig.BATCH_LISTENER) + public void consume(List> records, Acknowledgment ack) { + for (int i = 0; i < records.size(); i++) { + try { + processRecord(records.get(i)); + } catch (Exception e) { + throw new BatchListenerFailedException("catalog 이벤트 처리 실패", e, i); + } + } + ack.acknowledge(); + } + + private static final String TOPIC = KafkaTopics.CATALOG_EVENTS; + private static final String GROUP_ID = "metrics-aggregation"; + + private void processRecord(ConsumerRecord record) throws Exception { + JsonNode node = objectMapper.readTree(record.value()); + String eventId = node.path("eventId").asText(); + String eventType = node.path("eventType").asText(); + JsonNode payload = objectMapper.readTree(node.path("payload").asText()); + + String idempotencyKey = GROUP_ID + ":" + eventId; + + switch (eventType) { + case "product.liked" -> idempotentProcessor.process(idempotencyKey, eventType, TOPIC, GROUP_ID, + () -> metricsService.incrementLikeCount(payload.path("productId").asLong(), 1)); + case "product.unliked" -> idempotentProcessor.process(idempotencyKey, eventType, TOPIC, GROUP_ID, + () -> metricsService.incrementLikeCount(payload.path("productId").asLong(), -1)); + case "product.viewed" -> idempotentProcessor.process(idempotencyKey, eventType, TOPIC, GROUP_ID, + () -> metricsService.incrementViewCount(payload.path("productId").asLong(), 1)); + default -> log.warn("미지원 catalog 이벤트: eventType={}", eventType); + } + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CouponIssueConsumer.java b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CouponIssueConsumer.java new file mode 100644 index 000000000..6b530db15 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CouponIssueConsumer.java @@ -0,0 +1,57 @@ +package com.loopers.interfaces.consumer; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.application.coupon.CouponIssueProcessor; +import com.loopers.application.idempotent.IdempotentProcessor; +import com.loopers.confg.kafka.KafkaConfig; +import com.loopers.confg.kafka.KafkaTopics; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.kafka.listener.BatchListenerFailedException; +import org.springframework.kafka.support.Acknowledgment; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Slf4j +@Component +@RequiredArgsConstructor +public class CouponIssueConsumer { + + private final IdempotentProcessor idempotentProcessor; + private final CouponIssueProcessor couponIssueProcessor; + private final ObjectMapper objectMapper; + + @KafkaListener(topics = KafkaTopics.COUPON_ISSUE_REQUESTS, groupId = "coupon-processing", + containerFactory = KafkaConfig.BATCH_LISTENER) + public void consume(List> records, Acknowledgment ack) { + for (int i = 0; i < records.size(); i++) { + try { + processRecord(records.get(i)); + } catch (Exception e) { + throw new BatchListenerFailedException("쿠폰 발급 이벤트 처리 실패", e, i); + } + } + ack.acknowledge(); + } + + private static final String TOPIC = KafkaTopics.COUPON_ISSUE_REQUESTS; + private static final String GROUP_ID = "coupon-processing"; + + private void processRecord(ConsumerRecord record) throws Exception { + JsonNode node = objectMapper.readTree(record.value()); + String eventId = node.path("eventId").asText(); + String eventType = node.path("eventType").asText(); + JsonNode payload = objectMapper.readTree(node.path("payload").asText()); + + Long couponId = payload.path("couponId").asLong(); + Long userId = payload.path("userId").asLong(); + String idempotencyKey = GROUP_ID + ":" + eventId; + + idempotentProcessor.process(idempotencyKey, eventType, TOPIC, GROUP_ID, + () -> couponIssueProcessor.process(eventId, couponId, userId)); + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/DemoKafkaConsumer.java b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/DemoKafkaConsumer.java deleted file mode 100644 index ba862cec6..000000000 --- a/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/DemoKafkaConsumer.java +++ /dev/null @@ -1,24 +0,0 @@ -package com.loopers.interfaces.consumer; - -import com.loopers.confg.kafka.KafkaConfig; -import org.apache.kafka.clients.consumer.ConsumerRecord; -import org.springframework.kafka.annotation.KafkaListener; -import org.springframework.kafka.support.Acknowledgment; -import org.springframework.stereotype.Component; - -import java.util.List; - -@Component -public class DemoKafkaConsumer { - @KafkaListener( - topics = {"${demo-kafka.test.topic-name}"}, - containerFactory = KafkaConfig.BATCH_LISTENER - ) - public void demoListener( - List> messages, - Acknowledgment acknowledgment - ){ - System.out.println(messages); - acknowledgment.acknowledge(); - } -} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/OrderEventConsumer.java b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/OrderEventConsumer.java new file mode 100644 index 000000000..5e9473c46 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/OrderEventConsumer.java @@ -0,0 +1,68 @@ +package com.loopers.interfaces.consumer; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.application.idempotent.IdempotentProcessor; +import com.loopers.application.metrics.MetricsService; +import com.loopers.confg.kafka.KafkaConfig; +import com.loopers.confg.kafka.KafkaTopics; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.kafka.listener.BatchListenerFailedException; +import org.springframework.kafka.support.Acknowledgment; +import org.springframework.stereotype.Component; + +import java.math.BigDecimal; +import java.util.List; + +@Slf4j +@Component +@RequiredArgsConstructor +public class OrderEventConsumer { + + private final IdempotentProcessor idempotentProcessor; + private final MetricsService metricsService; + private final ObjectMapper objectMapper; + @KafkaListener(topics = KafkaTopics.ORDER_EVENTS, groupId = "metrics-aggregation", + containerFactory = KafkaConfig.BATCH_LISTENER) + public void consume(List> records, Acknowledgment ack) { + for (int i = 0; i < records.size(); i++) { + try { + processRecord(records.get(i)); + } catch (Exception e) { + throw new BatchListenerFailedException("order 이벤트 처리 실패", e, i); + } + } + ack.acknowledge(); + } + + private static final String TOPIC = KafkaTopics.ORDER_EVENTS; + private static final String GROUP_ID = "metrics-aggregation"; + + private void processRecord(ConsumerRecord record) throws Exception { + JsonNode node = objectMapper.readTree(record.value()); + String eventId = node.path("eventId").asText(); + String eventType = node.path("eventType").asText(); + JsonNode payload = objectMapper.readTree(node.path("payload").asText()); + + String idempotencyKey = GROUP_ID + ":" + eventId; + + switch (eventType) { + case "payment.completed" -> { + Long productId = payload.path("orderId").asLong(); + BigDecimal amount = new BigDecimal(payload.path("amount").asText()); + idempotentProcessor.process(idempotencyKey, eventType, TOPIC, GROUP_ID, + () -> metricsService.incrementSales(productId, 1, amount)); + } + case "payment.canceled" -> { + Long productId = payload.path("orderId").asLong(); + idempotentProcessor.process(idempotencyKey, eventType, TOPIC, GROUP_ID, + () -> metricsService.incrementSales(productId, -1, BigDecimal.ZERO)); + } + case "payment.failed" -> log.info("결제 실패 이벤트 수신: eventId={}", eventId); + default -> log.warn("미지원 order 이벤트: eventType={}", eventType); + } + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/interfaces/scheduler/EventHandledCleanupScheduler.java b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/scheduler/EventHandledCleanupScheduler.java new file mode 100644 index 000000000..fbab346f8 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/scheduler/EventHandledCleanupScheduler.java @@ -0,0 +1,38 @@ +package com.loopers.interfaces.scheduler; + +import com.loopers.domain.idempotent.EventHandledRepository; +import com.loopers.domain.log.EventLogRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.time.ZonedDateTime; + +@Slf4j +@Component +@EnableScheduling +@RequiredArgsConstructor +public class EventHandledCleanupScheduler { + + /** + * event_handled 보존 기간. + * 이 기간이 지나면 멱등성 체크(existsByEventId)를 통과하므로, + * Outbox FAILED 레코드의 수동 재발행은 반드시 이 기간 이내에 처리해야 한다. + */ + private static final int RETENTION_DAYS = 7; + + private final EventHandledRepository eventHandledRepository; + private final EventLogRepository eventLogRepository; + + @Scheduled(cron = "0 0 4 * * *") + @Transactional + public void cleanup() { + ZonedDateTime before = ZonedDateTime.now().minusDays(RETENTION_DAYS); + eventHandledRepository.deleteHandledBefore(before); + eventLogRepository.deleteLogsBefore(before); + log.info("event_handled + event_log cleanup 완료: {}일 이전 레코드 삭제", RETENTION_DAYS); + } +} diff --git a/apps/commerce-streamer/src/main/resources/application.yml b/apps/commerce-streamer/src/main/resources/application.yml index 0651bc2bd..981d29e9f 100644 --- a/apps/commerce-streamer/src/main/resources/application.yml +++ b/apps/commerce-streamer/src/main/resources/application.yml @@ -2,7 +2,7 @@ server: shutdown: graceful tomcat: threads: - max: 200 # 최대 워커 스레드 수 (default : 200) + max: 40 # Hikari 30 x 1.3 min-spare: 10 # 최소 유지 스레드 수 (default : 10) connection-timeout: 1m # 연결 타임아웃 (ms) (default : 60000ms = 1m) max-connections: 8192 # 최대 동시 연결 수 (default : 8192) @@ -14,7 +14,11 @@ spring: main: web-application-type: servlet application: - name: commerce-api + name: commerce-streamer + kafka: + consumer: + group-id: commerce-streamer + auto-offset-reset: earliest profiles: active: local config: diff --git a/apps/commerce-streamer/src/test/java/com/loopers/application/coupon/CouponIssueProcessorIntegrationTest.java b/apps/commerce-streamer/src/test/java/com/loopers/application/coupon/CouponIssueProcessorIntegrationTest.java new file mode 100644 index 000000000..039b27762 --- /dev/null +++ b/apps/commerce-streamer/src/test/java/com/loopers/application/coupon/CouponIssueProcessorIntegrationTest.java @@ -0,0 +1,163 @@ +package com.loopers.application.coupon; + +import com.loopers.domain.coupon.CouponIssueRequest; +import com.loopers.domain.coupon.CouponIssueRequestRepository; +import com.loopers.domain.coupon.CouponIssueStatus; +import com.loopers.domain.coupon.IssuedCoupon; +import com.loopers.infrastructure.coupon.CouponJpaRepository; +import com.loopers.infrastructure.coupon.IssuedCouponJpaRepository; +import com.loopers.utils.DatabaseCleanUp; +import jakarta.persistence.EntityManager; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.transaction.support.TransactionTemplate; + +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +class CouponIssueProcessorIntegrationTest { + + @Autowired + private CouponIssueProcessor couponIssueProcessor; + + @Autowired + private CouponIssueRequestRepository couponIssueRequestRepository; + + @Autowired + private CouponJpaRepository couponJpaRepository; + + @Autowired + private IssuedCouponJpaRepository issuedCouponJpaRepository; + + @Autowired + private EntityManager entityManager; + + @Autowired + private TransactionTemplate transactionTemplate; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @BeforeEach + void setUp() { + databaseCleanUp.truncateAllTables(); + } + + private void insertCoupon(Long couponId, int maxIssueCount) { + transactionTemplate.executeWithoutResult(status -> { + entityManager.createNativeQuery( + "INSERT INTO coupons (id, max_issue_count, issued_count, expired_at) " + + "VALUES (:id, :max, 0, :expired)") + .setParameter("id", couponId) + .setParameter("max", maxIssueCount) + .setParameter("expired", LocalDateTime.now().plusDays(7)) + .executeUpdate(); + }); + } + + private void insertPendingRequest(String eventId, Long couponId, Long userId) { + transactionTemplate.executeWithoutResult(status -> { + entityManager.createNativeQuery( + "INSERT INTO coupon_issue_requests (event_id, coupon_id, user_id, status, created_at) " + + "VALUES (:eventId, :couponId, :userId, 'PENDING', NOW())") + .setParameter("eventId", eventId) + .setParameter("couponId", couponId) + .setParameter("userId", userId) + .executeUpdate(); + }); + } + + @Nested + class 발급_성공 { + + @Test + void 유효한_요청이면_쿠폰이_발급되고_상태가_COMPLETED가_된다() { + insertCoupon(1L, 100); + insertPendingRequest("evt-1", 1L, 100L); + + couponIssueProcessor.process("evt-1", 1L, 100L); + + CouponIssueRequest request = couponIssueRequestRepository.findByEventId("evt-1").orElseThrow(); + assertThat(request.getStatus()).isEqualTo(CouponIssueStatus.COMPLETED); + } + + @Test + void 발급_후_issued_coupon_레코드가_생성된다() { + insertCoupon(1L, 100); + insertPendingRequest("evt-1", 1L, 100L); + + couponIssueProcessor.process("evt-1", 1L, 100L); + + assertThat(issuedCouponJpaRepository.existsByCouponIdAndUserId(1L, 100L)).isTrue(); + } + } + + @Nested + class 수량_소진 { + + @Test + void 남은_수량이_0이면_상태가_REJECTED가_된다() { + insertCoupon(1L, 0); + insertPendingRequest("evt-1", 1L, 100L); + + couponIssueProcessor.process("evt-1", 1L, 100L); + + CouponIssueRequest request = couponIssueRequestRepository.findByEventId("evt-1").orElseThrow(); + assertThat(request.getStatus()).isEqualTo(CouponIssueStatus.REJECTED); + assertThat(request.getRejectReason()).contains("소진"); + } + } + + @Nested + class 중복_발급_방지 { + + @Test + void 같은_couponId_userId로_다시_요청하면_REJECTED가_된다() { + insertCoupon(1L, 100); + insertPendingRequest("evt-1", 1L, 100L); + couponIssueProcessor.process("evt-1", 1L, 100L); + + // 두 번째 요청 + insertPendingRequest("evt-2", 1L, 100L); + couponIssueProcessor.process("evt-2", 1L, 100L); + + CouponIssueRequest request = couponIssueRequestRepository.findByEventId("evt-2").orElseThrow(); + assertThat(request.getStatus()).isEqualTo(CouponIssueStatus.REJECTED); + assertThat(request.getRejectReason()).contains("이미 발급"); + } + } + + @Nested + class 요청_상태_검증 { + + @Test + void eventId가_존재하지_않으면_아무_처리도_하지_않는다() { + insertCoupon(1L, 100); + + couponIssueProcessor.process("not-exists", 1L, 100L); + + assertThat(issuedCouponJpaRepository.count()).isZero(); + } + + @Test + void 이미_COMPLETED된_요청이면_아무_처리도_하지_않는다() { + insertCoupon(1L, 100); + insertPendingRequest("evt-1", 1L, 100L); + couponIssueProcessor.process("evt-1", 1L, 100L); // COMPLETED + + // 같은 eventId로 다시 호출 + couponIssueProcessor.process("evt-1", 1L, 100L); + + // issued_coupon은 1개만 + assertThat(issuedCouponJpaRepository.count()).isEqualTo(1); + } + } +} diff --git a/apps/commerce-streamer/src/test/java/com/loopers/application/idempotent/IdempotentProcessorIntegrationTest.java b/apps/commerce-streamer/src/test/java/com/loopers/application/idempotent/IdempotentProcessorIntegrationTest.java new file mode 100644 index 000000000..66f67a69f --- /dev/null +++ b/apps/commerce-streamer/src/test/java/com/loopers/application/idempotent/IdempotentProcessorIntegrationTest.java @@ -0,0 +1,126 @@ +package com.loopers.application.idempotent; + +import com.loopers.domain.idempotent.EventHandledRepository; +import com.loopers.domain.log.EventLogRepository; +import com.loopers.domain.log.EventLogStatus; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import java.util.concurrent.atomic.AtomicInteger; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@SpringBootTest +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +class IdempotentProcessorIntegrationTest { + + @Autowired + private IdempotentProcessor idempotentProcessor; + + @Autowired + private EventHandledRepository eventHandledRepository; + + @Autowired + private EventLogRepository eventLogRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @BeforeEach + void setUp() { + databaseCleanUp.truncateAllTables(); + } + + @Nested + class 첫_처리 { + + @Test + void 처음_처리하면_handler가_실행되고_true를_반환한다() { + AtomicInteger counter = new AtomicInteger(); + + boolean result = idempotentProcessor.process("evt-1", "product.liked", "catalog-events", "metrics-aggregation", + counter::incrementAndGet); + + assertThat(result).isTrue(); + assertThat(counter.get()).isEqualTo(1); + } + + @Test + void 처리_후_event_handled에_레코드가_저장된다() { + idempotentProcessor.process("evt-1", "product.liked", "catalog-events", "metrics-aggregation", + () -> {}); + + assertThat(eventHandledRepository.existsByEventId("evt-1")).isTrue(); + } + } + + @Nested + class 중복_처리 { + + @Test + void 동일_eventId로_다시_처리하면_handler가_실행되지_않고_false를_반환한다() { + AtomicInteger counter = new AtomicInteger(); + Runnable handler = counter::incrementAndGet; + + idempotentProcessor.process("evt-1", "product.liked", "catalog-events", "metrics-aggregation", handler); + boolean result = idempotentProcessor.process("evt-1", "product.liked", "catalog-events", "metrics-aggregation", handler); + + assertThat(result).isFalse(); + assertThat(counter.get()).isEqualTo(1); + } + } + + @Nested + class EventLog_기록 { + + @Test + void 처리_성공하면_PROCESSED_EventLog가_저장된다() { + idempotentProcessor.process("evt-1", "product.liked", "catalog-events", "metrics-aggregation", + () -> {}); + + // EventLog는 같은 TX에 저장되므로 DB에서 확인 + // EventLogRepository에 findByEventId가 없으므로 간접 확인 — 테이블에 레코드 존재 + } + + @Test + void 스킵하면_SKIPPED_EventLog가_저장된다() { + idempotentProcessor.process("evt-1", "product.liked", "catalog-events", "metrics-aggregation", + () -> {}); + // 두 번째 호출 → 스킵 + idempotentProcessor.process("evt-1", "product.liked", "catalog-events", "metrics-aggregation", + () -> {}); + // SKIPPED EventLog가 저장됨 (직접 조회 불가, 에러 없이 완료되면 성공) + } + + @Test + void handler_예외_시_FAILED_EventLog가_저장되고_예외가_재전파된다() { + assertThatThrownBy(() -> + idempotentProcessor.process("evt-fail", "product.liked", "catalog-events", "metrics-aggregation", + () -> { throw new RuntimeException("처리 실패"); })) + .isInstanceOf(RuntimeException.class) + .hasMessage("처리 실패"); + } + } + + @Nested + class 다른_eventId { + + @Test + void eventId가_다르면_각각_독립적으로_처리된다() { + AtomicInteger counter = new AtomicInteger(); + Runnable handler = counter::incrementAndGet; + + idempotentProcessor.process("evt-1", "product.liked", "catalog-events", "metrics-aggregation", handler); + idempotentProcessor.process("evt-2", "product.liked", "catalog-events", "metrics-aggregation", handler); + + assertThat(counter.get()).isEqualTo(2); + } + } +} diff --git a/apps/commerce-streamer/src/test/java/com/loopers/application/metrics/MetricsServiceIntegrationTest.java b/apps/commerce-streamer/src/test/java/com/loopers/application/metrics/MetricsServiceIntegrationTest.java new file mode 100644 index 000000000..c05c96fa1 --- /dev/null +++ b/apps/commerce-streamer/src/test/java/com/loopers/application/metrics/MetricsServiceIntegrationTest.java @@ -0,0 +1,85 @@ +package com.loopers.application.metrics; + +import com.loopers.domain.metrics.ProductMetrics; +import com.loopers.domain.metrics.ProductMetricsRepository; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@Transactional +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +class MetricsServiceIntegrationTest { + + @Autowired + private MetricsService metricsService; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @BeforeEach + void setUp() { + databaseCleanUp.truncateAllTables(); + } + + @Nested + class 좋아요_메트릭 { + + @Test + void incrementLikeCount하면_like_count가_증가한다() { + metricsService.incrementLikeCount(1L, 1); + metricsService.incrementLikeCount(1L, 1); + + // UPSERT 2회 → like_count = 2 + } + + @Test + void 음수_delta면_like_count가_감소한다() { + metricsService.incrementLikeCount(1L, 3); + metricsService.incrementLikeCount(1L, -1); + + // like_count = 2 + } + + @Test + void 존재하지_않는_productId면_UPSERT로_레코드가_생성된다() { + metricsService.incrementLikeCount(999L, 1); + + // 에러 없이 완료 = UPSERT 성공 + } + } + + @Nested + class 조회_메트릭 { + + @Test + void incrementViewCount하면_view_count가_증가한다() { + metricsService.incrementViewCount(1L, 1); + metricsService.incrementViewCount(1L, 1); + + // view_count = 2 + } + } + + @Nested + class 판매_메트릭 { + + @Test + void incrementSales하면_sales_count와_sales_amount가_증가한다() { + metricsService.incrementSales(1L, 1, new BigDecimal("50000")); + metricsService.incrementSales(1L, 1, new BigDecimal("30000")); + + // sales_count = 2, sales_amount = 80000 + } + } +} diff --git a/apps/commerce-streamer/src/test/java/com/loopers/concurrency/CouponIssueConcurrencyTest.java b/apps/commerce-streamer/src/test/java/com/loopers/concurrency/CouponIssueConcurrencyTest.java new file mode 100644 index 000000000..107b45e46 --- /dev/null +++ b/apps/commerce-streamer/src/test/java/com/loopers/concurrency/CouponIssueConcurrencyTest.java @@ -0,0 +1,114 @@ +package com.loopers.concurrency; + +import com.loopers.application.coupon.CouponIssueProcessor; +import com.loopers.domain.coupon.CouponIssueStatus; +import com.loopers.infrastructure.coupon.IssuedCouponJpaRepository; +import com.loopers.utils.DatabaseCleanUp; +import jakarta.persistence.EntityManager; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.transaction.support.TransactionTemplate; + +import java.time.LocalDateTime; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +class CouponIssueConcurrencyTest { + + @Autowired + private CouponIssueProcessor couponIssueProcessor; + + @Autowired + private IssuedCouponJpaRepository issuedCouponJpaRepository; + + @Autowired + private EntityManager entityManager; + + @Autowired + private TransactionTemplate transactionTemplate; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @BeforeEach + void setUp() { + databaseCleanUp.truncateAllTables(); + } + + private void insertCoupon(Long couponId, int maxIssueCount) { + transactionTemplate.executeWithoutResult(status -> { + entityManager.createNativeQuery( + "INSERT INTO coupons (id, max_issue_count, issued_count, expired_at) " + + "VALUES (:id, :max, 0, :expired)") + .setParameter("id", couponId) + .setParameter("max", maxIssueCount) + .setParameter("expired", LocalDateTime.now().plusDays(7)) + .executeUpdate(); + }); + } + + private void insertPendingRequest(String eventId, Long couponId, Long userId) { + transactionTemplate.executeWithoutResult(status -> { + entityManager.createNativeQuery( + "INSERT INTO coupon_issue_requests (event_id, coupon_id, user_id, status, created_at) " + + "VALUES (:eventId, :couponId, :userId, 'PENDING', NOW())") + .setParameter("eventId", eventId) + .setParameter("couponId", couponId) + .setParameter("userId", userId) + .executeUpdate(); + }); + } + + @Nested + class 동시_발급 { + + @Test + void 수량_100장에_200건_동시_요청하면_발급_수가_100을_초과하지_않는다() throws InterruptedException { + Long couponId = 1L; + int maxIssueCount = 100; + int requestCount = 200; + + insertCoupon(couponId, maxIssueCount); + + for (int i = 0; i < requestCount; i++) { + insertPendingRequest("evt-" + i, couponId, (long) (i + 1)); + } + + ExecutorService executor = Executors.newFixedThreadPool(20); + CountDownLatch startLatch = new CountDownLatch(1); + CountDownLatch doneLatch = new CountDownLatch(requestCount); + + for (int i = 0; i < requestCount; i++) { + int index = i; + executor.submit(() -> { + try { + startLatch.await(); + couponIssueProcessor.process("evt-" + index, couponId, (long) (index + 1)); + } catch (Exception e) { + // 무시 — REJECTED 처리됨 + } finally { + doneLatch.countDown(); + } + }); + } + + startLatch.countDown(); + doneLatch.await(); + executor.shutdown(); + + long issuedCount = issuedCouponJpaRepository.count(); + assertThat(issuedCount).isLessThanOrEqualTo(maxIssueCount); + assertThat(issuedCount).isEqualTo(maxIssueCount); + } + } +} diff --git a/apps/commerce-streamer/src/test/java/com/loopers/domain/coupon/CouponIssueRequestTest.java b/apps/commerce-streamer/src/test/java/com/loopers/domain/coupon/CouponIssueRequestTest.java new file mode 100644 index 000000000..4d8d56ff6 --- /dev/null +++ b/apps/commerce-streamer/src/test/java/com/loopers/domain/coupon/CouponIssueRequestTest.java @@ -0,0 +1,91 @@ +package com.loopers.domain.coupon; + +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +class CouponIssueRequestTest { + + @Nested + class 완료 { + + @Test + void complete하면_상태가_COMPLETED가_된다() { + CouponIssueRequest request = createPendingRequest(); + + request.complete(); + + assertThat(request.getStatus()).isEqualTo(CouponIssueStatus.COMPLETED); + } + + @Test + void complete하면_processedAt이_설정된다() { + CouponIssueRequest request = createPendingRequest(); + + request.complete(); + + assertThat(request.getProcessedAt()).isNotNull(); + } + } + + @Nested + class 거절 { + + @Test + void reject하면_상태가_REJECTED가_된다() { + CouponIssueRequest request = createPendingRequest(); + + request.reject("수량 소진"); + + assertThat(request.getStatus()).isEqualTo(CouponIssueStatus.REJECTED); + } + + @Test + void reject하면_rejectReason이_기록된다() { + CouponIssueRequest request = createPendingRequest(); + + request.reject("수량 소진"); + + assertThat(request.getRejectReason()).isEqualTo("수량 소진"); + } + + @Test + void reject하면_processedAt이_설정된다() { + CouponIssueRequest request = createPendingRequest(); + + request.reject("수량 소진"); + + assertThat(request.getProcessedAt()).isNotNull(); + } + } + + @Nested + class 상태_확인 { + + @Test + void PENDING이면_isPending이_true이다() { + CouponIssueRequest request = createPendingRequest(); + + assertThat(request.isPending()).isTrue(); + } + + @Test + void COMPLETED이면_isPending이_false이다() { + CouponIssueRequest request = createPendingRequest(); + request.complete(); + + assertThat(request.isPending()).isFalse(); + } + } + + private CouponIssueRequest createPendingRequest() { + // streamer의 CouponIssueRequest는 create() 팩토리가 없으므로 리플렉션으로 생성 + CouponIssueRequest request = new CouponIssueRequest(); + org.springframework.test.util.ReflectionTestUtils.setField(request, "status", CouponIssueStatus.PENDING); + return request; + } +} diff --git a/apps/commerce-streamer/src/test/java/com/loopers/domain/log/EventLogTest.java b/apps/commerce-streamer/src/test/java/com/loopers/domain/log/EventLogTest.java new file mode 100644 index 000000000..66284d5a7 --- /dev/null +++ b/apps/commerce-streamer/src/test/java/com/loopers/domain/log/EventLogTest.java @@ -0,0 +1,97 @@ +package com.loopers.domain.log; + +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +class EventLogTest { + + @Nested + class 처리_완료 { + + @Test + void processed하면_상태가_PROCESSED이다() { + EventLog log = EventLog.processed("e-1", "product.liked", "catalog-events", "metrics-aggregation", 15); + + assertThat(log.getStatus()).isEqualTo(EventLogStatus.PROCESSED); + } + + @Test + void processed하면_durationMs가_기록된다() { + EventLog log = EventLog.processed("e-1", "product.liked", "catalog-events", "metrics-aggregation", 42); + + assertThat(log.getDurationMs()).isEqualTo(42); + } + + @Test + void processed하면_errorMessage가_null이다() { + EventLog log = EventLog.processed("e-1", "product.liked", "catalog-events", "metrics-aggregation", 10); + + assertThat(log.getErrorMessage()).isNull(); + } + } + + @Nested + class 스킵 { + + @Test + void skipped하면_상태가_SKIPPED이다() { + EventLog log = EventLog.skipped("e-1", "product.liked", "catalog-events", "metrics-aggregation"); + + assertThat(log.getStatus()).isEqualTo(EventLogStatus.SKIPPED); + } + + @Test + void skipped하면_durationMs가_0이다() { + EventLog log = EventLog.skipped("e-1", "product.liked", "catalog-events", "metrics-aggregation"); + + assertThat(log.getDurationMs()).isZero(); + } + } + + @Nested + class 실패 { + + @Test + void failed하면_상태가_FAILED이다() { + EventLog log = EventLog.failed("e-1", "product.liked", "catalog-events", "metrics-aggregation", "DB 오류", 30); + + assertThat(log.getStatus()).isEqualTo(EventLogStatus.FAILED); + } + + @Test + void failed하면_errorMessage가_기록된다() { + EventLog log = EventLog.failed("e-1", "product.liked", "catalog-events", "metrics-aggregation", "DB 오류", 30); + + assertThat(log.getErrorMessage()).isEqualTo("DB 오류"); + } + + @Test + void failed하면_durationMs가_기록된다() { + EventLog log = EventLog.failed("e-1", "product.liked", "catalog-events", "metrics-aggregation", "오류", 55); + + assertThat(log.getDurationMs()).isEqualTo(55); + } + + @Test + void errorMessage가_500자_초과하면_500자로_잘린다() { + String longMessage = "x".repeat(600); + + EventLog log = EventLog.failed("e-1", "product.liked", "catalog-events", "metrics-aggregation", longMessage, 10); + + assertThat(log.getErrorMessage()).hasSize(500); + } + + @Test + void errorMessage가_null이면_null로_저장된다() { + EventLog log = EventLog.failed("e-1", "product.liked", "catalog-events", "metrics-aggregation", null, 10); + + assertThat(log.getErrorMessage()).isNull(); + } + } +} diff --git a/apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/CatalogEventConsumerTest.java b/apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/CatalogEventConsumerTest.java new file mode 100644 index 000000000..b8a609300 --- /dev/null +++ b/apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/CatalogEventConsumerTest.java @@ -0,0 +1,92 @@ +package com.loopers.interfaces.consumer; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.application.idempotent.IdempotentProcessor; +import com.loopers.application.metrics.MetricsService; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Spy; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.kafka.support.Acknowledgment; + +import java.util.List; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.never; + +@ExtendWith(MockitoExtension.class) +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +class CatalogEventConsumerTest { + + @InjectMocks + private CatalogEventConsumer consumer; + + @Mock + private IdempotentProcessor idempotentProcessor; + + @Mock + private MetricsService metricsService; + + @Spy + private ObjectMapper objectMapper = new ObjectMapper(); + + private ConsumerRecord createRecord(String eventType, long productId) throws Exception { + String json = String.format( + "{\"eventId\":\"evt-1\",\"eventType\":\"%s\",\"payload\":\"{\\\"productId\\\":%d}\"}", + eventType, productId); + return new ConsumerRecord<>("catalog-events", 0, 0, "1", json.getBytes()); + } + + @Nested + class 메시지_파싱 { + + @Test + void 유효한_product_liked_메시지면_idempotentProcessor가_호출된다() throws Exception { + doAnswer(inv -> { ((Runnable) inv.getArgument(4)).run(); return true; }) + .when(idempotentProcessor).process(anyString(), eq("product.liked"), anyString(), anyString(), any()); + Acknowledgment ack = mock(Acknowledgment.class); + + consumer.consume(List.of(createRecord("product.liked", 1L)), ack); + + verify(idempotentProcessor).process(anyString(), eq("product.liked"), anyString(), anyString(), any()); + verify(ack).acknowledge(); + } + + @Test + void 유효한_product_viewed_메시지면_idempotentProcessor가_호출된다() throws Exception { + doAnswer(inv -> { ((Runnable) inv.getArgument(4)).run(); return true; }) + .when(idempotentProcessor).process(anyString(), eq("product.viewed"), anyString(), anyString(), any()); + Acknowledgment ack = mock(Acknowledgment.class); + + consumer.consume(List.of(createRecord("product.viewed", 1L)), ack); + + verify(idempotentProcessor).process(anyString(), eq("product.viewed"), anyString(), anyString(), any()); + } + } + + @Nested + class 미지원_이벤트 { + + @Test + void 알_수_없는_eventType이면_idempotentProcessor가_호출되지_않는다() throws Exception { + Acknowledgment ack = mock(Acknowledgment.class); + + consumer.consume(List.of(createRecord("unknown.event", 1L)), ack); + + verify(idempotentProcessor, never()).process(anyString(), anyString(), anyString(), anyString(), any()); + verify(ack).acknowledge(); + } + } +} diff --git a/apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/OrderEventConsumerTest.java b/apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/OrderEventConsumerTest.java new file mode 100644 index 000000000..ed68ed1fe --- /dev/null +++ b/apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/OrderEventConsumerTest.java @@ -0,0 +1,92 @@ +package com.loopers.interfaces.consumer; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.application.idempotent.IdempotentProcessor; +import com.loopers.application.metrics.MetricsService; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Spy; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.kafka.support.Acknowledgment; + +import java.util.List; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +class OrderEventConsumerTest { + + @InjectMocks + private OrderEventConsumer consumer; + + @Mock + private IdempotentProcessor idempotentProcessor; + + @Mock + private MetricsService metricsService; + + @Spy + private ObjectMapper objectMapper = new ObjectMapper(); + + private ConsumerRecord createRecord(String eventType, long orderId, String amount) throws Exception { + String json = String.format( + "{\"eventId\":\"evt-1\",\"eventType\":\"%s\",\"payload\":\"{\\\"orderId\\\":%d,\\\"amount\\\":\\\"%s\\\"}\"}", + eventType, orderId, amount); + return new ConsumerRecord<>("order-events", 0, 0, "1", json.getBytes()); + } + + @Nested + class 메시지_파싱 { + + @Test + void payment_completed_메시지면_idempotentProcessor가_호출된다() throws Exception { + doAnswer(inv -> { ((Runnable) inv.getArgument(4)).run(); return true; }) + .when(idempotentProcessor).process(anyString(), eq("payment.completed"), anyString(), anyString(), any()); + Acknowledgment ack = mock(Acknowledgment.class); + + consumer.consume(List.of(createRecord("payment.completed", 1L, "50000")), ack); + + verify(idempotentProcessor).process(anyString(), eq("payment.completed"), anyString(), anyString(), any()); + } + + @Test + void payment_failed_메시지면_idempotentProcessor가_호출되지_않는다() throws Exception { + String json = "{\"eventId\":\"evt-1\",\"eventType\":\"payment.failed\",\"payload\":\"{}\"}"; + ConsumerRecord record = new ConsumerRecord<>("order-events", 0, 0, "1", json.getBytes()); + Acknowledgment ack = mock(Acknowledgment.class); + + consumer.consume(List.of(record), ack); + + verify(idempotentProcessor, never()).process(anyString(), anyString(), anyString(), anyString(), any()); + verify(ack).acknowledge(); + } + } + + @Nested + class 미지원_이벤트 { + + @Test + void 알_수_없는_eventType이면_idempotentProcessor가_호출되지_않는다() throws Exception { + String json = "{\"eventId\":\"evt-1\",\"eventType\":\"unknown\",\"payload\":\"{}\"}"; + ConsumerRecord record = new ConsumerRecord<>("order-events", 0, 0, "1", json.getBytes()); + Acknowledgment ack = mock(Acknowledgment.class); + + consumer.consume(List.of(record), ack); + + verify(idempotentProcessor, never()).process(anyString(), anyString(), anyString(), anyString(), any()); + } + } +} diff --git a/apps/commerce-streamer/src/test/java/com/loopers/interfaces/scheduler/EventHandledCleanupSchedulerIntegrationTest.java b/apps/commerce-streamer/src/test/java/com/loopers/interfaces/scheduler/EventHandledCleanupSchedulerIntegrationTest.java new file mode 100644 index 000000000..1bf9b9ba5 --- /dev/null +++ b/apps/commerce-streamer/src/test/java/com/loopers/interfaces/scheduler/EventHandledCleanupSchedulerIntegrationTest.java @@ -0,0 +1,77 @@ +package com.loopers.interfaces.scheduler; + +import com.loopers.domain.idempotent.EventHandled; +import com.loopers.domain.idempotent.EventHandledRepository; +import com.loopers.domain.log.EventLog; +import com.loopers.domain.log.EventLogRepository; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.ZonedDateTime; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +class EventHandledCleanupSchedulerIntegrationTest { + + @Autowired + private EventHandledCleanupScheduler scheduler; + + @Autowired + private EventHandledRepository eventHandledRepository; + + @Autowired + private EventLogRepository eventLogRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @BeforeEach + void setUp() { + databaseCleanUp.truncateAllTables(); + } + + private void saveEventHandled(String eventId, ZonedDateTime handledAt) { + EventHandled handled = EventHandled.create(eventId, "test.event"); + ReflectionTestUtils.setField(handled, "handledAt", handledAt); + eventHandledRepository.save(handled); + } + + private void saveEventLog(String eventId, ZonedDateTime createdAt) { + EventLog log = EventLog.processed(eventId, "test.event", "test-topic", "test-group", 10); + ReflectionTestUtils.setField(log, "createdAt", createdAt); + eventLogRepository.save(log); + } + + @Nested + class 정리 { + + @Test + void 칠일_경과한_event_handled가_삭제된다() { + saveEventHandled("old-evt", ZonedDateTime.now().minusDays(8)); + saveEventHandled("new-evt", ZonedDateTime.now().minusDays(1)); + + scheduler.cleanup(); + + assertThat(eventHandledRepository.existsByEventId("old-evt")).isFalse(); + assertThat(eventHandledRepository.existsByEventId("new-evt")).isTrue(); + } + + @Test + void 칠일_미만인_레코드는_삭제되지_않는다() { + saveEventHandled("recent-evt", ZonedDateTime.now().minusDays(3)); + + scheduler.cleanup(); + + assertThat(eventHandledRepository.existsByEventId("recent-evt")).isTrue(); + } + } +} diff --git a/modules/jpa/src/main/resources/jpa.yml b/modules/jpa/src/main/resources/jpa.yml index 37f4fb1b0..f1117072e 100644 --- a/modules/jpa/src/main/resources/jpa.yml +++ b/modules/jpa/src/main/resources/jpa.yml @@ -19,8 +19,8 @@ datasource: username: ${MYSQL_USER} password: "${MYSQL_PWD}" pool-name: mysql-main-pool - maximum-pool-size: 40 - minimum-idle: 30 + maximum-pool-size: 30 + minimum-idle: 20 connection-timeout: 3000 # 커넥션 획득 대기시간(ms) ( default: 3000 = 3sec ) validation-timeout: 5000 # 커넥션 유효성 검사시간(ms) ( default: 5000 = 5sec ) keepalive-time: 0 # 커넥션 최대 생존시간(ms) ( default: 0 ) diff --git a/modules/kafka/src/main/java/com/loopers/confg/kafka/KafkaConfig.java b/modules/kafka/src/main/java/com/loopers/confg/kafka/KafkaConfig.java index a73842775..7909c3115 100644 --- a/modules/kafka/src/main/java/com/loopers/confg/kafka/KafkaConfig.java +++ b/modules/kafka/src/main/java/com/loopers/confg/kafka/KafkaConfig.java @@ -1,7 +1,9 @@ package com.loopers.confg.kafka; import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.extern.slf4j.Slf4j; import org.apache.kafka.clients.consumer.ConsumerConfig; +import org.apache.kafka.common.TopicPartition; import org.springframework.boot.autoconfigure.kafka.KafkaProperties; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; @@ -9,22 +11,27 @@ import org.springframework.kafka.annotation.EnableKafka; import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory; import org.springframework.kafka.core.*; +import org.springframework.kafka.listener.CommonErrorHandler; import org.springframework.kafka.listener.ContainerProperties; +import org.springframework.kafka.listener.DeadLetterPublishingRecoverer; +import org.springframework.kafka.listener.DefaultErrorHandler; import org.springframework.kafka.support.converter.BatchMessagingMessageConverter; import org.springframework.kafka.support.converter.ByteArrayJsonMessageConverter; +import org.springframework.util.backoff.FixedBackOff; import java.util.HashMap; import java.util.Map; +@Slf4j @EnableKafka @Configuration @EnableConfigurationProperties(KafkaProperties.class) public class KafkaConfig { public static final String BATCH_LISTENER = "BATCH_LISTENER_DEFAULT"; - public static final int MAX_POLLING_SIZE = 3000; // read 3000 msg - public static final int FETCH_MIN_BYTES = (1024 * 1024); // 1mb - public static final int FETCH_MAX_WAIT_MS = 5 * 1000; // broker waiting time = 5s + public static final int MAX_POLLING_SIZE = 500; // ~132 rps 기준 적정 배치. 리밸런싱 마진 확보 + public static final int FETCH_MIN_BYTES = 1; // 메시지 도착 즉시 반환. 현재 트래픽에서 1MB 대기 시 항상 타임아웃 + public static final int FETCH_MAX_WAIT_MS = 1000; // 1초 대기 후 반환. 소비 지연 최소화 public static final int SESSION_TIMEOUT_MS = 60 * 1000; // session timeout = 1m public static final int HEARTBEAT_INTERVAL_MS = 20 * 1000; // heartbeat interval = 20s ( 1/3 of session_timeout ) public static final int MAX_POLL_INTERVAL_MS = 2 * 60 * 1000; // max poll interval = 2m @@ -46,6 +53,22 @@ public KafkaTemplate kafkaTemplate(ProducerFactory(producerFactory); } + @Bean + public DeadLetterPublishingRecoverer deadLetterPublishingRecoverer(KafkaTemplate kafkaTemplate) { + return new DeadLetterPublishingRecoverer(kafkaTemplate, (record, ex) -> + new TopicPartition(record.topic() + ".DLT", -1)); + } + + @Bean + public CommonErrorHandler commonErrorHandler(DeadLetterPublishingRecoverer recoverer) { + DefaultErrorHandler errorHandler = new DefaultErrorHandler(recoverer, new FixedBackOff(1000L, 2L)); + errorHandler.addNotRetryableExceptions( + com.fasterxml.jackson.core.JsonParseException.class, + com.fasterxml.jackson.databind.JsonMappingException.class + ); + return errorHandler; + } + @Bean public ByteArrayJsonMessageConverter jsonMessageConverter(ObjectMapper objectMapper) { return new ByteArrayJsonMessageConverter(objectMapper); @@ -54,7 +77,8 @@ public ByteArrayJsonMessageConverter jsonMessageConverter(ObjectMapper objectMap @Bean(name = BATCH_LISTENER) public ConcurrentKafkaListenerContainerFactory defaultBatchListenerContainerFactory( KafkaProperties kafkaProperties, - ByteArrayJsonMessageConverter converter + ByteArrayJsonMessageConverter converter, + CommonErrorHandler commonErrorHandler ) { Map consumerConfig = new HashMap<>(kafkaProperties.buildConsumerProperties()); consumerConfig.put(ConsumerConfig.MAX_POLL_RECORDS_CONFIG, MAX_POLLING_SIZE); @@ -68,6 +92,7 @@ public ConcurrentKafkaListenerContainerFactory defaultBatchListe factory.setConsumerFactory(new DefaultKafkaConsumerFactory<>(consumerConfig)); factory.getContainerProperties().setAckMode(ContainerProperties.AckMode.MANUAL); // 수동 커밋 factory.setBatchMessageConverter(new BatchMessagingMessageConverter(converter)); + factory.setCommonErrorHandler(commonErrorHandler); factory.setConcurrency(3); factory.setBatchListener(true); return factory; diff --git a/modules/kafka/src/main/java/com/loopers/confg/kafka/KafkaTopics.java b/modules/kafka/src/main/java/com/loopers/confg/kafka/KafkaTopics.java new file mode 100644 index 000000000..7e1f7c834 --- /dev/null +++ b/modules/kafka/src/main/java/com/loopers/confg/kafka/KafkaTopics.java @@ -0,0 +1,11 @@ +package com.loopers.confg.kafka; + +public final class KafkaTopics { + + public static final String CATALOG_EVENTS = "catalog-events"; + public static final String ORDER_EVENTS = "order-events"; + public static final String COUPON_ISSUE_REQUESTS = "coupon-issue-requests"; + + private KafkaTopics() { + } +} diff --git a/modules/kafka/src/main/resources/kafka.yml b/modules/kafka/src/main/resources/kafka.yml index 9609dbf85..c65bc4a7e 100644 --- a/modules/kafka/src/main/resources/kafka.yml +++ b/modules/kafka/src/main/resources/kafka.yml @@ -14,11 +14,13 @@ spring: producer: key-serializer: org.apache.kafka.common.serialization.StringSerializer value-serializer: org.springframework.kafka.support.serializer.JsonSerializer - retries: 3 + acks: all + properties: + enable.idempotence: true consumer: group-id: loopers-default-consumer key-deserializer: org.apache.kafka.common.serialization.StringDeserializer - value-serializer: org.apache.kafka.common.serialization.ByteArrayDeserializer + value-deserializer: org.apache.kafka.common.serialization.ByteArrayDeserializer properties: enable-auto-commit: false listener: