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
7 changes: 4 additions & 3 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,11 @@ out/
### Kotlin ###
.kotlin

### QueryDSL Generated ###
**/src/main/generated/
### macOS ###
.DS_Store

### Claude Code ###
.claude/


### QueryDSL Generated ###
**/generated/
4 changes: 4 additions & 0 deletions apps/commerce-api/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ dependencies {
// security
implementation("org.springframework.security:spring-security-crypto")

// cache
implementation("org.springframework.boot:spring-boot-starter-cache")
implementation("com.github.ben-manes.caffeine:caffeine")

// querydsl
annotationProcessor("com.querydsl:querydsl-apt::jakarta")
annotationProcessor("jakarta.persistence:jakarta.persistence-api")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import com.loopers.domain.product.AdminProductSearchCondition;
import com.loopers.domain.product.MarginType;
import com.loopers.domain.product.Product;
import com.loopers.domain.product.ProductCacheStore;
import com.loopers.domain.product.ProductOption;
import com.loopers.domain.product.ProductService;
import com.loopers.domain.product.ProductStatus;
Expand All @@ -25,6 +26,7 @@ public class AdminProductFacade {
private final ProductService productService;
private final BrandService brandService;
private final CartService cartService;
private final ProductCacheStore productCacheStore;

public Page<ProductInfo> getProducts(AdminProductSearchCondition condition, Pageable pageable) {
return productService.adminSearch(condition, pageable).map(ProductInfo::from);
Expand Down Expand Up @@ -52,6 +54,9 @@ public ProductDetailInfo updateProduct(Long productId, String name, int price, i
List<ProductOption> options) {
Product product = productService.update(productId, name, price, supplyPrice, discountPrice,
shippingFee, description, status, displayYn, options);

// 상품 수정 시 상세 캐시를 명시적으로 삭제 -> 삭제 후 최초 사용자가 조회시 캐싱.
productCacheStore.evictDetail(productId);
return ProductDetailInfo.from(product);
Comment on lines +58 to 60
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

캐시 무효화를 트랜잭션 내부에서 직접 호출하는 것은 위험하다.

지금 방식은 커밋 전에 상세 캐시를 비워서 동시 조회가 이전 committed 데이터를 다시 캐시에 채울 수 있고, eviction 예외가 나면 상품 수정/삭제 자체의 가용성까지 같이 떨어뜨린다. 무효화는 커밋 이후 훅으로 분리하고, 실패는 로깅·재시도 대상으로 격리해서 DB 변경 성공 여부와 분리하는 편이 안전하다. 추가로 “커밋 전 동시 상세 조회가 stale cache를 재생성하지 않는다”와 “캐시 삭제 실패가 나도 DB 변경은 커밋된다” 통합 테스트를 넣어야 한다.

Also applies to: 69-70

🤖 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/AdminProductFacade.java`
around lines 58 - 60, The code calls productCacheStore.evictDetail(productId)
directly inside AdminProductFacade within the transaction, which can evict
before commit and make DB-change failures impact availability; refactor to
perform cache eviction after successful commit by registering an after-commit
hook or publishing an after-commit event (e.g., use
TransactionSynchronizationManager.registerSynchronization or an
`@TransactionalEventListener` / ApplicationEvent for
ProductUpdated/ProductDeleted) and move the eviction logic into that listener
which catches exceptions, logs them, and schedules retries rather than throwing;
ensure the symbols to change are productCacheStore.evictDetail(productId)
invocations in AdminProductFacade and add an after-commit listener class/method
to perform eviction and retry/logging, and add integration tests asserting (1)
concurrent read during commit does not repopulate stale cache and (2) cache
eviction failure does not prevent DB commit.

}

Expand All @@ -60,5 +65,8 @@ public void deleteProduct(Long productId) {
List<Long> optionIds = productService.findOptionIdsByProductId(productId);
cartService.deleteByProductOptionIds(optionIds);
productService.softDelete(productId);

// 삭제된 상품의 상세 캐시를 제거하여 삭제 후에도 캐시된 데이터가 반환되지 않도록 한다.
productCacheStore.evictDetail(productId);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,39 +2,116 @@

import com.loopers.domain.like.LikeService;
import com.loopers.domain.product.Product;
import com.loopers.domain.product.ProductCacheStore;
import com.loopers.domain.product.ProductSearchCondition;
import com.loopers.domain.product.ProductService;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;

@RequiredArgsConstructor
@Component
@Transactional(readOnly = true)
public class ProductFacade {

private final ProductService productService;
private final LikeService likeService;
private final ProductCacheStore redisCacheStore;
private final ProductCacheStore localCacheStore;

public ProductFacade(ProductService productService,
LikeService likeService,
@Qualifier("productRedisCacheStore") ProductCacheStore redisCacheStore,
@Qualifier("productLocalCacheStore") ProductCacheStore localCacheStore) {
this.productService = productService;
this.likeService = likeService;
this.redisCacheStore = redisCacheStore;
this.localCacheStore = localCacheStore;
}

/**
* 상품 목록 조회 - Redis Cache-Aside 적용.
*/
public Page<ProductInfo> getProducts(ProductSearchCondition condition, Pageable pageable) {
return productService.search(condition, pageable).map(ProductInfo::from);
Page<Product> products = redisCacheStore.getSearch(condition, pageable)
.orElseGet(() -> {
Page<Product> fromDb = productService.search(condition, pageable);
redisCacheStore.setSearch(condition, pageable, fromDb);
return fromDb;
});
return products.map(ProductInfo::from);
}

/**
* 상품 목록 조회 - 로컬 캐시 Cache-Aside 적용.
*/
public Page<ProductInfo> getProductsWithLocalCache(ProductSearchCondition condition, Pageable pageable) {
Page<Product> products = localCacheStore.getSearch(condition, pageable)
.orElseGet(() -> {
Page<Product> fromDb = productService.search(condition, pageable);
localCacheStore.setSearch(condition, pageable, fromDb);
return fromDb;
});
return products.map(ProductInfo::from);
}

/**
* 상품 상세 조회 - Redis Cache-Aside 적용.
*/
public ProductDetailInfo getProduct(Long productId) {
Product product = redisCacheStore.getDetail(productId)
.orElseGet(() -> {
Product fromDb = productService.findById(productId);
redisCacheStore.setDetail(productId, fromDb);
return fromDb;
});
return ProductDetailInfo.from(product);
}

/**
* 상품 상세 조회 - 로컬 캐시 Cache-Aside 적용.
*/
public ProductDetailInfo getProductWithLocalCache(Long productId) {
Product product = localCacheStore.getDetail(productId)
.orElseGet(() -> {
Product fromDb = productService.findById(productId);
localCacheStore.setDetail(productId, fromDb);
return fromDb;
});
return ProductDetailInfo.from(product);
}

/**
* 상품 목록 조회 - 캐시 미적용 (DB 직접 조회)
*/
public Page<ProductInfo> getProductsNoCache(ProductSearchCondition condition, Pageable pageable) {
return productService.search(condition, pageable).map(ProductInfo::from);
}

/**
* 상품 상세 조회 - 캐시 미적용 (DB 직접 조회)
*/
public ProductDetailInfo getProductNoCache(Long productId) {
Product product = productService.findById(productId);
return ProductDetailInfo.from(product);
}

@Transactional
public void like(Long memberId, Long productId) {
likeService.like(memberId, productId);
productService.findById(productId);
boolean changed = likeService.like(memberId, productId);
if (changed) {
productService.incrementLikeCount(productId);
}
}

@Transactional
public void unlike(Long memberId, Long productId) {
likeService.unlike(memberId, productId);
boolean changed = likeService.unlike(memberId, productId);
if (changed) {
productService.decrementLikeCount(productId);
}
}

public Page<ProductInfo> getLikedProducts(Long memberId, Pageable pageable) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package com.loopers.config;

import com.github.benmanes.caffeine.cache.Caffeine;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.cache.caffeine.CaffeineCache;
import org.springframework.cache.support.SimpleCacheManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.time.Duration;
import java.util.List;

@EnableCaching
@Configuration
public class LocalCacheConfig {

/**
* 로컬 캐시 사용
* @return
*/
@Bean
public CacheManager cacheManager() {
SimpleCacheManager cacheManager = new SimpleCacheManager();
cacheManager.setCaches(List.of(
buildCache("productSearch", Duration.ofMinutes(3), 1_000), // 상품 목록 조회용
buildCache("productDetail", Duration.ofMinutes(5), 5_000) // 상품 상세 조회용

// TTL의 경우,
// maxSize의 경우, 여러 개의 상품 상세 정보를 캐시하기 위해 상품 리스트 보다 상품 상세가 양이 더 많게 설정했음.
));
return cacheManager;
}

private CaffeineCache buildCache(String name, Duration ttl, long maxSize) {
return new CaffeineCache(name, Caffeine.newBuilder()
.expireAfterWrite(ttl)
.maximumSize(maxSize)
.recordStats()
.build());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
package com.loopers.config;

import com.loopers.application.product.ProductDetailInfo;
import com.loopers.application.product.ProductFacade;
import com.loopers.application.product.ProductInfo;
import com.loopers.domain.product.ProductSearchCondition;
import com.loopers.domain.product.ProductSortType;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Component;

/**
* 애플리케이션 기동 시 자주 조회되는 상품 데이터를 캐시에 미리 적재한다.
*
* - 상품 목록: 기본 정렬(LATEST) 0~2페이지
* - 상품 상세: 목록 첫 페이지에 노출된 상품들의 상세 정보
*
* Cache-Aside 로직은 ProductFacade가 담당하므로,
* 웜업은 Facade 메서드를 호출하여 자연스럽게 캐시를 적재한다.
* 웜업 실패 시에도 서비스 기동에는 영향을 주지 않는다.
*/
@Slf4j
@RequiredArgsConstructor
@Component
public class ProductCacheWarmUp {

private static final int WARM_UP_MAX_PAGE = 2;
private static final int DEFAULT_PAGE_SIZE = 20;

private final ProductFacade productFacade;

@EventListener(ApplicationReadyEvent.class)
public void warmUp() {
log.info("[CacheWarmUp] 상품 캐시 웜업 시작");

int listCount = warmUpSearchCache();
int detailCount = warmUpDetailCache();

log.info("[CacheWarmUp] 상품 캐시 웜업 완료 - 목록 {}페이지, 상세 {}건", listCount, detailCount);
}

private int warmUpSearchCache() {
ProductSearchCondition defaultCondition = ProductSearchCondition.of(null, ProductSortType.LATEST, null);
int warmedPages = 0;

for (int page = 0; page <= WARM_UP_MAX_PAGE; page++) {
try {
Pageable pageable = PageRequest.of(page, DEFAULT_PAGE_SIZE);
productFacade.getProducts(defaultCondition, pageable);
warmedPages++;
} catch (Exception e) {
log.warn("[CacheWarmUp] 상품 목록 캐시 웜업 실패: page={}", page, e);
}
}
return warmedPages;
}

private int warmUpDetailCache() {
ProductSearchCondition defaultCondition = ProductSearchCondition.of(null, ProductSortType.LATEST, null);
int warmedCount = 0;

try {
Pageable pageable = PageRequest.of(0, DEFAULT_PAGE_SIZE);
Page<ProductInfo> products = productFacade.getProducts(defaultCondition, pageable);

for (ProductInfo productInfo : products.getContent()) {
try {
productFacade.getProduct(productInfo.id());
warmedCount++;
} catch (Exception e) {
log.warn("[CacheWarmUp] 상품 상세 캐시 웜업 실패: productId={}", productInfo.id(), e);
}
}
} catch (Exception e) {
log.warn("[CacheWarmUp] 상품 상세 캐시 웜업 대상 조회 실패", e);
}
return warmedCount;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,11 @@ public void addInterceptors(InterceptorRegistry registry) {
"/api/v1/members/signup",
"/api/v1/brands/**",
"/api/v1/products",
"/api/v1/products/local-cache",
"/api/v1/products/{id}",
"/api/v1/products/{id}/local-cache",
"/api/v1/products/no-cache",
"/api/v1/products/{id}/no-cache",
"/api/v1/examples/**"
);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,4 +57,14 @@ public void changeStatus(BrandStatus status) {
public boolean isActive() {
return this.status == BrandStatus.ACTIVE;
}

/**
* 캐시 복원용 팩토리 메서드.
* 캐시에 저장된 최소한의 브랜드 정보로 도메인 객체를 복원한다.
*/
public static Brand restoreFromCache(Long id, String name) {
Brand brand = new Brand(name, "");
brand.restoreBase(id, null);
return brand;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,6 @@ public interface LikeRepository {
Page<Product> findLikedProductsByMemberId(Long memberId, Pageable pageable);

Like save(Like like);

void refreshLikeSummary();
}
Loading