-
Notifications
You must be signed in to change notification settings - Fork 44
[volume-5] 인덱스 추가 및 캐시 도입 - 김선민 #211
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: seonminKim1122
Are you sure you want to change the base?
Changes from all commits
af0d392
19a207f
c5da28b
b01b713
dfccc6c
5524817
412cce7
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,25 @@ | ||
| package com.loopers.application.product; | ||
|
|
||
| import java.util.List; | ||
| import java.util.Map; | ||
| import java.util.Optional; | ||
|
|
||
| public interface ProductCacheStore { | ||
|
|
||
| long DETAIL_TTL_MINUTES = 5; | ||
| long LIST_TTL_SECONDS = 30; | ||
|
|
||
| Optional<ProductInfo> get(Long productId); | ||
|
|
||
| void put(Long productId, ProductInfo productInfo); | ||
|
|
||
| void evict(Long productId); | ||
|
|
||
| record ProductListCacheEntry(List<Long> productIds, int totalPages) {} | ||
|
|
||
| Optional<ProductListCacheEntry> getList(Long brandId, String sort, int page, int size); | ||
|
|
||
| void putList(Long brandId, String sort, int page, int size, List<Long> productIds, int totalPages); | ||
|
|
||
| Map<Long, ProductInfo> multiGet(List<Long> productIds); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,116 @@ | ||
| package com.loopers.application.product; | ||
|
|
||
| import com.loopers.domain.brand.Brand; | ||
| import com.loopers.domain.brand.BrandRepository; | ||
| import com.loopers.domain.product.Product; | ||
| import com.loopers.domain.product.ProductRepository; | ||
| import com.loopers.domain.product.ProductSortType; | ||
| import com.loopers.support.error.CoreException; | ||
| import com.loopers.support.error.ErrorType; | ||
| import com.loopers.support.page.PageResponse; | ||
| import lombok.RequiredArgsConstructor; | ||
| import org.springframework.data.domain.PageRequest; | ||
| import org.springframework.data.domain.Pageable; | ||
| import org.springframework.stereotype.Component; | ||
| import org.springframework.transaction.annotation.Transactional; | ||
|
|
||
| import java.util.HashMap; | ||
| import java.util.List; | ||
| import java.util.Map; | ||
| import java.util.Objects; | ||
| import java.util.Optional; | ||
|
|
||
| @RequiredArgsConstructor | ||
| @Component | ||
| public class ProductQueryService { | ||
|
|
||
| private final ProductRepository productRepository; | ||
| private final BrandRepository brandRepository; | ||
| private final ProductAssembler productAssembler; | ||
| private final ProductCacheStore productCacheStore; | ||
|
|
||
| @Transactional(readOnly = true) | ||
| public PageResponse<ProductInfo> getList(Pageable pageable, Long brandId, String sort) { | ||
| int page = pageable.getPageNumber(); | ||
| int size = pageable.getPageSize(); | ||
| PageRequest pageRequest = PageRequest.of(page, size, ProductSortType.from(sort).getSort()); | ||
|
|
||
| return productCacheStore.getList(brandId, sort, page, size) | ||
| .map(entry -> resolveFromCache(entry, page, size)) | ||
| .orElseGet(() -> resolveFromDatabase(brandId, pageRequest, sort, page, size)); | ||
|
Comment on lines
+33
to
+40
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 캐시 키에는 정규화된 sort 값을 써야 한다. DB 조회는 패치 예시 `@Transactional`(readOnly = true)
public PageResponse<ProductInfo> getList(Pageable pageable, Long brandId, String sort) {
int page = pageable.getPageNumber();
int size = pageable.getPageSize();
- PageRequest pageRequest = PageRequest.of(page, size, ProductSortType.from(sort).getSort());
+ ProductSortType sortType = ProductSortType.from(sort);
+ String normalizedSort = sortType.name();
+ PageRequest pageRequest = PageRequest.of(page, size, sortType.getSort());
- return productCacheStore.getList(brandId, sort, page, size)
+ return productCacheStore.getList(brandId, normalizedSort, page, size)
.map(entry -> resolveFromCache(entry, page, size))
- .orElseGet(() -> resolveFromDatabase(brandId, pageRequest, sort, page, size));
+ .orElseGet(() -> resolveFromDatabase(brandId, pageRequest, normalizedSort, page, size));
}Also applies to: 72-89 🤖 Prompt for AI Agents |
||
| } | ||
|
|
||
| private PageResponse<ProductInfo> resolveFromCache(ProductCacheStore.ProductListCacheEntry entry, int page, int size) { | ||
| List<Long> productIds = entry.productIds(); | ||
| int totalPages = entry.totalPages(); | ||
| if (productIds.isEmpty()) { | ||
| return new PageResponse<>(List.of(), page, size, totalPages); | ||
| } | ||
|
|
||
| Map<Long, ProductInfo> cachedInfos = productCacheStore.multiGet(productIds); | ||
| List<Long> missingIds = productIds.stream().filter(id -> !cachedInfos.containsKey(id)).toList(); | ||
| if (missingIds.isEmpty()) { | ||
| return new PageResponse<>(productIds.stream().map(cachedInfos::get).toList(), page, size, totalPages); | ||
| } | ||
|
|
||
| Map<Long, ProductInfo> allInfoMap = new HashMap<>(cachedInfos); | ||
| List<Product> missingProducts = productRepository.findAllByIdIn(missingIds); | ||
| List<Long> missingBrandIds = missingProducts.stream().map(Product::brandId).distinct().toList(); | ||
| List<Brand> missingBrands = brandRepository.findAllByIdIn(missingBrandIds); | ||
| Map<Long, ProductInfo> missingInfoMap = productAssembler.toInfoMap(missingProducts, missingBrands); | ||
| allInfoMap.putAll(missingInfoMap); | ||
| missingInfoMap.forEach(productCacheStore::put); | ||
|
|
||
| List<ProductInfo> orderedInfos = productIds.stream() | ||
| .map(allInfoMap::get) | ||
| .filter(Objects::nonNull) | ||
| .toList(); | ||
|
|
||
| return new PageResponse<>(orderedInfos, page, size, totalPages); | ||
| } | ||
|
|
||
| private PageResponse<ProductInfo> resolveFromDatabase(Long brandId, PageRequest pageRequest, String sort, int page, int size) { | ||
| PageResponse<Product> products = brandId == null | ||
| ? productRepository.findAll(pageRequest) | ||
| : productRepository.findAllByBrandId(brandId, pageRequest); | ||
|
|
||
| List<Product> productList = products.content(); | ||
| if (productList.isEmpty()) { | ||
| productCacheStore.putList(brandId, sort, page, size, List.of(), products.totalPages()); | ||
| return new PageResponse<>(List.of(), products.page(), products.size(), products.totalPages()); | ||
| } | ||
|
|
||
| List<Long> brandIds = productList.stream().map(Product::brandId).toList(); | ||
| List<Brand> brands = brandRepository.findAllByIdIn(brandIds); | ||
| Map<Long, ProductInfo> infoMap = productAssembler.toInfoMap(productList, brands); | ||
|
|
||
| List<Long> productIds = productList.stream().map(Product::getId).toList(); | ||
| productCacheStore.putList(brandId, sort, page, size, productIds, products.totalPages()); | ||
| infoMap.forEach((id, info) -> productCacheStore.put(id, info)); | ||
|
|
||
| List<ProductInfo> orderedInfos = productIds.stream().map(infoMap::get).filter(Objects::nonNull).toList(); | ||
| return new PageResponse<>(orderedInfos, products.page(), products.size(), products.totalPages()); | ||
| } | ||
|
|
||
| @Transactional(readOnly = true) | ||
| public ProductInfo getDetail(Long productId) { | ||
| Optional<ProductInfo> cached = productCacheStore.get(productId); | ||
| if (cached.isPresent()) { | ||
| return cached.get(); | ||
| } | ||
|
|
||
| Product product = productRepository.findById(productId) | ||
| .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 상품입니다.")); | ||
|
|
||
| Optional<Brand> optionalBrand = brandRepository.findById(product.brandId()); | ||
| ProductInfo productInfo = ProductInfo.of(product, optionalBrand.map(Brand::name).orElse(null)); | ||
|
|
||
| productCacheStore.put(productId, productInfo); | ||
|
|
||
| return productInfo; | ||
| } | ||
|
|
||
| public void evict(Long productId) { | ||
| productCacheStore.evict(productId); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -6,7 +6,8 @@ | |||||||||||||||
|
|
||||||||||||||||
| public enum ProductSortType { | ||||||||||||||||
| LATEST(Sort.by(Sort.Direction.DESC, "createdAt")), // 또는 "createdAt" | ||||||||||||||||
| PRICE_ASC(Sort.by(Sort.Direction.ASC, "price")); | ||||||||||||||||
| PRICE_ASC(Sort.by(Sort.Direction.ASC, "price")), | ||||||||||||||||
| LIKE_COUNT(Sort.by(Sort.Direction.DESC, "likeCount")); | ||||||||||||||||
|
Comment on lines
+9
to
+10
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. LIKE_COUNT 정렬에 보조 키를 추가해야 한다.
패치 예시- LIKE_COUNT(Sort.by(Sort.Direction.DESC, "likeCount"));
+ LIKE_COUNT(
+ Sort.by(Sort.Direction.DESC, "likeCount")
+ .and(Sort.by(Sort.Direction.DESC, "id"))
+ );📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||
|
|
||||||||||||||||
| private final Sort sort; | ||||||||||||||||
|
|
||||||||||||||||
|
|
||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,96 @@ | ||
| package com.loopers.infrastructure.product; | ||
|
|
||
| import com.fasterxml.jackson.databind.ObjectMapper; | ||
| import com.loopers.application.product.ProductCacheStore; | ||
| import com.loopers.application.product.ProductInfo; | ||
| import lombok.RequiredArgsConstructor; | ||
| import org.springframework.data.redis.core.RedisTemplate; | ||
| import org.springframework.stereotype.Component; | ||
|
|
||
| import java.util.HashMap; | ||
| import java.util.List; | ||
| import java.util.Map; | ||
| import java.util.Optional; | ||
| import java.util.concurrent.TimeUnit; | ||
|
|
||
| @RequiredArgsConstructor | ||
| @Component | ||
| public class RedisProductCacheStore implements ProductCacheStore { | ||
|
|
||
| private static final String DETAIL_KEY_PREFIX = "product:detail:"; | ||
| private static final String LIST_KEY_PREFIX = "product:list:"; | ||
|
|
||
| private final RedisTemplate<String, String> redisTemplate; | ||
| private final ObjectMapper objectMapper; | ||
|
|
||
| @Override | ||
| public Optional<ProductInfo> get(Long productId) { | ||
| try { | ||
| String cached = redisTemplate.opsForValue().get(DETAIL_KEY_PREFIX + productId); | ||
| if (cached != null) { | ||
| return Optional.of(objectMapper.readValue(cached, ProductInfo.class)); | ||
| } | ||
| } catch (Exception ignored) { | ||
| } | ||
| return Optional.empty(); | ||
| } | ||
|
|
||
| @Override | ||
| public void put(Long productId, ProductInfo productInfo) { | ||
| try { | ||
| String json = objectMapper.writeValueAsString(productInfo); | ||
| redisTemplate.opsForValue().set(DETAIL_KEY_PREFIX + productId, json, DETAIL_TTL_MINUTES, TimeUnit.MINUTES); | ||
| } catch (Exception ignored) { | ||
| } | ||
| } | ||
|
|
||
| @Override | ||
| public void evict(Long productId) { | ||
| redisTemplate.delete(DETAIL_KEY_PREFIX + productId); | ||
|
Comment on lines
+47
to
+49
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
이 클래스는 패치 예시 public void evict(Long productId) {
- redisTemplate.delete(DETAIL_KEY_PREFIX + productId);
+ try {
+ redisTemplate.delete(DETAIL_KEY_PREFIX + productId);
+ } catch (Exception e) {
+ log.warn("Failed to evict product cache. productId={}", productId, e);
+ }
}
`@Override`
public Map<Long, ProductInfo> multiGet(List<Long> productIds) {
- List<String> keys = productIds.stream()
- .map(id -> DETAIL_KEY_PREFIX + id)
- .toList();
- List<String> values = redisTemplate.opsForValue().multiGet(keys);
Map<Long, ProductInfo> result = new HashMap<>();
- if (values == null) return result;
- for (int i = 0; i < productIds.size(); i++) {
- String value = values.get(i);
- if (value != null) {
- try {
- result.put(productIds.get(i), objectMapper.readValue(value, ProductInfo.class));
- } catch (Exception ignored) {
+ try {
+ List<String> keys = productIds.stream()
+ .map(id -> DETAIL_KEY_PREFIX + id)
+ .toList();
+ List<String> values = redisTemplate.opsForValue().multiGet(keys);
+ if (values == null) return result;
+ for (int i = 0; i < productIds.size(); i++) {
+ String value = values.get(i);
+ if (value != null) {
+ try {
+ result.put(productIds.get(i), objectMapper.readValue(value, ProductInfo.class));
+ } catch (Exception e) {
+ log.warn("Failed to deserialize product cache. productId={}", productIds.get(i), e);
+ }
}
}
+ } catch (Exception e) {
+ log.warn("Failed to read product caches. productIds={}", productIds, e);
}
return result;
}Also applies to: 74-90 🤖 Prompt for AI Agents |
||
| } | ||
|
|
||
| @Override | ||
| public Optional<ProductListCacheEntry> getList(Long brandId, String sort, int page, int size) { | ||
| try { | ||
| String cached = redisTemplate.opsForValue().get(listKey(brandId, sort, page, size)); | ||
| if (cached != null) { | ||
| return Optional.of(objectMapper.readValue(cached, ProductListCacheEntry.class)); | ||
| } | ||
| } catch (Exception ignored) { | ||
| } | ||
| return Optional.empty(); | ||
| } | ||
|
|
||
| @Override | ||
| public void putList(Long brandId, String sort, int page, int size, List<Long> productIds, int totalPages) { | ||
| try { | ||
| String json = objectMapper.writeValueAsString(new ProductListCacheEntry(productIds, totalPages)); | ||
| redisTemplate.opsForValue().set(listKey(brandId, sort, page, size), json, LIST_TTL_SECONDS, TimeUnit.SECONDS); | ||
| } catch (Exception ignored) { | ||
| } | ||
| } | ||
|
|
||
| @Override | ||
| public Map<Long, ProductInfo> multiGet(List<Long> productIds) { | ||
| List<String> keys = productIds.stream() | ||
| .map(id -> DETAIL_KEY_PREFIX + id) | ||
| .toList(); | ||
| List<String> values = redisTemplate.opsForValue().multiGet(keys); | ||
| Map<Long, ProductInfo> result = new HashMap<>(); | ||
| if (values == null) return result; | ||
| for (int i = 0; i < productIds.size(); i++) { | ||
| String value = values.get(i); | ||
| if (value != null) { | ||
| try { | ||
| result.put(productIds.get(i), objectMapper.readValue(value, ProductInfo.class)); | ||
| } catch (Exception ignored) { | ||
| } | ||
| } | ||
| } | ||
| return result; | ||
| } | ||
|
|
||
| private String listKey(Long brandId, String sort, int page, int size) { | ||
| return LIST_KEY_PREFIX + "brandId=" + brandId + ":sort=" + sort + ":page=" + page + ":size=" + size; | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
목록 캐시를 무효화할 계약이 필요하다.
현재 인터페이스는 상세 캐시만
evict할 수 있어서, 삭제/가격 변경 뒤에도 목록의productIds캐시는 TTL 동안 그대로 남는다. 운영에서는 삭제된 상품이 목록에 남거나PRICE_ASC·LIKE_COUNT결과가 실제 DB 순서와 달라질 수 있으므로, 브랜드/정렬 단위의 목록 캐시 무효화 API를 추가하거나 key scan 대신 namespace version 기반 일괄 폐기 전략으로 바꿔야 한다. 삭제·가격 변경·좋아요 수 변경 직후 다음 목록 조회가 이전 page ID 캐시를 재사용하지 않는 테스트를 추가해야 한다.🤖 Prompt for AI Agents