Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
4df323f
feat: 결제 연동 주문 생성 기능 추가
plan11plan Mar 22, 2026
60c8a94
feat: 재고/포인트 차감에 원자적 UPDATE 적용
plan11plan Mar 22, 2026
7bdd714
refactor: 주문 동시성 전략 변경 (낙관적 락 → 원자적 UPDATE + 비관적 락)
plan11plan Mar 22, 2026
743a283
test: 동시성 전략 변경에 따른 테스트 수정
plan11plan Mar 22, 2026
83c4c2f
refactor: 주문 이벤트 기반 리팩토링 — 방안 A (Facade 직접 호출 + PG 이벤트 분리)
plan11plan Mar 22, 2026
6d5824d
chore: spring-boot-starter-mail 의존성 및 SMTP 설정 추가
plan11plan Mar 24, 2026
ee052a8
feat: 주문 완료 이메일 발송 기능 구현
plan11plan Mar 24, 2026
679d460
test: 주문 완료 이벤트 발행 테스트 추가
plan11plan Mar 24, 2026
d86ed48
refactor: 좋아요 이벤트 기반 집계 분리
plan11plan Mar 24, 2026
fbda347
test: 좋아요 이벤트 분리에 따른 테스트 수정
plan11plan Mar 24, 2026
a0ecf7a
feat: 좋아요 집계 분할 재집계 스케줄러 구현
plan11plan Mar 24, 2026
7d75715
test: 좋아요 분할 재집계 스케줄러 테스트 추가
plan11plan Mar 24, 2026
b177ea0
feat: 회원가입 시 웰컴 포인트·쿠폰·이메일 발송 추가
plan11plan Mar 24, 2026
d6e7329
refactor: 회원가입 이벤트 기반 부가 로직 분리
plan11plan Mar 24, 2026
cc75641
test: 회원가입 이벤트 분리에 따른 테스트 수정
plan11plan Mar 24, 2026
447c6a4
fix: 재집계 JPQL 쿼리 MOD 함수로 수정
plan11plan Mar 24, 2026
1d5cb48
refactor: 회원가입 이벤트 발행을 Facade에서 Service로 이동
plan11plan Mar 24, 2026
0016fa8
test: 회원가입 이벤트 발행 위치 변경에 따른 테스트 수정
plan11plan Mar 24, 2026
71931d6
fix: 웰컴 쿠폰 자동 발급에 따른 쿠폰 E2E 테스트 기대값 수정
plan11plan Mar 24, 2026
4c3f03b
feat: 주문 완료 시 구매 금액 2% 포인트 적립 기능 구현
plan11plan Mar 24, 2026
bba8341
test: 주문 완료 포인트 적립 테스트 추가
plan11plan Mar 24, 2026
80a3c83
feat: 상품 조회수 INSERT-only 로그 기능 구현
plan11plan Mar 25, 2026
5fa87a5
feat: 선택적 인증(@OptionalLogin) 및 상품 조회 이벤트 발행
plan11plan Mar 25, 2026
f1b5198
fix: ProductViewLogRepository 인프라 구현체 추가
plan11plan Mar 25, 2026
352e410
chore: Kafka 모듈 설정 보강 (acks=all, idempotence, deserializer 수정)
plan11plan Mar 25, 2026
6bd3067
feat: commerce-api Kafka Producer 구현 (좋아요/조회/주문 이벤트 발행)
plan11plan Mar 25, 2026
d3abdab
feat: commerce-streamer Kafka Consumer 구현 (product_metrics 집계)
plan11plan Mar 25, 2026
1e2ccb5
refactor: 좋아요 집계를 Kafka Consumer로 이관 (Product.likeCount 제거)
plan11plan Mar 25, 2026
1ef9163
test: Kafka 이관에 따른 테스트 코드 수정
plan11plan Mar 25, 2026
66ed3bb
feat: Transactional Outbox Pattern + 스케줄러 기반 Kafka 발행 구현
plan11plan Mar 25, 2026
d19f5e8
feat: 선착순 쿠폰 발급 2중 문지기 구조 구현 (AtomicInteger + Atomic UPDATE)
plan11plan Mar 26, 2026
111361d
test: 선착순 쿠폰 2중 문지기 테스트 코드 추가/수정
plan11plan Mar 26, 2026
c89e294
chore: Docker Grafana 모니터링 및 인프라 설정 추가
plan11plan Mar 26, 2026
8f69bda
docs: 2중 문지기 부하 테스트 결과 정리
plan11plan Mar 26, 2026
5365117
refactor: 2차 문지기를 DB atomic UPDATE에서 INSERT-only 방식으로 전환
plan11plan Mar 26, 2026
5c6ccf3
test: INSERT-only 방식 전환에 따른 테스트 코드 수정
plan11plan Mar 26, 2026
3697743
chore: application.yml 설정 변경
plan11plan Mar 26, 2026
da8abf8
feat: 선착순 쿠폰 발급 Redis ZSET + Lua script 기반으로 전환
plan11plan Mar 26, 2026
07cde63
test: Redis ZSET 전환에 따른 테스트 코드 수정
plan11plan Mar 26, 2026
968690d
feat: 쿠폰 발급 Kafka 비동기 INSERT 도입
plan11plan Mar 26, 2026
cab76a9
test: Kafka 비동기 전환에 따른 테스트 코드 수정
plan11plan Mar 26, 2026
a049be0
feat: 쿠폰 발급 Transactional Outbox Pattern 적용
plan11plan Mar 27, 2026
a96a55d
test: Outbox Pattern 전환에 따른 CouponFacadeTest 수정
plan11plan Mar 27, 2026
7561059
fix: registerTotalQuantity 시 Redis 발급 기록 초기화
plan11plan Mar 27, 2026
aa5ee97
chore: 데이터 제너레이터 비활성화
plan11plan Mar 27, 2026
7814ba8
docs: 선착순 쿠폰 발급 아키텍처 문서 작성
plan11plan Mar 27, 2026
7d64146
refactor: 통계성 이벤트 Outbox → AFTER_COMMIT 직접 발행으로 전환
plan11plan Mar 27, 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
4 changes: 4 additions & 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 All @@ -25,6 +26,9 @@ dependencies {
// security
implementation("org.springframework.security:spring-security-crypto")

// mail
implementation("org.springframework.boot:spring-boot-starter-mail")

// querydsl
annotationProcessor("com.querydsl:querydsl-apt::jakarta")
annotationProcessor("jakarta.persistence:jakarta.persistence-api")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
import org.springframework.cloud.openfeign.EnableFeignClients;
import org.springframework.retry.annotation.EnableRetry;
import org.springframework.scheduling.annotation.EnableScheduling;
import java.util.TimeZone;

@EnableScheduling
@EnableRetry
@EnableFeignClients
@ConfigurationPropertiesScan
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,20 @@

import com.loopers.application.coupon.dto.CouponCriteria;
import com.loopers.application.coupon.dto.CouponResult;
import com.loopers.application.coupon.event.CouponIssuedMessage;
import com.loopers.confg.kafka.KafkaTopics;
import com.loopers.domain.coupon.CouponErrorCode;
import com.loopers.domain.coupon.CouponIssueLimiter;
import com.loopers.domain.coupon.CouponIssueResult;
import com.loopers.domain.coupon.CouponModel;
import com.loopers.domain.coupon.CouponService;
import com.loopers.infrastructure.outbox.OutboxEventPublisher;
import com.loopers.support.error.CoreException;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.orm.ObjectOptimisticLockingFailureException;
import org.springframework.retry.annotation.Backoff;
import org.springframework.retry.annotation.Retryable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

Expand All @@ -18,23 +24,32 @@
public class CouponFacade {

private final CouponService couponService;
private final CouponIssueLimiter couponIssueLimiter;
private final OutboxEventPublisher outboxEventPublisher;

@Transactional
public CouponResult.Detail registerCoupon(CouponCriteria.Create criteria) {
return CouponResult.Detail.from(
couponService.register(criteria.toCommand()));
CouponModel coupon = couponService.register(criteria.toCommand());
couponIssueLimiter.registerTotalQuantity(coupon.getId(), coupon.getTotalQuantity());
return CouponResult.Detail.from(coupon, 0);
}

@Transactional(readOnly = true)
public CouponResult.Detail getCoupon(Long couponId) {
return CouponResult.Detail.from(
couponService.getById(couponId));
couponService.getById(couponId),
couponService.countIssuedCoupons(couponId));
}

@Transactional(readOnly = true)
public Page<CouponResult.Detail> getCoupons(Pageable pageable) {
return couponService.getAll(pageable)
.map(CouponResult.Detail::from);
Page<CouponModel> coupons = couponService.getAll(pageable);
List<Long> couponIds = coupons.getContent().stream()
.map(CouponModel::getId)
.toList();
Map<Long, Long> issuedCountMap = couponService.countIssuedCoupons(couponIds);
return coupons.map(coupon -> CouponResult.Detail.from(
coupon, issuedCountMap.getOrDefault(coupon.getId(), 0L)));
}

@Transactional
Expand All @@ -53,14 +68,30 @@ public Page<CouponResult.IssuedDetail> getIssuedCoupons(Long couponId, Pageable
.map(CouponResult.IssuedDetail::from);
}

@Retryable(
retryFor = ObjectOptimisticLockingFailureException.class,
maxAttempts = 50,
backoff = @Backoff(delay = 50, random = true))
@Transactional
public CouponResult.IssuedDetail issueCoupon(Long couponId, Long userId) {
return CouponResult.IssuedDetail.from(
couponService.issue(couponId, userId));
CouponIssueResult result = couponIssueLimiter.tryIssue(couponId, userId);
if (result == CouponIssueResult.NOT_FOUND) {
throw new CoreException(CouponErrorCode.NOT_FOUND);
}
if (result == CouponIssueResult.ALREADY_ISSUED) {
throw new CoreException(CouponErrorCode.ALREADY_ISSUED);
}
if (result == CouponIssueResult.QUANTITY_EXHAUSTED) {
throw new CoreException(CouponErrorCode.QUANTITY_EXHAUSTED);
}

try {
outboxEventPublisher.publish(
"COUPON_ISSUED",
KafkaTopics.COUPON_ISSUED,
String.valueOf(couponId),
new CouponIssuedMessage(couponId, userId, System.currentTimeMillis()));
return CouponResult.IssuedDetail.pending(couponId, userId);
} catch (Exception e) {
couponIssueLimiter.rollback(couponId, userId);
throw e;
}
}

@Transactional(readOnly = true)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,20 +14,20 @@ public record Detail(
long discountValue,
Long minOrderAmount,
int totalQuantity,
int issuedQuantity,
long issuedQuantity,
ZonedDateTime expiredAt,
ZonedDateTime createdAt,
ZonedDateTime updatedAt
) {
public static Detail from(CouponModel model) {
public static Detail from(CouponModel model, long issuedQuantity) {
return new Detail(
model.getId(),
model.getName(),
model.getDiscountType().name(),
model.getDiscountValue(),
model.getMinOrderAmount(),
model.getTotalQuantity(),
model.getIssuedQuantity(),
issuedQuantity,
model.getExpiredAt(),
model.getCreatedAt(),
model.getUpdatedAt());
Expand All @@ -49,6 +49,10 @@ public static IssuedDetail from(OwnedCouponModel model) {
model.getUsedAt(),
model.getCreatedAt());
}

public static IssuedDetail pending(Long couponId, Long userId) {
return new IssuedDetail(null, userId, "PENDING", null, null);
}
}

public record OwnedDetail(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.loopers.application.coupon.event;

public record CouponIssuedMessage(
Long couponId,
Long userId,
long issuedAt
) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package com.loopers.application.notification;

import com.loopers.domain.order.event.OrderCompletedEvent;
import com.loopers.domain.notification.NotificationSender;
import com.loopers.domain.user.UserService;
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
@Component
@RequiredArgsConstructor
public class OrderNotificationHandler {

private final UserService userService;
private final NotificationSender notificationSender;

@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handle(OrderCompletedEvent event) {
try {
String email = userService.getById(event.userId()).getEmail();
notificationSender.send(
email,
"주문이 확정되었습니다",
String.format("주문번호 %d의 결제가 완료되어 주문이 확정되었습니다.", event.orderId()));
} catch (Exception e) {
log.warn("[Email] 발송 실패 — orderId={}, userId={}",
event.orderId(), event.userId(), e);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import com.loopers.application.order.dto.OrderCriteria;
import com.loopers.application.order.dto.OrderResult;
import com.loopers.application.order.event.OrderPaymentEvent;
import com.loopers.domain.brand.BrandService;
import com.loopers.domain.coupon.CouponService;
import com.loopers.domain.order.OrderModel;
Expand All @@ -14,11 +15,9 @@
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.orm.ObjectOptimisticLockingFailureException;
import org.springframework.retry.annotation.Backoff;
import org.springframework.retry.annotation.Retryable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

Expand All @@ -31,22 +30,41 @@ public class OrderFacade {
private final OrderService orderService;
private final CouponService couponService;
private final UserService userService;
private final ApplicationEventPublisher eventPublisher;

@Retryable(
retryFor = ObjectOptimisticLockingFailureException.class,
maxAttempts = 10,
backoff = @Backoff(delay = 50, random = true))
@Transactional
public OrderResult.OrderSummary createOrder(Long userId, OrderCriteria.Create criteria) {
OrderModel order = processOrder(userId, criteria);
return OrderResult.OrderSummary.from(order);
}

@Transactional
public OrderResult.OrderPaymentSummary createOrderWithPayment(
Long userId, OrderCriteria.Create criteria) {

OrderModel order = processOrder(userId, criteria);

// PG 결제는 TX 커밋 후 이벤트로 처리
eventPublisher.publishEvent(new OrderPaymentEvent(
order.getId(), userId, order.getTotalPrice(),
criteria.cardType(), criteria.cardNo()));

return OrderResult.OrderPaymentSummary.pending(order);
}

private OrderModel processOrder(Long userId, OrderCriteria.Create criteria) {
// 1. 재고 차감
List<ProductInfo.StockDeduction> deductionInfos =
productService.validateAndDeductStock(criteria.toStockDeductions());

Map<Long, String> brandNameMap = brandService.getNameMapByIds(
ProductInfo.StockDeduction.extractDistinctBrandIds(deductionInfos));

// 2. 주문 생성
OrderModel order = orderService.createOrder(
userId, OrderCommand.CreateItem.from(deductionInfos, brandNameMap));

// 3. 쿠폰 사용
if (criteria.ownedCouponId() != null) {
orderService.applyDiscount(
order,
Expand All @@ -55,9 +73,10 @@ public OrderResult.OrderSummary createOrder(Long userId, OrderCriteria.Create cr
order.getOriginalTotalPrice()));
}

// 4. 포인트 차감
userService.deductPoint(userId, order.getTotalPrice());

return OrderResult.OrderSummary.from(order);
return order;
}

@Transactional(readOnly = true)
Expand All @@ -84,31 +103,25 @@ public OrderResult.OrderDetail getOrderDetail(Long orderId) {
return OrderResult.OrderDetail.from(orderService.getById(orderId));
}

@Retryable(
retryFor = ObjectOptimisticLockingFailureException.class,
maxAttempts = 5,
backoff = @Backoff(delay = 50, random = true))
@Transactional
public void cancelMyOrderItem(Long userId, Long orderId, Long orderItemId) {
OrderModel order = orderService.getByIdAndUserId(orderId, userId);
OrderModel order = orderService.getByIdWithLock(orderId);
order.validateOwner(userId);
OrderInfo.CancelledItem cancelledItem = orderService.cancelItem(order, orderItemId);
productService.increaseStock(cancelledItem.productId(), cancelledItem.quantity());
if (cancelledItem.orderFullyCancelled()) {
couponService.restoreByOrderId(orderId);
}
}

@Retryable(
retryFor = ObjectOptimisticLockingFailureException.class,
maxAttempts = 5,
backoff = @Backoff(delay = 50, random = true))
@Transactional
public void cancelOrderItem(Long orderId, Long orderItemId) {
OrderModel order = orderService.getById(orderId);
OrderModel order = orderService.getByIdWithLock(orderId);
OrderInfo.CancelledItem cancelledItem = orderService.cancelItem(order, orderItemId);
productService.increaseStock(cancelledItem.productId(), cancelledItem.quantity());
if (cancelledItem.orderFullyCancelled()) {
couponService.restoreByOrderId(orderId);
}
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package com.loopers.application.order;

import com.loopers.domain.order.event.OrderCompletedEvent;
import com.loopers.domain.user.UserService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;

@Slf4j
@Component
@RequiredArgsConstructor
public class OrderPointHandler {

private final UserService userService;

@Transactional(propagation = Propagation.REQUIRES_NEW)
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handle(OrderCompletedEvent event) {
try {
long pointAmount = (long) (event.totalPrice() * 0.02);
if (pointAmount > 0) {
userService.addPoint(event.userId(), pointAmount);
}
} catch (Exception e) {
log.warn("[OrderPoint] 포인트 적립 실패 — orderId={}, userId={}",
event.orderId(), event.userId(), e);
}
}
}
Original file line number Diff line number Diff line change
@@ -1,15 +1,24 @@
package com.loopers.application.order.dto;

import com.loopers.domain.payment.CardType;
import com.loopers.domain.product.dto.ProductCommand;
import java.time.ZonedDateTime;
import java.util.List;

public class OrderCriteria {

public record Create(List<CreateItem> items, Long ownedCouponId) {
public record Create(
List<CreateItem> items,
Long ownedCouponId,
CardType cardType,
String cardNo) {

public Create(List<CreateItem> items) {
this(items, null);
this(items, null, null, null);
}

public Create(List<CreateItem> items, Long ownedCouponId) {
this(items, ownedCouponId, null, null);
}

public List<ProductCommand.StockDeduction> toStockDeductions() {
Expand Down
Loading