From aa3d2eea26d329b37e3d15e3b252900ee813b3f3 Mon Sep 17 00:00:00 2001 From: madirony Date: Thu, 12 Mar 2026 23:47:59 +0900 Subject: [PATCH 1/8] =?UTF-8?q?fix(domain):=20Money.subtract=20=EC=98=88?= =?UTF-8?q?=EC=99=B8=EB=A5=BC=20CoreException=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=20=EB=B0=8F=20Jackson=20=EC=97=AD=EC=A7=81?= =?UTF-8?q?=EB=A0=AC=ED=99=94=20=EC=A7=80=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - subtract()에서 IllegalArgumentException 대신 CoreException(BAD_REQUEST) 사용 - @JsonCreator/@JsonProperty 추가로 Redis 캐시 직렬화/역직렬화 지원 --- .../src/main/java/com/loopers/domain/common/Money.java | 7 +++++-- .../src/test/java/com/loopers/domain/common/MoneyTest.java | 3 +-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/common/Money.java b/apps/commerce-api/src/main/java/com/loopers/domain/common/Money.java index d25e1f431..ac39c4154 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/common/Money.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/common/Money.java @@ -1,5 +1,7 @@ package com.loopers.domain.common; +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import jakarta.persistence.Embeddable; @@ -17,7 +19,8 @@ public class Money { private BigDecimal amount; - private Money(BigDecimal amount) { + @JsonCreator + private Money(@JsonProperty("amount") BigDecimal amount) { if (amount == null || amount.compareTo(BigDecimal.ZERO) < 0) { throw new CoreException(ErrorType.BAD_REQUEST, "금액은 0 이상이어야 합니다."); } @@ -55,7 +58,7 @@ public boolean isGreaterThanOrEqual(Money other) { public Money subtract(Money other) { BigDecimal result = this.amount.subtract(other.amount); if (result.compareTo(BigDecimal.ZERO) < 0) { - throw new IllegalArgumentException( + throw new CoreException(ErrorType.BAD_REQUEST, "차감 결과가 음수입니다: " + this.amount + " - " + other.amount + " = " + result); } return new Money(result); diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/common/MoneyTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/common/MoneyTest.java index 59ed96c46..841d91954 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/common/MoneyTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/common/MoneyTest.java @@ -94,8 +94,7 @@ void subtractMoneyThrowsExceptionWhenNegative() { Money money2 = Money.of(1000L); assertThatThrownBy(() -> money1.subtract(money2)) - .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("차감 결과가 음수"); + .isInstanceOf(CoreException.class); } @Test From f60b4d3d12a9161a24956567e21b89193f58c2e0 Mon Sep 17 00:00:00 2001 From: madirony Date: Thu, 12 Mar 2026 23:48:19 +0900 Subject: [PATCH 2/8] =?UTF-8?q?perf(product):=20=EB=B3=B5=ED=95=A9=20?= =?UTF-8?q?=EC=9D=B8=EB=8D=B1=EC=8A=A4=20=EC=84=A4=EA=B3=84=20=EB=B0=8F=20?= =?UTF-8?q?=EB=B8=8C=EB=9E=9C=EB=93=9C=EB=B3=84=20=EC=83=81=ED=92=88=20?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=95=20=EC=A1=B0=ED=9A=8C=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - (brand_id, deleted, like_count DESC) 복합 인덱스 추가 — fullscan 제거 - likeCount 증감 도메인 메서드 제거 — DB 원자적 @Modifying 쿼리로 대체 완료 - findByBrandIdWithPaging 페이징 쿼리 추가 (likeCount DESC 정렬) - ddl-auto를 update로 변경하여 인덱스 자동 생성 지원 --- .../com/loopers/domain/product/Product.java | 15 +++------ .../domain/product/ProductRepository.java | 4 +++ .../product/ProductJpaRepository.java | 5 +++ .../product/ProductRepositoryImpl.java | 7 ++++ .../loopers/domain/product/ProductTest.java | 33 ------------------- modules/jpa/src/main/resources/jpa.yml | 2 +- 6 files changed, 21 insertions(+), 45 deletions(-) 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 ce744c299..fb696333e 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 @@ -8,13 +8,16 @@ import jakarta.persistence.Column; import jakarta.persistence.Embedded; import jakarta.persistence.Entity; +import jakarta.persistence.Index; import jakarta.persistence.Table; import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; @Entity -@Table(name = "products") +@Table(name = "products", indexes = { + @Index(name = "idx_products_brand_deleted_likes", columnList = "brand_id, deleted, like_count DESC") +}) @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) public class Product extends BaseEntity { @@ -48,16 +51,6 @@ public static Product create(Long brandId, String name, Money basePrice) { return new Product(brandId, name, basePrice, false, 0L); } - public void increaseLikeCount() { - this.likeCount++; - } - - public void decreaseLikeCount() { - if (this.likeCount > 0) { - this.likeCount--; - } - } - public void update(String name, Money basePrice) { validateName(name); this.name = name; 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 e946eba7a..5a1918735 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 @@ -1,5 +1,8 @@ package com.loopers.domain.product; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + import java.util.List; import java.util.Optional; @@ -10,6 +13,7 @@ public interface ProductRepository { List findAll(ProductSortCondition condition); List findByBrandId(Long brandId); List findByIdIn(List productIds); + Page findByBrandIdWithPaging(Long brandId, Pageable pageable); void increaseLikeCount(Long id); void decreaseLikeCount(Long id); } 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 ef15d8674..901ed8e8f 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 @@ -3,6 +3,8 @@ import com.loopers.domain.product.Product; import jakarta.persistence.LockModeType; import jakarta.persistence.QueryHint; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Lock; @@ -22,6 +24,9 @@ public interface ProductJpaRepository extends JpaRepository { List findByBrandIdAndDeletedFalse(Long brandId); + @Query("SELECT p FROM Product p WHERE p.brandId = :brandId AND p.deleted = false ORDER BY p.likeCount DESC") + Page findByBrandIdAndDeletedFalseOrderByLikeCountDesc(@Param("brandId") Long brandId, Pageable pageable); + Optional findByIdAndDeletedFalse(Long id); List findByIdInAndDeletedFalse(List productIds); 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 b4e57655f..48e1622e2 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 @@ -6,6 +6,8 @@ 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 org.springframework.data.domain.Sort; import org.springframework.stereotype.Repository; @@ -51,6 +53,11 @@ public List findByBrandId(Long brandId) { return productJpaRepository.findByBrandIdAndDeletedFalse(brandId); } + @Override + public Page findByBrandIdWithPaging(Long brandId, Pageable pageable) { + return productJpaRepository.findByBrandIdAndDeletedFalseOrderByLikeCountDesc(brandId, pageable); + } + @Override public List findByIdIn(List productIds) { return productJpaRepository.findByIdInAndDeletedFalse(productIds); diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java index b88d904ae..098b23aa5 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java @@ -98,38 +98,5 @@ void createWithZeroLikeCount() { assertThat(product.getLikeCount()).isZero(); } - - @Test - @DisplayName("좋아요 수를 증가시킬 수 있다") - void increaseLikeCount() { - Product product = Product.create(1L, "테스트 상품", Money.of(10000L)); - - product.increaseLikeCount(); - product.increaseLikeCount(); - - assertThat(product.getLikeCount()).isEqualTo(2L); - } - - @Test - @DisplayName("좋아요 수를 감소시킬 수 있다") - void decreaseLikeCount() { - Product product = Product.create(1L, "테스트 상품", Money.of(10000L)); - product.increaseLikeCount(); - product.increaseLikeCount(); - - product.decreaseLikeCount(); - - assertThat(product.getLikeCount()).isEqualTo(1L); - } - - @Test - @DisplayName("좋아요 수가 0이면 더 이상 감소하지 않는다") - void decreaseLikeCountAtZero() { - Product product = Product.create(1L, "테스트 상품", Money.of(10000L)); - - product.decreaseLikeCount(); - - assertThat(product.getLikeCount()).isZero(); - } } } diff --git a/modules/jpa/src/main/resources/jpa.yml b/modules/jpa/src/main/resources/jpa.yml index 37f4fb1b0..2249a5330 100644 --- a/modules/jpa/src/main/resources/jpa.yml +++ b/modules/jpa/src/main/resources/jpa.yml @@ -37,7 +37,7 @@ spring: jpa: show-sql: true hibernate: - ddl-auto: create + ddl-auto: update datasource: mysql-jpa: From a492e9313d37c90c2d6a31bb48dcdf31230f7ebc Mon Sep 17 00:00:00 2001 From: madirony Date: Thu, 12 Mar 2026 23:48:44 +0900 Subject: [PATCH 3/8] =?UTF-8?q?feat(cache):=20Cache-Aside=20=ED=8C=A8?= =?UTF-8?q?=ED=84=B4=20=EC=A0=81=EC=9A=A9=20=E2=80=94=20ProductCacheManage?= =?UTF-8?q?r=20=EC=9D=B8=ED=84=B0=ED=8E=98=EC=9D=B4=EC=8A=A4=20=EB=B0=8F?= =?UTF-8?q?=20Redis=20=EA=B5=AC=ED=98=84=EC=B2=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ProductCacheManager 인터페이스를 application 계층에 정의 (DIP) - RedisProductCacheManager를 infrastructure 계층에 구현 - TTL Jitter 적용 (목록 60+1~10초, 상세 300+1~30초) — Hot Key Stampede 방어 - 예외 흡수 후 Optional.empty() 반환 — DB fallback 보장 - CachedBrandProductPage, CachedProductDetail 캐시 전용 DTO 추가 - RedisConfig에 Fail-Fast 타임아웃 설정 (COMMAND 500ms, CONNECT 300ms) --- .../product/CachedBrandProductPage.java | 38 +++++++ .../product/CachedProductDetail.java | 35 ++++++ .../product/ProductCacheManager.java | 16 +++ .../product/RedisProductCacheManager.java | 106 ++++++++++++++++++ .../com/loopers/config/redis/RedisConfig.java | 18 ++- 5 files changed, 212 insertions(+), 1 deletion(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/product/CachedBrandProductPage.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/product/CachedProductDetail.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/product/ProductCacheManager.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/product/RedisProductCacheManager.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/CachedBrandProductPage.java b/apps/commerce-api/src/main/java/com/loopers/application/product/CachedBrandProductPage.java new file mode 100644 index 000000000..68f3c6614 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/CachedBrandProductPage.java @@ -0,0 +1,38 @@ +package com.loopers.application.product; + +import com.loopers.domain.common.Money; +import com.loopers.domain.product.Product; +import lombok.Builder; +import lombok.Getter; +import lombok.extern.jackson.Jacksonized; + +import java.util.List; + +@Getter +@Builder +@Jacksonized +public class CachedBrandProductPage { + private final List content; + private final long totalElements; + + @Getter + @Builder + @Jacksonized + public static class ProductSummary { + private final Long productId; + private final String productName; + private final Money basePrice; + private final boolean deleted; + private final long likeCount; + + public static ProductSummary from(Product product) { + return ProductSummary.builder() + .productId(product.getId()) + .productName(product.getName()) + .basePrice(product.getBasePrice()) + .deleted(product.isDeleted()) + .likeCount(product.getLikeCount()) + .build(); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/CachedProductDetail.java b/apps/commerce-api/src/main/java/com/loopers/application/product/CachedProductDetail.java new file mode 100644 index 000000000..74a219f3d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/CachedProductDetail.java @@ -0,0 +1,35 @@ +package com.loopers.application.product; + +import com.loopers.domain.common.Money; +import com.loopers.domain.product.Option; +import com.loopers.domain.product.Product; +import lombok.Builder; +import lombok.Getter; +import lombok.extern.jackson.Jacksonized; + +import java.util.List; + +@Getter +@Builder +@Jacksonized +public class CachedProductDetail { + private final Long productId; + private final String productName; + private final Money basePrice; + private final boolean deleted; + private final Long brandId; + private final long likeCount; + private final List options; + + public static CachedProductDetail from(Product product, List