Jinnie devlog

Spring 트랜잭션 이벤트 정리와 적용 본문

교육

Spring 트랜잭션 이벤트 정리와 적용

Jinnnie 2025. 8. 29. 10:47

커머스 시스템을 구현하면서 재고 차감, 포인트 차감, 쿠폰 사용, 결제 처리 등 모든 흐름을 하나의 트랜잭션 안에서 처리하였다. 하지만 이 방식은 트랜잭션이 커지고, 실패 포인트가 많아지며, 시스템의 결합도도 높아지는 단점이 있다.

이러한 단점들을 해결하고자 애플리케이션 이벤트를 활용해 유스케이스의 후속 흐름을 분리하고, 비동기 트랜잭션 흐름을 설계하는 방법을 알아보았다.

 

왜 트랜잭션을 나누는가?

현재 구조의 문제점

실패 전파 PG API가 느려지거나 실패하면 주문 전체가 롤백됩니다
높은 결합도 User, Product, Coupon, Payment 도메인이 모두 한 흐름에 엮입니다
재시도 불가 롤백은 가능하지만, 어디까지 성공했는지 불확실하여 복구가 어렵습니다
성능 저하 트랜잭션이 길어질수록 DB 락이 길게 유지되어 TPS가 하락합니다

 

개선방법

주문–결제 플로우에는 “지금 당장 커밋돼야 하는 핵심”과 “조금 나중에 처리해도 되는 후속 작업”이 섞여 있다. 이를 이벤트 기반으로 분리하면:

  • 핵심 트랜잭션은 작고 견고하게,
  • 후속 트랜잭션은 비동기/독립적으로 운영할 수 있어 장애 전파를 줄인다.

이번 설계의 기본 원칙은:

  • 핵심(필수) = 주문 생성·검증·금액 계산 → 반드시 커밋 보장
  • 후속(부가) = PG 호출·기록·전송 → 커밋 이후(AFTER_COMMIT) 분리

 

스프링 트랜잭션 이벤트 종류

BEFORE_COMMIT 커밋 직전 같은 트랜잭션 내부 커밋 롤백됨 최종 확정/검증/동일 트랜잭션 원자성 보장. 외부 I/O 지양. @Async 사용 부적합.
AFTER_COMMIT 커밋 직후 원 트랜잭션 커밋은 이미 끝남 후속 처리, 비동기 I/O, 알림/전송/인덱싱. 실패해도 본 트랜잭션 영향 없음.
AFTER_ROLLBACK 롤백 직후 원 트랜잭션 - 실패/롤백 알림, 보상 로직 트리거(선택).
AFTER_COMPLETION 커밋/롤백 둘 다 끝난 뒤 원 트랜잭션 - 트랜잭션 결과 상관없이 공통 정리/메트릭.

 

  • 원자성이 필요한 작업(함께 성공/실패해야 하는 도메인 변경)은 BEFORE_COMMIT.
  • 본 트랜잭션과 분리해야 하는 외부 I/OAFTER_COMMIT + @Async.

 

실제 코드에 적용해보기

(A) 주문 생성 흐름 — 핵심 vs 후속 분리

  • OrderFacade.createOrder
    • “주문 저장, 유효성 검증, 금액 계산”까지 핵심 트랜잭션으로 커밋 보장
    • 그 결과를 담은 OrderCreatedEvent 발행(트랜잭션 내부)
  • OrderCreatedEventHandler — AFTER_COMMIT, @Async
    • PG 결제 요청(외부 I/O) 만 실행
    • 주문 커밋과 분리되어, PG 장애가 있어도 주문 데이터는 안전하게 저장
    • 과제의 “후속 트랜잭션은 커밋 이후 실행” 원칙 충족

(B) 결제 확정(콜백) — 원자적 확정+은 BEFORE_COMMIT

  • PG 콜백 → PaymentFacade.processPgCallback → PaymentApproved/DeclinedEvent 발행
  • OrderPaymentEventHandler — BEFORE_COMMIT (동기)
    • 재고 차감·포인트 사용·쿠폰 사용 + 주문 상태 최종 확정을 한 트랜잭션에서 처리
    • 이유: “승인된 결제 ↔ 주문 PAID”는 분리 불가능한 불변식이기 때문
    • 이 단계에서 에러가 나면 전체 롤백되어 중간불일치를 막고, 다음 주기(리컨실) 재처리로 넘길 수 있다.
  • DataPlatformEventHandler — AFTER_COMMIT, @Async
    • 데이터 플랫폼 전송(승인/거절 결과)
    • 본 확정 트랜잭션과 느슨하게 결합하여 실패해도 주문/결제 확정에는 영향이 없다.
    • 이후 Outbox/재시도 등으로 보완 필요

정리: 주문 생성의 후속(PG 요청)은 AFTER_COMMIT, 결제 확정은 그 콜백 트랜잭션의 핵심이므로 BEFORE_COMMIT

 

(C) 상태 동기화 배치

  • PaymentScheduler(호출자) → PaymentService.sync/reconcilePaymentStatuses(cutoff, batchSize)
  • 조회 조건에 updatedAt < cutoff(예: now - 10s)를 사용해 너무 최신(진행 중) 데이터는 다음에 다시 시도
  • PG 상태에 따라 승인/거절이 결정되면 동일한 이벤트 체인을 태워 확정
  • 외부 호출에는 Timeout/Retry/CircuitBreaker/Fallback 적용

 

 

더 생각해 볼 것

결제 이벤트 테스트를 진행하며, AFTER_COMMIT + @Async 조합 때문에 테스트가 간헐적으로 실패하는 문제를 확인했다. @TransactionalEventListener(AFTER_COMMIT)는 커밋 이후에, @Async는 분리된 스레드에서 동작하므로 테스트가 즉시 단언하면 핸들러나 스케줄러가 아직 실행되지 않은 상태일 수 있다. 이로 인해 이벤트가 발행되지 않은 것처럼 보이거나, 스케줄 시점과 어긋나 실패가 발생했다.

 

이를 완화하기 위해 우선은 테스트에서 Awaitility로 짧은 대기와 재시도를 두어, 예상 상태가 관찰 가능한 시점까지 기다린 뒤 검증하도록 했다. 이런식으로 억지로 설정하여 테스트 하는 것 보다는 구조적인 해결 방법이 있을 것 같다.

await().atMost(Duration.ofSeconds(2))
       .pollInterval(Duration.ofMillis(50))
       .untilAsserted(() -> {
           // 기대 상태/이벤트 검증
       });

 

정리하자면, 커밋 이후 비동기 처리의 타이밍의 차이가 발생한건데,  테스트를 할 때 시스템의 시간과 스레드에 유의하여 진행해야 할 것 같다. 이벤트, 스케줄러, 동기/비동기 경계를 어떻게 설계하고 어떻게 테스트를 해야하는 지 더 고민해봐야겠다.