점점 많아지는 결제 후 처리 과정
진행중인 사이드 프로젝트는 수능 문제를 구매하는 플랫폼으로 유저는 포인트를 지불해서 수능 문제를 구매할 수 있습니다.
public void payment(Long userId, List<Long> questionIds, Long userCouponId) {
List<Question> questions = questionReader.getQuestions(questionIds);
paymentProcessor.questionPayment(QuestionPayment.create(userId, userCouponId, questions));
userQuestionAppender.appendUserQuestions(userId, questionIds);
}
프로젝트 초반에는 문제를 결제하게 되면 나의 문제집에 문제가 추가되는 후처리 과정만 있었기에
위 코드처럼 문제의 결제를 처리하는 payment 함수에서 결제 완료 후 직접 처리하는 방식이었습니다.
하지만 점점 기능을 고도화하게 되면서 후처리 과정이 아래와 같이 추가되었습니다.
1. 나의 문제집에 구매 한 문제를 추가
2. 구매한 문제 장바구니에서 제거
3. 문제 통계 데이터(판매량) 업데이트
4. 크리에이터 통계 데이터(판매량) 업데이트
public void payment(Long userId, List<Long> questionIds, Long userCouponId) {
QuestionOrder order = questionOrderGenerator.generateQuestionOrder(userId, questionIds);
QuestionPaymentCoupon questionPaymentCoupon = questionPaymentCouponProcessor.getCoupon(userCouponId, userId);
QuestionPayment paymentResult = questionPaymentProcessor.processQuestionPayment(QuestionPayment.create(userId, questionPaymentCoupon, order));
eventPublisher.publishEvent(new QuestionPaymentEvent(paymentResult));
}
// ----------------- //
@EventListener
@Async
public void updateCreatorStatistics(QuestionPaymentEvent event) {
// 크리에이터 판매 통계 업데이트
}
// ----------------- //
@EventListener
@Async
public void updateSalesCount(QuestionPaymentEvent event) {
// 문제 판매 통계 업데이트
}
// ----------------- //
@EventListener
@Async
public void appendUserQuestion(QuestionPaymentEvent event) {
// 나의 문제집 추가
}
// ----------------- //
@EventListner
@Async
public void clearCart(QuestionPaymentEvent event) {
// 구매한 문제 장바구니에서 제거
}
따라서 payment 함수가 결제만 처리할 수 있도록 결제 후 ApplicationEventPublisher를 사용하여 처리하는 방식으로 변경하였습니다.
기본적으로 ApplicationEventPublisher을 통해 발행 한 이벤트는 동기적으로 처리되기 때문에 4개의 후처리 과정이 다 끝나야
사용자에게 결제 완료 응답이 가게 됩니다.
저는 결제 과정만 끝나게 된다면 사용자 입장에서는 굳이 후처리 과정을 대기 할 필요가 없다고 생각하여
@EventListener에 @Async 어노테이션을 사용하여 비동기적으로 처리하고자 했습니다.
이벤트 발행 후 데이터의 정합성 문제
이벤트 발행으로 후처리 과정을 진행함으로써 payment 함수는 오로지 결제에 집중할 수 있게 되었습니다.
하지만 이벤트 방식은 결합성을 줄여주는 대신 다양한 예외 상황을 고려해야합니다.
이벤트를 발행해서 4개의 후처리 과정이 처리되는 도중 DB 문제나 네트워크 문제가 발생해서 정상적으로 처리되지 않을 수 있습니다.
예를 들어 결제(포인트 차감)는 되었지만 나의 문제집에는 문제가 추가되지 않을 수 있습니다.
이러한 데이터 정합성 문제를 해결하기 위해서 4개의 이벤트를 하나의 트랜젝션으로 처리하는 방식을 고려할 수 있습니다.
@Transactional
public void payment(Long userId, List<Long> questionIds, Long userCouponId) {
QuestionOrder order = questionOrderGenerator.generateQuestionOrder(userId, questionIds);
QuestionPaymentCoupon questionPaymentCoupon = questionPaymentCouponProcessor.getCoupon(userCouponId, userId);
QuestionPayment paymentResult = questionPaymentProcessor.processQuestionPayment(QuestionPayment.create(userId, questionPaymentCoupon, order));
eventPublisher.publishEvent(new QuestionPaymentEvent(paymentResult));
}
// ----------------- //
@TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)
public void updateCreatorStatistics(QuestionPaymentEvent event) {
// 크리에이터 판매 통계 업데이트
}
// ----------------- //
@TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)
public void updateSalesCount(QuestionPaymentEvent event) {
// 문제 판매 통계 업데이트
}
// ----------------- //
@TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)
public void appendUserQuestion(QuestionPaymentEvent event) {
// 나의 문제집 추가
}
// ----------------- //
@TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)
public void clearCart(QuestionPaymentEvent event) {
// 구매한 문제 장바구니에서 제거
}
위 코드처럼 비동기 방식인 @Async를 사용하지 않고 @TransactionalEventListener를 통해 4개의 후처리 로직들을
payment 함수의 트랜젝션으로 묶어서 처리할 수 있습니다.
이렇게 되면 4개의 후 처리 과정 중 하나라도 예외가 발생한다면 포인트 차감과 같은 결제 과정은 rollback이 될 것 입니다.
나의 문제집에 문제를 추가하는 후처리의 경우 보장되어야 하는 게 맞습니다.
그러나 문제집에 추가가 완료되었으나 통계 업데이트 혹은 장바구니 정리에 실패하였다고 진행된 결제를 rollback하는 것은
서비스 운영 입장에서 큰 손해일 수 있습니다.
통계 데이터는 배치를 통해서 보정해줄 수 있고 장바구니의 경우에도 여러가지 방법으로 충분히 해결 할수 있기 때문입니다.
따라서 결제 로직과 4개의 후처리 과정을 하나의 트랜젝션으로 묶는 것은 최적의 방법이 아닐 수 있습니다.
저는 해당 문제를 해결하기 위해 발행 된 이벤트가 유실되지 않도록 하였습니다.
이벤트에서 유실이라는 표현은 여러가지 의미가 있지만 지금 맥락에서는 성공적인 소비를 보장한다는 의미입니다.
@EventListener
@Async
public void appendUserQuestion(QuestionPaymentEvent event) {
// 나의 문제집 추가
}
만약 appendUserQuestion이 처리 되는 도중 예외가 발생한다면 Exception을 던지고 나서 끝이 날 것입니다.
그렇게 되면 유저 입장에서는 포인트는 차감되었지만 나의 문제집에 구매한 문제는 보이지 않고
운영자 입장에서도 별다른 로그가 없기 때문에 추적이 어려울 수 있습니다.
저는 이처럼 이벤트는 소비되었지만 정상적으로 처리되지 못한 것을 이벤트가 유실되었다고 표현하였습니다.
@EventListener
@Async
@Retryable
public void appendUserQuestion(QuestionPaymentEvent event) {
// 나의 문제집 추가
}
간단한 방법으로는 @Retryable 어노테이션을 이용해 예외가 발생한 경우 재시도 처리를 수행하도록 할 수 있습니다.
하지만 DB 장애가 30분 동안 지속된다면 30분동안 재시도 될 수 있으며 그 만큼 자원을 소유하게 됩니다.
그래서 일반적으로 발행된 메시지 / 이벤트를 보존하기 위해 메시징 브로커 / 이벤트 브로커를 사용할 수 있습니다.
메시지 브로커의 경우 RabbitMQ, AWS SQS가 있고 이벤트 브로커의 경우 Kafka가 있습니다.
현재 프로젝트는 비록 이벤트 방식이지만 이벤트 브로커인 Kafka를 도입하기엔 오버스펙이라고 판단하여
AWS SNS + SQS 조합을 통해 이벤트를 처리하고 있습니다.

Spring 애플리케이션에서 Topic(QuestionPayment)을 SNS에 발행하면 해당 토픽을 구독하고 있는 SQS(메시징 큐)에 전달이 되고 SQS를 구독하고 있는 Spring 애플리케이션이 소비하여 처리하는 방식입니다.
1. 문제 결제가 완료되면 QuestionPaymentEvent를 SNS로 발행합니다.
2. SNS는 자신을 구독하고 있는 통계 업데이트 큐, 나의 문제집 추가 큐, 장바구니 정리 큐에 Event를 전달합니다.
3. SQS에 이벤트가 전달되면 SQS를 구독하고 있던 Spring 애플리케이션에서 소비하여 처리합니다.
SQS와 같은 메시징 큐는 기본적으로 소비자가 메시지를 정상적으로 소비했다고 응답을 주었을 때 큐에서 해당 메시지를 제거합니다.
@SqsListener("append-user-question.fifo")
fun appendUserQuestion(@Payload event: QuestionPaymentEvent) {
userQuestionRepository.saveAll(create(event.questionPayment.userId, event.questionPayment.order.questionIds))
}
따라서 위 appendUserQuestion에서 예외가 발생한다면 정상적으로 소비했다는 응답을 주지 않기 때문에
SQS는 큐에서 메시지를 제거하지 않고 다른 구독자에게 이벤트를 다시 전달합니다.
이렇게 메시지 브로커가 제공하는 기능을 이용해서 메시지(이벤트)가 정상적으로 소비 됨을 보장 받을 수 있습니다.
만약 DB 장애가 오랫동안 지속되어 메시지 소비에 계속 실패한다면 Dead-Letter-Queue라는 기능을 이용해서 메시지를 별도로
보관할 수 있습니다. 이렇게 되면 무한정 메시지를 재소비하는 것을 방지할 수 있으며 추후 장애가 복구되고 나서 일괄적으로 메시지를
다시 처리할 수 있습니다.
이벤트 발행 실패로 인한 데이터 정합성 문제
메시지 브로커로 이벤트를 발행함으로써 이벤트가 유실되지 않도록하여 데이터 정합성 문제를 방지하였습니다.
하지만 또 다른 문제로인해 이벤트 방식에서 데이터 정합성 문제가 발생할 수 있습니다.
fun payment(userId: Long, order: QuestionOrder, questionPaymentCoupon: QuestionPaymentCoupon?): QuestionPayment {
val questionPayment = create(userId, questionPaymentCoupon, order)
questionPaymentProcessor.payment(questionPayment)
snsClient.publish(QuestionPaymentEvent(questionPayment).toRequest())
return questionPayment
}
위 코드는 결제가 완료되면 snsClient를 이용해서 QuestionPaymentEvent를 AWS SNS로 발행하게 됩니다.
SNS 발행은 내부적으로 AWS가 제공하는 API와 HTTP 통신을 통해 처리됩니다.
따라서 네트워크 문제 혹은 SNS 서비스 장애로 인해 이벤트 발행에 실패될 수 있습니다.
그렇게 되면 결제만 처리되고 이벤트 발행이 되지 않아 후처리 과정은 진행되지 않게 되고 데이터 정합성의 문제가 발생합니다.
따라서 결제처럼 어떠한 행위로 인해 이벤트가 발행된다면 이벤트 발행은 꼭 보장되어야 합니다.
저는 이벤트 발행을 보장하기 위한 기법 중 대표적인 Transactional OuntBox Pattern을 도입하였습니다.
Microservices Pattern: Pattern: Transactional outbox
First, write the message/event to a database OUTBOX table as part of the transaction that updates business objects, and then publish it to a message broker.
microservices.io
Transactional OuntBox Pattern은 이벤트 발행을 보장하기 위해 별도로 이벤트 발행의 티켓을 저장합니다.

위 그림을 보면 OrderService에서 Order와 관련된 작업 이후 이벤트가 발행되는 것으로 보이는데요.
그런데 Order 작업과 OutBox Table이라는 테이블에 Ticket을 Insert 하는 작업이 하나의 트랜젝션으로 묶여있는 것을 볼 수 있습니다.
그리고 별도의 배치 서버 혹은 스케줄러 (Relay)에서는 주기적으로 OutBox Table을 확인하면서 메시지를 발행하는 것을 볼 수 있습니다.
이렇게 Order작업과 이벤트 발행을 위한 Ticket을 Insert하는 작업을 하나의 트랜젝션으로 묶음으로써 데이터의 정합성을 보장합니다.
만약 Ticket을 Insert하는 작업이 실패한다면 Order 작업은 rollback됩니다.
또한 Order 작업과 Ticket 발행은 성공했지만 이벤트 발행에 실패하더라도 Message Relay 단계에서 지속적으로
OutBox Table에 저장된 Ticket을 통해 이벤트를 발행하려고 시도하기 때문에 언젠가는 이벤트가 발행됨을 보장받을 수 있습니다.
하지만 이벤트 발행은 Message Relay를 통해 비동기적으로 처리되기 때문에 별도의 배치 서버 혹은 스케줄러가 필요하게 됩니다.
주기적인 간격으로 OutBox Table을 Polling하며 이벤트를 발행하는 방법과 CDC라는 DB log 기반 데이터 변경 감지를 통해
이벤트를 발행하는 방법이 있습니다. 처음에는 사이드 프로젝트 수준이기 때문에 Polling 방식을 이용하려 했는데
기술 블로그에서 Spring boot가 제공하는 ApplicationEventPublisher를 통해 처리하는 방식을 보았고 적용하기로 했습니다.
(물론 이벤트 발행에 실패하는 경우 재발행을 위해 결국은 별도의 서버를 통해 이벤트 발행을 보장해야합니다.)
@Transactional
fun payment(userId: Long, order: QuestionOrder, questionPaymentCoupon: QuestionPaymentCoupon?): QuestionPayment {
val questionPayment = QuestionPayment.create(userId, questionPaymentCoupon, order)
questionPaymentProcessor.payment(questionPayment)
questionPaymentEventProcessor.createEvent(questionPayment)
return questionPayment
}
위 payment 함수는 결제를 처리하고 questionPaymentEventProcessor를 통해 이벤트를 발행합니다.
fun createEvent(questionPayment: QuestionPayment) {
val questionPaymentEvent = QuestionPaymentEvent(questionPayment)
questionPaymentEventLogRepository.save(
QuestionPaymentEventLog(
questionPayment.order.orderId,
false,
questionPaymentEvent.toJson(),
LocalDateTime.now(),
)
)
applicationEventPublisher.publishEvent(questionPaymentEvent)
}
createEvent는 questionPaymentEventLog라는 Table에 이벤트 발행을 위한 티켓을 저장합니다.
이후 applicationEventPublisher를 통해 Spring 내부 이벤트를 발행합니다.
payment 함수는 @Transactional를 사용했기 때문에 createEvent까지는 트랜젝션이 유지됩니다.
따라서 결제와 questionPaymentEventLog에 티켓이 저장되는 로직은 하나의 트랜젝션으로 묶이기 때문에
결제와 티켓 저장의 데이터 정합성은 보장됩니다.
이후 SNS로의 이벤트 발행을 처리하는 과정이 진행됩니다.
@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
fun publishEvent(questionPaymentEvent: QuestionPaymentEvent) {
snsClient.publish(questionPaymentEvent.toRequest())
questionPaymentEventLogRepository.publish(questionPaymentEvent.questionPayment.order.orderId)
}
createEvent 메서드에서 발행되는 내부 이벤트를 처리하는 메서드입니다.
내부 이벤트를 수신하면 snsClient를 통해 SNS로 이벤트를 발행하고 티켓의 발행 여부를 업데이트 하게 됩니다.
publishEvent 메서드는 TransactionalEventListener의 phase 속성을 통해 이벤트 발행자(payment)의 트랜젝션이
Commit되고나서 처리되도록 하였습니다.
만약 기존 트랜젝션(payment)에 포함 시킨다면 이벤트 발행에 실패하게 되는 경우 결제 과정 및 티켓 발행 로직도 rollback이 되어
높은 데이터의 정합성을 보장하는 것 같지만 문제가 발생할 수 있습니다.
먼저 snsClient.publish(이벤트 발행)에 성공했지만 네트워크 / DB 이슈로 예외가 발생하는 경우입니다.
이 경우 결제 과정과 티켓 발행은 rollback이 됩니다. 반면에 이벤트는 이미 발행되었기 때문에 후처리 과정이 진행되게 됩니다.
이렇게 되면 사용자는 포인트를 지불하지 않고 문제를 구매한 것과 같게 됩니다.
다음으로 만약 이벤트 발행 후 네트워크 문제로 인해 트랜젝션의 commit이 지연된다면 DB에 데이터가 반영되지 않은 채
이벤트를 처리할 수 있기 때문에 데이터 정합성에 문제가 발생할 수 있습니다.
따라서 비즈니스 로직 및 티켓 발행을 처리하는 트랜젝션과 분리하게 되었습니다.
또한 TransactionalEventListener phase 속성을 AFTER_COMMIT으로 하게 되면 기존 트랜젝션을 사용할 수 없기 때문에
DB의 변경 작업을 시도하는 경우 오류가 발생하게 됩니다.
이벤트 발행의 경우 하나의 쓰레드에서 동기적으로 처리 될 필요는 없기 때문에 @Async 어노테이션을 사용해서 별도의 쓰레드에서
처리하도록 하였습니다. 이렇게 되면 별도의 트랜젝션을 생성해서 처리하기 때문에 오류가 발생하지 않습니다.
만약 이벤트 발행에 실패하였다면 별도의 스케줄러 / 배치 서버를 통해 주기적으로 티켓 테이블을 확인하며 발행되지 않은 이벤트를
재발행 한다면 이벤트 발행을 보장할 수 있습니다.
마무리
문제 결제 후처리 과정을 이벤트 방식으로 처리하게 되면서 여러가지 발생할 수 있는 문제들에 대해 예외 처리를 해주었습니다.
아직 발행에 실패한 이벤트를 재발행 하는 별도의 스케줄러 / 배치 서버는 만들지 않았기 때문에 우선적으로 만들어야겠습니다.
또한 제가 적용한 Transactional OuntBox Pattern는 적어도 1회 이벤트 발행을 보장하는 방식입니다.
따라서 동일한 이벤트가 2번 이상 발행될 수 있는 문제가 발생할 수 있습니다.
동일 이벤트가 여러번 발행되는 문제로 발생할 수 있는 케이스를 분석하고 어떻게 처리 해야 할 지 고민해야겠습니다.
참고
회원시스템 이벤트기반 아키텍처 구축하기 | 우아한형제들 기술블로그
최초의 배달의민족은 하나의 프로젝트로 만들어졌습니다. 배달의민족의 주문수는 J 커브를 그리는 빠른 속도로 성장했고, 주문수가 커지면서 자연스럽게 트래픽 또한 매우 커졌습니다. 하나의
techblog.woowahan.com
트랜잭셔널 아웃박스 패턴의 실제 구현 사례 (29CM)
이 글에서는 실무 관점에서의 Apache Kafka 활용 에서 잠깐 소개했던 트랜잭셔널 아웃박스 패턴 (Transactional Outbox Pattern) 을 실제로 구현하여 활용하고 있는 29CM 의 사례를 소개하고자 한다.
medium.com
분산 시스템에서 메시지 안전하게 다루기
Transactional Outbox Pattern을 이용한 결과적 일관성 확보 by 강남언니 블로그
blog.gangnamunni.com
'spring' 카테고리의 다른 글
Spring / Spring Security filter chain 들여다보기 (0) | 2025.03.29 |
---|---|
이벤트가 한번 만 처리 되도록 보장하기 (feat. 멱등성) (0) | 2025.03.19 |
결제 로직에서 발생하는 예외 처리하기 2 (0) | 2025.03.10 |
Spring RabbitListener에서 예외가 발생하면? feat. 무한 루프 (0) | 2025.02.07 |
예외 처리의 예외 처리 (feat. RabbitMQ 지연 큐) (0) | 2025.01.18 |