-
Notifications
You must be signed in to change notification settings - Fork 44
[volume 5] 인덱스 설계 및 캐시를 통한 읽기 성능 최적화 #198
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: najang
Are you sure you want to change the base?
Changes from all commits
61ff533
fc02ca1
e544dd4
7a7f521
f923933
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 |
|---|---|---|
| @@ -1,47 +1,209 @@ | ||
| package com.loopers.application.product; | ||
|
|
||
| import com.fasterxml.jackson.core.JsonProcessingException; | ||
| import com.fasterxml.jackson.databind.ObjectMapper; | ||
| import com.loopers.config.redis.RedisConfig; | ||
| import com.loopers.domain.brand.BrandService; | ||
| import com.loopers.application.like.LikeApplicationService; | ||
| import com.loopers.domain.product.Product; | ||
| import com.loopers.domain.product.ProductService; | ||
| import com.loopers.domain.product.SellingStatus; | ||
| import lombok.RequiredArgsConstructor; | ||
| import org.springframework.beans.factory.annotation.Qualifier; | ||
| import org.springframework.data.domain.Page; | ||
| import org.springframework.data.domain.PageImpl; | ||
| import org.springframework.data.domain.Pageable; | ||
| import org.springframework.data.domain.Sort; | ||
| import org.springframework.data.redis.core.RedisCallback; | ||
| import org.springframework.data.redis.core.RedisTemplate; | ||
| import org.springframework.data.redis.core.ScanOptions; | ||
| import org.springframework.stereotype.Component; | ||
|
|
||
| @RequiredArgsConstructor | ||
| import java.time.Duration; | ||
| import java.util.ArrayList; | ||
| import java.util.List; | ||
|
|
||
| @Component | ||
| public class ProductFacade { | ||
|
|
||
| // 읽기 전용 조회 (Replica Preferred) - 캐시 히트 확인에 사용 | ||
| private final RedisTemplate<String, String> redisTemplate; | ||
| // 쓰기 전용 (Master) - 캐시 저장 및 삭제에 사용 | ||
| private final RedisTemplate<String, String> masterRedisTemplate; | ||
|
|
||
| private final ProductService productService; | ||
| private final BrandService brandService; | ||
| private final LikeApplicationService likeService; | ||
| private final ObjectMapper objectMapper; | ||
|
|
||
| public ProductFacade( | ||
| ProductService productService, | ||
| BrandService brandService, | ||
| LikeApplicationService likeService, | ||
| RedisTemplate<String, String> redisTemplate, | ||
| @Qualifier(RedisConfig.REDIS_TEMPLATE_MASTER) RedisTemplate<String, String> masterRedisTemplate, | ||
| ObjectMapper objectMapper | ||
| ) { | ||
| this.productService = productService; | ||
| this.brandService = brandService; | ||
| this.likeService = likeService; | ||
| this.redisTemplate = redisTemplate; | ||
| this.masterRedisTemplate = masterRedisTemplate; | ||
| this.objectMapper = objectMapper; | ||
| } | ||
|
|
||
| public ProductInfo findById(Long productId) { | ||
| return findById(productId, null); | ||
| } | ||
|
|
||
| /** | ||
| * Cache-Aside 패턴으로 상품 상세를 조회한다. | ||
| * - 캐시 히트: isLiked는 사용자별 데이터이므로 캐시에 저장하지 않고 별도 조회 후 오버레이 | ||
| * - 캐시 미스: DB 조회 후 isLiked=null 상태로 캐시 저장 (TTL 1분), 이후 isLiked 오버레이 | ||
| * - 캐시 키: product:detail:{productId} | ||
| */ | ||
| public ProductInfo findById(Long productId, Long userId) { | ||
| String cacheKey = "product:detail:" + productId; | ||
|
|
||
| // 캐시 히트: isLiked를 별도 조회해 오버레이 후 반환 | ||
| String cached = redisTemplate.opsForValue().get(cacheKey); | ||
| if (cached != null) { | ||
| try { | ||
| ProductInfo info = objectMapper.readValue(cached, ProductInfo.class); | ||
| Boolean isLiked = userId != null ? likeService.isLiked(userId, productId) : null; | ||
| return info.withIsLiked(isLiked); | ||
| } catch (JsonProcessingException ignored) { | ||
| } | ||
| } | ||
|
|
||
| // 캐시 미스: DB 조회 후 isLiked=null 상태로 캐시 저장 | ||
| Product product = productService.findById(productId); | ||
| ProductInfo info = ProductInfo.from(product); | ||
| try { | ||
| masterRedisTemplate.opsForValue().set(cacheKey, objectMapper.writeValueAsString(info), Duration.ofMinutes(1)); | ||
| } catch (JsonProcessingException ignored) { | ||
| } | ||
| Boolean isLiked = userId != null ? likeService.isLiked(userId, productId) : null; | ||
| return ProductInfo.from(product, isLiked); | ||
| return info.withIsLiked(isLiked); | ||
|
Comment on lines
+80
to
+86
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
echo "[1] Like write service candidates"
fd 'Like.*Service\.java' apps/commerce-api/src/main/java -x sed -n '1,260p' {}
echo
echo "[2] Cache invalidation usages"
rg -n -C3 'product:list:|product:detail:|evictProductListCache|masterRedisTemplate\.delete' apps/commerce-api/src/main/java
echo
echo "[3] Like count mutation call sites"
rg -n -C3 'increaseLikes\(|decreaseLikes\(|isLiked\(' apps/commerce-api/src/main/javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 17448 🏁 Script executed: # Verify the controller/API layer to understand the call chain
fd 'Like.*Controller\.java' apps/commerce-api/src/main/java -x cat
# Check if there's a LikeFacade
fd 'LikeFacade\.java' apps/commerce-api/src/main/java -x cat
# Check imports in LikeApplicationService to see dependencies
grep -n "import\|package" apps/commerce-api/src/main/java/com/loopers/application/like/LikeApplicationService.java | head -20Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 4808 🏁 Script executed: # Check if ProductFacade is accessible or if we need to expose evictProductListCache as public
grep -n "class ProductFacade\|public ProductInfo\|private void evictProductListCache" apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java | head -10
# Check the full signature of evictProductListCache
sed -n '159,164p' apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java
# Check if Product has brandId accessible
grep -n "brandId\|getBrandId" apps/commerce-api/src/main/java/com/loopers/domain/product/Product.javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 985 좋아요 쓰기 경로에서 상품 캐시를 무효화하지 않아 데이터 불일치 발생 가능하다.
수정안:
추가 테스트: 상품 상세 캐시 적재 후 like/unlike 수행하고, API 응답에서 🤖 Prompt for AI Agents |
||
| } | ||
|
|
||
| /** | ||
| * Cache-Aside 패턴으로 상품 목록을 조회한다. | ||
| * - page == 0: 캐시 우선 조회 → 미스 시 DB 조회 후 캐시 저장 (TTL 5분) | ||
| * - page > 0: 캐시 없이 DB 직접 조회 (페이지 수가 많아질수록 캐시 효용이 낮아 적용 제외) | ||
| */ | ||
| public Page<ProductInfo> findAll(Long brandId, Pageable pageable) { | ||
| return productService.findAll(brandId, pageable).map(ProductInfo::from); | ||
| // 첫 페이지가 아닌 경우 캐시 미적용 | ||
| if (pageable.getPageNumber() != 0) { | ||
| return productService.findAll(brandId, pageable).map(ProductInfo::from); | ||
| } | ||
|
|
||
| String cacheKey = buildCacheKey(brandId, pageable); | ||
|
Comment on lines
94
to
+100
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. page=0의 임의 size를 전부 캐시하면 공개 API 하나로 캐시 키가 무한히 늘어난다. 지금 구현은 첫 페이지면 수정 예시+ private static final int CACHEABLE_PAGE_SIZE = 20;
+
public Page<ProductInfo> findAll(Long brandId, Pageable pageable) {
- // 첫 페이지가 아닌 경우 캐시 미적용
- if (pageable.getPageNumber() != 0) {
+ // 첫 페이지 + 표준 페이지 크기만 캐시
+ if (pageable.getPageNumber() != 0 || pageable.getPageSize() != CACHEABLE_PAGE_SIZE) {
return productService.findAll(brandId, pageable).map(ProductInfo::from);
}Also applies to: 190-193 🤖 Prompt for AI Agents |
||
|
|
||
| // 캐시 히트: 직렬화된 JSON을 PageImpl로 복원해 반환 | ||
| String cached = redisTemplate.opsForValue().get(cacheKey); | ||
| if (cached != null) { | ||
| try { | ||
| ProductListCacheEntry entry = objectMapper.readValue(cached, ProductListCacheEntry.class); | ||
| return new PageImpl<>(entry.content(), pageable, entry.totalElements()); | ||
| } catch (JsonProcessingException ignored) { | ||
| } | ||
| } | ||
|
|
||
| // 캐시 미스: DB 조회 후 결과를 캐시에 저장 | ||
| Page<ProductInfo> result = productService.findAll(brandId, pageable).map(ProductInfo::from); | ||
| try { | ||
| ProductListCacheEntry entry = new ProductListCacheEntry(result.getContent(), result.getTotalElements()); | ||
| masterRedisTemplate.opsForValue().set(cacheKey, objectMapper.writeValueAsString(entry), Duration.ofMinutes(5)); | ||
| } catch (JsonProcessingException ignored) { | ||
| } | ||
| return result; | ||
| } | ||
|
|
||
| public ProductInfo create(Long brandId, String name, String description, int price, int stock, SellingStatus sellingStatus) { | ||
| brandService.findById(brandId); | ||
| return ProductInfo.from(productService.create(brandId, name, description, price, stock, sellingStatus)); | ||
| } | ||
|
Comment on lines
122
to
125
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. 상품 생성 후 목록 캐시를 비우지 않아 새 상품이 최대 5분간 보이지 않는다. 목록 캐시는 첫 페이지를 5분 동안 유지하는데, create 경로는 현재 캐시 무효화를 하지 않는다. 그래서 수정 예시 public ProductInfo create(Long brandId, String name, String description, int price, int stock, SellingStatus sellingStatus) {
brandService.findById(brandId);
- return ProductInfo.from(productService.create(brandId, name, description, price, stock, sellingStatus));
+ ProductInfo info = ProductInfo.from(productService.create(brandId, name, description, price, stock, sellingStatus));
+ evictProductListCache(info.brandId());
+ return info;
}🤖 Prompt for AI Agents |
||
|
|
||
| /** | ||
| * 상품 수정 후 목록 캐시 및 상세 캐시를 무효화한다. | ||
| * - 목록 캐시: brandId 기반 SCAN 패턴 삭제 | ||
| * - 상세 캐시: product:detail:{productId} exact key 삭제 | ||
| * update는 반환된 ProductInfo에서 brandId를 얻을 수 있으므로 별도 조회 불필요. | ||
| */ | ||
| public ProductInfo update(Long productId, String name, String description, int price, SellingStatus sellingStatus) { | ||
| return ProductInfo.from(productService.update(productId, name, description, price, sellingStatus)); | ||
| ProductInfo info = ProductInfo.from(productService.update(productId, name, description, price, sellingStatus)); | ||
| evictProductListCache(info.brandId()); | ||
| masterRedisTemplate.delete("product:detail:" + productId); | ||
| return info; | ||
| } | ||
|
|
||
| /** | ||
| * 상품 삭제 후 목록 캐시 및 상세 캐시를 무효화한다. | ||
| * - 목록 캐시: brandId 기반 SCAN 패턴 삭제 | ||
| * - 상세 캐시: product:detail:{productId} exact key 삭제 | ||
| * delete는 void 반환이므로 삭제 전에 별도로 조회해 brandId를 얻는다. | ||
| */ | ||
| public void delete(Long productId) { | ||
| Product product = productService.findById(productId); | ||
| Long brandId = product.getBrandId(); | ||
| productService.delete(productId); | ||
| evictProductListCache(brandId); | ||
| masterRedisTemplate.delete("product:detail:" + productId); | ||
| } | ||
|
|
||
| /** | ||
| * brandId와 관련된 캐시 키를 모두 삭제한다. | ||
| * - "product:list:all:*" : brandId 필터 없는 전체 목록 캐시 | ||
| * - "product:list:{brandId}:*" : 특정 브랜드 목록 캐시 | ||
| */ | ||
| private void evictProductListCache(Long brandId) { | ||
| deleteByPattern("product:list:all:*"); | ||
| if (brandId != null) { | ||
| deleteByPattern("product:list:" + brandId + ":*"); | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * SCAN 기반으로 패턴에 일치하는 키를 순회해 삭제한다. | ||
| * KEYS 명령은 싱글 스레드 Redis를 블로킹할 수 있어 운영 환경에서 사용 금지. | ||
| */ | ||
| private void deleteByPattern(String pattern) { | ||
| masterRedisTemplate.execute((RedisCallback<Void>) connection -> { | ||
| ScanOptions options = ScanOptions.scanOptions().match(pattern).count(100).build(); | ||
| List<byte[]> keys = new ArrayList<>(); | ||
| try (var cursor = connection.keyCommands().scan(options)) { | ||
| cursor.forEachRemaining(keys::add); | ||
| } catch (Exception ignored) { | ||
| } | ||
| if (!keys.isEmpty()) { | ||
| connection.keyCommands().del(keys.toArray(new byte[0][])); | ||
| } | ||
| return null; | ||
| }); | ||
| } | ||
|
|
||
| /** | ||
| * 캐시 키 형식: product:list:{brandId|all}:{sort}:0:{size} | ||
| * 예) product:list:all:latest:0:20 | ||
| * product:list:123:price_asc:0:20 | ||
| */ | ||
| private String buildCacheKey(Long brandId, Pageable pageable) { | ||
| String brandPart = brandId != null ? String.valueOf(brandId) : "all"; | ||
| String sortPart = sortKey(pageable.getSort()); | ||
| return "product:list:" + brandPart + ":" + sortPart + ":0:" + pageable.getPageSize(); | ||
| } | ||
|
|
||
| /** | ||
| * Sort 객체를 캐시 키용 문자열로 변환한다. | ||
| * ProductV1Controller.toSort()의 역매핑. | ||
| */ | ||
| private String sortKey(Sort sort) { | ||
| if (!sort.isSorted()) return "latest"; | ||
| Sort.Order order = sort.iterator().next(); | ||
| String prop = order.getProperty(); | ||
| if (prop.equals("createdAt")) return "latest"; | ||
| if (prop.equals("price.value")) return "price_asc"; | ||
| if (prop.equals("likeCount.value")) return "likes_desc"; | ||
| return prop + "_" + order.getDirection().name().toLowerCase(); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| package com.loopers.application.product; | ||
|
|
||
| import java.util.List; | ||
|
|
||
| /** | ||
| * Page<ProductInfo>는 PageImpl 역직렬화 이슈로 Redis에 직접 저장할 수 없어, | ||
| * 캐시 직렬화/역직렬화용 래퍼 record로 분리한다. | ||
| * 복원 시: new PageImpl<>(entry.content(), pageable, entry.totalElements()) | ||
| */ | ||
| public record ProductListCacheEntry(List<ProductInfo> content, long totalElements) { | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -5,6 +5,7 @@ | |
| import jakarta.persistence.Column; | ||
| import jakarta.persistence.EmbeddedId; | ||
| import jakarta.persistence.Entity; | ||
| import jakarta.persistence.Index; | ||
| import jakarta.persistence.PrePersist; | ||
| import jakarta.persistence.PreUpdate; | ||
| import jakarta.persistence.Table; | ||
|
|
@@ -14,7 +15,9 @@ | |
|
|
||
| @Getter | ||
| @Entity | ||
| @Table(name = "likes") | ||
| @Table(name = "likes", indexes = { | ||
| @Index(name = "idx_user_created_at", columnList = "user_id, created_at DESC") | ||
| }) | ||
|
Comment on lines
+18
to
+20
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 |
||
| public class Like { | ||
|
|
||
| @EmbeddedId | ||
|
|
||
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.
Replica 읽기와 Master 쓰기를 분리하면 무효화 직후 stale cache를 다시 읽을 수 있다.
운영에서는 Master에서 키를 삭제하거나 갱신해도 Replica 복제가 늦으면 바로 다음 조회가 이전 값을 다시 읽을 수 있다. 그러면 이 PR이 의도한 update/delete 후 즉시 일관성이 깨지고, 단일 Redis로 도는 테스트에서는 이 문제가 드러나지 않는다. 수정안은 최소한 캐시 무효화 직후 정확성이 필요한 목록/상세 조회는 같은 노드에서 읽고 쓰도록 통일하는 것이다. 추가 테스트로는 읽기/쓰기 Redis를 분리한 환경에서 수정·삭제 직후 GET이 stale 응답을 내지 않는지 검증하면 된다.