| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | ||||||
| 2 | 3 | 4 | 5 | 6 | 7 | 8 |
| 9 | 10 | 11 | 12 | 13 | 14 | 15 |
| 16 | 17 | 18 | 19 | 20 | 21 | 22 |
| 23 | 24 | 25 | 26 | 27 | 28 | 29 |
| 30 |
- Transactional
- 코딩테스트
- EntityGraph
- Java
- spring
- 동시성처리
- @Transactional
- collapse
- 알고리즘
- JPA
- AOP
- TDD
- EffectiveJava
- lombok
- 이펙티브자바
- 캐시
- thymeleaf
- effective java
- 자바
- 파이썬
- Garbage Collection
- 멱등성
- interceptor
- 클린아키텍처
- JVM
- Spring Security
- BFS
- 배낭문제
- cache
- 타임리프
- Today
- Total
Jinnie devlog
Spring 트랜잭션 이벤트 정리와 적용 본문
커머스 시스템을 구현하면서 재고 차감, 포인트 차감, 쿠폰 사용, 결제 처리 등 모든 흐름을 하나의 트랜잭션 안에서 처리하였다. 하지만 이 방식은 트랜잭션이 커지고, 실패 포인트가 많아지며, 시스템의 결합도도 높아지는 단점이 있다.
이러한 단점들을 해결하고자 애플리케이션 이벤트를 활용해 유스케이스의 후속 흐름을 분리하고, 비동기 트랜잭션 흐름을 설계하는 방법을 알아보았다.
왜 트랜잭션을 나누는가?
현재 구조의 문제점
| 실패 전파 | 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/O는 AFTER_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(() -> {
// 기대 상태/이벤트 검증
});
정리하자면, 커밋 이후 비동기 처리의 타이밍의 차이가 발생한건데, 테스트를 할 때 시스템의 시간과 스레드에 유의하여 진행해야 할 것 같다. 이벤트, 스케줄러, 동기/비동기 경계를 어떻게 설계하고 어떻게 테스트를 해야하는 지 더 고민해봐야겠다.
'교육' 카테고리의 다른 글
| Redis ZSET로 만드는 “오늘의 인기상품” (0) | 2025.09.12 |
|---|---|
| Kafka로 이벤트 파이프라인 만들기 (0) | 2025.09.05 |
| 외부 시스템 연동 지연, 장애, 실패 대응 (Failure-Ready Systems) (0) | 2025.08.22 |
| 10만건 조회 성능 개선 시도 (인덱스, 캐싱) (4) | 2025.08.15 |
| @Transactional 남용 줄이기 도전기 - 두 번의 삽질 (4) | 2025.08.08 |