Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,4 @@ out/
### Claude Code ###
*.md
!docs/**/*.md
!blog/**/*.md
3 changes: 3 additions & 0 deletions apps/commerce-api/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ dependencies {
implementation(project(":supports:logging"))
implementation(project(":supports:monitoring"))

// cache
implementation("com.github.ben-manes.caffeine:caffeine")
Comment on lines +9 to +10
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail
# 목적: caffeine 버전이 직접 고정되는지, 혹은 BOM/버전 카탈로그에서 관리되는지 확인한다.
# 기대 결과: direct version, version catalog entry, 또는 platform/BOM 선언 중 하나가 보여야 한다.
rg -n --hidden -g 'build.gradle.kts' -g 'settings.gradle.kts' -g 'libs.versions.toml' \
  'com\.github\.ben-manes\.caffeine:caffeine|spring-boot-dependencies|platform\(|enforcedPlatform\(|versionCatalogs|caffeine'

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 183


🏁 Script executed:

# 루트 레벨 build.gradle.kts 파일 확인
cat -n build.gradle.kts 2>/dev/null | head -50 || echo "Root build.gradle.kts not found"

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 1713


🏁 Script executed:

# settings.gradle.kts에서 버전 카탈로그 설정 확인
cat -n settings.gradle.kts 2>/dev/null | head -100 || echo "settings.gradle.kts not found"

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 1230


🏁 Script executed:

# libs.versions.toml 파일 위치 및 내용 확인
find . -name "libs.versions.toml" -type f -exec echo "Found: {}" \; -exec cat -n {} \;

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 67


🏁 Script executed:

# 전체 프로젝트 구조에서 버전 관리 파일 확인
git ls-files | grep -E '(gradle|version|catalog)' | head -20

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 542


🏁 Script executed:

cat -n gradle.properties

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 747


🏁 Script executed:

cat -n apps/commerce-api/build.gradle.kts

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 1477


🏁 Script executed:

rg -n "caffeine" modules/*/build.gradle.kts apps/*/build.gradle.kts supports/*/build.gradle.kts

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 정책 변경으로 운영 환경에서 예기치 않은 장애 분석이 어려워진다.

수정안:

  1. gradle.propertiescaffeine버전Version=3.1.x 형태로 명시
  2. apps/commerce-api/build.gradle.kts 라인 10을 implementation("com.github.ben-manes.caffeine:caffeine:${project.properties["caffeineVersion"]}") 형태로 변경
  3. 또는 버전을 직접 고정: implementation("com.github.ben-manes.caffeine:caffeine:3.1.x")

추가로 CI에서 의존성 버전을 검증하는 태스크(dependencyInsight, 또는 lockfile 체크)를 추가하면 버전 드리프트를 방지할 수 있다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/commerce-api/build.gradle.kts` around lines 9 - 10, The caffeine
dependency is currently floating; add a fixed version key in gradle.properties
(e.g., caffeineVersion=3.1.x) and update the implementation declaration in the
build script from "com.github.ben-manes.caffeine:caffeine" to reference that
property (use project.properties["caffeineVersion"]) or hard-code
"com.github.ben-manes.caffeine:caffeine:3.1.x"; optionally add a CI dependency
verification step (dependencyInsight or a lockfile check task) to prevent future
drift.


// web
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-validation")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

좋아요 행 변경과 likeCount 갱신 결과를 함께 검증해야 한다.

incrementLikeCountdecrementLikeCount는 영향 행 수를 반환하지만 지금은 무시한다. 운영에서는 상품 soft delete나 경쟁 상황으로 0건 업데이트가 나와도 트랜잭션이 정상 종료되어 Like 레코드와 비정규화 카운터가 어긋나고, 이후 목록 정렬과 캐시 값이 틀어질 수 있다.

영향 행 수가 1이 아니면 CoreException을 던져 전체 트랜잭션을 롤백해야 한다.

추가로 저장소가 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
Verify each finding against the current code and only fix it if needed.

In `@apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java`
around lines 32 - 33, When saving/removing likes in LikeFacade, check the return
value of productRepository.incrementLikeCount(productId) and
decrementLikeCount(productId) and if the returned affected rows != 1 throw a
CoreException so the surrounding transaction rolls back; update the LikeFacade
methods that call likeRepository.save(...) and likeRepository.delete(...) to
validate the update count from productRepository and throw CoreException on any
non-1 result, and add unit/integration tests that stub productRepository to
return 0 for both add and remove paths to assert the transaction is rolled back
and no counter drift occurs.

}

@Transactional
Expand All @@ -40,6 +41,7 @@ public void removeLike(Long memberId, Long productId) {
}

likeRepository.delete(likeOpt.get());
productRepository.decrementLikeCount(productId);
}

public List<Like> getLikesByMemberId(Long memberId) {
Expand Down
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
Expand Up @@ -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;

Expand All @@ -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
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

find . -name "ProductFacade.java" -type f | head -20

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 169


🏁 Script executed:

git ls-files | grep -i "producfacade" | head -20

Repository: 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 -20

Repository: 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 -100

Repository: 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.java

Repository: 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 3

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 8129


🏁 Script executed:

rg "CoreException" --type java -A 2 | head -50

Repository: 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 -200

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 8339


🏁 Script executed:

rg "getAllProductsCached" -B 2 -A 10

Repository: 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 3

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 2265


🏁 Script executed:

rg "MAX_PAGE_SIZE\|PAGE_SIZE" --type java

Repository: 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.java

Repository: 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 2

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 67


🏁 Script executed:

rg "IllegalArgumentException\|INVALID_REQUEST\|BAD_REQUEST" --type java | head -30

Repository: 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.java

Repository: 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 5

Repository: 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 8

Repository: 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 java

Repository: 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 1

Repository: 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.java

Repository: 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 -50

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 67


🏁 Script executed:

rg "IllegalArgumentException" apps/commerce-api/src/test -l | head -5

Repository: 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 -100

Repository: 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 1

Repository: 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 java

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 67


캐시 조회 전 페이지/정렬 파라미터를 검증해야 한다.

현재 구현은 page, size, sort를 그대로 캐시 키로 사용하고 PageRequest 생성에 사용한다. 검증이 없으면 음수 page, 0 이하의 size, 지원하지 않는 sort 문자열이 캐시 포트로 전달되어 불필요한 캐시 엔트리를 생성하고 DB 부하를 증가시킨다. PageRequest.of()는 유효하지 않은 값에 대해 프레임워크 예외를 발생시키는데, 이를 통일된 에러 처리로 변환하지 않으면 응답 형식이 일관성 없어진다.

수정안: cache lookup 전에 요청 파라미터를 검증하고 실패 시 CoreException(ErrorType.BAD_REQUEST)로 변환한다. 구체적으로 page >= 0, 1 <= size <= MAX_PAGE_SIZE (상수 정의 필요), 허용된 sort 값만 통과시켜야 한다.

추가 테스트: page=-1, page<0, size=0, size<0, size>MAX_PAGE_SIZE, 지원하지 않는 sort 각각에서 BAD_REQUEST 응답이 내려가고 캐시 포트가 호출되지 않는지 검증한다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java`
around lines 69 - 85, The getAllProductsCached method currently uses page, size,
and sort directly for cache lookup and PageRequest creation, which can produce
invalid inputs and inconsistent exceptions; before calling
productCachePort.getProductList or PageRequest.of, validate that page >= 0, 1 <=
size <= MAX_PAGE_SIZE (define MAX_PAGE_SIZE), and that sort is one of the
allowed values, and if any check fails throw new
CoreException(ErrorType.BAD_REQUEST); ensure validation happens at the top of
getAllProductsCached (reference method name) so invalid requests never call
productCachePort or create PageRequest, and add unit tests asserting BAD_REQUEST
and that productCachePort is not invoked for invalid page/size/sort cases.

}

// ── 기존 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));

Expand All @@ -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
Expand All @@ -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;
}

Expand All @@ -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())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
@Entity
@Table(name = "likes", uniqueConstraints = {
@UniqueConstraint(name = "uk_likes_member_product", columnNames = {"member_id", "product_id"})
}, indexes = {
@Index(name = "idx_likes_product_id", columnList = "product_id")
})
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 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 -20

Repository: 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 -15

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 304


🏁 Script executed:

cat -n modules/jpa/src/main/resources/jpa.yml

Repository: 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.java

Repository: 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 -40

Repository: 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 sql

Repository: 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 -100

Repository: 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 -100

Repository: 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 -150

Repository: 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/ -A3

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 10171


like_count 컬럼 배포는 명시적 DDL 스크립트 없이 진행 불가하다.

코드 레벨에서는 안전하게 설계되었다(nullable=false, atomic UPDATE, 배치 드리프트 보정). 하지만 프로덕션 환경은 ddl-auto=none으로 설정되어 있어, 사전에 별도의 DDL이 적용되어야 한다. 현재 레포지토리에는 마이그레이션 도구(Flyway/Liquibase)가 없고 SQL 파일도 없으므로, 수동 DDL 스크립트를 제공하거나 Flyway 마이그레이션 파일을 추가해야 한다.

배포 순서는 다음과 같아야 한다:

  1. DB에 like_count INT NOT NULL DEFAULT 0 컬럼 추가
  2. 기존 상품 행 백필 (필요시 UPDATE product SET like_count=0)
  3. 인덱스 생성
  4. 애플리케이션 코드 배포
  5. 배치 동기화 작업 실행

또한 코드 리뷰 시 ddl-auto=none 상태에서 스키마 검증 테스트를 추가하는 것을 권장한다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java`
around lines 12 - 16, Product 엔티티에 새로 추가된 like_count 컬럼과 인덱스는 프로덕션에서
ddl-auto=none 설정 때문에 애플리케이션 배포만으로는 생성되지 않으니, Product 엔티티(product class) 변경에 맞추어
Flyway 또는 수동 SQL 마이그레이션을 추가해 DB 스키마를 먼저 적용하도록 하세요: (1) ALTER TABLE product ADD
COLUMN like_count INT NOT NULL DEFAULT 0; (2) 백필이 필요한 경우 UPDATE product SET
like_count=0; (3) CREATE INDEX statements for idx_product_like_count,
idx_product_brand_like_count, idx_product_brand_price; (4) 그 다음 애플리케이션 배포 및 배치
동기화 실행; 또한 릴리스 파이프라인 또는 테스트에 스키마 검증(ddl-auto=none 환경에서 Product 엔티티 필드가 DB에 존재하는지
확인하는 통합 테스트)을 추가하세요.

})
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
Expand All @@ -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;
Expand Down
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
@@ -1,5 +1,8 @@
package com.loopers.domain.product;

import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;

import java.util.List;
import java.util.Optional;

Expand All @@ -14,4 +17,12 @@ public interface ProductRepository {
List<ProductWithBrand> findAllWithBrand();
List<ProductWithBrand> findAllWithBrand(String sort);
List<ProductWithBrand> findAllByBrandIdWithBrand(Long brandId);

// 페이지네이션 조회 (Brand JOIN)
Page<ProductWithBrand> findAllWithBrand(String sort, Pageable pageable);
Page<ProductWithBrand> findAllByBrandIdWithBrand(Long brandId, String sort, Pageable pageable);

// likeCount atomic 증감 (엔티티 로딩 없이 SQL 직접 실행)
int incrementLikeCount(Long productId);
int decrementLikeCount(Long productId);
}
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
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

L1 목록 캐시도 세대 기반 무효화를 이해해야 한다.

현재 L1 목록 키에는 version이 없고 evictProductList()는 전체 invalidateAll()만 수행한다. 운영에서는 좋아요 이벤트가 몰릴 때마다 무관한 brand/page 엔트리까지 함께 비워져 Caffeine hit ratio가 급락하고, Redis/DB read amplification으로 이어진다. 수정안: ProductCachePort에 목록 세대(version) 개념을 명시적으로 올려 L1/L2가 같은 세대로 키를 만들게 하거나, pub/sub 등으로 L1 세대를 동기화해 invalidateAll()은 상품 CUD에만 남기는 편이 안전하다. 추가 테스트: 여러 brand/page 목록을 미리 적재한 뒤 like성 무효화를 반복해도 전체 clear가 일어나지 않고 이전 세대 엔트리만 우회되는지 검증해야 한다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/main/java/com/loopers/infrastructure/product/CaffeineProductCacheAdapter.java`
around lines 53 - 65, The L1 list cache currently has no generation/versioning
so evictProductList() calling listCache.invalidateAll() clears unrelated
brand/page entries; change the contract in ProductCachePort to include a
list-generation (version) for list keys and update listKey(Long brandId, String
sort, int page, int size) to incorporate that generation, then modify
evictProductList() to increment or set the generation for the affected list
scope instead of invalidateAll() (or implement a pub/sub sync to bump L1
generation), and add tests that pre-warm multiple brand/page entries and
exercise repeated like/unlike invalidations to assert only the relevant
generation becomes bypassed while other brand/page entries remain cached.

}
Loading