Skip to content

Commit 52a4afe

Browse files
committed
feature:상품목록, 상품 상세 캐시 추가 및 관련 테스트 코드 작성
1 parent 4226840 commit 52a4afe

4 files changed

Lines changed: 200 additions & 7 deletions

File tree

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
}

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
}

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();
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
package com.loopers.domain.product;
2+
3+
import static org.junit.jupiter.api.Assertions.assertAll;
4+
import static org.junit.jupiter.api.Assertions.assertEquals;
5+
import static org.junit.jupiter.api.Assertions.assertTrue;
6+
import static org.mockito.Mockito.doThrow;
7+
8+
import com.loopers.domain.money.Money;
9+
import com.loopers.infrastructure.product.ProductJpaRepository;
10+
import com.loopers.utils.DatabaseCleanUp;
11+
import java.util.Set;
12+
import org.junit.jupiter.api.AfterEach;
13+
import org.junit.jupiter.api.DisplayName;
14+
import org.junit.jupiter.api.Nested;
15+
import org.junit.jupiter.api.Test;
16+
import org.springframework.beans.factory.annotation.Autowired;
17+
import org.springframework.boot.test.context.SpringBootTest;
18+
import org.springframework.data.domain.Page;
19+
import org.springframework.data.domain.PageRequest;
20+
import org.springframework.data.domain.Pageable;
21+
import org.springframework.data.domain.Sort;
22+
import org.springframework.data.redis.RedisConnectionFailureException;
23+
import org.springframework.data.redis.core.RedisTemplate;
24+
import org.springframework.data.redis.core.ValueOperations;
25+
import org.springframework.test.context.bean.override.mockito.MockitoSpyBean;
26+
27+
@SpringBootTest
28+
public class ProductCacheTest {
29+
@Autowired
30+
ProductService productService;
31+
32+
@Autowired
33+
ProductRepository productRepository;
34+
35+
@Autowired
36+
private ProductJpaRepository productJpaRepository;
37+
38+
@Autowired
39+
DatabaseCleanUp databaseCleanUp;
40+
41+
@MockitoSpyBean
42+
RedisTemplate<String, Object> redisTemplate;
43+
44+
@AfterEach
45+
void tearDown() {
46+
databaseCleanUp.truncateAllTables();
47+
48+
Set<String> keys = redisTemplate.keys("product:*");
49+
if (!keys.isEmpty()) {
50+
redisTemplate.delete(keys);
51+
}
52+
}
53+
54+
@DisplayName("캐시 동작 검증")
55+
@Nested
56+
class Cache {
57+
58+
@DisplayName("DB 조회 후 결과가 Redis에 저장되며, DB 데이터가 삭제되어도 캐시에서 조회된다.")
59+
@Test
60+
void return_cachedData_whenDbDataDeleted() {
61+
// arrange
62+
Long brandId = 1L;
63+
Product product = new Product(brandId, "캐시상품", "설명", new Money(10000L), 10);
64+
productRepository.save(product);
65+
66+
Pageable pageable = PageRequest.of(0, 10, Sort.by("id").descending());
67+
String expectedKey = "product:list:brand:" + brandId + ":page:0:size:10:sort:id,DESC";
68+
69+
// act 1: 첫 번째 조회 (Cache Miss -> DB 조회 -> Redis 저장)
70+
productService.getProductsByBrandId(brandId, pageable);
71+
72+
// assert 1: Redis에 키가 생성되었는지 확인
73+
assertTrue(redisTemplate.hasKey(expectedKey), "Redis에 캐시 키가 생성되어야 함");
74+
75+
// act 2: DB 데이터 강제 삭제 (변수 창출)
76+
productJpaRepository.deleteAll();
77+
78+
// act 3: 두 번째 조회 (Cache Hit -> Redis 조회)
79+
Page<Product> secondResult = productService.getProductsByBrandId(brandId, pageable);
80+
81+
// assert 2: DB는 비었지만 결과가 나와야 함
82+
assertAll("캐시 조회 검증",
83+
() -> assertEquals(1, secondResult.getTotalElements(), "DB 삭제 후에도 1개가 조회되어야 함"),
84+
() -> assertEquals("캐시상품", secondResult.getContent().get(0).getName(), "캐시된 상품명 일치 확인")
85+
);
86+
}
87+
88+
@DisplayName("Redis 연결 장애가 발생해도 서비스는 DB를 통해 정상적으로 데이터를 반환한다 (Fail-Safe).")
89+
@Test
90+
void return_dataFromDb_whenRedisConnectionFails() {
91+
// arrange
92+
Long brandId = 2L;
93+
Product product = new Product(brandId, "장애대응상품", "설명", new Money(20000L), 20);
94+
productRepository.save(product);
95+
96+
Pageable pageable = PageRequest.of(0, 10);
97+
98+
99+
ValueOperations<String, Object> ops = redisTemplate.opsForValue();
100+
101+
doThrow(new RedisConnectionFailureException("Redis 연결 불가"))
102+
.when(redisTemplate).opsForValue();
103+
104+
// act
105+
Page<Product> result = productService.getProductsByBrandId(brandId, pageable);
106+
107+
// assert
108+
assertAll("장애 대응 검증",
109+
() -> assertEquals(1, result.getTotalElements(), "Redis 에러 시에도 데이터가 반환되어야 함"),
110+
() -> assertEquals("장애대응상품", result.getContent().get(0).getName())
111+
);
112+
}
113+
}
114+
115+
@Nested
116+
@DisplayName("🔍 상품 상세 조회 캐시 검증")
117+
class CacheDetail {
118+
119+
@Test
120+
@DisplayName("상세 조회 시 캐시가 저장되고, DB 삭제 후에도 조회된다")
121+
void return_cachedProduct_whenDbDataDeleted() {
122+
// arrange
123+
Long brandId = 1L;
124+
Product product = new Product(brandId, "상세보기 상품", "설명", new Money(5000L), 10);
125+
Product savedProduct = productRepository.save(product);
126+
Long productId = savedProduct.getId();
127+
128+
String expectedKey = "product:detail:" + productId;
129+
130+
// act 1: 첫 번째 조회 (Cache Miss -> DB 조회 -> Redis 저장)
131+
productService.getProduct(productId);
132+
133+
// assert 1: Redis에 키가 생성되었는지 확인
134+
assertTrue(redisTemplate.hasKey(expectedKey), "상세 조회 후 Redis 키가 생성되어야 함");
135+
136+
// act 2: DB 데이터 강제 삭제
137+
productJpaRepository.deleteAll();
138+
139+
// act 3: 두 번째 조회 (Cache Hit -> Redis 조회)
140+
Product result = productService.getProduct(productId);
141+
142+
// assert 2: DB는 비었지만 결과가 나와야 함
143+
assertAll("상세 조회 캐시 검증",
144+
() -> assertEquals(savedProduct.getId(), result.getId(), "ID가 일치해야 함"),
145+
() -> assertEquals("상세보기 상품", result.getName(), "캐시된 상품명 일치 확인")
146+
);
147+
}
148+
}
149+
}

0 commit comments

Comments
 (0)