Skip to content

Commit 9631946

Browse files
authored
Feature/catalog caching (#20)
* feat: 상품 조회 결과를 캐시로 관리하는 서비스 로직 추가 * feat: 상품 조회하는 서비스 로직에 Cache Aside 패턴 적용
1 parent 197dda2 commit 9631946

2 files changed

Lines changed: 193 additions & 4 deletions

File tree

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

Lines changed: 39 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,32 @@ 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+
return result;
7898
}
7999

80100
/**
81101
* 상품 정보를 조회합니다.
102+
* <p>
103+
* Redis 캐시를 먼저 확인하고, 캐시에 없으면 DB에서 조회한 후 캐시에 저장합니다.
104+
* </p>
82105
*
83106
* @param productId 상품 ID
84107
* @return 상품 정보와 좋아요 수
85108
* @throws CoreException 상품을 찾을 수 없는 경우
86109
*/
87110
public ProductInfo getProduct(Long productId) {
111+
// 캐시에서 조회 시도
112+
ProductInfo cachedResult = productCacheService.getCachedProduct(productId);
113+
if (cachedResult != null) {
114+
return cachedResult;
115+
}
116+
117+
// 캐시에 없으면 DB에서 조회
88118
Product product = productRepository.findById(productId)
89119
.orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다."));
90120

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

101-
return new ProductInfo(productDetail);
131+
ProductInfo result = new ProductInfo(productDetail);
132+
133+
// 캐시에 저장
134+
productCacheService.cacheProduct(productId, result);
135+
136+
return result;
102137
}
103138

104139
}
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
package com.loopers.application.catalog;
2+
3+
import com.fasterxml.jackson.core.type.TypeReference;
4+
import com.fasterxml.jackson.databind.ObjectMapper;
5+
import lombok.RequiredArgsConstructor;
6+
import org.springframework.data.redis.core.RedisTemplate;
7+
import org.springframework.stereotype.Service;
8+
9+
import java.time.Duration;
10+
11+
/**
12+
* 상품 조회 결과를 Redis에 캐시하는 서비스.
13+
* <p>
14+
* 상품 목록 조회와 상품 상세 조회 결과를 캐시하여 성능을 향상시킵니다.
15+
* </p>
16+
* <p>
17+
* <b>캐시 전략:</b>
18+
* <ul>
19+
* <li><b>상품 목록:</b> 첫 3페이지만 캐시하여 메모리 사용량 최적화</li>
20+
* <li><b>상품 상세:</b> 모든 상품 상세 정보 캐시</li>
21+
* </ul>
22+
* </p>
23+
*
24+
* @author Loopers
25+
*/
26+
@Service
27+
@RequiredArgsConstructor
28+
public class ProductCacheService {
29+
30+
private static final String CACHE_KEY_PREFIX_LIST = "product:list:";
31+
private static final String CACHE_KEY_PREFIX_DETAIL = "product:detail:";
32+
private static final Duration CACHE_TTL = Duration.ofMinutes(1); // 1분 TTL
33+
34+
private final RedisTemplate<String, String> redisTemplate;
35+
private final ObjectMapper objectMapper;
36+
37+
/**
38+
* 상품 목록 조회 결과를 캐시에서 조회합니다.
39+
* <p>
40+
* 페이지 번호와 관계없이 캐시를 확인하고, 캐시에 있으면 반환합니다.
41+
* 캐시에 없으면 null을 반환하여 DB 조회를 유도합니다.
42+
* </p>
43+
*
44+
* @param brandId 브랜드 ID (null이면 전체)
45+
* @param sort 정렬 기준
46+
* @param page 페이지 번호
47+
* @param size 페이지당 상품 수
48+
* @return 캐시된 상품 목록 (없으면 null)
49+
*/
50+
public ProductInfoList getCachedProductList(Long brandId, String sort, int page, int size) {
51+
try {
52+
String key = buildListCacheKey(brandId, sort, page, size);
53+
String cachedValue = redisTemplate.opsForValue().get(key);
54+
55+
if (cachedValue == null) {
56+
return null;
57+
}
58+
return objectMapper.readValue(cachedValue, new TypeReference<ProductInfoList>() {});
59+
} catch (Exception e) {
60+
return null;
61+
}
62+
}
63+
64+
/**
65+
* 상품 목록 조회 결과를 캐시에 저장합니다.
66+
* <p>
67+
* 첫 3페이지인 경우에만 캐시에 저장합니다.
68+
* </p>
69+
*
70+
* @param brandId 브랜드 ID (null이면 전체)
71+
* @param sort 정렬 기준
72+
* @param page 페이지 번호
73+
* @param size 페이지당 상품 수
74+
* @param productInfoList 캐시할 상품 목록
75+
*/
76+
public void cacheProductList(Long brandId, String sort, int page, int size, ProductInfoList productInfoList) {
77+
// 3페이지까지만 캐시 저장
78+
if (page > 2) {
79+
return;
80+
}
81+
82+
try {
83+
String key = buildListCacheKey(brandId, sort, page, size);
84+
String value = objectMapper.writeValueAsString(productInfoList);
85+
redisTemplate.opsForValue().set(key, value, CACHE_TTL);
86+
} catch (Exception e) {
87+
// 캐시 저장 실패는 무시 (DB 조회로 폴백 가능)
88+
}
89+
}
90+
91+
/**
92+
* 상품 상세 조회 결과를 캐시에서 조회합니다.
93+
*
94+
* @param productId 상품 ID
95+
* @return 캐시된 상품 정보 (없으면 null)
96+
*/
97+
public ProductInfo getCachedProduct(Long productId) {
98+
try {
99+
String key = buildDetailCacheKey(productId);
100+
String cachedValue = redisTemplate.opsForValue().get(key);
101+
102+
if (cachedValue == null) {
103+
return null;
104+
}
105+
return objectMapper.readValue(cachedValue, new TypeReference<ProductInfo>() {});
106+
} catch (Exception e) {
107+
return null;
108+
}
109+
}
110+
111+
/**
112+
* 상품 상세 조회 결과를 캐시에 저장합니다.
113+
*
114+
* @param productId 상품 ID
115+
* @param productInfo 캐시할 상품 정보
116+
*/
117+
public void cacheProduct(Long productId, ProductInfo productInfo) {
118+
try {
119+
String key = buildDetailCacheKey(productId);
120+
String value = objectMapper.writeValueAsString(productInfo);
121+
redisTemplate.opsForValue().set(key, value, CACHE_TTL);
122+
} catch (Exception e) {
123+
// 캐시 저장 실패는 무시 (DB 조회로 폴백 가능)
124+
}
125+
}
126+
127+
/**
128+
* 상품 목록 캐시 키를 생성합니다.
129+
*
130+
* @param brandId 브랜드 ID (null이면 "all")
131+
* @param sort 정렬 기준
132+
* @param page 페이지 번호
133+
* @param size 페이지당 상품 수
134+
* @return 캐시 키
135+
*/
136+
private String buildListCacheKey(Long brandId, String sort, int page, int size) {
137+
String brandPart = brandId != null ? "brand:" + brandId : "brand:all";
138+
// sort가 null이면 기본값 "latest" 사용 (컨트롤러와 동일한 기본값)
139+
String sortValue = sort != null ? sort : "latest";
140+
return String.format("%s%s:sort:%s:page:%d:size:%d",
141+
CACHE_KEY_PREFIX_LIST, brandPart, sortValue, page, size);
142+
}
143+
144+
/**
145+
* 상품 상세 캐시 키를 생성합니다.
146+
*
147+
* @param productId 상품 ID
148+
* @return 캐시 키
149+
*/
150+
private String buildDetailCacheKey(Long productId) {
151+
return CACHE_KEY_PREFIX_DETAIL + productId;
152+
}
153+
}
154+

0 commit comments

Comments
 (0)