IOException이 발생했는데 트랜잭션이 커밋되었다면, Spring의 롤백 규칙을 정확히 알고 있는 걸까요?

@Transactional을 사용할 때 가장 쉽게 실수하는 부분이 예외와 롤백의 관계 입니다. 어떤 예외에서 롤백되고 어떤 예외에서 커밋되는지, 그 규칙을 정확히 이해해야 합니다.

Spring의 기본 롤백 정책

JAVA
@Transactional
public void process() {
    // ✓ 롤백되는 예외
    throw new RuntimeException();          // 언체크 예외
    throw new IllegalArgumentException();  // RuntimeException 하위
    throw new NullPointerException();      // RuntimeException 하위
    throw new OutOfMemoryError();          // Error

    // ✗ 롤백되지 않는 예외 (커밋됨!)
    throw new IOException();               // 체크 예외
    throw new SQLException();              // 체크 예외
    throw new Exception();                 // 체크 예외
}

규칙 정리

예외 타입기본 동작
RuntimeException (언체크)롤백
Error** 롤백**
Exception (체크)** 커밋**

왜 이런 규칙인가

Spring의 설계 철학입니다.

  • ** 체크 예외 **: "복구 가능한 비즈니스 예외"로 간주합니다. 예를 들어 잔고 부족은 예외적 상황이지만, 트랜잭션을 되돌릴 필요는 없을 수 있습니다.
  • ** 언체크 예외 **: "프로그래밍 오류나 시스템 장애"로 간주합니다. NullPointerException이 발생했다면 데이터 정합성을 위해 트랜잭션을 되돌려야 합니다.

하지만 실무에서 이 구분은 항상 맞지 않습니다. 체크 예외에서도 롤백이 필요한 경우가 많습니다.

rollbackFor — 롤백 대상 확장

JAVA
// 모든 예외에서 롤백
@Transactional(rollbackFor = Exception.class)
public void process() throws Exception {
    // IOException, SQLException 등 체크 예외에서도 롤백됨
}

// 특정 체크 예외에서만 롤백
@Transactional(rollbackFor = {IOException.class, CustomCheckedException.class})
public void process() throws IOException {
    // IOException → 롤백
    // 다른 체크 예외 → 커밋
}

공부하다 보니 실무에서는 rollbackFor = Exception.class를 기본으로 설정하는 팀이 많았습니다. 체크 예외가 커밋되어 데이터가 꼬이는 것보다 안전하기 때문입니다.

noRollbackFor — 롤백 대상 제외

JAVA
// 특정 런타임 예외에서는 롤백하지 않음
@Transactional(noRollbackFor = NotificationFailedException.class)
public void createOrder(OrderRequest request) {
    orderRepository.save(new Order(request));

    try {
        notificationService.sendNotification(request.getUserId());
    } catch (NotificationFailedException e) {
        // 알림 실패는 주문에 영향을 주지 않음 → 커밋
        log.warn("알림 전송 실패", e);
        throw e;  // noRollbackFor에 의해 커밋됨
    }
}

try-catch와 트랜잭션

예외를 잡으면 커밋

JAVA
@Transactional
public void process() {
    try {
        riskyOperation();  // RuntimeException 발생 가능
    } catch (RuntimeException e) {
        log.error("에러 발생", e);
        // 예외를 잡았으므로 프록시에 전달되지 않음 → 커밋됨
    }
    // 이후 로직 계속 실행 → 트랜잭션 커밋
}

예외가 프록시까지 전파되지 않으면 프록시는 "정상 종료"로 판단하여 커밋합니다.

잡았다가 다시 던지면 롤백

JAVA
@Transactional
public void process() {
    try {
        riskyOperation();
    } catch (RuntimeException e) {
        log.error("에러 발생, 롤백 예정", e);
        throw e;  // 다시 던지면 프록시가 받아서 롤백
    }
}

주의: REQUIRED에서의 내부 롤백

JAVA
@Service
public class OuterService {

    @Transactional
    public void outer() {
        try {
            innerService.inner();  // REQUIRED — 같은 트랜잭션 참여
        } catch (RuntimeException e) {
            // 예외를 잡아도 이미 rollback-only 마크 설정됨!
            log.error("inner 실패", e);
        }
        // 커밋 시도 → UnexpectedRollbackException 발생!
    }
}

이어서 트랜잭션이 적용된 메서드를 정의합니다.

JAVA
@Service
public class InnerService {

    @Transactional  // REQUIRED (기본)
    public void inner() {
        throw new RuntimeException("실패");
        // → 같은 물리 트랜잭션에 rollback-only 마크 설정
    }
}

이 경우에는 try-catch로 예외를 잡아도 롤백됩니다. 내부 트랜잭션이 같은 물리 트랜잭션을 공유하기 때문에 rollback-only 마크가 설정되어 있습니다.

예외 변환 패턴

체크 예외를 런타임 예외로 변환

JAVA
@Transactional
public void processFile(MultipartFile file) {
    try {
        fileService.save(file);  // IOException 가능
    } catch (IOException e) {
        // 체크 예외 → 런타임 예외 변환 → 롤백됨
        throw new FileProcessingException("파일 저장 실패", e);
    }
}

// 커스텀 런타임 예외
public class FileProcessingException extends RuntimeException {
    public FileProcessingException(String message, Throwable cause) {
        super(message, cause);
    }
}

비즈니스 예외 설계

JAVA
// 비즈니스 예외 — 롤백이 필요한 경우 RuntimeException 상속
public class InsufficientBalanceException extends RuntimeException {
    public InsufficientBalanceException(String message) {
        super(message);
    }
}

// 비즈니스 예외 — 롤백이 불필요한 경우
public class DuplicateNicknameException extends RuntimeException {
    // noRollbackFor로 설정하거나, try-catch로 처리
}

실무 권장 패턴

JAVA
@Service
@Transactional(readOnly = true)
public class OrderService {

    // 조회 — readOnly
    public OrderDto getOrder(Long id) {
        return orderRepository.findById(id)
            .map(OrderDto::from)
            .orElseThrow(() -> new OrderNotFoundException(id));
    }

    // 변경 — rollbackFor 명시
    @Transactional(rollbackFor = Exception.class)
    public OrderDto createOrder(OrderCreateRequest request) {
        // 모든 예외에서 롤백하도록 설정
        Order order = Order.create(request);
        orderRepository.save(order);
        return OrderDto.from(order);
    }
}

주의할 점

1. 라이브러리가 던지는 체크 예외를 놓치기 쉽다

외부 라이브러리(파일 I/O, HTTP 클라이언트 등)가 체크 예외를 던지면 @Transactional 기본 정책에 의해 트랜잭션이 커밋됩니다. 데이터는 절반만 저장된 상태가 될 수 있습니다. rollbackFor = Exception.class를 명시하지 않으면 의도치 않게 데이터 불일치가 발생하는 원인이 됩니다.

2. try-catch로 예외를 삼키면 트랜잭션 문제를 디버깅하기 매우 어렵다

@Transactional 메서드 안에서 예외를 catch하고 로그만 남기면 프록시 입장에서는 정상 종료로 판단하여 커밋합니다. 문제는 로그에는 에러가 찍히지만 트랜잭션은 커밋되어, "에러가 발생했는데 데이터는 저장되었다"는 혼란스러운 상황이 만들어진다는 점입니다.

3. noRollbackFor 설정을 남용하면 데이터 정합성이 깨진다

특정 예외에서 롤백을 막는 noRollbackFor는 편리하지만, 해당 예외가 발생한 시점까지의 변경사항이 모두 커밋됩니다. 비즈니스 로직이 복잡해지면 "이 예외 시점에 어디까지 반영되는지"를 추적하기 어려워지므로, 가급적 사용을 최소화하고 예외 흐름을 명확하게 설계해야 합니다.

정리

  • Spring의 기본 롤백 규칙은 **RuntimeException/Error에서만 롤백 **, 체크 예외에서는 커밋입니다.
  • rollbackFor = Exception.class로 모든 예외에서 롤백하도록 설정하는 것이 안전합니다.
  • try-catch로 예외를 잡으면 프록시에 전달되지 않으므로 ** 커밋 **됩니다.
  • REQUIRED에서 내부 트랜잭션이 롤백되면 rollback-only 마크 때문에 외부도 롤백됩니다.
  • 체크 예외는 ** 런타임 예외로 변환 **하거나 rollbackFor를 설정하여 롤백 누락을 방지합니다.
댓글 로딩 중...