프로젝트

하나로 묶인 트랜잭션을 쪼개다: 이벤트 기반 주문 시스템 리팩토링기

nahud 2025. 9. 25. 00:23
TL;DR: 주문-결제-재고-쿠폰 처리를 하나의 트랜잭션에서 처리하다가 PG 장애시 전체 시스템이 마비되는 문제를 우려. API 분리 → 이벤트 기반 구조로 단계적 리팩토링을 진행했지만 이벤트 실패 복구와 중복 처리 방지 등 새로운 고민거리도 생겼다.

as-is: 거대한 트랜잭션으로 다양한 관심사

to-be: 이벤트를 통해 나눠진 비즈니스 관심사

문제 발견: 점점 무거워지는 주문 흐름

이커머스 주문 시스템을 구현하면서 처음에는 단순하게 생각했다. 사용자가 “주문하기” 버튼을 누르면 모든 처리를 한 번에 끝내는 것이 깔끔하지 않을까?

// sudo code
@Transactional
fun createOrder(request: CreateOrderRequest): OrderResponse {
    // 1. 주문 생성
    
    // 2. 결제 처리에 따른 분기
    when (request.paymentType) {
        POINT -> {
            pointService.deduct(request.userId, order.totalAmount)
            // 성공하면 후속 처리
            productService.deductStock(order.orderLines)
            couponService.use(request.couponId)
            orderRepository.save(order.complete())
        }
        CARD -> {
            val paymentResult = pgClient.requestPayment(request)
            if (paymentResult.isSuccess) {
                // 웹훅 대기 상태로 저장
                orderRepository.save(order.pendingPayment())
            } else {
                throw PaymentFailedException()
            }
        }
    }
    
    return OrderResponse.from(order)
}

이 방식은 로컬에서 테스트할 때는 완벽해 보였다. 고민을 해보니 문제들이 보이기 시작했다.

 

첫 번째 문제: PG 장애가 전체 주문을 마비시킬것이다

어느 날 결제대행사(PG) API가 응답하지 않는 장애가 발생한다면? 주문조차 생성되지 않는 상황이 발생할 것이다. 하나의 트랜잭션에서 너무 많은 일을 처리하고 있다는걸 깨달았다.

 

두 번째 문제: 하나의 데이터베이스 커넥션이 점유시간을 오래 가질 것이다.

만약 저 중 쿠폰의 갱신을 경합하느라 트랜잭션이 오래걸린다면? 요청들이 많아진다면 데이터베이스에서 사용할 수 있는 커넥션이 금방 고갈되 유저들이 불편을 겪을 것이다.

 

세 번째 문제: 웹훅 처리의 애매함

카드 결제의 경우 PG사에서 오는 웹훅을 기다려야 한다. 그런데 웹훅이 오기 전까지는 주문 상태가 애매했다.

// 웹훅 핸들러에서 또 다른 트랜잭션
@PostMapping("/webhook/payment")
fun handlePaymentWebhook(webhook: PaymentWebhook) {
    when (webhook.status) {
        SUCCESS -> {
            // 이제서야 재고 차감과 쿠폰 사용을 해야 한다
            productService.deductStock(order.orderLines)
            couponService.use(order.couponId)
            orderRepository.updateStatus(order.id, COMPLETED)
        }
        FAILED -> {
            orderRepository.updateStatus(order.id, CANCELLED)
        }
    }
}

결국 같은 로직(재고 차감, 쿠폰 사용)을 두 군데서 처리하게 됐고, 코드 중복도 생기고 실수할 여지도 많아졌다.

진짜 문제는 트랜잭션의 책임이 너무 많다는 것

문제를 정리해보니 하나의 패턴이 보였다. 주문 트랜잭션이 다음과 같은 일들을 동시에 처리하려고 했다:

  • 비즈니스 로직: 주문 생성, 금액 계산
  • 외부 시스템 호출: PG API, 알림 발송
  • 자원 관리: 재고 차감, 포인트 차감
  • 후속 처리: 쿠폰 사용, 이벤트 로깅

이 중에서 정말 “지금 당장” 해야 하는 일과 “조금 나중에 해도 되는” 일을 구분하지 못했던 것이다.

그때부터 고민이 시작됐다. 이 무거운 트랜잭션을 어떻게 나눌 수 있을까? 사용자는 빠른 응답을 받으면서도, 시스템은 안전하게 모든 처리를 완료할 수 있는 방법은 없을까?

첫 번째 시도: API 분리부터 시작했다.

무거운 트랜잭션 문제를 인식한 후, 가장 먼저 든 생각은 “API부터 나누자”였다. 주문과 결제를 별도 엔드포인트로 분리하면 사용자는 빠른 응답을 받을 수 있을 것 같았다.

 

주문 API: 선점 개념 도입

// POST /orders - 주문 생성 API
@Transactional
fun createOrder(request: CreateOrderRequest): OrderResponse {
    val order = Order.create(request)
    
    // 1. 재고 선점 (실제 차감 X, 예약만)
    productService.reserveStock(order.orderLines)
    
    // 2. 쿠폰 사용 대기 상태로 변경
    if (request.couponId != null) {
        couponService.markAsWaiting(request.couponId)
    }
    
    // 3. 주문을 PENDING 상태로 저장
    val savedOrder = orderRepository.save(order.pending())
    
    return OrderResponse.from(savedOrder)
}

 

결제 API: 실제 차감 처리

// POST /payments - 결제 처리 API  
@Transactional
fun processPayment(request: PaymentRequest): PaymentResponse {
    val order = orderRepository.findById(request.orderId)
    
    when (request.paymentType) {
        POINT -> {
            pointService.deduct(request.userId, order.totalAmount)
            // 성공하면 선점했던 자원들을 확정
            confirmOrder(order)
        }
        CARD -> {
            val result = pgClient.requestPayment(request)
            if (result.isSuccess) {
                // PG 요청만 성공, 웹훅에서 실제 확정
                return PaymentResponse.pending(result.paymentId)
            } else {
                // 실패시 선점 해제  
                rollbackReservation(order)
                throw PaymentFailedException()
            }
        }
    }
}

private fun confirmOrder(order: Order) {
    productService.confirmReservation(order.orderLines) // 선점 -> 실제 차감
    couponService.confirmUsage(order.couponId) // 대기 -> 사용 완료  
    orderRepository.updateStatus(order.id, COMPLETED)
}

처음에는 만족스러웠다.

이 방식으로 바꾸고 나니 몇 가지 개선점이 보였다:

  • 빠른 응답: 주문 API는 재고 선점만 하고 바로 응답
  • 장애 격리: PG 장애가 주문 생성에 직접적인 영향을 주지 않음
  • 명확한 상태: 주문 상태를 통해 어느 단계까지 진행됐는지 추적 가능

하지만 새로운 문제들이 생겼다.

API는 분리했지만 여전히 결합도가 높다는걸 깨달았다.

// 결제 서비스가 여전히 모든걸 알아야 했다
private fun confirmOrder(order: Order) {
    productService.confirmReservation(order.orderLines) // 상품 도메인 의존
    couponService.confirmUsage(order.couponId)          // 쿠폰 도메인 의존  
    orderRepository.updateStatus(order.id, COMPLETED)   // 주문 도메인 의존
    // 나중에 알림, 포인트 적립 등이 추가되면?
}

웹훅 처리도 복잡했다.

카드 결제의 웹훅 처리에서 또 다른 문제가 드러났다:

@PostMapping("/webhook/payment")  
fun handlePaymentWebhook(webhook: PaymentWebhook) {
    when (webhook.status) {
        SUCCESS -> {
            val order = orderRepository.findByPaymentId(webhook.paymentId)
            confirmOrder(order) // 또 같은 로직 호출
        }
        FAILED -> {
            val order = orderRepository.findByPaymentId(webhook.paymentId)  
            rollbackReservation(order) // 선점 해제
        }
    }
}

결국 confirmOrder() 로직을 세 군데서 호출하게 됐다:

  • 포인트 결제 성공시
  • 카드 결제 웹훅 성공시
  • 재시도 로직에서

진짜 문제: 여전히 강한 결합

API를 분리했지만 본질적인 문제는 해결되지 않았다. 결제 서비스가 여전히 상품, 쿠폰, 주문 도메인을 모두 알고 있어야 했다.

// 결제 서비스의 의존성이 너무 많다
@Service  
class PaymentService(
    private val productService: ProductService,
    private val couponService: CouponService, 
    private val orderRepository: OrderRepository,
    private val pointService: PointService,
    private val pgClient: PGClient
) {
    // ...
}

새로운 후속 처리가 필요하면 (알림 발송, 포인트 적립, 이벤트 로깅 등) 결제 서비스를 계속 수정해야 했다. 이건 단일 책임 원칙에도 맞지 않았다.

그때 깨달았다. API 분리만으로는 근본적인 해결책이 아니라는 걸. 진짜 필요한 건 각 서비스가 자신의 책임만 갖고, 후속 처리는 “누군가 알아서” 하는 구조였다.

이벤트 기반 구조로의 전환

API 분리만으로는 근본적인 해결책이 아니라는걸 깨달은 후, 다른 접근 방식을 고민했다.

결제는 결제만 하면 되는 거 아닌가? 그럼 결제가 완료된 후의 일들은 “누가” 처리해야 할까?

 

이벤트라는 개념

// 결제 서비스가 해야 할 일은 이것뿐
@Transactional  
fun processPointPayment(request: PaymentRequest) {
    val order = orderRepository.findById(request.orderId)
    
    try {
        pointService.deduct(request.userId, order.totalAmount)
        // 결제 성공! 이제 누군가에게 알려주기만 하면 된다
        eventPublisher.publishEvent(PaymentSuccessEvent.from(order))
    } catch (e: InsufficientPointException) {
        // 결제 실패! 이것도 알려주자  
        eventPublisher.publishEvent(PaymentFailedEvent.from(order, e.message))
    }
}

카드 결제도 동일한 패턴으로

@Transactional
fun processCardPayment(request: PaymentRequest) {
    val order = orderRepository.findById(request.orderId)
    
    val result = pgClient.requestPayment(request)
    // PG 요청 결과에 관계없이 바로 사용자에게 응답
    return PaymentResponse.from(result)
}

// 웹훅도 이제 단순해진다
@PostMapping("/webhook/payment")
fun handlePaymentWebhook(webhook: PaymentWebhook) {
    when (webhook.status) {
        SUCCESS -> eventPublisher.publishEvent(PaymentSuccessEvent.from(webhook))
        FAILED -> eventPublisher.publishEvent(PaymentFailedEvent.from(webhook))
    }
}

이벤트 리스너가 후속 처리를 담당

결제 서비스는 결제만 하고, 후속 처리는 완전히 분리됐다.

@Component
class OrderEventHandler(
    private val productService: ProductService,
    private val couponService: CouponService,
    private val orderRepository: OrderRepository
) {
    
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    @Async
    fun handlePaymentSuccess(event: PaymentSuccessEvent) {
        // 결제가 성공적으로 커밋된 후에만 실행
        productService.confirmReservation(event.orderLines)
        couponService.confirmUsage(event.couponId)  
        orderRepository.updateStatus(event.orderId, OrderStatus.COMPLETED)
    }
    
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    @Async  
    fun handlePaymentFailure(event: PaymentFailedEvent) {
        // 실패 처리도 마찬가지
        productService.releaseReservation(event.orderLines)
        couponService.releaseWaiting(event.couponId)
        orderRepository.updateStatus(event.orderId, OrderStatus.CANCELLED)
    }
}

 

`@TransactionalEventListener`를 선택한 이유

처음에는 그냥 `@EventListener`를 쓰려고 했다. 하지만 곧 문제를 발견했다:

@Transactional
fun processPointPayment(request: PaymentRequest) {
    pointService.deduct(request.userId, order.totalAmount)
    eventPublisher.publishEvent(PaymentSuccessEvent.from(order))
    // 만약 이 트랜잭션이 롤백되면? 이벤트는 이미 발행됨
}

트랜잭션이 실패해서 롤백되는데 이벤트는 이미 발행되어 버리는 상황이 생길 수 있었다. 

그래서 `@TransactionalEventListener(phase = AFTER_COMMIT)`을 사용했다.

이렇게 하면:

  1. 결제 트랜잭션이 성공적으로 커밋된다
  2. 그 다음에 이벤트가 발행된다
  3. 별도의 트랜잭션에서 후속 처리가 실행된다

`@Async`를 추가한 이유

이벤트 리스너도 처음에는 동기로 실행했다. 하지만 후속 처리가 많아질수록 응답 시간이 늘어날 것으로 판단했다.

`@Async`를 추가하면 결제 완료 응답은 즉시 가고, 후속 처리는 백그라운드에서 병렬로 실행될 것이다.

확장이 정말 쉬워졌다.

// 알림 기능 추가? 리스너만 추가하면 끝
@Component  
class NotificationEventHandler {
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    @Async
    fun sendOrderCompleteNotification(event: PaymentSuccessEvent) {
        notificationService.send(event.userId, "주문이 완료됐습니다!")
    }
}

// 포인트 적립 기능 추가? 이것도 리스너만 추가
@Component
class PointEventHandler {
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) 
    @Async
    fun earnPoints(event: PaymentSuccessEvent) {
        pointService.earn(event.userId, event.amount * 0.01) // 1% 적립
    }
}

기존 결제 서비스나 주문 서비스는 전혀 수정하지 않아도 됐다. 이벤트 리스너만 추가하면 새로운 기능이 자동으로 동작했다.

아래와 같은 이점을 얻었다:

  • 코드 복잡도 변화: 서비스별 의존성이 크게 줄어들었다.
  • 장애 격리 효과: pg장애, 알림서버 장애가 발생해도 주문은 정상적으로 처리됐다.
  • 확장성 개선: 새로운 기능 추가가 쉬워졌다.
  • 트랜잭션 분리 효과: DB 트랜잭션 유지 시간도 줄어들었다.

아직 고민중인 부분들

이벤트 실패 시 복구 전략이 애매하다.

가장 큰 고민은 이벤트 처리가 실패했을 때다. 동기 방식이었다면 예외가 발생하면 전체 트랜잭션이 롤백되지만, 이벤트는 “fire and forget” 방식이라 실패해도 조용히 사라진다.

@TransactionalEventListener(phase = AFTER_COMMIT)
@Async
fun confirmOrder(event: PaymentSuccessEvent) {
    try {
        productService.confirmReservation(event.orderLines)
        couponService.confirmUsage(event.couponId)
    } catch (e: Exception) {
        // 이 예외를 어떻게 처리해야 할까?
        log.error("주문 확정 실패: ${event.orderId}", e)
        // 사용자는 결제 완료 알림을 받았는데, 실제로는 주문이 확정되지 않음
    }
}

실제로 DB 장애로 재고 확정이 실패한다면 유저는 결제 완료 알림을 받았지만 실제로는 주문이 대기 상태로 남아있을 것이다. 현재는 자동 복구 메커니즘이 없어 수동으로 처리해야 할 것이다.

 

중복 처리 방지가 생각보다 복잡하다.

@TransactionalEventListener(phase = AFTER_COMMIT)
@Async
fun earnPoints(event: PaymentSuccessEvent) {
    // 이 이벤트가 이미 처리됐는지 어떻게 알 수 있을까?
    pointService.earn(event.userId, event.amount * 0.01)
    // 같은 주문으로 포인트가 중복 적립될 위험
}

이벤트가 중복으로 발행되는 경우도 고려해야 했다. 트랜잭션 재시도나 시스템 장애 복구 과정에서 같은 이벤트가 여러 번 처리될 수 있다.

 

이벤트 처리 순서 보장이 어렵다

`@Async`로 병렬 처리하다 보니 순서가 중요한 이벤트들의 처리 순서가 보장되지 않는다.

 

모니터링과 디버깅이 어렵다

동기 방식일 때는 하나의 요청에 대한 전체 흐름을 쉽게 추적할 수 있었다. 하지만 이벤트 기반에서는 여러 스레드와 트랜잭션에 걸쳐 처리되다 보니 문제 상황을 파악하기 어렵다.

Spring ApplicationEvent의 한계

Spring의 ApplicationEvent는 단일 JVM 내에서만 동작한다. 지금은 모놀리식 구조라 문제없지만, 나중에 마이크로서비스로 분리하게 되면 이벤트를 다른 서비스로 전달할 수 없다.

// 현재는 같은 애플리케이션 내에서만 가능
@Component
class OrderEventHandler { // 주문 서비스 내부
    @TransactionalEventListener
    fun handle(event: PaymentSuccessEvent) {
        // 결제 이벤트를 받아서 처리
    }
}

// 만약 주문 서비스와 결제 서비스를 분리한다면?
// ApplicationEvent로는 서비스 간 통신 불가

이런 문제들을 해결하기 위해 몇 가지 방향을 고민 중이다:

  • 재시도 메커니즘 Spring Retry나 별도의 재시도 큐 도입
  • 이벤트 저장소: 이벤트를 DB에 먼저 저장하고 처리하는 Outbox 패턴
  • 분산 추적: 요청 ID를 이벤트에 포함해서 전체 흐름 추적
  • 메시지 브로커 나중에 Kafka 같은 외부 메시징 시스템 도입

이 고민들은 단계적으로 개선해나갈 계획이다.

중요한 건 완벽한 시스템을 만들려고 하기보다는, 현재 상황에서 가장 합리적인 선택을 하고 필요에 따라 발전시켜 나가는 것 같다.