Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,10 @@ out/

### Kotlin ###
.kotlin

### k6 load test outputs ###
tests/k6/results/

### Claude Code workspace ###
.claude/
infra.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Comment on lines +36 to +45
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Outbox 우회 경로를 운영 서비스 빈에 직접 두면 배포 단위로 전달 보장이 달라진다.

환경변수 한 번으로 동일한 CouponService가 Outbox 보장 경로와 비보장 경로를 오가면, 롤링 배포 중 일부 인스턴스만 다른 전달 의미를 갖게 된다. 운영에서는 동일 요청이 Pod마다 다르게 처리되어 유실 분석과 장애 재현이 어려워진다. 실험 경로는 @Profile 기반 별도 빈이나 테스트 전용 구성으로 분리하고, 기본 서비스는 항상 Outbox만 타도록 고정하는 편이 안전하다. 추가로 기본 프로필에서는 Outbox 저장만 수행되는 통합 테스트와, 실험 프로필에서만 direct publish 구현이 로딩되는 구성 테스트를 넣어 두는 것이 좋다.

Also applies to: 74-94

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponService.java`
around lines 36 - 45, The CouponService currently toggles outbox bypassing via
static env flags (BYPASS_OUTBOX, CRASH_BEFORE_SEND) causing mixed delivery
semantics across rolling deployments; instead remove these environment-driven
conditionals from CouponService and provide two distinct beans: a default
production CouponService (or implementation) that always writes to Outbox, and a
separate experimental/test implementation activated via `@Profile`("experiment")
or a dedicated test profile that performs direct publish/crash behavior;
relocate CRASH_BEFORE_SEND and bypass logic into that experimental bean/config
and add integration tests ensuring the default profile only saves Outbox events
and the experiment profile loads the direct-publish behavior.

private final ObjectMapper objectMapper;

// 기존 동기 발급 (주문 플로우에서 사용)
Expand Down Expand Up @@ -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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find and examine CouponService.java
find . -type f -name "CouponService.java" | head -5

Repository: 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"
fi

Repository: 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 2

Repository: 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 java

Repository: 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 -50

Repository: 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 -80

Repository: 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 java

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

Length of output: 598


kafkaTemplate.send() 반환값(Future)을 확인하지 않아 비동기 브로커 실패를 감지하지 못한다.

kafkaTemplate.send(...)는 비동기 호출이므로 현재 try/catch는 직렬화 오류 같은 동기 예외만 잡는다. 브로커 미가용, 토픽 미존재, 할당량 초과 같은 원격 실패는 반환된 Future 콜백에서만 드러나는데, 이 코드는 Future를 무시하고 바로 성공 로그를 남긴다. 그 결과 실제 브로커 ACK 상태와 무관하게 로그와 모니터링 지표가 낙관적으로 왜곡되어 장애를 감지하기 어려워진다.

반환된 Future에 success/failure 콜백을 등록하거나, future.get(timeout, TimeUnit.MILLISECONDS)로 일정 시간 내 ACK 확인 후에만 성공으로 기록하도록 변경하자. 추가로 브로커 미가용, 잘못된 토픽 같은 조건에서 실제로 실패 로그가 남고 성공 로그가 기록되지 않는 통합 테스트를 작성하자. OutboxPublisher도 동일한 패턴을 가지고 있으므로 함께 개선하자.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponService.java`
around lines 85 - 90, The current kafkaTemplate.send call in CouponService (and
the similar pattern in OutboxPublisher) ignores the returned ListenableFuture,
so remote broker failures are missed; update the send usage to capture the
returned ListenableFuture from
kafkaTemplate.send(KafkaTopics.COUPON_ISSUE_REQUESTS,
String.valueOf(couponTemplateId), objectMapper.readTree(payload)) and either
attach success/failure callbacks (addCallback) to log success only on onSuccess
and log errors on onFailure, or call future.get(timeout, TimeUnit.MILLISECONDS)
to synchronously confirm ACK before emitting the success log; ensure the catch
blocks only handle synchronous exceptions (e.g., JSON serialization) while
remote failures are handled by the future callback or get() exception handling,
and add integration tests asserting that broker-unavailable / missing-topic
scenarios produce failure logs and no success log for both CouponService and
OutboxPublisher.

}
});
return eventId;
}

outboxEventRepository.save(OutboxEvent.create(eventId, KafkaTopics.COUPON_ISSUE_REQUESTS, String.valueOf(couponTemplateId), objectMapper.writeValueAsString(event)));
return eventId; // 클라이언트가 결과 polling 시 사용
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -28,6 +29,7 @@
import java.util.UUID;
import java.util.stream.Collectors;

@Slf4j
@RequiredArgsConstructor
@Component
public class OrderService {
Expand Down Expand Up @@ -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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Outbox 패턴의 핵심 보장과 상충하는 fail-open 전략이다.

운영 관점 문제:

  1. Outbox 패턴의 본질은 "저장됐으면 반드시 발행된다"인데, try/catch로 저장 실패를 무시하면 이벤트가 영구 유실된다. OutboxPublisher는 저장된 이벤트만 재발행하므로, 저장 자체가 실패하면 복구 경로가 없다.
  2. LikeService, ProductFacade는 동일한 outbox save를 try/catch 없이 수행한다. 서비스 간 일관성이 깨지면 장애 분석 시 혼란을 야기한다.

수정안:

  • (A) fail-open 유지 시: 별도 DLQ 테이블이나 로컬 파일에 실패 이벤트를 기록하여 수동 복구 경로를 확보하라.
  • (B) 일관성 우선 시: try/catch를 제거하고 다른 서비스와 동일하게 처리하라. 랭킹 정합성이 중요하다면 주문 롤백이 더 안전하다.

추가 테스트:

  • outboxEventRepository.save() 예외 발생 시 주문이 정상 커밋되는지, 로그에 orderId가 남는지 검증하는 테스트 케이스가 필요하다.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/main/java/com/loopers/application/order/OrderService.java`
around lines 143 - 158, The current fail-open try/catch around creating/saving
outbox events in OrderService (loop over Order.OrderLineEntity calling
outboxEventRepository.save and OutboxEvent.create) allows permanent event loss
which violates the outbox guarantee; either remove the try/catch to treat save
failures like other services (e.g., LikeService/ProductFacade) so the exception
bubbles and the order transaction rolls back, or keep fail-open but implement a
durable failure sink (DLQ table or local file) inside the catch that records the
event payload, eventId, topic (KafkaTopics.CATALOG_EVENTS) and orderId for
manual replay; also add a test that simulates outboxEventRepository.save
throwing and asserts the chosen behavior (rollback or DLQ entry and order
commit/logging).


// 주문 완료 후 entered 키 삭제 — 슬롯 즉시 반환 (TTL 만료 대기 없이)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,18 @@
import com.loopers.domain.product.BrandRepository;
import com.loopers.domain.product.Product;
import com.loopers.domain.product.ProductRepository;
import com.loopers.domain.ranking.ProductRankEntry;
import com.loopers.domain.ranking.RankingRepository;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ZSetOperations.TypedTuple;
import org.springframework.stereotype.Component;

import java.time.DayOfWeek;
import java.time.LocalDate;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
Expand All @@ -21,24 +26,28 @@
public class RankingFacade {

private static final DateTimeFormatter KEY_DATE_FMT = DateTimeFormatter.BASIC_ISO_DATE;
private static final ZoneId ZONE = ZoneId.of("Asia/Seoul");
private static final String DEFAULT_REDIS_TEMPLATE = "defaultRedisTemplate";

private final RedisTemplate<String, String> redisTemplate;
private final ProductRepository productRepository;
private final BrandRepository brandRepository;
private final RankingRepository rankingRepository;

public RankingFacade(
@Qualifier(DEFAULT_REDIS_TEMPLATE) RedisTemplate<String, String> redisTemplate,
ProductRepository productRepository,
BrandRepository brandRepository
BrandRepository brandRepository,
RankingRepository rankingRepository
) {
this.redisTemplate = redisTemplate;
this.productRepository = productRepository;
this.brandRepository = brandRepository;
this.rankingRepository = rankingRepository;
}

/**
* Top-N 랭킹 조회 (ZREVRANGE + 상품 정보 Aggregation)
* 일간 Top-N 랭킹 조회 (Redis ZSET)
*/
public List<RankingInfo> getTopRankings(String date, int page, int size) {
String key = rankingKey(date);
Expand All @@ -54,18 +63,11 @@ public List<RankingInfo> getTopRankings(String date, int page, int size) {
.map(t -> Long.parseLong(t.getValue()))
.toList();

Map<Long, Product> productMap = productRepository.findAllByIds(productIds).stream()
.collect(Collectors.toMap(Product::getId, p -> p));

Map<Long, Brand> brandMap = productMap.values().stream()
.map(Product::getBrandId)
.filter(id -> id != null)
.distinct()
.flatMap(id -> brandRepository.findById(id).stream())
.collect(Collectors.toMap(Brand::getId, b -> b));
Map<Long, Product> productMap = fetchProductMap(productIds);
Map<Long, Brand> brandMap = fetchBrandMap(productMap);

long rank = start + 1;
List<RankingInfo> result = new java.util.ArrayList<>();
List<RankingInfo> result = new ArrayList<>();
for (TypedTuple<String> tuple : tuples) {
Long productId = Long.parseLong(tuple.getValue());
Product product = productMap.get(productId);
Expand All @@ -86,15 +88,77 @@ public List<RankingInfo> getTopRankings(String date, int page, int size) {
return result;
}

/**
* 주간 랭킹 조회 (MV 테이블)
*/
public List<RankingInfo> getWeeklyRankings(LocalDate date, int page, int size) {
LocalDate weekStart = date.with(DayOfWeek.MONDAY);
List<ProductRankEntry> entries = rankingRepository.findWeeklyRankings(weekStart, page, size);
return enrichRankEntries(entries);
}

/**
* 월간 랭킹 조회 (MV 테이블)
*/
public List<RankingInfo> getMonthlyRankings(LocalDate date, int page, int size) {
LocalDate monthStart = date.withDayOfMonth(1);
List<ProductRankEntry> entries = rankingRepository.findMonthlyRankings(monthStart, page, size);
return enrichRankEntries(entries);
}

/**
* 개별 상품 순위 조회 (ZREVRANK, 0-based → 1-based 변환)
*/
public Long getRank(Long productId) {
String key = rankingKey(LocalDate.now().format(KEY_DATE_FMT));
String key = rankingKey(LocalDate.now(ZONE).format(KEY_DATE_FMT));
Long rank = redisTemplate.opsForZSet().reverseRank(key, String.valueOf(productId));
return rank != null ? rank + 1 : null;
}

private List<RankingInfo> enrichRankEntries(List<ProductRankEntry> entries) {
if (entries.isEmpty()) {
return Collections.emptyList();
}

List<Long> productIds = entries.stream().map(ProductRankEntry::productId).toList();
Map<Long, Product> productMap = fetchProductMap(productIds);
Map<Long, Brand> brandMap = fetchBrandMap(productMap);

List<RankingInfo> result = new ArrayList<>();
for (ProductRankEntry entry : entries) {
Product product = productMap.get(entry.productId());
if (product == null) continue;
Brand brand = product.getBrandId() != null ? brandMap.get(product.getBrandId()) : null;
result.add(new RankingInfo(
entry.rankPosition(),
entry.productId(),
product.getName(),
product.getPrice(),
brand != null ? brand.getName() : null,
entry.totalScore()
));
}
return result;
}

private Map<Long, Product> fetchProductMap(List<Long> productIds) {
return productRepository.findAllByIds(productIds).stream()
.collect(Collectors.toMap(Product::getId, p -> p));
}

private Map<Long, Brand> fetchBrandMap(Map<Long, Product> productMap) {
List<Long> brandIds = productMap.values().stream()
.map(Product::getBrandId)
.filter(id -> id != null)
.distinct()
.toList();
if (brandIds.isEmpty()) {
return Collections.emptyMap();
}
return brandRepository.findAllByIds(brandIds).stream()
.collect(Collectors.toMap(Brand::getId, b -> b));
}

private String rankingKey(String date) {
return "ranking:all:" + date;
}
Expand Down
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
Expand Up @@ -5,6 +5,7 @@
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;

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

@RequiredArgsConstructor
Expand All @@ -22,4 +23,9 @@ public Brand save(Brand brand) {
public Optional<Brand> findById(Long id) {
return brandJpaRepository.findById(id);
}

@Override
public List<Brand> findAllByIds(List<Long> ids) {
return brandJpaRepository.findAllById(ids);
}
}
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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

JPA 엔티티에 기본 생성자가 누락되었다.

JPA 명세에 따르면 엔티티 클래스는 public 또는 protected 접근 수준의 기본 생성자가 필요하다. Hibernate가 일부 경우 동작할 수 있으나, 다른 JPA 구현체나 버전 업그레이드 시 InstantiationException이 발생할 수 있다.

🛡️ 기본 생성자 추가 제안
 `@Immutable`
 `@Entity`
 `@Table`(name = "mv_product_rank_monthly")
 `@IdClass`(MvProductRankMonthlyEntity.PK.class)
 public class MvProductRankMonthlyEntity {
+
+    protected MvProductRankMonthlyEntity() {}

     `@Id`
     `@Column`(name = "product_id")
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@Immutable
@Entity
@Table(name = "mv_product_rank_monthly")
@IdClass(MvProductRankMonthlyEntity.PK.class)
public class MvProductRankMonthlyEntity {
`@Immutable`
`@Entity`
`@Table`(name = "mv_product_rank_monthly")
`@IdClass`(MvProductRankMonthlyEntity.PK.class)
public class MvProductRankMonthlyEntity {
protected MvProductRankMonthlyEntity() {}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthlyEntity.java`
around lines 15 - 19, Add a no-argument constructor with public or protected
visibility to the JPA entity class MvProductRankMonthlyEntity so Hibernate/JPA
can instantiate it; locate the class declaration for MvProductRankMonthlyEntity
(which uses `@Entity` and IdClass(MvProductRankMonthlyEntity.PK.class)) and add a
protected (or public) default constructor alongside existing constructors to
satisfy the JPA spec and avoid InstantiationException in other JPA
implementations.


@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); }
}
}
Loading