들어가면서
정기 결제, 포인트 적립, 쿠폰 지급과 같이 금액이 직접적으로 연관된 시스템에서
가장 중요한 것은 처리 속도가 아니라 데이터 정합성과 안정성입니다.
정기 결제 배치 시스템을 운영하던 중
단 한 명의 결제 실패가 이미 성공한 여러 건의 결제까지 모두 롤백되는 문제를 경험했습니다.
더 큰 문제는
로그에는 실제 예외가 발생한 위치가 아닌 UnexpectedRollbackException만 남아 있어서
원인을 파악하는 데 생각보다 많은 시간이 소요되었다는 점이었습니다.
이 글은 당시 문제를 해결하는 과정에서 제가 무엇을 놓치고 있었고
트랜잭션을 어떻게 다시 바라보게 되었는지를 정리하기 위해 작성하게 되었습니다.
본 글에서는 다음 세 가지를 중심으로 이야기를 풀어보려 합니다.
- UnexpectedRollbackException이 발생한 실제 원인
- 트랜잭션 경계를 재설계하여 문제를 해결한 과정
- 이후 구조를 바라보며 정리하게 된 설계 기준
문제 상황
문제가 발생한 정기 결제 배치는 다음과 같은 구조로 동작하고 있었습니다.
- 배치 실행 시 결제 대상 사용자 전체 조회
- 사용자별 순차 결제 처리
- 배치 성공 여부를 DB에 INSERT
- 배치 전체를 하나의 트랜잭션으로 처리
특정 사용자 결제 처리 과정에서 NullPointerException이 발생했고
배치 종료 시 다음과 같은 로그가 발생했습니다.
UnexpectedRollbackException: Transaction silently rolled back
because it has been marked as rollback-only이미 성공 처리된 사용자들의 결제 내역까지 모두 롤백되는 치명적인 오류가 발생했습니다.
결제 시스템 특성상 개별 사용자의 실패가 다른 사용자에게 영향을 주면 안 되는데
이 구조에서는 단 한 건의 예외가 전체 배치 실패로 이어지는 심각한 문제를 내포하고 있었습니다.
원인 분석: try-catch로는 트랜잭션을 복구할 수 없다
문제의 코드 예시는 다음과 같은 구조였습니다.
@Transactional
public void runBatch() {
List<User> users = userRepository.findAll();
for (User user : users) {
try {
paymentService.process(user);
} catch (Exception e) {
log.error("payment failed. userId={}", user.getId(), e);
}
}
batchService.save(); //Batch 결과를 남기는 로그
}겉으로 보면 예외를 catch 하고 실패한 사용자만 로그를 남기고 넘어가는 구조처럼 보입니다.
하지만 실제로는 전혀 다른 흐름으로 동작하고 있었습니다.
- userRepository.findAll()로 결제 대상 사용자 조회
- 루프를 돌며 사용자별로 paymentService.process(user) 수행
- 특정 사용자 처리 중 RuntimeException 발생
- Spring 기본 전파 옵션(REQUIRED)에 의해 트랜잭션이 rollback-only 상태로 마킹
- 메서드 종료 시점에 UnexpectedRollbackException 발생 → 전체 롤백
예외를 catch 했다는 사실과 트랜잭션이 복구된다는 것은 전혀 다른 문제라는 점입니다.
try-catch는 단지 제어 흐름을 계속 이어가기 위한 장치일 뿐
이미 rollback-only로 마킹된 트랜잭션의 상태를 되돌릴 수는 없습니다.
추가적인 문제
이 구조에는 또 하나의 문제가 숨어 있었습니다.
예를 들어 다음과 같은 실행일 경우입니다.
- 결제 대상 사용자 3명 조회
- user1은 정상처리
- user2 처리 중 예외 발생
- 트랜잭션이 rollback-only 상태로 마킹
- user3이 로직 수행
문제는 이 시점입니다.
user3의 로직은 실행되어 DB 변경 쿼리도 실행하지만 이미 rollback-only 결과적으로 어떠한 변경도 커밋될 수 없습니다.
결과는 절대 반영될 수 없는 상태의 코드가 계속 수행되어 문제를 더 키우는 코드입니다.
원인 정리
이번 문제의 핵심은 예외를 제대로 처리하지 못했다가 아니라 트랜잭션 경계 설정 자체가 잘못되어 있었다는 점입니다.
여러 사용자에 대한 독립적인 결제 처리를 하나의 트랜잭션으로 묶었고 단 한 건의 예외가 전체 트랜잭션을 실패 상태로 만들었습니다.
이 구조에서는 아무리 try-catch를 잘 작성하더라도 결과적으로 전체 롤백을 피할 수 없는 상태였습니다.
해결 과정
문제의 원인을 정리해보면 명확했습니다.
독립적으로 수행해야하는 것을 하나의 트랜잭션으로 묶어서 한 건의 예외가 전체 트랜잭션 실패로 만들었습니다.
문제의 핵심은 예외 처리 방식이 아니라 트랜잭션 경계 설정이었습니다.
그래서 정기 결제 배치의 성격을 다시 정리했습니다.
- 사용자별 결제는 서로 독립적
- 한 명의 실패가 다른 사용자에게 영향을 주면 안 됨
- 배치의 역할은 “한 번에 처리”가 아니라“각각을 안전하게 처리”하는 것
사용자 단위로 실패를 격리해야한다는 기준을 세웠습니다.
재설계한 구조의 핵심은 조회와 처리를 완전히 분리하는 것이었습니다.
[No Transaction]
결제 대상 사용자 조회
↓
for (user)
→ [Transaction]
결제 처리
commit / rollback구조 설명
조회
- 결제 대상 사용자 조회는 트랜잭션 없이 수행
처리
- 사용자별 결제 처리마다 독립적인 트랜잭션 시작
- 실패 시 해당 사용자에 대한 작업만 롤백
격리
- try-catch는 제어 흐름을 위한 장치로만 사용
- 트랜잭션 정합성은 구조적으로 보장
이 구조가 해결한 문제들
- 단 한 건의 예외로 전체 배치가 롤백되던 문제 제거
- UnexpectedRollbackException 재발 방지
- 사용자별 실패 격리로 배치 안정성 확보
가장 중요하다고 생각한 것은 예외가 발생해도 괜찮은 구조입니다.
REQUIRES_NEW를 선택하지 않은 이유
트랜잭션 경계를 사용자 단위로 분리하기로 결정했을 때
가장 먼저 고려했던 대안은 결제 처리 로직에 REQUIRES_NEW 전파 옵션을 적용하는 방식이었습니다.
@Transactional(propagation = REQUIRES_NEW)
public void process(User user) {
// 결제 처리
}이 방법도 사용자별 독립적인 트랜잭션으로 처리할 수 있다는 점에서 유효했습니다.
하지만 최종적으로는 이 방법을 선택하지 않았습니다.
1. 불필요한 트랜잭션 / 커넥션 오버헤드
REQUIRES_NEW는 기존 트랜잭션을 일시 중단하고 새로운 트랜잭션을 시작합니다.
구조적으로 다음과 같은 흐름이 됩니다.
- 상위 runBatch() 트랜잭션이 DB 커넥션을 점유
- 하위 process() 트랜잭션이 추가 커넥션을 요구
이 방식은 기능적으로는 문제가 없지만 목적이 실패 격리라는 점을 고려하면
트랜잭션을 중첩시키는 구조는 불필요한 트랜잭션/커넥션 오버헤드를 발생시킨다고 판단했습니다.
특히 상위 트랜잭션이 실질적인 원자성을 요구하지 않는 상황에서
중첩 트랜잭션을 유지하는 것은 구조적 복잡도와 운영 부담만 증가시킬 수 있습니다.
2. 상위 트랜잭션 자체가 불필요한 구조
상위 트랜잭션 자체가 필요 없는 구조였다는 점입니다.
runBatch() 메서드의 역할은 단순합니다.
- 결제 대상 사용자 조회
- 사용자별 결제 처리 로직 호출
- 배치 결과 성공여부 저장
이 과정에는 하나의 트랜잭션으로 묶어야 할 원자적 작업 단위가 존재하지 않았습니다.
트랜잭션을 분리하기 위해 굳이 “트랜잭션 안에서 또 다른 트랜잭션을 여는 구조”를 유지할 이유가 없었습니다.
그래서 REQUIRES_NEW를 추가하는 대신 아예 상위 트랜잭션을 제거하는 방향을 선택했습니다.
트레이드오프
이 구조는 안정성과 실패 격리 측면에서는 명확한 장점을 가지지만 그에 따른 비용 역시 존재합니다.
장점: 예외가 발생해도 시스템이 무너지지 않는 구조
- 사용자별 결제 처리 완전 격리
- 단 한 건의 예외가 전체 배치에 영향 없음
- UnexpectedRollbackException 구조적 제거
- 의미 없는 DB 작업 및 트랜잭션 오염 제거
단점: 커밋 횟수 증가에 따른 I/O 오버헤드
- 트랜잭션을 사용자 단위로 분리하여 커밋 횟수 증가(사용자 수 == 트랜잭션 수)
- 커밋 횟수 증가 → I/O 오버헤드 증가
이는 분명하게 비용이 발생하였고 트래픽이 매우 큰 환경에서는 성능 튜닝이나 구조적 보완이 필요할 수 있습니다.
그럼에도 이 구조를 선택한 이유는 명확했습니다.
결제 도메인에서는 처리 속도보다는 데이터 정합성, 안정성을 가장 우선이 되어야합니다.
일부 성능을 양보하더라도 실패는 격리되고 성공한 데이터는 반드시 보존되어야하는 구조가 필요하다고 판단했습니다.
또한 이 구조는 향후 트래픽 증가나 아키텍처 확장 시에도
외부 API 분리, 비동기 처리 등 다음 단계로 자연스럽게 확장할 수 있는 기반이 됩니다.
아직 남은 구조적 한계
현재 구조에서는 트랜잭션 내부에서 외부 결제 API를 호출하고 있기 때문에 구조적인 한계가 남아 있습니다.
결제는 성공했지만 이후 데이터 처리 과정에서 예외가 발생하면 트랜잭션이 롤백되어 외부 시스템과 내부 데이터 간 정합성이 깨질 수 있습니다.
이 문제를 해결하기 위한 다음 단계로 Transactional Outbox Pattern 적용을 검토하고 있습니다.
Outbox 패턴은 비즈니스 로직과 외부 API 호출을 분리할 때 사용합니다.
트랜잭션 내에서는 Outbox 테이블에 수행 이벤트를 기록하고 이후 배치나 이벤트 트리거 기반 워커에서 이를 처리하는 방식입니다.
이벤트 전달을 최소 한 번 이상 보장하는 특성상 결제 도메인에서는 중복 처리 방지와 멱등성 설계가 필수적이기 때문에 현재 해당 부분을 중심으로 학습하고 있습니다.
마무리하면서
이번 문제를 해결하는 과정에서 많은 것을 배울 수 있었습니다.
트랜잭션 내부에서 RuntimeException이 발생하면 트랜잭션이 rollback-only 상태로 마킹되고 이후 커밋이 불가능해진다는 동작 방식도 명확히 이해할 수 있었습니다.
무엇보다 크게 느낀 점은 트랜잭션의 범위 설계가 데이터 정합성을 지키는 데 있어 가장 중요한 요소라는 점이었습니다.
이론적으로는 “원자적으로 수행되어야 할 단위를 트랜잭션으로 묶는다”는 개념을 알고 있었지만
실제 장애 상황을 통해 그 영향도를 체감할 수 있었던 경험은 흔하지 않다고 생각합니다.
현재 구조는 아직 하나의 중간 단계에 불과합니다.
향후에는 Transactional Outbox Pattern에 대한 이해를 더 깊게 가져가
외부 시스템 연계까지 고려하여 보다 안정적이고 단단한 결제 시스템으로 확장해 나가고자 합니다.
앞으로도 단순히 동작하는 코드를 넘어서
운영 환경과 도메인 특성을 고려한 안전한 경계 설정과 설계 판단을 지속적으로 고민해 나가려고 합니다.
'Spring' 카테고리의 다른 글
| [Spring] @Transactional이 동작하지 않는 이유: Proxy와 내부 호출(Self-Invocation)의 함정 (1) | 2025.12.09 |
|---|---|
| Function Calling으로 LLM에게 메서드 제공하기 (feat. AI Agent) (7) | 2025.08.10 |
| Advisor로 LLM에 추가 정보 제공 및 응답 가공하기 (feat. RAG) (3) | 2025.08.03 |
| Spring AI로 LLM 연결하기(Feat: ollama) (8) | 2025.07.27 |
| [Spring] Factory Method Pattern 사용하기 (Feat: injecting Collections) (0) | 2025.03.23 |