From 49d22a35083d4b0e7c5475d4ca28f8d52036bef3 Mon Sep 17 00:00:00 2001 From: Junyoung Kim Date: Thu, 12 Mar 2026 20:55:36 +0900 Subject: [PATCH 1/7] =?UTF-8?q?refactor:=20=EC=A2=8B=EC=95=84=EC=9A=94=20?= =?UTF-8?q?=EC=88=98=EB=A5=BC=20Product=20=EC=97=94=ED=8B=B0=ED=8B=B0?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EC=A7=91=EA=B3=84=20=ED=85=8C=EC=9D=B4?= =?UTF-8?q?=EB=B8=94(ProductLikeCount)=EB=A1=9C=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Product 엔티티의 likeCount 필드 및 increment/decrement 쿼리 제거 - ProductLikeCount 집계 엔티티 추가 (product_likes_count 테이블) - ProductFacade에서 LikeService를 통해 좋아요 수 조회 후 조합 - LikeFacade에서 ProductService 좋아요 카운트 조작 의존 제거 --- .../loopers/application/like/LikeFacade.java | 7 +--- .../loopers/application/like/LikeService.java | 23 ++++++++++ .../application/product/ProductFacade.java | 16 +++++-- .../application/product/ProductService.java | 16 ------- .../product/result/ProductResult.java | 4 +- .../result/ProductWithBrandResult.java | 6 +-- .../loopers/domain/like/ProductLikeCount.java | 42 +++++++++++++++++++ .../like/ProductLikeCountRepository.java | 11 +++++ .../com/loopers/domain/product/Product.java | 4 -- .../domain/product/ProductRepository.java | 4 -- .../like/ProductLikeCountJpaRepository.java | 14 +++++++ .../like/ProductLikeCountRepositoryImpl.java | 26 ++++++++++++ .../product/ProductJpaRepository.java | 9 ---- .../product/ProductRepositoryImpl.java | 10 ----- 14 files changed, 134 insertions(+), 58 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/like/ProductLikeCount.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/like/ProductLikeCountRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/like/ProductLikeCountJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/like/ProductLikeCountRepositoryImpl.java 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..c5fd51a66 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 @@ -15,6 +15,9 @@ import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; +import java.util.List; +import java.util.Map; + @RequiredArgsConstructor @Component public class ProductFacade { @@ -27,16 +30,23 @@ public class ProductFacade { public ProductWithBrandResult getProduct(Long productId) { ProductResult product = productService.getProduct(productId); BrandResult brand = brandService.getBrand(product.brandId()); - return ProductWithBrandResult.from(product, brand.name()); + int likeCount = likeService.getLikeCount(productId); + return ProductWithBrandResult.from(product, brand.name(), likeCount); } @Transactional(readOnly = true) public Page getProducts(Long brandId, Pageable pageable) { Page products = productService.getProducts(brandId, pageable); + List productIds = products.getContent().stream() + .map(ProductResult::id) + .toList(); + Map likeCounts = likeService.getLikeCounts(productIds); + return products.map(product -> { BrandResult brand = brandService.getBrand(product.brandId()); - return ProductWithBrandResult.from(product, brand.name()); + int likeCount = likeCounts.getOrDefault(product.id(), 0); + return ProductWithBrandResult.from(product, brand.name(), likeCount); }); } @@ -61,4 +71,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..7a6de727f 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 @@ -86,22 +86,6 @@ public void restoreStock(Long productId, int quantity) { 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, "상품을 찾을 수 없습니다."); - } - } - @Transactional public void deleteProduct(Long productId) { Product product = productRepository.findById(productId) 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..7daa5a42d 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(ProductResult product, String brandName, int likeCount) { return new ProductWithBrandResult( product.id(), product.brandId(), @@ -21,9 +21,9 @@ public static ProductWithBrandResult from(ProductResult product, String brandNam product.name(), product.price(), product.stock(), - product.likeCount(), + likeCount, product.createdAt(), product.updatedAt() ); } -} \ No newline at end of file +} 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/ProductRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java index 8dca426df..b298fe690 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,10 +19,6 @@ public interface ProductRepository { Optional findByIdWithLockAndDeletedAtIsNull(Long productId); - int incrementLikeCount(Long productId); - - int decrementLikeCount(Long productId); - Page findAllByBrandIdAndDeletedAtIsNull(Long brandId, Pageable pageable); Product save(Product product); 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/ProductJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java index 489ae248a..141489452 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,14 +20,6 @@ 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); 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..9493bae17 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 @@ -45,16 +45,6 @@ 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); From 4e12f9ac0057b98ed89c503532377a8581fdea9d Mon Sep 17 00:00:00 2001 From: Junyoung Kim Date: Fri, 13 Mar 2026 00:41:33 +0900 Subject: [PATCH 2/7] =?UTF-8?q?feat:=20=EC=83=81=ED=92=88=20=EB=8B=A4?= =?UTF-8?q?=EA=B1=B4=20=EC=A1=B0=ED=9A=8C=20=EC=8B=9C=20=EC=A2=8B=EC=95=84?= =?UTF-8?q?=EC=9A=94=20=EC=88=98=EB=A5=BC=20=EC=A1=B0=EC=9D=B8=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=A2=8B=EC=95=84=EC=9A=94=20=EC=88=9C=20=EC=A0=95?= =?UTF-8?q?=EB=A0=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - product_likes_count 테이블과 LEFT JOIN하여 별도 조회 제거 - JPQL에서 QueryDSL로 전환하여 타입 안전한 동적 쿼리 및 정렬 지원 - ProductResult와 ProductWithLikeCountResult를 분리하여 역할 명확화 --- .../application/product/ProductFacade.java | 16 +- .../application/product/ProductService.java | 14 +- .../result/ProductWithBrandResult.java | 14 ++ .../result/ProductWithLikeCountResult.java | 29 ++++ .../domain/product/ProductRepository.java | 6 +- .../domain/product/ProductWithLikeCount.java | 15 ++ .../product/ProductJpaRepository.java | 2 - .../product/ProductRepositoryImpl.java | 162 +++++++++++++----- 8 files changed, 198 insertions(+), 60 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/product/result/ProductWithLikeCountResult.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/ProductWithLikeCount.java 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 c5fd51a66..b7f48d0f9 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; @@ -15,9 +16,6 @@ import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; -import java.util.List; -import java.util.Map; - @RequiredArgsConstructor @Component public class ProductFacade { @@ -36,17 +34,13 @@ public ProductWithBrandResult getProduct(Long productId) { @Transactional(readOnly = true) public Page getProducts(Long brandId, Pageable pageable) { - Page products = productService.getProducts(brandId, pageable); - - List productIds = products.getContent().stream() - .map(ProductResult::id) - .toList(); - Map likeCounts = likeService.getLikeCounts(productIds); + Page products = (brandId != null) + ? productService.getProductsWithLikeCount(brandId, pageable) + : productService.getProductsWithLikeCount(pageable); return products.map(product -> { BrandResult brand = brandService.getBrand(product.brandId()); - int likeCount = likeCounts.getOrDefault(product.id(), 0); - return ProductWithBrandResult.from(product, brand.name(), likeCount); + return ProductWithBrandResult.from(product, brand.name()); }); } 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 7a6de727f..08b9f3ba3 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,6 +3,7 @@ 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.ProductRepository; import com.loopers.support.error.CoreException; @@ -38,7 +39,6 @@ public ProductResult registerProduct(Long brandId, ProductCreateCommand command) public ProductResult getProduct(Long productId) { Product product = productRepository.findByIdAndDeletedAtIsNull(productId) .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다.")); - return ProductResult.from(product); } @@ -49,9 +49,15 @@ public Page getProducts(Pageable pageable) { } @Transactional(readOnly = true) - public Page getProducts(Long brandId, Pageable pageable) { - return productRepository.findAllByBrandIdAndDeletedAtIsNull(brandId, pageable) - .map(ProductResult::from); + public Page getProductsWithLikeCount(Pageable pageable) { + return productRepository.findAllWithLikeCountByDeletedAtIsNull(pageable) + .map(ProductWithLikeCountResult::from); + } + + @Transactional(readOnly = true) + public Page getProductsWithLikeCount(Long brandId, Pageable pageable) { + return productRepository.findAllWithLikeCountByBrandIdAndDeletedAtIsNull(brandId, pageable) + .map(ProductWithLikeCountResult::from); } @Transactional(readOnly = true) 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 7daa5a42d..299c95846 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,6 +13,20 @@ public record ProductWithBrandResult( ZonedDateTime createdAt, ZonedDateTime updatedAt ) { + public static ProductWithBrandResult from(ProductWithLikeCountResult product, String brandName) { + return new ProductWithBrandResult( + product.id(), + product.brandId(), + brandName, + product.name(), + product.price(), + product.stock(), + product.likeCount(), + product.createdAt(), + product.updatedAt() + ); + } + public static ProductWithBrandResult from(ProductResult product, String brandName, int likeCount) { return new ProductWithBrandResult( product.id(), 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/product/ProductRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java index b298fe690..dffb51954 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,7 +19,9 @@ public interface ProductRepository { Optional findByIdWithLockAndDeletedAtIsNull(Long productId); - Page findAllByBrandIdAndDeletedAtIsNull(Long brandId, Pageable pageable); - Product save(Product product); + + Page findAllWithLikeCountByDeletedAtIsNull(Pageable pageable); + + 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/product/ProductJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java index 141489452..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 @@ -22,8 +22,6 @@ public interface ProductJpaRepository extends JpaRepository { 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 9493bae17..94e0edbe4 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,45 +21,117 @@ @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 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 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); + } + } From 525b4eb05f1ccd9b44e1e9a4a71102369a0f6638 Mon Sep 17 00:00:00 2001 From: Junyoung Kim Date: Fri, 13 Mar 2026 00:49:22 +0900 Subject: [PATCH 3/7] =?UTF-8?q?refactor:=20=EC=83=81=ED=92=88=20=EB=8B=A8?= =?UTF-8?q?=EA=B1=B4=20=EC=A1=B0=ED=9A=8C=20=EC=8B=9C=20=EC=A2=8B=EC=95=84?= =?UTF-8?q?=EC=9A=94=20=EC=88=98=EB=A5=BC=20=EC=A1=B0=EC=9D=B8=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=ED=95=A8=EA=BB=98=20=EC=A1=B0=ED=9A=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/product/ProductFacade.java | 5 ++--- .../application/product/ProductService.java | 8 +++++++ .../result/ProductWithBrandResult.java | 14 ------------- .../domain/product/ProductRepository.java | 2 ++ .../product/ProductRepositoryImpl.java | 21 +++++++++++++++++++ 5 files changed, 33 insertions(+), 17 deletions(-) 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 b7f48d0f9..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 @@ -26,10 +26,9 @@ 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()); - int likeCount = likeService.getLikeCount(productId); - return ProductWithBrandResult.from(product, brand.name(), likeCount); + return ProductWithBrandResult.from(product, brand.name()); } @Transactional(readOnly = true) 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 08b9f3ba3..2eb1f248a 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 @@ -42,6 +42,14 @@ public ProductResult getProduct(Long productId) { return ProductResult.from(product); } + @Transactional(readOnly = true) + public ProductWithLikeCountResult getProductWithLikeCount(Long productId) { + return ProductWithLikeCountResult.from( + productRepository.findWithLikeCountByIdAndDeletedAtIsNull(productId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다.")) + ); + } + @Transactional(readOnly = true) public Page getProducts(Pageable pageable) { return productRepository.findAllByDeletedAtIsNull(pageable) 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 299c95846..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 @@ -26,18 +26,4 @@ public static ProductWithBrandResult from(ProductWithLikeCountResult product, St product.updatedAt() ); } - - public static ProductWithBrandResult from(ProductResult product, String brandName, int likeCount) { - return new ProductWithBrandResult( - product.id(), - product.brandId(), - brandName, - product.name(), - product.price(), - product.stock(), - likeCount, - product.createdAt(), - product.updatedAt() - ); - } } 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 dffb51954..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 @@ -21,6 +21,8 @@ public interface ProductRepository { Product save(Product product); + Optional findWithLikeCountByIdAndDeletedAtIsNull(Long productId); + Page findAllWithLikeCountByDeletedAtIsNull(Pageable pageable); Page findAllWithLikeCountByBrandIdAndDeletedAtIsNull(Long brandId, Pageable pageable); 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 94e0edbe4..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 @@ -59,6 +59,27 @@ 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; From 7ea1c2bb6306b597c0b46ca69d4973cbeb92f193 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=80=E1=85=B5=E1=86=B7=E1=84=8C=E1=85=AE=E1=86=AB?= =?UTF-8?q?=E1=84=8B=E1=85=A7=E1=86=BC?= Date: Fri, 13 Mar 2026 16:34:05 +0900 Subject: [PATCH 4/7] =?UTF-8?q?feat:=20=EC=83=81=ED=92=88=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=20=EC=A1=B0=ED=9A=8C=20=EB=B0=8F=20=EB=AA=A9=EB=A1=9D?= =?UTF-8?q?=20=EC=A1=B0=ED=9A=8C=EC=97=90=20Redis=20=EC=BA=90=EC=8B=B1=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ProductCacheRepository 인터페이스에 단건/목록 캐시 메서드 정의 - ProductCacheRepositoryImpl에 Redis 기반 cache-aside 패턴 구현 - ProductService 조회 메서드에 캐시 조회 우선 적용, 변경 메서드에 캐시 무효화 추가 - CLAUDE.md에 캐시 키 네이밍 컨벤션 및 무효화 전략 문서화 Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 22 ++- .../application/product/ProductService.java | 76 ++++++-- .../product/ProductCacheRepository.java | 30 +++ .../product/ProductCacheRepositoryImpl.java | 174 ++++++++++++++++++ 4 files changed, 282 insertions(+), 20 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/ProductCacheRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductCacheRepositoryImpl.java 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/product/ProductService.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java index 2eb1f248a..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 @@ -5,23 +5,25 @@ 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) { @@ -32,40 +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) { - return ProductWithLikeCountResult.from( - productRepository.findWithLikeCountByIdAndDeletedAtIsNull(productId) - .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다.")) - ); + 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) { - return productRepository.findAllWithLikeCountByDeletedAtIsNull(pageable) - .map(ProductWithLikeCountResult::from); + 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 getProductsWithLikeCount(Long brandId, Pageable pageable) { - return productRepository.findAllWithLikeCountByBrandIdAndDeletedAtIsNull(brandId, pageable) - .map(ProductWithLikeCountResult::from); + 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) @@ -81,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); } @@ -90,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); } @@ -98,20 +130,26 @@ public void restoreStock(Long productId, int quantity) { Product product = productRepository.findByIdWithLockAndDeletedAtIsNull(productId) .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다.")); product.restoreStock(quantity); + 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/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/infrastructure/product/ProductCacheRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductCacheRepositoryImpl.java new file mode 100644 index 000000000..9a5d28cd9 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductCacheRepositoryImpl.java @@ -0,0 +1,174 @@ +package com.loopers.infrastructure.product; + +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 com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductCacheRepository; +import com.loopers.domain.product.ProductWithLikeCount; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Qualifier; +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.Set; +import java.util.stream.Collectors; + +@Slf4j +@Repository +public class ProductCacheRepositoryImpl 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); + + private final RedisTemplate redisTemplate; + private final ObjectMapper cacheObjectMapper; + + public ProductCacheRepositoryImpl( + @Qualifier("defaultRedisTemplate") 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 {} + + // === 단건: 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); + } + + @Override + public void evictProduct(Long productId) { + redisTemplate.delete(PRODUCT_KEY_PREFIX + productId); + redisTemplate.delete(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); + } + + // === 목록: 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())); + } + + // === 목록: 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())); + } + + @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())); + } + + // === 전체 목록 캐시 무효화 === + + @Override + public void evictAllProductsCache() { + Set keys = redisTemplate.keys(PRODUCTS_KEY_PREFIX + "*"); + if (keys != null && !keys.isEmpty()) { + redisTemplate.delete(keys); + } + } + + // === private helpers === + + private Optional getFromCache(String key, Class type) { + String json = redisTemplate.opsForValue().get(key); + if (json == null) { + return Optional.empty(); + } + try { + return Optional.of(cacheObjectMapper.readValue(json, type)); + } catch (JsonProcessingException e) { + log.warn("캐시 역직렬화 실패: key={}", key, e); + redisTemplate.delete(key); + return Optional.empty(); + } + } + + private void putToCache(String key, Object value) { + try { + String json = cacheObjectMapper.writeValueAsString(value); + redisTemplate.opsForValue().set(key, json, TTL); + } catch (JsonProcessingException e) { + log.warn("캐시 직렬화 실패: key={}", key, e); + } + } + + 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) {} +} From 1dbd658e27b5399f3cd8fd157acb762c007f88eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=80=E1=85=B5=E1=86=B7=E1=84=8C=E1=85=AE=E1=86=AB?= =?UTF-8?q?=E1=84=8B=E1=85=A7=E1=86=BC?= Date: Fri, 13 Mar 2026 16:48:51 +0900 Subject: [PATCH 5/7] =?UTF-8?q?fix:=20Redis=20=EC=9E=A5=EC=95=A0=20?= =?UTF-8?q?=EC=8B=9C=20=EC=84=9C=EB=B9=84=EC=8A=A4=20=EC=A0=95=EC=83=81=20?= =?UTF-8?q?=EB=8F=99=EC=9E=91=EC=9D=84=20=EC=9C=84=ED=95=9C=20=EC=98=88?= =?UTF-8?q?=EC=99=B8=20=EC=B2=98=EB=A6=AC=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 모든 Redis 연산에 try-catch를 적용하여 장애 시 DB 처리되게 보장 - getFromCache: 역직렬화 실패와 Redis 연결 실패를 분리 처리 - putToCache, evictProduct, evictAllProductsCache: Exception 흡수 --- .../product/ProductCacheRepositoryImpl.java | 52 ++++++++++++------- 1 file changed, 34 insertions(+), 18 deletions(-) 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 index 9a5d28cd9..7f84fb328 100644 --- 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 @@ -18,6 +18,7 @@ import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Repository; + import java.time.Duration; import java.util.List; import java.util.Optional; @@ -62,8 +63,12 @@ public void putProduct(Long productId, Product product) { @Override public void evictProduct(Long productId) { - redisTemplate.delete(PRODUCT_KEY_PREFIX + productId); - redisTemplate.delete(PRODUCT_KEY_PREFIX + productId + ":like"); + try { + redisTemplate.delete(PRODUCT_KEY_PREFIX + productId); + redisTemplate.delete(PRODUCT_KEY_PREFIX + productId + ":like"); + } catch (Exception e) { + log.warn("캐시 삭제 실패: productId={}", productId, e); + } } // === 단건: ProductWithLikeCount === @@ -125,34 +130,45 @@ public void putProductsWithLikeCount(Long brandId, Pageable pageable, Page keys = redisTemplate.keys(PRODUCTS_KEY_PREFIX + "*"); - if (keys != null && !keys.isEmpty()) { - redisTemplate.delete(keys); + try { + Set keys = redisTemplate.keys(PRODUCTS_KEY_PREFIX + "*"); + if (keys != null && !keys.isEmpty()) { + redisTemplate.delete(keys); + } + } catch (Exception e) { + log.warn("목록 캐시 전체 삭제 실패", e); } } // === private helpers === private Optional getFromCache(String key, Class type) { - String json = redisTemplate.opsForValue().get(key); - if (json == null) { - return Optional.empty(); - } - try { - return Optional.of(cacheObjectMapper.readValue(json, type)); - } catch (JsonProcessingException e) { - log.warn("캐시 역직렬화 실패: key={}", key, e); - redisTemplate.delete(key); - return Optional.empty(); - } + try { + String json = redisTemplate.opsForValue().get(key); + if (json == null) { + return Optional.empty(); + } + return Optional.of(cacheObjectMapper.readValue(json, type)); + } catch (JsonProcessingException e) { + log.warn("캐시 역직렬화 실패: key={}", key, e); + try { + redisTemplate.delete(key); + } catch (Exception ignored) { + log.warn("캐시 삭제 실패: key={}", key, e); + } + return Optional.empty(); + } catch (Exception e) { + log.warn("캐시 조회 실패: key={}", key, e); + return Optional.empty(); + } } private void putToCache(String key, Object value) { try { String json = cacheObjectMapper.writeValueAsString(value); redisTemplate.opsForValue().set(key, json, TTL); - } catch (JsonProcessingException e) { - log.warn("캐시 직렬화 실패: key={}", key, e); + } catch (Exception e) { + log.warn("캐시 저장 실패: key={}", key, e); } } From 142c6841ecea9b2304800dee419d52866acf6a46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=80=E1=85=B5=E1=86=B7=E1=84=8C=E1=85=AE=E1=86=AB?= =?UTF-8?q?=E1=84=8B=E1=85=A7=E1=86=BC?= Date: Fri, 13 Mar 2026 17:01:05 +0900 Subject: [PATCH 6/7] =?UTF-8?q?refactor:=20Redis=20=EC=BA=90=EC=8B=9C=20?= =?UTF-8?q?=EA=B3=B5=ED=86=B5=20=EB=A1=9C=EC=A7=81=EC=9D=84=20RedisCacheRe?= =?UTF-8?q?pository=20=EC=B6=94=EC=83=81=20=ED=81=B4=EB=9E=98=EC=8A=A4?= =?UTF-8?q?=EB=A1=9C=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fail-Silent 예외 처리, JSON 직렬화, EntityCacheMixin 등 공통 로직을 RedisCacheRepository 추상 클래스로 추출하여 재사용 가능하게 개선 --- .../cache/RedisCacheRepository.java | 100 ++++++++++++++++++ .../product/ProductCacheRepositoryImpl.java | 71 ++----------- 2 files changed, 106 insertions(+), 65 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/RedisCacheRepository.java 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..29c932c0c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/RedisCacheRepository.java @@ -0,0 +1,100 @@ +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; + private final Duration ttl; + + protected RedisCacheRepository( + RedisTemplate redisTemplate, + ObjectMapper objectMapper, + Duration ttl) { + this.redisTemplate = redisTemplate; + this.cacheObjectMapper = objectMapper.copy() + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + this.cacheObjectMapper.addMixIn(BaseEntity.class, EntityCacheMixin.class); + this.ttl = ttl; + } + + @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) { + try { + String json = cacheObjectMapper.writeValueAsString(value); + safeSet(key, json); + } 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) { + 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/product/ProductCacheRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductCacheRepositoryImpl.java index 7f84fb328..472e1b0e8 100644 --- 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 @@ -1,15 +1,10 @@ package com.loopers.infrastructure.product; -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 com.loopers.domain.product.Product; import com.loopers.domain.product.ProductCacheRepository; import com.loopers.domain.product.ProductWithLikeCount; -import lombok.extern.slf4j.Slf4j; +import com.loopers.infrastructure.cache.RedisCacheRepository; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageImpl; @@ -18,37 +13,24 @@ 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.Set; import java.util.stream.Collectors; -@Slf4j @Repository -public class ProductCacheRepositoryImpl implements ProductCacheRepository { +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); - private final RedisTemplate redisTemplate; - private final ObjectMapper cacheObjectMapper; - public ProductCacheRepositoryImpl( @Qualifier("defaultRedisTemplate") 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); + super(redisTemplate, objectMapper, TTL); } - @JsonAutoDetect(fieldVisibility = JsonAutoDetect.Visibility.ANY) - @JsonIgnoreProperties(ignoreUnknown = true) - abstract static class EntityCacheMixin {} - // === 단건: Product === @Override @@ -63,12 +45,8 @@ public void putProduct(Long productId, Product product) { @Override public void evictProduct(Long productId) { - try { - redisTemplate.delete(PRODUCT_KEY_PREFIX + productId); - redisTemplate.delete(PRODUCT_KEY_PREFIX + productId + ":like"); - } catch (Exception e) { - log.warn("캐시 삭제 실패: productId={}", productId, e); - } + safeDelete(PRODUCT_KEY_PREFIX + productId); + safeDelete(PRODUCT_KEY_PREFIX + productId + ":like"); } // === 단건: ProductWithLikeCount === @@ -130,48 +108,11 @@ public void putProductsWithLikeCount(Long brandId, Pageable pageable, Page keys = redisTemplate.keys(PRODUCTS_KEY_PREFIX + "*"); - if (keys != null && !keys.isEmpty()) { - redisTemplate.delete(keys); - } - } catch (Exception e) { - log.warn("목록 캐시 전체 삭제 실패", e); - } + safeDeleteByPattern(PRODUCTS_KEY_PREFIX + "*"); } // === private helpers === - private Optional getFromCache(String key, Class type) { - try { - String json = redisTemplate.opsForValue().get(key); - if (json == null) { - return Optional.empty(); - } - return Optional.of(cacheObjectMapper.readValue(json, type)); - } catch (JsonProcessingException e) { - log.warn("캐시 역직렬화 실패: key={}", key, e); - try { - redisTemplate.delete(key); - } catch (Exception ignored) { - log.warn("캐시 삭제 실패: key={}", key, e); - } - return Optional.empty(); - } catch (Exception e) { - log.warn("캐시 조회 실패: key={}", key, e); - return Optional.empty(); - } - } - - private void putToCache(String key, Object value) { - try { - String json = cacheObjectMapper.writeValueAsString(value); - redisTemplate.opsForValue().set(key, json, TTL); - } catch (Exception e) { - log.warn("캐시 저장 실패: key={}", key, e); - } - } - private String buildPageSuffix(Pageable pageable) { String sort = pageable.getSort().stream() .map(order -> order.getProperty() + "," + order.getDirection().name().toLowerCase()) From 037b833de0ebbbf4548d25985e8f3c73ad1d9783 Mon Sep 17 00:00:00 2001 From: Junyoung Kim Date: Sat, 14 Mar 2026 00:45:53 +0900 Subject: [PATCH 7/7] =?UTF-8?q?refactor:=20redis=20TTL=EC=9D=84=20?= =?UTF-8?q?=EB=B9=88=20=EC=83=9D=EC=84=B1=20=EC=8B=9C=EC=A0=90=EC=9D=B4=20?= =?UTF-8?q?=EC=95=84=EB=8B=8C=20=ED=98=B8=EC=B6=9C=20=EC=8B=9C=EC=A0=90?= =?UTF-8?q?=EC=97=90=20=EC=A7=80=EC=A0=95=ED=95=98=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 캐시 키마다 다른 TTL을 적용할 수 있도록 클래스 필드에서 메서드 파라미터로 이동 - @Primary 빈을 활용하여 불필요한 @Qualifier 제거 --- .../cache/RedisCacheRepository.java | 11 ++++------- .../product/ProductCacheRepositoryImpl.java | 15 +++++++-------- 2 files changed, 11 insertions(+), 15 deletions(-) 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 index 29c932c0c..80e8cb2c9 100644 --- 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 @@ -18,17 +18,14 @@ public abstract class RedisCacheRepository { private final RedisTemplate redisTemplate; private final ObjectMapper cacheObjectMapper; - private final Duration ttl; protected RedisCacheRepository( RedisTemplate redisTemplate, - ObjectMapper objectMapper, - Duration ttl) { + ObjectMapper objectMapper) { this.redisTemplate = redisTemplate; this.cacheObjectMapper = objectMapper.copy() .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); this.cacheObjectMapper.addMixIn(BaseEntity.class, EntityCacheMixin.class); - this.ttl = ttl; } @JsonAutoDetect(fieldVisibility = JsonAutoDetect.Visibility.ANY) @@ -51,10 +48,10 @@ protected Optional getFromCache(String key, Class type) { } } - protected void putToCache(String key, Object value) { + protected void putToCache(String key, Object value, Duration ttl) { try { String json = cacheObjectMapper.writeValueAsString(value); - safeSet(key, json); + safeSet(key, json, ttl); } catch (JsonProcessingException e) { log.warn("캐시 직렬화 실패: key={}", key, e); } @@ -90,7 +87,7 @@ private String safeGet(String key) { } } - private void safeSet(String key, String value) { + private void safeSet(String key, String value, Duration ttl) { try { redisTemplate.opsForValue().set(key, value, ttl); } catch (Exception e) { 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 index 472e1b0e8..a1424ca78 100644 --- 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 @@ -5,7 +5,6 @@ import com.loopers.domain.product.ProductCacheRepository; import com.loopers.domain.product.ProductWithLikeCount; import com.loopers.infrastructure.cache.RedisCacheRepository; -import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.PageRequest; @@ -26,9 +25,9 @@ public class ProductCacheRepositoryImpl extends RedisCacheRepository implements private static final Duration TTL = Duration.ofHours(1); public ProductCacheRepositoryImpl( - @Qualifier("defaultRedisTemplate") RedisTemplate redisTemplate, + RedisTemplate redisTemplate, ObjectMapper objectMapper) { - super(redisTemplate, objectMapper, TTL); + super(redisTemplate, objectMapper); } // === 단건: Product === @@ -40,7 +39,7 @@ public Optional getProduct(Long productId) { @Override public void putProduct(Long productId, Product product) { - putToCache(PRODUCT_KEY_PREFIX + productId, product); + putToCache(PRODUCT_KEY_PREFIX + productId, product, TTL); } @Override @@ -58,7 +57,7 @@ public Optional getProductWithLikeCount(Long productId) { @Override public void putProductWithLikeCount(Long productId, ProductWithLikeCount productWithLikeCount) { - putToCache(PRODUCT_KEY_PREFIX + productId + ":like", productWithLikeCount); + putToCache(PRODUCT_KEY_PREFIX + productId + ":like", productWithLikeCount, TTL); } // === 목록: Page === @@ -73,7 +72,7 @@ public Optional> getProducts(Pageable pageable) { @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())); + putToCache(key, new CachedProductPage(products.getContent(), products.getTotalElements(), products.getNumber(), products.getSize()), TTL); } // === 목록: Page === @@ -88,7 +87,7 @@ public Optional> getProductsWithLikeCount(Pageable pa @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())); + putToCache(key, new CachedProductWithLikeCountPage(products.getContent(), products.getTotalElements(), products.getNumber(), products.getSize()), TTL); } @Override @@ -101,7 +100,7 @@ public Optional> getProductsWithLikeCount(Long brandI @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())); + putToCache(key, new CachedProductWithLikeCountPage(products.getContent(), products.getTotalElements(), products.getNumber(), products.getSize()), TTL); } // === 전체 목록 캐시 무효화 ===