Skip to content

Commit 86dc621

Browse files
authored
Merge pull request #34 from junoade/week9-feature-redis
Week9 feature redis
2 parents 85a0bfb + 9f4f62a commit 86dc621

File tree

10 files changed

+327
-11
lines changed

10 files changed

+327
-11
lines changed
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
package com.loopers.application.ranking;
2+
3+
import com.loopers.application.product.ProductLikeSummary;
4+
import com.loopers.ranking.RankingEntry;
5+
6+
import java.time.LocalDate;
7+
import java.util.List;
8+
9+
public record RankingQueryResponse(
10+
LocalDate date,
11+
List<RankingEntry> rankingEntries,
12+
List<ProductLikeSummary> productLikeSummary
13+
) {
14+
public static RankingQueryResponse of(LocalDate date, List<RankingEntry> rankingEntries, List<ProductLikeSummary> productLikeSummary) {
15+
return new RankingQueryResponse(date, rankingEntries, productLikeSummary);
16+
}
17+
}
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
package com.loopers.application.ranking;
2+
3+
import com.loopers.application.like.event.ProductLikeEvent;
4+
import com.loopers.application.product.ProductLikeSummary;
5+
import com.loopers.application.product.ProductQueryService;
6+
import com.loopers.domain.product.ProductSortType;
7+
import com.loopers.ranking.DailyRankingResponse;
8+
import com.loopers.ranking.RankingEntry;
9+
import com.loopers.ranking.RankingZSetRepository;
10+
import com.loopers.support.error.CoreException;
11+
import com.loopers.support.error.ErrorType;
12+
import lombok.RequiredArgsConstructor;
13+
import lombok.extern.slf4j.Slf4j;
14+
import org.springframework.stereotype.Service;
15+
import org.springframework.transaction.annotation.Transactional;
16+
17+
import java.time.LocalDate;
18+
import java.time.ZoneId;
19+
import java.time.ZoneOffset;
20+
import java.time.format.DateTimeFormatter;
21+
import java.util.ArrayList;
22+
import java.util.List;
23+
import java.util.OptionalDouble;
24+
25+
@Slf4j
26+
@Service
27+
@RequiredArgsConstructor
28+
public class RankingQueryService {
29+
private final RankingZSetRepository rankingZSetRepository;
30+
private final ProductQueryService productQueryService;
31+
32+
@Transactional(readOnly = true)
33+
public RankingQueryResponse getDailyPopularProducts(String date, int size) {
34+
LocalDate target = initLocalDate(date);
35+
36+
int limit = (size <= 0) ? 20 : Math.min(size, 100);
37+
38+
List<RankingEntry> rankingEntries = rankingZSetRepository.findTopDailyAllByLimit(target, limit);
39+
List<ProductLikeSummary> productLikeSummaries = findProductSummaryFrom(rankingEntries);
40+
41+
return new RankingQueryResponse(
42+
target,
43+
rankingEntries,
44+
productLikeSummaries
45+
);
46+
}
47+
48+
@Transactional(readOnly = true)
49+
public OptionalDouble getDailyRankingScore(Long productId) {
50+
LocalDate now = LocalDate.now(ZoneId.systemDefault());
51+
return rankingZSetRepository.findDailyRanking(now, productId);
52+
}
53+
54+
private boolean hasValidDate(String date) {
55+
return date == null || date.isBlank();
56+
}
57+
58+
private LocalDate initLocalDate(String date) {
59+
return (hasValidDate(date))
60+
? LocalDate.now(ZoneId.systemDefault())
61+
: LocalDate.parse(date, DateTimeFormatter.BASIC_ISO_DATE);
62+
63+
}
64+
65+
private List<ProductLikeSummary> findProductSummaryFrom(List<RankingEntry> rankingEntries) {
66+
List<ProductLikeSummary> result = new ArrayList<>();
67+
68+
for(RankingEntry rankingEntry : rankingEntries) {
69+
ProductLikeSummary summary;
70+
try {
71+
summary = productQueryService.getProductLikeSummary(rankingEntry.productId());
72+
} catch (CoreException e) {
73+
if(e.getErrorType() == ErrorType.NOT_FOUND) {
74+
log.error("Could not find product like summary for {}", rankingEntry.productId());
75+
}
76+
summary = null;
77+
}
78+
result.add(summary);
79+
}
80+
return result;
81+
}
82+
}

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

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

33
import com.loopers.application.product.ProductLikeSummary;
44
import com.loopers.application.product.ProductQueryService;
5+
import com.loopers.application.ranking.RankingQueryService;
56
import com.loopers.domain.product.ProductSortType;
7+
import com.loopers.ranking.RankingEntry;
68
import com.loopers.support.tracking.general.UserActionType;
79
import com.loopers.interfaces.api.ApiResponse;
810
import com.loopers.support.tracking.annotation.TrackUserAction;
@@ -12,11 +14,14 @@
1214
import org.springframework.data.web.PageableDefault;
1315
import org.springframework.web.bind.annotation.*;
1416

17+
import java.util.OptionalDouble;
18+
1519
@RequiredArgsConstructor
1620
@RestController
1721
@RequestMapping("/api/v1/products")
1822
public class ProductV1Controller implements ProductV1ApiSpec{
1923
private final ProductQueryService productQueryService;
24+
private final RankingQueryService rankingQueryService;
2025

2126
@Override
2227
@GetMapping
@@ -41,7 +46,7 @@ public ApiResponse<ProductV1Dto.ProductListResponse<ProductLikeSummary>> getProd
4146
)
4247
public ApiResponse<ProductV1Dto.ProductDetailResponse<ProductLikeSummary>> getProductDetail(@PathVariable("productId") Long productId) {
4348
ProductLikeSummary productLikeSummary = productQueryService.getProductLikeSummary(productId);
44-
45-
return ApiResponse.success(ProductV1Dto.ProductDetailResponse.of(productLikeSummary));
49+
OptionalDouble rankingEntry = rankingQueryService.getDailyRankingScore(productId);
50+
return ApiResponse.success(ProductV1Dto.ProductDetailResponse.of(productLikeSummary, rankingEntry));
4651
}
4752
}

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

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
package com.loopers.interfaces.api.product;
22

3+
import com.loopers.ranking.RankingEntry;
34
import org.springframework.data.domain.Page;
45

56
import java.util.List;
7+
import java.util.OptionalDouble;
68

79
public class ProductV1Dto {
810

@@ -28,10 +30,15 @@ public static <T> ProductListResponse<T> of(Page<T> page, List<T> content) {
2830
}
2931

3032
public record ProductDetailResponse<T>(
31-
T content
33+
T content,
34+
OptionalDouble rankingScore
3235
){
3336
static <T> ProductDetailResponse<T> of(T content) {
34-
return new ProductDetailResponse<>(content);
37+
return new ProductDetailResponse<>(content, null);
38+
}
39+
40+
static <T> ProductDetailResponse<T> of(T content, OptionalDouble rankingScore) {
41+
return new ProductDetailResponse<>(content, rankingScore);
3542
}
3643
}
3744
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
package com.loopers.interfaces.api.ranking;
2+
3+
import com.loopers.application.ranking.RankingQueryResponse;
4+
import com.loopers.application.ranking.RankingQueryService;
5+
import lombok.RequiredArgsConstructor;
6+
import org.springframework.web.bind.annotation.GetMapping;
7+
import org.springframework.web.bind.annotation.RequestMapping;
8+
import org.springframework.web.bind.annotation.RequestParam;
9+
import org.springframework.web.bind.annotation.RestController;
10+
11+
@RequiredArgsConstructor
12+
@RestController
13+
@RequestMapping("/api/v1/ranking")
14+
public class RankingV1Controller {
15+
16+
private final RankingQueryService rankingQueryService;
17+
18+
@GetMapping
19+
public RankingQueryResponse getDailyRanking(
20+
@RequestParam(required = false, name = "date") String date,
21+
@RequestParam(defaultValue = "20", name = "size") int size
22+
) {
23+
return rankingQueryService.getDailyPopularProducts(date, size);
24+
}
25+
26+
}

apps/commerce-streamer/src/main/java/com/loopers/application/ranking/RankingAggregationService.java

Lines changed: 52 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,31 +2,76 @@
22

33
import com.loopers.ranking.RankingKey;
44
import lombok.RequiredArgsConstructor;
5+
6+
import org.springframework.data.redis.connection.RedisZSetCommands;
7+
import org.springframework.data.redis.core.RedisCallback;
58
import org.springframework.data.redis.core.StringRedisTemplate;
9+
import org.springframework.scheduling.annotation.Scheduled;
610
import org.springframework.stereotype.Service;
711

8-
import java.time.Duration;
9-
import java.time.Instant;
10-
import java.time.LocalDate;
11-
import java.time.ZoneOffset;
12+
import java.time.*;
1213

1314
@Service
1415
@RequiredArgsConstructor
1516
public class RankingAggregationService {
1617
private static final double LIKE_WEIGHT = 0.2d;
1718
private static final Duration TTL = Duration.ofDays(2);
19+
private static final double CARRY_OVER_WEIGHT = 0.1d;
1820

1921
private final StringRedisTemplate redisTemplate;
2022

2123
public void applyLike(long productId, Instant occurredAt) {
22-
LocalDate day = occurredAt.atZone(ZoneOffset.UTC).toLocalDate();
24+
LocalDate day = occurredAt.atZone(ZoneId.systemDefault()).toLocalDate();
2325
String key = RankingKey.dailyAll(day);
2426
String member = String.valueOf(productId);
2527

2628
// Score = Weight * 1
2729
redisTemplate.opsForZSet().incrementScore(key, member, LIKE_WEIGHT);
2830

29-
// test 설정
30-
redisTemplate.expire(key, TTL);
31+
setTTLOnlyOnce(key);
32+
}
33+
34+
/**
35+
* 일별 랭킹에 대한 전일자 Carry Over 스케줄러
36+
* - 매일 23:30분 수행합니다.
37+
*/
38+
@Scheduled(cron = "0 30 23 * * *")
39+
public void carryOverDailyRanking() {
40+
LocalDate today = LocalDate.now(ZoneId.systemDefault());
41+
LocalDate tomorrow = today.plusDays(1);
42+
43+
String sourceKey = RankingKey.dailyAll(today);
44+
String targetKey = RankingKey.dailyAll(tomorrow);
45+
46+
carryOver(sourceKey, targetKey, CARRY_OVER_WEIGHT);
47+
}
48+
49+
private void carryOver(String sourceKey, String targetKey, double weight) {
50+
redisTemplate.execute((RedisCallback<Long>) connection -> {
51+
byte[] target = redisTemplate.getStringSerializer().serialize(targetKey);
52+
byte[] source = redisTemplate.getStringSerializer().serialize(sourceKey);
53+
// ZUNIONSTORE dest 1 src WEIGHTS 0.1 AGGREGATE SUM
54+
Long result = (Long) connection.execute(
55+
"ZUNIONSTORE",
56+
target,
57+
"1".getBytes(),
58+
source,
59+
"WEIGHTS".getBytes(),
60+
String.valueOf(weight).getBytes(),
61+
"AGGREGATE".getBytes(),
62+
"SUM".getBytes()
63+
);
64+
// 순서 주의할 것
65+
// ZUNIONSTORE target ... → 키 재생성 되므로 이전 TTL 소멸됨
66+
connection.expire(target, TTL.getSeconds());
67+
return result;
68+
});
69+
}
70+
71+
private void setTTLOnlyOnce(String key) {
72+
Boolean exists = redisTemplate.hasKey(key);
73+
if (Boolean.FALSE.equals(exists)) {
74+
redisTemplate.expire(key, TTL);
75+
}
3176
}
3277
}
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.ranking.RankingKey;
4+
import com.loopers.testcontainers.RedisTestContainersConfig;
5+
import com.loopers.utils.RedisCleanUp;
6+
import org.assertj.core.api.Assertions;
7+
import org.junit.jupiter.api.BeforeEach;
8+
import org.junit.jupiter.api.DisplayName;
9+
import org.junit.jupiter.api.Test;
10+
import org.springframework.beans.factory.annotation.Autowired;
11+
import org.springframework.boot.test.context.SpringBootTest;
12+
import org.springframework.context.annotation.Import;
13+
import org.springframework.data.redis.core.StringRedisTemplate;
14+
15+
import java.time.LocalDate;
16+
import java.time.ZoneId;
17+
18+
import static org.assertj.core.api.AssertionsForClassTypes.assertThat;
19+
20+
@Import(RedisTestContainersConfig.class)
21+
@SpringBootTest
22+
class RankingAggregationServiceTest {
23+
24+
@Autowired
25+
RedisCleanUp redisCleanUp;
26+
27+
@Autowired
28+
private StringRedisTemplate redisTemplate;
29+
30+
@Autowired
31+
private RankingAggregationService rankingAggregationService;
32+
33+
@BeforeEach
34+
void setUp() {
35+
redisCleanUp.truncateAll();
36+
}
37+
38+
@Test
39+
@DisplayName("일별 랭킹 carry over 테스트 - 오늘 ZSET 점수를 0.1 가중치로 내일 키로 복사하고 TTL을 설정한다")
40+
void carryOver_should_copy_weighted_scores_to_tomorrow_key() {
41+
// given
42+
LocalDate today = LocalDate.now(ZoneId.systemDefault());
43+
LocalDate tomorrow = today.plusDays(1);
44+
45+
String sourceKey = RankingKey.dailyAll(today);
46+
String targetKey = RankingKey.dailyAll(tomorrow);
47+
48+
// source ZSET 준비 (productId=11: 6.0, productId=22: 1.5)
49+
redisTemplate.opsForZSet().add(sourceKey, "11", 6.0);
50+
redisTemplate.opsForZSet().add(sourceKey, "22", 1.5);
51+
52+
// when
53+
rankingAggregationService.carryOverDailyRanking();
54+
55+
// then (0.1 가중치 적용)
56+
Double s11 = redisTemplate.opsForZSet().score(targetKey, "11");
57+
Double s22 = redisTemplate.opsForZSet().score(targetKey, "22");
58+
59+
assertThat(s11).isNotNull();
60+
assertThat(s22).isNotNull();
61+
62+
assertThat(s11).isCloseTo(0.6, Assertions.offset(1e-9));
63+
assertThat(s22).isCloseTo(0.15, Assertions.offset(1e-9));
64+
65+
// TTL 확인 (expire를 먼저 걸고 unionstore를 하므로 TTL이 존재해야 함)
66+
Long ttl = redisTemplate.getExpire(targetKey); // seconds
67+
assertThat(ttl).isNotNull();
68+
assertThat(ttl).isGreaterThan(0);
69+
}
70+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
package com.loopers.ranking;
2+
3+
import java.time.LocalDate;
4+
import java.util.List;
5+
6+
public record DailyRankingResponse(LocalDate date, List<RankingEntry> rankingEntries) {
7+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
package com.loopers.ranking;
2+
3+
public record RankingEntry(long productId, double score) {
4+
}

0 commit comments

Comments
 (0)