diff --git a/CLAUDE.md b/CLAUDE.md index d7b13ca69..0ebc4e6b3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java index 144f799a6..029d1e27f 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java @@ -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; @@ -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); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeService.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeService.java index c7e3fb17e..619282c82 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeService.java @@ -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; @@ -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) { @@ -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 getLikeCounts(List productIds) { + return productLikesCountRepository.findByProductIdIn(productIds).stream() + .collect(Collectors.toMap( + ProductLikeCount::getProductId, + ProductLikeCount::getLikeCount + )); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java index 257e55a14..a30ba085b 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java @@ -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; @@ -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 getProducts(Long brandId, Pageable pageable) { - Page products = productService.getProducts(brandId, pageable); + Page products = (brandId != null) + ? productService.getProductsWithLikeCount(brandId, pageable) + : productService.getProductsWithLikeCount(pageable); return products.map(product -> { BrandResult brand = brandService.getBrand(product.brandId()); @@ -61,4 +64,4 @@ public void deleteProduct(Long productId) { likeService.deleteLikes(productId); productService.deleteProduct(productId); } -} \ No newline at end of file +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java index f358329a3..0a0972a33 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java @@ -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) { @@ -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 getProducts(Pageable pageable) { - return productRepository.findAllByDeletedAtIsNull(pageable) - .map(ProductResult::from); + Page pages = productCacheRepository.getProducts(pageable) + .orElseGet(() -> { + Page origin = productRepository.findAllByDeletedAtIsNull(pageable); + productCacheRepository.putProducts(pageable, origin); + return origin; + }); + return pages.map(ProductResult::from); + } + + @Transactional(readOnly = true) + public Page getProductsWithLikeCount(Pageable pageable) { + Page pages = productCacheRepository.getProductsWithLikeCount(pageable) + .orElseGet(() -> { + Page origin = productRepository.findAllWithLikeCountByDeletedAtIsNull(pageable); + productCacheRepository.putProductsWithLikeCount(pageable, origin); + return origin; + }); + return pages.map(ProductWithLikeCountResult::from); } @Transactional(readOnly = true) - public Page getProducts(Long brandId, Pageable pageable) { - return productRepository.findAllByBrandIdAndDeletedAtIsNull(brandId, pageable) - .map(ProductResult::from); + public Page getProductsWithLikeCount(Long brandId, Pageable pageable) { + Page pages = productCacheRepository.getProductsWithLikeCount(brandId, pageable) + .orElseGet(() -> { + Page origin = productRepository.findAllWithLikeCountByBrandIdAndDeletedAtIsNull(brandId, pageable); + productCacheRepository.putProductsWithLikeCount(brandId, pageable, origin); + return origin; + }); + return pages.map(ProductWithLikeCountResult::from); } @Transactional(readOnly = true) @@ -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(); return ProductResult.from(product); } @@ -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); } @@ -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 products = productRepository.findAllByBrandId(brandId); - products.forEach(Product::delete); + products.forEach(product -> { + product.delete(); + productCacheRepository.evictProduct(product.getId()); + }); + productCacheRepository.evictAllProductsCache(); } - -} +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/result/ProductResult.java b/apps/commerce-api/src/main/java/com/loopers/application/product/result/ProductResult.java index a5df11483..d6bd42f8a 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/result/ProductResult.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/result/ProductResult.java @@ -10,7 +10,6 @@ public record ProductResult( String name, int price, int stock, - int likeCount, ZonedDateTime createdAt, ZonedDateTime updatedAt ) { @@ -21,9 +20,8 @@ public static ProductResult from(Product product) { product.getName(), product.getPrice(), product.getStock(), - product.getLikeCount(), product.getCreatedAt(), product.getUpdatedAt() ); } -} \ No newline at end of file +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/result/ProductWithBrandResult.java b/apps/commerce-api/src/main/java/com/loopers/application/product/result/ProductWithBrandResult.java index ed0c05ebc..5a965cd0d 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/result/ProductWithBrandResult.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/result/ProductWithBrandResult.java @@ -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(), @@ -26,4 +26,4 @@ public static ProductWithBrandResult from(ProductResult product, String brandNam product.updatedAt() ); } -} \ No newline at end of file +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/result/ProductWithLikeCountResult.java b/apps/commerce-api/src/main/java/com/loopers/application/product/result/ProductWithLikeCountResult.java new file mode 100644 index 000000000..3056cab85 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/result/ProductWithLikeCountResult.java @@ -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() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/ProductLikeCount.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/ProductLikeCount.java new file mode 100644 index 000000000..05147df45 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/ProductLikeCount.java @@ -0,0 +1,42 @@ +package com.loopers.domain.like; + +import com.loopers.domain.BaseEntity; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "product_likes_count") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ProductLikeCount extends BaseEntity { + + @Column(name = "product_id", nullable = false, unique = true) + private Long productId; + + @Column(name = "like_count", nullable = false) + private int likeCount; + + @Builder + private ProductLikeCount(Long productId, int likeCount) { + this.productId = productId; + this.likeCount = likeCount; + guard(); + } + + @Override + protected void guard() { + if (productId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "상품 ID는 필수입니다."); + } + if (likeCount < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "좋아요 수는 0 이상이어야 합니다."); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/ProductLikeCountRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/ProductLikeCountRepository.java new file mode 100644 index 000000000..68436f8fc --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/ProductLikeCountRepository.java @@ -0,0 +1,11 @@ +package com.loopers.domain.like; + +import java.util.List; +import java.util.Optional; + +public interface ProductLikeCountRepository { + + Optional findByProductId(Long productId); + + List findByProductIdIn(List productIds); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java index aa9ca1622..7250c15ea 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java @@ -27,16 +27,12 @@ public class Product extends BaseEntity { @Column(nullable = false) private int stock; - @Column(nullable = false) - private int likeCount; - @Builder private Product(Long brandId, String name, int price, int stock) { this.brandId = brandId; this.name = name; this.price = price; this.stock = stock; - this.likeCount = 0; guard(); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductCacheRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductCacheRepository.java new file mode 100644 index 000000000..38891d68c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductCacheRepository.java @@ -0,0 +1,30 @@ +package com.loopers.domain.product; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import java.util.Optional; + +public interface ProductCacheRepository { + + // 단건 + Optional getProduct(Long productId); + void putProduct(Long productId, Product product); + void evictProduct(Long productId); + + Optional getProductWithLikeCount(Long productId); + void putProductWithLikeCount(Long productId, ProductWithLikeCount productWithLikeCount); + + // 목록 + Optional> getProducts(Pageable pageable); + void putProducts(Pageable pageable, Page products); + + Optional> getProductsWithLikeCount(Pageable pageable); + void putProductsWithLikeCount(Pageable pageable, Page products); + + Optional> getProductsWithLikeCount(Long brandId, Pageable pageable); + void putProductsWithLikeCount(Long brandId, Pageable pageable, Page products); + + // 전체 목록 캐시 무효화 + void evictAllProductsCache(); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java index 8dca426df..d98f984f1 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java @@ -19,11 +19,11 @@ public interface ProductRepository { Optional findByIdWithLockAndDeletedAtIsNull(Long productId); - int incrementLikeCount(Long productId); + Product save(Product product); - int decrementLikeCount(Long productId); + Optional findWithLikeCountByIdAndDeletedAtIsNull(Long productId); - Page findAllByBrandIdAndDeletedAtIsNull(Long brandId, Pageable pageable); + Page findAllWithLikeCountByDeletedAtIsNull(Pageable pageable); - Product save(Product product); + Page findAllWithLikeCountByBrandIdAndDeletedAtIsNull(Long brandId, Pageable pageable); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductWithLikeCount.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductWithLikeCount.java new file mode 100644 index 000000000..cb2f3804a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductWithLikeCount.java @@ -0,0 +1,15 @@ +package com.loopers.domain.product; + +import java.time.ZonedDateTime; + +public record ProductWithLikeCount( + Long id, + Long brandId, + String name, + int price, + int stock, + int likeCount, + ZonedDateTime createdAt, + ZonedDateTime updatedAt +) { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/RedisCacheRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/RedisCacheRepository.java new file mode 100644 index 000000000..80e8cb2c9 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/RedisCacheRepository.java @@ -0,0 +1,97 @@ +package com.loopers.infrastructure.cache; + +import com.fasterxml.jackson.annotation.JsonAutoDetect; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.domain.BaseEntity; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.RedisTemplate; + +import java.time.Duration; +import java.util.Optional; +import java.util.Set; + +@Slf4j +public abstract class RedisCacheRepository { + + private final RedisTemplate redisTemplate; + private final ObjectMapper cacheObjectMapper; + + protected RedisCacheRepository( + RedisTemplate redisTemplate, + ObjectMapper objectMapper) { + this.redisTemplate = redisTemplate; + this.cacheObjectMapper = objectMapper.copy() + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + this.cacheObjectMapper.addMixIn(BaseEntity.class, EntityCacheMixin.class); + } + + @JsonAutoDetect(fieldVisibility = JsonAutoDetect.Visibility.ANY) + @JsonIgnoreProperties(ignoreUnknown = true) + abstract static class EntityCacheMixin {} + + // === 캐시 조회/저장 === + + protected Optional getFromCache(String key, Class type) { + String json = safeGet(key); + if (json == null) { + return Optional.empty(); + } + try { + return Optional.of(cacheObjectMapper.readValue(json, type)); + } catch (JsonProcessingException e) { + log.warn("캐시 역직렬화 실패: key={}", key, e); + safeDelete(key); + return Optional.empty(); + } + } + + protected void putToCache(String key, Object value, Duration ttl) { + try { + String json = cacheObjectMapper.writeValueAsString(value); + safeSet(key, json, ttl); + } catch (JsonProcessingException e) { + log.warn("캐시 직렬화 실패: key={}", key, e); + } + } + + // === Redis 안전 연산 (Fail-Silent) === + + protected void safeDelete(String key) { + try { + redisTemplate.delete(key); + } catch (Exception e) { + log.warn("Redis 삭제 실패: key={}", key, e); + } + } + + protected void safeDeleteByPattern(String pattern) { + try { + Set keys = redisTemplate.keys(pattern); + if (keys != null && !keys.isEmpty()) { + redisTemplate.delete(keys); + } + } catch (Exception e) { + log.warn("Redis 패턴 삭제 실패: pattern={}", pattern, e); + } + } + + private String safeGet(String key) { + try { + return redisTemplate.opsForValue().get(key); + } catch (Exception e) { + log.warn("Redis 조회 실패: key={}", key, e); + return null; + } + } + + private void safeSet(String key, String value, Duration ttl) { + try { + redisTemplate.opsForValue().set(key, value, ttl); + } catch (Exception e) { + log.warn("Redis 저장 실패: key={}", key, e); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/ProductLikeCountJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/ProductLikeCountJpaRepository.java new file mode 100644 index 000000000..ec938d4db --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/ProductLikeCountJpaRepository.java @@ -0,0 +1,14 @@ +package com.loopers.infrastructure.like; + +import com.loopers.domain.like.ProductLikeCount; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface ProductLikeCountJpaRepository extends JpaRepository { + + Optional findByProductId(Long productId); + + List findByProductIdIn(List productIds); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/ProductLikeCountRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/ProductLikeCountRepositoryImpl.java new file mode 100644 index 000000000..3a1ec8a38 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/ProductLikeCountRepositoryImpl.java @@ -0,0 +1,26 @@ +package com.loopers.infrastructure.like; + +import com.loopers.domain.like.ProductLikeCount; +import com.loopers.domain.like.ProductLikeCountRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@RequiredArgsConstructor +@Repository +public class ProductLikeCountRepositoryImpl implements ProductLikeCountRepository { + + private final ProductLikeCountJpaRepository productLikeCountJpaRepository; + + @Override + public Optional findByProductId(Long productId) { + return productLikeCountJpaRepository.findByProductId(productId); + } + + @Override + public List findByProductIdIn(List productIds) { + return productLikeCountJpaRepository.findByProductIdIn(productIds); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductCacheRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductCacheRepositoryImpl.java new file mode 100644 index 000000000..a1424ca78 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductCacheRepositoryImpl.java @@ -0,0 +1,130 @@ +package com.loopers.infrastructure.product; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductCacheRepository; +import com.loopers.domain.product.ProductWithLikeCount; +import com.loopers.infrastructure.cache.RedisCacheRepository; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Repository; + +import java.time.Duration; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +@Repository +public class ProductCacheRepositoryImpl extends RedisCacheRepository implements ProductCacheRepository { + + private static final String PRODUCT_KEY_PREFIX = "product:"; + private static final String PRODUCTS_KEY_PREFIX = "products:"; + private static final Duration TTL = Duration.ofHours(1); + + public ProductCacheRepositoryImpl( + RedisTemplate redisTemplate, + ObjectMapper objectMapper) { + super(redisTemplate, objectMapper); + } + + // === 단건: Product === + + @Override + public Optional getProduct(Long productId) { + return getFromCache(PRODUCT_KEY_PREFIX + productId, Product.class); + } + + @Override + public void putProduct(Long productId, Product product) { + putToCache(PRODUCT_KEY_PREFIX + productId, product, TTL); + } + + @Override + public void evictProduct(Long productId) { + safeDelete(PRODUCT_KEY_PREFIX + productId); + safeDelete(PRODUCT_KEY_PREFIX + productId + ":like"); + } + + // === 단건: ProductWithLikeCount === + + @Override + public Optional getProductWithLikeCount(Long productId) { + return getFromCache(PRODUCT_KEY_PREFIX + productId + ":like", ProductWithLikeCount.class); + } + + @Override + public void putProductWithLikeCount(Long productId, ProductWithLikeCount productWithLikeCount) { + putToCache(PRODUCT_KEY_PREFIX + productId + ":like", productWithLikeCount, TTL); + } + + // === 목록: Page === + + @Override + public Optional> getProducts(Pageable pageable) { + String key = PRODUCTS_KEY_PREFIX + buildPageSuffix(pageable); + return getFromCache(key, CachedProductPage.class) + .map(cached -> new PageImpl<>(cached.content, PageRequest.of(cached.page, cached.size), cached.totalElements)); + } + + @Override + public void putProducts(Pageable pageable, Page products) { + String key = PRODUCTS_KEY_PREFIX + buildPageSuffix(pageable); + putToCache(key, new CachedProductPage(products.getContent(), products.getTotalElements(), products.getNumber(), products.getSize()), TTL); + } + + // === 목록: Page === + + @Override + public Optional> getProductsWithLikeCount(Pageable pageable) { + String key = PRODUCTS_KEY_PREFIX + "like:" + buildPageSuffix(pageable); + return getFromCache(key, CachedProductWithLikeCountPage.class) + .map(cached -> new PageImpl<>(cached.content, PageRequest.of(cached.page, cached.size), cached.totalElements)); + } + + @Override + public void putProductsWithLikeCount(Pageable pageable, Page products) { + String key = PRODUCTS_KEY_PREFIX + "like:" + buildPageSuffix(pageable); + putToCache(key, new CachedProductWithLikeCountPage(products.getContent(), products.getTotalElements(), products.getNumber(), products.getSize()), TTL); + } + + @Override + public Optional> getProductsWithLikeCount(Long brandId, Pageable pageable) { + String key = PRODUCTS_KEY_PREFIX + "like:brand:" + brandId + ":" + buildPageSuffix(pageable); + return getFromCache(key, CachedProductWithLikeCountPage.class) + .map(cached -> new PageImpl<>(cached.content, PageRequest.of(cached.page, cached.size), cached.totalElements)); + } + + @Override + public void putProductsWithLikeCount(Long brandId, Pageable pageable, Page products) { + String key = PRODUCTS_KEY_PREFIX + "like:brand:" + brandId + ":" + buildPageSuffix(pageable); + putToCache(key, new CachedProductWithLikeCountPage(products.getContent(), products.getTotalElements(), products.getNumber(), products.getSize()), TTL); + } + + // === 전체 목록 캐시 무효화 === + + @Override + public void evictAllProductsCache() { + safeDeleteByPattern(PRODUCTS_KEY_PREFIX + "*"); + } + + // === private helpers === + + private String buildPageSuffix(Pageable pageable) { + String sort = pageable.getSort().stream() + .map(order -> order.getProperty() + "," + order.getDirection().name().toLowerCase()) + .collect(Collectors.joining("_")); + if (sort.isEmpty()) { + sort = "unsorted"; + } + return "page:" + pageable.getPageNumber() + ":size:" + pageable.getPageSize() + ":sort:" + sort; + } + + // === 캐시 직렬화용 레코드 === + + record CachedProductPage(List content, long totalElements, int page, int size) {} + + record CachedProductWithLikeCountPage(List content, long totalElements, int page, int size) {} +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java index 489ae248a..00aa21cd0 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java @@ -6,7 +6,6 @@ import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Lock; -import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -21,18 +20,8 @@ public interface ProductJpaRepository extends JpaRepository { @Query("SELECT p FROM Product p WHERE p.id = :id AND p.deletedAt IS NULL") Optional findByIdWithLockAndDeletedAtIsNull(@Param("id") Long id); - @Modifying(clearAutomatically = true, flushAutomatically = true) - @Query("UPDATE Product p SET p.likeCount = p.likeCount + 1 WHERE p.id = :id AND p.deletedAt IS NULL") - int incrementLikeCount(@Param("id") Long id); - - @Modifying(clearAutomatically = true, flushAutomatically = true) - @Query("UPDATE Product p SET p.likeCount = p.likeCount - 1 WHERE p.id = :id AND p.likeCount > 0 AND p.deletedAt IS NULL") - int decrementLikeCount(@Param("id") Long id); - Page findAllByDeletedAtIsNull(Pageable pageable); - Page findAllByBrandIdAndDeletedAtIsNull(Long brandId, Pageable pageable); - List findAllByBrandId(Long brandId); List findAllByBrandIdAndDeletedAtIsNull(Long brandId); diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java index 998b94526..47f11cb23 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java @@ -1,9 +1,17 @@ package com.loopers.infrastructure.product; +import com.loopers.domain.like.QProductLikeCount; import com.loopers.domain.product.Product; import com.loopers.domain.product.ProductRepository; +import com.loopers.domain.product.ProductWithLikeCount; +import com.loopers.domain.product.QProduct; +import com.querydsl.core.types.Order; +import com.querydsl.core.types.OrderSpecifier; +import com.querydsl.core.types.Projections; +import com.querydsl.jpa.impl.JPAQueryFactory; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Repository; @@ -13,55 +21,138 @@ @RequiredArgsConstructor @Repository public class ProductRepositoryImpl implements ProductRepository { - private final ProductJpaRepository productJpaRepository; - - @Override - public Page findAllByDeletedAtIsNull(Pageable pageable) { - return productJpaRepository.findAllByDeletedAtIsNull(pageable); - } - - @Override - public List findAllByBrandId(Long brandId) { - return productJpaRepository.findAllByBrandId(brandId); - } - - @Override - public List findAllByBrandIdAndDeletedAtIsNull(Long brandId) { - return productJpaRepository.findAllByBrandIdAndDeletedAtIsNull(brandId); - } - - @Override - public Optional findById(Long productId) { - return productJpaRepository.findById(productId); - } - - @Override - public Optional findByIdAndDeletedAtIsNull(Long productId) { - return productJpaRepository.findByIdAndDeletedAtIsNull(productId); - } - - @Override - public Optional findByIdWithLockAndDeletedAtIsNull(Long productId) { - return productJpaRepository.findByIdWithLockAndDeletedAtIsNull(productId); - } - - @Override - public int incrementLikeCount(Long productId) { - return productJpaRepository.incrementLikeCount(productId); - } - - @Override - public int decrementLikeCount(Long productId) { - return productJpaRepository.decrementLikeCount(productId); - } - - @Override - public Page findAllByBrandIdAndDeletedAtIsNull(Long brandId, Pageable pageable) { - return productJpaRepository.findAllByBrandIdAndDeletedAtIsNull(brandId, pageable); - } - - @Override - public Product save(Product product) { - return productJpaRepository.save(product); - } + private final ProductJpaRepository productJpaRepository; + private final JPAQueryFactory queryFactory; + + @Override + public Page findAllByDeletedAtIsNull(Pageable pageable) { + return productJpaRepository.findAllByDeletedAtIsNull(pageable); + } + + @Override + public List findAllByBrandId(Long brandId) { + return productJpaRepository.findAllByBrandId(brandId); + } + + @Override + public List findAllByBrandIdAndDeletedAtIsNull(Long brandId) { + return productJpaRepository.findAllByBrandIdAndDeletedAtIsNull(brandId); + } + + @Override + public Optional findById(Long productId) { + return productJpaRepository.findById(productId); + } + + @Override + public Optional findByIdAndDeletedAtIsNull(Long productId) { + return productJpaRepository.findByIdAndDeletedAtIsNull(productId); + } + + @Override + public Optional findByIdWithLockAndDeletedAtIsNull(Long productId) { + return productJpaRepository.findByIdWithLockAndDeletedAtIsNull(productId); + } + + @Override + public Product save(Product product) { + return productJpaRepository.save(product); + } + + @Override + public Optional findWithLikeCountByIdAndDeletedAtIsNull(Long productId) { + QProduct p = QProduct.product; + QProductLikeCount plc = QProductLikeCount.productLikeCount; + + ProductWithLikeCount result = queryFactory + .select(Projections.constructor(ProductWithLikeCount.class, + p.id, p.brandId, p.name, p.price, p.stock, + plc.likeCount.coalesce(0), p.createdAt, p.updatedAt + )) + .from(p) + .leftJoin(plc).on(p.id.eq(plc.productId)) + .where( + p.id.eq(productId), + p.deletedAt.isNull() + ) + .fetchOne(); + + return Optional.ofNullable(result); + } + + @Override + public Page findAllWithLikeCountByDeletedAtIsNull(Pageable pageable) { + QProduct p = QProduct.product; + QProductLikeCount plc = QProductLikeCount.productLikeCount; + + List content = queryFactory + .select(Projections.constructor(ProductWithLikeCount.class, + p.id, p.brandId, p.name, p.price, p.stock, + plc.likeCount.coalesce(0), p.createdAt, p.updatedAt + )) + .from(p) + .leftJoin(plc).on(p.id.eq(plc.productId)) + .where(p.deletedAt.isNull()) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .orderBy(getOrderSpecifiers(pageable, p, plc)) + .fetch(); + + Long total = queryFactory + .select(p.count()) + .from(p) + .where(p.deletedAt.isNull()) + .fetchOne(); + + return new PageImpl<>(content, pageable, total != null ? total : 0L); + } + + @Override + public Page findAllWithLikeCountByBrandIdAndDeletedAtIsNull(Long brandId, Pageable pageable) { + QProduct p = QProduct.product; + QProductLikeCount plc = QProductLikeCount.productLikeCount; + + List content = queryFactory + .select(Projections.constructor(ProductWithLikeCount.class, + p.id, p.brandId, p.name, p.price, p.stock, + plc.likeCount.coalesce(0), p.createdAt, p.updatedAt + )) + .from(p) + .leftJoin(plc).on(p.id.eq(plc.productId)) + .where( + p.brandId.eq(brandId), + p.deletedAt.isNull() + ) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .orderBy(getOrderSpecifiers(pageable, p, plc)) + .fetch(); + + Long total = queryFactory + .select(p.count()) + .from(p) + .where( + p.brandId.eq(brandId), + p.deletedAt.isNull() + ) + .fetchOne(); + + return new PageImpl<>(content, pageable, total != null ? total : 0L); + } + + private OrderSpecifier[] getOrderSpecifiers(Pageable pageable, QProduct p, QProductLikeCount plc) { + return pageable.getSort().stream() + .map(order -> { + Order direction = order.isAscending() ? Order.ASC : Order.DESC; + return switch (order.getProperty()) { + case "like" -> new OrderSpecifier<>(direction, plc.likeCount.coalesce(0)); + case "price" -> new OrderSpecifier<>(direction, p.price); + case "name" -> new OrderSpecifier<>(direction, p.name); + case "createdAt" -> new OrderSpecifier<>(direction, p.createdAt); + default -> new OrderSpecifier<>(direction, p.createdAt); + }; + }) + .toArray(OrderSpecifier[]::new); + } + }