diff --git a/apps/commerce-api/build.gradle.kts b/apps/commerce-api/build.gradle.kts index 150315afa..027847bea 100644 --- a/apps/commerce-api/build.gradle.kts +++ b/apps/commerce-api/build.gradle.kts @@ -2,9 +2,11 @@ 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")) + implementation(project(":supports:kafka-events")) // web implementation("org.springframework.boot:spring-boot-starter-web") diff --git a/apps/commerce-api/src/main/java/com/loopers/CommerceApiApplication.java b/apps/commerce-api/src/main/java/com/loopers/CommerceApiApplication.java index f5e86c960..903b40071 100644 --- a/apps/commerce-api/src/main/java/com/loopers/CommerceApiApplication.java +++ b/apps/commerce-api/src/main/java/com/loopers/CommerceApiApplication.java @@ -5,10 +5,12 @@ import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.context.properties.ConfigurationPropertiesScan; import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.annotation.EnableScheduling; import java.util.TimeZone; @EnableAsync +@EnableScheduling @ConfigurationPropertiesScan @SpringBootApplication public class CommerceApiApplication { diff --git a/apps/commerce-api/src/main/java/com/loopers/application/event/CouponIssueRequestCreatedEvent.java b/apps/commerce-api/src/main/java/com/loopers/application/event/CouponIssueRequestCreatedEvent.java new file mode 100644 index 000000000..d7a909e8c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/event/CouponIssueRequestCreatedEvent.java @@ -0,0 +1,24 @@ +package com.loopers.application.event; + +import java.time.ZonedDateTime; + +/** + * 쿠폰 발급 요청 생성 이벤트. + * 발급 요청이 생성되었을 때 발행됨. + */ +public record CouponIssueRequestCreatedEvent( + String eventId, + String requestId, + Long couponId, + Long userId, + ZonedDateTime occurredAt +) { + public static CouponIssueRequestCreatedEvent of( + String eventId, + String requestId, + Long couponId, + Long userId + ) { + return new CouponIssueRequestCreatedEvent(eventId, requestId, couponId, userId, ZonedDateTime.now()); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/event/LikeCanceledEvent.java b/apps/commerce-api/src/main/java/com/loopers/application/event/LikeCanceledEvent.java new file mode 100644 index 000000000..ef64f1254 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/event/LikeCanceledEvent.java @@ -0,0 +1,18 @@ +package com.loopers.application.event; + +import java.time.ZonedDateTime; + +/** + * 좋아요 취소 이벤트. + * 사용자가 상품 좋아요를 취소했을 때 발행됨. + */ +public record LikeCanceledEvent( + Long likeId, + Long userId, + Long productId, + ZonedDateTime occurredAt +) { + public static LikeCanceledEvent of(Long likeId, Long userId, Long productId) { + return new LikeCanceledEvent(likeId, userId, productId, ZonedDateTime.now()); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/event/LikeCreatedEvent.java b/apps/commerce-api/src/main/java/com/loopers/application/event/LikeCreatedEvent.java new file mode 100644 index 000000000..b7be56f17 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/event/LikeCreatedEvent.java @@ -0,0 +1,18 @@ +package com.loopers.application.event; + +import java.time.ZonedDateTime; + +/** + * 좋아요 생성 이벤트. + * 사용자가 상품에 좋아요를 등록했을 때 발행됨. + */ +public record LikeCreatedEvent( + Long likeId, + Long userId, + Long productId, + ZonedDateTime occurredAt +) { + public static LikeCreatedEvent of(Long likeId, Long userId, Long productId) { + return new LikeCreatedEvent(likeId, userId, productId, ZonedDateTime.now()); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/event/OrderCompletedEvent.java b/apps/commerce-api/src/main/java/com/loopers/application/event/OrderCompletedEvent.java new file mode 100644 index 000000000..baa35f1bd --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/event/OrderCompletedEvent.java @@ -0,0 +1,20 @@ +package com.loopers.application.event; + +import java.time.ZonedDateTime; + +/** + * 주문 완료 이벤트. + * 주문이 성공적으로 생성되었을 때 발행됨. + */ +public record OrderCompletedEvent( + Long orderId, + Long userId, + Long productId, + Integer quantity, + Long totalAmount, + ZonedDateTime occurredAt +) { + public static OrderCompletedEvent of(Long orderId, Long userId, Long productId, Integer quantity, Long totalAmount) { + return new OrderCompletedEvent(orderId, userId, productId, quantity, totalAmount, ZonedDateTime.now()); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/event/ProductViewedEvent.java b/apps/commerce-api/src/main/java/com/loopers/application/event/ProductViewedEvent.java new file mode 100644 index 000000000..bf05ce99a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/event/ProductViewedEvent.java @@ -0,0 +1,17 @@ +package com.loopers.application.event; + +import java.time.ZonedDateTime; + +/** + * 상품 조회 이벤트. + * 사용자가 상품 상세 페이지를 조회했을 때 발행됨. + */ +public record ProductViewedEvent( + Long userId, + Long productId, + ZonedDateTime occurredAt +) { + public static ProductViewedEvent of(Long userId, Long productId) { + return new ProductViewedEvent(userId, productId, ZonedDateTime.now()); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/event/UserActionEvent.java b/apps/commerce-api/src/main/java/com/loopers/application/event/UserActionEvent.java new file mode 100644 index 000000000..f59e6ae98 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/event/UserActionEvent.java @@ -0,0 +1,20 @@ +package com.loopers.application.event; + +import com.loopers.domain.useraction.ActionType; + +import java.time.ZonedDateTime; + +/** + * 유저 행동 이벤트. + * 사용자의 행동(조회, 클릭, 좋아요, 주문 등)을 기록하기 위해 발행됨. + */ +public record UserActionEvent( + Long userId, + ActionType actionType, + Long targetId, + ZonedDateTime occurredAt +) { + public static UserActionEvent of(Long userId, ActionType actionType, Long targetId) { + return new UserActionEvent(userId, actionType, targetId, ZonedDateTime.now()); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/event/listener/LikeCountEventListener.java b/apps/commerce-api/src/main/java/com/loopers/application/event/listener/LikeCountEventListener.java new file mode 100644 index 000000000..5c47a9f3b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/event/listener/LikeCountEventListener.java @@ -0,0 +1,49 @@ +package com.loopers.application.event.listener; + +import com.loopers.application.event.LikeCanceledEvent; +import com.loopers.application.event.LikeCreatedEvent; +import com.loopers.infrastructure.cache.LikeCountCacheService; +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; + +/** + * 좋아요 카운트 이벤트 리스너. + * 좋아요 생성/취소 이벤트 발생 시 Redis 카운터 갱신. + * Redis 작업만 수행하므로 별도 트랜잭션이 필요 없음. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class LikeCountEventListener { + + private final LikeCountCacheService likeCountCacheService; + + /** + * 좋아요 생성 시 카운터 증가. + */ + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handleLikeCreated(LikeCreatedEvent event) { + try { + Long count = likeCountCacheService.increment(event.productId()); + log.debug("좋아요 카운터 증가: productId={}, count={}", event.productId(), count); + } catch (Exception e) { + log.warn("좋아요 카운터 증가 실패 (무시): productId={}", event.productId(), e); + } + } + + /** + * 좋아요 취소 시 카운터 감소. + */ + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handleLikeCanceled(LikeCanceledEvent event) { + try { + Long count = likeCountCacheService.decrement(event.productId()); + log.debug("좋아요 카운터 감소: productId={}, count={}", event.productId(), count); + } catch (Exception e) { + log.warn("좋아요 카운터 감소 실패 (무시): productId={}", event.productId(), e); + } + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/application/event/listener/OrderNotificationListener.java b/apps/commerce-api/src/main/java/com/loopers/application/event/listener/OrderNotificationListener.java new file mode 100644 index 000000000..09c89a872 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/event/listener/OrderNotificationListener.java @@ -0,0 +1,25 @@ +package com.loopers.application.event.listener; + +import com.loopers.application.event.OrderCompletedEvent; +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 OrderNotificationListener { + + /** + * 주문 완료 시 알림 발송. + */ + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handleOrderCompleted(OrderCompletedEvent event) { + log.info("주문 완료 알림: orderId={}, userId={}, totalAmount={}", + event.orderId(), event.userId(), event.totalAmount()); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/event/listener/OutboxEventListener.java b/apps/commerce-api/src/main/java/com/loopers/application/event/listener/OutboxEventListener.java new file mode 100644 index 000000000..7cdb641bc --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/event/listener/OutboxEventListener.java @@ -0,0 +1,111 @@ +package com.loopers.application.event.listener; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.application.event.CouponIssueRequestCreatedEvent; +import com.loopers.application.event.LikeCanceledEvent; +import com.loopers.application.event.LikeCreatedEvent; +import com.loopers.application.event.OrderCompletedEvent; +import com.loopers.application.event.ProductViewedEvent; +import com.loopers.domain.outbox.OutboxEvent; +import com.loopers.domain.outbox.OutboxEventRepository; +import com.loopers.event.AggregateType; +import com.loopers.event.EventType; +import com.loopers.event.KafkaTopics; +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; + +/** + * Transactional Outbox Pattern을 위한 이벤트 리스너. + * 트랜잭션 커밋 전에 Outbox 테이블에 이벤트를 저장. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class OutboxEventListener { + + private final OutboxEventRepository outboxEventRepository; + private final ObjectMapper objectMapper; + + @TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT) + public void handleLikeCreatedEvent(LikeCreatedEvent event) { + saveOutboxEvent( + EventType.LIKE_CREATED, + AggregateType.PRODUCT, + String.valueOf(event.productId()), + KafkaTopics.CATALOG_EVENTS, + event + ); + } + + @TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT) + public void handleLikeCanceledEvent(LikeCanceledEvent event) { + saveOutboxEvent( + EventType.LIKE_CANCELED, + AggregateType.PRODUCT, + String.valueOf(event.productId()), + KafkaTopics.CATALOG_EVENTS, + event + ); + } + + @TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT) + public void handleOrderCompletedEvent(OrderCompletedEvent event) { + saveOutboxEvent( + EventType.ORDER_COMPLETED, + AggregateType.ORDER, + String.valueOf(event.orderId()), + KafkaTopics.ORDER_EVENTS, + event + ); + } + + @TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT) + public void handleProductViewedEvent(ProductViewedEvent event) { + saveOutboxEvent( + EventType.PRODUCT_VIEWED, + AggregateType.PRODUCT, + String.valueOf(event.productId()), + KafkaTopics.CATALOG_EVENTS, + event + ); + } + + @TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT) + public void handleCouponIssueRequestCreatedEvent(CouponIssueRequestCreatedEvent event) { + saveOutboxEvent( + EventType.COUPON_ISSUE_REQUESTED, + AggregateType.COUPON, + String.valueOf(event.couponId()), + KafkaTopics.COUPON_ISSUE_REQUESTS, + event + ); + } + + private void saveOutboxEvent( + EventType eventType, + AggregateType aggregateType, + String aggregateId, + String topic, + Object event + ) { + try { + String payload = objectMapper.writeValueAsString(event); + OutboxEvent outboxEvent = OutboxEvent.create( + eventType.name(), + aggregateType.name(), + aggregateId, + topic, + payload + ); + outboxEventRepository.save(outboxEvent); + log.debug("Outbox event saved: type={}, aggregateId={}", eventType, aggregateId); + } catch (JsonProcessingException e) { + log.error("Failed to serialize event: {}", event, e); + throw new RuntimeException("Failed to serialize event for outbox", e); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/event/listener/UserActionLogListener.java b/apps/commerce-api/src/main/java/com/loopers/application/event/listener/UserActionLogListener.java new file mode 100644 index 000000000..5f7a6ea98 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/event/listener/UserActionLogListener.java @@ -0,0 +1,50 @@ +package com.loopers.application.event.listener; + +import com.loopers.application.event.UserActionEvent; +import com.loopers.domain.useraction.UserActionLog; +import com.loopers.domain.useraction.UserActionLogRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +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; + +/** + * 사용자 행동 로그 이벤트 리스너. + * 사용자 행동 이벤트 발생 시 비동기로 로그 저장. + * @Async로 비동기 실행되므로 메인 트랜잭션과 커넥션 경쟁 없음. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class UserActionLogListener { + + private final UserActionLogRepository userActionLogRepository; + + /** + * 사용자 행동 로그 저장. + * @TransactionalEventListener와 함께 사용 시 REQUIRES_NEW 필수. + */ + @Async("eventExecutor") + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void handleUserAction(UserActionEvent event) { + try { + UserActionLog actionLog = UserActionLog.create( + event.userId(), + event.actionType(), + event.targetId(), + event.occurredAt() + ); + userActionLogRepository.save(actionLog); + log.debug("사용자 행동 로그 저장: userId={}, actionType={}, targetId={}", + event.userId(), event.actionType(), event.targetId()); + } catch (Exception e) { + log.warn("사용자 행동 로그 저장 실패 (무시): userId={}, actionType={}", + event.userId(), event.actionType(), e); + } + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/application/fcfs/CouponIssueApplicationService.java b/apps/commerce-api/src/main/java/com/loopers/application/fcfs/CouponIssueApplicationService.java new file mode 100644 index 000000000..1fde79755 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/fcfs/CouponIssueApplicationService.java @@ -0,0 +1,70 @@ +package com.loopers.application.fcfs; + +import com.loopers.application.event.CouponIssueRequestCreatedEvent; +import com.loopers.domain.fcfs.CouponIssueRequest; +import com.loopers.domain.fcfs.CouponIssueRequestRepository; +import com.loopers.domain.fcfs.FcfsCouponRepository; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.UUID; + +/** + * 선착순 쿠폰 발급 Application Service. + */ +@Service +@RequiredArgsConstructor +public class CouponIssueApplicationService { + + private final FcfsCouponRepository fcfsCouponRepository; + private final CouponIssueRequestRepository couponIssueRequestRepository; + private final ApplicationEventPublisher eventPublisher; + + /** + * 쿠폰 발급 요청. + * 1. 쿠폰 존재 여부 확인 + * 2. 발급 요청 저장 (PENDING) + * 3. 이벤트 발행 → Outbox 패턴으로 Kafka에 전달 + */ + @Transactional + public CouponIssueRequestResult requestIssue(Long couponId, Long userId) { + // 1. 쿠폰 존재 확인 + if (!fcfsCouponRepository.existsById(couponId)) { + throw new CoreException(ErrorType.NOT_FOUND, "Coupon not found: " + couponId); + } + + // 2. 발급 요청 저장 + CouponIssueRequest request = CouponIssueRequest.create(couponId, userId); + CouponIssueRequest savedRequest = couponIssueRequestRepository.save(request); + + // 3. 이벤트 발행 (OutboxEventListener가 Outbox에 저장) + String eventId = UUID.randomUUID().toString(); + eventPublisher.publishEvent(CouponIssueRequestCreatedEvent.of( + eventId, + savedRequest.getRequestId(), + couponId, + userId + )); + + return CouponIssueRequestResult.of(savedRequest.getRequestId(), savedRequest.getStatus()); + } + + /** + * 발급 결과 조회. + */ + @Transactional(readOnly = true) + public CouponIssueResultResponse getIssueResult(String requestId) { + CouponIssueRequest request = couponIssueRequestRepository.findByRequestId(requestId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "Issue request not found: " + requestId)); + + return CouponIssueResultResponse.of( + request.getRequestId(), + request.getStatus(), + request.getFailureReason() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/fcfs/CouponIssueRequestResult.java b/apps/commerce-api/src/main/java/com/loopers/application/fcfs/CouponIssueRequestResult.java new file mode 100644 index 000000000..d8b42e3bd --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/fcfs/CouponIssueRequestResult.java @@ -0,0 +1,15 @@ +package com.loopers.application.fcfs; + +import com.loopers.domain.fcfs.CouponIssueRequestStatus; + +/** + * 쿠폰 발급 요청 결과 DTO. + */ +public record CouponIssueRequestResult( + String requestId, + CouponIssueRequestStatus status +) { + public static CouponIssueRequestResult of(String requestId, CouponIssueRequestStatus status) { + return new CouponIssueRequestResult(requestId, status); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/fcfs/CouponIssueResultResponse.java b/apps/commerce-api/src/main/java/com/loopers/application/fcfs/CouponIssueResultResponse.java new file mode 100644 index 000000000..a3d301b70 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/fcfs/CouponIssueResultResponse.java @@ -0,0 +1,16 @@ +package com.loopers.application.fcfs; + +import com.loopers.domain.fcfs.CouponIssueRequestStatus; + +/** + * 쿠폰 발급 결과 조회 응답 DTO. + */ +public record CouponIssueResultResponse( + String requestId, + CouponIssueRequestStatus status, + String failureReason +) { + public static CouponIssueResultResponse of(String requestId, CouponIssueRequestStatus status, String failureReason) { + return new CouponIssueResultResponse(requestId, status, failureReason); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeApplicationService.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeApplicationService.java index 4d45b8daf..c311b2d08 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeApplicationService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeApplicationService.java @@ -1,18 +1,20 @@ package com.loopers.application.like; +import com.loopers.application.event.LikeCanceledEvent; +import com.loopers.application.event.LikeCreatedEvent; +import com.loopers.application.event.UserActionEvent; import com.loopers.domain.like.Like; import com.loopers.domain.like.LikeDomainService; import com.loopers.domain.like.LikeRepository; import com.loopers.domain.product.ProductRepository; -import com.loopers.infrastructure.cache.LikeCountCacheService; +import com.loopers.domain.useraction.ActionType; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import org.springframework.transaction.support.TransactionSynchronization; -import org.springframework.transaction.support.TransactionSynchronizationManager; /** * 좋아요 Application Service. @@ -26,12 +28,12 @@ public class LikeApplicationService { private final LikeDomainService likeDomainService; private final ProductRepository productRepository; private final LikeRepository likeRepository; - private final LikeCountCacheService likeCountCacheService; + private final ApplicationEventPublisher eventPublisher; /** * 좋아요 등록. * 상품 존재 여부 검증 후 도메인 서비스 호출. - * 트랜잭션 커밋 후 Redis 카운터 증가. + * 트랜잭션 커밋 후 Redis 카운터 증가 이벤트 발행. * * @param userId 사용자 ID * @param productId 상품 ID @@ -42,7 +44,8 @@ public LikeResult like(Long userId, Long productId) { validateProductExists(productId); Like like = likeDomainService.like(userId, productId); - registerAfterCommit(() -> incrementLikeCount(productId)); + eventPublisher.publishEvent(LikeCreatedEvent.of(like.getId(), userId, productId)); + eventPublisher.publishEvent(UserActionEvent.of(userId, ActionType.LIKE, productId)); return LikeResult.from(like); } @@ -50,55 +53,23 @@ public LikeResult like(Long userId, Long productId) { /** * 좋아요 취소. * 멱등하게 동작 - 존재하지 않아도 예외 없이 처리. - * 트랜잭션 커밋 후 Redis 카운터 감소. + * 트랜잭션 커밋 후 Redis 카운터 감소 이벤트 발행. * * @param userId 사용자 ID * @param productId 상품 ID */ @Transactional public void unlike(Long userId, Long productId) { - boolean existed = likeDomainService.unlike(userId, productId); - - if (existed) { - registerAfterCommit(() -> decrementLikeCount(productId)); - } + likeRepository.findByUserIdAndProductId(userId, productId) + .ifPresent(like -> { + Long likeId = like.getId(); + likeRepository.delete(like); + eventPublisher.publishEvent(LikeCanceledEvent.of(likeId, userId, productId)); + }); } private void validateProductExists(Long productId) { productRepository.findByIdActive(productId) .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다.")); } - - private void incrementLikeCount(Long productId) { - try { - Long count = likeCountCacheService.increment(productId); - log.debug("좋아요 카운터 증가: productId={}, count={}", productId, count); - } catch (Exception e) { - log.warn("좋아요 카운터 증가 실패 (무시): productId={}", productId, e); - } - } - - private void decrementLikeCount(Long productId) { - try { - Long count = likeCountCacheService.decrement(productId); - log.debug("좋아요 카운터 감소: productId={}, count={}", productId, count); - } catch (Exception e) { - log.warn("좋아요 카운터 감소 실패 (무시): productId={}", productId, e); - } - } - - private void registerAfterCommit(Runnable action) { - if (TransactionSynchronizationManager.isSynchronizationActive()) { - TransactionSynchronizationManager.registerSynchronization( - new TransactionSynchronization() { - @Override - public void afterCommit() { - action.run(); - } - } - ); - } else { - action.run(); - } - } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderApplicationService.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderApplicationService.java index f7edcec50..744e2bdae 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderApplicationService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderApplicationService.java @@ -1,5 +1,7 @@ package com.loopers.application.order; +import com.loopers.application.event.OrderCompletedEvent; +import com.loopers.application.event.UserActionEvent; import com.loopers.domain.common.Money; import com.loopers.domain.coupon.CouponTemplate; import com.loopers.domain.coupon.CouponTemplateRepository; @@ -12,9 +14,11 @@ import com.loopers.domain.point.UserPointRepository; import com.loopers.domain.product.Product; import com.loopers.domain.product.ProductRepository; +import com.loopers.domain.useraction.ActionType; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -34,6 +38,7 @@ public class OrderApplicationService { private final CouponTemplateRepository couponTemplateRepository; private final IssuedCouponRepository issuedCouponRepository; private final UserPointRepository userPointRepository; + private final ApplicationEventPublisher eventPublisher; /** * 주문 생성. @@ -75,6 +80,9 @@ public OrderResult placeOrder(Long userId, List items) { Order order = Order.create(userId, orderItems); Order saved = orderRepository.save(order); + // 5) 이벤트 발행 + publishOrderEvents(saved, userId, items); + return OrderResult.from(saved); } @@ -205,6 +213,23 @@ public OrderResult placeOrderWithDiscount(Long userId, PlaceOrderWithDiscountReq Order order = Order.createWithDiscount(userId, orderItems, couponId, couponDiscount, pointDiscount); Order saved = orderRepository.save(order); + // 8. 이벤트 발행 + publishOrderEvents(saved, userId, request.items()); + return OrderResult.from(saved); } + + private void publishOrderEvents(Order order, Long userId, List items) { + int totalQuantity = items.stream().mapToInt(OrderItemRequest::quantity).sum(); + Long firstProductId = items.isEmpty() ? null : items.get(0).productId(); + + eventPublisher.publishEvent(OrderCompletedEvent.of( + order.getId(), + userId, + firstProductId, + totalQuantity, + order.getTotalPrice().amount() + )); + eventPublisher.publishEvent(UserActionEvent.of(userId, ActionType.ORDER, order.getId())); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java index bcd0a30a4..7ec321a09 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java @@ -1,11 +1,15 @@ package com.loopers.application.product; +import com.loopers.application.event.ProductViewedEvent; +import com.loopers.application.event.UserActionEvent; import com.loopers.domain.product.Product; import com.loopers.domain.product.ProductDomainService; import com.loopers.domain.product.ProductInfo; import com.loopers.domain.product.ProductSort; +import com.loopers.domain.useraction.ActionType; import com.loopers.infrastructure.cache.ProductLikeCountQueryService; import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.Pageable; @@ -22,7 +26,12 @@ public class ProductService { private final ProductDomainService productDomainService; private final ProductLikeCountQueryService likeCountQueryService; + private final ApplicationEventPublisher eventPublisher; + /** + * 상품 조회 (사용자 ID 없이). + * 조회 이벤트를 발행하지 않음. + */ @Transactional(readOnly = true) public ProductResult findById(Long id) { Product product = productDomainService.findById(id); @@ -30,6 +39,27 @@ public ProductResult findById(Long id) { return ProductResult.from(product, likeCount); } + /** + * 상품 조회 (사용자 ID 포함). + * 사용자 ID가 있으면 조회 이벤트 발행. + * + * @param id 상품 ID + * @param userId 사용자 ID (nullable) + * @return 상품 결과 + */ + @Transactional(readOnly = true) + public ProductResult findById(Long id, Long userId) { + Product product = productDomainService.findById(id); + long likeCount = likeCountQueryService.getLikeCount(id); + + if (userId != null) { + eventPublisher.publishEvent(ProductViewedEvent.of(userId, id)); + eventPublisher.publishEvent(UserActionEvent.of(userId, ActionType.VIEW, id)); + } + + return ProductResult.from(product, likeCount); + } + @Transactional(readOnly = true) public Page findAll(Long brandId, ProductSort sort, Pageable pageable) { if (sort == null) { diff --git a/apps/commerce-api/src/main/java/com/loopers/config/AsyncConfig.java b/apps/commerce-api/src/main/java/com/loopers/config/AsyncConfig.java new file mode 100644 index 000000000..28fd53eeb --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/config/AsyncConfig.java @@ -0,0 +1,32 @@ +package com.loopers.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +import java.util.concurrent.Executor; +import java.util.concurrent.ThreadPoolExecutor; + +/** + * 비동기 처리를 위한 Executor 설정. + * {@code @EnableAsync}는 CommerceApiApplication에 선언되어 있음. + */ +@Configuration +public class AsyncConfig { + + /** + * 이벤트 처리용 Executor. + * 부가 로직(로깅, 알림 등) 비동기 처리에 사용. + */ + @Bean(name = "eventExecutor") + public Executor eventExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(2); + executor.setMaxPoolSize(5); + executor.setQueueCapacity(100); + executor.setThreadNamePrefix("event-"); + executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); + executor.initialize(); + return executor; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/config/KafkaTopicConfig.java b/apps/commerce-api/src/main/java/com/loopers/config/KafkaTopicConfig.java new file mode 100644 index 000000000..00f97fde8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/config/KafkaTopicConfig.java @@ -0,0 +1,41 @@ +package com.loopers.config; + +import com.loopers.event.KafkaTopics; +import org.apache.kafka.clients.admin.NewTopic; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.kafka.config.TopicBuilder; + +/** + * Kafka 토픽 설정. + */ +@Configuration +public class KafkaTopicConfig { + + private static final int PARTITIONS = 3; + private static final int REPLICAS = 1; + + @Bean + public NewTopic catalogEventsTopic() { + return TopicBuilder.name(KafkaTopics.CATALOG_EVENTS) + .partitions(PARTITIONS) + .replicas(REPLICAS) + .build(); + } + + @Bean + public NewTopic orderEventsTopic() { + return TopicBuilder.name(KafkaTopics.ORDER_EVENTS) + .partitions(PARTITIONS) + .replicas(REPLICAS) + .build(); + } + + @Bean + public NewTopic couponIssueRequestsTopic() { + return TopicBuilder.name(KafkaTopics.COUPON_ISSUE_REQUESTS) + .partitions(PARTITIONS) + .replicas(REPLICAS) + .build(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/fcfs/CouponIssueRequest.java b/apps/commerce-api/src/main/java/com/loopers/domain/fcfs/CouponIssueRequest.java new file mode 100644 index 000000000..02b7fa513 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/fcfs/CouponIssueRequest.java @@ -0,0 +1,109 @@ +package com.loopers.domain.fcfs; + +import java.time.Instant; +import java.util.Objects; +import java.util.UUID; + +/** + * 쿠폰 발급 요청 도메인 모델. + */ +public class CouponIssueRequest { + + private final Long id; + private final String requestId; + private final Long couponId; + private final Long userId; + private CouponIssueRequestStatus status; + private String failureReason; + private final Instant createdAt; + private Instant updatedAt; + + private CouponIssueRequest( + Long id, + String requestId, + Long couponId, + Long userId, + CouponIssueRequestStatus status, + String failureReason, + Instant createdAt, + Instant updatedAt + ) { + this.id = id; + this.requestId = Objects.requireNonNull(requestId, "requestId must not be null"); + this.couponId = Objects.requireNonNull(couponId, "couponId must not be null"); + this.userId = Objects.requireNonNull(userId, "userId must not be null"); + this.status = Objects.requireNonNull(status, "status must not be null"); + this.failureReason = failureReason; + this.createdAt = createdAt; + this.updatedAt = updatedAt; + } + + public static CouponIssueRequest create(Long couponId, Long userId) { + return new CouponIssueRequest( + null, + UUID.randomUUID().toString(), + couponId, + userId, + CouponIssueRequestStatus.PENDING, + null, + Instant.now(), + Instant.now() + ); + } + + public static CouponIssueRequest reconstitute( + Long id, + String requestId, + Long couponId, + Long userId, + CouponIssueRequestStatus status, + String failureReason, + Instant createdAt, + Instant updatedAt + ) { + return new CouponIssueRequest(id, requestId, couponId, userId, status, failureReason, createdAt, updatedAt); + } + + public void markAsIssued() { + this.status = CouponIssueRequestStatus.ISSUED; + this.updatedAt = Instant.now(); + } + + public void markAsFailed(String reason) { + this.status = CouponIssueRequestStatus.FAILED; + this.failureReason = reason; + this.updatedAt = Instant.now(); + } + + public Long getId() { + return id; + } + + public String getRequestId() { + return requestId; + } + + public Long getCouponId() { + return couponId; + } + + public Long getUserId() { + return userId; + } + + public CouponIssueRequestStatus getStatus() { + return status; + } + + public String getFailureReason() { + return failureReason; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public Instant getUpdatedAt() { + return updatedAt; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/fcfs/CouponIssueRequestRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/fcfs/CouponIssueRequestRepository.java new file mode 100644 index 000000000..909fc6692 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/fcfs/CouponIssueRequestRepository.java @@ -0,0 +1,19 @@ +package com.loopers.domain.fcfs; + +import java.util.Optional; + +/** + * 쿠폰 발급 요청 Repository 인터페이스. + */ +public interface CouponIssueRequestRepository { + + CouponIssueRequest save(CouponIssueRequest request); + + Optional findByRequestId(String requestId); + + void updateStatus(String requestId, CouponIssueRequestStatus status, String failureReason); + + long countByStatus(CouponIssueRequestStatus status); + + long countByCouponIdAndStatus(Long couponId, CouponIssueRequestStatus status); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/fcfs/CouponIssueRequestStatus.java b/apps/commerce-api/src/main/java/com/loopers/domain/fcfs/CouponIssueRequestStatus.java new file mode 100644 index 000000000..0590520a0 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/fcfs/CouponIssueRequestStatus.java @@ -0,0 +1,10 @@ +package com.loopers.domain.fcfs; + +/** + * 쿠폰 발급 요청 상태. + */ +public enum CouponIssueRequestStatus { + PENDING, + ISSUED, + FAILED +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/fcfs/FcfsCoupon.java b/apps/commerce-api/src/main/java/com/loopers/domain/fcfs/FcfsCoupon.java new file mode 100644 index 000000000..867cf0f76 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/fcfs/FcfsCoupon.java @@ -0,0 +1,70 @@ +package com.loopers.domain.fcfs; + +import java.time.Instant; +import java.util.Objects; + +/** + * 선착순 쿠폰 도메인 모델. + */ +public class FcfsCoupon { + + private final Long id; + private final String name; + private final int totalQuantity; + private int issuedCount; + private final Instant createdAt; + + private FcfsCoupon(Long id, String name, int totalQuantity, int issuedCount, Instant createdAt) { + this.id = id; + this.name = Objects.requireNonNull(name, "name must not be null"); + this.totalQuantity = totalQuantity; + this.issuedCount = issuedCount; + this.createdAt = createdAt; + } + + public static FcfsCoupon create(String name, int totalQuantity) { + if (totalQuantity <= 0) { + throw new IllegalArgumentException("totalQuantity must be positive"); + } + return new FcfsCoupon(null, name, totalQuantity, 0, Instant.now()); + } + + public static FcfsCoupon reconstitute(Long id, String name, int totalQuantity, int issuedCount, Instant createdAt) { + return new FcfsCoupon(id, name, totalQuantity, issuedCount, createdAt); + } + + public boolean canIssue() { + return issuedCount < totalQuantity; + } + + public void incrementIssuedCount() { + if (!canIssue()) { + throw new IllegalStateException("Cannot issue coupon: quantity exhausted"); + } + this.issuedCount++; + } + + public int remainingQuantity() { + return totalQuantity - issuedCount; + } + + public Long getId() { + return id; + } + + public String getName() { + return name; + } + + public int getTotalQuantity() { + return totalQuantity; + } + + public int getIssuedCount() { + return issuedCount; + } + + public Instant getCreatedAt() { + return createdAt; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/fcfs/FcfsCouponRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/fcfs/FcfsCouponRepository.java new file mode 100644 index 000000000..300c98c08 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/fcfs/FcfsCouponRepository.java @@ -0,0 +1,19 @@ +package com.loopers.domain.fcfs; + +import java.util.Optional; + +/** + * 선착순 쿠폰 Repository 인터페이스. + */ +public interface FcfsCouponRepository { + + FcfsCoupon save(FcfsCoupon coupon); + + Optional findById(Long id); + + Optional findByIdWithLock(Long id); + + boolean existsById(Long id); + + void incrementIssuedCount(Long id); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/fcfs/FcfsIssuedCoupon.java b/apps/commerce-api/src/main/java/com/loopers/domain/fcfs/FcfsIssuedCoupon.java new file mode 100644 index 000000000..1bafc20de --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/fcfs/FcfsIssuedCoupon.java @@ -0,0 +1,46 @@ +package com.loopers.domain.fcfs; + +import java.time.Instant; +import java.util.Objects; + +/** + * 발급된 선착순 쿠폰 도메인 모델. + */ +public class FcfsIssuedCoupon { + + private final Long id; + private final Long couponId; + private final Long userId; + private final Instant issuedAt; + + private FcfsIssuedCoupon(Long id, Long couponId, Long userId, Instant issuedAt) { + this.id = id; + this.couponId = Objects.requireNonNull(couponId, "couponId must not be null"); + this.userId = Objects.requireNonNull(userId, "userId must not be null"); + this.issuedAt = issuedAt; + } + + public static FcfsIssuedCoupon create(Long couponId, Long userId) { + return new FcfsIssuedCoupon(null, couponId, userId, Instant.now()); + } + + public static FcfsIssuedCoupon reconstitute(Long id, Long couponId, Long userId, Instant issuedAt) { + return new FcfsIssuedCoupon(id, couponId, userId, issuedAt); + } + + public Long getId() { + return id; + } + + public Long getCouponId() { + return couponId; + } + + public Long getUserId() { + return userId; + } + + public Instant getIssuedAt() { + return issuedAt; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/fcfs/FcfsIssuedCouponRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/fcfs/FcfsIssuedCouponRepository.java new file mode 100644 index 000000000..2091c27c6 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/fcfs/FcfsIssuedCouponRepository.java @@ -0,0 +1,13 @@ +package com.loopers.domain.fcfs; + +/** + * 발급된 선착순 쿠폰 Repository 인터페이스. + */ +public interface FcfsIssuedCouponRepository { + + FcfsIssuedCoupon save(FcfsIssuedCoupon issuedCoupon); + + boolean existsByCouponIdAndUserId(Long couponId, Long userId); + + long countByCouponId(Long couponId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxEvent.java b/apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxEvent.java new file mode 100644 index 000000000..91f5c7c8d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxEvent.java @@ -0,0 +1,158 @@ +package com.loopers.domain.outbox; + +import java.time.Instant; +import java.util.Objects; +import java.util.UUID; + +/** + * Outbox 이벤트 도메인 모델. + * Transactional Outbox Pattern에서 이벤트를 저장하는 엔티티. + */ +public class OutboxEvent { + + private final Long id; + private final String eventId; + private final String eventType; + private final String aggregateType; + private final String aggregateId; + private final String topic; + private final String payload; + private final Instant createdAt; + private OutboxStatus status; + private int retryCount; + private Instant sentAt; + + private OutboxEvent( + Long id, + String eventId, + String eventType, + String aggregateType, + String aggregateId, + String topic, + String payload, + Instant createdAt, + OutboxStatus status, + int retryCount, + Instant sentAt + ) { + this.id = id; + this.eventId = Objects.requireNonNull(eventId, "eventId must not be null"); + this.eventType = Objects.requireNonNull(eventType, "eventType must not be null"); + this.aggregateType = Objects.requireNonNull(aggregateType, "aggregateType must not be null"); + this.aggregateId = Objects.requireNonNull(aggregateId, "aggregateId must not be null"); + this.topic = Objects.requireNonNull(topic, "topic must not be null"); + this.payload = Objects.requireNonNull(payload, "payload must not be null"); + this.createdAt = createdAt; + this.status = status; + this.retryCount = retryCount; + this.sentAt = sentAt; + } + + public static OutboxEvent create( + String eventType, + String aggregateType, + String aggregateId, + String topic, + String payload + ) { + return new OutboxEvent( + null, + UUID.randomUUID().toString(), + eventType, + aggregateType, + aggregateId, + topic, + payload, + Instant.now(), + OutboxStatus.PENDING, + 0, + null + ); + } + + public static OutboxEvent reconstitute( + Long id, + String eventId, + String eventType, + String aggregateType, + String aggregateId, + String topic, + String payload, + Instant createdAt, + OutboxStatus status, + int retryCount, + Instant sentAt + ) { + return new OutboxEvent( + id, + eventId, + eventType, + aggregateType, + aggregateId, + topic, + payload, + createdAt, + status, + retryCount, + sentAt + ); + } + + public void markAsSent() { + this.status = OutboxStatus.SENT; + this.sentAt = Instant.now(); + } + + public void markAsFailed() { + this.status = OutboxStatus.FAILED; + this.retryCount++; + } + + public void incrementRetry() { + this.retryCount++; + } + + public Long getId() { + return id; + } + + public String getEventId() { + return eventId; + } + + public String getEventType() { + return eventType; + } + + public String getAggregateType() { + return aggregateType; + } + + public String getAggregateId() { + return aggregateId; + } + + public String getTopic() { + return topic; + } + + public String getPayload() { + return payload; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public OutboxStatus getStatus() { + return status; + } + + public int getRetryCount() { + return retryCount; + } + + public Instant getSentAt() { + return sentAt; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxEventRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxEventRepository.java new file mode 100644 index 000000000..0fdd3e927 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxEventRepository.java @@ -0,0 +1,19 @@ +package com.loopers.domain.outbox; + +import java.util.List; + +/** + * OutboxEvent Repository 인터페이스. + */ +public interface OutboxEventRepository { + + OutboxEvent save(OutboxEvent event); + + List findPendingEvents(int limit); + + void updateStatus(Long id, OutboxStatus status); + + void markAsSent(Long id); + + void incrementRetryCount(Long id); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxStatus.java b/apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxStatus.java new file mode 100644 index 000000000..2b25d4543 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxStatus.java @@ -0,0 +1,10 @@ +package com.loopers.domain.outbox; + +/** + * Outbox 이벤트 상태. + */ +public enum OutboxStatus { + PENDING, + SENT, + FAILED +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/useraction/ActionType.java b/apps/commerce-api/src/main/java/com/loopers/domain/useraction/ActionType.java new file mode 100644 index 000000000..1ddfc459b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/useraction/ActionType.java @@ -0,0 +1,11 @@ +package com.loopers.domain.useraction; + +/** + * 사용자 행동 유형. + */ +public enum ActionType { + VIEW, + CLICK, + LIKE, + ORDER +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/useraction/UserActionLog.java b/apps/commerce-api/src/main/java/com/loopers/domain/useraction/UserActionLog.java new file mode 100644 index 000000000..e2e5b921b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/useraction/UserActionLog.java @@ -0,0 +1,66 @@ +package com.loopers.domain.useraction; + +import java.time.ZonedDateTime; +import java.util.Objects; + +/** + * 사용자 행동 로그 도메인 엔티티. + */ +public class UserActionLog { + + private final Long id; + private final Long userId; + private final ActionType actionType; + private final Long targetId; + private final ZonedDateTime occurredAt; + private final ZonedDateTime createdAt; + + private UserActionLog(Long id, Long userId, ActionType actionType, Long targetId, + ZonedDateTime occurredAt, ZonedDateTime createdAt) { + this.id = id; + this.userId = Objects.requireNonNull(userId, "userId must not be null"); + this.actionType = Objects.requireNonNull(actionType, "actionType must not be null"); + this.targetId = Objects.requireNonNull(targetId, "targetId must not be null"); + this.occurredAt = Objects.requireNonNull(occurredAt, "occurredAt must not be null"); + this.createdAt = createdAt != null ? createdAt : ZonedDateTime.now(); + } + + /** + * 새 행동 로그 생성. + */ + public static UserActionLog create(Long userId, ActionType actionType, Long targetId, ZonedDateTime occurredAt) { + return new UserActionLog(null, userId, actionType, targetId, occurredAt, ZonedDateTime.now()); + } + + /** + * 영속화된 로그 재구성. + */ + public static UserActionLog reconstitute(Long id, Long userId, ActionType actionType, + Long targetId, ZonedDateTime occurredAt, ZonedDateTime createdAt) { + return new UserActionLog(id, userId, actionType, targetId, occurredAt, createdAt); + } + + public Long getId() { + return id; + } + + public Long getUserId() { + return userId; + } + + public ActionType getActionType() { + return actionType; + } + + public Long getTargetId() { + return targetId; + } + + public ZonedDateTime getOccurredAt() { + return occurredAt; + } + + public ZonedDateTime getCreatedAt() { + return createdAt; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/useraction/UserActionLogRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/useraction/UserActionLogRepository.java new file mode 100644 index 000000000..52fc1d7f4 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/useraction/UserActionLogRepository.java @@ -0,0 +1,12 @@ +package com.loopers.domain.useraction; + +/** + * 사용자 행동 로그 Repository 인터페이스. + */ +public interface UserActionLogRepository { + + /** + * 행동 로그 저장. + */ + UserActionLog save(UserActionLog userActionLog); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxPublisher.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxPublisher.java new file mode 100644 index 000000000..c3257721f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxPublisher.java @@ -0,0 +1,86 @@ +package com.loopers.infrastructure.outbox; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.domain.outbox.OutboxEvent; +import com.loopers.domain.outbox.OutboxEventRepository; +import com.loopers.event.EventEnvelope; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.kafka.clients.producer.ProducerRecord; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +/** + * Outbox 테이블의 PENDING 이벤트를 Kafka로 발행하는 Publisher. + * 주기적으로 폴링하여 이벤트를 발행하고 상태를 갱신. + * KafkaTemplate 빈이 있을 때만 활성화. + */ +@Slf4j +@Component +@RequiredArgsConstructor +@ConditionalOnBean(KafkaTemplate.class) +public class OutboxPublisher { + + private static final int BATCH_SIZE = 100; + + private final OutboxEventRepository outboxEventRepository; + private final KafkaTemplate kafkaTemplate; + private final ObjectMapper objectMapper; + + @Scheduled(fixedDelay = 1000) + @Transactional + public void publishPendingEvents() { + List pendingEvents = outboxEventRepository.findPendingEvents(BATCH_SIZE); + + if (pendingEvents.isEmpty()) { + return; + } + + log.debug("Publishing {} pending outbox events", pendingEvents.size()); + + for (OutboxEvent event : pendingEvents) { + publishEvent(event); + } + } + + private void publishEvent(OutboxEvent event) { + try { + EventEnvelope envelope = new EventEnvelope( + event.getEventId(), + event.getEventType(), + event.getAggregateType(), + event.getAggregateId(), + event.getCreatedAt(), + event.getPayload() + ); + + ProducerRecord record = new ProducerRecord<>( + event.getTopic(), + event.getAggregateId(), + envelope + ); + + kafkaTemplate.send(record) + .whenComplete((result, ex) -> { + if (ex == null) { + outboxEventRepository.markAsSent(event.getId()); + log.debug("Event published successfully: eventId={}, topic={}", + event.getEventId(), event.getTopic()); + } else { + outboxEventRepository.incrementRetryCount(event.getId()); + log.error("Failed to publish event: eventId={}, topic={}, error={}", + event.getEventId(), event.getTopic(), ex.getMessage()); + } + }); + + } catch (Exception e) { + outboxEventRepository.incrementRetryCount(event.getId()); + log.error("Error creating event envelope: eventId={}", event.getEventId(), e); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/fcfs/CouponIssueRequestJpaEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/fcfs/CouponIssueRequestJpaEntity.java new file mode 100644 index 000000000..24f1d1748 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/fcfs/CouponIssueRequestJpaEntity.java @@ -0,0 +1,122 @@ +package com.loopers.infrastructure.persistence.jpa.fcfs; + +import com.loopers.domain.fcfs.CouponIssueRequestStatus; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.PrePersist; +import jakarta.persistence.PreUpdate; +import jakarta.persistence.Table; + +import java.time.Instant; + +/** + * 쿠폰 발급 요청 JPA 엔티티. + */ +@Entity +@Table( + name = "coupon_issue_request", + indexes = { + @Index(name = "idx_coupon_issue_request_request_id", columnList = "request_id", unique = true), + @Index(name = "idx_coupon_issue_request_coupon_status", columnList = "coupon_id, status") + } +) +public class CouponIssueRequestJpaEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "request_id", nullable = false, unique = true, length = 36) + private String requestId; + + @Column(name = "coupon_id", nullable = false) + private Long couponId; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 20) + private CouponIssueRequestStatus status; + + @Column(name = "failure_reason", length = 500) + private String failureReason; + + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + @Column(name = "updated_at", nullable = false) + private Instant updatedAt; + + protected CouponIssueRequestJpaEntity() {} + + public CouponIssueRequestJpaEntity( + String requestId, + Long couponId, + Long userId, + CouponIssueRequestStatus status + ) { + this.requestId = requestId; + this.couponId = couponId; + this.userId = userId; + this.status = status; + } + + @PrePersist + private void prePersist() { + Instant now = Instant.now(); + this.createdAt = now; + this.updatedAt = now; + } + + @PreUpdate + private void preUpdate() { + this.updatedAt = Instant.now(); + } + + public Long getId() { + return id; + } + + public String getRequestId() { + return requestId; + } + + public Long getCouponId() { + return couponId; + } + + public Long getUserId() { + return userId; + } + + public CouponIssueRequestStatus getStatus() { + return status; + } + + public void setStatus(CouponIssueRequestStatus status) { + this.status = status; + } + + public String getFailureReason() { + return failureReason; + } + + public void setFailureReason(String failureReason) { + this.failureReason = failureReason; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public Instant getUpdatedAt() { + return updatedAt; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/fcfs/CouponIssueRequestJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/fcfs/CouponIssueRequestJpaRepository.java new file mode 100644 index 000000000..99820be6b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/fcfs/CouponIssueRequestJpaRepository.java @@ -0,0 +1,29 @@ +package com.loopers.infrastructure.persistence.jpa.fcfs; + +import com.loopers.domain.fcfs.CouponIssueRequestStatus; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.Optional; + +/** + * 쿠폰 발급 요청 JPA Repository. + */ +public interface CouponIssueRequestJpaRepository extends JpaRepository { + + Optional findByRequestId(String requestId); + + @Modifying + @Query("UPDATE CouponIssueRequestJpaEntity r SET r.status = :status, r.failureReason = :failureReason WHERE r.requestId = :requestId") + void updateStatus( + @Param("requestId") String requestId, + @Param("status") CouponIssueRequestStatus status, + @Param("failureReason") String failureReason + ); + + long countByStatus(CouponIssueRequestStatus status); + + long countByCouponIdAndStatus(Long couponId, CouponIssueRequestStatus status); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/fcfs/CouponIssueRequestMapper.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/fcfs/CouponIssueRequestMapper.java new file mode 100644 index 000000000..7b4d6ef7d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/fcfs/CouponIssueRequestMapper.java @@ -0,0 +1,39 @@ +package com.loopers.infrastructure.persistence.jpa.fcfs; + +import com.loopers.domain.fcfs.CouponIssueRequest; + +/** + * CouponIssueRequest 도메인 객체와 JPA 엔티티 간 변환. + */ +public class CouponIssueRequestMapper { + + private CouponIssueRequestMapper() {} + + public static CouponIssueRequest toDomain(CouponIssueRequestJpaEntity entity) { + if (entity == null) { + return null; + } + return CouponIssueRequest.reconstitute( + entity.getId(), + entity.getRequestId(), + entity.getCouponId(), + entity.getUserId(), + entity.getStatus(), + entity.getFailureReason(), + entity.getCreatedAt(), + entity.getUpdatedAt() + ); + } + + public static CouponIssueRequestJpaEntity toJpaEntity(CouponIssueRequest domain) { + if (domain == null) { + return null; + } + return new CouponIssueRequestJpaEntity( + domain.getRequestId(), + domain.getCouponId(), + domain.getUserId(), + domain.getStatus() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/fcfs/CouponIssueRequestRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/fcfs/CouponIssueRequestRepositoryImpl.java new file mode 100644 index 000000000..6d82a1d9a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/fcfs/CouponIssueRequestRepositoryImpl.java @@ -0,0 +1,47 @@ +package com.loopers.infrastructure.persistence.jpa.fcfs; + +import com.loopers.domain.fcfs.CouponIssueRequest; +import com.loopers.domain.fcfs.CouponIssueRequestRepository; +import com.loopers.domain.fcfs.CouponIssueRequestStatus; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +/** + * CouponIssueRequestRepository 구현체. + */ +@Repository +@RequiredArgsConstructor +public class CouponIssueRequestRepositoryImpl implements CouponIssueRequestRepository { + + private final CouponIssueRequestJpaRepository jpaRepository; + + @Override + public CouponIssueRequest save(CouponIssueRequest request) { + CouponIssueRequestJpaEntity entity = CouponIssueRequestMapper.toJpaEntity(request); + CouponIssueRequestJpaEntity saved = jpaRepository.save(entity); + return CouponIssueRequestMapper.toDomain(saved); + } + + @Override + public Optional findByRequestId(String requestId) { + return jpaRepository.findByRequestId(requestId) + .map(CouponIssueRequestMapper::toDomain); + } + + @Override + public void updateStatus(String requestId, CouponIssueRequestStatus status, String failureReason) { + jpaRepository.updateStatus(requestId, status, failureReason); + } + + @Override + public long countByStatus(CouponIssueRequestStatus status) { + return jpaRepository.countByStatus(status); + } + + @Override + public long countByCouponIdAndStatus(Long couponId, CouponIssueRequestStatus status) { + return jpaRepository.countByCouponIdAndStatus(couponId, status); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/fcfs/FcfsCouponJpaEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/fcfs/FcfsCouponJpaEntity.java new file mode 100644 index 000000000..d0103944d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/fcfs/FcfsCouponJpaEntity.java @@ -0,0 +1,72 @@ +package com.loopers.infrastructure.persistence.jpa.fcfs; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.PrePersist; +import jakarta.persistence.Table; + +import java.time.Instant; + +/** + * 선착순 쿠폰 JPA 엔티티. + */ +@Entity +@Table(name = "fcfs_coupon") +public class FcfsCouponJpaEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "name", nullable = false, length = 200) + private String name; + + @Column(name = "total_quantity", nullable = false) + private int totalQuantity; + + @Column(name = "issued_count", nullable = false) + private int issuedCount; + + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + protected FcfsCouponJpaEntity() {} + + public FcfsCouponJpaEntity(String name, int totalQuantity, int issuedCount) { + this.name = name; + this.totalQuantity = totalQuantity; + this.issuedCount = issuedCount; + } + + @PrePersist + private void prePersist() { + this.createdAt = Instant.now(); + } + + public Long getId() { + return id; + } + + public String getName() { + return name; + } + + public int getTotalQuantity() { + return totalQuantity; + } + + public int getIssuedCount() { + return issuedCount; + } + + public void setIssuedCount(int issuedCount) { + this.issuedCount = issuedCount; + } + + public Instant getCreatedAt() { + return createdAt; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/fcfs/FcfsCouponJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/fcfs/FcfsCouponJpaRepository.java new file mode 100644 index 000000000..f18ad327a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/fcfs/FcfsCouponJpaRepository.java @@ -0,0 +1,24 @@ +package com.loopers.infrastructure.persistence.jpa.fcfs; + +import jakarta.persistence.LockModeType; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.Optional; + +/** + * 선착순 쿠폰 JPA Repository. + */ +public interface FcfsCouponJpaRepository extends JpaRepository { + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT c FROM FcfsCouponJpaEntity c WHERE c.id = :id") + Optional findByIdWithLock(@Param("id") Long id); + + @Modifying + @Query("UPDATE FcfsCouponJpaEntity c SET c.issuedCount = c.issuedCount + 1 WHERE c.id = :id") + void incrementIssuedCount(@Param("id") Long id); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/fcfs/FcfsCouponMapper.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/fcfs/FcfsCouponMapper.java new file mode 100644 index 000000000..e594ae7c1 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/fcfs/FcfsCouponMapper.java @@ -0,0 +1,35 @@ +package com.loopers.infrastructure.persistence.jpa.fcfs; + +import com.loopers.domain.fcfs.FcfsCoupon; + +/** + * FcfsCoupon 도메인 객체와 JPA 엔티티 간 변환. + */ +public class FcfsCouponMapper { + + private FcfsCouponMapper() {} + + public static FcfsCoupon toDomain(FcfsCouponJpaEntity entity) { + if (entity == null) { + return null; + } + return FcfsCoupon.reconstitute( + entity.getId(), + entity.getName(), + entity.getTotalQuantity(), + entity.getIssuedCount(), + entity.getCreatedAt() + ); + } + + public static FcfsCouponJpaEntity toJpaEntity(FcfsCoupon domain) { + if (domain == null) { + return null; + } + return new FcfsCouponJpaEntity( + domain.getName(), + domain.getTotalQuantity(), + domain.getIssuedCount() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/fcfs/FcfsCouponRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/fcfs/FcfsCouponRepositoryImpl.java new file mode 100644 index 000000000..199acb8ce --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/fcfs/FcfsCouponRepositoryImpl.java @@ -0,0 +1,47 @@ +package com.loopers.infrastructure.persistence.jpa.fcfs; + +import com.loopers.domain.fcfs.FcfsCoupon; +import com.loopers.domain.fcfs.FcfsCouponRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +/** + * FcfsCouponRepository 구현체. + */ +@Repository +@RequiredArgsConstructor +public class FcfsCouponRepositoryImpl implements FcfsCouponRepository { + + private final FcfsCouponJpaRepository jpaRepository; + + @Override + public FcfsCoupon save(FcfsCoupon coupon) { + FcfsCouponJpaEntity entity = FcfsCouponMapper.toJpaEntity(coupon); + FcfsCouponJpaEntity saved = jpaRepository.save(entity); + return FcfsCouponMapper.toDomain(saved); + } + + @Override + public Optional findById(Long id) { + return jpaRepository.findById(id) + .map(FcfsCouponMapper::toDomain); + } + + @Override + public Optional findByIdWithLock(Long id) { + return jpaRepository.findByIdWithLock(id) + .map(FcfsCouponMapper::toDomain); + } + + @Override + public boolean existsById(Long id) { + return jpaRepository.existsById(id); + } + + @Override + public void incrementIssuedCount(Long id) { + jpaRepository.incrementIssuedCount(id); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/fcfs/FcfsIssuedCouponJpaEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/fcfs/FcfsIssuedCouponJpaEntity.java new file mode 100644 index 000000000..83a90b772 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/fcfs/FcfsIssuedCouponJpaEntity.java @@ -0,0 +1,70 @@ +package com.loopers.infrastructure.persistence.jpa.fcfs; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.PrePersist; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; + +import java.time.Instant; + +/** + * 발급된 선착순 쿠폰 JPA 엔티티. + */ +@Entity +@Table( + name = "fcfs_issued_coupon", + uniqueConstraints = { + @UniqueConstraint(columnNames = {"coupon_id", "user_id"}) + }, + indexes = { + @Index(name = "idx_fcfs_issued_coupon_coupon_id", columnList = "coupon_id") + } +) +public class FcfsIssuedCouponJpaEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "coupon_id", nullable = false) + private Long couponId; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Column(name = "issued_at", nullable = false, updatable = false) + private Instant issuedAt; + + protected FcfsIssuedCouponJpaEntity() {} + + public FcfsIssuedCouponJpaEntity(Long couponId, Long userId) { + this.couponId = couponId; + this.userId = userId; + } + + @PrePersist + private void prePersist() { + this.issuedAt = Instant.now(); + } + + public Long getId() { + return id; + } + + public Long getCouponId() { + return couponId; + } + + public Long getUserId() { + return userId; + } + + public Instant getIssuedAt() { + return issuedAt; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/fcfs/FcfsIssuedCouponJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/fcfs/FcfsIssuedCouponJpaRepository.java new file mode 100644 index 000000000..efa41de90 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/fcfs/FcfsIssuedCouponJpaRepository.java @@ -0,0 +1,13 @@ +package com.loopers.infrastructure.persistence.jpa.fcfs; + +import org.springframework.data.jpa.repository.JpaRepository; + +/** + * 발급된 선착순 쿠폰 JPA Repository. + */ +public interface FcfsIssuedCouponJpaRepository extends JpaRepository { + + boolean existsByCouponIdAndUserId(Long couponId, Long userId); + + long countByCouponId(Long couponId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/fcfs/FcfsIssuedCouponMapper.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/fcfs/FcfsIssuedCouponMapper.java new file mode 100644 index 000000000..3a2d216fc --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/fcfs/FcfsIssuedCouponMapper.java @@ -0,0 +1,33 @@ +package com.loopers.infrastructure.persistence.jpa.fcfs; + +import com.loopers.domain.fcfs.FcfsIssuedCoupon; + +/** + * FcfsIssuedCoupon 도메인 객체와 JPA 엔티티 간 변환. + */ +public class FcfsIssuedCouponMapper { + + private FcfsIssuedCouponMapper() {} + + public static FcfsIssuedCoupon toDomain(FcfsIssuedCouponJpaEntity entity) { + if (entity == null) { + return null; + } + return FcfsIssuedCoupon.reconstitute( + entity.getId(), + entity.getCouponId(), + entity.getUserId(), + entity.getIssuedAt() + ); + } + + public static FcfsIssuedCouponJpaEntity toJpaEntity(FcfsIssuedCoupon domain) { + if (domain == null) { + return null; + } + return new FcfsIssuedCouponJpaEntity( + domain.getCouponId(), + domain.getUserId() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/fcfs/FcfsIssuedCouponRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/fcfs/FcfsIssuedCouponRepositoryImpl.java new file mode 100644 index 000000000..5d42f4103 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/fcfs/FcfsIssuedCouponRepositoryImpl.java @@ -0,0 +1,33 @@ +package com.loopers.infrastructure.persistence.jpa.fcfs; + +import com.loopers.domain.fcfs.FcfsIssuedCoupon; +import com.loopers.domain.fcfs.FcfsIssuedCouponRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +/** + * FcfsIssuedCouponRepository 구현체. + */ +@Repository +@RequiredArgsConstructor +public class FcfsIssuedCouponRepositoryImpl implements FcfsIssuedCouponRepository { + + private final FcfsIssuedCouponJpaRepository jpaRepository; + + @Override + public FcfsIssuedCoupon save(FcfsIssuedCoupon issuedCoupon) { + FcfsIssuedCouponJpaEntity entity = FcfsIssuedCouponMapper.toJpaEntity(issuedCoupon); + FcfsIssuedCouponJpaEntity saved = jpaRepository.save(entity); + return FcfsIssuedCouponMapper.toDomain(saved); + } + + @Override + public boolean existsByCouponIdAndUserId(Long couponId, Long userId) { + return jpaRepository.existsByCouponIdAndUserId(couponId, userId); + } + + @Override + public long countByCouponId(Long couponId) { + return jpaRepository.countByCouponId(couponId); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/outbox/OutboxEventJpaEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/outbox/OutboxEventJpaEntity.java new file mode 100644 index 000000000..2d94eccaf --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/outbox/OutboxEventJpaEntity.java @@ -0,0 +1,146 @@ +package com.loopers.infrastructure.persistence.jpa.outbox; + +import com.loopers.domain.outbox.OutboxStatus; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.PrePersist; +import jakarta.persistence.Table; + +import java.time.Instant; + +/** + * Outbox 이벤트 JPA 엔티티. + */ +@Entity +@Table( + name = "outbox_event", + indexes = { + @Index(name = "idx_outbox_status_created", columnList = "status, created_at") + } +) +public class OutboxEventJpaEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "event_id", nullable = false, unique = true, length = 36) + private String eventId; + + @Column(name = "event_type", nullable = false, length = 50) + private String eventType; + + @Column(name = "aggregate_type", nullable = false, length = 50) + private String aggregateType; + + @Column(name = "aggregate_id", nullable = false, length = 100) + private String aggregateId; + + @Column(name = "topic", nullable = false, length = 100) + private String topic; + + @Column(name = "payload", nullable = false, columnDefinition = "TEXT") + private String payload; + + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 20) + private OutboxStatus status; + + @Column(name = "retry_count", nullable = false) + private int retryCount; + + @Column(name = "sent_at") + private Instant sentAt; + + protected OutboxEventJpaEntity() {} + + public OutboxEventJpaEntity( + String eventId, + String eventType, + String aggregateType, + String aggregateId, + String topic, + String payload, + OutboxStatus status, + int retryCount + ) { + this.eventId = eventId; + this.eventType = eventType; + this.aggregateType = aggregateType; + this.aggregateId = aggregateId; + this.topic = topic; + this.payload = payload; + this.status = status; + this.retryCount = retryCount; + } + + @PrePersist + private void prePersist() { + this.createdAt = Instant.now(); + } + + public Long getId() { + return id; + } + + public String getEventId() { + return eventId; + } + + public String getEventType() { + return eventType; + } + + public String getAggregateType() { + return aggregateType; + } + + public String getAggregateId() { + return aggregateId; + } + + public String getTopic() { + return topic; + } + + public String getPayload() { + return payload; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public OutboxStatus getStatus() { + return status; + } + + public int getRetryCount() { + return retryCount; + } + + public Instant getSentAt() { + return sentAt; + } + + public void setStatus(OutboxStatus status) { + this.status = status; + } + + public void setSentAt(Instant sentAt) { + this.sentAt = sentAt; + } + + public void setRetryCount(int retryCount) { + this.retryCount = retryCount; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/outbox/OutboxEventJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/outbox/OutboxEventJpaRepository.java new file mode 100644 index 000000000..fc5e2e138 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/outbox/OutboxEventJpaRepository.java @@ -0,0 +1,34 @@ +package com.loopers.infrastructure.persistence.jpa.outbox; + +import com.loopers.domain.outbox.OutboxStatus; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.Instant; +import java.util.List; + +/** + * OutboxEvent JPA Repository. + */ +public interface OutboxEventJpaRepository extends JpaRepository { + + @Query("SELECT o FROM OutboxEventJpaEntity o WHERE o.status = :status ORDER BY o.createdAt ASC LIMIT :limit") + List findByStatusOrderByCreatedAtAsc( + @Param("status") OutboxStatus status, + @Param("limit") int limit + ); + + @Modifying + @Query("UPDATE OutboxEventJpaEntity o SET o.status = :status WHERE o.id = :id") + void updateStatus(@Param("id") Long id, @Param("status") OutboxStatus status); + + @Modifying + @Query("UPDATE OutboxEventJpaEntity o SET o.status = 'SENT', o.sentAt = :sentAt WHERE o.id = :id") + void markAsSent(@Param("id") Long id, @Param("sentAt") Instant sentAt); + + @Modifying + @Query("UPDATE OutboxEventJpaEntity o SET o.retryCount = o.retryCount + 1 WHERE o.id = :id") + void incrementRetryCount(@Param("id") Long id); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/outbox/OutboxEventMapper.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/outbox/OutboxEventMapper.java new file mode 100644 index 000000000..62ba23c90 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/outbox/OutboxEventMapper.java @@ -0,0 +1,46 @@ +package com.loopers.infrastructure.persistence.jpa.outbox; + +import com.loopers.domain.outbox.OutboxEvent; + +/** + * OutboxEvent 도메인 객체와 JPA 엔티티 간 변환을 담당. + */ +public class OutboxEventMapper { + + private OutboxEventMapper() {} + + public static OutboxEvent toDomain(OutboxEventJpaEntity entity) { + if (entity == null) { + return null; + } + return OutboxEvent.reconstitute( + entity.getId(), + entity.getEventId(), + entity.getEventType(), + entity.getAggregateType(), + entity.getAggregateId(), + entity.getTopic(), + entity.getPayload(), + entity.getCreatedAt(), + entity.getStatus(), + entity.getRetryCount(), + entity.getSentAt() + ); + } + + public static OutboxEventJpaEntity toJpaEntity(OutboxEvent domain) { + if (domain == null) { + return null; + } + return new OutboxEventJpaEntity( + domain.getEventId(), + domain.getEventType(), + domain.getAggregateType(), + domain.getAggregateId(), + domain.getTopic(), + domain.getPayload(), + domain.getStatus(), + domain.getRetryCount() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/outbox/OutboxEventRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/outbox/OutboxEventRepositoryImpl.java new file mode 100644 index 000000000..f04251c9f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/outbox/OutboxEventRepositoryImpl.java @@ -0,0 +1,50 @@ +package com.loopers.infrastructure.persistence.jpa.outbox; + +import com.loopers.domain.outbox.OutboxEvent; +import com.loopers.domain.outbox.OutboxEventRepository; +import com.loopers.domain.outbox.OutboxStatus; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.time.Instant; +import java.util.List; + +/** + * OutboxEventRepository 구현체. + */ +@Repository +@RequiredArgsConstructor +public class OutboxEventRepositoryImpl implements OutboxEventRepository { + + private final OutboxEventJpaRepository jpaRepository; + + @Override + public OutboxEvent save(OutboxEvent event) { + OutboxEventJpaEntity entity = OutboxEventMapper.toJpaEntity(event); + OutboxEventJpaEntity saved = jpaRepository.save(entity); + return OutboxEventMapper.toDomain(saved); + } + + @Override + public List findPendingEvents(int limit) { + return jpaRepository.findByStatusOrderByCreatedAtAsc(OutboxStatus.PENDING, limit) + .stream() + .map(OutboxEventMapper::toDomain) + .toList(); + } + + @Override + public void updateStatus(Long id, OutboxStatus status) { + jpaRepository.updateStatus(id, status); + } + + @Override + public void markAsSent(Long id) { + jpaRepository.markAsSent(id, Instant.now()); + } + + @Override + public void incrementRetryCount(Long id) { + jpaRepository.incrementRetryCount(id); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/useraction/UserActionLogJpaEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/useraction/UserActionLogJpaEntity.java new file mode 100644 index 000000000..170aa0fa1 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/useraction/UserActionLogJpaEntity.java @@ -0,0 +1,88 @@ +package com.loopers.infrastructure.persistence.jpa.useraction; + +import com.loopers.domain.useraction.ActionType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.PrePersist; +import jakarta.persistence.Table; + +import java.time.ZonedDateTime; + +/** + * 사용자 행동 로그 JPA 엔티티. + */ +@Entity +@Table( + name = "user_action_logs", + indexes = { + @Index(name = "idx_user_action_logs_user_id", columnList = "user_id"), + @Index(name = "idx_user_action_logs_action_type", columnList = "action_type"), + @Index(name = "idx_user_action_logs_occurred_at", columnList = "occurred_at") + } +) +public class UserActionLogJpaEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Enumerated(EnumType.STRING) + @Column(name = "action_type", nullable = false, length = 20) + private ActionType actionType; + + @Column(name = "target_id", nullable = false) + private Long targetId; + + @Column(name = "occurred_at", nullable = false) + private ZonedDateTime occurredAt; + + @Column(name = "created_at", nullable = false, updatable = false) + private ZonedDateTime createdAt; + + protected UserActionLogJpaEntity() {} + + public UserActionLogJpaEntity(Long userId, ActionType actionType, Long targetId, ZonedDateTime occurredAt) { + this.userId = userId; + this.actionType = actionType; + this.targetId = targetId; + this.occurredAt = occurredAt; + } + + @PrePersist + private void prePersist() { + this.createdAt = ZonedDateTime.now(); + } + + public Long getId() { + return id; + } + + public Long getUserId() { + return userId; + } + + public ActionType getActionType() { + return actionType; + } + + public Long getTargetId() { + return targetId; + } + + public ZonedDateTime getOccurredAt() { + return occurredAt; + } + + public ZonedDateTime getCreatedAt() { + return createdAt; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/useraction/UserActionLogJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/useraction/UserActionLogJpaRepository.java new file mode 100644 index 000000000..3419d0ab2 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/useraction/UserActionLogJpaRepository.java @@ -0,0 +1,9 @@ +package com.loopers.infrastructure.persistence.jpa.useraction; + +import org.springframework.data.jpa.repository.JpaRepository; + +/** + * 사용자 행동 로그 JPA Repository. + */ +public interface UserActionLogJpaRepository extends JpaRepository { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/useraction/UserActionLogMapper.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/useraction/UserActionLogMapper.java new file mode 100644 index 000000000..ab8e691cb --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/useraction/UserActionLogMapper.java @@ -0,0 +1,43 @@ +package com.loopers.infrastructure.persistence.jpa.useraction; + +import com.loopers.domain.useraction.UserActionLog; + +/** + * UserActionLog 도메인 객체와 JPA 엔티티 간 변환을 담당. + */ +public class UserActionLogMapper { + + private UserActionLogMapper() {} + + /** + * JPA 엔티티를 도메인 객체로 변환. + */ + public static UserActionLog toDomain(UserActionLogJpaEntity entity) { + if (entity == null) { + return null; + } + return UserActionLog.reconstitute( + entity.getId(), + entity.getUserId(), + entity.getActionType(), + entity.getTargetId(), + entity.getOccurredAt(), + entity.getCreatedAt() + ); + } + + /** + * 도메인 객체를 JPA 엔티티로 변환 (신규 저장용). + */ + public static UserActionLogJpaEntity toJpaEntity(UserActionLog domain) { + if (domain == null) { + return null; + } + return new UserActionLogJpaEntity( + domain.getUserId(), + domain.getActionType(), + domain.getTargetId(), + domain.getOccurredAt() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/useraction/UserActionLogRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/useraction/UserActionLogRepositoryImpl.java new file mode 100644 index 000000000..de749c1ea --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/useraction/UserActionLogRepositoryImpl.java @@ -0,0 +1,23 @@ +package com.loopers.infrastructure.persistence.jpa.useraction; + +import com.loopers.domain.useraction.UserActionLog; +import com.loopers.domain.useraction.UserActionLogRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +/** + * UserActionLogRepository 구현체. + */ +@Repository +@RequiredArgsConstructor +public class UserActionLogRepositoryImpl implements UserActionLogRepository { + + private final UserActionLogJpaRepository jpaRepository; + + @Override + public UserActionLog save(UserActionLog userActionLog) { + UserActionLogJpaEntity entity = UserActionLogMapper.toJpaEntity(userActionLog); + UserActionLogJpaEntity saved = jpaRepository.save(entity); + return UserActionLogMapper.toDomain(saved); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/fcfs/FcfsCouponV1Api.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/fcfs/FcfsCouponV1Api.java new file mode 100644 index 000000000..1e309c55d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/fcfs/FcfsCouponV1Api.java @@ -0,0 +1,65 @@ +package com.loopers.interfaces.api.fcfs; + +import com.loopers.application.fcfs.CouponIssueApplicationService; +import com.loopers.application.fcfs.CouponIssueRequestResult; +import com.loopers.application.fcfs.CouponIssueResultResponse; +import com.loopers.interfaces.api.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * 선착순 쿠폰 API. + */ +@RestController +@RequestMapping("/api/v1/fcfs-coupons") +@RequiredArgsConstructor +public class FcfsCouponV1Api { + + private final CouponIssueApplicationService couponIssueApplicationService; + + /** + * 쿠폰 발급 요청. + * POST /api/v1/fcfs-coupons/{couponId}/issue + */ + @PostMapping("/{couponId}/issue") + public ResponseEntity> requestIssue( + @PathVariable Long couponId, + @RequestBody FcfsCouponV1Dto.IssueRequest request + ) { + CouponIssueRequestResult result = couponIssueApplicationService.requestIssue(couponId, request.userId()); + + FcfsCouponV1Dto.IssueResponse response = new FcfsCouponV1Dto.IssueResponse( + result.requestId(), + result.status() + ); + + return ResponseEntity.status(HttpStatus.ACCEPTED) + .body(ApiResponse.success(response)); + } + + /** + * 발급 결과 조회. + * GET /api/v1/fcfs-coupons/issue-result/{requestId} + */ + @GetMapping("/issue-result/{requestId}") + public ResponseEntity> getIssueResult( + @PathVariable String requestId + ) { + CouponIssueResultResponse result = couponIssueApplicationService.getIssueResult(requestId); + + FcfsCouponV1Dto.IssueResultResponse response = new FcfsCouponV1Dto.IssueResultResponse( + result.requestId(), + result.status(), + result.failureReason() + ); + + return ResponseEntity.ok(ApiResponse.success(response)); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/fcfs/FcfsCouponV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/fcfs/FcfsCouponV1Dto.java new file mode 100644 index 000000000..edf09673a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/fcfs/FcfsCouponV1Dto.java @@ -0,0 +1,26 @@ +package com.loopers.interfaces.api.fcfs; + +import com.loopers.domain.fcfs.CouponIssueRequestStatus; + +/** + * 선착순 쿠폰 API DTO. + */ +public class FcfsCouponV1Dto { + + private FcfsCouponV1Dto() {} + + public record IssueRequest( + Long userId + ) {} + + public record IssueResponse( + String requestId, + CouponIssueRequestStatus status + ) {} + + public record IssueResultResponse( + String requestId, + CouponIssueRequestStatus status, + String failureReason + ) {} +} diff --git a/apps/commerce-api/src/main/resources/application.yml b/apps/commerce-api/src/main/resources/application.yml index 10ae07c4f..e60fb36e4 100644 --- a/apps/commerce-api/src/main/resources/application.yml +++ b/apps/commerce-api/src/main/resources/application.yml @@ -21,6 +21,7 @@ spring: import: - jpa.yml - redis.yml + - kafka.yml - pg.yml - resilience4j.yml - logging.yml diff --git a/apps/commerce-api/src/test/java/com/loopers/application/event/EventListenerIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/event/EventListenerIntegrationTest.java new file mode 100644 index 000000000..c34546f8a --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/event/EventListenerIntegrationTest.java @@ -0,0 +1,138 @@ +package com.loopers.application.event; + +import com.loopers.application.like.LikeApplicationService; +import com.loopers.application.like.LikeResult; +import com.loopers.config.TestRedisConfiguration; +import com.loopers.domain.common.Money; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; +import com.loopers.domain.product.Stock; +import com.loopers.infrastructure.cache.LikeCountCacheService; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.data.redis.core.RedisTemplate; + +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +@SpringBootTest +@Import(TestRedisConfiguration.class) +@DisplayName("이벤트 리스너 통합 테스트") +class EventListenerIntegrationTest { + + @Autowired + private LikeApplicationService likeApplicationService; + + @Autowired + private ProductRepository productRepository; + + @Autowired + private LikeCountCacheService likeCountCacheService; + + @Autowired + private RedisTemplate redisTemplate; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + // Redis 정리 + redisTemplate.execute(connection -> { + connection.serverCommands().flushAll(); + return null; + }, true); + } + + @Test + @DisplayName("좋아요 생성 후 Redis 카운터가 비동기로 갱신된다") + void 좋아요_생성_후_Redis_카운터_갱신() { + // Arrange + Product product = productRepository.save( + Product.create(1L, "테스트 상품", "설명", new Money(10000), new Stock(100), "http://image.url") + ); + Long userId = 1L; + + // Act + likeApplicationService.like(userId, product.getId()); + + // Assert - Awaitility로 비동기 갱신 확인 + await().atMost(5, TimeUnit.SECONDS).untilAsserted(() -> { + Long count = likeCountCacheService.get(product.getId()).orElse(0L); + assertThat(count).isEqualTo(1L); + }); + } + + @Test + @DisplayName("좋아요 취소 후 Redis 카운터가 비동기로 감소한다") + void 좋아요_취소_후_Redis_카운터_감소() { + // Arrange + Product product = productRepository.save( + Product.create(1L, "테스트 상품", "설명", new Money(10000), new Stock(100), "http://image.url") + ); + Long userId = 1L; + likeApplicationService.like(userId, product.getId()); + + // 좋아요 카운터가 증가할 때까지 대기 + await().atMost(5, TimeUnit.SECONDS).untilAsserted(() -> { + Long count = likeCountCacheService.get(product.getId()).orElse(0L); + assertThat(count).isEqualTo(1L); + }); + + // Act + likeApplicationService.unlike(userId, product.getId()); + + // Assert - Awaitility로 비동기 갱신 확인 + await().atMost(5, TimeUnit.SECONDS).untilAsserted(() -> { + Long count = likeCountCacheService.get(product.getId()).orElse(0L); + assertThat(count).isEqualTo(0L); + }); + } + + @Test + @DisplayName("여러 사용자가 좋아요하면 Redis 카운터가 정확하게 증가한다") + void 여러_사용자_좋아요_후_Redis_카운터_정확성() { + // Arrange + Product product = productRepository.save( + Product.create(1L, "테스트 상품", "설명", new Money(10000), new Stock(100), "http://image.url") + ); + int userCount = 5; + + // Act + for (int i = 1; i <= userCount; i++) { + likeApplicationService.like((long) i, product.getId()); + } + + // Assert + await().atMost(5, TimeUnit.SECONDS).untilAsserted(() -> { + Long count = likeCountCacheService.get(product.getId()).orElse(0L); + assertThat(count).isEqualTo((long) userCount); + }); + } + + @Test + @DisplayName("좋아요 DB 저장과 Redis 캐시 갱신이 분리되어 있어 캐시 실패해도 좋아요는 유지된다") + void 좋아요_저장_성공_검증() { + // Arrange + Product product = productRepository.save( + Product.create(1L, "테스트 상품", "설명", new Money(10000), new Stock(100), "http://image.url") + ); + Long userId = 1L; + + // Act + LikeResult result = likeApplicationService.like(userId, product.getId()); + + // Assert - 좋아요 결과가 즉시 반환됨 (이벤트 처리와 무관) + assertThat(result).isNotNull(); + assertThat(result.userId()).isEqualTo(userId); + assertThat(result.productId()).isEqualTo(product.getId()); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/event/listener/OutboxEventListenerTest.java b/apps/commerce-api/src/test/java/com/loopers/application/event/listener/OutboxEventListenerTest.java new file mode 100644 index 000000000..0726adad7 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/event/listener/OutboxEventListenerTest.java @@ -0,0 +1,143 @@ +package com.loopers.application.event.listener; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.application.event.LikeCanceledEvent; +import com.loopers.application.event.LikeCreatedEvent; +import com.loopers.application.event.OrderCompletedEvent; +import com.loopers.application.event.ProductViewedEvent; +import com.loopers.domain.outbox.OutboxEvent; +import com.loopers.domain.outbox.OutboxStatus; +import com.loopers.event.AggregateType; +import com.loopers.event.EventType; +import com.loopers.event.KafkaTopics; +import com.loopers.fake.FakeOutboxEventRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("OutboxEventListener 테스트") +class OutboxEventListenerTest { + + private FakeOutboxEventRepository outboxEventRepository; + private ObjectMapper objectMapper; + private OutboxEventListener listener; + + @BeforeEach + void setUp() { + outboxEventRepository = new FakeOutboxEventRepository(); + objectMapper = new ObjectMapper(); + objectMapper.findAndRegisterModules(); + listener = new OutboxEventListener(outboxEventRepository, objectMapper); + } + + @Nested + @DisplayName("LikeCreatedEvent 처리") + class LikeCreated { + + @Test + @DisplayName("성공 - Outbox에 이벤트 저장") + void 좋아요_생성_이벤트_저장() { + // Arrange + LikeCreatedEvent event = LikeCreatedEvent.of(1L, 100L, 200L); + + // Act + listener.handleLikeCreatedEvent(event); + + // Assert + List events = outboxEventRepository.findAll(); + assertThat(events).hasSize(1); + + OutboxEvent outboxEvent = events.get(0); + assertThat(outboxEvent.getEventType()).isEqualTo(EventType.LIKE_CREATED.name()); + assertThat(outboxEvent.getAggregateType()).isEqualTo(AggregateType.PRODUCT.name()); + assertThat(outboxEvent.getAggregateId()).isEqualTo("200"); + assertThat(outboxEvent.getTopic()).isEqualTo(KafkaTopics.CATALOG_EVENTS); + assertThat(outboxEvent.getStatus()).isEqualTo(OutboxStatus.PENDING); + assertThat(outboxEvent.getPayload()).contains("100"); + assertThat(outboxEvent.getPayload()).contains("200"); + } + } + + @Nested + @DisplayName("LikeCanceledEvent 처리") + class LikeCanceled { + + @Test + @DisplayName("성공 - Outbox에 이벤트 저장") + void 좋아요_취소_이벤트_저장() { + // Arrange + LikeCanceledEvent event = LikeCanceledEvent.of(1L, 100L, 200L); + + // Act + listener.handleLikeCanceledEvent(event); + + // Assert + List events = outboxEventRepository.findAll(); + assertThat(events).hasSize(1); + + OutboxEvent outboxEvent = events.get(0); + assertThat(outboxEvent.getEventType()).isEqualTo(EventType.LIKE_CANCELED.name()); + assertThat(outboxEvent.getAggregateType()).isEqualTo(AggregateType.PRODUCT.name()); + assertThat(outboxEvent.getAggregateId()).isEqualTo("200"); + assertThat(outboxEvent.getTopic()).isEqualTo(KafkaTopics.CATALOG_EVENTS); + assertThat(outboxEvent.getStatus()).isEqualTo(OutboxStatus.PENDING); + } + } + + @Nested + @DisplayName("OrderCompletedEvent 처리") + class OrderCompleted { + + @Test + @DisplayName("성공 - Outbox에 이벤트 저장") + void 주문_완료_이벤트_저장() { + // Arrange + OrderCompletedEvent event = OrderCompletedEvent.of(1L, 100L, 200L, 2, 50000L); + + // Act + listener.handleOrderCompletedEvent(event); + + // Assert + List events = outboxEventRepository.findAll(); + assertThat(events).hasSize(1); + + OutboxEvent outboxEvent = events.get(0); + assertThat(outboxEvent.getEventType()).isEqualTo(EventType.ORDER_COMPLETED.name()); + assertThat(outboxEvent.getAggregateType()).isEqualTo(AggregateType.ORDER.name()); + assertThat(outboxEvent.getAggregateId()).isEqualTo("1"); + assertThat(outboxEvent.getTopic()).isEqualTo(KafkaTopics.ORDER_EVENTS); + assertThat(outboxEvent.getStatus()).isEqualTo(OutboxStatus.PENDING); + } + } + + @Nested + @DisplayName("ProductViewedEvent 처리") + class ProductViewed { + + @Test + @DisplayName("성공 - Outbox에 이벤트 저장") + void 상품_조회_이벤트_저장() { + // Arrange + ProductViewedEvent event = ProductViewedEvent.of(100L, 200L); + + // Act + listener.handleProductViewedEvent(event); + + // Assert + List events = outboxEventRepository.findAll(); + assertThat(events).hasSize(1); + + OutboxEvent outboxEvent = events.get(0); + assertThat(outboxEvent.getEventType()).isEqualTo(EventType.PRODUCT_VIEWED.name()); + assertThat(outboxEvent.getAggregateType()).isEqualTo(AggregateType.PRODUCT.name()); + assertThat(outboxEvent.getAggregateId()).isEqualTo("200"); + assertThat(outboxEvent.getTopic()).isEqualTo(KafkaTopics.CATALOG_EVENTS); + assertThat(outboxEvent.getStatus()).isEqualTo(OutboxStatus.PENDING); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/fcfs/CouponIssueApplicationServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/application/fcfs/CouponIssueApplicationServiceTest.java new file mode 100644 index 000000000..8ec752225 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/fcfs/CouponIssueApplicationServiceTest.java @@ -0,0 +1,133 @@ +package com.loopers.application.fcfs; + +import com.loopers.application.event.CouponIssueRequestCreatedEvent; +import com.loopers.domain.fcfs.CouponIssueRequest; +import com.loopers.domain.fcfs.CouponIssueRequestStatus; +import com.loopers.domain.fcfs.FcfsCoupon; +import com.loopers.fake.FakeApplicationEventPublisher; +import com.loopers.fake.FakeCouponIssueRequestRepository; +import com.loopers.fake.FakeFcfsCouponRepository; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +@DisplayName("CouponIssueApplicationService 테스트") +class CouponIssueApplicationServiceTest { + + private FakeFcfsCouponRepository fcfsCouponRepository; + private FakeCouponIssueRequestRepository couponIssueRequestRepository; + private FakeApplicationEventPublisher eventPublisher; + private CouponIssueApplicationService service; + + @BeforeEach + void setUp() { + fcfsCouponRepository = new FakeFcfsCouponRepository(); + couponIssueRequestRepository = new FakeCouponIssueRequestRepository(); + eventPublisher = new FakeApplicationEventPublisher(); + service = new CouponIssueApplicationService( + fcfsCouponRepository, + couponIssueRequestRepository, + eventPublisher + ); + } + + @Nested + @DisplayName("쿠폰 발급 요청") + class RequestIssue { + + @Test + @DisplayName("성공 - 발급 요청이 PENDING 상태로 저장되고 이벤트가 발행된다") + void 발급_요청_성공() { + // Arrange + FcfsCoupon coupon = fcfsCouponRepository.save(FcfsCoupon.create("선착순 쿠폰", 100)); + Long userId = 1L; + + // Act + CouponIssueRequestResult result = service.requestIssue(coupon.getId(), userId); + + // Assert + assertThat(result.requestId()).isNotNull(); + assertThat(result.status()).isEqualTo(CouponIssueRequestStatus.PENDING); + + // 요청이 저장되었는지 확인 + Optional savedRequest = couponIssueRequestRepository.findByRequestId(result.requestId()); + assertThat(savedRequest).isPresent(); + assertThat(savedRequest.get().getStatus()).isEqualTo(CouponIssueRequestStatus.PENDING); + assertThat(savedRequest.get().getCouponId()).isEqualTo(coupon.getId()); + assertThat(savedRequest.get().getUserId()).isEqualTo(userId); + } + + @Test + @DisplayName("성공 - 발급 요청 시 CouponIssueRequestCreatedEvent 발행") + void 발급_요청_시_이벤트_발행() { + // Arrange + FcfsCoupon coupon = fcfsCouponRepository.save(FcfsCoupon.create("선착순 쿠폰", 100)); + Long userId = 1L; + + // Act + CouponIssueRequestResult result = service.requestIssue(coupon.getId(), userId); + + // Assert + assertThat(eventPublisher.hasEventOfType(CouponIssueRequestCreatedEvent.class)).isTrue(); + List events = eventPublisher.getEventsOfType(CouponIssueRequestCreatedEvent.class); + assertThat(events).hasSize(1); + + CouponIssueRequestCreatedEvent event = events.get(0); + assertThat(event.requestId()).isEqualTo(result.requestId()); + assertThat(event.couponId()).isEqualTo(coupon.getId()); + assertThat(event.userId()).isEqualTo(userId); + } + + @Test + @DisplayName("실패 - 존재하지 않는 쿠폰") + void 존재하지_않는_쿠폰_예외() { + // Arrange + Long nonExistentCouponId = 999L; + Long userId = 1L; + + // Act & Assert + CoreException ex = assertThrows(CoreException.class, + () -> service.requestIssue(nonExistentCouponId, userId)); + assertThat(ex.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } + + @Nested + @DisplayName("발급 결과 조회") + class GetIssueResult { + + @Test + @DisplayName("성공 - 존재하는 requestId로 결과 조회") + void 발급_결과_조회_성공() { + // Arrange + FcfsCoupon coupon = fcfsCouponRepository.save(FcfsCoupon.create("선착순 쿠폰", 100)); + CouponIssueRequestResult requestResult = service.requestIssue(coupon.getId(), 1L); + + // Act + CouponIssueResultResponse result = service.getIssueResult(requestResult.requestId()); + + // Assert + assertThat(result.requestId()).isEqualTo(requestResult.requestId()); + assertThat(result.status()).isEqualTo(CouponIssueRequestStatus.PENDING); + assertThat(result.failureReason()).isNull(); + } + + @Test + @DisplayName("실패 - 존재하지 않는 requestId") + void 존재하지_않는_requestId_예외() { + // Act & Assert + CoreException ex = assertThrows(CoreException.class, + () -> service.getIssueResult("non-existent-request-id")); + assertThat(ex.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/like/LikeApplicationServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeApplicationServiceTest.java index 30bd6a6eb..c2f5bf31d 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/like/LikeApplicationServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeApplicationServiceTest.java @@ -1,20 +1,24 @@ package com.loopers.application.like; +import com.loopers.application.event.LikeCanceledEvent; +import com.loopers.application.event.LikeCreatedEvent; +import com.loopers.application.event.UserActionEvent; import com.loopers.domain.common.Money; import com.loopers.domain.like.LikeDomainService; import com.loopers.domain.product.Product; import com.loopers.domain.product.Stock; -import com.loopers.fake.FakeLikeCountCacheService; +import com.loopers.domain.useraction.ActionType; +import com.loopers.fake.FakeApplicationEventPublisher; import com.loopers.fake.FakeLikeRepository; import com.loopers.fake.FakeProductRepository; -import com.loopers.infrastructure.cache.LikeCountCacheService; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; -import org.mockito.Mockito; + +import java.util.List; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; @@ -26,7 +30,7 @@ class LikeApplicationServiceTest { private FakeLikeRepository fakeLikeRepository; private FakeProductRepository fakeProductRepository; private LikeDomainService likeDomainService; - private LikeCountCacheService likeCountCacheService; + private FakeApplicationEventPublisher fakeEventPublisher; private LikeApplicationService likeApplicationService; @BeforeEach @@ -34,12 +38,12 @@ void setUp() { fakeLikeRepository = new FakeLikeRepository(); fakeProductRepository = new FakeProductRepository(); likeDomainService = new LikeDomainService(fakeLikeRepository); - likeCountCacheService = Mockito.mock(LikeCountCacheService.class); + fakeEventPublisher = new FakeApplicationEventPublisher(); likeApplicationService = new LikeApplicationService( likeDomainService, fakeProductRepository, fakeLikeRepository, - likeCountCacheService + fakeEventPublisher ); } @@ -69,6 +73,46 @@ class Like { assertThat(result.createdAt()).isNotNull(); } + @Test + @DisplayName("성공 - 좋아요 시 LikeCreatedEvent 발행") + void 좋아요_시_이벤트_발행() { + // Arrange + Product product = createAndSaveProduct(); + Long userId = 1L; + + // Act + LikeResult result = likeApplicationService.like(userId, product.getId()); + + // Assert + assertThat(fakeEventPublisher.hasEventOfType(LikeCreatedEvent.class)).isTrue(); + List events = fakeEventPublisher.getEventsOfType(LikeCreatedEvent.class); + assertThat(events).hasSize(1); + LikeCreatedEvent event = events.get(0); + assertThat(event.likeId()).isEqualTo(result.id()); + assertThat(event.userId()).isEqualTo(userId); + assertThat(event.productId()).isEqualTo(product.getId()); + } + + @Test + @DisplayName("성공 - 좋아요 시 UserActionEvent 발행") + void 좋아요_시_사용자_행동_이벤트_발행() { + // Arrange + Product product = createAndSaveProduct(); + Long userId = 1L; + + // Act + likeApplicationService.like(userId, product.getId()); + + // Assert + assertThat(fakeEventPublisher.hasEventOfType(UserActionEvent.class)).isTrue(); + List events = fakeEventPublisher.getEventsOfType(UserActionEvent.class); + assertThat(events).hasSize(1); + UserActionEvent event = events.get(0); + assertThat(event.userId()).isEqualTo(userId); + assertThat(event.actionType()).isEqualTo(ActionType.LIKE); + assertThat(event.targetId()).isEqualTo(product.getId()); + } + @Test @DisplayName("실패 - 상품이 존재하지 않는 경우") void 상품_미존재_예외() { @@ -108,6 +152,7 @@ class Unlike { Product product = createAndSaveProduct(); Long userId = 1L; likeApplicationService.like(userId, product.getId()); + fakeEventPublisher.clear(); // 이전 이벤트 초기화 // Act likeApplicationService.unlike(userId, product.getId()); @@ -116,6 +161,28 @@ class Unlike { assertThat(fakeLikeRepository.exists(userId, product.getId())).isFalse(); } + @Test + @DisplayName("성공 - 좋아요 취소 시 LikeCanceledEvent 발행") + void 좋아요_취소_시_이벤트_발행() { + // Arrange + Product product = createAndSaveProduct(); + Long userId = 1L; + LikeResult likeResult = likeApplicationService.like(userId, product.getId()); + fakeEventPublisher.clear(); + + // Act + likeApplicationService.unlike(userId, product.getId()); + + // Assert + assertThat(fakeEventPublisher.hasEventOfType(LikeCanceledEvent.class)).isTrue(); + List events = fakeEventPublisher.getEventsOfType(LikeCanceledEvent.class); + assertThat(events).hasSize(1); + LikeCanceledEvent event = events.get(0); + assertThat(event.likeId()).isEqualTo(likeResult.id()); + assertThat(event.userId()).isEqualTo(userId); + assertThat(event.productId()).isEqualTo(product.getId()); + } + @Test @DisplayName("성공 - 좋아요가 존재하지 않아도 멱등하게 동작") void 좋아요_미존재_멱등성() { @@ -126,5 +193,19 @@ class Unlike { // Act & Assert - 예외 없이 성공 assertDoesNotThrow(() -> likeApplicationService.unlike(userId, productId)); } + + @Test + @DisplayName("성공 - 좋아요가 존재하지 않으면 이벤트 발행하지 않음") + void 좋아요_미존재시_이벤트_미발행() { + // Arrange + Long userId = 1L; + Long productId = 999L; + + // Act + likeApplicationService.unlike(userId, productId); + + // Assert + assertThat(fakeEventPublisher.hasEventOfType(LikeCanceledEvent.class)).isFalse(); + } } } diff --git a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderApplicationServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderApplicationServiceTest.java index 403382374..6ff834157 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderApplicationServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderApplicationServiceTest.java @@ -1,5 +1,7 @@ package com.loopers.application.order; +import com.loopers.application.event.OrderCompletedEvent; +import com.loopers.application.event.UserActionEvent; import com.loopers.domain.common.Money; import com.loopers.domain.coupon.CouponTemplate; import com.loopers.domain.coupon.IssuedCoupon; @@ -8,6 +10,8 @@ import com.loopers.domain.point.UserPoint; import com.loopers.domain.product.Product; import com.loopers.domain.product.Stock; +import com.loopers.domain.useraction.ActionType; +import com.loopers.fake.FakeApplicationEventPublisher; import com.loopers.fake.FakeCouponTemplateRepository; import com.loopers.fake.FakeIssuedCouponRepository; import com.loopers.fake.FakeOrderRepository; @@ -34,6 +38,7 @@ class OrderApplicationServiceTest { private FakeCouponTemplateRepository fakeCouponTemplateRepository; private FakeIssuedCouponRepository fakeIssuedCouponRepository; private FakeUserPointRepository fakeUserPointRepository; + private FakeApplicationEventPublisher fakeEventPublisher; private OrderApplicationService orderApplicationService; @BeforeEach @@ -43,12 +48,14 @@ void setUp() { fakeCouponTemplateRepository = new FakeCouponTemplateRepository(); fakeIssuedCouponRepository = new FakeIssuedCouponRepository(); fakeUserPointRepository = new FakeUserPointRepository(); + fakeEventPublisher = new FakeApplicationEventPublisher(); orderApplicationService = new OrderApplicationService( fakeProductRepository, fakeOrderRepository, fakeCouponTemplateRepository, fakeIssuedCouponRepository, - fakeUserPointRepository + fakeUserPointRepository, + fakeEventPublisher ); } @@ -89,6 +96,52 @@ class PlaceOrder { assertThat(updatedProduct.getStock().quantity()).isEqualTo(98); // 100 - 2 } + @Test + @DisplayName("성공 - 주문 완료 시 OrderCompletedEvent 발행") + void 주문_완료_시_이벤트_발행() { + // Arrange + Product product = createAndSaveProduct("테스트 상품", 10000, 100); + Long userId = 1L; + List items = List.of( + new OrderItemRequest(product.getId(), 2) + ); + + // Act + OrderResult result = orderApplicationService.placeOrder(userId, items); + + // Assert + assertThat(fakeEventPublisher.hasEventOfType(OrderCompletedEvent.class)).isTrue(); + List events = fakeEventPublisher.getEventsOfType(OrderCompletedEvent.class); + assertThat(events).hasSize(1); + OrderCompletedEvent event = events.get(0); + assertThat(event.orderId()).isEqualTo(result.id()); + assertThat(event.userId()).isEqualTo(userId); + assertThat(event.totalAmount()).isEqualTo(20000L); + } + + @Test + @DisplayName("성공 - 주문 완료 시 UserActionEvent 발행") + void 주문_완료_시_사용자_행동_이벤트_발행() { + // Arrange + Product product = createAndSaveProduct("테스트 상품", 10000, 100); + Long userId = 1L; + List items = List.of( + new OrderItemRequest(product.getId(), 2) + ); + + // Act + OrderResult result = orderApplicationService.placeOrder(userId, items); + + // Assert + assertThat(fakeEventPublisher.hasEventOfType(UserActionEvent.class)).isTrue(); + List events = fakeEventPublisher.getEventsOfType(UserActionEvent.class); + assertThat(events).hasSize(1); + UserActionEvent event = events.get(0); + assertThat(event.userId()).isEqualTo(userId); + assertThat(event.actionType()).isEqualTo(ActionType.ORDER); + assertThat(event.targetId()).isEqualTo(result.id()); + } + @Test @DisplayName("성공 - 복수 상품 주문") void 복수_상품_주문_성공() { @@ -311,6 +364,34 @@ class PlaceOrderWithDiscount { assertThat(usedCoupon.getStatus()).isEqualTo(IssuedCouponStatus.USED); } + @Test + @DisplayName("성공 - 할인 주문 완료 시 이벤트 발행") + void 할인_주문_완료_시_이벤트_발행() { + // Arrange + Product product = createAndSaveProduct("테스트 상품", 50000, 100); + Long userId = 1L; + + CouponTemplate template = fakeCouponTemplateRepository.save( + CouponTemplate.createFixed("5000원 할인", new Money(5000), new Money(10000), 100, ZonedDateTime.now().plusDays(7)) + ); + IssuedCoupon issued = fakeIssuedCouponRepository.save( + IssuedCoupon.create(userId, template) + ); + + var request = new PlaceOrderWithDiscountRequest( + List.of(new OrderItemRequest(product.getId(), 1)), + issued.getId(), + null + ); + + // Act + OrderResult result = orderApplicationService.placeOrderWithDiscount(userId, request); + + // Assert + assertThat(fakeEventPublisher.hasEventOfType(OrderCompletedEvent.class)).isTrue(); + assertThat(fakeEventPublisher.hasEventOfType(UserActionEvent.class)).isTrue(); + } + @Test @DisplayName("성공 - 포인트만 적용") void 포인트만_적용_주문_성공() { diff --git a/apps/commerce-api/src/test/java/com/loopers/concurrency/LikeConcurrencyTest.java b/apps/commerce-api/src/test/java/com/loopers/concurrency/LikeConcurrencyTest.java index 80086e4ec..b61c1df72 100644 --- a/apps/commerce-api/src/test/java/com/loopers/concurrency/LikeConcurrencyTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/concurrency/LikeConcurrencyTest.java @@ -1,6 +1,7 @@ package com.loopers.concurrency; import com.loopers.application.like.LikeApplicationService; +import com.loopers.config.TestRedisConfiguration; import com.loopers.domain.common.Money; import com.loopers.domain.like.LikeRepository; import com.loopers.domain.product.Product; @@ -12,6 +13,7 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutorService; @@ -21,6 +23,7 @@ import static org.assertj.core.api.Assertions.assertThat; @SpringBootTest +@Import(TestRedisConfiguration.class) @DisplayName("좋아요 동시성 테스트") class LikeConcurrencyTest { diff --git a/apps/commerce-api/src/test/java/com/loopers/config/TestKafkaAutoConfiguration.java b/apps/commerce-api/src/test/java/com/loopers/config/TestKafkaAutoConfiguration.java new file mode 100644 index 000000000..c204f7daf --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/config/TestKafkaAutoConfiguration.java @@ -0,0 +1,23 @@ +package com.loopers.config; + +import com.loopers.event.EventEnvelope; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Primary; +import org.springframework.kafka.core.KafkaTemplate; + +import static org.mockito.Mockito.mock; + +/** + * 테스트 환경에서 자동으로 KafkaTemplate Mock을 제공. + */ +@AutoConfiguration +public class TestKafkaAutoConfiguration { + + @Bean + @Primary + @SuppressWarnings("unchecked") + public KafkaTemplate testKafkaTemplate() { + return mock(KafkaTemplate.class); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/fake/FakeApplicationEventPublisher.java b/apps/commerce-api/src/test/java/com/loopers/fake/FakeApplicationEventPublisher.java new file mode 100644 index 000000000..e2d5ef673 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/fake/FakeApplicationEventPublisher.java @@ -0,0 +1,59 @@ +package com.loopers.fake; + +import org.springframework.context.ApplicationEventPublisher; + +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; + +/** + * 테스트용 Fake ApplicationEventPublisher. + * 발행된 이벤트를 캡처하여 테스트에서 검증 가능. + */ +public class FakeApplicationEventPublisher implements ApplicationEventPublisher { + + private final List publishedEvents = new ArrayList<>(); + + @Override + public void publishEvent(Object event) { + publishedEvents.add(event); + } + + /** + * 발행된 모든 이벤트 조회. + */ + public List getPublishedEvents() { + return new ArrayList<>(publishedEvents); + } + + /** + * 특정 타입의 이벤트만 조회. + */ + public List getEventsOfType(Class eventType) { + return publishedEvents.stream() + .filter(eventType::isInstance) + .map(eventType::cast) + .collect(Collectors.toList()); + } + + /** + * 특정 타입의 이벤트 발행 여부 확인. + */ + public boolean hasEventOfType(Class eventType) { + return publishedEvents.stream().anyMatch(eventType::isInstance); + } + + /** + * 발행된 이벤트 수 조회. + */ + public int getEventCount() { + return publishedEvents.size(); + } + + /** + * 이벤트 초기화. + */ + public void clear() { + publishedEvents.clear(); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/fake/FakeCouponIssueRequestRepository.java b/apps/commerce-api/src/test/java/com/loopers/fake/FakeCouponIssueRequestRepository.java new file mode 100644 index 000000000..156948dbe --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/fake/FakeCouponIssueRequestRepository.java @@ -0,0 +1,78 @@ +package com.loopers.fake; + +import com.loopers.domain.fcfs.CouponIssueRequest; +import com.loopers.domain.fcfs.CouponIssueRequestRepository; +import com.loopers.domain.fcfs.CouponIssueRequestStatus; + +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicLong; + +/** + * 테스트용 Fake CouponIssueRequestRepository. + */ +public class FakeCouponIssueRequestRepository implements CouponIssueRequestRepository { + + private final Map store = new HashMap<>(); + private final AtomicLong idGenerator = new AtomicLong(0); + + @Override + public CouponIssueRequest save(CouponIssueRequest request) { + Long id = request.getId() != null ? request.getId() : idGenerator.incrementAndGet(); + CouponIssueRequest saved = CouponIssueRequest.reconstitute( + id, + request.getRequestId(), + request.getCouponId(), + request.getUserId(), + request.getStatus(), + request.getFailureReason(), + request.getCreatedAt(), + request.getUpdatedAt() + ); + store.put(request.getRequestId(), saved); + return saved; + } + + @Override + public Optional findByRequestId(String requestId) { + return Optional.ofNullable(store.get(requestId)); + } + + @Override + public void updateStatus(String requestId, CouponIssueRequestStatus status, String failureReason) { + CouponIssueRequest request = store.get(requestId); + if (request != null) { + CouponIssueRequest updated = CouponIssueRequest.reconstitute( + request.getId(), + request.getRequestId(), + request.getCouponId(), + request.getUserId(), + status, + failureReason, + request.getCreatedAt(), + request.getUpdatedAt() + ); + store.put(requestId, updated); + } + } + + @Override + public long countByStatus(CouponIssueRequestStatus status) { + return store.values().stream() + .filter(r -> r.getStatus() == status) + .count(); + } + + @Override + public long countByCouponIdAndStatus(Long couponId, CouponIssueRequestStatus status) { + return store.values().stream() + .filter(r -> r.getCouponId().equals(couponId) && r.getStatus() == status) + .count(); + } + + public void clear() { + store.clear(); + idGenerator.set(0); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/fake/FakeFcfsCouponRepository.java b/apps/commerce-api/src/test/java/com/loopers/fake/FakeFcfsCouponRepository.java new file mode 100644 index 000000000..c95305882 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/fake/FakeFcfsCouponRepository.java @@ -0,0 +1,61 @@ +package com.loopers.fake; + +import com.loopers.domain.fcfs.FcfsCoupon; +import com.loopers.domain.fcfs.FcfsCouponRepository; + +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicLong; + +/** + * 테스트용 Fake FcfsCouponRepository. + */ +public class FakeFcfsCouponRepository implements FcfsCouponRepository { + + private final Map store = new HashMap<>(); + private final AtomicLong idGenerator = new AtomicLong(0); + + @Override + public FcfsCoupon save(FcfsCoupon coupon) { + Long id = coupon.getId() != null ? coupon.getId() : idGenerator.incrementAndGet(); + FcfsCoupon saved = FcfsCoupon.reconstitute( + id, + coupon.getName(), + coupon.getTotalQuantity(), + coupon.getIssuedCount(), + coupon.getCreatedAt() + ); + store.put(id, saved); + return saved; + } + + @Override + public Optional findById(Long id) { + return Optional.ofNullable(store.get(id)); + } + + @Override + public Optional findByIdWithLock(Long id) { + return findById(id); + } + + @Override + public boolean existsById(Long id) { + return store.containsKey(id); + } + + @Override + public void incrementIssuedCount(Long id) { + FcfsCoupon coupon = store.get(id); + if (coupon != null) { + coupon.incrementIssuedCount(); + store.put(id, coupon); + } + } + + public void clear() { + store.clear(); + idGenerator.set(0); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/fake/FakeOutboxEventRepository.java b/apps/commerce-api/src/test/java/com/loopers/fake/FakeOutboxEventRepository.java new file mode 100644 index 000000000..2e63c118b --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/fake/FakeOutboxEventRepository.java @@ -0,0 +1,84 @@ +package com.loopers.fake; + +import com.loopers.domain.outbox.OutboxEvent; +import com.loopers.domain.outbox.OutboxEventRepository; +import com.loopers.domain.outbox.OutboxStatus; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.concurrent.atomic.AtomicLong; + +/** + * 테스트용 Fake OutboxEventRepository. + */ +public class FakeOutboxEventRepository implements OutboxEventRepository { + + private final List store = new ArrayList<>(); + private final AtomicLong idGenerator = new AtomicLong(0); + + @Override + public OutboxEvent save(OutboxEvent event) { + Long newId = idGenerator.incrementAndGet(); + OutboxEvent saved = OutboxEvent.reconstitute( + newId, + event.getEventId(), + event.getEventType(), + event.getAggregateType(), + event.getAggregateId(), + event.getTopic(), + event.getPayload(), + event.getCreatedAt(), + event.getStatus(), + event.getRetryCount(), + event.getSentAt() + ); + store.add(saved); + return saved; + } + + @Override + public List findPendingEvents(int limit) { + return store.stream() + .filter(e -> e.getStatus() == OutboxStatus.PENDING) + .sorted(Comparator.comparing(OutboxEvent::getCreatedAt)) + .limit(limit) + .toList(); + } + + @Override + public void updateStatus(Long id, OutboxStatus status) { + store.stream() + .filter(e -> e.getId().equals(id)) + .findFirst() + .ifPresent(e -> { + if (status == OutboxStatus.SENT) { + e.markAsSent(); + } else if (status == OutboxStatus.FAILED) { + e.markAsFailed(); + } + }); + } + + @Override + public void markAsSent(Long id) { + updateStatus(id, OutboxStatus.SENT); + } + + @Override + public void incrementRetryCount(Long id) { + store.stream() + .filter(e -> e.getId().equals(id)) + .findFirst() + .ifPresent(OutboxEvent::incrementRetry); + } + + public List findAll() { + return new ArrayList<>(store); + } + + public void clear() { + store.clear(); + idGenerator.set(0); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/fake/FakeUserActionLogRepository.java b/apps/commerce-api/src/test/java/com/loopers/fake/FakeUserActionLogRepository.java new file mode 100644 index 000000000..4d86eb05d --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/fake/FakeUserActionLogRepository.java @@ -0,0 +1,77 @@ +package com.loopers.fake; + +import com.loopers.domain.useraction.ActionType; +import com.loopers.domain.useraction.UserActionLog; +import com.loopers.domain.useraction.UserActionLogRepository; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicLong; +import java.util.stream.Collectors; + +/** + * 테스트용 Fake UserActionLogRepository. + * Map 기반 in-memory 구현. + */ +public class FakeUserActionLogRepository implements UserActionLogRepository { + + private final Map store = new HashMap<>(); + private final AtomicLong idGenerator = new AtomicLong(1); + + @Override + public UserActionLog save(UserActionLog userActionLog) { + Long id = idGenerator.getAndIncrement(); + UserActionLog saved = UserActionLog.reconstitute( + id, + userActionLog.getUserId(), + userActionLog.getActionType(), + userActionLog.getTargetId(), + userActionLog.getOccurredAt(), + userActionLog.getCreatedAt() + ); + store.put(id, saved); + return saved; + } + + /** + * 사용자 ID로 로그 조회. + */ + public List findByUserId(Long userId) { + return store.values().stream() + .filter(log -> log.getUserId().equals(userId)) + .collect(Collectors.toList()); + } + + /** + * 액션 타입으로 로그 조회. + */ + public List findByActionType(ActionType actionType) { + return store.values().stream() + .filter(log -> log.getActionType() == actionType) + .collect(Collectors.toList()); + } + + /** + * 모든 로그 조회. + */ + public List findAll() { + return new ArrayList<>(store.values()); + } + + /** + * 저장된 로그 수 조회. + */ + public int size() { + return store.size(); + } + + /** + * 저장소 초기화. + */ + public void clear() { + store.clear(); + idGenerator.set(1); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/infrastructure/outbox/OutboxIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/infrastructure/outbox/OutboxIntegrationTest.java new file mode 100644 index 000000000..993b04981 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/infrastructure/outbox/OutboxIntegrationTest.java @@ -0,0 +1,108 @@ +package com.loopers.infrastructure.outbox; + +import com.loopers.application.like.LikeApplicationService; +import com.loopers.config.TestRedisConfiguration; +import com.loopers.domain.common.Money; +import com.loopers.domain.outbox.OutboxEvent; +import com.loopers.domain.outbox.OutboxEventRepository; +import com.loopers.domain.outbox.OutboxStatus; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; +import com.loopers.domain.product.Stock; +import com.loopers.event.EventType; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.data.redis.core.RedisTemplate; + +import java.util.List; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +@SpringBootTest +@Import(TestRedisConfiguration.class) +@DisplayName("Outbox 통합 테스트") +class OutboxIntegrationTest { + + @Autowired + private LikeApplicationService likeApplicationService; + + @Autowired + private ProductRepository productRepository; + + @Autowired + private OutboxEventRepository outboxEventRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @Autowired + private RedisTemplate redisTemplate; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + redisTemplate.execute(connection -> { + connection.serverCommands().flushAll(); + return null; + }, true); + } + + @Test + @DisplayName("좋아요 생성 시 Outbox에 이벤트가 저장된다") + void 좋아요_생성_시_Outbox_이벤트_저장() { + // Arrange + Product product = productRepository.save( + Product.create(1L, "테스트 상품", "설명", new Money(10000), new Stock(100), "http://image.url") + ); + Long userId = 1L; + + // Act + likeApplicationService.like(userId, product.getId()); + + // Assert + await().atMost(2, TimeUnit.SECONDS).untilAsserted(() -> { + List events = outboxEventRepository.findPendingEvents(100); + assertThat(events).isNotEmpty(); + + OutboxEvent outboxEvent = events.stream() + .filter(e -> e.getEventType().equals(EventType.LIKE_CREATED.name())) + .findFirst() + .orElseThrow(); + + assertThat(outboxEvent.getAggregateId()).isEqualTo(String.valueOf(product.getId())); + assertThat(outboxEvent.getStatus()).isEqualTo(OutboxStatus.PENDING); + }); + } + + @Test + @DisplayName("좋아요 취소 시 Outbox에 이벤트가 저장된다") + void 좋아요_취소_시_Outbox_이벤트_저장() { + // Arrange + Product product = productRepository.save( + Product.create(1L, "테스트 상품", "설명", new Money(10000), new Stock(100), "http://image.url") + ); + Long userId = 1L; + likeApplicationService.like(userId, product.getId()); + + // Act + likeApplicationService.unlike(userId, product.getId()); + + // Assert + await().atMost(2, TimeUnit.SECONDS).untilAsserted(() -> { + List events = outboxEventRepository.findPendingEvents(100); + + boolean hasCanceledEvent = events.stream() + .anyMatch(e -> e.getEventType().equals(EventType.LIKE_CANCELED.name()) + && e.getAggregateId().equals(String.valueOf(product.getId()))); + + assertThat(hasCanceledEvent).isTrue(); + }); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/infrastructure/outbox/OutboxPublisherTest.java b/apps/commerce-api/src/test/java/com/loopers/infrastructure/outbox/OutboxPublisherTest.java new file mode 100644 index 000000000..8c87db6d3 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/infrastructure/outbox/OutboxPublisherTest.java @@ -0,0 +1,154 @@ +package com.loopers.infrastructure.outbox; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.domain.outbox.OutboxEvent; +import com.loopers.domain.outbox.OutboxStatus; +import com.loopers.event.EventEnvelope; +import com.loopers.event.KafkaTopics; +import com.loopers.fake.FakeOutboxEventRepository; +import org.apache.kafka.clients.producer.ProducerRecord; +import org.apache.kafka.clients.producer.RecordMetadata; +import org.apache.kafka.common.TopicPartition; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.kafka.support.SendResult; + +import java.util.List; +import java.util.concurrent.CompletableFuture; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@DisplayName("OutboxPublisher 테스트") +class OutboxPublisherTest { + + private FakeOutboxEventRepository outboxEventRepository; + private KafkaTemplate kafkaTemplate; + private ObjectMapper objectMapper; + private OutboxPublisher outboxPublisher; + + @BeforeEach + @SuppressWarnings("unchecked") + void setUp() { + outboxEventRepository = new FakeOutboxEventRepository(); + kafkaTemplate = mock(KafkaTemplate.class); + objectMapper = new ObjectMapper(); + objectMapper.findAndRegisterModules(); + outboxPublisher = new OutboxPublisher(outboxEventRepository, kafkaTemplate, objectMapper); + } + + private OutboxEvent createPendingEvent(String topic, String aggregateId) { + return outboxEventRepository.save( + OutboxEvent.create( + "LIKE_CREATED", + "PRODUCT", + aggregateId, + topic, + "{\"userId\":1,\"productId\":" + aggregateId + "}" + ) + ); + } + + @Nested + @DisplayName("publishPendingEvents") + class PublishPendingEvents { + + @Test + @DisplayName("성공 - PENDING 상태 이벤트를 Kafka로 발행하고 SENT로 변경") + void PENDING_이벤트_발행_성공() { + // Arrange + OutboxEvent event = createPendingEvent(KafkaTopics.CATALOG_EVENTS, "100"); + + CompletableFuture> future = CompletableFuture.completedFuture( + createSendResult(KafkaTopics.CATALOG_EVENTS, 0, 0L) + ); + when(kafkaTemplate.send(any(ProducerRecord.class))).thenReturn(future); + + // Act + outboxPublisher.publishPendingEvents(); + + // Assert + ArgumentCaptor> captor = + ArgumentCaptor.forClass(ProducerRecord.class); + verify(kafkaTemplate).send(captor.capture()); + + ProducerRecord record = captor.getValue(); + assertThat(record.topic()).isEqualTo(KafkaTopics.CATALOG_EVENTS); + assertThat(record.key()).isEqualTo("100"); + assertThat(record.value().eventType()).isEqualTo("LIKE_CREATED"); + assertThat(record.value().aggregateId()).isEqualTo("100"); + + // Outbox 상태가 SENT로 변경되어야 함 + List pendingEvents = outboxEventRepository.findPendingEvents(100); + assertThat(pendingEvents).isEmpty(); + } + + @Test + @DisplayName("성공 - PENDING 이벤트가 없으면 아무 것도 하지 않음") + void PENDING_이벤트_없음() { + // Act + outboxPublisher.publishPendingEvents(); + + // Assert + verify(kafkaTemplate, never()).send(any(ProducerRecord.class)); + } + + @Test + @DisplayName("실패 - Kafka 발행 실패 시 retry count 증가") + void Kafka_발행_실패_시_retry_증가() { + // Arrange + OutboxEvent event = createPendingEvent(KafkaTopics.CATALOG_EVENTS, "100"); + + CompletableFuture> failedFuture = new CompletableFuture<>(); + failedFuture.completeExceptionally(new RuntimeException("Kafka error")); + when(kafkaTemplate.send(any(ProducerRecord.class))).thenReturn(failedFuture); + + // Act + outboxPublisher.publishPendingEvents(); + + // Assert - 여전히 PENDING 상태이고 retry count 증가 + List pendingEvents = outboxEventRepository.findPendingEvents(100); + assertThat(pendingEvents).hasSize(1); + assertThat(pendingEvents.get(0).getRetryCount()).isEqualTo(1); + } + + @Test + @DisplayName("성공 - 여러 이벤트를 순차적으로 발행") + void 여러_이벤트_순차_발행() { + // Arrange + createPendingEvent(KafkaTopics.CATALOG_EVENTS, "100"); + createPendingEvent(KafkaTopics.CATALOG_EVENTS, "101"); + createPendingEvent(KafkaTopics.ORDER_EVENTS, "1"); + + CompletableFuture> future = CompletableFuture.completedFuture( + createSendResult(KafkaTopics.CATALOG_EVENTS, 0, 0L) + ); + when(kafkaTemplate.send(any(ProducerRecord.class))).thenReturn(future); + + // Act + outboxPublisher.publishPendingEvents(); + + // Assert + verify(kafkaTemplate, times(3)).send(any(ProducerRecord.class)); + List pendingEvents = outboxEventRepository.findPendingEvents(100); + assertThat(pendingEvents).isEmpty(); + } + } + + private SendResult createSendResult(String topic, int partition, long offset) { + RecordMetadata metadata = new RecordMetadata( + new TopicPartition(topic, partition), + offset, + 0, + System.currentTimeMillis(), + 0, + 0 + ); + return new SendResult<>(null, metadata); + } +} diff --git a/apps/commerce-api/src/test/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/apps/commerce-api/src/test/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 000000000..9fe93923b --- /dev/null +++ b/apps/commerce-api/src/test/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1 @@ +com.loopers.config.TestKafkaAutoConfiguration diff --git a/apps/commerce-collector/build.gradle.kts b/apps/commerce-collector/build.gradle.kts new file mode 100644 index 000000000..49879d07d --- /dev/null +++ b/apps/commerce-collector/build.gradle.kts @@ -0,0 +1,22 @@ +dependencies { + // add-ons + implementation(project(":modules:jpa")) + implementation(project(":modules:kafka")) + implementation(project(":supports:jackson")) + implementation(project(":supports:logging")) + implementation(project(":supports:monitoring")) + implementation(project(":supports:kafka-events")) + + // web + implementation("org.springframework.boot:spring-boot-starter-web") + implementation("org.springframework.boot:spring-boot-starter-actuator") + + // querydsl + annotationProcessor("com.querydsl:querydsl-apt::jakarta") + annotationProcessor("jakarta.persistence:jakarta.persistence-api") + annotationProcessor("jakarta.annotation:jakarta.annotation-api") + + // test-fixtures + testImplementation(testFixtures(project(":modules:jpa"))) + testImplementation(testFixtures(project(":modules:kafka"))) +} diff --git a/apps/commerce-collector/src/main/java/com/loopers/CollectorApplication.java b/apps/commerce-collector/src/main/java/com/loopers/CollectorApplication.java new file mode 100644 index 000000000..5b625c1cd --- /dev/null +++ b/apps/commerce-collector/src/main/java/com/loopers/CollectorApplication.java @@ -0,0 +1,12 @@ +package com.loopers; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class CollectorApplication { + + public static void main(String[] args) { + SpringApplication.run(CollectorApplication.class, args); + } +} diff --git a/apps/commerce-collector/src/main/java/com/loopers/application/consumer/CouponIssueConsumer.java b/apps/commerce-collector/src/main/java/com/loopers/application/consumer/CouponIssueConsumer.java new file mode 100644 index 000000000..e988225c4 --- /dev/null +++ b/apps/commerce-collector/src/main/java/com/loopers/application/consumer/CouponIssueConsumer.java @@ -0,0 +1,131 @@ +package com.loopers.application.consumer; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.domain.eventhandled.EventHandled; +import com.loopers.domain.eventhandled.EventHandledRepository; +import com.loopers.domain.fcfs.CouponIssueRequestStatus; +import com.loopers.event.CouponIssueRequestedEvent; +import com.loopers.event.EventEnvelope; +import com.loopers.event.KafkaTopics; +import com.loopers.infrastructure.persistence.jpa.fcfs.CouponIssueRequestJpaRepository; +import com.loopers.infrastructure.persistence.jpa.fcfs.FcfsCouponJpaEntity; +import com.loopers.infrastructure.persistence.jpa.fcfs.FcfsCouponJpaRepository; +import com.loopers.infrastructure.persistence.jpa.fcfs.FcfsIssuedCouponJpaEntity; +import com.loopers.infrastructure.persistence.jpa.fcfs.FcfsIssuedCouponJpaRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.kafka.support.Acknowledgment; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Optional; + +/** + * 쿠폰 발급 요청을 처리하는 Kafka Consumer. + * concurrency=1로 설정하여 같은 couponId가 같은 파티션에서 순서대로 처리되도록 함. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class CouponIssueConsumer { + + private final EventHandledRepository eventHandledRepository; + private final FcfsCouponJpaRepository fcfsCouponJpaRepository; + private final FcfsIssuedCouponJpaRepository fcfsIssuedCouponJpaRepository; + private final CouponIssueRequestJpaRepository couponIssueRequestJpaRepository; + private final ObjectMapper objectMapper; + + @KafkaListener( + topics = KafkaTopics.COUPON_ISSUE_REQUESTS, + groupId = "coupon-issuer-group", + concurrency = "1" + ) + @Transactional + public void consume(EventEnvelope envelope, Acknowledgment ack) { + try { + processEvent(envelope); + ack.acknowledge(); + } catch (Exception e) { + log.error("Failed to process coupon issue event: eventId={}", envelope.eventId(), e); + // 처리 실패 시 acknowledge하지 않아 재처리됨 + } + } + + /** + * 이벤트 처리 (테스트 가능하도록 분리) + */ + @Transactional + public void processEvent(EventEnvelope envelope) { + // 1. 멱등성 체크 + if (eventHandledRepository.existsByEventId(envelope.eventId())) { + log.debug("Event already handled: eventId={}", envelope.eventId()); + return; + } + + // 2. 이벤트 페이로드 파싱 + CouponIssueRequestedEvent event = parsePayload(envelope); + + // 3. 쿠폰 발급 처리 (SELECT...FOR UPDATE로 비관적 락) + processCouponIssue(event); + + // 4. 처리 완료 기록 + eventHandledRepository.save(EventHandled.create(envelope.eventId(), envelope.eventType())); + log.debug("Coupon issue event processed: eventId={}, requestId={}", + envelope.eventId(), event.requestId()); + } + + private CouponIssueRequestedEvent parsePayload(EventEnvelope envelope) { + try { + return objectMapper.readValue(envelope.payload(), CouponIssueRequestedEvent.class); + } catch (JsonProcessingException e) { + log.error("Failed to parse coupon issue event payload: eventId={}", envelope.eventId(), e); + throw new RuntimeException("Failed to parse event payload", e); + } + } + + private void processCouponIssue(CouponIssueRequestedEvent event) { + String requestId = event.requestId(); + Long couponId = event.couponId(); + Long userId = event.userId(); + + // 1. 쿠폰에 비관적 락 획득 (SELECT...FOR UPDATE) + Optional couponOpt = fcfsCouponJpaRepository.findByIdWithLock(couponId); + + if (couponOpt.isEmpty()) { + updateRequestStatus(requestId, CouponIssueRequestStatus.FAILED, "쿠폰을 찾을 수 없습니다."); + return; + } + + FcfsCouponJpaEntity coupon = couponOpt.get(); + + // 2. 중복 발급 체크 + if (fcfsIssuedCouponJpaRepository.existsByCouponIdAndUserId(couponId, userId)) { + updateRequestStatus(requestId, CouponIssueRequestStatus.FAILED, "이미 발급받은 쿠폰입니다."); + return; + } + + // 3. 수량 체크 + if (!coupon.canIssue()) { + updateRequestStatus(requestId, CouponIssueRequestStatus.FAILED, "쿠폰 수량이 소진되었습니다."); + return; + } + + // 4. 발급 처리 + coupon.setIssuedCount(coupon.getIssuedCount() + 1); + fcfsCouponJpaRepository.save(coupon); + + fcfsIssuedCouponJpaRepository.save(new FcfsIssuedCouponJpaEntity(couponId, userId)); + + // 5. 상태 업데이트 + updateRequestStatus(requestId, CouponIssueRequestStatus.ISSUED, null); + + log.info("Coupon issued successfully: couponId={}, userId={}, requestId={}", + couponId, userId, requestId); + } + + private void updateRequestStatus(String requestId, CouponIssueRequestStatus status, String failureReason) { + couponIssueRequestJpaRepository.updateStatus(requestId, status, failureReason); + } +} diff --git a/apps/commerce-collector/src/main/java/com/loopers/application/consumer/MetricsEventConsumer.java b/apps/commerce-collector/src/main/java/com/loopers/application/consumer/MetricsEventConsumer.java new file mode 100644 index 000000000..6e0172834 --- /dev/null +++ b/apps/commerce-collector/src/main/java/com/loopers/application/consumer/MetricsEventConsumer.java @@ -0,0 +1,142 @@ +package com.loopers.application.consumer; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.domain.eventhandled.EventHandled; +import com.loopers.domain.eventhandled.EventHandledRepository; +import com.loopers.domain.metrics.ProductMetrics; +import com.loopers.domain.metrics.ProductMetricsRepository; +import com.loopers.event.EventEnvelope; +import com.loopers.event.EventType; +import com.loopers.event.KafkaTopics; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.kafka.support.Acknowledgment; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +/** + * 메트릭 수집을 위한 Kafka Consumer. + * catalog-events, order-events 토픽에서 이벤트를 수신하여 메트릭을 집계. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class MetricsEventConsumer { + + private final EventHandledRepository eventHandledRepository; + private final ProductMetricsRepository productMetricsRepository; + private final ObjectMapper objectMapper; + + @KafkaListener( + topics = {KafkaTopics.CATALOG_EVENTS, KafkaTopics.ORDER_EVENTS}, + groupId = "metrics-collector" + ) + @Transactional + public void consume(EventEnvelope envelope, Acknowledgment ack) { + try { + processEvent(envelope); + ack.acknowledge(); + } catch (Exception e) { + log.error("Failed to process event: eventId={}, type={}", + envelope.eventId(), envelope.eventType(), e); + // 처리 실패 시 acknowledge하지 않아 재처리됨 + } + } + + /** + * 이벤트 처리 (테스트 가능하도록 분리) + */ + @Transactional + public void processEvent(EventEnvelope envelope) { + // 1. 멱등성 체크 + if (eventHandledRepository.existsByEventId(envelope.eventId())) { + log.debug("Event already handled: eventId={}", envelope.eventId()); + return; + } + + // 2. 이벤트 타입별 처리 + EventType eventType = EventType.valueOf(envelope.eventType()); + switch (eventType) { + case LIKE_CREATED -> handleLikeCreated(envelope); + case LIKE_CANCELED -> handleLikeCanceled(envelope); + case PRODUCT_VIEWED -> handleProductViewed(envelope); + case ORDER_COMPLETED -> handleOrderCompleted(envelope); + } + + // 3. 처리 완료 기록 + eventHandledRepository.save(EventHandled.create(envelope.eventId(), envelope.eventType())); + log.debug("Event processed: eventId={}, type={}", envelope.eventId(), envelope.eventType()); + } + + private void handleLikeCreated(EventEnvelope envelope) { + Long productId = Long.parseLong(envelope.aggregateId()); + ProductMetrics metrics = productMetricsRepository.getOrCreate(productId); + + // 이벤트 순서 검증 (delta 연산이므로 경고만 로그) + validateEventOrder(metrics, envelope); + + metrics.incrementLikeCount(); + metrics.updateLastEventTimestamp(envelope.timestamp()); + productMetricsRepository.save(metrics); + } + + private void handleLikeCanceled(EventEnvelope envelope) { + Long productId = Long.parseLong(envelope.aggregateId()); + ProductMetrics metrics = productMetricsRepository.getOrCreate(productId); + + validateEventOrder(metrics, envelope); + + metrics.decrementLikeCount(); + metrics.updateLastEventTimestamp(envelope.timestamp()); + productMetricsRepository.save(metrics); + } + + private void handleProductViewed(EventEnvelope envelope) { + Long productId = Long.parseLong(envelope.aggregateId()); + ProductMetrics metrics = productMetricsRepository.getOrCreate(productId); + + validateEventOrder(metrics, envelope); + + metrics.incrementViewCount(); + metrics.updateLastEventTimestamp(envelope.timestamp()); + productMetricsRepository.save(metrics); + } + + private void handleOrderCompleted(EventEnvelope envelope) { + try { + JsonNode payload = objectMapper.readTree(envelope.payload()); + Long productId = payload.get("productId").asLong(); + int quantity = payload.get("quantity").asInt(); + long totalAmount = payload.get("totalAmount").asLong(); + + ProductMetrics metrics = productMetricsRepository.getOrCreate(productId); + + validateEventOrder(metrics, envelope); + + metrics.addOrder(quantity, totalAmount); + metrics.updateLastEventTimestamp(envelope.timestamp()); + productMetricsRepository.save(metrics); + } catch (JsonProcessingException e) { + log.error("Failed to parse order completed event payload: eventId={}", envelope.eventId(), e); + throw new RuntimeException("Failed to parse event payload", e); + } + } + + /** + * 이벤트 순서 검증. + * Delta 연산에서는 모든 이벤트를 처리해야 하므로 경고만 로그. + * State 기반 연산에서는 이 메서드를 활용해 오래된 이벤트를 스킵할 수 있음. + */ + private void validateEventOrder(ProductMetrics metrics, EventEnvelope envelope) { + if (!metrics.isNewerEvent(envelope.timestamp())) { + log.warn("Out-of-order event detected: eventId={}, eventTimestamp={}, lastEventTimestamp={}, productId={}", + envelope.eventId(), + envelope.timestamp(), + metrics.getLastEventTimestamp(), + envelope.aggregateId()); + } + } +} diff --git a/apps/commerce-collector/src/main/java/com/loopers/domain/eventhandled/EventHandled.java b/apps/commerce-collector/src/main/java/com/loopers/domain/eventhandled/EventHandled.java new file mode 100644 index 000000000..7f67ec7cc --- /dev/null +++ b/apps/commerce-collector/src/main/java/com/loopers/domain/eventhandled/EventHandled.java @@ -0,0 +1,47 @@ +package com.loopers.domain.eventhandled; + +import java.time.Instant; +import java.util.Objects; + +/** + * 처리된 이벤트 기록. + * 멱등성 보장을 위해 처리 완료된 이벤트 ID를 저장. + */ +public class EventHandled { + + private final Long id; + private final String eventId; + private final String eventType; + private final Instant handledAt; + + private EventHandled(Long id, String eventId, String eventType, Instant handledAt) { + this.id = id; + this.eventId = Objects.requireNonNull(eventId, "eventId must not be null"); + this.eventType = Objects.requireNonNull(eventType, "eventType must not be null"); + this.handledAt = handledAt; + } + + public static EventHandled create(String eventId, String eventType) { + return new EventHandled(null, eventId, eventType, Instant.now()); + } + + public static EventHandled reconstitute(Long id, String eventId, String eventType, Instant handledAt) { + return new EventHandled(id, eventId, eventType, handledAt); + } + + public Long getId() { + return id; + } + + public String getEventId() { + return eventId; + } + + public String getEventType() { + return eventType; + } + + public Instant getHandledAt() { + return handledAt; + } +} diff --git a/apps/commerce-collector/src/main/java/com/loopers/domain/eventhandled/EventHandledRepository.java b/apps/commerce-collector/src/main/java/com/loopers/domain/eventhandled/EventHandledRepository.java new file mode 100644 index 000000000..614aa045e --- /dev/null +++ b/apps/commerce-collector/src/main/java/com/loopers/domain/eventhandled/EventHandledRepository.java @@ -0,0 +1,11 @@ +package com.loopers.domain.eventhandled; + +/** + * EventHandled Repository 인터페이스. + */ +public interface EventHandledRepository { + + EventHandled save(EventHandled eventHandled); + + boolean existsByEventId(String eventId); +} diff --git a/apps/commerce-collector/src/main/java/com/loopers/domain/fcfs/CouponIssueRequestStatus.java b/apps/commerce-collector/src/main/java/com/loopers/domain/fcfs/CouponIssueRequestStatus.java new file mode 100644 index 000000000..0590520a0 --- /dev/null +++ b/apps/commerce-collector/src/main/java/com/loopers/domain/fcfs/CouponIssueRequestStatus.java @@ -0,0 +1,10 @@ +package com.loopers.domain.fcfs; + +/** + * 쿠폰 발급 요청 상태. + */ +public enum CouponIssueRequestStatus { + PENDING, + ISSUED, + FAILED +} diff --git a/apps/commerce-collector/src/main/java/com/loopers/domain/metrics/ProductMetrics.java b/apps/commerce-collector/src/main/java/com/loopers/domain/metrics/ProductMetrics.java new file mode 100644 index 000000000..357539225 --- /dev/null +++ b/apps/commerce-collector/src/main/java/com/loopers/domain/metrics/ProductMetrics.java @@ -0,0 +1,133 @@ +package com.loopers.domain.metrics; + +import java.time.Instant; +import java.util.Objects; + +/** + * 상품 메트릭 집계 데이터. + */ +public class ProductMetrics { + + private final Long id; + private final Long productId; + private long likeCount; + private long viewCount; + private long orderCount; + private long orderTotalAmount; + private Instant updatedAt; + private Instant lastEventTimestamp; // 마지막으로 처리된 이벤트의 타임스탬프 + + private ProductMetrics( + Long id, + Long productId, + long likeCount, + long viewCount, + long orderCount, + long orderTotalAmount, + Instant updatedAt, + Instant lastEventTimestamp + ) { + this.id = id; + this.productId = Objects.requireNonNull(productId, "productId must not be null"); + this.likeCount = likeCount; + this.viewCount = viewCount; + this.orderCount = orderCount; + this.orderTotalAmount = orderTotalAmount; + this.updatedAt = updatedAt; + this.lastEventTimestamp = lastEventTimestamp; + } + + public static ProductMetrics create(Long productId) { + return new ProductMetrics(null, productId, 0, 0, 0, 0, Instant.now(), null); + } + + public static ProductMetrics reconstitute( + Long id, + Long productId, + long likeCount, + long viewCount, + long orderCount, + long orderTotalAmount, + Instant updatedAt, + Instant lastEventTimestamp + ) { + return new ProductMetrics(id, productId, likeCount, viewCount, orderCount, orderTotalAmount, updatedAt, lastEventTimestamp); + } + + /** + * 이벤트 타임스탬프가 마지막 처리된 이벤트보다 새로운지 확인. + * @param eventTimestamp 처리할 이벤트의 타임스탬프 + * @return 이벤트가 최신이거나 처음인 경우 true + */ + public boolean isNewerEvent(Instant eventTimestamp) { + if (lastEventTimestamp == null) { + return true; + } + return eventTimestamp.isAfter(lastEventTimestamp); + } + + /** + * 마지막 이벤트 타임스탬프 업데이트. + * 현재 저장된 것보다 새로운 경우에만 업데이트. + */ + public void updateLastEventTimestamp(Instant eventTimestamp) { + if (lastEventTimestamp == null || eventTimestamp.isAfter(lastEventTimestamp)) { + this.lastEventTimestamp = eventTimestamp; + } + } + + public void incrementLikeCount() { + this.likeCount++; + this.updatedAt = Instant.now(); + } + + public void decrementLikeCount() { + if (this.likeCount > 0) { + this.likeCount--; + } + this.updatedAt = Instant.now(); + } + + public void incrementViewCount() { + this.viewCount++; + this.updatedAt = Instant.now(); + } + + public void addOrder(int quantity, long amount) { + this.orderCount += quantity; + this.orderTotalAmount += amount; + this.updatedAt = Instant.now(); + } + + public Long getId() { + return id; + } + + public Long getProductId() { + return productId; + } + + public long getLikeCount() { + return likeCount; + } + + public long getViewCount() { + return viewCount; + } + + public long getOrderCount() { + return orderCount; + } + + public long getOrderTotalAmount() { + return orderTotalAmount; + } + + public Instant getUpdatedAt() { + return updatedAt; + } + + public Instant getLastEventTimestamp() { + return lastEventTimestamp; + } +} diff --git a/apps/commerce-collector/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java b/apps/commerce-collector/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java new file mode 100644 index 000000000..77e37cdf0 --- /dev/null +++ b/apps/commerce-collector/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java @@ -0,0 +1,15 @@ +package com.loopers.domain.metrics; + +import java.util.Optional; + +/** + * ProductMetrics Repository 인터페이스. + */ +public interface ProductMetricsRepository { + + ProductMetrics save(ProductMetrics metrics); + + Optional findByProductId(Long productId); + + ProductMetrics getOrCreate(Long productId); +} diff --git a/apps/commerce-collector/src/main/java/com/loopers/infrastructure/persistence/jpa/eventhandled/EventHandledJpaEntity.java b/apps/commerce-collector/src/main/java/com/loopers/infrastructure/persistence/jpa/eventhandled/EventHandledJpaEntity.java new file mode 100644 index 000000000..b628318f5 --- /dev/null +++ b/apps/commerce-collector/src/main/java/com/loopers/infrastructure/persistence/jpa/eventhandled/EventHandledJpaEntity.java @@ -0,0 +1,66 @@ +package com.loopers.infrastructure.persistence.jpa.eventhandled; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.PrePersist; +import jakarta.persistence.Table; + +import java.time.Instant; + +/** + * 처리된 이벤트 기록 JPA 엔티티. + */ +@Entity +@Table( + name = "event_handled", + indexes = { + @Index(name = "idx_event_handled_event_id", columnList = "event_id", unique = true) + } +) +public class EventHandledJpaEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "event_id", nullable = false, unique = true, length = 36) + private String eventId; + + @Column(name = "event_type", nullable = false, length = 50) + private String eventType; + + @Column(name = "handled_at", nullable = false) + private Instant handledAt; + + protected EventHandledJpaEntity() {} + + public EventHandledJpaEntity(String eventId, String eventType) { + this.eventId = eventId; + this.eventType = eventType; + } + + @PrePersist + private void prePersist() { + this.handledAt = Instant.now(); + } + + public Long getId() { + return id; + } + + public String getEventId() { + return eventId; + } + + public String getEventType() { + return eventType; + } + + public Instant getHandledAt() { + return handledAt; + } +} diff --git a/apps/commerce-collector/src/main/java/com/loopers/infrastructure/persistence/jpa/eventhandled/EventHandledJpaRepository.java b/apps/commerce-collector/src/main/java/com/loopers/infrastructure/persistence/jpa/eventhandled/EventHandledJpaRepository.java new file mode 100644 index 000000000..66af684a6 --- /dev/null +++ b/apps/commerce-collector/src/main/java/com/loopers/infrastructure/persistence/jpa/eventhandled/EventHandledJpaRepository.java @@ -0,0 +1,11 @@ +package com.loopers.infrastructure.persistence.jpa.eventhandled; + +import org.springframework.data.jpa.repository.JpaRepository; + +/** + * EventHandled JPA Repository. + */ +public interface EventHandledJpaRepository extends JpaRepository { + + boolean existsByEventId(String eventId); +} diff --git a/apps/commerce-collector/src/main/java/com/loopers/infrastructure/persistence/jpa/eventhandled/EventHandledMapper.java b/apps/commerce-collector/src/main/java/com/loopers/infrastructure/persistence/jpa/eventhandled/EventHandledMapper.java new file mode 100644 index 000000000..7494774f0 --- /dev/null +++ b/apps/commerce-collector/src/main/java/com/loopers/infrastructure/persistence/jpa/eventhandled/EventHandledMapper.java @@ -0,0 +1,33 @@ +package com.loopers.infrastructure.persistence.jpa.eventhandled; + +import com.loopers.domain.eventhandled.EventHandled; + +/** + * EventHandled 도메인 객체와 JPA 엔티티 간 변환을 담당. + */ +public class EventHandledMapper { + + private EventHandledMapper() {} + + public static EventHandled toDomain(EventHandledJpaEntity entity) { + if (entity == null) { + return null; + } + return EventHandled.reconstitute( + entity.getId(), + entity.getEventId(), + entity.getEventType(), + entity.getHandledAt() + ); + } + + public static EventHandledJpaEntity toJpaEntity(EventHandled domain) { + if (domain == null) { + return null; + } + return new EventHandledJpaEntity( + domain.getEventId(), + domain.getEventType() + ); + } +} diff --git a/apps/commerce-collector/src/main/java/com/loopers/infrastructure/persistence/jpa/eventhandled/EventHandledRepositoryImpl.java b/apps/commerce-collector/src/main/java/com/loopers/infrastructure/persistence/jpa/eventhandled/EventHandledRepositoryImpl.java new file mode 100644 index 000000000..17ba9dacc --- /dev/null +++ b/apps/commerce-collector/src/main/java/com/loopers/infrastructure/persistence/jpa/eventhandled/EventHandledRepositoryImpl.java @@ -0,0 +1,28 @@ +package com.loopers.infrastructure.persistence.jpa.eventhandled; + +import com.loopers.domain.eventhandled.EventHandled; +import com.loopers.domain.eventhandled.EventHandledRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +/** + * EventHandledRepository 구현체. + */ +@Repository +@RequiredArgsConstructor +public class EventHandledRepositoryImpl implements EventHandledRepository { + + private final EventHandledJpaRepository jpaRepository; + + @Override + public EventHandled save(EventHandled eventHandled) { + EventHandledJpaEntity entity = EventHandledMapper.toJpaEntity(eventHandled); + EventHandledJpaEntity saved = jpaRepository.save(entity); + return EventHandledMapper.toDomain(saved); + } + + @Override + public boolean existsByEventId(String eventId) { + return jpaRepository.existsByEventId(eventId); + } +} diff --git a/apps/commerce-collector/src/main/java/com/loopers/infrastructure/persistence/jpa/fcfs/CouponIssueRequestJpaEntity.java b/apps/commerce-collector/src/main/java/com/loopers/infrastructure/persistence/jpa/fcfs/CouponIssueRequestJpaEntity.java new file mode 100644 index 000000000..8713b2a7e --- /dev/null +++ b/apps/commerce-collector/src/main/java/com/loopers/infrastructure/persistence/jpa/fcfs/CouponIssueRequestJpaEntity.java @@ -0,0 +1,110 @@ +package com.loopers.infrastructure.persistence.jpa.fcfs; + +import com.loopers.domain.fcfs.CouponIssueRequestStatus; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.PrePersist; +import jakarta.persistence.PreUpdate; +import jakarta.persistence.Table; + +import java.time.Instant; + +/** + * 쿠폰 발급 요청 JPA 엔티티 (commerce-collector용). + */ +@Entity +@Table( + name = "coupon_issue_request", + indexes = { + @Index(name = "idx_coupon_issue_request_request_id", columnList = "request_id", unique = true), + @Index(name = "idx_coupon_issue_request_coupon_status", columnList = "coupon_id, status") + } +) +public class CouponIssueRequestJpaEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "request_id", nullable = false, unique = true, length = 36) + private String requestId; + + @Column(name = "coupon_id", nullable = false) + private Long couponId; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 20) + private CouponIssueRequestStatus status; + + @Column(name = "failure_reason", length = 500) + private String failureReason; + + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + @Column(name = "updated_at", nullable = false) + private Instant updatedAt; + + protected CouponIssueRequestJpaEntity() {} + + @PrePersist + private void prePersist() { + Instant now = Instant.now(); + this.createdAt = now; + this.updatedAt = now; + } + + @PreUpdate + private void preUpdate() { + this.updatedAt = Instant.now(); + } + + public Long getId() { + return id; + } + + public String getRequestId() { + return requestId; + } + + public Long getCouponId() { + return couponId; + } + + public Long getUserId() { + return userId; + } + + public CouponIssueRequestStatus getStatus() { + return status; + } + + public void setStatus(CouponIssueRequestStatus status) { + this.status = status; + } + + public String getFailureReason() { + return failureReason; + } + + public void setFailureReason(String failureReason) { + this.failureReason = failureReason; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public Instant getUpdatedAt() { + return updatedAt; + } +} diff --git a/apps/commerce-collector/src/main/java/com/loopers/infrastructure/persistence/jpa/fcfs/CouponIssueRequestJpaRepository.java b/apps/commerce-collector/src/main/java/com/loopers/infrastructure/persistence/jpa/fcfs/CouponIssueRequestJpaRepository.java new file mode 100644 index 000000000..6ef34c6b6 --- /dev/null +++ b/apps/commerce-collector/src/main/java/com/loopers/infrastructure/persistence/jpa/fcfs/CouponIssueRequestJpaRepository.java @@ -0,0 +1,29 @@ +package com.loopers.infrastructure.persistence.jpa.fcfs; + +import com.loopers.domain.fcfs.CouponIssueRequestStatus; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.Optional; + +/** + * 쿠폰 발급 요청 JPA Repository (commerce-collector용). + */ +public interface CouponIssueRequestJpaRepository extends JpaRepository { + + Optional findByRequestId(String requestId); + + @Modifying + @Query("UPDATE CouponIssueRequestJpaEntity r SET r.status = :status, r.failureReason = :failureReason WHERE r.requestId = :requestId") + void updateStatus( + @Param("requestId") String requestId, + @Param("status") CouponIssueRequestStatus status, + @Param("failureReason") String failureReason + ); + + long countByStatus(CouponIssueRequestStatus status); + + long countByCouponIdAndStatus(Long couponId, CouponIssueRequestStatus status); +} diff --git a/apps/commerce-collector/src/main/java/com/loopers/infrastructure/persistence/jpa/fcfs/FcfsCouponJpaEntity.java b/apps/commerce-collector/src/main/java/com/loopers/infrastructure/persistence/jpa/fcfs/FcfsCouponJpaEntity.java new file mode 100644 index 000000000..a410b5e4d --- /dev/null +++ b/apps/commerce-collector/src/main/java/com/loopers/infrastructure/persistence/jpa/fcfs/FcfsCouponJpaEntity.java @@ -0,0 +1,70 @@ +package com.loopers.infrastructure.persistence.jpa.fcfs; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.PrePersist; +import jakarta.persistence.Table; + +import java.time.Instant; + +/** + * 선착순 쿠폰 JPA 엔티티 (commerce-collector용). + */ +@Entity +@Table(name = "fcfs_coupon") +public class FcfsCouponJpaEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "name", nullable = false, length = 200) + private String name; + + @Column(name = "total_quantity", nullable = false) + private int totalQuantity; + + @Column(name = "issued_count", nullable = false) + private int issuedCount; + + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + protected FcfsCouponJpaEntity() {} + + @PrePersist + private void prePersist() { + this.createdAt = Instant.now(); + } + + public Long getId() { + return id; + } + + public String getName() { + return name; + } + + public int getTotalQuantity() { + return totalQuantity; + } + + public int getIssuedCount() { + return issuedCount; + } + + public void setIssuedCount(int issuedCount) { + this.issuedCount = issuedCount; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public boolean canIssue() { + return issuedCount < totalQuantity; + } +} diff --git a/apps/commerce-collector/src/main/java/com/loopers/infrastructure/persistence/jpa/fcfs/FcfsCouponJpaRepository.java b/apps/commerce-collector/src/main/java/com/loopers/infrastructure/persistence/jpa/fcfs/FcfsCouponJpaRepository.java new file mode 100644 index 000000000..cefdf629f --- /dev/null +++ b/apps/commerce-collector/src/main/java/com/loopers/infrastructure/persistence/jpa/fcfs/FcfsCouponJpaRepository.java @@ -0,0 +1,24 @@ +package com.loopers.infrastructure.persistence.jpa.fcfs; + +import jakarta.persistence.LockModeType; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.Optional; + +/** + * 선착순 쿠폰 JPA Repository (commerce-collector용). + */ +public interface FcfsCouponJpaRepository extends JpaRepository { + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT c FROM FcfsCouponJpaEntity c WHERE c.id = :id") + Optional findByIdWithLock(@Param("id") Long id); + + @Modifying + @Query("UPDATE FcfsCouponJpaEntity c SET c.issuedCount = c.issuedCount + 1 WHERE c.id = :id") + void incrementIssuedCount(@Param("id") Long id); +} diff --git a/apps/commerce-collector/src/main/java/com/loopers/infrastructure/persistence/jpa/fcfs/FcfsIssuedCouponJpaEntity.java b/apps/commerce-collector/src/main/java/com/loopers/infrastructure/persistence/jpa/fcfs/FcfsIssuedCouponJpaEntity.java new file mode 100644 index 000000000..f8f120377 --- /dev/null +++ b/apps/commerce-collector/src/main/java/com/loopers/infrastructure/persistence/jpa/fcfs/FcfsIssuedCouponJpaEntity.java @@ -0,0 +1,70 @@ +package com.loopers.infrastructure.persistence.jpa.fcfs; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.PrePersist; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; + +import java.time.Instant; + +/** + * 발급된 선착순 쿠폰 JPA 엔티티 (commerce-collector용). + */ +@Entity +@Table( + name = "fcfs_issued_coupon", + uniqueConstraints = { + @UniqueConstraint(columnNames = {"coupon_id", "user_id"}) + }, + indexes = { + @Index(name = "idx_fcfs_issued_coupon_coupon_id", columnList = "coupon_id") + } +) +public class FcfsIssuedCouponJpaEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "coupon_id", nullable = false) + private Long couponId; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Column(name = "issued_at", nullable = false, updatable = false) + private Instant issuedAt; + + protected FcfsIssuedCouponJpaEntity() {} + + public FcfsIssuedCouponJpaEntity(Long couponId, Long userId) { + this.couponId = couponId; + this.userId = userId; + } + + @PrePersist + private void prePersist() { + this.issuedAt = Instant.now(); + } + + public Long getId() { + return id; + } + + public Long getCouponId() { + return couponId; + } + + public Long getUserId() { + return userId; + } + + public Instant getIssuedAt() { + return issuedAt; + } +} diff --git a/apps/commerce-collector/src/main/java/com/loopers/infrastructure/persistence/jpa/fcfs/FcfsIssuedCouponJpaRepository.java b/apps/commerce-collector/src/main/java/com/loopers/infrastructure/persistence/jpa/fcfs/FcfsIssuedCouponJpaRepository.java new file mode 100644 index 000000000..fae425a82 --- /dev/null +++ b/apps/commerce-collector/src/main/java/com/loopers/infrastructure/persistence/jpa/fcfs/FcfsIssuedCouponJpaRepository.java @@ -0,0 +1,13 @@ +package com.loopers.infrastructure.persistence.jpa.fcfs; + +import org.springframework.data.jpa.repository.JpaRepository; + +/** + * 발급된 선착순 쿠폰 JPA Repository (commerce-collector용). + */ +public interface FcfsIssuedCouponJpaRepository extends JpaRepository { + + boolean existsByCouponIdAndUserId(Long couponId, Long userId); + + long countByCouponId(Long couponId); +} diff --git a/apps/commerce-collector/src/main/java/com/loopers/infrastructure/persistence/jpa/metrics/ProductMetricsJpaEntity.java b/apps/commerce-collector/src/main/java/com/loopers/infrastructure/persistence/jpa/metrics/ProductMetricsJpaEntity.java new file mode 100644 index 000000000..ed1344df3 --- /dev/null +++ b/apps/commerce-collector/src/main/java/com/loopers/infrastructure/persistence/jpa/metrics/ProductMetricsJpaEntity.java @@ -0,0 +1,119 @@ +package com.loopers.infrastructure.persistence.jpa.metrics; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.PrePersist; +import jakarta.persistence.PreUpdate; +import jakarta.persistence.Table; + +import java.time.Instant; + +/** + * 상품 메트릭 JPA 엔티티. + */ +@Entity +@Table( + name = "product_metrics", + indexes = { + @Index(name = "idx_product_metrics_product_id", columnList = "product_id", unique = true) + } +) +public class ProductMetricsJpaEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "product_id", nullable = false, unique = true) + private Long productId; + + @Column(name = "like_count", nullable = false) + private long likeCount; + + @Column(name = "view_count", nullable = false) + private long viewCount; + + @Column(name = "order_count", nullable = false) + private long orderCount; + + @Column(name = "order_total_amount", nullable = false) + private long orderTotalAmount; + + @Column(name = "updated_at", nullable = false) + private Instant updatedAt; + + @Column(name = "last_event_timestamp") + private Instant lastEventTimestamp; + + protected ProductMetricsJpaEntity() {} + + public ProductMetricsJpaEntity(Long productId) { + this.productId = productId; + this.likeCount = 0; + this.viewCount = 0; + this.orderCount = 0; + this.orderTotalAmount = 0; + } + + @PrePersist + @PreUpdate + private void prePersistOrUpdate() { + this.updatedAt = Instant.now(); + } + + public Long getId() { + return id; + } + + public Long getProductId() { + return productId; + } + + public long getLikeCount() { + return likeCount; + } + + public void setLikeCount(long likeCount) { + this.likeCount = likeCount; + } + + public long getViewCount() { + return viewCount; + } + + public void setViewCount(long viewCount) { + this.viewCount = viewCount; + } + + public long getOrderCount() { + return orderCount; + } + + public void setOrderCount(long orderCount) { + this.orderCount = orderCount; + } + + public long getOrderTotalAmount() { + return orderTotalAmount; + } + + public void setOrderTotalAmount(long orderTotalAmount) { + this.orderTotalAmount = orderTotalAmount; + } + + public Instant getUpdatedAt() { + return updatedAt; + } + + public Instant getLastEventTimestamp() { + return lastEventTimestamp; + } + + public void setLastEventTimestamp(Instant lastEventTimestamp) { + this.lastEventTimestamp = lastEventTimestamp; + } +} diff --git a/apps/commerce-collector/src/main/java/com/loopers/infrastructure/persistence/jpa/metrics/ProductMetricsJpaRepository.java b/apps/commerce-collector/src/main/java/com/loopers/infrastructure/persistence/jpa/metrics/ProductMetricsJpaRepository.java new file mode 100644 index 000000000..6b8ed7434 --- /dev/null +++ b/apps/commerce-collector/src/main/java/com/loopers/infrastructure/persistence/jpa/metrics/ProductMetricsJpaRepository.java @@ -0,0 +1,13 @@ +package com.loopers.infrastructure.persistence.jpa.metrics; + +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +/** + * ProductMetrics JPA Repository. + */ +public interface ProductMetricsJpaRepository extends JpaRepository { + + Optional findByProductId(Long productId); +} diff --git a/apps/commerce-collector/src/main/java/com/loopers/infrastructure/persistence/jpa/metrics/ProductMetricsMapper.java b/apps/commerce-collector/src/main/java/com/loopers/infrastructure/persistence/jpa/metrics/ProductMetricsMapper.java new file mode 100644 index 000000000..075108bd8 --- /dev/null +++ b/apps/commerce-collector/src/main/java/com/loopers/infrastructure/persistence/jpa/metrics/ProductMetricsMapper.java @@ -0,0 +1,35 @@ +package com.loopers.infrastructure.persistence.jpa.metrics; + +import com.loopers.domain.metrics.ProductMetrics; + +/** + * ProductMetrics 도메인 객체와 JPA 엔티티 간 변환을 담당. + */ +public class ProductMetricsMapper { + + private ProductMetricsMapper() {} + + public static ProductMetrics toDomain(ProductMetricsJpaEntity entity) { + if (entity == null) { + return null; + } + return ProductMetrics.reconstitute( + entity.getId(), + entity.getProductId(), + entity.getLikeCount(), + entity.getViewCount(), + entity.getOrderCount(), + entity.getOrderTotalAmount(), + entity.getUpdatedAt(), + entity.getLastEventTimestamp() + ); + } + + public static void updateEntity(ProductMetricsJpaEntity entity, ProductMetrics domain) { + entity.setLikeCount(domain.getLikeCount()); + entity.setViewCount(domain.getViewCount()); + entity.setOrderCount(domain.getOrderCount()); + entity.setOrderTotalAmount(domain.getOrderTotalAmount()); + entity.setLastEventTimestamp(domain.getLastEventTimestamp()); + } +} diff --git a/apps/commerce-collector/src/main/java/com/loopers/infrastructure/persistence/jpa/metrics/ProductMetricsRepositoryImpl.java b/apps/commerce-collector/src/main/java/com/loopers/infrastructure/persistence/jpa/metrics/ProductMetricsRepositoryImpl.java new file mode 100644 index 000000000..d42e0de9b --- /dev/null +++ b/apps/commerce-collector/src/main/java/com/loopers/infrastructure/persistence/jpa/metrics/ProductMetricsRepositoryImpl.java @@ -0,0 +1,45 @@ +package com.loopers.infrastructure.persistence.jpa.metrics; + +import com.loopers.domain.metrics.ProductMetrics; +import com.loopers.domain.metrics.ProductMetricsRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +/** + * ProductMetricsRepository 구현체. + */ +@Repository +@RequiredArgsConstructor +public class ProductMetricsRepositoryImpl implements ProductMetricsRepository { + + private final ProductMetricsJpaRepository jpaRepository; + + @Override + public ProductMetrics save(ProductMetrics metrics) { + ProductMetricsJpaEntity entity = jpaRepository.findByProductId(metrics.getProductId()) + .orElseGet(() -> new ProductMetricsJpaEntity(metrics.getProductId())); + + ProductMetricsMapper.updateEntity(entity, metrics); + ProductMetricsJpaEntity saved = jpaRepository.save(entity); + return ProductMetricsMapper.toDomain(saved); + } + + @Override + public Optional findByProductId(Long productId) { + return jpaRepository.findByProductId(productId) + .map(ProductMetricsMapper::toDomain); + } + + @Override + public ProductMetrics getOrCreate(Long productId) { + return jpaRepository.findByProductId(productId) + .map(ProductMetricsMapper::toDomain) + .orElseGet(() -> { + ProductMetricsJpaEntity entity = new ProductMetricsJpaEntity(productId); + ProductMetricsJpaEntity saved = jpaRepository.save(entity); + return ProductMetricsMapper.toDomain(saved); + }); + } +} diff --git a/apps/commerce-collector/src/main/resources/application.yml b/apps/commerce-collector/src/main/resources/application.yml new file mode 100644 index 000000000..a282f66bb --- /dev/null +++ b/apps/commerce-collector/src/main/resources/application.yml @@ -0,0 +1,50 @@ +server: + shutdown: graceful + port: 8082 + tomcat: + threads: + max: 200 + min-spare: 10 + connection-timeout: 1m + max-connections: 8192 + accept-count: 100 + keep-alive-timeout: 60s + max-http-request-header-size: 8KB + +spring: + main: + web-application-type: servlet + application: + name: commerce-collector + profiles: + active: local + config: + import: + - jpa.yml + - kafka.yml + - logging.yml + - monitoring.yml + +--- +spring: + config: + activate: + on-profile: local, test + +--- +spring: + config: + activate: + on-profile: dev + +--- +spring: + config: + activate: + on-profile: qa + +--- +spring: + config: + activate: + on-profile: prd diff --git a/apps/commerce-collector/src/test/java/com/loopers/application/consumer/MetricsEventConsumerTest.java b/apps/commerce-collector/src/test/java/com/loopers/application/consumer/MetricsEventConsumerTest.java new file mode 100644 index 000000000..83b4dc8f6 --- /dev/null +++ b/apps/commerce-collector/src/test/java/com/loopers/application/consumer/MetricsEventConsumerTest.java @@ -0,0 +1,181 @@ +package com.loopers.application.consumer; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.domain.eventhandled.EventHandled; +import com.loopers.domain.metrics.ProductMetrics; +import com.loopers.event.AggregateType; +import com.loopers.event.EventEnvelope; +import com.loopers.event.EventType; +import com.loopers.fake.FakeEventHandledRepository; +import com.loopers.fake.FakeProductMetricsRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("MetricsEventConsumer 테스트") +class MetricsEventConsumerTest { + + private FakeEventHandledRepository eventHandledRepository; + private FakeProductMetricsRepository productMetricsRepository; + private ObjectMapper objectMapper; + private MetricsEventConsumer consumer; + + @BeforeEach + void setUp() { + eventHandledRepository = new FakeEventHandledRepository(); + productMetricsRepository = new FakeProductMetricsRepository(); + objectMapper = new ObjectMapper(); + objectMapper.findAndRegisterModules(); + consumer = new MetricsEventConsumer(eventHandledRepository, productMetricsRepository, objectMapper); + } + + private EventEnvelope createEnvelope(EventType eventType, AggregateType aggregateType, String aggregateId, String payload) { + return new EventEnvelope( + UUID.randomUUID().toString(), + eventType.name(), + aggregateType.name(), + aggregateId, + Instant.now(), + payload + ); + } + + @Nested + @DisplayName("LIKE_CREATED 이벤트 처리") + class LikeCreated { + + @Test + @DisplayName("성공 - 좋아요 카운트 증가") + void 좋아요_카운트_증가() { + // Arrange + String payload = "{\"likeId\":1,\"userId\":100,\"productId\":200}"; + EventEnvelope envelope = createEnvelope(EventType.LIKE_CREATED, AggregateType.PRODUCT, "200", payload); + + // Act + consumer.processEvent(envelope); + + // Assert + Optional metrics = productMetricsRepository.findByProductId(200L); + assertThat(metrics).isPresent(); + assertThat(metrics.get().getLikeCount()).isEqualTo(1); + } + + @Test + @DisplayName("성공 - 처리 완료 기록") + void 처리_완료_기록() { + // Arrange + String payload = "{\"likeId\":1,\"userId\":100,\"productId\":200}"; + EventEnvelope envelope = createEnvelope(EventType.LIKE_CREATED, AggregateType.PRODUCT, "200", payload); + + // Act + consumer.processEvent(envelope); + + // Assert + List handled = eventHandledRepository.findAll(); + assertThat(handled).hasSize(1); + assertThat(handled.get(0).getEventId()).isEqualTo(envelope.eventId()); + } + + @Test + @DisplayName("멱등성 - 중복 이벤트는 무시") + void 중복_이벤트_무시() { + // Arrange + String eventId = UUID.randomUUID().toString(); + String payload = "{\"likeId\":1,\"userId\":100,\"productId\":200}"; + EventEnvelope envelope = new EventEnvelope( + eventId, + EventType.LIKE_CREATED.name(), + AggregateType.PRODUCT.name(), + "200", + Instant.now(), + payload + ); + + // 첫 번째 처리 + consumer.processEvent(envelope); + + // Act - 동일한 이벤트 다시 처리 + consumer.processEvent(envelope); + + // Assert - 카운트는 1이어야 함 + Optional metrics = productMetricsRepository.findByProductId(200L); + assertThat(metrics).isPresent(); + assertThat(metrics.get().getLikeCount()).isEqualTo(1); + } + } + + @Nested + @DisplayName("LIKE_CANCELED 이벤트 처리") + class LikeCanceled { + + @Test + @DisplayName("성공 - 좋아요 카운트 감소") + void 좋아요_카운트_감소() { + // Arrange - 먼저 좋아요 추가 + String createPayload = "{\"likeId\":1,\"userId\":100,\"productId\":200}"; + EventEnvelope createEnvelope = createEnvelope(EventType.LIKE_CREATED, AggregateType.PRODUCT, "200", createPayload); + consumer.processEvent(createEnvelope); + + // Act - 좋아요 취소 + String cancelPayload = "{\"likeId\":1,\"userId\":100,\"productId\":200}"; + EventEnvelope cancelEnvelope = createEnvelope(EventType.LIKE_CANCELED, AggregateType.PRODUCT, "200", cancelPayload); + consumer.processEvent(cancelEnvelope); + + // Assert + Optional metrics = productMetricsRepository.findByProductId(200L); + assertThat(metrics).isPresent(); + assertThat(metrics.get().getLikeCount()).isEqualTo(0); + } + } + + @Nested + @DisplayName("PRODUCT_VIEWED 이벤트 처리") + class ProductViewed { + + @Test + @DisplayName("성공 - 조회 카운트 증가") + void 조회_카운트_증가() { + // Arrange + String payload = "{\"userId\":100,\"productId\":200}"; + EventEnvelope envelope = createEnvelope(EventType.PRODUCT_VIEWED, AggregateType.PRODUCT, "200", payload); + + // Act + consumer.processEvent(envelope); + + // Assert + Optional metrics = productMetricsRepository.findByProductId(200L); + assertThat(metrics).isPresent(); + assertThat(metrics.get().getViewCount()).isEqualTo(1); + } + } + + @Nested + @DisplayName("ORDER_COMPLETED 이벤트 처리") + class OrderCompleted { + + @Test + @DisplayName("성공 - 주문 카운트 및 금액 증가") + void 주문_카운트_금액_증가() { + // Arrange + String payload = "{\"orderId\":1,\"userId\":100,\"productId\":200,\"quantity\":3,\"totalAmount\":30000}"; + EventEnvelope envelope = createEnvelope(EventType.ORDER_COMPLETED, AggregateType.ORDER, "1", payload); + + // Act + consumer.processEvent(envelope); + + // Assert + Optional metrics = productMetricsRepository.findByProductId(200L); + assertThat(metrics).isPresent(); + assertThat(metrics.get().getOrderCount()).isEqualTo(3); + assertThat(metrics.get().getOrderTotalAmount()).isEqualTo(30000); + } + } +} diff --git a/apps/commerce-collector/src/test/java/com/loopers/concurrency/FcfsCouponIssueConcurrencyTest.java b/apps/commerce-collector/src/test/java/com/loopers/concurrency/FcfsCouponIssueConcurrencyTest.java new file mode 100644 index 000000000..19be0bc18 --- /dev/null +++ b/apps/commerce-collector/src/test/java/com/loopers/concurrency/FcfsCouponIssueConcurrencyTest.java @@ -0,0 +1,217 @@ +package com.loopers.concurrency; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.application.consumer.CouponIssueConsumer; +import com.loopers.domain.fcfs.CouponIssueRequestStatus; +import com.loopers.event.AggregateType; +import com.loopers.event.CouponIssueRequestedEvent; +import com.loopers.event.EventEnvelope; +import com.loopers.event.EventType; +import com.loopers.infrastructure.persistence.jpa.fcfs.CouponIssueRequestJpaEntity; +import com.loopers.infrastructure.persistence.jpa.fcfs.CouponIssueRequestJpaRepository; +import com.loopers.infrastructure.persistence.jpa.fcfs.FcfsCouponJpaEntity; +import com.loopers.infrastructure.persistence.jpa.fcfs.FcfsCouponJpaRepository; +import com.loopers.infrastructure.persistence.jpa.fcfs.FcfsIssuedCouponJpaRepository; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; + +import java.time.Instant; +import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@DisplayName("선착순 쿠폰 발급 동시성 테스트") +class FcfsCouponIssueConcurrencyTest { + + @Autowired + private CouponIssueConsumer couponIssueConsumer; + + @Autowired + private FcfsCouponJpaRepository fcfsCouponJpaRepository; + + @Autowired + private FcfsIssuedCouponJpaRepository fcfsIssuedCouponJpaRepository; + + @Autowired + private CouponIssueRequestJpaRepository couponIssueRequestJpaRepository; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @Autowired + private JdbcTemplate jdbcTemplate; + + private Long couponId; + + @BeforeEach + void setUp() { + // 100개 수량의 선착순 쿠폰 생성 + couponId = insertFcfsCoupon("선착순 100명 쿠폰", 100, 0); + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @Test + @DisplayName("선착순 100명 쿠폰 발급 시 수량 초과가 발생하지 않는다") + void 선착순_100명_쿠폰_발급_시_수량_초과가_발생하지_않는다() throws Exception { + // Arrange: 150명의 요청 준비 + int requestCount = 150; + int totalQuantity = 100; + + // 150개의 발급 요청 생성 (각기 다른 userId) + for (int i = 1; i <= requestCount; i++) { + String requestId = "request-" + i; + insertCouponIssueRequest(requestId, couponId, (long) i); + } + + ExecutorService executor = Executors.newFixedThreadPool(50); + CountDownLatch latch = new CountDownLatch(requestCount); + AtomicInteger successCount = new AtomicInteger(); + AtomicInteger failCount = new AtomicInteger(); + + // Act: 동시에 150개의 이벤트 처리 + for (int i = 1; i <= requestCount; i++) { + final long userId = i; + final String requestId = "request-" + userId; + executor.submit(() -> { + try { + EventEnvelope envelope = createEventEnvelope(requestId, couponId, userId); + couponIssueConsumer.processEvent(envelope); + + // 발급 결과 확인 + CouponIssueRequestJpaEntity result = couponIssueRequestJpaRepository + .findByRequestId(requestId).orElseThrow(); + if (result.getStatus() == CouponIssueRequestStatus.ISSUED) { + successCount.incrementAndGet(); + } else { + failCount.incrementAndGet(); + } + } catch (Exception e) { + failCount.incrementAndGet(); + } finally { + latch.countDown(); + } + }); + } + latch.await(); + executor.shutdown(); + + // Assert: 정확히 100명만 발급 성공 + assertThat(successCount.get()).isEqualTo(totalQuantity); + assertThat(failCount.get()).isEqualTo(requestCount - totalQuantity); + + // 발급된 쿠폰 수 확인 + long issuedCount = fcfsIssuedCouponJpaRepository.countByCouponId(couponId); + assertThat(issuedCount).isEqualTo(totalQuantity); + + // 쿠폰의 issued_count 확인 + FcfsCouponJpaEntity updatedCoupon = fcfsCouponJpaRepository.findById(couponId).orElseThrow(); + assertThat(updatedCoupon.getIssuedCount()).isEqualTo(totalQuantity); + } + + @Test + @DisplayName("같은 유저가 중복 발급 요청하면 한 번만 발급된다") + void 같은_유저가_중복_발급_요청하면_한_번만_발급된다() throws Exception { + // Arrange: 같은 userId로 10개의 중복 요청 생성 + Long userId = 1L; + int duplicateRequestCount = 10; + + for (int i = 1; i <= duplicateRequestCount; i++) { + String requestId = "duplicate-request-" + i; + insertCouponIssueRequest(requestId, couponId, userId); + } + + ExecutorService executor = Executors.newFixedThreadPool(10); + CountDownLatch latch = new CountDownLatch(duplicateRequestCount); + AtomicInteger successCount = new AtomicInteger(); + AtomicInteger failCount = new AtomicInteger(); + + // Act: 동시에 10개의 중복 요청 처리 + for (int i = 1; i <= duplicateRequestCount; i++) { + final String requestId = "duplicate-request-" + i; + executor.submit(() -> { + try { + EventEnvelope envelope = createEventEnvelope(requestId, couponId, userId); + couponIssueConsumer.processEvent(envelope); + + // 발급 결과 확인 + CouponIssueRequestJpaEntity result = couponIssueRequestJpaRepository + .findByRequestId(requestId).orElseThrow(); + if (result.getStatus() == CouponIssueRequestStatus.ISSUED) { + successCount.incrementAndGet(); + } else { + failCount.incrementAndGet(); + } + } catch (Exception e) { + failCount.incrementAndGet(); + } finally { + latch.countDown(); + } + }); + } + latch.await(); + executor.shutdown(); + + // Assert: 1번만 발급 성공 + assertThat(successCount.get()).isEqualTo(1); + assertThat(failCount.get()).isEqualTo(duplicateRequestCount - 1); + + // 발급된 쿠폰 수 확인 + long issuedCount = fcfsIssuedCouponJpaRepository.countByCouponId(couponId); + assertThat(issuedCount).isEqualTo(1); + + // 쿠폰의 issued_count 확인 + FcfsCouponJpaEntity updatedCoupon = fcfsCouponJpaRepository.findById(couponId).orElseThrow(); + assertThat(updatedCoupon.getIssuedCount()).isEqualTo(1); + } + + private Long insertFcfsCoupon(String name, int totalQuantity, int issuedCount) { + jdbcTemplate.update( + "INSERT INTO fcfs_coupon (name, total_quantity, issued_count, created_at) VALUES (?, ?, ?, NOW())", + name, totalQuantity, issuedCount + ); + return jdbcTemplate.queryForObject("SELECT LAST_INSERT_ID()", Long.class); + } + + private void insertCouponIssueRequest(String requestId, Long couponId, Long userId) { + jdbcTemplate.update( + "INSERT INTO coupon_issue_request (request_id, coupon_id, user_id, status, created_at, updated_at) " + + "VALUES (?, ?, ?, 'PENDING', NOW(), NOW())", + requestId, couponId, userId + ); + } + + private EventEnvelope createEventEnvelope(String requestId, Long couponId, Long userId) throws Exception { + String eventId = UUID.randomUUID().toString(); + CouponIssueRequestedEvent event = CouponIssueRequestedEvent.of( + eventId, requestId, couponId, userId + ); + String payload = objectMapper.writeValueAsString(event); + + return new EventEnvelope( + eventId, + EventType.COUPON_ISSUE_REQUESTED.name(), + AggregateType.COUPON.name(), + String.valueOf(couponId), + Instant.now(), + payload + ); + } +} diff --git a/apps/commerce-collector/src/test/java/com/loopers/fake/FakeEventHandledRepository.java b/apps/commerce-collector/src/test/java/com/loopers/fake/FakeEventHandledRepository.java new file mode 100644 index 000000000..a6ebfcb00 --- /dev/null +++ b/apps/commerce-collector/src/test/java/com/loopers/fake/FakeEventHandledRepository.java @@ -0,0 +1,44 @@ +package com.loopers.fake; + +import com.loopers.domain.eventhandled.EventHandled; +import com.loopers.domain.eventhandled.EventHandledRepository; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicLong; + +/** + * 테스트용 Fake EventHandledRepository. + */ +public class FakeEventHandledRepository implements EventHandledRepository { + + private final List store = new ArrayList<>(); + private final AtomicLong idGenerator = new AtomicLong(0); + + @Override + public EventHandled save(EventHandled eventHandled) { + Long newId = idGenerator.incrementAndGet(); + EventHandled saved = EventHandled.reconstitute( + newId, + eventHandled.getEventId(), + eventHandled.getEventType(), + eventHandled.getHandledAt() + ); + store.add(saved); + return saved; + } + + @Override + public boolean existsByEventId(String eventId) { + return store.stream().anyMatch(e -> e.getEventId().equals(eventId)); + } + + public List findAll() { + return new ArrayList<>(store); + } + + public void clear() { + store.clear(); + idGenerator.set(0); + } +} diff --git a/apps/commerce-collector/src/test/java/com/loopers/fake/FakeProductMetricsRepository.java b/apps/commerce-collector/src/test/java/com/loopers/fake/FakeProductMetricsRepository.java new file mode 100644 index 000000000..5ed649b08 --- /dev/null +++ b/apps/commerce-collector/src/test/java/com/loopers/fake/FakeProductMetricsRepository.java @@ -0,0 +1,54 @@ +package com.loopers.fake; + +import com.loopers.domain.metrics.ProductMetrics; +import com.loopers.domain.metrics.ProductMetricsRepository; + +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicLong; + +/** + * 테스트용 Fake ProductMetricsRepository. + */ +public class FakeProductMetricsRepository implements ProductMetricsRepository { + + private final Map store = new HashMap<>(); + private final AtomicLong idGenerator = new AtomicLong(0); + + @Override + public ProductMetrics save(ProductMetrics metrics) { + Long id = metrics.getId() != null ? metrics.getId() : idGenerator.incrementAndGet(); + ProductMetrics saved = ProductMetrics.reconstitute( + id, + metrics.getProductId(), + metrics.getLikeCount(), + metrics.getViewCount(), + metrics.getOrderCount(), + metrics.getOrderTotalAmount(), + metrics.getUpdatedAt(), + metrics.getLastEventTimestamp() + ); + store.put(metrics.getProductId(), saved); + return saved; + } + + @Override + public Optional findByProductId(Long productId) { + return Optional.ofNullable(store.get(productId)); + } + + @Override + public ProductMetrics getOrCreate(Long productId) { + return findByProductId(productId) + .orElseGet(() -> { + ProductMetrics metrics = ProductMetrics.create(productId); + return save(metrics); + }); + } + + public void clear() { + store.clear(); + idGenerator.set(0); + } +} diff --git a/apps/commerce-collector/src/test/resources/application-test.yml b/apps/commerce-collector/src/test/resources/application-test.yml new file mode 100644 index 000000000..6fee01b22 --- /dev/null +++ b/apps/commerce-collector/src/test/resources/application-test.yml @@ -0,0 +1,7 @@ +spring: + config: + import: + - jpa.yml + - kafka.yml + - logging.yml + - monitoring.yml diff --git a/build.gradle.kts b/build.gradle.kts index 9c8490b8a..e41fc218a 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -63,6 +63,7 @@ subprojects { testImplementation("com.ninja-squad:springmockk:${project.properties["springMockkVersion"]}") testImplementation("org.mockito:mockito-core:${project.properties["mockitoVersion"]}") testImplementation("org.instancio:instancio-junit:${project.properties["instancioJUnitVersion"]}") + testImplementation("org.awaitility:awaitility:4.2.0") // Testcontainers testImplementation("org.springframework.boot:spring-boot-testcontainers") testImplementation("org.testcontainers:testcontainers") diff --git a/modules/kafka/src/main/resources/kafka.yml b/modules/kafka/src/main/resources/kafka.yml index 9609dbf85..c24c3a77a 100644 --- a/modules/kafka/src/main/resources/kafka.yml +++ b/modules/kafka/src/main/resources/kafka.yml @@ -14,7 +14,11 @@ spring: producer: key-serializer: org.apache.kafka.common.serialization.StringSerializer value-serializer: org.springframework.kafka.support.serializer.JsonSerializer + acks: all # 모든 replica 확인 후 응답 (데이터 내구성 보장) retries: 3 + properties: + enable.idempotence: true # 중복 메시지 방지 (정확히 한 번 전송 보장) + max.in.flight.requests.per.connection: 5 # idempotence와 함께 권장되는 설정 consumer: group-id: loopers-default-consumer key-deserializer: org.apache.kafka.common.serialization.StringDeserializer diff --git a/settings.gradle.kts b/settings.gradle.kts index a2c303835..a1eab059e 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -4,12 +4,14 @@ include( ":apps:commerce-api", ":apps:commerce-streamer", ":apps:commerce-batch", + ":apps:commerce-collector", ":modules:jpa", ":modules:redis", ":modules:kafka", ":supports:jackson", ":supports:logging", ":supports:monitoring", + ":supports:kafka-events", ) // configurations diff --git a/supports/kafka-events/build.gradle.kts b/supports/kafka-events/build.gradle.kts new file mode 100644 index 000000000..f3b3aeb80 --- /dev/null +++ b/supports/kafka-events/build.gradle.kts @@ -0,0 +1,7 @@ +plugins { + `java-library` +} + +dependencies { + implementation(project(":supports:jackson")) +} diff --git a/supports/kafka-events/src/main/java/com/loopers/event/AggregateType.java b/supports/kafka-events/src/main/java/com/loopers/event/AggregateType.java new file mode 100644 index 000000000..deca72ece --- /dev/null +++ b/supports/kafka-events/src/main/java/com/loopers/event/AggregateType.java @@ -0,0 +1,11 @@ +package com.loopers.event; + +/** + * 이벤트 대상 집계 타입. + */ +public enum AggregateType { + LIKE, + ORDER, + PRODUCT, + COUPON +} diff --git a/supports/kafka-events/src/main/java/com/loopers/event/CouponIssueRequestedEvent.java b/supports/kafka-events/src/main/java/com/loopers/event/CouponIssueRequestedEvent.java new file mode 100644 index 000000000..c3e2d0bf3 --- /dev/null +++ b/supports/kafka-events/src/main/java/com/loopers/event/CouponIssueRequestedEvent.java @@ -0,0 +1,31 @@ +package com.loopers.event; + +import java.time.Instant; + +/** + * 쿠폰 발급 요청 이벤트. + * Producer/Consumer 간 공유되는 이벤트 DTO. + */ +public record CouponIssueRequestedEvent( + String eventId, + String requestId, + Long couponId, + Long userId, + Instant occurredAt +) { + + public static CouponIssueRequestedEvent of( + String eventId, + String requestId, + Long couponId, + Long userId + ) { + return new CouponIssueRequestedEvent( + eventId, + requestId, + couponId, + userId, + Instant.now() + ); + } +} diff --git a/supports/kafka-events/src/main/java/com/loopers/event/EventEnvelope.java b/supports/kafka-events/src/main/java/com/loopers/event/EventEnvelope.java new file mode 100644 index 000000000..2a21ef9d8 --- /dev/null +++ b/supports/kafka-events/src/main/java/com/loopers/event/EventEnvelope.java @@ -0,0 +1,41 @@ +package com.loopers.event; + +import java.time.Instant; + +/** + * Kafka 메시지 전송을 위한 이벤트 봉투. + * Producer/Consumer 간 공유되는 이벤트 DTO. + * + * @param eventId 이벤트 고유 ID (UUID, 멱등성 키) + * @param eventType 이벤트 타입 (LIKE_CREATED, ORDER_COMPLETED 등) + * @param aggregateType 집계 타입 (LIKE, ORDER, PRODUCT) + * @param aggregateId 대상 엔티티 ID + * @param timestamp 이벤트 발생 시각 + * @param payload JSON 직렬화된 이벤트 데이터 + */ +public record EventEnvelope( + String eventId, + String eventType, + String aggregateType, + String aggregateId, + Instant timestamp, + String payload +) { + + public static EventEnvelope of( + String eventId, + String eventType, + String aggregateType, + String aggregateId, + String payload + ) { + return new EventEnvelope( + eventId, + eventType, + aggregateType, + aggregateId, + Instant.now(), + payload + ); + } +} diff --git a/supports/kafka-events/src/main/java/com/loopers/event/EventType.java b/supports/kafka-events/src/main/java/com/loopers/event/EventType.java new file mode 100644 index 000000000..3969abef0 --- /dev/null +++ b/supports/kafka-events/src/main/java/com/loopers/event/EventType.java @@ -0,0 +1,12 @@ +package com.loopers.event; + +/** + * 시스템 간 전파되는 이벤트 타입. + */ +public enum EventType { + LIKE_CREATED, + LIKE_CANCELED, + ORDER_COMPLETED, + PRODUCT_VIEWED, + COUPON_ISSUE_REQUESTED +} diff --git a/supports/kafka-events/src/main/java/com/loopers/event/KafkaTopics.java b/supports/kafka-events/src/main/java/com/loopers/event/KafkaTopics.java new file mode 100644 index 000000000..14786eab7 --- /dev/null +++ b/supports/kafka-events/src/main/java/com/loopers/event/KafkaTopics.java @@ -0,0 +1,13 @@ +package com.loopers.event; + +/** + * Kafka 토픽 이름 상수. + */ +public final class KafkaTopics { + + private KafkaTopics() {} + + public static final String CATALOG_EVENTS = "catalog-events"; + public static final String ORDER_EVENTS = "order-events"; + public static final String COUPON_ISSUE_REQUESTS = "coupon-issue-requests"; +}