ํ‹ฐ์Šคํ† ๋ฆฌ ๋ทฐ

[Spring Boot] ๊ฐ€์ƒ๊ณ„์ขŒ ์ž…๊ธˆ๊ธฐํ•œ ๋งŒ๋ฃŒ ์ž๋™ ์ฒ˜๋ฆฌ ์Šค์ผ€์ค„๋Ÿฌ ๊ตฌํ˜„๊ธฐ

[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๋กœ ๋„˜์–ด๊ฐˆ ์ฐจ๋ก€๋‹ค.

๋Œ“๊ธ€
๊ณต์ง€์‚ฌํ•ญ
์ตœ๊ทผ์— ์˜ฌ๋ผ์˜จ ๊ธ€
์ตœ๊ทผ์— ๋‹ฌ๋ฆฐ ๋Œ“๊ธ€
Total
Today
Yesterday
๋งํฌ
TAG
more
ยซ   2026/09   ยป
์ผ ์›” ํ™” ์ˆ˜ ๋ชฉ ๊ธˆ ํ† 
1 2 3 4 5
6 7 8 9 10 11 12
13 14 15 16 17 18 19
20 21 22 23 24 25 26
27 28 29 30
๊ธ€ ๋ณด๊ด€ํ•จ