Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
28 changes: 28 additions & 0 deletions .http/queue.http
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
### 대기열 진입
POST http://localhost:8080/api/v1/queue/bf2024/enter
X-User-Id: 1

### 순번 조회
GET http://localhost:8080/api/v1/queue/bf2024/position
X-User-Id: 1

### 다른 유저 진입
POST http://localhost:8080/api/v1/queue/bf2024/enter
X-User-Id: 2

### 다른 유저 순번 조회
GET http://localhost:8080/api/v1/queue/bf2024/position
X-User-Id: 2

### 순번 조회 (토큰 포함 응답)
GET http://localhost:8080/api/v1/queue/bf2024/position
X-User-Id: 1

### 주문 API (토큰 필요)
POST http://localhost:8080/api/v1/orders
X-User-Id: 1
X-Event-Id: bf2024
X-Queue-Token: {{token}}
Content-Type: application/json

{}
23 changes: 23 additions & 0 deletions .http/ranking.http
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
### 오늘 랭킹 조회 (기본)
GET http://localhost:8080/api/v1/rankings
Content-Type: application/json

### 날짜 지정 랭킹 조회
GET http://localhost:8080/api/v1/rankings?date=20260410&size=20&page=1
Content-Type: application/json

### 페이지 2 조회
GET http://localhost:8080/api/v1/rankings?date=20260410&size=10&page=2
Content-Type: application/json

### 주간 랭킹 조회
GET http://localhost:8080/api/v1/rankings?size=20&page=1&period=WEEKLY
Content-Type: application/json

### 월간 랭킹 조회
GET http://localhost:8080/api/v1/rankings?size=20&page=1&period=MONTHLY
Content-Type: application/json

### 상품 상세 (랭킹 포함)
GET http://localhost:8080/api/v1/products/1
Content-Type: application/json
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
@@ -0,0 +1,37 @@
package com.loopers.application.coupon;

import com.loopers.domain.coupon.CouponModel;
import com.loopers.domain.coupon.event.CouponIssueRequestedEvent;
import com.loopers.support.error.CoreException;
import com.loopers.support.error.ErrorType;
import lombok.RequiredArgsConstructor;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;

@RequiredArgsConstructor
@Component
public class CouponFacade {

private final CouponService couponService;
private final ApplicationEventPublisher eventPublisher;

/**
* 선착순 쿠폰 발급 요청.
* 즉시 발급하지 않고 Kafka로 비동기 처리 위임.
* 기본 유효성(만료, 수량)만 검증 후 이벤트 발행.
*/
@Transactional
public void requestCouponIssue(Long couponId, Long userId) {
CouponModel coupon = couponService.getCoupon(couponId);

if (coupon.isExpired()) {
throw new CoreException(ErrorType.BAD_REQUEST, "만료된 쿠폰입니다.");
}
if (!coupon.isQuantityAvailable()) {
throw new CoreException(ErrorType.BAD_REQUEST, "쿠폰 발급 수량이 초과되었습니다.");
}

eventPublisher.publishEvent(new CouponIssueRequestedEvent(couponId, userId));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import org.springframework.transaction.annotation.Transactional;

import java.util.List;
import java.util.Optional;

@RequiredArgsConstructor
@Component
Expand Down Expand Up @@ -60,6 +61,11 @@ public Page<CouponIssueModel> getIssuesByCoupon(Long couponId, Pageable pageable
return couponIssueRepository.findAllByCouponId(couponId, pageable);
}

@Transactional(readOnly = true)
public Optional<CouponIssueModel> findByUserAndCoupon(Long userId, Long couponId) {
return couponIssueRepository.findByUserIdAndCouponId(userId, couponId);
}

@Transactional
public void use(Long couponIssueId, Long userId, Long orderId) {
CouponIssueModel couponIssue = getCouponIssue(couponIssueId);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package com.loopers.application.like;

import com.loopers.application.product.ProductService;
import com.loopers.domain.like.event.LikeToggledEvent;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cache.CacheManager;
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
@RequiredArgsConstructor
@Component
public class LikeMetricsEventListener {

private final ProductService productService;
private final CacheManager cacheManager;

@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void handleLikeMetrics(LikeToggledEvent event) {
if (event.liked()) {
productService.incrementLikeCount(event.productId());
} else {
productService.decrementLikeCount(event.productId());
}
log.info("좋아요 집계 처리: productId={}, liked={}", event.productId(), event.liked());
evictProductDetailCache(event.productId());
}

private void evictProductDetailCache(Long productId) {
try {
var cache = cacheManager.getCache("productDetail");
if (cache != null) {
cache.evict(productId);
}
} catch (Exception e) {
log.warn("캐시 evict 실패 (productId={}): {}", productId, e.getMessage());
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@
import com.loopers.domain.like.LikeResult;
import com.loopers.domain.like.LikeModel;
import com.loopers.domain.like.LikeToggleService;
import com.loopers.application.product.ProductService;
import com.loopers.domain.like.event.LikeToggledEvent;
import lombok.RequiredArgsConstructor;
import org.springframework.cache.CacheManager;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;

Expand All @@ -16,9 +16,8 @@
public class LikeTransactionService {

private final LikeService likeService;
private final ProductService productService;
private final LikeToggleService likeToggleService;
private final CacheManager cacheManager;
private final ApplicationEventPublisher eventPublisher;

@Transactional
public void doLike(Long userId, Long productId) {
Expand All @@ -28,8 +27,7 @@ public void doLike(Long userId, Long productId) {
result.newLike().ifPresent(likeService::save);

if (result.countChanged()) {
productService.incrementLikeCount(productId);
evictProductDetailCache(productId);
eventPublisher.publishEvent(new LikeToggledEvent(productId, true));
}
}

Expand All @@ -39,14 +37,6 @@ public void doUnlike(Long userId, Long productId) {
if (activeLike.isEmpty()) return;

likeToggleService.unlike(activeLike.get());
productService.decrementLikeCount(activeLike.get().productId());
evictProductDetailCache(productId);
}

private void evictProductDetailCache(Long productId) {
var cache = cacheManager.getCache("productDetail");
if (cache != null) {
cache.evict(productId);
}
eventPublisher.publishEvent(new LikeToggledEvent(productId, false));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package com.loopers.application.logging;

import com.loopers.domain.like.event.LikeToggledEvent;
import com.loopers.domain.order.event.OrderPlacedEvent;
import com.loopers.domain.payment.event.PaymentCompletedEvent;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;

@Slf4j
@Component
public class UserActivityEventListener {

@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handleOrderPlaced(OrderPlacedEvent event) {
log.info("[UserActivity] 주문 생성 - userId={}, orderId={}, amount={}",
event.userId(), event.orderId(), event.totalAmountValue());
}

@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handlePaymentCompleted(PaymentCompletedEvent event) {
log.info("[UserActivity] 결제 {} - userId={}, orderId={}, paymentId={}",
event.success() ? "성공" : "실패", event.userId(), event.orderId(), event.paymentId());
}

@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handleLikeToggled(LikeToggledEvent event) {
log.info("[UserActivity] 좋아요 {} - productId={}",
event.liked() ? "등록" : "취소", event.productId());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@
import com.loopers.application.product.ProductService;
import com.loopers.application.stock.StockService;
import com.loopers.domain.stock.StockModel;
import com.loopers.domain.order.event.OrderPlacedEvent;
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;
Expand All @@ -31,6 +33,7 @@ public class OrderFacade {
private final StockService stockService;
private final CouponIssueService couponIssueService;
private final CouponService couponService;
private final ApplicationEventPublisher eventPublisher;

@Transactional
public OrderResult placeOrder(Long userId, List<OrderItemCommand> commands, Long couponIssueId) {
Expand Down Expand Up @@ -85,6 +88,16 @@ public OrderResult placeOrder(Long userId, List<OrderItemCommand> commands, Long

List<OrderItemModel> savedItems = orderService.saveAllItems(items);

List<OrderPlacedEvent.OrderItemEvent> orderItemEvents = snapshots.stream()
.map(s -> new OrderPlacedEvent.OrderItemEvent(
s.productId(), (long) s.productPrice().value(), s.quantity()
))
.toList();

eventPublisher.publishEvent(new OrderPlacedEvent(
order.getId(), userId, (long) totalAmount.value(), orderItemEvents
));

return OrderResult.of(order, savedItems);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
package com.loopers.application.outbox;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.loopers.domain.coupon.event.CouponIssueRequestedEvent;
import com.loopers.domain.like.event.LikeToggledEvent;
import com.loopers.domain.order.event.OrderPlacedEvent;
import com.loopers.domain.outbox.OutboxEvent;
import com.loopers.domain.outbox.OutboxEventEnvelope;
import com.loopers.domain.outbox.OutboxRepository;
import com.loopers.domain.payment.event.PaymentCompletedEvent;
import com.loopers.domain.product.event.ProductViewedEvent;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;

import java.time.ZonedDateTime;
import java.util.UUID;

/**
* BEFORE_COMMIT: 도메인 TX와 같은 TX에서 outbox 저장.
* 도메인 이벤트가 커밋되면 outbox 레코드도 함께 커밋된다.
*/
@Slf4j
@RequiredArgsConstructor
@Component
public class OutboxEventListener {

private final OutboxRepository outboxRepository;
private final ObjectMapper objectMapper;

@TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)
public void handleLikeToggled(LikeToggledEvent event) {
String eventType = event.liked() ? "LIKED" : "UNLIKED";
saveOutboxEvent("Product", event.productId(), eventType,
"catalog-events", String.valueOf(event.productId()), event);
}

@TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)
public void handleProductViewed(ProductViewedEvent event) {
saveOutboxEvent("Product", event.productId(), "PRODUCT_VIEWED",
"catalog-events", String.valueOf(event.productId()), event);
}

@TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)
public void handleOrderPlaced(OrderPlacedEvent event) {
saveOutboxEvent("Order", event.orderId(), "ORDER_PLACED",
"order-events", String.valueOf(event.orderId()), event);
}

@TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)
public void handlePaymentCompleted(PaymentCompletedEvent event) {
saveOutboxEvent("Payment", event.paymentId(), "PAYMENT_COMPLETED",
"order-events", String.valueOf(event.orderId()), event);
}

@TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)
public void handleCouponIssueRequested(CouponIssueRequestedEvent event) {
saveOutboxEvent("Coupon", event.couponId(), "COUPON_ISSUE_REQUESTED",
"coupon-issue-requests", String.valueOf(event.couponId()), event);
}

private void saveOutboxEvent(String aggregateType, Long aggregateId, String eventType,
String topic, String partitionKey, Object eventData) {
String eventId = UUID.randomUUID().toString();
OutboxEventEnvelope envelope = new OutboxEventEnvelope(eventId, eventType, ZonedDateTime.now(), eventData);

try {
String payload = objectMapper.writeValueAsString(envelope);
OutboxEvent outboxEvent = new OutboxEvent(
aggregateType, aggregateId, eventType, topic, partitionKey, payload
);
outboxRepository.save(outboxEvent);
log.debug("Outbox 이벤트 저장: eventType={}, aggregateId={}", eventType, aggregateId);
} catch (JsonProcessingException e) {
log.error("Outbox 이벤트 직렬화 실패: eventType={}, aggregateId={}", eventType, aggregateId, e);
throw new RuntimeException("Outbox 이벤트 직렬화 실패", e);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,12 @@
import com.loopers.domain.payment.PaymentStatus;
import com.loopers.infrastructure.payment.PgPaymentGateway;
import com.loopers.infrastructure.payment.dto.PgPaymentRequest;
import com.loopers.domain.payment.event.PaymentCompletedEvent;
import com.loopers.infrastructure.payment.dto.PgPaymentResponse;
import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;

Expand All @@ -29,6 +31,7 @@ public class PaymentFacade {
private final OrderService orderService;
private final PgPaymentGateway pgPaymentGateway;
private final PgProperties pgProperties;
private final ApplicationEventPublisher eventPublisher;

/**
* TX 분리 패턴:
Expand Down Expand Up @@ -76,6 +79,10 @@ public void handleCallback(String transactionKey, String status, String failureR
payment.markFailed(failureReason);
order.failPayment();
}

eventPublisher.publishEvent(new PaymentCompletedEvent(
payment.getId(), payment.orderId(), payment.userId(), "SUCCESS".equals(status)
));
}

@Transactional
Expand Down
Loading