Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
7994f6a
feat: ApplicationEvent 기반 부가 로직 분리
ghtjr410 Mar 27, 2026
4aa948b
feat: Transactional Outbox Pattern 구현
ghtjr410 Mar 27, 2026
646f4e0
feat: Kafka Consumer 멱등 처리 + 메트릭 집계
ghtjr410 Mar 27, 2026
c5ed60d
feat: Kafka 기반 선착순 쿠폰 발급
ghtjr410 Mar 27, 2026
a68c233
fix: AsyncConfig에 AsyncUncaughtExceptionHandler 안전망 추가
ghtjr410 Mar 27, 2026
ff29345
feat: DLQ 구성 + Consumer Group 분리 + 배치 처리
ghtjr410 Mar 27, 2026
d897d3a
feat: likeCount Reconciliation 스케줄러 + LikeApiE2ETest 비동기 대응
ghtjr410 Mar 27, 2026
bb79d3e
refactor: Outbox Relay를 즉시 발행 + Scheduled 보완 + 셀프컨슘으로 전환
ghtjr410 Mar 27, 2026
075e27d
refactor: Outbox INSERT를 BEFORE_COMMIT 리스너에서 Facade 직접 호출로 전환
ghtjr410 Mar 27, 2026
3492930
feat: Consumer 운영 가시성 추가 (EventLog + ConsumerMetrics)
ghtjr410 Mar 27, 2026
396d98f
fix: Consumer 셀프컨슘으로 Outbox SENT 마킹 + Javadoc 수정
ghtjr410 Mar 27, 2026
e2394f1
fix: OutboxRelayScheduler를 .get() 동기 방식으로 전환, backoff 제거
ghtjr410 Mar 27, 2026
d32c0f8
refactor: 토픽 상수 중앙화 + TransactionHelper 적용
ghtjr410 Mar 27, 2026
d8e5194
refactor: 셀프컨슘 제거, Kafka ACK 기준 SENT 마킹으로 통일
ghtjr410 Mar 27, 2026
9838ef9
refactor: SENT 마킹을 @Modifying + @Transactional로 단순화
ghtjr410 Mar 27, 2026
cd9fc59
feat: OutboxEventService에 @Transactional(MANDATORY) 추가
ghtjr410 Mar 27, 2026
b8b10ec
test: EDA 단위 테스트 5개 추가
ghtjr410 Mar 27, 2026
aeeb877
test: commerce-api 통합 테스트 3개 추가
ghtjr410 Mar 27, 2026
9367061
test: commerce-streamer 통합 테스트 2개 추가
ghtjr410 Mar 27, 2026
aac48c3
test: Consumer 파싱/분기 단위 테스트 + streamer 통합 테스트 추가
ghtjr410 Mar 27, 2026
6755f9a
test: 스케줄러 통합 테스트 2개 추가
ghtjr410 Mar 27, 2026
58ba3f2
test: CouponAsyncApiE2ETest 추가
ghtjr410 Mar 27, 2026
c55b3eb
refactor: 쿠폰 발급을 네이티브 쿼리에서 JPA Entity + JPQL로 전환 + 통합 테스트
ghtjr410 Mar 27, 2026
c591b89
test: CouponIssueConcurrencyTest 추가
ghtjr410 Mar 27, 2026
057d32c
refactor: Reconciliation을 product_metrics에서 likes COUNT(*)로 전환 + 통합 테스트
ghtjr410 Mar 27, 2026
a130ebf
chore: 리소스 풀 설정을 EC2 large(2vCPU, 8GB) 환경에 맞게 조정
ghtjr410 Mar 27, 2026
21886fc
fix: findPending에 stale 조건 추가 + PR 문서 트레이드오프 보강
ghtjr410 Mar 27, 2026
518ff3f
fix: Kafka Consumer 설정 현재 트래픽 기준 조정 + PR 문서 피드백 반영
ghtjr410 Mar 27, 2026
9db92dc
docs: product_metrics Reconciliation 부재 근거 + RETENTION_DAYS 7일 SLA 주석 추가
ghtjr410 Mar 27, 2026
7fe545c
docs: PR 문서 가독성 개선 — 목차 추가 + 시각 자료 전 요약 선행
ghtjr410 Mar 27, 2026
cb01af5
chore: PR.md, blog.md 삭제
ghtjr410 Mar 27, 2026
fb0d229
feat: CREATED 주문 만료 스케줄러 추가 — 10분 경과 시 자동 취소 + 재고/쿠폰 복원
ghtjr410 Mar 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/commerce-api/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand Down
Original file line number Diff line number Diff line change
@@ -1,19 +1,27 @@
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;
import org.springframework.transaction.annotation.Transactional;

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;

Expand All @@ -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

Expand Down Expand Up @@ -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<CouponIssueRequest> 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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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()
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package com.loopers.application.event;


public record CouponIssueRequestedEvent(String eventId, Long couponId, Long userId) {
}
Original file line number Diff line number Diff line change
@@ -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<Object, Object> 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) {
Comment on lines +53 to +57
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

publishToKafka() publishes the raw ProductLiked/UnlikedEvent JSON directly to catalog-events, but CatalogEventConsumer expects an envelope with eventId/eventType/payload. This contract mismatch will cause parsing failures (or empty eventType) in the streamer and prevent metrics updates. Consider publishing the same envelope format used by the consumers (and generating an eventId), or changing consumers to match the produced schema.

Copilot uses AI. Check for mistakes.
log.warn("Kafka 직접 발행 실패 (fire-and-forget): eventType={}, productId={}", eventType, productId, e);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package com.loopers.application.event;


public record PaymentCanceledEvent(Long paymentId, Long orderId, Long userId) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.loopers.application.event;


import java.math.BigDecimal;

public record PaymentCompletedEvent(Long paymentId, Long orderId, Long userId, BigDecimal amount) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package com.loopers.application.event;


public record PaymentFailedEvent(Long paymentId, Long orderId, Long userId, String reason) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package com.loopers.application.event;


public record ProductLikedEvent(Long userId, Long productId) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package com.loopers.application.event;


public record ProductUnlikedEvent(Long userId, Long productId) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
package com.loopers.application.event;

public record ProductViewedEvent(Long userId, Long productId) {
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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;
Expand All @@ -27,6 +29,7 @@ public class LikeFacade {
private final ProductService productService;
private final BrandService brandService;
private final ProductCacheManager productCacheManager;
private final ApplicationEventPublisher eventPublisher;

// Command

Expand All @@ -36,17 +39,15 @@ 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));
}
}

@Transactional
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));
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -91,4 +96,9 @@ public Page<Order> findOrdersByProductId(Long productId, Pageable pageable) {
public List<Order> findOrdersByStatusWithItems(OrderStatus status) {
return orderRepository.findAllByStatusWithItems(status);
}

@Transactional(readOnly = true)
public List<Order> findCreatedOlderThanWithItems(ZonedDateTime threshold) {
return orderRepository.findAllByStatusAndCreatedAtBeforeWithItems(OrderStatus.CREATED, threshold);
}
}
Loading