Introduce
풋살 매칭 서비스에서 매치 신청 시 결제 완료 후 구장주에게 SMS를 보내고, 매치 신청자에게 FCM 알림을 전송하는 등 부가 로직을 실행해야 한다. 초기에는 이 모든 처리를 결제 요청과 동기적으로 처리하였다. 하지만 이는 부가 로직에서 오류가 발생할 경우 트랜잭션이 rollback 되어 결제 데이터가 유실될 수 있는 가능성이 있었으며 알림 발송에 의해 스레드가 블로킹되어 응답 시간이 지연되기도 하였다.
따라서 Spring Event를 사용하여 `ApplicationEventPublisher + @Async + EventListener` 구조로 변경하였다.
스프링 이벤트를 발행하여 트랜잭션과 관심사 분리하기
Introduce 내가 개발중인 서비스에서 특정 상품을 결제하면 FCM을 통해 결제한 유저에게 알림을 전송하는 비즈니스 로직이 있다.결제 내역을 바탕으로 사용자에게 알림을 전송해야 함으로 결제 처
woojjam.tistory.com
관련된 내용은 해당 게시글에 정리되어 있다.
하지만 변경한 구조도 크게 두 가지 문제가 발생할 수 있다.
- 결제 트랜잭션이 커밋된 직후, publishEvent() 가 호출되기 이전에 서버가 다운된다면?
- @Async + @EventListener 내부에서 예외가 발생하면?
1)의 경우 결제는 성공하여 결제 데이터는 commit 되었지만, 이벤트는 아직 발행하지 않은 상태이다. 이때 서버가 배포에 의해 재시작되거나 오류로 인해 다운된다면 해당 이벤트는 영원히 사라진다.
2)의 경우 Async 스레드는 별도의 스레드에서 작업을 처리하기 때문에 오류가 발생하더라도 메인 스레드에서 이를 핸들링 할 수가 없다. 따라서 메인 스레드에서는 발행한 이벤트가 성공했는지의 여부를 알지 못한채 종료되게 된다.
물론 서버가 다운 되는 것 자체가 심각한 문제이긴 하지만 이는 복구가 가능한 문제이다. 하지만 메모리내에 저장된 이벤트가 손실되는 것은 복구가 불가능한 문제이다. 더군다나 결제와 같이 민감한 도메인이라면 이벤트 유실은 매우 치명적인 문제가 될 가능성이 높다.
Idea
처음에는 두 가지 대안을 고민하였다.
- 실패 정보 저장 + 스케줄링 재시도
- Completable Future로 비동기 처리 방식 변경
대안 1)의 경우 이벤트 리스너에서 예외가 발생하면 실패 정보를 별도 테이블에 저장하고, 스케줄러가 해당 레코드를 주기적으로 폴링하여 재시도하는 방식이다. 이는 Consumer에서 발생하는 예외로 인한 이벤트 유실 문제는 해결할 수 있다. 하지만 문제 1은 여전히 해결할 수 없다.
대안 2)의 경우 이벤트 발행이 아닌 비동기 관점에서 접근한 방식이다. CompletableFuture의 exceptionally와 handle을 이용한다면 호출 스레드에서 리스너 내에서 발생한 오류를 핸들링 하는 것이 가능하다. 하지만 이는 비동기 구현 방식을 바꾸는 것이지 Spring Event를 활용한 근본적인 해결책이 아니다. 결제 처리와 부가 로직의 관심사를 분리하여 결합도를 낮추기 위해 event 를 도입하고자 한 것인데 CompletableFuture는 오히려 결합도를 더 높이는 모양세가 된다. 그리고 무엇보다도 해당 방식 또한 문제 1은 여전히 해결할 수가 없다.
처음에 고민한 두 가지 대안 모두 근본적으로 서버가 다운된 경우를 해결할 수 없다. 즉, 다른 대안 방법을 찾아야 한다.
Transactional Messaging (Outbox Pattern)
Transactional Messaging 이란 DB 트랜잭션 커밋과 이벤트 또는 메시지 발행을 하나의 원자적 단위로 처리하는 방식을 말한다. 트랜잭션 커밋과 이벤트 발행은 본질적으로 서로 다른 시스템의 작업이다. RDB에서의 트랜잭션은 ACID 원칙을 보장하지만, 해당 트랜잭션이 커밋과 이후 메시지를 발행하는 행위는 원자적으로 보장되지 않는다. 따라서 두개의 작업을 하나의 원자적 단위로 처리하도록 하는 것이 Transactional Messaging이다.
앞서 언급한 문제점은 트랜잭션 커밋이 이벤트를 발행했다는 것을 보장하지는 않기 때문에 발생한 문제이다. 따라서 트랜잭션 커밋 시점에 발행할 이벤트 정보를 같이 저장한다면 트랜잭션 커밋과 이벤트 발행이 원자적으로 처리될 것이기 때문에 서버가 down되더라도 이벤트 정보는 유실되지 않고 저장된다.
📌 Outbox Pattern

Outbox 패턴은 외부로 보낼 이벤트를 즉시 발행하지 않고, 트랜잭션 내에서 outbox 라는 테이블에 이벤트 정보를 insert한 뒤 별도의 프로세스에서 주기적으로 Pending 상태의 outbox 레코드를 조회하여 이벤트를 수행하는 방식이다.
결국 트랜잭션이 commit되면 결제 데이터와 함께 발행할 이벤트 정보도 같이 commit될 것이고, rollback되면 결제 데이터와 함께 발행할 이벤트 정보도 저장되지 않기에 원자성을 보장할 수 있다.

이처럼 메인 트랜잭션에서 결제 데이터와 Outbox 레코드를 commit 한 뒤에 스케줄러에서 일정 주기로 pending 상태의 outbox 레코드를 polling한 뒤 이벤트를 발행하거나 발행 실패시 상태를 FAILED 로 업데이트하여 재시도까지 진행할 수 있다.
📌 CDC (Change Data Capture)
Outbox를 구현하기 위해서 polling 방식과 더불어 로그테일링과 같은 CDC 방식이 있다.
CDC(Change Data Capture)는 DB의 변경 로그(binlog, WAL 등)를 읽어 데이터 변경 사항을 감지하고 외부 시스템으로 전달하는 방식이다. 애플리케이션이 직접 테이블을 조회하지 않아도 INSERT, UPDATE, DELETE 같은 변경을 추적할 수 있어 이벤트 전파나 데이터 동기화에 자주 사용된다. CDC를 아웃박스 패턴과 함께 사용할 때는 outbox 테이블에 저장된 이벤트를 CDC가 읽어 메시지 브로커로 전달하는 방식으로 구현할 수 있다.
하지만 나는 해당 방식이 복잡도가 매우 높다고 판단하였기에 선택하지 않았다. CDC는 Debezium 같은 추가 미들웨어를 함께 운영해야 하고, 그에 따른 러닝커브뿐 아니라 커넥션 관리, DB 로그 설정 등 운영 복잡도도 높다고 판단하였다. 반면 당시 서비스는 실시간 이벤트 전파가 반드시 필요한 구조는 아니었기 때문에 더 단순하고 통제하기 쉬운 polling 기반 outbox 방식으로도 요구사항을 충분히 만족할 수 있었다.
Implement
Outbox를 스케줄러를 통해서만 polling할 경우 polling 주기를 짧게 해야 알림이 빠르게 도달한다. 하지만 주기가 짧아질 수록 아무런 이벤트가 없어도 데이터베이스를 조회하기 때문에 불 필요한 부하가 누적될 수 있다.
📌 시나리오
이런 문제를 해결하기 위해 두 가지의 경로로 분리하였다.

- `@Async` 스레드 + `@TransactionalEventListener(AFTER_COMMIT)`
- Fallback을 위한 스케줄러 polling
먼저 매치 신청 완료 및 결제 완료 처리, outbox 테이블에 매치 신청 완료 이벤트 정보를 Pending 상태로 저장하여 발행할 이벤트를 기록한다. 그런 다음 Spring의 `ApplicationEventPublisher.publishEvent( )` 를 활용하여 매치 신청 완료 이벤트를 발행한다. 도메인 로직과 이벤트 발행이 같은 트랜잭션에 묶여 있기에 로직이 모두 성공하거나 모두 실패하게 된다.
그리고 `@TransactionalEventListener(AFTER_COMMIT)` 을 통해 발행한 이벤트를 수신하는데, 이때 AFTER_COMMIT을 명시하기 때문에 트랜잭션이 commit 된 이후에 리스너가 실행되는것이 보장되게 된다. 따라서 outbox 테이블이 저장된 이후에 리스너가 동작하도록 하여 매번 스케줄러 polling으로 발생하는 부하를 줄일 수 있다.
그리고 서버가 다운되거나 이벤트 발행에 실패할 경우 여전히 이벤트가 유실될 수 있다. 따라서 Fallback을 위해서 마지막으로 스케줄러에서 일정 주기로 polling 하도록 하였다.
📌 적용
- Publisher에서 Outbox 저장
@Transactional
public PaymentResponse processPayment(PaymentRequest request) {
// 결제 생성 및 PG 승인
Payment payment = Payment.from(request);
payment.approve();
paymentRepository.save(payment);
// Outbox INSERT — Payment 저장과 동일한 트랜잭션
PaymentOutbox outbox = PaymentOutbox.create(
payment.getId(), OutboxEventType.PAYMENT_APPROVED.name(), payloadJson);
paymentOutboxRepository.save(outbox);
eventPublisher.publishEvent(new PaymentApprovedEvent(outbox.getId(), payload));
return PaymentResponse.from(payment);
}
`paymentRepository.save()`와 `paymentOutboxRepository.save()`가 동일한 트랜잭션 안에서 실행된다. 어느 하나라도 실패하면 둘 다 롤백된다. Spring이 트랜잭션 commit 이후 리스너를 실행하도록 예약하고 만약 DB가 롤백되면 이 리스너 자체는 실행되지 않는다.
이벤트에는 outboxId만 담는다. 리스너가 실제 처리에 필요한 데이터를 Outbox 테이블에서 직접 읽도록 하여 단일 진실 공급원으로 유지하였다.
- outbox 상태 관리
| 필드 | 역할 |
| eventType | 어떤 이벤트인지 식별 값 (PAYMENT_APPROVED) |
| payload | 처리에 필요한 데이터를 JSON으로 직렬화 |
| status | PENDING, SENT, FAILED 상태 전이 |
| processableAfter | 동시에 조회할 경우를 방지하기 위한 버퍼 시간 |
| retryCount / nextRetryAt | Fallback시 재시도 횟수와 다음 시도 시각 |
public static Outbox create(Long paymentId, String eventType, String payload) {
return Outbox.builder()
.status(OutboxStatus.PENDING)
.retryCount(0)
// processableAfter: 리스너 처리 시간보다 충분히 길게 설정
// 리스너가 처리 중인 Outbox를 Fallback 스케줄러가 동시에 집어가는 것을 방지
.processableAfter(LocalDateTime.now().plusMinutes(PROCESSABLE_AFTER_MINUTES))
.nextRetryAt(null)
.build();
}
public void markProcessed() {
this.status = OutboxStatus.PROCESSED;
}
processableAfter는 두 경로 간 레이스 컨디션을 방지하는 버퍼 시간이다. 스케줄러는 이 시각이 지난 outbox만 조회한다. 리스너가 정상 처리해 SENT로 update하면 스케줄러는 해당 이벤트를 건드리지 않는다.
- EventListener
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void onPaymentApproved(final PaymentApprovedEvent event) {
PaymentOutbox outbox = paymentOutboxRepository.findById(event.outboxId())
.orElseThrow(...);
try {
smsService.sendToStadiumOwner(...);
fcmService.sendToUser(...);
outbox.markProcessed(); // 성공 시 SENT → 스케줄러 중복 처리 차단
} catch (Exception e) {
// Outbox는 PENDING 유지 → 스케줄러 Fallback이 재처리한다.
log.error("Primary path 실패. Scheduler Fallback에 위임합니다. outboxId={}", outbox.getId(), e);
}
}
AFTER_COMMIT 시점에 실행되는 리스너는 원래 트랜잭션이 이미 완전히 종료된 상태다. `REQUIRES_NEW` 가 없다면 markProcessed()가 트랜잭션 바깥에서 실행되어 영속성이 보장되지 않기 때문에 `Propagation.REQUIRES_NEW`로 새 트랜잭션을 명시적으로 시작해야 한다.
그리고 이벤트 리스너에서 오류가 발생할 경우 로그만 남기고 에러를 삼켜 PENDING 상태를 유지시킨다. 따라서 스케줄러에서는 해당 이벤트를 재시도할 수 있다.
Conclusion
이번 구현은 단일 인스턴스를 전제로 하고있다. 서버가 여러 대로 늘어나면 지금 구조는 유효하지 않다.
fallback을 위해 스케줄러에서 polling 할 때 모든 인스턴스의 스케줄러가 동시에 실행될 수 있다. 그렇다면 동일한 outbox 레코드를 여러 인스턴스가 동시에 조회할 수 있기에 이벤트가 중복 실행될 수 있게된다. `processableAfter`를 통해서 버퍼 시간을 설정하여 리스너와 스케줄러가 동시에 실행될 가능성은 막았지만 인스턴스 간의 경합까지는 막지 못하고 있다. 따라서 다음에는 멀티 인스턴스에서 이를 어떻게 처리할 것인지를 다루어 볼 예정이다.
결국 Outbox 패턴을 도입함으로써 일부 이벤트가 유실될 수 있는 가능성을 구조적으로 방지할 수 있었다. 이벤트 유실은 발견하기는 어렵고 라이브 서버에서는 발견 자체가 늦는 조용하지만 치명적인 버그이다. Outbox 라는 새로운 패턴을 도입함으로써 복잡도가 늘어날 수는 있지만 그 비용이 이벤트 유실로 인한 문제점보다는 작다고 생각하기에 충분히 필요한 선택이다.
'Backend > Spring Boot' 카테고리의 다른 글
| 분산 환경에서 결제 이벤트 유실 방지하기 (Outbox, Failover Scheduler, ShedLock) (2) | 2026.04.22 |
|---|---|
| ShedLock으로 분산 환경에서 스케줄러 중복 실행을 방지하자 (0) | 2026.04.21 |
| 카카오톡 Bizmessage로 알림톡 전송하기 (1) | 2026.04.09 |
| 분산 환경에서 MySQL Named Lock으로 분산 락을 구축하고, 동시성 제어하기 (0) | 2026.03.24 |
| Virtual Thread로 병렬 호출하여 Thread Pool 고갈 문제 해결하기 (2) | 2026.03.13 |