-
Notifications
You must be signed in to change notification settings - Fork 44
[Volume 5] 상품 목록 조회 성능, 좋아요 수 정렬 구조 개선 및 인덱스·캐시 적용 #210
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: SukheeChoi
Are you sure you want to change the base?
Changes from all commits
c9d1387
ede254e
ecfb84a
d9b81ad
d328279
913fede
fb1e69f
768da3d
869f4b2
7a7116a
59b3cc8
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 |
|---|---|---|
|
|
@@ -42,3 +42,4 @@ out/ | |
| ### Claude Code ### | ||
| *.md | ||
| !docs/**/*.md | ||
| !blog/**/*.md | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -30,6 +30,7 @@ public void addLike(Long memberId, Long productId) { | |
| } | ||
|
|
||
| likeRepository.save(new Like(memberId, productId)); | ||
| productRepository.incrementLikeCount(productId); | ||
|
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. 좋아요 행 변경과
영향 행 수가 1이 아니면 추가로 저장소가 0을 반환하는 stub으로 add/remove 모두 실패 시 롤백되고 카운터 드리프트가 남지 않는지 테스트해야 한다. Based on learnings, "In the loop-pack-be-l2-vol3-java project, enforce unified error handling by routing errors through CoreException to ApiControllerAdvice to ensure a consistent response format." Also applies to: 43-44 🤖 Prompt for AI Agents |
||
| } | ||
|
|
||
| @Transactional | ||
|
|
@@ -40,6 +41,7 @@ public void removeLike(Long memberId, Long productId) { | |
| } | ||
|
|
||
| likeRepository.delete(likeOpt.get()); | ||
| productRepository.decrementLikeCount(productId); | ||
| } | ||
|
|
||
| public List<Like> getLikesByMemberId(Long memberId) { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| package com.loopers.application.product; | ||
|
|
||
| import com.loopers.interfaces.api.product.ProductDto; | ||
|
|
||
| public interface ProductCachePort { | ||
|
|
||
| // ── 상품 상세 캐시 ── | ||
|
|
||
| ProductDto.ProductResponse getProductDetail(Long productId); | ||
|
|
||
| void putProductDetail(Long productId, ProductDto.ProductResponse response); | ||
|
|
||
| void evictProductDetail(Long productId); | ||
|
|
||
| // ── 상품 목록 캐시 ── | ||
|
|
||
| ProductDto.PagedProductResponse getProductList(Long brandId, String sort, int page, int size); | ||
|
|
||
| void putProductList(Long brandId, String sort, int page, int size, ProductDto.PagedProductResponse response); | ||
|
|
||
| void evictProductList(); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -8,9 +8,13 @@ | |
| import com.loopers.domain.product.vo.Stock; | ||
| import com.loopers.domain.brand.Brand; | ||
| import com.loopers.domain.brand.BrandRepository; | ||
| import com.loopers.interfaces.api.product.ProductDto; | ||
| import com.loopers.support.error.CoreException; | ||
| import com.loopers.support.error.ErrorType; | ||
| import lombok.RequiredArgsConstructor; | ||
| import org.springframework.data.domain.Page; | ||
| import org.springframework.data.domain.PageRequest; | ||
| import org.springframework.data.domain.Pageable; | ||
| import org.springframework.stereotype.Service; | ||
| import org.springframework.transaction.annotation.Transactional; | ||
|
|
||
|
|
@@ -26,21 +30,80 @@ public class ProductFacade { | |
| private final ProductRepository productRepository; | ||
| private final BrandRepository brandRepository; | ||
| private final LikeRepository likeRepository; | ||
| private final ProductCachePort productCachePort; | ||
|
|
||
| // ── 상품 상세 (캐시 적용) ── | ||
|
|
||
| public ProductWithBrand getProductDetail(Long productId) { | ||
| Product product = productRepository.findById(productId) | ||
| .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다.")); | ||
| Brand brand = brandRepository.findById(product.getBrandId()).orElse(null); | ||
| String brandName = (brand != null) ? brand.getName() : null; | ||
| long likeCount = likeRepository.countByProductId(productId); | ||
| return new ProductWithBrand(product, brandName, likeCount); | ||
| return new ProductWithBrand(product, brandName, product.getLikeCount()); | ||
| } | ||
|
|
||
| public ProductDto.ProductResponse getProductDetailCached(Long productId) { | ||
| ProductDto.ProductResponse cached = productCachePort.getProductDetail(productId); | ||
| if (cached != null) { | ||
| return cached; | ||
| } | ||
|
|
||
| ProductWithBrand info = getProductDetail(productId); | ||
| ProductDto.ProductResponse response = ProductDto.ProductResponse.from(info); | ||
| productCachePort.putProductDetail(productId, response); | ||
| return response; | ||
| } | ||
|
|
||
| // ── 상품 목록 (페이지네이션 + 캐시 적용) ── | ||
|
|
||
| public Page<ProductWithBrand> getAllProducts(String sort, Pageable pageable) { | ||
| return productRepository.findAllWithBrand(sort, pageable); | ||
| } | ||
|
|
||
| public Page<ProductWithBrand> getProductsByBrandId(Long brandId, String sort, Pageable pageable) { | ||
| brandRepository.findById(brandId) | ||
| .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "브랜드를 찾을 수 없습니다.")); | ||
| return productRepository.findAllByBrandIdWithBrand(brandId, sort, pageable); | ||
| } | ||
|
|
||
| public ProductDto.PagedProductResponse getAllProductsCached(Long brandId, String sort, int page, int size) { | ||
| ProductDto.PagedProductResponse cached = productCachePort.getProductList(brandId, sort, page, size); | ||
| if (cached != null) { | ||
| return cached; | ||
| } | ||
|
|
||
| Pageable pageable = PageRequest.of(page, size); | ||
| Page<ProductWithBrand> result; | ||
| if (brandId != null) { | ||
| result = getProductsByBrandId(brandId, sort, pageable); | ||
| } else { | ||
| result = getAllProducts(sort, pageable); | ||
| } | ||
|
|
||
| ProductDto.PagedProductResponse response = ProductDto.PagedProductResponse.from(result); | ||
| productCachePort.putProductList(brandId, sort, page, size, response); | ||
| return response; | ||
|
Comment on lines
+69
to
+85
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: find . -name "ProductFacade.java" -type f | head -20Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 169 🏁 Script executed: git ls-files | grep -i "producfacade" | head -20Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 67 🏁 Script executed: git ls-files | grep -i "product" | grep -i "facade" | head -20Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 254 🏁 Script executed: fd "ProductFacade.java"Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 167 🏁 Script executed: cat -n apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java | head -100Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 4769 🏁 Script executed: wc -l apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 171 🏁 Script executed: cat -n apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java | sed -n '69,90p'Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 1108 🏁 Script executed: rg "PageRequest.of" --context 3Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 8129 🏁 Script executed: rg "CoreException" --type java -A 2 | head -50Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 4729 🏁 Script executed: fd "ProductFacadeTest.java" -exec cat {} \;Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 272 🏁 Script executed: cat apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeTest.java | head -200Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 8339 🏁 Script executed: rg "getAllProductsCached" -B 2 -A 10Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 5682 🏁 Script executed: rg "page.*size" apps/commerce-api/src/main/java/com/loopers/interfaces/api/product --type java -B 3 -A 3Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 2265 🏁 Script executed: rg "MAX_PAGE_SIZE\|PAGE_SIZE" --type javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 67 🏁 Script executed: cat apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductController.javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 1232 🏁 Script executed: rg "@Valid\|@NotNull\|@Min\|@Max" apps/commerce-api/src/main/java/com/loopers/interfaces/api/product --type java -B 2 -A 2Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 67 🏁 Script executed: rg "IllegalArgumentException\|INVALID_REQUEST\|BAD_REQUEST" --type java | head -30Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 67 🏁 Script executed: cat apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 1009 🏁 Script executed: rg "sort" apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java -B 3 -A 5Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 1589 🏁 Script executed: rg "toSort" apps/commerce-api/src/main/java/com/loopers/infrastructure/product -B 2 -A 8Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 5511 🏁 Script executed: rg "IllegalArgumentException" apps/commerce-api/src/main/java/com/loopers/infrastructure/product --type javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 67 🏁 Script executed: cat apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java | sed -n '47,80p'Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 1478 🏁 Script executed: rg "case.*->" apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java -A 1Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 240 🏁 Script executed: cat apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java | sed -n '80,100p'Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 732 🏁 Script executed: rg "private Sort toSort" -A 20 apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 532 🏁 Script executed: rg "ApiControllerAdvice\|ExceptionHandler" apps/commerce-api/src/main/java/com/loopers/support --type java -B 2 -A 5 | head -50Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 67 🏁 Script executed: rg "IllegalArgumentException" apps/commerce-api/src/test -l | head -5Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 238 🏁 Script executed: grep -r "page.*size" apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeTest.java | grep -E "0|20|-1"Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 67 🏁 Script executed: cat apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeTest.java | tail -100Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 4104 🏁 Script executed: rg "default -> Sort" apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java -A 1Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 153 🏁 Script executed: rg "PAGE_SIZE\|MAX_SIZE" apps/commerce-api/src/main/java/com/loopers --type javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 67 캐시 조회 전 페이지/정렬 파라미터를 검증해야 한다. 현재 구현은 수정안: cache lookup 전에 요청 파라미터를 검증하고 실패 시 추가 테스트: 🤖 Prompt for AI Agents |
||
| } | ||
|
|
||
| // ── 기존 List 반환 메서드 (하위 호환 + 벤치마크용) ── | ||
|
|
||
| public List<ProductWithBrand> getAllProducts() { | ||
| return enrichWithLikeCount(productRepository.findAllWithBrand()); | ||
| return productRepository.findAllWithBrand(); | ||
| } | ||
|
|
||
| public List<ProductWithBrand> getAllProducts(String sort) { | ||
| return productRepository.findAllWithBrand(sort); | ||
| } | ||
|
|
||
| public List<ProductWithBrand> getProductsByBrandId(Long brandId) { | ||
| brandRepository.findById(brandId) | ||
| .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "브랜드를 찾을 수 없습니다.")); | ||
| return productRepository.findAllByBrandIdWithBrand(brandId); | ||
| } | ||
|
|
||
| // ── 벤치마크 전용: AS-IS 재현 (enrichWithLikeCount + in-memory sort) ── | ||
|
|
||
| public List<ProductWithBrand> getAllProductsNoOptimization(String sort) { | ||
| List<ProductWithBrand> results = enrichWithLikeCount( | ||
| productRepository.findAllWithBrand(sort)); | ||
|
|
||
|
|
@@ -52,18 +115,16 @@ public List<ProductWithBrand> getAllProducts(String sort) { | |
| return results; | ||
| } | ||
|
|
||
| public List<ProductWithBrand> getProductsByBrandId(Long brandId) { | ||
| brandRepository.findById(brandId) | ||
| .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "브랜드를 찾을 수 없습니다.")); | ||
| return enrichWithLikeCount(productRepository.findAllByBrandIdWithBrand(brandId)); | ||
| } | ||
| // ── 상품 CUD (캐시 무효화 포함) ── | ||
|
|
||
| @Transactional | ||
| public Product createProduct(Long brandId, String name, int price, int stockQuantity) { | ||
| brandRepository.findById(brandId) | ||
| .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "브랜드를 찾을 수 없습니다.")); | ||
| Product product = new Product(brandId, name, new Price(price), new Stock(stockQuantity)); | ||
| return productRepository.save(product); | ||
| Product saved = productRepository.save(product); | ||
| productCachePort.evictProductList(); | ||
| return saved; | ||
| } | ||
|
|
||
| @Transactional | ||
|
|
@@ -73,6 +134,8 @@ public Product updateProduct(Long productId, String name, int price, int stockQu | |
| product.changeName(name); | ||
| product.changePrice(new Price(price)); | ||
| product.changeStock(new Stock(stockQuantity)); | ||
| productCachePort.evictProductDetail(productId); | ||
| productCachePort.evictProductList(); | ||
| return product; | ||
| } | ||
|
|
||
|
|
@@ -82,8 +145,12 @@ public void deleteProduct(Long productId) { | |
| .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다.")); | ||
| likeRepository.deleteAllByProductId(productId); | ||
| product.delete(); | ||
| productCachePort.evictProductDetail(productId); | ||
| productCachePort.evictProductList(); | ||
| } | ||
|
|
||
| // ── private: 벤치마크 전용 AS-IS 로직 보존 ── | ||
|
|
||
| private List<ProductWithBrand> enrichWithLikeCount(List<ProductWithBrand> products) { | ||
| List<Long> productIds = products.stream() | ||
| .map(pwb -> pwb.product().getId()) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -10,7 +10,10 @@ | |
|
|
||
| @Entity | ||
| @Table(name = "product", indexes = { | ||
| @Index(name = "idx_product_brand_id", columnList = "brand_id") | ||
| @Index(name = "idx_product_brand_id", columnList = "brand_id"), | ||
| @Index(name = "idx_product_like_count", columnList = "like_count DESC, id DESC"), | ||
| @Index(name = "idx_product_brand_like_count", columnList = "brand_id, like_count DESC, id DESC"), | ||
| @Index(name = "idx_product_brand_price", columnList = "brand_id, price ASC, id ASC") | ||
|
Comment on lines
12
to
+16
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
# 목적: like_count 컬럼 추가와 백필/인덱스 생성 마이그레이션이 실제로 존재하는지 확인한다.
# 기대 결과: DEFAULT 0, NOT NULL, backfill/update, index 생성 관련 구문이 함께 보여야 한다.
rg -n -C2 --hidden -g '!**/build/**' -g '!**/target/**' \
'like_count|idx_product_like_count|idx_product_brand_like_count|idx_product_brand_price|ALTER TABLE product|CREATE INDEX'Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 13886 🏁 Script executed: #!/bin/bash
# Flyway, Liquibase, 또는 기타 DB 마이그레이션 파일 위치 검색
find . -type f \( -name "*V*.sql" -o -name "*V*.SQL" -o -name "*.xml" -o -name "*.yaml" \) \
-path "*/db/migration/*" -o -path "*/flyway/*" -o -path "*/liquibase/*" 2>/dev/null | head -20
echo "---"
# like_count 관련 SQL 마이그레이션 검색
rg -l 'like_count|ALTER TABLE product|CREATE TABLE product' \
--type sql --type xml --type yaml 2>/dev/null | head -20Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 88 🏁 Script executed: #!/bin/bash
# Check for Hibernate DDL-auto and migration tool configuration
echo "=== Checking application config files ==="
fd -e properties -e yml -e yaml | xargs grep -l "ddl-auto\|flyway\|liquibase" 2>/dev/null | head -10
echo "=== Searching for SQL files in resources ==="
find . -path "*/resources/*" -name "*.sql" 2>/dev/null | head -20
echo "=== Checking for db/schema directories ==="
find . -type d \( -name "db" -o -name "schema" -o -name "sql" \) 2>/dev/null
echo "=== Checking build files for migration plugins ==="
rg "flyway|liquibase|migration" -t xml -t gradle -t toml 2>/dev/null | head -15Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 304 🏁 Script executed: cat -n modules/jpa/src/main/resources/jpa.ymlRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 2903 🏁 Script executed: cat -n apps/commerce-api/src/main/java/com/loopers/domain/product/Product.javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 2228 🏁 Script executed: # Check git history for like_count introduction
git log --all --oneline --grep="like_count" | head -20
echo "---"
# Check if this is the first time like_count appears in Product.java
git log --all -p -- apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java \
| grep -B5 -A5 "like_count" | head -40Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 923 🏁 Script executed: # Comprehensive search for any SQL files in the entire repo
echo "=== All SQL files in repo ==="
find . -name "*.sql" -type f 2>/dev/null
echo ""
echo "=== All directories with 'migration', 'schema', 'sql' in name ==="
find . -type d \( -name "*migration*" -o -name "*schema*" -o -name "*sql*" \) 2>/dev/null
echo ""
echo "=== Check docs directory for SQL scripts ==="
find docs -type f 2>/dev/null | grep -i sqlRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 227 🏁 Script executed: # Check if there's any deployment/runbook documentation mentioning like_count or manual DDL
echo "=== Checking for deployment/runbook docs ==="
find docs -type f \( -name "*.md" -o -name "*.txt" \) 2>/dev/null | xargs grep -l "like_count\|DDL\|migration\|deployment" 2>/dev/null | head -10
echo ""
echo "=== Check README for deployment instructions ==="
find . -maxdepth 2 -name "README*" -o -name "INSTALL*" -o -name "DEPLOY*" | xargs cat 2>/dev/null | head -100Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 1387 🏁 Script executed: # Check test files to understand if like_count is assumed to already exist
echo "=== Check test setup for product table schema ==="
find . -path "*/test/*" -name "*Product*.java" -type f | xargs grep -l "like_count\|@Table" 2>/dev/null | head -5
echo ""
echo "=== Batch job test to see if it expects pre-existing schema ==="
cat apps/commerce-batch/src/test/java/com/loopers/batch/job/likecountsync/LikeCountSyncJobConfigTest.java 2>/dev/null | head -100Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 285 🏁 Script executed: # Check the ProductPerformanceTest to see schema setup
cat -n apps/commerce-api/src/test/java/com/loopers/performance/ProductPerformanceTest.java | head -150Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 6801 🏁 Script executed: # Find the incrementLikeCount method to understand how like_count is being modified
echo "=== Search for incrementLikeCount implementation ==="
rg -n "incrementLikeCount" -A5 -B2
echo ""
echo "=== Check if ProductRepository has any methods for like_count management ==="
rg -n "LikeCount\|like_count" apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ -A3Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 10171
코드 레벨에서는 안전하게 설계되었다(nullable=false, atomic UPDATE, 배치 드리프트 보정). 하지만 프로덕션 환경은 배포 순서는 다음과 같아야 한다:
또한 코드 리뷰 시 🤖 Prompt for AI Agents |
||
| }) | ||
| @Getter | ||
| @NoArgsConstructor(access = AccessLevel.PROTECTED) | ||
|
|
@@ -28,6 +31,9 @@ public class Product extends BaseEntity { | |
| @Embedded | ||
| private Stock stock; | ||
|
|
||
| @Column(name = "like_count", nullable = false) | ||
| private int likeCount = 0; | ||
|
|
||
| public Product(Long brandId, String name, Price price, Stock stock) { | ||
| this.brandId = brandId; | ||
| this.name = name; | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,39 @@ | ||
| package com.loopers.domain.product; | ||
|
|
||
| import jakarta.persistence.Column; | ||
| import jakarta.persistence.Entity; | ||
| import jakarta.persistence.Id; | ||
| import jakarta.persistence.Table; | ||
| import lombok.AccessLevel; | ||
| import lombok.Getter; | ||
| import lombok.NoArgsConstructor; | ||
|
|
||
| import java.time.ZonedDateTime; | ||
|
|
||
| @Entity | ||
| @Table(name = "product_like_stats") | ||
| @Getter | ||
| @NoArgsConstructor(access = AccessLevel.PROTECTED) | ||
| public class ProductLikeStats { | ||
|
|
||
| @Id | ||
| @Column(name = "product_id") | ||
| private Long productId; | ||
|
|
||
| @Column(name = "like_count", nullable = false) | ||
| private int likeCount; | ||
|
|
||
| @Column(name = "synced_at", nullable = false) | ||
| private ZonedDateTime syncedAt; | ||
|
|
||
| public ProductLikeStats(Long productId, int likeCount) { | ||
| this.productId = productId; | ||
| this.likeCount = likeCount; | ||
| this.syncedAt = ZonedDateTime.now(); | ||
| } | ||
|
|
||
| public void updateCount(int likeCount) { | ||
| this.likeCount = likeCount; | ||
| this.syncedAt = ZonedDateTime.now(); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| package com.loopers.domain.product; | ||
|
|
||
| import java.util.List; | ||
|
|
||
| public interface ProductLikeStatsRepository { | ||
| ProductLikeStats save(ProductLikeStats stats); | ||
| List<ProductLikeStats> saveAll(List<ProductLikeStats> statsList); | ||
| List<ProductLikeStats> findAll(); | ||
| void syncAllFromLikes(); | ||
| int correctProductLikeCounts(); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,66 @@ | ||
| package com.loopers.infrastructure.product; | ||
|
|
||
| import com.github.benmanes.caffeine.cache.Cache; | ||
| import com.github.benmanes.caffeine.cache.Caffeine; | ||
| import com.loopers.application.product.ProductCachePort; | ||
| import com.loopers.interfaces.api.product.ProductDto; | ||
| import org.springframework.stereotype.Component; | ||
|
|
||
| import java.time.Duration; | ||
|
|
||
| @Component | ||
| public class CaffeineProductCacheAdapter implements ProductCachePort { | ||
|
|
||
| private final Cache<String, ProductDto.ProductResponse> detailCache; | ||
| private final Cache<String, ProductDto.PagedProductResponse> listCache; | ||
|
|
||
| public CaffeineProductCacheAdapter() { | ||
| this.detailCache = Caffeine.newBuilder() | ||
| .maximumSize(500) | ||
| .expireAfterWrite(Duration.ofSeconds(30)) | ||
| .build(); | ||
| this.listCache = Caffeine.newBuilder() | ||
| .maximumSize(200) | ||
| .expireAfterWrite(Duration.ofSeconds(15)) | ||
| .build(); | ||
| } | ||
|
|
||
| @Override | ||
| public ProductDto.ProductResponse getProductDetail(Long productId) { | ||
| return detailCache.getIfPresent(detailKey(productId)); | ||
| } | ||
|
|
||
| @Override | ||
| public void putProductDetail(Long productId, ProductDto.ProductResponse response) { | ||
| detailCache.put(detailKey(productId), response); | ||
| } | ||
|
|
||
| @Override | ||
| public void evictProductDetail(Long productId) { | ||
| detailCache.invalidate(detailKey(productId)); | ||
| } | ||
|
|
||
| @Override | ||
| public ProductDto.PagedProductResponse getProductList(Long brandId, String sort, int page, int size) { | ||
| return listCache.getIfPresent(listKey(brandId, sort, page, size)); | ||
| } | ||
|
|
||
| @Override | ||
| public void putProductList(Long brandId, String sort, int page, int size, ProductDto.PagedProductResponse response) { | ||
| listCache.put(listKey(brandId, sort, page, size), response); | ||
| } | ||
|
|
||
| @Override | ||
| public void evictProductList() { | ||
| listCache.invalidateAll(); | ||
| } | ||
|
|
||
| private String detailKey(Long productId) { | ||
| return "detail:" + productId; | ||
| } | ||
|
|
||
| private String listKey(Long brandId, String sort, int page, int size) { | ||
| String brandPart = brandId != null ? String.valueOf(brandId) : "all"; | ||
| return "list:brand:" + brandPart + ":sort:" + sort + ":page:" + page + ":size:" + size; | ||
| } | ||
|
Comment on lines
+53
to
+65
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. L1 목록 캐시도 세대 기반 무효화를 이해해야 한다. 현재 L1 목록 키에는 version이 없고 🤖 Prompt for AI Agents |
||
| } | ||
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.
🧩 Analysis chain
🏁 Script executed:
Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 183
🏁 Script executed:
Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 1713
🏁 Script executed:
Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 1230
🏁 Script executed:
Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 67
🏁 Script executed:
Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 542
🏁 Script executed:
Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 747
🏁 Script executed:
Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 1477
🏁 Script executed:
Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 183
Caffeine 의존성 버전을 gradle.properties에 명시하거나 직접 고정해야 한다.
현재 caffeine 의존성은 버전이 전혀 관리되지 않는 상태다. Spring Cloud Dependencies BOM에는 caffeine이 포함되지 않으므로, 이 라이브러리는 Gradle의 기본 해석 규칙에 따라 부동(floating) 버전으로 해석된다. 이는 빌드 시점에 따라 의도치 않게 다른 버전이 설치될 수 있으며, 캐시 메모리 사용 특성이나 eviction 정책 변경으로 운영 환경에서 예기치 않은 장애 분석이 어려워진다.
수정안:
gradle.properties에caffeine버전Version=3.1.x형태로 명시apps/commerce-api/build.gradle.kts라인 10을implementation("com.github.ben-manes.caffeine:caffeine:${project.properties["caffeineVersion"]}")형태로 변경implementation("com.github.ben-manes.caffeine:caffeine:3.1.x")추가로 CI에서 의존성 버전을 검증하는 태스크(
dependencyInsight, 또는 lockfile 체크)를 추가하면 버전 드리프트를 방지할 수 있다.🤖 Prompt for AI Agents