Skip to content

Commit bf1b6ca

Browse files
authored
Merge pull request #134 from rnqhstmd/round5
[volume-5] Redis 캐시 및 인덱스 적용
2 parents 9b09a7d + 8ee1746 commit bf1b6ca

38 files changed

Lines changed: 1748 additions & 65 deletions

apps/commerce-api/build.gradle.kts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,4 +19,6 @@ dependencies {
1919
// test-fixtures
2020
testImplementation(testFixtures(project(":modules:jpa")))
2121
testImplementation(testFixtures(project(":modules:redis")))
22+
23+
testImplementation("net.datafaker:datafaker:2.0.2")
2224
}
Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.loopers.application.like;
22

3+
import com.loopers.application.product.ProductCacheService;
34
import com.loopers.domain.like.LikeService;
45
import com.loopers.domain.product.Product;
56
import com.loopers.domain.product.ProductService;
@@ -11,24 +12,38 @@
1112

1213
@Component
1314
@RequiredArgsConstructor
14-
@Transactional(readOnly = true)
1515
public class LikeFacade {
1616

1717
private final LikeService likeService;
1818
private final UserService userService;
1919
private final ProductService productService;
20+
private final ProductCacheService productCacheService;
2021

2122
@Transactional
2223
public void addLike(String userId, Long productId) {
2324
User user = userService.getUserByUserId(userId);
24-
Product product = productService.getProduct(productId);
25-
likeService.addLike(user, product);
25+
Product product = productService.getProductWithPessimisticLock(productId);
26+
27+
boolean isNewLike = likeService.addLike(user, product);
28+
29+
if (isNewLike) {
30+
productService.incrementLikeCount(productId);
31+
productCacheService.evictProductDetailCache(productId);
32+
productCacheService.evictProductListCachesByLikesSort();
33+
}
2634
}
2735

2836
@Transactional
2937
public void removeLike(String userId, Long productId) {
3038
User user = userService.getUserByUserId(userId);
31-
Product product = productService.getProduct(productId);
32-
likeService.removeLike(user, product);
39+
Product product = productService.getProductWithPessimisticLock(productId);
40+
41+
boolean wasRemoved = likeService.removeLike(user, product);
42+
43+
if (wasRemoved) {
44+
productService.decrementLikeCount(productId);
45+
productCacheService.evictProductDetailCache(productId);
46+
productCacheService.evictProductListCachesByLikesSort();
47+
}
3348
}
3449
}
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
package com.loopers.application.product;
2+
3+
import com.loopers.config.redis.RedisConfig;
4+
import com.loopers.domain.product.Product;
5+
import com.loopers.domain.product.ProductSearchCondition;
6+
import com.loopers.domain.product.ProductService;
7+
import org.springframework.beans.factory.annotation.Qualifier;
8+
import org.springframework.beans.factory.annotation.Value;
9+
import org.springframework.data.domain.Page;
10+
import org.springframework.data.redis.core.RedisTemplate;
11+
import org.springframework.data.redis.core.ScanOptions;
12+
import org.springframework.stereotype.Service;
13+
14+
import java.time.Duration;
15+
import java.util.HashSet;
16+
import java.util.Map;
17+
import java.util.Set;
18+
import java.util.stream.Collectors;
19+
20+
@Service
21+
public class ProductCacheService {
22+
23+
private final ProductService productService;
24+
private final RedisTemplate<String, Object> redisTemplate;
25+
26+
@Value("${cache.version.product}")
27+
private String cacheVersion;
28+
29+
private String productDetailKeyPrefix() {
30+
return "product:" + cacheVersion + ":detail:";
31+
}
32+
33+
private String productListKeyPrefix() {
34+
return "product:" + cacheVersion + ":list:";
35+
}
36+
37+
private static final Duration DETAIL_TTL = Duration.ofMinutes(10);
38+
private static final Duration LIST_TTL = Duration.ofMinutes(5);
39+
40+
public ProductCacheService(
41+
ProductService productService,
42+
@Qualifier(RedisConfig.REDIS_TEMPLATE_CACHE) RedisTemplate<String, Object> redisTemplate
43+
) {
44+
this.productService = productService;
45+
this.redisTemplate = redisTemplate;
46+
}
47+
48+
public ProductDetailInfo getProductDetailWithCache(Long productId) {
49+
String key = productDetailKeyPrefix() + productId;
50+
51+
Object cached = redisTemplate.opsForValue().get(key);
52+
if (cached instanceof ProductDetailInfo info) {
53+
return info;
54+
}
55+
56+
Product product = productService.getProduct(productId);
57+
ProductDetailInfo info = ProductDetailInfo.of(product, product.getLikeCount());
58+
59+
redisTemplate.opsForValue().set(key, info, DETAIL_TTL);
60+
61+
return info;
62+
}
63+
64+
public ProductListInfo getProductListWithCache(String cacheKey, ProductSearchCondition condition) {
65+
String key = productListKeyPrefix() + cacheKey;
66+
67+
Object cached = redisTemplate.opsForValue().get(key);
68+
if (cached instanceof ProductListInfo info) {
69+
return info;
70+
}
71+
72+
Page<Product> productPage = productService.getProducts(condition);
73+
Map<Long, Long> likeCountMap = productPage.getContent().stream()
74+
.collect(Collectors.toMap(
75+
Product::getId,
76+
Product::getLikeCount
77+
));
78+
ProductListInfo info = ProductListInfo.of(productPage, likeCountMap);
79+
80+
redisTemplate.opsForValue().set(key, info, LIST_TTL);
81+
82+
return info;
83+
}
84+
85+
public void evictProductDetailCache(Long productId) {
86+
String key = productDetailKeyPrefix() + productId;
87+
redisTemplate.delete(key);
88+
}
89+
90+
public void evictProductListCachesByLikesSort() {
91+
String pattern = productListKeyPrefix() + "*:sort:likes_desc:*";
92+
Set<String> keys = scanRedisKeys(pattern);
93+
if (!keys.isEmpty()) {
94+
redisTemplate.delete(keys);
95+
}
96+
}
97+
98+
public void evictAllProductListCaches() {
99+
String pattern = productListKeyPrefix() + "*";
100+
Set<String> keys = scanRedisKeys(pattern);
101+
if (!keys.isEmpty()) {
102+
redisTemplate.delete(keys);
103+
}
104+
}
105+
106+
public void evictAllProductCaches() {
107+
Set<String> detailKeys = scanRedisKeys(productDetailKeyPrefix() + "*");
108+
Set<String> listKeys = scanRedisKeys(productListKeyPrefix() + "*");
109+
110+
long deletedCount = 0;
111+
if (!detailKeys.isEmpty()) {
112+
deletedCount += redisTemplate.delete(detailKeys);
113+
}
114+
if (!listKeys.isEmpty()) {
115+
deletedCount += redisTemplate.delete(listKeys);
116+
}
117+
}
118+
119+
private Set<String> scanRedisKeys(String pattern) {
120+
Set<String> keys = new HashSet<>();
121+
ScanOptions options = ScanOptions.scanOptions()
122+
.match(pattern)
123+
.count(100)
124+
.build();
125+
126+
try {
127+
var connectionFactory = redisTemplate.getConnectionFactory();
128+
if (connectionFactory == null) {
129+
return keys;
130+
}
131+
var connection = connectionFactory.getConnection();
132+
try (var cursor = connection.scan(options)) {
133+
while (cursor.hasNext()) {
134+
keys.add(new String(cursor.next()));
135+
}
136+
}
137+
} catch (Exception e) {
138+
return keys;
139+
}
140+
return keys;
141+
}
142+
}

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
import com.loopers.domain.product.Product;
44

5+
import java.io.Serializable;
6+
57
public record ProductDetailInfo(
68
Long productId,
79
String productName,
@@ -10,7 +12,7 @@ public record ProductDetailInfo(
1012
Long brandId,
1113
String brandName,
1214
Long likeCount
13-
) {
15+
) implements Serializable {
1416
public static ProductDetailInfo of(Product product, Long likeCount) {
1517
return new ProductDetailInfo(
1618
product.getId(),
Lines changed: 13 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,37 @@
11
package com.loopers.application.product;
22

3-
import com.loopers.domain.brand.BrandService;
4-
import com.loopers.domain.like.LikeService;
5-
import com.loopers.domain.product.Product;
63
import com.loopers.domain.product.ProductSearchCondition;
7-
import com.loopers.domain.product.ProductService;
84
import lombok.RequiredArgsConstructor;
9-
import org.springframework.data.domain.Page;
5+
import lombok.extern.slf4j.Slf4j;
106
import org.springframework.stereotype.Component;
117
import org.springframework.transaction.annotation.Transactional;
128

13-
import java.util.Map;
14-
9+
@Slf4j
1510
@Component
1611
@RequiredArgsConstructor
1712
@Transactional(readOnly = true)
1813
public class ProductFacade {
1914

20-
private final ProductService productService;
21-
private final LikeService likeService;
15+
private final ProductCacheService productCacheService;
2216

2317
public ProductDetailInfo getProductDetail(Long productId) {
24-
Product product = productService.getProduct(productId);
25-
Long likeCount = likeService.getLikeCount(product);
26-
return ProductDetailInfo.of(product, likeCount);
18+
return productCacheService.getProductDetailWithCache(productId);
2719
}
2820

2921
public ProductListInfo getProducts(ProductGetListCommand command) {
22+
String cacheKey = String.format("brand:%s:sort:%s:page:%d:size:%d",
23+
command.brandId() != null ? command.brandId() : "all",
24+
command.getSortType().name().toLowerCase(),
25+
command.pageable().getPageNumber(),
26+
command.pageable().getPageSize()
27+
);
28+
3029
ProductSearchCondition condition = new ProductSearchCondition(
3130
command.brandId(),
31+
command.getSortType(),
3232
command.pageable()
3333
);
3434

35-
Page<Product> productPage = productService.getProducts(condition);
36-
Map<Long, Long> likeCountMap = likeService.getLikeCounts(productPage.getContent());
37-
return ProductListInfo.of(productPage, likeCountMap);
35+
return productCacheService.getProductListWithCache(cacheKey, condition);
3836
}
3937
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
11
package com.loopers.application.product;
22

3+
import com.loopers.domain.product.ProductSortType;
34
import org.springframework.data.domain.Pageable;
45

56
public record ProductGetListCommand(
67
Long brandId,
8+
String sort,
79
Pageable pageable
810
) {
11+
public ProductSortType getSortType() {
12+
return ProductSortType.from(sort);
13+
}
914
}

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import com.loopers.domain.product.Product;
44
import org.springframework.data.domain.Page;
55

6+
import java.io.Serializable;
67
import java.util.List;
78
import java.util.Map;
89

@@ -12,7 +13,7 @@ public record ProductListInfo(
1213
int size,
1314
long totalElements,
1415
int totalPages
15-
) {
16+
) implements Serializable {
1617
public static ProductListInfo of(Page<Product> productPage, Map<Long, Long> likeCountMap) {
1718
List<ProductContent> contents = productPage.getContent().stream()
1819
.map(product -> ProductContent.of(
@@ -37,7 +38,7 @@ public record ProductContent(
3738
Long brandId,
3839
String brandName,
3940
Long likeCount
40-
) {
41+
) implements Serializable {
4142
public static ProductContent of(Product product, Long likeCount) {
4243
return new ProductContent(
4344
product.getId(),

apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,19 @@
1010

1111
@Entity
1212
@Getter
13-
@Table(name = "likes", uniqueConstraints = {
14-
@UniqueConstraint(
15-
name = "uk_user_product",
16-
columnNames = {"user_id", "product_id"}
17-
)
18-
})
13+
@Table(
14+
name = "likes",
15+
uniqueConstraints = {
16+
@UniqueConstraint(
17+
name = "uk_user_product",
18+
columnNames = {"user_id", "product_id"}
19+
)
20+
},
21+
indexes = {
22+
@Index(name = "idx_user_id", columnList = "user_id"),
23+
@Index(name = "idx_product_id", columnList = "product_id")
24+
}
25+
)
1926
@NoArgsConstructor(access = AccessLevel.PROTECTED)
2027
public class Like extends BaseEntity {
2128

0 commit comments

Comments
 (0)