Skip to content
Open
22 changes: 21 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,27 @@ public class Product extends BaseEntity {
- Facade: 다중 도메인 조합 시 `@Transactional`
- 도메인 엔티티에는 `@Transactional` 사용 금지

## 10. 테스트
## 10. 캐시 규칙

### 캐시 키 네이밍 컨벤션
- 형식: `{도메인}:{식별자 또는 조건}` (콜론 구분, kebab-case 없이 소문자)
- 단건: `{도메인}:{id}` — 예: `product:1`
- 목록: `{도메인}s:{필터}:page:{page}:size:{size}:sort:{sort}` — 예: `products:brand:3:page:0:size:20:sort:createdAt,desc`
- 필터 없는 전체 목록: `{도메인}s:all:page:{page}:size:{size}:sort:{sort}` — 예: `products:all:page:0:size:20:sort:createdAt,desc`

### 캐시 무효화 전략
| 이벤트 | 단건 캐시 (`{도메인}:{id}`) | 목록 캐시 (`{도메인}s:...`) |
|--------|---------------------------|---------------------------|
| 등록 | 해당 없음 | 관련 목록 캐시 삭제 |
| 수정 | 해당 키 삭제 | 관련 목록 캐시 삭제 |
| 삭제 | 해당 키 삭제 | 관련 목록 캐시 삭제 |

### 캐시 인프라
- 캐시 구현체는 `infrastructure` 계층에 `{Domain}CacheManager`로 위치
- TTL: 도메인 특성에 따라 결정 (기본 1시간)
- 직렬화: JSON (Jackson ObjectMapper)

## 11. 테스트
- 테스트 코드 생성 시 test-generate 스킬을 따른다.
- 메서드명: 한국어, 유비쿼터스 언어 기반
- 구조: given-when-then
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package com.loopers.application.like;

import com.loopers.application.like.result.LikeResult;
import com.loopers.application.product.ProductService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
Expand All @@ -11,18 +10,14 @@
public class LikeFacade {

private final LikeService likeService;
private final ProductService productService;

@Transactional
public LikeResult like(Long userId, Long productId) {
LikeResult result = likeService.like(userId, productId);
productService.incrementLikeCount(productId);
return result;
return likeService.like(userId, productId);
}

@Transactional
public void unlike(Long userId, Long productId) {
likeService.unlike(userId, productId);
productService.decrementLikeCount(productId);
}
Comment on lines 14 to 22
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

ProductService 의존성 제거로 결합도가 낮아졌다.

다만, 기존에 ProductService를 통해 처리하던 좋아요 수 갱신 로직이 제거되었으나, 대체 로직이 구현되지 않았다. ProductLikeCount 엔티티의 증감 로직이 없으므로 좋아요 수는 갱신되지 않는다. LikeFacade 또는 LikeService에서 카운트 갱신 책임을 명확히 해야 한다.

🤖 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 14 - 22, Removing ProductService left ProductLikeCount update
unimplemented so likes don't change; add explicit increment/decrement of
ProductLikeCount inside the transactional flow: either extend
LikeService.like/unlike to update the ProductLikeCount entity (or add new
methods like incrementLikeCount(Long productId) and decrementLikeCount(Long
productId)) and invoke them from LikeFacade.like/unlike (or call the updated
LikeService methods), ensuring the operations run within the same `@Transactional`
boundary and handle creating the ProductLikeCount row if missing and preventing
negative counts on unlike.

}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
import com.loopers.application.like.result.LikeResult;
import com.loopers.domain.like.ProductLike;
import com.loopers.domain.like.ProductLikeRepository;
import com.loopers.domain.like.ProductLikeCount;
import com.loopers.domain.like.ProductLikeCountRepository;
import com.loopers.support.error.CoreException;
import com.loopers.support.error.ErrorType;
import lombok.RequiredArgsConstructor;
Expand All @@ -11,11 +13,16 @@
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

@RequiredArgsConstructor
@Service
public class LikeService {

private final ProductLikeRepository productLikeRepository;
private final ProductLikeCountRepository productLikesCountRepository;

@Transactional
public LikeResult like(Long userId, Long productId) {
Expand Down Expand Up @@ -48,4 +55,20 @@ public void unlike(Long userId, Long productId) {
public void deleteLikes(Long productId) {
productLikeRepository.deleteByProductId(productId);
}

@Transactional(readOnly = true)
public int getLikeCount(Long productId) {
return productLikesCountRepository.findByProductId(productId)
.map(ProductLikeCount::getLikeCount)
.orElse(0);
}

@Transactional(readOnly = true)
public Map<Long, Integer> getLikeCounts(List<Long> productIds) {
return productLikesCountRepository.findByProductIdIn(productIds).stream()
.collect(Collectors.toMap(
ProductLikeCount::getProductId,
ProductLikeCount::getLikeCount
));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import com.loopers.application.product.command.ProductUpdateCommand;
import com.loopers.application.product.result.ProductResult;
import com.loopers.application.product.result.ProductWithBrandResult;
import com.loopers.application.product.result.ProductWithLikeCountResult;
import com.loopers.support.error.CoreException;
import com.loopers.support.error.ErrorType;
import lombok.RequiredArgsConstructor;
Expand All @@ -25,14 +26,16 @@ public class ProductFacade {

@Transactional(readOnly = true)
public ProductWithBrandResult getProduct(Long productId) {
ProductResult product = productService.getProduct(productId);
ProductWithLikeCountResult product = productService.getProductWithLikeCount(productId);
BrandResult brand = brandService.getBrand(product.brandId());
return ProductWithBrandResult.from(product, brand.name());
}

@Transactional(readOnly = true)
public Page<ProductWithBrandResult> getProducts(Long brandId, Pageable pageable) {
Page<ProductResult> products = productService.getProducts(brandId, pageable);
Page<ProductWithLikeCountResult> products = (brandId != null)
? productService.getProductsWithLikeCount(brandId, pageable)
: productService.getProductsWithLikeCount(pageable);

return products.map(product -> {
BrandResult brand = brandService.getBrand(product.brandId());
Expand Down Expand Up @@ -61,4 +64,4 @@ public void deleteProduct(Long productId) {
likeService.deleteLikes(productId);
productService.deleteProduct(productId);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,24 +3,27 @@
import com.loopers.application.product.command.ProductCreateCommand;
import com.loopers.application.product.command.ProductUpdateCommand;
import com.loopers.application.product.result.ProductResult;
import com.loopers.application.product.result.ProductWithLikeCountResult;
import com.loopers.domain.product.Product;
import com.loopers.domain.product.ProductCacheRepository;
import com.loopers.domain.product.ProductRepository;
import com.loopers.domain.product.ProductWithLikeCount;
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.Pageable;

import java.util.List;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;

@RequiredArgsConstructor
@Service
public class ProductService {

private final ProductRepository productRepository;
private final ProductCacheRepository productCacheRepository;

@Transactional
public ProductResult registerProduct(Long brandId, ProductCreateCommand command) {
Expand All @@ -31,27 +34,66 @@ public ProductResult registerProduct(Long brandId, ProductCreateCommand command)
.stock(command.stock())
.build();

return ProductResult.from(productRepository.save(product));
Product saved = productRepository.save(product);
productCacheRepository.evictAllProductsCache();
return ProductResult.from(saved);
}

@Transactional(readOnly = true)
public ProductResult getProduct(Long productId) {
Product product = productRepository.findByIdAndDeletedAtIsNull(productId)
.orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다."));

Product product = productCacheRepository.getProduct(productId)
.orElseGet(() -> {
Product origin = productRepository.findByIdAndDeletedAtIsNull(productId)
.orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다."));
productCacheRepository.putProduct(productId, origin);
return origin;
});
return ProductResult.from(product);
}

@Transactional(readOnly = true)
public ProductWithLikeCountResult getProductWithLikeCount(Long productId) {
ProductWithLikeCount productWithLikeCount = productCacheRepository.getProductWithLikeCount(productId)
.orElseGet(() -> {
ProductWithLikeCount origin = productRepository.findWithLikeCountByIdAndDeletedAtIsNull(productId)
.orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다."));
productCacheRepository.putProductWithLikeCount(productId, origin);
return origin;
});
return ProductWithLikeCountResult.from(productWithLikeCount);
}

@Transactional(readOnly = true)
public Page<ProductResult> getProducts(Pageable pageable) {
return productRepository.findAllByDeletedAtIsNull(pageable)
.map(ProductResult::from);
Page<Product> pages = productCacheRepository.getProducts(pageable)
.orElseGet(() -> {
Page<Product> origin = productRepository.findAllByDeletedAtIsNull(pageable);
productCacheRepository.putProducts(pageable, origin);
return origin;
});
return pages.map(ProductResult::from);
}

@Transactional(readOnly = true)
public Page<ProductWithLikeCountResult> getProductsWithLikeCount(Pageable pageable) {
Page<ProductWithLikeCount> pages = productCacheRepository.getProductsWithLikeCount(pageable)
.orElseGet(() -> {
Page<ProductWithLikeCount> origin = productRepository.findAllWithLikeCountByDeletedAtIsNull(pageable);
productCacheRepository.putProductsWithLikeCount(pageable, origin);
return origin;
});
return pages.map(ProductWithLikeCountResult::from);
}

@Transactional(readOnly = true)
public Page<ProductResult> getProducts(Long brandId, Pageable pageable) {
return productRepository.findAllByBrandIdAndDeletedAtIsNull(brandId, pageable)
.map(ProductResult::from);
public Page<ProductWithLikeCountResult> getProductsWithLikeCount(Long brandId, Pageable pageable) {
Page<ProductWithLikeCount> pages = productCacheRepository.getProductsWithLikeCount(brandId, pageable)
.orElseGet(() -> {
Page<ProductWithLikeCount> origin = productRepository.findAllWithLikeCountByBrandIdAndDeletedAtIsNull(brandId, pageable);
productCacheRepository.putProductsWithLikeCount(brandId, pageable, origin);
return origin;
});
return pages.map(ProductWithLikeCountResult::from);
}

@Transactional(readOnly = true)
Expand All @@ -67,6 +109,8 @@ public ProductResult modifyProduct(Long productId, Long brandId, ProductUpdateCo
.orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다."));

product.changeInfo(brandId, command.name(), command.price(), command.stock());
productCacheRepository.evictProduct(productId);
productCacheRepository.evictAllProductsCache();

Comment on lines +112 to 114
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

트랜잭션 내에서 캐시를 무효화하면, 롤백 시 캐시 불일치가 발생한다.

현재 modifyProduct, deductStock, restoreStock, deleteProduct 등에서 트랜잭션 내부에서 캐시를 evict한다. 만약 이후 로직에서 예외가 발생하여 트랜잭션이 롤백되면, DB는 원래 상태로 복원되지만 캐시는 이미 삭제된 상태이다. 이후 캐시 miss 시 DB에서 읽어오므로 데이터는 정합하지만, 불필요한 캐시 miss와 DB 부하가 발생한다.

더 심각한 경우, 다른 요청이 캐시 삭제 후 ~ 트랜잭션 롤백 전 사이에 DB를 조회하여 캐시에 저장하면, 롤백 후에도 변경 전 데이터가 캐시에 남아있게 된다.

수정안: TransactionSynchronizationManager.registerSynchronization()을 사용하여 트랜잭션 커밋 후에 캐시를 무효화하는 패턴을 적용해야 한다.

♻️ 트랜잭션 커밋 후 캐시 무효화 예시
`@Transactional`
public ProductResult modifyProduct(Long productId, Long brandId, ProductUpdateCommand command) {
    Product product = productRepository.findByIdAndDeletedAtIsNull(productId)
            .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다."));

    product.changeInfo(brandId, command.name(), command.price(), command.stock());
    
    TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
        `@Override`
        public void afterCommit() {
            productCacheRepository.evictProduct(productId);
            productCacheRepository.evictAllProductsCache();
        }
    });

    return ProductResult.from(product);
}

Also applies to: 123-125, 133-135, 142-144

🤖 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/ProductService.java`
around lines 112 - 114, Currently productCacheRepository.evictProduct(...) and
evictAllProductsCache() are called inside transactions (seen in methods
modifyProduct, deductStock, restoreStock, deleteProduct), causing cache/DB
inconsistency on rollback; change each method to register a
TransactionSynchronization via
TransactionSynchronizationManager.registerSynchronization(...) and perform
productCacheRepository.evictProduct(productId) and
productCacheRepository.evictAllProductsCache() inside the afterCommit() callback
so eviction only happens after the transaction successfully commits.

return ProductResult.from(product);
}
Expand All @@ -76,6 +120,8 @@ public ProductResult deductStock(Long productId, int quantity) {
Product product = productRepository.findByIdWithLockAndDeletedAtIsNull(productId)
.orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다."));
product.deductStock(quantity);
productCacheRepository.evictProduct(productId);
productCacheRepository.evictAllProductsCache();
return ProductResult.from(product);
}

Expand All @@ -84,36 +130,26 @@ public void restoreStock(Long productId, int quantity) {
Product product = productRepository.findByIdWithLockAndDeletedAtIsNull(productId)
.orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다."));
product.restoreStock(quantity);
}

@Transactional
public void incrementLikeCount(Long productId) {
int updatedCount = productRepository.incrementLikeCount(productId);
if (updatedCount == 0) {
throw new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다.");
}
}

@Transactional
public void decrementLikeCount(Long productId) {
int updatedCount = productRepository.decrementLikeCount(productId);
if (updatedCount == 0) {
throw new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다.");
}
productCacheRepository.evictProduct(productId);
productCacheRepository.evictAllProductsCache();
}

@Transactional
public void deleteProduct(Long productId) {
Product product = productRepository.findById(productId)
.orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다."));

product.delete();
productCacheRepository.evictProduct(productId);
productCacheRepository.evictAllProductsCache();
}

@Transactional
public void deleteProducts(Long brandId) {
List<Product> products = productRepository.findAllByBrandId(brandId);
products.forEach(Product::delete);
products.forEach(product -> {
product.delete();
productCacheRepository.evictProduct(product.getId());
});
productCacheRepository.evictAllProductsCache();
}

}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ public record ProductResult(
String name,
int price,
int stock,
int likeCount,
ZonedDateTime createdAt,
ZonedDateTime updatedAt
) {
Expand All @@ -21,9 +20,8 @@ public static ProductResult from(Product product) {
product.getName(),
product.getPrice(),
product.getStock(),
product.getLikeCount(),
product.getCreatedAt(),
product.getUpdatedAt()
);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ public record ProductWithBrandResult(
ZonedDateTime createdAt,
ZonedDateTime updatedAt
) {
public static ProductWithBrandResult from(ProductResult product, String brandName) {
public static ProductWithBrandResult from(ProductWithLikeCountResult product, String brandName) {
return new ProductWithBrandResult(
product.id(),
product.brandId(),
Expand All @@ -26,4 +26,4 @@ public static ProductWithBrandResult from(ProductResult product, String brandNam
product.updatedAt()
);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package com.loopers.application.product.result;

import com.loopers.domain.product.ProductWithLikeCount;

import java.time.ZonedDateTime;

public record ProductWithLikeCountResult(
Long id,
Long brandId,
String name,
int price,
int stock,
int likeCount,
ZonedDateTime createdAt,
ZonedDateTime updatedAt
) {
public static ProductWithLikeCountResult from(ProductWithLikeCount product) {
return new ProductWithLikeCountResult(
product.id(),
product.brandId(),
product.name(),
product.price(),
product.stock(),
product.likeCount(),
product.createdAt(),
product.updatedAt()
);
}
}
Loading