From d9ae7adc3cfd0fb44a493c8638e258ce188e8750 Mon Sep 17 00:00:00 2001
From: simplify-len
Date: Mon, 3 Nov 2025 22:08:41 +0900
Subject: [PATCH 1/5] Add GitHub Actions workflow for PR Agent
---
.github/workflows/main.yml | 13 +++++++++++++
1 file changed, 13 insertions(+)
create mode 100644 .github/workflows/main.yml
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 }}
From 2d1046b40e1c5664db64f9cfeef47d82712ede6d Mon Sep 17 00:00:00 2001
From: minor7295 <44902090+minor7295@users.noreply.github.com>
Date: Fri, 21 Nov 2025 01:48:38 +0900
Subject: [PATCH 2/5] Feature/misc api (#15)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* refactor: product 도메인의 likeCount 집계 방식을 배치 방식으로 변경
* refactor: CatalogProductFacade에서 발생하는 n+1 쿼리 수정
* refactor: SignUpFacade의 NPE 가능성 제거
* refactor: Brand 도메인 name 필드의 입력 검증 로직 추가
* refactor: Order도메인 내의 OrderItem을 불변 객체로 설정
* feat: 브랜드 정보 조회 API 추가
* feat: 상품 조회 API 추가
* feat: 좋아요 수 집계 로직 추가
* feat: 좋아요 API 추가
* feat: 구매 API 추가
---
apps/commerce-api/build.gradle.kts | 3 +
.../com/loopers/CommerceApiApplication.java | 2 +
.../catalog/CatalogBrandFacade.java | 55 ++++++
.../catalog/CatalogProductFacade.java | 46 +++--
.../loopers/application/like/LikeFacade.java | 101 ++++++++---
.../scheduler/LikeCountSyncScheduler.java | 98 ++++++++++
.../application/signup/SignUpFacade.java | 8 +-
.../batch/LikeCountSyncBatchConfig.java | 171 ++++++++++++++++++
.../java/com/loopers/domain/brand/Brand.java | 18 +-
.../loopers/domain/brand/BrandRepository.java | 12 ++
.../loopers/domain/like/LikeRepository.java | 10 +
.../java/com/loopers/domain/order/Order.java | 38 +++-
.../com/loopers/domain/product/Product.java | 20 ++
.../loopers/domain/product/ProductDetail.java | 38 ++++
.../domain/product/ProductRepository.java | 51 ++++++
.../brand/BrandRepositoryImpl.java | 6 +
.../like/LikeJpaRepository.java | 11 ++
.../like/LikeRepositoryImpl.java | 9 +
.../product/ProductJpaRepository.java | 48 +++++
.../product/ProductRepositoryImpl.java | 38 +++-
.../api/catalog/BrandV1Controller.java | 39 ++++
.../interfaces/api/catalog/BrandV1Dto.java | 30 +++
.../api/catalog/ProductV1Controller.java | 62 +++++++
.../interfaces/api/catalog/ProductV1Dto.java | 95 ++++++++++
.../interfaces/api/like/LikeV1Controller.java | 76 ++++++++
.../interfaces/api/like/LikeV1Dto.java | 73 ++++++++
.../purchasing/PurchasingV1Controller.java | 75 ++++++++
.../api/purchasing/PurchasingV1Dto.java | 122 +++++++++++++
.../src/main/resources/application.yml | 5 +
.../application/like/LikeFacadeTest.java | 101 +++++++++++
30 files changed, 1419 insertions(+), 42 deletions(-)
create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/catalog/CatalogBrandFacade.java
create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/scheduler/LikeCountSyncScheduler.java
create mode 100644 apps/commerce-api/src/main/java/com/loopers/config/batch/LikeCountSyncBatchConfig.java
create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/catalog/BrandV1Controller.java
create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/catalog/BrandV1Dto.java
create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/catalog/ProductV1Controller.java
create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/catalog/ProductV1Dto.java
create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Controller.java
create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Dto.java
create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/purchasing/PurchasingV1Controller.java
create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/purchasing/PurchasingV1Dto.java
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..5460dbbc0 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,6 +38,23 @@ 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
@@ -47,13 +65,35 @@ 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 레벨에서 중복 삽입을 물리적으로 방지
Like like = Like.of(user.getId(), productId);
- likeRepository.save(like);
+ try {
+ likeRepository.save(like);
+ } catch (org.springframework.dao.DataIntegrityViolationException e) {
+ // UNIQUE 제약조건 위반 예외 처리
+ // 동시에 여러 요청이 들어와서 모두 "없음"으로 판단하고 저장을 시도할 때,
+ // 첫 번째만 성공하고 나머지는 UNIQUE 제약조건 위반 예외 발생
+ // 이미 좋아요가 존재하는 경우이므로 정상 처리로 간주 (멱등성 보장)
+
+ // 저장 실패 후 다시 한 번 확인 (다른 트랜잭션이 이미 저장했을 수 있음)
+ Optional savedLike = likeRepository.findByUserIdAndProductId(user.getId(), productId);
+ if (savedLike.isEmpty()) {
+ // 예외가 발생했지만 실제로 저장되지 않은 경우 (드문 경우)
+ // UNIQUE 제약조건 위반이지만 다른 이유일 수 있으므로 예외를 다시 던짐
+ throw e;
+ }
+ // 이미 저장되어 있으므로 정상 처리로 간주
+ return;
+ }
}
/**
@@ -81,11 +121,23 @@ public void removeLike(String userId, Long productId) {
/**
* 사용자가 좋아요한 상품 목록을 조회합니다.
+ *
+ * 상품 정보 조회를 병렬로 처리하여 성능을 최적화합니다.
+ *
+ *
+ * 좋아요 수 조회 전략:
+ *
+ * 비동기 집계: 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 +153,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 +210,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/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 필드에 동기화합니다.
+ *
+ *
+ * 동작 원리:
+ *
+ * 주기적으로 실행 (기본: 5초마다)
+ * Spring Batch Job 실행
+ * Reader: 모든 상품 ID 조회
+ * Processor: 각 상품의 좋아요 수 집계 (Like 테이블 COUNT(*))
+ * Writer: Product 테이블의 likeCount 필드 업데이트
+ *
+ *
+ *
+ * 설계 근거:
+ *
+ * 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 필드에 동기화합니다.
+ *
+ *
+ * 배치 구조:
+ *
+ * Reader: 모든 상품 ID 조회
+ * Processor: 각 상품의 좋아요 수 집계 (Like 테이블 COUNT(*))
+ * Writer: Product.likeCount 필드 업데이트
+ *
+ *
+ *
+ * 설계 근거:
+ *
+ * 대량 처리: 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/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/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/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/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 범위 최소화
+ *
+ *
+ *
+ * 동작 원리:
+ *
+ * SELECT ... FOR UPDATE 실행 → 해당 행에 배타적 락 설정
+ * 다른 트랜잭션의 쓰기/FOR UPDATE는 차단 (일반 읽기는 가능)
+ * 재고 차감 후 트랜잭션 커밋 → 락 해제
+ * 대기 중이던 트랜잭션이 최신 값을 읽어 처리
+ *
+ *
+ *
+ * @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/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/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;
+ }
}
From 98c1ff8d481431b85a31c2cfba8cd197e3f648d2 Mon Sep 17 00:00:00 2001
From: minor7295 <44902090+minor7295@users.noreply.github.com>
Date: Fri, 21 Nov 2025 01:57:16 +0900
Subject: [PATCH 3/5] Feature/concurrency like (#16)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* test: 좋아요 동시성 테스트 로직 추가
* feat: like에 unique constraint 적용해서 동시성 이슈 발생하지 않도록 함
---
.../loopers/application/like/LikeFacade.java | 24 +-
.../java/com/loopers/domain/like/Like.java | 11 +-
.../like/LikeFacadeConcurrencyTest.java | 336 ++++++++++++++++++
3 files changed, 355 insertions(+), 16 deletions(-)
create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeConcurrencyTest.java
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 5460dbbc0..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
@@ -60,7 +60,6 @@ public class LikeFacade {
* @param productId 상품 ID
* @throws CoreException 사용자 또는 상품을 찾을 수 없는 경우
*/
- @Transactional
public void addLike(String userId, Long productId) {
User user = loadUser(userId);
loadProduct(productId);
@@ -75,24 +74,15 @@ public void addLike(String userId, Long productId) {
// 저장 시도 (동시성 상황에서는 UNIQUE 제약조건 위반 예외 발생 가능)
// ✅ UNIQUE 제약조건이 최종 보호: DB 레벨에서 중복 삽입을 물리적으로 방지
+ // @Transactional이 없어도 save() 호출 시 자동 트랜잭션으로 예외를 catch할 수 있음
Like like = Like.of(user.getId(), productId);
try {
likeRepository.save(like);
} catch (org.springframework.dao.DataIntegrityViolationException e) {
- // UNIQUE 제약조건 위반 예외 처리
+ // UNIQUE 제약조건 위반 = 이미 저장됨 (멱등성 보장)
// 동시에 여러 요청이 들어와서 모두 "없음"으로 판단하고 저장을 시도할 때,
// 첫 번째만 성공하고 나머지는 UNIQUE 제약조건 위반 예외 발생
- // 이미 좋아요가 존재하는 경우이므로 정상 처리로 간주 (멱등성 보장)
-
- // 저장 실패 후 다시 한 번 확인 (다른 트랜잭션이 이미 저장했을 수 있음)
- Optional savedLike = likeRepository.findByUserIdAndProductId(user.getId(), productId);
- if (savedLike.isEmpty()) {
- // 예외가 발생했지만 실제로 저장되지 않은 경우 (드문 경우)
- // UNIQUE 제약조건 위반이지만 다른 이유일 수 있으므로 예외를 다시 던짐
- throw e;
- }
- // 이미 저장되어 있으므로 정상 처리로 간주
- return;
+ // 이미 좋아요가 존재하는 경우이므로 정상 처리로 간주
}
}
@@ -106,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);
@@ -116,7 +105,12 @@ public void removeLike(String userId, Long productId) {
return;
}
- likeRepository.delete(like.get());
+ try {
+ likeRepository.delete(like.get());
+ } catch (Exception e) {
+ // 동시성 상황에서 이미 삭제된 경우 등 예외 발생 가능
+ // 멱등성 보장: 이미 삭제된 경우 정상 처리로 간주
+ }
}
/**
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/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);
+ }
+ }
+}
+
From 55245f21e5cc9cbc1e04d1ae795a09c8b0375e28 Mon Sep 17 00:00:00 2001
From: minor7295 <44902090+minor7295@users.noreply.github.com>
Date: Fri, 21 Nov 2025 02:19:00 +0900
Subject: [PATCH 4/5] Feature/coupon (#17)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* test: 쿠폰 관련 테스트 코드 추가
* feat: coupon 구현
---
.../purchasing/OrderItemCommand.java | 14 +-
.../purchasing/PurchasingFacade.java | 170 +++++++++++++++++-
.../com/loopers/domain/coupon/Coupon.java | 137 ++++++++++++++
.../domain/coupon/CouponRepository.java | 39 ++++
.../com/loopers/domain/coupon/CouponType.java | 23 +++
.../com/loopers/domain/coupon/UserCoupon.java | 134 ++++++++++++++
.../domain/coupon/UserCouponRepository.java | 52 ++++++
.../discount/CouponDiscountStrategy.java | 23 +++
.../CouponDiscountStrategyFactory.java | 53 ++++++
.../discount/FixedAmountDiscountStrategy.java | 32 ++++
.../discount/PercentageDiscountStrategy.java | 32 ++++
.../coupon/CouponJpaRepository.java | 26 +++
.../coupon/CouponRepositoryImpl.java | 49 +++++
.../coupon/UserCouponJpaRepository.java | 61 +++++++
.../coupon/UserCouponRepositoryImpl.java | 49 +++++
.../purchasing/PurchasingFacadeTest.java | 167 +++++++++++++++++
16 files changed, 1053 insertions(+), 8 deletions(-)
create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/coupon/Coupon.java
create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponRepository.java
create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponType.java
create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/coupon/UserCoupon.java
create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/coupon/UserCouponRepository.java
create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/coupon/discount/CouponDiscountStrategy.java
create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/coupon/discount/CouponDiscountStrategyFactory.java
create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/coupon/discount/FixedAmountDiscountStrategy.java
create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/coupon/discount/PercentageDiscountStrategy.java
create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponJpaRepository.java
create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponRepositoryImpl.java
create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/UserCouponJpaRepository.java
create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/UserCouponRepositoryImpl.java
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..d4d1813d3 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,7 +16,7 @@
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;
@@ -35,6 +41,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 +51,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,11 +87,18 @@ 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<>();
@@ -67,7 +110,11 @@ public OrderInfo createOrder(String userId, List commands) {
String.format("상품이 중복되었습니다. (상품 ID: %d)", command.productId()));
}
- Product product = productRepository.findById(command.productId())
+ // 비관적 락을 사용하여 상품 조회 (재고 차감 시 동시성 제어)
+ // - id는 PK 인덱스가 있어 Lock 범위 최소화 (Record Lock만 적용)
+ // - Lost Update 방지: 동시 주문 시 재고 음수 방지 및 정확한 차감 보장 (재고 oversell 방지)
+ // - 트랜잭션 내부에 외부 I/O 없음, lock holding time 매우 짧음
+ Product product = productRepository.findByIdForUpdate(command.productId())
.orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND,
String.format("상품을 찾을 수 없습니다. (상품 ID: %d)", command.productId())));
products.add(product);
@@ -80,7 +127,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());
@@ -127,7 +181,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 +197,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 +252,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/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/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 발생
+ *
+ *
+ *
+ * 동작 원리:
+ *
+ * 일반 조회로 UserCoupon 엔티티 로드 (version 포함)
+ * 쿠폰 사용 처리 (isUsed = true)
+ * 저장 시 version 체크 → 다른 트랜잭션이 먼저 수정했다면 OptimisticLockException 발생
+ * 예외 발생 시 쿠폰 사용 실패 처리
+ *
+ *
+ *
+ * @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/test/java/com/loopers/application/purchasing/PurchasingFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadeTest.java
index 0809b50cc..a1b53c3b4 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() {
@@ -239,4 +282,128 @@ void getOrder_withDifferentUser_throwsException() {
.hasFieldOrPropertyWithValue("errorType", ErrorType.NOT_FOUND);
}
+ @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);
+ }
+
}
From e229a4fbf0aa5991d9d38edb9b407bdeed10842e Mon Sep 17 00:00:00 2001
From: minor7295 <44902090+minor7295@users.noreply.github.com>
Date: Fri, 21 Nov 2025 02:27:08 +0900
Subject: [PATCH 5/5] Feature/concurrency purchasing (#18)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* test: 주문 동시성 테스트 로직 추가
* test: 주문 흐름의 원자성을 검증하는 테스트 코드 추가
* feat: 비관적 락 적용하여 주문 동시성 이슈 발생하지 않도록 함
* refactor: deadlock 문제 수정
---
.../purchasing/PurchasingFacade.java | 78 +++-
.../com/loopers/domain/order/OrderItem.java | 1 -
.../loopers/domain/user/UserRepository.java | 19 +
.../user/UserJpaRepository.java | 35 ++
.../user/UserRepositoryImpl.java | 8 +
.../PurchasingFacadeConcurrencyTest.java | 358 ++++++++++++++++++
.../purchasing/PurchasingFacadeTest.java | 173 ++++++++-
7 files changed, 644 insertions(+), 28 deletions(-)
create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadeConcurrencyTest.java
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 d4d1813d3..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
@@ -21,11 +21,9 @@
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;
/**
@@ -100,23 +98,39 @@ public OrderInfo createOrder(String userId, List commands) {
// - 트랜잭션 내부에 외부 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<>();
+ for (Long productId : sortedProductIds) {
// 비관적 락을 사용하여 상품 조회 (재고 차감 시 동시성 제어)
// - id는 PK 인덱스가 있어 Lock 범위 최소화 (Record Lock만 적용)
// - Lost Update 방지: 동시 주문 시 재고 음수 방지 및 정확한 차감 보장 (재고 oversell 방지)
// - 트랜잭션 내부에 외부 I/O 없음, lock holding time 매우 짧음
- Product product = productRepository.findByIdForUpdate(command.productId())
+ // - ✅ 정렬된 순서로 락 획득하여 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(
@@ -150,6 +164,13 @@ public OrderInfo createOrder(String userId, List commands) {
/**
* 주문을 취소하고 포인트를 환불하며 재고를 원복한다.
+ *
+ * 동시성 제어:
+ *
+ * 비관적 락 사용: 재고 원복 시 동시성 제어를 위해 findByIdForUpdate 사용
+ * Deadlock 방지: 상품 ID를 정렬하여 일관된 락 획득 순서 보장
+ *
+ *
*
* @param order 주문 엔티티
* @param user 사용자 엔티티
@@ -160,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);
}
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/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/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 범위 최소화
+ *
+ *
+ *
+ * 동작 원리:
+ *
+ * SELECT ... FOR UPDATE 실행 → 해당 행에 배타적 락 설정
+ * 다른 트랜잭션의 쓰기/FOR UPDATE는 차단 (일반 읽기는 가능)
+ * 포인트 차감 후 트랜잭션 커밋 → 락 해제
+ * 대기 중이던 트랜잭션이 최신 값을 읽어 처리
+ *
+ *
+ *
+ * @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/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 a1b53c3b4..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
@@ -116,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
@@ -156,7 +156,7 @@ void createOrder_userNotFound() {
// arrange
String unknownUserId = "unknown";
List commands = List.of(
- new OrderItemCommand(1L, 1)
+ OrderItemCommand.of(1L, 1)
);
// act & assert
@@ -175,9 +175,10 @@ 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(
- new OrderItemCommand(productId, 2)
+ OrderItemCommand.of(productId, 2)
);
// act & assert
@@ -185,9 +186,73 @@ 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("상품 재고가 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(
+ 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(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
@@ -202,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
@@ -225,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);
@@ -246,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);
@@ -271,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();
@@ -282,6 +347,94 @@ 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() {