Skip to content

Commit 5a4975e

Browse files
authored
Merge pull request #228 from Kimjipang/main
[volume-9] Product Ranking with Redis
2 parents 5e43c2a + acc30d7 commit 5a4975e

29 files changed

Lines changed: 686 additions & 25 deletions

.github/workflows/main.yml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
name: PR Agent
2+
on:
3+
pull_request:
4+
types: [opened, synchronize]
5+
jobs:
6+
pr_agent_job:
7+
runs-on: ubuntu-latest
8+
steps:
9+
- name: PR Agent action step
10+
uses: Codium-ai/pr-agent@main
11+
env:
12+
OPENAI_KEY: ${{ secrets.OPENAI_KEY }}
13+
GITHUB_TOKEN: ${{ secrets.G_TOKEN }}

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ docker-compose -f ./docker/monitoring-compose.yml up
2727
Root
2828
├── apps ( spring-applications )
2929
│ ├── 📦 commerce-api
30+
│ ├── 📦 commerce-batch
3031
│ └── 📦 commerce-streamer
3132
├── modules ( reusable-configurations )
3233
│ ├── 📦 jpa

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

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717
import org.springframework.transaction.annotation.Transactional;
1818

1919
import java.math.BigDecimal;
20+
import java.time.LocalDate;
21+
import java.time.format.DateTimeFormatter;
2022
import java.util.List;
2123

2224
@Component
@@ -25,6 +27,7 @@ public class ProductFacade {
2527
private final ProductRepository productRepository;
2628
private final BrandRepository brandRepository;
2729
private final OutboxRepository outBoxRepository;
30+
private final RankingRedisReader rankingRedisReader;
2831

2932
@Transactional
3033
public ProductInfo registerProduct(ProductV1Dto.ProductRequest request) {
@@ -52,7 +55,7 @@ public List<ProductInfo> findAllProducts() {
5255

5356
@Transactional
5457
@Cacheable(value = "product", key = "#id")
55-
public ProductInfo findProductById(Long id) {
58+
public ProductRankingInfo findProductById(Long id) {
5659
Product product = productRepository.findById(id).orElseThrow(
5760
() -> new CoreException(ErrorType.NOT_FOUND, "찾고자 하는 상품이 존재하지 않습니다.")
5861
);
@@ -65,7 +68,14 @@ public ProductInfo findProductById(Long id) {
6568

6669
outBoxRepository.save(outBoxEvent);
6770

68-
return ProductInfo.from(product);
71+
RankingInfo ranking = null;
72+
73+
try {
74+
String date = LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE);
75+
ranking = rankingRedisReader.getDailyRanking(date, product.getId());
76+
} catch (Exception ignored) {}
77+
78+
return ProductRankingInfo.from(product, ranking);
6979
}
7080

7181
@Transactional(readOnly = true)
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package com.loopers.application.product;
2+
3+
import com.loopers.domain.product.Product;
4+
5+
import java.math.BigDecimal;
6+
7+
public record ProductRankingInfo(Long id, Long brandId, String name, BigDecimal price, int stock, int likeCount, int rank, double score) {
8+
public static ProductRankingInfo from(Product product, RankingInfo ranking) {
9+
return new ProductRankingInfo(
10+
product.getId(),
11+
product.getBrandId(),
12+
product.getName(),
13+
product.getPrice(),
14+
product.getStock(),
15+
product.getLikeCount(),
16+
ranking.rank(),
17+
ranking.score()
18+
);
19+
}
20+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
package com.loopers.application.product;
2+
3+
public record RankingInfo(
4+
String date,
5+
double score,
6+
int rank,
7+
Long total
8+
) {
9+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
package com.loopers.application.product;
2+
3+
import lombok.RequiredArgsConstructor;
4+
import org.springframework.data.redis.core.RedisCallback;
5+
import org.springframework.data.redis.core.StringRedisTemplate;
6+
import org.springframework.data.redis.serializer.RedisSerializer;
7+
import org.springframework.stereotype.Component;
8+
9+
import java.util.List;
10+
11+
@Component
12+
@RequiredArgsConstructor
13+
public class RankingRedisReader {
14+
private final StringRedisTemplate redisTemplate;
15+
16+
public RankingInfo getDailyRanking(String date, Long productId) {
17+
String key = "ranking:all:" + date;
18+
String member = String.valueOf(productId);
19+
20+
RedisSerializer<String> serializer = redisTemplate.getStringSerializer();
21+
byte[] keyBytes = serializer.serialize(key);
22+
byte[] memberBytes = serializer.serialize(member);
23+
24+
@SuppressWarnings("unchecked")
25+
List<Object> results = (List<Object>) redisTemplate.executePipelined((RedisCallback<Object>) connection -> {
26+
connection.zScore(keyBytes, memberBytes); // -> Double (or null)
27+
connection.zRevRank(keyBytes, memberBytes); // -> Long (or null) 0-base
28+
connection.zCard(keyBytes); // -> Long
29+
return null;
30+
});
31+
32+
Double score = (Double) results.get(0);
33+
Long revRank0 = (Long) results.get(1);
34+
Long total = (Long) results.get(2);
35+
36+
Integer rank = (revRank0 == null) ? null : Math.toIntExact(revRank0 + 1);
37+
38+
return new RankingInfo(date, score, rank, total);
39+
}
40+
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
package com.loopers.application.ranking;
2+
3+
import com.loopers.interfaces.api.ranking.RankingV1Dto;
4+
import lombok.RequiredArgsConstructor;
5+
import org.springframework.data.redis.core.StringRedisTemplate;
6+
import org.springframework.data.redis.core.ZSetOperations;
7+
import org.springframework.stereotype.Component;
8+
9+
import java.time.LocalDate;
10+
import java.time.ZoneId;
11+
import java.time.format.DateTimeFormatter;
12+
import java.util.ArrayList;
13+
import java.util.List;
14+
import java.util.Set;
15+
16+
@Component
17+
@RequiredArgsConstructor
18+
public class RankingFacade {
19+
private final StringRedisTemplate redisTemplate;
20+
private static final ZoneId KST = ZoneId.of("Asia/Seoul");
21+
private static final DateTimeFormatter YYYYMMDD = DateTimeFormatter.BASIC_ISO_DATE;
22+
23+
public RankingV1Dto.ProductRankingPageResponse getDailyProductRanking(int page, int size) {
24+
if (page < 1) page = 1;
25+
if (size < 1) size = 20;
26+
27+
String date = LocalDate.now(KST).format(YYYYMMDD);
28+
29+
String key = "ranking:all:" + date;
30+
ZSetOperations<String, String> zset = redisTemplate.opsForZSet();
31+
32+
Long total = zset.size(key);
33+
long totalElements = (total == null) ? 0 : total;
34+
35+
if (totalElements == 0) {
36+
return new RankingV1Dto.ProductRankingPageResponse(date, page, size, 0, 0, List.of());
37+
}
38+
39+
long start = (long) (page - 1) * size;
40+
long end = start + size - 1;
41+
42+
if (start >= totalElements) {
43+
int totalPages = (int) Math.ceil((double) totalElements / size);
44+
return new RankingV1Dto.ProductRankingPageResponse(date, page, size, totalElements, totalPages, List.of());
45+
}
46+
47+
Set<ZSetOperations.TypedTuple<String>> tuples =
48+
zset.reverseRangeWithScores(key, start, end);
49+
50+
List<RankingV1Dto.ProductRankingResponse> items = new ArrayList<>();
51+
if (tuples != null) {
52+
long rank = start + 1;
53+
for (var t : tuples) {
54+
String member = t.getValue();
55+
Double score = t.getScore();
56+
if (member == null || score == null) continue;
57+
58+
items.add(new RankingV1Dto.ProductRankingResponse(
59+
rank++,
60+
Long.parseLong(member),
61+
score
62+
));
63+
}
64+
}
65+
66+
int totalPages = (int) Math.ceil((double) totalElements / size);
67+
return new RankingV1Dto.ProductRankingPageResponse(date, page, size, totalElements, totalPages, items);
68+
}
69+
70+
}

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ public interface ProductV1ApiSpec {
1515
ApiResponse<List<ProductV1Dto.ProductResponse>> findAllProducts();
1616

1717
@Operation(summary = "상품 상세 조회")
18-
ApiResponse<ProductV1Dto.ProductResponse> findProductById(Long id);
18+
ApiResponse<ProductV1Dto.ProductRankingResponse> findProductById(Long id);
1919

2020
@Operation(summary = "상품 정렬 조회")
2121
ApiResponse<List<ProductV1Dto.ProductResponse>> findProductsBySortCondition(ProductV1Dto.SearchProductRequest request);

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

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import com.loopers.application.product.ProductFacade;
44
import com.loopers.application.product.ProductInfo;
5+
import com.loopers.application.product.ProductRankingInfo;
56
import com.loopers.interfaces.api.ApiResponse;
67
import lombok.RequiredArgsConstructor;
78
import org.springframework.web.bind.annotation.GetMapping;
@@ -42,9 +43,9 @@ public ApiResponse<List<ProductV1Dto.ProductResponse>> findAllProducts() {
4243

4344
@GetMapping("/{id}")
4445
@Override
45-
public ApiResponse<ProductV1Dto.ProductResponse> findProductById(@PathVariable Long id) {
46-
ProductInfo info = productFacade.findProductById(id);
47-
ProductV1Dto.ProductResponse response = ProductV1Dto.ProductResponse.from(info);
46+
public ApiResponse<ProductV1Dto.ProductRankingResponse> findProductById(@PathVariable Long id) {
47+
ProductRankingInfo info = productFacade.findProductById(id);
48+
ProductV1Dto.ProductRankingResponse response = ProductV1Dto.ProductRankingResponse.from(info);
4849

4950
return ApiResponse.success(response);
5051
}

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

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,27 @@
11
package com.loopers.interfaces.api.product;
22

33
import com.loopers.application.product.ProductInfo;
4+
import com.loopers.application.product.ProductRankingInfo;
45
import com.loopers.domain.product.Product;
56
import com.loopers.support.error.CoreException;
67
import com.loopers.support.error.ErrorType;
78

89
import java.math.BigDecimal;
910

1011
public class ProductV1Dto {
12+
public record ProductRankingResponse(Long id, Long brandId, String name, BigDecimal price, int stock, int rank, double score) {
13+
public static ProductRankingResponse from(ProductRankingInfo info) {
14+
return new ProductRankingResponse(
15+
info.id(),
16+
info.brandId(),
17+
info.name(),
18+
info.price(),
19+
info.stock(),
20+
info.rank(),
21+
info.score()
22+
);
23+
}
24+
}
1125
public record ProductResponse(Long id, Long brandId, String name, BigDecimal price, int stock, int likeCount) {
1226
public static ProductResponse from(ProductInfo info) {
1327
return new ProductResponse(

0 commit comments

Comments
 (0)