Skip to content

Commit 6859902

Browse files
authored
Merge pull request #138 from yeonjiyeon/feature/week5
[volume-5] 인덱스 및 Redis 캐시 적용
2 parents 3fa4863 + fea577f commit 6859902

14 files changed

Lines changed: 507 additions & 14 deletions

File tree

apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,27 +23,26 @@ public class LikeFacade {
2323
public LikeInfo like(long userId, long productId) {
2424
Optional<Like> existingLike = likeService.findLike(userId, productId);
2525

26-
2726
if (existingLike.isPresent()) {
2827
Product product = productService.getProduct(productId);
2928
return LikeInfo.from(existingLike.get(), product.getLikeCount());
3029
}
3130

3231
for (int i = 0; i < RETRY_COUNT; i++) {
3332
try {
33+
return transactionTemplate.execute(status -> {
34+
Like newLike = likeService.save(userId, productId);
35+
int updatedLikeCount = productService.increaseLikeCount(productId);
36+
return LikeInfo.from(newLike, updatedLikeCount);
37+
});
3438

35-
Like newLike = likeService.save(userId, productId);
36-
int updatedLikeCount = productService.increaseLikeCount(productId);
37-
38-
return LikeInfo.from(newLike, updatedLikeCount);
3939
} catch (ObjectOptimisticLockingFailureException e) {
4040
if (i == RETRY_COUNT - 1) {
4141
throw e;
4242
}
4343
sleep(50);
4444
}
4545
}
46-
4746
throw new IllegalStateException("좋아요 처리 재시도 횟수를 초과했습니다.");
4847
}
4948

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

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,11 @@
1111
@RequiredArgsConstructor
1212
@Component
1313
public class ProductFacade {
14+
1415
private final ProductService productService;
1516
private final BrandService brandService;
1617

17-
public Page<ProductInfo> getProductInfo(Pageable pageable) {
18+
public Page<ProductInfo> getProductsInfo(Pageable pageable) {
1819
Page<Product> products = productService.getProducts(pageable);
1920
return products.map(product -> {
2021
String brandName = brandService.getBrand(product.getBrandId())
@@ -23,4 +24,12 @@ public Page<ProductInfo> getProductInfo(Pageable pageable) {
2324
});
2425
}
2526

27+
public ProductInfo getProductInfo(long id) {
28+
Product product = productService.getProduct(id);
29+
String brandName = brandService.getBrand(product.getBrandId())
30+
.getName();
31+
32+
return ProductInfo.from(product, brandName);
33+
}
34+
2635
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
package com.loopers.config.redis;
2+
3+
import com.fasterxml.jackson.annotation.JsonCreator;
4+
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
5+
import com.fasterxml.jackson.annotation.JsonProperty;
6+
import com.fasterxml.jackson.databind.JsonNode;
7+
import com.fasterxml.jackson.databind.ObjectMapper;
8+
import com.fasterxml.jackson.databind.SerializationFeature;
9+
import com.fasterxml.jackson.databind.jsontype.BasicPolymorphicTypeValidator;
10+
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
11+
import org.springframework.context.annotation.Bean;
12+
import org.springframework.context.annotation.Configuration;
13+
import org.springframework.data.domain.PageImpl;
14+
import org.springframework.data.redis.connection.RedisConnectionFactory;
15+
import org.springframework.data.redis.core.RedisTemplate;
16+
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
17+
import org.springframework.data.redis.serializer.StringRedisSerializer;
18+
19+
import java.util.List;
20+
21+
@Configuration
22+
public class RedisConfig {
23+
24+
@Bean
25+
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory) {
26+
RedisTemplate<String, Object> template = new RedisTemplate<>();
27+
template.setConnectionFactory(connectionFactory);
28+
29+
ObjectMapper objectMapper = new ObjectMapper();
30+
31+
objectMapper.registerModule(new JavaTimeModule());
32+
objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
33+
34+
objectMapper.activateDefaultTyping(
35+
BasicPolymorphicTypeValidator.builder()
36+
.allowIfBaseType(Object.class)
37+
.build(),
38+
ObjectMapper.DefaultTyping.EVERYTHING
39+
);
40+
41+
objectMapper.addMixIn(PageImpl.class, PageImplMixin.class);
42+
43+
GenericJackson2JsonRedisSerializer serializer = new GenericJackson2JsonRedisSerializer(objectMapper);
44+
45+
template.setKeySerializer(new StringRedisSerializer());
46+
template.setValueSerializer(serializer);
47+
template.setHashKeySerializer(new StringRedisSerializer());
48+
template.setHashValueSerializer(serializer);
49+
50+
return template;
51+
}
52+
53+
@JsonIgnoreProperties(ignoreUnknown = true)
54+
abstract static class PageImplMixin<T> {
55+
@JsonCreator
56+
public PageImplMixin(@JsonProperty("content") List<T> content,
57+
@JsonProperty("pageable") JsonNode pageable,
58+
@JsonProperty("total") long total) {
59+
}
60+
}
61+
}

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import com.loopers.domain.money.Money;
55
import com.loopers.support.error.CoreException;
66
import com.loopers.support.error.ErrorType;
7+
import jakarta.persistence.AttributeOverride;
78
import jakarta.persistence.Column;
89
import jakarta.persistence.Embedded;
910
import jakarta.persistence.Entity;
@@ -26,7 +27,7 @@ public class Product extends BaseEntity {
2627
@Column(nullable = false)
2728
private String description;
2829

29-
@Column(nullable = false)
30+
@AttributeOverride(name = "value", column = @Column(name = "price"))
3031
@Embedded
3132
private Money price;
3233

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

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
package com.loopers.domain.product;
22

33
import com.loopers.domain.order.OrderItem;
4+
import com.loopers.support.cache.RedisCacheHandler;
45
import com.loopers.support.error.CoreException;
56
import com.loopers.support.error.ErrorType;
7+
import java.time.Duration;
68
import java.util.List;
79
import java.util.Map;
810
import java.util.stream.Collectors;
@@ -18,19 +20,38 @@ public class ProductService {
1820

1921
private final ProductRepository productRepository;
2022

23+
private final RedisCacheHandler redisCacheHandler;
2124

2225
public Page<Product> getProducts(Pageable pageable) {
23-
return productRepository.findAll(pageable);
26+
String key = makeCacheKey("product:list", pageable);
27+
return redisCacheHandler.getOrLoad(
28+
key,
29+
Duration.ofMinutes(5),
30+
Page.class,
31+
() -> productRepository.findAll(pageable)
32+
);
2433
}
2534

2635
@Transactional
2736
public Product getProduct(Long id) {
28-
return productRepository.findById(id).orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다."));
37+
String key = "product:detail:" + id;
38+
return redisCacheHandler.getOrLoad(
39+
key,
40+
Duration.ofMinutes(10),
41+
Product.class,
42+
() -> productRepository.findById(id)
43+
.orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다."))
44+
);
2945
}
3046

3147
public Page<Product> getProductsByBrandId(Long brandId, Pageable pageable) {
32-
33-
return productRepository.findByBrandId(brandId, pageable);
48+
String key = makeCacheKey("product:list:brand:" + brandId, pageable);
49+
return redisCacheHandler.getOrLoad(
50+
key,
51+
Duration.ofMinutes(5),
52+
Page.class,
53+
() -> productRepository.findByBrandId(brandId, pageable)
54+
);
3455
}
3556

3657

@@ -69,4 +90,18 @@ public int decreaseLikeCount(Long productId) {
6990

7091
return product.decreaseLikeCount();
7192
}
93+
94+
private String makeCacheKey(String prefix, Pageable pageable) {
95+
StringBuilder sb = new StringBuilder();
96+
sb.append(prefix);
97+
sb.append(":page:").append(pageable.getPageNumber());
98+
sb.append(":size:").append(pageable.getPageSize());
99+
100+
if (pageable.getSort().isSorted()) {
101+
pageable.getSort().forEach(order ->
102+
sb.append(":sort:").append(order.getProperty()).append(",").append(order.getDirection())
103+
);
104+
}
105+
return sb.toString();
106+
}
72107
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
package com.loopers.support.cache;
2+
3+
import com.loopers.support.page.PageWrapper;
4+
import java.time.Duration;
5+
import java.util.function.Supplier;
6+
import lombok.RequiredArgsConstructor;
7+
import org.springframework.data.domain.Page;
8+
import org.springframework.data.redis.core.RedisTemplate;
9+
import org.springframework.stereotype.Component;
10+
11+
@RequiredArgsConstructor
12+
@Component
13+
public class RedisCacheHandler {
14+
private final RedisTemplate<String, Object> redisTemplate;
15+
16+
/**
17+
* 캐시 조회 -> (없거나 에러나면) -> DB 조회 -> 캐시 저장
18+
* @param key 캐시 키
19+
* @param ttl 만료 시간
20+
* @param type 반환 타입 (캐스팅용)
21+
* @param dbFetcher DB 조회 로직 (람다)
22+
*/
23+
public <T> T getOrLoad(String key, Duration ttl, Class<T> type, Supplier<T> dbFetcher) {
24+
try {
25+
Object cachedData = redisTemplate.opsForValue().get(key);
26+
if (cachedData != null) {
27+
28+
if (cachedData instanceof PageWrapper) {
29+
return (T) ((PageWrapper<?>) cachedData).toPage();
30+
}
31+
return type.cast(cachedData);
32+
}
33+
} catch (Exception e) {
34+
}
35+
36+
T result = dbFetcher.get();
37+
38+
if (result != null) {
39+
try {
40+
Object dataToSave = result;
41+
if (result instanceof Page) {
42+
dataToSave = new PageWrapper<>((Page<?>) result);
43+
}
44+
45+
redisTemplate.opsForValue().set(key, dataToSave, ttl);
46+
} catch (Exception e) {
47+
}
48+
}
49+
50+
return result;
51+
}
52+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
package com.loopers.support.page;
2+
3+
import java.util.List;
4+
import org.springframework.data.domain.Page;
5+
import org.springframework.data.domain.PageImpl;
6+
import org.springframework.data.domain.PageRequest;
7+
8+
public class PageWrapper<T> {
9+
private List<T> content;
10+
private long totalElements;
11+
private int pageNumber;
12+
private int pageSize;
13+
14+
public PageWrapper() {}
15+
16+
public PageWrapper(Page<T> page) {
17+
this.content = page.getContent();
18+
this.totalElements = page.getTotalElements();
19+
this.pageNumber = page.getNumber();
20+
this.pageSize = page.getSize();
21+
}
22+
23+
public Page<T> toPage() {
24+
return new PageImpl<>(content, PageRequest.of(pageNumber, pageSize), totalElements);
25+
}
26+
27+
public List<T> getContent() { return content; }
28+
public long getTotalElements() { return totalElements; }
29+
public int getPageNumber() { return pageNumber; }
30+
public int getPageSize() { return pageSize; }
31+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
datasource:
2+
mysql-jpa:
3+
main:
4+
jdbc-url: "jdbc:mysql://127.0.0.1:3306/loopers?serverTimezone=Asia/Seoul&characterEncoding=UTF-8&useSSL=false&allowPublicKeyRetrieval=true"
5+
username: application
6+
password: application
7+
driver-class-name: com.mysql.cj.jdbc.Driver
8+
maximum-pool-size: 10
9+
minimum-idle: 5
10+
pool-name: MyHikariCP
11+
12+
spring:
13+
jpa:
14+
hibernate:
15+
ddl-auto: update
16+
show-sql: true
17+
properties:
18+
hibernate:
19+
format_sql: true
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
datasource:
2+
redis:
3+
database: 0
4+
master:
5+
host: 127.0.0.1
6+
port: 6379
7+
replicas: []

apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeIntegrationTest.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ void return_productInfoPage_withBrandNames() {
5656
Pageable pageable = PageRequest.of(0, 20, Sort.by(Sort.Direction.DESC, "createdAt"));
5757

5858
// act
59-
Page<ProductInfo> result = productFacade.getProductInfo(pageable);
59+
Page<ProductInfo> result = productFacade.getProductsInfo(pageable);
6060

6161
// assert
6262
assertAll(
@@ -72,7 +72,7 @@ void return_emptyPage_whenNoProductsExist() {
7272
Pageable pageable = PageRequest.of(0, 10);
7373

7474
// act
75-
Page<ProductInfo> result = productFacade.getProductInfo(pageable);
75+
Page<ProductInfo> result = productFacade.getProductsInfo(pageable);
7676

7777
// assert
7878
assertThat(result.isEmpty()).isTrue();

0 commit comments

Comments
 (0)