diff --git a/.gitignore b/.gitignore index 75fe145db..30eeee7c2 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,4 @@ out/ ### Claude Code ### *.md !docs/**/*.md +!blog/**/*.md diff --git a/apps/commerce-api/build.gradle.kts b/apps/commerce-api/build.gradle.kts index 6d6b8bf46..5a5c8bc34 100644 --- a/apps/commerce-api/build.gradle.kts +++ b/apps/commerce-api/build.gradle.kts @@ -6,6 +6,9 @@ dependencies { implementation(project(":supports:logging")) implementation(project(":supports:monitoring")) + // cache + implementation("com.github.ben-manes.caffeine:caffeine") + // web implementation("org.springframework.boot:spring-boot-starter-web") implementation("org.springframework.boot:spring-boot-starter-validation") 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 1f7a334ae..e34ec3391 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 @@ -30,6 +30,7 @@ public void addLike(Long memberId, Long productId) { } likeRepository.save(new Like(memberId, productId)); + productRepository.incrementLikeCount(productId); } @Transactional @@ -40,6 +41,7 @@ public void removeLike(Long memberId, Long productId) { } likeRepository.delete(likeOpt.get()); + productRepository.decrementLikeCount(productId); } public List getLikesByMemberId(Long memberId) { diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductCachePort.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductCachePort.java new file mode 100644 index 000000000..bd9c71b6d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductCachePort.java @@ -0,0 +1,22 @@ +package com.loopers.application.product; + +import com.loopers.interfaces.api.product.ProductDto; + +public interface ProductCachePort { + + // ── 상품 상세 캐시 ── + + ProductDto.ProductResponse getProductDetail(Long productId); + + void putProductDetail(Long productId, ProductDto.ProductResponse response); + + void evictProductDetail(Long productId); + + // ── 상품 목록 캐시 ── + + ProductDto.PagedProductResponse getProductList(Long brandId, String sort, int page, int size); + + void putProductList(Long brandId, String sort, int page, int size, ProductDto.PagedProductResponse response); + + void evictProductList(); +} 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 691553b54..796d5437b 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 @@ -8,9 +8,13 @@ import com.loopers.domain.product.vo.Stock; import com.loopers.domain.brand.Brand; import com.loopers.domain.brand.BrandRepository; +import com.loopers.interfaces.api.product.ProductDto; 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.PageRequest; +import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -26,21 +30,80 @@ public class ProductFacade { private final ProductRepository productRepository; private final BrandRepository brandRepository; private final LikeRepository likeRepository; + private final ProductCachePort productCachePort; + + // ── 상품 상세 (캐시 적용) ── public ProductWithBrand getProductDetail(Long productId) { Product product = productRepository.findById(productId) .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다.")); Brand brand = brandRepository.findById(product.getBrandId()).orElse(null); String brandName = (brand != null) ? brand.getName() : null; - long likeCount = likeRepository.countByProductId(productId); - return new ProductWithBrand(product, brandName, likeCount); + return new ProductWithBrand(product, brandName, product.getLikeCount()); + } + + public ProductDto.ProductResponse getProductDetailCached(Long productId) { + ProductDto.ProductResponse cached = productCachePort.getProductDetail(productId); + if (cached != null) { + return cached; + } + + ProductWithBrand info = getProductDetail(productId); + ProductDto.ProductResponse response = ProductDto.ProductResponse.from(info); + productCachePort.putProductDetail(productId, response); + return response; + } + + // ── 상품 목록 (페이지네이션 + 캐시 적용) ── + + public Page getAllProducts(String sort, Pageable pageable) { + return productRepository.findAllWithBrand(sort, pageable); + } + + public Page getProductsByBrandId(Long brandId, String sort, Pageable pageable) { + brandRepository.findById(brandId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "브랜드를 찾을 수 없습니다.")); + return productRepository.findAllByBrandIdWithBrand(brandId, sort, pageable); + } + + public ProductDto.PagedProductResponse getAllProductsCached(Long brandId, String sort, int page, int size) { + ProductDto.PagedProductResponse cached = productCachePort.getProductList(brandId, sort, page, size); + if (cached != null) { + return cached; + } + + Pageable pageable = PageRequest.of(page, size); + Page result; + if (brandId != null) { + result = getProductsByBrandId(brandId, sort, pageable); + } else { + result = getAllProducts(sort, pageable); + } + + ProductDto.PagedProductResponse response = ProductDto.PagedProductResponse.from(result); + productCachePort.putProductList(brandId, sort, page, size, response); + return response; } + // ── 기존 List 반환 메서드 (하위 호환 + 벤치마크용) ── + public List getAllProducts() { - return enrichWithLikeCount(productRepository.findAllWithBrand()); + return productRepository.findAllWithBrand(); } public List getAllProducts(String sort) { + return productRepository.findAllWithBrand(sort); + } + + public List getProductsByBrandId(Long brandId) { + brandRepository.findById(brandId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "브랜드를 찾을 수 없습니다.")); + return productRepository.findAllByBrandIdWithBrand(brandId); + } + + // ── 벤치마크 전용: AS-IS 재현 (enrichWithLikeCount + in-memory sort) ── + + public List getAllProductsNoOptimization(String sort) { List results = enrichWithLikeCount( productRepository.findAllWithBrand(sort)); @@ -52,18 +115,16 @@ public List getAllProducts(String sort) { return results; } - public List getProductsByBrandId(Long brandId) { - brandRepository.findById(brandId) - .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "브랜드를 찾을 수 없습니다.")); - return enrichWithLikeCount(productRepository.findAllByBrandIdWithBrand(brandId)); - } + // ── 상품 CUD (캐시 무효화 포함) ── @Transactional public Product createProduct(Long brandId, String name, int price, int stockQuantity) { brandRepository.findById(brandId) .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "브랜드를 찾을 수 없습니다.")); Product product = new Product(brandId, name, new Price(price), new Stock(stockQuantity)); - return productRepository.save(product); + Product saved = productRepository.save(product); + productCachePort.evictProductList(); + return saved; } @Transactional @@ -73,6 +134,8 @@ public Product updateProduct(Long productId, String name, int price, int stockQu product.changeName(name); product.changePrice(new Price(price)); product.changeStock(new Stock(stockQuantity)); + productCachePort.evictProductDetail(productId); + productCachePort.evictProductList(); return product; } @@ -82,8 +145,12 @@ public void deleteProduct(Long productId) { .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다.")); likeRepository.deleteAllByProductId(productId); product.delete(); + productCachePort.evictProductDetail(productId); + productCachePort.evictProductList(); } + // ── private: 벤치마크 전용 AS-IS 로직 보존 ── + private List enrichWithLikeCount(List products) { List productIds = products.stream() .map(pwb -> pwb.product().getId()) diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java index 23fd03c21..007bd862b 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java @@ -10,6 +10,8 @@ @Entity @Table(name = "likes", uniqueConstraints = { @UniqueConstraint(name = "uk_likes_member_product", columnNames = {"member_id", "product_id"}) +}, indexes = { + @Index(name = "idx_likes_product_id", columnList = "product_id") }) @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) 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 2776adb7d..901d51e13 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 @@ -10,7 +10,10 @@ @Entity @Table(name = "product", indexes = { - @Index(name = "idx_product_brand_id", columnList = "brand_id") + @Index(name = "idx_product_brand_id", columnList = "brand_id"), + @Index(name = "idx_product_like_count", columnList = "like_count DESC, id DESC"), + @Index(name = "idx_product_brand_like_count", columnList = "brand_id, like_count DESC, id DESC"), + @Index(name = "idx_product_brand_price", columnList = "brand_id, price ASC, id ASC") }) @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) @@ -28,6 +31,9 @@ public class Product extends BaseEntity { @Embedded private Stock stock; + @Column(name = "like_count", nullable = false) + private int likeCount = 0; + public Product(Long brandId, String name, Price price, Stock stock) { this.brandId = brandId; this.name = name; diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductLikeStats.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductLikeStats.java new file mode 100644 index 000000000..1f6922f07 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductLikeStats.java @@ -0,0 +1,39 @@ +package com.loopers.domain.product; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.ZonedDateTime; + +@Entity +@Table(name = "product_like_stats") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ProductLikeStats { + + @Id + @Column(name = "product_id") + private Long productId; + + @Column(name = "like_count", nullable = false) + private int likeCount; + + @Column(name = "synced_at", nullable = false) + private ZonedDateTime syncedAt; + + public ProductLikeStats(Long productId, int likeCount) { + this.productId = productId; + this.likeCount = likeCount; + this.syncedAt = ZonedDateTime.now(); + } + + public void updateCount(int likeCount) { + this.likeCount = likeCount; + this.syncedAt = ZonedDateTime.now(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductLikeStatsRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductLikeStatsRepository.java new file mode 100644 index 000000000..1e47288d7 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductLikeStatsRepository.java @@ -0,0 +1,11 @@ +package com.loopers.domain.product; + +import java.util.List; + +public interface ProductLikeStatsRepository { + ProductLikeStats save(ProductLikeStats stats); + List saveAll(List statsList); + List findAll(); + void syncAllFromLikes(); + int correctProductLikeCounts(); +} 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 4df9322fe..61255103c 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; @@ -14,4 +17,12 @@ public interface ProductRepository { List findAllWithBrand(); List findAllWithBrand(String sort); List findAllByBrandIdWithBrand(Long brandId); + + // 페이지네이션 조회 (Brand JOIN) + Page findAllWithBrand(String sort, Pageable pageable); + Page findAllByBrandIdWithBrand(Long brandId, String sort, Pageable pageable); + + // likeCount atomic 증감 (엔티티 로딩 없이 SQL 직접 실행) + int incrementLikeCount(Long productId); + int decrementLikeCount(Long productId); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/CaffeineProductCacheAdapter.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/CaffeineProductCacheAdapter.java new file mode 100644 index 000000000..c5ba3687d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/CaffeineProductCacheAdapter.java @@ -0,0 +1,66 @@ +package com.loopers.infrastructure.product; + +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import com.loopers.application.product.ProductCachePort; +import com.loopers.interfaces.api.product.ProductDto; +import org.springframework.stereotype.Component; + +import java.time.Duration; + +@Component +public class CaffeineProductCacheAdapter implements ProductCachePort { + + private final Cache detailCache; + private final Cache listCache; + + public CaffeineProductCacheAdapter() { + this.detailCache = Caffeine.newBuilder() + .maximumSize(500) + .expireAfterWrite(Duration.ofSeconds(30)) + .build(); + this.listCache = Caffeine.newBuilder() + .maximumSize(200) + .expireAfterWrite(Duration.ofSeconds(15)) + .build(); + } + + @Override + public ProductDto.ProductResponse getProductDetail(Long productId) { + return detailCache.getIfPresent(detailKey(productId)); + } + + @Override + public void putProductDetail(Long productId, ProductDto.ProductResponse response) { + detailCache.put(detailKey(productId), response); + } + + @Override + public void evictProductDetail(Long productId) { + detailCache.invalidate(detailKey(productId)); + } + + @Override + public ProductDto.PagedProductResponse getProductList(Long brandId, String sort, int page, int size) { + return listCache.getIfPresent(listKey(brandId, sort, page, size)); + } + + @Override + public void putProductList(Long brandId, String sort, int page, int size, ProductDto.PagedProductResponse response) { + listCache.put(listKey(brandId, sort, page, size), response); + } + + @Override + public void evictProductList() { + listCache.invalidateAll(); + } + + private String detailKey(Long productId) { + return "detail:" + productId; + } + + private String listKey(Long brandId, String sort, int page, int size) { + String brandPart = brandId != null ? String.valueOf(brandId) : "all"; + return "list:brand:" + brandPart + ":sort:" + sort + ":page:" + page + ":size:" + size; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/MultiLayerProductCacheAdapter.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/MultiLayerProductCacheAdapter.java new file mode 100644 index 000000000..205563919 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/MultiLayerProductCacheAdapter.java @@ -0,0 +1,79 @@ +package com.loopers.infrastructure.product; + +import com.loopers.application.product.ProductCachePort; +import com.loopers.interfaces.api.product.ProductDto; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.context.annotation.Primary; +import org.springframework.stereotype.Component; + +@Primary +@Component +public class MultiLayerProductCacheAdapter implements ProductCachePort { + + private final ProductCachePort l1Cache; + private final ProductCachePort l2Cache; + + public MultiLayerProductCacheAdapter( + @Qualifier("caffeineProductCacheAdapter") ProductCachePort l1Cache, + @Qualifier("redisProductCacheAdapter") ProductCachePort l2Cache + ) { + this.l1Cache = l1Cache; + this.l2Cache = l2Cache; + } + + // ── 상품 상세 캐시 ── + + @Override + public ProductDto.ProductResponse getProductDetail(Long productId) { + ProductDto.ProductResponse cached = l1Cache.getProductDetail(productId); + if (cached != null) { + return cached; + } + + cached = l2Cache.getProductDetail(productId); + if (cached != null) { + l1Cache.putProductDetail(productId, cached); + } + return cached; + } + + @Override + public void putProductDetail(Long productId, ProductDto.ProductResponse response) { + l2Cache.putProductDetail(productId, response); + l1Cache.putProductDetail(productId, response); + } + + @Override + public void evictProductDetail(Long productId) { + l1Cache.evictProductDetail(productId); + l2Cache.evictProductDetail(productId); + } + + // ── 상품 목록 캐시 ── + + @Override + public ProductDto.PagedProductResponse getProductList(Long brandId, String sort, int page, int size) { + ProductDto.PagedProductResponse cached = l1Cache.getProductList(brandId, sort, page, size); + if (cached != null) { + return cached; + } + + cached = l2Cache.getProductList(brandId, sort, page, size); + if (cached != null) { + l1Cache.putProductList(brandId, sort, page, size, cached); + } + return cached; + } + + @Override + public void putProductList(Long brandId, String sort, int page, int size, ProductDto.PagedProductResponse response) { + l2Cache.putProductList(brandId, sort, page, size, response); + l1Cache.putProductList(brandId, sort, page, size, response); + } + + @Override + public void evictProductList() { + l1Cache.evictProductList(); + l2Cache.evictProductList(); + } +} 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 c5a17501e..2d9ae85ad 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 @@ -2,9 +2,12 @@ import com.loopers.domain.product.Product; import jakarta.persistence.LockModeType; +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; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -32,4 +35,24 @@ public interface ProductJpaRepository extends JpaRepository { @Query("SELECT p, b.name FROM Product p LEFT JOIN Brand b ON b.id = p.brandId" + " WHERE p.brandId = :brandId AND p.deletedAt IS NULL AND (b.deletedAt IS NULL OR b.id IS NULL)") List findAllByBrandIdWithBrand(@Param("brandId") Long brandId); + + // 페이지네이션 조회 (Sort는 Pageable에 내장하여 전달) + @Query(value = "SELECT p, b.name FROM Product p LEFT JOIN Brand b ON b.id = p.brandId" + + " WHERE p.deletedAt IS NULL AND (b.deletedAt IS NULL OR b.id IS NULL)", + countQuery = "SELECT COUNT(p) FROM Product p WHERE p.deletedAt IS NULL") + Page findAllWithBrandPaged(Pageable pageable); + + @Query(value = "SELECT p, b.name FROM Product p LEFT JOIN Brand b ON b.id = p.brandId" + + " WHERE p.brandId = :brandId AND p.deletedAt IS NULL AND (b.deletedAt IS NULL OR b.id IS NULL)", + countQuery = "SELECT COUNT(p) FROM Product p WHERE p.brandId = :brandId AND p.deletedAt IS NULL") + Page findAllByBrandIdWithBrandPaged(@Param("brandId") Long brandId, Pageable pageable); + + // likeCount atomic 증감 — 엔티티 로딩 없이 단일 UPDATE 문으로 실행 + @Modifying + @Query("UPDATE Product p SET p.likeCount = p.likeCount + 1 WHERE p.id = :productId AND p.deletedAt IS NULL") + int incrementLikeCount(@Param("productId") Long productId); + + @Modifying + @Query("UPDATE Product p SET p.likeCount = CASE WHEN p.likeCount > 0 THEN p.likeCount - 1 ELSE 0 END WHERE p.id = :productId AND p.deletedAt IS NULL") + int decrementLikeCount(@Param("productId") Long productId); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductLikeStatsJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductLikeStatsJpaRepository.java new file mode 100644 index 000000000..c9fa84136 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductLikeStatsJpaRepository.java @@ -0,0 +1,19 @@ +package com.loopers.infrastructure.product; + +import com.loopers.domain.product.ProductLikeStats; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; + +public interface ProductLikeStatsJpaRepository extends JpaRepository { + + @Modifying + @Query(value = "REPLACE INTO product_like_stats (product_id, like_count, synced_at) " + + "SELECT l.product_id, COUNT(*), NOW() FROM likes l GROUP BY l.product_id", nativeQuery = true) + void syncAllFromLikes(); + + @Modifying + @Query(value = "UPDATE product p JOIN product_like_stats pls ON p.id = pls.product_id " + + "SET p.like_count = pls.like_count WHERE p.like_count != pls.like_count AND p.deleted_at IS NULL", nativeQuery = true) + int correctProductLikeCounts(); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductLikeStatsRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductLikeStatsRepositoryImpl.java new file mode 100644 index 000000000..557bf9ef4 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductLikeStatsRepositoryImpl.java @@ -0,0 +1,40 @@ +package com.loopers.infrastructure.product; + +import com.loopers.domain.product.ProductLikeStats; +import com.loopers.domain.product.ProductLikeStatsRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +@RequiredArgsConstructor +public class ProductLikeStatsRepositoryImpl implements ProductLikeStatsRepository { + + private final ProductLikeStatsJpaRepository productLikeStatsJpaRepository; + + @Override + public ProductLikeStats save(ProductLikeStats stats) { + return productLikeStatsJpaRepository.save(stats); + } + + @Override + public List saveAll(List statsList) { + return productLikeStatsJpaRepository.saveAll(statsList); + } + + @Override + public List findAll() { + return productLikeStatsJpaRepository.findAll(); + } + + @Override + public void syncAllFromLikes() { + productLikeStatsJpaRepository.syncAllFromLikes(); + } + + @Override + public int correctProductLikeCounts() { + return productLikeStatsJpaRepository.correctProductLikeCounts(); + } +} 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 327a2a2af..af01efef9 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 @@ -4,6 +4,9 @@ import com.loopers.domain.product.ProductRepository; import com.loopers.domain.product.ProductWithBrand; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; import org.springframework.stereotype.Repository; @@ -62,8 +65,34 @@ public List findAllByBrandIdWithBrand(Long brandId) { .toList(); } + @Override + public Page findAllWithBrand(String sort, Pageable pageable) { + Pageable sortedPageable = PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(), toSort(sort)); + return productJpaRepository.findAllWithBrandPaged(sortedPageable) + .map(this::toProductWithBrand); + } + + @Override + public Page findAllByBrandIdWithBrand(Long brandId, String sort, Pageable pageable) { + Pageable sortedPageable = PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(), toSort(sort)); + return productJpaRepository.findAllByBrandIdWithBrandPaged(brandId, sortedPageable) + .map(this::toProductWithBrand); + } + + @Override + public int incrementLikeCount(Long productId) { + return productJpaRepository.incrementLikeCount(productId); + } + + @Override + public int decrementLikeCount(Long productId) { + return productJpaRepository.decrementLikeCount(productId); + } + private ProductWithBrand toProductWithBrand(Object[] row) { - return new ProductWithBrand((Product) row[0], (String) row[1], 0L); + Product product = (Product) row[0]; + String brandName = (String) row[1]; + return new ProductWithBrand(product, brandName, product.getLikeCount()); } private Sort toSort(String sort) { @@ -72,6 +101,10 @@ private Sort toSort(String sort) { } return switch (sort) { case "price_asc" -> Sort.by("price.value").ascending(); + case "likes_desc" -> Sort.by( + Sort.Order.desc("likeCount"), + Sort.Order.desc("id") + ); default -> Sort.by("createdAt").descending(); }; } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/RedisProductCacheAdapter.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/RedisProductCacheAdapter.java new file mode 100644 index 000000000..ac712286b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/RedisProductCacheAdapter.java @@ -0,0 +1,132 @@ +package com.loopers.infrastructure.product; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.application.product.ProductCachePort; +import com.loopers.interfaces.api.product.ProductDto; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Component; + +import java.util.concurrent.TimeUnit; + +@Slf4j +@Component +public class RedisProductCacheAdapter implements ProductCachePort { + + private static final String PRODUCT_DETAIL_KEY_PREFIX = "product:detail:"; + private static final String PRODUCT_LIST_KEY_PREFIX = "product:list:"; + private static final String PRODUCT_LIST_VERSION_KEY = "product:list:version"; + private static final long DETAIL_TTL_MINUTES = 10; + private static final long LIST_TTL_MINUTES = 5; + + private final RedisTemplate readTemplate; + private final RedisTemplate writeTemplate; + private final ObjectMapper objectMapper; + + public RedisProductCacheAdapter( + RedisTemplate readTemplate, + @Qualifier("redisTemplateMaster") RedisTemplate writeTemplate, + ObjectMapper objectMapper + ) { + this.readTemplate = readTemplate; + this.writeTemplate = writeTemplate; + this.objectMapper = objectMapper; + } + + // ── 상품 상세 캐시 ── + + @Override + public ProductDto.ProductResponse getProductDetail(Long productId) { + try { + String key = PRODUCT_DETAIL_KEY_PREFIX + productId; + String cached = readTemplate.opsForValue().get(key); + if (cached == null) { + return null; + } + return objectMapper.readValue(cached, ProductDto.ProductResponse.class); + } catch (Exception e) { + log.warn("Redis 상품 상세 캐시 조회 실패 (productId={}): {}", productId, e.getMessage()); + return null; + } + } + + @Override + public void putProductDetail(Long productId, ProductDto.ProductResponse response) { + try { + String key = PRODUCT_DETAIL_KEY_PREFIX + productId; + String json = objectMapper.writeValueAsString(response); + writeTemplate.opsForValue().set(key, json, DETAIL_TTL_MINUTES, TimeUnit.MINUTES); + } catch (Exception e) { + log.warn("Redis 상품 상세 캐시 저장 실패 (productId={}): {}", productId, e.getMessage()); + } + } + + @Override + public void evictProductDetail(Long productId) { + try { + String key = PRODUCT_DETAIL_KEY_PREFIX + productId; + writeTemplate.delete(key); + } catch (Exception e) { + log.warn("Redis 상품 상세 캐시 삭제 실패 (productId={}): {}", productId, e.getMessage()); + } + } + + // ── 상품 목록 캐시 (버전 기반 무효화) ── + + @Override + public ProductDto.PagedProductResponse getProductList(Long brandId, String sort, int page, int size) { + try { + String key = buildListKey(brandId, sort, page, size); + if (key == null) return null; + String cached = readTemplate.opsForValue().get(key); + if (cached == null) { + return null; + } + return objectMapper.readValue(cached, ProductDto.PagedProductResponse.class); + } catch (Exception e) { + log.warn("Redis 상품 목록 캐시 조회 실패: {}", e.getMessage()); + return null; + } + } + + @Override + public void putProductList(Long brandId, String sort, int page, int size, ProductDto.PagedProductResponse response) { + try { + String key = buildListKey(brandId, sort, page, size); + if (key == null) return; + String json = objectMapper.writeValueAsString(response); + writeTemplate.opsForValue().set(key, json, LIST_TTL_MINUTES, TimeUnit.MINUTES); + } catch (Exception e) { + log.warn("Redis 상품 목록 캐시 저장 실패: {}", e.getMessage()); + } + } + + @Override + public void evictProductList() { + try { + writeTemplate.opsForValue().increment(PRODUCT_LIST_VERSION_KEY); + } catch (Exception e) { + log.warn("Redis 상품 목록 캐시 버전 증가 실패: {}", e.getMessage()); + } + } + + private String buildListKey(Long brandId, String sort, int page, int size) { + try { + String version = readTemplate.opsForValue().get(PRODUCT_LIST_VERSION_KEY); + if (version == null) { + version = "0"; + writeTemplate.opsForValue().setIfAbsent(PRODUCT_LIST_VERSION_KEY, "0"); + } + String brandPart = brandId != null ? String.valueOf(brandId) : "all"; + return PRODUCT_LIST_KEY_PREFIX + "v" + version + + ":brand:" + brandPart + + ":sort:" + sort + + ":page:" + page + + ":size:" + size; + } catch (Exception e) { + log.warn("Redis 캐시 키 생성 실패: {}", e.getMessage()); + return null; + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeController.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeController.java index a261247f0..075eefda2 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeController.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeController.java @@ -1,6 +1,7 @@ package com.loopers.interfaces.api.like; import com.loopers.application.like.LikeFacade; +import com.loopers.application.product.ProductCachePort; import com.loopers.domain.like.Like; import com.loopers.domain.member.Member; import com.loopers.interfaces.api.ApiResponse; @@ -17,16 +18,21 @@ public class LikeController { private final LikeFacade likeFacade; + private final ProductCachePort productCachePort; @PostMapping("/api/v1/products/{productId}/likes") public ApiResponse addLike(@AuthMember Member member, @PathVariable Long productId) { likeFacade.addLike(member.getId(), productId); + productCachePort.evictProductDetail(productId); + productCachePort.evictProductList(); return ApiResponse.success(null); } @DeleteMapping("/api/v1/products/{productId}/likes") public ApiResponse removeLike(@AuthMember Member member, @PathVariable Long productId) { likeFacade.removeLike(member.getId(), productId); + productCachePort.evictProductDetail(productId); + productCachePort.evictProductList(); return ApiResponse.success(null); } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductBenchmarkController.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductBenchmarkController.java new file mode 100644 index 000000000..abc466de9 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductBenchmarkController.java @@ -0,0 +1,46 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.application.product.ProductFacade; +import com.loopers.domain.product.ProductWithBrand; +import com.loopers.interfaces.api.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/products") +public class ProductBenchmarkController { + + private final ProductFacade productFacade; + + @GetMapping("/no-cache") + public ApiResponse getProductsNoCache( + @RequestParam(required = false) Long brandId, + @RequestParam(defaultValue = "latest") String sort, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size + ) { + Page result; + if (brandId != null) { + result = productFacade.getProductsByBrandId(brandId, sort, PageRequest.of(page, size)); + } else { + result = productFacade.getAllProducts(sort, PageRequest.of(page, size)); + } + return ApiResponse.success(ProductDto.PagedProductResponse.from(result)); + } + + @GetMapping("/no-optimization") + public ApiResponse> getProductsNoOptimization( + @RequestParam(defaultValue = "latest") String sort + ) { + List products = productFacade.getAllProductsNoOptimization(sort); + List responses = products.stream() + .map(ProductDto.ProductResponse::from) + .toList(); + return ApiResponse.success(responses); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductController.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductController.java index 05383244c..2393d9859 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductController.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductController.java @@ -1,13 +1,10 @@ package com.loopers.interfaces.api.product; import com.loopers.application.product.ProductFacade; -import com.loopers.domain.product.ProductWithBrand; import com.loopers.interfaces.api.ApiResponse; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; -import java.util.List; - @RestController @RequiredArgsConstructor @RequestMapping("/api/v1/products") @@ -16,25 +13,19 @@ public class ProductController { private final ProductFacade productFacade; @GetMapping - public ApiResponse> getProducts( + public ApiResponse getProducts( @RequestParam(required = false) Long brandId, - @RequestParam(defaultValue = "latest") String sort + @RequestParam(defaultValue = "latest") String sort, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size ) { - List products; - if (brandId != null) { - products = productFacade.getProductsByBrandId(brandId); - } else { - products = productFacade.getAllProducts(sort); - } - List responses = products.stream() - .map(ProductDto.ProductResponse::from) - .toList(); - return ApiResponse.success(responses); + ProductDto.PagedProductResponse response = productFacade.getAllProductsCached(brandId, sort, page, size); + return ApiResponse.success(response); } @GetMapping("/{productId}") public ApiResponse getProduct(@PathVariable Long productId) { - ProductWithBrand info = productFacade.getProductDetail(productId); - return ApiResponse.success(ProductDto.ProductResponse.from(info)); + ProductDto.ProductResponse response = productFacade.getProductDetailCached(productId); + return ApiResponse.success(response); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductDto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductDto.java index ef5671e8a..80e5f7465 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductDto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductDto.java @@ -5,6 +5,9 @@ import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; +import org.springframework.data.domain.Page; + +import java.util.List; public class ProductDto { @@ -55,4 +58,25 @@ public static ProductResponse from(Product product) { ); } } + + public record PagedProductResponse( + List data, + long totalElements, + int totalPages, + int page, + int size + ) { + public static PagedProductResponse from(Page pageResult) { + List data = pageResult.getContent().stream() + .map(ProductResponse::from) + .toList(); + return new PagedProductResponse( + data, + pageResult.getTotalElements(), + pageResult.getTotalPages(), + pageResult.getNumber(), + pageResult.getSize() + ); + } + } } diff --git a/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeTest.java index 2d5ceb55d..7cb743953 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeTest.java @@ -35,9 +35,9 @@ void setUp() { @DisplayName("좋아요 추가") class AddLike { - @DisplayName("좋아요를 추가하면 Like 레코드가 저장된다") + @DisplayName("좋아요를 추가하면 Like 레코드가 저장되고 Product.likeCount가 1 증가한다") @Test - void addLike_savesLikeRecord() { + void addLike_savesLikeRecord_andIncrementsLikeCount() { Product product = productRepository.save( new Product(1L, "에어맥스", new Price(150000), new Stock(10))); Long memberId = 1L; @@ -46,9 +46,10 @@ void addLike_savesLikeRecord() { assertThat(likeRepository.existsByMemberIdAndProductId(memberId, product.getId())).isTrue(); assertThat(likeRepository.countByProductId(product.getId())).isEqualTo(1); + assertThat(product.getLikeCount()).isEqualTo(1); } - @DisplayName("이미 좋아요한 상품에 다시 좋아요하면 멱등하게 처리된다") + @DisplayName("이미 좋아요한 상품에 다시 좋아요하면 멱등하게 처리된다 (likeCount 불변)") @Test void addLike_whenAlreadyLiked_isIdempotent() { Product product = productRepository.save( @@ -60,6 +61,7 @@ void addLike_whenAlreadyLiked_isIdempotent() { assertThat(likeRepository.countByProductId(product.getId())).isEqualTo(1); assertThat(likeRepository.findAllByMemberId(memberId)).hasSize(1); + assertThat(product.getLikeCount()).isEqualTo(1); } @DisplayName("존재하지 않는 상품에 좋아요하면 예외가 발생한다") @@ -82,6 +84,7 @@ void addLike_byMultipleMembers_accumulatesCount() { likeFacade.addLike(3L, product.getId()); assertThat(likeRepository.countByProductId(product.getId())).isEqualTo(3); + assertThat(product.getLikeCount()).isEqualTo(3); } } @@ -89,9 +92,9 @@ void addLike_byMultipleMembers_accumulatesCount() { @DisplayName("좋아요 취소") class RemoveLike { - @DisplayName("좋아요를 취소하면 Like 레코드가 삭제된다") + @DisplayName("좋아요를 취소하면 Like 레코드가 삭제되고 Product.likeCount가 1 감소한다") @Test - void removeLike_deletesLikeRecord() { + void removeLike_deletesLikeRecord_andDecrementsLikeCount() { Product product = productRepository.save( new Product(1L, "에어맥스", new Price(150000), new Stock(10))); Long memberId = 1L; @@ -101,6 +104,7 @@ void removeLike_deletesLikeRecord() { assertThat(likeRepository.existsByMemberIdAndProductId(memberId, product.getId())).isFalse(); assertThat(likeRepository.countByProductId(product.getId())).isEqualTo(0); + assertThat(product.getLikeCount()).isEqualTo(0); } @DisplayName("좋아요하지 않은 상품의 좋아요를 취소해도 예외 없이 멱등하게 처리된다") @@ -112,6 +116,7 @@ void removeLike_whenNotLiked_isIdempotent() { likeFacade.removeLike(1L, product.getId()); assertThat(likeRepository.countByProductId(product.getId())).isEqualTo(0); + assertThat(product.getLikeCount()).isEqualTo(0); } } diff --git a/apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeTest.java index f4f3e8e32..591ea7d2d 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeTest.java @@ -8,13 +8,17 @@ import com.loopers.domain.product.vo.Stock; import com.loopers.fake.FakeBrandRepository; import com.loopers.fake.FakeLikeRepository; +import com.loopers.fake.FakeProductCachePort; import com.loopers.fake.FakeProductRepository; +import com.loopers.interfaces.api.product.ProductDto; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; import java.util.List; @@ -34,14 +38,14 @@ void setUp() { brandRepository = new FakeBrandRepository(); likeRepository = new FakeLikeRepository(); productRepository.setBrandRepository(brandRepository); - productFacade = new ProductFacade(productRepository, brandRepository, likeRepository); + productFacade = new ProductFacade(productRepository, brandRepository, likeRepository, new FakeProductCachePort()); } @Nested @DisplayName("상품 상세 조회") class GetProductDetail { - @DisplayName("상품을 조회하면 브랜드 정보가 함께 반환된다") + @DisplayName("상품을 조회하면 브랜드 정보와 likeCount가 함께 반환된다") @Test void getProductDetail_returnsProductWithBrand() { // arrange @@ -56,6 +60,7 @@ void getProductDetail_returnsProductWithBrand() { assertThat(result.product().getId()).isEqualTo(product.getId()); assertThat(result.product().getName()).isEqualTo("에어맥스"); assertThat(result.brandName()).isEqualTo("나이키"); + assertThat(result.likeCount()).isEqualTo(0); } @DisplayName("존재하지 않는 상품을 조회하면 예외가 발생한다") @@ -80,10 +85,28 @@ void getProductDetail_whenBrandDeleted_returnsNullBrandName() { // assert assertThat(result.brandName()).isNull(); } + + @DisplayName("likeCount가 반영된 상품 상세가 반환된다") + @Test + void getProductDetail_returnsLikeCount() { + // arrange + Brand brand = brandRepository.save(new Brand("나이키", "스포츠 브랜드")); + Product product = productRepository.save( + new Product(brand.getId(), "에어맥스", new Price(150000), new Stock(10))); + productRepository.incrementLikeCount(product.getId()); + productRepository.incrementLikeCount(product.getId()); + productRepository.incrementLikeCount(product.getId()); + + // act + ProductWithBrand result = productFacade.getProductDetail(product.getId()); + + // assert + assertThat(result.likeCount()).isEqualTo(3); + } } @Nested - @DisplayName("상품 전체 조회") + @DisplayName("상품 전체 조회 (페이지네이션)") class GetAllProducts { @DisplayName("모든 상품이 브랜드 정보와 함께 반환된다") @@ -95,37 +118,111 @@ void getAllProducts_returnsAllWithBrandInfo() { productRepository.save(new Product(brand.getId(), "에어포스", new Price(120000), new Stock(20))); // act - List result = productFacade.getAllProducts(); + Page result = productFacade.getAllProducts("latest", PageRequest.of(0, 20)); // assert - assertThat(result).hasSize(2); - assertThat(result).allSatisfy(info -> + assertThat(result.getContent()).hasSize(2); + assertThat(result.getContent()).allSatisfy(info -> assertThat(info.brandName()).isEqualTo("나이키") ); } - @DisplayName("상품이 없으면 빈 리스트가 반환된다") + @DisplayName("상품이 없으면 빈 페이지가 반환된다") @Test - void getAllProducts_whenEmpty_returnsEmptyList() { + void getAllProducts_whenEmpty_returnsEmptyPage() { // act - List result = productFacade.getAllProducts(); + Page result = productFacade.getAllProducts("latest", PageRequest.of(0, 20)); // assert - assertThat(result).isEmpty(); + assertThat(result.getContent()).isEmpty(); + assertThat(result.getTotalElements()).isZero(); } - @DisplayName("브랜드가 삭제된 상품은 브랜드 이름이 null로 반환된다") + @DisplayName("좋아요순 정렬이 DB에서 처리된다") + @Test + void getAllProducts_likesDesc_sortedByLikeCount() { + // arrange + Brand brand = brandRepository.save(new Brand("나이키", "스포츠 브랜드")); + Product p1 = productRepository.save(new Product(brand.getId(), "에어맥스", new Price(150000), new Stock(10))); + Product p2 = productRepository.save(new Product(brand.getId(), "에어포스", new Price(120000), new Stock(20))); + Product p3 = productRepository.save(new Product(brand.getId(), "덩크", new Price(130000), new Stock(15))); + + // p2에 좋아요 3개, p3에 1개, p1에 0개 + productRepository.incrementLikeCount(p2.getId()); + productRepository.incrementLikeCount(p2.getId()); + productRepository.incrementLikeCount(p2.getId()); + productRepository.incrementLikeCount(p3.getId()); + + // act + Page result = productFacade.getAllProducts("likes_desc", PageRequest.of(0, 20)); + + // assert + List content = result.getContent(); + assertThat(content).hasSize(3); + assertThat(content.get(0).product().getName()).isEqualTo("에어포스"); + assertThat(content.get(0).likeCount()).isEqualTo(3); + assertThat(content.get(1).product().getName()).isEqualTo("덩크"); + assertThat(content.get(1).likeCount()).isEqualTo(1); + assertThat(content.get(2).product().getName()).isEqualTo("에어맥스"); + assertThat(content.get(2).likeCount()).isEqualTo(0); + } + + @DisplayName("페이지네이션이 올바르게 동작한다") @Test - void getAllProducts_whenBrandDeleted_returnsNullBrandName() { + void getAllProducts_pagination_worksCorrectly() { // arrange - productRepository.save(new Product(999L, "에어맥스", new Price(150000), new Stock(10))); + Brand brand = brandRepository.save(new Brand("나이키", "스포츠 브랜드")); + for (int i = 0; i < 25; i++) { + productRepository.save(new Product(brand.getId(), "상품" + i, new Price(10000 + i), new Stock(10))); + } // act - List result = productFacade.getAllProducts(); + Page page0 = productFacade.getAllProducts("latest", PageRequest.of(0, 10)); + Page page1 = productFacade.getAllProducts("latest", PageRequest.of(1, 10)); + Page page2 = productFacade.getAllProducts("latest", PageRequest.of(2, 10)); // assert - assertThat(result).hasSize(1); - assertThat(result.get(0).brandName()).isNull(); + assertThat(page0.getContent()).hasSize(10); + assertThat(page1.getContent()).hasSize(10); + assertThat(page2.getContent()).hasSize(5); + assertThat(page0.getTotalElements()).isEqualTo(25); + assertThat(page0.getTotalPages()).isEqualTo(3); + } + } + + @Nested + @DisplayName("캐시 통합 조회") + class CachedQueries { + + @DisplayName("캐시 미스 시 DB에서 조회하여 반환한다") + @Test + void getAllProductsCached_onCacheMiss_returnsFromDb() { + // arrange + Brand brand = brandRepository.save(new Brand("나이키", "스포츠 브랜드")); + productRepository.save(new Product(brand.getId(), "에어맥스", new Price(150000), new Stock(10))); + + // act + ProductDto.PagedProductResponse response = productFacade.getAllProductsCached(null, "latest", 0, 20); + + // assert + assertThat(response.data()).hasSize(1); + assertThat(response.totalElements()).isEqualTo(1); + } + + @DisplayName("상품 상세 캐시 미스 시 DB에서 조회하여 반환한다") + @Test + void getProductDetailCached_onCacheMiss_returnsFromDb() { + // arrange + Brand brand = brandRepository.save(new Brand("나이키", "스포츠 브랜드")); + Product product = productRepository.save( + new Product(brand.getId(), "에어맥스", new Price(150000), new Stock(10))); + + // act + ProductDto.ProductResponse response = productFacade.getProductDetailCached(product.getId()); + + // assert + assertThat(response.name()).isEqualTo("에어맥스"); + assertThat(response.brandName()).isEqualTo("나이키"); } } @@ -238,4 +335,30 @@ void deleteProduct_hardDeletesLikes() { assertThat(likeRepository.findByMemberIdAndProductId(2L, product.getId())).isEmpty(); } } + + @Nested + @DisplayName("벤치마크 전용 AS-IS 재현") + class NoOptimization { + + @DisplayName("enrichWithLikeCount + in-memory sort가 동작한다") + @Test + void getAllProductsNoOptimization_usesLegacyPath() { + // arrange + Brand brand = brandRepository.save(new Brand("나이키", "스포츠 브랜드")); + Product p1 = productRepository.save(new Product(brand.getId(), "에어맥스", new Price(150000), new Stock(10))); + Product p2 = productRepository.save(new Product(brand.getId(), "에어포스", new Price(120000), new Stock(20))); + + // p2에 좋아요 2개 (likeRepository를 통해) + likeRepository.save(new Like(1L, p2.getId())); + likeRepository.save(new Like(2L, p2.getId())); + + // act + List result = productFacade.getAllProductsNoOptimization("likes_desc"); + + // assert + assertThat(result).hasSize(2); + assertThat(result.get(0).likeCount()).isEqualTo(2); + assertThat(result.get(0).product().getName()).isEqualTo("에어포스"); + } + } } diff --git a/apps/commerce-api/src/test/java/com/loopers/concurrency/LikeConcurrencyTest.java b/apps/commerce-api/src/test/java/com/loopers/concurrency/LikeConcurrencyTest.java index b1de52c30..751ce4aaa 100644 --- a/apps/commerce-api/src/test/java/com/loopers/concurrency/LikeConcurrencyTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/concurrency/LikeConcurrencyTest.java @@ -45,7 +45,7 @@ void tearDown() { databaseCleanUp.truncateAllTables(); } - @DisplayName("동일 상품에 여러 명이 동시에 좋아요하면 모두 성공하고 Like 레코드가 정확히 생성된다") + @DisplayName("동일 상품에 여러 명이 동시에 좋아요하면 모두 성공하고 Like 레코드 + Product.likeCount가 정확하다") @Test void concurrentLikes_allSucceed_andCountIsCorrect() throws InterruptedException { // arrange @@ -76,12 +76,15 @@ void concurrentLikes_allSucceed_andCountIsCorrect() throws InterruptedException latch.await(); executor.shutdown(); - // assert — 락 없이 UNIQUE 제약으로 중복 방지, 모두 성공 + // assert — Like 레코드 수와 Product.likeCount가 일치해야 한다 + long actualLikeRecords = likeRepository.countByProductId(productId); + Product updatedProduct = productRepository.findById(productId).orElseThrow(); assertThat(successCount.get()).isEqualTo(threadCount); - assertThat(likeRepository.countByProductId(productId)).isEqualTo(threadCount); + assertThat(actualLikeRecords).isEqualTo(threadCount); + assertThat(updatedProduct.getLikeCount()).isEqualTo(threadCount); } - @DisplayName("동일 상품에 여러 명이 좋아요 후 일부가 취소하면 Like 레코드 수가 정확하다") + @DisplayName("동일 상품에 여러 명이 좋아요 후 일부가 취소하면 Like 레코드 수와 Product.likeCount가 일치한다") @Test void concurrentLikeAndUnlike_countsCorrectly() throws InterruptedException { // arrange @@ -91,7 +94,7 @@ void concurrentLikeAndUnlike_countsCorrectly() throws InterruptedException { new Product(brand.getId(), "에어맥스", new Price(100000), new Stock(10))); Long productId = product.getId(); - // 먼저 10명이 좋아요 + // 먼저 100명이 좋아요 ExecutorService executor1 = Executors.newFixedThreadPool(likeCount); CountDownLatch latch1 = new CountDownLatch(likeCount); for (int i = 0; i < likeCount; i++) { @@ -128,7 +131,10 @@ void concurrentLikeAndUnlike_countsCorrectly() throws InterruptedException { latch2.await(); executor2.shutdown(); - // assert - assertThat(likeRepository.countByProductId(productId)).isEqualTo(likeCount - unlikeCount); + // assert — Like 레코드 수와 Product.likeCount가 일치해야 한다 + long actualLikeRecords = likeRepository.countByProductId(productId); + Product updatedProduct = productRepository.findById(productId).orElseThrow(); + assertThat(actualLikeRecords).isEqualTo(likeCount - unlikeCount); + assertThat(updatedProduct.getLikeCount()).isEqualTo((int) actualLikeRecords); } } diff --git a/apps/commerce-api/src/test/java/com/loopers/fake/FakeProductCachePort.java b/apps/commerce-api/src/test/java/com/loopers/fake/FakeProductCachePort.java new file mode 100644 index 000000000..183cfd7b6 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/fake/FakeProductCachePort.java @@ -0,0 +1,37 @@ +package com.loopers.fake; + +import com.loopers.application.product.ProductCachePort; +import com.loopers.interfaces.api.product.ProductDto; + +public class FakeProductCachePort implements ProductCachePort { + + @Override + public ProductDto.ProductResponse getProductDetail(Long productId) { + return null; + } + + @Override + public void putProductDetail(Long productId, ProductDto.ProductResponse response) { + // no-op + } + + @Override + public void evictProductDetail(Long productId) { + // no-op + } + + @Override + public ProductDto.PagedProductResponse getProductList(Long brandId, String sort, int page, int size) { + return null; + } + + @Override + public void putProductList(Long brandId, String sort, int page, int size, ProductDto.PagedProductResponse response) { + // no-op + } + + @Override + public void evictProductList() { + // no-op + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/fake/FakeProductRepository.java b/apps/commerce-api/src/test/java/com/loopers/fake/FakeProductRepository.java index d8969a6eb..4e12ad272 100644 --- a/apps/commerce-api/src/test/java/com/loopers/fake/FakeProductRepository.java +++ b/apps/commerce-api/src/test/java/com/loopers/fake/FakeProductRepository.java @@ -6,6 +6,9 @@ import com.loopers.domain.product.Product; import com.loopers.domain.product.ProductRepository; import com.loopers.domain.product.ProductWithBrand; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; import java.lang.reflect.Field; import java.util.ArrayList; @@ -65,7 +68,7 @@ public List findAllByBrandId(Long brandId) { public List findAllWithBrand() { return store.values().stream() .filter(product -> product.getDeletedAt() == null) - .map(product -> new ProductWithBrand(product, resolveBrandName(product.getBrandId()), 0L)) + .map(product -> new ProductWithBrand(product, resolveBrandName(product.getBrandId()), product.getLikeCount())) .toList(); } @@ -75,7 +78,7 @@ public List findAllWithBrand(String sort) { return store.values().stream() .filter(product -> product.getDeletedAt() == null) .sorted(comparator) - .map(product -> new ProductWithBrand(product, resolveBrandName(product.getBrandId()), 0L)) + .map(product -> new ProductWithBrand(product, resolveBrandName(product.getBrandId()), product.getLikeCount())) .toList(); } @@ -84,10 +87,44 @@ public List findAllByBrandIdWithBrand(Long brandId) { return store.values().stream() .filter(product -> product.getDeletedAt() == null) .filter(product -> product.getBrandId().equals(brandId)) - .map(product -> new ProductWithBrand(product, resolveBrandName(product.getBrandId()), 0L)) + .map(product -> new ProductWithBrand(product, resolveBrandName(product.getBrandId()), product.getLikeCount())) .toList(); } + @Override + public Page findAllWithBrand(String sort, Pageable pageable) { + List all = findAllWithBrand(sort); + return toPage(all, pageable); + } + + @Override + public Page findAllByBrandIdWithBrand(Long brandId, String sort, Pageable pageable) { + Comparator comparator = toComparator(sort); + List all = store.values().stream() + .filter(product -> product.getDeletedAt() == null) + .filter(product -> product.getBrandId().equals(brandId)) + .sorted(comparator) + .map(product -> new ProductWithBrand(product, resolveBrandName(product.getBrandId()), product.getLikeCount())) + .toList(); + return toPage(all, pageable); + } + + @Override + public int incrementLikeCount(Long productId) { + Product product = store.get(productId); + if (product == null || product.getDeletedAt() != null) return 0; + setLikeCount(product, product.getLikeCount() + 1); + return 1; + } + + @Override + public int decrementLikeCount(Long productId) { + Product product = store.get(productId); + if (product == null || product.getDeletedAt() != null) return 0; + setLikeCount(product, Math.max(0, product.getLikeCount() - 1)); + return 1; + } + public void setBrandRepository(BrandRepository brandRepository) { this.brandRepository = brandRepository; } @@ -105,10 +142,19 @@ private Comparator toComparator(String sort) { } return switch (sort) { case "price_asc" -> Comparator.comparingInt(p -> p.getPrice().getValue()); + case "likes_desc" -> Comparator.comparing(Product::getLikeCount).reversed() + .thenComparing(Comparator.comparing(Product::getId).reversed()); default -> Comparator.comparing(Product::getId).reversed(); }; } + private Page toPage(List all, Pageable pageable) { + int start = (int) pageable.getOffset(); + int end = Math.min(start + pageable.getPageSize(), all.size()); + List pageContent = start < all.size() ? all.subList(start, end) : List.of(); + return new PageImpl<>(pageContent, pageable, all.size()); + } + private void setBaseEntityId(Object entity, long id) { try { Field idField = BaseEntity.class.getDeclaredField("id"); @@ -118,4 +164,14 @@ private void setBaseEntityId(Object entity, long id) { throw new RuntimeException(e); } } + + private void setLikeCount(Product product, int count) { + try { + Field likeCountField = Product.class.getDeclaredField("likeCount"); + likeCountField.setAccessible(true); + likeCountField.setInt(product, count); + } catch (Exception e) { + throw new RuntimeException(e); + } + } } diff --git a/apps/commerce-api/src/test/java/com/loopers/infrastructure/product/CaffeineProductCacheAdapterTest.java b/apps/commerce-api/src/test/java/com/loopers/infrastructure/product/CaffeineProductCacheAdapterTest.java new file mode 100644 index 000000000..979aa8368 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/infrastructure/product/CaffeineProductCacheAdapterTest.java @@ -0,0 +1,102 @@ +package com.loopers.infrastructure.product; + +import com.loopers.interfaces.api.product.ProductDto; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class CaffeineProductCacheAdapterTest { + + private CaffeineProductCacheAdapter cache; + + @BeforeEach + void setUp() { + cache = new CaffeineProductCacheAdapter(); + } + + @Nested + @DisplayName("상품 상세 캐시") + class DetailCache { + + @DisplayName("put 후 get하면 저장된 값이 반환된다") + @Test + void putAndGet() { + ProductDto.ProductResponse response = new ProductDto.ProductResponse( + 1L, 10L, "나이키", "에어맥스", 150000, 10, 5); + + cache.putProductDetail(1L, response); + + ProductDto.ProductResponse cached = cache.getProductDetail(1L); + assertThat(cached).isEqualTo(response); + } + + @DisplayName("캐시에 없는 상품은 null을 반환한다") + @Test + void getReturnsNullOnMiss() { + assertThat(cache.getProductDetail(999L)).isNull(); + } + + @DisplayName("evict 후 get하면 null을 반환한다") + @Test + void evictRemovesEntry() { + ProductDto.ProductResponse response = new ProductDto.ProductResponse( + 1L, 10L, "나이키", "에어맥스", 150000, 10, 5); + cache.putProductDetail(1L, response); + + cache.evictProductDetail(1L); + + assertThat(cache.getProductDetail(1L)).isNull(); + } + } + + @Nested + @DisplayName("상품 목록 캐시") + class ListCache { + + @DisplayName("put 후 get하면 저장된 값이 반환된다") + @Test + void putAndGet() { + ProductDto.PagedProductResponse response = new ProductDto.PagedProductResponse( + List.of(), 0, 0, 0, 20); + + cache.putProductList(null, "latest", 0, 20, response); + + ProductDto.PagedProductResponse cached = cache.getProductList(null, "latest", 0, 20); + assertThat(cached).isEqualTo(response); + } + + @DisplayName("brandId가 다르면 별도 캐시 엔트리이다") + @Test + void differentBrandIdIsSeparateEntry() { + ProductDto.PagedProductResponse allBrands = new ProductDto.PagedProductResponse( + List.of(), 100, 5, 0, 20); + ProductDto.PagedProductResponse brand1 = new ProductDto.PagedProductResponse( + List.of(), 10, 1, 0, 20); + + cache.putProductList(null, "latest", 0, 20, allBrands); + cache.putProductList(1L, "latest", 0, 20, brand1); + + assertThat(cache.getProductList(null, "latest", 0, 20).totalElements()).isEqualTo(100); + assertThat(cache.getProductList(1L, "latest", 0, 20).totalElements()).isEqualTo(10); + } + + @DisplayName("evictProductList는 모든 목록 캐시를 무효화한다") + @Test + void evictClearsAllListEntries() { + cache.putProductList(null, "latest", 0, 20, new ProductDto.PagedProductResponse( + List.of(), 0, 0, 0, 20)); + cache.putProductList(1L, "likes_desc", 0, 10, new ProductDto.PagedProductResponse( + List.of(), 0, 0, 0, 10)); + + cache.evictProductList(); + + assertThat(cache.getProductList(null, "latest", 0, 20)).isNull(); + assertThat(cache.getProductList(1L, "likes_desc", 0, 10)).isNull(); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/infrastructure/product/MultiLayerProductCacheAdapterTest.java b/apps/commerce-api/src/test/java/com/loopers/infrastructure/product/MultiLayerProductCacheAdapterTest.java new file mode 100644 index 000000000..252ab9914 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/infrastructure/product/MultiLayerProductCacheAdapterTest.java @@ -0,0 +1,197 @@ +package com.loopers.infrastructure.product; + +import com.loopers.application.product.ProductCachePort; +import com.loopers.interfaces.api.product.ProductDto; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class MultiLayerProductCacheAdapterTest { + + private SpyProductCachePort l1; + private SpyProductCachePort l2; + private MultiLayerProductCacheAdapter multiLayer; + + @BeforeEach + void setUp() { + l1 = new SpyProductCachePort(); + l2 = new SpyProductCachePort(); + multiLayer = new MultiLayerProductCacheAdapter(l1, l2); + } + + @Nested + @DisplayName("상품 상세 GET") + class GetDetail { + + @DisplayName("L1 히트 시 L1에서 반환하고 L2를 조회하지 않는다") + @Test + void l1Hit_returnsFromL1() { + ProductDto.ProductResponse response = detailResponse(1L); + l1.putProductDetail(1L, response); + + ProductDto.ProductResponse result = multiLayer.getProductDetail(1L); + + assertThat(result).isEqualTo(response); + assertThat(l2.getCallCount).isZero(); + } + + @DisplayName("L1 미스 + L2 히트 시 L2에서 반환하고 L1에 backfill한다") + @Test + void l1Miss_l2Hit_backfillsL1() { + ProductDto.ProductResponse response = detailResponse(1L); + l2.putProductDetail(1L, response); + + ProductDto.ProductResponse result = multiLayer.getProductDetail(1L); + + assertThat(result).isEqualTo(response); + assertThat(l1.getProductDetail(1L)).isEqualTo(response); + } + + @DisplayName("L1 미스 + L2 미스 시 null을 반환한다") + @Test + void bothMiss_returnsNull() { + assertThat(multiLayer.getProductDetail(999L)).isNull(); + } + } + + @Nested + @DisplayName("상품 상세 PUT") + class PutDetail { + + @DisplayName("L2 먼저, L1에도 저장한다") + @Test + void putStoresInBothLayers() { + ProductDto.ProductResponse response = detailResponse(1L); + + multiLayer.putProductDetail(1L, response); + + assertThat(l2.getProductDetail(1L)).isEqualTo(response); + assertThat(l1.getProductDetail(1L)).isEqualTo(response); + } + } + + @Nested + @DisplayName("상품 상세 EVICT") + class EvictDetail { + + @DisplayName("L1과 L2 모두에서 삭제한다") + @Test + void evictRemovesFromBothLayers() { + ProductDto.ProductResponse response = detailResponse(1L); + l1.putProductDetail(1L, response); + l2.putProductDetail(1L, response); + + multiLayer.evictProductDetail(1L); + + assertThat(l1.getProductDetail(1L)).isNull(); + assertThat(l2.getProductDetail(1L)).isNull(); + } + } + + @Nested + @DisplayName("상품 목록 GET") + class GetList { + + @DisplayName("L1 히트 시 L1에서 반환한다") + @Test + void l1Hit_returnsFromL1() { + ProductDto.PagedProductResponse response = listResponse(); + l1.putProductList(null, "latest", 0, 20, response); + + ProductDto.PagedProductResponse result = multiLayer.getProductList(null, "latest", 0, 20); + + assertThat(result).isEqualTo(response); + } + + @DisplayName("L1 미스 + L2 히트 시 L1에 backfill한다") + @Test + void l1Miss_l2Hit_backfillsL1() { + ProductDto.PagedProductResponse response = listResponse(); + l2.putProductList(null, "latest", 0, 20, response); + + multiLayer.getProductList(null, "latest", 0, 20); + + assertThat(l1.getProductList(null, "latest", 0, 20)).isEqualTo(response); + } + } + + @Nested + @DisplayName("상품 목록 EVICT") + class EvictList { + + @DisplayName("L1과 L2 모두에서 목록을 무효화한다") + @Test + void evictClearsBothLayers() { + l1.putProductList(null, "latest", 0, 20, listResponse()); + l2.putProductList(null, "latest", 0, 20, listResponse()); + + multiLayer.evictProductList(); + + assertThat(l1.getProductList(null, "latest", 0, 20)).isNull(); + assertThat(l2.getProductList(null, "latest", 0, 20)).isNull(); + } + } + + // ── 헬퍼 ── + + private static ProductDto.ProductResponse detailResponse(Long id) { + return new ProductDto.ProductResponse(id, 10L, "나이키", "에어맥스", 150000, 10, 5); + } + + private static ProductDto.PagedProductResponse listResponse() { + return new ProductDto.PagedProductResponse(List.of(), 0, 0, 0, 20); + } + + /** + * 테스트용 Spy — HashMap 기반 캐시 + 호출 횟수 카운팅 + */ + static class SpyProductCachePort implements ProductCachePort { + + private final Map detailStore = new HashMap<>(); + private final Map listStore = new HashMap<>(); + int getCallCount = 0; + + @Override + public ProductDto.ProductResponse getProductDetail(Long productId) { + getCallCount++; + return detailStore.get(productId); + } + + @Override + public void putProductDetail(Long productId, ProductDto.ProductResponse response) { + detailStore.put(productId, response); + } + + @Override + public void evictProductDetail(Long productId) { + detailStore.remove(productId); + } + + @Override + public ProductDto.PagedProductResponse getProductList(Long brandId, String sort, int page, int size) { + return listStore.get(listKey(brandId, sort, page, size)); + } + + @Override + public void putProductList(Long brandId, String sort, int page, int size, ProductDto.PagedProductResponse response) { + listStore.put(listKey(brandId, sort, page, size), response); + } + + @Override + public void evictProductList() { + listStore.clear(); + } + + private String listKey(Long brandId, String sort, int page, int size) { + String brandPart = brandId != null ? String.valueOf(brandId) : "all"; + return brandPart + ":" + sort + ":" + page + ":" + size; + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/performance/ProductPerformanceTest.java b/apps/commerce-api/src/test/java/com/loopers/performance/ProductPerformanceTest.java new file mode 100644 index 000000000..86ec34a80 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/performance/ProductPerformanceTest.java @@ -0,0 +1,152 @@ +package com.loopers.performance; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandRepository; +import com.loopers.domain.like.Like; +import com.loopers.domain.like.LikeRepository; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; +import com.loopers.domain.product.vo.Price; +import com.loopers.domain.product.vo.Stock; +import com.loopers.utils.DatabaseCleanUp; +import jakarta.persistence.EntityManager; +import jakarta.persistence.Query; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import java.util.ArrayList; +import java.util.List; +import java.util.Random; + +@SpringBootTest +@Disabled("성능 테스트는 수동 실행. 10만 건 시딩에 수 분 소요") +class ProductPerformanceTest { + + private static final Logger log = LoggerFactory.getLogger(ProductPerformanceTest.class); + + @Autowired + private ProductRepository productRepository; + + @Autowired + private BrandRepository brandRepository; + + @Autowired + private LikeRepository likeRepository; + + @Autowired + private EntityManager entityManager; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + private final Random random = new Random(42); + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("10만 건 데이터 시딩 + EXPLAIN 분석") + @Test + void seedAndAnalyze() { + // === 1. 데이터 시딩 === + log.info("=== 데이터 시딩 시작 ==="); + int brandCount = 100; + int productCount = 100_000; + int productPerBrand = productCount / brandCount; + + // 브랜드 100개 + List brands = new ArrayList<>(); + for (int i = 0; i < brandCount; i++) { + brands.add(brandRepository.save(new Brand("브랜드" + i, "설명" + i))); + } + log.info("브랜드 {} 개 생성 완료", brandCount); + + // 상품 10만 개 (브랜드당 ~1,000개) + List products = new ArrayList<>(); + for (int i = 0; i < productCount; i++) { + Brand brand = brands.get(i / productPerBrand); + int price = 1000 + random.nextInt(499_000); // 1,000 ~ 500,000 + Product product = productRepository.save( + new Product(brand.getId(), "상품" + i, new Price(price), new Stock(random.nextInt(100)))); + products.add(product); + + if ((i + 1) % 10_000 == 0) { + log.info("상품 {} 개 생성 완료", i + 1); + } + } + + // likeCount 설정 (멱법칙 분포 — 소수 상품이 높은 좋아요) + for (int i = 0; i < productCount; i++) { + int likes = (int) Math.round(Math.pow(random.nextDouble(), 3) * 10_000); + if (likes > 0) { + Product p = products.get(i); + for (int j = 0; j < likes && j < 50; j++) { // 실제 Like 레코드는 최대 50개만 + try { + likeRepository.save(new Like((long) (i * 100 + j + 1), p.getId())); + productRepository.incrementLikeCount(p.getId()); + } catch (Exception ignored) { + } + } + } + } + log.info("좋아요 데이터 생성 완료"); + + // === 2. EXPLAIN 분석 === + log.info("=== EXPLAIN 분석 시작 ==="); + + // 전체 상품 좋아요순 정렬 + analyzeQuery("전체 상품 + 좋아요순", + "EXPLAIN SELECT p.*, b.name FROM product p LEFT JOIN brand b ON b.id = p.brand_id " + + "WHERE p.deleted_at IS NULL AND (b.deleted_at IS NULL OR b.id IS NULL) " + + "ORDER BY p.like_count DESC, p.id DESC LIMIT 20"); + + // 브랜드 필터 + 좋아요순 + Long firstBrandId = brands.get(0).getId(); + analyzeQuery("브랜드 필터 + 좋아요순", + "EXPLAIN SELECT p.*, b.name FROM product p LEFT JOIN brand b ON b.id = p.brand_id " + + "WHERE p.brand_id = " + firstBrandId + " AND p.deleted_at IS NULL AND (b.deleted_at IS NULL OR b.id IS NULL) " + + "ORDER BY p.like_count DESC, p.id DESC LIMIT 20"); + + // 브랜드 필터 + 가격순 + analyzeQuery("브랜드 필터 + 가격순", + "EXPLAIN SELECT p.*, b.name FROM product p LEFT JOIN brand b ON b.id = p.brand_id " + + "WHERE p.brand_id = " + firstBrandId + " AND p.deleted_at IS NULL AND (b.deleted_at IS NULL OR b.id IS NULL) " + + "ORDER BY p.price ASC, p.id ASC LIMIT 20"); + + // Like countByProductId + Long firstProductId = products.get(0).getId(); + analyzeQuery("좋아요 카운트 (product_id 인덱스 활용)", + "EXPLAIN SELECT COUNT(*) FROM likes WHERE product_id = " + firstProductId); + + // AS-IS: GROUP BY 집계 + analyzeQuery("AS-IS: 전체 상품 GROUP BY 좋아요 집계", + "EXPLAIN SELECT l.product_id, COUNT(*) FROM likes l GROUP BY l.product_id"); + + log.info("=== EXPLAIN 분석 완료 ==="); + } + + private void analyzeQuery(String label, String explainSql) { + Query query = entityManager.createNativeQuery(explainSql); + List results = query.getResultList(); + + log.info("\n--- {} ---", label); + log.info("SQL: {}", explainSql.replace("EXPLAIN ", "")); + for (Object row : results) { + if (row instanceof Object[] cols) { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < cols.length; i++) { + sb.append(cols[i] != null ? cols[i].toString() : "NULL"); + if (i < cols.length - 1) sb.append(" | "); + } + log.info(" {}", sb.toString()); + } + } + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/likecountsync/LikeCountSyncJobConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/likecountsync/LikeCountSyncJobConfig.java new file mode 100644 index 000000000..3e5164367 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/likecountsync/LikeCountSyncJobConfig.java @@ -0,0 +1,49 @@ +package com.loopers.batch.job.likecountsync; + +import com.loopers.batch.job.likecountsync.step.LikeCountSyncTasklet; +import com.loopers.batch.listener.JobListener; +import com.loopers.batch.listener.StepMonitorListener; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.configuration.annotation.JobScope; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.launch.support.RunIdIncrementer; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.PlatformTransactionManager; + +@ConditionalOnProperty(name = "spring.batch.job.name", havingValue = LikeCountSyncJobConfig.JOB_NAME) +@RequiredArgsConstructor +@Configuration +public class LikeCountSyncJobConfig { + public static final String JOB_NAME = "likeCountSyncJob"; + private static final String STEP_SYNC_NAME = "likeCountSyncStep"; + + private final JobRepository jobRepository; + private final JobListener jobListener; + private final StepMonitorListener stepMonitorListener; + private final LikeCountSyncTasklet likeCountSyncTasklet; + private final PlatformTransactionManager transactionManager; + + @Bean(JOB_NAME) + public Job likeCountSyncJob() { + return new JobBuilder(JOB_NAME, jobRepository) + .incrementer(new RunIdIncrementer()) + .start(likeCountSyncStep()) + .listener(jobListener) + .build(); + } + + @JobScope + @Bean(STEP_SYNC_NAME) + public Step likeCountSyncStep() { + return new StepBuilder(STEP_SYNC_NAME, jobRepository) + .tasklet(likeCountSyncTasklet, transactionManager) + .listener(stepMonitorListener) + .build(); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/likecountsync/step/LikeCountSyncTasklet.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/likecountsync/step/LikeCountSyncTasklet.java new file mode 100644 index 000000000..611b361a0 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/likecountsync/step/LikeCountSyncTasklet.java @@ -0,0 +1,38 @@ +package com.loopers.batch.job.likecountsync.step; + +import jakarta.persistence.EntityManager; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.StepContribution; +import org.springframework.batch.core.scope.context.ChunkContext; +import org.springframework.batch.core.step.tasklet.Tasklet; +import org.springframework.batch.repeat.RepeatStatus; +import org.springframework.stereotype.Component; + +@Slf4j +@RequiredArgsConstructor +@Component +public class LikeCountSyncTasklet implements Tasklet { + + private final EntityManager entityManager; + + @Override + public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) { + log.info("[LikeCountSync] 1단계: likes 테이블 → product_like_stats 동기화 시작"); + int synced = entityManager.createNativeQuery( + "REPLACE INTO product_like_stats (product_id, like_count, synced_at) " + + "SELECT l.product_id, COUNT(*), NOW() FROM likes l GROUP BY l.product_id" + ).executeUpdate(); + log.info("[LikeCountSync] 1단계 완료 — 동기화 행 수: {}", synced); + + log.info("[LikeCountSync] 2단계: product.like_count 드리프트 보정 시작"); + int corrected = entityManager.createNativeQuery( + "UPDATE product p JOIN product_like_stats pls ON p.id = pls.product_id " + + "SET p.like_count = pls.like_count " + + "WHERE p.like_count != pls.like_count AND p.deleted_at IS NULL" + ).executeUpdate(); + log.info("[LikeCountSync] 2단계 완료 — 보정된 상품 수: {}", corrected); + + return RepeatStatus.FINISHED; + } +} diff --git a/blog/blog-week5-read-optimization.md b/blog/blog-week5-read-optimization.md new file mode 100644 index 000000000..330bcffd6 --- /dev/null +++ b/blog/blog-week5-read-optimization.md @@ -0,0 +1,232 @@ +# 상품 조회 P95 3초 → 8ms: 인덱스가 해결한 것, 하지 못한 것, 캐시가 대신한 것 + +--- + +> **TL;DR**: 1000만 건 규모의 테이블에서 페이지네이션 조회(20건/페이지)가 100 rps 동시 요청 시 100% 실패하던 구조를, 인덱스 + 비정규화 + 멀티 레이어 캐시(L1 Caffeine + L2 Redis)로 P95 8ms / 에러율 0%까지 개선했다. 이 글은 그 과정에서 내린 판단들과, 왜 그렇게 결정했는지에 대한 기록이다. + +--- + +## 문제를 처음 마주했을 때 + +상품 목록 조회 API에 좋아요 순 정렬을 추가하면서 문제가 시작됐다. + +처음에는 단순하게 접근했다. `likes` 테이블에서 `GROUP BY product_id`로 좋아요 수를 세고, Java `Comparator`로 정렬하면 되지 않을까. 페이지네이션을 적용해도, 정렬 기준이 DB 밖(Java)에 있으니 **전체 데이터를 먼저 메모리에 올려야** 했다. 10만 건 정도에서는 2초 걸렸다. 느리긴 했지만 동작은 했다. + +그런데 프로덕션 규모를 가정하고 데이터를 1000만 건으로 늘려보니 상황이 달라졌다. 단건 응답이 308초. K6로 100 rps를 걸면 99% 이상의 요청이 타임아웃으로 실패했다. 20건만 보여주면 되는 페이지네이션 요청인데, **매번 1000만 건 전체를 스캔하고 있었다.** 이건 "느린 서비스"가 아니라 **서비스 불능** 상태였다. + +원인을 분석해보니 세 가지가 겹쳐 있었다. + +1. 전체 상품을 메모리에 올려 정렬하고 있었다 (DB의 인덱스/LIMIT을 활용하지 못하고 Java에서 정렬) +2. 좋아요 수를 매 요청마다 COUNT 집계로 파생시키고 있었다 +3. 동일한 쿼리가 반복되는데 캐시가 없었다 + +하나만 고쳐서는 안 될 것 같았다. 각각의 문제에 대해 어떤 순서로, 어떤 기준으로 접근할지 고민했다. + +--- + +## 판단 1. 좋아요 수를 어디에 둘 것인가 + +가장 먼저 마주한 건 `likeCount`의 위치 문제였다. + +사실 이전에 쓰기 경합을 줄이기 위해 `likeCount` 컬럼을 의도적으로 제거한 적이 있었다. 좋아요가 몰릴 때 같은 row에 대한 UPDATE 경합이 발생하니까, 차라리 `COUNT(*)`로 파생시키는 게 낫다고 판단했었다. + +그런데 이번에 읽기 병목을 마주하면서, 같은 구조를 다른 눈으로 보게 됐다. + +| 시점 | 우선순위 | 결정 | +|------|---------|------| +| 이전 | 쓰기 경합 해소 > 읽기 성능 | `likeCount` 제거, `COUNT(*)` 파생 | +| 현재 | 읽기 성능 > 쓰기 경합 | `likeCount` 재도입, atomic SQL로 경합 최소화 | + +**트레이드오프의 축이 바뀌었다**고 느꼈다. 쓰기 경합은 atomic UPDATE(`SET like_count = like_count + 1`)로 줄일 수 있지만, 1000만 건에서 매번 `COUNT(*) GROUP BY`를 치는 건 구조적으로 한계가 있었다. 운영 환경을 다르게 가정하니, 문제의 무게중심이 달라졌다. + +--- + +## 판단 2. 인덱스를 어떻게 설계할 것인가 + +비정규화만으로는 부족했다. 1000만 건에서 `ORDER BY like_count DESC`를 하면, 인덱스 없이는 전체 테이블 스캔 + filesort가 발생한다. + +처음에는 `like_count`에 단일 인덱스를 걸었다. 그런데 브랜드 필터가 걸리면 인덱스를 타지 못했다. `WHERE brand_id = ? ORDER BY like_count DESC` — 이 조합은 단일 컬럼 인덱스로 커버되지 않는다. + +결국 **유스케이스별로 복합 인덱스**를 설계했다. + +``` +idx_product_like_count (like_count DESC, id DESC) → 전체 + 좋아요순 +idx_product_brand_like_count (brand_id, like_count DESC, id DESC) → 브랜드 필터 + 좋아요순 +idx_product_brand_price (brand_id, price ASC, id ASC) → 브랜드 필터 + 가격순 +idx_likes_product_id (product_id) → 좋아요 카운트 커버링 +``` + +EXPLAIN으로 전후를 비교해보니 차이가 명확했다. + +**AS-IS (인덱스 없음)**: +``` +type: ALL | rows: 9,955,217 | Extra: Using filesort +``` + +**TO-BE (복합 인덱스 적용)**: +``` +type: range | rows: 20 | Extra: Using index condition +``` + +스캔 행이 9,955,217 → 20으로 줄었다. 인덱스가 이미 정렬되어 있으므로 `LIMIT`만큼만 읽고 멈춘다. + +--- + +## 판단 3. 인덱스만으로 충분한가 + +여기서 한 가지 착각할 뻔했다. EXPLAIN 결과가 극적으로 좋아지니까, "인덱스면 충분하지 않나?"라는 생각이 들었다. 이제 DB가 인덱스를 타서 20건만 빠르게 읽으니까 괜찮을 거라고. + +그래서 **인덱스만 적용하고 캐시를 뺀 상태**로 100 rps 부하 테스트를 돌려봤다. 결과는 예상 밖이었다. + +| 시나리오 | P95 | Error Rate | 처리량 | +|---------|-----|-----------|--------| +| 인덱스 없음 | 3.01s | 100% | 51 rps | +| **인덱스+비정규화, 캐시 없음** | **3.02s** | **99.65%** | **35 rps** | + +인덱스를 걸었는데 오히려 처리량이 떨어졌다. 왜? + +Grafana의 HikariCP 패널에서 답을 찾았다. **커넥션 40개가 전부 점유**되어 있었다. 인덱스가 단건 쿼리를 빠르게 하는 건 맞지만, 100 rps로 동시에 밀려오는 요청이 각각 DB 커넥션을 잡으면, 커넥션 풀이 포화되면서 뒤따르는 요청들이 대기 큐에 빠진다. 한 건의 쿼리가 1ms여도, **커넥션을 기다리는 시간이 3초**가 된다. + +이 시점에서 깨달은 것: 캐시의 본질적 가치는 "빠른 응답"이 아니라 **"DB에 안 가게 하는 것"** 이다. + +--- + +## 판단 4. 캐시 전략을 어떻게 설계할 것인가 + +캐시를 적용하기로 했다. 그런데 결정할 게 많았다. + +### TTL은 어떻게? + +- 상품 상세: TTL 10분. 상품 정보는 자주 바뀌지 않고, 변경 시 명시적으로 evict한다. +- 상품 목록: TTL 5분. 목록은 새 상품 등록, 좋아요 변동 등으로 상대적으로 자주 바뀐다. + +처음에는 둘 다 10분으로 뒀는데, 목록 캐시가 너무 오래 유지되면 "방금 좋아요 눌렀는데 순위가 안 바뀌어요" 같은 불만이 생길 것 같았다. 결국 목록의 TTL을 짧게 조정했다. + +### 무효화 전략은? + +상품 상세는 단건이니까 `evict(productId)`로 충분하다. 문제는 목록이었다. 브랜드, 정렬, 페이지 조합으로 캐시 키가 무수히 많다. + +처음에는 패턴 매칭 삭제(`SCAN`)를 고려했다. 하지만 키가 수천 개일 때 O(N) 순회는 Redis에 부담이 된다. 결국 **버전 기반 무효화**를 선택했다. + +``` +캐시 키: product:list:v{version}:brand:3:sort:likeCount:page:0:size:20 +무효화: INCR product:list:version → 기존 키는 자연스럽게 miss +``` + +O(1)이고, 기존 키는 TTL이 만료되면 알아서 정리된다. 다만 무효화 시 모든 목록 캐시가 한꺼번에 miss되는 thundering herd 가능성은 있다. 현재 규모에서는 DB가 충분히 감당할 수 있다고 판단했지만, 트래픽이 10배로 늘면 재고해야 할 지점이다. + +### Redis 장애 시에는? + +try-catch로 감싸서 DB 직접 조회로 폴백한다. 캐시는 **최적화 계층이지 필수 의존이 아니다**. 이 원칙은 처음부터 정해두고 싶었다. + +--- + +## 판단 5. 왜 Redis만으로 부족하다고 생각했는가 + +Redis 캐시만 적용한 상태에서 P95가 10ms, 에러율 0%까지 떨어졌다. 충분히 만족할 만한 수치다. + +그런데 한 가지 마음에 걸렸다. 모든 캐시 조회가 Redis 네트워크 왕복을 거치고 있었다. Docker 환경에서 Redis가 localhost라 1ms 미만이지만, 실 운영에서 Redis가 별도 서버에 있으면 왕복 1~3ms가 추가된다. 수천 RPS에서 그 차이가 Tomcat 스레드 점유 시간으로 누적되면? + +인기 상품 상위 0.5%만 JVM 로컬 캐시(Caffeine)에 올리면 네트워크 비용 자체를 없앨 수 있다. 메모리는 ~1.5MB. 무시 가능한 비용이다. + +``` +GET: L1(Caffeine) hit → 반환 (μs) + L1 miss → L2(Redis) hit → L1 backfill → 반환 (ms) + 양쪽 miss → DB 조회 → L2 저장 → L1 저장 +``` + +**벤치마크 결과 (동일 조건: 100 rps, 1분, 1000만 건)**: + +| 시나리오 | P50 | P95 | Error Rate | 처리량 | +|---------|-----|-----|-----------|--------| +| L2 Redis Only | 6.47ms | 10.19ms | 0% | 100 rps | +| **L1+L2 Multi-Layer** | **4.76ms** | **8.04ms** | **0%** | **100 rps** | + +수치 차이는 2ms다. 하지만 이건 Redis가 localhost인 Docker 환경의 결과다. 실 운영에서는 이 차이가 더 벌어질 거라고 예상한다. + +--- + +## 판단 6. 캐시 구현체를 인터페이스로 분리한 이유 + +멀티 레이어 캐시를 만들면서 구조적인 문제를 발견했다. + +기존 `ProductCacheService`는 application 레이어의 concrete class인데, `RedisTemplate`을 직접 의존하고 있었다. Repository는 DIP를 잘 지키고 있었는데, 캐시만 예외였다. + +``` +// Repository — DIP 준수 +ProductFacade → ProductRepository (domain interface) ← ProductRepositoryImpl (infrastructure) + +// 캐시 — DIP 위반 +ProductFacade → ProductCacheService (concrete, RedisTemplate 직접 의존) +``` + +처음에는 "캐시니까 그냥 이대로 써도 되지 않을까" 싶었다. 그런데 테스트를 작성하면서 문제를 체감했다. Fake 객체를 만들려면 `extends ProductCacheService`에서 `super(null, null, null)`을 호출해야 했다. 생성자 파라미터가 바뀔 때마다 모든 Fake가 깨진다. + +L1, L2, MultiLayer 세 개의 구현체가 필요한 시점에서, 인터페이스 분리는 선택이 아니라 필수였다. + +``` +ProductCachePort (application, interface) + ├── CaffeineProductCacheAdapter (infrastructure, L1) + ├── RedisProductCacheAdapter (infrastructure, L2) + └── MultiLayerProductCacheAdapter (infrastructure, @Primary, L1+L2) +``` + +호출부(`ProductFacade`, `LikeController`)는 타입과 변수명만 교체하면 됐다. 메서드 시그니처가 동일하니까. + +--- + +## 검증 — 어떻게 측정했는가 + +"좋아졌다"를 체감하려면 수치가 필요했다. 그리고 **각 계층이 얼마나 기여하는지** 분리해서 보고 싶었다. + +### 환경 구성 + +- **MySQL** (Docker): 상품 1000만 건, 브랜드 500개, 회원 5000명, 좋아요 95만 건 +- **Redis** (Docker): Master-Replica 구성 +- **K6**: 100 rps, 1분, constant-arrival-rate. 페이지 0~4 × 정렬 3종 = 15개 조합을 랜덤 요청 (각 요청당 20건 페이지네이션) +- **Prometheus + Grafana**: P95, RPS, Error Rate, HikariCP, JVM Heap 모니터링 + +### 비교군 설계 + +각 최적화 계층의 기여분을 분리하기 위해 A/B 비교 엔드포인트를 추가했다. + +| 엔드포인트 | 인덱스 | 비정규화 | 캐시 | 증명하는 것 | +|-----------|--------|---------|------|-----------| +| `/products/no-optimization` | X | X | X | 기준선 — 왜 최적화가 필요한가 | +| `/products/no-cache` | O | O | X | 인덱스만으로 충분한가 | +| `/products` (L2) | O | O | L2 | 캐시 하나로 얼마나 달라지는가 | +| `/products` (L1+L2) | O | O | L1+L2 | 로컬 캐시가 추가로 줄여주는 것 | + +### Grafana에서 읽은 것 + +![P95 Response Time + P50/RPS](../docs/images/grafana-dashboard-top.png) +![P50 + RPS + Error Rate + HikariCP](../docs/images/grafana-dashboard-middle.png) +![Error Rate + HikariCP + JVM Heap + Total Requests](../docs/images/grafana-dashboard-bottom.png) + +숫자 테이블보다 Grafana가 더 직관적으로 보여주는 것들이 있었다. + +**HikariCP 패널이 진짜 병목을 드러냈다.** 비캐시 구간에서 40개(Max Pool) 전부 점유, 캐시 구간에서 1~2개. 느린 쿼리 하나가 문제가 아니라, 느린 쿼리가 커넥션을 물고 놓지 않으면 뒤따르는 모든 요청이 대기에 빠진다. 이걸 보고 나서 "캐시는 속도 최적화"라는 생각이 바뀌었다. **캐시는 가용성 확보**다. + +**RPS 패널에서 서비스 용량의 차이가 보였다.** 비캐시는 목표 100 rps에 실제 35~51 rps만 처리하고 나머지는 유실됐다. 캐시를 적용하니 100 rps를 안정적으로 소화했다. 같은 하드웨어에서 캐시 유무가 처리 가능 트래픽을 2~3배 갈랐다. + +**L2 → L1+L2 차이는 환경의 한계를 알고 읽어야 한다.** 10ms → 8ms, 2ms 차이. Redis가 localhost여서 네트워크 latency가 거의 0인 Docker 환경이기 때문이다. 실 운영에서 Redis가 별도 서버에 있으면 이 차이는 더 벌어질 것이다. + +--- + +## 시행착오 + +검증 과정이 순탄하지는 않았다. + +**Docker `/tmp` 디스크 포화.** No Optimization 테스트를 먼저 돌리면, 1000만 건 전체 풀스캔 + filesort가 MySQL 임시 파일을 대량 생성해서 Docker VM 디스크를 채웠다. 이후 돌리는 캐시 테스트도 캐시 미스 시 DB 쿼리가 `No space left on device`로 실패하며 연쇄적으로 무너졌다. MySQL `sort_buffer_size`를 8MB로 올리고, 테스트 간 MySQL 컨테이너를 재시작해서 해결했다. + +**앱 재시작 시 1000만 건 데이터 유실.** local 프로필의 `ddl-auto: create` 때문에, 앱을 재시작하면 테이블이 재생성됐다. Stored procedure로 30분 걸려 시딩한 데이터가 순식간에 날아가는 경험을 했다... `--spring.jpa.hibernate.ddl-auto=none`을 JVM 인자로 전달해서 해결했는데, 한 번 당하기 전에는 떠올리기 어려운 종류의 실수였다. + +--- + +## 돌아보며 + +이번 작업에서 가장 크게 배운 건, **같은 구조도 문제의 맥락이 바뀌면 다시 판단해야 한다**는 점이다. `likeCount` 비정규화가 대표적이다. 쓰기 경합 관점에서는 제거하는 게 맞았지만, 읽기 병목 관점에서는 다시 도입하는 게 맞았다. "이전에 결정한 거니까"라고 고집하지 않고, 현재의 문제에 맞게 재판단하는 게 중요했다. + +그리고 인덱스만 믿고 캐시를 빼봤을 때 오히려 더 느려진 경험이 인상적이었다. EXPLAIN의 rows가 20이어도, 100 rps에서 커넥션 풀이 포화되면 의미가 없다. **단건 성능과 동시성 하의 성능은 완전히 다른 문제**라는 걸 체감했다. + +아직 아쉬운 부분도 있다. 다중 서버 환경에서 L1 캐시의 일관성 문제는 짧은 TTL로 회피하고 있을 뿐, 근본적으로 해결하지는 않았다. 트래픽이 더 커지면 Redis Pub/Sub 기반의 L1 무효화를 추가해야 할 것 같다. 그건 다음 과제로 남겨둔다. \ No newline at end of file diff --git a/blog/round5-read-optimization.md b/blog/round5-read-optimization.md new file mode 100644 index 000000000..606b91d09 --- /dev/null +++ b/blog/round5-read-optimization.md @@ -0,0 +1,366 @@ +# Round 5 — Practical Read Optimization: 시니어 아키텍트 분석 + +--- + +## 0. Context — 왜 이 변경이 필요한가 + +현재 시스템은 **쓰기 정합성**에 최적화되어 있다. 4주차에서 `Product.likeCount`를 제거하고 `COUNT(*)`로 파생시켜 **쓰기 경합을 구조적으로 제거**했다. 그러나 상품이 10만 건을 넘어가면 읽기 쪽에서 심각한 병목이 발생한다. + +**현재 상품 목록 조회 흐름 (AS-IS):** +``` +GET /api/v1/products?sort=likes_desc + +1. ProductFacade.getAllProducts("likes_desc") +2. → productRepository.findAllWithBrand("likes_desc") // 전체 상품 LEFT JOIN Brand +3. → enrichWithLikeCount(products) // SELECT productId, COUNT(l) GROUP BY productId +4. → Java Comparator로 in-memory 정렬 // likeCount 기준 역순 정렬 +5. 결과: List (전체, 페이지네이션 없음) +``` + +**문제점 3가지:** +1. **전량 로딩**: 10만 건을 메모리에 올려 Java에서 정렬 → O(N log N) + 메모리 압박 +2. **매 요청마다 COUNT 집계**: likes 테이블 전체를 GROUP BY → 인덱스 없이 Full Scan +3. **캐시 부재**: 동일한 목록 쿼리가 매번 DB를 직격 + +--- + +## 1. 현재 시스템 진단 + +### 1-1. 엔티티별 인덱스 현황 + +| 엔티티 | 인덱스명 | 컬럼 | 용도 | +|--------|---------|------|------| +| **Product** | `idx_product_brand_id` | `brand_id` | 브랜드별 필터 | +| **Like** | `uk_likes_member_product` | `(member_id, product_id)` UNIQUE | 중복 좋아요 방지 | +| **Order** | `idx_orders_member_id` | `member_id` | 회원별 주문 조회 | +| **Order** | `idx_orders_member_created_at` | `(member_id, created_at)` | 회원+기간 주문 조회 | +| **CouponIssue** | `idx_coupon_issue_member_id` | `member_id` | 회원별 쿠폰 조회 | +| **CouponIssue** | `idx_coupon_issue_coupon_id` | `coupon_id` | 쿠폰별 발급 내역 | + +**인덱스 갭:** +- Like 테이블에 `product_id` 단독 인덱스 없음 → `countByProductId` 쿼리가 Full Scan +- Product에 `(brand_id, price)` 복합 인덱스 없음 → 브랜드 필터 + 가격 정렬 시 filesort 발생 +- Product에 `like_count` 컬럼 자체가 없음 → DB 정렬 불가, in-memory 정렬 강제 + +### 1-2. 조회 흐름별 병목 분석 + +| 시나리오 | 현재 동작 | 병목 | +|---------|----------|------| +| 전체 상품 + 최신순 | `ORDER BY created_at DESC` (DB) | 10만 건 전량 반환 (페이지네이션 없음) | +| 전체 상품 + 가격순 | `ORDER BY price ASC` (DB) | 10만 건 전량 반환 | +| 전체 상품 + 좋아요순 | Java in-memory sort | **10만 건 로딩 + COUNT 집계 + Java 정렬** | +| 브랜드 필터 + 좋아요순 | Java in-memory sort | 필터 후에도 in-memory 정렬 | +| 상품 상세 | `findById` + `countByProductId` | 매 요청마다 COUNT 쿼리 | + +--- + +## 2. 핵심 설계 판단 + +### 2-1. 4주차 → 5주차: 의도적 방향 전환 + +4주차에서 `Product.likeCount`를 제거한 근거: +> "저장 자체가 경합을 만들기도 한다" — 좋아요와 주문이 같은 Product 행에서 경합 + +5주차에서 `likeCount`를 다시 도입하는 근거: +> 10만 건 상품에서 매 요청마다 `COUNT(*) + GROUP BY + in-memory sort`는 읽기 병목 + +**이건 모순이 아니라 트레이드오프의 축이 바뀐 것이다.** +- 4주차: 쓰기 경합 > 읽기 성능 → likeCount 제거 +- 5주차: 읽기 성능 > 쓰기 경합 → likeCount 재도입 (단, 경합 최소화 방식으로) + +**경합 최소화 전략**: `SET like_count = like_count + 1` (atomic SQL) +- 엔티티를 메모리에 로딩하지 않음 → read-modify-write 패턴 제거 +- DB 행 잠금은 단일 UPDATE 문 실행 시간(마이크로초)만 유지 +- 4주차의 `Stock.decrease()`처럼 트랜잭션 전체를 잠그는 것과는 본질적으로 다름 + +### 2-2. 비정규화 vs MaterializedView + +| 관점 | 비정규화 (like_count 컬럼) | MaterializedView | +|------|--------------------------|------------------| +| 구현 복잡도 | 낮음 | MySQL은 MV 미지원, 시뮬레이션 필요 | +| 실시간성 | 즉시 반영 | 주기적 갱신 (지연) | +| 인덱스 활용 | 직접 인덱스 생성 가능 | 별도 테이블에 인덱스 필요 | +| 쓰기 부하 | 좋아요마다 UPDATE 1회 추가 | 배치/스케줄러 부하 | + +**선택: 비정규화 (Primary) + MaterializedView 시뮬레이션 (Secondary)** + +### 2-3. 캐시 방식 — @Cacheable vs RedisTemplate 직접 사용 + +**선택: RedisTemplate 직접 사용** +- 캐시 흐름이 AOP로 감춰지지 않아 가시성 확보 +- 이미 구축된 Master/Replica RedisTemplate 활용 +- StringRedisSerializer 기반이므로 JSON 직렬화 직접 수행 + +--- + +## 3. 구현 결과 + +### 3-1. Product 비정규화 — likeCount 컬럼 + 인덱스 4개 + +**새 인덱스:** + +| 인덱스명 | 컬럼 | 커버하는 쿼리 | +|---------|------|-------------| +| `idx_product_like_count` | `like_count DESC, id DESC` | 전체 상품 + 좋아요순 정렬 | +| `idx_product_brand_like_count` | `brand_id, like_count DESC, id DESC` | 브랜드 필터 + 좋아요순 | +| `idx_product_brand_price` | `brand_id, price ASC, id ASC` | 브랜드 필터 + 가격순 | +| `idx_likes_product_id` | `product_id` (Like 테이블) | countByProductId 최적화 | + +**likeCount 갱신**: atomic SQL (`like_count = like_count + 1`) + +### 3-2. 조회 쿼리 리팩토링 + +- `enrichWithLikeCount()` 제거 → `product.getLikeCount()` 사용 +- in-memory Comparator 정렬 제거 → DB `ORDER BY like_count DESC, id DESC` +- 페이지네이션 (`Page`) 적용 + +### 3-3. Redis 캐시 적용 + +- 상품 상세: `product:detail:{id}` / TTL 10분 / Cache-Aside +- 상품 목록: `product:list:v{version}:brand:...` / TTL 5분 / 버전 기반 무효화 +- Redis 장애 시 fallback: try-catch로 DB 직접 조회 + +### 3-4. MaterializedView 시뮬레이션 + +- `product_like_stats` 테이블 + `LikeCountSyncJob` 배치 +- `REPLACE INTO ... SELECT COUNT(*) ...` → `UPDATE product SET like_count = ...` + +### 3-5. 성능 비교 엔드포인트 + +| 경로 | 인덱스 | 캐시 | 비정규화 | +|------|--------|------|----------| +| `GET /api/v1/products` | O | O | O | +| `GET /api/v1/products/no-cache` | O | X | O | +| `GET /api/v1/products/no-optimization` | O | X | X (COUNT + in-memory sort) | + +--- + +## 4. 수정/생성 파일 목록 + +### 수정 파일 + +| 파일 | 변경 내용 | +|------|-----------| +| `domain/product/Product.java` | `likeCount` 필드, 인덱스 3개 추가 | +| `domain/product/ProductRepository.java` | `incrementLikeCount`, `decrementLikeCount`, 페이지네이션 메서드 추가 | +| `domain/like/Like.java` | `product_id` 인덱스 추가 | +| `infrastructure/product/ProductJpaRepository.java` | `@Modifying` 증감 쿼리, 페이지네이션 쿼리 추가 | +| `infrastructure/product/ProductRepositoryImpl.java` | 위임 구현, `toSort` 수정, `toProductWithBrand` 수정 | +| `application/like/LikeFacade.java` | 좋아요 등록/취소 시 `incrementLikeCount`/`decrementLikeCount` 호출 | +| `application/product/ProductFacade.java` | `enrichWithLikeCount` 제거, 캐시 통합, 페이지네이션 | +| `interfaces/api/product/ProductController.java` | `page`/`size` 파라미터, 응답 구조 변경 | +| `interfaces/api/product/ProductDto.java` | `PagedProductResponse` 추가 | +| `interfaces/api/like/LikeController.java` | 좋아요 변경 시 캐시 무효화 | +| `test/.../fake/FakeProductRepository.java` | 증감 구현, 페이지네이션 구현 | +| `test/.../application/product/ProductFacadeTest.java` | 새 흐름 반영 | +| `test/.../application/like/LikeFacadeTest.java` | likeCount 동기화 테스트 | +| `test/.../concurrency/LikeConcurrencyTest.java` | 비정규화 카운트 정합성 검증 | + +### 신규 파일 + +| 파일 | 설명 | +|------|------| +| `application/product/ProductCacheService.java` | Redis 캐시 서비스 | +| `domain/product/ProductLikeStats.java` | MV 시뮬레이션용 엔티티 | +| `domain/product/ProductLikeStatsRepository.java` | 도메인 인터페이스 | +| `infrastructure/product/ProductLikeStatsJpaRepository.java` | JPA 구현 | +| `infrastructure/product/ProductLikeStatsRepositoryImpl.java` | DIP 구현체 | +| `interfaces/api/product/ProductBenchmarkController.java` | 성능 비교 엔드포인트 | +| `test/.../fake/FakeProductCacheService.java` | 테스트용 캐시 서비스 | +| `test/.../performance/ProductPerformanceTest.java` | 10만 건 시딩 + EXPLAIN 분석 | +| commerce-batch: `LikeCountSyncJobConfig.java` | 배치 Job 설정 | +| commerce-batch: `LikeCountSyncTasklet.java` | 정합성 동기화 Tasklet | +| `k6/common.js` | K6 공통 옵션 | +| `k6/product-list-optimized.js` | 최적화 후 부하 테스트 | +| `k6/product-list-no-cache.js` | 캐시 미적용 부하 테스트 | +| `k6/product-list-no-optimization.js` | AS-IS 재현 부하 테스트 | +| `k6/product-detail.js` | 상세 조회 부하 테스트 | + +--- + +## 5. 검증 방법 + +### 자동 테스트 +- `./gradlew :apps:commerce-api:test` — 전체 테스트 통과 +- LikeFacadeTest: addLike/removeLike 시 `product.getLikeCount()` 동기화 검증 +- LikeConcurrencyTest: 100 동시 좋아요 → `Product.likeCount == COUNT(*)` 검증 + +### K6 부하 테스트 + Grafana 모니터링 + +```bash +# 인프라 기동 +docker compose -f docker/infra-compose.yml up -d +docker compose -f docker/monitoring-compose.yml up -d + +# 앱 기동 +./gradlew :apps:commerce-api:bootRun + +# K6 실행 +k6 run k6/product-list-optimized.js +k6 run k6/product-list-no-cache.js +k6 run k6/product-list-no-optimization.js +``` + +### A/B 비교 매트릭스 + +| 비교 축 | A 엔드포인트 | B 엔드포인트 | 측정 대상 | +|---------|-------------|-------------|----------| +| 캐시 효과 | `/products` | `/products/no-cache` | Redis 캐시의 응답 시간 절감 | +| 전체 최적화 | `/products` | `/products/no-optimization` | 인덱스+비정규화+캐시 총 효과 | +| DB 레벨 최적화 | `/products/no-cache` | `/products/no-optimization` | 인덱스+비정규화 단독 효과 | + +--- + +## 6. 성능 검증 결과 (10만 건 실측) + +### 6-1. 데이터 규모 + +| 데이터 | 건수 | +|--------|------| +| 브랜드 | 100 | +| 멤버 | 1,000 | +| **상품** | **100,000** | +| **좋아요** | **95,000** | +| max like_count | 50 (멱법칙 분포) | + +### 6-2. EXPLAIN 분석 — AS-IS vs TO-BE + +#### 전체 상품 + 좋아요순 정렬 (가장 비싼 쿼리) + +**AS-IS** (COUNT + GROUP BY + in-memory sort): +``` +type=ALL | key=NULL | rows=99,770 | Extra=Using where + → 서브쿼리: type=index | rows=95,100 (likes 전체 스캔) +``` + +**TO-BE** (비정규화 like_count + idx_product_like_count): +``` +type=index | key=idx_product_like_count | rows=20 | Extra=Using where +``` + +**개선: 스캔 행 4,988배 감소 (99,770 → 20)** + +#### 브랜드 필터 + 좋아요순 + +``` +type=ref | key=idx_product_brand_like_count | rows=1,000 | Extra=Using where +``` +- 복합 인덱스 `(brand_id, like_count DESC, id DESC)` 활용 +- brand_id로 필터링 후 이미 정렬된 인덱스에서 LIMIT만큼 반환 + +#### 좋아요 카운트 (상세 조회) + +``` +type=ref | key=idx_likes_product_id | rows=50 | Extra=Using index (커버링 인덱스) +``` +- 인덱스만으로 카운트 완료 — 테이블 접근 불필요 + +### 6-3. 단건 API 응답 시간 + +| 시나리오 | 평균 응답 시간 | vs AS-IS 개선율 | +|---------|-------------|----------------| +| **최적화 후 (캐시 HIT)** | **11ms** | **186배** | +| 캐시 미적용 (인덱스+비정규화만) | 25ms | 82배 | +| **AS-IS (COUNT+in-memory sort)** | **2,047ms** | 기준 | + +### 6-4. K6 부하 테스트 (200 RPS Peak, 70초) + +| 시나리오 | P95 | P99 | 실패율 | 처리량 | Threshold | +|---------|-----|-----|--------|--------|-----------| +| **최적화 후 (캐시 O)** | **23ms** | **107ms** | **0%** | 141 rps | **PASS** | +| 캐시 미적용 (인덱스만) | 5,830ms | 6,950ms | 12% | 54 rps | FAIL | +| **AS-IS (no-optimization)** | **9,710ms** | **60,000ms** | **99.4%** | 31 rps | FAIL | + +### 6-5. A/B 비교 분석 + +| 비교 축 | 측정 | P95 기준 개선율 | +|---------|------|----------------| +| **캐시 효과** (최적화 vs no-cache) | 23ms vs 5,830ms | **253배** | +| **DB 최적화 효과** (no-cache vs AS-IS) | 5,830ms vs 9,710ms | **1.7배** | +| **전체 최적화** (최적화 vs AS-IS) | 23ms vs 9,710ms | **422배** | + +### 6-6. 핵심 인사이트 + +1. **캐시가 가장 큰 효과**: 200 RPS에서 캐시 유무가 서비스 가용성을 결정함. 인덱스+비정규화만으로는 DB 커넥션 풀(40개)이 포화되어 12% 실패 발생 +2. **인덱스는 필수 인프라**: 단건 쿼리 기준 82배 개선. 하지만 고부하에서는 단독으로 부족 +3. **AS-IS는 서비스 불능**: 200 RPS에서 99.4% 실패. 10만 건을 매번 메모리에 올리는 구조는 대규모 트래픽에서 사용 불가 + +--- + +## 7. 1000만 건 실측 — 프로덕션급 부하 검증 + +### 7-0. 테스트 환경 + +- **MySQL buffer_pool_size**: 4GB (프로덕션 환경에 근접) +- **데이터**: 상품 10,000,000건, 좋아요 950,000건, 브랜드 500개, 회원 5,000명 +- **좋아요 분포**: 멱법칙 (Power-law) — 소수 인기 상품에 좋아요 집중 + +### 7-1. EXPLAIN 분석 (1000만 건) + +#### 좋아요순 정렬 — TO-BE (비정규화 + 인덱스) + +``` +type=index | key=idx_product_like_count | rows=20 | Extra=Using where +``` +- **1000만 건에서도 스캔 행이 20**. 인덱스가 이미 정렬되어 있으므로 LIMIT만큼만 읽음 + +#### 좋아요순 정렬 — AS-IS (COUNT 집계 + in-memory sort) + +``` +type=index | key=PRIMARY | rows=9,955,217 | Extra=Using where; Using temporary; Using filesort +``` +- **전체 ~1000만 행을 스캔** + temporary table + filesort → 물리적으로 사용 불가 + +#### 브랜드 필터 + 좋아요순 + +``` +type=ref | key=idx_product_brand_like_count | rows=34,704 | Extra=Using where +``` +- 복합 인덱스로 brand_id 필터 → 정렬된 순서로 LIMIT 반환. 10만 건(1,000행) 대비 행 수 증가는 브랜드당 상품 수 증가(1,000 → 20,000)에 비례 + +### 7-2. 단건 API 응답 시간 + +| 시나리오 | 응답 시간 | vs 10만 건 대비 | 비고 | +|---------|----------|---------------|------| +| **최적화 후 (캐시 HIT)** | **~10ms** | 동일 | 캐시 적중 시 데이터 규모와 무관 | +| **최적화 후 (캐시 MISS, 첫 요청)** | **~1.8초** | 느려짐 | 1000만 건 COUNT 쿼리 (첫 요청만) | +| **캐시 미적용 (인덱스만)** | **~1.1초** | 약간 느려짐 | 매 요청마다 DB 조회 | +| **AS-IS (COUNT + in-memory sort)** | **~308초** (5분+) | **150배 악화** | 10만 건(2초) → 1000만 건(308초). **사실상 사용 불가** | + +### 7-3. K6 부하 테스트 (200 RPS Peak, 70초) + +| 시나리오 | P95 | P99 | 에러율 | 처리량 | Threshold | +|---------|-----|-----|--------|--------|-----------| +| **최적화 후 (캐시 O)** | **14ms** | **35ms** | **0%** | 141 rps | **PASS** | +| **캐시 미적용 (인덱스만)** | **67ms** | **249ms** | **0%** | 141 rps | **PASS** | +| **AS-IS (no-optimization)** | — | — | — | — | **단건 308초로 부하 테스트 불가** | + +### 7-4. 10만 건 vs 1000만 건 비교 + +| 지표 | 10만 건 | 1000만 건 | 변화 | +|------|--------|----------|------| +| **최적화 후 P95** | 23ms | **14ms** | 오히려 개선 (캐시 워밍업 효과) | +| **no-cache P95** | 5,830ms | **67ms** | **87배 개선** | +| **no-cache 에러율** | 12% | **0%** | 에러 완전 해소 | +| **AS-IS 단건** | 2초 | **308초** | **150배 악화** | + +**핵심 발견: 10만 건에서 no-cache가 실패했던 이유는 10만 건을 전량 반환(페이지네이션 없음)하던 구조 때문이었다. 1000만 건에서는 앱을 재기동하여 최신 코드(페이지네이션 + 비정규화 정렬)가 적용되었고, 결과적으로 no-cache도 안정적으로 200 RPS를 처리한다.** + +### 7-5. Grafana 모니터링 (1000만 건) + +![P95 Response Time + RPS (10M)](../docs/images/grafana-10m-response-time-rps.png) +![Error Rate + HikariCP + JVM Heap (10M)](../docs/images/grafana-10m-error-hikari-jvm.png) + +**Grafana 관측:** +1. **P95 Response Time**: 최적화 후(초록/노랑)는 바닥에 깔려있고, no-optimization(파랑)은 ~30초로 폭등 +2. **RPS**: K6 실행 구간에서 200 req/s까지 정상 도달 (최적화, no-cache 모두) +3. **HikariCP**: no-optimization 실행 시 DB 커넥션 40개 포화 → 최적화 후는 저부하 +4. **JVM Heap**: no-optimization 시 Old Gen이 4GB까지 급증 (1000만 건 전량 로딩) → 최적화 후는 안정 +5. **Total Requests**: 최적화 9.9K, no-cache 9.9K, no-optimization **1건** (308초 단 1건) + +### 7-6. 1000만 건 핵심 인사이트 + +1. **인덱스+비정규화가 본질적 해결**: 1000만 건에서도 EXPLAIN rows=20. 데이터 규모가 100배 증가해도 인덱스 기반 조회는 O(1)에 가깝다 +2. **캐시는 중요하지만 유일한 해답이 아님**: no-cache도 P95=67ms로 안정적. 인덱스+비정규화+페이지네이션이 갖춰진 상태에서 캐시는 "좋은 보너스" +3. **AS-IS는 데이터 규모에 비례해 붕괴**: 10만→1000만 (100배)에서 응답 시간은 2초→308초 (150배). O(N) 이상의 비선형 악화 +4. **버퍼풀 4GB 설정의 의미**: 1000만 건 인덱스가 메모리에 상주하여 디스크 I/O를 최소화. 프로덕션에서는 버퍼풀을 물리 메모리의 60-80%로 설정하는 것이 표준 diff --git a/docs/images/grafana-10m-error-hikari-jvm.png b/docs/images/grafana-10m-error-hikari-jvm.png new file mode 100644 index 000000000..82cda3618 Binary files /dev/null and b/docs/images/grafana-10m-error-hikari-jvm.png differ diff --git a/docs/images/grafana-10m-l1l2-error-hikari-jvm.png b/docs/images/grafana-10m-l1l2-error-hikari-jvm.png new file mode 100644 index 000000000..1e2c3e67d Binary files /dev/null and b/docs/images/grafana-10m-l1l2-error-hikari-jvm.png differ diff --git a/docs/images/grafana-10m-l1l2-response-time-rps.png b/docs/images/grafana-10m-l1l2-response-time-rps.png new file mode 100644 index 000000000..2bc0527c9 Binary files /dev/null and b/docs/images/grafana-10m-l1l2-response-time-rps.png differ diff --git a/docs/images/grafana-10m-response-time-rps.png b/docs/images/grafana-10m-response-time-rps.png new file mode 100644 index 000000000..2bf5de2cb Binary files /dev/null and b/docs/images/grafana-10m-response-time-rps.png differ diff --git a/docs/images/grafana-dashboard-bottom.png b/docs/images/grafana-dashboard-bottom.png new file mode 100644 index 000000000..cc7df5cd0 Binary files /dev/null and b/docs/images/grafana-dashboard-bottom.png differ diff --git a/docs/images/grafana-dashboard-middle.png b/docs/images/grafana-dashboard-middle.png new file mode 100644 index 000000000..8a6524731 Binary files /dev/null and b/docs/images/grafana-dashboard-middle.png differ diff --git a/docs/images/grafana-dashboard-top.png b/docs/images/grafana-dashboard-top.png new file mode 100644 index 000000000..bb5f35a72 Binary files /dev/null and b/docs/images/grafana-dashboard-top.png differ diff --git a/docs/images/grafana-error-hikari-jvm.png b/docs/images/grafana-error-hikari-jvm.png new file mode 100644 index 000000000..6af05da02 Binary files /dev/null and b/docs/images/grafana-error-hikari-jvm.png differ diff --git a/docs/images/grafana-response-time-rps.png b/docs/images/grafana-response-time-rps.png new file mode 100644 index 000000000..f8be17296 Binary files /dev/null and b/docs/images/grafana-response-time-rps.png differ diff --git a/k6/common.js b/k6/common.js new file mode 100644 index 000000000..cf57f708d --- /dev/null +++ b/k6/common.js @@ -0,0 +1,35 @@ +import { check } from 'k6'; + +export const BASE_URL = __ENV.BASE_URL || 'http://localhost:8080'; + +export const defaultOptions = { + scenarios: { + load_test: { + executor: 'ramping-arrival-rate', + startRate: 10, + timeUnit: '1s', + preAllocatedVUs: 50, + maxVUs: 300, + stages: [ + { duration: '10s', target: 50 }, // Warm-up + { duration: '20s', target: 200 }, // Ramp-up + { duration: '30s', target: 200 }, // Peak + { duration: '10s', target: 10 }, // Cool-down + ], + }, + }, + thresholds: { + http_req_duration: ['p(95)<500', 'p(99)<1000'], + http_req_failed: ['rate<0.01'], + }, +}; + +export function checkResponse(res, name) { + check(res, { + [`${name} status 200`]: (r) => r.status === 200, + [`${name} has data`]: (r) => { + const body = JSON.parse(r.body); + return body.meta && body.meta.result === 'SUCCESS'; + }, + }); +} diff --git a/k6/product-detail.js b/k6/product-detail.js new file mode 100644 index 000000000..f73cb93cc --- /dev/null +++ b/k6/product-detail.js @@ -0,0 +1,21 @@ +import http from 'k6/http'; +import { BASE_URL, defaultOptions, checkResponse } from './common.js'; + +export const options = { + ...defaultOptions, + thresholds: { + ...defaultOptions.thresholds, + http_req_duration: ['p(95)<100', 'p(99)<200'], + }, +}; + +// 1~100 범위의 상품 ID를 랜덤 조회 (시딩 데이터 기준) +const MAX_PRODUCT_ID = __ENV.MAX_PRODUCT_ID ? parseInt(__ENV.MAX_PRODUCT_ID) : 100; + +export default function () { + const productId = Math.floor(Math.random() * MAX_PRODUCT_ID) + 1; + const url = `${BASE_URL}/api/v1/products/${productId}`; + + const res = http.get(url); + checkResponse(res, 'product-detail'); +} diff --git a/k6/product-list-benchmark.js b/k6/product-list-benchmark.js new file mode 100644 index 000000000..0ada2bf33 --- /dev/null +++ b/k6/product-list-benchmark.js @@ -0,0 +1,43 @@ +import http from 'k6/http'; +import { check } from 'k6'; + +const BASE_URL = __ENV.BASE_URL || 'http://localhost:8080'; + +export const options = { + scenarios: { + load_test: { + executor: 'constant-arrival-rate', + rate: 100, + timeUnit: '1s', + duration: '1m', + preAllocatedVUs: 50, + maxVUs: 200, + }, + }, + thresholds: { + http_req_duration: ['p(95)<500'], + http_req_failed: ['rate<0.01'], + }, +}; + +const sorts = ['latest', 'price_asc', 'likes_desc']; + +export default function () { + const sort = sorts[Math.floor(Math.random() * sorts.length)]; + const page = Math.floor(Math.random() * 5); // 0~4 페이지 + const endpoint = __ENV.ENDPOINT || ''; + const url = `${BASE_URL}/api/v1/products${endpoint}?sort=${sort}&page=${page}&size=20`; + + const res = http.get(url); + check(res, { + 'status 200': (r) => r.status === 200, + 'has data': (r) => { + try { + const body = JSON.parse(r.body); + return body.meta && body.meta.result === 'SUCCESS'; + } catch (e) { + return false; + } + }, + }); +} diff --git a/k6/product-list-no-cache.js b/k6/product-list-no-cache.js new file mode 100644 index 000000000..0c656e2c4 --- /dev/null +++ b/k6/product-list-no-cache.js @@ -0,0 +1,21 @@ +import http from 'k6/http'; +import { BASE_URL, defaultOptions, checkResponse } from './common.js'; + +export const options = { + ...defaultOptions, + thresholds: { + ...defaultOptions.thresholds, + http_req_duration: ['p(95)<300', 'p(99)<500'], // 인덱스만 사용, 캐시 없음 + }, +}; + +const sorts = ['latest', 'price_asc', 'likes_desc']; + +export default function () { + const sort = sorts[Math.floor(Math.random() * sorts.length)]; + const page = Math.floor(Math.random() * 5); + const url = `${BASE_URL}/api/v1/products/no-cache?sort=${sort}&page=${page}&size=20`; + + const res = http.get(url); + checkResponse(res, 'no-cache-list'); +} diff --git a/k6/product-list-no-optimization.js b/k6/product-list-no-optimization.js new file mode 100644 index 000000000..7bbcf629e --- /dev/null +++ b/k6/product-list-no-optimization.js @@ -0,0 +1,20 @@ +import http from 'k6/http'; +import { BASE_URL, defaultOptions, checkResponse } from './common.js'; + +export const options = { + ...defaultOptions, + thresholds: { + ...defaultOptions.thresholds, + http_req_duration: ['p(95)<2000', 'p(99)<5000'], // AS-IS: 전량 로딩 + COUNT + in-memory sort + }, +}; + +const sorts = ['latest', 'price_asc', 'likes_desc']; + +export default function () { + const sort = sorts[Math.floor(Math.random() * sorts.length)]; + const url = `${BASE_URL}/api/v1/products/no-optimization?sort=${sort}`; + + const res = http.get(url); + checkResponse(res, 'no-optimization-list'); +} diff --git a/k6/product-list-optimized.js b/k6/product-list-optimized.js new file mode 100644 index 000000000..3cca8d658 --- /dev/null +++ b/k6/product-list-optimized.js @@ -0,0 +1,21 @@ +import http from 'k6/http'; +import { BASE_URL, defaultOptions, checkResponse } from './common.js'; + +export const options = { + ...defaultOptions, + thresholds: { + ...defaultOptions.thresholds, + http_req_duration: ['p(95)<100', 'p(99)<200'], // 캐시 적용 시 더 빠른 응답 기대 + }, +}; + +const sorts = ['latest', 'price_asc', 'likes_desc']; + +export default function () { + const sort = sorts[Math.floor(Math.random() * sorts.length)]; + const page = Math.floor(Math.random() * 5); // 0~4 페이지 + const url = `${BASE_URL}/api/v1/products?sort=${sort}&page=${page}&size=20`; + + const res = http.get(url); + checkResponse(res, 'optimized-list'); +}