Skip to content

Commit 5054a54

Browse files
authored
[volume-5] 인덱스와 캐싱을 통한 읽기 성능 최적화 (#125)
* Feature/catalog caching (#20) * feat: 상품 조회 결과를 캐시로 관리하는 서비스 로직 추가 * feat: 상품 조회하는 서비스 로직에 Cache Aside 패턴 적용 * Feature/cache eviction (#21) * feature: 상품 좋아요 수에 로컬캐시 적용 (#21) * test: LikeFacade 테스트 코드 수정 * feat: Product 엔티티에 인덱스 추가 (#22)
1 parent 197dda2 commit 5054a54

6 files changed

Lines changed: 384 additions & 17 deletions

File tree

apps/commerce-api/src/main/java/com/loopers/application/catalog/CatalogProductFacade.java

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,12 @@
2828
public class CatalogProductFacade {
2929
private final ProductRepository productRepository;
3030
private final BrandRepository brandRepository;
31+
private final ProductCacheService productCacheService;
3132

3233
/**
3334
* 상품 목록을 조회합니다.
3435
* <p>
36+
* Redis 캐시를 확인하고, 캐시에 없으면 DB에서 조회한 후 캐시에 저장합니다.
3537
* 배치 조회를 통해 N+1 쿼리 문제를 해결합니다.
3638
* </p>
3739
*
@@ -42,11 +44,24 @@ public class CatalogProductFacade {
4244
* @return 상품 목록 조회 결과
4345
*/
4446
public ProductInfoList getProducts(Long brandId, String sort, int page, int size) {
47+
// sort 기본값 처리 (컨트롤러와 동일하게 "latest" 사용)
48+
String normalizedSort = (sort != null && !sort.isBlank()) ? sort : "latest";
49+
50+
// 캐시에서 조회 시도
51+
ProductInfoList cachedResult = productCacheService.getCachedProductList(brandId, normalizedSort, page, size);
52+
if (cachedResult != null) {
53+
return cachedResult;
54+
}
55+
56+
// 캐시에 없으면 DB에서 조회
4557
long totalCount = productRepository.countAll(brandId);
46-
List<Product> products = productRepository.findAll(brandId, sort, page, size);
58+
List<Product> products = productRepository.findAll(brandId, normalizedSort, page, size);
4759

4860
if (products.isEmpty()) {
49-
return new ProductInfoList(List.of(), totalCount, page, size);
61+
ProductInfoList emptyResult = new ProductInfoList(List.of(), totalCount, page, size);
62+
// 캐시 저장
63+
productCacheService.cacheProductList(brandId, normalizedSort, page, size, emptyResult);
64+
return emptyResult;
5065
}
5166

5267
// ✅ 배치 조회로 N+1 쿼리 문제 해결
@@ -74,17 +89,33 @@ public ProductInfoList getProducts(Long brandId, String sort, int page, int size
7489
})
7590
.toList();
7691

77-
return new ProductInfoList(productsInfo, totalCount, page, size);
92+
ProductInfoList result = new ProductInfoList(productsInfo, totalCount, page, size);
93+
94+
// 캐시 저장
95+
productCacheService.cacheProductList(brandId, normalizedSort, page, size, result);
96+
97+
// 로컬 캐시의 좋아요 수 델타 적용 (DB 조회 결과에도 델타 반영)
98+
return productCacheService.applyLikeCountDelta(result);
7899
}
79100

80101
/**
81102
* 상품 정보를 조회합니다.
103+
* <p>
104+
* Redis 캐시를 먼저 확인하고, 캐시에 없으면 DB에서 조회한 후 캐시에 저장합니다.
105+
* </p>
82106
*
83107
* @param productId 상품 ID
84108
* @return 상품 정보와 좋아요 수
85109
* @throws CoreException 상품을 찾을 수 없는 경우
86110
*/
87111
public ProductInfo getProduct(Long productId) {
112+
// 캐시에서 조회 시도
113+
ProductInfo cachedResult = productCacheService.getCachedProduct(productId);
114+
if (cachedResult != null) {
115+
return cachedResult;
116+
}
117+
118+
// 캐시에 없으면 DB에서 조회
88119
Product product = productRepository.findById(productId)
89120
.orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다."));
90121

@@ -98,7 +129,13 @@ public ProductInfo getProduct(Long productId) {
98129
// ProductDetail 생성 (Aggregate 경계 준수: Brand 엔티티 대신 brandName만 전달)
99130
ProductDetail productDetail = ProductDetail.from(product, brand.getName(), likesCount);
100131

101-
return new ProductInfo(productDetail);
132+
ProductInfo result = new ProductInfo(productDetail);
133+
134+
// 캐시에 저장
135+
productCacheService.cacheProduct(productId, result);
136+
137+
// 로컬 캐시의 좋아요 수 델타 적용 (DB 조회 결과에도 델타 반영)
138+
return productCacheService.applyLikeCountDelta(result);
102139
}
103140

104141
}
Lines changed: 272 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,272 @@
1+
package com.loopers.application.catalog;
2+
3+
import com.fasterxml.jackson.core.type.TypeReference;
4+
import com.fasterxml.jackson.databind.ObjectMapper;
5+
import com.loopers.domain.product.ProductDetail;
6+
import lombok.RequiredArgsConstructor;
7+
import org.springframework.data.redis.core.RedisTemplate;
8+
import org.springframework.stereotype.Service;
9+
10+
import java.time.Duration;
11+
import java.util.List;
12+
import java.util.concurrent.ConcurrentHashMap;
13+
import java.util.stream.Collectors;
14+
15+
/**
16+
* 상품 조회 결과를 Redis에 캐시하는 서비스.
17+
* <p>
18+
* 상품 목록 조회와 상품 상세 조회 결과를 캐시하여 성능을 향상시킵니다.
19+
* </p>
20+
* <p>
21+
* <b>캐시 전략:</b>
22+
* <ul>
23+
* <li><b>상품 목록:</b> 첫 3페이지만 캐시하여 메모리 사용량 최적화</li>
24+
* <li><b>상품 상세:</b> 모든 상품 상세 정보 캐시</li>
25+
* </ul>
26+
* </p>
27+
*
28+
* @author Loopers
29+
*/
30+
@Service
31+
@RequiredArgsConstructor
32+
public class ProductCacheService {
33+
34+
private static final String CACHE_KEY_PREFIX_LIST = "product:list:";
35+
private static final String CACHE_KEY_PREFIX_DETAIL = "product:detail:";
36+
private static final Duration CACHE_TTL = Duration.ofMinutes(1); // 1분 TTL
37+
38+
private final RedisTemplate<String, String> redisTemplate;
39+
private final ObjectMapper objectMapper;
40+
41+
/**
42+
* 로컬 캐시: 상품별 좋아요 수 델타 (productId -> likeCount delta)
43+
* <p>
44+
* 좋아요 추가/취소 시 델타를 저장하고, 캐시 조회 시 델타를 적용하여 반환합니다.
45+
* 배치 집계 후에는 초기화됩니다.
46+
* </p>
47+
*/
48+
private final ConcurrentHashMap<Long, Long> likeCountDeltaCache = new ConcurrentHashMap<>();
49+
50+
/**
51+
* 상품 목록 조회 결과를 캐시에서 조회합니다.
52+
* <p>
53+
* 페이지 번호와 관계없이 캐시를 확인하고, 캐시에 있으면 반환합니다.
54+
* 캐시에 없으면 null을 반환하여 DB 조회를 유도합니다.
55+
* </p>
56+
* <p>
57+
* 로컬 캐시의 좋아요 수 델타를 적용하여 반환합니다.
58+
* </p>
59+
*
60+
* @param brandId 브랜드 ID (null이면 전체)
61+
* @param sort 정렬 기준
62+
* @param page 페이지 번호
63+
* @param size 페이지당 상품 수
64+
* @return 캐시된 상품 목록 (없으면 null)
65+
*/
66+
public ProductInfoList getCachedProductList(Long brandId, String sort, int page, int size) {
67+
try {
68+
String key = buildListCacheKey(brandId, sort, page, size);
69+
String cachedValue = redisTemplate.opsForValue().get(key);
70+
71+
if (cachedValue == null) {
72+
return null;
73+
}
74+
75+
ProductInfoList cachedList = objectMapper.readValue(cachedValue, new TypeReference<ProductInfoList>() {});
76+
77+
// 로컬 캐시의 좋아요 수 델타 적용
78+
return applyLikeCountDelta(cachedList);
79+
} catch (Exception e) {
80+
return null;
81+
}
82+
}
83+
84+
/**
85+
* 상품 목록 조회 결과를 캐시에 저장합니다.
86+
* <p>
87+
* 첫 3페이지인 경우에만 캐시에 저장합니다.
88+
* </p>
89+
*
90+
* @param brandId 브랜드 ID (null이면 전체)
91+
* @param sort 정렬 기준
92+
* @param page 페이지 번호
93+
* @param size 페이지당 상품 수
94+
* @param productInfoList 캐시할 상품 목록
95+
*/
96+
public void cacheProductList(Long brandId, String sort, int page, int size, ProductInfoList productInfoList) {
97+
// 3페이지까지만 캐시 저장
98+
if (page > 2) {
99+
return;
100+
}
101+
102+
try {
103+
String key = buildListCacheKey(brandId, sort, page, size);
104+
String value = objectMapper.writeValueAsString(productInfoList);
105+
redisTemplate.opsForValue().set(key, value, CACHE_TTL);
106+
} catch (Exception e) {
107+
// 캐시 저장 실패는 무시 (DB 조회로 폴백 가능)
108+
}
109+
}
110+
111+
/**
112+
* 상품 상세 조회 결과를 캐시에서 조회합니다.
113+
* <p>
114+
* 로컬 캐시의 좋아요 수 델타를 적용하여 반환합니다.
115+
* </p>
116+
*
117+
* @param productId 상품 ID
118+
* @return 캐시된 상품 정보 (없으면 null)
119+
*/
120+
public ProductInfo getCachedProduct(Long productId) {
121+
try {
122+
String key = buildDetailCacheKey(productId);
123+
String cachedValue = redisTemplate.opsForValue().get(key);
124+
125+
if (cachedValue == null) {
126+
return null;
127+
}
128+
129+
ProductInfo cachedInfo = objectMapper.readValue(cachedValue, new TypeReference<ProductInfo>() {});
130+
131+
// 로컬 캐시의 좋아요 수 델타 적용
132+
return applyLikeCountDelta(cachedInfo);
133+
} catch (Exception e) {
134+
return null;
135+
}
136+
}
137+
138+
/**
139+
* 상품 상세 조회 결과를 캐시에 저장합니다.
140+
*
141+
* @param productId 상품 ID
142+
* @param productInfo 캐시할 상품 정보
143+
*/
144+
public void cacheProduct(Long productId, ProductInfo productInfo) {
145+
try {
146+
String key = buildDetailCacheKey(productId);
147+
String value = objectMapper.writeValueAsString(productInfo);
148+
redisTemplate.opsForValue().set(key, value, CACHE_TTL);
149+
} catch (Exception e) {
150+
// 캐시 저장 실패는 무시 (DB 조회로 폴백 가능)
151+
}
152+
}
153+
154+
/**
155+
* 상품 목록 캐시 키를 생성합니다.
156+
*
157+
* @param brandId 브랜드 ID (null이면 "all")
158+
* @param sort 정렬 기준
159+
* @param page 페이지 번호
160+
* @param size 페이지당 상품 수
161+
* @return 캐시 키
162+
*/
163+
private String buildListCacheKey(Long brandId, String sort, int page, int size) {
164+
String brandPart = brandId != null ? "brand:" + brandId : "brand:all";
165+
// sort가 null이면 기본값 "latest" 사용 (컨트롤러와 동일한 기본값)
166+
String sortValue = sort != null ? sort : "latest";
167+
return String.format("%s%s:sort:%s:page:%d:size:%d",
168+
CACHE_KEY_PREFIX_LIST, brandPart, sortValue, page, size);
169+
}
170+
171+
/**
172+
* 상품 상세 캐시 키를 생성합니다.
173+
*
174+
* @param productId 상품 ID
175+
* @return 캐시 키
176+
*/
177+
private String buildDetailCacheKey(Long productId) {
178+
return CACHE_KEY_PREFIX_DETAIL + productId;
179+
}
180+
181+
/**
182+
* 좋아요 수 델타를 증가시킵니다.
183+
* <p>
184+
* 좋아요 추가 시 호출됩니다.
185+
* </p>
186+
*
187+
* @param productId 상품 ID
188+
*/
189+
public void incrementLikeCountDelta(Long productId) {
190+
likeCountDeltaCache.merge(productId, 1L, Long::sum);
191+
}
192+
193+
/**
194+
* 좋아요 수 델타를 감소시킵니다.
195+
* <p>
196+
* 좋아요 취소 시 호출됩니다.
197+
* </p>
198+
*
199+
* @param productId 상품 ID
200+
*/
201+
public void decrementLikeCountDelta(Long productId) {
202+
likeCountDeltaCache.merge(productId, -1L, Long::sum);
203+
}
204+
205+
/**
206+
* 모든 좋아요 수 델타를 초기화합니다.
207+
* <p>
208+
* 배치 집계 후 호출됩니다.
209+
* </p>
210+
*/
211+
public void clearAllLikeCountDelta() {
212+
likeCountDeltaCache.clear();
213+
}
214+
215+
/**
216+
* 상품 목록에 좋아요 수 델타를 적용합니다.
217+
* <p>
218+
* DB에서 직접 조회한 결과에도 델타를 적용하기 위해 public으로 제공합니다.
219+
* </p>
220+
*
221+
* @param productInfoList 상품 목록
222+
* @return 델타가 적용된 상품 목록
223+
*/
224+
public ProductInfoList applyLikeCountDelta(ProductInfoList productInfoList) {
225+
if (likeCountDeltaCache.isEmpty()) {
226+
return productInfoList;
227+
}
228+
229+
List<ProductInfo> updatedProducts = productInfoList.products().stream()
230+
.map(this::applyLikeCountDelta)
231+
.collect(Collectors.toList());
232+
233+
return new ProductInfoList(
234+
updatedProducts,
235+
productInfoList.totalCount(),
236+
productInfoList.page(),
237+
productInfoList.size()
238+
);
239+
}
240+
241+
/**
242+
* 상품 정보에 좋아요 수 델타를 적용합니다.
243+
* <p>
244+
* DB에서 직접 조회한 결과에도 델타를 적용하기 위해 public으로 제공합니다.
245+
* </p>
246+
*
247+
* @param productInfo 상품 정보
248+
* @return 델타가 적용된 상품 정보
249+
*/
250+
public ProductInfo applyLikeCountDelta(ProductInfo productInfo) {
251+
Long delta = likeCountDeltaCache.get(productInfo.productDetail().getId());
252+
if (delta == null || delta == 0) {
253+
return productInfo;
254+
}
255+
256+
ProductDetail originalDetail = productInfo.productDetail();
257+
Long updatedLikesCount = originalDetail.getLikesCount() + delta;
258+
259+
ProductDetail updatedDetail = ProductDetail.of(
260+
originalDetail.getId(),
261+
originalDetail.getName(),
262+
originalDetail.getPrice(),
263+
originalDetail.getStock(),
264+
originalDetail.getBrandId(),
265+
originalDetail.getBrandName(),
266+
updatedLikesCount
267+
);
268+
269+
return new ProductInfo(updatedDetail);
270+
}
271+
}
272+

0 commit comments

Comments
 (0)