Skip to content
Merged
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
9 changes: 9 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,15 @@

---

## 트래픽 규모 전제

- 이 프로젝트는 **쿠팡, 무신사급 대규모 트래픽 이커머스**를 위한 설계를 적용하는 프로젝트이다
- 모든 설계 결정(배치 주기, 테이블 정리 전략, 스레드 풀, 커넥션 풀 등)은 대규모 트래픽 기준으로 검토한다
- "현재 단일 인스턴스니까 괜찮다"가 아니라, **스케일아웃 시에도 안전한 구조**를 기본으로 설계한다
- 산술 근거 제시 시 피크 트래픽 기준으로 계산한다

---

## 도메인 & 객체 설계 전략

### Entity / VO / Domain Service 구분
Expand Down
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,15 +1,19 @@
package com.loopers.application.coupon;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.loopers.domain.coupon.*;
import com.loopers.support.error.CoreException;
import com.loopers.support.error.ErrorType;
import lombok.RequiredArgsConstructor;
import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.time.Clock;
import java.time.ZonedDateTime;
import java.util.List;
import java.util.Map;

@Service
@RequiredArgsConstructor
Expand All @@ -18,6 +22,9 @@ public class CouponFacade {

private final CouponRepository couponRepository;
private final CouponIssueRepository couponIssueRepository;
private final CouponIssueRequestRepository couponIssueRequestRepository;
private final KafkaTemplate<Object, Object> kafkaTemplate;
private final ObjectMapper objectMapper;
private final Clock clock;

// ── Admin: 쿠폰 템플릿 CRUD ──
Expand Down Expand Up @@ -130,6 +137,40 @@ public void restoreCoupon(Long couponIssueId) {
couponIssue.cancelUse(ZonedDateTime.now(clock));
}

// ── 대고객: 선착순 쿠폰 발급 요청 ──

@Transactional
public CouponIssueRequest requestCouponIssue(Long couponId, Long memberId) {
Coupon coupon = couponRepository.findById(couponId)
.orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "쿠폰을 찾을 수 없습니다."));

ZonedDateTime now = ZonedDateTime.now(clock);
if (now.isAfter(coupon.getExpiredAt())) {
throw new CoreException(ErrorType.BAD_REQUEST, "만료된 쿠폰은 발급 요청할 수 없습니다.");
}

CouponIssueRequest request = couponIssueRequestRepository.save(
CouponIssueRequest.create(couponId, memberId));

try {
String payload = objectMapper.writeValueAsString(Map.of(
"requestId", request.getId(),
"couponId", couponId,
"memberId", memberId
));
kafkaTemplate.send("coupon-issue-requests", String.valueOf(couponId), payload);
} catch (JsonProcessingException e) {
throw new RuntimeException("쿠폰 발급 요청 페이로드 직렬화 실패", e);
}

return request;
}

public CouponIssueRequest getIssueRequest(Long requestId) {
return couponIssueRequestRepository.findById(requestId)
.orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "쿠폰 발급 요청을 찾을 수 없습니다."));
}

public ZonedDateTime now() {
return ZonedDateTime.now(clock);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package com.loopers.application.event;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.loopers.domain.event.ProductViewedEvent;
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;

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

@Slf4j
@Component
@RequiredArgsConstructor
public class ProductViewKafkaPublisher {

private final KafkaTemplate<Object, Object> kafkaTemplate;
private final ObjectMapper objectMapper;

@Async("eventExecutor")
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true)
public void handle(ProductViewedEvent event) {
try {
String payload = objectMapper.writeValueAsString(Map.of(
"eventId", UUID.randomUUID().toString(),
"eventType", "PRODUCT_VIEWED",
"productId", event.productId(),
"memberId", event.memberId()
));
kafkaTemplate.send("catalog-events", String.valueOf(event.productId()), payload);
} catch (JsonProcessingException e) {
log.warn("조회수 이벤트 Kafka 발행 실패 — productId={}", event.productId(), e);
}
Comment on lines +25 to +38
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 | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

cat -n apps/commerce-api/src/main/java/com/loopers/application/event/ProductViewKafkaPublisher.java

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 1887


🏁 Script executed:

# Search for eventExecutor configuration
rg "eventExecutor" --type java

# Check other kafkaTemplate.send usage patterns
rg "kafkaTemplate\.send" --type java -A 2

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 2072


🏁 Script executed:

# Read AsyncConfig
cat -n apps/commerce-api/src/main/java/com/loopers/infrastructure/kafka/AsyncConfig.java

# Search for tests
find . -name "*ProductViewKafkaPublisher*Test*" -o -name "*ProductViewKafkaPublisherTest*"

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 1257


🏁 Script executed:

cat -n ./apps/commerce-api/src/test/java/com/loopers/application/event/ProductViewKafkaPublisherTest.java

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 1945


Kafka 전송 실패가 조용히 유실될 수 있다.

kafkaTemplate.send()의 반환값인 ListenableFuture를 캡처하지 않아 전송 실패가 감지되지 않는다. 직렬화 실패는 warn으로 삼키고, Kafka 브로커 오류나 타임아웃 같은 send 실패는 완전히 무시된다. 결과적으로 조회수 이벤트가 손실되어도 운영자는 감지하기 어렵고, 다운스트림 조회수 집계와 상품 순위에 오차가 누적된다.

다음을 적용하라:

  • send() 결과에 콜백을 붙여 전송 실패를 메트릭으로 기록하거나 실패 큐에 적재한다.
  • JsonProcessingException 외에 send() 메서드가 던질 수 있는 다른 예외도 처리한다.
  • 전송 실패와 직렬화 실패 시나리오를 포함한 통합 테스트를 추가한다(현재 테스트는 정상 경로만 커버).
🤖 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/ProductViewKafkaPublisher.java`
around lines 25 - 38, The handle(ProductViewedEvent) method currently ignores
kafkaTemplate.send() result and only catches JsonProcessingException; capture
the send() ListenableFuture, attach a callback (onSuccess/onFailure) to log
failures, increment a metric and/or enqueue the failed payload to a
retry/failure queue, and also handle exceptions thrown synchronously by send()
by surrounding send with try/catch in addition to the callback; update
references to objectMapper.writeValueAsString and kafkaTemplate.send in
ProductViewKafkaPublisher to ensure both serialization and broker/send errors
are surfaced and recorded, and add integration tests that simulate
JsonProcessingException and Kafka send failures to assert metrics/queueing
behavior.

}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
package com.loopers.application.like;

import com.loopers.domain.event.DomainEventPublisher;
import com.loopers.domain.event.LikeCreatedEvent;
import com.loopers.domain.event.LikeRemovedEvent;
import com.loopers.domain.like.Like;
import com.loopers.domain.like.LikeRepository;
import com.loopers.domain.product.ProductRepository;
Expand All @@ -10,6 +13,7 @@
import org.springframework.transaction.annotation.Transactional;

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

@Service
Expand All @@ -19,6 +23,7 @@ public class LikeFacade {

private final LikeRepository likeRepository;
private final ProductRepository productRepository;
private final DomainEventPublisher domainEventPublisher;

@Transactional
public void addLike(Long memberId, Long productId) {
Expand All @@ -30,7 +35,10 @@ public void addLike(Long memberId, Long productId) {
}

likeRepository.save(new Like(memberId, productId));
productRepository.incrementLikeCount(productId);

domainEventPublisher.publish("catalog", String.valueOf(productId),
"LIKE_CREATED", Map.of("productId", productId, "memberId", memberId),
new LikeCreatedEvent(productId, memberId));
}

@Transactional
Expand All @@ -41,7 +49,10 @@ public void removeLike(Long memberId, Long productId) {
}

likeRepository.delete(likeOpt.get());
productRepository.decrementLikeCount(productId);

domainEventPublisher.publish("catalog", String.valueOf(productId),
"LIKE_REMOVED", Map.of("productId", productId, "memberId", memberId),
new LikeRemovedEvent(productId, memberId));
}

public List<Like> getLikesByMemberId(Long memberId) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@
import com.loopers.application.coupon.CouponFacade;
import com.loopers.domain.brand.Brand;
import com.loopers.domain.brand.BrandRepository;
import com.loopers.domain.event.DomainEventPublisher;
import com.loopers.domain.event.OrderCancelledEvent;
import com.loopers.domain.event.OrderCreatedEvent;
import com.loopers.domain.event.OrderItemSnapshot;
import com.loopers.domain.order.Order;
import com.loopers.domain.order.OrderItem;
import com.loopers.domain.order.OrderRepository;
Expand Down Expand Up @@ -32,6 +36,7 @@ public class OrderFacade {
private final ProductRepository productRepository;
private final BrandRepository brandRepository;
private final CouponFacade couponFacade;
private final DomainEventPublisher domainEventPublisher;

@Transactional
public Order createOrder(Long memberId, List<OrderItemRequest> itemRequests) {
Expand Down Expand Up @@ -109,6 +114,16 @@ public Order createOrder(Long memberId, List<OrderItemRequest> itemRequests, Lon
couponFacade.linkCouponToOrder(resolvedCouponIssueId, order.getId());
}

// 8. Outbox INSERT + 이벤트 발행
List<OrderItemSnapshot> eventItems = order.getItems().stream()
.map(item -> new OrderItemSnapshot(item.getProductId(), item.getQuantity(), item.getProductPrice()))
.toList();

domainEventPublisher.publish("order", String.valueOf(order.getId()),
"ORDER_CREATED",
Map.of("orderId", order.getId(), "memberId", memberId, "items", eventItems),
new OrderCreatedEvent(order.getId(), memberId, eventItems));

Comment on lines +117 to +126
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 | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# DomainEventPublisherImpl 구현 확인
ast-grep --pattern $'class DomainEventPublisherImpl {
  $$$
  publish($$$) {
    $$$
  }
  $$$
}'

# Kafka 전송이 AFTER_COMMIT 리스너에서 발생하는지 확인
rg -n "@TransactionalEventListener" --type=java -A 5

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 4583


🏁 Script executed:

# Find DomainEventPublisher interface and implementations
rg -n "interface DomainEventPublisher|class.*DomainEventPublisher" --type=java -A 3

# Find OrderCreatedEvent and its listener
rg -n "class OrderCreatedEvent|@TransactionalEventListener.*OrderCreatedEvent" --type=java -A 5

# Check the publish method signature
rg -n "void publish\(" --type=java -B 2 -A 10

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 3992


🏁 Script executed:

# Find OrderCreatedEvent listener
rg -n "OrderCreatedEvent" --type=java -B 2 -A 8

# Check if there's a listener for OrderCreatedEvent
rg -n "@TransactionalEventListener.*Order|handle.*OrderCreatedEvent" --type=java -A 10

# Look at the full DomainEventPublisherImpl
cat apps/commerce-api/src/main/java/com/loopers/infrastructure/event/DomainEventPublisherImpl.java

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 4631


🏁 Script executed:

# Search for any listener that might handle OrderCreatedEvent
rg -n "OrderCreatedEvent" --type=java

# Get the complete createOrder method from OrderFacade
sed -n '90,128p' apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java

# Look for Kafka publishers in the codebase
rg -n "KafkaTemplate|kafkaTemplate|sendMessage" --type=java -B 2 -A 3 | head -50

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 7452


🏁 Script executed:

# Search for EventOutbox processing/polling
rg -n "EventOutbox|eventOutbox" --type=java | grep -i "process\|poll\|schedule\|publish" | head -20

# Look for scheduled methods that might process outbox
rg -n "@Scheduled|@Async" --type=java -A 5 | grep -i "outbox\|event" -B 2 -A 5

# Search for any processor or poller
rg -n "class.*Outbox.*|class.*Poller" --type=java

# Check test for DomainEventPublisher to understand expected behavior
cat apps/commerce-api/src/test/java/com/loopers/infrastructure/event/DomainEventPublisherImplTest.java

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 8951


🏁 Script executed:

# Search for any listener that handles Order-related events
rg -n "handle.*Order|Order.*Event" --type=java -B 2 -A 5

# Check if EventOutbox has any processor/consumer
rg -n "EventOutbox" --type=java | grep -v "test\|Repository\|domain\|event/DomainEventPublisher"

# Look for all `@TransactionalEventListener` handlers
rg -n "@TransactionalEventListener" --type=java -B 1 | grep "public void\|EventListener" | head -20

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 6468


OrderCreatedEvent 리스너 구현이 누락되었다.

DomainEventPublisherImpl은 올바르게 구현되었으나, OrderCreatedEvent에 대한 @TransactionalEventListener 핸들러가 존재하지 않는다. EventOutbox에 저장되는 이벤트가 실제로 처리되지 않아 주문 생성 이벤트가 손실된다.

코드베이스의 다른 도메인 이벤트(ProductViewedEvent, LikeCreatedEvent)는 @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) 핸들러로 커밋 후 처리되는데, 주문 이벤트는 이를 구현해야 한다. 현재 상태로는 주문이 생성되어도 다운스트림 시스템(알림, 분석, 재고 연동 등)이 이벤트를 수신하지 못한다.

필수 구현:

  • OrderCreatedEvent 핸들러 클래스 생성
  • @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) 적용
  • Kafka 발행 또는 필요한 비즈니스 로직 처리
  • OrderCancelledEvent 핸들러도 동일 패턴으로 구현
🤖 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 117 - 126, The OrderCreatedEvent is never handled after outbox
persistence; create a new listener class (e.g., OrderEventListener) that defines
`@TransactionalEventListener`(phase = TransactionPhase.AFTER_COMMIT) methods to
handle OrderCreatedEvent and OrderCancelledEvent, map the payload to required
DTOs, and invoke the existing DomainEventPublisherImpl or Kafka publishing logic
to emit the event downstream (mirror the pattern used by
ProductViewedEvent/LikeCreatedEvent handlers); ensure methods subscribe to the
domain event types (OrderCreatedEvent, OrderCancelledEvent), run after commit,
and perform the same outbox-to-kafka publish flow or business logic as other
domain listeners.

return order;
}

Expand Down Expand Up @@ -152,6 +167,16 @@ public void cancelOrder(Long orderId, Long memberId) {
if (order.getCouponIssueId() != null) {
couponFacade.restoreCoupon(order.getCouponIssueId());
}

// Outbox INSERT + 이벤트 발행
List<OrderItemSnapshot> eventItems = order.getItems().stream()
.map(item -> new OrderItemSnapshot(item.getProductId(), item.getQuantity(), item.getProductPrice()))
.toList();

domainEventPublisher.publish("order", String.valueOf(orderId),
"ORDER_CANCELLED",
Map.of("orderId", orderId, "memberId", memberId, "items", eventItems),
new OrderCancelledEvent(orderId, memberId, eventItems));
}

public List<Order> getOrdersByMemberId(Long memberId, ZonedDateTime startAt, ZonedDateTime endAt) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,16 +1,13 @@
package com.loopers.application.payment;

import com.loopers.domain.coupon.CouponIssue;
import com.loopers.domain.coupon.CouponIssueRepository;
import com.loopers.application.coupon.CouponFacade;
import com.loopers.application.product.ProductFacade;
import com.loopers.domain.order.Order;
import com.loopers.domain.order.OrderItem;
import com.loopers.domain.order.OrderRepository;
import com.loopers.domain.payment.*;
import com.loopers.domain.product.Product;
import com.loopers.domain.product.ProductRepository;
import com.loopers.infrastructure.pg.PgPaymentStatusResponse;
import com.loopers.infrastructure.pg.PgRouter;
import com.loopers.infrastructure.redis.StockReservationRedisRepository;
import com.loopers.support.error.CoreException;
import com.loopers.support.error.ErrorType;
import lombok.RequiredArgsConstructor;
Expand All @@ -30,7 +27,7 @@
* <li>CallbackInbox에 원본 저장 (RECEIVED)</li>
* <li>조건부 UPDATE로 Payment 상태 전이</li>
* <li>SUCCESS → Order.pay() + Inbox PROCESSED</li>
* <li>FAILED → 재고 복원(Redis INCR + DB) + 쿠폰 복원 + Inbox PROCESSED</li>
* <li>FAILED → 재고 복원(ProductFacade) + 쿠폰 복원(CouponFacade) + Inbox PROCESSED</li>
* </ol>
*
* <p>Polling Hybrid: PENDING/UNKNOWN 상태 결제건을 주기적으로 PG 확인</p>
Expand All @@ -44,11 +41,11 @@
public class PaymentRecoveryService {

private final PaymentRepository paymentRepository;
private final PaymentStatusHistoryRepository historyRepository;
private final CallbackInboxRepository callbackInboxRepository;
private final OrderRepository orderRepository;
private final ProductRepository productRepository;
private final CouponIssueRepository couponIssueRepository;
private final StockReservationRedisRepository stockRedisRepository;
private final ProductFacade productFacade;
private final CouponFacade couponFacade;
private final PgRouter pgRouter;

/**
Expand Down Expand Up @@ -102,6 +99,10 @@ private void processPaymentTransition(PaymentModel payment, String pgStatus, Cal
return;
}

// 상태 전이 이력 기록
historyRepository.save(PaymentStatusHistory.create(
payment.getId(), payment.getStatus(), targetStatus, "CALLBACK", null));

// 상태 전이 성공
if (targetStatus == PaymentStatus.PAID) {
handlePaymentSuccess(payment);
Expand All @@ -127,23 +128,16 @@ private void handlePaymentFailure(PaymentModel payment) {
Order order = orderRepository.findById(payment.getOrderId()).orElse(null);
if (order == null) return;

// 재고 복원 (Redis INCR + DB)
// 재고 복원 → ProductFacade 위임
for (OrderItem item : order.getItems()) {
stockRedisRepository.increase(item.getProductId(), item.getQuantity());
productRepository.findById(item.getProductId()).ifPresent(product -> {
product.increaseStock(item.getQuantity());
productRepository.save(product);
});
productFacade.restoreStock(item.getProductId(), item.getQuantity());
}
log.info("재고 복원 완료: orderId={}", order.getId());

// 쿠폰 복원
// 쿠폰 복원 → CouponFacade 위임
if (order.getCouponIssueId() != null) {
couponIssueRepository.findById(order.getCouponIssueId()).ifPresent(couponIssue -> {
couponIssue.cancelUse(ZonedDateTime.now());
couponIssueRepository.save(couponIssue);
log.info("쿠폰 복원 완료: couponIssueId={}", couponIssue.getId());
});
couponFacade.restoreCoupon(order.getCouponIssueId());
log.info("쿠폰 복원 완료: couponIssueId={}", order.getCouponIssueId());
}
}

Expand Down Expand Up @@ -219,6 +213,8 @@ private void pollPgStatus(PaymentModel payment) {
int affected = paymentRepository.updateStatusConditionally(
payment.getId(), PaymentStatus.PAID, allowedStatuses);
if (affected > 0) {
historyRepository.save(PaymentStatusHistory.create(
payment.getId(), payment.getStatus(), PaymentStatus.PAID, "POLLING", null));
handlePaymentSuccess(payment);
log.info("Polling 복구 성공: paymentId={}, → PAID", payment.getId());
}
Expand All @@ -227,6 +223,9 @@ private void pollPgStatus(PaymentModel payment) {
int affected = paymentRepository.updateStatusConditionally(
payment.getId(), PaymentStatus.FAILED, allowedStatuses);
if (affected > 0) {
historyRepository.save(PaymentStatusHistory.create(
payment.getId(), payment.getStatus(), PaymentStatus.FAILED,
"POLLING", pgStatus.reason()));
handlePaymentFailure(payment);
log.info("Polling 복구: paymentId={}, → FAILED (reason={})",
payment.getId(), pgStatus.reason());
Expand Down
Loading