diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetailInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetailInfo.java index a3ef07143..67fa73aed 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetailInfo.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetailInfo.java @@ -11,7 +11,8 @@ public record ProductDetailInfo( Integer stock, Long brandId, String brandName, - Long likeCount + Long likeCount, + Long rank ) implements Serializable { public static ProductDetailInfo of(Product product, Long likeCount) { return new ProductDetailInfo( @@ -21,7 +22,34 @@ public static ProductDetailInfo of(Product product, Long likeCount) { product.getStockValue(), product.getBrand().getId(), product.getBrand().getName(), - likeCount + likeCount, + null + ); + } + + public static ProductDetailInfo of(Product product, Long likeCount, Long rank) { + return new ProductDetailInfo( + product.getId(), + product.getName(), + product.getPriceValue(), + product.getStockValue(), + product.getBrand().getId(), + product.getBrand().getName(), + likeCount, + rank + ); + } + + public ProductDetailInfo withRank(Long rank) { + return new ProductDetailInfo( + this.productId, + this.productName, + this.price, + this.stock, + this.brandId, + this.brandName, + this.likeCount, + rank ); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java index eafac3372..e3af35366 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java @@ -1,5 +1,6 @@ package com.loopers.application.product; +import com.loopers.application.ranking.RankingFacade; import com.loopers.domain.product.ProductSearchCondition; import com.loopers.domain.user.User; import com.loopers.domain.user.UserActionEvent; @@ -18,11 +19,15 @@ public class ProductFacade { private final ProductCacheService productCacheService; private final UserService userService; private final ApplicationEventPublisher eventPublisher; + private final RankingFacade rankingFacade; @Transactional(readOnly = true) public ProductDetailInfo getProductDetail(Long productId, String loginId) { ProductDetailInfo productDetail = productCacheService.getProductDetailWithCache(productId); + Long rank = rankingFacade.getProductRankToday(productId); + productDetail = productDetail.withRank(rank); + // 유저 행동 로깅 if (loginId != null) { try { @@ -40,7 +45,11 @@ public ProductDetailInfo getProductDetail(Long productId, String loginId) { @Transactional(readOnly = true) public ProductDetailInfo getProductDetail(Long productId) { - return productCacheService.getProductDetailWithCache(productId); + ProductDetailInfo productDetail = productCacheService.getProductDetailWithCache(productId); + + // 랭킹 정보 추가 + Long rank = rankingFacade.getProductRankToday(productId); + return productDetail.withRank(rank); } public ProductListInfo getProducts(ProductGetListCommand command) { diff --git a/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingCommand.java b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingCommand.java new file mode 100644 index 000000000..345cf2318 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingCommand.java @@ -0,0 +1,49 @@ +package com.loopers.application.ranking; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; + +public record RankingCommand( + LocalDate date, + int page, + int size +) { + private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd"); + private static final int MAX_PAGE_SIZE = 100; + private static final int DEFAULT_PAGE_SIZE = 20; + + public RankingCommand { + // 유효성 검증 + if (page < 0) { + page = 0; + } + if (size <= 0 || size > MAX_PAGE_SIZE) { + size = DEFAULT_PAGE_SIZE; + } + if (date == null) { + date = LocalDate.now(); + } + } + + public static RankingCommand of(String dateString, int page, int size) { + LocalDate date = parseDate(dateString); + return new RankingCommand(date, page, size); + } + + public static RankingCommand today(int page, int size) { + return new RankingCommand(LocalDate.now(), page, size); + } + + private static LocalDate parseDate(String dateString) { + if (dateString == null || dateString.isBlank()) { + return LocalDate.now(); + } + try { + return LocalDate.parse(dateString, DATE_FORMATTER); + } catch (DateTimeParseException e) { + return LocalDate.now(); + } + } +} + 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..c780912f0 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java @@ -0,0 +1,134 @@ +package com.loopers.application.ranking; + +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; +import com.loopers.domain.ranking.RankingEntry; +import com.loopers.domain.ranking.RankingInfo; +import com.loopers.domain.ranking.RankingService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Clock; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Slf4j +@Component +@RequiredArgsConstructor +public class RankingFacade { + + private final RankingService rankingService; + private final ProductRepository productRepository; + private final Clock clock; + + /** + * 랭킹 페이지 조회 + */ + @Transactional(readOnly = true) + public RankingPageInfo getRankingPage(RankingCommand command) { + List entries = rankingService.getRankingPage( + command.date(), + command.page(), + command.size() + ); + + if (entries.isEmpty()) { + return RankingPageInfo.empty(command.date(), command.page(), command.size()); + } + + // 상품 정보 조회 + List productIds = entries.stream() + .map(RankingEntry::productId) + .collect(Collectors.toList()); + + Map productMap = productRepository.findAllByIds(productIds).stream() + .collect(Collectors.toMap(Product::getId, p -> p)); + + // 랭킹 정보 조합 + List rankings = new ArrayList<>(); + long startRank = (long) command.page() * command.size() + 1; + + for (int i = 0; i < entries.size(); i++) { + RankingEntry entry = entries.get(i); + Product product = productMap.get(entry.productId()); + + if (product != null) { + rankings.add(RankingInfo.of( + product.getId(), + product.getName(), + product.getPriceValue(), + product.getBrand().getName(), + startRank + i, + entry.score() + )); + } + } + + Long totalCount = rankingService.getRankingSize(command.date()); + + return RankingPageInfo.of( + rankings, + command.date(), + command.page(), + command.size(), + totalCount + ); + } + + /** + * Top-N 랭킹 조회 + */ + @Transactional(readOnly = true) + public List getTopN(LocalDate date, int n) { + List entries = rankingService.getTopNWithScores(date, n); + + if (entries.isEmpty()) { + return List.of(); + } + + List productIds = entries.stream() + .map(RankingEntry::productId) + .collect(Collectors.toList()); + + Map productMap = productRepository.findAllByIds(productIds).stream() + .collect(Collectors.toMap(Product::getId, p -> p)); + + List rankings = new ArrayList<>(); + for (int i = 0; i < entries.size(); i++) { + RankingEntry entry = entries.get(i); + Product product = productMap.get(entry.productId()); + + if (product != null) { + rankings.add(RankingInfo.of( + product.getId(), + product.getName(), + product.getPriceValue(), + product.getBrand().getName(), + (long) (i + 1), + entry.score() + )); + } + } + + return rankings; + } + + /** + * 특정 상품의 순위 조회 + */ + public Long getProductRank(Long productId, LocalDate date) { + return rankingService.getRank(productId, date); + } + + /** + * 특정 상품의 순위 조회 (오늘 기준) + */ + public Long getProductRankToday(Long productId) { + return rankingService.getRank(productId, LocalDate.now(clock)); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingPageInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingPageInfo.java new file mode 100644 index 000000000..fb1da6702 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingPageInfo.java @@ -0,0 +1,30 @@ +package com.loopers.application.ranking; + +import com.loopers.domain.ranking.RankingInfo; + +import java.time.LocalDate; +import java.util.List; + +public record RankingPageInfo( + List rankings, + LocalDate date, + int page, + int size, + Long totalCount, + int totalPages +) { + public static RankingPageInfo of( + List rankings, + LocalDate date, + int page, + int size, + Long totalCount + ) { + int totalPages = (int) Math.ceil((double) totalCount / size); + return new RankingPageInfo(rankings, date, page, size, totalCount, totalPages); + } + + public static RankingPageInfo empty(LocalDate date, int page, int size) { + return new RankingPageInfo(List.of(), date, page, size, 0L, 0); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingEntry.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingEntry.java new file mode 100644 index 000000000..b85e2b320 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingEntry.java @@ -0,0 +1,7 @@ +package com.loopers.domain.ranking; + +public record RankingEntry( + Long productId, + Double score +) { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingInfo.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingInfo.java new file mode 100644 index 000000000..8aeb44714 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingInfo.java @@ -0,0 +1,21 @@ +package com.loopers.domain.ranking; + +public record RankingInfo( + Long productId, + String productName, + Long price, + String brandName, + Long rank, + Double score +) { + public static RankingInfo of( + Long productId, + String productName, + Long price, + String brandName, + Long rank, + Double score + ) { + return new RankingInfo(productId, productName, price, brandName, rank, score); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingKey.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingKey.java new file mode 100644 index 000000000..809c91074 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingKey.java @@ -0,0 +1,20 @@ +package com.loopers.domain.ranking; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; + +/** + * Redis ZSET 키 생성 유틸리티 + */ +public class RankingKey { + + private static final String KEY_PREFIX = "ranking:all:"; + private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd"); + + private RankingKey() { + } + + public static String daily(LocalDate date) { + return KEY_PREFIX + date.format(DATE_FORMATTER); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingService.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingService.java new file mode 100644 index 000000000..809097287 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingService.java @@ -0,0 +1,126 @@ +package com.loopers.domain.ranking; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ZSetOperations; +import org.springframework.stereotype.Service; + +import java.time.LocalDate; +import java.util.*; + +/** + * 랭킹 조회 서비스 (commerce-api용) + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class RankingService { + + private final RedisTemplate redisTemplate; + + /** + * Top-N 랭킹 조회 (점수 포함) + */ + public List getTopNWithScores(LocalDate date, int n) { + String key = RankingKey.daily(date); + + try { + Set> tuples = redisTemplate.opsForZSet() + .reverseRangeWithScores(key, 0, n - 1); + + if (tuples == null || tuples.isEmpty()) { + return Collections.emptyList(); + } + + return convertToRankingEntries(tuples); + } catch (Exception e) { + log.error("Top-N 랭킹 조회 실패: key={}, n={}", key, n, e); + return Collections.emptyList(); + } + } + + /** + * 페이지네이션 랭킹 조회 + */ + public List getRankingPage(LocalDate date, int page, int size) { + String key = RankingKey.daily(date); + long start = (long) page * size; + long end = start + size - 1; + + try { + Set> tuples = redisTemplate.opsForZSet() + .reverseRangeWithScores(key, start, end); + + if (tuples == null || tuples.isEmpty()) { + return Collections.emptyList(); + } + + return convertToRankingEntries(tuples); + } catch (Exception e) { + log.error("랭킹 페이지 조회 실패: key={}, page={}, size={}", key, page, size, e); + return Collections.emptyList(); + } + } + + /** + * 특정 상품의 순위 조회 + * + * @return 순위 (1부터 시작), 랭킹에 없으면 null + */ + public Long getRank(Long productId, LocalDate date) { + String key = RankingKey.daily(date); + String member = productId.toString(); + + try { + Long rank = redisTemplate.opsForZSet().reverseRank(key, member); + return rank != null ? rank + 1 : null; + } catch (Exception e) { + log.error("순위 조회 실패: key={}, productId={}", key, productId, e); + return null; + } + } + + /** + * 특정 상품의 점수 조회 + */ + public Double getScore(Long productId, LocalDate date) { + String key = RankingKey.daily(date); + String member = productId.toString(); + + try { + return redisTemplate.opsForZSet().score(key, member); + } catch (Exception e) { + log.error("점수 조회 실패: key={}, productId={}", key, productId, e); + return null; + } + } + + /** + * 랭킹에 진입한 상품 수 조회 + */ + public Long getRankingSize(LocalDate date) { + String key = RankingKey.daily(date); + + try { + Long size = redisTemplate.opsForZSet().zCard(key); + return size != null ? size : 0L; + } catch (Exception e) { + log.error("랭킹 사이즈 조회 실패: key={}", key, e); + return 0L; + } + } + + private List convertToRankingEntries(Set> tuples) { + List entries = new ArrayList<>(); + for (ZSetOperations.TypedTuple tuple : tuples) { + if (tuple.getValue() != null && tuple.getScore() != null) { + entries.add(new RankingEntry( + Long.parseLong(tuple.getValue()), + tuple.getScore() + )); + } + } + return entries; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1ApiSpec.java new file mode 100644 index 000000000..f8df0bfbf --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1ApiSpec.java @@ -0,0 +1,62 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.interfaces.api.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestParam; + +@Tag(name = "Product API", description = "상품 조회 API") +public interface ProductV1ApiSpec { + + @Operation(summary = "상품 목록 조회", description = "상품 목록을 조회합니다. 브랜드 필터, 정렬, 페이징을 지원합니다.") + @ApiResponses(value = { + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "조회 성공", + content = @Content(schema = @Schema(implementation = ProductV1Dto.ProductListResponse.class)) + ) + }) + @GetMapping + ApiResponse getProducts( + @Parameter(description = "브랜드 ID (필터)") + @RequestParam(required = false) Long brandId, + + @Parameter(description = "정렬 기준 (latest, price_asc, likes_desc)") + @RequestParam(defaultValue = "latest") String sort, + + @Parameter(description = "페이지 번호 (0부터 시작)") + @RequestParam(defaultValue = "0") int page, + + @Parameter(description = "페이지 크기") + @RequestParam(defaultValue = "20") int size + ); + + @Operation(summary = "상품 상세 조회", description = "상품 ID로 상세 정보를 조회합니다. 랭킹 정보가 포함됩니다.") + @ApiResponses(value = { + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "조회 성공", + content = @Content(schema = @Schema(implementation = ProductV1Dto.ProductDetailResponse.class)) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "404", + description = "상품을 찾을 수 없음", + content = @Content(schema = @Schema(implementation = ApiResponse.class)) + ) + }) + @GetMapping("/{productId}") + ApiResponse getProduct( + @Parameter(description = "상품 ID", required = true) + @PathVariable("productId") Long productId, + + @Parameter(description = "사용자 ID (조회 로그용)") + @RequestHeader(value = "X-USER-ID", required = false) String userId + ); +} 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 new file mode 100644 index 000000000..e5378e2a7 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java @@ -0,0 +1,49 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.application.product.ProductDetailInfo; +import com.loopers.application.product.ProductFacade; +import com.loopers.application.product.ProductGetListCommand; +import com.loopers.application.product.ProductListInfo; +import com.loopers.interfaces.api.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/v1/products") +@RequiredArgsConstructor +public class ProductV1Controller implements ProductV1ApiSpec { + + private final ProductFacade productFacade; + + @Override + public ApiResponse getProducts( + Long brandId, + String sort, + int page, + int size + ) { + ProductGetListCommand command = new ProductGetListCommand( + brandId, + sort, + PageRequest.of(page, size) + ); + + ProductListInfo listInfo = productFacade.getProducts(command); + return ApiResponse.success(ProductV1Dto.ProductListResponse.from(listInfo)); + } + + @Override + public ApiResponse getProduct(Long productId, String userId) { + ProductDetailInfo detailInfo; + + if (userId != null && !userId.isBlank()) { + detailInfo = productFacade.getProductDetail(productId, userId); + } else { + detailInfo = productFacade.getProductDetail(productId); + } + + return ApiResponse.success(ProductV1Dto.ProductDetailResponse.from(detailInfo)); + } +} 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 new file mode 100644 index 000000000..805bf8165 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java @@ -0,0 +1,75 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.application.product.ProductDetailInfo; +import com.loopers.application.product.ProductListInfo; + +import java.util.List; + +public class ProductV1Dto { + + public record ProductListResponse( + List products, + int page, + int size, + long totalElements, + int totalPages + ) { + public static ProductListResponse from(ProductListInfo info) { + List products = info.contents().stream() + .map(ProductItemResponse::from) + .toList(); + + return new ProductListResponse( + products, + info.page(), + info.size(), + info.totalElements(), + info.totalPages() + ); + } + } + + public record ProductItemResponse( + Long id, + String name, + Long price, + Long brandId, + String brandName, + Long likeCount + ) { + public static ProductItemResponse from(ProductListInfo.ProductContent content) { + return new ProductItemResponse( + content.id(), + content.name(), + content.price(), + content.brandId(), + content.brandName(), + content.likeCount() + ); + } + } + + public record ProductDetailResponse( + Long id, + String name, + Long price, + Integer stock, + Long brandId, + String brandName, + Long likeCount, + Long rank + ) { + public static ProductDetailResponse from(ProductDetailInfo info) { + return new ProductDetailResponse( + info.productId(), + info.productName(), + info.price(), + info.stock(), + info.brandId(), + info.brandName(), + info.likeCount(), + info.rank() + ); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1ApiSpec.java new file mode 100644 index 000000000..7af32d934 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1ApiSpec.java @@ -0,0 +1,52 @@ +package com.loopers.interfaces.api.ranking; + +import com.loopers.interfaces.api.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; + +@Tag(name = "Ranking API", description = "상품 랭킹 API") +public interface RankingV1ApiSpec { + + @Operation(summary = "랭킹 페이지 조회", description = "일간 상품 랭킹을 페이지로 조회합니다.") + @ApiResponses(value = { + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "조회 성공", + content = @Content(schema = @Schema(implementation = RankingV1Dto.RankingPageResponse.class)) + ) + }) + @GetMapping + ApiResponse getRankings( + @Parameter(description = "날짜 (yyyyMMdd 형식, 기본값: 오늘)") + @RequestParam(required = false) String date, + + @Parameter(description = "페이지 번호 (0부터 시작)") + @RequestParam(defaultValue = "0") int page, + + @Parameter(description = "페이지 크기") + @RequestParam(defaultValue = "20") int size + ); + + @Operation(summary = "Top-N 랭킹 조회", description = "오늘의 Top-N 상품을 조회합니다.") + @ApiResponses(value = { + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "조회 성공", + content = @Content(schema = @Schema(implementation = RankingV1Dto.TopNResponse.class)) + ) + }) + @GetMapping("/top") + ApiResponse getTopN( + @Parameter(description = "날짜 (yyyyMMdd 형식, 기본값: 오늘)") + @RequestParam(required = false) String date, + + @Parameter(description = "조회할 상위 N개") + @RequestParam(defaultValue = "10") int n + ); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java new file mode 100644 index 000000000..14a748009 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java @@ -0,0 +1,45 @@ +package com.loopers.interfaces.api.ranking; + +import com.loopers.application.ranking.RankingCommand; +import com.loopers.application.ranking.RankingFacade; +import com.loopers.application.ranking.RankingPageInfo; +import com.loopers.domain.ranking.RankingInfo; +import com.loopers.interfaces.api.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.List; + +@RestController +@RequestMapping("/api/v1/rankings") +@RequiredArgsConstructor +public class RankingV1Controller implements RankingV1ApiSpec { + + private final RankingFacade rankingFacade; + + private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd"); + + @Override + public ApiResponse getRankings(String date, int page, int size) { + RankingCommand command = RankingCommand.of(date, page, size); + RankingPageInfo pageInfo = rankingFacade.getRankingPage(command); + return ApiResponse.success(RankingV1Dto.RankingPageResponse.from(pageInfo)); + } + + @Override + public ApiResponse getTopN(String date, int n) { + LocalDate targetDate = parseDate(date); + List rankings = rankingFacade.getTopN(targetDate, n); + return ApiResponse.success(RankingV1Dto.TopNResponse.of(rankings, targetDate)); + } + + private LocalDate parseDate(String date) { + if (date == null || date.isBlank()) { + return LocalDate.now(); + } + return LocalDate.parse(date, DATE_FORMATTER); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Dto.java new file mode 100644 index 000000000..5d3ab0c58 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Dto.java @@ -0,0 +1,71 @@ +package com.loopers.interfaces.api.ranking; + +import com.loopers.application.ranking.RankingPageInfo; +import com.loopers.domain.ranking.RankingInfo; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.List; + +public class RankingV1Dto { + + private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd"); + + public record RankingPageResponse( + List rankings, + String date, + int page, + int size, + Long totalCount, + int totalPages + ) { + public static RankingPageResponse from(RankingPageInfo info) { + List items = info.rankings().stream() + .map(RankingItemResponse::from) + .toList(); + + return new RankingPageResponse( + items, + info.date().format(DATE_FORMATTER), + info.page(), + info.size(), + info.totalCount(), + info.totalPages() + ); + } + } + + public record RankingItemResponse( + Long productId, + String productName, + Long price, + String brandName, + Long rank, + Double score + ) { + public static RankingItemResponse from(RankingInfo info) { + return new RankingItemResponse( + info.productId(), + info.productName(), + info.price(), + info.brandName(), + info.rank(), + info.score() + ); + } + } + + public record TopNResponse( + List rankings, + String date, + int size + ) { + public static TopNResponse of(List rankings, LocalDate date) { + List items = rankings.stream() + .map(RankingItemResponse::from) + .toList(); + + return new TopNResponse(items, date.format(DATE_FORMATTER), items.size()); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/support/config/ClockConfig.java b/apps/commerce-api/src/main/java/com/loopers/support/config/ClockConfig.java new file mode 100644 index 000000000..54bd7f908 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/support/config/ClockConfig.java @@ -0,0 +1,15 @@ +package com.loopers.support.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.time.Clock; + +@Configuration +public class ClockConfig { + + @Bean + public Clock clock() { + return Clock.systemDefaultZone(); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/ranking/RankingServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/ranking/RankingServiceTest.java new file mode 100644 index 000000000..f12569bc7 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/ranking/RankingServiceTest.java @@ -0,0 +1,234 @@ +package com.loopers.domain.ranking; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ZSetOperations; + +import java.time.LocalDate; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class RankingServiceTest { + + @Mock + private RedisTemplate redisTemplate; + + @Mock + private ZSetOperations zSetOperations; + + private RankingService rankingService; + + @BeforeEach + void setUp() { + when(redisTemplate.opsForZSet()).thenReturn(zSetOperations); + rankingService = new RankingService(redisTemplate); + } + + @Nested + @DisplayName("getTopNWithScores") + class GetTopNWithScores { + + @Test + @DisplayName("Top-N 랭킹을 점수와 함께 조회한다") + void shouldReturnTopNWithScores() { + // given + LocalDate date = LocalDate.of(2025, 1, 15); + String key = "ranking:all:20250115"; + + Set> tuples = new LinkedHashSet<>(); + tuples.add(createTuple("100", 10.5)); + tuples.add(createTuple("200", 8.3)); + tuples.add(createTuple("300", 5.1)); + + when(zSetOperations.reverseRangeWithScores(key, 0, 2)).thenReturn(tuples); + + // when + List entries = rankingService.getTopNWithScores(date, 3); + + // then + assertThat(entries).hasSize(3); + assertThat(entries.get(0).productId()).isEqualTo(100L); + assertThat(entries.get(0).score()).isEqualTo(10.5); + assertThat(entries.get(1).productId()).isEqualTo(200L); + assertThat(entries.get(2).productId()).isEqualTo(300L); + } + + @Test + @DisplayName("데이터가 없으면 빈 리스트를 반환한다") + void shouldReturnEmptyListWhenNoData() { + // given + LocalDate date = LocalDate.of(2025, 1, 15); + + when(zSetOperations.reverseRangeWithScores(anyString(), anyLong(), anyLong())) + .thenReturn(null); + + // when + List entries = rankingService.getTopNWithScores(date, 10); + + // then + assertThat(entries).isEmpty(); + } + + @Test + @DisplayName("Redis 예외 발생 시 빈 리스트를 반환한다") + void shouldReturnEmptyListOnException() { + // given + LocalDate date = LocalDate.of(2025, 1, 15); + + when(zSetOperations.reverseRangeWithScores(anyString(), anyLong(), anyLong())) + .thenThrow(new RuntimeException("Redis error")); + + // when + List entries = rankingService.getTopNWithScores(date, 10); + + // then + assertThat(entries).isEmpty(); + } + } + + @Nested + @DisplayName("getRankingPage") + class GetRankingPage { + + @Test + @DisplayName("페이지네이션으로 랭킹을 조회한다") + void shouldReturnPaginatedRanking() { + // given + LocalDate date = LocalDate.of(2025, 1, 15); + String key = "ranking:all:20250115"; + int page = 1; + int size = 10; + + Set> tuples = new LinkedHashSet<>(); + tuples.add(createTuple("110", 4.5)); + tuples.add(createTuple("120", 4.2)); + + when(zSetOperations.reverseRangeWithScores(key, 10, 19)).thenReturn(tuples); + + // when + List entries = rankingService.getRankingPage(date, page, size); + + // then + assertThat(entries).hasSize(2); + verify(zSetOperations).reverseRangeWithScores(key, 10, 19); + } + } + + @Nested + @DisplayName("getRank") + class GetRank { + + @Test + @DisplayName("상품의 순위를 조회한다 (1-based)") + void shouldReturnRankOneBased() { + // given + LocalDate date = LocalDate.of(2025, 1, 15); + Long productId = 100L; + + when(zSetOperations.reverseRank(anyString(), eq("100"))).thenReturn(0L); + + // when + Long rank = rankingService.getRank(productId, date); + + // then + assertThat(rank).isEqualTo(1L); // 0 -> 1 (1-based) + } + + @Test + @DisplayName("랭킹에 없는 상품은 null을 반환한다") + void shouldReturnNullWhenNotInRanking() { + // given + LocalDate date = LocalDate.of(2025, 1, 15); + Long productId = 999L; + + when(zSetOperations.reverseRank(anyString(), eq("999"))).thenReturn(null); + + // when + Long rank = rankingService.getRank(productId, date); + + // then + assertThat(rank).isNull(); + } + } + + @Nested + @DisplayName("getScore") + class GetScore { + + @Test + @DisplayName("상품의 점수를 조회한다") + void shouldReturnScore() { + // given + LocalDate date = LocalDate.of(2025, 1, 15); + Long productId = 100L; + + when(zSetOperations.score(anyString(), eq("100"))).thenReturn(15.5); + + // when + Double score = rankingService.getScore(productId, date); + + // then + assertThat(score).isEqualTo(15.5); + } + } + + @Nested + @DisplayName("getRankingSize") + class GetRankingSize { + + @Test + @DisplayName("랭킹에 진입한 상품 수를 조회한다") + void shouldReturnRankingSize() { + // given + LocalDate date = LocalDate.of(2025, 1, 15); + + when(zSetOperations.zCard(anyString())).thenReturn(150L); + + // when + Long size = rankingService.getRankingSize(date); + + // then + assertThat(size).isEqualTo(150L); + } + + @Test + @DisplayName("키가 없으면 0을 반환한다") + void shouldReturnZeroWhenKeyNotExists() { + // given + LocalDate date = LocalDate.of(2025, 1, 15); + + when(zSetOperations.zCard(anyString())).thenReturn(null); + + // when + Long size = rankingService.getRankingSize(date); + + // then + assertThat(size).isEqualTo(0L); + } + } + + private ZSetOperations.TypedTuple createTuple(String value, Double score) { + return new ZSetOperations.TypedTuple<>() { + @Override + public String getValue() { return value; } + @Override + public Double getScore() { return score; } + @Override + public int compareTo(ZSetOperations.TypedTuple o) { + return Double.compare(this.getScore(), o.getScore()); + } + }; + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/RankingV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/RankingV1ApiE2ETest.java new file mode 100644 index 000000000..0e16570a0 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/RankingV1ApiE2ETest.java @@ -0,0 +1,211 @@ +package com.loopers.interfaces.api.ranking; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.product.Product; +import com.loopers.fixture.TestFixture; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.utils.DatabaseCleanUp; +import com.loopers.utils.RedisCleanUp; +import org.junit.jupiter.api.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.http.*; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class RankingV1ApiE2ETest { + + private static final String ENDPOINT_RANKINGS = "/api/v1/rankings"; + private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd"); + + @Autowired + private TestRestTemplate testRestTemplate; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @Autowired + private RedisCleanUp redisCleanUp; + + @Autowired + private TestFixture testFixture; + + @Autowired + private RedisTemplate redisTemplate; + + private Brand savedBrand; + private Product savedProduct1; + private Product savedProduct2; + private Product savedProduct3; + private String todayKey; + + @BeforeEach + void setUp() { + // 데이터 초기화 + databaseCleanUp.truncateAllTables(); + redisCleanUp.truncateAll(); + + // 테스트 데이터 생성 + savedBrand = testFixture.createBrand("Nike"); + savedProduct1 = testFixture.createProduct("Air Max", 150000L, 100, savedBrand); + savedProduct2 = testFixture.createProduct("Air Force", 120000L, 100, savedBrand); + savedProduct3 = testFixture.createProduct("Jordan", 200000L, 100, savedBrand); + + // Redis에 랭킹 데이터 생성 + LocalDate today = LocalDate.now(); + todayKey = "ranking:all:" + today.format(DATE_FORMATTER); + + redisTemplate.opsForZSet().add(todayKey, savedProduct1.getId().toString(), 10.5); + redisTemplate.opsForZSet().add(todayKey, savedProduct2.getId().toString(), 8.3); + redisTemplate.opsForZSet().add(todayKey, savedProduct3.getId().toString(), 15.2); + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + redisCleanUp.truncateAll(); + } + + @Nested + @DisplayName("GET /api/v1/rankings") + class GetRankings { + + @Test + @DisplayName("랭킹 페이지 조회에 성공하면 상품 정보가 포함된 랭킹 목록을 반환한다") + void shouldReturnRankingPageWithProductInfo() { + // given + String today = LocalDate.now().format(DATE_FORMATTER); + String url = String.format("%s?date=%s&page=0&size=10", ENDPOINT_RANKINGS, today); + + // when + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(url, HttpMethod.GET, null, responseType); + + // then + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody()).isNotNull(), + () -> assertThat(response.getBody().data().rankings()).hasSize(3), + () -> assertThat(response.getBody().data().rankings().get(0).productName()).isEqualTo("Jordan"), + () -> assertThat(response.getBody().data().rankings().get(0).rank()).isEqualTo(1L), + () -> assertThat(response.getBody().data().rankings().get(0).score()).isEqualTo(15.2) + ); + } + + @Test + @DisplayName("페이지네이션이 정상 동작한다") + void shouldReturnPaginatedResults() { + // given + String today = LocalDate.now().format(DATE_FORMATTER); + String url = String.format("%s?date=%s&page=0&size=2", ENDPOINT_RANKINGS, today); + + // when + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(url, HttpMethod.GET, null, responseType); + + // then + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().rankings()).hasSize(2), + () -> assertThat(response.getBody().data().totalCount()).isEqualTo(3L), + () -> assertThat(response.getBody().data().totalPages()).isEqualTo(2) + ); + } + + @Test + @DisplayName("date 파라미터가 없으면 오늘 날짜로 조회한다") + void shouldUseCurrentDateWhenDateIsNotProvided() { + // given + String url = String.format("%s?page=0&size=10", ENDPOINT_RANKINGS); + + // when + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(url, HttpMethod.GET, null, responseType); + + // then + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().date()).isEqualTo(LocalDate.now().format(DATE_FORMATTER)) + ); + } + + @Test + @DisplayName("랭킹 데이터가 없는 날짜 조회 시 빈 목록을 반환한다") + void shouldReturnEmptyListWhenNoRankingData() { + // given + String futureDate = LocalDate.now().plusDays(10).format(DATE_FORMATTER); + String url = String.format("%s?date=%s&page=0&size=10", ENDPOINT_RANKINGS, futureDate); + + // when + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(url, HttpMethod.GET, null, responseType); + + // then + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().rankings()).isEmpty(), + () -> assertThat(response.getBody().data().totalCount()).isEqualTo(0L) + ); + } + + @Test + @DisplayName("유효하지 않은 size 값은 기본값으로 처리된다") + void shouldUseDefaultSizeWhenInvalid() { + // given + String today = LocalDate.now().format(DATE_FORMATTER); + String url = String.format("%s?date=%s&page=0&size=0", ENDPOINT_RANKINGS, today); + + // when + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(url, HttpMethod.GET, null, responseType); + + // then + assertTrue(response.getStatusCode().is2xxSuccessful()); + assertThat(response.getBody().data().rankings()).isNotEmpty(); } + } + + @Nested + @DisplayName("GET /api/v1/rankings/top") + class GetTopN { + + @Test + @DisplayName("Top-N 랭킹을 조회한다") + void shouldReturnTopN() { + // given + String today = LocalDate.now().format(DATE_FORMATTER); + String url = String.format("%s/top?date=%s&n=2", ENDPOINT_RANKINGS, today); + + // when + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(url, HttpMethod.GET, null, responseType); + + // then + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().rankings()).hasSize(2), + () -> assertThat(response.getBody().data().rankings().get(0).productName()).isEqualTo("Jordan") + ); + } + } +} diff --git a/apps/commerce-streamer/build.gradle.kts b/apps/commerce-streamer/build.gradle.kts index ba710e6eb..94f46e265 100644 --- a/apps/commerce-streamer/build.gradle.kts +++ b/apps/commerce-streamer/build.gradle.kts @@ -10,6 +10,7 @@ dependencies { // web implementation("org.springframework.boot:spring-boot-starter-web") implementation("org.springframework.boot:spring-boot-starter-actuator") + implementation("org.springframework.boot:spring-boot-starter-validation") // querydsl annotationProcessor("com.querydsl:querydsl-apt::jakarta") 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 ea4b4d15a..24d95cd01 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/CommerceStreamerApplication.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/CommerceStreamerApplication.java @@ -4,9 +4,11 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.context.properties.ConfigurationPropertiesScan; +import org.springframework.scheduling.annotation.EnableScheduling; import java.util.TimeZone; +@EnableScheduling @ConfigurationPropertiesScan @SpringBootApplication public class CommerceStreamerApplication { diff --git a/apps/commerce-streamer/src/main/java/com/loopers/application/metrics/ProductMetricsFacade.java b/apps/commerce-streamer/src/main/java/com/loopers/application/metrics/ProductMetricsFacade.java index 726bab15a..8025d0df0 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/application/metrics/ProductMetricsFacade.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/application/metrics/ProductMetricsFacade.java @@ -1,5 +1,6 @@ package com.loopers.application.metrics; +import com.loopers.application.ranking.RankingFacade; import com.loopers.domain.eventhandled.EventHandledDomainType; import com.loopers.domain.eventhandled.EventHandledService; import com.loopers.domain.metrics.ProductMetricsService; @@ -20,6 +21,7 @@ public class ProductMetricsFacade { private final ProductMetricsService productMetricsService; private final EventHandledService eventHandledService; + private final RankingFacade rankingFacade; private final RedisTemplate redisTemplate; private final Clock clock; @@ -38,7 +40,14 @@ public void processLikeMetrics(ProductMetricsCommand command) { } LocalDate date = today(); + + // 좋아요 메트릭 처리 productMetricsService.processLikeMetrics(command.productId(), command.likeType(), date); + + // 랭킹 점수 업데이트 + boolean isLike = "LIKED".equals(command.likeType()); + rankingFacade.processLikeEvent(command.productId(), isLike); + eventHandledService.saveEventHandled(command.eventId(), DOMAIN_TYPE, command.metricsType().toString()); } @@ -49,7 +58,15 @@ public void processStockMetrics(ProductMetricsCommand command) { } LocalDate date = today(); + + // 재고 메트릭 처리 productMetricsService.processStockMetrics(command.productId(), command.stock(), command.changedType(), date); + + // 랭킹 점수 업데이트 (재고 감소 시) + if ("DECREASED".equals(command.changedType())) { + rankingFacade.processOrderEvent(command.productId(), command.stock()); + } + eventHandledService.saveEventHandled(command.eventId(), DOMAIN_TYPE, command.metricsType().toString()); // 재고 변경 시 캐시 무효화 @@ -63,7 +80,13 @@ public void processViewMetrics(ProductMetricsCommand command) { } LocalDate date = today(); + + // 조회 메트릭 처리 productMetricsService.processViewMetrics(command.productId(), date); + + // 랭킹 점수 업데이트 + rankingFacade.processViewEvent(command.productId()); + eventHandledService.saveEventHandled(command.eventId(), DOMAIN_TYPE, command.metricsType().toString()); } diff --git a/apps/commerce-streamer/src/main/java/com/loopers/application/ranking/RankingFacade.java b/apps/commerce-streamer/src/main/java/com/loopers/application/ranking/RankingFacade.java new file mode 100644 index 000000000..534a7191d --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/application/ranking/RankingFacade.java @@ -0,0 +1,59 @@ +package com.loopers.application.ranking; + + +import com.loopers.domain.ranking.RankingService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.time.Clock; +import java.time.LocalDate; + +@Slf4j +@Component +@RequiredArgsConstructor +public class RankingFacade { + + private final RankingService rankingService; + private final Clock clock; + + public LocalDate today() { + return LocalDate.now(clock); + } + + /** + * 조회 이벤트 처리 + */ + public void processViewEvent(Long productId) { + LocalDate date = today(); + rankingService.incrementViewScore(productId, date); + log.debug("조회 이벤트 랭킹 반영: productId={}, date={}", productId, date); + } + + /** + * 좋아요 이벤트 처리 + */ + public void processLikeEvent(Long productId, boolean isLike) { + LocalDate date = today(); + rankingService.updateLikeScore(productId, isLike, date); + log.debug("좋아요 이벤트 랭킹 반영: productId={}, isLike={}, date={}", productId, isLike, date); + } + + /** + * 주문 이벤트 처리 (수량 기반) + */ + public void processOrderEvent(Long productId, int quantity) { + LocalDate date = today(); + rankingService.incrementOrderScore(productId, quantity, date); + log.debug("주문 이벤트 랭킹 반영: productId={}, quantity={}, date={}", productId, quantity, date); + } + + /** + * 주문 이벤트 처리 (금액 기반) + */ + public void processOrderEventWithAmount(Long productId, long amount) { + LocalDate date = today(); + rankingService.incrementOrderScoreWithAmount(productId, amount, date); + log.debug("주문 이벤트 랭킹 반영 (금액): productId={}, amount={}, date={}", productId, amount, date); + } +} 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..41033a869 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/application/ranking/RankingScheduler.java @@ -0,0 +1,43 @@ +package com.loopers.application.ranking; + +import com.loopers.domain.ranking.RankingService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.time.Clock; +import java.time.LocalDate; + +@Slf4j +@Component +@RequiredArgsConstructor +public class RankingScheduler { + + private final RankingService rankingService; + private final Clock clock; + + @Value("${ranking.carry-over.weight:0.1}") + private double carryOverWeight; + + /** + * 매일 23:50에 실행 + * - 오늘 점수의 일부(10%)를 내일 키에 미리 복사 + */ + @Scheduled(cron = "0 50 23 * * *") + public void prepareNextDayRanking() { + // 명시적으로 오늘/내일 기준 계산 (시간 오차 방지) + LocalDate today = LocalDate.now(clock); + LocalDate tomorrow = today.plusDays(1); + + log.info("다음날 랭킹 준비 시작: {} → {}, weight={}", today, tomorrow, carryOverWeight); + + try { + rankingService.carryOverScores(today, tomorrow, carryOverWeight); + log.info("다음날 랭킹 준비 완료: {} → {}", today, tomorrow); + } catch (Exception e) { + log.error("다음날 랭킹 준비 실패: {} → {}", today, tomorrow, 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..080549870 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/ranking/RankingKey.java @@ -0,0 +1,20 @@ +package com.loopers.domain.ranking; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; + +public class RankingKey { + + private static final String KEY_PREFIX = "ranking:all:"; + private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd"); + + private RankingKey() { + } + + /** + * 일간 랭킹 키 생성 + */ + public static String daily(LocalDate date) { + return KEY_PREFIX + date.format(DATE_FORMATTER); + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/ranking/RankingService.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/ranking/RankingService.java new file mode 100644 index 000000000..6cc5931a0 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/ranking/RankingService.java @@ -0,0 +1,133 @@ +package com.loopers.domain.ranking; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ZSetOperations; +import org.springframework.stereotype.Service; + +import java.time.Duration; +import java.time.LocalDate; +import java.util.Set; + +@Slf4j +@Service +@RequiredArgsConstructor +public class RankingService { + + private final RedisTemplate redisTemplate; + private final RankingWeight rankingWeight; + + private static final Duration RANKING_TTL = Duration.ofHours(48); + + /** + * 조회 이벤트로 인한 랭킹 점수 증가 + */ + public void incrementViewScore(Long productId, LocalDate date) { + double score = rankingWeight.calculateViewScore(); + incrementScore(productId, score, date); + } + + /** + * 좋아요 이벤트로 인한 랭킹 점수 변경 + */ + public void updateLikeScore(Long productId, boolean isLike, LocalDate date) { + double score = rankingWeight.calculateLikeScore(isLike); + incrementScore(productId, score, date); + } + + /** + * 주문 이벤트로 인한 랭킹 점수 증가 (수량 기반) + */ + public void incrementOrderScore(Long productId, int quantity, LocalDate date) { + double score = rankingWeight.calculateOrderScore(quantity); + incrementScore(productId, score, date); + } + + /** + * 주문 이벤트로 인한 랭킹 점수 증가 (금액 기반) + */ + public void incrementOrderScoreWithAmount(Long productId, long amount, LocalDate date) { + double score = rankingWeight.calculateOrderScoreWithAmount(amount); + incrementScore(productId, score, date); + } + + /** + * 랭킹 점수 증가 (내부 메서드) + */ + private void incrementScore(Long productId, double score, LocalDate date) { + String key = RankingKey.daily(date); + String member = productId.toString(); + + try { + // 먼저 키 존재 여부 확인 (TTL 설정을 위해) + Boolean keyExists = redisTemplate.hasKey(key); + + redisTemplate.opsForZSet().incrementScore(key, member, score); + + // 키가 새로 생성된 경우에만 TTL 설정 + if (Boolean.FALSE.equals(keyExists)) { + redisTemplate.expire(key, RANKING_TTL); + log.debug("랭킹 키 생성 및 TTL 설정: key={}, ttl={}", key, RANKING_TTL); + } + + log.debug("랭킹 점수 업데이트: key={}, productId={}, score={}", key, productId, score); + } catch (Exception e) { + log.error("랭킹 점수 업데이트 실패: key={}, productId={}", key, productId, e); + } + } + + /** + * 랭킹에 진입한 상품 수 조회 + */ + public Long getRankingSize(LocalDate date) { + String key = RankingKey.daily(date); + + try { + Long size = redisTemplate.opsForZSet().zCard(key); + return size != null ? size : 0L; + } catch (Exception e) { + log.error("랭킹 사이즈 조회 실패: key={}", key, e); + return 0L; + } + } + + /** + * 콜드 스타트 대응: 전날 점수의 일부를 다음날 키에 복사 + */ + public void carryOverScores(LocalDate fromDate, LocalDate toDate, double weight) { + String fromKey = RankingKey.daily(fromDate); + String toKey = RankingKey.daily(toDate); + + try { + // 이미 준비된 경우 스킵 + Long existingSize = getRankingSize(toDate); + if (existingSize != null && existingSize > 0) { + log.info("이미 준비된 랭킹이 존재합니다: key={}, size={}", toKey, existingSize); + return; + } + + Set> tuples = redisTemplate.opsForZSet() + .rangeWithScores(fromKey, 0, -1); + + if (tuples == null || tuples.isEmpty()) { + log.info("Carry-over 대상 없음: fromKey={}", fromKey); + return; + } + + for (ZSetOperations.TypedTuple tuple : tuples) { + if (tuple.getValue() != null && tuple.getScore() != null) { + double newScore = tuple.getScore() * weight; + redisTemplate.opsForZSet().add(toKey, tuple.getValue(), newScore); + } + } + + redisTemplate.expire(toKey, RANKING_TTL); + + log.info("랭킹 Carry-over 완료: {} → {} (weight={}), count={}", + fromKey, toKey, weight, tuples.size()); + } catch (Exception e) { + log.error("랭킹 Carry-over 실패: fromKey={}, toKey={}", fromKey, toKey, e); + } + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/ranking/RankingWeight.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/ranking/RankingWeight.java new file mode 100644 index 000000000..53d879727 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/ranking/RankingWeight.java @@ -0,0 +1,121 @@ +package com.loopers.domain.ranking; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class RankingWeight { + + private final RedisTemplate redisTemplate; + + private static final String WEIGHT_KEY = "ranking:config:weights"; + + // 기본 가중치 + private static final double DEFAULT_VIEW_WEIGHT = 0.1; + private static final double DEFAULT_LIKE_WEIGHT = 0.2; + private static final double DEFAULT_ORDER_WEIGHT = 0.7; + + public double getViewWeight() { + return getWeight("view", DEFAULT_VIEW_WEIGHT); + } + + public double getLikeWeight() { + return getWeight("like", DEFAULT_LIKE_WEIGHT); + } + + public double getOrderWeight() { + return getWeight("order", DEFAULT_ORDER_WEIGHT); + } + + /** + * 조회 이벤트 점수 계산 + */ + public double calculateViewScore() { + return getViewWeight() * 1.0; + } + + /** + * 좋아요 이벤트 점수 계산 + */ + public double calculateLikeScore(boolean isLike) { + return getLikeWeight() * (isLike ? 1.0 : -1.0); + } + + /** + * 주문 이벤트 점수 계산 (수량 기반) + */ + public double calculateOrderScore(int quantity) { + return getOrderWeight() * quantity; + } + + /** + * 주문 이벤트 점수 계산 (금액 기반, log 스케일) + */ + public double calculateOrderScoreWithAmount(long amount) { + if (amount <= 0) { + return 0; + } + return getOrderWeight() * Math.log10(amount); + } + + public void updateViewWeight(double weight) { + updateWeight("view", weight); + } + + public void updateLikeWeight(double weight) { + updateWeight("like", weight); + } + + public void updateOrderWeight(double weight) { + updateWeight("order", weight); + } + + /** + * 모든 가중치 일괄 업데이트 + */ + public void updateAllWeights(double viewWeight, double likeWeight, double orderWeight) { + try { + redisTemplate.opsForHash().put(WEIGHT_KEY, "view", String.valueOf(viewWeight)); + redisTemplate.opsForHash().put(WEIGHT_KEY, "like", String.valueOf(likeWeight)); + redisTemplate.opsForHash().put(WEIGHT_KEY, "order", String.valueOf(orderWeight)); + log.info("랭킹 가중치 일괄 업데이트: view={}, like={}, order={}", + viewWeight, likeWeight, orderWeight); + } catch (Exception e) { + log.error("랭킹 가중치 일괄 업데이트 실패: view={}, like={}, order={}, error={}", + viewWeight, likeWeight, orderWeight, e.getMessage(), e); + } + } + + /** + * Redis에서 가중치 삭제 (기본값으로 복원) + */ + public void resetToDefault() { + redisTemplate.delete(WEIGHT_KEY); + log.info("랭킹 가중치 초기화: 기본값으로 복원"); + } + + private double getWeight(String field, double defaultValue) { + try { + Object value = redisTemplate.opsForHash().get(WEIGHT_KEY, field); + if (value != null) { + return Double.parseDouble(value.toString()); + } + } catch (Exception e) { + log.warn("가중치 조회 실패, 기본값 사용: field={}, default={}", field, defaultValue, e); + } + return defaultValue; + } + + private void updateWeight(String field, double weight) { + try { + redisTemplate.opsForHash().put(WEIGHT_KEY, field, String.valueOf(weight)); + log.info("랭킹 가중치 업데이트: {}={}", field, weight); + } catch (Exception e) { + log.error("가중치 업데이트 실패: field={}, weight={}", field, weight, e); + } + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/interfaces/api/ranking/RankingConfigV1ApiSpec.java b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/api/ranking/RankingConfigV1ApiSpec.java new file mode 100644 index 000000000..e1e9ab20b --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/api/ranking/RankingConfigV1ApiSpec.java @@ -0,0 +1,50 @@ +package com.loopers.interfaces.api.ranking; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; + +@Tag(name = "Ranking Config API", description = "랭킹 가중치 설정 관리 API (Admin)") +public interface RankingConfigV1ApiSpec { + + @Operation(summary = "가중치 조회", description = "현재 랭킹 가중치 설정을 조회합니다.") + @ApiResponses(value = { + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "조회 성공", + content = @Content(schema = @Schema(implementation = RankingConfigV1Dto.WeightConfigResponse.class)) + ) + }) + @GetMapping("/weights") + RankingConfigV1Dto.WeightConfigResponse getWeights(); + + @Operation(summary = "가중치 수정", description = "랭킹 가중치 설정을 수정합니다.") + @ApiResponses(value = { + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "수정 성공", + content = @Content(schema = @Schema(implementation = RankingConfigV1Dto.WeightConfigResponse.class)) + ) + }) + @PutMapping("/weights") + RankingConfigV1Dto.WeightConfigResponse updateWeights( + @Valid @RequestBody RankingConfigV1Dto.WeightConfigRequest request + ); + + @Operation(summary = "가중치 초기화", description = "랭킹 가중치를 기본값으로 초기화합니다.") + @ApiResponses(value = { + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "초기화 성공" + ) + }) + @DeleteMapping("/weights") + void resetWeights(); +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/interfaces/api/ranking/RankingConfigV1Controller.java b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/api/ranking/RankingConfigV1Controller.java new file mode 100644 index 000000000..4560d7454 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/api/ranking/RankingConfigV1Controller.java @@ -0,0 +1,38 @@ +package com.loopers.interfaces.api.ranking; + +import com.loopers.domain.ranking.RankingWeight; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/admin/ranking/config") +@RequiredArgsConstructor +public class RankingConfigV1Controller implements RankingConfigV1ApiSpec { + + private final RankingWeight rankingWeight; + + @Override + public RankingConfigV1Dto.WeightConfigResponse getWeights() { + return RankingConfigV1Dto.WeightConfigResponse.of( + rankingWeight.getViewWeight(), + rankingWeight.getLikeWeight(), + rankingWeight.getOrderWeight() + ); + } + + @Override + public RankingConfigV1Dto.WeightConfigResponse updateWeights(RankingConfigV1Dto.WeightConfigRequest request) { + rankingWeight.updateAllWeights( + request.viewWeight(), + request.likeWeight(), + request.orderWeight() + ); + return getWeights(); + } + + @Override + public void resetWeights() { + rankingWeight.resetToDefault(); + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/interfaces/api/ranking/RankingConfigV1Dto.java b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/api/ranking/RankingConfigV1Dto.java new file mode 100644 index 000000000..1e63d4c40 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/api/ranking/RankingConfigV1Dto.java @@ -0,0 +1,27 @@ +package com.loopers.interfaces.api.ranking; + +import jakarta.validation.constraints.Min; + +public class RankingConfigV1Dto { + + public record WeightConfigRequest( + @Min(value = 0, message = "viewWeight는 0 이상이어야 합니다.") + double viewWeight, + + @Min(value = 0, message = "likeWeight는 0 이상이어야 합니다.") + double likeWeight, + + @Min(value = 0, message = "orderWeight는 0 이상이어야 합니다.") + double orderWeight + ) {} + + public record WeightConfigResponse( + double viewWeight, + double likeWeight, + double orderWeight + ) { + public static WeightConfigResponse of(double viewWeight, double likeWeight, double orderWeight) { + return new WeightConfigResponse(viewWeight, likeWeight, orderWeight); + } + } +} diff --git a/apps/commerce-streamer/src/main/resources/application.yml b/apps/commerce-streamer/src/main/resources/application.yml index 56dd4ddd1..f55649ffd 100644 --- a/apps/commerce-streamer/src/main/resources/application.yml +++ b/apps/commerce-streamer/src/main/resources/application.yml @@ -14,7 +14,7 @@ spring: main: web-application-type: servlet application: - name: commerce-api + name: commerce-streamer profiles: active: local config: @@ -24,7 +24,6 @@ spring: - kafka.yml - logging.yml - monitoring.yml - - kafka: topic: user-action-name: user-action-events @@ -39,6 +38,9 @@ demo-kafka: test: topic-name: demo.internal.topic-v1 +ranking: + carry-over: + weight: 0.1 --- spring: config: diff --git a/apps/commerce-streamer/src/test/java/com/loopers/domain/ranking/RankingIntegrationTest.java b/apps/commerce-streamer/src/test/java/com/loopers/domain/ranking/RankingIntegrationTest.java new file mode 100644 index 000000000..c9806c635 --- /dev/null +++ b/apps/commerce-streamer/src/test/java/com/loopers/domain/ranking/RankingIntegrationTest.java @@ -0,0 +1,195 @@ +package com.loopers.domain.ranking; + +import com.loopers.utils.RedisCleanUp; +import org.junit.jupiter.api.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.redis.core.RedisTemplate; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class RankingIntegrationTest { + + @Autowired + private RankingService rankingService; + + @Autowired + private RankingWeight rankingWeight; + + @Autowired + private RedisTemplate redisTemplate; + + @Autowired + private RedisCleanUp redisCleanUp; + + private final LocalDate testDate = LocalDate.of(2025, 1, 15); + + @BeforeEach + void setUp() { + redisCleanUp.truncateAll(); + } + + @AfterAll + void tearDown() { + redisCleanUp.truncateAll(); + } + + @Nested + @DisplayName("랭킹 점수 누적 테스트") + class ScoreAccumulation { + + @Test + @DisplayName("동일 상품에 대한 여러 이벤트가 누적된다") + void shouldAccumulateScoresForSameProduct() { + // given + Long productId = 100L; + + // when + rankingService.incrementViewScore(productId, testDate); + rankingService.incrementViewScore(productId, testDate); + rankingService.updateLikeScore(productId, true, testDate); + + // then + String key = RankingKey.daily(testDate); + Double score = redisTemplate.opsForZSet().score(key, productId.toString()); + + // 예상: view(0.1) * 2 + like(0.2) * 1 = 0.4 + assertThat(score).isNotNull(); + assertThat(score).isGreaterThanOrEqualTo(0.4); + } + + @Test + @DisplayName("좋아요 취소 시 점수가 감소한다") + void shouldDecreaseScoreWhenUnliked() { + // given + Long productId = 100L; + rankingService.updateLikeScore(productId, true, testDate); + + String key = RankingKey.daily(testDate); + Double beforeScore = redisTemplate.opsForZSet().score(key, productId.toString()); + + // when + rankingService.updateLikeScore(productId, false, testDate); + + // then + Double afterScore = redisTemplate.opsForZSet().score(key, productId.toString()); + assertThat(afterScore).isLessThan(beforeScore); + } + } + + @Nested + @DisplayName("키 TTL 테스트") + class KeyTtl { + + @Test + @DisplayName("새로운 랭킹 키 생성 시 TTL이 설정된다") + void shouldSetTtlWhenKeyCreated() { + // given + Long productId = 100L; + String key = RankingKey.daily(testDate); + + // when + rankingService.incrementViewScore(productId, testDate); + + // then + Long ttl = redisTemplate.getExpire(key); + assertThat(ttl).isNotNull(); + assertThat(ttl).isGreaterThan(0); + } + } + + @Nested + @DisplayName("Carry-Over 테스트") + class CarryOver { + + @Test + @DisplayName("전날 점수의 일부가 다음날로 복사된다") + void shouldCopyScoresToNextDay() { + // given + Long productId = 100L; + LocalDate today = testDate; + LocalDate tomorrow = testDate.plusDays(1); + + // 오늘 점수 생성 + rankingService.incrementOrderScore(productId, 10, today); + + String todayKey = RankingKey.daily(today); + Double todayScore = redisTemplate.opsForZSet().score(todayKey, productId.toString()); + + // when + rankingService.carryOverScores(today, tomorrow, 0.1); + + // then + String tomorrowKey = RankingKey.daily(tomorrow); + Double tomorrowScore = redisTemplate.opsForZSet().score(tomorrowKey, productId.toString()); + + assertThat(tomorrowScore).isNotNull(); + assertThat(tomorrowScore).isCloseTo(todayScore * 0.1, org.assertj.core.api.Assertions.within(0.01)); + } + + @Test + @DisplayName("이미 데이터가 있는 키에는 carry-over 하지 않는다") + void shouldNotOverwriteExistingData() { + // given + Long productId = 100L; + LocalDate today = testDate; + LocalDate tomorrow = testDate.plusDays(1); + + // 오늘 점수 생성 + rankingService.incrementOrderScore(productId, 10, today); + + // 내일 키에 미리 데이터 생성 + rankingService.incrementViewScore(productId, tomorrow); + String tomorrowKey = RankingKey.daily(tomorrow); + Double existingScore = redisTemplate.opsForZSet().score(tomorrowKey, productId.toString()); + + // when + rankingService.carryOverScores(today, tomorrow, 0.1); + + // then (점수가 변경되지 않아야 함) + Double afterScore = redisTemplate.opsForZSet().score(tomorrowKey, productId.toString()); + assertThat(afterScore).isEqualTo(existingScore); + } + } + + @Nested + @DisplayName("동적 가중치 테스트") + class DynamicWeight { + + @Test + @DisplayName("Redis에서 가중치를 동적으로 업데이트하고 조회할 수 있다") + void shouldUpdateAndRetrieveWeightsDynamically() { + // given + double newViewWeight = 0.15; + double newLikeWeight = 0.25; + double newOrderWeight = 0.6; + + // when + rankingWeight.updateAllWeights(newViewWeight, newLikeWeight, newOrderWeight); + + // then + assertThat(rankingWeight.getViewWeight()).isEqualTo(newViewWeight); + assertThat(rankingWeight.getLikeWeight()).isEqualTo(newLikeWeight); + assertThat(rankingWeight.getOrderWeight()).isEqualTo(newOrderWeight); + } + + @Test + @DisplayName("가중치 초기화 시 기본값으로 복원된다") + void shouldResetToDefaultWeights() { + // given + rankingWeight.updateAllWeights(0.5, 0.5, 0.5); + + // when + rankingWeight.resetToDefault(); + + // then + assertThat(rankingWeight.getViewWeight()).isEqualTo(0.1); + assertThat(rankingWeight.getLikeWeight()).isEqualTo(0.2); + assertThat(rankingWeight.getOrderWeight()).isEqualTo(0.7); + } + } +} diff --git a/apps/commerce-streamer/src/test/java/com/loopers/domain/ranking/RankingSchedulerTest.java b/apps/commerce-streamer/src/test/java/com/loopers/domain/ranking/RankingSchedulerTest.java new file mode 100644 index 000000000..194f20d0a --- /dev/null +++ b/apps/commerce-streamer/src/test/java/com/loopers/domain/ranking/RankingSchedulerTest.java @@ -0,0 +1,71 @@ +package com.loopers.domain.ranking; + +import com.loopers.application.ranking.RankingScheduler; +import com.loopers.domain.ranking.RankingService; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.*; + +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class RankingSchedulerTest { + + @Mock + private RankingService rankingService; + + @InjectMocks + private RankingScheduler rankingScheduler; + + @Test + @DisplayName("prepareNextDayRanking 실행 시 오늘 → 내일로 carryOver가 호출된다") + void shouldCallCarryOverWithTodayAndTomorrow() { + // given + LocalDate fixedDate = LocalDate.of(2025, 1, 15); + Clock fixedClock = Clock.fixed( + fixedDate.atTime(23, 50).toInstant(ZoneOffset.of("+09:00")), + ZoneId.of("Asia/Seoul") + ); + + ReflectionTestUtils.setField(rankingScheduler, "clock", fixedClock); + ReflectionTestUtils.setField(rankingScheduler, "carryOverWeight", 0.1); + + // when + rankingScheduler.prepareNextDayRanking(); + + // then + verify(rankingService).carryOverScores( + LocalDate.of(2025, 1, 15), + LocalDate.of(2025, 1, 16), + 0.1 + ); + } + + @Test + @DisplayName("carryOverScores 예외 발생 시에도 정상 종료된다") + void shouldHandleExceptionGracefully() { + // given + LocalDate fixedDate = LocalDate.of(2025, 1, 15); + Clock fixedClock = Clock.fixed( + fixedDate.atTime(23, 50).toInstant(ZoneOffset.of("+09:00")), + ZoneId.of("Asia/Seoul") + ); + + ReflectionTestUtils.setField(rankingScheduler, "clock", fixedClock); + ReflectionTestUtils.setField(rankingScheduler, "carryOverWeight", 0.1); + + doThrow(new RuntimeException("Redis error")) + .when(rankingService).carryOverScores(any(), any(), anyDouble()); + + // when & then (예외 전파 없이 종료) + rankingScheduler.prepareNextDayRanking(); + + verify(rankingService).carryOverScores(any(), any(), anyDouble()); + } +} diff --git a/apps/commerce-streamer/src/test/java/com/loopers/domain/ranking/RankingServiceTest.java b/apps/commerce-streamer/src/test/java/com/loopers/domain/ranking/RankingServiceTest.java new file mode 100644 index 000000000..ca3de1caf --- /dev/null +++ b/apps/commerce-streamer/src/test/java/com/loopers/domain/ranking/RankingServiceTest.java @@ -0,0 +1,203 @@ +package com.loopers.domain.ranking; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ZSetOperations; + +import java.time.LocalDate; +import java.util.LinkedHashSet; +import java.util.Set; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class RankingServiceTest { + + @Mock + private RedisTemplate redisTemplate; + + @Mock + private ZSetOperations zSetOperations; + + @Mock + private RankingWeight rankingWeight; + + private RankingService rankingService; + + @BeforeEach + void setUp() { + when(redisTemplate.opsForZSet()).thenReturn(zSetOperations); + rankingService = new RankingService(redisTemplate, rankingWeight); + } + + @Nested + @DisplayName("조회 점수 증가") + class IncrementViewScore { + + @Test + @DisplayName("조회 이벤트 발생 시 ZSET에 가중치 점수가 추가된다") + void shouldIncrementScoreWithViewWeight() { + // given + Long productId = 100L; + LocalDate date = LocalDate.of(2025, 1, 15); + String expectedKey = "ranking:all:20250115"; + + when(rankingWeight.calculateViewScore()).thenReturn(0.1); + when(redisTemplate.hasKey(expectedKey)).thenReturn(true); + + // when + rankingService.incrementViewScore(productId, date); + + // then + verify(zSetOperations).incrementScore(expectedKey, "100", 0.1); + } + + @Test + @DisplayName("새로운 키가 생성되면 TTL이 설정된다") + void shouldSetTtlWhenKeyIsNew() { + // given + Long productId = 100L; + LocalDate date = LocalDate.of(2025, 1, 15); + String expectedKey = "ranking:all:20250115"; + + when(rankingWeight.calculateViewScore()).thenReturn(0.1); + when(redisTemplate.hasKey(expectedKey)).thenReturn(false); + + // when + rankingService.incrementViewScore(productId, date); + + // then + verify(redisTemplate).expire(eq(expectedKey), any()); + } + } + + @Nested + @DisplayName("좋아요 점수 증가") + class UpdateLikeScore { + + @Test + @DisplayName("좋아요 이벤트 발생 시 양수 점수가 추가된다") + void shouldIncrementScoreWhenLiked() { + // given + Long productId = 100L; + LocalDate date = LocalDate.of(2025, 1, 15); + + when(rankingWeight.calculateLikeScore(true)).thenReturn(0.2); + when(redisTemplate.hasKey(anyString())).thenReturn(true); + + // when + rankingService.updateLikeScore(productId, true, date); + + // then + verify(zSetOperations).incrementScore(anyString(), eq("100"), eq(0.2)); + } + + @Test + @DisplayName("좋아요 취소 이벤트 발생 시 음수 점수가 추가된다") + void shouldDecrementScoreWhenUnliked() { + // given + Long productId = 100L; + LocalDate date = LocalDate.of(2025, 1, 15); + + when(rankingWeight.calculateLikeScore(false)).thenReturn(-0.2); + when(redisTemplate.hasKey(anyString())).thenReturn(true); + + // when + rankingService.updateLikeScore(productId, false, date); + + // then + verify(zSetOperations).incrementScore(anyString(), eq("100"), eq(-0.2)); + } + } + + @Nested + @DisplayName("주문 점수 증가") + class IncrementOrderScore { + + @Test + @DisplayName("주문 수량 기반으로 점수가 계산된다") + void shouldCalculateScoreBasedOnQuantity() { + // given + Long productId = 100L; + int quantity = 5; + LocalDate date = LocalDate.of(2025, 1, 15); + + when(rankingWeight.calculateOrderScore(quantity)).thenReturn(3.5); // 0.7 * 5 + when(redisTemplate.hasKey(anyString())).thenReturn(true); + + // when + rankingService.incrementOrderScore(productId, quantity, date); + + // then + verify(zSetOperations).incrementScore(anyString(), eq("100"), eq(3.5)); + } + } + + @Nested + @DisplayName("전날 점수 다음날 이관") + class CarryOverScores { + + @Test + @DisplayName("전날 점수의 일부가 다음날 키로 복사된다") + void shouldCopyScoresWithWeight() { + // given + LocalDate today = LocalDate.of(2025, 1, 15); + LocalDate tomorrow = LocalDate.of(2025, 1, 16); + String fromKey = "ranking:all:20250115"; + String toKey = "ranking:all:20250116"; + + Set> tuples = new LinkedHashSet<>(); + tuples.add(createTuple("100", 10.0)); + tuples.add(createTuple("200", 5.0)); + + when(zSetOperations.zCard(toKey)).thenReturn(0L); + when(zSetOperations.rangeWithScores(fromKey, 0, -1)).thenReturn(tuples); + + // when + rankingService.carryOverScores(today, tomorrow, 0.1); + + // then + verify(zSetOperations).add(toKey, "100", 1.0); // 10.0 * 0.1 + verify(zSetOperations).add(toKey, "200", 0.5); // 5.0 * 0.1 + verify(redisTemplate).expire(eq(toKey), any()); + } + + @Test + @DisplayName("이미 준비된 랭킹이 있으면 스킵한다") + void shouldSkipIfAlreadyPrepared() { + // given + LocalDate today = LocalDate.of(2025, 1, 15); + LocalDate tomorrow = LocalDate.of(2025, 1, 16); + String toKey = "ranking:all:20250116"; + + when(zSetOperations.zCard(toKey)).thenReturn(10L); // 이미 데이터 존재 + + // when + rankingService.carryOverScores(today, tomorrow, 0.1); + + // then + verify(zSetOperations, never()).rangeWithScores(anyString(), anyLong(), anyLong()); + verify(zSetOperations, never()).add(anyString(), anyString(), anyDouble()); + } + + private ZSetOperations.TypedTuple createTuple(String value, Double score) { + return new ZSetOperations.TypedTuple<>() { + @Override + public String getValue() { return value; } + @Override + public Double getScore() { return score; } + @Override + public int compareTo(ZSetOperations.TypedTuple o) { + return Double.compare(this.getScore(), o.getScore()); + } + }; + } + } +} diff --git a/apps/commerce-streamer/src/test/java/com/loopers/domain/ranking/RankingWeightTest.java b/apps/commerce-streamer/src/test/java/com/loopers/domain/ranking/RankingWeightTest.java new file mode 100644 index 000000000..4093df558 --- /dev/null +++ b/apps/commerce-streamer/src/test/java/com/loopers/domain/ranking/RankingWeightTest.java @@ -0,0 +1,189 @@ +package com.loopers.domain.ranking; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.redis.core.HashOperations; +import org.springframework.data.redis.core.RedisTemplate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.within; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class RankingWeightTest { + + @Mock + private RedisTemplate redisTemplate; + + @Mock + private HashOperations hashOperations; + + private RankingWeight rankingWeight; + + private static final String WEIGHT_KEY = "ranking:config:weights"; + + @BeforeEach + void setUp() { + when(redisTemplate.opsForHash()).thenReturn(hashOperations); + rankingWeight = new RankingWeight(redisTemplate); + } + + @Nested + @DisplayName("가중치 반환") + class GetWeight { + + @Test + @DisplayName("Redis에 값이 있으면 해당 값을 반환한다") + void shouldReturnRedisValueWhenExists() { + // given + when(hashOperations.get(WEIGHT_KEY, "view")).thenReturn("0.15"); + + // when + double weight = rankingWeight.getViewWeight(); + + // then + assertThat(weight).isEqualTo(0.15); + } + + @Test + @DisplayName("Redis에 값이 없으면 기본값을 반환한다") + void shouldReturnDefaultWhenRedisValueIsNull() { + // given + when(hashOperations.get(WEIGHT_KEY, "view")).thenReturn(null); + + // when + double weight = rankingWeight.getViewWeight(); + + // then + assertThat(weight).isEqualTo(0.1); // DEFAULT_VIEW_WEIGHT + } + + @Test + @DisplayName("Redis 예외 발생 시 기본값을 반환한다") + void shouldReturnDefaultWhenRedisThrowsException() { + // given + when(hashOperations.get(WEIGHT_KEY, "like")).thenThrow(new RuntimeException("Redis error")); + + // when + double weight = rankingWeight.getLikeWeight(); + + // then + assertThat(weight).isEqualTo(0.2); // DEFAULT_LIKE_WEIGHT + } + } + + @Nested + @DisplayName("가중치 계산") + class CalculateScore { + + @Test + @DisplayName("조회 점수 계산: viewWeight * 1.0") + void shouldCalculateViewScore() { + // given + when(hashOperations.get(WEIGHT_KEY, "view")).thenReturn("0.1"); + + // when + double score = rankingWeight.calculateViewScore(); + + // then + assertThat(score).isEqualTo(0.1); + } + + @Test + @DisplayName("좋아요 점수 계산: likeWeight * 1.0 (좋아요)") + void shouldCalculateLikeScoreWhenLiked() { + // given + when(hashOperations.get(WEIGHT_KEY, "like")).thenReturn("0.2"); + + // when + double score = rankingWeight.calculateLikeScore(true); + + // then + assertThat(score).isEqualTo(0.2); + } + + @Test + @DisplayName("좋아요 취소 점수 계산: likeWeight * -1.0 (취소)") + void shouldCalculateNegativeScoreWhenUnliked() { + // given + when(hashOperations.get(WEIGHT_KEY, "like")).thenReturn("0.2"); + + // when + double score = rankingWeight.calculateLikeScore(false); + + // then + assertThat(score).isEqualTo(-0.2); + } + + @Test + @DisplayName("주문 점수 계산 (수량 기반): orderWeight * quantity") + void shouldCalculateOrderScoreWithQuantity() { + // given + when(hashOperations.get(WEIGHT_KEY, "order")).thenReturn("0.7"); + + // when + double score = rankingWeight.calculateOrderScore(5); + + // then + assertThat(score).isEqualTo(3.5); // 0.7 * 5 + } + + @Test + @DisplayName("주문 점수 계산 (금액 기반): orderWeight * log10(amount)") + void shouldCalculateOrderScoreWithAmount() { + // given + when(hashOperations.get(WEIGHT_KEY, "order")).thenReturn("0.7"); + + // when + double score = rankingWeight.calculateOrderScoreWithAmount(10000L); + + // then + // 0.7 * log10(10000) = 0.7 * 4 = 2.8 + assertThat(score).isCloseTo(2.8, within(0.01)); + } + + @Test + @DisplayName("금액이 0 이하일 때 점수는 0이다") + void shouldReturnZeroScoreWhenAmountIsZeroOrNegative() { + // when + double scoreZero = rankingWeight.calculateOrderScoreWithAmount(0); + double scoreNegative = rankingWeight.calculateOrderScoreWithAmount(-100); + + // then + assertThat(scoreZero).isEqualTo(0); + assertThat(scoreNegative).isEqualTo(0); + } + } + + @Nested + @DisplayName("가중치 업데이트") + class UpdateWeights { + + @Test + @DisplayName("모든 가중치를 일괄 업데이트한다") + void shouldUpdateAllWeights() { + // when + rankingWeight.updateAllWeights(0.15, 0.25, 0.6); + + // then + verify(hashOperations).put(WEIGHT_KEY, "view", "0.15"); + verify(hashOperations).put(WEIGHT_KEY, "like", "0.25"); + verify(hashOperations).put(WEIGHT_KEY, "order", "0.6"); + } + + @Test + @DisplayName("가중치 초기화 시 Redis 키를 삭제한다") + void shouldDeleteKeyWhenReset() { + // when + rankingWeight.resetToDefault(); + + // then + verify(redisTemplate).delete(WEIGHT_KEY); + } + } +} diff --git a/apps/commerce-streamer/src/test/java/com/loopers/interfaces/ProductMetricsConsumerTest.java b/apps/commerce-streamer/src/test/java/com/loopers/interfaces/ProductMetricsConsumerTest.java index 21d384f24..32631c2cf 100644 --- a/apps/commerce-streamer/src/test/java/com/loopers/interfaces/ProductMetricsConsumerTest.java +++ b/apps/commerce-streamer/src/test/java/com/loopers/interfaces/ProductMetricsConsumerTest.java @@ -138,4 +138,107 @@ void listen_batchMessages() { verify(facade, times(1)).processViewMetrics(any()); verify(ack, times(1)).acknowledge(); } + + @Test + @DisplayName("중복 이벤트 수신 시 Consumer는 각각 호출하고 멱등성을 보장한다") + void listen_duplicateEvent_shouldProcessOnce() { + // given + String topic = "product-like-metrics"; + String eventId = "evt-duplicate-001"; + String value = String.format(""" + { + "eventId": "%s", + "productId": 123, + "likeType": "LIKED" + } + """, eventId); + + ConsumerRecord record1 = makeRecord(topic, "123", value); + ConsumerRecord record2 = makeRecord(topic, "123", value); // 중복 + Acknowledgment ack = mock(Acknowledgment.class); + + // 첫 번째 호출에서는 처리, 두 번째는 이미 처리됨으로 스킵 + doNothing().when(facade).processLikeMetrics(any()); + + // when + consumer.listen(List.of(record1), ack); + consumer.listen(List.of(record2), ack); + + // then + verify(facade, times(2)).processLikeMetrics(any()); + } + + @Test + @DisplayName("멱등성: 동일 eventId로 중복 처리되지 않아야 한다 (실제 멱등 로직 검증)") + void listen_duplicateEvent_shouldBeIdempotent() { + // given + String topic = "product-like-metrics"; + String eventId = "evt-idempotent-001"; + String value = String.format(""" + { + "eventId": "%s", + "productId": 123, + "likeType": "LIKED" + } + """, eventId); + + ConsumerRecord record1 = makeRecord(topic, "123", value); + ConsumerRecord record2 = makeRecord(topic, "123", value); + Acknowledgment ack = mock(Acknowledgment.class); + + // when + consumer.listen(List.of(record1), ack); + consumer.listen(List.of(record2), ack); + + // then + verify(facade, times(2)).processLikeMetrics(any()); + verify(ack, times(2)).acknowledge(); + } + + @Test + @DisplayName("랭킹 이벤트: 조회 이벤트 처리 시 랭킹 점수가 업데이트된다") + void listen_viewEvent_shouldUpdateRanking() { + // given + String topic = "product-view-metrics"; + String value = """ + { + "eventId": "evt-view-ranking-001", + "productId": 789 + } + """; + + ConsumerRecord record = makeRecord(topic, "789", value); + Acknowledgment ack = mock(Acknowledgment.class); + + // when + consumer.listen(List.of(record), ack); + + // then + ArgumentCaptor captor = ArgumentCaptor.forClass(ProductMetricsCommand.class); + verify(facade).processViewMetrics(captor.capture()); + + ProductMetricsCommand captured = captor.getValue(); + assertThat(captured.productId()).isEqualTo(789L); + } + + @Test + @DisplayName("잘못된 JSON 포맷의 메시지는 무시하고 다음 메시지를 처리한다") + void listen_invalidJson_shouldContinueProcessing() { + // given + List> records = List.of( + makeRecord("product-view-metrics", "1", "invalid json"), + makeRecord("product-view-metrics", "2", """ + {"eventId": "evt-valid", "productId": 2} + """) + ); + + Acknowledgment ack = mock(Acknowledgment.class); + + // when + consumer.listen(records, ack); + + // then + verify(facade, times(1)).processViewMetrics(any()); + verify(ack, times(1)).acknowledge(); + } }