+ *
+ * @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 생명주기:
+ *
+ *
SELECT ... FOR UPDATE 실행 시 락 획득
+ *
트랜잭션 내에서 락 유지 (외부 I/O 없음, 매우 짧은 시간)
+ *
트랜잭션 커밋/롤백 시 락 자동 해제
+ *
*
*
* @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 필드에 동기화합니다.
+ *
+ *
+ * @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 리포지토리.
+ *