Skip to content

Commit fc8e319

Browse files
authored
Merge pull request #44 from hyujikoh/feat/sorted-list
zset 랭킹 시스템 구현
2 parents 3a6755c + a93dad6 commit fc8e319

35 files changed

Lines changed: 3077 additions & 727 deletions

apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetailInfo.java

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.loopers.application.product;
22

33
import com.loopers.application.brand.BrandInfo;
4+
import com.loopers.cache.dto.CachePayloads.RankingItem;
45
import com.loopers.domain.brand.BrandEntity;
56
import com.loopers.domain.product.ProductEntity;
67
import com.loopers.domain.product.ProductMaterializedViewEntity;
@@ -19,13 +20,18 @@ public record ProductDetailInfo(
1920
Integer stockQuantity,
2021
ProductPriceInfo price,
2122
BrandInfo brand,
22-
Boolean isLiked // 사용자 좋아요 여부
23+
Boolean isLiked, // 사용자 좋아요 여부
24+
RankingItem ranking // 상품 랭킹 정보 (nullable)
2325
) {
2426

2527
/**
2628
* MV 엔티티와 좋아요 여부로 생성 (권장)
2729
*/
2830
public static ProductDetailInfo from(ProductMaterializedViewEntity mv, Boolean isLiked) {
31+
return from(mv, isLiked, null);
32+
}
33+
34+
public static ProductDetailInfo from(ProductMaterializedViewEntity mv, Boolean isLiked, RankingItem ranking) {
2935
if (mv == null) {
3036
throw new IllegalArgumentException("MV 엔티티는 필수입니다.");
3137
}
@@ -44,14 +50,19 @@ public static ProductDetailInfo from(ProductMaterializedViewEntity mv, Boolean i
4450
mv.getBrandId(),
4551
mv.getBrandName()
4652
),
47-
isLiked
53+
isLiked,
54+
ranking
4855
);
4956
}
5057

5158
/**
5259
* ProductEntity + BrandEntity + 좋아요수로 생성 (MV 사용 권장)
5360
*/
5461
public static ProductDetailInfo of(ProductEntity product, BrandEntity brand, Long likeCount, Boolean isLiked) {
62+
return of(product, brand, likeCount, isLiked, null);
63+
}
64+
65+
public static ProductDetailInfo of(ProductEntity product, BrandEntity brand, Long likeCount, Boolean isLiked, RankingItem ranking) {
5566
if (product == null) {
5667
throw new IllegalArgumentException("상품 정보는 필수입니다.");
5768
}
@@ -74,7 +85,8 @@ public static ProductDetailInfo of(ProductEntity product, BrandEntity brand, Lon
7485
brand.getId(),
7586
brand.getName()
7687
),
77-
isLiked
88+
isLiked,
89+
ranking
7890
);
7991
}
8092

@@ -87,7 +99,22 @@ public static ProductDetailInfo fromWithSyncLike(ProductDetailInfo productDetail
8799
productDetailInfo.stockQuantity(),
88100
productDetailInfo.price(),
89101
productDetailInfo.brand(),
90-
isLiked
102+
isLiked,
103+
productDetailInfo.ranking()
104+
);
105+
}
106+
107+
public static ProductDetailInfo fromWithRanking(ProductDetailInfo productDetailInfo, RankingItem ranking) {
108+
return new ProductDetailInfo(
109+
productDetailInfo.id(),
110+
productDetailInfo.name(),
111+
productDetailInfo.description(),
112+
productDetailInfo.likeCount(),
113+
productDetailInfo.stockQuantity(),
114+
productDetailInfo.price(),
115+
productDetailInfo.brand(),
116+
productDetailInfo.isLiked(),
117+
ranking
91118
);
92119
}
93120
}

apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java

Lines changed: 82 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,21 @@
11
package com.loopers.application.product;
22

3+
import java.time.LocalDate;
4+
import java.util.List;
5+
import java.util.Objects;
36
import java.util.Optional;
47
import java.util.Set;
8+
import java.util.stream.Collectors;
59

610
import org.springframework.data.domain.Page;
11+
import org.springframework.data.domain.PageImpl;
12+
import org.springframework.data.domain.Pageable;
713
import org.springframework.stereotype.Component;
814
import org.springframework.transaction.annotation.Transactional;
915

1016
import com.loopers.cache.CacheStrategy;
17+
import com.loopers.cache.RankingRedisService;
18+
import com.loopers.cache.dto.CachePayloads.RankingItem;
1119
import com.loopers.domain.brand.BrandEntity;
1220
import com.loopers.domain.brand.BrandService;
1321
import com.loopers.domain.like.LikeService;
@@ -37,6 +45,7 @@ public class ProductFacade {
3745
private final UserService userService;
3846
private final BrandService brandService;
3947
private final UserBehaviorTracker behaviorTracker;
48+
private final RankingRedisService rankingRedisService;
4049

4150
/**
4251
* 도메인 서비스에서 MV 엔티티를 조회하고, Facade에서 DTO로 변환합니다.
@@ -61,7 +70,7 @@ public Page<ProductInfo> getProducts(ProductSearchFilter productSearchFilter) {
6170
* 도메인 서비스에서 엔티티를 조회하고, Facade에서 DTO로 변환합니다.
6271
*
6372
* @param productId 상품 ID
64-
* @param username 사용자명 (nullable)
73+
* @param username 사용자 ID (nullable)
6574
* @return 상품 상세 정보
6675
*/
6776
@Transactional(readOnly = true)
@@ -96,18 +105,82 @@ public ProductDetailInfo getProductDetail(Long productId, String username) {
96105
productCacheService.cacheProductDetail(productId, result);
97106
}
98107

99-
// 5. 유저 행동 추적 (상품 조회) - 캐시 히트/미스와 관계없이 항상 이벤트 발행
108+
// 5. 랭킹 정보 결합 (오늘 날짜 기준 실시간 순위 조회)
109+
final RankingItem ranking = rankingRedisService.getProductRanking(LocalDate.now(), productId);
110+
result = ProductDetailInfo.fromWithRanking(result, ranking);
111+
112+
// 6. 유저 행동 추적 (이벤트 발행)
100113
if (userId != null) {
101-
behaviorTracker.trackProductView(
102-
userId,
103-
productId,
104-
null // searchKeyword는 Controller에서 받아야 함
105-
);
114+
behaviorTracker.trackProductView(userId, productId, null);
106115
}
107116

108117
return result;
109118
}
110119

120+
/**
121+
* 랭킹 상품 목록 조회
122+
* <p>
123+
* 콜드 스타트 Fallback: 오늘 랭킹이 비어있으면 어제 랭킹 반환
124+
*
125+
* @param pageable 페이징 정보
126+
* @param date 조회 날짜 (null이면 오늘)
127+
* @return 랭킹 상품 목록
128+
*/
129+
@Transactional(readOnly = true)
130+
public Page<ProductInfo> getRankingProducts(Pageable pageable, LocalDate date) {
131+
LocalDate targetDate = date != null ? date : LocalDate.now();
132+
133+
// 1. 랭킹 조회
134+
List<RankingItem> rankings = rankingRedisService.getRanking(
135+
targetDate,
136+
pageable.getPageNumber() + 1,
137+
pageable.getPageSize()
138+
);
139+
140+
// 2. 콜드 스타트 Fallback: 오늘 랭킹이 비어있으면 어제 랭킹 조회
141+
if (rankings.isEmpty() && date == null) {
142+
LocalDate yesterday = targetDate.minusDays(1);
143+
log.info("콜드 스타트 Fallback: 오늘({}) 랭킹 없음, 어제({}) 랭킹 조회", targetDate, yesterday);
144+
145+
rankings = rankingRedisService.getRanking(
146+
yesterday,
147+
pageable.getPageNumber() + 1,
148+
pageable.getPageSize()
149+
);
150+
151+
if (!rankings.isEmpty()) {
152+
targetDate = yesterday; // totalCount 계산을 위해 날짜 변경
153+
}
154+
}
155+
156+
if (rankings.isEmpty()) {
157+
log.debug("랭킹 데이터 없음: date={}", targetDate);
158+
return Page.empty(pageable);
159+
}
160+
161+
// 3. 상품 ID 목록 추출
162+
List<Long> productIds = rankings.stream()
163+
.map(RankingItem::productId)
164+
.collect(Collectors.toList());
165+
166+
// 4. 상품 정보 조회 (MV 사용)
167+
List<ProductMaterializedViewEntity> products = mvService.getByIds(productIds);
168+
169+
// 5. 랭킹 순서대로 정렬
170+
List<ProductInfo> sortedProducts = productIds.stream()
171+
.map(productId -> products.stream()
172+
.filter(p -> p.getProductId().equals(productId))
173+
.findFirst()
174+
.map(ProductInfo::from)
175+
.orElse(null))
176+
.filter(Objects::nonNull)
177+
.collect(Collectors.toList());
178+
179+
// 6. Page 객체 생성
180+
long totalCount = rankingRedisService.getRankingCount(targetDate);
181+
return new PageImpl<>(sortedProducts, pageable, totalCount);
182+
}
183+
111184
/**
112185
* 상품을 삭제합니다.
113186
* <p>
@@ -116,7 +189,7 @@ public ProductDetailInfo getProductDetail(Long productId, String username) {
116189
* @param productId 상품 ID
117190
*/
118191
@Transactional
119-
public void deletedProduct(Long productId) {
192+
public void deleteProduct(Long productId) {
120193
// 1. 상품 삭제
121194
ProductEntity product = productService.getActiveProductDetail(productId);
122195
product.delete();
@@ -137,7 +210,7 @@ public void deletedProduct(Long productId) {
137210
* @param brandId 브랜드 ID
138211
*/
139212
@Transactional
140-
public void deletedBrand(Long brandId) {
213+
public void deleteBrand(Long brandId) {
141214
// 1. 브랜드 삭제
142215
BrandEntity brand = brandService.getBrandById(brandId);
143216
brand.delete();

apps/commerce-api/src/main/java/com/loopers/domain/product/ProductMVService.java

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,16 @@ public ProductMaterializedViewEntity getById(Long productId) {
5454
));
5555
}
5656

57+
/**
58+
* 여러 상품 ID로 MV 목록을 조회합니다.
59+
*
60+
* @param productIds 상품 ID 목록
61+
* @return 상품 MV 목록
62+
*/
63+
public List<ProductMaterializedViewEntity> getByIds(List<Long> productIds) {
64+
return mvRepository.findByIdIn(productIds);
65+
}
66+
5767
/**
5868
* 브랜드별 상품 MV를 페이징 조회합니다.
5969
*

apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1ApiSpec.java

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
import io.swagger.v3.oas.annotations.responses.ApiResponses;
66
import io.swagger.v3.oas.annotations.tags.Tag;
77

8+
import java.time.LocalDate;
9+
810
import org.springframework.data.domain.Pageable;
911
import org.springframework.data.web.PageableDefault;
1012
import org.springframework.web.bind.annotation.PathVariable;
@@ -29,6 +31,21 @@ ApiResponse<PageResponse<ProductV1Dtos.ProductListResponse>> getProducts(
2931
@PageableDefault(size = 20) Pageable pageable,
3032
@RequestParam(required = false) Long brandId,
3133
@RequestParam(required = false) String productName
34+
);
35+
36+
37+
@Operation(
38+
summary = "랭킹 상품 목록 조회",
39+
description = "일자 기준 랭킹 상품 목록을 페이징하여 조회합니다. date 파라미터가 없으면 오늘 날짜 기준으로 조회합니다."
40+
)
41+
@ApiResponses({
42+
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "조회 성공"),
43+
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "잘못된 요청")
44+
})
45+
ApiResponse<PageResponse<ProductV1Dtos.ProductListResponse>> getRankingProducts(
46+
@PageableDefault(size = 20) Pageable pageable,
47+
@Parameter(description = "조회 날짜 (yyyyMMdd 형식, 선택)", example = "20251223")
48+
@RequestParam(required = false) LocalDate date
3249
);
3350

3451
@Operation(

apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
package com.loopers.interfaces.api.product;
22

3+
import java.time.LocalDate;
4+
35
import org.springframework.data.domain.Page;
46
import org.springframework.data.domain.Pageable;
57
import org.springframework.data.web.PageableDefault;
@@ -8,6 +10,7 @@
810
import com.loopers.application.product.ProductDetailInfo;
911
import com.loopers.application.product.ProductFacade;
1012
import com.loopers.application.product.ProductInfo;
13+
import com.loopers.cache.dto.CachePayloads.RankingItem;
1114
import com.loopers.domain.product.dto.ProductSearchFilter;
1215
import com.loopers.interfaces.api.ApiResponse;
1316
import com.loopers.interfaces.api.common.PageResponse;
@@ -21,7 +24,6 @@ public class ProductV1Controller implements ProductV1ApiSpec {
2124

2225
private final ProductFacade productFacade;
2326

24-
2527
@GetMapping(Uris.Product.GET_LIST)
2628
@Override
2729
public ApiResponse<PageResponse<ProductV1Dtos.ProductListResponse>> getProducts(
@@ -35,15 +37,26 @@ public ApiResponse<PageResponse<ProductV1Dtos.ProductListResponse>> getProducts(
3537
return ApiResponse.success(PageResponse.from(responsePage));
3638
}
3739

40+
@GetMapping(Uris.Ranking.GET_RANKING)
41+
@Override
42+
public ApiResponse<PageResponse<ProductV1Dtos.ProductListResponse>> getRankingProducts(
43+
@PageableDefault(size = 20) Pageable pageable,
44+
@RequestParam(required = false) LocalDate date
45+
) {
46+
Page<ProductInfo> products = productFacade.getRankingProducts(pageable, date);
47+
Page<ProductV1Dtos.ProductListResponse> responsePage = products.map(ProductV1Dtos.ProductListResponse::from);
48+
return ApiResponse.success(PageResponse.from(responsePage));
49+
}
50+
3851
@GetMapping(Uris.Product.GET_DETAIL)
3952
@Override
4053
public ApiResponse<ProductV1Dtos.ProductDetailResponse> getProductDetail(
4154
@PathVariable Long productId,
4255
@RequestHeader(value = "X-USER-ID", required = false) String username
4356
) {
4457
ProductDetailInfo productDetail = productFacade.getProductDetail(productId, username);
45-
ProductV1Dtos.ProductDetailResponse response = ProductV1Dtos.ProductDetailResponse.from(productDetail);
46-
return ApiResponse.success(response);
58+
59+
// 3. 응답 생성
60+
return ApiResponse.success(ProductV1Dtos.ProductDetailResponse.from(productDetail));
4761
}
4862
}
49-

apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dtos.java

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,9 +72,20 @@ public record ProductDetailResponse(
7272
BrandDetailResponse brand,
7373

7474
@Schema(description = "사용자의 좋아요 여부", example = "true")
75-
Boolean isLiked
75+
Boolean isLiked,
76+
77+
@Schema(description = "랭킹 정보 (랭킹에 없으면 null)")
78+
RankingResponse ranking
7679
) {
7780
public static ProductDetailResponse from(ProductDetailInfo productDetailInfo) {
81+
RankingResponse rankingResponse = null;
82+
if (productDetailInfo.ranking() != null) {
83+
rankingResponse = new RankingResponse(
84+
productDetailInfo.ranking().rank(),
85+
productDetailInfo.ranking().score()
86+
);
87+
}
88+
7889
return new ProductDetailResponse(
7990
productDetailInfo.id(),
8091
productDetailInfo.name(),
@@ -89,7 +100,28 @@ public static ProductDetailResponse from(ProductDetailInfo productDetailInfo) {
89100
productDetailInfo.brand().id(),
90101
productDetailInfo.brand().name()
91102
),
92-
productDetailInfo.isLiked()
103+
productDetailInfo.isLiked(),
104+
rankingResponse
105+
);
106+
}
107+
108+
public static ProductDetailResponse fromWithRanking(ProductDetailInfo productDetailInfo, RankingResponse ranking) {
109+
return new ProductDetailResponse(
110+
productDetailInfo.id(),
111+
productDetailInfo.name(),
112+
productDetailInfo.description(),
113+
productDetailInfo.likeCount(),
114+
productDetailInfo.stockQuantity(),
115+
new PriceResponse(
116+
productDetailInfo.price().originPrice(),
117+
productDetailInfo.price().discountPrice()
118+
),
119+
new BrandDetailResponse(
120+
productDetailInfo.brand().id(),
121+
productDetailInfo.brand().name()
122+
),
123+
productDetailInfo.isLiked(),
124+
ranking
93125
);
94126
}
95127
}
@@ -123,6 +155,16 @@ public record BrandDetailResponse(
123155
String brandName
124156
) {
125157
}
158+
159+
@Schema(description = "랭킹 정보")
160+
public record RankingResponse(
161+
@Schema(description = "순위", example = "1")
162+
Long rank,
163+
164+
@Schema(description = "랭킹 점수", example = "123.45")
165+
Double score
166+
) {
167+
}
126168
}
127169

128170

0 commit comments

Comments
 (0)