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
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,8 +4,10 @@
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
import org.springframework.scheduling.annotation.EnableScheduling;
import java.util.TimeZone;

@EnableScheduling
@ConfigurationPropertiesScan
@SpringBootApplication
public class CommerceApiApplication {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,25 +1,38 @@
package com.loopers.application.coupon;

import com.loopers.application.coupon.dto.CouponIssueRequestResDto;
import com.loopers.application.coupon.dto.FindMyCouponResDto;
import com.loopers.application.coupon.dto.IssueCouponResDto;
import com.loopers.domain.coupon.model.CouponIssueRequest;
import com.loopers.domain.coupon.model.FirstComeCoupon;
import com.loopers.domain.coupon.model.UserCoupon;
import com.loopers.domain.coupon.model.UserCouponItem;
import com.loopers.domain.coupon.repository.CouponIssueRequestRepository;
import com.loopers.domain.coupon.service.CouponService;
import com.loopers.domain.coupon.service.FirstComeCouponService;
import com.loopers.domain.member.model.Member;
import com.loopers.domain.member.service.MemberService;
import com.loopers.support.error.CoreException;
import com.loopers.support.error.ErrorType;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;

import java.util.Map;

@RequiredArgsConstructor
@Component
@Transactional(readOnly = true)
public class CouponFacade {

private final CouponService couponService;
private final MemberService memberService;
private final FirstComeCouponService firstComeCouponService;
private final CouponIssueRequestRepository couponIssueRequestRepository;
private final KafkaTemplate<Object, Object> kafkaTemplate;

@Transactional(rollbackFor = Exception.class)
public IssueCouponResDto issueCoupon(String loginId, String password, Long couponTemplateId) {
Expand All @@ -33,4 +46,37 @@ public Page<FindMyCouponResDto> getMyCoupons(String loginId, String password, Pa
Page<UserCouponItem> items = couponService.getUserCouponsWithTemplate(member.getId(), pageable);
return items.map(FindMyCouponResDto::from);
}

@Transactional(rollbackFor = Exception.class)
public CouponIssueRequestResDto requestFirstComeIssue(String loginId, String password, Long couponTemplateId) {
Member member = memberService.findMember(loginId, password);
FirstComeCoupon fcCoupon = firstComeCouponService.getByTemplateId(couponTemplateId);

firstComeCouponService.addToQueue(fcCoupon, member.getId());
try {
CouponIssueRequest request = couponIssueRequestRepository.save(
CouponIssueRequest.create(couponTemplateId, member.getId()));

kafkaTemplate.send("coupon-issue-requests",
String.valueOf(couponTemplateId),
Map.of("requestId", request.getId(),
"couponTemplateId", couponTemplateId,
"memberId", member.getId()));

return CouponIssueRequestResDto.from(request);
} catch (Exception e) {
firstComeCouponService.removeFromQueue(couponTemplateId, member.getId());
throw e;
}
Comment on lines +50 to +70
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:

find . -type f -name "CouponFacade.java" | head -5

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

Length of output: 167


🏁 Script executed:

find . -type f -name "FirstComeCouponService.java" | head -5

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

Length of output: 180


🏁 Script executed:

find . -type f -name "CouponIssueRequestRepository.java" | head -5

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

Length of output: 189


🏁 Script executed:

cat -n ./apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponFacade.java

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

Length of output: 4579


🏁 Script executed:

cat -n ./apps/commerce-api/src/main/java/com/loopers/domain/coupon/service/FirstComeCouponService.java

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

Length of output: 4696


🏁 Script executed:

cat -n ./apps/commerce-api/src/main/java/com/loopers/domain/coupon/repository/CouponIssueRequestRepository.java

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

Length of output: 764


Redis/DB/Kafka 상태 불일치로 FCFS 요청이 유실될 수 있다.

운영 중 Kafka 브로커 장애나 프로세스 종료 시 Redis 대기열 등록, 요청 row 저장, Kafka 발행이 서로 다른 시스템에 분산되어 상태 일관성이 깨진다. 특히 kafkaTemplate.send()는 비동기이고 반환값(ListenableFuture)을 사용하지 않아 브로커 ack 실패가 현재 catch 블록으로 유입되지 않으므로 removeFromQueue()도 실행되지 않는다. 그 결과 Redis에는 요청자 정보가 남아 있고 DB에도 요청 row가 남아 있으나 Kafka에는 발행되지 않는 상태가 발생한다.

수정안: 이 메서드에서 Kafka를 직접 호출하지 말고 요청 저장과 함께 outbox 테이블 행을 동일한 DB 트랜잭션 내에 기록한 뒤 별도 relay 프로세스가 outbox에서 읽어 Kafka로 발행하는 구조로 변경한다. 추가 테스트로 kafkaTemplate.send()가 비동기 콜백에서 예외 발생하는 경우, DB 트랜잭션 커밋 실패 시나리오, Redis 장애 시 fallback 경로를 각각 주입하여 요청 row/Redis 대기열/outbox 행/발행 여부가 함께 일관되게 유지되는지 검증해야 한다.

🤖 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/coupon/CouponFacade.java`
around lines 50 - 70, The requestFirstComeIssue method currently calls
kafkaTemplate.send asynchronously after saving a CouponIssueRequest and adding
the member to Redis queue (firstComeCouponService.addToQueue), which can leave
Redis/DB/Kafka inconsistent; instead remove the direct kafkaTemplate.send call
and write an Outbox row (e.g., OutboxEvent entity) in the same DB transaction
alongside couponIssueRequestRepository.save so the event is persisted
atomically, keep firstComeCouponService.removeFromQueue in error handling, and
implement a separate relay process to read Outbox rows and publish to Kafka; add
tests that simulate kafka send failures, DB commit failures, and Redis failures
to verify the queue entry, request row, outbox row and eventual publish are kept
consistent.

}

public CouponIssueRequestResDto getIssueRequestStatus(String loginId, String password, Long requestId) {
Member member = memberService.findMember(loginId, password);
CouponIssueRequest request = couponIssueRequestRepository.findById(requestId)
.orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 발급 요청입니다."));
if (!request.getMemberId().equals(member.getId())) {
throw new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 발급 요청입니다.");
}
return CouponIssueRequestResDto.from(request);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.loopers.application.coupon.dto;

import com.loopers.domain.coupon.model.CouponIssueRequest;
import com.loopers.domain.coupon.model.CouponIssueStatus;

public record CouponIssueRequestResDto(Long requestId, Long couponTemplateId, CouponIssueStatus status) {

public static CouponIssueRequestResDto from(CouponIssueRequest request) {
return new CouponIssueRequestResDto(request.getId(), request.getCouponTemplateId(), request.getStatus());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package com.loopers.application.event;

import com.loopers.domain.event.FavoriteAddedEvent;
import com.loopers.domain.event.FavoriteRemovedEvent;
import com.loopers.domain.outbox.model.OutboxEvent;
import com.loopers.domain.outbox.model.OutboxEventType;
import com.loopers.domain.outbox.repository.OutboxEventRepository;
import com.loopers.domain.product.service.ProductService;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
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
@RequiredArgsConstructor
@Component
public class FavoriteEventListener {

private final ProductService productService;
private final OutboxEventRepository outboxEventRepository;
private final ObjectMapper objectMapper;

@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void onFavoriteAdded(FavoriteAddedEvent event) {
log.info("좋아요 집계 처리 - productId: {}, memberId: {}", event.productId(), event.memberId());
productService.increaseLikeCount(event.productId());
outboxEventRepository.save(OutboxEvent.create(
OutboxEventType.FAVORITE_ADDED,
String.valueOf(event.productId()),
toJson(event)
));
log.info("유저 행동 로깅 - memberId: {}, action: FAVORITE_ADD, targetId: {}", event.memberId(), event.productId());
}

@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void onFavoriteRemoved(FavoriteRemovedEvent event) {
log.info("좋아요 집계 해제 처리 - productId: {}, memberId: {}", event.productId(), event.memberId());
productService.decreaseLikeCount(event.productId());
outboxEventRepository.save(OutboxEvent.create(
OutboxEventType.FAVORITE_REMOVED,
String.valueOf(event.productId()),
toJson(event)
));
log.info("유저 행동 로깅 - memberId: {}, action: FAVORITE_REMOVE, targetId: {}", event.memberId(), event.productId());
}

private String toJson(Object obj) {
try {
return objectMapper.writeValueAsString(obj);
} catch (JsonProcessingException e) {
throw new RuntimeException("이벤트 직렬화 실패", e);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package com.loopers.application.event;

import com.loopers.domain.coupon.service.CouponService;
import com.loopers.domain.event.OrderCreatedEvent;
import com.loopers.domain.event.OrderExpiredEvent;
import com.loopers.domain.order.model.OrderProduct;
import com.loopers.domain.order.service.OrderProductService;
import com.loopers.domain.outbox.model.OutboxEvent;
import com.loopers.domain.outbox.model.OutboxEventType;
import com.loopers.domain.outbox.repository.OutboxEventRepository;
import com.loopers.domain.product.service.ProductService;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
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;

import java.util.List;

@Slf4j
@RequiredArgsConstructor
@Component
public class OrderEventListener {

private final OutboxEventRepository outboxEventRepository;
private final OrderProductService orderProductService;
private final ProductService productService;
private final CouponService couponService;
private final ObjectMapper objectMapper;

@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void onOrderCreated(OrderCreatedEvent event) {
log.info("주문 생성 이벤트 - orderId: {}, memberId: {}, totalPrice: {}", event.orderId(), event.memberId(), event.totalPrice());
outboxEventRepository.save(OutboxEvent.create(
OutboxEventType.ORDER_CREATED,
String.valueOf(event.orderId()),
toJson(event)
));
Comment on lines +35 to +43
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:

find . -name "OrderEventListener.java" -type f

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

Length of output: 172


🏁 Script executed:

find . -name "*Outbox*" -type f | head -20

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

Length of output: 839


🏁 Script executed:

git ls-files "apps/commerce-api" | grep -E "(Event|Outbox)" | head -20

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

Length of output: 1659


🏁 Script executed:

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

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

Length of output: 3805


🏁 Script executed:

cat -n apps/commerce-api/src/main/java/com/loopers/domain/outbox/model/OutboxEvent.java

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

Length of output: 1760


🏁 Script executed:

cat -n apps/commerce-api/src/main/java/com/loopers/domain/outbox/repository/OutboxEventRepository.java

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

Length of output: 426


🏁 Script executed:

cat -n apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/repository/impl/OutboxEventRepositoryImpl.java

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

Length of output: 1429


🏁 Script executed:

rg "publishEvent\|new OrderCreatedEvent\|applicationEventPublisher" --type java -A 3 -B 3 | head -50

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

Length of output: 67


🏁 Script executed:

find . -path "*/order/service/*.java" -type f | head -10

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

Length of output: 261


🏁 Script executed:

cat -n apps/commerce-api/src/main/java/com/loopers/domain/order/service/OrderService.java

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

Length of output: 3395


🏁 Script executed:

rg "OrderCreatedEvent" --type java -B 5 -A 2 | head -60

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

Length of output: 4571


🏁 Script executed:

cat -n apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java

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

Length of output: 4611


🏁 Script executed:

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

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

Length of output: 4471


🏁 Script executed:

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

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

Length of output: 3211


🏁 Script executed:

rg "OutboxEventType" --type java | head -20

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

Length of output: 2392


🏁 Script executed:

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

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

Length of output: 2173


Outbox를 AFTER_COMMIT에서 저장하면 주문 커밋과 원자성이 끊어진다.

OrderFacade.createOrder()는 주문 저장 후 같은 트랜잭션 내에서 OrderCreatedEvent를 발행한다. 하지만 OrderEventListener.onOrderCreated()는 @TransactionalEventListener(phase = AFTER_COMMIT) + @Transactional(REQUIRES_NEW)로 설정되어, 주문 커밋 이후 새로운 트랜잭션에서 outbox 행을 저장한다. 운영 중 주문 커밋 직후 JVM이 종료되거나 listener 실행이 실패하면 주문은 이미 생성되었는데 outbox 행이 없어 downstream이 해당 이벤트를 영구히 받지 못한다.

수정안은 outbox 저장을 주문 생성과 같은 트랜잭션 내에서 수행하고, 별도의 OutboxRelay 스케줄러가 이미 존재하므로 outbox 행을 대기 상태로 저장 후 relay가 비동기로 발행하도록 분리하는 것이다. PaymentEventListener의 onPaymentCompleted()와 FavoriteEventListener의 onFavoriteAdded/Removed()도 동일한 패턴을 사용하고 있으므로 함께 수정해야 한다. 추가 테스트로 outbox 저장 실패를 주입했을 때 주문과 outbox가 함께 rollback되어 주문만 있고 outbox는 없는 상태가 생기지 않는지 검증해야 한다.

🤖 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/OrderEventListener.java`
around lines 35 - 43, The outbox save must happen in the same transaction as the
domain write so remove the AFTER_COMMIT + REQUIRES_NEW pattern in the listeners
and persist outbox rows inside the ordering transaction: update
OrderEventListener.onOrderCreated to save the OutboxEvent within the original
transaction (e.g., change `@TransactionalEventListener`(phase =
TransactionPhase.AFTER_COMMIT) and remove `@Transactional`(propagation =
Propagation.REQUIRES_NEW) — or alternatively invoke outbox persistence directly
from OrderFacade.createOrder right after the order save using
outboxEventRepository.save(OutboxEvent.create(...)) so the outbox and order
share one commit), and apply the same change to
PaymentEventListener.onPaymentCompleted and
FavoriteEventListener.onFavoriteAdded/Removed; add tests that inject an outbox
save failure to verify both the order and outbox are rolled back together and no
“order without outbox” state can occur, and rely on the existing OutboxRelay
scheduler to asynchronously publish persisted outbox rows.

log.info("유저 행동 로깅 - memberId: {}, action: ORDER_CREATE, targetId: {}", event.memberId(), event.orderId());
}

@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void onOrderExpired(OrderExpiredEvent event) {
log.info("주문 만료 처리 - orderId: {}", event.orderId());
try {
List<OrderProduct> products = orderProductService.findByOrderId(event.orderId());
for (OrderProduct op : products) {
productService.increaseStockAtomic(op.getProductId(), op.getQuantity().value());
}
if (event.userCouponId() != null) {
couponService.restoreUserCoupon(event.userCouponId());
}
log.info("주문 만료 복원 완료 - orderId: {}, 재고 복원 {}건, 쿠폰 복원: {}",
event.orderId(), products.size(), event.userCouponId() != null);
} catch (Exception e) {
log.error("주문 만료 복원 실패 - orderId: {}, 수동 확인 필요", event.orderId(), e);
}
Comment on lines +49 to +63
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:

find . -name "OrderEventListener.java" -type f

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

Length of output: 172


🏁 Script executed:

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

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

Length of output: 3805


🏁 Script executed:

# onOrderExpired 메서드 주변 전체 컨텍스트 확인
sed -n '40,70p' apps/commerce-api/src/main/java/com/loopers/application/event/OrderEventListener.java

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

Length of output: 1495


🏁 Script executed:

# 클래스 선언과 import 확인
head -50 apps/commerce-api/src/main/java/com/loopers/application/event/OrderEventListener.java

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

Length of output: 2375


🏁 Script executed:

# 호출되는 서비스들의 구현 확인 - orderProductService.findByOrderId
find . -name "*OrderProductService*" -type f

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

Length of output: 264


🏁 Script executed:

# 호출되는 서비스들의 구현 확인 - productService.increaseStockAtomic
find . -name "*ProductService*" -type f

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

Length of output: 534


🏁 Script executed:

# 호출되는 서비스들의 구현 확인 - couponService.restoreUserCoupon
find . -name "*CouponService*" -type f

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

Length of output: 444


🏁 Script executed:

cat -n ./apps/commerce-api/src/main/java/com/loopers/domain/product/service/ProductService.java

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

Length of output: 8742


🏁 Script executed:

cat -n ./apps/commerce-api/src/main/java/com/loopers/domain/coupon/service/CouponService.java

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

Length of output: 5546


🏁 Script executed:

cat -n ./apps/commerce-api/src/main/java/com/loopers/domain/order/service/OrderProductService.java

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

Length of output: 954


보상 처리 예외를 무시하면 부분 복구가 커밋된다.

현재 REQUIRES_NEW 트랜잭션에서 예외를 catch하여 무시하므로, 루프 내 여러 상품의 재고 복원 중 하나만 실패해도 앞의 성공한 복원들은 그대로 커밋된다. 예를 들어 첫 번째 상품 재고는 복원되고 두 번째 상품 복원이 실패한 경우, 메서드가 정상 종료되어 트랜잭션이 커밋되어 부분 복구 상태가 영구적으로 남는다. 운영 환경에서는 주문 취소 후 일부 재고와 쿠폰만 복구된 불일치 상태가 발생한다.

예외를 전파하여 REQUIRES_NEW 트랜잭션 전체를 롤백하거나, 실패 건을 outbox 이벤트로 기록해 별도 재시도 메커니즘으로 처리해야 한다. 추가로 OrderProduct 반복 중 특정 상품의 재고 복원만 실패하는 시나리오를 테스트하여 어떤 데이터도 부분 반영되지 않는지 검증해야 한다.

🤖 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/OrderEventListener.java`
around lines 49 - 63, The onOrderExpired method in OrderEventListener currently
swallows exceptions which allows partial commits inside the REQUIRES_NEW
transaction; change the error-handling so failures cause the transaction to roll
back or are recorded for retry: either remove the broad try/catch and let
exceptions from productService.increaseStockAtomic or
couponService.restoreUserCoupon propagate so the REQUIRES_NEW transaction
aborts, or catch per-item failures inside the loop and instead persist an
outbox/failure record for the failed product (including orderId, productId,
quantity, error) and then throw a new exception after the loop to trigger
rollback; also add tests for OrderEventListener.onOrderExpired (mock
OrderProductService.findByOrderId and productService.increaseStockAtomic) to
verify that when any increaseStockAtomic throws, no stock/coupon changes are
committed and the outbox entry is created if using the retry approach.

}

private String toJson(Object obj) {
try {
return objectMapper.writeValueAsString(obj);
} catch (JsonProcessingException e) {
throw new RuntimeException("이벤트 직렬화 실패", e);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package com.loopers.application.event;

import com.loopers.domain.event.OrderExpiredEvent;
import com.loopers.domain.order.OrderStatus;
import com.loopers.domain.order.model.Orders;
import com.loopers.domain.order.service.OrderService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;

@Slf4j
@RequiredArgsConstructor
@Component
public class OrderExpireScheduler {

private final OrderService orderService;
private final ApplicationEventPublisher eventPublisher;

@Scheduled(fixedDelay = 60000)
@Transactional
public void expireUnpaidOrders() {
List<Orders> expiredOrders = orderService.findExpiredOrders();
if (expiredOrders.isEmpty()) {
return;
}

for (Orders order : expiredOrders) {
orderService.updateOrderStatus(order.getId(), OrderStatus.CANCELLED);
eventPublisher.publishEvent(new OrderExpiredEvent(order.getId(), order.getUserCouponId()));
log.info("주문 만료 처리 - orderId: {}", order.getId());
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package com.loopers.application.event;

import com.loopers.domain.outbox.model.OutboxEvent;
import com.loopers.domain.outbox.model.OutboxEventType;
import com.loopers.domain.outbox.repository.OutboxEventRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

import java.util.List;

@Slf4j
@RequiredArgsConstructor
@Component
public class OutboxRelay {

private final OutboxEventRepository outboxEventRepository;
private final KafkaTemplate<Object, Object> kafkaTemplate;

@Scheduled(fixedDelay = 1000)
public void relay() {
List<OutboxEvent> events = outboxEventRepository.findByStatusInit();
if (events.isEmpty()) {
return;
}

for (OutboxEvent event : events) {
String topic = resolveTopic(event.getEventType());
try {
kafkaTemplate.send(topic, event.getAggregateId(), event.getPayload()).get();
event.markPublished();
log.info("Outbox 이벤트 발행 - topic: {}, key: {}, eventType: {}", topic, event.getAggregateId(), event.getEventType());
} catch (Exception e) {
event.markFailed();
log.error("Outbox 이벤트 발행 실패 - eventId: {}", event.getId(), e);
}
outboxEventRepository.save(event);
}
}

private String resolveTopic(OutboxEventType eventType) {
return switch (eventType) {
case FAVORITE_ADDED, FAVORITE_REMOVED -> "catalog-events";
case ORDER_CREATED, PAYMENT_COMPLETED -> "order-events";
};
}
}
Loading