-
Notifications
You must be signed in to change notification settings - Fork 44
feat : 7주 과제 #290
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: dfdf0202
Are you sure you want to change the base?
feat : 7주 과제 #290
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: find . -name "OrderEventListener.java" -type fRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 172 🏁 Script executed: find . -name "*Outbox*" -type f | head -20Repository: 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 -20Repository: 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.javaRepository: 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.javaRepository: 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.javaRepository: 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.javaRepository: 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 -50Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 67 🏁 Script executed: find . -path "*/order/service/*.java" -type f | head -10Repository: 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.javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 3395 🏁 Script executed: rg "OrderCreatedEvent" --type java -B 5 -A 2 | head -60Repository: 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.javaRepository: 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.javaRepository: 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.javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 3211 🏁 Script executed: rg "OutboxEventType" --type java | head -20Repository: 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.javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 2173 Outbox를 OrderFacade.createOrder()는 주문 저장 후 같은 트랜잭션 내에서 OrderCreatedEvent를 발행한다. 하지만 OrderEventListener.onOrderCreated()는 수정안은 outbox 저장을 주문 생성과 같은 트랜잭션 내에서 수행하고, 별도의 OutboxRelay 스케줄러가 이미 존재하므로 outbox 행을 대기 상태로 저장 후 relay가 비동기로 발행하도록 분리하는 것이다. PaymentEventListener의 onPaymentCompleted()와 FavoriteEventListener의 onFavoriteAdded/Removed()도 동일한 패턴을 사용하고 있으므로 함께 수정해야 한다. 추가 테스트로 outbox 저장 실패를 주입했을 때 주문과 outbox가 함께 rollback되어 🤖 Prompt for AI Agents |
||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: find . -name "OrderEventListener.java" -type fRepository: 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.javaRepository: 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.javaRepository: 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.javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 2375 🏁 Script executed: # 호출되는 서비스들의 구현 확인 - orderProductService.findByOrderId
find . -name "*OrderProductService*" -type fRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 264 🏁 Script executed: # 호출되는 서비스들의 구현 확인 - productService.increaseStockAtomic
find . -name "*ProductService*" -type fRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 534 🏁 Script executed: # 호출되는 서비스들의 구현 확인 - couponService.restoreUserCoupon
find . -name "*CouponService*" -type fRepository: 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.javaRepository: 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.javaRepository: 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.javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 954 보상 처리 예외를 무시하면 부분 복구가 커밋된다. 현재 예외를 전파하여 🤖 Prompt for AI Agents |
||
| } | ||
|
|
||
| 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"; | ||
| }; | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 167
🏁 Script executed:
Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 180
🏁 Script executed:
Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 189
🏁 Script executed:
Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 4579
🏁 Script executed:
Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 4696
🏁 Script executed:
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