From 1e0d095b6d9f64b2936f381903993913ec9ef5a6 Mon Sep 17 00:00:00 2001
From: minor7295
Date: Wed, 10 Dec 2025 00:49:42 +0900
Subject: [PATCH 1/3] =?UTF-8?q?refactor:=20application=20=EB=A0=88?=
=?UTF-8?q?=EC=9D=B4=EC=96=B4=EB=A5=BC=20=EB=8F=84=EB=A9=94=EC=9D=B8?=
=?UTF-8?q?=EB=B3=84=20=EC=96=B4=ED=94=8C=EB=A6=AC=EC=BC=80=EC=9D=B4?=
=?UTF-8?q?=EC=85=98=20=EC=84=9C=EB=B9=84=EC=8A=A4=EC=99=80=20=EC=96=B4?=
=?UTF-8?q?=ED=94=8C=EB=A6=AC=EC=BC=80=EC=9D=B4=EC=85=98=EC=9D=98=20?=
=?UTF-8?q?=EC=A1=B0=ED=95=A9=EC=9D=B8=20facade=EB=A1=9C=20=EB=B6=84?=
=?UTF-8?q?=EB=A6=AC=ED=95=98=EC=97=AC=20=EA=B5=AC=EC=84=B1?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../BrandService.java} | 39 ++++++-
...gProductFacade.java => CatalogFacade.java} | 23 ++--
.../coupon/CouponService.java | 12 +-
.../HeartFacade.java} | 40 +++----
.../loopers/application/like/LikeService.java | 70 ++++++++++++
.../order/OrderService.java | 17 ++-
.../PaymentRequestCommand.java | 2 +-
.../payment/PaymentService.java | 30 +++--
.../ProductCacheService.java | 4 +-
.../application/product/ProductService.java | 108 ++++++++++++++++++
.../purchasing/PurchasingFacade.java | 42 ++++---
.../scheduler/LikeCountSyncScheduler.java | 98 ----------------
.../application/signup/SignUpFacade.java | 74 ------------
.../application/signup/SignUpInfo.java | 38 ------
.../user/UserService.java | 69 +++++++++--
.../application/userinfo/UserInfoFacade.java | 69 -----------
.../batch/LikeCountSyncBatchConfig.java | 2 +-
.../domain/payment/PaymentGateway.java | 2 +-
.../domain/product/ProductService.java | 53 ---------
.../payment/PaymentGatewayImpl.java | 2 +-
.../api/catalog/BrandV1Controller.java | 6 +-
.../interfaces/api/catalog/BrandV1Dto.java | 4 +-
.../api/catalog/ProductV1Controller.java | 8 +-
.../interfaces/api/like/LikeV1Controller.java | 10 +-
.../interfaces/api/like/LikeV1Dto.java | 6 +-
.../pointwallet/PointWalletV1Controller.java | 8 +-
.../api/pointwallet/PointWalletV1Dto.java | 4 +-
27 files changed, 388 insertions(+), 452 deletions(-)
rename apps/commerce-api/src/main/java/com/loopers/application/{catalog/CatalogBrandFacade.java => brand/BrandService.java} (57%)
rename apps/commerce-api/src/main/java/com/loopers/application/catalog/{CatalogProductFacade.java => CatalogFacade.java} (85%)
rename apps/commerce-api/src/main/java/com/loopers/{domain => application}/coupon/CouponService.java (89%)
rename apps/commerce-api/src/main/java/com/loopers/application/{like/LikeFacade.java => heart/HeartFacade.java} (86%)
create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/like/LikeService.java
rename apps/commerce-api/src/main/java/com/loopers/{domain => application}/order/OrderService.java (92%)
rename apps/commerce-api/src/main/java/com/loopers/application/{purchasing => payment}/PaymentRequestCommand.java (96%)
rename apps/commerce-api/src/main/java/com/loopers/{domain => application}/payment/PaymentService.java (93%)
rename apps/commerce-api/src/main/java/com/loopers/application/{catalog => product}/ProductCacheService.java (98%)
create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java
delete mode 100644 apps/commerce-api/src/main/java/com/loopers/application/scheduler/LikeCountSyncScheduler.java
delete mode 100644 apps/commerce-api/src/main/java/com/loopers/application/signup/SignUpFacade.java
delete mode 100644 apps/commerce-api/src/main/java/com/loopers/application/signup/SignUpInfo.java
rename apps/commerce-api/src/main/java/com/loopers/{domain => application}/user/UserService.java (59%)
delete mode 100644 apps/commerce-api/src/main/java/com/loopers/application/userinfo/UserInfoFacade.java
delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java
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/brand/BrandService.java
similarity index 57%
rename from apps/commerce-api/src/main/java/com/loopers/application/catalog/CatalogBrandFacade.java
rename to apps/commerce-api/src/main/java/com/loopers/application/brand/BrandService.java
index 4cdc2f177..94f1a63b8 100644
--- a/apps/commerce-api/src/main/java/com/loopers/application/catalog/CatalogBrandFacade.java
+++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandService.java
@@ -1,4 +1,4 @@
-package com.loopers.application.catalog;
+package com.loopers.application.brand;
import com.loopers.domain.brand.Brand;
import com.loopers.domain.brand.BrandRepository;
@@ -6,6 +6,9 @@
import com.loopers.support.error.ErrorType;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.List;
/**
* 브랜드 조회 파사드.
@@ -18,9 +21,36 @@
*/
@RequiredArgsConstructor
@Component
-public class CatalogBrandFacade {
+public class BrandService {
private final BrandRepository brandRepository;
+ /**
+ * 브랜드 ID로 브랜드를 조회합니다.
+ *
+ * @param brandId 브랜드 ID
+ * @return 조회된 브랜드
+ * @throws CoreException 브랜드를 찾을 수 없는 경우
+ */
+ @Transactional(readOnly = true)
+ public Brand getBrand(Long brandId) {
+ return brandRepository.findById(brandId)
+ .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "브랜드를 찾을 수 없습니다."));
+ }
+
+ /**
+ * 브랜드 ID 목록으로 브랜드 목록을 조회합니다.
+ *
+ * 배치 조회를 통해 N+1 쿼리 문제를 해결합니다.
+ *
+ *
+ * @param brandIds 조회할 브랜드 ID 목록
+ * @return 조회된 브랜드 목록
+ */
+ @Transactional(readOnly = true)
+ public List getBrands(List brandIds) {
+ return brandRepository.findAllById(brandIds);
+ }
+
/**
* 브랜드 정보를 조회합니다.
*
@@ -28,9 +58,8 @@ public class CatalogBrandFacade {
* @return 브랜드 정보
* @throws CoreException 브랜드를 찾을 수 없는 경우
*/
- public BrandInfo getBrand(Long brandId) {
- Brand brand = brandRepository.findById(brandId)
- .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "브랜드를 찾을 수 없습니다."));
+ public BrandInfo getBrandInfo(Long brandId) {
+ Brand brand = getBrand(brandId);
return BrandInfo.from(brand);
}
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/CatalogFacade.java
similarity index 85%
rename from apps/commerce-api/src/main/java/com/loopers/application/catalog/CatalogProductFacade.java
rename to apps/commerce-api/src/main/java/com/loopers/application/catalog/CatalogFacade.java
index 1ebf5c394..f46e74301 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/CatalogFacade.java
@@ -1,10 +1,11 @@
package com.loopers.application.catalog;
+import com.loopers.application.brand.BrandService;
+import com.loopers.application.product.ProductCacheService;
+import com.loopers.application.product.ProductService;
import com.loopers.domain.brand.Brand;
-import com.loopers.domain.brand.BrandRepository;
import com.loopers.domain.product.Product;
import com.loopers.domain.product.ProductDetail;
-import com.loopers.domain.product.ProductRepository;
import com.loopers.support.error.CoreException;
import com.loopers.support.error.ErrorType;
import lombok.RequiredArgsConstructor;
@@ -25,9 +26,9 @@
*/
@RequiredArgsConstructor
@Component
-public class CatalogProductFacade {
- private final ProductRepository productRepository;
- private final BrandRepository brandRepository;
+public class CatalogFacade {
+ private final BrandService brandService;
+ private final ProductService productService;
private final ProductCacheService productCacheService;
/**
@@ -54,8 +55,8 @@ public ProductInfoList getProducts(Long brandId, String sort, int page, int size
}
// 캐시에 없으면 DB에서 조회
- long totalCount = productRepository.countAll(brandId);
- List products = productRepository.findAll(brandId, normalizedSort, page, size);
+ long totalCount = productService.countAll(brandId);
+ List products = productService.findAll(brandId, normalizedSort, page, size);
if (products.isEmpty()) {
ProductInfoList emptyResult = new ProductInfoList(List.of(), totalCount, page, size);
@@ -72,7 +73,7 @@ public ProductInfoList getProducts(Long brandId, String sort, int page, int size
.toList();
// 브랜드 배치 조회 및 Map으로 변환 (O(1) 조회를 위해)
- Map brandMap = brandRepository.findAllById(brandIds).stream()
+ Map brandMap = brandService.getBrands(brandIds).stream()
.collect(Collectors.toMap(Brand::getId, brand -> brand));
// 상품 정보 변환 (이미 조회한 Product 재사용)
@@ -116,12 +117,10 @@ public ProductInfo getProduct(Long productId) {
}
// 캐시에 없으면 DB에서 조회
- Product product = productRepository.findById(productId)
- .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다."));
+ Product product = productService.getProduct(productId);
// 브랜드 조회
- Brand brand = brandRepository.findById(product.getBrandId())
- .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "브랜드를 찾을 수 없습니다."));
+ Brand brand = brandService.getBrand(product.getBrandId());
// ✅ Product.likeCount 필드 사용 (비동기 집계된 값)
Long likesCount = product.getLikeCount();
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponService.java b/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponService.java
similarity index 89%
rename from apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponService.java
rename to apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponService.java
index ec0b09d2b..b99503199 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponService.java
+++ b/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponService.java
@@ -1,5 +1,9 @@
-package com.loopers.domain.coupon;
+package com.loopers.application.coupon;
+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.support.error.CoreException;
import com.loopers.support.error.ErrorType;
@@ -9,10 +13,10 @@
import org.springframework.transaction.annotation.Transactional;
/**
- * 쿠폰 도메인 서비스.
+ * 쿠폰 애플리케이션 서비스.
*
- * 쿠폰 조회, 사용 등의 도메인 로직을 처리합니다.
- * Repository에 의존하며 비즈니스 규칙을 캡슐화합니다.
+ * 쿠폰 조회, 사용 등의 애플리케이션 로직을 처리합니다.
+ * Repository에 의존하며 트랜잭션 관리 및 동시성 제어를 담당합니다.
*
*
* @author Loopers
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/heart/HeartFacade.java
similarity index 86%
rename from apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java
rename to apps/commerce-api/src/main/java/com/loopers/application/heart/HeartFacade.java
index de21d46b5..f562072d2 100644
--- a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java
+++ b/apps/commerce-api/src/main/java/com/loopers/application/heart/HeartFacade.java
@@ -1,12 +1,12 @@
-package com.loopers.application.like;
+package com.loopers.application.heart;
-import com.loopers.application.catalog.ProductCacheService;
+import com.loopers.application.like.LikeService;
+import com.loopers.application.product.ProductCacheService;
+import com.loopers.application.product.ProductService;
+import com.loopers.application.user.UserService;
import com.loopers.domain.like.Like;
-import com.loopers.domain.like.LikeRepository;
import com.loopers.domain.product.Product;
-import com.loopers.domain.product.ProductRepository;
import com.loopers.domain.user.User;
-import com.loopers.domain.user.UserRepository;
import com.loopers.support.error.CoreException;
import com.loopers.support.error.ErrorType;
import org.springframework.transaction.annotation.Transactional;
@@ -29,10 +29,10 @@
*/
@RequiredArgsConstructor
@Component
-public class LikeFacade {
- private final LikeRepository likeRepository;
- private final UserRepository userRepository;
- private final ProductRepository productRepository;
+public class HeartFacade {
+ private final LikeService likeService;
+ private final UserService userService;
+ private final ProductService productService;
private final ProductCacheService productCacheService;
/**
@@ -69,7 +69,7 @@ public void addLike(String userId, Long productId) {
// 먼저 일반 조회로 중복 체크 (대부분의 경우 빠르게 처리)
// ⚠️ 주의: 애플리케이션 레벨 체크만으로는 race condition을 완전히 방지할 수 없음
// 동시에 두 요청이 들어오면 둘 다 "없음"으로 판단 → 둘 다 저장 시도 가능
- Optional existingLike = likeRepository.findByUserIdAndProductId(user.getId(), productId);
+ Optional existingLike = likeService.getLike(user.getId(), productId);
if (existingLike.isPresent()) {
return;
}
@@ -79,7 +79,7 @@ public void addLike(String userId, Long productId) {
// @Transactional이 없어도 save() 호출 시 자동 트랜잭션으로 예외를 catch할 수 있음
Like like = Like.of(user.getId(), productId);
try {
- likeRepository.save(like);
+ likeService.save(like);
// 좋아요 추가 성공 시 로컬 캐시의 델타 증가
productCacheService.incrementLikeCountDelta(productId);
} catch (org.springframework.dao.DataIntegrityViolationException e) {
@@ -105,13 +105,13 @@ public void removeLike(String userId, Long productId) {
User user = loadUser(userId);
loadProduct(productId);
- Optional like = likeRepository.findByUserIdAndProductId(user.getId(), productId);
+ Optional like = likeService.getLike(user.getId(), productId);
if (like.isEmpty()) {
return;
}
try {
- likeRepository.delete(like.get());
+ likeService.delete(like.get());
// 좋아요 취소 성공 시 로컬 캐시의 델타 감소
productCacheService.decrementLikeCountDelta(productId);
} catch (Exception e) {
@@ -144,7 +144,7 @@ public List getLikedProducts(String userId) {
User user = loadUser(userId);
// 사용자의 좋아요 목록 조회
- List likes = likeRepository.findAllByUserId(user.getId());
+ List likes = likeService.getLikesByUserId(user.getId());
if (likes.isEmpty()) {
return List.of();
@@ -156,7 +156,7 @@ public List getLikedProducts(String userId) {
.toList();
// ✅ 배치 조회로 N+1 쿼리 문제 해결
- Map productMap = productRepository.findAllById(productIds).stream()
+ Map productMap = productService.getProducts(productIds).stream()
.collect(Collectors.toMap(Product::getId, product -> product));
// 요청한 상품 ID와 조회된 상품 수가 일치하는지 확인
@@ -180,17 +180,11 @@ public List getLikedProducts(String userId) {
}
private User loadUser(String userId) {
- User user = userRepository.findByUserId(userId);
- if (user == null) {
- throw new CoreException(ErrorType.NOT_FOUND, "사용자를 찾을 수 없습니다.");
- }
- return user;
+ return userService.getUser(userId);
}
private Product loadProduct(Long productId) {
- return productRepository.findById(productId)
- .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND,
- String.format("상품을 찾을 수 없습니다. (상품 ID: %d)", productId)));
+ return productService.getProduct(productId);
}
/**
diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeService.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeService.java
new file mode 100644
index 000000000..f421433cd
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeService.java
@@ -0,0 +1,70 @@
+package com.loopers.application.like;
+
+import com.loopers.domain.like.Like;
+import com.loopers.domain.like.LikeRepository;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Component;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * 좋아요 애플리케이션 서비스.
+ *
+ * 좋아요 조회, 저장, 삭제 등의 애플리케이션 로직을 처리합니다.
+ * Repository에 의존하며 트랜잭션 관리를 담당합니다.
+ *
+ *
+ * @author Loopers
+ * @version 1.0
+ */
+@RequiredArgsConstructor
+@Component
+public class LikeService {
+ private final LikeRepository likeRepository;
+
+ /**
+ * 사용자 ID와 상품 ID로 좋아요를 조회합니다.
+ *
+ * @param userId 사용자 ID
+ * @param productId 상품 ID
+ * @return 조회된 좋아요를 담은 Optional
+ */
+ @Transactional(readOnly = true)
+ public Optional getLike(Long userId, Long productId) {
+ return likeRepository.findByUserIdAndProductId(userId, productId);
+ }
+
+ /**
+ * 좋아요를 저장합니다.
+ *
+ * @param like 저장할 좋아요
+ * @return 저장된 좋아요
+ */
+ @Transactional
+ public Like save(Like like) {
+ return likeRepository.save(like);
+ }
+
+ /**
+ * 좋아요를 삭제합니다.
+ *
+ * @param like 삭제할 좋아요
+ */
+ @Transactional
+ public void delete(Like like) {
+ likeRepository.delete(like);
+ }
+
+ /**
+ * 사용자 ID로 좋아요한 상품 목록을 조회합니다.
+ *
+ * @param userId 사용자 ID
+ * @return 좋아요 목록
+ */
+ @Transactional(readOnly = true)
+ public List getLikesByUserId(Long userId) {
+ return likeRepository.findAllByUserId(userId);
+ }
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderService.java
similarity index 92%
rename from apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java
rename to apps/commerce-api/src/main/java/com/loopers/application/order/OrderService.java
index e74c024d7..18f9f8c2e 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java
+++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderService.java
@@ -1,5 +1,9 @@
-package com.loopers.domain.order;
+package com.loopers.application.order;
+import com.loopers.domain.order.Order;
+import com.loopers.domain.order.OrderItem;
+import com.loopers.domain.order.OrderRepository;
+import com.loopers.domain.order.OrderStatus;
import com.loopers.domain.payment.PaymentStatus;
import com.loopers.domain.product.Product;
import com.loopers.domain.user.Point;
@@ -15,9 +19,10 @@
import java.util.Optional;
/**
- * 주문 도메인 서비스.
+ * 주문 애플리케이션 서비스.
*
- * 주문의 기본 CRUD 및 상태 변경을 담당합니다.
+ * 주문의 기본 CRUD 및 상태 변경을 담당하는 애플리케이션 서비스입니다.
+ * Repository에 의존하며 트랜잭션 관리를 담당합니다.
*
*
* @author Loopers
@@ -60,7 +65,7 @@ public Order getById(Long orderId) {
* @return 조회된 주문 (없으면 Optional.empty())
*/
@Transactional(readOnly = true)
- public Optional findById(Long orderId) {
+ public Optional getOrder(Long orderId) {
return orderRepository.findById(orderId);
}
@@ -71,7 +76,7 @@ public Optional findById(Long orderId) {
* @return 해당 사용자의 주문 목록
*/
@Transactional(readOnly = true)
- public List findAllByUserId(Long userId) {
+ public List getOrdersByUserId(Long userId) {
return orderRepository.findAllByUserId(userId);
}
@@ -82,7 +87,7 @@ public List findAllByUserId(Long userId) {
* @return 해당 상태의 주문 목록
*/
@Transactional(readOnly = true)
- public List findAllByStatus(OrderStatus status) {
+ public List getOrdersByStatus(OrderStatus status) {
return orderRepository.findAllByStatus(status);
}
diff --git a/apps/commerce-api/src/main/java/com/loopers/application/purchasing/PaymentRequestCommand.java b/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentRequestCommand.java
similarity index 96%
rename from apps/commerce-api/src/main/java/com/loopers/application/purchasing/PaymentRequestCommand.java
rename to apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentRequestCommand.java
index 3834136a6..b028afc3f 100644
--- a/apps/commerce-api/src/main/java/com/loopers/application/purchasing/PaymentRequestCommand.java
+++ b/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentRequestCommand.java
@@ -1,4 +1,4 @@
-package com.loopers.application.purchasing;
+package com.loopers.application.payment;
import com.loopers.support.error.CoreException;
import com.loopers.support.error.ErrorType;
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentService.java b/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentService.java
similarity index 93%
rename from apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentService.java
rename to apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentService.java
index b7308675c..773c9b27d 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentService.java
+++ b/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentService.java
@@ -1,6 +1,6 @@
-package com.loopers.domain.payment;
+package com.loopers.application.payment;
-import com.loopers.application.purchasing.PaymentRequestCommand;
+import com.loopers.domain.payment.*;
import com.loopers.support.error.CoreException;
import com.loopers.support.error.ErrorType;
import lombok.RequiredArgsConstructor;
@@ -15,10 +15,10 @@
import java.util.Optional;
/**
- * 결제 도메인 서비스.
+ * 결제 애플리케이션 서비스.
*
- * 결제의 생성, 조회, 상태 변경 및 PG 연동을 담당합니다.
- * 도메인 로직은 Payment 엔티티에 위임하며, Service는 조회/저장 및 PG 연동을 담당합니다.
+ * 결제의 생성, 조회, 상태 변경 및 PG 연동을 담당하는 애플리케이션 서비스입니다.
+ * 도메인 로직은 Payment 엔티티에 위임하며, Service는 조회/저장, 트랜잭션 관리 및 PG 연동을 담당합니다.
*
*
* @author Loopers
@@ -30,7 +30,7 @@
public class PaymentService {
private final PaymentRepository paymentRepository;
- private final PaymentGateway paymentGateway; // 인터페이스에 의존 (DIP 준수)
+ private final PaymentGateway paymentGateway;
private final PaymentFailureClassifier paymentFailureClassifier;
@Value("${payment.callback.base-url}")
@@ -123,8 +123,7 @@ public Payment create(
*/
@Transactional
public void toSuccess(Long paymentId, LocalDateTime completedAt) {
- Payment payment = paymentRepository.findById(paymentId)
- .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "결제를 찾을 수 없습니다."));
+ Payment payment = getPayment(paymentId);
payment.toSuccess(completedAt); // Entity에 위임
paymentRepository.save(payment);
}
@@ -142,8 +141,7 @@ public void toSuccess(Long paymentId, LocalDateTime completedAt) {
*/
@Transactional
public void toFailed(Long paymentId, String failureReason, LocalDateTime completedAt) {
- Payment payment = paymentRepository.findById(paymentId)
- .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "결제를 찾을 수 없습니다."));
+ Payment payment = getPayment(paymentId);
payment.toFailed(failureReason, completedAt); // Entity에 위임
paymentRepository.save(payment);
}
@@ -156,7 +154,7 @@ public void toFailed(Long paymentId, String failureReason, LocalDateTime complet
* @throws CoreException 결제를 찾을 수 없는 경우
*/
@Transactional(readOnly = true)
- public Payment findById(Long paymentId) {
+ public Payment getPayment(Long paymentId) {
return paymentRepository.findById(paymentId)
.orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "결제를 찾을 수 없습니다."));
}
@@ -168,7 +166,7 @@ public Payment findById(Long paymentId) {
* @return 조회된 Payment (없으면 Optional.empty())
*/
@Transactional(readOnly = true)
- public Optional findByOrderId(Long orderId) {
+ public Optional getPaymentByOrderId(Long orderId) {
return paymentRepository.findByOrderId(orderId);
}
@@ -179,7 +177,7 @@ public Optional findByOrderId(Long orderId) {
* @return 해당 사용자의 결제 목록
*/
@Transactional(readOnly = true)
- public List findAllByUserId(Long userId) {
+ public List getPaymentsByUserId(Long userId) {
return paymentRepository.findAllByUserId(userId);
}
@@ -190,7 +188,7 @@ public List findAllByUserId(Long userId) {
* @return 해당 상태의 결제 목록
*/
@Transactional(readOnly = true)
- public List findAllByStatus(PaymentStatus status) {
+ public List getPaymentsByStatus(PaymentStatus status) {
return paymentRepository.findAllByStatus(status);
}
@@ -286,7 +284,7 @@ public PaymentStatus getPaymentStatus(String userId, Long orderId) {
*/
@Transactional
public void handleCallback(Long orderId, String transactionKey, PaymentStatus status, String reason) {
- Optional paymentOpt = findByOrderId(orderId);
+ Optional paymentOpt = getPaymentByOrderId(orderId);
if (paymentOpt.isEmpty()) {
log.warn("콜백 처리 시 결제를 찾을 수 없습니다. (orderId: {})", orderId);
return;
@@ -326,7 +324,7 @@ public void recoverAfterTimeout(String userId, Long orderId, Duration delayDurat
// 결제 상태 조회
PaymentStatus status = getPaymentStatus(userId, orderId);
- Optional paymentOpt = findByOrderId(orderId);
+ Optional paymentOpt = getPaymentByOrderId(orderId);
if (paymentOpt.isEmpty()) {
log.warn("복구 시 결제를 찾을 수 없습니다. (orderId: {})", orderId);
diff --git a/apps/commerce-api/src/main/java/com/loopers/application/catalog/ProductCacheService.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductCacheService.java
similarity index 98%
rename from apps/commerce-api/src/main/java/com/loopers/application/catalog/ProductCacheService.java
rename to apps/commerce-api/src/main/java/com/loopers/application/product/ProductCacheService.java
index 8428efa50..4a1e43a23 100644
--- a/apps/commerce-api/src/main/java/com/loopers/application/catalog/ProductCacheService.java
+++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductCacheService.java
@@ -1,7 +1,9 @@
-package com.loopers.application.catalog;
+package com.loopers.application.product;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
+import com.loopers.application.catalog.ProductInfo;
+import com.loopers.application.catalog.ProductInfoList;
import com.loopers.domain.product.ProductDetail;
import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.RedisTemplate;
diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java
new file mode 100644
index 000000000..82703ba2e
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java
@@ -0,0 +1,108 @@
+package com.loopers.application.product;
+
+import com.loopers.domain.product.Product;
+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 org.springframework.transaction.annotation.Transactional;
+
+import java.util.List;
+
+/**
+ * 상품 애플리케이션 서비스.
+ *
+ * 상품 조회, 저장 등의 애플리케이션 로직을 처리합니다.
+ * Repository에 의존하며 트랜잭션 관리를 담당합니다.
+ *
+ *
+ * @author Loopers
+ * @version 1.0
+ */
+@RequiredArgsConstructor
+@Component
+public class ProductService {
+ private final ProductRepository productRepository;
+
+ /**
+ * 상품 ID로 상품을 조회합니다.
+ *
+ * @param productId 조회할 상품 ID
+ * @return 조회된 상품
+ * @throws CoreException 상품을 찾을 수 없는 경우
+ */
+ @Transactional(readOnly = true)
+ public Product getProduct(Long productId) {
+ return productRepository.findById(productId)
+ .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND,
+ String.format("상품을 찾을 수 없습니다. (상품 ID: %d)", productId)));
+ }
+
+ /**
+ * 상품 ID 목록으로 상품 목록을 조회합니다.
+ *
+ * 배치 조회를 통해 N+1 쿼리 문제를 해결합니다.
+ *
+ *
+ * @param productIds 조회할 상품 ID 목록
+ * @return 조회된 상품 목록
+ */
+ @Transactional(readOnly = true)
+ public List getProducts(List productIds) {
+ return productRepository.findAllById(productIds);
+ }
+
+ /**
+ * 상품 ID로 상품을 조회합니다. (비관적 락)
+ *
+ * 동시성 제어가 필요한 경우 사용합니다. (예: 재고 차감)
+ *
+ *
+ * @param productId 조회할 상품 ID
+ * @return 조회된 상품
+ * @throws CoreException 상품을 찾을 수 없는 경우
+ */
+ @Transactional
+ public Product getProductForUpdate(Long productId) {
+ return productRepository.findByIdForUpdate(productId)
+ .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND,
+ String.format("상품을 찾을 수 없습니다. (상품 ID: %d)", productId)));
+ }
+
+ /**
+ * 상품 목록을 저장합니다.
+ *
+ * @param products 저장할 상품 목록
+ */
+ @Transactional
+ public void saveAll(List products) {
+ products.forEach(productRepository::save);
+ }
+
+ /**
+ * 상품 목록을 조회합니다.
+ *
+ * @param brandId 브랜드 ID (null이면 전체 조회)
+ * @param sort 정렬 기준 (latest, price_asc, likes_desc)
+ * @param page 페이지 번호 (0부터 시작)
+ * @param size 페이지당 상품 수
+ * @return 상품 목록
+ */
+ @Transactional(readOnly = true)
+ public List findAll(Long brandId, String sort, int page, int size) {
+ return productRepository.findAll(brandId, sort, page, size);
+ }
+
+ /**
+ * 상품 목록의 총 개수를 조회합니다.
+ *
+ * @param brandId 브랜드 ID (null이면 전체 조회)
+ * @return 상품 총 개수
+ */
+ @Transactional(readOnly = true)
+ public long countAll(Long brandId) {
+ return productRepository.countAll(brandId);
+ }
+}
+
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 3da1072dd..15a1fc404 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
@@ -2,17 +2,17 @@
import com.loopers.domain.order.Order;
import com.loopers.domain.order.OrderItem;
-import com.loopers.domain.order.OrderService;
+import com.loopers.application.order.OrderService;
import com.loopers.domain.order.OrderStatus;
import com.loopers.domain.product.Product;
-import com.loopers.domain.product.ProductService;
+import com.loopers.application.product.ProductService;
import com.loopers.domain.user.Point;
import com.loopers.domain.user.User;
-import com.loopers.domain.user.UserService;
-import com.loopers.domain.coupon.CouponService;
+import com.loopers.application.user.UserService;
+import com.loopers.application.coupon.CouponService;
import com.loopers.infrastructure.payment.PaymentGatewayDto;
import com.loopers.domain.payment.PaymentRequestResult;
-import com.loopers.domain.payment.PaymentService;
+import com.loopers.application.payment.PaymentService;
import com.loopers.domain.payment.Payment;
import com.loopers.domain.payment.PaymentStatus;
import com.loopers.domain.payment.CardType;
@@ -38,8 +38,12 @@
/**
* 구매 파사드.
*
- * 주문 생성과 결제(포인트 차감), 재고 조정, 외부 연동을 조율한다.
+ * 주문 생성과 결제(포인트 차감), 재고 조정, 외부 연동을 조율하는 애플리케이션 서비스입니다.
+ * 여러 도메인 서비스를 조합하여 구매 유즈케이스를 처리합니다.
*
+ *
+ * @author Loopers
+ * @version 1.0
*/
@Slf4j
@RequiredArgsConstructor
@@ -120,7 +124,7 @@ public OrderInfo createOrder(String userId, List commands, Lon
// - userId는 UNIQUE 인덱스가 있어 Lock 범위 최소화 (Record Lock만 적용)
// - Lost Update 방지: 동시 주문 시 포인트 중복 차감 방지 (금전적 손실 방지)
// - 트랜잭션 내부에 외부 I/O 없음, lock holding time 매우 짧음
- User user = userService.findByUserIdForUpdate(userId);
+ User user = userService.getUserForUpdate(userId);
// ✅ Deadlock 방지: 상품 ID를 정렬하여 일관된 락 획득 순서 보장
// 여러 상품을 주문할 때, 항상 동일한 순서로 락을 획득하여 deadlock 방지
@@ -144,7 +148,7 @@ public OrderInfo createOrder(String userId, List commands, Lon
// - Lost Update 방지: 동시 주문 시 재고 음수 방지 및 정확한 차감 보장 (재고 oversell 방지)
// - 트랜잭션 내부에 외부 I/O 없음, lock holding time 매우 짧음
// - ✅ 정렬된 순서로 락 획득하여 deadlock 방지
- Product product = productService.findByIdForUpdate(productId);
+ Product product = productService.getProductForUpdate(productId);
productMap.put(productId, product);
}
@@ -296,7 +300,7 @@ public void cancelOrder(Order order, User user) {
}
// ✅ Deadlock 방지: User 락을 먼저 획득하여 createOrder와 동일한 락 획득 순서 보장
- User lockedUser = userService.findByUserIdForUpdate(user.getUserId());
+ User lockedUser = userService.getUserForUpdate(user.getUserId());
if (lockedUser == null) {
throw new CoreException(ErrorType.NOT_FOUND, "사용자를 찾을 수 없습니다.");
}
@@ -311,7 +315,7 @@ public void cancelOrder(Order order, User user) {
// 정렬된 순서대로 상품 락 획득 (Deadlock 방지)
Map productMap = new java.util.HashMap<>();
for (Long productId : sortedProductIds) {
- Product product = productService.findByIdForUpdate(productId);
+ Product product = productService.getProductForUpdate(productId);
productMap.put(productId, product);
}
@@ -321,7 +325,7 @@ public void cancelOrder(Order order, User user) {
.toList();
// 실제로 사용된 포인트만 환불 (Payment에서 확인)
- Long refundPointAmount = paymentService.findByOrderId(order.getId())
+ Long refundPointAmount = paymentService.getPaymentByOrderId(order.getId())
.map(Payment::getUsedPoint)
.orElse(0L);
@@ -341,8 +345,8 @@ public void cancelOrder(Order order, User user) {
*/
@Transactional(readOnly = true)
public List getOrders(String userId) {
- User user = userService.findByUserId(userId);
- List orders = orderService.findAllByUserId(user.getId());
+ User user = userService.getUser(userId);
+ List orders = orderService.getOrdersByUserId(user.getId());
return orders.stream()
.map(OrderInfo::from)
.toList();
@@ -357,7 +361,7 @@ public List getOrders(String userId) {
*/
@Transactional(readOnly = true)
public OrderInfo getOrder(String userId, Long orderId) {
- User user = userService.findByUserId(userId);
+ User user = userService.getUser(userId);
Order order = orderService.getById(orderId);
if (!order.getUserId().equals(user.getId())) {
@@ -483,7 +487,7 @@ public boolean updateOrderStatusByPaymentResult(
String reason
) {
try {
- Order order = orderService.findById(orderId).orElse(null);
+ Order order = orderService.getOrder(orderId).orElse(null);
if (order == null) {
log.warn("주문 상태 업데이트 시 주문을 찾을 수 없습니다. (orderId: {})", orderId);
@@ -509,7 +513,7 @@ public boolean updateOrderStatusByPaymentResult(
return true;
} else if (paymentStatus == PaymentStatus.FAILED) {
// 결제 실패: 주문 취소 및 리소스 원복
- User user = userService.findById(order.getUserId());
+ User user = userService.getUserById(order.getUserId());
if (user == null) {
log.warn("주문 상태 업데이트 시 사용자를 찾을 수 없습니다. (orderId: {}, userId: {})",
orderId, order.getUserId());
@@ -551,7 +555,7 @@ public void updateOrderStatusToCompleted(Long orderId, String transactionKey) {
}
// Payment 상태 업데이트 (PaymentService 사용)
- paymentService.findByOrderId(orderId).ifPresent(payment -> {
+ paymentService.getPaymentByOrderId(orderId).ifPresent(payment -> {
if (payment.getStatus() == PaymentStatus.PENDING) {
paymentService.toSuccess(payment.getId(), java.time.LocalDateTime.now());
}
@@ -741,7 +745,7 @@ private PaymentGatewayDto.TransactionStatus verifyCallbackWithPgInquiry(
// User의 userId (String)를 가져오기 위해 User 조회
User user;
try {
- user = userService.findById(userId);
+ user = userService.getUserById(userId);
} catch (CoreException e) {
log.warn("콜백 검증 시 사용자를 찾을 수 없습니다. 콜백 정보를 사용합니다. (orderId: {}, userId: {})",
orderId, userId);
@@ -864,7 +868,7 @@ private void handlePaymentFailure(String userId, Long orderId, String errorCode,
// 사용자 조회 (Service를 통한 접근)
User user;
try {
- user = userService.findByUserId(userId);
+ user = userService.getUser(userId);
} catch (CoreException e) {
log.warn("결제 실패 처리 시 사용자를 찾을 수 없습니다. (userId: {}, orderId: {})", userId, orderId);
return;
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
deleted file mode 100644
index 4621b4fef..000000000
--- a/apps/commerce-api/src/main/java/com/loopers/application/scheduler/LikeCountSyncScheduler.java
+++ /dev/null
@@ -1,98 +0,0 @@
-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
deleted file mode 100644
index 293505b15..000000000
--- a/apps/commerce-api/src/main/java/com/loopers/application/signup/SignUpFacade.java
+++ /dev/null
@@ -1,74 +0,0 @@
-package com.loopers.application.signup;
-
-import com.loopers.domain.user.Gender;
-import com.loopers.domain.user.Point;
-import com.loopers.domain.user.User;
-import com.loopers.domain.user.UserService;
-import com.loopers.support.error.CoreException;
-import com.loopers.support.error.ErrorType;
-import jakarta.transaction.Transactional;
-import lombok.RequiredArgsConstructor;
-import org.springframework.stereotype.Component;
-
-import java.util.Locale;
-
-/**
- * 회원가입 파사드.
- *
- * 회원가입 시 사용자 생성을 처리하는 애플리케이션 서비스입니다.
- * 사용자 생성 시 포인트는 자동으로 0으로 초기화됩니다.
- * 트랜잭션 경계를 관리하여 데이터 일관성을 보장합니다.
- *
- *
- * @author Loopers
- * @version 1.0
- */
-@RequiredArgsConstructor
-@Component
-public class SignUpFacade {
- private final UserService userService;
-
- /**
- * 회원가입을 처리합니다.
- *
- * 사용자를 생성하며, 포인트는 자동으로 0으로 초기화됩니다.
- * 전체 과정이 하나의 트랜잭션으로 처리됩니다.
- *
- *
- * @param userId 사용자 ID
- * @param email 이메일 주소
- * @param birthDateStr 생년월일 (yyyy-MM-dd)
- * @param genderStr 성별 문자열 (MALE 또는 FEMALE)
- * @return 생성된 사용자 정보
- * @throws CoreException gender 값이 유효하지 않거나, 유효성 검증 실패 또는 중복 ID 존재 시
- */
- @Transactional
- public SignUpInfo signUp(String userId, String email, String birthDateStr, String genderStr) {
- Gender gender = parseGender(genderStr);
- Point point = Point.of(0L);
- User user = userService.create(userId, email, birthDateStr, gender, point);
- return SignUpInfo.from(user);
- }
-
- /**
- * 성별 문자열을 Gender enum으로 변환합니다.
- *
- * 도메인 진입점에서 방어 로직을 제공하여 NPE를 방지합니다.
- *
- *
- * @param genderStr 성별 문자열
- * @return Gender enum
- * @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);
- } catch (IllegalArgumentException e) {
- throw new CoreException(ErrorType.BAD_REQUEST, "gender 값이 올바르지 않습니다.");
- }
- }
-}
diff --git a/apps/commerce-api/src/main/java/com/loopers/application/signup/SignUpInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/signup/SignUpInfo.java
deleted file mode 100644
index c84caf7a3..000000000
--- a/apps/commerce-api/src/main/java/com/loopers/application/signup/SignUpInfo.java
+++ /dev/null
@@ -1,38 +0,0 @@
-package com.loopers.application.signup;
-
-import com.loopers.domain.user.Gender;
-import com.loopers.domain.user.User;
-
-import java.time.LocalDate;
-
-/**
- * 회원가입 결과 정보를 담는 레코드.
- *
- * User 도메인 엔티티로부터 생성된 불변 데이터 전송 객체입니다.
- *
- *
- * @param id 사용자 엔티티 ID
- * @param userId 사용자 ID
- * @param email 이메일 주소
- * @param birthDate 생년월일
- * @param gender 성별
- * @author Loopers
- * @version 1.0
- */
-public record SignUpInfo(Long id, String userId, String email, LocalDate birthDate, Gender gender) {
- /**
- * User 엔티티로부터 SignUpInfo를 생성합니다.
- *
- * @param user 변환할 사용자 엔티티
- * @return 생성된 SignUpInfo
- */
- public static SignUpInfo from(User user) {
- return new SignUpInfo(
- user.getId(),
- user.getUserId(),
- user.getEmail(),
- user.getBirthDate(),
- user.getGender()
- );
- }
-}
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java b/apps/commerce-api/src/main/java/com/loopers/application/user/UserService.java
similarity index 59%
rename from apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java
rename to apps/commerce-api/src/main/java/com/loopers/application/user/UserService.java
index 8c6d062e2..1b6689d34 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java
+++ b/apps/commerce-api/src/main/java/com/loopers/application/user/UserService.java
@@ -1,5 +1,9 @@
-package com.loopers.domain.user;
+package com.loopers.application.user;
+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.support.error.CoreException;
import com.loopers.support.error.ErrorType;
import lombok.RequiredArgsConstructor;
@@ -8,10 +12,10 @@
import org.springframework.transaction.annotation.Transactional;
/**
- * 사용자 도메인 서비스.
+ * 사용자 애플리케이션 서비스.
*
- * 사용자 생성, 조회 등의 도메인 로직을 처리합니다.
- * Repository에 의존하며 데이터 무결성 제약 조건을 처리합니다.
+ * 사용자 생성, 조회, 포인트 관리 등의 애플리케이션 로직을 처리합니다.
+ * Repository에 의존하며 트랜잭션 관리 및 데이터 무결성 제약 조건을 처리합니다.
*
*
* @author Loopers
@@ -52,7 +56,7 @@ public User create(String userId, String email, String birthDateStr, Gender gend
* @throws CoreException 사용자를 찾을 수 없는 경우
*/
@Transactional(readOnly = true)
- public User findByUserId(String userId) {
+ public User getUser(String userId) {
User user = userRepository.findByUserId(userId);
if (user == null) {
throw new CoreException(ErrorType.NOT_FOUND, "사용자를 찾을 수 없습니다.");
@@ -71,7 +75,7 @@ public User findByUserId(String userId) {
* @throws CoreException 사용자를 찾을 수 없는 경우
*/
@Transactional
- public User findByUserIdForUpdate(String userId) {
+ public User getUserForUpdate(String userId) {
User user = userRepository.findByUserIdForUpdate(userId);
if (user == null) {
throw new CoreException(ErrorType.NOT_FOUND, "사용자를 찾을 수 없습니다.");
@@ -87,7 +91,7 @@ public User findByUserIdForUpdate(String userId) {
* @throws CoreException 사용자를 찾을 수 없는 경우
*/
@Transactional(readOnly = true)
- public User findById(Long id) {
+ public User getUserById(Long id) {
User user = userRepository.findById(id);
if (user == null) {
throw new CoreException(ErrorType.NOT_FOUND, "사용자를 찾을 수 없습니다.");
@@ -105,4 +109,55 @@ public User findById(Long id) {
public User save(User user) {
return userRepository.save(user);
}
+
+ /**
+ * 사용자의 포인트를 조회합니다.
+ *
+ * @param userId 조회할 사용자 ID
+ * @return 포인트 정보
+ * @throws CoreException 사용자를 찾을 수 없는 경우
+ */
+ @Transactional(readOnly = true)
+ public PointsInfo getPoints(String userId) {
+ User user = getUser(userId);
+ return PointsInfo.from(user);
+ }
+
+ /**
+ * 사용자의 포인트를 충전합니다.
+ *
+ * 트랜잭션 내에서 실행되어 데이터 일관성을 보장합니다.
+ *
+ *
+ * @param userId 충전할 사용자 ID
+ * @param amount 충전할 포인트 금액
+ * @return 충전된 포인트 정보
+ * @throws CoreException 사용자를 찾을 수 없거나 충전 금액이 유효하지 않은 경우
+ */
+ @Transactional
+ public PointsInfo chargePoint(String userId, Long amount) {
+ User user = getUser(userId);
+ Point point = Point.of(amount);
+ user.receivePoint(point);
+ User savedUser = save(user);
+ return PointsInfo.from(savedUser);
+ }
+
+ /**
+ * 포인트 정보를 담는 레코드.
+ *
+ * @param userId 사용자 ID
+ * @param balance 포인트 잔액
+ */
+ public record PointsInfo(String userId, Long balance) {
+ /**
+ * User 엔티티로부터 PointsInfo를 생성합니다.
+ *
+ * @param user 사용자 엔티티
+ * @return 생성된 PointsInfo
+ */
+ public static PointsInfo from(User user) {
+ return new PointsInfo(user.getUserId(), user.getPoint().getValue());
+ }
+ }
}
diff --git a/apps/commerce-api/src/main/java/com/loopers/application/userinfo/UserInfoFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/userinfo/UserInfoFacade.java
deleted file mode 100644
index ceae04eef..000000000
--- a/apps/commerce-api/src/main/java/com/loopers/application/userinfo/UserInfoFacade.java
+++ /dev/null
@@ -1,69 +0,0 @@
-package com.loopers.application.userinfo;
-
-import com.loopers.domain.user.User;
-import com.loopers.domain.user.UserRepository;
-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 UserInfoFacade {
- private final UserRepository userRepository;
-
- /**
- * 사용자 ID로 사용자 정보를 조회합니다.
- *
- * @param userId 조회할 사용자 ID
- * @return 조회된 사용자 정보
- * @throws CoreException 사용자를 찾을 수 없는 경우
- */
- public UserInfo getUserInfo(String userId) {
- User user = userRepository.findByUserId(userId);
- if (user == null) {
- throw new CoreException(ErrorType.NOT_FOUND, "사용자를 찾을 수 없습니다.");
- }
- return UserInfo.from(user);
- }
-
- /**
- * 사용자 정보를 담는 레코드.
- *
- * @param userId 사용자 ID
- * @param email 이메일 주소
- * @param birthDate 생년월일
- * @param gender 성별
- */
- public record UserInfo(
- String userId,
- String email,
- java.time.LocalDate birthDate,
- com.loopers.domain.user.Gender gender
- ) {
- /**
- * User 엔티티로부터 UserInfo를 생성합니다.
- *
- * @param user 사용자 엔티티
- * @return 생성된 UserInfo
- */
- public static UserInfo from(User user) {
- return new UserInfo(
- user.getUserId(),
- user.getEmail(),
- user.getBirthDate(),
- user.getGender()
- );
- }
- }
-}
-
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
index 6bf72da5c..d92ccd4e8 100644
--- 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
@@ -1,6 +1,6 @@
package com.loopers.config.batch;
-import com.loopers.application.catalog.ProductCacheService;
+import com.loopers.application.product.ProductCacheService;
import com.loopers.domain.like.LikeRepository;
import com.loopers.domain.product.ProductRepository;
import lombok.RequiredArgsConstructor;
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentGateway.java b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentGateway.java
index a8f2864d8..1612276d4 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentGateway.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentGateway.java
@@ -1,6 +1,6 @@
package com.loopers.domain.payment;
-import com.loopers.application.purchasing.PaymentRequestCommand;
+import com.loopers.application.payment.PaymentRequestCommand;
/**
* 결제 게이트웨이 인터페이스.
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java
deleted file mode 100644
index dde8b402b..000000000
--- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java
+++ /dev/null
@@ -1,53 +0,0 @@
-package com.loopers.domain.product;
-
-import com.loopers.support.error.CoreException;
-import com.loopers.support.error.ErrorType;
-import lombok.RequiredArgsConstructor;
-import org.springframework.stereotype.Component;
-import org.springframework.transaction.annotation.Transactional;
-
-import java.util.List;
-
-/**
- * 상품 도메인 서비스.
- *
- * 상품 조회, 저장 등의 도메인 로직을 처리합니다.
- * Repository에 의존하며 비즈니스 규칙을 캡슐화합니다.
- *
- *
- * @author Loopers
- * @version 1.0
- */
-@RequiredArgsConstructor
-@Component
-public class ProductService {
- private final ProductRepository productRepository;
-
- /**
- * 상품 ID로 상품을 조회합니다. (비관적 락)
- *
- * 동시성 제어가 필요한 경우 사용합니다. (예: 재고 차감)
- *
- *
- * @param productId 조회할 상품 ID
- * @return 조회된 상품
- * @throws CoreException 상품을 찾을 수 없는 경우
- */
- @Transactional
- public Product findByIdForUpdate(Long productId) {
- return productRepository.findByIdForUpdate(productId)
- .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND,
- String.format("상품을 찾을 수 없습니다. (상품 ID: %d)", productId)));
- }
-
- /**
- * 상품 목록을 저장합니다.
- *
- * @param products 저장할 상품 목록
- */
- @Transactional
- public void saveAll(List products) {
- products.forEach(productRepository::save);
- }
-}
-
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentGatewayImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentGatewayImpl.java
index 5d4b994fa..ee81c5438 100644
--- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentGatewayImpl.java
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentGatewayImpl.java
@@ -1,7 +1,7 @@
package com.loopers.infrastructure.payment;
import com.loopers.domain.payment.PaymentGateway;
-import com.loopers.application.purchasing.PaymentRequestCommand;
+import com.loopers.application.payment.PaymentRequestCommand;
import com.loopers.domain.payment.PaymentRequestResult;
import com.loopers.domain.payment.PaymentStatus;
import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker;
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
index a56cc1c63..32fc066ad 100644
--- 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
@@ -1,6 +1,6 @@
package com.loopers.interfaces.api.catalog;
-import com.loopers.application.catalog.CatalogBrandFacade;
+import com.loopers.application.brand.BrandService;
import com.loopers.interfaces.api.ApiResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
@@ -22,7 +22,7 @@
@RequestMapping("/api/v1/brands")
public class BrandV1Controller {
- private final CatalogBrandFacade catalogBrandFacade;
+ private final BrandService brandService;
/**
* 브랜드 정보를 조회합니다.
@@ -32,7 +32,7 @@ public class BrandV1Controller {
*/
@GetMapping("/{brandId}")
public ApiResponse getBrand(@PathVariable Long brandId) {
- CatalogBrandFacade.BrandInfo brandInfo = catalogBrandFacade.getBrand(brandId);
+ BrandService.BrandInfo brandInfo = brandService.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
index 2bc497615..53e8fa13a 100644
--- 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
@@ -1,6 +1,6 @@
package com.loopers.interfaces.api.catalog;
-import com.loopers.application.catalog.CatalogBrandFacade;
+import com.loopers.application.brand.BrandService;
/**
* 브랜드 조회 API v1의 데이터 전송 객체(DTO) 컨테이너.
@@ -22,7 +22,7 @@ public record BrandResponse(Long brandId, String name) {
* @param brandInfo 브랜드 정보
* @return 생성된 응답 객체
*/
- public static BrandResponse from(CatalogBrandFacade.BrandInfo brandInfo) {
+ public static BrandResponse from(BrandService.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
index 4dc38d439..c275b3b7d 100644
--- 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
@@ -1,6 +1,6 @@
package com.loopers.interfaces.api.catalog;
-import com.loopers.application.catalog.CatalogProductFacade;
+import com.loopers.application.catalog.CatalogFacade;
import com.loopers.application.catalog.ProductInfo;
import com.loopers.application.catalog.ProductInfoList;
import com.loopers.interfaces.api.ApiResponse;
@@ -25,7 +25,7 @@
@RequestMapping("/api/v1/products")
public class ProductV1Controller {
- private final CatalogProductFacade catalogProductFacade;
+ private final CatalogFacade catalogFacade;
/**
* 상품 목록을 조회합니다.
@@ -43,7 +43,7 @@ public ApiResponse getProducts(
@RequestParam(required = false, defaultValue = "0") int page,
@RequestParam(required = false, defaultValue = "20") int size
) {
- ProductInfoList result = catalogProductFacade.getProducts(brandId, sort, page, size);
+ ProductInfoList result = catalogFacade.getProducts(brandId, sort, page, size);
return ApiResponse.success(ProductV1Dto.ProductsResponse.from(result));
}
@@ -55,7 +55,7 @@ public ApiResponse getProducts(
*/
@GetMapping("/{productId}")
public ApiResponse getProduct(@PathVariable Long productId) {
- ProductInfo productInfo = catalogProductFacade.getProduct(productId);
+ ProductInfo productInfo = catalogFacade.getProduct(productId);
return ApiResponse.success(ProductV1Dto.ProductResponse.from(productInfo));
}
}
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
index 2935b424d..640c909e3 100644
--- 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
@@ -1,6 +1,6 @@
package com.loopers.interfaces.api.like;
-import com.loopers.application.like.LikeFacade;
+import com.loopers.application.heart.HeartFacade;
import com.loopers.interfaces.api.ApiResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.DeleteMapping;
@@ -25,7 +25,7 @@
@RequestMapping("/api/v1/like/products")
public class LikeV1Controller {
- private final LikeFacade likeFacade;
+ private final HeartFacade heartFacade;
/**
* 상품에 좋아요를 추가합니다.
@@ -39,7 +39,7 @@ public ApiResponse addLike(
@RequestHeader("X-USER-ID") String userId,
@PathVariable Long productId
) {
- likeFacade.addLike(userId, productId);
+ heartFacade.addLike(userId, productId);
return ApiResponse.success(null);
}
@@ -55,7 +55,7 @@ public ApiResponse removeLike(
@RequestHeader("X-USER-ID") String userId,
@PathVariable Long productId
) {
- likeFacade.removeLike(userId, productId);
+ heartFacade.removeLike(userId, productId);
return ApiResponse.success(null);
}
@@ -69,7 +69,7 @@ public ApiResponse removeLike(
public ApiResponse getLikedProducts(
@RequestHeader("X-USER-ID") String userId
) {
- var likedProducts = likeFacade.getLikedProducts(userId);
+ var likedProducts = heartFacade.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
index 1fc6f20f0..e154c036b 100644
--- 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
@@ -1,6 +1,6 @@
package com.loopers.interfaces.api.like;
-import com.loopers.application.like.LikeFacade;
+import com.loopers.application.heart.HeartFacade;
import java.util.List;
@@ -25,7 +25,7 @@ public record LikedProductsResponse(
* @param likedProducts 좋아요한 상품 목록
* @return 생성된 응답 객체
*/
- public static LikedProductsResponse from(List likedProducts) {
+ public static LikedProductsResponse from(List likedProducts) {
return new LikedProductsResponse(
likedProducts.stream()
.map(LikedProductResponse::from)
@@ -58,7 +58,7 @@ public record LikedProductResponse(
* @param likedProduct 좋아요한 상품 정보
* @return 생성된 응답 객체
*/
- public static LikedProductResponse from(LikeFacade.LikedProduct likedProduct) {
+ public static LikedProductResponse from(HeartFacade.LikedProduct likedProduct) {
return new LikedProductResponse(
likedProduct.productId(),
likedProduct.name(),
diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/pointwallet/PointWalletV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/pointwallet/PointWalletV1Controller.java
index 4efc043ca..e0ffa6f26 100644
--- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/pointwallet/PointWalletV1Controller.java
+++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/pointwallet/PointWalletV1Controller.java
@@ -1,6 +1,6 @@
package com.loopers.interfaces.api.pointwallet;
-import com.loopers.application.pointwallet.PointWalletFacade;
+import com.loopers.application.user.UserService;
import com.loopers.interfaces.api.ApiResponse;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
@@ -25,7 +25,7 @@
@RequestMapping("/api/v1")
public class PointWalletV1Controller {
- private final PointWalletFacade pointWalletFacade;
+ private final UserService userService;
/**
* 현재 사용자의 포인트를 조회합니다.
@@ -38,7 +38,7 @@ public class PointWalletV1Controller {
public ApiResponse getMyPoints(
@RequestHeader("X-USER-ID") String userId
) {
- PointWalletFacade.PointsInfo pointsInfo = pointWalletFacade.getPoints(userId);
+ UserService.PointsInfo pointsInfo = userService.getPoints(userId);
return ApiResponse.success(PointWalletV1Dto.PointsResponse.from(pointsInfo));
}
@@ -55,7 +55,7 @@ public ApiResponse chargePoints(
@RequestHeader("X-USER-ID") String userId,
@Valid @RequestBody PointWalletV1Dto.ChargeRequest request
) {
- PointWalletFacade.PointsInfo pointsInfo = pointWalletFacade.chargePoint(userId, request.amount());
+ UserService.PointsInfo pointsInfo = userService.chargePoint(userId, request.amount());
return ApiResponse.success(PointWalletV1Dto.PointsResponse.from(pointsInfo));
}
}
diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/pointwallet/PointWalletV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/pointwallet/PointWalletV1Dto.java
index 461605598..c6e7c97b6 100644
--- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/pointwallet/PointWalletV1Dto.java
+++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/pointwallet/PointWalletV1Dto.java
@@ -1,6 +1,6 @@
package com.loopers.interfaces.api.pointwallet;
-import com.loopers.application.pointwallet.PointWalletFacade;
+import com.loopers.application.user.UserService;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
@@ -24,7 +24,7 @@ public record PointsResponse(String userId, Long balance) {
* @param pointsInfo 포인트 정보
* @return 생성된 응답 객체
*/
- public static PointsResponse from(PointWalletFacade.PointsInfo pointsInfo) {
+ public static PointsResponse from(UserService.PointsInfo pointsInfo) {
return new PointsResponse(pointsInfo.userId(), pointsInfo.balance());
}
}
From add1227cb81f2b9959ed3275f6d07eb55699c892 Mon Sep 17 00:00:00 2001
From: minor7295
Date: Wed, 10 Dec 2025 00:51:04 +0900
Subject: [PATCH 2/3] =?UTF-8?q?refactor:=20application=EA=B0=80=20?=
=?UTF-8?q?=EB=8F=84=EB=A9=94=EC=9D=B8=EB=B3=84=20=EC=96=B4=ED=94=8C?=
=?UTF-8?q?=EB=A6=AC=EC=BC=80=EC=9D=B4=EC=85=98=20=EC=84=9C=EB=B9=84?=
=?UTF-8?q?=EC=8A=A4=EC=99=80=20=ED=8C=8C=EC=82=AC=EB=93=9C=EB=A1=9C=20?=
=?UTF-8?q?=EA=B5=AC=EB=B6=84=EB=90=9C=20=EA=B2=83=EC=97=90=20=EB=A7=9E?=
=?UTF-8?q?=EC=B6=B0=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20?=
=?UTF-8?q?=EC=88=98=EC=A0=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../pointwallet/PointWalletFacade.java | 83 -----------------
.../HeartFacadeConcurrencyTest.java} | 32 +++----
.../HeartFacadeTest.java} | 32 +++----
.../signup/SignUpFacadeIntegrationTest.java | 88 -------------------
.../UserServiceIntegrationTest.java} | 86 +++++++++++++++---
.../UserInfoFacadeIntegrationTest.java | 80 -----------------
.../domain/coupon/CouponServiceTest.java | 1 +
.../domain/order/OrderServiceTest.java | 1 +
.../domain/payment/PaymentServiceTest.java | 3 +-
.../domain/product/ProductServiceTest.java | 1 +
.../loopers/domain/user/UserServiceTest.java | 1 +
.../api/PointWalletV1ApiE2ETest.java | 13 +--
.../api/PurchasingV1ApiE2ETest.java | 12 +--
.../interfaces/api/UserInfoV1ApiE2ETest.java | 11 +--
14 files changed, 129 insertions(+), 315 deletions(-)
delete mode 100644 apps/commerce-api/src/main/java/com/loopers/application/pointwallet/PointWalletFacade.java
rename apps/commerce-api/src/test/java/com/loopers/application/{like/LikeFacadeConcurrencyTest.java => heart/HeartFacadeConcurrencyTest.java} (93%)
rename apps/commerce-api/src/test/java/com/loopers/application/{like/LikeFacadeTest.java => heart/HeartFacadeTest.java} (89%)
delete mode 100644 apps/commerce-api/src/test/java/com/loopers/application/signup/SignUpFacadeIntegrationTest.java
rename apps/commerce-api/src/test/java/com/loopers/application/{pointwallet/PointWalletFacadeIntegrationTest.java => user/UserServiceIntegrationTest.java} (53%)
delete mode 100644 apps/commerce-api/src/test/java/com/loopers/application/userinfo/UserInfoFacadeIntegrationTest.java
diff --git a/apps/commerce-api/src/main/java/com/loopers/application/pointwallet/PointWalletFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/pointwallet/PointWalletFacade.java
deleted file mode 100644
index 1e1d171a1..000000000
--- a/apps/commerce-api/src/main/java/com/loopers/application/pointwallet/PointWalletFacade.java
+++ /dev/null
@@ -1,83 +0,0 @@
-package com.loopers.application.pointwallet;
-
-import com.loopers.domain.user.Point;
-import com.loopers.domain.user.User;
-import com.loopers.domain.user.UserRepository;
-import com.loopers.support.error.CoreException;
-import com.loopers.support.error.ErrorType;
-import jakarta.transaction.Transactional;
-import lombok.RequiredArgsConstructor;
-import org.springframework.stereotype.Component;
-
-/**
- * 포인트 지갑 파사드.
- *
- * 포인트 조회 및 충전 유즈케이스를 처리하는 애플리케이션 서비스입니다.
- * 트랜잭션 경계를 관리하여 데이터 일관성을 보장합니다.
- *
- *
- * @author Loopers
- * @version 1.0
- */
-@RequiredArgsConstructor
-@Component
-public class PointWalletFacade {
- private final UserRepository userRepository;
-
- /**
- * 사용자의 포인트를 조회합니다.
- *
- * @param userId 조회할 사용자 ID
- * @return 포인트 정보
- * @throws CoreException 사용자를 찾을 수 없는 경우
- */
- public PointsInfo getPoints(String userId) {
- User user = userRepository.findByUserId(userId);
- if (user == null) {
- throw new CoreException(ErrorType.NOT_FOUND, "사용자를 찾을 수 없습니다.");
- }
- return PointsInfo.from(user);
- }
-
- /**
- * 사용자의 포인트를 충전합니다.
- *
- * 트랜잭션 내에서 실행되어 데이터 일관성을 보장합니다.
- *
- *
- * @param userId 충전할 사용자 ID
- * @param amount 충전할 포인트 금액
- * @return 충전된 포인트 정보
- * @throws CoreException 사용자를 찾을 수 없거나 충전 금액이 유효하지 않은 경우
- */
- @Transactional
- public PointsInfo chargePoint(String userId, Long amount) {
- User user = userRepository.findByUserId(userId);
- if (user == null) {
- throw new CoreException(ErrorType.NOT_FOUND, "사용자를 찾을 수 없습니다.");
- }
- Point point = Point.of(amount);
- user.receivePoint(point);
- User savedUser = userRepository.save(user);
- return PointsInfo.from(savedUser);
- }
-
- /**
- * 포인트 정보를 담는 레코드.
- *
- * @param userId 사용자 ID
- * @param balance 포인트 잔액
- */
- public record PointsInfo(String userId, Long balance) {
- /**
- * User 엔티티로부터 PointsInfo를 생성합니다.
- *
- * @param user 사용자 엔티티
- * @return 생성된 PointsInfo
- */
- public static PointsInfo from(User user) {
- return new PointsInfo(user.getUserId(), user.getPoint().getValue());
- }
- }
-}
-
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/heart/HeartFacadeConcurrencyTest.java
similarity index 93%
rename from apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeConcurrencyTest.java
rename to apps/commerce-api/src/test/java/com/loopers/application/heart/HeartFacadeConcurrencyTest.java
index 1e7b42394..fc9afe984 100644
--- a/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeConcurrencyTest.java
+++ b/apps/commerce-api/src/test/java/com/loopers/application/heart/HeartFacadeConcurrencyTest.java
@@ -1,4 +1,4 @@
-package com.loopers.application.like;
+package com.loopers.application.heart;
import com.loopers.domain.brand.Brand;
import com.loopers.domain.brand.BrandRepository;
@@ -37,10 +37,10 @@
@SpringBootTest
@Import(MySqlTestContainersConfig.class)
@DisplayName("LikeFacade 동시성 테스트")
-class LikeFacadeConcurrencyTest {
+class HeartFacadeConcurrencyTest {
@Autowired
- private LikeFacade likeFacade;
+ private HeartFacade heartFacade;
@Autowired
private UserRepository userRepository;
@@ -116,7 +116,7 @@ void concurrencyTest_likeShouldBeProperlyCounted() throws InterruptedException {
for (User user : users) {
executorService.submit(() -> {
try {
- likeFacade.addLike(user.getUserId(), productId);
+ heartFacade.addLike(user.getUserId(), productId);
successCount.incrementAndGet();
} catch (Exception e) {
synchronized (exceptions) {
@@ -159,7 +159,7 @@ void concurrencyTest_sameUserMultipleRequests_shouldBeCountedCorrectly() throws
for (int i = 0; i < concurrentRequestCount; i++) {
executorService.submit(() -> {
try {
- likeFacade.addLike(userId, productId);
+ heartFacade.addLike(userId, productId);
successCount.incrementAndGet();
} catch (Exception e) {
synchronized (exceptions) {
@@ -233,19 +233,19 @@ void concurrencyTest_transactionReadOnlyAndUniqueConstraintServeDifferentPurpose
String userId2 = user2.getUserId();
// user1이 상품1, 상품2에 좋아요를 이미 누른 상태
- likeFacade.addLike(userId1, product1.getId());
- likeFacade.addLike(userId1, product2.getId());
+ heartFacade.addLike(userId1, product1.getId());
+ heartFacade.addLike(userId1, product2.getId());
ExecutorService executorService = Executors.newFixedThreadPool(20);
CountDownLatch latch = new CountDownLatch(20);
- List> allResults = new ArrayList<>();
+ List> allResults = new ArrayList<>();
// act
// 여러 스레드에서 동시에 조회를 수행
for (int i = 0; i < 10; i++) {
executorService.submit(() -> {
try {
- List result = likeFacade.getLikedProducts(userId1);
+ List result = heartFacade.getLikedProducts(userId1);
synchronized (allResults) {
allResults.add(result);
}
@@ -267,14 +267,14 @@ void concurrencyTest_transactionReadOnlyAndUniqueConstraintServeDifferentPurpose
if (index % 2 == 0) {
// user2가 상품1에 좋아요 추가
try {
- likeFacade.addLike(userId2, product1.getId());
+ heartFacade.addLike(userId2, product1.getId());
} catch (Exception e) {
// 이미 좋아요가 있으면 무시
}
} else {
// user2가 상품2에 좋아요 추가
try {
- likeFacade.addLike(userId2, product2.getId());
+ heartFacade.addLike(userId2, product2.getId());
} catch (Exception e) {
// 이미 좋아요가 있으면 무시
}
@@ -310,25 +310,25 @@ void concurrencyTest_transactionReadOnlyAndUniqueConstraintServeDifferentPurpose
// 참고: allResults는 동기화 이전에 조회된 결과이므로 likesCount가 0일 수 있습니다.
// 이 테스트는 @Transactional(readOnly = true)의 일관성 보장을 검증하는 것이 목적이므로,
// 동시성 테스트 중 조회된 결과의 상품 ID 일관성만 확인합니다.
- for (List result : allResults) {
+ for (List result : allResults) {
// user1의 좋아요 목록에는 상품1, 상품2가 포함되어야 함
List resultProductIds = result.stream()
- .map(LikeFacade.LikedProduct::productId)
+ .map(HeartFacade.LikedProduct::productId)
.sorted()
.toList();
assertThat(resultProductIds).contains(product1.getId(), product2.getId());
}
// 최종 상태 확인 (동기화 후)
- List finalResult = likeFacade.getLikedProducts(userId1);
+ List finalResult = heartFacade.getLikedProducts(userId1);
List finalProductIds = finalResult.stream()
- .map(LikeFacade.LikedProduct::productId)
+ .map(HeartFacade.LikedProduct::productId)
.sorted()
.toList();
assertThat(finalProductIds).containsExactlyInAnyOrder(product1.getId(), product2.getId());
// 동기화 후에는 정확한 좋아요 수가 반영되어야 함
- for (LikeFacade.LikedProduct likedProduct : finalResult) {
+ for (HeartFacade.LikedProduct likedProduct : finalResult) {
assertThat(likedProduct.likesCount()).isGreaterThan(0);
}
}
diff --git a/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/heart/HeartFacadeTest.java
similarity index 89%
rename from apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeTest.java
rename to apps/commerce-api/src/test/java/com/loopers/application/heart/HeartFacadeTest.java
index 148fe1968..b8edf53c0 100644
--- a/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeTest.java
+++ b/apps/commerce-api/src/test/java/com/loopers/application/heart/HeartFacadeTest.java
@@ -1,6 +1,6 @@
-package com.loopers.application.like;
+package com.loopers.application.heart;
-import com.loopers.application.catalog.ProductCacheService;
+import com.loopers.application.product.ProductCacheService;
import com.loopers.domain.like.Like;
import com.loopers.domain.like.LikeRepository;
import com.loopers.domain.product.Product;
@@ -28,7 +28,7 @@
import static org.mockito.Mockito.never;
@DisplayName("LikeFacade 좋아요 등록/취소/중복 방지 흐름 검증")
-class LikeFacadeTest {
+class HeartFacadeTest {
@Mock
private LikeRepository likeRepository;
@@ -43,7 +43,7 @@ class LikeFacadeTest {
private ProductCacheService productCacheService;
@InjectMocks
- private LikeFacade likeFacade;
+ private HeartFacade heartFacade;
private static final String DEFAULT_USER_ID = "testuser";
private static final Long DEFAULT_USER_INTERNAL_ID = 1L;
@@ -63,7 +63,7 @@ void addLike_success() {
.thenReturn(Optional.empty());
// act
- likeFacade.addLike(DEFAULT_USER_ID, DEFAULT_PRODUCT_ID);
+ heartFacade.addLike(DEFAULT_USER_ID, DEFAULT_PRODUCT_ID);
// assert
verify(likeRepository).save(any(Like.class));
@@ -79,7 +79,7 @@ void removeLike_success() {
.thenReturn(Optional.of(like));
// act
- likeFacade.removeLike(DEFAULT_USER_ID, DEFAULT_PRODUCT_ID);
+ heartFacade.removeLike(DEFAULT_USER_ID, DEFAULT_PRODUCT_ID);
// assert
verify(likeRepository).delete(like);
@@ -94,7 +94,7 @@ void addLike_isIdempotent() {
.thenReturn(Optional.of(Like.of(DEFAULT_USER_INTERNAL_ID, DEFAULT_PRODUCT_ID)));
// act
- likeFacade.addLike(DEFAULT_USER_ID, DEFAULT_PRODUCT_ID);
+ heartFacade.addLike(DEFAULT_USER_ID, DEFAULT_PRODUCT_ID);
// assert - save는 한 번만 호출되어야 함 (중복 방지)
verify(likeRepository, never()).save(any(Like.class));
@@ -109,7 +109,7 @@ void removeLike_isIdempotent() {
.thenReturn(Optional.empty()); // 좋아요 없음
// act - 좋아요가 없는 상태에서 취소 시도
- likeFacade.removeLike(DEFAULT_USER_ID, DEFAULT_PRODUCT_ID);
+ heartFacade.removeLike(DEFAULT_USER_ID, DEFAULT_PRODUCT_ID);
// assert - 예외가 발생하지 않아야 함 (멱등성 보장)
verify(likeRepository).findByUserIdAndProductId(DEFAULT_USER_INTERNAL_ID, DEFAULT_PRODUCT_ID);
@@ -124,7 +124,7 @@ void addLike_userNotFound() {
when(userRepository.findByUserId(unknownUserId)).thenReturn(null);
// act & assert
- assertThatThrownBy(() -> likeFacade.addLike(unknownUserId, DEFAULT_PRODUCT_ID))
+ assertThatThrownBy(() -> heartFacade.addLike(unknownUserId, DEFAULT_PRODUCT_ID))
.isInstanceOf(CoreException.class)
.hasFieldOrPropertyWithValue("errorType", ErrorType.NOT_FOUND);
}
@@ -138,7 +138,7 @@ void addLike_productNotFound() {
when(productRepository.findById(nonExistentProductId)).thenReturn(Optional.empty());
// act & assert
- assertThatThrownBy(() -> likeFacade.addLike(DEFAULT_USER_ID, nonExistentProductId))
+ assertThatThrownBy(() -> heartFacade.addLike(DEFAULT_USER_ID, nonExistentProductId))
.isInstanceOf(CoreException.class)
.hasFieldOrPropertyWithValue("errorType", ErrorType.NOT_FOUND);
}
@@ -166,13 +166,13 @@ void getLikedProducts_success() {
.thenReturn(List.of(product1, product2));
// act
- List result = likeFacade.getLikedProducts(DEFAULT_USER_ID);
+ List result = heartFacade.getLikedProducts(DEFAULT_USER_ID);
// assert
assertThat(result).hasSize(2);
- assertThat(result).extracting(LikeFacade.LikedProduct::productId)
+ assertThat(result).extracting(HeartFacade.LikedProduct::productId)
.containsExactlyInAnyOrder(productId1, productId2);
- assertThat(result).extracting(LikeFacade.LikedProduct::likesCount)
+ assertThat(result).extracting(HeartFacade.LikedProduct::likesCount)
.containsExactlyInAnyOrder(5L, 3L);
}
@@ -184,7 +184,7 @@ void getLikedProducts_emptyList() {
when(likeRepository.findAllByUserId(DEFAULT_USER_INTERNAL_ID)).thenReturn(List.of());
// act
- List result = likeFacade.getLikedProducts(DEFAULT_USER_ID);
+ List result = heartFacade.getLikedProducts(DEFAULT_USER_ID);
// assert
assertThat(result).isEmpty();
@@ -212,7 +212,7 @@ void getLikedProducts_productNotFound() {
.thenReturn(List.of(product1)); // product1만 반환 (nonExistentProductId는 없음)
// act & assert
- assertThatThrownBy(() -> likeFacade.getLikedProducts(DEFAULT_USER_ID))
+ assertThatThrownBy(() -> heartFacade.getLikedProducts(DEFAULT_USER_ID))
.isInstanceOf(CoreException.class)
.hasFieldOrPropertyWithValue("errorType", ErrorType.NOT_FOUND);
}
@@ -225,7 +225,7 @@ void getLikedProducts_userNotFound() {
when(userRepository.findByUserId(unknownUserId)).thenReturn(null);
// act & assert
- assertThatThrownBy(() -> likeFacade.getLikedProducts(unknownUserId))
+ assertThatThrownBy(() -> heartFacade.getLikedProducts(unknownUserId))
.isInstanceOf(CoreException.class)
.hasFieldOrPropertyWithValue("errorType", ErrorType.NOT_FOUND);
}
diff --git a/apps/commerce-api/src/test/java/com/loopers/application/signup/SignUpFacadeIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/signup/SignUpFacadeIntegrationTest.java
deleted file mode 100644
index c827cae1d..000000000
--- a/apps/commerce-api/src/test/java/com/loopers/application/signup/SignUpFacadeIntegrationTest.java
+++ /dev/null
@@ -1,88 +0,0 @@
-package com.loopers.application.signup;
-
-import com.loopers.domain.user.Gender;
-import com.loopers.domain.user.User;
-import com.loopers.domain.user.UserTestFixture;
-import com.loopers.infrastructure.user.UserJpaRepository;
-import com.loopers.support.error.CoreException;
-import com.loopers.support.error.ErrorType;
-import com.loopers.utils.DatabaseCleanUp;
-import org.junit.jupiter.api.AfterEach;
-import org.junit.jupiter.api.DisplayName;
-import org.junit.jupiter.api.Nested;
-import org.junit.jupiter.params.ParameterizedTest;
-import org.junit.jupiter.params.provider.EnumSource;
-import org.mockito.Mockito;
-import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.boot.test.context.SpringBootTest;
-import org.springframework.test.context.bean.override.mockito.MockitoSpyBean;
-
-import static org.assertj.core.api.Assertions.assertThat;
-import static org.junit.jupiter.api.Assertions.assertAll;
-import static org.junit.jupiter.api.Assertions.assertThrows;
-import static org.mockito.ArgumentMatchers.any;
-import static org.mockito.Mockito.times;
-import static org.mockito.Mockito.verify;
-
-@SpringBootTest
-@DisplayName("SignUpFacade 통합 테스트")
-class SignUpFacadeIntegrationTest {
- @Autowired
- private SignUpFacade signUpFacade;
-
- @MockitoSpyBean
- private UserJpaRepository userJpaRepository;
-
- @Autowired
- private DatabaseCleanUp databaseCleanUp;
-
- @AfterEach
- void tearDown() {
- databaseCleanUp.truncateAllTables();
- }
-
- @DisplayName("회원 가입에 관한 통합 테스트")
- @Nested
- class SignUp {
- @DisplayName("회원가입시 User 저장이 수행된다.")
- @ParameterizedTest
- @EnumSource(Gender.class)
- void returnsSignUpInfo_whenValidIdIsProvided(Gender gender) {
- // arrange
- String userId = UserTestFixture.ValidUser.USER_ID;
- String email = UserTestFixture.ValidUser.EMAIL;
- String birthDate = UserTestFixture.ValidUser.BIRTH_DATE;
- Mockito.reset(userJpaRepository);
-
- // act
- SignUpInfo signUpInfo = signUpFacade.signUp(userId, email, birthDate, gender.name());
-
- // assert
- assertAll(
- () -> assertThat(signUpInfo).isNotNull(),
- () -> assertThat(signUpInfo.userId()).isEqualTo(userId),
- () -> verify(userJpaRepository, times(1)).save(any(User.class))
- );
- }
-
- @DisplayName("이미 가입된 ID로 회원가입 시도 시, 실패한다.")
- @ParameterizedTest
- @EnumSource(Gender.class)
- void fails_whenDuplicateUserIdExists(Gender gender) {
- // arrange
- String userId = UserTestFixture.ValidUser.USER_ID;
- String email = UserTestFixture.ValidUser.EMAIL;
- String birthDate = UserTestFixture.ValidUser.BIRTH_DATE;
- signUpFacade.signUp(userId, email, birthDate, gender.name());
-
- // act
- CoreException result = assertThrows(CoreException.class, () ->
- signUpFacade.signUp(userId, email, birthDate, gender.name())
- );
-
- // assert
- assertThat(result.getErrorType()).isEqualTo(ErrorType.CONFLICT);
- }
- }
-}
-
diff --git a/apps/commerce-api/src/test/java/com/loopers/application/pointwallet/PointWalletFacadeIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/user/UserServiceIntegrationTest.java
similarity index 53%
rename from apps/commerce-api/src/test/java/com/loopers/application/pointwallet/PointWalletFacadeIntegrationTest.java
rename to apps/commerce-api/src/test/java/com/loopers/application/user/UserServiceIntegrationTest.java
index cebc13975..db7a1de10 100644
--- a/apps/commerce-api/src/test/java/com/loopers/application/pointwallet/PointWalletFacadeIntegrationTest.java
+++ b/apps/commerce-api/src/test/java/com/loopers/application/user/UserServiceIntegrationTest.java
@@ -1,8 +1,10 @@
-package com.loopers.application.pointwallet;
+package com.loopers.application.user;
-import com.loopers.application.signup.SignUpFacade;
import com.loopers.domain.user.Gender;
+import com.loopers.domain.user.Point;
+import com.loopers.domain.user.User;
import com.loopers.domain.user.UserTestFixture;
+import com.loopers.infrastructure.user.UserJpaRepository;
import com.loopers.support.error.CoreException;
import com.loopers.support.error.ErrorType;
import com.loopers.utils.DatabaseCleanUp;
@@ -12,21 +14,27 @@
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.EnumSource;
+import org.mockito.Mockito;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.test.context.bean.override.mockito.MockitoSpyBean;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.junit.jupiter.api.Assertions.assertAll;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
@SpringBootTest
-@DisplayName("PointWalletFacade 통합 테스트")
-class PointWalletFacadeIntegrationTest {
+@DisplayName("UserService 통합 테스트")
+class UserServiceIntegrationTest {
@Autowired
- private PointWalletFacade pointWalletFacade;
+ private UserService userService;
- @Autowired
- private SignUpFacade signUpFacade;
+ @MockitoSpyBean
+ private UserJpaRepository userJpaRepository;
@Autowired
private DatabaseCleanUp databaseCleanUp;
@@ -36,6 +44,57 @@ void tearDown() {
databaseCleanUp.truncateAllTables();
}
+ /**
+ * 테스트용 사용자를 생성합니다.
+ */
+ private void createUser(String userId, String email, String birthDate, Gender gender) {
+ userService.create(userId, email, birthDate, gender, Point.of(0L));
+ }
+
+ @DisplayName("회원 가입에 관한 통합 테스트")
+ @Nested
+ class SignUp {
+ @DisplayName("회원가입시 User 저장이 수행된다.")
+ @ParameterizedTest
+ @EnumSource(Gender.class)
+ void createsUser_whenValidIdIsProvided(Gender gender) {
+ // arrange
+ String userId = UserTestFixture.ValidUser.USER_ID;
+ String email = UserTestFixture.ValidUser.EMAIL;
+ String birthDate = UserTestFixture.ValidUser.BIRTH_DATE;
+ Mockito.reset(userJpaRepository);
+
+ // act
+ User user = userService.create(userId, email, birthDate, gender, Point.of(0L));
+
+ // assert
+ assertAll(
+ () -> assertThat(user).isNotNull(),
+ () -> assertThat(user.getUserId()).isEqualTo(userId),
+ () -> verify(userJpaRepository, times(1)).save(any(User.class))
+ );
+ }
+
+ @DisplayName("이미 가입된 ID로 회원가입 시도 시, 실패한다.")
+ @ParameterizedTest
+ @EnumSource(Gender.class)
+ void fails_whenDuplicateUserIdExists(Gender gender) {
+ // arrange
+ String userId = UserTestFixture.ValidUser.USER_ID;
+ String email = UserTestFixture.ValidUser.EMAIL;
+ String birthDate = UserTestFixture.ValidUser.BIRTH_DATE;
+ userService.create(userId, email, birthDate, gender, Point.of(0L));
+
+ // act
+ CoreException result = assertThrows(CoreException.class, () ->
+ userService.create(userId, email, birthDate, gender, Point.of(0L))
+ );
+
+ // assert
+ assertThat(result.getErrorType()).isEqualTo(ErrorType.CONFLICT);
+ }
+ }
+
@DisplayName("포인트 조회에 관한 통합 테스트")
@Nested
class PointInfo {
@@ -47,10 +106,10 @@ void returnsPoints_whenUserExists(Gender gender) {
String userId = UserTestFixture.ValidUser.USER_ID;
String email = UserTestFixture.ValidUser.EMAIL;
String birthDate = UserTestFixture.ValidUser.BIRTH_DATE;
- signUpFacade.signUp(userId, email, birthDate, gender.name());
+ createUser(userId, email, birthDate, gender);
// act
- PointWalletFacade.PointsInfo pointsInfo = pointWalletFacade.getPoints(userId);
+ UserService.PointsInfo pointsInfo = userService.getPoints(userId);
// assert
assertAll(
@@ -67,7 +126,7 @@ void throwsException_whenUserDoesNotExist() {
String userId = "unknown";
// act & assert
- assertThatThrownBy(() -> pointWalletFacade.getPoints(userId))
+ assertThatThrownBy(() -> userService.getPoints(userId))
.isInstanceOf(CoreException.class)
.hasFieldOrPropertyWithValue("errorType", ErrorType.NOT_FOUND);
}
@@ -84,11 +143,11 @@ void chargesPoints_success(Gender gender) {
String userId = UserTestFixture.ValidUser.USER_ID;
String email = UserTestFixture.ValidUser.EMAIL;
String birthDate = UserTestFixture.ValidUser.BIRTH_DATE;
- signUpFacade.signUp(userId, email, birthDate, gender.name());
+ createUser(userId, email, birthDate, gender);
Long chargeAmount = 10_000L;
// act
- PointWalletFacade.PointsInfo pointsInfo = pointWalletFacade.chargePoint(userId, chargeAmount);
+ UserService.PointsInfo pointsInfo = userService.chargePoint(userId, chargeAmount);
// assert
assertAll(
@@ -106,10 +165,9 @@ void throwsException_whenUserDoesNotExist() {
Long chargeAmount = 10_000L;
// act & assert
- assertThatThrownBy(() -> pointWalletFacade.chargePoint(userId, chargeAmount))
+ assertThatThrownBy(() -> userService.chargePoint(userId, chargeAmount))
.isInstanceOf(CoreException.class)
.hasFieldOrPropertyWithValue("errorType", ErrorType.NOT_FOUND);
}
}
}
-
diff --git a/apps/commerce-api/src/test/java/com/loopers/application/userinfo/UserInfoFacadeIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/userinfo/UserInfoFacadeIntegrationTest.java
deleted file mode 100644
index 78efa5e30..000000000
--- a/apps/commerce-api/src/test/java/com/loopers/application/userinfo/UserInfoFacadeIntegrationTest.java
+++ /dev/null
@@ -1,80 +0,0 @@
-package com.loopers.application.userinfo;
-
-import com.loopers.application.signup.SignUpFacade;
-import com.loopers.domain.user.Gender;
-import com.loopers.domain.user.UserTestFixture;
-import com.loopers.support.error.CoreException;
-import com.loopers.support.error.ErrorType;
-import com.loopers.utils.DatabaseCleanUp;
-import org.junit.jupiter.api.AfterEach;
-import org.junit.jupiter.api.DisplayName;
-import org.junit.jupiter.api.Nested;
-import org.junit.jupiter.api.Test;
-import org.junit.jupiter.params.ParameterizedTest;
-import org.junit.jupiter.params.provider.EnumSource;
-import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.boot.test.context.SpringBootTest;
-
-import java.time.LocalDate;
-
-import static org.assertj.core.api.Assertions.assertThat;
-import static org.assertj.core.api.Assertions.assertThatThrownBy;
-import static org.junit.jupiter.api.Assertions.assertAll;
-
-@SpringBootTest
-@DisplayName("UserInfoFacade 통합 테스트")
-class UserInfoFacadeIntegrationTest {
- @Autowired
- private UserInfoFacade userInfoFacade;
-
- @Autowired
- private SignUpFacade signUpFacade;
-
- @Autowired
- private DatabaseCleanUp databaseCleanUp;
-
- @AfterEach
- void tearDown() {
- databaseCleanUp.truncateAllTables();
- }
-
- @DisplayName("회원 조회에 관한 통합 테스트")
- @Nested
- class UserInfo {
- @DisplayName("해당 ID 의 회원이 존재할 경우, 회원 정보가 반환된다.")
- @ParameterizedTest
- @EnumSource(Gender.class)
- void returnsUserInfo_whenUserExists(Gender gender) {
- // arrange
- String userId = UserTestFixture.ValidUser.USER_ID;
- String email = UserTestFixture.ValidUser.EMAIL;
- String birthDate = UserTestFixture.ValidUser.BIRTH_DATE;
- signUpFacade.signUp(userId, email, birthDate, gender.name());
-
- // act
- UserInfoFacade.UserInfo userInfo = userInfoFacade.getUserInfo(userId);
-
- // assert
- assertAll(
- () -> assertThat(userInfo).isNotNull(),
- () -> assertThat(userInfo.userId()).isEqualTo(userId),
- () -> assertThat(userInfo.email()).isEqualTo(email),
- () -> assertThat(userInfo.birthDate()).isEqualTo(LocalDate.parse(birthDate)),
- () -> assertThat(userInfo.gender()).isEqualTo(gender)
- );
- }
-
- @DisplayName("해당 ID 의 회원이 존재하지 않을 경우, 예외가 발생한다.")
- @Test
- void throwsException_whenUserDoesNotExist() {
- // arrange
- String userId = "unknown";
-
- // act & assert
- assertThatThrownBy(() -> userInfoFacade.getUserInfo(userId))
- .isInstanceOf(CoreException.class)
- .hasFieldOrPropertyWithValue("errorType", ErrorType.NOT_FOUND);
- }
- }
-}
-
diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/coupon/CouponServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/coupon/CouponServiceTest.java
index c15742303..e0c867d89 100644
--- a/apps/commerce-api/src/test/java/com/loopers/domain/coupon/CouponServiceTest.java
+++ b/apps/commerce-api/src/test/java/com/loopers/domain/coupon/CouponServiceTest.java
@@ -1,5 +1,6 @@
package com.loopers.domain.coupon;
+import com.loopers.application.coupon.CouponService;
import com.loopers.domain.coupon.discount.CouponDiscountStrategy;
import com.loopers.domain.coupon.discount.CouponDiscountStrategyFactory;
import com.loopers.support.error.CoreException;
diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceTest.java
index 1dab0a951..b259c2dd2 100644
--- a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceTest.java
+++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceTest.java
@@ -1,5 +1,6 @@
package com.loopers.domain.order;
+import com.loopers.application.order.OrderService;
import com.loopers.domain.payment.PaymentStatus;
import com.loopers.domain.product.Product;
import com.loopers.domain.user.Gender;
diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/payment/PaymentServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/payment/PaymentServiceTest.java
index 963eab173..daf03ca99 100644
--- a/apps/commerce-api/src/test/java/com/loopers/domain/payment/PaymentServiceTest.java
+++ b/apps/commerce-api/src/test/java/com/loopers/domain/payment/PaymentServiceTest.java
@@ -1,6 +1,7 @@
package com.loopers.domain.payment;
-import com.loopers.application.purchasing.PaymentRequestCommand;
+import com.loopers.application.payment.PaymentService;
+import com.loopers.application.payment.PaymentRequestCommand;
import com.loopers.support.error.CoreException;
import com.loopers.support.error.ErrorType;
import org.junit.jupiter.api.BeforeEach;
diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceTest.java
index 2ae4afeb2..1b1682d8a 100644
--- a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceTest.java
+++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceTest.java
@@ -1,5 +1,6 @@
package com.loopers.domain.product;
+import com.loopers.application.product.ProductService;
import com.loopers.support.error.CoreException;
import com.loopers.support.error.ErrorType;
import org.junit.jupiter.api.DisplayName;
diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceTest.java
index 087413f54..e907edbbe 100644
--- a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceTest.java
+++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceTest.java
@@ -1,5 +1,6 @@
package com.loopers.domain.user;
+import com.loopers.application.user.UserService;
import com.loopers.support.error.CoreException;
import com.loopers.support.error.ErrorType;
import org.junit.jupiter.api.DisplayName;
diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/PointWalletV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/PointWalletV1ApiE2ETest.java
index c2629fb78..559a26477 100644
--- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/PointWalletV1ApiE2ETest.java
+++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/PointWalletV1ApiE2ETest.java
@@ -1,7 +1,8 @@
package com.loopers.interfaces.api;
-import com.loopers.application.signup.SignUpFacade;
+import com.loopers.application.user.UserService;
import com.loopers.domain.user.Gender;
+import com.loopers.domain.user.Point;
import com.loopers.domain.user.UserTestFixture;
import com.loopers.interfaces.api.pointwallet.PointWalletV1Dto;
import com.loopers.utils.DatabaseCleanUp;
@@ -31,17 +32,17 @@ public class PointWalletV1ApiE2ETest {
private static final String ENDPOINT_POINTS = "/api/v1/me/points";
private final TestRestTemplate testRestTemplate;
- private final SignUpFacade signUpFacade;
+ private final UserService userService;
private final DatabaseCleanUp databaseCleanUp;
@Autowired
public PointWalletV1ApiE2ETest(
TestRestTemplate testRestTemplate,
- SignUpFacade signUpFacade,
+ UserService userService,
DatabaseCleanUp databaseCleanUp
) {
this.testRestTemplate = testRestTemplate;
- this.signUpFacade = signUpFacade;
+ this.userService = userService;
this.databaseCleanUp = databaseCleanUp;
}
@@ -61,7 +62,7 @@ void returnsPoints_whenUserExists(Gender gender) {
String userId = UserTestFixture.ValidUser.USER_ID;
String email = UserTestFixture.ValidUser.EMAIL;
String birthDate = UserTestFixture.ValidUser.BIRTH_DATE;
- signUpFacade.signUp(userId, email, birthDate, gender.name());
+ userService.create(userId, email, birthDate, gender, Point.of(0L));
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
@@ -145,7 +146,7 @@ void returnsChargedBalance_whenUserExists(Gender gender) {
String userId = UserTestFixture.ValidUser.USER_ID;
String email = UserTestFixture.ValidUser.EMAIL;
String birthDate = UserTestFixture.ValidUser.BIRTH_DATE;
- signUpFacade.signUp(userId, email, birthDate, gender.name());
+ userService.create(userId, email, birthDate, gender, Point.of(0L));
Long chargeAmount = 1000L;
PointWalletV1Dto.ChargeRequest requestBody = new PointWalletV1Dto.ChargeRequest(chargeAmount);
diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/PurchasingV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/PurchasingV1ApiE2ETest.java
index a09516c93..9c597b09b 100644
--- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/PurchasingV1ApiE2ETest.java
+++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/PurchasingV1ApiE2ETest.java
@@ -1,6 +1,6 @@
package com.loopers.interfaces.api;
-import com.loopers.application.pointwallet.PointWalletFacade;
+import com.loopers.application.user.UserService;
import com.loopers.application.signup.SignUpFacade;
import com.loopers.domain.brand.Brand;
import com.loopers.domain.brand.BrandRepository;
@@ -72,7 +72,7 @@ public class PurchasingV1ApiE2ETest {
private SignUpFacade signUpFacade;
@Autowired
- private PointWalletFacade pointWalletFacade;
+ private UserService userService;
@Autowired
private ProductRepository productRepository;
@@ -108,7 +108,7 @@ private HttpEntity createOrderRequest(Long produc
String email = UserTestFixture.ValidUser.EMAIL;
String birthDate = UserTestFixture.ValidUser.BIRTH_DATE;
signUpFacade.signUp(userId, email, birthDate, Gender.MALE.name());
- pointWalletFacade.chargePoint(userId, 500_000L);
+ userService.chargePoint(userId, 500_000L);
Brand brand = Brand.of("테스트 브랜드");
Brand savedBrand = brandRepository.save(brand);
@@ -240,7 +240,7 @@ void returns200_whenPaymentCallbackSuccess() {
String email = UserTestFixture.ValidUser.EMAIL;
String birthDate = UserTestFixture.ValidUser.BIRTH_DATE;
signUpFacade.signUp(userId, email, birthDate, Gender.MALE.name());
- pointWalletFacade.chargePoint(userId, 500_000L);
+ userService.chargePoint(userId, 500_000L);
Brand brand = Brand.of("테스트 브랜드");
Brand savedBrand = brandRepository.save(brand);
@@ -345,7 +345,7 @@ void returns200_whenPaymentCallbackFailure() {
String email = UserTestFixture.ValidUser.EMAIL;
String birthDate = UserTestFixture.ValidUser.BIRTH_DATE;
signUpFacade.signUp(userId, email, birthDate, Gender.MALE.name());
- pointWalletFacade.chargePoint(userId, 500_000L);
+ userService.chargePoint(userId, 500_000L);
Brand brand = Brand.of("테스트 브랜드");
Brand savedBrand = brandRepository.save(brand);
@@ -456,7 +456,7 @@ void returns200_whenOrderStatusRecovered() {
String email = UserTestFixture.ValidUser.EMAIL;
String birthDate = UserTestFixture.ValidUser.BIRTH_DATE;
signUpFacade.signUp(userId, email, birthDate, Gender.MALE.name());
- pointWalletFacade.chargePoint(userId, 500_000L);
+ userService.chargePoint(userId, 500_000L);
Brand brand = Brand.of("테스트 브랜드");
Brand savedBrand = brandRepository.save(brand);
diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserInfoV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserInfoV1ApiE2ETest.java
index f9d33a421..6cea33855 100644
--- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserInfoV1ApiE2ETest.java
+++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserInfoV1ApiE2ETest.java
@@ -1,7 +1,8 @@
package com.loopers.interfaces.api;
-import com.loopers.application.signup.SignUpFacade;
+import com.loopers.application.user.UserService;
import com.loopers.domain.user.Gender;
+import com.loopers.domain.user.Point;
import com.loopers.domain.user.UserTestFixture;
import com.loopers.interfaces.api.userinfo.UserInfoV1Dto;
import com.loopers.utils.DatabaseCleanUp;
@@ -31,17 +32,17 @@ public class UserInfoV1ApiE2ETest {
private static final String ENDPOINT_ME = "/api/v1/me";
private final TestRestTemplate testRestTemplate;
- private final SignUpFacade signUpFacade;
+ private final UserService userService;
private final DatabaseCleanUp databaseCleanUp;
@Autowired
public UserInfoV1ApiE2ETest(
TestRestTemplate testRestTemplate,
- SignUpFacade signUpFacade,
+ UserService userService,
DatabaseCleanUp databaseCleanUp
) {
this.testRestTemplate = testRestTemplate;
- this.signUpFacade = signUpFacade;
+ this.userService = userService;
this.databaseCleanUp = databaseCleanUp;
}
@@ -61,7 +62,7 @@ void returnsUserInfo_whenUserExists(Gender gender) {
String userId = UserTestFixture.ValidUser.USER_ID;
String email = UserTestFixture.ValidUser.EMAIL;
String birthDate = UserTestFixture.ValidUser.BIRTH_DATE;
- signUpFacade.signUp(userId, email, birthDate, gender.name());
+ userService.create(userId, email, birthDate, gender, Point.of(0L));
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
From 1ed281ffac7f82421c932814fed80625c275ba86 Mon Sep 17 00:00:00 2001
From: minor7295
Date: Wed, 10 Dec 2025 00:51:26 +0900
Subject: [PATCH 3/3] =?UTF-8?q?refactor:=20scheduler=EB=A5=BC=20infrastruc?=
=?UTF-8?q?ture=20=EB=A0=88=EC=9D=B4=EC=96=B4=EB=A1=9C=20=EC=9D=B4?=
=?UTF-8?q?=EB=8F=99?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../scheduler/LikeCountSyncScheduler.java | 98 +++++++++++++++++++
1 file changed, 98 insertions(+)
create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/scheduler/LikeCountSyncScheduler.java
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/scheduler/LikeCountSyncScheduler.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/scheduler/LikeCountSyncScheduler.java
new file mode 100644
index 000000000..a4e144a47
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/scheduler/LikeCountSyncScheduler.java
@@ -0,0 +1,98 @@
+package com.loopers.infrastructure.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);
+ }
+ }
+}
+