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
Original file line number Diff line number Diff line change
Expand Up @@ -7,75 +7,79 @@
import com.loopers.domain.product.ProductSortType;
import com.loopers.domain.product.ProductStatus;
import lombok.RequiredArgsConstructor;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.cache.annotation.Caching;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Component;

import java.util.List;
import java.util.Map;

@RequiredArgsConstructor
@Component
public class ProductFacade {

public static final String PRODUCT_DETAIL_CACHE = "productDetail";
public static final String PRODUCT_LIST_CACHE = "productList";

private final ProductService productService;
private final LikeService likeService;

@Cacheable(cacheNames = PRODUCT_DETAIL_CACHE, key = "'product:detail:' + #id")
public ProductInfo getProduct(Long id) {
ProductModel product = productService.getProduct(id);
long likeCount = likeService.getLikeCount(id);
return ProductInfo.from(product, likeCount);
return ProductInfo.from(product);
}

public Page<ProductInfo> getAll(Pageable pageable, ProductSortType sortType) {
@Cacheable(
cacheNames = PRODUCT_LIST_CACHE,
key = "'product:list:brand:' + (#brandId == null ? 'all' : #brandId) + ':sort:' + #sortType.name() + ':page:' + #pageable.pageNumber + ':size:' + #pageable.pageSize"
)
public ProductPageInfo getAll(Pageable pageable, ProductSortType sortType, Long brandId) {
Pageable sortedPageable = applySorting(pageable, sortType);

Page<ProductModel> products = productService.getAll(sortedPageable, sortType);

List<Long> productIds = products.getContent().stream()
.map(ProductModel::getId)
.toList();

Map<Long, Long> likeCounts = likeService.getLikeCountsByProductIds(productIds);

return products.map(product -> ProductInfo.from(product, likeCounts.getOrDefault(product.getId(), 0L)));
Page<ProductModel> products = productService.getAll(sortedPageable, sortType, brandId);
return ProductPageInfo.from(products.map(ProductInfo::from));
}

@CacheEvict(cacheNames = PRODUCT_LIST_CACHE, allEntries = true)
public ProductInfo register(Long brandId, String name, Long price, String description, int stockQuantity, ProductStatus status) {
ProductModel product = productService.register(brandId, name, price, description, stockQuantity, status);
return ProductInfo.from(product, 0L);
return ProductInfo.from(product);
}

@Caching(evict = {
@CacheEvict(cacheNames = PRODUCT_DETAIL_CACHE, key = "'product:detail:' + #id"),
@CacheEvict(cacheNames = PRODUCT_LIST_CACHE, allEntries = true)
})
public ProductInfo update(Long id, Long brandId, String name, Long price, String description, int stockQuantity, ProductStatus status) {
ProductModel product = productService.update(id, brandId, name, price, description, stockQuantity, status);
long likeCount = likeService.getLikeCount(id);
return ProductInfo.from(product, likeCount);
return ProductInfo.from(product);
}

@Caching(evict = {
@CacheEvict(cacheNames = PRODUCT_DETAIL_CACHE, key = "'product:detail:' + #id"),
@CacheEvict(cacheNames = PRODUCT_LIST_CACHE, allEntries = true)
})
public void delete(Long id) {
productService.delete(id);
}

public List<ProductInfo> getMyLikedProducts(Long userId) {
List<LikeModel> likes = likeService.getMyLikes(userId);

List<Long> productIds = likes.stream()
.map(like -> like.getProduct().getId())
.toList();

Map<Long, Long> likeCounts = likeService.getLikeCountsByProductIds(productIds);

return likes.stream()
.map(like -> ProductInfo.from(like.getProduct(), likeCounts.getOrDefault(like.getProduct().getId(), 0L)))
.map(like -> ProductInfo.from(like.getProduct()))
.toList();
}

private Pageable applySorting(Pageable pageable, ProductSortType sortType) {
return switch (sortType) {
case LATEST -> PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(), Sort.by(Sort.Direction.DESC, "createdAt"));
case PRICE_ASC -> PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(), Sort.by(Sort.Direction.ASC, "price"));
case LATEST -> PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(), Sort.by(Sort.Direction.DESC, "createdAt").and(Sort.by(Sort.Direction.DESC, "id")));
case PRICE_ASC -> PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(), Sort.by(Sort.Direction.ASC, "price").and(Sort.by(Sort.Direction.ASC, "id")));
case LIKES_DESC -> PageRequest.of(pageable.getPageNumber(), pageable.getPageSize());
};
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
package com.loopers.application.product;

import com.fasterxml.jackson.annotation.JsonTypeInfo;
import com.loopers.domain.product.ProductModel;

import java.time.ZonedDateTime;

@JsonTypeInfo(use = JsonTypeInfo.Id.CLASS)
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
# JsonTypeInfo CLASS 사용 지점과 Jackson default typing 설정을 확인한다.
rg -n -C3 'JsonTypeInfo\\.Id\\.CLASS|activateDefaultTyping\\(|DefaultTyping\\.NON_FINAL'

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

Length of output: 261


🏁 Script executed:

#!/bin/bash
# JsonTypeInfo CLASS 사용과 Jackson default typing 설정을 확인한다.
# Rust regex 문법에 맞게 수정

echo "=== 1. JsonTypeInfo.Id.CLASS 검색 ==="
rg -n 'JsonTypeInfo\.Id\.CLASS' -A 2 -B 2

echo -e "\n=== 2. activateDefaultTyping 검색 ==="
rg -n 'activateDefaultTyping' -A 2 -B 2

echo -e "\n=== 3. DefaultTyping.NON_FINAL 검색 ==="
rg -n 'DefaultTyping\.NON_FINAL' -A 2 -B 2

echo -e "\n=== 4. ProductInfo.java 전체 내용 ==="
find . -name 'ProductInfo.java' -type f

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

Length of output: 2686


🏁 Script executed:

#!/bin/bash
# ProductInfo.java 파일 직접 확인
find . -name 'ProductInfo.java' -type f -exec wc -l {} \;

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

Length of output: 170


🏁 Script executed:

#!/bin/bash
# ProductInfo.java 내용 확인
find . -path '*product/ProductInfo.java' -type f -exec cat -n {} \;

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

Length of output: 1333


🏁 Script executed:

#!/bin/bash
# CacheConfig 검색
find . -name '*CacheConfig.java' -type f

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

Length of output: 162


🏁 Script executed:

#!/bin/bash
# CacheConfig.java 전체 내용 확인
cat -n apps/commerce-api/src/main/java/com/loopers/support/config/CacheConfig.java

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

Length of output: 4769


🏁 Script executed:

#!/bin/bash
# 보안/역직렬화 관련 테스트 검색
find . -name '*Test*.java' -o -name '*test*.java' | xargs rg -l 'deserial|security|malicious|polymorphic|CacheConfig' 2>/dev/null

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

Length of output: 504


🏁 Script executed:

#!/bin/bash
# 다른 Jackson 보안 설정 확인
rg -n 'ObjectMapper|JsonMapper|disableDefaultTyping|StdTypeResolverBuilder|TypeResolverBuilder' apps/commerce-api/src/main/java/com/loopers/support/config/ -A 1 -B 1

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

Length of output: 2413


🏁 Script executed:

#!/bin/bash
# ProductInfo와 ProductPageInfo 사용처 확인 - API 응답/캐시 계층
rg -l 'ProductInfo|ProductPageInfo' apps/commerce-api/src/main/java --type java | head -20

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

Length of output: 783


🏁 Script executed:

#!/bin/bash
# Jackson 및 Redis 보안 관련 설정 확인
rg -n 'getPolymorphicTypeValidator|LaissezFaireSubTypeValidator|BasicPolymorphicTypeValidator|allowIfBaseType|allowIfClassIs' apps/commerce-api/ -A 2 -B 2

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

Length of output: 728


🏁 Script executed:

#!/bin/bash
# 캐시 관련 테스트 또는 보안 테스트 확인
find apps/commerce-api/src/test -name '*.java' -exec grep -l 'Cache\|Redis\|polymorphic\|deserial' {} \;

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

Length of output: 335


🏁 Script executed:

#!/bin/bash
# Spring Boot 및 Jackson 버전 확인 (pom.xml 또는 build.gradle)
find . -name 'pom.xml' -o -name 'build.gradle' | head -5

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

Length of output: 67


🏁 Script executed:

#!/bin/bash
# 프로젝트 루트의 build 파일에서 Jackson 버전 확인
cat pom.xml 2>/dev/null | grep -A 2 -B 2 'jackson' || cat build.gradle 2>/dev/null | grep -i 'jackson'

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

Length of output: 67


🏁 Script executed:

#!/bin/bash
# ProductCacheTest 내용 확인
cat -n apps/commerce-api/src/test/java/com/loopers/application/product/ProductCacheTest.java

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

Length of output: 11211


🏁 Script executed:

#!/bin/bash
# 프로젝트 빌드 파일 위치 확인
find . -maxdepth 3 -type f \( -name 'pom.xml' -o -name 'build.gradle*' -o -name 'gradle.properties' \)

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

Length of output: 449


🏁 Script executed:

#!/bin/bash
# Jackson 의존성 확인
find . -name 'pom.xml' -exec cat {} \; 2>/dev/null | grep -A 5 'jackson-databind'

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

Length of output: 67


🏁 Script executed:

#!/bin/bash
# 대안: 모든 pom.xml 찾아 Jackson 버전 확인
find . -type f -name 'pom.xml' | xargs grep -h 'jackson' 2>/dev/null | grep -E 'version|artifactId' | head -20

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

Length of output: 67


캐시 역직렬화 공격 표면을 줄이기 위해 JsonTypeInfo.Id.CLASS 제거가 필요하다.

운영 관점에서 Redis 캐시 값 변조 시 의도하지 않은 타입 역직렬화 위험이 있다. LINE 8의 @JsonTypeInfo(use = JsonTypeInfo.Id.CLASS) 어노테이션은 CacheConfigactivateDefaultTyping(getPolymorphicTypeValidator(), NON_FINAL, ...)과 함께 작동하면서 캐시 페이로드의 @class 필드를 통해 임의 클래스 인스턴스화를 허용한다. Redis 접근 제어가 완벽하지 않은 환경에서는 캐시 데이터 변조로 보안 침해가 발생할 수 있다.

수정안:

  1. @JsonTypeInfo 어노테이션 제거
  2. CacheConfig에서 명시적 패키지 화이트리스트 기반 PolymorphicTypeValidator 또는 캐시별 직렬화기 도입
  3. 악의적인 @class 값을 포함한 캐시 페이로드 역직렬화가 거부되는지 검증하는 보안 회귀 테스트 추가

ProductCacheTest.java는 캐시 히트/미스와 무효화만 검증하므로, 변조된 캐시 데이터에 대한 방어 테스트가 없다.

🤖 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/ProductInfo.java`
at line 8, Remove the unsafe polymorphic type hint on ProductInfo by deleting
the `@JsonTypeInfo`(use = JsonTypeInfo.Id.CLASS) annotation from the ProductInfo
class, and instead harden CacheConfig by replacing activateDefaultTyping(...)
with an explicit PolymorphicTypeValidator that white-lists only trusted packages
(or register explicit serializers per cache) via
CacheConfig.getPolymorphicTypeValidator()/activateDefaultTyping changes; then
add a security regression test (e.g., extend ProductCacheTest) that attempts to
deserialize a manipulated cache payload containing a malicious "@class" and
asserts the deserialization is rejected/throws to ensure tampered cache entries
are not instantiated.

public record ProductInfo(
Long id,
Long brandId,
Expand All @@ -17,7 +19,7 @@ public record ProductInfo(
ZonedDateTime createdAt,
ZonedDateTime updatedAt
) {
public static ProductInfo from(ProductModel product, long likeCount) {
public static ProductInfo from(ProductModel product) {
return new ProductInfo(
product.getId(),
product.getBrand().getId(),
Expand All @@ -27,7 +29,7 @@ public static ProductInfo from(ProductModel product, long likeCount) {
product.getDescription(),
product.getStockQuantity(),
product.getStatus().name(),
likeCount,
product.getLikeCount(),
product.getCreatedAt(),
product.getUpdatedAt()
);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package com.loopers.application.product;

import com.fasterxml.jackson.annotation.JsonTypeInfo;
import org.springframework.data.domain.Page;

import java.util.List;

@JsonTypeInfo(use = JsonTypeInfo.Id.CLASS)
public record ProductPageInfo(
List<ProductInfo> content,
int page,
int size,
long totalElements,
int totalPages
) {
public static ProductPageInfo from(Page<ProductInfo> page) {
return new ProductPageInfo(
page.getContent(),
page.getNumber(),
page.getSize(),
page.getTotalElements(),
page.getTotalPages()
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,11 @@
@Entity
@Table(
name = "likes",
uniqueConstraints = @UniqueConstraint(columnNames = {"user_id", "product_id"})
uniqueConstraints = @UniqueConstraint(columnNames = {"user_id", "product_id"}),
indexes = {
@Index(name = "idx_likes_product_id", columnList = "product_id"),
@Index(name = "idx_likes_user_id", columnList = "user_id")
}
)
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
import com.loopers.support.error.CoreException;
import com.loopers.support.error.ErrorType;
import lombok.RequiredArgsConstructor;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Caching;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
Expand All @@ -20,8 +22,12 @@ public class LikeService {
private final ProductRepository productRepository;

@Transactional
@Caching(evict = {
@CacheEvict(cacheNames = "productDetail", key = "'product:detail:' + #productId"),
@CacheEvict(cacheNames = "productList", allEntries = true)
Comment on lines +26 to +27
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

좋아요 한 번마다 productList 전체 eviction은 캐시 목표를 스스로 무너뜨린다.

운영에서는 인기 상품의 like/unlike 한 번마다 모든 브랜드·페이지·정렬 조합의 list cache가 함께 비워져 read burst가 다시 DB로 쏠린다. 관리자 저빈도 수정과 달리 사용자 좋아요는 고빈도이므로, 수정안은 brand 범위나 versioned key 방식으로 eviction 범위를 좁히는 것이다. 추가 테스트로 brand A 상품의 like가 brand B의 list cache key를 보존하는지 검증해달라. Based on learnings, full cache invalidation was accepted only for low-frequency admin writes with a short TTL.

Also applies to: 50-51

🤖 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/like/LikeService.java`
around lines 26 - 27, The current `@CacheEvict` on LikeService evicts allEntries
from "productList" which causes full list cache thrashing; change it to evict
only the brand-scoped or versioned key instead of allEntries: replace
`@CacheEvict`(cacheNames = "productList", allEntries = true) with a targeted
eviction like `@CacheEvict`(cacheNames = "productList", key =
"'product:list:brand:' + `#product.brand.id`") or use a versioned key strategy
(e.g., include a productListVersionProvider token in the key and evict only that
token for the product's brand inside the like/unlike method such as likeProduct
or toggleLike), and add an automated test that likes a product of brand A and
asserts the cached entry for brand B's productList key remains present to verify
isolation.

})
Comment on lines 24 to +28
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

🌐 Web query:

In Spring Framework, does @CacheEvicton a@transactional method run after method return or after transaction commit, and what is the recommended way to evict cache strictly after commit?

💡 Result:

  • When does @CacheEvict run? By default, @CacheEvict performs the eviction after the annotated method is successfully invoked/returns (beforeInvocation=false by default). (docs.spring.io)
  • Is that “after transaction commit”? Not necessarily. @CacheEvict is not inherently transaction-phase aware; without extra support it runs “after method invocation”, which can still be before the surrounding @Transactional advice actually commits, depending on advisor ordering.

Recommended way to evict strictly after commit

Use Spring’s transaction-aware cache support so evictions are deferred to the transaction after-commit callback:

  • Wrap your CacheManager with TransactionAwareCacheManagerProxy, or use a transaction-aware cache/decorator (or enable “transactionAware” on cache managers that support it). Spring’s transaction-aware cache support performs put/evict/clear only in the after-commit phase of a successful transaction. (docs.spring.io)
  • If your cache manager is an AbstractTransactionSupportingCacheManager, you can typically enable this by setting setTransactionAware(true). (stackoverflow.com)

Citations:


🏁 Script executed:

cat apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java

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

Length of output: 2994


🏁 Script executed:

rg "TransactionAwareCacheManagerProxy|setTransactionAware|@TransactionalEventListener" --type java

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

Length of output: 67


🏁 Script executed:

fd "CacheConfig|CacheManager|cache" --extension java | xargs grep -l "setTransactionAware\|TransactionAware" 2>/dev/null

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

Length of output: 69


🏁 Script executed:

fd "application\.(yml|yaml|properties)" --type f | xargs cat 2>/dev/null | grep -A 10 -i cache

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

Length of output: 67


🏁 Script executed:

fd ".*Config\\.java" --type f | xargs grep -l "cache\|Cache" 2>/dev/null | head -5 | xargs cat

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

Length of output: 4146


🏁 Script executed:

rg "@Bean.*CacheManager|@Bean.*CacheConfig|setTransactionAware" --type java -A 3

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

Length of output: 67


트랜잭션 commit 이후 무효화를 보장하도록 after-commit 패턴으로 변경해야 한다.

@CacheEvict는 기본적으로 메서드 반환 직후에 동작하므로, @Transactional이 감싼 트랜잭션의 commit 시점과 일치하지 않는다. 실제 운영에서는 like/unlike 트랜잭션이 아직 commit되지 않았는데 캐시가 먼저 비워지면, 동시 조회가 이전 상태를 DB에서 읽어 캐시에 다시 채우게 되어 최대 5분 동안 stale data가 남을 수 있다.

수정안은 두 가지다:

  1. ProductService.evictProductListAfterCommit()처럼 @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)로 옮기기
  2. 또는 CacheConfig에서 RedisCacheManagerTransactionAwareCacheManagerProxy로 감싸거나 transaction-aware 설정 활성화

추가로 productList 전체 무효화(allEntries = true)를 매 like/unlike마다 실행하면 1분 TTL 캐시의 hit rate가 급격히 떨어진다. 관리자 수정 빈도가 낮은 시점이라면 현재 설정이 받아들여질 수 있지만, 사용자 like 빈도가 높으면 선택적 무효화(예: 브랜드별)로 전환을 고려해야 한다.

commit 전 재캐시 방지 검증: 쓰기 트랜잭션 진행 중 동시 조회를 붙여 캐시가 먼저 비워진 후 old value로 재캐시되지 않음을 테스트해달라.

Also applies to: 48-52

🤖 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/like/LikeService.java`
around lines 24 - 28, The current `@CacheEvict` on the like/unlike method in
LikeService executes before the transaction commit and can cause stale
recaching; change eviction to happen after commit by moving eviction logic to a
TransactionalEventListener(method like evictProductListAfterCommit annotated
with `@TransactionalEventListener`(phase = TransactionPhase.AFTER_COMMIT)) or
enable a transaction-aware cache manager (wrap RedisCacheManager with
TransactionAwareCacheManagerProxy or enable transaction-aware setting in
CacheConfig) and remove or replace the expensive allEntries = true on
"productList" with a more targeted eviction strategy (e.g., brand or
product-list segment key) to avoid huge hit-rate drops; finally add a
concurrency test that performs a like/unlike transaction while a concurrent read
triggers cache population to ensure the cache is not repopulated with the old
value before commit.

public LikeModel like(Long userId, Long productId) {
ProductModel product = productRepository.findById(productId)
ProductModel product = productRepository.findByIdForUpdate(productId)
.orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 상품입니다."));
Comment on lines +30 to 31
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
fd 'ProductJpaRepository.java' -x sed -n '1,220p' {}

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

Length of output: 2327


🏁 Script executed:

fd 'ProductJpaRepository.java' -x wc -l {}

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

Length of output: 182


🏁 Script executed:

fd 'ProductRepositoryImpl.java' -x head -100 {}

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

Length of output: 1643


🏁 Script executed:

rg '@Lock' apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java -A 2

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

Length of output: 265


🏁 Script executed:

fd 'ProductJpaRepository.java' -x cat {}

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

Length of output: 2327


🏁 Script executed:

fd 'LikeService.java' -x sed -n '25,60p' {}

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

Length of output: 1686


🏁 Script executed:

fd 'OrderJpaRepository.java' -x rg '@QueryHints|@Lock' -A 3 {}

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

Length of output: 67


🏁 Script executed:

fd 'OrderJpaRepository.java' -x head -100 {}

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

Length of output: 1602


🏁 Script executed:

rg '@QueryHints' -A 2 --type java | head -40

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

Length of output: 67


🏁 Script executed:

rg 'jakarta\.persistence\.lock\.timeout' -A 1 -B 1 --type java

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

Length of output: 67


🏁 Script executed:

rg '@QueryHints' -B 2 -A 3 --type java

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

Length of output: 67


🏁 Script executed:

rg '@Lock' -B 1 -A 3 --type java

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

Length of output: 1472


ProductJpaRepository.findByIdForUpdate()에 락 타임아웃 힌트를 추가해야 한다.

이 메서드는 LikeService.like(), LikeService.unlike()에서 호출되는 핫 경로이다. 현재 @Lock(LockModeType.PESSIMISTIC_WRITE) 만 있고 @QueryHints(QueryHint(name = "jakarta.persistence.lock.timeout", value = "3000"))가 없으면, 동일 상품에 대한 동시 요청에서 일부 스레드가 락을 기다리다 무기한 대기 상태에 빠질 수 있다. 프로젝트 표준에 맞게 다른 WithLock 메서드들과 동일한 3000ms 타임아웃 힌트를 적용하고, 동일 상품 동시 like/unlike 요청에서 타임아웃이 예측 가능한 예외로 매핑되는지 확인하는 통합 테스트를 추가한다.

🤖 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/like/LikeService.java`
around lines 30 - 31, Add a 3000ms pessimistic lock timeout hint to the
repository method used by the hot path: update
ProductJpaRepository.findByIdForUpdate() to include `@QueryHints`(QueryHint(name =
"jakarta.persistence.lock.timeout", value = "3000")) while keeping
`@Lock`(LockModeType.PESSIMISTIC_WRITE) so concurrent
LikeService.like()/LikeService.unlike() callers don’t block indefinitely; also
add an integration test that issues concurrent like/unlike requests against the
same product and asserts that a lock timeout results in the expected predictable
exception mapping (verify the service/exception translator maps the lock timeout
to the agreed application exception).


likeRepository.findByUserIdAndProductId(userId, productId)
Expand All @@ -31,18 +37,28 @@ public LikeModel like(Long userId, Long productId) {

LikeModel like = new LikeModel(userId, product);
try {
return likeRepository.save(like);
LikeModel savedLike = likeRepository.save(like);
product.increaseLikeCount();
return savedLike;
} catch (DataIntegrityViolationException e) {
throw new CoreException(ErrorType.CONFLICT, "이미 좋아요한 상품입니다.");
}
}

@Transactional
@Caching(evict = {
@CacheEvict(cacheNames = "productDetail", key = "'product:detail:' + #productId"),
@CacheEvict(cacheNames = "productList", allEntries = true)
})
public void unlike(Long userId, Long productId) {
ProductModel product = productRepository.findByIdForUpdate(productId)
.orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 상품입니다."));

LikeModel like = likeRepository.findByUserIdAndProductId(userId, productId)
.orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "좋아요가 존재하지 않습니다."));

likeRepository.delete(like);
product.decreaseLikeCount();
}

@Transactional(readOnly = true)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,15 @@
import org.springframework.util.StringUtils;

@Entity
@Table(name = "products")
@Table(
name = "products",
indexes = {
@Index(name = "idx_products_deleted_created_id", columnList = "deleted_at, created_at, id"),
@Index(name = "idx_products_deleted_price_id", columnList = "deleted_at, price, id"),
@Index(name = "idx_products_brand_deleted_like_id", columnList = "brand_id, deleted_at, like_count, id"),
@Index(name = "idx_products_deleted_like_id", columnList = "deleted_at, like_count, id")
}
)
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class ProductModel extends BaseEntity {
Expand All @@ -36,6 +44,9 @@ public class ProductModel extends BaseEntity {
@Column(name = "status", nullable = false)
private ProductStatus status;

@Column(name = "like_count", nullable = false)
private long likeCount;

public ProductModel(BrandModel brand, String name, Long price, String description, int stockQuantity, ProductStatus status) {
validate(brand, name, price, stockQuantity, status);
this.brand = brand;
Expand All @@ -44,6 +55,7 @@ public ProductModel(BrandModel brand, String name, Long price, String descriptio
this.description = description;
this.stockQuantity = stockQuantity;
this.status = status;
this.likeCount = 0L;
}

public void update(BrandModel brand, String name, Long price, String description, int stockQuantity, ProductStatus status) {
Expand All @@ -66,6 +78,17 @@ public void deductStock(int quantity) {
this.stockQuantity -= quantity;
}

public void increaseLikeCount() {
this.likeCount += 1;
}

public void decreaseLikeCount() {
if (this.likeCount <= 0) {
throw new CoreException(ErrorType.CONFLICT, "좋아요 수가 이미 0입니다.");
}
this.likeCount -= 1;
}

private void validate(BrandModel brand, String name, Long price, int stockQuantity, ProductStatus status) {
if (brand == null) {
throw new CoreException(ErrorType.BAD_REQUEST, "브랜드는 필수입니다.");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,6 @@ public interface ProductRepository {
Optional<ProductModel> findById(Long id);
Optional<ProductModel> findByIdForUpdate(Long id);
ProductModel save(ProductModel product);
Page<ProductModel> findAll(Pageable pageable);
Page<ProductModel> findAllOrderByLikesDesc(Pageable pageable);
Page<ProductModel> findAll(Pageable pageable, Long brandId);
Page<ProductModel> findAllOrderByLikesDesc(Pageable pageable, Long brandId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,11 @@ public ProductModel getProduct(Long id) {
}

@Transactional(readOnly = true)
public Page<ProductModel> getAll(Pageable pageable, ProductSortType sortType) {
public Page<ProductModel> getAll(Pageable pageable, ProductSortType sortType, Long brandId) {
if (sortType == ProductSortType.LIKES_DESC) {
return productRepository.findAllOrderByLikesDesc(pageable);
return productRepository.findAllOrderByLikesDesc(pageable, brandId);
}
return productRepository.findAll(pageable);
return productRepository.findAll(pageable, brandId);
}

@Transactional
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

import java.util.Optional;

Expand All @@ -26,8 +27,20 @@ public interface ProductJpaRepository extends JpaRepository<ProductModel, Long>
Page<ProductModel> findAllByDeletedAtIsNull(Pageable pageable);

@Query(
value = "SELECT p FROM ProductModel p JOIN FETCH p.brand LEFT JOIN LikeModel l ON l.product = p WHERE p.deletedAt IS NULL GROUP BY p ORDER BY COUNT(l) DESC",
value = "SELECT p FROM ProductModel p JOIN FETCH p.brand WHERE p.deletedAt IS NULL AND p.brand.id = :brandId",
countQuery = "SELECT COUNT(p) FROM ProductModel p WHERE p.deletedAt IS NULL AND p.brand.id = :brandId"
)
Page<ProductModel> findAllByDeletedAtIsNullAndBrandId(@Param("brandId") Long brandId, Pageable pageable);

@Query(
value = "SELECT p FROM ProductModel p JOIN FETCH p.brand WHERE p.deletedAt IS NULL ORDER BY p.likeCount DESC, p.id DESC",
countQuery = "SELECT COUNT(p) FROM ProductModel p WHERE p.deletedAt IS NULL"
)
Page<ProductModel> findAllOrderByLikesDesc(Pageable pageable);
Page<ProductModel> findAllOrderByLikeCountDesc(Pageable pageable);

@Query(
value = "SELECT p FROM ProductModel p JOIN FETCH p.brand WHERE p.deletedAt IS NULL AND p.brand.id = :brandId ORDER BY p.likeCount DESC, p.id DESC",
countQuery = "SELECT COUNT(p) FROM ProductModel p WHERE p.deletedAt IS NULL AND p.brand.id = :brandId"
)
Page<ProductModel> findAllByBrandIdOrderByLikeCountDesc(@Param("brandId") Long brandId, Pageable pageable);
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,18 @@ public ProductModel save(ProductModel product) {
}

@Override
public Page<ProductModel> findAll(Pageable pageable) {
public Page<ProductModel> findAll(Pageable pageable, Long brandId) {
if (brandId != null) {
return productJpaRepository.findAllByDeletedAtIsNullAndBrandId(brandId, pageable);
}
return productJpaRepository.findAllByDeletedAtIsNull(pageable);
}

@Override
public Page<ProductModel> findAllOrderByLikesDesc(Pageable pageable) {
return productJpaRepository.findAllOrderByLikesDesc(pageable);
public Page<ProductModel> findAllOrderByLikesDesc(Pageable pageable, Long brandId) {
if (brandId != null) {
return productJpaRepository.findAllByBrandIdOrderByLikeCountDesc(brandId, pageable);
}
return productJpaRepository.findAllOrderByLikeCountDesc(pageable);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;

@Tag(name = "Product Admin V1 API", description = "상품 관리자 API 입니다.")
Expand All @@ -14,7 +13,7 @@ public interface ProductAdminV1ApiSpec {
summary = "상품 목록 조회",
description = "상품 목록을 페이징하여 조회합니다."
)
ApiResponse<Page<ProductAdminV1Dto.ProductResponse>> getAll(Pageable pageable);
ApiResponse<ProductAdminV1Dto.ProductListResponse> getAll(Pageable pageable);

@Operation(
summary = "상품 상세 조회",
Expand Down
Loading