Skip to content
Open
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
Expand Up @@ -4,9 +4,11 @@
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.annotation.EnableScheduling;
import java.util.TimeZone;

@EnableAsync
@EnableScheduling
@ConfigurationPropertiesScan
@SpringBootApplication
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
package com.loopers.application.coupon;

import com.loopers.domain.coupon.CouponIssueRequest;
import com.loopers.domain.coupon.CouponIssueRequestReader;
import com.loopers.domain.coupon.CouponIssueRequestRepository;
import com.loopers.domain.coupon.CouponIssueRequestStatus;
import com.loopers.domain.coupon.CouponService;
import com.loopers.domain.coupon.MemberCoupon;
import com.loopers.domain.member.Member;
import com.loopers.domain.member.MemberService;
import com.loopers.domain.outbox.OutboxEventPublisher;
import com.loopers.support.error.CoreException;
import com.loopers.support.error.ErrorType;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;
import java.util.Map;
import java.util.UUID;

@Slf4j
@RequiredArgsConstructor
@Component
public class CouponIssueFacade {

private final MemberService memberService;
private final CouponService couponService;
private final CouponIssueRequestRepository couponIssueRequestRepository;
private final CouponIssueRequestReader couponIssueRequestReader;
private final OutboxEventPublisher outboxEventPublisher;

@Transactional
public CouponIssueInfo requestAsyncIssuance(String loginId, Long couponId) {
Long memberId = getMemberId(loginId);

boolean alreadyRequested = couponIssueRequestReader.existsByMemberIdAndCouponIdAndStatusIn(
memberId, couponId,
List.of(CouponIssueRequestStatus.PENDING, CouponIssueRequestStatus.COMPLETED)
);
if (alreadyRequested) {
throw new CoreException(ErrorType.CONFLICT, "이미 쿠폰 발급 요청이 존재합니다.");
}

String requestId = UUID.randomUUID().toString();
CouponIssueRequest request = CouponIssueRequest.create(requestId, memberId, couponId);
couponIssueRequestRepository.save(request);

outboxEventPublisher.publish(
"COUPON", couponId, "COUPON_ISSUE_REQUESTED",
"coupon-issue-requests", String.valueOf(couponId),
Map.of("requestId", requestId, "memberId", memberId, "couponId", couponId)
);

return CouponIssueInfo.of(request);
}

@Transactional
public void processIssuance(String requestId) {
CouponIssueRequest request = couponIssueRequestReader.findByRequestId(requestId)
.orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "쿠폰 발급 요청을 찾을 수 없습니다."));

if (request.isProcessed()) {
log.info("이미 처리된 쿠폰 발급 요청: requestId={}, status={}", requestId, request.getStatus());
return;
}

try {
MemberCoupon memberCoupon = couponService.issueCoupon(request.getCouponId(), request.getMemberId());
request.complete(memberCoupon.getId());
log.info("쿠폰 발급 성공: requestId={}, memberCouponId={}", requestId, memberCoupon.getId());
} catch (CoreException e) {
request.fail(e.getMessage());
log.warn("쿠폰 발급 실패: requestId={}, reason={}", requestId, e.getMessage());
} catch (Exception e) {
log.error("쿠폰 발급 중 예기치 않은 오류: requestId={}, error={}", requestId, e.getMessage(), e);
throw e;
}
}

@Transactional(readOnly = true)
public CouponIssueInfo getIssueRequest(String requestId) {
CouponIssueRequest request = couponIssueRequestReader.findByRequestId(requestId)
.orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "쿠폰 발급 요청을 찾을 수 없습니다."));
return CouponIssueInfo.of(request);
}

private Long getMemberId(String loginId) {
Member member = memberService.getMemberByLoginId(loginId);
return member.getId();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package com.loopers.application.coupon;

import com.loopers.domain.coupon.CouponIssueRequest;

public record CouponIssueInfo(
String requestId,
Long memberId,
Long couponId,
String status,
String failReason,
Long memberCouponId
) {
public static CouponIssueInfo of(CouponIssueRequest request) {
return new CouponIssueInfo(
request.getRequestId(),
request.getMemberId(),
request.getCouponId(),
request.getStatus().name(),
request.getFailReason(),
request.getMemberCouponId()
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package com.loopers.application.event;

import com.loopers.domain.brand.BrandService;
import com.loopers.domain.event.LikeToggledEvent;
import com.loopers.domain.like.LikeTargetType;
import com.loopers.domain.product.ProductService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;

@Slf4j
@RequiredArgsConstructor
@Component
public class LikeEventListener {

private final ProductService productService;
private final BrandService brandService;

@Async("eventExecutor")
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handleLikeToggled(LikeToggledEvent event) {
log.info("[이벤트] 좋아요 토글 - memberId={}, targetType={}, targetId={}, liked={}",
event.memberId(), event.targetType(), event.targetId(), event.liked());

if (event.targetType() == LikeTargetType.PRODUCT) {
if (event.liked()) {
productService.increaseLikeCount(event.targetId());
} else {
productService.decreaseLikeCount(event.targetId());
}
} else if (event.targetType() == LikeTargetType.BRAND) {
if (event.liked()) {
brandService.increaseLikeCount(event.targetId());
} else {
brandService.decreaseLikeCount(event.targetId());
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package com.loopers.application.event;

import com.loopers.domain.event.OrderCancelledEvent;
import com.loopers.domain.event.OrderCreatedEvent;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;

@Slf4j
@Component
public class OrderEventListener {

@Async("eventExecutor")
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handleOrderCreated(OrderCreatedEvent event) {
log.info("[이벤트] 주문 생성 - orderId={}, memberId={}, totalAmount={}, itemCount={}",
event.orderId(), event.memberId(), event.totalAmount(), event.items().size());
}

@Async("eventExecutor")
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handleOrderCancelled(OrderCancelledEvent event) {
log.info("[이벤트] 주문 취소 - orderId={}, memberId={}",
event.orderId(), event.memberId());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package com.loopers.application.event;

import com.loopers.domain.event.PaymentCompletedEvent;
import com.loopers.domain.event.PaymentFailedEvent;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;

@Slf4j
@Component
public class PaymentEventListener {

@Async("eventExecutor")
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handlePaymentCompleted(PaymentCompletedEvent event) {
log.info("[이벤트] 결제 완료 - paymentId={}, orderId={}, memberId={}, amount={}",
event.paymentId(), event.orderId(), event.memberId(), event.amount());
}

@Async("eventExecutor")
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handlePaymentFailed(PaymentFailedEvent event) {
log.info("[이벤트] 결제 실패 - paymentId={}, orderId={}, memberId={}, reason={}",
event.paymentId(), event.orderId(), event.memberId(), event.reason());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package com.loopers.application.event;

import com.loopers.domain.event.ProductViewedEvent;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;

@Slf4j
@Component
public class UserActivityEventListener {

@Async("eventExecutor")
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handleProductViewed(ProductViewedEvent event) {
log.info("[이벤트] 상품 조회 - memberId={}, productId={}",
event.memberId(), event.productId());
Comment on lines +17 to +18
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

상품 조회 이벤트를 INFO 로그로 남기지 않는 편이 안전하다.

상품 조회는 고트래픽 경로라서 현재처럼 memberId까지 INFO로 남기면 로그 비용이 빠르게 커지고, 사용자 식별자가 장기 보관 로그에 남는다. 운영 로그는 DEBUG로 낮추거나 샘플링하고, 꼭 필요하면 식별자는 제거한 별도 메트릭/감사 경로로 보내는 편이 안전하다. 추가 테스트로는 appender 기반 테스트를 넣어 INFO 레벨 로그에 사용자 식별자가 출력되지 않는지 검증해야 한다.

수정 예시
-        log.info("[이벤트] 상품 조회 - memberId={}, productId={}",
-            event.memberId(), event.productId());
+        log.debug("[이벤트] 상품 조회 - productId={}", event.productId());

As per coding guidelines **/*.java: 로깅 시 민감정보 노출 가능성을 점검한다.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
log.info("[이벤트] 상품 조회 - memberId={}, productId={}",
event.memberId(), event.productId());
log.debug("[이벤트] 상품 조회 - productId={}", event.productId());
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/main/java/com/loopers/application/event/UserActivityEventListener.java`
around lines 17 - 18, Change the high-volume product-view log in
UserActivityEventListener so it does not write user-identifying data at INFO
level: replace or move the current log.info("[이벤트] 상품 조회 - memberId={},
productId={}", event.memberId(), event.productId()) to a lower level (e.g.,
log.debug) or remove memberId from the INFO message and only log productId; if
you must capture memberId send it to a separate audited/metrics path. Also add
an appender-based test that exercises UserActivityEventListener and asserts
INFO-level output does not contain event.memberId() (but allows DEBUG to contain
it if needed).

}
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ public class LikeCountSyncScheduler {
private final ProductCacheManager productCacheManager;
private final TransactionTemplate transactionTemplate;

@Scheduled(fixedRate = 300_000)
@Scheduled(fixedRate = 600_000)
public void syncLikeCounts() {
syncProductLikeCounts();
syncBrandLikeCounts();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
import com.loopers.domain.PageResult;
import com.loopers.domain.brand.Brand;
import com.loopers.domain.brand.BrandService;
import com.loopers.domain.event.LikeToggledEvent;
import com.loopers.domain.outbox.OutboxEventPublisher;
import com.loopers.domain.like.Like;
import com.loopers.domain.like.LikeService;
import com.loopers.domain.like.LikeTargetType;
Expand All @@ -12,6 +14,7 @@
import com.loopers.domain.product.Product;
import com.loopers.domain.product.ProductService;
import lombok.RequiredArgsConstructor;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;

Expand All @@ -28,6 +31,8 @@ public class LikeFacade {
private final ProductService productService;
private final BrandService brandService;
private final LikeService likeService;
private final ApplicationEventPublisher eventPublisher;
private final OutboxEventPublisher outboxEventPublisher;

@Transactional
public LikeToggleInfo toggleProductLike(String loginId, Long productId) {
Expand All @@ -37,6 +42,11 @@ public LikeToggleInfo toggleProductLike(String loginId, Long productId) {
boolean liked = likeService.toggleLike(memberId, LikeTargetType.PRODUCT, productId);
int likeCount = likeService.countLikes(LikeTargetType.PRODUCT, productId);

LikeToggledEvent productLikeEvent = new LikeToggledEvent(memberId, LikeTargetType.PRODUCT, productId, liked);
eventPublisher.publishEvent(productLikeEvent);
outboxEventPublisher.publish("PRODUCT", productId, "LIKE_TOGGLED",
"catalog-events", String.valueOf(productId), productLikeEvent);

return new LikeToggleInfo(liked, likeCount);
}

Expand All @@ -48,6 +58,8 @@ public LikeToggleInfo toggleBrandLike(String loginId, Long brandId) {
boolean liked = likeService.toggleLike(memberId, LikeTargetType.BRAND, brandId);
int likeCount = likeService.countLikes(LikeTargetType.BRAND, brandId);

eventPublisher.publishEvent(new LikeToggledEvent(memberId, LikeTargetType.BRAND, brandId, liked));

return new LikeToggleInfo(liked, likeCount);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,11 @@
import com.loopers.domain.order.OrderService;
import com.loopers.domain.product.Product;
import com.loopers.domain.product.ProductService;
import com.loopers.domain.event.OrderCancelledEvent;
import com.loopers.domain.event.OrderCreatedEvent;
import com.loopers.domain.outbox.OutboxEventPublisher;
import lombok.RequiredArgsConstructor;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;

Expand All @@ -31,6 +35,8 @@ public class OrderFacade {
private final OrderService orderService;
private final AddressService addressService;
private final CouponService couponService;
private final ApplicationEventPublisher eventPublisher;
private final OutboxEventPublisher outboxEventPublisher;

@Transactional
public OrderInfo createOrder(String loginId, Long addressId, Long memberCouponId,
Expand Down Expand Up @@ -89,6 +95,17 @@ public OrderInfo createOrder(String loginId, Long addressId, Long memberCouponId
// 5. 주문 항목 생성
List<OrderItem> items = orderService.createOrderItems(order.getId(), commands);

// 6. 이벤트 발행
List<OrderCreatedEvent.OrderItemSnapshot> itemSnapshots = commands.stream()
.map(cmd -> new OrderCreatedEvent.OrderItemSnapshot(
cmd.productId(), cmd.productName(), cmd.productPrice(), cmd.quantity()))
.toList();
OrderCreatedEvent orderCreatedEvent = new OrderCreatedEvent(
order.getId(), memberId, order.getTotalAmount(), itemSnapshots);
eventPublisher.publishEvent(orderCreatedEvent);
outboxEventPublisher.publish("ORDER", order.getId(), "ORDER_CREATED",
"order-events", String.valueOf(order.getId()), orderCreatedEvent);
Comment on lines +99 to +107
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

할인 주문의 이벤트 금액 기준이 서로 달라 downstream 집계가 어긋날 수 있다.

여기서 담는 totalAmount는 할인 후 총액인데, item snapshot의 price는 할인 전 단가다. 같은 이벤트 안에 순액과 총액 기준이 섞여 있으면 상품별 매출을 price * quantity로 집계하는 쪽과 주문 총액을 보는 쪽의 수치가 영구히 갈라진다. item snapshot에 할인 배분값 또는 netAmount를 포함해 금액 기준을 하나로 맞추고, 쿠폰 적용 주문이 consumer 집계까지 거쳐도 주문 총액과 상품별 합계가 기대한 기준으로 일치하는 통합 테스트를 추가해야 한다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java`
around lines 99 - 107, 현재 OrderCreatedEvent에 담긴 totalAmount(할인 후 총액)와
OrderItemSnapshot.price(할인 전 단가)가 혼재되어 downstream 집계 불일치를 초래합니다; 수정책으로
OrderItemSnapshot(생성하는 map 람다)에 할인 배분된 금액을 표현하는 필드(예: netAmount 또는
discountedPrice, 그리고 quantity를 곱한 netLineAmount)를 추가하고 OrderCreatedEvent 생성 시 해당
net 금액들을 함께 전달 및 설정하도록 변경하여 totalAmount 기준과 일치시키세요(참조 대상: OrderCreatedEvent,
OrderCreatedEvent.OrderItemSnapshot, 매핑 람다, outboxEventPublisher.publish 호출 지점).
또한 쿠폰/할인 적용 주문에 대해 주문 총액(totalAmount)과 item별 net 합계가 동일함을 검증하는 통합 테스트를 추가해
consumer 집계까지 검증하세요.


return OrderInfo.of(order, items);
}

Expand All @@ -113,6 +130,9 @@ public void cancelOrder(String loginId, Long orderId) {
if (result.order().getMemberCouponId() != null) {
couponService.restoreCoupon(result.order().getMemberCouponId());
}

// 이벤트 발행
eventPublisher.publishEvent(new OrderCancelledEvent(orderId, memberId));
}

public OrderInfo updateShippingAddress(String loginId, Long orderId,
Expand Down
Loading