-
Notifications
You must be signed in to change notification settings - Fork 34
redis z-set 기반 랭킹 시스템 구현 #214
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
Conversation
- Kafka 이벤트 처리 시 비동기 방식으로 변경하여 성능 개선 - ExecutorService를 사용하여 이벤트 처리 스레드 풀 구성 - 종료 시 ExecutorService 정리 로직 추가
- Redis ZSET을 이용한 랭킹 점수 업데이트 및 조회 기능 구현 - 일간 랭킹 키 생성 및 날짜 추출 메서드 추가 - 랭킹 아이템 및 점수 데이터 구조 정의
- 이벤트 처리 파사드 구현 - 다양한 이벤트에 대한 랭킹 점수 생성 메서드 추가 - Kafka 컨슈머에서 이벤트 처리 로직 개선 - 랭킹 점수 배치 업데이트 기능 추가
- CoreException 클래스를 추가하여 사용자 정의 예외 처리 구현 - 다양한 에러 타입을 정의하는 ErrorType 열거형 추가 - 각 에러 타입에 대한 HTTP 상태 코드 및 메시지 설정
- 상품 상세 응답에 랭킹 정보 포함 - 랭킹 정보가 없을 경우 null 처리 - 랭킹 상품 목록 조회 API 추가
- 상품 상세 조회 시 랭킹 정보를 포함하도록 수정 - 랭킹 정보가 없는 경우를 처리하는 로직 추가 - 랭킹 점수 업데이트 기능 개선
- 콜드 스타트 문제 해결을 위해 오늘 랭킹이 비어있을 경우 어제 랭킹을 반환하도록 수정 - 랭킹 Carry-Over 스케줄러 추가로 매일 점수 일부를 다음 날로 이월
- DatabaseCleanUp 및 RedisCleanUp 유틸리티 추가로 테스트 데이터 정리 방식 개선 - EventProcessingFacade를 통한 이벤트 처리 로직 테스트로 신뢰성 향상 - 여러 이벤트의 랭킹 점수를 배치로 업데이트하는 테스트 추가
- EventRepository와 ProductMetricsRepository에서 deleteAll 메서드 제거 - MetricsService를 MetricsApplicationService로 변경 - ProductMetricsRepository에 메트릭 저장 및 조회 메서드 주석 추가
- 이벤트 처리 완료를 위한 Domain Service 구현 - 이벤트 처리 결과 DTO 추가 - Kafka Consumer와의 통합을 위한 메트릭스 서비스 리팩토링
- MetricsService를 MetricsApplicationService로 이름 변경 - EventProcessingFacade 및 MetricsLockCleanupScheduler에서 의존성 수정
- 애플리케이션 이름을 commerce-streamer로 변경 - Kafka 토픽의 파티션 수를 3에서 1로 감소 - 불필요한 Kafka 토픽 설정 코드 제거 - Hot 캐시 갱신 주기를 50분에서 20분으로 변경
- 랭킹 목록 조회 API에 대한 E2E 테스트 구현 - 다양한 랭킹 데이터 시나리오 검증 - 상품 상세 조회 시 랭킹 정보 포함 여부 테스트
- 응답 본문에서 null 체크를 추가하여 안정성 향상 - 불필요한 변수 선언 제거로 코드 간결화
- MetricsApplicationService 클래스를 MetricsService로 리팩토링 - 관련된 모든 의존성 및 호출 부분 수정
zset 랭킹 시스템 구현
|
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상품 순위 기능을 추가하고, 이벤트 처리 및 메트릭 시스템을 애플리케이션 계층으로 재구성하여 Redis 기반 순위 저장·조회, 이벤트→순위 점수 생성·배치 업데이트, 컨슈머 병렬 처리 및 스케줄러 이월 기능을 도입했습니다. Changes
Sequence Diagram(s)sequenceDiagram
actor Client
participant ProductAPI as Product API
participant ProductFacade as ProductFacade
participant RankingRedis as RankingRedisService
participant MV as ProductMVService
Client->>ProductAPI: GET /api/v1/rankings?date=2025-12-23&page=0&size=20
ProductAPI->>ProductFacade: getRankingProducts(pageable, date)
ProductFacade->>RankingRedis: getRanking(date, page, size)
RankingRedis-->>ProductFacade: List<RankingItem> (productIds, rank, score)
ProductFacade->>MV: getByIds(productIds)
MV-->>ProductFacade: List<ProductMaterializedViewEntity>
ProductFacade->>ProductFacade: 병합 -> Page<ProductInfo>
ProductFacade-->>ProductAPI: Page<ProductInfo>
ProductAPI-->>Client: ApiResponse(PageResponse)
sequenceDiagram
participant Kafka as Kafka
participant Consumer as MetricsKafkaConsumer
participant Executor as ExecutorService
participant EventFacade as EventProcessingFacade
participant MetricsApp as MetricsService (app)
participant RankingDomain as RankingService
participant RankingRedis as RankingRedisService
Kafka->>Consumer: 배치 레코드
Consumer->>Executor: submit 병렬 작업
Executor->>EventFacade: processCatalogEvent(event)
EventFacade->>MetricsApp: incrementView/applyLikeDelta/addSales
MetricsApp-->>EventFacade: 업데이트 완료
EventFacade->>RankingDomain: generateRankingScore(envelope)
RankingDomain-->>EventFacade: RankingScore
Consumer->>RankingRedis: updateRankingScoresBatch(aggregatedScores)
RankingRedis-->>Consumer: 업데이트 결과
Consumer-->>Kafka: 레코드 커밋
Estimated code review effort🎯 4 (Complex) | ⏱️ ~50 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: 6
🧹 Nitpick comments (23)
apps/commerce-streamer/src/main/java/com/loopers/application/metrics/MetricsService.java (2)
168-170: 타임존 설정이 시스템 기본값에 의존합니다.
ZoneId.systemDefault()는 배포 환경마다 다를 수 있어 분산 환경에서 시간 불일치가 발생할 수 있습니다. 명시적인 타임존(예:ZoneId.of("Asia/Seoul")또는ZoneOffset.UTC) 사용을 권장합니다.🔎 제안된 수정
+ private static final ZoneId ZONE_ID = ZoneId.of("Asia/Seoul"); // 또는 ZoneOffset.UTC + private ZonedDateTime convertToZonedDateTime(long epochMillis) { - return ZonedDateTime.ofInstant(Instant.ofEpochMilli(epochMillis), ZoneId.systemDefault()); + return ZonedDateTime.ofInstant(Instant.ofEpochMilli(epochMillis), ZONE_ID); }
185-196:ConcurrentHashMap은 삽입 순서를 보장하지 않습니다.
stream().limit(toRemove)로 제거되는 항목은 "가장 오래된" 항목이 아닌 임의의 항목입니다. 메모리 관리 목적이라면 현재 방식도 동작하지만, 오래된 항목 우선 제거가 필요하다면LinkedHashMap또는 타임스탬프 기반 만료 로직을 고려해주세요.apps/commerce-api/src/main/java/com/loopers/domain/product/ProductMVService.java (1)
57-65: 입력 검증 추가를 권장합니다.새로운
getByIds메서드가 추가되었습니다. 그러나productIds가 null이거나 빈 리스트인 경우에 대한 명시적인 처리가 없습니다.🔎 입력 검증 추가 제안
public List<ProductMaterializedViewEntity> getByIds(List<Long> productIds) { + if (productIds == null || productIds.isEmpty()) { + return List.of(); + } return mvRepository.findByIdIn(productIds); }apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java (2)
13-13: 사용되지 않는 import 제거
RankingItem이 import 되었지만 이 컨트롤러에서는 사용되지 않습니다.ProductFacade.getRankingProducts는Page<ProductInfo>를 반환하며,RankingItem은 내부적으로만 사용됩니다.🔎 제안된 수정
-import com.loopers.cache.dto.CachePayloads.RankingItem;
58-59: 불필요한 주석 제거 또는 수정 필요
// 3. 응답 생성주석이 있지만 1단계와 2단계 주석이 없어 맥락이 맞지 않습니다. 리팩토링 과정에서 남은 것으로 보입니다.🔎 제안된 수정
ProductDetailInfo productDetail = productFacade.getProductDetail(productId, username); - - // 3. 응답 생성 return ApiResponse.success(ProductV1Dtos.ProductDetailResponse.from(productDetail));apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/MetricsKafkaConsumer.java (1)
36-41: 스레드 풀 설정 검토
ThreadPoolExecutor설정이 합리적으로 보입니다.CallerRunsPolicy를 사용하여 큐가 가득 찼을 때 백프레셔를 적용하는 것은 좋은 선택입니다.다만, 테스트 가능성을 위해
ExecutorService를 생성자 주입으로 변경하는 것을 고려해 보세요. 현재 구조에서는 단위 테스트 시 스레드 풀 동작을 모킹하기 어렵습니다.apps/commerce-streamer/src/main/java/com/loopers/support/error/CoreException.java (1)
6-18: LGTM! 원인(cause) 체이닝 생성자 추가 고려깔끔한 예외 설계입니다. 다만, 다른 예외를 래핑할 때 원인(cause)을 전달할 수 있는 생성자 추가를 고려해 보세요. 이렇게 하면 스택 트레이스에서 근본 원인을 추적하기 쉬워집니다.
🔎 제안: cause 체이닝 생성자 추가
public CoreException(ErrorType errorType, String customMessage, Throwable cause) { super(customMessage != null ? customMessage : errorType.getMessage(), cause); this.errorType = errorType; this.customMessage = customMessage; }apps/commerce-streamer/src/test/java/com/loopers/integration/RankingIntegrationTest.java (1)
259-284: 캐리오버 테스트에서 내일 키 정리 순서 확인Line 270에서
tomorrowKey를 삭제하고, 테스트 후 Line 283에서 다시 삭제합니다. 첫 번째 삭제는 테스트 사전 조건을 위한 것이고, 두 번째는 정리를 위한 것으로 보입니다.@AfterEach나RedisCleanUp을 통해 일관되게 정리하는 것이 더 깔끔할 수 있습니다.apps/commerce-streamer/src/main/java/com/loopers/infrastructure/scheduler/RankingCarryOverScheduler.java (1)
70-73: 수동 캐리오버 접근 제어 고려
manualCarryOver메서드가 public으로 노출되어 있습니다. 운영 환경에서 의도치 않은 호출을 방지하려면 Admin API를 통해서만 접근하거나, 보안 어노테이션을 추가하는 것을 고려해 보세요.apps/commerce-streamer/src/main/java/com/loopers/application/event/EventProcessingFacade.java (2)
58-59: OLD_EVENT_THRESHOLD_MS를 설정 가능하도록 외부화 고려현재 1시간 임계값이 하드코딩되어 있습니다. 환경별로 다른 임계값이 필요할 수 있으므로,
@Value어노테이션이나 설정 파일을 통해 외부에서 주입받는 것을 고려해 주세요.
173-186: LIKE_ACTION 처리 시 UNLIKE도 랭킹 점수를 생성 시도함
processLikeAction에서 action 타입과 무관하게 항상rankingService.generateRankingScore를 호출합니다.RankingService.generateLikeActionScore에서 UNLIKE는 null을 반환하지만, 의도적인 설계라면 괜찮습니다. 다만 UNLIKE의 경우 호출 자체를 생략하면 불필요한 역직렬화를 피할 수 있습니다.modules/redis/src/main/java/com/loopers/cache/CacheKeyGenerator.java (1)
155-166:extractDateFromKey예외 처리 적절함잘못된 키 형식에 대해
IllegalArgumentException을 throw하는 것이 적절합니다. 다만LocalDate.parse에서 발생할 수 있는DateTimeParseException도 함께 처리하면 더 명확한 에러 메시지를 제공할 수 있습니다.🔎 파싱 예외 처리 추가 예시
public LocalDate extractDateFromKey(String key) { String expectedPrefix = RANKING_PREFIX + DELIMITER + ALL_PREFIX + DELIMITER; if (!key.startsWith(expectedPrefix)) { throw new IllegalArgumentException("Invalid ranking key format: " + key); } String dateStr = key.substring(expectedPrefix.length()); - return LocalDate.parse(dateStr, DATE_FORMATTER); + try { + return LocalDate.parse(dateStr, DATE_FORMATTER); + } catch (DateTimeParseException e) { + throw new IllegalArgumentException("Invalid date format in ranking key: " + key, e); + } }apps/commerce-streamer/src/main/java/com/loopers/domain/ranking/RankingService.java (1)
100-107: 에러 발생 시 반환 타입 불일치Line 105에서
new ArrayList<>()를 반환하지만,RankingRedisService.getRanking은List.of()를 반환합니다. 일관성을 위해List.of()를 사용하거나, 명시적으로 가변 리스트가 필요한 경우가 아니라면 불변 리스트를 사용하는 것이 좋습니다.🔎 불변 리스트 사용 제안
} catch (Exception e) { log.error("랭킹 조회 실패: date={}, page={}, size={}", targetDate, page, size, e); - return new ArrayList<>(); + return List.of(); }apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java (1)
169-177: 랭킹 순서 정렬 시 O(n*m) 복잡도 개선 고려현재 중첩 스트림으로
productIds×products탐색이 발생합니다. 상품 수가 많아지면 성능 이슈가 있을 수 있으므로Map으로 미리 인덱싱하는 것을 권장합니다.🔎 Map 기반 정렬 개선 예시
+ // 4. 상품 정보 조회 및 Map 인덱싱 List<ProductMaterializedViewEntity> products = mvService.getByIds(productIds); + Map<Long, ProductMaterializedViewEntity> productMap = products.stream() + .collect(Collectors.toMap(ProductMaterializedViewEntity::getProductId, p -> p)); // 5. 랭킹 순서대로 정렬 List<ProductInfo> sortedProducts = productIds.stream() - .map(productId -> products.stream() - .filter(p -> p.getProductId().equals(productId)) - .findFirst() - .map(ProductInfo::from) - .orElse(null)) + .map(productMap::get) .filter(Objects::nonNull) + .map(ProductInfo::from) .collect(Collectors.toList());apps/commerce-streamer/src/main/java/com/loopers/domain/event/EventHandledService.java (1)
39-55: eventId null 검증 고려
markAsHandled메서드에서eventId가 null인 경우EventEntity.create(eventId)에서 문제가 발생할 수 있습니다. 호출자(MetricsService.tryMarkHandled)에서 이미 검증한다면 괜찮지만, 방어적으로 null 체크를 추가하면 더 안전합니다.🔎 null 검증 추가 예시
@Transactional public boolean markAsHandled(String eventId) { + if (eventId == null || eventId.isBlank()) { + log.warn("이벤트 ID가 null 또는 빈 값: {}", eventId); + return false; + } + try { // 트랜잭션 내에서 다시 한번 확인 (동시성 안전)apps/commerce-streamer/src/test/java/com/loopers/domain/ranking/RankingServiceTest.java (1)
50-50: ObjectMapper 인스턴스 공유 고려
ObjectMapper가 테스트 클래스에서 직접 생성되어 있습니다. Spring 컨텍스트에서 제공하는 빈을 사용하거나@BeforeEach에서 설정하면 일관성을 높일 수 있습니다.apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dtos.java (1)
108-127:fromWithRanking과from메서드 간 중복 코드 존재두 메서드 간 대부분의 로직이 중복됩니다.
from메서드가 내부적으로fromWithRanking을 호출하도록 리팩터링하면 유지보수성이 향상됩니다.🔎 중복 제거 리팩터링 예시
public static ProductDetailResponse from(ProductDetailInfo productDetailInfo) { RankingResponse rankingResponse = null; if (productDetailInfo.ranking() != null) { rankingResponse = new RankingResponse( productDetailInfo.ranking().rank(), productDetailInfo.ranking().score() ); } - - return new ProductDetailResponse( - productDetailInfo.id(), - productDetailInfo.name(), - productDetailInfo.description(), - productDetailInfo.likeCount(), - productDetailInfo.stockQuantity(), - new PriceResponse( - productDetailInfo.price().originPrice(), - productDetailInfo.price().discountPrice() - ), - new BrandDetailResponse( - productDetailInfo.brand().id(), - productDetailInfo.brand().name() - ), - productDetailInfo.isLiked(), - rankingResponse - ); + return fromWithRanking(productDetailInfo, rankingResponse); }modules/redis/src/main/java/com/loopers/cache/RankingRedisService.java (4)
42-83: 두updateRankingScoresBatch메서드 간 중복 코드두 오버로드 메서드에서 점수 집계 및 Redis 파이프라인 로직이 중복됩니다. 내부 헬퍼 메서드로 공통 로직을 추출하면 유지보수성이 향상됩니다.
Also applies to: 91-130
250-289: carryOverScores에서 파이프라인 미사용으로 성능 저하 가능
carryOverScores가 상품별로 개별incrementScore를 호출합니다. 상품 수가 많으면 네트워크 왕복이 증가하여 성능 저하가 발생할 수 있습니다.updateRankingScoresBatch처럼 파이프라인을 사용하는 것을 권장합니다.🔎 파이프라인 적용 예시
- for (ZSetOperations.TypedTuple<String> tuple : sourceData) { - String member = tuple.getValue(); - Double score = tuple.getScore(); - - if (member != null && score != null) { - double weightedScore = score * carryOverWeight; - zSetOps.incrementScore(targetKey, member, weightedScore); - } - } + redisTemplate.executePipelined((RedisCallback<Object>) connection -> { + for (ZSetOperations.TypedTuple<String> tuple : sourceData) { + String member = tuple.getValue(); + Double score = tuple.getScore(); + + if (member != null && score != null) { + double weightedScore = score * carryOverWeight; + zSetOps.incrementScore(targetKey, member, weightedScore); + } + } + return null; + });
250-250: carryOverWeight 파라미터 유효성 검증 추가 고려
carryOverWeight가 0.0~1.0 범위인지 검증하지 않습니다. 잘못된 값(음수 또는 1 초과)이 전달되면 예상치 못한 랭킹 점수가 발생할 수 있습니다.🔎 파라미터 검증 예시
public long carryOverScores(LocalDate sourceDate, LocalDate targetDate, double carryOverWeight) { + if (carryOverWeight < 0.0 || carryOverWeight > 1.0) { + throw new IllegalArgumentException("carryOverWeight must be between 0.0 and 1.0: " + carryOverWeight); + } + String sourceKey = cacheKeyGenerator.generateDailyRankingKey(sourceDate);
162-162: 완전한 클래스 경로 대신 import 사용 권장
new java.util.ArrayList<>()가 사용되고 있습니다. 파일 상단에 import를 추가하고new ArrayList<>()로 사용하는 것이 일관성 있습니다.modules/redis/src/main/java/com/loopers/cache/dto/CachePayloads.java (1)
47-51: 분산 환경에서ZoneId.systemDefault()사용 시 일관성 문제가 발생할 수 있습니다.서버마다 시스템 타임존이 다를 경우, 동일한
occurredAtEpochMillis가 다른 날짜로 변환될 수 있습니다. 이로 인해 랭킹 데이터가 의도하지 않은 일자 키에 저장될 수 있습니다.명시적인 타임존(예:
ZoneId.of("Asia/Seoul")) 또는 UTC 사용을 고려하세요.🔎 수정 제안
+ private static final java.time.ZoneId RANKING_ZONE = java.time.ZoneId.of("Asia/Seoul"); + public java.time.LocalDate getEventDate() { return java.time.Instant.ofEpochMilli(occurredAtEpochMillis) - .atZone(java.time.ZoneId.systemDefault()) + .atZone(RANKING_ZONE) .toLocalDate(); }apps/commerce-api/src/test/java/com/loopers/interfaces/api/RankingV1ApiE2ETest.java (1)
77-108: 사용되지 않는 지역 변수testBrandId를 제거하거나 인라인 처리할 수 있습니다.
testBrandId변수가 선언되고 할당되지만 이후 사용되지 않습니다.brand.getId()를 직접 사용하는 것이 더 간결합니다.🔎 수정 제안
@BeforeEach void setUp() { databaseCleanUp.truncateAllTables(); redisCleanUp.truncateAll(); testProductIds.clear(); - Long testBrandId = null; today = LocalDate.now(); // 테스트용 브랜드 생성 BrandEntity brand = brandService.registerBrand( BrandTestFixture.createRequest("랭킹테스트브랜드", "랭킹 E2E 테스트용 브랜드") ); - testBrandId = brand.getId(); // 테스트용 상품 5개 생성 for (int i = 1; i <= 5; i++) { ProductDomainCreateRequest productRequest = ProductTestFixture.createRequest( - testBrandId, + brand.getId(), "랭킹테스트상품" + i,
📜 Review details
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (35)
apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetailInfo.javaapps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.javaapps/commerce-api/src/main/java/com/loopers/domain/product/ProductMVService.javaapps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1ApiSpec.javaapps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.javaapps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dtos.javaapps/commerce-api/src/main/java/com/loopers/support/Uris.javaapps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeRankingTest.javaapps/commerce-api/src/test/java/com/loopers/domain/product/ProductIntegrationTest.javaapps/commerce-api/src/test/java/com/loopers/interfaces/api/ProductV1ApiE2ETest.javaapps/commerce-api/src/test/java/com/loopers/interfaces/api/RankingV1ApiE2ETest.javaapps/commerce-streamer/src/main/java/com/loopers/application/event/EventProcessingFacade.javaapps/commerce-streamer/src/main/java/com/loopers/application/event/dto/EventProcessingResult.javaapps/commerce-streamer/src/main/java/com/loopers/application/metrics/MetricsService.javaapps/commerce-streamer/src/main/java/com/loopers/domain/event/EventHandledService.javaapps/commerce-streamer/src/main/java/com/loopers/domain/metrics/MetricsService.javaapps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.javaapps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsService.javaapps/commerce-streamer/src/main/java/com/loopers/domain/metrics/repository/MetricsRepository.javaapps/commerce-streamer/src/main/java/com/loopers/domain/ranking/RankingService.javaapps/commerce-streamer/src/main/java/com/loopers/infrastructure/event/EventRepositoryImpl.javaapps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/MetricsRepositoryImpl.javaapps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.javaapps/commerce-streamer/src/main/java/com/loopers/infrastructure/scheduler/MetricsLockCleanupScheduler.javaapps/commerce-streamer/src/main/java/com/loopers/infrastructure/scheduler/RankingCarryOverScheduler.javaapps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/MetricsKafkaConsumer.javaapps/commerce-streamer/src/main/java/com/loopers/support/error/CoreException.javaapps/commerce-streamer/src/main/java/com/loopers/support/error/ErrorType.javaapps/commerce-streamer/src/test/java/com/loopers/domain/ranking/RankingServiceTest.javaapps/commerce-streamer/src/test/java/com/loopers/integration/MetricsEventProcessingIntegrationTest.javaapps/commerce-streamer/src/test/java/com/loopers/integration/RankingIntegrationTest.javaapps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/MetricsKafkaConsumerTest.javamodules/redis/src/main/java/com/loopers/cache/CacheKeyGenerator.javamodules/redis/src/main/java/com/loopers/cache/RankingRedisService.javamodules/redis/src/main/java/com/loopers/cache/dto/CachePayloads.java
💤 Files with no reviewable changes (4)
- apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java
- apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/MetricsService.java
- apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/repository/MetricsRepository.java
- apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/MetricsRepositoryImpl.java
🧰 Additional context used
🧠 Learnings (8)
📚 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:
apps/commerce-api/src/test/java/com/loopers/interfaces/api/ProductV1ApiE2ETest.javaapps/commerce-streamer/src/main/java/com/loopers/application/metrics/MetricsService.javaapps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsService.javaapps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java
📚 Learning: 2025-12-21T15:12:57.845Z
Learnt from: hyujikoh
Repo: Loopers-dev-lab/loopers-spring-java-template PR: 200
File: apps/commerce-streamer/src/main/java/com/loopers/infrastructure/scheduler/MetricsLockCleanupScheduler.java:29-36
Timestamp: 2025-12-21T15:12:57.845Z
Learning: In MetricsLockCleanupScheduler in apps/commerce-streamer (Loopers-dev-lab/loopers-spring-java-template), the scheduled cleanup methods (cleanupUnusedLocks, cleanupProcessedEvents, monitorLockStatus) operate on JVM-local memory structures (ConcurrentHashMap for locks and idempotency cache in MetricsService). Each application instance has its own separate memory space, so duplicate execution across multiple instances is the intended behavior—each instance must clean up its own memory. Distributed locking is not needed for JVM-internal memory cleanup tasks.
Applied to files:
apps/commerce-streamer/src/main/java/com/loopers/infrastructure/scheduler/MetricsLockCleanupScheduler.javaapps/commerce-streamer/src/main/java/com/loopers/application/metrics/MetricsService.java
📚 Learning: 2025-12-19T09:30:12.459Z
Learnt from: HongChangMo
Repo: Loopers-dev-lab/loopers-spring-java-template PR: 198
File: apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentEventListener.java:0-0
Timestamp: 2025-12-19T09:30:12.459Z
Learning: In the loopers-spring-java-template repository's commerce-api module, when entities are managed within a transactional context (e.g., Transactional methods), prefer relying on JPA dirty checking for automatic persistence rather than explicit save() calls. Both Payment and Order entities in PaymentEventListener use this pattern, with state changes automatically flushed on transaction commit.
Applied to files:
apps/commerce-streamer/src/test/java/com/loopers/integration/MetricsEventProcessingIntegrationTest.javaapps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsService.javaapps/commerce-streamer/src/main/java/com/loopers/domain/event/EventHandledService.java
📚 Learning: 2025-12-19T20:59:57.713Z
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:57.713Z
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:
apps/commerce-streamer/src/test/java/com/loopers/integration/MetricsEventProcessingIntegrationTest.javaapps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/MetricsKafkaConsumerTest.javaapps/commerce-streamer/src/main/java/com/loopers/application/event/EventProcessingFacade.javaapps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsService.javaapps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/MetricsKafkaConsumer.javaapps/commerce-streamer/src/main/java/com/loopers/domain/event/EventHandledService.java
📚 Learning: 2025-12-18T13:24:54.339Z
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:54.339Z
Learning: In this codebase, Kafka consumers delegate error handling and event tracking to the service layer via EventInboxAspect. Service methods annotated with InboxEvent are intercepted by the aspect, which handles failures by saving failed EventInbox entries and logging errors. This centralized approach avoids duplicating error handling logic across multiple consumers.
Applied to files:
apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/MetricsKafkaConsumerTest.java
📚 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/interfaces/consumer/MetricsKafkaConsumer.java
📚 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:
apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.javamodules/redis/src/main/java/com/loopers/cache/CacheKeyGenerator.javaapps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1ApiSpec.javaapps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java
📚 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:
apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetailInfo.javaapps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java
🧬 Code graph analysis (14)
apps/commerce-streamer/src/main/java/com/loopers/application/metrics/MetricsService.java (3)
apps/commerce-streamer/src/main/java/com/loopers/application/event/EventProcessingFacade.java (1)
Service(44-240)apps/commerce-streamer/src/main/java/com/loopers/domain/event/EventHandledService.java (1)
Service(18-56)apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsService.java (1)
Service(21-93)
apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/MetricsKafkaConsumerTest.java (2)
apps/commerce-streamer/src/main/java/com/loopers/application/event/dto/EventProcessingResult.java (1)
EventProcessingResult(11-44)modules/redis/src/main/java/com/loopers/cache/dto/CachePayloads.java (1)
CachePayloads(10-85)
apps/commerce-streamer/src/main/java/com/loopers/application/event/EventProcessingFacade.java (3)
apps/commerce-streamer/src/main/java/com/loopers/domain/event/EventHandledService.java (1)
Service(18-56)modules/redis/src/main/java/com/loopers/cache/RankingRedisService.java (1)
Service(27-291)apps/commerce-streamer/src/main/java/com/loopers/application/event/dto/EventProcessingResult.java (1)
EventProcessingResult(11-44)
apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/MetricsKafkaConsumer.java (1)
modules/redis/src/main/java/com/loopers/cache/dto/CachePayloads.java (1)
CachePayloads(10-85)
apps/commerce-streamer/src/main/java/com/loopers/domain/ranking/RankingService.java (3)
modules/redis/src/main/java/com/loopers/cache/RankingRedisService.java (1)
Service(27-291)modules/redis/src/main/java/com/loopers/cache/dto/CachePayloads.java (1)
CachePayloads(10-85)stress/kafka_event-driven-e2e-test.js (1)
action(109-109)
apps/commerce-streamer/src/main/java/com/loopers/domain/event/EventHandledService.java (2)
apps/commerce-streamer/src/main/java/com/loopers/application/event/EventProcessingFacade.java (1)
Service(44-240)apps/commerce-streamer/src/main/java/com/loopers/application/metrics/MetricsService.java (1)
Service(40-207)
apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java (3)
modules/redis/src/main/java/com/loopers/cache/dto/CachePayloads.java (1)
CachePayloads(10-85)apps/commerce-api/src/main/java/com/loopers/support/Uris.java (3)
Uris(9-115)Ranking(85-91)Product(73-80)apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dtos.java (1)
ProductV1Dtos(10-168)
apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeRankingTest.java (1)
modules/redis/src/main/java/com/loopers/cache/dto/CachePayloads.java (1)
CachePayloads(10-85)
apps/commerce-streamer/src/test/java/com/loopers/domain/ranking/RankingServiceTest.java (1)
modules/redis/src/main/java/com/loopers/cache/dto/CachePayloads.java (1)
CachePayloads(10-85)
apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetailInfo.java (1)
modules/redis/src/main/java/com/loopers/cache/dto/CachePayloads.java (1)
CachePayloads(10-85)
apps/commerce-streamer/src/main/java/com/loopers/support/error/CoreException.java (1)
apps/pg-simulator/src/main/kotlin/com/loopers/support/error/CoreException.kt (1)
errorType(3-6)
apps/commerce-streamer/src/test/java/com/loopers/integration/RankingIntegrationTest.java (1)
modules/redis/src/main/java/com/loopers/cache/dto/CachePayloads.java (1)
CachePayloads(10-85)
apps/commerce-streamer/src/main/java/com/loopers/support/error/ErrorType.java (2)
apps/commerce-streamer/src/main/java/com/loopers/support/error/CoreException.java (1)
Getter(5-19)apps/pg-simulator/src/main/kotlin/com/loopers/support/error/ErrorType.kt (1)
status(5-11)
apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java (2)
apps/commerce-streamer/src/main/java/com/loopers/infrastructure/scheduler/RankingCarryOverScheduler.java (1)
Component(21-74)modules/redis/src/main/java/com/loopers/cache/dto/CachePayloads.java (1)
CachePayloads(10-85)
🔇 Additional comments (52)
apps/commerce-streamer/src/main/java/com/loopers/infrastructure/scheduler/MetricsLockCleanupScheduler.java (1)
6-6: LGTM!DDD 기반 리팩토링에 따라
MetricsService가 application 계층으로 이동한 것에 맞춰 import 경로가 올바르게 업데이트되었습니다.apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java (1)
5-24: LGTM!DDD 원칙에 따라 순수한 Domain Repository 인터페이스로 잘 정의되었습니다.
deleteAll()제거로 상품 메트릭에 집중된 도메인 경계가 명확해졌습니다.apps/commerce-streamer/src/main/java/com/loopers/application/metrics/MetricsService.java (3)
159-161: 락 획득 실패 시 메트릭 업데이트가 무시됩니다.100ms 타임아웃 내에 락을 획득하지 못하면 업데이트가 조용히 스킵됩니다. 높은 동시성 상황에서 데이터 누락이 발생할 수 있습니다. 재시도 로직 추가 또는 타임아웃 값 조정을 검토해주세요.
71-95: 멱등성 처리 로직이 잘 구현되었습니다.메모리 캐시(빠른 경로) → DB 확인 → DB 저장의 3단계 전략으로 효율적인 멱등성 체크가 구현되었습니다.
EventHandledService와의 협업도 적절합니다.
40-61: Application 계층 서비스로서 책임 분리가 명확합니다.Domain Service, Infrastructure 의존성을 조합하여 유스케이스를 조정하는 역할이 잘 정의되어 있습니다. 상품별 메모리 락과 멱등성 캐시 관리를 통한 동시성 제어 전략도 적절합니다.
apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsService.java (3)
31-37: @transactional 내에서 명시적 save() 호출이 있습니다.JPA 관리 엔티티는 트랜잭션 커밋 시 dirty checking으로 자동 flush됩니다.
getOrCreateMetrics가 새 엔티티를 생성할 수 있으므로 현재의 명시적save()호출은 안전한 접근입니다. 코드 의도가 명확하여 유지해도 좋습니다.
44-65: 좋아요 엣지 케이스 처리가 적절합니다.새 상품에 대한 좋아요 감소(unlike 이벤트가 like보다 먼저 도착하는 경우) 무시 로직이 올바르게 구현되어 있습니다. 명확한 분기 처리와 로깅으로 디버깅도 용이합니다.
89-92:getOrCreateMetrics헬퍼가 깔끔하게 구현되었습니다.
orElseGet을 사용한 지연 생성 패턴이 적절하며, 호출자가 수정 후 저장하는 구조로 불필요한 DB 호출을 방지합니다.apps/commerce-streamer/src/main/java/com/loopers/support/error/ErrorType.java (1)
1-53: LGTM! 구조화된 에러 타입이 추가되었습니다.새로운
ErrorTypeenum이 체계적으로 정의되었으며, 랭킹 시스템을 포함한 도메인별 에러 케이스를 잘 포괄하고 있습니다.CoreException과의 통합도 명확합니다.apps/commerce-streamer/src/main/java/com/loopers/infrastructure/event/EventRepositoryImpl.java (1)
25-29: 코드 포맷팅 개선메서드 간 가독성을 위한 공백 추가입니다.
apps/commerce-api/src/test/java/com/loopers/domain/product/ProductIntegrationTest.java (1)
275-275: 메서드 네이밍 개선이 반영되었습니다.
deletedBrand에서deleteBrand로의 메서드명 변경이 올바르게 적용되었습니다. 더 명확한 네이밍입니다.apps/commerce-api/src/test/java/com/loopers/interfaces/api/ProductV1ApiE2ETest.java (1)
207-207: 메서드 네이밍 개선이 E2E 테스트에 반영되었습니다.
deletedProduct에서deleteProduct로의 메서드명 변경이 E2E 테스트에 일관되게 적용되었습니다.Also applies to: 343-343
apps/commerce-api/src/main/java/com/loopers/support/Uris.java (1)
82-91: 랭킹 API 엔드포인트 그룹이 추가되었습니다.기존 패턴과 일관되게 새로운
Ranking엔드포인트 그룹이 정의되었습니다. 랭킹 시스템 구현을 위한 적절한 URI 구조입니다.apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetailInfo.java (3)
15-25: 랭킹 정보 통합이 잘 설계되었습니다.
ProductDetailInfo에ranking필드가 추가되어 상품 상세 정보에 랭킹 데이터를 포함할 수 있게 되었습니다. Nullable로 설계되어 랭킹 정보가 없는 경우도 유연하게 처리할 수 있습니다.
30-56: 하위 호환성을 유지하는 팩토리 메서드 확장기존
from(mv, isLiked)메서드가from(mv, isLiked, null)을 호출하도록 변경되어 하위 호환성을 유지하면서 새로운 랭킹 파라미터를 추가했습니다. 깔끔한 API 확장 전략입니다.
107-119: 유용한 헬퍼 메서드 추가
fromWithRanking헬퍼 메서드가 추가되어 기존ProductDetailInfo인스턴스에 랭킹 정보를 추가할 수 있게 되었습니다. 이는 랭킹 데이터를 나중에 병합하는 사용 사례에 유용합니다.apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java (1)
40-49: LGTM!랭킹 상품 조회 엔드포인트가 기존
getProducts패턴을 잘 따르고 있습니다.LocalDate date파라미터를 optional로 처리하여 콜드 스타트 폴백 로직을 Facade 레이어에서 처리할 수 있게 설계되었습니다.apps/commerce-streamer/src/test/java/com/loopers/integration/MetricsEventProcessingIntegrationTest.java (1)
47-57: LGTM!중앙 집중식 정리 유틸리티(
DatabaseCleanUp,RedisCleanUp)를 사용하여 테스트 격리를 개선한 좋은 리팩토링입니다. 개별 repository의deleteAll()대신truncateAllTables()와truncateAll()을 사용하면 모든 관련 데이터가 일관되게 정리됩니다.apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/MetricsKafkaConsumer.java (2)
66-76: 배치 처리 실패 시 동작 확인 필요모든 레코드가 실패해도 (rankingScores가 비어있어도)
ack.acknowledge()가 호출됩니다. 현재 설계 의도가 "at-least-once with best-effort processing"이라면 괜찮지만, 일부 실패 시 재처리가 필요하다면 에러 처리 전략을 재검토해야 합니다.
114-128: LGTM!
@PreDestroy를 통한 graceful shutdown 구현이 적절합니다. 10초 대기 후 강제 종료하고,InterruptedException발생 시 인터럽트 상태를 복원하는 패턴을 올바르게 따르고 있습니다.apps/commerce-streamer/src/main/java/com/loopers/application/event/dto/EventProcessingResult.java (1)
16-43: LGTM!팩토리 메서드(
notProcessed(),processed(RankingScore))를 사용한 결과 DTO 설계가 명확합니다.CatalogEventResult와OrderEventResult가 현재 동일한 구조지만, 타입 안전성을 위해 별도로 유지하는 것은 합리적인 선택입니다. 향후 이벤트 유형별로 추가 필드가 필요할 경우 쉽게 확장할 수 있습니다.apps/commerce-streamer/src/test/java/com/loopers/integration/RankingIntegrationTest.java (2)
64-70: 테스트 격리 일관성 확인
RedisCleanUp.truncateAll()만 사용하고 있습니다.MetricsEventProcessingIntegrationTest에서는DatabaseCleanUp도 함께 사용하는데, 이 테스트에서는 DB 정리가 필요하지 않은지 확인해 주세요. 랭킹 데이터가 Redis에만 저장된다면 현재 구현이 맞습니다.
161-198: LGTM!여러 이벤트의 점수 누적 테스트가 잘 구성되어 있습니다. 조회 3회(0.1×3) + 좋아요 2회(0.2×2) = 0.7 예상 점수 계산이 명확하게 문서화되어 있고,
offset(0.1)을 사용하여 부동소수점 오차를 적절히 처리하고 있습니다.apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeRankingTest.java (4)
72-87: LGTM!
createMockMVEntity헬퍼 메서드가 테스트 데이터 생성을 잘 추상화하고 있습니다.RETURNS_DEEP_STUBS를 사용하여 중첩된 객체(예:mv.getPrice().getOriginPrice())의 모킹을 깔끔하게 처리하고 있습니다.
165-197: LGTM!콜드 스타트 폴백 테스트가 잘 설계되어 있습니다. 오늘 랭킹이 비어있을 때 어제 랭킹으로 폴백하는 로직을 명확하게 검증하고 있으며,
date를null로 전달하여 폴백이 활성화되는 조건을 테스트합니다.
199-215: LGTM!명시적 날짜 지정 시 폴백하지 않는 동작을
verify(..., never())로 검증하는 것이 적절합니다. 이는 PR 목표에 명시된 "명시적 날짜 지정 시 폴백 없음" 요구사항과 일치합니다.
240-263: LGTM!상품 상세 조회 시 랭킹 정보 포함 테스트가 적절합니다.
RankingItem의 rank와 score가ProductDetailInfo.ranking()에 올바르게 매핑되는지 검증하고 있습니다.apps/commerce-streamer/src/main/java/com/loopers/application/event/EventProcessingFacade.java (3)
144-155: 예외 처리 시 상위 계층으로 전파하지 않음 확인 필요
updateRankingScores메서드에서 예외 발생 시 로그만 남기고 호출자에게 전파하지 않습니다. 반면RankingService.updateRankingScoresBatch는CoreException을 throw합니다. 호출자가 실패를 알 필요가 있는지 확인해 주세요.
67-99: 카탈로그 이벤트 처리 로직 LGTM멱등성 체크, 과거 이벤트 필터링, 이벤트 타입별 분기 처리가 잘 구현되어 있습니다. 과거 이벤트도
tryMarkHandled로 마킹하여 재처리를 방지하는 것이 적절합니다.
188-206: STOCK_DEPLETED 이벤트는 랭킹 점수를 생성하지 않음 확인
processStockDepleted가CatalogEventResult.notProcessed()를 반환하여 랭킹 점수를 생성하지 않는 것이 PR 요구사항과 일치합니다 (PRODUCT_VIEW, LIKE_ACTION, PAYMENT_SUCCESS만 랭킹에 반영).modules/redis/src/main/java/com/loopers/cache/CacheKeyGenerator.java (2)
148-153:LocalDate.now()사용 시 타임존 고려 필요
generateTodayRankingKey()가 시스템 기본 타임존을 사용합니다. 서버와 애플리케이션 간 타임존이 다르면 날짜 불일치가 발생할 수 있습니다. 명시적으로 타임존을 지정하거나, 일관된 타임존 정책을 확인해 주세요.🔎 타임존 명시 예시
+ private static final ZoneId RANKING_ZONE = ZoneId.of("Asia/Seoul"); + public String generateTodayRankingKey() { - return generateDailyRankingKey(LocalDate.now()); + return generateDailyRankingKey(LocalDate.now(RANKING_ZONE)); }
136-146: 일간 랭킹 키 생성 로직 LGTM
generateDailyRankingKey메서드가 기존 키 생성 패턴(StringJoiner,DELIMITER)과 일관되게 구현되어 있습니다.apps/commerce-streamer/src/main/java/com/loopers/domain/ranking/RankingService.java (3)
67-84: updateRankingScoresBatch의 예외 전파 전략 확인
updateRankingScoresBatch는 실패 시CoreException을 throw하지만,getRanking과getProductRanking은 예외를 로그만 남기고 빈 결과/null을 반환합니다. 쓰기 연산은 실패를 전파하고 읽기 연산은 graceful degradation하는 의도적 설계라면 적절합니다.
45-59: 랭킹 점수 생성 로직 LGTM
generateRankingScore메서드가 이벤트 타입별로 적절히 분기하며, 지원하지 않는 타입에 대해 null을 반환하는 것이 적절합니다.
151-164: 좋아요 취소(UNLIKE)는 랭킹 점수 생성하지 않음 확인PR 요구사항에 따라 LIKE만 랭킹에 반영하고 UNLIKE는 점수를 생성하지 않는 것이 올바르게 구현되어 있습니다.
apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java (3)
108-111: 상품 상세 조회에 실시간 랭킹 정보 통합 LGTM상품 상세 조회 시 오늘 날짜 기준으로 랭킹 정보를 결합하는 로직이 적절하게 구현되어 있습니다. 랭킹 정보가 없어도 null로 처리되어 안전합니다.
140-154: 콜드 스타트 Fallback 로직 LGTM오늘 랭킹이 비어있고 명시적 날짜 지정이 없을 때만 어제 랭킹으로 폴백하는 로직이 PR 요구사항과 일치합니다.
targetDate변경으로getRankingCount호출 시에도 올바른 날짜를 사용합니다.
192-213: 메서드명 개선 완료 (deleteProduct, deleteBrand)메서드명이 Java 네이밍 컨벤션에 맞게 과거형에서 현재형으로 변경되었으며, 모든 호출 위치가 함께 업데이트되었습니다.
apps/commerce-streamer/src/main/java/com/loopers/domain/event/EventHandledService.java (1)
38-55: 이벤트 멱등성 처리 로직 LGTM트랜잭션 내에서 이중 확인(double-check) 패턴을 사용하여 동시성 문제를 안전하게 처리합니다. Unique 제약 조건 위반 등 예외 발생 시
false를 반환하는 것이 적절합니다.apps/commerce-streamer/src/test/java/com/loopers/domain/ranking/RankingServiceTest.java (1)
1-296: 포괄적인 단위 테스트 LGTM
RankingService의 주요 기능(점수 생성, 배치 업데이트, 랭킹 조회)에 대한 테스트가 잘 구성되어 있습니다. UNLIKE 시 점수 미생성, 로그 정규화 검증, 빈 리스트 처리 등 엣지 케이스도 포함되어 있습니다.apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dtos.java (2)
75-106: ProductDetailResponse에 랭킹 정보 추가 LGTM
RankingResponse추출 로직이 null-safe하게 구현되어 있으며, 랭킹 정보가 없는 경우 null을 반환합니다. API 응답에 실시간 랭킹 정보가 포함됩니다.
158-167: RankingResponse DTO 정의 LGTM
@Schema어노테이션으로 API 문서화가 잘 되어 있습니다.rank와score필드가 명확하게 정의되어 있습니다.modules/redis/src/main/java/com/loopers/cache/RankingRedisService.java (2)
35-35: RANKING_TTL 2일 설정 확인2일 TTL이 요구사항과 일치합니다. 콜드 스타트 폴백 시 어제 데이터 조회가 가능합니다.
140-185: 페이지네이션 랭킹 조회 로직 LGTM1-based 페이지를 0-based Redis 인덱스로 변환하고,
reverseRangeWithScores로 점수 높은 순 조회 후RankingItem으로 변환하는 로직이 올바르게 구현되어 있습니다.NumberFormatException처리도 적절합니다.modules/redis/src/main/java/com/loopers/cache/dto/CachePayloads.java (1)
70-82: LGTM!
Math.log(totalPrice + 1)을 사용한 로그 정규화와getWeightedScore()구현이 적절합니다. 0원 주문에 대한 안전 처리도 잘 되어 있습니다.apps/commerce-api/src/test/java/com/loopers/interfaces/api/RankingV1ApiE2ETest.java (4)
110-234: LGTM!랭킹 목록 조회 테스트들이 잘 구성되어 있습니다. 점수 순 정렬, 빈 결과, 페이징, 특정 날짜 조회 등 주요 시나리오가 모두 커버됩니다.
236-296: LGTM!콜드 스타트 시나리오에 대한 Fallback 테스트가 잘 작성되었습니다. 명시적 날짜 지정 시 Fallback이 동작하지 않는 것도 검증하고 있습니다.
298-429: LGTM!상품 상세 조회 시 랭킹 정보 포함/미포함 테스트와 점수 누적 테스트가 정확하게 작성되었습니다.
431-461: LGTM!Score Carry-Over 테스트가 10% 이월 로직을 정확히 검증합니다.
isCloseTo를 사용한 부동소수점 비교도 적절합니다.참고로, Line 458-459의 cleanup 코드는 assertion 실패 시 실행되지 않지만,
@BeforeEach에서redisCleanUp.truncateAll()을 호출하므로 다음 테스트에는 영향이 없습니다.apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/MetricsKafkaConsumerTest.java (3)
43-68: LGTM!Facade 패턴을 통한 이벤트 처리 위임이 잘 검증되고 있습니다. Based on learnings, 에러 처리가 EventInboxAspect로 위임되는 구조와 일치합니다.
70-94: LGTM!처리되지 않은 이벤트가 랭킹 업데이트에서 제외되는 것을
never()로 검증하는 것이 적절합니다.
163-191: LGTM!배치 랭킹 업데이트 테스트가 잘 작성되었습니다.
argThat(list -> list.size() == 2)를 사용하여 2개의 점수가 배치로 처리되는 것을 정확히 검증합니다.
apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1ApiSpec.java
Show resolved
Hide resolved
...e-streamer/src/main/java/com/loopers/infrastructure/scheduler/RankingCarryOverScheduler.java
Show resolved
Hide resolved
apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/MetricsKafkaConsumer.java
Show resolved
Hide resolved
...ommerce-streamer/src/test/java/com/loopers/interfaces/consumer/MetricsKafkaConsumerTest.java
Show resolved
Hide resolved
- 일간 랭킹 키 생성 시 날짜 형식 변경 - EventType의 weight 필드를 final로 변경하여 불변성 강화 - 로그 메시지에서 잘못된 이벤트 타입 수정 - API 문서에서 날짜 형식 설명 업데이트
refactor(cache): 캐시 키 및 페이로드 관련 코드 개선
|
@coderabbitai full review |
✅ Actions performedFull review triggered. |
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: 3
♻️ Duplicate comments (3)
apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1ApiSpec.java (1)
47-48: 날짜 형식 명확성 확인 필요API 문서의
example은 "2025-12-23" (ISO-8601 형식)을 보여주고 있으며, 이는 Spring의 기본 LocalDate 바인딩 형식과 일치합니다. 그러나 이전 리뷰에서 "yyyyMMdd" 형식에 대한 우려가 제기되었습니다.현재 코드에서
@DateTimeFormat어노테이션이 없으므로 클라이언트는 ISO 형식("yyyy-MM-dd")을 사용해야 합니다. API 문서의 example과 실제 동작이 일치하는지 확인하고, 다른 형식 지원이 필요하다면@DateTimeFormat(pattern = "yyyyMMdd")추가를 고려하세요.다음 스크립트로 프로젝트 전체의 날짜 형식 처리 패턴을 확인할 수 있습니다:
#!/bin/bash # 날짜 형식 처리 패턴 확인 rg -nP "@DateTimeFormat|DateTimeFormatter" --type=java -C3apps/commerce-streamer/src/main/java/com/loopers/infrastructure/scheduler/RankingCarryOverScheduler.java (1)
40-61: 스케줄러 구현이 적절합니다.에러 핸들링과 로깅이 잘 구현되어 있습니다. 스케줄러 실패 시에도 서비스가 계속 동작하도록 예외를 catch하고 로깅하는 방식이 적절합니다.
단, 이전 리뷰에서 지적된 대로
CommerceStreamerApplication에@EnableScheduling어노테이션이 추가되어야@Scheduled메서드가 동작합니다.modules/redis/src/main/java/com/loopers/cache/dto/CachePayloads.java (1)
49-54: Javadoc 오타가 여전히 존재합니다"조회 이벤트 생성 생성" → "조회 이벤트 생성"으로 수정이 필요합니다. (이전 리뷰에서 지적된 사항)
🧹 Nitpick comments (14)
apps/commerce-streamer/src/main/java/com/loopers/support/error/ErrorType.java (1)
14-17: 에러 코드로 HTTP Reason Phrase 대신 커스텀 식별자 사용을 권장합니다.현재
code필드가HttpStatus.getReasonPhrase()로 설정되어 "Internal Server Error", "Bad Request" 등의 값을 가집니다. 이는status필드와 중복되며, 프로그래밍 방식의 에러 처리나 로깅에 유용하지 않습니다.애플리케이션별 고유 식별자(예: "ERR_INTERNAL_001", "ERR_USER_NOT_FOUND")를 사용하면 더 명확한 에러 추적과 클라이언트 측 처리가 가능합니다.
🔎 제안하는 개선안
public enum ErrorType { - INTERNAL_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, HttpStatus.INTERNAL_SERVER_ERROR.getReasonPhrase(), "일시적인 오류가 발생했습니다."), - BAD_REQUEST(HttpStatus.BAD_REQUEST, HttpStatus.BAD_REQUEST.getReasonPhrase(), "잘못된 요청입니다."), - NOT_FOUND(HttpStatus.NOT_FOUND, HttpStatus.NOT_FOUND.getReasonPhrase(), "존재하지 않는 요청입니다."), - CONFLICT(HttpStatus.CONFLICT, HttpStatus.CONFLICT.getReasonPhrase(), "이미 존재하는 리소스입니다."), + INTERNAL_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "ERR_INTERNAL_001", "일시적인 오류가 발생했습니다."), + BAD_REQUEST(HttpStatus.BAD_REQUEST, "ERR_BAD_REQUEST", "잘못된 요청입니다."), + NOT_FOUND(HttpStatus.NOT_FOUND, "ERR_NOT_FOUND", "존재하지 않는 요청입니다."), + CONFLICT(HttpStatus.CONFLICT, "ERR_CONFLICT", "이미 존재하는 리소스입니다."),나머지 에러 타입들도 유사하게 적용:
NOT_FOUND_USER→ code: "ERR_USER_NOT_FOUND"NOT_FOUND_BRAND→ code: "ERR_BRAND_NOT_FOUND"- 등등
Also applies to: 50-52
apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/MetricsKafkaConsumer.java (3)
36-41: 스레드 풀 설정 검토 권장현재 설정값(core=20, max=100, queue=1000, CallerRunsPolicy)은 다음을 의미합니다:
- 최대 1,100개의 작업을 동시 처리 가능 (100 실행 중 + 1,000 대기)
- 큐가 가득 차면 Kafka 리스너 스레드가 직접 처리하여 백프레셔 제공
프로덕션 환경에서는 다음을 고려하세요:
- 메모리 사용량: 큐 크기 × 평균 이벤트 크기
- 처리 지연: 큐가 가득 찰 경우 Kafka 리스너 블로킹으로 인한 Consumer lag 증가
- 리소스 제한: CPU 코어 수와 스레드 풀 크기의 균형
실제 부하 테스트를 통해 적절한 스레드 풀 크기를 결정하는 것을 권장합니다.
54-64: 예외 처리 시 컨텍스트 정보 추가 권장현재 catch 블록에서 예외를 로깅하고 null을 반환하지만, 어떤 이벤트에서 실패했는지 추적하기 어렵습니다. 디버깅과 모니터링을 위해 이벤트 메타데이터(topic, partition, offset)를 로그에 포함하는 것을 권장합니다.
🔎 제안된 개선
.map(record -> CompletableFuture.supplyAsync(() -> { try { var result = eventProcessingFacade.processCatalogEvent(record.value()); return (result.processed()) ? result.rankingScore() : null; } catch (Exception e) { - log.error("Failed to process catalog event", e); + log.error("Failed to process catalog event - topic: {}, partition: {}, offset: {}", + record.topic(), record.partition(), record.offset(), e); return null; } }, executorService))Also applies to: 88-98
67-70: 배치 처리 타임아웃 설정 고려
CompletableFuture.join()은 무한정 대기하므로, 일부 이벤트 처리가 지연되면 전체 배치가 블로킹됩니다. 프로덕션 환경에서는 배치 처리에 타임아웃을 설정하여 일부 실패가 전체 시스템을 막지 않도록 하는 것을 고려하세요.예:
CompletableFuture.allOf(...).orTimeout(30, TimeUnit.SECONDS)사용Also applies to: 101-104
apps/commerce-streamer/src/main/java/com/loopers/application/event/EventProcessingFacade.java (1)
58-60: 과거 이벤트 임계값 검토 권장현재 1시간(60분)보다 오래된 이벤트를 필터링합니다. Kafka Consumer lag이나 시스템 장애로 인해 지연된 정상 이벤트가 필터링될 수 있습니다.
프로덕션 환경의 예상 최대 지연 시간을 고려하여 임계값을 조정하거나, 설정 파일로 외부화하는 것을 권장합니다.
apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeRankingTest.java (2)
41-42: 테스트 엄격도 설정 재검토 권장
Strictness.LENIENT설정은 사용되지 않는 stub을 허용하여 테스트가 불필요한 mock 설정을 감지하지 못하게 합니다. 특정 테스트에만 필요한 경우 메서드 레벨에서 적용하고, 클래스 레벨에서는 기본(STRICT_STUBS) 사용을 권장합니다.
72-87: Deep stubs 사용 시 주의
RETURNS_DEEP_STUBS는 편리하지만 테스트를 내부 구현에 강하게 결합시킵니다. 특히mv.getPrice().getOriginPrice()같은 체이닝된 호출은 Price 객체의 내부 구조 변경 시 테스트가 깨질 수 있습니다.가능하다면 실제 객체 또는 명시적 stub을 사용하는 것을 고려하세요. 현재 사용은 헬퍼 메서드에 국한되어 있어 큰 문제는 아닙니다.
apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java (1)
169-177: 랭킹 순서 정렬 로직의 시간 복잡도 개선을 권장합니다.현재 구현은 각
productId에 대해products리스트를 순회하므로 O(n*m) 복잡도입니다. 상품 수가 많아지면 성능 저하가 발생할 수 있습니다.🔎 Map을 활용한 개선 제안
- // 5. 랭킹 순서대로 정렬 - List<ProductInfo> sortedProducts = productIds.stream() - .map(productId -> products.stream() - .filter(p -> p.getProductId().equals(productId)) - .findFirst() - .map(ProductInfo::from) - .orElse(null)) - .filter(Objects::nonNull) - .collect(Collectors.toList()); + // 5. 랭킹 순서대로 정렬 (Map을 활용하여 O(n) 조회) + Map<Long, ProductMaterializedViewEntity> productMap = products.stream() + .collect(Collectors.toMap(ProductMaterializedViewEntity::getProductId, p -> p)); + + List<ProductInfo> sortedProducts = productIds.stream() + .map(productMap::get) + .filter(Objects::nonNull) + .map(ProductInfo::from) + .collect(Collectors.toList());modules/redis/src/main/java/com/loopers/cache/RankingRedisService.java (1)
264-275: Carry-Over 시 Pipeline 미사용으로 인한 성능 저하 가능성다른 배치 업데이트 메서드와 달리
carryOverScores는 개별incrementScore호출을 사용합니다. 상품 수가 많을 경우 Redis 왕복 횟수가 증가하여 성능 저하가 발생할 수 있습니다.🔎 Pipeline 적용 제안
// 가중치를 적용하여 대상 키에 추가 (기존 점수에 합산) ZSetOperations<String, String> zSetOps = redisTemplate.opsForZSet(); - for (ZSetOperations.TypedTuple<String> tuple : sourceData) { - String member = tuple.getValue(); - Double score = tuple.getScore(); - - if (member != null && score != null) { - double weightedScore = score * carryOverWeight; - zSetOps.incrementScore(targetKey, member, weightedScore); - } - } + redisTemplate.executePipelined((RedisCallback<Object>) connection -> { + for (ZSetOperations.TypedTuple<String> tuple : sourceData) { + String member = tuple.getValue(); + Double score = tuple.getScore(); + + if (member != null && score != null) { + double weightedScore = score * carryOverWeight; + zSetOps.incrementScore(targetKey, member, weightedScore); + } + } + return null; + });apps/commerce-streamer/src/main/java/com/loopers/application/metrics/MetricsService.java (2)
185-196: 이벤트 캐시 정리 시 순서 보장 없음
ConcurrentHashMap은 순회 순서를 보장하지 않으므로,limit(toRemove)로 제거되는 항목이 가장 오래된 것이 아닐 수 있습니다. 최근 처리된 이벤트가 제거되면 중복 처리가 발생할 수 있습니다. DB 백업이 있어 심각하지는 않지만,LinkedHashMap또는 시간 기반 만료를 고려해 보세요.
140-145: 사용되지 않는 매개변수
occurredAtEpochMillis매개변수가 메서드 내에서 사용되지 않습니다. API 일관성을 위해 유지하는 것이라면 괜찮지만, 그렇지 않다면 제거를 고려해 보세요.apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dtos.java (1)
108-126:from()과fromWithRanking()간 코드 중복두 팩토리 메서드가 거의 동일한 매핑 로직을 가지고 있습니다. 현재 규모에서는 허용 가능하지만, 필드가 추가될 때 동기화 누락 가능성이 있습니다. 공통 빌더나 private 헬퍼 메서드 추출을 고려해 보세요.
apps/commerce-streamer/src/test/java/com/loopers/domain/ranking/RankingServiceTest.java (1)
50-50: 사용되지 않는 ObjectMapper 필드
objectMapper필드가 선언되어 있지만writeValueAsString으로만 사용됩니다.@Mock이나@InjectMocks가 아니므로 테스트 간 상태 공유 위험은 없지만, 각 테스트에서 로컬 변수로 사용하거나@BeforeEach에서 초기화하는 것이 더 명확합니다.apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsService.java (1)
89-92:getOrCreateMetrics에서 생성된 엔티티는 저장되지 않음새로 생성된
ProductMetricsEntity는 이 헬퍼에서 저장되지 않고 호출자가 저장해야 합니다. 현재 모든 호출자가 저장하고 있어 문제는 없지만, 향후 새 호출자 추가 시 주의가 필요합니다. Javadoc에 이 동작을 명시하면 좋겠습니다.
📜 Review details
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (36)
apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetailInfo.javaapps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.javaapps/commerce-api/src/main/java/com/loopers/domain/product/ProductMVService.javaapps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1ApiSpec.javaapps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.javaapps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dtos.javaapps/commerce-api/src/main/java/com/loopers/support/Uris.javaapps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeRankingTest.javaapps/commerce-api/src/test/java/com/loopers/domain/product/ProductIntegrationTest.javaapps/commerce-api/src/test/java/com/loopers/interfaces/api/ProductV1ApiE2ETest.javaapps/commerce-api/src/test/java/com/loopers/interfaces/api/RankingV1ApiE2ETest.javaapps/commerce-streamer/src/main/java/com/loopers/CommerceStreamerApplication.javaapps/commerce-streamer/src/main/java/com/loopers/application/event/EventProcessingFacade.javaapps/commerce-streamer/src/main/java/com/loopers/application/event/dto/EventProcessingResult.javaapps/commerce-streamer/src/main/java/com/loopers/application/metrics/MetricsService.javaapps/commerce-streamer/src/main/java/com/loopers/domain/event/EventHandledService.javaapps/commerce-streamer/src/main/java/com/loopers/domain/metrics/MetricsService.javaapps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.javaapps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsService.javaapps/commerce-streamer/src/main/java/com/loopers/domain/metrics/repository/MetricsRepository.javaapps/commerce-streamer/src/main/java/com/loopers/domain/ranking/RankingService.javaapps/commerce-streamer/src/main/java/com/loopers/infrastructure/event/EventRepositoryImpl.javaapps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/MetricsRepositoryImpl.javaapps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.javaapps/commerce-streamer/src/main/java/com/loopers/infrastructure/scheduler/MetricsLockCleanupScheduler.javaapps/commerce-streamer/src/main/java/com/loopers/infrastructure/scheduler/RankingCarryOverScheduler.javaapps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/MetricsKafkaConsumer.javaapps/commerce-streamer/src/main/java/com/loopers/support/error/CoreException.javaapps/commerce-streamer/src/main/java/com/loopers/support/error/ErrorType.javaapps/commerce-streamer/src/test/java/com/loopers/domain/ranking/RankingServiceTest.javaapps/commerce-streamer/src/test/java/com/loopers/integration/MetricsEventProcessingIntegrationTest.javaapps/commerce-streamer/src/test/java/com/loopers/integration/RankingIntegrationTest.javaapps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/MetricsKafkaConsumerTest.javamodules/redis/src/main/java/com/loopers/cache/CacheKeyGenerator.javamodules/redis/src/main/java/com/loopers/cache/RankingRedisService.javamodules/redis/src/main/java/com/loopers/cache/dto/CachePayloads.java
💤 Files with no reviewable changes (4)
- apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/MetricsRepositoryImpl.java
- apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/repository/MetricsRepository.java
- apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java
- apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/MetricsService.java
🧰 Additional context used
🧠 Learnings (9)
📚 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:
apps/commerce-api/src/test/java/com/loopers/interfaces/api/ProductV1ApiE2ETest.javaapps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.javaapps/commerce-streamer/src/main/java/com/loopers/application/metrics/MetricsService.javaapps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsService.java
📚 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:
apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.javamodules/redis/src/main/java/com/loopers/cache/CacheKeyGenerator.javaapps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.javaapps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1ApiSpec.java
📚 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:
apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.javaapps/commerce-api/src/main/java/com/loopers/application/product/ProductDetailInfo.java
📚 Learning: 2025-12-21T15:12:57.845Z
Learnt from: hyujikoh
Repo: Loopers-dev-lab/loopers-spring-java-template PR: 200
File: apps/commerce-streamer/src/main/java/com/loopers/infrastructure/scheduler/MetricsLockCleanupScheduler.java:29-36
Timestamp: 2025-12-21T15:12:57.845Z
Learning: In MetricsLockCleanupScheduler in apps/commerce-streamer (Loopers-dev-lab/loopers-spring-java-template), the scheduled cleanup methods (cleanupUnusedLocks, cleanupProcessedEvents, monitorLockStatus) operate on JVM-local memory structures (ConcurrentHashMap for locks and idempotency cache in MetricsService). Each application instance has its own separate memory space, so duplicate execution across multiple instances is the intended behavior—each instance must clean up its own memory. Distributed locking is not needed for JVM-internal memory cleanup tasks.
Applied to files:
apps/commerce-streamer/src/main/java/com/loopers/application/metrics/MetricsService.javaapps/commerce-streamer/src/main/java/com/loopers/infrastructure/scheduler/MetricsLockCleanupScheduler.java
📚 Learning: 2025-12-19T09:30:12.459Z
Learnt from: HongChangMo
Repo: Loopers-dev-lab/loopers-spring-java-template PR: 198
File: apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentEventListener.java:0-0
Timestamp: 2025-12-19T09:30:12.459Z
Learning: In the loopers-spring-java-template repository's commerce-api module, when entities are managed within a transactional context (e.g., Transactional methods), prefer relying on JPA dirty checking for automatic persistence rather than explicit save() calls. Both Payment and Order entities in PaymentEventListener use this pattern, with state changes automatically flushed on transaction commit.
Applied to files:
apps/commerce-streamer/src/test/java/com/loopers/integration/MetricsEventProcessingIntegrationTest.javaapps/commerce-streamer/src/main/java/com/loopers/domain/event/EventHandledService.java
📚 Learning: 2025-12-19T20:59:57.713Z
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:57.713Z
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:
apps/commerce-streamer/src/test/java/com/loopers/integration/MetricsEventProcessingIntegrationTest.javaapps/commerce-streamer/src/main/java/com/loopers/domain/event/EventHandledService.javaapps/commerce-streamer/src/main/java/com/loopers/application/event/EventProcessingFacade.javaapps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsService.javaapps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/MetricsKafkaConsumer.javaapps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/MetricsKafkaConsumerTest.java
📚 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/interfaces/consumer/MetricsKafkaConsumer.java
📚 Learning: 2025-12-19T23:39:20.851Z
Learnt from: toongri
Repo: Loopers-dev-lab/loopers-spring-kotlin-template PR: 68
File: apps/commerce-streamer/src/main/kotlin/com/loopers/interfaces/consumer/product/ProductEventConsumer.kt:0-0
Timestamp: 2025-12-19T23:39:20.851Z
Learning: In Loopers-dev-lab/loopers-spring-kotlin-template, toongri prefers pragmatic idempotency handling in Kafka consumers: when idempotency key persistence fails after successful business logic execution, log a warning and continue (accepting low risk of duplicates on retry) rather than rolling back business data. This keeps business logic decoupled from idempotency store (which might be Redis/external system, not RDB), avoids over-engineering for rare edge cases, and prioritizes core business value over auxiliary metadata. Transaction-based atomicity between business data and idempotency keys is considered excessive when duplicate event probability is very low.
Applied to files:
apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/MetricsKafkaConsumer.javaapps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/MetricsKafkaConsumerTest.java
📚 Learning: 2025-12-18T13:24:54.339Z
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:54.339Z
Learning: In this codebase, Kafka consumers delegate error handling and event tracking to the service layer via EventInboxAspect. Service methods annotated with InboxEvent are intercepted by the aspect, which handles failures by saving failed EventInbox entries and logging errors. This centralized approach avoids duplicating error handling logic across multiple consumers.
Applied to files:
apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/MetricsKafkaConsumerTest.java
🧬 Code graph analysis (13)
apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeRankingTest.java (1)
modules/redis/src/main/java/com/loopers/cache/dto/CachePayloads.java (1)
CachePayloads(10-81)
apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java (1)
modules/redis/src/main/java/com/loopers/cache/dto/CachePayloads.java (1)
CachePayloads(10-81)
apps/commerce-streamer/src/test/java/com/loopers/domain/ranking/RankingServiceTest.java (1)
modules/redis/src/main/java/com/loopers/cache/dto/CachePayloads.java (1)
CachePayloads(10-81)
apps/commerce-streamer/src/main/java/com/loopers/application/metrics/MetricsService.java (1)
apps/commerce-streamer/src/main/java/com/loopers/application/event/EventProcessingFacade.java (1)
Service(44-240)
apps/commerce-api/src/test/java/com/loopers/interfaces/api/RankingV1ApiE2ETest.java (3)
modules/redis/src/main/java/com/loopers/cache/dto/CachePayloads.java (1)
CachePayloads(10-81)apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dtos.java (1)
ProductV1Dtos(10-168)apps/commerce-api/src/main/java/com/loopers/support/Uris.java (3)
Uris(9-115)Ranking(85-91)Product(73-80)
apps/commerce-streamer/src/main/java/com/loopers/domain/event/EventHandledService.java (2)
apps/commerce-streamer/src/main/java/com/loopers/application/event/EventProcessingFacade.java (1)
Service(44-240)apps/commerce-streamer/src/main/java/com/loopers/application/metrics/MetricsService.java (1)
Service(40-207)
apps/commerce-streamer/src/main/java/com/loopers/domain/ranking/RankingService.java (2)
modules/redis/src/main/java/com/loopers/cache/RankingRedisService.java (1)
Service(27-291)modules/redis/src/main/java/com/loopers/cache/dto/CachePayloads.java (1)
CachePayloads(10-81)
apps/commerce-streamer/src/main/java/com/loopers/support/error/ErrorType.java (2)
apps/commerce-streamer/src/main/java/com/loopers/support/error/CoreException.java (1)
Getter(5-19)apps/pg-simulator/src/main/kotlin/com/loopers/support/error/ErrorType.kt (1)
status(5-11)
apps/commerce-streamer/src/main/java/com/loopers/CommerceStreamerApplication.java (1)
apps/commerce-api/src/main/java/com/loopers/CommerceApiApplication.java (1)
ConfigurationPropertiesScan(14-30)
apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/MetricsKafkaConsumer.java (1)
modules/redis/src/main/java/com/loopers/cache/dto/CachePayloads.java (1)
CachePayloads(10-81)
apps/commerce-streamer/src/main/java/com/loopers/support/error/CoreException.java (1)
apps/pg-simulator/src/main/kotlin/com/loopers/support/error/CoreException.kt (1)
errorType(3-6)
apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetailInfo.java (1)
modules/redis/src/main/java/com/loopers/cache/dto/CachePayloads.java (1)
CachePayloads(10-81)
apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/MetricsKafkaConsumerTest.java (2)
apps/commerce-streamer/src/main/java/com/loopers/application/event/dto/EventProcessingResult.java (1)
EventProcessingResult(11-44)modules/redis/src/main/java/com/loopers/cache/dto/CachePayloads.java (1)
CachePayloads(10-81)
🔇 Additional comments (54)
apps/commerce-streamer/src/test/java/com/loopers/integration/MetricsEventProcessingIntegrationTest.java (3)
47-51: 클린업 유틸리티 주입이 올바르게 설정되어 있습니다.
DatabaseCleanUp과RedisCleanUp은 모두@Component어노테이션으로 등록되어 있으며,@SpringBootTest컨텍스트에서 컴포넌트 스캔을 통해 자동으로 빈으로 등록됩니다. 따라서@Autowired를 통한 주입이 정상적으로 작동합니다.
55-56: 명시적 클린업 전략이 Kafka 통합 테스트에 적합합니다.
@Transactional롤백 대신 명시적 클린업을 사용하는 것은 다음 이유로 올바릅니다:
- Kafka 메시지 처리는 트랜잭션 경계 밖에서 비동기적으로 발생
- Redis 상태는 트랜잭션 롤백으로 정리되지 않음
truncateAllTables()와truncateAll()모두 동기적으로 완료되어 테스트 격리 보장
22-23: 클린업 유틸리티 클래스들이 모두 올바르게 구현되어 있습니다.
- DatabaseCleanUp: EntityManager를 통해 모든 @entity 클래스를 자동 검색하고, 외래 키 제약 조건을 SET FOREIGN_KEY_CHECKS = 0/1로 처리한 후 모든 테이블을 TRUNCATE합니다.
- RedisCleanUp: flushAll() 명령으로 Redis의 모든 키를 정리합니다.
- setUp() 메서드: @beforeeach를 통해 각 테스트 전에 두 유틸리티의 정리 메서드를 순서대로 호출하여 테스트 격리를 보장합니다.
비동기 메시지 처리(Kafka)를 포함하는 통합 테스트에 적합한 구조입니다.
apps/commerce-streamer/src/main/java/com/loopers/support/error/CoreException.java (1)
5-18: 구현이 정확합니다.
CoreException클래스의 구조와 생성자 로직이 올바르게 구현되었습니다:
RuntimeException확장으로 unchecked exception 패턴 준수- Final 필드로 불변성 보장
- 생성자 위임 패턴이 깔끔하게 적용됨
- Kotlin 버전과의 일관성 유지
apps/commerce-streamer/src/main/java/com/loopers/infrastructure/event/EventRepositoryImpl.java (1)
25-29: LGTM!메서드 간 가독성을 위한 포맷팅 변경입니다.
apps/commerce-streamer/src/main/java/com/loopers/CommerceStreamerApplication.java (1)
8-14: LGTM!
@EnableScheduling추가로 RankingCarryOverScheduler, MetricsLockCleanupScheduler 등 스케줄러 컴포넌트가 정상 동작할 수 있습니다. CommerceApiApplication과 일관된 구성입니다.apps/commerce-api/src/main/java/com/loopers/domain/product/ProductMVService.java (1)
57-65: LGTM!배치 조회 메서드가 기존
findByIdsAsPage와 일관된 패턴으로 구현되었습니다. 랭킹 기능에서 다수의 상품 MV를 조회하는 데 유용합니다.apps/commerce-streamer/src/test/java/com/loopers/integration/RankingIntegrationTest.java (3)
40-71: LGTM!테스트 설정이 적절합니다.
@BeforeEach에서 Redis 정리 후 테스트를 실행하여 격리된 테스트 환경을 보장합니다.
72-199: LGTM!Kafka → Redis ZSET 적재 테스트가 PR 요구사항에 맞게 구현되었습니다:
- PRODUCT_VIEW: 0.1점
- LIKE_ACTION: 0.2점
- PAYMENT_SUCCESS: 0.6 × log(totalPrice + 1)
- 동일 상품 점수 누적 검증
201-302: LGTM!랭킹 조회 및 Score Carry-Over 테스트가 잘 구현되었습니다:
- 점수 높은 순 정렬 검증
- 10% 점수 이월 동작 검증
- 원본 데이터 없을 때 스킵 동작 검증
콜드 스타트 대응 요구사항을 충족합니다.
apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetailInfo.java (2)
23-55: LGTM!
RankingItem ranking필드 추가와 오버로드된 팩토리 메서드 구현이 잘 되었습니다. 기존 코드와의 하위 호환성을 유지하면서 랭킹 정보를 확장했습니다.
93-119: LGTM!
fromWithSyncLike가 기존 랭킹을 유지하고,fromWithRanking이 새 랭킹으로 복사본을 생성하는 불변 패턴이 올바르게 구현되었습니다.apps/commerce-api/src/test/java/com/loopers/interfaces/api/ProductV1ApiE2ETest.java (2)
207-207: LGTM!
ProductFacade.deleteProduct메서드명 변경이 테스트에 올바르게 반영되었습니다.
343-343: LGTM!동일한 메서드명 변경이 일관되게 적용되었습니다.
apps/commerce-api/src/test/java/com/loopers/domain/product/ProductIntegrationTest.java (1)
275-275: LGTM!
ProductFacade.deleteBrand메서드명 변경이 테스트에 올바르게 반영되었습니다.apps/commerce-streamer/src/main/java/com/loopers/infrastructure/scheduler/MetricsLockCleanupScheduler.java (1)
6-6: LGTM!
MetricsServiceimport 경로가 DDD 계층 구조 리팩토링에 맞게domain→application패키지로 업데이트되었습니다. Based on learnings, 이 스케줄러는 JVM-local 메모리 구조에서 동작하므로 분산 락이 필요하지 않습니다.apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java (1)
5-24: Javadoc 추가로 문서화 개선됨Repository 인터페이스에 명확한 Javadoc 주석이 추가되어 각 메서드의 역할이 분명해졌습니다.
deleteAll()메서드 제거는 의도적인 API 축소로 보이며, 안전한 Repository 설계에 부합합니다.apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java (2)
40-49: 랭킹 엔드포인트 구현 적절함새로운 랭킹 조회 엔드포인트가 Facade 패턴을 따라 깔끔하게 구현되었습니다. Pageable과 선택적 날짜 파라미터를 받아 일관된 응답 형식으로 반환합니다.
58-60: 반환 로직 간소화 개선중간 변수를 제거하고 직접 반환하도록 변경하여 코드가 더 간결해졌습니다.
apps/commerce-api/src/main/java/com/loopers/support/Uris.java (1)
82-91: 엔드포인트 상수 추가 적절함새로운 Ranking 엔드포인트 그룹이 기존 패턴(private 생성자, BASE 및 특정 경로 상수)을 일관되게 따르고 있습니다.
apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/MetricsKafkaConsumer.java (1)
114-128: Graceful shutdown 구현 우수PreDestroy 훅을 통한 스레드 풀 종료 처리가 잘 구현되어 있습니다. 10초 대기 후 강제 종료하는 방식은 적절하며, InterruptedException 처리도 올바릅니다.
apps/commerce-streamer/src/main/java/com/loopers/application/event/EventProcessingFacade.java (2)
144-155: 랭킹 점수 업데이트 실패 처리 확인 필요배치 업데이트 실패 시 예외를 로깅만 하고 삼켜버립니다. 랭킹 데이터가 비즈니스에 중요하다면 다음을 고려하세요:
- 실패한 점수를 Dead Letter Queue에 저장
- 재시도 메커니즘 구현
- 모니터링 메트릭 발행
현재 구현은 best-effort 방식이므로, 이것이 의도된 동작인지 확인이 필요합니다.
Based on learnings, 멱등성 키 저장 실패 시 비즈니스 로직을 롤백하지 않는 실용적 접근을 선호한다는 것을 알고 있지만, 랭킹 점수 업데이트는 핵심 기능이므로 실패 처리 전략을 명확히 하는 것이 좋습니다.
67-99: 이벤트 처리 구조 우수Application Facade 패턴이 잘 적용되었습니다:
- 역직렬화, 유효성 검증, 멱등성 체크를 한 곳에서 처리
- Domain 서비스(RankingService, MetricsService)에 위임
- 명확한 책임 분리와 레이어 구분
Also applies to: 107-136
apps/commerce-streamer/src/main/java/com/loopers/application/event/dto/EventProcessingResult.java (1)
1-44: 처리 결과 DTO 설계 우수Record 타입과 정적 팩토리 메서드를 활용하여 명확하고 간결한 DTO를 구현했습니다.
processed플래그와RankingScore를 함께 반환하여 처리 여부와 결과를 한 번에 전달할 수 있습니다.apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeRankingTest.java (3)
89-159: 랭킹 조회 테스트 커버리지 우수랭킹 상품 목록 조회의 핵심 시나리오를 잘 커버하고 있습니다:
- 랭킹 순서 유지 확인
- 빈 데이터 처리
- null 날짜 파라미터 기본값 처리
161-234: 콜드 스타트 Fallback 로직 테스트 포괄적중요한 비즈니스 로직인 콜드 스타트 대응이 잘 테스트되었습니다:
- 오늘 → 어제 fallback
- 명시적 날짜 지정 시 fallback 없음
- 둘 다 비어있는 경우 처리
PR 목표에 명시된 콜드 스타트 요구사항을 충실히 검증하고 있습니다.
236-286: 상품 상세 랭킹 통합 테스트 적절상품 상세 조회 시 랭킹 정보 포함/미포함 케이스를 모두 테스트하여 null 처리가 올바른지 확인하고 있습니다.
apps/commerce-streamer/src/main/java/com/loopers/infrastructure/scheduler/RankingCarryOverScheduler.java (1)
70-73: 수동 Carry-Over 메서드 구현 확인테스트 및 운영 목적의 수동 실행 메서드가 적절하게 구현되어 있습니다.
modules/redis/src/main/java/com/loopers/cache/CacheKeyGenerator.java (1)
155-166: extractDateFromKey 구현이 적절합니다.키 형식 검증 후 날짜 추출 및 파싱 로직이 올바르게 구현되어 있습니다. 잘못된 형식의 키에 대해
IllegalArgumentException을 던지는 것도 적절합니다.apps/commerce-api/src/test/java/com/loopers/interfaces/api/RankingV1ApiE2ETest.java (3)
77-108: 테스트 셋업이 잘 구성되어 있습니다.DB/Redis 클린업, 테스트 데이터 생성, MV 동기화까지 E2E 테스트에 필요한 모든 준비 과정이 적절하게 구현되어 있습니다.
240-267: 콜드 스타트 Fallback 테스트가 핵심 시나리오를 잘 검증합니다.오늘 랭킹이 없을 때 어제 랭킹으로 폴백하는 동작을 명확하게 테스트하고 있습니다.
435-460: Carry-Over 테스트에서 리소스 정리가 적절합니다.테스트 전
tomorrowKey삭제와 테스트 후 정리(Line 459)로 테스트 격리가 잘 되어 있습니다.Offset.offset(0.01)을 사용한 부동소수점 비교도 적절합니다.apps/commerce-streamer/src/main/java/com/loopers/domain/ranking/RankingService.java (2)
45-59: 이벤트 타입별 랭킹 점수 생성 로직이 적절합니다.switch 표현식을 사용하여 이벤트 타입별로 분기 처리하고, 지원하지 않는 타입에 대해서는 debug 레벨 로깅 후 null을 반환하는 방식이 적절합니다.
151-164: 좋아요 취소 이벤트 처리 정책이 명확합니다."LIKE" 액션만 점수에 반영하고 "UNLIKE"는 무시하는 정책이 명확하게 구현되어 있습니다. PR 명세에 따라 좋아요 취소 시 점수 차감 없이 누적만 하는 방식입니다.
apps/commerce-streamer/src/main/java/com/loopers/domain/event/EventHandledService.java (1)
38-55: 멱등성 처리를 위한 이중 확인 패턴이 적절합니다.트랜잭션 내에서 다시 한번
existsById를 확인하여 동시성 문제를 방지하고, 예외 발생 시false를 반환하여 중복 저장을 안전하게 처리합니다.다만, 모든
Exception을 catch하는 것은 예상치 못한 오류도 숨길 수 있으므로, 운영 환경에서는 특정 예외(예:DataIntegrityViolationException)만 처리하고 나머지는 re-throw하는 것을 고려해볼 수 있습니다.apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java (2)
108-111: 상품 상세 조회 시 실시간 랭킹 정보 병합이 적절합니다.오늘 날짜 기준으로 Redis에서 랭킹 정보를 조회하여 상품 상세 정보에 포함시키는 구현이 올바릅니다.
140-154: 콜드 스타트 Fallback 로직이 PR 명세에 맞게 구현되었습니다.날짜 미지정 시(
date == null) 오늘 랭킹이 비어있으면 어제 랭킹으로 폴백하고, 명시적 날짜 지정 시에는 폴백하지 않는 정책이 올바르게 구현되어 있습니다.apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/MetricsKafkaConsumerTest.java (3)
42-67: Facade 기반 테스트 구조가 적절합니다.Consumer가 EventProcessingFacade에 위임하는 구조를 반영하여, Facade mock을 통해 테스트하는 방식이 올바릅니다.
124-163: 부분 실패 시나리오 테스트가 이전 리뷰 피드백을 반영했습니다.이전 리뷰에서 지적된 대로, 성공한 메시지의 랭킹 점수가 실제로 업데이트되는지 검증하는 assertion(Line 158-161)이 추가되었습니다.
165-193: 배치 랭킹 업데이트 테스트가 추가되었습니다.여러 이벤트의 랭킹 점수가 한 번의 배치로 업데이트되는 시나리오를 검증하는 테스트가 적절하게 구현되어 있습니다.
modules/redis/src/main/java/com/loopers/cache/RankingRedisService.java (2)
42-83: 배치 업데이트 구현이 적절합니다.날짜별 그룹화, 상품별 점수 집계, Redis Pipeline 사용, TTL 설정까지 효율적인 배치 처리가 잘 구현되어 있습니다.
140-185: 페이징 랭킹 조회 구현이 올바릅니다.1-based 페이지 인덱스를 Redis의 0-based 인덱스로 변환하고,
reverseRangeWithScores를 사용하여 점수 높은 순으로 조회하는 로직이 적절합니다.NumberFormatException처리로 잘못된 데이터에 대한 방어도 되어 있습니다.apps/commerce-streamer/src/main/java/com/loopers/application/metrics/MetricsService.java (2)
71-95: 멱등성 체크 로직 LGTM메모리 캐시를 통한 빠른 경로와 DB 확인을 통한 2단계 멱등성 체크가 잘 구현되어 있습니다. 동시성 충돌 시에도 캐시에 기록하여 후속 중복 호출을 빠르게 차단합니다.
149-166: 락 획득 실패 시 업데이트 누락 가능성 검토 필요
tryLock이 실패하면 메트릭 업데이트가 조용히 스킵됩니다. 높은 경합 상황에서 일부 이벤트가 메트릭에 반영되지 않을 수 있습니다. 메트릭 정확도가 중요하다면 재시도 로직 또는 대기 시간 증가를 고려해 보세요.apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dtos.java (2)
80-106: null-safe 랭킹 매핑 LGTM
productDetailInfo.ranking()이 null인 경우를 적절히 처리하여 안전하게RankingResponse를 생성합니다.
159-167: RankingResponse 레코드 LGTM랭킹 정보를 표현하기 위한 간결한 레코드입니다. Swagger 스키마 어노테이션이 적절히 적용되어 있습니다.
apps/commerce-streamer/src/test/java/com/loopers/domain/ranking/RankingServiceTest.java (3)
52-181: 랭킹 점수 생성 테스트 LGTM이벤트 타입별 점수 생성 로직이 잘 테스트되어 있습니다. 가중치 계산(PRODUCT_VIEW: 0.1, LIKE_ACTION: 0.2, PAYMENT_SUCCESS: 0.6)과 로그 정규화 검증이 적절합니다. UNLIKE 이벤트가 null을 반환하는 케이스도 포함되어 있습니다.
183-232: 배치 업데이트 테스트 LGTM빈 리스트 처리와 날짜가 null인 경우의 오버로드 메서드 호출을 올바르게 검증합니다.
234-295: 랭킹 조회 테스트 LGTM페이징된 랭킹 조회와 개별 상품 랭킹 조회, 랭킹에 없는 상품의 null 반환을 적절히 검증합니다.
modules/redis/src/main/java/com/loopers/cache/dto/CachePayloads.java (2)
27-38: EventType enum 스레드 안전성 개선 확인
weight필드가final로 선언되어 이전 리뷰에서 지적된 스레드 안전성 문제가 해결되었습니다.
14-18: RankingItem 레코드 LGTM랭킹 아이템을 표현하기 위한 간결한 레코드입니다.
apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsService.java (3)
31-37: 조회수 증가 로직 LGTM
getOrCreateMetrics를 통해 메트릭을 조회하거나 생성한 후 조회수를 증가시킵니다. 트랜잭션 내에서 안전하게 처리됩니다.
44-65: 좋아요 수 변경 로직 LGTM새 상품에 대한 좋아요 감소를 무시하는 방어적 로직이 잘 구현되어 있습니다. 기존/신규 메트릭에 대한 분기 처리가 명확합니다.
72-84: 판매량 증가 로직 LGTM수량 검증과 메트릭 업데이트가 트랜잭션 내에서 안전하게 처리됩니다.
|
|
||
| //좋아요 관련 오류 | ||
| ALREADY_LIKED_PRODUCT(HttpStatus.CONFLICT, HttpStatus.CONFLICT.getReasonPhrase(), "이미 좋아요한 상품입니다."), | ||
| NOT_EXIST_LIKED(HttpStatus.BAD_REQUEST, HttpStatus.BAD_REQUEST.getReasonPhrase(), "좋아요하지 않은 상품입니다."), |
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.
NOT_EXIST_LIKED의 HTTP 상태 코드를 NOT_FOUND로 변경하세요.
"좋아요하지 않은 상품"은 리소스가 존재하지 않는 경우이므로 HttpStatus.NOT_FOUND (404)가 더 적절합니다. BAD_REQUEST (400)는 클라이언트 요청 형식이 잘못된 경우에 사용됩니다.
🔎 수정 제안
- NOT_EXIST_LIKED(HttpStatus.BAD_REQUEST, HttpStatus.BAD_REQUEST.getReasonPhrase(), "좋아요하지 않은 상품입니다."),
+ NOT_EXIST_LIKED(HttpStatus.NOT_FOUND, HttpStatus.NOT_FOUND.getReasonPhrase(), "좋아요하지 않은 상품입니다."),📝 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.
| NOT_EXIST_LIKED(HttpStatus.BAD_REQUEST, HttpStatus.BAD_REQUEST.getReasonPhrase(), "좋아요하지 않은 상품입니다."), | |
| NOT_EXIST_LIKED(HttpStatus.NOT_FOUND, HttpStatus.NOT_FOUND.getReasonPhrase(), "좋아요하지 않은 상품입니다."), |
🤖 Prompt for AI Agents
In apps/commerce-streamer/src/main/java/com/loopers/support/error/ErrorType.java
around line 32, update the NOT_EXIST_LIKED enum entry to use
HttpStatus.NOT_FOUND instead of HttpStatus.BAD_REQUEST: replace both the
HttpStatus.BAD_REQUEST argument and the status reason argument
(HttpStatus.BAD_REQUEST.getReasonPhrase()) with HttpStatus.NOT_FOUND and
HttpStatus.NOT_FOUND.getReasonPhrase(), leaving the message "좋아요하지 않은 상품입니다."
unchanged.
| /** | ||
| * 일간 랭킹 키 생성: ranking:all:2025-12-23 | ||
| */ | ||
| public String generateDailyRankingKey(LocalDate date) { | ||
| return new StringJoiner(DELIMITER) | ||
| .add(RANKING_PREFIX) | ||
| .add(ALL_PREFIX) | ||
| .add(date.format(DATE_FORMATTER)) | ||
| .toString(); | ||
| } |
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.
JavaDoc 주석과 실제 키 형식이 불일치합니다.
주석에는 ranking:all:2025-12-23 형식으로 표기되어 있지만, DATE_FORMATTER가 yyyyMMdd 패턴을 사용하므로 실제 생성되는 키는 ranking:all:20251223 형식입니다.
🔎 수정 제안
/**
- * 일간 랭킹 키 생성: ranking:all:2025-12-23
+ * 일간 랭킹 키 생성: ranking:all:20251223
*/
public String generateDailyRankingKey(LocalDate date) {📝 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.
| /** | |
| * 일간 랭킹 키 생성: ranking:all:2025-12-23 | |
| */ | |
| public String generateDailyRankingKey(LocalDate date) { | |
| return new StringJoiner(DELIMITER) | |
| .add(RANKING_PREFIX) | |
| .add(ALL_PREFIX) | |
| .add(date.format(DATE_FORMATTER)) | |
| .toString(); | |
| } | |
| /** | |
| * 일간 랭킹 키 생성: ranking:all:20251223 | |
| */ | |
| public String generateDailyRankingKey(LocalDate date) { | |
| return new StringJoiner(DELIMITER) | |
| .add(RANKING_PREFIX) | |
| .add(ALL_PREFIX) | |
| .add(date.format(DATE_FORMATTER)) | |
| .toString(); | |
| } |
🤖 Prompt for AI Agents
In modules/redis/src/main/java/com/loopers/cache/CacheKeyGenerator.java around
lines 137-146, the JavaDoc example shows a hyphenated date
(ranking:all:2025-12-23) but the code uses DATE_FORMATTER with pattern yyyyMMdd
producing ranking:all:20251223; update the JavaDoc to match the actual key
format (e.g., ranking:all:20251223) OR, if the intended key must include dashes,
change DATE_FORMATTER to use yyyy-MM-dd and adjust any dependent code/tests
accordingly.
| public static RankingScore forPaymentSuccess(Long productId, java.math.BigDecimal totalPrice, long occurredAt) { | ||
| // 로그 정규화 적용하여 극값 방지 | ||
| // Math.log(x + 1)을 사용하여 0원일 때도 안전하게 처리 | ||
| double normalizedScore = Math.log(totalPrice.doubleValue() + 1); | ||
| return new RankingScore(productId, EventType.PAYMENT_SUCCESS, normalizedScore, occurredAt); | ||
| } |
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.
totalPrice가 null인 경우 NPE 발생 가능
totalPrice.doubleValue()는 totalPrice가 null이면 NullPointerException을 발생시킵니다. 호출자가 null을 전달하지 않도록 보장하거나, 방어적으로 null 체크를 추가하세요.
🔎 수정 제안
public static RankingScore forPaymentSuccess(Long productId, java.math.BigDecimal totalPrice, long occurredAt) {
+ if (totalPrice == null) {
+ totalPrice = java.math.BigDecimal.ZERO;
+ }
// 로그 정규화 적용하여 극값 방지
// Math.log(x + 1)을 사용하여 0원일 때도 안전하게 처리
double normalizedScore = Math.log(totalPrice.doubleValue() + 1);
return new RankingScore(productId, EventType.PAYMENT_SUCCESS, normalizedScore, occurredAt);
}📝 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.
| public static RankingScore forPaymentSuccess(Long productId, java.math.BigDecimal totalPrice, long occurredAt) { | |
| // 로그 정규화 적용하여 극값 방지 | |
| // Math.log(x + 1)을 사용하여 0원일 때도 안전하게 처리 | |
| double normalizedScore = Math.log(totalPrice.doubleValue() + 1); | |
| return new RankingScore(productId, EventType.PAYMENT_SUCCESS, normalizedScore, occurredAt); | |
| } | |
| public static RankingScore forPaymentSuccess(Long productId, java.math.BigDecimal totalPrice, long occurredAt) { | |
| if (totalPrice == null) { | |
| totalPrice = java.math.BigDecimal.ZERO; | |
| } | |
| // 로그 정규화 적용하여 극값 방지 | |
| // Math.log(x + 1)을 사용하여 0원일 때도 안전하게 처리 | |
| double normalizedScore = Math.log(totalPrice.doubleValue() + 1); | |
| return new RankingScore(productId, EventType.PAYMENT_SUCCESS, normalizedScore, occurredAt); | |
| } |
🤖 Prompt for AI Agents
In modules/redis/src/main/java/com/loopers/cache/dto/CachePayloads.java around
lines 66 to 71, the method uses totalPrice.doubleValue() which will throw NPE if
totalPrice is null; add a defensive null check at the start of forPaymentSuccess
and handle null safely (either throw a clear IllegalArgumentException with a
descriptive message or treat null as BigDecimal.ZERO), then compute the
normalized score from the non-null BigDecimal (e.g., add 1 before taking
Math.log) and return the RankingScore as before.
- 일간 랭킹 키 생성 시 날짜 형식을 변경하여 일관성 향상 - 포인트 충전 관련 오류 메시지를 보다 명확하게 수정 - BigDecimal 사용 시 불필요한 패키지 경로 제거
refactor(cache): 캐시 키 형식 및 오류 메시지 개선
Week 9 PR: Redis ZSET 기반 실시간 랭킹 시스템
📌 Summary
Redis ZSET을 활용한 실시간 상품 랭킹 시스템을 구현했습니다.
주요 구현 내용:
GET /api/v1/rankings) 및 상품 상세 조회 시 랭킹 정보 포함구현된 이벤트 → 랭킹 점수:
PRODUCT_VIEW: 조회 → Weight 0.1 × Score 1 = 0.1점LIKE_ACTION: 좋아요 → Weight 0.2 × Score 1 = 0.2점PAYMENT_SUCCESS: 주문 → Weight 0.6 × log(totalPrice + 1) = 로그 정규화 점수💬 Review Points
1. 가중치 설계 (Weighted Sum)
배경 및 문제 상황:
상품 인기도를 측정하기 위해 여러 이벤트(조회, 좋아요, 주문)를 종합해야 하는데, 각 이벤트의 중요도가 다릅니다. 단순히 이벤트 횟수만 합산하면 조회 1회와 주문 1회가 동일한 가중치를 갖게 되어 실제 비즈니스 가치를 반영하지 못합니다.
해결 방안:
이벤트별 가중치(Weight)와 점수(Score)를 분리하여 설계했습니다:
구현 세부사항:
고민한 점:
log(totalPrice + 1))를 적용한 이유는, 고가 상품 1건과 저가 상품 다수 판매 간 균형을 맞추기 위함이었습니다..2. DDD 기반 구조 리팩토링
배경 및 문제 상황:
기존 구조에서 여러 문제점이 있었습니다:
MetricsFacade가 Repository를 직접 의존하여 Self-Transaction 문제 발생해결 방안:
클린 아키텍처 원칙에 따라 계층을 명확히 분리했습니다:
구현 세부사항:
1) Interface Layer - Consumer (단순 위임):
2) Application Layer - Facade (외부 진입점):
3) Application Layer - Service (내부 조합):
4) Domain Layer - Domain Service (Self-Transaction 해결):
5) DTO 분리:
고민한 점:
Facade vs Service 명명:
EventProcessingFacade는 외부 진입점(Consumer에서 호출)이므로 Facade로,MetricsService는 내부 조합용이므로 Service로 명명했습니다. Facade가 Facade를 호출하는 것은 안티패턴이라고 생각이 들어서, 이렇게 클래스 명을 따로 구분했는데, 결국 응용계층에서 다른 응용 객체를 이용한것이 안티패턴이 아닌가 싶습니다.Self-Transaction 문제 해결: 기존에
MetricsService가 Repository를 직접 의존하면서 같은 클래스 내@Transactional메서드 호출 시 프록시를 거치지 않아 트랜잭션이 적용되지 않는 문제가 있었습니다. Domain Service(ProductMetricsService,EventHandledService)를 분리하여 각 서비스가 자체 트랜잭션 경계를 관리하도록 했습니다.계층별 책임 분리:
3. 콜드 스타트 해결 전략
배경 및 문제 상황:
일간 랭킹 키(
ranking:all:20251226)를 사용하면 자정에 새로운 날짜 키가 생성되어 랭킹 데이터가 비어있게 됩니다.해결 방안:
2중 안전망을 구축했습니다:
구현 세부사항:
고민한 점:
4. E2E 테스트 전략
배경 및 문제 상황:
단위 테스트와 통합 테스트만으로는 실제 운영 환경에서의 전체 흐름을 검증하기 어렵습니다. 특히 랭킹 시스템은 여러 컴포넌트(DB, Redis, API)가 연동되어야 하므로, 실제 데이터 생성부터 API 응답까지 전체 프로세스를 검증할 필요가 있다 생각했습니다.
해결 방안:
랭킹 시스템에 필요한 각각 인프라에 데이터를 넣는 상황에서 api 응답이 기대했던 대로 진행되었는지 여부만 e2e 로 테스트 했습니다.
구현 세부사항:
1) E2E 테스트 - 실제 데이터 생성 → Redis 적재 → API 조회:
2) 콜드 스타트 Fallback E2E 검증:
3) 상품 상세 조회 시 랭킹 정보 포함 검증:
4) 점수 누적 및 Carry-Over 검증:
고민한 점:
@BeforeEach에서 실제 데이터 생성,@AfterEach에서 DB/Redis 정리를 통해 테스트 간 격리를 보장했습니다.TestRestTemplate을 사용하여 실제 HTTP 요청/응답을 검증하여 직렬화/역직렬화 문제도 함께 검증합니다.getWeightedScore()적용 후 점수를 검증하여 실제 운영 환경과 동일한 결과를 확인합니다.5. Redis ZSET 키 전략
해결 방안:
일간 키 전략을 채택했습니다:
구현 세부사항:
고민한 점:
allprefix를 사용한 이유: 향후 카테고리별/브랜드별 랭킹 확장을 고려했습니다.✅ Checklist
Ranking Consumer (3/3)
랭킹 ZSET의 TTL, 키 전략을 적절하게 구성하였다
modules/redis/src/main/java/com/loopers/cache/RankingRedisService.java(17줄, TTL 2일)modules/redis/src/main/java/com/loopers/cache/CacheKeyGenerator.java(127-133줄, 키 생성)날짜별로 적재할 키를 계산하는 기능을 만들었다
modules/redis/src/main/java/com/loopers/cache/dto/CachePayloads.java(44-48줄,getEventDate())이벤트가 발생한 후, ZSET에 점수가 적절하게 반영된다
modules/redis/src/main/java/com/loopers/cache/RankingRedisService.java(28-70줄,updateRankingScoresBatch())Ranking API (3/3)
랭킹 Page 조회 시 정상적으로 랭킹 정보가 반환된다
apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java(40-47줄)랭킹 Page 조회 시 상품정보가 Aggregation 되어 제공된다
apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java(145-160줄)상품 상세 조회 시 해당 상품의 순위가 함께 반환된다
apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java(100-101줄)콜드 스타트 해결 (2/2)
Score Carry-Over 스케줄러 구현 (23:50 실행)
apps/commerce-streamer/src/main/java/com/loopers/infrastructure/scheduler/RankingCarryOverScheduler.javaAPI Fallback 로직 구현 (어제 랭킹 조회)
apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java(125-140줄)DDD 리팩토링 (4/4)
Interface → Application 계층 분리 (Consumer → Facade 위임)
apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/MetricsKafkaConsumer.javaapps/commerce-streamer/src/main/java/com/loopers/application/event/EventProcessingFacade.javaApplication 계층 내 Facade/Service 명명 규칙 적용
EventProcessingFacade(외부 진입점)MetricsService(내부 조합 - Facade→Facade 안티패턴 방지)Domain Service 분리 (Self-Transaction 문제 해결)
apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsService.javaapps/commerce-streamer/src/main/java/com/loopers/domain/event/EventHandledService.javaDTO 분리
apps/commerce-streamer/src/main/java/com/loopers/application/event/dto/EventProcessingResult.java테스트 (4/4)
RankingServiceTest - 점수 생성, 가중치 계산 단위 테스트
apps/commerce-streamer/src/test/java/com/loopers/domain/ranking/RankingServiceTest.javaRankingIntegrationTest - Kafka → Redis ZSET 통합 테스트
apps/commerce-streamer/src/test/java/com/loopers/integration/RankingIntegrationTest.javaProductFacadeRankingTest - 랭킹 API, Fallback 로직 단위 테스트 (Mock)
apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeRankingTest.javaRankingV1ApiE2ETest - 랭킹 API E2E 테스트 (실제 데이터 생성 → Redis 적재 → API 조회)
apps/commerce-api/src/test/java/com/loopers/interfaces/api/RankingV1ApiE2ETest.java📎 References
Summary by CodeRabbit
New Features
Improvements
Tests
✏️ Tip: You can customize this high-level summary in your review settings.