From eea5ff9ebe69e5dc89b2d62b634ed551ccfd2a4e Mon Sep 17 00:00:00 2001 From: ghtjr410 Date: Mon, 9 Mar 2026 07:46:14 +0900 Subject: [PATCH 1/5] =?UTF-8?q?fix:=20=EC=9E=AC=EA=B3=A0=20=EC=B0=A8?= =?UTF-8?q?=EA=B0=90=20=EC=8B=A4=ED=8C=A8=20=EC=8B=9C=20=EB=B6=80=EC=A1=B1?= =?UTF-8?q?=ED=95=9C=20=EC=83=81=ED=92=88=20ID=EB=A5=BC=20=EC=98=88?= =?UTF-8?q?=EC=99=B8=20=EB=A9=94=EC=8B=9C=EC=A7=80=EC=97=90=20=ED=8F=AC?= =?UTF-8?q?=ED=95=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/loopers/application/product/ProductService.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java index 65d8d89a4..b6354e66f 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java @@ -68,7 +68,7 @@ public void decreaseStocks(Map productQuantities) { productId, productQuantities.get(productId) ); if (updated == 0) { - throw new CoreException(ErrorType.BAD_REQUEST, "재고가 부족합니다"); + throw new CoreException(ErrorType.BAD_REQUEST, "상품(id: " + productId + ")의 재고가 부족합니다"); } } } From e0ec50cacb12cbf79ba61f2dc78c889cf0c629c9 Mon Sep 17 00:00:00 2001 From: ghtjr410 Date: Fri, 13 Mar 2026 12:17:23 +0900 Subject: [PATCH 2/5] =?UTF-8?q?perf:=20=EC=83=81=ED=92=88=C2=B7=EC=A3=BC?= =?UTF-8?q?=EB=AC=B8=20=EC=9D=B8=EB=8D=B1=EC=8A=A4=20=EC=84=A4=EA=B3=84=20?= =?UTF-8?q?=EB=B0=8F=20=EC=A1=B0=ED=9A=8C=20=EC=BF=BC=EB=A6=AC=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/order/OrderFacade.java | 17 +- .../loopers/application/order/OrderInfo.java | 7 + .../application/order/OrderService.java | 19 +- .../java/com/loopers/domain/order/Order.java | 42 +- .../com/loopers/domain/order/OrderItem.java | 5 +- .../loopers/domain/order/OrderRepository.java | 8 +- .../com/loopers/domain/order/OrderStatus.java | 9 + .../com/loopers/domain/product/Product.java | 12 +- .../order/OrderJpaRepository.java | 28 +- .../order/OrderRepositoryImpl.java | 19 +- .../api/order/OrderAdminApiV1Spec.java | 8 + .../api/order/OrderAdminV1Controller.java | 12 +- .../interfaces/api/order/OrderAdminV1Dto.java | 5 + .../interfaces/api/order/OrderRequest.java | 25 +- .../api/order/OrderV1Controller.java | 2 +- .../interfaces/api/order/OrderV1Dto.java | 5 + .../order/OrderServiceIntegrationTest.java | 4 +- docs/performance/order/explain-analysis.sql | 120 +++++ docs/performance/order/index-analysis.md | 380 ++++++++++++++++ docs/performance/order/seed-orders.sql | 166 +++++++ docs/performance/product/explain-analysis.sql | 91 ++++ docs/performance/product/index-analysis.md | 421 ++++++++++++++++++ docs/performance/product/seed-products.sql | 65 +++ 23 files changed, 1440 insertions(+), 30 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatus.java create mode 100644 docs/performance/order/explain-analysis.sql create mode 100644 docs/performance/order/index-analysis.md create mode 100644 docs/performance/order/seed-orders.sql create mode 100644 docs/performance/product/explain-analysis.sql create mode 100644 docs/performance/product/index-analysis.md create mode 100644 docs/performance/product/seed-products.sql diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java index 96fa3b24d..d3f25a512 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java @@ -5,6 +5,7 @@ import com.loopers.application.product.ProductService; import com.loopers.domain.order.Order; import com.loopers.domain.product.Product; +import com.loopers.domain.order.OrderStatus; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; @@ -72,8 +73,8 @@ public OrderInfo getOrderDetail(Long userId, Long orderId) { } @Transactional(readOnly = true) - public Page getOrderList(Long userId, ZonedDateTime startDateTime, ZonedDateTime endDateTime, Pageable pageable) { - Page orders = orderService.findOrdersByUserIdAndDateRange(userId, startDateTime, endDateTime, pageable); + public Page getOrderList(Long userId, OrderStatus status, ZonedDateTime startDateTime, ZonedDateTime endDateTime, Pageable pageable) { + Page orders = orderService.findOrdersByUserIdAndStatusAndDateRange(userId, status, startDateTime, endDateTime, pageable); return orders.map(OrderInfo.OrderSummary::from); } @@ -84,8 +85,16 @@ public OrderInfo getAdminOrderDetail(Long orderId) { } @Transactional(readOnly = true) - public Page getAdminOrderList(Pageable pageable) { - Page orders = orderService.findAllOrders(pageable); + public Page getAdminOrderList(OrderStatus status, Pageable pageable) { + Page orders = (status != null) + ? orderService.findOrdersByStatus(status, pageable) + : orderService.findAllOrders(pageable); + return orders.map(OrderInfo.OrderAdminSummary::from); + } + + @Transactional(readOnly = true) + public Page getAdminOrdersByProduct(Long productId, Pageable pageable) { + Page orders = orderService.findOrdersByProductId(productId, pageable); return orders.map(OrderInfo.OrderAdminSummary::from); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java index 324b16d33..87e2141c4 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java @@ -2,6 +2,7 @@ import com.loopers.domain.order.Order; import com.loopers.domain.order.OrderItem; +import com.loopers.domain.order.OrderStatus; import java.math.BigDecimal; import java.time.LocalDateTime; @@ -10,6 +11,7 @@ public record OrderInfo( Long id, Long userId, + OrderStatus status, BigDecimal totalAmount, BigDecimal discountAmount, BigDecimal finalAmount, @@ -44,6 +46,7 @@ public static OrderInfo from(Order order) { return new OrderInfo( order.getId(), order.getUserId(), + order.getStatus(), order.getTotalAmount(), order.getDiscountAmount(), order.getFinalAmount(), @@ -55,6 +58,7 @@ public static OrderInfo from(Order order) { public record OrderSummary( Long id, + OrderStatus status, BigDecimal totalAmount, BigDecimal discountAmount, BigDecimal finalAmount, @@ -65,6 +69,7 @@ public record OrderSummary( public static OrderSummary from(Order order) { return new OrderSummary( order.getId(), + order.getStatus(), order.getTotalAmount(), order.getDiscountAmount(), order.getFinalAmount(), @@ -77,6 +82,7 @@ public static OrderSummary from(Order order) { public record OrderAdminSummary( Long id, Long userId, + OrderStatus status, BigDecimal totalAmount, BigDecimal discountAmount, BigDecimal finalAmount, @@ -88,6 +94,7 @@ public static OrderAdminSummary from(Order order) { return new OrderAdminSummary( order.getId(), order.getUserId(), + order.getStatus(), order.getTotalAmount(), order.getDiscountAmount(), order.getFinalAmount(), diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderService.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderService.java index 209bf04d8..5fe077cd8 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderService.java @@ -2,6 +2,7 @@ import com.loopers.domain.order.Order; import com.loopers.domain.order.OrderRepository; +import com.loopers.domain.order.OrderStatus; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; @@ -52,12 +53,22 @@ public Order getOrder(Long orderId) { } @Transactional(readOnly = true) - public Page findOrdersByUserIdAndDateRange(Long userId, ZonedDateTime startDate, ZonedDateTime endDate, Pageable pageable) { - return orderRepository.findAllByUserIdAndCreatedAtBetween(userId, startDate, endDate, pageable); + public Page findAllOrders(Pageable pageable) { + return orderRepository.findAll(pageable); } @Transactional(readOnly = true) - public Page findAllOrders(Pageable pageable) { - return orderRepository.findAll(pageable); + public Page findOrdersByUserIdAndStatusAndDateRange(Long userId, OrderStatus status, ZonedDateTime startDate, ZonedDateTime endDate, Pageable pageable) { + return orderRepository.findAllByUserIdAndStatusAndCreatedAtBetween(userId, status, startDate, endDate, pageable); + } + + @Transactional(readOnly = true) + public Page findOrdersByStatus(OrderStatus status, Pageable pageable) { + return orderRepository.findAllByStatus(status, pageable); + } + + @Transactional(readOnly = true) + public Page findOrdersByProductId(Long productId, Pageable pageable) { + return orderRepository.findAllByProductId(productId, pageable); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java index 1cdb400ac..3641684ae 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java @@ -5,10 +5,13 @@ import jakarta.persistence.CascadeType; import jakarta.persistence.Column; import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; import jakarta.persistence.OneToMany; +import jakarta.persistence.Index; import jakarta.persistence.PrePersist; import jakarta.persistence.Table; import lombok.Getter; @@ -19,7 +22,11 @@ import java.util.List; @Entity -@Table(name = "orders") +@Table(name = "orders", indexes = { + @Index(name = "idx_orders_user_status_created", columnList = "user_id, status, created_at DESC"), + @Index(name = "idx_orders_user_created", columnList = "user_id, created_at DESC"), + @Index(name = "idx_orders_status_created", columnList = "status, created_at DESC") +}) @Getter public class Order { @@ -41,6 +48,10 @@ public class Order { @Column(name = "final_amount", nullable = false, precision = 15, scale = 2) private BigDecimal finalAmount; + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 20) + private OrderStatus status; + @Column(name = "issued_coupon_id") private Long issuedCouponId; @@ -57,6 +68,7 @@ public static Order create(Long userId) { validateUserId(userId); Order order = new Order(); order.userId = userId; + order.status = OrderStatus.PENDING; order.totalAmount = BigDecimal.ZERO; order.discountAmount = BigDecimal.ZERO; order.finalAmount = BigDecimal.ZERO; @@ -81,6 +93,34 @@ public void applyCoupon(Long issuedCouponId, BigDecimal discountAmount) { this.finalAmount = this.totalAmount.subtract(discountAmount).max(BigDecimal.ZERO); } + public void pay() { + if (this.status != OrderStatus.PENDING) { + throw new CoreException(ErrorType.BAD_REQUEST, "결제는 PENDING 상태에서만 가능합니다"); + } + this.status = OrderStatus.PAID; + } + + public void ship() { + if (this.status != OrderStatus.PAID) { + throw new CoreException(ErrorType.BAD_REQUEST, "배송은 PAID 상태에서만 가능합니다"); + } + this.status = OrderStatus.SHIPPING; + } + + public void deliver() { + if (this.status != OrderStatus.SHIPPING) { + throw new CoreException(ErrorType.BAD_REQUEST, "배송완료는 SHIPPING 상태에서만 가능합니다"); + } + this.status = OrderStatus.DELIVERED; + } + + public void cancel() { + if (this.status == OrderStatus.DELIVERED) { + throw new CoreException(ErrorType.BAD_REQUEST, "배송완료된 주문은 취소할 수 없습니다"); + } + this.status = OrderStatus.CANCELLED; + } + @PrePersist protected void onCreate() { this.createdAt = ZonedDateTime.now(); diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java index 7f20e87d0..89c63f9c6 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java @@ -10,6 +10,7 @@ import jakarta.persistence.Id; import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; +import jakarta.persistence.Index; import jakarta.persistence.PrePersist; import jakarta.persistence.Table; import lombok.Getter; @@ -18,7 +19,9 @@ import java.time.ZonedDateTime; @Entity -@Table(name = "order_item") +@Table(name = "order_item", indexes = { + @Index(name = "idx_order_item_product", columnList = "product_id, order_id") +}) @Getter public class OrderItem { diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java index c221a90da..78e5960c3 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java @@ -14,7 +14,11 @@ public interface OrderRepository { // Query Optional findByIdWithItems(Long id); - Page findAllByUserIdAndCreatedAtBetween(Long userId, ZonedDateTime startDate, ZonedDateTime endDate, Pageable pageable); - Page findAll(Pageable pageable); + + Page findAllByUserIdAndStatusAndCreatedAtBetween(Long userId, OrderStatus status, ZonedDateTime startDate, ZonedDateTime endDate, Pageable pageable); + + Page findAllByStatus(OrderStatus status, Pageable pageable); + + Page findAllByProductId(Long productId, Pageable pageable); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatus.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatus.java new file mode 100644 index 000000000..dfaeda506 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatus.java @@ -0,0 +1,9 @@ +package com.loopers.domain.order; + +public enum OrderStatus { + PENDING, + PAID, + SHIPPING, + DELIVERED, + CANCELLED +} 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 426a13093..f04f67528 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 @@ -5,6 +5,7 @@ import com.loopers.support.error.ErrorType; import jakarta.persistence.Column; import jakarta.persistence.Entity; +import jakarta.persistence.Index; import jakarta.persistence.Table; import jakarta.persistence.Version; import lombok.Getter; @@ -12,7 +13,16 @@ import java.math.BigDecimal; @Entity -@Table(name = "products") +@Table(name = "products", indexes = { + // 핵심 인덱스: 브랜드 필터 + 정렬 + @Index(name = "idx_products_brand_created", columnList = "deleted_at, brand_id, created_at DESC"), + @Index(name = "idx_products_brand_price", columnList = "deleted_at, brand_id, price"), + @Index(name = "idx_products_brand_likes", columnList = "deleted_at, brand_id, like_count DESC"), + // 방어 인덱스: 브랜드 필터 없는 전체 조회 (캐시 미스 대비) + @Index(name = "idx_products_created_only", columnList = "deleted_at, created_at DESC"), + @Index(name = "idx_products_likes_only", columnList = "deleted_at, like_count DESC"), + @Index(name = "idx_products_price_only", columnList = "deleted_at, price") +}) @Getter public class Product extends BaseEntity { diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java index 656b4b04d..20165b0c7 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java @@ -1,6 +1,7 @@ package com.loopers.infrastructure.order; import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderStatus; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; @@ -16,19 +17,32 @@ public interface OrderJpaRepository extends JpaRepository { @Query("SELECT o FROM Order o JOIN FETCH o.orderItems WHERE o.id = :id") Optional findByIdWithItems(@Param("id") Long id); + @Query(value = "SELECT o FROM Order o ORDER BY o.createdAt DESC", + countQuery = "SELECT COUNT(o) FROM Order o") + Page findAllByOrderByCreatedAtDesc(Pageable pageable); + @Query(value = "SELECT o FROM Order o WHERE o.userId = :userId " + + "AND (:status IS NULL OR o.status = :status) " + "AND (:startDate IS NULL OR o.createdAt >= :startDate) " + "AND (:endDate IS NULL OR o.createdAt < :endDate) " + "ORDER BY o.createdAt DESC", countQuery = "SELECT COUNT(o) FROM Order o WHERE o.userId = :userId " + + "AND (:status IS NULL OR o.status = :status) " + "AND (:startDate IS NULL OR o.createdAt >= :startDate) " + "AND (:endDate IS NULL OR o.createdAt < :endDate)") - Page findAllByUserIdAndCreatedAtBetween(@Param("userId") Long userId, - @Param("startDate") ZonedDateTime startDate, - @Param("endDate") ZonedDateTime endDate, - Pageable pageable); + Page findAllByUserIdAndStatusAndCreatedAtBetween(@Param("userId") Long userId, + @Param("status") OrderStatus status, + @Param("startDate") ZonedDateTime startDate, + @Param("endDate") ZonedDateTime endDate, + Pageable pageable); - @Query(value = "SELECT o FROM Order o ORDER BY o.createdAt DESC", - countQuery = "SELECT COUNT(o) FROM Order o") - Page findAllByOrderByCreatedAtDesc(Pageable pageable); + @Query(value = "SELECT o FROM Order o WHERE o.status = :status ORDER BY o.createdAt DESC", + countQuery = "SELECT COUNT(o) FROM Order o WHERE o.status = :status") + Page findAllByStatus(@Param("status") OrderStatus status, Pageable pageable); + + @Query(value = "SELECT DISTINCT o FROM Order o JOIN o.orderItems oi " + + "WHERE oi.productId = :productId ORDER BY o.createdAt DESC", + countQuery = "SELECT COUNT(DISTINCT o) FROM Order o JOIN o.orderItems oi " + + "WHERE oi.productId = :productId") + Page findAllByProductId(@Param("productId") Long productId, Pageable pageable); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java index 0b17be85d..d2d57ef4d 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java @@ -2,6 +2,7 @@ import com.loopers.domain.order.Order; import com.loopers.domain.order.OrderRepository; +import com.loopers.domain.order.OrderStatus; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -29,12 +30,22 @@ public Optional findByIdWithItems(Long id) { } @Override - public Page findAllByUserIdAndCreatedAtBetween(Long userId, ZonedDateTime startDate, ZonedDateTime endDate, Pageable pageable) { - return orderJpaRepository.findAllByUserIdAndCreatedAtBetween(userId, startDate, endDate, pageable); + public Page findAll(Pageable pageable) { + return orderJpaRepository.findAllByOrderByCreatedAtDesc(pageable); } @Override - public Page findAll(Pageable pageable) { - return orderJpaRepository.findAllByOrderByCreatedAtDesc(pageable); + public Page findAllByUserIdAndStatusAndCreatedAtBetween(Long userId, OrderStatus status, ZonedDateTime startDate, ZonedDateTime endDate, Pageable pageable) { + return orderJpaRepository.findAllByUserIdAndStatusAndCreatedAtBetween(userId, status, startDate, endDate, pageable); + } + + @Override + public Page findAllByStatus(OrderStatus status, Pageable pageable) { + return orderJpaRepository.findAllByStatus(status, pageable); + } + + @Override + public Page findAllByProductId(Long productId, Pageable pageable) { + return orderJpaRepository.findAllByProductId(productId, pageable); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderAdminApiV1Spec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderAdminApiV1Spec.java index b0b3aaa6b..ffe35f8e8 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderAdminApiV1Spec.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderAdminApiV1Spec.java @@ -23,4 +23,12 @@ ApiResponse> list( description = "특정 주문의 상세 정보를 조회합니다." ) ApiResponse detail(Long orderId); + + @Operation( + summary = "상품별 주문 목록 조회 (Admin)", + description = "특정 상품이 포함된 주문을 최신순으로 페이징 조회합니다." + ) + ApiResponse> listByProduct( + OrderRequest.ListByProduct request + ); } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderAdminV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderAdminV1Controller.java index 029a78591..5fa778cf5 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderAdminV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderAdminV1Controller.java @@ -25,7 +25,7 @@ public class OrderAdminV1Controller implements OrderAdminApiV1Spec { @Override public ApiResponse> list( @Valid OrderRequest.ListAll request) { - Page orders = orderFacade.getAdminOrderList(request.toPageable()); + Page orders = orderFacade.getAdminOrderList(request.status(), request.toPageable()); PageResponse pageResponse = PageResponse.from(orders, OrderAdminV1Dto.OrderListResponse::from); return ApiResponse.success(pageResponse); @@ -37,4 +37,14 @@ public ApiResponse detail(@PathVariable Long orde OrderInfo info = orderFacade.getAdminOrderDetail(orderId); return ApiResponse.success(OrderAdminV1Dto.OrderResponse.from(info)); } + + @GetMapping("/by-product") + @Override + public ApiResponse> listByProduct( + @Valid OrderRequest.ListByProduct request) { + Page orders = orderFacade.getAdminOrdersByProduct(request.productId(), request.toPageable()); + PageResponse pageResponse = + PageResponse.from(orders, OrderAdminV1Dto.OrderListResponse::from); + return ApiResponse.success(pageResponse); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderAdminV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderAdminV1Dto.java index f137635b9..f53acbd40 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderAdminV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderAdminV1Dto.java @@ -1,6 +1,7 @@ package com.loopers.interfaces.api.order; import com.loopers.application.order.OrderInfo; +import com.loopers.domain.order.OrderStatus; import java.math.BigDecimal; import java.time.LocalDateTime; @@ -13,6 +14,7 @@ public class OrderAdminV1Dto { public record OrderResponse( Long id, Long userId, + OrderStatus status, BigDecimal totalAmount, BigDecimal discountAmount, BigDecimal finalAmount, @@ -28,6 +30,7 @@ public static OrderResponse from(OrderInfo info) { return new OrderResponse( info.id(), info.userId(), + info.status(), info.totalAmount(), info.discountAmount(), info.finalAmount(), @@ -60,6 +63,7 @@ public static OrderItemResponse from(OrderInfo.OrderItemInfo item) { public record OrderListResponse( Long id, Long userId, + OrderStatus status, BigDecimal totalAmount, BigDecimal discountAmount, BigDecimal finalAmount, @@ -71,6 +75,7 @@ public static OrderListResponse from(OrderInfo.OrderAdminSummary summary) { return new OrderListResponse( summary.id(), summary.userId(), + summary.status(), summary.totalAmount(), summary.discountAmount(), summary.finalAmount(), diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderRequest.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderRequest.java index ff1805d03..57073be1d 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderRequest.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderRequest.java @@ -1,6 +1,7 @@ package com.loopers.interfaces.api.order; import com.loopers.application.order.OrderCommand; +import com.loopers.domain.order.OrderStatus; import jakarta.validation.Valid; import jakarta.validation.constraints.AssertTrue; import jakarta.validation.constraints.Max; @@ -10,6 +11,7 @@ import jakarta.validation.constraints.Size; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; import java.time.LocalDate; import java.time.ZoneId; @@ -54,6 +56,7 @@ public record PlaceItem( // Query public record ListByUser( + OrderStatus status, LocalDate startDate, LocalDate endDate, @PositiveOrZero(message = "페이지 번호는 0 이상이어야 합니다") Integer page, @@ -72,7 +75,7 @@ public boolean isStartDateBeforeEndDate() { } public Pageable toPageable() { - return PageRequest.of(page, size); + return PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt")); } public ZonedDateTime startDateTime() { @@ -87,6 +90,7 @@ public ZonedDateTime endDateTime() { } public record ListAll( + OrderStatus status, @PositiveOrZero(message = "페이지 번호는 0 이상이어야 합니다") Integer page, @Min(value = 1, message = "페이지 크기는 1 이상이어야 합니다") @Max(value = 100, message = "페이지 크기는 100 이하여야 합니다") Integer size @@ -97,7 +101,24 @@ public record ListAll( } public Pageable toPageable() { - return PageRequest.of(page, size); + return PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt")); + } + } + + public record ListByProduct( + @NotNull(message = "상품 ID는 필수입니다") + Long productId, + @PositiveOrZero(message = "페이지 번호는 0 이상이어야 합니다") Integer page, + @Min(value = 1, message = "페이지 크기는 1 이상이어야 합니다") + @Max(value = 100, message = "페이지 크기는 100 이하여야 합니다") Integer size + ) { + public ListByProduct { + page = Objects.requireNonNullElse(page, 0); + size = Objects.requireNonNullElse(size, 20); + } + + public Pageable toPageable() { + return PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt")); } } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java index 74fa18425..aff812ea9 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java @@ -51,7 +51,7 @@ public ApiResponse> listOrders( @AuthUser AuthenticatedUser user, @Valid OrderRequest.ListByUser request) { Page orders = orderFacade.getOrderList( - user.id(), request.startDateTime(), request.endDateTime(), request.toPageable()); + user.id(), request.status(), request.startDateTime(), request.endDateTime(), request.toPageable()); PageResponse pageResponse = PageResponse.from(orders, OrderV1Dto.OrderListResponse::from); return ApiResponse.success(pageResponse); } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java index d30c0437c..ae297cfcd 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java @@ -1,6 +1,7 @@ package com.loopers.interfaces.api.order; import com.loopers.application.order.OrderInfo; +import com.loopers.domain.order.OrderStatus; import java.math.BigDecimal; import java.time.LocalDateTime; @@ -12,6 +13,7 @@ public class OrderV1Dto { public record OrderResponse( Long id, + OrderStatus status, BigDecimal totalAmount, BigDecimal discountAmount, BigDecimal finalAmount, @@ -26,6 +28,7 @@ public static OrderResponse from(OrderInfo info) { .toList(); return new OrderResponse( info.id(), + info.status(), info.totalAmount(), info.discountAmount(), info.finalAmount(), @@ -57,6 +60,7 @@ public static OrderItemResponse from(OrderInfo.OrderItemInfo item) { public record OrderListResponse( Long id, + OrderStatus status, BigDecimal totalAmount, BigDecimal discountAmount, BigDecimal finalAmount, @@ -67,6 +71,7 @@ public record OrderListResponse( public static OrderListResponse from(OrderInfo.OrderSummary summary) { return new OrderListResponse( summary.id(), + summary.status(), summary.totalAmount(), summary.discountAmount(), summary.finalAmount(), diff --git a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderServiceIntegrationTest.java index 7b96d8191..1c535f14b 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderServiceIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderServiceIntegrationTest.java @@ -120,7 +120,7 @@ class 주문_목록_조회 { OrderCommand.CreateItem.of(3L, "바지", new BigDecimal("40000"), 1) ))); - Page result = orderService.findOrdersByUserIdAndDateRange(1L, null, null, PageRequest.of(0, 20)); + Page result = orderService.findOrdersByUserIdAndStatusAndDateRange(1L, null, null, null, PageRequest.of(0, 20)); assertThat(result.getContent()).hasSize(2); assertThat(result.getContent()).allMatch(order -> order.getUserId().equals(1L)); @@ -134,7 +134,7 @@ class 주문_목록_조회 { ))); } - Page result = orderService.findOrdersByUserIdAndDateRange(1L, null, null, PageRequest.of(0, 2)); + Page result = orderService.findOrdersByUserIdAndStatusAndDateRange(1L, null, null, null, PageRequest.of(0, 2)); assertThat(result.getContent()).hasSize(2); assertThat(result.getTotalElements()).isEqualTo(5); diff --git a/docs/performance/order/explain-analysis.sql b/docs/performance/order/explain-analysis.sql new file mode 100644 index 000000000..9820b7063 --- /dev/null +++ b/docs/performance/order/explain-analysis.sql @@ -0,0 +1,120 @@ +-- ============================================================= +-- 주문 도메인 EXPLAIN 분석 — 9가지 시나리오 +-- ============================================================= +-- 환경: MySQL 8.0, orders 500,000건, order_item 1,500,000건 +-- 상태 분포: DELIVERED 54%, CANCELLED 29%, SHIPPING 9%, PAID 5%, PENDING 2% +-- 유저 10,000명 (파레토), 상품 5,000개 +-- +-- 인덱스: +-- idx_orders_user_status_created (user_id, status, created_at DESC) +-- idx_orders_user_created (user_id, created_at DESC) ← 방어 +-- idx_orders_status_created (status, created_at DESC) +-- idx_order_item_product (product_id, order_id) ← covering +-- ============================================================= + + +-- ===================== +-- AS-IS: 인덱스 없는 상태 (기준선) +-- ===================== +-- 실행 전 인덱스 제거: +-- DROP INDEX idx_orders_user_status_created ON orders; +-- DROP INDEX idx_orders_user_created ON orders; +-- DROP INDEX idx_orders_status_created ON orders; +-- DROP INDEX idx_order_item_product ON order_item; + +-- [시나리오 1] 사용자 주문 목록 (인덱스 없음) +EXPLAIN SELECT * FROM orders + WHERE user_id = 50 AND status = 'SHIPPING' + AND created_at BETWEEN '2026-03-01' AND '2026-03-13' + ORDER BY created_at DESC LIMIT 20; +-- 실측: type=ALL | key=NULL | rows=498,012 | filtered=0.11% | Using where; Using filesort + +-- [시나리오 2] 관리자 상태별 주문 목록 (인덱스 없음) +EXPLAIN SELECT * FROM orders + WHERE status = 'SHIPPING' + ORDER BY created_at DESC LIMIT 20; +-- 실측: type=ALL | key=NULL | rows=498,012 | filtered=10.0% | Using where; Using filesort + +-- [시나리오 3] 상품별 주문 내역 (인덱스 없음) +EXPLAIN SELECT o.* FROM orders o + JOIN order_item oi ON o.id = oi.order_id + WHERE oi.product_id = 100 + ORDER BY o.created_at DESC LIMIT 20; +-- 실측: oi → type=ALL | key=NULL | rows=1,492,800 | Using where; Using temporary; Using filesort +-- o → type=eq_ref | key=PRIMARY | rows=1 + + +-- ===================== +-- TO-BE: 인덱스 적용 후 +-- ===================== +-- 인덱스 복구: +-- CREATE INDEX idx_orders_user_status_created ON orders(user_id, status, created_at DESC); +-- CREATE INDEX idx_orders_user_created ON orders(user_id, created_at DESC); +-- CREATE INDEX idx_orders_status_created ON orders(status, created_at DESC); +-- CREATE INDEX idx_order_item_product ON order_item(product_id, order_id); + +-- [시나리오 4] 사용자 주문 — status 있음 (인덱스 최적) +EXPLAIN SELECT * FROM orders + WHERE user_id = 50 AND status = 'SHIPPING' + AND created_at BETWEEN '2026-03-01' AND '2026-03-13' + ORDER BY created_at DESC LIMIT 20; +-- 실측: type=range | key=idx_orders_user_status_created | rows=17 | filtered=100.0% | Using index condition + +-- [시나리오 5] 사용자 주문 — status 없음, 후보 B만 (중간 컬럼 skip) +-- idx_orders_user_created를 DROP한 상태에서 실행 +EXPLAIN SELECT * FROM orders + WHERE user_id = 50 + AND created_at BETWEEN '2026-03-01' AND '2026-03-13' + ORDER BY created_at DESC LIMIT 20; +-- 실측: type=ref | key=idx_orders_user_status_created | ref=const | rows=1,535 | filtered=11.11% | Using index condition; Using filesort +-- 분석: status 컬럼을 건너뛰므로 created_at 정렬 불가 → filesort 발생 + +-- [시나리오 6] 사용자 주문 — status 없음, 후보 A+B (방어 인덱스) +-- idx_orders_user_created 있는 상태에서 실행 +EXPLAIN SELECT * FROM orders + WHERE user_id = 50 + AND created_at BETWEEN '2026-03-01' AND '2026-03-13' + ORDER BY created_at DESC LIMIT 20; +-- 실측: type=range | key=idx_orders_user_created | rows=186 | filtered=100.0% | Using index condition +-- 분석: 옵티마이저가 후보 A 인덱스를 자동 선택, filesort 제거 + + +-- ===================== +-- 데이터 분포 영향: 상태별 성능 양극화 +-- ===================== + +-- [시나리오 7] 관리자 SHIPPING (소수 상태 — 46,673건, 9%) +EXPLAIN SELECT * FROM orders + WHERE status = 'SHIPPING' + ORDER BY created_at DESC LIMIT 20; +-- 실측: type=ref | key=idx_orders_status_created | ref=const | rows=90,062 | filtered=100.0% +-- 참고: EXPLAIN rows는 옵티마이저 추정값이며 실제 건수(46,673)와 차이 있음 + +-- [시나리오 8] 관리자 DELIVERED (다수 상태 — 272,052건, 54%) +EXPLAIN SELECT * FROM orders + WHERE status = 'DELIVERED' + ORDER BY created_at DESC LIMIT 20; +-- 실측: type=ref | key=idx_orders_status_created | ref=const | rows=249,006 | filtered=100.0% +-- 분석: 같은 인덱스인데 SHIPPING 대비 rows 3배. 데이터 분포가 인덱스 효과를 좌우함. +-- 단, LIMIT 20 + 인덱스 정렬 순서 보장 → Early Termination으로 실제 20건만 읽고 중단. + + +-- ===================== +-- Covering Index 확인 +-- ===================== + +-- [시나리오 9] 상품별 주문 내역 (covering index) +EXPLAIN SELECT o.* FROM orders o + JOIN order_item oi ON o.id = oi.order_id + WHERE oi.product_id = 100 + ORDER BY o.created_at DESC LIMIT 20; +-- 실측: oi → type=ref | key=idx_order_item_product | rows=324 | Using index; Using temporary; Using filesort +-- o → type=eq_ref | key=PRIMARY | rows=1 +-- 분석: order_item은 Using index(covering) → 테이블 접근 없이 인덱스만으로 처리 +-- orders의 created_at 정렬은 두 테이블 간 정렬이라 인덱스로 해결 불가 → filesort 잔존 +-- 324건 filesort는 실행 시간에 거의 영향 없으므로 수용 + +-- Covering index 단독 확인 +EXPLAIN SELECT oi.order_id FROM order_item oi WHERE oi.product_id = 100; +-- 실측: type=ref | key=idx_order_item_product | rows=324 | Extra=Using index +-- 분석: (product_id, order_id) 인덱스에 order_id가 포함되어 테이블 접근 불필요 \ No newline at end of file diff --git a/docs/performance/order/index-analysis.md b/docs/performance/order/index-analysis.md new file mode 100644 index 000000000..40aa56534 --- /dev/null +++ b/docs/performance/order/index-analysis.md @@ -0,0 +1,380 @@ +# 주문 도메인 -인덱스 분석 보고서 + +## TL;DR + +주문 도메인에서 3가지 조회 패턴(사용자 주문 목록, 관리자 상태별 목록, 상품별 주문 내역)에 대해 +인덱스를 설계하고 EXPLAIN 전후 비교를 수행했다. +50만 주문 + 150만 아이템 기준, **498,012건 풀스캔 → 17건 인덱스 스캔(API 1)**으로 극적 개선을 확인했다. +특히 **같은 인덱스에서 데이터 분포(SHIPPING 9만 vs DELIVERED 25만)에 따라 성능이 3배 차이** 나는 것을 실측으로 증명했다. + +--- + +## 1. 분석 대상 + +### 비즈니스 맥락 + +주문 데이터는 다양한 주체가 다양한 조건으로 조회한다. + +- **고객**: "내 주문 확인해주세요" → user_id + 기간 + 상태 필터 +- **관리자**: "배송중인 주문 보여줘" → status 필터 + 최신순 정렬 +- **비즈니스 분석가**: "이 상품 얼마나 팔렸어?" → product_id 기반 JOIN 조회 + +각 패턴은 서로 다른 인덱스 트레이드오프를 보여준다. + +| API | 핵심 포인트 | +|-----|-----------| +| 1. 사용자 주문 목록 | 등호 vs 범위 조건의 인덱스 순서 | +| 2. 관리자 상태별 목록 | 데이터 분포가 인덱스 효과에 미치는 영향 | +| 3. 상품별 주문 내역 | JOIN에서의 covering index 활용 | + +### 테이블 구조 + +```sql +CREATE TABLE orders ( + id BIGINT NOT NULL AUTO_INCREMENT, + user_id BIGINT NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'PENDING', + total_amount DECIMAL(15,2) NOT NULL, + discount_amount DECIMAL(15,2) NOT NULL, + final_amount DECIMAL(15,2) NOT NULL, + issued_coupon_id BIGINT DEFAULT NULL, + created_at DATETIME(6) NOT NULL, + PRIMARY KEY (id) +); + +CREATE TABLE order_item ( + id BIGINT NOT NULL AUTO_INCREMENT, + order_id BIGINT NOT NULL, + product_id BIGINT NOT NULL, + product_name VARCHAR(255) NOT NULL, + price DECIMAL(12,2) NOT NULL, + quantity INT NOT NULL, + created_at DATETIME(6) NOT NULL, + PRIMARY KEY (id), + KEY FKt4dc2r9nbvbujrljv3e23iibt (order_id), + FOREIGN KEY (order_id) REFERENCES orders (id) +); +``` + +### 데이터셋 -현업 유사 분포 + +| 항목 | 값 | 비고 | +|------|-----|------| +| 총 주문 | 500,000건 | 중소 커머스 1년 분량 | +| 총 주문 아이템 | 1,500,000건 | 주문당 3개 고정 | +| 유저 | 10,000명 | 파레토 분포 | +| 상품 | 5,000개 | - | + +#### 상태별 분포 (핵심) + +| status | 건수 | 비율 | 시기 | +|--------|------|------|------| +| DELIVERED | 272,052 | 54% | 2주 전 ~ 1년 전 | +| CANCELLED | 146,807 | 29% | 전 기간 | +| SHIPPING | 46,673 | 9% | 최근 2주 | +| PAID | 24,566 | 5% | 최근 2~3일 | +| PENDING | 9,902 | 2% | 최근 수시간 | + +**완료/취소 상태가 전체의 83%**, 진행중(SHIPPING+PAID+PENDING)이 16%. +이 분포가 API 2에서 같은 인덱스의 성능이 극단적으로 달라지는 원인이 된다. + +#### 유저별 주문 분포 (파레토) + +| 주문 수 | 유저 수 | 비고 | +|---------|---------|------| +| 200건 이상 | 982명 | 상위 헤비 유저 | +| 50~199건 | 18명 | - | +| 10~49건 | 8,493명 | 대다수 | +| 1~9건 | 507명 | 저빈도 유저 | + +--- + +## 2. AS-IS: 인덱스 없는 상태 + +```sql +-- [API 1] 사용자 주문 목록 +EXPLAIN SELECT * FROM orders +WHERE user_id = 50 AND status = 'SHIPPING' +AND created_at BETWEEN '2026-03-01' AND '2026-03-13' +ORDER BY created_at DESC LIMIT 20; + +-- [API 2] 관리자 상태별 주문 목록 +EXPLAIN SELECT * FROM orders +WHERE status = 'SHIPPING' +ORDER BY created_at DESC LIMIT 20; + +-- [API 3] 상품별 주문 내역 +EXPLAIN SELECT o.* FROM orders o +JOIN order_item oi ON o.id = oi.order_id +WHERE oi.product_id = 100 +ORDER BY o.created_at DESC LIMIT 20; +``` + +| API | table | type | key | rows | filtered | Extra | +|-----|-------|------|-----|------|----------|-------| +| 1 | orders | **ALL** | NULL | **498,012** | 0.11% | Using where; Using filesort | +| 2 | orders | **ALL** | NULL | **498,012** | 10.0% | Using where; Using filesort | +| 3 | oi | **ALL** | NULL | **1,492,800** | 10.0% | Using where; Using temporary; Using filesort | +| 3 | o | eq_ref | PRIMARY | 1 | 100.0% | - | + +### 문제점 + +- **API 1**: 50만건 풀스캔, filtered=0.11% → 99.89%를 읽고 버린다. user_id 인덱스 자체가 없음. +- **API 2**: 50만건 풀스캔 + filesort. +- **API 3**: order_item **149만건 풀스캔** + temporary table + filesort. 가장 심각. + +--- + +## 3. 인덱스 설계 + +### 적용한 인덱스 3개 + +```sql +-- API 1: 사용자 주문 목록 +CREATE INDEX idx_orders_user_status_created ON orders(user_id, status, created_at DESC); + +-- API 2: 관리자 상태별 주문 목록 +CREATE INDEX idx_orders_status_created ON orders(status, created_at DESC); + +-- API 3: 상품별 주문 내역 +CREATE INDEX idx_order_item_product ON order_item(product_id, order_id); +``` + +### API 1 인덱스 설계 근거 -등호 vs 범위 순서 + +쿼리 구조: +``` +WHERE user_id = 50 → 등호 + AND status = 'SHIPPING' → 등호 + AND created_at BETWEEN ... → 범위 +ORDER BY created_at DESC → 정렬 +``` + +| 후보 | 컬럼 | 특징 | +|------|------|------| +| A | (user_id, created_at DESC) | 기간만 필터 시 최적, status 필터 시 비효율 | +| **B (채택)** | (user_id, status, created_at DESC) | 등호 2개 + 범위/정렬 마지막 | + +**핵심 원칙: 등호 조건을 앞에, 범위/정렬을 뒤에.** +user_id와 status는 등호 조건이므로 인덱스 앞에 배치하면 범위를 최대한 좁힌 후 created_at 정렬이 인덱스 순서와 일치하여 filesort가 불필요하다. + +#### 후보 A vs B 실측 비교 + +| 케이스 | 후보 B만 | 후보 A + B 모두 | +|--------|---------|---------------| +| status 있음 (`WHERE user_id=50 AND status='SHIPPING' AND created_at BETWEEN ...`) | rows=17, filesort 없음 | rows=17, filesort 없음 | +| status 없음 (`WHERE user_id=50 AND created_at BETWEEN ...`) | rows=1,535, **filesort 있음** | rows=186, **filesort 없음** | + +status 있을 때는 후보 B가 최적(rows=17). 하지만 status 없이 "내 전체 주문 보기"를 할 때, **후보 B만으로는 중간 컬럼(status) skip으로 filesort가 발생**한다(rows=1,535). + +후보 A를 추가하면 옵티마이저가 상황에 따라 최적 인덱스를 자동 선택한다. +"전체 주문 보기(status 없음)"는 기본 화면에서 자주 사용되는 패턴이고, 헤비 유저(200건 이상 982명)에서는 filesort 비용이 커질 수 있으므로 **후보 A도 방어 인덱스로 유지**한다. + +이는 상품 인덱스에서 "캐시 미스 대비 방어 인덱스"를 추가한 판단과 동일한 맥락이다. + +만약 status 없이 `WHERE user_id = 50 AND created_at BETWEEN ...`만 오는 경우에도, +`(user_id, status, created_at)` 인덱스는 user_id까지는 타고 들어가므로 풀스캔보다는 낫다. +다만 status를 건너뛰면 created_at 정렬은 인덱스로 처리할 수 없어 filesort가 발생한다. +(상품 인덱스에서 확인한 **중간 컬럼 skip** 문제와 동일) + +실제로 "내 전체 주문 보기(status 필터 없음)"는 기본 화면에서 자주 사용되는 패턴이다. 이 경우 `(user_id, created_at DESC)` 후보 A가 더 적합할 수 있으나, 해당 유저의 주문 수가 일반적으로 50건 미만이므로 filesort 비용이 미미하다고 판단하여 현재는 후보 B로 통합했다. + +> **상품 인덱스와의 연결**: 상품에서는 `IN` 조건이 없어 WHERE 선두가 정석이었지만, +> 주문에서 `status IN ('SHIPPING', 'PAID')` 같은 다중 상태 조회가 필요해지면 +> IN이 정렬을 깨뜨리는 문제가 발생한다. 이 경우 정렬 선두 인덱스를 검토해야 한다. + +### API 2 인덱스 설계 근거 -단순하지만 데이터 분포가 핵심 + +``` +WHERE status = ? +ORDER BY created_at DESC +``` + +`(status, created_at DESC)` -등호 필터 + 정렬 순서 일치. 설계 자체는 단순하다. +**이 인덱스의 진짜 분석 포인트는 데이터 분포에 있다.** (섹션 5에서 상세 분석) + +### API 3 인덱스 설계 근거 -covering index + +``` +SELECT o.* FROM orders o +JOIN order_item oi ON o.id = oi.order_id +WHERE oi.product_id = 100 +ORDER BY o.created_at DESC +``` + +| 후보 | 컬럼 (order_item) | 특징 | +|------|-------------------|------| +| A | (product_id) | 기본. product_id로 필터 후 order_id를 테이블에서 읽음 | +| **B (채택)** | (product_id, order_id) | covering index. order_id까지 인덱스에 포함 → 테이블 접근 불필요 | + +`(product_id, order_id)` 인덱스는 JOIN에 필요한 order_id까지 인덱스에 포함하므로, +order_item **테이블에 접근하지 않고 인덱스만으로** JOIN 키를 제공할 수 있다. +EXPLAIN의 `Using index`가 이를 확인해준다. + +--- + +## 4. TO-BE: 인덱스 적용 후 + +| API | type | key | rows | filtered | Extra | +|-----|------|-----|------|----------|-------| +| 1 | **range** | idx_orders_user_status_created | **17** | 100.0% | Using index condition | +| 2 (SHIPPING) | **ref** | idx_orders_status_created | **90,062** | 100.0% | - | +| 3 (oi) | **ref** | idx_order_item_product | **324** | 100.0% | **Using index**; Using temporary; Using filesort | +| 3 (o) | eq_ref | PRIMARY | 1 | 100.0% | - | + +### Before/After 비교 + +| API | Before rows | After rows | 개선율 | filesort | +|-----|------------|-----------|--------|----------| +| 1. 사용자 주문 | 498,012 | **17** | **29,295x** | 제거 ✅ | +| 2. 관리자 상태별 | 498,012 | 90,062 | 5.5x | 제거 ✅ | +| 3. 상품별 주문 (oi) | 1,492,800 | **324** | **4,608x** | 유지 (orders 정렬) | + +### API별 분석 + +**API 1**: `rows=17`로 가장 극적인 개선. user_id + status + created_at 범위 세 조건이 모두 인덱스를 타서 정확히 필요한 행만 스캔한다. filesort도 제거. + +**API 2**: 인덱스를 타지만 `rows=90,062`로 여전히 많다. 이는 SHIPPING 상태의 데이터가 9만건이기 때문. 인덱스 문제가 아니라 **데이터 분포 문제**다. (다음 섹션에서 상세 분석) + +**API 3**: order_item은 `rows=324, Using index`로 최적. 하지만 **orders 테이블의 created_at 정렬** 때문에 `Using temporary; Using filesort`가 남아있다. 324건의 filesort이므로 실질적 성능 영향은 미미하나, 인기 상품(수만건)에서는 체감될 수 있다. + +--- + +## 5. 데이터 분포가 인덱스 효과에 미치는 영향 (API 2) + +### 같은 인덱스, 극단적으로 다른 성능 + +```sql +EXPLAIN SELECT * FROM orders WHERE status = 'SHIPPING' ORDER BY created_at DESC LIMIT 20; +EXPLAIN SELECT * FROM orders WHERE status = 'DELIVERED' ORDER BY created_at DESC LIMIT 20; +``` + +| status | 건수 | 비율 | EXPLAIN rows | +|--------|------|------|-------------| +| SHIPPING | 46,673 | 9% | **90,062** | +| DELIVERED | 272,052 | 54% | **249,006** | + +> **참고**: EXPLAIN의 rows는 MySQL 옵티마이저의 **추정값**이므로 실제 건수(46,673 / 272,052)와 차이가 있다. 이는 통계 정보 기반 추정이기 때문이며, 추세(3배 차이)는 정확히 반영된다. + +**같은 인덱스 `(status, created_at DESC)`를 타는데, rows가 3배 차이가 난다.** + +### 왜 이런 차이가 발생하는가? + +인덱스는 status = 'SHIPPING'인 행들의 범위를 빠르게 찾아준다. +하지만 **그 범위 안의 데이터 수가 status마다 다르다.** + +- SHIPPING: 4만 6천건 → 인덱스가 좁혀주는 범위가 작아 효율적 +- DELIVERED: 27만건 → 인덱스가 좁혀봤자 절반이 넘음 + +### 실무적 시사점 + +관리자가 "배송중(SHIPPING)" 주문을 조회할 때는 빠르지만, +"배달완료(DELIVERED)" 주문을 조회할 때는 느릴 수 있다. + +이것이 멘토가 강조한 **"데이터셋을 현업과 유사하게 만드는 것이 중요하다"**의 의미다. +균등 분포로 테스트하면 이 차이를 발견할 수 없다. +완료/취소가 압도적으로 많은 현실적 분포에서만 이 문제가 드러난다. + +### 해결 방향 + +| 대안 | 설명 | 적합한 경우 | +|------|------|-----------| +| A. 현재 유지 | DELIVERED 조회 시 rows가 크지만 LIMIT 20이면 인덱스 순서대로 20건만 읽고 끝 | 대부분의 경우 충분 | +| B. 기간 필터 추가 | `WHERE status = 'DELIVERED' AND created_at > '2026-01-01'` | 관리자가 전체가 아닌 최근만 볼 때 | +| C. 캐시 | 관리자 페이지는 동일 결과를 반복 조회할 가능성 높음 | 조회 빈도가 높을 때 | + +현재는 **A로 충분**하다고 판단했다. `(status, created_at DESC)` 인덱스가 정렬 순서를 보장하므로, LIMIT 20과 결합하면 DELIVERED 27만건을 전부 스캔하지 않고 **앞에서 20건만 읽고 멈춘다(Early Termination).** 인덱스 자체가 created_at DESC로 이미 정렬된 상태이므로, MySQL은 인덱스를 순서대로 탐색하다가 20건이 채워지는 순간 즉시 중단한다. EXPLAIN의 rows=249,006은 **해당 status의 전체 추정 행 수**이며, LIMIT과 결합한 실제 스캔 행 수와는 다르다. + +--- + +## 6. Covering Index 효과 (API 3) + +### (product_id) vs (product_id, order_id) + +```sql +-- covering index 확인 +EXPLAIN SELECT oi.order_id FROM order_item oi WHERE oi.product_id = 100; +-- rows=324, Extra=Using index +``` + +`Using index`는 **인덱스만으로 쿼리를 완전히 처리**했다는 의미다. +order_item 테이블의 실제 데이터 페이지에 접근하지 않고, 인덱스 B-Tree만 읽어 order_id를 반환한다. + +만약 `(product_id)` 단일 인덱스였다면, product_id로 필터 후 **각 행의 order_id를 가져오기 위해 테이블을 다시 읽어야** 한다. 이 추가 I/O가 건수가 많을수록 성능 차이를 만든다. + +--- + +## 7. 전체 EXPLAIN 요약 + +| # | 시나리오 | type | key | rows | filesort | 비고 | +|---|---------|------|-----|------|----------|------| +| 1 | 사용자 주문 (인덱스 없음) | ALL | NULL | 498,012 | O | Before | +| 2 | 관리자 상태별 (인덱스 없음) | ALL | NULL | 498,012 | O | Before | +| 3 | 상품별 주문 (인덱스 없음) | ALL | NULL | 1,492,800 | O | Before | +| 4 | 사용자 주문 -status 있음 (인덱스 적용) | range | idx_user_status_created | **17** | X | **29,295x 개선** | +| 5 | 사용자 주문 -status 없음, 후보 B만 | ref | idx_user_status_created | 1,535 | O | **중간 컬럼 skip → filesort** | +| 6 | 사용자 주문 -status 없음, 후보 A+B | range | idx_user_created | **186** | X | **방어 인덱스로 filesort 제거** | +| 7 | 관리자 SHIPPING (인덱스 적용) | ref | idx_status_created | 90,062 | X | 5.5x 개선 | +| 8 | 관리자 DELIVERED (인덱스 적용) | ref | idx_status_created | 249,006 | X | **분포 영향: SHIPPING의 3배** | +| 9 | 상품별 주문 (인덱스 적용) | ref | idx_item_product | **324** | O (orders) | covering index + 4,608x 개선 | + +--- + +## 8. 트레이드오프 정리 + +### 인덱스 4개 설계의 장단점 + +| 장점 | 단점 | +|------|------| +| 3가지 조회 패턴 + status 유무 모두 커버 | 인덱스 4개 유지 비용 | +| API 1에서 rows 29,295배 감소 | API 2에서 DELIVERED 조회 시 rows가 여전히 큼 | +| status 없는 기본 화면에서도 filesort 제거 | API 3에서 orders 정렬 filesort 잔존 | +| Covering index로 order_item 테이블 접근 제거 | - | + +### API 3의 filesort 잔존 -왜 허용했는가? + +order_item에서 product_id로 필터한 결과(324건)를 orders.created_at으로 정렬하는데, +이 **두 테이블 간 정렬**은 단일 인덱스로 해결할 수 없다. +324건의 filesort는 실행 시간에 거의 영향을 주지 않으며, +이를 해결하려면 비정규화(order_item에 created_at 중복 저장) 등 구조 변경이 필요하다. +현재 규모에서는 **비용 대비 이득이 적다**고 판단하여 허용했다. + +### 주문은 캐시 미적용 -인덱스가 유일한 방어선 + +상품과 달리 주문은 **개인 데이터**이므로 캐시 히트율이 구조적으로 낮다. +"같은 유저가 같은 조건으로 반복 조회"하는 빈도가 낮기 때문이다. +따라서 현재 주문 조회는 **인덱스가 유일한 성능 방어선**이며, +상품처럼 "캐시 + 방어 인덱스" 이중 안전망 구조가 아닌 인덱스 단독으로 성능을 보장해야 한다. + +### 향후 개선 가능 사항 + +- **API 3 filesort 제거**: 현재 단일 JOIN 쿼리를 **2단계 쿼리(ID 추출 → ID IN FETCH)**로 분리하면 temporary table을 제거할 수 있다. 1단계에서 covering index로 order_id만 추출하고, 2단계에서 PK IN 조회로 orders를 가져오면 JOIN 없이 처리 가능. 324건에서는 체감 차이가 적으나, 인기 상품(수만건)에서는 유의미한 개선이 기대된다. +- `status IN ('SHIPPING', 'PAID')` 같은 다중 상태 조회가 필요해지면, IN이 정렬을 깨뜨리는 문제 발생. **정렬 선두 인덱스** 검토 필요. +- 인기 상품(order_item 수만건)의 API 3 성능이 문제되면, 비정규화 또는 Pre-aggregation 검토. +- OFFSET 기반 deep page 문제 발생 시 Cursor 전환 검토. 다만 관리자 페이지는 "N페이지로 점프" UX가 필요하므로 OFFSET 유지가 적합할 수 있다. + +--- + +## 9. 최종 인덱스 DDL + +```sql +-- API 1: 사용자 주문 목록 -status 있을 때 (등호 + 등호 + 범위/정렬) +CREATE INDEX idx_orders_user_status_created ON orders(user_id, status, created_at DESC); + +-- API 1: 사용자 주문 목록 -status 없을 때 (방어 인덱스) +CREATE INDEX idx_orders_user_created ON orders(user_id, created_at DESC); + +-- API 2: 관리자 상태별 주문 목록 (등호 + 정렬) +CREATE INDEX idx_orders_status_created ON orders(status, created_at DESC); + +-- API 3: 상품별 주문 내역 (covering index) +CREATE INDEX idx_order_item_product ON order_item(product_id, order_id); +``` + +### 핵심 학습 포인트 + +1. **등호 vs 범위 순서**: 등호 조건(user_id, status)을 앞에, 범위/정렬(created_at)을 뒤에 배치해야 인덱스 정렬이 동작한다. +2. **데이터 분포의 영향**: 같은 인덱스라도 조건값의 데이터 양에 따라 성능이 극단적으로 달라진다. 현업 유사 분포로 테스트해야 이 차이를 발견할 수 있다. +3. **Covering index**: JOIN에 필요한 컬럼까지 인덱스에 포함하면 테이블 접근을 제거할 수 있다. +4. **IN 조건과 정렬의 충돌**: 단일 등호는 문제없지만, IN(multiple equality)이 들어오면 정렬이 깨질 수 있다. 이 경우 정렬 선두 인덱스를 검토해야 한다. +5. **filesort 허용 판단**: 모든 filesort를 제거하는 것이 목표가 아니다. 소량(324건)의 filesort는 감수하고, 구조 변경 비용 대비 이득을 판단하는 것이 실무적 접근이다. \ No newline at end of file diff --git a/docs/performance/order/seed-orders.sql b/docs/performance/order/seed-orders.sql new file mode 100644 index 000000000..cb7ddb92c --- /dev/null +++ b/docs/performance/order/seed-orders.sql @@ -0,0 +1,166 @@ +-- ============================================================= +-- 주문 성능 테스트용 시드 데이터 +-- 주문 500,000건, 주문 아이템 ~1,500,000건 +-- ============================================================= +-- 사전 조건: products 테이블에 상품 데이터가 있어야 합니다. +-- (seed-products.sql 실행 후 사용) +-- 이 스크립트는 Docker 로컬 환경(MySQL 8.0)에서 실행합니다. + +-- ============================================================= +-- 1. 주문 500,000건 생성 +-- ============================================================= +-- 유저: 10,000명 (user_id: 1~10,000) +-- 상태별 분포: +-- DELIVERED 65% (325,000건) - 2주전 ~ 1년전 +-- CANCELLED 25% (125,000건) - 전 기간 +-- SHIPPING 5% (25,000건) - 최근 2주 +-- PAID 3% (15,000건) - 최근 2~3일 +-- PENDING 2% (10,000건) - 최근 수시간 +-- +-- 유저별 주문 분포 (파레토): +-- 상위 1% (100명): 200~500건 +-- 상위 10% (1,000명): 50~200건 +-- 나머지 (9,000명): 1~50건 + +DROP PROCEDURE IF EXISTS seed_orders; + +DELIMITER // +CREATE PROCEDURE seed_orders() +BEGIN + DECLARE i INT DEFAULT 0; + DECLARE batch_size INT DEFAULT 1000; + DECLARE total INT DEFAULT 500000; + DECLARE user_count INT DEFAULT 10000; + + WHILE i < total DO + INSERT INTO orders (user_id, status, total_amount, discount_amount, final_amount, issued_coupon_id, created_at) + SELECT + -- 파레토 분포: 상위 1%가 많은 주문 + CASE + WHEN RAND() < 0.30 THEN FLOOR(RAND() * 100) + 1 -- 상위 1% (100명) → 30% 주문 + WHEN RAND() < 0.60 THEN FLOOR(RAND() * 900) + 101 -- 상위 10% (900명) → 30% 주문 + ELSE FLOOR(RAND() * 9000) + 1001 -- 나머지 (9,000명) → 40% 주문 + END AS user_id, + -- 상태별 분포 + CASE + WHEN RAND() < 0.02 THEN 'PENDING' + WHEN RAND() < 0.05 THEN 'PAID' + WHEN RAND() < 0.10 THEN 'SHIPPING' + WHEN RAND() < 0.35 THEN 'CANCELLED' + ELSE 'DELIVERED' + END AS status, + ROUND(10000 + RAND() * 490000, 2) AS total_amount, + ROUND(RAND() * 50000, 2) AS discount_amount, + ROUND(10000 + RAND() * 440000, 2) AS final_amount, + IF(RAND() < 0.3, FLOOR(RAND() * 1000) + 1, NULL) AS issued_coupon_id, + -- 시간대별 분포 + CASE + WHEN RAND() < 0.02 THEN DATE_SUB(NOW(), INTERVAL FLOOR(RAND() * 6) HOUR) -- PENDING: 최근 수시간 + WHEN RAND() < 0.05 THEN DATE_SUB(NOW(), INTERVAL FLOOR(RAND() * 3) DAY) -- PAID: 최근 2~3일 + WHEN RAND() < 0.10 THEN DATE_SUB(NOW(), INTERVAL FLOOR(RAND() * 14) DAY) -- SHIPPING: 최근 2주 + ELSE DATE_SUB(NOW(), INTERVAL FLOOR(14 + RAND() * 351) DAY) -- DELIVERED/CANCELLED: 2주~1년전 + END AS created_at + FROM ( + SELECT @rownum := @rownum + 1 AS seq + FROM information_schema.columns a + CROSS JOIN information_schema.columns b + CROSS JOIN (SELECT @rownum := 0) r + LIMIT 1000 + ) t; + + SET i = i + batch_size; + END WHILE; +END // +DELIMITER ; + +CALL seed_orders(); +DROP PROCEDURE IF EXISTS seed_orders; + +-- ============================================================= +-- 2. 주문 아이템 생성 (주문당 3개 고정, 배치 INSERT) +-- ============================================================= +-- CURSOR 방식은 50만건 × 서브쿼리로 수시간 소요. +-- 배치 INSERT로 orders.id 범위를 1000건씩 잡아 한 번에 3000건 INSERT. +-- 50만 주문 × 3개 = 150만건, 10분 이내 목표. + +DROP PROCEDURE IF EXISTS seed_order_items; + +DELIMITER // +CREATE PROCEDURE seed_order_items() +BEGIN + DECLARE v_min_id BIGINT; + DECLARE v_max_id BIGINT; + DECLARE v_current BIGINT; + DECLARE v_batch_end BIGINT; + DECLARE batch_size INT DEFAULT 1000; + DECLARE product_count INT DEFAULT 5000; + + SELECT MIN(id), MAX(id) INTO v_min_id, v_max_id FROM orders; + SET v_current = v_min_id; + + WHILE v_current <= v_max_id DO + SET v_batch_end = LEAST(v_current + batch_size - 1, v_max_id); + + -- 주문당 아이템 3개를 한 번에 INSERT (아이템 1번) + INSERT INTO order_item (order_id, product_id, product_name, price, quantity, created_at) + SELECT + o.id, + FLOOR(1 + RAND() * product_count), + CONCAT('Product_', LPAD(FLOOR(1 + RAND() * product_count), 6, '0')), + ROUND(1000 + RAND() * 99000, 2), + FLOOR(1 + RAND() * 5), + o.created_at + FROM orders o + WHERE o.id BETWEEN v_current AND v_batch_end; + + -- 아이템 2번 + INSERT INTO order_item (order_id, product_id, product_name, price, quantity, created_at) + SELECT + o.id, + FLOOR(1 + RAND() * product_count), + CONCAT('Product_', LPAD(FLOOR(1 + RAND() * product_count), 6, '0')), + ROUND(1000 + RAND() * 99000, 2), + FLOOR(1 + RAND() * 5), + o.created_at + FROM orders o + WHERE o.id BETWEEN v_current AND v_batch_end; + + -- 아이템 3번 + INSERT INTO order_item (order_id, product_id, product_name, price, quantity, created_at) + SELECT + o.id, + FLOOR(1 + RAND() * product_count), + CONCAT('Product_', LPAD(FLOOR(1 + RAND() * product_count), 6, '0')), + ROUND(1000 + RAND() * 99000, 2), + FLOOR(1 + RAND() * 5), + o.created_at + FROM orders o + WHERE o.id BETWEEN v_current AND v_batch_end; + + SET v_current = v_current + batch_size; + END WHILE; +END // +DELIMITER ; + +CALL seed_order_items(); +DROP PROCEDURE IF EXISTS seed_order_items; + +-- ============================================================= +-- 3. 확인 쿼리 +-- ============================================================= +SELECT COUNT(*) AS total_orders FROM orders; +SELECT status, COUNT(*) AS cnt FROM orders GROUP BY status ORDER BY cnt DESC; +SELECT COUNT(*) AS total_order_items FROM order_item; + +-- 유저별 주문 수 분포 +SELECT + CASE + WHEN cnt >= 200 THEN '200+' + WHEN cnt >= 50 THEN '50~199' + WHEN cnt >= 10 THEN '10~49' + ELSE '1~9' + END AS order_range, + COUNT(*) AS user_count +FROM (SELECT user_id, COUNT(*) AS cnt FROM orders GROUP BY user_id) t +GROUP BY order_range +ORDER BY MIN(cnt) DESC; diff --git a/docs/performance/product/explain-analysis.sql b/docs/performance/product/explain-analysis.sql new file mode 100644 index 000000000..5bbe7ccf2 --- /dev/null +++ b/docs/performance/product/explain-analysis.sql @@ -0,0 +1,91 @@ +-- ============================================================= +-- 상품 목록 조회 EXPLAIN 분석 +-- ============================================================= +-- 인덱스: +-- idx_products_brand_created : (deleted_at, brand_id, created_at DESC) +-- idx_products_brand_price : (deleted_at, brand_id, price) +-- idx_products_brand_likes : (deleted_at, brand_id, like_count DESC) + +-- ============================================================= +-- 1. 최신순 정렬 (RECENT) - brandId 필터 없음 +-- ============================================================= +EXPLAIN +SELECT * +FROM products +WHERE deleted_at IS NULL +ORDER BY created_at DESC +LIMIT 20 OFFSET 0; + +-- ============================================================= +-- 2. 최신순 정렬 (RECENT) - brandId 필터 있음 +-- 기대: idx_products_brand_created 사용, filesort 없음 +-- ============================================================= +EXPLAIN +SELECT * +FROM products +WHERE deleted_at IS NULL + AND brand_id = 1 +ORDER BY created_at DESC +LIMIT 20 OFFSET 0; + +-- ============================================================= +-- 3. 가격 오름차순 정렬 (PRICE_ASC) - brandId 필터 없음 +-- ============================================================= +EXPLAIN +SELECT * +FROM products +WHERE deleted_at IS NULL +ORDER BY price ASC +LIMIT 20 OFFSET 0; + +-- ============================================================= +-- 4. 가격 오름차순 정렬 (PRICE_ASC) - brandId 필터 있음 +-- 기대: idx_products_brand_price 사용, filesort 없음 +-- ============================================================= +EXPLAIN +SELECT * +FROM products +WHERE deleted_at IS NULL + AND brand_id = 1 +ORDER BY price ASC +LIMIT 20 OFFSET 0; + +-- ============================================================= +-- 5. 좋아요 내림차순 정렬 (LIKES_DESC) - brandId 필터 없음 +-- ============================================================= +EXPLAIN +SELECT * +FROM products +WHERE deleted_at IS NULL +ORDER BY like_count DESC +LIMIT 20 OFFSET 0; + +-- ============================================================= +-- 6. 좋아요 내림차순 정렬 (LIKES_DESC) - brandId 필터 있음 +-- 기대: idx_products_brand_likes 사용, filesort 없음 +-- ============================================================= +EXPLAIN +SELECT * +FROM products +WHERE deleted_at IS NULL + AND brand_id = 1 +ORDER BY like_count DESC +LIMIT 20 OFFSET 0; + +-- ============================================================= +-- 7. COUNT 쿼리 (페이징용) +-- ============================================================= +EXPLAIN +SELECT COUNT(*) +FROM products +WHERE deleted_at IS NULL + AND brand_id = 1; + +-- ============================================================= +-- 8. 단건 상세 조회 (PK) +-- 기대: PRIMARY KEY 사용 (const) +-- ============================================================= +EXPLAIN +SELECT * +FROM products +WHERE id = 1; diff --git a/docs/performance/product/index-analysis.md b/docs/performance/product/index-analysis.md new file mode 100644 index 000000000..82af13d19 --- /dev/null +++ b/docs/performance/product/index-analysis.md @@ -0,0 +1,421 @@ +# 상품 목록 조회 -인덱스 분석 보고서 + +## TL;DR + +상품 목록 조회에서 브랜드 필터 + 정렬(좋아요순/가격순/최신순) 시나리오에 대해 +복합 인덱스 3개를 설계하고 EXPLAIN 전후 비교를 수행했다. +10만 건 기준, **풀스캔(99,574건) → 인덱스 스캔(5,000건)으로 약 95% 스캔 감소 + filesort 제거**를 확인했다. +브랜드 필터 없는 전체 조회는 중간 컬럼 skip으로 filesort가 재발생했으며, +처음에는 캐시만으로 해결하려 했으나, **캐시 미스 시나리오를 고려하여 방어 인덱스 3개를 추가**했다. +최종 인덱스 6개 + Redis 캐시의 **이중 안전망** 구조로 설계했다. + +--- + +## 1. 분석 대상 + +### 비즈니스 맥락 + +커머스 서비스에서 가장 빈번한 조회 패턴은 **"브랜드별 상품 목록을 특정 기준으로 정렬해서 보여주는 것"**이다. +사용자는 다음과 같은 방식으로 상품을 조회한다. + +- 브랜드 A의 상품을 좋아요 많은 순으로 보기 +- 브랜드 A의 상품을 가격 낮은 순으로 보기 +- 브랜드 A의 상품을 최신순으로 보기 +- 전체 상품을 좋아요순/최신순/가격순으로 보기 (메인 홈) + +모든 조회에는 soft delete 필터(`deleted_at IS NULL`)가 공통으로 적용된다. + +### 테이블 구조 + +```sql +CREATE TABLE products ( + id BIGINT NOT NULL AUTO_INCREMENT, + brand_id BIGINT NOT NULL, + name VARCHAR(200) NOT NULL, + price DECIMAL(12,2) NOT NULL, + stock_quantity INT NOT NULL, + like_count INT NOT NULL, + description VARCHAR(1000) DEFAULT NULL, + version BIGINT DEFAULT NULL, + deleted_at DATETIME(6) DEFAULT NULL, + created_at DATETIME(6) NOT NULL, + updated_at DATETIME(6) NOT NULL, + PRIMARY KEY (id) +); +``` + +### 데이터셋 + +| 항목 | 값 | 비고 | +|------|-----|------| +| 총 상품 수 | 100,000건 | 과제 요구사항 충족 | +| 브랜드 수 | 20개 | 브랜드당 약 5,000건 균등 분포 | +| price | 1,000 ~ 500,000 | 랜덤 분포 | +| like_count | 0 ~ 5,000 | 랜덤 분포 | +| deleted_at | 전부 NULL | soft delete 미적용 상태 | + +--- + +## 2. AS-IS: 인덱스 없는 상태 (기준선) + +### 시나리오 1~2: 브랜드 필터 + 정렬 (인덱스 없음) + +```sql +-- [1] 브랜드 + 좋아요순 +EXPLAIN SELECT * FROM products +WHERE deleted_at IS NULL AND brand_id = 1 +ORDER BY like_count DESC LIMIT 20; + +-- [2] 브랜드 + 가격순 +EXPLAIN SELECT * FROM products +WHERE deleted_at IS NULL AND brand_id = 1 +ORDER BY price ASC LIMIT 20; +``` + +| 시나리오 | type | key | rows | filtered | Extra | +|---------|------|-----|------|----------|-------| +| [1] 브랜드+좋아요순 | **ALL** | NULL | **99,574** | 1.0% | Using where; **Using filesort** | +| [2] 브랜드+가격순 | **ALL** | NULL | **99,574** | 1.0% | Using where; **Using filesort** | + +### 문제점 + +- **type=ALL**: 10만 건 전체를 풀스캔한다. 인덱스를 전혀 사용하지 않는다. +- **rows=99,574**: brand_id = 1인 데이터는 5,000건인데, 99,574건을 모두 스캔해야 찾을 수 있다. +- **filtered=1.0%**: 99%의 행을 읽고 버린다. +- **Using filesort**: ORDER BY를 인덱스로 처리하지 못하고, 결과를 메모리/디스크에 올려 별도 정렬을 수행한다. +- 트래픽이 증가하면 스캔 행 수에 비례해 **선형적으로 느려진다.** + +--- + +## 3. 인덱스 설계 + +### 적용한 복합 인덱스 3개 + +```sql +CREATE INDEX idx_products_brand_created ON products(deleted_at, brand_id, created_at DESC); +CREATE INDEX idx_products_brand_price ON products(deleted_at, brand_id, price); +CREATE INDEX idx_products_brand_likes ON products(deleted_at, brand_id, like_count DESC); +``` + +### 왜 이 순서인가? + +복합 인덱스의 컬럼 순서는 쿼리 구조에 의해 결정된다. + +``` +WHERE deleted_at IS NULL → 1번째: 등호 조건 (IS NULL도 등호 취급) + AND brand_id = 1 → 2번째: 등호 조건 +ORDER BY like_count DESC → 3번째: 정렬 +``` + +**원칙: 등호 조건을 앞에, 정렬/범위를 뒤에 배치해야 인덱스 정렬이 동작한다.** + +B-Tree 인덱스는 왼쪽부터 순서대로 탐색한다. +등호 조건(deleted_at, brand_id)으로 범위를 좁힌 뒤, 마지막 컬럼(like_count)이 이미 정렬된 상태이므로 별도 정렬(filesort)이 필요 없다. + +### 왜 deleted_at이 선두인가? + +처음에는 **카디널리티가 높은 컬럼을 앞에 두는 것**이 최선이라고 생각했다. +그 논리라면 brand_id(20종)가 deleted_at(NULL/값 2종)보다 앞에 와야 한다. + +하지만 복합 인덱스에서 **등호 조건끼리는 순서가 성능에 거의 영향을 주지 않는다.** +B-Tree에서 `(deleted_at, brand_id)` 든 `(brand_id, deleted_at)` 든 두 레벨을 타고 내려가 같은 지점에 도달하기 때문이다. + +그렇다면 남는 기준은 **재사용성**이다. +`deleted_at IS NULL`은 **모든 조회 쿼리에 공통으로 들어가는 조건**이다. +brand_id 없이 전체 조회하는 경우에도 deleted_at IS NULL은 항상 있으므로, +선두에 두면 인덱스의 첫 번째 컬럼이라도 활용할 수 있다. + +반대로 `(brand_id, deleted_at, ...)` 순서라면 brand_id 조건이 없는 쿼리에서는 인덱스를 아예 타지 못한다. + +### 왜 인덱스를 3개로 나누었는가? + +| 대안 | 설명 | 문제 | +|------|------|------| +| A. 하나의 인덱스로 합침 | `(deleted_at, brand_id, price, like_count, created_at)` | ORDER BY like_count 시 3번째 컬럼(price)을 건너뛸 수 없어 정렬 불가 | +| **B. 정렬 기준별 분리 (채택)** | 정렬 컬럼이 마지막에 위치하도록 3개 | 정렬 기준마다 인덱스 정렬이 동작 | + +복합 인덱스에서 **정렬 컬럼은 반드시 마지막에 위치**해야 한다. +정렬 기준이 3가지(created_at, price, like_count)라면 인덱스도 3개가 필요하다. + +--- + +## 4. TO-BE: 인덱스 적용 후 + +### 시나리오 3~5: 브랜드 필터 + 정렬 (인덱스 최적) + +```sql +-- [3] 브랜드 + 좋아요순 +EXPLAIN SELECT * FROM products +WHERE deleted_at IS NULL AND brand_id = 1 +ORDER BY like_count DESC LIMIT 20; + +-- [4] 브랜드 + 가격순 +EXPLAIN SELECT * FROM products +WHERE deleted_at IS NULL AND brand_id = 1 +ORDER BY price ASC LIMIT 20; + +-- [5] 브랜드 + 최신순 +EXPLAIN SELECT * FROM products +WHERE deleted_at IS NULL AND brand_id = 1 +ORDER BY created_at DESC LIMIT 20; +``` + +| 시나리오 | type | key | ref | rows | filtered | Extra | +|---------|------|-----|-----|------|----------|-------| +| [3] 브랜드+좋아요순 | ref | idx_products_brand_likes | const,const | 5,000 | 100.0% | Using index condition | +| [4] 브랜드+가격순 | ref | idx_products_brand_price | const,const | 5,000 | 100.0% | Using index condition | +| [5] 브랜드+최신순 | ref | idx_products_brand_created | const,const | 5,000 | 100.0% | Using index condition | + +### 개선 포인트 + +- **type=ref**: 등호 조건 2개(deleted_at, brand_id)로 인덱스를 타고 들어간다. +- **ref=const,const**: 두 조건 모두 인덱스의 등호 탐색에 사용되었음을 의미한다. +- **rows=5,000**: 99,574 → 5,000으로 **약 95% 스캔 감소.** +- **filtered=100.0%**: 인덱스가 조건을 완전히 커버하여 불필요한 행을 읽지 않는다. +- **Using filesort 제거**: 인덱스 자체가 정렬 순서를 보장하므로 별도 정렬이 불필요하다. +- 각 정렬 기준에 맞는 인덱스가 자동으로 선택된다 (MySQL 옵티마이저 판단). + +--- + +## 5. 인덱스 효과가 제한되는 케이스 -중간 컬럼 skip + +### 시나리오 6~7: 브랜드 필터 없는 전체 조회 (인덱스 3개 상태) + +```sql +-- [6] 브랜드 없이 최신순 +EXPLAIN SELECT * FROM products +WHERE deleted_at IS NULL +ORDER BY created_at DESC LIMIT 20; + +-- [7] 브랜드 없이 좋아요순 +EXPLAIN SELECT * FROM products +WHERE deleted_at IS NULL +ORDER BY like_count DESC LIMIT 20; +``` + +| 시나리오 | type | key | ref | rows | filtered | Extra | +|---------|------|-----|-----|------|----------|-------| +| [6] 브랜드 없이 최신순 | ref | idx_products_brand_created | const | 49,787 | 100.0% | Using index condition; **Using filesort** | +| [7] 브랜드 없이 좋아요순 | ref | idx_products_brand_created | const | 49,787 | 100.0% | Using index condition; **Using filesort** | + +### 왜 이런 결과가 나오는가? + +- **ref=const 하나만**: deleted_at IS NULL 조건만 인덱스를 탔다. +- brand_id 조건이 없으므로 인덱스의 **두 번째 컬럼(brand_id)을 건너뛴다.** +- 복합 인덱스는 **중간 컬럼을 skip하면 그 뒤 컬럼의 정렬도 인덱스로 처리할 수 없다.** +- 결과: 49,787건 스캔 + **filesort 재발생.** + +### 브랜드 있을 때 vs 없을 때 비교 + +| 조건 | key | ref | rows | filesort | +|------|-----|-----|------|----------| +| brand_id 있음 | idx_brand_likes | const,const | **5,000** | 없음 | +| brand_id 없음 | idx_brand_created | const | **49,787** | **있음** | + +**같은 인덱스인데 중간 컬럼 하나 빠졌을 뿐인데 rows 10배, filesort 발생.** +이것이 복합 인덱스에서 컬럼 순서가 중요한 이유다. + +--- + +## 6. 판단의 전환 -캐시만으로 충분한가? + +### 처음 판단: 캐시만으로 해결하자 + +시나리오 6, 7의 filesort 문제를 확인한 후, 처음에는 **인덱스 추가 없이 캐시만으로 해결**하려 했다. + +| 대안 | 설명 | 장점 | 단점 | +|------|------|------|------| +| A. 인덱스 추가 | `(deleted_at, created_at DESC)` 등 별도 생성 | filesort 제거 | 인덱스 6개로 증가 | +| **B. 캐시로 해결 (처음 채택)** | 전체 조회 결과를 Redis에 캐싱 | DB 자체를 안 타므로 근본 해결 | 캐시 미스 시 무방비 | + +근거는 이랬다. +1. 전체 상품 목록은 모든 유저가 동일한 결과를 본다. 캐시 효율이 높다. +2. 인덱스를 6개로 늘리면 관리 복잡도가 올라간다. +3. 인덱스와 캐시의 역할을 나누는 것이 깔끔하다. + +### 재고: 캐시 미스는 생각보다 자주 발생한다 + +하지만 실무를 고려하면 캐시 미스 시나리오가 적지 않다. + +- **TTL 만료**: 5분마다 주기적으로 발생 +- **상품 수정/등록/삭제**: 무효화로 목록 캐시 전체 삭제 +- **좋아요 변경**: 빈도 높은 이벤트 +- **Redis 장애**: 전체 캐시 유실 +- **배포 시 Redis flush**: 운영 팀 판단에 따라 발생 가능 +- **Redis 메모리 부족**: LRU 정책으로 키가 밀려남 + +캐시가 빠져있는 순간 49,787건 스캔 + filesort가 발생하면, +그 요청들이 DB에 직접 부하를 주게 된다. +캐시 미스가 동시에 몰리면 **cache stampede(캐시 폭주)** 로 이어질 수도 있다. + +### 최종 판단: 방어 인덱스 추가 + +**캐시는 "있으면 좋은 것"이지, "없으면 안 되는 것"이어서는 안 된다.** +캐시가 빠져도 DB가 견딜 수 있는 구조가 실무에서는 더 안전하다. + +인덱스 3개를 추가하면, 캐시 히트 시에는 DB를 안 타고, 캐시 미스 시에도 인덱스가 잡아주는 **이중 안전망**이 된다. + +처음에는 최신순과 좋아요순 2개만 추가하려 했으나, 가격순 전체 조회도 기능으로 존재하는 이상 가격순만 빠뜨리면 **그 하나가 캐시 미스 시 유일한 약점**이 된다. 최신순/좋아요순은 인덱스로 잡아주는데 가격순만 filesort가 발생하면, DB 부하가 특정 정렬에 집중되는 불균형이 생긴다. 방어 인덱스를 두기로 한 이상, **모든 정렬 기준에 일관적으로 적용**하는 것이 맞다고 판단했다. + +--- + +## 7. 방어 인덱스 적용 -filesort 제거 확인 + +### 추가한 인덱스 + +```sql +CREATE INDEX idx_products_created_only ON products(deleted_at, created_at DESC); +CREATE INDEX idx_products_likes_only ON products(deleted_at, like_count DESC); +CREATE INDEX idx_products_price_only ON products(deleted_at, price); +``` + +### 시나리오 9~11: 방어 인덱스 적용 후 + +```sql +-- [9] 브랜드 없이 최신순 (방어 인덱스 적용) +EXPLAIN SELECT * FROM products +WHERE deleted_at IS NULL +ORDER BY created_at DESC LIMIT 20; + +-- [10] 브랜드 없이 좋아요순 (방어 인덱스 적용) +EXPLAIN SELECT * FROM products +WHERE deleted_at IS NULL +ORDER BY like_count DESC LIMIT 20; + +-- [11] 브랜드 없이 가격순 (방어 인덱스 적용) +EXPLAIN SELECT * FROM products +WHERE deleted_at IS NULL +ORDER BY price ASC LIMIT 20; +``` + +| 시나리오 | type | key | ref | rows | filtered | Extra | +|---------|------|-----|-----|------|----------|-------| +| [9] 브랜드 없이 최신순 | range | idx_products_created_only | - | 49,787 | 100.0% | Using index condition | +| [10] 브랜드 없이 좋아요순 | ref | idx_products_likes_only | const | 49,787 | 100.0% | Using index condition | +| [11] 브랜드 없이 가격순 | ref | idx_products_price_only | const | 49,787 | 100.0% | Using index condition | + +### 방어 인덱스 전후 비교 + +| 시나리오 | Before (인덱스 3개) | After (인덱스 6개) | +|---------|-------------------|-------------------| +| 브랜드 없이 최신순 | filesort **있음** | filesort **없음** | +| 브랜드 없이 좋아요순 | filesort **있음** | filesort **없음** | +| 브랜드 없이 가격순 | filesort **있음** | filesort **없음** | + +rows는 EXPLAIN 상 49,787로 표시되지만, 인덱스가 정렬 순서를 보장하므로 +**LIMIT 20과 결합하면 실제로는 앞에서 20건만 읽고 멈춘다.** +filesort가 있을 때는 49,787건을 전부 정렬해야 20건을 뽑을 수 있었으나, +filesort가 없으면 인덱스 순서대로 20건만 스캔하면 끝이다. + +--- + +## 8. 데이터 분포와 rows 차이 + +### 시나리오 8: 브랜드별 rows 비교 + +```sql +EXPLAIN SELECT * FROM products WHERE deleted_at IS NULL AND brand_id = 1 ORDER BY like_count DESC LIMIT 20; +EXPLAIN SELECT * FROM products WHERE deleted_at IS NULL AND brand_id = 10 ORDER BY like_count DESC LIMIT 20; +EXPLAIN SELECT * FROM products WHERE deleted_at IS NULL AND brand_id = 20 ORDER BY like_count DESC LIMIT 20; +``` + +| brand_id | rows | key | +|----------|------|-----| +| 1 | 5,000 | idx_products_brand_likes | +| 10 | 5,000 | idx_products_brand_likes | +| 20 | 5,000 | idx_products_brand_likes | + +현재 데이터셋은 브랜드당 **균등 5,000건**으로 분포되어 있어 rows가 동일하다. + +실무에서는 인기 브랜드(예: 나이키)에 상품이 2만 건, 소규모 브랜드에 500건처럼 **편차가 크다.** +이 경우 동일 인덱스라도 인기 브랜드 조회 시 rows가 크게 증가하며, +LIMIT과 결합 시 성능 차이가 두드러질 수 있다. + +> **향후 개선**: 시드 데이터에 파레토 분포(상위 20% 브랜드에 80% 상품 집중)를 적용하면 +> 더 현실적인 분석이 가능하다. + +--- + +## 9. 전체 EXPLAIN 요약 + +| # | 시나리오 | type | key | rows | filesort | 비고 | +|---|---------|------|-----|------|----------|------| +| 1 | 브랜드+좋아요순 (인덱스 없음) | ALL | NULL | 99,574 | O | **Before 기준선** | +| 2 | 브랜드+가격순 (인덱스 없음) | ALL | NULL | 99,574 | O | Before 기준선 | +| 3 | 브랜드+좋아요순 (인덱스 적용) | ref | idx_brand_likes | 5,000 | X | **95% 감소** | +| 4 | 브랜드+가격순 (인덱스 적용) | ref | idx_brand_price | 5,000 | X | 95% 감소 | +| 5 | 브랜드+최신순 (인덱스 적용) | ref | idx_brand_created | 5,000 | X | 95% 감소 | +| 6 | 브랜드 없이 최신순 (3개) | ref | idx_brand_created | 49,787 | O | **중간 컬럼 skip** | +| 7 | 브랜드 없이 좋아요순 (3개) | ref | idx_brand_created | 49,787 | O | 중간 컬럼 skip | +| 8 | 브랜드별 rows 비교 | ref | idx_brand_likes | 5,000 | X | 균등 분포 확인 | +| 9 | 브랜드 없이 최신순 (6개) | range | idx_created_only | 49,787 | X | **방어 인덱스로 filesort 제거** | +| 10 | 브랜드 없이 좋아요순 (6개) | ref | idx_likes_only | 49,787 | X | **방어 인덱스로 filesort 제거** | +| 11 | 브랜드 없이 가격순 (6개) | ref | idx_price_only | 49,787 | X | **방어 인덱스로 filesort 제거** | + +--- + +## 10. 트레이드오프 정리 + +### 인덱스 6개 설계의 장단점 + +| 장점 | 단점 | +|------|------| +| 브랜드 필터 유무와 관계없이 모든 정렬 패턴에서 filesort 제거 | 인덱스 6개 유지 비용 (INSERT/UPDATE 시 갱신) | +| 캐시 미스 시에도 DB가 안정적으로 응답 (이중 안전망) | 정렬 기준 추가 시 인덱스도 추가 필요 | +| 캐시와 인덱스의 역할 분담이 명확 | - | + +### 인덱스 구조: 핵심 3개 + 방어 3개 + +| 구분 | 인덱스 | 역할 | +|------|--------|------| +| 핵심 | `(deleted_at, brand_id, created_at DESC)` | 브랜드 + 최신순 | +| 핵심 | `(deleted_at, brand_id, price)` | 브랜드 + 가격순 | +| 핵심 | `(deleted_at, brand_id, like_count DESC)` | 브랜드 + 좋아요순 | +| 방어 | `(deleted_at, created_at DESC)` | 전체 최신순 (캐시 미스 대비) | +| 방어 | `(deleted_at, like_count DESC)` | 전체 좋아요순 (캐시 미스 대비) | +| 방어 | `(deleted_at, price)` | 전체 가격순 (캐시 미스 대비) | + +### 읽기 vs 쓰기 비용 판단 + +커머스 서비스에서 읽기:쓰기 비율은 약 99:1이다. +인덱스 6개로 인한 쓰기 비용 증가보다, **캐시 미스 시 49,787건 filesort가 서비스에 미치는 영향**이 훨씬 크다. +쓰기 비용은 선형적으로 소량 증가하지만, filesort는 트래픽 폭주 시 DB를 압박할 수 있다. + +### 최종 전략: 이중 안전망 + +``` +캐시 히트 시 → DB를 아예 안 탐 (Redis/Caffeine에서 반환) +캐시 미스 시 → 인덱스가 filesort 없이 처리 (방어 인덱스) +``` + +캐시는 "있으면 좋은 것"이지, "없으면 안 되는 것"이어서는 안 된다. +캐시가 빠져도 서비스가 버틸 수 있는 구조가 실무적으로 안전하다. + +### 향후 개선 가능 사항 + +- 데이터가 수백만 건으로 커지면 **OFFSET 기반 페이지네이션의 deep page 문제** 발생 가능. Cursor 기반 전환 검토. +- 브랜드별 상품 수 편차가 커지면 **인기 브랜드의 rows 증가**로 성능 저하 가능. 파레토 분포 시드 데이터로 사전 검증 권장. + +--- + +## 11. 최종 인덱스 DDL + +```sql +-- 핵심 인덱스: 브랜드 필터 + 정렬 (3개) +CREATE INDEX idx_products_brand_created ON products(deleted_at, brand_id, created_at DESC); +CREATE INDEX idx_products_brand_price ON products(deleted_at, brand_id, price); +CREATE INDEX idx_products_brand_likes ON products(deleted_at, brand_id, like_count DESC); + +-- 방어 인덱스: 브랜드 필터 없는 전체 조회 (3개) +CREATE INDEX idx_products_created_only ON products(deleted_at, created_at DESC); +CREATE INDEX idx_products_likes_only ON products(deleted_at, like_count DESC); +CREATE INDEX idx_products_price_only ON products(deleted_at, price); +``` + +### 핵심 학습 포인트 + +1. **복합 인덱스 순서**: 등호 조건을 앞에, 정렬을 뒤에 배치해야 filesort를 제거할 수 있다. +2. **공통 조건의 선두 배치**: 모든 쿼리에 공통인 deleted_at을 선두에 두어 재사용성을 높인다. 등호 조건끼리는 순서보다 재사용성이 더 중요한 판단 기준이다. +3. **정렬 기준별 분리**: 정렬 컬럼이 다르면 하나의 인덱스로 합칠 수 없다. 정렬 기준 수만큼 인덱스가 필요하다. +4. **중간 컬럼 skip의 영향**: 복합 인덱스에서 중간 컬럼이 빠지면 그 뒤 컬럼의 정렬이 불가능하다. (시나리오 6, 7에서 실측 확인) +5. **방어적 설계**: 캐시만 믿지 말고, 캐시 미스 시에도 DB가 견딜 수 있는 인덱스를 함께 두는 것이 실무적 판단이다. 처음에는 캐시만으로 충분하다고 생각했으나, TTL 만료, 무효화, Redis 장애 등 캐시 미스 시나리오를 고려하면 **인덱스 + 캐시의 이중 안전망**이 더 안전하다. \ No newline at end of file diff --git a/docs/performance/product/seed-products.sql b/docs/performance/product/seed-products.sql new file mode 100644 index 000000000..24c35803f --- /dev/null +++ b/docs/performance/product/seed-products.sql @@ -0,0 +1,65 @@ +-- ============================================================= +-- 상품 성능 테스트용 시드 데이터 (브랜드 20개, 상품 10만건) +-- ============================================================= +-- 사전 조건: brands 테이블에 20개 브랜드가 있어야 합니다. +-- 이 스크립트는 Docker 로컬 환경(MySQL 8.0)에서 실행합니다. + +-- 1. 브랜드 20개 생성 +INSERT INTO brands (name, description, created_at, updated_at) +SELECT + CONCAT('Brand_', LPAD(seq, 2, '0')), + CONCAT('Brand_', LPAD(seq, 2, '0'), ' 설명'), + NOW(), + NOW() +FROM ( + SELECT ROW_NUMBER() OVER () AS seq + FROM information_schema.columns + LIMIT 20 +) t; + +-- 2. 상품 100,000건 생성 (브랜드별 약 5,000건) +-- price: 1,000 ~ 500,000 랜덤 +-- stock_quantity: 0 ~ 10,000 랜덤 +-- like_count: 0 ~ 5,000 랜덤 +DROP PROCEDURE IF EXISTS seed_products; + +DELIMITER // +CREATE PROCEDURE seed_products() +BEGIN + DECLARE i INT DEFAULT 0; + DECLARE batch_size INT DEFAULT 1000; + DECLARE total INT DEFAULT 100000; + DECLARE brand_count INT DEFAULT 20; + + -- 트랜잭션 단위로 1000건씩 INSERT + WHILE i < total DO + INSERT INTO products (brand_id, name, price, stock_quantity, description, like_count, version, created_at, updated_at) + SELECT + ((seq % brand_count) + 1) AS brand_id, + CONCAT('Product_', LPAD(seq + 1, 6, '0')) AS name, + ROUND(1000 + RAND() * 499000, 2) AS price, + FLOOR(RAND() * 10001) AS stock_quantity, + CONCAT('상품 설명 #', seq + 1) AS description, + FLOOR(RAND() * 5001) AS like_count, + 0 AS version, + DATE_SUB(NOW(), INTERVAL FLOOR(RAND() * 365) DAY) AS created_at, + NOW() AS updated_at + FROM ( + SELECT @rownum := @rownum + 1 AS seq + FROM information_schema.columns a + CROSS JOIN information_schema.columns b + CROSS JOIN (SELECT @rownum := i - 1) r + LIMIT 1000 + ) t; + + SET i = i + batch_size; + END WHILE; +END // +DELIMITER ; + +CALL seed_products(); +DROP PROCEDURE IF EXISTS seed_products; + +-- 확인 +SELECT COUNT(*) AS total_products FROM products; +SELECT brand_id, COUNT(*) AS cnt FROM products GROUP BY brand_id ORDER BY brand_id; From 7b1f03c169241e011a029ed8c8c738a35c2ffc4c Mon Sep 17 00:00:00 2001 From: ghtjr410 Date: Fri, 13 Mar 2026 15:34:35 +0900 Subject: [PATCH 3/5] =?UTF-8?q?perf:=20=EC=83=81=ED=92=88=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20Redis=20=EC=BA=90=EC=8B=9C=20=EC=A0=81=EC=9A=A9=20?= =?UTF-8?q?=EB=B0=8F=20=EC=BA=90=EC=8B=9C=20=EC=A0=84=EB=9E=B5=20=EB=B9=84?= =?UTF-8?q?=EA=B5=90=20=EC=8B=A4=ED=97=98=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/rules/project/architecture.md | 2 +- apps/commerce-api/build.gradle.kts | 3 + .../loopers/application/like/LikeFacade.java | 5 + .../loopers/application/like/LikeService.java | 5 + .../product/ProductExperimentFacade.java | 273 ++++++++++++++ .../product/ProductExperimentInfo.java | 32 ++ .../application/product/ProductFacade.java | 46 ++- .../application/product/ProductScheduler.java | 16 +- .../application/product/ProductService.java | 14 +- .../domain/product/ProductRepository.java | 5 +- .../ProductCacheInvalidationConfig.java | 64 ++++ .../product/ProductCacheManager.java | 113 ++++++ .../product/ProductJpaRepository.java | 22 +- .../product/ProductLocalCacheManager.java | 77 ++++ .../product/ProductRepositoryImpl.java | 14 +- .../interfaces/api/CursorResponse.java | 20 + .../experiment/OrderExperimentRequest.java | 96 +++++ .../api/experiment/OrderExperimentV1Dto.java | 58 +++ .../ProductExperimentController.java | 119 ++++++ .../experiment/ProductExperimentRequest.java | 46 +++ .../experiment/ProductExperimentV1Dto.java | 62 ++++ .../transaction/TransactionHelper.java | 18 + .../ProductCacheManagerIntegrationTest.java | 160 ++++++++ docs/performance/product/cache-strategy.md | 342 ++++++++++++++++++ k6/detail-test.js | 151 ++++++++ k6/list-test.js | 236 ++++++++++++ .../com/loopers/config/redis/RedisConfig.java | 11 + .../RedisTestContainersConfig.java | 3 - 28 files changed, 1992 insertions(+), 21 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/product/ProductExperimentFacade.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/product/ProductExperimentInfo.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductCacheInvalidationConfig.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductCacheManager.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductLocalCacheManager.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/CursorResponse.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/experiment/OrderExperimentRequest.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/experiment/OrderExperimentV1Dto.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/experiment/ProductExperimentController.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/experiment/ProductExperimentRequest.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/experiment/ProductExperimentV1Dto.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/support/transaction/TransactionHelper.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/infrastructure/product/ProductCacheManagerIntegrationTest.java create mode 100644 docs/performance/product/cache-strategy.md create mode 100644 k6/detail-test.js create mode 100644 k6/list-test.js diff --git a/.claude/rules/project/architecture.md b/.claude/rules/project/architecture.md index 5b8e2261a..9734d96cd 100644 --- a/.claude/rules/project/architecture.md +++ b/.claude/rules/project/architecture.md @@ -1,6 +1,6 @@ # 아키텍처 -## 패키지 구조 (DDD) +## 패키지 구조 (4-Layered_Architecture) ``` com.loopers/ ├── interfaces/ # REST 컨트롤러, Request DTO, Response DTO (V1Dto) diff --git a/apps/commerce-api/build.gradle.kts b/apps/commerce-api/build.gradle.kts index cb54a44be..f770dc47c 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.security:spring-security-crypto") 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 80103a737..76bf42f87 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 @@ -3,6 +3,7 @@ import com.loopers.application.brand.BrandService; import com.loopers.application.product.ProductService; import com.loopers.domain.brand.Brand; +import com.loopers.infrastructure.product.ProductCacheManager; import com.loopers.domain.like.Like; import com.loopers.domain.product.Product; import com.loopers.support.error.CoreException; @@ -12,6 +13,7 @@ import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; +import static com.loopers.support.transaction.TransactionHelper.afterCommit; import java.util.Map; import java.util.Set; @@ -24,6 +26,7 @@ public class LikeFacade { private final LikeService likeService; private final ProductService productService; private final BrandService brandService; + private final ProductCacheManager productCacheManager; // Command @@ -34,6 +37,7 @@ public void like(Long userId, Long productId) { boolean created = likeService.like(userId, productId); if (created) { productService.incrementLikeCount(productId); + afterCommit(() -> productCacheManager.evictDetail(productId)); } } @@ -42,6 +46,7 @@ public void unlike(Long userId, Long productId) { boolean deleted = likeService.unlike(userId, productId); if (deleted) { productService.decrementLikeCountIfPositive(productId); + afterCommit(() -> productCacheManager.evictDetail(productId)); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeService.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeService.java index 9e45c11d1..af67fff72 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeService.java @@ -40,6 +40,11 @@ public boolean unlike(Long userId, Long productId) { // Query + @Transactional(readOnly = true) + public boolean isLiked(Long userId, Long productId) { + return likeRepository.existsByUserIdAndProductId(userId, productId); + } + @Transactional(readOnly = true) public Page findLikedActiveProducts(Long userId, Pageable pageable) { return likeRepository.findAllByUserIdWithActiveProduct(userId, pageable); diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductExperimentFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductExperimentFacade.java new file mode 100644 index 000000000..0d1b6f4c2 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductExperimentFacade.java @@ -0,0 +1,273 @@ +package com.loopers.application.product; + +import com.loopers.application.brand.BrandService; +import com.loopers.application.like.LikeService; +import com.loopers.domain.brand.Brand; +import com.loopers.domain.product.Product; +import com.loopers.infrastructure.product.ProductCacheManager; +import com.loopers.infrastructure.product.ProductCacheManager.CachedPage; +import com.loopers.infrastructure.product.ProductLocalCacheManager; +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.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; + +@Component +@RequiredArgsConstructor +public class ProductExperimentFacade { + + private final ProductService productService; + private final BrandService brandService; + private final LikeService likeService; + private final ProductCacheManager productCacheManager; + private final ProductLocalCacheManager localCacheManager; + + // ===== Detail ===== + + // v1: DB only + @Transactional(readOnly = true) + public ProductExperimentInfo getDetailV1(Long productId, Long userId) { + Product product = productService.getActiveProduct(productId); + Brand brand = brandService.getBrand(product.getBrandId()); + ProductInfo info = ProductInfo.from(product, brand.getName()); + boolean liked = userId != null && likeService.isLiked(userId, productId); + return ProductExperimentInfo.from(info, liked); + } + + // v2: Redis → DB + @Transactional(readOnly = true) + public ProductExperimentInfo getDetailV2(Long productId, Long userId) { + Optional cached = productCacheManager.getDetail(productId); + ProductInfo info; + if (cached.isPresent()) { + info = cached.get(); + } else { + Product product = productService.getActiveProduct(productId); + Brand brand = brandService.getBrand(product.getBrandId()); + info = ProductInfo.from(product, brand.getName()); + productCacheManager.putDetail(productId, info); + } + boolean liked = userId != null && likeService.isLiked(userId, productId); + return ProductExperimentInfo.from(info, liked); + } + + // v3: Caffeine → Redis → DB + @Transactional(readOnly = true) + public ProductExperimentInfo getDetailV3(Long productId, Long userId) { + Optional l1 = localCacheManager.getDetail(productId); + if (l1.isPresent()) { + boolean liked = userId != null && likeService.isLiked(userId, productId); + return ProductExperimentInfo.from(l1.get(), liked); + } + + Optional l2 = productCacheManager.getDetail(productId); + ProductInfo info; + if (l2.isPresent()) { + info = l2.get(); + } else { + Product product = productService.getActiveProduct(productId); + Brand brand = brandService.getBrand(product.getBrandId()); + info = ProductInfo.from(product, brand.getName()); + productCacheManager.putDetail(productId, info); + } + + localCacheManager.putDetail(productId, info); + boolean liked = userId != null && likeService.isLiked(userId, productId); + return ProductExperimentInfo.from(info, liked); + } + + // ===== List Offset ===== + + // v1: DB only (offset) + @Transactional(readOnly = true) + public Page getListOffsetV1(Long brandId, Pageable pageable) { + Page products = productService.findActiveProducts(brandId, pageable); + return toProductInfoPage(products); + } + + // v2: Redis → DB (offset) + @Transactional(readOnly = true) + public Page getListOffsetV2(Long brandId, Pageable pageable) { + String sort = pageable.getSort().toString(); + int page = pageable.getPageNumber(); + int size = pageable.getPageSize(); + + Optional cached = productCacheManager.getList(brandId, sort, page, size); + if (cached.isPresent()) { + CachedPage cp = cached.get(); + return new PageImpl<>(cp.content(), PageRequest.of(cp.page(), cp.size()), cp.totalElements()); + } + + Page products = productService.findActiveProducts(brandId, pageable); + Page result = toProductInfoPage(products); + productCacheManager.putList(brandId, sort, page, size, + new CachedPage(result.getContent(), page, size, result.getTotalElements())); + return result; + } + + // v3: Caffeine → Redis → DB (offset) + @Transactional(readOnly = true) + public Page getListOffsetV3(Long brandId, Pageable pageable) { + String sort = pageable.getSort().toString(); + int page = pageable.getPageNumber(); + int size = pageable.getPageSize(); + String l1Key = offsetCacheKey(brandId, sort, page, size); + + Optional l1 = localCacheManager.getList(l1Key); + if (l1.isPresent()) { + CachedPage cp = l1.get(); + return new PageImpl<>(cp.content(), PageRequest.of(cp.page(), cp.size()), cp.totalElements()); + } + + Optional l2 = productCacheManager.getList(brandId, sort, page, size); + if (l2.isPresent()) { + localCacheManager.putList(l1Key, l2.get()); + CachedPage cp = l2.get(); + return new PageImpl<>(cp.content(), PageRequest.of(cp.page(), cp.size()), cp.totalElements()); + } + + Page products = productService.findActiveProducts(brandId, pageable); + Page result = toProductInfoPage(products); + CachedPage cachedPage = new CachedPage(result.getContent(), page, size, result.getTotalElements()); + productCacheManager.putList(brandId, sort, page, size, cachedPage); + localCacheManager.putList(l1Key, cachedPage); + return result; + } + + // ===== List Cursor ===== + + // v1: DB only (cursor) + @Transactional(readOnly = true) + public CursorResult getListCursorV1(Long brandId, Long cursor, int size) { + List products = productService.findActiveProductsCursor(brandId, cursor, size + 1); + return toCursorResult(products, size); + } + + // v2: Redis → DB (cursor) + @Transactional(readOnly = true) + public CursorResult getListCursorV2(Long brandId, Long cursor, int size) { + String cacheKey = cursorCacheKey(brandId, cursor, size); + Optional cached = productCacheManager.getList(brandId, cacheKey, 0, size); + if (cached.isPresent()) { + CachedPage cp = cached.get(); + boolean hasNext = cp.totalElements() > 0; + Long nextCursor = hasNext && !cp.content().isEmpty() + ? cp.content().get(cp.content().size() - 1).id() + : null; + return new CursorResult(cp.content(), nextCursor, hasNext); + } + + List products = productService.findActiveProductsCursor(brandId, cursor, size + 1); + CursorResult result = toCursorResult(products, size); + productCacheManager.putList(brandId, cacheKey, 0, size, + new CachedPage(result.content(), 0, size, result.hasNext() ? 1 : 0)); + return result; + } + + // v3: Caffeine → Redis → DB (cursor) + @Transactional(readOnly = true) + public CursorResult getListCursorV3(Long brandId, Long cursor, int size) { + String cacheKey = cursorCacheKey(brandId, cursor, size); + String l1Key = "cursor:" + cacheKey; + + Optional l1 = localCacheManager.getList(l1Key); + if (l1.isPresent()) { + CachedPage cp = l1.get(); + boolean hasNext = cp.totalElements() > 0; + Long nextCursor = hasNext && !cp.content().isEmpty() + ? cp.content().get(cp.content().size() - 1).id() + : null; + return new CursorResult(cp.content(), nextCursor, hasNext); + } + + Optional l2 = productCacheManager.getList(brandId, cacheKey, 0, size); + if (l2.isPresent()) { + localCacheManager.putList(l1Key, l2.get()); + CachedPage cp = l2.get(); + boolean hasNext = cp.totalElements() > 0; + Long nextCursor = hasNext && !cp.content().isEmpty() + ? cp.content().get(cp.content().size() - 1).id() + : null; + return new CursorResult(cp.content(), nextCursor, hasNext); + } + + List products = productService.findActiveProductsCursor(brandId, cursor, size + 1); + CursorResult result = toCursorResult(products, size); + CachedPage cachedPage = new CachedPage(result.content(), 0, size, result.hasNext() ? 1 : 0); + productCacheManager.putList(brandId, cacheKey, 0, size, cachedPage); + localCacheManager.putList(l1Key, cachedPage); + return result; + } + + // ===== Cache Stats ===== + + public String getCacheStats() { + return "L1 Detail: " + localCacheManager.getDetailStats() + + " | L1 List: " + localCacheManager.getListStats(); + } + + // ===== Private ===== + + private Page toProductInfoPage(Page products) { + Set brandIds = products.getContent().stream() + .map(Product::getBrandId) + .collect(Collectors.toSet()); + + Map brandMap = brandService.getBrandsMapByIds(brandIds); + + for (Product product : products.getContent()) { + if (!brandMap.containsKey(product.getBrandId())) { + throw new CoreException(ErrorType.NOT_FOUND, + "브랜드 매핑 누락. productId=" + product.getId() + ", brandId=" + product.getBrandId()); + } + } + + return products.map(product -> ProductInfo.from(product, brandMap.get(product.getBrandId()).getName())); + } + + private CursorResult toCursorResult(List products, int size) { + boolean hasNext = products.size() > size; + List content = hasNext ? products.subList(0, size) : products; + + Set brandIds = content.stream() + .map(Product::getBrandId) + .collect(Collectors.toSet()); + + Map brandMap = brandService.getBrandsMapByIds(brandIds); + + List infoList = content.stream() + .map(product -> ProductInfo.from(product, brandMap.get(product.getBrandId()).getName())) + .toList(); + + Long nextCursor = hasNext && !infoList.isEmpty() + ? infoList.get(infoList.size() - 1).id() + : null; + + return new CursorResult(infoList, nextCursor, hasNext); + } + + private String offsetCacheKey(Long brandId, String sort, int page, int size) { + String brandPart = brandId != null ? brandId.toString() : "all"; + return "offset:" + brandPart + ":" + sort + ":" + page + ":" + size; + } + + private String cursorCacheKey(Long brandId, Long cursor, int size) { + String brandPart = brandId != null ? brandId.toString() : "all"; + String cursorPart = cursor != null ? cursor.toString() : "start"; + return "cursor:" + brandPart + ":" + cursorPart + ":" + size; + } + + public record CursorResult(List content, Long nextCursor, boolean hasNext) { + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductExperimentInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductExperimentInfo.java new file mode 100644 index 000000000..29c526a1c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductExperimentInfo.java @@ -0,0 +1,32 @@ +package com.loopers.application.product; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +public record ProductExperimentInfo( + Long id, + Long brandId, + String brandName, + String name, + BigDecimal price, + Integer stockQuantity, + String description, + Integer likeCount, + boolean liked, + LocalDateTime createdAt +) { + public static ProductExperimentInfo from(ProductInfo info, boolean liked) { + return new ProductExperimentInfo( + info.id(), + info.brandId(), + info.brandName(), + info.name(), + info.price(), + info.stockQuantity(), + info.description(), + info.likeCount(), + liked, + info.createdAt() + ); + } +} 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 c11de7b49..9d0bbaed9 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 @@ -2,16 +2,22 @@ import com.loopers.application.brand.BrandService; import com.loopers.domain.brand.Brand; +import com.loopers.infrastructure.product.ProductCacheManager; +import com.loopers.infrastructure.product.ProductCacheManager.CachedPage; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import com.loopers.domain.product.Product; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; +import static com.loopers.support.transaction.TransactionHelper.afterCommit; import java.util.Map; +import java.util.Optional; import java.util.Set; import java.util.stream.Collectors; @@ -21,6 +27,7 @@ public class ProductFacade { private final ProductService productService; private final BrandService brandService; + private final ProductCacheManager productCacheManager; // Command @@ -28,19 +35,30 @@ public class ProductFacade { public ProductInfo register(ProductCommand.Register command) { Brand brand = brandService.getActiveBrand(command.brandId()); Product product = productService.register(command); - return ProductInfo.from(product, brand.getName()); + ProductInfo info = ProductInfo.from(product, brand.getName()); + afterCommit(() -> productCacheManager.evictAllLists()); + return info; } @Transactional public ProductInfo updateInfo(Long productId, ProductCommand.UpdateInfo command) { Product product = productService.updateInfo(productId, command); Brand brand = brandService.getBrand(product.getBrandId()); - return ProductInfo.from(product, brand.getName()); + ProductInfo info = ProductInfo.from(product, brand.getName()); + afterCommit(() -> { + productCacheManager.evictDetail(productId); + productCacheManager.evictAllLists(); + }); + return info; } @Transactional public void delete(Long productId) { productService.delete(productId); + afterCommit(() -> { + productCacheManager.evictDetail(productId); + productCacheManager.evictAllLists(); + }); } // Query @@ -54,13 +72,30 @@ public ProductInfo getDetail(Long productId) { @Transactional(readOnly = true) public ProductInfo getActiveDetail(Long productId) { + Optional cached = productCacheManager.getDetail(productId); + if (cached.isPresent()) { + return cached.get(); + } + Product product = productService.getActiveProduct(productId); Brand brand = brandService.getBrand(product.getBrandId()); - return ProductInfo.from(product, brand.getName()); + ProductInfo info = ProductInfo.from(product, brand.getName()); + productCacheManager.putDetail(productId, info); + return info; } @Transactional(readOnly = true) public Page getActiveList(Long brandId, Pageable pageable) { + String sort = pageable.getSort().toString(); + int page = pageable.getPageNumber(); + int size = pageable.getPageSize(); + + Optional cached = productCacheManager.getList(brandId, sort, page, size); + if (cached.isPresent()) { + CachedPage cachedPage = cached.get(); + return new PageImpl<>(cachedPage.content(), PageRequest.of(cachedPage.page(), cachedPage.size()), cachedPage.totalElements()); + } + Page products = productService.findActiveProducts(brandId, pageable); Set brandIds = products.getContent().stream() @@ -76,7 +111,10 @@ public Page getActiveList(Long brandId, Pageable pageable) { } } - return products.map(product -> ProductInfo.from(product, brandMap.get(product.getBrandId()).getName())); + Page result = products.map(product -> ProductInfo.from(product, brandMap.get(product.getBrandId()).getName())); + productCacheManager.putList(brandId, sort, page, size, + new CachedPage(result.getContent(), page, size, result.getTotalElements())); + return result; } @Transactional(readOnly = true) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductScheduler.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductScheduler.java index 94f26e259..674e839d7 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductScheduler.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductScheduler.java @@ -23,16 +23,24 @@ public void cleanup() { for (Long brandId : brandIds) { try { int totalDeleted = 0; - int deleted; - do { - deleted = productService.softDeleteByBrandIdInBatch(brandId, BATCH_SIZE); + while (true) { + List ids = productService.findIdsForCleanup(brandId, BATCH_SIZE); + if (ids.isEmpty()) break; + + int deleted = productService.softDeleteByIds(ids); totalDeleted += deleted; - } while (deleted == BATCH_SIZE); + + if (ids.size() < BATCH_SIZE) break; + Thread.sleep(100); + } if (totalDeleted > 0) { log.info("브랜드 {} 상품 {}개 정리 완료", brandId, totalDeleted); } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + log.warn("브랜드 {} 상품 정리 중 인터럽트 발생", brandId); } catch (Exception e) { log.error("브랜드 {} 상품 정리 실패", brandId, e); } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java index b6354e66f..00af6ea0c 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java @@ -73,9 +73,14 @@ public void decreaseStocks(Map productQuantities) { } } + @Transactional(readOnly = true) + public List findIdsForCleanup(Long brandId, int batchSize) { + return productRepository.findIdsByBrandIdForCleanup(brandId, batchSize); + } + @Transactional - public int softDeleteByBrandIdInBatch(Long brandId, int batchSize) { - return productRepository.softDeleteByBrandIdInBatch(brandId, batchSize); + public int softDeleteByIds(List ids) { + return productRepository.softDeleteByIds(ids); } @@ -119,6 +124,11 @@ public Page findActiveProducts(Long brandId, Pageable pageable) { return productRepository.findAllActiveWithActiveBrand(brandId, pageable); } + @Transactional(readOnly = true) + public List findActiveProductsCursor(Long brandId, Long cursor, int limit) { + return productRepository.findAllActiveCursor(brandId, cursor, limit); + } + @Transactional(readOnly = true) public Map getProductsMapByIds(Set productIds) { return productRepository.findAllByIdIn(productIds).stream() 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 979b142b4..b4a8ed263 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 @@ -14,7 +14,8 @@ public interface ProductRepository { int decreaseStockIfEnough(Long productId, int quantity); int incrementLikeCount(Long productId); int decrementLikeCountIfPositive(Long productId); - int softDeleteByBrandIdInBatch(Long brandId, int batchSize); + List findIdsByBrandIdForCleanup(Long brandId, int batchSize); + int softDeleteByIds(List ids); // Query Optional findById(Long id); @@ -30,4 +31,6 @@ public interface ProductRepository { Page findAllActiveWithActiveBrand(Long brandId, Pageable pageable); List findBrandIdsWithUncleanedProducts(); + + List findAllActiveCursor(Long brandId, Long cursor, int limit); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductCacheInvalidationConfig.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductCacheInvalidationConfig.java new file mode 100644 index 000000000..c93124e38 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductCacheInvalidationConfig.java @@ -0,0 +1,64 @@ +package com.loopers.infrastructure.product; + +import com.loopers.config.redis.RedisConfig; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.listener.ChannelTopic; +import org.springframework.data.redis.listener.RedisMessageListenerContainer; +import org.springframework.data.redis.listener.adapter.MessageListenerAdapter; + +@Slf4j +@Configuration +@RequiredArgsConstructor +public class ProductCacheInvalidationConfig { + + public static final String CHANNEL = "cache:product:invalidate"; + + @Bean + public ChannelTopic productCacheInvalidationTopic() { + return new ChannelTopic(CHANNEL); + } + + @Bean + public MessageListenerAdapter productCacheMessageListener(ProductLocalCacheManager localCacheManager) { + return new MessageListenerAdapter(new ProductCacheInvalidationSubscriber(localCacheManager)); + } + + @Bean + public RedisMessageListenerContainer productCacheListenerContainer( + @Qualifier(RedisConfig.CONNECTION_PUB_SUB) RedisConnectionFactory connectionFactory, + MessageListenerAdapter productCacheMessageListener, + ChannelTopic productCacheInvalidationTopic + ) { + RedisMessageListenerContainer container = new RedisMessageListenerContainer(); + container.setConnectionFactory(connectionFactory); + container.addMessageListener(productCacheMessageListener, productCacheInvalidationTopic); + return container; + } + + @Slf4j + @RequiredArgsConstructor + static class ProductCacheInvalidationSubscriber { + + private final ProductLocalCacheManager localCacheManager; + + @SuppressWarnings("unused") + public void handleMessage(String message) { + log.debug("캐시 무효화 메시지 수신: {}", message); + + if (message.startsWith("detail:")) { + String productIdStr = message.substring("detail:".length()); + localCacheManager.evictDetail(Long.parseLong(productIdStr)); + } else if ("list:all".equals(message)) { + localCacheManager.evictAllLists(); + } else if ("all".equals(message)) { + localCacheManager.evictAllDetails(); + localCacheManager.evictAllLists(); + } + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductCacheManager.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductCacheManager.java new file mode 100644 index 000000000..51c86f62c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductCacheManager.java @@ -0,0 +1,113 @@ +package com.loopers.infrastructure.product; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.application.product.ProductInfo; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Component; + +import java.time.Duration; +import java.util.List; +import java.util.Optional; +import java.util.Set; + +@Slf4j +@Component +@RequiredArgsConstructor +public class ProductCacheManager { + + private static final String DETAIL_KEY_PREFIX = "product:detail:"; + private static final String LIST_KEY_PREFIX = "product:list:"; + private static final String LIST_KEYS_REGISTRY = "product:list-keys"; + private static final Duration DETAIL_TTL = Duration.ofMinutes(10); + private static final Duration LIST_TTL = Duration.ofMinutes(5); + + private final RedisTemplate redisTemplate; + private final ObjectMapper objectMapper; + + // Command + + public void putDetail(Long productId, ProductInfo info) { + try { + String json = objectMapper.writeValueAsString(info); + redisTemplate.opsForValue().set(detailKey(productId), json, DETAIL_TTL); + } catch (JsonProcessingException e) { + log.warn("상품 상세 캐시 저장 실패. productId={}", productId, e); + } + } + + public void putList(Long brandId, String sort, int page, int size, CachedPage cachedPage) { + try { + String key = listKey(brandId, sort, page, size); + String json = objectMapper.writeValueAsString(cachedPage); + redisTemplate.opsForValue().set(key, json, LIST_TTL); + redisTemplate.opsForSet().add(LIST_KEYS_REGISTRY, key); + } catch (JsonProcessingException e) { + log.warn("상품 목록 캐시 저장 실패. brandId={}, sort={}", brandId, sort, e); + } + } + + public void evictDetail(Long productId) { + redisTemplate.delete(detailKey(productId)); + publishInvalidation("detail:" + productId); + } + + public void evictAllLists() { + Set keys = redisTemplate.opsForSet().members(LIST_KEYS_REGISTRY); + if (keys != null && !keys.isEmpty()) { + redisTemplate.delete(keys); + } + redisTemplate.delete(LIST_KEYS_REGISTRY); + publishInvalidation("list:all"); + } + + private void publishInvalidation(String message) { + try { + redisTemplate.convertAndSend(ProductCacheInvalidationConfig.CHANNEL, message); + } catch (Exception e) { + log.warn("캐시 무효화 메시지 발행 실패: {}", message, e); + } + } + + // Query + + public Optional getDetail(Long productId) { + String json = redisTemplate.opsForValue().get(detailKey(productId)); + if (json == null) { + return Optional.empty(); + } + try { + return Optional.of(objectMapper.readValue(json, ProductInfo.class)); + } catch (JsonProcessingException e) { + log.warn("상품 상세 캐시 역직렬화 실패. productId={}", productId, e); + return Optional.empty(); + } + } + + public Optional getList(Long brandId, String sort, int page, int size) { + String json = redisTemplate.opsForValue().get(listKey(brandId, sort, page, size)); + if (json == null) { + return Optional.empty(); + } + try { + return Optional.of(objectMapper.readValue(json, CachedPage.class)); + } catch (JsonProcessingException e) { + log.warn("상품 목록 캐시 역직렬화 실패. brandId={}, sort={}", brandId, sort, e); + return Optional.empty(); + } + } + + private String detailKey(Long productId) { + return DETAIL_KEY_PREFIX + productId; + } + + private String listKey(Long brandId, String sort, int page, int size) { + String brandPart = brandId != null ? brandId.toString() : "all"; + return LIST_KEY_PREFIX + brandPart + ":" + sort + ":" + page + ":" + size; + } + + public record CachedPage(List content, int page, int size, long totalElements) { + } +} 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 de9e40c81..ee10f46a7 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 @@ -29,11 +29,13 @@ public interface ProductJpaRepository extends JpaRepository { @Query("UPDATE Product p SET p.likeCount = p.likeCount - 1 WHERE p.id = :id AND p.likeCount > 0") int decrementLikeCountIfPositive(@Param("id") Long id); + @Query(value = "SELECT id FROM products WHERE brand_id = :brandId AND deleted_at IS NULL ORDER BY id LIMIT :batchSize", + nativeQuery = true) + List findIdsByBrandIdForCleanup(@Param("brandId") Long brandId, @Param("batchSize") int batchSize); + @Modifying - @Query(value = "UPDATE products p SET p.deleted_at = NOW() " + - "WHERE p.brand_id = :brandId AND p.deleted_at IS NULL " + - "ORDER BY p.id LIMIT :batchSize", nativeQuery = true) - int softDeleteByBrandIdInBatch(@Param("brandId") Long brandId, @Param("batchSize") int batchSize); + @Query("UPDATE Product p SET p.deletedAt = CURRENT_TIMESTAMP WHERE p.id IN :ids") + int softDeleteByIds(@Param("ids") List ids); // Query @Query("SELECT p FROM Product p WHERE p.id = :id AND p.deletedAt IS NULL") @@ -89,4 +91,16 @@ public interface ProductJpaRepository extends JpaRepository { "JOIN Brand b ON p.brandId = b.id " + "WHERE b.deletedAt IS NOT NULL AND p.deletedAt IS NULL") List findBrandIdsWithUncleanedProducts(); + + @Query(value = "SELECT p.* FROM products p " + + "JOIN brands b ON p.brand_id = b.id " + + "WHERE p.deleted_at IS NULL AND b.deleted_at IS NULL " + + "AND (:brandId IS NULL OR p.brand_id = :brandId) " + + "AND (:cursor IS NULL OR p.id < :cursor) " + + "ORDER BY p.id DESC " + + "LIMIT :limit", + nativeQuery = true) + List findAllActiveCursor(@Param("brandId") Long brandId, + @Param("cursor") Long cursor, + @Param("limit") int limit); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductLocalCacheManager.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductLocalCacheManager.java new file mode 100644 index 000000000..b6cd9e1f3 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductLocalCacheManager.java @@ -0,0 +1,77 @@ +package com.loopers.infrastructure.product; + +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import com.loopers.application.product.ProductInfo; +import com.loopers.infrastructure.product.ProductCacheManager.CachedPage; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.time.Duration; +import java.util.Optional; + +@Slf4j +@Component +public class ProductLocalCacheManager { + + private final Cache detailCache; + private final Cache listCache; + + public ProductLocalCacheManager() { + this.detailCache = Caffeine.newBuilder() + .maximumSize(200) + .expireAfterWrite(Duration.ofMinutes(1)) + .recordStats() + .build(); + + this.listCache = Caffeine.newBuilder() + .maximumSize(100) + .expireAfterWrite(Duration.ofSeconds(30)) + .recordStats() + .build(); + } + + // Command + + public void putDetail(Long productId, ProductInfo info) { + detailCache.put(detailKey(productId), info); + } + + public void putList(String cacheKey, CachedPage cachedPage) { + listCache.put(cacheKey, cachedPage); + } + + public void evictDetail(Long productId) { + detailCache.invalidate(detailKey(productId)); + } + + public void evictAllDetails() { + detailCache.invalidateAll(); + } + + public void evictAllLists() { + listCache.invalidateAll(); + } + + // Query + + public Optional getDetail(Long productId) { + return Optional.ofNullable(detailCache.getIfPresent(detailKey(productId))); + } + + public Optional getList(String cacheKey) { + return Optional.ofNullable(listCache.getIfPresent(cacheKey)); + } + + public String getDetailStats() { + return detailCache.stats().toString(); + } + + public String getListStats() { + return listCache.stats().toString(); + } + + private String detailKey(Long productId) { + return "detail:" + productId; + } +} 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 5bf5a1d28..fcf023ae9 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 @@ -40,8 +40,13 @@ public int decrementLikeCountIfPositive(Long productId) { } @Override - public int softDeleteByBrandIdInBatch(Long brandId, int batchSize) { - return productJpaRepository.softDeleteByBrandIdInBatch(brandId, batchSize); + public List findIdsByBrandIdForCleanup(Long brandId, int batchSize) { + return productJpaRepository.findIdsByBrandIdForCleanup(brandId, batchSize); + } + + @Override + public int softDeleteByIds(List ids) { + return productJpaRepository.softDeleteByIds(ids); } // Query @@ -94,4 +99,9 @@ public Page findAllActiveWithActiveBrand(Long brandId, Pageable pageabl public List findBrandIdsWithUncleanedProducts() { return productJpaRepository.findBrandIdsWithUncleanedProducts(); } + + @Override + public List findAllActiveCursor(Long brandId, Long cursor, int limit) { + return productJpaRepository.findAllActiveCursor(brandId, cursor, limit); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/CursorResponse.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/CursorResponse.java new file mode 100644 index 000000000..07ce3deeb --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/CursorResponse.java @@ -0,0 +1,20 @@ +package com.loopers.interfaces.api; + +import java.util.List; +import java.util.function.Function; + +public record CursorResponse( + List content, + Long nextCursor, + boolean hasNext, + int size +) { + public static CursorResponse of(List content, Long nextCursor, boolean hasNext, int size) { + return new CursorResponse<>(content, nextCursor, hasNext, size); + } + + public static CursorResponse of(List content, Long nextCursor, boolean hasNext, int size, Function mapper) { + List mapped = content.stream().map(mapper).toList(); + return new CursorResponse<>(mapped, nextCursor, hasNext, size); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/experiment/OrderExperimentRequest.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/experiment/OrderExperimentRequest.java new file mode 100644 index 000000000..75ef97920 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/experiment/OrderExperimentRequest.java @@ -0,0 +1,96 @@ +package com.loopers.interfaces.api.experiment; + +import com.loopers.domain.order.OrderStatus; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.PositiveOrZero; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; + +import java.time.LocalDate; +import java.time.ZoneId; +import java.time.ZonedDateTime; + +public record OrderExperimentRequest() { + + // Query + + public record UserOrderList( + @NotNull(message = "userId는 필수입니다") + Long userId, + + OrderStatus status, + + LocalDate startDate, + + LocalDate endDate, + + @PositiveOrZero(message = "페이지 번호는 0 이상이어야 합니다") + Integer page, + + @Min(value = 1, message = "페이지 크기는 1 이상이어야 합니다") + @Max(value = 100, message = "페이지 크기는 100 이하여야 합니다") + Integer size + ) { + public UserOrderList { + if (page == null) page = 0; + if (size == null) size = 20; + } + + public Pageable toPageable() { + return PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt")); + } + + public ZonedDateTime startDateTime() { + return startDate != null ? startDate.atStartOfDay(ZoneId.systemDefault()) : null; + } + + public ZonedDateTime endDateTime() { + return endDate != null ? endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()) : null; + } + } + + public record AdminStatusList( + @NotNull(message = "status는 필수입니다") + OrderStatus status, + + @PositiveOrZero(message = "페이지 번호는 0 이상이어야 합니다") + Integer page, + + @Min(value = 1, message = "페이지 크기는 1 이상이어야 합니다") + @Max(value = 100, message = "페이지 크기는 100 이하여야 합니다") + Integer size + ) { + public AdminStatusList { + if (page == null) page = 0; + if (size == null) size = 20; + } + + public Pageable toPageable() { + return PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt")); + } + } + + public record ProductOrderList( + @NotNull(message = "productId는 필수입니다") + Long productId, + + @PositiveOrZero(message = "페이지 번호는 0 이상이어야 합니다") + Integer page, + + @Min(value = 1, message = "페이지 크기는 1 이상이어야 합니다") + @Max(value = 100, message = "페이지 크기는 100 이하여야 합니다") + Integer size + ) { + public ProductOrderList { + if (page == null) page = 0; + if (size == null) size = 20; + } + + public Pageable toPageable() { + return PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt")); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/experiment/OrderExperimentV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/experiment/OrderExperimentV1Dto.java new file mode 100644 index 000000000..fc2ab41dd --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/experiment/OrderExperimentV1Dto.java @@ -0,0 +1,58 @@ +package com.loopers.interfaces.api.experiment; + +import com.loopers.application.order.OrderInfo; +import com.loopers.domain.order.OrderStatus; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +public class OrderExperimentV1Dto { + + // Response + + public record UserOrderResponse( + Long id, + OrderStatus status, + BigDecimal totalAmount, + BigDecimal discountAmount, + BigDecimal finalAmount, + Long issuedCouponId, + LocalDateTime createdAt + ) { + public static UserOrderResponse from(OrderInfo.OrderSummary summary) { + return new UserOrderResponse( + summary.id(), + summary.status(), + summary.totalAmount(), + summary.discountAmount(), + summary.finalAmount(), + summary.issuedCouponId(), + summary.createdAt() + ); + } + } + + public record AdminOrderResponse( + Long id, + Long userId, + OrderStatus status, + BigDecimal totalAmount, + BigDecimal discountAmount, + BigDecimal finalAmount, + Long issuedCouponId, + LocalDateTime createdAt + ) { + public static AdminOrderResponse from(OrderInfo.OrderAdminSummary summary) { + return new AdminOrderResponse( + summary.id(), + summary.userId(), + summary.status(), + summary.totalAmount(), + summary.discountAmount(), + summary.finalAmount(), + summary.issuedCouponId(), + summary.createdAt() + ); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/experiment/ProductExperimentController.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/experiment/ProductExperimentController.java new file mode 100644 index 000000000..05788f432 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/experiment/ProductExperimentController.java @@ -0,0 +1,119 @@ +package com.loopers.interfaces.api.experiment; + +import com.loopers.application.product.ProductExperimentFacade; +import com.loopers.application.product.ProductExperimentFacade.CursorResult; +import com.loopers.application.product.ProductExperimentInfo; +import com.loopers.application.product.ProductInfo; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.CursorResponse; +import com.loopers.interfaces.api.PageResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/experiment/products") +@RequiredArgsConstructor +public class ProductExperimentController { + + private final ProductExperimentFacade experimentFacade; + + // ===== Detail ===== + + @GetMapping("/v1/{productId}") + public ApiResponse detailV1( + @PathVariable Long productId, + @RequestHeader(value = "X-User-Id", required = false) Long userId + ) { + ProductExperimentInfo info = experimentFacade.getDetailV1(productId, userId); + return ApiResponse.success(ProductExperimentV1Dto.DetailResponse.from(info)); + } + + @GetMapping("/v2/{productId}") + public ApiResponse detailV2( + @PathVariable Long productId, + @RequestHeader(value = "X-User-Id", required = false) Long userId + ) { + ProductExperimentInfo info = experimentFacade.getDetailV2(productId, userId); + return ApiResponse.success(ProductExperimentV1Dto.DetailResponse.from(info)); + } + + @GetMapping("/v3/{productId}") + public ApiResponse detailV3( + @PathVariable Long productId, + @RequestHeader(value = "X-User-Id", required = false) Long userId + ) { + ProductExperimentInfo info = experimentFacade.getDetailV3(productId, userId); + return ApiResponse.success(ProductExperimentV1Dto.DetailResponse.from(info)); + } + + // ===== List Offset ===== + + @GetMapping("/v1/offset") + public ApiResponse> listOffsetV1( + @Valid ProductExperimentRequest.ListOffset request + ) { + Page result = experimentFacade.getListOffsetV1(request.brandId(), request.toPageable()); + return ApiResponse.success(PageResponse.from(result, ProductExperimentV1Dto.ListResponse::from)); + } + + @GetMapping("/v2/offset") + public ApiResponse> listOffsetV2( + @Valid ProductExperimentRequest.ListOffset request + ) { + Page result = experimentFacade.getListOffsetV2(request.brandId(), request.toPageable()); + return ApiResponse.success(PageResponse.from(result, ProductExperimentV1Dto.ListResponse::from)); + } + + @GetMapping("/v3/offset") + public ApiResponse> listOffsetV3( + @Valid ProductExperimentRequest.ListOffset request + ) { + Page result = experimentFacade.getListOffsetV3(request.brandId(), request.toPageable()); + return ApiResponse.success(PageResponse.from(result, ProductExperimentV1Dto.ListResponse::from)); + } + + // ===== List Cursor ===== + + @GetMapping("/v1/cursor") + public ApiResponse> listCursorV1( + @Valid ProductExperimentRequest.ListCursor request + ) { + CursorResult result = experimentFacade.getListCursorV1(request.brandId(), request.cursor(), request.size()); + return ApiResponse.success(CursorResponse.of( + result.content(), result.nextCursor(), result.hasNext(), request.size(), + ProductExperimentV1Dto.ListResponse::from)); + } + + @GetMapping("/v2/cursor") + public ApiResponse> listCursorV2( + @Valid ProductExperimentRequest.ListCursor request + ) { + CursorResult result = experimentFacade.getListCursorV2(request.brandId(), request.cursor(), request.size()); + return ApiResponse.success(CursorResponse.of( + result.content(), result.nextCursor(), result.hasNext(), request.size(), + ProductExperimentV1Dto.ListResponse::from)); + } + + @GetMapping("/v3/cursor") + public ApiResponse> listCursorV3( + @Valid ProductExperimentRequest.ListCursor request + ) { + CursorResult result = experimentFacade.getListCursorV3(request.brandId(), request.cursor(), request.size()); + return ApiResponse.success(CursorResponse.of( + result.content(), result.nextCursor(), result.hasNext(), request.size(), + ProductExperimentV1Dto.ListResponse::from)); + } + + // ===== Stats ===== + + @GetMapping("/cache-stats") + public ApiResponse cacheStats() { + return ApiResponse.success(experimentFacade.getCacheStats()); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/experiment/ProductExperimentRequest.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/experiment/ProductExperimentRequest.java new file mode 100644 index 000000000..2767f879e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/experiment/ProductExperimentRequest.java @@ -0,0 +1,46 @@ +package com.loopers.interfaces.api.experiment; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.PositiveOrZero; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; + +public record ProductExperimentRequest() { + + // Query + + public record ListOffset( + Long brandId, + + @PositiveOrZero(message = "페이지 번호는 0 이상이어야 합니다") + Integer page, + + @Min(value = 1, message = "페이지 크기는 1 이상이어야 합니다") + @Max(value = 100, message = "페이지 크기는 100 이하여야 합니다") + Integer size + ) { + public ListOffset { + if (page == null) page = 0; + if (size == null) size = 20; + } + + public Pageable toPageable() { + return PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt")); + } + } + + public record ListCursor( + Long brandId, + Long cursor, + + @Min(value = 1, message = "페이지 크기는 1 이상이어야 합니다") + @Max(value = 100, message = "페이지 크기는 100 이하여야 합니다") + Integer size + ) { + public ListCursor { + if (size == null) size = 20; + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/experiment/ProductExperimentV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/experiment/ProductExperimentV1Dto.java new file mode 100644 index 000000000..292913ab2 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/experiment/ProductExperimentV1Dto.java @@ -0,0 +1,62 @@ +package com.loopers.interfaces.api.experiment; + +import com.loopers.application.product.ProductExperimentInfo; +import com.loopers.application.product.ProductInfo; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +public class ProductExperimentV1Dto { + + // Response + + public record DetailResponse( + Long id, + Long brandId, + String brandName, + String name, + BigDecimal price, + Integer stockQuantity, + String description, + Integer likeCount, + boolean liked, + LocalDateTime createdAt + ) { + public static DetailResponse from(ProductExperimentInfo info) { + return new DetailResponse( + info.id(), + info.brandId(), + info.brandName(), + info.name(), + info.price(), + info.stockQuantity(), + info.description(), + info.likeCount(), + info.liked(), + info.createdAt() + ); + } + } + + public record ListResponse( + Long id, + Long brandId, + String brandName, + String name, + BigDecimal price, + Integer likeCount, + LocalDateTime createdAt + ) { + public static ListResponse from(ProductInfo info) { + return new ListResponse( + info.id(), + info.brandId(), + info.brandName(), + info.name(), + info.price(), + info.likeCount(), + info.createdAt() + ); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/support/transaction/TransactionHelper.java b/apps/commerce-api/src/main/java/com/loopers/support/transaction/TransactionHelper.java new file mode 100644 index 000000000..b5d3e9fef --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/support/transaction/TransactionHelper.java @@ -0,0 +1,18 @@ +package com.loopers.support.transaction; + +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +public class TransactionHelper { + + public static void afterCommit(Runnable action) { + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + action.run(); + } + }); + } + + private TransactionHelper() {} +} diff --git a/apps/commerce-api/src/test/java/com/loopers/infrastructure/product/ProductCacheManagerIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/infrastructure/product/ProductCacheManagerIntegrationTest.java new file mode 100644 index 000000000..81cbf46fc --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/infrastructure/product/ProductCacheManagerIntegrationTest.java @@ -0,0 +1,160 @@ +package com.loopers.infrastructure.product; + +import com.loopers.application.product.ProductInfo; +import com.loopers.infrastructure.product.ProductCacheManager.CachedPage; +import com.loopers.utils.RedisCleanUp; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +class ProductCacheManagerIntegrationTest { + + @Autowired + private ProductCacheManager productCacheManager; + + @Autowired + private RedisCleanUp redisCleanUp; + + @BeforeEach + void setUp() { + redisCleanUp.truncateAll(); + } + + @Nested + class 상품_상세_캐시 { + + @Test + void 캐시_저장_후_조회하면_동일한_데이터를_반환한다() { + // given + Long productId = 1L; + ProductInfo info = createProductInfo(productId); + + // when + productCacheManager.putDetail(productId, info); + Optional cached = productCacheManager.getDetail(productId); + + // then + assertThat(cached).isPresent(); + ProductInfo result = cached.get(); + assertThat(result.id()).isEqualTo(productId); + assertThat(result.name()).isEqualTo("테스트 상품"); + assertThat(result.price()).isEqualByComparingTo(new BigDecimal("10000")); + assertThat(result.brandName()).isEqualTo("테스트 브랜드"); + } + + @Test + void 캐시가_없으면_빈_Optional을_반환한다() { + // when + Optional cached = productCacheManager.getDetail(999L); + + // then + assertThat(cached).isEmpty(); + } + + @Test + void 캐시를_삭제하면_조회되지_않는다() { + // given + Long productId = 1L; + productCacheManager.putDetail(productId, createProductInfo(productId)); + + // when + productCacheManager.evictDetail(productId); + Optional cached = productCacheManager.getDetail(productId); + + // then + assertThat(cached).isEmpty(); + } + } + + @Nested + class 상품_목록_캐시 { + + @Test + void 캐시_저장_후_조회하면_동일한_데이터를_반환한다() { + // given + Long brandId = 1L; + String sort = "createdAt: DESC"; + int page = 0; + int size = 20; + CachedPage cachedPage = new CachedPage( + List.of(createProductInfo(1L), createProductInfo(2L)), + page, size, 2L + ); + + // when + productCacheManager.putList(brandId, sort, page, size, cachedPage); + Optional cached = productCacheManager.getList(brandId, sort, page, size); + + // then + assertThat(cached).isPresent(); + CachedPage result = cached.get(); + assertThat(result.content()).hasSize(2); + assertThat(result.totalElements()).isEqualTo(2L); + } + + @Test + void 캐시가_없으면_빈_Optional을_반환한다() { + // when + Optional cached = productCacheManager.getList(1L, "createdAt: DESC", 0, 20); + + // then + assertThat(cached).isEmpty(); + } + + @Test + void brandId가_null이면_all로_캐시된다() { + // given + CachedPage cachedPage = new CachedPage( + List.of(createProductInfo(1L)), + 0, 20, 1L + ); + + // when + productCacheManager.putList(null, "createdAt: DESC", 0, 20, cachedPage); + Optional cached = productCacheManager.getList(null, "createdAt: DESC", 0, 20); + + // then + assertThat(cached).isPresent(); + } + + @Test + void 목록_캐시를_전체_삭제하면_조회되지_않는다() { + // given + productCacheManager.putList(1L, "createdAt: DESC", 0, 20, + new CachedPage(List.of(createProductInfo(1L)), 0, 20, 1L)); + productCacheManager.putList(2L, "price: ASC", 0, 20, + new CachedPage(List.of(createProductInfo(2L)), 0, 20, 1L)); + + // when + productCacheManager.evictAllLists(); + + // then + assertThat(productCacheManager.getList(1L, "createdAt: DESC", 0, 20)).isEmpty(); + assertThat(productCacheManager.getList(2L, "price: ASC", 0, 20)).isEmpty(); + } + } + + private ProductInfo createProductInfo(Long id) { + return new ProductInfo( + id, 1L, "테스트 브랜드", "테스트 상품", + new BigDecimal("10000"), 100, "설명", + 0, ProductInfo.Status.ACTIVE, + LocalDateTime.of(2024, 1, 1, 0, 0), + LocalDateTime.of(2024, 1, 1, 0, 0), + null + ); + } +} diff --git a/docs/performance/product/cache-strategy.md b/docs/performance/product/cache-strategy.md new file mode 100644 index 000000000..d3adf2ad4 --- /dev/null +++ b/docs/performance/product/cache-strategy.md @@ -0,0 +1,342 @@ +# 상품 조회 캐시 전략 — 의사결정 보고서 + +## TL;DR + +상품 조회 성능을 DB only → Redis → Caffeine+Redis 3단계로 최적화했다. +각 단계의 효과를 독립 측정할 수 있도록 v1/v2/v3 실험 구조를 설계했으며, +RedisTemplate 직접 사용, Read-Through 패턴, Redis Pub/Sub 기반 L1 무효화를 적용했다. +상품 상세는 PK 조회(type=const, rows=1)로 쿼리 자체는 최적이나, **인기 상품의 반복 조회 부하를 줄이기 위해 캐시를 적용**한다. +afterCommit 적용과 SET 레지스트리 전환으로 정합성·블로킹 문제를 해결했으며, +남은 제약(좋아요 시 목록 stale, Cache Stampede 등)과 프로덕션 보강 방향을 함께 정리한다. + +--- + +## 1. 캐시 적용 여부 판단 + +| 대상 | 캐시 적용 | 이유 | +|------|----------|------| +| 상품 상세 | ✅ | 모든 유저가 같은 데이터, 변경 빈도 낮음, 인기 상품에 트래픽 집중 | +| 상품 목록 (브랜드 없음) | ✅ | 모든 유저가 동일 결과, 인덱스로 해결 못 하는 패턴의 캐시 미스 대비 | +| 상품 목록 (브랜드 있음) | ✅ | 정렬×브랜드 조합이 유한, 반복 조회 발생 | +| 주문 목록 | ❌ | 개인 데이터, 유저마다 결과 다름, 캐시 히트율 구조적으로 낮음 | +| 주문 상세 | ❌ | 개인 데이터, 반복 조회 빈도 낮음 | +| 브랜드 목록 | ❌ | 데이터 적음(20건), DB 조회도 충분히 빠름 | + +**판단 기준**: "같은 요청이 반복되는가?" + "결과가 유저 무관한가?" — 둘 다 YES면 캐시 효과 높음. +주문은 둘 다 NO이므로 인덱스가 유일한 방어선이다. + +--- + +## 2. 3단계 실험 설계 — 왜 v1/v2/v3로 나눴는가 + +| 버전 | 구조 | 위치 | 목적 | +|------|------|------|------| +| v1 | DB only | ProductExperimentFacade | 캐시 없는 기준선 (baseline) | +| v2 | Redis (L2) → DB | ProductExperimentFacade | 네트워크 캐시의 효과 측정 | +| v3 | Caffeine (L1) → Redis (L2) → DB | ProductExperimentFacade | 로컬 캐시 추가 효과 측정 | +| 프로덕션 | Redis (L2) → DB | ProductFacade | 현재 배포 중인 코드 | + +### 왜 한 번에 v3로 안 갔는가? + +각 계층의 **독립적 기여도**를 측정하기 위해서다. +v3만 있으면 "Redis가 빠른 건지, Caffeine이 빠른 건지" 분리할 수 없다. +v1 → v2에서 Redis의 효과를, v2 → v3에서 Caffeine의 추가 효과를 각각 확인할 수 있다. + +### 프로덕션은 왜 L2만 적용했는가? + +L1(Caffeine)은 실험 코드에서 효과를 검증한 후 프로덕션에 반영하려는 단계적 접근이다. +L1을 프로덕션에 적용하면 Pub/Sub 무효화가 필수인데, 이 경로의 안정성을 충분히 테스트한 후 반영하는 것이 안전하다. + +--- + +## 3. 선택 1: @Cacheable vs RedisTemplate + +| 대안 | 장점 | 단점 | +|------|------|------| +| A. @Cacheable 어노테이션 | 코드 간결, Spring 추상화 | 캐시 흐름이 AOP 뒤에 숨겨짐, 키/TTL 세밀 제어 어려움 | +| **B. RedisTemplate 직접 사용 (채택)** | 캐시 흐름 가시적, 키/TTL 세밀 제어, 에러 핸들링 자유 | 보일러플레이트 증가 | + +### 왜 B를 선택했는가 + +Read-Through 패턴의 흐름(조회 → 미스 → DB → 저장)을 **코드로 직접 보면서 제어**하는 것이 목적이었다. +상세 캐시(TTL 10분)와 목록 캐시(TTL 5분)의 TTL을 키 레벨에서 다르게 설정해야 했고, +v2/v3 실험 코드에서 hit/miss 분기를 명시적으로 작성해야 비교 측정이 가능했다. + +```java +// v2 실험 코드 — hit/miss 분기가 명시적 +Optional cached = productCacheManager.getDetail(productId); +if (cached.isPresent()) { + return cached.get(); // HIT → DB 스킵 +} else { +info = ProductInfo.from(product, ...); // MISS → DB 조회 + productCacheManager.putDetail(productId, info); // Redis에 저장 +} +``` + +--- + +## 4. 캐시 계층별 TTL 설계 + +| 계층 | 대상 | TTL | 근거 | +|------|------|-----|------| +| L2 (Redis) | 상품 상세 | 10분 | 관리자만 수정, 하루 수 회 이하. 변경 빈도 낮음 | +| L2 (Redis) | 상품 목록 (offset/cursor) | 5분 | 좋아요 변경으로 정렬 변동 가능. 상세보다 짧게 | +| L1 (Caffeine) | 상품 상세 | 1분 | Pub/Sub 유실 대비 안전망. maxSize=200개 | +| L1 (Caffeine) | 상품 목록 | 30초 | 변동 빈도 높은 데이터, 가장 짧게. maxSize=100개 | + +### TTL 계층 구조의 원칙 + +``` +Caffeine(30초~1분) < Redis(5분~10분) < DB(원본) +``` + +**가까운 캐시일수록 TTL이 짧다.** +이유는 가까운 캐시일수록 무효화가 어렵기 때문이다. +Redis는 중앙화되어 있어 DELETE 한 번이면 되지만, +Caffeine은 서버마다 따로 있어서 Pub/Sub에 의존해야 한다. +Pub/Sub이 유실되면 TTL이 최종 안전망이 되므로, 짧게 잡는 것이 안전하다. + +--- + +## 5. 캐시 키 설계 + +| 키 패턴 | TTL | 용도 | 무효화 시점 | +|---------|-----|------|------------| +| `product:detail:{productId}` | 10분 | 상품 상세 | 수정/삭제/좋아요 변경 | +| `product:list:{brandId\|all}:{sort}:{page}:{size}` | 5분 | 목록 (offset) | 등록/수정/삭제 | +| `product:list:{brandId\|all}:cursor:...:{size}` | 5분 | 목록 (cursor) | 등록/수정/삭제 | +| Caffeine `detail:{productId}` | 1분 | L1 상세 | Pub/Sub 수신 시 | +| Caffeine 목록 키 | 30초 | L1 목록 | Pub/Sub `list:all` 수신 시 | + +--- + +## 6. 선택 2: 무효화 전략 — 전체 evict vs 선택적 evict vs TTL 의존 + +| 대안 | 장점 | 단점 | +|------|------|------| +| A. TTL만 의존 (evict 안 함) | 구현 단순, 캐시 적중률 최고 | 변경이 최대 5분간 미반영 | +| **B. 상세 + 목록 전체 evict (채택)** | 정합성 보장, 구현 단순 | 좋아요 빈번 시 캐시 적중률 급락 | +| C. 정렬 타입별 선택적 evict | 불필요한 캐시 삭제 방지 | 구현 복잡, 어떤 정렬이 영향받는지 판단 필요 | + +### 왜 B를 선택했는가 + +현재 서비스 규모에서는 정합성을 우선시했다. +좋아요 변경 시 정렬 순서가 바뀔 수 있으므로 목록 캐시를 전체 무효화하는 것이 안전하다. + +단, **좋아요는 예외**로 목록 evict를 하지 않는다. +좋아요는 빈도가 높아 매번 전체 목록을 evict하면 캐시 의미가 없어진다. +목록의 like_count는 TTL(5분) 만료까지 stale할 수 있으나, 커머스 상품 목록에서 이 수준은 허용 가능하다. + +### 이벤트별 무효화 매핑 + +| 이벤트 | 상세(L2) | 목록(L2) | L1 | 호출 위치 | +|--------|---------|---------|-----|----------| +| 상품 등록 | - | evictAllLists() | Pub/Sub | ProductFacade.register() | +| 상품 수정 | evictDetail(id) | evictAllLists() | Pub/Sub | ProductFacade.updateInfo() | +| 상품 삭제 | evictDetail(id) | evictAllLists() | Pub/Sub | ProductFacade.delete() | +| 좋아요 등록 | evictDetail(id) | TTL 의존 | Pub/Sub | LikeFacade.like() | +| 좋아요 취소 | evictDetail(id) | TTL 의존 | Pub/Sub | LikeFacade.unlike() | + +--- + +## 7. 선택 3: L1 무효화 — 왜 Redis Pub/Sub인가 + +| 대안 | 장점 | 단점 | +|------|------|------| +| A. TTL만 의존 | 구현 최단순 | 최대 1분간 서버 간 불일치 | +| **B. Redis Pub/Sub (채택)** | 거의 실시간 무효화, Redis 인프라 이미 있음 | Pub/Sub 유실 가능성 | +| C. Kafka 이벤트 | 메시지 유실 없음 (영속) | 과도한 인프라, 캐시 무효화에 오버킬 | + +### 무효화 흐름 + +``` +상품 수정 → ProductCacheManager.evictDetail(id) + → redisTemplate.delete(key) // L2 삭제 + → redisTemplate.convertAndSend("cache:product:invalidate", "detail:{id}") + → ProductCacheInvalidationSubscriber.handleMessage() + → localCacheManager.evictDetail(id) // 모든 서버의 L1 삭제 +``` + +메시지 타입 3종: `detail:{id}`, `list:all`, `all` + +Pub/Sub이 유실되더라도 Caffeine TTL(상세 1분, 목록 30초)이 최종 안전망이다. + +--- + +## 8. 장애 시나리오 대비 현황 + +| 시나리오 | 대비 상태 | 현재 구현 | +|---------|----------|----------| +| Redis 다운 | ✅ 대비 | try-catch + Optional.empty → DB fallback | +| Caffeine 메모리 초과 | ✅ 대비 | maxSize(상세 200, 목록 100) + LRU eviction | +| Pub/Sub 유실 | ✅ 대비 | TTL이 안전망 (최대 1분 stale) | +| 캐시-DB 정합성 | ✅ 대비 | TransactionHelper.afterCommit()으로 커밋 후 무효화 | +| KEYS * 블로킹 | ✅ 대비 | 키 레지스트리(Set) 기반으로 KEYS 패턴 제거 | +| Cache Stampede | ⚠️ 미대비 | 분산 락 미적용. TTL 만료 시 동시 DB 폭주 가능 | + +--- + +## 9. 알려진 제약 사항과 개선 방향 + +### ~~제약 1: afterCommit 미사용~~ → 해결 완료 + +**현재 상태**: `TransactionHelper.afterCommit()`을 통해 트랜잭션 커밋 후 캐시 무효화가 실행된다. + +```java +// ProductFacade.updateInfo() — 현재 코드 +@Transactional +public ProductInfo updateInfo(Long productId, ...) { + Product product = productService.updateInfo(productId, command); + TransactionHelper.afterCommit(() -> { + productCacheManager.evictDetail(productId); + productCacheManager.evictAllLists(); + }); + return info; +} +``` + +커밋이 확정된 후에만 캐시를 무효화하므로, 롤백 시 불필요한 cache miss와 커밋 전 stale 재적재 문제가 모두 해결되었다. +단, afterCommit은 동시 쓰기 시 레이스 컨디션을 완전히 제거하지는 않는다 (Thread A DELETE 후 Thread B가 stale 값을 SET하는 시나리오). TTL이 최종 안전망이며, 이는 캐시 무효화의 구조적 한계다. + +### ~~제약 2: KEYS 패턴~~ → 해결 완료 + +**현재 상태**: 키 레지스트리(Redis Set) 기반으로 KEYS 패턴을 제거했다. + +```java +// 저장 시 — 레지스트리에 키 추적 +public void putList(...) { + String key = listKey(brandId, sort, page, size); + redisTemplate.opsForValue().set(key, json, LIST_TTL); + redisTemplate.opsForSet().add(LIST_KEYS_REGISTRY, key); // 추적 +} + +// 삭제 시 — 레지스트리에서 키 목록 조회 후 삭제 +public void evictAllLists() { + Set keys = redisTemplate.opsForSet().members(LIST_KEYS_REGISTRY); + if (keys != null && !keys.isEmpty()) { + redisTemplate.delete(keys); + } + redisTemplate.delete(LIST_KEYS_REGISTRY); + publishInvalidation("list:all"); +} +``` + +KEYS 명령 없이 **추적된 키만 정확히 삭제**하므로 Redis 블로킹 위험이 없다. page/size가 동적이어도 저장 시점에 추적하므로 누락이 발생하지 않는다. + +### 제약 3: 좋아요 변경 시 목록 stale + +**현재 상태**: 좋아요 등록/취소 시 상세 캐시만 무효화, 목록은 TTL(5분) 의존. + +**왜 이렇게 했는가**: 좋아요는 빈도가 높다. 매 좋아요마다 전체 목록 evict하면 캐시 적중률이 급락한다. + +**개선 방향**: 트래픽 커지면 `@TransactionalEventListener`로 커밋 후 좋아요순 목록만 선택적 evict. + +### 제약 4: Cache Stampede 미대비 + +**현재 상태**: TTL 만료 시 동시에 같은 키를 조회하면 모든 요청이 DB를 직접 친다. + +**개선 방향**: Redis 분산 락(`setIfAbsent` + TTL)으로 한 요청만 DB 조회하고 나머지는 대기. 또는 TTL 만료 전에 미리 갱신하는 Refresh-Ahead 패턴. + +### 제약 5: Write-Through 미적용 + +**현재 상태**: 무효화(DELETE) 후 다음 조회에서 캐시 미스가 1회 발생한다. + +**개선 방향**: 상품 상세는 관리자만 수정하므로 동시 쓰기가 거의 없다. DELETE 대신 SET(최신값)으로 전환하면 캐시 미스 자체를 없앨 수 있다. 단, 좋아요처럼 동시 쓰기가 빈번한 데이터는 순서 꼬임 위험이 있어 DELETE(무효화)가 적합하다. + +| 대상 | 현재 전략 | 전환 가능 전략 | 전환 조건 | +|------|---------|-------------|---------| +| 상품 상세 | DELETE (무효화) | Write-Through (SET) | 동시 쓰기 거의 없음 | +| 좋아요 | DELETE (무효화) | 유지 | 동시 쓰기 빈번 | +| 목록 | DELETE + TTL | 유지 | 조합 다양, SET 대상 특정 어려움 | + +--- + +## 10. 전체 흐름 요약 + +### 상품 상세 조회 (v3 기준) + +``` +요청 → Caffeine(L1) 확인 + ├─ HIT → 바로 반환 (~0.1ms) + └─ MISS → Redis(L2) 확인 + ├─ HIT → L1에 승격 후 반환 (~1ms) + └─ MISS → DB 조회 → L2 저장 → L1 저장 → 반환 (~10ms) +``` + +### 상품 수정 시 무효화 + +``` +상품 수정 → DB UPDATE → COMMIT + → afterCommit 실행: + → Redis DELETE(상세 키) + → Redis SMEMBERS(레지스트리) → DELETE(목록 키들) → DELETE(레지스트리) + → Redis PUBLISH(무효화 메시지) + → 모든 서버의 Caffeine 해당 키 삭제 +``` + +--- + +## 11. k6 부하 테스트 결과 (20 VUs, 순차 실행) + +### 상품 상세 조회 + +| 지표 | v1 (DB) | v2 (Redis) | v3 (L1+L2) | 개선율 (v1→v3) | +|------|---------|-----------|-----------|--------------| +| avg | 18.16ms | 17.37ms | **13.88ms** | 24% | +| p50 | 14.26ms | 12.08ms | **10.82ms** | 24% | +| p95 | 40.30ms | 43.62ms | **29.66ms** | 26% | +| p99 | 77.67ms | 92.83ms | **61.37ms** | 21% | +| max | 186.00ms | 323.78ms | 512.75ms | - | + +**분석**: 상세 조회는 PK 기반(type=const, rows=1)이라 DB 자체가 이미 빠르다. +v1과 v2의 차이가 적은 이유는 Redis 네트워크 홉 비용과 PK 조회 비용이 비슷한 수준이기 때문이다. +v3(Caffeine)는 네트워크 0홉이라 p50 기준 24% 개선. 인기 상품에 트래픽이 집중되는 파레토 분포(상위 1%에 80% 트래픽)에서 L1 캐시 히트율이 높아 효과가 나타난다. + +### 상품 목록 조회 — Offset Pagination + +| 지표 | v1 (DB) | v2 (Redis) | v3 (L1+L2) | 개선율 (v1→v3) | +|------|---------|-----------|-----------|--------------| +| avg | 873.54ms | 11.14ms | **9.52ms** | **92배** | +| p50 | 852.20ms | 8.12ms | **7.79ms** | **109배** | +| p95 | 1,307.36ms | 21.87ms | **21.35ms** | **62배** | +| p99 | 1,495.66ms | 50.11ms | **34.66ms** | **43배** | +| max | 1,979.25ms | 1,392.15ms | **95.54ms** | 21배 | + +### 상품 목록 조회 — Cursor Pagination + +| 지표 | v1 (DB) | v2 (Redis) | v3 (L1+L2) | 개선율 (v1→v3) | +|------|---------|-----------|-----------|--------------| +| avg | 273.36ms | 16.41ms | **8.09ms** | **34배** | +| p50 | 255.92ms | 10.82ms | **7.00ms** | **37배** | +| p95 | 478.80ms | 33.84ms | **15.76ms** | **30배** | +| p99 | 580.45ms | 153.85ms | **26.73ms** | **21배** | +| max | 830.49ms | 402.45ms | 374.23ms | - | + +### 종합 분석 + +**1. 목록 조회에서 캐시 효과가 극적이다.** +상세는 PK 조회라 v1이 이미 18ms로 빨랐지만, 목록은 v1이 873ms로 느리기 때문에 캐시 적용 시 92배 개선. 이는 인덱스 분석에서 "브랜드 필터 없는 전체 조회는 캐시로 해결"이라고 판단한 근거와 일치한다. + +**2. Cursor가 Offset보다 v1(DB only)에서 3.2배 빠르다.** +Offset v1 avg=873ms, Cursor v1 avg=273ms. COUNT 쿼리 제거 효과다. +하지만 v2/v3에서는 차이가 줄어든다(v3 기준 9.5ms vs 8ms). 캐시 히트 시에는 페이지네이션 방식이 성능에 영향을 거의 주지 않는다. + +**3. L1(Caffeine) 추가 효과는 목록에서 뚜렷하다.** +v2 → v3 개선: 상세 p50 12ms→10ms(17%), 목록 Cursor p99 154ms→27ms(**82%**). +특히 p99에서 L1 효과가 크다. Redis 네트워크 지연이 간헐적으로 발생할 때 L1이 안전망 역할을 한다. + +**4. 테스트 조건** +- VU: 20 (순차 실행, v1→v2→v3) +- 각 시나리오 30초, warm-up 50회 +- 상세: 파레토 분포 (상위 1% 상품에 80% 트래픽) +- 목록: 페이지 깊이 분포 (70% 얕은 / 25% 중간 / 5% 깊은) + +--- + +## 12. 핵심 학습 포인트 + +1. **캐시는 계층이다**: 가까울수록 빠르고, 가까울수록 무효화가 어렵다. TTL을 계층별로 차등 설정하는 것이 핵심. +2. **독립 측정이 가능한 실험 설계**: v1/v2/v3로 각 계층의 기여도를 분리 측정할 수 있어야 "왜 이 계층이 필요한가"를 증명할 수 있다. +3. **인덱스와 캐시의 역할 분담**: 조건 조합이 다양한 패턴은 인덱스, 결과가 동일한 패턴은 캐시. 둘 다 쓰는 것이 실무적 판단. 방어 인덱스로 캐시 미스 시에도 DB가 견디는 이중 안전망. +4. **완벽한 무효화는 없다**: afterCommit, Double Delete, Pub/Sub 모두 극단적 케이스에서 stale 가능성이 있다. TTL이 최종 안전망이라는 인식이 중요하다. +5. **한계를 아는 것이 설계의 일부다**: KEYS 패턴 문제, Pub/Sub 유실, Cache Stampede, 좋아요 목록 stale — 이런 제약을 인식하고 감수한 이유를 설명할 수 있어야 한다. \ No newline at end of file diff --git a/k6/detail-test.js b/k6/detail-test.js new file mode 100644 index 000000000..ae058a660 --- /dev/null +++ b/k6/detail-test.js @@ -0,0 +1,151 @@ +import http from 'k6/http'; +import { check, sleep } from 'k6'; +import { Trend, Counter } from 'k6/metrics'; + +// 파레토 분포: 상위 1% 상품(ID 1~100)에 80% 트래픽 +const HOT_PRODUCT_IDS = Array.from({ length: 100 }, (_, i) => i + 1); +const COLD_PRODUCT_IDS = Array.from({ length: 9900 }, (_, i) => i + 101); + +const BASE_URL = __ENV.BASE_URL || 'http://localhost:8080'; +const USER_ID = '1'; + +// 커스텀 메트릭 +const v1Duration = new Trend('v1_detail_duration', true); +const v2Duration = new Trend('v2_detail_duration', true); +const v3Duration = new Trend('v3_detail_duration', true); +const v1Requests = new Counter('v1_detail_requests'); +const v2Requests = new Counter('v2_detail_requests'); +const v3Requests = new Counter('v3_detail_requests'); + +export const options = { + summaryTrendStats: ['avg', 'min', 'med', 'max', 'p(50)', 'p(95)', 'p(99)'], + scenarios: { + v1_detail: { + executor: 'constant-vus', + vus: 20, + duration: '30s', + exec: 'testV1', + tags: { version: 'v1' }, + startTime: '0s', + }, + v2_warmup: { + executor: 'per-vu-iterations', + vus: 1, + iterations: 50, + exec: 'warmupV2', + tags: { version: 'v2-warmup' }, + startTime: '35s', + }, + v2_detail: { + executor: 'constant-vus', + vus: 20, + duration: '30s', + exec: 'testV2', + tags: { version: 'v2' }, + startTime: '40s', + }, + v3_warmup: { + executor: 'per-vu-iterations', + vus: 1, + iterations: 50, + exec: 'warmupV3', + tags: { version: 'v3-warmup' }, + startTime: '75s', + }, + v3_detail: { + executor: 'constant-vus', + vus: 20, + duration: '30s', + exec: 'testV3', + tags: { version: 'v3' }, + startTime: '80s', + }, + }, + thresholds: { + 'v1_detail_duration': ['p(50)<50', 'p(95)<200'], + 'v2_detail_duration': ['p(50)<20', 'p(95)<100'], + 'v3_detail_duration': ['p(50)<10', 'p(95)<50'], + }, +}; + +function getProductId() { + // 80% 확률로 핫 상품, 20% 확률로 콜드 상품 + if (Math.random() < 0.8) { + return HOT_PRODUCT_IDS[Math.floor(Math.random() * HOT_PRODUCT_IDS.length)]; + } + return COLD_PRODUCT_IDS[Math.floor(Math.random() * COLD_PRODUCT_IDS.length)]; +} + +const params = { + headers: { 'X-User-Id': USER_ID }, +}; + +export function warmupV2() { + const productId = getProductId(); + http.get(`${BASE_URL}/api/experiment/products/v2/${productId}`, params); +} + +export function warmupV3() { + const productId = getProductId(); + http.get(`${BASE_URL}/api/experiment/products/v3/${productId}`, params); +} + +export function testV1() { + const productId = getProductId(); + const res = http.get(`${BASE_URL}/api/experiment/products/v1/${productId}`, params); + check(res, { 'v1 status 200': (r) => r.status === 200 }); + v1Duration.add(res.timings.duration); + v1Requests.add(1); +} + +export function testV2() { + const productId = getProductId(); + const res = http.get(`${BASE_URL}/api/experiment/products/v2/${productId}`, params); + check(res, { 'v2 status 200': (r) => r.status === 200 }); + v2Duration.add(res.timings.duration); + v2Requests.add(1); +} + +export function testV3() { + const productId = getProductId(); + const res = http.get(`${BASE_URL}/api/experiment/products/v3/${productId}`, params); + check(res, { 'v3 status 200': (r) => r.status === 200 }); + v3Duration.add(res.timings.duration); + v3Requests.add(1); +} + +export function handleSummary(data) { + const extract = (metricName) => { + const m = data.metrics[metricName]; + if (!m) return { avg: '-', p50: '-', p95: '-', p99: '-', max: '-' }; + return { + avg: m.values['avg']?.toFixed(2) || '-', + p50: m.values['p(50)']?.toFixed(2) || '-', + p95: m.values['p(95)']?.toFixed(2) || '-', + p99: m.values['p(99)']?.toFixed(2) || '-', + max: m.values['max']?.toFixed(2) || '-', + }; + }; + + const v1 = extract('v1_detail_duration'); + const v2 = extract('v2_detail_duration'); + const v3 = extract('v3_detail_duration'); + + const summary = ` +======================================== + 상품 상세 조회 성능 비교 결과 +======================================== + +| 지표 | v1 (DB) | v2 (Redis) | v3 (L1+L2) | +|-----------|------------|------------|------------| +| avg (ms) | ${v1.avg.padStart(8)} | ${v2.avg.padStart(8)} | ${v3.avg.padStart(8)} | +| p50 (ms) | ${v1.p50.padStart(8)} | ${v2.p50.padStart(8)} | ${v3.p50.padStart(8)} | +| p95 (ms) | ${v1.p95.padStart(8)} | ${v2.p95.padStart(8)} | ${v3.p95.padStart(8)} | +| p99 (ms) | ${v1.p99.padStart(8)} | ${v2.p99.padStart(8)} | ${v3.p99.padStart(8)} | +| max (ms) | ${v1.max.padStart(8)} | ${v2.max.padStart(8)} | ${v3.max.padStart(8)} | + +======================================== +`; + console.log(summary); + return {}; +} diff --git a/k6/list-test.js b/k6/list-test.js new file mode 100644 index 000000000..7c0d2c09e --- /dev/null +++ b/k6/list-test.js @@ -0,0 +1,236 @@ +import http from 'k6/http'; +import { check } from 'k6'; +import { Trend, Counter } from 'k6/metrics'; + +const BASE_URL = __ENV.BASE_URL || 'http://localhost:8080'; + +// 페이지 깊이 분포: 70% 얕은(1~5), 25% 중간(6~20), 5% 깊은(21~50) +function getPage() { + const r = Math.random(); + if (r < 0.7) return Math.floor(Math.random() * 5); // page 0~4 + if (r < 0.95) return 5 + Math.floor(Math.random() * 15); // page 5~19 + return 20 + Math.floor(Math.random() * 30); // page 20~49 +} + +// Cursor 시뮬레이션: 깊은 페이지는 더 작은 cursor ID +function getCursor() { + const r = Math.random(); + if (r < 0.7) return 10000 - Math.floor(Math.random() * 100); // 최신 근처 + if (r < 0.95) return 10000 - 100 - Math.floor(Math.random() * 300); + return 10000 - 400 - Math.floor(Math.random() * 600); // 오래된 데이터 +} + +// ===== Offset 메트릭 ===== +const v1OffsetDuration = new Trend('v1_offset_duration', true); +const v2OffsetDuration = new Trend('v2_offset_duration', true); +const v3OffsetDuration = new Trend('v3_offset_duration', true); + +// ===== Cursor 메트릭 ===== +const v1CursorDuration = new Trend('v1_cursor_duration', true); +const v2CursorDuration = new Trend('v2_cursor_duration', true); +const v3CursorDuration = new Trend('v3_cursor_duration', true); + +export const options = { + summaryTrendStats: ['avg', 'min', 'med', 'max', 'p(50)', 'p(95)', 'p(99)'], + scenarios: { + // ===== Offset ===== + v1_offset: { + executor: 'constant-vus', + vus: 20, + duration: '30s', + exec: 'testOffsetV1', + tags: { version: 'v1', pagination: 'offset' }, + startTime: '0s', + }, + v2_offset_warmup: { + executor: 'per-vu-iterations', + vus: 1, + iterations: 50, + exec: 'warmupOffsetV2', + tags: { version: 'v2-warmup', pagination: 'offset' }, + startTime: '35s', + }, + v2_offset: { + executor: 'constant-vus', + vus: 20, + duration: '30s', + exec: 'testOffsetV2', + tags: { version: 'v2', pagination: 'offset' }, + startTime: '40s', + }, + v3_offset_warmup: { + executor: 'per-vu-iterations', + vus: 1, + iterations: 50, + exec: 'warmupOffsetV3', + tags: { version: 'v3-warmup', pagination: 'offset' }, + startTime: '75s', + }, + v3_offset: { + executor: 'constant-vus', + vus: 20, + duration: '30s', + exec: 'testOffsetV3', + tags: { version: 'v3', pagination: 'offset' }, + startTime: '80s', + }, + // ===== Cursor ===== + v1_cursor: { + executor: 'constant-vus', + vus: 20, + duration: '30s', + exec: 'testCursorV1', + tags: { version: 'v1', pagination: 'cursor' }, + startTime: '115s', + }, + v2_cursor_warmup: { + executor: 'per-vu-iterations', + vus: 1, + iterations: 50, + exec: 'warmupCursorV2', + tags: { version: 'v2-warmup', pagination: 'cursor' }, + startTime: '150s', + }, + v2_cursor: { + executor: 'constant-vus', + vus: 20, + duration: '30s', + exec: 'testCursorV2', + tags: { version: 'v2', pagination: 'cursor' }, + startTime: '155s', + }, + v3_cursor_warmup: { + executor: 'per-vu-iterations', + vus: 1, + iterations: 50, + exec: 'warmupCursorV3', + tags: { version: 'v3-warmup', pagination: 'cursor' }, + startTime: '190s', + }, + v3_cursor: { + executor: 'constant-vus', + vus: 20, + duration: '30s', + exec: 'testCursorV3', + tags: { version: 'v3', pagination: 'cursor' }, + startTime: '195s', + }, + }, +}; + +// ===== Warm-up ===== + +export function warmupOffsetV2() { + const page = getPage(); + http.get(`${BASE_URL}/api/experiment/products/v2/offset?page=${page}&size=20`); +} + +export function warmupOffsetV3() { + const page = getPage(); + http.get(`${BASE_URL}/api/experiment/products/v3/offset?page=${page}&size=20`); +} + +export function warmupCursorV2() { + const cursor = getCursor(); + http.get(`${BASE_URL}/api/experiment/products/v2/cursor?cursor=${cursor}&size=20`); +} + +export function warmupCursorV3() { + const cursor = getCursor(); + http.get(`${BASE_URL}/api/experiment/products/v3/cursor?cursor=${cursor}&size=20`); +} + +// ===== Offset ===== + +export function testOffsetV1() { + const page = getPage(); + const res = http.get(`${BASE_URL}/api/experiment/products/v1/offset?page=${page}&size=20`); + check(res, { 'v1 offset 200': (r) => r.status === 200 }); + v1OffsetDuration.add(res.timings.duration); +} + +export function testOffsetV2() { + const page = getPage(); + const res = http.get(`${BASE_URL}/api/experiment/products/v2/offset?page=${page}&size=20`); + check(res, { 'v2 offset 200': (r) => r.status === 200 }); + v2OffsetDuration.add(res.timings.duration); +} + +export function testOffsetV3() { + const page = getPage(); + const res = http.get(`${BASE_URL}/api/experiment/products/v3/offset?page=${page}&size=20`); + check(res, { 'v3 offset 200': (r) => r.status === 200 }); + v3OffsetDuration.add(res.timings.duration); +} + +// ===== Cursor ===== + +export function testCursorV1() { + const cursor = getCursor(); + const res = http.get(`${BASE_URL}/api/experiment/products/v1/cursor?cursor=${cursor}&size=20`); + check(res, { 'v1 cursor 200': (r) => r.status === 200 }); + v1CursorDuration.add(res.timings.duration); +} + +export function testCursorV2() { + const cursor = getCursor(); + const res = http.get(`${BASE_URL}/api/experiment/products/v2/cursor?cursor=${cursor}&size=20`); + check(res, { 'v2 cursor 200': (r) => r.status === 200 }); + v2CursorDuration.add(res.timings.duration); +} + +export function testCursorV3() { + const cursor = getCursor(); + const res = http.get(`${BASE_URL}/api/experiment/products/v3/cursor?cursor=${cursor}&size=20`); + check(res, { 'v3 cursor 200': (r) => r.status === 200 }); + v3CursorDuration.add(res.timings.duration); +} + +export function handleSummary(data) { + const extract = (name) => { + const m = data.metrics[name]; + if (!m) return { avg: '-', p50: '-', p95: '-', p99: '-', max: '-' }; + return { + avg: m.values['avg']?.toFixed(2) || '-', + p50: m.values['p(50)']?.toFixed(2) || '-', + p95: m.values['p(95)']?.toFixed(2) || '-', + p99: m.values['p(99)']?.toFixed(2) || '-', + max: m.values['max']?.toFixed(2) || '-', + }; + }; + + const v1o = extract('v1_offset_duration'); + const v2o = extract('v2_offset_duration'); + const v3o = extract('v3_offset_duration'); + const v1c = extract('v1_cursor_duration'); + const v2c = extract('v2_cursor_duration'); + const v3c = extract('v3_cursor_duration'); + + const summary = ` +======================================== + 상품 목록 조회 성능 비교 결과 +======================================== + + [Offset Pagination] +| 지표 | v1 (DB) | v2 (Redis) | v3 (L1+L2) | +|-----------|------------|------------|------------| +| avg (ms) | ${v1o.avg.padStart(8)} | ${v2o.avg.padStart(8)} | ${v3o.avg.padStart(8)} | +| p50 (ms) | ${v1o.p50.padStart(8)} | ${v2o.p50.padStart(8)} | ${v3o.p50.padStart(8)} | +| p95 (ms) | ${v1o.p95.padStart(8)} | ${v2o.p95.padStart(8)} | ${v3o.p95.padStart(8)} | +| p99 (ms) | ${v1o.p99.padStart(8)} | ${v2o.p99.padStart(8)} | ${v3o.p99.padStart(8)} | +| max (ms) | ${v1o.max.padStart(8)} | ${v2o.max.padStart(8)} | ${v3o.max.padStart(8)} | + + [Cursor Pagination] +| 지표 | v1 (DB) | v2 (Redis) | v3 (L1+L2) | +|-----------|------------|------------|------------| +| avg (ms) | ${v1c.avg.padStart(8)} | ${v2c.avg.padStart(8)} | ${v3c.avg.padStart(8)} | +| p50 (ms) | ${v1c.p50.padStart(8)} | ${v2c.p50.padStart(8)} | ${v3c.p50.padStart(8)} | +| p95 (ms) | ${v1c.p95.padStart(8)} | ${v2c.p95.padStart(8)} | ${v3c.p95.padStart(8)} | +| p99 (ms) | ${v1c.p99.padStart(8)} | ${v2c.p99.padStart(8)} | ${v3c.p99.padStart(8)} | +| max (ms) | ${v1c.max.padStart(8)} | ${v2c.max.padStart(8)} | ${v3c.max.padStart(8)} | + +======================================== +`; + console.log(summary); + return {}; +} diff --git a/modules/redis/src/main/java/com/loopers/config/redis/RedisConfig.java b/modules/redis/src/main/java/com/loopers/config/redis/RedisConfig.java index 0a2b614ca..663dca4ec 100644 --- a/modules/redis/src/main/java/com/loopers/config/redis/RedisConfig.java +++ b/modules/redis/src/main/java/com/loopers/config/redis/RedisConfig.java @@ -7,6 +7,7 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Primary; +import org.springframework.data.redis.connection.RedisStandaloneConfiguration; import org.springframework.data.redis.connection.RedisStaticMasterReplicaConfiguration; import org.springframework.data.redis.connection.lettuce.LettuceClientConfiguration; import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; @@ -20,6 +21,7 @@ @EnableConfigurationProperties(RedisProperties.class) public class RedisConfig{ private static final String CONNECTION_MASTER = "redisConnectionMaster"; + public static final String CONNECTION_PUB_SUB = "redisConnectionPubSub"; public static final String REDIS_TEMPLATE_MASTER = "redisTemplateMaster"; private final RedisProperties redisProperties; @@ -40,6 +42,15 @@ public LettuceConnectionFactory defaultRedisConnectionFactory() { ); } + @Qualifier(CONNECTION_PUB_SUB) + @Bean + public LettuceConnectionFactory pubSubRedisConnectionFactory() { + RedisNodeInfo master = redisProperties.master(); + RedisStandaloneConfiguration standaloneConfig = new RedisStandaloneConfiguration(master.host(), master.port()); + standaloneConfig.setDatabase(redisProperties.database()); + return new LettuceConnectionFactory(standaloneConfig); + } + @Qualifier(CONNECTION_MASTER) @Bean public LettuceConnectionFactory masterRedisConnectionFactory() { diff --git a/modules/redis/src/testFixtures/java/com/loopers/testcontainers/RedisTestContainersConfig.java b/modules/redis/src/testFixtures/java/com/loopers/testcontainers/RedisTestContainersConfig.java index 35bf94f06..83edd0458 100644 --- a/modules/redis/src/testFixtures/java/com/loopers/testcontainers/RedisTestContainersConfig.java +++ b/modules/redis/src/testFixtures/java/com/loopers/testcontainers/RedisTestContainersConfig.java @@ -10,9 +10,6 @@ public class RedisTestContainersConfig { static { redisContainer.start(); - } - - public RedisTestContainersConfig() { System.setProperty("datasource.redis.database", "0"); System.setProperty("datasource.redis.master.host", redisContainer.getHost()); System.setProperty("datasource.redis.master.port", String.valueOf(redisContainer.getFirstMappedPort())); From a772bb913bbca5aafd80a6a8a72382f68e5f6c84 Mon Sep 17 00:00:00 2001 From: ghtjr410 Date: Fri, 13 Mar 2026 17:09:00 +0900 Subject: [PATCH 4/5] =?UTF-8?q?perf:=20=EC=BA=90=EC=8B=9C=20=ED=9E=88?= =?UTF-8?q?=ED=8A=B8=EC=9C=A8=20=EC=B8=A1=EC=A0=95=20=EC=B6=94=EA=B0=80,?= =?UTF-8?q?=20L1=20=EB=AA=A9=EB=A1=9D=20maxSize=20=EC=A1=B0=EC=A0=95,=20?= =?UTF-8?q?=EC=BA=90=EC=8B=9C=20=EC=A0=84=EB=9E=B5=20=EB=B3=B4=EA=B3=A0?= =?UTF-8?q?=EC=84=9C=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../product/ProductExperimentFacade.java | 3 +- .../product/ProductCacheManager.java | 29 ++ .../product/ProductLocalCacheManager.java | 2 +- docs/performance/product/cache-strategy.md | 317 ++++++++++++++---- 4 files changed, 284 insertions(+), 67 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductExperimentFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductExperimentFacade.java index 0d1b6f4c2..3eec178ea 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductExperimentFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductExperimentFacade.java @@ -213,7 +213,8 @@ public CursorResult getListCursorV3(Long brandId, Long cursor, int size) { // ===== Cache Stats ===== public String getCacheStats() { - return "L1 Detail: " + localCacheManager.getDetailStats() + return productCacheManager.getStats() + + " | L1 Detail: " + localCacheManager.getDetailStats() + " | L1 List: " + localCacheManager.getListStats(); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductCacheManager.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductCacheManager.java index 51c86f62c..bd2d005a0 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductCacheManager.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductCacheManager.java @@ -12,6 +12,7 @@ import java.util.List; import java.util.Optional; import java.util.Set; +import java.util.concurrent.atomic.AtomicLong; @Slf4j @Component @@ -27,6 +28,11 @@ public class ProductCacheManager { private final RedisTemplate redisTemplate; private final ObjectMapper objectMapper; + private final AtomicLong detailHit = new AtomicLong(); + private final AtomicLong detailMiss = new AtomicLong(); + private final AtomicLong listHit = new AtomicLong(); + private final AtomicLong listMiss = new AtomicLong(); + // Command public void putDetail(Long productId, ProductInfo info) { @@ -76,12 +82,15 @@ private void publishInvalidation(String message) { public Optional getDetail(Long productId) { String json = redisTemplate.opsForValue().get(detailKey(productId)); if (json == null) { + detailMiss.incrementAndGet(); return Optional.empty(); } try { + detailHit.incrementAndGet(); return Optional.of(objectMapper.readValue(json, ProductInfo.class)); } catch (JsonProcessingException e) { log.warn("상품 상세 캐시 역직렬화 실패. productId={}", productId, e); + detailMiss.incrementAndGet(); return Optional.empty(); } } @@ -89,16 +98,36 @@ public Optional getDetail(Long productId) { public Optional getList(Long brandId, String sort, int page, int size) { String json = redisTemplate.opsForValue().get(listKey(brandId, sort, page, size)); if (json == null) { + listMiss.incrementAndGet(); return Optional.empty(); } try { + listHit.incrementAndGet(); return Optional.of(objectMapper.readValue(json, CachedPage.class)); } catch (JsonProcessingException e) { log.warn("상품 목록 캐시 역직렬화 실패. brandId={}, sort={}", brandId, sort, e); + listMiss.incrementAndGet(); return Optional.empty(); } } + public String getStats() { + long dTotal = detailHit.get() + detailMiss.get(); + long lTotal = listHit.get() + listMiss.get(); + return String.format( + "Redis L2 — detail[hit=%d, miss=%d, rate=%.1f%%] list[hit=%d, miss=%d, rate=%.1f%%]", + detailHit.get(), detailMiss.get(), dTotal > 0 ? 100.0 * detailHit.get() / dTotal : 0, + listHit.get(), listMiss.get(), lTotal > 0 ? 100.0 * listHit.get() / lTotal : 0 + ); + } + + public void resetStats() { + detailHit.set(0); + detailMiss.set(0); + listHit.set(0); + listMiss.set(0); + } + private String detailKey(Long productId) { return DETAIL_KEY_PREFIX + productId; } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductLocalCacheManager.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductLocalCacheManager.java index b6cd9e1f3..129bb7d98 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductLocalCacheManager.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductLocalCacheManager.java @@ -25,7 +25,7 @@ public ProductLocalCacheManager() { .build(); this.listCache = Caffeine.newBuilder() - .maximumSize(100) + .maximumSize(500) .expireAfterWrite(Duration.ofSeconds(30)) .recordStats() .build(); diff --git a/docs/performance/product/cache-strategy.md b/docs/performance/product/cache-strategy.md index d3adf2ad4..0bd63589a 100644 --- a/docs/performance/product/cache-strategy.md +++ b/docs/performance/product/cache-strategy.md @@ -9,6 +9,9 @@ RedisTemplate 직접 사용, Read-Through 패턴, Redis Pub/Sub 기반 L1 무효 afterCommit 적용과 SET 레지스트리 전환으로 정합성·블로킹 문제를 해결했으며, 남은 제약(좋아요 시 목록 stale, Cache Stampede 등)과 프로덕션 보강 방향을 함께 정리한다. +목록 조회에서 v1(DB) avg 963ms → v3(L1+L2) avg 8ms로 **Cursor 기준 88배 개선**. +캐시 히트율은 L2 목록 **98.4%**, L1 목록 **94.0%**으로 우수 수준. + --- ## 1. 캐시 적용 여부 판단 @@ -66,9 +69,9 @@ v2/v3 실험 코드에서 hit/miss 분기를 명시적으로 작성해야 비교 // v2 실험 코드 — hit/miss 분기가 명시적 Optional cached = productCacheManager.getDetail(productId); if (cached.isPresent()) { - return cached.get(); // HIT → DB 스킵 + return cached.get(); // HIT → DB 스킵 } else { -info = ProductInfo.from(product, ...); // MISS → DB 조회 + info = ProductInfo.from(product, ...); // MISS → DB 조회 productCacheManager.putDetail(productId, info); // Redis에 저장 } ``` @@ -82,7 +85,7 @@ info = ProductInfo.from(product, ...); // MISS → DB 조회 | L2 (Redis) | 상품 상세 | 10분 | 관리자만 수정, 하루 수 회 이하. 변경 빈도 낮음 | | L2 (Redis) | 상품 목록 (offset/cursor) | 5분 | 좋아요 변경으로 정렬 변동 가능. 상세보다 짧게 | | L1 (Caffeine) | 상품 상세 | 1분 | Pub/Sub 유실 대비 안전망. maxSize=200개 | -| L1 (Caffeine) | 상품 목록 | 30초 | 변동 빈도 높은 데이터, 가장 짧게. maxSize=100개 | +| L1 (Caffeine) | 상품 목록 | 30초 | 변동 빈도 높은 데이터, 가장 짧게. maxSize=500개 | ### TTL 계층 구조의 원칙 @@ -163,27 +166,78 @@ Pub/Sub이 유실되더라도 Caffeine TTL(상세 1분, 목록 30초)이 최종 --- -## 8. 장애 시나리오 대비 현황 +## 8. 캐시 무효화의 구조적 한계와 발전 경로 + +### afterCommit DELETE로도 막을 수 없는 레이스 컨디션 + +afterCommit으로 커밋 후 무효화를 보장해도, 다음 시나리오에서 stale data가 캐시에 남을 수 있다. + +``` +Thread A: DB UPDATE (10,000→15,000) +Thread B: 캐시 MISS → DB 읽음 (10,000, 아직 커밋 전) +Thread A: COMMIT → afterCommit DELETE +Thread B: Redis SET (10,000) ← DELETE 뒤에 SET! +결과: 캐시에 옛날 값(10,000원)이 TTL까지 남음 +``` + +Thread B가 DB를 읽은 시점에는 Thread A가 아직 커밋 전이라 옛날 값을 읽는다. +그 사이에 Thread A가 커밋하고 DELETE까지 끝냈는데, Thread B는 뒤늦게 SET을 실행한다. +**읽기 스레드가 쓰기 스레드의 존재 자체를 모르기 때문에 생기는 구조적 문제**다. + +이 레이스가 발생하려면 세 조건이 동시에 충족되어야 한다. +1. 캐시가 정확히 이 시점에 miss (TTL 만료 or 직전에 삭제됨) +2. 읽기 스레드가 DB를 읽는 시점이 쓰기 커밋 직전 +3. 읽기 스레드의 SET이 쓰기 스레드의 DELETE보다 늦게 도착 + +윈도우가 수백ms 이내로 매우 좁아 확률은 낮지만, 트래픽이 올라가면 언젠가 발생한다. + +### 규모별 발전 경로 + +이 레이스 컨디션에 대한 대응 수준은 서비스 규모에 따라 달라진다. + +| 단계 | 전략 | stale 최대 시간 | 적합 규모 | 대표 사례 | +|------|------|---------------|----------|----------| +| 1단계 | **afterCommit DELETE + TTL (현재)** | TTL 전체 (5~10분) | 소규모 커머스 | 일반 서비스 | +| 2단계 | Delayed Double Delete | ~500ms | 중규모 커머스 | 트래픽 증가 시 전환 | +| 3단계 | Version 기반 충돌 해결 | 0초 (원천 해결) | 대규모 | Meta TAO | +| 4단계 | CDC(binlog) + 비동기 무효화 | near-realtime | 초대규모 | Uber Flux | + +**현재 1단계를 선택한 이유:** + +- 레이스 윈도우가 수백ms 이내로 매우 좁아 발생 확률이 극히 낮다. +- 발생하더라도 TTL(상세 10분, 목록 5분) 만료 시 자연 해소된다. +- 상품 가격이 잠깐 틀려도 **결제 시점에서 DB 가격을 재검증**하면 실제 손해는 없다. +- 2단계(DDD)는 코드 복잡도가 증가하고, 유효 캐시 오삭제 위험이 있다. + +**2단계 전환 시점:** +트래픽이 늘어 레이스 발생 빈도가 체감될 때, 또는 가격 정합성에 대한 비즈니스 요구가 강화될 때. afterCommit 구조 위에 500ms 지연 2차 DELETE만 추가하면 되므로 전환 비용이 낮다. + +**3단계는 현재 과도한 이유:** +각 데이터에 version 필드를 붙이고 SET 시 버전 비교를 해야 하므로, 매 읽기마다 Redis 2회 호출(GET version + GET data)이 필요하다. 또한 전역 버전 사용 시 상품 1개 수정에 전체 캐시가 무효화되는 스탬피드 위험이 있다. Meta TAO처럼 하루 1경건 요청을 처리하는 규모가 아니면 비용 대비 이득이 없다. + +--- + +## 9. 장애 시나리오 대비 현황 | 시나리오 | 대비 상태 | 현재 구현 | |---------|----------|----------| | Redis 다운 | ✅ 대비 | try-catch + Optional.empty → DB fallback | -| Caffeine 메모리 초과 | ✅ 대비 | maxSize(상세 200, 목록 100) + LRU eviction | +| Caffeine 메모리 초과 | ✅ 대비 | maxSize(상세 200, 목록 500) + LRU eviction | | Pub/Sub 유실 | ✅ 대비 | TTL이 안전망 (최대 1분 stale) | | 캐시-DB 정합성 | ✅ 대비 | TransactionHelper.afterCommit()으로 커밋 후 무효화 | | KEYS * 블로킹 | ✅ 대비 | 키 레지스트리(Set) 기반으로 KEYS 패턴 제거 | +| afterCommit 레이스 | ⚠️ TTL 의존 | 구조적 한계. 발전 경로 인지 (섹션 8) | | Cache Stampede | ⚠️ 미대비 | 분산 락 미적용. TTL 만료 시 동시 DB 폭주 가능 | --- -## 9. 알려진 제약 사항과 개선 방향 +## 10. 알려진 제약 사항과 개선 방향 ### ~~제약 1: afterCommit 미사용~~ → 해결 완료 **현재 상태**: `TransactionHelper.afterCommit()`을 통해 트랜잭션 커밋 후 캐시 무효화가 실행된다. ```java -// ProductFacade.updateInfo() — 현재 코드 @Transactional public ProductInfo updateInfo(Long productId, ...) { Product product = productService.updateInfo(productId, command); @@ -196,7 +250,7 @@ public ProductInfo updateInfo(Long productId, ...) { ``` 커밋이 확정된 후에만 캐시를 무효화하므로, 롤백 시 불필요한 cache miss와 커밋 전 stale 재적재 문제가 모두 해결되었다. -단, afterCommit은 동시 쓰기 시 레이스 컨디션을 완전히 제거하지는 않는다 (Thread A DELETE 후 Thread B가 stale 값을 SET하는 시나리오). TTL이 최종 안전망이며, 이는 캐시 무효화의 구조적 한계다. +단, afterCommit은 동시 쓰기 시 레이스 컨디션을 완전히 제거하지는 않는다 (섹션 8 참조). TTL이 최종 안전망이며, 이는 캐시 무효화의 구조적 한계다. ### ~~제약 2: KEYS 패턴~~ → 해결 완료 @@ -221,27 +275,24 @@ public void evictAllLists() { } ``` -KEYS 명령 없이 **추적된 키만 정확히 삭제**하므로 Redis 블로킹 위험이 없다. page/size가 동적이어도 저장 시점에 추적하므로 누락이 발생하지 않는다. +KEYS 명령 없이 **추적된 키만 정확히 삭제**하므로 Redis 블로킹 위험이 없다. ### 제약 3: 좋아요 변경 시 목록 stale -**현재 상태**: 좋아요 등록/취소 시 상세 캐시만 무효화, 목록은 TTL(5분) 의존. - -**왜 이렇게 했는가**: 좋아요는 빈도가 높다. 매 좋아요마다 전체 목록 evict하면 캐시 적중률이 급락한다. +좋아요 등록/취소 시 상세 캐시만 무효화, 목록은 TTL(5분) 의존. +좋아요는 빈도가 높아 매번 전체 목록 evict하면 캐시 적중률이 급락한다. **개선 방향**: 트래픽 커지면 `@TransactionalEventListener`로 커밋 후 좋아요순 목록만 선택적 evict. ### 제약 4: Cache Stampede 미대비 -**현재 상태**: TTL 만료 시 동시에 같은 키를 조회하면 모든 요청이 DB를 직접 친다. +TTL 만료 시 동시에 같은 키를 조회하면 모든 요청이 DB를 직접 친다. **개선 방향**: Redis 분산 락(`setIfAbsent` + TTL)으로 한 요청만 DB 조회하고 나머지는 대기. 또는 TTL 만료 전에 미리 갱신하는 Refresh-Ahead 패턴. ### 제약 5: Write-Through 미적용 -**현재 상태**: 무효화(DELETE) 후 다음 조회에서 캐시 미스가 1회 발생한다. - -**개선 방향**: 상품 상세는 관리자만 수정하므로 동시 쓰기가 거의 없다. DELETE 대신 SET(최신값)으로 전환하면 캐시 미스 자체를 없앨 수 있다. 단, 좋아요처럼 동시 쓰기가 빈번한 데이터는 순서 꼬임 위험이 있어 DELETE(무효화)가 적합하다. +무효화(DELETE) 후 다음 조회에서 캐시 미스가 1회 발생한다. | 대상 | 현재 전략 | 전환 가능 전략 | 전환 조건 | |------|---------|-------------|---------| @@ -251,81 +302,216 @@ KEYS 명령 없이 **추적된 키만 정확히 삭제**하므로 Redis 블로 --- -## 10. 전체 흐름 요약 - -### 상품 상세 조회 (v3 기준) - +## 11. 전체 흐름 요약 + +### 상품 상세 조회 (v3: L1 → L2 → DB) + +```mermaid +sequenceDiagram + participant C as Client + participant F as Facade + participant L1 as Caffeine (L1) + participant L2 as Redis (L2) + participant DB as MySQL + + C->>F: GET /products/{id} + + F->>L1: getDetail(id) + + alt L1 HIT (~0.1ms) + L1-->>F: ProductInfo + F-->>C: 200 OK + else L1 MISS + F->>L2: getDetail(id) + + alt L2 HIT (~1ms) + L2-->>F: ProductInfo (JSON) + F->>L1: putDetail(id, info) + Note over L1: TTL 1분, maxSize 200 + F-->>C: 200 OK + else L2 MISS (~10ms) + F->>DB: SELECT * FROM products WHERE id = ? + DB-->>F: Product Entity + F->>L2: putDetail(id, info) + Note over L2: TTL 10분 + F->>L1: putDetail(id, info) + F-->>C: 200 OK + end + end ``` -요청 → Caffeine(L1) 확인 - ├─ HIT → 바로 반환 (~0.1ms) - └─ MISS → Redis(L2) 확인 - ├─ HIT → L1에 승격 후 반환 (~1ms) - └─ MISS → DB 조회 → L2 저장 → L1 저장 → 반환 (~10ms) + +### 상품 목록 조회 (v3: L1 → L2 → DB) + +```mermaid +sequenceDiagram + participant C as Client + participant F as Facade + participant L1 as Caffeine (L1) + participant L2 as Redis (L2) + participant DB as MySQL + + C->>F: GET /products?brandId=1&sort=likes_desc + + F->>L1: getList(cacheKey) + + alt L1 HIT (~0.1ms) + L1-->>F: CachedPage + F-->>C: 200 OK + else L1 MISS + F->>L2: getList(brandId, sort, page, size) + + alt L2 HIT (~1ms) + L2-->>F: CachedPage (JSON) + F->>L1: putList(cacheKey, page) + Note over L1: TTL 30초, maxSize 500 + F-->>C: 200 OK + else L2 MISS + F->>DB: SELECT ... ORDER BY like_count DESC + DB-->>F: Page + F->>L2: putList(brandId, sort, page, size, cachedPage) + Note over L2: TTL 5분 + 키 레지스트리 등록 + F->>L1: putList(cacheKey, cachedPage) + F-->>C: 200 OK + end + end ``` -### 상품 수정 시 무효화 +### 상품 수정 시 캐시 무효화 + +```mermaid +sequenceDiagram + participant Admin as Admin + participant F as Facade + participant DB as MySQL + participant L2 as Redis (L2) + participant PS as Pub/Sub + participant L1a as Server A - Caffeine + participant L1b as Server B - Caffeine + + Admin->>F: PATCH /products/{id} + F->>DB: UPDATE products SET ... + F->>DB: COMMIT + + Note over F: afterCommit 콜백 실행 + + F->>L2: DELETE product:detail:{id} + F->>L2: SMEMBERS product:list-keys + L2-->>F: [key1, key2, ...] + F->>L2: DELETE key1, key2, ... + F->>L2: DELETE product:list-keys + + F->>PS: PUBLISH "detail:{id}" + F->>PS: PUBLISH "list:all" + PS-->>L1a: invalidate detail + lists + PS-->>L1b: invalidate detail + lists + Note over L1a,L1b: Pub/Sub 유실 시 TTL이 안전망
(상세 1분, 목록 30초) + + F-->>Admin: 200 OK ``` -상품 수정 → DB UPDATE → COMMIT - → afterCommit 실행: - → Redis DELETE(상세 키) - → Redis SMEMBERS(레지스트리) → DELETE(목록 키들) → DELETE(레지스트리) - → Redis PUBLISH(무효화 메시지) - → 모든 서버의 Caffeine 해당 키 삭제 + +### 좋아요 변경 시 캐시 무효화 + +```mermaid +sequenceDiagram + participant U as User + participant LF as LikeFacade + participant DB as MySQL + participant L2 as Redis (L2) + participant PS as Pub/Sub + participant L1 as Caffeine (L1) + + U->>LF: POST /likes (productId) + LF->>DB: INSERT INTO likes + UPDATE like_count + LF->>DB: COMMIT + + Note over LF: afterCommit 콜백 실행 + + LF->>L2: DELETE product:detail:{id} + LF->>PS: PUBLISH "detail:{id}" + PS-->>L1: invalidate detail + + Note over L2: 목록 캐시는 evict 안 함
→ TTL(5분) 만료까지 stale 허용
→ 좋아요 빈도 높아 매번 evict하면 캐시 무의미 + + LF-->>U: 200 OK ``` --- -## 11. k6 부하 테스트 결과 (20 VUs, 순차 실행) +## 12. k6 부하 테스트 결과 (20 VUs, 순차 실행) ### 상품 상세 조회 -| 지표 | v1 (DB) | v2 (Redis) | v3 (L1+L2) | 개선율 (v1→v3) | -|------|---------|-----------|-----------|--------------| -| avg | 18.16ms | 17.37ms | **13.88ms** | 24% | -| p50 | 14.26ms | 12.08ms | **10.82ms** | 24% | -| p95 | 40.30ms | 43.62ms | **29.66ms** | 26% | -| p99 | 77.67ms | 92.83ms | **61.37ms** | 21% | -| max | 186.00ms | 323.78ms | 512.75ms | - | +| 지표 | v1 (DB) | v2 (Redis) | v3 (L1+L2) | +|------|---------|-----------|-----------| +| avg | 13.51ms | 16.66ms | 15.82ms | +| p50 | 10.83ms | 12.29ms | 13.33ms | +| p95 | 27.29ms | 38.90ms | 31.95ms | +| p99 | 52.17ms | 80.45ms | 54.12ms | +| max | 442.62ms | 1,189.89ms | 338.05ms | + +**분석: 캐시가 DB보다 느리다 — 이것도 학습 포인트다.** + +상세 조회는 PK 기반(type=const, rows=1)이라 DB 쿼리 자체가 ~10ms로 이미 최적이다. +Redis 캐시를 거치면 네트워크 홉 + JSON 역직렬화 오버헤드가 추가되어 오히려 느려진다. +이는 **"캐시는 DB 쿼리가 비싼 곳에서만 효과가 있다"**는 것을 실측으로 보여준다. -**분석**: 상세 조회는 PK 기반(type=const, rows=1)이라 DB 자체가 이미 빠르다. -v1과 v2의 차이가 적은 이유는 Redis 네트워크 홉 비용과 PK 조회 비용이 비슷한 수준이기 때문이다. -v3(Caffeine)는 네트워크 0홉이라 p50 기준 24% 개선. 인기 상품에 트래픽이 집중되는 파레토 분포(상위 1%에 80% 트래픽)에서 L1 캐시 히트율이 높아 효과가 나타난다. +그럼에도 상세에 캐시를 유지하는 이유는 **DB 보호** 때문이다. +20 VU에서는 DB가 버티지만, 200 VU가 되면 커넥션 풀이 포화된다. +캐시 히트 시 DB 커넥션을 아예 안 잡으므로, 트래픽 폭주 시 DB를 보호하는 역할을 한다. ### 상품 목록 조회 — Offset Pagination | 지표 | v1 (DB) | v2 (Redis) | v3 (L1+L2) | 개선율 (v1→v3) | |------|---------|-----------|-----------|--------------| -| avg | 873.54ms | 11.14ms | **9.52ms** | **92배** | -| p50 | 852.20ms | 8.12ms | **7.79ms** | **109배** | -| p95 | 1,307.36ms | 21.87ms | **21.35ms** | **62배** | -| p99 | 1,495.66ms | 50.11ms | **34.66ms** | **43배** | -| max | 1,979.25ms | 1,392.15ms | **95.54ms** | 21배 | +| avg | 963.71ms | 18.29ms | **22.31ms** | **43배** | +| p50 | 947.26ms | 11.73ms | **16.14ms** | **59배** | +| p95 | 1,489.65ms | 43.36ms | **52.63ms** | **28배** | +| p99 | 1,854.94ms | 96.16ms | **142.60ms** | **13배** | +| max | 2,201.39ms | 1,349.06ms | **726.59ms** | 3배 | + +Offset에서 v3가 v2보다 약간 느린 이유: Offset은 키 조합이 다양해서(brandId × sort × page × size) L1 히트율이 Cursor만큼 높지 않다. ### 상품 목록 조회 — Cursor Pagination | 지표 | v1 (DB) | v2 (Redis) | v3 (L1+L2) | 개선율 (v1→v3) | |------|---------|-----------|-----------|--------------| -| avg | 273.36ms | 16.41ms | **8.09ms** | **34배** | -| p50 | 255.92ms | 10.82ms | **7.00ms** | **37배** | -| p95 | 478.80ms | 33.84ms | **15.76ms** | **30배** | -| p99 | 580.45ms | 153.85ms | **26.73ms** | **21배** | -| max | 830.49ms | 402.45ms | 374.23ms | - | +| avg | 740.83ms | 17.76ms | **8.42ms** | **88배** | +| p50 | 576.97ms | 11.25ms | **6.78ms** | **85배** | +| p95 | 2,059.54ms | 36.90ms | **17.85ms** | **115배** | +| p99 | 2,551.50ms | 171.34ms | **31.92ms** | **80배** | +| max | 3,084.75ms | 579.93ms | 372.09ms | 8배 | + +### 캐시 히트율 (k6 실행 후 측정) + +| 계층 | 대상 | hit | miss | 히트율 | 판정 | +|------|------|-----|------|--------|------| +| L2 (Redis) | 상세 | 35,426 | 7,857 | **81.8%** | 건강 (80%↑) | +| L2 (Redis) | 목록 | 68,658 | 1,114 | **98.4%** | 우수 (90%↑) | +| L1 (Caffeine) | 상세 | 30,084 | 7,514 | **80.1%** | 건강 | +| L1 (Caffeine) | 목록 | 93,854 | 3,520 | **94.0%** | 우수 | + +목록의 히트율이 상세보다 높은 이유: 목록은 브랜드×정렬 조합이 유한(20×3=60개)하여 같은 키가 반복 히트된다. +상세는 파레토 분포로 인기 상품(상위 1%)에 80% 트래픽이 집중되지만, 나머지 20% 트래픽이 10,000개 상품에 분산되어 miss가 상대적으로 많다. ### 종합 분석 **1. 목록 조회에서 캐시 효과가 극적이다.** -상세는 PK 조회라 v1이 이미 18ms로 빨랐지만, 목록은 v1이 873ms로 느리기 때문에 캐시 적용 시 92배 개선. 이는 인덱스 분석에서 "브랜드 필터 없는 전체 조회는 캐시로 해결"이라고 판단한 근거와 일치한다. +상세는 PK 조회라 DB 자체가 이미 빠르지만, 목록은 v1이 963ms로 느리기 때문에 캐시 적용 시 88배 개선(Cursor 기준). + +**2. Cursor가 Offset보다 v1(DB only)에서 1.3배 빠르다.** +Offset v1 avg=963ms, Cursor v1 avg=740ms. v3에서는 차이가 더 벌어진다(22ms vs 8ms). +캐시 미스 시에도 Cursor가 유리하고, 캐시 히트 시에도 L1 히트율이 더 높아 v3 효과가 극대화된다. -**2. Cursor가 Offset보다 v1(DB only)에서 3.2배 빠르다.** -Offset v1 avg=873ms, Cursor v1 avg=273ms. COUNT 쿼리 제거 효과다. -하지만 v2/v3에서는 차이가 줄어든다(v3 기준 9.5ms vs 8ms). 캐시 히트 시에는 페이지네이션 방식이 성능에 영향을 거의 주지 않는다. +**3. L1(Caffeine) 효과는 Cursor 목록에서 가장 뚜렷하다.** +v2 → v3: Cursor p99 171ms → 31ms(**82% 개선**). +L1이 Redis 네트워크 지연의 간헐적 스파이크를 흡수하는 안전망 역할을 한다. -**3. L1(Caffeine) 추가 효과는 목록에서 뚜렷하다.** -v2 → v3 개선: 상세 p50 12ms→10ms(17%), 목록 Cursor p99 154ms→27ms(**82%**). -특히 p99에서 L1 효과가 크다. Redis 네트워크 지연이 간헐적으로 발생할 때 L1이 안전망 역할을 한다. +**4. "캐시가 항상 빠른 건 아니다" — 상세 조회의 교훈.** +PK 조회처럼 이미 충분히 빠른 쿼리에는 캐시 오버헤드가 오히려 손해다. +캐시의 진짜 가치는 속도가 아니라 **DB 커넥션 풀 보호**에 있다. -**4. 테스트 조건** +**5. 테스트 조건** - VU: 20 (순차 실행, v1→v2→v3) - 각 시나리오 30초, warm-up 50회 - 상세: 파레토 분포 (상위 1% 상품에 80% 트래픽) @@ -333,10 +519,11 @@ v2 → v3 개선: 상세 p50 12ms→10ms(17%), 목록 Cursor p99 154ms→27ms(** --- -## 12. 핵심 학습 포인트 +## 13. 핵심 학습 포인트 1. **캐시는 계층이다**: 가까울수록 빠르고, 가까울수록 무효화가 어렵다. TTL을 계층별로 차등 설정하는 것이 핵심. 2. **독립 측정이 가능한 실험 설계**: v1/v2/v3로 각 계층의 기여도를 분리 측정할 수 있어야 "왜 이 계층이 필요한가"를 증명할 수 있다. -3. **인덱스와 캐시의 역할 분담**: 조건 조합이 다양한 패턴은 인덱스, 결과가 동일한 패턴은 캐시. 둘 다 쓰는 것이 실무적 판단. 방어 인덱스로 캐시 미스 시에도 DB가 견디는 이중 안전망. -4. **완벽한 무효화는 없다**: afterCommit, Double Delete, Pub/Sub 모두 극단적 케이스에서 stale 가능성이 있다. TTL이 최종 안전망이라는 인식이 중요하다. -5. **한계를 아는 것이 설계의 일부다**: KEYS 패턴 문제, Pub/Sub 유실, Cache Stampede, 좋아요 목록 stale — 이런 제약을 인식하고 감수한 이유를 설명할 수 있어야 한다. \ No newline at end of file +3. **캐시가 항상 빠른 건 아니다**: PK 조회처럼 DB 자체가 빠르면 캐시 오버헤드가 오히려 손해. 캐시의 진짜 가치는 속도가 아니라 DB 보호. +4. **인덱스와 캐시의 역할 분담**: 조건 조합이 다양한 패턴은 인덱스, 결과가 동일한 패턴은 캐시. 방어 인덱스로 캐시 미스 시에도 DB가 견디는 이중 안전망. +5. **완벽한 무효화는 없다**: afterCommit → DDD → Version 기반 → CDC로 정합성을 높일 수 있지만, 각 단계마다 복잡도가 올라간다. 현재 규모에 맞는 수준을 선택하고, 발전 경로를 인지하는 것이 실무적 판단. +6. **한계를 아는 것이 설계의 일부다**: afterCommit 레이스, Pub/Sub 유실, Cache Stampede, 좋아요 목록 stale — 이런 제약을 인식하고 감수한 이유를 설명할 수 있어야 한다. \ No newline at end of file From 6cb39d1f4d6a615829170509dd96c58ca81f40b0 Mon Sep 17 00:00:00 2001 From: ghtjr410 Date: Fri, 13 Mar 2026 22:55:40 +0900 Subject: [PATCH 5/5] =?UTF-8?q?docs:=20=EC=BA=90=EC=8B=9C=20=EC=8B=9C?= =?UTF-8?q?=ED=80=80=EC=8A=A4=20=EB=8B=A4=EC=9D=B4=EC=96=B4=EA=B7=B8?= =?UTF-8?q?=EB=9E=A8=20UML=20=ED=91=9C=EC=A4=80=20=EC=A4=80=EC=88=98=20(ac?= =?UTF-8?q?tivation=20bar,=20critical,=20par)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/performance/product/cache-strategy.md | 150 +++++++++------------ 1 file changed, 66 insertions(+), 84 deletions(-) diff --git a/docs/performance/product/cache-strategy.md b/docs/performance/product/cache-strategy.md index 0bd63589a..ea4e3f13f 100644 --- a/docs/performance/product/cache-strategy.md +++ b/docs/performance/product/cache-strategy.md @@ -304,7 +304,7 @@ TTL 만료 시 동시에 같은 키를 조회하면 모든 요청이 DB를 직 ## 11. 전체 흐름 요약 -### 상품 상세 조회 (v3: L1 → L2 → DB) +### 조회 흐름 (v3: L1 → L2 → DB) ```mermaid sequenceDiagram @@ -314,126 +314,108 @@ sequenceDiagram participant L2 as Redis (L2) participant DB as MySQL - C->>F: GET /products/{id} + C->>+F: 조회 요청 - F->>L1: getDetail(id) + activate F + F->>+L1: get(key) + L1-->>-F: Optional - alt L1 HIT (~0.1ms) - L1-->>F: ProductInfo - F-->>C: 200 OK + alt L1 HIT + F-->>C: 응답 else L1 MISS - F->>L2: getDetail(id) - - alt L2 HIT (~1ms) - L2-->>F: ProductInfo (JSON) - F->>L1: putDetail(id, info) - Note over L1: TTL 1분, maxSize 200 - F-->>C: 200 OK - else L2 MISS (~10ms) - F->>DB: SELECT * FROM products WHERE id = ? - DB-->>F: Product Entity - F->>L2: putDetail(id, info) - Note over L2: TTL 10분 - F->>L1: putDetail(id, info) - F-->>C: 200 OK - end - end -``` - -### 상품 목록 조회 (v3: L1 → L2 → DB) - -```mermaid -sequenceDiagram - participant C as Client - participant F as Facade - participant L1 as Caffeine (L1) - participant L2 as Redis (L2) - participant DB as MySQL + F->>+L2: get(key) + L2-->>-F: Optional - C->>F: GET /products?brandId=1&sort=likes_desc - - F->>L1: getList(cacheKey) - - alt L1 HIT (~0.1ms) - L1-->>F: CachedPage - F-->>C: 200 OK - else L1 MISS - F->>L2: getList(brandId, sort, page, size) - - alt L2 HIT (~1ms) - L2-->>F: CachedPage (JSON) - F->>L1: putList(cacheKey, page) - Note over L1: TTL 30초, maxSize 500 - F-->>C: 200 OK + alt L2 HIT + F->>L1: put(key) — L1 승격 + F-->>C: 응답 else L2 MISS - F->>DB: SELECT ... ORDER BY like_count DESC - DB-->>F: Page - F->>L2: putList(brandId, sort, page, size, cachedPage) - Note over L2: TTL 5분 + 키 레지스트리 등록 - F->>L1: putList(cacheKey, cachedPage) - F-->>C: 200 OK + critical @Transactional(readOnly) + F->>+DB: SELECT + DB-->>-F: Entity + end + F->>L2: set(key, TTL) + F->>L1: put(key) + F-->>C: 응답 end end + deactivate F ``` -### 상품 수정 시 캐시 무효화 +### 수정/삭제 시 캐시 무효화 ```mermaid sequenceDiagram - participant Admin as Admin + participant C as Client participant F as Facade participant DB as MySQL participant L2 as Redis (L2) participant PS as Pub/Sub - participant L1a as Server A - Caffeine - participant L1b as Server B - Caffeine + participant L1a as Server A (L1) + participant L1b as Server B (L1) - Admin->>F: PATCH /products/{id} - F->>DB: UPDATE products SET ... - F->>DB: COMMIT + C->>+F: 수정/삭제 요청 - Note over F: afterCommit 콜백 실행 + critical @Transactional + F->>+DB: UPDATE / DELETE + DB-->>-F: OK + end - F->>L2: DELETE product:detail:{id} - F->>L2: SMEMBERS product:list-keys - L2-->>F: [key1, key2, ...] - F->>L2: DELETE key1, key2, ... - F->>L2: DELETE product:list-keys + Note over F: afterCommit 콜백 - F->>PS: PUBLISH "detail:{id}" - F->>PS: PUBLISH "list:all" - PS-->>L1a: invalidate detail + lists - PS-->>L1b: invalidate detail + lists + activate F + F->>+L2: DEL 상세 키 + L2-->>-F: OK + F->>+L2: DEL 목록 키 (레지스트리 기반) + L2-->>-F: OK + F->>+PS: PUBLISH 무효화 메시지 + PS-->>-F: OK + deactivate F - Note over L1a,L1b: Pub/Sub 유실 시 TTL이 안전망
(상세 1분, 목록 30초) + par 멀티 인스턴스 L1 동기화 + PS-->>L1a: invalidate(key) + PS-->>L1b: invalidate(key) + end + + Note over L1a,L1b: Pub/Sub 유실 시 TTL이 최종 안전망 - F-->>Admin: 200 OK + F-->>-C: 응답 ``` -### 좋아요 변경 시 캐시 무효화 +### 좋아요 시 캐시 무효화 ```mermaid sequenceDiagram participant U as User - participant LF as LikeFacade + participant F as LikeFacade participant DB as MySQL participant L2 as Redis (L2) participant PS as Pub/Sub participant L1 as Caffeine (L1) - U->>LF: POST /likes (productId) - LF->>DB: INSERT INTO likes + UPDATE like_count - LF->>DB: COMMIT + U->>+F: 좋아요 등록/취소 + + critical @Transactional + F->>+DB: INSERT/DELETE like + DB-->>-F: OK + F->>+DB: UPDATE product.like_count + DB-->>-F: OK + end + + Note over F: afterCommit 콜백 - Note over LF: afterCommit 콜백 실행 + activate F + F->>+L2: DEL 상세 키만 + L2-->>-F: OK + F->>+PS: PUBLISH 상세 무효화 + PS-->>-F: OK + deactivate F - LF->>L2: DELETE product:detail:{id} - LF->>PS: PUBLISH "detail:{id}" - PS-->>L1: invalidate detail + PS-->>L1: invalidate(detail:productId) - Note over L2: 목록 캐시는 evict 안 함
→ TTL(5분) 만료까지 stale 허용
→ 좋아요 빈도 높아 매번 evict하면 캐시 무의미 + Note over L2: 목록은 evict 안 함
TTL(5분) 의존 — stale 허용 - LF-->>U: 200 OK + F-->>-U: 응답 ``` ---