From d0bc31e926e90d1ef0ac53cce989254088239e4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B5=E1=86=B7=E1=84=82=E1=85=A1=E1=84=92?= =?UTF-8?q?=E1=85=A7=E1=86=AB?= Date: Thu, 12 Mar 2026 16:26:34 +0900 Subject: [PATCH 01/17] =?UTF-8?q?refactor:=20=EC=A3=BC=EB=AC=B8=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EB=B6=88=ED=95=84=EC=9A=94=ED=95=9C=20FETCH=20JOIN=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0=20=EB=B0=8F=20=EC=A1=B0=ED=9A=8C=20=EA=B2=BD=EB=A1=9C?= =?UTF-8?q?=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../loopers/domain/order/OrderRepository.java | 1 + .../order/OrderJpaRepository.java | 7 +++- .../infrastructure/order/OrderMapper.java | 38 +++++++++++++++++++ .../order/OrderRepositoryImpl.java | 19 +++++++++- 4 files changed, 62 insertions(+), 3 deletions(-) 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 02d530076..c1d04dddf 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 @@ -8,6 +8,7 @@ public interface OrderRepository { Order save(Order order); Optional findById(Long id); List findAllByUserId(Long userId, ZonedDateTime startAt, ZonedDateTime endAt); + List findAllByUserIdWithItems(Long userId, ZonedDateTime startAt, ZonedDateTime endAt); List findAll(int page, int size); long count(); } 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 e9314aabd..9a7d9f841 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 @@ -13,11 +13,14 @@ public interface OrderJpaRepository extends JpaRepository { @Query("SELECT o FROM OrderEntity o LEFT JOIN FETCH o.items WHERE o.id = :id") Optional findByIdWithItems(@Param("id") Long id); - @Query("SELECT o FROM OrderEntity o LEFT JOIN FETCH o.items WHERE o.userId = :userId " + + @Query("SELECT o FROM OrderEntity o WHERE o.userId = :userId " + "AND o.createdAt >= :startAt AND o.createdAt < :endAt " + "ORDER BY o.createdAt DESC") - List findAllByUserIdAndCreatedAtBetween( + List findOrdersByUserIdAndCreatedAtBetween( @Param("userId") Long userId, @Param("startAt") ZonedDateTime startAt, @Param("endAt") ZonedDateTime endAt); + + @Query("SELECT o FROM OrderEntity o LEFT JOIN FETCH o.items WHERE o.id IN :ids") + List findAllByIdInWithItems(@Param("ids") List ids); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderMapper.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderMapper.java index e4786184a..378c20174 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderMapper.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderMapper.java @@ -108,6 +108,44 @@ public Order toDomain(OrderEntity entity) { ); } + /** + * JPA Entity → Domain (items 제외 — 목록 조회 전용) + */ + public Order toDomainWithoutItems(OrderEntity entity) { + Address address = new Address( + entity.getZipCode(), + entity.getAddressLine1(), + entity.getAddressLine2() + ); + + return Order.reconstitute( + entity.getId(), + entity.getUserId(), + entity.getOrderNumber(), + List.of(), + entity.getSubtotalAmount(), + entity.getDiscountAmount(), + entity.getPointUsedAmount(), + entity.getShippingFee(), + entity.getTotalAmount(), + entity.getStatus(), + entity.getOrdererName(), + entity.getOrdererPhone(), + entity.getReceiverName(), + entity.getReceiverPhone(), + address, + entity.getCouponId(), + entity.getPaymentId(), + entity.getPaymentMethod(), + entity.getOrderedAt(), + entity.getExpiresAt(), + entity.getCanceledAt(), + entity.getCreatedAt(), + entity.getUpdatedAt(), + entity.getDeletedAt() + ); + } + /** * OrderItem Domain → Entity */ 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 acaa284ab..5bf8a7f1a 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 @@ -37,7 +37,24 @@ public Optional findById(Long id) { @Override public List findAllByUserId(Long userId, ZonedDateTime startAt, ZonedDateTime endAt) { - return orderJpaRepository.findAllByUserIdAndCreatedAtBetween(userId, startAt, endAt) + return orderJpaRepository.findOrdersByUserIdAndCreatedAtBetween(userId, startAt, endAt) + .stream() + .map(orderMapper::toDomainWithoutItems) + .collect(Collectors.toList()); + } + + @Override + public List findAllByUserIdWithItems(Long userId, ZonedDateTime startAt, ZonedDateTime endAt) { + List ids = orderJpaRepository.findOrdersByUserIdAndCreatedAtBetween(userId, startAt, endAt) + .stream() + .map(OrderEntity::getId) + .collect(Collectors.toList()); + + if (ids.isEmpty()) { + return List.of(); + } + + return orderJpaRepository.findAllByIdInWithItems(ids) .stream() .map(orderMapper::toDomain) .collect(Collectors.toList()); From 3ce4c19779196e9b6258fd6c1069cdd7796a5b75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B5=E1=86=B7=E1=84=82=E1=85=A1=E1=84=92?= =?UTF-8?q?=E1=85=A7=E1=86=AB?= Date: Thu, 12 Mar 2026 17:15:35 +0900 Subject: [PATCH 02/17] =?UTF-8?q?refactor:=20=20=EC=83=81=ED=92=88=20JOIN/?= =?UTF-8?q?=EC=84=9C=EB=B8=8C=EC=BF=BC=EB=A6=AC=20=EB=AA=A8=EB=91=90=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0,=20getActiveBrandIds()=20+=20=EB=A6=AC?= =?UTF-8?q?=ED=84=B0=EB=9F=B4=20IN=EC=9C=BC=EB=A1=9C=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../product/ProductRepositoryImpl.java | 36 +++++++++++-------- 1 file changed, 22 insertions(+), 14 deletions(-) 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 616c0ee88..7afe87e28 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 @@ -5,7 +5,8 @@ import com.loopers.domain.product.ProductRepository; import com.loopers.domain.product.ProductSortType; import com.loopers.domain.product.ProductStatus; -import com.loopers.infrastructure.brand.QBrandEntity; +import com.loopers.infrastructure.brand.BrandEntity; +import com.loopers.infrastructure.brand.BrandJpaRepository; import com.querydsl.core.types.OrderSpecifier; import com.querydsl.core.types.dsl.BooleanExpression; import com.querydsl.jpa.impl.JPAQueryFactory; @@ -27,15 +28,18 @@ public class ProductRepositoryImpl implements ProductRepository { private final ProductJpaRepository productJpaRepository; private final ProductMapper productMapper; private final JPAQueryFactory queryFactory; + private final BrandJpaRepository brandJpaRepository; public ProductRepositoryImpl( ProductJpaRepository productJpaRepository, ProductMapper productMapper, - JPAQueryFactory queryFactory + JPAQueryFactory queryFactory, + BrandJpaRepository brandJpaRepository ) { this.productJpaRepository = productJpaRepository; this.productMapper = productMapper; this.queryFactory = queryFactory; + this.brandJpaRepository = brandJpaRepository; } @Override @@ -91,16 +95,15 @@ public long count(Long brandId) { @Override public List findAllDisplayable(Long brandId, ProductSortType sort, int page, int size) { QProductEntity product = QProductEntity.productEntity; - QBrandEntity brand = QBrandEntity.brandEntity; + + List activeBrandIds = getActiveBrandIds(); return queryFactory .selectFrom(product) - .innerJoin(brand).on(product.brandId.eq(brand.id)) .where( product.deletedAt.isNull(), product.status.in(ProductStatus.ACTIVE, ProductStatus.SOLDOUT), - brand.deletedAt.isNull(), - brand.status.eq(BrandStatus.ACTIVE), + product.brandId.in(activeBrandIds), brandIdEq(product, brandId) ) .orderBy(toOrderSpecifier(product, sort)) @@ -115,17 +118,16 @@ public List findAllDisplayable(Long brandId, ProductSortType sort, int @Override public long countDisplayable(Long brandId) { QProductEntity product = QProductEntity.productEntity; - QBrandEntity brand = QBrandEntity.brandEntity; + + List activeBrandIds = getActiveBrandIds(); Long count = queryFactory .select(product.count()) .from(product) - .innerJoin(brand).on(product.brandId.eq(brand.id)) .where( product.deletedAt.isNull(), product.status.in(ProductStatus.ACTIVE, ProductStatus.SOLDOUT), - brand.deletedAt.isNull(), - brand.status.eq(BrandStatus.ACTIVE), + product.brandId.in(activeBrandIds), brandIdEq(product, brandId) ) .fetchOne(); @@ -174,16 +176,15 @@ public List findAllByBrandId(Long brandId) { @Override public List findAllByIdIn(List ids) { QProductEntity product = QProductEntity.productEntity; - QBrandEntity brand = QBrandEntity.brandEntity; + + List activeBrandIds = getActiveBrandIds(); return queryFactory .selectFrom(product) - .innerJoin(brand).on(product.brandId.eq(brand.id)) .where( product.id.in(ids), product.deletedAt.isNull(), - brand.deletedAt.isNull(), - brand.status.eq(BrandStatus.ACTIVE) + product.brandId.in(activeBrandIds) ) .fetch() .stream() @@ -201,6 +202,13 @@ public int decrementLikeCount(Long id) { return productJpaRepository.decrementLikeCount(id); } + private List getActiveBrandIds() { + return brandJpaRepository.findAllByStatusAndDeletedAtIsNull(BrandStatus.ACTIVE) + .stream() + .map(BrandEntity::getId) + .toList(); + } + /** brandId가 null이면 필터 미적용 */ private BooleanExpression brandIdEq(QProductEntity product, Long brandId) { return brandId != null ? product.brandId.eq(brandId) : null; From b19176f743e9dbf3e97c508c77284225ff846136 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B5=E1=86=B7=E1=84=82=E1=85=A1=E1=84=92?= =?UTF-8?q?=E1=85=A7=E1=86=AB?= Date: Thu, 12 Mar 2026 18:09:16 +0900 Subject: [PATCH 03/17] =?UTF-8?q?feat:=20=EC=83=81=ED=92=88/=EC=A3=BC?= =?UTF-8?q?=EB=AC=B8=20=EB=AA=A9=EB=A1=9D=20API=EC=97=90=20Cursor=20?= =?UTF-8?q?=EA=B8=B0=EB=B0=98=20=ED=8E=98=EC=9D=B4=EC=A7=80=EB=84=A4?= =?UTF-8?q?=EC=9D=B4=EC=85=98=20=EC=A0=81=EC=9A=A9=20(COUNT=20=EC=BF=BC?= =?UTF-8?q?=EB=A6=AC=20=EC=A0=9C=EA=B1=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/order/OrderFacade.java | 18 +++-- .../application/product/ProductFacade.java | 22 +++--- .../loopers/domain/common/CursorResult.java | 26 +++++++ .../loopers/domain/order/OrderRepository.java | 7 ++ .../loopers/domain/order/OrderService.java | 6 +- .../loopers/domain/product/ProductCursor.java | 31 ++++++++ .../domain/product/ProductRepository.java | 7 +- .../domain/product/ProductService.java | 13 ++-- .../order/OrderRepositoryImpl.java | 39 +++++++++- .../product/ProductRepositoryImpl.java | 72 +++++++++---------- .../interfaces/api/common/CursorEncoder.java | 43 +++++++++++ .../interfaces/api/order/OrderApiSpec.java | 6 +- .../interfaces/api/order/OrderController.java | 34 +++++++-- .../interfaces/api/order/OrderResponse.java | 11 ++- .../api/product/ProductApiSpec.java | 6 +- .../api/product/ProductController.java | 61 ++++++++++++++-- .../api/product/ProductResponse.java | 15 ++-- .../domain/order/OrderServiceTest.java | 38 ---------- .../ProductRepositoryIntegrationTest.java | 69 ------------------ http/commerce-api/order.http | 13 ++-- http/commerce-api/product.http | 7 +- 21 files changed, 339 insertions(+), 205 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/common/CursorResult.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/ProductCursor.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/common/CursorEncoder.java 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 ef6dc31e9..4e76045e9 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 @@ -10,6 +10,7 @@ import com.loopers.domain.coupon.CouponTemplate; import com.loopers.domain.coupon.IssuedCoupon; import com.loopers.domain.inventory.InventoryService; +import com.loopers.domain.common.CursorResult; import com.loopers.domain.order.Order; import com.loopers.domain.order.OrderItem; import com.loopers.domain.order.OrderService; @@ -315,18 +316,19 @@ private String generateIdempotencyKey() { return "PAY-" + UUID.randomUUID().toString().substring(0, 12).toUpperCase(); } - /** 주문 목록 조회 */ + /** 주문 목록 커서 조회 */ @Transactional(readOnly = true) - public OrderListResult getOrders(Long userId, ZonedDateTime startAt, ZonedDateTime endAt) { - List orders = orderService.getOrders(userId, startAt, endAt); + public OrderCursorResult getOrdersWithCursor(Long userId, ZonedDateTime startAt, ZonedDateTime endAt, + ZonedDateTime cursorCreatedAt, Long cursorId, int size) { + CursorResult result = orderService.getOrdersWithCursor(userId, startAt, endAt, cursorCreatedAt, cursorId, size); - List summaries = orders.stream() + List summaries = result.items().stream() .map(o -> new OrderSummaryResult( o.getId(), o.getOrderNumber(), o.getStatus().name(), o.getTotalAmount(), o.getCreatedAt())) .toList(); - return new OrderListResult(summaries); + return new OrderCursorResult(summaries, result.hasNext(), size); } /** 주문 상세 조회 */ @@ -361,7 +363,11 @@ public record OrderCreateResult( public record OrderItemCommand(Long productId, int quantity) {} - public record OrderListResult(List orders) {} + public record OrderCursorResult( + List orders, + boolean hasNext, + int size + ) {} public record OrderSummaryResult( Long orderId, String orderNumber, String status, 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 2635c50a5..4135eeb57 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 @@ -3,7 +3,9 @@ import com.loopers.application.brand.BrandInfo; import com.loopers.domain.brand.Brand; import com.loopers.domain.brand.BrandService; +import com.loopers.domain.common.CursorResult; import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductCursor; import com.loopers.domain.product.ProductService; import com.loopers.domain.product.ProductSortType; import org.springframework.stereotype.Component; @@ -35,27 +37,23 @@ public ProductDetailResult getProductDetail(Long productId) { return new ProductDetailResult(ProductInfo.from(product), BrandInfo.from(brand)); } - /** 고객 상품 목록 조회 (상품 목록 + 브랜드명 조합) */ + /** 고객 상품 목록 커서 조회 (COUNT 쿼리 없음) */ @Transactional(readOnly = true) - public ProductListResult getDisplayableProducts(Long brandId, ProductSortType sort, int page, int size) { - List products = productService.getDisplayableProducts(brandId, sort, page, size); - long totalElements = productService.countDisplayableProducts(brandId); - int totalPages = size > 0 ? (int) Math.ceil((double) totalElements / size) : 0; + public ProductCursorResult getDisplayableProductsWithCursor(Long brandId, ProductSortType sort, ProductCursor cursor, int size) { + CursorResult result = productService.getDisplayableProductsWithCursor(brandId, sort, cursor, size); - List productInfos = products.stream() + List productInfos = result.items().stream() .map(ProductInfo::from) .toList(); - return new ProductListResult(productInfos, page, size, totalElements, totalPages); + return new ProductCursorResult(productInfos, result.hasNext(), size); } public record ProductDetailResult(ProductInfo product, BrandInfo brand) {} - public record ProductListResult( + public record ProductCursorResult( List products, - int page, - int size, - long totalElements, - int totalPages + boolean hasNext, + int size ) {} } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/common/CursorResult.java b/apps/commerce-api/src/main/java/com/loopers/domain/common/CursorResult.java new file mode 100644 index 000000000..52b9dd985 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/common/CursorResult.java @@ -0,0 +1,26 @@ +package com.loopers.domain.common; + +import java.util.List; + +/** + * 커서 기반 페이지네이션 결과 + * + * LIMIT + 1 조회 결과를 기반으로 hasNext를 판단한다. + * totalElements/totalPages 없이 "다음 페이지 존재 여부"만 제공한다. + */ +public record CursorResult( + List items, + boolean hasNext +) { + /** + * LIMIT + 1로 조회한 결과에서 CursorResult를 생성한다. + * + * @param fetchedItems LIMIT + 1로 조회한 결과 + * @param size 요청한 페이지 크기 + */ + public static CursorResult of(List fetchedItems, int size) { + boolean hasNext = fetchedItems.size() > size; + List items = hasNext ? fetchedItems.subList(0, size) : fetchedItems; + return new CursorResult<>(items, hasNext); + } +} 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 c1d04dddf..7b44323d9 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 @@ -1,5 +1,7 @@ package com.loopers.domain.order; +import com.loopers.domain.common.CursorResult; + import java.time.ZonedDateTime; import java.util.List; import java.util.Optional; @@ -9,6 +11,11 @@ public interface OrderRepository { Optional findById(Long id); List findAllByUserId(Long userId, ZonedDateTime startAt, ZonedDateTime endAt); List findAllByUserIdWithItems(Long userId, ZonedDateTime startAt, ZonedDateTime endAt); + + /** 주문 목록 커서 조회 (created_at DESC, id DESC) */ + CursorResult findAllByUserIdWithCursor(Long userId, ZonedDateTime startAt, ZonedDateTime endAt, + ZonedDateTime cursorCreatedAt, Long cursorId, int size); + List findAll(int page, int size); long count(); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java index f2c976b4f..a911c30ae 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java @@ -1,5 +1,6 @@ package com.loopers.domain.order; +import com.loopers.domain.common.CursorResult; import com.loopers.support.error.CoreException; import com.loopers.support.error.OrderErrorType; import org.springframework.stereotype.Component; @@ -55,8 +56,9 @@ public Order getOrder(Long orderId, Long userId) { } @Transactional(readOnly = true) - public List getOrders(Long userId, ZonedDateTime startAt, ZonedDateTime endAt) { - return orderRepository.findAllByUserId(userId, startAt, endAt); + public CursorResult getOrdersWithCursor(Long userId, ZonedDateTime startAt, ZonedDateTime endAt, + ZonedDateTime cursorCreatedAt, Long cursorId, int size) { + return orderRepository.findAllByUserIdWithCursor(userId, startAt, endAt, cursorCreatedAt, cursorId, size); } @Transactional diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductCursor.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductCursor.java new file mode 100644 index 000000000..dd93768f7 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductCursor.java @@ -0,0 +1,31 @@ +package com.loopers.domain.product; + +import java.time.ZonedDateTime; + +/** + * 상품 목록 커서 데이터 + * + * 정렬 타입에 따라 사용되는 필드가 다르다: + * - LATEST: createdAt + id + * - PRICE_ASC / PRICE_DESC: basePrice + id + * - LIKES_DESC: likeCount + id + */ +public record ProductCursor( + ProductSortType sort, + ZonedDateTime createdAt, + Integer basePrice, + Integer likeCount, + Long id +) { + public static ProductCursor ofLatest(ZonedDateTime createdAt, Long id) { + return new ProductCursor(ProductSortType.LATEST, createdAt, null, null, id); + } + + public static ProductCursor ofPrice(ProductSortType sort, Integer basePrice, Long id) { + return new ProductCursor(sort, null, basePrice, null, id); + } + + public static ProductCursor ofLikes(Integer likeCount, Long id) { + return new ProductCursor(ProductSortType.LIKES_DESC, null, null, likeCount, id); + } +} 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 59b0cf86b..deea557a7 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java @@ -1,5 +1,7 @@ package com.loopers.domain.product; +import com.loopers.domain.common.CursorResult; + import java.util.List; import java.util.Optional; @@ -17,9 +19,8 @@ public interface ProductRepository { List findAll(int page, int size, Long brandId); long count(Long brandId); - /** 고객 노출 가능 상품 페이지 조회 (brandId null이면 전체) */ - List findAllDisplayable(Long brandId, ProductSortType sort, int page, int size); - long countDisplayable(Long brandId); + /** 고객 노출 가능 상품 커서 조회 (brandId null이면 전체) */ + CursorResult findAllDisplayableWithCursor(Long brandId, ProductSortType sort, ProductCursor cursor, int size); /** BrandFacade용: 브랜드별 ACTIVE 상품 조회 */ List findAllActiveByBrandId(Long brandId); diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java index 14b38797b..308bce2b4 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java @@ -1,5 +1,6 @@ package com.loopers.domain.product; +import com.loopers.domain.common.CursorResult; import com.loopers.support.error.CoreException; import com.loopers.support.error.ProductErrorType; import org.springframework.stereotype.Component; @@ -91,16 +92,10 @@ public long countAllProducts(Long brandId) { return productRepository.count(brandId); } - /** 노출 가능 상품 페이지 조회 (고객용, brandId 선택 필터) */ + /** 노출 가능 상품 커서 조회 (고객용) */ @Transactional(readOnly = true) - public List getDisplayableProducts(Long brandId, ProductSortType sort, int page, int size) { - return productRepository.findAllDisplayable(brandId, sort, page, size); - } - - /** 노출 가능 상품 수 조회 (고객 페이지네이션 메타 정보용) */ - @Transactional(readOnly = true) - public long countDisplayableProducts(Long brandId) { - return productRepository.countDisplayable(brandId); + public CursorResult getDisplayableProductsWithCursor(Long brandId, ProductSortType sort, ProductCursor cursor, int size) { + return productRepository.findAllDisplayableWithCursor(brandId, sort, cursor, size); } /** 브랜드별 ACTIVE 상품 조회 (BrandFacade 고객 상세용) */ 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 5bf8a7f1a..7261e51fb 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 @@ -1,7 +1,10 @@ package com.loopers.infrastructure.order; +import com.loopers.domain.common.CursorResult; import com.loopers.domain.order.Order; import com.loopers.domain.order.OrderRepository; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.jpa.impl.JPAQueryFactory; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Sort; import org.springframework.stereotype.Repository; @@ -16,10 +19,13 @@ public class OrderRepositoryImpl implements OrderRepository { private final OrderJpaRepository orderJpaRepository; private final OrderMapper orderMapper; + private final JPAQueryFactory queryFactory; - public OrderRepositoryImpl(OrderJpaRepository orderJpaRepository, OrderMapper orderMapper) { + public OrderRepositoryImpl(OrderJpaRepository orderJpaRepository, OrderMapper orderMapper, + JPAQueryFactory queryFactory) { this.orderJpaRepository = orderJpaRepository; this.orderMapper = orderMapper; + this.queryFactory = queryFactory; } @Override @@ -60,6 +66,37 @@ public List findAllByUserIdWithItems(Long userId, ZonedDateTime startAt, .collect(Collectors.toList()); } + @Override + public CursorResult findAllByUserIdWithCursor(Long userId, ZonedDateTime startAt, ZonedDateTime endAt, + ZonedDateTime cursorCreatedAt, Long cursorId, int size) { + QOrderEntity order = QOrderEntity.orderEntity; + + List fetched = queryFactory + .selectFrom(order) + .where( + order.userId.eq(userId), + order.createdAt.goe(startAt), + order.createdAt.lt(endAt), + orderCursorCondition(order, cursorCreatedAt, cursorId) + ) + .orderBy(order.createdAt.desc(), order.id.desc()) + .limit(size + 1) + .fetch() + .stream() + .map(orderMapper::toDomainWithoutItems) + .toList(); + + return CursorResult.of(fetched, size); + } + + private BooleanExpression orderCursorCondition(QOrderEntity order, ZonedDateTime cursorCreatedAt, Long cursorId) { + if (cursorCreatedAt == null || cursorId == null) { + return null; + } + return order.createdAt.lt(cursorCreatedAt) + .or(order.createdAt.eq(cursorCreatedAt).and(order.id.lt(cursorId))); + } + @Override public List findAll(int page, int size) { return orderJpaRepository.findAll(PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt"))) 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 7afe87e28..9331133c6 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java @@ -1,7 +1,9 @@ package com.loopers.infrastructure.product; import com.loopers.domain.brand.BrandStatus; +import com.loopers.domain.common.CursorResult; import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductCursor; import com.loopers.domain.product.ProductRepository; import com.loopers.domain.product.ProductSortType; import com.loopers.domain.product.ProductStatus; @@ -91,48 +93,30 @@ public long count(Long brandId) { return count != null ? count : 0L; } - /** 고객 노출 가능 상품 페이지 조회 (ACTIVE, SOLDOUT만, 삭제 제외, 브랜드 ACTIVE만) */ @Override - public List findAllDisplayable(Long brandId, ProductSortType sort, int page, int size) { + public CursorResult findAllDisplayableWithCursor(Long brandId, ProductSortType sort, ProductCursor cursor, int size) { QProductEntity product = QProductEntity.productEntity; List activeBrandIds = getActiveBrandIds(); - return queryFactory + var query = queryFactory .selectFrom(product) .where( product.deletedAt.isNull(), product.status.in(ProductStatus.ACTIVE, ProductStatus.SOLDOUT), product.brandId.in(activeBrandIds), - brandIdEq(product, brandId) + brandIdEq(product, brandId), + cursorCondition(product, sort, cursor) ) - .orderBy(toOrderSpecifier(product, sort)) - .offset((long) page * size) - .limit(size) - .fetch() + .orderBy(toOrderSpecifiers(product, sort)) + .limit(size + 1); + + List fetched = query.fetch() .stream() .map(productMapper::toDomain) .toList(); - } - @Override - public long countDisplayable(Long brandId) { - QProductEntity product = QProductEntity.productEntity; - - List activeBrandIds = getActiveBrandIds(); - - Long count = queryFactory - .select(product.count()) - .from(product) - .where( - product.deletedAt.isNull(), - product.status.in(ProductStatus.ACTIVE, ProductStatus.SOLDOUT), - product.brandId.in(activeBrandIds), - brandIdEq(product, brandId) - ) - .fetchOne(); - - return count != null ? count : 0L; + return CursorResult.of(fetched, size); } /** 브랜드별 ACTIVE 상품 조회 (고객용, 삭제 제외) */ @@ -214,16 +198,32 @@ private BooleanExpression brandIdEq(QProductEntity product, Long brandId) { return brandId != null ? product.brandId.eq(brandId) : null; } - /** ProductSortType → QueryDSL OrderSpecifier 변환 (정렬 DIP) */ - private OrderSpecifier toOrderSpecifier(QProductEntity product, ProductSortType sort) { - if (sort == null) { - return product.createdAt.desc(); + /** ProductSortType → QueryDSL OrderSpecifier 배열 (정렬키 + id tie-breaking) */ + private OrderSpecifier[] toOrderSpecifiers(QProductEntity product, ProductSortType sort) { + ProductSortType effectiveSort = sort != null ? sort : ProductSortType.LATEST; + return switch (effectiveSort) { + case LATEST -> new OrderSpecifier[]{product.createdAt.desc(), product.id.desc()}; + case PRICE_ASC -> new OrderSpecifier[]{product.basePrice.asc(), product.id.asc()}; + case PRICE_DESC -> new OrderSpecifier[]{product.basePrice.desc(), product.id.desc()}; + case LIKES_DESC -> new OrderSpecifier[]{product.likeCount.desc(), product.id.desc()}; + }; + } + + /** 커서 조건: (sortKey, id) 기반 WHERE 절 생성 */ + private BooleanExpression cursorCondition(QProductEntity product, ProductSortType sort, ProductCursor cursor) { + if (cursor == null) { + return null; } - return switch (sort) { - case LATEST -> product.createdAt.desc(); - case PRICE_ASC -> product.basePrice.asc(); - case PRICE_DESC -> product.basePrice.desc(); - case LIKES_DESC -> product.likeCount.desc(); + ProductSortType effectiveSort = sort != null ? sort : ProductSortType.LATEST; + return switch (effectiveSort) { + case LATEST -> product.createdAt.lt(cursor.createdAt()) + .or(product.createdAt.eq(cursor.createdAt()).and(product.id.lt(cursor.id()))); + case PRICE_ASC -> product.basePrice.gt(cursor.basePrice()) + .or(product.basePrice.eq(cursor.basePrice()).and(product.id.gt(cursor.id()))); + case PRICE_DESC -> product.basePrice.lt(cursor.basePrice()) + .or(product.basePrice.eq(cursor.basePrice()).and(product.id.lt(cursor.id()))); + case LIKES_DESC -> product.likeCount.lt(cursor.likeCount()) + .or(product.likeCount.eq(cursor.likeCount()).and(product.id.lt(cursor.id()))); }; } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/common/CursorEncoder.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/common/CursorEncoder.java new file mode 100644 index 000000000..91ff2237a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/common/CursorEncoder.java @@ -0,0 +1,43 @@ +package com.loopers.interfaces.api.common; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.Map; + +/** + * 커서 인코딩/디코딩 유틸리티 + * + * 커서 데이터를 Base64 URL-safe 문자열로 인코딩/디코딩한다. + * API 계약의 일부이므로 Interfaces 레이어에 위치한다. + */ +public final class CursorEncoder { + + private static final ObjectMapper MAPPER = new ObjectMapper() + .registerModule(new JavaTimeModule()) + .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + + private CursorEncoder() {} + + public static String encode(Map data) { + try { + String json = MAPPER.writeValueAsString(data); + return Base64.getUrlEncoder().withoutPadding().encodeToString(json.getBytes(StandardCharsets.UTF_8)); + } catch (JsonProcessingException e) { + throw new IllegalStateException("커서 인코딩 실패", e); + } + } + + public static Map decode(String cursor) { + try { + String json = new String(Base64.getUrlDecoder().decode(cursor), StandardCharsets.UTF_8); + return MAPPER.readValue(json, MAPPER.getTypeFactory().constructMapType(Map.class, String.class, Object.class)); + } catch (Exception e) { + throw new IllegalArgumentException("유효하지 않은 커서입니다.", e); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderApiSpec.java index 2ca123b6a..42037d2a2 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderApiSpec.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderApiSpec.java @@ -15,9 +15,9 @@ public interface OrderApiSpec { ApiResponse createOrder( @AuthUser User user, OrderRequest.CreateOrderRequest request); - @Operation(summary = "주문 목록 조회", description = "본인의 주문 목록을 조회합니다. 날짜 범위 지정 가능 (미지정 시 최근 3개월).") - ApiResponse getOrders( - @AuthUser User user, ZonedDateTime startAt, ZonedDateTime endAt); + @Operation(summary = "주문 목록 조회", description = "본인의 주문 목록을 커서 기반으로 조회합니다. 날짜 범위 지정 가능 (미지정 시 최근 3개월).") + ApiResponse getOrders( + @AuthUser User user, ZonedDateTime startAt, ZonedDateTime endAt, String cursor, int size); @Operation(summary = "주문 상세 조회", description = "주문 상세 정보를 조회합니다. 스냅샷 기반입니다.") ApiResponse getOrder( diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderController.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderController.java index 64c3a4518..e20af7b1e 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderController.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderController.java @@ -3,6 +3,7 @@ import com.loopers.application.order.OrderFacade; import com.loopers.domain.user.User; import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.common.CursorEncoder; import com.loopers.support.auth.AuthUser; import com.loopers.support.error.CoreException; import com.loopers.support.error.OrderErrorType; @@ -17,7 +18,9 @@ import org.springframework.web.bind.annotation.RestController; import java.time.ZonedDateTime; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; @RestController @RequestMapping("/api/v1/orders") @@ -66,14 +69,26 @@ public ApiResponse createOrder( @GetMapping @Override - public ApiResponse getOrders( + public ApiResponse getOrders( @AuthUser User user, @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) ZonedDateTime startAt, - @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) ZonedDateTime endAt) { + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) ZonedDateTime endAt, + @RequestParam(required = false) String cursor, + @RequestParam(defaultValue = "20") int size) { + ZonedDateTime start = startAt != null ? startAt : ZonedDateTime.now().minusMonths(3); ZonedDateTime end = endAt != null ? endAt : ZonedDateTime.now(); - OrderFacade.OrderListResult result = orderFacade.getOrders(user.getId(), start, end); + ZonedDateTime cursorCreatedAt = null; + Long cursorId = null; + if (cursor != null && !cursor.isBlank()) { + Map data = CursorEncoder.decode(cursor); + cursorCreatedAt = ZonedDateTime.parse((String) data.get("createdAt")); + cursorId = ((Number) data.get("id")).longValue(); + } + + OrderFacade.OrderCursorResult result = orderFacade.getOrdersWithCursor( + user.getId(), start, end, cursorCreatedAt, cursorId, size); List summaries = result.orders().stream() .map(o -> new OrderResponse.OrderSummary( @@ -81,7 +96,18 @@ public ApiResponse getOrders( o.totalAmount(), o.createdAt())) .toList(); - return ApiResponse.success(new OrderResponse.OrderListResponse(summaries)); + String nextCursor = null; + if (result.hasNext() && !result.orders().isEmpty()) { + OrderFacade.OrderSummaryResult lastOrder = result.orders().get(result.orders().size() - 1); + Map data = new LinkedHashMap<>(); + data.put("createdAt", lastOrder.createdAt().toString()); + data.put("id", lastOrder.orderId()); + nextCursor = CursorEncoder.encode(data); + } + + return ApiResponse.success(new OrderResponse.OrderCursorListResponse( + summaries, + new OrderResponse.PagingInfo(result.hasNext(), nextCursor, size))); } @GetMapping("/{orderId}") diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderResponse.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderResponse.java index bc60eed01..fd1067d37 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderResponse.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderResponse.java @@ -13,8 +13,15 @@ public record OrderSummary( ZonedDateTime createdAt ) {} - public record OrderListResponse( - List orders + public record OrderCursorListResponse( + List orders, + PagingInfo paging + ) {} + + public record PagingInfo( + boolean hasNext, + String nextCursor, + int size ) {} public record OrderItemDetail( diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductApiSpec.java index cc17918ef..e8f79cad3 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductApiSpec.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductApiSpec.java @@ -8,9 +8,9 @@ @Tag(name = "Product API", description = "상품 관련 고객 API") public interface ProductApiSpec { - @Operation(summary = "상품 목록 조회", description = "노출 가능한 상품 목록을 페이지네이션으로 조회합니다.") - ApiResponse getProducts( - Long brandId, ProductSortType sort, int page, int size); + @Operation(summary = "상품 목록 조회", description = "노출 가능한 상품 목록을 커서 기반 페이지네이션으로 조회합니다.") + ApiResponse getProducts( + Long brandId, ProductSortType sort, String cursor, int size); @Operation(summary = "상품 상세 조회", description = "상품 상세 정보를 조회합니다.") ApiResponse getProduct(Long productId); diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductController.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductController.java index df11903d8..decac6b4b 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductController.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductController.java @@ -2,15 +2,20 @@ import com.loopers.application.product.ProductFacade; import com.loopers.application.product.ProductInfo; +import com.loopers.domain.product.ProductCursor; import com.loopers.domain.product.ProductSortType; import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.common.CursorEncoder; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import java.time.ZonedDateTime; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; @RestController @RequestMapping("/api/v1/products") @@ -24,20 +29,31 @@ public ProductController(ProductFacade productFacade) { @GetMapping @Override - public ApiResponse getProducts( + public ApiResponse getProducts( @RequestParam(required = false) Long brandId, @RequestParam(required = false) ProductSortType sort, - @RequestParam(defaultValue = "0") int page, + @RequestParam(required = false) String cursor, @RequestParam(defaultValue = "20") int size ) { - ProductFacade.ProductListResult result = productFacade.getDisplayableProducts(brandId, sort, page, size); + ProductSortType effectiveSort = sort != null ? sort : ProductSortType.LATEST; + ProductCursor productCursor = decodeCursor(cursor, effectiveSort); + + ProductFacade.ProductCursorResult result = productFacade.getDisplayableProductsWithCursor( + brandId, effectiveSort, productCursor, size); List summaries = result.products().stream() .map(ProductResponse.ProductSummary::from) .toList(); - return ApiResponse.success(new ProductResponse.ProductListResponse( - summaries, result.page(), result.size(), result.totalElements(), result.totalPages())); + String nextCursor = null; + if (result.hasNext() && !result.products().isEmpty()) { + ProductInfo lastProduct = result.products().get(result.products().size() - 1); + nextCursor = encodeCursor(effectiveSort, lastProduct); + } + + return ApiResponse.success(new ProductResponse.ProductCursorListResponse( + summaries, + new ProductResponse.PagingInfo(result.hasNext(), nextCursor, size))); } @GetMapping("/{productId}") @@ -49,4 +65,39 @@ public ApiResponse getProduct(@PathVariable Long return ApiResponse.success(ProductResponse.ProductDetail.from(product, brandName)); } + + private ProductCursor decodeCursor(String cursor, ProductSortType sort) { + if (cursor == null || cursor.isBlank()) { + return null; + } + Map data = CursorEncoder.decode(cursor); + + String cursorSort = (String) data.get("sort"); + if (!sort.name().equals(cursorSort)) { + throw new IllegalArgumentException("커서의 정렬 타입이 요청과 일치하지 않습니다."); + } + + Long id = ((Number) data.get("id")).longValue(); + + return switch (sort) { + case LATEST -> ProductCursor.ofLatest(ZonedDateTime.parse((String) data.get("sv")), id); + case PRICE_ASC, PRICE_DESC -> ProductCursor.ofPrice(sort, ((Number) data.get("sv")).intValue(), id); + case LIKES_DESC -> ProductCursor.ofLikes(((Number) data.get("sv")).intValue(), id); + }; + } + + private String encodeCursor(ProductSortType sort, ProductInfo lastProduct) { + Map data = new LinkedHashMap<>(); + data.put("sort", sort.name()); + data.put("id", lastProduct.id()); + + Object sortValue = switch (sort) { + case LATEST -> lastProduct.createdAt().toString(); + case PRICE_ASC, PRICE_DESC -> lastProduct.basePrice(); + case LIKES_DESC -> lastProduct.likeCount(); + }; + data.put("sv", sortValue); + + return CursorEncoder.encode(data); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductResponse.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductResponse.java index 4a1a5078d..e823f6541 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductResponse.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductResponse.java @@ -47,13 +47,16 @@ public static ProductDetail from(ProductInfo info, String brandName) { } } - /** 상품 목록 + 페이지네이션 메타 정보 */ - public record ProductListResponse( + /** 상품 목록 + Cursor 페이지네이션 메타 정보 */ + public record ProductCursorListResponse( List products, - int page, - int size, - long totalElements, - int totalPages + PagingInfo paging + ) {} + + public record PagingInfo( + boolean hasNext, + String nextCursor, + int size ) {} /** 상품 상태 → 고객용 재고 상태 문자열 변환 */ diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceTest.java index f1f534ea4..089ddc68c 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceTest.java @@ -10,14 +10,12 @@ import org.junit.jupiter.api.Test; import org.mockito.Mockito; -import java.time.ZonedDateTime; import java.util.List; import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -199,42 +197,6 @@ class 상세조회 { } } - @DisplayName("주문 목록을 조회할 때,") - @Nested - class 목록조회 { - - @Test - void 사용자의_주문_목록이_반환된다() { - // arrange - ZonedDateTime startAt = ZonedDateTime.now().minusMonths(3); - ZonedDateTime endAt = ZonedDateTime.now(); - List orders = List.of(createOrder()); - when(orderRepository.findAllByUserId(eq(1L), any(ZonedDateTime.class), any(ZonedDateTime.class))) - .thenReturn(orders); - - // act - List result = orderService.getOrders(1L, startAt, endAt); - - // assert - assertThat(result).hasSize(1); - } - - @Test - void 주문이_없으면_빈_리스트가_반환된다() { - // arrange - ZonedDateTime startAt = ZonedDateTime.now().minusMonths(3); - ZonedDateTime endAt = ZonedDateTime.now(); - when(orderRepository.findAllByUserId(eq(1L), any(ZonedDateTime.class), any(ZonedDateTime.class))) - .thenReturn(List.of()); - - // act - List result = orderService.getOrders(1L, startAt, endAt); - - // assert - assertThat(result).isEmpty(); - } - } - @DisplayName("주문을 확정할 때,") @Nested class 확정 { diff --git a/apps/commerce-api/src/test/java/com/loopers/infrastructure/product/ProductRepositoryIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/infrastructure/product/ProductRepositoryIntegrationTest.java index 789ff162b..2b4bb5ca4 100644 --- a/apps/commerce-api/src/test/java/com/loopers/infrastructure/product/ProductRepositoryIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/infrastructure/product/ProductRepositoryIntegrationTest.java @@ -4,7 +4,6 @@ import com.loopers.domain.brand.BrandRepository; import com.loopers.domain.product.Product; import com.loopers.domain.product.ProductRepository; -import com.loopers.domain.product.ProductSortType; import com.loopers.domain.product.ProductStatus; import com.loopers.utils.DatabaseCleanUp; import org.junit.jupiter.api.AfterEach; @@ -156,74 +155,6 @@ class Count { } } - @Nested - @DisplayName("findAllDisplayable 메서드는") - class FindAllDisplayable { - - @Test - void ACTIVE와_SOLDOUT_상태만_반환한다() { - // arrange - Brand brand = createBrand("나이키"); - createProduct(brand.getId(), "활성상품", 10000, ProductStatus.ACTIVE); - createProduct(brand.getId(), "품절상품", 20000, ProductStatus.SOLDOUT); - createProduct(brand.getId(), "숨김상품", 30000, ProductStatus.HIDDEN); - createProduct(brand.getId(), "단종상품", 40000, ProductStatus.DISCONTINUED); - - // act - List result = productRepository.findAllDisplayable(null, ProductSortType.LATEST, 0, 20); - - // assert - assertThat(result).hasSize(2); - } - - @Test - void LATEST_정렬은_최신순으로_반환한다() { - // arrange - Brand brand = createBrand("나이키"); - createProduct(brand.getId(), "상품1", 10000); - createProduct(brand.getId(), "상품2", 20000); - - // act - List result = productRepository.findAllDisplayable(null, ProductSortType.LATEST, 0, 20); - - // assert - assertThat(result).hasSize(2); - assertThat(result.get(0).getName()).isEqualTo("상품2"); - } - - @Test - void PRICE_ASC_정렬은_가격_오름차순으로_반환한다() { - // arrange - Brand brand = createBrand("나이키"); - createProduct(brand.getId(), "비싼상품", 300000); - createProduct(brand.getId(), "싼상품", 100000); - - // act - List result = productRepository.findAllDisplayable(null, ProductSortType.PRICE_ASC, 0, 20); - - // assert - assertThat(result.get(0).getBasePrice()).isEqualTo(100000); - assertThat(result.get(1).getBasePrice()).isEqualTo(300000); - } - - @Test - void brandId로_필터링하여_반환한다() { - // arrange - Brand nike = createBrand("나이키"); - Brand adidas = createBrand("아디다스"); - createProduct(nike.getId(), "에어맥스", 150000); - createProduct(adidas.getId(), "슈퍼스타", 100000); - - // act - List result = productRepository.findAllDisplayable( - nike.getId(), ProductSortType.LATEST, 0, 20); - - // assert - assertThat(result).hasSize(1); - assertThat(result.get(0).getName()).isEqualTo("에어맥스"); - } - } - @Nested @DisplayName("findAllActiveByBrandId 메서드는") class FindAllActiveByBrandId { diff --git a/http/commerce-api/order.http b/http/commerce-api/order.http index d5df0ddf7..20fa6a883 100644 --- a/http/commerce-api/order.http +++ b/http/commerce-api/order.http @@ -27,13 +27,18 @@ X-Loopers-LoginPw: Hx7!mK2@ "ordererPhone": "010-1234-5678" } -### 주문 목록 조회 -GET http://localhost:8080/api/v1/orders +### 주문 목록 조회 (커서 페이지네이션) +GET http://localhost:8080/api/v1/orders?size=20 X-Loopers-LoginId: nahyeon X-Loopers-LoginPw: Hx7!mK2@ -### 주문 목록 조회 (날짜 범위) -GET http://localhost:8080/api/v1/orders?startAt=2026-01-01T00:00:00+09:00&endAt=2026-12-31T23:59:59+09:00 +### 주문 목록 조회 (날짜 범위 + 커서) +GET http://localhost:8080/api/v1/orders?startAt=2026-01-01T00:00:00+09:00&endAt=2026-12-31T23:59:59+09:00&size=20 +X-Loopers-LoginId: nahyeon +X-Loopers-LoginPw: Hx7!mK2@ + +### 주문 목록 조회 (커서로 다음 페이지) +GET http://localhost:8080/api/v1/orders?size=20&cursor={{nextCursor}} X-Loopers-LoginId: nahyeon X-Loopers-LoginPw: Hx7!mK2@ diff --git a/http/commerce-api/product.http b/http/commerce-api/product.http index abe6d11c0..9b6a54945 100644 --- a/http/commerce-api/product.http +++ b/http/commerce-api/product.http @@ -3,8 +3,11 @@ ### 상품 목록 조회 (기본) GET http://localhost:8080/api/v1/products -### 상품 목록 조회 (페이지네이션 + 정렬) -GET http://localhost:8080/api/v1/products?page=0&size=10&sort=LATEST +### 상품 목록 조회 (커서 페이지네이션 + 정렬) +GET http://localhost:8080/api/v1/products?size=10&sort=LATEST + +### 상품 목록 조회 (커서로 다음 페이지) +GET http://localhost:8080/api/v1/products?size=10&sort=LATEST&cursor={{nextCursor}} ### 상품 목록 조회 (가격 오름차순) GET http://localhost:8080/api/v1/products?sort=PRICE_ASC From b336d85a4aa6cc8c98fa26169c0acd305b593694 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B5=E1=86=B7=E1=84=82=E1=85=A1=E1=84=92?= =?UTF-8?q?=E1=85=A7=E1=86=AB?= Date: Thu, 12 Mar 2026 20:28:11 +0900 Subject: [PATCH 04/17] =?UTF-8?q?feat:=20Redis=20Cache-Aside=20=ED=8C=A8?= =?UTF-8?q?=ED=84=B4=20=EC=A0=81=EC=9A=A9=20(=EC=83=81=ED=92=88=20?= =?UTF-8?q?=EC=83=81=EC=84=B8/=EB=AA=A9=EB=A1=9D,=20=EC=A3=BC=EB=AC=B8=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/brand/BrandAdminFacade.java | 15 +- .../application/cache/OrderCacheManager.java | 91 +++++++ .../cache/ProductCacheManager.java | 220 +++++++++++++++++ .../application/order/OrderFacade.java | 42 +++- .../application/payment/PaymentFacade.java | 8 +- .../product/ProductAdminFacade.java | 16 +- .../application/product/ProductFacade.java | 38 ++- .../interfaces/api/order/OrderController.java | 4 +- .../cache/OrderCacheIntegrationTest.java | 162 +++++++++++++ .../cache/ProductCacheIntegrationTest.java | 228 ++++++++++++++++++ docker/infra-compose.yml | 2 + 11 files changed, 811 insertions(+), 15 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/cache/OrderCacheManager.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/cache/ProductCacheManager.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/cache/OrderCacheIntegrationTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/cache/ProductCacheIntegrationTest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandAdminFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandAdminFacade.java index 5941c766c..9530b58a9 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandAdminFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandAdminFacade.java @@ -1,5 +1,6 @@ package com.loopers.application.brand; +import com.loopers.application.cache.ProductCacheManager; import com.loopers.application.product.ProductInfo; import com.loopers.domain.brand.Brand; import com.loopers.domain.brand.BrandService; @@ -26,13 +27,16 @@ public class BrandAdminFacade { private final ProductService productService; private final InventoryService inventoryService; private final CartItemService cartItemService; + private final ProductCacheManager productCacheManager; public BrandAdminFacade(BrandService brandService, ProductService productService, - InventoryService inventoryService, CartItemService cartItemService) { + InventoryService inventoryService, CartItemService cartItemService, + ProductCacheManager productCacheManager) { this.brandService = brandService; this.productService = productService; this.inventoryService = inventoryService; this.cartItemService = cartItemService; + this.productCacheManager = productCacheManager; } /** 어드민 브랜드 상세 조회 (브랜드 + 전체 상품 목록, status 포함) */ @@ -54,11 +58,15 @@ public void deleteBrand(Long brandId) { brandService.delete(brandId); List products = productService.getAllProductsByBrandId(brandId); + List productIds = products.stream().map(Product::getId).toList(); + for (Product product : products) { productService.delete(product.getId()); inventoryService.delete(product.getId()); cartItemService.deleteByProductId(product.getId()); } + + productCacheManager.registerBrandDeleteDoubleDelete(productIds); } /** 전체 브랜드 목록 페이지네이션 조회 */ @@ -89,10 +97,13 @@ public BrandInfo updateBrand(Long brandId, String name, String description) { return BrandInfo.from(brand); } - /** 브랜드 상태 변경 */ + /** 브랜드 상태 변경 — 상품 목록 캐시 무효화 (브랜드 필터 변경) */ @Transactional public BrandInfo changeBrandStatus(Long brandId, BrandStatus status) { Brand brand = brandService.changeStatus(brandId, status); + + productCacheManager.registerListOnlyDoubleDelete(); + return BrandInfo.from(brand); } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/cache/OrderCacheManager.java b/apps/commerce-api/src/main/java/com/loopers/application/cache/OrderCacheManager.java new file mode 100644 index 000000000..4bc5df208 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/cache/OrderCacheManager.java @@ -0,0 +1,91 @@ +package com.loopers.application.cache; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.application.order.OrderFacade; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Component; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +import java.time.Duration; +import java.util.Optional; + +/** + * 주문 목록 캐시 매니저 + * + * Cache-Aside 패턴의 캐시 읽기/저장/무효화를 담당한다. + * 무효화 전략: afterCommit DELETE + TTL 안전망 (Double Delete 안 함) + * + * 키 설계: + * 주문 목록: orders:list:{userId} + * - 첫 페이지(cursor=null) + 기본 조회(최근 3개월)만 캐싱 + * - 커스텀 기간 조회는 캐시 우회 + */ +@Component +public class OrderCacheManager { + + private static final Logger log = LoggerFactory.getLogger(OrderCacheManager.class); + + private static final String LIST_KEY_PREFIX = "orders:list:"; + private static final Duration LIST_TTL = Duration.ofSeconds(300); + + private final RedisTemplate redisTemplate; + private final ObjectMapper objectMapper; + + public OrderCacheManager(RedisTemplate redisTemplate, + ObjectMapper objectMapper) { + this.redisTemplate = redisTemplate; + this.objectMapper = objectMapper; + } + + // ── 주문 목록 캐시 ── + + public Optional getOrderList(Long userId) { + String key = LIST_KEY_PREFIX + userId; + String json = redisTemplate.opsForValue().get(key); + if (json == null) { + return Optional.empty(); + } + try { + return Optional.of(objectMapper.readValue(json, OrderFacade.OrderCursorResult.class)); + } catch (JsonProcessingException e) { + log.warn("주문 목록 캐시 역직렬화 실패 (userId={}), 캐시 삭제 후 DB 조회", userId, e); + redisTemplate.delete(key); + return Optional.empty(); + } + } + + public void putOrderList(Long userId, OrderFacade.OrderCursorResult result) { + String key = LIST_KEY_PREFIX + userId; + try { + String json = objectMapper.writeValueAsString(result); + redisTemplate.opsForValue().set(key, json, LIST_TTL); + } catch (JsonProcessingException e) { + log.warn("주문 목록 캐시 직렬화 실패 (userId={})", userId, e); + } + } + + /** + * @Transactional 메서드에서 호출 — afterCommit에서 캐시 삭제. + */ + public void registerEvictAfterCommit(Long userId) { + TransactionSynchronizationManager.registerSynchronization( + new TransactionSynchronization() { + @Override + public void afterCommit() { + evictOrderList(userId); + } + } + ); + } + + /** + * txTemplate 등 프로그래밍 방식 트랜잭션에서 커밋 후 직접 호출. + */ + public void evictOrderList(Long userId) { + redisTemplate.delete(LIST_KEY_PREFIX + userId); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/cache/ProductCacheManager.java b/apps/commerce-api/src/main/java/com/loopers/application/cache/ProductCacheManager.java new file mode 100644 index 000000000..b4ecfc534 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/cache/ProductCacheManager.java @@ -0,0 +1,220 @@ +package com.loopers.application.cache; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.application.brand.BrandInfo; +import com.loopers.application.product.ProductFacade; +import com.loopers.application.product.ProductInfo; +import com.loopers.domain.brand.BrandService; +import com.loopers.domain.brand.Brand; +import com.loopers.domain.product.ProductSortType; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Component; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; + +/** + * 상품 캐시 매니저 + * + * Cache-Aside 패턴의 캐시 읽기/저장/무효화를 담당한다. + * 무효화 전략: Delayed Double Delete (afterCommit DELETE + 500ms 후 2차 DELETE) + * + * 키 설계: + * 상품 상세: products:detail:{productId} + * 상품 목록: products:list:{sort}:{brandId|all} + */ +@Component +public class ProductCacheManager { + + private static final Logger log = LoggerFactory.getLogger(ProductCacheManager.class); + + private static final String DETAIL_KEY_PREFIX = "products:detail:"; + private static final String LIST_KEY_PREFIX = "products:list:"; + private static final Duration DETAIL_TTL = Duration.ofSeconds(300); + private static final Duration LIST_TTL = Duration.ofSeconds(60); + private static final long DOUBLE_DELETE_DELAY_MS = 500; + + private final RedisTemplate redisTemplate; + private final ObjectMapper objectMapper; + private final BrandService brandService; + + public ProductCacheManager(RedisTemplate redisTemplate, + ObjectMapper objectMapper, + BrandService brandService) { + this.redisTemplate = redisTemplate; + this.objectMapper = objectMapper; + this.brandService = brandService; + } + + // ── 상품 상세 캐시 ── + + public Optional getProductDetail(Long productId) { + String key = DETAIL_KEY_PREFIX + productId; + String json = redisTemplate.opsForValue().get(key); + if (json == null) { + return Optional.empty(); + } + try { + return Optional.of(objectMapper.readValue(json, ProductFacade.ProductDetailResult.class)); + } catch (JsonProcessingException e) { + log.warn("상품 상세 캐시 역직렬화 실패 (productId={}), 캐시 삭제 후 DB 조회", productId, e); + redisTemplate.delete(key); + return Optional.empty(); + } + } + + public void putProductDetail(Long productId, ProductFacade.ProductDetailResult result) { + String key = DETAIL_KEY_PREFIX + productId; + try { + String json = objectMapper.writeValueAsString(result); + redisTemplate.opsForValue().set(key, json, DETAIL_TTL); + } catch (JsonProcessingException e) { + log.warn("상품 상세 캐시 직렬화 실패 (productId={})", productId, e); + } + } + + // ── 상품 목록 캐시 ── + + public Optional getProductList(ProductSortType sort, Long brandId) { + String key = listKey(sort, brandId); + String json = redisTemplate.opsForValue().get(key); + if (json == null) { + return Optional.empty(); + } + try { + return Optional.of(objectMapper.readValue(json, ProductFacade.ProductCursorResult.class)); + } catch (JsonProcessingException e) { + log.warn("상품 목록 캐시 역직렬화 실패 (sort={}, brandId={}), 캐시 삭제 후 DB 조회", sort, brandId, e); + redisTemplate.delete(key); + return Optional.empty(); + } + } + + public void putProductList(ProductSortType sort, Long brandId, ProductFacade.ProductCursorResult result) { + String key = listKey(sort, brandId); + try { + String json = objectMapper.writeValueAsString(result); + redisTemplate.opsForValue().set(key, json, LIST_TTL); + } catch (JsonProcessingException e) { + log.warn("상품 목록 캐시 직렬화 실패 (sort={}, brandId={})", sort, brandId, e); + } + } + + // ── Delayed Double Delete (afterCommit 등록) ── + + /** + * 상품 데이터 변경 시 호출. + * afterCommit 콜백에서 1차 DELETE + 500ms 후 2차 DELETE를 수행한다. + * + * @param productId null이면 상세 캐시 삭제를 건너뛰고 목록만 삭제 + */ + public void registerDelayedDoubleDelete(Long productId) { + TransactionSynchronizationManager.registerSynchronization( + new TransactionSynchronization() { + @Override + public void afterCommit() { + evictProductCaches(productId); + + CompletableFuture.delayedExecutor(DOUBLE_DELETE_DELAY_MS, TimeUnit.MILLISECONDS) + .execute(() -> evictProductCaches(productId)); + } + } + ); + } + + /** + * 브랜드 삭제 시 호출 — 소속 상품 상세 캐시 + 목록 캐시 전체 삭제. + * 브랜드 삭제 시 소속 상품이 전부 soft delete되므로 상세 캐시도 무효화 필수. + */ + public void registerBrandDeleteDoubleDelete(List productIds) { + TransactionSynchronizationManager.registerSynchronization( + new TransactionSynchronization() { + @Override + public void afterCommit() { + evictProductDetailBatch(productIds); + evictAllProductListCache(); + + CompletableFuture.delayedExecutor(DOUBLE_DELETE_DELAY_MS, TimeUnit.MILLISECONDS) + .execute(() -> { + evictProductDetailBatch(productIds); + evictAllProductListCache(); + }); + } + } + ); + } + + /** + * 브랜드 상태 변경 시 호출 — 상품 목록 캐시만 삭제 (상세는 브랜드 상태 무관). + */ + public void registerListOnlyDoubleDelete() { + TransactionSynchronizationManager.registerSynchronization( + new TransactionSynchronization() { + @Override + public void afterCommit() { + evictAllProductListCache(); + + CompletableFuture.delayedExecutor(DOUBLE_DELETE_DELAY_MS, TimeUnit.MILLISECONDS) + .execute(() -> evictAllProductListCache()); + } + } + ); + } + + // ── 내부 메서드 ── + + private void evictProductCaches(Long productId) { + if (productId != null) { + redisTemplate.delete(DETAIL_KEY_PREFIX + productId); + } + evictAllProductListCache(); + } + + private void evictProductDetailBatch(List productIds) { + if (productIds == null || productIds.isEmpty()) { + return; + } + List keys = productIds.stream() + .map(id -> DETAIL_KEY_PREFIX + id) + .toList(); + redisTemplate.delete(keys); + } + + /** + * 상품 목록 캐시 전체 삭제. + * 4개 정렬 × (all + 활성 브랜드) 키를 열거해서 Collection 일괄 삭제. + */ + private void evictAllProductListCache() { + try { + List activeBrands = brandService.getAllActiveBrands(); + List keys = new ArrayList<>(); + + for (ProductSortType sort : ProductSortType.values()) { + keys.add(LIST_KEY_PREFIX + sort.name() + ":all"); + for (Brand brand : activeBrands) { + keys.add(LIST_KEY_PREFIX + sort.name() + ":" + brand.getId()); + } + } + + if (!keys.isEmpty()) { + redisTemplate.delete(keys); + } + } catch (Exception e) { + log.warn("상품 목록 캐시 삭제 실패 — TTL 안전망으로 자연 만료 대기", e); + } + } + + private String listKey(ProductSortType sort, Long brandId) { + String brandPart = (brandId != null) ? String.valueOf(brandId) : "all"; + return LIST_KEY_PREFIX + sort.name() + ":" + brandPart; + } +} 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 4e76045e9..e54bfc7a2 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 @@ -1,5 +1,6 @@ package com.loopers.application.order; +import com.loopers.application.cache.OrderCacheManager; import com.loopers.domain.address.UserAddress; import com.loopers.domain.address.UserAddressService; import com.loopers.domain.brand.Brand; @@ -65,13 +66,15 @@ public class OrderFacade { private final PointService pointService; private final PaymentService paymentService; private final TransactionTemplate txTemplate; + private final OrderCacheManager orderCacheManager; public OrderFacade(OrderService orderService, UserAddressService userAddressService, ProductService productService, BrandService brandService, InventoryService inventoryService, CartItemService cartItemService, CouponService couponService, PointService pointService, PaymentService paymentService, - PlatformTransactionManager txManager) { + PlatformTransactionManager txManager, + OrderCacheManager orderCacheManager) { this.orderService = orderService; this.userAddressService = userAddressService; this.productService = productService; @@ -83,6 +86,7 @@ public OrderFacade(OrderService orderService, UserAddressService userAddressServ this.paymentService = paymentService; this.txTemplate = new TransactionTemplate(txManager); this.txTemplate.setTimeout(30); + this.orderCacheManager = orderCacheManager; } /** @@ -95,7 +99,10 @@ public OrderCreateResult createOrder(Long userId, String userName, String ordere userId, userName, ordererPhone, itemCommands, addressId, issuedCouponId, pointAmount, paymentMethod); - return processPaymentAndConfirm(context); + OrderCreateResult result = processPaymentAndConfirm(context); + + orderCacheManager.evictOrderList(userId); + return result; } /** @@ -126,6 +133,7 @@ public OrderCreateResult createOrderFromCart(Long userId, String userName, Strin log.warn("장바구니 삭제 실패 — 주문은 정상 완료 (orderId={})", result.orderId(), e); } + orderCacheManager.evictOrderList(userId); return result; } @@ -275,9 +283,13 @@ private void compensateOrder(OrderPaymentContext context) { inventoryService.releaseAll(context.productQtyMap()); orderService.cancel(context.orderId(), context.userId()); }); + + // txTemplate 완료 = 커밋 완료 → 직접 캐시 삭제 + orderCacheManager.evictOrderList(context.userId()); } catch (Exception compensateEx) { log.error("CRITICAL: 보상 트랜잭션 실패 — 수동 복구 필요 (orderId={}, paymentId={})", context.orderId(), context.paymentId(), compensateEx); + // 보상 실패 시 캐시 삭제도 안 됨 → TTL 안전망 (300초) } } @@ -306,6 +318,8 @@ public void cancelOrder(Long orderId, Long userId) { Map productQtyMap = order.getItems().stream() .collect(Collectors.toMap(OrderItem::getProductId, OrderItem::getQuantity, Integer::sum)); inventoryService.releaseAll(productQtyMap); + + orderCacheManager.registerEvictAfterCommit(userId); } private String generateOrderNumber() { @@ -316,10 +330,22 @@ private String generateIdempotencyKey() { return "PAY-" + UUID.randomUUID().toString().substring(0, 12).toUpperCase(); } - /** 주문 목록 커서 조회 */ + /** + * 주문 목록 커서 조회 — 기본 조회(첫 페이지 + 커스텀 기간 없음)만 Cache-Aside + * + * @param isDefaultQuery Controller에서 판단: startAt/endAt/cursor 모두 미지정 시 true + */ @Transactional(readOnly = true) public OrderCursorResult getOrdersWithCursor(Long userId, ZonedDateTime startAt, ZonedDateTime endAt, - ZonedDateTime cursorCreatedAt, Long cursorId, int size) { + ZonedDateTime cursorCreatedAt, Long cursorId, int size, + boolean isDefaultQuery) { + if (isDefaultQuery) { + java.util.Optional cached = orderCacheManager.getOrderList(userId); + if (cached.isPresent()) { + return cached.get(); + } + } + CursorResult result = orderService.getOrdersWithCursor(userId, startAt, endAt, cursorCreatedAt, cursorId, size); List summaries = result.items().stream() @@ -328,7 +354,13 @@ public OrderCursorResult getOrdersWithCursor(Long userId, ZonedDateTime startAt, o.getTotalAmount(), o.getCreatedAt())) .toList(); - return new OrderCursorResult(summaries, result.hasNext(), size); + OrderCursorResult cursorResult = new OrderCursorResult(summaries, result.hasNext(), size); + + if (isDefaultQuery) { + orderCacheManager.putOrderList(userId, cursorResult); + } + + return cursorResult; } /** 주문 상세 조회 */ diff --git a/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.java index afe4ccf43..cd845bf12 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.java @@ -1,5 +1,6 @@ package com.loopers.application.payment; +import com.loopers.application.cache.OrderCacheManager; import com.loopers.domain.coupon.CouponService; import com.loopers.domain.coupon.CouponTemplate; import com.loopers.domain.coupon.IssuedCoupon; @@ -39,15 +40,17 @@ public class PaymentFacade { private final InventoryService inventoryService; private final PointService pointService; private final CouponService couponService; + private final OrderCacheManager orderCacheManager; public PaymentFacade(OrderService orderService, PaymentService paymentService, InventoryService inventoryService, PointService pointService, - CouponService couponService) { + CouponService couponService, OrderCacheManager orderCacheManager) { this.orderService = orderService; this.paymentService = paymentService; this.inventoryService = inventoryService; this.pointService = pointService; this.couponService = couponService; + this.orderCacheManager = orderCacheManager; } /** @@ -136,6 +139,9 @@ public PaymentRequestResult requestPayment(Long orderId, Long userId, String pay // 포인트 적립 pointService.earn(userId, order.getTotalAmount()); + + // 주문 상태 변경(PENDING → PAID) → afterCommit에서 캐시 삭제 + orderCacheManager.registerEvictAfterCommit(userId); } else { payment.reject(); diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductAdminFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductAdminFacade.java index 866daa02f..b718c6744 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductAdminFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductAdminFacade.java @@ -1,6 +1,7 @@ package com.loopers.application.product; import com.loopers.application.brand.BrandInfo; +import com.loopers.application.cache.ProductCacheManager; import com.loopers.application.inventory.InventoryInfo; import com.loopers.domain.brand.Brand; import com.loopers.domain.brand.BrandService; @@ -27,13 +28,16 @@ public class ProductAdminFacade { private final BrandService brandService; private final InventoryService inventoryService; private final CartItemService cartItemService; + private final ProductCacheManager productCacheManager; public ProductAdminFacade(ProductService productService, BrandService brandService, - InventoryService inventoryService, CartItemService cartItemService) { + InventoryService inventoryService, CartItemService cartItemService, + ProductCacheManager productCacheManager) { this.productService = productService; this.brandService = brandService; this.inventoryService = inventoryService; this.cartItemService = cartItemService; + this.productCacheManager = productCacheManager; } /** 상품 등록 (브랜드 ACTIVE 검증 + 상품 생성 + 재고 생성) */ @@ -46,6 +50,8 @@ public ProductAdminDetailResult createProduct(Long brandId, String name, String Product product = productService.create(brandId, name, description, basePrice); Inventory inventory = inventoryService.create(product.getId(), quantity); + productCacheManager.registerDelayedDoubleDelete(null); + return new ProductAdminDetailResult( ProductInfo.from(product), BrandInfo.from(brand), InventoryInfo.from(inventory)); } @@ -81,12 +87,17 @@ public void deleteProduct(Long productId) { productService.delete(productId); inventoryService.delete(productId); cartItemService.deleteByProductId(productId); + + productCacheManager.registerDelayedDoubleDelete(productId); } /** 상품 부분 수정 */ @Transactional public ProductAdminDetailResult updateProduct(Long productId, String name, String description, Integer basePrice) { productService.update(productId, name, description, basePrice); + + productCacheManager.registerDelayedDoubleDelete(productId); + return getProductDetail(productId); } @@ -94,6 +105,9 @@ public ProductAdminDetailResult updateProduct(Long productId, String name, Strin @Transactional public ProductAdminDetailResult changeProductStatus(Long productId, ProductStatus status) { productService.changeStatus(productId, status); + + productCacheManager.registerDelayedDoubleDelete(productId); + return getProductDetail(productId); } 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 4135eeb57..a8e0b0295 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 @@ -1,6 +1,7 @@ package com.loopers.application.product; import com.loopers.application.brand.BrandInfo; +import com.loopers.application.cache.ProductCacheManager; import com.loopers.domain.brand.Brand; import com.loopers.domain.brand.BrandService; import com.loopers.domain.common.CursorResult; @@ -12,6 +13,7 @@ import org.springframework.transaction.annotation.Transactional; import java.util.List; +import java.util.Optional; /** * 고객 상품 조회 Facade @@ -23,30 +25,56 @@ public class ProductFacade { private final ProductService productService; private final BrandService brandService; + private final ProductCacheManager productCacheManager; - public ProductFacade(ProductService productService, BrandService brandService) { + public ProductFacade(ProductService productService, BrandService brandService, + ProductCacheManager productCacheManager) { this.productService = productService; this.brandService = brandService; + this.productCacheManager = productCacheManager; } - /** 고객 상품 상세 조회 (상품 + 브랜드명) */ + /** 고객 상품 상세 조회 (상품 + 브랜드명) — Cache-Aside */ @Transactional(readOnly = true) public ProductDetailResult getProductDetail(Long productId) { + Optional cached = productCacheManager.getProductDetail(productId); + if (cached.isPresent()) { + return cached.get(); + } + Product product = productService.getDisplayableProduct(productId); Brand brand = brandService.getActiveBrand(product.getBrandId()); - return new ProductDetailResult(ProductInfo.from(product), BrandInfo.from(brand)); + ProductDetailResult result = new ProductDetailResult(ProductInfo.from(product), BrandInfo.from(brand)); + + productCacheManager.putProductDetail(productId, result); + return result; } - /** 고객 상품 목록 커서 조회 (COUNT 쿼리 없음) */ + /** 고객 상품 목록 커서 조회 (COUNT 쿼리 없음) — 첫 페이지만 Cache-Aside */ @Transactional(readOnly = true) public ProductCursorResult getDisplayableProductsWithCursor(Long brandId, ProductSortType sort, ProductCursor cursor, int size) { + boolean isFirstPage = (cursor == null); + + if (isFirstPage) { + Optional cached = productCacheManager.getProductList(sort, brandId); + if (cached.isPresent()) { + return cached.get(); + } + } + CursorResult result = productService.getDisplayableProductsWithCursor(brandId, sort, cursor, size); List productInfos = result.items().stream() .map(ProductInfo::from) .toList(); - return new ProductCursorResult(productInfos, result.hasNext(), size); + ProductCursorResult cursorResult = new ProductCursorResult(productInfos, result.hasNext(), size); + + if (isFirstPage) { + productCacheManager.putProductList(sort, brandId, cursorResult); + } + + return cursorResult; } public record ProductDetailResult(ProductInfo product, BrandInfo brand) {} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderController.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderController.java index e20af7b1e..91f953810 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderController.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderController.java @@ -76,6 +76,8 @@ public ApiResponse getOrders( @RequestParam(required = false) String cursor, @RequestParam(defaultValue = "20") int size) { + boolean isDefaultQuery = (startAt == null && endAt == null && (cursor == null || cursor.isBlank())); + ZonedDateTime start = startAt != null ? startAt : ZonedDateTime.now().minusMonths(3); ZonedDateTime end = endAt != null ? endAt : ZonedDateTime.now(); @@ -88,7 +90,7 @@ public ApiResponse getOrders( } OrderFacade.OrderCursorResult result = orderFacade.getOrdersWithCursor( - user.getId(), start, end, cursorCreatedAt, cursorId, size); + user.getId(), start, end, cursorCreatedAt, cursorId, size, isDefaultQuery); List summaries = result.orders().stream() .map(o -> new OrderResponse.OrderSummary( diff --git a/apps/commerce-api/src/test/java/com/loopers/application/cache/OrderCacheIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/cache/OrderCacheIntegrationTest.java new file mode 100644 index 000000000..0e70fe355 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/cache/OrderCacheIntegrationTest.java @@ -0,0 +1,162 @@ +package com.loopers.application.cache; + +import com.loopers.application.order.OrderFacade; +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandRepository; +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderItem; +import com.loopers.domain.order.OrderService; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; +import com.loopers.utils.DatabaseCleanUp; +import com.loopers.utils.RedisCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +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 org.springframework.data.redis.core.RedisTemplate; + +import java.time.ZonedDateTime; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +class OrderCacheIntegrationTest { + + @Autowired + private OrderFacade orderFacade; + + @Autowired + private OrderService orderService; + + @Autowired + private ProductRepository productRepository; + + @Autowired + private BrandRepository brandRepository; + + @Autowired + private RedisTemplate redisTemplate; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @Autowired + private RedisCleanUp redisCleanUp; + + private static final Long USER_ID = 1L; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + redisCleanUp.truncateAll(); + } + + @Nested + @DisplayName("주문 목록 캐시") + class OrderListCache { + + @Test + void 기본_조회_시_캐시에_저장된다() { + // arrange + createOrderDirectly(USER_ID, "ORD-TEST-001"); + + // act — 기본 조회 (Controller 기본값 시뮬레이션) + ZonedDateTime defaultStart = ZonedDateTime.now().minusMonths(3); + ZonedDateTime defaultEnd = ZonedDateTime.now(); + orderFacade.getOrdersWithCursor(USER_ID, defaultStart, defaultEnd, null, null, 20, true); + + // assert + String key = "orders:list:" + USER_ID; + String cached = redisTemplate.opsForValue().get(key); + assertThat(cached).isNotNull(); + assertThat(cached).contains("ORD-TEST-001"); + } + + @Test + void 캐시_히트_시_동일한_결과를_반환한다() { + // arrange + createOrderDirectly(USER_ID, "ORD-TEST-001"); + ZonedDateTime defaultStart = ZonedDateTime.now().minusMonths(3); + ZonedDateTime defaultEnd = ZonedDateTime.now(); + + // act + var firstResult = orderFacade.getOrdersWithCursor(USER_ID, defaultStart, defaultEnd, null, null, 20, true); + var secondResult = orderFacade.getOrdersWithCursor(USER_ID, defaultStart, defaultEnd, null, null, 20, true); + + // assert + assertThat(secondResult.orders()).hasSize(firstResult.orders().size()); + assertThat(secondResult.orders().get(0).orderNumber()) + .isEqualTo(firstResult.orders().get(0).orderNumber()); + } + + @Test + void 커스텀_기간_조회는_캐시를_사용하지_않는다() { + // arrange + createOrderDirectly(USER_ID, "ORD-TEST-001"); + + // act — 커스텀 기간 (isDefaultQuery = false) + ZonedDateTime now = ZonedDateTime.now(); + orderFacade.getOrdersWithCursor(USER_ID, now.minusDays(7), now, null, null, 20, false); + + // assert — 캐시에 저장되지 않음 + String key = "orders:list:" + USER_ID; + assertThat(redisTemplate.opsForValue().get(key)).isNull(); + } + + @Test + void 주문_취소_시_캐시가_삭제된다() { + // arrange + Order order = createOrderDirectly(USER_ID, "ORD-TEST-001"); + ZonedDateTime defaultStart = ZonedDateTime.now().minusMonths(3); + ZonedDateTime defaultEnd = ZonedDateTime.now(); + + orderFacade.getOrdersWithCursor(USER_ID, defaultStart, defaultEnd, null, null, 20, true); + assertThat(redisTemplate.opsForValue().get("orders:list:" + USER_ID)).isNotNull(); + + // act — 주문 취소 (afterCommit DELETE) + orderFacade.cancelOrder(order.getId(), USER_ID); + + // assert + assertThat(redisTemplate.opsForValue().get("orders:list:" + USER_ID)).isNull(); + } + + @Test + void 유저별로_별도_캐시가_생성된다() { + // arrange + Long userId2 = 2L; + createOrderDirectly(USER_ID, "ORD-USER1"); + createOrderDirectly(userId2, "ORD-USER2"); + ZonedDateTime defaultStart = ZonedDateTime.now().minusMonths(3); + ZonedDateTime defaultEnd = ZonedDateTime.now(); + + // act + orderFacade.getOrdersWithCursor(USER_ID, defaultStart, defaultEnd, null, null, 20, true); + orderFacade.getOrdersWithCursor(userId2, defaultStart, defaultEnd, null, null, 20, true); + + // assert + assertThat(redisTemplate.opsForValue().get("orders:list:" + USER_ID)).contains("ORD-USER1"); + assertThat(redisTemplate.opsForValue().get("orders:list:" + userId2)).contains("ORD-USER2"); + } + } + + private Order createOrderDirectly(Long userId, String orderNumber) { + Brand brand = brandRepository.save(Brand.register("테스트브랜드", "설명")); + Product product = productRepository.save(Product.register(brand.getId(), "테스트상품", "설명", 10000)); + + List items = List.of( + OrderItem.snapshot(product.getId(), "테스트상품", "테스트브랜드", 10000, 1) + ); + + return orderService.create(userId, orderNumber, items, + "테스터", "010-1234-5678", + "수령인", "010-8765-4321", + "12345", "서울시 강남구", "101호"); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/cache/ProductCacheIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/cache/ProductCacheIntegrationTest.java new file mode 100644 index 000000000..06c712950 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/cache/ProductCacheIntegrationTest.java @@ -0,0 +1,228 @@ +package com.loopers.application.cache; + +import com.loopers.application.product.ProductFacade; +import com.loopers.application.product.ProductAdminFacade; +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandRepository; +import com.loopers.domain.brand.BrandStatus; +import com.loopers.domain.product.ProductSortType; +import com.loopers.utils.DatabaseCleanUp; +import com.loopers.utils.RedisCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +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 org.springframework.data.redis.core.RedisTemplate; + +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +class ProductCacheIntegrationTest { + + @Autowired + private ProductFacade productFacade; + + @Autowired + private ProductAdminFacade productAdminFacade; + + @Autowired + private BrandRepository brandRepository; + + @Autowired + private RedisTemplate redisTemplate; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @Autowired + private RedisCleanUp redisCleanUp; + + private Brand activeBrand; + + @BeforeEach + void setUp() { + activeBrand = brandRepository.save(Brand.register("테스트브랜드", "설명")); + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + redisCleanUp.truncateAll(); + } + + @Nested + @DisplayName("상품 상세 캐시") + class ProductDetailCache { + + @Test + void 상품_상세_조회_시_캐시에_저장된다() { + // arrange + var created = productAdminFacade.createProduct( + activeBrand.getId(), "에어맥스", "설명", 150000, 100); + Long productId = created.product().id(); + + // 생성 시 Double Delete로 캐시가 삭제되므로, 잠시 대기 후 조회 + waitForDoubleDelete(); + + // act + productFacade.getProductDetail(productId); + + // assert + String key = "products:detail:" + productId; + String cached = redisTemplate.opsForValue().get(key); + assertThat(cached).isNotNull(); + assertThat(cached).contains("에어맥스"); + assertThat(cached).contains("150000"); + } + + @Test + void 캐시_히트_시_동일한_결과를_반환한다() { + // arrange + var created = productAdminFacade.createProduct( + activeBrand.getId(), "에어맥스", "설명", 150000, 100); + Long productId = created.product().id(); + waitForDoubleDelete(); + + // act — 첫 조회 (cache miss → DB → cache put) + var firstResult = productFacade.getProductDetail(productId); + // act — 두 번째 조회 (cache hit) + var secondResult = productFacade.getProductDetail(productId); + + // assert + assertThat(secondResult.product().id()).isEqualTo(firstResult.product().id()); + assertThat(secondResult.product().name()).isEqualTo(firstResult.product().name()); + assertThat(secondResult.brand().name()).isEqualTo(firstResult.brand().name()); + } + + @Test + void 상품_수정_시_캐시가_삭제된다() { + // arrange + var created = productAdminFacade.createProduct( + activeBrand.getId(), "에어맥스", "설명", 150000, 100); + Long productId = created.product().id(); + waitForDoubleDelete(); + + productFacade.getProductDetail(productId); // 캐시 저장 + String key = "products:detail:" + productId; + assertThat(redisTemplate.opsForValue().get(key)).isNotNull(); + + // act — 상품 수정 (Delayed Double Delete 트리거) + productAdminFacade.updateProduct(productId, "에어포스", null, null); + waitForDoubleDelete(); + + // assert — 캐시 삭제됨 + assertThat(redisTemplate.opsForValue().get(key)).isNull(); + } + + @Test + void 상품_삭제_시_캐시가_삭제된다() { + // arrange + var created = productAdminFacade.createProduct( + activeBrand.getId(), "에어맥스", "설명", 150000, 100); + Long productId = created.product().id(); + waitForDoubleDelete(); + + productFacade.getProductDetail(productId); // 캐시 저장 + String key = "products:detail:" + productId; + assertThat(redisTemplate.opsForValue().get(key)).isNotNull(); + + // act + productAdminFacade.deleteProduct(productId); + waitForDoubleDelete(); + + // assert + assertThat(redisTemplate.opsForValue().get(key)).isNull(); + } + } + + @Nested + @DisplayName("상품 목록 캐시") + class ProductListCache { + + @Test + void 첫_페이지_조회_시_캐시에_저장된다() { + // arrange + productAdminFacade.createProduct(activeBrand.getId(), "상품1", "설명", 10000, 100); + productAdminFacade.createProduct(activeBrand.getId(), "상품2", "설명", 20000, 100); + waitForDoubleDelete(); + + // act + productFacade.getDisplayableProductsWithCursor(null, ProductSortType.LATEST, null, 20); + + // assert + String key = "products:list:LATEST:all"; + String cached = redisTemplate.opsForValue().get(key); + assertThat(cached).isNotNull(); + assertThat(cached).contains("상품1"); + assertThat(cached).contains("상품2"); + } + + @Test + void 브랜드_필터_조회_시_브랜드별_키로_캐싱된다() { + // arrange + productAdminFacade.createProduct(activeBrand.getId(), "상품1", "설명", 10000, 100); + waitForDoubleDelete(); + + // act + productFacade.getDisplayableProductsWithCursor( + activeBrand.getId(), ProductSortType.PRICE_ASC, null, 20); + + // assert + String key = "products:list:PRICE_ASC:" + activeBrand.getId(); + assertThat(redisTemplate.opsForValue().get(key)).isNotNull(); + } + + @Test + void 상품_생성_시_목록_캐시가_삭제된다() { + // arrange + productAdminFacade.createProduct(activeBrand.getId(), "상품1", "설명", 10000, 100); + waitForDoubleDelete(); + + productFacade.getDisplayableProductsWithCursor(null, ProductSortType.LATEST, null, 20); + assertThat(redisTemplate.opsForValue().get("products:list:LATEST:all")).isNotNull(); + + // act — 새 상품 생성 (목록 캐시 무효화) + productAdminFacade.createProduct(activeBrand.getId(), "상품2", "설명", 20000, 100); + waitForDoubleDelete(); + + // assert — 목록 캐시 삭제됨 + assertThat(redisTemplate.opsForValue().get("products:list:LATEST:all")).isNull(); + } + + @Test + void 정렬_타입별로_별도_캐시가_생성된다() { + // arrange + productAdminFacade.createProduct(activeBrand.getId(), "상품1", "설명", 10000, 100); + waitForDoubleDelete(); + + // act + productFacade.getDisplayableProductsWithCursor(null, ProductSortType.LATEST, null, 20); + productFacade.getDisplayableProductsWithCursor(null, ProductSortType.PRICE_ASC, null, 20); + + // assert + assertThat(redisTemplate.opsForValue().get("products:list:LATEST:all")).isNotNull(); + assertThat(redisTemplate.opsForValue().get("products:list:PRICE_ASC:all")).isNotNull(); + assertThat(redisTemplate.opsForValue().get("products:list:LIKES_DESC:all")).isNull(); + } + } + + /** + * Delayed Double Delete의 2차 DELETE(500ms 후)가 완료될 때까지 대기. + * 테스트에서 캐시 상태를 정확히 확인하기 위해 필요. + */ + private void waitForDoubleDelete() { + try { + Thread.sleep(700); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } +} diff --git a/docker/infra-compose.yml b/docker/infra-compose.yml index 18e5fcf5f..da23ba289 100644 --- a/docker/infra-compose.yml +++ b/docker/infra-compose.yml @@ -26,6 +26,8 @@ services: "redis-server", # redis 서버 실행 명령어 "--appendonly", "yes", # AOF (AppendOnlyFile) 영속성 기능 켜기 "--save", "", + "--maxmemory", "512mb", # 캐시용 메모리 상한 + "--maxmemory-policy", "allkeys-lru", # 메모리 초과 시 LRU 키부터 삭제 "--latency-monitor-threshold", "100", # 특정 command 가 지정 시간(ms) 이상 걸리면 monitor 기록 ] healthcheck: From 7c04f8ff0703947198e8b4924d6580d97ab84a1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B5=E1=86=B7=E1=84=82=E1=85=A1=E1=84=92?= =?UTF-8?q?=E1=85=A7=E1=86=AB?= Date: Thu, 12 Mar 2026 20:30:23 +0900 Subject: [PATCH 05/17] =?UTF-8?q?feat:=20=EC=84=B1=EB=8A=A5=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=EC=9A=A9=2014=EA=B0=9C=20=ED=85=8C=EC=9D=B4?= =?UTF-8?q?=EB=B8=94=20=EB=8C=80=EB=9F=89=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20?= =?UTF-8?q?=EC=8B=9C=EB=8D=94=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../seeder/BrandLikeSeeder.java | 110 +++++++++ .../infrastructure/seeder/BrandSeeder.java | 68 ++++++ .../infrastructure/seeder/CartItemSeeder.java | 128 ++++++++++ .../seeder/CouponTemplateSeeder.java | 114 +++++++++ .../infrastructure/seeder/DataSeeder.java | 116 +++++++++ .../seeder/InventorySeeder.java | 70 ++++++ .../seeder/IssuedCouponSeeder.java | 136 +++++++++++ .../infrastructure/seeder/OrderSeeder.java | 222 ++++++++++++++++++ .../infrastructure/seeder/PaymentSeeder.java | 149 ++++++++++++ .../seeder/PointAccountSeeder.java | 74 ++++++ .../seeder/ProductLikeSeeder.java | 107 +++++++++ .../infrastructure/seeder/ProductSeeder.java | 176 ++++++++++++++ .../seeder/UserAddressSeeder.java | 130 ++++++++++ .../infrastructure/seeder/UserSeeder.java | 58 +++++ 14 files changed, 1658 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/seeder/BrandLikeSeeder.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/seeder/BrandSeeder.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/seeder/CartItemSeeder.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/seeder/CouponTemplateSeeder.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/seeder/DataSeeder.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/seeder/InventorySeeder.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/seeder/IssuedCouponSeeder.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/seeder/OrderSeeder.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/seeder/PaymentSeeder.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/seeder/PointAccountSeeder.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/seeder/ProductLikeSeeder.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/seeder/ProductSeeder.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/seeder/UserAddressSeeder.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/seeder/UserSeeder.java diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/seeder/BrandLikeSeeder.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/seeder/BrandLikeSeeder.java new file mode 100644 index 000000000..863958336 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/seeder/BrandLikeSeeder.java @@ -0,0 +1,110 @@ +package com.loopers.infrastructure.seeder; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +import java.sql.Timestamp; +import java.time.ZonedDateTime; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Random; +import java.util.Set; + +/** + * brand_likes 시딩 (~50,000건) + * + * 분포: + * - 인기 브랜드(상위 80개)에 좋아요 집중 (멱법칙) + * - 유저당 평균 10개 브랜드, 1~50개 범위 + * - UniqueConstraint(user_id, brand_id) 준수 + */ +@Component +class BrandLikeSeeder { + + private static final Logger log = LoggerFactory.getLogger(BrandLikeSeeder.class); + private static final int BATCH_SIZE = 5_000; + private static final int USER_COUNT = UserSeeder.USER_COUNT; + private static final int BRAND_COUNT = BrandSeeder.BRAND_COUNT; + private static final int ACTIVE_BRAND_COUNT = BrandSeeder.ACTIVE_BRAND_COUNT; + + private final JdbcTemplate jdbcTemplate; + private final Random random = new Random(77); + + BrandLikeSeeder(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + void seed() { + log.info("[BrandLikeSeeder] 브랜드 좋아요 생성 시작"); + long start = System.currentTimeMillis(); + + ZonedDateTime now = ZonedDateTime.now(ZoneId.of("Asia/Seoul")); + List batch = new ArrayList<>(BATCH_SIZE); + long inserted = 0; + + // 유저별로 좋아요할 브랜드 수 결정 + for (int userId = 1; userId <= USER_COUNT; userId++) { + int likeCount = generateUserBrandLikeCount(); + Set likedBrands = new HashSet<>(); + + for (int j = 0; j < likeCount; j++) { + int brandId; + do { + brandId = generateBrandId(); + } while (likedBrands.contains(brandId)); + likedBrands.add(brandId); + + batch.add(new Object[]{ + userId, + brandId, + Timestamp.from(now.minusDays(random.nextInt(180)).toInstant()) + }); + + if (batch.size() >= BATCH_SIZE) { + flushBatch(batch); + inserted += batch.size(); + batch.clear(); + } + } + } + + if (!batch.isEmpty()) { + flushBatch(batch); + inserted += batch.size(); + } + + log.info("[BrandLikeSeeder] {}건 생성 완료 ({}ms)", inserted, System.currentTimeMillis() - start); + } + + /** 유저당 브랜드 좋아요 수: 평균 10개, 1~50개 범위 */ + private int generateUserBrandLikeCount() { + double r = random.nextDouble(); + if (r < 0.30) return random.nextInt(3) + 1; // 1~3 + if (r < 0.70) return random.nextInt(8) + 4; // 4~11 + if (r < 0.90) return random.nextInt(15) + 12; // 12~26 + return random.nextInt(24) + 27; // 27~50 + } + + /** 인기 브랜드(1~80)에 집중 */ + private int generateBrandId() { + double r = random.nextDouble(); + if (r < 0.70) { + return random.nextInt(80) + 1; // 대형 브랜드 + } else if (r < 0.95) { + return random.nextInt(320) + 81; // 중소 브랜드 + } else { + return random.nextInt(100) + 401; // INACTIVE 브랜드 + } + } + + private void flushBatch(List batch) { + jdbcTemplate.batchUpdate( + "INSERT INTO brand_likes (user_id, brand_id, created_at) VALUES (?, ?, ?)", + batch + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/seeder/BrandSeeder.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/seeder/BrandSeeder.java new file mode 100644 index 000000000..378d48a03 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/seeder/BrandSeeder.java @@ -0,0 +1,68 @@ +package com.loopers.infrastructure.seeder; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.jdbc.core.BatchPreparedStatementSetter; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +import java.sql.PreparedStatement; +import java.sql.SQLException; +import java.sql.Timestamp; +import java.time.ZonedDateTime; +import java.time.ZoneId; + +@Component +class BrandSeeder { + + private static final Logger log = LoggerFactory.getLogger(BrandSeeder.class); + static final int BRAND_COUNT = 500; + static final int ACTIVE_BRAND_COUNT = 400; + + private final JdbcTemplate jdbcTemplate; + + BrandSeeder(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + void seed() { + log.info("[BrandSeeder] {}개 브랜드 생성 시작", BRAND_COUNT); + long start = System.currentTimeMillis(); + + ZonedDateTime now = ZonedDateTime.now(ZoneId.of("Asia/Seoul")); + + jdbcTemplate.batchUpdate( + "INSERT INTO brands (name, description, status, created_at, updated_at, deleted_at) " + + "VALUES (?, ?, ?, ?, ?, ?)", + new BatchPreparedStatementSetter() { + @Override + public void setValues(PreparedStatement ps, int i) throws SQLException { + int idx = i + 1; + ps.setString(1, "브랜드" + idx); + ps.setString(2, "브랜드" + idx + " 설명"); + + // 1~400: ACTIVE, 401~500: INACTIVE + String status = idx <= ACTIVE_BRAND_COUNT ? "ACTIVE" : "INACTIVE"; + ps.setString(3, status); + + ps.setTimestamp(4, Timestamp.from(now.toInstant())); + ps.setTimestamp(5, Timestamp.from(now.toInstant())); + + // 491~500번 브랜드: soft delete (10개) + if (idx > 490) { + ps.setTimestamp(6, Timestamp.from(now.toInstant())); + } else { + ps.setNull(6, java.sql.Types.TIMESTAMP); + } + } + + @Override + public int getBatchSize() { + return BRAND_COUNT; + } + } + ); + + log.info("[BrandSeeder] {}개 브랜드 생성 완료 ({}ms)", BRAND_COUNT, System.currentTimeMillis() - start); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/seeder/CartItemSeeder.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/seeder/CartItemSeeder.java new file mode 100644 index 000000000..8f4b01a47 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/seeder/CartItemSeeder.java @@ -0,0 +1,128 @@ +package com.loopers.infrastructure.seeder; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +import java.sql.Timestamp; +import java.time.ZonedDateTime; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Random; +import java.util.Set; + +/** + * cart_items 시딩 (~15,000건) + * + * 분포: + * - 전체 유저의 60%(3,000명)가 장바구니 보유 + * - user당 1~8개, 평균 5개 + * - quantity: 1개 70% / 2개 20% / 3~5개 10% + * - deleted_at: 90% NULL / 10% 삭제 + * - UniqueConstraint(user_id, product_id) 준수 + */ +@Component +class CartItemSeeder { + + private static final Logger log = LoggerFactory.getLogger(CartItemSeeder.class); + private static final int BATCH_SIZE = 5_000; + private static final int USER_COUNT = UserSeeder.USER_COUNT; + private static final int PRODUCT_COUNT = ProductSeeder.PRODUCT_COUNT; + + private final JdbcTemplate jdbcTemplate; + private final Random random = new Random(33); + + CartItemSeeder(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + void seed() { + log.info("[CartItemSeeder] 장바구니 데이터 생성 시작"); + long start = System.currentTimeMillis(); + + ZonedDateTime now = ZonedDateTime.now(ZoneId.of("Asia/Seoul")); + List batch = new ArrayList<>(BATCH_SIZE); + long inserted = 0; + + // 60%의 유저가 장바구니 보유 + for (int userId = 1; userId <= USER_COUNT; userId++) { + if (random.nextDouble() >= 0.60) continue; // 40%는 장바구니 없음 + + int itemCount = generateItemCount(); + Set usedProducts = new HashSet<>(); + + for (int j = 0; j < itemCount; j++) { + int productId; + do { + productId = generateProductId(); + } while (usedProducts.contains(productId)); + usedProducts.add(productId); + + int quantity = generateQuantity(); + ZonedDateTime createdAt = now.minusDays(random.nextInt(30)); + boolean isDeleted = random.nextDouble() < 0.10; + + batch.add(new Object[]{ + userId, productId, quantity, + Timestamp.from(createdAt.toInstant()), + Timestamp.from(now.toInstant()), + isDeleted ? Timestamp.from(now.minusDays(random.nextInt(7)).toInstant()) : null + }); + + if (batch.size() >= BATCH_SIZE) { + flushBatch(batch); + inserted += batch.size(); + batch.clear(); + } + } + } + + if (!batch.isEmpty()) { + flushBatch(batch); + inserted += batch.size(); + } + + log.info("[CartItemSeeder] {}건 생성 완료 ({}ms)", inserted, System.currentTimeMillis() - start); + } + + /** 1~8개, 평균 5개 */ + private int generateItemCount() { + double r = random.nextDouble(); + if (r < 0.10) return 1; + if (r < 0.25) return 2; + if (r < 0.40) return 3; + if (r < 0.55) return 4; + if (r < 0.70) return 5; + if (r < 0.82) return 6; + if (r < 0.92) return 7; + return 8; + } + + /** 인기 상품에 약간 집중 (1~PRODUCT_COUNT) */ + private int generateProductId() { + // 상품 ID가 낮을수록 인기 브랜드 상품일 확률 높음 + if (random.nextDouble() < 0.30) { + return random.nextInt(PRODUCT_COUNT / 10) + 1; // 상위 10% + } + return random.nextInt(PRODUCT_COUNT) + 1; + } + + /** 1개 70% / 2개 20% / 3~5개 10% */ + private int generateQuantity() { + double r = random.nextDouble(); + if (r < 0.70) return 1; + if (r < 0.90) return 2; + return random.nextInt(3) + 3; // 3~5 + } + + private void flushBatch(List batch) { + jdbcTemplate.batchUpdate( + "INSERT INTO cart_items (user_id, product_id, quantity, created_at, updated_at, deleted_at) " + + "VALUES (?, ?, ?, ?, ?, ?)", + batch + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/seeder/CouponTemplateSeeder.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/seeder/CouponTemplateSeeder.java new file mode 100644 index 000000000..ba15273c0 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/seeder/CouponTemplateSeeder.java @@ -0,0 +1,114 @@ +package com.loopers.infrastructure.seeder; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.jdbc.core.BatchPreparedStatementSetter; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +import java.sql.PreparedStatement; +import java.sql.SQLException; +import java.sql.Timestamp; +import java.sql.Types; +import java.time.ZonedDateTime; +import java.time.ZoneId; +import java.util.Random; + +/** + * coupon_templates 시딩 (50건) + * + * 분포: + * - discount_type: PERCENTAGE 60% / FIXED_AMOUNT 40% + * - status: ACTIVE 60% / EXPIRED 30% / INACTIVE 10% + * - valid 기간: 7~90일 + */ +@Component +class CouponTemplateSeeder { + + private static final Logger log = LoggerFactory.getLogger(CouponTemplateSeeder.class); + static final int TEMPLATE_COUNT = 50; + + private final JdbcTemplate jdbcTemplate; + private final Random random = new Random(55); + + CouponTemplateSeeder(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + void seed() { + log.info("[CouponTemplateSeeder] {}건 생성 시작", TEMPLATE_COUNT); + long start = System.currentTimeMillis(); + + ZonedDateTime now = ZonedDateTime.now(ZoneId.of("Asia/Seoul")); + + jdbcTemplate.batchUpdate( + "INSERT INTO coupon_templates (name, description, discount_type, discount_value, " + + "max_discount_amount, min_order_amount, max_issue_count, max_issue_count_per_user, " + + "issued_count, valid_from, valid_to, status, created_at, updated_at, deleted_at) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + new BatchPreparedStatementSetter() { + @Override + public void setValues(PreparedStatement ps, int i) throws SQLException { + int idx = i + 1; + boolean isPercentage = random.nextDouble() < 0.60; + String discountType = isPercentage ? "PERCENT" : "FIXED"; + + ps.setString(1, "쿠폰" + idx); + ps.setString(2, "쿠폰" + idx + " 설명"); + ps.setString(3, discountType); + + if (isPercentage) { + ps.setInt(4, (random.nextInt(6) + 1) * 5); // 5, 10, 15, 20, 25, 30% + ps.setInt(5, (random.nextInt(10) + 1) * 5_000); // 5,000~50,000 + } else { + ps.setInt(4, (random.nextInt(10) + 1) * 1_000); // 1,000~10,000 + ps.setNull(5, Types.INTEGER); + } + + ps.setInt(6, (random.nextInt(10) + 1) * 10_000); // 10,000~100,000 + int maxIssue = (random.nextInt(50) + 1) * 100; // 100~5,000 + ps.setInt(7, maxIssue); + ps.setInt(8, random.nextInt(3) + 1); // 1~3 + + // issued_count: status에 따라 + String status = generateStatus(); + int issuedCount; + if ("EXPIRED".equals(status)) { + issuedCount = (int) (maxIssue * (0.3 + random.nextDouble() * 0.7)); // 30~100% + } else if ("ACTIVE".equals(status)) { + issuedCount = (int) (maxIssue * random.nextDouble() * 0.8); // 0~80% + } else { + issuedCount = (int) (maxIssue * random.nextDouble() * 0.3); // 0~30% + } + ps.setInt(9, issuedCount); + + int validDays = random.nextInt(84) + 7; // 7~90일 + ZonedDateTime validFrom = now.minusDays(random.nextInt(365)); + ZonedDateTime validTo = validFrom.plusDays(validDays); + ps.setTimestamp(10, Timestamp.from(validFrom.toInstant())); + ps.setTimestamp(11, Timestamp.from(validTo.toInstant())); + + ps.setString(12, status); + ps.setTimestamp(13, Timestamp.from(validFrom.toInstant())); + ps.setTimestamp(14, Timestamp.from(now.toInstant())); + ps.setNull(15, Types.TIMESTAMP); + } + + @Override + public int getBatchSize() { + return TEMPLATE_COUNT; + } + } + ); + + log.info("[CouponTemplateSeeder] {}건 생성 완료 ({}ms)", TEMPLATE_COUNT, System.currentTimeMillis() - start); + } + + /** ACTIVE 60% / EXPIRED 30% / INACTIVE 10% */ + private String generateStatus() { + double r = random.nextDouble(); + if (r < 0.60) return "ACTIVE"; + if (r < 0.90) return "EXPIRED"; + return "INACTIVE"; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/seeder/DataSeeder.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/seeder/DataSeeder.java new file mode 100644 index 000000000..1124c8205 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/seeder/DataSeeder.java @@ -0,0 +1,116 @@ +package com.loopers.infrastructure.seeder; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.context.annotation.Profile; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +/** + * 성능 테스트용 대량 데이터 시더 (local 프로파일 전용) + * + * 총 14개 테이블 시딩: + * users(5K) → brands(500) → products(200K) → product_likes(~10M) → inventories(200K) + * → brand_likes(~50K) → coupon_templates(50) → orders(100K) → order_items(~150K) + * → payments(100K) → issued_coupons(30K) → point_accounts(5K) → cart_items(~15K) + * → user_addresses(~8K) + * + * @see 도메인별 최적화 대상 분석 및 데이터 분포 설계.md + */ +@Component +@Profile("local") +public class DataSeeder implements ApplicationRunner { + + private static final Logger log = LoggerFactory.getLogger(DataSeeder.class); + + private final JdbcTemplate jdbcTemplate; + private final UserSeeder userSeeder; + private final BrandSeeder brandSeeder; + private final ProductSeeder productSeeder; + private final ProductLikeSeeder productLikeSeeder; + private final InventorySeeder inventorySeeder; + private final BrandLikeSeeder brandLikeSeeder; + private final CouponTemplateSeeder couponTemplateSeeder; + private final OrderSeeder orderSeeder; + private final PaymentSeeder paymentSeeder; + private final IssuedCouponSeeder issuedCouponSeeder; + private final PointAccountSeeder pointAccountSeeder; + private final CartItemSeeder cartItemSeeder; + private final UserAddressSeeder userAddressSeeder; + + public DataSeeder( + JdbcTemplate jdbcTemplate, + UserSeeder userSeeder, + BrandSeeder brandSeeder, + ProductSeeder productSeeder, + ProductLikeSeeder productLikeSeeder, + InventorySeeder inventorySeeder, + BrandLikeSeeder brandLikeSeeder, + CouponTemplateSeeder couponTemplateSeeder, + OrderSeeder orderSeeder, + PaymentSeeder paymentSeeder, + IssuedCouponSeeder issuedCouponSeeder, + PointAccountSeeder pointAccountSeeder, + CartItemSeeder cartItemSeeder, + UserAddressSeeder userAddressSeeder + ) { + this.jdbcTemplate = jdbcTemplate; + this.userSeeder = userSeeder; + this.brandSeeder = brandSeeder; + this.productSeeder = productSeeder; + this.productLikeSeeder = productLikeSeeder; + this.inventorySeeder = inventorySeeder; + this.brandLikeSeeder = brandLikeSeeder; + this.couponTemplateSeeder = couponTemplateSeeder; + this.orderSeeder = orderSeeder; + this.paymentSeeder = paymentSeeder; + this.issuedCouponSeeder = issuedCouponSeeder; + this.pointAccountSeeder = pointAccountSeeder; + this.cartItemSeeder = cartItemSeeder; + this.userAddressSeeder = userAddressSeeder; + } + + @Override + public void run(ApplicationArguments args) { + if (isAlreadySeeded()) { + log.info("[DataSeeder] 데이터가 이미 존재합니다. 시딩을 건너뜁니다."); + return; + } + + log.info("[DataSeeder] ===== 성능 테스트용 대량 데이터 시딩 시작 (14개 테이블) ====="); + long start = System.currentTimeMillis(); + + // Phase 1: 기본 엔티티 + userSeeder.seed(); + brandSeeder.seed(); + + // Phase 2: 상품 관련 + productSeeder.seed(); + productLikeSeeder.seed(); + inventorySeeder.seed(); + brandLikeSeeder.seed(); + + // Phase 3: 쿠폰 + couponTemplateSeeder.seed(); + + // Phase 4: 주문 + 결제 + orderSeeder.seed(); + paymentSeeder.seed(); + issuedCouponSeeder.seed(); + + // Phase 5: 유저 부가 데이터 + pointAccountSeeder.seed(); + cartItemSeeder.seed(); + userAddressSeeder.seed(); + + long elapsed = System.currentTimeMillis() - start; + log.info("[DataSeeder] ===== 시딩 완료 ({}ms, {}초) =====", elapsed, elapsed / 1000); + } + + private boolean isAlreadySeeded() { + Integer count = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM products", Integer.class); + return count != null && count > 0; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/seeder/InventorySeeder.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/seeder/InventorySeeder.java new file mode 100644 index 000000000..bdecf8431 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/seeder/InventorySeeder.java @@ -0,0 +1,70 @@ +package com.loopers.infrastructure.seeder; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.jdbc.core.BatchPreparedStatementSetter; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +import java.sql.PreparedStatement; +import java.sql.SQLException; +import java.sql.Timestamp; +import java.time.ZonedDateTime; +import java.time.ZoneId; +import java.util.Random; + +/** + * inventories 시딩 — products와 1:1 매핑 + */ +@Component +class InventorySeeder { + + private static final Logger log = LoggerFactory.getLogger(InventorySeeder.class); + private static final int BATCH_SIZE = 5_000; + + private final JdbcTemplate jdbcTemplate; + private final Random random = new Random(42); + + InventorySeeder(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + void seed() { + int productCount = ProductSeeder.PRODUCT_COUNT; + log.info("[InventorySeeder] {}건 생성 시작", productCount); + long start = System.currentTimeMillis(); + + ZonedDateTime now = ZonedDateTime.now(ZoneId.of("Asia/Seoul")); + + for (int batchStart = 0; batchStart < productCount; batchStart += BATCH_SIZE) { + int currentBatchStart = batchStart; + int currentBatchSize = Math.min(BATCH_SIZE, productCount - batchStart); + + jdbcTemplate.batchUpdate( + "INSERT INTO inventories (product_id, quantity, reserved_qty, created_at, updated_at) " + + "VALUES (?, ?, ?, ?, ?)", + new BatchPreparedStatementSetter() { + @Override + public void setValues(PreparedStatement ps, int i) throws SQLException { + long productId = currentBatchStart + i + 1; + int quantity = random.nextInt(991) + 10; // 10~1000 + int reservedQty = quantity / 10; // 10% 예약 + + ps.setLong(1, productId); + ps.setInt(2, quantity); + ps.setInt(3, reservedQty); + ps.setTimestamp(4, Timestamp.from(now.toInstant())); + ps.setTimestamp(5, Timestamp.from(now.toInstant())); + } + + @Override + public int getBatchSize() { + return currentBatchSize; + } + } + ); + } + + log.info("[InventorySeeder] {}건 생성 완료 ({}ms)", productCount, System.currentTimeMillis() - start); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/seeder/IssuedCouponSeeder.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/seeder/IssuedCouponSeeder.java new file mode 100644 index 000000000..4eba8b4cf --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/seeder/IssuedCouponSeeder.java @@ -0,0 +1,136 @@ +package com.loopers.infrastructure.seeder; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +import java.sql.Timestamp; +import java.sql.Types; +import java.time.ZonedDateTime; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.List; +import java.util.Random; + +/** + * issued_coupons 시딩 (~30,000건) + * + * 분포: + * - user_id: 상위 20% 유저가 50% 보유 + * - status: USED 50% / UNUSED 25% / EXPIRED 20% / CANCELED 5% + * - USED인 경우 order_id 매핑 + */ +@Component +class IssuedCouponSeeder { + + private static final Logger log = LoggerFactory.getLogger(IssuedCouponSeeder.class); + private static final int ISSUED_COUNT = 30_000; + private static final int BATCH_SIZE = 5_000; + private static final int USER_COUNT = UserSeeder.USER_COUNT; + private static final int TEMPLATE_COUNT = CouponTemplateSeeder.TEMPLATE_COUNT; + + private final JdbcTemplate jdbcTemplate; + private final Random random = new Random(66); + + IssuedCouponSeeder(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + void seed() { + log.info("[IssuedCouponSeeder] {}건 생성 시작", ISSUED_COUNT); + long start = System.currentTimeMillis(); + + // PAID 주문 ID 목록 조회 (USED 쿠폰에 매핑용) + List paidOrderIds = jdbcTemplate.queryForList( + "SELECT id FROM orders WHERE status = 'PAID' ORDER BY id", Long.class + ); + + ZonedDateTime now = ZonedDateTime.now(ZoneId.of("Asia/Seoul")); + List batch = new ArrayList<>(BATCH_SIZE); + long inserted = 0; + int paidOrderIdx = 0; + + for (int i = 0; i < ISSUED_COUNT; i++) { + long userId = generateUserId(); + long templateId = generateTemplateId(); + String status = generateStatus(); + + ZonedDateTime createdAt = now.minusDays(random.nextInt(365)); + ZonedDateTime usedAt = null; + Long orderId = null; + + if ("USED".equals(status) && paidOrderIdx < paidOrderIds.size()) { + orderId = paidOrderIds.get(paidOrderIdx % paidOrderIds.size()); + paidOrderIdx++; + usedAt = createdAt.plusDays(random.nextInt(30) + 1); + } + + // 쿠폰 스냅샷 필드 + boolean isPercentage = random.nextDouble() < 0.60; + String discountType = isPercentage ? "PERCENT" : "FIXED"; + int discountValue = isPercentage ? (random.nextInt(6) + 1) * 5 : (random.nextInt(10) + 1) * 1_000; + Integer maxDiscountAmount = isPercentage ? (random.nextInt(10) + 1) * 5_000 : null; + + batch.add(new Object[]{ + templateId, userId, status, orderId, + usedAt != null ? Timestamp.from(usedAt.toInstant()) : null, + "쿠폰" + templateId, discountType, discountValue, maxDiscountAmount, + Timestamp.from(createdAt.toInstant()), + Timestamp.from(now.toInstant()), + null // deleted_at + }); + + if (batch.size() >= BATCH_SIZE) { + flushBatch(batch); + inserted += batch.size(); + batch.clear(); + + if (inserted % 10_000 == 0) { + log.info("[IssuedCouponSeeder] {}/{} 완료", inserted, ISSUED_COUNT); + } + } + } + + if (!batch.isEmpty()) { + flushBatch(batch); + inserted += batch.size(); + } + + log.info("[IssuedCouponSeeder] {}건 생성 완료 ({}ms)", inserted, System.currentTimeMillis() - start); + } + + /** 상위 20% 유저가 50% 보유 */ + private long generateUserId() { + if (random.nextDouble() < 0.50) { + return random.nextInt(USER_COUNT / 5) + 1; + } + return random.nextInt(USER_COUNT * 4 / 5) + USER_COUNT / 5 + 1; + } + + /** ACTIVE 쿠폰(1~30)에 70% 집중 */ + private long generateTemplateId() { + if (random.nextDouble() < 0.70) { + return random.nextInt(30) + 1; + } + return random.nextInt(20) + 31; + } + + /** USED 50% / ISSUED 30% / EXPIRED 20% */ + private String generateStatus() { + double r = random.nextDouble(); + if (r < 0.50) return "USED"; + if (r < 0.80) return "ISSUED"; + return "EXPIRED"; + } + + private void flushBatch(List batch) { + jdbcTemplate.batchUpdate( + "INSERT INTO issued_coupons (coupon_template_id, user_id, status, order_id, " + + "used_at, coupon_name, discount_type, discount_value, max_discount_amount, " + + "created_at, updated_at, deleted_at) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + batch + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/seeder/OrderSeeder.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/seeder/OrderSeeder.java new file mode 100644 index 000000000..dc4e13712 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/seeder/OrderSeeder.java @@ -0,0 +1,222 @@ +package com.loopers.infrastructure.seeder; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +import java.sql.Timestamp; +import java.time.ZonedDateTime; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.List; +import java.util.Random; +import java.util.UUID; + +/** + * orders + order_items 시딩 (케브 멘토 추가 숙제용) + * + * 분포: + * - user_id: 불균등 (상위 10% 유저가 50% 주문) + * - status: PAID 70% / CANCELED 15% / PENDING 10% / EXPIRED 5% + * - ordered_at: 최근 6개월 분산 + * - items per order: 1개 60% / 2개 30% / 3개 10% + */ +@Component +class OrderSeeder { + + private static final Logger log = LoggerFactory.getLogger(OrderSeeder.class); + private static final int ORDER_COUNT = 100_000; + private static final int BATCH_SIZE = 2_000; + private static final int USER_COUNT = UserSeeder.USER_COUNT; + private static final int PRODUCT_COUNT = ProductSeeder.PRODUCT_COUNT; + + private final JdbcTemplate jdbcTemplate; + private final Random random = new Random(42); + + OrderSeeder(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + void seed() { + log.info("[OrderSeeder] 주문 {}건 + 주문상품 생성 시작", ORDER_COUNT); + long start = System.currentTimeMillis(); + + ZonedDateTime now = ZonedDateTime.now(ZoneId.of("Asia/Seoul")); + + List orderBatch = new ArrayList<>(BATCH_SIZE); + List itemBatch = new ArrayList<>(BATCH_SIZE * 2); + long totalItems = 0; + + for (int i = 0; i < ORDER_COUNT; i++) { + long userId = generateUserId(); + String orderNumber = "ORD-" + UUID.randomUUID().toString().substring(0, 12).toUpperCase(); + String status = generateOrderStatus(); + ZonedDateTime orderedAt = now.minusDays(random.nextInt(180)).minusHours(random.nextInt(24)); + + int itemCount = generateItemCount(); + int subtotal = 0; + + // 주문 상품 준비 + List currentItems = new ArrayList<>(); + for (int j = 0; j < itemCount; j++) { + long productId = random.nextInt(PRODUCT_COUNT) + 1; + int unitPrice = (random.nextInt(490) + 10) * 1000; // 10,000 ~ 500,000 + int quantity = random.nextInt(3) + 1; // 1~3 + subtotal += unitPrice * quantity; + + currentItems.add(new Object[]{ + productId, + "상품" + productId, + "브랜드" + (random.nextInt(100) + 1), + unitPrice, + quantity + }); + } + + int discountAmount = random.nextDouble() < 0.3 ? random.nextInt(5000) + 1000 : 0; + int pointUsed = random.nextDouble() < 0.2 ? random.nextInt(3000) : 0; + int shippingFee = subtotal >= 50_000 ? 0 : 3_000; + int totalAmount = Math.max(0, subtotal - discountAmount - pointUsed + shippingFee); + + ZonedDateTime canceledAt = "CANCELED".equals(status) ? orderedAt.plusDays(1) : null; + ZonedDateTime expiresAt = "PENDING".equals(status) ? orderedAt.plusMinutes(30) : null; + + orderBatch.add(new Object[]{ + userId, orderNumber, subtotal, discountAmount, pointUsed, shippingFee, totalAmount, + status, + "주문자" + userId, "010-1234-" + String.format("%04d", userId), + "수령인" + userId, "010-5678-" + String.format("%04d", userId), + "12345", "서울시 강남구 테스트로 " + userId, "테스트동 " + (i + 1) + "호", + null, null, random.nextDouble() < 0.5 ? "CARD" : "BANK_TRANSFER", + Timestamp.from(orderedAt.toInstant()), + expiresAt != null ? Timestamp.from(expiresAt.toInstant()) : null, + canceledAt != null ? Timestamp.from(canceledAt.toInstant()) : null, + Timestamp.from(orderedAt.toInstant()), + Timestamp.from(now.toInstant()) + }); + + // order_items는 order INSERT 후 order_id를 알아야 하므로 별도 저장 + for (Object[] item : currentItems) { + itemBatch.add(item); + } + totalItems += currentItems.size(); + + if (orderBatch.size() >= BATCH_SIZE) { + flushOrders(orderBatch); + orderBatch.clear(); + + if ((i + 1) % 10_000 == 0) { + log.info("[OrderSeeder] 주문 {}/{} 완료", i + 1, ORDER_COUNT); + } + } + } + + // 남은 주문 배치 + if (!orderBatch.isEmpty()) { + flushOrders(orderBatch); + } + + // order_items: order_id를 DB에서 매핑 + seedOrderItems(totalItems); + + log.info("[OrderSeeder] 주문 {}건 + 주문상품 {}건 생성 완료 ({}ms)", + ORDER_COUNT, totalItems, System.currentTimeMillis() - start); + } + + /** + * order_items 시딩: orders 삽입 후, 각 order의 item_count에 맞게 생성 + */ + private void seedOrderItems(long expectedTotal) { + log.info("[OrderSeeder] 주문상품 {}건 생성 시작", expectedTotal); + + // 주문 ID 목록 조회 + List orderIds = jdbcTemplate.queryForList("SELECT id FROM orders ORDER BY id", Long.class); + + List batch = new ArrayList<>(5_000); + long inserted = 0; + Random itemRandom = new Random(123); + + for (Long orderId : orderIds) { + int itemCount = generateItemCountWith(itemRandom); + + for (int j = 0; j < itemCount; j++) { + long productId = itemRandom.nextInt(PRODUCT_COUNT) + 1; + int unitPrice = (itemRandom.nextInt(490) + 10) * 1000; + int quantity = itemRandom.nextInt(3) + 1; + + batch.add(new Object[]{ + orderId, + productId, + "상품" + productId, + "브랜드" + (itemRandom.nextInt(100) + 1), + unitPrice, + quantity + }); + + if (batch.size() >= 5_000) { + flushOrderItems(batch); + inserted += batch.size(); + batch.clear(); + } + } + } + + if (!batch.isEmpty()) { + flushOrderItems(batch); + inserted += batch.size(); + } + + log.info("[OrderSeeder] 주문상품 {}건 생성 완료", inserted); + } + + private void flushOrders(List batch) { + jdbcTemplate.batchUpdate( + "INSERT INTO orders (user_id, order_number, subtotal_amount, discount_amount, point_used_amount, " + + "shipping_fee, total_amount, status, orderer_name, orderer_phone, receiver_name, receiver_phone, " + + "zip_code, address_line1, address_line2, coupon_id, payment_id, payment_method, " + + "ordered_at, expires_at, canceled_at, created_at, updated_at) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + batch + ); + } + + private void flushOrderItems(List batch) { + jdbcTemplate.batchUpdate( + "INSERT INTO order_items (order_id, product_id, product_name, brand_name, unit_price, quantity) " + + "VALUES (?, ?, ?, ?, ?, ?)", + batch + ); + } + + /** 상위 10%(1~500)가 50% 주문 */ + private long generateUserId() { + if (random.nextDouble() < 0.50) { + return random.nextInt(500) + 1; // 헤비 유저 (1~500) + } + return random.nextInt(4500) + 501; // 일반 유저 (501~5000) + } + + private String generateOrderStatus() { + double r = random.nextDouble(); + if (r < 0.70) return "PAID"; + if (r < 0.85) return "CANCELED"; + if (r < 0.95) return "PENDING"; + return "EXPIRED"; + } + + /** 1개: 60%, 2개: 30%, 3개: 10% */ + private int generateItemCount() { + double r = random.nextDouble(); + if (r < 0.60) return 1; + if (r < 0.90) return 2; + return 3; + } + + private int generateItemCountWith(Random r) { + double v = r.nextDouble(); + if (v < 0.60) return 1; + if (v < 0.90) return 2; + return 3; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/seeder/PaymentSeeder.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/seeder/PaymentSeeder.java new file mode 100644 index 000000000..51e26b059 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/seeder/PaymentSeeder.java @@ -0,0 +1,149 @@ +package com.loopers.infrastructure.seeder; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +import java.sql.Timestamp; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.time.ZonedDateTime; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Random; +import java.util.UUID; + +/** + * payments 시딩 — orders와 1:1 매핑 (PAID/CANCELED 주문 대상) + * + * 분포: + * - status: orders.status와 정합 (PAID→APPROVED, CANCELED→CANCELED, PENDING→PENDING) + * - payment_method: CREDIT_CARD 50% / BANK_TRANSFER 20% / KAKAO_PAY 20% / NAVER_PAY 10% + */ +@Component +class PaymentSeeder { + + private static final Logger log = LoggerFactory.getLogger(PaymentSeeder.class); + private static final int BATCH_SIZE = 5_000; + + private final JdbcTemplate jdbcTemplate; + private final Random random = new Random(88); + + PaymentSeeder(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + void seed() { + log.info("[PaymentSeeder] 결제 데이터 생성 시작"); + long start = System.currentTimeMillis(); + + // 주문 정보 조회 + List> orders = jdbcTemplate.queryForList( + "SELECT id, total_amount, status, ordered_at, canceled_at FROM orders ORDER BY id" + ); + + ZonedDateTime now = ZonedDateTime.now(ZoneId.of("Asia/Seoul")); + List batch = new ArrayList<>(BATCH_SIZE); + long inserted = 0; + + for (Map order : orders) { + Long orderId = ((Number) order.get("id")).longValue(); + int totalAmount = ((Number) order.get("total_amount")).intValue(); + String orderStatus = (String) order.get("status"); + Timestamp orderedAt = toTimestamp(order.get("ordered_at")); + Timestamp canceledAt = toTimestamp(order.get("canceled_at")); + + // PENDING/EXPIRED 주문도 결제 시도 기록은 있을 수 있음 + String paymentStatus = mapPaymentStatus(orderStatus); + String paymentMethod = generatePaymentMethod(); + String idempotencyKey = UUID.randomUUID().toString(); + + Integer approvedAmount = null; + String pgTxnId = null; + Timestamp approvedAtTs = null; + Timestamp failedAtTs = null; + Timestamp canceledAtTs = null; + + if ("APPROVED".equals(paymentStatus)) { + approvedAmount = totalAmount; + pgTxnId = "PG-" + UUID.randomUUID().toString().substring(0, 16).toUpperCase(); + approvedAtTs = new Timestamp(orderedAt.getTime() + 1000); // 주문 1초 후 승인 + } else if ("CANCELED".equals(paymentStatus)) { + approvedAmount = totalAmount; + pgTxnId = "PG-" + UUID.randomUUID().toString().substring(0, 16).toUpperCase(); + approvedAtTs = new Timestamp(orderedAt.getTime() + 1000); + canceledAtTs = canceledAt; + } else if ("FAILED".equals(paymentStatus)) { + failedAtTs = new Timestamp(orderedAt.getTime() + 500); + } + // PENDING: 모두 null + + batch.add(new Object[]{ + orderId, paymentStatus, paymentMethod, totalAmount, + approvedAmount, pgTxnId, idempotencyKey, + orderedAt, approvedAtTs, failedAtTs, canceledAtTs, + orderedAt, // created_at + Timestamp.from(now.toInstant()), // updated_at + null // deleted_at + }); + + if (batch.size() >= BATCH_SIZE) { + flushBatch(batch); + inserted += batch.size(); + batch.clear(); + + if (inserted % 20_000 == 0) { + log.info("[PaymentSeeder] {}/{} 완료", inserted, orders.size()); + } + } + } + + if (!batch.isEmpty()) { + flushBatch(batch); + inserted += batch.size(); + } + + log.info("[PaymentSeeder] {}건 생성 완료 ({}ms)", inserted, System.currentTimeMillis() - start); + } + + private String mapPaymentStatus(String orderStatus) { + return switch (orderStatus) { + case "PAID" -> "APPROVED"; + case "CANCELED" -> "CANCELED"; + case "PENDING" -> "REQUESTED"; + case "EXPIRED" -> "FAILED"; + default -> "REQUESTED"; + }; + } + + /** CREDIT_CARD 50% / BANK_TRANSFER 20% / KAKAO_PAY 20% / NAVER_PAY 10% */ + private String generatePaymentMethod() { + double r = random.nextDouble(); + if (r < 0.50) return "CREDIT_CARD"; + if (r < 0.70) return "BANK_TRANSFER"; + if (r < 0.90) return "KAKAO_PAY"; + return "NAVER_PAY"; + } + + /** DB에서 반환된 datetime 값을 Timestamp로 안전하게 변환 */ + private Timestamp toTimestamp(Object value) { + if (value == null) return null; + if (value instanceof Timestamp ts) return ts; + if (value instanceof LocalDateTime ldt) return Timestamp.valueOf(ldt); + throw new IllegalArgumentException("Unsupported datetime type: " + value.getClass()); + } + + private void flushBatch(List batch) { + jdbcTemplate.batchUpdate( + "INSERT INTO payments (order_id, status, payment_method, requested_amount, " + + "approved_amount, pg_txn_id, idempotency_key, " + + "requested_at, approved_at, failed_at, canceled_at, " + + "created_at, updated_at, deleted_at) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + batch + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/seeder/PointAccountSeeder.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/seeder/PointAccountSeeder.java new file mode 100644 index 000000000..242b434c6 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/seeder/PointAccountSeeder.java @@ -0,0 +1,74 @@ +package com.loopers.infrastructure.seeder; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.jdbc.core.BatchPreparedStatementSetter; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +import java.sql.PreparedStatement; +import java.sql.SQLException; +import java.sql.Timestamp; +import java.time.ZonedDateTime; +import java.time.ZoneId; +import java.util.Random; + +/** + * point_accounts 시딩 — users와 1:1 (5,000건) + * + * 분포: + * - balance: 로그분포 (60%가 0~5,000원, 나머지 5,000~50,000원) + */ +@Component +class PointAccountSeeder { + + private static final Logger log = LoggerFactory.getLogger(PointAccountSeeder.class); + private static final int USER_COUNT = UserSeeder.USER_COUNT; + + private final JdbcTemplate jdbcTemplate; + private final Random random = new Random(99); + + PointAccountSeeder(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + void seed() { + log.info("[PointAccountSeeder] {}건 생성 시작", USER_COUNT); + long start = System.currentTimeMillis(); + + ZonedDateTime now = ZonedDateTime.now(ZoneId.of("Asia/Seoul")); + + jdbcTemplate.batchUpdate( + "INSERT INTO point_accounts (user_id, balance, created_at, updated_at, deleted_at) " + + "VALUES (?, ?, ?, ?, ?)", + new BatchPreparedStatementSetter() { + @Override + public void setValues(PreparedStatement ps, int i) throws SQLException { + long userId = i + 1; + int balance = generateBalance(); + + ps.setLong(1, userId); + ps.setInt(2, balance); + ps.setTimestamp(3, Timestamp.from(now.toInstant())); + ps.setTimestamp(4, Timestamp.from(now.toInstant())); + ps.setNull(5, java.sql.Types.TIMESTAMP); + } + + @Override + public int getBatchSize() { + return USER_COUNT; + } + } + ); + + log.info("[PointAccountSeeder] {}건 생성 완료 ({}ms)", USER_COUNT, System.currentTimeMillis() - start); + } + + /** 60%: 0~5,000원 / 25%: 5,001~20,000원 / 15%: 20,001~50,000원 */ + private int generateBalance() { + double r = random.nextDouble(); + if (r < 0.60) return random.nextInt(5_001); + if (r < 0.85) return random.nextInt(15_000) + 5_001; + return random.nextInt(30_000) + 20_001; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/seeder/ProductLikeSeeder.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/seeder/ProductLikeSeeder.java new file mode 100644 index 000000000..7de1e8a71 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/seeder/ProductLikeSeeder.java @@ -0,0 +1,107 @@ +package com.loopers.infrastructure.seeder; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +import java.sql.Timestamp; +import java.time.ZonedDateTime; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Random; +import java.util.Set; + +/** + * product_likes 시딩 + * + * products.like_count와 정합성을 맞춤: + * 각 상품의 like_count만큼 실제 product_likes 레코드를 생성한다. + * UniqueConstraint(user_id, product_id)를 위반하지 않도록 유저를 중복 없이 선택한다. + */ +@Component +class ProductLikeSeeder { + + private static final Logger log = LoggerFactory.getLogger(ProductLikeSeeder.class); + private static final int BATCH_SIZE = 5_000; + private static final int USER_COUNT = UserSeeder.USER_COUNT; + + private final JdbcTemplate jdbcTemplate; + private final ProductSeeder productSeeder; + private final Random random = new Random(42); + + ProductLikeSeeder(JdbcTemplate jdbcTemplate, ProductSeeder productSeeder) { + this.jdbcTemplate = jdbcTemplate; + this.productSeeder = productSeeder; + } + + void seed() { + int[] likeCountsPerProduct = productSeeder.getLikeCountsPerProduct(); + if (likeCountsPerProduct == null) { + log.warn("[ProductLikeSeeder] ProductSeeder 미실행. 건너뜁니다."); + return; + } + + // 전체 좋아요 건수 계산 + long totalLikes = 0; + for (int lc : likeCountsPerProduct) { + totalLikes += Math.min(lc, USER_COUNT); // 유저 수 초과 불가 + } + log.info("[ProductLikeSeeder] 약 {}건 생성 시작", totalLikes); + long start = System.currentTimeMillis(); + + ZonedDateTime now = ZonedDateTime.now(ZoneId.of("Asia/Seoul")); + List batch = new ArrayList<>(BATCH_SIZE); + long inserted = 0; + + for (int productIdx = 0; productIdx < likeCountsPerProduct.length; productIdx++) { + int likeCount = Math.min(likeCountsPerProduct[productIdx], USER_COUNT); + if (likeCount == 0) continue; + + long productId = productIdx + 1; + Set usedUsers = new HashSet<>(); + + for (int j = 0; j < likeCount; j++) { + // 중복 없이 유저 선택 + int userId; + do { + userId = random.nextInt(USER_COUNT) + 1; + } while (usedUsers.contains(userId)); + usedUsers.add(userId); + + batch.add(new Object[]{ + userId, + productId, + Timestamp.from(now.minusDays(random.nextInt(180)).toInstant()) + }); + + if (batch.size() >= BATCH_SIZE) { + flushBatch(batch); + inserted += batch.size(); + batch.clear(); + + if (inserted % 50_000 == 0) { + log.info("[ProductLikeSeeder] {}/{} 완료", inserted, totalLikes); + } + } + } + } + + // 남은 배치 처리 + if (!batch.isEmpty()) { + flushBatch(batch); + inserted += batch.size(); + } + + log.info("[ProductLikeSeeder] {}건 생성 완료 ({}ms)", inserted, System.currentTimeMillis() - start); + } + + private void flushBatch(List batch) { + jdbcTemplate.batchUpdate( + "INSERT INTO product_likes (user_id, product_id, created_at) VALUES (?, ?, ?)", + batch + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/seeder/ProductSeeder.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/seeder/ProductSeeder.java new file mode 100644 index 000000000..9a2a32f75 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/seeder/ProductSeeder.java @@ -0,0 +1,176 @@ +package com.loopers.infrastructure.seeder; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.jdbc.core.BatchPreparedStatementSetter; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +import java.sql.PreparedStatement; +import java.sql.SQLException; +import java.sql.Timestamp; +import java.time.ZonedDateTime; +import java.time.ZoneId; +import java.util.Random; + +/** + * 상품 10만건 시딩 + * + * 분포 설계: + * - brand_id: 파레토 — 상위 80개 브랜드(1~80)에 60%, 나머지 320개 ACTIVE(81~400)에 35%, INACTIVE(401~500)에 5% + * - status: ACTIVE 70% / SOLDOUT 15% / HIDDEN 10% / DISCONTINUED 5% + * - base_price: 로그 정규분포 (1,300 ~ 5,000,000원) + * - like_count: 멱법칙 (80%: 0~10, 15%: 11~100, 4%: 101~500, 1%: 500~5000) + * - deleted_at: 95% NULL / 5% 삭제 + * - created_at: 최근 1년, 최근 3개월에 40% 집중 + */ +@Component +class ProductSeeder { + + private static final Logger log = LoggerFactory.getLogger(ProductSeeder.class); + static final int PRODUCT_COUNT = 200_000; + private static final int BATCH_SIZE = 5_000; + + private final JdbcTemplate jdbcTemplate; + private final Random random = new Random(42); // 재현 가능한 시드 + + // 시딩 후 각 상품의 likeCount를 저장 (ProductLikeSeeder에서 사용) + private int[] likeCountsPerProduct; + + ProductSeeder(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + int[] getLikeCountsPerProduct() { + return likeCountsPerProduct; + } + + void seed() { + log.info("[ProductSeeder] {}건 생성 시작", PRODUCT_COUNT); + long start = System.currentTimeMillis(); + + likeCountsPerProduct = new int[PRODUCT_COUNT]; + + ZonedDateTime now = ZonedDateTime.now(ZoneId.of("Asia/Seoul")); + + // 배치 단위로 나눠서 삽입 (메모리 절약) + for (int batchStart = 0; batchStart < PRODUCT_COUNT; batchStart += BATCH_SIZE) { + int currentBatchStart = batchStart; + int currentBatchSize = Math.min(BATCH_SIZE, PRODUCT_COUNT - batchStart); + + jdbcTemplate.batchUpdate( + "INSERT INTO products (brand_id, name, description, base_price, status, like_count, created_at, updated_at, deleted_at) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + new BatchPreparedStatementSetter() { + @Override + public void setValues(PreparedStatement ps, int i) throws SQLException { + int globalIdx = currentBatchStart + i; + int productNum = globalIdx + 1; + + // brand_id: 파레토 분포 + long brandId = generateBrandId(); + ps.setLong(1, brandId); + + ps.setString(2, "상품" + productNum); + ps.setString(3, "상품" + productNum + " 설명입니다."); + + // base_price: 로그 정규분포 + int price = generatePrice(); + ps.setInt(4, price); + + // status: ACTIVE 70% / SOLDOUT 15% / HIDDEN 10% / DISCONTINUED 5% + String status = generateStatus(); + ps.setString(5, status); + + // like_count: 멱법칙 + int likeCount = generateLikeCount(); + likeCountsPerProduct[globalIdx] = likeCount; + ps.setInt(6, likeCount); + + // created_at: 최근 1년, 최근 3개월 40% 집중 + ZonedDateTime createdAt = generateCreatedAt(now); + ps.setTimestamp(7, Timestamp.from(createdAt.toInstant())); + ps.setTimestamp(8, Timestamp.from(now.toInstant())); + + // deleted_at: 5% 삭제 + if (random.nextDouble() < 0.05) { + ps.setTimestamp(9, Timestamp.from(now.minusDays(random.nextInt(30)).toInstant())); + } else { + ps.setNull(9, java.sql.Types.TIMESTAMP); + } + } + + @Override + public int getBatchSize() { + return currentBatchSize; + } + } + ); + + if ((currentBatchStart + currentBatchSize) % 20_000 == 0) { + log.info("[ProductSeeder] {}/{} 완료", currentBatchStart + currentBatchSize, PRODUCT_COUNT); + } + } + + log.info("[ProductSeeder] {}건 생성 완료 ({}ms)", PRODUCT_COUNT, System.currentTimeMillis() - start); + } + + /** + * 파레토 분포: 상위 80개(1~80) 60%, 중위 320개(81~400) 35%, INACTIVE(401~500) 5% + */ + private long generateBrandId() { + double r = random.nextDouble(); + if (r < 0.60) { + return random.nextInt(80) + 1; // 1~80 (대형 브랜드) + } else if (r < 0.95) { + return random.nextInt(320) + 81; // 81~400 (중소 브랜드) + } else { + return random.nextInt(100) + 401; // 401~500 (INACTIVE 브랜드) + } + } + + /** + * 로그 정규분포: 1,300 ~ 5,000,000원, 중심은 1만~10만원 + */ + private int generatePrice() { + // log-normal: mean=10 (≈22,000원), sigma=1.0 + double logPrice = 10.0 + random.nextGaussian() * 1.0; + int price = (int) Math.exp(logPrice); + price = Math.max(1_300, Math.min(5_000_000, price)); + // 100원 단위로 반올림 + return (price / 100) * 100; + } + + private String generateStatus() { + double r = random.nextDouble(); + if (r < 0.70) return "ACTIVE"; + if (r < 0.85) return "SOLDOUT"; + if (r < 0.95) return "HIDDEN"; + return "DISCONTINUED"; + } + + /** + * 멱법칙: 80%: 0~10, 15%: 11~100, 4%: 101~500, 1%: 500~5000 + */ + private int generateLikeCount() { + double r = random.nextDouble(); + if (r < 0.80) return random.nextInt(11); // 0~10 + if (r < 0.95) return random.nextInt(90) + 11; // 11~100 + if (r < 0.99) return random.nextInt(400) + 101; // 101~500 + return random.nextInt(4500) + 501; // 501~5000 + } + + /** + * 최근 1년, 최근 3개월에 40% 집중 + */ + private ZonedDateTime generateCreatedAt(ZonedDateTime now) { + double r = random.nextDouble(); + if (r < 0.40) { + // 최근 3개월 (0~90일 전) + return now.minusDays(random.nextInt(90)).minusHours(random.nextInt(24)); + } else { + // 3개월~1년 전 (91~365일 전) + return now.minusDays(random.nextInt(275) + 91).minusHours(random.nextInt(24)); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/seeder/UserAddressSeeder.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/seeder/UserAddressSeeder.java new file mode 100644 index 000000000..1231e8244 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/seeder/UserAddressSeeder.java @@ -0,0 +1,130 @@ +package com.loopers.infrastructure.seeder; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +import java.sql.Timestamp; +import java.time.ZonedDateTime; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.List; +import java.util.Random; + +/** + * user_addresses 시딩 (~8,000건) + * + * 분포: + * - 유저의 80%(4,000명) 보유 + * - user당: 1개 60% / 2개 30% / 3개 10% + * - is_default: 유저당 정확히 1개만 true + * - zip_code: 서울 40% / 경기 30% / 기타 30% + */ +@Component +class UserAddressSeeder { + + private static final Logger log = LoggerFactory.getLogger(UserAddressSeeder.class); + private static final int BATCH_SIZE = 5_000; + private static final int USER_COUNT = UserSeeder.USER_COUNT; + + private final JdbcTemplate jdbcTemplate; + private final Random random = new Random(44); + + private static final String[][] REGIONS = { + // {zip_prefix, address_line1} + {"06", "서울시 강남구 테헤란로"}, + {"07", "서울시 서초구 반포대로"}, + {"04", "서울시 용산구 이태원로"}, + {"05", "서울시 마포구 홍익로"}, + {"13", "경기도 성남시 분당구 판교로"}, + {"14", "경기도 수원시 영통구 광교로"}, + {"10", "경기도 고양시 일산동구 중앙로"}, + {"16", "경기도 용인시 수지구 성복로"}, + {"21", "인천시 연수구 송도문화로"}, + {"34", "대전시 유성구 대학로"}, + {"41", "대구시 수성구 범어로"}, + {"48", "부산시 해운대구 센텀중앙로"}, + }; + + UserAddressSeeder(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + void seed() { + log.info("[UserAddressSeeder] 배송지 데이터 생성 시작"); + long start = System.currentTimeMillis(); + + ZonedDateTime now = ZonedDateTime.now(ZoneId.of("Asia/Seoul")); + List batch = new ArrayList<>(BATCH_SIZE); + long inserted = 0; + + for (int userId = 1; userId <= USER_COUNT; userId++) { + if (random.nextDouble() >= 0.80) continue; // 20%는 주소 미등록 + + int addressCount = generateAddressCount(); + + for (int j = 0; j < addressCount; j++) { + boolean isDefault = (j == 0); // 첫 번째만 기본 배송지 + int regionIdx = generateRegionIndex(); + String[] region = REGIONS[regionIdx]; + String zipCode = region[0] + String.format("%03d", random.nextInt(1000)); + + batch.add(new Object[]{ + userId, + "수령인" + userId + "-" + (j + 1), + "010-" + String.format("%04d", random.nextInt(10000)) + "-" + String.format("%04d", random.nextInt(10000)), + zipCode, + region[1] + " " + (random.nextInt(200) + 1), + (random.nextInt(30) + 1) + "층 " + (random.nextInt(10) + 1) + "호", + isDefault, + Timestamp.from(now.minusDays(random.nextInt(365)).toInstant()), + Timestamp.from(now.toInstant()), + null // deleted_at + }); + + if (batch.size() >= BATCH_SIZE) { + flushBatch(batch); + inserted += batch.size(); + batch.clear(); + } + } + } + + if (!batch.isEmpty()) { + flushBatch(batch); + inserted += batch.size(); + } + + log.info("[UserAddressSeeder] {}건 생성 완료 ({}ms)", inserted, System.currentTimeMillis() - start); + } + + /** 1개 60% / 2개 30% / 3개 10% */ + private int generateAddressCount() { + double r = random.nextDouble(); + if (r < 0.60) return 1; + if (r < 0.90) return 2; + return 3; + } + + /** 서울 40% / 경기 30% / 기타 30% */ + private int generateRegionIndex() { + double r = random.nextDouble(); + if (r < 0.40) { + return random.nextInt(4); // 서울 (0~3) + } else if (r < 0.70) { + return random.nextInt(4) + 4; // 경기 (4~7) + } else { + return random.nextInt(4) + 8; // 기타 (8~11) + } + } + + private void flushBatch(List batch) { + jdbcTemplate.batchUpdate( + "INSERT INTO user_addresses (user_id, receiver_name, phone, zip_code, " + + "address_line1, address_line2, is_default, created_at, updated_at, deleted_at) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + batch + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/seeder/UserSeeder.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/seeder/UserSeeder.java new file mode 100644 index 000000000..23dd4899b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/seeder/UserSeeder.java @@ -0,0 +1,58 @@ +package com.loopers.infrastructure.seeder; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.jdbc.core.BatchPreparedStatementSetter; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +import java.sql.PreparedStatement; +import java.sql.SQLException; +import java.sql.Timestamp; +import java.time.ZonedDateTime; +import java.time.ZoneId; + +@Component +class UserSeeder { + + private static final Logger log = LoggerFactory.getLogger(UserSeeder.class); + static final int USER_COUNT = 5_000; + + private final JdbcTemplate jdbcTemplate; + + UserSeeder(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + void seed() { + log.info("[UserSeeder] {}명 생성 시작", USER_COUNT); + long start = System.currentTimeMillis(); + + ZonedDateTime now = ZonedDateTime.now(ZoneId.of("Asia/Seoul")); + + jdbcTemplate.batchUpdate( + "INSERT INTO users (login_id, password, name, birth_date, email, created_at, updated_at) " + + "VALUES (?, ?, ?, ?, ?, ?, ?)", + new BatchPreparedStatementSetter() { + @Override + public void setValues(PreparedStatement ps, int i) throws SQLException { + int idx = i + 1; + ps.setString(1, "user" + idx); + ps.setString(2, "$2a$10$dummyHashedPasswordForSeeding"); + ps.setString(3, "테스트유저" + idx); + ps.setDate(4, java.sql.Date.valueOf("1990-01-01")); + ps.setString(5, "user" + idx + "@test.com"); + ps.setTimestamp(6, Timestamp.from(now.toInstant())); + ps.setTimestamp(7, Timestamp.from(now.toInstant())); + } + + @Override + public int getBatchSize() { + return USER_COUNT; + } + } + ); + + log.info("[UserSeeder] {}명 생성 완료 ({}ms)", USER_COUNT, System.currentTimeMillis() - start); + } +} From 531804523a9d9c8a6c3aac6c14736252464d7235 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B5=E1=86=B7=E1=84=82=E1=85=A1=E1=84=92?= =?UTF-8?q?=E1=85=A7=E1=86=AB?= Date: Thu, 12 Mar 2026 20:30:59 +0900 Subject: [PATCH 06/17] =?UTF-8?q?feat:=20k6=20=EC=84=B1=EB=8A=A5=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=8A=A4=ED=81=AC=EB=A6=BD?= =?UTF-8?q?=ED=8A=B8=20=EC=B6=94=EA=B0=80=20(=EB=AA=A9=EB=A1=9D/=EC=83=81?= =?UTF-8?q?=EC=84=B8/=EB=B6=80=ED=95=98)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- k6/product-detail.js | 51 ++++++++++++++++++++++++++++++++++ k6/product-list.js | 66 ++++++++++++++++++++++++++++++++++++++++++++ k6/product-load.js | 62 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 179 insertions(+) create mode 100644 k6/product-detail.js create mode 100644 k6/product-list.js create mode 100644 k6/product-load.js diff --git a/k6/product-detail.js b/k6/product-detail.js new file mode 100644 index 000000000..3052fd2f6 --- /dev/null +++ b/k6/product-detail.js @@ -0,0 +1,51 @@ +import http from 'k6/http'; +import { check, sleep } from 'k6'; + +/** + * 상품 상세 조회 성능 테스트 (캐시 효과 측정용) + * + * 시나리오: + * - 인기 상품(1~1000)에 80% 집중, 나머지 20%는 전체 범위 + * - 같은 상품 반복 조회 → 캐시 히트율 측정 + * + * 실행 방법: + * k6 run k6/product-detail.js + * k6 run --duration 30s --vus 10 k6/product-detail.js + */ + +const BASE_URL = 'http://localhost:8080'; +const PRODUCT_COUNT = 200000; + +export const options = { + scenarios: { + baseline: { + executor: 'constant-vus', + vus: 1, + duration: '30s', + }, + }, + thresholds: { + http_req_duration: ['p(95)<200', 'p(99)<500'], + http_req_failed: ['rate<0.01'], + }, +}; + +export default function () { + const productId = generateProductId(); + const url = `${BASE_URL}/api/v1/products/${productId}`; + const res = http.get(url, { tags: { name: 'product_detail' } }); + + check(res, { + '200 OK': (r) => r.status === 200, + }); + + sleep(0.3); +} + +/** 인기 상품에 80% 집중 (캐시 히트율 테스트) */ +function generateProductId() { + if (Math.random() < 0.8) { + return Math.floor(Math.random() * 1000) + 1; // 1~1000 + } + return Math.floor(Math.random() * PRODUCT_COUNT) + 1; // 1~200000 +} diff --git a/k6/product-list.js b/k6/product-list.js new file mode 100644 index 000000000..bdf4d6c5d --- /dev/null +++ b/k6/product-list.js @@ -0,0 +1,66 @@ +import http from 'k6/http'; +import { check, sleep } from 'k6'; + +/** + * 상품 목록 조회 성능 테스트 + * + * 테스트 시나리오: + * 1. 전체 조회 (brandId 없음) - 4종 정렬 + * 2. 브랜드 필터 조회 (brandId 있음) - 4종 정렬 + * + * 실행 방법: + * k6 run k6/product-list.js + * k6 run --duration 30s --vus 10 k6/product-list.js + */ + +const BASE_URL = 'http://localhost:8080'; +const SORT_TYPES = ['LATEST', 'PRICE_ASC', 'PRICE_DESC', 'LIKES_DESC']; +const BRAND_IDS = [1, 5, 20, 50, 100]; // 다양한 브랜드 + +export const options = { + // Stage 1: 단일 유저 기본 성능 측정 + scenarios: { + baseline: { + executor: 'constant-vus', + vus: 1, + duration: '30s', + }, + }, + thresholds: { + http_req_duration: ['p(95)<500', 'p(99)<1000'], // P95 < 500ms, P99 < 1s + http_req_failed: ['rate<0.01'], // 에러율 1% 미만 + }, +}; + +export default function () { + const sortType = SORT_TYPES[Math.floor(Math.random() * SORT_TYPES.length)]; + + // 70%: 전체 조회, 30%: 브랜드 필터 + if (Math.random() < 0.7) { + productListAll(sortType); + } else { + const brandId = BRAND_IDS[Math.floor(Math.random() * BRAND_IDS.length)]; + productListByBrand(sortType, brandId); + } + + sleep(0.5); // 요청 간 간격 +} + +function productListAll(sortType) { + const url = `${BASE_URL}/api/v1/products?sort=${sortType}&size=20`; + const res = http.get(url, { tags: { name: 'product_list_all' } }); + + check(res, { + '200 OK': (r) => r.status === 200, + 'has products': (r) => JSON.parse(r.body).data.products.length > 0, + }); +} + +function productListByBrand(sortType, brandId) { + const url = `${BASE_URL}/api/v1/products?sort=${sortType}&size=20&brandId=${brandId}`; + const res = http.get(url, { tags: { name: 'product_list_brand' } }); + + check(res, { + '200 OK': (r) => r.status === 200, + }); +} diff --git a/k6/product-load.js b/k6/product-load.js new file mode 100644 index 000000000..77959dd31 --- /dev/null +++ b/k6/product-load.js @@ -0,0 +1,62 @@ +import http from 'k6/http'; +import { check, sleep } from 'k6'; + +/** + * 동시 부하 테스트 (목록 + 상세 혼합) + * + * 단계별 부하 증가: + * - Ramp-up: 0→20 VU (30초) + * - Sustain: 20 VU 유지 (1분) + * - Ramp-down: 20→0 VU (10초) + * + * 실행 방법: + * k6 run k6/product-load.js + */ + +const BASE_URL = 'http://localhost:8080'; +const SORT_TYPES = ['LATEST', 'PRICE_ASC', 'PRICE_DESC', 'LIKES_DESC']; +const BRAND_IDS = [1, 5, 20, 50, 100]; +const PRODUCT_COUNT = 200000; + +export const options = { + stages: [ + { duration: '30s', target: 20 }, // 0→20 VU + { duration: '1m', target: 20 }, // 20 VU 유지 + { duration: '10s', target: 0 }, // 정리 + ], + thresholds: { + http_req_duration: ['p(95)<1000', 'p(99)<2000'], + http_req_failed: ['rate<0.05'], + }, +}; + +export default function () { + const r = Math.random(); + + if (r < 0.5) { + // 50%: 상품 목록 전체 조회 + const sort = SORT_TYPES[Math.floor(Math.random() * SORT_TYPES.length)]; + const url = `${BASE_URL}/api/v1/products?sort=${sort}&size=20`; + const res = http.get(url, { tags: { name: 'list_all' } }); + check(res, { '200 OK': (r) => r.status === 200 }); + + } else if (r < 0.7) { + // 20%: 상품 목록 브랜드 필터 + const sort = SORT_TYPES[Math.floor(Math.random() * SORT_TYPES.length)]; + const brandId = BRAND_IDS[Math.floor(Math.random() * BRAND_IDS.length)]; + const url = `${BASE_URL}/api/v1/products?sort=${sort}&size=20&brandId=${brandId}`; + const res = http.get(url, { tags: { name: 'list_brand' } }); + check(res, { '200 OK': (r) => r.status === 200 }); + + } else { + // 30%: 상품 상세 + const productId = Math.random() < 0.8 + ? Math.floor(Math.random() * 1000) + 1 + : Math.floor(Math.random() * PRODUCT_COUNT) + 1; + const url = `${BASE_URL}/api/v1/products/${productId}`; + const res = http.get(url, { tags: { name: 'detail' } }); + check(res, { '200 OK': (r) => r.status === 200 }); + } + + sleep(0.3); +} From 2c6124d2157b3543927f241c0de3b70b4be719a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B5=E1=86=B7=E1=84=82=E1=85=A1=E1=84=92?= =?UTF-8?q?=E1=85=A7=E1=86=AB?= Date: Thu, 12 Mar 2026 20:45:15 +0900 Subject: [PATCH 07/17] =?UTF-8?q?feat:=20k6=20=EC=84=B1=EB=8A=A5=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=8A=A4=ED=81=AC=EB=A6=BD?= =?UTF-8?q?=ED=8A=B8=20=EC=B6=94=EA=B0=80=20(=EC=BA=90=EC=8B=9C=20?= =?UTF-8?q?=EC=9A=B0=ED=9A=8C=20=ED=85=8C=EC=8A=A4=ED=8A=B8/DB=20=EB=B3=B4?= =?UTF-8?q?=ED=98=B8=20=ED=9A=A8=EA=B3=BC=20=ED=85=8C=EC=8A=A4=ED=8A=B8-?= =?UTF-8?q?=EC=BA=90=EC=8B=9C=20=EC=9B=8C=EB=B0=8D=EC=97=85=20=ED=9B=84=20?= =?UTF-8?q?20=20VU=20=EB=B6=80=ED=95=98)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- k6/cache-protection.js | 40 +++++++++++++++++ k6/concurrent-read-write.js | 88 +++++++++++++++++++++++++++++++++++++ k6/no-cache-baseline.js | 61 +++++++++++++++++++++++++ 3 files changed, 189 insertions(+) create mode 100644 k6/cache-protection.js create mode 100644 k6/concurrent-read-write.js create mode 100644 k6/no-cache-baseline.js diff --git a/k6/cache-protection.js b/k6/cache-protection.js new file mode 100644 index 000000000..20435eddb --- /dev/null +++ b/k6/cache-protection.js @@ -0,0 +1,40 @@ +import http from 'k6/http'; +import { check, sleep } from 'k6'; + +/** + * DB 보호 효과 테스트 — 캐시 워밍업 후 20 VU 부하 + * + * 캐시가 워밍업된 상태에서 동일 키를 반복 조회. + * 대부분 캐시 히트 → DB 호출 최소화 → 응답 시간 안정성 확인. + * + * 실행 방법: + * k6 run k6/cache-protection.js + */ + +const BASE_URL = 'http://localhost:8080'; +const SORT_TYPES = ['LATEST', 'PRICE_ASC', 'PRICE_DESC', 'LIKES_DESC']; + +export const options = { + stages: [ + { duration: '10s', target: 20 }, // 0→20 VU + { duration: '30s', target: 20 }, // 20 VU 유지 + { duration: '5s', target: 0 }, // 정리 + ], + thresholds: { + http_req_duration: ['p(95)<100', 'p(99)<200'], + http_req_failed: ['rate<0.01'], + }, +}; + +export default function () { + // 캐시 히트가 대부분인 시나리오: 첫 페이지만 반복 조회 + const sort = SORT_TYPES[Math.floor(Math.random() * SORT_TYPES.length)]; + const url = `${BASE_URL}/api/v1/products?sort=${sort}&size=20`; + const res = http.get(url, { tags: { name: 'list_cached' } }); + + check(res, { + '200 OK': (r) => r.status === 200, + }); + + sleep(0.1); +} diff --git a/k6/concurrent-read-write.js b/k6/concurrent-read-write.js new file mode 100644 index 000000000..e2344992a --- /dev/null +++ b/k6/concurrent-read-write.js @@ -0,0 +1,88 @@ +import http from 'k6/http'; +import { check, sleep } from 'k6'; +import { Counter } from 'k6/metrics'; + +/** + * 동시 읽기+쓰기 테스트 + * + * 시나리오: + * - 19 VU: 상품 목록/상세 반복 조회 (읽기) + * - 1 VU: 10초마다 상품 가격 수정 (쓰기 → 캐시 무효화) + * + * 검증 포인트: + * - 쓰기 발생 후에도 읽기 응답이 안정적인가 + * - 캐시 무효화 후 첫 읽기(miss)의 레이턴시 스파이크는 얼마인가 + */ + +const BASE_URL = 'http://localhost:8080'; +const SORT_TYPES = ['LATEST', 'PRICE_ASC', 'PRICE_DESC', 'LIKES_DESC']; +const ADMIN_HEADER = { 'Content-Type': 'application/json', 'X-Loopers-Ldap': 'loopers.admin' }; + +const stalePriceCount = new Counter('stale_price_detected'); + +export const options = { + scenarios: { + readers: { + executor: 'constant-vus', + vus: 19, + duration: '30s', + exec: 'reader', + }, + writer: { + executor: 'constant-vus', + vus: 1, + duration: '30s', + exec: 'writer', + startTime: '5s', // 5초 후 쓰기 시작 (캐시 워밍업 시간) + }, + }, + thresholds: { + http_req_duration: ['p(95)<300'], + http_req_failed: ['rate<0.05'], + }, +}; + +// 워밍업: 캐시 저장 +export function setup() { + for (const sort of SORT_TYPES) { + http.get(`${BASE_URL}/api/v1/products?sort=${sort}&size=20`); + } + // 상품 1의 원래 가격 저장 + const res = http.get(`${BASE_URL}/api/v1/products/1`); + const data = JSON.parse(res.body); + return { originalPrice: data.data.basePrice }; +} + +export function reader() { + const r = Math.random(); + if (r < 0.7) { + const sort = SORT_TYPES[Math.floor(Math.random() * SORT_TYPES.length)]; + const res = http.get(`${BASE_URL}/api/v1/products?sort=${sort}&size=20`, + { tags: { name: 'read_list' } }); + check(res, { '200 OK': (r) => r.status === 200 }); + } else { + const res = http.get(`${BASE_URL}/api/v1/products/1`, + { tags: { name: 'read_detail' } }); + check(res, { '200 OK': (r) => r.status === 200 }); + } + sleep(0.1); +} + +export function writer() { + // 10초마다 가격 변경 + const newPrice = 10000 + Math.floor(Math.random() * 90000); + const res = http.patch(`${BASE_URL}/api-admin/v1/products/1`, + JSON.stringify({ basePrice: newPrice }), + { headers: ADMIN_HEADER, tags: { name: 'write_update' } } + ); + check(res, { 'update 200': (r) => r.status === 200 }); + sleep(10); +} + +// 테스트 종료 후 원래 가격 복원 +export function teardown(data) { + http.patch(`${BASE_URL}/api-admin/v1/products/1`, + JSON.stringify({ basePrice: data.originalPrice }), + { headers: ADMIN_HEADER } + ); +} diff --git a/k6/no-cache-baseline.js b/k6/no-cache-baseline.js new file mode 100644 index 000000000..7feffc10d --- /dev/null +++ b/k6/no-cache-baseline.js @@ -0,0 +1,61 @@ +import http from 'k6/http'; +import { check, sleep } from 'k6'; + +/** + * DB Only 부하 테스트 (캐시 우회) + * + * 2페이지 요청(cursor 있음) → 캐시를 우회하고 매번 DB 조회. + * 캐시 히트 테스트와 동일 부하(20 VU)로 비교. + * + * 실행 방법: + * k6 run k6/no-cache-baseline.js + */ + +const BASE_URL = 'http://localhost:8080'; +const SORT_TYPES = ['LATEST', 'PRICE_ASC', 'PRICE_DESC', 'LIKES_DESC']; + +export const options = { + stages: [ + { duration: '10s', target: 20 }, + { duration: '30s', target: 20 }, + { duration: '5s', target: 0 }, + ], + thresholds: { + http_req_duration: ['p(95)<500', 'p(99)<1000'], + http_req_failed: ['rate<0.01'], + }, +}; + +// 미리 가져올 커서 — 2페이지 요청을 시뮬레이션 +let cursors = {}; + +export function setup() { + // 각 sort별 첫 페이지를 조회해서 nextCursor 확보 + const result = {}; + for (const sort of SORT_TYPES) { + const res = http.get(`${BASE_URL}/api/v1/products?sort=${sort}&size=20`); + if (res.status === 200) { + const body = JSON.parse(res.body); + // 응답에 cursor가 있으면 저장 + if (body.data && body.data.products && body.data.products.length > 0) { + const lastProduct = body.data.products[body.data.products.length - 1]; + result[sort] = lastProduct.id; + } + } + } + return result; +} + +export default function (data) { + const sort = SORT_TYPES[Math.floor(Math.random() * SORT_TYPES.length)]; + // 2페이지 요청 → cursor 파라미터 포함 → 캐시 우회 (첫 페이지만 캐싱) + const lastId = data[sort] || 100000; + const url = `${BASE_URL}/api/v1/products?sort=${sort}&size=20&cursorId=${lastId}`; + const res = http.get(url, { tags: { name: 'list_no_cache' } }); + + check(res, { + '200 OK': (r) => r.status === 200, + }); + + sleep(0.1); +} From 02f07f1ef615d7b3f457d417045fc2998e8c390c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B5=E1=86=B7=E1=84=82=E1=85=A1=E1=84=92?= =?UTF-8?q?=E1=85=A7=E1=86=AB?= Date: Fri, 13 Mar 2026 00:15:01 +0900 Subject: [PATCH 08/17] =?UTF-8?q?fix:=20OrderSeeder=20=EC=A3=BC=EB=AC=B8?= =?UTF-8?q?=20=EA=B8=88=EC=95=A1=EA=B3=BC=20=EC=A3=BC=EB=AC=B8=EC=83=81?= =?UTF-8?q?=ED=92=88=20=EC=A0=95=ED=95=A9=EC=84=B1=20=EB=B6=88=EC=9D=BC?= =?UTF-8?q?=EC=B9=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../infrastructure/seeder/OrderSeeder.java | 91 +++++++++---------- 1 file changed, 41 insertions(+), 50 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/seeder/OrderSeeder.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/seeder/OrderSeeder.java index dc4e13712..23f11a017 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/seeder/OrderSeeder.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/seeder/OrderSeeder.java @@ -21,6 +21,8 @@ * - status: PAID 70% / CANCELED 15% / PENDING 10% / EXPIRED 5% * - ordered_at: 최근 6개월 분산 * - items per order: 1개 60% / 2개 30% / 3개 10% + * + * 정합성: orders.subtotal_amount = sum(order_items.unit_price * quantity) */ @Component class OrderSeeder { @@ -44,8 +46,9 @@ void seed() { ZonedDateTime now = ZonedDateTime.now(ZoneId.of("Asia/Seoul")); + // 주문과 주문상품을 함께 쌓고, 주문 flush 후 order_id를 매핑해서 주문상품도 flush List orderBatch = new ArrayList<>(BATCH_SIZE); - List itemBatch = new ArrayList<>(BATCH_SIZE * 2); + List> itemsPerOrder = new ArrayList<>(BATCH_SIZE); long totalItems = 0; for (int i = 0; i < ORDER_COUNT; i++) { @@ -57,7 +60,7 @@ void seed() { int itemCount = generateItemCount(); int subtotal = 0; - // 주문 상품 준비 + // 주문 상품 준비 (이 아이템들이 그대로 DB에 저장됨) List currentItems = new ArrayList<>(); for (int j = 0; j < itemCount; j++) { long productId = random.nextInt(PRODUCT_COUNT) + 1; @@ -95,16 +98,13 @@ void seed() { Timestamp.from(orderedAt.toInstant()), Timestamp.from(now.toInstant()) }); - - // order_items는 order INSERT 후 order_id를 알아야 하므로 별도 저장 - for (Object[] item : currentItems) { - itemBatch.add(item); - } + itemsPerOrder.add(currentItems); totalItems += currentItems.size(); if (orderBatch.size() >= BATCH_SIZE) { - flushOrders(orderBatch); + flushOrdersAndItems(orderBatch, itemsPerOrder); orderBatch.clear(); + itemsPerOrder.clear(); if ((i + 1) % 10_000 == 0) { log.info("[OrderSeeder] 주문 {}/{} 완료", i + 1, ORDER_COUNT); @@ -112,62 +112,60 @@ void seed() { } } - // 남은 주문 배치 + // 남은 배치 if (!orderBatch.isEmpty()) { - flushOrders(orderBatch); + flushOrdersAndItems(orderBatch, itemsPerOrder); } - // order_items: order_id를 DB에서 매핑 - seedOrderItems(totalItems); - log.info("[OrderSeeder] 주문 {}건 + 주문상품 {}건 생성 완료 ({}ms)", ORDER_COUNT, totalItems, System.currentTimeMillis() - start); } /** - * order_items 시딩: orders 삽입 후, 각 order의 item_count에 맞게 생성 + * 주문 배치 INSERT 후, 생성된 order_id를 조회해서 주문상품에 매핑하고 함께 INSERT. + * subtotal_amount와 order_items 금액 합계의 정합성을 보장한다. */ - private void seedOrderItems(long expectedTotal) { - log.info("[OrderSeeder] 주문상품 {}건 생성 시작", expectedTotal); - - // 주문 ID 목록 조회 - List orderIds = jdbcTemplate.queryForList("SELECT id FROM orders ORDER BY id", Long.class); + private void flushOrdersAndItems(List orderBatch, List> itemsPerOrder) { + // 주문 INSERT 전 마지막 ID 확인 + Long lastIdBefore = jdbcTemplate.queryForObject( + "SELECT COALESCE(MAX(id), 0) FROM orders", Long.class + ); - List batch = new ArrayList<>(5_000); - long inserted = 0; - Random itemRandom = new Random(123); + // 주문 INSERT + flushOrders(orderBatch); - for (Long orderId : orderIds) { - int itemCount = generateItemCountWith(itemRandom); + // 방금 삽입된 주문들의 ID 조회 + List newOrderIds = jdbcTemplate.queryForList( + "SELECT id FROM orders WHERE id > ? ORDER BY id", + Long.class, lastIdBefore + ); - for (int j = 0; j < itemCount; j++) { - long productId = itemRandom.nextInt(PRODUCT_COUNT) + 1; - int unitPrice = (itemRandom.nextInt(490) + 10) * 1000; - int quantity = itemRandom.nextInt(3) + 1; + // 주문상품에 order_id 매핑 후 INSERT + List itemBatch = new ArrayList<>(5_000); + for (int i = 0; i < newOrderIds.size(); i++) { + Long orderId = newOrderIds.get(i); + List items = itemsPerOrder.get(i); - batch.add(new Object[]{ + for (Object[] item : items) { + itemBatch.add(new Object[]{ orderId, - productId, - "상품" + productId, - "브랜드" + (itemRandom.nextInt(100) + 1), - unitPrice, - quantity + item[0], // productId + item[1], // productName + item[2], // brandName + item[3], // unitPrice + item[4] // quantity }); - if (batch.size() >= 5_000) { - flushOrderItems(batch); - inserted += batch.size(); - batch.clear(); + if (itemBatch.size() >= 5_000) { + flushOrderItems(itemBatch); + itemBatch.clear(); } } } - if (!batch.isEmpty()) { - flushOrderItems(batch); - inserted += batch.size(); + if (!itemBatch.isEmpty()) { + flushOrderItems(itemBatch); } - - log.info("[OrderSeeder] 주문상품 {}건 생성 완료", inserted); } private void flushOrders(List batch) { @@ -212,11 +210,4 @@ private int generateItemCount() { if (r < 0.90) return 2; return 3; } - - private int generateItemCountWith(Random r) { - double v = r.nextDouble(); - if (v < 0.60) return 1; - if (v < 0.90) return 2; - return 3; - } } From b95558b090e0415345b64ec81236ccd23a66eecf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B5=E1=86=B7=E1=84=82=E1=85=A1=E1=84=92?= =?UTF-8?q?=E1=85=A7=E1=86=AB?= Date: Fri, 13 Mar 2026 00:43:50 +0900 Subject: [PATCH 09/17] =?UTF-8?q?feat:=20JPA=20Entity=EC=97=90=20@Index=20?= =?UTF-8?q?=EC=96=B4=EB=85=B8=ED=85=8C=EC=9D=B4=EC=85=98=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(=EC=88=98=EB=8F=99=20DDL=20=E2=86=92=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EA=B4=80=EB=A6=AC=20=EC=A0=84=ED=99=98)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../loopers/infrastructure/address/UserAddressEntity.java | 5 ++++- .../loopers/infrastructure/coupon/IssuedCouponEntity.java | 5 ++++- .../com/loopers/infrastructure/order/OrderEntity.java | 5 ++++- .../com/loopers/infrastructure/product/ProductEntity.java | 8 +++++++- 4 files changed, 19 insertions(+), 4 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/address/UserAddressEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/address/UserAddressEntity.java index e44c22ad1..5ac922aec 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/address/UserAddressEntity.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/address/UserAddressEntity.java @@ -5,6 +5,7 @@ import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; +import jakarta.persistence.Index; import jakarta.persistence.Table; import java.time.ZonedDateTime; @@ -13,7 +14,9 @@ * JPA 어노테이션만 사용 */ @Entity -@Table(name = "user_addresses") +@Table(name = "user_addresses", indexes = { + @Index(name = "idx_user_addresses_user_created", columnList = "user_id, created_at") +}) public class UserAddressEntity { @Id diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/IssuedCouponEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/IssuedCouponEntity.java index 5ff0fb9c8..23e7d3e08 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/IssuedCouponEntity.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/IssuedCouponEntity.java @@ -9,6 +9,7 @@ import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; +import jakarta.persistence.Index; import jakarta.persistence.Table; import java.time.ZonedDateTime; @@ -17,7 +18,9 @@ * JPA 어노테이션만 사용 */ @Entity -@Table(name = "issued_coupons") +@Table(name = "issued_coupons", indexes = { + @Index(name = "idx_issued_coupons_user_created", columnList = "user_id, created_at") +}) public class IssuedCouponEntity { @Id diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderEntity.java index a107810f0..f50c21f62 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderEntity.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderEntity.java @@ -11,6 +11,7 @@ import jakarta.persistence.Id; import jakarta.persistence.JoinColumn; import jakarta.persistence.OneToMany; +import jakarta.persistence.Index; import jakarta.persistence.Table; import com.loopers.domain.order.OrderStatus; import java.time.ZonedDateTime; @@ -22,7 +23,9 @@ * JPA 어노테이션만 사용 */ @Entity -@Table(name = "orders") +@Table(name = "orders", indexes = { + @Index(name = "idx_orders_user_created", columnList = "user_id, created_at") +}) public class OrderEntity { @Id diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductEntity.java index e347a2e19..3323e488d 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductEntity.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductEntity.java @@ -8,6 +8,7 @@ import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; +import jakarta.persistence.Index; import jakarta.persistence.Table; import java.time.ZonedDateTime; @@ -16,7 +17,12 @@ * JPA 어노테이션만 사용 */ @Entity -@Table(name = "products") +@Table(name = "products", indexes = { + @Index(name = "idx_products_latest", columnList = "created_at DESC, id DESC, status, deleted_at"), + @Index(name = "idx_products_price", columnList = "base_price ASC, id ASC, status, deleted_at"), + @Index(name = "idx_products_likes", columnList = "like_count DESC, id DESC, status, deleted_at"), + @Index(name = "idx_products_brand", columnList = "brand_id, status, deleted_at") +}) public class ProductEntity { @Id From 5ef36500e10e11b8da56d90f3321063ccf4c32e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B5=E1=86=B7=E1=84=82=E1=85=A1=E1=84=92?= =?UTF-8?q?=E1=85=A7=E1=86=AB?= Date: Fri, 13 Mar 2026 22:43:34 +0900 Subject: [PATCH 10/17] =?UTF-8?q?fix:=20UserSeeder=20=EC=8B=A4=EC=A0=9C=20?= =?UTF-8?q?BCrypt=20=ED=95=B4=EC=8B=9C=EB=A1=9C=20=ED=8C=A8=EC=8A=A4?= =?UTF-8?q?=EC=9B=8C=EB=93=9C=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/loopers/infrastructure/seeder/UserSeeder.java | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/seeder/UserSeeder.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/seeder/UserSeeder.java index 23dd4899b..6159d9692 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/seeder/UserSeeder.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/seeder/UserSeeder.java @@ -4,6 +4,7 @@ import org.slf4j.LoggerFactory; import org.springframework.jdbc.core.BatchPreparedStatementSetter; import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.stereotype.Component; import java.sql.PreparedStatement; @@ -17,6 +18,10 @@ class UserSeeder { private static final Logger log = LoggerFactory.getLogger(UserSeeder.class); static final int USER_COUNT = 5_000; + static final String SEEDING_RAW_PASSWORD = "Hx7!mK2@"; + + private static final String[] LAST_NAME = {"김", "이", "박", "최", "정", "강", "조", "윤", "장", "임"}; + private static final String[] FIRST_NAME = {"민준", "서연", "도윤", "하윤", "서준", "지우", "예준", "수아", "주원", "지호"}; private final JdbcTemplate jdbcTemplate; @@ -29,6 +34,7 @@ void seed() { long start = System.currentTimeMillis(); ZonedDateTime now = ZonedDateTime.now(ZoneId.of("Asia/Seoul")); + String encodedPassword = new BCryptPasswordEncoder().encode(SEEDING_RAW_PASSWORD); jdbcTemplate.batchUpdate( "INSERT INTO users (login_id, password, name, birth_date, email, created_at, updated_at) " + @@ -38,8 +44,8 @@ void seed() { public void setValues(PreparedStatement ps, int i) throws SQLException { int idx = i + 1; ps.setString(1, "user" + idx); - ps.setString(2, "$2a$10$dummyHashedPasswordForSeeding"); - ps.setString(3, "테스트유저" + idx); + ps.setString(2, encodedPassword); + ps.setString(3, LAST_NAME[i % LAST_NAME.length] + FIRST_NAME[i % FIRST_NAME.length]); ps.setDate(4, java.sql.Date.valueOf("1990-01-01")); ps.setString(5, "user" + idx + "@test.com"); ps.setTimestamp(6, Timestamp.from(now.toInstant())); From 31473e294b40f585002ccdc307876c36f14b44b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B5=E1=86=B7=E1=84=82=E1=85=A1=E1=84=92?= =?UTF-8?q?=E1=85=A7=E1=86=AB?= Date: Fri, 13 Mar 2026 22:49:46 +0900 Subject: [PATCH 11/17] =?UTF-8?q?fix:=20=EC=BF=A0=ED=8F=B0=20=EB=B0=9C?= =?UTF-8?q?=EA=B8=89=20http=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- http/commerce-api/coupon.http | 23 +++++++++-------------- 1 file changed, 9 insertions(+), 14 deletions(-) diff --git a/http/commerce-api/coupon.http b/http/commerce-api/coupon.http index 27f4827b4..752f39ecc 100644 --- a/http/commerce-api/coupon.http +++ b/http/commerce-api/coupon.http @@ -1,15 +1,10 @@ ### ========== 쿠폰 API ========== ### 쿠폰 발급 -POST http://localhost:8080/api/v1/coupons/issue -Content-Type: application/json +POST http://localhost:8080/api/v1/coupons/1/issue X-Loopers-LoginId: nahyeon X-Loopers-LoginPw: Hx7!mK2@ -{ - "couponTemplateId": 1 -} - ### 내 쿠폰 목록 조회 GET http://localhost:8080/api/v1/users/me/coupons X-Loopers-LoginId: nahyeon @@ -25,13 +20,13 @@ X-Loopers-LoginPw: Hx7!mK2@ ### ========== 어드민 쿠폰 템플릿 API ========== ### 쿠폰 템플릿 목록 조회 -GET http://localhost:8080/api-admin/v1/coupon-templates -X-Loopers-Ldap: admin +GET http://localhost:8080/api-admin/v1/coupons +X-Loopers-Ldap: loopers.admin ### 쿠폰 템플릿 생성 -POST http://localhost:8080/api-admin/v1/coupon-templates +POST http://localhost:8080/api-admin/v1/coupons Content-Type: application/json -X-Loopers-Ldap: admin +X-Loopers-Ldap: loopers.admin { "name": "신규 가입 쿠폰", @@ -47,9 +42,9 @@ X-Loopers-Ldap: admin } ### 쿠폰 템플릿 수정 -PUT http://localhost:8080/api-admin/v1/coupon-templates/1 +PUT http://localhost:8080/api-admin/v1/coupons/1 Content-Type: application/json -X-Loopers-Ldap: admin +X-Loopers-Ldap: loopers.admin { "name": "수정된 쿠폰", @@ -61,5 +56,5 @@ X-Loopers-Ldap: admin } ### 쿠폰 템플릿 삭제 -DELETE http://localhost:8080/api-admin/v1/coupon-templates/1 -X-Loopers-Ldap: admin +DELETE http://localhost:8080/api-admin/v1/coupons/1 +X-Loopers-Ldap: loopers.admin From 9ec13d2c398a5e1808fd300b14340e88f9efc0a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B5=E1=86=B7=E1=84=82=E1=85=A1=E1=84=92?= =?UTF-8?q?=E1=85=A7=E1=86=AB?= Date: Sat, 14 Mar 2026 10:09:44 +0900 Subject: [PATCH 12/17] =?UTF-8?q?refactor:=20=20@Transactional(readOnly=20?= =?UTF-8?q?=3D=20true)=20=EC=A0=9C=EA=B1=B0.=20Service=EC=97=90=20?= =?UTF-8?q?=EC=9D=B4=EB=AF=B8=20=EC=9E=88=EC=9C=BC=EB=AF=80=EB=A1=9C=20?= =?UTF-8?q?=EC=BA=90=EC=8B=9C=20=ED=9E=88=ED=8A=B8=20=EC=8B=9C=20=EB=B6=88?= =?UTF-8?q?=ED=95=84=EC=9A=94=ED=95=9C=20DB=20=EC=BB=A4=EB=84=A5=EC=85=98?= =?UTF-8?q?=20=EC=A0=90=EC=9C=A0=20=EB=B0=A9=EC=A7=80.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/brand/BrandAdminFacade.java | 4 +-- .../cache/ProductCacheManager.java | 27 ++++----------- .../application/order/OrderFacade.java | 1 - .../application/payment/PaymentFacade.java | 16 ++++++++- .../product/ProductAdminFacade.java | 8 ++--- .../application/product/ProductFacade.java | 4 --- .../support/error/PaymentErrorType.java | 3 +- .../cache/ProductCacheIntegrationTest.java | 34 +++++++++---------- 8 files changed, 46 insertions(+), 51 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandAdminFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandAdminFacade.java index 9530b58a9..b00cf3e35 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandAdminFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandAdminFacade.java @@ -66,7 +66,7 @@ public void deleteBrand(Long brandId) { cartItemService.deleteByProductId(product.getId()); } - productCacheManager.registerBrandDeleteDoubleDelete(productIds); + productCacheManager.registerBrandDeleteEvictAfterCommit(productIds); } /** 전체 브랜드 목록 페이지네이션 조회 */ @@ -102,7 +102,7 @@ public BrandInfo updateBrand(Long brandId, String name, String description) { public BrandInfo changeBrandStatus(Long brandId, BrandStatus status) { Brand brand = brandService.changeStatus(brandId, status); - productCacheManager.registerListOnlyDoubleDelete(); + productCacheManager.registerListOnlyEvictAfterCommit(); return BrandInfo.from(brand); } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/cache/ProductCacheManager.java b/apps/commerce-api/src/main/java/com/loopers/application/cache/ProductCacheManager.java index b4ecfc534..9d57dbd5b 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/cache/ProductCacheManager.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/cache/ProductCacheManager.java @@ -19,14 +19,12 @@ import java.util.ArrayList; import java.util.List; import java.util.Optional; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.TimeUnit; /** * 상품 캐시 매니저 * * Cache-Aside 패턴의 캐시 읽기/저장/무효화를 담당한다. - * 무효화 전략: Delayed Double Delete (afterCommit DELETE + 500ms 후 2차 DELETE) + * 무효화 전략: afterCommit DELETE + TTL 안전망 * * 키 설계: * 상품 상세: products:detail:{productId} @@ -41,7 +39,6 @@ public class ProductCacheManager { private static final String LIST_KEY_PREFIX = "products:list:"; private static final Duration DETAIL_TTL = Duration.ofSeconds(300); private static final Duration LIST_TTL = Duration.ofSeconds(60); - private static final long DOUBLE_DELETE_DELAY_MS = 500; private final RedisTemplate redisTemplate; private final ObjectMapper objectMapper; @@ -109,23 +106,20 @@ public void putProductList(ProductSortType sort, Long brandId, ProductFacade.Pro } } - // ── Delayed Double Delete (afterCommit 등록) ── + // ── afterCommit 캐시 무효화 ── /** * 상품 데이터 변경 시 호출. - * afterCommit 콜백에서 1차 DELETE + 500ms 후 2차 DELETE를 수행한다. + * afterCommit 콜백에서 캐시를 삭제한다. TTL이 최종 안전망. * * @param productId null이면 상세 캐시 삭제를 건너뛰고 목록만 삭제 */ - public void registerDelayedDoubleDelete(Long productId) { + public void registerEvictAfterCommit(Long productId) { TransactionSynchronizationManager.registerSynchronization( new TransactionSynchronization() { @Override public void afterCommit() { evictProductCaches(productId); - - CompletableFuture.delayedExecutor(DOUBLE_DELETE_DELAY_MS, TimeUnit.MILLISECONDS) - .execute(() -> evictProductCaches(productId)); } } ); @@ -135,19 +129,13 @@ public void afterCommit() { * 브랜드 삭제 시 호출 — 소속 상품 상세 캐시 + 목록 캐시 전체 삭제. * 브랜드 삭제 시 소속 상품이 전부 soft delete되므로 상세 캐시도 무효화 필수. */ - public void registerBrandDeleteDoubleDelete(List productIds) { + public void registerBrandDeleteEvictAfterCommit(List productIds) { TransactionSynchronizationManager.registerSynchronization( new TransactionSynchronization() { @Override public void afterCommit() { evictProductDetailBatch(productIds); evictAllProductListCache(); - - CompletableFuture.delayedExecutor(DOUBLE_DELETE_DELAY_MS, TimeUnit.MILLISECONDS) - .execute(() -> { - evictProductDetailBatch(productIds); - evictAllProductListCache(); - }); } } ); @@ -156,15 +144,12 @@ public void afterCommit() { /** * 브랜드 상태 변경 시 호출 — 상품 목록 캐시만 삭제 (상세는 브랜드 상태 무관). */ - public void registerListOnlyDoubleDelete() { + public void registerListOnlyEvictAfterCommit() { TransactionSynchronizationManager.registerSynchronization( new TransactionSynchronization() { @Override public void afterCommit() { evictAllProductListCache(); - - CompletableFuture.delayedExecutor(DOUBLE_DELETE_DELAY_MS, TimeUnit.MILLISECONDS) - .execute(() -> evictAllProductListCache()); } } ); 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 e54bfc7a2..fe6817961 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 @@ -335,7 +335,6 @@ private String generateIdempotencyKey() { * * @param isDefaultQuery Controller에서 판단: startAt/endAt/cursor 모두 미지정 시 true */ - @Transactional(readOnly = true) public OrderCursorResult getOrdersWithCursor(Long userId, ZonedDateTime startAt, ZonedDateTime endAt, ZonedDateTime cursorCreatedAt, Long cursorId, int size, boolean isDefaultQuery) { diff --git a/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.java index cd845bf12..92898efaa 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.java @@ -13,9 +13,12 @@ import com.loopers.domain.payment.PaymentService; import com.loopers.domain.point.PointAccount; import com.loopers.domain.point.PointService; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductService; import com.loopers.support.error.CoreException; import com.loopers.support.error.CouponErrorType; import com.loopers.support.error.OrderErrorType; +import com.loopers.support.error.PaymentErrorType; import com.loopers.support.error.PointErrorType; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; @@ -40,16 +43,19 @@ public class PaymentFacade { private final InventoryService inventoryService; private final PointService pointService; private final CouponService couponService; + private final ProductService productService; private final OrderCacheManager orderCacheManager; public PaymentFacade(OrderService orderService, PaymentService paymentService, InventoryService inventoryService, PointService pointService, - CouponService couponService, OrderCacheManager orderCacheManager) { + CouponService couponService, ProductService productService, + OrderCacheManager orderCacheManager) { this.orderService = orderService; this.paymentService = paymentService; this.inventoryService = inventoryService; this.pointService = pointService; this.couponService = couponService; + this.productService = productService; this.orderCacheManager = orderCacheManager; } @@ -109,6 +115,14 @@ public PaymentRequestResult requestPayment(Long orderId, Long userId, String pay throw new CoreException(OrderErrorType.INVALID_ORDER_STATUS); } + // 결제 시점 가격 재검증 — 주문 생성 후 상품 가격이 변경되었는지 확인 + for (OrderItem item : order.getItems()) { + Product product = productService.getById(item.getProductId()); + if (product.getBasePrice() != item.getUnitPrice()) { + throw new CoreException(PaymentErrorType.PRICE_CHANGED); + } + } + Payment payment = paymentService.create( orderId, order.getTotalAmount(), paymentMethod, generateIdempotencyKey()); diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductAdminFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductAdminFacade.java index b718c6744..67f1013d7 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductAdminFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductAdminFacade.java @@ -50,7 +50,7 @@ public ProductAdminDetailResult createProduct(Long brandId, String name, String Product product = productService.create(brandId, name, description, basePrice); Inventory inventory = inventoryService.create(product.getId(), quantity); - productCacheManager.registerDelayedDoubleDelete(null); + productCacheManager.registerEvictAfterCommit(null); return new ProductAdminDetailResult( ProductInfo.from(product), BrandInfo.from(brand), InventoryInfo.from(inventory)); @@ -88,7 +88,7 @@ public void deleteProduct(Long productId) { inventoryService.delete(productId); cartItemService.deleteByProductId(productId); - productCacheManager.registerDelayedDoubleDelete(productId); + productCacheManager.registerEvictAfterCommit(productId); } /** 상품 부분 수정 */ @@ -96,7 +96,7 @@ public void deleteProduct(Long productId) { public ProductAdminDetailResult updateProduct(Long productId, String name, String description, Integer basePrice) { productService.update(productId, name, description, basePrice); - productCacheManager.registerDelayedDoubleDelete(productId); + productCacheManager.registerEvictAfterCommit(productId); return getProductDetail(productId); } @@ -106,7 +106,7 @@ public ProductAdminDetailResult updateProduct(Long productId, String name, Strin public ProductAdminDetailResult changeProductStatus(Long productId, ProductStatus status) { productService.changeStatus(productId, status); - productCacheManager.registerDelayedDoubleDelete(productId); + productCacheManager.registerEvictAfterCommit(productId); return getProductDetail(productId); } 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 a8e0b0295..588821881 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 @@ -10,8 +10,6 @@ import com.loopers.domain.product.ProductService; import com.loopers.domain.product.ProductSortType; import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Transactional; - import java.util.List; import java.util.Optional; @@ -35,7 +33,6 @@ public ProductFacade(ProductService productService, BrandService brandService, } /** 고객 상품 상세 조회 (상품 + 브랜드명) — Cache-Aside */ - @Transactional(readOnly = true) public ProductDetailResult getProductDetail(Long productId) { Optional cached = productCacheManager.getProductDetail(productId); if (cached.isPresent()) { @@ -51,7 +48,6 @@ public ProductDetailResult getProductDetail(Long productId) { } /** 고객 상품 목록 커서 조회 (COUNT 쿼리 없음) — 첫 페이지만 Cache-Aside */ - @Transactional(readOnly = true) public ProductCursorResult getDisplayableProductsWithCursor(Long brandId, ProductSortType sort, ProductCursor cursor, int size) { boolean isFirstPage = (cursor == null); diff --git a/apps/commerce-api/src/main/java/com/loopers/support/error/PaymentErrorType.java b/apps/commerce-api/src/main/java/com/loopers/support/error/PaymentErrorType.java index f358e8a3b..8bee64ca7 100644 --- a/apps/commerce-api/src/main/java/com/loopers/support/error/PaymentErrorType.java +++ b/apps/commerce-api/src/main/java/com/loopers/support/error/PaymentErrorType.java @@ -5,7 +5,8 @@ public enum PaymentErrorType implements ErrorType { PAYMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 결제입니다."), DUPLICATE_IDEMPOTENCY_KEY(HttpStatus.CONFLICT, "이미 처리된 결제 요청입니다."), - INVALID_PAYMENT_STATUS(HttpStatus.CONFLICT, "현재 결제 상태에서는 수행할 수 없는 작업입니다."); + INVALID_PAYMENT_STATUS(HttpStatus.CONFLICT, "현재 결제 상태에서는 수행할 수 없는 작업입니다."), + PRICE_CHANGED(HttpStatus.CONFLICT, "주문 시점과 현재 상품 가격이 변경되었습니다. 주문을 다시 진행해주세요."); private final HttpStatus status; private final String message; diff --git a/apps/commerce-api/src/test/java/com/loopers/application/cache/ProductCacheIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/cache/ProductCacheIntegrationTest.java index 06c712950..2f1170db3 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/cache/ProductCacheIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/cache/ProductCacheIntegrationTest.java @@ -69,8 +69,8 @@ class ProductDetailCache { activeBrand.getId(), "에어맥스", "설명", 150000, 100); Long productId = created.product().id(); - // 생성 시 Double Delete로 캐시가 삭제되므로, 잠시 대기 후 조회 - waitForDoubleDelete(); + // 생성 시 afterCommit으로 캐시가 삭제되므로, 잠시 대기 후 조회 + waitForCacheEviction(); // act productFacade.getProductDetail(productId); @@ -89,7 +89,7 @@ class ProductDetailCache { var created = productAdminFacade.createProduct( activeBrand.getId(), "에어맥스", "설명", 150000, 100); Long productId = created.product().id(); - waitForDoubleDelete(); + waitForCacheEviction(); // act — 첫 조회 (cache miss → DB → cache put) var firstResult = productFacade.getProductDetail(productId); @@ -108,15 +108,15 @@ class ProductDetailCache { var created = productAdminFacade.createProduct( activeBrand.getId(), "에어맥스", "설명", 150000, 100); Long productId = created.product().id(); - waitForDoubleDelete(); + waitForCacheEviction(); productFacade.getProductDetail(productId); // 캐시 저장 String key = "products:detail:" + productId; assertThat(redisTemplate.opsForValue().get(key)).isNotNull(); - // act — 상품 수정 (Delayed Double Delete 트리거) + // act — 상품 수정 (afterCommit 캐시 삭제 트리거) productAdminFacade.updateProduct(productId, "에어포스", null, null); - waitForDoubleDelete(); + waitForCacheEviction(); // assert — 캐시 삭제됨 assertThat(redisTemplate.opsForValue().get(key)).isNull(); @@ -128,7 +128,7 @@ class ProductDetailCache { var created = productAdminFacade.createProduct( activeBrand.getId(), "에어맥스", "설명", 150000, 100); Long productId = created.product().id(); - waitForDoubleDelete(); + waitForCacheEviction(); productFacade.getProductDetail(productId); // 캐시 저장 String key = "products:detail:" + productId; @@ -136,7 +136,7 @@ class ProductDetailCache { // act productAdminFacade.deleteProduct(productId); - waitForDoubleDelete(); + waitForCacheEviction(); // assert assertThat(redisTemplate.opsForValue().get(key)).isNull(); @@ -152,7 +152,7 @@ class ProductListCache { // arrange productAdminFacade.createProduct(activeBrand.getId(), "상품1", "설명", 10000, 100); productAdminFacade.createProduct(activeBrand.getId(), "상품2", "설명", 20000, 100); - waitForDoubleDelete(); + waitForCacheEviction(); // act productFacade.getDisplayableProductsWithCursor(null, ProductSortType.LATEST, null, 20); @@ -169,7 +169,7 @@ class ProductListCache { void 브랜드_필터_조회_시_브랜드별_키로_캐싱된다() { // arrange productAdminFacade.createProduct(activeBrand.getId(), "상품1", "설명", 10000, 100); - waitForDoubleDelete(); + waitForCacheEviction(); // act productFacade.getDisplayableProductsWithCursor( @@ -184,14 +184,14 @@ class ProductListCache { void 상품_생성_시_목록_캐시가_삭제된다() { // arrange productAdminFacade.createProduct(activeBrand.getId(), "상품1", "설명", 10000, 100); - waitForDoubleDelete(); + waitForCacheEviction(); productFacade.getDisplayableProductsWithCursor(null, ProductSortType.LATEST, null, 20); assertThat(redisTemplate.opsForValue().get("products:list:LATEST:all")).isNotNull(); // act — 새 상품 생성 (목록 캐시 무효화) productAdminFacade.createProduct(activeBrand.getId(), "상품2", "설명", 20000, 100); - waitForDoubleDelete(); + waitForCacheEviction(); // assert — 목록 캐시 삭제됨 assertThat(redisTemplate.opsForValue().get("products:list:LATEST:all")).isNull(); @@ -201,7 +201,7 @@ class ProductListCache { void 정렬_타입별로_별도_캐시가_생성된다() { // arrange productAdminFacade.createProduct(activeBrand.getId(), "상품1", "설명", 10000, 100); - waitForDoubleDelete(); + waitForCacheEviction(); // act productFacade.getDisplayableProductsWithCursor(null, ProductSortType.LATEST, null, 20); @@ -215,12 +215,12 @@ class ProductListCache { } /** - * Delayed Double Delete의 2차 DELETE(500ms 후)가 완료될 때까지 대기. - * 테스트에서 캐시 상태를 정확히 확인하기 위해 필요. + * afterCommit 캐시 삭제가 완료될 때까지 대기. + * 트랜잭션 커밋 후 비동기 처리 여유를 위해 짧은 대기. */ - private void waitForDoubleDelete() { + private void waitForCacheEviction() { try { - Thread.sleep(700); + Thread.sleep(100); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } From 9608d46886757a4d597c08b3e27c3d388a05fcf8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B5=E1=86=B7=E1=84=82=E1=85=A1=E1=84=92?= =?UTF-8?q?=E1=85=A7=E1=86=AB?= Date: Sat, 14 Mar 2026 10:53:30 +0900 Subject: [PATCH 13/17] =?UTF-8?q?refactor:=20LikeFacade=20=EC=BA=90?= =?UTF-8?q?=EC=8B=9C=20=EB=AC=B4=ED=9A=A8=ED=99=94=20=EB=88=84=EB=9D=BD=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/cache/OrderCacheManager.java | 11 ++++- .../cache/ProductCacheManager.java | 43 ++++++++----------- .../loopers/application/like/LikeFacade.java | 22 ++++++---- 3 files changed, 40 insertions(+), 36 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/cache/OrderCacheManager.java b/apps/commerce-api/src/main/java/com/loopers/application/cache/OrderCacheManager.java index 4bc5df208..f52b91594 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/cache/OrderCacheManager.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/cache/OrderCacheManager.java @@ -12,6 +12,7 @@ import java.time.Duration; import java.util.Optional; +import java.util.concurrent.ThreadLocalRandom; /** * 주문 목록 캐시 매니저 @@ -30,7 +31,8 @@ public class OrderCacheManager { private static final Logger log = LoggerFactory.getLogger(OrderCacheManager.class); private static final String LIST_KEY_PREFIX = "orders:list:"; - private static final Duration LIST_TTL = Duration.ofSeconds(300); + private static final int LIST_TTL_BASE = 120; + private static final int LIST_TTL_JITTER = 15; private final RedisTemplate redisTemplate; private final ObjectMapper objectMapper; @@ -62,7 +64,7 @@ public void putOrderList(Long userId, OrderFacade.OrderCursorResult result) { String key = LIST_KEY_PREFIX + userId; try { String json = objectMapper.writeValueAsString(result); - redisTemplate.opsForValue().set(key, json, LIST_TTL); + redisTemplate.opsForValue().set(key, json, ttlWithJitter()); } catch (JsonProcessingException e) { log.warn("주문 목록 캐시 직렬화 실패 (userId={})", userId, e); } @@ -88,4 +90,9 @@ public void afterCommit() { public void evictOrderList(Long userId) { redisTemplate.delete(LIST_KEY_PREFIX + userId); } + + private Duration ttlWithJitter() { + int jitter = ThreadLocalRandom.current().nextInt(-LIST_TTL_JITTER, LIST_TTL_JITTER + 1); + return Duration.ofSeconds(LIST_TTL_BASE + jitter); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/cache/ProductCacheManager.java b/apps/commerce-api/src/main/java/com/loopers/application/cache/ProductCacheManager.java index 9d57dbd5b..9421b9fbc 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/cache/ProductCacheManager.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/cache/ProductCacheManager.java @@ -2,11 +2,7 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; -import com.loopers.application.brand.BrandInfo; import com.loopers.application.product.ProductFacade; -import com.loopers.application.product.ProductInfo; -import com.loopers.domain.brand.BrandService; -import com.loopers.domain.brand.Brand; import com.loopers.domain.product.ProductSortType; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -16,9 +12,10 @@ import org.springframework.transaction.support.TransactionSynchronizationManager; import java.time.Duration; -import java.util.ArrayList; import java.util.List; import java.util.Optional; +import java.util.Set; +import java.util.concurrent.ThreadLocalRandom; /** * 상품 캐시 매니저 @@ -37,19 +34,18 @@ public class ProductCacheManager { private static final String DETAIL_KEY_PREFIX = "products:detail:"; private static final String LIST_KEY_PREFIX = "products:list:"; - private static final Duration DETAIL_TTL = Duration.ofSeconds(300); - private static final Duration LIST_TTL = Duration.ofSeconds(60); + private static final int DETAIL_TTL_BASE = 300; + private static final int DETAIL_TTL_JITTER = 30; + private static final int LIST_TTL_BASE = 60; + private static final int LIST_TTL_JITTER = 10; private final RedisTemplate redisTemplate; private final ObjectMapper objectMapper; - private final BrandService brandService; public ProductCacheManager(RedisTemplate redisTemplate, - ObjectMapper objectMapper, - BrandService brandService) { + ObjectMapper objectMapper) { this.redisTemplate = redisTemplate; this.objectMapper = objectMapper; - this.brandService = brandService; } // ── 상품 상세 캐시 ── @@ -73,7 +69,7 @@ public void putProductDetail(Long productId, ProductFacade.ProductDetailResult r String key = DETAIL_KEY_PREFIX + productId; try { String json = objectMapper.writeValueAsString(result); - redisTemplate.opsForValue().set(key, json, DETAIL_TTL); + redisTemplate.opsForValue().set(key, json, ttlWithJitter(DETAIL_TTL_BASE, DETAIL_TTL_JITTER)); } catch (JsonProcessingException e) { log.warn("상품 상세 캐시 직렬화 실패 (productId={})", productId, e); } @@ -100,7 +96,7 @@ public void putProductList(ProductSortType sort, Long brandId, ProductFacade.Pro String key = listKey(sort, brandId); try { String json = objectMapper.writeValueAsString(result); - redisTemplate.opsForValue().set(key, json, LIST_TTL); + redisTemplate.opsForValue().set(key, json, ttlWithJitter(LIST_TTL_BASE, LIST_TTL_JITTER)); } catch (JsonProcessingException e) { log.warn("상품 목록 캐시 직렬화 실패 (sort={}, brandId={})", sort, brandId, e); } @@ -176,21 +172,13 @@ private void evictProductDetailBatch(List productIds) { /** * 상품 목록 캐시 전체 삭제. - * 4개 정렬 × (all + 활성 브랜드) 키를 열거해서 Collection 일괄 삭제. + * Redis SCAN으로 products:list:* 패턴 키를 찾아 일괄 삭제. + * DB 조회 없이 Redis 내에서 완결되므로 캐시 삭제를 위한 추가 DB 부하가 없다. */ private void evictAllProductListCache() { try { - List activeBrands = brandService.getAllActiveBrands(); - List keys = new ArrayList<>(); - - for (ProductSortType sort : ProductSortType.values()) { - keys.add(LIST_KEY_PREFIX + sort.name() + ":all"); - for (Brand brand : activeBrands) { - keys.add(LIST_KEY_PREFIX + sort.name() + ":" + brand.getId()); - } - } - - if (!keys.isEmpty()) { + Set keys = redisTemplate.keys(LIST_KEY_PREFIX + "*"); + if (keys != null && !keys.isEmpty()) { redisTemplate.delete(keys); } } catch (Exception e) { @@ -202,4 +190,9 @@ private String listKey(ProductSortType sort, Long brandId) { String brandPart = (brandId != null) ? String.valueOf(brandId) : "all"; return LIST_KEY_PREFIX + sort.name() + ":" + brandPart; } + + private Duration ttlWithJitter(int baseSeconds, int jitterRange) { + int jitter = ThreadLocalRandom.current().nextInt(-jitterRange, jitterRange + 1); + return Duration.ofSeconds(baseSeconds + jitter); + } } 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 49c9ef669..12784b741 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java @@ -1,5 +1,6 @@ package com.loopers.application.like; +import com.loopers.application.cache.ProductCacheManager; import com.loopers.domain.brand.Brand; import com.loopers.domain.brand.BrandService; import com.loopers.domain.like.BrandLike; @@ -30,33 +31,36 @@ public class LikeFacade { private final BrandLikeService brandLikeService; private final ProductService productService; private final BrandService brandService; + private final ProductCacheManager productCacheManager; public LikeFacade(LikeService likeService, BrandLikeService brandLikeService, - ProductService productService, BrandService brandService) { + ProductService productService, BrandService brandService, + ProductCacheManager productCacheManager) { this.likeService = likeService; this.brandLikeService = brandLikeService; this.productService = productService; this.brandService = brandService; + this.productCacheManager = productCacheManager; } - /** 상품 좋아요 (상품 검증 → 좋아요 생성 → likeCount 증가) */ + /** 상품 좋아요 (상품 검증 → 좋아요 생성 → likeCount 증가 → 캐시 무효화) */ @Transactional public LikeResult likeProduct(Long userId, Long productId) { - productService.getDisplayableProduct(productId); + Product product = productService.getDisplayableProduct(productId); likeService.like(userId, productId); productService.incrementLikeCount(productId); - Product updated = productService.getById(productId); - return new LikeResult(updated.getLikeCount()); + productCacheManager.registerEvictAfterCommit(productId); + return new LikeResult(product.getLikeCount() + 1); } - /** 상품 좋아요 취소 (상품 존재 검증 → 좋아요 삭제 → likeCount 감소) */ + /** 상품 좋아요 취소 (상품 존재 검증 → 좋아요 삭제 → likeCount 감소 → 캐시 무효화) */ @Transactional public LikeResult unlikeProduct(Long userId, Long productId) { - productService.getById(productId); + Product product = productService.getById(productId); likeService.unlike(userId, productId); productService.decrementLikeCount(productId); - Product updated = productService.getById(productId); - return new LikeResult(updated.getLikeCount()); + productCacheManager.registerEvictAfterCommit(productId); + return new LikeResult(product.getLikeCount() - 1); } /** 브랜드 좋아요 (활성 브랜드 검증 → 좋아요 생성) */ From 36e1432dcce66136e3555900c51ee05e03af5020 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B5=E1=86=B7=E1=84=82=E1=85=A1=E1=84=92?= =?UTF-8?q?=E1=85=A7=E1=86=AB?= Date: Wed, 18 Mar 2026 01:58:15 +0900 Subject: [PATCH 14/17] =?UTF-8?q?refactor:=20=EC=9D=B8=EB=8D=B1=EC=8A=A4?= =?UTF-8?q?=204=EA=B0=9C=EC=97=90=EC=84=9C=20deleted=5Fat=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0,=20=EC=A0=95=EB=A0=AC=20=EC=9D=B8=EB=8D=B1=EC=8A=A4?= =?UTF-8?q?=EB=A5=BC=20=EC=BB=A4=EC=84=9C=20=ED=8E=98=EC=9D=B4=EC=A7=80?= =?UTF-8?q?=EB=84=A4=EC=9D=B4=EC=85=98=20=EC=B5=9C=EC=A0=81=ED=99=94=20?= =?UTF-8?q?=EA=B5=AC=EC=A1=B0=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/loopers/infrastructure/product/ProductEntity.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductEntity.java index 3323e488d..7dd17afe5 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductEntity.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductEntity.java @@ -18,10 +18,10 @@ */ @Entity @Table(name = "products", indexes = { - @Index(name = "idx_products_latest", columnList = "created_at DESC, id DESC, status, deleted_at"), - @Index(name = "idx_products_price", columnList = "base_price ASC, id ASC, status, deleted_at"), - @Index(name = "idx_products_likes", columnList = "like_count DESC, id DESC, status, deleted_at"), - @Index(name = "idx_products_brand", columnList = "brand_id, status, deleted_at") + @Index(name = "idx_products_latest", columnList = "created_at DESC, id DESC"), + @Index(name = "idx_products_price", columnList = "base_price ASC, id ASC"), + @Index(name = "idx_products_likes", columnList = "like_count DESC, id DESC"), + @Index(name = "idx_products_brand", columnList = "brand_id, status") }) public class ProductEntity { From ab24290c18d8c11a802c5c03a0bd3af5d0106049 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B5=E1=86=B7=E1=84=82=E1=85=A1=E1=84=92?= =?UTF-8?q?=E1=85=A7=E1=86=AB?= Date: Wed, 18 Mar 2026 02:03:56 +0900 Subject: [PATCH 15/17] =?UTF-8?q?refactor:=20Caffeine=20=EB=A1=9C=EC=BB=AC?= =?UTF-8?q?=20=EC=BA=90=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/commerce-api/build.gradle.kts | 3 +++ .../product/ProductRepositoryImpl.java | 16 ++++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/apps/commerce-api/build.gradle.kts b/apps/commerce-api/build.gradle.kts index db169be04..659a5019d 100644 --- a/apps/commerce-api/build.gradle.kts +++ b/apps/commerce-api/build.gradle.kts @@ -9,6 +9,9 @@ dependencies { // web implementation("org.springframework.boot:spring-boot-starter-web") + // local cache + implementation("com.github.ben-manes.caffeine:caffeine") + // security (BCrypt) implementation("org.springframework.security:spring-security-crypto") implementation("org.springframework.boot:spring-boot-starter-actuator") 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 9331133c6..eca1f6f5c 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java @@ -1,5 +1,7 @@ package com.loopers.infrastructure.product; +import com.github.benmanes.caffeine.cache.Caffeine; +import com.github.benmanes.caffeine.cache.LoadingCache; import com.loopers.domain.brand.BrandStatus; import com.loopers.domain.common.CursorResult; import com.loopers.domain.product.Product; @@ -14,6 +16,7 @@ import com.querydsl.jpa.impl.JPAQueryFactory; import org.springframework.stereotype.Repository; +import java.time.Duration; import java.util.List; import java.util.Optional; @@ -27,11 +30,15 @@ @Repository public class ProductRepositoryImpl implements ProductRepository { + private static final String ACTIVE_BRAND_IDS_KEY = "activeBrandIds"; + private final ProductJpaRepository productJpaRepository; private final ProductMapper productMapper; private final JPAQueryFactory queryFactory; private final BrandJpaRepository brandJpaRepository; + private final LoadingCache> activeBrandIdsCache; + public ProductRepositoryImpl( ProductJpaRepository productJpaRepository, ProductMapper productMapper, @@ -42,6 +49,11 @@ public ProductRepositoryImpl( this.productMapper = productMapper; this.queryFactory = queryFactory; this.brandJpaRepository = brandJpaRepository; + + this.activeBrandIdsCache = Caffeine.newBuilder() + .expireAfterWrite(Duration.ofSeconds(30)) + .maximumSize(1) + .build(key -> loadActiveBrandIds()); } @Override @@ -187,6 +199,10 @@ public int decrementLikeCount(Long id) { } private List getActiveBrandIds() { + return activeBrandIdsCache.get(ACTIVE_BRAND_IDS_KEY); + } + + private List loadActiveBrandIds() { return brandJpaRepository.findAllByStatusAndDeletedAtIsNull(BrandStatus.ACTIVE) .stream() .map(BrandEntity::getId) From 8855e8e2a194a3af152c82a6385c3c1b88b277cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B5=E1=86=B7=E1=84=82=E1=85=A1=E1=84=92?= =?UTF-8?q?=E1=85=A7=E1=86=AB?= Date: Thu, 19 Mar 2026 05:44:16 +0900 Subject: [PATCH 16/17] =?UTF-8?q?refactor:=20Cache=20Decomposition(ID=20?= =?UTF-8?q?=EB=A6=AC=EC=8A=A4=ED=8A=B8=20+=20=EC=83=81=EC=84=B8=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC),=20SCAN,=20Redis=20=EC=9E=A5=EC=95=A0=20fal?= =?UTF-8?q?lback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../cache/ProductCacheManager.java | 190 ++++++++++++------ .../loopers/application/like/LikeFacade.java | 8 +- .../application/product/ProductFacade.java | 121 ++++++++++- 3 files changed, 245 insertions(+), 74 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/cache/ProductCacheManager.java b/apps/commerce-api/src/main/java/com/loopers/application/cache/ProductCacheManager.java index 9421b9fbc..6015ea865 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/cache/ProductCacheManager.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/cache/ProductCacheManager.java @@ -6,26 +6,35 @@ import com.loopers.domain.product.ProductSortType; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.data.redis.core.Cursor; import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ScanOptions; import org.springframework.stereotype.Component; import org.springframework.transaction.support.TransactionSynchronization; import org.springframework.transaction.support.TransactionSynchronizationManager; import java.time.Duration; +import java.util.HashMap; +import java.util.HashSet; import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.Set; import java.util.concurrent.ThreadLocalRandom; /** - * 상품 캐시 매니저 + * 상품 캐시 매니저 — Cache Decomposition 패턴 * - * Cache-Aside 패턴의 캐시 읽기/저장/무효화를 담당한다. - * 무효화 전략: afterCommit DELETE + TTL 안전망 + * 목록 캐시와 상세 캐시를 분리하여 무효화 범위를 최소화한다. * - * 키 설계: - * 상품 상세: products:detail:{productId} - * 상품 목록: products:list:{sort}:{brandId|all} + * 목록 캐시: products:list:{sort}:{brandId|all} → ID 리스트 + 메타데이터 + * 상세 캐시: products:detail:{productId} → ProductDetailResult (ProductInfo + BrandInfo) + * + * 조립 흐름 (첫 페이지): + * 1. 목록 캐시에서 ID 리스트 조회 + * 2. MGET으로 상세 캐시 일괄 조회 + * 3. 미스 ID만 DB에서 조회 후 캐시 적재 (partial miss 처리) + * 4. 원래 순서대로 조립하여 반환 */ @Component public class ProductCacheManager { @@ -48,89 +57,144 @@ public ProductCacheManager(RedisTemplate redisTemplate, this.objectMapper = objectMapper; } - // ── 상품 상세 캐시 ── + // ── 상품 상세 캐시 (단건) ── public Optional getProductDetail(Long productId) { - String key = DETAIL_KEY_PREFIX + productId; - String json = redisTemplate.opsForValue().get(key); - if (json == null) { - return Optional.empty(); - } try { + String key = DETAIL_KEY_PREFIX + productId; + String json = redisTemplate.opsForValue().get(key); + if (json == null) { + return Optional.empty(); + } return Optional.of(objectMapper.readValue(json, ProductFacade.ProductDetailResult.class)); - } catch (JsonProcessingException e) { - log.warn("상품 상세 캐시 역직렬화 실패 (productId={}), 캐시 삭제 후 DB 조회", productId, e); - redisTemplate.delete(key); + } catch (Exception e) { + log.warn("상품 상세 캐시 조회 실패 (productId={}), DB 폴백", productId, e); return Optional.empty(); } } public void putProductDetail(Long productId, ProductFacade.ProductDetailResult result) { - String key = DETAIL_KEY_PREFIX + productId; try { + String key = DETAIL_KEY_PREFIX + productId; String json = objectMapper.writeValueAsString(result); redisTemplate.opsForValue().set(key, json, ttlWithJitter(DETAIL_TTL_BASE, DETAIL_TTL_JITTER)); - } catch (JsonProcessingException e) { - log.warn("상품 상세 캐시 직렬화 실패 (productId={})", productId, e); + } catch (Exception e) { + log.warn("상품 상세 캐시 저장 실패 (productId={})", productId, e); } } - // ── 상품 목록 캐시 ── + // ── 상품 상세 캐시 (배치 — MGET) ── - public Optional getProductList(ProductSortType sort, Long brandId) { - String key = listKey(sort, brandId); - String json = redisTemplate.opsForValue().get(key); - if (json == null) { - return Optional.empty(); + public Map getProductDetailBatch(List productIds) { + if (productIds.isEmpty()) { + return Map.of(); + } + try { + List keys = productIds.stream() + .map(id -> DETAIL_KEY_PREFIX + id) + .toList(); + + List values = redisTemplate.opsForValue().multiGet(keys); + if (values == null) { + return Map.of(); + } + + Map result = new HashMap<>(); + for (int i = 0; i < productIds.size(); i++) { + String json = values.get(i); + if (json != null) { + try { + result.put(productIds.get(i), + objectMapper.readValue(json, ProductFacade.ProductDetailResult.class)); + } catch (JsonProcessingException e) { + log.warn("상품 상세 캐시 역직렬화 실패 (productId={})", productIds.get(i)); + } + } + } + return result; + } catch (Exception e) { + log.warn("상품 상세 배치 캐시 조회 실패, DB 폴백", e); + return Map.of(); } + } + + // ── 목록 캐시 (ID 리스트 + 메타데이터) ── + + public Optional getProductListIds(ProductSortType sort, Long brandId) { try { - return Optional.of(objectMapper.readValue(json, ProductFacade.ProductCursorResult.class)); - } catch (JsonProcessingException e) { - log.warn("상품 목록 캐시 역직렬화 실패 (sort={}, brandId={}), 캐시 삭제 후 DB 조회", sort, brandId, e); - redisTemplate.delete(key); + String key = listKey(sort, brandId); + String json = redisTemplate.opsForValue().get(key); + if (json == null) { + return Optional.empty(); + } + return Optional.of(objectMapper.readValue(json, ProductListCache.class)); + } catch (Exception e) { + log.warn("상품 목록 캐시 조회 실패 (sort={}, brandId={}), DB 폴백", sort, brandId, e); return Optional.empty(); } } - public void putProductList(ProductSortType sort, Long brandId, ProductFacade.ProductCursorResult result) { - String key = listKey(sort, brandId); + public void putProductListIds(ProductSortType sort, Long brandId, ProductListCache cache) { try { - String json = objectMapper.writeValueAsString(result); + String key = listKey(sort, brandId); + String json = objectMapper.writeValueAsString(cache); redisTemplate.opsForValue().set(key, json, ttlWithJitter(LIST_TTL_BASE, LIST_TTL_JITTER)); - } catch (JsonProcessingException e) { - log.warn("상품 목록 캐시 직렬화 실패 (sort={}, brandId={})", sort, brandId, e); + } catch (Exception e) { + log.warn("상품 목록 캐시 저장 실패 (sort={}, brandId={})", sort, brandId, e); } } // ── afterCommit 캐시 무효화 ── /** - * 상품 데이터 변경 시 호출. - * afterCommit 콜백에서 캐시를 삭제한다. TTL이 최종 안전망. + * 상세 캐시만 삭제 — 좋아요 변경 시 사용. + * 목록 캐시(ID 순서)는 TTL 만료에 의존한다. + */ + public void registerDetailOnlyEvictAfterCommit(Long productId) { + TransactionSynchronizationManager.registerSynchronization( + new TransactionSynchronization() { + @Override + public void afterCommit() { + redisTemplate.delete(DETAIL_KEY_PREFIX + productId); + } + } + ); + } + + /** + * 상세 + 목록 캐시 삭제 — 상품 정보 변경, 상태 변경, 삭제 시 사용. + * 목록의 구성원이나 순서가 바뀌는 이벤트에서 호출한다. * - * @param productId null이면 상세 캐시 삭제를 건너뛰고 목록만 삭제 + * @param productId null이면 상세 삭제를 건너뛰고 목록만 삭제 (신규 등록 시) */ public void registerEvictAfterCommit(Long productId) { TransactionSynchronizationManager.registerSynchronization( new TransactionSynchronization() { @Override public void afterCommit() { - evictProductCaches(productId); + if (productId != null) { + redisTemplate.delete(DETAIL_KEY_PREFIX + productId); + } + evictAllProductListCache(); } } ); } /** - * 브랜드 삭제 시 호출 — 소속 상품 상세 캐시 + 목록 캐시 전체 삭제. - * 브랜드 삭제 시 소속 상품이 전부 soft delete되므로 상세 캐시도 무효화 필수. + * 브랜드 삭제 시 — 소속 상품 상세 캐시 일괄 삭제 + 목록 캐시 전체 삭제. */ public void registerBrandDeleteEvictAfterCommit(List productIds) { TransactionSynchronizationManager.registerSynchronization( new TransactionSynchronization() { @Override public void afterCommit() { - evictProductDetailBatch(productIds); + if (productIds != null && !productIds.isEmpty()) { + List keys = productIds.stream() + .map(id -> DETAIL_KEY_PREFIX + id) + .toList(); + redisTemplate.delete(keys); + } evictAllProductListCache(); } } @@ -138,7 +202,7 @@ public void afterCommit() { } /** - * 브랜드 상태 변경 시 호출 — 상품 목록 캐시만 삭제 (상세는 브랜드 상태 무관). + * 브랜드 상태 변경 시 — 목록 캐시만 삭제 (상품 상세에는 영향 없음). */ public void registerListOnlyEvictAfterCommit() { TransactionSynchronizationManager.registerSynchronization( @@ -153,32 +217,23 @@ public void afterCommit() { // ── 내부 메서드 ── - private void evictProductCaches(Long productId) { - if (productId != null) { - redisTemplate.delete(DETAIL_KEY_PREFIX + productId); - } - evictAllProductListCache(); - } - - private void evictProductDetailBatch(List productIds) { - if (productIds == null || productIds.isEmpty()) { - return; - } - List keys = productIds.stream() - .map(id -> DETAIL_KEY_PREFIX + id) - .toList(); - redisTemplate.delete(keys); - } - /** - * 상품 목록 캐시 전체 삭제. - * Redis SCAN으로 products:list:* 패턴 키를 찾아 일괄 삭제. - * DB 조회 없이 Redis 내에서 완결되므로 캐시 삭제를 위한 추가 DB 부하가 없다. + * 목록 캐시 전체 삭제 — Redis SCAN 사용 (KEYS 대신). + * 어드민 변경 시에만 호출되므로 빈도가 낮다. */ private void evictAllProductListCache() { try { - Set keys = redisTemplate.keys(LIST_KEY_PREFIX + "*"); - if (keys != null && !keys.isEmpty()) { + ScanOptions options = ScanOptions.scanOptions() + .match(LIST_KEY_PREFIX + "*") + .count(100) + .build(); + Set keys = new HashSet<>(); + try (Cursor cursor = redisTemplate.scan(options)) { + while (cursor.hasNext()) { + keys.add(cursor.next()); + } + } + if (!keys.isEmpty()) { redisTemplate.delete(keys); } } catch (Exception e) { @@ -187,12 +242,19 @@ private void evictAllProductListCache() { } private String listKey(ProductSortType sort, Long brandId) { + String sortPart = (sort != null) ? sort.name() : "LATEST"; String brandPart = (brandId != null) ? String.valueOf(brandId) : "all"; - return LIST_KEY_PREFIX + sort.name() + ":" + brandPart; + return LIST_KEY_PREFIX + sortPart + ":" + brandPart; } private Duration ttlWithJitter(int baseSeconds, int jitterRange) { int jitter = ThreadLocalRandom.current().nextInt(-jitterRange, jitterRange + 1); return Duration.ofSeconds(baseSeconds + jitter); } + + public record ProductListCache( + List productIds, + boolean hasNext, + int size + ) {} } 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 12784b741..941587a66 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 @@ -43,23 +43,23 @@ public LikeFacade(LikeService likeService, BrandLikeService brandLikeService, this.productCacheManager = productCacheManager; } - /** 상품 좋아요 (상품 검증 → 좋아요 생성 → likeCount 증가 → 캐시 무효화) */ + /** 상품 좋아요 (상품 검증 → 좋아요 생성 → likeCount 증가 → 상세 캐시만 삭제) */ @Transactional public LikeResult likeProduct(Long userId, Long productId) { Product product = productService.getDisplayableProduct(productId); likeService.like(userId, productId); productService.incrementLikeCount(productId); - productCacheManager.registerEvictAfterCommit(productId); + productCacheManager.registerDetailOnlyEvictAfterCommit(productId); return new LikeResult(product.getLikeCount() + 1); } - /** 상품 좋아요 취소 (상품 존재 검증 → 좋아요 삭제 → likeCount 감소 → 캐시 무효화) */ + /** 상품 좋아요 취소 (상품 존재 검증 → 좋아요 삭제 → likeCount 감소 → 상세 캐시만 삭제) */ @Transactional public LikeResult unlikeProduct(Long userId, Long productId) { Product product = productService.getById(productId); likeService.unlike(userId, productId); productService.decrementLikeCount(productId); - productCacheManager.registerEvictAfterCommit(productId); + productCacheManager.registerDetailOnlyEvictAfterCommit(productId); return new LikeResult(product.getLikeCount() - 1); } 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 588821881..7557eb84d 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 @@ -10,13 +10,24 @@ import com.loopers.domain.product.ProductService; import com.loopers.domain.product.ProductSortType; import org.springframework.stereotype.Component; + +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.Optional; +import java.util.function.Function; +import java.util.stream.Collectors; /** * 고객 상품 조회 Facade * * Product + Brand 도메인 서비스를 조합하여 고객 상품 조회 유스케이스를 처리한다. + * + * 캐시 전략: + * - @Transactional 미사용: 캐시 히트 시 DB 커넥션을 점유하지 않는다. + * 각 서비스 메서드가 자체 @Transactional을 관리한다. + * - Cache Decomposition: 목록 캐시(ID 리스트)와 상세 캐시(ProductDetailResult)를 분리. + * 상품 정보 변경 시 상세 캐시만 삭제하면 되므로 무효화 범위가 최소화된다. */ @Component public class ProductFacade { @@ -32,7 +43,10 @@ public ProductFacade(ProductService productService, BrandService brandService, this.productCacheManager = productCacheManager; } - /** 고객 상품 상세 조회 (상품 + 브랜드명) — Cache-Aside */ + /** + * 고객 상품 상세 조회 — Cache-Aside. + * @Transactional 없음: 캐시 히트 시 DB 커넥션 미사용. + */ public ProductDetailResult getProductDetail(Long productId) { Optional cached = productCacheManager.getProductDetail(productId); if (cached.isPresent()) { @@ -47,14 +61,25 @@ public ProductDetailResult getProductDetail(Long productId) { return result; } - /** 고객 상품 목록 커서 조회 (COUNT 쿼리 없음) — 첫 페이지만 Cache-Aside */ + /** + * 고객 상품 목록 커서 조회 — Cache Decomposition + 첫 페이지 캐싱. + * + * 첫 페이지(cursor=null): + * 1. 목록 캐시(ID 리스트) 조회 + * 2. MGET으로 상세 캐시 일괄 조회 + * 3. partial miss ID만 DB에서 조회 후 캐시 적재 + * 4. 원래 순서대로 조립 + * + * 이후 페이지: DB 직접 조회 (캐시 미사용) + */ public ProductCursorResult getDisplayableProductsWithCursor(Long brandId, ProductSortType sort, ProductCursor cursor, int size) { boolean isFirstPage = (cursor == null); if (isFirstPage) { - Optional cached = productCacheManager.getProductList(sort, brandId); - if (cached.isPresent()) { - return cached.get(); + Optional cachedIds = + productCacheManager.getProductListIds(sort, brandId); + if (cachedIds.isPresent()) { + return assembleFromCache(cachedIds.get()); } } @@ -67,12 +92,96 @@ public ProductCursorResult getDisplayableProductsWithCursor(Long brandId, Produc ProductCursorResult cursorResult = new ProductCursorResult(productInfos, result.hasNext(), size); if (isFirstPage) { - productCacheManager.putProductList(sort, brandId, cursorResult); + cacheFirstPage(result.items(), result.hasNext(), size, sort, brandId); } return cursorResult; } + /** + * 캐시된 ID 리스트 + 상세 캐시로 커서 목록 응답 조립. + * partial miss 발생 시 미스 ID만 DB에서 조회 후 캐시 적재. + */ + private ProductCursorResult assembleFromCache(ProductCacheManager.ProductListCache listCache) { + List productIds = listCache.productIds(); + + Map detailMap = new HashMap<>( + productCacheManager.getProductDetailBatch(productIds)); + + List missingIds = productIds.stream() + .filter(id -> !detailMap.containsKey(id)) + .toList(); + + if (!missingIds.isEmpty()) { + detailMap.putAll(loadAndCacheProductDetails(missingIds)); + } + + Map finalDetailMap = Map.copyOf(detailMap); + List productInfos = productIds.stream() + .filter(finalDetailMap::containsKey) + .map(id -> finalDetailMap.get(id).product()) + .toList(); + + return new ProductCursorResult(productInfos, listCache.hasNext(), listCache.size()); + } + + /** + * 첫 페이지 DB 조회 결과를 목록 캐시(ID) + 상세 캐시(상품+브랜드)에 적재. + */ + private void cacheFirstPage(List products, boolean hasNext, int size, + ProductSortType sort, Long brandId) { + List ids = products.stream().map(Product::getId).toList(); + productCacheManager.putProductListIds(sort, brandId, + new ProductCacheManager.ProductListCache(ids, hasNext, size)); + + cacheProductDetails(products); + } + + /** + * 상품 목록에 대해 브랜드 정보를 조회하여 상세 캐시에 일괄 적재. + */ + private void cacheProductDetails(List products) { + if (products.isEmpty()) { + return; + } + List brandIds = products.stream() + .map(Product::getBrandId).distinct().toList(); + Map brandMap = brandService.getBrandsByIds(brandIds).stream() + .collect(Collectors.toMap(Brand::getId, Function.identity())); + + for (Product product : products) { + Brand brand = brandMap.get(product.getBrandId()); + if (brand != null) { + productCacheManager.putProductDetail(product.getId(), + new ProductDetailResult(ProductInfo.from(product), BrandInfo.from(brand))); + } + } + } + + /** + * partial miss ID들을 DB에서 조회하여 상세 캐시에 적재 후 반환. + */ + private Map loadAndCacheProductDetails(List missingIds) { + List products = productService.getProductsByIds(missingIds); + + List brandIds = products.stream() + .map(Product::getBrandId).distinct().toList(); + Map brandMap = brandService.getBrandsByIds(brandIds).stream() + .collect(Collectors.toMap(Brand::getId, Function.identity())); + + Map result = new HashMap<>(); + for (Product product : products) { + Brand brand = brandMap.get(product.getBrandId()); + if (brand != null) { + ProductDetailResult detail = new ProductDetailResult( + ProductInfo.from(product), BrandInfo.from(brand)); + result.put(product.getId(), detail); + productCacheManager.putProductDetail(product.getId(), detail); + } + } + return result; + } + public record ProductDetailResult(ProductInfo product, BrandInfo brand) {} public record ProductCursorResult( From 99afc9ba36d4b35a2fe9177165f73538f4a0f713 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B5=E1=86=B7=E1=84=82=E1=85=A1=E1=84=92?= =?UTF-8?q?=E1=85=A7=E1=86=AB?= Date: Thu, 19 Mar 2026 19:46:40 +0900 Subject: [PATCH 17/17] =?UTF-8?q?test:=20=EB=AA=A9=EB=A1=9D=20=EC=BA=90?= =?UTF-8?q?=EC=8B=9C=EA=B0=80=20ID=EB=A7=8C=20=EC=A0=80=EC=9E=A5=ED=95=98?= =?UTF-8?q?=EB=8A=94=20=EA=B5=AC=EC=A1=B0=EB=A1=9C=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=EC=BD=94=EB=93=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/cache/ProductCacheIntegrationTest.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/commerce-api/src/test/java/com/loopers/application/cache/ProductCacheIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/cache/ProductCacheIntegrationTest.java index 2f1170db3..7a5313bcc 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/cache/ProductCacheIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/cache/ProductCacheIntegrationTest.java @@ -157,12 +157,12 @@ class ProductListCache { // act productFacade.getDisplayableProductsWithCursor(null, ProductSortType.LATEST, null, 20); - // assert + // assert — 목록 캐시는 ID만 저장하는 구조 String key = "products:list:LATEST:all"; String cached = redisTemplate.opsForValue().get(key); assertThat(cached).isNotNull(); - assertThat(cached).contains("상품1"); - assertThat(cached).contains("상품2"); + assertThat(cached).contains("productIds"); + assertThat(cached).contains("hasNext"); } @Test