Java Transaction silently rolled back 문제는 어떤 상황에서 발생할까
Java Spring 환경에서 Transaction silently rolled back 메시지는 보통 트랜잭션 내부에서 이미 롤백이 결정되었는데, 바깥 코드에서는 정상 처리된 것처럼 커밋을 시도할 때 발생합니다. 대표적으로 Spring에서는 UnexpectedRollbackException 형태로 나타나는 경우가 많습니다.
이 메시지를 처음 보면 “예외를 잡았는데 왜 롤백되지?”라는 생각이 들 수 있습니다. 하지만 Spring 입장에서 보면 이미 해당 트랜잭션은 정상 커밋이 불가능한 상태입니다. 예외를 catch 했는지와 별개로, 트랜잭션 매니저 내부에는 rollback-only 표시가 남아 있기 때문입니다.
즉, 이 문제의 핵심은 “예외를 잡았느냐”가 아니라 “트랜잭션이 이미 롤백 전용 상태로 표시되었느냐”입니다. 실무에서는 이 차이를 놓쳐서 서비스 로직은 정상 흐름처럼 보이는데, 마지막 커밋 단계에서 예외가 터지는 상황을 자주 보게 됩니다.
Transaction silently rolled back의 핵심 원리
Transaction silently rolled back은 말 그대로 트랜잭션이 정상 커밋되지 않고 조용히 롤백되었다는 뜻에 가깝습니다. 다만 “조용히”라는 표현 때문에 아무 예외도 없이 넘어간다는 의미로 오해하면 안 됩니다. Spring은 바깥 호출자가 커밋을 기대하고 있었는데 실제로는 롤백되었으므로, 이를 알리기 위해 UnexpectedRollbackException을 던집니다.
rollback-only 상태란 무엇인가
Spring 트랜잭션은 특정 예외가 발생하면 현재 트랜잭션을 rollback-only 상태로 표시할 수 있습니다. 이 상태가 되면 이후 코드에서 예외를 잡고 계속 진행하더라도, 같은 트랜잭션은 더 이상 커밋될 수 없습니다.
비유하면 “이 작업 묶음은 이미 실패 처리하기로 결정되었다”는 표시가 트랜잭션에 찍힌 것입니다. 이후 코드가 정상적으로 끝나도 커밋 시점에 트랜잭션 매니저는 이 표시를 확인하고 롤백합니다.
@Transactional
public void outer() {
try {
inner();
} catch (RuntimeException e) {
// 예외를 잡았으니 정상 처리된 것처럼 보입니다.
log.warn("inner 처리 실패. 계속 진행합니다.", e);
}
// 여기까지 코드가 정상 실행되어도
// 트랜잭션은 이미 rollback-only 상태일 수 있습니다.
}
@Transactional
public void inner() {
throw new RuntimeException("DB 처리 중 오류 발생");
}
위 코드에서 outer()는 예외를 잡았기 때문에 겉으로는 정상 종료되는 것처럼 보입니다. 하지만 inner()에서 발생한 런타임 예외 때문에 같은 트랜잭션이 rollback-only 상태로 표시될 수 있습니다. 이후 outer()가 커밋을 시도하면 Spring은 정상 커밋 대신 롤백을 수행하고 예외를 던집니다.
Transaction silently rolled back이 자주 발생하는 코드 패턴
Transaction silently rolled back 문제는 대체로 트랜잭션 범위와 예외 처리 위치가 어긋날 때 발생합니다. 특히 서비스 메서드 안에서 예외를 잡고 “실패해도 계속 진행”하는 로직을 만들 때 주의해야 합니다.
패턴 1. 내부 메서드 예외를 catch 했지만 같은 트랜잭션을 공유하는 경우
가장 흔한 패턴은 내부 처리 중 예외가 발생했지만, 바깥 서비스에서 이를 catch 하고 계속 진행하는 구조입니다. 개발자는 예외를 잡았으니 문제가 끝났다고 생각하지만, 트랜잭션 관점에서는 이미 롤백 대상으로 표시되었을 수 있습니다.
@Service
public class OrderService {
private final PaymentService paymentService;
private final OrderRepository orderRepository;
public OrderService(PaymentService paymentService, OrderRepository orderRepository) {
this.paymentService = paymentService;
this.orderRepository = orderRepository;
}
@Transactional
public void createOrder(Order order) {
orderRepository.save(order);
try {
paymentService.pay(order);
} catch (RuntimeException e) {
order.markPaymentFailed();
}
orderRepository.save(order);
}
}
이 코드는 의도만 보면 결제 실패를 주문 실패 상태로 저장하려는 흐름입니다. 하지만 paymentService.pay()가 같은 트랜잭션 안에서 실행되고, 그 안에서 런타임 예외가 발생했다면 현재 트랜잭션은 rollback-only가 될 수 있습니다.
그 상태에서 order.markPaymentFailed()와 orderRepository.save(order)를 호출해도 최종 커밋은 실패합니다. 코드상으로는 실패 상태를 저장한 것처럼 보이지만, 실제로는 트랜잭션 전체가 롤백되는 구조입니다.
패턴 2. 예외를 삼켜서 상위 계층이 성공으로 오해하는 경우
두 번째 패턴은 catch 블록에서 예외를 로그만 남기고 끝내는 방식입니다. 이 경우 호출자는 정상 처리된 것으로 판단하지만, 실제 트랜잭션은 커밋할 수 없는 상태일 수 있습니다.
@Transactional
public void process() {
try {
saveMainData();
saveSubData();
} catch (Exception e) {
log.warn("일부 데이터 저장 실패", e);
}
}
이 방식은 겉으로는 방어적인 코드처럼 보입니다. 하지만 트랜잭션 안에서 모든 예외를 넓게 잡아버리면 실패를 의도적으로 무시하는 것인지, 정말 전체 작업을 실패 처리해야 하는 것인지 코드만 보고 판단하기 어려워집니다.
팀 단위로 유지보수할 때는 이런 코드가 특히 위험합니다. 호출하는 쪽에서는 성공으로 이해하고 다음 로직을 진행할 수 있지만, 실제 데이터는 저장되지 않았을 수 있기 때문입니다.
UnexpectedRollbackException과 Transaction silently rolled back의 관계
Spring에서 Transaction silently rolled back 메시지는 보통 UnexpectedRollbackException과 함께 확인됩니다. 이 예외는 이름 그대로 호출자는 커밋을 기대했지만, 실제로는 롤백되어 예기치 않은 결과가 발생했다는 의미입니다.
org.springframework.transaction.UnexpectedRollbackException:
Transaction silently rolled back because it has been marked as rollback-only
이 예외가 중요한 이유는 원인이 발생한 지점과 예외가 터지는 지점이 다를 수 있다는 점입니다. 실제 문제는 내부 메서드에서 발생했는데, 스택트레이스상으로는 바깥 트랜잭션이 종료되는 시점에서 예외가 보일 수 있습니다.
그래서 이 문제를 분석할 때는 예외가 마지막에 터진 위치만 보면 안 됩니다. 트랜잭션 안에서 먼저 발생한 예외, catch로 삼켜진 예외, 내부 서비스 호출 구조를 같이 확인해야 합니다.
Transaction silently rolled back 원인 추적 방법
Transaction silently rolled back 문제를 해결하려면 “어디서 rollback-only가 되었는지”를 찾아야 합니다. 단순히 마지막 UnexpectedRollbackException 위치만 보는 방식으로는 원인을 놓치기 쉽습니다.
1. catch 블록에서 삼킨 예외를 먼저 확인한다
가장 먼저 볼 부분은 @Transactional 메서드 내부의 catch 블록입니다. 특히 Exception이나 RuntimeException을 넓게 잡고 로그만 남기는 코드가 있다면 의심해볼 필요가 있습니다.
catch (Exception e) {
log.warn("처리 실패", e);
}
이런 코드는 당장 서비스가 멈추지 않게 하는 데는 도움이 될 수 있습니다. 하지만 트랜잭션 안에서는 예외를 숨기는 것이 오히려 문제 분석을 어렵게 만들 수 있습니다. 실패를 무시할 것인지, 별도 트랜잭션으로 기록할 것인지, 전체 롤백할 것인지가 코드에 드러나야 합니다.
2. 내부 서비스의 @Transactional 전파 옵션을 확인한다
Spring의 기본 트랜잭션 전파 옵션은 Propagation.REQUIRED입니다. 이미 트랜잭션이 있으면 새로 만들지 않고 기존 트랜잭션에 참여합니다. 이 기본 동작 때문에 내부 서비스에서 발생한 예외가 바깥 트랜잭션 전체에 영향을 줄 수 있습니다.
@Transactional
public void outer() {
innerService.inner();
}
@Transactional
public void inner() {
// 기본값은 Propagation.REQUIRED
// 기존 트랜잭션이 있으면 그 트랜잭션에 참여합니다.
}
내부 작업이 실패해도 바깥 작업은 저장되어야 한다면, 기본 전파 옵션이 맞지 않을 수 있습니다. 반대로 내부 작업 실패 시 전체를 되돌려야 한다면 기본 동작이 더 적절합니다. 중요한 것은 트랜잭션 경계를 의도적으로 정하는 것입니다.
3. 원인 예외와 최종 예외를 분리해서 본다
이 문제에서는 최종 예외가 UnexpectedRollbackException으로 보이더라도 실제 원인은 그보다 앞서 발생한 예외일 수 있습니다. 그래서 로그를 볼 때는 같은 요청 흐름 안에서 먼저 찍힌 예외 로그를 찾아야 합니다.
실무에서는 마지막 에러 로그만 보고 트랜잭션 설정을 바꾸는 경우가 있는데, 그러면 근본 원인을 놓칠 수 있습니다. 먼저 실패한 저장 로직, 검증 로직, 외부 호출 이후 DB 업데이트 로직이 있었는지 순서대로 보는 편이 좋습니다.
Transaction silently rolled back 해결 방법 1: 예외를 숨기지 않고 명확히 전파하기
Transaction silently rolled back을 해결하는 가장 단순한 방법은 실패를 실패로 인정하고 예외를 상위로 전파하는 것입니다. 해당 작업이 하나의 트랜잭션으로 묶여야 한다면, 내부 예외를 억지로 catch 해서 정상 흐름처럼 만들 필요가 없습니다.
@Transactional
public void createOrder(Order order) {
orderRepository.save(order);
paymentService.pay(order);
order.markPaid();
orderRepository.save(order);
}
이 구조에서는 결제 처리 중 예외가 발생하면 주문 생성 전체가 롤백됩니다. 주문과 결제가 하나의 성공 단위로 묶여야 한다면 이 방식이 가장 읽기 쉽습니다. 실패를 별도로 저장해야 한다면 그때는 트랜잭션 경계를 분리하는 방식으로 접근하는 편이 낫습니다.
중요한 점은 catch를 무조건 없애자는 뜻이 아닙니다. 예외를 잡는다면 그 이후의 트랜잭션 결과가 어떻게 될지까지 함께 설계해야 합니다.
Transaction silently rolled back 해결 방법 2: REQUIRES_NEW로 트랜잭션 분리하기
내부 작업 실패와 별개로 바깥 작업은 커밋되어야 한다면 Propagation.REQUIRES_NEW를 고려할 수 있습니다. 이 옵션은 기존 트랜잭션과 별개로 새로운 트랜잭션을 시작합니다.
예를 들어 주문 생성은 저장하되, 결제 실패 이력이나 알림 실패 이력은 별도 트랜잭션으로 남기고 싶을 수 있습니다. 이런 경우에는 실패 기록 로직을 독립된 트랜잭션으로 분리하는 것이 의도를 드러내기 좋습니다.
@Service
public class PaymentFailLogService {
private final PaymentFailLogRepository repository;
public PaymentFailLogService(PaymentFailLogRepository repository) {
this.repository = repository;
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void saveFailLog(Long orderId, String reason) {
repository.save(new PaymentFailLog(orderId, reason));
}
}
@Transactional
public void createOrder(Order order) {
orderRepository.save(order);
try {
paymentService.pay(order);
} catch (RuntimeException e) {
paymentFailLogService.saveFailLog(order.getId(), e.getMessage());
throw e;
}
}
위 구조에서는 실패 로그 저장이 별도 트랜잭션으로 실행됩니다. 바깥 주문 트랜잭션이 롤백되더라도 실패 로그는 남길 수 있습니다. 다만 REQUIRES_NEW는 트랜잭션을 분리하는 도구이지, 모든 문제를 해결하는 기본 옵션은 아닙니다.
무분별하게 사용하면 데이터 일관성 판단이 더 어려워질 수 있습니다. 어떤 데이터는 롤백되고 어떤 데이터는 커밋되는 구조가 되기 때문에, 업무적으로 정말 분리되어도 되는지 먼저 확인해야 합니다.
Transaction silently rolled back 해결 방법 3: noRollbackFor를 신중하게 사용하기
특정 예외가 발생해도 롤백하지 않아야 한다면 noRollbackFor를 사용할 수 있습니다. 다만 이 방식은 예외가 발생해도 해당 트랜잭션을 커밋하겠다는 의미이므로, 업무적으로 안전한 경우에만 적용해야 합니다.
@Transactional(noRollbackFor = NotificationException.class)
public void completeOrder(Order order) {
order.markCompleted();
orderRepository.save(order);
notificationService.send(order);
}
예를 들어 주문 완료 저장은 반드시 커밋되어야 하고, 알림 발송 실패는 주문 완료 여부에 영향을 주면 안 되는 요구사항이라면 이런 접근을 검토할 수 있습니다. 하지만 알림 발송 로직이 DB 변경까지 포함한다면 이야기가 달라집니다.
noRollbackFor는 편리하지만 의도를 잘못 잡으면 실패한 상태까지 커밋될 수 있습니다. 그래서 단순히 예외를 없애기 위한 목적으로 넣기보다는, 해당 예외가 트랜잭션 롤백 조건에서 제외되어도 되는지 먼저 판단해야 합니다.
Transaction silently rolled back 해결 방법 4: 트랜잭션 범위를 줄이기
Transaction silently rolled back 문제는 트랜잭션 범위가 너무 넓어서 생기는 경우도 있습니다. 하나의 서비스 메서드 안에 DB 저장, 외부 API 호출, 상태 변경, 실패 처리까지 모두 들어가 있으면 예외 흐름이 복잡해집니다.
트랜잭션은 가능한 한 DB 일관성이 필요한 구간에 집중시키는 편이 좋습니다. 외부 API 호출이나 실패 후 보상 처리까지 같은 트랜잭션에 넣으면 어느 실패가 전체 롤백을 유발해야 하는지 판단하기 어려워집니다.
public void processOrder(OrderCommand command) {
Order order = orderCreateService.create(command);
try {
paymentService.pay(order);
orderStatusService.markPaid(order.getId());
} catch (RuntimeException e) {
orderStatusService.markPaymentFailed(order.getId());
}
}
위 예시는 전체 흐름을 하나의 트랜잭션으로 감싸지 않고, 상태 변경이 필요한 지점마다 트랜잭션 경계를 분리하는 방식입니다. 물론 모든 도메인에 맞는 정답은 아닙니다. 주문 생성과 결제가 반드시 하나의 원자적 작업이어야 한다면 다른 설계가 필요합니다.
다만 실패를 허용하고 상태로 관리하는 업무라면 트랜잭션을 작게 나누는 것이 코드 의도를 더 분명하게 만듭니다. 협업 관점에서도 어떤 단계가 독립적으로 커밋되는지 드러나기 때문에 유지보수에 유리합니다.
@Transactional 사용 시 함께 확인해야 할 주의점
Transaction silently rolled back을 분석할 때는 예외 처리뿐 아니라 @Transactional이 실제로 적용되고 있는지도 같이 확인해야 합니다. Spring의 트랜잭션은 프록시 기반으로 동작하는 경우가 많기 때문에 호출 방식에 따라 예상과 다르게 동작할 수 있습니다.
같은 클래스 내부 호출은 트랜잭션이 적용되지 않을 수 있다
같은 클래스 안에서 this.inner()처럼 내부 메서드를 호출하면 Spring 프록시를 거치지 않아 @Transactional이 기대대로 적용되지 않을 수 있습니다. 이 문제는 rollback-only와는 조금 다른 영역이지만, 트랜잭션 분석 중 함께 확인해야 하는 부분입니다.
@Service
public class OrderService {
public void outer() {
inner(); // 프록시를 거치지 않는 내부 호출
}
@Transactional
public void inner() {
// 기대와 다르게 트랜잭션이 적용되지 않을 수 있습니다.
}
}
트랜잭션 경계를 명확히 나누고 싶다면 별도 서비스로 분리하거나, 호출 구조를 프록시가 개입할 수 있는 형태로 설계하는 편이 좋습니다. 이 부분은 코드가 컴파일도 되고 실행도 되기 때문에 더 늦게 발견되는 경우가 있습니다.
checked exception과 runtime exception의 롤백 기준을 구분한다
Spring의 기본 트랜잭션 롤백 규칙에서는 일반적으로 런타임 예외와 에러는 롤백 대상입니다. 반면 checked exception은 기본적으로 롤백되지 않습니다. 이 차이를 모르고 예외 클래스를 설계하면 의도와 다른 커밋 또는 롤백이 발생할 수 있습니다.
@Transactional(rollbackFor = OrderException.class)
public void createOrder(Order order) throws OrderException {
orderRepository.save(order);
if (!order.isValid()) {
throw new OrderException("주문 데이터가 올바르지 않습니다.");
}
}
checked exception에서도 롤백이 필요하다면 rollbackFor를 명시하는 편이 안전합니다. 반대로 특정 예외에서는 커밋해야 한다면 noRollbackFor를 사용할 수 있습니다. 다만 두 옵션 모두 트랜잭션 정책을 바꾸는 설정이므로, 코드 리뷰에서 의도를 설명할 수 있어야 합니다.
Transaction silently rolled back을 피하기 위한 실무 기준
Transaction silently rolled back 문제를 줄이려면 예외 처리와 트랜잭션 경계를 함께 설계해야 합니다. 단순히 catch를 추가하거나 전파 옵션을 바꾸는 방식으로는 비슷한 문제가 다시 발생할 수 있습니다.
실패를 무시할지, 상태로 남길지, 전체 롤백할지 먼저 정한다
트랜잭션 설계에서 가장 먼저 정해야 하는 것은 실패의 의미입니다. 내부 작업이 실패했을 때 전체 작업도 실패해야 한다면 예외를 전파하는 구조가 자연스럽습니다. 반대로 일부 실패를 허용해야 한다면 그 실패를 상태나 로그로 남기는 별도 흐름이 필요합니다.
이 기준 없이 catch 블록부터 작성하면 코드가 애매해집니다. 실패를 무시하는 것인지, 재시도 대상인지, 사용자에게 알려야 하는지 알 수 없기 때문입니다. 트랜잭션은 기술 설정이지만, 실제로는 업무 정책과 가깝게 맞물려 있습니다.
catch 블록에서는 후속 처리 의도를 분명히 남긴다
예외를 잡아야 한다면 catch 블록 안에서 무엇을 할지 명확해야 합니다. 로그만 남기고 끝내는 코드는 짧지만, 나중에 분석할 때 의도를 알기 어렵습니다.
try {
paymentService.pay(order);
} catch (PaymentException e) {
paymentFailLogService.saveFailLog(order.getId(), e.getMessage());
orderStatusService.markPaymentFailed(order.getId());
}
이런 식으로 실패 후 어떤 상태를 남길지 코드에 드러나면, 트랜잭션 분리 필요성도 자연스럽게 보입니다. 단순히 예외를 숨기는 코드보다 업무 흐름을 이해하기 쉽습니다.
전파 옵션은 문제 회피가 아니라 경계 표현으로 사용한다
REQUIRES_NEW, NESTED, REQUIRED 같은 전파 옵션은 예외를 피하기 위한 임시방편이 아닙니다. 각 작업이 같은 성공 단위를 가져야 하는지, 독립적으로 커밋되어야 하는지를 표현하는 도구입니다.
따라서 UnexpectedRollbackException이 발생했다고 바로 REQUIRES_NEW를 붙이는 방식은 피하는 편이 좋습니다. 먼저 현재 작업의 일관성 기준을 정하고, 그 기준에 맞게 트랜잭션 경계를 조정해야 합니다.
Transaction silently rolled back 디버깅 체크리스트
Transaction silently rolled back이 발생했다면 아래 순서로 확인하면 원인을 좁히기 쉽습니다. 마지막 예외만 보지 말고, 같은 요청 흐름에서 먼저 발생한 예외를 찾는 것이 중요합니다.
@Transactional메서드 안에서Exception또는RuntimeException을 catch 하고 있는지 확인합니다.- catch 블록에서 예외를 다시 던지지 않고 로그만 남기는 코드가 있는지 확인합니다.
- 내부 서비스가 같은 트랜잭션에 참여하는지, 별도 트랜잭션이어야 하는지 확인합니다.
Propagation.REQUIRED기본 동작이 현재 요구사항과 맞는지 확인합니다.- checked exception을 사용하는 경우
rollbackFor설정이 필요한지 확인합니다. - 실패 로그나 상태 저장이 필요하다면
REQUIRES_NEW또는 트랜잭션 분리를 검토합니다. - 같은 클래스 내부 호출로 인해
@Transactional이 적용되지 않는 구조가 있는지 확인합니다.
이 체크리스트를 따라가면 대부분의 원인은 catch 블록, 트랜잭션 전파 옵션, 예외 롤백 정책 중 하나에서 발견됩니다. 특히 “예외를 잡았는데도 롤백된다”는 상황은 rollback-only 상태를 먼저 의심하는 것이 좋습니다.
Java Transaction silently rolled back 정리
Java Spring에서 Transaction silently rolled back은 단순한 예외 메시지가 아니라, 트랜잭션이 이미 rollback-only 상태가 되었음을 알려주는 신호입니다. 예외를 catch 했다고 해서 트랜잭션이 다시 커밋 가능한 상태로 돌아가는 것은 아닙니다.
이 문제를 해결하려면 먼저 실패의 의미를 정해야 합니다. 전체 작업이 실패해야 한다면 예외를 전파하고, 일부 실패를 허용해야 한다면 트랜잭션 경계를 분리하거나 실패 상태를 별도로 저장해야 합니다.
실무에서는 트랜잭션 문제를 설정 하나로만 해결하려고 하면 코드 의도가 흐려지는 경우가 많습니다. @Transactional의 전파 옵션, 예외 처리 위치, rollback 정책을 함께 보면서 “어떤 작업이 하나의 성공 단위인가”를 기준으로 설계하는 것이 가장 안전합니다.
정리하면, Transaction silently rolled back 문제는 예외를 잡았는지보다 트랜잭션이 이미 rollback-only 상태가 되었는지가 핵심입니다. 마지막 커밋 시점의 예외만 보지 말고, 그 전에 발생한 내부 예외와 트랜잭션 경계를 함께 확인해야 합니다.
'개발 > JAVA' 카테고리의 다른 글
| [JAVA] Java에서 Too many connections 발생했을 때 대응 방법 (0) | 2026.05.18 |
|---|---|
| [JAVA] Java Could not open JPA EntityManager 에러 해결 방법 (0) | 2026.05.17 |
| [JAVA] LazyInitializationException 실무에서 해결한 방법 (0) | 2026.05.15 |
| [JAVA] Embedded Tomcat 기동 실패 원인 분석 (0) | 2026.05.14 |
| [JAVA] @Transactional 안 먹히는 상황 정리 (0) | 2026.05.13 |
