-
Notifications
You must be signed in to change notification settings - Fork 34
[volume-9] Product Ranking with Redis #211
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
[volume-9] Product Ranking with Redis #211
Conversation
|
Important Review skippedAuto incremental reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the You can disable this status message by setting the Walkthrough상품 순위 기능을 구현하는 대규모 기능 추가 PR입니다. Kafka 기반의 이벤트 처리, Redis 캐싱, API 엔드포인트, 스케줄러를 포함하며 좋아요/조회/판매 이벤트에 따라 동적으로 상품 순위 점수를 계산 및 관리합니다. Changes
Sequence Diagram(s)sequenceDiagram
participant Client
participant API as ProductV1Api
participant Service as GetProductRankingService
participant CacheRepo as ProductRankingCacheRepository
participant Repository as ProductRepository
participant MySQL as MySQL DB
participant Redis as Redis Cache
Client->>API: GET /rankings?date=2025-12-15&pageNo=0&pageSize=10
API->>Service: getRanking(GetProductRankingQuery)
Service->>CacheRepo: getRankings(date, pageNo, pageSize)
CacheRepo->>Redis: ZREVRANGE ranking:all:20251215 0 9 WITHSCORES
Redis-->>CacheRepo: ProductRankings(list, totalElements, ...)
Service->>Repository: findRankingListBy(productIds)
Repository->>MySQL: SELECT product, brand WHERE id IN (...)
MySQL-->>Repository: List<ProductRankingListProjection>
Repository-->>Service: ProductRankingList
Service->>Service: ProductRankingList.with(rankings)
Service-->>API: ProductRankingList
API-->>Client: ApiResponse<GetProductRankingsResponse>
sequenceDiagram
participant Kafka as Kafka Topic
participant Consumer as IncreaseProductViewRankingScoreKafkaConsumer
participant Service as IncreaseProductViewRankingScoreService
participant CacheRepo as ProductRankingCacheRepository
participant Redis as Redis
Kafka->>Consumer: product-detail-viewed records (batch)
Consumer->>Consumer: Deserialize to IncreaseProductViewRankingScoreEvent
Consumer->>Service: increase(IncreaseProductViewRankingScoreCommand)
Service->>CacheRepo: increaseDaily(productId, today, weight=0.1)
CacheRepo->>Redis: ZINCRBY ranking:all:20251215 0.1 productId
Redis->>Redis: Update sorted set, set TTL 2 days
Redis-->>CacheRepo: Score updated
CacheRepo-->>Service: void
Service-->>Consumer: void
Consumer->>Kafka: Acknowledge batch
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related PRs
Suggested labels
Poem
Pre-merge checks and finishing touches❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
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.
Actionable comments posted: 16
Note
Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (4)
core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/productlike/ProductLikeBulkRepositoryImpl.java (1)
32-38: SQL 파라미터 이름 불일치로 인한 런타임 오류 발생Line 34에서 파라미터를 "id"로 바인딩하고 있지만, Line 27의 SQL 쿼리는
:productId를 참조하고 있습니다. NamedParameterJdbcTemplate는 파라미터 이름이 정확히 일치해야 하므로, 이 메서드 호출 시 런타임에서InvalidDataAccessApiUsageException이 발생합니다.🔎 파라미터 이름 불일치 수정
SqlParameterSource[] batchArgs = entities.stream() .map(entity -> new MapSqlParameterSource() - .addValue("id", entity.getProductId()) + .addValue("productId", entity.getProductId()) .addValue("userId", entity.getUserId()) .addValue("createdAt", entity.getCreatedAt()) ) .toArray(SqlParameterSource[]::new);core/infra/database/redis/src/main/java/com/loopers/core/infra/database/redis/product/ProductLikeRedisRepositoryImpl.java (2)
25-32: Redis 작업에 @transactional 사용 불가Spring의
@Transactional은 JDBC/JPA 트랜잭션을 위한 것이며, Redis 작업에 원자성을 제공하지 않습니다. 현재 구현의 문제점:
- Redis 작업들이 원자적으로 실행되지 않음 (ZADD와 EXPIRE 사이에 장애 발생 시 TTL 설정 안됨)
- 트랜잭션 관리 오버헤드만 발생
- 개발자에게 잘못된 원자성 보장을 암시
Redis 원자성이 필요한 경우
RedisTemplate의execute()메서드와SessionCallback을 사용하여 MULTI/EXEC 트랜잭션을 명시적으로 구현하거나, 이 어노테이션을 제거하세요.🔎 RedisTemplate을 사용한 원자적 작업 예시
- @Transactional public void saveLike(String productId, String userId, Long timestamp) { - redisTemplate.opsForZSet().add(LIKE_PRODUCT_CACHE_KEY_PREFIX + productId, userId, timestamp); - redisTemplate.opsForZSet().add(LIKE_USER_CACHE_KEY_PREFIX + userId, productId, timestamp); - - redisTemplate.expire(LIKE_PRODUCT_CACHE_KEY_PREFIX + productId, Duration.ofDays(TTL_DAYS)); - redisTemplate.expire(LIKE_USER_CACHE_KEY_PREFIX + userId, Duration.ofDays(TTL_DAYS)); + redisTemplate.execute(new SessionCallback<List<Object>>() { + @Override + public List<Object> execute(RedisOperations operations) throws DataAccessException { + operations.multi(); + operations.opsForZSet().add(LIKE_PRODUCT_CACHE_KEY_PREFIX + productId, userId, timestamp); + operations.opsForZSet().add(LIKE_USER_CACHE_KEY_PREFIX + userId, productId, timestamp); + operations.expire(LIKE_PRODUCT_CACHE_KEY_PREFIX + productId, Duration.ofDays(TTL_DAYS)); + operations.expire(LIKE_USER_CACHE_KEY_PREFIX + userId, Duration.ofDays(TTL_DAYS)); + return operations.exec(); + } + }); }
35-39: Redis 작업에 @transactional 사용 불가 (deleteLike)
saveLike와 동일한 문제가 있습니다. 두 개의 ZREM 작업이 원자적으로 실행되지 않아 한 쪽만 성공하면 데이터 불일치가 발생할 수 있습니다.Redis MULTI/EXEC 트랜잭션을 사용하거나 이 어노테이션을 제거하세요.
core/service/src/main/java/com/loopers/core/service/product/IncreaseProductMetricViewCountService.java (1)
18-23:aggregateIdField값이 존재하지 않는 필드를 참조합니다.
IncreaseProductMetricViewCountCommand레코드는String eventId, String productId필드만 가지고 있습니다. 현재 설정된aggregateIdField = "id"는 존재하지 않는 필드를 참조하므로 런타임에 리플렉션 오류가 발생합니다.aggregateIdField를"productId"로 변경하거나 명령 클래스에id필드를 추가해야 합니다.
🟡 Minor comments (5)
core/infra/database/redis/src/main/java/com/loopers/core/infra/database/redis/product/ProductRankingRedisRepositoryImpl.java-61-70 (1)
61-70:unionAndStore의 Aggregate.SUM 동작으로 인한 병합 기능 확인
carryOverWithWeights메서드가 빈 리스트와Aggregate.SUM을 사용하므로, 단순 복사+가중치가 아니라 가중치 적용 후 기존 데이터와의 합산입니다.현재 사용 패턴(매일 23:50에 이전 날짜 순위를 다음 날짜로 이월)에서는 destKey가 새로 생성되어 문제가 없지만, 만약 destKey에 이미 데이터가 있는 경우 점수들이 누적됩니다. 이것이 의도된 동작인지 확인이 필요하며, 필요시 메서드명을
mergeWithWeights같은 명확한 이름으로 변경하거나 문서화를 추가하세요.core/service/src/main/java/com/loopers/core/service/product/ProductRankingCarryOverService.java-19-24 (1)
19-24:LocalDate.now()를 두 번 호출하는 것은 자정 경계에서 문제가 될 수 있습니다.
LocalDate.now()를 두 번 호출하면 자정 근처에서 실행될 경우today와tomorrow가 예상과 다른 날짜를 가질 수 있습니다. 이미 생성된today변수를 재사용하세요.🔎 수정 제안
public void carryOver() { LocalDate today = LocalDate.now(); - LocalDate tomorrow = LocalDate.now().plusDays(1); + LocalDate tomorrow = today.plusDays(1); productRankingCacheRepository.carryOver(today, tomorrow, carryOverWeight); }core/domain/src/main/java/com/loopers/core/domain/product/repository/ProductRankingCacheRepository.java-15-15 (1)
15-15:getDailyRankingBy메서드의 의도를 명확히 하기 위해 문서화가 필요합니다.이 메서드는 구현에서
LocalDate.now()를 명시적으로 사용하고 있어 오늘의 랭킹만 조회하도록 설계되었습니다. 그러나 메서드 시그니처만으로는 이 의도가 명확하지 않습니다.getRankings(LocalDate date, ...)와 달리 날짜 파라미터가 없어 API 사용자가 혼동할 수 있습니다.메서드 동작을 명확히 하기 위해 다음 중 하나를 수행하세요:
- Javadoc 추가: 이 메서드가 오늘의 랭킹만 반환함을 명확히 문서화
- 메서드명 변경:
getTodaysRankingBy또는getCurrentRankingBy로 변경하여 의도를 명확히 함- LocalDate 파라미터 추가: 메서드 유연성 확대 및 API 일관성 확보
core/service/src/main/java/com/loopers/core/service/product/IncreaseProductSalesRankingScoreService.java-38-45 (1)
38-45: @transactional 어노테이션을 추가하여 트랜잭션 경계를 명시하세요.이 메서드는 여러 repository 조회(Payment, Order, OrderItems)를 수행하지만
@Transactional어노테이션이 없습니다.IncreaseProductTotalSalesService와 같은 유사한 서비스들은@InboxEvent와 함께@Transactional을 사용하여 일관성 있게 트랜잭션 경계를 정의하고 있습니다.@InboxEvent어노테이션이 eventId 기반 중복 감지를 통해 이벤트 수준의 멱등성을 보장하므로, 명시적인@Transactional을 추가하면 데이터베이스 읽기 작업의 일관성을 더욱 강화할 수 있습니다.core/infra/database/redis/src/main/java/com/loopers/core/infra/database/redis/product/impl/ProductRankingCacheRepositoryImpl.java-29-55 (1)
29-55:getRankings메서드의 페이지네이션 로직이 잘 구현되어 있습니다.null 체크, 빈 결과 처리, 페이지 계산이 적절합니다. 다만
pageSize파라미터에 대한 유효성 검증이 없어 0 또는 음수 값이 전달될 경우 50행의Math.ceil((double) totalElements / pageSize)에서ArithmeticException이 발생할 수 있습니다. API 레이어의@RequestParam에@Min(1)또는@Positive애너테이션 추가, 또는GetProductRankingQuery레코드에 검증 제약 조건을 추가하는 것을 권장합니다.
🧹 Nitpick comments (12)
core/service/src/main/java/com/loopers/core/service/product/command/IncreaseProductViewRankingScoreCommand.java (1)
1-7: 입력 검증 또는 제약사항 문서화를 고려하세요.
eventId와productId필드가 String 타입으로 정의되어 있으며 검증 로직이 없습니다. 이 커맨드가 외부 이벤트(Kafka)에서 생성되는 경우, null이나 빈 문자열 또는 잘못된 형식의 값이 전달될 수 있습니다.다음 중 하나를 권장합니다:
- 팩토리 메서드에서 null/빈 문자열 검증 추가
- 또는 호출자가 유효성을 보장한다는 가정을 JavaDoc으로 명시
🔎 검증 로직이 포함된 팩토리 메서드 예시
public record IncreaseProductViewRankingScoreCommand( String eventId, String productId ) { + public IncreaseProductViewRankingScoreCommand { + if (eventId == null || eventId.isBlank()) { + throw new IllegalArgumentException("eventId must not be null or blank"); + } + if (productId == null || productId.isBlank()) { + throw new IllegalArgumentException("productId must not be null or blank"); + } + } }core/infra/database/redis/src/main/java/com/loopers/core/infra/database/redis/product/ProductRankingRedisRepository.java (1)
8-22: 메서드 계약을 문서화하세요.인터페이스가 깔끔하게 정의되어 있으나, 일부 메서드의 반환값 의미를 명확히 하면 좋겠습니다. 특히:
getRankScore: 랭킹에 없는 경우 null 반환?getRankingPosition: 랭킹에 없는 경우 null 반환?JavaDoc으로 null 반환 조건을 명시하면 호출자가 null 체크를 누락하는 실수를 방지할 수 있습니다.
🔎 JavaDoc 추가 예시
public interface ProductRankingRedisRepository { void increaseDaily(String productId, LocalDate date, Double score); Set<TypedTuple<String>> getTopProductsWithScores(LocalDate date, long start, long stop); Long countRankings(LocalDate date); + /** + * 지정된 날짜의 상품 랭킹 점수를 조회합니다. + * @return 랭킹 점수, 랭킹에 없는 경우 null + */ Double getRankScore(LocalDate date, String productId); + /** + * 지정된 날짜의 상품 랭킹 순위를 조회합니다 (1-based). + * @return 랭킹 순위 (1부터 시작), 랭킹에 없는 경우 null + */ Long getRankingPosition(LocalDate date, String productId); void carryOverWithWeights(String sourceKey, String destKey, Double weight); }core/service/src/test/java/com/loopers/core/service/product/GetProductRankingServiceIntegrationTest.java (1)
129-150: 테스트 커버리지가 포괄적입니다.페이지네이션, 부분 상품 조회, 빈 랭킹, 상품 정보 정확성에 대한 테스트가 잘 작성되었습니다. 추가로 랭킹 순서(점수 기반 정렬)가 올바르게 적용되는지 검증하는 테스트도 고려해 보세요.
🔎 점수 순서 검증 테스트 예시
@Test @DisplayName("랭킹은 점수 내림차순으로 정렬된다") void getRankingOrderedByScore() { // Given productRankingCacheRepository.increaseDaily(testProducts.get(0).getId(), testDate, 100.0); productRankingCacheRepository.increaseDaily(testProducts.get(1).getId(), testDate, 300.0); productRankingCacheRepository.increaseDaily(testProducts.get(2).getId(), testDate, 200.0); GetProductRankingQuery query = new GetProductRankingQuery(testDate, 0, 10); // When ProductRankingList result = getProductRankingService.getRanking(query); // Then assertThat(result.products().get(0).id()).isEqualTo(testProducts.get(1).getId()); // 300점 assertThat(result.products().get(1).id()).isEqualTo(testProducts.get(2).getId()); // 200점 assertThat(result.products().get(2).id()).isEqualTo(testProducts.get(0).getId()); // 100점 }core/infra/database/redis/src/testFixtures/java/com/loopers/core/infra/database/redis/util/RedisCleanUp.java (1)
7-21: 중복 코드 경로 수정 필요합니다.원본 리뷰 댓글에서 언급한
modules/redis/src/testFixtures/java/com/loopers/utils/RedisCleanUp.java파일은 존재하지 않습니다. 실제 중복은 다음 위치에 존재합니다:
core/infra/database/redis/redis-config/src/testFixtures/java/com/loopers/core/infra/database/redis/util/RedisCleanUp.java두 파일의 구현이 완전히 동일하므로, 중복을 제거하고 한 곳으로 통합하거나 공유하는 방식으로 리팩토링하는 것을 권장합니다.
core/service/src/main/java/com/loopers/core/service/product/IncreaseProductSalesRankingScoreService.java (2)
43-43: 분산 환경에서LocalDate.now()사용을 검토하세요.
LocalDate.now()는 서버의 시스템 시간대를 사용합니다. 여러 리전에 배포된 서비스에서 서로 다른 날짜를 사용할 수 있어 랭킹 데이터가 불일치할 수 있습니다.🔎 UTC 기반 날짜 사용 제안
+import java.time.ZoneId; + orderItems.forEach( - item -> productRankingCacheRepository.increaseDaily(item.getProductId(), LocalDate.now(), weight) + item -> productRankingCacheRepository.increaseDaily(item.getProductId(), LocalDate.now(ZoneId.of("UTC")), weight) );또는 이벤트에 날짜 정보를 포함시켜 결정론적으로 처리하는 방안도 고려하세요.
39-41: 조회 메서드 네이밍의 일관성을 고려하세요.Line 39는
getById를 사용하고 Line 40은getBy를 사용합니다. 두 메서드 모두 엔티티를 조회하는 동일한 역할을 하지만 네이밍이 다릅니다.프로젝트 전체에서 "존재하지 않으면 예외를 던지는 조회" 메서드의 네이밍 규칙(예:
getById,getBy,findByIdOrThrow)을 통일하는 것을 권장합니다.core/domain/src/main/java/com/loopers/core/domain/product/repository/ProductRankingCacheRepository.java (1)
11-11:Double대신double을 고려하세요.
increaseDaily의 score 파라미터가Double(nullable)로 선언되어 있습니다. null score를 허용할 명확한 이유가 없다면 primitivedouble을 사용하는 것이 null 안전성 측면에서 더 낫습니다.🔎 제안: primitive double 사용
-void increaseDaily(ProductId productId, LocalDate date, Double score); +void increaseDaily(ProductId productId, LocalDate date, double score);carryOver의 decayFactor도 동일하게 적용 가능합니다.
apps/commerce-api/src/main/java/com/loopers/application/api/product/ProductV1Api.java (1)
68-70: 파라미터 유효성 검증을 추가하세요.현재 구현에는 다음 검증이 없습니다:
date가 미래 날짜이거나 과도하게 과거일 수 있음pageNo가 음수일 수 있음pageSize가 0, 음수, 또는 과도하게 클 수 있음 (예: 10000)🔎 제안: Bean Validation 적용
DTO 클래스에 validation 어노테이션을 추가하거나, 컨트롤러에서 검증하세요:
public ApiResponse<GetProductRankingsResponse> getProductRankings( - @RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate date, - @RequestParam(required = false, defaultValue = "0") int pageNo, - @RequestParam(required = false, defaultValue = "10") int pageSize + @RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd") @PastOrPresent LocalDate date, + @RequestParam(required = false, defaultValue = "0") @Min(0) int pageNo, + @RequestParam(required = false, defaultValue = "10") @Min(1) @Max(100) int pageSize ) {또는 서비스 레이어에서 비즈니스 로직 검증을 수행하세요.
apps/commerce-api/src/main/java/com/loopers/application/api/product/ProductV1ApiSpec.java (1)
9-11:GetProductRankingsResponse가GetProductDetailResponse내부에 중첩되어 있습니다.
GetProductRankingsResponse는 상품 상세 조회와는 독립적인 랭킹 목록 응답입니다.GetProductDetailResponse내부에 중첩하는 것보다ProductV1Dto의 직접적인 내부 record로 선언하는 것이 구조적으로 더 명확합니다.apps/commerce-api/src/main/java/com/loopers/application/api/product/ProductV1Dto.java (1)
122-163:GetProductRankingsResponse가GetProductDetailResponse내부에 중첩되어 있어 구조가 혼란스럽습니다.랭킹 목록 응답(
GetProductRankingsResponse)은 상품 상세 응답(GetProductDetailResponse)과 논리적 관계가 없습니다.ProductV1Dto의 최상위 레벨에 선언하는 것이 더 적절합니다.🔎 구조 개선 제안
public class ProductV1Dto { + + public record GetProductRankingsResponse( + List<ProductRankingResponse> products, + long totalElements, + int totalPages, + boolean hasNext, + boolean hasPrevious + ) { + // ... 기존 구현 + } + public record GetProductDetailResponse( // ... ) { - public record GetProductRankingsResponse( - // ... - ) { } } }core/infra/database/redis/src/main/java/com/loopers/core/infra/database/redis/product/impl/ProductRankingCacheRepositoryImpl.java (2)
57-68:LocalDate.now()사용으로 단위 테스트가 어렵습니다.
getDailyRankingBy메서드에서LocalDate.now()를 직접 호출하면 테스트 시 날짜를 제어할 수 없습니다.Clock을 주입받거나 날짜를 파라미터로 받는 것을 고려해 보세요.🔎 개선 제안
+ private final Clock clock; + @Override - public ProductRanking getDailyRankingBy(ProductId productId) { - LocalDate today = LocalDate.now(); + public ProductRanking getDailyRankingBy(ProductId productId, LocalDate date) { + // 또는 Clock 주입 사용: LocalDate today = LocalDate.now(clock); + LocalDate today = date; Double score = repository.getRankScore(today, productId.value());
70-76: Redis 키 포맷이ProductRankingRedisRepositoryImpl과 중복됩니다.
"ranking:all:"접두사와 날짜 포맷이ProductRankingRedisRepositoryImpl.RANKING_KEY_PREFIX와 중복됩니다. 키 생성 로직을 한 곳에서 관리하거나 상수를 공유하는 것이 좋습니다.🔎 개선 제안
ProductRankingRedisRepository인터페이스에 키 생성 메서드를 추가하거나, 공통 상수 클래스를 만들어 중복을 제거하세요:// ProductRankingRedisRepository에 추가 String buildRankingKey(LocalDate date);그러면
carryOver메서드에서:@Override public void carryOver(LocalDate sourceDate, LocalDate targetDate, Double weight) { - String sourceKey = "ranking:all:" + sourceDate.format(java.time.format.DateTimeFormatter.ofPattern("yyyyMMdd")); - String targetKey = "ranking:all:" + targetDate.format(java.time.format.DateTimeFormatter.ofPattern("yyyyMMdd")); + String sourceKey = repository.buildRankingKey(sourceDate); + String targetKey = repository.buildRankingKey(targetDate); repository.carryOverWithWeights(sourceKey, targetKey, weight); }
📜 Review details
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (59)
apps/commerce-api/src/main/java/com/loopers/application/api/product/ProductLikeEventScheduler.javaapps/commerce-api/src/main/java/com/loopers/application/api/product/ProductRankingCarryOverScheduler.javaapps/commerce-api/src/main/java/com/loopers/application/api/product/ProductV1Api.javaapps/commerce-api/src/main/java/com/loopers/application/api/product/ProductV1ApiSpec.javaapps/commerce-api/src/main/java/com/loopers/application/api/product/ProductV1Dto.javaapps/commerce-api/src/main/resources/application.ymlapps/commerce-api/src/test/java/com/loopers/application/api/product/ProductV1ApiIntegrationTest.javaapps/commerce-streamer/src/main/java/com/loopers/applications/streamer/consumer/product/IncreaseProductLikeRankingScoreKafkaConsumer.javaapps/commerce-streamer/src/main/java/com/loopers/applications/streamer/consumer/product/IncreaseProductSalesRankingScoreKafkaConsumer.javaapps/commerce-streamer/src/main/java/com/loopers/applications/streamer/consumer/product/IncreaseProductTotalSalesKafkaConsumer.javaapps/commerce-streamer/src/main/java/com/loopers/applications/streamer/consumer/product/IncreaseProductViewKafkaConsumer.javaapps/commerce-streamer/src/main/java/com/loopers/applications/streamer/consumer/product/IncreaseProductViewRankingScoreKafkaConsumer.javaapps/commerce-streamer/src/main/java/com/loopers/applications/streamer/consumer/product/dto/IncreaseProductLikeRankingScoreEvent.javaapps/commerce-streamer/src/main/java/com/loopers/applications/streamer/consumer/product/dto/IncreaseProductSalesRankingScoreEvent.javaapps/commerce-streamer/src/main/java/com/loopers/applications/streamer/consumer/product/dto/IncreaseProductViewRankingScoreEvent.javaapps/commerce-streamer/src/main/resources/application.ymlcore/domain/src/main/java/com/loopers/core/domain/event/type/EventType.javacore/domain/src/main/java/com/loopers/core/domain/product/ProductDetail.javacore/domain/src/main/java/com/loopers/core/domain/product/ProductRankingList.javacore/domain/src/main/java/com/loopers/core/domain/product/event/ProductDetailViewEvent.javacore/domain/src/main/java/com/loopers/core/domain/product/event/ProductLikeEvent.javacore/domain/src/main/java/com/loopers/core/domain/product/repository/ProductLikeCacheRepository.javacore/domain/src/main/java/com/loopers/core/domain/product/repository/ProductRankingCacheRepository.javacore/domain/src/main/java/com/loopers/core/domain/product/repository/ProductRepository.javacore/domain/src/main/java/com/loopers/core/domain/product/vo/ProductRanking.javacore/domain/src/main/java/com/loopers/core/domain/product/vo/ProductRankings.javacore/domain/src/main/java/com/loopers/core/domain/productlike/event/ProductLikeEventPublisher.javacore/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/ProductQuerydslRepository.javacore/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/ProductQuerydslRepositoryImpl.javacore/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/dto/ProductRankingListProjection.javacore/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/impl/ProductRepositoryImpl.javacore/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/productlike/ProductLikeBulkRepositoryImpl.javacore/infra/database/redis/build.gradle.ktscore/infra/database/redis/src/main/java/com/loopers/core/infra/database/redis/product/ProductLikeRedisRepositoryImpl.javacore/infra/database/redis/src/main/java/com/loopers/core/infra/database/redis/product/ProductRankingRedisRepository.javacore/infra/database/redis/src/main/java/com/loopers/core/infra/database/redis/product/ProductRankingRedisRepositoryImpl.javacore/infra/database/redis/src/main/java/com/loopers/core/infra/database/redis/product/entity/ProductDetailRedisEntity.javacore/infra/database/redis/src/main/java/com/loopers/core/infra/database/redis/product/impl/ProductRankingCacheRepositoryImpl.javacore/infra/database/redis/src/testFixtures/java/com/loopers/core/infra/database/redis/util/RedisCleanUp.javacore/infra/event/src/main/java/com/loopers/core/infra/event/kafka/productlike/publisher/dto/ProductLikeKafkaEvent.javacore/infra/event/src/main/java/com/loopers/core/infra/event/kafka/productlike/publisher/impl/ProductLikeEventPublisherImpl.javacore/service/build.gradle.ktscore/service/src/main/java/com/loopers/core/service/product/ClearProductCacheService.javacore/service/src/main/java/com/loopers/core/service/product/GetProductRankingService.javacore/service/src/main/java/com/loopers/core/service/product/IncreaseProductMetricViewCountService.javacore/service/src/main/java/com/loopers/core/service/product/IncreaseProductSalesRankingScoreService.javacore/service/src/main/java/com/loopers/core/service/product/IncreaseProductViewRankingScoreService.javacore/service/src/main/java/com/loopers/core/service/product/ProductQueryService.javacore/service/src/main/java/com/loopers/core/service/product/ProductRankingCarryOverService.javacore/service/src/main/java/com/loopers/core/service/product/command/IncreaseProductSalesRankingScoreCommand.javacore/service/src/main/java/com/loopers/core/service/product/command/IncreaseProductViewRankingScoreCommand.javacore/service/src/main/java/com/loopers/core/service/product/query/GetProductRankingQuery.javacore/service/src/main/java/com/loopers/core/service/productlike/IncreaseProductLikeRankingScoreService.javacore/service/src/main/java/com/loopers/core/service/productlike/ProductLikeEventPublishService.javacore/service/src/main/java/com/loopers/core/service/productlike/ProductLikeService.javacore/service/src/main/java/com/loopers/core/service/productlike/command/IncreaseProductLikeRankingScoreCommand.javacore/service/src/test/java/com/loopers/core/service/IntegrationTest.javacore/service/src/test/java/com/loopers/core/service/product/GetProductRankingServiceIntegrationTest.javacore/service/src/test/resources/application-test.yml
🧰 Additional context used
🧠 Learnings (11)
📚 Learning: 2025-12-18T13:24:51.650Z
Learnt from: kilian-develop
Repo: Loopers-dev-lab/loopers-spring-java-template PR: 190
File: apps/commerce-streamer/src/main/java/com/loopers/applications/streamer/consumer/product/IncreaseProductViewKafkaConsumer.java:25-35
Timestamp: 2025-12-18T13:24:51.650Z
Learning: Adopt centralized error handling for Kafka consumers by using the EventInboxAspect to intercept methods annotated with InboxEvent. Ensure that service methods annotated with InboxEvent save failed EventInbox entries and log errors, avoiding duplicating error handling logic in individual consumers. Apply this pattern broadly to similar consumer/service layers within the commerce-streamer module.
Applied to files:
apps/commerce-streamer/src/main/java/com/loopers/applications/streamer/consumer/product/dto/IncreaseProductViewRankingScoreEvent.javaapps/commerce-streamer/src/main/java/com/loopers/applications/streamer/consumer/product/IncreaseProductSalesRankingScoreKafkaConsumer.javaapps/commerce-streamer/src/main/java/com/loopers/applications/streamer/consumer/product/dto/IncreaseProductLikeRankingScoreEvent.javaapps/commerce-streamer/src/main/java/com/loopers/applications/streamer/consumer/product/IncreaseProductViewRankingScoreKafkaConsumer.javaapps/commerce-streamer/src/main/java/com/loopers/applications/streamer/consumer/product/dto/IncreaseProductSalesRankingScoreEvent.javaapps/commerce-streamer/src/main/java/com/loopers/applications/streamer/consumer/product/IncreaseProductTotalSalesKafkaConsumer.javaapps/commerce-streamer/src/main/java/com/loopers/applications/streamer/consumer/product/IncreaseProductLikeRankingScoreKafkaConsumer.javaapps/commerce-streamer/src/main/java/com/loopers/applications/streamer/consumer/product/IncreaseProductViewKafkaConsumer.java
📚 Learning: 2025-12-19T21:30:08.018Z
Learnt from: toongri
Repo: Loopers-dev-lab/loopers-spring-kotlin-template PR: 68
File: apps/commerce-api/src/main/kotlin/com/loopers/infrastructure/outbox/OutboxEventListener.kt:0-0
Timestamp: 2025-12-19T21:30:08.018Z
Learning: In the Loopers-dev-lab/loopers-spring-kotlin-template Kafka event pipeline, Like events (LikeCreatedEventV1, LikeCanceledEventV1) intentionally use aggregateType="Like" with aggregateId=productId. The aggregateId serves as a partitioning/grouping key (not a unique Like entity identifier), ensuring all like events for the same product go to the same partition for ordering guarantees and aligning with ProductStatisticService's product-based aggregation logic. Using individual like_id would scatter events across partitions and break the statistics aggregation pattern.
Applied to files:
core/service/src/main/java/com/loopers/core/service/productlike/ProductLikeService.javaapps/commerce-api/src/main/java/com/loopers/application/api/product/ProductLikeEventScheduler.javacore/infra/event/src/main/java/com/loopers/core/infra/event/kafka/productlike/publisher/impl/ProductLikeEventPublisherImpl.javaapps/commerce-streamer/src/main/java/com/loopers/applications/streamer/consumer/product/dto/IncreaseProductLikeRankingScoreEvent.javacore/infra/event/src/main/java/com/loopers/core/infra/event/kafka/productlike/publisher/dto/ProductLikeKafkaEvent.javaapps/commerce-api/src/main/resources/application.ymlcore/service/src/main/java/com/loopers/core/service/productlike/ProductLikeEventPublishService.javaapps/commerce-streamer/src/main/java/com/loopers/applications/streamer/consumer/product/IncreaseProductTotalSalesKafkaConsumer.javacore/service/src/main/java/com/loopers/core/service/productlike/command/IncreaseProductLikeRankingScoreCommand.javaapps/commerce-streamer/src/main/java/com/loopers/applications/streamer/consumer/product/IncreaseProductLikeRankingScoreKafkaConsumer.javacore/domain/src/main/java/com/loopers/core/domain/product/event/ProductLikeEvent.javacore/domain/src/main/java/com/loopers/core/domain/productlike/event/ProductLikeEventPublisher.javaapps/commerce-streamer/src/main/java/com/loopers/applications/streamer/consumer/product/IncreaseProductViewKafkaConsumer.javacore/domain/src/main/java/com/loopers/core/domain/event/type/EventType.javacore/service/src/main/java/com/loopers/core/service/product/IncreaseProductMetricViewCountService.java
📚 Learning: 2025-12-19T20:59:50.009Z
Learnt from: toongri
Repo: Loopers-dev-lab/loopers-spring-kotlin-template PR: 68
File: docs/week8/round8-detailed-design.md:151-178
Timestamp: 2025-12-19T20:59:50.009Z
Learning: In the Loopers-dev-lab/loopers-spring-kotlin-template repository's Kafka event pipeline, only 5 domain events are intentionally published to Kafka via CloudEventEnvelopeFactory: OrderPaidEventV1, LikeCreatedEventV1, LikeCanceledEventV1, ProductViewedEventV1, and StockDepletedEventV1. Other domain events (OrderCreatedEventV1, OrderCanceledEventV1, PaymentCreatedEventV1, PaymentPaidEventV1, PaymentFailedEventV1) are internal-only and intentionally not mapped in resolveMetadata(), which correctly returns null for them to exclude them from Outbox publication.
Applied to files:
core/service/src/main/java/com/loopers/core/service/productlike/ProductLikeService.javacore/infra/event/src/main/java/com/loopers/core/infra/event/kafka/productlike/publisher/impl/ProductLikeEventPublisherImpl.javaapps/commerce-streamer/src/main/java/com/loopers/applications/streamer/consumer/product/IncreaseProductSalesRankingScoreKafkaConsumer.javaapps/commerce-streamer/src/main/java/com/loopers/applications/streamer/consumer/product/IncreaseProductViewRankingScoreKafkaConsumer.javacore/infra/event/src/main/java/com/loopers/core/infra/event/kafka/productlike/publisher/dto/ProductLikeKafkaEvent.javacore/service/src/main/java/com/loopers/core/service/productlike/ProductLikeEventPublishService.javacore/domain/src/main/java/com/loopers/core/domain/product/event/ProductLikeEvent.javacore/domain/src/main/java/com/loopers/core/domain/productlike/event/ProductLikeEventPublisher.javaapps/commerce-streamer/src/main/resources/application.ymlcore/domain/src/main/java/com/loopers/core/domain/event/type/EventType.java
📚 Learning: 2025-11-27T09:09:24.961Z
Learnt from: sky980221
Repo: Loopers-dev-lab/loopers-spring-java-template PR: 121
File: apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java:22-24
Timestamp: 2025-11-27T09:09:24.961Z
Learning: Product 엔티티 (apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java)는 유즈케이스별로 의도적으로 다른 락 전략을 사용한다: 좋아요 기능에는 비관적 락(findByIdForUpdate)을, 재고 차감에는 낙관적 락(Version + 재시도)을 사용한다.
Applied to files:
core/service/src/main/java/com/loopers/core/service/productlike/ProductLikeService.javacore/domain/src/main/java/com/loopers/core/domain/product/repository/ProductLikeCacheRepository.javacore/domain/src/main/java/com/loopers/core/domain/product/event/ProductLikeEvent.javaapps/commerce-api/src/main/java/com/loopers/application/api/product/ProductV1ApiSpec.java
📚 Learning: 2025-11-21T03:38:07.494Z
Learnt from: minor7295
Repo: Loopers-dev-lab/loopers-spring-java-template PR: 99
File: apps/commerce-api/src/main/resources/application.yml:26-30
Timestamp: 2025-11-21T03:38:07.494Z
Learning: The batch job implementation for likeCount synchronization in apps/commerce-api is temporary and intended for development environment only. It will be replaced with Event-Driven Architecture (EDA) before production deployment, so production-level configuration concerns (like profile-based initialize-schema settings) are not required.
Applied to files:
apps/commerce-api/src/main/java/com/loopers/application/api/product/ProductLikeEventScheduler.javaapps/commerce-streamer/src/main/resources/application.yml
📚 Learning: 2025-12-02T08:12:06.383Z
Learnt from: junoade
Repo: Loopers-dev-lab/loopers-spring-java-template PR: 0
File: :0-0
Timestamp: 2025-12-02T08:12:06.383Z
Learning: ProductQueryService에서 상품 목록 조회 시 Redis 캐시를 적용했으며, 캐시 키는 brandId, sortType, pageNumber, pageSize의 조합으로 구성되고 TTL은 5분으로 설정되어 있다.
Applied to files:
core/service/src/test/java/com/loopers/core/service/product/GetProductRankingServiceIntegrationTest.javaapps/commerce-api/src/test/java/com/loopers/application/api/product/ProductV1ApiIntegrationTest.java
📚 Learning: 2025-12-16T09:44:15.945Z
Learnt from: toongri
Repo: Loopers-dev-lab/loopers-spring-kotlin-template PR: 58
File: apps/commerce-api/src/main/kotlin/com/loopers/domain/payment/PaymentService.kt:157-183
Timestamp: 2025-12-16T09:44:15.945Z
Learning: In PaymentService.requestPgPayment (apps/commerce-api/src/main/kotlin/com/loopers/domain/payment/PaymentService.kt), PG payment requests are idempotent using paymentId as the idempotency key, so Retryable covering the entire method including the pgClient.requestPayment call is safe and will not cause duplicate charges even if retries occur due to ObjectOptimisticLockingFailureException.
Applied to files:
core/service/src/test/resources/application-test.yml
📚 Learning: 2025-12-19T03:23:54.756Z
Learnt from: minor7295
Repo: Loopers-dev-lab/loopers-spring-java-template PR: 191
File: modules/kafka/src/testFixtures/java/com/loopers/testcontainers/KafkaTestContainersConfig.java:24-34
Timestamp: 2025-12-19T03:23:54.756Z
Learning: In the loopers-spring-java-template project, test container configurations (MySQL, Redis, Kafka) use static initialization blocks for consistency across the template. This pattern should be maintained for uniformity, even if alternative lifecycle management approaches exist.
Applied to files:
core/service/src/test/java/com/loopers/core/service/IntegrationTest.javacore/infra/database/redis/build.gradle.kts
📚 Learning: 2025-11-09T10:41:39.297Z
Learnt from: ghojeong
Repo: Loopers-dev-lab/loopers-spring-kotlin-template PR: 25
File: apps/commerce-api/src/main/kotlin/com/loopers/domain/product/ProductRepository.kt:1-12
Timestamp: 2025-11-09T10:41:39.297Z
Learning: In this codebase, domain repository interfaces are allowed to use Spring Data's org.springframework.data.domain.Page and org.springframework.data.domain.Pageable types. This is an accepted architectural decision and should not be flagged as a DIP violation.
Applied to files:
core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/ProductQuerydslRepository.java
📚 Learning: 2025-11-12T13:04:50.782Z
Learnt from: kilian-develop
Repo: Loopers-dev-lab/loopers-spring-java-template PR: 72
File: core/domain/src/main/java/com/loopers/core/domain/productlike/vo/ProductLikeId.java:5-7
Timestamp: 2025-11-12T13:04:50.782Z
Learning: In the com.loopers codebase, domain entity ID value objects (e.g., ProductLikeId, OrderItemId, ProductId, PaymentId, OrderId, BrandId) are system-generated identifiers and do not require pattern validation (regex, length checks). They are implemented as simple records with a String value and an empty() factory method returning null for unsaved entities. This differs from UserIdentifier, which is a user-supplied login ID that requires format validation. Domain IDs should not be flagged for missing validation logic in the create() method.
<!-- [add_learning]
UserIdentifier와 같은 사용자 입력 ID와 ProductLikeId, OrderItemId 등의 도메인 ID는 검증 패턴이 다릅니다. UserIdentifier는 사용자가 입력하는 로그인 ID로서 정규식, 길이 등의 형식 검증이 필요하지만, 도메인 ID는 시스템에서 생성하는 식별자(UUID, DB 생성 ID)이므로 패턴 검증이 불필요합니다. 도메인 ID VO는 단순한 record와 empty() 팩토리 메서드만으로 충분합니다.
Applied to files:
core/domain/src/main/java/com/loopers/core/domain/product/event/ProductLikeEvent.java
📚 Learning: 2025-12-19T03:23:51.674Z
Learnt from: minor7295
Repo: Loopers-dev-lab/loopers-spring-java-template PR: 191
File: modules/kafka/src/testFixtures/java/com/loopers/testcontainers/KafkaTestContainersConfig.java:24-34
Timestamp: 2025-12-19T03:23:51.674Z
Learning: Maintain the use of static initialization blocks in test fixtures for testcontainers configurations (e.g., MySQL, Redis, Kafka) to ensure consistency and predictable initialization across the loopers-spring-java-template project. Apply this pattern to all test-fixture Java files under src/testFixtures/java to preserve uniform lifecycle behavior, unless there's a compelling, well-justified reason to diverge.
Applied to files:
core/infra/database/redis/src/testFixtures/java/com/loopers/core/infra/database/redis/util/RedisCleanUp.java
🧬 Code graph analysis (15)
core/service/src/main/java/com/loopers/core/service/product/GetProductRankingService.java (5)
core/service/src/main/java/com/loopers/core/service/product/ClearProductCacheService.java (1)
Service(10-25)core/service/src/main/java/com/loopers/core/service/product/IncreaseProductMetricViewCountService.java (1)
Service(12-31)core/service/src/main/java/com/loopers/core/service/product/IncreaseProductSalesRankingScoreService.java (1)
Service(20-46)core/service/src/main/java/com/loopers/core/service/product/IncreaseProductViewRankingScoreService.java (1)
Service(13-32)core/service/src/main/java/com/loopers/core/service/productlike/IncreaseProductLikeRankingScoreService.java (1)
Service(13-32)
core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/dto/ProductRankingListProjection.java (1)
core/domain/src/main/java/com/loopers/core/domain/product/vo/ProductRankings.java (1)
Getter(7-39)
apps/commerce-api/src/main/java/com/loopers/application/api/product/ProductLikeEventScheduler.java (1)
core/infra/event/src/main/java/com/loopers/core/infra/event/kafka/productlike/publisher/impl/ProductLikeEventPublisherImpl.java (1)
Component(11-24)
apps/commerce-streamer/src/main/java/com/loopers/applications/streamer/consumer/product/IncreaseProductSalesRankingScoreKafkaConsumer.java (4)
apps/commerce-streamer/src/main/java/com/loopers/applications/streamer/consumer/product/IncreaseProductLikeRankingScoreKafkaConsumer.java (1)
Component(15-36)apps/commerce-streamer/src/main/java/com/loopers/applications/streamer/consumer/product/IncreaseProductTotalSalesKafkaConsumer.java (1)
Component(15-36)apps/commerce-streamer/src/main/java/com/loopers/applications/streamer/consumer/product/IncreaseProductViewKafkaConsumer.java (1)
Component(15-37)apps/commerce-streamer/src/main/java/com/loopers/applications/streamer/consumer/product/IncreaseProductViewRankingScoreKafkaConsumer.java (1)
Component(15-37)
apps/commerce-streamer/src/main/java/com/loopers/applications/streamer/consumer/product/IncreaseProductViewRankingScoreKafkaConsumer.java (4)
apps/commerce-streamer/src/main/java/com/loopers/applications/streamer/consumer/product/IncreaseProductLikeRankingScoreKafkaConsumer.java (1)
Component(15-36)apps/commerce-streamer/src/main/java/com/loopers/applications/streamer/consumer/product/IncreaseProductSalesRankingScoreKafkaConsumer.java (1)
Component(15-36)apps/commerce-streamer/src/main/java/com/loopers/applications/streamer/consumer/product/IncreaseProductTotalSalesKafkaConsumer.java (1)
Component(15-36)apps/commerce-streamer/src/main/java/com/loopers/applications/streamer/consumer/product/IncreaseProductViewKafkaConsumer.java (1)
Component(15-37)
core/service/src/test/java/com/loopers/core/service/product/GetProductRankingServiceIntegrationTest.java (2)
core/domain/src/testFixtures/java/com/loopers/core/domain/brand/BrandFixture.java (1)
BrandFixture(10-35)core/domain/src/testFixtures/java/com/loopers/core/domain/product/ProductFixture.java (1)
ProductFixture(12-73)
core/service/src/main/java/com/loopers/core/service/productlike/ProductLikeEventPublishService.java (1)
core/service/src/main/java/com/loopers/core/service/productlike/ProductLikeService.java (1)
Service(25-66)
core/service/src/main/java/com/loopers/core/service/productlike/IncreaseProductLikeRankingScoreService.java (5)
core/service/src/main/java/com/loopers/core/service/product/ClearProductCacheService.java (1)
Service(10-25)core/service/src/main/java/com/loopers/core/service/product/GetProductRankingService.java (1)
Service(11-24)core/service/src/main/java/com/loopers/core/service/product/IncreaseProductMetricViewCountService.java (1)
Service(12-31)core/service/src/main/java/com/loopers/core/service/product/IncreaseProductSalesRankingScoreService.java (1)
Service(20-46)core/service/src/main/java/com/loopers/core/service/product/IncreaseProductViewRankingScoreService.java (1)
Service(13-32)
core/infra/database/redis/src/testFixtures/java/com/loopers/core/infra/database/redis/util/RedisCleanUp.java (1)
modules/redis/src/testFixtures/java/com/loopers/utils/RedisCleanUp.java (1)
Component(7-20)
core/infra/database/redis/src/main/java/com/loopers/core/infra/database/redis/product/ProductRankingRedisRepositoryImpl.java (2)
apps/commerce-api/src/main/java/com/loopers/application/api/product/ProductRankingCarryOverScheduler.java (1)
Component(8-18)core/infra/database/redis/src/main/java/com/loopers/core/infra/database/redis/product/impl/ProductRankingCacheRepositoryImpl.java (1)
Component(18-89)
core/infra/database/redis/src/main/java/com/loopers/core/infra/database/redis/product/impl/ProductRankingCacheRepositoryImpl.java (1)
core/infra/database/redis/src/main/java/com/loopers/core/infra/database/redis/product/ProductRankingRedisRepositoryImpl.java (1)
Component(16-71)
core/domain/src/main/java/com/loopers/core/domain/product/vo/ProductRankings.java (1)
core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/dto/ProductRankingListProjection.java (1)
Getter(15-48)
core/service/src/main/java/com/loopers/core/service/product/IncreaseProductSalesRankingScoreService.java (5)
core/service/src/main/java/com/loopers/core/service/product/ClearProductCacheService.java (1)
Service(10-25)core/service/src/main/java/com/loopers/core/service/product/GetProductRankingService.java (1)
Service(11-24)core/service/src/main/java/com/loopers/core/service/product/IncreaseProductMetricViewCountService.java (1)
Service(12-31)core/service/src/main/java/com/loopers/core/service/product/IncreaseProductViewRankingScoreService.java (1)
Service(13-32)core/service/src/main/java/com/loopers/core/service/productlike/IncreaseProductLikeRankingScoreService.java (1)
Service(13-32)
apps/commerce-api/src/test/java/com/loopers/application/api/product/ProductV1ApiIntegrationTest.java (1)
apps/commerce-api/src/main/java/com/loopers/application/api/product/ProductV1Dto.java (1)
ProductV1Dto(12-165)
apps/commerce-api/src/main/java/com/loopers/application/api/product/ProductV1ApiSpec.java (1)
apps/commerce-api/src/main/java/com/loopers/application/api/product/ProductV1Dto.java (1)
ProductV1Dto(12-165)
🔇 Additional comments (37)
core/infra/database/redis/src/main/java/com/loopers/core/infra/database/redis/product/ProductLikeRedisRepositoryImpl.java (2)
1-1: 패키지 구조 단순화 승인
impl서브패키지를 제거하여 패키지 구조를 단순화한 것은 좋은 리팩토링입니다. 다른 Redis 컴포넌트들과의 일관성을 유지합니다.
26-32: TTL 재설정 패턴 확인 필요좋아요가 추가될 때마다 TTL이 1일로 재설정됩니다. 이는 자주 좋아요를 받는 상품은 캐시에서 만료되지 않는다는 의미입니다.
이것이 의도된 동작인지 확인하세요:
- 의도된 경우: 활성 상품을 계속 캐시에 유지하는 것이 목적
- 의도되지 않은 경우: 초기 생성 시에만 TTL을 설정하거나, TTL 갱신 로직 재검토 필요
core/domain/src/main/java/com/loopers/core/domain/product/event/ProductLikeEvent.java (1)
1-10: LGTM!깔끔한 도메인 이벤트 정의입니다. 필요한 필드만 포함하고 있으며 record 패턴을 잘 활용하고 있습니다.
apps/commerce-streamer/src/main/resources/application.yml (1)
43-50: 랭킹 점수 가중치 합계를 확인하세요.테스트 설정과 동일하게 가중치 합계가 1.1입니다 (like: 0.2 + view: 0.1 + pay: 0.7 + carryOver: 0.1).
이벤트 가중치(like, view, pay)와 이월 계수(carryOver)가 동일한 방식으로 합산되는 것이 의도된 설계인지 확인하세요. 일반적으로:
- 이벤트 가중치는 합이 1.0이 되도록 정규화
- carryOver는 이전 점수의 감쇠/보존 비율로 별도 처리
core/domain/src/main/java/com/loopers/core/domain/productlike/event/ProductLikeEventPublisher.java (1)
1-8: LGTM!명확하고 간결한 인터페이스 정의입니다. 단일 책임 원칙을 잘 따르고 있습니다.
core/infra/database/redis/src/main/java/com/loopers/core/infra/database/redis/product/ProductRankingRedisRepositoryImpl.java (1)
20-22: TTL 설정이 비즈니스 요구사항과 일치하는지 확인하세요.랭킹 데이터의 TTL이 2일로 설정되어 있습니다. 이것이 비즈니스 요구사항에 부합하는지 확인하세요:
- 일일 랭킹 스케줄러가 매일 자정 전(23:50)에 실행되는 것으로 보임
- carryOver가 이전 날짜 데이터를 사용
- 2일 TTL이면 어제와 오늘의 데이터만 유지됨
carryOver 로직이 2일 이상 이전의 데이터를 참조하지 않는다면 적절하지만, 시스템 장애나 스케줄러 지연으로 carryOver가 늦어질 경우 데이터 손실이 발생할 수 있습니다.
안전 여유를 위해 TTL을 3일로 늘리는 것을 고려하세요.
core/service/src/test/resources/application-test.yml (1)
26-33: 가중치 설정이 올바르게 구성되어 있습니다.Event 가중치(like: 0.2, view: 0.1, pay: 0.7)의 합계는 1.0으로 정규화되어 있으며, carryOver(0.1)는 완전히 별도의 감쇠 계수로 운영됩니다. 세 가지 Event 서비스(IncreaseProductLikeRankingScoreService, IncreaseProductViewRankingScoreService, IncreaseProductSalesRankingScoreService)는 각각 독립적으로 자신의 가중치를 점수에 더하고, ProductRankingCarryOverService는 매일 자정에 실행되어 전날의 점수를 감쇠 계수로 곱해 다음날로 이월합니다. 따라서 1.1의 합계는 문제가 아니며, 가중치 설계는 의도된 대로 정상 작동합니다.
core/infra/database/redis/build.gradle.kts (1)
2-12: LGTM!테스트 픽스처 플러그인과 의존성 추가가 적절합니다. 다른 모듈(예:
core/service)에서 Redis 테스트 픽스처를 활용할 수 있도록 잘 구성되었습니다.core/domain/src/main/java/com/loopers/core/domain/event/type/EventType.java (1)
14-18: LGTM!랭킹 기능을 위한 새로운 이벤트 타입들이 기존 네이밍 컨벤션과 일관성 있게 추가되었습니다. 좋아요/조회/판매 이벤트에 대한 명확한 분리가 잘 되어 있습니다.
apps/commerce-api/src/main/resources/application.yml (1)
35-42: 가중치 설정이 적절해 보입니다.
like(0.2) + view(0.1) + pay(0.7) = 1.0으로 정규화된 가중치 구성이 잘 되어 있습니다.carryOver(0.1)는 이전 기간 점수 이월에 사용되는 것으로 보입니다.환경별(dev/qa/prd)로 다른 가중치가 필요한지 확인해 주세요. 프로덕션 환경에서 A/B 테스트나 튜닝이 필요할 경우 환경별 오버라이드를 고려할 수 있습니다.
apps/commerce-streamer/src/main/java/com/loopers/applications/streamer/consumer/product/IncreaseProductViewKafkaConsumer.java (1)
21-35: 명시적 groupId 추가가 좋습니다.컨슈머 그룹 식별을 위한
groupId추가가 적절합니다. 독립적인 오프셋 추적과 모니터링에 도움이 됩니다.배치 처리 중 일부 레코드 실패 시에도
acknowledgment.acknowledge()가 호출되는 점을 인지해 주세요. 학습된 패턴에 따르면EventInboxAspect를 통해 중앙화된 에러 처리가 이루어지므로 현재 구조가 의도된 것이라면 문제없습니다.core/domain/src/main/java/com/loopers/core/domain/product/repository/ProductRepository.java (1)
37-38: LGTM!
findRankingListBy메서드 추가가 기존 레포지토리 패턴과 일관성 있게 구현되었습니다. 도메인 객체(ProductRankingList)를 반환하여 계층 간 의존성이 적절히 관리됩니다.구현체에서 빈
productIds리스트가 전달될 경우의 처리가 되어 있는지 확인해 주세요.core/service/build.gradle.kts (1)
25-25: LGTM!Redis 테스트 픽스처 의존성이 기존 MySQL 테스트 픽스처 패턴과 일관성 있게 추가되었습니다.
apps/commerce-streamer/src/main/java/com/loopers/applications/streamer/consumer/product/IncreaseProductTotalSalesKafkaConsumer.java (1)
21-35: LGTM!
IncreaseProductViewKafkaConsumer와 동일한 패턴으로groupId가 추가되어 일관성이 유지됩니다. 판매 이벤트에 대한 독립적인 컨슈머 그룹 관리가 가능해집니다.core/infra/database/redis/src/main/java/com/loopers/core/infra/database/redis/product/entity/ProductDetailRedisEntity.java (1)
31-34: 팩토리 메서드 사용으로의 리팩토링이 적절합니다.생성자 호출을 정적 팩토리 메서드로 변경하여 도메인 레이어의 변경사항과 일치시켰습니다.
core/service/src/main/java/com/loopers/core/service/product/query/GetProductRankingQuery.java (1)
5-9: 페이지네이션 쿼리 레코드가 깔끔합니다.불변 데이터 캐리어로서 적절한 구조입니다. pageNo와 pageSize에 대한 유효성 검증은 API 레이어나 서비스 레이어에서 처리되는 것으로 보입니다.
core/infra/event/src/main/java/com/loopers/core/infra/event/kafka/productlike/publisher/dto/ProductLikeKafkaEvent.java (1)
5-12: Kafka 이벤트 DTO 구조가 적절합니다.도메인 이벤트를 Kafka 이벤트로 변환하는 간결한 구조입니다. productId를 키로 사용하여 동일 상품에 대한 이벤트가 같은 파티션으로 전송되도록 보장합니다.
Based on learnings, 같은 상품에 대한 좋아요 이벤트를 동일 파티션으로 라우팅하여 순서 보장과 통계 집계를 지원하는 패턴입니다.
core/service/src/main/java/com/loopers/core/service/product/command/IncreaseProductSalesRankingScoreCommand.java (1)
3-6: 판매 랭킹 점수 증가 커맨드가 깔끔합니다.불변 커맨드 객체로서 적절한 구조입니다. paymentId를 통해 결제 이벤트와 연결됩니다.
apps/commerce-streamer/src/main/java/com/loopers/applications/streamer/consumer/product/dto/IncreaseProductViewRankingScoreEvent.java (1)
5-9: 이벤트-커맨드 변환 패턴이 적절합니다.Kafka 컨슈머가 이벤트를 커맨드로 변환하여 서비스 레이어로 전달하는 깔끔한 구조입니다.
Based on learnings, 중앙화된 에러 처리(EventInboxAspect)를 활용하므로 컨슈머 레이어에서 단순히 변환 로직만 담당하는 것이 적절합니다.
core/service/src/main/java/com/loopers/core/service/productlike/ProductLikeService.java (1)
44-53: Transactional Outbox 패턴이 올바르게 구현되었습니다.좋아요 상태 변경 후 이벤트를 outbox에 저장하여 최종적 일관성을 보장합니다. 동일 트랜잭션 내에서 처리되므로 상태 변경과 이벤트 발행의 원자성이 보장됩니다.
Based on learnings, AggregateType.PRODUCT와 productId를 사용하여 동일 상품에 대한 이벤트를 같은 파티션으로 라우팅하고 순서를 보장하는 패턴입니다.
core/service/src/main/java/com/loopers/core/service/productlike/command/IncreaseProductLikeRankingScoreCommand.java (1)
3-6: 좋아요 랭킹 점수 증가 커맨드가 깔끔합니다.불변 커맨드 객체로서 적절한 구조입니다. 좋아요 이벤트를 기반으로 랭킹 점수를 업데이트하는 플로우에 사용됩니다.
apps/commerce-streamer/src/main/java/com/loopers/applications/streamer/consumer/product/dto/IncreaseProductLikeRankingScoreEvent.java (1)
5-12: 구현이 깔끔하고 적절합니다.이벤트 DTO가 명확하게 정의되어 있고,
toCommand()메서드를 통한 커맨드 변환도 올바르게 구현되었습니다.core/domain/src/main/java/com/loopers/core/domain/product/vo/ProductRanking.java (1)
3-7: 도메인 값 객체가 올바르게 정의되었습니다.
ProductRanking레코드가 랭킹 데이터를 표현하는 값 객체로 적절하게 설계되었습니다.core/domain/src/main/java/com/loopers/core/domain/product/vo/ProductRankings.java (1)
7-38: 페이지네이션 메타데이터를 포함한 값 객체가 잘 설계되었습니다.
ProductRankings클래스가 랭킹 데이터와 페이지네이션 정보를 적절하게 캡슐화하고 있으며,getProducts()헬퍼 메서드를 통해 편리한 데이터 추출을 제공합니다.core/service/src/main/java/com/loopers/core/service/product/ProductQueryService.java (1)
70-81: 랭킹 데이터 통합이 잘 구현되었습니다.캐시 조회 여부와 관계없이 랭킹 데이터를 별도로 가져와서
ProductDetail에 첨부하는 방식이 적절합니다. 랭킹 데이터는 자주 변경될 수 있으므로 제품 상세 캐시와 분리하여 관리하는 것이 올바른 설계입니다.apps/commerce-api/src/test/java/com/loopers/application/api/product/ProductV1ApiIntegrationTest.java (1)
147-214: 랭킹 API 통합 테스트가 잘 작성되었습니다.HTTP 상태 코드 검증과 페이지네이션 정보 검증이 포함되어 있어 기본적인 테스트 커버리지가 확보되었습니다.
core/service/src/main/java/com/loopers/core/service/product/IncreaseProductViewRankingScoreService.java (1)
13-31: 구현이 기존 패턴과 일관성 있게 작성되었습니다.
IncreaseProductSalesRankingScoreService및IncreaseProductLikeRankingScoreService와 동일한 패턴을 따르고 있어 코드 일관성이 잘 유지되었습니다.core/infra/event/src/main/java/com/loopers/core/infra/event/kafka/productlike/publisher/impl/ProductLikeEventPublisherImpl.java (1)
11-23: 구현이 올바릅니다!Kafka 메시지 발행 패턴이 적절합니다.
productId를 메시지 키로 사용하여 동일 상품의 이벤트들이 같은 파티션으로 전송되도록 보장하고 있으며, 이는 순서 보장과 통계 집계 패턴에 부합합니다.Based on learnings, 동일 상품의 좋아요 이벤트는 파티셔닝을 위해 productId를 키로 사용해야 합니다.
apps/commerce-streamer/src/main/java/com/loopers/applications/streamer/consumer/product/IncreaseProductSalesRankingScoreKafkaConsumer.java (1)
15-36: 구현 패턴이 일관되게 적용되었습니다.배치 리스너 패턴과 수동 확인(acknowledgment) 처리가 다른 컨슈머들과 동일하게 구현되어 있습니다.
Based on learnings, 에러 처리는 서비스 레이어의
@InboxEvent어노테이션을 통해 중앙화되어 있으므로, 컨슈머에서 별도의 에러 처리 로직이 없는 것이 의도된 설계입니다.apps/commerce-streamer/src/main/java/com/loopers/applications/streamer/consumer/product/IncreaseProductViewRankingScoreKafkaConsumer.java (1)
15-37: 구현이 다른 컨슈머들과 일관됩니다.배치 리스너 패턴이 일관되게 적용되어 있으며, 중앙화된 에러 처리를 활용합니다.
Based on learnings,
@InboxEvent어노테이션을 통한 중앙화된 에러 처리 패턴을 따르고 있습니다.apps/commerce-streamer/src/main/java/com/loopers/applications/streamer/consumer/product/dto/IncreaseProductSalesRankingScoreEvent.java (1)
5-13: DTO 설계가 적절합니다.이벤트에서 커맨드로의 변환이 명확하며, record 타입을 사용하여 불변성을 보장합니다.
apps/commerce-api/src/main/java/com/loopers/application/api/product/ProductV1Api.java (1)
65-75: 새 랭킹 엔드포인트가 잘 구현되었습니다.날짜 포맷 지정과 페이지네이션 기본값 설정이 적절합니다.
core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/dto/ProductRankingListProjection.java (1)
37-47: LGTM!to()메서드의 null 값들은 의도된 설계입니다.MySQL에서는 상품의 기본 정보만 조회하고, ranking과 score는 Redis 캐시에서 별도로 조회하여
ProductRankingList.with(ProductRankings)메서드를 통해 병합되는 구조로 이해됩니다.apps/commerce-api/src/main/java/com/loopers/application/api/product/ProductV1ApiSpec.java (1)
40-44: 새 랭킹 API 엔드포인트 추가가 적절합니다.날짜 기반 랭킹 조회와 페이지네이션 파라미터가 잘 설계되어 있습니다.
core/domain/src/main/java/com/loopers/core/domain/product/ProductDetail.java (1)
14-28: LGTM! 불변 패턴이 잘 적용되었습니다.
create()팩토리 메서드와with()메서드를 통해 랭킹 데이터를 나중에 첨부할 수 있는 유연한 구조입니다. 도메인 객체의 불변성을 유지하면서 점진적 데이터 구성이 가능합니다.core/domain/src/main/java/com/loopers/core/domain/product/ProductRankingList.java (1)
45-57:ProductRankingItemrecord 구조가 적절합니다.도메인 값 객체들을 활용한 타입 안전한 설계입니다. 단,
with(ProductRanking)메서드의 null 안전성은 위에서 언급한 대로 보완이 필요합니다.apps/commerce-api/src/main/java/com/loopers/application/api/product/ProductV1Dto.java (1)
91-95: LGTM! null 안전한 랭킹 매핑입니다.
Optional.ofNullable을 사용하여 랭킹 데이터가 없는 경우를 적절히 처리하고 있습니다.
| @Scheduled(fixedDelay = 1000) | ||
| public void publish() { | ||
| service.publish(); | ||
| } |
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.
1초 간격의 스케줄링이 과도할 수 있습니다.
fixedDelay = 1000은 매우 짧은 간격으로, 프로덕션 환경에서 불필요한 부하를 초래할 수 있습니다. 이벤트 발행 빈도와 비즈니스 요구사항을 고려하여 적절한 간격(예: 5초 또는 10초)으로 조정하는 것을 권장합니다.
추가로, 분산 환경에서 여러 인스턴스가 동시에 실행될 경우 중복 발행이 발생할 수 있습니다. ShedLock과 같은 분산 락 메커니즘 도입을 고려하세요.
🔎 제안하는 수정사항
- @Scheduled(fixedDelay = 1000)
+ @Scheduled(fixedDelay = 5000) // Consider 5-10 seconds based on business requirements
public void publish() {
service.publish();
}분산 락 적용 예시:
+ @SchedulerLock(name = "ProductLikeEventScheduler_publish", lockAtMostFor = "4s", lockAtLeastFor = "1s")
@Scheduled(fixedDelay = 5000)
public void publish() {
service.publish();
}🤖 Prompt for AI Agents
In
apps/commerce-api/src/main/java/com/loopers/application/api/product/ProductLikeEventScheduler.java
around lines 14 to 17, the scheduled task runs every 1 second which is likely
too frequent for production and can cause unnecessary load and duplicate events
in distributed deployments; increase the fixedDelay to a safer interval (for
example 5000 or 10000 ms) according to business needs and integrate a
distributed lock (e.g., ShedLock or similar) around the publish() invocation so
only one instance executes the task at a time.
| @Scheduled(cron = "0 50 23 * * *") | ||
| public void carryOver() { | ||
| service.carryOver(); | ||
| } |
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.
타임존 설정 및 분산 락이 누락되었습니다.
현재 크론 표현식에 타임존이 지정되지 않아 서버의 기본 타임존을 사용합니다. 이는 다음 문제를 야기할 수 있습니다:
- 타임존 불일치: 여러 인스턴스가 다른 타임존에서 실행될 경우 예상과 다른 시간에 실행됩니다.
- 중복 실행: 분산 환경에서 모든 인스턴스가 동시에 carry-over를 실행하여 데이터 불일치가 발생할 수 있습니다.
23:50에 실행되는 일일 처리 작업이므로, 명확한 타임존 지정과 분산 락이 필수적입니다.
🔎 제안하는 수정사항
- @Scheduled(cron = "0 50 23 * * *")
+ @SchedulerLock(name = "ProductRankingCarryOverScheduler_carryOver",
+ lockAtMostFor = "10m", lockAtLeastFor = "1m")
+ @Scheduled(cron = "0 50 23 * * *", zone = "Asia/Seoul")
public void carryOver() {
service.carryOver();
}Committable suggestion skipped: line range outside the PR's diff.
|
|
||
| import java.time.LocalDate; | ||
|
|
||
| import static com.loopers.application.api.product.ProductV1Dto.GetProductDetailResponse.GetProductRankingsResponse; |
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.
🛠️ Refactor suggestion | 🟠 Major
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Find the definition of GetProductRankingsResponse
rg -A 10 "record GetProductRankingsResponse" --type javaRepository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 1477
🏁 Script executed:
#!/bin/bash
# Also search for GetProductDetailResponse to see the full structure
rg -B 5 -A 20 "record GetProductDetailResponse" --type javaRepository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 3333
🏁 Script executed:
#!/bin/bash
# Check all usages of GetProductRankingsResponse across the codebase
rg "GetProductRankingsResponse" --type javaRepository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 2634
🏁 Script executed:
#!/bin/bash
# Get the full structure around GetProductDetailResponse and GetProductRankingsResponse
sed -n '90,180p' apps/commerce-api/src/main/java/com/loopers/application/api/product/ProductV1Dto.javaRepository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 2799
🏁 Script executed:
#!/bin/bash
# Count the record definitions in ProductV1Dto to understand the structure
rg "record Get" apps/commerce-api/src/main/java/com/loopers/application/api/product/ProductV1Dto.java --type javaRepository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 403
GetProductRankingsResponse를 ProductV1Dto의 최상위 레벨로 이동해야 합니다.
현재 GetProductRankingsResponse는 GetProductDetailResponse 내부에 중첩되어 있으나, 이는 설계 오류입니다. GetProductRankingsResponse는 상품 랭킹 목록 조회 API(getProductRankings)의 응답으로 사용되며, 상품 상세 조회 API(getProductDetail)의 응답인 GetProductDetailResponse와 전혀 다른 목적을 가집니다. 두 응답 구조가 독립적이므로 GetProductRankingsResponse와 ProductRankingResponse는 ProductV1Dto의 최상위 레벨로 옮기기를 권장합니다.
🤖 Prompt for AI Agents
In
apps/commerce-api/src/test/java/com/loopers/application/api/product/ProductV1ApiIntegrationTest.java
around line 25, the test imports a nested class GetProductRankingsResponse from
ProductV1Dto.GetProductDetailResponse which is incorrect; move
GetProductRankingsResponse (and ProductRankingResponse) out of the
GetProductDetailResponse inner class and promote them to top-level static inner
classes (or separate classes) directly under ProductV1Dto, update the class
declarations and constructors accordingly, then update all imports/usages
(including this test) to import
com.loopers.application.api.product.ProductV1Dto.GetProductRankingsResponse (and
ProductRankingResponse) from the new top-level location and run compilation to
fix any reference errors.
| public void listen( | ||
| List<ConsumerRecord<Object, String>> records, | ||
| Acknowledgment acknowledgment | ||
| ) { | ||
| records.stream() | ||
| .map(event -> JacksonUtil.convertToObject(event.value(), IncreaseProductLikeRankingScoreEvent.class)) | ||
| .map(IncreaseProductLikeRankingScoreEvent::toCommand) | ||
| .forEach(service::increase); | ||
| acknowledgment.acknowledge(); | ||
| } |
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.
에러 처리 및 중복 방지 로직이 누락되었습니다.
현재 구현에는 두 가지 중요한 문제가 있습니다:
-
에러 처리 부재: 배치 처리 중 하나의 이벤트라도 실패하면 전체 배치가 손실됩니다. 학습된 패턴에 따르면,
service.increase()메서드에@InboxEvent어노테이션을 적용하여EventInboxAspect를 통한 중앙화된 에러 처리를 사용해야 합니다. -
멱등성 보장 부재:
eventId가 중복 이벤트를 방지하는 데 사용되지 않습니다. 재처리 시 동일한 이벤트가 여러 번 처리될 수 있습니다.
Based on learnings, centralized error handling via EventInboxAspect should be adopted.
🔎 제안하는 수정사항
서비스 메서드에 @InboxEvent 어노테이션 추가:
// In IncreaseProductLikeRankingScoreService.java
@InboxEvent
public void increase(IncreaseProductLikeRankingScoreCommand command) {
// existing logic
}또는 컨슈머에서 각 이벤트별 에러 처리:
public void listen(
List<ConsumerRecord<Object, String>> records,
Acknowledgment acknowledgment
) {
- records.stream()
- .map(event -> JacksonUtil.convertToObject(event.value(), IncreaseProductLikeRankingScoreEvent.class))
- .map(IncreaseProductLikeRankingScoreEvent::toCommand)
- .forEach(service::increase);
+ records.forEach(record -> {
+ try {
+ IncreaseProductLikeRankingScoreEvent event =
+ JacksonUtil.convertToObject(record.value(), IncreaseProductLikeRankingScoreEvent.class);
+ service.increase(event.toCommand());
+ } catch (Exception e) {
+ // Log error but continue processing other events
+ log.error("Failed to process event: {}", record.value(), e);
+ }
+ });
acknowledgment.acknowledge();
}🤖 Prompt for AI Agents
In
apps/commerce-streamer/src/main/java/com/loopers/applications/streamer/consumer/product/IncreaseProductLikeRankingScoreKafkaConsumer.java
around lines 26-35, the consumer lacks per-event error handling and idempotency:
update the service method (IncreaseProductLikeRankingScoreService.increase) to
be annotated with @InboxEvent so EventInboxAspect provides centralized error
handling and duplicate protection, ensure the command object carries the eventId
when mapping from the event so the inbox can deduplicate, and adjust the
consumer to process records individually (apply the mapping to commands
one-by-one and invoke service.increase for each) while only acknowledging the
Kafka batch after all records processed successfully (or consider acknowledging
per-record after successful handling) so failed events don’t cause silent loss.
| records.stream() | ||
| .map(event -> JacksonUtil.convertToObject(event.value(), IncreaseProductSalesRankingScoreEvent.class)) | ||
| .map(IncreaseProductSalesRankingScoreEvent::toCommand) | ||
| .forEach(service::increase); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
# Find JacksonUtil implementation
fd -type f -name "*JacksonUtil*"Repository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 258
🏁 Script executed:
# Search for JacksonUtil.convertToObject implementation
rg -n "def convertToObject|public.*convertToObject" --type=javaRepository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 221
🏁 Script executed:
# Look for the specific file mentioned in the review
fd -type f -name "IncreaseProductSalesRankingScoreKafkaConsumer.java"Repository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 258
🏁 Script executed:
# Search for exception handling patterns in Kafka consumers
rg -n "catch|throws|try" --type=java -g "**/*KafkaConsumer.java" -A 2 -B 2Repository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 70
🏁 Script executed:
# Read JacksonUtil implementation
cat -n supports/jackson/src/main/java/com/loopers/JacksonUtil.javaRepository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 1749
🏁 Script executed:
# Find the Kafka consumer file
find . -name "IncreaseProductSalesRankingScoreKafkaConsumer.java"Repository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 228
🏁 Script executed:
# Search for KafkaConsumer and error handling patterns
rg -n "catch|throws|ConsumerErrorHandler|DeadLetter" -A 3 -B 1Repository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 24499
🏁 Script executed:
# Read the Kafka consumer file
cat -n ./apps/commerce-streamer/src/main/java/com/loopers/applications/streamer/consumer/product/IncreaseProductSalesRankingScoreKafkaConsumer.javaRepository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 1778
🏁 Script executed:
# Check for Kafka error handlers in the streamer module
find ./apps/commerce-streamer -name "*.java" -type f -exec grep -l "CommonErrorHandler\|DeadLetterPublishingRecoverer\|ErrorHandler" {} \;Repository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 70
🏁 Script executed:
# Check Kafka consumer properties and configurations
find ./apps/commerce-streamer -name "*.yml" -o -name "*.yaml" -o -name "*.properties" | head -20Repository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 147
🏁 Script executed:
# Check KafkaConfig to understand batch listener configuration and error handling
cat -n ./apps/commerce-streamer/src/main/java/com/loopers/core/infra/event/kafka/config/KafkaConfig.javaRepository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 217
🏁 Script executed:
# Check application.yml for Kafka consumer properties
cat -n ./apps/commerce-streamer/src/main/resources/application.ymlRepository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 2038
🏁 Script executed:
# Look for any global error handlers or interceptors
find ./apps/commerce-streamer -name "*.java" -type f -exec grep -l "ConsumerAwareListenerErrorHandler\|ErrorHandler" {} \;Repository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 70
🏁 Script executed:
# Find kafka.yml file
find ./apps/commerce-streamer -name "kafka.yml" -type fRepository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 70
🏁 Script executed:
# Find KafkaConfig file anywhere in the codebase
find . -name "KafkaConfig.java" -type fRepository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 192
🏁 Script executed:
# Search for BATCH_LISTENER constant to find where it's defined
rg -n "BATCH_LISTENER" -A 10Repository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 15881
🏁 Script executed:
# Check if there's any error handler in the streamer module
find ./apps/commerce-streamer -name "*.java" | head -30Repository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 1765
🏁 Script executed:
# Read the complete KafkaConfig.java file
cat -n ./core/infra/event/kafka-config/src/main/java/com/loopers/core/infra/event/kafka/config/KafkaConfig.javaRepository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 4458
🏁 Script executed:
# Check kafka.yml configuration file
find . -name "kafka.yml" -type f -exec cat -n {} \;Repository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 1584
🏁 Script executed:
# Check if there's any CommonErrorHandler or global error handling defined
rg -n "CommonErrorHandler|ErrorHandler|setErrorHandler" --type=javaRepository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 70
🏁 Script executed:
# Look at the full Kafka configuration for error handlers
find . -name "*KafkaListener*" -o -name "*ErrorHandler*" | grep -i javaRepository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 70
🏁 Script executed:
# Check Spring Kafka listener error handler annotation usage
rg -n "@KafkaListener" -A 15 apps/commerce-streamerRepository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 16761
역직렬화 실패 시 무한 재처리 시나리오가 실제로 발생합니다.
JacksonUtil.convertToObject()가 실패하면 RuntimeException이 발생하고, 현재 코드에서는 이를 처리하지 않습니다. MANUAL 커밋 모드에서 예외가 발생하면 acknowledgment.acknowledge()이 호출되지 않아 메시지가 커밋되지 않으며, 공식 에러 핸들러가 없는 상태에서 Kafka는 해당 배치를 계속 재처리합니다. 잘못된 형식의 메시지로 인해 무한 재시도 루프가 발생할 수 있습니다.
다음 중 하나로 해결하세요:
- 스트림 처리를 try-catch로 감싸고 역직렬화 실패 레코드를 필터링
KafkaConfig에서CommonErrorHandler또는DeadLetterPublishingRecoverer설정- 또는 역직렬화 실패 시 로그하고 계속 진행하는 방식으로 개별 메시지 처리
🤖 Prompt for AI Agents
In
apps/commerce-streamer/src/main/java/com/loopers/applications/streamer/consumer/product/IncreaseProductSalesRankingScoreKafkaConsumer.java
around lines 30-33, the stream currently throws a RuntimeException on Jackson
deserialization failure which prevents acknowledgment and causes infinite
reprocessing; change to process records individually with safe deserialization
(e.g., map each record through a try-catch or Optional-returning converter, log
deserialization errors and skip those records), only call service::increase for
successfully deserialized commands, and ensure acknowledgment. Alternatively,
configure a CommonErrorHandler/DeadLetterPublishingRecoverer in KafkaConfig to
route bad records to a DLQ; pick one approach, implement per-record try-catch +
logging and skipping or add the error handler and DLQ configuration so malformed
messages are not retried indefinitely.
| aggregateIdField = "id" | ||
| ) | ||
| public void clear(ClearProductCacheCommand command) { | ||
| productCacheRepository.delete(new ProductId(command.productId())); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Find ClearProductCacheCommand definition
echo "=== ClearProductCacheCommand definition ==="
fd -type f -name "*.java" | xargs rg -l "class ClearProductCacheCommand|record ClearProductCacheCommand" 2>/dev/null
# Get more detailed info
echo -e "\n=== ClearProductCacheCommand details ==="
fd -type f -name "*ClearProductCacheCommand*" 2>/dev/null | head -20
# Search for ClearProductCacheCommand class/record definition
echo -e "\n=== Definition content ==="
rg "class ClearProductCacheCommand|record ClearProductCacheCommand" -A 15 2>/dev/null | head -40Repository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 1072
🏁 Script executed:
#!/bin/bash
# Check for all usages of ClearProductCacheCommand
echo "=== ClearProductCacheCommand constructor usages ==="
rg "new ClearProductCacheCommand|ClearProductCacheCommand(" -A 3 -B 1 2>/dev/null | head -80
# Check for CLEAR_PRODUCT_CACHE event references
echo -e "\n=== CLEAR_PRODUCT_CACHE event usage ==="
rg "CLEAR_PRODUCT_CACHE" -B 2 -A 2 2>/dev/null | head -60Repository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 1324
🏁 Script executed:
#!/bin/bash
# Check the ClearProductCacheService file context
echo "=== ClearProductCacheService full content ==="
cat -n core/service/src/main/java/com/loopers/core/service/product/ClearProductCacheService.java 2>/dev/null || \
fd -name "ClearProductCacheService.java" -exec cat -n {} \; 2>/dev/null | head -50Repository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 1155
aggregateIdField를 "id"에서 "productId"로 변경하세요.
ClearProductCacheCommand 레코드는 eventId와 productId 필드만 보유하고 있습니다. 하지만 @InboxEvent 어노테이션의 aggregateIdField = "id"는 존재하지 않는 필드를 참조하고 있어 이벤트 처리 시 런타임 오류가 발생합니다. 라인 20의 aggregateIdField를 "productId"로 수정하세요.
🤖 Prompt for AI Agents
In
core/service/src/main/java/com/loopers/core/service/product/ClearProductCacheService.java
around lines 20 to 23, the @InboxEvent annotation incorrectly sets
aggregateIdField = "id" which does not exist on ClearProductCacheCommand; change
aggregateIdField to "productId" so it matches the record field, keeping the rest
of the method (productCacheRepository.delete(new
ProductId(command.productId()))) unchanged.
| public ProductRankingList getRanking(GetProductRankingQuery query) { | ||
| ProductRankings rankings = productRankingCacheRepository.getRankings(query.date(), query.pageNo(), query.pageSize()); | ||
| ProductRankingList rankingList = productRepository.findRankingListBy(rankings.getProducts()); | ||
|
|
||
| return rankingList.with(rankings); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
# Find ProductRankingList.with() method
rg -n "def with|\.with\(" --type=java -A 3 -B 1 | head -40Repository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 3033
🏁 Script executed:
# Find ProductRankingList class
fd -type f -name "*ProductRankingList*" --type fRepository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 258
🏁 Script executed:
# Find ProductRepository interface/class
fd -type f -name "*ProductRepository*" --type fRepository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 258
🏁 Script executed:
# Search for findRankingListBy method
rg -n "findRankingListBy" --type=java -A 5Repository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 2047
🏁 Script executed:
# Find ProductRankings class
fd -type f -name "*ProductRankings*" --type fRepository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 258
🏁 Script executed:
# Find and read ProductRankingList.java
find . -name "ProductRankingList.java" -type f -exec cat {} \;Repository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 2012
🏁 Script executed:
# Read ProductRepositoryImpl.findRankingListBy method completely
rg -n "findRankingListBy" -A 15 --type javaRepository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 3102
🏁 Script executed:
# Search for ProductRankings.getProducts()
rg -n "getProducts" -B 2 -A 8 --type java | head -50Repository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 1945
🏁 Script executed:
# Verify ProductRankings to confirm it can be empty
cat $(find . -name "ProductRankings.java" -type f)Repository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 978
🏁 Script executed:
# Check if getRankings() can return empty rankings
rg -n "getRankings" -B 2 -A 10 | grep -A 10 "getRankings"Repository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 5673
ProductRankingList.with() 메서드에서 NullPointerException 위험
캐시가 반환하는 랭킹 데이터가 ProductRepository에서 조회한 상품과 완전히 일치하지 않을 경우, rankingsByProductId.get(item.id())이 null을 반환하고, 이 null 값을 item.with(ranking)에 전달하게 됩니다. ProductRankingItem.with() 메서드는 null 체크 없이 ranking.ranking() 및 ranking.score()를 호출하므로 NullPointerException이 발생합니다.
빈 랭킹이나 누락된 상품에 대해 안전하게 처리할 수 있도록 수정해야 합니다.
| @InboxEvent( | ||
| aggregateType = "PRODUCT", | ||
| eventType = "INCREASE_PRODUCT_LIKE_RANKING_SCORE", | ||
| eventIdField = "eventId", | ||
| aggregateIdField = "id" | ||
| ) | ||
| public void increase(IncreaseProductLikeRankingScoreCommand command) { | ||
| ProductId productId = new ProductId(command.productId()); | ||
| productRankingCacheRepository.increaseDaily(productId, LocalDate.now(), weight); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Find the IncreaseProductLikeRankingScoreCommand class definition
find . -type f -name "*IncreaseProductLikeRankingScoreCommand*" 2>/dev/null | head -20Repository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 205
🏁 Script executed:
cat ./core/service/src/main/java/com/loopers/core/service/productlike/command/IncreaseProductLikeRankingScoreCommand.javaRepository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 251
🏁 Script executed:
#!/bin/bash
# Find and compare similar service classes to verify the correct pattern
fd "IncreaseProductViewRankingScoreService.java" --exec cat {}Repository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 1263
🏁 Script executed:
fd "IncreaseProductViewRankingScoreCommand.java" --exec cat {}Repository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 247
🏁 Script executed:
#!/bin/bash
# Search for all @InboxEvent annotations to identify the pattern
rg "@InboxEvent" -A 5 --type javaRepository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 4918
🏁 Script executed:
#!/bin/bash
# Check ClearProductCacheService command to verify the pattern
fd "ClearProductCacheCommand.java" --exec cat {}Repository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 233
aggregateIdField 값이 올바르지 않습니다.
aggregateIdField = "id"로 설정되어 있지만, IncreaseProductLikeRankingScoreCommand는 id 필드가 없고 productId 필드만 있습니다. 같은 문제가 IncreaseProductViewRankingScoreService, ClearProductCacheService, IncreaseProductMetricViewCountService에서도 발견됩니다. aggregateIdField를 "productId"로 수정해야 합니다.
🤖 Prompt for AI Agents
In
core/service/src/main/java/com/loopers/core/service/productlike/IncreaseProductLikeRankingScoreService.java
around lines 22 to 31, the InboxEvent annotation uses aggregateIdField = "id"
but the command object exposes productId; change aggregateIdField to "productId"
so the framework maps the aggregate id correctly. Also apply the same change
(aggregateIdField -> "productId") in the other affected services:
IncreaseProductViewRankingScoreService, ClearProductCacheService, and
IncreaseProductMetricViewCountService.
| outboxes.stream() | ||
| .map(EventOutbox::getPayload) | ||
| .map(payload -> JacksonUtil.convertToObject(payload.value(), ProductLikeEvent.class)) | ||
| .forEach(publisher::publish); | ||
|
|
||
| outboxes.stream() | ||
| .map(EventOutbox::publish) | ||
| .forEach(eventOutboxRepository::save); |
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가
published로 표시됨 - 발행되지 않은 이벤트가 발행된 것으로 잘못 기록될 수 있음
각 이벤트를 개별적으로 처리하여 발행 성공 시에만 상태를 업데이트하는 것을 권장합니다.
🔎 수정 제안
- outboxes.stream()
- .map(EventOutbox::getPayload)
- .map(payload -> JacksonUtil.convertToObject(payload.value(), ProductLikeEvent.class))
- .forEach(publisher::publish);
-
- outboxes.stream()
- .map(EventOutbox::publish)
- .forEach(eventOutboxRepository::save);
+ outboxes.forEach(outbox -> {
+ ProductLikeEvent event = JacksonUtil.convertToObject(
+ outbox.getPayload().value(),
+ ProductLikeEvent.class
+ );
+ publisher.publish(event);
+ eventOutboxRepository.save(outbox.publish());
+ });추가로 @Transactional 어노테이션 적용 및 발행 실패 시 예외 처리 전략도 검토해 보세요.
🤖 Prompt for AI Agents
In
core/service/src/main/java/com/loopers/core/service/productlike/ProductLikeEventPublishService.java
around lines 30 to 37, the current two separate streams publish events and then
mark all outboxes published, causing non-atomic behavior where failed publishes
can still be marked published; change to process each EventOutbox in a single
loop: for each outbox convert payload to ProductLikeEvent, call
publisher.publish and only if publish returns/finishes successfully update the
outbox (e.g., set published flag or call EventOutbox.publish()) and immediately
save it via eventOutboxRepository.save; wrap the method in @Transactional (or
use transaction per-item as appropriate) and handle publish exceptions by
logging and rethrowing or skipping without marking the outbox published so
failed publishes are not recorded as published.
| @Autowired(required = false) | ||
| private RedisCleanUp redisCleanUp; | ||
|
|
||
| @AfterEach | ||
| void databaseCleanUp() { | ||
| void cleanup() { | ||
| databaseCleanUp.truncateAllTables(); | ||
| redisCleanUp.truncateAll(); |
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.
Null 체크 누락으로 인한 NPE 위험이 있습니다.
redisCleanUp이 required = false로 선택적 주입되지만, Line 24에서 null 체크 없이 truncateAll()을 호출합니다. Redis가 설정되지 않은 테스트 환경에서 NPE가 발생할 수 있습니다.
🔎 Null 체크를 추가한 수정안
@AfterEach
void cleanup() {
databaseCleanUp.truncateAllTables();
- redisCleanUp.truncateAll();
+ if (redisCleanUp != null) {
+ redisCleanUp.truncateAll();
+ }
}📝 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.
| @Autowired(required = false) | |
| private RedisCleanUp redisCleanUp; | |
| @AfterEach | |
| void databaseCleanUp() { | |
| void cleanup() { | |
| databaseCleanUp.truncateAllTables(); | |
| redisCleanUp.truncateAll(); | |
| @Autowired(required = false) | |
| private RedisCleanUp redisCleanUp; | |
| @AfterEach | |
| void cleanup() { | |
| databaseCleanUp.truncateAllTables(); | |
| if (redisCleanUp != null) { | |
| redisCleanUp.truncateAll(); | |
| } | |
| } |
🤖 Prompt for AI Agents
In core/service/src/test/java/com/loopers/core/service/IntegrationTest.java
around lines 18 to 24, redisCleanUp is injected with required = false but used
without a null check on line 24, risking an NPE in environments without Redis;
update cleanup() to guard the call (e.g., if (redisCleanUp != null)
redisCleanUp.truncateAll(); or use
Optional.ofNullable(redisCleanUp).ifPresent(RedisCleanUp::truncateAll)) so
truncateAll() is only invoked when redisCleanUp is present.
📌 Summary
구현 기능
상품 랭킹 스코어 증가 구현
상품 좋아요, 구매, 상세 조회 시 상품 랭킹 스코어를 증가시키는 컨슈머 및 서비스를 추가했습니다.
좋아요, 조회, 구매의 가중치는 yml을 통해 설정으로 분리했습니다.
상품 랭킹 점수는 하루 기준으로 관리했습니다.
TTL과 같은 기준은 과제의 기준으로 구현했습니다.
상품 랭킹 조회 구현
Redis를 통해 페이지네이션 형식으로 구현했습니다.
reverseRangeWithScores를 통해 시작과 끝 범위 정해 구현했습니다.상품 상세 조회에 랭킹정보 추가
redis zset의
reverseRank,score를 통해 상품ID로 랭킹을 조회 하도록 했습니다.carryOver
콜드스타트 문제를 해결하기위해 carryOver를 구현했습니다. scheduler는 매일 23시 50분에 동작합니다.
carryOver는
unionAndStore를 통해 구현했고 이전 점수의 가중치를 설정하여 구현했습니다.이 가중치 또한 yml로 분리했습니다.
💬 Review Points
배치 리스너를 통해 이벤트를 받아 스코어를 증가시키는 방식에서 이미 배치 방식으로 수행하는 것 자체가 어느정도의 실시간성을 포기하고 가져가는 것이라면 23시 59분에 구매,조회,좋아요가 진행됐지만 다음날의 스코어로 증가되는 부분은 어느정도 감안하고 가는게 맞을까요?
멘토링을 들었을 때, 뭔가 대규모 시스템에서는 랭킹 시스템을 redis의 zset을 이용하여 구현하는 것이 부적절해 보였는데(필터링 이슈..) 실제 대규모 시스템에서 어떻게 구현할 수 있을지? 면접 질문으로 들어온다면 어떻게 대답할 수 있을까요?
✅ Checklist
📈 Ranking Consumer
⚾ Ranking API
📎 References
Summary by CodeRabbit
릴리스 노트
새 기능
테스트
✏️ Tip: You can customize this high-level summary in your review settings.