diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 000000000..1f80db6bf --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,13 @@ +name: PR Agent +on: + pull_request: + types: [opened, synchronize] +jobs: + pr_agent_job: + runs-on: ubuntu-latest + steps: + - name: PR Agent action step + uses: Codium-ai/pr-agent@main + env: + OPENAI_KEY: ${{ secrets.OPENAI_KEY }} + GITHUB_TOKEN: ${{ secrets.G_TOKEN }} diff --git a/apps/commerce-api/build.gradle.kts b/apps/commerce-api/build.gradle.kts index 03ce68f02..969c6a1f0 100644 --- a/apps/commerce-api/build.gradle.kts +++ b/apps/commerce-api/build.gradle.kts @@ -11,6 +11,9 @@ dependencies { implementation("org.springframework.boot:spring-boot-starter-actuator") implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:${project.properties["springDocOpenApiVersion"]}") + // batch + implementation("org.springframework.boot:spring-boot-starter-batch") + // 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..0b4b1cde4 100644 --- a/apps/commerce-api/src/main/java/com/loopers/CommerceApiApplication.java +++ b/apps/commerce-api/src/main/java/com/loopers/CommerceApiApplication.java @@ -4,10 +4,12 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.context.properties.ConfigurationPropertiesScan; +import org.springframework.scheduling.annotation.EnableScheduling; import java.util.TimeZone; @ConfigurationPropertiesScan @SpringBootApplication +@EnableScheduling public class CommerceApiApplication { @PostConstruct diff --git a/apps/commerce-api/src/main/java/com/loopers/application/catalog/CatalogBrandFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/catalog/CatalogBrandFacade.java new file mode 100644 index 000000000..4cdc2f177 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/catalog/CatalogBrandFacade.java @@ -0,0 +1,55 @@ +package com.loopers.application.catalog; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandRepository; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +/** + * 브랜드 조회 파사드. + *

+ * 브랜드 정보 조회 유즈케이스를 처리하는 애플리케이션 서비스입니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +@RequiredArgsConstructor +@Component +public class CatalogBrandFacade { + private final BrandRepository brandRepository; + + /** + * 브랜드 정보를 조회합니다. + * + * @param brandId 브랜드 ID + * @return 브랜드 정보 + * @throws CoreException 브랜드를 찾을 수 없는 경우 + */ + public BrandInfo getBrand(Long brandId) { + Brand brand = brandRepository.findById(brandId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "브랜드를 찾을 수 없습니다.")); + return BrandInfo.from(brand); + } + + /** + * 브랜드 정보를 담는 레코드. + * + * @param id 브랜드 ID + * @param name 브랜드 이름 + */ + public record BrandInfo(Long id, String name) { + /** + * Brand 엔티티로부터 BrandInfo를 생성합니다. + * + * @param brand 브랜드 엔티티 + * @return 생성된 BrandInfo + */ + public static BrandInfo from(Brand brand) { + return new BrandInfo(brand.getId(), brand.getName()); + } + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/application/catalog/CatalogProductFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/catalog/CatalogProductFacade.java index 53d7ca98c..3cba60deb 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/catalog/CatalogProductFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/catalog/CatalogProductFacade.java @@ -2,17 +2,14 @@ import com.loopers.domain.brand.Brand; import com.loopers.domain.brand.BrandRepository; -import com.loopers.domain.like.LikeRepository; import com.loopers.domain.product.Product; import com.loopers.domain.product.ProductDetail; -import com.loopers.domain.product.ProductDetailService; import com.loopers.domain.product.ProductRepository; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; -import java.util.Comparator; import java.util.List; import java.util.Map; import java.util.stream.Collectors; @@ -31,11 +28,12 @@ public class CatalogProductFacade { private final ProductRepository productRepository; private final BrandRepository brandRepository; - private final LikeRepository likeRepository; - private final ProductDetailService productDetailService; /** * 상품 목록을 조회합니다. + *

+ * 배치 조회를 통해 N+1 쿼리 문제를 해결합니다. + *

* * @param brandId 브랜드 ID (선택) * @param sort 정렬 기준 (latest, price_asc, likes_desc) @@ -46,9 +44,36 @@ public class CatalogProductFacade { public ProductInfoList getProducts(Long brandId, String sort, int page, int size) { long totalCount = productRepository.countAll(brandId); List products = productRepository.findAll(brandId, sort, page, size); + + if (products.isEmpty()) { + return new ProductInfoList(List.of(), totalCount, page, size); + } + + // ✅ 배치 조회로 N+1 쿼리 문제 해결 + // 브랜드 ID 수집 + List brandIds = products.stream() + .map(Product::getBrandId) + .distinct() + .toList(); + + // 브랜드 배치 조회 및 Map으로 변환 (O(1) 조회를 위해) + Map brandMap = brandRepository.findAllById(brandIds).stream() + .collect(Collectors.toMap(Brand::getId, brand -> brand)); + + // 상품 정보 변환 (이미 조회한 Product 재사용) List productsInfo = products.stream() - .map(product -> getProduct(product.getId())) + .map(product -> { + Brand brand = brandMap.get(product.getBrandId()); + if (brand == null) { + throw new CoreException(ErrorType.NOT_FOUND, + String.format("브랜드를 찾을 수 없습니다. (브랜드 ID: %d)", product.getBrandId())); + } + // ✅ Product.likeCount 필드 사용 (비동기 집계된 값) + ProductDetail productDetail = ProductDetail.from(product, brand.getName(), product.getLikeCount()); + return new ProductInfo(productDetail); + }) .toList(); + return new ProductInfoList(productsInfo, totalCount, page, size); } @@ -67,12 +92,11 @@ public ProductInfo getProduct(Long productId) { Brand brand = brandRepository.findById(product.getBrandId()) .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "브랜드를 찾을 수 없습니다.")); - // 좋아요 수 조회 - Map likesCountMap = likeRepository.countByProductIds(List.of(productId)); - Long likesCount = likesCountMap.getOrDefault(productId, 0L); + // ✅ Product.likeCount 필드 사용 (비동기 집계된 값) + Long likesCount = product.getLikeCount(); - // 도메인 서비스를 통해 ProductDetail 생성 (도메인 객체 협력) - ProductDetail productDetail = productDetailService.combineProductAndBrand(product, brand, likesCount); + // ProductDetail 생성 (Aggregate 경계 준수: Brand 엔티티 대신 brandName만 전달) + ProductDetail productDetail = ProductDetail.from(product, brand.getName(), likesCount); return new ProductInfo(productDetail); } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java index c97814f0f..3c016a492 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java @@ -8,13 +8,14 @@ import com.loopers.domain.user.UserRepository; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; -import jakarta.transaction.Transactional; +import org.springframework.transaction.annotation.Transactional; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.stream.Collectors; /** * 좋아요 관리 파사드. @@ -37,23 +38,52 @@ public class LikeFacade { *

* 멱등성을 보장합니다. 이미 좋아요가 존재하는 경우 아무 작업도 수행하지 않습니다. *

+ *

+ * 동시성 제어 전략: + *

    + *
  • UNIQUE 제약조건 사용: 데이터베이스 레벨에서 중복 삽입을 물리적으로 방지
  • + *
  • 애플리케이션 레벨 한계: 애플리케이션 레벨로는 race condition을 완전히 방지할 수 없음
  • + *
  • 예외 처리: UNIQUE 제약조건 위반 시 DataIntegrityViolationException 처리하여 멱등성 보장
  • + *
+ *

+ *

+ * DBA 설득 근거 (유니크 인덱스 사용): + *

    + *
  • 트래픽 패턴: 좋아요는 고 QPS write-heavy 테이블이 아니며, 전체 서비스에서 차지하는 비중이 낮음
  • + *
  • 애플리케이션 레벨 한계: 동일 시점 동시 요청 시 select 시점엔 중복 없음 → insert 2번 발생 가능
  • + *
  • 데이터 무결성: DB만이 강한 무결성(Strong Consistency)을 제공할 수 있음
  • + *
  • 비즈니스 데이터 보호: 중복 좋아요로 인한 비즈니스 데이터 오염 방지
  • + *
+ *

* * @param userId 사용자 ID (String) * @param productId 상품 ID * @throws CoreException 사용자 또는 상품을 찾을 수 없는 경우 */ - @Transactional public void addLike(String userId, Long productId) { User user = loadUser(userId); loadProduct(productId); + // 먼저 일반 조회로 중복 체크 (대부분의 경우 빠르게 처리) + // ⚠️ 주의: 애플리케이션 레벨 체크만으로는 race condition을 완전히 방지할 수 없음 + // 동시에 두 요청이 들어오면 둘 다 "없음"으로 판단 → 둘 다 저장 시도 가능 Optional existingLike = likeRepository.findByUserIdAndProductId(user.getId(), productId); if (existingLike.isPresent()) { return; } + // 저장 시도 (동시성 상황에서는 UNIQUE 제약조건 위반 예외 발생 가능) + // ✅ UNIQUE 제약조건이 최종 보호: DB 레벨에서 중복 삽입을 물리적으로 방지 + // @Transactional이 없어도 save() 호출 시 자동 트랜잭션으로 예외를 catch할 수 있음 Like like = Like.of(user.getId(), productId); - likeRepository.save(like); + try { + likeRepository.save(like); + } catch (org.springframework.dao.DataIntegrityViolationException e) { + // UNIQUE 제약조건 위반 = 이미 저장됨 (멱등성 보장) + // 동시에 여러 요청이 들어와서 모두 "없음"으로 판단하고 저장을 시도할 때, + // 첫 번째만 성공하고 나머지는 UNIQUE 제약조건 위반 예외 발생 + // 이미 좋아요가 존재하는 경우이므로 정상 처리로 간주 + } } /** @@ -66,7 +96,6 @@ public void addLike(String userId, Long productId) { * @param productId 상품 ID * @throws CoreException 사용자 또는 상품을 찾을 수 없는 경우 */ - @Transactional public void removeLike(String userId, Long productId) { User user = loadUser(userId); loadProduct(productId); @@ -76,16 +105,33 @@ public void removeLike(String userId, Long productId) { return; } - likeRepository.delete(like.get()); + try { + likeRepository.delete(like.get()); + } catch (Exception e) { + // 동시성 상황에서 이미 삭제된 경우 등 예외 발생 가능 + // 멱등성 보장: 이미 삭제된 경우 정상 처리로 간주 + } } /** * 사용자가 좋아요한 상품 목록을 조회합니다. + *

+ * 상품 정보 조회를 병렬로 처리하여 성능을 최적화합니다. + *

+ *

+ * 좋아요 수 조회 전략: + *

    + *
  • 비동기 집계: Product.likeCount 필드 사용 (스케줄러로 주기적 동기화)
  • + *
  • Eventually Consistent: 약간의 지연 허용 (최대 5초)
  • + *
  • 성능 최적화: COUNT(*) 쿼리 없이 컬럼만 읽으면 됨
  • + *
+ *

* * @param userId 사용자 ID (String) * @return 좋아요한 상품 목록 * @throws CoreException 사용자를 찾을 수 없는 경우 */ + @Transactional(readOnly = true) public List getLikedProducts(String userId) { User user = loadUser(userId); @@ -101,26 +147,26 @@ public List getLikedProducts(String userId) { .map(Like::getProductId) .toList(); - // 상품 정보 조회 - List products = productIds.stream() - .map(productId -> productRepository.findById(productId) - .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, - String.format("상품을 찾을 수 없습니다. (상품 ID: %d)", productId)))) - .toList(); + // ✅ 배치 조회로 N+1 쿼리 문제 해결 + Map productMap = productRepository.findAllById(productIds).stream() + .collect(Collectors.toMap(Product::getId, product -> product)); - // 좋아요 수 집계 - Map likesCountMap = likeRepository.countByProductIds(productIds); + // 요청한 상품 ID와 조회된 상품 수가 일치하는지 확인 + if (productMap.size() != productIds.size()) { + throw new CoreException(ErrorType.NOT_FOUND, "일부 상품을 찾을 수 없습니다."); + } // 좋아요 목록을 상품 정보와 좋아요 수와 함께 변환 + // ✅ Product.likeCount 필드 사용 (비동기 집계된 값) return likes.stream() .map(like -> { - Product product = products.stream() - .filter(p -> p.getId().equals(like.getProductId())) - .findFirst() - .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, - String.format("상품을 찾을 수 없습니다. (상품 ID: %d)", like.getProductId()))); - Long likesCount = likesCountMap.getOrDefault(like.getProductId(), 0L); - return LikedProduct.from(product, like, likesCount); + Product product = productMap.get(like.getProductId()); + if (product == null) { + throw new CoreException(ErrorType.NOT_FOUND, + String.format("상품을 찾을 수 없습니다. (상품 ID: %d)", like.getProductId())); + } + // Product 엔티티의 likeCount 필드를 내부에서 사용 + return LikedProduct.from(product); }) .toList(); } @@ -158,21 +204,26 @@ public record LikedProduct( Long likesCount ) { /** - * Product와 Like로부터 LikedProduct를 생성합니다. + * Product로부터 LikedProduct를 생성합니다. + *

+ * Product.likeCount 필드를 사용하여 좋아요 수를 가져옵니다. + *

* * @param product 상품 엔티티 - * @param like 좋아요 엔티티 - * @param likesCount 좋아요 수 * @return 생성된 LikedProduct + * @throws IllegalArgumentException product가 null인 경우 */ - public static LikedProduct from(Product product, Like like, Long likesCount) { + public static LikedProduct from(Product product) { + if (product == null) { + throw new IllegalArgumentException("상품은 null일 수 없습니다."); + } return new LikedProduct( product.getId(), product.getName(), product.getPrice(), product.getStock(), product.getBrandId(), - likesCount + product.getLikeCount() // ✅ Product.likeCount 필드 사용 (비동기 집계된 값) ); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/purchasing/OrderItemCommand.java b/apps/commerce-api/src/main/java/com/loopers/application/purchasing/OrderItemCommand.java index b93d31215..903595a6c 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/purchasing/OrderItemCommand.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/purchasing/OrderItemCommand.java @@ -8,8 +8,9 @@ * * @param productId 상품 ID * @param quantity 수량 + * @param couponCode 쿠폰 코드 (선택) */ -public record OrderItemCommand(Long productId, Integer quantity) { +public record OrderItemCommand(Long productId, Integer quantity, String couponCode) { public OrderItemCommand { if (productId == null) { throw new CoreException(ErrorType.BAD_REQUEST, "상품 ID는 필수입니다."); @@ -18,5 +19,16 @@ public record OrderItemCommand(Long productId, Integer quantity) { throw new CoreException(ErrorType.BAD_REQUEST, "상품 수량은 1개 이상이어야 합니다."); } } + + /** + * 쿠폰 코드 없이 OrderItemCommand를 생성합니다. + * + * @param productId 상품 ID + * @param quantity 수량 + * @return 생성된 OrderItemCommand + */ + public static OrderItemCommand of(Long productId, Integer quantity) { + return new OrderItemCommand(productId, quantity, null); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/purchasing/PurchasingFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/purchasing/PurchasingFacade.java index 4bc808613..82e55c406 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/purchasing/PurchasingFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/purchasing/PurchasingFacade.java @@ -1,8 +1,14 @@ package com.loopers.application.purchasing; +import com.loopers.domain.coupon.Coupon; +import com.loopers.domain.coupon.CouponRepository; +import com.loopers.domain.coupon.UserCoupon; +import com.loopers.domain.coupon.UserCouponRepository; +import com.loopers.domain.coupon.discount.CouponDiscountStrategyFactory; import com.loopers.domain.order.Order; import com.loopers.domain.order.OrderItem; import com.loopers.domain.order.OrderRepository; +import org.springframework.orm.ObjectOptimisticLockingFailureException; import com.loopers.domain.product.Product; import com.loopers.domain.product.ProductRepository; import com.loopers.domain.user.Point; @@ -10,16 +16,14 @@ import com.loopers.domain.user.UserRepository; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; -import jakarta.transaction.Transactional; +import org.springframework.transaction.annotation.Transactional; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; import java.util.ArrayList; -import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Objects; -import java.util.Set; import java.util.stream.Collectors; /** @@ -35,6 +39,9 @@ public class PurchasingFacade { private final UserRepository userRepository; private final ProductRepository productRepository; private final OrderRepository orderRepository; + private final CouponRepository couponRepository; + private final UserCouponRepository userCouponRepository; + private final CouponDiscountStrategyFactory couponDiscountStrategyFactory; /** * 주문을 생성한다. @@ -42,7 +49,34 @@ public class PurchasingFacade { * 1. 사용자 조회 및 존재 여부 검증
* 2. 상품 재고 검증 및 차감
* 3. 사용자 포인트 검증 및 차감
- * 4. 주문 저장 및 외부 시스템 알림 + * 4. 주문 저장 + *

+ *

+ * 동시성 제어 전략: + *

    + *
  • PESSIMISTIC_WRITE 사용 근거: Lost Update 방지 및 데이터 일관성 보장
  • + *
  • 포인트 차감: 동시 주문 시 포인트 중복 차감 방지 (금전적 손실 방지)
  • + *
  • 재고 차감: 동시 주문 시 재고 음수 방지 및 정확한 차감 보장 (재고 oversell 방지)
  • + *
  • Lock 범위 최소화: PK/UNIQUE 인덱스 기반 조회로 Lock 범위 최소화
  • + *
+ *

+ *

+ * DBA 설득 근거 (비관적 락 사용): + *

    + *
  • 제한적 사용: 전역이 아닌 금전적 손실 위험이 있는 특정 도메인에만 사용
  • + *
  • 트랜잭션 최소화: 트랜잭션 내부에 외부 I/O 없음, lock holding time 매우 짧음 (몇 ms)
  • + *
  • Lock 범위 최소화: PK/UNIQUE 인덱스 기반 조회로 해당 행만 락 (Record Lock)
  • + *
  • 애플리케이션 레벨 한계: 애플리케이션 레벨로는 race condition을 완전히 방지할 수 없어서 DB 차원의 strong consistency 필요
  • + *
  • 낙관적 락 기본 전략: 쿠폰 사용은 낙관적 락 사용 (Hot Spot 대응)
  • + *
+ *

+ *

+ * Lock 생명주기: + *

    + *
  1. SELECT ... FOR UPDATE 실행 시 락 획득
  2. + *
  3. 트랜잭션 내에서 락 유지 (외부 I/O 없음, 매우 짧은 시간)
  4. + *
  5. 트랜잭션 커밋/롤백 시 락 자동 해제
  6. + *
*

* * @param userId 사용자 식별자 (로그인 ID) @@ -51,25 +85,52 @@ public class PurchasingFacade { */ @Transactional public OrderInfo createOrder(String userId, List commands) { + if (userId == null || userId.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "사용자 ID는 필수입니다."); + } if (commands == null || commands.isEmpty()) { throw new CoreException(ErrorType.BAD_REQUEST, "주문 아이템은 1개 이상이어야 합니다."); } - User user = loadUser(userId); + // 비관적 락을 사용하여 사용자 조회 (포인트 차감 시 동시성 제어) + // - userId는 UNIQUE 인덱스가 있어 Lock 범위 최소화 (Record Lock만 적용) + // - Lost Update 방지: 동시 주문 시 포인트 중복 차감 방지 (금전적 손실 방지) + // - 트랜잭션 내부에 외부 I/O 없음, lock holding time 매우 짧음 + User user = loadUserForUpdate(userId); - Set productIds = new HashSet<>(); - List products = new ArrayList<>(); - List orderItems = new ArrayList<>(); + // ✅ Deadlock 방지: 상품 ID를 정렬하여 일관된 락 획득 순서 보장 + // 여러 상품을 주문할 때, 항상 동일한 순서로 락을 획득하여 deadlock 방지 + List sortedProductIds = commands.stream() + .map(OrderItemCommand::productId) + .distinct() + .sorted() + .toList(); - for (OrderItemCommand command : commands) { - if (!productIds.add(command.productId())) { - throw new CoreException(ErrorType.BAD_REQUEST, - String.format("상품이 중복되었습니다. (상품 ID: %d)", command.productId())); - } + // 중복 상품 검증 + if (sortedProductIds.size() != commands.size()) { + throw new CoreException(ErrorType.BAD_REQUEST, "상품이 중복되었습니다."); + } + + // 정렬된 순서대로 상품 락 획득 (Deadlock 방지) + Map productMap = new java.util.HashMap<>(); - Product product = productRepository.findById(command.productId()) + for (Long productId : sortedProductIds) { + // 비관적 락을 사용하여 상품 조회 (재고 차감 시 동시성 제어) + // - id는 PK 인덱스가 있어 Lock 범위 최소화 (Record Lock만 적용) + // - Lost Update 방지: 동시 주문 시 재고 음수 방지 및 정확한 차감 보장 (재고 oversell 방지) + // - 트랜잭션 내부에 외부 I/O 없음, lock holding time 매우 짧음 + // - ✅ 정렬된 순서로 락 획득하여 deadlock 방지 + Product product = productRepository.findByIdForUpdate(productId) .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, - String.format("상품을 찾을 수 없습니다. (상품 ID: %d)", command.productId()))); + String.format("상품을 찾을 수 없습니다. (상품 ID: %d)", productId))); + productMap.put(productId, product); + } + + // OrderItem 생성 + List products = new ArrayList<>(); + List orderItems = new ArrayList<>(); + for (OrderItemCommand command : commands) { + Product product = productMap.get(command.productId()); products.add(product); orderItems.add(OrderItem.of( @@ -80,7 +141,14 @@ public OrderInfo createOrder(String userId, List commands) { )); } - Order order = Order.of(user.getId(), orderItems); + // 쿠폰 처리 (있는 경우) + String couponCode = extractCouponCode(commands); + Integer discountAmount = 0; + if (couponCode != null && !couponCode.isBlank()) { + discountAmount = applyCoupon(user.getId(), couponCode, calculateSubtotal(orderItems)); + } + + Order order = Order.of(user.getId(), orderItems, couponCode, discountAmount); decreaseStocksForOrderItems(order.getItems(), products); deductUserPoint(user, order.getTotalAmount()); @@ -96,6 +164,13 @@ public OrderInfo createOrder(String userId, List commands) { /** * 주문을 취소하고 포인트를 환불하며 재고를 원복한다. + *

+ * 동시성 제어: + *

    + *
  • 비관적 락 사용: 재고 원복 시 동시성 제어를 위해 findByIdForUpdate 사용
  • + *
  • Deadlock 방지: 상품 ID를 정렬하여 일관된 락 획득 순서 보장
  • + *
+ *

* * @param order 주문 엔티티 * @param user 사용자 엔티티 @@ -106,18 +181,41 @@ public void cancelOrder(Order order, User user) { throw new CoreException(ErrorType.BAD_REQUEST, "취소할 주문과 사용자 정보는 필수입니다."); } - List products = order.getItems().stream() - .map(item -> productRepository.findById(item.getProductId()) + // ✅ Deadlock 방지: User 락을 먼저 획득하여 createOrder와 동일한 락 획득 순서 보장 + // createOrder: User 락 → Product 락 (정렬됨) + // cancelOrder: User 락 → Product 락 (정렬됨) - 동일한 순서로 락 획득 + User lockedUser = userRepository.findByUserIdForUpdate(user.getUserId()); + if (lockedUser == null) { + throw new CoreException(ErrorType.NOT_FOUND, "사용자를 찾을 수 없습니다."); + } + + // ✅ Deadlock 방지: 상품 ID를 정렬하여 일관된 락 획득 순서 보장 + List sortedProductIds = order.getItems().stream() + .map(OrderItem::getProductId) + .distinct() + .sorted() + .toList(); + + // 정렬된 순서대로 상품 락 획득 (Deadlock 방지) + Map productMap = new java.util.HashMap<>(); + for (Long productId : sortedProductIds) { + Product product = productRepository.findByIdForUpdate(productId) .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, - String.format("상품을 찾을 수 없습니다. (상품 ID: %d)", item.getProductId())))) + String.format("상품을 찾을 수 없습니다. (상품 ID: %d)", productId))); + productMap.put(productId, product); + } + + // OrderItem 순서대로 Product 리스트 생성 + List products = order.getItems().stream() + .map(item -> productMap.get(item.getProductId())) .toList(); order.cancel(); increaseStocksForOrderItems(order.getItems(), products); - user.receivePoint(Point.of((long) order.getTotalAmount())); + lockedUser.receivePoint(Point.of((long) order.getTotalAmount())); products.forEach(productRepository::save); - userRepository.save(user); + userRepository.save(lockedUser); orderRepository.save(order); } @@ -127,7 +225,7 @@ public void cancelOrder(Order order, User user) { * @param userId 사용자 식별자 (로그인 ID) * @return 주문 목록 */ - @Transactional + @Transactional(readOnly = true) public List getOrders(String userId) { User user = loadUser(userId); List orders = orderRepository.findAllByUserId(user.getId()); @@ -143,7 +241,7 @@ public List getOrders(String userId) { * @param orderId 주문 ID * @return 주문 정보 */ - @Transactional + @Transactional(readOnly = true) public OrderInfo getOrder(String userId, Long orderId) { User user = loadUser(userId); Order order = orderRepository.findById(orderId) @@ -198,5 +296,107 @@ private User loadUser(String userId) { } return user; } + + /** + * 비관적 락을 사용하여 사용자를 조회합니다. + *

+ * 포인트 차감 등 동시성 제어가 필요한 경우 사용합니다. + *

+ *

+ * 전제 조건: userId는 상위 계층에서 이미 null/blank 검증이 완료되어야 합니다. + *

+ * + * @param userId 사용자 ID (null이 아니고 비어있지 않아야 함) + * @return 조회된 사용자 + * @throws CoreException 사용자를 찾을 수 없는 경우 + */ + private User loadUserForUpdate(String userId) { + User user = userRepository.findByUserIdForUpdate(userId); + if (user == null) { + throw new CoreException(ErrorType.NOT_FOUND, "사용자를 찾을 수 없습니다."); + } + return user; + } + + /** + * 주문 명령에서 쿠폰 코드를 추출합니다. + * + * @param commands 주문 명령 목록 + * @return 쿠폰 코드 (없으면 null) + */ + private String extractCouponCode(List commands) { + return commands.stream() + .filter(cmd -> cmd.couponCode() != null && !cmd.couponCode().isBlank()) + .map(OrderItemCommand::couponCode) + .findFirst() + .orElse(null); + } + + /** + * 쿠폰을 적용하여 할인 금액을 계산하고 쿠폰을 사용 처리합니다. + *

+ * 동시성 제어 전략: + *

    + *
  • OPTIMISTIC_LOCK 사용 근거: 쿠폰 중복 사용 방지, Hot Spot 대응
  • + *
  • @Version 필드: UserCoupon 엔티티의 version 필드를 통해 자동으로 낙관적 락 적용
  • + *
  • 동시 사용 시: 한 명만 성공하고 나머지는 OptimisticLockException 발생
  • + *
  • 사용 목적: 동일 쿠폰으로 여러 기기에서 동시 주문해도 한 번만 사용되도록 보장
  • + *
+ *

+ * + * @param userId 사용자 ID + * @param couponCode 쿠폰 코드 + * @param subtotal 주문 소계 금액 + * @return 할인 금액 + * @throws CoreException 쿠폰을 찾을 수 없거나 사용 불가능한 경우, 동시 사용으로 인한 충돌 시 + */ + private Integer applyCoupon(Long userId, String couponCode, Integer subtotal) { + // 쿠폰 존재 여부 확인 + Coupon coupon = couponRepository.findByCode(couponCode) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, + String.format("쿠폰을 찾을 수 없습니다. (쿠폰 코드: %s)", couponCode))); + + // 낙관적 락을 사용하여 사용자 쿠폰 조회 (동시성 제어) + // @Version 필드가 있어 자동으로 낙관적 락이 적용됨 + UserCoupon userCoupon = userCouponRepository.findByUserIdAndCouponCodeForUpdate(userId, couponCode) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, + String.format("사용자가 소유한 쿠폰을 찾을 수 없습니다. (쿠폰 코드: %s)", couponCode))); + + // 쿠폰 사용 가능 여부 확인 + if (!userCoupon.isAvailable()) { + throw new CoreException(ErrorType.BAD_REQUEST, + String.format("이미 사용된 쿠폰입니다. (쿠폰 코드: %s)", couponCode)); + } + + // 쿠폰 사용 처리 + userCoupon.use(); + + // 할인 금액 계산 (전략 패턴 사용) + Integer discountAmount = coupon.calculateDiscountAmount(subtotal, couponDiscountStrategyFactory); + + try { + // 사용자 쿠폰 저장 (version 체크 자동 수행) + // 다른 트랜잭션이 먼저 수정했다면 OptimisticLockException 발생 + userCouponRepository.save(userCoupon); + } catch (ObjectOptimisticLockingFailureException e) { + // 낙관적 락 충돌: 다른 트랜잭션이 먼저 쿠폰을 사용함 + throw new CoreException(ErrorType.CONFLICT, + String.format("쿠폰이 이미 사용되었습니다. (쿠폰 코드: %s)", couponCode)); + } + + return discountAmount; + } + + /** + * 주문 아이템 목록으로부터 소계 금액을 계산합니다. + * + * @param orderItems 주문 아이템 목록 + * @return 계산된 소계 금액 + */ + private Integer calculateSubtotal(List orderItems) { + return orderItems.stream() + .mapToInt(item -> item.getPrice() * item.getQuantity()) + .sum(); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/scheduler/LikeCountSyncScheduler.java b/apps/commerce-api/src/main/java/com/loopers/application/scheduler/LikeCountSyncScheduler.java new file mode 100644 index 000000000..4621b4fef --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/scheduler/LikeCountSyncScheduler.java @@ -0,0 +1,98 @@ +package com.loopers.application.scheduler; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.JobExecution; +import org.springframework.batch.core.JobParameters; +import org.springframework.batch.core.JobParametersBuilder; +import org.springframework.batch.core.launch.JobLauncher; +import org.springframework.batch.core.repository.JobRestartException; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +/** + * 좋아요 수 동기화 스케줄러. + *

+ * 주기적으로 Spring Batch Job을 실행하여 Like 테이블의 COUNT(*) 결과를 Product.likeCount 필드에 동기화합니다. + *

+ *

+ * 동작 원리: + *

    + *
  1. 주기적으로 실행 (기본: 5초마다)
  2. + *
  3. Spring Batch Job 실행
  4. + *
  5. Reader: 모든 상품 ID 조회
  6. + *
  7. Processor: 각 상품의 좋아요 수 집계 (Like 테이블 COUNT(*))
  8. + *
  9. Writer: Product 테이블의 likeCount 필드 업데이트
  10. + *
+ *

+ *

+ * 설계 근거: + *

    + *
  • Spring Batch 사용: 대량 처리, 청크 단위 처리, 재시작 가능
  • + *
  • Eventually Consistent: 좋아요 수는 약간의 지연 허용 가능
  • + *
  • 성능 최적화: 조회 시 COUNT(*) 대신 컬럼만 읽으면 됨
  • + *
  • 쓰기 경합 최소화: Like 테이블은 Insert-only로 쓰기 경합 없음
  • + *
  • 확장성: Redis 없이도 대규모 트래픽 처리 가능
  • + *
+ *

+ * + * @author Loopers + * @version 1.0 + */ +@Slf4j +@RequiredArgsConstructor +@Component +public class LikeCountSyncScheduler { + + private final JobLauncher jobLauncher; + private final Job likeCountSyncJob; + + /** + * 좋아요 수를 동기화합니다. + *

+ * 5초마다 실행되어 Spring Batch Job을 통해 Like 테이블의 집계 결과를 Product.likeCount에 반영합니다. + *

+ *

+ * Spring Batch 장점: + *

    + *
  • 청크 단위 처리: 100개씩 묶어서 처리하여 성능 최적화
  • + *
  • 트랜잭션 관리: 청크 단위로 커밋하여 안정성 보장
  • + *
  • 재시작 가능: Job 실패 시 재시작 가능
  • + *
  • 모니터링: Spring Batch 메타데이터로 실행 이력 추적
  • + *
+ *

+ *

+ * 주기적 실행 전략: + *

    + *
  • 타임스탬프 기반 JobParameters: 매 실행마다 타임스탬프를 추가하여 새로운 JobInstance 생성
  • + *
  • 5초마다 실행: 스케줄러가 5초마다 Job을 실행하여 좋아요 수를 최신화
  • + *
+ *

+ */ + @Scheduled(fixedDelay = 5000) // 5초마다 실행 + public void syncLikeCounts() { + try { + log.debug("좋아요 수 동기화 배치 Job 시작"); + + // 타임스탬프를 JobParameters에 추가하여 매번 새로운 JobInstance 생성 + // Spring Batch는 동일한 JobParameters를 가진 JobInstance를 재실행하지 않으므로, + // 타임스탬프를 추가하여 매 실행마다 새로운 JobInstance를 생성합니다. + JobParameters jobParameters = new JobParametersBuilder() + .addString("jobName", "likeCountSync") + .addLong("timestamp", System.currentTimeMillis()) + .toJobParameters(); + + // Spring Batch Job 실행 + JobExecution jobExecution = jobLauncher.run(likeCountSyncJob, jobParameters); + + log.debug("좋아요 수 동기화 배치 Job 완료: status={}", jobExecution.getStatus()); + + } catch (JobRestartException e) { + log.error("좋아요 수 동기화 배치 Job 재시작 실패", e); + } catch (Exception e) { + log.error("좋아요 수 동기화 배치 Job 실행 중 오류 발생", e); + } + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/application/signup/SignUpFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/signup/SignUpFacade.java index 9ede52abb..293505b15 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/signup/SignUpFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/signup/SignUpFacade.java @@ -52,12 +52,18 @@ public SignUpInfo signUp(String userId, String email, String birthDateStr, Strin /** * 성별 문자열을 Gender enum으로 변환합니다. + *

+ * 도메인 진입점에서 방어 로직을 제공하여 NPE를 방지합니다. + *

* * @param genderStr 성별 문자열 * @return Gender enum - * @throws CoreException gender 값이 유효하지 않은 경우 + * @throws CoreException gender 값이 null이거나 유효하지 않은 경우 */ private Gender parseGender(String genderStr) { + if (genderStr == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "gender 값이 올바르지 않습니다."); + } try { String genderValue = genderStr.trim().toUpperCase(Locale.ROOT); return Gender.valueOf(genderValue); diff --git a/apps/commerce-api/src/main/java/com/loopers/config/batch/LikeCountSyncBatchConfig.java b/apps/commerce-api/src/main/java/com/loopers/config/batch/LikeCountSyncBatchConfig.java new file mode 100644 index 000000000..016ca8c0f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/config/batch/LikeCountSyncBatchConfig.java @@ -0,0 +1,171 @@ +package com.loopers.config.batch; + +import com.loopers.domain.like.LikeRepository; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.batch.item.ItemProcessor; +import org.springframework.batch.item.ItemReader; +import org.springframework.batch.item.ItemWriter; +import org.springframework.batch.item.support.ListItemReader; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.PlatformTransactionManager; + +import java.util.List; +import java.util.Map; + +/** + * 좋아요 수 동기화 배치 Job Configuration. + *

+ * Spring Batch를 사용하여 Like 테이블의 COUNT(*) 결과를 Product.likeCount 필드에 동기화합니다. + *

+ *

+ * 배치 구조: + *

    + *
  1. Reader: 모든 상품 ID 조회
  2. + *
  3. Processor: 각 상품의 좋아요 수 집계 (Like 테이블 COUNT(*))
  4. + *
  5. Writer: Product.likeCount 필드 업데이트
  6. + *
+ *

+ *

+ * 설계 근거: + *

    + *
  • 대량 처리: Spring Batch의 청크 단위 처리로 성능 최적화
  • + *
  • 트랜잭션 관리: 청크 단위로 커밋하여 안정성 보장
  • + *
  • 재시작 가능: Job 실패 시 재시작 가능
  • + *
  • 모니터링: Spring Batch 메타데이터로 실행 이력 추적
  • + *
+ *

+ * + * @author Loopers + * @version 1.0 + */ +@Slf4j +@RequiredArgsConstructor +@Configuration +public class LikeCountSyncBatchConfig { + + private final JobRepository jobRepository; + private final PlatformTransactionManager transactionManager; + private final ProductRepository productRepository; + private final LikeRepository likeRepository; + + private static final int CHUNK_SIZE = 100; // 청크 크기: 100개씩 처리 + + /** + * 좋아요 수 동기화 Job을 생성합니다. + * + * @return 좋아요 수 동기화 Job + */ + @Bean + public Job likeCountSyncJob() { + return new JobBuilder("likeCountSyncJob", jobRepository) + .start(likeCountSyncStep()) + .build(); + } + + /** + * 좋아요 수 동기화 Step을 생성합니다. + *

+ * allowStartIfComplete(true) 설정: + *

    + *
  • 주기적 실행: 스케줄러에서 주기적으로 실행할 수 있도록 완료된 Step도 재실행 가능
  • + *
  • 고정된 JobParameters: 고정된 JobParameters를 사용하므로 완료된 JobInstance도 재실행 필요
  • + *
+ *

+ * + * @return 좋아요 수 동기화 Step + */ + @Bean + public Step likeCountSyncStep() { + return new StepBuilder("likeCountSyncStep", jobRepository) + .chunk(CHUNK_SIZE, transactionManager) + .reader(productIdReader()) + .processor(productLikeCountProcessor()) + .writer(productLikeCountWriter()) + .allowStartIfComplete(true) // ✅ 완료된 Step도 재실행 가능 (스케줄러에서 주기적 실행) + .build(); + } + + /** + * 모든 상품 ID를 읽어오는 Reader를 생성합니다. + *

+ * @StepScope 사용 이유: + *

    + *
  • 최신 데이터 보장: 매 Step 실행 시마다 Reader가 새로 생성되어 최신 상품 ID 목록 조회
  • + *
  • 신규 상품 포함: 애플리케이션 기동 이후 생성된 상품도 배치 Job 처리 대상에 포함
  • + *
  • 싱글톤 스코프 문제 해결: @Bean 기본 스코프(싱글톤)로 인한 스냅샷 고정 문제 방지
  • + *
+ *

+ *

+ * 동작 원리: + *

    + *
  • @StepScope는 Step 실행 시마다 Bean을 새로 생성
  • + *
  • 매번 productRepository.findAllProductIds()를 호출하여 최신 상품 ID 목록 조회
  • + *
  • 스케줄러가 주기적으로 Job을 실행해도 항상 최신 상품 목록 기준으로 동기화
  • + *
+ *

+ * + * @return 상품 ID Reader + */ + @Bean + @StepScope + public ItemReader productIdReader() { + List productIds = productRepository.findAllProductIds(); + log.debug("좋아요 수 동기화 대상 상품 수: {}", productIds.size()); + return new ListItemReader<>(productIds); + } + + /** + * 상품 ID로부터 좋아요 수를 집계하는 Processor를 생성합니다. + * + * @return 상품 좋아요 수 Processor + */ + @Bean + public ItemProcessor productLikeCountProcessor() { + return productId -> { + // Like 테이블에서 해당 상품의 좋아요 수 집계 + Map likeCountMap = likeRepository.countByProductIds(List.of(productId)); + Long likeCount = likeCountMap.getOrDefault(productId, 0L); + return new ProductLikeCount(productId, likeCount); + }; + } + + /** + * Product.likeCount 필드를 업데이트하는 Writer를 생성합니다. + * + * @return 상품 좋아요 수 Writer + */ + @Bean + public ItemWriter productLikeCountWriter() { + return items -> { + for (ProductLikeCount item : items) { + try { + productRepository.updateLikeCount(item.productId(), item.likeCount()); + } catch (Exception e) { + log.warn("상품 좋아요 수 업데이트 실패: productId={}, likeCount={}, error={}", + item.productId(), item.likeCount(), e.getMessage()); + // 개별 실패는 로그만 남기고 계속 진행 + } + } + }; + } + + /** + * 상품 ID와 좋아요 수를 담는 레코드. + * + * @param productId 상품 ID + * @param likeCount 좋아요 수 + */ + public record ProductLikeCount(Long productId, Long likeCount) { + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java index 611b27a2c..b0b1f55c3 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java @@ -1,6 +1,8 @@ package com.loopers.domain.brand; import com.loopers.domain.BaseEntity; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.Table; @@ -22,18 +24,32 @@ @NoArgsConstructor(access = AccessLevel.PROTECTED) @Getter public class Brand extends BaseEntity { - @Column(name = "name") + @Column(name = "name", nullable = false) private String name; /** * Brand 인스턴스를 생성합니다. * * @param name 브랜드 이름 + * @throws CoreException name이 null이거나 공백일 경우 */ public Brand(String name) { + validateName(name); this.name = name; } + /** + * 브랜드 이름의 유효성을 검증합니다. + * + * @param name 검증할 브랜드 이름 + * @throws CoreException name이 null이거나 공백일 경우 + */ + private void validateName(String name) { + if (name == null || name.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "브랜드 이름은 필수입니다."); + } + } + /** * Brand 인스턴스를 생성하는 정적 팩토리 메서드. * 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 index df55a0780..03d6db682 100644 --- 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 @@ -1,5 +1,6 @@ package com.loopers.domain.brand; +import java.util.List; import java.util.Optional; /** @@ -27,5 +28,16 @@ public interface BrandRepository { * @return 조회된 브랜드를 담은 Optional */ Optional findById(Long brandId); + + /** + * 브랜드 ID 목록으로 브랜드 목록을 조회합니다. + *

+ * 배치 조회를 통해 N+1 쿼리 문제를 해결합니다. + *

+ * + * @param brandIds 조회할 브랜드 ID 목록 + * @return 조회된 브랜드 목록 + */ + List findAllById(List brandIds); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/Coupon.java b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/Coupon.java new file mode 100644 index 000000000..b02c07333 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/Coupon.java @@ -0,0 +1,137 @@ +package com.loopers.domain.coupon; + +import com.loopers.domain.BaseEntity; +import com.loopers.domain.coupon.discount.CouponDiscountStrategy; +import com.loopers.domain.coupon.discount.CouponDiscountStrategyFactory; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 쿠폰 도메인 엔티티. + *

+ * 쿠폰의 기본 정보(코드, 타입, 할인 금액/비율)를 관리합니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +@Entity +@Table(name = "coupon") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +public class Coupon extends BaseEntity { + @Column(name = "code", unique = true, nullable = false, length = 50) + private String code; + + @Enumerated(EnumType.STRING) + @Column(name = "type", nullable = false) + private CouponType type; + + @Column(name = "discount_value", nullable = false) + private Integer discountValue; + + /** + * Coupon 인스턴스를 생성합니다. + * + * @param code 쿠폰 코드 (필수, 최대 50자) + * @param type 쿠폰 타입 (필수) + * @param discountValue 할인 값 (필수, 0 초과) + * - FIXED_AMOUNT: 할인 금액 + * - PERCENTAGE: 할인 비율 (0-100) + * @throws CoreException 유효성 검증 실패 시 + */ + public Coupon(String code, CouponType type, Integer discountValue) { + validateCode(code); + validateType(type); + validateDiscountValue(type, discountValue); + this.code = code; + this.type = type; + this.discountValue = discountValue; + } + + /** + * Coupon 인스턴스를 생성하는 정적 팩토리 메서드. + * + * @param code 쿠폰 코드 + * @param type 쿠폰 타입 + * @param discountValue 할인 값 + * @return 생성된 Coupon 인스턴스 + * @throws CoreException 유효성 검증 실패 시 + */ + public static Coupon of(String code, CouponType type, Integer discountValue) { + return new Coupon(code, type, discountValue); + } + + /** + * 쿠폰 코드의 유효성을 검증합니다. + * + * @param code 검증할 쿠폰 코드 + * @throws CoreException code가 null, 공백이거나 50자를 초과할 경우 + */ + private void validateCode(String code) { + if (code == null || code.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "쿠폰 코드는 필수입니다."); + } + if (code.length() > 50) { + throw new CoreException(ErrorType.BAD_REQUEST, "쿠폰 코드는 50자를 초과할 수 없습니다."); + } + } + + /** + * 쿠폰 타입의 유효성을 검증합니다. + * + * @param type 검증할 쿠폰 타입 + * @throws CoreException type이 null일 경우 + */ + private void validateType(CouponType type) { + if (type == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "쿠폰 타입은 필수입니다."); + } + } + + /** + * 할인 값의 유효성을 검증합니다. + * + * @param type 쿠폰 타입 + * @param discountValue 검증할 할인 값 + * @throws CoreException discountValue가 null이거나 유효하지 않을 경우 + */ + private void validateDiscountValue(CouponType type, Integer discountValue) { + if (discountValue == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "할인 값은 필수입니다."); + } + if (discountValue <= 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "할인 값은 0보다 커야 합니다."); + } + if (type == CouponType.PERCENTAGE && discountValue > 100) { + throw new CoreException(ErrorType.BAD_REQUEST, "정률 쿠폰의 할인 비율은 100을 초과할 수 없습니다."); + } + } + + /** + * 주문 금액에 쿠폰을 적용하여 할인 금액을 계산합니다. + *

+ * 전략 패턴을 사용하여 쿠폰 타입별 할인 계산 로직을 분리합니다. + * 새로운 쿠폰 타입이 추가되어도 기존 코드를 수정하지 않고 확장할 수 있습니다. + *

+ * + * @param orderAmount 주문 금액 + * @param strategyFactory 할인 계산 전략 팩토리 + * @return 할인 금액 + * @throws CoreException orderAmount가 null이거나 0 이하일 경우 + */ + public Integer calculateDiscountAmount(Integer orderAmount, CouponDiscountStrategyFactory strategyFactory) { + if (orderAmount == null || orderAmount <= 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "주문 금액은 0보다 커야 합니다."); + } + + // 전략 패턴을 사용하여 쿠폰 타입별 할인 계산 + CouponDiscountStrategy strategy = strategyFactory.getStrategy(this.type); + return strategy.calculateDiscountAmount(orderAmount, this.discountValue); + } +} + 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..6ffe52a9e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponRepository.java @@ -0,0 +1,39 @@ +package com.loopers.domain.coupon; + +import java.util.Optional; + +/** + * Coupon 엔티티에 대한 저장소 인터페이스. + *

+ * 쿠폰 정보의 영속성 계층과의 상호작용을 정의합니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +public interface CouponRepository { + /** + * 쿠폰을 저장합니다. + * + * @param coupon 저장할 쿠폰 + * @return 저장된 쿠폰 + */ + Coupon save(Coupon coupon); + + /** + * 쿠폰 코드로 쿠폰을 조회합니다. + * + * @param code 쿠폰 코드 + * @return 조회된 쿠폰을 담은 Optional + */ + Optional findByCode(String code); + + /** + * 쿠폰 ID로 쿠폰을 조회합니다. + * + * @param couponId 쿠폰 ID + * @return 조회된 쿠폰을 담은 Optional + */ + Optional findById(Long couponId); +} + 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..183cb31c4 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponType.java @@ -0,0 +1,23 @@ +package com.loopers.domain.coupon; + +/** + * 쿠폰 타입. + *

+ * 정액 쿠폰과 정률 쿠폰을 구분합니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +public enum CouponType { + /** + * 정액 쿠폰: 고정 금액 할인 + */ + FIXED_AMOUNT, + + /** + * 정률 쿠폰: 비율 할인 + */ + PERCENTAGE +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/UserCoupon.java b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/UserCoupon.java new file mode 100644 index 000000000..3f2e07322 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/UserCoupon.java @@ -0,0 +1,134 @@ +package com.loopers.domain.coupon; + +import com.loopers.domain.BaseEntity; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 사용자 쿠폰 도메인 엔티티. + *

+ * 사용자가 소유한 쿠폰과 사용 여부를 관리합니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +@Entity +@Table(name = "user_coupon", uniqueConstraints = { + @UniqueConstraint(name = "uk_user_coupon_user_coupon", columnNames = {"ref_user_id", "ref_coupon_id"}) +}) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +public class UserCoupon extends BaseEntity { + @Column(name = "ref_user_id", nullable = false) + private Long userId; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "ref_coupon_id", nullable = false) + private Coupon coupon; + + @Column(name = "is_used", nullable = false) + private Boolean isUsed; + + @Version + @Column(name = "version", nullable = false) + private Long version; + + /** + * UserCoupon 인스턴스를 생성합니다. + * + * @param userId 사용자 ID (필수) + * @param coupon 쿠폰 (필수) + * @throws CoreException 유효성 검증 실패 시 + */ + public UserCoupon(Long userId, Coupon coupon) { + validateUserId(userId); + validateCoupon(coupon); + this.userId = userId; + this.coupon = coupon; + this.isUsed = false; + } + + /** + * UserCoupon 인스턴스를 생성하는 정적 팩토리 메서드. + * + * @param userId 사용자 ID + * @param coupon 쿠폰 + * @return 생성된 UserCoupon 인스턴스 + * @throws CoreException 유효성 검증 실패 시 + */ + public static UserCoupon of(Long userId, Coupon coupon) { + return new UserCoupon(userId, coupon); + } + + /** + * 사용자 ID의 유효성을 검증합니다. + * + * @param userId 검증할 사용자 ID + * @throws CoreException userId가 null일 경우 + */ + private void validateUserId(Long userId) { + if (userId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "사용자 ID는 필수입니다."); + } + } + + /** + * 쿠폰의 유효성을 검증합니다. + * + * @param coupon 검증할 쿠폰 + * @throws CoreException coupon이 null일 경우 + */ + private void validateCoupon(Coupon coupon) { + if (coupon == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "쿠폰은 필수입니다."); + } + } + + /** + * 쿠폰을 사용합니다. + *

+ * 이미 사용된 쿠폰은 다시 사용할 수 없습니다. + *

+ * + * @throws CoreException 이미 사용된 쿠폰일 경우 + */ + public void use() { + if (this.isUsed) { + throw new CoreException(ErrorType.BAD_REQUEST, "이미 사용된 쿠폰입니다."); + } + this.isUsed = true; + } + + /** + * 쿠폰이 사용 가능한지 확인합니다. + * + * @return 사용 가능하면 true, 아니면 false + */ + public boolean isAvailable() { + return !this.isUsed; + } + + /** + * 쿠폰 코드를 반환합니다. + * + * @return 쿠폰 코드 + */ + public String getCouponCode() { + return coupon.getCode(); + } + + /** + * 쿠폰을 반환합니다. + * + * @return 쿠폰 + */ + public Coupon getCoupon() { + return coupon; + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/UserCouponRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/UserCouponRepository.java new file mode 100644 index 000000000..0bfd69db7 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/UserCouponRepository.java @@ -0,0 +1,52 @@ +package com.loopers.domain.coupon; + +import java.util.Optional; + +/** + * UserCoupon 엔티티에 대한 저장소 인터페이스. + *

+ * 사용자 쿠폰 정보의 영속성 계층과의 상호작용을 정의합니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +public interface UserCouponRepository { + /** + * 사용자 쿠폰을 저장합니다. + * + * @param userCoupon 저장할 사용자 쿠폰 + * @return 저장된 사용자 쿠폰 + */ + UserCoupon save(UserCoupon userCoupon); + + /** + * 사용자 ID와 쿠폰 코드로 사용자 쿠폰을 조회합니다. + * + * @param userId 사용자 ID + * @param couponCode 쿠폰 코드 + * @return 조회된 사용자 쿠폰을 담은 Optional + */ + Optional findByUserIdAndCouponCode(Long userId, String couponCode); + + /** + * 사용자 ID와 쿠폰 코드로 사용자 쿠폰을 조회합니다. (낙관적 락) + *

+ * 동시성 제어가 필요한 경우 사용합니다. (예: 쿠폰 사용) + *

+ *

+ * Lock 전략: + *

    + *
  • OPTIMISTIC_LOCK: @Version 필드를 통한 낙관적 락 사용
  • + *
  • 동시 사용 시: 한 명만 성공하고 나머지는 OptimisticLockException 발생
  • + *
  • 사용 목적: 쿠폰 사용 시 Lost Update 방지, Hot Spot 대응
  • + *
+ *

+ * + * @param userId 사용자 ID + * @param couponCode 쿠폰 코드 + * @return 조회된 사용자 쿠폰을 담은 Optional + */ + Optional findByUserIdAndCouponCodeForUpdate(Long userId, String couponCode); +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/discount/CouponDiscountStrategy.java b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/discount/CouponDiscountStrategy.java new file mode 100644 index 000000000..883ab15bd --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/discount/CouponDiscountStrategy.java @@ -0,0 +1,23 @@ +package com.loopers.domain.coupon.discount; + +/** + * 쿠폰 할인 계산 전략 인터페이스. + *

+ * 전략 패턴을 사용하여 쿠폰 타입별 할인 계산 로직을 분리합니다. + * 새로운 쿠폰 타입이 추가되어도 기존 코드를 수정하지 않고 확장할 수 있습니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +public interface CouponDiscountStrategy { + /** + * 주문 금액에 쿠폰을 적용하여 할인 금액을 계산합니다. + * + * @param orderAmount 주문 금액 + * @param discountValue 할인 값 (쿠폰 타입에 따라 의미가 다름) + * @return 할인 금액 + */ + Integer calculateDiscountAmount(Integer orderAmount, Integer discountValue); +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/discount/CouponDiscountStrategyFactory.java b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/discount/CouponDiscountStrategyFactory.java new file mode 100644 index 000000000..36bc98a7f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/discount/CouponDiscountStrategyFactory.java @@ -0,0 +1,53 @@ +package com.loopers.domain.coupon.discount; + +import com.loopers.domain.coupon.CouponType; +import org.springframework.stereotype.Component; + +import java.util.Map; + +/** + * 쿠폰 할인 계산 전략 팩토리. + *

+ * 쿠폰 타입에 따라 적절한 할인 계산 전략을 반환합니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +@Component +public class CouponDiscountStrategyFactory { + private final Map strategyMap; + + /** + * CouponDiscountStrategyFactory를 생성합니다. + * + * @param fixedAmountStrategy 정액 쿠폰 전략 + * @param percentageStrategy 정률 쿠폰 전략 + */ + public CouponDiscountStrategyFactory( + FixedAmountDiscountStrategy fixedAmountStrategy, + PercentageDiscountStrategy percentageStrategy + ) { + this.strategyMap = Map.of( + CouponType.FIXED_AMOUNT, fixedAmountStrategy, + CouponType.PERCENTAGE, percentageStrategy + ); + } + + /** + * 쿠폰 타입에 해당하는 할인 계산 전략을 반환합니다. + * + * @param type 쿠폰 타입 + * @return 할인 계산 전략 + * @throws IllegalArgumentException 지원하지 않는 쿠폰 타입인 경우 + */ + public CouponDiscountStrategy getStrategy(CouponType type) { + CouponDiscountStrategy strategy = strategyMap.get(type); + if (strategy == null) { + throw new IllegalArgumentException( + String.format("지원하지 않는 쿠폰 타입입니다. (타입: %s)", type)); + } + return strategy; + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/discount/FixedAmountDiscountStrategy.java b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/discount/FixedAmountDiscountStrategy.java new file mode 100644 index 000000000..f7a25a11a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/discount/FixedAmountDiscountStrategy.java @@ -0,0 +1,32 @@ +package com.loopers.domain.coupon.discount; + +import org.springframework.stereotype.Component; + +/** + * 정액 쿠폰 할인 계산 전략. + *

+ * 고정 금액을 할인하며, 할인 금액이 주문 금액을 초과하지 않도록 보장합니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +@Component +public class FixedAmountDiscountStrategy implements CouponDiscountStrategy { + /** + * {@inheritDoc} + *

+ * 정액 쿠폰: 할인 금액이 주문 금액을 초과하지 않도록 제한합니다. + *

+ * + * @param orderAmount 주문 금액 + * @param discountValue 할인 금액 + * @return 할인 금액 (주문 금액을 초과하지 않음) + */ + @Override + public Integer calculateDiscountAmount(Integer orderAmount, Integer discountValue) { + // 할인 금액이 주문 금액을 초과하지 않도록 + return Math.min(discountValue, orderAmount); + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/discount/PercentageDiscountStrategy.java b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/discount/PercentageDiscountStrategy.java new file mode 100644 index 000000000..c8b0a87e8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/discount/PercentageDiscountStrategy.java @@ -0,0 +1,32 @@ +package com.loopers.domain.coupon.discount; + +import org.springframework.stereotype.Component; + +/** + * 정률 쿠폰 할인 계산 전략. + *

+ * 주문 금액의 일정 비율을 할인합니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +@Component +public class PercentageDiscountStrategy implements CouponDiscountStrategy { + /** + * {@inheritDoc} + *

+ * 정률 쿠폰: 주문 금액의 할인 비율만큼 할인합니다. + *

+ * + * @param orderAmount 주문 금액 + * @param discountValue 할인 비율 (0-100) + * @return 할인 금액 + */ + @Override + public Integer calculateDiscountAmount(Integer orderAmount, Integer discountValue) { + // 주문 금액의 할인 비율만큼 할인 + return (int) Math.round(orderAmount * discountValue / 100.0); + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java index 326de17ed..d6d78bd1b 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java @@ -4,6 +4,7 @@ import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; @@ -18,7 +19,15 @@ * @version 1.0 */ @Entity -@Table(name = "`like`") +@Table( + name = "`like`", + uniqueConstraints = { + @UniqueConstraint( + name = "uk_like_user_product", + columnNames = {"ref_user_id", "ref_product_id"} + ) + } +) @NoArgsConstructor(access = AccessLevel.PROTECTED) @Getter public class Like extends BaseEntity { 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 index fbc2976c1..96b09f43e 100644 --- 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 @@ -53,5 +53,15 @@ public interface LikeRepository { * @return 상품 ID를 키로, 좋아요 수를 값으로 하는 Map */ Map countByProductIds(List productIds); + + /** + * 모든 상품의 좋아요 수를 집계합니다. + *

+ * 비동기 집계 스케줄러에서 사용됩니다. + *

+ * + * @return 상품 ID를 키로, 좋아요 수를 값으로 하는 Map + */ + Map countAllByProductIds(); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java index 4c0a59214..78b5e1049 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java @@ -36,6 +36,12 @@ public class Order extends BaseEntity { @Column(name = "total_amount", nullable = false) private Integer totalAmount; + @Column(name = "coupon_code", length = 50) + private String couponCode; + + @Column(name = "discount_amount") + private Integer discountAmount; + @JdbcTypeCode(SqlTypes.JSON) @Column(name = "items", nullable = false, columnDefinition = "json") private List items; @@ -45,14 +51,21 @@ public class Order extends BaseEntity { * * @param userId 사용자 ID * @param items 주문 아이템 목록 + * @param couponCode 쿠폰 코드 (선택) + * @param discountAmount 할인 금액 (선택) * @throws CoreException items가 null이거나 비어있을 경우 */ - public Order(Long userId, List items) { + public Order(Long userId, List items, String couponCode, Integer discountAmount) { validateUserId(userId); validateItems(items); this.userId = userId; - this.items = items; - this.totalAmount = calculateTotalAmount(items); + // ✅ 방어적 복사로 불변 리스트 생성 (총액과 아이템의 일관성 보장) + List immutableItems = List.copyOf(items); + this.items = immutableItems; + Integer subtotal = calculateTotalAmount(immutableItems); + this.discountAmount = discountAmount != null ? discountAmount : 0; + this.totalAmount = Math.max(0, subtotal - this.discountAmount); + this.couponCode = couponCode; this.status = OrderStatus.PENDING; } @@ -64,7 +77,20 @@ public Order(Long userId, List items) { * @return 생성된 Order 인스턴스 */ public static Order of(Long userId, List items) { - return new Order(userId, items); + return new Order(userId, items, null, null); + } + + /** + * Order 인스턴스를 생성하는 정적 팩토리 메서드 (쿠폰 포함). + * + * @param userId 사용자 ID + * @param items 주문 아이템 목록 + * @param couponCode 쿠폰 코드 + * @param discountAmount 할인 금액 + * @return 생성된 Order 인스턴스 + */ + public static Order of(Long userId, List items, String couponCode, Integer discountAmount) { + return new Order(userId, items, couponCode, discountAmount); } /** @@ -118,10 +144,10 @@ public void complete() { /** * 주문을 취소 상태로 변경합니다. * 상태 변경만 수행하며, 포인트 환불은 도메인 서비스에서 처리합니다. - * PENDING 상태의 주문만 취소할 수 있습니다. + * PENDING 또는 COMPLETED 상태의 주문만 취소할 수 있습니다. */ public void cancel() { - if (this.status != OrderStatus.PENDING) { + if (this.status != OrderStatus.PENDING && this.status != OrderStatus.COMPLETED) { throw new CoreException(ErrorType.BAD_REQUEST, String.format("취소할 수 없는 주문 상태입니다. (현재 상태: %s)", this.status)); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java index 8e18bc60d..0b38e00b9 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java @@ -15,7 +15,6 @@ */ @Getter @EqualsAndHashCode -@Embeddable public class OrderItem { private Long productId; private String name; diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java index 52315a26e..8e56d4e20 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java @@ -35,6 +35,9 @@ public class Product extends BaseEntity { @Column(name = "ref_brand_id", nullable = false) private Long brandId; + @Column(name = "like_count", nullable = false) + private Long likeCount; + /** * Product 인스턴스를 생성합니다. * @@ -53,6 +56,7 @@ public Product(String name, Integer price, Integer stock, Long brandId) { this.price = price; this.stock = stock; this.brandId = brandId; + this.likeCount = 0L; } /** @@ -156,5 +160,21 @@ private void validateQuantity(Integer quantity) { throw new CoreException(ErrorType.BAD_REQUEST, "수량은 0보다 커야 합니다."); } } + + /** + * 좋아요 수를 업데이트합니다. + *

+ * 비동기 집계 스케줄러에서 사용됩니다. + *

+ * + * @param likeCount 업데이트할 좋아요 수 (0 이상) + * @throws CoreException likeCount가 null이거나 음수일 경우 + */ + public void updateLikeCount(Long likeCount) { + if (likeCount == null || likeCount < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "좋아요 수는 0 이상이어야 합니다."); + } + this.likeCount = likeCount; + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductDetail.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductDetail.java index 190b4a00d..24a7e3c4c 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductDetail.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductDetail.java @@ -49,5 +49,43 @@ private ProductDetail(Long id, String name, Integer price, Integer stock, Long b public static ProductDetail of(Long id, String name, Integer price, Integer stock, Long brandId, String brandName, Long likesCount) { return new ProductDetail(id, name, price, stock, brandId, brandName, likesCount); } + + /** + * Product, 브랜드 이름, 좋아요 수로부터 ProductDetail을 생성하는 정적 팩토리 메서드. + *

+ * 상품 상세 조회 시 Product와 브랜드 이름, 좋아요 수를 조합하여 ProductDetail을 생성합니다. + *

+ *

+ * Aggregate 경계 준수: Brand Aggregate 엔티티 대신 필요한 값(brandName)만 전달하여 + * Aggregate 간 직접 참조를 피합니다. + *

+ * + * @param product 상품 엔티티 + * @param brandName 브랜드 이름 + * @param likesCount 좋아요 수 + * @return 생성된 ProductDetail 인스턴스 + * @throws IllegalArgumentException product, brandName, likesCount가 null인 경우 + */ + public static ProductDetail from(Product product, String brandName, Long likesCount) { + if (product == null) { + throw new IllegalArgumentException("상품은 null일 수 없습니다."); + } + if (brandName == null) { + throw new IllegalArgumentException("브랜드 이름은 필수입니다."); + } + if (likesCount == null) { + throw new IllegalArgumentException("좋아요 수는 null일 수 없습니다."); + } + + return ProductDetail.of( + product.getId(), + product.getName(), + product.getPrice(), + product.getStock(), + product.getBrandId(), + brandName, + likesCount + ); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java index ca4837bd9..425ce977c 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java @@ -29,6 +29,36 @@ public interface ProductRepository { */ Optional findById(Long productId); + /** + * 상품 ID로 상품을 조회합니다. (비관적 락) + *

+ * 동시성 제어가 필요한 경우 사용합니다. (예: 재고 차감) + *

+ *

+ * Lock 전략: + *

    + *
  • PESSIMISTIC_WRITE: SELECT ... FOR UPDATE 사용
  • + *
  • Lock 범위: PK(id) 기반 조회로 해당 행만 락 (최소화)
  • + *
  • 사용 목적: 재고 차감 시 Lost Update 방지
  • + *
+ *

+ * + * @param productId 조회할 상품 ID + * @return 조회된 상품을 담은 Optional + */ + Optional findByIdForUpdate(Long productId); + + /** + * 상품 ID 목록으로 상품 목록을 조회합니다. + *

+ * 배치 조회를 통해 N+1 쿼리 문제를 해결합니다. + *

+ * + * @param productIds 조회할 상품 ID 목록 + * @return 조회된 상품 목록 + */ + List findAllById(List productIds); + /** * 상품 목록을 조회합니다. * @@ -47,5 +77,26 @@ public interface ProductRepository { * @return 상품 총 개수 */ long countAll(Long brandId); + + /** + * 상품의 좋아요 수를 업데이트합니다. + *

+ * 비동기 집계 스케줄러에서 사용됩니다. + *

+ * + * @param productId 상품 ID + * @param likeCount 업데이트할 좋아요 수 + */ + void updateLikeCount(Long productId, Long likeCount); + + /** + * 모든 상품 ID를 조회합니다. + *

+ * Spring Batch Reader에서 사용됩니다. + *

+ * + * @return 모든 상품 ID 목록 + */ + List findAllProductIds(); } 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 index e2fffb58f..09d47afe2 100644 --- 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 @@ -25,4 +25,23 @@ public interface UserRepository { * @return 조회된 사용자, 없으면 null */ User findByUserId(String userId); + + /** + * 사용자 ID로 사용자를 조회합니다. (비관적 락) + *

+ * 동시성 제어가 필요한 경우 사용합니다. (예: 포인트 차감) + *

+ *

+ * Lock 전략: + *

    + *
  • PESSIMISTIC_WRITE: SELECT ... FOR UPDATE 사용
  • + *
  • Lock 범위: UNIQUE(userId) 인덱스 기반 조회로 해당 행만 락 (최소화)
  • + *
  • 사용 목적: 포인트 차감 시 Lost Update 방지
  • + *
+ *

+ * + * @param userId 조회할 사용자 ID + * @return 조회된 사용자, 없으면 null + */ + User findByUserIdForUpdate(String userId); } 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 index 7e616b95b..79f186e9a 100644 --- 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 @@ -5,6 +5,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; +import java.util.List; import java.util.Optional; /** @@ -24,5 +25,10 @@ public Brand save(Brand brand) { public Optional findById(Long brandId) { return brandJpaRepository.findById(brandId); } + + @Override + public List findAllById(List brandIds) { + return brandJpaRepository.findAllById(brandIds); + } } 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..bd4eb6ee9 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponJpaRepository.java @@ -0,0 +1,26 @@ +package com.loopers.infrastructure.coupon; + +import com.loopers.domain.coupon.Coupon; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +/** + * Coupon 엔티티를 위한 Spring Data JPA 리포지토리. + *

+ * JpaRepository를 확장하여 기본 CRUD 기능을 제공합니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +public interface CouponJpaRepository extends JpaRepository { + /** + * 쿠폰 코드로 쿠폰을 조회합니다. + * + * @param code 쿠폰 코드 + * @return 조회된 쿠폰을 담은 Optional + */ + Optional findByCode(String code); +} + 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..3f4af5fdb --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponRepositoryImpl.java @@ -0,0 +1,49 @@ +package com.loopers.infrastructure.coupon; + +import com.loopers.domain.coupon.Coupon; +import com.loopers.domain.coupon.CouponRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.Optional; + +/** + * CouponRepository의 JPA 구현체. + *

+ * Spring Data JPA를 활용하여 Coupon 엔티티의 + * 영속성 작업을 처리합니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +@RequiredArgsConstructor +@Component +public class CouponRepositoryImpl implements CouponRepository { + private final CouponJpaRepository couponJpaRepository; + + /** + * {@inheritDoc} + */ + @Override + public Coupon save(Coupon coupon) { + return couponJpaRepository.save(coupon); + } + + /** + * {@inheritDoc} + */ + @Override + public Optional findByCode(String code) { + return couponJpaRepository.findByCode(code); + } + + /** + * {@inheritDoc} + */ + @Override + public Optional findById(Long couponId) { + return couponJpaRepository.findById(couponId); + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/UserCouponJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/UserCouponJpaRepository.java new file mode 100644 index 000000000..710f74a74 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/UserCouponJpaRepository.java @@ -0,0 +1,61 @@ +package com.loopers.infrastructure.coupon; + +import com.loopers.domain.coupon.UserCoupon; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.Optional; + +/** + * UserCoupon 엔티티를 위한 Spring Data JPA 리포지토리. + *

+ * JpaRepository를 확장하여 기본 CRUD 기능을 제공합니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +public interface UserCouponJpaRepository extends JpaRepository { + /** + * 사용자 ID와 쿠폰 코드로 사용자 쿠폰을 조회합니다. + * + * @param userId 사용자 ID + * @param couponCode 쿠폰 코드 + * @return 조회된 사용자 쿠폰을 담은 Optional + */ + @Query("SELECT uc FROM UserCoupon uc JOIN uc.coupon c WHERE uc.userId = :userId AND c.code = :couponCode") + Optional findByUserIdAndCouponCode(@Param("userId") Long userId, @Param("couponCode") String couponCode); + + /** + * 사용자 ID와 쿠폰 코드로 사용자 쿠폰을 조회합니다. + *

+ * Optimistic Lock을 사용하여 동시성 제어를 보장합니다. + * UserCoupon 엔티티의 @Version 필드를 통해 자동으로 낙관적 락이 적용됩니다. + *

+ *

+ * Lock 전략: + *

    + *
  • OPTIMISTIC_LOCK 선택 근거: 쿠폰 사용 시 Lost Update 방지, Hot Spot 대응
  • + *
  • @Version 필드: 엔티티에 version 필드가 있어 자동으로 낙관적 락 적용
  • + *
  • 동시 사용 시: 한 명만 성공하고 나머지는 OptimisticLockException 발생
  • + *
+ *

+ *

+ * 동작 원리: + *

    + *
  1. 일반 조회로 UserCoupon 엔티티 로드 (version 포함)
  2. + *
  3. 쿠폰 사용 처리 (isUsed = true)
  4. + *
  5. 저장 시 version 체크 → 다른 트랜잭션이 먼저 수정했다면 OptimisticLockException 발생
  6. + *
  7. 예외 발생 시 쿠폰 사용 실패 처리
  8. + *
+ *

+ * + * @param userId 사용자 ID + * @param couponCode 쿠폰 코드 + * @return 조회된 사용자 쿠폰을 담은 Optional + */ + @Query("SELECT uc FROM UserCoupon uc JOIN uc.coupon c WHERE uc.userId = :userId AND c.code = :couponCode") + Optional findByUserIdAndCouponCodeForUpdate(@Param("userId") Long userId, @Param("couponCode") String couponCode); +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/UserCouponRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/UserCouponRepositoryImpl.java new file mode 100644 index 000000000..8daaf5567 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/UserCouponRepositoryImpl.java @@ -0,0 +1,49 @@ +package com.loopers.infrastructure.coupon; + +import com.loopers.domain.coupon.UserCoupon; +import com.loopers.domain.coupon.UserCouponRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.Optional; + +/** + * UserCouponRepository의 JPA 구현체. + *

+ * Spring Data JPA를 활용하여 UserCoupon 엔티티의 + * 영속성 작업을 처리합니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +@RequiredArgsConstructor +@Component +public class UserCouponRepositoryImpl implements UserCouponRepository { + private final UserCouponJpaRepository userCouponJpaRepository; + + /** + * {@inheritDoc} + */ + @Override + public UserCoupon save(UserCoupon userCoupon) { + return userCouponJpaRepository.save(userCoupon); + } + + /** + * {@inheritDoc} + */ + @Override + public Optional findByUserIdAndCouponCode(Long userId, String couponCode) { + return userCouponJpaRepository.findByUserIdAndCouponCode(userId, couponCode); + } + + /** + * {@inheritDoc} + */ + @Override + public Optional findByUserIdAndCouponCodeForUpdate(Long userId, String couponCode) { + return userCouponJpaRepository.findByUserIdAndCouponCodeForUpdate(userId, couponCode); + } +} + 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 index 3c1c3399a..61d335de8 100644 --- 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 @@ -40,6 +40,17 @@ public interface LikeJpaRepository extends JpaRepository { @Query("SELECT l.productId, COUNT(l) FROM Like l WHERE l.productId IN :productIds GROUP BY l.productId") List countByProductIds(@Param("productIds") List productIds); + /** + * 모든 상품의 좋아요 수를 집계합니다. + *

+ * 비동기 집계 스케줄러에서 사용됩니다. + *

+ * + * @return 상품 ID와 좋아요 수의 쌍 목록 + */ + @Query("SELECT l.productId, COUNT(l) FROM Like l GROUP BY l.productId") + List countAllByProductIds(); + /** * 상품별 좋아요 수를 Map으로 변환합니다. * 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 index bd169e7b2..113d524f8 100644 --- 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 @@ -41,5 +41,14 @@ public List findAllByUserId(Long userId) { public Map countByProductIds(List productIds) { return likeJpaRepository.countByProductIdsAsMap(productIds); } + + @Override + public Map countAllByProductIds() { + return likeJpaRepository.countAllByProductIds().stream() + .collect(java.util.stream.Collectors.toMap( + row -> (Long) row[0], + row -> ((Number) row[1]).longValue() + )); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java index fe294dad8..d9913a180 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java @@ -1,9 +1,16 @@ package com.loopers.infrastructure.product; import com.loopers.domain.product.Product; +import jakarta.persistence.LockModeType; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.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 java.util.List; +import java.util.Optional; /** * Product 엔티티를 위한 Spring Data JPA 리포지토리. @@ -39,5 +46,46 @@ public interface ProductJpaRepository extends JpaRepository { * @return 상품 개수 */ long countByBrandId(Long brandId); + + /** + * 상품 ID로 상품을 조회합니다. (비관적 락) + *

+ * SELECT ... FOR UPDATE를 사용하여 동시성 제어를 보장합니다. + *

+ *

+ * Lock 전략: + *

    + *
  • PESSIMISTIC_WRITE 선택 근거: 재고 차감 시 Lost Update 방지
  • + *
  • Lock 범위 최소화: PK(id) 기반 조회로 해당 행만 락
  • + *
  • 인덱스 활용: PK는 자동으로 인덱스가 생성되어 Lock 범위 최소화
  • + *
+ *

+ *

+ * 동작 원리: + *

    + *
  1. SELECT ... FOR UPDATE 실행 → 해당 행에 배타적 락 설정
  2. + *
  3. 다른 트랜잭션의 쓰기/FOR UPDATE는 차단 (일반 읽기는 가능)
  4. + *
  5. 재고 차감 후 트랜잭션 커밋 → 락 해제
  6. + *
  7. 대기 중이던 트랜잭션이 최신 값을 읽어 처리
  8. + *
+ *

+ * + * @param productId 조회할 상품 ID + * @return 조회된 상품을 담은 Optional + */ + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT p FROM Product p WHERE p.id = :productId") + Optional findByIdForUpdate(@Param("productId") Long productId); + + /** + * 모든 상품 ID를 조회합니다. + *

+ * 비동기 집계 스케줄러에서 사용됩니다. + *

+ * + * @return 모든 상품 ID 목록 + */ + @Query("SELECT p.id FROM Product p") + List findAllProductIds(); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java index f578a7d3a..29cc890f2 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java @@ -43,6 +43,22 @@ public Optional findById(Long productId) { return productJpaRepository.findById(productId); } + /** + * {@inheritDoc} + */ + @Override + public Optional findByIdForUpdate(Long productId) { + return productJpaRepository.findByIdForUpdate(productId); + } + + /** + * {@inheritDoc} + */ + @Override + public List findAllById(List productIds) { + return productJpaRepository.findAllById(productIds); + } + /** * {@inheritDoc} */ @@ -65,10 +81,30 @@ public long countAll(Long brandId) { : productJpaRepository.count(); } + /** + * {@inheritDoc} + */ + @Override + public void updateLikeCount(Long productId, Long likeCount) { + Product product = productJpaRepository.findById(productId) + .orElseThrow(() -> new IllegalArgumentException( + String.format("상품을 찾을 수 없습니다. (상품 ID: %d)", productId))); + product.updateLikeCount(likeCount); + productJpaRepository.save(product); + } + + /** + * {@inheritDoc} + */ + @Override + public List findAllProductIds() { + return productJpaRepository.findAllProductIds(); + } + private Pageable createPageable(String sort, int page, int size) { Sort sortObj = switch (sort != null ? sort : "latest") { case "price_asc" -> Sort.by(Sort.Direction.ASC, "price"); - case "likes_desc" -> Sort.by(Sort.Direction.DESC, "id"); // 좋아요 수는 별도 처리 필요 + case "likes_desc" -> Sort.by(Sort.Direction.DESC, "likeCount"); // ✅ Product.likeCount 필드로 정렬 default -> Sort.by(Sort.Direction.DESC, "createdAt"); }; return PageRequest.of(page, size, sortObj); 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 index 905189891..2de935469 100644 --- 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 @@ -1,7 +1,12 @@ package com.loopers.infrastructure.user; import com.loopers.domain.user.User; +import jakarta.persistence.LockModeType; 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 java.util.Optional; /** @@ -22,4 +27,34 @@ public interface UserJpaRepository extends JpaRepository { * @return 조회된 사용자를 담은 Optional */ Optional findByUserId(String userId); + + /** + * 사용자 ID로 사용자를 조회합니다. (비관적 락) + *

+ * SELECT ... FOR UPDATE를 사용하여 동시성 제어를 보장합니다. + *

+ *

+ * Lock 전략: + *

    + *
  • PESSIMISTIC_WRITE 선택 근거: 포인트 차감 시 Lost Update 방지
  • + *
  • Lock 범위 최소화: UNIQUE(userId) 인덱스 기반 조회로 해당 행만 락
  • + *
  • 인덱스 활용: UNIQUE 제약조건으로 인덱스가 자동 생성되어 Lock 범위 최소화
  • + *
+ *

+ *

+ * 동작 원리: + *

    + *
  1. SELECT ... FOR UPDATE 실행 → 해당 행에 배타적 락 설정
  2. + *
  3. 다른 트랜잭션의 쓰기/FOR UPDATE는 차단 (일반 읽기는 가능)
  4. + *
  5. 포인트 차감 후 트랜잭션 커밋 → 락 해제
  6. + *
  7. 대기 중이던 트랜잭션이 최신 값을 읽어 처리
  8. + *
+ *

+ * + * @param userId 조회할 사용자 ID + * @return 조회된 사용자를 담은 Optional + */ + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT u FROM User u WHERE u.userId = :userId") + Optional findByUserIdForUpdate(@Param("userId") String userId); } 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 index 25d6ead87..62d2512cf 100644 --- 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 @@ -35,4 +35,12 @@ public User save(User user) { public User findByUserId(String userId) { return userJpaRepository.findByUserId(userId).orElse(null); } + + /** + * {@inheritDoc} + */ + @Override + public User findByUserIdForUpdate(String userId) { + return userJpaRepository.findByUserIdForUpdate(userId).orElse(null); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/catalog/BrandV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/catalog/BrandV1Controller.java new file mode 100644 index 000000000..a56cc1c63 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/catalog/BrandV1Controller.java @@ -0,0 +1,39 @@ +package com.loopers.interfaces.api.catalog; + +import com.loopers.application.catalog.CatalogBrandFacade; +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; + +/** + * 브랜드 조회 API v1 컨트롤러. + *

+ * 브랜드 정보 조회 유즈케이스를 처리합니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/brands") +public class BrandV1Controller { + + private final CatalogBrandFacade catalogBrandFacade; + + /** + * 브랜드 정보를 조회합니다. + * + * @param brandId 브랜드 ID + * @return 브랜드 정보를 담은 API 응답 + */ + @GetMapping("/{brandId}") + public ApiResponse getBrand(@PathVariable Long brandId) { + CatalogBrandFacade.BrandInfo brandInfo = catalogBrandFacade.getBrand(brandId); + return ApiResponse.success(BrandV1Dto.BrandResponse.from(brandInfo)); + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/catalog/BrandV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/catalog/BrandV1Dto.java new file mode 100644 index 000000000..2bc497615 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/catalog/BrandV1Dto.java @@ -0,0 +1,30 @@ +package com.loopers.interfaces.api.catalog; + +import com.loopers.application.catalog.CatalogBrandFacade; + +/** + * 브랜드 조회 API v1의 데이터 전송 객체(DTO) 컨테이너. + * + * @author Loopers + * @version 1.0 + */ +public class BrandV1Dto { + /** + * 브랜드 정보 응답 데이터. + * + * @param brandId 브랜드 ID + * @param name 브랜드 이름 + */ + public record BrandResponse(Long brandId, String name) { + /** + * BrandInfo로부터 BrandResponse를 생성합니다. + * + * @param brandInfo 브랜드 정보 + * @return 생성된 응답 객체 + */ + public static BrandResponse from(CatalogBrandFacade.BrandInfo brandInfo) { + return new BrandResponse(brandInfo.id(), brandInfo.name()); + } + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/catalog/ProductV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/catalog/ProductV1Controller.java new file mode 100644 index 000000000..4dc38d439 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/catalog/ProductV1Controller.java @@ -0,0 +1,62 @@ +package com.loopers.interfaces.api.catalog; + +import com.loopers.application.catalog.CatalogProductFacade; +import com.loopers.application.catalog.ProductInfo; +import com.loopers.application.catalog.ProductInfoList; +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.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** + * 상품 조회 API v1 컨트롤러. + *

+ * 상품 목록 조회 및 상품 정보 조회 유즈케이스를 처리합니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/products") +public class ProductV1Controller { + + private final CatalogProductFacade catalogProductFacade; + + /** + * 상품 목록을 조회합니다. + * + * @param brandId 브랜드 ID (선택) + * @param sort 정렬 기준 (latest, price_asc, likes_desc) + * @param page 페이지 번호 (기본값 0) + * @param size 페이지당 상품 수 (기본값 20) + * @return 상품 목록을 담은 API 응답 + */ + @GetMapping + public ApiResponse getProducts( + @RequestParam(required = false) Long brandId, + @RequestParam(required = false, defaultValue = "latest") String sort, + @RequestParam(required = false, defaultValue = "0") int page, + @RequestParam(required = false, defaultValue = "20") int size + ) { + ProductInfoList result = catalogProductFacade.getProducts(brandId, sort, page, size); + return ApiResponse.success(ProductV1Dto.ProductsResponse.from(result)); + } + + /** + * 상품 정보를 조회합니다. + * + * @param productId 상품 ID + * @return 상품 정보를 담은 API 응답 + */ + @GetMapping("/{productId}") + public ApiResponse getProduct(@PathVariable Long productId) { + ProductInfo productInfo = catalogProductFacade.getProduct(productId); + return ApiResponse.success(ProductV1Dto.ProductResponse.from(productInfo)); + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/catalog/ProductV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/catalog/ProductV1Dto.java new file mode 100644 index 000000000..3661d9c9e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/catalog/ProductV1Dto.java @@ -0,0 +1,95 @@ +package com.loopers.interfaces.api.catalog; + +import com.loopers.application.catalog.ProductInfo; +import com.loopers.application.catalog.ProductInfoList; + +import java.util.List; + +/** + * 상품 조회 API v1의 데이터 전송 객체(DTO) 컨테이너. + * + * @author Loopers + * @version 1.0 + */ +public class ProductV1Dto { + /** + * 상품 정보 응답 데이터. + * + * @param productId 상품 ID + * @param name 상품 이름 + * @param price 상품 가격 + * @param stock 상품 재고 + * @param brandId 브랜드 ID + * @param likesCount 좋아요 수 + */ + public record ProductResponse( + Long productId, + String name, + Integer price, + Integer stock, + Long brandId, + Long likesCount + ) { + /** + * ProductInfo로부터 ProductResponse를 생성합니다. + * + * @param productInfo 상품 상세 정보 + * @return 생성된 응답 객체 + */ + public static ProductResponse from(ProductInfo productInfo) { + var detail = productInfo.productDetail(); + return new ProductResponse( + detail.getId(), + detail.getName(), + detail.getPrice(), + detail.getStock(), + detail.getBrandId(), + detail.getLikesCount() + ); + } + } + + /** + * 상품 목록 응답 데이터. + * + * @param products 상품 목록 + * @param totalCount 전체 상품 수 + * @param page 현재 페이지 번호 + * @param size 페이지당 상품 수 + * @param totalPages 전체 페이지 수 + * @param hasNext 다음 페이지 존재 여부 + * @param hasPrevious 이전 페이지 존재 여부 + */ + public record ProductsResponse( + List products, + long totalCount, + int page, + int size, + int totalPages, + boolean hasNext, + boolean hasPrevious + ) { + /** + * ProductInfoList로부터 ProductsResponse를 생성합니다. + * + * @param result 상품 목록 조회 결과 + * @return 생성된 응답 객체 + */ + public static ProductsResponse from(ProductInfoList result) { + List productResponses = result.products().stream() + .map(ProductResponse::from) + .toList(); + + return new ProductsResponse( + productResponses, + result.totalCount(), + result.page(), + result.size(), + result.getTotalPages(), + result.hasNext(), + result.hasPrevious() + ); + } + } +} + 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..2935b424d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Controller.java @@ -0,0 +1,76 @@ +package com.loopers.interfaces.api.like; + +import com.loopers.application.like.LikeFacade; +import com.loopers.interfaces.api.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * 좋아요 API v1 컨트롤러. + *

+ * 상품 좋아요 추가, 삭제, 목록 조회 유즈케이스를 처리합니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/like/products") +public class LikeV1Controller { + + private final LikeFacade likeFacade; + + /** + * 상품에 좋아요를 추가합니다. + * + * @param userId X-USER-ID 헤더로 전달된 사용자 ID + * @param productId 상품 ID + * @return 성공 응답 + */ + @PostMapping("/{productId}") + public ApiResponse addLike( + @RequestHeader("X-USER-ID") String userId, + @PathVariable Long productId + ) { + likeFacade.addLike(userId, productId); + return ApiResponse.success(null); + } + + /** + * 상품의 좋아요를 취소합니다. + * + * @param userId X-USER-ID 헤더로 전달된 사용자 ID + * @param productId 상품 ID + * @return 성공 응답 + */ + @DeleteMapping("/{productId}") + public ApiResponse removeLike( + @RequestHeader("X-USER-ID") String userId, + @PathVariable Long productId + ) { + likeFacade.removeLike(userId, productId); + return ApiResponse.success(null); + } + + /** + * 사용자가 좋아요한 상품 목록을 조회합니다. + * + * @param userId X-USER-ID 헤더로 전달된 사용자 ID + * @return 좋아요한 상품 목록을 담은 API 응답 + */ + @GetMapping + public ApiResponse getLikedProducts( + @RequestHeader("X-USER-ID") String userId + ) { + var likedProducts = likeFacade.getLikedProducts(userId); + return ApiResponse.success(LikeV1Dto.LikedProductsResponse.from(likedProducts)); + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Dto.java new file mode 100644 index 000000000..1fc6f20f0 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Dto.java @@ -0,0 +1,73 @@ +package com.loopers.interfaces.api.like; + +import com.loopers.application.like.LikeFacade; + +import java.util.List; + +/** + * 좋아요 API v1의 데이터 전송 객체(DTO) 컨테이너. + * + * @author Loopers + * @version 1.0 + */ +public class LikeV1Dto { + /** + * 좋아요한 상품 목록 응답 데이터. + * + * @param products 좋아요한 상품 목록 + */ + public record LikedProductsResponse( + List products + ) { + /** + * LikeFacade.LikedProduct 목록으로부터 LikedProductsResponse를 생성합니다. + * + * @param likedProducts 좋아요한 상품 목록 + * @return 생성된 응답 객체 + */ + public static LikedProductsResponse from(List likedProducts) { + return new LikedProductsResponse( + likedProducts.stream() + .map(LikedProductResponse::from) + .toList() + ); + } + } + + /** + * 좋아요한 상품 정보 응답 데이터. + * + * @param productId 상품 ID + * @param name 상품 이름 + * @param price 상품 가격 + * @param stock 상품 재고 + * @param brandId 브랜드 ID + * @param likesCount 좋아요 수 + */ + public record LikedProductResponse( + Long productId, + String name, + Integer price, + Integer stock, + Long brandId, + Long likesCount + ) { + /** + * LikeFacade.LikedProduct로부터 LikedProductResponse를 생성합니다. + * + * @param likedProduct 좋아요한 상품 정보 + * @return 생성된 응답 객체 + */ + public static LikedProductResponse from(LikeFacade.LikedProduct likedProduct) { + return new LikedProductResponse( + likedProduct.productId(), + likedProduct.name(), + likedProduct.price(), + likedProduct.stock(), + likedProduct.brandId(), + likedProduct.likesCount() + ); + } + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/purchasing/PurchasingV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/purchasing/PurchasingV1Controller.java new file mode 100644 index 000000000..744ca1b15 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/purchasing/PurchasingV1Controller.java @@ -0,0 +1,75 @@ +package com.loopers.interfaces.api.purchasing; + +import com.loopers.application.purchasing.OrderInfo; +import com.loopers.application.purchasing.PurchasingFacade; +import com.loopers.interfaces.api.ApiResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +/** + * 주문 API v1 컨트롤러. + */ +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/orders") +public class PurchasingV1Controller { + + private final PurchasingFacade purchasingFacade; + + /** + * 주문을 생성한다. + * + * @param userId X-USER-ID 헤더 + * @param request 주문 생성 요청 + * @return 생성된 주문 정보 + */ + @PostMapping + public ApiResponse createOrder( + @RequestHeader("X-USER-ID") String userId, + @Valid @RequestBody PurchasingV1Dto.CreateRequest request + ) { + OrderInfo orderInfo = purchasingFacade.createOrder(userId, request.toCommands()); + return ApiResponse.success(PurchasingV1Dto.OrderResponse.from(orderInfo)); + } + + /** + * 현재 사용자의 주문 목록을 조회한다. + * + * @param userId X-USER-ID 헤더 + * @return 주문 목록 + */ + @GetMapping + public ApiResponse getOrders( + @RequestHeader("X-USER-ID") String userId + ) { + List orderInfos = purchasingFacade.getOrders(userId); + return ApiResponse.success(PurchasingV1Dto.OrdersResponse.from(orderInfos)); + } + + /** + * 현재 사용자의 단일 주문을 조회한다. + * + * @param userId X-USER-ID 헤더 + * @param orderId 주문 ID + * @return 주문 상세 정보 + */ + @GetMapping("/{orderId}") + public ApiResponse getOrder( + @RequestHeader("X-USER-ID") String userId, + @PathVariable Long orderId + ) { + OrderInfo orderInfo = purchasingFacade.getOrder(userId, orderId); + return ApiResponse.success(PurchasingV1Dto.OrderResponse.from(orderInfo)); + } +} + + diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/purchasing/PurchasingV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/purchasing/PurchasingV1Dto.java new file mode 100644 index 000000000..ce278fc49 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/purchasing/PurchasingV1Dto.java @@ -0,0 +1,122 @@ +package com.loopers.interfaces.api.purchasing; + +import com.loopers.application.purchasing.OrderInfo; +import com.loopers.application.purchasing.OrderItemCommand; +import com.loopers.application.purchasing.OrderItemInfo; +import com.loopers.domain.order.OrderStatus; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; + +import java.util.List; + +public final class PurchasingV1Dto { + + private PurchasingV1Dto() { + } + + /** + * 주문 생성 요청 DTO. + */ + public record CreateRequest( + @NotEmpty(message = "주문 상품은 1개 이상이어야 합니다.") + List<@Valid ItemRequest> items + ) { + public List toCommands() { + return items.stream() + .map(item -> OrderItemCommand.of(item.productId(), item.quantity())) + .toList(); + } + } + + /** + * 주문 생성 요청 아이템 DTO. + */ + public record ItemRequest( + @NotNull(message = "상품 ID는 필수입니다.") + Long productId, + + @NotNull(message = "상품 수량은 필수입니다.") + @Min(value = 1, message = "상품 수량은 1개 이상이어야 합니다.") + Integer quantity + ) { + } + + /** + * 주문 응답 DTO. + */ + public record OrderResponse( + Long orderId, + Long userId, + Integer totalAmount, + OrderStatus status, + List items + ) { + /** + * OrderInfo로부터 OrderResponse를 생성합니다. + * + * @param orderInfo 주문 정보 + * @return 생성된 응답 객체 + */ + public static OrderResponse from(OrderInfo orderInfo) { + List itemResponses = orderInfo.items().stream() + .map(OrderItemResponse::from) + .toList(); + + return new OrderResponse( + orderInfo.orderId(), + orderInfo.userId(), + orderInfo.totalAmount(), + orderInfo.status(), + itemResponses + ); + } + } + + /** + * 주문 아이템 응답 DTO. + */ + public record OrderItemResponse( + Long productId, + String name, + Integer price, + Integer quantity + ) { + /** + * OrderItemInfo로부터 OrderItemResponse를 생성합니다. + * + * @param itemInfo 주문 아이템 정보 + * @return 생성된 응답 객체 + */ + public static OrderItemResponse from(OrderItemInfo itemInfo) { + return new OrderItemResponse( + itemInfo.productId(), + itemInfo.name(), + itemInfo.price(), + itemInfo.quantity() + ); + } + } + + /** + * 주문 목록 응답 DTO. + */ + public record OrdersResponse(List orders) { + /** + * OrderInfo 목록으로부터 OrdersResponse를 생성합니다. + * + * @param orderInfos 주문 정보 목록 + * @return 생성된 응답 객체 + */ + public static OrdersResponse from(List orderInfos) { + return new OrdersResponse( + orderInfos.stream() + .map(OrderResponse::from) + .toList() + ); + } + } +} + + diff --git a/apps/commerce-api/src/main/resources/application.yml b/apps/commerce-api/src/main/resources/application.yml index 484c070d0..0f9239776 100644 --- a/apps/commerce-api/src/main/resources/application.yml +++ b/apps/commerce-api/src/main/resources/application.yml @@ -23,6 +23,11 @@ spring: - redis.yml - logging.yml - monitoring.yml + batch: + jdbc: + initialize-schema: always # Spring Batch 메타데이터 테이블 자동 생성 (임시: production 배포 전 EDA로 교체 예정) + job: + enabled: false # 스케줄러에서 수동 실행하므로 자동 실행 비활성화 springdoc: use-fqn: true diff --git a/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeConcurrencyTest.java b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeConcurrencyTest.java new file mode 100644 index 000000000..1e7b42394 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeConcurrencyTest.java @@ -0,0 +1,336 @@ +package com.loopers.application.like; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandRepository; +import com.loopers.domain.like.LikeRepository; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; +import com.loopers.domain.user.Gender; +import com.loopers.domain.user.Point; +import com.loopers.domain.user.User; +import com.loopers.domain.user.UserRepository; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import com.loopers.testcontainers.MySqlTestContainersConfig; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * LikeFacade 동시성 테스트 + *

+ * 여러 스레드에서 동시에 좋아요 요청을 보내도 데이터 일관성이 유지되는지 검증합니다. + *

+ */ +@SpringBootTest +@Import(MySqlTestContainersConfig.class) +@DisplayName("LikeFacade 동시성 테스트") +class LikeFacadeConcurrencyTest { + + @Autowired + private LikeFacade likeFacade; + + @Autowired + private UserRepository userRepository; + + @Autowired + private ProductRepository productRepository; + + @Autowired + private BrandRepository brandRepository; + + @Autowired + private LikeRepository likeRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + private User createAndSaveUser(String userId, String email, long point) { + User user = User.of(userId, email, "1990-01-01", Gender.MALE, Point.of(point)); + return userRepository.save(user); + } + + private Brand createAndSaveBrand(String brandName) { + Brand brand = Brand.of(brandName); + return brandRepository.save(brand); + } + + private Product createAndSaveProduct(String productName, int price, int stock, Long brandId) { + Product product = Product.of(productName, price, stock, brandId); + return productRepository.save(product); + } + + /** + * 상품들의 좋아요 수를 동기화합니다. + *

+ * 테스트에서 비동기 스케줄러를 기다리지 않고 직접 like count를 업데이트하기 위해 사용합니다. + *

+ * + * @param productIds 동기화할 상품 ID 목록 + */ + private void syncLikeCounts(List productIds) { + Map likeCountMap = likeRepository.countByProductIds(productIds); + for (Long productId : productIds) { + Long likeCount = likeCountMap.getOrDefault(productId, 0L); + productRepository.updateLikeCount(productId, likeCount); + } + } + + @Test + @DisplayName("동일한 상품에 대해 여러명이 좋아요를 요청해도, 상품의 좋아요 개수가 정상 반영되어야 한다") + void concurrencyTest_likeShouldBeProperlyCounted() throws InterruptedException { + // arrange + Brand brand = createAndSaveBrand("테스트 브랜드"); + Product product = createAndSaveProduct("테스트 상품", 10_000, 100, brand.getId()); + Long productId = product.getId(); + + int userCount = 10; + List users = new ArrayList<>(); + for (int i = 0; i < userCount; i++) { + users.add(createAndSaveUser("user" + i, "user" + i + "@example.com", 0L)); + } + + ExecutorService executorService = Executors.newFixedThreadPool(userCount); + CountDownLatch latch = new CountDownLatch(userCount); + AtomicInteger successCount = new AtomicInteger(0); + List exceptions = new ArrayList<>(); + + // act + for (User user : users) { + executorService.submit(() -> { + try { + likeFacade.addLike(user.getUserId(), productId); + successCount.incrementAndGet(); + } catch (Exception e) { + synchronized (exceptions) { + exceptions.add(e); + } + } finally { + latch.countDown(); + } + }); + } + latch.await(); + executorService.shutdown(); + + // assert + long actualLikesCount = likeRepository.countByProductIds(List.of(productId)) + .getOrDefault(productId, 0L); + + assertThat(actualLikesCount).isEqualTo(userCount); + assertThat(successCount.get()).isEqualTo(userCount); + assertThat(exceptions).isEmpty(); + } + + @Test + @DisplayName("동일한 사용자가 동시에 여러번 좋아요를 요청해도, 정상적으로 카운트되어야 한다") + void concurrencyTest_sameUserMultipleRequests_shouldBeCountedCorrectly() throws InterruptedException { + // arrange + Brand brand = createAndSaveBrand("테스트 브랜드"); + Product product = createAndSaveProduct("테스트 상품", 10_000, 100, brand.getId()); + Long productId = product.getId(); + User user = createAndSaveUser("testuser", "test@example.com", 0L); + String userId = user.getUserId(); + + int concurrentRequestCount = 10; + ExecutorService executorService = Executors.newFixedThreadPool(concurrentRequestCount); + CountDownLatch latch = new CountDownLatch(concurrentRequestCount); + AtomicInteger successCount = new AtomicInteger(0); + List exceptions = new ArrayList<>(); + + // act + for (int i = 0; i < concurrentRequestCount; i++) { + executorService.submit(() -> { + try { + likeFacade.addLike(userId, productId); + successCount.incrementAndGet(); + } catch (Exception e) { + synchronized (exceptions) { + exceptions.add(e); + } + } finally { + latch.countDown(); + } + }); + } + latch.await(); + executorService.shutdown(); + + // assert + // UNIQUE 제약조건으로 인해 정확히 1개의 좋아요만 저장되어야 함 + long actualLikesCount = likeRepository.countByProductIds(List.of(productId)) + .getOrDefault(productId, 0L); + + assertThat(actualLikesCount).isEqualTo(1L); + // 애플리케이션 레벨 체크 또는 데이터베이스 UNIQUE 제약조건으로 인해 + // 모든 요청이 성공하거나 일부는 예외가 발생할 수 있지만, + // 최종적으로는 1개의 좋아요만 저장되어야 함 + assertThat(successCount.get() + exceptions.size()).isEqualTo(concurrentRequestCount); + } + + @Test + @DisplayName("@Transactional(readOnly = true)와 UNIQUE 제약조건은 서로 다른 목적을 가진다") + void concurrencyTest_transactionReadOnlyAndUniqueConstraintServeDifferentPurposes() throws InterruptedException { + // 이 테스트는 @Transactional(readOnly = true)와 UNIQUE 제약조건의 차이를 보여줍니다. + // + // UNIQUE 제약조건: + // - 목적: 데이터 무결성 보장 (중복 데이터 방지) + // - 예시: LikeFacade.addLike()에서 동일 사용자가 동일 상품에 중복 좋아요 방지 + // - 작동: 데이터베이스 레벨에서 물리적으로 중복 삽입 방지 + // + // @Transactional(readOnly = true): + // - 목적: 여러 쿼리 간의 논리적 일관성 보장 + // - 예시: LikeFacade.getLikedProducts()에서 좋아요 목록과 집계 결과의 일관성 + // - 작동: 모든 쿼리가 동일한 트랜잭션 내에서 실행되어 일관된 스냅샷을 봄 + // + // REPEATABLE READ 격리 수준에서: + // - 트랜잭션이 없으면: 각 쿼리가 독립적으로 실행되며, 각 쿼리는 자체 스냅샷을 봄 + // - 트랜잭션이 있으면: 모든 쿼리가 동일한 트랜잭션 시작 시점의 스냅샷을 봄 + // + // 실제 문제 시나리오: + // 1. 좋아요 목록 조회 (쿼리 1) - 시점 T1의 스냅샷 + // 2. 다른 트랜잭션이 좋아요 추가 (커밋) + // 3. 좋아요 수 집계 (쿼리 2) - 시점 T2의 스냅샷 (T1과 다를 수 있음) + // + // 트랜잭션이 없으면: + // - 쿼리 1과 쿼리 2가 서로 다른 시점의 스냅샷을 볼 수 있음 + // - 좋아요 목록에는 상품1이 1개로 보이지만, 집계 결과는 2개일 수 있음 + // + // 트랜잭션이 있으면: + // - 모든 쿼리가 동일한 시점의 스냅샷을 봄 + // - 좋아요 목록과 집계 결과가 일관됨 + // + // 왜 테스트가 통과하는가? + // - REPEATABLE READ에서는 각 쿼리가 자체적으로 일관된 스냅샷을 봄 + // - 쿼리 실행 시간이 매우 짧아서 다른 트랜잭션이 정확히 중간에 개입할 확률이 낮음 + // - 하지만 여러 쿼리 간의 논리적 일관성을 보장하려면 트랜잭션이 필요함 + + // arrange + Brand brand = createAndSaveBrand("테스트 브랜드"); + Product product1 = createAndSaveProduct("상품1", 10_000, 100, brand.getId()); + Product product2 = createAndSaveProduct("상품2", 20_000, 100, brand.getId()); + + User user1 = createAndSaveUser("user1", "user1@example.com", 0L); + User user2 = createAndSaveUser("user2", "user2@example.com", 0L); + String userId1 = user1.getUserId(); + String userId2 = user2.getUserId(); + + // user1이 상품1, 상품2에 좋아요를 이미 누른 상태 + likeFacade.addLike(userId1, product1.getId()); + likeFacade.addLike(userId1, product2.getId()); + + ExecutorService executorService = Executors.newFixedThreadPool(20); + CountDownLatch latch = new CountDownLatch(20); + List> allResults = new ArrayList<>(); + + // act + // 여러 스레드에서 동시에 조회를 수행 + for (int i = 0; i < 10; i++) { + executorService.submit(() -> { + try { + List result = likeFacade.getLikedProducts(userId1); + synchronized (allResults) { + allResults.add(result); + } + } catch (Exception e) { + e.printStackTrace(); + } finally { + latch.countDown(); + } + }); + } + + // 다른 스레드들이 조회 중간에 좋아요를 추가/삭제 + for (int i = 0; i < 10; i++) { + final int index = i; + executorService.submit(() -> { + try { + // 조회가 시작된 후 실행되도록 약간의 지연 + Thread.sleep(1 + index); + if (index % 2 == 0) { + // user2가 상품1에 좋아요 추가 + try { + likeFacade.addLike(userId2, product1.getId()); + } catch (Exception e) { + // 이미 좋아요가 있으면 무시 + } + } else { + // user2가 상품2에 좋아요 추가 + try { + likeFacade.addLike(userId2, product2.getId()); + } catch (Exception e) { + // 이미 좋아요가 있으면 무시 + } + } + } catch (Exception e) { + e.printStackTrace(); + } finally { + latch.countDown(); + } + }); + } + + latch.await(); + executorService.shutdown(); + + // 좋아요 수 동기화 (비동기 스케줄러를 기다리지 않고 직접 업데이트) + syncLikeCounts(List.of(product1.getId(), product2.getId())); + + // assert + // @Transactional(readOnly = true)가 있으면: + // - 모든 조회가 동일한 트랜잭션 내에서 실행되어 일관된 스냅샷을 봄 + // - 각 조회 결과 내에서 좋아요 목록과 집계 결과가 일관됨 + + // @Transactional(readOnly = true)가 없으면: + // - 각 쿼리가 독립적으로 실행되어 서로 다른 시점의 데이터를 볼 수 있음 + // - 하지만 REPEATABLE READ에서는 각 쿼리가 자체 스냅샷을 보므로 + // 실제로는 문제가 드물 수 있음 + + // 검증: 모든 조회 결과가 정상적으로 반환되었는지 확인 + assertThat(allResults).hasSize(10); + + // 각 조회 결과가 올바른 형식인지 확인 + // 참고: allResults는 동기화 이전에 조회된 결과이므로 likesCount가 0일 수 있습니다. + // 이 테스트는 @Transactional(readOnly = true)의 일관성 보장을 검증하는 것이 목적이므로, + // 동시성 테스트 중 조회된 결과의 상품 ID 일관성만 확인합니다. + for (List result : allResults) { + // user1의 좋아요 목록에는 상품1, 상품2가 포함되어야 함 + List resultProductIds = result.stream() + .map(LikeFacade.LikedProduct::productId) + .sorted() + .toList(); + assertThat(resultProductIds).contains(product1.getId(), product2.getId()); + } + + // 최종 상태 확인 (동기화 후) + List finalResult = likeFacade.getLikedProducts(userId1); + List finalProductIds = finalResult.stream() + .map(LikeFacade.LikedProduct::productId) + .sorted() + .toList(); + assertThat(finalProductIds).containsExactlyInAnyOrder(product1.getId(), product2.getId()); + + // 동기화 후에는 정확한 좋아요 수가 반영되어야 함 + for (LikeFacade.LikedProduct likedProduct : finalResult) { + assertThat(likedProduct.likesCount()).isGreaterThan(0); + } + } +} + diff --git a/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeTest.java index 86f7d91a0..d64ba6cbb 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeTest.java @@ -12,8 +12,10 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import java.util.List; import java.util.Optional; +import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; @@ -135,6 +137,93 @@ void addLike_productNotFound() { .hasFieldOrPropertyWithValue("errorType", ErrorType.NOT_FOUND); } + @Test + @DisplayName("좋아요한 상품 목록을 조회할 수 있다") + void getLikedProducts_success() { + // arrange + setupMockUser(DEFAULT_USER_ID, DEFAULT_USER_INTERNAL_ID); + + Long productId1 = 1L; + Long productId2 = 2L; + + Like like1 = Like.of(DEFAULT_USER_INTERNAL_ID, productId1); + Like like2 = Like.of(DEFAULT_USER_INTERNAL_ID, productId2); + List likes = List.of(like1, like2); + + // ✅ Product.likeCount 필드 사용 (비동기 집계된 값) + Product product1 = createMockProduct(productId1, "상품1", 10000, 10, 1L, 5L); + Product product2 = createMockProduct(productId2, "상품2", 20000, 20, 1L, 3L); + + when(likeRepository.findAllByUserId(DEFAULT_USER_INTERNAL_ID)).thenReturn(likes); + // ✅ findAllById를 사용하므로 findAllById를 mock해야 함 + when(productRepository.findAllById(List.of(productId1, productId2))) + .thenReturn(List.of(product1, product2)); + + // act + List result = likeFacade.getLikedProducts(DEFAULT_USER_ID); + + // assert + assertThat(result).hasSize(2); + assertThat(result).extracting(LikeFacade.LikedProduct::productId) + .containsExactlyInAnyOrder(productId1, productId2); + assertThat(result).extracting(LikeFacade.LikedProduct::likesCount) + .containsExactlyInAnyOrder(5L, 3L); + } + + @Test + @DisplayName("좋아요한 상품이 없으면 빈 목록을 반환한다") + void getLikedProducts_emptyList() { + // arrange + setupMockUser(DEFAULT_USER_ID, DEFAULT_USER_INTERNAL_ID); + when(likeRepository.findAllByUserId(DEFAULT_USER_INTERNAL_ID)).thenReturn(List.of()); + + // act + List result = likeFacade.getLikedProducts(DEFAULT_USER_ID); + + // assert + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("좋아요한 상품 목록 조회 시 상품을 찾을 수 없으면 예외를 던진다") + void getLikedProducts_productNotFound() { + // arrange + setupMockUser(DEFAULT_USER_ID, DEFAULT_USER_INTERNAL_ID); + + Long productId1 = 1L; + Long nonExistentProductId = 999L; + + Like like1 = Like.of(DEFAULT_USER_INTERNAL_ID, productId1); + Like like2 = Like.of(DEFAULT_USER_INTERNAL_ID, nonExistentProductId); + List likes = List.of(like1, like2); + + Product product1 = createMockProduct(productId1, "상품1", 10000, 10, 1L, 5L); + + when(likeRepository.findAllByUserId(DEFAULT_USER_INTERNAL_ID)).thenReturn(likes); + // ✅ findAllById를 사용하므로 findAllById를 mock해야 함 + // nonExistentProductId가 포함되지 않아서 예외가 발생해야 함 + when(productRepository.findAllById(List.of(productId1, nonExistentProductId))) + .thenReturn(List.of(product1)); // product1만 반환 (nonExistentProductId는 없음) + + // act & assert + assertThatThrownBy(() -> likeFacade.getLikedProducts(DEFAULT_USER_ID)) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.NOT_FOUND); + } + + @Test + @DisplayName("좋아요한 상품 목록 조회 시 사용자를 찾을 수 없으면 예외를 던진다") + void getLikedProducts_userNotFound() { + // arrange + String unknownUserId = "unknown"; + when(userRepository.findByUserId(unknownUserId)).thenReturn(null); + + // act & assert + assertThatThrownBy(() -> likeFacade.getLikedProducts(unknownUserId)) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.NOT_FOUND); + } + // Helper methods for test setup private void setupMocks(String userId, Long userInternalId, Long productId) { @@ -152,5 +241,17 @@ private void setupMockProduct(Long productId) { Product mockProduct = mock(Product.class); when(productRepository.findById(productId)).thenReturn(Optional.of(mockProduct)); } + + private Product createMockProduct(Long productId, String name, Integer price, Integer stock, Long brandId, Long likeCount) { + Product product = mock(Product.class); + when(product.getId()).thenReturn(productId); + when(product.getName()).thenReturn(name); + when(product.getPrice()).thenReturn(price); + when(product.getStock()).thenReturn(stock); + when(product.getBrandId()).thenReturn(brandId); + // ✅ Product.likeCount 필드 mock 설정 (비동기 집계된 값) + when(product.getLikeCount()).thenReturn(likeCount); + return product; + } } diff --git a/apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadeConcurrencyTest.java b/apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadeConcurrencyTest.java new file mode 100644 index 000000000..3220b41c0 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadeConcurrencyTest.java @@ -0,0 +1,358 @@ +package com.loopers.application.purchasing; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandRepository; +import com.loopers.domain.coupon.Coupon; +import com.loopers.domain.coupon.CouponRepository; +import com.loopers.domain.coupon.CouponType; +import com.loopers.domain.coupon.UserCoupon; +import com.loopers.domain.coupon.UserCouponRepository; +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderRepository; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; +import com.loopers.domain.user.Gender; +import com.loopers.domain.user.Point; +import com.loopers.domain.user.User; +import com.loopers.domain.user.UserRepository; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import com.loopers.testcontainers.MySqlTestContainersConfig; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * PurchasingFacade 동시성 테스트 + *

+ * 여러 스레드에서 동시에 주문 요청을 보내도 데이터 일관성이 유지되는지 검증합니다. + * - 포인트 차감의 정확성 + * - 재고 차감의 정확성 + * - 쿠폰 사용의 중복 방지 (예시) + *

+ */ +@SpringBootTest +@Import(MySqlTestContainersConfig.class) +@DisplayName("PurchasingFacade 동시성 테스트") +class PurchasingFacadeConcurrencyTest { + + @Autowired + private PurchasingFacade purchasingFacade; + + @Autowired + private UserRepository userRepository; + + @Autowired + private ProductRepository productRepository; + + @Autowired + private BrandRepository brandRepository; + + @Autowired + private OrderRepository orderRepository; + + @Autowired + private CouponRepository couponRepository; + + @Autowired + private UserCouponRepository userCouponRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + private User createAndSaveUser(String userId, String email, long point) { + User user = User.of(userId, email, "1990-01-01", Gender.MALE, Point.of(point)); + return userRepository.save(user); + } + + private Brand createAndSaveBrand(String brandName) { + Brand brand = Brand.of(brandName); + return brandRepository.save(brand); + } + + private Product createAndSaveProduct(String productName, int price, int stock, Long brandId) { + Product product = Product.of(productName, price, stock, brandId); + return productRepository.save(product); + } + + private Coupon createAndSaveCoupon(String code, CouponType type, Integer discountValue) { + Coupon coupon = Coupon.of(code, type, discountValue); + return couponRepository.save(coupon); + } + + private UserCoupon createAndSaveUserCoupon(Long userId, Coupon coupon) { + UserCoupon userCoupon = UserCoupon.of(userId, coupon); + return userCouponRepository.save(userCoupon); + } + + @Test + @DisplayName("동일한 유저가 서로 다른 주문을 동시에 수행해도, 포인트가 정상적으로 차감되어야 한다") + void concurrencyTest_pointShouldProperlyDecreaseWhenOrderCreated() throws InterruptedException { + // arrange + User user = createAndSaveUser("testuser", "test@example.com", 100_000L); + String userId = user.getUserId(); + Brand brand = createAndSaveBrand("테스트 브랜드"); + + int orderCount = 5; + List products = new ArrayList<>(); + for (int i = 0; i < orderCount; i++) { + products.add(createAndSaveProduct("상품" + i, 10_000, 100, brand.getId())); + } + + ExecutorService executorService = Executors.newFixedThreadPool(orderCount); + CountDownLatch latch = new CountDownLatch(orderCount); + AtomicInteger successCount = new AtomicInteger(0); + List exceptions = new ArrayList<>(); + + // act + for (int i = 0; i < orderCount; i++) { + final int index = i; + executorService.submit(() -> { + try { + List commands = List.of( + OrderItemCommand.of(products.get(index).getId(), 1) + ); + purchasingFacade.createOrder(userId, commands); + successCount.incrementAndGet(); + } catch (Exception e) { + synchronized (exceptions) { + exceptions.add(e); + } + } finally { + latch.countDown(); + } + }); + } + latch.await(); + executorService.shutdown(); + + // assert + User savedUser = userRepository.findByUserId(userId); + long expectedRemainingPoint = 100_000L - (10_000L * orderCount); + + assertThat(successCount.get()).isEqualTo(orderCount); + assertThat(exceptions).isEmpty(); + assertThat(savedUser.getPoint().getValue()).isEqualTo(expectedRemainingPoint); + } + + @Test + @DisplayName("동일한 상품에 대해 여러 주문이 동시에 요청되어도, 재고가 정상적으로 차감되어야 한다") + void concurrencyTest_stockShouldBeProperlyDecreasedWhenOrdersCreated() throws InterruptedException { + // arrange + User user = createAndSaveUser("testuser", "test@example.com", 1_000_000L); + String userId = user.getUserId(); + Brand brand = createAndSaveBrand("테스트 브랜드"); + Product product = createAndSaveProduct("테스트 상품", 10_000, 100, brand.getId()); + Long productId = product.getId(); + + int orderCount = 10; + int quantityPerOrder = 5; + + ExecutorService executorService = Executors.newFixedThreadPool(orderCount); + CountDownLatch latch = new CountDownLatch(orderCount); + AtomicInteger successCount = new AtomicInteger(0); + List exceptions = new ArrayList<>(); + + // act + for (int i = 0; i < orderCount; i++) { + executorService.submit(() -> { + try { + List commands = List.of( + OrderItemCommand.of(productId, quantityPerOrder) + ); + purchasingFacade.createOrder(userId, commands); + successCount.incrementAndGet(); + } catch (Exception e) { + synchronized (exceptions) { + exceptions.add(e); + } + } finally { + latch.countDown(); + } + }); + } + latch.await(); + executorService.shutdown(); + + // assert + Product savedProduct = productRepository.findById(productId).orElseThrow(); + int expectedStock = 100 - (successCount.get() * quantityPerOrder); + + assertThat(savedProduct.getStock()).isEqualTo(expectedStock); + assertThat(successCount.get() + exceptions.size()).isEqualTo(orderCount); + } + + @Test + @DisplayName("동일한 쿠폰으로 여러 기기에서 동시에 주문해도, 쿠폰은 단 한번만 사용되어야 한다") + void concurrencyTest_couponShouldBeUsedOnlyOnceWhenOrdersCreated() throws InterruptedException { + // arrange + User user = createAndSaveUser("testuser", "test@example.com", 100_000L); + String userId = user.getUserId(); + Brand brand = createAndSaveBrand("테스트 브랜드"); + Product product = createAndSaveProduct("테스트 상품", 10_000, 100, brand.getId()); + + // 정액 쿠폰 생성 (5,000원 할인) + Coupon coupon = createAndSaveCoupon("COUPON001", CouponType.FIXED_AMOUNT, 5_000); + String couponCode = coupon.getCode(); + + // 사용자에게 쿠폰 지급 + UserCoupon userCoupon = createAndSaveUserCoupon(user.getId(), coupon); + + int concurrentRequestCount = 10; // 요구사항: 10개 스레드 + + ExecutorService executorService = Executors.newFixedThreadPool(concurrentRequestCount); + CountDownLatch latch = new CountDownLatch(concurrentRequestCount); + AtomicInteger successCount = new AtomicInteger(0); + List exceptions = new ArrayList<>(); + + // act + for (int i = 0; i < concurrentRequestCount; i++) { + executorService.submit(() -> { + try { + List commands = List.of( + new OrderItemCommand(product.getId(), 1, couponCode) + ); + purchasingFacade.createOrder(userId, commands); + successCount.incrementAndGet(); + } catch (Exception e) { + synchronized (exceptions) { + exceptions.add(e); + } + } finally { + latch.countDown(); + } + }); + } + latch.await(); + executorService.shutdown(); + + // assert + // 쿠폰은 정확히 1번만 사용되어야 함 + UserCoupon savedUserCoupon = userCouponRepository.findByUserIdAndCouponCode(user.getId(), couponCode) + .orElseThrow(); + assertThat(savedUserCoupon.isAvailable()).isFalse(); // 사용됨 + assertThat(savedUserCoupon.getIsUsed()).isTrue(); + + // 성공한 주문은 1개만 있어야 함 (나머지는 쿠폰 중복 사용으로 실패) + assertThat(successCount.get()).isEqualTo(1); + assertThat(exceptions).hasSize(concurrentRequestCount - 1); + + // 성공한 주문의 할인 금액이 적용되었는지 확인 + List orders = orderRepository.findAllByUserId(user.getId()); + assertThat(orders).hasSize(1); + Order order = orders.get(0); + assertThat(order.getCouponCode()).isEqualTo(couponCode); + assertThat(order.getDiscountAmount()).isEqualTo(5_000); + assertThat(order.getTotalAmount()).isEqualTo(5_000); // 10,000 - 5,000 = 5,000 + } + + @Test + @DisplayName("주문 취소 중 다른 스레드가 재고를 변경해도, 재고 원복이 정확하게 이루어져야 한다") + void concurrencyTest_cancelOrderShouldRestoreStockAccuratelyDuringConcurrentStockChanges() throws InterruptedException { + // arrange + User user = createAndSaveUser("testuser", "test@example.com", 1_000_000L); + String userId = user.getUserId(); + Brand brand = createAndSaveBrand("테스트 브랜드"); + Product product = createAndSaveProduct("테스트 상품", 10_000, 100, brand.getId()); + Long productId = product.getId(); + + // 주문 생성 (재고 5개 차감) + int orderQuantity = 5; + List commands = List.of( + OrderItemCommand.of(productId, orderQuantity) + ); + OrderInfo orderInfo = purchasingFacade.createOrder(userId, commands); + Long orderId = orderInfo.orderId(); + + // 주문 취소 전 재고 확인 (100 - 5 = 95) + Product productBeforeCancel = productRepository.findById(productId).orElseThrow(); + int stockBeforeCancel = productBeforeCancel.getStock(); + assertThat(stockBeforeCancel).isEqualTo(95); + + // 주문 조회 + Order order = orderRepository.findById(orderId).orElseThrow(); + + ExecutorService executorService = Executors.newFixedThreadPool(3); + CountDownLatch latch = new CountDownLatch(3); + AtomicInteger cancelSuccess = new AtomicInteger(0); + AtomicInteger orderSuccess = new AtomicInteger(0); + List exceptions = new ArrayList<>(); + + // act + // 스레드 1: 주문 취소 (재고 원복) + executorService.submit(() -> { + try { + purchasingFacade.cancelOrder(order, user); + cancelSuccess.incrementAndGet(); + } catch (Exception e) { + synchronized (exceptions) { + exceptions.add(e); + } + } finally { + latch.countDown(); + } + }); + + // 스레드 2, 3: 취소 중간에 다른 주문 생성 (재고 추가 차감) + for (int i = 0; i < 2; i++) { + executorService.submit(() -> { + try { + Thread.sleep(10); // 취소가 시작된 후 실행되도록 약간의 지연 + List otherCommands = List.of( + OrderItemCommand.of(productId, 3) + ); + purchasingFacade.createOrder(userId, otherCommands); + orderSuccess.incrementAndGet(); + } catch (Exception e) { + synchronized (exceptions) { + exceptions.add(e); + } + } finally { + latch.countDown(); + } + }); + } + + latch.await(); + executorService.shutdown(); + + // assert + // findByIdForUpdate로 인해 비관적 락이 적용되어 재고 원복이 정확하게 이루어져야 함 + Product finalProduct = productRepository.findById(productId).orElseThrow(); + int finalStock = finalProduct.getStock(); + + // 시나리오: + // 1. 초기 재고: 100 + // 2. 첫 주문: 95 (100 - 5) + // 3. 주문 취소: 100 (95 + 5) - 비관적 락으로 정확한 재고 조회 후 원복 + // 4. 다른 주문 2개: 각각 3개씩 차감 + // - 취소와 동시에 실행되면 락 대기 후 순차 처리 + // - 최종 재고: 100 - 3 - 3 = 94 (취소로 5개 원복 후 2개 주문으로 6개 차감) + + assertThat(cancelSuccess.get()).isEqualTo(1); + // 취소가 성공했고, 비관적 락으로 인해 정확한 재고가 원복되었는지 확인 + // 취소로 5개가 원복되고, 다른 주문 2개로 6개가 차감되므로: 95 + 5 - 6 = 94 + int expectedStock = stockBeforeCancel + orderQuantity - (orderSuccess.get() * 3); + assertThat(finalStock).isEqualTo(expectedStock); + + // 예외가 발생하지 않았는지 확인 + assertThat(exceptions).isEmpty(); + } +} + diff --git a/apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadeTest.java index 0809b50cc..6464e9552 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadeTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadeTest.java @@ -2,6 +2,12 @@ import com.loopers.domain.brand.Brand; import com.loopers.domain.brand.BrandRepository; +import com.loopers.domain.coupon.Coupon; +import com.loopers.domain.coupon.CouponRepository; +import com.loopers.domain.coupon.CouponType; +import com.loopers.domain.coupon.UserCoupon; +import com.loopers.domain.coupon.UserCouponRepository; +import com.loopers.domain.order.OrderRepository; import com.loopers.domain.order.OrderStatus; import com.loopers.domain.product.Product; import com.loopers.domain.product.ProductRepository; @@ -38,6 +44,15 @@ class PurchasingFacadeTest { @Autowired private BrandRepository brandRepository; + + @Autowired + private OrderRepository orderRepository; + + @Autowired + private CouponRepository couponRepository; + + @Autowired + private UserCouponRepository userCouponRepository; @Autowired private DatabaseCleanUp databaseCleanUp; @@ -63,6 +78,34 @@ private Product createAndSaveProduct(String productName, int price, int stock, L return productRepository.save(product); } + /** + * 쿠폰을 생성하고 저장합니다. + * + * @param code 쿠폰 코드 + * @param type 쿠폰 타입 + * @param discountValue 할인 값 + * @return 저장된 쿠폰 + */ + private Coupon createAndSaveCoupon(String code, CouponType type, Integer discountValue) { + Coupon coupon = Coupon.of(code, type, discountValue); + return couponRepository.save(coupon); + } + + /** + * 사용자 쿠폰을 생성하고 저장합니다. + *

+ * 쿠폰은 이미 저장된 상태여야 합니다. + *

+ * + * @param userId 사용자 ID + * @param coupon 저장된 쿠폰 + * @return 저장된 사용자 쿠폰 + */ + private UserCoupon createAndSaveUserCoupon(Long userId, Coupon coupon) { + UserCoupon userCoupon = UserCoupon.of(userId, coupon); + return userCouponRepository.save(userCoupon); + } + @Test @DisplayName("주문 생성 시 재고 차감, 포인트 차감, 주문 완료, 외부 전송을 수행한다") void createOrder_successFlow() { @@ -73,8 +116,8 @@ void createOrder_successFlow() { Product product2 = createAndSaveProduct("상품2", 5_000, 5, brand.getId()); List commands = List.of( - new OrderItemCommand(product1.getId(), 2), - new OrderItemCommand(product2.getId(), 1) + OrderItemCommand.of(product1.getId(), 2), + OrderItemCommand.of(product2.getId(), 1) ); // act @@ -113,7 +156,7 @@ void createOrder_userNotFound() { // arrange String unknownUserId = "unknown"; List commands = List.of( - new OrderItemCommand(1L, 1) + OrderItemCommand.of(1L, 1) ); // act & assert @@ -132,9 +175,40 @@ void createOrder_stockNotEnough() { Brand brand = createAndSaveBrand("브랜드2"); Product product = createAndSaveProduct("상품", 10_000, 1, brand.getId()); final Long productId = product.getId(); + final int initialStock = product.getStock(); + + List commands = List.of( + OrderItemCommand.of(productId, 2) + ); + + // act & assert + assertThatThrownBy(() -> purchasingFacade.createOrder(userId, commands)) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.BAD_REQUEST); + + // 롤백 확인: 포인트가 차감되지 않았는지 확인 + User savedUser = userRepository.findByUserId(userId); + assertThat(savedUser.getPoint().getValue()).isEqualTo(50_000L); + + // 롤백 확인: 재고가 변경되지 않았는지 확인 + Product savedProduct = productRepository.findById(productId).orElseThrow(); + assertThat(savedProduct.getStock()).isEqualTo(initialStock); + } + + @Test + @DisplayName("상품 재고가 0이면 예외를 던지고 포인트는 차감되지 않는다") + void createOrder_stockZero() { + // arrange + User user = createAndSaveUser("testuser2", "test2@example.com", 50_000L); + final String userId = user.getUserId(); + + Brand brand = createAndSaveBrand("브랜드2"); + Product product = createAndSaveProduct("상품", 10_000, 0, brand.getId()); + final Long productId = product.getId(); + final int initialStock = product.getStock(); List commands = List.of( - new OrderItemCommand(productId, 2) + OrderItemCommand.of(productId, 1) ); // act & assert @@ -142,9 +216,43 @@ void createOrder_stockNotEnough() { .isInstanceOf(CoreException.class) .hasFieldOrPropertyWithValue("errorType", ErrorType.BAD_REQUEST); - // 포인트가 차감되지 않았는지 확인 + // 롤백 확인: 포인트가 차감되지 않았는지 확인 User savedUser = userRepository.findByUserId(userId); assertThat(savedUser.getPoint().getValue()).isEqualTo(50_000L); + + // 롤백 확인: 재고가 변경되지 않았는지 확인 + Product savedProduct = productRepository.findById(productId).orElseThrow(); + assertThat(savedProduct.getStock()).isEqualTo(initialStock); + } + + @Test + @DisplayName("유저의 포인트 잔액이 부족하면 예외를 던지고 재고는 차감되지 않는다") + void createOrder_pointNotEnough() { + // arrange + User user = createAndSaveUser("testuser2", "test2@example.com", 5_000L); + final String userId = user.getUserId(); + + Brand brand = createAndSaveBrand("브랜드2"); + Product product = createAndSaveProduct("상품", 10_000, 10, brand.getId()); + final Long productId = product.getId(); + final int initialStock = product.getStock(); + + List commands = List.of( + OrderItemCommand.of(productId, 1) + ); + + // act & assert + assertThatThrownBy(() -> purchasingFacade.createOrder(userId, commands)) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.BAD_REQUEST); + + // 롤백 확인: 포인트가 차감되지 않았는지 확인 + User savedUser = userRepository.findByUserId(userId); + assertThat(savedUser.getPoint().getValue()).isEqualTo(5_000L); + + // 롤백 확인: 재고가 변경되지 않았는지 확인 + Product savedProduct = productRepository.findById(productId).orElseThrow(); + assertThat(savedProduct.getStock()).isEqualTo(initialStock); } @Test @@ -159,8 +267,8 @@ void createOrder_duplicateProducts_throwsException() { final Long productId = product.getId(); List commands = List.of( - new OrderItemCommand(productId, 1), - new OrderItemCommand(productId, 2) + OrderItemCommand.of(productId, 1), + OrderItemCommand.of(productId, 2) ); // act & assert @@ -182,7 +290,7 @@ void getOrders_returnsUserOrders() { Product product = createAndSaveProduct("상품", 10_000, 10, brand.getId()); List commands = List.of( - new OrderItemCommand(product.getId(), 1) + OrderItemCommand.of(product.getId(), 1) ); purchasingFacade.createOrder(user.getUserId(), commands); @@ -203,7 +311,7 @@ void getOrder_returnsSingleOrder() { Product product = createAndSaveProduct("상품", 10_000, 10, brand.getId()); List commands = List.of( - new OrderItemCommand(product.getId(), 1) + OrderItemCommand.of(product.getId(), 1) ); OrderInfo createdOrder = purchasingFacade.createOrder(user.getUserId(), commands); @@ -228,7 +336,7 @@ void getOrder_withDifferentUser_throwsException() { Product product = createAndSaveProduct("상품", 10_000, 10, brand.getId()); List commands = List.of( - new OrderItemCommand(product.getId(), 1) + OrderItemCommand.of(product.getId(), 1) ); OrderInfo user1Order = purchasingFacade.createOrder(user1Id, commands); final Long orderId = user1Order.orderId(); @@ -239,4 +347,216 @@ void getOrder_withDifferentUser_throwsException() { .hasFieldOrPropertyWithValue("errorType", ErrorType.NOT_FOUND); } + @Test + @DisplayName("주문 전체 흐름에 대해 원자성이 보장되어야 한다 - 실패 시 모든 작업이 롤백된다") + void createOrder_atomicityGuaranteed() { + // arrange + User user = createAndSaveUser("testuser", "test@example.com", 50_000L); + final String userId = user.getUserId(); + final long initialPoint = user.getPoint().getValue(); + + Brand brand = createAndSaveBrand("브랜드"); + Product product1 = createAndSaveProduct("상품1", 10_000, 5, brand.getId()); + Product product2 = createAndSaveProduct("상품2", 20_000, 3, brand.getId()); + final Long product1Id = product1.getId(); + final Long product2Id = product2.getId(); + final int initialStock1 = product1.getStock(); + final int initialStock2 = product2.getStock(); + + // product2의 재고가 부족한 상황 (3개 재고인데 5개 주문) + List commands = List.of( + OrderItemCommand.of(product1Id, 2), + OrderItemCommand.of(product2Id, 5) // 재고 부족 + ); + + // act & assert + assertThatThrownBy(() -> purchasingFacade.createOrder(userId, commands)) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.BAD_REQUEST); + + // 롤백 확인: 포인트가 차감되지 않았는지 확인 + User savedUser = userRepository.findByUserId(userId); + assertThat(savedUser.getPoint().getValue()).isEqualTo(initialPoint); + + // 롤백 확인: 모든 상품의 재고가 변경되지 않았는지 확인 + Product savedProduct1 = productRepository.findById(product1Id).orElseThrow(); + Product savedProduct2 = productRepository.findById(product2Id).orElseThrow(); + assertThat(savedProduct1.getStock()).isEqualTo(initialStock1); + assertThat(savedProduct2.getStock()).isEqualTo(initialStock2); + + // 롤백 확인: 주문이 저장되지 않았는지 확인 + List orders = purchasingFacade.getOrders(userId); + assertThat(orders).isEmpty(); + } + + @Test + @DisplayName("주문 성공 시, 모든 처리는 정상 반영되어야 한다 - 재고, 포인트, 주문 모두 반영") + void createOrder_success_allOperationsReflected() { + // arrange + User user = createAndSaveUser("testuser", "test@example.com", 100_000L); + final String userId = user.getUserId(); + final long initialPoint = user.getPoint().getValue(); + + Brand brand = createAndSaveBrand("브랜드"); + Product product1 = createAndSaveProduct("상품1", 10_000, 10, brand.getId()); + Product product2 = createAndSaveProduct("상품2", 15_000, 5, brand.getId()); + final Long product1Id = product1.getId(); + final Long product2Id = product2.getId(); + final int initialStock1 = product1.getStock(); + final int initialStock2 = product2.getStock(); + + List commands = List.of( + OrderItemCommand.of(product1Id, 3), + OrderItemCommand.of(product2Id, 2) + ); + final int totalAmount = (10_000 * 3) + (15_000 * 2); + + // act + OrderInfo orderInfo = purchasingFacade.createOrder(userId, commands); + + // assert + // 주문이 정상적으로 생성되었는지 확인 + assertThat(orderInfo.status()).isEqualTo(OrderStatus.COMPLETED); + assertThat(orderInfo.items()).hasSize(2); + + // 재고가 정상적으로 차감되었는지 확인 + Product savedProduct1 = productRepository.findById(product1Id).orElseThrow(); + Product savedProduct2 = productRepository.findById(product2Id).orElseThrow(); + assertThat(savedProduct1.getStock()).isEqualTo(initialStock1 - 3); + assertThat(savedProduct2.getStock()).isEqualTo(initialStock2 - 2); + + // 포인트가 정상적으로 차감되었는지 확인 + User savedUser = userRepository.findByUserId(userId); + assertThat(savedUser.getPoint().getValue()).isEqualTo(initialPoint - totalAmount); + + // 주문이 저장되었는지 확인 + List orders = purchasingFacade.getOrders(userId); + assertThat(orders).hasSize(1); + assertThat(orders.get(0).orderId()).isEqualTo(orderInfo.orderId()); + } + + @Test + @DisplayName("정액 쿠폰을 적용하여 주문할 수 있다") + void createOrder_withFixedAmountCoupon_success() { + // arrange + User user = createAndSaveUser("testuser", "test@example.com", 50_000L); + String userId = user.getUserId(); + Brand brand = createAndSaveBrand("브랜드"); + Product product = createAndSaveProduct("상품", 10_000, 10, brand.getId()); + + Coupon coupon = createAndSaveCoupon("FIXED5000", CouponType.FIXED_AMOUNT, 5_000); + createAndSaveUserCoupon(user.getId(), coupon); + + List commands = List.of( + new OrderItemCommand(product.getId(), 1, "FIXED5000") + ); + + // act + OrderInfo orderInfo = purchasingFacade.createOrder(userId, commands); + + // assert + assertThat(orderInfo.status()).isEqualTo(OrderStatus.COMPLETED); + assertThat(orderInfo.totalAmount()).isEqualTo(5_000); // 10,000 - 5,000 = 5,000 + + // 쿠폰이 사용되었는지 확인 + UserCoupon savedUserCoupon = userCouponRepository.findByUserIdAndCouponCode(user.getId(), "FIXED5000") + .orElseThrow(); + assertThat(savedUserCoupon.getIsUsed()).isTrue(); + } + + @Test + @DisplayName("정률 쿠폰을 적용하여 주문할 수 있다") + void createOrder_withPercentageCoupon_success() { + // arrange + User user = createAndSaveUser("testuser", "test@example.com", 50_000L); + String userId = user.getUserId(); + Brand brand = createAndSaveBrand("브랜드"); + Product product = createAndSaveProduct("상품", 10_000, 10, brand.getId()); + + Coupon coupon = createAndSaveCoupon("PERCENT20", CouponType.PERCENTAGE, 20); + createAndSaveUserCoupon(user.getId(), coupon); + + List commands = List.of( + new OrderItemCommand(product.getId(), 1, "PERCENT20") + ); + + // act + OrderInfo orderInfo = purchasingFacade.createOrder(userId, commands); + + // assert + assertThat(orderInfo.status()).isEqualTo(OrderStatus.COMPLETED); + assertThat(orderInfo.totalAmount()).isEqualTo(8_000); // 10,000 - (10,000 * 20%) = 8,000 + + // 쿠폰이 사용되었는지 확인 + UserCoupon savedUserCoupon = userCouponRepository.findByUserIdAndCouponCode(user.getId(), "PERCENT20") + .orElseThrow(); + assertThat(savedUserCoupon.getIsUsed()).isTrue(); + } + + @Test + @DisplayName("존재하지 않는 쿠폰으로 주문하면 실패한다") + void createOrder_withNonExistentCoupon_shouldFail() { + // arrange + User user = createAndSaveUser("testuser", "test@example.com", 50_000L); + String userId = user.getUserId(); + Brand brand = createAndSaveBrand("브랜드"); + Product product = createAndSaveProduct("상품", 10_000, 10, brand.getId()); + + List commands = List.of( + new OrderItemCommand(product.getId(), 1, "NON_EXISTENT") + ); + + // act & assert + assertThatThrownBy(() -> purchasingFacade.createOrder(userId, commands)) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.NOT_FOUND); + } + + @Test + @DisplayName("사용자가 소유하지 않은 쿠폰으로 주문하면 실패한다") + void createOrder_withCouponNotOwnedByUser_shouldFail() { + // arrange + User user = createAndSaveUser("testuser", "test@example.com", 50_000L); + String userId = user.getUserId(); + Brand brand = createAndSaveBrand("브랜드"); + Product product = createAndSaveProduct("상품", 10_000, 10, brand.getId()); + + Coupon coupon = Coupon.of("COUPON001", CouponType.FIXED_AMOUNT, 5_000); + couponRepository.save(coupon); + // 사용자에게 쿠폰을 지급하지 않음 + + List commands = List.of( + new OrderItemCommand(product.getId(), 1, "COUPON001") + ); + + // act & assert + assertThatThrownBy(() -> purchasingFacade.createOrder(userId, commands)) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.NOT_FOUND); + } + + @Test + @DisplayName("이미 사용된 쿠폰으로 주문하면 실패한다") + void createOrder_withUsedCoupon_shouldFail() { + // arrange + User user = createAndSaveUser("testuser", "test@example.com", 50_000L); + String userId = user.getUserId(); + Brand brand = createAndSaveBrand("브랜드"); + Product product = createAndSaveProduct("상품", 10_000, 10, brand.getId()); + + Coupon coupon = createAndSaveCoupon("USED_COUPON", CouponType.FIXED_AMOUNT, 5_000); + UserCoupon userCoupon = createAndSaveUserCoupon(user.getId(), coupon); + userCoupon.use(); // 이미 사용 처리 + userCouponRepository.save(userCoupon); + + List commands = List.of( + new OrderItemCommand(product.getId(), 1, "USED_COUPON") + ); + + // act & assert + assertThatThrownBy(() -> purchasingFacade.createOrder(userId, commands)) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.BAD_REQUEST); + } + }