ํฐ์คํ ๋ฆฌ ๋ทฐ
[Spring Boot] ๊ฐ์๊ณ์ข ์ ๊ธ๊ธฐํ ๋ง๋ฃ ์๋ ์ฒ๋ฆฌ ์ค์ผ์ค๋ฌ ๊ตฌํ โฉ
ๅๆ 2026. 8. 25. 16:27[Spring Boot] ๊ฐ์๊ณ์ข ์ ๊ธ๊ธฐํ ๋ง๋ฃ ์๋ ์ฒ๋ฆฌ ์ค์ผ์ค๋ฌ ๊ตฌํ๊ธฐ
์นด๋ง ํ๋ก์ ํธ์ ๋ฌดํต์ฅ์
๊ธ(๊ฐ์๊ณ์ข)์ ๋ถ์ธ ์ง๋ ์ข ๋๋๋ฐ, ์ ์ "์
๊ธ๊ธฐํ ์ง๋๋ฉด ์ด๋ป๊ฒ ๋๋๊ฐ"๋ ์ค์ฝํ ๋ฐ์ผ๋ก ๋จ๊ฒจ๋์๋ค.
๊ฐ์๊ณ์ข๋ง ๋ฐ๊ธํด๋๊ณ ์๋ฌด๋ ์
๊ธ์ ์ ํ๋ฉด, ์ฃผ๋ฌธ์ ์์ํ PENDING์ผ๋ก ๋จ๊ณ ์ฌ๊ณ ๋ ๊ณ์ ๋ฌถ์ฌ์๋ ์ํ์๋ ๊ฒ.
์ด๋ฒ์ ์
๊ธ๊ธฐํ ๋ง๋ฃ ์๋ ์ฒ๋ฆฌ ์ค์ผ์ค๋ฌ๋ฅผ ๋ถ์ฌ์ ์ด ๊ตฌ๋ฉ์ ๋ฉ์ ๋ค.
๐ ๊ฐ์
๋ชฉํ๋ ๊ฐ๋จํ๋ค. vbankDueDate(์
๊ธ๊ธฐํ)๊ฐ ์ง๋ฌ๋๋ฐ๋ ์ฌ์ ํ PENDING ์ํ์ธ ์ฃผ๋ฌธ์ ๋งค์๊ฐ ์ค์บํด์,
์๋์ผ๋ก ์ทจ์ ์ฒ๋ฆฌํ๊ณ ์ฌ๊ณ ๋ฅผ ๋ณต๊ตฌํ๋ ๊ฒ. ๊ธฐ์กด์ ์ด๋ฏธ ์ํ ํ ์ธ ๋ง๋ฃ / ์ฅ๋ฐ๊ตฌ๋ ๋ง๋ฃ๋ฅผ ์ฒ๋ฆฌํ๋
DiscountScheduler๊ฐ ์์ด์, ๊ทธ ํจํด์ ๊ทธ๋๋ก ์ฐธ๊ณ ํ๋ค.
๐ค ์ค๊ณ ๊ฒฐ์
์๋ก ์ง๊ธฐ ์ ์ ๊ธฐ์กด cancelOrder()(์ฌ์ฉ์๊ฐ ์ง์ ์ทจ์ํ๋ API)๋ฅผ ๋ค์ ๋ค์ฌ๋ค๋ดค๋๋ฐ, ์ด๊ฒ ํ๋ ค๋ ์ผ์ด๋ ๊ฑฐ์ ๋๊ฐ๋๋ผ.
"์ฃผ๋ฌธ์ ์ทจ์ ์ํ๋ก ๋ฐ๊พธ๊ณ , ์ฌ๊ณ ๋ฅผ ๋ณต๊ตฌํ๊ณ , ํฌํธ์์ ์ทจ์ ์์ฒญ์ ๋ณด๋ธ๋ค" โ ์ ์ผํ ์ฐจ์ด๋ ๋๊ฐ/์ ์ทจ์ํ๋์ง๋ฟ์ด์๋ค.
๊ทธ๋์ ์์ ํ ์๋ก ์ง๋ ๋์ , ์ทจ์ ์ฌ์ ๋ฅผ ํ๋ ์ถ๊ฐํ๊ณ ์ฌ๊ณ ๋ณต๊ตฌ ๋ก์ง์ ๊ณต์ฉ ๋ฉ์๋๋ก ๋ฝ์์ ์ฌ์ฌ์ฉํ๋ ๋ฐฉํฅ์ผ๋ก ๊ฐ๋ค.
์ฌ์ฉ์๊ฐ ์ทจ์ํ ๊ฑด์ง ์์คํ ์ด ์๋์ผ๋ก ๋ง๋ฃ์ํจ ๊ฑด์ง ๊ตฌ๋ถ์ด ์ ๋๋ฉด, ๋์ค์ ์ฃผ๋ฌธ ๋ชฉ๋ก์ ๋ณผ ๋ ํท๊ฐ๋ฆฐ๋ค. ๊ทธ๋์CancelReason์VBANK_EXPIRED๋ผ๋ ์ฌ์ ๋ฅผ ๋ณ๋๋ก ์ถ๊ฐํ๋ค.
๐ ๏ธ ๊ตฌํ ํ๋ฆ
โ CancelReason์ ๋ง๋ฃ ์ ์ฉ ์ฌ์ ์ถ๊ฐ:
VBANK_EXPIRED("์
๊ธ๊ธฐํ ๋ง๋ฃ"),
โก OrderRepository์ "์
๊ธ๊ธฐํ ์ง๋ PENDING ์ฃผ๋ฌธ"์ ์กฐํํ๋ ์ฟผ๋ฆฌ ์ถ๊ฐ. ์ฌ๊ณ ๋ณต๊ตฌ๋ฅผ ์ํด orderItems๊น์ง fetch join์ผ๋ก ํ ๋ฒ์ ๋๊ณ ์จ๋ค:
@Query("""
select distinct o from Order o
join fetch o.orderItems oi
join fetch oi.productVariant
where o.status = :status
and o.vbankDueDate < :now
""")
List<Order> findExpiredVirtualAccountOrders(
@Param("status") OrderStatus status,
@Param("now") LocalDateTime now
);
โข OrderCommandService์ ๋ง๋ฃ ์ฒ๋ฆฌ ๋ฉ์๋ ์ถ๊ฐ. ์ด ๊ณผ์ ์์ cancelOrder() ์์ ์๋ "์ฌ๊ณ ๋ณต๊ตฌ์ฉ ์์ฌ์๋ ์บก์ณ + ์ฌ๊ณ ๋ณต๊ตฌ" ๋ก์ง์ด
์์ ํ ๋๊ฐ์ด ํ์ํ๋ค๋ ๊ฑธ ๋ฐ๊ฒฌํด์, ์์ ๊ณต์ฉ private ๋ฉ์๋ ๋ ๊ฐ๋ก ๋ฝ์ cancelOrder()๋ ๊ฐ์ด ์ฌ์ฌ์ฉํ๋๋ก ๋ฆฌํฉํ ๋งํ๋ค:
private Map<Long, Long> captureRemainingQuantities(Order order) {
Map<Long, Long> remainingByItemId = new HashMap<>();
for (OrderItem item : order.getOrderItems()) {
long remainingQuantity = item.getQuantity() - item.getCancelledQuantity();
if (remainingQuantity > 0) {
remainingByItemId.put(item.getId(), remainingQuantity);
}
}
return remainingByItemId;
}
private void restoreStock(Order order, Map<Long, Long> remainingByItemId) {
for (OrderItem item : order.getOrderItems()) {
Long remainingQuantity = remainingByItemId.get(item.getId());
if (remainingQuantity == null) continue;
productStockRepository
.findByProductVariant_Id(item.getProductVariant().getId())
.ifPresent(stock -> stock.increase(remainingQuantity));
}
}
@Transactional
public int expireVirtualAccountOrders() {
LocalDateTime now = LocalDateTime.now();
List<Order> expiredOrders = orderRepository.findExpiredVirtualAccountOrders(OrderStatus.PENDING, now);
for (Order order : expiredOrders) {
Map<Long, Long> remainingByItemId = captureRemainingQuantities(order);
order.cancel(CancelReason.VBANK_EXPIRED);
if (order.getImpUid() != null) {
portOneClient.cancelPayment(order.getImpUid(), CancelReason.VBANK_EXPIRED.getDescription());
}
restoreStock(order, remainingByItemId);
}
return expiredOrders.size();
}
โฃ DiscountScheduler ์ ๊ฐ์ ํจํค์ง์ VirtualAccountScheduler๋ฅผ ์๋ก ๋ง๋ค๊ณ , ๋งค์ ์ ๊ฐ์ ์คํ๋๋๋ก cron์ ๊ฑธ์๋ค:
@Scheduled(cron = "0 0 * * * *")
public void expireOverdueVirtualAccountOrders() {
int expiredCount = orderCommandService.expireVirtualAccountOrders();
log.info("[๊ฐ์๊ณ์ข ๋ง๋ฃ ์ค์ผ์ค๋ฌ] ๋ง๋ฃ ์ฒ๋ฆฌ: {}๊ฑด", expiredCount);
}
๐งช ํ ์คํธ ๊ณํ
Mockito๋ก ๋จ์ ํ
์คํธ ์์ฑ. ์
๊ธ๊ธฐํ ์ง๋ PENDING ์ฃผ๋ฌธ ํ๋๋ฅผ ๋ง๋ค์ด๋๊ณ expireVirtualAccountOrders()๋ฅผ ํธ์ถํ ๋ค,
โ ๋ฆฌํด๊ฐ(์ฒ๋ฆฌ ๊ฑด์), โก ์ฃผ๋ฌธ ์ํ๊ฐ CANCELLED๋ก ์ ํ๋๋์ง, โข ์ฌ์ ๊ฐ VBANK_EXPIRED๋ก ์ ์ฅ๋๋์ง,
โฃ ์ฌ๊ณ ๊ฐ ์ฃผ๋ฌธ ์๋๋งํผ ์ ํํ ๋ณต๊ตฌ๋๋์ง, โค ํฌํธ์ cancelPayment()๊ฐ ํธ์ถ๋๋์ง ์ด๋ ๊ฒ ๋ค์ฏ ๊ฐ์ง๋ฅผ ๊ฒ์ฆํ๋ค.
๐ ํท๊ฐ๋ ธ๋ ๊ฒ๋ค & ํธ๋ฌ๋ธ์ํ
์ฟผ๋ฆฌ์ select distinct๋ฅผ ์ ๋ถ์ฌ์ผ ํ๋์ง ์ฒ์์ ๊ฐ์ด ์ ์๋ค. ์๊ณ ๋ณด๋ join fetch๋ก ์ปฌ๋ ์
(orderItems)์ ๊ฐ์ด ๊ฐ์ ธ์ค๋ฉด,
SQL ๋ ๋ฒจ์์๋ ์กฐ์ธ์ด๋ผ ์ฃผ๋ฌธ ํ๋์ ์์ดํ
์ด 2๊ฐ๋ฉด ๊ฒฐ๊ณผ๋ 2์ค์ด ๋์ด๋ฒ๋ฆฐ๋ค. distinct ์์ด ๊ทธ๋ฅ List๋ก ๋ฐ์ผ๋ฉด
๊ฐ์ ์ฃผ๋ฌธ์ด ๋ฆฌ์คํธ์ ์ค๋ณต์ผ๋ก ๋ค์ด๊ฐ์, ์ด๋ฏธ ์ทจ์๋ ์ฃผ๋ฌธ์ ๋ ์ทจ์ํ๋ ค๋ค๊ฐ ์์ธ๊ฐ ํฐ์ง ๋ปํ๋ค. distinct๋ SQL์ ์ค๋ณต ์ ๊ฑฐ๊ฐ ์๋๋ผ
"JPA๊ฐ ๋ฃจํธ ์ํฐํฐ ๊ธฐ์ค์ผ๋ก ์ค๋ณต ์ ๊ฑฐํด์ค๋ค"๋ ๊ฐ๋
์ผ๋ก ์ดํดํ๋ ๋ง์๋จ์ด์ก๋ค.
์ฌ๊ณ ๋ณต๊ตฌ ๋ก์ง์์๋ ์์๊ฐ ์ ์ค์ํ์ง ํท๊ฐ๋ ธ์๋ค. captureRemainingQuantities()๋ฅผ ๊ผญ order.cancel() ํธ์ถ ์ ์ ์คํํด์ผ ํ๋๋ฐ,
์ด์ ๋ cancel()์ด ๋ด๋ถ์ ์ผ๋ก cancelledQuantity๋ฅผ ์ด๋ฏธ ๋ค ์ฑ์๋ฒ๋ ค์, ๊ทธ ์ดํ์ "์ผ๋ง๋ ๋จ์์์๋์ง" ์ ๋ณด ์์ฒด๊ฐ ์ฌ๋ผ์ง๊ธฐ ๋๋ฌธ์ด์๋ค.
๋จผ์ ์ค๋
์ท์ ๋ ๋๊ณ , ์ํ๋ฅผ ๋ฐ๊พธ๊ณ , ๊ทธ ์ค๋
์ท์ผ๋ก ๋ณต๊ตฌํ๋ ์์๊ฐ ํ์์๋ ๊ฒ.
๊ทธ๋ผ ๊ฐ์ ํจํด์ ๋ถ๋ถ์ทจ์(cancelOrderItem())์๋ ์ ์ฉํ ์ ์์ง ์์๊น ์ถ์๋๋ฐ, ์ด๊ฑด ์๋์๋ค.
cancelOrder()๋ฅ๋ "๋จ์ ๊ฑธ ์ ๋ถ" ์ทจ์ํด์ quantity - cancelledQuantity ๊ณ์ฐ์ด ๊ณง ์ทจ์๋๊ณผ ์ผ์นํ์ง๋ง,
cancelOrderItem()์ ํธ์ถํ๋ ์ชฝ์ด ์์๋ก ์ง์ ํ ๋ถ๋ถ ์๋๋ง ์ทจ์ํ๋ ๊ฑฐ๋ผ ๊ฐ์ ๊ณ์ฐ์์ ์ฐ๋ฉด ์ฌ๊ณ ๊ฐ ๊ณผ๋ค ๋ณต๊ตฌ๋๋ ๋ฒ๊ทธ๋ก ์ด์ด์ง๋ค.
ํ๋ก ์ ๋ฆฌํ๋ฉด ์ด๋ ๋ค:
| ๊ตฌ๋ถ | cancelOrder() / expireVirtualAccountOrders() | cancelOrderItem() |
|---|---|---|
| ์ทจ์ ๋ฒ์ | ๋จ์ ์๋ ์ ์ฒด | ํธ์ถ์๊ฐ ์ง์ ํ ์ผ๋ถ ์๋ |
| ์ทจ์๋ ๊ณ์ฐ | quantity - cancelledQuantity (๊ณ์ฐํด์ผ ์) | command.cancelQuantity() (์ด๋ฏธ ์๊ณ ์์) |
| ์ฌ๊ณ ๋ณต๊ตฌ ๋ฐฉ์ | ์บก์ณ โ ์ทจ์ โ ๋ณต๊ตฌ (3๋จ๊ณ) | ๋ฐ์ ๊ฐ ๊ทธ๋๋ก ์ทจ์ยท๋ณต๊ตฌ์ ์ฌ์ฌ์ฉ (2๋จ๊ณ) |
๐งฉ ๋ง๋ฌด๋ฆฌ ์ ๋ฆฌ
์ ๊ธฐ๋ฅ์ฒ๋ผ ๋ณด์์ง๋ง ์ค์ ๋ก๋ ๊ธฐ์กด ์ทจ์ ๋ก์ง์ ์ค์ผ์ค๋ฌ๊ฐ ๋์ ํธ๋ฆฌ๊ฑฐํ๋ ๊ฒ์ ๊ฐ๊น์ ๋ค. ๋๋ถ์ ์์ ํ ์๋ก ์ง๊ธฐ๋ณด๋ค๋ ๊ธฐ์กด ์ฝ๋์์ ์ง์ง ์ค๋ณต(์ฌ๊ณ ๋ณต๊ตฌ ๋ก์ง)์ ์ฐพ์์ ๊ณต์ฉํํ๋ ๋ฆฌํฉํ ๋ง๊น์ง ๊ฐ์ด ํ ์ ์์๊ณ , ๋ฐ๋๋ก ๊ฒ๋ณด๊ธฐ์ ๋น์ทํด๋ ์ ๋ ํฉ์น๋ฉด ์ ๋๋ ๋ก์ง(cancelOrderItem)๋ ๊ตฌ๋ถํด๋ด๋ ์ฐ์ต์ด ๋๋ค. ์ด๊ฑธ๋ก ์นด๋ง ํ๋ก์ ํธ์ ๊ธฐ๋ฅ ๊ฐ๋ฐ ํํธ๋ ์ผ๋จ ๋ง๋ฌด๋ฆฌ โ ๋ค์์ Swagger ์ ๋ฆฌ, AOP ๋ก๊น , Docker+CI/CD๋ก ๋์ด๊ฐ ์ฐจ๋ก๋ค.