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 @@ -5,6 +5,7 @@
import com.loopers.domain.product.OptionRepository;
import com.loopers.domain.product.Product;
import com.loopers.domain.product.ProductRepository;
import com.loopers.application.product.ProductCacheManager;
import com.loopers.support.error.CoreException;
import com.loopers.support.error.ErrorType;
import lombok.RequiredArgsConstructor;
Expand All @@ -18,6 +19,7 @@
public class AdminProductAppService {
private final ProductRepository productRepository;
private final OptionRepository optionRepository;
private final ProductCacheManager productCacheManager;

@Transactional
public Product create(Long brandId, String name, Money basePrice) {
Expand All @@ -36,6 +38,7 @@ public Option createOption(Long productId, String name, Money additionalPrice, i
public Product update(Long id, String name, Money basePrice) {
Product product = getById(id);
product.update(name, basePrice);
productCacheManager.evictProductCaches(id, product.getBrandId());
return product;
}

Expand All @@ -44,6 +47,7 @@ public void delete(Long id) {
Product product = getById(id);
product.delete();
deleteOptionsByProductId(id);
productCacheManager.evictProductCaches(id, product.getBrandId());
}

@Transactional
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package com.loopers.application.product;

import com.loopers.domain.common.Money;
import com.loopers.domain.product.Product;
import lombok.Builder;
import lombok.Getter;
import lombok.extern.jackson.Jacksonized;

import java.util.List;

@Getter
@Builder
@Jacksonized
public class CachedBrandProductPage {
private final List<ProductSummary> content;
private final long totalElements;

@Getter
@Builder
@Jacksonized
public static class ProductSummary {
private final Long productId;
private final String productName;
private final Money basePrice;
private final boolean deleted;
private final long likeCount;

public static ProductSummary from(Product product) {
return ProductSummary.builder()
.productId(product.getId())
.productName(product.getName())
.basePrice(product.getBasePrice())
.deleted(product.isDeleted())
.likeCount(product.getLikeCount())
.build();
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package com.loopers.application.product;

import com.loopers.domain.common.Money;
import com.loopers.domain.product.Option;
import com.loopers.domain.product.Product;
import lombok.Builder;
import lombok.Getter;
import lombok.extern.jackson.Jacksonized;

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

@Getter
@Builder
@Jacksonized
public class CachedProductDetail {
private final Long productId;
private final String productName;
private final Money basePrice;
private final boolean deleted;
private final Long brandId;
private final long likeCount;
private final List<ProductInfo.OptionInfo> options;

public static CachedProductDetail from(Product product, List<Option> options, long likeCount) {
return CachedProductDetail.builder()
.productId(product.getId())
.productName(product.getName())
.basePrice(product.getBasePrice())
.deleted(product.isDeleted())
.brandId(product.getBrandId())
.likeCount(likeCount)
.options(Optional.ofNullable(options).orElseGet(Collections::emptyList)
.stream().map(ProductInfo.OptionInfo::from).toList())
.build();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,19 +9,29 @@
import com.loopers.support.error.CoreException;
import com.loopers.support.error.ErrorType;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
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;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;

@Slf4j
@Service
@RequiredArgsConstructor
public class ProductAppService {
private static final int CACHEABLE_PAGE_LIMIT = 2;
private static final int MAX_PAGE_SIZE = 50;

private final ProductRepository productRepository;
private final OptionRepository optionRepository;
private final ProductCacheManager productCacheManager;

@Transactional
public Product create(Long brandId, String name, Money basePrice) {
Expand All @@ -40,6 +50,65 @@ public List<Product> getProducts(ProductSortCondition condition) {
return productRepository.findAll(condition);
}

@Transactional(readOnly = true)
public Page<Product> getProductsByBrandId(Long brandId, int page, int size) {
return productRepository.findByBrandIdWithPaging(brandId, validatedPageRequest(page, size));
}

@Transactional(readOnly = true)
public CachedProductDetail getProductDetailCached(Long productId) {
try {
Optional<CachedProductDetail> cached = productCacheManager.getProductDetail(productId);
if (cached.isPresent()) {
return cached.get();
}
} catch (Exception e) {
log.warn("캐시 조회 실패, DB fallback. productId={}", productId, e);
}

Product product = getById(productId);
List<Option> options = optionRepository.findByProductId(productId);
long likeCount = product.getLikeCount();

CachedProductDetail detail = CachedProductDetail.from(product, options, likeCount);
try {
productCacheManager.putProductDetail(productId, detail);
} catch (Exception e) {
log.warn("캐시 저장 실패. productId={}", productId, e);
}
return detail;
}

@Transactional(readOnly = true)
public CachedBrandProductPage getProductsByBrandIdCached(Long brandId, int page, int size) {
if (page <= CACHEABLE_PAGE_LIMIT) {
try {
Optional<CachedBrandProductPage> cached = productCacheManager.getProductList(brandId, page, size);
if (cached.isPresent()) {
return cached.get();
}
} catch (Exception e) {
log.warn("캐시 조회 실패, DB fallback. brandId={}, page={}", brandId, page, e);
}
}

Page<Product> products = productRepository.findByBrandIdWithPaging(brandId, validatedPageRequest(page, size));
CachedBrandProductPage result = CachedBrandProductPage.builder()
.content(products.getContent().stream().map(CachedBrandProductPage.ProductSummary::from).toList())
.totalElements(products.getTotalElements())
.build();

if (page <= CACHEABLE_PAGE_LIMIT) {
try {
productCacheManager.putProductList(brandId, page, size, result);
} catch (Exception e) {
log.warn("캐시 저장 실패. brandId={}, page={}", brandId, page, e);
}
}

return result;
}

@Transactional(readOnly = true)
public Option getOptionById(Long optionId) {
return optionRepository.findById(optionId)
Expand Down Expand Up @@ -119,4 +188,11 @@ public void increaseLikeCount(Long productId) {
public void decreaseLikeCount(Long productId) {
productRepository.decreaseLikeCount(productId);
}

private Pageable validatedPageRequest(int page, int size) {
if (page < 0 || size <= 0 || size > MAX_PAGE_SIZE) {
throw new CoreException(ErrorType.BAD_REQUEST, "page는 0 이상, size는 1~" + MAX_PAGE_SIZE + " 범위여야 합니다.");
}
return PageRequest.of(page, size);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package com.loopers.application.product;

import java.util.Optional;

public interface ProductCacheManager {

Optional<CachedBrandProductPage> getProductList(Long brandId, int page, int size);

void putProductList(Long brandId, int page, int size, CachedBrandProductPage value);

Optional<CachedProductDetail> getProductDetail(Long productId);

void putProductDetail(Long productId, CachedProductDetail value);

void evictProductCaches(Long productId, Long brandId);
Comment on lines +7 to +15
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

목록 캐시 무효화 계약이 키 차원을 모두 표현하지 못한다.

getProductListputProductList(brandId, page, size) 조합으로 엔트리를 만들지만 evictProductCachessize와 캐시 대상 페이지 범위를 전달받지 않는다. 이 계약으로는 구현체가 완전한 무효화를 할 수 없어 추정 상수에 의존하게 되고, 운영에서는 관리자 수정/삭제 직후 요청 size에 따라 일부 사용자만 오래된 목록을 TTL 동안 계속 보게 된다. 브랜드 단위 버전 키로 네임스페이스를 끊거나, 최소한 무효화 계약에 페이지/사이즈 정책을 포함해 구현체가 추측하지 않도록 바꾸는 편이 안전하다. 추가 테스트로 서로 다른 size와 캐시 가능 페이지에 저장한 목록 캐시가 수정/삭제 이후 모두 miss 되는지 검증해야 한다.

🤖 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/ProductCacheManager.java`
around lines 7 - 15, evictProductCaches currently can't fully invalidate list
entries because getProductList/putProductList store entries by (brandId, page,
size) but evictProductCaches(Long productId, Long brandId) lacks page/size (or a
brand-level namespace) info; update the contract to either (a) include page and
size (e.g., evictProductCaches(Long productId, Long brandId, Integer page,
Integer size) or evictBySizeRange) so implementations can remove exact keys, or
(b) introduce a brand-version/namespace token used by
getProductList/putProductList and bumped by evictProductCaches(brandId) so all
page/size combos are implicitly invalidated; then add tests for
CachedBrandProductPage to assert lists cached at different sizes/pages are all
misses after a putProductDetail/evictProductCaches call.

}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@
import com.loopers.domain.product.Product;
import com.loopers.domain.product.ProductSortCondition;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Component;

import java.util.List;
Expand All @@ -21,13 +24,19 @@ public class ProductFacade {
private final LikeAppService likeAppService;

public ProductInfo getProductDetail(Long productId, Long userId) {
Product product = productAppService.getById(productId);
Brand brand = brandAppService.getById(product.getBrandId());
List<Option> options = productAppService.getOptionsByProductId(productId);
long likeCount = likeAppService.countByProductId(productId);
CachedProductDetail detail = productAppService.getProductDetailCached(productId);
Brand brand = brandAppService.getById(detail.getBrandId());
boolean likedByUser = userId != null && likeAppService.isLikedByUser(userId, productId);
return toProductInfo(detail, brand, likedByUser);
}

return ProductInfo.of(product, brand, options, likeCount, likedByUser);
public Page<ProductInfo> getProductsByBrand(Long brandId, int page, int size) {
CachedBrandProductPage cached = productAppService.getProductsByBrandIdCached(brandId, page, size);
Brand brand = brandAppService.getById(brandId);
List<ProductInfo> content = cached.getContent().stream()
.map(summary -> toProductInfo(summary, brand))
.toList();
return new PageImpl<>(content, PageRequest.of(page, size), cached.getTotalElements());
}

public List<ProductInfo> getProductList(ProductSortCondition condition, Long userId) {
Expand Down Expand Up @@ -57,4 +66,32 @@ public List<ProductInfo> getProductList(ProductSortCondition condition, Long use
))
.toList();
}

private ProductInfo toProductInfo(CachedProductDetail detail, Brand brand, boolean likedByUser) {
return ProductInfo.builder()
.productId(detail.getProductId())
.productName(detail.getProductName())
.basePrice(detail.getBasePrice())
.deleted(detail.isDeleted())
.brandId(brand.getId())
.brandName(brand.getName())
.likeCount(detail.getLikeCount())
.likedByUser(likedByUser)
.options(detail.getOptions())
.build();
}

private ProductInfo toProductInfo(CachedBrandProductPage.ProductSummary summary, Brand brand) {
return ProductInfo.builder()
.productId(summary.getProductId())
.productName(summary.getProductName())
.basePrice(summary.getBasePrice())
.deleted(summary.isDeleted())
.brandId(brand.getId())
.brandName(brand.getName())
.likeCount(summary.getLikeCount())
.likedByUser(false)
.options(List.of())
.build();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,13 @@
import com.loopers.domain.product.Product;
import lombok.Builder;
import lombok.Getter;
import lombok.extern.jackson.Jacksonized;

import java.util.List;

@Getter
@Builder
@Jacksonized
public class ProductInfo {
private final Long productId;
private final String productName;
Expand All @@ -24,6 +26,7 @@ public class ProductInfo {

@Getter
@Builder
@Jacksonized
public static class OptionInfo {
private final Long optionId;
private final String optionName;
Expand All @@ -42,6 +45,20 @@ public static OptionInfo from(Option option) {
}
}

public ProductInfo withLikedByUser(boolean likedByUser) {
return ProductInfo.builder()
.productId(this.productId)
.productName(this.productName)
.basePrice(this.basePrice)
.deleted(this.deleted)
.brandId(this.brandId)
.brandName(this.brandName)
.likeCount(this.likeCount)
.likedByUser(likedByUser)
.options(this.options)
.build();
}

public static ProductInfo of(Product product, Brand brand, List<Option> options, long likeCount, boolean likedByUser) {
return ProductInfo.builder()
.productId(product.getId())
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package com.loopers.domain.common;

import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.loopers.support.error.CoreException;
import com.loopers.support.error.ErrorType;
import jakarta.persistence.Embeddable;
Expand All @@ -17,7 +19,8 @@
public class Money {
private BigDecimal amount;

private Money(BigDecimal amount) {
@JsonCreator
private Money(@JsonProperty("amount") BigDecimal amount) {
if (amount == null || amount.compareTo(BigDecimal.ZERO) < 0) {
throw new CoreException(ErrorType.BAD_REQUEST, "금액은 0 이상이어야 합니다.");
}
Expand Down Expand Up @@ -55,7 +58,7 @@ public boolean isGreaterThanOrEqual(Money other) {
public Money subtract(Money other) {
BigDecimal result = this.amount.subtract(other.amount);
if (result.compareTo(BigDecimal.ZERO) < 0) {
throw new IllegalArgumentException(
throw new CoreException(ErrorType.BAD_REQUEST,
"차감 결과가 음수입니다: " + this.amount + " - " + other.amount + " = " + result);
}
return new Money(result);
Expand Down
Loading