Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,14 @@
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
import org.springframework.cloud.openfeign.EnableFeignClients;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.annotation.EnableScheduling;
import java.util.TimeZone;

@ConfigurationPropertiesScan
@SpringBootApplication
@EnableScheduling
@EnableAsync
@EnableFeignClients
public class CommerceApiApplication {

Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
package com.loopers.application.catalog;
package com.loopers.application.brand;

import com.loopers.domain.brand.Brand;
import com.loopers.domain.brand.BrandRepository;
import com.loopers.support.error.CoreException;
import com.loopers.support.error.ErrorType;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;

/**
* ๋ธŒ๋žœ๋“œ ์กฐํšŒ ํŒŒ์‚ฌ๋“œ.
Expand All @@ -18,19 +21,45 @@
*/
@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 ๋ชฉ๋ก์œผ๋กœ ๋ธŒ๋žœ๋“œ ๋ชฉ๋ก์„ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค.
* <p>
* ๋ฐฐ์น˜ ์กฐํšŒ๋ฅผ ํ†ตํ•ด N+1 ์ฟผ๋ฆฌ ๋ฌธ์ œ๋ฅผ ํ•ด๊ฒฐํ•ฉ๋‹ˆ๋‹ค.
* </p>
*
* @param brandIds ์กฐํšŒํ•  ๋ธŒ๋žœ๋“œ ID ๋ชฉ๋ก
* @return ์กฐํšŒ๋œ ๋ธŒ๋žœ๋“œ ๋ชฉ๋ก
*/
@Transactional(readOnly = true)
public List<Brand> getBrands(List<Long> brandIds) {
return brandRepository.findAllById(brandIds);
}

/**
* ๋ธŒ๋žœ๋“œ ์ •๋ณด๋ฅผ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค.
*
* @param brandId ๋ธŒ๋žœ๋“œ ID
* @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);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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;

/**
Expand All @@ -54,8 +55,8 @@ public ProductInfoList getProducts(Long brandId, String sort, int page, int size
}

// ์บ์‹œ์— ์—†์œผ๋ฉด DB์—์„œ ์กฐํšŒ
long totalCount = productRepository.countAll(brandId);
List<Product> products = productRepository.findAll(brandId, normalizedSort, page, size);
long totalCount = productService.countAll(brandId);
List<Product> products = productService.findAll(brandId, normalizedSort, page, size);

if (products.isEmpty()) {
ProductInfoList emptyResult = new ProductInfoList(List.of(), totalCount, page, size);
Expand All @@ -72,7 +73,7 @@ public ProductInfoList getProducts(Long brandId, String sort, int page, int size
.toList();

// ๋ธŒ๋žœ๋“œ ๋ฐฐ์น˜ ์กฐํšŒ ๋ฐ Map์œผ๋กœ ๋ณ€ํ™˜ (O(1) ์กฐํšŒ๋ฅผ ์œ„ํ•ด)
Map<Long, Brand> brandMap = brandRepository.findAllById(brandIds).stream()
Map<Long, Brand> brandMap = brandService.getBrands(brandIds).stream()
.collect(Collectors.toMap(Brand::getId, brand -> brand));

// ์ƒํ’ˆ ์ •๋ณด ๋ณ€ํ™˜ (์ด๋ฏธ ์กฐํšŒํ•œ Product ์žฌ์‚ฌ์šฉ)
Expand Down Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package com.loopers.application.coupon;

import com.loopers.domain.coupon.CouponEvent;
import com.loopers.domain.coupon.CouponEventPublisher;
import com.loopers.domain.order.OrderEvent;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;

/**
* ์ฟ ํฐ ์ด๋ฒคํŠธ ํ•ธ๋“ค๋Ÿฌ.
* <p>
* ์ฃผ๋ฌธ ์ƒ์„ฑ ์ด๋ฒคํŠธ๋ฅผ ๋ฐ›์•„ ์ฟ ํฐ ์‚ฌ์šฉ ์ฒ˜๋ฆฌ๋ฅผ ์ˆ˜ํ–‰ํ•˜๋Š” ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ ๋กœ์ง์„ ์ฒ˜๋ฆฌํ•ฉ๋‹ˆ๋‹ค.
* </p>
* <p>
* <b>DDD/EDA ๊ด€์ :</b>
* <ul>
* <li><b>์ฑ…์ž„ ๋ถ„๋ฆฌ:</b> CouponService๋Š” ์ฟ ํฐ ๋„๋ฉ”์ธ ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง, CouponEventHandler๋Š” ์ด๋ฒคํŠธ ์ฒ˜๋ฆฌ ๋กœ์ง</li>
* <li><b>์ด๋ฒคํŠธ ํ•ธ๋“ค๋Ÿฌ:</b> ์ด๋ฒคํŠธ๋ฅผ ๋ฐ›์•„์„œ ์ฒ˜๋ฆฌํ•˜๋Š” ์—ญํ• ์„ ๋ช…ํ™•ํžˆ ๋‚˜ํƒ€๋ƒ„</li>
* <li><b>๋„๋ฉ”์ธ ๊ฒฝ๊ณ„ ์ค€์ˆ˜:</b> ์ฟ ํฐ ๋„๋ฉ”์ธ์€ ์ฟ ํฐ ์ ์šฉ ์ด๋ฒคํŠธ๋งŒ ๋ฐœํ–‰ํ•˜๊ณ , ์ฃผ๋ฌธ ๋„๋ฉ”์ธ์€ ์ž์‹ ์˜ ์ƒํƒœ๋ฅผ ๊ด€๋ฆฌ</li>
* </ul>
* </p>
*
* @author Loopers
* @version 1.0
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class CouponEventHandler {

private final CouponService couponService;
private final CouponEventPublisher couponEventPublisher;

/**
* ์ฃผ๋ฌธ ์ƒ์„ฑ ์ด๋ฒคํŠธ๋ฅผ ์ฒ˜๋ฆฌํ•˜์—ฌ ์ฟ ํฐ์„ ์‚ฌ์šฉํ•˜๊ณ  ์ฟ ํฐ ์ ์šฉ ์ด๋ฒคํŠธ๋ฅผ ๋ฐœํ–‰ํ•ฉ๋‹ˆ๋‹ค.
* <p>
* ์ฟ ํฐ ์ฝ”๋“œ๊ฐ€ ์žˆ๋Š” ๊ฒฝ์šฐ์—๋งŒ ์ฟ ํฐ ์‚ฌ์šฉ ์ฒ˜๋ฆฌ๋ฅผ ์ˆ˜ํ–‰ํ•ฉ๋‹ˆ๋‹ค.
* ์ฟ ํฐ ์ ์šฉ ํ›„ CouponApplied ์ด๋ฒคํŠธ๋ฅผ ๋ฐœํ–‰ํ•˜์—ฌ ์ฃผ๋ฌธ ๋„๋ฉ”์ธ์ด ์ž์‹ ์˜ ์ƒํƒœ๋ฅผ ์—…๋ฐ์ดํŠธํ•˜๋„๋ก ํ•ฉ๋‹ˆ๋‹ค.
* </p>
*
* @param event ์ฃผ๋ฌธ ์ƒ์„ฑ ์ด๋ฒคํŠธ
*/
@Transactional
public void handleOrderCreated(OrderEvent.OrderCreated event) {
// ์ฟ ํฐ ์ฝ”๋“œ๊ฐ€ ์—†๋Š” ๊ฒฝ์šฐ ์ฒ˜๋ฆฌํ•˜์ง€ ์•Š์Œ
if (event.couponCode() == null || event.couponCode().isBlank()) {
log.debug("์ฟ ํฐ ์ฝ”๋“œ๊ฐ€ ์—†์–ด ์ฟ ํฐ ์‚ฌ์šฉ ์ฒ˜๋ฆฌ๋ฅผ ๊ฑด๋„ˆ๋œ๋‹ˆ๋‹ค. (orderId: {})", event.orderId());
return;
}

// ์ฟ ํฐ ์‚ฌ์šฉ ์ฒ˜๋ฆฌ (์ฟ ํฐ ์‚ฌ์šฉ ๋งˆํ‚น ๋ฐ ํ• ์ธ ๊ธˆ์•ก ๊ณ„์‚ฐ)
Integer discountAmount = couponService.applyCoupon(
event.userId(),
event.couponCode(),
event.subtotal()
);

// โœ… ๋„๋ฉ”์ธ ์ด๋ฒคํŠธ ๋ฐœํ–‰: ์ฟ ํฐ์ด ์ ์šฉ๋˜์—ˆ์Œ (๊ณผ๊ฑฐ ์‚ฌ์‹ค)
// ์ฃผ๋ฌธ ๋„๋ฉ”์ธ์ด ์ด ์ด๋ฒคํŠธ๋ฅผ ๊ตฌ๋…ํ•˜์—ฌ ์ž์‹ ์˜ ์ƒํƒœ๋ฅผ ์—…๋ฐ์ดํŠธํ•จ
couponEventPublisher.publish(CouponEvent.CouponApplied.of(
event.orderId(),
event.userId(),
event.couponCode(),
discountAmount
));

log.info("์ฟ ํฐ ์‚ฌ์šฉ ์ฒ˜๋ฆฌ ์™„๋ฃŒ. (orderId: {}, couponCode: {}, discountAmount: {})",
event.orderId(), event.couponCode(), discountAmount);
}
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
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;
import lombok.RequiredArgsConstructor;
import org.springframework.orm.ObjectOptimisticLockingFailureException;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;

/**
* ์ฟ ํฐ ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ ์„œ๋น„์Šค.
* <p>
* ์ฟ ํฐ ์กฐํšŒ, ์‚ฌ์šฉ ๋“ฑ์˜ ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ ๋กœ์ง์„ ์ฒ˜๋ฆฌํ•ฉ๋‹ˆ๋‹ค.
* Repository์— ์˜์กดํ•˜๋ฉฐ ํŠธ๋žœ์žญ์…˜ ๊ด€๋ฆฌ ๋ฐ ๋™์‹œ์„ฑ ์ œ์–ด๋ฅผ ๋‹ด๋‹นํ•ฉ๋‹ˆ๋‹ค.
* </p>
*
* @author Loopers
* @version 1.0
*/
@RequiredArgsConstructor
@Component
public class CouponService {
private final CouponRepository couponRepository;
private final UserCouponRepository userCouponRepository;
private final CouponDiscountStrategyFactory couponDiscountStrategyFactory;

/**
* ์ฟ ํฐ์„ ์ ์šฉํ•˜์—ฌ ํ• ์ธ ๊ธˆ์•ก์„ ๊ณ„์‚ฐํ•˜๊ณ  ์ฟ ํฐ์„ ์‚ฌ์šฉ ์ฒ˜๋ฆฌํ•ฉ๋‹ˆ๋‹ค.
* <p>
* <b>๋™์‹œ์„ฑ ์ œ์–ด ์ „๋žต:</b>
* <ul>
* <li><b>OPTIMISTIC_LOCK ์‚ฌ์šฉ ๊ทผ๊ฑฐ:</b> ์ฟ ํฐ ์ค‘๋ณต ์‚ฌ์šฉ ๋ฐฉ์ง€, Hot Spot ๋Œ€์‘</li>
* <li><b>@Version ํ•„๋“œ:</b> UserCoupon ์—”ํ‹ฐํ‹ฐ์˜ version ํ•„๋“œ๋ฅผ ํ†ตํ•ด ์ž๋™์œผ๋กœ ๋‚™๊ด€์  ๋ฝ ์ ์šฉ</li>
* <li><b>๋™์‹œ ์‚ฌ์šฉ ์‹œ:</b> ํ•œ ๋ช…๋งŒ ์„ฑ๊ณตํ•˜๊ณ  ๋‚˜๋จธ์ง€๋Š” OptimisticLockException ๋ฐœ์ƒ</li>
* <li><b>์‚ฌ์šฉ ๋ชฉ์ :</b> ๋™์ผ ์ฟ ํฐ์œผ๋กœ ์—ฌ๋Ÿฌ ๊ธฐ๊ธฐ์—์„œ ๋™์‹œ ์ฃผ๋ฌธํ•ด๋„ ํ•œ ๋ฒˆ๋งŒ ์‚ฌ์šฉ๋˜๋„๋ก ๋ณด์žฅ</li>
* </ul>
* </p>
*
* @param userId ์‚ฌ์šฉ์ž ID
* @param couponCode ์ฟ ํฐ ์ฝ”๋“œ
* @param subtotal ์ฃผ๋ฌธ ์†Œ๊ณ„ ๊ธˆ์•ก
* @return ํ• ์ธ ๊ธˆ์•ก
* @throws CoreException ์ฟ ํฐ์„ ์ฐพ์„ ์ˆ˜ ์—†๊ฑฐ๋‚˜ ์‚ฌ์šฉ ๋ถˆ๊ฐ€๋Šฅํ•œ ๊ฒฝ์šฐ, ๋™์‹œ ์‚ฌ์šฉ์œผ๋กœ ์ธํ•œ ์ถฉ๋Œ ์‹œ
*/
@Transactional
public Integer applyCoupon(Long userId, String couponCode, Integer subtotal) {
// ์ฟ ํฐ ์กด์žฌ ์—ฌ๋ถ€ ํ™•์ธ
Coupon coupon = couponRepository.findByCode(couponCode)
.orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND,
String.format("์ฟ ํฐ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค. (์ฟ ํฐ ์ฝ”๋“œ: %s)", couponCode)));

// ๋‚™๊ด€์  ๋ฝ์„ ์‚ฌ์šฉํ•˜์—ฌ ์‚ฌ์šฉ์ž ์ฟ ํฐ ์กฐํšŒ (๋™์‹œ์„ฑ ์ œ์–ด)
// @Version ํ•„๋“œ๊ฐ€ ์žˆ์–ด ์ž๋™์œผ๋กœ ๋‚™๊ด€์  ๋ฝ์ด ์ ์šฉ๋จ
UserCoupon userCoupon = userCouponRepository.findByUserIdAndCouponCodeForUpdate(userId, couponCode)
.orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND,
String.format("์‚ฌ์šฉ์ž๊ฐ€ ์†Œ์œ ํ•œ ์ฟ ํฐ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค. (์ฟ ํฐ ์ฝ”๋“œ: %s)", couponCode)));

// ์ฟ ํฐ ์‚ฌ์šฉ ๊ฐ€๋Šฅ ์—ฌ๋ถ€ ํ™•์ธ
if (!userCoupon.isAvailable()) {
throw new CoreException(ErrorType.BAD_REQUEST,
String.format("์ด๋ฏธ ์‚ฌ์šฉ๋œ ์ฟ ํฐ์ž…๋‹ˆ๋‹ค. (์ฟ ํฐ ์ฝ”๋“œ: %s)", couponCode));
}

// ์ฟ ํฐ ์‚ฌ์šฉ ์ฒ˜๋ฆฌ
userCoupon.use();

// ํ• ์ธ ๊ธˆ์•ก ๊ณ„์‚ฐ (์ „๋žต ํŒจํ„ด ์‚ฌ์šฉ)
Integer discountAmount = coupon.calculateDiscountAmount(subtotal, couponDiscountStrategyFactory);

try {
// ์‚ฌ์šฉ์ž ์ฟ ํฐ ์ €์žฅ (version ์ฒดํฌ ์ž๋™ ์ˆ˜ํ–‰)
// ๋‹ค๋ฅธ ํŠธ๋žœ์žญ์…˜์ด ๋จผ์ € ์ˆ˜์ •ํ–ˆ๋‹ค๋ฉด OptimisticLockException ๋ฐœ์ƒ
userCouponRepository.save(userCoupon);
// โœ… flush()๋ฅผ ๋ช…์‹œ์ ์œผ๋กœ ํ˜ธ์ถœํ•˜์—ฌ Optimistic Lock ์ฒดํฌ๋ฅผ ์ฆ‰์‹œ ์ˆ˜ํ–‰
// flush() ์—†์ด๋Š” ํŠธ๋žœ์žญ์…˜ ์ปค๋ฐ‹ ์‹œ์ ์— ์ฒดํฌ๋˜๋ฏ€๋กœ, ์—ฌ๋Ÿฌ ํŠธ๋žœ์žญ์…˜์ด ๋™์‹œ์— ์„ฑ๊ณตํ•  ์ˆ˜ ์žˆ์Œ
userCouponRepository.flush();
} catch (ObjectOptimisticLockingFailureException e) {
// ๋‚™๊ด€์  ๋ฝ ์ถฉ๋Œ: ๋‹ค๋ฅธ ํŠธ๋žœ์žญ์…˜์ด ๋จผ์ € ์ฟ ํฐ์„ ์‚ฌ์šฉํ•จ
throw new CoreException(ErrorType.CONFLICT,
String.format("์ฟ ํฐ์ด ์ด๋ฏธ ์‚ฌ์šฉ๋˜์—ˆ์Šต๋‹ˆ๋‹ค. (์ฟ ํฐ ์ฝ”๋“œ: %s)", couponCode));
}

return discountAmount;
}
}

Loading