diff --git a/.codeguide/loopers-1-week.md b/.codeguide/loopers-1-week.md index a8ace53e5..955cded63 100644 --- a/.codeguide/loopers-1-week.md +++ b/.codeguide/loopers-1-week.md @@ -43,3 +43,16 @@ - [ ] 포인트 조회에 성공할 경우, 보유 포인트를 응답으로 반환한다. - [ ] `X-USER-ID` 헤더가 없을 경우, `400 Bad Request` 응답을 반환한다. + + +### 포인트 충전 + +**🧱 단위 테스트** +- [ ] 이하의 정수로 포인트를 충전 시 실패한다. + +**🔗 통합 테스트** +- [ ] 존재하지 않는 유저 ID 로 충전을 시도한 경우, 실패한다. + +**🌐 E2E 테스트** +- [ ] 포인트 충전이 성공할 경우, 업데이트된 보유 포인트를 응답으로 반환한다. +- [ ] 존재하지 않는 유저로 요청할 경우, 404 Not Found 응답을 반환한다. diff --git a/.gitignore b/.gitignore index 5a979af6f..907fa771b 100644 --- a/.gitignore +++ b/.gitignore @@ -4,7 +4,9 @@ build/ !gradle/wrapper/gradle-wrapper.jar !**/src/main/**/build/ !**/src/test/**/build/ - +.kiro +apps/commerce-api/src/main/generated/** +docs/project-analysis ### STS ### .apt_generated .classpath diff --git a/apps/commerce-api/build.gradle.kts b/apps/commerce-api/build.gradle.kts index 03ce68f02..6173c7676 100644 --- a/apps/commerce-api/build.gradle.kts +++ b/apps/commerce-api/build.gradle.kts @@ -11,6 +11,10 @@ dependencies { implementation("org.springframework.boot:spring-boot-starter-actuator") implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:${project.properties["springDocOpenApiVersion"]}") + + // faker + implementation("net.datafaker:datafaker:2.0.2") + // querydsl annotationProcessor("com.querydsl:querydsl-apt::jakarta") annotationProcessor("jakarta.persistence:jakarta.persistence-api") diff --git a/apps/commerce-api/src/main/java/com/loopers/CommerceApiApplication.java b/apps/commerce-api/src/main/java/com/loopers/CommerceApiApplication.java index 9027b51bf..12bb5df25 100644 --- a/apps/commerce-api/src/main/java/com/loopers/CommerceApiApplication.java +++ b/apps/commerce-api/src/main/java/com/loopers/CommerceApiApplication.java @@ -1,13 +1,17 @@ package com.loopers; -import jakarta.annotation.PostConstruct; +import java.util.TimeZone; + import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.context.properties.ConfigurationPropertiesScan; -import java.util.TimeZone; +import org.springframework.scheduling.annotation.EnableScheduling; + +import jakarta.annotation.PostConstruct; @ConfigurationPropertiesScan @SpringBootApplication +@EnableScheduling public class CommerceApiApplication { @PostConstruct diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandInfo.java new file mode 100644 index 000000000..ebba7c144 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandInfo.java @@ -0,0 +1,12 @@ +package com.loopers.application.brand; + +/** + * @author hyunjikoh + * @since 2025. 11. 10. + */ +public record BrandInfo( + Long id, + String name, + String description +) { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/example/ExampleFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/example/ExampleFacade.java index 552a9ad62..e7481a8ec 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/example/ExampleFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/example/ExampleFacade.java @@ -1,9 +1,11 @@ package com.loopers.application.example; +import org.springframework.stereotype.Component; + import com.loopers.domain.example.ExampleModel; import com.loopers.domain.example.ExampleService; + import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; @RequiredArgsConstructor @Component diff --git a/apps/commerce-api/src/main/java/com/loopers/application/example/ExampleInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/example/ExampleInfo.java index 877aba96c..d1f70cdf7 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/example/ExampleInfo.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/example/ExampleInfo.java @@ -5,9 +5,9 @@ public record ExampleInfo(Long id, String name, String description) { public static ExampleInfo from(ExampleModel model) { return new ExampleInfo( - model.getId(), - model.getName(), - model.getDescription() + model.getId(), + model.getName(), + model.getDescription() ); } } 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 new file mode 100644 index 000000000..8c6bd8b4b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java @@ -0,0 +1,83 @@ +package com.loopers.application.like; + +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import com.loopers.domain.like.LikeResult; +import com.loopers.domain.like.LikeService; +import com.loopers.domain.product.ProductCacheService; +import com.loopers.domain.product.ProductEntity; +import com.loopers.domain.product.ProductService; +import com.loopers.domain.user.UserEntity; +import com.loopers.domain.user.UserService; +import com.loopers.support.error.CoreException; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * 좋아요 애플리케이션 파사드 + * + * 좋아요 관련 유스케이스를 조정합니다. + * 여러 도메인 서비스(User, Product, Like)를 조합하여 완전한 비즈니스 흐름을 구현합니다. + * + * @author hyunjikoh + * @since 2025. 11. 11. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class LikeFacade { + private final ProductService productService; + private final UserService userService; + private final LikeService likeService; + private final ProductCacheService cacheService; + + /** + * 좋아요를 등록하거나 복원합니다. + * + * @param username 사용자명 + * @param productId 상품 ID + * @return 좋아요 정보 DTO + * @throws CoreException 사용자 또는 상품을 찾을 수 없는 경우 + */ + @Transactional + public LikeInfo upsertLike(String username, Long productId) { + log.debug("좋아요 등록/복원 시작 - username: {}, productId: {}", username, productId); + + // 1. 사용자 검증 + UserEntity user = userService.getUserByUsername(username); + + // 2. 상품 검증 + ProductEntity product = productService.getActiveProductDetail(productId); + + // 3. 좋아요 등록/복원 (실제 변경 여부 확인) + LikeResult result = likeService.upsertLike(user, product); + + // 5. DTO 변환 후 반환 + return LikeInfo.of(result.entity(), product, user); + } + + /** + * 좋아요를 취소합니다. + * + * 좋아요를 삭제하고 상품의 좋아요 카운트를 감소시킵니다. + * + * @param username 사용자명 + * @param productId 상품 ID + * @throws CoreException 사용자 또는 상품을 찾을 수 없는 경우 + */ + @Transactional + public void unlikeProduct(String username, Long productId) { + log.debug("좋아요 취소 시작 - username: {}, productId: {}", username, productId); + + // 1. 사용자 검증 + UserEntity user = userService.getUserByUsername(username); + + // 2. 상품 검증 + ProductEntity product = productService.getActiveProductDetail(productId); + + // 3. 좋아요 취소 + likeService.unlikeProduct(user, product); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeInfo.java new file mode 100644 index 000000000..6552111bf --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeInfo.java @@ -0,0 +1,27 @@ +package com.loopers.application.like; + +import java.time.ZonedDateTime; + +import com.loopers.domain.like.LikeEntity; +import com.loopers.domain.product.ProductEntity; +import com.loopers.domain.user.UserEntity; + +/** + * @author hyunjikoh + * @since 2025. 11. 12. + */ +public record LikeInfo( + String username, + Long productId, + String productName, + ZonedDateTime createdAt +) { + public static LikeInfo of(LikeEntity like, ProductEntity product, UserEntity user) { + return new LikeInfo( + user.getUsername(), + product.getId(), + product.getName(), + like.getCreatedAt() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderCreateCommand.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderCreateCommand.java new file mode 100644 index 000000000..91af40c53 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderCreateCommand.java @@ -0,0 +1,18 @@ +package com.loopers.application.order; + +import java.util.List; + +import lombok.Builder; + +/** + * 주문 생성 커맨드 + * + * @author hyunjikoh + * @since 2025. 11. 14. + */ +@Builder +public record OrderCreateCommand( + String username, + List orderItems +) { +} 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 new file mode 100644 index 000000000..d075e8832 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java @@ -0,0 +1,272 @@ +package com.loopers.application.order; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Objects; +import java.util.stream.IntStream; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import com.loopers.domain.coupon.CouponEntity; +import com.loopers.domain.coupon.CouponService; +import com.loopers.domain.order.OrderEntity; +import com.loopers.domain.order.OrderItemEntity; +import com.loopers.domain.order.OrderService; +import com.loopers.domain.order.OrderStatus; +import com.loopers.domain.order.dto.OrderCreationResult; +import com.loopers.domain.point.PointService; +import com.loopers.domain.product.ProductEntity; +import com.loopers.domain.product.ProductService; +import com.loopers.domain.user.UserEntity; +import com.loopers.domain.user.UserService; + +import lombok.RequiredArgsConstructor; + +/** + * 주문 Facade + * + * 주문 생성, 확정, 취소 등의 유스케이스를 조정합니다. + * + * @author hyunjikoh + * @since 2025. 11. 14. + */ +@Component +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class OrderFacade { + + private final OrderService orderService; + private final UserService userService; + private final ProductService productService; + private final PointService pointService; + private final CouponService couponService; + + /** + * 주문 생성 + * + * 여러 도메인 서비스를 조정하여 주문 생성 유스케이스를 완성합니다. + * + * @param command 주문 생성 명령 + * @return 생성된 주문 정보 + * @throws IllegalArgumentException 재고 부족 또는 주문 불가능한 경우 + */ + @Transactional + public OrderInfo createOrder(OrderCreateCommand command) { + // 1. 주문자 정보 조회 (락 적용) + UserEntity user = userService.findByUsernameWithLock(command.username()); + + // 2. 주문 항목을 상품 ID 기준으로 정렬 (교착 상태 방지) + List sortedItems = command.orderItems().stream() + .sorted(Comparator.comparing(OrderItemCommand::productId)) + .toList(); + + // 3. 상품 검증 및 준비 (재고 확인, 락 적용) + List orderableProducts = new ArrayList<>(); + List coupons = new ArrayList<>(); + List quantities = new ArrayList<>(); + + for (OrderItemCommand itemCommand : sortedItems) { + // 상품 정보 조회 및 재고 잠금 + ProductEntity product = productService.getProductDetailLock(itemCommand.productId()); + + // 재고 확인 + if (!product.canOrder(itemCommand.quantity())) { + throw new IllegalArgumentException( + String.format("주문할 수 없는 상품입니다. 상품 ID: %d, 요청 수량: %d, 현재 재고: %d", + product.getId(), itemCommand.quantity(), product.getStockQuantity()) + ); + } + + // 쿠폰 검증 및 준비 + CouponEntity coupon = itemCommand.couponId() != null + ? couponService.getCouponByIdAndUserId(itemCommand.couponId(), user.getId()) + : null; + if (coupon != null && coupon.isUsed()) { + throw new IllegalArgumentException("이미 사용된 쿠폰입니다."); + } + + orderableProducts.add(product); + coupons.add(coupon); + quantities.add(itemCommand.quantity()); + } + + // 4. 도메인 서비스: 주문 및 주문 항목 생성 (도메인 로직) + OrderCreationResult creationResult = orderService.createOrderWithItems( + user.getId(), + orderableProducts, + coupons, + quantities + ); + + // 5. 포인트 차감 + pointService.use(user, creationResult.order().getFinalTotalAmount()); + + // 6. 쿠폰 사용 처리 + coupons.stream().filter(Objects::nonNull).forEach(couponService::consumeCoupon); + + IntStream.range(0, orderableProducts.size()) + .forEach(i -> productService.deductStock(orderableProducts.get(i), quantities.get(i))); + + // 8. 주문 정보 반환 + return OrderInfo.from(creationResult.order(), creationResult.orderItems()); + } + + /** + * 주문 확정 + * + * 주문을 확정합니다. (재고는 이미 주문 생성 시 차감되었음) + * + * @param orderId 주문 ID + * @param username 사용자명 + * @return 확정된 주문 정보 + */ + @Transactional + public OrderInfo confirmOrder(Long orderId, String username) { + UserEntity user = userService.getUserByUsername(username); + + // 1. 주문 확정 + OrderEntity order = orderService.getOrderByIdAndUserId(orderId, user.getId()); + order.confirmOrder(); + + // 2. 주문 항목 조회 + List orderItems = orderService.getOrderItemsByOrderId(orderId); + + return OrderInfo.from(order, orderItems); + } + + /** + * 주문 취소 + * + * 여러 도메인 서비스를 조정하여 주문 취소 유스케이스를 완성합니다. + * 주문을 취소하고 차감된 재고를 원복하며 포인트를 환불합니다. + * + * @param orderId 주문 ID + * @param username 사용자명 (포인트 환불용) + * @return 취소된 주문 정보 + */ + @Transactional + public OrderInfo cancelOrder(Long orderId, String username) { + // 1. 사용자 조회 + UserEntity user = userService.getUserByUsername(username); + + // 2. 주문 조회 + OrderEntity order = orderService.getOrderByIdAndUserId(orderId, user.getId()); + + // 3. 도메인 서비스: 주문 취소 처리 (도메인 로직) + List orderItems = orderService.cancelOrderDomain(order); + + // 4. 재고 원복 + for (OrderItemEntity orderItem : orderItems) { + productService.restoreStock(orderItem.getProductId(), orderItem.getQuantity()); + } + + // 5. 쿠폰 원복 + orderItems.stream() + .filter(orderItem -> orderItem.getCouponId() != null) + .forEach(orderItem -> { + CouponEntity coupon = couponService.getCouponByIdAndUserId( + orderItem.getCouponId(), + order.getUserId() + ); + if (!coupon.isUsed()) { + throw new IllegalStateException("취소하려는 주문의 쿠폰이 사용된 상태가 아닙니다."); + } + couponService.revertCoupon(coupon); + }); + + // 6. 포인트 환불 (할인 후 금액으로) + pointService.refund(username, order.getFinalTotalAmount()); + + return OrderInfo.from(order, orderItems); + } + + /** + * 주문 ID로 주문 조회 + * + * @param username 사용자명 + * @param orderId 주문 ID + * @return 주문 정보 + */ + public OrderInfo getOrderById(String username, Long orderId) { + UserEntity user = userService.getUserByUsername(username); + OrderEntity order = orderService.getOrderByIdAndUserId(orderId, user.getId()); + List orderItems = orderService.getOrderItemsByOrderId(orderId); + return OrderInfo.from(order, orderItems); + } + + /** + * 사용자 ID로 주문 요약 목록을 페이징하여 조회합니다. + * 주문 항목 정보는 포함하지 않고 항목 개수만 포함합니다. + * + * @param userId 사용자 ID + * @param pageable 페이징 정보 + * @return 페이징된 주문 요약 정보 목록 + */ + public Page getOrderSummariesByUserId(Long userId, Pageable pageable) { + Page orderPage = orderService.getOrdersByUserId(userId, pageable); + return orderPage.map(order -> { + int itemCount = orderService.countOrderItems(order.getId()); + return OrderSummary.from(order, itemCount); + }); + } + + /** + * 사용자 ID와 주문 상태로 주문 요약 목록을 페이징하여 조회합니다. + * + * @param userId 사용자 ID + * @param status 주문 상태 + * @param pageable 페이징 정보 + * @return 페이징된 주문 요약 정보 목록 + */ + public Page getOrderSummariesByUserIdAndStatus( + Long userId, + OrderStatus status, + Pageable pageable) { + Page orderPage = orderService.getOrdersByUserIdAndStatus(userId, status, pageable); + return orderPage.map(order -> { + int itemCount = orderService.countOrderItems(order.getId()); + return OrderSummary.from(order, itemCount); + }); + } + + /** + * 주문 ID로 주문 요약 정보를 조회합니다. + * + * @param orderId 주문 ID + * @return 주문 요약 정보 + */ + public OrderSummary getOrderSummaryById(Long orderId, String username) { + UserEntity user = userService.getUserByUsername(username); + + OrderEntity order = orderService.getOrderByIdAndUserId(orderId, user.getId()); + int itemCount = orderService.countOrderItems(orderId); + return OrderSummary.from(order, itemCount); + } + + /** + * 주문 ID로 주문 항목 목록을 페이징하여 조회합니다. + * + * @param orderId 주문 ID + * @param username 사용자명 + * @param pageable 페이징 정보 + * @return 주문 항목 정보 페이지 + */ + public Page getOrderItemsByOrderId( + Long orderId, + String username, + Pageable pageable) { + UserEntity user = userService.getUserByUsername(username); + + // 주문 존재 여부 확인 + orderService.getOrderByIdAndUserId(orderId, user.getId()); + + // 주문 항목 페이징 조회 + Page orderItemsPage = + orderService.getOrderItemsByOrderId(orderId, pageable); + return orderItemsPage.map(OrderItemInfo::from); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java new file mode 100644 index 000000000..24f594e39 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java @@ -0,0 +1,51 @@ +package com.loopers.application.order; + +import java.math.BigDecimal; +import java.time.ZonedDateTime; +import java.util.List; + +import com.loopers.domain.order.OrderEntity; +import com.loopers.domain.order.OrderItemEntity; +import com.loopers.domain.order.OrderStatus; + +/** + * 주문 정보 DTO + * + * @author hyunjikoh + * @since 2025. 11. 14. + */ +public record OrderInfo( + Long id, + Long userId, + BigDecimal originalTotalAmount, + BigDecimal discountAmount, + BigDecimal finalTotalAmount, + OrderStatus status, + List orderItems, + ZonedDateTime createdAt, + ZonedDateTime updatedAt +) { + + /** + * OrderEntity로부터 OrderInfo를 생성합니다. + * + * @param order 주문 엔티티 + * @param orderItems 주문 항목 엔티티 목록 + * @return OrderInfo + */ + public static OrderInfo from(OrderEntity order, List orderItems) { + return new OrderInfo( + order.getId(), + order.getUserId(), + order.getOriginalTotalAmount(), + order.getDiscountAmount(), + order.getFinalTotalAmount(), + order.getStatus(), + orderItems.stream() + .map(OrderItemInfo::from) + .toList(), + order.getCreatedAt(), + order.getUpdatedAt() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderItemCommand.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderItemCommand.java new file mode 100644 index 000000000..e3c90b1c0 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderItemCommand.java @@ -0,0 +1,17 @@ +package com.loopers.application.order; + +import lombok.Builder; + +/** + * 주문 항목 커맨드 + * + * @author hyunjikoh + * @since 2025. 11. 14. + */ +@Builder +public record OrderItemCommand( + Long productId, + Integer quantity, + Long couponId +) { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderItemInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderItemInfo.java new file mode 100644 index 000000000..f22573250 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderItemInfo.java @@ -0,0 +1,45 @@ +package com.loopers.application.order; + +import java.math.BigDecimal; +import java.time.ZonedDateTime; + +import com.loopers.domain.order.OrderItemEntity; + +/** + * 주문 항목 정보 DTO + * + * @author hyunjikoh + * @since 2025. 11. 14. + */ +public record OrderItemInfo( + Long id, + Long orderId, + Long productId, + Integer quantity, + BigDecimal unitPrice, + BigDecimal discountAmount, + BigDecimal totalPrice, + ZonedDateTime createdAt, + ZonedDateTime updatedAt +) { + + /** + * OrderItemEntity로부터 OrderItemInfo를 생성합니다. + * + * @param orderItem 주문 항목 엔티티 + * @return OrderItemInfo + */ + public static OrderItemInfo from(OrderItemEntity orderItem) { + return new OrderItemInfo( + orderItem.getId(), + orderItem.getOrderId(), + orderItem.getProductId(), + orderItem.getQuantity(), + orderItem.getUnitPrice(), + orderItem.getDiscountAmount(), + orderItem.getTotalPrice(), + orderItem.getCreatedAt(), + orderItem.getUpdatedAt() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderSummary.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderSummary.java new file mode 100644 index 000000000..73f7a7069 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderSummary.java @@ -0,0 +1,48 @@ +package com.loopers.application.order; + +import java.math.BigDecimal; +import java.time.ZonedDateTime; + +import com.loopers.domain.order.OrderEntity; +import com.loopers.domain.order.OrderStatus; + +/** + * 주문 목록 조회용 요약 정보 DTO + * 주문 항목 정보를 포함하지 않아 가볍게 조회 가능 + * + * @author hyunjikoh + * @since 2025. 11. 14. + */ +public record OrderSummary( + Long id, + Long userId, + BigDecimal originalTotalAmount, + BigDecimal discountAmount, + BigDecimal finalTotalAmount, + OrderStatus status, + int itemCount, + ZonedDateTime createdAt, + ZonedDateTime updatedAt +) { + + /** + * OrderEntity로부터 OrderSummary를 생성합니다. + * + * @param order 주문 엔티티 + * @param itemCount 주문 항목 개수 + * @return OrderSummary + */ + public static OrderSummary from(OrderEntity order, int itemCount) { + return new OrderSummary( + order.getId(), + order.getUserId(), + order.getOriginalTotalAmount(), + order.getDiscountAmount(), + order.getFinalTotalAmount(), + order.getStatus(), + itemCount, + order.getCreatedAt(), + order.getUpdatedAt() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/point/PointFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/point/PointFacade.java new file mode 100644 index 000000000..c2ea5b3ca --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/point/PointFacade.java @@ -0,0 +1,43 @@ +package com.loopers.application.point; + +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import com.loopers.domain.point.PointService; +import com.loopers.domain.user.UserEntity; +import com.loopers.domain.user.UserService; +import com.loopers.interfaces.api.point.PointV1Dtos; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; + +import lombok.RequiredArgsConstructor; + +/** + * @author hyunjikoh + * @since 2025. 10. 29. + */ + +@Component +@RequiredArgsConstructor +public class PointFacade { + private final PointService pointService; + private final UserService userService; + + @Transactional(readOnly = true) + public PointV1Dtos.PointInfo getPointInfo(String username) { + UserEntity user = userService.getUserByUsername(username); + + // 포인트 정보가 없는 경우를 사용자 없는 정보로 간주. + if (user == null) { + throw new CoreException(ErrorType.NOT_FOUND_USER); + } + return PointV1Dtos.PointInfo.from(user); + } + + @Transactional + public PointV1Dtos.PointChargeResponse chargePoint(String username, PointV1Dtos.PointChargeRequest request) { + java.math.BigDecimal totalAmount = pointService.charge(username, request.amount()); + + return new PointV1Dtos.PointChargeResponse(username, totalAmount); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/BatchUpdateResult.java b/apps/commerce-api/src/main/java/com/loopers/application/product/BatchUpdateResult.java new file mode 100644 index 000000000..e783c1608 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/BatchUpdateResult.java @@ -0,0 +1,122 @@ +package com.loopers.application.product; + +import java.util.Collections; +import java.util.Set; + +import lombok.Getter; + +/** + * 배치 업데이트 결과 DTO + * + * MV 배치 동기화 작업의 결과를 담는 불변 객체 + * 변경된 상품/브랜드 ID를 포함하여 캐시 무효화에 사용 + */ +@Getter +public class BatchUpdateResult { + + private final boolean success; + private final int createdCount; + private final int updatedCount; + private final long durationMs; + private final String errorMessage; + + // 변경된 상품 ID 목록 (생성 또는 갱신된 상품) + private final Set changedProductIds; + + // 변경된 상품들이 속한 브랜드 ID 목록 + private final Set affectedBrandIds; + + private BatchUpdateResult( + boolean success, + int createdCount, + int updatedCount, + long durationMs, + String errorMessage, + Set changedProductIds, + Set affectedBrandIds + ) { + this.success = success; + this.createdCount = createdCount; + this.updatedCount = updatedCount; + this.durationMs = durationMs; + this.errorMessage = errorMessage; + this.changedProductIds = changedProductIds != null + ? Collections.unmodifiableSet(changedProductIds) + : Collections.emptySet(); + this.affectedBrandIds = affectedBrandIds != null + ? Collections.unmodifiableSet(affectedBrandIds) + : Collections.emptySet(); + } + + /** + * 성공 결과 생성 + */ + public static BatchUpdateResult success( + int createdCount, + int updatedCount, + long durationMs, + Set changedProductIds, + Set affectedBrandIds + ) { + return new BatchUpdateResult( + true, + createdCount, + updatedCount, + durationMs, + null, + changedProductIds, + affectedBrandIds + ); + } + + /** + * 성공 결과 생성 (변경 추적 없음) + */ + public static BatchUpdateResult success(int createdCount, int updatedCount, long durationMs) { + return success(createdCount, updatedCount, durationMs, Collections.emptySet(), Collections.emptySet()); + } + + /** + * 실패 결과 생성 + */ + public static BatchUpdateResult failure(String errorMessage, long durationMs) { + return new BatchUpdateResult( + false, + 0, + 0, + durationMs, + errorMessage, + Collections.emptySet(), + Collections.emptySet() + ); + } + + /** + * 변경 사항 있는지 확인 + */ + public boolean hasChanges() { + return success && (createdCount > 0 || updatedCount > 0); + } + + /** + * 전체 처리 건수 (생성 + 갱신) + */ + public int getTotalCount() { + return createdCount + updatedCount; + } + + @Override + public String toString() { + if (success) { + return String.format( + "BatchUpdateResult{success=true, created=%d, updated=%d, duration=%dms}", + createdCount, updatedCount, durationMs + ); + } else { + return String.format( + "BatchUpdateResult{success=false, error='%s', duration=%dms}", + errorMessage, durationMs + ); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetailInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetailInfo.java new file mode 100644 index 000000000..80c219ee6 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetailInfo.java @@ -0,0 +1,95 @@ +package com.loopers.application.product; + +import com.loopers.application.brand.BrandInfo; +import com.loopers.domain.brand.BrandEntity; +import com.loopers.domain.product.ProductEntity; +import com.loopers.domain.product.ProductMaterializedViewEntity; + +/** + * 상품 상세 정보 DTO + * + * MV 테이블 우선 사용 (성능 최적화) + * isLiked: 비로그인 false, 로그인 사용자의 좋아요 여부 + */ +public record ProductDetailInfo( + Long id, + String name, + String description, + Long likeCount, + Integer stockQuantity, + ProductPriceInfo price, + BrandInfo brand, + Boolean isLiked // 사용자 좋아요 여부 +) { + + /** + * MV 엔티티와 좋아요 여부로 생성 (권장) + */ + public static ProductDetailInfo from(ProductMaterializedViewEntity mv, Boolean isLiked) { + if (mv == null) { + throw new IllegalArgumentException("MV 엔티티는 필수입니다."); + } + + return new ProductDetailInfo( + mv.getProductId(), + mv.getName(), + mv.getDescription(), + mv.getLikeCount(), + mv.getStockQuantity(), + new ProductPriceInfo( + mv.getPrice().getOriginPrice(), + mv.getPrice().getDiscountPrice() + ), + new BrandInfo( + mv.getBrandId(), + mv.getBrandName(), + null // MV에는 브랜드 설명이 없으므로 null + ), + isLiked + ); + } + + /** + * ProductEntity + BrandEntity + 좋아요수로 생성 (MV 사용 권장) + */ + public static ProductDetailInfo of(ProductEntity product, BrandEntity brand, Long likeCount, Boolean isLiked) { + if (product == null) { + throw new IllegalArgumentException("상품 정보는 필수입니다."); + } + + if (brand == null) { + throw new IllegalArgumentException("브랜드 정보는 필수입니다."); + } + + return new ProductDetailInfo( + product.getId(), + product.getName(), + product.getDescription(), + likeCount != null ? likeCount : 0L, + product.getStockQuantity(), + new ProductPriceInfo( + product.getPrice().getOriginPrice(), + product.getPrice().getDiscountPrice() + ), + new BrandInfo( + brand.getId(), + brand.getName(), + brand.getDescription() + ), + isLiked + ); + } + + public static ProductDetailInfo fromWithSyncLike(ProductDetailInfo productDetailInfo, Boolean isLiked) { + return new ProductDetailInfo( + productDetailInfo.id(), + productDetailInfo.name(), + productDetailInfo.description(), + productDetailInfo.likeCount(), + productDetailInfo.stockQuantity(), + productDetailInfo.price(), + productDetailInfo.brand(), + isLiked + ); + } +} 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 new file mode 100644 index 000000000..f2be8b9fa --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java @@ -0,0 +1,112 @@ +package com.loopers.application.product; + +import java.util.Optional; + +import org.springframework.data.domain.Page; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import com.loopers.domain.like.LikeService; +import com.loopers.domain.product.*; +import com.loopers.domain.product.dto.ProductSearchFilter; +import com.loopers.domain.user.UserService; +import com.loopers.infrastructure.cache.CacheStrategy; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * 상품 관련 유스케이스를 조정하는 Application Facade + * Application Layer의 역할: + * 도메인 서비스 호출 및 조정 + * 도메인 엔티티를 DTO로 변환 + * 트랜잭션 경계 관리 + */ +@Component +@RequiredArgsConstructor +@Slf4j +public class ProductFacade { + private final ProductService productService; + private final ProductMVService mvService; + private final ProductCacheService productCacheService; + private final LikeService likeService; + private final UserService userService; + + /** + * 도메인 서비스에서 MV 엔티티를 조회하고, Facade에서 DTO로 변환합니다. + * + * @param productSearchFilter 검색 조건 + * @return 상품 목록 + */ + @Transactional(readOnly = true) + public Page getProducts(ProductSearchFilter productSearchFilter) { + // 1. 캐시 전략 결정 + CacheStrategy strategy = productCacheService.determineCacheStrategy(productSearchFilter); + + // 2. 도메인 서비스에서 MV 엔티티 조회 + Page mvEntities = + mvService.getMVEntitiesByStrategy(productSearchFilter, strategy); + + // 4. DTO 변환 + return mvEntities.map(ProductInfo::from); + } + + /** + * 도메인 서비스에서 엔티티를 조회하고, Facade에서 DTO로 변환합니다. + * + * @param productId 상품 ID + * @param username 사용자명 (nullable) + * @return 상품 상세 정보 + */ + @Transactional(readOnly = true) + public ProductDetailInfo getProductDetail(Long productId, String username) { + // 1. 캐시 조회 + Optional cachedDetail = productCacheService.getProductDetailFromCache(productId); + + Boolean isLiked = username != null + ? likeService.findLike(userService.getUserByUsername(username).getId(), productId) + .map(like -> like.getDeletedAt() == null) + .orElse(false) + : false; + + // 캐시 히트 시 사용자 좋아요 상태 동기화 후 반환 + if (cachedDetail.isPresent()) { + log.debug("상품 상세 캐시 히트 - productId: {}", productId); + return ProductDetailInfo.fromWithSyncLike(cachedDetail.get(), isLiked); + } + + log.debug("상품 상세 캐시 미스 - productId: {}", productId); + + // 2. MV 엔티티 조회 + ProductMaterializedViewEntity productMaterializedViewEntity = mvService.getById(productId); + + ProductDetailInfo productDetail = ProductDetailInfo.from(productMaterializedViewEntity, isLiked); + + // 3. 캐시 저장 + productCacheService.cacheProductDetail(productId, productDetail); + + // 5. 사용자 좋아요 상태 적용 + return productDetail; + } + + /** + * 상품을 삭제합니다. + *

+ * 상품 삭제 후 MV 동기화 및 캐시 무효화를 수행합니다. + * + * @param productId 상품 ID + */ + @Transactional + public void deletedProduct(Long productId) { + // 1. 상품 삭제 + ProductEntity product = productService.getActiveProductDetail(productId); + product.delete(); + + // 2. MV 동기화 + mvService.deleteById(productId); + + // 3. 캐시 무효화 + productCacheService.getProductDetailFromCache(productId) + .ifPresent(detail -> productCacheService.evictProductDetail(productId)); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java new file mode 100644 index 000000000..c7db16e81 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java @@ -0,0 +1,72 @@ +package com.loopers.application.product; + +import java.time.ZonedDateTime; + +import com.loopers.domain.product.ProductEntity; +import com.loopers.domain.product.ProductMaterializedViewEntity; + +/** + * 상품 목록 정보 DTO + * + * MV 테이블 우선 사용 (성능 최적화) + */ +public record ProductInfo( + Long id, + String name, + String description, + Long likeCount, + ProductPriceInfo price, + Long brandId, + ZonedDateTime createdAt +) { + /** + * MV 엔티티로 생성 (권장) + */ + public static ProductInfo from(ProductMaterializedViewEntity mv) { + if (mv == null) { + throw new IllegalArgumentException("MV 엔티티는 필수입니다."); + } + + return new ProductInfo( + mv.getProductId(), + mv.getName(), + mv.getDescription(), + mv.getLikeCount(), + new ProductPriceInfo( + mv.getPrice().getOriginPrice(), + mv.getPrice().getDiscountPrice() + ), + mv.getBrandId(), + mv.getCreatedAt() + ); + } + + /** + * ProductEntity + 좋아요수로 생성 (레거시, MV 사용 권장) + */ + public static ProductInfo of(ProductEntity product, Long likeCount) { + if (product == null) { + throw new IllegalArgumentException("상품 정보는 필수입니다."); + } + + return new ProductInfo( + product.getId(), + product.getName(), + product.getDescription(), + likeCount != null ? likeCount : 0L, + new ProductPriceInfo( + product.getPrice().getOriginPrice(), + product.getPrice().getDiscountPrice() + ), + product.getBrandId(), + product.getCreatedAt() + ); + } + + /** + * ProductEntity로 생성 (레거시, MV 사용 권장) + */ + public static ProductInfo of(ProductEntity product) { + return of(product, 0L); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductPriceInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductPriceInfo.java new file mode 100644 index 000000000..de88a9637 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductPriceInfo.java @@ -0,0 +1,12 @@ +package com.loopers.application.product; + +import java.math.BigDecimal; + +/** + * 상품 가격 정보 DTO + */ +public record ProductPriceInfo( + BigDecimal originPrice, + BigDecimal discountPrice +) { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java new file mode 100644 index 000000000..db8e948af --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java @@ -0,0 +1,39 @@ +package com.loopers.application.user; + +import java.util.Optional; + +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import com.loopers.domain.point.PointService; +import com.loopers.domain.user.UserEntity; +import com.loopers.domain.user.UserService; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; + +import lombok.RequiredArgsConstructor; + +import jakarta.validation.constraints.NotNull; + +@RequiredArgsConstructor +@Component +@Transactional(readOnly = true) +public class UserFacade { + private final UserService userService; + private final PointService pointService; + + @Transactional + public UserInfo registerUser(UserRegisterCommand command) { + // 회원 가입 + UserEntity register = userService.register(command.toDomainRequest()); + + return UserInfo.from(register); + } + + @Transactional(readOnly = true) + public UserInfo getUserByUsername(@NotNull String username) { + return Optional.ofNullable(userService.getUserByUsername(username)) + .map(UserInfo::from) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND_USER, "user not found for username: " + username)); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/user/UserInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/user/UserInfo.java new file mode 100644 index 000000000..fb024c093 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/user/UserInfo.java @@ -0,0 +1,17 @@ +package com.loopers.application.user; + +import java.time.LocalDate; + +import com.loopers.domain.user.Gender; +import com.loopers.domain.user.UserEntity; + +public record UserInfo(Long id, + String username, + String email, + LocalDate birthdate, + Gender gender) { + public static UserInfo from(UserEntity entity) { + return new UserInfo(entity.getId(), entity.getUsername(), entity.getEmail(), entity.getBirthdate(), entity.getGender()); + } + +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/user/UserRegisterCommand.java b/apps/commerce-api/src/main/java/com/loopers/application/user/UserRegisterCommand.java new file mode 100644 index 000000000..085f4dbd5 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/user/UserRegisterCommand.java @@ -0,0 +1,19 @@ +package com.loopers.application.user; + +import com.loopers.domain.user.Gender; +import com.loopers.domain.user.UserDomainCreateRequest; + +public record UserRegisterCommand( + String username, + String email, + String birthdate, + Gender gender +) { + public static UserRegisterCommand of(String username, String email, String birthdate, Gender gender) { + return new UserRegisterCommand(username, email, birthdate, gender); + } + + public UserDomainCreateRequest toDomainRequest() { + return UserDomainCreateRequest.of(username, email, birthdate, gender); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandDomainCreateRequest.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandDomainCreateRequest.java new file mode 100644 index 000000000..b07985017 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandDomainCreateRequest.java @@ -0,0 +1,33 @@ +package com.loopers.domain.brand; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +/** + * 브랜드 도메인 생성 요청 DTO + * + * 도메인 레이어에서 사용하는 브랜드 생성 요청 정보입니다. + * Application Layer에서 Domain Layer로 전달되는 데이터 구조입니다. + * + * @author hyunjikoh + * @since 2025. 11. 12. + */ +public record BrandDomainCreateRequest( + @NotBlank(message = "브랜드 이름은 필수입니다.") + @Size(max = 100, message = "브랜드 이름은 100자를 초과할 수 없습니다.") + String name, + + String description +) { + /** + * 정적 팩토리 메서드 + * + * @param name 브랜드 이름 + * @param description 브랜드 설명 + * @return BrandDomainCreateRequest 인스턴스 + */ + public static BrandDomainCreateRequest of(String name, String description) { + return new BrandDomainCreateRequest(name, description); + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandEntity.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandEntity.java new file mode 100644 index 000000000..c2bd7c5bb --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandEntity.java @@ -0,0 +1,70 @@ +package com.loopers.domain.brand; + +import static java.util.Objects.requireNonNull; +import java.util.Objects; + +import com.loopers.domain.BaseEntity; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Index; +import jakarta.persistence.Table; + +/** + * @author hyunjikoh + * @since 2025. 11. 8. + */ +@Entity +@Table(name = "brands", indexes = { + @Index(name = "idx_brand_name", columnList = "name") +}) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class BrandEntity extends BaseEntity { + @Column(unique = true, nullable = false, length = 100) + private String name; + + @Column(columnDefinition = "TEXT") + private String description; + + public BrandEntity(String name, String description) { + requireNonNull(name); + this.name = name; + this.description = description; + + } + + /** + * 브랜드 엔티티를 생성합니다. + * + * @param request 브랜드 생성 요청 정보 + * @return 생성된 브랜드 엔티티 + */ + public static BrandEntity createBrandEntity(BrandDomainCreateRequest request) { + if (Objects.isNull(request)) { + throw new IllegalArgumentException("브랜드 생성 요청 정보는 필수입니다."); + } + + if (Objects.isNull(request.name()) || request.name().isBlank()) { + throw new IllegalArgumentException("브랜드 이름은 필수 입력값입니다."); + } + + return new BrandEntity(request.name(), request.description()); + } + + @Override + protected void guard() { + if (this.name == null || this.name.isBlank()) { + throw new IllegalStateException("브랜드 이름은 비어있을 수 없습니다."); + } + + if (this.name.length() > 100) { + throw new IllegalStateException("브랜드 이름은 100자를 초과할 수 없습니다."); + } + } + +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java new file mode 100644 index 000000000..e0a9e28c7 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java @@ -0,0 +1,32 @@ +package com.loopers.domain.brand; + +import java.util.List; +import java.util.Optional; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import com.loopers.domain.brand.dto.BrandSearchFilter; + +/** + * @author hyunjikoh + * @since 2025. 11. 9. + */ +public interface BrandRepository { + BrandEntity save(BrandEntity brand); + + Page listBrands(Pageable pageable); + + Optional getBrandById(long id); + + Optional findByName(String name); + + Page searchBrands(BrandSearchFilter filter, Pageable pageable); + + /** + * 모든 활성 브랜드를 조회합니다. + * + * @return 모든 활성 브랜드 목록 + */ + List findAll(); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java new file mode 100644 index 000000000..c53a344ab --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java @@ -0,0 +1,124 @@ +package com.loopers.domain.brand; + +import java.util.List; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import com.loopers.domain.brand.dto.BrandSearchFilter; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; + +import lombok.RequiredArgsConstructor; + +import jakarta.validation.Valid; + +/** + * 브랜드 도메인 서비스 + * + * 브랜드 도메인의 비즈니스 로직을 처리합니다. + * 단일 책임 원칙에 따라 브랜드 Repository에만 의존합니다. + * + * @author hyunjikoh + * @since 2025. 11. 9. + */ +@RequiredArgsConstructor +@Component +public class BrandService { + private final BrandRepository brandRepository; + + /** + * 브랜드 목록을 조회합니다. + * + * @param pageable 페이징 정보 + * @return 브랜드 목록 페이지 + */ + @Transactional(readOnly = true) + public Page listBrands(Pageable pageable) { + return brandRepository.listBrands(pageable); + } + + /** + * 모든 활성 브랜드를 조회합니다. + * + * 캐시 배치 갱신 등에서 사용됩니다. + * + * @return 모든 활성 브랜드 목록 + */ + @Transactional(readOnly = true) + public List getAllBrands() { + return brandRepository.findAll(); + } + + /** + * 브랜드 ID로 브랜드를 조회합니다. + * + * @param id 브랜드 ID + * @return 조회된 브랜드 엔티티 + * @throws CoreException 브랜드를 찾을 수 없는 경우 + */ + @Transactional(readOnly = true) + public BrandEntity getBrandById(long id) { + return brandRepository.getBrandById(id) + .orElseThrow(() -> new CoreException( + ErrorType.NOT_FOUND_BRAND, + String.format("브랜드를 찾을 수 없습니다. (ID: %d)", id) + )); + } + + /** + * 브랜드 이름으로 브랜드를 조회합니다. + * + * @param name 브랜드 이름 + * @return 조회된 브랜드 엔티티 + * @throws CoreException 브랜드를 찾을 수 없는 경우 + */ + @Transactional(readOnly = true) + public BrandEntity getBrandByName(String name) { + return brandRepository.findByName(name) + .orElseThrow(() -> new CoreException( + ErrorType.NOT_FOUND_BRAND, + String.format("브랜드를 찾을 수 없습니다. (이름: %s)", name) + )); + } + + /** + * 검색 필터 조건으로 브랜드를 조회합니다. + * + * @param filter 검색 필터 + * @param pageable 페이징 정보 + * @return 검색된 브랜드 목록 페이지 + */ + @Transactional(readOnly = true) + public Page searchBrands(BrandSearchFilter filter, Pageable pageable) { + return brandRepository.searchBrands(filter, pageable); + } + + /** + * 브랜드를 등록합니다. + * + * 브랜드 등록은 단일 도메인 작업이므로 도메인 서비스에서 트랜잭션 처리합니다. + * + * @param request 브랜드 생성 요청 정보 + * @return 등록된 브랜드 엔티티 + * @throws CoreException 중복된 브랜드 이름이 존재하는 경우 + */ + @Transactional + public BrandEntity registerBrand(@Valid BrandDomainCreateRequest request) { + // 중복 브랜드명 검증 + brandRepository.findByName(request.name()) + .ifPresent(existingBrand -> { + throw new CoreException( + ErrorType.DUPLICATE_BRAND, + String.format("이미 존재하는 브랜드 이름입니다. (이름: %s)", request.name()) + ); + }); + + // 브랜드 엔티티 생성 + BrandEntity brandEntity = BrandEntity.createBrandEntity(request); + + return brandRepository.save(brandEntity); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/dto/BrandSearchFilter.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/dto/BrandSearchFilter.java new file mode 100644 index 000000000..a11b16dc2 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/dto/BrandSearchFilter.java @@ -0,0 +1,16 @@ +package com.loopers.domain.brand.dto; + +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class BrandSearchFilter { + private String brandName; + + public static BrandSearchFilter of(String brandName) { + return BrandSearchFilter.builder() + .brandName(brandName) + .build(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponEntity.java b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponEntity.java new file mode 100644 index 000000000..2e8eafa05 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponEntity.java @@ -0,0 +1,246 @@ +package com.loopers.domain.coupon; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.Objects; + +import com.loopers.domain.BaseEntity; +import com.loopers.domain.user.UserEntity; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import jakarta.persistence.*; + +/** + * 쿠폰 엔티티 + * + * @author hyunjikoh + * @since 2025. 11. 18. + */ +@Entity +@Table(name = "coupons") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class CouponEntity extends BaseEntity { + + /** + * 사용자 ID (users.id 참조) + */ + @Column(name = "user_id", nullable = false) + private Long userId; + + /** + * 쿠폰 타입 (FIXED_AMOUNT: 정액 할인, PERCENTAGE: 배율 할인) + */ + @Enumerated(EnumType.STRING) + @Column(name = "coupon_type", nullable = false, length = 20) + private CouponType couponType; + + /** + * 정액 할인 금액 (정액 쿠폰용) + */ + @Column(name = "fixed_amount", precision = 10, scale = 2) + private BigDecimal fixedAmount; + + /** + * 할인 비율 (배율 쿠폰용, 0-100) + */ + @Column(name = "percentage") + private Integer percentage; + + /** + * 쿠폰 상태 (UNUSED: 미사용, USED: 사용됨) + */ + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 20) + private CouponStatus status = CouponStatus.UNUSED; + + @Version + private Long version; + + + /** + * 정액 쿠폰 생성자 + */ + public CouponEntity(Long userId, BigDecimal fixedAmount) { + validateUserId(userId); + validateFixedAmount(fixedAmount); + + this.userId = userId; + this.couponType = CouponType.FIXED_AMOUNT; + this.fixedAmount = fixedAmount; + this.percentage = null; + } + + /** + * 배율 쿠폰 생성자 + */ + public CouponEntity(Long userId, Integer percentage) { + validateUserId(userId); + validatePercentage(percentage); + + this.userId = userId; + this.couponType = CouponType.PERCENTAGE; + this.fixedAmount = null; + this.percentage = percentage; + } + + /** + * 정액 쿠폰 생성 + * + * 정액 할인 쿠폰을 생성합니다. 고정 금액만큼 할인을 제공합니다. + * + * @param user 쿠폰을 소유할 사용자 ID (null 또는 0 이하일 수 없음) + * @param fixedAmount 정액 할인 금액 (0보다 커야 함) + * @return 생성된 정액 쿠폰 엔티티 + * @throws IllegalArgumentException 유효하지 않은 사용자 ID 또는 할인 금액인 경우 + */ + public static CouponEntity createFixedAmountCoupon(UserEntity user, BigDecimal fixedAmount) { + Objects.requireNonNull(user, "유효하지 않은 사용자 입니다."); + Objects.requireNonNull(fixedAmount, "정액 할인 금액은 필수입니다."); + + + return new CouponEntity(user.getId(), fixedAmount); + } + + /** + * 배율 쿠폰 생성 + * + * 배율 할인 쿠폰을 생성합니다. 상품 가격의 일정 비율만큼 할인을 제공합니다. + * + * @param user 쿠폰을 소유할 사용자 ID (null 또는 0 이하일 수 없음) + * @param percentage 할인 비율 (0보다 크고 100 이하여야 함) + * @return 생성된 배율 쿠폰 엔티티 + * @throws IllegalArgumentException 유효하지 않은 사용자 ID 또는 할인 비율인 경우 + */ + public static CouponEntity createPercentageCoupon(UserEntity user, Integer percentage) { + Objects.requireNonNull(user, "유효하지 않은 사용자 ID입니다."); + Objects.requireNonNull(percentage, "할인 비율은 필수입니다."); + + + return new CouponEntity(user.getId(), percentage); + } + + /** + * 쿠폰 사용 처리 + * + * 쿠폰을 사용 상태로 변경합니다. 이미 사용된 쿠폰은 사용할 수 없습니다. + * + * @throws IllegalStateException 이미 사용된 쿠폰인 경우 + */ + public void use() { + if (this.status == CouponStatus.USED) { + throw new IllegalStateException("이미 사용된 쿠폰입니다."); + } + this.status = CouponStatus.USED; + } + + /** + * 쿠폰 사용 여부 확인 + * + * 쿠폰이 이미 사용되었는지 여부를 반환합니다. + * + * @return 사용된 경우 true, 미사용인 경우 false + */ + public boolean isUsed() { + return this.status == CouponStatus.USED; + } + + /** + * 쿠폰 사용 가능 여부 확인 + * + * 쿠폰을 사용할 수 있는 상태인지 여부를 반환합니다. + * + * @return 사용 가능한 경우 true, 이미 사용된 경우 false + */ + public boolean canUse() { + return this.status == CouponStatus.UNUSED; + } + + /** + * 할인 금액 계산 + * + * 쿠폰 타입에 따라 상품 가격에 대한 할인 금액을 계산합니다. + * + * 정액 쿠폰: 고정 금액만큼 할인 + * 배율 쿠폰: 상품 가격의 일정 비율만큼 할인 (소수점 이하 반올림) + * + * + * @param productPrice 상품 가격 (0보다 커야 함) + * @return 계산된 할인 금액 + * @throws IllegalArgumentException 상품 가격이 유효하지 않은 경우 + */ + public BigDecimal calculateDiscount(BigDecimal productPrice) { + if (productPrice == null || productPrice.compareTo(BigDecimal.ZERO) <= 0) { + throw new IllegalArgumentException("상품 가격은 0보다 커야 합니다."); + } + + return switch (this.couponType) { + case FIXED_AMOUNT -> this.fixedAmount.min(productPrice); + case PERCENTAGE -> productPrice.multiply(BigDecimal.valueOf(this.percentage)) + .divide(new BigDecimal("100"), 0, RoundingMode.HALF_UP); + }; + } + + /** + * 쿠폰 소유권 검증 + * + * 지정된 사용자 ID가 쿠폰의 소유자와 일치하는지 검증합니다. + * + * @param userId 검증할 사용자 ID + * @throws IllegalArgumentException 소유자가 아닌 경우 + */ + public void validateOwnership(Long userId) { + if (!Objects.equals(this.userId, userId)) { + throw new IllegalArgumentException("쿠폰 소유자만 사용할 수 있습니다."); + } + } + + // 검증 메서드들 + private static void validateUserId(Long userId) { + if (userId == null) { + throw new IllegalArgumentException("유효하지 않은 사용자 ID입니다."); + } + } + + private static void validateFixedAmount(BigDecimal fixedAmount) { + if (fixedAmount == null || fixedAmount.compareTo(BigDecimal.ZERO) <= 0) { + throw new IllegalArgumentException("정액 할인 금액은 0보다 커야 합니다."); + } + } + + private static void validatePercentage(Integer percentage) { + if (percentage == null || percentage <= 0 || percentage > 100) { + throw new IllegalArgumentException("할인 비율은 0보다 크고 100 이하여야 합니다."); + } + } + + @Override + protected void guard() { + // 쿠폰 타입별 필수 필드 검증 + switch (this.couponType) { + case FIXED_AMOUNT -> { + if (this.fixedAmount == null || this.fixedAmount.compareTo(BigDecimal.ZERO) <= 0) { + throw new IllegalStateException("정액 쿠폰은 할인 금액이 필수입니다."); + } + if (this.percentage != null) { + throw new IllegalStateException("정액 쿠폰은 할인 비율을 가질 수 없습니다."); + } + } + case PERCENTAGE -> { + if (this.percentage == null || this.percentage <= 0 || this.percentage > 100) { + throw new IllegalStateException("배율 쿠폰은 0-100 범위의 할인 비율이 필수입니다."); + } + if (this.fixedAmount != null) { + throw new IllegalStateException("배율 쿠폰은 정액 할인 금액을 가질 수 없습니다."); + } + } + } + } + + public void revert() { + this.status = CouponStatus.UNUSED; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponRepository.java new file mode 100644 index 000000000..eef9feb98 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponRepository.java @@ -0,0 +1,13 @@ +package com.loopers.domain.coupon; + +import java.util.Optional; + +/** + * @author hyunjikoh + * @since 2025. 11. 9. + */ +public interface CouponRepository { + CouponEntity save(CouponEntity any); + + Optional findByIdAndUserId(Long couponId, Long userId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponService.java b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponService.java new file mode 100644 index 000000000..d8c11cc72 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponService.java @@ -0,0 +1,58 @@ +package com.loopers.domain.coupon; + +import java.math.BigDecimal; + +import org.springframework.orm.ObjectOptimisticLockingFailureException; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import com.loopers.domain.user.UserEntity; + +import lombok.RequiredArgsConstructor; + +/** + * @author hyunjikoh + * @since 2025. 11. 18. + */ + +@RequiredArgsConstructor +@Component +public class CouponService { + private final CouponRepository couponRepository; + + + @Transactional + public CouponEntity createPercentCoupon(UserEntity user, int percent) { + CouponEntity coupon = CouponEntity.createPercentageCoupon(user, percent); + return couponRepository.save(coupon); + } + + @Transactional + public CouponEntity createFixedAmountCoupon(UserEntity user, BigDecimal fixedAmount) { + CouponEntity coupon = CouponEntity.createFixedAmountCoupon(user, fixedAmount); + return couponRepository.save(coupon); + } + + + @Transactional(readOnly = true) + public CouponEntity getCouponByIdAndUserId(Long couponId, Long userId) { + return couponRepository.findByIdAndUserId(couponId, userId) + .orElseThrow(() -> new IllegalArgumentException("쿠폰을 찾을 수 없습니다. id: " + couponId)); + } + + @Transactional + public void consumeCoupon(CouponEntity coupon) { + try { + coupon.use(); + couponRepository.save(coupon); + } catch (ObjectOptimisticLockingFailureException e) { + throw new IllegalArgumentException("쿠폰을 사용할 수 없습니다. id: " + coupon.getId()); + } + } + + @Transactional + public void revertCoupon(CouponEntity coupon) { + coupon.revert(); + couponRepository.save(coupon); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponStatus.java b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponStatus.java new file mode 100644 index 000000000..a5b480973 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponStatus.java @@ -0,0 +1,17 @@ +package com.loopers.domain.coupon; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +/** + * @author hyunjikoh + * @since 2025. 11. 18. + */ +@Getter +@RequiredArgsConstructor +public enum CouponStatus { + UNUSED("미사용"), + USED("사용"); + + private final String description; +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponType.java b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponType.java new file mode 100644 index 000000000..34367d6d1 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponType.java @@ -0,0 +1,17 @@ +package com.loopers.domain.coupon; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +/** + * @author hyunjikoh + * @since 2025. 11. 18. + */ +@Getter +@RequiredArgsConstructor +public enum CouponType { + FIXED_AMOUNT("정액 할인"), + PERCENTAGE("배율 할인"); + + private final String description; +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleModel.java index c588c4a8a..f07770a45 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleModel.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleModel.java @@ -3,6 +3,7 @@ import com.loopers.domain.BaseEntity; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; + import jakarta.persistence.Entity; import jakarta.persistence.Table; @@ -13,7 +14,8 @@ public class ExampleModel extends BaseEntity { private String name; private String description; - protected ExampleModel() {} + protected ExampleModel() { + } public ExampleModel(String name, String description) { if (name == null || name.isBlank()) { diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleService.java b/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleService.java index c0e8431e8..8c3e6be41 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleService.java @@ -1,10 +1,12 @@ package com.loopers.domain.example; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; + import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Transactional; @RequiredArgsConstructor @Component @@ -15,6 +17,6 @@ public class ExampleService { @Transactional(readOnly = true) public ExampleModel getExample(Long id) { return exampleRepository.find(id) - .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "[id = " + id + "] 예시를 찾을 수 없습니다.")); + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "[id = " + id + "] 예시를 찾을 수 없습니다.")); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeEntity.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeEntity.java new file mode 100644 index 000000000..30d359a9b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeEntity.java @@ -0,0 +1,44 @@ +package com.loopers.domain.like; + +import static java.util.Objects.requireNonNull; + +import com.loopers.domain.BaseEntity; + +import lombok.Getter; +import lombok.NoArgsConstructor; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; + +/** + * @author hyunjikoh + * @since 2025. 11. 11. + */ +@Entity +@Table(name = "likes", uniqueConstraints = { + @UniqueConstraint(name = "uc_likee_user_product", columnNames = {"userId", "productId"}) +}) +@Getter +@NoArgsConstructor +public class LikeEntity extends BaseEntity { + + @Column(nullable = false) + private Long userId; + + @Column(nullable = false) + private Long productId; + + private LikeEntity(Long userId, Long productId) { + requireNonNull(userId, "user ID는 필수입니다."); + requireNonNull(productId, "상품명은 필수입니다."); + + this.userId = userId; + this.productId = productId; + } + + public static LikeEntity createEntity(Long userId, Long productId) { + return new LikeEntity(userId, productId); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeRepository.java new file mode 100644 index 000000000..9cef913a4 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeRepository.java @@ -0,0 +1,28 @@ +package com.loopers.domain.like; + +import java.util.List; +import java.util.Optional; + +/** + * @author hyunjikoh + * @since 2025. 11. 11. + */ +public interface LikeRepository { + LikeEntity save(LikeEntity entity); + + Optional findByUserIdAndProductId(Long userId, Long productId); + + List findAll(); + + /** + * 특정 상품의 활성 좋아요 수를 조회한다. + */ + Long countByProductIdAndDeletedAtIsNull(Long productId); + + /** + * 좋아요가 있는 모든 상품 ID를 조회한다. + * + * 배치 동기화 시 사용됩니다. + */ + List findDistinctProductIds(); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeResult.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeResult.java new file mode 100644 index 000000000..4206b1edc --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeResult.java @@ -0,0 +1,8 @@ +package com.loopers.domain.like; + +/** + * @author hyunjikoh + * @since 2025. 11. 27. + */ +public record LikeResult(LikeEntity entity, boolean changed) { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java new file mode 100644 index 000000000..d6a4415ff --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java @@ -0,0 +1,113 @@ +package com.loopers.domain.like; + +import java.util.Optional; + +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import com.loopers.domain.product.ProductEntity; +import com.loopers.domain.user.UserEntity; + +import lombok.RequiredArgsConstructor; + +/** + * 좋아요 도메인 서비스 + * + * 좋아요 도메인의 비즈니스 로직을 처리합니다. + * 좋아요와 상품 간의 협력을 통해 좋아요 카운트를 관리합니다. + * + * @author hyunjikoh + * @since 2025. 11. 11. + */ +@Component +@RequiredArgsConstructor +public class LikeService { + private final LikeRepository likeRepository; + + /** + * 사용자와 상품의 좋아요 관계를 조회합니다. + * + * @param userId 사용자 ID + * @param productId 상품 ID + * @return 좋아요 엔티티 (Optional) + */ + @Transactional(readOnly = true) + public Optional findLike(Long userId, Long productId) { + return likeRepository.findByUserIdAndProductId(userId, productId); + } + + + + /** + * 좋아요를 등록하거나 복원합니다 (Upsert). + * + * - 좋아요 관계가 없으면: 새로 생성하고 카운트 증가 + * - 삭제된 좋아요가 있으면: 복원하고 카운트 증가 + * - 활성 좋아요가 있으면: 기존 엔티티 반환 (카운트 변경 없음 - 중복 방지) + * + * 좋아요 카운트는 DB 원자적 연산(UPDATE 쿼리)으로 처리하여 동시성을 보장합니다. + * + * @param user 사용자 엔티티 + * @param product 상품 엔티티 + * @return 좋아요 등록 결과 (엔티티와 실제 변경 여부) + */ + @Transactional + public LikeResult upsertLike(UserEntity user, ProductEntity product) { + Optional existingLike = likeRepository.findByUserIdAndProductId(user.getId(), product.getId()); + + if (existingLike.isPresent()) { + LikeEntity like = existingLike.get(); + // 삭제된 좋아요인 경우만 복원 및 카운트 증가 + if (like.getDeletedAt() != null) { + like.restore(); + return new LikeResult(like, true); // 복원됨 - 통계 업데이트 필요 + } + // 활성 좋아요인 경우: 카운트 변경 없음 (중복 방지) + return new LikeResult(like, false); // 이미 존재 - 통계 업데이트 불필요 + } + + // 좋아요가 없는 경우 새로 생성 + LikeEntity newLike = likeRepository.save(LikeEntity.createEntity(user.getId(), product.getId())); + return new LikeResult(newLike, true); // 새로 생성됨 - 통계 업데이트 필요 + } + + /** + * 좋아요를 취소합니다 (소프트 삭제). + * + * 좋아요를 삭제하고 상품의 좋아요 카운트를 감소시킵니다. + * 좋아요 카운트는 DB 원자적 연산(UPDATE 쿼리)으로 처리하여 동시성을 보장합니다. + * + * @param user 사용자 엔티티 + * @param product 상품 엔티티 + * @return 실제로 삭제가 발생했는지 여부 (통계 업데이트 필요 여부) + */ + @Transactional + public boolean unlikeProduct(UserEntity user, ProductEntity product) { + Optional existingLike = likeRepository.findByUserIdAndProductId(user.getId(), product.getId()); + + if (existingLike.isEmpty()) { + return false; // 좋아요가 없음 - 통계 업데이트 불필요 + } + + LikeEntity like = existingLike.get(); + + // 이미 삭제된 좋아요인 경우 무시 (멱등성 보장) + if (like.getDeletedAt() != null) { + return false; // 이미 삭제됨 - 통계 업데이트 불필요 + } + + like.delete(); + return true; // 삭제됨 - 통계 업데이트 필요 + } + + /** + * 상품의 좋아요 수를 조회합니다. + * + * @param product + * @return + */ + @Transactional(readOnly = true) + public Long countByProduct(ProductEntity product) { + return likeRepository.countByProductIdAndDeletedAtIsNull(product.getId()); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderEntity.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderEntity.java new file mode 100644 index 000000000..dd41375d4 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderEntity.java @@ -0,0 +1,196 @@ +package com.loopers.domain.order; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.Objects; + +import com.loopers.domain.BaseEntity; +import com.loopers.domain.order.dto.OrderDomainCreateRequest; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import jakarta.persistence.*; + +/** + * @author hyunjikoh + * @since 2025. 11. 14. + */ +@Entity +@Table(name = "orders", indexes = { + @Index(name = "idx_order_user_id", columnList = "user_id"), + @Index(name = "idx_order_status", columnList = "status"), +}) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class OrderEntity extends BaseEntity { + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Column(name = "original_total_amount", precision = 10, scale = 2, nullable = false) + private BigDecimal originalTotalAmount; + + @Column(name = "discount_amount", precision = 10, scale = 2, nullable = false) + private BigDecimal discountAmount; + + @Column(name = "final_total_amount", precision = 10, scale = 2, nullable = false) + private BigDecimal finalTotalAmount; + + @Deprecated + @Column(name = "total_amount", precision = 10, scale = 2, nullable = false) + private BigDecimal totalAmount; + + @Enumerated(EnumType.STRING) + @Column(name = "status", length = 20, nullable = false) + private OrderStatus status; + + /** + * 주문 엔티티 생성자 + * + * @param request 주문 생성 요청 DTO + * @throws IllegalArgumentException request가 null인 경우 + */ + private OrderEntity(OrderDomainCreateRequest request) { + Objects.requireNonNull(request, "주문 생성 요청은 필수입니다."); + Objects.requireNonNull(request.userId(), "사용자 ID는 필수입니다."); + Objects.requireNonNull(request.originalTotalAmount(), "할인 전 총액은 필수입니다."); + Objects.requireNonNull(request.discountAmount(), "할인 금액은 필수입니다."); + Objects.requireNonNull(request.finalTotalAmount(), "할인 후 총액은 필수입니다."); + + if (request.originalTotalAmount().compareTo(BigDecimal.ZERO) <= 0) { + throw new IllegalArgumentException("할인 전 총액은 0보다 커야 합니다."); + } + + if (request.discountAmount().compareTo(BigDecimal.ZERO) < 0) { + throw new IllegalArgumentException("할인 금액은 0 이상이어야 합니다."); + } + + if (request.finalTotalAmount().compareTo(BigDecimal.ZERO) <= 0) { + throw new IllegalArgumentException("할인 후 총액은 0보다 커야 합니다."); + } + + // 금액 정합성 검증: 할인 전 총액 - 할인 금액 = 할인 후 총액 + BigDecimal calculatedFinalAmount = request.originalTotalAmount().subtract(request.discountAmount()); + if (calculatedFinalAmount.setScale(2, RoundingMode.HALF_UP) + .compareTo(request.finalTotalAmount().setScale(2, RoundingMode.HALF_UP)) != 0) { + throw new IllegalArgumentException( + String.format("금액 정합성 오류: 할인 전 총액(%s) - 할인 금액(%s) = 할인 후 총액(%s)이 일치하지 않습니다. (계산 결과: %s)", + request.originalTotalAmount(), request.discountAmount(), + request.finalTotalAmount(), calculatedFinalAmount) + ); + } + + this.userId = request.userId(); + this.originalTotalAmount = request.originalTotalAmount().setScale(2, RoundingMode.HALF_UP); + this.discountAmount = request.discountAmount().setScale(2, RoundingMode.HALF_UP); + this.finalTotalAmount = request.finalTotalAmount().setScale(2, RoundingMode.HALF_UP); + this.totalAmount = this.finalTotalAmount; // 하위 호환성을 위해 유지 + this.status = OrderStatus.PENDING; + } + + /** + * 주문을 생성합니다. + * + * @param request 주문 생성 요청 DTO + * @return 생성된 주문 엔티티 + */ + public static OrderEntity createOrder(OrderDomainCreateRequest request) { + Objects.requireNonNull(request, "주문 생성 요청은 null일 수 없습니다."); + return new OrderEntity(request); + } + + /** + * 주문을 확정합니다. + * PENDING 상태에서만 CONFIRMED로 변경 가능합니다. + */ + public void confirmOrder() { + if (this.status != OrderStatus.PENDING || this.getDeletedAt() != null) { + throw new CoreException( + ErrorType.INVALID_ORDER_STATUS, + String.format("주문 확정은 대기 상태 또는 활성화된 주문만 가능합니다. (현재 상태: %s)", this.status) + ); + } + + this.status = OrderStatus.CONFIRMED; + } + + /** + * 주문이 대기 상태인지 확인합니다. + * + * @return 대기 상태 여부 + */ + public boolean isPending() { + return this.status == OrderStatus.PENDING; + } + + /** + * 주문이 확정 상태인지 확인합니다. + * + * @return 확정 상태 여부 + */ + public boolean isConfirmed() { + return this.status == OrderStatus.CONFIRMED; + } + + /** + * 주문을 취소합니다. + * + * PENDING 또는 CONFIRMED 상태의 주문만 취소할 수 있습니다. + * + * @throws IllegalStateException 취소할 수 없는 상태인 경우 + */ + public void cancelOrder() { + if (this.status != OrderStatus.PENDING && this.status != OrderStatus.CONFIRMED) { + throw new IllegalStateException( + String.format("PENDING 또는 CONFIRMED 상태의 주문만 취소할 수 있습니다. 현재 상태: %s", this.status) + ); + } + this.status = OrderStatus.CANCELLED; + } + + /** + * 주문이 취소 상태인지 확인합니다. + * + * @return 취소 상태 여부 + */ + public boolean isCancelled() { + return this.status == OrderStatus.CANCELLED; + } + + @Override + protected void guard() { + if (this.userId == null) { + throw new IllegalStateException("사용자 ID는 필수입니다."); + } + + if (this.originalTotalAmount == null || this.originalTotalAmount.compareTo(BigDecimal.ZERO) <= 0) { + throw new IllegalStateException("할인 전 총액은 0보다 커야 합니다."); + } + + if (this.discountAmount == null || this.discountAmount.compareTo(BigDecimal.ZERO) < 0) { + throw new IllegalStateException("할인 금액은 0 이상이어야 합니다."); + } + + if (this.finalTotalAmount == null || this.finalTotalAmount.compareTo(BigDecimal.ZERO) <= 0) { + throw new IllegalStateException("할인 후 총액은 0보다 커야 합니다."); + } + + // 금액 정합성 검증 + BigDecimal calculatedFinalAmount = this.originalTotalAmount.subtract(this.discountAmount); + if (calculatedFinalAmount.compareTo(this.finalTotalAmount) != 0) { + throw new IllegalStateException( + String.format("금액 정합성 오류: 할인 전 총액(%s) - 할인 금액(%s) ≠ 할인 후 총액(%s)", + this.originalTotalAmount, this.discountAmount, this.finalTotalAmount) + ); + } + + if (this.status == null) { + throw new IllegalStateException("주문 상태는 필수입니다."); + } + + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemEntity.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemEntity.java new file mode 100644 index 000000000..46ae4ba14 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemEntity.java @@ -0,0 +1,163 @@ +package com.loopers.domain.order; + +import java.math.BigDecimal; + +import com.loopers.domain.BaseEntity; +import com.loopers.domain.order.dto.OrderItemDomainCreateRequest; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Index; +import jakarta.persistence.Table; + +/** + * @author hyunjikoh + * @since 2025. 11. 14. + */ +@Entity +@Table(name = "order_items", indexes = { + @Index(name = "idx_order_item_order_id", columnList = "order_id"), + @Index(name = "idx_order_item_product_id", columnList = "product_id") +}) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class OrderItemEntity extends BaseEntity { + + @Column(name = "order_id", nullable = false) + private Long orderId; + + @Column(name = "product_id", nullable = false) + private Long productId; + + @Column(name = "coupon_id", nullable = true) + private Long couponId; + + @Column(name = "quantity", nullable = false) + private Integer quantity; + + @Column(name = "unit_price", precision = 10, scale = 2, nullable = false) + private BigDecimal unitPrice; + + @Column(name = "discount_amount", precision = 10, scale = 2, nullable = false) + private BigDecimal discountAmount; + + @Column(name = "total_price", precision = 10, scale = 2, nullable = false) + private BigDecimal totalPrice; + + /** + * 주문 항목 엔티티 생성자 + * + * @param request 주문 항목 생성 요청 DTO + * @throws IllegalArgumentException request가 null이거나 필수 값이 누락된 경우 + */ + private OrderItemEntity(OrderItemDomainCreateRequest request) { + validateRequest(request); + + this.orderId = request.orderId(); + this.productId = request.productId(); + this.couponId = request.couponId(); + this.quantity = request.quantity(); + this.unitPrice = request.unitPrice(); + this.discountAmount = request.discountAmount() != null ? request.discountAmount() : BigDecimal.ZERO; + this.totalPrice = calculateItemTotal(); + } + + /** + * 주문 항목 생성 요청의 유효성을 검증합니다. + * + * @param request 주문 항목 생성 요청 DTO + * @throws IllegalArgumentException 유효하지 않은 값이 포함된 경우 + */ + private void validateRequest(OrderItemDomainCreateRequest request) { + if (request == null) { + throw new IllegalArgumentException("주문 항목 생성 요청은 필수입니다."); + } + + if (request.orderId() == null) { + throw new IllegalArgumentException("주문 ID는 필수입니다."); + } + + if (request.productId() == null) { + throw new IllegalArgumentException("상품 ID는 필수입니다."); + } + + if (request.quantity() == null) { + throw new IllegalArgumentException("주문 수량은 필수입니다."); + } + + if (request.quantity() <= 0) { + throw new IllegalArgumentException("주문 수량은 1 이상이어야 합니다."); + } + + if (request.unitPrice() == null) { + throw new IllegalArgumentException("단가는 필수입니다."); + } + + if (request.unitPrice().compareTo(BigDecimal.ZERO) <= 0) { + throw new IllegalArgumentException("단가는 0보다 커야 합니다."); + } + } + + /** + * 주문 항목을 생성합니다. + * + * @param request 주문 항목 생성 요청 DTO + * @return 생성된 주문 항목 엔티티 + */ + public static OrderItemEntity createOrderItem(OrderItemDomainCreateRequest request) { + return new OrderItemEntity(request); + } + + /** + * 주문 항목의 총액을 계산합니다. + * + * 총액 = (단가 × 수량) - 할인 금액 + * + * @return 항목 총액 (할인 적용 후) + */ + public BigDecimal calculateItemTotal() { + BigDecimal baseAmount = unitPrice.multiply(BigDecimal.valueOf(quantity)); + return baseAmount.subtract(discountAmount != null ? discountAmount : BigDecimal.ZERO); + } + + @Override + protected void guard() { + if (this.orderId == null) { + throw new IllegalStateException("주문 ID는 필수입니다."); + } + + if (this.productId == null) { + throw new IllegalStateException("상품 ID는 필수입니다."); + } + + if (this.quantity == null || this.quantity <= 0) { + throw new IllegalStateException("주문 수량은 1 이상이어야 합니다."); + } + + if (this.unitPrice == null || this.unitPrice.compareTo(BigDecimal.ZERO) <= 0) { + throw new IllegalStateException("단가는 0보다 커야 합니다."); + } + + if (this.discountAmount == null || this.discountAmount.compareTo(BigDecimal.ZERO) < 0) { + throw new IllegalStateException("할인 금액은 0 이상이어야 합니다."); + } + + if (this.totalPrice == null || this.totalPrice.compareTo(BigDecimal.ZERO) < 0) { + throw new IllegalStateException("총 가격은 0 이상이어야 합니다."); + } + + // 총 가격 일치성 검증: (단가 × 수량) - 할인 금액 = 총 가격 + BigDecimal baseAmount = unitPrice.multiply(BigDecimal.valueOf(quantity)); + BigDecimal calculatedTotal = baseAmount.subtract(discountAmount); + if (this.totalPrice.compareTo(calculatedTotal) != 0) { + throw new IllegalStateException( + String.format("총 가격 정합성 오류: (단가 × 수량)(%s) - 할인 금액(%s) ≠ 총 가격(%s)", + baseAmount, discountAmount, this.totalPrice) + ); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemRepository.java new file mode 100644 index 000000000..4dc9e85ae --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemRepository.java @@ -0,0 +1,49 @@ +package com.loopers.domain.order; + +import java.util.List; + +/** + * 주문 항목 Repository 인터페이스 + * + * @author hyunjikoh + * @since 2025. 11. 14. + */ +public interface OrderItemRepository { + + /** + * 주문 항목을 저장합니다. + * + * @param orderItem 저장할 주문 항목 엔티티 + * @return 저장된 주문 항목 엔티티 + */ + OrderItemEntity save(OrderItemEntity orderItem); + + /** + * 주문 ID로 주문 항목 목록을 조회합니다. + * + * @param orderId 주문 ID + * @return 주문 항목 목록 + */ + List findByOrderId(Long orderId); + + void saveAll(List orderItemsByOrderId); + + /** + * 주문 ID로 주문 항목 개수를 조회합니다. + * + * @param orderId 주문 ID + * @return 주문 항목 개수 + */ + int countByOrderId(Long orderId); + + /** + * 주문 ID로 주문 항목 목록을 페이징하여 조회합니다. + * + * @param orderId 주문 ID + * @param pageable 페이징 정보 + * @return 페이징된 주문 항목 목록 + */ + org.springframework.data.domain.Page findByOrderId( + Long orderId, + org.springframework.data.domain.Pageable pageable); +} 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 new file mode 100644 index 000000000..f2fcee0cd --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java @@ -0,0 +1,53 @@ +package com.loopers.domain.order; + +import java.util.Optional; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +/** + * 주문 Repository 인터페이스 + * + * @author hyunjikoh + * @since 2025. 11. 14. + */ +public interface OrderRepository { + + /** + * 주문을 저장합니다. + * + * @param order 저장할 주문 엔티티 + * @return 저장된 주문 엔티티 + */ + OrderEntity save(OrderEntity order); + + /** + * 주문 ID로 주문을 조회합니다. + * + * @param id 주문 ID + * @param userId + * @return 조회된 주문 엔티티 (Optional) + */ + Optional findByIdAndUserId(Long id, Long userId); + + /** + * 사용자 ID로 주문 목록을 페이징하여 조회합니다. + * + * @param userId 사용자 ID + * @param pageable 페이징 정보 + * @return 페이징된 주문 목록 + */ + Page findByUserId( + Long userId, + Pageable pageable); + + /** + * 사용자 ID와 주문 상태로 주문 목록을 페이징하여 조회합니다. + * + * @param userId 사용자 ID + * @param status 주문 상태 + * @param pageable 페이징 정보 + * @return 페이징된 주문 목록 + */ + Page findByUserIdAndStatus(Long userId, OrderStatus status, Pageable pageable); +} 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 new file mode 100644 index 000000000..4a0b91004 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java @@ -0,0 +1,267 @@ +package com.loopers.domain.order; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import com.loopers.domain.coupon.CouponEntity; +import com.loopers.domain.order.dto.OrderCreationResult; +import com.loopers.domain.order.dto.OrderDomainCreateRequest; +import com.loopers.domain.order.dto.OrderItemData; +import com.loopers.domain.order.dto.OrderItemDomainCreateRequest; +import com.loopers.domain.product.ProductEntity; +import com.loopers.domain.user.UserEntity; +import com.loopers.domain.user.UserService; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; + +import lombok.RequiredArgsConstructor; + +/** + * 주문 도메인 서비스 + * + * 주문과 주문 항목에 대한 핵심 비즈니스 로직을 처리합니다. + * + * @author hyunjikoh + * @since 2025. 11. 14. + */ +@Component +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class OrderService { + + private final OrderRepository orderRepository; + private final OrderItemRepository orderItemRepository; + private final UserService userService; + + /** + * 주문을 생성합니다. + * + * @param request 주문 생성 요청 + * @return 생성된 주문 엔티티 + */ + @Transactional + public OrderEntity createOrder(OrderDomainCreateRequest request) { + OrderEntity order = OrderEntity.createOrder(request); + return orderRepository.save(order); + } + + /** + * 주문과 주문 항목들을 생성합니다 (도메인 로직). + * + * 주문 생성의 핵심 도메인 로직을 처리합니다: + * + * 주문 항목별 금액 계산 (원가, 할인, 최종가) + * 전체 주문 금액 집계 + * 주문 엔티티 및 주문 항목 엔티티 생성 + * + * + * @param userId 사용자 ID + * @param orderableProducts 주문 가능한 상품 목록 (재고 확인 완료) + * @param coupons 적용할 쿠폰 목록 (주문 항목과 동일 순서) + * @param quantities 주문 수량 목록 (주문 항목과 동일 순서) + * @return 생성된 주문 엔티티와 주문 항목 목록 + */ + @Transactional + public OrderCreationResult createOrderWithItems( + Long userId, + List orderableProducts, + List coupons, + List quantities) { + + // 1. 항목별 금액 계산 + List itemDataList = new ArrayList<>(); + BigDecimal originalTotalAmount = BigDecimal.ZERO; + BigDecimal totalDiscountAmount = BigDecimal.ZERO; + + for (int i = 0; i < orderableProducts.size(); i++) { + ProductEntity product = orderableProducts.get(i); + CouponEntity coupon = coupons.get(i); + Integer quantity = quantities.get(i); + + // 항목별 원가 계산 + BigDecimal basePrice = product.getSellingPrice().multiply(BigDecimal.valueOf(quantity)); + + // 항목별 할인 금액 계산 + BigDecimal itemDiscount = BigDecimal.ZERO; + Long couponId = null; + if (coupon != null) { + itemDiscount = coupon.calculateDiscount(basePrice); + couponId = coupon.getId(); + } + + itemDataList.add(new OrderItemData( + product.getId(), + couponId, + quantity, + product.getSellingPrice(), + itemDiscount + )); + + originalTotalAmount = originalTotalAmount.add(basePrice); + totalDiscountAmount = totalDiscountAmount.add(itemDiscount); + } + + BigDecimal finalTotalAmount = originalTotalAmount.subtract(totalDiscountAmount); + + // 2. 주문 엔티티 생성 + OrderEntity order = createOrder( + new OrderDomainCreateRequest( + userId, + originalTotalAmount, + totalDiscountAmount, + finalTotalAmount + ) + ); + + // 3. 주문 항목 엔티티 생성 + List orderItems = itemDataList.stream() + .map(itemData -> createOrderItem( + new OrderItemDomainCreateRequest( + order.getId(), + itemData.productId(), + itemData.couponId(), + itemData.quantity(), + itemData.unitPrice(), + itemData.discountAmount() + ) + )) + .toList(); + + return new OrderCreationResult(order, orderItems); + } + + /** + * 주문 취소의 도메인 로직을 처리합니다. + * + * 주문 상태를 취소로 변경하고 주문 항목 목록을 반환합니다. + * + * @param order 취소할 주문 엔티티 + * @return 정렬된 주문 항목 목록 (교착 상태 방지를 위해 productId 기준 정렬) + */ + @Transactional + public List cancelOrderDomain(OrderEntity order) { + // 주문 취소 처리 + order.cancelOrder(); + + // 주문 항목 조회 및 정렬 (교착 상태 방지) + return getOrderItemsByOrderId(order.getId()) + .stream() + .sorted(Comparator.comparing(OrderItemEntity::getProductId)) + .toList(); + } + + /** + * 주문 ID로 주문을 조회합니다. + * + * @param orderId 주문 ID + * @param userId 사용자 ID + * @return 조회된 주문 엔티티 + * @throws CoreException 주문을 찾을 수 없는 경우 + */ + public OrderEntity getOrderByIdAndUserId(Long orderId, Long userId) { + return orderRepository.findByIdAndUserId(orderId, userId) + .orElseThrow(() -> new CoreException( + ErrorType.NOT_FOUND, + String.format("주문을 찾을 수 없습니다. (ID: %d)", orderId) + )); + } + + /** + * 주문 항목을 생성합니다. + * + * @param request 주문 항목 생성 요청 + * @return 생성된 주문 항목 엔티티 + */ + @Transactional + public OrderItemEntity createOrderItem(OrderItemDomainCreateRequest request) { + OrderItemEntity orderItem = OrderItemEntity.createOrderItem(request); + return orderItemRepository.save(orderItem); + } + + /** + * 주문 ID로 주문 항목 목록을 조회합니다. + * + * @param orderId 주문 ID + * @return 주문 항목 목록 + */ + public List getOrderItemsByOrderId(Long orderId) { + return orderItemRepository.findByOrderId(orderId); + } + + /** + * 주문을 삭제합니다 (소프트 삭제). + * + * @param orderId 주문 ID + * @throws CoreException 주문을 찾을 수 없는 경우 + */ + @Transactional + public void deleteOrder(Long orderId, String username) { + UserEntity user = userService.getUserByUsername(username); + + OrderEntity order = getOrderByIdAndUserId(orderId, user.getId()); + order.delete(); + + List orderItemsByOrderId = getOrderItemsByOrderId(orderId); + + orderRepository.save(order); + orderItemRepository.saveAll(orderItemsByOrderId); + } + + /** + * 사용자 ID로 주문 목록을 페이징하여 조회합니다. + * + * @param userId 사용자 ID + * @param pageable 페이징 정보 + * @return 페이징된 주문 목록 + */ + public Page getOrdersByUserId( + Long userId, + Pageable pageable) { + return orderRepository.findByUserId(userId, pageable); + } + + /** + * 사용자 ID와 주문 상태로 주문 목록을 페이징하여 조회합니다. + * + * @param userId 사용자 ID + * @param status 주문 상태 + * @param pageable 페이징 정보 + * @return 페이징된 주문 목록 + */ + public Page getOrdersByUserIdAndStatus( + Long userId, + OrderStatus status, + Pageable pageable) { + return orderRepository.findByUserIdAndStatus(userId, status, pageable); + } + + /** + * 주문 ID로 주문 항목 개수를 조회합니다. + * + * @param orderId 주문 ID + * @return 주문 항목 개수 + */ + public int countOrderItems(Long orderId) { + return orderItemRepository.countByOrderId(orderId); + } + + /** + * 주문 ID로 주문 항목 목록을 페이징하여 조회합니다. + * + * @param orderId 주문 ID + * @param pageable 페이징 정보 + * @return 페이징된 주문 항목 목록 + */ + public Page getOrderItemsByOrderId( + Long orderId, + Pageable pageable) { + return orderItemRepository.findByOrderId(orderId, pageable); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatus.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatus.java new file mode 100644 index 000000000..b2cd39aa6 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatus.java @@ -0,0 +1,35 @@ +package com.loopers.domain.order; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +/** + * 주문 상태 열거형 + * + * 주문의 생명주기를 나타내는 상태값입니다. + * + * @author hyunjikoh + * @since 2025. 11. 14. + */ +@Getter +@RequiredArgsConstructor +public enum OrderStatus { + + /** + * 대기 - 주문이 생성되었으나 아직 확정되지 않은 상태 + */ + PENDING("대기", "주문이 생성되었으나 아직 확정되지 않은 상태"), + + /** + * 확정 - 결제가 완료되어 주문이 확정된 상태 + */ + CONFIRMED("확정", "결제가 완료되어 주문이 확정된 상태"), + + /** + * 취소 - 주문이 취소된 상태 + */ + CANCELLED("취소", "주문이 취소된 상태"); + + private final String description; + private final String detail; +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/dto/OrderCreationResult.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/dto/OrderCreationResult.java new file mode 100644 index 000000000..eff511dd1 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/dto/OrderCreationResult.java @@ -0,0 +1,16 @@ +package com.loopers.domain.order.dto; + +import java.util.List; + +import com.loopers.domain.order.OrderEntity; +import com.loopers.domain.order.OrderItemEntity; + +/** + * @author hyunjikoh + * @since 2025. 11. 22. + */ +public record OrderCreationResult( + OrderEntity order, + List orderItems +) { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/dto/OrderDomainCreateRequest.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/dto/OrderDomainCreateRequest.java new file mode 100644 index 000000000..b958a8a7c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/dto/OrderDomainCreateRequest.java @@ -0,0 +1,64 @@ +package com.loopers.domain.order.dto; + +import java.math.BigDecimal; +import java.math.RoundingMode; + +/** + * 주문 생성 도메인 요청 DTO + * + * @param userId 사용자 ID + * @param originalTotalAmount 할인 전 총액 + * @param discountAmount 총 할인 금액 + * @param finalTotalAmount 할인 후 총액 + * @author hyunjikoh + * @since 2025. 11. 14. + */ +public record OrderDomainCreateRequest( + Long userId, + BigDecimal originalTotalAmount, + BigDecimal discountAmount, + BigDecimal finalTotalAmount +) { + /** + * 레코드 생성자 - 유효성 검증 + */ + public OrderDomainCreateRequest { + if (userId == null) { + throw new IllegalArgumentException("사용자 ID는 필수입니다."); + } + + if (originalTotalAmount == null) { + throw new IllegalArgumentException("할인 전 총액은 필수입니다."); + } + + if (originalTotalAmount.compareTo(BigDecimal.ZERO) <= 0) { + throw new IllegalArgumentException("할인 전 총액은 0보다 커야 합니다."); + } + + if (discountAmount == null) { + throw new IllegalArgumentException("할인 금액은 필수입니다."); + } + + if (discountAmount.compareTo(BigDecimal.ZERO) < 0) { + throw new IllegalArgumentException("할인 금액은 0 이상이어야 합니다."); + } + + if (finalTotalAmount == null) { + throw new IllegalArgumentException("할인 후 총액은 필수입니다."); + } + + if (finalTotalAmount.compareTo(BigDecimal.ZERO) <= 0) { + throw new IllegalArgumentException("할인 후 총액은 0보다 커야 합니다."); + } + + // 금액 정합성 검증 + BigDecimal calculatedFinalAmount = originalTotalAmount.subtract(discountAmount); + if (calculatedFinalAmount.setScale(2, RoundingMode.HALF_UP) + .compareTo(finalTotalAmount.setScale(2, RoundingMode.HALF_UP)) != 0) { + throw new IllegalArgumentException( + String.format("금액 정합성 오류: 할인 전 총액(%s) - 할인 금액(%s) ≠ 할인 후 총액(%s)", + originalTotalAmount, discountAmount, finalTotalAmount) + ); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/dto/OrderItemData.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/dto/OrderItemData.java new file mode 100644 index 000000000..1a2bf26c2 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/dto/OrderItemData.java @@ -0,0 +1,16 @@ +package com.loopers.domain.order.dto; + +import java.math.BigDecimal; + +/** + * @author hyunjikoh + * @since 2025. 11. 22. + */ +public record OrderItemData( + Long productId, + Long couponId, + Integer quantity, + BigDecimal unitPrice, + BigDecimal discountAmount +) { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/dto/OrderItemDomainCreateRequest.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/dto/OrderItemDomainCreateRequest.java new file mode 100644 index 000000000..0f6f62cec --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/dto/OrderItemDomainCreateRequest.java @@ -0,0 +1,62 @@ +package com.loopers.domain.order.dto; + +import java.math.BigDecimal; + +/** + * 주문 항목 생성 도메인 요청 DTO + * + * @param orderId 주문 ID + * @param productId 상품 ID + * @param couponId 쿠폰 ID + * @param quantity 수량 + * @param unitPrice 단가 + * @param discountAmount 할인 금액 + * @author hyunjikoh + * @since 2025. 11. 14. + */ +public record OrderItemDomainCreateRequest( + Long orderId, + Long productId, + Long couponId, + Integer quantity, + BigDecimal unitPrice, + BigDecimal discountAmount +) { + /** + * 레코드 생성자 - 유효성 검증 + */ + public OrderItemDomainCreateRequest { + if (orderId == null) { + throw new IllegalArgumentException("주문 ID는 필수입니다."); + } + + if (productId == null) { + throw new IllegalArgumentException("상품 ID는 필수입니다."); + } + + if (quantity == null) { + throw new IllegalArgumentException("주문 수량은 필수입니다."); + } + + if (quantity <= 0) { + throw new IllegalArgumentException("주문 수량은 1 이상이어야 합니다."); + } + + if (quantity > 999) { + throw new IllegalArgumentException("주문 수량은 999개를 초과할 수 없습니다."); + } + + if (unitPrice == null) { + throw new IllegalArgumentException("단가는 필수입니다."); + } + + if (unitPrice.compareTo(BigDecimal.ZERO) <= 0) { + throw new IllegalArgumentException("단가는 0보다 커야 합니다."); + } + + // discountAmount는 null이면 0으로 처리 + if (discountAmount != null && discountAmount.compareTo(BigDecimal.ZERO) < 0) { + throw new IllegalArgumentException("할인 금액은 0 이상이어야 합니다."); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/point/PointHistoryEntity.java b/apps/commerce-api/src/main/java/com/loopers/domain/point/PointHistoryEntity.java new file mode 100644 index 000000000..136cb4852 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/point/PointHistoryEntity.java @@ -0,0 +1,106 @@ +package com.loopers.domain.point; + +import java.math.BigDecimal; +import java.util.Objects; + +import com.loopers.domain.BaseEntity; +import com.loopers.domain.user.UserEntity; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import jakarta.persistence.*; + +/** + * @author hyunjikoh + * @since 2025. 10. 31. + */ +@Entity +@Table(name = "point_histories") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class PointHistoryEntity extends BaseEntity { + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private UserEntity user; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private PointTransactionType transactionType; + + @Column(precision = 9, scale = 2, nullable = false) + private BigDecimal amount; + + @Column(precision = 9, scale = 2, nullable = false) + private BigDecimal balanceAfter; + + private PointHistoryEntity(UserEntity user, PointTransactionType transactionType, BigDecimal amount, + BigDecimal balanceAfter) { + validateInputs(user, transactionType, amount, balanceAfter); + + this.user = user; + this.transactionType = transactionType; + this.amount = amount; + this.balanceAfter = balanceAfter; + } + + /** + * 포인트 충전 이력을 생성합니다. + * + * @param user 사용자 엔티티 + * @param chargeAmount 충전 금액 + * @param balanceAfter 충전 후 잔액 + * @return 충전 이력 엔티티 + */ + public static PointHistoryEntity createChargeHistory(UserEntity user, BigDecimal chargeAmount, BigDecimal balanceAfter) { + return new PointHistoryEntity(user, PointTransactionType.CHARGE, chargeAmount, balanceAfter); + } + + /** + * 포인트 충전 이력을 생성합니다. + * + * @param user 사용자 엔티티 + * @param chargeAmount 충전 금액 + * @param balanceAfter 충전 후 잔액 + * @return 충전 이력 엔티티 + */ + public static PointHistoryEntity createRefundHistory(UserEntity user, BigDecimal chargeAmount, BigDecimal balanceAfter) { + return new PointHistoryEntity(user, PointTransactionType.REFUND, chargeAmount, balanceAfter); + } + + /** + * 포인트 사용 이력을 생성합니다. + * + * @param user 사용자 엔티티 + * @param useAmount 사용 금액 + * @param balanceAfter 사용 후 잔액 + * @return 사용 이력 엔티티 + */ + public static PointHistoryEntity createUseHistory(UserEntity user, BigDecimal useAmount, BigDecimal balanceAfter) { + return new PointHistoryEntity(user, PointTransactionType.USE, useAmount, balanceAfter); + } + + /** + * 입력값 유효성을 검사합니다. + */ + private void validateInputs(UserEntity user, PointTransactionType transactionType, BigDecimal amount, + BigDecimal balanceAfter) { + if (Objects.isNull(user)) { + throw new IllegalArgumentException("사용자 엔티티는 필수값입니다."); + } + + if (Objects.isNull(transactionType)) { + throw new IllegalArgumentException("거래 유형은 필수값입니다."); + } + + if (Objects.isNull(amount) || amount.compareTo(BigDecimal.ZERO) <= 0) { + throw new IllegalArgumentException("거래 금액은 0보다 커야 합니다."); + } + + if (Objects.isNull(balanceAfter) || balanceAfter.compareTo(BigDecimal.ZERO) < 0) { + throw new IllegalArgumentException("거래 후 잔액은 0 이상이어야 합니다."); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/point/PointHistoryRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/point/PointHistoryRepository.java new file mode 100644 index 000000000..09e73165f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/point/PointHistoryRepository.java @@ -0,0 +1,21 @@ +package com.loopers.domain.point; + +import java.util.List; + +import com.loopers.domain.user.UserEntity; + +/** + * @author hyunjikoh + * @since 2025. 10. 31. + */ +public interface PointHistoryRepository { + PointHistoryEntity save(PointHistoryEntity pointHistory); + + /** + * 특정 사용자의 포인트 이력을 생성일 기준 내림차순으로 조회합니다. + * + * @param user 사용자 엔티티 + * @return 포인트 이력 목록 (최신순) + */ + List findByUserOrderByCreatedAtDesc(UserEntity user); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/point/PointService.java b/apps/commerce-api/src/main/java/com/loopers/domain/point/PointService.java new file mode 100644 index 000000000..3c5a34371 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/point/PointService.java @@ -0,0 +1,106 @@ +package com.loopers.domain.point; + +import java.math.BigDecimal; +import java.util.List; + +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import com.loopers.domain.user.UserEntity; +import com.loopers.domain.user.UserRepository; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; + +import lombok.RequiredArgsConstructor; + +/** + * @author hyunjikoh + * @since 2025. 10. 29. + */ +@Component +@RequiredArgsConstructor +public class PointService { + private final UserRepository userRepository; + private final PointHistoryRepository pointHistoryRepository; + + /** + * 사용자의 포인트 이력을 조회합니다. + * TODO : 페이징이 필요할 경우 별도 메소드 추가 + * + * @param username 사용자명 + * @return 포인트 이력 목록 (최신순) + */ + @Transactional(readOnly = true) + public List getPointHistories(String username) { + UserEntity user = userRepository.findByUsername(username) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND_USER)); + + return pointHistoryRepository.findByUserOrderByCreatedAtDesc(user); + } + + /** + * 사용자에게 포인트를 충전합니다. + * + * @param username 사용자명 + * @param amount 충전할 금액 + * @return 충전 후 포인트 잔액 + */ + @Transactional + public BigDecimal charge(String username, BigDecimal amount) { + UserEntity user = userRepository.findByUsername(username) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND_USER, "존재하지 않는 사용자입니다.")); + + user.chargePoint(amount); + + // 포인트 이력 생성 + PointHistoryEntity history = PointHistoryEntity.createChargeHistory(user, amount, user.getPointAmount()); + pointHistoryRepository.save(history); + + userRepository.save(user); + + return user.getPointAmount(); + } + + /** + * 사용자에게 포인트를 충전합니다. + * + * @param username 사용자명 + * @param amount 충전할 금액 + * @return 충전 후 포인트 잔액 + */ + @Transactional + public BigDecimal refund(String username, BigDecimal amount) { + UserEntity user = userRepository.findByUsername(username) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND_USER, "존재하지 않는 사용자입니다.")); + + user.chargePoint(amount); + + // 포인트 이력 생성 + PointHistoryEntity history = PointHistoryEntity.createRefundHistory(user, amount, user.getPointAmount()); + pointHistoryRepository.save(history); + + userRepository.save(user); + + return user.getPointAmount(); + } + + /** + * 사용자의 포인트를 사용합니다. + * + * @param user 사용자명 + * @param amount 사용할 금액 + * @return 사용 후 포인트 잔액 + */ + @Transactional + public BigDecimal use(UserEntity user, BigDecimal amount) { + user.usePoint(amount); + + // 포인트 이력 생성 (사용) + PointHistoryEntity history = PointHistoryEntity.createUseHistory(user, amount, user.getPointAmount()); + pointHistoryRepository.save(history); + + userRepository.save(user); + + return user.getPointAmount(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/point/PointTransactionType.java b/apps/commerce-api/src/main/java/com/loopers/domain/point/PointTransactionType.java new file mode 100644 index 000000000..96e7c5c1e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/point/PointTransactionType.java @@ -0,0 +1,11 @@ +package com.loopers.domain.point; + +/** + * @author hyunjikoh + * @since 2025. 10. 31. + */ +public enum PointTransactionType { + CHARGE, // 충전 + USE, // 사용 + REFUND // 환불 +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/Price.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/Price.java new file mode 100644 index 000000000..5a552d0a3 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/Price.java @@ -0,0 +1,157 @@ +package com.loopers.domain.product; + +import java.math.BigDecimal; +import java.math.RoundingMode; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; + +/** + * 상품 가격을 표현하는 Value Object + * 정가와 할인가를 관리하며, 가격 관련 비즈니스 로직을 캡슐화한다. + * + * @author hyunjikoh + * @since 2025. 11. 10. + */ +@Embeddable +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Price { + + @Column(name = "origin_price", precision = 10, scale = 2, nullable = false) + private BigDecimal originPrice; + + @Column(name = "discount_price", precision = 10, scale = 2) + private BigDecimal discountPrice; + + /** + * 가격 정보를 생성한다. + * + * @param originPrice 정가 (필수) + * @param discountPrice 할인가 (선택) + * @return Price 객체 + */ + public static Price of(BigDecimal originPrice, BigDecimal discountPrice) { + validatePrice(originPrice, discountPrice); + + Price price = new Price(); + price.originPrice = originPrice.setScale(2, RoundingMode.HALF_UP); + price.discountPrice = discountPrice != null ? + discountPrice.setScale(2, RoundingMode.HALF_UP) : null; + + return price; + } + + + public static Price of(BigDecimal originPrice) { + validatePrice(originPrice, null); + + Price price = new Price(); + price.originPrice = originPrice.setScale(2, RoundingMode.HALF_UP); + price.discountPrice = null; + + return price; + } + + /** + * 정가만으로 가격 정보를 생성한다. + * + * @param originPrice 정가 + * @return Price 객체 + */ + public static Price ofOriginOnly(BigDecimal originPrice) { + return of(originPrice, null); + } + + /** + * 실제 판매 가격을 반환한다. + * 할인가가 있으면 할인가를, 없으면 정가를 반환한다. + * + * @return 실제 판매 가격 + */ + public BigDecimal getSellingPrice() { + return discountPrice != null ? discountPrice : originPrice; + } + + /** + * 할인율을 계산한다. + * + * @return 할인율 (0-100 사이의 값, 할인가가 없으면 0) + */ + public BigDecimal getDiscountRate() { + if (discountPrice == null || discountPrice.compareTo(originPrice) >= 0) { + return BigDecimal.ZERO; + } + + BigDecimal discount = originPrice.subtract(discountPrice); + return discount.divide(originPrice, 4, RoundingMode.HALF_UP) + .multiply(BigDecimal.valueOf(100)) + .setScale(2, RoundingMode.HALF_UP); + } + + /** + * 할인 여부를 확인한다. + * + * @return 할인 상품인지 여부 + */ + public boolean isDiscounted() { + return discountPrice != null && discountPrice.compareTo(originPrice) < 0; + } + + /** + * 할인 금액을 계산한다. + * + * @return 할인 금액 (할인가가 없거나 할인가가 정가보다 크거나 같으면 0) + */ + public BigDecimal getDiscountAmount() { + if (!isDiscounted()) { + return BigDecimal.ZERO; + } + return originPrice.subtract(discountPrice); + } + + /** + * 할인가를 설정한다. + * + * @param discountPrice 할인가 + */ + public void applyDiscount(BigDecimal discountPrice) { + validateDiscountPrice(this.originPrice, discountPrice); + this.discountPrice = discountPrice.setScale(2, RoundingMode.HALF_UP); + } + + /** + * 할인을 제거한다. + */ + public void removeDiscount() { + this.discountPrice = null; + } + + private static void validatePrice(BigDecimal originPrice, BigDecimal discountPrice) { + if (originPrice == null) { + throw new IllegalArgumentException("정가는 필수입니다."); + } + + if (originPrice.compareTo(BigDecimal.ZERO) <= 0) { + throw new IllegalArgumentException("정가는 0보다 커야 합니다."); + } + + if (discountPrice != null) { + validateDiscountPrice(originPrice, discountPrice); + } + } + + private static void validateDiscountPrice(BigDecimal originPrice, BigDecimal discountPrice) { + if (discountPrice.compareTo(BigDecimal.ZERO) < 0) { + throw new IllegalArgumentException("할인가는 음수일 수 없습니다."); + } + + if (discountPrice.compareTo(originPrice) > 0) { + throw new IllegalArgumentException("할인가는 정가보다 클 수 없습니다."); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductCacheService.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductCacheService.java new file mode 100644 index 000000000..7fc66843a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductCacheService.java @@ -0,0 +1,376 @@ +package com.loopers.domain.product; + +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.TimeUnit; + +import org.springframework.data.domain.Pageable; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.application.product.ProductDetailInfo; +import com.loopers.domain.product.dto.ProductSearchFilter; +import com.loopers.infrastructure.cache.CacheKeyGenerator; +import com.loopers.infrastructure.cache.CacheStrategy; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * 상품 캐시 서비스 구현체 + * Hot/Warm/Cold 전략별로 Redis 캐싱 관리 + * 캐시 실패 시 로깅만 하고 서비스는 계속 동작 + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class ProductCacheService { + + private final RedisTemplate redisTemplate; + private final CacheKeyGenerator cacheKeyGenerator; + private final ObjectMapper objectMapper; + + // TTL 상수 - Hot/Warm/Cold 전략별 차별화 + private static final long HOT_DATA_TTL = 30; // Hot: 30분 (배치 갱신) + private static final long WARM_DATA_TTL = 10; // Warm: 10분 (Cache-Aside) + private static final TimeUnit TTL_UNIT = TimeUnit.MINUTES; + + // ========== Hot: 상품 상세 (배치 갱신, TTL 60분) ========== + + public void cacheProductDetail(Long productId, ProductDetailInfo productDetail) { + try { + String key = cacheKeyGenerator.generateProductDetailKey(productId); + String value = objectMapper.writeValueAsString(productDetail); + + redisTemplate.opsForValue().set(key, value, HOT_DATA_TTL, TTL_UNIT); + + log.debug("상품 상세 캐시 저장 - productId: {}", productId); + } catch (JsonProcessingException e) { + log.warn("상품 상세 캐시 저장 실패 (JSON 직렬화 오류) - productId: {}, error: {}", + productId, e.getMessage()); + } catch (Exception e) { + log.warn("상품 상세 캐시 저장 실패 - productId: {}, error: {}", + productId, e.getMessage()); + } + } + + + public void batchCacheProductDetails(java.util.List productDetails) { + if (productDetails == null || productDetails.isEmpty()) { + log.debug("배치 캐시 저장 대상 없음"); + return; + } + + int successCount = 0; + int failCount = 0; + + for (ProductDetailInfo productDetail : productDetails) { + try { + cacheProductDetail(productDetail.id(), productDetail); + successCount++; + } catch (Exception e) { + failCount++; + log.warn("배치 캐시 저장 실패 - productId: {}", productDetail.id()); + } + } + + log.info("배치 캐시 완료 - 성공: {}, 실패: {}", successCount, failCount); + } + + + public Optional getProductDetailFromCache(Long productId) { + try { + String key = cacheKeyGenerator.generateProductDetailKey(productId); + String value = redisTemplate.opsForValue().get(key); + + if (value == null) { + log.debug("캐시 미스 - productId: {}", productId); + return Optional.empty(); + } + + ProductDetailInfo productDetail = objectMapper.readValue(value, ProductDetailInfo.class); + log.debug("캐시 히트 - productId: {}", productId); + + return Optional.of(productDetail); + } catch (JsonProcessingException e) { + log.warn("캐시 조회 실패 (JSON 역직렬화) - productId: {}", productId); + return Optional.empty(); + } catch (Exception e) { + log.warn("캐시 조회 실패 - productId: {}", productId); + return Optional.empty(); + } + } + + + public void evictProductDetail(Long productId) { + try { + String key = cacheKeyGenerator.generateProductDetailKey(productId); + redisTemplate.delete(key); + + log.debug("캐시 삭제 - productId: {}", productId); + } catch (Exception e) { + log.warn("캐시 삭제 실패 - productId: {}", productId); + } + } + + // ========== Hot/Warm: 상품 ID 리스트 (Hot: 60분, Warm: 10분) ========== + + + public void cacheProductIds(CacheStrategy strategy, Long brandId, + Pageable pageable, + List productIds) { + try { + String key = cacheKeyGenerator.generateProductIdsKey(strategy, brandId, pageable); + String value = objectMapper.writeValueAsString(productIds); + + long ttl = strategy == CacheStrategy.HOT ? HOT_DATA_TTL : WARM_DATA_TTL; + redisTemplate.opsForValue().set(key, value, ttl, TTL_UNIT); + + log.debug("상품 ID 리스트 캐시 저장 - strategy: {}, brandId: {}", strategy, brandId); + } catch (JsonProcessingException e) { + log.warn("상품 ID 리스트 캐시 저장 실패 (JSON 직렬화) - strategy: {}", strategy); + } catch (Exception e) { + log.warn("상품 ID 리스트 캐시 저장 실패 - strategy: {}", strategy); + } + } + + + public Optional> getProductIdsFromCache(CacheStrategy strategy, Long brandId, + Pageable pageable) { + try { + String key = cacheKeyGenerator.generateProductIdsKey(strategy, brandId, pageable); + String value = redisTemplate.opsForValue().get(key); + + if (value == null) { + log.debug("캐시 미스 - strategy: {}, brandId: {}", strategy, brandId); + return Optional.empty(); + } + + List productIds = objectMapper.readValue(value, + objectMapper.getTypeFactory().constructCollectionType(List.class, Long.class)); + + log.debug("캐시 히트 - strategy: {}, brandId: {}", strategy, brandId); + + return Optional.of(productIds); + } catch (JsonProcessingException e) { + log.warn("캐시 조회 실패 (JSON 역직렬화) - strategy: {}", strategy); + return Optional.empty(); + } catch (Exception e) { + log.warn("캐시 조회 실패 - strategy: {}", strategy); + return Optional.empty(); + } + } + + + public void evictProductIdsByStrategy(CacheStrategy strategy) { + try { + String pattern = cacheKeyGenerator.generateProductIdsPattern(strategy); + deleteByPattern(pattern); + + log.debug("전략별 캐시 삭제 - strategy: {}", strategy); + } catch (Exception e) { + log.warn("전략별 캐시 삭제 실패 - strategy: {}", strategy); + } + } + + // ========== Cold: 전체 Page 객체 (레거시, TTL 5분) ========== + + + public void evictProductListByBrand(Long brandId) { + try { + String pattern = cacheKeyGenerator.generateProductListPatternByBrand(brandId); + deleteByPattern(pattern); + + log.debug("브랜드 목록 캐시 삭제 - brandId: {}", brandId); + } catch (Exception e) { + log.warn("브랜드 목록 캐시 삭제 실패 - brandId: {}", brandId); + } + } + + // ========== 범용 캐시 연산 ========== + + + public void set(String key, Object value, long timeout, TimeUnit timeUnit) { + try { + String jsonValue = objectMapper.writeValueAsString(value); + redisTemplate.opsForValue().set(key, jsonValue, timeout, timeUnit); + + log.debug("캐시 저장 - key: {}", key); + } catch (JsonProcessingException e) { + log.warn("캐시 저장 실패 (JSON 직렬화) - key: {}", key); + } catch (Exception e) { + log.warn("캐시 저장 실패 - key: {}", key); + } + } + + + public Optional get(String key, Class clazz) { + try { + String value = redisTemplate.opsForValue().get(key); + + if (value == null) { + log.debug("캐시 미스 - key: {}", key); + return Optional.empty(); + } + + T result = objectMapper.readValue(value, clazz); + log.debug("캐시 히트 - key: {}", key); + + return Optional.of(result); + } catch (JsonProcessingException e) { + log.warn("캐시 조회 실패 (JSON 역직렬화) - key: {}", key); + return Optional.empty(); + } catch (Exception e) { + log.warn("캐시 조회 실패 - key: {}", key); + return Optional.empty(); + } + } + + public void deleteByPattern(String pattern) { + try { + Set keys = redisTemplate.keys(pattern); + + if (keys != null && !keys.isEmpty()) { + redisTemplate.delete(keys); + log.debug("패턴 캐시 삭제 - pattern: {}, count: {}", pattern, keys.size()); + } else { + log.debug("삭제 대상 없음 - pattern: {}", pattern); + } + } catch (Exception e) { + log.warn("패턴 캐시 삭제 실패 - pattern: {}", pattern); + } + } + + // ========== 세밀한 캐시 무효화 (Incremental Invalidation) ========== + + + public void evictProductCaches(Set productIds) { + if (productIds == null || productIds.isEmpty()) { + log.debug("무효화 대상 없음"); + return; + } + + log.info("상품 캐시 무효화 - 대상: {}개", productIds.size()); + + int deletedCount = 0; + + for (Long productId : productIds) { + try { + evictProductDetail(productId); + deletedCount++; + } catch (Exception e) { + log.warn("상품 캐시 무효화 실패 - productId: {}", productId); + } + } + + log.info("상품 캐시 무효화 완료 - 삭제: {}개", deletedCount); + } + + + public void evictBrandCaches(Set brandIds) { + if (brandIds == null || brandIds.isEmpty()) { + log.debug("무효화 대상 없음"); + return; + } + + log.info("브랜드 캐시 무효화 - 대상: {}개", brandIds.size()); + + int deletedKeyCount = 0; + + for (Long brandId : brandIds) { + try { + // Hot 캐시 삭제 + String hotPattern = String.format("product:ids:hot:brand:%d:*", brandId); + Set hotKeys = redisTemplate.keys(hotPattern); + if (hotKeys != null && !hotKeys.isEmpty()) { + redisTemplate.delete(hotKeys); + deletedKeyCount += hotKeys.size(); + } + + // Warm 캐시 삭제 + String warmPattern = String.format("product:ids:warm:brand:%d:*", brandId); + Set warmKeys = redisTemplate.keys(warmPattern); + if (warmKeys != null && !warmKeys.isEmpty()) { + redisTemplate.delete(warmKeys); + deletedKeyCount += warmKeys.size(); + } + + // 레거시 캐시 삭제 + evictProductListByBrand(brandId); + + } catch (Exception e) { + log.warn("브랜드 캐시 무효화 실패 - brandId: {}", brandId); + } + } + + log.info("브랜드 캐시 무효화 완료 - 삭제 키: {}개", deletedKeyCount); + } + + + public void evictCachesAfterMVSync(Set changedProductIds, Set affectedBrandIds) { + log.info("MV 동기화 후 캐시 무효화 - 변경상품: {}개, 브랜드: {}개", + changedProductIds.size(), affectedBrandIds.size()); + + long startTime = System.currentTimeMillis(); + + try { + // 1. 변경된 상품 상세 캐시 무효화 + evictProductCaches(changedProductIds); + + // 2. 영향받은 브랜드 목록 캐시 무효화 + evictBrandCaches(affectedBrandIds); + + // 3. 전체 상품 목록 캐시 무효화 + if (!changedProductIds.isEmpty()) { + deleteByPattern("product:ids:hot:brand:null:*"); + deleteByPattern("product:ids:warm:brand:null:*"); + } + + long duration = System.currentTimeMillis() - startTime; + log.info("MV 동기화 캐시 무효화 완료 - {}ms", duration); + + } catch (Exception e) { + long duration = System.currentTimeMillis() - startTime; + log.error("MV 동기화 캐시 무효화 실패 - {}ms", duration, e); + } + } + + /** + * 캐시 전략 결정 + */ + public CacheStrategy determineCacheStrategy(ProductSearchFilter filter) { + if (filter.pageable().getPageNumber() == 0) + return CacheStrategy.HOT; + + if (filter.pageable().getPageNumber() > 2) + return CacheStrategy.COLD; + + if (filter.productName() != null && !filter.productName().trim().isEmpty()) { + log.debug("Cold 전략 선택 - 상품명 검색: {}", filter.productName()); + return CacheStrategy.COLD; + } + + if (filter.brandId() != null) { + if (isPopularitySort(filter.pageable())) { + log.debug("Hot 전략 선택 - 브랜드: {}, 인기순 정렬", filter.brandId()); + return CacheStrategy.HOT; + } + log.debug("Warm 전략 선택 - 브랜드: {}", filter.brandId()); + return CacheStrategy.WARM; + } + + log.debug("Warm 전략 선택 - 전체 목록"); + return CacheStrategy.WARM; + } + + /** + * 인기순 정렬 여부 확인 + */ + private boolean isPopularitySort(Pageable pageable) { + return pageable.getSort().stream() + .anyMatch(order -> "likeCount".equals(order.getProperty()) && order.isDescending()); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductDomainCreateRequest.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductDomainCreateRequest.java new file mode 100644 index 000000000..0a9c0e8ef --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductDomainCreateRequest.java @@ -0,0 +1,69 @@ +package com.loopers.domain.product; + +import java.math.BigDecimal; + +import jakarta.validation.constraints.*; + +/** + * 상품 도메인 생성 요청 DTO + * + * 도메인 레이어에서 사용하는 상품 생성 요청 정보입니다. + * Application Layer에서 Domain Layer로 전달되는 데이터 구조입니다. + * + * @author hyunjikoh + * @since 2025. 11. 12. + */ +public record ProductDomainCreateRequest( + @NotNull(message = "브랜드 ID는 필수입니다.") + Long brandId, + + @NotBlank(message = "상품명은 필수입니다.") + @Size(max = 200, message = "상품명은 200자를 초과할 수 없습니다.") + String name, + + String description, + + @NotNull(message = "정가는 필수입니다.") + @Min(value = 1, message = "정가는 0보다 커야 합니다.") + BigDecimal originPrice, + + @PositiveOrZero(message = "할인가는 0 이상이어야 합니다.") + BigDecimal discountPrice, + + @NotNull(message = "재고 수량은 필수입니다.") + @PositiveOrZero(message = "재고 수량은 0 이상이어야 합니다.") + Integer stockQuantity +) { + /** + * 정적 팩토리 메서드 (할인가 없는 경우) + * + * @param brandId 브랜드 ID + * @param name 상품명 + * @param description 상품 설명 + * @param originPrice 정가 + * @param stockQuantity 재고 수량 + * @return ProductDomainCreateRequest 인스턴스 + */ + public static ProductDomainCreateRequest of(Long brandId, String name, String description, + BigDecimal originPrice, Integer stockQuantity) { + return new ProductDomainCreateRequest(brandId, name, description, originPrice, null, stockQuantity); + } + + /** + * 정적 팩토리 메서드 (할인가 있는 경우) + * + * @param brandId 브랜드 ID + * @param name 상품명 + * @param description 상품 설명 + * @param originPrice 정가 + * @param discountPrice 할인가 + * @param stockQuantity 재고 수량 + * @return ProductDomainCreateRequest 인스턴스 + */ + public static ProductDomainCreateRequest of(Long brandId, String name, String description, + BigDecimal originPrice, BigDecimal discountPrice, + Integer stockQuantity) { + return new ProductDomainCreateRequest(brandId, name, description, originPrice, discountPrice, stockQuantity); + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductEntity.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductEntity.java new file mode 100644 index 000000000..f98ef0a8d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductEntity.java @@ -0,0 +1,285 @@ +package com.loopers.domain.product; + +import static java.util.Objects.requireNonNull; +import java.math.BigDecimal; +import java.util.Objects; + +import com.loopers.domain.BaseEntity; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import jakarta.persistence.*; +import jakarta.validation.constraints.NotNull; + +/** + * 상품 엔티티 + * + * @author hyunjikoh + * @since 2025. 11. 10. + */ +@Entity +@Table(name = "products", indexes = { + @Index(name = "idx_productentity_brand_id", columnList = "brand_id"), + @Index(name = "idx_productentity_name", columnList = "name"), + @Index(name = "idx_productentity_like_count", columnList = "like_count"), + @Index(name = "idx_productentity_brand_id_like_count", columnList = "brand_id, like_count") +}) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ProductEntity extends BaseEntity { + + @Column(name = "brand_id", nullable = false) + private Long brandId; + + @Column(name = "name", nullable = false, length = 200) + private String name; + + @Column(name = "description", columnDefinition = "TEXT") + private String description; + + @Embedded + @NotNull + private Price price; + + @Column(name = "stock_quantity", nullable = false) + private Integer stockQuantity; + + // like_count는 ProductLikeStatsEntity(MV 테이블)에서 관리 + // @Column(name = "like_count", nullable = false) + // private Long likeCount = 0L; + + /** + * 상품 엔티티 생성자 + * + * @param brandId 브랜드 ID + * @param name 상품명 + * @param description 상품 설명 + * @param originPrice 정가 + * @param discountPrice 할인가 + * @param stockQuantity 재고 수량 + */ + public ProductEntity(Long brandId, String name, String description, + BigDecimal originPrice, BigDecimal discountPrice, Integer stockQuantity) { + requireNonNull(brandId, "브랜드 ID는 필수입니다."); + requireNonNull(name, "상품명은 필수입니다."); + requireNonNull(originPrice, "정가는 필수입니다."); + requireNonNull(stockQuantity, "재고 수량은 필수입니다."); + + validateName(name); + validateOriginPrice(originPrice); + validateDiscountPrice(originPrice, discountPrice); + validateStockQuantity(stockQuantity); + + this.brandId = brandId; + this.name = name.trim(); + this.description = description != null ? description.trim() : null; + this.price = discountPrice != null ? Price.of(originPrice, discountPrice) : Price.of(originPrice); + this.stockQuantity = stockQuantity; + } + + /** + * 상품 엔티티를 생성한다. + * + * @param request 상품 생성 요청 정보 + * @return 생성된 상품 엔티티 + */ + public static ProductEntity createEntity(ProductDomainCreateRequest request) { + if (Objects.isNull(request)) { + throw new IllegalArgumentException("상품 생성 요청 정보는 필수입니다."); + } + + if (request.brandId() == null) { + throw new IllegalArgumentException("브랜드 ID는 필수입니다."); + } + + if (request.name() == null || request.name().trim().isEmpty()) { + throw new IllegalArgumentException("상품명은 필수입니다."); + } + + if (request.originPrice() == null || request.originPrice().compareTo(BigDecimal.ZERO) <= 0) { + throw new IllegalArgumentException("정가는 0보다 커야 합니다."); + } + + if (request.discountPrice() != null && request.discountPrice().compareTo(BigDecimal.ZERO) <= 0) { + throw new IllegalArgumentException("할인가는 0보다 커야 합니다."); + } + + if (request.discountPrice() != null && request.discountPrice().compareTo(request.originPrice()) >= 0) { + throw new IllegalArgumentException("할인가는 정가보다 작아야 합니다."); + } + + if (request.stockQuantity() == null || request.stockQuantity() < 0) { + throw new IllegalArgumentException("재고 수량은 0 이상이어야 합니다."); + } + + return new ProductEntity( + request.brandId(), + request.name(), + request.description(), + request.originPrice(), + request.discountPrice(), + request.stockQuantity() + ); + } + + /** + * 재고를 차감한다. + * + * @param quantity 차감할 재고 수량 + */ + public void deductStock(int quantity) { + if (quantity <= 0) { + throw new IllegalArgumentException("차감할 재고 수량은 0보다 커야 합니다."); + } + + if (this.stockQuantity < quantity) { + throw new CoreException( + ErrorType.INSUFFICIENT_STOCK, + String.format("재고가 부족합니다. (요청: %d, 보유: %d)", quantity, this.stockQuantity) + ); + } + + this.stockQuantity -= quantity; + } + + /** + * 재고를 원복한다. + * + * 주문 취소 시 차감된 재고를 다시 복구합니다. + * + * @param quantity 원복할 재고 수량 + */ + public void restoreStock(int quantity) { + if (quantity <= 0) { + throw new IllegalArgumentException("원복할 재고 수량은 0보다 커야 합니다."); + } + + this.stockQuantity += quantity; + } + + + // increaseLikeCount, decreaseLikeCount 메서드는 제거 + // 좋아요 수는 ProductLikeStatsEntity(MV 테이블)에서 관리 + + /** + * 재고 여부를 확인한다. + * + * @return 재고가 있는지 여부 + */ + public boolean hasStock() { + return this.stockQuantity > 0; + } + + /** + * 지정된 수량만큼 주문 가능한지 확인한다. + * TODO : 향후 예약 주문 기능이 추가되면 수정 필요 + * + * @param quantity 주문 수량 + * @return 주문 가능 여부 + */ + public boolean canOrder(int quantity) { + return this.stockQuantity >= quantity && quantity > 0; + } + + /** + * 할인 여부를 확인한다. + * + * @return 할인 상품인지 여부 + */ + public boolean isDiscounted() { + return this.price.isDiscounted(); + } + + @Override + protected void guard() { + if (this.brandId == null) { + throw new IllegalStateException("브랜드 ID는 필수입니다."); + } + + if (this.name == null || this.name.isBlank()) { + throw new IllegalStateException("상품명은 비어있을 수 없습니다."); + } + + if (this.name.length() > 200) { + throw new IllegalStateException("상품명은 200자를 초과할 수 없습니다."); + } + + if (this.price == null) { + throw new IllegalStateException("가격 정보는 필수입니다."); + } + + if (this.stockQuantity == null || this.stockQuantity < 0) { + throw new IllegalStateException("재고 수량은 0 이상이어야 합니다."); + } + + // likeCount 검증 제거 - MV 테이블에서 관리 + } + + /** + * 상품명 유효성을 검증한다. + */ + private void validateName(String name) { + if (name == null || name.trim().isEmpty()) { + throw new IllegalArgumentException("상품명은 필수 입력값입니다."); + } + + if (name.length() > 200) { + throw new IllegalArgumentException("상품명은 200자를 초과할 수 없습니다."); + } + } + + /** + * 정가 유효성을 검증한다. + */ + private void validateOriginPrice(BigDecimal originPrice) { + if (originPrice == null) { + throw new IllegalArgumentException("정가는 필수 입력값입니다."); + } + + if (originPrice.compareTo(BigDecimal.ZERO) <= 0) { + throw new IllegalArgumentException("정가는 0보다 커야 합니다."); + } + } + + /** + * 할인가 유효성을 검증한다. + */ + private void validateDiscountPrice(BigDecimal originPrice, BigDecimal discountPrice) { + if (discountPrice == null) { + return; // 할인가는 선택사항 + } + + if (discountPrice.compareTo(BigDecimal.ZERO) <= 0) { + throw new IllegalArgumentException("할인가는 0보다 커야 합니다."); + } + + if (discountPrice.compareTo(originPrice) >= 0) { + throw new IllegalArgumentException("할인가는 정가보다 작아야 합니다."); + } + } + + /** + * 재고 수량 유효성을 검증한다. + */ + private void validateStockQuantity(Integer stockQuantity) { + if (stockQuantity == null) { + throw new IllegalArgumentException("재고 수량은 필수 입력값입니다."); + } + + if (stockQuantity < 0) { + throw new IllegalArgumentException("재고 수량은 0 이상이어야 합니다."); + } + } + + public void setStockQuantity(int stockQuantity) { + this.stockQuantity = stockQuantity; + } + + public BigDecimal getSellingPrice() { + return price.getSellingPrice(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductMVRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductMVRepository.java new file mode 100644 index 000000000..70a0d8806 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductMVRepository.java @@ -0,0 +1,115 @@ +package com.loopers.domain.product; + +import java.time.ZonedDateTime; +import java.util.List; +import java.util.Optional; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import com.loopers.domain.product.dto.ProductSearchFilter; + +/** + * 상품 Materialized View 리포지토리 인터페이스 + * + * MV 테이블에 대한 데이터 접근 계층을 정의합니다. + * 실제 구현은 Infrastructure 계층에서 제공됩니다. + * + * @author hyunjikoh + * @since 2025. 11. 27. + */ +public interface ProductMVRepository { + + /** + * ID로 상품 MV를 조회합니다. + * + * @param productId 상품 ID + * @return 상품 MV (존재하지 않으면 Optional.empty()) + */ + Optional findById(Long productId); + /** + * 브랜드별 상품 MV를 페이징 조회합니다. + * + * @param brandId 브랜드 ID + * @param pageable 페이징 정보 + * @return 페이징된 상품 MV 목록 + */ + Page findByBrandId(Long brandId, Pageable pageable); + + /** + * 상품명으로 검색하여 MV를 페이징 조회합니다. + * + * @param keyword 검색 키워드 (상품명에 포함된 문자열) + * @param pageable 페이징 정보 + * @return 페이징된 상품 MV 목록 + */ + Page findByNameContaining(String keyword, Pageable pageable); + + /** + * 여러 상품 ID로 MV를 일괄 조회합니다. + * + * @param productIds 상품 ID 목록 + * @return 상품 MV 목록 + */ + List findByIdIn(List productIds); + + /** + * 지정된 시간 이전에 업데이트된 MV를 조회합니다. + * 배치 업데이트 시 변경된 데이터를 찾기 위해 사용됩니다. + * + * @param time 기준 시간 + * @return 업데이트가 필요한 MV 목록 + */ + List findByLastUpdatedAtBefore(ZonedDateTime time); + + /** + * MV 엔티티를 저장합니다. + * + * @param entity 저장할 MV 엔티티 + * @return 저장된 MV 엔티티 + */ + ProductMaterializedViewEntity save(ProductMaterializedViewEntity entity); + + /** + * MV 엔티티 목록을 일괄 저장합니다. + * 배치 업데이트 시 사용됩니다. + * + * @param entities 저장할 MV 엔티티 목록 + * @return 저장된 MV 엔티티 목록 + */ + List saveAll(List entities); + + /** + * 지정된 상품 ID 목록에 해당하는 MV를 삭제합니다. + * 상품이 삭제되었을 때 MV도 함께 삭제하기 위해 사용됩니다. + * + * @param productIds 삭제할 상품 ID 목록 + */ + void deleteByProductIdIn(List productIds); + + /** + * MV가 존재하는지 확인합니다. + * + * @param productId 상품 ID + * @return 존재 여부 + */ + boolean existsByProductId(Long productId); + + /** + * 전체 MV 개수를 조회합니다. + * + * @return MV 개수 + */ + long count(); + + /** + * 검색 필터를 기반으로 상품 MV를 페이징 조회합니다. + * + * @param searchFilter 검색 필터 (브랜드 ID, 상품명, 페이징 정보) + * @return 페이징된 상품 MV 목록 + */ + Page findBySearchFilter(ProductSearchFilter searchFilter); + + + List findChangedProductsForSync(ZonedDateTime lastBatchTime); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductMVService.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductMVService.java new file mode 100644 index 000000000..6d7fa3b1b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductMVService.java @@ -0,0 +1,260 @@ +package com.loopers.domain.product; + +import java.time.ZonedDateTime; +import java.util.*; +import java.util.stream.Collectors; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.loopers.application.product.BatchUpdateResult; +import com.loopers.domain.brand.BrandEntity; +import com.loopers.domain.product.dto.ProductSearchFilter; +import com.loopers.infrastructure.cache.CacheStrategy; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * 상품 Materialized View 서비스 + *

+ * MV 테이블 조회 및 배치 동기화를 담당하는 서비스입니다. + * 상품, 브랜드, 좋아요 정보를 통합하여 조회 성능을 최적화합니다. + * + * @author hyunjikoh + * @since 2025. 11. 27. + */ +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +@Slf4j +public class ProductMVService { + + private final ProductMVRepository mvRepository; + private final ProductCacheService productCacheService; + private ZonedDateTime lastBatchTime = ZonedDateTime.now().minusYears(1); // 초기값 + + /** + * 상품 ID로 MV를 조회합니다. + * + * @param productId 상품 ID + * @return 상품 MV (존재하지 않으면 Optional.empty()) + */ + public ProductMaterializedViewEntity getById(Long productId) { + return mvRepository.findById(productId). + orElseThrow(() -> new CoreException( + ErrorType.NOT_FOUND_PRODUCT, + String.format("상품을 찾을 수 없습니다. (ID: %d)", productId) + )); + } + + /** + * 브랜드별 상품 MV를 페이징 조회합니다. + * + * @param brandId 브랜드 ID + * @param pageable 페이징 정보 + * @return 페이징된 상품 MV 목록 + */ + public Page findByBrandId(Long brandId, Pageable pageable) { + return mvRepository.findByBrandId(brandId, pageable); + } + + /** + * @param productIds 상품 ID 목록 + * @param pageable 페이징 정보 + * @return 페이징된 상품 MV 목록 + */ + public Page findByIdsAsPage(List productIds, Pageable pageable) { + List mvEntities = mvRepository.findByIdIn(productIds); + return new PageImpl<>(mvEntities, pageable, mvEntities.size()); + } + + // ========== MV 엔티티 조회 (캐시 전략 포함) ========== + + /** + * 캐시 전략에 따라 MV 엔티티 목록을 조회합니다. + *

+ * 도메인 엔티티만 반환하며, DTO 변환은 Facade에서 수행합니다. + * + * @param filter 검색 조건 + * @param strategy 캐시 전략 + * @return MV 엔티티 페이지 + */ + @Transactional(readOnly = true) + public Page getMVEntitiesByStrategy( + ProductSearchFilter filter, + CacheStrategy strategy + ) { + return switch (strategy) { + case HOT -> getProductsWithCache(filter, CacheStrategy.HOT); + case WARM -> getProductsWithCache(filter, CacheStrategy.WARM); + default -> findBySearchFilter(filter); + }; + } + + public Page findBySearchFilter(ProductSearchFilter filter) { + return mvRepository.findBySearchFilter(filter); + } + + /** + * 캐시를 사용하여 MV 엔티티를 조회합니다. + */ + private Page getProductsWithCache( + ProductSearchFilter filter, + CacheStrategy strategy + ) { + Long brandId = filter.brandId(); + Pageable pageable = filter.pageable(); + + // 1. 캐시에서 ID 리스트 조회 + Optional> cachedIds = productCacheService.getProductIdsFromCache( + strategy, brandId, pageable + ); + + if (cachedIds.isPresent()) { + log.debug("{} 캐시 히트 - brandId: {}, page: {}", strategy, brandId, pageable.getPageNumber()); + return findByIdsAsPage(cachedIds.get(), pageable); + } + + log.debug("{} 캐시 미스 - brandId: {}, page: {}", strategy, brandId, pageable.getPageNumber()); + + // 2. 통합된 searchFilter 기반 조회로 변경 + Page mvProducts = mvRepository.findBySearchFilter(filter); + + // 3. ID 리스트 캐싱 + List productIds = mvProducts.getContent().stream() + .map(ProductMaterializedViewEntity::getProductId) + .toList(); + + productCacheService.cacheProductIds(strategy, brandId, pageable, productIds); + + return mvProducts; + } + + /** + * MV 테이블을 원본 테이블과 동기화합니다. + * 배치 스케줄러에서 주기적으로 호출됩니다. + * + * @return 배치 업데이트 결과 + */ + @Transactional + public BatchUpdateResult syncMaterializedView() { + long startTime = System.currentTimeMillis(); + int updatedCount = 0; + int createdCount = 0; + + // 변경 추적용 + Set changedProductIds = new HashSet<>(); + Set affectedBrandIds = new HashSet<>(); + + try { + log.info("MV 배치 동기화 시작 - 마지막 배치 시간: {}", lastBatchTime); + + List changedProducts = mvRepository.findChangedProductsForSync(lastBatchTime); + + if (changedProducts.isEmpty()) { + log.info("변경된 상품이 없습니다."); + long duration = System.currentTimeMillis() - startTime; + lastBatchTime = ZonedDateTime.now(); // 배치 시간 갱신 + return BatchUpdateResult.success(0, 0, duration, changedProductIds, affectedBrandIds); + } + + log.info("변경 감지: {}건", changedProducts.size()); + + // 2. 변경된 상품 ID 목록 추출 + List productIds = changedProducts.stream() + .map(ProductMVSyncDto::getProductId) + .collect(Collectors.toList()); + + // 3. 기존 MV 조회 (변경된 상품만) + Map existingMVMap = mvRepository.findByIdIn(productIds).stream() + .collect(Collectors.toMap(ProductMaterializedViewEntity::getProductId, mv -> mv)); + + // 4. MV 생성 또는 업데이트 + List toSave = new ArrayList<>(); + + for (ProductMVSyncDto dto : changedProducts) { + ProductMaterializedViewEntity existingMV = existingMVMap.get(dto.getProductId()); + + if (existingMV == null) { + // 신규 생성 + ProductMaterializedViewEntity newMV = ProductMaterializedViewEntity.fromDto(dto); + toSave.add(newMV); + createdCount++; + + // 변경 추적 + changedProductIds.add(dto.getProductId()); + affectedBrandIds.add(dto.getBrandId()); + } else { + // 기존 MV와 비교하여 실제 변경이 있는 경우만 업데이트 + boolean hasChanges = ProductMaterializedViewEntity.hasActualChangesFromDto(existingMV, dto); + + if (hasChanges) { + syncMVFromDto(existingMV, dto); + toSave.add(existingMV); + updatedCount++; + + // 변경 추적 + changedProductIds.add(dto.getProductId()); + affectedBrandIds.add(dto.getBrandId()); + } + } + } + + // 5. 일괄 저장 + if (!toSave.isEmpty()) { + mvRepository.saveAll(toSave); + } + + // 6. 배치 시간 갱신 + lastBatchTime = ZonedDateTime.now(); + + long duration = System.currentTimeMillis() - startTime; + log.info("MV 배치 동기화 완료 - 생성: {}건, 갱신: {}건, 변경된 상품: {}개, 영향받은 브랜드: {}개, 소요: {}ms", + createdCount, updatedCount, changedProductIds.size(), affectedBrandIds.size(), duration); + + return BatchUpdateResult.success( + createdCount, + updatedCount, + duration, + changedProductIds, + affectedBrandIds + ); + + } catch (Exception e) { + long duration = System.currentTimeMillis() - startTime; + log.error("MV 배치 동기화 실패 - 소요: {}ms", duration, e); + return BatchUpdateResult.failure(e.getMessage(), duration); + } + } + + + /** + * DTO로부터 기존 MV를 동기화합니다. + */ + private void syncMVFromDto(ProductMaterializedViewEntity mv, ProductMVSyncDto dto) { + ProductEntity product = new ProductEntity( + dto.getBrandId(), + dto.getProductName(), + dto.getProductDescription(), + dto.getOriginPrice(), + dto.getDiscountPrice(), + dto.getStockQuantity() + ); + + BrandEntity brand = new BrandEntity(dto.getBrandName(), null); + + mv.sync(product, brand, dto.getLikeCount() != null ? dto.getLikeCount() : 0L); + } + + + @Transactional + public void deleteById(Long productId) { + mvRepository.deleteByProductIdIn(List.of(productId)); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductMVSyncDto.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductMVSyncDto.java new file mode 100644 index 000000000..95661a5b7 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductMVSyncDto.java @@ -0,0 +1,38 @@ +package com.loopers.domain.product; + +import java.math.BigDecimal; +import java.time.ZonedDateTime; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * MV 동기화용 DTO + * + * Product, Brand, Like를 조인하여 한 번의 쿼리로 조회한 결과를 담습니다. + * + * @author hyunjikoh + * @since 2025. 11. 28. + */ +@Getter +@AllArgsConstructor +public class ProductMVSyncDto { + // 상품 정보 + private Long productId; + private String productName; + private String productDescription; + private BigDecimal originPrice; + private BigDecimal discountPrice; + private Integer stockQuantity; + private ZonedDateTime productUpdatedAt; + + // 브랜드 정보 + private Long brandId; + private String brandName; + private ZonedDateTime brandUpdatedAt; + + // 좋아요 정보 + private Long likeCount; + private ZonedDateTime likeUpdatedAt; + +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductMaterializedViewEntity.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductMaterializedViewEntity.java new file mode 100644 index 000000000..7487cc42a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductMaterializedViewEntity.java @@ -0,0 +1,313 @@ +package com.loopers.domain.product; + +import java.time.ZonedDateTime; +import java.util.Objects; + +import com.loopers.domain.BaseEntity; +import com.loopers.domain.brand.BrandEntity; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import jakarta.persistence.*; + +/** + * 상품 Materialized View 엔티티 + * + * 상품, 브랜드, 좋아요 정보를 통합하여 조회 성능을 최적화하기 위한 MV 테이블입니다. + * 실시간 업데이트가 아닌 배치 업데이트(2분 간격)를 통해 데이터를 동기화합니다. + * + * @author hyunjikoh + * @since 2025. 11. 27. + */ +@Entity +@Table(name = "product_materialized_view", indexes = { + @Index(name = "idx_pmv_brand_id", columnList = "brand_id"), + @Index(name = "idx_pmv_like_count", columnList = "like_count"), + @Index(name = "idx_pmv_brand_like", columnList = "brand_id, like_count"), + @Index(name = "idx_pmv_name", columnList = "name"), + @Index(name = "idx_pmv_updated_at", columnList = "last_updated_at"), + @Index(name = "idx_pmv_product_updated_at", columnList = "product_updated_at"), + @Index(name = "idx_pmv_like_updated_at", columnList = "like_updated_at"), + @Index(name = "idx_pmv_brand_updated_at", columnList = "brand_updated_at") +}) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ProductMaterializedViewEntity extends BaseEntity { + + @Column(name = "product_id", nullable = false, unique = true) + private Long productId; + + // 상품 정보 + @Column(name = "name", nullable = false, length = 200) + private String name; + + @Column(name = "description", columnDefinition = "TEXT") + private String description; + + @Embedded + private Price price; + + @Column(name = "stock_quantity", nullable = false) + private Integer stockQuantity; + + // 브랜드 정보 + @Column(name = "brand_id", nullable = false) + private Long brandId; + + @Column(name = "brand_name", nullable = false, length = 100) + private String brandName; + + // 좋아요 정보 + @Column(name = "like_count", nullable = false) + private Long likeCount; + + /** + * 상품명, 가격, 재고 등이 변경된 시간을 추적 + */ + @Column(name = "product_updated_at", nullable = false) + private ZonedDateTime productUpdatedAt; + + /** + * LikeEntity의 마지막 변경 시간 + * 좋아요 추가/삭제가 발생한 시간을 추적 + */ + @Column(name = "like_updated_at", nullable = false) + private ZonedDateTime likeUpdatedAt; + + /** + * 브랜드명, 설명 등이 변경된 시간을 추적 + */ + @Column(name = "brand_updated_at", nullable = false) + private ZonedDateTime brandUpdatedAt; + + /** + * MV의 마지막 동기화 시간 + * 배치 작업이 이 엔티티를 마지막으로 업데이트한 시간 + */ + @Column(name = "last_updated_at", nullable = false) + private ZonedDateTime lastUpdatedAt; + + /** + * ProductMaterializedViewEntity 생성자 + */ + private ProductMaterializedViewEntity( + Long productId, + String name, + String description, + Price price, + Integer stockQuantity, + Long brandId, + String brandName, + Long likeCount, + ZonedDateTime productUpdatedAt, + ZonedDateTime brandUpdatedAt, + ZonedDateTime likeUpdatedAt, + ZonedDateTime lastUpdatedAt + + ) { + this.productId = productId; + this.name = name; + this.description = description; + this.price = price; + this.stockQuantity = stockQuantity; + this.brandId = brandId; + this.brandName = brandName; + this.likeCount = likeCount; + this.productUpdatedAt = productUpdatedAt; + this.brandUpdatedAt = brandUpdatedAt; + this.likeUpdatedAt = likeUpdatedAt; + this.lastUpdatedAt = lastUpdatedAt; + } + + /** + * Product, Brand, 좋아요 수로부터 MV 엔티티를 생성합니다. + * + * @param product 상품 엔티티 + * @param brand 브랜드 엔티티 + * @param likeCount 좋아요 수 + * @param likeUpdatedAt 좋아요 최신 업데이트 시간 + * @return 생성된 MV 엔티티 + * @throws IllegalArgumentException 필수 파라미터가 null인 경우 + */ + public static ProductMaterializedViewEntity from( + ProductEntity product, + BrandEntity brand, + Long likeCount, + ZonedDateTime likeUpdatedAt + ) { + Objects.requireNonNull(product, "상품 엔티티는 필수입니다."); + Objects.requireNonNull(brand, "브랜드 엔티티는 필수입니다."); + Objects.requireNonNull(likeCount, "좋아요 수는 필수입니다."); + + if (likeCount < 0) { + throw new IllegalArgumentException("좋아요 수는 0 이상이어야 합니다."); + } + + return new ProductMaterializedViewEntity( + product.getId(), + product.getName(), + product.getDescription(), + product.getPrice(), + product.getStockQuantity(), + brand.getId(), + brand.getName(), + likeCount, + product.getUpdatedAt(), + brand.getUpdatedAt(), + likeUpdatedAt != null ? likeUpdatedAt : ZonedDateTime.now(), + ZonedDateTime.now() + ); + } + + /** + * ProductMVSyncDto로부터 MV 엔티티를 생성합니다. + * + * 배치 동기화 시 단일 쿼리 결과로부터 MV를 생성합니다. + * + * @param dto 동기화용 DTO + * @return 생성된 MV 엔티티 + * @throws IllegalArgumentException 필수 파라미터가 null인 경우 + */ + public static ProductMaterializedViewEntity fromDto(ProductMVSyncDto dto) { + Objects.requireNonNull(dto, "동기화 DTO는 필수입니다."); + + Long likeCount = dto.getLikeCount() != null ? dto.getLikeCount() : 0L; + if (likeCount < 0) { + throw new IllegalArgumentException("좋아요 수는 0 이상이어야 합니다."); + } + + return new ProductMaterializedViewEntity( + dto.getProductId(), + dto.getProductName(), + dto.getProductDescription(), + Price.of(dto.getOriginPrice(), dto.getDiscountPrice()), + dto.getStockQuantity(), + dto.getBrandId(), + dto.getBrandName(), + likeCount, + dto.getProductUpdatedAt(), + dto.getBrandUpdatedAt(), + dto.getLikeUpdatedAt() != null ? dto.getLikeUpdatedAt() : ZonedDateTime.now(), + ZonedDateTime.now() + ); + } + + /** + * 기존 MV 엔티티를 최신 데이터로 동기화합니다. + * + * @param product 최신 상품 엔티티 + * @param brand 최신 브랜드 엔티티 + * @param likeCount 최신 좋아요 수 + * @throws IllegalArgumentException 필수 파라미터가 null인 경우 + */ + public void sync(ProductEntity product, BrandEntity brand, Long likeCount) { + Objects.requireNonNull(product, "상품 엔티티는 필수입니다."); + Objects.requireNonNull(brand, "브랜드 엔티티는 필수입니다."); + Objects.requireNonNull(likeCount, "좋아요 수는 필수입니다."); + + if (likeCount < 0) { + throw new IllegalArgumentException("좋아요 수는 0 이상이어야 합니다."); + } + + if (!this.productId.equals(product.getId())) { + throw new IllegalArgumentException( + String.format("상품 ID가 일치하지 않습니다. (MV: %d, Product: %d)", + this.productId, product.getId()) + ); + } + + this.name = product.getName(); + this.description = product.getDescription(); + this.price = product.getPrice(); + this.stockQuantity = product.getStockQuantity(); + this.brandId = brand.getId(); + this.brandName = brand.getName(); + this.likeCount = likeCount; + this.productUpdatedAt = product.getUpdatedAt(); + this.brandUpdatedAt = brand.getUpdatedAt(); + this.lastUpdatedAt = ZonedDateTime.now(); + } + + @Override + protected void guard() { + if (this.productId == null) { + throw new IllegalStateException("상품 ID는 필수입니다."); + } + + if (this.name == null || this.name.isBlank()) { + throw new IllegalStateException("상품명은 비어있을 수 없습니다."); + } + + if (this.name.length() > 200) { + throw new IllegalStateException("상품명은 200자를 초과할 수 없습니다."); + } + + if (this.price == null) { + throw new IllegalStateException("가격 정보는 필수입니다."); + } + + if (this.stockQuantity == null || this.stockQuantity < 0) { + throw new IllegalStateException("재고 수량은 0 이상이어야 합니다."); + } + + if (this.brandId == null) { + throw new IllegalStateException("브랜드 ID는 필수입니다."); + } + + if (this.brandName == null || this.brandName.isBlank()) { + throw new IllegalStateException("브랜드명은 비어있을 수 없습니다."); + } + + if (this.brandName.length() > 100) { + throw new IllegalStateException("브랜드명은 100자를 초과할 수 없습니다."); + } + + if (this.likeCount == null || this.likeCount < 0) { + throw new IllegalStateException("좋아요 수는 0 이상이어야 합니다."); + } + + if (this.lastUpdatedAt == null) { + throw new IllegalStateException("마지막 업데이트 시간은 필수입니다."); + } + } + + + /** + * DTO와 기존 MV를 비교하여 실제 변경사항이 있는지 확인합니다. + */ + public static boolean hasActualChangesFromDto(ProductMaterializedViewEntity mv, ProductMVSyncDto dto) { + // 상품명 변경 + if (!mv.getName().equals(dto.getProductName())) { + return true; + } + + // 가격 변경 + if (!mv.getPrice().getOriginPrice().equals(dto.getOriginPrice())) { + return true; + } + + if (dto.getDiscountPrice() != null && !mv.getPrice().getDiscountPrice().equals(dto.getDiscountPrice())) { + return true; + } + + // 재고 변경 + if (!mv.getStockQuantity().equals(dto.getStockQuantity())) { + return true; + } + + // 브랜드명 변경 + if (!mv.getBrandName().equals(dto.getBrandName())) { + return true; + } + + // 좋아요 수 변경 + Long dtoLikeCount = dto.getLikeCount() != null ? dto.getLikeCount() : 0L; + if (!mv.getLikeCount().equals(dtoLikeCount)) { + return true; + } + + return false; + } +} 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 new file mode 100644 index 000000000..36eea5c14 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java @@ -0,0 +1,30 @@ +package com.loopers.domain.product; + +import java.util.Optional; + +import org.springframework.data.domain.Page; + +import com.loopers.domain.product.dto.ProductSearchFilter; + +/** + * @author hyunjikoh + * @since 2025. 11. 10. + */ +public interface ProductRepository { + ProductEntity save(ProductEntity product); + + Page getProducts(ProductSearchFilter searchFilter); + + Optional findActiveById(Long id); + + Optional findById(Long id); + + /** + * 비관적 락을 사용하여 상품을 조회합니다. + * 동시성 제어가 필요한 재고 차감 시 사용됩니다. + * + * @param id 상품 ID + * @return 상품 엔티티 + */ + Optional findByIdWithLock(Long id); +} 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 new file mode 100644 index 000000000..8e310c8fe --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java @@ -0,0 +1,141 @@ +package com.loopers.domain.product; + +import java.util.List; +import java.util.Optional; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import com.loopers.domain.product.dto.ProductSearchFilter; +import com.loopers.infrastructure.cache.CacheStrategy; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import jakarta.validation.Valid; + +/** + * 상품 도메인 서비스 + * @author hyunjikoh + * @since 2025. 11. 10. + */ +@Component +@RequiredArgsConstructor +@Slf4j +public class ProductService { + private final ProductRepository productRepository; + + /** + * 검색 필터 조건으로 상품 목록을 조회합니다. + * + * @param searchFilter 검색 필터 (브랜드ID, 정렬 조건 등) + * @return 검색된 상품 목록 페이지 + */ + @Transactional(readOnly = true) + public Page getProducts(ProductSearchFilter searchFilter) { + return productRepository.getProducts(searchFilter); + } + + /** + * 상품 ID로 상품 상세 정보를 조회합니다. + * + * @param id 상품 ID + * @return 조회된 상품 엔티티 + * @throws CoreException 상품을 찾을 수 없는 경우 + */ + @Transactional(readOnly = true) + public ProductEntity getActiveProductDetail(Long id) { + return productRepository.findActiveById(id) + .orElseThrow(() -> new CoreException( + ErrorType.NOT_FOUND_PRODUCT, + String.format("상품을 찾을 수 없습니다. (ID: %d)", id) + )); + } + + /** + * 상품 ID로 상품 상세 정보를 조회합니다. + * + * @param id 상품 ID + * @return 조회된 상품 엔티티 + * @throws CoreException 상품을 찾을 수 없는 경우 + */ + @Transactional(readOnly = true) + public ProductEntity getProductDetail(Long id) { + return productRepository.findById(id) + .orElseThrow(() -> new CoreException( + ErrorType.NOT_FOUND_PRODUCT, + String.format("상품을 찾을 수 없습니다. (ID: %d)", id) + )); + } + + /** + * 상품 ID로 상품 상세 정보를 조회합니다. + * + * @param id 상품 ID + * @return 조회된 상품 엔티티 + * @throws CoreException 상품을 찾을 수 없는 경우 + */ + @Transactional(readOnly = true) + public ProductEntity getProductDetailLock(Long id) { + return productRepository.findByIdWithLock(id) + .orElseThrow(() -> new CoreException( + ErrorType.NOT_FOUND_PRODUCT, + String.format("상품을 찾을 수 없습니다. (ID: %d)", id) + )); + } + + /** + * 상품을 등록합니다. + * + * 상품 등록은 단일 도메인 작업이므로 도메인 서비스에서 트랜잭션 처리합니다. + * + * @param request 상품 생성 요청 정보 + * @return 등록된 상품 엔티티 + */ + @Transactional + public ProductEntity registerProduct(@Valid ProductDomainCreateRequest request) { + // 상품 엔티티 생성 + ProductEntity productEntity = ProductEntity.createEntity(request); + + return productRepository.save(productEntity); + } + + /** + * 상품 엔티티의 재고를 차감합니다. + * + * 이미 조회된 상품 엔티티의 재고를 차감할 때 사용합니다. + * + * @param product 재고를 차감할 상품 엔티티 + * @param quantity 차감할 재고 수량 + * @return 재고가 차감된 상품 엔티티 + */ + @Transactional + public ProductEntity deductStock(ProductEntity product, int quantity) { + product.deductStock(quantity); + return productRepository.save(product); + } + + /** + * 상품 재고를 원복합니다. + * + * 주문 취소 시 차감된 재고를 다시 복구합니다. + * + * @param productId 상품 ID + * @param quantity 원복할 재고 수량 + * @return 재고가 원복된 상품 엔티티 + * @throws CoreException 상품을 찾을 수 없는 경우 + */ + @Transactional + public ProductEntity restoreStock(Long productId, int quantity) { + // 비관적 락을 사용하여 동시성 제어 + ProductEntity product = productRepository.findByIdWithLock(productId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다. ID: " + productId)); + + product.restoreStock(quantity); + return productRepository.save(product); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/dto/ProductSearchFilter.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/dto/ProductSearchFilter.java new file mode 100644 index 000000000..01a618eb7 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/dto/ProductSearchFilter.java @@ -0,0 +1,14 @@ +package com.loopers.domain.product.dto; + +import org.springframework.data.domain.Pageable; + +/** + * @author hyunjikoh + * @since 2025. 11. 11. + */ +public record ProductSearchFilter( + Long brandId, + String productName, + Pageable pageable +) { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/Gender.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/Gender.java new file mode 100644 index 000000000..a58378096 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/Gender.java @@ -0,0 +1,10 @@ +package com.loopers.domain.user; + +/** + * @author hyunjikoh + * @since 2025. 10. 28. + */ +public enum Gender { + MALE, + FEMALE +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserDomainCreateRequest.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserDomainCreateRequest.java new file mode 100644 index 000000000..1254969df --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserDomainCreateRequest.java @@ -0,0 +1,25 @@ +package com.loopers.domain.user; + +import jakarta.validation.constraints.NotNull; + +/** + * @author hyunjikoh + * @since 2025. 10. 26. + */ +public record UserDomainCreateRequest( + @NotNull + String username, + + @NotNull + String email, + + @NotNull + String birthdate, + + @NotNull + Gender gender +) { + public static UserDomainCreateRequest of(String username, String email, String birthdate, Gender gender) { + return new UserDomainCreateRequest(username, email, birthdate, gender); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserEntity.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserEntity.java new file mode 100644 index 000000000..2fe229f12 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserEntity.java @@ -0,0 +1,146 @@ +package com.loopers.domain.user; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.LocalDate; +import java.util.Objects; + +import com.loopers.domain.BaseEntity; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import jakarta.persistence.*; +import jakarta.validation.Valid; + +/** + * @author hyunjikoh + * @since 2025. 10. 26. + */ +@Entity +@Table(name = "users") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class UserEntity extends BaseEntity { + + @Column(unique = true, nullable = false, length = 10) + private String username; + + @Column(nullable = false, length = 254) + private String email; + + @Column(nullable = false) + private LocalDate birthdate; + + @Enumerated(EnumType.STRING) + @Column(length = 10, nullable = false) + private Gender gender; + + @Column(name = "point_amount", precision = 9, scale = 2, nullable = false) + private BigDecimal pointAmount = BigDecimal.ZERO; + + public static UserEntity createUserEntity(@Valid UserDomainCreateRequest request) { + if (Objects.isNull(request.username()) || !request.username().matches("^[A-Za-z0-9]{1,10}$")) { + throw new IllegalArgumentException("사용자명은 영문 및 숫자 10자 이내여야 합니다."); + } + + if (Objects.isNull(request.email()) || !request.email().matches("^[A-Za-z0-9+_.-]+@(.+)$")) { + throw new IllegalArgumentException("올바른 형식의 이메일 주소여야 합니다."); + } + + if (Objects.isNull(request.gender())) { + throw new IllegalArgumentException("성별은 필수 입력값입니다."); + } + + try { + if (Objects.isNull(request.birthdate())) { + throw new IllegalArgumentException("생년월일은 필수 입력값입니다."); + } + LocalDate parsedBirthdate = LocalDate.parse(request.birthdate()); + if (parsedBirthdate.isAfter(LocalDate.now())) { + throw new IllegalArgumentException("생년월일은 현재 날짜보다 이후일 수 없습니다."); + } + return new UserEntity( + request.username(), + request.email(), + parsedBirthdate, + request.gender()); + } catch (Exception e) { + throw new IllegalArgumentException(e instanceof IllegalArgumentException ? + e.getMessage() : "올바른 날짜 형식이어야 합니다."); + } + + } + + private UserEntity(String username, String email, LocalDate birthdate, Gender gender) { + if (Objects.isNull(username) || username.trim().isEmpty()) { + throw new IllegalArgumentException("사용자명은 필수값입니다."); + } + if (Objects.isNull(email) || email.trim().isEmpty()) { + throw new IllegalArgumentException("이메일은 필수값입니다."); + } + if (Objects.isNull(birthdate)) { + throw new IllegalArgumentException("생년월일은 필수값입니다."); + } + if (Objects.isNull(gender)) { + throw new IllegalArgumentException("성별은 필수값입니다."); + } + if (birthdate.isAfter(LocalDate.now())) { + throw new IllegalArgumentException("생년월일은 현재 날짜보다 이후일 수 없습니다."); + } + + this.username = username; + this.email = email; + this.birthdate = birthdate; + this.gender = gender; + this.pointAmount = BigDecimal.ZERO; + } + + /** + * 포인트를 충전합니다. + * + * @param amount 충전할 금액 + */ + public void chargePoint(BigDecimal amount) { + validateChargeAmount(amount); + this.pointAmount = this.pointAmount.add(amount); + } + + /** + * 충전 금액의 유효성을 검사합니다. + */ + private void validateChargeAmount(BigDecimal amount) { + if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) { + throw new IllegalArgumentException("충전 금액은 0보다 커야 합니다."); + } + } + + /** + * 포인트 잔액을 반환합니다. + */ + public BigDecimal getPointAmount() { + return pointAmount.setScale(2, RoundingMode.HALF_UP); + } + + /** + * 포인트를 사용합니다. + * + * @param amount 사용할 금액 + * @throws IllegalArgumentException 포인트가 부족한 경우 + */ + public void usePoint(BigDecimal amount) { + if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) { + throw new IllegalArgumentException("사용 금액은 0보다 커야 합니다."); + } + + if (this.pointAmount.compareTo(amount) < 0) { + throw new IllegalArgumentException( + String.format("포인트가 부족합니다. (보유: %s, 사용: %s)", + this.pointAmount, amount) + ); + } + + this.pointAmount = this.pointAmount.subtract(amount); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java new file mode 100644 index 000000000..499bcfc92 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java @@ -0,0 +1,15 @@ +package com.loopers.domain.user; + +import java.util.Optional; + +/** + * @author hyunjikoh + * @since 2025. 10. 27. + */ +public interface UserRepository { + UserEntity save(UserEntity userEntity); + + Optional findByUsername(String username); + + Optional findByUsernameWithLock(String username); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java new file mode 100644 index 000000000..fa4beaa21 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java @@ -0,0 +1,45 @@ +package com.loopers.domain.user; + +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; + +import lombok.RequiredArgsConstructor; + +import jakarta.validation.Valid; + +/** + * @author hyunjikoh + * @since 2025. 10. 27. + */ +@RequiredArgsConstructor +@Component +public class UserService { + private final UserRepository userRepository; + + @Transactional + public UserEntity register(@Valid UserDomainCreateRequest userRegisterRequest) { + userRepository.findByUsername(userRegisterRequest.username()) + .ifPresent(user -> { + throw new IllegalArgumentException("이미 존재하는 사용자 이름입니다: " + userRegisterRequest.username()); + }); + + UserEntity userEntity = UserEntity.createUserEntity(userRegisterRequest); + + return userRepository.save(userEntity); + } + + @Transactional(readOnly = true) + public UserEntity getUserByUsername(String username) { + return userRepository.findByUsername(username) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND_USER)); + } + + @Transactional(readOnly = true) + public UserEntity findByUsernameWithLock(String username) { + return userRepository.findByUsernameWithLock(username) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND_USER)); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java new file mode 100644 index 000000000..08cc272cf --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java @@ -0,0 +1,26 @@ +package com.loopers.infrastructure.brand; + +import java.util.List; +import java.util.Optional; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +import com.loopers.domain.brand.BrandEntity; + +/** + * @author hyunjikoh + * @since 2025. 10. 31. + */ +public interface BrandJpaRepository extends JpaRepository { + Page findAllByDeletedAtNull(Pageable pageable); + + Optional findByIdAndDeletedAtNull(long id); + + Optional findByNameAndDeletedAtNull(String name); + + List findByDeletedAtNull(); + + Boolean existsByNameAndDeletedAtNull(String name); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandQueryRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandQueryRepository.java new file mode 100644 index 000000000..b7019f6b5 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandQueryRepository.java @@ -0,0 +1,54 @@ +package com.loopers.infrastructure.brand; + +import static com.loopers.domain.brand.QBrandEntity.brandEntity; +import java.util.List; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +import com.loopers.domain.brand.BrandEntity; +import com.loopers.domain.brand.dto.BrandSearchFilter; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.jpa.impl.JPAQueryFactory; + +import lombok.RequiredArgsConstructor; + +/** + * @author hyunjikoh + * @since 2025. 11. 10. + */ +@Component +@RequiredArgsConstructor +public class BrandQueryRepository { + private final JPAQueryFactory queryFactory; + + public Page searchBrands(BrandSearchFilter filter, Pageable pageable) { + List content = queryFactory + .selectFrom(brandEntity) + .where( + brandNameContains(filter.getBrandName()), + brandEntity.deletedAt.isNull() + ) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + Long total = queryFactory + .select(brandEntity.count()) + .from(brandEntity) + .where( + brandNameContains(filter.getBrandName()), + brandEntity.deletedAt.isNull() + ) + .fetchOne(); + + return new PageImpl<>(content, pageable, total != null ? total : 0); + } + + private BooleanExpression brandNameContains(String name) { + return StringUtils.hasText(name) ? brandEntity.name.contains(name) : null; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java new file mode 100644 index 000000000..c6984f8b2 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java @@ -0,0 +1,56 @@ +package com.loopers.infrastructure.brand; + +import java.util.List; +import java.util.Optional; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; + +import com.loopers.domain.brand.BrandEntity; +import com.loopers.domain.brand.BrandRepository; +import com.loopers.domain.brand.dto.BrandSearchFilter; + +import lombok.RequiredArgsConstructor; + +/** + * @author hyunjikoh + * @since 2025. 10. 31. + */ +@Component +@RequiredArgsConstructor +public class BrandRepositoryImpl implements BrandRepository { + + private final BrandJpaRepository brandJpaRepository; + private final BrandQueryRepository queryRepository; + + @Override + public BrandEntity save(BrandEntity brand) { + return brandJpaRepository.save(brand); + } + + @Override + public Page listBrands(Pageable pageable) { + return searchBrands(BrandSearchFilter.of(null), pageable); + } + + @Override + public Optional getBrandById(long id) { + return brandJpaRepository.findByIdAndDeletedAtNull(id); + } + + @Override + public Optional findByName(String name) { + return brandJpaRepository.findByNameAndDeletedAtNull(name); + } + + @Override + public Page searchBrands(BrandSearchFilter filter, Pageable pageable) { + return queryRepository.searchBrands(filter, pageable); + } + + @Override + public List findAll() { + return brandJpaRepository.findByDeletedAtNull(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/CacheKeyGenerator.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/CacheKeyGenerator.java new file mode 100644 index 000000000..ab3e35a1f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/CacheKeyGenerator.java @@ -0,0 +1,158 @@ +package com.loopers.infrastructure.cache; + +import java.util.StringJoiner; + +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Component; + +/** + * 캐시 키 생성기 + * + * Hot/Warm/Cold 전략별 캐시 키 생성 + * - product:detail:{productId} + * - product:ids:{strategy}:{brandId}:{page}:{size}:{sort} + * - product:page:{brandId}:{productName}:{page}:{size}:{sort} + */ +@Component +public class CacheKeyGenerator { + + private static final String DELIMITER = ":"; + private static final String NULL_VALUE = "null"; + + // 캐시 키 프리픽스 + private static final String PRODUCT_PREFIX = "product"; + private static final String DETAIL_PREFIX = "detail"; + private static final String IDS_PREFIX = "ids"; + private static final String PAGE_PREFIX = "page"; + + /** + * 상품 상세 캐시 키: product:detail:{productId} + */ + public String generateProductDetailKey(Long productId) { + return new StringJoiner(DELIMITER) + .add(PRODUCT_PREFIX) + .add(DETAIL_PREFIX) + .add(String.valueOf(productId)) + .toString(); + } + + /** + * 상품 ID 리스트 캐시 키: product:ids:{strategy}:{brandId}:{page}:{size}:{sort} + * ID만 캐싱하여 개별 상품 변경 시 전체 캐시 무효화 방지 + */ + public String generateProductIdsKey(CacheStrategy strategy, Long brandId, Pageable pageable) { + return new StringJoiner(DELIMITER) + .add(PRODUCT_PREFIX) + .add(IDS_PREFIX) + .add(strategy.name().toLowerCase()) + .add(brandId != null ? String.valueOf(brandId) : NULL_VALUE) + .add(String.valueOf(pageable.getPageNumber())) + .add(String.valueOf(pageable.getPageSize())) + .add(generateSortString(pageable.getSort())) + .toString(); + } + + /** + * 상품 목록 캐시 키: product:page:{brandId}:{productName}:{page}:{size}:{sort} + * 레거시 방식 (Cold 전략 또는 특수 검색용) + */ + public String generateProductListKey(Long brandId, String productName, Pageable pageable) { + StringJoiner joiner = new StringJoiner(DELIMITER) + .add(PRODUCT_PREFIX) + .add(PAGE_PREFIX) + .add(brandId != null ? String.valueOf(brandId) : NULL_VALUE) + .add(productName != null ? sanitizeProductName(productName) : NULL_VALUE) + .add(String.valueOf(pageable.getPageNumber())) + .add(String.valueOf(pageable.getPageSize())) + .add(generateSortString(pageable.getSort())); + + return joiner.toString(); + } + + /** + * 상품 ID 리스트 패턴: product:ids:{strategy}:* + */ + public String generateProductIdsPattern(CacheStrategy strategy) { + return new StringJoiner(DELIMITER) + .add(PRODUCT_PREFIX) + .add(IDS_PREFIX) + .add(strategy.name().toLowerCase()) + .add("*") + .toString(); + } + + /** + * 특정 브랜드의 상품 ID 리스트 패턴: product:ids:{strategy}:{brandId}:* + */ + public String generateProductIdsPatternByBrand(CacheStrategy strategy, Long brandId) { + return new StringJoiner(DELIMITER) + .add(PRODUCT_PREFIX) + .add(IDS_PREFIX) + .add(strategy.name().toLowerCase()) + .add(String.valueOf(brandId)) + .add("*") + .toString(); + } + + /** + * 특정 브랜드의 모든 목록 패턴 (레거시): product:page:{brandId}:* + */ + public String generateProductListPatternByBrand(Long brandId) { + return new StringJoiner(DELIMITER) + .add(PRODUCT_PREFIX) + .add(PAGE_PREFIX) + .add(String.valueOf(brandId)) + .add("*") + .toString(); + } + + /** + * 모든 상품 목록 패턴 (레거시): product:page:* + */ + public String generateProductListPattern() { + return new StringJoiner(DELIMITER) + .add(PRODUCT_PREFIX) + .add(PAGE_PREFIX) + .add("*") + .toString(); + } + + /** + * 상품명을 캐시 키에 안전한 형태로 변환 (공백→언더스코어, 특수문자 제거, 최대 50자) + */ + private String sanitizeProductName(String productName) { + if (productName == null || productName.trim().isEmpty()) { + return NULL_VALUE; + } + + // 공백을 언더스코어로 변환하고, 특수문자 제거 + String sanitized = productName.trim() + .replaceAll("\\s+", "_") + .replaceAll("[^a-zA-Z0-9가-힣_]", ""); + + // 최대 50자로 제한 + if (sanitized.length() > 50) { + sanitized = sanitized.substring(0, 50); + } + + return sanitized.isEmpty() ? NULL_VALUE : sanitized; + } + + /** + * Sort 객체를 문자열로 변환 (예: "likeCount_desc,id_asc") + */ + private String generateSortString(Sort sort) { + if (sort.isUnsorted()) { + return "unsorted"; + } + + StringJoiner sortJoiner = new StringJoiner(","); + sort.forEach(order -> { + String direction = order.getDirection().isAscending() ? "asc" : "desc"; + sortJoiner.add(order.getProperty() + "_" + direction); + }); + + return sortJoiner.toString(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/CacheStrategy.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/CacheStrategy.java new file mode 100644 index 000000000..65f79c938 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/CacheStrategy.java @@ -0,0 +1,55 @@ +package com.loopers.infrastructure.cache; + +import java.util.concurrent.TimeUnit; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +/** + * 캐시 전략: Hot/Warm/Cold 차별화된 TTL과 갱신 방식 + * + * - Hot: 배치 갱신, TTL 60분 (인기순, 1페이지) + * - Warm: Cache-Aside, TTL 10분 (2~3페이지) + * - Cold: 캐시 미사용 (4페이지 이상) + */ +@Getter +@RequiredArgsConstructor +public enum CacheStrategy { + + /** + * Hot: 배치 갱신, TTL 60분 + * 인기순 정렬, 1페이지 (가장 빈번 조회) + */ + HOT(30, TimeUnit.MINUTES, true, CacheUpdateStrategy.BATCH_REFRESH), + + /** + * Warm: Cache-Aside, TTL 10분 + * 2~3페이지 (꾸준한 조회) + */ + WARM(10, TimeUnit.MINUTES, false, CacheUpdateStrategy.CACHE_ASIDE), + + /** + * Cold: 캐시 미사용 + * 4페이지 이상 (거의 조회 안 됨) + */ + COLD(0, TimeUnit.MINUTES, false, CacheUpdateStrategy.NO_CACHE); + + private final long ttl; + private final TimeUnit timeUnit; + private final boolean useBatchUpdate; + private final CacheUpdateStrategy updateStrategy; + + /** + * 캐시 사용 여부 + */ + public boolean shouldCache() { + return this != COLD; + } + + /** + * 배치 갱신 사용 여부 + */ + public boolean shouldUseBatchUpdate() { + return useBatchUpdate; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/CacheUpdateStrategy.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/CacheUpdateStrategy.java new file mode 100644 index 000000000..e49142a67 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/CacheUpdateStrategy.java @@ -0,0 +1,25 @@ +package com.loopers.infrastructure.cache; + +/** + * 캐시 갱신 전략 + */ +public enum CacheUpdateStrategy { + + /** + * 배치 갱신 (주기적으로 미리 갱신) + * - 적용: Hot 데이터 (상품 목록, 인기 상품 등) + */ + BATCH_REFRESH, + + /** + * Cache-Aside (조회 시 캐시 미스 발생 시 DB에서 로드) + * - 적용: Warm 데이터 (개별 상품 상세정보 등) + */ + CACHE_ASIDE, + + /** + * 캐시 미사용 (항상 DB 직접 조회) + * - 적용: Cold 데이터 (조회 빈도 낮은 데이터) + */ + NO_CACHE +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponJpaRepository.java new file mode 100644 index 000000000..b81867ea3 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponJpaRepository.java @@ -0,0 +1,15 @@ +package com.loopers.infrastructure.coupon; + +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.loopers.domain.coupon.CouponEntity; + +/** + * @author hyunjikoh + * @since 2025. 11. 11. + */ +public interface CouponJpaRepository extends JpaRepository { + Optional findByIdAndUserIdAndDeletedAtIsNull(Long id, Long userId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponRepositoryImpl.java new file mode 100644 index 000000000..fcc806542 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponRepositoryImpl.java @@ -0,0 +1,30 @@ +package com.loopers.infrastructure.coupon; + +import java.util.Optional; + +import org.springframework.stereotype.Component; + +import com.loopers.domain.coupon.CouponEntity; +import com.loopers.domain.coupon.CouponRepository; + +import lombok.RequiredArgsConstructor; + +/** + * @author hyunjikoh + * @since 2025. 11. 11. + */ +@Component +@RequiredArgsConstructor +public class CouponRepositoryImpl implements CouponRepository { + private final CouponJpaRepository couponJpaRepository; + + @Override + public CouponEntity save(CouponEntity coupon) { + return couponJpaRepository.save(coupon); + } + + @Override + public Optional findByIdAndUserId(Long couponId, Long userId) { + return couponJpaRepository.findByIdAndUserIdAndDeletedAtIsNull(couponId, userId); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleJpaRepository.java index ce6d3ead0..5d8e50f7c 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleJpaRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleJpaRepository.java @@ -1,6 +1,8 @@ package com.loopers.infrastructure.example; -import com.loopers.domain.example.ExampleModel; import org.springframework.data.jpa.repository.JpaRepository; -public interface ExampleJpaRepository extends JpaRepository {} +import com.loopers.domain.example.ExampleModel; + +public interface ExampleJpaRepository extends JpaRepository { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleRepositoryImpl.java index 37f2272f0..f66fd8ee8 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleRepositoryImpl.java @@ -1,11 +1,13 @@ package com.loopers.infrastructure.example; +import java.util.Optional; + +import org.springframework.stereotype.Component; + import com.loopers.domain.example.ExampleModel; import com.loopers.domain.example.ExampleRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; -import java.util.Optional; +import lombok.RequiredArgsConstructor; @RequiredArgsConstructor @Component diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java new file mode 100644 index 000000000..74c7d769e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java @@ -0,0 +1,29 @@ +package com.loopers.infrastructure.like; + +import java.util.List; +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; + +import com.loopers.domain.like.LikeEntity; + +/** + * @author hyunjikoh + * @since 2025. 11. 11. + */ +public interface LikeJpaRepository extends JpaRepository { + + Optional findByUserIdAndProductId(Long userId, Long productId); + + /** + * 특정 상품의 활성 좋아요 수를 조회한다. + */ + Long countByProductIdAndDeletedAtIsNull(Long productId); + + /** + * 좋아요가 있는 모든 상품 ID를 중복 없이 조회한다. + */ + @Query("SELECT DISTINCT l.productId FROM LikeEntity l WHERE l.deletedAt IS NULL") + List findDistinctProductIds(); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java new file mode 100644 index 000000000..363cec7a4 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java @@ -0,0 +1,46 @@ +package com.loopers.infrastructure.like; + +import java.util.List; +import java.util.Optional; + +import org.springframework.stereotype.Component; + +import com.loopers.domain.like.LikeEntity; +import com.loopers.domain.like.LikeRepository; + +import lombok.RequiredArgsConstructor; + +/** + * @author hyunjikoh + * @since 2025. 11. 11. + */ +@Component +@RequiredArgsConstructor +public class LikeRepositoryImpl implements LikeRepository { + private final LikeJpaRepository likeJpaRepository; + + @Override + public LikeEntity save(LikeEntity entity) { + return likeJpaRepository.save(entity); + } + + @Override + public Optional findByUserIdAndProductId(Long userId, Long productId) { + return likeJpaRepository.findByUserIdAndProductId(userId, productId); + } + + @Override + public List findAll() { + return likeJpaRepository.findAll(); + } + + @Override + public Long countByProductIdAndDeletedAtIsNull(Long productId) { + return likeJpaRepository.countByProductIdAndDeletedAtIsNull(productId); + } + + @Override + public List findDistinctProductIds() { + return likeJpaRepository.findDistinctProductIds(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemJpaRepository.java new file mode 100644 index 000000000..08123bc6a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemJpaRepository.java @@ -0,0 +1,43 @@ +package com.loopers.infrastructure.order; + +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.loopers.domain.order.OrderItemEntity; + +/** + * 주문 항목 JPA Repository + * + * @author hyunjikoh + * @since 2025. 11. 14. + */ +public interface OrderItemJpaRepository extends JpaRepository { + + /** + * 주문 ID로 주문 항목 목록을 조회합니다. + * + * @param orderId 주문 ID + * @return 주문 항목 목록 + */ + List findByOrderId(Long orderId); + + /** + * 주문 ID로 주문 항목 개수를 조회합니다. + * + * @param orderId 주문 ID + * @return 주문 항목 개수 + */ + int countByOrderId(Long orderId); + + /** + * 주문 ID로 주문 항목 목록을 페이징하여 조회합니다. + * + * @param orderId 주문 ID + * @param pageable 페이징 정보 + * @return 페이징된 주문 항목 목록 + */ + org.springframework.data.domain.Page findByOrderId( + Long orderId, + org.springframework.data.domain.Pageable pageable); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemRepositoryImpl.java new file mode 100644 index 000000000..6f7214967 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemRepositoryImpl.java @@ -0,0 +1,50 @@ +package com.loopers.infrastructure.order; + +import java.util.List; + +import org.springframework.stereotype.Repository; + +import com.loopers.domain.order.OrderItemEntity; +import com.loopers.domain.order.OrderItemRepository; + +import lombok.RequiredArgsConstructor; + +/** + * 주문 항목 Repository 구현체 + * + * @author hyunjikoh + * @since 2025. 11. 14. + */ +@Repository +@RequiredArgsConstructor +public class OrderItemRepositoryImpl implements OrderItemRepository { + + private final OrderItemJpaRepository orderItemJpaRepository; + + @Override + public OrderItemEntity save(OrderItemEntity orderItem) { + return orderItemJpaRepository.save(orderItem); + } + + @Override + public List findByOrderId(Long orderId) { + return orderItemJpaRepository.findByOrderId(orderId); + } + + @Override + public void saveAll(List orderItemsByOrderId) { + orderItemJpaRepository.saveAll(orderItemsByOrderId); + } + + @Override + public int countByOrderId(Long orderId) { + return orderItemJpaRepository.countByOrderId(orderId); + } + + @Override + public org.springframework.data.domain.Page findByOrderId( + Long orderId, + org.springframework.data.domain.Pageable pageable) { + return orderItemJpaRepository.findByOrderId(orderId, pageable); + } +} 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 new file mode 100644 index 000000000..310ee743b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java @@ -0,0 +1,55 @@ +package com.loopers.infrastructure.order; + +import java.util.List; +import java.util.Optional; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +import com.loopers.domain.order.OrderEntity; +import com.loopers.domain.order.OrderStatus; + +/** + * 주문 JPA Repository + * + * @author hyunjikoh + * @since 2025. 11. 14. + */ +public interface OrderJpaRepository extends JpaRepository { + + /** + * 주문 ID로 삭제되지 않은 주문을 조회합니다. + * + * @param id 주문 ID + * @param userId + * @return 주문 엔티티 (Optional) + */ + Optional findByIdAndUserIdAndDeletedAtIsNull(Long id, Long userId); + + /** + * 사용자 ID로 삭제되지 않은 주문 목록을 조회합니다. + * + * @param userId 사용자 ID + * @return 주문 목록 + */ + List findByUserIdAndDeletedAtIsNull(Long userId); + + /** + * 사용자 ID로 삭제되지 않은 주문 목록을 페이징하여 조회합니다. + * + * @param userId 사용자 ID + * @param pageable 페이징 정보 + * @return 페이징된 주문 목록 + */ + Page findByUserIdAndDeletedAtIsNull(Long userId, Pageable pageable); + + /** + * 사용자 ID와 주문 상태로 삭제되지 않은 주문 목록을 조회합니다. + * + * @param userId 사용자 ID + * @param status 주문 상태 + * @return 주문 목록 + */ + Page findByUserIdAndStatusAndDeletedAtIsNull(Long userId, OrderStatus status, Pageable pageable); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java new file mode 100644 index 000000000..37df0b11c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java @@ -0,0 +1,46 @@ +package com.loopers.infrastructure.order; + +import java.util.Optional; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Repository; + +import com.loopers.domain.order.OrderEntity; +import com.loopers.domain.order.OrderRepository; +import com.loopers.domain.order.OrderStatus; + +import lombok.RequiredArgsConstructor; + +/** + * 주문 Repository 구현체 + * + * @author hyunjikoh + * @since 2025. 11. 14. + */ +@Repository +@RequiredArgsConstructor +public class OrderRepositoryImpl implements OrderRepository { + + private final OrderJpaRepository orderJpaRepository; + + @Override + public OrderEntity save(OrderEntity order) { + return orderJpaRepository.save(order); + } + + @Override + public Optional findByIdAndUserId(Long id, Long userId) { + return orderJpaRepository.findByIdAndUserIdAndDeletedAtIsNull(id, userId); + } + + @Override + public Page findByUserId(Long userId, Pageable pageable) { + return orderJpaRepository.findByUserIdAndDeletedAtIsNull(userId, pageable); + } + + @Override + public Page findByUserIdAndStatus(Long userId, OrderStatus status, Pageable pageable) { + return orderJpaRepository.findByUserIdAndStatusAndDeletedAtIsNull(userId, status, pageable); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/point/PointHistoryJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/point/PointHistoryJpaRepository.java new file mode 100644 index 000000000..40a2fe269 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/point/PointHistoryJpaRepository.java @@ -0,0 +1,23 @@ +package com.loopers.infrastructure.point; + +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.loopers.domain.point.PointHistoryEntity; +import com.loopers.domain.user.UserEntity; + +/** + * @author hyunjikoh + * @since 2025. 10. 31. + */ +public interface PointHistoryJpaRepository extends JpaRepository { + + /** + * 특정 사용자 포인트의 이력을 생성일 기준 내림차순으로 조회합니다. + * + * @param user 사용자 엔티티 + * @return 포인트 이력 목록 (최신순) + */ + List findByUserOrderByCreatedAtDesc(UserEntity user); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/point/PointHistoryRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/point/PointHistoryRepositoryImpl.java new file mode 100644 index 000000000..9860a690a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/point/PointHistoryRepositoryImpl.java @@ -0,0 +1,32 @@ +package com.loopers.infrastructure.point; + +import java.util.List; + +import org.springframework.stereotype.Component; + +import com.loopers.domain.point.PointHistoryEntity; +import com.loopers.domain.point.PointHistoryRepository; +import com.loopers.domain.user.UserEntity; + +import lombok.RequiredArgsConstructor; + +/** + * @author hyunjikoh + * @since 2025. 10. 31. + */ +@Component +@RequiredArgsConstructor +public class PointHistoryRepositoryImpl implements PointHistoryRepository { + + private final PointHistoryJpaRepository pointHistoryJpaRepository; + + @Override + public PointHistoryEntity save(PointHistoryEntity pointHistory) { + return pointHistoryJpaRepository.save(pointHistory); + } + + @Override + public List findByUserOrderByCreatedAtDesc(UserEntity user) { + return pointHistoryJpaRepository.findByUserOrderByCreatedAtDesc(user); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java new file mode 100644 index 000000000..c37631804 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java @@ -0,0 +1,32 @@ +package com.loopers.infrastructure.product; + +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import com.loopers.domain.product.ProductEntity; + +import jakarta.persistence.LockModeType; + +/** + * @author hyunjikoh + * @since 2025. 11. 10. + */ +public interface ProductJpaRepository extends JpaRepository { + + Optional findByIdAndDeletedAtIsNull(Long id); + + /** + * 비관적 락을 사용하여 상품을 조회합니다. + * 동시성 제어를 위해 재고 차감 시 사용됩니다. + * + * @param id 상품 ID + * @return 상품 엔티티 + */ + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT p FROM ProductEntity p WHERE p.id = :id AND p.deletedAt IS NULL") + Optional findByIdWithLock(@Param("id") Long id); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductMVBatchScheduler.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductMVBatchScheduler.java new file mode 100644 index 000000000..9e7d325c1 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductMVBatchScheduler.java @@ -0,0 +1,191 @@ +package com.loopers.infrastructure.product; + +import java.util.List; +import java.util.stream.Collectors; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import com.loopers.application.product.BatchUpdateResult; +import com.loopers.domain.brand.BrandEntity; +import com.loopers.domain.brand.BrandService; +import com.loopers.domain.product.ProductCacheService; +import com.loopers.domain.product.ProductMaterializedViewEntity; +import com.loopers.domain.product.ProductMVService; +import com.loopers.domain.product.dto.ProductSearchFilter; +import com.loopers.infrastructure.cache.CacheStrategy; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * ProductMaterializedView 배치 스케줄러 + * + * MV 테이블 동기화 및 Hot 캐시 갱신을 통합 관리합니다. + * + * 2분마다: MV 테이블 동기화 + * 50분마다: Hot 캐시 갱신 (인기순 상품, 브랜드별 인기순) + * + */ +@Component +@RequiredArgsConstructor +@Slf4j +public class ProductMVBatchScheduler { + private final ProductMVService mvService; + private final ProductCacheService cacheService; + private final BrandService brandService; + + // 배치 갱신 설정 + private static final int TOP_PRODUCTS_COUNT = 100; + private static final int CACHE_PAGES_PER_BRAND = 3; + private static final int PAGE_SIZE = 20; + + /** + * MV 테이블 동기화 배치 작업 (2분마다) + */ + @Scheduled(fixedDelay = 120000) + public void syncMaterializedView() { + log.info("MV 배치 업데이트 시작"); + + try { + BatchUpdateResult result = mvService.syncMaterializedView(); + + if (!result.isSuccess()) { + log.error("MV 배치 업데이트 실패 - error: {}", result.getErrorMessage()); + return; + } + + if (result.hasChanges()) { + log.info("변경사항 감지 - 선택적 캐시 무효화"); + cacheService.evictCachesAfterMVSync( + result.getChangedProductIds(), + result.getAffectedBrandIds() + ); + } else { + log.info("변경사항 없음"); + } + + log.info("MV 배치 업데이트 완료 - 생성: {}건, 갱신: {}건, 소요: {}ms", + result.getCreatedCount(), result.getUpdatedCount(), result.getDurationMs()); + + } catch (Exception e) { + log.error("MV 배치 업데이트 중 오류", e); + } + } + + /** + * Hot 캐시 갱신 배치 작업 (50분마다) + * + * 배치 갱신으로 캐시 스탬피드 방지 + * ProductMVRepository를 직접 사용하여 likeCount 정렬 보장 + */ + @Scheduled(fixedRate = 50 * 60 * 1000, initialDelay = 60 * 1000) + public void refreshHotCache() { + log.info("Hot 캐시 갱신 시작"); + + long startTime = System.currentTimeMillis(); + + try { + refreshPopularProductDetails(); + refreshBrandPopularProductIds(); + + long duration = System.currentTimeMillis() - startTime; + log.info("Hot 캐시 갱신 완료 - {}ms", duration); + + } catch (Exception e) { + log.error("Hot 캐시 갱신 실패", e); + } + } + + /** + * 인기 상품 상세 정보 갱신 (likeCount 상위 100개) + */ + private void refreshPopularProductDetails() { + log.debug("인기 상품 상세 갱신"); + + try { + Pageable pageable = PageRequest.of(0, TOP_PRODUCTS_COUNT, + Sort.by(Sort.Direction.DESC, "likeCount")); + + Page popularProducts = mvService.findBySearchFilter(new ProductSearchFilter( null, null , pageable)); + + List productIds = popularProducts.getContent().stream() + .map(ProductMaterializedViewEntity::getProductId) + .collect(Collectors.toList()); + + cacheService.cacheProductIds(CacheStrategy.HOT, null, pageable, productIds); + + log.info("인기 상품 상세 갱신 완료 - {}개", productIds.size()); + + } catch (Exception e) { + log.error("인기 상품 상세 갱신 실패", e); + } + } + + /** + * 브랜드별 인기순 상품 ID 리스트 갱신 (첫 3페이지) + */ + private void refreshBrandPopularProductIds() { + log.debug("브랜드별 인기순 ID 리스트 갱신"); + + try { + List brands = brandService.getAllBrands(); + int totalRefreshed = 0; + + for (BrandEntity brand : brands) { + try { + int refreshed = refreshBrandProductIds(brand.getId()); + totalRefreshed += refreshed; + } catch (Exception e) { + log.warn("브랜드 ID 리스트 갱신 실패 - brandId: {}", brand.getId()); + } + } + + log.info("브랜드별 ID 리스트 갱신 완료 - {}개 브랜드, {}페이지", + brands.size(), totalRefreshed); + + } catch (Exception e) { + log.error("브랜드별 ID 리스트 갱신 실패", e); + } + } + + /** + * 특정 브랜드의 인기순 상품 ID 리스트 갱신 + */ + private int refreshBrandProductIds(Long brandId) { + int refreshedPages = 0; + Sort sort = Sort.by(Sort.Direction.DESC, "likeCount"); + + for (int page = 0; page < CACHE_PAGES_PER_BRAND; page++) { + try { + Pageable pageable = PageRequest.of(page, PAGE_SIZE, sort); + + + Page products = + mvService.findByBrandId(brandId, pageable); + + if (products.isEmpty()) { + break; + } + + List productIds = products.getContent().stream() + .map(ProductMaterializedViewEntity::getProductId) + .collect(Collectors.toList()); + + cacheService.cacheProductIds(CacheStrategy.HOT, brandId, pageable, productIds); + refreshedPages++; + + log.debug("브랜드 ID 리스트 갱신 - brandId: {}, page: {}", brandId, page); + + } catch (Exception e) { + log.warn("브랜드 ID 리스트 갱신 실패 - brandId: {}, page: {}", brandId, page); + } + } + + return refreshedPages; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductMVJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductMVJpaRepository.java new file mode 100644 index 000000000..b4bd5c19e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductMVJpaRepository.java @@ -0,0 +1,64 @@ +package com.loopers.infrastructure.product; + +import java.time.ZonedDateTime; +import java.util.List; +import java.util.Optional; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.repository.query.Param; + +import com.loopers.domain.product.ProductMaterializedViewEntity; + +/** + * 상품 Materialized View JPA 리포지토리 + * + * Spring Data JPA를 활용한 기본 CRUD 및 쿼리 메서드를 제공합니다. + * + * @author hyunjikoh + * @since 2025. 11. 27. + */ +public interface ProductMVJpaRepository extends JpaRepository { + + /** + * 상품 ID로 MV를 조회합니다. + * + * @param productId 상품 ID + * @return 상품 MV + */ + Optional findByProductId(Long productId); + + /** + * 여러 상품 ID로 MV를 일괄 조회합니다. + * + * @param productIds 상품 ID 목록 + * @return 상품 MV 목록 + */ + List findByProductIdIn(@Param("productIds") List productIds); + + /** + * 지정된 시간 이전에 업데이트된 MV를 조회합니다. + * + * @param time 기준 시간 + * @return 업데이트가 필요한 MV 목록 + */ + List findByLastUpdatedAtBefore(ZonedDateTime time); + + /** + * 지정된 상품 ID 목록에 해당하는 MV를 삭제합니다. + * + * @param productIds 삭제할 상품 ID 목록 + */ + @Modifying + void deleteByProductIdIn(List productIds); + + /** + * 상품 ID로 MV 존재 여부를 확인합니다. + * + * @param productId 상품 ID + * @return 존재 여부 + */ + boolean existsByProductId(Long productId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductMVQueryRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductMVQueryRepository.java new file mode 100644 index 000000000..846918e0b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductMVQueryRepository.java @@ -0,0 +1,301 @@ +package com.loopers.infrastructure.product; + +import static com.loopers.domain.product.QProductMaterializedViewEntity.productMaterializedViewEntity; +import java.time.ZonedDateTime; +import java.util.List; +import java.util.stream.Collectors; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; + +import com.loopers.domain.brand.QBrandEntity; +import com.loopers.domain.like.QLikeEntity; +import com.loopers.domain.product.ProductMVSyncDto; +import com.loopers.domain.product.ProductMaterializedViewEntity; +import com.loopers.domain.product.QProductEntity; +import com.loopers.domain.product.dto.ProductSearchFilter; +import com.querydsl.core.types.OrderSpecifier; +import com.querydsl.core.types.Projections; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.jpa.impl.JPAQueryFactory; + +import lombok.RequiredArgsConstructor; + +/** + * 상품 Materialized View QueryDSL 리포지토리 + * + * QueryDSL을 활용한 동적 쿼리 및 복잡한 조회 로직을 처리합니다. + * 페이징, 정렬, 필터링 등의 기능을 제공합니다. + * + * @author hyunjikoh + * @since 2025. 11. 27. + */ +@Component +@RequiredArgsConstructor +public class ProductMVQueryRepository { + + private final JPAQueryFactory queryFactory; + + /** + * 브랜드별 상품 MV를 페이징 조회합니다. + * + * @param brandId 브랜드 ID + * @param pageable 페이징 정보 + * @return 페이징된 상품 MV 목록 + */ + public Page findByBrandId(Long brandId, Pageable pageable) { + BooleanExpression whereCondition = brandIdEq(brandId); + List> orderSpecifiers = buildOrderSpecifiers(pageable); + + List content = queryFactory + .selectFrom(productMaterializedViewEntity) + .where(whereCondition) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .orderBy(orderSpecifiers.toArray(new OrderSpecifier[0])) + .fetch(); + + Long total = queryFactory + .select(productMaterializedViewEntity.count()) + .from(productMaterializedViewEntity) + .where(whereCondition) + .fetchOne(); + + return new PageImpl<>(content, pageable, total != null ? total : 0); + } + + /** + * 상품명으로 검색하여 MV를 페이징 조회합니다. + * + * @param keyword 검색 키워드 + * @param pageable 페이징 정보 + * @return 페이징된 상품 MV 목록 + */ + public Page findByNameContaining(String keyword, Pageable pageable) { + BooleanExpression whereCondition = nameContains(keyword); + List> orderSpecifiers = buildOrderSpecifiers(pageable); + + List content = queryFactory + .selectFrom(productMaterializedViewEntity) + .where(whereCondition) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .orderBy(orderSpecifiers.toArray(new OrderSpecifier[0])) + .fetch(); + + Long total = queryFactory + .select(productMaterializedViewEntity.count()) + .from(productMaterializedViewEntity) + .where(whereCondition) + .fetchOne(); + + return new PageImpl<>(content, pageable, total != null ? total : 0); + } + + /** + * 검색 필터를 기반으로 상품 MV를 페이징 조회합니다. + * + * @param searchFilter 검색 필터 (브랜드 ID, 상품명, 페이징 정보) + * @return 페이징된 상품 MV 목록 + */ + public Page findBySearchFilter(ProductSearchFilter searchFilter) { + BooleanExpression whereCondition = buildWhereCondition(searchFilter); + List> orderSpecifiers = buildOrderSpecifiers(searchFilter.pageable()); + + List content = queryFactory + .selectFrom(productMaterializedViewEntity) + .where(whereCondition) + .offset(searchFilter.pageable().getOffset()) + .limit(searchFilter.pageable().getPageSize()) + .orderBy(orderSpecifiers.toArray(new OrderSpecifier[0])) + .fetch(); + + Long total = queryFactory + .select(productMaterializedViewEntity.count()) + .from(productMaterializedViewEntity) + .where(whereCondition) + .fetchOne(); + + return new PageImpl<>(content, searchFilter.pageable(), total != null ? total : 0); + } + + /** + * 검색 필터 기반 where 조건을 빌드합니다. + * + * @param searchFilter 검색 필터 + * @return where 조건식 + */ + private BooleanExpression buildWhereCondition(ProductSearchFilter searchFilter) { + BooleanExpression whereCondition = null; + + // 브랜드 ID 조건 + if (searchFilter.brandId() != null) { + whereCondition = brandIdEq(searchFilter.brandId()); + } + + // 상품명 검색 조건 + if (searchFilter.productName() != null && !searchFilter.productName().trim().isEmpty()) { + BooleanExpression nameCondition = nameContains(searchFilter.productName()); + whereCondition = whereCondition != null ? whereCondition.and(nameCondition) : nameCondition; + } + + return whereCondition; + } + + /** + * Pageable 기반 정렬 조건을 빌드합니다. + * + * @param pageable 페이징 정보 + * @return 정렬 조건 목록 + */ + private List> buildOrderSpecifiers(Pageable pageable) { + if (pageable.getSort().isEmpty()) { + // 기본 정렬: 좋아요 수 내림차순 + return List.of(productMaterializedViewEntity.likeCount.desc()); + } + + return pageable.getSort().stream() + .map(sort -> { + String property = sort.getProperty(); + boolean isAsc = sort.isAscending(); + return switch (property) { + case "likeCount" -> isAsc ? + productMaterializedViewEntity.likeCount.asc() : + productMaterializedViewEntity.likeCount.desc(); + case "name" -> isAsc ? + productMaterializedViewEntity.name.asc() : + productMaterializedViewEntity.name.desc(); + case "price" -> isAsc ? + productMaterializedViewEntity.price.originPrice.asc() : + productMaterializedViewEntity.price.originPrice.desc(); + case "createdAt" -> isAsc ? + productMaterializedViewEntity.createdAt.asc() : + productMaterializedViewEntity.createdAt.desc(); + case "updatedAt" -> isAsc ? + productMaterializedViewEntity.updatedAt.asc() : + productMaterializedViewEntity.updatedAt.desc(); + default -> productMaterializedViewEntity.likeCount.desc(); + }; + }) + .collect(Collectors.toList()); + } + + /** + * 브랜드 ID 동등 조건을 생성합니다. + * + * @param brandId 브랜드 ID + * @return 조건식 + */ + private BooleanExpression brandIdEq(Long brandId) { + return brandId != null ? productMaterializedViewEntity.brandId.eq(brandId) : null; + } + + /** + * 상품명 포함 조건을 생성합니다. + * + * @param keyword 검색 키워드 + * @return 조건식 + */ + private BooleanExpression nameContains(String keyword) { + return keyword != null && !keyword.isBlank() ? + productMaterializedViewEntity.name.contains(keyword) : null; + } + + + /** + * 지정된 시간 이후 변경된 상품 MV를 조회합니다. + * + * 상품, 좋아요, 브랜드 중 하나라도 변경된 경우를 감지합니다. + * 여유 시간(기본 1분)을 추가하여 타이밍 이슈를 방지합니다. + * + * @param syncedTime 마지막 배치 실행 시간 + * @param marginMinutes 여유 시간 (분) + * @return 변경된 MV 엔티티 목록 + */ + public List findChangedSince( + ZonedDateTime syncedTime, + int marginMinutes + ) { + // 여유 시간을 적용한 기준 시간 설정 + ZonedDateTime baseTime = syncedTime.minusMinutes(marginMinutes); + + + return queryFactory + .selectFrom(productMaterializedViewEntity) + .where( + // 상품, 좋아요, 브랜드 중 하나라도 변경된 경우 + productMaterializedViewEntity.productUpdatedAt.after(baseTime) + .or(productMaterializedViewEntity.likeUpdatedAt.after(baseTime)) + .or(productMaterializedViewEntity.brandUpdatedAt.after(baseTime)), + // 삭제되지 않은 MV만 조회 + productMaterializedViewEntity.deletedAt.isNull() + ) + .orderBy(productMaterializedViewEntity.productUpdatedAt.desc()) + .fetch(); + } + + /** + * 마지막 배치 시간 이후 변경된 데이터를 Product, Brand, Like 조인으로 조회합니다. + * + * 단일 쿼리로 변경된 상품만 조회하여 성능을 최적화합니다. + * + * @param lastBatchTime 마지막 배치 실행 시간 + * @return 변경된 상품 데이터 DTO 목록 + */ + public List findChangedProductsForSync(ZonedDateTime lastBatchTime) { + QProductEntity product = QProductEntity.productEntity; + QBrandEntity brand = QBrandEntity.brandEntity; + QLikeEntity like = QLikeEntity.likeEntity; + + return queryFactory + .select(Projections.constructor( + ProductMVSyncDto.class, + // 상품 정보 + product.id, + product.name, + product.description, + product.price.originPrice, + product.price.discountPrice, + product.stockQuantity, + product.updatedAt, + // 브랜드 정보 + brand.id, + brand.name, + brand.updatedAt, + // 좋아요 정보 + like.id.count().coalesce(0L), + like.updatedAt.max() + )) + .from(product) + .leftJoin(brand) + .on(product.brandId.eq(brand.id)) + .leftJoin(like) + .on(like.productId.eq(product.id) + .and(like.deletedAt.isNull())) + .where( + // 상품, 브랜드, 좋아요 중 하나라도 변경된 경우 + product.updatedAt.after(lastBatchTime) + .or(brand.updatedAt.after(lastBatchTime)) + .or(like.updatedAt.after(lastBatchTime)), + // 삭제되지 않은 상품만 + product.deletedAt.isNull(), + brand.deletedAt.isNull() + ) + .groupBy( + product.id, + product.name, + product.description, + product.price.originPrice, + product.price.discountPrice, + product.stockQuantity, + product.updatedAt, + brand.id, + brand.name, + brand.updatedAt + ) + .fetch(); + } + +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductMVRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductMVRepositoryImpl.java new file mode 100644 index 000000000..9fd55c8be --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductMVRepositoryImpl.java @@ -0,0 +1,98 @@ +package com.loopers.infrastructure.product; + +import java.time.ZonedDateTime; +import java.util.List; +import java.util.Optional; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import com.loopers.domain.product.ProductMVRepository; +import com.loopers.domain.product.ProductMVSyncDto; +import com.loopers.domain.product.ProductMaterializedViewEntity; +import com.loopers.domain.product.dto.ProductSearchFilter; + +import lombok.RequiredArgsConstructor; + +/** + * 상품 Materialized View 리포지토리 구현체 + * + * Domain 계층의 ProductMVRepository 인터페이스를 구현합니다. + * JPA Repository와 QueryDSL을 활용하여 데이터 접근을 처리합니다. + * + * @author hyunjikoh + * @since 2025. 11. 27. + */ +@Component +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ProductMVRepositoryImpl implements ProductMVRepository { + + private final ProductMVJpaRepository jpaRepository; + private final ProductMVQueryRepository queryRepository; + + @Override + public Optional findById(Long productId) { + return jpaRepository.findByProductId(productId); + } + + + @Override + public Page findByBrandId(Long brandId, Pageable pageable) { + return queryRepository.findByBrandId(brandId, pageable); + } + + @Override + public Page findByNameContaining(String keyword, Pageable pageable) { + return queryRepository.findByNameContaining(keyword, pageable); + } + + @Override + public List findByIdIn(List productIds) { + return jpaRepository.findByProductIdIn(productIds); + } + + @Override + public List findByLastUpdatedAtBefore(ZonedDateTime time) { + return jpaRepository.findByLastUpdatedAtBefore(time); + } + + @Override + @Transactional + public ProductMaterializedViewEntity save(ProductMaterializedViewEntity entity) { + return jpaRepository.save(entity); + } + + @Override + @Transactional + public List saveAll(List entities) { + return jpaRepository.saveAll(entities); + } + + @Override + public void deleteByProductIdIn(List productIds) { + jpaRepository.deleteByProductIdIn(productIds); + } + + @Override + public boolean existsByProductId(Long productId) { + return jpaRepository.existsByProductId(productId); + } + + @Override + public long count() { + return jpaRepository.count(); + } + + @Override + public Page findBySearchFilter(ProductSearchFilter searchFilter) { + return queryRepository.findBySearchFilter(searchFilter); + } + + @Override + public List findChangedProductsForSync(ZonedDateTime lastBatchTime) { + return queryRepository.findChangedProductsForSync(lastBatchTime); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductQueryRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductQueryRepository.java new file mode 100644 index 000000000..f17e5ca55 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductQueryRepository.java @@ -0,0 +1,107 @@ +package com.loopers.infrastructure.product; + +import static com.loopers.domain.product.QProductEntity.productEntity; +import java.util.List; +import java.util.stream.Collectors; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; + +import com.loopers.domain.product.ProductEntity; +import com.loopers.domain.product.dto.ProductSearchFilter; +import com.querydsl.core.types.OrderSpecifier; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.jpa.impl.JPAQueryFactory; + +import lombok.RequiredArgsConstructor; + +/** + * @author hyunjikoh + * @since 2025. 11. 10. + */ +@Component +@RequiredArgsConstructor +public class ProductQueryRepository { + private final JPAQueryFactory queryFactory; + + /** + * 상품 페이징 조회 + * + * @param searchFilter + * @return + */ + public Page getProducts(ProductSearchFilter searchFilter) { + Pageable pageable = searchFilter.pageable(); + + // 공통 where 조건 빌드 + BooleanExpression whereCondition = buildWhereCondition(searchFilter); + + // 정렬 조건 빌드 + List> orderSpecifiers = buildOrderSpecifiers(pageable); + + // 조회용 쿼리 + List content = queryFactory + .selectFrom(productEntity) + .where(whereCondition) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .orderBy(orderSpecifiers.toArray(new OrderSpecifier[0])) + .fetch(); + + // 카운트 쿼리 (동일한 조건 적용) + Long total = queryFactory + .select(productEntity.count()) + .from(productEntity) + .where(whereCondition) + .fetchOne(); + + return new PageImpl<>(content, pageable, total != null ? total : 0); + } + + /** + * pageable 기반 정렬 조건 빌드 + * + * @param pageable + * @return + */ + private List> buildOrderSpecifiers(Pageable pageable) { + return pageable.getSort().stream() + .map(sort -> { + String property = sort.getProperty(); + boolean isAsc = sort.isAscending(); + return switch (property) { + case "createdAt" -> isAsc ? productEntity.createdAt.asc() : productEntity.createdAt.desc(); + case "id" -> isAsc ? productEntity.id.asc() : productEntity.id.desc(); + // likeCount 정렬은 MV 테이블에서 처리하므로 제거 + // 필요시 ProductLikeStatsEntity를 조인하여 정렬 + case "price" -> isAsc ? productEntity.price.originPrice.asc() : productEntity.price.originPrice.desc(); + default -> + // 기본 정렬: 생성일시 내림차순 + productEntity.createdAt.desc(); + }; + }) + .collect(Collectors.toList()); + } + + /** + * 검색 필터 기반 where 조건 빌드 + * + * @param searchFilter + * @return + */ + private BooleanExpression buildWhereCondition(ProductSearchFilter searchFilter) { + return productEntity.deletedAt.isNull() + .and(brandIdEq(searchFilter.brandId())) + .and(productNameContains(searchFilter.productName())); + } + + private BooleanExpression brandIdEq(Long brandId) { + return brandId != null ? productEntity.brandId.eq(brandId) : null; + } + + private BooleanExpression productNameContains(String productName) { + return productName != null ? productEntity.name.contains(productName) : null; + } +} 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 new file mode 100644 index 000000000..63e6eb02d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java @@ -0,0 +1,48 @@ +package com.loopers.infrastructure.product; + +import java.util.Optional; + +import org.springframework.data.domain.Page; +import org.springframework.stereotype.Component; + +import com.loopers.domain.product.ProductEntity; +import com.loopers.domain.product.ProductRepository; +import com.loopers.domain.product.dto.ProductSearchFilter; + +import lombok.RequiredArgsConstructor; + +/** + * @author hyunjikoh + * @since 2025. 11. 10. + */ +@Component +@RequiredArgsConstructor +public class ProductRepositoryImpl implements ProductRepository { + private final ProductJpaRepository productJpaRepository; + private final ProductQueryRepository productQueryRepository; + + @Override + public ProductEntity save(ProductEntity product) { + return productJpaRepository.save(product); + } + + @Override + public Page getProducts(ProductSearchFilter searchFilter) { + return productQueryRepository.getProducts(searchFilter); + } + + @Override + public Optional findActiveById(Long id) { + return productJpaRepository.findByIdAndDeletedAtIsNull(id); + } + + @Override + public Optional findById(Long id) { + return productJpaRepository.findById(id); + } + + @Override + public Optional findByIdWithLock(Long id) { + return productJpaRepository.findByIdWithLock(id); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java new file mode 100644 index 000000000..8f07dc0f6 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java @@ -0,0 +1,18 @@ +package com.loopers.infrastructure.user; + +import static jakarta.persistence.LockModeType.PESSIMISTIC_WRITE; +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; + +import com.loopers.domain.user.UserEntity; + +public interface UserJpaRepository extends JpaRepository { + Optional findByUsernameAndDeletedAtIsNull(String username); + + @Lock(PESSIMISTIC_WRITE) + @Query("SELECT u FROM UserEntity u WHERE u.username = :username AND u.deletedAt IS NULL") + Optional findByUsernameWithLockAndDeletedAtIsNull(String username); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java new file mode 100644 index 000000000..6ac5010f9 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java @@ -0,0 +1,35 @@ +package com.loopers.infrastructure.user; + +import java.util.Optional; + +import org.springframework.stereotype.Component; + +import com.loopers.domain.user.UserEntity; +import com.loopers.domain.user.UserRepository; + +import lombok.RequiredArgsConstructor; + +/** + * @author hyunjikoh + * @since 2025. 10. 27. + */ +@RequiredArgsConstructor +@Component +public class UserRepositoryImpl implements UserRepository { + private final UserJpaRepository userJpaRepository; + + @Override + public UserEntity save(UserEntity userEntity) { + return userJpaRepository.save(userEntity); + } + + @Override + public Optional findByUsername(String username) { + return userJpaRepository.findByUsernameAndDeletedAtIsNull(username); + } + + @Override + public Optional findByUsernameWithLock(String username) { + return userJpaRepository.findByUsernameWithLockAndDeletedAtIsNull(username); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java index 20b2809c8..2fe6017a8 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java @@ -1,13 +1,14 @@ package com.loopers.interfaces.api; -import com.fasterxml.jackson.databind.JsonMappingException; -import com.fasterxml.jackson.databind.exc.InvalidFormatException; -import com.fasterxml.jackson.databind.exc.MismatchedInputException; -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import lombok.extern.slf4j.Slf4j; +import java.util.Arrays; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + import org.springframework.http.ResponseEntity; import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.MissingRequestHeaderException; import org.springframework.web.bind.MissingServletRequestParameterException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; @@ -15,10 +16,13 @@ import org.springframework.web.server.ServerWebInputException; import org.springframework.web.servlet.resource.NoResourceFoundException; -import java.util.Arrays; -import java.util.regex.Matcher; -import java.util.regex.Pattern; -import java.util.stream.Collectors; +import com.fasterxml.jackson.databind.JsonMappingException; +import com.fasterxml.jackson.databind.exc.InvalidFormatException; +import com.fasterxml.jackson.databind.exc.MismatchedInputException; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; + +import lombok.extern.slf4j.Slf4j; @RestControllerAdvice @Slf4j @@ -38,6 +42,14 @@ public ResponseEntity> handleBadRequest(MethodArgumentTypeMismatc return failureResponse(ErrorType.BAD_REQUEST, message); } + @ExceptionHandler + public ResponseEntity> handleBadRequest(MethodArgumentNotValidException e) { + String errorMessage = e.getBindingResult().getAllErrors().stream() + .map(error -> error.getDefaultMessage() != null ? error.getDefaultMessage() : "유효성 검증에 실패했습니다.") + .collect(Collectors.joining(", ")); + return failureResponse(ErrorType.BAD_REQUEST, errorMessage); + } + @ExceptionHandler public ResponseEntity> handleBadRequest(MissingServletRequestParameterException e) { String name = e.getParameterName(); @@ -46,6 +58,13 @@ public ResponseEntity> handleBadRequest(MissingServletRequestPara return failureResponse(ErrorType.BAD_REQUEST, message); } + @ExceptionHandler + public ResponseEntity> handleBadRequest(MissingRequestHeaderException e) { + String headerName = e.getHeaderName(); + String message = String.format("필수 요청 헤더 '%s'가 누락되었습니다.", headerName); + return failureResponse(ErrorType.BAD_REQUEST, message); + } + @ExceptionHandler public ResponseEntity> handleBadRequest(HttpMessageNotReadableException e) { String errorMessage; @@ -53,15 +72,15 @@ public ResponseEntity> handleBadRequest(HttpMessageNotReadableExc if (rootCause instanceof InvalidFormatException invalidFormat) { String fieldName = invalidFormat.getPath().stream() - .map(ref -> ref.getFieldName() != null ? ref.getFieldName() : "?") - .collect(Collectors.joining(".")); + .map(ref -> ref.getFieldName() != null ? ref.getFieldName() : "?") + .collect(Collectors.joining(".")); String valueIndicationMessage = ""; if (invalidFormat.getTargetType().isEnum()) { Class enumClass = invalidFormat.getTargetType(); String enumValues = Arrays.stream(enumClass.getEnumConstants()) - .map(Object::toString) - .collect(Collectors.joining(", ")); + .map(Object::toString) + .collect(Collectors.joining(", ")); valueIndicationMessage = "사용 가능한 값 : [" + enumValues + "]"; } @@ -69,20 +88,20 @@ public ResponseEntity> handleBadRequest(HttpMessageNotReadableExc Object value = invalidFormat.getValue(); errorMessage = String.format("필드 '%s'의 값 '%s'이(가) 예상 타입(%s)과 일치하지 않습니다. %s", - fieldName, value, expectedType, valueIndicationMessage); + fieldName, value, expectedType, valueIndicationMessage); } else if (rootCause instanceof MismatchedInputException mismatchedInput) { String fieldPath = mismatchedInput.getPath().stream() - .map(ref -> ref.getFieldName() != null ? ref.getFieldName() : "?") - .collect(Collectors.joining(".")); + .map(ref -> ref.getFieldName() != null ? ref.getFieldName() : "?") + .collect(Collectors.joining(".")); errorMessage = String.format("필수 필드 '%s'이(가) 누락되었습니다.", fieldPath); } else if (rootCause instanceof JsonMappingException jsonMapping) { String fieldPath = jsonMapping.getPath().stream() - .map(ref -> ref.getFieldName() != null ? ref.getFieldName() : "?") - .collect(Collectors.joining(".")); + .map(ref -> ref.getFieldName() != null ? ref.getFieldName() : "?") + .collect(Collectors.joining(".")); errorMessage = String.format("필드 '%s'에서 JSON 매핑 오류가 발생했습니다: %s", - fieldPath, jsonMapping.getOriginalMessage()); + fieldPath, jsonMapping.getOriginalMessage()); } else { errorMessage = "요청 본문을 처리하는 중 오류가 발생했습니다. JSON 메세지 규격을 확인해주세요."; @@ -121,6 +140,6 @@ private String extractMissingParameter(String message) { private ResponseEntity> failureResponse(ErrorType errorType, String errorMessage) { return ResponseEntity.status(errorType.getStatus()) - .body(ApiResponse.fail(errorType.getCode(), errorMessage != null ? errorMessage : errorType.getMessage())); + .body(ApiResponse.fail(errorType.getCode(), errorMessage != null ? errorMessage : errorType.getMessage())); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiResponse.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiResponse.java index 33b77b529..d01500408 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiResponse.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiResponse.java @@ -25,8 +25,8 @@ public static ApiResponse success(T data) { public static ApiResponse fail(String errorCode, String errorMessage) { return new ApiResponse<>( - Metadata.fail(errorCode, errorMessage), - null + Metadata.fail(errorCode, errorMessage), + null ); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/common/PageResponse.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/common/PageResponse.java new file mode 100644 index 000000000..6db694b60 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/common/PageResponse.java @@ -0,0 +1,50 @@ +package com.loopers.interfaces.api.common; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; + +import org.springframework.data.domain.Page; + +/** + * @author hyunjikoh + * @since 2025. 11. 21. + */ +@Schema(description = "페이징 응답") +public record PageResponse( + @Schema(description = "데이터 목록") + List content, + + @Schema(description = "현재 페이지 번호 (0부터 시작)", example = "0") + int pageNumber, + + @Schema(description = "페이지 크기", example = "20") + int pageSize, + + @Schema(description = "전체 요소 개수", example = "100") + long totalElements, + + @Schema(description = "전체 페이지 개수", example = "5") + int totalPages, + + @Schema(description = "첫 페이지 여부", example = "true") + boolean first, + + @Schema(description = "마지막 페이지 여부", example = "false") + boolean last, + + @Schema(description = "비어있는 페이지 여부", example = "false") + boolean empty +) { + public static PageResponse from(Page page) { + return new PageResponse<>( + page.getContent(), + page.getNumber(), + page.getSize(), + page.getTotalElements(), + page.getTotalPages(), + page.isFirst(), + page.isLast(), + page.isEmpty() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1ApiSpec.java index 219e3101e..a706ca60e 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1ApiSpec.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1ApiSpec.java @@ -1,19 +1,20 @@ package com.loopers.interfaces.api.example; -import com.loopers.interfaces.api.ApiResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.tags.Tag; +import com.loopers.interfaces.api.ApiResponse; + @Tag(name = "Example V1 API", description = "Loopers 예시 API 입니다.") public interface ExampleV1ApiSpec { @Operation( - summary = "예시 조회", - description = "ID로 예시를 조회합니다." + summary = "예시 조회", + description = "ID로 예시를 조회합니다." ) ApiResponse getExample( - @Schema(name = "예시 ID", description = "조회할 예시의 ID") - Long exampleId + @Schema(name = "예시 ID", description = "조회할 예시의 ID") + Long exampleId ); } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Controller.java index 917376016..d89a26140 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Controller.java @@ -1,14 +1,16 @@ package com.loopers.interfaces.api.example; -import com.loopers.application.example.ExampleFacade; -import com.loopers.application.example.ExampleInfo; -import com.loopers.interfaces.api.ApiResponse; -import lombok.RequiredArgsConstructor; 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.RestController; +import com.loopers.application.example.ExampleFacade; +import com.loopers.application.example.ExampleInfo; +import com.loopers.interfaces.api.ApiResponse; + +import lombok.RequiredArgsConstructor; + @RequiredArgsConstructor @RestController @RequestMapping("/api/v1/examples") @@ -19,7 +21,7 @@ public class ExampleV1Controller implements ExampleV1ApiSpec { @GetMapping("/{exampleId}") @Override public ApiResponse getExample( - @PathVariable(value = "exampleId") Long exampleId + @PathVariable(value = "exampleId") Long exampleId ) { ExampleInfo info = exampleFacade.getExample(exampleId); ExampleV1Dto.ExampleResponse response = ExampleV1Dto.ExampleResponse.from(info); diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Dto.java index 4ecf0eea5..763359ac1 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Dto.java @@ -6,9 +6,9 @@ public class ExampleV1Dto { public record ExampleResponse(Long id, String name, String description) { public static ExampleResponse from(ExampleInfo info) { return new ExampleResponse( - info.id(), - info.name(), - info.description() + info.id(), + info.name(), + info.description() ); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1ApiSpec.java new file mode 100644 index 000000000..791e4b014 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1ApiSpec.java @@ -0,0 +1,36 @@ +package com.loopers.interfaces.api.like; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.tags.Tag; + +import com.loopers.interfaces.api.ApiResponse; + +@Tag(name = "Like V1 API", description = "좋아요 관리 API") +public interface LikeV1ApiSpec { + + @Operation( + summary = "좋아요 등록", + description = "상품에 좋아요를 등록합니다. 이미 좋아요한 상품이면 무시됩니다." + ) + ApiResponse upsertLike( + @Schema(name = "사용자명", description = "좋아요할 사용자명") + String username, + + @Schema(name = "상품 ID", description = "좋아요할 상품의 ID") + Long productId + ); + + @Operation( + summary = "좋아요 취소", + description = "상품의 좋아요를 취소합니다." + ) + ApiResponse unlikeProduct( + @Schema(name = "사용자명", description = "좋아요 취소할 사용자명") + String username, + + @Schema(name = "상품 ID", description = "좋아요 취소할 상품의 ID") + Long productId + ); +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Controller.java new file mode 100644 index 000000000..f0d161558 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Controller.java @@ -0,0 +1,39 @@ +package com.loopers.interfaces.api.like; + +import org.springframework.web.bind.annotation.*; + +import com.loopers.application.like.LikeFacade; +import com.loopers.application.like.LikeInfo; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.support.Uris; + +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +public class LikeV1Controller implements LikeV1ApiSpec { + + private final LikeFacade likeFacade; + + @PostMapping(Uris.Like.UPSERT) + @Override + public ApiResponse upsertLike( + @RequestHeader("X-USER-ID") String username, + @PathVariable Long productId + ) { + LikeInfo likeInfo = likeFacade.upsertLike(username, productId); + LikeV1Dtos.LikeResponse response = LikeV1Dtos.LikeResponse.from(likeInfo); + return ApiResponse.success(response); + } + + @DeleteMapping(Uris.Like.CANCEL) + @Override + public ApiResponse unlikeProduct( + @RequestHeader("X-USER-ID") String username, + @PathVariable Long productId + ) { + likeFacade.unlikeProduct(username, productId); + return ApiResponse.success(null); + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Dtos.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Dtos.java new file mode 100644 index 000000000..beb7d9beb --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Dtos.java @@ -0,0 +1,34 @@ +package com.loopers.interfaces.api.like; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.ZonedDateTime; + +import com.loopers.application.like.LikeInfo; + +public class LikeV1Dtos { + + @Schema(description = "좋아요 등록 응답") + public record LikeResponse( + @Schema(description = "사용자명", example = "testuser") + String username, + + @Schema(description = "상품 ID", example = "1") + Long productId, + + @Schema(description = "상품명", example = "나이키 에어맥스") + String productName, + + @Schema(description = "등록 일시") + ZonedDateTime createdAt + ) { + public static LikeResponse from(LikeInfo likeInfo) { + return new LikeResponse( + likeInfo.username(), + likeInfo.productId(), + likeInfo.productName(), + likeInfo.createdAt() + ); + } + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1ApiSpec.java new file mode 100644 index 000000000..24e4e3bab --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1ApiSpec.java @@ -0,0 +1,48 @@ +package com.loopers.interfaces.api.order; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.tags.Tag; + +import org.springframework.data.domain.Pageable; +import org.springframework.web.bind.annotation.RequestHeader; + +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.common.PageResponse; + +@Tag(name = "Order V1 API", description = "주문 관리 API") +public interface OrderV1ApiSpec { + + @Operation( + summary = "주문 등록", + description = "새로운 주문을 등록합니다." + ) + ApiResponse createOrder( + @Schema(name = "사용자명", description = "주문할 사용자명") + String username, + OrderV1Dtos.OrderCreateRequest request + ); + + @Operation( + summary = "주문 목록 조회", + description = "사용자의 주문 목록을 페이징하여 조회합니다." + ) + ApiResponse> getOrders( + @Schema(name = "사용자명", description = "조회할 사용자명") + String username, + + @Schema(name = "페이징 정보", description = "페이지 번호와 크기") + Pageable pageable + ); + + @Operation( + summary = "주문 상세 조회", + description = "주문 ID로 주문 상세 정보를 조회합니다." + ) + ApiResponse getOrderDetail( + @RequestHeader("X-USER-ID") String username, + + @Schema(name = "주문 ID", description = "조회할 주문의 ID") + Long orderId + ); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java new file mode 100644 index 000000000..d85d8c803 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java @@ -0,0 +1,70 @@ +package com.loopers.interfaces.api.order; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +import org.springframework.web.bind.annotation.*; + +import com.loopers.application.order.OrderCreateCommand; +import com.loopers.application.order.OrderFacade; +import com.loopers.application.order.OrderInfo; +import com.loopers.application.order.OrderSummary; +import com.loopers.application.user.UserFacade; +import com.loopers.application.user.UserInfo; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.common.PageResponse; +import com.loopers.support.Uris; + +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +public class OrderV1Controller implements OrderV1ApiSpec { + + private final OrderFacade orderFacade; + private final UserFacade userFacade; + + @PostMapping(Uris.Order.CREATE) + @Override + public ApiResponse createOrder( + @RequestHeader("X-USER-ID") String username, + @RequestBody OrderV1Dtos.OrderCreateRequest request + ) { + OrderCreateCommand command = request.toCommand(username); + OrderInfo orderInfo = orderFacade.createOrder(command); + OrderV1Dtos.OrderCreateResponse response = OrderV1Dtos.OrderCreateResponse.from(orderInfo); + return ApiResponse.success(response); + } + + @GetMapping(Uris.Order.GET_LIST) + @Override + public ApiResponse> getOrders( + @RequestHeader("X-USER-ID") String username, + @PageableDefault(size = 20) Pageable pageable + ) { + UserInfo userInfo = userFacade.getUserByUsername(username); + Page orders = orderFacade.getOrderSummariesByUserId(userInfo.id(), pageable); + Page responsePage = orders.map(summary -> + new OrderV1Dtos.OrderListResponse( + summary.id(), + summary.status(), + summary.originalTotalAmount(), + summary.discountAmount(), + summary.finalTotalAmount(), + summary.createdAt() + ) + ); + return ApiResponse.success(PageResponse.from(responsePage)); + } + + @GetMapping(Uris.Order.GET_DETAIL) + @Override + public ApiResponse getOrderDetail( + @RequestHeader("X-USER-ID") String username, + @PathVariable Long orderId + ) { + OrderInfo orderInfo = orderFacade.getOrderById(username, orderId); + OrderV1Dtos.OrderDetailResponse response = OrderV1Dtos.OrderDetailResponse.from(orderInfo); + return ApiResponse.success(response); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dtos.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dtos.java new file mode 100644 index 000000000..61bbb9f7d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dtos.java @@ -0,0 +1,186 @@ +package com.loopers.interfaces.api.order; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.math.BigDecimal; +import java.time.ZonedDateTime; +import java.util.List; + +import com.loopers.application.order.OrderCreateCommand; +import com.loopers.application.order.OrderInfo; +import com.loopers.application.order.OrderItemCommand; +import com.loopers.domain.order.OrderStatus; + +public class OrderV1Dtos { + + @Schema(description = "주문 등록 요청") + public record OrderCreateRequest( + @Schema(description = "주문 상품 목록", requiredMode = Schema.RequiredMode.REQUIRED) + List items + ) { + public OrderCreateCommand toCommand(String username) { + List orderItems = items.stream() + .map(item -> new OrderItemCommand( + item.productId(), + item.quantity(), + item.couponId() + )) + .toList(); + return new OrderCreateCommand(username, orderItems); + } + } + + @Schema(description = "주문 상품 정보") + public record OrderItemRequest( + @Schema(description = "상품 ID", example = "1", requiredMode = Schema.RequiredMode.REQUIRED) + Long productId, + + @Schema(description = "수량", example = "2", requiredMode = Schema.RequiredMode.REQUIRED) + Integer quantity, + + @Schema(description = "쿠폰 ID (선택)", example = "1") + Long couponId + ) { + } + + @Schema(description = "주문 등록 응답") + public record OrderCreateResponse( + @Schema(description = "주문 ID", example = "1") + Long orderId, + + @Schema(description = "주문 상태", example = "PENDING") + OrderStatus status, + + @Schema(description = "할인 전 총액", example = "25000.00") + BigDecimal originalTotalAmount, + + @Schema(description = "할인 금액", example = "5000.00") + BigDecimal discountAmount, + + @Schema(description = "할인 후 총액 (실제 결제 금액)", example = "20000.00") + BigDecimal finalTotalAmount, + + @Schema(description = "주문 일시") + ZonedDateTime orderedAt + ) { + public static OrderCreateResponse from(OrderInfo orderInfo) { + return new OrderCreateResponse( + orderInfo.id(), + orderInfo.status(), + orderInfo.originalTotalAmount(), + orderInfo.discountAmount(), + orderInfo.finalTotalAmount(), + orderInfo.createdAt() + ); + } + } + + @Schema(description = "주문 상세 응답") + public record OrderDetailResponse( + @Schema(description = "주문 ID", example = "1") + Long orderId, + + @Schema(description = "사용자 ID", example = "1") + Long userId, + + @Schema(description = "주문 상태", example = "PENDING") + OrderStatus status, + + @Schema(description = "할인 전 총액", example = "25000.00") + BigDecimal originalTotalAmount, + + @Schema(description = "할인 금액", example = "5000.00") + BigDecimal discountAmount, + + @Schema(description = "할인 후 총액 (실제 결제 금액)", example = "20000.00") + BigDecimal finalTotalAmount, + + @Schema(description = "주문 상품 목록") + List items, + + @Schema(description = "주문 일시") + ZonedDateTime orderedAt, + + @Schema(description = "수정 일시") + ZonedDateTime updatedAt + ) { + public static OrderDetailResponse from(OrderInfo orderInfo) { + return new OrderDetailResponse( + orderInfo.id(), + orderInfo.userId(), + orderInfo.status(), + orderInfo.originalTotalAmount(), + orderInfo.discountAmount(), + orderInfo.finalTotalAmount(), + orderInfo.orderItems().stream() + .map(OrderItemResponse::from) + .toList(), + orderInfo.createdAt(), + orderInfo.updatedAt() + ); + } + } + + @Schema(description = "주문 상품 응답") + public record OrderItemResponse( + @Schema(description = "주문 항목 ID", example = "1") + Long id, + + @Schema(description = "상품 ID", example = "1") + Long productId, + + @Schema(description = "수량", example = "2") + Integer quantity, + + @Schema(description = "단가", example = "10000.00") + BigDecimal unitPrice, + + @Schema(description = "할인 금액", example = "2000.00") + BigDecimal discountAmount, + + @Schema(description = "총 금액 (할인 적용 후)", example = "18000.00") + BigDecimal totalPrice + ) { + public static OrderItemResponse from(com.loopers.application.order.OrderItemInfo itemInfo) { + return new OrderItemResponse( + itemInfo.id(), + itemInfo.productId(), + itemInfo.quantity(), + itemInfo.unitPrice(), + itemInfo.discountAmount(), + itemInfo.totalPrice() + ); + } + } + + @Schema(description = "주문 목록 응답") + public record OrderListResponse( + @Schema(description = "주문 ID", example = "1") + Long orderId, + + @Schema(description = "주문 상태", example = "PENDING") + OrderStatus status, + + @Schema(description = "할인 전 총액", example = "25000.00") + BigDecimal originalTotalAmount, + + @Schema(description = "할인 금액", example = "5000.00") + BigDecimal discountAmount, + + @Schema(description = "할인 후 총액 (실제 결제 금액)", example = "20000.00") + BigDecimal finalTotalAmount, + + @Schema(description = "주문 일시") + ZonedDateTime orderedAt + ) { + public static OrderListResponse from(OrderInfo orderInfo) { + return new OrderListResponse( + orderInfo.id(), + orderInfo.status(), + orderInfo.originalTotalAmount(), + orderInfo.discountAmount(), + orderInfo.finalTotalAmount(), + orderInfo.createdAt() + ); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1Controller.java new file mode 100644 index 000000000..b27fe9a0f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1Controller.java @@ -0,0 +1,37 @@ +package com.loopers.interfaces.api.point; + +import org.springframework.web.bind.annotation.*; + +import com.loopers.application.point.PointFacade; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.support.Uris; + +import lombok.RequiredArgsConstructor; + +import jakarta.validation.Valid; + +/** + * @author hyunjikoh + * @since 2025. 10. 27. + */ +@RequiredArgsConstructor +@RestController +public class PointV1Controller { + private final PointFacade pointFacade; + + @GetMapping(Uris.Point.GET_INFO) + public ApiResponse getPointInfo(@RequestHeader("X-USER-ID") String username) { + PointV1Dtos.PointInfo pointInfo = pointFacade.getPointInfo(username); + + return ApiResponse.success(PointV1Dtos.PointInfoResponse.from(pointInfo)); + } + + @PostMapping(Uris.Point.CHARGE) + public ApiResponse chargePoint( + @RequestHeader("X-USER-ID") String username, + @Valid @RequestBody PointV1Dtos.PointChargeRequest request) { + PointV1Dtos.PointChargeResponse response = pointFacade.chargePoint(username, request); + + return ApiResponse.success(response); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1Dtos.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1Dtos.java new file mode 100644 index 000000000..75587d38a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1Dtos.java @@ -0,0 +1,43 @@ +package com.loopers.interfaces.api.point; + +import java.math.BigDecimal; + +import com.loopers.domain.user.UserEntity; + +public class PointV1Dtos { + + public record PointInfoResponse( + String username, + BigDecimal currentPointAmount + ) { + public static PointInfoResponse from(PointInfo pointInfo) { + return new PointInfoResponse( + pointInfo.username(), + pointInfo.currentPointAmount() + ); + } + } + + public record PointInfo( + String username, + BigDecimal currentPointAmount + ) { + public static PointInfo from(UserEntity user) { + return new PointInfo( + user.getUsername(), + user.getPointAmount() + ); + } + } + + public record PointChargeRequest( + BigDecimal amount + ) { + } + + public record PointChargeResponse( + String username, + BigDecimal totalAmount + ) { + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1ApiSpec.java new file mode 100644 index 000000000..1684fcd0a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1ApiSpec.java @@ -0,0 +1,52 @@ +package com.loopers.interfaces.api.product; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; + +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestParam; + +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.common.PageResponse; + +@Tag(name = "Product V1 API", description = "상품 관리 API") +public interface ProductV1ApiSpec { + + @Operation( + summary = "상품 목록 조회", + description = "상품 목록을 페이징하여 조회합니다." + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "조회 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "잘못된 요청") + }) + ApiResponse> getProducts( + @PageableDefault(size = 20) Pageable pageable, + @RequestParam(required = false) Long brandId, + @RequestParam(required = false) String productName + ); + + @Operation( + summary = "상품 상세 조회", + description = "상품 ID로 상품 상세 정보를 조회합니다. 로그인한 사용자의 경우 좋아요 여부도 함께 조회됩니다." + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "조회 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "상품을 찾을 수 없음") + }) + ApiResponse getProductDetail( + @Parameter(description = "상품 ID", example = "1", required = true) + @PathVariable + Long productId, + + @Parameter(description = "사용자명 (선택)", example = "testuser") + @RequestHeader(value = "X-USER-ID", required = false) + String username + ); +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java new file mode 100644 index 000000000..106381f81 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java @@ -0,0 +1,49 @@ +package com.loopers.interfaces.api.product; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +import org.springframework.web.bind.annotation.*; + +import com.loopers.application.product.ProductDetailInfo; +import com.loopers.application.product.ProductFacade; +import com.loopers.application.product.ProductInfo; +import com.loopers.domain.product.dto.ProductSearchFilter; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.common.PageResponse; +import com.loopers.support.Uris; + +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +public class ProductV1Controller implements ProductV1ApiSpec { + + private final ProductFacade productFacade; + + + @GetMapping(Uris.Product.GET_LIST) + @Override + public ApiResponse> getProducts( + @PageableDefault(size = 20) Pageable pageable, + @RequestParam(required = false) Long brandId, + @RequestParam(required = false) String productName + ) { + ProductSearchFilter filter = new ProductSearchFilter(brandId, productName, pageable); + Page products = productFacade.getProducts(filter); + Page responsePage = products.map(ProductV1Dtos.ProductListResponse::from); + return ApiResponse.success(PageResponse.from(responsePage)); + } + + @GetMapping(Uris.Product.GET_DETAIL) + @Override + public ApiResponse getProductDetail( + @PathVariable Long productId, + @RequestHeader(value = "X-USER-ID", required = false) String username + ) { + ProductDetailInfo productDetail = productFacade.getProductDetail(productId, username); + ProductV1Dtos.ProductDetailResponse response = ProductV1Dtos.ProductDetailResponse.from(productDetail); + return ApiResponse.success(response); + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dtos.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dtos.java new file mode 100644 index 000000000..1fe8ca600 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dtos.java @@ -0,0 +1,132 @@ +package com.loopers.interfaces.api.product; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.math.BigDecimal; +import java.time.ZonedDateTime; + +import com.loopers.application.product.ProductDetailInfo; +import com.loopers.application.product.ProductInfo; + +public class ProductV1Dtos { + + @Schema(description = "상품 목록 응답") + public record ProductListResponse( + @Schema(description = "상품 ID", example = "1") + Long productId, + + @Schema(description = "상품명", example = "나이키 에어맥스") + String name, + + @Schema(description = "상품 설명", example = "최고의 운동화") + String description, + + @Schema(description = "좋아요 수", example = "100") + Long likeCount, + + @Schema(description = "가격 정보") + PriceResponse price, + + @Schema(description = "브랜드 ID", example = "1") + Long brandId, + + @Schema(description = "등록 일시") + ZonedDateTime createdAt + ) { + public static ProductListResponse from(ProductInfo productInfo) { + return new ProductListResponse( + productInfo.id(), + productInfo.name(), + productInfo.description(), + productInfo.likeCount(), + new PriceResponse( + productInfo.price().originPrice(), + productInfo.price().discountPrice() + ), + productInfo.brandId(), + productInfo.createdAt() + ); + } + } + + @Schema(description = "상품 상세 응답") + public record ProductDetailResponse( + @Schema(description = "상품 ID", example = "1") + Long productId, + + @Schema(description = "상품명", example = "나이키 에어맥스") + String name, + + @Schema(description = "상품 설명", example = "최고의 운동화") + String description, + + @Schema(description = "좋아요 수", example = "100") + Long likeCount, + + @Schema(description = "재고 수량", example = "50") + Integer stockQuantity, + + @Schema(description = "가격 정보") + PriceResponse price, + + @Schema(description = "브랜드 정보") + BrandDetailResponse brand, + + @Schema(description = "사용자의 좋아요 여부", example = "true") + Boolean isLiked + ) { + public static ProductDetailResponse from(ProductDetailInfo productDetailInfo) { + return new ProductDetailResponse( + productDetailInfo.id(), + productDetailInfo.name(), + productDetailInfo.description(), + productDetailInfo.likeCount(), + productDetailInfo.stockQuantity(), + new PriceResponse( + productDetailInfo.price().originPrice(), + productDetailInfo.price().discountPrice() + ), + new BrandDetailResponse( + productDetailInfo.brand().id(), + productDetailInfo.brand().name(), + productDetailInfo.brand().description() + ), + productDetailInfo.isLiked() + ); + } + } + + @Schema(description = "가격 정보") + public record PriceResponse( + @Schema(description = "정가", example = "50000.00") + BigDecimal originPrice, + + @Schema(description = "할인가 (할인이 없으면 정가와 동일)", example = "45000.00") + BigDecimal discountPrice + ) { + } + + @Schema(description = "브랜드 정보") + public record BrandResponse( + @Schema(description = "브랜드 ID", example = "1") + Long brandId, + + @Schema(description = "브랜드명", example = "나이키") + String brandName + ) { + } + + @Schema(description = "브랜드 상세 정보") + public record BrandDetailResponse( + @Schema(description = "브랜드 ID", example = "1") + Long brandId, + + @Schema(description = "브랜드명", example = "나이키") + String brandName, + + @Schema(description = "브랜드 설명", example = "세계적인 스포츠 브랜드") + String brandDescription + ) { + } +} + + diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java new file mode 100644 index 000000000..6d1500617 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java @@ -0,0 +1,36 @@ +package com.loopers.interfaces.api.user; + +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import com.loopers.application.user.UserFacade; +import com.loopers.application.user.UserInfo; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.support.Uris; + +import lombok.RequiredArgsConstructor; + +import jakarta.validation.Valid; + +/** + * @author hyunjikoh + * @since 2025. 10. 27. + */ +@RequiredArgsConstructor +@RestController +@Validated +public class UserV1Controller { + private final UserFacade userFacade; + + @PostMapping(Uris.User.REGISTER) + public ApiResponse register(@RequestBody @Valid UserV1Dtos.UserRegisterRequest request) { + UserInfo userInfo = userFacade.registerUser(request.toCommand()); + return ApiResponse.success(UserV1Dtos.UserRegisterResponse.from(userInfo)); + } + + @GetMapping(Uris.User.GET_BY_USERNAME) + public ApiResponse getUserByUsername(@RequestParam String username) { + UserInfo userInfo = userFacade.getUserByUsername(username); + return ApiResponse.success(UserV1Dtos.UserInfoResponse.from(userInfo)); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dtos.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dtos.java new file mode 100644 index 000000000..92152df18 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dtos.java @@ -0,0 +1,66 @@ +package com.loopers.interfaces.api.user; + +import java.time.LocalDate; + +import com.loopers.application.user.UserInfo; +import com.loopers.application.user.UserRegisterCommand; +import com.loopers.domain.user.Gender; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +public class UserV1Dtos { + public record UserRegisterRequest( + @NotNull @Size(max = 10) @Pattern(regexp = "^[a-zA-Z0-9]+$") + String username, + + @NotNull @Email + String email, + + @NotNull @Pattern(regexp = "^\\d{4}-\\d{2}-\\d{2}$") + String birthdate, + + @NotNull + Gender gender + ) { + public UserRegisterCommand toCommand() { + return UserRegisterCommand.of(username, email, birthdate, gender); + } + } + + public record UserRegisterResponse( + Long id, + String username, + String email, + LocalDate birthdate, + Gender gender + ) { + public static UserRegisterResponse from(UserInfo userInfo) { + return new UserRegisterResponse( + userInfo.id(), + userInfo.username(), + userInfo.email(), + userInfo.birthdate(), + userInfo.gender() + ); + } + } + + public record UserInfoResponse( + String username, + String email, + LocalDate birthdate, + Gender gender + ) { + public static UserInfoResponse from(UserInfo userInfo) { + return new UserInfoResponse( + userInfo.username(), + userInfo.email(), + userInfo.birthdate(), + userInfo.gender() + ); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/support/Uris.java b/apps/commerce-api/src/main/java/com/loopers/support/Uris.java new file mode 100644 index 000000000..98e49107e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/support/Uris.java @@ -0,0 +1,91 @@ +package com.loopers.support; + +/** + * API 엔드포인트 URI 상수 관리 클래스 + * + * @author hyunjikoh + * @since 2025. 10. 31. + */ +public class Uris { + + private Uris() { + // Utility class + } + + /** + * API 버전 상수 + */ + public static final String API_V1 = "/api/v1"; + + /** + * Example API 엔드포인트 + */ + public static class Example { + private Example() { + } + + public static final String BASE = API_V1 + "/examples"; + public static final String GET_BY_ID = BASE + "/{exampleId}"; + } + + /** + * User API 엔드포인트 + */ + public static class User { + private User() { + } + + public static final String BASE = API_V1 + "/users"; + public static final String REGISTER = BASE; + public static final String GET_BY_USERNAME = BASE; + } + + /** + * Point API 엔드포인트 + */ + public static class Point { + private Point() { + } + + public static final String BASE = API_V1 + "/points"; + public static final String GET_INFO = BASE; + public static final String CHARGE = BASE + "/charge"; + } + + /** + * Order API 엔드포인트 + */ + public static class Order { + private Order() { + } + + public static final String BASE = API_V1 + "/orders"; + public static final String CREATE = BASE; + public static final String GET_LIST = BASE; + public static final String GET_DETAIL = BASE + "/{orderId}"; + } + + /** + * Product API 엔드포인트 + */ + public static class Product { + private Product() { + } + + public static final String BASE = API_V1 + "/products"; + public static final String GET_LIST = BASE; + public static final String GET_DETAIL = BASE + "/{productId}"; + } + + /** + * Like API 엔드포인트 + */ + public static class Like { + private Like() { + } + + public static final String BASE = API_V1 + "/likes"; + public static final String UPSERT = BASE + "/products/{productId}"; + public static final String CANCEL = BASE + "/products/{productId}"; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java b/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java index 5d142efbf..41b834b24 100644 --- a/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java +++ b/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java @@ -1,17 +1,40 @@ package com.loopers.support.error; +import org.springframework.http.HttpStatus; + import lombok.Getter; import lombok.RequiredArgsConstructor; -import org.springframework.http.HttpStatus; @Getter @RequiredArgsConstructor public enum ErrorType { - /** 범용 에러 */ + /** + * 범용 에러 + */ INTERNAL_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, HttpStatus.INTERNAL_SERVER_ERROR.getReasonPhrase(), "일시적인 오류가 발생했습니다."), BAD_REQUEST(HttpStatus.BAD_REQUEST, HttpStatus.BAD_REQUEST.getReasonPhrase(), "잘못된 요청입니다."), NOT_FOUND(HttpStatus.NOT_FOUND, HttpStatus.NOT_FOUND.getReasonPhrase(), "존재하지 않는 요청입니다."), - CONFLICT(HttpStatus.CONFLICT, HttpStatus.CONFLICT.getReasonPhrase(), "이미 존재하는 리소스입니다."); + CONFLICT(HttpStatus.CONFLICT, HttpStatus.CONFLICT.getReasonPhrase(), "이미 존재하는 리소스입니다."), + + // 사용자 관련 오류 + NOT_FOUND_USER(HttpStatus.NOT_FOUND, HttpStatus.NOT_FOUND.getReasonPhrase(), "존재하지 않는 사용자 입니다."), + + // 브랜드 관련 오류 + NOT_FOUND_BRAND(HttpStatus.NOT_FOUND, HttpStatus.NOT_FOUND.getReasonPhrase(), "존재하지 않는 브랜드입니다."), + DUPLICATE_BRAND(HttpStatus.CONFLICT, HttpStatus.CONFLICT.getReasonPhrase(), "이미 존재하는 브랜드 이름입니다."), + + // 상품 관련 오류 + NOT_FOUND_PRODUCT(HttpStatus.NOT_FOUND, HttpStatus.NOT_FOUND.getReasonPhrase(), "존재하지 않는 상품입니다."), + INSUFFICIENT_STOCK(HttpStatus.BAD_REQUEST, HttpStatus.BAD_REQUEST.getReasonPhrase(), "재고가 부족합니다."), + + //좋아요 관련 오류 + ALREADY_LIKED_PRODUCT(HttpStatus.CONFLICT, HttpStatus.CONFLICT.getReasonPhrase(), "이미 좋아요한 상품입니다."), + NOT_EXIST_LIKED(HttpStatus.BAD_REQUEST, HttpStatus.BAD_REQUEST.getReasonPhrase(), "좋아요하지 않은 상품입니다."), + + // 주문 관련 오류 + NOT_FOUND_ORDER(HttpStatus.NOT_FOUND, HttpStatus.NOT_FOUND.getReasonPhrase(), "존재하지 않는 주문입니다."), + INVALID_ORDER_STATUS(HttpStatus.BAD_REQUEST, HttpStatus.BAD_REQUEST.getReasonPhrase(), "유효하지 않은 주문 상태입니다."), + EMPTY_ORDER_ITEMS(HttpStatus.BAD_REQUEST, HttpStatus.BAD_REQUEST.getReasonPhrase(), "주문 항목은 최소 1개 이상이어야 합니다."); private final HttpStatus status; private final String code; diff --git a/apps/commerce-api/src/main/java/com/loopers/util/ProductDataGeneratorRunner.java b/apps/commerce-api/src/main/java/com/loopers/util/ProductDataGeneratorRunner.java new file mode 100644 index 000000000..56996ecfa --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/util/ProductDataGeneratorRunner.java @@ -0,0 +1,247 @@ +package com.loopers.util; + +import net.datafaker.Faker; +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Random; +import java.util.Set; + +import org.springframework.boot.CommandLineRunner; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import com.loopers.domain.brand.BrandDomainCreateRequest; +import com.loopers.domain.brand.BrandEntity; +import com.loopers.domain.product.ProductEntity; +import com.loopers.infrastructure.brand.BrandJpaRepository; +import com.loopers.infrastructure.product.ProductJpaRepository; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * 상품 및 브랜드 데이터 생성 Runner + * 애플리케이션 시작 시 자동으로 브랜드 및 상품 데이터를 생성합니다 + * + * ✅ 생성 규칙: + * + * 상품: 정확히 100,000개 + * 브랜드: 유니크 이름으로 필요한 만큼 자동 생성 + * + * + * 실행 방법: + * --args='--spring.profiles.active=local --product.data.generate=true' + */ +@Slf4j +@Component +@RequiredArgsConstructor +@ConditionalOnProperty(name = "product.data.generate", havingValue = "true") +public class ProductDataGeneratorRunner implements CommandLineRunner { + + private final ProductJpaRepository productRepository; + private final BrandJpaRepository brandRepository; + private final Faker faker = new Faker(new Locale("ko")); + private final Random random = new Random(); + + private static final int BATCH_SIZE = 1000; + private static final int TOTAL_PRODUCTS = 100000; // ✅ 정확히 10만 개 + private static final int INITIAL_BRAND_COUNT = 100; // 초기 브랜드 개수 + + private final Set generatedBrandNames = new HashSet<>(); + + @Override + @Transactional + public void run(String... args) throws Exception { + long startTime = System.currentTimeMillis(); + + try { + log.info("데이터 생성 시작"); + + // 1. 브랜드 데이터 생성 + List brands = generateAndSaveBrands(); + log.info("브랜드 생성 완료: {}개", brands.size()); + + // 2. 정확히 100,000개의 상품 생성 + generateAndSaveProducts(brands); + + long duration = System.currentTimeMillis() - startTime; + log.info("✅ 데이터 생성 완료 - 소요 시간: {}ms ({}초)", duration, duration / 1000); + + } catch (Exception e) { + log.error("데이터 생성 중 오류 발생", e); + throw e; + } + } + + /** + * 브랜드 데이터를 생성하고 저장합니다. + * ✅ 중복된 브랜드명을 방지합니다. + */ + private List generateAndSaveBrands() { + List brands = new ArrayList<>(); + + int createdCount = 0; + int attemptCount = 0; + int maxAttempts = INITIAL_BRAND_COUNT * 10; // 무한 루프 방지 + + while (createdCount < INITIAL_BRAND_COUNT && attemptCount < maxAttempts) { + attemptCount++; + + // ✅ 유니크한 브랜드명 생성 + String brandName = generateUniqueBrandName(); + + if (brandName == null) { + log.warn("유니크한 브랜드명 생성에 실패했습니다. 시도: {}/{}", attemptCount, maxAttempts); + continue; + } + + // 브랜드 생성 + BrandEntity brand = BrandEntity.createBrandEntity( + new BrandDomainCreateRequest( + brandName, + faker.lorem().paragraph() + ) + ); + + brands.add(brand); + createdCount++; + + // 배치 단위로 저장 + if (brands.size() >= BATCH_SIZE) { + brandRepository.saveAll(brands); + log.info("브랜드 배치 저장: {}개 (누적: {}개)", brands.size(), createdCount); + brands.clear(); + } + } + + // 남은 데이터 저장 + if (!brands.isEmpty()) { + brandRepository.saveAll(brands); + log.info("브랜드 배치 저장: {}개 (누적: {}개)", brands.size(), createdCount); + } + + if (createdCount < INITIAL_BRAND_COUNT) { + log.warn("요청한 브랜드 개수만큼 생성되지 않았습니다. 생성: {}개, 요청: {}개", + createdCount, INITIAL_BRAND_COUNT); + } + + // ✅ 저장된 모든 브랜드 반환 + return brandRepository.findAll(); + } + + /** + * 유니크한 브랜드명을 생성합니다. + * + * 중복된 이름이 생성되면 재시도합니다. + * + * @return 유니크한 브랜드명 (생성 실패 시 null) + */ + private String generateUniqueBrandName() { + int maxRetries = 50; + + for (int i = 0; i < maxRetries; i++) { + String brandName = faker.company().name(); + + // ✅ 중복 검사 (메모리 + 데이터베이스) + if (!generatedBrandNames.contains(brandName) && + !brandRepository.existsByNameAndDeletedAtNull(brandName)) { + + generatedBrandNames.add(brandName); + return brandName; + } + } + + log.debug("{}번 시도 후에도 유니크한 브랜드명을 생성하지 못했습니다.", maxRetries); + return null; + } + + /** + * 정확히 100,000개의 상품 데이터를 생성하고 저장합니다. + * ✅ 브랜드 리스트에서 라운드로빈으로 선택하여 모든 브랜드에 상품이 분배됩니다. + */ + private void generateAndSaveProducts(List brands) { + if (brands.isEmpty()) { + log.error("생성할 브랜드가 없습니다. 상품 생성을 중단합니다."); + return; + } + + List products = new ArrayList<>(); + int totalProducts = 0; + int brandIndex = 0; + + log.info("상품 생성 시작: {}개 (브랜드별로 분배됨)", TOTAL_PRODUCTS); + + // ✅ 정확히 100,000개 생성 + for (int i = 0; i < TOTAL_PRODUCTS; i++) { + // 라운드로빈으로 브랜드 선택 (모든 브랜드에 균등 분배) + BrandEntity brand = brands.get(brandIndex % brands.size()); + brandIndex++; + + ProductEntity product = createRandomProduct(brand.getId()); + products.add(product); + + // 배치 단위로 저장 + if (products.size() >= BATCH_SIZE) { + productRepository.saveAll(products); + totalProducts += products.size(); + + // 진행 상황 표시 (10% 단위) + int progressPercent = (totalProducts * 100) / TOTAL_PRODUCTS; + if (progressPercent % 10 == 0) { + log.info("상품 생성 진행률: {}% ({}/{}개)", + progressPercent, totalProducts, TOTAL_PRODUCTS); + } + + products.clear(); + } + } + + // 남은 데이터 저장 + if (!products.isEmpty()) { + productRepository.saveAll(products); + totalProducts += products.size(); + } + + // ✅ 최종 생성 개수 검증 + long dbProductCount = productRepository.count(); + log.info("✅ 상품 생성 완료: {}개 (DB 확인: {}개)", totalProducts, dbProductCount); + + if (dbProductCount != TOTAL_PRODUCTS) { + log.warn("⚠️ 상품 개수 불일치 - 요청: {}개, DB: {}개", TOTAL_PRODUCTS, dbProductCount); + } + } + + /** + * 무작위 상품을 생성합니다. + */ + private ProductEntity createRandomProduct(Long brandId) { + String productName = faker.commerce().productName(); + String description = faker.lorem().sentence(); + BigDecimal originPrice = generateRandomPrice(10000, 500000); + BigDecimal discountPrice = random.nextDouble() > 0.5 + ? originPrice.multiply(BigDecimal.valueOf(random.nextDouble() * 0.8 + 0.1)) + .setScale(0, java.math.RoundingMode.HALF_UP) + : null; + int stockQuantity = random.nextInt(1000) + 1; + + return new ProductEntity( + brandId, + productName, + description, + originPrice, + discountPrice, + stockQuantity + ); + } + + /** + * 범위 내의 무작위 가격을 생성합니다. + */ + private BigDecimal generateRandomPrice(int minPrice, int maxPrice) { + return BigDecimal.valueOf(random.nextInt(maxPrice - minPrice + 1) + minPrice); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/CommerceApiContextTest.java b/apps/commerce-api/src/test/java/com/loopers/CommerceApiContextTest.java index a485bafe8..a42aebf2b 100644 --- a/apps/commerce-api/src/test/java/com/loopers/CommerceApiContextTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/CommerceApiContextTest.java @@ -8,7 +8,6 @@ class CommerceApiContextTest { @Test void contextLoads() { - // 이 테스트는 Spring Boot 애플리케이션 컨텍스트가 로드되는지 확인합니다. - // 모든 빈이 올바르게 로드되었는지 확인하는 데 사용됩니다. + } } diff --git a/apps/commerce-api/src/test/java/com/loopers/application/product/ProductCacheStrategyIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductCacheStrategyIntegrationTest.java new file mode 100644 index 000000000..d584fc518 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductCacheStrategyIntegrationTest.java @@ -0,0 +1,412 @@ +package com.loopers.application.product; + +import static org.assertj.core.api.Assertions.assertThat; +import java.util.List; +import java.util.Optional; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +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.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; + +import com.loopers.domain.brand.BrandRepository; +import com.loopers.domain.product.ProductCacheService; +import com.loopers.domain.product.ProductMVService; +import com.loopers.domain.product.ProductRepository; +import com.loopers.domain.product.dto.ProductSearchFilter; +import com.loopers.fixtures.ProductTestFixture; +import com.loopers.infrastructure.cache.CacheStrategy; +import com.loopers.utils.DatabaseCleanUp; +import com.loopers.utils.RedisCleanUp; + +/** + * 캐싱 전략 통합 테스트 + * + * Hot/Warm/Cold 데이터 캐싱 전략이 올바르게 작동하는지 검증합니다. + * + * @author hyunjikoh + * @since 2025. 11. 10. + */ +@SpringBootTest +@DisplayName("캐싱 전략 통합 테스트") +public class ProductCacheStrategyIntegrationTest { + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @Autowired + private RedisCleanUp redisCleanUp; + + @Autowired + private ProductFacade productFacade; + + @Autowired + private ProductCacheService cacheService; + + @Autowired + private ProductMVService mvService; + + @Autowired + private BrandRepository brandRepository; + + @Autowired + private ProductRepository productRepository; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + redisCleanUp.truncateAll(); + } + + @Nested + @DisplayName("Hot 데이터 캐싱 (첫 2페이지)") + class HotDataCachingTest { + + @Test + @DisplayName("전체 상품 목록 첫 페이지는 HOT 전략으로 캐시된다") + void should_cache_first_2_pages_as_hot_data() { + // Given: 충분한 상품 데이터 생성 (3페이지 이상) + ProductTestFixture.createBrandsAndProducts(brandRepository, productRepository, 1, 50); + mvService.syncMaterializedView(); + + // When: 첫 번째 페이지 조회 + Pageable page0 = PageRequest.of(0, 20); + ProductSearchFilter filter0 = new ProductSearchFilter(null, null, page0); + productFacade.getProducts(filter0); + + // Then: 캐시에 저장됨 (Hot 전략) + Optional> cachedIds0 = cacheService.getProductIdsFromCache( + CacheStrategy.HOT, null, page0 + ); + assertThat(cachedIds0).isPresent(); + assertThat(cachedIds0.get()).hasSize(20); + + // When: 두 번째 페이지 조회 + Pageable page1 = PageRequest.of(1, 20); + ProductSearchFilter filter1 = new ProductSearchFilter(null, null, page1); + Page result1 = productFacade.getProducts(filter1); + + // Then: 캐시에 저장됨 (WARM 전략) + Optional> cachedIds1 = cacheService.getProductIdsFromCache( + CacheStrategy.WARM, null, page1 + ); + assertThat(cachedIds1).isPresent(); + assertThat(cachedIds1.get()).hasSize(20); + } + + @Test + @DisplayName("브랜드별 상품 목록 첫 2페이지는 HOT 전략으로 캐시된다") + void should_cache_brand_first_2_pages_as_hot_data() { + // Given: 특정 브랜드의 상품 충분히 생성 + ProductTestFixture.createBrandsAndProducts(brandRepository, productRepository, 1, 50); + mvService.syncMaterializedView(); + Long brandId = 1L; + + // When: 브랜드별 첫 번째 페이지 조회 + Pageable page0 = PageRequest.of(0, 20); + ProductSearchFilter filter0 = new ProductSearchFilter(brandId, null, page0); + Page result0 = productFacade.getProducts(filter0); + + // Then: 캐시에 저장됨 (Hot 전략) + Optional> cachedIds0 = cacheService.getProductIdsFromCache( + CacheStrategy.HOT, brandId, page0 + ); + assertThat(cachedIds0).isPresent(); + + // When: 브랜드별 두 번째 페이지 조회 + Pageable page1 = PageRequest.of(1, 20); + ProductSearchFilter filter1 = new ProductSearchFilter(brandId, null, page1); + Page result1 = productFacade.getProducts(filter1); + + // Then: 캐시에 저장됨 (Hot 전략) + Optional> cachedIds1 = cacheService.getProductIdsFromCache( + CacheStrategy.WARM, brandId, page1 + ); + assertThat(cachedIds1).isPresent(); + } + + @Test + @DisplayName("Hot 데이터는 30분 TTL로 캐시된다") + void should_cache_hot_data_with_30_minutes_ttl() { + // Given: 상품 데이터 생성 + ProductTestFixture.createBrandsAndProducts(brandRepository, productRepository, 1, 25); + mvService.syncMaterializedView(); + + // When: 첫 페이지 조회 (Hot 데이터) + Pageable pageable = PageRequest.of(0, 20); + ProductSearchFilter filter = new ProductSearchFilter(null, null, pageable); + productFacade.getProducts(filter); + + // Then: 캐시에 저장되고 TTL이 30분 이하임 + Optional> cachedIds = cacheService.getProductIdsFromCache( + CacheStrategy.HOT, null, pageable + ); + assertThat(cachedIds).isPresent(); + + // TODO: Redis TTL 확인 로직 추가 + // TTL이 30분(1800초) 이하인지 검증 + } + } + + @Nested + @DisplayName("Warm 데이터 캐싱 (2~3페이지)") + class WarmDataCachingTest { + + @Test + @DisplayName("전체 상품 목록 2~3페이지(page index 1~2)는 Warm 전략으로 캐시된다") + void should_cache_pages_2_to_3_as_warm_data() { + // Given: 충분한 상품 데이터 생성 (4페이지 이상) + ProductTestFixture.createBrandsAndProducts(brandRepository, productRepository, 1, 120); + mvService.syncMaterializedView(); + + // When: 2페이지 조회 (page index 1) + Pageable page2 = PageRequest.of(1, 20); + ProductSearchFilter filter2 = new ProductSearchFilter(null, null, page2); + productFacade.getProducts(filter2); + + // Then: Warm 전략으로 캐시됨 + Optional> cachedIds2 = cacheService.getProductIdsFromCache( + CacheStrategy.WARM, null, page2 + ); + assertThat(cachedIds2).isPresent(); + + // When: 3페이지 조회 (page index 2) + Pageable page3 = PageRequest.of(2, 20); + ProductSearchFilter filter3 = new ProductSearchFilter(null, null, page3); + productFacade.getProducts(filter3); + + // Then: Warm 전략으로 캐시됨 + Optional> cachedIds3 = cacheService.getProductIdsFromCache( + CacheStrategy.WARM, null, page3 + ); + assertThat(cachedIds3).isPresent(); + + // When: 4페이지 조회 (page index 3) + Pageable page4 = PageRequest.of(3, 20); + ProductSearchFilter filter4 = new ProductSearchFilter(null, null, page4); + productFacade.getProducts(filter4); + + // Then: COLD 전략으로 캐시를 안함 + Optional> cachedIds4 = cacheService.getProductIdsFromCache( + CacheStrategy.COLD, null, page4 + ); + assertThat(cachedIds4).isEmpty(); + } + + @Test + @DisplayName("Warm 데이터는 15분 TTL로 캐시된다") + void should_cache_warm_data_with_15_minutes_ttl() { + // Given: 상품 데이터 생성 + ProductTestFixture.createBrandsAndProducts(brandRepository, productRepository, 1, 70); + mvService.syncMaterializedView(); + + // When: 3페이지 조회 (Warm 데이터) + Pageable pageable = PageRequest.of(2, 20); + ProductSearchFilter filter = new ProductSearchFilter(null, null, pageable); + productFacade.getProducts(filter); + + // Then: 캐시에 저장되고 TTL이 15분 이하임 + Optional> cachedIds = cacheService.getProductIdsFromCache( + CacheStrategy.WARM, null, pageable + ); + assertThat(cachedIds).isPresent(); + + // TODO: Redis TTL 확인 로직 추가 + // TTL이 15분(900초) 이하인지 검증 + } + } + + @Nested + @DisplayName("Cold 데이터 캐싱 (6페이지 이후)") + class ColdDataCachingTest { + + @Test + @DisplayName("6페이지 이후는 Cold 전략으로 캐시된다") + void should_cache_pages_after_6_as_cold_data() { + // Given: 충분한 상품 데이터 생성 + ProductTestFixture.createBrandsAndProducts(brandRepository, productRepository, 1, 150); + mvService.syncMaterializedView(); + + // When: 6페이지 조회 + Pageable page5 = PageRequest.of(5, 20); + ProductSearchFilter filter5 = new ProductSearchFilter(null, null, page5); + productFacade.getProducts(filter5); + + // Then: Cold 전략은 캐시하지 않음 (캐시 미스 확인) + Optional> cachedIds5 = cacheService.getProductIdsFromCache( + CacheStrategy.COLD, null, page5 + ); + assertThat(cachedIds5).isEmpty(); + + // When: 7페이지 조회 + Pageable page6 = PageRequest.of(6, 20); + ProductSearchFilter filter6 = new ProductSearchFilter(null, null, page6); + productFacade.getProducts(filter6); + + // Then: Cold 전략은 캐시하지 않음 (캐시 미스 확인) + Optional> cachedIds6 = cacheService.getProductIdsFromCache( + CacheStrategy.COLD, null, page6 + ); + assertThat(cachedIds6).isEmpty(); + } + + @Test + @DisplayName("검색 결과는 Cold 전략으로 캐시를 안한다.") + void should_cache_search_results_as_cold_data() { + // Given: 상품 데이터 생성 + ProductTestFixture.createBrandsAndProducts(brandRepository, productRepository, 1, 30); + mvService.syncMaterializedView(); + + // When: 검색 조회 + Pageable pageable = PageRequest.of(0, 20); + ProductSearchFilter filter = new ProductSearchFilter(null, "Product", pageable); + productFacade.getProducts(filter); + + // Then: Cold 전략으로 캐시됨 + // TODO: 검색 결과 캐시 검증 로직 추가 + } + } + + @Nested + @DisplayName("캐시 히트/미스 시나리오") + class CacheHitMissTest { + + @Test + @DisplayName("캐시 미스 시 MV에서 조회하고 캐시에 저장한다") + void should_query_mv_and_cache_on_cache_miss() { + // Given: 상품 데이터 생성 (캐시 없음) + ProductTestFixture.createBrandsAndProducts(brandRepository, productRepository, 1, 25); + mvService.syncMaterializedView(); + + Pageable pageable = PageRequest.of(0, 20); + + // When: 첫 조회 (캐시 미스) + ProductSearchFilter filter = new ProductSearchFilter(null, null, pageable); + Page result = productFacade.getProducts(filter); + + // Then: 결과 반환됨 + assertThat(result.getContent()).hasSize(20); + + // Then: 캐시에 저장됨 + Optional> cachedIds = cacheService.getProductIdsFromCache( + CacheStrategy.HOT, null, pageable + ); + assertThat(cachedIds).isPresent(); + } + + @Test + @DisplayName("캐시 히트 시 DB 조회 없이 캐시에서 반환한다") + void should_return_from_cache_without_db_query_on_cache_hit() { + // Given: 상품 데이터 생성 및 캐시 워밍업 + ProductTestFixture.createBrandsAndProducts(brandRepository, productRepository, 1, 25); + mvService.syncMaterializedView(); + + Pageable pageable = PageRequest.of(0, 20); + ProductSearchFilter filter = new ProductSearchFilter(null, null, pageable); + + // 첫 조회로 캐시 생성 + productFacade.getProducts(filter); + + // When: 두 번째 조회 (캐시 히트) + Page result = productFacade.getProducts(filter); + + // Then: 결과 반환됨 + assertThat(result.getContent()).hasSize(20); + + // TODO: DB 조회 횟수 검증 (쿼리 카운터 사용) + } + + @Test + @DisplayName("상품 상세 조회 시 캐시 미스면 MV에서 조회하고 캐시에 저장한다") + void should_cache_product_detail_on_cache_miss() { + // Given: 상품 생성 + ProductTestFixture.createBrandsAndProducts(brandRepository, productRepository, 1, 5); + mvService.syncMaterializedView(); + Long productId = 1L; + + // When: 상품 상세 조회 (캐시 미스) + ProductDetailInfo result = productFacade.getProductDetail(productId, null); + + // Then: 결과 반환됨 + assertThat(result).isNotNull(); + assertThat(result.id()).isEqualTo(productId); + + // Then: 캐시에 저장됨 + Optional cached = cacheService.getProductDetailFromCache(productId); + assertThat(cached).isPresent(); + assertThat(cached.get().id()).isEqualTo(productId); + } + + @Test + @DisplayName("상품 상세 조회 시 캐시 히트면 캐시에서 반환한다") + void should_return_product_detail_from_cache_on_cache_hit() { + // Given: 상품 생성 및 캐시 워밍업 + ProductTestFixture.createBrandsAndProducts(brandRepository, productRepository, 1, 5); + mvService.syncMaterializedView(); + Long productId = 1L; + + // 첫 조회로 캐시 생성 + productFacade.getProductDetail(productId, null); + + // When: 두 번째 조회 (캐시 히트) + ProductDetailInfo result = productFacade.getProductDetail(productId, null); + + // Then: 결과 반환됨 + assertThat(result).isNotNull(); + assertThat(result.id()).isEqualTo(productId); + + // TODO: DB 조회 횟수 검증 + } + } + + @Nested + @DisplayName("캐시 무효화") + class CacheEvictionTest { + + @Test + @DisplayName("상품 정보 변경 시 해당 상품의 캐시가 무효화된다") + void should_evict_cache_when_product_changes() { + // Given: 상품 생성 및 캐시 생성 + ProductTestFixture.createBrandsAndProducts(brandRepository, productRepository, 1, 5); + mvService.syncMaterializedView(); + Long productId = 1L; + + productFacade.getProductDetail(productId, null); + assertThat(cacheService.getProductDetailFromCache(productId)).isPresent(); + + // When: 캐시 무효화 + cacheService.evictProductDetail(productId); + + // Then: 캐시에서 제거됨 + assertThat(cacheService.getProductDetailFromCache(productId)).isEmpty(); + } + + @Test + @DisplayName("전략별 캐시 무효화가 정상 작동한다") + void should_evict_cache_by_strategy() { + // Given: 상품 데이터 생성 및 여러 페이지 캐시 생성 + ProductTestFixture.createBrandsAndProducts(brandRepository, productRepository, 1, 150); + mvService.syncMaterializedView(); + + // Hot, Warm, Cold 데이터 캐시 생성 + productFacade.getProducts(new ProductSearchFilter(null, null, PageRequest.of(0, 20))); // Hot + productFacade.getProducts(new ProductSearchFilter(null, null, PageRequest.of(2, 20))); // Warm + productFacade.getProducts(new ProductSearchFilter(null, null, PageRequest.of(5, 20))); // Cold 저장 안함 + + // When: Hot 캐시만 무효화 + cacheService.evictProductIdsByStrategy(CacheStrategy.HOT); + + // Then: Hot 캐시만 제거됨 + assertThat(cacheService.getProductIdsFromCache(CacheStrategy.HOT, null, PageRequest.of(0, 20))).isEmpty(); + + // Warm 은 유지 콜드는 저장 안함 + assertThat(cacheService.getProductIdsFromCache(CacheStrategy.WARM, null, PageRequest.of(2, 20))).isPresent(); + assertThat(cacheService.getProductIdsFromCache(CacheStrategy.COLD, null, PageRequest.of(5, 20))).isEmpty(); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceIntegrationTest.java new file mode 100644 index 000000000..08132b195 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceIntegrationTest.java @@ -0,0 +1,135 @@ +package com.loopers.domain.brand; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +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.domain.Page; +import org.springframework.data.domain.Pageable; + +import com.loopers.domain.brand.dto.BrandSearchFilter; +import com.loopers.fixtures.BrandTestFixture; +import com.loopers.support.error.CoreException; +import com.loopers.utils.DatabaseCleanUp; +import com.loopers.utils.RedisCleanUp; + +@SpringBootTest +@DisplayName("BrandService 통합 테스트") +class BrandServiceIntegrationTest { + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @Autowired + private BrandRepository brandRepository; + + @Autowired + private BrandService brandService; + + @Autowired + private RedisCleanUp redisCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + redisCleanUp.truncateAll(); + + } + + @Nested + @DisplayName("브랜드 목록 조회") + class ListBrandsTest { + @Test + @DisplayName("브랜드명으로 검색하여 페이지네이션된 결과를 조회한다") + void search_brands_by_name_with_pagination() { + // given + BrandTestFixture.saveBrands(brandRepository, 5); + BrandTestFixture.createAndSave(brandRepository, "특별한브랜드", "설명"); + + BrandSearchFilter filter = BrandSearchFilter.of("특별한"); + Pageable pageable = Pageable.ofSize(10).withPage(0); + + // when + Page resultPages = brandService.searchBrands(filter, pageable); + + // then + assertThat(resultPages.getContent()).hasSize(1); + assertThat(resultPages.getContent().get(0).getName()).isEqualTo("특별한브랜드"); + } + + @Test + @DisplayName("검색어가 없으면 전체 브랜드를 페이지네이션하여 조회한다") + void search_brands_without_filter_returns_all() { + // given + BrandTestFixture.saveBrands(brandRepository, 5); + Pageable pageable = Pageable.ofSize(10).withPage(0); + + // when + Page resultPages = brandService.searchBrands(BrandSearchFilter.of(null), pageable); + + // then + assertThat(resultPages.getTotalElements()).isEqualTo(5L); + } + } + + @Nested + @DisplayName("브랜드 상세 조회") + class GetBrandTest { + @Test + @DisplayName("브랜드 상세 정보를 조회하는데 성공한다") + void get_brand_detail_success() { + // given + BrandEntity savedBrand = BrandTestFixture.saveBrands(brandRepository, 1).get(0); + + // when + BrandEntity brand = brandService.getBrandById(savedBrand.getId()); + + // then + assertThat(brand).isNotNull(); + assertThat(brand.getId()).isEqualTo(savedBrand.getId()); + assertThat(brand.getName()).isEqualTo(savedBrand.getName()); + assertThat(brand.getCreatedAt()).isEqualTo(savedBrand.getCreatedAt()); + assertThat(brand.getDeletedAt()).isNull(); + } + + @Test + @DisplayName("브랜드 이름으로 상세 정보를 조회하는데 성공한다") + void get_brand_by_name_success() { + // given + BrandEntity savedBrand = BrandTestFixture.saveBrands(brandRepository, 1).get(0); + + // when + BrandEntity brand = brandService.getBrandByName(savedBrand.getName()); + + // then + assertThat(brand).isNotNull(); + assertThat(brand.getName()).isEqualTo(savedBrand.getName()); + assertThat(brand.getId()).isEqualTo(savedBrand.getId()); + } + + @Test + @DisplayName("존재하지 않는 브랜드 이름으로 조회하면 null을 반환한다") + void get_brand_by_name_returns_null_when_not_found() { + // given + String nonExistentName = "존재하지않는브랜드"; + + Assertions.assertThatThrownBy(() -> brandService.getBrandByName(nonExistentName)).isInstanceOf(CoreException.class).hasMessage("브랜드를 찾을 수 없습니다. (이름: 존재하지않는브랜드)"); + } + + @Test + @DisplayName("브랜드의 id 기준 찾지 못하면 null을 반환한다") + void get_brand_return_null_when_not_found() { + // given + BrandTestFixture.saveBrands(brandRepository, 5); + long nonExistentId = 6L; + + // when + Assertions.assertThatThrownBy(() -> brandService.getBrandById(nonExistentId)).isInstanceOf(CoreException.class).hasMessage("브랜드를 찾을 수 없습니다. (ID: 6)"); + + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandUnitTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandUnitTest.java new file mode 100644 index 000000000..d9d7ef644 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandUnitTest.java @@ -0,0 +1,55 @@ +package com.loopers.domain.brand; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import org.junit.jupiter.api.DisplayName; + +/** + * @author hyunjikoh + * @since 2025. 11. 8. + */ +class BrandUnitTest { + + @org.junit.jupiter.api.Test + @DisplayName("유효한 정보로 BrandEntity 객체를 생성할 수 있다") + void create_brand_entity_success() { + String name = "Test Brand"; + String description = "This is a test brand."; + BrandDomainCreateRequest brandDomainCreateRequest = new BrandDomainCreateRequest(name, description); + BrandEntity brandEntity = BrandEntity.createBrandEntity(brandDomainCreateRequest); + + assertEquals(name, brandEntity.getName()); + assertEquals(description, brandEntity.getDescription()); + } + + @org.junit.jupiter.api.Test + @DisplayName("브랜드 설명이 null 이여도 생성에 성공한다") + void create_brand_entity_no_desc_success() { + // given + String name = "Test Brand"; + String description = null; + BrandDomainCreateRequest brandDomainCreateRequest = new BrandDomainCreateRequest(name, description); + + BrandEntity brandEntity = BrandEntity.createBrandEntity(brandDomainCreateRequest); + + assertEquals(name, brandEntity.getName()); + assertNull(brandEntity.getDescription()); + } + + @org.junit.jupiter.api.Test + @DisplayName("브랜드 이름이 null 이면 생성에 실패한다") + void create_brand_entity_no_name_fail() { + // given + String name = null; + String description = "This is a test brand."; + BrandDomainCreateRequest brandDomainCreateRequest = new BrandDomainCreateRequest(name, description); + + assertThatThrownBy(() -> { + BrandEntity.createBrandEntity(brandDomainCreateRequest); + }).isInstanceOf(IllegalArgumentException.class) + .hasMessage("브랜드 이름은 필수 입력값입니다."); + } + +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/coupon/CouponUnitTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/coupon/CouponUnitTest.java new file mode 100644 index 000000000..411f6ce77 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/coupon/CouponUnitTest.java @@ -0,0 +1,102 @@ +package com.loopers.domain.coupon; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; +import java.math.BigDecimal; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.loopers.domain.user.UserEntity; +import com.loopers.fixtures.UserTestFixture; + +/** + * @author hyunjikoh + * @since 2025. 11. 18. + */ +@DisplayName("CouponService 단위 테스트") +@ExtendWith(MockitoExtension.class) +class CouponUnitTest { + + @InjectMocks + private CouponService couponService; + + @Mock + private CouponRepository couponRepository; + + @Nested + @DisplayName("쿠폰 생성") + class CreateCoupon { + + @Test + @DisplayName("유효한 사용자 ID와 정액으로 정액 쿠폰을 생성하면 성공한다") + void valid_user_id_and_fixed_amount_creates_fixed_amount_coupon_successfully() { + // given + UserEntity user = UserTestFixture.createDefaultUserEntity(); + + // when + CouponEntity percentageCoupon = CouponEntity.createPercentageCoupon(user, 30); + + when(couponRepository.save(any(CouponEntity.class))).thenReturn(percentageCoupon); + + // then + CouponEntity percentCoupon = couponService.createPercentCoupon(user, 30); + assertNotNull(percentCoupon); + assertEquals(user.getId(), percentCoupon.getUserId()); + assertEquals(CouponStatus.UNUSED, percentCoupon.getStatus()); + assertEquals(30, percentCoupon.getPercentage().intValue()); + } + + @Test + @DisplayName("유효하지 않은 사용자 ID로 정액 쿠폰 생성 시 예외가 발생한다") + void invalid_user_id_throws_exception_when_creating_fixed_amount_coupon() { + assertThrows(NullPointerException.class, () -> couponService.createPercentCoupon(null, 30)); + } + + @Test + @DisplayName("유효하지 않은 정액으로 정액 쿠폰 생성 시 예외가 발생한다") + void invalid_fixed_amount_throws_exception_when_creating_fixed_amount_coupon() { + assertThrows(IllegalArgumentException.class, () -> couponService.createPercentCoupon(UserTestFixture.createDefaultUserEntity(), 101)); + } + + @Test + @DisplayName("유효한 사용자 ID와 배율로 배율 쿠폰을 생성하면 성공한다") + void valid_user_id_and_percentage_creates_percentage_coupon_successfully() { + UserEntity user = UserTestFixture.createDefaultUserEntity(); + + // when + CouponEntity percentageCoupon = CouponEntity.createFixedAmountCoupon(user, new BigDecimal("5000.00")); + + when(couponRepository.save(any(CouponEntity.class))).thenReturn(percentageCoupon); + + // then + CouponEntity percentCoupon = couponService.createFixedAmountCoupon(user, new BigDecimal("5000.00")); + assertNotNull(percentCoupon); + assertEquals(user.getId(), percentCoupon.getUserId()); + assertEquals(CouponStatus.UNUSED, percentCoupon.getStatus()); + assertEquals(CouponType.FIXED_AMOUNT, percentCoupon.getCouponType()); + + assertEquals(new BigDecimal("5000.00"), percentCoupon.getFixedAmount()); + } + + @Test + @DisplayName("유효하지 않은 사용자 ID로 배율 쿠폰 생성 시 예외가 발생한다") + void invalid_user_id_throws_exception_when_creating_percentage_coupon() { + assertThrows(NullPointerException.class, () -> couponService.createFixedAmountCoupon(null, new BigDecimal("5000.00"))); + } + + @Test + @DisplayName("유효하지 않은 배율로 배율 쿠폰 생성 시 예외가 발생한다") + void invalid_percentage_throws_exception_when_creating_percentage_coupon() { + assertThrows(IllegalArgumentException.class, () -> couponService.createFixedAmountCoupon(UserTestFixture.createDefaultUserEntity(), new BigDecimal("0"))); + } + + } + +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleModelTest.java index 44ca7576e..390263937 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleModelTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleModelTest.java @@ -1,14 +1,15 @@ package com.loopers.domain.example; -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; + import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertAll; -import static org.junit.jupiter.api.Assertions.assertThrows; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; class ExampleModelTest { @DisplayName("예시 모델을 생성할 때, ") @@ -26,9 +27,9 @@ void createsExampleModel_whenNameAndDescriptionAreProvided() { // assert assertAll( - () -> assertThat(exampleModel.getId()).isNotNull(), - () -> assertThat(exampleModel.getName()).isEqualTo(name), - () -> assertThat(exampleModel.getDescription()).isEqualTo(description) + () -> assertThat(exampleModel.getId()).isNotNull(), + () -> assertThat(exampleModel.getName()).isEqualTo(name), + () -> assertThat(exampleModel.getDescription()).isEqualTo(description) ); } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleServiceIntegrationTest.java index bbd5fdbe1..5bdca7852 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleServiceIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleServiceIntegrationTest.java @@ -1,9 +1,9 @@ package com.loopers.domain.example; -import com.loopers.infrastructure.example.ExampleJpaRepository; -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import com.loopers.utils.DatabaseCleanUp; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; + import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; @@ -11,9 +11,11 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertAll; -import static org.junit.jupiter.api.Assertions.assertThrows; +import com.loopers.infrastructure.example.ExampleJpaRepository; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import com.loopers.utils.DatabaseCleanUp; +import com.loopers.utils.RedisCleanUp; @SpringBootTest class ExampleServiceIntegrationTest { @@ -26,9 +28,13 @@ class ExampleServiceIntegrationTest { @Autowired private DatabaseCleanUp databaseCleanUp; + @Autowired + private RedisCleanUp redisCleanUp; + @AfterEach void tearDown() { databaseCleanUp.truncateAllTables(); + redisCleanUp.truncateAll(); } @DisplayName("예시를 조회할 때,") @@ -39,7 +45,7 @@ class Get { void returnsExampleInfo_whenValidIdIsProvided() { // arrange ExampleModel exampleModel = exampleJpaRepository.save( - new ExampleModel("예시 제목", "예시 설명") + new ExampleModel("예시 제목", "예시 설명") ); // act @@ -47,10 +53,10 @@ void returnsExampleInfo_whenValidIdIsProvided() { // assert assertAll( - () -> assertThat(result).isNotNull(), - () -> assertThat(result.getId()).isEqualTo(exampleModel.getId()), - () -> assertThat(result.getName()).isEqualTo(exampleModel.getName()), - () -> assertThat(result.getDescription()).isEqualTo(exampleModel.getDescription()) + () -> assertThat(result).isNotNull(), + () -> assertThat(result.getId()).isEqualTo(exampleModel.getId()), + () -> assertThat(result.getName()).isEqualTo(exampleModel.getName()), + () -> assertThat(result.getDescription()).isEqualTo(exampleModel.getDescription()) ); } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeIntegrationTest.java new file mode 100644 index 000000000..f38d4ebc6 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeIntegrationTest.java @@ -0,0 +1,759 @@ +package com.loopers.domain.like; + +import static java.util.concurrent.Executors.newFixedThreadPool; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.atomic.AtomicInteger; + +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +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 com.loopers.application.like.LikeFacade; +import com.loopers.application.like.LikeInfo; +import com.loopers.application.user.UserFacade; +import com.loopers.application.user.UserInfo; +import com.loopers.application.user.UserRegisterCommand; +import com.loopers.domain.product.ProductDomainCreateRequest; +import com.loopers.domain.product.ProductEntity; +import com.loopers.domain.product.ProductRepository; +import com.loopers.domain.product.ProductService; +import com.loopers.domain.user.UserEntity; +import com.loopers.domain.user.UserRepository; +import com.loopers.fixtures.ProductTestFixture; +import com.loopers.fixtures.UserTestFixture; +import com.loopers.support.error.CoreException; +import com.loopers.utils.DatabaseCleanUp; +import com.loopers.utils.RedisCleanUp; + +import lombok.extern.slf4j.Slf4j; + +/** + * LikeFacade 통합 테스트 + * 좋아요 등록 및 취소 기능을 파사드 레벨에서 검증합니다. + * 각 도메인 서비스를 통해 트랜잭션이 적용된 데이터 저장을 수행합니다. + * + * @author hyunjikoh + * @since 2025. 11. 12. + */ +@Slf4j +@SpringBootTest +@DisplayName("LikeFacade 통합 테스트") +public class LikeIntegrationTest { + + @Autowired + private LikeFacade likeFacade; + + @Autowired + private UserFacade userFacade; + + @Autowired + private UserRepository userRepository; + + @Autowired + private ProductRepository productRepository; + + @Autowired + private LikeRepository likeRepository; + + @Autowired + private ProductService productService; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @Autowired + private RedisCleanUp redisCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + redisCleanUp.truncateAll(); + + } + + /** + * 좋아요 등록 관련 테스트 케이스 그룹 + */ + @Nested + @DisplayName("좋아요 등록") + class LikeRegistration { + + /** + * 유효한 사용자와 상품이 존재할 때 좋아요 등록에 성공하는지 테스트 + */ + @Test + @DisplayName("유효한 사용자와 상품이면 좋아요 등록에 성공한다") + void should_register_like_successfully_when_valid_user_and_product() { + // Given: 사용자 생성 + UserRegisterCommand command = UserTestFixture.createDefaultUserCommand(); + UserInfo userInfo = userFacade.registerUser(command); + + // Given: 상품 생성 요청 + ProductDomainCreateRequest productRequest = ProductTestFixture.createRequest( + 1L, + "테스트상품", + "상품 설명", + new BigDecimal("10000"), + 100 + ); + ProductEntity savedProduct = productService.registerProduct(productRequest); + // 좋아요 수는 MV 테이블에서 관리하므로 ProductEntity에서 직접 조회하지 않음 + + // When: 좋아요 등록 + LikeInfo result = likeFacade.upsertLike(userInfo.username(), savedProduct.getId()); + + // Then: 좋아요 등록 성공 검증 + assertThat(result).isNotNull(); + assertThat(result.username()).isEqualTo(userInfo.username()); + assertThat(result.productId()).isEqualTo(savedProduct.getId()); + + // Then: 좋아요 등록 성공 검증 완료 + // 좋아요 수는 MV 테이블에서 관리하므로 별도 검증 필요시 ProductLikeStatsService 사용 + } + + @Test + @DisplayName("삭제된 사용자면 예외를 던진다") + void should_throw_exception_when_user_is_deleted() { + // Given + UserEntity deletedUser = UserTestFixture.createDefaultUserEntity(); + deletedUser.delete(); + userRepository.save(deletedUser); + + // Given: 상품 생성 요청 + ProductDomainCreateRequest productRequest = ProductTestFixture.createRequest( + 1L, + "테스트상품", + "상품 설명", + new BigDecimal("10000"), + 100 + ); + ProductEntity savedProduct = productService.registerProduct(productRequest); + + // When & Then + assertThatThrownBy( + () -> likeFacade.upsertLike(deletedUser.getUsername(), savedProduct.getId()) + ).isInstanceOf(CoreException.class); + } + + @Test + @DisplayName("존재하지 않는 사용자면 예외를 던진다") + void should_throw_exception_when_user_not_exists() { + // Given: 상품 생성 요청 + ProductDomainCreateRequest productRequest = ProductTestFixture.createRequest( + 1L, + "테스트상품", + "상품 설명", + new BigDecimal("10000"), + 100 + ); + ProductEntity savedProduct = productService.registerProduct(productRequest); + + // When & Then + assertThatThrownBy( + () -> likeFacade.upsertLike("existUser", savedProduct.getId()) + ).isInstanceOf(CoreException.class); + } + + + @Test + @DisplayName("삭제된 상품이면 예외를 던진다") + void should_throw_exception_when_product_is_deleted() { + // Given + UserEntity user = UserTestFixture.createDefaultUserEntity(); + userRepository.save(user); + + ProductEntity deletedProduct = ProductTestFixture.createEntity(1L, + "테스트상품", + "상품 설명", + new BigDecimal("10000"), + 100); + deletedProduct.delete(); + productRepository.save(deletedProduct); + + // When & Then + assertThatThrownBy( + () -> likeFacade.upsertLike(user.getUsername(), deletedProduct.getId()) + ).isInstanceOf(CoreException.class); + } + + @Test + @DisplayName("존재하지 않는 상품이면 예외를 던진다") + void should_throw_exception_when_product_not_exists() { + // Given + UserEntity user = UserTestFixture.createDefaultUserEntity(); + userRepository.save(user); + + // When & Then + assertThatThrownBy( + () -> likeFacade.upsertLike(user.getUsername(), 999L) + ).isInstanceOf(CoreException.class); + } + + @Test + @DisplayName("이미 삭제된 좋아요를 다시 등록하면 복원된다") + void should_restore_like_when_register_deleted_like_again() { + // Given: 사용자 생성 + UserRegisterCommand command = UserTestFixture.createDefaultUserCommand(); + UserInfo userInfo = userFacade.registerUser(command); + + ProductDomainCreateRequest request = ProductTestFixture.createRequest( + 1L, + "테스트상품", + "상품 설명", + new BigDecimal("10000"), + 100 + ); + + + ProductEntity product = productService.registerProduct(request); + + LikeEntity deletedLike = LikeEntity.createEntity(userInfo.id(), product.getId()); + deletedLike.delete(); + likeRepository.save(deletedLike); + + // When + LikeInfo result = likeFacade.upsertLike(userInfo.username(), product.getId()); + + // Then + assertThat(result).isNotNull(); + Optional found = likeRepository.findByUserIdAndProductId( + userInfo.id(), product.getId() + ); + assertThat(found).isPresent(); + assertThat(found.get().getDeletedAt()).isNull(); + } + } + + + @Nested + @DisplayName("좋아요 취소") + class LikeCancellation { + @Test + @DisplayName("유효한 사용자의 좋아요를 취소하면 성공한다") + void should_cancel_like_successfully_when_valid_user() { + // Given: 사용자 생성 + UserRegisterCommand command = UserTestFixture.createDefaultUserCommand(); + UserInfo userInfo = userFacade.registerUser(command); + + ProductDomainCreateRequest request = ProductTestFixture.createRequest( + 1L, + "테스트상품", + "상품 설명", + new BigDecimal("10000"), + 100 + ); + ProductEntity product = productService.registerProduct(request); + + // When: 좋아요 등록 + likeFacade.upsertLike(userInfo.username(), product.getId()); + + // When: 좋아요 취소 + likeFacade.unlikeProduct(userInfo.username(), product.getId()); + + // Then: 좋아요 취소 검증 + Optional found = likeRepository.findByUserIdAndProductId( + userInfo.id(), product.getId() + ); + assertThat(found).isPresent(); + assertThat(found.get().getDeletedAt()).isNotNull(); + + // 좋아요 수는 MV 테이블에서 관리하므로 별도 검증 필요시 ProductLikeStatsService 사용 + } + + @Test + @DisplayName("삭제된 사용자가 좋아요를 취소하려 하면 예외를 던진다") + void should_throw_exception_when_deleted_user_tries_to_cancel_like() { + + // When & Then + assertThatThrownBy( + () -> likeFacade.unlikeProduct("nonExistUser", 999L) + ).isInstanceOf(CoreException.class); + } + + @Test + @DisplayName("존재하지 않는 사용자가 좋아요를 취소하려 하면 예외를 던진다") + void should_throw_exception_when_non_existent_user_tries_to_cancel_like() { + // When & Then + assertThatThrownBy( + () -> likeFacade.unlikeProduct("nonExistUser", 999L) + ).isInstanceOf(CoreException.class); + } + + @Test + @DisplayName("삭제된 상품의 좋아요를 취소하면 예외 처리한다.") + void should_throw_exception_when_cancel_like_for_deleted_product() { + // Given + UserEntity user = UserTestFixture.createDefaultUserEntity(); + userRepository.save(user); + + ProductDomainCreateRequest request = ProductTestFixture.createRequest( + 1L, + "테스트상품", + "상품 설명", + new BigDecimal("10000"), + 100 + ); + ProductEntity deletedProduct = productService.registerProduct(request); + deletedProduct.delete(); + productRepository.save(deletedProduct); + + LikeEntity like = LikeEntity.createEntity(user.getId(), deletedProduct.getId()); + likeRepository.save(like); + + + // Then + assertThatThrownBy( + () -> likeFacade.unlikeProduct(user.getUsername(), deletedProduct.getId()) + ).isInstanceOf(CoreException.class); + } + + @Test + @DisplayName("좋아요가 존재하지 않아도 취소는 무시하고 성공한다") + void should_succeed_silently_when_cancel_non_existent_like() { + // Given + UserEntity user = UserTestFixture.createDefaultUserEntity(); + userRepository.save(user); + + ProductDomainCreateRequest request = ProductTestFixture.createRequest( + 1L, + "테스트상품", + "상품 설명", + new BigDecimal("10000"), + 100 + ); + ProductEntity product = productService.registerProduct(request); + + // When & Then - 예외 없이 정상 완료 + likeFacade.unlikeProduct(user.getUsername(), product.getId()); + } + } + + @Nested + @DisplayName("좋아요 카운트 관리") + class LikeCountManagement { + + @Test + @DisplayName("좋아요 등록 시 좋아요 엔티티가 생성된다") + void should_create_like_entity_when_like_registered() { + // Given: 사용자와 상품 생성 + UserRegisterCommand command = UserTestFixture.createDefaultUserCommand(); + UserInfo userInfo = userFacade.registerUser(command); + + ProductDomainCreateRequest request = ProductTestFixture.createRequest( + 1L, + "테스트상품", + "상품 설명", + new BigDecimal("10000"), + 100 + ); + ProductEntity product = productService.registerProduct(request); + + // When: 좋아요 등록 + likeFacade.upsertLike(userInfo.username(), product.getId()); + + // Then: 좋아요 엔티티 생성 검증 + Optional like = likeRepository.findByUserIdAndProductId( + userInfo.id(), product.getId() + ); + assertThat(like).isPresent(); + assertThat(like.get().getDeletedAt()).isNull(); + } + + @Test + @DisplayName("좋아요 취소 시 좋아요 엔티티가 삭제 상태로 변경된다") + void should_mark_like_as_deleted_when_cancelled() { + // Given: 사용자, 상품, 좋아요 생성 + UserRegisterCommand command = UserTestFixture.createDefaultUserCommand(); + UserInfo userInfo = userFacade.registerUser(command); + + ProductDomainCreateRequest request = ProductTestFixture.createRequest( + 1L, + "테스트상품", + "상품 설명", + new BigDecimal("10000"), + 100 + ); + ProductEntity product = productService.registerProduct(request); + + // 좋아요 등록 + likeFacade.upsertLike(userInfo.username(), product.getId()); + + // When: 좋아요 취소 + likeFacade.unlikeProduct(userInfo.username(), product.getId()); + + // Then: 좋아요 엔티티가 삭제 상태로 변경되었는지 검증 + Optional like = likeRepository.findByUserIdAndProductId( + userInfo.id(), product.getId() + ); + assertThat(like).isPresent(); + assertThat(like.get().getDeletedAt()).isNotNull(); + } + + @Test + @DisplayName("여러 사용자가 좋아요하면 각각 독립적인 좋아요 엔티티가 생성된다") + void should_create_independent_like_entities_when_multiple_users_like() { + // Given: 여러 사용자 생성 + UserRegisterCommand command1 = UserTestFixture.createUserCommand( + "user1", "user1@example.com", "1990-01-01", com.loopers.domain.user.Gender.MALE + ); + UserRegisterCommand command2 = UserTestFixture.createUserCommand( + "user2", "user2@example.com", "1990-01-01", com.loopers.domain.user.Gender.FEMALE + ); + UserRegisterCommand command3 = UserTestFixture.createUserCommand( + "user3", "user3@example.com", "1990-01-01", com.loopers.domain.user.Gender.MALE + ); + + UserInfo user1 = userFacade.registerUser(command1); + UserInfo user2 = userFacade.registerUser(command2); + UserInfo user3 = userFacade.registerUser(command3); + + // Given: 상품 생성 + ProductDomainCreateRequest request = ProductTestFixture.createRequest( + 1L, + "인기상품", + "상품 설명", + new BigDecimal("10000"), + 100 + ); + ProductEntity product = productService.registerProduct(request); + + // When: 세 명의 사용자가 좋아요 등록 + likeFacade.upsertLike(user1.username(), product.getId()); + likeFacade.upsertLike(user2.username(), product.getId()); + likeFacade.upsertLike(user3.username(), product.getId()); + + // Then: 각 사용자별로 독립적인 좋아요 엔티티가 생성되었는지 검증 + List likes = likeRepository.findAll(); + long activeLikes = likes.stream() + .filter(like -> like.getProductId().equals(product.getId()) + && like.getDeletedAt() == null) + .count(); + assertThat(activeLikes).isEqualTo(3); + } + + @Test + @DisplayName("삭제된 좋아요를 복원하면 deletedAt이 null로 변경된다") + void should_restore_deleted_like_when_re_registered() { + // Given: 사용자와 상품 생성 + UserRegisterCommand command = UserTestFixture.createDefaultUserCommand(); + UserInfo userInfo = userFacade.registerUser(command); + + ProductDomainCreateRequest request = ProductTestFixture.createRequest( + 1L, + "테스트상품", + "상품 설명", + new BigDecimal("10000"), + 100 + ); + ProductEntity product = productService.registerProduct(request); + + // Given: 삭제된 좋아요 생성 + LikeEntity deletedLike = LikeEntity.createEntity(userInfo.id(), product.getId()); + deletedLike.delete(); + likeRepository.save(deletedLike); + + // When: 좋아요 복원 (재등록) + likeFacade.upsertLike(userInfo.username(), product.getId()); + + // Then: 좋아요가 복원되었는지 검증 + Optional restoredLike = likeRepository.findByUserIdAndProductId( + userInfo.id(), product.getId() + ); + assertThat(restoredLike).isPresent(); + assertThat(restoredLike.get().getDeletedAt()).isNull(); + } + + @Test + @DisplayName("좋아요 등록 후 취소하면 삭제 상태로 변경된다") + void should_mark_as_deleted_when_cancel_like() { + // Given: 사용자와 상품 생성 + UserRegisterCommand command = UserTestFixture.createDefaultUserCommand(); + UserInfo userInfo = userFacade.registerUser(command); + + ProductDomainCreateRequest request = ProductTestFixture.createRequest( + 1L, + "테스트상품", + "상품 설명", + new BigDecimal("10000"), + 100 + ); + ProductEntity product = productService.registerProduct(request); + + // Given: 정상적인 flow로 좋아요 등록 + likeFacade.upsertLike(userInfo.username(), product.getId()); + + // When: 좋아요 취소 + likeFacade.unlikeProduct(userInfo.username(), product.getId()); + + // Then: 좋아요가 삭제 상태로 변경되었는지 검증 + Optional cancelledLike = likeRepository.findByUserIdAndProductId( + userInfo.id(), product.getId() + ); + assertThat(cancelledLike).isPresent(); + assertThat(cancelledLike.get().getDeletedAt()).isNotNull(); + } + + @Test + @DisplayName("좋아요가 없는 상태에서 취소를 시도해도 예외 없이 정상 처리된다") + void should_succeed_silently_when_cancel_non_existent_like() { + // Given: 사용자와 상품 생성 (좋아요 없음) + UserRegisterCommand command = UserTestFixture.createDefaultUserCommand(); + UserInfo userInfo = userFacade.registerUser(command); + + ProductDomainCreateRequest request = ProductTestFixture.createRequest( + 1L, + "테스트상품", + "상품 설명", + new BigDecimal("10000"), + 100 + ); + ProductEntity product = productService.registerProduct(request); + + // When & Then: 좋아요가 없는 상태에서 취소 시도 (멱등성 보장) + Assertions.assertThatCode(() -> + likeFacade.unlikeProduct(userInfo.username(), product.getId()) + ).doesNotThrowAnyException(); + } + } + + @Nested + @DisplayName("멱등성 테스트") + class IdempotencyTest { + + @Test + @DisplayName("동시에 같은 좋아요를 등록하면 중복이 생성되지 않는다") + void should_not_create_duplicate_when_concurrent_like_registration() throws InterruptedException { + // Given: 사용자와 상품 생성 + UserRegisterCommand command = UserTestFixture.createDefaultUserCommand(); + UserInfo userInfo = userFacade.registerUser(command); + + ProductDomainCreateRequest request = ProductTestFixture.createRequest( + 1L, + "테스트상품", + "상품 설명", + new BigDecimal("10000"), + 100 + ); + ProductEntity product = productService.registerProduct(request); + + // When: 동시에 10번 좋아요 등록 시도 + int threadCount = 10; + CountDownLatch latch = new CountDownLatch(threadCount); + ExecutorService executorService = newFixedThreadPool(threadCount); + AtomicInteger successCount = new AtomicInteger(0); + AtomicInteger failCount = new AtomicInteger(0); + + for (int i = 0; i < threadCount; i++) { + executorService.submit(() -> { + try { + likeFacade.upsertLike(userInfo.username(), product.getId()); + successCount.incrementAndGet(); + } catch (Exception e) { + failCount.incrementAndGet(); + } finally { + latch.countDown(); + } + }); + } + + latch.await(); + executorService.shutdown(); + + // Then: 좋아요는 1개만 생성되어야 함 + List likes = likeRepository.findAll(); + long userLikes = likes.stream() + .filter(like -> like.getUserId().equals(userInfo.id()) + && like.getProductId().equals(product.getId()) + && like.getDeletedAt() == null) + .count(); + + assertThat(userLikes).isEqualTo(1); + assertThat(successCount.get() + failCount.get()).isEqualTo(threadCount); + } + + @Test + @DisplayName("동시에 같은 좋아요를 등록 및 취소하면 멱등성이 보장된다") + void should_guarantee_idempotency_when_concurrent_like_and_unlike() throws InterruptedException { + // Given: 사용자와 상품 생성 + UserRegisterCommand command = UserTestFixture.createDefaultUserCommand(); + UserInfo userInfo = userFacade.registerUser(command); + + ProductDomainCreateRequest request = ProductTestFixture.createRequest( + 1L, + "테스트상품", + "상품 설명", + new BigDecimal("10000"), + 100 + ); + ProductEntity product = productService.registerProduct(request); + + // When: 동시에 등록과 취소를 반복 + int threadCount = 20; + CountDownLatch latch = new CountDownLatch(threadCount); + ExecutorService executorService = newFixedThreadPool(threadCount); + + for (int i = 0; i < threadCount; i++) { + final int index = i; + executorService.submit(() -> { + try { + if (index % 2 == 0) { + // 짝수 스레드는 등록 + likeFacade.upsertLike(userInfo.username(), product.getId()); + } else { + // 홀수 스레드는 취소 + likeFacade.unlikeProduct(userInfo.username(), product.getId()); + } + } catch (Exception e) { + // 동시성으로 인한 예외는 무시 + } finally { + latch.countDown(); + } + }); + } + + latch.await(); + executorService.shutdown(); + + // Then: 최종 상태는 일관성이 있어야 함 + Optional finalLike = likeRepository.findByUserIdAndProductId( + userInfo.id(), product.getId() + ); + + // 좋아요 엔티티가 존재해야 함 (등록 또는 취소 중 하나의 상태) + assertThat(finalLike).isPresent(); + + // 데이터베이스에 중복된 좋아요가 없어야 함 + List allLikes = likeRepository.findAll(); + long duplicateCount = allLikes.stream() + .filter(like -> like.getUserId().equals(userInfo.id()) + && like.getProductId().equals(product.getId())) + .count(); + assertThat(duplicateCount).isEqualTo(1); + } + + @Test + @DisplayName("동시에 여러 번 좋아요 취소 시 중복 취소가 발생하지 않는다") + void should_not_duplicate_cancel_when_concurrent_unlike_operations() throws InterruptedException { + // Given: 사용자, 상품, 좋아요 생성 + UserRegisterCommand command = UserTestFixture.createDefaultUserCommand(); + UserInfo userInfo = userFacade.registerUser(command); + + ProductDomainCreateRequest request = ProductTestFixture.createRequest( + 1L, + "테스트상품", + "상품 설명", + new BigDecimal("10000"), + 100 + ); + ProductEntity product = productService.registerProduct(request); + + // 좋아요 등록 + likeFacade.upsertLike(userInfo.username(), product.getId()); + + // When: 동시에 10번 취소 시도 + int threadCount = 10; + CountDownLatch latch = new CountDownLatch(threadCount); + ExecutorService executorService = newFixedThreadPool(threadCount); + AtomicInteger successCount = new AtomicInteger(0); + + for (int i = 0; i < threadCount; i++) { + executorService.submit(() -> { + try { + likeFacade.unlikeProduct(userInfo.username(), product.getId()); + successCount.incrementAndGet(); + } catch (Exception e) { + // 예외 무시 + } finally { + latch.countDown(); + } + }); + } + + latch.await(); + executorService.shutdown(); + + // Then: 좋아요는 삭제 상태여야 하며, 중복 삭제가 발생하지 않아야 함 + Optional like = likeRepository.findByUserIdAndProductId( + userInfo.id(), product.getId() + ); + + assertThat(like).isPresent(); + assertThat(like.get().getDeletedAt()).isNotNull(); + + // 모든 취소 시도가 성공해야 함 (멱등성) + assertThat(successCount.get()).isEqualTo(threadCount); + } + + @Test + @DisplayName("복수의 사용자가 동시에 같은 상품에 좋아요하면 각각 독립적으로 등록된다") + void should_register_independently_when_multiple_users_like_same_product_concurrently() throws InterruptedException { + // Given: 여러 사용자와 하나의 상품 생성 + int userCount = 5; + List users = new ArrayList<>(); + + for (int i = 0; i < userCount; i++) { + UserRegisterCommand command = UserTestFixture.createUserCommand( + "user" + i, + "user" + i + "@example.com", + "1990-01-01", + com.loopers.domain.user.Gender.MALE + ); + UserInfo userInfo = userFacade.registerUser(command); + users.add(userInfo); + } + + ProductDomainCreateRequest request = ProductTestFixture.createRequest( + 1L, + "인기상품", + "상품 설명", + new BigDecimal("10000"), + 100 + ); + ProductEntity product = productService.registerProduct(request); + + // When: 모든 사용자가 동시에 좋아요 등록 + CountDownLatch latch = new CountDownLatch(userCount); + ExecutorService executorService = newFixedThreadPool(userCount); + AtomicInteger successCount = new AtomicInteger(0); + + for (UserInfo user : users) { + executorService.submit(() -> { + try { + likeFacade.upsertLike(user.username(), product.getId()); + successCount.incrementAndGet(); + } catch (Exception e) { + // 예외 무시 + } finally { + latch.countDown(); + } + }); + } + + latch.await(); + executorService.shutdown(); + + // Then: 각 사용자별로 독립적인 좋아요가 생성되어야 함 + List likes = likeRepository.findAll(); + long activeLikes = likes.stream() + .filter(like -> like.getProductId().equals(product.getId()) + && like.getDeletedAt() == null) + .count(); + + assertThat(activeLikes).isEqualTo(userCount); + assertThat(successCount.get()).isEqualTo(userCount); + + + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeUnitTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeUnitTest.java new file mode 100644 index 000000000..34b9513c5 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeUnitTest.java @@ -0,0 +1,221 @@ +package com.loopers.domain.like; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; +import java.math.BigDecimal; +import java.util.Optional; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.loopers.domain.product.ProductEntity; +import com.loopers.domain.product.ProductRepository; +import com.loopers.domain.user.UserEntity; +import com.loopers.fixtures.ProductTestFixture; +import com.loopers.fixtures.UserTestFixture; + +/** + * LikeService의 단위 테스트 클래스 + * 좋아요 등록, 취소 기능을 테스트하며, 중복 방지 및 멱등성 처리를 검증합니다. + * + * @author hyunjikoh + * @since 2025. 11. 11. + */ +@ExtendWith(MockitoExtension.class) +@DisplayName("LikeService 단위 테스트") +class LikeUnitTest { + + @Mock + private LikeRepository likeRepository; + + @Mock + private ProductRepository productRepository; + + @InjectMocks + private LikeService likeService; + + /** + * 좋아요 등록 관련 테스트 케이스 그룹 + */ + @Nested + @DisplayName("좋아요 등록") + class LikeRegistration { + + @Test + @DisplayName("좋아요 관계가 존재하지 않으면 신규 생성한다.") + void createLikeWhenNotExist() { + // Given: 좋아요 관계가 존재하지 않는 상황 설정 + UserEntity user = UserTestFixture.createDefaultUserEntity(); + + ProductEntity product = ProductTestFixture.createEntity( + 1L, + "테스트 상품", + "상품 설명", + new BigDecimal("10000"), + 100 + ); + + LikeEntity newLike = LikeEntity.createEntity(user.getId(), product.getId()); + + when(likeRepository.findByUserIdAndProductId(user.getId(), product.getId())) + .thenReturn(Optional.empty()); + when(likeRepository.save(any(LikeEntity.class))).thenReturn(newLike); + + // When: 좋아요 등록 메서드 호출 + LikeResult likeResult = likeService.upsertLike(user, product); + + // Then: 신규 생성되었는지 검증 (MV 테이블은 별도 서비스에서 처리) + assertNotNull(likeResult); + verify(likeRepository, times(1)).save(any(LikeEntity.class)); + // ProductRepository의 incrementLikeCount는 더 이상 사용하지 않음 + } + + @Test + @DisplayName("이미 삭제된 좋아요 관계가 존재하면 복원한다.") + void restoreDeletedLikeWhenExist() { + // Given: 삭제된 좋아요 엔티티가 존재하는 상황 설정 + UserEntity user = UserTestFixture.createDefaultUserEntity(); + + ProductEntity product = ProductTestFixture.createEntity( + 1L, + "테스트 상품", + "상품 설명", + new BigDecimal("10000"), + 100 + ); + + LikeEntity deletedLike = LikeEntity.createEntity(user.getId(), product.getId()); + deletedLike.delete(); // 삭제 상태로 설정 + + when(likeRepository.findByUserIdAndProductId(user.getId(), product.getId())) + .thenReturn(Optional.of(deletedLike)); + + // When: 좋아요 등록 메서드 호출 (복원) + LikeResult result = likeService.upsertLike(user, product); + + // Then: 복원되었는지 검증 + assertNotNull(result); + assertNull(result.entity().getDeletedAt(), "복원 후 deletedAt이 null이어야 함"); + verify(likeRepository, never()).save(any(LikeEntity.class)); // 기존 엔티티 복원이므로 save 호출 안함 + } + + @Test + @DisplayName("이미 존재하는 활성 좋아요 관계이면 중복 방지로 카운트를 증가시키지 않는다") + void duplicateLikeDoesNotIncreaseCount() { + // Given: 활성 상태의 좋아요 엔티티가 존재하는 상황 설정 + UserEntity user = UserTestFixture.createDefaultUserEntity(); + + ProductEntity product = ProductTestFixture.createEntity( + 1L, + "테스트 상품", + "상품 설명", + new BigDecimal("10000"), + 100 + ); + // 좋아요 수는 MV 테이블에서 관리하므로 ProductEntity에서 직접 설정하지 않음 + + LikeEntity existingLike = LikeEntity.createEntity(user.getId(), product.getId()); + + when(likeRepository.findByUserIdAndProductId(user.getId(), product.getId())) + .thenReturn(Optional.of(existingLike)); + + // When: 좋아요 등록 메서드 호출 (중복 시도) + LikeResult likeResult = likeService.upsertLike(user, product); + + // Then: 기존 엔티티가 반환되고 중복 처리되었는지 검증 + assertEquals(existingLike, likeResult.entity(), "기존 엔티티가 반환되어야 함"); + // MV 테이블은 별도 서비스에서 처리하므로 ProductRepository 호출 검증하지 않음 + } + } + + /** + * 좋아요 취소 관련 테스트 케이스 그룹 + */ + @Nested + @DisplayName("좋아요 취소") + class LikeCancellation { + + @Test + @DisplayName("존재하지 않는 좋아요 관계이면 취소를 무시한다") + void ignoreCancelWhenNotExist() { + // Given: 좋아요 관계가 존재하지 않는 상황 설정 + UserEntity user = UserTestFixture.createDefaultUserEntity(); + + ProductEntity product = ProductTestFixture.createEntity( + 1L, + "테스트 상품", + "상품 설명", + new BigDecimal("10000"), + 100 + ); + + when(likeRepository.findByUserIdAndProductId(user.getId(), product.getId())) + .thenReturn(Optional.empty()); + + // When: 좋아요 취소 메서드 호출 + likeService.unlikeProduct(user, product); + + // Then: 변경사항 없음 (MV 테이블은 별도 서비스에서 처리) + // ProductRepository 호출 검증하지 않음 + } + + @Test + @DisplayName("존재하는 활성 좋아요 관계이면 취소한다.") + void cancelActiveLike() { + // Given: 활성 상태의 좋아요 엔티티가 존재하는 상황 설정 + UserEntity user = UserTestFixture.createDefaultUserEntity(); + + ProductEntity product = ProductTestFixture.createEntity( + 1L, + "테스트 상품", + "상품 설명", + new BigDecimal("10000"), + 100 + ); + + LikeEntity activeLike = LikeEntity.createEntity(user.getId(), product.getId()); + + when(likeRepository.findByUserIdAndProductId(user.getId(), product.getId())) + .thenReturn(Optional.of(activeLike)); + + // When: 좋아요 취소 메서드 호출 + likeService.unlikeProduct(user, product); + + // Then: 엔티티가 삭제 상태로 변경되었는지 검증 + assertNotNull(activeLike.getDeletedAt(), "취소 후 deletedAt이 설정되어야 함"); + } + + @Test + @DisplayName("이미 삭제된 좋아요 관계이면 취소를 무시한다") + void ignoreCancelWhenAlreadyDeleted() { + // Given: 이미 삭제된 좋아요 엔티티가 존재하는 상황 설정 + UserEntity user = UserTestFixture.createDefaultUserEntity(); + + ProductEntity product = ProductTestFixture.createEntity( + 1L, + "테스트 상품", + "상품 설명", + new BigDecimal("10000"), + 100 + ); + + LikeEntity deletedLike = LikeEntity.createEntity(user.getId(), product.getId()); + deletedLike.delete(); // 이미 삭제 상태 + + when(likeRepository.findByUserIdAndProductId(user.getId(), product.getId())) + .thenReturn(Optional.of(deletedLike)); + + // When: 좋아요 취소 메서드 호출 + likeService.unlikeProduct(user, product); + + // Then: 이미 삭제된 상태이므로 아무 작업도 수행되지 않음 (멱등성 보장) + assertNotNull(deletedLike.getDeletedAt(), "이미 삭제된 상태 유지"); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/IntegrationTest/CouponConcurrencyIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/IntegrationTest/CouponConcurrencyIntegrationTest.java new file mode 100644 index 000000000..2f8a05e2f --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/IntegrationTest/CouponConcurrencyIntegrationTest.java @@ -0,0 +1,669 @@ +package com.loopers.domain.order.IntegrationTest; + +import static org.assertj.core.api.Assertions.assertThat; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import org.junit.jupiter.api.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.orm.ObjectOptimisticLockingFailureException; + +import com.loopers.application.order.OrderCreateCommand; +import com.loopers.application.order.OrderFacade; +import com.loopers.application.order.OrderInfo; +import com.loopers.application.order.OrderItemCommand; +import com.loopers.application.user.UserFacade; +import com.loopers.application.user.UserInfo; +import com.loopers.application.user.UserRegisterCommand; +import com.loopers.domain.brand.BrandEntity; +import com.loopers.domain.brand.BrandService; +import com.loopers.domain.coupon.CouponEntity; +import com.loopers.domain.coupon.CouponService; +import com.loopers.domain.coupon.CouponStatus; +import com.loopers.domain.point.PointService; +import com.loopers.domain.product.ProductDomainCreateRequest; +import com.loopers.domain.product.ProductEntity; +import com.loopers.domain.product.ProductService; +import com.loopers.domain.user.Gender; +import com.loopers.domain.user.UserEntity; +import com.loopers.domain.user.UserService; +import com.loopers.fixtures.BrandTestFixture; +import com.loopers.fixtures.ProductTestFixture; +import com.loopers.fixtures.UserTestFixture; +import com.loopers.utils.DatabaseCleanUp; +import com.loopers.utils.RedisCleanUp; + +/** + * 쿠폰 동시성 제어 통합 테스트 + * + * @author hyunjikoh + * @since 2025. 11. 20. + */ +@SpringBootTest +@DisplayName("쿠폰 동시성 제어 통합 테스트") +public class CouponConcurrencyIntegrationTest { + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @Autowired + private RedisCleanUp redisCleanUp; + + @Autowired + private OrderFacade orderFacade; + + @Autowired + private UserFacade userFacade; + + @Autowired + private ProductService productService; + + @Autowired + private BrandService brandService; + + @Autowired + private PointService pointService; + + @Autowired + private CouponService couponService; + + @Autowired + private UserService userService; + + @BeforeEach + void setUp() { + // 테스트 데이터는 각 테스트에서 필요한 만큼만 생성 + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + redisCleanUp.truncateAll(); + + } + + @Nested + @DisplayName("동시 쿠폰 사용") + class ConcurrentCouponUsage { + + @Test + @DisplayName("동일한 쿠폰을 동시에 사용하려고 하면 한 번만 성공한다") + void should_allow_only_one_order_when_using_same_coupon_concurrently() throws InterruptedException { + // Given: 브랜드 생성 + BrandEntity brand = brandService.registerBrand( + BrandTestFixture.createRequest("테스트브랜드", "브랜드 설명") + ); + + // Given: 두 명의 사용자 생성 및 포인트 충전 + List users = new ArrayList<>(); + for (int i = 0; i < 2; i++) { + UserRegisterCommand userCommand = new UserRegisterCommand( + "user" + i, + "user" + i + "@test.com", + LocalDate.of(1990, 1, 1).toString(), + Gender.MALE + ); + UserInfo userInfo = userFacade.registerUser(userCommand); + pointService.charge(userInfo.username(), new BigDecimal("50000")); + users.add(userInfo); + } + + // Given: 상품 생성 (충분한 재고) + ProductDomainCreateRequest productRequest = ProductTestFixture.createRequest( + brand.getId(), + "테스트상품", + "상품 설명", + new BigDecimal("10000"), + 100 + ); + ProductEntity product = productService.registerProduct(productRequest); + + // Given: 동일한 쿠폰을 첫 번째 사용자에게 발급 + UserEntity user1 = userService.getUserByUsername(users.get(0).username()); + CouponEntity sharedCoupon = couponService.createFixedAmountCoupon(user1, new BigDecimal("5000")); + + CountDownLatch startLatch = new CountDownLatch(1); + CountDownLatch endLatch = new CountDownLatch(2); + AtomicInteger successCount = new AtomicInteger(0); + AtomicInteger failureCount = new AtomicInteger(0); + List exceptions = Collections.synchronizedList(new ArrayList<>()); + List successfulOrders = Collections.synchronizedList(new ArrayList<>()); + + // When - 두 사용자가 동시에 같은 쿠폰으로 주문 시도 + ExecutorService executor = Executors.newFixedThreadPool(2); + + // 사용자1 주문 스레드 + executor.submit(() -> { + try { + startLatch.await(); // 동시 시작을 위한 대기 + + OrderCreateCommand orderCommand = OrderCreateCommand.builder() + .username(users.get(0).username()) + .orderItems(List.of( + OrderItemCommand.builder() + .productId(product.getId()) + .quantity(1) + .couponId(sharedCoupon.getId()) + .build() + )) + .build(); + + OrderInfo result = orderFacade.createOrder(orderCommand); + successfulOrders.add(result); + successCount.incrementAndGet(); + } catch (Exception e) { + failureCount.incrementAndGet(); + exceptions.add(e); + } finally { + endLatch.countDown(); + } + }); + + // 사용자1이 동일 쿠폰으로 두 번째 주문 시도 + executor.submit(() -> { + try { + startLatch.await(); // 동시 시작을 위한 대기 + + OrderCreateCommand orderCommand = OrderCreateCommand.builder() + .username(users.get(0).username()) + .orderItems(List.of( + OrderItemCommand.builder() + .productId(product.getId()) + .quantity(1) + .couponId(sharedCoupon.getId()) + .build() + )) + .build(); + + OrderInfo result = orderFacade.createOrder(orderCommand); + successfulOrders.add(result); + successCount.incrementAndGet(); + } catch (Exception e) { + failureCount.incrementAndGet(); + exceptions.add(e); + } finally { + endLatch.countDown(); + } + }); + + // 동시 실행 시작 + startLatch.countDown(); + + // 모든 스레드 완료 대기 (최대 10초) + boolean completed = endLatch.await(10, TimeUnit.SECONDS); + executor.shutdown(); + + // Then + assertThat(completed).isTrue(); // 타임아웃 없이 완료되어야 함 + assertThat(successCount.get()).isEqualTo(1); // 정확히 하나만 성공 + assertThat(failureCount.get()).isEqualTo(1); // 정확히 하나만 실패 + + // Then: 실패한 경우의 예외 검증 - 낙관적 락 예외 또는 비즈니스 예외 + assertThat(exceptions).hasSize(1); + Exception failedException = exceptions.get(0); + + // 낙관적 락 예외 또는 비즈니스 예외 중 하나여야 함 + assertThat(failedException) + .satisfiesAnyOf( + // 낙관적 락 충돌 예외 + ex -> assertThat(ex) + .isInstanceOf(ObjectOptimisticLockingFailureException.class), + // 비즈니스 예외 (쿠폰 이미 사용됨) + ex -> assertThat(ex) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("이미 사용된 쿠폰입니다") + ); + + // Then: 쿠폰 상태 검증 - 사용됨 상태여야 함 + CouponEntity usedCoupon = couponService.getCouponByIdAndUserId(sharedCoupon.getId(), user1.getId()); + assertThat(usedCoupon.getStatus()).isEqualTo(CouponStatus.USED); + + // Then: 성공한 주문이 정확히 하나만 존재해야 함 + assertThat(successfulOrders).hasSize(1); + assertThat(successfulOrders.get(0).finalTotalAmount()) + .isEqualByComparingTo(new BigDecimal("5000")); // 10,000 - 5,000 + } + + @Test + @DisplayName("낙관적 락 충돌 시 적절한 예외가 발생한다") + void should_throw_optimistic_lock_exception_on_version_conflict() throws InterruptedException { + // Given: 브랜드 생성 + BrandEntity brand = brandService.registerBrand( + BrandTestFixture.createRequest("테스트브랜드", "브랜드 설명") + ); + + // Given: 사용자 생성 및 포인트 충전 + UserRegisterCommand userCommand = UserTestFixture.createDefaultUserCommand(); + UserInfo userInfo = userFacade.registerUser(userCommand); + pointService.charge(userInfo.username(), new BigDecimal("50000")); + + // Given: 상품 생성 + ProductDomainCreateRequest productRequest = ProductTestFixture.createRequest( + brand.getId(), + "테스트상품", + "상품 설명", + new BigDecimal("10000"), + 100 + ); + ProductEntity product = productService.registerProduct(productRequest); + + // Given: 쿠폰 생성 + UserEntity user = userService.getUserByUsername(userInfo.username()); + CouponEntity coupon = couponService.createFixedAmountCoupon(user, new BigDecimal("3000")); + + CountDownLatch startLatch = new CountDownLatch(1); + CountDownLatch endLatch = new CountDownLatch(2); + List exceptions = Collections.synchronizedList(new ArrayList<>()); + AtomicInteger successCount = new AtomicInteger(0); + + ExecutorService executor = Executors.newFixedThreadPool(2); + + // When - 동시에 같은 쿠폰 사용 시도 + for (int i = 0; i < 2; i++) { + executor.submit(() -> { + try { + startLatch.await(); + + OrderCreateCommand orderCommand = OrderCreateCommand.builder() + .username(userInfo.username()) + .orderItems(List.of( + OrderItemCommand.builder() + .productId(product.getId()) + .quantity(1) + .couponId(coupon.getId()) + .build() + )) + .build(); + + orderFacade.createOrder(orderCommand); + successCount.incrementAndGet(); + } catch (Exception e) { + exceptions.add(e); + } finally { + endLatch.countDown(); + } + }); + } + + startLatch.countDown(); + boolean completed = endLatch.await(5, TimeUnit.SECONDS); + executor.shutdown(); + + // Then + assertThat(completed).isTrue(); + assertThat(successCount.get()).isEqualTo(1); + assertThat(exceptions).hasSize(1); + + // Then: 낙관적 락 예외 또는 비즈니스 예외 발생 확인 + Exception exception = exceptions.get(0); + assertThat(exception) + .satisfiesAnyOf( + ex -> assertThat(ex).isInstanceOf(ObjectOptimisticLockingFailureException.class), + ex -> assertThat(ex).isInstanceOf(IllegalArgumentException.class) + ); + } + + @Test + @DisplayName("쿠폰 사용 시 낙관적 락이 정상적으로 동작한다") + void should_apply_optimistic_lock_when_using_coupon() { + // Given: 브랜드 생성 + BrandEntity brand = brandService.registerBrand( + BrandTestFixture.createRequest("테스트브랜드", "브랜드 설명") + ); + + // Given: 사용자 생성 및 포인트 충전 + UserRegisterCommand userCommand = UserTestFixture.createDefaultUserCommand(); + UserInfo userInfo = userFacade.registerUser(userCommand); + pointService.charge(userInfo.username(), new BigDecimal("20000")); + + // Given: 상품 생성 + ProductDomainCreateRequest productRequest = ProductTestFixture.createRequest( + brand.getId(), + "테스트상품", + "상품 설명", + new BigDecimal("10000"), + 100 + ); + ProductEntity product = productService.registerProduct(productRequest); + + // Given: 쿠폰 생성 + UserEntity user = userService.getUserByUsername(userInfo.username()); + CouponEntity coupon = couponService.createFixedAmountCoupon(user, new BigDecimal("3000")); + + // When - 정상적인 쿠폰 사용 + OrderCreateCommand orderCommand = OrderCreateCommand.builder() + .username(userInfo.username()) + .orderItems(List.of( + OrderItemCommand.builder() + .productId(product.getId()) + .quantity(1) + .couponId(coupon.getId()) + .build() + )) + .build(); + + OrderInfo result = orderFacade.createOrder(orderCommand); + + // Then: 주문이 정상적으로 생성되어야 함 + assertThat(result).isNotNull(); + assertThat(result.finalTotalAmount()).isEqualByComparingTo(new BigDecimal("7000")); // 10,000 - 3,000 + + // Then: 쿠폰이 사용됨 상태로 변경되어야 함 + CouponEntity usedCoupon = couponService.getCouponByIdAndUserId(coupon.getId(), user.getId()); + assertThat(usedCoupon.getStatus()).isEqualTo(CouponStatus.USED); + assertThat(usedCoupon.getVersion()).isEqualTo(1); + } + + @Test + @DisplayName("동일 쿠폰 동시 사용 시 한 트랜잭션만 성공하고 나머지는 실패한다") + void should_succeed_only_one_transaction_when_concurrent_same_coupon_usage() throws InterruptedException { + // Given: 브랜드 생성 + BrandEntity brand = brandService.registerBrand( + BrandTestFixture.createRequest("테스트브랜드", "브랜드 설명") + ); + + // Given: 상품 생성 (충분한 재고) + ProductDomainCreateRequest productRequest = ProductTestFixture.createRequest( + brand.getId(), + "테스트상품", + "상품 설명", + new BigDecimal("10000"), + 100 + ); + ProductEntity product = productService.registerProduct(productRequest); + + int threadCount = 5; // 5개 스레드로 동시 접근 + + // Given: 한 명의 사용자 생성 및 포인트 충전 + UserRegisterCommand userCommand = UserTestFixture.createDefaultUserCommand(); + UserInfo userInfo = userFacade.registerUser(userCommand); + pointService.charge(userInfo.username(), new BigDecimal("100000")); // 충분한 포인트 + + // Given: 쿠폰 생성 (1회만 사용 가능) + UserEntity user = userService.getUserByUsername(userInfo.username()); + CouponEntity coupon = couponService.createFixedAmountCoupon(user, new BigDecimal("2000")); + + CountDownLatch startLatch = new CountDownLatch(1); + CountDownLatch endLatch = new CountDownLatch(threadCount); + AtomicInteger successCount = new AtomicInteger(0); + AtomicInteger failureCount = new AtomicInteger(0); + List exceptions = Collections.synchronizedList(new ArrayList<>()); + + ExecutorService executor = Executors.newFixedThreadPool(threadCount); + + // When - 동일한 사용자가 같은 쿠폰으로 여러 번 동시 주문 시도 + for (int i = 0; i < threadCount; i++) { + executor.submit(() -> { + try { + startLatch.await(); + + OrderCreateCommand orderCommand = OrderCreateCommand.builder() + .username(userInfo.username()) + .orderItems(List.of( + OrderItemCommand.builder() + .productId(product.getId()) + .quantity(1) + .couponId(coupon.getId()) + .build() + )) + .build(); + + orderFacade.createOrder(orderCommand); + successCount.incrementAndGet(); + } catch (Exception e) { + failureCount.incrementAndGet(); + exceptions.add(e); + } finally { + endLatch.countDown(); + } + }); + } + + startLatch.countDown(); // 동시 실행 시작 + boolean completed = endLatch.await(10, TimeUnit.SECONDS); + executor.shutdown(); + + // Then + assertThat(completed).isTrue(); + assertThat(successCount.get()).isEqualTo(1); // 정확히 하나만 성공 + assertThat(failureCount.get()).isEqualTo(threadCount - 1); // 나머지는 모두 실패 + + // Then: 실패 예외들 검증 + assertThat(exceptions).hasSize(threadCount - 1); + exceptions.forEach(exception -> { + assertThat(exception) + .satisfiesAnyOf( + // 낙관적 락 충돌 예외 + ex -> assertThat(ex) + .isInstanceOf(ObjectOptimisticLockingFailureException.class), + // 비즈니스 예외 (쿠폰 이미 사용됨) + ex -> assertThat(ex) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("이미 사용된 쿠폰입니다") + ); + }); + + // Then: 쿠폰 상태 검증 + CouponEntity usedCoupon = couponService.getCouponByIdAndUserId(coupon.getId(), user.getId()); + assertThat(usedCoupon.getStatus()).isEqualTo(CouponStatus.USED); + } + + @Test + @DisplayName("동시 쿠폰 사용 실패 시 적절한 예외 메시지가 반환된다") + void should_return_appropriate_error_message_when_concurrent_coupon_usage_fails() throws InterruptedException { + // Given: 브랜드 생성 + BrandEntity brand = brandService.registerBrand( + BrandTestFixture.createRequest("테스트브랜드", "브랜드 설명") + ); + + // Given: 사용자 생성 및 포인트 충전 + UserRegisterCommand userCommand = UserTestFixture.createDefaultUserCommand(); + UserInfo userInfo = userFacade.registerUser(userCommand); + pointService.charge(userInfo.username(), new BigDecimal("50000")); + + // Given: 상품 생성 + ProductDomainCreateRequest productRequest = ProductTestFixture.createRequest( + brand.getId(), + "테스트상품", + "상품 설명", + new BigDecimal("10000"), + 100 + ); + ProductEntity product = productService.registerProduct(productRequest); + + // Given: 쿠폰 생성 + UserEntity user = userService.getUserByUsername(userInfo.username()); + CouponEntity coupon = couponService.createFixedAmountCoupon(user, new BigDecimal("1000")); + + CountDownLatch startLatch = new CountDownLatch(1); + CountDownLatch endLatch = new CountDownLatch(2); + List errorMessages = Collections.synchronizedList(new ArrayList<>()); + List exceptions = Collections.synchronizedList(new ArrayList<>()); + AtomicInteger successCount = new AtomicInteger(0); + + ExecutorService executor = Executors.newFixedThreadPool(2); + + // When - 동시 쿠폰 사용 시도 + for (int i = 0; i < 2; i++) { + executor.submit(() -> { + try { + startLatch.await(); + + OrderCreateCommand orderCommand = OrderCreateCommand.builder() + .username(userInfo.username()) + .orderItems(List.of( + OrderItemCommand.builder() + .productId(product.getId()) + .quantity(1) + .couponId(coupon.getId()) + .build() + )) + .build(); + + orderFacade.createOrder(orderCommand); + successCount.incrementAndGet(); + } catch (Exception e) { + errorMessages.add(e.getMessage()); + exceptions.add(e); + } finally { + endLatch.countDown(); + } + }); + } + + startLatch.countDown(); + boolean completed = endLatch.await(5, TimeUnit.SECONDS); + executor.shutdown(); + + // Then + assertThat(completed).isTrue(); + assertThat(successCount.get()).isEqualTo(1); + assertThat(errorMessages).hasSize(1); + assertThat(exceptions).hasSize(1); + + // Then: 예외 타입 검증 - 낙관적 락 예외 또는 비즈니스 예외 + Exception failedException = exceptions.get(0); + assertThat(failedException) + .satisfiesAnyOf( + // 낙관적 락 충돌 예외 + ex -> assertThat(ex).isInstanceOf(ObjectOptimisticLockingFailureException.class), + // 비즈니스 예외 (쿠폰 이미 사용됨) + ex -> assertThat(ex).isInstanceOf(IllegalArgumentException.class) + ); + + // Then: 에러 메시지가 사용자 친화적이어야 함 (비즈니스 예외인 경우) + if (failedException instanceof IllegalArgumentException) { + String errorMessage = errorMessages.get(0); + assertThat(errorMessage) + .satisfiesAnyOf( + msg -> assertThat(msg).contains("이미 사용된 쿠폰"), + msg -> assertThat(msg).contains("쿠폰을 사용할 수 없습니다"), + msg -> assertThat(msg).contains("사용된 쿠폰입니다") + ); + } + } + } + + @Nested + @DisplayName("서로 다른 쿠폰 동시 사용") + class DifferentCouponsConcurrentUsage { + + @Test + @DisplayName("여러 사용자가 각자의 쿠폰을 동시에 사용할 수 있다") + void should_allow_concurrent_orders_with_different_coupons() throws InterruptedException { + // Given: 브랜드 생성 + BrandEntity brand = brandService.registerBrand( + BrandTestFixture.createRequest("테스트브랜드", "브랜드 설명") + ); + + // Given: 상품 생성 (충분한 재고) + ProductDomainCreateRequest productRequest = ProductTestFixture.createRequest( + brand.getId(), + "테스트상품", + "상품 설명", + new BigDecimal("10000"), + 100 + ); + ProductEntity product = productService.registerProduct(productRequest); + + int userCount = 3; + List users = new ArrayList<>(); + List coupons = new ArrayList<>(); + + // Given: 각 사용자마다 서로 다른 쿠폰 생성 및 발급 + for (int i = 0; i < userCount; i++) { + // 사용자 생성 + UserRegisterCommand userCommand = new UserRegisterCommand( + "user" + i, + "user" + i + "@test.com", + LocalDate.of(1990, 1, 1).toString(), + Gender.MALE + ); + UserInfo userInfo = userFacade.registerUser(userCommand); + pointService.charge(userInfo.username(), new BigDecimal("50000")); + users.add(userInfo); + + // 각 사용자에게 서로 다른 할인 금액의 쿠폰 발급 + UserEntity userEntity = userService.getUserByUsername(userInfo.username()); + BigDecimal discountAmount = new BigDecimal(1000 * (i + 1)); // 1000, 2000, 3000 + CouponEntity coupon = couponService.createFixedAmountCoupon(userEntity, discountAmount); + coupons.add(coupon); + } + + CountDownLatch startLatch = new CountDownLatch(1); + CountDownLatch endLatch = new CountDownLatch(userCount); + AtomicInteger successCount = new AtomicInteger(0); + List exceptions = Collections.synchronizedList(new ArrayList<>()); + List createdOrders = Collections.synchronizedList(new ArrayList<>()); + + ExecutorService executor = Executors.newFixedThreadPool(userCount); + + // When - 각 사용자가 자신의 쿠폰으로 동시 주문 + for (int i = 0; i < userCount; i++) { + final int index = i; + executor.submit(() -> { + try { + startLatch.await(); + + OrderCreateCommand orderCommand = OrderCreateCommand.builder() + .username(users.get(index).username()) + .orderItems(List.of( + OrderItemCommand.builder() + .productId(product.getId()) + .quantity(1) + .couponId(coupons.get(index).getId()) + .build() + )) + .build(); + + OrderInfo result = orderFacade.createOrder(orderCommand); + createdOrders.add(result); + successCount.incrementAndGet(); + } catch (Exception e) { + exceptions.add(e); + } finally { + endLatch.countDown(); + } + }); + } + + startLatch.countDown(); + boolean completed = endLatch.await(10, TimeUnit.SECONDS); + executor.shutdown(); + + // Then + assertThat(completed).isTrue(); + assertThat(successCount.get()).isEqualTo(userCount); // 모든 주문이 성공해야 함 + assertThat(exceptions).isEmpty(); // 예외가 발생하지 않아야 함 + assertThat(createdOrders).hasSize(userCount); + + // Then: 모든 쿠폰이 사용됨 상태여야 함 + for (int i = 0; i < userCount; i++) { + UserEntity user = userService.getUserByUsername(users.get(i).username()); + CouponEntity coupon = couponService.getCouponByIdAndUserId(coupons.get(i).getId(), user.getId()); + assertThat(coupon.getStatus()).isEqualTo(CouponStatus.USED); + } + + // Then: 각 주문의 할인 금액 검증 + for (int i = 0; i < userCount; i++) { + BigDecimal expectedDiscount = new BigDecimal(1000 * (i + 1)); + BigDecimal expectedFinalAmount = new BigDecimal("10000").subtract(expectedDiscount); + + boolean hasMatchingOrder = createdOrders.stream() + .anyMatch(order -> order.finalTotalAmount().compareTo(expectedFinalAmount) == 0); + assertThat(hasMatchingOrder) + .as("할인 금액 %s원이 적용된 주문이 존재해야 함", expectedDiscount) + .isTrue(); + } + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/IntegrationTest/OrderCancelIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/IntegrationTest/OrderCancelIntegrationTest.java new file mode 100644 index 000000000..986e89734 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/IntegrationTest/OrderCancelIntegrationTest.java @@ -0,0 +1,227 @@ +package com.loopers.domain.order.IntegrationTest; + +import static com.loopers.domain.point.PointTransactionType.REFUND; +import static org.assertj.core.api.Assertions.assertThat; +import java.math.BigDecimal; +import java.util.List; + +import org.junit.jupiter.api.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import com.loopers.application.order.OrderCreateCommand; +import com.loopers.application.order.OrderFacade; +import com.loopers.application.order.OrderInfo; +import com.loopers.application.order.OrderItemCommand; +import com.loopers.application.user.UserFacade; +import com.loopers.application.user.UserInfo; +import com.loopers.application.user.UserRegisterCommand; +import com.loopers.domain.brand.BrandEntity; +import com.loopers.domain.brand.BrandService; +import com.loopers.domain.order.OrderStatus; +import com.loopers.domain.point.PointHistoryEntity; +import com.loopers.domain.point.PointService; +import com.loopers.domain.product.ProductDomainCreateRequest; +import com.loopers.domain.product.ProductEntity; +import com.loopers.domain.product.ProductService; +import com.loopers.fixtures.BrandTestFixture; +import com.loopers.fixtures.ProductTestFixture; +import com.loopers.fixtures.UserTestFixture; +import com.loopers.utils.DatabaseCleanUp; +import com.loopers.utils.RedisCleanUp; + +/** + * @author hyunjikoh + * @since 2025. 11. 17. + */ +@SpringBootTest +public class OrderCancelIntegrationTest { + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @Autowired + private RedisCleanUp redisCleanUp; + + @Autowired + private OrderFacade orderFacade; + + @Autowired + private UserFacade userFacade; + + @Autowired + private ProductService productService; + + @Autowired + private BrandService brandService; + + @Autowired + private PointService pointService; + + @BeforeEach + void setUp() { + // 테스트 데이터는 각 테스트에서 필요한 만큼만 생성 + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + redisCleanUp.truncateAll(); + + } + + @Nested + @DisplayName("주문 취소 및 재고 원복 테스트") + class order_cancellation_and_stock_restorationTest { + + @Test + @DisplayName("주문 취소 시 재고가 원복되고 포인트가 환불된다") + void 주문_취소_시_재고가_원복되고_포인트가_환불된다() { + // Given: 브랜드 생성 + BrandEntity brand = brandService.registerBrand( + BrandTestFixture.createRequest("테스트브랜드", "브랜드 설명") + ); + + // Given: 사용자 생성 및 포인트 충전 + UserRegisterCommand userCommand = UserTestFixture.createDefaultUserCommand(); + UserInfo userInfo = userFacade.registerUser(userCommand); + pointService.charge(userInfo.username(), new BigDecimal("50000")); + + // Given: 상품 생성 + Integer initialStock = 100; + ProductDomainCreateRequest productRequest = ProductTestFixture.createRequest( + brand.getId(), + "테스트상품", + "상품 설명", + new BigDecimal("10000"), + initialStock + ); + ProductEntity product = productService.registerProduct(productRequest); + + // Given: 주문 생성 + Integer orderQuantity = 2; + OrderCreateCommand orderCommand = OrderCreateCommand.builder() + .username(userInfo.username()) + .orderItems(List.of( + OrderItemCommand.builder() + .productId(product.getId()) + .quantity(orderQuantity) + .build() + )) + .build(); + + OrderInfo createdOrder = orderFacade.createOrder(orderCommand); + + // 주문 생성 후 재고 확인 + ProductEntity productAfterOrder = productService.getActiveProductDetail(product.getId()); + assertThat(productAfterOrder.getStockQuantity()).isEqualTo(initialStock - orderQuantity); + + // When: 주문 취소 + OrderInfo cancelledOrder = orderFacade.cancelOrder(createdOrder.id(), userInfo.username()); + + // Then: 재고 원복 확인 + ProductEntity productAfterCancel = productService.getActiveProductDetail(product.getId()); + assertThat(productAfterCancel.getStockQuantity()).isEqualTo(initialStock); + + // Then: 주문 상태 확인 + assertThat(cancelledOrder.status()).isEqualTo(OrderStatus.CANCELLED); + + // Then: 포인트 환불 확인 + PointHistoryEntity pointHistoryEntity = pointService.getPointHistories(userInfo.username()).get(0); + + assertThat(pointHistoryEntity.getTransactionType()).isEqualTo(REFUND); + assertThat(pointHistoryEntity.getAmount()).isEqualTo(new BigDecimal("20000").setScale(2)); + + assertThat(pointHistoryEntity.getBalanceAfter()) + .isEqualTo(new BigDecimal("50000").setScale(2)); + } + + @Test + @DisplayName("PENDING 상태의 주문을 취소할 수 있다") + void PENDING_상태의_주문을_취소할_수_있다() { + // Given: 브랜드 생성 + BrandEntity brand = brandService.registerBrand( + BrandTestFixture.createRequest("테스트브랜드", "브랜드 설명") + ); + + // Given: 사용자 생성 및 포인트 충전 + UserRegisterCommand userCommand = UserTestFixture.createDefaultUserCommand(); + UserInfo userInfo = userFacade.registerUser(userCommand); + pointService.charge(userInfo.username(), new BigDecimal("50000")); + + // Given: 상품 생성 + ProductDomainCreateRequest productRequest = ProductTestFixture.createRequest( + brand.getId(), + "테스트상품", + "상품 설명", + new BigDecimal("10000"), + 100 + ); + ProductEntity product = productService.registerProduct(productRequest); + + // Given: 주문 생성 + OrderCreateCommand orderCommand = OrderCreateCommand.builder() + .username(userInfo.username()) + .orderItems(List.of( + OrderItemCommand.builder() + .productId(product.getId()) + .quantity(1) + .build() + )) + .build(); + + OrderInfo createdOrder = orderFacade.createOrder(orderCommand); + assertThat(createdOrder.status()).isEqualTo(OrderStatus.PENDING); + + // When: 주문 취소 + OrderInfo cancelledOrder = orderFacade.cancelOrder(createdOrder.id(), userInfo.username()); + + // Then: 주문 상태 확인 + assertThat(cancelledOrder.status()).isEqualTo(OrderStatus.CANCELLED); + } + + @Test + @DisplayName("CONFIRMED 상태의 주문을 취소할 수 있다") + void CONFIRMED_상태의_주문을_취소할_수_있다() { + // Given: 브랜드 생성 + BrandEntity brand = brandService.registerBrand( + BrandTestFixture.createRequest("테스트브랜드", "브랜드 설명") + ); + + // Given: 사용자 생성 및 포인트 충전 + UserRegisterCommand userCommand = UserTestFixture.createDefaultUserCommand(); + UserInfo userInfo = userFacade.registerUser(userCommand); + pointService.charge(userInfo.username(), new BigDecimal("50000")); + + // Given: 상품 생성 + ProductDomainCreateRequest productRequest = ProductTestFixture.createRequest( + brand.getId(), + "테스트상품", + "상품 설명", + new BigDecimal("10000"), + 100 + ); + ProductEntity product = productService.registerProduct(productRequest); + + // Given: 주문 생성 및 확정 + OrderCreateCommand orderCommand = OrderCreateCommand.builder() + .username(userInfo.username()) + .orderItems(List.of( + OrderItemCommand.builder() + .productId(product.getId()) + .quantity(1) + .build() + )) + .build(); + + OrderInfo createdOrder = orderFacade.createOrder(orderCommand); + OrderInfo confirmedOrder = orderFacade.confirmOrder(createdOrder.id(), userInfo.username()); + assertThat(confirmedOrder.status()).isEqualTo(OrderStatus.CONFIRMED); + + // When: 주문 취소 + OrderInfo cancelledOrder = orderFacade.cancelOrder(confirmedOrder.id(), userInfo.username()); + + // Then: 주문 상태 확인 + assertThat(cancelledOrder.status()).isEqualTo(OrderStatus.CANCELLED); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/IntegrationTest/OrderCancelWithCouponIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/IntegrationTest/OrderCancelWithCouponIntegrationTest.java new file mode 100644 index 000000000..a42ac104e --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/IntegrationTest/OrderCancelWithCouponIntegrationTest.java @@ -0,0 +1,803 @@ +package com.loopers.domain.order.IntegrationTest; + +import static org.assertj.core.api.Assertions.assertThat; +import java.math.BigDecimal; +import java.util.List; + +import org.junit.jupiter.api.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import com.loopers.application.order.OrderCreateCommand; +import com.loopers.application.order.OrderFacade; +import com.loopers.application.order.OrderInfo; +import com.loopers.application.order.OrderItemCommand; +import com.loopers.application.user.UserFacade; +import com.loopers.application.user.UserInfo; +import com.loopers.application.user.UserRegisterCommand; +import com.loopers.domain.brand.BrandEntity; +import com.loopers.domain.brand.BrandService; +import com.loopers.domain.coupon.CouponEntity; +import com.loopers.domain.coupon.CouponService; +import com.loopers.domain.coupon.CouponStatus; +import com.loopers.domain.order.OrderService; +import com.loopers.domain.point.PointService; +import com.loopers.domain.product.ProductDomainCreateRequest; +import com.loopers.domain.product.ProductEntity; +import com.loopers.domain.product.ProductService; +import com.loopers.domain.user.UserEntity; +import com.loopers.domain.user.UserService; +import com.loopers.fixtures.BrandTestFixture; +import com.loopers.fixtures.ProductTestFixture; +import com.loopers.fixtures.UserTestFixture; +import com.loopers.utils.DatabaseCleanUp; +import com.loopers.utils.RedisCleanUp; + +/** + * 쿠폰을 사용한 주문 취소 통합 테스트 + * + * @author hyunjikoh + * @since 2025. 11. 20. + */ +@SpringBootTest +@DisplayName("쿠폰을 사용한 주문 취소 통합 테스트") +public class OrderCancelWithCouponIntegrationTest { + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @Autowired + private RedisCleanUp redisCleanUp; + + @Autowired + private OrderFacade orderFacade; + + @Autowired + private UserFacade userFacade; + + @Autowired + private ProductService productService; + + @Autowired + private BrandService brandService; + + @Autowired + private PointService pointService; + + @Autowired + private CouponService couponService; + + @Autowired + private OrderService orderService; + + @Autowired + private UserService userService; + + @BeforeEach + void setUp() { + // 테스트 데이터는 각 테스트에서 필요한 만큼만 생성 + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + redisCleanUp.truncateAll(); + + } + + @Nested + @DisplayName("쿠폰 복구") + class CouponRestoration { + + @Test + @DisplayName("정액 쿠폰을 사용한 주문을 취소하면 쿠폰이 복구된다") + void should_restore_fixed_coupon_when_canceling_order() { + // Given: 브랜드 생성 + BrandEntity brand = brandService.registerBrand( + BrandTestFixture.createRequest("테스트브랜드", "브랜드 설명") + ); + + // Given: 사용자 생성 및 포인트 충전 + UserRegisterCommand userCommand = UserTestFixture.createDefaultUserCommand(); + UserInfo userInfo = userFacade.registerUser(userCommand); + BigDecimal initialPoints = new BigDecimal("30000"); + pointService.charge(userInfo.username(), initialPoints); + + // Given: 상품 생성 (단가 10,000원) + ProductDomainCreateRequest productRequest = ProductTestFixture.createRequest( + brand.getId(), + "테스트상품", + "상품 설명", + new BigDecimal("10000"), + 100 + ); + ProductEntity product = productService.registerProduct(productRequest); + + // Given: 정액 할인 쿠폰 생성 (5,000원 할인) + UserEntity user = userService.getUserByUsername(userInfo.username()); + CouponEntity fixedCoupon = couponService.createFixedAmountCoupon( + user, + new BigDecimal("5000") + ); + + // Given: 쿠폰을 사용하여 주문 생성 (수량 2개) + // 예상 계산: (10,000 * 2) - 5,000 = 15,000원 + OrderCreateCommand orderCommand = OrderCreateCommand.builder() + .username(userInfo.username()) + .orderItems(List.of( + OrderItemCommand.builder() + .productId(product.getId()) + .quantity(2) + .couponId(fixedCoupon.getId()) + .build() + )) + .build(); + + OrderInfo createdOrder = orderFacade.createOrder(orderCommand); + + // Given: 주문 생성 후 쿠폰이 사용됨 상태인지 확인 + CouponEntity usedCoupon = couponService.getCouponByIdAndUserId(fixedCoupon.getId(), user.getId()); + assertThat(usedCoupon.getStatus()) + .as("주문 생성 후 쿠폰 상태") + .isEqualTo(CouponStatus.USED); + + // When: 주문 취소 + OrderInfo cancelledOrder = orderFacade.cancelOrder(createdOrder.id(), userInfo.username()); + + // Then: 쿠폰이 복구되어 UNUSED 상태로 변경되었는지 검증 + CouponEntity restoredCoupon = couponService.getCouponByIdAndUserId(fixedCoupon.getId(), user.getId()); + assertThat(restoredCoupon.getStatus()) + .as("주문 취소 후 쿠폰 상태가 UNUSED로 복구되어야 함") + .isEqualTo(CouponStatus.UNUSED); + + // Then: 쿠폰을 다시 사용할 수 있는지 검증 + assertThat(restoredCoupon.canUse()) + .as("복구된 쿠폰은 다시 사용 가능해야 함") + .isTrue(); + + // Then: 포인트가 정확하게 환불되었는지 검증 + UserEntity updatedUser = userService.getUserByUsername(userInfo.username()); + assertThat(updatedUser.getPointAmount()) + .as("주문 취소 후 포인트가 초기 금액으로 복구되어야 함") + .isEqualByComparingTo(initialPoints); + + // Then: 상품 재고가 복구되었는지 검증 + ProductEntity updatedProduct = productService.getActiveProductDetail(product.getId()); + assertThat(updatedProduct.getStockQuantity()) + .as("주문 취소 후 재고가 복구되어야 함") + .isEqualTo(100); + } + + @Test + @DisplayName("배율 쿠폰을 사용한 주문을 취소하면 쿠폰이 복구된다") + void should_restore_percentage_coupon_when_canceling_order() { + // Given: 브랜드 생성 + BrandEntity brand = brandService.registerBrand( + BrandTestFixture.createRequest("테스트브랜드", "브랜드 설명") + ); + + // Given: 사용자 생성 및 포인트 충전 + UserRegisterCommand userCommand = UserTestFixture.createDefaultUserCommand(); + UserInfo userInfo = userFacade.registerUser(userCommand); + BigDecimal initialPoints = new BigDecimal("50000"); + pointService.charge(userInfo.username(), initialPoints); + + // Given: 상품 생성 (단가 20,000원) + ProductDomainCreateRequest productRequest = ProductTestFixture.createRequest( + brand.getId(), + "테스트상품", + "상품 설명", + new BigDecimal("20000"), + 100 + ); + ProductEntity product = productService.registerProduct(productRequest); + + // Given: 배율 할인 쿠폰 생성 (25% 할인) + UserEntity user = userService.getUserByUsername(userInfo.username()); + CouponEntity percentageCoupon = couponService.createPercentCoupon( + user, + 25 + ); + + // Given: 쿠폰을 사용하여 주문 생성 (수량 2개) + // 예상 계산: (20,000 * 2) * 0.75 = 30,000원 + OrderCreateCommand orderCommand = OrderCreateCommand.builder() + .username(userInfo.username()) + .orderItems(List.of( + OrderItemCommand.builder() + .productId(product.getId()) + .quantity(2) + .couponId(percentageCoupon.getId()) + .build() + )) + .build(); + + OrderInfo createdOrder = orderFacade.createOrder(orderCommand); + + // Given: 주문 생성 후 쿠폰이 사용됨 상태인지 확인 + CouponEntity usedCoupon = couponService.getCouponByIdAndUserId(percentageCoupon.getId(), user.getId()); + assertThat(usedCoupon.getStatus()) + .as("주문 생성 후 배율 쿠폰 상태") + .isEqualTo(CouponStatus.USED); + + // When: 주문 취소 + orderFacade.cancelOrder(createdOrder.id(), userInfo.username()); + + // Then: 배율 쿠폰이 복구되어 UNUSED 상태로 변경되었는지 검증 + CouponEntity restoredCoupon = couponService.getCouponByIdAndUserId(percentageCoupon.getId(), user.getId()); + assertThat(restoredCoupon.getStatus()) + .as("주문 취소 후 배율 쿠폰 상태가 UNUSED로 복구되어야 함") + .isEqualTo(CouponStatus.UNUSED); + + // Then: 쿠폰을 다시 사용할 수 있는지 검증 + assertThat(restoredCoupon.canUse()) + .as("복구된 배율 쿠폰은 다시 사용 가능해야 함") + .isTrue(); + + // Then: 포인트가 정확하게 환불되었는지 검증 + UserEntity updatedUser = userService.getUserByUsername(userInfo.username()); + assertThat(updatedUser.getPointAmount()) + .as("주문 취소 후 포인트가 초기 금액으로 복구되어야 함") + .isEqualByComparingTo(initialPoints); + } + + @Test + @DisplayName("주문 취소 시 쿠폰 상태가 UNUSED로 변경된다") + void should_change_coupon_status_to_unused_when_canceling_order() { + // Given: 브랜드 생성 + BrandEntity brand = brandService.registerBrand( + BrandTestFixture.createRequest("테스트브랜드", "브랜드 설명") + ); + + // Given: 사용자 생성 및 포인트 충전 + UserRegisterCommand userCommand = UserTestFixture.createDefaultUserCommand(); + UserInfo userInfo = userFacade.registerUser(userCommand); + pointService.charge(userInfo.username(), new BigDecimal("20000")); + + // Given: 상품 생성 + ProductDomainCreateRequest productRequest = ProductTestFixture.createRequest( + brand.getId(), + "테스트상품", + "상품 설명", + new BigDecimal("10000"), + 100 + ); + ProductEntity product = productService.registerProduct(productRequest); + + // Given: 쿠폰 생성 + UserEntity user = userService.getUserByUsername(userInfo.username()); + CouponEntity coupon = couponService.createFixedAmountCoupon( + user, + new BigDecimal("3000") + ); + + // Given: 쿠폰 초기 상태 확인 + assertThat(coupon.getStatus()) + .as("쿠폰 생성 직후 상태") + .isEqualTo(CouponStatus.UNUSED); + + // Given: 쿠폰을 사용하여 주문 생성 + OrderCreateCommand orderCommand = OrderCreateCommand.builder() + .username(userInfo.username()) + .orderItems(List.of( + OrderItemCommand.builder() + .productId(product.getId()) + .quantity(1) + .couponId(coupon.getId()) + .build() + )) + .build(); + + OrderInfo createdOrder = orderFacade.createOrder(orderCommand); + + // Given: 주문 후 쿠폰 상태가 USED로 변경되었는지 확인 + CouponEntity usedCoupon = couponService.getCouponByIdAndUserId(coupon.getId(), user.getId()); + assertThat(usedCoupon.getStatus()) + .as("주문 생성 후 쿠폰 상태가 USED로 변경") + .isEqualTo(CouponStatus.USED); + + // When: 주문 취소 + orderFacade.cancelOrder(createdOrder.id(), userInfo.username()); + + // Then: 쿠폰 상태가 UNUSED로 변경되었는지 검증 + CouponEntity restoredCoupon = couponService.getCouponByIdAndUserId(coupon.getId(), user.getId()); + assertThat(restoredCoupon.getStatus()) + .as("주문 취소 후 쿠폰 상태가 UNUSED로 복구") + .isEqualTo(CouponStatus.UNUSED); + + // Then: 쿠폰 상태 변경 이력 검증 (UNUSED -> USED -> UNUSED) + assertThat(restoredCoupon.getStatus()) + .as("최종 쿠폰 상태는 UNUSED") + .isNotEqualTo(CouponStatus.USED) + .isEqualTo(CouponStatus.UNUSED); + } + + @Test + @DisplayName("여러 쿠폰을 사용한 주문 취소 시 모든 쿠폰이 복구된다") + void should_restore_all_coupons_when_canceling_order_with_multiple_coupons() { + // Given: 브랜드 생성 + BrandEntity brand = brandService.registerBrand( + BrandTestFixture.createRequest("테스트브랜드", "브랜드 설명") + ); + + // Given: 사용자 생성 및 포인트 충전 + UserRegisterCommand userCommand = UserTestFixture.createDefaultUserCommand(); + UserInfo userInfo = userFacade.registerUser(userCommand); + BigDecimal initialPoints = new BigDecimal("100000"); + pointService.charge(userInfo.username(), initialPoints); + + // Given: 상품 2개 생성 + ProductDomainCreateRequest product1Request = ProductTestFixture.createRequest( + brand.getId(), + "상품1", + "상품1 설명", + new BigDecimal("15000"), + 100 + ); + ProductEntity product1 = productService.registerProduct(product1Request); + + ProductDomainCreateRequest product2Request = ProductTestFixture.createRequest( + brand.getId(), + "상품2", + "상품2 설명", + new BigDecimal("25000"), + 100 + ); + ProductEntity product2 = productService.registerProduct(product2Request); + + // Given: 쿠폰 2개 생성 (정액 쿠폰, 배율 쿠폰) + UserEntity user = userService.getUserByUsername(userInfo.username()); + CouponEntity fixedCoupon = couponService.createFixedAmountCoupon( + user, + new BigDecimal("5000") + ); + CouponEntity percentageCoupon = couponService.createPercentCoupon( + user, + 20 + ); + + // Given: 여러 쿠폰을 사용하여 주문 생성 + // 상품1: 15,000 - 5,000 = 10,000원 + // 상품2: 25,000 * 0.8 = 20,000원 + // 총 30,000원 + OrderCreateCommand orderCommand = OrderCreateCommand.builder() + .username(userInfo.username()) + .orderItems(List.of( + OrderItemCommand.builder() + .productId(product1.getId()) + .quantity(1) + .couponId(fixedCoupon.getId()) + .build(), + OrderItemCommand.builder() + .productId(product2.getId()) + .quantity(1) + .couponId(percentageCoupon.getId()) + .build() + )) + .build(); + + OrderInfo createdOrder = orderFacade.createOrder(orderCommand); + + // Given: 주문 생성 후 모든 쿠폰이 사용됨 상태인지 확인 + CouponEntity usedFixedCoupon = couponService.getCouponByIdAndUserId(fixedCoupon.getId(), user.getId()); + CouponEntity usedPercentageCoupon = couponService.getCouponByIdAndUserId(percentageCoupon.getId(), user.getId()); + + assertThat(usedFixedCoupon.getStatus()) + .as("주문 생성 후 정액 쿠폰 상태") + .isEqualTo(CouponStatus.USED); + assertThat(usedPercentageCoupon.getStatus()) + .as("주문 생성 후 배율 쿠폰 상태") + .isEqualTo(CouponStatus.USED); + + // When: 주문 취소 + orderFacade.cancelOrder(createdOrder.id(), userInfo.username()); + + // Then: 모든 쿠폰이 복구되어 UNUSED 상태로 변경되었는지 검증 + CouponEntity restoredFixedCoupon = couponService.getCouponByIdAndUserId(fixedCoupon.getId(), user.getId()); + CouponEntity restoredPercentageCoupon = couponService.getCouponByIdAndUserId(percentageCoupon.getId(), user.getId()); + + assertThat(restoredFixedCoupon.getStatus()) + .as("주문 취소 후 정액 쿠폰이 UNUSED로 복구") + .isEqualTo(CouponStatus.UNUSED); + assertThat(restoredPercentageCoupon.getStatus()) + .as("주문 취소 후 배율 쿠폰이 UNUSED로 복구") + .isEqualTo(CouponStatus.UNUSED); + + // Then: 모든 쿠폰을 다시 사용할 수 있는지 검증 + assertThat(restoredFixedCoupon.canUse()) + .as("복구된 정액 쿠폰은 다시 사용 가능") + .isTrue(); + assertThat(restoredPercentageCoupon.canUse()) + .as("복구된 배율 쿠폰은 다시 사용 가능") + .isTrue(); + + // Then: 포인트가 정확하게 환불되었는지 검증 + UserEntity updatedUser = userService.getUserByUsername(userInfo.username()); + assertThat(updatedUser.getPointAmount()) + .as("주문 취소 후 포인트가 초기 금액으로 복구") + .isEqualByComparingTo(initialPoints); + + // Then: 모든 상품의 재고가 복구되었는지 검증 + ProductEntity updatedProduct1 = productService.getActiveProductDetail(product1.getId()); + ProductEntity updatedProduct2 = productService.getActiveProductDetail(product2.getId()); + + assertThat(updatedProduct1.getStockQuantity()) + .as("상품1 재고 복구") + .isEqualTo(100); + assertThat(updatedProduct2.getStockQuantity()) + .as("상품2 재고 복구") + .isEqualTo(100); + } + } + + @Nested + @DisplayName("포인트 및 재고 복구") + class PointAndStockRestoration { + + @Test + @DisplayName("쿠폰 사용 주문 취소 시 포인트가 정확하게 환불된다") + void should_refund_correct_amount_when_canceling_order_with_coupon() { + // Given: 브랜드 생성 + BrandEntity brand = brandService.registerBrand( + BrandTestFixture.createRequest("테스트브랜드", "브랜드 설명") + ); + + // Given: 사용자 생성 및 포인트 충전 + UserRegisterCommand userCommand = UserTestFixture.createDefaultUserCommand(); + UserInfo userInfo = userFacade.registerUser(userCommand); + BigDecimal initialPoints = new BigDecimal("50000"); + pointService.charge(userInfo.username(), initialPoints); + + // Given: 상품 생성 (단가 12,000원) + ProductDomainCreateRequest productRequest = ProductTestFixture.createRequest( + brand.getId(), + "테스트상품", + "상품 설명", + new BigDecimal("12000"), + 100 + ); + ProductEntity product = productService.registerProduct(productRequest); + + // Given: 정액 할인 쿠폰 생성 (3,000원 할인) + UserEntity user = userService.getUserByUsername(userInfo.username()); + CouponEntity coupon = couponService.createFixedAmountCoupon( + user, + new BigDecimal("3000") + ); + + // Given: 쿠폰을 사용하여 주문 생성 (수량 2개) + // 원래 금액: 12,000 * 2 = 24,000원 + // 할인 후 금액: 24,000 - 3,000 = 21,000원 + OrderCreateCommand orderCommand = OrderCreateCommand.builder() + .username(userInfo.username()) + .orderItems(List.of( + OrderItemCommand.builder() + .productId(product.getId()) + .quantity(2) + .couponId(coupon.getId()) + .build() + )) + .build(); + + OrderInfo createdOrder = orderFacade.createOrder(orderCommand); + + // Given: 주문 후 포인트 확인 + UserEntity userAfterOrder = userService.getUserByUsername(userInfo.username()); + BigDecimal pointsAfterOrder = userAfterOrder.getPointAmount(); + BigDecimal expectedPointsAfterOrder = initialPoints.subtract(new BigDecimal("21000")); + + assertThat(pointsAfterOrder) + .as("주문 후 포인트 (50,000 - 21,000 = 29,000)") + .isEqualByComparingTo(expectedPointsAfterOrder); + + // When: 주문 취소 + orderFacade.cancelOrder(createdOrder.id(), userInfo.username()); + + // Then: 포인트가 정확하게 환불되었는지 검증 + UserEntity userAfterCancel = userService.getUserByUsername(userInfo.username()); + + assertThat(userAfterCancel.getPointAmount()) + .as("주문 취소 후 포인트가 초기 금액으로 복구 (50,000)") + .isEqualByComparingTo(initialPoints); + + // Then: 환불된 포인트 금액 검증 + BigDecimal refundedAmount = userAfterCancel.getPointAmount().subtract(pointsAfterOrder); + + assertThat(refundedAmount) + .as("환불된 포인트는 실제 결제 금액과 동일 (21,000)") + .isEqualByComparingTo(new BigDecimal("21000")); + } + + @Test + @DisplayName("쿠폰 사용 주문 취소 시 재고가 정확하게 복구된다") + void should_restore_stock_correctly_when_canceling_order_with_coupon() { + // Given: 브랜드 생성 + BrandEntity brand = brandService.registerBrand( + BrandTestFixture.createRequest("테스트브랜드", "브랜드 설명") + ); + + // Given: 사용자 생성 및 포인트 충전 + UserRegisterCommand userCommand = UserTestFixture.createDefaultUserCommand(); + UserInfo userInfo = userFacade.registerUser(userCommand); + pointService.charge(userInfo.username(), new BigDecimal("100000")); + + // Given: 상품 생성 (초기 재고 50개) + ProductDomainCreateRequest productRequest = ProductTestFixture.createRequest( + brand.getId(), + "테스트상품", + "상품 설명", + new BigDecimal("15000"), + 50 + ); + ProductEntity product = productService.registerProduct(productRequest); + int initialStock = product.getStockQuantity(); + + // Given: 배율 할인 쿠폰 생성 (30% 할인) + UserEntity user = userService.getUserByUsername(userInfo.username()); + CouponEntity coupon = couponService.createPercentCoupon( + user, + 30 + ); + + // Given: 쿠폰을 사용하여 주문 생성 (수량 7개) + int orderQuantity = 7; + OrderCreateCommand orderCommand = OrderCreateCommand.builder() + .username(userInfo.username()) + .orderItems(List.of( + OrderItemCommand.builder() + .productId(product.getId()) + .quantity(orderQuantity) + .couponId(coupon.getId()) + .build() + )) + .build(); + + OrderInfo createdOrder = orderFacade.createOrder(orderCommand); + + // Given: 주문 후 재고 확인 + ProductEntity productAfterOrder = productService.getActiveProductDetail(product.getId()); + int expectedStockAfterOrder = initialStock - orderQuantity; + + assertThat(productAfterOrder.getStockQuantity()) + .as("주문 후 재고 (50 - 7 = 43)") + .isEqualTo(expectedStockAfterOrder); + + // When: 주문 취소 + orderFacade.cancelOrder(createdOrder.id(), userInfo.username()); + + // Then: 재고가 정확하게 복구되었는지 검증 + ProductEntity productAfterCancel = productService.getActiveProductDetail(product.getId()); + + assertThat(productAfterCancel.getStockQuantity()) + .as("주문 취소 후 재고가 초기 수량으로 복구 (50)") + .isEqualTo(initialStock); + + // Then: 복구된 재고 수량 검증 + int restoredStock = productAfterCancel.getStockQuantity() - productAfterOrder.getStockQuantity(); + + assertThat(restoredStock) + .as("복구된 재고는 주문 수량과 동일 (7)") + .isEqualTo(orderQuantity); + } + + @Test + @DisplayName("쿠폰 할인 금액을 제외한 실제 결제 금액만 환불된다") + void should_refund_only_actual_payment_amount_excluding_coupon_discount() { + // Given: 브랜드 생성 + BrandEntity brand = brandService.registerBrand( + BrandTestFixture.createRequest("테스트브랜드", "브랜드 설명") + ); + + // Given: 사용자 생성 및 포인트 충전 + UserRegisterCommand userCommand = UserTestFixture.createDefaultUserCommand(); + UserInfo userInfo = userFacade.registerUser(userCommand); + BigDecimal initialPoints = new BigDecimal("80000"); + pointService.charge(userInfo.username(), initialPoints); + + // Given: 상품 생성 (단가 30,000원) + ProductDomainCreateRequest productRequest = ProductTestFixture.createRequest( + brand.getId(), + "테스트상품", + "상품 설명", + new BigDecimal("30000"), + 100 + ); + ProductEntity product = productService.registerProduct(productRequest); + + // Given: 정액 할인 쿠폰 생성 (10,000원 할인) + UserEntity user = userService.getUserByUsername(userInfo.username()); + CouponEntity coupon = couponService.createFixedAmountCoupon( + user, + new BigDecimal("10000") + ); + + // Given: 쿠폰을 사용하여 주문 생성 (수량 2개) + // 원래 금액: 30,000 * 2 = 60,000원 + // 쿠폰 할인: -10,000원 + // 실제 결제 금액: 50,000원 + OrderCreateCommand orderCommand = OrderCreateCommand.builder() + .username(userInfo.username()) + .orderItems(List.of( + OrderItemCommand.builder() + .productId(product.getId()) + .quantity(2) + .couponId(coupon.getId()) + .build() + )) + .build(); + + OrderInfo createdOrder = orderFacade.createOrder(orderCommand); + + // Given: 주문 후 포인트 확인 + UserEntity userAfterOrder = userService.getUserByUsername(userInfo.username()); + BigDecimal actualPaymentAmount = new BigDecimal("50000"); + BigDecimal expectedPointsAfterOrder = initialPoints.subtract(actualPaymentAmount); + + assertThat(userAfterOrder.getPointAmount()) + .as("주문 후 포인트 (80,000 - 50,000 = 30,000)") + .isEqualByComparingTo(expectedPointsAfterOrder); + + // When: 주문 취소 + orderFacade.cancelOrder(createdOrder.id(), userInfo.username()); + + // Then: 실제 결제 금액만 환불되었는지 검증 + UserEntity userAfterCancel = userService.getUserByUsername(userInfo.username()); + BigDecimal refundedAmount = userAfterCancel.getPointAmount().subtract(userAfterOrder.getPointAmount()); + + assertThat(refundedAmount) + .as("환불된 금액은 실제 결제 금액 (50,000원)이어야 함") + .isEqualByComparingTo(actualPaymentAmount); + + // Then: 쿠폰 할인 금액은 환불되지 않음을 검증 + BigDecimal originalAmount = new BigDecimal("60000"); + BigDecimal couponDiscount = new BigDecimal("10000"); + + assertThat(refundedAmount) + .as("환불 금액은 원래 금액(60,000)이 아닌 할인 후 금액(50,000)") + .isNotEqualByComparingTo(originalAmount) + .isEqualByComparingTo(originalAmount.subtract(couponDiscount)); + + // Then: 최종 포인트가 초기 금액으로 복구되었는지 검증 + assertThat(userAfterCancel.getPointAmount()) + .as("주문 취소 후 포인트가 초기 금액으로 복구 (80,000)") + .isEqualByComparingTo(initialPoints); + } + + @Test + @DisplayName("여러 쿠폰 사용 주문 취소 시 포인트와 재고가 모두 정확하게 복구된다") + void should_restore_points_and_stock_correctly_with_multiple_coupons() { + // Given: 브랜드 생성 + BrandEntity brand = brandService.registerBrand( + BrandTestFixture.createRequest("테스트브랜드", "브랜드 설명") + ); + + // Given: 사용자 생성 및 포인트 충전 + UserRegisterCommand userCommand = UserTestFixture.createDefaultUserCommand(); + UserInfo userInfo = userFacade.registerUser(userCommand); + BigDecimal initialPoints = new BigDecimal("150000"); + pointService.charge(userInfo.username(), initialPoints); + + // Given: 상품 3개 생성 + ProductDomainCreateRequest product1Request = ProductTestFixture.createRequest( + brand.getId(), + "상품1", + "상품1 설명", + new BigDecimal("20000"), + 80 + ); + ProductEntity product1 = productService.registerProduct(product1Request); + int product1InitialStock = product1.getStockQuantity(); + + ProductDomainCreateRequest product2Request = ProductTestFixture.createRequest( + brand.getId(), + "상품2", + "상품2 설명", + new BigDecimal("30000"), + 60 + ); + ProductEntity product2 = productService.registerProduct(product2Request); + int product2InitialStock = product2.getStockQuantity(); + + ProductDomainCreateRequest product3Request = ProductTestFixture.createRequest( + brand.getId(), + "상품3", + "상품3 설명", + new BigDecimal("40000"), + 50 + ); + ProductEntity product3 = productService.registerProduct(product3Request); + int product3InitialStock = product3.getStockQuantity(); + + // Given: 쿠폰 3개 생성 + UserEntity user = userService.getUserByUsername(userInfo.username()); + CouponEntity fixedCoupon1 = couponService.createFixedAmountCoupon( + user, + new BigDecimal("5000") + ); + CouponEntity percentageCoupon = couponService.createPercentCoupon( + user, + 20 + ); + CouponEntity fixedCoupon2 = couponService.createFixedAmountCoupon( + user, + new BigDecimal("8000") + ); + + // Given: 여러 쿠폰을 사용하여 주문 생성 + // 상품1: (20,000 * 2) - 5,000 = 35,000원 + // 상품2: (30,000 * 1) * 0.8 = 24,000원 + // 상품3: (40,000 * 1) - 8,000 = 32,000원 + // 총 결제 금액: 91,000원 + OrderCreateCommand orderCommand = OrderCreateCommand.builder() + .username(userInfo.username()) + .orderItems(List.of( + OrderItemCommand.builder() + .productId(product1.getId()) + .quantity(2) + .couponId(fixedCoupon1.getId()) + .build(), + OrderItemCommand.builder() + .productId(product2.getId()) + .quantity(1) + .couponId(percentageCoupon.getId()) + .build(), + OrderItemCommand.builder() + .productId(product3.getId()) + .quantity(1) + .couponId(fixedCoupon2.getId()) + .build() + )) + .build(); + + OrderInfo createdOrder = orderFacade.createOrder(orderCommand); + + // Given: 주문 후 포인트와 재고 확인 + UserEntity userAfterOrder = userService.getUserByUsername(userInfo.username()); + BigDecimal totalPaymentAmount = new BigDecimal("91000"); + + assertThat(userAfterOrder.getPointAmount()) + .as("주문 후 포인트 (150,000 - 91,000 = 59,000)") + .isEqualByComparingTo(initialPoints.subtract(totalPaymentAmount)); + + ProductEntity product1AfterOrder = productService.getActiveProductDetail(product1.getId()); + ProductEntity product2AfterOrder = productService.getActiveProductDetail(product2.getId()); + ProductEntity product3AfterOrder = productService.getActiveProductDetail(product3.getId()); + + assertThat(product1AfterOrder.getStockQuantity()).isEqualTo(product1InitialStock - 2); + assertThat(product2AfterOrder.getStockQuantity()).isEqualTo(product2InitialStock - 1); + assertThat(product3AfterOrder.getStockQuantity()).isEqualTo(product3InitialStock - 1); + + // When: 주문 취소 + orderFacade.cancelOrder(createdOrder.id(), userInfo.username()); + + // Then: 포인트가 정확하게 환불되었는지 검증 + UserEntity userAfterCancel = userService.getUserByUsername(userInfo.username()); + + assertThat(userAfterCancel.getPointAmount()) + .as("주문 취소 후 포인트가 초기 금액으로 복구 (150,000)") + .isEqualByComparingTo(initialPoints); + + // Then: 모든 상품의 재고가 정확하게 복구되었는지 검증 + ProductEntity product1AfterCancel = productService.getActiveProductDetail(product1.getId()); + ProductEntity product2AfterCancel = productService.getActiveProductDetail(product2.getId()); + ProductEntity product3AfterCancel = productService.getActiveProductDetail(product3.getId()); + + assertThat(product1AfterCancel.getStockQuantity()) + .as("상품1 재고 복구 (80)") + .isEqualTo(product1InitialStock); + assertThat(product2AfterCancel.getStockQuantity()) + .as("상품2 재고 복구 (60)") + .isEqualTo(product2InitialStock); + assertThat(product3AfterCancel.getStockQuantity()) + .as("상품3 재고 복구 (50)") + .isEqualTo(product3InitialStock); + + // Then: 환불된 포인트 금액 검증 + BigDecimal refundedAmount = userAfterCancel.getPointAmount().subtract(userAfterOrder.getPointAmount()); + + assertThat(refundedAmount) + .as("환불된 포인트는 총 결제 금액과 동일 (91,000)") + .isEqualByComparingTo(totalPaymentAmount); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/IntegrationTest/OrderConfirmationIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/IntegrationTest/OrderConfirmationIntegrationTest.java new file mode 100644 index 000000000..9638fe261 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/IntegrationTest/OrderConfirmationIntegrationTest.java @@ -0,0 +1,234 @@ +package com.loopers.domain.order.IntegrationTest; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import java.math.BigDecimal; +import java.util.List; + +import org.junit.jupiter.api.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import com.loopers.application.order.OrderCreateCommand; +import com.loopers.application.order.OrderFacade; +import com.loopers.application.order.OrderInfo; +import com.loopers.application.order.OrderItemCommand; +import com.loopers.application.user.UserFacade; +import com.loopers.application.user.UserInfo; +import com.loopers.application.user.UserRegisterCommand; +import com.loopers.domain.brand.BrandEntity; +import com.loopers.domain.brand.BrandService; +import com.loopers.domain.order.OrderService; +import com.loopers.domain.order.OrderStatus; +import com.loopers.domain.point.PointService; +import com.loopers.domain.product.ProductEntity; +import com.loopers.domain.product.ProductRepository; +import com.loopers.domain.product.ProductService; +import com.loopers.domain.user.UserRepository; +import com.loopers.fixtures.BrandTestFixture; +import com.loopers.fixtures.ProductTestFixture; +import com.loopers.fixtures.UserTestFixture; +import com.loopers.utils.DatabaseCleanUp; +import com.loopers.utils.RedisCleanUp; + +/** + * @author hyunjikoh + * @since 2025. 11. 17. + */ +@SpringBootTest +public class OrderConfirmationIntegrationTest { + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @Autowired + private RedisCleanUp redisCleanUp; + + @Autowired + private OrderFacade orderFacade; + + @Autowired + private ProductRepository productRepository; + + @Autowired + private UserRepository userRepository; + + @Autowired + private UserFacade userFacade; + + @Autowired + private ProductService productService; + + @Autowired + private BrandService brandService; + + @Autowired + private PointService pointService; + @Autowired + private OrderService orderService; + + @BeforeEach + void setUp() { + // 테스트 데이터는 각 테스트에서 필요한 만큼만 생성 + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + redisCleanUp.truncateAll(); + + } + + @Nested + @DisplayName("주문 확정") + class OrderConfirmation { + + @Test + @DisplayName("PENDING 상태의 주문을 확정하면 CONFIRMED 상태로 변경된다") + void should_change_status_to_confirmed_when_confirming_pending_order() { + // Given: 브랜드 생성 + BrandEntity brand = brandService.registerBrand( + BrandTestFixture.createRequest("테스트브랜드", "브랜드 설명") + ); + + // Given: 사용자 생성 및 포인트 충전 + UserRegisterCommand userCommand = UserTestFixture.createDefaultUserCommand(); + UserInfo userInfo = userFacade.registerUser(userCommand); + pointService.charge(userInfo.username(), new BigDecimal("50000")); + + // Given: 상품 생성 + ProductEntity product = productService.registerProduct( + ProductTestFixture.createRequest( + brand.getId(), + "테스트상품", + "상품 설명", + new BigDecimal("10000"), + 100 + ) + ); + + // Given: 주문 생성 + OrderCreateCommand orderCommand = OrderCreateCommand.builder() + .username(userInfo.username()) + .orderItems(List.of( + OrderItemCommand.builder() + .productId(product.getId()) + .quantity(2) + .build() + )) + .build(); + OrderInfo createdOrder = orderFacade.createOrder(orderCommand); + + // When: 주문 확정 + OrderInfo confirmedOrder = orderFacade.confirmOrder(createdOrder.id(), userInfo.username()); + + // Then: 주문 상태가 CONFIRMED로 변경되었는지 검증 + assertThat(confirmedOrder.status()).isEqualTo(OrderStatus.CONFIRMED); + assertThat(confirmedOrder.id()).isEqualTo(createdOrder.id()); + } + + @Test + @DisplayName("PENDING이 아닌 상태의 주문 확정 시 예외가 발생한다") + void should_throw_exception_when_confirming_non_pending_order() { + // Given: 브랜드 생성 + BrandEntity brand = brandService.registerBrand( + BrandTestFixture.createRequest("테스트브랜드", "브랜드 설명") + ); + + // Given: 사용자 생성 및 포인트 충전 + UserRegisterCommand userCommand = UserTestFixture.createDefaultUserCommand(); + UserInfo userInfo = userFacade.registerUser(userCommand); + pointService.charge(userInfo.username(), new BigDecimal("50000")); + + // Given: 상품 생성 + ProductEntity product = productService.registerProduct( + ProductTestFixture.createRequest( + brand.getId(), + "테스트상품", + "상품 설명", + new BigDecimal("10000"), + 100 + ) + ); + + // Given: 주문 생성 및 확정 + OrderCreateCommand orderCommand = OrderCreateCommand.builder() + .username(userInfo.username()) + .orderItems(List.of( + OrderItemCommand.builder() + .productId(product.getId()) + .quantity(1) + .build() + )) + .build(); + OrderInfo createdOrder = orderFacade.createOrder(orderCommand); + orderFacade.confirmOrder(createdOrder.id(), userInfo.username()); // 첫 번째 확정 + + // When & Then: 이미 확정된 주문을 다시 확정하려고 하면 예외 발생 + assertThatThrownBy(() -> orderFacade.confirmOrder(createdOrder.id(), userInfo.username())) + .isInstanceOf(Exception.class) + .hasMessageContaining("주문 확정은 대기 상태 또는 활성화된 주문만 가능합니다."); + } + + @Test + @DisplayName("존재하지 않는 주문 확정 시 예외가 발생한다") + void should_throw_exception_when_confirming_non_existent_order() { + // Given: 존재하지 않는 주문 ID + Long nonExistentOrderId = 99999L; + + UserRegisterCommand userCommand = UserTestFixture.createDefaultUserCommand(); + UserInfo userInfo = userFacade.registerUser(userCommand); + + // When & Then: 존재하지 않는 주문 확정 시 예외 발생 + assertThatThrownBy(() -> orderFacade.confirmOrder(nonExistentOrderId, userInfo.username())) + .isInstanceOf(Exception.class) + .hasMessageContaining("주문을 찾을 수 없습니다"); + } + + @Test + @DisplayName("삭제된 주문 확정 시 예외가 발생한다") + void should_throw_exception_when_confirming_deleted_order() { + // Given: 브랜드 생성 + BrandEntity brand = brandService.registerBrand( + BrandTestFixture.createRequest("테스트브랜드", "브랜드 설명") + ); + + // Given: 사용자 생성 및 포인트 충전 + UserRegisterCommand userCommand = UserTestFixture.createDefaultUserCommand(); + UserInfo userInfo = userFacade.registerUser(userCommand); + pointService.charge(userInfo.username(), new BigDecimal("50000")); + + // Given: 상품 생성 + ProductEntity product = productService.registerProduct( + ProductTestFixture.createRequest( + brand.getId(), + "테스트상품", + "상품 설명", + new BigDecimal("10000"), + 100 + ) + ); + + // Given: 주문 생성 + OrderCreateCommand orderCommand = OrderCreateCommand.builder() + .username(userInfo.username()) + .orderItems(List.of( + OrderItemCommand.builder() + .productId(product.getId()) + .quantity(1) + .build() + )) + .build(); + OrderInfo createdOrder = orderFacade.createOrder(orderCommand); + + // Given: 주문 삭제 (소프트 삭제) + orderService.deleteOrder(createdOrder.id(), userInfo.username()); + + // When & Then: 삭제된 주문 확정 시 예외 발생 (삭제된 주문은 조회되지 않음) + assertThatThrownBy(() -> orderFacade.confirmOrder(createdOrder.id(), userInfo.username())) + .isInstanceOf(Exception.class) + .hasMessageContaining("주문을 찾을 수 없습니다"); + } + } + +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/IntegrationTest/OrderCreateIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/IntegrationTest/OrderCreateIntegrationTest.java new file mode 100644 index 000000000..385f17353 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/IntegrationTest/OrderCreateIntegrationTest.java @@ -0,0 +1,402 @@ +package com.loopers.domain.order.IntegrationTest; + +import static org.assertj.core.api.Assertions.*; +import java.math.BigDecimal; +import java.util.List; + +import org.junit.jupiter.api.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import com.loopers.application.order.OrderCreateCommand; +import com.loopers.application.order.OrderFacade; +import com.loopers.application.order.OrderInfo; +import com.loopers.application.order.OrderItemCommand; +import com.loopers.application.user.UserFacade; +import com.loopers.application.user.UserInfo; +import com.loopers.application.user.UserRegisterCommand; +import com.loopers.domain.brand.BrandEntity; +import com.loopers.domain.brand.BrandService; +import com.loopers.domain.order.OrderService; +import com.loopers.domain.order.OrderStatus; +import com.loopers.domain.point.PointService; +import com.loopers.domain.product.ProductDomainCreateRequest; +import com.loopers.domain.product.ProductEntity; +import com.loopers.domain.product.ProductRepository; +import com.loopers.domain.product.ProductService; +import com.loopers.domain.user.UserRepository; +import com.loopers.fixtures.BrandTestFixture; +import com.loopers.fixtures.ProductTestFixture; +import com.loopers.fixtures.UserTestFixture; +import com.loopers.utils.DatabaseCleanUp; +import com.loopers.utils.RedisCleanUp; + +/** + * @author hyunjikoh + * @since 2025. 11. 17. + */ +@SpringBootTest +public class OrderCreateIntegrationTest { + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @Autowired + private RedisCleanUp redisCleanUp; + + @Autowired + private OrderFacade orderFacade; + + @Autowired + private ProductRepository productRepository; + + @Autowired + private UserRepository userRepository; + + @Autowired + private UserFacade userFacade; + + @Autowired + private ProductService productService; + + @Autowired + private BrandService brandService; + + @Autowired + private PointService pointService; + @Autowired + private OrderService orderService; + + @BeforeEach + void setUp() { + // 테스트 데이터는 각 테스트에서 필요한 만큼만 생성 + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + redisCleanUp.truncateAll(); + + } + + @Nested + @DisplayName("주문 생성") + class OrderCreation { + + @Test + @DisplayName("유효한 사용자와 상품으로 주문을 생성하면 성공한다") + void should_create_order_successfully_with_valid_user_and_products() { + // Given: 브랜드 생성 + BrandEntity brand = brandService.registerBrand(BrandTestFixture.createRequest("테스트브랜드", "브랜드 설명")); + + // Given: 사용자 생성 및 포인트 충전 + UserRegisterCommand command = UserTestFixture.createDefaultUserCommand(); + UserInfo userInfo = userFacade.registerUser(command); + pointService.charge(userInfo.username(), new BigDecimal("50000")); + + // Given: 상품 생성 + ProductDomainCreateRequest productRequest = ProductTestFixture.createRequest( + brand.getId(), + "테스트상품", + "상품 설명", + new BigDecimal("10000"), + 100 + ); + ProductEntity savedProduct = productService.registerProduct(productRequest); + + // Given: 주문 생성 요청 + OrderCreateCommand orderCommand = OrderCreateCommand.builder() + .username(userInfo.username()) + .orderItems(List.of( + OrderItemCommand.builder() + .productId(savedProduct.getId()) + .quantity(2) + .build() + )) + .build(); + + // When: 주문 생성 + OrderInfo result = orderFacade.createOrder(orderCommand); + + // Then: 주문이 정상 생성되었는지 검증 + assertThat(result).isNotNull(); + assertThat(result.userId()).isEqualTo(userInfo.id()); + assertThat(result.originalTotalAmount()).isEqualTo(new BigDecimal("20000.00")); // 10000 * 2 + assertThat(result.finalTotalAmount()).isEqualTo(new BigDecimal("20000.00")); // 할인 없음 + assertThat(result.status()).isEqualTo(OrderStatus.PENDING); + assertThat(result.orderItems()).hasSize(1); + assertThat(result.orderItems().get(0).quantity()).isEqualTo(2); + } + + @Test + @DisplayName("주문 생성 시 주문 항목들이 함께 저장된다") + void should_save_order_items_when_creating_order() { + // Given: 브랜드 생성 + BrandEntity brand = brandService.registerBrand( + BrandTestFixture.createRequest("테스트브랜드", "브랜드 설명") + ); + + // Given: 사용자 생성 및 포인트 충전 + UserRegisterCommand userCommand = UserTestFixture.createDefaultUserCommand(); + UserInfo userInfo = userFacade.registerUser(userCommand); + pointService.charge(userInfo.username(), new BigDecimal("100000")); + + // Given: 여러 상품 생성 + ProductEntity product1 = productService.registerProduct( + ProductTestFixture.createRequest( + brand.getId(), + "상품1", + "상품1 설명", + new BigDecimal("10000"), + 100 + ) + ); + + ProductEntity product2 = productService.registerProduct( + ProductTestFixture.createRequest( + brand.getId(), + "상품2", + "상품2 설명", + new BigDecimal("20000"), + 50 + ) + ); + + ProductEntity product3 = productService.registerProduct( + ProductTestFixture.createRequest( + brand.getId(), + "상품3", + "상품3 설명", + new BigDecimal("15000"), + 30 + ) + ); + + // Given: 여러 상품을 포함한 주문 생성 요청 + OrderCreateCommand orderCommand = OrderCreateCommand.builder() + .username(userInfo.username()) + .orderItems(List.of( + OrderItemCommand.builder() + .productId(product1.getId()) + .quantity(2) + .build(), + OrderItemCommand.builder() + .productId(product2.getId()) + .quantity(1) + .build(), + OrderItemCommand.builder() + .productId(product3.getId()) + .quantity(3) + .build() + )) + .build(); + + // When: 주문 생성 + OrderInfo result = orderFacade.createOrder(orderCommand); + + // Then: 주문 항목들이 모두 저장되었는지 검증 + assertThat(result.orderItems()).isNotNull(); + assertThat(result.orderItems()).hasSize(3); + + // Then: 각 주문 항목의 정보가 정확한지 검증 + assertThat(result.orderItems()) + .extracting("productId", "quantity", "unitPrice") + .containsExactlyInAnyOrder( + tuple(product1.getId(), 2, new BigDecimal("10000.00")), + tuple(product2.getId(), 1, new BigDecimal("20000.00")), + tuple(product3.getId(), 3, new BigDecimal("15000.00")) + ); + + // Then: 각 주문 항목의 총액이 정확한지 검증 + assertThat(result.orderItems().get(0).totalPrice()) + .isEqualTo(result.orderItems().get(0).unitPrice() + .multiply(BigDecimal.valueOf(result.orderItems().get(0).quantity()))); + + // Then: 주문 총액이 모든 항목의 합계와 일치하는지 검증 + BigDecimal expectedTotal = new BigDecimal("10000.00").multiply(BigDecimal.valueOf(2)) // 20000 + .add(new BigDecimal("20000.00").multiply(BigDecimal.valueOf(1))) // 20000 + .add(new BigDecimal("15000.00").multiply(BigDecimal.valueOf(3))); // 45000 + assertThat(result.finalTotalAmount()).isEqualTo(expectedTotal); // 85000.00 + } + + @Test + @DisplayName("주문 생성 시 총액이 주문 항목들의 합계와 일치한다") + void should_match_total_amount_with_sum_of_order_items() { + // Given: 브랜드 생성 + BrandEntity brand = brandService.registerBrand( + BrandTestFixture.createRequest("테스트브랜드", "브랜드 설명") + ); + + // Given: 사용자 생성 및 포인트 충전 + UserRegisterCommand userCommand = UserTestFixture.createDefaultUserCommand(); + UserInfo userInfo = userFacade.registerUser(userCommand); + pointService.charge(userInfo.username(), new BigDecimal("200000")); + + // Given: 다양한 가격의 상품 생성 + ProductEntity product1 = productService.registerProduct( + ProductTestFixture.createRequest(brand.getId(), "상품1", "설명1", new BigDecimal("12500"), 100) + ); + ProductEntity product2 = productService.registerProduct( + ProductTestFixture.createRequest(brand.getId(), "상품2", "설명2", new BigDecimal("33000"), 50) + ); + ProductEntity product3 = productService.registerProduct( + ProductTestFixture.createRequest(brand.getId(), "상품3", "설명3", new BigDecimal("7800"), 30) + ); + + // Given: 주문 생성 요청 + OrderCreateCommand orderCommand = OrderCreateCommand.builder() + .username(userInfo.username()) + .orderItems(List.of( + OrderItemCommand.builder().productId(product1.getId()).quantity(3).build(), + OrderItemCommand.builder().productId(product2.getId()).quantity(2).build(), + OrderItemCommand.builder().productId(product3.getId()).quantity(5).build() + )) + .build(); + + // When: 주문 생성 + OrderInfo result = orderFacade.createOrder(orderCommand); + + // Then: 주문 항목들의 총액 합계 계산 + BigDecimal itemsTotal = result.orderItems().stream() + .map(item -> item.unitPrice().multiply(BigDecimal.valueOf(item.quantity()))) + .reduce(BigDecimal.ZERO, BigDecimal::add); + + // Then: 주문 총액과 항목 합계가 일치하는지 검증 + assertThat(result.finalTotalAmount()).isEqualTo(itemsTotal); + + // Then: 예상 총액과도 일치하는지 검증 + BigDecimal expectedTotal = new BigDecimal("12500.00").multiply(BigDecimal.valueOf(3)) // 37500 + .add(new BigDecimal("33000.00").multiply(BigDecimal.valueOf(2))) // 66000 + .add(new BigDecimal("7800.00").multiply(BigDecimal.valueOf(5))); // 39000 + assertThat(result.finalTotalAmount()).isEqualTo(expectedTotal); // 142500.00 + } + + @Test + @DisplayName("재고가 부족한 상품으로 주문 생성 시 예외가 발생한다") + void should_throw_exception_when_creating_order_with_insufficient_stock() { + // Given: 브랜드 생성 + BrandEntity brand = brandService.registerBrand( + BrandTestFixture.createRequest("테스트브랜드", "브랜드 설명") + ); + + // Given: 사용자 생성 및 포인트 충전 + UserRegisterCommand userCommand = UserTestFixture.createDefaultUserCommand(); + UserInfo userInfo = userFacade.registerUser(userCommand); + pointService.charge(userInfo.username(), new BigDecimal("100000")); + + // Given: 재고가 적은 상품 생성 + ProductEntity product = productService.registerProduct( + ProductTestFixture.createRequest( + brand.getId(), + "재고부족상품", + "재고가 5개만 있는 상품", + new BigDecimal("10000"), + 5 // 재고 5개 + ) + ); + + // Given: 재고보다 많은 수량으로 주문 생성 요청 + OrderCreateCommand orderCommand = OrderCreateCommand.builder() + .username(userInfo.username()) + .orderItems(List.of( + OrderItemCommand.builder() + .productId(product.getId()) + .quantity(10) // 재고(5개)보다 많은 수량 요청 + .build() + )) + .build(); + + // When & Then: 주문 생성 시 예외 발생 + assertThatThrownBy(() -> orderFacade.createOrder(orderCommand)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("주문할 수 없는 상품입니다.") + .hasMessageContaining("요청 수량: 10") + .hasMessageContaining("재고: 5"); + } + + @Test + @DisplayName("포인트가 부족한 경우 주문 생성 시 예외가 발생한다") + void should_throw_exception_when_creating_order_with_insufficient_points() { + // Given: 브랜드 생성 + BrandEntity brand = brandService.registerBrand( + BrandTestFixture.createRequest("테스트브랜드", "브랜드 설명") + ); + + // Given: 사용자 생성 및 적은 포인트 충전 + UserRegisterCommand userCommand = UserTestFixture.createDefaultUserCommand(); + UserInfo userInfo = userFacade.registerUser(userCommand); + pointService.charge(userInfo.username(), new BigDecimal("5000")); // 5,000원만 충전 + + // Given: 상품 생성 + ProductEntity product = productService.registerProduct( + ProductTestFixture.createRequest( + brand.getId(), + "고가상품", + "비싼 상품", + new BigDecimal("10000"), + 100 + ) + ); + + // Given: 포인트보다 비싼 주문 생성 요청 + OrderCreateCommand orderCommand = OrderCreateCommand.builder() + .username(userInfo.username()) + .orderItems(List.of( + OrderItemCommand.builder() + .productId(product.getId()) + .quantity(2) // 20,000원 (보유 포인트 5,000원보다 많음) + .build() + )) + .build(); + + // When & Then: 주문 생성 시 포인트 부족 예외 발생 + assertThatThrownBy(() -> orderFacade.createOrder(orderCommand)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("포인트가 부족합니다"); + } + + @Test + @DisplayName("삭제된 상품으로 주문 생성 시 예외가 발생한다") + void should_throw_exception_when_creating_order_with_deleted_product() { + // Given: 브랜드 생성 + BrandEntity brand = brandService.registerBrand( + BrandTestFixture.createRequest("테스트브랜드", "브랜드 설명") + ); + + // Given: 사용자 생성 및 포인트 충전 + UserRegisterCommand userCommand = UserTestFixture.createDefaultUserCommand(); + UserInfo userInfo = userFacade.registerUser(userCommand); + pointService.charge(userInfo.username(), new BigDecimal("50000")); + + // Given: 상품 생성 후 삭제 + ProductEntity product = productService.registerProduct( + ProductTestFixture.createRequest( + brand.getId(), + "삭제될상품", + "곧 삭제될 상품", + new BigDecimal("10000"), + 100 + ) + ); + Long productId = product.getId(); + product.delete(); + productRepository.save(product); + + + // Given: 삭제된 상품으로 주문 생성 요청 + OrderCreateCommand orderCommand = OrderCreateCommand.builder() + .username(userInfo.username()) + .orderItems(List.of( + OrderItemCommand.builder() + .productId(productId) + .quantity(1) + .build() + )) + .build(); + + // When & Then: 주문 생성 시 예외 발생 + assertThatThrownBy(() -> orderFacade.createOrder(orderCommand)) + .isInstanceOf(Exception.class) + .hasMessageContaining("상품을 찾을 수 없습니다"); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/IntegrationTest/OrderCreateWithCouponIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/IntegrationTest/OrderCreateWithCouponIntegrationTest.java new file mode 100644 index 000000000..56d57fa0a --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/IntegrationTest/OrderCreateWithCouponIntegrationTest.java @@ -0,0 +1,775 @@ +package com.loopers.domain.order.IntegrationTest; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import java.math.BigDecimal; +import java.util.List; + +import org.junit.jupiter.api.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import com.loopers.application.order.OrderCreateCommand; +import com.loopers.application.order.OrderFacade; +import com.loopers.application.order.OrderInfo; +import com.loopers.application.order.OrderItemCommand; +import com.loopers.application.user.UserFacade; +import com.loopers.application.user.UserInfo; +import com.loopers.application.user.UserRegisterCommand; +import com.loopers.domain.brand.BrandEntity; +import com.loopers.domain.brand.BrandService; +import com.loopers.domain.coupon.CouponEntity; +import com.loopers.domain.coupon.CouponService; +import com.loopers.domain.coupon.CouponStatus; +import com.loopers.domain.order.OrderService; +import com.loopers.domain.point.PointService; +import com.loopers.domain.product.ProductDomainCreateRequest; +import com.loopers.domain.product.ProductEntity; +import com.loopers.domain.product.ProductService; +import com.loopers.domain.user.Gender; +import com.loopers.domain.user.UserEntity; +import com.loopers.domain.user.UserService; +import com.loopers.fixtures.BrandTestFixture; +import com.loopers.fixtures.ProductTestFixture; +import com.loopers.fixtures.UserTestFixture; +import com.loopers.utils.DatabaseCleanUp; +import com.loopers.utils.RedisCleanUp; + +/** + * 쿠폰을 사용한 주문 생성 통합 테스트 + * + * @author hyunjikoh + * @since 2025. 11. 20. + */ +@SpringBootTest +@DisplayName("쿠폰을 사용한 주문 생성 통합 테스트") +public class OrderCreateWithCouponIntegrationTest { + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @Autowired + private RedisCleanUp redisCleanUp; + + @Autowired + private OrderFacade orderFacade; + + @Autowired + private UserFacade userFacade; + + @Autowired + private ProductService productService; + + @Autowired + private BrandService brandService; + + @Autowired + private PointService pointService; + + @Autowired + private CouponService couponService; + + @Autowired + private OrderService orderService; + @Autowired + private UserService userService; + + @BeforeEach + void setUp() { + // 테스트 데이터는 각 테스트에서 필요한 만큼만 생성 + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + redisCleanUp.truncateAll(); + + } + + @Nested + @DisplayName("정액 할인 쿠폰 주문") + class FixedAmountCouponOrder { + + @Test + @DisplayName("정액 할인 쿠폰을 사용하여 주문을 생성할 수 있다") + void should_create_order_with_fixed_amount_coupon() { + // Given: 브랜드 생성 + BrandEntity brand = brandService.registerBrand(BrandTestFixture.createRequest("테스트브랜드", "브랜드 설명")); + + // Given: 사용자 생성 및 포인트 충전 + UserRegisterCommand command = UserTestFixture.createDefaultUserCommand(); + UserInfo userInfo = userFacade.registerUser(command); + pointService.charge(userInfo.username(), new BigDecimal("15000")); + + // Given: 상품 생성 + ProductDomainCreateRequest productRequest = ProductTestFixture.createRequest( + brand.getId(), + "테스트상품", + "상품 설명", + new BigDecimal("10000"), + 100 + ); + ProductEntity savedProduct = productService.registerProduct(productRequest); + + UserEntity user = userService.getUserByUsername(userInfo.username()); + + CouponEntity fixedAmountCoupon = couponService.createFixedAmountCoupon(user, new BigDecimal("5000")); + + // Given: 주문 생성 요청 + OrderCreateCommand orderCommand = OrderCreateCommand.builder() + .username(userInfo.username()) + .orderItems(List.of( + OrderItemCommand.builder() + .productId(savedProduct.getId()) + .quantity(2) + .couponId(fixedAmountCoupon.getId()) + .build() + )) + .build(); + + // When: 주문 생성 + OrderInfo result = orderFacade.createOrder(orderCommand); + + // Then + CouponEntity usedCoupon = couponService.getCouponByIdAndUserId(fixedAmountCoupon.getId(), user.getId()); + assertThat(usedCoupon.getStatus()).isEqualTo(CouponStatus.USED); + } + + @Test + @DisplayName("정액 쿠폰 적용 후 총 주문 금액이 정확하게 계산된다") + void should_calculate_total_amount_correctly_with_fixed_coupon() { + // Given: 브랜드 생성 + BrandEntity brand = brandService.registerBrand( + BrandTestFixture.createRequest("테스트브랜드", "브랜드 설명") + ); + + // Given: 사용자 생성 및 포인트 충전 + UserRegisterCommand userCommand = UserTestFixture.createDefaultUserCommand(); + UserInfo userInfo = userFacade.registerUser(userCommand); + pointService.charge(userInfo.username(), new BigDecimal("50000")); + + // Given: 상품 생성 (단가 10,000원) + ProductDomainCreateRequest productRequest = ProductTestFixture.createRequest( + brand.getId(), + "테스트상품", + "상품 설명", + new BigDecimal("10000"), + 100 + ); + ProductEntity product = productService.registerProduct(productRequest); + + // Given: 정액 할인 쿠폰 생성 (5,000원 할인) + UserEntity user = userService.getUserByUsername(userInfo.username()); + CouponEntity fixedCoupon = couponService.createFixedAmountCoupon( + user, + new BigDecimal("5000") + ); + + // Given: 주문 생성 요청 (수량 2개) + // 예상 계산: (10,000 * 2) - 5,000 = 15,000원 + OrderCreateCommand orderCommand = OrderCreateCommand.builder() + .username(userInfo.username()) + .orderItems(List.of( + OrderItemCommand.builder() + .productId(product.getId()) + .quantity(2) + .couponId(fixedCoupon.getId()) + .build() + )) + .build(); + + // When: 주문 생성 + OrderInfo result = orderFacade.createOrder(orderCommand); + + // Then: 총 주문 금액 검증 + BigDecimal expectedOriginalAmount = new BigDecimal("20000"); // 10,000 * 2 + BigDecimal expectedDiscountAmount = new BigDecimal("5000"); // 쿠폰 할인 + BigDecimal expectedFinalAmount = new BigDecimal("15000"); // 20,000 - 5,000 + + assertThat(result.originalTotalAmount()) + .as("할인 전 원래 주문 금액") + .isEqualByComparingTo(expectedOriginalAmount); + + assertThat(result.discountAmount()) + .as("쿠폰 할인 금액") + .isEqualByComparingTo(expectedDiscountAmount); + + assertThat(result.finalTotalAmount()) + .as("쿠폰 할인이 적용된 최종 주문 금액") + .isEqualByComparingTo(expectedFinalAmount); + + // Then: 주문 항목 금액 검증 + assertThat(result.orderItems()).hasSize(1); + assertThat(result.orderItems().get(0).quantity()).isEqualTo(2); + assertThat(result.orderItems().get(0).unitPrice()) + .as("상품 단가") + .isEqualByComparingTo(new BigDecimal("10000")); + + // Then: 실제 할인이 적용되었는지 검증 + BigDecimal itemTotal = result.orderItems().get(0).unitPrice() + .multiply(BigDecimal.valueOf(result.orderItems().get(0).quantity())); + BigDecimal actualDiscount = itemTotal.subtract(result.finalTotalAmount()); + + assertThat(actualDiscount) + .as("실제 적용된 할인 금액") + .isEqualByComparingTo(expectedDiscountAmount); + } + + + @Test + @DisplayName("정액 쿠폰 할인 후 포인트 차감이 정확하게 이루어진다") + void should_deduct_points_correctly_after_fixed_coupon_discount() { + // Given: 브랜드 생성 + BrandEntity brand = brandService.registerBrand( + BrandTestFixture.createRequest("테스트브랜드", "브랜드 설명") + ); + + // Given: 사용자 생성 및 포인트 충전 + UserRegisterCommand userCommand = UserTestFixture.createDefaultUserCommand(); + UserInfo userInfo = userFacade.registerUser(userCommand); + BigDecimal initialPoints = new BigDecimal("30000"); + pointService.charge(userInfo.username(), initialPoints); + + // Given: 상품 생성 (단가 12,000원) + ProductDomainCreateRequest productRequest = ProductTestFixture.createRequest( + brand.getId(), + "테스트상품", + "상품 설명", + new BigDecimal("12000"), + 100 + ); + ProductEntity product = productService.registerProduct(productRequest); + + // Given: 정액 할인 쿠폰 생성 (3,000원 할인) + UserEntity user = userService.getUserByUsername(userInfo.username()); + CouponEntity fixedCoupon = couponService.createFixedAmountCoupon( + user, + new BigDecimal("3000") + ); + + // Given: 주문 생성 요청 (수량 2개) + // 예상 계산: (12,000 * 2) - 3,000 = 21,000원 + OrderCreateCommand orderCommand = OrderCreateCommand.builder() + .username(userInfo.username()) + .orderItems(List.of( + OrderItemCommand.builder() + .productId(product.getId()) + .quantity(2) + .couponId(fixedCoupon.getId()) + .build() + )) + .build(); + + // When: 주문 생성 + OrderInfo result = orderFacade.createOrder(orderCommand); + + // Then: 포인트 차감 검증 + BigDecimal expectedOriginalAmount = new BigDecimal("24000"); // 12,000 * 2 + BigDecimal expectedDiscountAmount = new BigDecimal("3000"); // 쿠폰 할인 + BigDecimal expectedFinalAmount = new BigDecimal("21000"); // 24,000 - 3,000 + BigDecimal expectedRemainingPoints = initialPoints.subtract(expectedFinalAmount); // 30,000 - 21,000 = 9,000 + + // Then: 주문 금액 확인 + assertThat(result.finalTotalAmount()) + .as("쿠폰 할인이 적용된 최종 주문 금액") + .isEqualByComparingTo(expectedFinalAmount); + + // Then: 사용자의 남은 포인트 확인 + UserEntity updatedUser = userService.getUserByUsername(userInfo.username()); + assertThat(updatedUser.getPointAmount()) + .as("주문 후 남은 포인트 (초기 30,000 - 할인 후 금액 21,000)") + .isEqualByComparingTo(expectedRemainingPoints); + + // Then: 차감된 포인트가 할인 적용 후 금액과 일치하는지 확인 + BigDecimal deductedPoints = initialPoints.subtract(updatedUser.getPointAmount()); + assertThat(deductedPoints) + .as("실제 차감된 포인트는 쿠폰 할인 후 금액과 동일해야 함") + .isEqualByComparingTo(expectedFinalAmount); + + // Then: 원래 금액이 아닌 할인된 금액만큼만 차감되었는지 확인 + assertThat(deductedPoints) + .as("할인 전 금액(24,000)이 아닌 할인 후 금액(21,000)만 차감") + .isLessThan(expectedOriginalAmount) + .isEqualByComparingTo(expectedFinalAmount); + } + } + + @Nested + @DisplayName("배율 할인 쿠폰 주문") + class PercentageCouponOrder { + + @Test + @DisplayName("배율 할인 쿠폰을 사용하여 주문을 생성할 수 있다") + void should_create_order_with_percentage_coupon() { + // Given: 브랜드 생성 + BrandEntity brand = brandService.registerBrand( + BrandTestFixture.createRequest("테스트브랜드", "브랜드 설명") + ); + + // Given: 사용자 생성 및 포인트 충전 + UserRegisterCommand userCommand = UserTestFixture.createDefaultUserCommand(); + UserInfo userInfo = userFacade.registerUser(userCommand); + pointService.charge(userInfo.username(), new BigDecimal("20000")); + + // Given: 상품 생성 (단가 10,000원) + ProductDomainCreateRequest productRequest = ProductTestFixture.createRequest( + brand.getId(), + "테스트상품", + "상품 설명", + new BigDecimal("10000"), + 100 + ); + ProductEntity product = productService.registerProduct(productRequest); + + // Given: 배율 할인 쿠폰 생성 (20% 할인) + UserEntity user = userService.getUserByUsername(userInfo.username()); + CouponEntity percentageCoupon = couponService.createPercentCoupon( + user, + 20 + ); + + // Given: 주문 생성 요청 (수량 2개) + // 예상 계산: (10,000 * 2) * 0.8 = 16,000원 + OrderCreateCommand orderCommand = OrderCreateCommand.builder() + .username(userInfo.username()) + .orderItems(List.of( + OrderItemCommand.builder() + .productId(product.getId()) + .quantity(2) + .couponId(percentageCoupon.getId()) + .build() + )) + .build(); + + // When: 주문 생성 + OrderInfo result = orderFacade.createOrder(orderCommand); + + // Then: 주문이 정상적으로 생성되었는지 검증 + assertThat(result).isNotNull(); + assertThat(result.orderItems()).hasSize(1); + + // Then: 쿠폰이 사용됨 상태로 변경되었는지 검증 + CouponEntity usedCoupon = couponService.getCouponByIdAndUserId(percentageCoupon.getId(), user.getId()); + assertThat(usedCoupon.getStatus()).isEqualTo(CouponStatus.USED); + } + + @Test + @DisplayName("배율 쿠폰 할인 금액이 소수점 이하 반올림되어 계산된다") + void should_round_percentage_coupon_discount_correctly() { + // Given: 브랜드 생성 + BrandEntity brand = brandService.registerBrand( + BrandTestFixture.createRequest("테스트브랜드", "브랜드 설명") + ); + + // Given: 사용자 생성 및 포인트 충전 + UserRegisterCommand userCommand = UserTestFixture.createDefaultUserCommand(); + UserInfo userInfo = userFacade.registerUser(userCommand); + pointService.charge(userInfo.username(), new BigDecimal("50000")); + + // Given: 상품 생성 (단가 9,999원 - 소수점 발생하는 금액) + ProductDomainCreateRequest productRequest = ProductTestFixture.createRequest( + brand.getId(), + "테스트상품", + "상품 설명", + new BigDecimal("9999"), + 100 + ); + ProductEntity product = productService.registerProduct(productRequest); + + // Given: 배율 할인 쿠폰 생성 (15% 할인) + // 9,999 * 0.15 = 1,499.85 -> 반올림 필요 + UserEntity user = userService.getUserByUsername(userInfo.username()); + CouponEntity percentageCoupon = couponService.createPercentCoupon( + user, + 20 + ); + + // Given: 주문 생성 요청 (수량 1개) + OrderCreateCommand orderCommand = OrderCreateCommand.builder() + .username(userInfo.username()) + .orderItems(List.of( + OrderItemCommand.builder() + .productId(product.getId()) + .quantity(1) + .couponId(percentageCoupon.getId()) + .build() + )) + .build(); + + // When: 주문 생성 + OrderInfo result = orderFacade.createOrder(orderCommand); + + // Then: 할인 금액이 정수로 반올림되었는지 검증 + BigDecimal originalAmount = new BigDecimal("9999"); + BigDecimal expectedDiscount = new BigDecimal("2000"); // 9999 * 0.2 = 1999.8 -> 2000 (반올림) + BigDecimal expectedFinalAmount = originalAmount.subtract(expectedDiscount); // 8,499 + + assertThat(result.finalTotalAmount()) + .as("배율 쿠폰 할인 후 금액 (소수점 반올림 적용)") + .isEqualByComparingTo(expectedFinalAmount); + + // Then: 할인 금액에 소수점이 없는지 확인 + assertThat(result.finalTotalAmount().scale()) + .as("최종 금액은 소수점이 없어야 함") + .isLessThanOrEqualTo(2); + } + + @Test + @DisplayName("배율 쿠폰 적용 후 총 주문 금액이 정확하게 계산된다") + void should_calculate_total_amount_correctly_with_percentage_coupon() { + // Given: 브랜드 생성 + BrandEntity brand = brandService.registerBrand( + BrandTestFixture.createRequest("테스트브랜드", "브랜드 설명") + ); + + // Given: 사용자 생성 및 포인트 충전 + UserRegisterCommand userCommand = UserTestFixture.createDefaultUserCommand(); + UserInfo userInfo = userFacade.registerUser(userCommand); + BigDecimal initialPoints = new BigDecimal("100000"); + pointService.charge(userInfo.username(), initialPoints); + + // Given: 상품 생성 (단가 25,000원) + ProductDomainCreateRequest productRequest = ProductTestFixture.createRequest( + brand.getId(), + "테스트상품", + "상품 설명", + new BigDecimal("25000"), + 100 + ); + ProductEntity product = productService.registerProduct(productRequest); + + // Given: 배율 할인 쿠폰 생성 (30% 할인) + UserEntity user = userService.getUserByUsername(userInfo.username()); + CouponEntity percentageCoupon = couponService.createPercentCoupon( + user, + 30 + ); + + // Given: 주문 생성 요청 (수량 3개) + // 예상 계산: (25,000 * 3) * 0.7 = 52,500원 + OrderCreateCommand orderCommand = OrderCreateCommand.builder() + .username(userInfo.username()) + .orderItems(List.of( + OrderItemCommand.builder() + .productId(product.getId()) + .quantity(3) + .couponId(percentageCoupon.getId()) + .build() + )) + .build(); + + // When: 주문 생성 + OrderInfo result = orderFacade.createOrder(orderCommand); + + // Then: 총 주문 금액 검증 + BigDecimal expectedOriginalAmount = new BigDecimal("75000"); // 25,000 * 3 + BigDecimal expectedDiscountRate = new BigDecimal("0.30"); // 30% + BigDecimal expectedDiscountAmount = expectedOriginalAmount.multiply(expectedDiscountRate); // 22,500 + BigDecimal expectedFinalAmount = new BigDecimal("52500"); // 75,000 - 22,500 + + assertThat(result.finalTotalAmount()) + .as("배율 쿠폰 할인이 적용된 최종 주문 금액") + .isEqualByComparingTo(expectedFinalAmount); + + // Then: 주문 항목 검증 + assertThat(result.orderItems()).hasSize(1); + assertThat(result.orderItems().get(0).quantity()).isEqualTo(3); + assertThat(result.orderItems().get(0).unitPrice()) + .as("상품 단가") + .isEqualByComparingTo(new BigDecimal("25000")); + + // Then: 실제 할인율 검증 + BigDecimal itemTotal = result.orderItems().get(0).unitPrice() + .multiply(BigDecimal.valueOf(result.orderItems().get(0).quantity())); + BigDecimal actualDiscount = itemTotal.subtract(result.finalTotalAmount()); + + assertThat(actualDiscount) + .as("실제 적용된 할인 금액 (30%)") + .isEqualByComparingTo(expectedDiscountAmount); + + // Then: 포인트 차감 검증 + UserEntity updatedUser = userService.getUserByUsername(userInfo.username()); + BigDecimal expectedRemainingPoints = initialPoints.subtract(expectedFinalAmount); + + assertThat(updatedUser.getPointAmount()) + .as("주문 후 남은 포인트") + .isEqualByComparingTo(expectedRemainingPoints); + } + } + + @Nested + @DisplayName("쿠폰 사용 예외 처리") + class CouponUsageException { + + @Test + @DisplayName("이미 사용된 쿠폰으로 주문 시 예외가 발생한다") + void should_throw_exception_when_using_already_used_coupon() { + // Given: 브랜드 생성 + BrandEntity brand = brandService.registerBrand( + BrandTestFixture.createRequest("테스트브랜드", "브랜드 설명") + ); + + // Given: 사용자 생성 및 포인트 충전 + UserRegisterCommand userCommand = UserTestFixture.createDefaultUserCommand(); + UserInfo userInfo = userFacade.registerUser(userCommand); + pointService.charge(userInfo.username(), new BigDecimal("50000")); + + // Given: 상품 생성 + ProductDomainCreateRequest productRequest = ProductTestFixture.createRequest( + brand.getId(), + "테스트상품", + "상품 설명", + new BigDecimal("10000"), + 100 + ); + ProductEntity product = productService.registerProduct(productRequest); + + // Given: 쿠폰 생성 및 첫 번째 주문으로 사용 + UserEntity user = userService.getUserByUsername(userInfo.username()); + CouponEntity coupon = couponService.createFixedAmountCoupon( + user, + new BigDecimal("5000") + ); + + OrderCreateCommand firstOrderCommand = OrderCreateCommand.builder() + .username(userInfo.username()) + .orderItems(List.of( + OrderItemCommand.builder() + .productId(product.getId()) + .quantity(1) + .couponId(coupon.getId()) + .build() + )) + .build(); + orderFacade.createOrder(firstOrderCommand); + + // Given: 동일한 쿠폰으로 두 번째 주문 시도 + OrderCreateCommand secondOrderCommand = OrderCreateCommand.builder() + .username(userInfo.username()) + .orderItems(List.of( + OrderItemCommand.builder() + .productId(product.getId()) + .quantity(1) + .couponId(coupon.getId()) + .build() + )) + .build(); + + // When & Then: 이미 사용된 쿠폰으로 주문 시 예외 발생 + assertThatThrownBy(() -> orderFacade.createOrder(secondOrderCommand)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("이미 사용된 쿠폰입니다."); + } + + @Test + @DisplayName("다른 사용자의 쿠폰으로 주문 시 예외가 발생한다") + void should_throw_exception_when_using_other_users_coupon() { + // Given: 브랜드 생성 + BrandEntity brand = brandService.registerBrand( + BrandTestFixture.createRequest("테스트브랜드", "브랜드 설명") + ); + + // Given: 첫 번째 사용자 생성 및 쿠폰 발급 + UserRegisterCommand user1Command = UserTestFixture.createUserCommand( + "user1", + "user1@example.com", + "1990-01-01", + Gender.MALE + ); + UserInfo user1Info = userFacade.registerUser(user1Command); + UserEntity user1 = userService.getUserByUsername(user1Info.username()); + CouponEntity user1Coupon = couponService.createFixedAmountCoupon( + user1, + new BigDecimal("5000") + ); + + // Given: 두 번째 사용자 생성 및 포인트 충전 + UserRegisterCommand user2Command = UserTestFixture.createUserCommand( + "user2", + "user2@example.com", + "1990-01-01", + Gender.MALE + ); + UserInfo user2Info = userFacade.registerUser(user2Command); + pointService.charge(user2Info.username(), new BigDecimal("20000")); + + // Given: 상품 생성 + ProductDomainCreateRequest productRequest = ProductTestFixture.createRequest( + brand.getId(), + "테스트상품", + "상품 설명", + new BigDecimal("10000"), + 100 + ); + ProductEntity product = productService.registerProduct(productRequest); + + // Given: user2가 user1의 쿠폰으로 주문 시도 + OrderCreateCommand orderCommand = OrderCreateCommand.builder() + .username(user2Info.username()) + .orderItems(List.of( + OrderItemCommand.builder() + .productId(product.getId()) + .quantity(1) + .couponId(user1Coupon.getId()) + .build() + )) + .build(); + + // When & Then: 다른 사용자의 쿠폰 사용 시 쿠폰 자체를 찾을수 없다. + assertThatThrownBy(() -> orderFacade.createOrder(orderCommand)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("쿠폰을 찾을 수 없습니다"); + } + + @Test + @DisplayName("존재하지 않는 쿠폰 ID로 주문 시 예외가 발생한다") + void should_throw_exception_when_using_non_existent_coupon() { + // Given: 브랜드 생성 + BrandEntity brand = brandService.registerBrand( + BrandTestFixture.createRequest("테스트브랜드", "브랜드 설명") + ); + + // Given: 사용자 생성 및 포인트 충전 + UserRegisterCommand userCommand = UserTestFixture.createDefaultUserCommand(); + UserInfo userInfo = userFacade.registerUser(userCommand); + pointService.charge(userInfo.username(), new BigDecimal("20000")); + + // Given: 상품 생성 + ProductDomainCreateRequest productRequest = ProductTestFixture.createRequest( + brand.getId(), + "테스트상품", + "상품 설명", + new BigDecimal("10000"), + 100 + ); + ProductEntity product = productService.registerProduct(productRequest); + + // Given: 존재하지 않는 쿠폰 ID로 주문 시도 + Long nonExistentCouponId = 99999L; + OrderCreateCommand orderCommand = OrderCreateCommand.builder() + .username(userInfo.username()) + .orderItems(List.of( + OrderItemCommand.builder() + .productId(product.getId()) + .quantity(1) + .couponId(nonExistentCouponId) + .build() + )) + .build(); + + // When & Then: 존재하지 않는 쿠폰 ID 사용 시 예외 발생 + assertThatThrownBy(() -> orderFacade.createOrder(orderCommand)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("쿠폰을 찾을 수 없습니다"); + } + + @Test + @DisplayName("쿠폰 할인 후 포인트가 부족하면 주문이 실패한다") + void should_fail_order_when_insufficient_points_after_coupon_discount() { + // Given: 브랜드 생성 + BrandEntity brand = brandService.registerBrand( + BrandTestFixture.createRequest("테스트브랜드", "브랜드 설명") + ); + + // Given: 사용자 생성 및 부족한 포인트 충전 + UserRegisterCommand userCommand = UserTestFixture.createDefaultUserCommand(); + UserInfo userInfo = userFacade.registerUser(userCommand); + pointService.charge(userInfo.username(), new BigDecimal("5000")); // 부족한 포인트 + + // Given: 상품 생성 (단가 10,000원) + ProductDomainCreateRequest productRequest = ProductTestFixture.createRequest( + brand.getId(), + "테스트상품", + "상품 설명", + new BigDecimal("10000"), + 100 + ); + ProductEntity product = productService.registerProduct(productRequest); + + // Given: 정액 할인 쿠폰 생성 (2,000원 할인) + // 할인 후 금액: 10,000 - 2,000 = 8,000원 (보유 포인트 5,000원으로 부족) + UserEntity user = userService.getUserByUsername(userInfo.username()); + CouponEntity coupon = couponService.createFixedAmountCoupon( + user, + new BigDecimal("2000") + ); + + // Given: 주문 생성 요청 + OrderCreateCommand orderCommand = OrderCreateCommand.builder() + .username(userInfo.username()) + .orderItems(List.of( + OrderItemCommand.builder() + .productId(product.getId()) + .quantity(1) + .couponId(coupon.getId()) + .build() + )) + .build(); + + // When & Then: 쿠폰 할인 후에도 포인트 부족으로 주문 실패 + assertThatThrownBy(() -> orderFacade.createOrder(orderCommand)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("포인트가 부족합니다"); + + // Then: 쿠폰은 사용되지 않은 상태로 유지 + CouponEntity unusedCoupon = couponService.getCouponByIdAndUserId(coupon.getId(), user.getId()); + assertThat(unusedCoupon.getStatus()).isEqualTo(CouponStatus.UNUSED); + } + + @Test + @DisplayName("쿠폰 할인 후에도 재고가 부족하면 주문이 실패한다") + void should_fail_order_when_insufficient_stock_even_with_coupon() { + // Given: 브랜드 생성 + BrandEntity brand = brandService.registerBrand( + BrandTestFixture.createRequest("테스트브랜드", "브랜드 설명") + ); + + // Given: 사용자 생성 및 포인트 충전 + UserRegisterCommand userCommand = UserTestFixture.createDefaultUserCommand(); + UserInfo userInfo = userFacade.registerUser(userCommand); + pointService.charge(userInfo.username(), new BigDecimal("50000")); + + // Given: 재고가 부족한 상품 생성 (재고 5개) + ProductDomainCreateRequest productRequest = ProductTestFixture.createRequest( + brand.getId(), + "테스트상품", + "상품 설명", + new BigDecimal("10000"), + 5 // 재고 5개만 + ); + ProductEntity product = productService.registerProduct(productRequest); + + // Given: 쿠폰 생성 + UserEntity user = userService.getUserByUsername(userInfo.username()); + CouponEntity coupon = couponService.createFixedAmountCoupon( + user, + new BigDecimal("5000") + ); + + // Given: 재고보다 많은 수량으로 주문 시도 (10개 주문) + OrderCreateCommand orderCommand = OrderCreateCommand.builder() + .username(userInfo.username()) + .orderItems(List.of( + OrderItemCommand.builder() + .productId(product.getId()) + .quantity(10) // 재고(5개)보다 많은 수량 + .couponId(coupon.getId()) + .build() + )) + .build(); + + // When & Then: 쿠폰이 있어도 재고 부족으로 주문 실패 + assertThatThrownBy(() -> orderFacade.createOrder(orderCommand)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("주문할 수 없는 상품입니다."); + + // Then: 쿠폰은 사용되지 않은 상태로 유지 + CouponEntity unusedCoupon = couponService.getCouponByIdAndUserId(coupon.getId(), user.getId()); + assertThat(unusedCoupon.getStatus()).isEqualTo(CouponStatus.UNUSED); + + // Then: 상품 재고는 변경되지 않음 + ProductEntity unchangedProduct = productService.getActiveProductDetail(product.getId()); + assertThat(unchangedProduct.getStockQuantity()).isEqualTo(5); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/IntegrationTest/OrderItemManagementIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/IntegrationTest/OrderItemManagementIntegrationTest.java new file mode 100644 index 000000000..f74452b52 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/IntegrationTest/OrderItemManagementIntegrationTest.java @@ -0,0 +1,253 @@ +package com.loopers.domain.order.IntegrationTest; + +import static org.assertj.core.api.Assertions.*; +import java.math.BigDecimal; +import java.util.List; + +import org.junit.jupiter.api.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; + +import com.loopers.application.order.*; +import com.loopers.application.user.UserFacade; +import com.loopers.application.user.UserInfo; +import com.loopers.application.user.UserRegisterCommand; +import com.loopers.domain.brand.BrandEntity; +import com.loopers.domain.brand.BrandService; +import com.loopers.domain.point.PointService; +import com.loopers.domain.product.ProductEntity; +import com.loopers.domain.product.ProductService; +import com.loopers.fixtures.BrandTestFixture; +import com.loopers.fixtures.ProductTestFixture; +import com.loopers.fixtures.UserTestFixture; +import com.loopers.support.error.CoreException; +import com.loopers.utils.DatabaseCleanUp; +import com.loopers.utils.RedisCleanUp; + +/** + * @author hyunjikoh + * @since 2025. 11. 17. + */ +@SpringBootTest +public class OrderItemManagementIntegrationTest { + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @Autowired + private RedisCleanUp redisCleanUp; + + @Autowired + private OrderFacade orderFacade; + + @Autowired + private UserFacade userFacade; + + @Autowired + private ProductService productService; + + @Autowired + private BrandService brandService; + + @Autowired + private PointService pointService; + + @BeforeEach + void setUp() { + // 테스트 데이터는 각 테스트에서 필요한 만큼만 생성 + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + redisCleanUp.truncateAll(); + + } + + @Nested + @DisplayName("주문 항목 관리") + class OrderItemManagement { + + @Test + @DisplayName("주문 요약 목록 조회 시 주문 항목 정보는 포함하지 않고 항목 개수만 포함한다") + void should_not_include_order_items_when_retrieving_order_summary_list() { + // Given: 브랜드 생성 + BrandEntity brand = brandService.registerBrand( + BrandTestFixture.createRequest("테스트브랜드", "브랜드 설명") + ); + + // Given: 사용자 생성 및 포인트 충전 + UserRegisterCommand userCommand = UserTestFixture.createDefaultUserCommand(); + UserInfo userInfo = userFacade.registerUser(userCommand); + pointService.charge(userInfo.username(), new BigDecimal("100000")); + + // Given: 여러 상품 생성 + ProductEntity product1 = productService.registerProduct( + ProductTestFixture.createRequest(brand.getId(), "상품1", "설명1", new BigDecimal("10000"), 100) + ); + ProductEntity product2 = productService.registerProduct( + ProductTestFixture.createRequest(brand.getId(), "상품2", "설명2", new BigDecimal("20000"), 100) + ); + + // Given: 여러 항목을 포함한 주문 생성 + orderFacade.createOrder(OrderCreateCommand.builder() + .username(userInfo.username()) + .orderItems(List.of( + OrderItemCommand.builder().productId(product1.getId()).quantity(2).build(), + OrderItemCommand.builder().productId(product2.getId()).quantity(3).build() + )) + .build()); + + // When: 주문 요약 목록 페이징 조회 + Page orderSummaries = orderFacade.getOrderSummariesByUserId( + userInfo.id(), + PageRequest.of(0, 10) + ); + + // Then: 주문 항목 정보는 포함하지 않고 항목 개수만 포함 + assertThat(orderSummaries).isNotNull(); + assertThat(orderSummaries.getContent()).hasSize(1); + assertThat(orderSummaries.getTotalElements()).isEqualTo(1); + assertThat(orderSummaries.getContent().get(0).itemCount()).isEqualTo(2); + assertThat(orderSummaries.getContent().get(0).finalTotalAmount()).isEqualTo(new BigDecimal("80000.00")); + } + + @Test + @DisplayName("주문 ID로 주문 항목 목록을 페이징하여 조회할 수 있다") + void should_retrieve_order_items_with_pagination() { + // Given: 브랜드 생성 + BrandEntity brand = brandService.registerBrand( + BrandTestFixture.createRequest("테스트브랜드", "브랜드 설명") + ); + + // Given: 사용자 생성 및 포인트 충전 + UserRegisterCommand userCommand = UserTestFixture.createDefaultUserCommand(); + UserInfo userInfo = userFacade.registerUser(userCommand); + pointService.charge(userInfo.username(), new BigDecimal("500000")); + + // Given: 10개의 상품 생성 + List productIds = new java.util.ArrayList<>(); + for (int i = 1; i <= 10; i++) { + ProductEntity product = productService.registerProduct( + ProductTestFixture.createRequest( + brand.getId(), + "상품" + i, + "설명" + i, + new BigDecimal("10000"), + 100 + ) + ); + productIds.add(product.getId()); + } + + // Given: 10개 항목을 포함한 주문 생성 + List itemCommands = productIds.stream() + .map(productId -> OrderItemCommand.builder() + .productId(productId) + .quantity(1) + .build()) + .toList(); + + OrderInfo createdOrder = orderFacade.createOrder(OrderCreateCommand.builder() + .username(userInfo.username()) + .orderItems(itemCommands) + .build()); + + // When: 주문 항목을 페이지 크기 3으로 첫 번째 페이지 조회 + Page firstPage = + orderFacade.getOrderItemsByOrderId( + createdOrder.id(), + userInfo.username(), + PageRequest.of(0, 3) + ); + + // Then: 페이징 결과 검증 + assertThat(firstPage).isNotNull(); + assertThat(firstPage.getContent()).hasSize(3); + assertThat(firstPage.getTotalElements()).isEqualTo(10); + assertThat(firstPage.getTotalPages()).isEqualTo(4); + assertThat(firstPage.getNumber()).isEqualTo(0); + + // When: 두 번째 페이지 조회 + Page secondPage = + orderFacade.getOrderItemsByOrderId( + createdOrder.id(), + userInfo.username(), PageRequest.of(1, 3) + ); + + // Then: 두 번째 페이지 검증 + assertThat(secondPage.getContent()).hasSize(3); + assertThat(secondPage.getNumber()).isEqualTo(1); + } + + @Test + @DisplayName("주문 상세 조회 시에만 주문 항목 전체를 조회한다") + void should_retrieve_all_order_items_only_when_getting_order_detail() { + // Given: 브랜드 생성 + BrandEntity brand = brandService.registerBrand( + BrandTestFixture.createRequest("테스트브랜드", "브랜드 설명") + ); + + // Given: 사용자 생성 및 포인트 충전 + UserRegisterCommand userCommand = UserTestFixture.createDefaultUserCommand(); + UserInfo userInfo = userFacade.registerUser(userCommand); + pointService.charge(userInfo.username(), new BigDecimal("100000")); + + // Given: 여러 상품 생성 + ProductEntity product1 = productService.registerProduct( + ProductTestFixture.createRequest(brand.getId(), "상품1", "설명1", new BigDecimal("10000"), 100) + ); + ProductEntity product2 = productService.registerProduct( + ProductTestFixture.createRequest(brand.getId(), "상품2", "설명2", new BigDecimal("20000"), 100) + ); + ProductEntity product3 = productService.registerProduct( + ProductTestFixture.createRequest(brand.getId(), "상품3", "설명3", new BigDecimal("15000"), 100) + ); + + // Given: 여러 항목을 포함한 주문 생성 + OrderInfo createdOrder = orderFacade.createOrder(OrderCreateCommand.builder() + .username(userInfo.username()) + .orderItems(List.of( + OrderItemCommand.builder().productId(product1.getId()).quantity(2).build(), + OrderItemCommand.builder().productId(product2.getId()).quantity(1).build(), + OrderItemCommand.builder().productId(product3.getId()).quantity(3).build() + )) + .build()); + + // When: 주문 상세 조회 + OrderInfo orderDetail = orderFacade.getOrderById(userInfo.username(), createdOrder.id()); + + // Then: 주문 항목 전체가 조회됨 + assertThat(orderDetail.orderItems()).isNotNull(); + assertThat(orderDetail.orderItems()).hasSize(3); + assertThat(orderDetail.orderItems()) + .extracting("productId", "quantity") + .containsExactlyInAnyOrder( + tuple(product1.getId(), 2), + tuple(product2.getId(), 1), + tuple(product3.getId(), 3) + ); + } + + @Test + @DisplayName("존재하지 않는 주문의 주문 항목 조회 시 예외가 발생한다") + void should_throw_exception_when_retrieving_items_of_non_existent_order() { + // Given: 존재하지 않는 주문 ID + Long nonExistentOrderId = 99999L; + // Given: 사용자 생성 및 포인트 충전 + UserRegisterCommand userCommand = UserTestFixture.createDefaultUserCommand(); + UserInfo userInfo = userFacade.registerUser(userCommand); + pointService.charge(userInfo.username(), new BigDecimal("100000")); + + + // When & Then: 존재하지 않는 주문의 항목 조회 시 예외 발생 + assertThatThrownBy(() -> orderFacade.getOrderItemsByOrderId( + nonExistentOrderId, + userInfo.username(), PageRequest.of(0, 10) + )) + .isInstanceOf(CoreException.class) + .hasMessageContaining("주문을 찾을 수 없습니다"); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/IntegrationTest/OrderRetrievalIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/IntegrationTest/OrderRetrievalIntegrationTest.java new file mode 100644 index 000000000..519a76ee2 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/IntegrationTest/OrderRetrievalIntegrationTest.java @@ -0,0 +1,378 @@ +package com.loopers.domain.order.IntegrationTest; + +import static org.assertj.core.api.Assertions.*; +import java.math.BigDecimal; +import java.util.List; + +import org.junit.jupiter.api.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; + +import com.loopers.application.order.*; +import com.loopers.application.user.UserFacade; +import com.loopers.application.user.UserInfo; +import com.loopers.application.user.UserRegisterCommand; +import com.loopers.domain.brand.BrandEntity; +import com.loopers.domain.brand.BrandService; +import com.loopers.domain.order.OrderService; +import com.loopers.domain.order.OrderStatus; +import com.loopers.domain.point.PointService; +import com.loopers.domain.product.ProductEntity; +import com.loopers.domain.product.ProductService; +import com.loopers.fixtures.BrandTestFixture; +import com.loopers.fixtures.ProductTestFixture; +import com.loopers.fixtures.UserTestFixture; +import com.loopers.support.error.CoreException; +import com.loopers.utils.DatabaseCleanUp; +import com.loopers.utils.RedisCleanUp; + +/** + * @author hyunjikoh + * @since 2025. 11. 17. + */ +@SpringBootTest +public class OrderRetrievalIntegrationTest { + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @Autowired + private RedisCleanUp redisCleanUp; + + @Autowired + private OrderFacade orderFacade; + + + @Autowired + private UserFacade userFacade; + + @Autowired + private ProductService productService; + + @Autowired + private BrandService brandService; + + @Autowired + private PointService pointService; + @Autowired + private OrderService orderService; + + @BeforeEach + void setUp() { + // 테스트 데이터는 각 테스트에서 필요한 만큼만 생성 + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + redisCleanUp.truncateAll(); + + } + + @Nested + @DisplayName("주문 조회") + class OrderRetrieval { + + @Test + @DisplayName("주문 ID로 주문 요약 정보를 조회할 수 있다") + void should_retrieve_order_summary_by_id() { + // Given: 브랜드 생성 + BrandEntity brand = brandService.registerBrand( + BrandTestFixture.createRequest("테스트브랜드", "브랜드 설명") + ); + + // Given: 사용자 생성 및 포인트 충전 + UserRegisterCommand userCommand = UserTestFixture.createDefaultUserCommand(); + UserInfo userInfo = userFacade.registerUser(userCommand); + pointService.charge(userInfo.username(), new BigDecimal("50000")); + + // Given: 상품 생성 + ProductEntity product = productService.registerProduct( + ProductTestFixture.createRequest( + brand.getId(), + "테스트상품", + "상품 설명", + new BigDecimal("10000"), + 100 + ) + ); + + // Given: 주문 생성 + OrderCreateCommand orderCommand = OrderCreateCommand.builder() + .username(userInfo.username()) + .orderItems(List.of( + OrderItemCommand.builder() + .productId(product.getId()) + .quantity(2) + .build() + )) + .build(); + OrderInfo createdOrder = orderFacade.createOrder(orderCommand); + + // When: 주문 ID로 요약 정보 조회 + OrderSummary retrievedOrder = orderFacade.getOrderSummaryById(createdOrder.id(), userInfo.username()); + + // Then: 주문 요약 정보가 정확히 조회되는지 검증 + assertThat(retrievedOrder).isNotNull(); + assertThat(retrievedOrder.id()).isEqualTo(createdOrder.id()); + assertThat(retrievedOrder.userId()).isEqualTo(userInfo.id()); + assertThat(retrievedOrder.finalTotalAmount()).isEqualTo(new BigDecimal("20000.00")); + assertThat(retrievedOrder.status()).isEqualTo(OrderStatus.PENDING); + assertThat(retrievedOrder.itemCount()).isEqualTo(1); + } + + @Test + @DisplayName("사용자 ID로 주문 요약 목록을 페이징하여 조회할 수 있다") + void should_retrieve_order_summaries_by_user_id_with_pagination() { + // Given: 브랜드 생성 + BrandEntity brand = brandService.registerBrand( + BrandTestFixture.createRequest("테스트브랜드", "브랜드 설명") + ); + + // Given: 사용자 생성 및 포인트 충전 + UserRegisterCommand userCommand = UserTestFixture.createDefaultUserCommand(); + UserInfo userInfo = userFacade.registerUser(userCommand); + pointService.charge(userInfo.username(), new BigDecimal("100000")); + + // Given: 상품 생성 + ProductEntity product1 = productService.registerProduct( + ProductTestFixture.createRequest(brand.getId(), "상품1", "설명1", new BigDecimal("10000"), 100) + ); + ProductEntity product2 = productService.registerProduct( + ProductTestFixture.createRequest(brand.getId(), "상품2", "설명2", new BigDecimal("20000"), 100) + ); + + // Given: 여러 개의 주문 생성 + OrderInfo order1 = orderFacade.createOrder(OrderCreateCommand.builder() + .username(userInfo.username()) + .orderItems(List.of(OrderItemCommand.builder().productId(product1.getId()).quantity(1).build())) + .build()); + + OrderInfo order2 = orderFacade.createOrder(OrderCreateCommand.builder() + .username(userInfo.username()) + .orderItems(List.of(OrderItemCommand.builder().productId(product2.getId()).quantity(2).build())) + .build()); + + // When: 사용자 ID로 주문 요약 목록 페이징 조회 + Page orderPage = orderFacade.getOrderSummariesByUserId( + userInfo.id(), + PageRequest.of(0, 10) + ); + + // Then: 해당 사용자의 모든 주문이 조회되는지 검증 + assertThat(orderPage).isNotNull(); + assertThat(orderPage.getContent()).hasSize(2); + assertThat(orderPage.getTotalElements()).isEqualTo(2); + assertThat(orderPage.getContent()).extracting("id").containsExactlyInAnyOrder(order1.id(), order2.id()); + assertThat(orderPage.getContent()).allMatch(order -> order.userId().equals(userInfo.id())); + } + + @Test + @DisplayName("주문 조회 시 주문 항목들이 함께 조회된다") + void should_retrieve_order_with_order_items() { + // Given: 브랜드 생성 + BrandEntity brand = brandService.registerBrand( + BrandTestFixture.createRequest("테스트브랜드", "브랜드 설명") + ); + + // Given: 사용자 생성 및 포인트 충전 + UserRegisterCommand userCommand = UserTestFixture.createDefaultUserCommand(); + UserInfo userInfo = userFacade.registerUser(userCommand); + pointService.charge(userInfo.username(), new BigDecimal("100000")); + + // Given: 여러 상품 생성 + ProductEntity product1 = productService.registerProduct( + ProductTestFixture.createRequest(brand.getId(), "상품1", "설명1", new BigDecimal("10000"), 100) + ); + ProductEntity product2 = productService.registerProduct( + ProductTestFixture.createRequest(brand.getId(), "상품2", "설명2", new BigDecimal("20000"), 100) + ); + ProductEntity product3 = productService.registerProduct( + ProductTestFixture.createRequest(brand.getId(), "상품3", "설명3", new BigDecimal("15000"), 100) + ); + + // Given: 여러 항목을 포함한 주문 생성 + OrderCreateCommand orderCommand = OrderCreateCommand.builder() + .username(userInfo.username()) + .orderItems(List.of( + OrderItemCommand.builder().productId(product1.getId()).quantity(2).build(), + OrderItemCommand.builder().productId(product2.getId()).quantity(1).build(), + OrderItemCommand.builder().productId(product3.getId()).quantity(3).build() + )) + .build(); + OrderInfo createdOrder = orderFacade.createOrder(orderCommand); + + // When: 주문 조회 + OrderInfo retrievedOrder = orderFacade.getOrderById(userInfo.username(), createdOrder.id()); + + // Then: 주문 항목들이 함께 조회되는지 검증 + assertThat(retrievedOrder.orderItems()).isNotNull(); + assertThat(retrievedOrder.orderItems()).hasSize(3); + assertThat(retrievedOrder.orderItems()) + .extracting("productId", "quantity") + .containsExactlyInAnyOrder( + tuple(product1.getId(), 2), + tuple(product2.getId(), 1), + tuple(product3.getId(), 3) + ); + } + + @Test + @DisplayName("존재하지 않는 주문 조회 시 예외가 발생한다") + void should_throw_exception_when_retrieving_non_existent_order() { + // Given: 존재하지 않는 주문 ID + Long nonExistentOrderId = 99999L; + + // Given: 사용자 생성 및 포인트 충전 + UserRegisterCommand userCommand = UserTestFixture.createDefaultUserCommand(); + UserInfo userInfo = userFacade.registerUser(userCommand); + pointService.charge(userInfo.username(), new BigDecimal("100000")); + + // When & Then: 존재하지 않는 주문 조회 시 예외 발생 + assertThatThrownBy(() -> orderFacade.getOrderSummaryById(nonExistentOrderId, userInfo.username())) + .isInstanceOf(CoreException.class) + .hasMessageContaining("주문을 찾을 수 없습니다"); + } + + @Test + @DisplayName("삭제된 주문은 조회되지 않는다") + void should_not_retrieve_deleted_orders() { + // Given: 브랜드 생성 + BrandEntity brand = brandService.registerBrand( + BrandTestFixture.createRequest("테스트브랜드", "브랜드 설명") + ); + + // Given: 사용자 생성 및 포인트 충전 + UserRegisterCommand userCommand = UserTestFixture.createDefaultUserCommand(); + UserInfo userInfo = userFacade.registerUser(userCommand); + pointService.charge(userInfo.username(), new BigDecimal("50000")); + + // Given: 상품 생성 + ProductEntity product = productService.registerProduct( + ProductTestFixture.createRequest(brand.getId(), "테스트상품", "설명", new BigDecimal("10000"), 100) + ); + + // Given: 주문 생성 + OrderInfo createdOrder = orderFacade.createOrder(OrderCreateCommand.builder() + .username(userInfo.username()) + .orderItems(List.of(OrderItemCommand.builder().productId(product.getId()).quantity(1).build())) + .build()); + + // Given: 주문 삭제 + orderService.deleteOrder(createdOrder.id(), userInfo.username()); + + // When & Then: 삭제된 주문 조회 시 예외 발생 + assertThatThrownBy(() -> orderFacade.getOrderSummaryById(createdOrder.id(), userInfo.username())) + .isInstanceOf(Exception.class) + .hasMessageContaining("주문을 찾을 수 없습니다"); + } + + @Test + @DisplayName("사용자의 주문 요약 목록을 페이징하여 조회할 수 있다") + void should_retrieve_user_order_summaries_with_pagination() { + // Given: 브랜드 생성 + BrandEntity brand = brandService.registerBrand( + BrandTestFixture.createRequest("테스트브랜드", "브랜드 설명") + ); + + // Given: 사용자 생성 및 포인트 충전 + UserRegisterCommand userCommand = UserTestFixture.createDefaultUserCommand(); + UserInfo userInfo = userFacade.registerUser(userCommand); + pointService.charge(userInfo.username(), new BigDecimal("500000")); + + // Given: 상품 생성 + ProductEntity product = productService.registerProduct( + ProductTestFixture.createRequest(brand.getId(), "테스트상품", "설명", new BigDecimal("10000"), 1000) + ); + + // Given: 5개의 주문 생성 + for (int i = 0; i < 5; i++) { + orderFacade.createOrder(OrderCreateCommand.builder() + .username(userInfo.username()) + .orderItems(List.of(OrderItemCommand.builder().productId(product.getId()).quantity(1).build())) + .build()); + } + + // When: 페이지 크기 2로 첫 번째 페이지 조회 + Page firstPage = orderFacade.getOrderSummariesByUserId( + userInfo.id(), + PageRequest.of(0, 2) + ); + + // Then: 페이징 결과 검증 + assertThat(firstPage).isNotNull(); + assertThat(firstPage.getContent()).hasSize(2); + assertThat(firstPage.getTotalElements()).isEqualTo(5); + assertThat(firstPage.getTotalPages()).isEqualTo(3); + assertThat(firstPage.getNumber()).isEqualTo(0); + assertThat(firstPage.getContent()).allMatch(summary -> summary.itemCount() == 1); + } + + @Test + @DisplayName("주문 상태별로 필터링하여 페이징 조회할 수 있다") + void should_filter_orders_by_status_with_pagination() { + // Given: 브랜드 생성 + BrandEntity brand = brandService.registerBrand( + BrandTestFixture.createRequest("테스트브랜드", "브랜드 설명") + ); + + // Given: 사용자 생성 및 포인트 충전 + UserRegisterCommand userCommand = UserTestFixture.createDefaultUserCommand(); + UserInfo userInfo = userFacade.registerUser(userCommand); + pointService.charge(userInfo.username(), new BigDecimal("100000")); + + // Given: 상품 생성 + ProductEntity product = productService.registerProduct( + ProductTestFixture.createRequest(brand.getId(), "테스트상품", "설명", new BigDecimal("10000"), 100) + ); + + // Given: 여러 주문 생성 (일부는 확정) + OrderInfo order1 = orderFacade.createOrder(OrderCreateCommand.builder() + .username(userInfo.username()) + .orderItems(List.of(OrderItemCommand.builder().productId(product.getId()).quantity(1).build())) + .build()); + + OrderInfo order2 = orderFacade.createOrder(OrderCreateCommand.builder() + .username(userInfo.username()) + .orderItems(List.of(OrderItemCommand.builder().productId(product.getId()).quantity(1).build())) + .build()); + + OrderInfo order3 = orderFacade.createOrder(OrderCreateCommand.builder() + .username(userInfo.username()) + .orderItems(List.of(OrderItemCommand.builder().productId(product.getId()).quantity(1).build())) + .build()); + + // Given: 일부 주문 확정 + orderFacade.confirmOrder(order1.id(), userInfo.username()); + + // When: CONFIRMED 상태의 주문만 페이징 조회 + Page confirmedOrders = orderFacade.getOrderSummariesByUserIdAndStatus( + userInfo.id(), + OrderStatus.CONFIRMED, + PageRequest.of(0, 10) + ); + + // Then: CONFIRMED 상태의 주문만 조회되는지 검증 + assertThat(confirmedOrders).isNotNull(); + assertThat(confirmedOrders.getContent()).hasSize(1); + assertThat(confirmedOrders.getTotalElements()).isEqualTo(1); + assertThat(confirmedOrders.getContent()).allMatch(order -> order.status() == OrderStatus.CONFIRMED); + assertThat(confirmedOrders.getContent()).extracting("id").containsExactlyInAnyOrder(order1.id()); + + // When: PENDING 상태의 주문만 페이징 조회 + Page pendingOrders = orderFacade.getOrderSummariesByUserIdAndStatus( + userInfo.id(), + OrderStatus.PENDING, + PageRequest.of(0, 10) + ); + + // Then: PENDING 상태의 주문만 조회되는지 검증 + assertThat(pendingOrders).isNotNull(); + assertThat(pendingOrders.getContent()).hasSize(2); + assertThat(pendingOrders.getTotalElements()).isEqualTo(2); + assertThat(pendingOrders.getContent().get(0).id()).isEqualTo(order2.id()); + assertThat(pendingOrders.getContent().get(0).status()).isEqualTo(OrderStatus.PENDING); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/IntegrationTest/TransactionAndConcurrencyIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/IntegrationTest/TransactionAndConcurrencyIntegrationTest.java new file mode 100644 index 000000000..b75cb65e9 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/IntegrationTest/TransactionAndConcurrencyIntegrationTest.java @@ -0,0 +1,1116 @@ +package com.loopers.domain.order.IntegrationTest; + +import static java.util.concurrent.Executors.newFixedThreadPool; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import org.junit.jupiter.api.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import com.loopers.application.order.OrderCreateCommand; +import com.loopers.application.order.OrderFacade; +import com.loopers.application.order.OrderInfo; +import com.loopers.application.order.OrderItemCommand; +import com.loopers.application.user.UserFacade; +import com.loopers.application.user.UserInfo; +import com.loopers.application.user.UserRegisterCommand; +import com.loopers.domain.brand.BrandEntity; +import com.loopers.domain.brand.BrandService; +import com.loopers.domain.order.OrderStatus; +import com.loopers.domain.point.PointService; +import com.loopers.domain.product.ProductEntity; +import com.loopers.domain.product.ProductService; +import com.loopers.domain.user.Gender; +import com.loopers.domain.user.UserEntity; +import com.loopers.domain.user.UserRepository; +import com.loopers.fixtures.BrandTestFixture; +import com.loopers.fixtures.ProductTestFixture; +import com.loopers.fixtures.UserTestFixture; +import com.loopers.utils.DatabaseCleanUp; +import com.loopers.utils.RedisCleanUp; + +/** + * @author hyunjikoh + * @since 2025. 11. 17. + */ +@SpringBootTest +public class TransactionAndConcurrencyIntegrationTest { + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @Autowired + private RedisCleanUp redisCleanUp; + + @Autowired + private OrderFacade orderFacade; + + + @Autowired + private UserRepository userRepository; + + @Autowired + private UserFacade userFacade; + + @Autowired + private ProductService productService; + + @Autowired + private BrandService brandService; + + @Autowired + private PointService pointService; + + @BeforeEach + void setUp() { + // 테스트 데이터는 각 테스트에서 필요한 만큼만 생성 + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + redisCleanUp.truncateAll(); + + } + + @Nested + @DisplayName("트랜잭션 및 동시성") + class TransactionAndConcurrency { + + @Test + @DisplayName("재고 예약 실패 시 이미 예약된 재고가 해제된다") + void should_release_reserved_stock_when_subsequent_reservation_fails() { + // Given: 브랜드 생성 + BrandEntity brand = brandService.registerBrand( + BrandTestFixture.createRequest("테스트브랜드", "브랜드 설명") + ); + + // Given: 사용자 생성 및 포인트 충전 + UserRegisterCommand userCommand = UserTestFixture.createDefaultUserCommand(); + UserInfo userInfo = userFacade.registerUser(userCommand); + pointService.charge(userInfo.username(), new BigDecimal("100000")); + + // Given: 두 개의 상품 생성 (하나는 재고 부족) + ProductEntity product1 = productService.registerProduct( + ProductTestFixture.createRequest( + brand.getId(), + "정상상품", + "재고가 충분한 상품", + new BigDecimal("10000"), + 100 + ) + ); + + ProductEntity product2 = productService.registerProduct( + ProductTestFixture.createRequest( + brand.getId(), + "재고부족상품", + "재고가 부족한 상품", + new BigDecimal("20000"), + 5 // 재고 5개 + ) + ); + + // Given: 첫 번째 상품은 정상, 두 번째 상품은 재고 부족으로 주문 생성 요청 + OrderCreateCommand orderCommand = OrderCreateCommand.builder() + .username(userInfo.username()) + .orderItems(List.of( + OrderItemCommand.builder() + .productId(product1.getId()) + .quantity(10) // 정상 주문 + .build(), + OrderItemCommand.builder() + .productId(product2.getId()) + .quantity(10) // 재고 부족 (5개만 있음) + .build() + )) + .build(); + + // When & Then: 주문 생성 실패 + assertThatThrownBy(() -> orderFacade.createOrder(orderCommand)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("주문할 수 없는 상품입니다."); + + // Then: 첫 번째 상품의 재고가 원래대로 복구되었는지 확인 + ProductEntity product1AfterFail = productService.getActiveProductDetail(product1.getId()); + assertThat(product1AfterFail.getStockQuantity()).isEqualTo(100); + + // Then: 두 번째 상품의 재고도 변경되지 않았는지 확인 + ProductEntity product2AfterFail = productService.getActiveProductDetail(product2.getId()); + assertThat(product2AfterFail.getStockQuantity()).isEqualTo(5); + } + + @Test + @DisplayName("포인트 차감 실패 시 예약된 모든 재고가 해제된다") + void should_release_all_reserved_stock_when_point_deduction_fails() { + // Given: 브랜드 생성 + BrandEntity brand = brandService.registerBrand( + BrandTestFixture.createRequest("테스트브랜드", "브랜드 설명") + ); + + // Given: 사용자 생성 및 부족한 포인트 충전 + UserRegisterCommand userCommand = UserTestFixture.createDefaultUserCommand(); + UserInfo userInfo = userFacade.registerUser(userCommand); + pointService.charge(userInfo.username(), new BigDecimal("5000")); // 부족한 포인트 + + // Given: 여러 상품 생성 + ProductEntity product1 = productService.registerProduct( + ProductTestFixture.createRequest( + brand.getId(), + "상품1", + "설명1", + new BigDecimal("10000"), + 100 + ) + ); + + ProductEntity product2 = productService.registerProduct( + ProductTestFixture.createRequest( + brand.getId(), + "상품2", + "설명2", + new BigDecimal("20000"), + 50 + ) + ); + + ProductEntity product3 = productService.registerProduct( + ProductTestFixture.createRequest( + brand.getId(), + "상품3", + "설명3", + new BigDecimal("15000"), + 30 + ) + ); + + // Given: 포인트가 부족한 주문 생성 요청 + OrderCreateCommand orderCommand = OrderCreateCommand.builder() + .username(userInfo.username()) + .orderItems(List.of( + OrderItemCommand.builder().productId(product1.getId()).quantity(2).build(), + OrderItemCommand.builder().productId(product2.getId()).quantity(1).build(), + OrderItemCommand.builder().productId(product3.getId()).quantity(3).build() + )) + .build(); + + // When & Then: 포인트 부족으로 주문 생성 실패 + assertThatThrownBy(() -> orderFacade.createOrder(orderCommand)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("포인트가 부족합니다"); + + // Then: 모든 상품의 재고가 원래대로 복구되었는지 확인 + ProductEntity product1AfterFail = productService.getActiveProductDetail(product1.getId()); + assertThat(product1AfterFail.getStockQuantity()).isEqualTo(100); + + ProductEntity product2AfterFail = productService.getActiveProductDetail(product2.getId()); + assertThat(product2AfterFail.getStockQuantity()).isEqualTo(50); + + ProductEntity product3AfterFail = productService.getActiveProductDetail(product3.getId()); + assertThat(product3AfterFail.getStockQuantity()).isEqualTo(30); + + // Then: 사용자 포인트도 차감되지 않았는지 확인 + UserEntity user = userRepository.findByUsername(userInfo.username()) + .orElseThrow(); + assertThat(user.getPointAmount()).isEqualTo(new BigDecimal("5000.00")); + } + + @Test + @DisplayName("주문 생성 실패 시 포인트 복구 및 재고 해제가 수행된다") + void should_restore_points_and_release_stock_when_order_creation_fails() { + // Given: 브랜드 생성 + BrandEntity brand = brandService.registerBrand( + BrandTestFixture.createRequest("테스트브랜드", "브랜드 설명") + ); + + // Given: 사용자 생성 및 포인트 충전 + UserRegisterCommand userCommand = UserTestFixture.createDefaultUserCommand(); + UserInfo userInfo = userFacade.registerUser(userCommand); + BigDecimal initialPoints = new BigDecimal("100000.00"); + pointService.charge(userInfo.username(), initialPoints); + + // Given: 상품 생성 + ProductEntity product = productService.registerProduct( + ProductTestFixture.createRequest( + brand.getId(), + "테스트상품", + "상품 설명", + new BigDecimal("10000"), + 100 + ) + ); + + Integer initialStock = product.getStockQuantity(); + + // Given: 유효하지 않은 주문 생성 요청 (수량이 0) + OrderCreateCommand invalidOrderCommand = OrderCreateCommand.builder() + .username(userInfo.username()) + .orderItems(List.of( + OrderItemCommand.builder() + .productId(product.getId()) + .quantity(0) // 유효하지 않은 수량 + .build() + )) + .build(); + + // When & Then: 주문 생성 실패 + assertThatThrownBy(() -> orderFacade.createOrder(invalidOrderCommand)) + .isInstanceOf(IllegalArgumentException.class); + + // Then: 재고가 원래대로 복구되었는지 확인 + ProductEntity productAfterFail = productService.getActiveProductDetail(product.getId()); + assertThat(productAfterFail.getStockQuantity()).isEqualTo(initialStock); + + // Then: 포인트가 차감되지 않았는지 확인 + UserEntity user = userRepository.findByUsername(userInfo.username()) + .orElseThrow(); + assertThat(user.getPointAmount()).isEqualTo(initialPoints); + } + + @Test + @DisplayName("동시에 같은 상품을 주문할 때 재고 차감이 정확하게 발생한다") + void should_deduct_stock_accurately_when_ordering_same_product_concurrently() throws InterruptedException { + // Given: 브랜드 생성 + BrandEntity brand = brandService.registerBrand( + BrandTestFixture.createRequest("테스트브랜드", "브랜드 설명") + ); + + // Given: 여러 사용자 생성 및 포인트 충전 + List users = new ArrayList<>(); + for (int i = 0; i < 10; i++) { + UserRegisterCommand userCommand = new UserRegisterCommand( + "user" + i, + "user" + i + "@test.com", + LocalDate.of(1990, 1, 1).toString(), + Gender.MALE + ); + UserInfo userInfo = userFacade.registerUser(userCommand); + pointService.charge(userInfo.username(), new BigDecimal("50000")); + users.add(userInfo); + } + + // Given: 재고가 충분한 상품 생성 + int initialStock = 50; + ProductEntity product = productService.registerProduct( + ProductTestFixture.createRequest( + brand.getId(), + "인기상품", + "동시성 테스트용 상품", + new BigDecimal("10000"), + initialStock + ) + ); + + // Given: 동시 주문 설정 + int threadCount = 10; + int orderQuantityPerUser = 5; + AtomicInteger successCount; + AtomicInteger failCount; + ExecutorService executorService = newFixedThreadPool(threadCount); + try { + CountDownLatch latch = new CountDownLatch(threadCount); + successCount = new AtomicInteger(0); + failCount = new AtomicInteger(0); + + // When: 10명의 사용자가 동시에 각각 5개씩 주문 시도 + for (int i = 0; i < threadCount; i++) { + final int userIndex = i; + executorService.submit(() -> { + try { + OrderCreateCommand orderCommand = OrderCreateCommand.builder() + .username(users.get(userIndex).username()) + .orderItems(List.of( + OrderItemCommand.builder() + .productId(product.getId()) + .quantity(orderQuantityPerUser) + .build() + )) + .build(); + + orderFacade.createOrder(orderCommand); + successCount.incrementAndGet(); + } catch (Exception e) { + failCount.incrementAndGet(); + } finally { + latch.countDown(); + } + }); + } + + // Then: 모든 스레드 완료 대기 + latch.await(30, TimeUnit.SECONDS); + + } finally { + executorService.shutdown(); + } + + // Then: 재고가 충분하므로 모든 주문이 성공해야 함 + assertThat(successCount.get()).isEqualTo(threadCount); + assertThat(failCount.get()).isZero(); + + // Then: 최종 재고가 정확히 차감되었는지 검증 (동시성 제어 완벽) + ProductEntity finalProduct = productService.getActiveProductDetail(product.getId()); + int expectedFinalStock = 0; + + // 엄격한 검증: 정확히 일치해야 함 + assertThat(finalProduct.getStockQuantity()) + .as("비관적 락으로 동시성이 보호되므로 재고는 정확히 일치해야 함") + .isEqualTo(expectedFinalStock); + + // 추가 검증: Oversell 절대 발생하지 않음 + assertThat(finalProduct.getStockQuantity()) + .as("재고는 절대 음수가 될 수 없음") + .isGreaterThanOrEqualTo(0); + } + + @Test + @DisplayName("동시에 같은 상품을 주문할 때 재고 부족 시 일부만 성공한다") + void should_succeed_partially_when_stock_insufficient_during_concurrent_orders() throws InterruptedException { + // Given: 브랜드 생성 + BrandEntity brand = brandService.registerBrand( + BrandTestFixture.createRequest("테스트브랜드", "브랜드 설명") + ); + + // Given: 여러 사용자 생성 및 포인트 충전 + List users = new ArrayList<>(); + for (int i = 0; i < 10; i++) { + UserRegisterCommand userCommand = new UserRegisterCommand( + "user" + i, + "user" + i + "@test.com", + LocalDate.of(1990, 1, 1).toString(), + Gender.MALE + ); + UserInfo userInfo = userFacade.registerUser(userCommand); + pointService.charge(userInfo.username(), new BigDecimal("100000")); + users.add(userInfo); + } + + // Given: 재고가 부족한 상품 생성 + Integer initialStock = 25; // 10명이 5개씩 주문하면 부족 (50개 필요) + ProductEntity product = productService.registerProduct( + ProductTestFixture.createRequest( + brand.getId(), + "한정상품", + "재고가 부족한 한정 상품", + new BigDecimal("10000"), + initialStock + ) + ); + + // Given: 동시 주문 설정 + int threadCount = 10; + int orderQuantityPerUser = 5; + AtomicInteger successCount; + AtomicInteger failCount; + ExecutorService executorService = newFixedThreadPool(threadCount); + try { + CountDownLatch latch = new CountDownLatch(threadCount); + successCount = new AtomicInteger(0); + failCount = new AtomicInteger(0); + + // When: 10명의 사용자가 동시에 각각 5개씩 주문 시도 + for (int i = 0; i < threadCount; i++) { + final int userIndex = i; + executorService.submit(() -> { + try { + OrderCreateCommand orderCommand = OrderCreateCommand.builder() + .username(users.get(userIndex).username()) + .orderItems(List.of( + OrderItemCommand.builder() + .productId(product.getId()) + .quantity(orderQuantityPerUser) + .build() + )) + .build(); + + orderFacade.createOrder(orderCommand); + successCount.incrementAndGet(); + } catch (Exception e) { + failCount.incrementAndGet(); + } finally { + latch.countDown(); + } + }); + } + + // Then: 모든 스레드 완료 대기 + latch.await(30, TimeUnit.SECONDS); + } finally { + executorService.shutdown(); + } + + // Then: 일부 주문만 성공했는지 확인 (재고 부족으로 모두 성공할 수 없음) + assertThat(successCount.get()).isLessThan(threadCount); + assertThat(failCount.get()).isGreaterThan(0); + + // Then: 성공 + 실패 = 전체 시도 수 + assertThat(successCount.get() + failCount.get()).isEqualTo(threadCount); + + ProductEntity finalProduct = productService.getActiveProductDetail(product.getId()); + assertThat(finalProduct.getStockQuantity()) + .as("재고는 절대 음수가 될 수 없음 (Oversell 방지)") + .isGreaterThanOrEqualTo(0) + .isLessThan(initialStock); + + // 선택: 성공한 주문 수와 차감된 재고 일치 여부까지 검증 + int deductedStock = initialStock - finalProduct.getStockQuantity(); + int expectedDeductedStock = successCount.get() * orderQuantityPerUser; + assertThat(deductedStock) + .as("차감된 재고는 성공한 주문 수량과 정확히 일치해야 함") + .isEqualTo(expectedDeductedStock); + } + + @Test + @DisplayName("주문 취소 시 트랜잭션이 롤백되면 재고와 포인트가 원상태로 유지된다") + void should_maintain_stock_and_points_when_cancel_transaction_rollback() { + // Given: 브랜드 생성 + BrandEntity brand = brandService.registerBrand( + BrandTestFixture.createRequest("테스트브랜드", "브랜드 설명") + ); + + // Given: 사용자 생성 및 포인트 충전 + UserRegisterCommand userCommand = UserTestFixture.createDefaultUserCommand(); + UserInfo userInfo = userFacade.registerUser(userCommand); + BigDecimal initialPoints = new BigDecimal("50000.00"); + pointService.charge(userInfo.username(), initialPoints); + + // Given: 상품 생성 + Integer initialStock = 100; + ProductEntity product = productService.registerProduct( + ProductTestFixture.createRequest( + brand.getId(), + "테스트상품", + "상품 설명", + new BigDecimal("10000"), + initialStock + ) + ); + + // Given: 주문 생성 + Integer orderQuantity = 5; + OrderCreateCommand orderCommand = OrderCreateCommand.builder() + .username(userInfo.username()) + .orderItems(List.of( + OrderItemCommand.builder() + .productId(product.getId()) + .quantity(orderQuantity) + .build() + )) + .build(); + + OrderInfo createdOrder = orderFacade.createOrder(orderCommand); + + // Given: 주문 생성 후 상태 확인 + ProductEntity productAfterOrder = productService.getActiveProductDetail(product.getId()); + assertThat(productAfterOrder.getStockQuantity()).isEqualTo(initialStock - orderQuantity); + + UserEntity userAfterOrder = userRepository.findByUsername(userInfo.username()) + .orElseThrow(); + BigDecimal pointsAfterOrder = userAfterOrder.getPointAmount(); + assertThat(pointsAfterOrder).isLessThan(initialPoints); + + // When: 주문 취소 + OrderInfo cancelledOrder = orderFacade.cancelOrder(createdOrder.id(), userInfo.username()); + + // Then: 재고 원복 확인 + ProductEntity productAfterCancel = productService.getActiveProductDetail(product.getId()); + assertThat(productAfterCancel.getStockQuantity()).isEqualTo(initialStock); + + // Then: 포인트 환불 확인 + UserEntity userAfterCancel = userRepository.findByUsername(userInfo.username()) + .orElseThrow(); + assertThat(userAfterCancel.getPointAmount()).isEqualTo(initialPoints); + + // Then: 주문 상태 확인 + assertThat(cancelledOrder.status()).isEqualTo(OrderStatus.CANCELLED); + } + } + + @Nested + @DisplayName("재고 동시성 제어") + class StockConcurrencyControl { + + @Test + @DisplayName("동일한 상품에 대해 여러 주문이 동시에 요청되어도 재고가 정상적으로 차감된다") + void should_deduct_stock_correctly_with_concurrent_orders() throws InterruptedException { + // Given: 브랜드 생성 + BrandEntity brand = brandService.registerBrand( + BrandTestFixture.createRequest("테스트브랜드", "브랜드 설명") + ); + + // Given: 여러 사용자 생성 및 포인트 충전 + List users = new ArrayList<>(); + for (int i = 0; i < 20; i++) { + UserRegisterCommand userCommand = new UserRegisterCommand( + "user" + i, + "user" + i + "@test.com", + LocalDate.of(1990, 1, 1).toString(), + Gender.MALE + ); + UserInfo userInfo = userFacade.registerUser(userCommand); + pointService.charge(userInfo.username(), new BigDecimal("100000")); + users.add(userInfo); + } + + // Given: 재고가 제한된 상품 생성 + int initialStock = 100; + ProductEntity product = productService.registerProduct( + ProductTestFixture.createRequest( + brand.getId(), + "한정판상품", + "재고 동시성 테스트용 상품", + new BigDecimal("5000"), + initialStock + ) + ); + + // Given: 동시 주문 설정 + int threadCount = 20; + int orderQuantityPerUser = 3; + AtomicInteger successCount; + AtomicInteger failCount; + ExecutorService executorService = newFixedThreadPool(threadCount); + try { + CountDownLatch latch = new CountDownLatch(threadCount); + successCount = new AtomicInteger(0); + failCount = new AtomicInteger(0); + + // When: 20명의 사용자가 동시에 각각 3개씩 주문 시도 (총 60개 필요, 재고 100개) + for (int i = 0; i < threadCount; i++) { + final int userIndex = i; + executorService.submit(() -> { + try { + OrderCreateCommand orderCommand = OrderCreateCommand.builder() + .username(users.get(userIndex).username()) + .orderItems(List.of( + OrderItemCommand.builder() + .productId(product.getId()) + .quantity(orderQuantityPerUser) + .build() + )) + .build(); + + orderFacade.createOrder(orderCommand); + successCount.incrementAndGet(); + } catch (Exception e) { + failCount.incrementAndGet(); + } finally { + latch.countDown(); + } + }); + } + + // Then: 모든 스레드 완료 대기 + latch.await(30, TimeUnit.SECONDS); + } finally { + executorService.shutdown(); + } + + // Then: 재고가 충분하므로 모든 주문이 성공해야 함 + assertThat(successCount.get()).isEqualTo(threadCount); + assertThat(failCount.get()).isZero(); + + // Then: 최종 재고가 정확히 차감되었는지 검증 + ProductEntity finalProduct = productService.getActiveProductDetail(product.getId()); + int expectedFinalStock = initialStock - (threadCount * orderQuantityPerUser); + assertThat(finalProduct.getStockQuantity()) + .as("동시성 제어로 재고는 정확히 차감되어야 함") + .isEqualTo(expectedFinalStock); + + // Then: Oversell이 발생하지 않았는지 확인 + assertThat(finalProduct.getStockQuantity()) + .as("재고는 절대 음수가 될 수 없음") + .isGreaterThanOrEqualTo(0); + } + + @Test + @DisplayName("재고 부족 시 동시 주문 중 일부만 성공하고 Oversell이 발생하지 않는다") + void should_prevent_oversell_when_concurrent_orders_exceed_stock() throws InterruptedException { + // Given: 브랜드 생성 + BrandEntity brand = brandService.registerBrand( + BrandTestFixture.createRequest("테스트브랜드", "브랜드 설명") + ); + + // Given: 여러 사용자 생성 및 포인트 충전 + List users = new ArrayList<>(); + for (int i = 0; i < 30; i++) { + UserRegisterCommand userCommand = new UserRegisterCommand( + "user" + i, + "user" + i + "@test.com", + LocalDate.of(1990, 1, 1).toString(), + Gender.FEMALE + ); + UserInfo userInfo = userFacade.registerUser(userCommand); + pointService.charge(userInfo.username(), new BigDecimal("100000")); + users.add(userInfo); + } + + // Given: 재고가 부족한 상품 생성 + Integer initialStock = 50; + ProductEntity product = productService.registerProduct( + ProductTestFixture.createRequest( + brand.getId(), + "초한정상품", + "Oversell 방지 테스트용 상품", + new BigDecimal("8000"), + initialStock + ) + ); + + // Given: 동시 주문 설정 + int threadCount = 30; + int orderQuantityPerUser = 5; // 총 150개 필요하지만 재고는 50개 + AtomicInteger successCount; + AtomicInteger failCount; + ExecutorService executorService = newFixedThreadPool(threadCount); + try { + CountDownLatch latch = new CountDownLatch(threadCount); + successCount = new AtomicInteger(0); + failCount = new AtomicInteger(0); + + // When: 30명의 사용자가 동시에 각각 5개씩 주문 시도 + for (int i = 0; i < threadCount; i++) { + final int userIndex = i; + executorService.submit(() -> { + try { + OrderCreateCommand orderCommand = OrderCreateCommand.builder() + .username(users.get(userIndex).username()) + .orderItems(List.of( + OrderItemCommand.builder() + .productId(product.getId()) + .quantity(orderQuantityPerUser) + .build() + )) + .build(); + + orderFacade.createOrder(orderCommand); + successCount.incrementAndGet(); + } catch (Exception e) { + failCount.incrementAndGet(); + } finally { + latch.countDown(); + } + }); + } + + // Then: 모든 스레드 완료 대기 + latch.await(30, TimeUnit.SECONDS); + } finally { + executorService.shutdown(); + } + + // Then: 재고 부족으로 일부만 성공해야 함 + int maxPossibleOrders = initialStock / orderQuantityPerUser; + assertThat(successCount.get()) + .as("재고 50개로 5개씩 주문 시 최대 10건만 성공해야 함") + .isLessThanOrEqualTo(maxPossibleOrders); + assertThat(failCount.get()).isGreaterThan(0); + + // Then: 최종 재고 확인 - Oversell 절대 발생하지 않음 + ProductEntity finalProduct = productService.getActiveProductDetail(product.getId()); + assertThat(finalProduct.getStockQuantity()) + .as("재고는 절대 음수가 될 수 없음 (Oversell 방지)") + .isGreaterThanOrEqualTo(0); + + // Then: 성공한 주문 수와 차감된 재고가 일치하는지 확인 + int deductedStock = initialStock - finalProduct.getStockQuantity(); + int expectedDeductedStock = successCount.get() * orderQuantityPerUser; + assertThat(deductedStock) + .as("차감된 재고는 성공한 주문 수량과 정확히 일치해야 함") + .isEqualTo(expectedDeductedStock); + } + + @Test + @DisplayName("여러 상품을 동시에 주문할 때 각 상품의 재고가 독립적으로 차감된다") + void should_deduct_each_product_stock_independently_with_concurrent_orders() throws InterruptedException { + // Given: 브랜드 생성 + BrandEntity brand = brandService.registerBrand( + BrandTestFixture.createRequest("테스트브랜드", "브랜드 설명") + ); + + // Given: 여러 상품 생성 + ProductEntity product1 = productService.registerProduct( + ProductTestFixture.createRequest(brand.getId(), "상품A", "설명A", new BigDecimal("5000"), 100) + ); + ProductEntity product2 = productService.registerProduct( + ProductTestFixture.createRequest(brand.getId(), "상품B", "설명B", new BigDecimal("6000"), 80) + ); + ProductEntity product3 = productService.registerProduct( + ProductTestFixture.createRequest(brand.getId(), "상품C", "설명C", new BigDecimal("7000"), 60) + ); + + // Given: 여러 사용자 생성 및 포인트 충전 + List users = new ArrayList<>(); + for (int i = 0; i < 15; i++) { + UserRegisterCommand userCommand = new UserRegisterCommand( + "user" + i, + "user" + i + "@test.com", + LocalDate.of(1990, 1, 1).toString(), + Gender.MALE + ); + UserInfo userInfo = userFacade.registerUser(userCommand); + pointService.charge(userInfo.username(), new BigDecimal("200000")); + users.add(userInfo); + } + + // Given: 동시 주문 설정 + int threadCount = 15; + AtomicInteger successCount; + ExecutorService executorService = newFixedThreadPool(threadCount); + try { + CountDownLatch latch = new CountDownLatch(threadCount); + successCount = new AtomicInteger(0); + + // When: 15명의 사용자가 동시에 여러 상품을 주문 + for (int i = 0; i < threadCount; i++) { + final int userIndex = i; + executorService.submit(() -> { + try { + OrderCreateCommand orderCommand = OrderCreateCommand.builder() + .username(users.get(userIndex).username()) + .orderItems(List.of( + OrderItemCommand.builder().productId(product1.getId()).quantity(2).build(), + OrderItemCommand.builder().productId(product2.getId()).quantity(3).build(), + OrderItemCommand.builder().productId(product3.getId()).quantity(1).build() + )) + .build(); + + orderFacade.createOrder(orderCommand); + successCount.incrementAndGet(); + } catch (Exception e) { + // 재고 부족 등의 이유로 실패 가능 + } finally { + latch.countDown(); + } + }); + } + + // Then: 모든 스레드 완료 대기 + latch.await(30, TimeUnit.SECONDS); + } finally { + executorService.shutdown(); + } + + // Then: 각 상품의 재고가 독립적으로 정확히 차감되었는지 확인 + ProductEntity finalProduct1 = productService.getActiveProductDetail(product1.getId()); + ProductEntity finalProduct2 = productService.getActiveProductDetail(product2.getId()); + ProductEntity finalProduct3 = productService.getActiveProductDetail(product3.getId()); + + // 모든 주문이 성공했다면 + if (successCount.get() == threadCount) { + assertThat(finalProduct1.getStockQuantity()).isEqualTo(100 - (15 * 2)); + assertThat(finalProduct2.getStockQuantity()).isEqualTo(80 - (15 * 3)); + assertThat(finalProduct3.getStockQuantity()).isEqualTo(60 - (15)); + } + + // 모든 상품의 재고가 음수가 아닌지 확인 + assertThat(finalProduct1.getStockQuantity()).isGreaterThanOrEqualTo(0); + assertThat(finalProduct2.getStockQuantity()).isGreaterThanOrEqualTo(0); + assertThat(finalProduct3.getStockQuantity()).isGreaterThanOrEqualTo(0); + } + } + + @Nested + @DisplayName("포인트 동시성 제어") + class PointConcurrencyControl { + + @Test + @DisplayName("동일한 유저가 서로 다른 주문을 동시에 수행해도 포인트가 정상적으로 차감된다") + void should_deduct_points_correctly_with_concurrent_orders_by_same_user() throws InterruptedException { + // Given: 브랜드 생성 + BrandEntity brand = brandService.registerBrand( + BrandTestFixture.createRequest("테스트브랜드", "브랜드 설명") + ); + + // Given: 사용자 생성 및 충분한 포인트 충전 + UserRegisterCommand userCommand = UserTestFixture.createDefaultUserCommand(); + UserInfo userInfo = userFacade.registerUser(userCommand); + BigDecimal initialPoints = new BigDecimal("500000.00"); + pointService.charge(userInfo.username(), initialPoints); + + // Given: 여러 상품 생성 + List products = new ArrayList<>(); + for (int i = 0; i < 10; i++) { + ProductEntity product = productService.registerProduct( + ProductTestFixture.createRequest( + brand.getId(), + "상품" + i, + "설명" + i, + new BigDecimal("10000"), + 100 + ) + ); + products.add(product); + } + + // Given: 동시 주문 설정 + int threadCount = 10; + AtomicInteger successCount; + AtomicInteger failCount; + ExecutorService executorService = newFixedThreadPool(threadCount); + try { + CountDownLatch latch = new CountDownLatch(threadCount); + successCount = new AtomicInteger(0); + failCount = new AtomicInteger(0); + + // When: 동일 사용자가 동시에 10개의 서로 다른 주문 시도 + for (int i = 0; i < threadCount; i++) { + final int productIndex = i; + executorService.submit(() -> { + try { + OrderCreateCommand orderCommand = OrderCreateCommand.builder() + .username(userInfo.username()) + .orderItems(List.of( + OrderItemCommand.builder() + .productId(products.get(productIndex).getId()) + .quantity(2) + .build() + )) + .build(); + + orderFacade.createOrder(orderCommand); + successCount.incrementAndGet(); + } catch (Exception e) { + failCount.incrementAndGet(); + } finally { + latch.countDown(); + } + }); + } + + // Then: 모든 스레드 완료 대기 + latch.await(30, TimeUnit.SECONDS); + } finally { + executorService.shutdown(); + } + + // Then: 포인트가 충분하므로 모든 주문이 성공해야 함 + assertThat(successCount.get()).isEqualTo(threadCount); + assertThat(failCount.get()).isZero(); + + // Then: 최종 포인트가 정확히 차감되었는지 검증 + UserEntity finalUser = userRepository.findByUsername(userInfo.username()) + .orElseThrow(); + BigDecimal expectedFinalPoints = initialPoints.subtract( + new BigDecimal("10000").multiply(new BigDecimal("2")).multiply(new BigDecimal(threadCount)) + ); + assertThat(finalUser.getPointAmount()) + .as("동시성 제어로 포인트는 정확히 차감되어야 함") + .isEqualTo(expectedFinalPoints); + + // Then: 포인트가 음수가 되지 않았는지 확인 + assertThat(finalUser.getPointAmount()) + .as("포인트는 절대 음수가 될 수 없음") + .isGreaterThanOrEqualTo(BigDecimal.ZERO); + } + + @Test + @DisplayName("포인트 부족 시 동일 유저의 동시 주문 중 일부만 성공한다") + void should_succeed_partially_when_points_insufficient_with_concurrent_orders() throws InterruptedException { + // Given: 브랜드 생성 + BrandEntity brand = brandService.registerBrand( + BrandTestFixture.createRequest("테스트브랜드", "브랜드 설명") + ); + + // Given: 사용자 생성 및 제한된 포인트 충전 + UserRegisterCommand userCommand = new UserRegisterCommand( + "user", + "user@test.com", + LocalDate.of(1990, 1, 1).toString(), + Gender.MALE + ); + UserInfo userInfo = userFacade.registerUser(userCommand); + BigDecimal initialPoints = new BigDecimal("50000.00"); // 제한된 포인트 + pointService.charge(userInfo.username(), initialPoints); + + // Given: 여러 상품 생성 + List products = new ArrayList<>(); + for (int i = 0; i < 15; i++) { + ProductEntity product = productService.registerProduct( + ProductTestFixture.createRequest( + brand.getId(), + "고가상품" + i, + "설명" + i, + new BigDecimal("15000"), // 비싼 상품 + 100 + ) + ); + products.add(product); + } + + // Given: 동시 주문 설정 + int threadCount = 15; + AtomicInteger successCount; + AtomicInteger failCount; + ExecutorService executorService = newFixedThreadPool(threadCount); + try { + CountDownLatch latch = new CountDownLatch(threadCount); + successCount = new AtomicInteger(0); + failCount = new AtomicInteger(0); + + // When: 동일 사용자가 동시에 15개의 주문 시도 (총 225,000 포인트 필요, 보유 50,000) + for (int i = 0; i < threadCount; i++) { + final int productIndex = i; + executorService.submit(() -> { + try { + OrderCreateCommand orderCommand = OrderCreateCommand.builder() + .username(userInfo.username()) + .orderItems(List.of( + OrderItemCommand.builder() + .productId(products.get(productIndex).getId()) + .quantity(1) + .build() + )) + .build(); + + orderFacade.createOrder(orderCommand); + successCount.incrementAndGet(); + } catch (Exception e) { + failCount.incrementAndGet(); + } finally { + latch.countDown(); + } + }); + } + + // Then: 모든 스레드 완료 대기 + latch.await(30, TimeUnit.SECONDS); + executorService.shutdown(); + } finally { + executorService.shutdown(); + } + + // Then: 포인트 부족으로 일부만 성공해야 함 + int maxPossibleOrders = initialPoints.divide(new BigDecimal("15000"), 0, RoundingMode.DOWN).intValue(); + assertThat(successCount.get()) + .as("포인트 50,000으로 15,000원 상품은 최대 3개만 구매 가능") + .isLessThanOrEqualTo(maxPossibleOrders); + assertThat(failCount.get()).isGreaterThan(0); + + // Then: 최종 포인트 확인 - 음수가 되지 않음 + UserEntity finalUser = userRepository.findByUsername(userInfo.username()) + .orElseThrow(); + assertThat(finalUser.getPointAmount()) + .as("포인트는 절대 음수가 될 수 없음") + .isGreaterThanOrEqualTo(BigDecimal.ZERO); + + // Then: 성공한 주문 수와 차감된 포인트가 일치하는지 확인 + BigDecimal deductedPoints = initialPoints.subtract(finalUser.getPointAmount()); + BigDecimal expectedDeductedPoints = new BigDecimal("15000").multiply(new BigDecimal(successCount.get())); + assertThat(deductedPoints) + .as("차감된 포인트는 성공한 주문 금액과 정확히 일치해야 함") + .isEqualTo(expectedDeductedPoints.setScale(2)); + } + + @Test + @DisplayName("여러 사용자가 동시에 주문해도 각 사용자의 포인트가 독립적으로 차감된다") + void should_deduct_each_user_points_independently_with_concurrent_orders() throws InterruptedException { + // Given: 브랜드 생성 + BrandEntity brand = brandService.registerBrand( + BrandTestFixture.createRequest("테스트브랜드", "브랜드 설명") + ); + + // Given: 상품 생성 + ProductEntity product = productService.registerProduct( + ProductTestFixture.createRequest( + brand.getId(), + "공통상품", + "여러 사용자가 구매하는 상품", + new BigDecimal("20000"), + 1000 + ) + ); + + // Given: 여러 사용자 생성 및 각각 다른 포인트 충전 + List users = new ArrayList<>(); + List initialPointsList = new ArrayList<>(); + for (int i = 0; i < 20; i++) { + UserRegisterCommand userCommand = new UserRegisterCommand( + "user" + i, + "user" + i + "@test.com", + LocalDate.of(1990, 1, 1).toString(), + Gender.MALE + ); + UserInfo userInfo = userFacade.registerUser(userCommand); + BigDecimal points = new BigDecimal((i + 1) * 10000); // 10000, 20000, 30000, ... + pointService.charge(userInfo.username(), points); + users.add(userInfo); + initialPointsList.add(points); + } + + // Given: 동시 주문 설정 + int threadCount = 20; + ExecutorService executorService = newFixedThreadPool(threadCount); + try { + CountDownLatch latch = new CountDownLatch(threadCount); + AtomicInteger successCount = new AtomicInteger(0); + + // When: 20명의 사용자가 동시에 주문 + for (int i = 0; i < threadCount; i++) { + final int userIndex = i; + executorService.submit(() -> { + try { + OrderCreateCommand orderCommand = OrderCreateCommand.builder() + .username(users.get(userIndex).username()) + .orderItems(List.of( + OrderItemCommand.builder() + .productId(product.getId()) + .quantity(1) + .build() + )) + .build(); + + orderFacade.createOrder(orderCommand); + successCount.incrementAndGet(); + } catch (Exception e) { + // 포인트 부족으로 실패 가능 + } finally { + latch.countDown(); + } + }); + } + + // Then: 모든 스레드 완료 대기 + latch.await(30, TimeUnit.SECONDS); + } finally { + executorService.shutdown(); + } + + // Then: 각 사용자의 포인트가 독립적으로 차감되었는지 확인 + for (int i = 0; i < users.size(); i++) { + UserEntity user = userRepository.findByUsername(users.get(i).username()) + .orElseThrow(); + + BigDecimal initialPoints = initialPointsList.get(i); + BigDecimal orderAmount = new BigDecimal("20000"); + + // 포인트가 충분했던 사용자는 차감되어야 함 + if (initialPoints.compareTo(orderAmount) >= 0) { + assertThat(user.getPointAmount()) + .as("사용자 " + i + "의 포인트가 정확히 차감되어야 함") + .isEqualTo(initialPoints.subtract(orderAmount.setScale(2))); + } else { + // 포인트가 부족했던 사용자는 차감되지 않아야 함 + assertThat(user.getPointAmount()) + .as("사용자 " + i + "의 포인트는 변경되지 않아야 함") + .isEqualTo(initialPoints.setScale(2)); + } + + // 모든 사용자의 포인트가 음수가 아닌지 확인 + assertThat(user.getPointAmount()) + .as("사용자 " + i + "의 포인트는 음수가 될 수 없음") + .isGreaterThanOrEqualTo(BigDecimal.ZERO); + } + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderItemUnitTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderItemUnitTest.java new file mode 100644 index 000000000..631c66504 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderItemUnitTest.java @@ -0,0 +1,528 @@ +package com.loopers.domain.order; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import java.math.BigDecimal; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import com.loopers.domain.order.dto.OrderItemDomainCreateRequest; + +/** + * OrderItemEntity 단위 테스트 + * + * @author hyunjikoh + * @since 2025. 11. 14. + */ +@DisplayName("OrderItemEntity 단위 테스트") +class OrderItemUnitTest { + + @Nested + @DisplayName("주문 항목 엔티티 생성") + class OrderItemCreation { + + @Test + @DisplayName("유효한 정보로 주문 항목 엔티티를 생성하면 성공한다") + void should_create_order_item_successfully_with_valid_information() { + // given + OrderItemDomainCreateRequest request = new OrderItemDomainCreateRequest( + 1L, + 100L, + null, + 2, + new BigDecimal("10000.00"), + null + ); + + // when + OrderItemEntity orderItem = OrderItemEntity.createOrderItem(request); + + // then + assertThat(orderItem).isNotNull(); + assertThat(orderItem.getOrderId()).isEqualTo(1L); + assertThat(orderItem.getProductId()).isEqualTo(100L); + assertThat(orderItem.getQuantity()).isEqualTo(2); + assertThat(orderItem.getUnitPrice()).isEqualByComparingTo(new BigDecimal("10000.00")); + assertThat(orderItem.getTotalPrice()).isEqualByComparingTo(new BigDecimal("20000.00")); + } + + @Test + @DisplayName("주문 항목 생성 시 총 가격이 자동으로 계산된다") + void should_calculate_total_price_automatically_when_creating_order_item() { + // given + OrderItemDomainCreateRequest request = new OrderItemDomainCreateRequest( + 1L, + 100L, + null, + 3, + new BigDecimal("15000.00"), + null + ); + + // when + OrderItemEntity orderItem = OrderItemEntity.createOrderItem(request); + + // then + BigDecimal expectedTotal = new BigDecimal("15000.00").multiply(BigDecimal.valueOf(3)); + assertThat(orderItem.getTotalPrice()).isEqualByComparingTo(expectedTotal); + assertThat(orderItem.getTotalPrice()).isEqualByComparingTo(new BigDecimal("45000.00")); + } + + @Test + @DisplayName("주문 항목 생성 요청이 null인 경우 예외가 발생한다") + void should_throw_exception_when_create_request_is_null() { + // given + OrderItemDomainCreateRequest request = null; + + // when & then + assertThatThrownBy(() -> OrderItemEntity.createOrderItem(request)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("주문 항목 생성 요청은 필수입니다."); + } + + @Test + @DisplayName("주문 ID가 null인 경우 예외가 발생한다") + void should_throw_exception_when_order_id_is_null() { + // given & when & then + assertThatThrownBy(() -> new OrderItemDomainCreateRequest( + null, + 100L, + null, + 2, + new BigDecimal("10000.00"), + null + )) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("주문 ID는 필수입니다."); + } + + @Test + @DisplayName("상품 ID가 null인 경우 예외가 발생한다") + void should_throw_exception_when_product_id_is_null() { + // given & when & then + assertThatThrownBy(() -> new OrderItemDomainCreateRequest( + 1L, + null, + null, + 2, + new BigDecimal("10000.00"), + null + )) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("상품 ID는 필수입니다."); + } + + @Test + @DisplayName("주문 수량이 null인 경우 예외가 발생한다") + void should_throw_exception_when_quantity_is_null() { + // given & when & then + assertThatThrownBy(() -> new OrderItemDomainCreateRequest( + 1L, + 100L, + null, + null, + new BigDecimal("10000.00"), + null + )) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("주문 수량은 필수입니다."); + } + + @Test + @DisplayName("주문 수량이 0 이하인 경우 예외가 발생한다") + void should_throw_exception_when_quantity_is_zero_or_negative() { + // given & when & then + assertThatThrownBy(() -> new OrderItemDomainCreateRequest( + 1L, + 100L, + null, + 0, + new BigDecimal("10000.00"), + null + )) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("주문 수량은 1 이상이어야 합니다."); + + assertThatThrownBy(() -> new OrderItemDomainCreateRequest( + 1L, + 100L, + null, + -1, + new BigDecimal("10000.00"), + null + )) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("주문 수량은 1 이상이어야 합니다."); + } + + @Test + @DisplayName("주문 수량이 999를 초과하는 경우 예외가 발생한다") + void should_throw_exception_when_quantity_exceeds_999() { + // given & when & then + assertThatThrownBy(() -> new OrderItemDomainCreateRequest( + 1L, + 100L, + null, + 1000, + new BigDecimal("10000.00"), + null + )) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("주문 수량은 999개를 초과할 수 없습니다."); + } + + @Test + @DisplayName("단가가 null인 경우 예외가 발생한다") + void should_throw_exception_when_unit_price_is_null() { + // given & when & then + assertThatThrownBy(() -> new OrderItemDomainCreateRequest( + 1L, + 100L, + null, + 2, + null, + null + )) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("단가는 필수입니다."); + } + + @Test + @DisplayName("단가가 0 이하인 경우 예외가 발생한다") + void should_throw_exception_when_unit_price_is_zero_or_negative() { + // given & when & then + assertThatThrownBy(() -> new OrderItemDomainCreateRequest( + 1L, + 100L, + null, + 2, + BigDecimal.ZERO, + null + )) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("단가는 0보다 커야 합니다."); + + assertThatThrownBy(() -> new OrderItemDomainCreateRequest( + 1L, + 100L, + null, + 2, + new BigDecimal("-1000.00"), + null + )) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("단가는 0보다 커야 합니다."); + } + } + + @Nested + @DisplayName("항목 총액 계산") + class ItemTotalCalculation { + + @Test + @DisplayName("단가와 수량을 곱한 값이 총 가격으로 계산된다") + void should_calculate_total_price_by_multiplying_unit_price_and_quantity() { + // given + BigDecimal unitPrice = new BigDecimal("25000.00"); + Integer quantity = 4; + OrderItemDomainCreateRequest request = new OrderItemDomainCreateRequest( + 1L, + 100L, + null, + quantity, + unitPrice, + null + ); + + // when + OrderItemEntity orderItem = OrderItemEntity.createOrderItem(request); + BigDecimal calculatedTotal = orderItem.calculateItemTotal(); + + // then + BigDecimal expectedTotal = unitPrice.multiply(BigDecimal.valueOf(quantity)); + assertThat(calculatedTotal).isEqualByComparingTo(expectedTotal); + assertThat(calculatedTotal).isEqualByComparingTo(new BigDecimal("100000.00")); + } + + @Test + @DisplayName("수량이 1인 경우 총 가격은 단가와 같다") + void should_equal_unit_price_when_quantity_is_one() { + // given + BigDecimal unitPrice = new BigDecimal("50000.00"); + OrderItemDomainCreateRequest request = new OrderItemDomainCreateRequest( + 1L, + 100L, + null, + 1, + unitPrice, + null + ); + + // when + OrderItemEntity orderItem = OrderItemEntity.createOrderItem(request); + + // then + assertThat(orderItem.getTotalPrice()).isEqualByComparingTo(unitPrice); + assertThat(orderItem.calculateItemTotal()).isEqualByComparingTo(unitPrice); + } + + @Test + @DisplayName("수량이 여러 개인 경우 총 가격이 정확히 계산된다") + void should_calculate_total_price_accurately_with_multiple_quantities() { + // given + OrderItemDomainCreateRequest request1 = new OrderItemDomainCreateRequest( + 1L, 100L, + null, 5, new BigDecimal("12000.00"), + null + ); + OrderItemDomainCreateRequest request2 = new OrderItemDomainCreateRequest( + 1L, 101L, null, 10, new BigDecimal("8500.00"), + null + ); + OrderItemDomainCreateRequest request3 = new OrderItemDomainCreateRequest( + 1L, 102L, null, 999, new BigDecimal("100.00"), + null + ); + + // when + OrderItemEntity orderItem1 = OrderItemEntity.createOrderItem(request1); + OrderItemEntity orderItem2 = OrderItemEntity.createOrderItem(request2); + OrderItemEntity orderItem3 = OrderItemEntity.createOrderItem(request3); + + // then + assertThat(orderItem1.getTotalPrice()).isEqualByComparingTo(new BigDecimal("60000.00")); + assertThat(orderItem2.getTotalPrice()).isEqualByComparingTo(new BigDecimal("85000.00")); + assertThat(orderItem3.getTotalPrice()).isEqualByComparingTo(new BigDecimal("99900.00")); + } + } + + @Nested + @DisplayName("엔티티 검증") + class EntityValidation { + + @Test + @DisplayName("모든 필수 값이 유효하면 검증에 성공한다") + void should_pass_validation_when_all_required_fields_are_valid() { + // given + OrderItemDomainCreateRequest request = new OrderItemDomainCreateRequest( + 1L, + 100L, + null, + 5, + new BigDecimal("20000.00"), + null + ); + + // when + OrderItemEntity orderItem = OrderItemEntity.createOrderItem(request); + + // then - guard() 메서드가 예외를 던지지 않으면 검증 성공 + assertThat(orderItem).isNotNull(); + assertThat(orderItem.getOrderId()).isNotNull(); + assertThat(orderItem.getProductId()).isNotNull(); + assertThat(orderItem.getQuantity()).isNotNull().isPositive(); + assertThat(orderItem.getUnitPrice()).isNotNull().isPositive(); + assertThat(orderItem.getTotalPrice()).isNotNull().isPositive(); + } + + @Test + @DisplayName("주문 ID가 null이면 검증에 실패한다") + void should_fail_validation_when_order_id_is_null() { + // given & when & then + assertThatThrownBy(() -> new OrderItemDomainCreateRequest( + null, + 100L, + null, + 2, + new BigDecimal("10000.00"), + null + )) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("주문 ID는 필수입니다."); + } + + @Test + @DisplayName("상품 ID가 null이면 검증에 실패한다") + void should_fail_validation_when_product_id_is_null() { + // given & when & then + assertThatThrownBy(() -> new OrderItemDomainCreateRequest( + 1L, + null, + null, + 2, + new BigDecimal("10000.00"), + null + )) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("상품 ID는 필수입니다."); + } + + @Test + @DisplayName("주문 수량이 null이면 검증에 실패한다") + void should_fail_validation_when_quantity_is_null() { + // given & when & then + assertThatThrownBy(() -> new OrderItemDomainCreateRequest( + 1L, + 100L, + null, + null, + new BigDecimal("10000.00"), + null + )) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("주문 수량은 필수입니다."); + } + + @Test + @DisplayName("주문 수량이 0 이하이면 검증에 실패한다") + void should_fail_validation_when_quantity_is_zero_or_negative() { + // given & when & then + assertThatThrownBy(() -> new OrderItemDomainCreateRequest( + 1L, + 100L, + null, + 0, + new BigDecimal("10000.00"), + null + )) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("주문 수량은 1 이상이어야 합니다."); + } + + @Test + @DisplayName("단가가 null이면 검증에 실패한다") + void should_fail_validation_when_unit_price_is_null() { + // given & when & then + assertThatThrownBy(() -> new OrderItemDomainCreateRequest( + 1L, + 100L, + null, + 2, + null, + null + )) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("단가는 필수입니다."); + } + + @Test + @DisplayName("단가가 0 이하이면 검증에 실패한다") + void should_fail_validation_when_unit_price_is_zero_or_negative() { + // given & when & then + assertThatThrownBy(() -> new OrderItemDomainCreateRequest( + 1L, + 100L, + null, + 2, + BigDecimal.ZERO, + null + )) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("단가는 0보다 커야 합니다."); + } + + @Test + @DisplayName("총 가격이 null이면 검증에 실패한다") + void should_fail_validation_when_total_price_is_null() { + // given + OrderItemDomainCreateRequest request = new OrderItemDomainCreateRequest( + 1L, + 100L, + null, + 2, + new BigDecimal("10000.00"), + null + ); + OrderItemEntity orderItem = OrderItemEntity.createOrderItem(request); + + // when - 리플렉션을 사용하여 totalPrice를 null로 설정 + try { + java.lang.reflect.Field totalPriceField = OrderItemEntity.class.getDeclaredField("totalPrice"); + totalPriceField.setAccessible(true); + totalPriceField.set(orderItem, null); + + // then - guard() 메서드 호출 시 예외 발생 + assertThatThrownBy(() -> { + java.lang.reflect.Method guardMethod = OrderItemEntity.class.getDeclaredMethod("guard"); + guardMethod.setAccessible(true); + guardMethod.invoke(orderItem); + }) + .getCause() + .isInstanceOf(IllegalStateException.class) + .hasMessage("총 가격은 0 이상이어야 합니다."); + } catch (Exception e) { + throw new RuntimeException("리플렉션 테스트 실패", e); + } + } + + @Test + @DisplayName("총 가격이 0 이하이면 검증에 실패한다") + void should_fail_validation_when_total_price_is_zero_or_negative() { + // given + OrderItemDomainCreateRequest request = new OrderItemDomainCreateRequest( + 1L, + 100L, + null, + 2, + new BigDecimal("10000.00"), + null + ); + OrderItemEntity orderItem = OrderItemEntity.createOrderItem(request); + + // when - 리플렉션을 사용하여 totalPrice를 0으로 설정 + try { + java.lang.reflect.Field totalPriceField = OrderItemEntity.class.getDeclaredField("totalPrice"); + totalPriceField.setAccessible(true); + totalPriceField.set(orderItem, BigDecimal.ZERO); + + // then - guard() 메서드 호출 시 예외 발생 + assertThatThrownBy(() -> { + java.lang.reflect.Method guardMethod = OrderItemEntity.class.getDeclaredMethod("guard"); + guardMethod.setAccessible(true); + guardMethod.invoke(orderItem); + }) + .getCause() + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("총 가격 정합성 오류"); + } catch (Exception e) { + throw new RuntimeException("리플렉션 테스트 실패", e); + } + } + + @Test + @DisplayName("총 가격이 단가_곱하기_수량과_일치하지_않으면_검증에_실패한다") + void should_fail_validation_when_total_price_does_not_match_unit_price_times_quantity() { + // given + OrderItemDomainCreateRequest request = new OrderItemDomainCreateRequest( + 1L, + 100L, + null, + 2, + new BigDecimal("10000.00"), + null + ); + OrderItemEntity orderItem = OrderItemEntity.createOrderItem(request); + + // when - 리플렉션을 사용하여 totalPrice를 잘못된 값으로 설정 + try { + java.lang.reflect.Field totalPriceField = OrderItemEntity.class.getDeclaredField("totalPrice"); + totalPriceField.setAccessible(true); + totalPriceField.set(orderItem, new BigDecimal("15000.00")); // 잘못된 총액 + + // then - guard() 메서드 호출 시 예외 발생 + assertThatThrownBy(() -> { + java.lang.reflect.Method guardMethod = OrderItemEntity.class.getDeclaredMethod("guard"); + guardMethod.setAccessible(true); + guardMethod.invoke(orderItem); + }) + .getCause() + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("총 가격 정합성 오류:"); + } catch (Exception e) { + throw new RuntimeException("리플렉션 테스트 실패", e); + } + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderUnitTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderUnitTest.java new file mode 100644 index 000000000..6e9b92087 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderUnitTest.java @@ -0,0 +1,282 @@ +package com.loopers.domain.order; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import java.lang.reflect.Field; +import java.math.BigDecimal; + +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.util.ReflectionUtils; + +import com.loopers.domain.order.dto.OrderDomainCreateRequest; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; + +/** + * OrderEntity 단위 테스트 + * + * @author hyunjikoh + * @since 2025. 11. 14. + */ +@DisplayName("OrderEntity 단위 테스트") +class OrderUnitTest { + + @Nested + @DisplayName("주문 엔티티 생성") + class 주문_엔티티_생성 { + + @Test + @DisplayName("유효한 정보로 주문 엔티티를 생성하면 성공한다") + void should_create_order_entity_successfully_with_valid_information() { + // given + Long userId = 1L; + BigDecimal originalTotalAmount = new BigDecimal("50000"); + BigDecimal discountAmount = BigDecimal.ZERO; + BigDecimal finalTotalAmount = new BigDecimal("50000"); + OrderDomainCreateRequest request = new OrderDomainCreateRequest(userId, originalTotalAmount, discountAmount, finalTotalAmount); + + // when + OrderEntity order = OrderEntity.createOrder(request); + + // then + Assertions.assertThat(order).isNotNull(); + Assertions.assertThat(order.getUserId()).isEqualTo(userId); + Assertions.assertThat(order.getOriginalTotalAmount()).isEqualByComparingTo(originalTotalAmount); + Assertions.assertThat(order.getDiscountAmount()).isEqualByComparingTo(discountAmount); + Assertions.assertThat(order.getFinalTotalAmount()).isEqualByComparingTo(finalTotalAmount); + } + + @Test + @DisplayName("주문 생성 시 상태는 PENDING이다") + void should_have_pending_status_when_order_is_created() { + // given + OrderDomainCreateRequest request = new OrderDomainCreateRequest(1L, new BigDecimal("50000"), BigDecimal.ZERO, new BigDecimal("50000")); + + // when + OrderEntity order = OrderEntity.createOrder(request); + + // then + Assertions.assertThat(order.getStatus()).isEqualTo(OrderStatus.PENDING); + Assertions.assertThat(order.isPending()).isTrue(); + } + + @Test + @DisplayName("주문 생성 요청이 null인 경우 예외가 발생한다") + void should_throw_exception_when_create_request_is_null() { + // given & when & then + Assertions.assertThatThrownBy(() -> OrderEntity.createOrder(null)) + .isInstanceOf(NullPointerException.class) + .hasMessage("주문 생성 요청은 null일 수 없습니다."); + } + + @Test + @DisplayName("사용자 ID가 null인 경우 예외가 발생한다") + void should_throw_exception_when_user_id_is_null() { + // given & when & then + Assertions.assertThatThrownBy(() -> + new OrderDomainCreateRequest(null, new BigDecimal("50000"), BigDecimal.ZERO, new BigDecimal("50000"))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("사용자 ID는 필수입니다."); + } + + @Test + @DisplayName("주문 총액이 null인 경우 예외가 발생한다") + void should_throw_exception_when_total_amount_is_null() { + // given & when & then + Assertions.assertThatThrownBy(() -> + new OrderDomainCreateRequest(1L, null, BigDecimal.ZERO, new BigDecimal("50000"))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("할인 전 총액은 필수입니다."); + } + + @Test + @DisplayName("주문 총액이 0 이하인 경우 예외가 발생한다") + void should_throw_exception_when_total_amount_is_zero_or_negative() { + // given & when & then + Assertions.assertThatThrownBy(() -> + new OrderDomainCreateRequest(1L, BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("할인 전 총액은 0보다 커야 합니다."); + + Assertions.assertThatThrownBy(() -> + new OrderDomainCreateRequest(1L, new BigDecimal("-1000"), BigDecimal.ZERO, new BigDecimal("-1000"))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("할인 전 총액은 0보다 커야 합니다."); + } + } + + @Nested + @DisplayName("주문 확정") + class 주문_확정 { + private OrderEntity order; + + @BeforeEach + void setUp() { + OrderDomainCreateRequest request = new OrderDomainCreateRequest(1L, new BigDecimal("50000"), BigDecimal.ZERO, new BigDecimal("50000")); + order = OrderEntity.createOrder(request); + } + + @Test + @DisplayName("PENDING 상태의 주문을 확정하면 CONFIRMED 상태로 변경된다") + void should_change_status_to_confirmed_when_confirming_pending_order() { + // given + Assertions.assertThat(order.getStatus()).isEqualTo(OrderStatus.PENDING); + + // when + order.confirmOrder(); + + // then + Assertions.assertThat(order.getStatus()).isEqualTo(OrderStatus.CONFIRMED); + Assertions.assertThat(order.isConfirmed()).isTrue(); + } + + @Test + @DisplayName("PENDING이 아닌 상태에서 확정하면 예외가 발생한다") + void should_throw_exception_when_confirming_non_pending_order() { + // given + order.confirmOrder(); // CONFIRMED 상태로 변경 + + // when & then + Assertions.assertThatThrownBy(() -> order.confirmOrder()) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.INVALID_ORDER_STATUS) + .hasMessageContaining("주문 확정은 대기 상태 또는 활성화된 주문만 가능합니다."); + } + } + + @Nested + @DisplayName("주문 상태 확인") + class 주문_상태_확인 { + private OrderEntity order; + + @BeforeEach + void setUp() { + OrderDomainCreateRequest request = new OrderDomainCreateRequest(1L, new BigDecimal("50000"), BigDecimal.ZERO, new BigDecimal("50000")); + order = OrderEntity.createOrder(request); + } + + @Test + @DisplayName("PENDING 상태의 주문은 isPending이 true를 반환한다") + void should_return_true_for_is_pending_when_order_status_is_pending() { + // given & when & then + Assertions.assertThat(order.isPending()).isTrue(); + Assertions.assertThat(order.isConfirmed()).isFalse(); + } + + @Test + @DisplayName("CONFIRMED 상태의 주문은 isConfirmed가 true를 반환한다") + void should_return_true_for_is_confirmed_when_order_status_is_confirmed() { + // given + order.confirmOrder(); + + // when & then + Assertions.assertThat(order.isConfirmed()).isTrue(); + Assertions.assertThat(order.isPending()).isFalse(); + } + + @Test + @DisplayName("PENDING 상태의 주문은 isConfirmed가 false를 반환한다") + void should_return_false_for_is_confirmed_when_order_status_is_pending() { + // given & when & then + Assertions.assertThat(order.isConfirmed()).isFalse(); + } + } + + @Nested + @DisplayName("엔티티 검증") + class 엔티티_검증 { + + @Test + @DisplayName("모든 필수 값이 유효하면 검증에 성공한다") + void should_pass_validation_when_all_required_fields_are_valid() { + // given + OrderDomainCreateRequest request = new OrderDomainCreateRequest(1L, new BigDecimal("50000"), BigDecimal.ZERO, new BigDecimal("50000")); + OrderEntity order = OrderEntity.createOrder(request); + + // when & then + Assertions.assertThatCode(order::guard) + .doesNotThrowAnyException(); + } + + @Test + @DisplayName("사용자 ID가 null이면 검증에 실패한다") + void should_fail_validation_when_user_id_is_null() { + // given + OrderDomainCreateRequest request = new OrderDomainCreateRequest(1L, new BigDecimal("50000"), BigDecimal.ZERO, new BigDecimal("50000")); + OrderEntity order = OrderEntity.createOrder(request); + + Field userIdField = ReflectionUtils.findField(OrderEntity.class, "userId"); + assertNotNull(userIdField); + ReflectionUtils.makeAccessible(userIdField); + ReflectionUtils.setField(userIdField, order, null); + + // when & then + Assertions.assertThatThrownBy(order::guard) + .isInstanceOf(IllegalStateException.class) + .hasMessage("사용자 ID는 필수입니다."); + } + + @Test + @DisplayName("주문 총액이 null이면 검증에 실패한다") + void should_fail_validation_when_total_amount_is_null() { + // given + OrderDomainCreateRequest request = new OrderDomainCreateRequest(1L, new BigDecimal("50000"), BigDecimal.ZERO, new BigDecimal("50000")); + OrderEntity order = OrderEntity.createOrder(request); + + Field originalTotalAmountField = ReflectionUtils.findField(OrderEntity.class, "originalTotalAmount"); + assertNotNull(originalTotalAmountField); + ReflectionUtils.makeAccessible(originalTotalAmountField); + ReflectionUtils.setField(originalTotalAmountField, order, null); + + // when & then + Assertions.assertThatThrownBy(order::guard) + .isInstanceOf(IllegalStateException.class) + .hasMessage("할인 전 총액은 0보다 커야 합니다."); + } + + @Test + @DisplayName("주문 총액이 0 이하이면 검증에 실패한다") + void should_fail_validation_when_total_amount_is_zero_or_negative() { + // given + OrderDomainCreateRequest request = new OrderDomainCreateRequest(1L, new BigDecimal("50000"), BigDecimal.ZERO, new BigDecimal("50000")); + OrderEntity order = OrderEntity.createOrder(request); + + Field originalTotalAmountField = ReflectionUtils.findField(OrderEntity.class, "originalTotalAmount"); + assertNotNull(originalTotalAmountField); + ReflectionUtils.makeAccessible(originalTotalAmountField); + + // 0인 경우 + ReflectionUtils.setField(originalTotalAmountField, order, BigDecimal.ZERO); + Assertions.assertThatThrownBy(order::guard) + .isInstanceOf(IllegalStateException.class) + .hasMessage("할인 전 총액은 0보다 커야 합니다."); + + // 음수인 경우 + ReflectionUtils.setField(originalTotalAmountField, order, new BigDecimal("-1000")); + Assertions.assertThatThrownBy(order::guard) + .isInstanceOf(IllegalStateException.class) + .hasMessage("할인 전 총액은 0보다 커야 합니다."); + } + + @Test + @DisplayName("주문 상태가 null이면 검증에 실패한다") + void should_fail_validation_when_order_status_is_null() { + // given + OrderDomainCreateRequest request = new OrderDomainCreateRequest(1L, new BigDecimal("50000"), BigDecimal.ZERO, new BigDecimal("50000")); + OrderEntity order = OrderEntity.createOrder(request); + + Field statusField = ReflectionUtils.findField(OrderEntity.class, "status"); + assertNotNull(statusField); + ReflectionUtils.makeAccessible(statusField); + ReflectionUtils.setField(statusField, order, null); + + // when & then + Assertions.assertThatThrownBy(order::guard) + .isInstanceOf(IllegalStateException.class) + .hasMessage("주문 상태는 필수입니다."); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/point/PointServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/point/PointServiceIntegrationTest.java new file mode 100644 index 000000000..c84e8a402 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/point/PointServiceIntegrationTest.java @@ -0,0 +1,134 @@ +package com.loopers.domain.point; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import java.math.BigDecimal; +import java.util.List; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +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.test.context.bean.override.mockito.MockitoSpyBean; + +import com.loopers.application.user.UserFacade; +import com.loopers.application.user.UserInfo; +import com.loopers.application.user.UserRegisterCommand; +import com.loopers.domain.user.UserEntity; +import com.loopers.domain.user.UserRepository; +import com.loopers.fixtures.PointTestFixture; +import com.loopers.fixtures.UserTestFixture; +import com.loopers.support.error.CoreException; +import com.loopers.utils.DatabaseCleanUp; +import com.loopers.utils.RedisCleanUp; + +/** + * @author hyunjikoh + * @since 2025. 10. 29. + */ +@SpringBootTest +@DisplayName("PointService 통합 테스트") +class PointServiceIntegrationTest { + + @Autowired + private PointService pointService; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @Autowired + private RedisCleanUp redisCleanUp; + + @Autowired + private UserFacade userFacade; + + @Autowired + private UserRepository userRepository; + + @MockitoSpyBean + private PointHistoryRepository pointHistoryRepository; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + redisCleanUp.truncateAll(); + + } + + @Nested + @DisplayName("포인트 조회") + class GetPointTest { + + @Test + @DisplayName("사용자 등록 시 포인트가 0으로 초기화되어 있다") + void user_has_zero_point_when_registered() { + // given + UserRegisterCommand command = UserTestFixture.createDefaultUserCommand(); + UserInfo userInfo = userFacade.registerUser(command); + + // when + UserEntity user = userRepository.findByUsername(userInfo.username()).orElseThrow(); + + // then + PointTestFixture.assertUserPointIsZero(user); + } + } + + @Nested + @DisplayName("포인트 충전") + class ChargePointTest { + + @Test + @DisplayName("존재하지 않는 유저 ID로 충전을 시도하면 실패한다") + void charge_with_nonexistent_user_fails() { + // when & then + assertThatThrownBy(() -> pointService.charge( + PointTestFixture.NONEXISTENT_USERNAME, + PointTestFixture.CHARGE_AMOUNT_1000)) + .isInstanceOf(CoreException.class) + .hasMessage(PointTestFixture.ERROR_USER_NOT_FOUND); + } + + @Test + @DisplayName("존재하는 유저가 1000원을 충전하면 충전된 보유 총량을 반환한다") + void charge_1000_returns_total_amount() { + // given + UserRegisterCommand command = UserTestFixture.createDefaultUserCommand(); + UserInfo userInfo = userFacade.registerUser(command); + + // when + BigDecimal totalAmount = pointService.charge( + userInfo.username(), + PointTestFixture.CHARGE_AMOUNT_1000); + + // then + assertThat(totalAmount).isEqualByComparingTo(PointTestFixture.CHARGE_AMOUNT_1000_SCALED); + + // 사용자 엔티티에서도 포인트가 업데이트되었는지 확인 + UserEntity user = userRepository.findByUsername(userInfo.username()).orElseThrow(); + PointTestFixture.assertUserPointAmount(user, PointTestFixture.CHARGE_AMOUNT_1000_SCALED); + } + + @Test + @DisplayName("포인트 충전 시 포인트 내역이 저장된다") + void charge_saves_point_history() { + // given + UserRegisterCommand command = UserTestFixture.createDefaultUserCommand(); + UserInfo userInfo = userFacade.registerUser(command); + + // when + pointService.charge(userInfo.username(), PointTestFixture.CHARGE_AMOUNT_1000); + + // then + List userPointHistories = pointService.getPointHistories(userInfo.username()); + assertThat(userPointHistories).hasSize(1); + + PointHistoryEntity history = userPointHistories.get(0); + assertThat(history.getTransactionType()).isEqualTo(PointTransactionType.CHARGE); + assertThat(history.getAmount()).isEqualByComparingTo(PointTestFixture.CHARGE_AMOUNT_1000); + assertThat(history.getBalanceAfter()).isEqualByComparingTo(PointTestFixture.CHARGE_AMOUNT_1000_SCALED); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductIntegrationTest.java new file mode 100644 index 000000000..7f56cce64 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductIntegrationTest.java @@ -0,0 +1,467 @@ +package com.loopers.domain.product; + +import static org.assertj.core.api.Assertions.assertThat; +import java.math.BigDecimal; +import java.util.Comparator; +import java.util.List; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +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.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; + +import com.loopers.application.like.LikeFacade; +import com.loopers.application.product.ProductDetailInfo; +import com.loopers.application.product.ProductFacade; +import com.loopers.application.product.ProductInfo; +import com.loopers.application.user.UserFacade; +import com.loopers.application.user.UserInfo; +import com.loopers.application.user.UserRegisterCommand; +import com.loopers.domain.brand.BrandEntity; +import com.loopers.domain.brand.BrandRepository; +import com.loopers.domain.like.LikeEntity; +import com.loopers.domain.like.LikeRepository; +import com.loopers.domain.product.dto.ProductSearchFilter; +import com.loopers.fixtures.BrandTestFixture; +import com.loopers.fixtures.ProductTestFixture; +import com.loopers.fixtures.UserTestFixture; +import com.loopers.support.error.ErrorType; +import com.loopers.utils.DatabaseCleanUp; +import com.loopers.utils.RedisCleanUp; + +/** + * @author hyunjikoh + * @since 2025. 11. 10. + */ +@SpringBootTest +@DisplayName("Product 통합 테스트") +public class ProductIntegrationTest { + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @Autowired + private RedisCleanUp redisCleanUp; + + @Autowired + private BrandRepository brandRepository; + + @Autowired + private ProductRepository productRepository; + + @Autowired + private ProductFacade productFacade; + + @Autowired + private UserFacade userFacade; + + @Autowired + private LikeFacade likeFacade; + + @Autowired + private LikeRepository likeRepository; + + @Autowired + private ProductMVService productMVService; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + redisCleanUp.truncateAll(); + } + + @Nested + @DisplayName("상품 저장") + class ProductSaveTest { + + @Test + @DisplayName("새로운 상품 등록 시 상품이 저장된다") + void save_product_with_brand() { + // given + BrandEntity brand = BrandTestFixture.createAndSave(brandRepository, "Nike", "Just do it"); + + // when + ProductEntity product = ProductTestFixture.createAndSave( + productRepository, + brand, + "Air Max", + "편안한 운동화", + new BigDecimal("120000"), + 50 + ); + + // then + assertThat(product.getId()).isNotNull(); + assertThat(product.getName()).isEqualTo("Air Max"); + assertThat(product.getBrandId()).isEqualTo(brand.getId()); + } + } + + @Nested + @DisplayName("상품 목록 조회") + class ProductListTest { + + @Test + @DisplayName("상품 목록을 페이징하여 조회할 수 있다") + void get_product_pagination() { + // given + ProductTestFixture.createBrandsAndProducts(brandRepository, productRepository, 2, 5); // 2개 브랜드, 각 브랜드당 5개 상품 생성 + productMVService.syncMaterializedView(); + + Pageable pageable = PageRequest.of(0, 5); + ProductSearchFilter productSearchFilter = new ProductSearchFilter(null, null, pageable); + + // when + Page productInfos = productFacade.getProducts(productSearchFilter); + + // then + assertThat(productInfos).isNotNull(); + assertThat(productInfos.getContent()).hasSize(5); + assertThat(productInfos.getTotalElements()).isEqualTo(10); + + ProductInfo firstProduct = productInfos.getContent().get(0); + assertThat(firstProduct.name()).isNotNull(); + assertThat(firstProduct.price().originPrice()).isEqualTo(new BigDecimal("10000.00")); + assertThat(firstProduct.likeCount()).isNotNull(); + } + + @Test + @DisplayName("브랜드 ID로 상품을 필터링하여 조회할 수 있다") + void filter_products_by_brand() { + // given + ProductTestFixture.createBrandsAndProducts(brandRepository, productRepository, 2, 5); // 2개 브랜드, 각 브랜드당 5개 상품 생성 + productMVService.syncMaterializedView(); + Pageable pageable = PageRequest.of(0, 25); + ProductSearchFilter productSearchFilter = new ProductSearchFilter(1L, null, pageable); + + // when + Page productInfos = productFacade.getProducts(productSearchFilter); + + // then + assertThat(productInfos).isNotNull(); + assertThat(productInfos.getContent()).hasSize(5); + assertThat(productInfos.getTotalElements()).isEqualTo(5); + + productInfos.getContent().forEach(productInfo -> { + assertThat(productInfo.brandId()).isEqualTo(1L); + }); + } + + @Test + @DisplayName("상품이 없는 경우 빈 목록을 반환한다") + void return_empty_list_when_no_products() { + Pageable pageable = PageRequest.of(0, 25); + ProductSearchFilter productSearchFilter = new ProductSearchFilter(1L, null, pageable); + productMVService.syncMaterializedView(); + // when + Page productInfos = productFacade.getProducts(productSearchFilter); + + // then + assertThat(productInfos).isNotNull(); + assertThat(productInfos.getContent()).hasSize(0); + assertThat(productInfos.getTotalElements()).isEqualTo(0); + } + + @Test + @DisplayName("최신순으로 상품을 정렬하여 조회할 수 있다") + void get_products_sorted_by_latest() { + // given + ProductTestFixture.createBrandsAndProducts(brandRepository, productRepository, 1, 5); // 1개 브랜드, 5개 상품 생성 + productMVService.syncMaterializedView(); + Pageable pageable = PageRequest.of(0, 10, Sort.by(Sort.Direction.DESC, "createdAt")); + ProductSearchFilter productSearchFilter = new ProductSearchFilter(null, null, pageable); + + // when + Page productInfos = productFacade.getProducts(productSearchFilter); + + // then + assertThat(productInfos).isNotNull(); + assertThat(productInfos.getContent()).hasSize(5); + assertThat(productInfos.getTotalElements()).isEqualTo(5); + + // 최신순 정렬 검증 (생성일시 내림차순) + List createdAts = productInfos.getContent().stream() + .map(ProductInfo::createdAt) // ProductInfo에 생성일시 필드가 있다고 가정 + .toList(); + assertThat(createdAts).isSortedAccordingTo(Comparator.reverseOrder()); + } + + @Test + @DisplayName("존재하지 않는 브랜드로 상품을 필터링하면 빈 목록을 반환한다") + void return_empty_list_when_filtering_by_non_existent_brand() { + // given + ProductTestFixture.createBrandsAndProducts(brandRepository, productRepository, 2, 5); // 2개 브랜드, 각 브랜드당 5개 상품 생성 + productMVService.syncMaterializedView(); + Pageable pageable = PageRequest.of(0, 25); + ProductSearchFilter productSearchFilter = new ProductSearchFilter(9L, null, pageable); + + // when + Page productInfos = productFacade.getProducts(productSearchFilter); + + // then + assertThat(productInfos).isNotNull(); + assertThat(productInfos.getContent()).hasSize(0); + assertThat(productInfos.getTotalElements()).isEqualTo(0); + } + } + + @Nested + @DisplayName("상품 상세 조회") + class ProductDetailTest { + + @Test + @DisplayName("상품 상세를 조회할 수 있다") + void get_product_detail_success() { + // given + ProductTestFixture.createBrandsAndProducts(brandRepository, productRepository, 2, 5); // 2개 브랜드, 각 브랜드당 5개 상품 생성 + + // when + ProductDetailInfo productDetail = productFacade.getProductDetail(1L, null); + + // then + assertThat(productDetail).isNotNull(); + assertThat(productDetail.name()).isNotNull(); + assertThat(productDetail.price().originPrice()).isEqualTo(new BigDecimal("10000.00")); + assertThat(productDetail.brand()).isNotNull(); + } + + @Test + @DisplayName("존재하지 않는 상품 상세 조회 시 ProductNotFoundException이 발생한다") + void throw_exception_when_product_not_found() { + // given + Long nonExistentId = 999L; + + // when & then + assertThat(org.junit.jupiter.api.Assertions.assertThrows( + com.loopers.support.error.CoreException.class, + () -> productFacade.getProductDetail(nonExistentId, null) + ).getErrorType()).isEqualTo(ErrorType.NOT_FOUND_PRODUCT); + } + + @Test + @DisplayName("상품 상세 조회 시 존재하지 않는 브랜드인 경우 예외가 발생한다") + void throw_exception_when_product_has_non_existent_brand() { + // given + BrandEntity brand = BrandTestFixture.createAndSave(brandRepository, "Test Brand", "Test Description"); + ProductEntity product = ProductTestFixture.createAndSave(productRepository, brand); + + // 브랜드 삭제 (소프트 삭제) + brand.delete(); + brandRepository.save(brand); + + // when & then + assertThat(org.junit.jupiter.api.Assertions.assertThrows( + com.loopers.support.error.CoreException.class, + () -> productFacade.getProductDetail(product.getId(), null) + ).getErrorType()).isEqualTo(ErrorType.NOT_FOUND_BRAND); // 적절한 에러 타입으로 변경 + } + } + + @Nested + @DisplayName("상품 상세 조회 - 좋아요 정보 포함") + class ProductDetailWithLikeInfoTest { + + @Test + @DisplayName("로그인한 사용자가 좋아요한 상품 조회 시 isLiked가 true다") + void should_return_is_liked_true_when_user_liked_product() { + // Given: 사용자 생성 + UserRegisterCommand command = UserTestFixture.createDefaultUserCommand(); + UserInfo userInfo = userFacade.registerUser(command); + + // Given: 브랜드와 상품 생성 + BrandEntity brand = BrandTestFixture.createAndSave(brandRepository, "Test Brand", "Test Description"); + ProductEntity product = ProductTestFixture.createAndSave( + productRepository, + brand, + "Test Product", + "Product Description", + new BigDecimal("10000"), + 100 + ); + + // Given: 좋아요 등록 + likeFacade.upsertLike(userInfo.username(), product.getId()); + + // When: 상품 상세 조회 (사용자 정보 포함) + ProductDetailInfo result = productFacade.getProductDetail( + product.getId(), + userInfo.username() + ); + + // Then: 좋아요 여부 및 카운트 확인 + assertThat(result.isLiked()).isTrue(); + assertThat(result.likeCount()).isEqualTo(1L); + } + + @Test + @DisplayName("로그인한 사용자가 좋아요하지 않은 상품 조회 시 isLiked가 false다") + void should_return_is_liked_false_when_user_not_liked_product() { + // Given: 사용자 생성 + UserRegisterCommand command = UserTestFixture.createDefaultUserCommand(); + UserInfo userInfo = userFacade.registerUser(command); + + // Given: 브랜드와 상품 생성 (좋아요는 등록하지 않음) + BrandEntity brand = BrandTestFixture.createAndSave(brandRepository, "Test Brand", "Test Description"); + ProductEntity product = ProductTestFixture.createAndSave( + productRepository, + brand, + "Test Product", + "Product Description", + new BigDecimal("10000"), + 100 + ); + + // When: 상품 상세 조회 (사용자 정보 포함) + ProductDetailInfo result = productFacade.getProductDetail( + product.getId(), + userInfo.username() + ); + + // Then: 좋아요 여부 및 카운트 확인 + assertThat(result.isLiked()).isFalse(); + assertThat(result.likeCount()).isEqualTo(0L); + } + + @Test + @DisplayName("비로그인 사용자가 상품 조회 시 isLiked가 false이다") + void should_return_is_liked_false_when_anonymous_user() { + // Given: 브랜드와 상품 생성 + BrandEntity brand = BrandTestFixture.createAndSave(brandRepository, "Test Brand", "Test Description"); + ProductEntity product = ProductTestFixture.createAndSave( + productRepository, + brand, + "Test Product", + "Product Description", + new BigDecimal("10000"), + 100 + ); + + // When: 비로그인 상태로 상품 조회 + ProductDetailInfo result = productFacade.getProductDetail( + product.getId(), + null // 비로그인 + ); + + // Then: 좋아요 여부는 false, 카운트는 0 + assertThat(result.isLiked()).isFalse(); + assertThat(result.likeCount()).isEqualTo(0L); + } + + @Test + @DisplayName("여러 사용자가 좋아요한 상품의 좋아요 수가 정확히 표시된다") + void should_show_correct_like_count_when_multiple_users_liked() { + // Given: 여러 사용자 생성 + UserRegisterCommand command1 = UserTestFixture.createUserCommand( + "user1", "user1@example.com", "1990-01-01", com.loopers.domain.user.Gender.MALE + ); + UserRegisterCommand command2 = UserTestFixture.createUserCommand( + "user2", "user2@example.com", "1990-01-01", com.loopers.domain.user.Gender.FEMALE + ); + UserRegisterCommand command3 = UserTestFixture.createUserCommand( + "user3", "user3@example.com", "1990-01-01", com.loopers.domain.user.Gender.MALE + ); + + UserInfo user1 = userFacade.registerUser(command1); + UserInfo user2 = userFacade.registerUser(command2); + UserInfo user3 = userFacade.registerUser(command3); + + // Given: 브랜드와 상품 생성 + BrandEntity brand = BrandTestFixture.createAndSave(brandRepository, "Test Brand", "Test Description"); + ProductEntity product = ProductTestFixture.createAndSave( + productRepository, + brand, + "Popular Product", + "Product Description", + new BigDecimal("10000"), + 100 + ); + + // Given: 세 명의 사용자가 좋아요 등록 + likeFacade.upsertLike(user1.username(), product.getId()); + likeFacade.upsertLike(user2.username(), product.getId()); + likeFacade.upsertLike(user3.username(), product.getId()); + + // When: user1이 상품 조회 + ProductDetailInfo result = productFacade.getProductDetail( + product.getId(), + user1.username() + ); + + // Then: user1은 좋아요 했고, 총 좋아요 수는 3 + assertThat(result.isLiked()).isTrue(); + assertThat(result.likeCount()).isEqualTo(3L); + } + + @Test + @DisplayName("좋아요 취소 후 상품 조회 시 isLiked가 false이고 카운트가 감소한다") + void should_return_is_liked_false_and_decreased_count_after_unlike() { + // Given: 사용자 생성 + UserRegisterCommand command = UserTestFixture.createDefaultUserCommand(); + UserInfo userInfo = userFacade.registerUser(command); + + // Given: 브랜드와 상품 생성 + BrandEntity brand = BrandTestFixture.createAndSave(brandRepository, "Test Brand", "Test Description"); + ProductEntity product = ProductTestFixture.createAndSave( + productRepository, + brand, + "Test Product", + "Product Description", + new BigDecimal("10000"), + 100 + ); + + // Given: 좋아요 등록 후 취소 + likeFacade.upsertLike(userInfo.username(), product.getId()); + likeFacade.unlikeProduct(userInfo.username(), product.getId()); + + // When: 상품 상세 조회 + ProductDetailInfo result = productFacade.getProductDetail( + product.getId(), + userInfo.username() + ); + + // Then: 좋아요 취소 상태 확인 + assertThat(result.isLiked()).isFalse(); + assertThat(result.likeCount()).isEqualTo(0L); + } + + @Test + @DisplayName("삭제된 좋아요는 isLiked false로 표시되고 카운트에 포함되지 않는다") + void should_not_count_deleted_likes() { + // Given: 사용자 생성 + UserRegisterCommand command = UserTestFixture.createDefaultUserCommand(); + UserInfo userInfo = userFacade.registerUser(command); + + // Given: 브랜드와 상품 생성 + BrandEntity brand = BrandTestFixture.createAndSave(brandRepository, "Test Brand", "Test Description"); + ProductEntity product = ProductTestFixture.createAndSave( + productRepository, + brand, + "Test Product", + "Product Description", + new BigDecimal("10000"), + 100 + ); + + // Given: 삭제된 좋아요 엔티티 직접 생성 + LikeEntity deletedLike = LikeEntity.createEntity(userInfo.id(), product.getId()); + deletedLike.delete(); + likeRepository.save(deletedLike); + + // When: 상품 상세 조회 + ProductDetailInfo result = productFacade.getProductDetail( + product.getId(), + userInfo.username() + ); + + // Then: 삭제된 좋아요는 카운트되지 않음 + assertThat(result.isLiked()).isFalse(); + assertThat(result.likeCount()).isEqualTo(0L); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductUnitTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductUnitTest.java new file mode 100644 index 000000000..22987de0c --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductUnitTest.java @@ -0,0 +1,516 @@ +package com.loopers.domain.product; + +import java.lang.reflect.Field; +import java.math.BigDecimal; + +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.util.ReflectionUtils; + +import com.loopers.domain.brand.BrandEntity; +import com.loopers.fixtures.BrandTestFixture; + +/** + * @author hyunjikoh + * @since 2025. 11. 10. + */ +@DisplayName("ProductEntity 단위 테스트") +class ProductUnitTest { + + @Nested + @DisplayName("상품 엔티티 생성") + class 상품_엔티티_생성 { + + @Test + @DisplayName("유효한 정보로 상품 엔티티를 생성하면 성공한다") + void 유효한_정보로_상품_엔티티를_생성하면_성공한다() { + // given + BrandEntity brand = BrandTestFixture.createEntity("Nike", "Just Do It"); + ProductDomainCreateRequest productDomainRequest = ProductDomainCreateRequest.of( + brand.getId(), "Air Max", "Comfortable running shoes", new BigDecimal("100000"), 50 + ); + + // when + ProductEntity entity = ProductEntity.createEntity(productDomainRequest); + + // then + Assertions.assertThat(entity).isNotNull(); + Assertions.assertThat(entity.getBrandId()).isEqualTo(brand.getId()); + Assertions.assertThat(entity.getName()).isEqualTo("Air Max"); + } + + @Test + @DisplayName("할인가가 있는 상품 엔티티를 생성하면 성공한다") + void 할인가가_있는_상품_엔티티를_생성하면_성공한다() { + // given + BrandEntity brand = BrandTestFixture.createEntity("Nike", "Just Do It"); + ProductDomainCreateRequest productDomainRequest = new ProductDomainCreateRequest( + brand.getId(), "Air Max", "Comfortable running shoes", new BigDecimal("100000.00"), new BigDecimal("80000.00"), 50 + ); + + // when + ProductEntity entity = ProductEntity.createEntity(productDomainRequest); + + // then + Assertions.assertThat(entity).isNotNull(); + Assertions.assertThat(entity.getBrandId()).isEqualTo(brand.getId()); + Assertions.assertThat(entity.getName()).isEqualTo(productDomainRequest.name()); + Assertions.assertThat(entity.getPrice().getOriginPrice()).isEqualTo(productDomainRequest.originPrice()); + Assertions.assertThat(entity.getPrice().getDiscountPrice()).isEqualTo(productDomainRequest.discountPrice()); + + } + + @Test + @DisplayName("브랜드 ID가 null인 경우 예외가 발생한다") + void 브랜드가_null인_경우_예외가_발생한다() { + ProductDomainCreateRequest productDomainRequest = new ProductDomainCreateRequest( + null, "Air Max", "Comfortable running shoes", new BigDecimal("100000.00"), new BigDecimal("100000.00"), 50 + ); + + Assertions.assertThatThrownBy(() -> ProductEntity.createEntity(productDomainRequest)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("브랜드 ID는 필수입니다."); + } + + @Test + @DisplayName("상품명이 null인 경우 예외가 발생한다") + void 상품명이_null인_경우_예외가_발생한다() { + BrandEntity brand = BrandTestFixture.createEntity("Nike", "Just Do It"); + + ProductDomainCreateRequest productDomainRequest = new ProductDomainCreateRequest( + brand.getId(), null, "Comfortable running shoes", new BigDecimal("100000.00"), new BigDecimal("100000.00"), 50 + ); + Assertions.assertThatThrownBy(() -> ProductEntity.createEntity(productDomainRequest)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("상품명은 필수입니다."); + + } + + @Test + @DisplayName("상품명이 200자를 초과하는 경우 예외가 발생한다") + void 상품명이_200자를_초과하는_경우_예외가_발생한다() { + // given + BrandEntity brand = BrandTestFixture.createEntity("Nike", "Just Do It"); + String tooLongName = "a".repeat(201); + + // when & then + Assertions.assertThatThrownBy(() -> { + ProductDomainCreateRequest request = new ProductDomainCreateRequest( + brand.getId(), tooLongName, "description", + new BigDecimal("10000"), null, 100 + ); + ProductEntity.createEntity(request); + }).isInstanceOf(IllegalArgumentException.class) + .hasMessage("상품명은 200자를 초과할 수 없습니다."); + } + + @Test + @DisplayName("재고 수량이 음수인 경우 예외가 발생한다") + void 재고_수량이_음수인_경우_예외가_발생한다() { + // given + BrandEntity brand = BrandTestFixture.createEntity("Nike", "Just Do It"); + + // when & then + Assertions.assertThatThrownBy(() -> { + ProductDomainCreateRequest request = new ProductDomainCreateRequest( + brand.getId(), "상품명", "description", + new BigDecimal("10000"), null, -1 + ); + ProductEntity.createEntity(request); + }).isInstanceOf(IllegalArgumentException.class) + .hasMessage("재고 수량은 0 이상이어야 합니다."); + } + } + + @Nested + @DisplayName("재고 여부 확인") + class 재고_여부_확인 { + private ProductEntity product; + private BrandEntity brand; + + @BeforeEach + void setUp() { + brand = BrandTestFixture.createEntity("Nike", "Just Do It"); + } + + @Test + @DisplayName("재고가 있으면 true를 반환한다") + void 재고가_있으면_true를_반환한다() { + // given + product = ProductEntity.createEntity( + ProductDomainCreateRequest.of( + brand.getId(), "Test Product", "Description", + new BigDecimal("10000"), 100 + ) + ); + + // when & then + Assertions.assertThat(product.hasStock()).isTrue(); + } + + @Test + @DisplayName("재고가 0이면 false를 반환한다") + void 재고가_0이면_false를_반환한다() { + // given + product = ProductEntity.createEntity( + ProductDomainCreateRequest.of( + brand.getId(), "Test Product", "Description", + new BigDecimal("10000"), 0 + ) + ); + + // when & then + Assertions.assertThat(product.hasStock()).isFalse(); + } + } + + @Nested + @DisplayName("주문 가능 여부 확인") + class 주문_가능_여부_확인 { + private ProductEntity product; + private BrandEntity brand; + + @BeforeEach + void setUp() { + brand = BrandTestFixture.createEntity("Nike", "Just Do It"); + product = ProductEntity.createEntity( + ProductDomainCreateRequest.of( + brand.getId(), "Test Product", "Description", + new BigDecimal("10000"), 100 + ) + ); + } + + @Test + @DisplayName("요청 수량이 재고 내에 있으면 true를 반환한다") + void 요청_수량이_재고_내에_있으면_true를_반환한다() { + // given + int orderQuantity = product.getStockQuantity() - 1; + + // when & then + Assertions.assertThat(product.canOrder(orderQuantity)).isTrue(); + } + + @Test + @DisplayName("요청 수량이 재고와 정확히 같으면 true를 반환한다") + void 요청_수량이_재고와_정확히_같으면_true를_반환한다() { + // given + int orderQuantity = product.getStockQuantity(); + + // when & then + Assertions.assertThat(product.canOrder(orderQuantity)).isTrue(); + } + + @Test + @DisplayName("요청 수량이 재고보다 많으면 false를 반환한다") + void 요청_수량이_재고보다_많으면_false를_반환한다() { + // given + int orderQuantity = product.getStockQuantity() + 1; + + // when & then + Assertions.assertThat(product.canOrder(orderQuantity)).isFalse(); + } + + @Test + @DisplayName("요청 수량이 0 이하이면 false를 반환한다") + void 요청_수량이_0_이하이면_false를_반환한다() { + // when & then + Assertions.assertThat(product.canOrder(0)).isFalse(); + Assertions.assertThat(product.canOrder(-1)).isFalse(); + } + + @Test + @DisplayName("재고가 없으면 false를 반환한다") + void 재고가_없으면_false를_반환한다() { + // given + product.setStockQuantity(0); + + // when & then + Assertions.assertThat(product.canOrder(1)).isFalse(); + } + } + + @Nested + @DisplayName("판매 가격 조회") + class 판매_가격_조회 { + private ProductEntity product; + private BrandEntity brand; + private final BigDecimal ORIGIN_PRICE = new BigDecimal("10000"); + private final BigDecimal DISCOUNT_PRICE = new BigDecimal("8000"); + + @BeforeEach + void setUp() { + brand = BrandTestFixture.createEntity("Nike", "Just Do It"); + } + + @Test + @DisplayName("할인이 없는 경우 정가를 반환한다") + void 할인이_없는_경우_정가를_반환한다() { + // given + product = ProductEntity.createEntity( + ProductDomainCreateRequest.of( + brand.getId(), "Test Product", "Description", + ORIGIN_PRICE, 100 + ) + ); + + // when & then + Assertions.assertThat(product.getSellingPrice()) + .isEqualByComparingTo(ORIGIN_PRICE); + } + + @Test + @DisplayName("할인이 있는 경우 할인가를 반환한다") + void 할인이_있는_경우_할인가를_반환한다() { + // given + product = ProductEntity.createEntity( + new ProductDomainCreateRequest( + brand.getId(), "Test Product", "Description", + ORIGIN_PRICE, DISCOUNT_PRICE, 100 + ) + ); + + // when & then + Assertions.assertThat(product.getSellingPrice()) + .isEqualByComparingTo(DISCOUNT_PRICE); + } + } + + @Nested + @DisplayName("할인 여부 확인") + class 할인_여부_확인 { + private ProductEntity product; + private BrandEntity brand; + private final BigDecimal ORIGIN_PRICE = new BigDecimal("10000"); + private final BigDecimal DISCOUNT_PRICE = new BigDecimal("8000"); + + @BeforeEach + void setUp() { + brand = BrandTestFixture.createEntity("Nike", "Just Do It"); + } + + @Test + @DisplayName("할인이 적용된 상품이면 true를 반환한다") + void 할인이_적용된_상품이면_true를_반환한다() { + // given + product = ProductEntity.createEntity( + new ProductDomainCreateRequest( + brand.getId(), "Test Product", "Description", + ORIGIN_PRICE, DISCOUNT_PRICE, 100 + ) + ); + + // when & then + Assertions.assertThat(product.isDiscounted()).isTrue(); + } + + @Test + @DisplayName("할인이 없는 상품이면 false를 반환한다") + void 할인이_없는_상품이면_false를_반환한다() { + // given + product = ProductEntity.createEntity( + ProductDomainCreateRequest.of( + brand.getId(), "Test Product", "Description", + ORIGIN_PRICE, 100 + ) + ); + + // when & then + Assertions.assertThat(product.isDiscounted()).isFalse(); + } + } + + @Nested + @DisplayName("재고 차감") + class 재고_차감 { + private ProductEntity product; + private BrandEntity brand; + + @BeforeEach + void setUp() { + brand = BrandTestFixture.createEntity("Nike", "Just Do It"); + product = ProductEntity.createEntity( + ProductDomainCreateRequest.of( + brand.getId(), "Test Product", "Description", + new BigDecimal("10000"), 100 + ) + ); + } + + @Test + @DisplayName("정상적인 재고 차감이 성공한다") + void deduct_stock_success() { + // given + int deductQuantity = 10; + int initialStock = product.getStockQuantity(); + + // when + product.deductStock(deductQuantity); + + // then + Assertions.assertThat(product.getStockQuantity()) + .isEqualTo(initialStock - deductQuantity); + } + + @Test + @DisplayName("재고보다 많은 수량을 차감하면 CoreException이 발생한다") + void deduct_stock_insufficient() { + // given + int deductQuantity = product.getStockQuantity() + 1; + + // when & then + Assertions.assertThatThrownBy(() -> product.deductStock(deductQuantity)) + .isInstanceOf(com.loopers.support.error.CoreException.class) + .hasMessageContaining("재고가 부족합니다"); + } + + @Test + @DisplayName("0 이하의 수량을 차감하면 IllegalArgumentException이 발생한다") + void deduct_stock_invalid_quantity() { + // when & then + Assertions.assertThatThrownBy(() -> product.deductStock(0)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("차감할 재고 수량은 0보다 커야 합니다."); + + Assertions.assertThatThrownBy(() -> product.deductStock(-1)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("차감할 재고 수량은 0보다 커야 합니다."); + } + } + + @Nested + @DisplayName("엔티티 검증") + class 엔티티_검증 { + private BrandEntity brand; + private final BigDecimal ORIGIN_PRICE = new BigDecimal("10000"); + private final int STOCK_QUANTITY = 100; + + @BeforeEach + void setUp() { + brand = BrandTestFixture.createEntity("Nike", "Just Do It"); + } + + @Test + @DisplayName("모든 필수 값이 유효하면 검증에 성공한다") + void 모든_필수_값이_유효하면_검증에_성공한다() { + // given + ProductEntity product = ProductEntity.createEntity( + ProductDomainCreateRequest.of( + brand.getId(), "Test Product", "Description", + ORIGIN_PRICE, STOCK_QUANTITY + ) + ); + + // when & then + Assertions.assertThatCode(() -> product.guard()) + .doesNotThrowAnyException(); + } + + @Test + @DisplayName("브랜드 ID가 null이면 검증에 실패한다") + void 브랜드_ID가_null이면_검증에_실패한다() { + // given + ProductEntity product = ProductEntity.createEntity( + ProductDomainCreateRequest.of( + brand.getId(), "Test Product", "Description", + ORIGIN_PRICE, STOCK_QUANTITY + ) + ); + + // when + Field brandIdField = ReflectionUtils.findField(ProductEntity.class, "brandId"); + ReflectionUtils.makeAccessible(brandIdField); + ReflectionUtils.setField(brandIdField, product, null); + + // then + Assertions.assertThatThrownBy(() -> product.guard()) + .isInstanceOf(IllegalStateException.class) + .hasMessage("브랜드 ID는 필수입니다."); + } + + @Test + @DisplayName("가격 정보가 null이면 검증에 실패한다") + void 가격_정보가_null이면_검증에_실패한다() { + // given + ProductEntity product = ProductEntity.createEntity( + ProductDomainCreateRequest.of( + brand.getId(), "Test Product", "Description", + ORIGIN_PRICE, STOCK_QUANTITY + ) + ); + + // when + Field priceField = ReflectionUtils.findField(ProductEntity.class, "price"); + ReflectionUtils.makeAccessible(priceField); + ReflectionUtils.setField(priceField, product, null); + + // then + Assertions.assertThatThrownBy(() -> product.guard()) + .isInstanceOf(IllegalStateException.class) + .hasMessage("가격 정보는 필수입니다."); + } + + @Test + @DisplayName("상품명이 유효하지 않으면 검증에 실패한다") + void 상품명이_유효하지_않으면_검증에_실패한다() { + // given + ProductEntity product = ProductEntity.createEntity( + ProductDomainCreateRequest.of( + brand.getId(), "Test Product", "Description", + ORIGIN_PRICE, STOCK_QUANTITY + ) + ); + + // when + Field nameField = ReflectionUtils.findField(ProductEntity.class, "name"); + ReflectionUtils.makeAccessible(nameField); + + // 상품명이 null인 경우 + ReflectionUtils.setField(nameField, product, null); + + // then + Assertions.assertThatThrownBy(() -> product.guard()) + .isInstanceOf(IllegalStateException.class) + .hasMessage("상품명은 비어있을 수 없습니다."); + + // 상품명이 빈 문자열인 경우 + ReflectionUtils.setField(nameField, product, ""); + + Assertions.assertThatThrownBy(() -> product.guard()) + .isInstanceOf(IllegalStateException.class) + .hasMessage("상품명은 비어있을 수 없습니다."); + + // 상품명이 200자 초과인 경우 + ReflectionUtils.setField(nameField, product, "a".repeat(201)); + + Assertions.assertThatThrownBy(() -> product.guard()) + .isInstanceOf(IllegalStateException.class) + .hasMessage("상품명은 200자를 초과할 수 없습니다."); + } + + @Test + @DisplayName("재고 수량이 음수이면 검증에 실패한다") + void 재고_수량이_음수이면_검증에_실패한다() { + // given + ProductEntity product = ProductEntity.createEntity( + ProductDomainCreateRequest.of( + brand.getId(), "Test Product", "Description", + ORIGIN_PRICE, STOCK_QUANTITY + ) + ); + + // when + Field stockQuantityField = ReflectionUtils.findField(ProductEntity.class, "stockQuantity"); + ReflectionUtils.makeAccessible(stockQuantityField); + ReflectionUtils.setField(stockQuantityField, product, -1); + + // then + Assertions.assertThatThrownBy(() -> product.guard()) + .isInstanceOf(IllegalStateException.class) + .hasMessage("재고 수량은 0 이상이어야 합니다."); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java new file mode 100644 index 000000000..2aeddb9de --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java @@ -0,0 +1,130 @@ +package com.loopers.domain.user; + +import static com.loopers.support.error.ErrorType.NOT_FOUND_USER; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; + +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +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.dao.DataIntegrityViolationException; + +import com.loopers.fixtures.UserTestFixture; +import com.loopers.support.error.CoreException; +import com.loopers.utils.DatabaseCleanUp; +import com.loopers.utils.RedisCleanUp; + +/** + * @author hyunjikoh + * @since 2025. 10. 27. + */ +@SpringBootTest +@DisplayName("UserService 통합 테스트") +class UserServiceIntegrationTest { + @Autowired + private UserService userService; + + @Autowired + private UserRepository userRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @Autowired + private RedisCleanUp redisCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + redisCleanUp.truncateAll(); + + } + + @Nested + @DisplayName("회원 가입") + class RegisterUserTest { + + @Test + @DisplayName("유효한 정보로 회원 가입하면 User가 저장된다") + void register_user_success() { + // given + UserDomainCreateRequest request = UserTestFixture.createDefaultUserDomainRequest(); + + // when + UserEntity result = userService.register(request); + + // then + UserTestFixture.assertUserEntityPersisted(result, request); + } + + @Test + @DisplayName("이미 가입된 사용자명으로 회원가입을 시도하면 실패한다") + void register_user_fail_when_username_already_exists() { + // given + UserDomainCreateRequest existingUser = UserTestFixture.createUserDomainRequest( + "testuser", "existing@email.com", "1990-01-01", Gender.MALE); + userRepository.save(UserEntity.createUserEntity(existingUser)); + + UserDomainCreateRequest duplicateUser = UserTestFixture.createUserDomainRequest( + "testuser", "new@email.com", "1990-01-02", Gender.MALE); + + // when & then + assertThatThrownBy(() -> userService.register(duplicateUser)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("이미 존재하는 사용자 이름입니다"); + } + + @Test + @DisplayName("이미 가입된 사용자명으로 저장을 시도하면 실패한다") + void save_user_fail_when_username_already_exists() { + // given + UserDomainCreateRequest existingUser = UserTestFixture.createUserDomainRequest( + "testuser", "existing@email.com", "1990-01-01", Gender.MALE); + userRepository.save(UserEntity.createUserEntity(existingUser)); + + UserDomainCreateRequest duplicateUser = UserTestFixture.createUserDomainRequest( + "testuser", "new@email.com", "1990-01-02", Gender.MALE); + + // when & then + Assertions.assertThatThrownBy(() -> userRepository.save(UserEntity.createUserEntity(duplicateUser))) + .isInstanceOf(DataIntegrityViolationException.class); + } + } + + @Nested + @DisplayName("사용자 조회") + class GetUserTest { + + @Test + @DisplayName("사용자명으로 사용자 정보를 조회할 수 있다") + void get_user_by_username_success() { + // given + UserDomainCreateRequest req = UserTestFixture.createDefaultUserDomainRequest(); + UserEntity savedUser = userRepository.save(UserEntity.createUserEntity(req)); + + // when + UserEntity foundUser = userService.getUserByUsername(savedUser.getUsername()); + + // then + assertThat(foundUser).isNotNull(); + assertThat(foundUser.getUsername()).isEqualTo(savedUser.getUsername()); + assertThat(foundUser.getEmail()).isEqualTo(savedUser.getEmail()); + assertThat(foundUser.getBirthdate()).isEqualTo(savedUser.getBirthdate()); + assertThat(foundUser.getId()).isEqualTo(savedUser.getId()); + } + + @Test + @DisplayName("존재하지 않는 사용자명으로 조회하면 예외를 던진다") + void get_user_by_username_return_null_when_not_found() { + // given + String nonExistentUsername = "nonexistent"; + + Assertions.assertThatThrownBy(() -> userService.getUserByUsername(nonExistentUsername)) + .isInstanceOf(CoreException.class).hasMessage(NOT_FOUND_USER.getMessage()); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserUnitTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserUnitTest.java new file mode 100644 index 000000000..8a9c07b9e --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserUnitTest.java @@ -0,0 +1,154 @@ +package com.loopers.domain.user; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import com.loopers.fixtures.UserTestFixture; + +/** + * @author hyunjikoh + * @since 2025. 10. 26. + */ +@DisplayName("UserEntity 단위 테스트") +class UserUnitTest { + + @Nested + @DisplayName("사용자 생성") + class CreateUserTest { + + @Test + @DisplayName("유효한 정보로 User 객체를 생성할 수 있다") + void create_user_entity_success() { + // given + UserDomainCreateRequest userRegisterRequest = UserTestFixture.createDefaultUserDomainRequest(); + + // when + UserEntity userEntity = UserEntity.createUserEntity(userRegisterRequest); + + // then + UserTestFixture.assertUserEntity(userEntity, userRegisterRequest); + } + } + + @Nested + @DisplayName("사용자 검증") + class UsernameValidationTest { + + @Test + @DisplayName("사용자명이 영문 및 숫자 10자를 초과하면 생성에 실패한다") + void create_user_entity_fail_when_username_exceeds_10_characters() { + UserTestFixture.assertUserCreationFails( + UserTestFixture.InvalidUsername.TOO_LONG, + UserTestFixture.DEFAULT_EMAIL, + UserTestFixture.DEFAULT_BIRTHDATE, + UserTestFixture.DEFAULT_GENDER, + UserTestFixture.InvalidUsername.EXPECTED_MESSAGE + ); + } + + @Test + @DisplayName("사용자명에 특수문자가 포함되면 생성에 실패한다") + void create_user_entity_fail_when_username_contains_invalid_characters() { + UserTestFixture.assertUserCreationFails( + UserTestFixture.InvalidUsername.SPECIAL_CHAR, + UserTestFixture.DEFAULT_EMAIL, + UserTestFixture.DEFAULT_BIRTHDATE, + UserTestFixture.DEFAULT_GENDER, + UserTestFixture.InvalidUsername.EXPECTED_MESSAGE + ); + } + + @Test + @DisplayName("이메일이 올바른 형식이 아니면 생성에 실패한다") + void create_user_entity_fail_when_email_format_invalid() { + UserTestFixture.assertUserCreationFails( + UserTestFixture.DEFAULT_USERNAME, + UserTestFixture.InvalidEmail.INVALID_FORMAT, + UserTestFixture.DEFAULT_BIRTHDATE, + UserTestFixture.DEFAULT_GENDER, + UserTestFixture.InvalidEmail.EXPECTED_MESSAGE + ); + } + + @Test + @DisplayName("생년월일이 null이면 생성에 실패한다") + void create_user_entity_fail_when_birthdate_is_null() { + UserTestFixture.assertUserCreationFails( + UserTestFixture.DEFAULT_USERNAME, + UserTestFixture.DEFAULT_EMAIL, + null, + UserTestFixture.DEFAULT_GENDER, + UserTestFixture.InvalidBirthdate.NULL_MESSAGE + ); + } + + @Test + @DisplayName("생년월일이 yyyy-MM-dd 형식이 아니면 생성에 실패한다") + void create_user_entity_fail_when_birthdate_format_invalid() { + UserTestFixture.assertUserCreationFails( + UserTestFixture.DEFAULT_USERNAME, + UserTestFixture.DEFAULT_EMAIL, + UserTestFixture.InvalidBirthdate.INVALID_FORMAT, + UserTestFixture.DEFAULT_GENDER, + UserTestFixture.InvalidBirthdate.FORMAT_MESSAGE + ); + } + + @Test + @DisplayName("성별이 null이면 생성에 실패한다") + void create_user_entity_fail_when_gender_is_null() { + UserTestFixture.assertUserCreationFails(UserTestFixture.DEFAULT_USERNAME, + UserTestFixture.DEFAULT_EMAIL, + UserTestFixture.DEFAULT_BIRTHDATE, + null, + UserTestFixture.InvalidGender.EXPECTED_MESSAGE + ); + } + } + + @Nested + @DisplayName("포인트 관리") + class PointManagementTest { + + @Test + @DisplayName("사용자 생성 시 포인트는 0으로 초기화된다") + void user_created_with_zero_point() { + // given + UserDomainCreateRequest userRegisterRequest = UserTestFixture.createDefaultUserDomainRequest(); + + // when + UserEntity userEntity = UserEntity.createUserEntity(userRegisterRequest); + + // then + UserTestFixture.assertUserPointIsZero(userEntity); + } + + @Test + @DisplayName("포인트를 충전할 수 있다") + void can_charge_point() { + // given + UserDomainCreateRequest userRegisterRequest = UserTestFixture.createDefaultUserDomainRequest(); + UserEntity userEntity = UserEntity.createUserEntity(userRegisterRequest); + java.math.BigDecimal chargeAmount = new java.math.BigDecimal("1000"); + + // when + userEntity.chargePoint(chargeAmount); + + // then + UserTestFixture.assertUserPointAmount(userEntity, new java.math.BigDecimal("1000.00")); + } + + @Test + @DisplayName("0 이하의 금액으로 충전하면 실패한다") + void charge_fails_with_zero_or_negative_amount() { + // given + UserDomainCreateRequest userRegisterRequest = UserTestFixture.createDefaultUserDomainRequest(); + UserEntity userEntity = UserEntity.createUserEntity(userRegisterRequest); + + // when & then + UserTestFixture.assertChargePointFails(userEntity, java.math.BigDecimal.ZERO, "충전 금액은 0보다 커야 합니다."); + UserTestFixture.assertChargePointFails(userEntity, new java.math.BigDecimal("-100"), "충전 금액은 0보다 커야 합니다."); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/fixtures/BrandTestFixture.java b/apps/commerce-api/src/test/java/com/loopers/fixtures/BrandTestFixture.java new file mode 100644 index 000000000..8468544ca --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/fixtures/BrandTestFixture.java @@ -0,0 +1,80 @@ +package com.loopers.fixtures; + +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +import com.loopers.domain.brand.BrandDomainCreateRequest; +import com.loopers.domain.brand.BrandEntity; +import com.loopers.domain.brand.BrandRepository; + +/** + * 브랜드 테스트 픽스처 + * 테스트에서 사용할 브랜드 엔티티 및 요청 객체를 생성합니다. + * + * @author hyunjikoh + * @since 2025. 11. 12. + */ +public class BrandTestFixture { + + /** + * BrandDomainCreateRequest를 생성합니다. + * + * @param name 브랜드 이름 + * @param description 브랜드 설명 + * @return BrandDomainCreateRequest + */ + public static BrandDomainCreateRequest createRequest(String name, String description) { + return BrandDomainCreateRequest.of(name, description); + } + + /** + * BrandEntity를 생성합니다. + * + * @param name 브랜드 이름 + * @param description 브랜드 설명 + * @return BrandEntity + */ + public static BrandEntity createEntity(String name, String description) { + BrandDomainCreateRequest request = createRequest(name, description); + return BrandEntity.createBrandEntity(request); + } + + /** + * 여러 개의 BrandEntity를 생성합니다. + * + * @param count 생성할 개수 + * @return BrandEntity 리스트 + */ + public static List createEntities(int count) { + return IntStream.range(0, count) + .mapToObj(i -> createEntity("브랜드" + i, "설명" + i)) + .collect(Collectors.toList()); + } + + /** + * 여러 개의 브랜드를 생성하고 저장합니다. + * + * @param repository 브랜드 레포지토리 + * @param count 생성할 개수 + * @return 저장된 BrandEntity 리스트 + */ + public static List saveBrands(BrandRepository repository, int count) { + return createEntities(count).stream() + .map(repository::save) + .collect(Collectors.toList()); + } + + /** + * 브랜드를 생성하고 저장합니다. + * + * @param repository 브랜드 레포지토리 + * @param name 브랜드 이름 + * @param description 브랜드 설명 + * @return 저장된 BrandEntity + */ + public static BrandEntity createAndSave(BrandRepository repository, String name, String description) { + BrandEntity brand = createEntity(name, description); + return repository.save(brand); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/fixtures/OrderTestFixture.java b/apps/commerce-api/src/test/java/com/loopers/fixtures/OrderTestFixture.java new file mode 100644 index 000000000..6646c44f7 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/fixtures/OrderTestFixture.java @@ -0,0 +1,505 @@ +package com.loopers.fixtures; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import org.assertj.core.api.Assertions; + +import com.loopers.application.order.OrderCreateCommand; +import com.loopers.application.order.OrderItemCommand; +import com.loopers.domain.order.OrderEntity; +import com.loopers.domain.order.OrderItemEntity; +import com.loopers.domain.order.OrderStatus; + +/** + * 주문 관련 테스트 픽스처 클래스 + * 통합 테스트에서 편리하게 사용할 수 있는 주문 객체 생성 메서드들을 제공합니다. + * + * @author hyunjikoh + * @since 2025. 11. 20. + */ +public class OrderTestFixture { + + // ==================== 기본값 ==================== + public static final String DEFAULT_USERNAME = "testuser"; + public static final BigDecimal DEFAULT_TOTAL_AMOUNT = new BigDecimal("10000.00"); + public static final Integer DEFAULT_QUANTITY = 1; + public static final Integer DEFAULT_LARGE_QUANTITY = 10; + + // ==================== 기본 OrderCreateCommand 생성 ==================== + + /** + * 기본값으로 OrderCreateCommand 생성 + * + * @param username 사용자명 + * @param productId 상품 ID + * @return OrderCreateCommand + */ + public static OrderCreateCommand createDefaultOrderCommand(String username, Long productId) { + return createOrderCommand(username, List.of( + createOrderItem(productId, DEFAULT_QUANTITY, null) + )); + } + + /** + * 기본값으로 OrderCreateCommand 생성 (쿠폰 포함) + * + * @param username 사용자명 + * @param productId 상품 ID + * @param couponId 쿠폰 ID + * @return OrderCreateCommand + */ + public static OrderCreateCommand createOrderCommandWithCoupon(String username, Long productId, Long couponId) { + return createOrderCommand(username, List.of( + createOrderItem(productId, DEFAULT_QUANTITY, couponId) + )); + } + + /** + * 커스텀 OrderCreateCommand 생성 + * + * @param username 사용자명 + * @param orderItems 주문 항목 리스트 + * @return OrderCreateCommand + */ + public static OrderCreateCommand createOrderCommand(String username, List orderItems) { + return OrderCreateCommand.builder() + .username(username) + .orderItems(orderItems) + .build(); + } + + // ==================== 단일 상품 주문 ==================== + + /** + * 단일 상품 주문 생성 + * + * @param username 사용자명 + * @param productId 상품 ID + * @param quantity 수량 + * @return OrderCreateCommand + */ + public static OrderCreateCommand createSingleProductOrder(String username, Long productId, Integer quantity) { + return createOrderCommand(username, List.of( + createOrderItem(productId, quantity, null) + )); + } + + /** + * 단일 상품 주문 생성 (쿠폰 포함) + * + * @param username 사용자명 + * @param productId 상품 ID + * @param quantity 수량 + * @param couponId 쿠폰 ID + * @return OrderCreateCommand + */ + public static OrderCreateCommand createSingleProductOrderWithCoupon( + String username, + Long productId, + Integer quantity, + Long couponId + ) { + return createOrderCommand(username, List.of( + createOrderItem(productId, quantity, couponId) + )); + } + + // ==================== 다중 상품 주문 ==================== + + /** + * 다중 상품 주문 생성 + * + * @param username 사용자명 + * @param productIds 상품 ID 리스트 + * @param quantities 수량 리스트 + * @return OrderCreateCommand + */ + public static OrderCreateCommand createMultiProductOrder( + String username, + List productIds, + List quantities + ) { + List items = new ArrayList<>(); + for (int i = 0; i < productIds.size(); i++) { + items.add(createOrderItem(productIds.get(i), quantities.get(i), null)); + } + return createOrderCommand(username, items); + } + + /** + * 다중 상품 주문 생성 (쿠폰 포함) + * + * @param username 사용자명 + * @param productIds 상품 ID 리스트 + * @param quantities 수량 리스트 + * @param couponIds 쿠폰 ID 리스트 (null 가능) + * @return OrderCreateCommand + */ + public static OrderCreateCommand createMultiProductOrderWithCoupons( + String username, + List productIds, + List quantities, + List couponIds + ) { + List items = new ArrayList<>(); + for (int i = 0; i < productIds.size(); i++) { + Long couponId = (couponIds != null && i < couponIds.size()) ? couponIds.get(i) : null; + items.add(createOrderItem(productIds.get(i), quantities.get(i), couponId)); + } + return createOrderCommand(username, items); + } + + /** + * 다중 상품 주문 생성 (Map 기반) + * + * @param username 사용자명 + * @param productQuantityMap 상품ID와 수량 맵 + * @return OrderCreateCommand + */ + public static OrderCreateCommand createMultiProductOrder( + String username, + Map productQuantityMap + ) { + List items = new ArrayList<>(); + productQuantityMap.forEach((productId, quantity) -> + items.add(createOrderItem(productId, quantity, null)) + ); + return createOrderCommand(username, items); + } + + /** + * 다중 상품 + 다중 쿠폰 주문 (Map 기반) + * + * @param username 사용자명 + * @param productCouponMap 상품ID와 쿠폰ID 맵 (쿠폰 없으면 null) + * @param quantityMap 상품ID와 수량 맵 + * @return OrderCreateCommand + */ + public static OrderCreateCommand createMultiProductOrderWithCouponMap( + String username, + Map productCouponMap, + Map quantityMap + ) { + List items = new ArrayList<>(); + productCouponMap.forEach((productId, couponId) -> { + Integer quantity = quantityMap.getOrDefault(productId, DEFAULT_QUANTITY); + items.add(createOrderItem(productId, quantity, couponId)); + }); + return createOrderCommand(username, items); + } + + // ==================== OrderItemCommand 생성 ==================== + + /** + * OrderItemCommand 생성 + * + * @param productId 상품 ID + * @param quantity 수량 + * @param couponId 쿠폰 ID (null 가능) + * @return OrderItemCommand + */ + public static OrderItemCommand createOrderItem(Long productId, Integer quantity, Long couponId) { + return OrderItemCommand.builder() + .productId(productId) + .quantity(quantity) + .couponId(couponId) + .build(); + } + + /** + * OrderItemCommand 생성 (쿠폰 없음) + * + * @param productId 상품 ID + * @param quantity 수량 + * @return OrderItemCommand + */ + public static OrderItemCommand createOrderItem(Long productId, Integer quantity) { + return createOrderItem(productId, quantity, null); + } + + // ==================== 통합 테스트 시나리오 헬퍼 ==================== + + /** + * 통합 테스트용 - 간단한 주문 생성 + * + * @param username 사용자명 + * @param productId 상품 ID + * @param quantity 수량 + * @return OrderCreateCommand + */ + public static OrderCreateCommand createSimpleOrder(String username, Long productId, Integer quantity) { + return createSingleProductOrder(username, productId, quantity); + } + + /** + * 통합 테스트용 - 쿠폰 적용 단일 상품 주문 + * + * @param username 사용자명 + * @param productId 상품 ID + * @param quantity 수량 + * @param couponId 쿠폰 ID + * @return OrderCreateCommand + */ + public static OrderCreateCommand createOrderWithSingleCoupon( + String username, + Long productId, + Integer quantity, + Long couponId + ) { + return createSingleProductOrderWithCoupon(username, productId, quantity, couponId); + } + + /** + * 통합 테스트용 - 동시성 테스트를 위한 동일 주문 생성 + * + * @param username 사용자명 + * @param productId 상품 ID + * @param quantity 수량 + * @param couponId 쿠폰 ID (null 가능) + * @param count 생성할 주문 개수 + * @return OrderCreateCommand 리스트 + */ + public static List createConcurrentOrders( + String username, + Long productId, + Integer quantity, + Long couponId, + int count + ) { + List commands = new ArrayList<>(); + for (int i = 0; i < count; i++) { + commands.add(couponId != null + ? createOrderWithSingleCoupon(username, productId, quantity, couponId) + : createSimpleOrder(username, productId, quantity) + ); + } + return commands; + } + + /** + * 통합 테스트용 - 서로 다른 쿠폰을 사용하는 동시 주문 생성 + * + * @param username 사용자명 + * @param productId 상품 ID + * @param quantity 수량 + * @param couponIds 쿠폰 ID 리스트 + * @return OrderCreateCommand 리스트 + */ + public static List createConcurrentOrdersWithDifferentCoupons( + String username, + Long productId, + Integer quantity, + List couponIds + ) { + List commands = new ArrayList<>(); + for (Long couponId : couponIds) { + commands.add(createOrderWithSingleCoupon(username, productId, quantity, couponId)); + } + return commands; + } + + /** + * 통합 테스트용 - 재고 부족 시나리오를 위한 대량 주문 + * + * @param username 사용자명 + * @param productId 상품 ID + * @param quantity 수량 (재고보다 많게) + * @return OrderCreateCommand + */ + public static OrderCreateCommand createOrderExceedingStock( + String username, + Long productId, + Integer quantity + ) { + return createSimpleOrder(username, productId, quantity); + } + + /** + * 통합 테스트용 - 포인트 부족 시나리오를 위한 고액 주문 + * + * @param username 사용자명 + * @param productId 상품 ID + * @param quantity 대량 수량 + * @return OrderCreateCommand + */ + public static OrderCreateCommand createHighValueOrder( + String username, + Long productId, + Integer quantity + ) { + return createSimpleOrder(username, productId, quantity); + } + + // ==================== 검증 헬퍼 메서드 ==================== + + /** + * 주문 상태 검증 + * + * @param order 주문 엔티티 + * @param expectedStatus 예상 상태 + */ + public static void assertOrderStatus(OrderEntity order, OrderStatus expectedStatus) { + Assertions.assertThat(order.getStatus()).isEqualTo(expectedStatus); + } + + /** + * 주문 금액 검증 + * + * @param order 주문 엔티티 + * @param expectedAmount 예상 금액 + */ + public static void assertOrderAmount(OrderEntity order, BigDecimal expectedAmount) { + Assertions.assertThat(order.getTotalAmount()).isEqualByComparingTo(expectedAmount); + } + + /** + * 주문 항목 개수 검증 + * + * @param orderItems 주문 항목 리스트 + * @param expectedCount 예상 개수 + */ + public static void assertOrderItemCount(List orderItems, int expectedCount) { + Assertions.assertThat(orderItems).hasSize(expectedCount); + } + + /** + * 주문 항목 상세 검증 + * + * @param orderItem 주문 항목 + * @param expectedProductId 예상 상품 ID + * @param expectedQuantity 예상 수량 + * @param expectedUnitPrice 예상 단가 + */ + public static void assertOrderItem( + OrderItemEntity orderItem, + Long expectedProductId, + Integer expectedQuantity, + BigDecimal expectedUnitPrice + ) { + Assertions.assertThat(orderItem.getProductId()).isEqualTo(expectedProductId); + Assertions.assertThat(orderItem.getQuantity()).isEqualTo(expectedQuantity); + Assertions.assertThat(orderItem.getUnitPrice()).isEqualByComparingTo(expectedUnitPrice); + } + + /** + * 주문 총액 계산 + * + * @param orderItems 주문 항목 리스트 + * @return 계산된 총액 + */ + public static BigDecimal calculateTotalAmount(List orderItems) { + return orderItems.stream() + .map(item -> item.getUnitPrice().multiply(BigDecimal.valueOf(item.getQuantity()))) + .reduce(BigDecimal.ZERO, BigDecimal::add); + } + + /** + * 주문 항목 총액 검증 + * + * @param orderItem 주문 항목 + * @param expectedTotalPrice 예상 총액 + */ + public static void assertOrderItemTotalPrice(OrderItemEntity orderItem, BigDecimal expectedTotalPrice) { + BigDecimal actualTotalPrice = orderItem.getUnitPrice().multiply(BigDecimal.valueOf(orderItem.getQuantity())); + Assertions.assertThat(actualTotalPrice).isEqualByComparingTo(expectedTotalPrice); + } + + /** + * 주문 항목 리스트에서 특정 상품의 항목 찾기 + * + * @param orderItems 주문 항목 리스트 + * @param productId 상품 ID + * @return 찾은 주문 항목 (없으면 null) + */ + public static OrderItemEntity findOrderItemByProductId(List orderItems, Long productId) { + return orderItems.stream() + .filter(item -> item.getProductId().equals(productId)) + .findFirst() + .orElse(null); + } + + /** + * 주문 항목 리스트에서 특정 상품들의 항목 찾기 + * + * @param orderItems 주문 항목 리스트 + * @param productIds 상품 ID 리스트 + * @return 찾은 주문 항목 리스트 + */ + public static List findOrderItemsByProductIds( + List orderItems, + List productIds + ) { + return orderItems.stream() + .filter(item -> productIds.contains(item.getProductId())) + .toList(); + } + + /** + * 주문 상태 변경 검증 + * + * @param order 주문 엔티티 + * @param fromStatus 변경 전 상태 + * @param toStatus 변경 후 상태 + */ + public static void assertOrderStatusChange(OrderEntity order, OrderStatus fromStatus, OrderStatus toStatus) { + Assertions.assertThat(order.getStatus()).isNotEqualTo(fromStatus); + Assertions.assertThat(order.getStatus()).isEqualTo(toStatus); + } + + /** + * 주문 생성 시간 검증 + * + * @param order 주문 엔티티 + */ + public static void assertOrderCreatedAtIsNotNull(OrderEntity order) { + Assertions.assertThat(order.getCreatedAt()).isNotNull(); + } + + /** + * 주문 ID 검증 + * + * @param order 주문 엔티티 + */ + public static void assertOrderIdIsNotNull(OrderEntity order) { + Assertions.assertThat(order.getId()).isNotNull(); + } + + /** + * 주문 사용자 ID 검증 + * + * @param order 주문 엔티티 + * @param expectedUserId 예상 사용자 ID + */ + public static void assertOrderUserId(OrderEntity order, Long expectedUserId) { + Assertions.assertThat(order.getUserId()).isEqualTo(expectedUserId); + } + + /** + * 주문 할인 금액 계산 (쿠폰 적용 전후 비교용) + * + * @param originalAmount 원래 금액 + * @param discountedAmount 할인된 금액 + * @return 할인 금액 + */ + public static BigDecimal calculateDiscountAmount(BigDecimal originalAmount, BigDecimal discountedAmount) { + return originalAmount.subtract(discountedAmount); + } + + /** + * 할인율 계산 (백분율) + * + * @param originalAmount 원래 금액 + * @param discountedAmount 할인된 금액 + * @return 할인율 (0-100) + */ + public static BigDecimal calculateDiscountRate(BigDecimal originalAmount, BigDecimal discountedAmount) { + if (originalAmount.compareTo(BigDecimal.ZERO) == 0) { + return BigDecimal.ZERO; + } + BigDecimal discount = originalAmount.subtract(discountedAmount); + return discount.multiply(new BigDecimal("100")).divide(originalAmount, 2, java.math.RoundingMode.HALF_UP); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/fixtures/PointTestFixture.java b/apps/commerce-api/src/test/java/com/loopers/fixtures/PointTestFixture.java new file mode 100644 index 000000000..cb157c122 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/fixtures/PointTestFixture.java @@ -0,0 +1,139 @@ +package com.loopers.fixtures; + +import static org.assertj.core.api.Assertions.assertThat; +import java.math.BigDecimal; + +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; + +import com.loopers.application.user.UserRegisterCommand; +import com.loopers.domain.user.Gender; +import com.loopers.domain.user.UserEntity; +import com.loopers.interfaces.api.point.PointV1Dtos; + +/** + * Point 테스트를 위한 공통 픽스처 클래스 + * + * @author hyunjikoh + * @since 2025. 10. 31. + */ +public class PointTestFixture { + + // 기본 포인트 테스트 데이터 + public static final BigDecimal DEFAULT_POINT_AMOUNT = BigDecimal.ZERO.setScale(2); + public static final BigDecimal CHARGE_AMOUNT_1000 = new BigDecimal("1000"); + public static final BigDecimal CHARGE_AMOUNT_1000_SCALED = new BigDecimal("1000.00"); + public static final BigDecimal ZERO_AMOUNT = BigDecimal.ZERO; + public static final BigDecimal NEGATIVE_AMOUNT = new BigDecimal("-100"); + + public static final String NONEXISTENT_USERNAME = "nonexistentuser"; + + // HTTP 헤더 관련 상수 + public static final String USER_ID_HEADER = "X-USER-ID"; + + // 에러 메시지 + public static final String ERROR_INVALID_CHARGE_AMOUNT = "충전 금액은 0보다 커야 합니다."; + public static final String ERROR_USER_NOT_FOUND = "존재하지 않는 사용자입니다."; + + /** + * 기본 UserRegisterCommand 생성 + */ + public static UserRegisterCommand createDefaultUserRegisterCommand() { + return new UserRegisterCommand( + "testuser", + "test@example.com", + "1990-01-01", + Gender.MALE + ); + } + + /** + * 커스텀 UserRegisterCommand 생성 + */ + public static UserRegisterCommand createUserRegisterCommand(String username, String email, String birthdate, + Gender gender) { + return new UserRegisterCommand(username, email, birthdate, gender); + } + + /** + * UserRegisterCommand.of() 메서드를 사용한 생성 + */ + public static UserRegisterCommand createUserRegisterCommandOf(String username, String email, String birthdate, + Gender gender) { + return UserRegisterCommand.of(username, email, birthdate, gender); + } + + /** + * X-USER-ID 헤더가 포함된 HttpHeaders 생성 + */ + public static HttpHeaders createUserIdHeaders(String username) { + HttpHeaders headers = new HttpHeaders(); + headers.set(USER_ID_HEADER, username); + return headers; + } + + /** + * 기본 사용자명으로 X-USER-ID 헤더 생성 + */ + public static HttpHeaders createDefaultUserIdHeaders() { + return createUserIdHeaders("testuser"); + } + + /** + * 사용자의 포인트 금액 검증 헬퍼 메서드 + */ + public static void assertUserPointAmount(UserEntity user, BigDecimal expectedAmount) { + assertThat(user).isNotNull(); + assertThat(user.getPointAmount()).isEqualByComparingTo(expectedAmount); + } + + /** + * 사용자의 포인트가 0인지 검증하는 헬퍼 메서드 + */ + public static void assertUserPointIsZero(UserEntity user) { + assertThat(user).isNotNull(); + assertThat(user.getPointAmount()).isEqualByComparingTo(DEFAULT_POINT_AMOUNT); + } + + /** + * PointChargeRequest 생성 + */ + public static PointV1Dtos.PointChargeRequest createChargeRequest(BigDecimal amount) { + return new PointV1Dtos.PointChargeRequest(amount); + } + + /** + * 기본 충전 요청 생성 (1000원) + */ + public static PointV1Dtos.PointChargeRequest createDefaultChargeRequest() { + return createChargeRequest(CHARGE_AMOUNT_1000); + } + + /** + * X-USER-ID 헤더와 Content-Type이 포함된 HttpHeaders 생성 + */ + public static HttpHeaders createChargeHeaders(String username) { + HttpHeaders headers = new HttpHeaders(); + headers.set(USER_ID_HEADER, username); + headers.setContentType(MediaType.APPLICATION_JSON); + return headers; + } + + /** + * 테스트용 사용자 데이터 클래스 + */ + public static class TestUser { + public static final String USERNAME = "testuser"; + public static final String EMAIL = "dvum0045@gmail.com"; + public static final String BIRTHDATE = "1990-01-01"; + public static final Gender GENDER = Gender.FEMALE; + + public static UserRegisterCommand createCommand() { + return createUserRegisterCommand(USERNAME, EMAIL, BIRTHDATE, GENDER); + } + + public static UserRegisterCommand createCommandOf() { + return createUserRegisterCommandOf(USERNAME, EMAIL, BIRTHDATE, GENDER); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/fixtures/ProductTestFixture.java b/apps/commerce-api/src/test/java/com/loopers/fixtures/ProductTestFixture.java new file mode 100644 index 000000000..467e1c2ca --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/fixtures/ProductTestFixture.java @@ -0,0 +1,173 @@ +package com.loopers.fixtures; + +import java.math.BigDecimal; +import java.util.List; +import java.util.concurrent.atomic.AtomicLong; +import java.util.stream.IntStream; + +import com.loopers.domain.brand.BrandEntity; +import com.loopers.domain.brand.BrandRepository; +import com.loopers.domain.product.ProductDomainCreateRequest; +import com.loopers.domain.product.ProductEntity; +import com.loopers.domain.product.ProductRepository; + +/** + * 상품 테스트 픽스처 + * + * 테스트에서 사용할 상품 엔티티 및 요청 객체를 생성합니다. + * + * @author hyunjikoh + * @since 2025. 11. 10. + */ +public class ProductTestFixture { + private static final AtomicLong ID_GENERATOR = new AtomicLong(1); + + /** + * ProductDomainCreateRequest를 생성합니다 (할인가 없음). + * + * @param brandId 브랜드 ID + * @param name 상품명 + * @param description 상품 설명 + * @param originPrice 정가 + * @param stockQuantity 재고 수량 + * @return ProductDomainCreateRequest + */ + public static ProductDomainCreateRequest createRequest( + Long brandId, + String name, + String description, + BigDecimal originPrice, + Integer stockQuantity + ) { + return ProductDomainCreateRequest.of(brandId, name, description, originPrice, stockQuantity); + } + + /** + * ProductDomainCreateRequest를 생성합니다 (할인가 포함). + * + * @param brandId 브랜드 ID + * @param name 상품명 + * @param description 상품 설명 + * @param originPrice 정가 + * @param discountPrice 할인가 + * @param stockQuantity 재고 수량 + * @return ProductDomainCreateRequest + */ + public static ProductDomainCreateRequest createRequest( + Long brandId, + String name, + String description, + BigDecimal originPrice, + BigDecimal discountPrice, + Integer stockQuantity + ) { + return ProductDomainCreateRequest.of(brandId, name, description, originPrice, discountPrice, stockQuantity); + } + + /** + * 브랜드 엔티티로 상품을 생성합니다. + * + * @param brand 브랜드 엔티티 + * @return ProductEntity + */ + public static ProductEntity createEntity(BrandEntity brand) { + return createEntity( + brand.getId(), + "상품" + ID_GENERATOR.getAndIncrement(), + "상품 설명", + new BigDecimal("10000"), + 100 + ); + } + + /** + * ProductEntity를 생성합니다. + * + * @param brandId 브랜드 ID + * @param name 상품명 + * @param description 상품 설명 + * @param price 정가 + * @param stock 재고 수량 + * @return ProductEntity + */ + public static ProductEntity createEntity( + Long brandId, + String name, + String description, + BigDecimal price, + int stock + ) { + ProductDomainCreateRequest request = createRequest(brandId, name, description, price, stock); + return ProductEntity.createEntity(request); + } + + /** + * 상품을 생성하고 저장합니다. + * + * @param productRepository 상품 레포지토리 + * @param brand 브랜드 엔티티 + * @param name 상품명 + * @param description 상품 설명 + * @param price 정가 + * @param stock 재고 수량 + * @return 저장된 ProductEntity + */ + public static ProductEntity createAndSave( + ProductRepository productRepository, + BrandEntity brand, + String name, + String description, + BigDecimal price, + int stock + ) { + ProductEntity product = createEntity(brand.getId(), name, description, price, stock); + return productRepository.save(product); + } + + /** + * 상품을 생성하고 저장합니다 (기본값 사용). + * + * @param productRepository 상품 레포지토리 + * @param brand 브랜드 엔티티 + * @return 저장된 ProductEntity + */ + public static ProductEntity createAndSave( + ProductRepository productRepository, + BrandEntity brand + ) { + return createAndSave( + productRepository, + brand, + "상품" + ID_GENERATOR.getAndIncrement(), + "상품 설명", + new BigDecimal("10000"), + 100 + ); + } + + /** + * 브랜드와 상품을 생성하는 헬퍼 메서드 + * + * @param brandRepository 브랜드 리포지토리 + * @param productRepository 상품 리포지토리 + * @param brandCount 브랜드 수 + * @param productCountPerBrand 브랜드당 상품 수 + */ + public static void createBrandsAndProducts( + BrandRepository brandRepository, + ProductRepository productRepository, + int brandCount, + int productCountPerBrand + ) { + List brands = BrandTestFixture.createEntities(brandCount) + .stream() + .map(brandRepository::save) + .toList(); + + brands.forEach(brand -> { + IntStream.range(0, productCountPerBrand).forEach(i -> { + ProductTestFixture.createAndSave(productRepository, brand); + }); + }); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/fixtures/UserTestFixture.java b/apps/commerce-api/src/test/java/com/loopers/fixtures/UserTestFixture.java new file mode 100644 index 000000000..49ee37912 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/fixtures/UserTestFixture.java @@ -0,0 +1,186 @@ +package com.loopers.fixtures; + +import java.time.LocalDate; + +import org.assertj.core.api.Assertions; + +import com.loopers.application.user.UserRegisterCommand; +import com.loopers.domain.user.Gender; +import com.loopers.domain.user.UserDomainCreateRequest; +import com.loopers.domain.user.UserEntity; +import com.loopers.interfaces.api.user.UserV1Dtos; + +/** + * User 관련 테스트 픽스처 클래스 + * 테스트에서 자주 사용되는 User 객체 생성 메서드들을 제공합니다. + * + * @author hyunjikoh + * @since 2025. 10. 31. + */ +public class UserTestFixture { + + // 기본 유효한 데이터 + public static final String DEFAULT_USERNAME = "testuser"; + public static final String DEFAULT_EMAIL = "test@example.com"; + public static final String DEFAULT_BIRTHDATE = "1990-01-01"; + public static final Gender DEFAULT_GENDER = Gender.MALE; + + // 유효하지 않은 데이터 + public static final String INVALID_USERNAME_TOO_LONG = "testuser123"; // 11자 + public static final String INVALID_USERNAME_SPECIAL_CHAR = "test@user"; + public static final String INVALID_EMAIL = "invalid-email"; + public static final String INVALID_BIRTHDATE = "1990/01/01"; + + /** + * 기본값으로 UserDomainCreateRequest 생성 + */ + public static UserDomainCreateRequest createDefaultUserDomainRequest() { + return new UserDomainCreateRequest(DEFAULT_USERNAME, DEFAULT_EMAIL, DEFAULT_BIRTHDATE, DEFAULT_GENDER); + } + + /** + * 커스텀 UserDomainCreateRequest 생성 + */ + public static UserDomainCreateRequest createUserDomainRequest(String username, String email, String birthdate, + Gender gender) { + return new UserDomainCreateRequest(username, email, birthdate, gender); + } + + /** + * 기본값으로 UserV1Dtos.UserRegisterRequest 생성 (API 계층용) + */ + public static UserV1Dtos.UserRegisterRequest createDefaultApiRequest() { + return new UserV1Dtos.UserRegisterRequest(DEFAULT_USERNAME, DEFAULT_EMAIL, DEFAULT_BIRTHDATE, DEFAULT_GENDER); + } + + /** + * 커스텀 UserV1Dtos.UserRegisterRequest 생성 (API 계층용) + */ + public static UserV1Dtos.UserRegisterRequest createApiRequest(String username, String email, String birthdate, + Gender gender) { + return new UserV1Dtos.UserRegisterRequest(username, email, birthdate, gender); + } + + /** + * 기본값으로 UserEntity 생성 + */ + public static UserEntity createDefaultUserEntity() { + UserDomainCreateRequest request = createDefaultUserDomainRequest(); + return UserEntity.createUserEntity(request); + } + + /** + * 커스텀 UserEntity 생성 + */ + public static UserEntity createUserEntity(String username, String email, String birthdate, Gender gender) { + UserDomainCreateRequest request = createUserDomainRequest(username, email, birthdate, gender); + return UserEntity.createUserEntity(request); + } + + /** + * 기본값으로 UserRegisterCommand 생성 + */ + public static UserRegisterCommand createDefaultUserCommand() { + return UserRegisterCommand.of(DEFAULT_USERNAME, DEFAULT_EMAIL, DEFAULT_BIRTHDATE, DEFAULT_GENDER); + } + + /** + * 커스텀 UserRegisterCommand 생성 + */ + public static UserRegisterCommand createUserCommand(String username, String email, String birthdate, + Gender gender) { + return UserRegisterCommand.of(username, email, birthdate, gender); + } + + /** + * UserEntity 검증 헬퍼 메서드 + */ + public static void assertUserEntity(UserEntity actual, UserDomainCreateRequest expected) { + Assertions.assertThat(actual.getId()).isNotNull(); + Assertions.assertThat(actual.getUsername()).isEqualTo(expected.username()); + Assertions.assertThat(actual.getEmail()).isEqualTo(expected.email()); + Assertions.assertThat(actual.getBirthdate()).isEqualTo(LocalDate.parse(expected.birthdate())); + Assertions.assertThat(actual.getGender()).isEqualTo(expected.gender()); + } + + /** + * 저장된 UserEntity 검증 헬퍼 메서드 (DB 저장 후 검증용) + */ + public static void assertUserEntityPersisted(UserEntity actual, UserDomainCreateRequest expected) { + Assertions.assertThat(actual.getId()).isNotNull(); + Assertions.assertThat(actual.getUsername()).isEqualTo(expected.username()); + Assertions.assertThat(actual.getEmail()).isEqualTo(expected.email()); + Assertions.assertThat(actual.getBirthdate()).isEqualTo(LocalDate.parse(expected.birthdate())); + Assertions.assertThat(actual.getGender()).isEqualTo(expected.gender()); + Assertions.assertThat(actual.getCreatedAt()).isNotNull(); + Assertions.assertThat(actual.getDeletedAt()).isNull(); + } + + /** + * User 생성 실패 검증 헬퍼 메서드 + */ + public static void assertUserCreationFails(String username, String email, String birthdate, Gender gender, + String expectedMessage) { + UserDomainCreateRequest request = createUserDomainRequest(username, email, birthdate, gender); + + Assertions.assertThatThrownBy(() -> UserEntity.createUserEntity(request)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage(expectedMessage); + } + + /** + * 무효한 사용자명 테스트 데이터 + */ + public static class InvalidUsername { + public static final String TOO_LONG = INVALID_USERNAME_TOO_LONG; + public static final String SPECIAL_CHAR = INVALID_USERNAME_SPECIAL_CHAR; + public static final String EXPECTED_MESSAGE = "사용자명은 영문 및 숫자 10자 이내여야 합니다."; + } + + /** + * 무효한 이메일 테스트 데이터 + */ + public static class InvalidEmail { + public static final String INVALID_FORMAT = INVALID_EMAIL; + public static final String EXPECTED_MESSAGE = "올바른 형식의 이메일 주소여야 합니다."; + } + + /** + * 무효한 생년월일 테스트 데이터 + */ + public static class InvalidBirthdate { + public static final String INVALID_FORMAT = INVALID_BIRTHDATE; + public static final String NULL_MESSAGE = "생년월일은 필수 입력값입니다."; + public static final String FORMAT_MESSAGE = "올바른 날짜 형식이어야 합니다."; + } + + /** + * 무효한 성별 테스트 데이터 + */ + public static class InvalidGender { + public static final String EXPECTED_MESSAGE = "성별은 필수 입력값입니다."; + } + + /** + * 사용자의 포인트가 0인지 검증하는 헬퍼 메서드 + */ + public static void assertUserPointIsZero(UserEntity user) { + Assertions.assertThat(user.getPointAmount()).isEqualByComparingTo(java.math.BigDecimal.ZERO.setScale(2)); + } + + /** + * 사용자의 포인트 금액 검증 헬퍼 메서드 + */ + public static void assertUserPointAmount(UserEntity user, java.math.BigDecimal expectedAmount) { + Assertions.assertThat(user.getPointAmount()).isEqualByComparingTo(expectedAmount); + } + + /** + * 포인트 충전 실패 검증 헬퍼 메서드 + */ + public static void assertChargePointFails(UserEntity user, java.math.BigDecimal amount, String expectedMessage) { + Assertions.assertThatThrownBy(() -> user.chargePoint(amount)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage(expectedMessage); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ExampleV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ExampleV1ApiE2ETest.java index 1bb3dba65..c3f13ae14 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ExampleV1ApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ExampleV1ApiE2ETest.java @@ -1,9 +1,10 @@ package com.loopers.interfaces.api; -import com.loopers.domain.example.ExampleModel; -import com.loopers.infrastructure.example.ExampleJpaRepository; -import com.loopers.interfaces.api.example.ExampleV1Dto; -import com.loopers.utils.DatabaseCleanUp; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertTrue; +import java.util.function.Function; + import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; @@ -17,11 +18,11 @@ import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; -import java.util.function.Function; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertAll; -import static org.junit.jupiter.api.Assertions.assertTrue; +import com.loopers.domain.example.ExampleModel; +import com.loopers.infrastructure.example.ExampleJpaRepository; +import com.loopers.interfaces.api.example.ExampleV1Dto; +import com.loopers.utils.DatabaseCleanUp; +import com.loopers.utils.RedisCleanUp; @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) class ExampleV1ApiE2ETest { @@ -31,21 +32,26 @@ class ExampleV1ApiE2ETest { private final TestRestTemplate testRestTemplate; private final ExampleJpaRepository exampleJpaRepository; private final DatabaseCleanUp databaseCleanUp; + private final RedisCleanUp redisCleanUp; @Autowired public ExampleV1ApiE2ETest( - TestRestTemplate testRestTemplate, - ExampleJpaRepository exampleJpaRepository, - DatabaseCleanUp databaseCleanUp + TestRestTemplate testRestTemplate, + ExampleJpaRepository exampleJpaRepository, + DatabaseCleanUp databaseCleanUp, + RedisCleanUp redisCleanUp ) { this.testRestTemplate = testRestTemplate; this.exampleJpaRepository = exampleJpaRepository; this.databaseCleanUp = databaseCleanUp; + this.redisCleanUp = redisCleanUp; } @AfterEach void tearDown() { databaseCleanUp.truncateAllTables(); + redisCleanUp.truncateAll(); + } @DisplayName("GET /api/v1/examples/{id}") @@ -56,21 +62,22 @@ class Get { void returnsExampleInfo_whenValidIdIsProvided() { // arrange ExampleModel exampleModel = exampleJpaRepository.save( - new ExampleModel("예시 제목", "예시 설명") + new ExampleModel("예시 제목", "예시 설명") ); String requestUrl = ENDPOINT_GET.apply(exampleModel.getId()); // act - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { + }; ResponseEntity> response = - testRestTemplate.exchange(requestUrl, HttpMethod.GET, new HttpEntity<>(null), responseType); + testRestTemplate.exchange(requestUrl, HttpMethod.GET, new HttpEntity<>(null), responseType); // assert assertAll( - () -> assertTrue(response.getStatusCode().is2xxSuccessful()), - () -> assertThat(response.getBody().data().id()).isEqualTo(exampleModel.getId()), - () -> assertThat(response.getBody().data().name()).isEqualTo(exampleModel.getName()), - () -> assertThat(response.getBody().data().description()).isEqualTo(exampleModel.getDescription()) + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().id()).isEqualTo(exampleModel.getId()), + () -> assertThat(response.getBody().data().name()).isEqualTo(exampleModel.getName()), + () -> assertThat(response.getBody().data().description()).isEqualTo(exampleModel.getDescription()) ); } @@ -81,14 +88,15 @@ void throwsBadRequest_whenIdIsNotProvided() { String requestUrl = "/api/v1/examples/나나"; // act - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { + }; ResponseEntity> response = - testRestTemplate.exchange(requestUrl, HttpMethod.GET, new HttpEntity<>(null), responseType); + testRestTemplate.exchange(requestUrl, HttpMethod.GET, new HttpEntity<>(null), responseType); // assert assertAll( - () -> assertTrue(response.getStatusCode().is4xxClientError()), - () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST) + () -> assertTrue(response.getStatusCode().is4xxClientError()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST) ); } @@ -100,14 +108,15 @@ void throwsException_whenInvalidIdIsProvided() { String requestUrl = ENDPOINT_GET.apply(invalidId); // act - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { + }; ResponseEntity> response = - testRestTemplate.exchange(requestUrl, HttpMethod.GET, new HttpEntity<>(null), responseType); + testRestTemplate.exchange(requestUrl, HttpMethod.GET, new HttpEntity<>(null), responseType); // assert assertAll( - () -> assertTrue(response.getStatusCode().is4xxClientError()), - () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND) + () -> assertTrue(response.getStatusCode().is4xxClientError()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND) ); } } diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/LikeV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/LikeV1ApiE2ETest.java new file mode 100644 index 000000000..6aa94a234 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/LikeV1ApiE2ETest.java @@ -0,0 +1,528 @@ +package com.loopers.interfaces.api; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertTrue; +import java.math.BigDecimal; +import java.util.Objects; + +import org.junit.jupiter.api.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.*; + +import com.loopers.application.user.UserFacade; +import com.loopers.application.user.UserRegisterCommand; +import com.loopers.domain.brand.BrandEntity; +import com.loopers.domain.brand.BrandService; +import com.loopers.domain.product.ProductDomainCreateRequest; +import com.loopers.domain.product.ProductEntity; +import com.loopers.domain.product.ProductService; +import com.loopers.fixtures.BrandTestFixture; +import com.loopers.fixtures.ProductTestFixture; +import com.loopers.fixtures.UserTestFixture; +import com.loopers.interfaces.api.like.LikeV1Dtos; +import com.loopers.interfaces.api.product.ProductV1Dtos; +import com.loopers.support.Uris; +import com.loopers.utils.DatabaseCleanUp; +import com.loopers.utils.RedisCleanUp; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@DisplayName("Like API E2E 테스트") +class LikeV1ApiE2ETest { + + private final TestRestTemplate testRestTemplate; + private final DatabaseCleanUp databaseCleanUp; + private final RedisCleanUp redisCleanUp; + @Autowired + private ProductService productService; + + @Autowired + private BrandService brandService; + + @Autowired + private UserFacade userFacade; + + @Autowired + public LikeV1ApiE2ETest( + TestRestTemplate testRestTemplate, + DatabaseCleanUp databaseCleanUp, + RedisCleanUp redisCleanUp + ) { + this.testRestTemplate = testRestTemplate; + this.databaseCleanUp = databaseCleanUp; + this.redisCleanUp = redisCleanUp; + } + + private String testUsername; + private Long testProductId; + + @BeforeEach + void setUp() { + // 테스트용 사용자 생성 + UserRegisterCommand userCommand = UserTestFixture.createDefaultUserCommand(); + userFacade.registerUser(userCommand); + testUsername = userCommand.username(); + + // 테스트용 브랜드 생성 + BrandEntity brand = brandService.registerBrand( + BrandTestFixture.createRequest("테스트브랜드", "E2E 테스트용 브랜드") + ); + + // 테스트용 상품 생성 + ProductDomainCreateRequest productRequest = ProductTestFixture.createRequest( + brand.getId(), + "테스트상품", + "E2E 테스트용 상품", + new BigDecimal("10000"), + 100 + ); + ProductEntity product = productService.registerProduct(productRequest); + testProductId = product.getId(); + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + redisCleanUp.truncateAll(); + + } + + @Nested + @DisplayName("좋아요 등록 및 조회 플로우") + class LikeRegistrationAndRetrievalFlow { + + @Test + @DisplayName("좋아요 등록 후 상품 상세 조회 시 좋아요 여부가 true로 표시된다") + void like_product_and_verify_liked_status_in_product_detail() { + // given + HttpHeaders headers = new HttpHeaders(); + headers.set("X-USER-ID", testUsername); + + // when - 1. 좋아요 등록 + ParameterizedTypeReference> likeResponseType = + new ParameterizedTypeReference<>() { + }; + ResponseEntity> likeResponse = + testRestTemplate.exchange( + Uris.Like.UPSERT, + HttpMethod.POST, + new HttpEntity<>(null, headers), + likeResponseType, + testProductId + ); + + // then - 1. 좋아요 등록 성공 검증 + assertAll( + () -> assertTrue(likeResponse.getStatusCode().is2xxSuccessful()), + () -> assertThat(likeResponse.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(Objects.requireNonNull(likeResponse.getBody()).data().username()) + .isEqualTo(testUsername), + () -> assertThat(Objects.requireNonNull(likeResponse.getBody()).data().productId()) + .isEqualTo(testProductId) + ); + + // when - 2. 상품 상세 조회 (좋아요 여부 확인) + ParameterizedTypeReference> productResponseType = + new ParameterizedTypeReference<>() { + }; + ResponseEntity> productResponse = + testRestTemplate.exchange( + Uris.Product.GET_DETAIL, + HttpMethod.GET, + new HttpEntity<>(null, headers), + productResponseType, + testProductId + ); + + // then - 2. 좋아요 여부가 true로 표시되는지 검증 + assertAll( + () -> assertTrue(productResponse.getStatusCode().is2xxSuccessful()), + () -> assertThat(productResponse.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(Objects.requireNonNull(productResponse.getBody()).data().productId()) + .isEqualTo(testProductId), + () -> assertThat(Objects.requireNonNull(productResponse.getBody()).data().isLiked()) + .isTrue(), + () -> assertThat(Objects.requireNonNull(productResponse.getBody()).data().likeCount()) + .isEqualTo(1L) + ); + } + + @Test + @DisplayName("좋아요 등록 후 취소하면 상품 상세 조회 시 좋아요 여부가 false로 표시된다") + void unlike_product_and_verify_unliked_status_in_product_detail() { + // given + HttpHeaders headers = new HttpHeaders(); + headers.set("X-USER-ID", testUsername); + + // when - 1. 좋아요 등록 + ParameterizedTypeReference> likeResponseType = + new ParameterizedTypeReference<>() { + }; + testRestTemplate.exchange( + Uris.Like.UPSERT, + HttpMethod.POST, + new HttpEntity<>(null, headers), + likeResponseType, + testProductId + ); + + // when - 2. 좋아요 취소 + ParameterizedTypeReference> unlikeResponseType = + new ParameterizedTypeReference<>() { + }; + ResponseEntity> unlikeResponse = + testRestTemplate.exchange( + Uris.Like.CANCEL, + HttpMethod.DELETE, + new HttpEntity<>(null, headers), + unlikeResponseType, + testProductId + ); + + // then - 2. 좋아요 취소 성공 검증 + assertAll( + () -> assertTrue(unlikeResponse.getStatusCode().is2xxSuccessful()), + () -> assertThat(unlikeResponse.getStatusCode()).isEqualTo(HttpStatus.OK) + ); + + // when - 3. 상품 상세 조회 (좋아요 여부 확인) + ParameterizedTypeReference> productResponseType = + new ParameterizedTypeReference<>() { + }; + ResponseEntity> productResponse = + testRestTemplate.exchange( + Uris.Product.GET_DETAIL, + HttpMethod.GET, + new HttpEntity<>(null, headers), + productResponseType, + testProductId + ); + + // then - 3. 좋아요 여부가 false로 표시되는지 검증 + assertAll( + () -> assertTrue(productResponse.getStatusCode().is2xxSuccessful()), + () -> assertThat(productResponse.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(Objects.requireNonNull(productResponse.getBody()).data().productId()) + .isEqualTo(testProductId), + () -> assertThat(Objects.requireNonNull(productResponse.getBody()).data().isLiked()) + .isFalse(), + () -> assertThat(Objects.requireNonNull(productResponse.getBody()).data().likeCount()) + .isEqualTo(0L) + ); + } + + @Test + @DisplayName("비로그인 사용자는 상품 조회 시 좋아요 여부가 false로 표시된다") + void guest_user_sees_liked_status_as_false() { + // given - 로그인한 사용자가 좋아요 등록 + HttpHeaders headers = new HttpHeaders(); + headers.set("X-USER-ID", testUsername); + + ParameterizedTypeReference> likeResponseType = + new ParameterizedTypeReference<>() { + }; + testRestTemplate.exchange( + Uris.Like.UPSERT, + HttpMethod.POST, + new HttpEntity<>(null, headers), + likeResponseType, + testProductId + ); + + // when - 비로그인 사용자가 상품 상세 조회 (헤더 없음) + ParameterizedTypeReference> productResponseType = + new ParameterizedTypeReference<>() { + }; + ResponseEntity> productResponse = + testRestTemplate.exchange( + Uris.Product.GET_DETAIL, + HttpMethod.GET, + null, + productResponseType, + testProductId + ); + + // then - 비로그인 사용자는 좋아요 여부가 false + assertAll( + () -> assertTrue(productResponse.getStatusCode().is2xxSuccessful()), + () -> assertThat(Objects.requireNonNull(productResponse.getBody()).data().isLiked()) + .isFalse(), + () -> assertThat(Objects.requireNonNull(productResponse.getBody()).data().likeCount()) + .isEqualTo(1L) // 다른 사용자가 좋아요 했으므로 카운트는 1 + ); + } + + @Test + @DisplayName("중복 좋아요 등록 시 좋아요 수는 증가하지 않는다") + void duplicate_like_does_not_increase_like_count() { + // given + HttpHeaders headers = new HttpHeaders(); + headers.set("X-USER-ID", testUsername); + + // when - 1. 첫 번째 좋아요 등록 + ParameterizedTypeReference> likeResponseType = + new ParameterizedTypeReference<>() { + }; + testRestTemplate.exchange( + Uris.Like.UPSERT, + HttpMethod.POST, + new HttpEntity<>(null, headers), + likeResponseType, + testProductId + ); + + // when - 2. 두 번째 좋아요 등록 (중복) + testRestTemplate.exchange( + Uris.Like.UPSERT, + HttpMethod.POST, + new HttpEntity<>(null, headers), + likeResponseType, + testProductId + ); + + // when - 3. 상품 상세 조회 + ParameterizedTypeReference> productResponseType = + new ParameterizedTypeReference<>() { + }; + ResponseEntity> productResponse = + testRestTemplate.exchange( + Uris.Product.GET_DETAIL, + HttpMethod.GET, + new HttpEntity<>(null, headers), + productResponseType, + testProductId + ); + + // then - 좋아요 수는 1개만 카운트 + assertAll( + () -> assertTrue(productResponse.getStatusCode().is2xxSuccessful()), + () -> assertThat(Objects.requireNonNull(productResponse.getBody()).data().likeCount()) + .isEqualTo(1L), + () -> assertThat(Objects.requireNonNull(productResponse.getBody()).data().isLiked()) + .isTrue() + ); + } + } + + @Nested + @DisplayName("좋아요 등록 API") + class UpsertLikeTest { + + @Test + @DisplayName("유효한 사용자와 상품으로 좋아요 등록에 성공한다") + void upsert_like_success_with_valid_user_and_product() { + // given + HttpHeaders headers = new HttpHeaders(); + headers.set("X-USER-ID", testUsername); + + // when + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() { + }; + ResponseEntity> response = + testRestTemplate.exchange( + Uris.Like.UPSERT, + HttpMethod.POST, + new HttpEntity<>(null, headers), + responseType, + testProductId + ); + + // then + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(Objects.requireNonNull(response.getBody()).data().username()) + .isEqualTo(testUsername), + () -> assertThat(Objects.requireNonNull(response.getBody()).data().productId()) + .isEqualTo(testProductId), + () -> assertThat(Objects.requireNonNull(response.getBody()).data().productName()) + .isEqualTo("테스트상품") + ); + } + + @Test + @DisplayName("존재하지 않는 사용자로 좋아요 등록 시 404를 응답한다") + void upsert_like_fail_when_user_not_found() { + // given + HttpHeaders headers = new HttpHeaders(); + headers.set("X-USER-ID", "nonExistentUser"); + + // when + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() { + }; + ResponseEntity> response = + testRestTemplate.exchange( + Uris.Like.UPSERT, + HttpMethod.POST, + new HttpEntity<>(null, headers), + responseType, + testProductId + ); + + // then + assertAll( + () -> assertTrue(response.getStatusCode().is4xxClientError()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND) + ); + } + + @Test + @DisplayName("존재하지 않는 상품으로 좋아요 등록 시 404를 응답한다") + void upsert_like_fail_when_product_not_found() { + // given + HttpHeaders headers = new HttpHeaders(); + headers.set("X-USER-ID", testUsername); + Long nonExistentProductId = 99999L; + + // when + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() { + }; + ResponseEntity> response = + testRestTemplate.exchange( + Uris.Like.UPSERT, + HttpMethod.POST, + new HttpEntity<>(null, headers), + responseType, + nonExistentProductId + ); + + // then + assertAll( + () -> assertTrue(response.getStatusCode().is4xxClientError()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND) + ); + } + } + + @Nested + @DisplayName("좋아요 취소 API") + class UnlikeProductTest { + + @Test + @DisplayName("좋아요한 상품을 취소하면 성공한다") + void unlike_product_success() { + // given - 좋아요 등록 + HttpHeaders headers = new HttpHeaders(); + headers.set("X-USER-ID", testUsername); + + ParameterizedTypeReference> likeResponseType = + new ParameterizedTypeReference<>() { + }; + testRestTemplate.exchange( + Uris.Like.UPSERT, + HttpMethod.POST, + new HttpEntity<>(null, headers), + likeResponseType, + testProductId + ); + + // when - 좋아요 취소 + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() { + }; + ResponseEntity> response = + testRestTemplate.exchange( + Uris.Like.CANCEL, + HttpMethod.DELETE, + new HttpEntity<>(null, headers), + responseType, + testProductId + ); + + // then + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK) + ); + } + + @Test + @DisplayName("좋아요하지 않은 상품을 취소해도 성공한다 (멱등성)") + void unlike_product_success_even_when_not_liked() { + // given + HttpHeaders headers = new HttpHeaders(); + headers.set("X-USER-ID", testUsername); + + // when - 좋아요 없이 취소 시도 + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() { + }; + ResponseEntity> response = + testRestTemplate.exchange( + Uris.Like.CANCEL, + HttpMethod.DELETE, + new HttpEntity<>(null, headers), + responseType, + testProductId + ); + + // then - 멱등성 보장으로 성공 + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK) + ); + } + + @Test + @DisplayName("존재하지 않는 사용자로 좋아요 취소 시 404를 응답한다") + void unlike_product_fail_when_user_not_found() { + // given + HttpHeaders headers = new HttpHeaders(); + headers.set("X-USER-ID", "nonExistentUser"); + + // when + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() { + }; + ResponseEntity> response = + testRestTemplate.exchange( + Uris.Like.CANCEL, + HttpMethod.DELETE, + new HttpEntity<>(null, headers), + responseType, + testProductId + ); + + // then + assertAll( + () -> assertTrue(response.getStatusCode().is4xxClientError()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND) + ); + } + + @Test + @DisplayName("존재하지 않는 상품으로 좋아요 취소 시 404를 응답한다") + void unlike_product_fail_when_product_not_found() { + // given + HttpHeaders headers = new HttpHeaders(); + headers.set("X-USER-ID", testUsername); + Long nonExistentProductId = 99999L; + + // when + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() { + }; + ResponseEntity> response = + testRestTemplate.exchange( + Uris.Like.CANCEL, + HttpMethod.DELETE, + new HttpEntity<>(null, headers), + responseType, + nonExistentProductId + ); + + // then + assertAll( + () -> assertTrue(response.getStatusCode().is4xxClientError()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND) + ); + } + } +} + diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/OrderV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/OrderV1ApiE2ETest.java new file mode 100644 index 000000000..5ed2fa95b --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/OrderV1ApiE2ETest.java @@ -0,0 +1,369 @@ +package com.loopers.interfaces.api; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertTrue; +import java.math.BigDecimal; +import java.util.List; +import java.util.Objects; + +import org.junit.jupiter.api.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.*; + +import com.loopers.application.order.OrderCreateCommand; +import com.loopers.application.order.OrderFacade; +import com.loopers.application.order.OrderInfo; +import com.loopers.application.order.OrderItemCommand; +import com.loopers.application.point.PointFacade; +import com.loopers.application.user.UserFacade; +import com.loopers.application.user.UserRegisterCommand; +import com.loopers.domain.brand.BrandEntity; +import com.loopers.domain.brand.BrandService; +import com.loopers.domain.order.OrderStatus; +import com.loopers.domain.product.ProductDomainCreateRequest; +import com.loopers.domain.product.ProductEntity; +import com.loopers.domain.product.ProductService; +import com.loopers.fixtures.BrandTestFixture; +import com.loopers.fixtures.ProductTestFixture; +import com.loopers.fixtures.UserTestFixture; +import com.loopers.interfaces.api.common.PageResponse; +import com.loopers.interfaces.api.order.OrderV1Dtos; +import com.loopers.interfaces.api.point.PointV1Dtos; +import com.loopers.support.Uris; +import com.loopers.utils.DatabaseCleanUp; +import com.loopers.utils.RedisCleanUp; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@DisplayName("Order API E2E 테스트") +class OrderV1ApiE2ETest { + + private final TestRestTemplate testRestTemplate; + private final DatabaseCleanUp databaseCleanUp; + private final RedisCleanUp redisCleanUp; + @Autowired + private UserFacade userFacade; + + @Autowired + private OrderFacade orderFacade; + + @Autowired + private PointFacade pointFacade; + + @Autowired + private ProductService productService; + + @Autowired + private BrandService brandService; + + @Autowired + public OrderV1ApiE2ETest( + TestRestTemplate testRestTemplate, + DatabaseCleanUp databaseCleanUp, + RedisCleanUp redisCleanUp + ) { + this.testRestTemplate = testRestTemplate; + this.databaseCleanUp = databaseCleanUp; + this.redisCleanUp = redisCleanUp; + } + + private String testUsername; + private Long testProductId; + + @BeforeEach + void setUp() { + // 테스트용 브랜드 생성 + BrandEntity brand = brandService.registerBrand( + BrandTestFixture.createRequest("테스트브랜드", "E2E 테스트용 브랜드") + ); + + // 테스트용 상품 생성 + ProductDomainCreateRequest productRequest = ProductTestFixture.createRequest( + brand.getId(), + "테스트상품", + "E2E 테스트용 상품", + new BigDecimal("10000"), + 1000 + ); + ProductEntity product = productService.registerProduct(productRequest); + testProductId = product.getId(); + + // 테스트용 사용자 생성 + UserRegisterCommand userCommand = UserTestFixture.createDefaultUserCommand(); + userFacade.registerUser(userCommand); + testUsername = userCommand.username(); + + // 테스트용 포인트 충전 (충분한 금액) + PointV1Dtos.PointChargeRequest chargeRequest = new PointV1Dtos.PointChargeRequest(new BigDecimal("1000000")); + pointFacade.chargePoint(testUsername, chargeRequest); + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + testProductId = null; + testUsername = null; + redisCleanUp.truncateAll(); + + } + + @Nested + @DisplayName("주문 등록 API") + class CreateOrderTest { + + @Test + @DisplayName("올바른 주문 정보로 주문을 등록하면 주문 정보를 응답한다") + void create_order_success() { + // given + HttpHeaders headers = new HttpHeaders(); + headers.set("X-USER-ID", testUsername); + headers.setContentType(MediaType.APPLICATION_JSON); + + OrderV1Dtos.OrderCreateRequest request = new OrderV1Dtos.OrderCreateRequest( + List.of(new OrderV1Dtos.OrderItemRequest(testProductId, 2, null)) + ); + + // when + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() { + }; + ResponseEntity> response = + testRestTemplate.exchange(Uris.Order.CREATE, HttpMethod.POST, + new HttpEntity<>(request, headers), responseType); + + // then + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(Objects.requireNonNull(response.getBody()).data().orderId()).isNotNull(), + () -> assertThat(Objects.requireNonNull(response.getBody()).data().status()) + .isEqualTo(OrderStatus.PENDING), + () -> assertThat(Objects.requireNonNull(response.getBody()).data().originalTotalAmount()) + .isEqualByComparingTo(new BigDecimal("20000.00")), + () -> assertThat(Objects.requireNonNull(response.getBody()).data().finalTotalAmount()) + .isEqualByComparingTo(new BigDecimal("20000.00")) + ); + } + + @Test + @DisplayName("X-USER-ID 헤더가 없으면 400 Bad Request 응답을 반환한다") + void create_order_fail_when_header_missing() { + // given + OrderV1Dtos.OrderCreateRequest request = new OrderV1Dtos.OrderCreateRequest( + List.of(new OrderV1Dtos.OrderItemRequest(testProductId, 1, null)) + ); + + // when + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() { + }; + ResponseEntity> response = + testRestTemplate.exchange(Uris.Order.CREATE, HttpMethod.POST, + new HttpEntity<>(request, null), responseType); + + // then + assertAll( + () -> assertTrue(response.getStatusCode().is4xxClientError()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST) + ); + } + + @Test + @DisplayName("존재하지 않는 사용자로 주문 등록 시 404 Not Found 응답을 반환한다") + void create_order_fail_when_user_not_found() { + // given + HttpHeaders headers = new HttpHeaders(); + headers.set("X-USER-ID", "nonexistentuser"); + headers.setContentType(MediaType.APPLICATION_JSON); + + OrderV1Dtos.OrderCreateRequest request = new OrderV1Dtos.OrderCreateRequest( + List.of(new OrderV1Dtos.OrderItemRequest(testProductId, 1, null)) + ); + + // when + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() { + }; + ResponseEntity> response = + testRestTemplate.exchange(Uris.Order.CREATE, HttpMethod.POST, + new HttpEntity<>(request, headers), responseType); + + // then + assertAll( + () -> assertTrue(response.getStatusCode().is4xxClientError()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND) + ); + } + } + + @Nested + @DisplayName("주문 목록 조회 API") + class GetOrdersTest { + + @Test + @DisplayName("사용자의 주문 목록을 페이징하여 조회한다") + void get_orders_with_pagination_success() { + // given + createTestOrder(testUsername, 1); + createTestOrder(testUsername, 2); + createTestOrder(testUsername, 3); + + HttpHeaders headers = new HttpHeaders(); + headers.set("X-USER-ID", testUsername); + + // when + ParameterizedTypeReference>> responseType = + new ParameterizedTypeReference<>() { + }; + ResponseEntity>> response = + testRestTemplate.exchange(Uris.Order.GET_LIST + "?page=0&size=2", + HttpMethod.GET, new HttpEntity<>(null, headers), responseType); + + // then + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(Objects.requireNonNull(response.getBody()).data().content()).hasSize(2), + () -> assertThat(Objects.requireNonNull(response.getBody()).data().totalElements()) + .isEqualTo(3), + () -> assertThat(Objects.requireNonNull(response.getBody()).data().totalPages()) + .isEqualTo(2) + ); + } + + @Test + @DisplayName("주문이 없는 사용자는 빈 목록을 응답받는다") + void get_orders_returns_empty_list_when_no_orders() { + // given + HttpHeaders headers = new HttpHeaders(); + headers.set("X-USER-ID", testUsername); + + // when + ParameterizedTypeReference>> responseType = + new ParameterizedTypeReference<>() { + }; + ResponseEntity>> response = + testRestTemplate.exchange(Uris.Order.GET_LIST, + HttpMethod.GET, new HttpEntity<>(null, headers), responseType); + + // then + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(Objects.requireNonNull(response.getBody()).data().content()).isEmpty(), + () -> assertThat(Objects.requireNonNull(response.getBody()).data().totalElements()) + .isEqualTo(0) + ); + } + + @Test + @DisplayName("X-USER-ID 헤더가 없으면 400 Bad Request 응답을 반환한다") + void get_orders_fail_when_header_missing() { + // when + ParameterizedTypeReference>> responseType = + new ParameterizedTypeReference<>() { + }; + ResponseEntity>> response = + testRestTemplate.exchange(Uris.Order.GET_LIST, + HttpMethod.GET, new HttpEntity<>(null, null), responseType); + + // then + assertAll( + () -> assertTrue(response.getStatusCode().is4xxClientError()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST) + ); + } + } + + @Nested + @DisplayName("주문 상세 조회 API") + class GetOrderDetailTest { + + @Test + @DisplayName("주문 ID로 주문 상세 정보를 조회한다") + void get_order_detail_success() { + // given + Long orderId = createTestOrder(testUsername, 3); + HttpHeaders headers = new HttpHeaders(); + headers.set("X-USER-ID", testUsername); + + // when + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() { + }; + ResponseEntity> response = + testRestTemplate.exchange(Uris.Order.GET_DETAIL, + HttpMethod.GET, new HttpEntity<>(null, headers), responseType, orderId); + + // then + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(Objects.requireNonNull(response.getBody()).data().orderId()) + .isEqualTo(orderId), + () -> assertThat(Objects.requireNonNull(response.getBody()).data().userId()).isNotNull(), + () -> assertThat(Objects.requireNonNull(response.getBody()).data().originalTotalAmount()) + .isEqualByComparingTo(new BigDecimal("30000.00")), + () -> assertThat(Objects.requireNonNull(response.getBody()).data().finalTotalAmount()) + .isEqualByComparingTo(new BigDecimal("30000.00")), + () -> assertThat(Objects.requireNonNull(response.getBody()).data().items()) + .isNotEmpty() + ); + } + + @Test + @DisplayName("존재하지 않는 주문 ID로 조회하면 404 Not Found 응답을 반환한다") + void get_order_detail_fail_when_order_not_found() { + // given + + Long nonExistentOrderId = 99999L; + HttpHeaders headers = new HttpHeaders(); + headers.set("X-USER-ID", testUsername); + + // when + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() { + }; + ResponseEntity> response = + testRestTemplate.exchange(Uris.Order.GET_DETAIL, + HttpMethod.GET, new HttpEntity<>(null, headers), responseType, nonExistentOrderId); + + // then + assertAll( + () -> assertTrue(response.getStatusCode().is4xxClientError()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND) + ); + } + + @Test + @DisplayName("숫자가 아닌 주문 ID로 조회하면 400 Bad Request 응답을 반환한다") + void get_order_detail_fail_when_invalid_order_id() { + // when + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() { + }; + ResponseEntity> response = + testRestTemplate.exchange(Uris.Order.BASE + "/invalid", + HttpMethod.GET, null, responseType); + + // then + assertAll( + () -> assertTrue(response.getStatusCode().is4xxClientError()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST) + ); + } + } + + // 테스트 헬퍼 메서드 + private Long createTestOrder(String username, int quantity) { + OrderCreateCommand command = new OrderCreateCommand( + username, + List.of(new OrderItemCommand(testProductId, quantity, null)) + ); + OrderInfo orderInfo = orderFacade.createOrder(command); + return orderInfo.id(); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/PointV1E2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/PointV1E2ETest.java new file mode 100644 index 000000000..31cd94dad --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/PointV1E2ETest.java @@ -0,0 +1,259 @@ +package com.loopers.interfaces.api; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertTrue; +import java.math.BigDecimal; +import java.util.Objects; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +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.boot.test.web.client.TestRestTemplate; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.*; + +import com.loopers.application.user.UserFacade; +import com.loopers.application.user.UserRegisterCommand; +import com.loopers.fixtures.UserTestFixture; +import com.loopers.interfaces.api.point.PointV1Dtos; +import com.loopers.support.Uris; +import com.loopers.utils.DatabaseCleanUp; +import com.loopers.utils.RedisCleanUp; + +/** + * @author hyunjikoh + * @since 2025. 10. 29. + */ +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@DisplayName("Point API E2E 테스트") +class PointV1E2ETest { + private final TestRestTemplate testRestTemplate; + private final DatabaseCleanUp databaseCleanUp; + private final RedisCleanUp redisCleanUp; + @Autowired + private UserFacade userFacade; + + @Autowired + public PointV1E2ETest( + TestRestTemplate testRestTemplate, + DatabaseCleanUp databaseCleanUp, + UserFacade userFacade, + RedisCleanUp redisCleanUp + ) { + this.testRestTemplate = testRestTemplate; + this.databaseCleanUp = databaseCleanUp; + this.userFacade = userFacade; + this.redisCleanUp = redisCleanUp; + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + redisCleanUp.truncateAll(); + + } + + @Nested + @DisplayName("포인트 조회 API") + class GetPointInfoTest { + + @Test + @DisplayName("X-USER-ID 헤더를 기준으로 사용자의 포인트 조회를 성공한다") + void get_user_point_success() { + // given + UserRegisterCommand userCommand = UserTestFixture.createDefaultUserCommand(); + userFacade.registerUser(userCommand); + + HttpHeaders headers = new HttpHeaders(); + headers.set("X-USER-ID", userCommand.username()); + + // when + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() { + }; + ResponseEntity> response = + testRestTemplate.exchange(Uris.Point.GET_INFO, HttpMethod.GET, + new HttpEntity<>(null, headers), responseType); + + // then + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(Objects.requireNonNull(response.getBody()).data().username()) + .isEqualTo(userCommand.username()), + () -> assertThat(Objects.requireNonNull(response.getBody()).data().currentPointAmount()) + .isEqualTo(BigDecimal.ZERO.setScale(2)) + ); + } + + @Test + @DisplayName("X-USER-ID 헤더가 없을 경우 400 Bad Request 응답을 반환한다") + void get_user_point_fail_when_header_missing() { + // when + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() { + }; + ResponseEntity> response = + testRestTemplate.exchange(Uris.Point.GET_INFO, HttpMethod.GET, + new HttpEntity<>(null, null), responseType); + + // then + assertAll( + () -> assertTrue(response.getStatusCode().is4xxClientError()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST) + ); + } + } + + @Nested + @DisplayName("포인트 충전 API") + class ChargePointTest { + + @Test + @DisplayName("존재하는 유저가 1000원을 충전하면 충전된 보유 총량을 응답으로 반환한다") + void charge_1000_returns_total_amount() { + // given + UserRegisterCommand userCommand = UserTestFixture.createDefaultUserCommand(); + userFacade.registerUser(userCommand); + + HttpHeaders headers = new HttpHeaders(); + headers.set("X-USER-ID", userCommand.username()); + headers.setContentType(MediaType.APPLICATION_JSON); + + PointV1Dtos.PointChargeRequest chargeRequest = new PointV1Dtos.PointChargeRequest(new BigDecimal("1000")); + + // when + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() { + }; + ResponseEntity> response = + testRestTemplate.exchange(Uris.Point.CHARGE, HttpMethod.POST, + new HttpEntity<>(chargeRequest, headers), responseType); + + // then + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(Objects.requireNonNull(response.getBody()).data().username()) + .isEqualTo(userCommand.username()), + () -> assertThat(Objects.requireNonNull(response.getBody()).data().totalAmount()) + .isEqualByComparingTo(new BigDecimal("1000.00")) + ); + } + + @Test + @DisplayName("존재하는 유저가 여러 번 충전하면 누적된 총 금액을 응답으로 반환한다") + void charge_multiple_times_returns_accumulated_amount() { + // given + UserRegisterCommand userCommand = UserTestFixture.createDefaultUserCommand(); + userFacade.registerUser(userCommand); + + HttpHeaders headers = new HttpHeaders(); + headers.set("X-USER-ID", userCommand.username()); + headers.setContentType(MediaType.APPLICATION_JSON); + + // 1차 충전: 1000원 + PointV1Dtos.PointChargeRequest firstChargeRequest = new PointV1Dtos.PointChargeRequest(new BigDecimal("1000")); + + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() { + }; + + ResponseEntity> firstResponse = + testRestTemplate.exchange(Uris.Point.CHARGE, HttpMethod.POST, + new HttpEntity<>(firstChargeRequest, headers), responseType); + + // 1차 충전 검증 + assertAll( + () -> assertTrue(firstResponse.getStatusCode().is2xxSuccessful()), + () -> assertThat(firstResponse.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(Objects.requireNonNull(firstResponse.getBody()).data().totalAmount()) + .isEqualByComparingTo(new BigDecimal("1000.00")) + ); + + // 2차 충전: 2500원 + PointV1Dtos.PointChargeRequest secondChargeRequest = new PointV1Dtos.PointChargeRequest(new BigDecimal("2500")); + + ResponseEntity> secondResponse = + testRestTemplate.exchange(Uris.Point.CHARGE, HttpMethod.POST, + new HttpEntity<>(secondChargeRequest, headers), responseType); + + // 2차 충전 검증 (1000 + 2500 = 3500) + assertAll( + () -> assertTrue(secondResponse.getStatusCode().is2xxSuccessful()), + () -> assertThat(secondResponse.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(Objects.requireNonNull(secondResponse.getBody()).data().username()) + .isEqualTo(userCommand.username()), + () -> assertThat(Objects.requireNonNull(secondResponse.getBody()).data().totalAmount()) + .isEqualByComparingTo(new BigDecimal("3500.00")) + ); + + // 3차 충전: 500원 + PointV1Dtos.PointChargeRequest thirdChargeRequest = new PointV1Dtos.PointChargeRequest(new BigDecimal("500")); + + ResponseEntity> thirdResponse = + testRestTemplate.exchange(Uris.Point.CHARGE, HttpMethod.POST, + new HttpEntity<>(thirdChargeRequest, headers), responseType); + + // 3차 충전 검증 (1000 + 2500 + 500 = 4000) + assertAll( + () -> assertTrue(thirdResponse.getStatusCode().is2xxSuccessful()), + () -> assertThat(thirdResponse.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(Objects.requireNonNull(thirdResponse.getBody()).data().username()) + .isEqualTo(userCommand.username()), + () -> assertThat(Objects.requireNonNull(thirdResponse.getBody()).data().totalAmount()) + .isEqualByComparingTo(new BigDecimal("4000.00")) + ); + + // 최종 포인트 조회로 검증 + ParameterizedTypeReference> pointInfoResponseType = + new ParameterizedTypeReference<>() { + }; + HttpHeaders getHeaders = new HttpHeaders(); + getHeaders.set("X-USER-ID", userCommand.username()); + + ResponseEntity> pointInfoResponse = + testRestTemplate.exchange(Uris.Point.GET_INFO, HttpMethod.GET, + new HttpEntity<>(null, getHeaders), pointInfoResponseType); + + // 최종 포인트 조회 검증 + assertAll( + () -> assertTrue(pointInfoResponse.getStatusCode().is2xxSuccessful()), + () -> assertThat(pointInfoResponse.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(Objects.requireNonNull(pointInfoResponse.getBody()).data().username()) + .isEqualTo(userCommand.username()), + () -> assertThat(Objects.requireNonNull(pointInfoResponse.getBody()).data().currentPointAmount()) + .isEqualByComparingTo(new BigDecimal("4000.00")) + ); + } + + @Test + @DisplayName("존재하지 않는 유저로 요청하면 404 Not Found 응답을 반환한다") + void charge_with_nonexistent_user_returns_404() { + // given + HttpHeaders headers = new HttpHeaders(); + headers.set("X-USER-ID", "nonexistentuser"); + headers.setContentType(MediaType.APPLICATION_JSON); + + PointV1Dtos.PointChargeRequest chargeRequest = new PointV1Dtos.PointChargeRequest(new BigDecimal("1000")); + + // when + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() { + }; + ResponseEntity> response = + testRestTemplate.exchange(Uris.Point.CHARGE, HttpMethod.POST, + new HttpEntity<>(chargeRequest, headers), responseType); + + // then + assertAll( + () -> assertTrue(response.getStatusCode().is4xxClientError()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND) + ); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ProductV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ProductV1ApiE2ETest.java new file mode 100644 index 000000000..705957481 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ProductV1ApiE2ETest.java @@ -0,0 +1,380 @@ +package com.loopers.interfaces.api; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertTrue; +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +import org.junit.jupiter.api.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.*; + +import com.loopers.application.product.ProductFacade; +import com.loopers.application.user.UserFacade; +import com.loopers.application.user.UserRegisterCommand; +import com.loopers.domain.brand.BrandEntity; +import com.loopers.domain.brand.BrandService; +import com.loopers.domain.product.ProductDomainCreateRequest; +import com.loopers.domain.product.ProductEntity; +import com.loopers.domain.product.ProductMVService; +import com.loopers.domain.product.ProductService; +import com.loopers.fixtures.BrandTestFixture; +import com.loopers.fixtures.ProductTestFixture; +import com.loopers.fixtures.UserTestFixture; +import com.loopers.infrastructure.product.ProductJpaRepository; +import com.loopers.interfaces.api.common.PageResponse; +import com.loopers.interfaces.api.product.ProductV1Dtos; +import com.loopers.support.Uris; +import com.loopers.utils.DatabaseCleanUp; +import com.loopers.utils.RedisCleanUp; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@DisplayName("Product API E2E 테스트") +class ProductV1ApiE2ETest { + + private final TestRestTemplate testRestTemplate; + private final DatabaseCleanUp databaseCleanUp; + private final RedisCleanUp redisCleanUp; + @Autowired + private ProductService productService; + + @Autowired + private ProductJpaRepository productJpaRepository; + + @Autowired + private BrandService brandService; + + @Autowired + private UserFacade userFacade; + + @Autowired + private ProductMVService productMVService; + + @Autowired + private ProductFacade productFacade; + + + @Autowired + public ProductV1ApiE2ETest( + TestRestTemplate testRestTemplate, + DatabaseCleanUp databaseCleanUp, + RedisCleanUp redisCleanUp + ) { + this.testRestTemplate = testRestTemplate; + this.databaseCleanUp = databaseCleanUp; + this.redisCleanUp = redisCleanUp; + } + + private Long testBrandId; + private final List testProductIds = new ArrayList<>(); + private String testUsername; + + @BeforeEach + void setUp() { + // 테스트용 브랜드 생성 + BrandEntity brand = brandService.registerBrand( + BrandTestFixture.createRequest("테스트브랜드", "E2E 테스트용 브랜드") + ); + testBrandId = brand.getId(); + + // 테스트용 상품 여러 개 생성 + for (int i = 1; i <= 5; i++) { + ProductDomainCreateRequest productRequest = ProductTestFixture.createRequest( + testBrandId, + "테스트상품" + i, + "E2E 테스트용 상품 " + i, + new BigDecimal(String.valueOf(10000 * i)), + new BigDecimal(String.valueOf(7000 * i)), + 100 + ); + ProductEntity product = productService.registerProduct(productRequest); + testProductIds.add(product.getId()); + } + productMVService.syncMaterializedView(); + + // 테스트용 사용자 생성 (상품 상세 조회 시 좋아요 정보 테스트용) + UserRegisterCommand userCommand = UserTestFixture.createDefaultUserCommand(); + userFacade.registerUser(userCommand); + testUsername = userCommand.username(); + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + testProductIds.clear(); + testBrandId = null; + testUsername = null; + redisCleanUp.truncateAll(); + + } + + @Nested + @DisplayName("상품 목록 조회 API") + class GetProductsTest { + + @Test + @DisplayName("상품 목록을 페이징하여 조회한다") + void get_products_with_pagination_success() { + // when + ParameterizedTypeReference>> responseType = + new ParameterizedTypeReference<>() { + }; + ResponseEntity>> response = + testRestTemplate.exchange(Uris.Product.GET_LIST + "?page=0&size=3", + HttpMethod.GET, null, responseType); + + // then + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(Objects.requireNonNull(response.getBody()).data().content()).hasSize(3), + () -> assertThat(Objects.requireNonNull(response.getBody()).data().totalElements()) + .isEqualTo(5), + () -> assertThat(Objects.requireNonNull(response.getBody()).data().totalPages()) + .isEqualTo(2), + () -> assertThat(Objects.requireNonNull(response.getBody()).data().first()).isTrue(), + () -> assertThat(Objects.requireNonNull(response.getBody()).data().last()).isFalse() + ); + } + + @Test + @DisplayName("두 번째 페이지를 조회하면 남은 상품들을 응답한다") + void get_products_second_page_success() { + // when + ParameterizedTypeReference>> responseType = + new ParameterizedTypeReference<>() { + }; + ResponseEntity>> response = + testRestTemplate.exchange(Uris.Product.GET_LIST + "?page=1&size=3", + HttpMethod.GET, null, responseType); + + // then + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(Objects.requireNonNull(response.getBody()).data().content()).hasSize(2), + () -> assertThat(Objects.requireNonNull(response.getBody()).data().pageNumber()).isEqualTo(1), + () -> assertThat(Objects.requireNonNull(response.getBody()).data().first()).isFalse(), + () -> assertThat(Objects.requireNonNull(response.getBody()).data().last()).isTrue() + ); + } + + @Test + @DisplayName("기본 페이지 크기로 조회하면 20개씩 조회된다") + void get_products_with_default_page_size_success() { + // given - 21개의 상품 추가 생성 (총 26개) + for (int i = 6; i <= 26; i++) { + ProductDomainCreateRequest productRequest = ProductTestFixture.createRequest( + testBrandId, + "추가상품" + i, + "추가 상품 " + i, + new BigDecimal("5000"), + 50 + ); + productService.registerProduct(productRequest); + } + productMVService.syncMaterializedView(); + + // when + ParameterizedTypeReference>> responseType = + new ParameterizedTypeReference<>() { + }; + ResponseEntity>> response = + testRestTemplate.exchange(Uris.Product.GET_LIST, + HttpMethod.GET, null, responseType); + + // then + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(Objects.requireNonNull(response.getBody()).data().content()).hasSize(20), + () -> assertThat(Objects.requireNonNull(response.getBody()).data().pageSize()).isEqualTo(20), + () -> assertThat(Objects.requireNonNull(response.getBody()).data().totalElements()) + .isEqualTo(26) + ); + } + + @Test + @DisplayName("상품이 없으면 빈 목록을 응답한다") + void get_products_returns_empty_list_when_no_products() { + // given - 모든 상품 삭제 + testProductIds.forEach(productId -> { + productFacade.deletedProduct(productId); + }); + productMVService.syncMaterializedView(); + + // when + ParameterizedTypeReference>> responseType = + new ParameterizedTypeReference<>() { + }; + ResponseEntity>> response = + testRestTemplate.exchange(Uris.Product.GET_LIST, + HttpMethod.GET, null, responseType); + + // then + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(Objects.requireNonNull(response.getBody()).data().content()).isEmpty(), + () -> assertThat(Objects.requireNonNull(response.getBody()).data().totalElements()) + .isEqualTo(0), + () -> assertThat(Objects.requireNonNull(response.getBody()).data().empty()).isTrue() + ); + } + } + + @Nested + @DisplayName("상품 상세 조회 API") + class GetProductDetailTest { + + @Test + @DisplayName("상품 ID로 상품 상세 정보를 조회한다") + void get_product_detail_success() { + // given + Long productId = testProductIds.get(0); + + // when + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() { + }; + ResponseEntity> response = + testRestTemplate.exchange(Uris.Product.GET_DETAIL, + HttpMethod.GET, null, responseType, productId); + + // then + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(Objects.requireNonNull(response.getBody()).data().productId()) + .isEqualTo(productId), + () -> assertThat(Objects.requireNonNull(response.getBody()).data().name()) + .isEqualTo("테스트상품1"), + () -> assertThat(Objects.requireNonNull(response.getBody()).data().brand()).isNotNull(), + () -> assertThat(Objects.requireNonNull(response.getBody()).data().brand().brandId()) + .isEqualTo(testBrandId), + () -> assertThat(Objects.requireNonNull(response.getBody()).data().isLiked()) + .isFalse() // 비로그인 사용자는 좋아요 안 함 + ); + } + + @Test + @DisplayName("로그인한 사용자는 좋아요 정보를 함께 조회한다") + void get_product_detail_with_user_success() { + // given + Long productId = testProductIds.get(0); + HttpHeaders headers = new HttpHeaders(); + headers.set("X-USER-ID", testUsername); + + // when + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() { + }; + ResponseEntity> response = + testRestTemplate.exchange(Uris.Product.GET_DETAIL, + HttpMethod.GET, new HttpEntity<>(null, headers), responseType, productId); + + // then + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(Objects.requireNonNull(response.getBody()).data().productId()) + .isEqualTo(productId), + () -> assertThat(Objects.requireNonNull(response.getBody()).data().isLiked()) + .isFalse() // 아직 좋아요 하지 않음 + ); + } + + @Test + @DisplayName("상품 상세 정보에 가격 정보가 포함된다") + void get_product_detail_includes_price_info() { + // given + Long productId = testProductIds.get(0); + + // when + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() { + }; + ResponseEntity> response = + testRestTemplate.exchange(Uris.Product.GET_DETAIL, + HttpMethod.GET, null, responseType, productId); + + // then + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(Objects.requireNonNull(response.getBody()).data().price()).isNotNull(), + () -> assertThat(Objects.requireNonNull(response.getBody()).data().price().originPrice()) + .isEqualByComparingTo(new BigDecimal("10000")), + () -> assertThat(Objects.requireNonNull(response.getBody()).data().price().discountPrice()) + .isNotNull() + ); + } + + @Test + @DisplayName("존재하지 않는 상품 ID로 조회하면 404 Not Found를 응답한다") + void get_product_detail_fail_when_product_not_found() { + // given + Long nonExistentProductId = 99999L; + + // when + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() { + }; + ResponseEntity> response = + testRestTemplate.exchange(Uris.Product.GET_DETAIL, + HttpMethod.GET, null, responseType, nonExistentProductId); + + // then + assertAll( + () -> assertTrue(response.getStatusCode().is4xxClientError()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND) + ); + } + + @Test + @DisplayName("삭제된 상품 ID로 조회하면 404 Not Found를 응답한다") + void get_product_detail_fail_when_product_deleted() { + // given + Long productId = testProductIds.get(0); + ProductEntity product = productService.getActiveProductDetail(productId); + productFacade.deletedProduct(product.getId()); + + + // when + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() { + }; + ResponseEntity> response = + testRestTemplate.exchange(Uris.Product.GET_DETAIL, + HttpMethod.GET, null, responseType, productId); + + // then + assertAll( + () -> assertTrue(response.getStatusCode().is4xxClientError()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND) + ); + } + + @Test + @DisplayName("숫자가 아닌 상품 ID로 조회하면 400 Bad Request를 응답한다") + void get_product_detail_fail_when_invalid_product_id() { + // when + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() { + }; + ResponseEntity> response = + testRestTemplate.exchange("/api/v1/products/invalid", + HttpMethod.GET, null, responseType); + + // then + assertAll( + () -> assertTrue(response.getStatusCode().is4xxClientError()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST) + ); + } + } +} + diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserV1ApiE2ETest.java new file mode 100644 index 000000000..b0a0c24e8 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserV1ApiE2ETest.java @@ -0,0 +1,168 @@ +package com.loopers.interfaces.api; + +import static com.loopers.fixtures.UserTestFixture.*; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertTrue; +import java.util.Objects; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +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.boot.test.web.client.TestRestTemplate; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import com.loopers.domain.user.UserEntity; +import com.loopers.domain.user.UserRepository; +import com.loopers.fixtures.UserTestFixture; +import com.loopers.interfaces.api.user.UserV1Dtos; +import com.loopers.support.Uris; +import com.loopers.utils.DatabaseCleanUp; +import com.loopers.utils.RedisCleanUp; + +/** + * @author hyunjikoh + * @since 2025. 10. 27. + */ +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@DisplayName("User API E2E 테스트") +class UserV1ApiE2ETest { + + private final TestRestTemplate testRestTemplate; + private final DatabaseCleanUp databaseCleanUp; + private final RedisCleanUp redisCleanUp; + + @Autowired + private UserRepository userRepository; + + @Autowired + public UserV1ApiE2ETest( + TestRestTemplate testRestTemplate, + DatabaseCleanUp databaseCleanUp, + RedisCleanUp redisCleanUp + ) { + this.testRestTemplate = testRestTemplate; + this.databaseCleanUp = databaseCleanUp; + this.redisCleanUp = redisCleanUp; + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + redisCleanUp.truncateAll(); + + } + + @Nested + @DisplayName("회원가입 API") + class RegisterUserTest { + + @Test + @DisplayName("올바른 API 요청으로 회원가입하면 생성된 사용자 정보를 응답한다") + void register_user_success() { + // given + UserV1Dtos.UserRegisterRequest apiRequest = UserTestFixture.createDefaultApiRequest(); + + // when + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() { + }; + ResponseEntity> response = + testRestTemplate.exchange(Uris.User.REGISTER, HttpMethod.POST, + new HttpEntity<>(apiRequest), responseType); + + // then + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(Objects.requireNonNull(response.getBody()).data().username()) + .isEqualTo(DEFAULT_USERNAME), + () -> assertThat(Objects.requireNonNull(response.getBody()).data().email()) + .isEqualTo(DEFAULT_EMAIL) + ); + } + + @Test + @DisplayName("성별이 null인 회원가입 요청 시 400 Bad Request 응답을 반환한다") + void register_user_fail_when_gender_is_null() { + // given + UserV1Dtos.UserRegisterRequest apiRequest = UserTestFixture.createApiRequest( + "testuser", "dvum0045@gmail.com", "1990-01-01", null); + + // when + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() { + }; + ResponseEntity> response = + testRestTemplate.exchange(Uris.User.REGISTER, HttpMethod.POST, + new HttpEntity<>(apiRequest), responseType); + + // then + assertAll( + () -> assertTrue(response.getStatusCode().is4xxClientError()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST) + ); + } + } + + @Nested + @DisplayName("사용자 조회 API") + class GetUserTest { + + @Test + @DisplayName("등록된 사용자명으로 사용자를 조회하면 사용자 정보를 응답한다") + void get_user_by_username_success() { + // given + UserEntity userEntity = UserTestFixture.createDefaultUserEntity(); + userRepository.save(userEntity); + + // when + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() { + }; + ResponseEntity> response = + testRestTemplate.exchange(Uris.User.GET_BY_USERNAME + "?username=testuser", + HttpMethod.GET, null, responseType); + + // then + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(Objects.requireNonNull(response.getBody()).data().username()) + .isEqualTo(DEFAULT_USERNAME), + () -> assertThat(Objects.requireNonNull(response.getBody()).data().email()) + .isEqualTo(DEFAULT_EMAIL), + () -> assertThat(Objects.requireNonNull(response.getBody()).data().gender()) + .isEqualTo(DEFAULT_GENDER) + ); + } + + @Test + @DisplayName("존재하지 않는 사용자명으로 조회하면 404 Not Found 응답을 반환한다") + void get_user_by_username_fail_when_not_found() { + // given + String nonExistentUsername = "nonExistentUser"; + + // when + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() { + }; + ResponseEntity> response = + testRestTemplate.exchange(Uris.User.GET_BY_USERNAME + "?username=" + nonExistentUsername, + HttpMethod.GET, null, responseType); + + // then + assertAll( + () -> assertTrue(response.getStatusCode().is4xxClientError()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND) + ); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/support/error/CoreExceptionTest.java b/apps/commerce-api/src/test/java/com/loopers/support/error/CoreExceptionTest.java index 44db8c5e6..dfd35d85c 100644 --- a/apps/commerce-api/src/test/java/com/loopers/support/error/CoreExceptionTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/support/error/CoreExceptionTest.java @@ -1,10 +1,10 @@ package com.loopers.support.error; +import static org.assertj.core.api.Assertions.assertThat; + import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import static org.assertj.core.api.Assertions.assertThat; - class CoreExceptionTest { @DisplayName("ErrorType 기반의 예외 생성 시, 별도의 메시지가 주어지지 않으면 ErrorType의 메시지를 사용한다.") @Test diff --git a/docs/domain.md b/docs/domain.md new file mode 100644 index 000000000..9a0968311 --- /dev/null +++ b/docs/domain.md @@ -0,0 +1,33 @@ +## 도메인 모델 + +### 사용자 (User) + +#### 속성 +- id : Long +- username : 사용자 id (영문 및 숫자 10자 이내) +- email : 이메일 (xx@yy.zz) +- birthDate : 생년월일 (yyyy-mm-dd) + +#### 규칙 +- username은 고유해야 한다. +- email은 고유해야 한다. +- 속성에 정의된 양식을 준수해서 엔티티를 저장 해야한다. + + +### 포인트 (Point) + +#### 속성 +- id : Long +- userId : Long (사용자 ID, User 엔티티와 연관) +- amount : BigDecimal (보유 포인트 금액) +- createdAt : LocalDateTime (생성 시간) +- updatedAt : LocalDateTime (수정 시간) + +#### 규칙 +- amount는 음수가 될 수 없다. +- userId는 User 엔티티의 id와 연관되어야 한다. +- 포인트는 사용자의 활동에 따라 적립 및 차감될 수 있다. +- 포인트의 이력은 있어야 한다. +- amount는 0 이상이어야 한다. +- amount의 소수점 이하는 2자리까지 허용한다. +- 한 사용자당 하나의 포인트 레코드만 존재해야 한다. (userId는 고유해야 한다) \ No newline at end of file diff --git a/docs/week2/01-requirements.md b/docs/week2/01-requirements.md new file mode 100644 index 000000000..6b6f3b1a1 --- /dev/null +++ b/docs/week2/01-requirements.md @@ -0,0 +1,201 @@ +# 이커머스 서비스 요구사항 명세 + +## 개발 범위 + +### 포함 기능 +- 상품 목록/상세 조회 및 브랜드 조회 +- 상품 좋아요 등록/취소 (멱등성 보장) +- 주문 생성 및 결제 플로우 (재고 차감, 포인트 차감) + +### 사용자 식별 +- 모든 API는 `userId` 헤더를 통해 사용자를 식별 +- 별도 인증/인가 로직 없음 + +--- + +## 📖 유저 시나리오 + +### 핵심 사용자 여정 +``` +1. 사용자가 이커머스 사이트에 접속 +2. 다양한 브랜드의 상품들을 둘러보며 탐색 +3. 마음에 드는 상품에 좋아요 등록 +4. 여러 상품을 선택하여 한 번에 주문 +5. 보유 포인트로 간편 결제 완료 +``` + +### 주요 쇼핑 행동 +- 상품 비교 후 구매 +- 브랜드 기반 탐색 +- 빠른 검색과 구매 +--- + + +## 시나리오 1 : 상품 발견과 탐색 + +### 유저 스토리 +> **"다양한 상품을 찾아보고 싶다."** + +**상황**: 사용자가 새로운 상품을 찾거나 특정 조건의 상품을 탐색하려는 경우 +**핵심 니즈**: +- 적당한 양의 상품을 압도되지 않게 보기 +- 개인 취향/예산에 맞는 상품 빠르게 탐색 +- 인기/최신 상품 우선 확인 + +### 기능 요구사항 + +#### 1. 상품 목록 조회 +- 페이지 단위로 상품을 조회할 수 있다 (20개 기본, 100개 최대) +- 무한 스크롤 또는 페이징 방식으로 추가 상품을 볼 수 있다 +- 사용자 부담을 줄이는 적절한 표시 수량을 제공한다 + +#### 2. 필터링 및 정렬 +- **브랜드 필터링**: 특정 브랜드의 상품만 볼 수 있다 +- **정렬 옵션**: + - 최신순 (`latest`): 새로 등록된 상품부터 + - 가격 오름차순 (`price_asc`): 저렴한 상품부터 + - 좋아요순 (`likes_desc`): 인기 있는 상품부터 + +#### 3. 상품 상세 정보 +- 구매 결정을 위한 기본 정보 제공 + - 상품명과 설명 + - 가격 정보 (정가, 할인가) + - 재고 수량 + - 브랜드 정보 (브랜드명, 설명) + - 출시일 +- 좋아요 관련 정보 + - 해당 상품의 총 좋아요 수 + - 현재 조회 중인 사용자의 좋아요 여부 + +#### 4. 오류 처리 +- 존재하지 않는 상품 조회 시 404 Not Found 응답 +- 존재하지 않는 브랜드로 필터링 시 빈 목록 반환 (404 아님) + +### 기술 요구사항 +- **페이지네이션**: 기본 20개, 최대 100개 +- **쿼리 파라미터**: `brandId`, `sort`, `page`, `size` +- **응답 데이터**: 상품 기본 정보, 브랜드 정보, 좋아요 수, 사용자 좋아요 상태 +- **성능 목표**: 상품 목록 조회 < 500ms, 상품 상세 조회 < 300ms + +--- + +## 시나리오 2 : 브랜드 중심 쇼핑 + +### 유저 스토리 +> **"내가 좋아하는 브랜드의 다른 상품들도 보고 싶어요"** + +**상황**: 특정 브랜드에 관심이 생겼거나 브랜드 충성도가 높은 사용자 (ex. 무신사 스탠다드) +**니즈**: +- 브랜드가 어떤 정체성을 가진지 알고 싶음 +- 해당 브랜드의 다른 상품들도 함께 탐색하고 싶음 + +### 기능 요구사항 + +#### 1. 브랜드 조회 +- 사용자는 브랜드의 기본 정보를 조회할 수 있다 +- 브랜드명과 설명을 통해 브랜드에 대해 이해할 수 있다 + +#### 2. 브랜드 기반 상품 발견 +- 특정 브랜드의 모든 상품을 모아서 볼 수 있다 +- 브랜드 페이지에서 자연스럽게 상품 목록으로 이동 가능 + +#### 3. 오류 처리 +- 존재하지 않는 브랜드 조회 시 404 Not Found 응답 + +### 기술 구현 요구사항 +- 브랜드 정보 조회 API +- 브랜드별 상품 필터링 기능 +- 브랜드명 중복 불가 정책 + +--- + +## 시나리오 3 : 관심 상품 관리 (좋아요) + +### 유저 스토리 +> **"마음에 드는 상품을 표시해두고 나중에 다시 보고 싶어요"** + +**상황**: 탐색 중 마음에 드는 상품을 발견했지만 즉시 구매하지는 않는 경우 +**니즈**: +- 관심 상품을 간단하게 표시하고 싶음 +- 실수로 여러 번 눌러도 문제없이 동작했으면 함 +- 나중에 관심 표시한 상품들을 모아서 보고 싶음 +- 다른 사람들은 어떤 상품을 좋아하는지 궁금함 + +### 기능 요구사항 + +#### 1. 즉석 관심 표현 +- 상품 보는 중에 클릭 한 번으로 좋아요 등록 가능 +- 마음이 바뀌면 언제든 좋아요 취소 가능 +- 실수로 여러 번 클릭해도 의도한 대로 동작 (멱등성) + +#### 2. 개인 위시리스트 +- 내가 좋아요한 상품들을 모아서 볼 수 있다 + - 좋아요한 시간 순으로 정렬 (최신순) + - 각 상품의 현재 가격, 재고 상태 확인 가능 +- 관심 상품 목록에서 바로 구매로 이어질 수 있다 + +#### 3. 상품 인기도 확인 +- 상품 조회 시 전체 좋아요 수를 통해 인기도 확인 +- 다른 사용자들의 관심도를 참고할 수 있음 + +#### 4. 오류 처리 +- 존재하지 않는 상품에 좋아요 시도 시 404 Not Found 응답 + +### 기술 구현 요구사항 +- 사용자당 상품별 좋아요 최대 1개 +- 멱등성 보장: 중복 등록/취소 시 성공 응답 +- 실시간 좋아요 수 집계 및 반영 + +--- + +## 시나리오 4 : 상품 주문 + +### 유저 스토리 +> **"여러 상품을 한 번에 주문하고 포인트로 결제하고 싶다"** + +**상황**: 탐색과 좋아요를 통해 구매할 상품들을 정했고 실제 주문하려는 상황 +**니즈**: +- 장바구니처럼 여러 상품을 한 번에 주문하고 싶음 +- 복잡한 결제 과정 없이 간단하게 구매하고 싶음 +- 재고나 포인트 문제가 있으면 주문 전에 미리 알고 싶음 +- 주문 후 내역을 확인하고 싶음 + +### 기능 요구사항 + +#### 1. 다중 상품 주문 +- 여러 상품을 선택하고 각각 수량 지정 가능 +- 총 주문 금액을 명확하게 확인 가능 +- 주문 전 재고와 포인트 상황 검증 + +#### 2. 포인트 기반 간편 결제 +- 사전 충전된 포인트로 결제 처리 +- 카드 정보 입력 등 복잡한 과정 없음 +- 포인트 부족 시 명확한 안내와 함께 주문 실패 + +#### 3. 안전한 주문 처리 +- 재고 부족 시 주문 실패 및 명확한 메시지 +- 일부 상품만 주문되는 상황 방지 (전체 성공 또는 전체 실패 기반 원자성 보장) +- 주문 완료 시 재고와 포인트 동시 차감 + +#### 4. 주문 이력 관리 +- 내 주문 목록 조회 가능 +- 주문별 상세 내역 확인 가능 +- 주문 상태 추적 가능 + +#### 5. 오류 처리 +- 존재하지 않는 상품으로 주문 시도 시 404 Not Found 응답 +- 존재하지 않는 주문 조회 시 404 Not Found 응답 +- 존재하지 않는 사용자의 주문 목록 조회 시 404 Not Found 응답 +- 재고 부족 시 400 Bad Request 응답 (명확한 오류 메시지 포함) +- 포인트 부족 시 400 Bad Request 응답 (필요/보유 포인트 정보 포함) +- 동시 주문으로 인한 재고 부족 시 409 Conflict 응답 +- 이미 취소된 주문을 다시 취소 시도 시 400 Bad Request 응답 + +### 기술 구현 요구사항 +- 트랜잭션: 재고 차감, 포인트 차감, 주문 생성의 원자성 +- 동시성 제어: 동시 주문 시 정확한 재고 관리 +- 주문 상태: PENDING, CONFIRMED, CANCELLED +- 외부 시스템 연동 (Mock 처리) +- **오류 응답 정책**: 404 (리소스 없음), 400 (비즈니스 규칙 위반), 409 (동시성 충돌) + +--- \ No newline at end of file diff --git a/docs/week2/02-sequence-diagrams.md b/docs/week2/02-sequence-diagrams.md new file mode 100644 index 000000000..39560c3ec --- /dev/null +++ b/docs/week2/02-sequence-diagrams.md @@ -0,0 +1,537 @@ +# 🔄 02. 시퀀스 다이어그램 + +## 1. 상품 목록 조회 + +```mermaid +sequenceDiagram + participant User + participant ProductController + participant ProductService + participant BrandService + participant ProductRepository + + User->>ProductController: GET /api/v1/products?brandId=1&sort=latest&page=0&size=20 + ProductController->>ProductService: getProducts(brandId=1, sort=latest, pageable) + + alt 브랜드 필터링이 있는 경우 + ProductService->>BrandService: validateBrandExists(brandId=1) + alt 브랜드가 존재하지 않는 경우 + BrandService-->>ProductService: BrandNotFoundException + ProductService-->>ProductController: BrandNotFoundException + ProductController-->>User: 404 Not Found + else 브랜드가 존재하는 경우 + BrandService-->>ProductService: Brand validated + ProductService->>ProductRepository: findByBrandIdWithSort(brandId, sort, pageable) + end + else 전체 상품 조회 + ProductService->>ProductRepository: findAllWithSort(sort, pageable) + end + + ProductRepository-->>ProductService: Page + + alt 상품이 존재하는 경우 + ProductService-->>ProductController: ProductListResponse(totalElements=25, content=[...]) + ProductController-->>User: 200 OK + else 상품이 없는 경우 + ProductService-->>ProductController: ProductListResponse(totalElements=0, content=[]) + ProductController-->>User: 200 OK + end +``` + +## 2. 상품 상세 조회 + +```mermaid +sequenceDiagram + participant User + participant ProductController + participant ProductService + participant ProductRepository + + User->>ProductController: GET /api/v1/products/1
Header: X-USER-ID=123 (optional) + ProductController->>ProductService: getProductDetail(productId=1, userId=123) + + ProductService->>ProductRepository: findProductDetail(productId=1, userId=123) + + alt 상품이 존재하는 경우 + ProductRepository-->>ProductService: ProductDetailInfo(product, brand, totalLikes, isLiked) + ProductService-->>ProductController: ProductDetailResponse + ProductController-->>User: 200 OK + else 상품이 존재하지 않는 경우 + ProductRepository-->>ProductService: Optional.empty() + ProductService-->>ProductController: ProductNotFoundException + ProductController-->>User: 404 Not Found + end +``` + +## 3. 브랜드 목록 조회 + +```mermaid +sequenceDiagram + participant User + participant BrandController + participant BrandService + participant BrandRepository + + User->>BrandController: GET /api/v1/brands?page=0&size=20 + BrandController->>BrandService: getBrands(pageable) + BrandService->>BrandRepository: findAll(pageable) + BrandRepository-->>BrandService: Page + + alt 브랜드가 존재하는 경우 + BrandService-->>BrandController: BrandListResponse(totalElements=15, content=[...]) + BrandController-->>User: 200 OK + else 브랜드가 없는 경우 + BrandService-->>BrandController: BrandListResponse(totalElements=0, content=[]) + BrandController-->>User: 200 OK + end +``` + +## 4. 브랜드 상세 조회 + +```mermaid +sequenceDiagram + participant User + participant BrandController + participant BrandService + participant BrandRepository + + User->>BrandController: GET /api/v1/brands/1 + BrandController->>BrandService: getBrandById(brandId=1) + BrandService->>BrandRepository: findById(brandId=1) + + alt 브랜드가 존재하는 경우 + BrandRepository-->>BrandService: BrandEntity + BrandService-->>BrandController: BrandDetailResponse + BrandController-->>User: 200 OK + else 브랜드가 존재하지 않는 경우 + BrandRepository-->>BrandService: Optional.empty() + BrandService-->>BrandController: BrandNotFoundException + BrandController-->>User: 404 Not Found + end +``` + +## 5. 좋아요 등록 + +```mermaid +sequenceDiagram + participant User + participant LikeController + participant LikeFacade + participant UserService + participant ProductService + participant LikeService + participant LikeRepository + participant ProductRepository + + User->>LikeController: POST /api/v1/like/products/1
Header: X-USER-ID=testuser + LikeController->>LikeFacade: upsertLike(username="testuser", productId=1) + + LikeFacade->>UserService: getUserByUsername("testuser") + alt 사용자가 존재하지 않는 경우 + UserService-->>LikeFacade: CoreException(NOT_FOUND_USER) + LikeFacade-->>LikeController: CoreException + LikeController-->>User: 404 Not Found + else 사용자가 존재하는 경우 + UserService-->>LikeFacade: UserEntity + + LikeFacade->>ProductService: getProductDetail(productId=1) + alt 상품이 존재하지 않는 경우 + ProductService-->>LikeFacade: CoreException(NOT_FOUND_PRODUCT) + LikeFacade-->>LikeController: CoreException + LikeController-->>User: 404 Not Found + else 상품이 존재하는 경우 + ProductService-->>LikeFacade: ProductEntity + + LikeFacade->>LikeService: upsertLike(user, product) + LikeService->>LikeRepository: findByUserIdAndProductId(userId, productId) + + alt 좋아요가 없는 경우 + LikeRepository-->>LikeService: Optional.empty() + LikeService->>ProductRepository: save(product) [좋아요 카운트 +1] + LikeService->>LikeRepository: save(newLike) + LikeRepository-->>LikeService: LikeEntity (신규) + else 삭제된 좋아요가 있는 경우 + LikeRepository-->>LikeService: LikeEntity (deleted) + Note over LikeService: like.restore() + LikeService->>ProductRepository: save(product) [좋아요 카운트 +1] + LikeRepository-->>LikeService: LikeEntity (복원) + else 활성 좋아요가 있는 경우 + LikeRepository-->>LikeService: LikeEntity (active) + Note over LikeService: 카운트 변경 없음 (중복 방지) + LikeRepository-->>LikeService: LikeEntity (기존) + end + + LikeService-->>LikeFacade: LikeEntity + LikeFacade-->>LikeController: LikeInfo + LikeController-->>User: 200 OK + end + end +``` + +## 6. 좋아요 취소 + +```mermaid +sequenceDiagram + participant User + participant LikeController + participant LikeFacade + participant UserService + participant ProductService + participant LikeService + participant LikeRepository + participant ProductRepository + + User->>LikeController: DELETE /api/v1/like/products/1
Header: X-USER-ID=testuser + LikeController->>LikeFacade: unlikeProduct(username="testuser", productId=1) + + LikeFacade->>UserService: getUserByUsername("testuser") + alt 사용자가 존재하지 않는 경우 + UserService-->>LikeFacade: CoreException(NOT_FOUND_USER) + LikeFacade-->>LikeController: CoreException + LikeController-->>User: 404 Not Found + else 사용자가 존재하는 경우 + UserService-->>LikeFacade: UserEntity + + LikeFacade->>ProductService: getProductDetail(productId=1) + alt 상품이 존재하지 않는 경우 + ProductService-->>LikeFacade: CoreException(NOT_FOUND_PRODUCT) + LikeFacade-->>LikeController: CoreException + LikeController-->>User: 404 Not Found + else 상품이 존재하는 경우 + ProductService-->>LikeFacade: ProductEntity + + LikeFacade->>LikeService: unlikeProduct(user, product) + LikeService->>LikeRepository: findByUserIdAndProductId(userId, productId) + + alt 좋아요가 없는 경우 + LikeRepository-->>LikeService: Optional.empty() + Note over LikeService: 아무 작업 없음 (멱등성 보장) + else 이미 삭제된 좋아요인 경우 + LikeRepository-->>LikeService: LikeEntity (deleted) + Note over LikeService: 아무 작업 없음 (멱등성 보장) + else 활성 좋아요인 경우 + LikeRepository-->>LikeService: LikeEntity (active) + Note over LikeService: like.delete() (소프트 삭제) + LikeService->>ProductRepository: save(product) [좋아요 카운트 -1] + end + + LikeService-->>LikeFacade: void + LikeFacade-->>LikeController: void + LikeController-->>User: 200 OK + end + end +``` + +## 7. 포인트 충전 + +```mermaid +sequenceDiagram + participant User + participant PointController + participant PointFacade + participant PointService + participant UserService + participant UserRepository + participant PointHistoryRepository + + User->>PointController: POST /api/v1/points/charge
Header: X-USER-ID=testuser
Body: {"amount": 10000} + PointController->>PointFacade: chargePoint(username="testuser", request) + + PointFacade->>PointService: charge(username, amount) + PointService->>UserRepository: findByUsername("testuser") + + alt 사용자가 존재하지 않는 경우 + UserRepository-->>PointService: Optional.empty() + PointService-->>PointFacade: CoreException(NOT_FOUND_USER) + PointFacade-->>PointController: CoreException + PointController-->>User: 404 Not Found + else 사용자가 존재하는 경우 + UserRepository-->>PointService: UserEntity + + Note over PointService: user.chargePoint(amount)
[포인트 잔액 증가] + + PointService->>PointHistoryRepository: save(chargeHistory) + Note over PointService: PointHistoryEntity.createChargeHistory()
(userId, amount, balanceAfter) + PointHistoryRepository-->>PointService: PointHistoryEntity + + PointService->>UserRepository: save(user) + UserRepository-->>PointService: UserEntity + + PointService-->>PointFacade: 충전 후 잔액 + PointFacade-->>PointController: PointChargeResponse + PointController-->>User: 200 OK + end +``` + +## 8. 포인트 조회 + +```mermaid +sequenceDiagram + participant User + participant PointController + participant PointFacade + participant PointService + participant UserService + participant UserRepository + participant PointHistoryRepository + + User->>PointController: GET /api/v1/points
Header: X-USER-ID=testuser + PointController->>PointFacade: getPointInfo(username="testuser") + + PointFacade->>UserService: getUserByUsername("testuser") + alt 사용자가 존재하지 않는 경우 + UserService-->>PointFacade: CoreException(NOT_FOUND_USER) + PointFacade-->>PointController: CoreException + PointController-->>User: 404 Not Found + else 사용자가 존재하는 경우 + UserService-->>PointFacade: UserEntity + + PointFacade->>PointService: getPointHistories(username) + PointService->>UserRepository: findByUsername("testuser") + UserRepository-->>PointService: UserEntity + + PointService->>PointHistoryRepository: findByUserOrderByCreatedAtDesc(user) + PointHistoryRepository-->>PointService: List + PointService-->>PointFacade: List + + Note over PointFacade: PointInfo 생성
(잔액, 이력 목록) + PointFacade-->>PointController: PointInfo + PointController-->>User: 200 OK + end +``` + +## 10. 주문 요청 + +```mermaid +sequenceDiagram + participant User + participant OrderController + participant OrderFacade + participant UserService + participant ProductService + participant PointService + participant OrderService + participant OrderRepository + participant ProductRepository + + User->>OrderController: POST /api/v1/orders
Header: X-USER-ID=testuser
Body: {"items":[{"productId":1,"quantity":2}]} + OrderController->>OrderFacade: createOrder(command) + + OrderFacade->>UserService: getUserByUsername("testuser") + alt 사용자가 존재하지 않는 경우 + UserService-->>OrderFacade: CoreException(NOT_FOUND_USER) + OrderFacade-->>OrderController: CoreException + OrderController-->>User: 404 Not Found + else 사용자가 존재하는 경우 + UserService-->>OrderFacade: UserEntity + + Note over OrderFacade: 주문 항목을 productId로 정렬 (데드락 방지) + + loop 각 주문 상품 검증 (정렬된 순서) + OrderFacade->>ProductService: getProductDetailLock(productId) [비관적 락] + alt 상품이 존재하지 않는 경우 + ProductService-->>OrderFacade: CoreException(NOT_FOUND_PRODUCT) + OrderFacade-->>OrderController: CoreException + OrderController-->>User: 404 Not Found + else 재고가 부족한 경우 + ProductService-->>OrderFacade: ProductEntity + Note over OrderFacade: product.canOrder(quantity) = false + OrderFacade-->>OrderController: IllegalArgumentException + OrderController-->>User: 400 Bad Request + else 재고 충분 + ProductService-->>OrderFacade: ProductEntity (locked) + Note over OrderFacade: 주문 가능 상품 목록에 추가
총 주문 금액 계산 + end + end + + OrderFacade->>PointService: use(user, totalAmount) + alt 포인트가 부족한 경우 + PointService-->>OrderFacade: CoreException(INSUFFICIENT_POINTS) + Note over OrderFacade: 트랜잭션 롤백 (재고 락 자동 해제) + OrderFacade-->>OrderController: CoreException + OrderController-->>User: 400 Bad Request + else 포인트 차감 성공 + PointService-->>OrderFacade: 차감 후 잔액 + + OrderFacade->>OrderService: createOrder(request) + OrderService->>OrderRepository: save(order) + OrderRepository-->>OrderService: OrderEntity + OrderService-->>OrderFacade: OrderEntity + + loop 각 주문 항목 생성 및 재고 차감 + OrderFacade->>ProductService: deductStock(product, quantity) + ProductService->>ProductRepository: save(product) [재고 차감] + ProductRepository-->>ProductService: ProductEntity + ProductService-->>OrderFacade: ProductEntity + + OrderFacade->>OrderService: createOrderItem(request) + OrderService->>OrderRepository: save(orderItem) + OrderRepository-->>OrderService: OrderItemEntity + OrderService-->>OrderFacade: OrderItemEntity + end + + OrderFacade-->>OrderController: OrderInfo + OrderController-->>User: 201 Created + end + end +``` + +### 🔒 주문 처리 원자성 보장 + +#### **트랜잭션 전략** +- **@Transactional 기반 원자성**: OrderFacade의 createOrder 메서드 전체가 하나의 트랜잭션 + - 모든 작업이 성공하면 커밋 + - 중간에 예외 발생 시 자동 롤백 (재고 락 해제, 포인트 복구, 주문 취소) + +#### **데드락 방지 전략** +- **정렬된 락 획득**: 주문 항목을 productId 기준으로 정렬하여 처리 + - 스레드 A: [상품1, 상품2] 순서로 락 획득 + - 스레드 B: [상품1, 상품2] 순서로 락 획득 (동일 순서) + - 결과: 원형 대기(circular wait) 방지 + +#### **동시성 제어** +- **비관적 락(Pessimistic Lock)**: getProductDetailLock()에서 SELECT ... FOR UPDATE 사용 + - 재고 조회 시점에 행 레벨 락 획득 + - 트랜잭션 종료 시까지 다른 트랜잭션의 접근 차단 + +## 11. 사용자의 주문 목록 조회 + +```mermaid +sequenceDiagram + participant User + participant OrderController + participant OrderFacade + participant OrderService + participant OrderRepository + + User->>OrderController: GET /api/v1/orders?page=0&size=20
Header: X-USER-ID=testuser + OrderController->>OrderFacade: getOrderSummariesByUserId(userId, pageable) + + OrderFacade->>OrderService: getOrdersByUserId(userId, pageable) + OrderService->>OrderRepository: findByUserId(userId, pageable) + + alt 주문 내역이 존재하는 경우 + OrderRepository-->>OrderService: Page + OrderService-->>OrderFacade: Page + + loop 각 주문에 대해 + OrderFacade->>OrderService: countOrderItems(orderId) + OrderService->>OrderRepository: countByOrderId(orderId) + OrderRepository-->>OrderService: itemCount + OrderService-->>OrderFacade: itemCount + Note over OrderFacade: OrderSummary.from(order, itemCount) + end + + OrderFacade-->>OrderController: Page + OrderController-->>User: 200 OK + else 주문 내역이 없는 경우 + OrderRepository-->>OrderService: Page.empty() + OrderService-->>OrderFacade: Page.empty() + OrderFacade-->>OrderController: Page.empty() + OrderController-->>User: 200 OK + end +``` + +## 12. 주문 상세 조회 + +```mermaid +sequenceDiagram + participant User + participant OrderController + participant OrderFacade + participant OrderService + participant OrderRepository + + User->>OrderController: GET /api/v1/orders/1
Header: X-USER-ID=testuser + OrderController->>OrderFacade: getOrderById(orderId=1) + + OrderFacade->>OrderService: getOrderById(orderId=1) + OrderService->>OrderRepository: findById(orderId=1) + + alt 주문이 존재하지 않는 경우 + OrderRepository-->>OrderService: Optional.empty() + OrderService-->>OrderFacade: CoreException(NOT_FOUND) + OrderFacade-->>OrderController: CoreException + OrderController-->>User: 404 Not Found + else 주문이 존재하는 경우 + OrderRepository-->>OrderService: OrderEntity + OrderService-->>OrderFacade: OrderEntity + + OrderFacade->>OrderService: getOrderItemsByOrderId(orderId=1) + OrderService->>OrderRepository: findByOrderId(orderId=1) + OrderRepository-->>OrderService: List + OrderService-->>OrderFacade: List + + Note over OrderFacade: OrderInfo.from(order, orderItems) + OrderFacade-->>OrderController: OrderInfo + OrderController-->>User: 200 OK + end +``` + +## 13. 주문 취소 + +```mermaid +sequenceDiagram + participant User + participant OrderController + participant OrderFacade + participant OrderService + participant ProductService + participant PointService + participant OrderRepository + participant ProductRepository + + User->>OrderController: DELETE /api/v1/orders/1
Header: X-USER-ID=testuser + OrderController->>OrderFacade: cancelOrder(orderId=1, username="testuser") + + OrderFacade->>OrderService: getOrderById(orderId=1) + OrderService->>OrderRepository: findById(orderId=1) + + alt 주문이 존재하지 않는 경우 + OrderRepository-->>OrderService: Optional.empty() + OrderService-->>OrderFacade: CoreException(NOT_FOUND) + OrderFacade-->>OrderController: CoreException + OrderController-->>User: 404 Not Found + else 주문이 존재하는 경우 + OrderRepository-->>OrderService: OrderEntity + OrderService-->>OrderFacade: OrderEntity + + Note over OrderFacade: order.cancelOrder() [상태 변경] + + OrderFacade->>OrderService: getOrderItemsByOrderId(orderId=1) + OrderService->>OrderRepository: findByOrderId(orderId=1) + OrderRepository-->>OrderService: List + OrderService-->>OrderFacade: List + + Note over OrderFacade: 주문 항목을 productId로 정렬 (데드락 방지) + + loop 각 주문 항목에 대해 재고 원복 (정렬된 순서) + OrderFacade->>ProductService: restoreStock(productId, quantity) [비관적 락] + ProductService->>ProductRepository: findByIdWithLock(productId) + ProductRepository-->>ProductService: ProductEntity (locked) + Note over ProductService: product.restoreStock(quantity) + ProductService->>ProductRepository: save(product) [재고 복구] + ProductRepository-->>ProductService: ProductEntity + ProductService-->>OrderFacade: ProductEntity + end + + OrderFacade->>PointService: charge(username, totalAmount) + Note over PointService: user.chargePoint(amount)
포인트 이력 생성 (CHARGE) + PointService-->>OrderFacade: 환불 후 잔액 + + OrderFacade-->>OrderController: OrderInfo (취소됨) + OrderController-->>User: 200 OK + end +``` + +### 🔒 주문 취소 원자성 보장 + +#### **트랜잭션 전략** +- **@Transactional 기반 원자성**: OrderFacade의 cancelOrder 메서드 전체가 하나의 트랜잭션 + - 주문 상태 변경, 재고 복구, 포인트 환불이 모두 성공하면 커밋 + - 중간에 예외 발생 시 자동 롤백 + +#### **데드락 방지 전략** +- **정렬된 락 획득**: 주문 항목을 productId 기준으로 정렬하여 재고 복구 + - 주문 생성 시와 동일한 순서로 락 획득 + - 원형 대기(circular wait) 방지 diff --git a/docs/week2/03-class-diagram.md b/docs/week2/03-class-diagram.md new file mode 100644 index 000000000..400f50503 --- /dev/null +++ b/docs/week2/03-class-diagram.md @@ -0,0 +1,378 @@ +# 03. 클래스 다이어그램 + +## 도메인 모델 개요 + +이커머스 서비스의 핵심 도메인 객체들과 그들 간의 관계를 정의합니다. +DDD 기반 레이어드 아키텍처를 따르며, BaseEntity를 상속한 엔티티 설계를 적용합니다. + +--- + +## 주요 도메인 규칙 + +### 비즈니스 제약사항 + +| 도메인 | 핵심 규칙 | +|--------|-----------| +| **User** | 포인트는 음수 불가, 차감 시 잔액 검증 필수 | +| **Product** | 재고는 음수 불가, 예약 시 가용 수량 검증 필수 | +| **Order** | 재고 예약과 포인트 차감은 원자적 처리 | +| **Like** | 사용자당 상품별 1개만 가능, 멱등성 보장 | +| **Brand** | 브랜드명 중복 불가, 소프트 삭제 지원 | + +### 도메인 관계 요약 + +| 관계 | 설명 | 다중성 | 비고 | +|----------------------|-------------------|-----|------| +| Product -> Brand | 상품은 브랜드에 속함 | N:1 | 필수 관계 | +| Like -> User | 좋아요는 사용자가 생성 | N:1 | 필수 관계 | +| Like -> Product | 좋아요는 상품에 대한 것 | N:1 | 필수 관계 | +| Order -> User | 주문은 사용자가 생성 | N:1 | 필수 관계 | +| OrderItem -> Order | 주문 상세내역은 주문 정보 포함 | N:1 | 필수 관계 | +| OrderItem -> Product | 주문항목은 상품을 참조 | N:1 | 필수 관계 | +| PointHistory -> User | 포인트 이력은 사용자별 관리 | N:1 | 필수 관계 | + +--- + +## 전체 도메인 모델 + +```mermaid +classDiagram + class UserEntity { + -String username + -String email + -LocalDate birthdate + -Gender gender + -BigDecimal pointAmount + +createUserEntity() UserEntity + +chargePoint() void + +usePoint() void + +getPointAmount() BigDecimal + } + + class BrandEntity { + -String name + -String description + +createBrandEntity() BrandEntity + } + + class ProductEntity { + -Long brandId + -String name + -String description + -Price price + -Integer stockQuantity + -Long likeCount + +createEntity() ProductEntity + +deductStock() void + +restoreStock() void + +increaseLikeCount() void + +decreaseLikeCount() void + +hasStock() boolean + +canOrder() boolean + +isDiscounted() boolean + +getSellingPrice() BigDecimal + } + + class Price { + <> + -BigDecimal originPrice + -BigDecimal discountPrice + +of() Price + +getSellingPrice() BigDecimal + +getDiscountRate() BigDecimal + +isDiscounted() boolean + +applyDiscount() void + +removeDiscount() void + } + + class LikeEntity { + -Long userId + -Long productId + +createEntity() LikeEntity + } + + class OrderEntity { + -Long userId + -BigDecimal totalAmount + -OrderStatus status + +createOrder() OrderEntity + +confirmOrder() void + +cancelOrder() void + +isPending() boolean + +isConfirmed() boolean + +isCancelled() boolean + } + + class OrderItemEntity { + -Long orderId + -Long productId + -Integer quantity + -BigDecimal unitPrice + -BigDecimal totalPrice + +createOrderItem() OrderItemEntity + +calculateItemTotal() BigDecimal + } + + class PointHistoryEntity { + -UserEntity user + -BigDecimal amount + -PointTransactionType transactionType + -BigDecimal balanceAfter + +createChargeHistory() PointHistoryEntity + +createUseHistory() PointHistoryEntity + } + + UserEntity "1" --> "*" LikeEntity : userId + UserEntity "1" --> "*" OrderEntity : userId + UserEntity "1" --> "*" PointHistoryEntity : ManyToOne + ProductEntity "1" --> "*" LikeEntity : productId + BrandEntity "1" --> "*" ProductEntity : brandId + ProductEntity "1" *-- "1" Price : 임베디드 + OrderEntity "1" --> "1..*" OrderItemEntity : orderId + OrderItemEntity "*" --> "1" ProductEntity : productId +``` + +--- + +## 도메인별 상세 다이어그램 + +### 1. 사용자 도메인 (User Domain) + +```mermaid +classDiagram + class UserEntity { + -String username + -String email + -LocalDate birthdate + -Gender gender + -BigDecimal pointAmount + +createUserEntity() UserEntity + +chargePoint() void + +usePoint() void + +getPointAmount() BigDecimal + } + + class PointHistoryEntity { + -UserEntity user + -BigDecimal amount + -PointTransactionType transactionType + -BigDecimal balanceAfter + +createChargeHistory() PointHistoryEntity + +createUseHistory() PointHistoryEntity + } + + UserEntity "1" --> "*" PointHistoryEntity : ManyToOne 관계 +``` + +### 2. 상품 도메인 (Product Domain) + +```mermaid +classDiagram + class BrandEntity { + -String name + -String description + +createBrandEntity() BrandEntity + } + + class ProductEntity { + -Long brandId + -String name + -String description + -Price price + -Integer stockQuantity + -Long likeCount + +createEntity() ProductEntity + +deductStock() void + +restoreStock() void + +increaseLikeCount() void + +decreaseLikeCount() void + +hasStock() boolean + +canOrder() boolean + +isDiscounted() boolean + +getSellingPrice() BigDecimal + } + + class Price { + <> + -BigDecimal originPrice + -BigDecimal discountPrice + +of() Price + +getSellingPrice() BigDecimal + +getDiscountRate() BigDecimal + +isDiscounted() boolean + +getDiscountAmount() BigDecimal + +applyDiscount() void + +removeDiscount() void + } + + BrandEntity "1" --> "*" ProductEntity : brandId 참조 + ProductEntity "1" *-- "1" Price : 임베디드 타입 +``` + +### 3. 좋아요 도메인 (Like Domain) + +```mermaid +classDiagram + class LikeEntity { + -Long userId + -Long productId + +createEntity() LikeEntity + } + + class UserEntity { + -String username + -String email + -LocalDate birthdate + -Gender gender + -BigDecimal pointAmount + +createUserEntity() UserEntity + +chargePoint() void + +usePoint() void + } + + class ProductEntity { + -Long brandId + -String name + -Price price + -Integer stockQuantity + -Long likeCount + +createEntity() ProductEntity + +increaseLikeCount() void + +decreaseLikeCount() void + } + + LikeEntity "*" --> "1" UserEntity : userId 참조 + LikeEntity "*" --> "1" ProductEntity : productId 참조 +``` + +### 4. 주문 도메인 (Order Domain) + +```mermaid +classDiagram + class OrderEntity { + -Long userId + -BigDecimal totalAmount + -OrderStatus status + +createOrder() OrderEntity + +confirmOrder() void + +cancelOrder() void + +isPending() boolean + +isConfirmed() boolean + +isCancelled() boolean + } + + class OrderItemEntity { + -Long orderId + -Long productId + -Integer quantity + -BigDecimal unitPrice + -BigDecimal totalPrice + +createOrderItem() OrderItemEntity + +calculateItemTotal() BigDecimal + } + + class UserEntity { + -String username + -String email + -LocalDate birthdate + -Gender gender + -BigDecimal pointAmount + +createUserEntity() UserEntity + +chargePoint() void + +usePoint() void + } + + class ProductEntity { + -Long brandId + -String name + -Price price + -Integer stockQuantity + -Long likeCount + +createEntity() ProductEntity + +deductStock() void + +restoreStock() void + } + + OrderEntity "1" --> "*" OrderItemEntity : orderId 참조 + OrderEntity "*" --> "1" UserEntity : userId 참조 + OrderItemEntity "*" --> "1" ProductEntity : productId 참조 +``` + +--- + +## 도메인 엔티티 핵심 책임 + +### 1. UserEntity - 사용자 도메인의 핵심 책임 + +| 메서드 | 책임 | 비즈니스 규칙 | 구현 세부사항 | +|--------|----------|--------------------------|---------------| +| `createUserEntity()` | 사용자 생성 | 사용자명/이메일 유효성 검증, 생년월일 검증 | 정적 팩토리 메서드, 불변 객체 생성 | +| `chargePoint()` | 포인트 충전 | 충전 금액 양수 검증 | BigDecimal 정밀 계산, 원자적 연산 | +| `usePoint()` | 포인트 사용 | 잔액 부족 시 예외 발생, 음수 방지 | 원자적 연산, 상태 변경 후 검증 | +| `getPointAmount()` | 포인트 조회 | 소수점 2자리로 반올림 | RoundingMode.HALF_UP 적용 | + +### 2. ProductEntity - 상품 도메인의 핵심 책임 + +| 메서드 | 책임 | 비즈니스 규칙 | 구현 세부사항 | +|--------|------|---------------|---------------| +| `createEntity()` | 상품 생성 | 가격 유효성, 재고 음수 방지, 브랜드 ID 필수 | 정적 팩토리 메서드, Price 임베디드 타입 | +| `hasStock()` | 재고 확인 | 재고 수량 > 0 | 단순 재고 존재 여부 확인 | +| `canOrder()` | 주문 가능 여부 | 재고 >= 수량, 수량 > 0 | 복합 조건 검증 | +| `deductStock()` | 재고 차감 | 재고 부족 시 INSUFFICIENT_STOCK 예외 | 원자적 재고 차감 연산 | +| `restoreStock()` | 재고 복구 | 취소 시 재고 복원 | 재고 복구 연산 | +| `increaseLikeCount()` | 좋아요 수 증가 | 음수 방지, 동시성 제어 | 원자적 증가 연산 | +| `decreaseLikeCount()` | 좋아요 수 감소 | 0 이하로 감소 방지 | 최소값 0 보장 | +| `isDiscounted()` | 할인 여부 | Price 객체의 할인가 존재 여부 | Price.isDiscounted() 위임 | +| `getSellingPrice()` | 판매가 조회 | 할인가 우선, 없으면 정가 | Price.getSellingPrice() 위임 | + +### 3. OrderEntity - 주문 도메인의 핵심 책임 + +| 메서드 | 책임 | 비즈니스 규칙 | 구현 세부사항 | +|--------|------|---------------|---------------| +| `createOrder()` | 주문 생성 | 사용자 ID 필수, 총액 > 0 | 정적 팩토리 메서드, 초기 상태 PENDING | +| `confirmOrder()` | 주문 확정 | PENDING → CONFIRMED 상태 전이 | INVALID_ORDER_STATUS 예외 처리 | +| `cancelOrder()` | 주문 취소 | PENDING/CONFIRMED → CANCELLED | 취소 가능 상태 검증 | +| `isPending()` | 대기 상태 확인 | 주문 상태가 PENDING인지 확인 | 상태 검증 메서드 | +| `isConfirmed()` | 확정 상태 확인 | 주문 상태가 CONFIRMED인지 확인 | 상태 검증 메서드 | +| `isCancelled()` | 취소 상태 확인 | 주문 상태가 CANCELLED인지 확인 | 상태 검증 메서드 | + +### 4. OrderItemEntity - 주문 항목 도메인의 핵심 책임 + +| 메서드 | 책임 | 비즈니스 규칙 | 구현 세부사항 | +|--------|------|---------------|---------------| +| `createOrderItem()` | 주문 항목 생성 | 주문 ID, 상품 ID 필수, 수량 >= 1, 단가 > 0 | 정적 팩토리 메서드, 스냅샷 가격 | +| `calculateItemTotal()` | 항목 총액 계산 | 단가 × 수량 = 총액 | BigDecimal 정밀 계산 | + +### 5. LikeEntity - 좋아요 도메인의 핵심 책임 + +| 메서드 | 책임 | 비즈니스 규칙 | 구현 세부사항 | +|-----------------------|-------------|-------------------------|-------------------| +| `createEntity()` | 좋아요 생성 | 사용자 ID, 상품 ID 필수, 복합 유니크 제약 | 정적 팩토리 메서드, 복합키 검증 | + +### 6. BrandEntity - 브랜드 도메인의 핵심 책임 + +| 메서드 | 책임 | 비즈니스 규칙 | 구현 세부사항 | +|--------|------|---------------|---------------| +| `createBrandEntity()` | 브랜드 생성 | 브랜드명 필수, 설명 선택사항, 길이 <= 100 | 정적 팩토리 메서드, 유니크 제약 | + +### 7. PointHistoryEntity - 포인트 이력 도메인의 핵심 책임 + +| 메서드 | 책임 | 비즈니스 규칙 | 구현 세부사항 | +|--------|------|---------------|---------------| +| `createChargeHistory()` | 충전 이력 생성 | 충전 금액 양수, 잔액 일치성 | 정적 팩토리 메서드, CHARGE 타입 | +| `createUseHistory()` | 사용 이력 생성 | 사용 금액 양수, 잔액 일치성 | 정적 팩토리 메서드, USE 타입 | + +### 8. Price - 가격 임베디드 타입의 핵심 책임 + +| 메서드 | 책임 | 비즈니스 규칙 | 구현 세부사항 | +|--------|------|---------------|---------------| +| `of()` | Price 객체 생성 | 정가 필수, 할인가 선택사항 | 정적 팩토리 메서드, 불변 객체 | +| `ofOriginOnly()` | 정가만으로 생성 | 정가 > 0 | 할인가 없는 Price 생성 | +| `getSellingPrice()` | 실제 판매가 반환 | 할인가 우선, 없으면 정가 | 할인가 존재 여부에 따른 분기 | +| `getDiscountRate()` | 할인율 계산 | (정가-할인가)/정가 * 100 | BigDecimal 정밀 계산, 소수점 2자리 | +| `isDiscounted()` | 할인 여부 확인 | 할인가가 null이 아니고 정가보다 작음 | 할인가 유효성 검증 | +| `getDiscountAmount()` | 할인 금액 계산 | 정가 - 할인가 | 할인 절약 금액 반환 | +| `applyDiscount()` | 할인 적용 | 할인가 >= 0, 할인가 <= 정가 | 할인가 유효성 검증 후 적용 | +| `removeDiscount()` | 할인 제거 | 할인가를 null로 설정 | 할인 정보 초기화 | + +--- \ No newline at end of file diff --git a/docs/week2/04-erd.md b/docs/week2/04-erd.md new file mode 100644 index 000000000..de3d61164 --- /dev/null +++ b/docs/week2/04-erd.md @@ -0,0 +1,347 @@ +# 04. ERD + +## 개요 + +이커머스 서비스의 데이터베이스 ERD 입니다. + +--- + +## 전체 ERD + +```mermaid +erDiagram + users ||--o{ likes: "" + products ||--o{ likes: "" + orders ||--|{ order_items: "" + users ||--o{ orders: "" + users ||--o{ point_histories: "" + brands ||--o{ products: "" + products ||--o{ order_items: "" + + users { + bigint id PK "기본키" + varchar(10) username UK "사용자명(영문/숫자 10자)" + varchar(254) email "이메일" + date birthdate "생년월일" + varchar(10) gender "성별 MALE/FEMALE 열거형" + decimal point_amount "보유 포인트 금액" + timestamp created_at "생성일시" + timestamp updated_at "수정일시" + timestamp deleted_at "삭제일시" + } + + brands { + bigint id PK "기본키" + varchar(100) name UK "브랜드명" + text description "브랜드 설명" + timestamp created_at "생성일시" + timestamp updated_at "수정일시" + timestamp deleted_at "삭제일시" + } + + products { + bigint id PK "기본키" + bigint brand_id "브랜드 ID" + varchar(200) name "상품명" + text description "상품 설명" + decimal origin_price "정가(Price 임베디드)" + decimal discount_price "할인가(Price 임베디드)" + int stock_quantity "재고 수량" + bigint like_count "좋아요 수(비정규화)" + timestamp created_at "생성일시" + timestamp updated_at "수정일시" + timestamp deleted_at "삭제일시" + } + + likes { + bigint id PK "기본키" + bigint userId "사용자 ID" + bigint productId "상품 ID" + timestamp created_at "생성일시" + timestamp updated_at "수정일시" + timestamp deleted_at "삭제일시" + } + + orders { + bigint id PK "기본키" + bigint user_id "사용자 ID" + decimal total_amount "총 주문 금액" + varchar(20) status "주문 상태(PENDING/CONFIRMED/CANCELLED)" + timestamp created_at "생성일시" + timestamp updated_at "수정일시" + timestamp deleted_at "삭제일시" + } + + order_items { + bigint id PK "기본키" + bigint order_id "주문 ID" + bigint product_id "상품 ID" + int quantity "주문 수량" + decimal unit_price "단가(주문 시점 가격)" + decimal total_price "총 가격(단가 * 수량)" + timestamp created_at "생성일시" + timestamp updated_at "수정일시" + timestamp deleted_at "삭제일시" + } + + point_histories { + bigint id PK "기본키" + bigint user_id "사용자 ID" + decimal amount "포인트 변동 금액" + varchar(20) transaction_type "거래 유형(CHARGE/USE)" + decimal balance_after "거래 후 잔액" + timestamp created_at "생성일시" + timestamp updated_at "수정일시" + timestamp deleted_at "삭제일시" + } +``` + +--- + +## 테이블별 상세 설명 + +### 1. users (사용자) + +**목적**: 이커머스 서비스를 이용하는 사용자 정보 관리 + +| 컬럼명 | 타입 | 제약조건 | 설명 | +|--------|------|----------|------| +| id | BIGINT | PK, AUTO_INCREMENT | 사용자 고유 식별자 | +| username | VARCHAR(10) | UNIQUE, NOT NULL | 사용자명 (영문/숫자 10자 이내) | +| email | VARCHAR(254) | NOT NULL | 이메일 주소 | +| birthdate | DATE | NOT NULL | 생년월일 | +| gender | VARCHAR(10) | NOT NULL | 성별 (MALE/FEMALE) | +| point_amount | DECIMAL(9,2) | NOT NULL, DEFAULT 0 | 포인트 잔액 (음수 불가) | +| created_at | TIMESTAMP | NOT NULL | 생성일시 | +| updated_at | TIMESTAMP | NOT NULL | 수정일시 | +| deleted_at | TIMESTAMP | NULL | 삭제일시| + +**인덱스**: +- `idx_user_username`: username (유니크 인덱스) + +**비즈니스 규칙**: +- 포인트는 항상 0 이상이어야 함 +- 포인트 차감 시 잔액 검증 필수 +- 사용자명은 중복 불가 + +--- + +### 2. brands (브랜드) + +**목적**: 상품을 그룹화하는 브랜드 정보 관리 + +| 컬럼명 | 타입 | 제약조건 | 설명 | +|--------|------|----------|------------| +| id | BIGINT | PK, AUTO_INCREMENT | 브랜드 고유 식별자 | +| name | VARCHAR(100) | UNIQUE, NOT NULL | 브랜드명 | +| description | TEXT | NULL | 브랜드 설명 | +| created_at | TIMESTAMP | NOT NULL | 생성일시 | +| updated_at | TIMESTAMP | NOT NULL | 수정일시 | +| deleted_at | TIMESTAMP | NULL | 삭제일시 | + +**인덱스**: +- `idx_brand_name`: name (유니크 인덱스) + +**비즈니스 규칙**: +- 브랜드명은 중복 불가 + +--- + +### 3. products (상품) + +**목적**: 판매 가능한 상품 정보 관리 + +| 컬럼명 | 타입 | 제약조건 | 설명 | +|--------|------|----------|---------------| +| id | BIGINT | PK, AUTO_INCREMENT | 상품 고유 식별자 | +| brand_id | BIGINT | NOT NULL | 브랜드 ID (brands.id) | +| name | VARCHAR(200) | NOT NULL | 상품명 | +| description | TEXT | NULL | 상품 설명 | +| origin_price | DECIMAL(10,2) | NOT NULL | 정가 (Price 임베디드 타입) | +| discount_price | DECIMAL(10,2) | NULL | 할인가 (Price 임베디드 타입, 선택사항) | +| stock_quantity | INT | NOT NULL, DEFAULT 0 | 재고 수량 (음수 불가) | +| like_count | BIGINT | NOT NULL, DEFAULT 0 | 좋아요 수 | +| created_at | TIMESTAMP | NOT NULL | 생성일시 | +| updated_at | TIMESTAMP | NOT NULL | 수정일시 | +| deleted_at | TIMESTAMP | NULL | 삭제일시| + +**인덱스**: +- `idx_productentity_brand_id`: brand_id (브랜드별 상품 조회) +- `idx_productentity_name`: name (상품명 검색) + +**임베디드 타입 (Price)**: +```java +@Embeddable +public class Price { + private BigDecimal originPrice; // 정가 + private BigDecimal discountPrice; // 할인가 (선택사항) + + // 실제 판매가 반환 (할인가가 있으면 할인가, 없으면 정가) + public BigDecimal getSellingPrice() +} +``` + +**비즈니스 규칙**: +- 재고는 항상 0 이상이어야 함 +- 재고 차감 시 가용 수량 검증 필수 +- 할인가는 선택사항이며, 설정 시 정가보다 작아야 함 +- Price는 임베디드 타입으로 정가와 할인가를 함께 관리 +- released_at 필드는 엔티티에서 제거됨 (비즈니스 요구사항 변경) + +--- + +### 4. likes (좋아요) + +**목적**: 사용자의 상품 좋아요 정보 관리 + +| 컬럼명 | 타입 | 제약조건 | 설명 | +|--------|------|----------|------| +| id | BIGINT | PK, AUTO_INCREMENT | 좋아요 고유 식별자 | +| userId | BIGINT | NOT NULL, UNIQUE (with productId) | 사용자 ID (users.id) | +| productId | BIGINT | NOT NULL, UNIQUE (with userId) | 상품 ID (products.id) | +| created_at | TIMESTAMP | NOT NULL | 좋아요 등록 시간 | +| updated_at | TIMESTAMP | NOT NULL | 수정일시 | +| deleted_at | TIMESTAMP | NULL | 삭제일시| + +**인덱스**: +- `uc_likee_user_product`: (userId, productId) UNIQUE (중복 방지) + +**논리적 관계** (물리적 FK 없음): +- userId → users(id) +- productId → products(id) + +**비즈니스 규칙**: +- 사용자당 상품별 좋아요는 1개만 가능 (복합 고유 제약) +- 좋아요 등록/취소는 멱등성 보장 +- 좋아요 등록 시 products.like_count 증가 +- 좋아요 취소 시 products.like_count 감소 + +--- + +### 5. orders (주문) + +**목적**: 사용자의 주문 정보 관리 + +| 컬럼명 | 타입 | 제약조건 | 설명 | +|--------|------|----------|------| +| id | BIGINT | PK, AUTO_INCREMENT | 주문 고유 식별자 | +| user_id | BIGINT | NOT NULL | 사용자 ID (users.id) | +| total_amount | DECIMAL(10,2) | NOT NULL | 총 주문 금액 | +| status | VARCHAR(20) | NOT NULL | 주문 상태 (PENDING/CONFIRMED/CANCELLED) | +| created_at | TIMESTAMP | NOT NULL | 생성일시 (주문 일시로 사용) | +| updated_at | TIMESTAMP | NOT NULL | 수정일시 | +| deleted_at | TIMESTAMP | NULL | 삭제일시| + +**인덱스**: +- `idx_order_user_id`: user_id (사용자별 주문 목록 조회) +- `idx_order_status`: status (상태별 주문 조회) + +**논리적 관계** (물리적 FK 없음): +- user_id → users(id) + +**비즈니스 규칙**: +- 주문 생성 시 상태는 PENDING +- 결제 완료 시 CONFIRMED로 변경 +- PENDING 또는 CONFIRMED 상태에서 CANCELLED로 변경 가능 +- total_amount는 order_items의 total_price 합계 +- ordered_at 필드는 제거되고 created_at을 주문 일시로 사용 + +**상태 전이**: +``` +PENDING → CONFIRMED (결제 완료) +PENDING → CANCELLED (주문 취소) +CONFIRMED → CANCELLED (주문 취소) +``` + +--- + +### 6. order_items (주문 상세) + +**목적**: 주문에 포함된 개별 상품 정보 관리 + +| 컬럼명 | 타입 | 제약조건 | 설명 | +|--------|------|----------|------| +| id | BIGINT | PK, AUTO_INCREMENT | 주문 상세 고유 식별자 | +| order_id | BIGINT | NOT NULL | 주문 ID (orders.id) | +| product_id | BIGINT | NOT NULL | 상품 ID (products.id) | +| quantity | INT | NOT NULL | 주문 수량 (1 이상) | +| unit_price | DECIMAL(10,2) | NOT NULL | 단가 (주문 시점의 상품 가격) | +| total_price | DECIMAL(10,2) | NOT NULL | 총 가격 (unit_price * quantity) | +| created_at | TIMESTAMP | NOT NULL | 생성일시 | +| updated_at | TIMESTAMP | NOT NULL | 수정일시 | +| deleted_at | TIMESTAMP | NULL | 삭제일시| + +**인덱스**: +- `idx_order_item_order_id`: order_id (주문별 상세 조회) +- `idx_order_item_product_id`: product_id (상품별 주문 이력) + +**논리적 관계** (물리적 FK 없음): +- order_id → orders(id) +- product_id → products(id) + +**비즈니스 규칙**: +- quantity는 1 이상이어야 함 +- unit_price는 주문 시점의 상품 가격 스냅샷 +- total_price = unit_price * quantity +- 주문 생성 시 재고 차감 필수 + +--- + +### 7. point_histories (포인트 이력) + +**목적**: 사용자의 포인트 변동 이력 관리 (감사 추적) + +| 컬럼명 | 타입 | 제약조건 | 설명 | +|--------|------|----------|------| +| id | BIGINT | PK, AUTO_INCREMENT | 포인트 이력 고유 식별자 | +| user_id | BIGINT | NOT NULL (FK) | 사용자 ID (users.id) - ManyToOne 관계 | +| amount | DECIMAL(9,2) | NOT NULL | 포인트 변동 금액 (항상 양수) | +| transaction_type | VARCHAR(20) | NOT NULL | 거래 유형 (CHARGE/USE/REFUND) | +| balance_after | DECIMAL(9,2) | NOT NULL | 거래 후 잔액 | +| created_at | TIMESTAMP | NOT NULL | 거래 일시 | +| updated_at | TIMESTAMP | NOT NULL | 수정일시 | +| deleted_at | TIMESTAMP | NULL | 삭제일시| + +**인덱스**: +- JPA에서 자동 생성되는 FK 인덱스 사용 + +**물리적 관계**: +- user_id → users(id) - ManyToOne 관계로 물리적 FK 존재 + +**비즈니스 규칙**: +- 모든 포인트 변동은 이력으로 기록 +- amount는 항상 양수 (거래 유형으로 충전/사용 구분) +- balance_after는 거래 후 users.point_amount와 일치 +- UserEntity와 ManyToOne 관계로 물리적 FK 존재 + +**거래 유형**: +- `CHARGE`: 포인트 충전 +- `USE`: 포인트 사용 (주문 결제) +- `REFUND`: 포인트 환불 (주문 취소) +--- + +## 데이터베이스 제약사항 + +### 1. 외래키 제약조건 + +**⚠️ 물리적 FK 제약조건 제한적 사용 정책** + +이 프로젝트는 **대부분의 테이블에서 물리적 외래키(Foreign Key) 제약조건을 사용하지 않습니다**. + +**예외**: +- `point_histories` 테이블의 `user_id`는 JPA `@ManyToOne` 관계로 물리적 FK 존재 + - 포인트 이력은 사용자와 강한 결합 관계 + - 사용자 삭제 시 이력도 함께 관리 필요 + +**물리적 FK 미사용 이유**: +- **성능**: FK 제약조건은 INSERT/UPDATE/DELETE 시 추가 검증 오버헤드 발생 +- **유연성**: 데이터 마이그레이션 및 배치 작업 시 제약 없음 +- **확장성**: 샤딩, 파티셔닝 등 분산 환경에서 유리 +- **장애 격리**: 참조 테이블 장애 시에도 독립적 운영 가능 + +--- +### 2. 테이블 네이밍 규칙 + +- 복수형 사용 (users, products, orders) +- 스네이크 케이스 (snake_case) +- 소문자 사용 diff --git a/docs/week3/01-requirements.md b/docs/week3/01-requirements.md new file mode 100644 index 000000000..6b6f3b1a1 --- /dev/null +++ b/docs/week3/01-requirements.md @@ -0,0 +1,201 @@ +# 이커머스 서비스 요구사항 명세 + +## 개발 범위 + +### 포함 기능 +- 상품 목록/상세 조회 및 브랜드 조회 +- 상품 좋아요 등록/취소 (멱등성 보장) +- 주문 생성 및 결제 플로우 (재고 차감, 포인트 차감) + +### 사용자 식별 +- 모든 API는 `userId` 헤더를 통해 사용자를 식별 +- 별도 인증/인가 로직 없음 + +--- + +## 📖 유저 시나리오 + +### 핵심 사용자 여정 +``` +1. 사용자가 이커머스 사이트에 접속 +2. 다양한 브랜드의 상품들을 둘러보며 탐색 +3. 마음에 드는 상품에 좋아요 등록 +4. 여러 상품을 선택하여 한 번에 주문 +5. 보유 포인트로 간편 결제 완료 +``` + +### 주요 쇼핑 행동 +- 상품 비교 후 구매 +- 브랜드 기반 탐색 +- 빠른 검색과 구매 +--- + + +## 시나리오 1 : 상품 발견과 탐색 + +### 유저 스토리 +> **"다양한 상품을 찾아보고 싶다."** + +**상황**: 사용자가 새로운 상품을 찾거나 특정 조건의 상품을 탐색하려는 경우 +**핵심 니즈**: +- 적당한 양의 상품을 압도되지 않게 보기 +- 개인 취향/예산에 맞는 상품 빠르게 탐색 +- 인기/최신 상품 우선 확인 + +### 기능 요구사항 + +#### 1. 상품 목록 조회 +- 페이지 단위로 상품을 조회할 수 있다 (20개 기본, 100개 최대) +- 무한 스크롤 또는 페이징 방식으로 추가 상품을 볼 수 있다 +- 사용자 부담을 줄이는 적절한 표시 수량을 제공한다 + +#### 2. 필터링 및 정렬 +- **브랜드 필터링**: 특정 브랜드의 상품만 볼 수 있다 +- **정렬 옵션**: + - 최신순 (`latest`): 새로 등록된 상품부터 + - 가격 오름차순 (`price_asc`): 저렴한 상품부터 + - 좋아요순 (`likes_desc`): 인기 있는 상품부터 + +#### 3. 상품 상세 정보 +- 구매 결정을 위한 기본 정보 제공 + - 상품명과 설명 + - 가격 정보 (정가, 할인가) + - 재고 수량 + - 브랜드 정보 (브랜드명, 설명) + - 출시일 +- 좋아요 관련 정보 + - 해당 상품의 총 좋아요 수 + - 현재 조회 중인 사용자의 좋아요 여부 + +#### 4. 오류 처리 +- 존재하지 않는 상품 조회 시 404 Not Found 응답 +- 존재하지 않는 브랜드로 필터링 시 빈 목록 반환 (404 아님) + +### 기술 요구사항 +- **페이지네이션**: 기본 20개, 최대 100개 +- **쿼리 파라미터**: `brandId`, `sort`, `page`, `size` +- **응답 데이터**: 상품 기본 정보, 브랜드 정보, 좋아요 수, 사용자 좋아요 상태 +- **성능 목표**: 상품 목록 조회 < 500ms, 상품 상세 조회 < 300ms + +--- + +## 시나리오 2 : 브랜드 중심 쇼핑 + +### 유저 스토리 +> **"내가 좋아하는 브랜드의 다른 상품들도 보고 싶어요"** + +**상황**: 특정 브랜드에 관심이 생겼거나 브랜드 충성도가 높은 사용자 (ex. 무신사 스탠다드) +**니즈**: +- 브랜드가 어떤 정체성을 가진지 알고 싶음 +- 해당 브랜드의 다른 상품들도 함께 탐색하고 싶음 + +### 기능 요구사항 + +#### 1. 브랜드 조회 +- 사용자는 브랜드의 기본 정보를 조회할 수 있다 +- 브랜드명과 설명을 통해 브랜드에 대해 이해할 수 있다 + +#### 2. 브랜드 기반 상품 발견 +- 특정 브랜드의 모든 상품을 모아서 볼 수 있다 +- 브랜드 페이지에서 자연스럽게 상품 목록으로 이동 가능 + +#### 3. 오류 처리 +- 존재하지 않는 브랜드 조회 시 404 Not Found 응답 + +### 기술 구현 요구사항 +- 브랜드 정보 조회 API +- 브랜드별 상품 필터링 기능 +- 브랜드명 중복 불가 정책 + +--- + +## 시나리오 3 : 관심 상품 관리 (좋아요) + +### 유저 스토리 +> **"마음에 드는 상품을 표시해두고 나중에 다시 보고 싶어요"** + +**상황**: 탐색 중 마음에 드는 상품을 발견했지만 즉시 구매하지는 않는 경우 +**니즈**: +- 관심 상품을 간단하게 표시하고 싶음 +- 실수로 여러 번 눌러도 문제없이 동작했으면 함 +- 나중에 관심 표시한 상품들을 모아서 보고 싶음 +- 다른 사람들은 어떤 상품을 좋아하는지 궁금함 + +### 기능 요구사항 + +#### 1. 즉석 관심 표현 +- 상품 보는 중에 클릭 한 번으로 좋아요 등록 가능 +- 마음이 바뀌면 언제든 좋아요 취소 가능 +- 실수로 여러 번 클릭해도 의도한 대로 동작 (멱등성) + +#### 2. 개인 위시리스트 +- 내가 좋아요한 상품들을 모아서 볼 수 있다 + - 좋아요한 시간 순으로 정렬 (최신순) + - 각 상품의 현재 가격, 재고 상태 확인 가능 +- 관심 상품 목록에서 바로 구매로 이어질 수 있다 + +#### 3. 상품 인기도 확인 +- 상품 조회 시 전체 좋아요 수를 통해 인기도 확인 +- 다른 사용자들의 관심도를 참고할 수 있음 + +#### 4. 오류 처리 +- 존재하지 않는 상품에 좋아요 시도 시 404 Not Found 응답 + +### 기술 구현 요구사항 +- 사용자당 상품별 좋아요 최대 1개 +- 멱등성 보장: 중복 등록/취소 시 성공 응답 +- 실시간 좋아요 수 집계 및 반영 + +--- + +## 시나리오 4 : 상품 주문 + +### 유저 스토리 +> **"여러 상품을 한 번에 주문하고 포인트로 결제하고 싶다"** + +**상황**: 탐색과 좋아요를 통해 구매할 상품들을 정했고 실제 주문하려는 상황 +**니즈**: +- 장바구니처럼 여러 상품을 한 번에 주문하고 싶음 +- 복잡한 결제 과정 없이 간단하게 구매하고 싶음 +- 재고나 포인트 문제가 있으면 주문 전에 미리 알고 싶음 +- 주문 후 내역을 확인하고 싶음 + +### 기능 요구사항 + +#### 1. 다중 상품 주문 +- 여러 상품을 선택하고 각각 수량 지정 가능 +- 총 주문 금액을 명확하게 확인 가능 +- 주문 전 재고와 포인트 상황 검증 + +#### 2. 포인트 기반 간편 결제 +- 사전 충전된 포인트로 결제 처리 +- 카드 정보 입력 등 복잡한 과정 없음 +- 포인트 부족 시 명확한 안내와 함께 주문 실패 + +#### 3. 안전한 주문 처리 +- 재고 부족 시 주문 실패 및 명확한 메시지 +- 일부 상품만 주문되는 상황 방지 (전체 성공 또는 전체 실패 기반 원자성 보장) +- 주문 완료 시 재고와 포인트 동시 차감 + +#### 4. 주문 이력 관리 +- 내 주문 목록 조회 가능 +- 주문별 상세 내역 확인 가능 +- 주문 상태 추적 가능 + +#### 5. 오류 처리 +- 존재하지 않는 상품으로 주문 시도 시 404 Not Found 응답 +- 존재하지 않는 주문 조회 시 404 Not Found 응답 +- 존재하지 않는 사용자의 주문 목록 조회 시 404 Not Found 응답 +- 재고 부족 시 400 Bad Request 응답 (명확한 오류 메시지 포함) +- 포인트 부족 시 400 Bad Request 응답 (필요/보유 포인트 정보 포함) +- 동시 주문으로 인한 재고 부족 시 409 Conflict 응답 +- 이미 취소된 주문을 다시 취소 시도 시 400 Bad Request 응답 + +### 기술 구현 요구사항 +- 트랜잭션: 재고 차감, 포인트 차감, 주문 생성의 원자성 +- 동시성 제어: 동시 주문 시 정확한 재고 관리 +- 주문 상태: PENDING, CONFIRMED, CANCELLED +- 외부 시스템 연동 (Mock 처리) +- **오류 응답 정책**: 404 (리소스 없음), 400 (비즈니스 규칙 위반), 409 (동시성 충돌) + +--- \ No newline at end of file diff --git a/docs/week3/02-sequence-diagrams.md b/docs/week3/02-sequence-diagrams.md new file mode 100644 index 000000000..39560c3ec --- /dev/null +++ b/docs/week3/02-sequence-diagrams.md @@ -0,0 +1,537 @@ +# 🔄 02. 시퀀스 다이어그램 + +## 1. 상품 목록 조회 + +```mermaid +sequenceDiagram + participant User + participant ProductController + participant ProductService + participant BrandService + participant ProductRepository + + User->>ProductController: GET /api/v1/products?brandId=1&sort=latest&page=0&size=20 + ProductController->>ProductService: getProducts(brandId=1, sort=latest, pageable) + + alt 브랜드 필터링이 있는 경우 + ProductService->>BrandService: validateBrandExists(brandId=1) + alt 브랜드가 존재하지 않는 경우 + BrandService-->>ProductService: BrandNotFoundException + ProductService-->>ProductController: BrandNotFoundException + ProductController-->>User: 404 Not Found + else 브랜드가 존재하는 경우 + BrandService-->>ProductService: Brand validated + ProductService->>ProductRepository: findByBrandIdWithSort(brandId, sort, pageable) + end + else 전체 상품 조회 + ProductService->>ProductRepository: findAllWithSort(sort, pageable) + end + + ProductRepository-->>ProductService: Page + + alt 상품이 존재하는 경우 + ProductService-->>ProductController: ProductListResponse(totalElements=25, content=[...]) + ProductController-->>User: 200 OK + else 상품이 없는 경우 + ProductService-->>ProductController: ProductListResponse(totalElements=0, content=[]) + ProductController-->>User: 200 OK + end +``` + +## 2. 상품 상세 조회 + +```mermaid +sequenceDiagram + participant User + participant ProductController + participant ProductService + participant ProductRepository + + User->>ProductController: GET /api/v1/products/1
Header: X-USER-ID=123 (optional) + ProductController->>ProductService: getProductDetail(productId=1, userId=123) + + ProductService->>ProductRepository: findProductDetail(productId=1, userId=123) + + alt 상품이 존재하는 경우 + ProductRepository-->>ProductService: ProductDetailInfo(product, brand, totalLikes, isLiked) + ProductService-->>ProductController: ProductDetailResponse + ProductController-->>User: 200 OK + else 상품이 존재하지 않는 경우 + ProductRepository-->>ProductService: Optional.empty() + ProductService-->>ProductController: ProductNotFoundException + ProductController-->>User: 404 Not Found + end +``` + +## 3. 브랜드 목록 조회 + +```mermaid +sequenceDiagram + participant User + participant BrandController + participant BrandService + participant BrandRepository + + User->>BrandController: GET /api/v1/brands?page=0&size=20 + BrandController->>BrandService: getBrands(pageable) + BrandService->>BrandRepository: findAll(pageable) + BrandRepository-->>BrandService: Page + + alt 브랜드가 존재하는 경우 + BrandService-->>BrandController: BrandListResponse(totalElements=15, content=[...]) + BrandController-->>User: 200 OK + else 브랜드가 없는 경우 + BrandService-->>BrandController: BrandListResponse(totalElements=0, content=[]) + BrandController-->>User: 200 OK + end +``` + +## 4. 브랜드 상세 조회 + +```mermaid +sequenceDiagram + participant User + participant BrandController + participant BrandService + participant BrandRepository + + User->>BrandController: GET /api/v1/brands/1 + BrandController->>BrandService: getBrandById(brandId=1) + BrandService->>BrandRepository: findById(brandId=1) + + alt 브랜드가 존재하는 경우 + BrandRepository-->>BrandService: BrandEntity + BrandService-->>BrandController: BrandDetailResponse + BrandController-->>User: 200 OK + else 브랜드가 존재하지 않는 경우 + BrandRepository-->>BrandService: Optional.empty() + BrandService-->>BrandController: BrandNotFoundException + BrandController-->>User: 404 Not Found + end +``` + +## 5. 좋아요 등록 + +```mermaid +sequenceDiagram + participant User + participant LikeController + participant LikeFacade + participant UserService + participant ProductService + participant LikeService + participant LikeRepository + participant ProductRepository + + User->>LikeController: POST /api/v1/like/products/1
Header: X-USER-ID=testuser + LikeController->>LikeFacade: upsertLike(username="testuser", productId=1) + + LikeFacade->>UserService: getUserByUsername("testuser") + alt 사용자가 존재하지 않는 경우 + UserService-->>LikeFacade: CoreException(NOT_FOUND_USER) + LikeFacade-->>LikeController: CoreException + LikeController-->>User: 404 Not Found + else 사용자가 존재하는 경우 + UserService-->>LikeFacade: UserEntity + + LikeFacade->>ProductService: getProductDetail(productId=1) + alt 상품이 존재하지 않는 경우 + ProductService-->>LikeFacade: CoreException(NOT_FOUND_PRODUCT) + LikeFacade-->>LikeController: CoreException + LikeController-->>User: 404 Not Found + else 상품이 존재하는 경우 + ProductService-->>LikeFacade: ProductEntity + + LikeFacade->>LikeService: upsertLike(user, product) + LikeService->>LikeRepository: findByUserIdAndProductId(userId, productId) + + alt 좋아요가 없는 경우 + LikeRepository-->>LikeService: Optional.empty() + LikeService->>ProductRepository: save(product) [좋아요 카운트 +1] + LikeService->>LikeRepository: save(newLike) + LikeRepository-->>LikeService: LikeEntity (신규) + else 삭제된 좋아요가 있는 경우 + LikeRepository-->>LikeService: LikeEntity (deleted) + Note over LikeService: like.restore() + LikeService->>ProductRepository: save(product) [좋아요 카운트 +1] + LikeRepository-->>LikeService: LikeEntity (복원) + else 활성 좋아요가 있는 경우 + LikeRepository-->>LikeService: LikeEntity (active) + Note over LikeService: 카운트 변경 없음 (중복 방지) + LikeRepository-->>LikeService: LikeEntity (기존) + end + + LikeService-->>LikeFacade: LikeEntity + LikeFacade-->>LikeController: LikeInfo + LikeController-->>User: 200 OK + end + end +``` + +## 6. 좋아요 취소 + +```mermaid +sequenceDiagram + participant User + participant LikeController + participant LikeFacade + participant UserService + participant ProductService + participant LikeService + participant LikeRepository + participant ProductRepository + + User->>LikeController: DELETE /api/v1/like/products/1
Header: X-USER-ID=testuser + LikeController->>LikeFacade: unlikeProduct(username="testuser", productId=1) + + LikeFacade->>UserService: getUserByUsername("testuser") + alt 사용자가 존재하지 않는 경우 + UserService-->>LikeFacade: CoreException(NOT_FOUND_USER) + LikeFacade-->>LikeController: CoreException + LikeController-->>User: 404 Not Found + else 사용자가 존재하는 경우 + UserService-->>LikeFacade: UserEntity + + LikeFacade->>ProductService: getProductDetail(productId=1) + alt 상품이 존재하지 않는 경우 + ProductService-->>LikeFacade: CoreException(NOT_FOUND_PRODUCT) + LikeFacade-->>LikeController: CoreException + LikeController-->>User: 404 Not Found + else 상품이 존재하는 경우 + ProductService-->>LikeFacade: ProductEntity + + LikeFacade->>LikeService: unlikeProduct(user, product) + LikeService->>LikeRepository: findByUserIdAndProductId(userId, productId) + + alt 좋아요가 없는 경우 + LikeRepository-->>LikeService: Optional.empty() + Note over LikeService: 아무 작업 없음 (멱등성 보장) + else 이미 삭제된 좋아요인 경우 + LikeRepository-->>LikeService: LikeEntity (deleted) + Note over LikeService: 아무 작업 없음 (멱등성 보장) + else 활성 좋아요인 경우 + LikeRepository-->>LikeService: LikeEntity (active) + Note over LikeService: like.delete() (소프트 삭제) + LikeService->>ProductRepository: save(product) [좋아요 카운트 -1] + end + + LikeService-->>LikeFacade: void + LikeFacade-->>LikeController: void + LikeController-->>User: 200 OK + end + end +``` + +## 7. 포인트 충전 + +```mermaid +sequenceDiagram + participant User + participant PointController + participant PointFacade + participant PointService + participant UserService + participant UserRepository + participant PointHistoryRepository + + User->>PointController: POST /api/v1/points/charge
Header: X-USER-ID=testuser
Body: {"amount": 10000} + PointController->>PointFacade: chargePoint(username="testuser", request) + + PointFacade->>PointService: charge(username, amount) + PointService->>UserRepository: findByUsername("testuser") + + alt 사용자가 존재하지 않는 경우 + UserRepository-->>PointService: Optional.empty() + PointService-->>PointFacade: CoreException(NOT_FOUND_USER) + PointFacade-->>PointController: CoreException + PointController-->>User: 404 Not Found + else 사용자가 존재하는 경우 + UserRepository-->>PointService: UserEntity + + Note over PointService: user.chargePoint(amount)
[포인트 잔액 증가] + + PointService->>PointHistoryRepository: save(chargeHistory) + Note over PointService: PointHistoryEntity.createChargeHistory()
(userId, amount, balanceAfter) + PointHistoryRepository-->>PointService: PointHistoryEntity + + PointService->>UserRepository: save(user) + UserRepository-->>PointService: UserEntity + + PointService-->>PointFacade: 충전 후 잔액 + PointFacade-->>PointController: PointChargeResponse + PointController-->>User: 200 OK + end +``` + +## 8. 포인트 조회 + +```mermaid +sequenceDiagram + participant User + participant PointController + participant PointFacade + participant PointService + participant UserService + participant UserRepository + participant PointHistoryRepository + + User->>PointController: GET /api/v1/points
Header: X-USER-ID=testuser + PointController->>PointFacade: getPointInfo(username="testuser") + + PointFacade->>UserService: getUserByUsername("testuser") + alt 사용자가 존재하지 않는 경우 + UserService-->>PointFacade: CoreException(NOT_FOUND_USER) + PointFacade-->>PointController: CoreException + PointController-->>User: 404 Not Found + else 사용자가 존재하는 경우 + UserService-->>PointFacade: UserEntity + + PointFacade->>PointService: getPointHistories(username) + PointService->>UserRepository: findByUsername("testuser") + UserRepository-->>PointService: UserEntity + + PointService->>PointHistoryRepository: findByUserOrderByCreatedAtDesc(user) + PointHistoryRepository-->>PointService: List + PointService-->>PointFacade: List + + Note over PointFacade: PointInfo 생성
(잔액, 이력 목록) + PointFacade-->>PointController: PointInfo + PointController-->>User: 200 OK + end +``` + +## 10. 주문 요청 + +```mermaid +sequenceDiagram + participant User + participant OrderController + participant OrderFacade + participant UserService + participant ProductService + participant PointService + participant OrderService + participant OrderRepository + participant ProductRepository + + User->>OrderController: POST /api/v1/orders
Header: X-USER-ID=testuser
Body: {"items":[{"productId":1,"quantity":2}]} + OrderController->>OrderFacade: createOrder(command) + + OrderFacade->>UserService: getUserByUsername("testuser") + alt 사용자가 존재하지 않는 경우 + UserService-->>OrderFacade: CoreException(NOT_FOUND_USER) + OrderFacade-->>OrderController: CoreException + OrderController-->>User: 404 Not Found + else 사용자가 존재하는 경우 + UserService-->>OrderFacade: UserEntity + + Note over OrderFacade: 주문 항목을 productId로 정렬 (데드락 방지) + + loop 각 주문 상품 검증 (정렬된 순서) + OrderFacade->>ProductService: getProductDetailLock(productId) [비관적 락] + alt 상품이 존재하지 않는 경우 + ProductService-->>OrderFacade: CoreException(NOT_FOUND_PRODUCT) + OrderFacade-->>OrderController: CoreException + OrderController-->>User: 404 Not Found + else 재고가 부족한 경우 + ProductService-->>OrderFacade: ProductEntity + Note over OrderFacade: product.canOrder(quantity) = false + OrderFacade-->>OrderController: IllegalArgumentException + OrderController-->>User: 400 Bad Request + else 재고 충분 + ProductService-->>OrderFacade: ProductEntity (locked) + Note over OrderFacade: 주문 가능 상품 목록에 추가
총 주문 금액 계산 + end + end + + OrderFacade->>PointService: use(user, totalAmount) + alt 포인트가 부족한 경우 + PointService-->>OrderFacade: CoreException(INSUFFICIENT_POINTS) + Note over OrderFacade: 트랜잭션 롤백 (재고 락 자동 해제) + OrderFacade-->>OrderController: CoreException + OrderController-->>User: 400 Bad Request + else 포인트 차감 성공 + PointService-->>OrderFacade: 차감 후 잔액 + + OrderFacade->>OrderService: createOrder(request) + OrderService->>OrderRepository: save(order) + OrderRepository-->>OrderService: OrderEntity + OrderService-->>OrderFacade: OrderEntity + + loop 각 주문 항목 생성 및 재고 차감 + OrderFacade->>ProductService: deductStock(product, quantity) + ProductService->>ProductRepository: save(product) [재고 차감] + ProductRepository-->>ProductService: ProductEntity + ProductService-->>OrderFacade: ProductEntity + + OrderFacade->>OrderService: createOrderItem(request) + OrderService->>OrderRepository: save(orderItem) + OrderRepository-->>OrderService: OrderItemEntity + OrderService-->>OrderFacade: OrderItemEntity + end + + OrderFacade-->>OrderController: OrderInfo + OrderController-->>User: 201 Created + end + end +``` + +### 🔒 주문 처리 원자성 보장 + +#### **트랜잭션 전략** +- **@Transactional 기반 원자성**: OrderFacade의 createOrder 메서드 전체가 하나의 트랜잭션 + - 모든 작업이 성공하면 커밋 + - 중간에 예외 발생 시 자동 롤백 (재고 락 해제, 포인트 복구, 주문 취소) + +#### **데드락 방지 전략** +- **정렬된 락 획득**: 주문 항목을 productId 기준으로 정렬하여 처리 + - 스레드 A: [상품1, 상품2] 순서로 락 획득 + - 스레드 B: [상품1, 상품2] 순서로 락 획득 (동일 순서) + - 결과: 원형 대기(circular wait) 방지 + +#### **동시성 제어** +- **비관적 락(Pessimistic Lock)**: getProductDetailLock()에서 SELECT ... FOR UPDATE 사용 + - 재고 조회 시점에 행 레벨 락 획득 + - 트랜잭션 종료 시까지 다른 트랜잭션의 접근 차단 + +## 11. 사용자의 주문 목록 조회 + +```mermaid +sequenceDiagram + participant User + participant OrderController + participant OrderFacade + participant OrderService + participant OrderRepository + + User->>OrderController: GET /api/v1/orders?page=0&size=20
Header: X-USER-ID=testuser + OrderController->>OrderFacade: getOrderSummariesByUserId(userId, pageable) + + OrderFacade->>OrderService: getOrdersByUserId(userId, pageable) + OrderService->>OrderRepository: findByUserId(userId, pageable) + + alt 주문 내역이 존재하는 경우 + OrderRepository-->>OrderService: Page + OrderService-->>OrderFacade: Page + + loop 각 주문에 대해 + OrderFacade->>OrderService: countOrderItems(orderId) + OrderService->>OrderRepository: countByOrderId(orderId) + OrderRepository-->>OrderService: itemCount + OrderService-->>OrderFacade: itemCount + Note over OrderFacade: OrderSummary.from(order, itemCount) + end + + OrderFacade-->>OrderController: Page + OrderController-->>User: 200 OK + else 주문 내역이 없는 경우 + OrderRepository-->>OrderService: Page.empty() + OrderService-->>OrderFacade: Page.empty() + OrderFacade-->>OrderController: Page.empty() + OrderController-->>User: 200 OK + end +``` + +## 12. 주문 상세 조회 + +```mermaid +sequenceDiagram + participant User + participant OrderController + participant OrderFacade + participant OrderService + participant OrderRepository + + User->>OrderController: GET /api/v1/orders/1
Header: X-USER-ID=testuser + OrderController->>OrderFacade: getOrderById(orderId=1) + + OrderFacade->>OrderService: getOrderById(orderId=1) + OrderService->>OrderRepository: findById(orderId=1) + + alt 주문이 존재하지 않는 경우 + OrderRepository-->>OrderService: Optional.empty() + OrderService-->>OrderFacade: CoreException(NOT_FOUND) + OrderFacade-->>OrderController: CoreException + OrderController-->>User: 404 Not Found + else 주문이 존재하는 경우 + OrderRepository-->>OrderService: OrderEntity + OrderService-->>OrderFacade: OrderEntity + + OrderFacade->>OrderService: getOrderItemsByOrderId(orderId=1) + OrderService->>OrderRepository: findByOrderId(orderId=1) + OrderRepository-->>OrderService: List + OrderService-->>OrderFacade: List + + Note over OrderFacade: OrderInfo.from(order, orderItems) + OrderFacade-->>OrderController: OrderInfo + OrderController-->>User: 200 OK + end +``` + +## 13. 주문 취소 + +```mermaid +sequenceDiagram + participant User + participant OrderController + participant OrderFacade + participant OrderService + participant ProductService + participant PointService + participant OrderRepository + participant ProductRepository + + User->>OrderController: DELETE /api/v1/orders/1
Header: X-USER-ID=testuser + OrderController->>OrderFacade: cancelOrder(orderId=1, username="testuser") + + OrderFacade->>OrderService: getOrderById(orderId=1) + OrderService->>OrderRepository: findById(orderId=1) + + alt 주문이 존재하지 않는 경우 + OrderRepository-->>OrderService: Optional.empty() + OrderService-->>OrderFacade: CoreException(NOT_FOUND) + OrderFacade-->>OrderController: CoreException + OrderController-->>User: 404 Not Found + else 주문이 존재하는 경우 + OrderRepository-->>OrderService: OrderEntity + OrderService-->>OrderFacade: OrderEntity + + Note over OrderFacade: order.cancelOrder() [상태 변경] + + OrderFacade->>OrderService: getOrderItemsByOrderId(orderId=1) + OrderService->>OrderRepository: findByOrderId(orderId=1) + OrderRepository-->>OrderService: List + OrderService-->>OrderFacade: List + + Note over OrderFacade: 주문 항목을 productId로 정렬 (데드락 방지) + + loop 각 주문 항목에 대해 재고 원복 (정렬된 순서) + OrderFacade->>ProductService: restoreStock(productId, quantity) [비관적 락] + ProductService->>ProductRepository: findByIdWithLock(productId) + ProductRepository-->>ProductService: ProductEntity (locked) + Note over ProductService: product.restoreStock(quantity) + ProductService->>ProductRepository: save(product) [재고 복구] + ProductRepository-->>ProductService: ProductEntity + ProductService-->>OrderFacade: ProductEntity + end + + OrderFacade->>PointService: charge(username, totalAmount) + Note over PointService: user.chargePoint(amount)
포인트 이력 생성 (CHARGE) + PointService-->>OrderFacade: 환불 후 잔액 + + OrderFacade-->>OrderController: OrderInfo (취소됨) + OrderController-->>User: 200 OK + end +``` + +### 🔒 주문 취소 원자성 보장 + +#### **트랜잭션 전략** +- **@Transactional 기반 원자성**: OrderFacade의 cancelOrder 메서드 전체가 하나의 트랜잭션 + - 주문 상태 변경, 재고 복구, 포인트 환불이 모두 성공하면 커밋 + - 중간에 예외 발생 시 자동 롤백 + +#### **데드락 방지 전략** +- **정렬된 락 획득**: 주문 항목을 productId 기준으로 정렬하여 재고 복구 + - 주문 생성 시와 동일한 순서로 락 획득 + - 원형 대기(circular wait) 방지 diff --git a/docs/week3/03-class-diagram.md b/docs/week3/03-class-diagram.md new file mode 100644 index 000000000..400f50503 --- /dev/null +++ b/docs/week3/03-class-diagram.md @@ -0,0 +1,378 @@ +# 03. 클래스 다이어그램 + +## 도메인 모델 개요 + +이커머스 서비스의 핵심 도메인 객체들과 그들 간의 관계를 정의합니다. +DDD 기반 레이어드 아키텍처를 따르며, BaseEntity를 상속한 엔티티 설계를 적용합니다. + +--- + +## 주요 도메인 규칙 + +### 비즈니스 제약사항 + +| 도메인 | 핵심 규칙 | +|--------|-----------| +| **User** | 포인트는 음수 불가, 차감 시 잔액 검증 필수 | +| **Product** | 재고는 음수 불가, 예약 시 가용 수량 검증 필수 | +| **Order** | 재고 예약과 포인트 차감은 원자적 처리 | +| **Like** | 사용자당 상품별 1개만 가능, 멱등성 보장 | +| **Brand** | 브랜드명 중복 불가, 소프트 삭제 지원 | + +### 도메인 관계 요약 + +| 관계 | 설명 | 다중성 | 비고 | +|----------------------|-------------------|-----|------| +| Product -> Brand | 상품은 브랜드에 속함 | N:1 | 필수 관계 | +| Like -> User | 좋아요는 사용자가 생성 | N:1 | 필수 관계 | +| Like -> Product | 좋아요는 상품에 대한 것 | N:1 | 필수 관계 | +| Order -> User | 주문은 사용자가 생성 | N:1 | 필수 관계 | +| OrderItem -> Order | 주문 상세내역은 주문 정보 포함 | N:1 | 필수 관계 | +| OrderItem -> Product | 주문항목은 상품을 참조 | N:1 | 필수 관계 | +| PointHistory -> User | 포인트 이력은 사용자별 관리 | N:1 | 필수 관계 | + +--- + +## 전체 도메인 모델 + +```mermaid +classDiagram + class UserEntity { + -String username + -String email + -LocalDate birthdate + -Gender gender + -BigDecimal pointAmount + +createUserEntity() UserEntity + +chargePoint() void + +usePoint() void + +getPointAmount() BigDecimal + } + + class BrandEntity { + -String name + -String description + +createBrandEntity() BrandEntity + } + + class ProductEntity { + -Long brandId + -String name + -String description + -Price price + -Integer stockQuantity + -Long likeCount + +createEntity() ProductEntity + +deductStock() void + +restoreStock() void + +increaseLikeCount() void + +decreaseLikeCount() void + +hasStock() boolean + +canOrder() boolean + +isDiscounted() boolean + +getSellingPrice() BigDecimal + } + + class Price { + <> + -BigDecimal originPrice + -BigDecimal discountPrice + +of() Price + +getSellingPrice() BigDecimal + +getDiscountRate() BigDecimal + +isDiscounted() boolean + +applyDiscount() void + +removeDiscount() void + } + + class LikeEntity { + -Long userId + -Long productId + +createEntity() LikeEntity + } + + class OrderEntity { + -Long userId + -BigDecimal totalAmount + -OrderStatus status + +createOrder() OrderEntity + +confirmOrder() void + +cancelOrder() void + +isPending() boolean + +isConfirmed() boolean + +isCancelled() boolean + } + + class OrderItemEntity { + -Long orderId + -Long productId + -Integer quantity + -BigDecimal unitPrice + -BigDecimal totalPrice + +createOrderItem() OrderItemEntity + +calculateItemTotal() BigDecimal + } + + class PointHistoryEntity { + -UserEntity user + -BigDecimal amount + -PointTransactionType transactionType + -BigDecimal balanceAfter + +createChargeHistory() PointHistoryEntity + +createUseHistory() PointHistoryEntity + } + + UserEntity "1" --> "*" LikeEntity : userId + UserEntity "1" --> "*" OrderEntity : userId + UserEntity "1" --> "*" PointHistoryEntity : ManyToOne + ProductEntity "1" --> "*" LikeEntity : productId + BrandEntity "1" --> "*" ProductEntity : brandId + ProductEntity "1" *-- "1" Price : 임베디드 + OrderEntity "1" --> "1..*" OrderItemEntity : orderId + OrderItemEntity "*" --> "1" ProductEntity : productId +``` + +--- + +## 도메인별 상세 다이어그램 + +### 1. 사용자 도메인 (User Domain) + +```mermaid +classDiagram + class UserEntity { + -String username + -String email + -LocalDate birthdate + -Gender gender + -BigDecimal pointAmount + +createUserEntity() UserEntity + +chargePoint() void + +usePoint() void + +getPointAmount() BigDecimal + } + + class PointHistoryEntity { + -UserEntity user + -BigDecimal amount + -PointTransactionType transactionType + -BigDecimal balanceAfter + +createChargeHistory() PointHistoryEntity + +createUseHistory() PointHistoryEntity + } + + UserEntity "1" --> "*" PointHistoryEntity : ManyToOne 관계 +``` + +### 2. 상품 도메인 (Product Domain) + +```mermaid +classDiagram + class BrandEntity { + -String name + -String description + +createBrandEntity() BrandEntity + } + + class ProductEntity { + -Long brandId + -String name + -String description + -Price price + -Integer stockQuantity + -Long likeCount + +createEntity() ProductEntity + +deductStock() void + +restoreStock() void + +increaseLikeCount() void + +decreaseLikeCount() void + +hasStock() boolean + +canOrder() boolean + +isDiscounted() boolean + +getSellingPrice() BigDecimal + } + + class Price { + <> + -BigDecimal originPrice + -BigDecimal discountPrice + +of() Price + +getSellingPrice() BigDecimal + +getDiscountRate() BigDecimal + +isDiscounted() boolean + +getDiscountAmount() BigDecimal + +applyDiscount() void + +removeDiscount() void + } + + BrandEntity "1" --> "*" ProductEntity : brandId 참조 + ProductEntity "1" *-- "1" Price : 임베디드 타입 +``` + +### 3. 좋아요 도메인 (Like Domain) + +```mermaid +classDiagram + class LikeEntity { + -Long userId + -Long productId + +createEntity() LikeEntity + } + + class UserEntity { + -String username + -String email + -LocalDate birthdate + -Gender gender + -BigDecimal pointAmount + +createUserEntity() UserEntity + +chargePoint() void + +usePoint() void + } + + class ProductEntity { + -Long brandId + -String name + -Price price + -Integer stockQuantity + -Long likeCount + +createEntity() ProductEntity + +increaseLikeCount() void + +decreaseLikeCount() void + } + + LikeEntity "*" --> "1" UserEntity : userId 참조 + LikeEntity "*" --> "1" ProductEntity : productId 참조 +``` + +### 4. 주문 도메인 (Order Domain) + +```mermaid +classDiagram + class OrderEntity { + -Long userId + -BigDecimal totalAmount + -OrderStatus status + +createOrder() OrderEntity + +confirmOrder() void + +cancelOrder() void + +isPending() boolean + +isConfirmed() boolean + +isCancelled() boolean + } + + class OrderItemEntity { + -Long orderId + -Long productId + -Integer quantity + -BigDecimal unitPrice + -BigDecimal totalPrice + +createOrderItem() OrderItemEntity + +calculateItemTotal() BigDecimal + } + + class UserEntity { + -String username + -String email + -LocalDate birthdate + -Gender gender + -BigDecimal pointAmount + +createUserEntity() UserEntity + +chargePoint() void + +usePoint() void + } + + class ProductEntity { + -Long brandId + -String name + -Price price + -Integer stockQuantity + -Long likeCount + +createEntity() ProductEntity + +deductStock() void + +restoreStock() void + } + + OrderEntity "1" --> "*" OrderItemEntity : orderId 참조 + OrderEntity "*" --> "1" UserEntity : userId 참조 + OrderItemEntity "*" --> "1" ProductEntity : productId 참조 +``` + +--- + +## 도메인 엔티티 핵심 책임 + +### 1. UserEntity - 사용자 도메인의 핵심 책임 + +| 메서드 | 책임 | 비즈니스 규칙 | 구현 세부사항 | +|--------|----------|--------------------------|---------------| +| `createUserEntity()` | 사용자 생성 | 사용자명/이메일 유효성 검증, 생년월일 검증 | 정적 팩토리 메서드, 불변 객체 생성 | +| `chargePoint()` | 포인트 충전 | 충전 금액 양수 검증 | BigDecimal 정밀 계산, 원자적 연산 | +| `usePoint()` | 포인트 사용 | 잔액 부족 시 예외 발생, 음수 방지 | 원자적 연산, 상태 변경 후 검증 | +| `getPointAmount()` | 포인트 조회 | 소수점 2자리로 반올림 | RoundingMode.HALF_UP 적용 | + +### 2. ProductEntity - 상품 도메인의 핵심 책임 + +| 메서드 | 책임 | 비즈니스 규칙 | 구현 세부사항 | +|--------|------|---------------|---------------| +| `createEntity()` | 상품 생성 | 가격 유효성, 재고 음수 방지, 브랜드 ID 필수 | 정적 팩토리 메서드, Price 임베디드 타입 | +| `hasStock()` | 재고 확인 | 재고 수량 > 0 | 단순 재고 존재 여부 확인 | +| `canOrder()` | 주문 가능 여부 | 재고 >= 수량, 수량 > 0 | 복합 조건 검증 | +| `deductStock()` | 재고 차감 | 재고 부족 시 INSUFFICIENT_STOCK 예외 | 원자적 재고 차감 연산 | +| `restoreStock()` | 재고 복구 | 취소 시 재고 복원 | 재고 복구 연산 | +| `increaseLikeCount()` | 좋아요 수 증가 | 음수 방지, 동시성 제어 | 원자적 증가 연산 | +| `decreaseLikeCount()` | 좋아요 수 감소 | 0 이하로 감소 방지 | 최소값 0 보장 | +| `isDiscounted()` | 할인 여부 | Price 객체의 할인가 존재 여부 | Price.isDiscounted() 위임 | +| `getSellingPrice()` | 판매가 조회 | 할인가 우선, 없으면 정가 | Price.getSellingPrice() 위임 | + +### 3. OrderEntity - 주문 도메인의 핵심 책임 + +| 메서드 | 책임 | 비즈니스 규칙 | 구현 세부사항 | +|--------|------|---------------|---------------| +| `createOrder()` | 주문 생성 | 사용자 ID 필수, 총액 > 0 | 정적 팩토리 메서드, 초기 상태 PENDING | +| `confirmOrder()` | 주문 확정 | PENDING → CONFIRMED 상태 전이 | INVALID_ORDER_STATUS 예외 처리 | +| `cancelOrder()` | 주문 취소 | PENDING/CONFIRMED → CANCELLED | 취소 가능 상태 검증 | +| `isPending()` | 대기 상태 확인 | 주문 상태가 PENDING인지 확인 | 상태 검증 메서드 | +| `isConfirmed()` | 확정 상태 확인 | 주문 상태가 CONFIRMED인지 확인 | 상태 검증 메서드 | +| `isCancelled()` | 취소 상태 확인 | 주문 상태가 CANCELLED인지 확인 | 상태 검증 메서드 | + +### 4. OrderItemEntity - 주문 항목 도메인의 핵심 책임 + +| 메서드 | 책임 | 비즈니스 규칙 | 구현 세부사항 | +|--------|------|---------------|---------------| +| `createOrderItem()` | 주문 항목 생성 | 주문 ID, 상품 ID 필수, 수량 >= 1, 단가 > 0 | 정적 팩토리 메서드, 스냅샷 가격 | +| `calculateItemTotal()` | 항목 총액 계산 | 단가 × 수량 = 총액 | BigDecimal 정밀 계산 | + +### 5. LikeEntity - 좋아요 도메인의 핵심 책임 + +| 메서드 | 책임 | 비즈니스 규칙 | 구현 세부사항 | +|-----------------------|-------------|-------------------------|-------------------| +| `createEntity()` | 좋아요 생성 | 사용자 ID, 상품 ID 필수, 복합 유니크 제약 | 정적 팩토리 메서드, 복합키 검증 | + +### 6. BrandEntity - 브랜드 도메인의 핵심 책임 + +| 메서드 | 책임 | 비즈니스 규칙 | 구현 세부사항 | +|--------|------|---------------|---------------| +| `createBrandEntity()` | 브랜드 생성 | 브랜드명 필수, 설명 선택사항, 길이 <= 100 | 정적 팩토리 메서드, 유니크 제약 | + +### 7. PointHistoryEntity - 포인트 이력 도메인의 핵심 책임 + +| 메서드 | 책임 | 비즈니스 규칙 | 구현 세부사항 | +|--------|------|---------------|---------------| +| `createChargeHistory()` | 충전 이력 생성 | 충전 금액 양수, 잔액 일치성 | 정적 팩토리 메서드, CHARGE 타입 | +| `createUseHistory()` | 사용 이력 생성 | 사용 금액 양수, 잔액 일치성 | 정적 팩토리 메서드, USE 타입 | + +### 8. Price - 가격 임베디드 타입의 핵심 책임 + +| 메서드 | 책임 | 비즈니스 규칙 | 구현 세부사항 | +|--------|------|---------------|---------------| +| `of()` | Price 객체 생성 | 정가 필수, 할인가 선택사항 | 정적 팩토리 메서드, 불변 객체 | +| `ofOriginOnly()` | 정가만으로 생성 | 정가 > 0 | 할인가 없는 Price 생성 | +| `getSellingPrice()` | 실제 판매가 반환 | 할인가 우선, 없으면 정가 | 할인가 존재 여부에 따른 분기 | +| `getDiscountRate()` | 할인율 계산 | (정가-할인가)/정가 * 100 | BigDecimal 정밀 계산, 소수점 2자리 | +| `isDiscounted()` | 할인 여부 확인 | 할인가가 null이 아니고 정가보다 작음 | 할인가 유효성 검증 | +| `getDiscountAmount()` | 할인 금액 계산 | 정가 - 할인가 | 할인 절약 금액 반환 | +| `applyDiscount()` | 할인 적용 | 할인가 >= 0, 할인가 <= 정가 | 할인가 유효성 검증 후 적용 | +| `removeDiscount()` | 할인 제거 | 할인가를 null로 설정 | 할인 정보 초기화 | + +--- \ No newline at end of file diff --git a/docs/week3/04-erd.md b/docs/week3/04-erd.md new file mode 100644 index 000000000..de3d61164 --- /dev/null +++ b/docs/week3/04-erd.md @@ -0,0 +1,347 @@ +# 04. ERD + +## 개요 + +이커머스 서비스의 데이터베이스 ERD 입니다. + +--- + +## 전체 ERD + +```mermaid +erDiagram + users ||--o{ likes: "" + products ||--o{ likes: "" + orders ||--|{ order_items: "" + users ||--o{ orders: "" + users ||--o{ point_histories: "" + brands ||--o{ products: "" + products ||--o{ order_items: "" + + users { + bigint id PK "기본키" + varchar(10) username UK "사용자명(영문/숫자 10자)" + varchar(254) email "이메일" + date birthdate "생년월일" + varchar(10) gender "성별 MALE/FEMALE 열거형" + decimal point_amount "보유 포인트 금액" + timestamp created_at "생성일시" + timestamp updated_at "수정일시" + timestamp deleted_at "삭제일시" + } + + brands { + bigint id PK "기본키" + varchar(100) name UK "브랜드명" + text description "브랜드 설명" + timestamp created_at "생성일시" + timestamp updated_at "수정일시" + timestamp deleted_at "삭제일시" + } + + products { + bigint id PK "기본키" + bigint brand_id "브랜드 ID" + varchar(200) name "상품명" + text description "상품 설명" + decimal origin_price "정가(Price 임베디드)" + decimal discount_price "할인가(Price 임베디드)" + int stock_quantity "재고 수량" + bigint like_count "좋아요 수(비정규화)" + timestamp created_at "생성일시" + timestamp updated_at "수정일시" + timestamp deleted_at "삭제일시" + } + + likes { + bigint id PK "기본키" + bigint userId "사용자 ID" + bigint productId "상품 ID" + timestamp created_at "생성일시" + timestamp updated_at "수정일시" + timestamp deleted_at "삭제일시" + } + + orders { + bigint id PK "기본키" + bigint user_id "사용자 ID" + decimal total_amount "총 주문 금액" + varchar(20) status "주문 상태(PENDING/CONFIRMED/CANCELLED)" + timestamp created_at "생성일시" + timestamp updated_at "수정일시" + timestamp deleted_at "삭제일시" + } + + order_items { + bigint id PK "기본키" + bigint order_id "주문 ID" + bigint product_id "상품 ID" + int quantity "주문 수량" + decimal unit_price "단가(주문 시점 가격)" + decimal total_price "총 가격(단가 * 수량)" + timestamp created_at "생성일시" + timestamp updated_at "수정일시" + timestamp deleted_at "삭제일시" + } + + point_histories { + bigint id PK "기본키" + bigint user_id "사용자 ID" + decimal amount "포인트 변동 금액" + varchar(20) transaction_type "거래 유형(CHARGE/USE)" + decimal balance_after "거래 후 잔액" + timestamp created_at "생성일시" + timestamp updated_at "수정일시" + timestamp deleted_at "삭제일시" + } +``` + +--- + +## 테이블별 상세 설명 + +### 1. users (사용자) + +**목적**: 이커머스 서비스를 이용하는 사용자 정보 관리 + +| 컬럼명 | 타입 | 제약조건 | 설명 | +|--------|------|----------|------| +| id | BIGINT | PK, AUTO_INCREMENT | 사용자 고유 식별자 | +| username | VARCHAR(10) | UNIQUE, NOT NULL | 사용자명 (영문/숫자 10자 이내) | +| email | VARCHAR(254) | NOT NULL | 이메일 주소 | +| birthdate | DATE | NOT NULL | 생년월일 | +| gender | VARCHAR(10) | NOT NULL | 성별 (MALE/FEMALE) | +| point_amount | DECIMAL(9,2) | NOT NULL, DEFAULT 0 | 포인트 잔액 (음수 불가) | +| created_at | TIMESTAMP | NOT NULL | 생성일시 | +| updated_at | TIMESTAMP | NOT NULL | 수정일시 | +| deleted_at | TIMESTAMP | NULL | 삭제일시| + +**인덱스**: +- `idx_user_username`: username (유니크 인덱스) + +**비즈니스 규칙**: +- 포인트는 항상 0 이상이어야 함 +- 포인트 차감 시 잔액 검증 필수 +- 사용자명은 중복 불가 + +--- + +### 2. brands (브랜드) + +**목적**: 상품을 그룹화하는 브랜드 정보 관리 + +| 컬럼명 | 타입 | 제약조건 | 설명 | +|--------|------|----------|------------| +| id | BIGINT | PK, AUTO_INCREMENT | 브랜드 고유 식별자 | +| name | VARCHAR(100) | UNIQUE, NOT NULL | 브랜드명 | +| description | TEXT | NULL | 브랜드 설명 | +| created_at | TIMESTAMP | NOT NULL | 생성일시 | +| updated_at | TIMESTAMP | NOT NULL | 수정일시 | +| deleted_at | TIMESTAMP | NULL | 삭제일시 | + +**인덱스**: +- `idx_brand_name`: name (유니크 인덱스) + +**비즈니스 규칙**: +- 브랜드명은 중복 불가 + +--- + +### 3. products (상품) + +**목적**: 판매 가능한 상품 정보 관리 + +| 컬럼명 | 타입 | 제약조건 | 설명 | +|--------|------|----------|---------------| +| id | BIGINT | PK, AUTO_INCREMENT | 상품 고유 식별자 | +| brand_id | BIGINT | NOT NULL | 브랜드 ID (brands.id) | +| name | VARCHAR(200) | NOT NULL | 상품명 | +| description | TEXT | NULL | 상품 설명 | +| origin_price | DECIMAL(10,2) | NOT NULL | 정가 (Price 임베디드 타입) | +| discount_price | DECIMAL(10,2) | NULL | 할인가 (Price 임베디드 타입, 선택사항) | +| stock_quantity | INT | NOT NULL, DEFAULT 0 | 재고 수량 (음수 불가) | +| like_count | BIGINT | NOT NULL, DEFAULT 0 | 좋아요 수 | +| created_at | TIMESTAMP | NOT NULL | 생성일시 | +| updated_at | TIMESTAMP | NOT NULL | 수정일시 | +| deleted_at | TIMESTAMP | NULL | 삭제일시| + +**인덱스**: +- `idx_productentity_brand_id`: brand_id (브랜드별 상품 조회) +- `idx_productentity_name`: name (상품명 검색) + +**임베디드 타입 (Price)**: +```java +@Embeddable +public class Price { + private BigDecimal originPrice; // 정가 + private BigDecimal discountPrice; // 할인가 (선택사항) + + // 실제 판매가 반환 (할인가가 있으면 할인가, 없으면 정가) + public BigDecimal getSellingPrice() +} +``` + +**비즈니스 규칙**: +- 재고는 항상 0 이상이어야 함 +- 재고 차감 시 가용 수량 검증 필수 +- 할인가는 선택사항이며, 설정 시 정가보다 작아야 함 +- Price는 임베디드 타입으로 정가와 할인가를 함께 관리 +- released_at 필드는 엔티티에서 제거됨 (비즈니스 요구사항 변경) + +--- + +### 4. likes (좋아요) + +**목적**: 사용자의 상품 좋아요 정보 관리 + +| 컬럼명 | 타입 | 제약조건 | 설명 | +|--------|------|----------|------| +| id | BIGINT | PK, AUTO_INCREMENT | 좋아요 고유 식별자 | +| userId | BIGINT | NOT NULL, UNIQUE (with productId) | 사용자 ID (users.id) | +| productId | BIGINT | NOT NULL, UNIQUE (with userId) | 상품 ID (products.id) | +| created_at | TIMESTAMP | NOT NULL | 좋아요 등록 시간 | +| updated_at | TIMESTAMP | NOT NULL | 수정일시 | +| deleted_at | TIMESTAMP | NULL | 삭제일시| + +**인덱스**: +- `uc_likee_user_product`: (userId, productId) UNIQUE (중복 방지) + +**논리적 관계** (물리적 FK 없음): +- userId → users(id) +- productId → products(id) + +**비즈니스 규칙**: +- 사용자당 상품별 좋아요는 1개만 가능 (복합 고유 제약) +- 좋아요 등록/취소는 멱등성 보장 +- 좋아요 등록 시 products.like_count 증가 +- 좋아요 취소 시 products.like_count 감소 + +--- + +### 5. orders (주문) + +**목적**: 사용자의 주문 정보 관리 + +| 컬럼명 | 타입 | 제약조건 | 설명 | +|--------|------|----------|------| +| id | BIGINT | PK, AUTO_INCREMENT | 주문 고유 식별자 | +| user_id | BIGINT | NOT NULL | 사용자 ID (users.id) | +| total_amount | DECIMAL(10,2) | NOT NULL | 총 주문 금액 | +| status | VARCHAR(20) | NOT NULL | 주문 상태 (PENDING/CONFIRMED/CANCELLED) | +| created_at | TIMESTAMP | NOT NULL | 생성일시 (주문 일시로 사용) | +| updated_at | TIMESTAMP | NOT NULL | 수정일시 | +| deleted_at | TIMESTAMP | NULL | 삭제일시| + +**인덱스**: +- `idx_order_user_id`: user_id (사용자별 주문 목록 조회) +- `idx_order_status`: status (상태별 주문 조회) + +**논리적 관계** (물리적 FK 없음): +- user_id → users(id) + +**비즈니스 규칙**: +- 주문 생성 시 상태는 PENDING +- 결제 완료 시 CONFIRMED로 변경 +- PENDING 또는 CONFIRMED 상태에서 CANCELLED로 변경 가능 +- total_amount는 order_items의 total_price 합계 +- ordered_at 필드는 제거되고 created_at을 주문 일시로 사용 + +**상태 전이**: +``` +PENDING → CONFIRMED (결제 완료) +PENDING → CANCELLED (주문 취소) +CONFIRMED → CANCELLED (주문 취소) +``` + +--- + +### 6. order_items (주문 상세) + +**목적**: 주문에 포함된 개별 상품 정보 관리 + +| 컬럼명 | 타입 | 제약조건 | 설명 | +|--------|------|----------|------| +| id | BIGINT | PK, AUTO_INCREMENT | 주문 상세 고유 식별자 | +| order_id | BIGINT | NOT NULL | 주문 ID (orders.id) | +| product_id | BIGINT | NOT NULL | 상품 ID (products.id) | +| quantity | INT | NOT NULL | 주문 수량 (1 이상) | +| unit_price | DECIMAL(10,2) | NOT NULL | 단가 (주문 시점의 상품 가격) | +| total_price | DECIMAL(10,2) | NOT NULL | 총 가격 (unit_price * quantity) | +| created_at | TIMESTAMP | NOT NULL | 생성일시 | +| updated_at | TIMESTAMP | NOT NULL | 수정일시 | +| deleted_at | TIMESTAMP | NULL | 삭제일시| + +**인덱스**: +- `idx_order_item_order_id`: order_id (주문별 상세 조회) +- `idx_order_item_product_id`: product_id (상품별 주문 이력) + +**논리적 관계** (물리적 FK 없음): +- order_id → orders(id) +- product_id → products(id) + +**비즈니스 규칙**: +- quantity는 1 이상이어야 함 +- unit_price는 주문 시점의 상품 가격 스냅샷 +- total_price = unit_price * quantity +- 주문 생성 시 재고 차감 필수 + +--- + +### 7. point_histories (포인트 이력) + +**목적**: 사용자의 포인트 변동 이력 관리 (감사 추적) + +| 컬럼명 | 타입 | 제약조건 | 설명 | +|--------|------|----------|------| +| id | BIGINT | PK, AUTO_INCREMENT | 포인트 이력 고유 식별자 | +| user_id | BIGINT | NOT NULL (FK) | 사용자 ID (users.id) - ManyToOne 관계 | +| amount | DECIMAL(9,2) | NOT NULL | 포인트 변동 금액 (항상 양수) | +| transaction_type | VARCHAR(20) | NOT NULL | 거래 유형 (CHARGE/USE/REFUND) | +| balance_after | DECIMAL(9,2) | NOT NULL | 거래 후 잔액 | +| created_at | TIMESTAMP | NOT NULL | 거래 일시 | +| updated_at | TIMESTAMP | NOT NULL | 수정일시 | +| deleted_at | TIMESTAMP | NULL | 삭제일시| + +**인덱스**: +- JPA에서 자동 생성되는 FK 인덱스 사용 + +**물리적 관계**: +- user_id → users(id) - ManyToOne 관계로 물리적 FK 존재 + +**비즈니스 규칙**: +- 모든 포인트 변동은 이력으로 기록 +- amount는 항상 양수 (거래 유형으로 충전/사용 구분) +- balance_after는 거래 후 users.point_amount와 일치 +- UserEntity와 ManyToOne 관계로 물리적 FK 존재 + +**거래 유형**: +- `CHARGE`: 포인트 충전 +- `USE`: 포인트 사용 (주문 결제) +- `REFUND`: 포인트 환불 (주문 취소) +--- + +## 데이터베이스 제약사항 + +### 1. 외래키 제약조건 + +**⚠️ 물리적 FK 제약조건 제한적 사용 정책** + +이 프로젝트는 **대부분의 테이블에서 물리적 외래키(Foreign Key) 제약조건을 사용하지 않습니다**. + +**예외**: +- `point_histories` 테이블의 `user_id`는 JPA `@ManyToOne` 관계로 물리적 FK 존재 + - 포인트 이력은 사용자와 강한 결합 관계 + - 사용자 삭제 시 이력도 함께 관리 필요 + +**물리적 FK 미사용 이유**: +- **성능**: FK 제약조건은 INSERT/UPDATE/DELETE 시 추가 검증 오버헤드 발생 +- **유연성**: 데이터 마이그레이션 및 배치 작업 시 제약 없음 +- **확장성**: 샤딩, 파티셔닝 등 분산 환경에서 유리 +- **장애 격리**: 참조 테이블 장애 시에도 독립적 운영 가능 + +--- +### 2. 테이블 네이밍 규칙 + +- 복수형 사용 (users, products, orders) +- 스네이크 케이스 (snake_case) +- 소문자 사용 diff --git a/docs/week4/01-requirements.md b/docs/week4/01-requirements.md new file mode 100644 index 000000000..510a0ae62 --- /dev/null +++ b/docs/week4/01-requirements.md @@ -0,0 +1,243 @@ +# 이커머스 서비스 요구사항 명세 + +## 개발 범위 + +### 포함 기능 +- 상품 목록/상세 조회 및 브랜드 조회 +- 상품 좋아요 등록/취소 (멱등성 보장) +- 주문 생성 및 결제 플로우 (재고 차감, 포인트 차감) + +### 사용자 식별 +- 모든 API는 `userId` 헤더를 통해 사용자를 식별 +- 별도 인증/인가 로직 없음 + +--- + +## 📖 유저 시나리오 + +### 핵심 사용자 여정 +``` +1. 사용자가 이커머스 사이트에 접속 +2. 다양한 브랜드의 상품들을 둘러보며 탐색 +3. 마음에 드는 상품에 좋아요 등록 +4. 여러 상품을 선택하여 한 번에 주문 +5. 보유 포인트로 간편 결제 완료 +``` + +### 주요 쇼핑 행동 +- 상품 비교 후 구매 +- 브랜드 기반 탐색 +- 빠른 검색과 구매 +--- + + +## 시나리오 1 : 상품 발견과 탐색 + +### 유저 스토리 +> **"다양한 상품을 찾아보고 싶다."** + +**상황**: 사용자가 새로운 상품을 찾거나 특정 조건의 상품을 탐색하려는 경우 +**핵심 니즈**: +- 적당한 양의 상품을 압도되지 않게 보기 +- 개인 취향/예산에 맞는 상품 빠르게 탐색 +- 인기/최신 상품 우선 확인 + +### 기능 요구사항 + +#### 1. 상품 목록 조회 +- 페이지 단위로 상품을 조회할 수 있다 (20개 기본, 100개 최대) +- 무한 스크롤 또는 페이징 방식으로 추가 상품을 볼 수 있다 +- 사용자 부담을 줄이는 적절한 표시 수량을 제공한다 + +#### 2. 필터링 및 정렬 +- **브랜드 필터링**: 특정 브랜드의 상품만 볼 수 있다 +- **정렬 옵션**: + - 최신순 (`latest`): 새로 등록된 상품부터 + - 가격 오름차순 (`price_asc`): 저렴한 상품부터 + - 좋아요순 (`likes_desc`): 인기 있는 상품부터 + +#### 3. 상품 상세 정보 +- 구매 결정을 위한 기본 정보 제공 + - 상품명과 설명 + - 가격 정보 (정가, 할인가) + - 재고 수량 + - 브랜드 정보 (브랜드명, 설명) + - 출시일 +- 좋아요 관련 정보 + - 해당 상품의 총 좋아요 수 + - 현재 조회 중인 사용자의 좋아요 여부 + +#### 4. 오류 처리 +- 존재하지 않는 상품 조회 시 404 Not Found 응답 +- 존재하지 않는 브랜드로 필터링 시 빈 목록 반환 (404 아님) + +### 기술 요구사항 +- **페이지네이션**: 기본 20개, 최대 100개 +- **쿼리 파라미터**: `brandId`, `sort`, `page`, `size` +- **응답 데이터**: 상품 기본 정보, 브랜드 정보, 좋아요 수, 사용자 좋아요 상태 +- **성능 목표**: 상품 목록 조회 < 500ms, 상품 상세 조회 < 300ms + +--- + +## 시나리오 2 : 브랜드 중심 쇼핑 + +### 유저 스토리 +> **"내가 좋아하는 브랜드의 다른 상품들도 보고 싶어요"** + +**상황**: 특정 브랜드에 관심이 생겼거나 브랜드 충성도가 높은 사용자 (ex. 무신사 스탠다드) +**니즈**: +- 브랜드가 어떤 정체성을 가진지 알고 싶음 +- 해당 브랜드의 다른 상품들도 함께 탐색하고 싶음 + +### 기능 요구사항 + +#### 1. 브랜드 조회 +- 사용자는 브랜드의 기본 정보를 조회할 수 있다 +- 브랜드명과 설명을 통해 브랜드에 대해 이해할 수 있다 + +#### 2. 브랜드 기반 상품 발견 +- 특정 브랜드의 모든 상품을 모아서 볼 수 있다 +- 브랜드 페이지에서 자연스럽게 상품 목록으로 이동 가능 + +#### 3. 오류 처리 +- 존재하지 않는 브랜드 조회 시 404 Not Found 응답 + +### 기술 구현 요구사항 +- 브랜드 정보 조회 API +- 브랜드별 상품 필터링 기능 +- 브랜드명 중복 불가 정책 + +--- + +## 시나리오 3 : 관심 상품 관리 (좋아요) + +### 유저 스토리 +> **"마음에 드는 상품을 표시해두고 나중에 다시 보고 싶어요"** + +**상황**: 탐색 중 마음에 드는 상품을 발견했지만 즉시 구매하지는 않는 경우 +**니즈**: +- 관심 상품을 간단하게 표시하고 싶음 +- 실수로 여러 번 눌러도 문제없이 동작했으면 함 +- 나중에 관심 표시한 상품들을 모아서 보고 싶음 +- 다른 사람들은 어떤 상품을 좋아하는지 궁금함 + +### 기능 요구사항 + +#### 1. 즉석 관심 표현 +- 상품 보는 중에 클릭 한 번으로 좋아요 등록 가능 +- 마음이 바뀌면 언제든 좋아요 취소 가능 +- 실수로 여러 번 클릭해도 의도한 대로 동작 (멱등성) + +#### 2. 개인 위시리스트 +- 내가 좋아요한 상품들을 모아서 볼 수 있다 + - 좋아요한 시간 순으로 정렬 (최신순) + - 각 상품의 현재 가격, 재고 상태 확인 가능 +- 관심 상품 목록에서 바로 구매로 이어질 수 있다 + +#### 3. 상품 인기도 확인 +- 상품 조회 시 전체 좋아요 수를 통해 인기도 확인 +- 다른 사용자들의 관심도를 참고할 수 있음 + +#### 4. 오류 처리 +- 존재하지 않는 상품에 좋아요 시도 시 404 Not Found 응답 + +### 기술 구현 요구사항 +- 사용자당 상품별 좋아요 최대 1개 +- 멱등성 보장: 중복 등록/취소 시 성공 응답 +- 실시간 좋아요 수 집계 및 반영 + +--- + +## 시나리오 4 : 상품 주문 + +### 유저 스토리 +> **"여러 상품을 한 번에 주문하고 포인트로 결제하고 싶다"** + +**상황**: 탐색과 좋아요를 통해 구매할 상품들을 정했고 실제 주문하려는 상황 +**니즈**: +- 장바구니처럼 여러 상품을 한 번에 주문하고 싶음 +- 복잡한 결제 과정 없이 간단하게 구매하고 싶음 +- 재고나 포인트 문제가 있으면 주문 전에 미리 알고 싶음 +- 주문 후 내역을 확인하고 싶음 + +### 기능 요구사항 + +#### 1. 다중 상품 주문 +- 여러 상품을 선택하고 각각 수량 지정 가능 +- 총 주문 금액을 명확하게 확인 가능 +- 주문 전 재고와 포인트 상황 검증 + +#### 2. 포인트 기반 간편 결제 +- 사전 충전된 포인트로 결제 처리 +- 카드 정보 입력 등 복잡한 과정 없음 +- 포인트 부족 시 명확한 안내와 함께 주문 실패 + +#### 3. 안전한 주문 처리 +- 재고 부족 시 주문 실패 및 명확한 메시지 +- 일부 상품만 주문되는 상황 방지 (전체 성공 또는 전체 실패 기반 원자성 보장) +- 주문 완료 시 재고와 포인트 동시 차감 + +#### 4. 주문 이력 관리 +- 내 주문 목록 조회 가능 +- 주문별 상세 내역 확인 가능 +- 주문 상태 추적 가능 + +#### 5. 오류 처리 +- 존재하지 않는 상품으로 주문 시도 시 404 Not Found 응답 +- 존재하지 않는 주문 조회 시 404 Not Found 응답 +- 존재하지 않는 사용자의 주문 목록 조회 시 404 Not Found 응답 +- 재고 부족 시 400 Bad Request 응답 (명확한 오류 메시지 포함) +- 포인트 부족 시 400 Bad Request 응답 (필요/보유 포인트 정보 포함) +- 동시 주문으로 인한 재고 부족 시 409 Conflict 응답 +- 이미 취소된 주문을 다시 취소 시도 시 400 Bad Request 응답 + +### 기술 구현 요구사항 +- 트랜잭션: 재고 차감, 포인트 차감, 주문 생성의 원자성 +- 동시성 제어: 동시 주문 시 정확한 재고 관리 +- 주문 상태: PENDING, CONFIRMED, CANCELLED +- 외부 시스템 연동 (Mock 처리) +- **오류 응답 정책**: 404 (리소스 없음), 400 (비즈니스 규칙 위반), 409 (동시성 충돌) + +--- +## 시나리오 5 : 쿠폰 관리 및 적용 +### 유저 스토리 +"할인 쿠폰을 사용해서 주문 가격을 절감하고 싶다" + +상황: 쿠폰을 보유 중이며 주문 시 할인을 받고 싶은 경우 니즈: +- 쿠폰으로 얼마나 할인받을 수 있는지 미리 확인하고 싶음 +- 주문 시 쿠폰을 적용해서 결제하고 싶음 +- 쿠폰 사용 후 다시 사용하려고 할 때 실수로 중복 사용되지 않았으면 함 + +### 기능 요구사항 +1. 쿠폰 타입 + 정액 쿠폰: 고정 금액 할인 (예: 5,000원 할인) + 배율 쿠폰: 주문 금액의 일정 비율 할인 (예: 10% 할인) +2. 쿠폰 1회성 및 사용 제한 + 쿠폰은 1회성으로 한 번 사용하면 재사용 불가 + 이미 사용한 쿠폰은 다시 사용할 수 없음 + 사용 중인 쿠폰을 재사용 시도 시 명확한 에러 메시지 반환 +3. 주문 시 쿠폰 적용 (설계 검토 대상) + 현재 검토 중인 적용 방식: + 방식 A: 상품별 쿠폰 적용 - 각 상품마다 개별적으로 쿠폰 적용 + 방식 B: 주문 전체에 쿠폰 적용 - 1개 주문에 1개 쿠폰 적용 + 임시 결정: 주문 단위로 1건의 쿠폰만 적용 가능 + 1개 주문에 최대 1개의 쿠폰 적용 + 여러 쿠폰을 조합해서 사용하는 기능은 추후 검토 예정 +4. 동시성 제어 + 동시에 같은 쿠폰으로 여러 주문을 시도해도 1건만 사용 성공 + 첫 번째 사용자의 주문만 쿠폰 적용되고, 나머지는 쿠폰 사용 불가 에러 반환 + 쿠폰의 일회성을 보장하기 위한 원자적 처리 +5. 쿠폰 조회 + 사용자의 보유 쿠폰 목록 조회 가능 + 각 쿠폰의 정보 (타입, 할인율/금액, 사용 여부) 확인 가능 + 이미 사용한 쿠폰은 구분 가능해야 함 +6. 오류 처리 + 존재하지 않는 쿠폰 사용 시도 시 404 Not Found 응답 + 이미 사용된 쿠폰 재사용 시도 시 400 Bad Request 응답 + 사용 권한이 없는 쿠폰 사용 시도 시 403 Forbidden 응답 + 기술 구현 요구사항 + 쿠폰 타입: FIXED_AMOUNT (정액), PERCENTAGE (배율) + 쿠폰 상태: UNUSED (미사용), USED (사용됨) + 트랜잭션: 쿠폰 사용 상태 변경과 주문 생성의 원자성 + 동시성 제어: 쿠폰의 일회성 보장을 위한 낙관적/비관적 잠금 처리 + 검증: 주문 생성 시 쿠폰 유효성 검증 (존재 여부, 사용 가능 여부, 보유자 확인) \ No newline at end of file diff --git a/docs/week4/02-sequence-diagrams.md b/docs/week4/02-sequence-diagrams.md new file mode 100644 index 000000000..8d8373a2b --- /dev/null +++ b/docs/week4/02-sequence-diagrams.md @@ -0,0 +1,723 @@ +# 🔄 02. 시퀀스 다이어그램 + +## 1. 상품 목록 조회 + +```mermaid +sequenceDiagram + participant User + participant ProductController + participant ProductService + participant BrandService + participant ProductRepository + + User->>ProductController: GET /api/v1/products?brandId=1&sort=latest&page=0&size=20 + ProductController->>ProductService: getProducts(brandId=1, sort=latest, pageable) + + alt 브랜드 필터링이 있는 경우 + ProductService->>BrandService: validateBrandExists(brandId=1) + alt 브랜드가 존재하지 않는 경우 + BrandService-->>ProductService: BrandNotFoundException + ProductService-->>ProductController: BrandNotFoundException + ProductController-->>User: 404 Not Found + else 브랜드가 존재하는 경우 + BrandService-->>ProductService: Brand validated + ProductService->>ProductRepository: findByBrandIdWithSort(brandId, sort, pageable) + end + else 전체 상품 조회 + ProductService->>ProductRepository: findAllWithSort(sort, pageable) + end + + ProductRepository-->>ProductService: Page + + alt 상품이 존재하는 경우 + ProductService-->>ProductController: ProductListResponse(totalElements=25, content=[...]) + ProductController-->>User: 200 OK + else 상품이 없는 경우 + ProductService-->>ProductController: ProductListResponse(totalElements=0, content=[]) + ProductController-->>User: 200 OK + end +``` + +## 2. 상품 상세 조회 + +```mermaid +sequenceDiagram + participant User + participant ProductController + participant ProductService + participant ProductRepository + + User->>ProductController: GET /api/v1/products/1
Header: X-USER-ID=123 (optional) + ProductController->>ProductService: getProductDetail(productId=1, userId=123) + + ProductService->>ProductRepository: findProductDetail(productId=1, userId=123) + + alt 상품이 존재하는 경우 + ProductRepository-->>ProductService: ProductDetailInfo(product, brand, totalLikes, isLiked) + ProductService-->>ProductController: ProductDetailResponse + ProductController-->>User: 200 OK + else 상품이 존재하지 않는 경우 + ProductRepository-->>ProductService: Optional.empty() + ProductService-->>ProductController: ProductNotFoundException + ProductController-->>User: 404 Not Found + end +``` + +## 3. 브랜드 목록 조회 + +```mermaid +sequenceDiagram + participant User + participant BrandController + participant BrandService + participant BrandRepository + + User->>BrandController: GET /api/v1/brands?page=0&size=20 + BrandController->>BrandService: getBrands(pageable) + BrandService->>BrandRepository: findAll(pageable) + BrandRepository-->>BrandService: Page + + alt 브랜드가 존재하는 경우 + BrandService-->>BrandController: BrandListResponse(totalElements=15, content=[...]) + BrandController-->>User: 200 OK + else 브랜드가 없는 경우 + BrandService-->>BrandController: BrandListResponse(totalElements=0, content=[]) + BrandController-->>User: 200 OK + end +``` + +## 4. 브랜드 상세 조회 + +```mermaid +sequenceDiagram + participant User + participant BrandController + participant BrandService + participant BrandRepository + + User->>BrandController: GET /api/v1/brands/1 + BrandController->>BrandService: getBrandById(brandId=1) + BrandService->>BrandRepository: findById(brandId=1) + + alt 브랜드가 존재하는 경우 + BrandRepository-->>BrandService: BrandEntity + BrandService-->>BrandController: BrandDetailResponse + BrandController-->>User: 200 OK + else 브랜드가 존재하지 않는 경우 + BrandRepository-->>BrandService: Optional.empty() + BrandService-->>BrandController: BrandNotFoundException + BrandController-->>User: 404 Not Found + end +``` + +## 5. 좋아요 등록 + +```mermaid +sequenceDiagram + participant User + participant LikeController + participant LikeFacade + participant UserService + participant ProductService + participant LikeService + participant LikeRepository + participant ProductRepository + + User->>LikeController: POST /api/v1/like/products/1
Header: X-USER-ID=testuser + LikeController->>LikeFacade: upsertLike(username="testuser", productId=1) + + LikeFacade->>UserService: getUserByUsername("testuser") + alt 사용자가 존재하지 않는 경우 + UserService-->>LikeFacade: CoreException(NOT_FOUND_USER) + LikeFacade-->>LikeController: CoreException + LikeController-->>User: 404 Not Found + else 사용자가 존재하는 경우 + UserService-->>LikeFacade: UserEntity + + LikeFacade->>ProductService: getProductDetail(productId=1) + alt 상품이 존재하지 않는 경우 + ProductService-->>LikeFacade: CoreException(NOT_FOUND_PRODUCT) + LikeFacade-->>LikeController: CoreException + LikeController-->>User: 404 Not Found + else 상품이 존재하는 경우 + ProductService-->>LikeFacade: ProductEntity + + LikeFacade->>LikeService: upsertLike(user, product) + LikeService->>LikeRepository: findByUserIdAndProductId(userId, productId) + + alt 좋아요가 없는 경우 + LikeRepository-->>LikeService: Optional.empty() + LikeService->>ProductRepository: save(product) [좋아요 카운트 +1] + LikeService->>LikeRepository: save(newLike) + LikeRepository-->>LikeService: LikeEntity (신규) + else 삭제된 좋아요가 있는 경우 + LikeRepository-->>LikeService: LikeEntity (deleted) + Note over LikeService: like.restore() + LikeService->>ProductRepository: save(product) [좋아요 카운트 +1] + LikeRepository-->>LikeService: LikeEntity (복원) + else 활성 좋아요가 있는 경우 + LikeRepository-->>LikeService: LikeEntity (active) + Note over LikeService: 카운트 변경 없음 (중복 방지) + LikeRepository-->>LikeService: LikeEntity (기존) + end + + LikeService-->>LikeFacade: LikeEntity + LikeFacade-->>LikeController: LikeInfo + LikeController-->>User: 200 OK + end + end +``` + +## 6. 좋아요 취소 + +```mermaid +sequenceDiagram + participant User + participant LikeController + participant LikeFacade + participant UserService + participant ProductService + participant LikeService + participant LikeRepository + participant ProductRepository + + User->>LikeController: DELETE /api/v1/like/products/1
Header: X-USER-ID=testuser + LikeController->>LikeFacade: unlikeProduct(username="testuser", productId=1) + + LikeFacade->>UserService: getUserByUsername("testuser") + alt 사용자가 존재하지 않는 경우 + UserService-->>LikeFacade: CoreException(NOT_FOUND_USER) + LikeFacade-->>LikeController: CoreException + LikeController-->>User: 404 Not Found + else 사용자가 존재하는 경우 + UserService-->>LikeFacade: UserEntity + + LikeFacade->>ProductService: getProductDetail(productId=1) + alt 상품이 존재하지 않는 경우 + ProductService-->>LikeFacade: CoreException(NOT_FOUND_PRODUCT) + LikeFacade-->>LikeController: CoreException + LikeController-->>User: 404 Not Found + else 상품이 존재하는 경우 + ProductService-->>LikeFacade: ProductEntity + + LikeFacade->>LikeService: unlikeProduct(user, product) + LikeService->>LikeRepository: findByUserIdAndProductId(userId, productId) + + alt 좋아요가 없는 경우 + LikeRepository-->>LikeService: Optional.empty() + Note over LikeService: 아무 작업 없음 (멱등성 보장) + else 이미 삭제된 좋아요인 경우 + LikeRepository-->>LikeService: LikeEntity (deleted) + Note over LikeService: 아무 작업 없음 (멱등성 보장) + else 활성 좋아요인 경우 + LikeRepository-->>LikeService: LikeEntity (active) + Note over LikeService: like.delete() (소프트 삭제) + LikeService->>ProductRepository: save(product) [좋아요 카운트 -1] + end + + LikeService-->>LikeFacade: void + LikeFacade-->>LikeController: void + LikeController-->>User: 200 OK + end + end +``` + +## 7. 포인트 충전 + +```mermaid +sequenceDiagram + participant User + participant PointController + participant PointFacade + participant PointService + participant UserService + participant UserRepository + participant PointHistoryRepository + + User->>PointController: POST /api/v1/points/charge
Header: X-USER-ID=testuser
Body: {"amount": 10000} + PointController->>PointFacade: chargePoint(username="testuser", request) + + PointFacade->>PointService: charge(username, amount) + PointService->>UserRepository: findByUsername("testuser") + + alt 사용자가 존재하지 않는 경우 + UserRepository-->>PointService: Optional.empty() + PointService-->>PointFacade: CoreException(NOT_FOUND_USER) + PointFacade-->>PointController: CoreException + PointController-->>User: 404 Not Found + else 사용자가 존재하는 경우 + UserRepository-->>PointService: UserEntity + + Note over PointService: user.chargePoint(amount)
[포인트 잔액 증가] + + PointService->>PointHistoryRepository: save(chargeHistory) + Note over PointService: PointHistoryEntity.createChargeHistory()
(userId, amount, balanceAfter) + PointHistoryRepository-->>PointService: PointHistoryEntity + + PointService->>UserRepository: save(user) + UserRepository-->>PointService: UserEntity + + PointService-->>PointFacade: 충전 후 잔액 + PointFacade-->>PointController: PointChargeResponse + PointController-->>User: 200 OK + end +``` + +## 8. 포인트 조회 + +```mermaid +sequenceDiagram + participant User + participant PointController + participant PointFacade + participant PointService + participant UserService + participant UserRepository + participant PointHistoryRepository + + User->>PointController: GET /api/v1/points
Header: X-USER-ID=testuser + PointController->>PointFacade: getPointInfo(username="testuser") + + PointFacade->>UserService: getUserByUsername("testuser") + alt 사용자가 존재하지 않는 경우 + UserService-->>PointFacade: CoreException(NOT_FOUND_USER) + PointFacade-->>PointController: CoreException + PointController-->>User: 404 Not Found + else 사용자가 존재하는 경우 + UserService-->>PointFacade: UserEntity + + PointFacade->>PointService: getPointHistories(username) + PointService->>UserRepository: findByUsername("testuser") + UserRepository-->>PointService: UserEntity + + PointService->>PointHistoryRepository: findByUserOrderByCreatedAtDesc(user) + PointHistoryRepository-->>PointService: List + PointService-->>PointFacade: List + + Note over PointFacade: PointInfo 생성
(잔액, 이력 목록) + PointFacade-->>PointController: PointInfo + PointController-->>User: 200 OK + end +``` + +## 10. 주문 요청 + +```mermaid +sequenceDiagram + participant User + participant OrderController + participant OrderFacade + participant UserService + participant ProductService + participant PointService + participant OrderService + participant OrderRepository + participant ProductRepository + + User->>OrderController: POST /api/v1/orders
Header: X-USER-ID=testuser
Body: {"items":[{"productId":1,"quantity":2}]} + OrderController->>OrderFacade: createOrder(command) + + OrderFacade->>UserService: getUserByUsername("testuser") + alt 사용자가 존재하지 않는 경우 + UserService-->>OrderFacade: CoreException(NOT_FOUND_USER) + OrderFacade-->>OrderController: CoreException + OrderController-->>User: 404 Not Found + else 사용자가 존재하는 경우 + UserService-->>OrderFacade: UserEntity + + Note over OrderFacade: 주문 항목을 productId로 정렬 (데드락 방지) + + loop 각 주문 상품 검증 (정렬된 순서) + OrderFacade->>ProductService: getProductDetailLock(productId) [비관적 락] + alt 상품이 존재하지 않는 경우 + ProductService-->>OrderFacade: CoreException(NOT_FOUND_PRODUCT) + OrderFacade-->>OrderController: CoreException + OrderController-->>User: 404 Not Found + else 재고가 부족한 경우 + ProductService-->>OrderFacade: ProductEntity + Note over OrderFacade: product.canOrder(quantity) = false + OrderFacade-->>OrderController: IllegalArgumentException + OrderController-->>User: 400 Bad Request + else 재고 충분 + ProductService-->>OrderFacade: ProductEntity (locked) + Note over OrderFacade: 주문 가능 상품 목록에 추가
총 주문 금액 계산 + end + end + + OrderFacade->>PointService: use(user, totalAmount) + alt 포인트가 부족한 경우 + PointService-->>OrderFacade: CoreException(INSUFFICIENT_POINTS) + Note over OrderFacade: 트랜잭션 롤백 (재고 락 자동 해제) + OrderFacade-->>OrderController: CoreException + OrderController-->>User: 400 Bad Request + else 포인트 차감 성공 + PointService-->>OrderFacade: 차감 후 잔액 + + OrderFacade->>OrderService: createOrder(request) + OrderService->>OrderRepository: save(order) + OrderRepository-->>OrderService: OrderEntity + OrderService-->>OrderFacade: OrderEntity + + loop 각 주문 항목 생성 및 재고 차감 + OrderFacade->>ProductService: deductStock(product, quantity) + ProductService->>ProductRepository: save(product) [재고 차감] + ProductRepository-->>ProductService: ProductEntity + ProductService-->>OrderFacade: ProductEntity + + OrderFacade->>OrderService: createOrderItem(request) + OrderService->>OrderRepository: save(orderItem) + OrderRepository-->>OrderService: OrderItemEntity + OrderService-->>OrderFacade: OrderItemEntity + end + + OrderFacade-->>OrderController: OrderInfo + OrderController-->>User: 201 Created + end + end +``` + +### 🔒 주문 처리 원자성 보장 + +#### **트랜잭션 전략** +- **@Transactional 기반 원자성**: OrderFacade의 createOrder 메서드 전체가 하나의 트랜잭션 + - 모든 작업이 성공하면 커밋 + - 중간에 예외 발생 시 자동 롤백 (재고 락 해제, 포인트 복구, 주문 취소) + +#### **데드락 방지 전략** +- **정렬된 락 획득**: 주문 항목을 productId 기준으로 정렬하여 처리 + - 스레드 A: [상품1, 상품2] 순서로 락 획득 + - 스레드 B: [상품1, 상품2] 순서로 락 획득 (동일 순서) + - 결과: 원형 대기(circular wait) 방지 + +#### **동시성 제어** +- **비관적 락(Pessimistic Lock)**: getProductDetailLock()에서 SELECT ... FOR UPDATE 사용 + - 재고 조회 시점에 행 레벨 락 획득 + - 트랜잭션 종료 시까지 다른 트랜잭션의 접근 차단 + +## 11. 사용자의 주문 목록 조회 + +```mermaid +sequenceDiagram + participant User + participant OrderController + participant OrderFacade + participant OrderService + participant OrderRepository + + User->>OrderController: GET /api/v1/orders?page=0&size=20
Header: X-USER-ID=testuser + OrderController->>OrderFacade: getOrderSummariesByUserId(userId, pageable) + + OrderFacade->>OrderService: getOrdersByUserId(userId, pageable) + OrderService->>OrderRepository: findByUserId(userId, pageable) + + alt 주문 내역이 존재하는 경우 + OrderRepository-->>OrderService: Page + OrderService-->>OrderFacade: Page + + loop 각 주문에 대해 + OrderFacade->>OrderService: countOrderItems(orderId) + OrderService->>OrderRepository: countByOrderId(orderId) + OrderRepository-->>OrderService: itemCount + OrderService-->>OrderFacade: itemCount + Note over OrderFacade: OrderSummary.from(order, itemCount) + end + + OrderFacade-->>OrderController: Page + OrderController-->>User: 200 OK + else 주문 내역이 없는 경우 + OrderRepository-->>OrderService: Page.empty() + OrderService-->>OrderFacade: Page.empty() + OrderFacade-->>OrderController: Page.empty() + OrderController-->>User: 200 OK + end +``` + +## 12. 주문 상세 조회 + +```mermaid +sequenceDiagram + participant User + participant OrderController + participant OrderFacade + participant OrderService + participant OrderRepository + + User->>OrderController: GET /api/v1/orders/1
Header: X-USER-ID=testuser + OrderController->>OrderFacade: getOrderById(orderId=1) + + OrderFacade->>OrderService: getOrderById(orderId=1) + OrderService->>OrderRepository: findById(orderId=1) + + alt 주문이 존재하지 않는 경우 + OrderRepository-->>OrderService: Optional.empty() + OrderService-->>OrderFacade: CoreException(NOT_FOUND) + OrderFacade-->>OrderController: CoreException + OrderController-->>User: 404 Not Found + else 주문이 존재하는 경우 + OrderRepository-->>OrderService: OrderEntity + OrderService-->>OrderFacade: OrderEntity + + OrderFacade->>OrderService: getOrderItemsByOrderId(orderId=1) + OrderService->>OrderRepository: findByOrderId(orderId=1) + OrderRepository-->>OrderService: List + OrderService-->>OrderFacade: List + + Note over OrderFacade: OrderInfo.from(order, orderItems) + OrderFacade-->>OrderController: OrderInfo + OrderController-->>User: 200 OK + end +``` + +## 13. 주문 취소 + +```mermaid +sequenceDiagram + participant User + participant OrderController + participant OrderFacade + participant OrderService + participant ProductService + participant PointService + participant OrderRepository + participant ProductRepository + + User->>OrderController: DELETE /api/v1/orders/1
Header: X-USER-ID=testuser + OrderController->>OrderFacade: cancelOrder(orderId=1, username="testuser") + + OrderFacade->>OrderService: getOrderById(orderId=1) + OrderService->>OrderRepository: findById(orderId=1) + + alt 주문이 존재하지 않는 경우 + OrderRepository-->>OrderService: Optional.empty() + OrderService-->>OrderFacade: CoreException(NOT_FOUND) + OrderFacade-->>OrderController: CoreException + OrderController-->>User: 404 Not Found + else 주문이 존재하는 경우 + OrderRepository-->>OrderService: OrderEntity + OrderService-->>OrderFacade: OrderEntity + + Note over OrderFacade: order.cancelOrder() [상태 변경] + + OrderFacade->>OrderService: getOrderItemsByOrderId(orderId=1) + OrderService->>OrderRepository: findByOrderId(orderId=1) + OrderRepository-->>OrderService: List + OrderService-->>OrderFacade: List + + Note over OrderFacade: 주문 항목을 productId로 정렬 (데드락 방지) + + loop 각 주문 항목에 대해 재고 원복 (정렬된 순서) + OrderFacade->>ProductService: restoreStock(productId, quantity) [비관적 락] + ProductService->>ProductRepository: findByIdWithLock(productId) + ProductRepository-->>ProductService: ProductEntity (locked) + Note over ProductService: product.restoreStock(quantity) + ProductService->>ProductRepository: save(product) [재고 복구] + ProductRepository-->>ProductService: ProductEntity + ProductService-->>OrderFacade: ProductEntity + end + + OrderFacade->>PointService: charge(username, totalAmount) + Note over PointService: user.chargePoint(amount)
포인트 이력 생성 (CHARGE) + PointService-->>OrderFacade: 환불 후 잔액 + + OrderFacade-->>OrderController: OrderInfo (취소됨) + OrderController-->>User: 200 OK + end +``` +--- + +## 14. 현재 사용 가능한 쿠폰 목록 조회 +```mermaid +sequenceDiagram +participant User +participant CouponController +participant CouponService +participant UserService +participant CouponRepository + + User->>CouponController: GET /api/v1/coupons
Header: X-USER-ID=testuser + CouponController->>CouponService: getCouponsByUser(username="testuser") + + CouponService->>UserService: getUserByUsername("testuser") + alt 사용자가 존재하지 않는 경우 + UserService-->>CouponService: CoreException(NOT_FOUND_USER) + CouponService-->>CouponController: CoreException + CouponController-->>User: 404 Not Found + else 사용자가 존재하는 경우 + UserService-->>CouponService: UserEntity + + CouponService->>CouponRepository: findByUserId(userId) + alt 쿠폰이 존재하는 경우 + CouponRepository-->>CouponService: List + Note over CouponService: CouponInfo 목록으로 변환
(타입, 할인액/율, 상태) + CouponService-->>CouponController: List + CouponController-->>User: 200 OK + else 쿠폰이 없는 경우 + CouponRepository-->>CouponService: Empty List + CouponService-->>CouponController: Empty List + CouponController-->>User: 200 OK + end + end +``` +--- +## 15. 쿠폰 상세 조회 +```mermaid +sequenceDiagram + participant User + participant CouponController + participant CouponService + participant CouponRepository + + User->>CouponController: GET /api/v1/coupons/1 + CouponController->>CouponService: getCouponById(couponId=1) + CouponService->>CouponRepository: findById(couponId=1) + + alt 쿠폰이 존재하는 경우 + CouponRepository-->>CouponService: CouponEntity + Note over CouponService: CouponInfo로 변환
(사용자, 타입, 할인액/율, 상태) + CouponService-->>CouponController: CouponInfo + CouponController-->>User: 200 OK + else 쿠폰이 존재하지 않는 경우 + CouponRepository-->>CouponService: Optional.empty() + CouponService-->>CouponController: CoreException(NOT_FOUND_COUPON) + CouponController-->>User: 404 Not Found + end + + +``` + +--- +## 16. 주문 생성(쿠폰 적용) +```mermaid +sequenceDiagram + participant User + participant OrderController + participant OrderFacade + participant UserService + participant ProductService + participant CouponService + participant PointService + participant OrderService + participant OrderRepository + participant ProductRepository + participant CouponRepository + + User->>OrderController: POST /api/v1/orders
Header: X-USER-ID=testuser
Body: {"items":[{"productId":1,"quantity":2,"couponId":10},...]} + OrderController->>OrderFacade: createOrder(command) + + OrderFacade->>UserService: getUserByUsername("testuser") + alt 사용자가 존재하지 않는 경우 + UserService-->>OrderFacade: CoreException(NOT_FOUND_USER) + OrderFacade-->>OrderController: CoreException + OrderController-->>User: 404 Not Found + else 사용자가 존재하는 경우 + UserService-->>OrderFacade: UserEntity + + Note over OrderFacade: 주문 항목을 productId로 정렬 (데드락 방지) + + loop 각 주문 상품 검증 (정렬된 순서) + OrderFacade->>ProductService: getProductDetailLock(productId) [비관적 락] + alt 상품이 존재하지 않는 경우 + ProductService-->>OrderFacade: CoreException(NOT_FOUND_PRODUCT) + OrderFacade-->>OrderController: CoreException + OrderController-->>User: 404 Not Found + else 재고가 부족한 경우 + ProductService-->>OrderFacade: ProductEntity + Note over OrderFacade: product.canOrder(quantity) = false + OrderFacade-->>OrderController: IllegalArgumentException + OrderController-->>User: 400 Bad Request + else 재고 충분 + ProductService-->>OrderFacade: ProductEntity (locked) + Note over OrderFacade: 상품 기본가 계산 + + alt 쿠폰이 지정된 경우 + OrderFacade->>CouponService: getCouponWithLock(couponId) [비관적 락] + alt 쿠폰이 존재하지 않는 경우 + CouponService-->>OrderFacade: CoreException(NOT_FOUND_COUPON) + OrderFacade-->>OrderController: CoreException + OrderController-->>User: 404 Not Found + else 쿠폰이 이미 사용된 경우 + CouponService-->>OrderFacade: CoreException(ALREADY_USED_COUPON) + OrderFacade-->>OrderController: CoreException + OrderController-->>User: 400 Bad Request + else 쿠폰 소유자가 다른 경우 + CouponService-->>OrderFacade: CoreException(FORBIDDEN_COUPON) + OrderFacade-->>OrderController: CoreException + OrderController-->>User: 403 Forbidden + else 쿠폰이 유효한 경우 + CouponService-->>OrderFacade: CouponEntity (locked) + Note over OrderFacade: 쿠폰 타입별 할인액 계산
- FIXED_AMOUNT: fixedAmount
- PERCENTAGE: productPrice * percentage / 100
상품 최종가 = productPrice - discountAmount + end + else 쿠폰이 지정되지 않은 경우 + Note over OrderFacade: 상품 기본가 = 최종가 + end + + Note over OrderFacade: 주문 가능 상품 목록에 추가
총 주문 금액 계산 (할인 적용) + end + end + + OrderFacade->>PointService: use(user, totalAmount) + alt 포인트가 부족한 경우 + PointService-->>OrderFacade: CoreException(INSUFFICIENT_POINTS) + Note over OrderFacade: 트랜잭션 롤백 (재고/쿠폰 락 자동 해제) + OrderFacade-->>OrderController: CoreException + OrderController-->>User: 400 Bad Request + else 포인트 차감 성공 + PointService-->>OrderFacade: 차감 후 잔액 + + OrderFacade->>OrderService: createOrder(request) + OrderService->>OrderRepository: save(order) + OrderRepository-->>OrderService: OrderEntity + OrderService-->>OrderFacade: OrderEntity + + loop 각 주문 항목 생성, 재고 차감, 쿠폰 상태 업데이트 (정렬된 순서) + OrderFacade->>ProductService: deductStock(product, quantity) + ProductService->>ProductRepository: save(product) [재고 차감] + ProductRepository-->>ProductService: ProductEntity + ProductService-->>OrderFacade: ProductEntity + + OrderFacade->>OrderService: createOrderItem(request) + OrderService->>OrderRepository: save(orderItem) + OrderRepository-->>OrderService: OrderItemEntity + OrderService-->>OrderFacade: OrderItemEntity + + alt 쿠폰이 적용된 경우 + OrderFacade->>CouponService: useCoupon(coupon) + Note over CouponService: coupon.use() [상태: UNUSED → USED] + CouponService->>CouponRepository: save(coupon) + CouponRepository-->>CouponService: CouponEntity (USED) + CouponService-->>OrderFacade: CouponEntity + + OrderFacade->>OrderService: createCouponUsageHistory(coupon, orderItem) + OrderService->>OrderRepository: save(couponUsageHistory) + OrderRepository-->>OrderService: CouponUsageHistoryEntity + OrderService-->>OrderFacade: CouponUsageHistoryEntity + else 쿠폰이 적용되지 않은 경우 + Note over OrderFacade: 쿠폰 처리 없음 + end + end + + OrderFacade-->>OrderController: OrderInfo (쿠폰 할인액 포함) + OrderController-->>User: 201 Created + end + end + + +``` + +--- + + + + + +### 🔒 주문 취소 원자성 보장 + +#### **트랜잭션 전략** +- **@Transactional 기반 원자성**: OrderFacade의 cancelOrder 메서드 전체가 하나의 트랜잭션 + - 주문 상태 변경, 재고 복구, 포인트 환불이 모두 성공하면 커밋 + - 중간에 예외 발생 시 자동 롤백 + +#### **데드락 방지 전략** +- **정렬된 락 획득**: 주문 항목을 productId 기준으로 정렬하여 재고 복구 + - 주문 생성 시와 동일한 순서로 락 획득 + - 원형 대기(circular wait) 방지 diff --git a/docs/week4/03-class-diagram.md b/docs/week4/03-class-diagram.md new file mode 100644 index 000000000..4da16f2b0 --- /dev/null +++ b/docs/week4/03-class-diagram.md @@ -0,0 +1,560 @@ +# 03. 클래스 다이어그램 + +## 도메인 모델 개요 + +이커머스 서비스의 핵심 도메인 객체들과 그들 간의 관계를 정의합니다. +DDD 기반 레이어드 아키텍처를 따르며, BaseEntity를 상속한 엔티티 설계를 적용합니다. + +--- + +## 주요 도메인 규칙 + +### 비즈니스 제약사항 + +| 도메인 | 핵심 규칙 | +|--------|-----------| +| **User** | 포인트는 음수 불가, 차감 시 잔액 검증 필수 | +| **Product** | 재고는 음수 불가, 예약 시 가용 수량 검증 필수 | +| **Order** | 재고 예약과 포인트 차감은 원자적 처리 | +| **Like** | 사용자당 상품별 1개만 가능, 멱등성 보장 | +| **Brand** | 브랜드명 중복 불가, 소프트 삭제 지원 | +| **Coupon** | 1회성 사용, 사용 후 재사용 불가, 동시성 제어 필수 | + +### 도메인 관계 요약 + +| 관계 | 설명 | 다중성 | 비고 | +|----------------------|-------------------|-----|------| +| Product -> Brand | 상품은 브랜드에 속함 | N:1 | 필수 관계 | +| Like -> User | 좋아요는 사용자가 생성 | N:1 | 필수 관계 | +| Like -> Product | 좋아요는 상품에 대한 것 | N:1 | 필수 관계 | +| Order -> User | 주문은 사용자가 생성 | N:1 | 필수 관계 | +| OrderItem -> Order | 주문 상세내역은 주문 정보 포함 | N:1 | 필수 관계 | +| OrderItem -> Product | 주문항목은 상품을 참조 | N:1 | 필수 관계 | +| PointHistory -> User | 포인트 이력은 사용자별 관리 | N:1 | 필수 관계 | +| Coupon -> User | 쿠폰은 사용자에게 발급됨 | N:1 | 필수 관계 | +| OrderItem -> Coupon | 주문항목에 쿠폰 적용 가능 | N:1 | 선택 관계 | +| CouponUsageHistory -> Coupon | 쿠폰 사용 이력 기록 | N:1 | 필수 관계 | +| CouponUsageHistory -> OrderItem | 어떤 주문에 사용되었는지 추적 | N:1 | 필수 관계 | + +--- + +## 전체 도메인 모델 + +```mermaid +classDiagram + class UserEntity { + -String username + -String email + -LocalDate birthdate + -Gender gender + -BigDecimal pointAmount + +createUserEntity() UserEntity + +chargePoint() void + +usePoint() void + +getPointAmount() BigDecimal + } + + class BrandEntity { + -String name + -String description + +createBrandEntity() BrandEntity + } + + class ProductEntity { + -Long brandId + -String name + -String description + -Price price + -Integer stockQuantity + -Long likeCount + +createEntity() ProductEntity + +deductStock() void + +restoreStock() void + +increaseLikeCount() void + +decreaseLikeCount() void + +hasStock() boolean + +canOrder() boolean + +isDiscounted() boolean + +getSellingPrice() BigDecimal + } + + class Price { + <> + -BigDecimal originPrice + -BigDecimal discountPrice + +of() Price + +getSellingPrice() BigDecimal + +getDiscountRate() BigDecimal + +isDiscounted() boolean + +applyDiscount() void + +removeDiscount() void + } + + class LikeEntity { + -Long userId + -Long productId + +createEntity() LikeEntity + } + + class OrderEntity { + -Long userId + -BigDecimal totalAmount + -OrderStatus status + +createOrder() OrderEntity + +confirmOrder() void + +cancelOrder() void + +isPending() boolean + +isConfirmed() boolean + +isCancelled() boolean + } + + class OrderItemEntity { + -Long orderId + -Long productId + -Integer quantity + -BigDecimal unitPrice + -BigDecimal totalPrice + +createOrderItem() OrderItemEntity + +calculateItemTotal() BigDecimal + } + + class PointHistoryEntity { + -UserEntity user + -BigDecimal amount + -PointTransactionType transactionType + -BigDecimal balanceAfter + +createChargeHistory() PointHistoryEntity + +createUseHistory() PointHistoryEntity + } + + class CouponEntity { + -Long userId + -CouponType couponType + -BigDecimal fixedAmount + -BigDecimal percentage + -CouponStatus status + +createCoupon() CouponEntity + +use() void + +isUsed() boolean + +canUse() boolean + +calculateDiscount() BigDecimal + } + + class CouponUsageHistoryEntity { + -Long couponId + -Long orderItemId + -BigDecimal discountAmount + +createHistory() CouponUsageHistoryEntity + } + + UserEntity "1" --> "*" LikeEntity : userId + UserEntity "1" --> "*" OrderEntity : userId + UserEntity "1" --> "*" PointHistoryEntity : ManyToOne + UserEntity "1" --> "*" CouponEntity : userId + ProductEntity "1" --> "*" LikeEntity : productId + BrandEntity "1" --> "*" ProductEntity : brandId + ProductEntity "1" *-- "1" Price : 임베디드 + OrderEntity "1" --> "1..*" OrderItemEntity : orderId + OrderItemEntity "*" --> "1" ProductEntity : productId + OrderItemEntity "*" --> "0..1" CouponEntity : couponId (선택) + CouponEntity "1" --> "*" CouponUsageHistoryEntity : couponId + OrderItemEntity "1" --> "*" CouponUsageHistoryEntity : orderItemId +``` + +--- + +## 도메인별 상세 다이어그램 + +### 1. 사용자 도메인 (User Domain) + +```mermaid +classDiagram + class UserEntity { + -String username + -String email + -LocalDate birthdate + -Gender gender + -BigDecimal pointAmount + +createUserEntity() UserEntity + +chargePoint() void + +usePoint() void + +getPointAmount() BigDecimal + } + + class PointHistoryEntity { + -UserEntity user + -BigDecimal amount + -PointTransactionType transactionType + -BigDecimal balanceAfter + +createChargeHistory() PointHistoryEntity + +createUseHistory() PointHistoryEntity + } + + UserEntity "1" --> "*" PointHistoryEntity : ManyToOne 관계 +``` + +### 2. 상품 도메인 (Product Domain) + +```mermaid +classDiagram + class BrandEntity { + -String name + -String description + +createBrandEntity() BrandEntity + } + + class ProductEntity { + -Long brandId + -String name + -String description + -Price price + -Integer stockQuantity + -Long likeCount + +createEntity() ProductEntity + +deductStock() void + +restoreStock() void + +increaseLikeCount() void + +decreaseLikeCount() void + +hasStock() boolean + +canOrder() boolean + +isDiscounted() boolean + +getSellingPrice() BigDecimal + } + + class Price { + <> + -BigDecimal originPrice + -BigDecimal discountPrice + +of() Price + +getSellingPrice() BigDecimal + +getDiscountRate() BigDecimal + +isDiscounted() boolean + +getDiscountAmount() BigDecimal + +applyDiscount() void + +removeDiscount() void + } + + BrandEntity "1" --> "*" ProductEntity : brandId 참조 + ProductEntity "1" *-- "1" Price : 임베디드 타입 +``` + +### 3. 좋아요 도메인 (Like Domain) + +```mermaid +classDiagram + class LikeEntity { + -Long userId + -Long productId + +createEntity() LikeEntity + } + + class UserEntity { + -String username + -String email + -LocalDate birthdate + -Gender gender + -BigDecimal pointAmount + +createUserEntity() UserEntity + +chargePoint() void + +usePoint() void + } + + class ProductEntity { + -Long brandId + -String name + -Price price + -Integer stockQuantity + -Long likeCount + +createEntity() ProductEntity + +increaseLikeCount() void + +decreaseLikeCount() void + } + + LikeEntity "*" --> "1" UserEntity : userId 참조 + LikeEntity "*" --> "1" ProductEntity : productId 참조 +``` + +### 4. 주문 도메인 (Order Domain) + +```mermaid +classDiagram + class OrderEntity { + -Long userId + -BigDecimal totalAmount + -OrderStatus status + +createOrder() OrderEntity + +confirmOrder() void + +cancelOrder() void + +isPending() boolean + +isConfirmed() boolean + +isCancelled() boolean + } + + class OrderItemEntity { + -Long orderId + -Long productId + -Long couponId + -Integer quantity + -BigDecimal unitPrice + -BigDecimal discountAmount + -BigDecimal totalPrice + +createOrderItem() OrderItemEntity + +createOrderItemWithCoupon() OrderItemEntity + +calculateItemTotal() BigDecimal + +hasCoupon() boolean + } + + class UserEntity { + -String username + -String email + -LocalDate birthdate + -Gender gender + -BigDecimal pointAmount + +createUserEntity() UserEntity + +chargePoint() void + +usePoint() void + } + + class ProductEntity { + -Long brandId + -String name + -Price price + -Integer stockQuantity + -Long likeCount + +createEntity() ProductEntity + +deductStock() void + +restoreStock() void + } + + OrderEntity "1" --> "*" OrderItemEntity : orderId 참조 + OrderEntity "*" --> "1" UserEntity : userId 참조 + OrderItemEntity "*" --> "1" ProductEntity : productId 참조 +``` + +### 5. 쿠폰 도메인 (Coupon Domain) + +```mermaid +classDiagram + class CouponEntity { + -Long userId + -CouponType couponType + -BigDecimal fixedAmount + -BigDecimal percentage + -CouponStatus status + +createCoupon() CouponEntity + +use() void + +isUsed() boolean + +canUse() boolean + +calculateDiscount() BigDecimal + +validateOwnership() void + } + + class CouponUsageHistoryEntity { + -Long couponId + -Long orderItemId + -BigDecimal discountAmount + +createHistory() CouponUsageHistoryEntity + } + + class UserEntity { + -String username + -String email + -LocalDate birthdate + -Gender gender + -BigDecimal pointAmount + +createUserEntity() UserEntity + } + + class OrderItemEntity { + -Long orderId + -Long productId + -Long couponId + -Integer quantity + -BigDecimal unitPrice + -BigDecimal discountAmount + -BigDecimal totalPrice + +createOrderItemWithCoupon() OrderItemEntity + } + + CouponEntity "*" --> "1" UserEntity : userId 참조 + CouponEntity "1" --> "*" CouponUsageHistoryEntity : couponId 참조 + OrderItemEntity "0..1" --> "1" CouponEntity : couponId 참조 (선택) + CouponUsageHistoryEntity "*" --> "1" OrderItemEntity : orderItemId 참조 +``` + +**쿠폰 타입 (CouponType)** +- `FIXED_AMOUNT`: 정액 할인 (예: 5,000원 할인) +- `PERCENTAGE`: 배율 할인 (예: 10% 할인) + +**쿠폰 상태 (CouponStatus)** +- `UNUSED`: 미사용 (사용 가능) +- `USED`: 사용됨 (재사용 불가) + +--- + +## 도메인 엔티티 핵심 책임 + +### 1. UserEntity - 사용자 도메인의 핵심 책임 + +| 메서드 | 책임 | 비즈니스 규칙 | 구현 세부사항 | +|--------|----------|--------------------------|---------------| +| `createUserEntity()` | 사용자 생성 | 사용자명/이메일 유효성 검증, 생년월일 검증 | 정적 팩토리 메서드, 불변 객체 생성 | +| `chargePoint()` | 포인트 충전 | 충전 금액 양수 검증 | BigDecimal 정밀 계산, 원자적 연산 | +| `usePoint()` | 포인트 사용 | 잔액 부족 시 예외 발생, 음수 방지 | 원자적 연산, 상태 변경 후 검증 | +| `getPointAmount()` | 포인트 조회 | 소수점 2자리로 반올림 | RoundingMode.HALF_UP 적용 | + +### 2. ProductEntity - 상품 도메인의 핵심 책임 + +| 메서드 | 책임 | 비즈니스 규칙 | 구현 세부사항 | +|--------|------|---------------|---------------| +| `createEntity()` | 상품 생성 | 가격 유효성, 재고 음수 방지, 브랜드 ID 필수 | 정적 팩토리 메서드, Price 임베디드 타입 | +| `hasStock()` | 재고 확인 | 재고 수량 > 0 | 단순 재고 존재 여부 확인 | +| `canOrder()` | 주문 가능 여부 | 재고 >= 수량, 수량 > 0 | 복합 조건 검증 | +| `deductStock()` | 재고 차감 | 재고 부족 시 INSUFFICIENT_STOCK 예외 | 원자적 재고 차감 연산 | +| `restoreStock()` | 재고 복구 | 취소 시 재고 복원 | 재고 복구 연산 | +| `increaseLikeCount()` | 좋아요 수 증가 | 음수 방지, 동시성 제어 | 원자적 증가 연산 | +| `decreaseLikeCount()` | 좋아요 수 감소 | 0 이하로 감소 방지 | 최소값 0 보장 | +| `isDiscounted()` | 할인 여부 | Price 객체의 할인가 존재 여부 | Price.isDiscounted() 위임 | +| `getSellingPrice()` | 판매가 조회 | 할인가 우선, 없으면 정가 | Price.getSellingPrice() 위임 | + +### 3. OrderEntity - 주문 도메인의 핵심 책임 + +| 메서드 | 책임 | 비즈니스 규칙 | 구현 세부사항 | +|--------|------|---------------|---------------| +| `createOrder()` | 주문 생성 | 사용자 ID 필수, 총액 > 0 | 정적 팩토리 메서드, 초기 상태 PENDING | +| `confirmOrder()` | 주문 확정 | PENDING → CONFIRMED 상태 전이 | INVALID_ORDER_STATUS 예외 처리 | +| `cancelOrder()` | 주문 취소 | PENDING/CONFIRMED → CANCELLED | 취소 가능 상태 검증 | +| `isPending()` | 대기 상태 확인 | 주문 상태가 PENDING인지 확인 | 상태 검증 메서드 | +| `isConfirmed()` | 확정 상태 확인 | 주문 상태가 CONFIRMED인지 확인 | 상태 검증 메서드 | +| `isCancelled()` | 취소 상태 확인 | 주문 상태가 CANCELLED인지 확인 | 상태 검증 메서드 | + +### 4. OrderItemEntity - 주문 항목 도메인의 핵심 책임 + +| 메서드 | 책임 | 비즈니스 규칙 | 구현 세부사항 | +|--------|------|---------------|---------------| +| `createOrderItem()` | 주문 항목 생성 | 주문 ID, 상품 ID 필수, 수량 >= 1, 단가 > 0 | 정적 팩토리 메서드, 스냅샷 가격 | +| `calculateItemTotal()` | 항목 총액 계산 | 단가 × 수량 = 총액 | BigDecimal 정밀 계산 | + +### 5. LikeEntity - 좋아요 도메인의 핵심 책임 + +| 메서드 | 책임 | 비즈니스 규칙 | 구현 세부사항 | +|-----------------------|-------------|-------------------------|-------------------| +| `createEntity()` | 좋아요 생성 | 사용자 ID, 상품 ID 필수, 복합 유니크 제약 | 정적 팩토리 메서드, 복합키 검증 | + +### 6. BrandEntity - 브랜드 도메인의 핵심 책임 + +| 메서드 | 책임 | 비즈니스 규칙 | 구현 세부사항 | +|--------|------|---------------|---------------| +| `createBrandEntity()` | 브랜드 생성 | 브랜드명 필수, 설명 선택사항, 길이 <= 100 | 정적 팩토리 메서드, 유니크 제약 | + +### 7. PointHistoryEntity - 포인트 이력 도메인의 핵심 책임 + +| 메서드 | 책임 | 비즈니스 규칙 | 구현 세부사항 | +|--------|------|---------------|---------------| +| `createChargeHistory()` | 충전 이력 생성 | 충전 금액 양수, 잔액 일치성 | 정적 팩토리 메서드, CHARGE 타입 | +| `createUseHistory()` | 사용 이력 생성 | 사용 금액 양수, 잔액 일치성 | 정적 팩토리 메서드, USE 타입 | + +### 8. CouponEntity - 쿠폰 도메인의 핵심 책임 + +| 메서드 | 책임 | 비즈니스 규칙 | 구현 세부사항 | +|--------|------|---------------|---------------| +| `createCoupon()` | 쿠폰 생성 | 사용자 ID 필수, 쿠폰 타입별 금액/비율 검증 | 정적 팩토리 메서드, 초기 상태 UNUSED | +| `use()` | 쿠폰 사용 | UNUSED → USED 상태 전이, 이미 사용된 경우 예외 | 1회성 보장, 동시성 제어 필요 | +| `isUsed()` | 사용 여부 확인 | 쿠폰 상태가 USED인지 확인 | 상태 검증 메서드 | +| `canUse()` | 사용 가능 여부 | 쿠폰 상태가 UNUSED인지 확인 | 사용 전 검증 메서드 | +| `calculateDiscount()` | 할인액 계산 | FIXED_AMOUNT: fixedAmount 반환
PERCENTAGE: 상품가 × percentage / 100 | 쿠폰 타입별 할인 계산 로직 | +| `validateOwnership()` | 소유권 검증 | 쿠폰 소유자와 사용자 일치 확인 | 권한 검증, 불일치 시 예외 | + +### 9. CouponUsageHistoryEntity - 쿠폰 사용 이력 도메인의 핵심 책임 + +| 메서드 | 책임 | 비즈니스 규칙 | 구현 세부사항 | +|--------|------|---------------|---------------| +| `createHistory()` | 사용 이력 생성 | 쿠폰 ID, 주문항목 ID, 할인액 필수 | 정적 팩토리 메서드, 추적 가능성 보장 | + +### 10. Price - 가격 임베디드 타입의 핵심 책임 + +| 메서드 | 책임 | 비즈니스 규칙 | 구현 세부사항 | +|--------|------|---------------|---------------| +| `of()` | Price 객체 생성 | 정가 필수, 할인가 선택사항 | 정적 팩토리 메서드, 불변 객체 | +| `ofOriginOnly()` | 정가만으로 생성 | 정가 > 0 | 할인가 없는 Price 생성 | +| `getSellingPrice()` | 실제 판매가 반환 | 할인가 우선, 없으면 정가 | 할인가 존재 여부에 따른 분기 | +| `getDiscountRate()` | 할인율 계산 | (정가-할인가)/정가 * 100 | BigDecimal 정밀 계산, 소수점 2자리 | +| `isDiscounted()` | 할인 여부 확인 | 할인가가 null이 아니고 정가보다 작음 | 할인가 유효성 검증 | +| `getDiscountAmount()` | 할인 금액 계산 | 정가 - 할인가 | 할인 절약 금액 반환 | +| `applyDiscount()` | 할인 적용 | 할인가 >= 0, 할인가 <= 정가 | 할인가 유효성 검증 후 적용 | +| `removeDiscount()` | 할인 제거 | 할인가를 null로 설정 | 할인 정보 초기화 | + +--- + +## 쿠폰 도메인 상세 설계 + +### 쿠폰 타입별 할인 계산 로직 + +#### 1. 정액 쿠폰 (FIXED_AMOUNT) +``` +할인액 = fixedAmount +최종 가격 = 상품 가격 - fixedAmount +``` + +**예시**: +- 상품 가격: 50,000원 +- 쿠폰: 5,000원 정액 할인 +- 할인액: 5,000원 +- 최종 가격: 45,000원 + +#### 2. 배율 쿠폰 (PERCENTAGE) +``` +할인액 = 상품 가격 × (percentage / 100) +최종 가격 = 상품 가격 - 할인액 +``` + +**예시**: +- 상품 가격: 50,000원 +- 쿠폰: 10% 할인 +- 할인액: 5,000원 +- 최종 가격: 45,000원 + +### 쿠폰 사용 제약사항 + +| 제약사항 | 설명 | 검증 시점 | 예외 타입 | +|---------|------|----------|----------| +| **1회성** | 쿠폰은 한 번만 사용 가능 | 주문 생성 시 | `ALREADY_USED_COUPON` | +| **소유권** | 쿠폰 소유자만 사용 가능 | 주문 생성 시 | `FORBIDDEN_COUPON` | +| **존재 여부** | 유효한 쿠폰 ID 필수 | 주문 생성 시 | `NOT_FOUND_COUPON` | +| **동시성** | 동시 사용 시도 시 1건만 성공 | 주문 생성 시 | 비관적 락으로 제어 | + +### 쿠폰 적용 범위 (현재 설계) + +**주문 단위 쿠폰 적용**: +- 1개 주문에 최대 1개의 쿠폰 적용 +- 주문 전체 금액에 대해 할인 적용 +- 여러 쿠폰 조합 사용은 추후 검토 예정 + +**향후 확장 가능성**: +- 상품별 쿠폰 적용 +- 쿠폰 조합 사용 +- 최소 주문 금액 조건 +- 쿠폰 유효기간 관리 + +### 쿠폰 동시성 제어 전략 + +#### 비관적 락 (Pessimistic Lock) 사용 +```java +// CouponRepository +@Lock(LockModeType.PESSIMISTIC_WRITE) +@Query("SELECT c FROM CouponEntity c WHERE c.id = :couponId") +Optional findByIdWithLock(@Param("couponId") Long couponId); +``` + +**동작 방식**: +1. 쿠폰 조회 시 행 레벨 락 획득 (SELECT ... FOR UPDATE) +2. 트랜잭션 종료 시까지 다른 트랜잭션의 접근 차단 +3. 쿠폰 상태 변경 (UNUSED → USED) +4. 트랜잭션 커밋 시 락 해제 + +**장점**: +- 쿠폰의 1회성을 확실하게 보장 +- 동시 사용 시도 시 순차적으로 처리 +- 데이터 정합성 보장 + +--- \ No newline at end of file diff --git a/docs/week4/04-erd.md b/docs/week4/04-erd.md new file mode 100644 index 000000000..c2533a472 --- /dev/null +++ b/docs/week4/04-erd.md @@ -0,0 +1,512 @@ +# 04. ERD + +## 개요 + +이커머스 서비스의 데이터베이스 ERD 입니다. + +--- + +## 전체 ERD + +```mermaid +erDiagram + users ||--o{ likes: "" + products ||--o{ likes: "" + orders ||--|{ order_items: "" + users ||--o{ orders: "" + users ||--o{ point_histories: "" + brands ||--o{ products: "" + products ||--o{ order_items: "" + users ||--o{ coupons: "" + coupons ||--o{ coupon_usage_histories: "" + order_items ||--o{ coupon_usage_histories: "" + order_items }o--o| coupons: "" + + users { + bigint id PK "기본키" + varchar(10) username UK "사용자명(영문/숫자 10자)" + varchar(254) email "이메일" + date birthdate "생년월일" + varchar(10) gender "성별 MALE/FEMALE 열거형" + decimal point_amount "보유 포인트 금액" + timestamp created_at "생성일시" + timestamp updated_at "수정일시" + timestamp deleted_at "삭제일시" + } + + brands { + bigint id PK "기본키" + varchar(100) name UK "브랜드명" + text description "브랜드 설명" + timestamp created_at "생성일시" + timestamp updated_at "수정일시" + timestamp deleted_at "삭제일시" + } + + products { + bigint id PK "기본키" + bigint brand_id "브랜드 ID" + varchar(200) name "상품명" + text description "상품 설명" + decimal origin_price "정가(Price 임베디드)" + decimal discount_price "할인가(Price 임베디드)" + int stock_quantity "재고 수량" + bigint like_count "좋아요 수(비정규화)" + timestamp created_at "생성일시" + timestamp updated_at "수정일시" + timestamp deleted_at "삭제일시" + } + + likes { + bigint id PK "기본키" + bigint userId "사용자 ID" + bigint productId "상품 ID" + timestamp created_at "생성일시" + timestamp updated_at "수정일시" + timestamp deleted_at "삭제일시" + } + + orders { + bigint id PK "기본키" + bigint user_id "사용자 ID" + decimal total_amount "총 주문 금액" + varchar(20) status "주문 상태(PENDING/CONFIRMED/CANCELLED)" + timestamp created_at "생성일시" + timestamp updated_at "수정일시" + timestamp deleted_at "삭제일시" + } + + order_items { + bigint id PK "기본키" + bigint order_id "주문 ID" + bigint product_id "상품 ID" + bigint coupon_id "쿠폰 ID(선택사항)" + int quantity "주문 수량" + decimal unit_price "단가(주문 시점 가격)" + decimal discount_amount "쿠폰 할인 금액" + decimal total_price "총 가격(단가 * 수량 - 할인액)" + timestamp created_at "생성일시" + timestamp updated_at "수정일시" + timestamp deleted_at "삭제일시" + } + + point_histories { + bigint id PK "기본키" + bigint user_id "사용자 ID" + decimal amount "포인트 변동 금액" + varchar(20) transaction_type "거래 유형(CHARGE/USE)" + decimal balance_after "거래 후 잔액" + timestamp created_at "생성일시" + timestamp updated_at "수정일시" + timestamp deleted_at "삭제일시" + } + + coupons { + bigint id PK "기본키" + bigint user_id "사용자 ID" + varchar(20) coupon_type "쿠폰 타입(FIXED_AMOUNT/PERCENTAGE)" + decimal fixed_amount "정액 할인 금액(정액 쿠폰용)" + decimal percentage "할인 비율(배율 쿠폰용)" + varchar(20) status "쿠폰 상태(UNUSED/USED)" + timestamp created_at "생성일시" + timestamp updated_at "수정일시" + timestamp deleted_at "삭제일시" + } + + coupon_usage_histories { + bigint id PK "기본키" + bigint coupon_id "쿠폰 ID" + bigint order_item_id "주문 항목 ID" + decimal discount_amount "할인 적용 금액" + timestamp created_at "생성일시" + timestamp updated_at "수정일시" + timestamp deleted_at "삭제일시" + } +``` + +--- + +## 테이블별 상세 설명 + +### 1. users (사용자) + +**목적**: 이커머스 서비스를 이용하는 사용자 정보 관리 + +| 컬럼명 | 타입 | 제약조건 | 설명 | +|--------|------|----------|------| +| id | BIGINT | PK, AUTO_INCREMENT | 사용자 고유 식별자 | +| username | VARCHAR(10) | UNIQUE, NOT NULL | 사용자명 (영문/숫자 10자 이내) | +| email | VARCHAR(254) | NOT NULL | 이메일 주소 | +| birthdate | DATE | NOT NULL | 생년월일 | +| gender | VARCHAR(10) | NOT NULL | 성별 (MALE/FEMALE) | +| point_amount | DECIMAL(9,2) | NOT NULL, DEFAULT 0 | 포인트 잔액 (음수 불가) | +| created_at | TIMESTAMP | NOT NULL | 생성일시 | +| updated_at | TIMESTAMP | NOT NULL | 수정일시 | +| deleted_at | TIMESTAMP | NULL | 삭제일시| + +**인덱스**: +- `idx_user_username`: username (유니크 인덱스) + +**비즈니스 규칙**: +- 포인트는 항상 0 이상이어야 함 +- 포인트 차감 시 잔액 검증 필수 +- 사용자명은 중복 불가 + +--- + +### 2. brands (브랜드) + +**목적**: 상품을 그룹화하는 브랜드 정보 관리 + +| 컬럼명 | 타입 | 제약조건 | 설명 | +|--------|------|----------|------------| +| id | BIGINT | PK, AUTO_INCREMENT | 브랜드 고유 식별자 | +| name | VARCHAR(100) | UNIQUE, NOT NULL | 브랜드명 | +| description | TEXT | NULL | 브랜드 설명 | +| created_at | TIMESTAMP | NOT NULL | 생성일시 | +| updated_at | TIMESTAMP | NOT NULL | 수정일시 | +| deleted_at | TIMESTAMP | NULL | 삭제일시 | + +**인덱스**: +- `idx_brand_name`: name (유니크 인덱스) + +**비즈니스 규칙**: +- 브랜드명은 중복 불가 + +--- + +### 3. products (상품) + +**목적**: 판매 가능한 상품 정보 관리 + +| 컬럼명 | 타입 | 제약조건 | 설명 | +|--------|------|----------|---------------| +| id | BIGINT | PK, AUTO_INCREMENT | 상품 고유 식별자 | +| brand_id | BIGINT | NOT NULL | 브랜드 ID (brands.id) | +| name | VARCHAR(200) | NOT NULL | 상품명 | +| description | TEXT | NULL | 상품 설명 | +| origin_price | DECIMAL(10,2) | NOT NULL | 정가 (Price 임베디드 타입) | +| discount_price | DECIMAL(10,2) | NULL | 할인가 (Price 임베디드 타입, 선택사항) | +| stock_quantity | INT | NOT NULL, DEFAULT 0 | 재고 수량 (음수 불가) | +| like_count | BIGINT | NOT NULL, DEFAULT 0 | 좋아요 수 | +| created_at | TIMESTAMP | NOT NULL | 생성일시 | +| updated_at | TIMESTAMP | NOT NULL | 수정일시 | +| deleted_at | TIMESTAMP | NULL | 삭제일시| + +**인덱스**: +- `idx_productentity_brand_id`: brand_id (브랜드별 상품 조회) +- `idx_productentity_name`: name (상품명 검색) + +**임베디드 타입 (Price)**: +```java +@Embeddable +public class Price { + private BigDecimal originPrice; // 정가 + private BigDecimal discountPrice; // 할인가 (선택사항) + + // 실제 판매가 반환 (할인가가 있으면 할인가, 없으면 정가) + public BigDecimal getSellingPrice() +} +``` + +**비즈니스 규칙**: +- 재고는 항상 0 이상이어야 함 +- 재고 차감 시 가용 수량 검증 필수 +- 할인가는 선택사항이며, 설정 시 정가보다 작아야 함 +- Price는 임베디드 타입으로 정가와 할인가를 함께 관리 +- released_at 필드는 엔티티에서 제거됨 (비즈니스 요구사항 변경) + +--- + +### 4. likes (좋아요) + +**목적**: 사용자의 상품 좋아요 정보 관리 + +| 컬럼명 | 타입 | 제약조건 | 설명 | +|--------|------|----------|------| +| id | BIGINT | PK, AUTO_INCREMENT | 좋아요 고유 식별자 | +| userId | BIGINT | NOT NULL, UNIQUE (with productId) | 사용자 ID (users.id) | +| productId | BIGINT | NOT NULL, UNIQUE (with userId) | 상품 ID (products.id) | +| created_at | TIMESTAMP | NOT NULL | 좋아요 등록 시간 | +| updated_at | TIMESTAMP | NOT NULL | 수정일시 | +| deleted_at | TIMESTAMP | NULL | 삭제일시| + +**인덱스**: +- `uc_likee_user_product`: (userId, productId) UNIQUE (중복 방지) + +**논리적 관계** (물리적 FK 없음): +- userId → users(id) +- productId → products(id) + +**비즈니스 규칙**: +- 사용자당 상품별 좋아요는 1개만 가능 (복합 고유 제약) +- 좋아요 등록/취소는 멱등성 보장 +- 좋아요 등록 시 products.like_count 증가 +- 좋아요 취소 시 products.like_count 감소 + +--- + +### 5. orders (주문) + +**목적**: 사용자의 주문 정보 관리 + +| 컬럼명 | 타입 | 제약조건 | 설명 | +|--------|------|----------|------| +| id | BIGINT | PK, AUTO_INCREMENT | 주문 고유 식별자 | +| user_id | BIGINT | NOT NULL | 사용자 ID (users.id) | +| total_amount | DECIMAL(10,2) | NOT NULL | 총 주문 금액 | +| status | VARCHAR(20) | NOT NULL | 주문 상태 (PENDING/CONFIRMED/CANCELLED) | +| created_at | TIMESTAMP | NOT NULL | 생성일시 (주문 일시로 사용) | +| updated_at | TIMESTAMP | NOT NULL | 수정일시 | +| deleted_at | TIMESTAMP | NULL | 삭제일시| + +**인덱스**: +- `idx_order_user_id`: user_id (사용자별 주문 목록 조회) +- `idx_order_status`: status (상태별 주문 조회) + +**논리적 관계** (물리적 FK 없음): +- user_id → users(id) + +**비즈니스 규칙**: +- 주문 생성 시 상태는 PENDING +- 결제 완료 시 CONFIRMED로 변경 +- PENDING 또는 CONFIRMED 상태에서 CANCELLED로 변경 가능 +- total_amount는 order_items의 total_price 합계 +- ordered_at 필드는 제거되고 created_at을 주문 일시로 사용 + +**상태 전이**: +``` +PENDING → CONFIRMED (결제 완료) +PENDING → CANCELLED (주문 취소) +CONFIRMED → CANCELLED (주문 취소) +``` + +--- + +### 6. order_items (주문 상세) + +**목적**: 주문에 포함된 개별 상품 정보 관리 + +| 컬럼명 | 타입 | 제약조건 | 설명 | +|--------|------|----------|------| +| id | BIGINT | PK, AUTO_INCREMENT | 주문 상세 고유 식별자 | +| order_id | BIGINT | NOT NULL | 주문 ID (orders.id) | +| product_id | BIGINT | NOT NULL | 상품 ID (products.id) | +| quantity | INT | NOT NULL | 주문 수량 (1 이상) | +| unit_price | DECIMAL(10,2) | NOT NULL | 단가 (주문 시점의 상품 가격) | +| total_price | DECIMAL(10,2) | NOT NULL | 총 가격 (unit_price * quantity) | +| created_at | TIMESTAMP | NOT NULL | 생성일시 | +| updated_at | TIMESTAMP | NOT NULL | 수정일시 | +| deleted_at | TIMESTAMP | NULL | 삭제일시| + +**인덱스**: +- `idx_order_item_order_id`: order_id (주문별 상세 조회) +- `idx_order_item_product_id`: product_id (상품별 주문 이력) +- `idx_order_item_coupon_id`: coupon_id (쿠폰별 사용 이력) + +**논리적 관계** (물리적 FK 없음): +- order_id → orders(id) +- product_id → products(id) +- coupon_id → coupons(id) (선택사항) + +**비즈니스 규칙**: +- quantity는 1 이상이어야 함 +- unit_price는 주문 시점의 상품 가격 스냅샷 +- discount_amount는 쿠폰 적용 시 할인 금액 (기본값 0) +- total_price = unit_price * quantity - discount_amount +- 주문 생성 시 재고 차감 필수 +- 쿠폰 적용 시 coupon_usage_histories에 이력 생성 + +--- + +### 7. point_histories (포인트 이력) + +**목적**: 사용자의 포인트 변동 이력 관리 (감사 추적) + +| 컬럼명 | 타입 | 제약조건 | 설명 | +|--------|------|----------|------| +| id | BIGINT | PK, AUTO_INCREMENT | 포인트 이력 고유 식별자 | +| user_id | BIGINT | NOT NULL (FK) | 사용자 ID (users.id) - ManyToOne 관계 | +| amount | DECIMAL(9,2) | NOT NULL | 포인트 변동 금액 (항상 양수) | +| transaction_type | VARCHAR(20) | NOT NULL | 거래 유형 (CHARGE/USE/REFUND) | +| balance_after | DECIMAL(9,2) | NOT NULL | 거래 후 잔액 | +| created_at | TIMESTAMP | NOT NULL | 거래 일시 | +| updated_at | TIMESTAMP | NOT NULL | 수정일시 | +| deleted_at | TIMESTAMP | NULL | 삭제일시| + +**인덱스**: +- JPA에서 자동 생성되는 FK 인덱스 사용 + +**물리적 관계**: +- user_id → users(id) - ManyToOne 관계로 물리적 FK 존재 + +**비즈니스 규칙**: +- 모든 포인트 변동은 이력으로 기록 +- amount는 항상 양수 (거래 유형으로 충전/사용 구분) +- balance_after는 거래 후 users.point_amount와 일치 +- UserEntity와 ManyToOne 관계로 물리적 FK 존재 + +**거래 유형**: +- `CHARGE`: 포인트 충전 +- `USE`: 포인트 사용 (주문 결제) +- `REFUND`: 포인트 환불 (주문 취소) + +--- + +### 8. coupons (쿠폰) + +**목적**: 사용자에게 발급된 할인 쿠폰 정보 관리 + +| 컬럼명 | 타입 | 제약조건 | 설명 | +|--------|------|----------|------| +| id | BIGINT | PK, AUTO_INCREMENT | 쿠폰 고유 식별자 | +| user_id | BIGINT | NOT NULL | 사용자 ID (users.id) | +| coupon_type | VARCHAR(20) | NOT NULL | 쿠폰 타입 (FIXED_AMOUNT/PERCENTAGE) | +| fixed_amount | DECIMAL(10,2) | NULL | 정액 할인 금액 (정액 쿠폰용) | +| percentage | DECIMAL(5,2) | NULL | 할인 비율 (배율 쿠폰용, 0-100) | +| status | VARCHAR(20) | NOT NULL | 쿠폰 상태 (UNUSED/USED) | +| created_at | TIMESTAMP | NOT NULL | 생성일시 | +| updated_at | TIMESTAMP | NOT NULL | 수정일시 | +| deleted_at | TIMESTAMP | NULL | 삭제일시 | + +**인덱스**: +- `idx_coupon_user_id`: user_id (사용자별 쿠폰 조회) +- `idx_coupon_status`: status (상태별 쿠폰 조회) +- `idx_coupon_user_status`: (user_id, status) (사용자별 사용 가능 쿠폰 조회) + +**논리적 관계** (물리적 FK 없음): +- user_id → users(id) + +**비즈니스 규칙**: +- 쿠폰은 1회성으로 한 번 사용하면 재사용 불가 +- FIXED_AMOUNT 타입: fixed_amount 필수, percentage NULL +- PERCENTAGE 타입: percentage 필수 (0-100), fixed_amount NULL +- 초기 상태는 UNUSED, 사용 후 USED로 변경 +- 동시성 제어를 위한 비관적 락 적용 + +**쿠폰 타입**: +- `FIXED_AMOUNT`: 정액 할인 (예: 5,000원 할인) +- `PERCENTAGE`: 배율 할인 (예: 10% 할인) + +**쿠폰 상태**: +- `UNUSED`: 미사용 (사용 가능) +- `USED`: 사용됨 (재사용 불가) + +--- + +### 9. coupon_usage_histories (쿠폰 사용 이력) + +**목적**: 쿠폰 사용 이력 추적 및 감사 + +| 컬럼명 | 타입 | 제약조건 | 설명 | +|--------|------|----------|------| +| id | BIGINT | PK, AUTO_INCREMENT | 쿠폰 사용 이력 고유 식별자 | +| coupon_id | BIGINT | NOT NULL | 쿠폰 ID (coupons.id) | +| order_item_id | BIGINT | NOT NULL | 주문 항목 ID (order_items.id) | +| discount_amount | DECIMAL(10,2) | NOT NULL | 실제 할인 적용 금액 | +| created_at | TIMESTAMP | NOT NULL | 사용일시 | +| updated_at | TIMESTAMP | NOT NULL | 수정일시 | +| deleted_at | TIMESTAMP | NULL | 삭제일시 | + +**인덱스**: +- `idx_coupon_usage_coupon_id`: coupon_id (쿠폰별 사용 이력) +- `idx_coupon_usage_order_item_id`: order_item_id (주문 항목별 쿠폰 사용) + +**논리적 관계** (물리적 FK 없음): +- coupon_id → coupons(id) +- order_item_id → order_items(id) + +**비즈니스 규칙**: +- 쿠폰 사용 시 반드시 이력 생성 +- discount_amount는 실제 적용된 할인 금액 기록 +- 주문 취소 시에도 이력은 유지 (감사 목적) +- 쿠폰과 주문 항목 간의 연결 추적 + +--- + +## 데이터베이스 제약사항 + +### 1. 외래키 제약조건 + +**⚠️ 물리적 FK 제약조건 제한적 사용 정책** + +이 프로젝트는 **대부분의 테이블에서 물리적 외래키(Foreign Key) 제약조건을 사용하지 않습니다**. + +**예외**: +- `point_histories` 테이블의 `user_id`는 JPA `@ManyToOne` 관계로 물리적 FK 존재 + - 포인트 이력은 사용자와 강한 결합 관계 + - 사용자 삭제 시 이력도 함께 관리 필요 + +**물리적 FK 미사용 이유**: +- **성능**: FK 제약조건은 INSERT/UPDATE/DELETE 시 추가 검증 오버헤드 발생 +- **유연성**: 데이터 마이그레이션 및 배치 작업 시 제약 없음 +- **확장성**: 샤딩, 파티셔닝 등 분산 환경에서 유리 +- **장애 격리**: 참조 테이블 장애 시에도 독립적 운영 가능 + +--- +### 2. 테이블 네이밍 규칙 + +- 복수형 사용 (users, products, orders, coupons) +- 스네이크 케이스 (snake_case) +- 소문자 사용 +- 연결 테이블은 복수형_복수형 형태 (coupon_usage_histories) + +### 3. 쿠폰 관련 제약사항 + +#### 쿠폰 타입별 데이터 제약 + +| 쿠폰 타입 | fixed_amount | percentage | 검증 규칙 | +|----------|--------------|------------|----------| +| FIXED_AMOUNT | NOT NULL (> 0) | NULL | 정액 할인 금액 필수 | +| PERCENTAGE | NULL | NOT NULL (0-100) | 할인 비율 필수, 0-100% 범위 | + +#### 쿠폰 동시성 제어 + +**비관적 락 (Pessimistic Lock) 적용**: +```sql +SELECT * FROM coupons WHERE id = ? FOR UPDATE; +``` + +**동작 방식**: +1. 쿠폰 조회 시 행 레벨 락 획득 +2. 트랜잭션 종료까지 다른 트랜잭션 접근 차단 +3. 쿠폰 상태 변경 (UNUSED → USED) +4. 트랜잭션 커밋 시 락 해제 + +#### 쿠폰 할인 계산 로직 + +**정액 쿠폰 (FIXED_AMOUNT)**: +``` +discount_amount = coupons.fixed_amount +final_price = unit_price * quantity - discount_amount +``` + +**배율 쿠폰 (PERCENTAGE)**: +``` +discount_amount = (unit_price * quantity) * (coupons.percentage / 100) +final_price = unit_price * quantity - discount_amount +``` + +#### 쿠폰 적용 범위 (현재 설계) + +- **주문 항목별 쿠폰 적용**: 각 order_item에 개별 쿠폰 적용 가능 +- **1회성 보장**: 쿠폰은 한 번만 사용 가능 (status: UNUSED → USED) +- **소유권 검증**: 쿠폰 소유자만 사용 가능 +- **이력 추적**: coupon_usage_histories로 사용 이력 관리 + +#### 데이터 정합성 보장 + +1. **쿠폰 사용 원자성**: + - 쿠폰 상태 변경 (UNUSED → USED) + - 주문 항목 생성 (coupon_id, discount_amount 포함) + - 쿠폰 사용 이력 생성 + - 모든 작업이 하나의 트랜잭션에서 처리 + +2. **할인 금액 일치성**: + - order_items.discount_amount = coupon_usage_histories.discount_amount + - 쿠폰 타입별 계산 로직 일치성 보장 + +3. **중복 사용 방지**: + - 비관적 락으로 동시 접근 제어 + - 쿠폰 상태 검증 (UNUSED만 사용 가능) + - 소유권 검증 (user_id 일치 확인)