본문 바로가기
💻 트러블슈팅 (에러 해결)

예외 처리(try-catch)를 했는데 왜 롤백될까? UnexpectedRollbackException 완벽 해결법

by 데브프리 2026. 8. 13.

안녕하세요, 여러분의 든든한 IT 길잡이 데브프리입니다.

 

스프링 부트(Spring Boot)로 백엔드 비즈니스 로직을 짜다 보면 이런 요구사항을 흔히 마주합니다. "고객이 주문을 완료하면 핵심 데이터(주문 정보)는 무조건 DB에 저장되어야 하고, 부가적인 데이터(주문 이력 로그) 저장에 실패하더라도 메인 주문은 절대 취소(롤백)되면 안 됩니다!"

 

이 요구사항을 구현하기 위해 백엔드 개발자들은 아주 자연스럽게 이력 저장 로직을 try-catch 문으로 감싸서 에러를 무시하도록 코드를 작성합니다. 하지만 실서버에 배포하고 로그 저장이 실패하는 순간, 잡았다고 생각했던 에러는 온데간데없고 뜬금없이 아래와 같은 시뻘건 에러가 터지며 주문까지 통째로 롤백(Rollback)되어 버립니다.

Java

org.springframework.transaction.UnexpectedRollbackException: Transaction silently rolled back because it has been marked as rollback-only

"어? 분명히 catch로 에러를 잡아서 정상 흐름으로 넘겼는데, 왜 조용히 롤백(silently rolled back) 되었다고 화를 내는 걸까요?"

 

오늘은 실무에서 트랜잭션을 다룰 때 수많은 개발자를 멘붕에 빠뜨리는 UnexpectedRollbackException의 진짜 원인을 객관적으로 분석하고, 이를 가장 안전하고 우아하게 해결하는 트러블슈팅 베스트 프랙티스를 파헤쳐 드립니다.

 

1. 실전 트러블슈팅: 완벽해 보이는 코드의 함정

문제가 발생한 전형적인 실무 코드를 살펴봅시다. 메인 주문을 처리하는 OrderService와 이력을 저장하는 LogService가 분리되어 있습니다.

Java

@Service
public class OrderService {
    private final LogService logService;

    @Transactional
    public void createOrder(OrderRequest request) {
        // 1. 핵심 주문 정보 저장 (성공)
        orderRepository.save(new Order(request));

        // 2. 부가 이력 저장 (실패해도 주문은 유지되어야 함!)
        try {
            logService.saveLog(request); // 여기서 RuntimeException 발생!
        } catch (Exception e) {
            log.warn("이력 저장 실패, 하지만 주문은 계속 진행합니다.", e);
        }
    }
}

@Service
public class LogService {
    
    @Transactional
    public void saveLog(OrderRequest request) {
        // DB 제약 조건 위반 등으로 예외 발생!
        throw new RuntimeException("이력 DB 저장 실패");
    }
}

개발자의 의도는 명확합니다. LogService에서 에러가 터져도, OrderService의 catch 블록이 에러를 꿀꺽 삼키고 로직을 끝까지 정상적으로 완주합니다. 자바의 문법상으로는 아무런 문제가 없죠.

 

하지만 스프링의 트랜잭션 매니저(Transaction Manager)가 개입하면 이야기가 완전히 달라집니다.

 

2. 왜 터졌을까? (Rollback-Only 마크의 진실)

이 현상을 이해하려면 스프링의 기본 트랜잭션 전파 속성인 Propagation.REQUIRED의 동작 원리를 알아야 합니다.

  1. OrderService.createOrder()가 실행될 때 거대한 하나의 물리적 트랜잭션(A)이 생성됩니다.
  2. 내부에서 LogService.saveLog()가 호출됩니다. 이때 LogService에도 @Transactional이 붙어있으므로, 스프링은 새로운 트랜잭션을 만들지 않고 기존의 거대한 트랜잭션(A)에 슬쩍 참여(Participate)시킵니다.
  3. LogService에서 RuntimeException이 터집니다!
  4. 에러가 튀어나오면서 LogService를 감싸고 있던 스프링 AOP 프록시(Proxy)가 이를 감지합니다. 프록시는 즉시 거대한 트랜잭션(A) 전체에 "이 트랜잭션은 이미 망가졌으니 무조건 롤백해!(Rollback-Only)"라는 낙인(Mark)을 쾅 찍어버립니다.
  5. 에러는 계속 튀어나와 OrderService의 catch 블록에 도달하고, 개발자는 이를 덮어버린 채 메서드를 정상 종료시킵니다.
  6. 메서드가 끝나고 스프링 트랜잭션 매니저가 커밋(Commit)을 시도합니다. 그런데 트랜잭션(A)을 살펴보니 누군가(LogService)가 이미 Rollback-Only 낙인을 찍어둔 것을 발견합니다!

스프링 입장에서는 "개발자는 커밋하라고 정상 종료시켰는데, 트랜잭션 상태는 무조건 롤백하라고 되어있네? 이건 모순이야!"라고 판단하고, 강제로 전체 롤백을 수행함과 동시에 UnexpectedRollbackException을 던지며 개발자에게 강력하게 경고하는 것입니다.

내부 트랜잭션 실패로 인한 Rollback-Only 마킹과 전체 롤백 비유
내부 메서드에서 예외가 발생하면 해당 트랜잭션 전체에 'Rollback-Only'라는 되돌릴 수 없는 낙인이 찍힙니다. 바깥쪽에서 아무리 에러를 숨겨도 커밋 시점에 결국 롤백을 피할 수 없습니다.

 

💡 [데브프리의 심층 면접 꿀팁: 예외라고 다 같은 예외가 아니다? Checked Exception의 반전!]

면접관이 "만약 LogService에서 던진 예외가 RuntimeException이 아니라 IOException(Checked Exception)이었다면 똑같이 UnexpectedRollbackException이 터질까요?"라고 묻는다면 100점짜리 압박 질문입니다!

정답은 "아니요, 정상적으로 커밋됩니다!"입니다.

스프링의 @Transactional은 기본적으로 RuntimeException과 Error가 발생했을 때만 트랜잭션을 롤백(Rollback-Only 마킹)시키도록 설계되어 있습니다. 개발자가 명시적으로 try-catch 처리를 강제받는 Checked Exception(Exception을 상속받지만 RuntimeException은 아닌 것들)이 발생하면, 스프링은 이를 '비즈니스적인 의미가 있는 예외(예: 잔액 부족 알림 등)'로 간주하여 롤백 마크를 찍지 않고 그대로 커밋(Commit)을 진행합니다.

따라서 Checked Exception이 터졌을 때도 무조건 롤백을 시키고 싶다면 @Transactional(rollbackFor = Exception.class) 옵션을 반드시 명시해 주어야 한다는 점을 어필하세요. 이 대답 하나면 스프링 트랜잭션의 동작 원리를 꿰뚫는 시니어로 인정받을 수 있습니다!

 

3. 해결 베스트 프랙티스: 트랜잭션 끊어내기

이 끔찍한 연쇄 롤백을 막고 개발자의 의도대로 부가 기능만 조용히 실패하게 만들려면, 트랜잭션의 연결 고리를 끊어내야 합니다.

해결책 1: REQUIRES_NEW 사용 (가장 깔끔한 실무 표준)

가장 확실한 방법은 실패해도 되는 내부 로직을 완전히 독립적인 새로운 트랜잭션으로 분리하는 것입니다. LogService의 어노테이션에 propagation = Propagation.REQUIRES_NEW 옵션을 줍니다.

Java

@Service
public class LogService {
    
    // 💡 새로운 물리적 트랜잭션(B)을 강제로 열어서 진행합니다!
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void saveLog(OrderRequest request) {
        throw new RuntimeException("이력 DB 저장 실패");
    }
}

이렇게 하면 LogService에서 에러가 터져도 새로 생성된 트랜잭션(B)에만 Rollback-Only 낙인이 찍힙니다. 메인 트랜잭션(A)은 이 낙인으로부터 완벽하게 격리되어 있으므로, try-catch로 에러를 잡은 뒤 안전하게 메인 주문 정보를 커밋(Commit)할 수 있습니다.

해결책 2: 내부 로직의 @Transactional 제거 혹은 내부에서 잡기

만약 LogService가 굳이 트랜잭션에 묶일 필요가 없는 단순한 로직이라면 어노테이션 자체를 제거해 버리는 것도 방법입니다.

하지만 DB 조작이 필요해서 제거할 수 없다면, LogService의 @Transactional 메서드가 에러를 밖으로 던지기 전에 자신의 내부에서 먼저 try-catch로 해결하고 정상 리턴하도록 코드를 수정하면 프록시가 에러를 눈치채지 못하므로 낙인을 찍지 않습니다.

 

4. 전문가의 확고한 결론 (Verdict)

중첩 트랜잭션과 REQUIRES_NEW, 실무에 언제 도입하고 언제 피해야 할까요?

  • 👍 확실한 도입 타이밍: 외부 시스템 연동 로그 적재, 통계 데이터 반영, 슬랙 알림 발송 기록 등 '메인 비즈니스 로직의 성공 여부와 엮여서 같이 롤백되면 절대 안 되는 부가적인 작업'을 하나의 흐름 안에서 처리해야 할 때 REQUIRES_NEW를 도입하는 것이 핵심 베스트 프랙티스입니다. 이를 통해 서비스의 결합도를 낮추고 데이터베이스 정합성을 독립적으로 지켜낼 수 있습니다. (물론 이 단계를 넘어 규모가 더 커지면 카프카(Kafka)나 이벤트를 활용한 비동기 분리가 최종 목표가 되어야 합니다.)
  • 🛑 도입을 피해야 할 때: 로직을 분리하겠다고 무작정 모든 서비스 메서드에 REQUIRES_NEW를 남발해서는 절대 안 됩니다. REQUIRES_NEW는 실행되는 순간 기존 커넥션을 그대로 둔 채 데이터베이스 커넥션(Connection)을 하나 더 추가로 점유합니다. 트래픽이 몰릴 때 남발하게 되면 HikariCP 커넥션 풀이 순식간에 고갈되어 서버 전체가 뻗어버리는 '데드락(Deadlock)' 현상의 주범이 됩니다. 하나의 완벽한 원자성(All or Nothing)이 보장되어야 하는 핵심 비즈니스 로직(예: 출금과 입금)은 무조건 기본값인 REQUIRED로 묶어서 하나로 관리해야 합니다.

UnexpectedRollbackException은 스프링 부트가 개발자의 논리적 실수를 막아주기 위해 던지는 아주 고마운 안전장치입니다. 트랜잭션의 전파 원리와 AOP 프록시의 특성을 명확히 이해하신다면, 더 이상 트랜잭션 경계에서 당황하는 일은 없을 것입니다.

 

항상 팩트에 기반한 객관적인 실무 가이드로 여러분의 삽질 시간을 줄여드리겠습니다. 다음 포스팅에서도 개발자들에게 뼈가 되고 살이 되는 백엔드 지식으로 찾아뵙겠습니다!

 

👉 실서버 뻗기 전에 꼭 확인하세요! 내 서버 스펙에 맞는 최적의 DB 커넥션 풀 개수 계산기 [링크]