-
Notifications
You must be signed in to change notification settings - Fork 44
[VOLUME-10] Spring Batch 를 이용한 주간, 월간 랭킹 구현 - 조용민 #391
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: ymcho
Are you sure you want to change the base?
Changes from all commits
dc52f3e
3dacca2
94abd3d
24b2722
6d467af
2551073
bf1d672
d415f39
1af5bd9
256b46b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -13,19 +13,36 @@ | |
| import com.loopers.support.error.ErrorType; | ||
| import lombok.RequiredArgsConstructor; | ||
| import lombok.SneakyThrows; | ||
| import lombok.extern.slf4j.Slf4j; | ||
| import org.springframework.kafka.core.KafkaTemplate; | ||
| import org.springframework.stereotype.Component; | ||
| import org.springframework.transaction.annotation.Transactional; | ||
| import org.springframework.transaction.support.TransactionSynchronization; | ||
| import org.springframework.transaction.support.TransactionSynchronizationManager; | ||
|
|
||
| import java.time.Instant; | ||
| import java.util.UUID; | ||
|
|
||
| /** | ||
| * 실험용 플래그 | ||
| * BYPASS_OUTBOX=true → Outbox 없이 TX commit 후 직접 Kafka 발행 | ||
| * CRASH_BEFORE_SEND=true → TX commit 완료 후, Kafka send 직전 크래시 시뮬레이션 → 이벤트 유실 재현 | ||
| */ | ||
| @Slf4j | ||
| @RequiredArgsConstructor | ||
| @Component | ||
| public class CouponService { | ||
|
|
||
| private static final boolean BYPASS_OUTBOX = | ||
| Boolean.parseBoolean(System.getenv().getOrDefault("BYPASS_OUTBOX", "false")); | ||
|
|
||
| private static final boolean CRASH_BEFORE_SEND = | ||
| Boolean.parseBoolean(System.getenv().getOrDefault("CRASH_BEFORE_SEND", "false")); | ||
|
|
||
| private final CouponTemplateRepository couponTemplateRepository; | ||
| private final IssuedCouponRepository issuedCouponRepository; | ||
| private final OutboxEventRepository outboxEventRepository; | ||
| private final KafkaTemplate<Object, Object> kafkaTemplate; | ||
| private final ObjectMapper objectMapper; | ||
|
|
||
| // 기존 동기 발급 (주문 플로우에서 사용) | ||
|
|
@@ -53,6 +70,29 @@ public CouponInfo.IssuedCouponInfo issue(Long memberId, Long couponTemplateId) { | |
| public String requestIssue(Long memberId, Long couponTemplateId) { | ||
| String eventId = UUID.randomUUID().toString(); | ||
| CouponIssueRequestEvent event = new CouponIssueRequestEvent(eventId, memberId, couponTemplateId, Instant.now().toEpochMilli()); | ||
|
|
||
| if (BYPASS_OUTBOX) { | ||
| // [실험 3] Outbox 없이 직접 발행 — TX commit 후 afterCommit()에서 Kafka 발행 시도 | ||
| // DB commit과 Kafka send가 원자적이지 않음 → 그 사이 크래시 시 이벤트 유실 | ||
| String payload = objectMapper.writeValueAsString(event); | ||
| TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { | ||
| @Override | ||
| public void afterCommit() { | ||
| if (CRASH_BEFORE_SEND) { | ||
| log.error("[Bypass] CRASH_BEFORE_SEND — DB commit 완료, Kafka send 전 크래시! eventId={}", eventId); | ||
| throw new RuntimeException("simulated crash before Kafka send"); | ||
| } | ||
| try { | ||
| kafkaTemplate.send(KafkaTopics.COUPON_ISSUE_REQUESTS, String.valueOf(couponTemplateId), objectMapper.readTree(payload)); | ||
| log.info("[Bypass] direct Kafka send eventId={}", eventId); | ||
| } catch (Exception e) { | ||
| log.error("[Bypass] Kafka send failed eventId={}", eventId, e); | ||
| } | ||
|
Comment on lines
+85
to
+90
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: #!/bin/bash
# Find and examine CouponService.java
find . -type f -name "CouponService.java" | head -5Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 168 🏁 Script executed: #!/bin/bash
# Read the file to see context around lines 85-90
if [ -f "apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponService.java" ]; then
wc -l "apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponService.java"
fiRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 169 🏁 Script executed: #!/bin/bash
# Check the context around the problematic code
cat -n "apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponService.java" | sed -n '70,105p'Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 1966 🏁 Script executed: #!/bin/bash
# Search for kafkaTemplate usage patterns in the codebase
rg "kafkaTemplate\.send" --type java -B 2 -A 2Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 1568 🏁 Script executed: #!/bin/bash
# Check if Future callbacks are used anywhere in the codebase
rg "addCallback|ListenableFuture|SendResult" --type javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 67 🏁 Script executed: #!/bin/bash
# Check Spring Kafka configuration for this project
find . -type f \( -name "*Kafka*.java" -o -name "*kafka*.properties" -o -name "*kafka*.yml" \) | grep -E "config|properties|yml"Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 164 🏁 Script executed: #!/bin/bash
# Check OutboxPublisher for similar patterns
cat -n "apps/commerce-api/src/main/java/com/loopers/application/outbox/OutboxPublisher.java" | head -50Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 2164 🏁 Script executed: #!/bin/bash
# Check Kafka configuration for error handling or default error handler
cat "modules/kafka/src/main/resources/kafka.yml"Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 1940 🏁 Script executed: #!/bin/bash
# Search for any KafkaTemplate configuration or bean setup
rg "KafkaTemplate|kafkaTemplate" --type java -B 3 -A 3 | head -80Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 6512 🏁 Script executed: #!/bin/bash
# Check if there are any error handlers or listeners registered
rg "@KafkaListener|KafkaListenerErrorHandler|ErrorHandler" --type javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 598 kafkaTemplate.send() 반환값(Future)을 확인하지 않아 비동기 브로커 실패를 감지하지 못한다.
반환된 Future에 success/failure 콜백을 등록하거나, 🤖 Prompt for AI Agents |
||
| } | ||
| }); | ||
| return eventId; | ||
| } | ||
|
|
||
| outboxEventRepository.save(OutboxEvent.create(eventId, KafkaTopics.COUPON_ISSUE_REQUESTS, String.valueOf(couponTemplateId), objectMapper.writeValueAsString(event))); | ||
| return eventId; // 클라이언트가 결과 polling 시 사용 | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -18,6 +18,7 @@ | |
| import com.loopers.support.error.ErrorType; | ||
| import lombok.RequiredArgsConstructor; | ||
| import lombok.SneakyThrows; | ||
| import lombok.extern.slf4j.Slf4j; | ||
| import org.springframework.stereotype.Component; | ||
| import org.springframework.transaction.annotation.Transactional; | ||
| import org.springframework.transaction.support.TransactionSynchronization; | ||
|
|
@@ -28,6 +29,7 @@ | |
| import java.util.UUID; | ||
| import java.util.stream.Collectors; | ||
|
|
||
| @Slf4j | ||
| @RequiredArgsConstructor | ||
| @Component | ||
| public class OrderService { | ||
|
|
@@ -138,16 +140,21 @@ public OrderResult placeOrder(Long memberId, List<OrderLineRequest> items, Long | |
| .collect(Collectors.toList()); | ||
|
|
||
| // Outbox에 ORDERED 이벤트 저장 (주문 라인별 1건씩 — 랭킹은 상품 단위) | ||
| for (Order.OrderLineEntity ol : order.getOrderLines()) { | ||
| String eventId = UUID.randomUUID().toString(); | ||
| CatalogEvent event = CatalogEvent.ordered( | ||
| eventId, ol.getProductId(), memberId, Instant.now().toEpochMilli(), | ||
| ol.getUnitPrice(), ol.getQuantity() | ||
| ); | ||
| outboxEventRepository.save(OutboxEvent.create( | ||
| eventId, KafkaTopics.CATALOG_EVENTS, String.valueOf(ol.getProductId()), | ||
| objectMapper.writeValueAsString(event) | ||
| )); | ||
| // 랭킹은 보조 지표이므로 실패해도 주문을 롤백하지 않는다 (fail-open) | ||
| try { | ||
| for (Order.OrderLineEntity ol : order.getOrderLines()) { | ||
| String eventId = UUID.randomUUID().toString(); | ||
| CatalogEvent event = CatalogEvent.ordered( | ||
| eventId, ol.getProductId(), memberId, Instant.now().toEpochMilli(), | ||
| ol.getUnitPrice(), ol.getQuantity() | ||
| ); | ||
| outboxEventRepository.save(OutboxEvent.create( | ||
| eventId, KafkaTopics.CATALOG_EVENTS, String.valueOf(ol.getProductId()), | ||
| objectMapper.writeValueAsString(event) | ||
| )); | ||
| } | ||
| } catch (Exception e) { | ||
| log.warn("[Order] ORDERED 이벤트 Outbox 저장 실패 orderId={}", order.getId(), e); | ||
| } | ||
|
Comment on lines
+143
to
158
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Outbox 패턴의 핵심 보장과 상충하는 fail-open 전략이다. 운영 관점 문제:
수정안:
추가 테스트:
🤖 Prompt for AI Agents |
||
|
|
||
| // 주문 완료 후 entered 키 삭제 — 슬롯 즉시 반환 (TTL 만료 대기 없이) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,10 +1,13 @@ | ||
| package com.loopers.domain.product; | ||
|
|
||
| import java.util.List; | ||
| import java.util.Optional; | ||
|
|
||
| public interface BrandRepository { | ||
|
|
||
| Brand save(Brand brand); | ||
|
|
||
| Optional<Brand> findById(Long id); | ||
|
|
||
| List<Brand> findAllByIds(List<Long> ids); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| package com.loopers.domain.ranking; | ||
|
|
||
| /** | ||
| * MV 테이블 조회 결과를 담는 도메인 VO. | ||
| * weekly/monthly 공통으로 사용한다. | ||
| */ | ||
| public record ProductRankEntry( | ||
| Long productId, | ||
| double totalScore, | ||
| int rankPosition | ||
| ) {} |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| package com.loopers.domain.ranking; | ||
|
|
||
| public enum RankingPeriod { | ||
| DAILY, WEEKLY, MONTHLY | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| package com.loopers.domain.ranking; | ||
|
|
||
| import java.time.LocalDate; | ||
| import java.util.List; | ||
|
|
||
| public interface RankingRepository { | ||
|
|
||
| List<ProductRankEntry> findWeeklyRankings(LocalDate weekStartDate, int page, int size); | ||
|
|
||
| List<ProductRankEntry> findMonthlyRankings(LocalDate monthStartDate, int page, int size); | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,59 @@ | ||||||||||||||||||||||||||
| package com.loopers.infrastructure.ranking; | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| import jakarta.persistence.Column; | ||||||||||||||||||||||||||
| import jakarta.persistence.Entity; | ||||||||||||||||||||||||||
| import jakarta.persistence.Id; | ||||||||||||||||||||||||||
| import jakarta.persistence.IdClass; | ||||||||||||||||||||||||||
| import jakarta.persistence.Table; | ||||||||||||||||||||||||||
| import org.hibernate.annotations.Immutable; | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| import java.io.Serializable; | ||||||||||||||||||||||||||
| import java.time.LocalDate; | ||||||||||||||||||||||||||
| import java.time.LocalDateTime; | ||||||||||||||||||||||||||
| import java.util.Objects; | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| @Immutable | ||||||||||||||||||||||||||
| @Entity | ||||||||||||||||||||||||||
| @Table(name = "mv_product_rank_monthly") | ||||||||||||||||||||||||||
| @IdClass(MvProductRankMonthlyEntity.PK.class) | ||||||||||||||||||||||||||
| public class MvProductRankMonthlyEntity { | ||||||||||||||||||||||||||
|
Comment on lines
+15
to
+19
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. JPA 엔티티에 기본 생성자가 누락되었다. JPA 명세에 따르면 엔티티 클래스는 🛡️ 기본 생성자 추가 제안 `@Immutable`
`@Entity`
`@Table`(name = "mv_product_rank_monthly")
`@IdClass`(MvProductRankMonthlyEntity.PK.class)
public class MvProductRankMonthlyEntity {
+
+ protected MvProductRankMonthlyEntity() {}
`@Id`
`@Column`(name = "product_id")📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| @Id | ||||||||||||||||||||||||||
| @Column(name = "product_id") | ||||||||||||||||||||||||||
| private Long productId; | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| @Id | ||||||||||||||||||||||||||
| @Column(name = "month_start_date") | ||||||||||||||||||||||||||
| private LocalDate monthStartDate; | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| @Column(name = "total_score") | ||||||||||||||||||||||||||
| private double totalScore; | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| @Column(name = "rank_position") | ||||||||||||||||||||||||||
| private int rankPosition; | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| @Column(name = "aggregated_at") | ||||||||||||||||||||||||||
| private LocalDateTime aggregatedAt; | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| public Long getProductId() { return productId; } | ||||||||||||||||||||||||||
| public LocalDate getMonthStartDate() { return monthStartDate; } | ||||||||||||||||||||||||||
| public double getTotalScore() { return totalScore; } | ||||||||||||||||||||||||||
| public int getRankPosition() { return rankPosition; } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| public static class PK implements Serializable { | ||||||||||||||||||||||||||
| private Long productId; | ||||||||||||||||||||||||||
| private LocalDate monthStartDate; | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| protected PK() {} | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| @Override | ||||||||||||||||||||||||||
| public boolean equals(Object o) { | ||||||||||||||||||||||||||
| if (this == o) return true; | ||||||||||||||||||||||||||
| if (!(o instanceof PK pk)) return false; | ||||||||||||||||||||||||||
| return Objects.equals(productId, pk.productId) && Objects.equals(monthStartDate, pk.monthStartDate); | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| @Override | ||||||||||||||||||||||||||
| public int hashCode() { return Objects.hash(productId, monthStartDate); } | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Outbox 우회 경로를 운영 서비스 빈에 직접 두면 배포 단위로 전달 보장이 달라진다.
환경변수 한 번으로 동일한
CouponService가 Outbox 보장 경로와 비보장 경로를 오가면, 롤링 배포 중 일부 인스턴스만 다른 전달 의미를 갖게 된다. 운영에서는 동일 요청이 Pod마다 다르게 처리되어 유실 분석과 장애 재현이 어려워진다. 실험 경로는@Profile기반 별도 빈이나 테스트 전용 구성으로 분리하고, 기본 서비스는 항상 Outbox만 타도록 고정하는 편이 안전하다. 추가로 기본 프로필에서는 Outbox 저장만 수행되는 통합 테스트와, 실험 프로필에서만 direct publish 구현이 로딩되는 구성 테스트를 넣어 두는 것이 좋다.Also applies to: 74-94
🤖 Prompt for AI Agents