diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java index a32e62f30..de9f15813 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java @@ -7,6 +7,7 @@ import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import java.math.BigDecimal; +import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.cache.annotation.CacheEvict; import org.springframework.cache.annotation.Cacheable; @@ -72,6 +73,21 @@ public Page getProductsByBrand(Long brandId, Pageable pageable) { }); } + /** + * 여러 상품 ID로 조회 (랭킹용) + */ + @Transactional(readOnly = true) + public List findByIds(List ids) { + List products = productRepository.findByIdIn(ids); + // Brand 로딩 및 DTO 변환 + return products.stream() + .map(product -> { + product.getBrand().getName(); // Brand 로딩 + return ProductInfo.from(product); + }) + .toList(); + } + @Transactional @CacheEvict(value = "product", key = "#id") public Product updateProduct(Long id, String name, BigDecimal price, Integer stock, diff --git a/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java new file mode 100644 index 000000000..4749bd948 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java @@ -0,0 +1,93 @@ +package com.loopers.application.ranking; + +import com.loopers.application.product.ProductInfo; +import com.loopers.application.product.ProductService; +import com.loopers.application.ranking.RankingService.RankingItem; +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +/** + * 랭킹 + 상품 정보 조합 Facade + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class RankingFacade { + + private final RankingService rankingService; + private final ProductService productService; + + /** + * 일간 랭킹 페이지 조회 (상품 정보 포함) + */ + public List getDailyRanking(String date, int page, int size) { + // 1. Redis ZSET에서 랭킹 조회 + List rankingItems = rankingService.getDailyRanking(date, page, size); + + if (rankingItems.isEmpty()) { + return List.of(); + } + + // 2. Product 정보 조회 (Batch) + List productIds = rankingItems.stream() + .map(RankingItem::getProductId) + .toList(); + + Map productMap = productService.findByIds(productIds).stream() + .collect(Collectors.toMap(ProductInfo::id, p -> p)); + + // 3. 랭킹 + 상품 정보 조합 + List results = new ArrayList<>(); + for (RankingItem item : rankingItems) { + ProductInfo product = productMap.get(item.getProductId()); + + if (product == null) { + log.warn("랭킹에 있지만 상품 정보 없음 - productId: {}", item.getProductId()); + continue; + } + + results.add(RankingProductInfo.builder() + .rank(item.getRank()) + .score(item.getScore()) + .productId(product.id()) + .productName(product.name()) + .brandName(product.brand().name()) + .price(product.price()) + .stock(product.stock()) + .likeCount(product.likeCount()) + .build()); + } + + return results; + } + + /** + * 오늘 랭킹 페이지 조회 + */ + public List getTodayRanking(int page, int size) { + String today = java.time.LocalDate.now().format(java.time.format.DateTimeFormatter.ofPattern("yyyyMMdd")); + return getDailyRanking(today, page, size); + } + + /** + * 랭킹 + 상품 정보 DTO + */ + @lombok.Getter + @lombok.Builder + public static class RankingProductInfo { + private int rank; // 순위 + private Double score; // 점수 + private Long productId; // 상품 ID + private String productName; // 상품명 + private String brandName; // 브랜드명 + private BigDecimal price; // 가격 + private Integer stock; // 재고 + private Long likeCount; // 좋아요 수 + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingService.java b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingService.java new file mode 100644 index 000000000..740007845 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingService.java @@ -0,0 +1,129 @@ +package com.loopers.application.ranking; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ZSetOperations.TypedTuple; +import org.springframework.stereotype.Service; + +/** + * 랭킹 조회 서비스 (Redis ZSET 조회) + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class RankingService { + + private final RedisTemplate redisTemplate; + + private static final String DAILY_PREFIX = "ranking:all:"; + private static final String HOURLY_PREFIX = "ranking:realtime:"; + private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd"); + + /** + * 일간 랭킹 Top-N 조회 + * + * @param date 조회 날짜 (yyyyMMdd) + * @param page 페이지 번호 (1부터 시작) + * @param size 페이지 크기 + * @return 랭킹 아이템 목록 + */ + public List getDailyRanking(String date, int page, int size) { + String key = DAILY_PREFIX + date; + return getRanking(key, page, size); + } + + /** + * 오늘 일간 랭킹 Top-N 조회 + */ + public List getTodayRanking(int page, int size) { + String today = LocalDate.now().format(DATE_FORMATTER); + return getDailyRanking(today, page, size); + } + + /** + * 특정 상품의 일간 랭킹 순위 조회 + * + * @param date 조회 날짜 + * @param productId 상품 ID + * @return 순위 (1부터 시작, 없으면 null) + */ + public Long getProductRank(String date, Long productId) { + String key = DAILY_PREFIX + date; + String member = productId.toString(); + + Long rank = redisTemplate.opsForZSet().reverseRank(key, member); + return rank != null ? rank + 1 : null; // 0-based → 1-based + } + + /** + * 오늘 특정 상품의 랭킹 순위 조회 + */ + public Long getProductRankToday(Long productId) { + String today = LocalDate.now().format(DATE_FORMATTER); + return getProductRank(today, productId); + } + + /** + * 특정 상품의 점수 조회 + */ + public Double getProductScore(String date, Long productId) { + String key = DAILY_PREFIX + date; + String member = productId.toString(); + + return redisTemplate.opsForZSet().score(key, member); + } + + /** + * ZSET에서 랭킹 조회 (내부 공통 로직) + */ + private List getRanking(String key, int page, int size) { + // 페이지 계산 (1-based → 0-based) + int start = (page - 1) * size; + int end = start + size - 1; + + // ZREVRANGE로 점수 높은 순으로 조회 + Set> results = redisTemplate.opsForZSet() + .reverseRangeWithScores(key, start, end); + + if (results == null || results.isEmpty()) { + log.debug("랭킹 조회 결과 없음 - key: {}, page: {}, size: {}", key, page, size); + return List.of(); + } + + List items = new ArrayList<>(); + int rank = start + 1; // 순위는 1부터 시작 + + for (TypedTuple tuple : results) { + Long productId = Long.parseLong(tuple.getValue()); + Double score = tuple.getScore(); + + items.add(RankingItem.builder() + .rank(rank++) + .productId(productId) + .score(score) + .build()); + } + + log.info("랭킹 조회 완료 - key: {}, page: {}, size: {}, count: {}", + key, page, size, items.size()); + + return items; + } + + /** + * 랭킹 아이템 DTO + */ + @lombok.Getter + @lombok.Builder + public static class RankingItem { + private int rank; // 순위 (1부터 시작) + private Long productId; // 상품 ID + private Double score; // 점수 + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java index 9b53b1f47..04c944f9b 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java @@ -2,6 +2,7 @@ import com.loopers.application.product.ProductInfo; import com.loopers.application.product.ProductService; +import com.loopers.application.ranking.RankingService; import com.loopers.application.useraction.UserActionService; import com.loopers.domain.product.Product; import com.loopers.interfaces.api.ApiResponse; @@ -25,6 +26,7 @@ public class ProductV1Controller implements ProductV1ApiSpec { private final ProductService productService; private final UserActionService userActionService; + private final RankingService rankingService; @PostMapping @Override @@ -55,7 +57,11 @@ public ApiResponse getProduct( } ProductInfo productInfo = productService.getProduct(productId); - ProductV1Dto.ProductResponse response = ProductV1Dto.ProductResponse.from(productInfo); + + // 오늘의 랭킹 순위 조회 + Long rank = rankingService.getProductRankToday(productId); + + ProductV1Dto.ProductResponse response = ProductV1Dto.ProductResponse.from(productInfo, rank); return ApiResponse.success(response); } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java index cb636bbe3..340b1e03b 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java @@ -44,7 +44,8 @@ public record ProductResponse( Integer stock, String description, BrandResponse brand, - Long likeCount + Long likeCount, + Long rank // 오늘의 랭킹 순위 (없으면 null) ) { public static ProductResponse from(ProductInfo info) { return new ProductResponse( @@ -54,7 +55,21 @@ public static ProductResponse from(ProductInfo info) { info.stock(), info.description(), BrandResponse.from(info.brand()), - info.likeCount() + info.likeCount(), + null // 기본값 null + ); + } + + public static ProductResponse from(ProductInfo info, Long rank) { + return new ProductResponse( + info.id(), + info.name(), + info.price(), + info.stock(), + info.description(), + BrandResponse.from(info.brand()), + info.likeCount(), + rank ); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingApi.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingApi.java new file mode 100644 index 000000000..3958a85e9 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingApi.java @@ -0,0 +1,75 @@ +package com.loopers.interfaces.api.ranking; + +import com.loopers.application.ranking.RankingFacade; +import com.loopers.application.ranking.RankingFacade.RankingProductInfo; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** + * 랭킹 API + */ +@Tag(name = "Ranking", description = "상품 랭킹 API") +@RestController +@RequestMapping("/api/v1/rankings") +@RequiredArgsConstructor +public class RankingApi { + + private final RankingFacade rankingFacade; + + private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd"); + + @Operation( + summary = "일간 랭킹 조회", + description = "특정 날짜의 상품 랭킹을 페이지 단위로 조회합니다." + ) + @GetMapping + public ResponseEntity getRankings( + @Parameter(description = "조회 날짜 (yyyyMMdd), 미입력 시 오늘", example = "20250123") + @RequestParam(required = false) String date, + + @Parameter(description = "페이지 번호 (1부터 시작)", example = "1") + @RequestParam(defaultValue = "1") int page, + + @Parameter(description = "페이지 크기", example = "20") + @RequestParam(defaultValue = "20") int size + ) { + // 날짜 검증 + String targetDate = validateAndGetDate(date); + + // 랭킹 조회 + List rankings = rankingFacade.getDailyRanking(targetDate, page, size); + + return ResponseEntity.ok(new RankingResponse( + targetDate, + page, + size, + rankings + )); + } + + /** + * 날짜 검증 및 변환 + */ + private String validateAndGetDate(String date) { + if (date == null || date.isBlank()) { + return LocalDate.now().format(DATE_FORMATTER); + } + + try { + LocalDate.parse(date, DATE_FORMATTER); + return date; + } catch (Exception e) { + throw new IllegalArgumentException("날짜 형식이 올바르지 않습니다. (yyyyMMdd)"); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingResponse.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingResponse.java new file mode 100644 index 000000000..641eca8d1 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingResponse.java @@ -0,0 +1,21 @@ +package com.loopers.interfaces.api.ranking; + +import com.loopers.application.ranking.RankingFacade.RankingProductInfo; +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; + +@Schema(description = "랭킹 조회 응답") +public record RankingResponse( + @Schema(description = "조회 날짜", example = "20250123") + String date, + + @Schema(description = "페이지 번호", example = "1") + int page, + + @Schema(description = "페이지 크기", example = "20") + int size, + + @Schema(description = "랭킹 목록") + List rankings +) { +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/CommerceStreamerApplication.java b/apps/commerce-streamer/src/main/java/com/loopers/CommerceStreamerApplication.java index 7d5a0e8fc..1bf21da5f 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/CommerceStreamerApplication.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/CommerceStreamerApplication.java @@ -5,7 +5,9 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.context.properties.ConfigurationPropertiesScan; +import org.springframework.scheduling.annotation.EnableScheduling; +@EnableScheduling @ConfigurationPropertiesScan @SpringBootApplication public class CommerceStreamerApplication { diff --git a/apps/commerce-streamer/src/main/java/com/loopers/application/ranking/RankingAggregator.java b/apps/commerce-streamer/src/main/java/com/loopers/application/ranking/RankingAggregator.java new file mode 100644 index 000000000..ae3bf2c39 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/application/ranking/RankingAggregator.java @@ -0,0 +1,131 @@ +package com.loopers.application.ranking; + +import com.loopers.domain.ranking.RankingKey; +import com.loopers.domain.ranking.RankingScore; +import java.time.Duration; +import java.util.Map; +import java.util.Map.Entry; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; + +/** + * Redis ZSET 기반 랭킹 집계 서비스 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class RankingAggregator { + + private final RedisTemplate redisTemplate; + + private static final Duration TTL_DAILY = Duration.ofDays(2); // 일간 랭킹 TTL: 2일 + private static final Duration TTL_HOURLY = Duration.ofHours(48); // 시간별 랭킹 TTL: 48시간 + + /** + * 일간 랭킹에 조회 점수 증가 + */ + public void incrementViewScore(Long productId) { + String key = RankingKey.dailyToday(); + double score = RankingScore.viewScore(); + incrementScore(key, productId, score, TTL_DAILY); + + // 실시간 랭킹에도 반영 + String hourlyKey = RankingKey.hourlyNow(); + incrementScore(hourlyKey, productId, score, TTL_HOURLY); + } + + /** + * 일간 랭킹에 좋아요 점수 증가 + */ + public void incrementLikeScore(Long productId) { + String key = RankingKey.dailyToday(); + double score = RankingScore.likeScore(); + incrementScore(key, productId, score, TTL_DAILY); + + // 실시간 랭킹에도 반영 + String hourlyKey = RankingKey.hourlyNow(); + incrementScore(hourlyKey, productId, score, TTL_HOURLY); + } + + /** + * 일간 랭킹에 좋아요 점수 감소 + */ + public void decrementLikeScore(Long productId) { + String key = RankingKey.dailyToday(); + double score = RankingScore.unlikeScore(); + incrementScore(key, productId, score, TTL_DAILY); + + // 실시간 랭킹에도 반영 + String hourlyKey = RankingKey.hourlyNow(); + incrementScore(hourlyKey, productId, score, TTL_HOURLY); + } + + /** + * 일간 랭킹에 주문 점수 증가 + */ + public void incrementOrderScore(Long productId, int price, int amount) { + String key = RankingKey.dailyToday(); + double score = RankingScore.orderScore(price, amount); + incrementScore(key, productId, score, TTL_DAILY); + + // 실시간 랭킹에도 반영 + String hourlyKey = RankingKey.hourlyNow(); + incrementScore(hourlyKey, productId, score, TTL_HOURLY); + } + + /** + * 배치 점수 증가 (성능 최적화) + * - 동일 productId의 점수를 합산한 후 한 번에 반영 + */ + public void incrementScoresBatch(Map scoreMap) { + if (scoreMap == null || scoreMap.isEmpty()) { + return; + } + + String dailyKey = RankingKey.dailyToday(); + String hourlyKey = RankingKey.hourlyNow(); + + // Pipeline으로 성능 최적화 + redisTemplate.executePipelined((org.springframework.data.redis.core.RedisCallback) connection -> { + for (Entry entry : scoreMap.entrySet()) { + String member = entry.getKey().toString(); + Double score = entry.getValue(); + + // 일간 랭킹 + connection.zIncrBy(dailyKey.getBytes(), score, member.getBytes()); + // 실시간 랭킹 + connection.zIncrBy(hourlyKey.getBytes(), score, member.getBytes()); + } + return null; + }); + + // TTL 설정 + redisTemplate.expire(dailyKey, TTL_DAILY); + redisTemplate.expire(hourlyKey, TTL_HOURLY); + + log.info("배치 랭킹 점수 반영 완료 - count: {}, dailyKey: {}, hourlyKey: {}", + scoreMap.size(), dailyKey, hourlyKey); + } + + /** + * ZSET에 점수 증가 (ZINCRBY) + */ + private void incrementScore(String key, Long productId, double score, Duration ttl) { + String member = productId.toString(); + + try { + Double newScore = redisTemplate.opsForZSet().incrementScore(key, member, score); + + // TTL 설정 (키가 처음 생성될 때) + redisTemplate.expire(key, ttl); + + log.debug("랭킹 점수 증가 - key: {}, productId: {}, score: {:.2f}, newScore: {:.2f}", + key, productId, score, newScore); + } catch (Exception e) { + log.error("랭킹 점수 증가 실패 - key: {}, productId: {}, score: {}", + key, productId, score, e); + } + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/application/ranking/RankingScheduler.java b/apps/commerce-streamer/src/main/java/com/loopers/application/ranking/RankingScheduler.java new file mode 100644 index 000000000..ce27d625d --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/application/ranking/RankingScheduler.java @@ -0,0 +1,92 @@ +package com.loopers.application.ranking; + +import com.loopers.domain.ranking.RankingKey; +import com.loopers.domain.ranking.RankingScore; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +/** + * 랭킹 관리 스케줄러 + * - 콜드 스타트 문제 해결을 위한 Score Carry-Over + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class RankingScheduler { + + private final RedisTemplate redisTemplate; + + /** + * 매일 23:50에 내일 랭킹 키 미리 생성 (콜드 스타트 해결) + * - 오늘 점수의 10%를 내일 키로 복사 + */ + @Scheduled(cron = "0 50 23 * * *") // 매일 23:50 실행 + public void prepareNextDayRanking() { + try { + String todayKey = RankingKey.dailyToday(); + String tomorrowKey = RankingKey.dailyTomorrow(); + + log.info("내일 랭킹 키 생성 시작 - today: {}, tomorrow: {}", todayKey, tomorrowKey); + + // ZUNIONSTORE를 사용해 오늘 점수의 10%를 내일 키로 복사 + // destination key, numkeys, key1, weights, aggregate + Long result = redisTemplate.opsForZSet().unionAndStore( + todayKey, // source key + java.util.Collections.singleton(tomorrowKey), // destination keys + tomorrowKey // destination key + ); + + if (result != null && result > 0) { + // 가중치 0.1 적용 (모든 점수를 10%로 조정) + multiplyAllScores(tomorrowKey, RankingScore.carryOverWeight()); + + log.info("내일 랭킹 키 생성 완료 - key: {}, count: {}, weight: {}", + tomorrowKey, result, RankingScore.carryOverWeight()); + } else { + log.warn("오늘 랭킹 데이터가 없어 내일 키를 생성하지 않음 - todayKey: {}", todayKey); + } + + } catch (Exception e) { + log.error("내일 랭킹 키 생성 실패", e); + } + } + + /** + * ZSET의 모든 점수에 가중치 적용 + * - 각 멤버의 점수를 가중치만큼 곱함 + */ + private void multiplyAllScores(String key, double weight) { + try { + // ZRANGE로 모든 멤버와 점수 조회 + var entries = redisTemplate.opsForZSet().rangeWithScores(key, 0, -1); + + if (entries == null || entries.isEmpty()) { + return; + } + + // Pipeline으로 일괄 업데이트 + redisTemplate.executePipelined((org.springframework.data.redis.core.RedisCallback) connection -> { + for (var entry : entries) { + String member = entry.getValue(); + Double score = entry.getScore(); + + if (member != null && score != null) { + // 기존 점수 제거 후 새 점수로 추가 + double newScore = score * weight; + connection.zAdd(key.getBytes(), newScore, member.getBytes()); + } + } + return null; + }); + + log.debug("점수 가중치 적용 완료 - key: {}, weight: {}, count: {}", + key, weight, entries.size()); + + } catch (Exception e) { + log.error("점수 가중치 적용 실패 - key: {}", key, e); + } + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/ranking/RankingKey.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/ranking/RankingKey.java new file mode 100644 index 000000000..c17f4496e --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/ranking/RankingKey.java @@ -0,0 +1,71 @@ +package com.loopers.domain.ranking; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; + +/** + * Redis ZSET 랭킹 키 생성 전략 + */ +public class RankingKey { + + private static final String DAILY_PREFIX = "ranking:all:"; + private static final String HOURLY_PREFIX = "ranking:realtime:"; + private static final DateTimeFormatter DAILY_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd"); + private static final DateTimeFormatter HOURLY_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMddHH"); + + /** + * 일간 랭킹 키 생성 + * @param date 대상 날짜 + * @return ranking:all:yyyyMMdd + */ + public static String daily(LocalDate date) { + return DAILY_PREFIX + date.format(DAILY_FORMATTER); + } + + /** + * 오늘 일간 랭킹 키 생성 + */ + public static String dailyToday() { + return daily(LocalDate.now()); + } + + /** + * 어제 일간 랭킹 키 생성 + */ + public static String dailyYesterday() { + return daily(LocalDate.now().minusDays(1)); + } + + /** + * 내일 일간 랭킹 키 생성 (콜드 스타트용) + */ + public static String dailyTomorrow() { + return daily(LocalDate.now().plusDays(1)); + } + + /** + * 시간 단위 실시간 랭킹 키 생성 + * @param dateTime 대상 시간 + * @return ranking:realtime:yyyyMMddHH + */ + public static String hourly(LocalDateTime dateTime) { + return HOURLY_PREFIX + dateTime.format(HOURLY_FORMATTER); + } + + /** + * 현재 시간의 실시간 랭킹 키 생성 + */ + public static String hourlyNow() { + return hourly(LocalDateTime.now()); + } + + /** + * 문자열 날짜로 일간 랭킹 키 생성 + * @param dateString yyyyMMdd 형식 + */ + public static String daily(String dateString) { + LocalDate date = LocalDate.parse(dateString, DAILY_FORMATTER); + return daily(date); + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/ranking/RankingScore.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/ranking/RankingScore.java new file mode 100644 index 000000000..d5c8ba245 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/ranking/RankingScore.java @@ -0,0 +1,65 @@ +package com.loopers.domain.ranking; + +/** + * 랭킹 점수 계산 전략 + * - 가중치 기반 점수 계산 + */ +public class RankingScore { + + // 가중치 설정 + private static final double WEIGHT_VIEW = 0.1; // 조회 + private static final double WEIGHT_LIKE = 0.2; // 좋아요 + private static final double WEIGHT_ORDER = 0.6; // 주문 + + // 콜드 스타트 해결을 위한 전날 점수 가중치 + private static final double WEIGHT_CARRY_OVER = 0.1; // 10% + + /** + * 조회 이벤트 점수 + */ + public static double viewScore() { + return WEIGHT_VIEW * 1; + } + + /** + * 좋아요 이벤트 점수 + */ + public static double likeScore() { + return WEIGHT_LIKE * 1; + } + + /** + * 좋아요 취소 이벤트 점수 (음수) + */ + public static double unlikeScore() { + return -(WEIGHT_LIKE * 1); + } + + /** + * 주문 이벤트 점수 + * @param price 단가 + * @param amount 수량 + * @return 가중치 적용된 점수 + */ + public static double orderScore(int price, int amount) { + // 로그 정규화 적용 (큰 값의 영향력 감소) + double rawScore = price * amount; + double normalizedScore = Math.log10(rawScore + 1); // +1은 log(0) 방지 + return WEIGHT_ORDER * normalizedScore; + } + + /** + * 콜드 스타트를 위한 전날 점수 가중치 + */ + public static double carryOverWeight() { + return WEIGHT_CARRY_OVER; + } + + /** + * 가중치 정보 조회 + */ + public static String getWeightInfo() { + return String.format("View: %.1f, Like: %.1f, Order: %.1f, CarryOver: %.1f", + WEIGHT_VIEW, WEIGHT_LIKE, WEIGHT_ORDER, WEIGHT_CARRY_OVER); + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CatalogEventConsumer.java b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CatalogEventConsumer.java index fc1b7bd31..6aa1bd973 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CatalogEventConsumer.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CatalogEventConsumer.java @@ -4,6 +4,7 @@ import com.loopers.application.dlq.DeadLetterQueueService; import com.loopers.application.inbox.EventInboxService; import com.loopers.application.metrics.ProductMetricsService; +import com.loopers.application.ranking.RankingAggregator; import com.loopers.confg.kafka.KafkaConfig; import java.util.List; import java.util.Map; @@ -27,6 +28,7 @@ public class CatalogEventConsumer { private final EventInboxService eventInboxService; private final ProductMetricsService productMetricsService; + private final RankingAggregator rankingAggregator; private final DeadLetterQueueService deadLetterQueueService; private final ObjectMapper objectMapper; @@ -41,7 +43,7 @@ public void consumeCatalogEvents( List> records, Acknowledgment acknowledgment ) { - log.info("📦 Catalog 이벤트 수신 - count: {}", records.size()); + log.info("Catalog 이벤트 수신 - count: {}", records.size()); int successCount = 0; int skipCount = 0; @@ -57,7 +59,7 @@ public void consumeCatalogEvents( } } catch (Exception e) { failCount++; - log.error("❌ 이벤트 처리 실패 - partition: {}, offset: {}, key: {}, error: {}", + log.error("이벤트 처리 실패 - partition: {}, offset: {}, key: {}, error: {}", record.partition(), record.offset(), record.key(), e.getMessage(), e); // DLQ에 전송 @@ -68,7 +70,7 @@ public void consumeCatalogEvents( // Offset 커밋 (배치 단위) acknowledgment.acknowledge(); - log.info("✅ Catalog 이벤트 처리 완료 - success: {}, skip: {}, fail: {}, total: {}", + log.info("Catalog 이벤트 처리 완료 - success: {}, skip: {}, fail: {}, total: {}", successCount, skipCount, failCount, records.size()); } @@ -90,14 +92,14 @@ protected boolean processEvent(ConsumerRecord record) throws Exc String aggregateId = (String) eventData.get("aggregateId"); if (eventId == null) { - log.warn("⚠️ eventId가 없는 메시지 - partition: {}, offset: {}", + log.warn("eventId가 없는 메시지 - partition: {}, offset: {}", record.partition(), record.offset()); return false; } // 1. 중복 체크 (Inbox) if (eventInboxService.isDuplicate(eventId)) { - log.info("🔁 중복 이벤트 스킵 - eventId: {}, eventType: {}", eventId, eventType); + log.info("중복 이벤트 스킵 - eventId: {}, eventType: {}", eventId, eventType); return false; } @@ -110,21 +112,24 @@ protected boolean processEvent(ConsumerRecord record) throws Exc switch (eventType) { case "LikeCreatedEvent": productMetricsService.incrementLikeCount(productId); + rankingAggregator.incrementLikeScore(productId); break; case "LikeDeletedEvent": productMetricsService.decrementLikeCount(productId); + rankingAggregator.decrementLikeScore(productId); break; case "ProductViewedEvent": productMetricsService.incrementViewCount(productId); + rankingAggregator.incrementViewScore(productId); break; default: - log.warn("⚠️ 알 수 없는 이벤트 타입 - eventType: {}", eventType); + log.warn("알 수 없는 이벤트 타입 - eventType: {}", eventType); } - log.info("✨ 이벤트 처리 완료 - eventId: {}, eventType: {}, productId: {}", + log.info("이벤트 처리 완료 - eventId: {}, eventType: {}, productId: {}", eventId, eventType, productId); return true; @@ -148,7 +153,7 @@ private void sendToDLQ(ConsumerRecord record, Exception error, i retryCount ); } catch (Exception e) { - log.error("❌ DLQ 저장 실패 - error: {}", e.getMessage(), e); + log.error("DLQ 저장 실패 - error: {}", e.getMessage(), e); } } diff --git a/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/OrderEventConsumer.java b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/OrderEventConsumer.java index 4c3d8932d..186361c8f 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/OrderEventConsumer.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/OrderEventConsumer.java @@ -4,6 +4,7 @@ import com.loopers.application.dlq.DeadLetterQueueService; import com.loopers.application.inbox.EventInboxService; import com.loopers.application.metrics.ProductMetricsService; +import com.loopers.application.ranking.RankingAggregator; import com.loopers.confg.kafka.KafkaConfig; import java.math.BigDecimal; import java.util.List; @@ -28,6 +29,7 @@ public class OrderEventConsumer { private final EventInboxService eventInboxService; private final ProductMetricsService productMetricsService; + private final RankingAggregator rankingAggregator; private final DeadLetterQueueService deadLetterQueueService; private final ObjectMapper objectMapper; @@ -40,7 +42,7 @@ public void consumeOrderEvents( List> records, Acknowledgment acknowledgment ) { - log.info("📦 Order 이벤트 수신 - count: {}", records.size()); + log.info("Order 이벤트 수신 - count: {}", records.size()); int successCount = 0; int skipCount = 0; @@ -56,7 +58,7 @@ public void consumeOrderEvents( } } catch (Exception e) { failCount++; - log.error("❌ 이벤트 처리 실패 - partition: {}, offset: {}, key: {}, error: {}", + log.error("이벤트 처리 실패 - partition: {}, offset: {}, key: {}, error: {}", record.partition(), record.offset(), record.key(), e.getMessage(), e); // DLQ에 전송 @@ -67,7 +69,7 @@ public void consumeOrderEvents( // Offset 커밋 (배치 단위) acknowledgment.acknowledge(); - log.info("✅ Order 이벤트 처리 완료 - success: {}, skip: {}, fail: {}, total: {}", + log.info("Order 이벤트 처리 완료 - success: {}, skip: {}, fail: {}, total: {}", successCount, skipCount, failCount, records.size()); } @@ -88,14 +90,14 @@ protected boolean processEvent(ConsumerRecord record) throws Exc String aggregateId = (String) eventData.get("aggregateId"); if (eventId == null) { - log.warn("⚠️ eventId가 없는 메시지 - partition: {}, offset: {}", + log.warn("eventId가 없는 메시지 - partition: {}, offset: {}", record.partition(), record.offset()); return false; } // 1. 중복 체크 (Inbox) if (eventInboxService.isDuplicate(eventId)) { - log.info("🔁 중복 이벤트 스킵 - eventId: {}, eventType: {}", eventId, eventType); + log.info("중복 이벤트 스킵 - eventId: {}, eventType: {}", eventId, eventType); return false; } @@ -107,10 +109,10 @@ protected boolean processEvent(ConsumerRecord record) throws Exc processOrderCreated(payload); } else if ("PaymentSuccessEvent".equals(eventType)) { // 추가 처리 로직 (필요 시) - log.info("💰 결제 성공 이벤트 수신 - eventId: {}", eventId); + log.info("결제 성공 이벤트 수신 - eventId: {}", eventId); } - log.info("✨ 이벤트 처리 완료 - eventId: {}, eventType: {}", + log.info("이벤트 처리 완료 - eventId: {}, eventType: {}", eventId, eventType); return true; @@ -128,7 +130,7 @@ private void processOrderCreated(String payload) throws Exception { Object payloadObj = eventData.get("payload"); if (payloadObj == null) { - log.warn("⚠️ payload가 없는 OrderCreatedEvent"); + log.warn("payload가 없는 OrderCreatedEvent"); return; } @@ -151,8 +153,12 @@ private void processOrderCreated(String payload) throws Exception { BigDecimal amount = new BigDecimal(amountObj.toString()); productMetricsService.incrementOrderCount(productId, quantity, amount); + + // 랭킹 점수 반영 (단가 계산) + int unitPrice = amount.divide(new BigDecimal(quantity), 0, BigDecimal.ROUND_HALF_UP).intValue(); + rankingAggregator.incrementOrderScore(productId, unitPrice, quantity); } else { - log.warn("⚠️ OrderCreatedEvent에 필수 필드 누락 - productId: {}, quantity: {}, amount: {}", + log.warn("OrderCreatedEvent에 필수 필드 누락 - productId: {}, quantity: {}, amount: {}", productIdObj, quantityObj, amountObj); } } @@ -175,7 +181,7 @@ private void sendToDLQ(ConsumerRecord record, Exception error, i retryCount ); } catch (Exception e) { - log.error("❌ DLQ 저장 실패 - error: {}", e.getMessage(), e); + log.error("DLQ 저장 실패 - error: {}", e.getMessage(), e); } } diff --git a/apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/CatalogEventConsumerTest.java b/apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/CatalogEventConsumerTest.java index ce684b743..13beb6886 100644 --- a/apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/CatalogEventConsumerTest.java +++ b/apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/CatalogEventConsumerTest.java @@ -9,6 +9,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.loopers.application.inbox.EventInboxService; import com.loopers.application.metrics.ProductMetricsService; +import com.loopers.application.ranking.RankingAggregator; import java.util.HashMap; import java.util.Map; import org.apache.kafka.clients.consumer.ConsumerRecord; @@ -37,6 +38,9 @@ class CatalogEventConsumerTest { @Mock private ProductMetricsService productMetricsService; + @Mock + private RankingAggregator rankingAggregator; + private CatalogEventConsumer catalogEventConsumer; private final ObjectMapper objectMapper = new ObjectMapper(); @@ -47,6 +51,7 @@ void setUp() { catalogEventConsumer = new CatalogEventConsumer( eventInboxService, productMetricsService, + rankingAggregator, null, // DeadLetterQueueService - 단위 테스트에서 사용하지 않음 objectMapper ); diff --git a/apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/OrderEventConsumerTest.java b/apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/OrderEventConsumerTest.java index 66e950dae..a63e2334a 100644 --- a/apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/OrderEventConsumerTest.java +++ b/apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/OrderEventConsumerTest.java @@ -11,6 +11,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.loopers.application.inbox.EventInboxService; import com.loopers.application.metrics.ProductMetricsService; +import com.loopers.application.ranking.RankingAggregator; import java.math.BigDecimal; import java.util.HashMap; import java.util.Map; @@ -40,6 +41,9 @@ class OrderEventConsumerTest { @Mock private ProductMetricsService productMetricsService; + @Mock + private RankingAggregator rankingAggregator; + private OrderEventConsumer orderEventConsumer; private final ObjectMapper objectMapper = new ObjectMapper(); @@ -50,6 +54,7 @@ void setUp() { orderEventConsumer = new OrderEventConsumer( eventInboxService, productMetricsService, + rankingAggregator, null, // DeadLetterQueueService - 단위 테스트에서 사용하지 않음 objectMapper );