-
Notifications
You must be signed in to change notification settings - Fork 44
[volume - 5] 인덱스 추가 및 캐시 설계 - 김윤선 #217
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: kimyam1008
Are you sure you want to change the base?
Changes from all commits
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 |
|---|---|---|
|
|
@@ -7,6 +7,7 @@ | |
| import com.loopers.domain.product.ProductRepository; | ||
| import com.loopers.domain.product.exception.ProductInsufficientStockException; | ||
| import com.loopers.domain.product.exception.ProductNotDeletedException; | ||
| import com.loopers.infrastructure.product.ProductCacheStore; | ||
| import com.loopers.support.error.CoreException; | ||
| import com.loopers.support.error.ErrorType; | ||
| import lombok.RequiredArgsConstructor; | ||
|
|
@@ -28,6 +29,7 @@ public class ProductFacade { | |
| private final ProductRepository productRepository; | ||
| private final BrandRepository brandRepository; | ||
| private final ProductService productService; | ||
| private final ProductCacheStore productCacheStore; | ||
|
|
||
|
Comment on lines
+32
to
33
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. 캐시 무효화가 ProductFacade 경로에만 묶여 있어 다른 쓰기 경로에서 정합성이 깨진다 운영 관점에서 현재 무효화는 이 파사드 경로에만 있어, 🤖 Prompt for AI Agents |
||
| @Transactional | ||
| public ProductDto.ProductInfo register(Long brandId, String name, String description, BigDecimal price, Integer stock) { | ||
|
|
@@ -58,43 +60,65 @@ public ProductDto.ProductInfo update(Long productId, String name, String descrip | |
| Product saved = productRepository.save(product); | ||
| Brand brand = brandRepository.findById(saved.getBrandId()) | ||
| .orElseThrow(() -> new CoreException(ErrorType.BRAND_NOT_FOUND)); | ||
| productCacheStore.evictProduct(productId); | ||
| return ProductDto.ProductInfo.of(saved, brand.getName()); | ||
|
Comment on lines
+63
to
64
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. 쓰기 메서드 전반에서 커밋 전 캐시 무효화가 동일하게 반복된다 Line 63, 132, 148, 158, 174의 즉시 삭제는 커밋 전 재캐시 경쟁 상태를 만들어 오래된 단건 캐시가 1시간 유지될 수 있다. 수정안으로 공통 헬퍼를 두고 Also applies to: 132-133, 148-149, 158-159, 174-175 🤖 Prompt for AI Agents |
||
| } | ||
|
|
||
| @Transactional(readOnly = true) | ||
| public Page<ProductDto.ProductInfo> getProducts(ProductSortType sortType, Pageable pageable) { | ||
| Pageable sorted = PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(), sortType.toSort()); | ||
| Page<Product> products = productRepository.findAll(sorted); | ||
|
|
||
| Set<Long> brandIds = products.stream() | ||
| .map(Product::getBrandId) | ||
| .collect(Collectors.toSet()); | ||
|
|
||
| Map<Long, String> brandNameMap = brandRepository.findAllByIds(brandIds).stream() | ||
| .collect(Collectors.toMap(Brand::getId, Brand::getName)); | ||
|
|
||
| return products.map(product -> | ||
| ProductDto.ProductInfo.of(product, brandNameMap.getOrDefault(product.getBrandId(), "")) | ||
| ); | ||
| int page = pageable.getPageNumber(); | ||
| int size = pageable.getPageSize(); | ||
|
|
||
| return productCacheStore.getProductList(null, sortType, page, size) | ||
| .orElseGet(() -> { | ||
| Pageable sorted = PageRequest.of(page, size, sortType.toSort()); | ||
| Page<Product> products = productRepository.findAll(sorted); | ||
|
|
||
| Set<Long> brandIds = products.stream() | ||
| .map(Product::getBrandId) | ||
| .collect(Collectors.toSet()); | ||
|
|
||
| Map<Long, String> brandNameMap = brandRepository.findAllByIds(brandIds).stream() | ||
| .collect(Collectors.toMap(Brand::getId, Brand::getName)); | ||
|
|
||
| Page<ProductDto.ProductInfo> result = products.map(product -> | ||
| ProductDto.ProductInfo.of(product, brandNameMap.getOrDefault(product.getBrandId(), "")) | ||
| ); | ||
| productCacheStore.putProductList(null, sortType, page, size, result); | ||
| return result; | ||
| }); | ||
| } | ||
|
|
||
| @Transactional(readOnly = true) | ||
| public Page<ProductDto.ProductInfo> getProductsByBrand(Long brandId, ProductSortType sortType, Pageable pageable) { | ||
| Brand brand = brandRepository.findById(brandId) | ||
| .orElseThrow(() -> new CoreException(ErrorType.BRAND_NOT_FOUND)); | ||
|
|
||
| Pageable sorted = PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(), sortType.toSort()); | ||
| return productRepository.findByBrandId(brandId, sorted) | ||
| .map(product -> ProductDto.ProductInfo.of(product, brand.getName())); | ||
| int page = pageable.getPageNumber(); | ||
| int size = pageable.getPageSize(); | ||
|
|
||
| return productCacheStore.getProductList(brandId, sortType, page, size) | ||
| .orElseGet(() -> { | ||
| Brand brand = brandRepository.findById(brandId) | ||
| .orElseThrow(() -> new CoreException(ErrorType.BRAND_NOT_FOUND)); | ||
|
|
||
| Pageable sorted = PageRequest.of(page, size, sortType.toSort()); | ||
| Page<ProductDto.ProductInfo> result = productRepository.findByBrandId(brandId, sorted) | ||
| .map(product -> ProductDto.ProductInfo.of(product, brand.getName())); | ||
| productCacheStore.putProductList(brandId, sortType, page, size, result); | ||
| return result; | ||
| }); | ||
| } | ||
|
|
||
| @Transactional(readOnly = true) | ||
| public ProductDto.ProductInfo getProduct(Long productId) { | ||
| Product product = productRepository.findById(productId) | ||
| .orElseThrow(() -> new CoreException(ErrorType.PRODUCT_NOT_FOUND)); | ||
| Brand brand = brandRepository.findById(product.getBrandId()) | ||
| .orElseThrow(() -> new CoreException(ErrorType.BRAND_NOT_FOUND)); | ||
| return ProductDto.ProductInfo.of(product, brand.getName()); | ||
| return productCacheStore.getProduct(productId) | ||
| .orElseGet(() -> { | ||
| Product product = productRepository.findById(productId) | ||
| .orElseThrow(() -> new CoreException(ErrorType.PRODUCT_NOT_FOUND)); | ||
| Brand brand = brandRepository.findById(product.getBrandId()) | ||
| .orElseThrow(() -> new CoreException(ErrorType.BRAND_NOT_FOUND)); | ||
| ProductDto.ProductInfo info = ProductDto.ProductInfo.of(product, brand.getName()); | ||
| productCacheStore.putProduct(productId, info); | ||
| return info; | ||
| }); | ||
| } | ||
|
|
||
| @Transactional | ||
|
|
@@ -105,6 +129,7 @@ public ProductDto.ProductInfo increaseStock(Long productId, Integer quantity) { | |
| Product saved = productRepository.save(product); | ||
| Brand brand = brandRepository.findById(saved.getBrandId()) | ||
| .orElseThrow(() -> new CoreException(ErrorType.BRAND_NOT_FOUND)); | ||
| productCacheStore.evictProduct(productId); | ||
| return ProductDto.ProductInfo.of(saved, brand.getName()); | ||
| } | ||
|
|
||
|
|
@@ -120,6 +145,7 @@ public ProductDto.ProductInfo decreaseStock(Long productId, Integer quantity) { | |
| Product saved = productRepository.save(product); | ||
| Brand brand = brandRepository.findById(saved.getBrandId()) | ||
| .orElseThrow(() -> new CoreException(ErrorType.BRAND_NOT_FOUND)); | ||
| productCacheStore.evictProduct(productId); | ||
| return ProductDto.ProductInfo.of(saved, brand.getName()); | ||
| } | ||
|
|
||
|
|
@@ -129,6 +155,7 @@ public void delete(Long productId) { | |
| .orElseThrow(() -> new CoreException(ErrorType.PRODUCT_NOT_FOUND)); | ||
| productService.deleteProduct(product); | ||
| productRepository.save(product); | ||
| productCacheStore.evictProduct(productId); | ||
| } | ||
|
|
||
| @Transactional | ||
|
|
@@ -144,6 +171,7 @@ public ProductDto.ProductInfo restore(Long productId) { | |
| Product saved = productRepository.save(product); | ||
| Brand brand = brandRepository.findByIdIncludingDeleted(saved.getBrandId()) | ||
| .orElseThrow(() -> new CoreException(ErrorType.BRAND_NOT_FOUND)); | ||
| productCacheStore.evictProduct(productId); | ||
| return ProductDto.ProductInfo.of(saved, brand.getName()); | ||
| } | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -4,14 +4,22 @@ | |
| import com.loopers.domain.product.exception.ProductInsufficientStockException; | ||
| import jakarta.persistence.Column; | ||
| import jakarta.persistence.Entity; | ||
| import jakarta.persistence.Index; | ||
| import jakarta.persistence.Table; | ||
| import jakarta.persistence.Version; | ||
| import lombok.Getter; | ||
|
|
||
| import java.math.BigDecimal; | ||
|
|
||
| @Entity | ||
| @Table(name = "products") | ||
| @Table( | ||
| name = "products", | ||
| indexes = { | ||
| @Index(name = "idx_products_brand_id_like_count", columnList = "brand_id, like_count DESC"), | ||
| @Index(name = "idx_products_brand_id_created_at", columnList = "brand_id, created_at DESC"), | ||
| @Index(name = "idx_products_brand_id_price", columnList = "brand_id, price ASC") | ||
| } | ||
| ) | ||
|
Comment on lines
+15
to
+22
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. 인덱스 애노테이션만으로는 운영 스키마에 반영되지 않는다 운영 관점에서 운영 반영용 SQL 예시CREATE INDEX idx_products_brand_id_like_count
ON products (brand_id, like_count DESC);
CREATE INDEX idx_products_brand_id_created_at
ON products (brand_id, created_at DESC);
CREATE INDEX idx_products_brand_id_price
ON products (brand_id, price ASC);🤖 Prompt for AI Agents |
||
| @Getter | ||
| public class Product extends BaseEntity { | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,86 @@ | ||
| package com.loopers.infrastructure.product; | ||
|
|
||
| import com.fasterxml.jackson.databind.ObjectMapper; | ||
| import com.loopers.application.product.ProductDto; | ||
| import com.loopers.application.product.ProductSortType; | ||
| import lombok.RequiredArgsConstructor; | ||
| import lombok.extern.slf4j.Slf4j; | ||
| import org.springframework.data.domain.Page; | ||
| import org.springframework.data.domain.PageImpl; | ||
| import org.springframework.data.domain.PageRequest; | ||
| import org.springframework.data.redis.core.RedisTemplate; | ||
| import org.springframework.stereotype.Component; | ||
|
|
||
| import java.time.Duration; | ||
| import java.util.List; | ||
| import java.util.Optional; | ||
|
|
||
| @Slf4j | ||
| @Component | ||
| @RequiredArgsConstructor | ||
| public class ProductCacheStore { | ||
|
|
||
| private static final String PRODUCT_KEY_PREFIX = "product:"; | ||
| private static final String PRODUCT_LIST_KEY_PREFIX = "product:list:"; | ||
| private static final Duration PRODUCT_TTL = Duration.ofHours(1); | ||
| private static final Duration PRODUCT_LIST_TTL = Duration.ofMinutes(1); | ||
|
|
||
| private final RedisTemplate<String, String> redisTemplate; | ||
| private final ObjectMapper objectMapper; | ||
|
|
||
| public Optional<ProductDto.ProductInfo> getProduct(Long productId) { | ||
| try { | ||
| String json = redisTemplate.opsForValue().get(productKey(productId)); | ||
| if (json == null) return Optional.empty(); | ||
| return Optional.of(objectMapper.readValue(json, ProductDto.ProductInfo.class)); | ||
| } catch (Exception e) { | ||
| log.warn("캐시 조회 실패 - product:{}", productId, e); | ||
| return Optional.empty(); | ||
| } | ||
| } | ||
|
|
||
| public void putProduct(Long productId, ProductDto.ProductInfo info) { | ||
| try { | ||
| String json = objectMapper.writeValueAsString(info); | ||
| redisTemplate.opsForValue().set(productKey(productId), json, PRODUCT_TTL); | ||
| } catch (Exception e) { | ||
| log.warn("캐시 저장 실패 - product:{}", productId, e); | ||
| } | ||
| } | ||
|
|
||
| public void evictProduct(Long productId) { | ||
| redisTemplate.delete(productKey(productId)); | ||
| } | ||
|
Comment on lines
+51
to
+53
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. 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
# 1) evictProduct가 예외를 처리하지 않는지 확인
rg -n "public void evictProduct|redisTemplate.delete" apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductCacheStore.java -C2
# 2) evictProduct가 트랜잭션 메서드에서 직접 호출되는지 확인
rg -n "(`@Transactional`|evictProduct\\()" apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java -C2
rg -n "(`@Transactional`|evictProduct\\()" apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java -C2Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 4218 🏁 Script executed: #!/bin/bash
set -euo pipefail
# 1) View full ProductCacheStore.java to see all methods
cat apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductCacheStore.java
# 2) Check if get/put methods have try/catch
grep -n "try\|catch\|public.*get\|public.*put" apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductCacheStore.java || true
# 3) Search for other evict methods in the codebase
find apps/commerce-api -name "*.java" -type f | xargs grep -l "public.*evict" 2>/dev/null || trueRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 4468 🏁 Script executed: #!/bin/bash
set -euo pipefail
# 1) Search for all evict methods in the codebase
find apps/commerce-api -name "*.java" -type f -exec grep -l "evict" {} \; 2>/dev/null
# 2) Check ProductService to see if there are other eviction patterns
rg -n "evict|clear\(\)" apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java -C2 || true
# 3) Check if there are transactional boundaries after evictProduct call
rg -B5 "evictProduct\(" apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java | head -30Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 1961 🏁 Script executed: #!/bin/bash
set -euo pipefail
# Verify the exact line where evictProduct is called in a `@Transactional` context
# to confirm transaction boundary
rg -B10 "evictProduct\(productId\)" apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java | head -40Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 1986 Redis 삭제 예외 전파로 쓰기 트랜잭션이 실패할 수 있다 Line 51-53의 같은 클래스의 수정 예시 public void evictProduct(Long productId) {
- redisTemplate.delete(productKey(productId));
+ try {
+ redisTemplate.delete(productKey(productId));
+ } catch (Exception e) {
+ log.warn("캐시 삭제 실패 - product:{}", productId, e);
+ }
}추가로 🤖 Prompt for AI Agents |
||
|
|
||
| public Optional<Page<ProductDto.ProductInfo>> getProductList(Long brandId, ProductSortType sortType, int page, int size) { | ||
| try { | ||
| String json = redisTemplate.opsForValue().get(listKey(brandId, sortType, page, size)); | ||
| if (json == null) return Optional.empty(); | ||
| ProductListCache cache = objectMapper.readValue(json, ProductListCache.class); | ||
| return Optional.of(new PageImpl<>(cache.content(), PageRequest.of(page, size), cache.totalElements())); | ||
| } catch (Exception e) { | ||
| log.warn("캐시 조회 실패 - product list brand:{} sort:{} page:{}", brandId, sortType, page, e); | ||
| return Optional.empty(); | ||
| } | ||
| } | ||
|
|
||
| public void putProductList(Long brandId, ProductSortType sortType, int page, int size, Page<ProductDto.ProductInfo> result) { | ||
| try { | ||
| ProductListCache cache = new ProductListCache(result.getContent(), result.getTotalElements()); | ||
| String json = objectMapper.writeValueAsString(cache); | ||
| redisTemplate.opsForValue().set(listKey(brandId, sortType, page, size), json, PRODUCT_LIST_TTL); | ||
| } catch (Exception e) { | ||
| log.warn("캐시 저장 실패 - product list brand:{} sort:{} page:{}", brandId, sortType, page, e); | ||
| } | ||
| } | ||
|
|
||
| private String productKey(Long id) { | ||
| return PRODUCT_KEY_PREFIX + id; | ||
| } | ||
|
|
||
| private String listKey(Long brandId, ProductSortType sortType, int page, int size) { | ||
| return PRODUCT_LIST_KEY_PREFIX + "brand=" + brandId + ":sort=" + sortType + ":page=" + page + ":size=" + size; | ||
| } | ||
|
|
||
| private record ProductListCache(List<ProductDto.ProductInfo> content, long totalElements) {} | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -7,6 +7,7 @@ | |
| import com.loopers.domain.product.Product; | ||
| import com.loopers.domain.product.ProductService; | ||
| import com.loopers.domain.product.ProductRepository; | ||
| import com.loopers.infrastructure.product.ProductCacheStore; | ||
| import com.loopers.support.error.CoreException; | ||
| import com.loopers.support.error.ErrorType; | ||
| import org.junit.jupiter.api.BeforeEach; | ||
|
|
@@ -41,7 +42,8 @@ void setUp() { | |
| productFacade = new ProductFacade( | ||
| productRepository, | ||
| brandRepository, | ||
| new ProductService() | ||
| new ProductService(), | ||
| mock(ProductCacheStore.class) | ||
| ); | ||
|
Comment on lines
42
to
47
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. 캐시 연동 회귀를 막는 검증이 누락되어 있다 운영 관점에서 캐시 로직이 깨져도 현재 테스트는 통과할 수 있어 장애를 사전에 차단하기 어렵다. 수정안으로 🤖 Prompt for AI Agents |
||
|
|
||
| brand = Brand.create(new BrandName("LOOPERS"), new BrandDescription("루퍼스 브랜드")); | ||
|
|
||
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.
트랜잭션 커밋 전에 캐시를 삭제하면 오래된 값이 장시간 재캐시될 수 있다
Line 85와 Line 115에서 캐시를 즉시 삭제하면, 커밋 전 동시 조회가 DB의 이전 값을 읽어 1시간 TTL로 다시 캐시할 수 있어 운영 중 데이터 신뢰도가 떨어진다. 수정안으로 캐시 무효화는
afterCommit훅에서 실행해야 한다. 추가 테스트로 “쓰기 트랜잭션 커밋 지연 중 동시 조회” 시나리오와 “롤백 시 캐시 미삭제” 시나리오를 통합 테스트에 넣어야 한다.수정 예시 (after-commit eviction)
Also applies to: 115-115
🤖 Prompt for AI Agents