이번 주에는 주문/결제 로직을 다시 바라보면서 설계에 대한 사고방식을 바꾸는 경험을 했다.
처음 구현 당시에는 “주문 생성부터 결제까지 한 트랜잭션으로 처리하는 게 당연하다”라고 생각했었다.
하나의 흐름에서 모든 걸 끝내는 것이 단순하고 깔끔해 보였기 때문이다.
역할과 책임에 따라 서비스를 분리했지만, 그래도 여전히 하나의 트랜잭션
처음에는 역할과 책임에 따라 서비스와 도메인을 나누는 것에 집중했다.
주문, 결제, 재고, 쿠폰 기능을 각각의 서비스로 분리했지만, 트랜잭션 경계까지는 고려하지 않았다는 게 문제였다.
결국 주문 생성 → 쿠폰 사용 → 결제 요청 → 재고 차감 → 주문 완료까지
겉으로는 서비스가 분리된 것처럼 보여도, 실제로는 하나의 거대한 트랜잭션이었다.
처음에는 결제를 사용자의 포인트로만 처리했기 때문에 크게 문제를 느끼지 못했다.
하지만 이후 가상 PG를 통한 카드 결제 기능을 추가하면서 외부 PG API 연동을 포함하게 되자 운영 환경에서 문제가 드러나기 시작했다.
단일 트랜잭션의 한계
트랜잭션 점유 시간 증가
외부 PG API 호출로 인해 트랜잭션이 길어지고, 트랜잭션이 길어지면 커넥션이 점유되는 시간이 늘어나 동시에 많은 주문을 처리할 경우 다른 요청들이 커넥션을 대기하는 상황이 생긴다.
장애 전파 범위 확대
결제 실패 한 번으로 주문 전체가 롤백되는 상황 발생
높은 도메인 결합도
주문, 결제, 재고, 쿠폰 로직이 한 트랜잭션에 묶여 유지보수 어려움
시스템 안정성 저하
일부 장애가 전체 서비스의 처리 속도를 늦추는 문제로 이어질 수 있음
이벤트로 흐름을 분리
문제를 해결하기 위해 선택한 방법은 이벤트 기반 설계였다.
핵심은 "꼭 처리되어야 하는 핵심 로직과"과 "나중에 해도 되는 부가로직"을 분리하는 것이다.
주문 생성은 핵심 트랜잭션으로서 빠르게 커밋하고 결제 요청, 쿠폰 사용, 주문 완료 처리 등은 이벤트로 분리해 별도 트랜잭션에서 처리했다.
@Component
class PaymentEventListener(
private val orderFacade: OrderFacade,
private val paymentService: PaymentService,
) {
private val log = getLogger(this::class.java)
@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
fun handle(event: OrderCreatedEvent) {
log.info("주문 생성 이벤트 수신: $event")
paymentService.pay(
PaymentCommand.Pay(
event.orderId,
event.userId,
PaymentMethodType.valueOf(event.paymentMethod),
Price(event.totalPrice),
event.cardType?.let { PaymentCardType.valueOf(it) },
event.cardNo,
event.orderKey,
),
)
}
@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
fun handle(event: PaymentCompletedEvent) {
log.info("결제 완료 이벤트 수신: $event")
orderFacade.completeOrder(event.orderKey, event.transactionKey)
}
@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
fun handle(event: PaymentFailedEvent) {
log.info("결제 실패 이벤트 수신: $event")
orderFacade.failOrder(event.orderKey, event.transactionKey)
}
@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_ROLLBACK)
fun handle(event: OrderCompletedEvent) {
log.info("주문 완료 롤백 이벤트 수신: $event")
orderFacade.recoveryOrder(event.orderKey, event.transactionKey)
}
}
예전에는 "확실하게 한 번에 처리해야 안전하다"는 생각이었다면, "지금은 트랜잭션을 나누는 것이 더 안전하다"라는 쪽으로 사고가 바뀌었다.
트랜잭션은 짧을수록 안정적이다
- 핵심 로직만 커밋하고, 부가 로직은 이벤트로 분리하는 것이 효율적이다.
이벤트 기반 설계는 도메인 결합도를 낮춘다
- 각 도메인이 서로의 내부 동작을 몰라도 흐름이 이어진다.
설계의 단순함보다 운영 환경에서의 안정성이 중요하다
- 단순해 보이는 한 트랜잭션 처리 방식이 실제로는 병목과 장애를 만들 수 있다.
정리
한 트랜잭션에서 모든 걸 처리하는 게 단순하고 확실해 보였지만, 외부 환경과 연동하면서 병목과 장애 전파를 유발할 수 있다는 걸 직접 확인해봤다.
이번 주는 "모든 걸 한 번에 처리하는 것"에서 "흐름을 나누는 것" 처럼 "사고 방식의 전환"이 가장 핵심이었던 것 같다.
'Loopers' 카테고리의 다른 글
| WIL - 9주차 회고 (1) | 2025.09.12 |
|---|---|
| WIL - 8주차 회고 (0) | 2025.09.05 |
| WIL - 6주차 회고 (1) | 2025.08.24 |
| WIL - 5주차 회고 (2) | 2025.08.17 |
| WIL - 4주차 회고 (5) | 2025.08.10 |
댓글