[Advanced-1] 예외와 트랜잭션 커밋, 롤백

2026. 1. 4. 14:59·Spring/DB

서론: 이전 학습과의 차별점

지금까지 스프링 트랜잭션의 기본적인 사용법과 예외 처리의 기초를 다뤘다. @Transactional 애노테이션을 사용하는 방법, 체크 예외와 언체크 예외의 차이, 예외 전환과 추상화, JdbcTemplate을 통한 반복 코드 제거 등을 배웠다.

이번 글에서는 그 기초 지식을 바탕으로 더 깊이 있는 주제를 다룬다. 예외가 발생했을 때 트랜잭션이 실제로 어떻게 동작하는지, 스프링이 예외 종류에 따라 자동으로 커밋과 롤백을 결정하는 원리, 그리고 실무에서 마주치는 다양한 예외 상황에 대한 처리 전략을 심층적으로 살펴본다.

이전 내용과의 가장 큰 차별점은 트랜잭션의 '자동' 동작 메커니즘을 이해하는 것이다. 단순히 @Transactional을 붙이는 수준을 넘어, 예외 발생 시 실제로 어떤 로직이 실행되는지, 왜 그런 결정이 내려지는지, 필요할 때 어떻게 이를 제어할 수 있는지를 배우게 된다.


1. 스프링 트랜잭션 AOP의 기본 정책

1.1. 기본 원칙: 예외 타입에 따른 자동 결정

스프링의 @Transactional AOP는 예외 발생 시 다음과 같은 기본 정책을 따른다. 이 정책은 스프링 트랜잭션 관리의 핵심으로, 개발자가 명시적으로 처리하지 않아도 자동으로 적용된다.

  1. 정상 종료(리턴) → 트랜잭션 커밋
    • 메서드가 예외 없이 정상적으로 종료되면 트랜잭션은 자동으로 커밋된다.
  2. 언체크 예외 발생 → 트랜잭션 롤백
    • RuntimeException이나 그 하위 예외가 발생하면 트랜잭션이 자동으로 롤백된다.
    • Error와 그 하위 예외도 동일하게 롤백된다.
  3. 체크 예외 발생 → 트랜잭션 커밋
    • Exception을 상속받은 체크 예외가 발생하면 트랜잭션이 커밋된다.

이 정책을 개념적으로 코드로 표현하면 다음과 같다:

// 스프링 트랜잭션 AOP의 내부 로직 (개념적 표현)
try {
    // 비즈니스 로직 실행
    Object result = method.invoke(target, args);

    // 정상 종료: 커밋
    transactionManager.commit(status);
    return result;

} catch (RuntimeException | Error e) {
    // 언체크 예외: 롤백
    transactionManager.rollback(status);
    throw e;

} catch (Exception e) {
    // 체크 예외: 커밋
    transactionManager.commit(status);
    throw e;
}

1.2. 기본 정책의 논리적 근거

스프링이 이런 기본 정책을 채택한 이유는 예외의 성격에 따른 실용적인 구분에 있다.

1.2.1. 언체크 예외의 성격: 복구 불가능한 시스템 예외

RuntimeException과 Error는 대부분 시스템 레벨의 문제를 나타낸다. 예를 들어:

  • NullPointerException: 객체 참조가 잘못된 경우
  • IllegalArgumentException: 잘못된 인자가 전달된 경우
  • OutOfMemoryError: 메모리 부족으로 인한 심각한 시스템 오류

이런 예외들은 일반적으로 애플리케이션 로직으로 복구하기 어렵거나 불가능하다. 따라서 트랜잭션을 롤백하여 데이터의 일관성을 유지하는 것이 합리적이다.

1.2.2. 체크 예외의 성격: 비즈니스 의미가 있는 예외

Exception을 상속받은 체크 예외는 주로 비즈니스 로직과 관련된 예외 상황을 나타낸다. 예를 들어:

  • 사용자 입력 검증 실패
  • 잔고 부족으로 인한 결제 실패
  • 중복된 데이터 저장 시도

이런 예외들은 비즈니스 로직의 일부로 간주되며, 호출자가 반드시 처리하거나 선언해야 한다. 체크 예외가 발생했다고 해서 트랜잭션 전체를 롤백하는 것은 비즈니스 요구사항과 맞지 않을 수 있다.


2. 기본 정책 검증: 코드를 통한 실험

2.1. 실험 환경 구성

기본 정책이 실제로 동작하는지 확인하기 위해 간단한 테스트 코드를 작성한다. 먼저 로깅 설정을 통해 트랜잭션의 커밋과 롤백을 확인할 수 있도록 한다.

application.properties

properties

# 트랜잭션 인터셉터 로그 레벨 설정
logging.level.org.springframework.transaction.interceptor=TRACE

# 데이터소스 트랜잭션 매니저 로그 레벨 설정
logging.level.org.springframework.jdbc.datasource.DataSourceTransactionManager=DEBUG

# JPA 트랜잭션 매니저 로그 레벨 설정
logging.level.org.springframework.orm.jpa.JpaTransactionManager=DEBUG

# 하이버네이트 트랜잭션 로그 레벨 설정
logging.level.org.hibernate.resource.transaction=DEBUG

# JPA SQL 로그 설정
logging.level.org.hibernate.SQL=DEBUG

2.2. 테스트 코드 구현

다양한 예외 상황을 테스트하기 위한 클래스를 구현한다.

RollbackTest.java

package hello.springtx.exception;

import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;

import static org.assertj.core.api.Assertions.*;

@SpringBootTest
public class RollbackTest {

    @Autowired
    RollbackService service;

    @Test
    void runtimeException() {
        // RuntimeException 발생 시 롤백되는지 테스트
        assertThatThrownBy(() -> service.runtimeException())
                .isInstanceOf(RuntimeException.class);
    }

    @Test
    void checkedException() {
        // 체크 예외 발생 시 커밋되는지 테스트
        assertThatThrownBy(() -> service.checkedException())
                .isInstanceOf(MyException.class);
    }

    @Test
    void rollbackFor() {
        // rollbackFor 옵션으로 체크 예외를 롤백하는지 테스트
        assertThatThrownBy(() -> service.rollbackFor())
                .isInstanceOf(MyException.class);
    }

    @TestConfiguration
    static class RollbackTestConfig {
        @Bean
        RollbackService rollbackService() {
            return new RollbackService();
        }
    }

    @Slf4j
    static class RollbackService {

        // 런타임 예외 발생: 롤백
        @Transactional
        public void runtimeException() {
            log.info("call runtimeException");
            throw new RuntimeException();
        }

        // 체크 예외 발생: 커밋
        @Transactional
        public void checkedException() throws MyException {
            log.info("call checkedException");
            throw new MyException();
        }

        // 체크 예외 rollbackFor 지정: 롤백
        @Transactional(rollbackFor = MyException.class)
        public void rollbackFor() throws MyException {
            log.info("call rollbackFor");
            throw new MyException();
        }
    }

    static class MyException extends Exception {
        // 사용자 정의 체크 예외
    }
}

2.3. 테스트 결과 분석

2.3.1. runtimeException() 테스트

코드:

@Transactional
public void runtimeException() {
    log.info("call runtimeException");
    throw new RuntimeException();
}

실행 결과 로그:

Getting transaction for [...RollbackService.runtimeException]
call runtimeException
Completing transaction for [...RollbackService.runtimeException] after exception: RuntimeException
Initiating transaction rollback
Rolling back JPA transaction on EntityManager

분석:

  • RuntimeException이 발생했기 때문에 스프링은 트랜잭션을 롤백한다.
  • 로그에서 Initiating transaction rollback과 Rolling back JPA transaction on EntityManager를 확인할 수 있다.
  • 이는 기본 정책인 "언체크 예외 발생 시 롤백"이 정상적으로 동작함을 보여준다.

2.3.2. checkedException() 테스트

코드:

@Transactional
public void checkedException() throws MyException {
    log.info("call checkedException");
    throw new MyException();
}

실행 결과 로그:

Getting transaction for [...RollbackService.checkedException]
call checkedException
Completing transaction for [...RollbackService.checkedException] after exception: MyException
Initiating transaction commit
Committing JPA transaction on EntityManager

분석:

  • MyException은 Exception을 상속받은 체크 예외이다.
  • 체크 예외가 발생했음에도 불구하고 트랜잭션이 커밋된다.
  • 로그에서 Initiating transaction commit과 Committing JPA transaction on EntityManager를 확인할 수 있다.
  • 이는 기본 정책인 "체크 예외 발생 시 커밋"이 정상적으로 동작함을 보여준다.

2.3.3. rollbackFor() 테스트

코드:

@Transactional(rollbackFor = MyException.class)
public void rollbackFor() throws MyException {
    log.info("call rollbackFor");
    throw new MyException();
}

실행 결과 로그:

Getting transaction for [...RollbackService.rollbackFor]
call rollbackFor
Completing transaction for [...RollbackService.rollbackFor] after exception: MyException
Initiating transaction rollback
Rolling back JPA transaction on EntityManager

분석:

  • @Transactional(rollbackFor = MyException.class) 옵션을 사용했다.
  • 동일한 체크 예외(MyException)가 발생했지만, 이번에는 트랜잭션이 롤백된다.
  • 이는 rollbackFor 옵션이 기본 정책을 오버라이드하여 특정 체크 예외를 롤백하도록 설정할 수 있음을 보여준다.

2.4. rollbackFor와 noRollbackFor 옵션

2.4.1. rollbackFor 옵션

rollbackFor 옵션은 기본 정책에 추가로 어떤 예외가 발생할 때 롤백할지를 지정한다.

// 특정 체크 예외를 롤백하도록 지정
@Transactional(rollbackFor = MyException.class)
public void method1() throws MyException {
    throw new MyException();  // 롤백됨
}

// 여러 예외를 롤백하도록 지정
@Transactional(rollbackFor = {MyException.class, YourException.class})
public void method2() throws MyException, YourException {
    // ...
}

// 모든 예외를 롤백하도록 지정
@Transactional(rollbackFor = Exception.class)
public void method3() throws Exception {
    throw new Exception();  // 롤백됨 (기본 정책과 다름)
}

2.4.2. noRollbackFor 옵션

noRollbackFor 옵션은 기본 정책에 추가로 어떤 예외가 발생할 때 롤백하지 않을지를 지정한다.

// 특정 런타임 예외를 롤백하지 않도록 지정
@Transactional(noRollbackFor = IllegalArgumentException.class)
public void method4() {
    throw new IllegalArgumentException();  // 커밋됨 (기본 정책과 다름)
}

// 여러 런타임 예외를 롤백하지 않도록 지정
@Transactional(noRollbackFor = {IllegalArgumentException.class, IllegalStateException.class})
public void method5() {
    // ...
}

2.4.3. 옵션 적용 순서

옵션들이 충돌할 경우의 적용 순서는 다음과 같다:

  1. noRollbackFor가 가장 높은 우선순위를 가진다.
  2. rollbackFor가 그 다음 우선순위를 가진다.
  3. 기본 정책이 가장 낮은 우선순위를 가진다.

예를 들어:

@Transactional(
    rollbackFor = Exception.class,      // 모든 예외 롤백
    noRollbackFor = IllegalArgumentException.class  // IllegalArgumentException은 롤백 안함
)
public void method() {
    throw new IllegalArgumentException();  // noRollbackFor가 우선, 커밋됨
}

3. 실전 적용: 비즈니스 예외 처리 시나리오

3.1. 비즈니스 요구사항 분석

실제 비즈니스 시나리오를 통해 트랜잭션과 예외 처리의 중요성을 이해해보자. 간단한 주문 시스템을 가정한다.

주문 시스템의 비즈니스 요구사항:

  1. 정상 시나리오:
    • 주문 시 결제를 성공하면 주문 데이터를 저장한다.
    • 결제 상태를 '완료'로 처리한다.
  2. 시스템 예외 시나리오:
    • 주문 시 내부에 복구 불가능한 예외가 발생하면 전체 데이터를 롤백한다.
  3. 비즈니스 예외 시나리오:
    • 주문 시 결제 잔고가 부족하면 주문 데이터는 저장한다.
    • 결제 상태를 '대기'로 처리한다.
    • 고객에게 잔고 부족을 알리고 별도의 계좌로 입금하도록 안내한다.

3.2. 도메인 모델 설계

3.2.1. 예외 클래스 정의

NotEnoughMoneyException.java

package hello.springtx.order;

/**
 * 결제 잔고가 부족할 때 발생하는 비즈니스 예외
 * Exception을 상속받아 체크 예외로 정의
 */
public class NotEnoughMoneyException extends Exception {

    public NotEnoughMoneyException(String message) {
        super(message);
    }

    public NotEnoughMoneyException(String message, Throwable cause) {
        super(message, cause);
    }
}

이 예외는 시스템 장애가 아닌 비즈니스 상황을 나타낸다. 체크 예외로 정의한 이유는 호출자가 반드시 이 예외를 인지하고 처리해야 하기 때문이다.

3.2.2. 엔티티 클래스 정의

Order.java

package hello.springtx.order;

import lombok.Getter;
import lombok.Setter;

import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.Id;
import javax.persistence.Table;

@Entity
@Table(name = "orders")  // order는 SQL 예약어이므로 orders로 테이블 이름 지정
@Getter
@Setter
public class Order {

    @Id
    @GeneratedValue
    private Long id;

    private String username;    // 정상, 예외, 잔고부족
    private String payStatus;   // 대기, 완료

    // 기본 생성자
    public Order() {}

    // 편의 생성자
    public Order(String username) {
        this.username = username;
    }
}

주의할 점은 @Table(name = "orders")로 테이블 이름을 지정한 부분이다. order는 SQL의 예약어(ORDER BY)이기 때문에 테이블 이름으로 사용할 수 없다.

3.2.3. 리포지토리 인터페이스

OrderRepository.java

package hello.springtx.order;

import org.springframework.data.jpa.repository.JpaRepository;

public interface OrderRepository extends JpaRepository<Order, Long> {
}

스프링 데이터 JPA를 사용하여 기본적인 CRUD 연산을 제공받는다.

3.3. 서비스 계층 구현

OrderService.java

package hello.springtx.order;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Slf4j
@Service
@RequiredArgsConstructor
public class OrderService {

    private final OrderRepository orderRepository;

    /**
     * 주문 처리 메서드
     * @param order 주문 정보
     * @throws NotEnoughMoneyException 결제 잔고가 부족한 경우
     *
     * 트랜잭션 특성:
     * - 정상: 주문 저장 후 결제 상태 '완료'로 설정
     * - 시스템 예외: RuntimeException 발생 시 전체 롤백
     * - 비즈니스 예외: NotEnoughMoneyException 발생 시 주문 저장 후 상태 '대기' 설정
     */
    @Transactional
    public void order(Order order) throws NotEnoughMoneyException {
        log.info("order 호출");

        // 1. 주문 저장 (JPA는 트랜잭션 커밋 시점에 실제 DB에 반영)
        orderRepository.save(order);
        log.info("주문 저장 완료: id={}, username={}", order.getId(), order.getUsername());

        // 2. 결제 프로세스 진행
        log.info("결제 프로세스 진입");

        if (order.getUsername().equals("예외")) {
            // 시스템 예외 시나리오
            log.info("시스템 예외 발생");
            throw new RuntimeException("시스템 예외");

        } else if (order.getUsername().equals("잔고부족")) {
            // 비즈니스 예외 시나리오
            log.info("잔고 부족 비즈니스 예외 발생");
            order.setPayStatus("대기");  // 상태를 대기로 설정
            throw new NotEnoughMoneyException("잔고가 부족합니다");

        } else {
            // 정상 시나리오
            log.info("정상 승인");
            order.setPayStatus("완료");
        }

        log.info("결제 프로세스 완료");
    }
}

3.4. 서비스 로직의 트랜잭션 동작 분석

위 서비스의 트랜잭션 동작을 자세히 분석해보자:

3.4.1. 정상 시나리오

// 사용자 이름이 "정상"인 경우
Order order = new Order("정상");
orderService.order(order);

// 동작 순서:
// 1. orderRepository.save(order) - 영속성 컨텍스트에 저장
// 2. order.setPayStatus("완료") - 엔티티 상태 변경
// 3. 메서드 정상 종료
// 4. 트랜잭션 커밋 시 JPA가 DB에 반영

3.4.2. 시스템 예외 시나리오

// 사용자 이름이 "예외"인 경우
Order order = new Order("예외");
try {
    orderService.order(order);
} catch (RuntimeException e) {
    // 예외 처리
}

// 동작 순서:
// 1. orderRepository.save(order) - 영속성 컨텍스트에 저장
// 2. throw new RuntimeException("시스템 예외") - 런타임 예외 발생
// 3. 스프링 트랜잭션 AOP가 RuntimeException을 감지
// 4. 기본 정책에 따라 트랜잭션 롤백
// 5. 영속성 컨텍스트가 초기화되어 저장된 데이터도 사라짐

3.4.3. 비즈니스 예외 시나리오

// 사용자 이름이 "잔고부족"인 경우
Order order = new Order("잔고부족");
try {
    orderService.order(order);
} catch (NotEnoughMoneyException e) {
    // 비즈니스 예외 처리
}

// 동작 순서:
// 1. orderRepository.save(order) - 영속성 컨텍스트에 저장
// 2. order.setPayStatus("대기") - 상태를 대기로 변경
// 3. throw new NotEnoughMoneyException("잔고가 부족합니다") - 체크 예외 발생
// 4. 스프링 트랜잭션 AOP가 NotEnoughMoneyException을 감지
// 5. 기본 정책에 따라 트랜잭션 커밋
// 6. JPA가 커밋 시점에 변경된 상태를 DB에 반영

3.5. 테스트 코드 작성

OrderServiceTest.java

package hello.springtx.order;

import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

import java.util.Optional;

import static org.assertj.core.api.Assertions.*;

@Slf4j
@SpringBootTest
class OrderServiceTest {

    @Autowired
    OrderService orderService;

    @Autowired
    OrderRepository orderRepository;

    @Test
    void complete() throws NotEnoughMoneyException {
        // given: 정상 시나리오
        Order order = new Order("정상");

        // when: 주문 처리
        orderService.order(order);

        // then: 데이터 검증
        Order findOrder = orderRepository.findById(order.getId()).get();
        assertThat(findOrder.getPayStatus()).isEqualTo("완료");
        log.info("정상 주문 완료: id={}, status={}", findOrder.getId(), findOrder.getPayStatus());
    }

    @Test
    void runtimeException() {
        // given: 시스템 예외 시나리오
        Order order = new Order("예외");

        // when, then: 런타임 예외 발생 확인
        assertThatThrownBy(() -> orderService.order(order))
                .isInstanceOf(RuntimeException.class)
                .hasMessage("시스템 예외");

        // then: 롤백되었으므로 데이터가 없어야 함
        Optional<Order> orderOptional = orderRepository.findById(order.getId());
        assertThat(orderOptional.isEmpty()).isTrue();
        log.info("시스템 예외 발생: 주문 데이터 롤백됨");
    }

    @Test
    void bizException() {
        // given: 비즈니스 예외 시나리오
        Order order = new Order("잔고부족");

        // when: 비즈니스 예외 발생
        try {
            orderService.order(order);
            fail("잔고 부족 예외가 발생해야 합니다.");
        } catch (NotEnoughMoneyException e) {
            // 비즈니스 예외 처리
            log.info("고객에게 잔고 부족을 알리고 별도의 계좌로 입금하도록 안내");
            log.info("예외 메시지: {}", e.getMessage());
        }

        // then: 데이터는 저장되어야 함
        Order findOrder = orderRepository.findById(order.getId()).get();
        assertThat(findOrder.getPayStatus()).isEqualTo("대기");
        log.info("비즈니스 예외 발생: 주문 데이터 저장됨, status={}", findOrder.getPayStatus());
    }
}

3.6. 테스트 실행 결과

3.6.1. 정상 시나리오 테스트 결과

order 호출
주문 저장 완료: id=1, username=정상
결제 프로세스 진입
정상 승인
결제 프로세스 완료
정상 주문 완료: id=1, status=완료

분석:

  • 모든 단계가 정상적으로 실행되었다.
  • 트랜잭션이 커밋되어 주문 데이터가 저장되었다.
  • 결제 상태가 '완료'로 올바르게 설정되었다.

3.6.2. 시스템 예외 시나리오 테스트 결과

order 호출
주문 저장 완료: id=2, username=예외
결제 프로세스 진입
시스템 예외 발생
시스템 예외 발생: 주문 데이터 롤백됨

분석:

  • 주문 저장까지는 정상적으로 진행되었다.
  • RuntimeException이 발생하여 스프링 트랜잭션 AOP가 이를 감지했다.
  • 기본 정책에 따라 트랜잭션이 롤백되었다.
  • 결과적으로 주문 데이터는 저장되지 않았다(데이터베이스에 존재하지 않음).

3.6.3. 비즈니스 예외 시나리오 테스트 결과

order 호출
주문 저장 완료: id=3, username=잔고부족
결제 프로세스 진입
잔고 부족 비즈니스 예외 발생
고객에게 잔고 부족을 알리고 별도의 계좌로 입금하도록 안내
예외 메시지: 잔고가 부족합니다
비즈니스 예외 발생: 주문 데이터 저장됨, status=대기

분석:

  • 주문이 저장되고 결제 상태가 '대기'로 설정되었다.
  • NotEnoughMoneyException(체크 예외)이 발생했다.
  • 기본 정책에 따라 트랜잭션이 커밋되었다.
  • 결과적으로 주문 데이터는 저장되었으며, 상태는 '대기'로 설정되었다.

3.7. 비즈니스 예외 처리의 중요성

위 예제에서 비즈니스 예외(NotEnoughMoneyException)가 발생했을 때 트랜잭션이 커밋되는 이유는 비즈니스적으로 매우 중요하다.

만약 롤백된다면 어떤 문제가 발생할까?

  1. 주문 데이터 소실: 고객이 주문을 했는데 아무런 기록이 남지 않는다.
  2. 고객 대응 불가: 고객에게 "잔고가 부족합니다. 다른 계좌로 입금해주세요"라고 안내할 수 없다.
  3. 비즈니스 흐름 단절: 주문→결제 실패→재시도라는 비즈니스 흐름이 끊어진다.

커밋함으로써 얻는 이점:

  1. 주문 기록 보존: 고객의 주문 의도가 데이터베이스에 기록된다.
  2. 고객 대응 가능: 주문 번호를 통해 고객에게 정확한 안내가 가능하다.
  3. 비즈니스 흐름 유지: "대기" 상태의 주문을 나중에 처리할 수 있다.
  4. 통계 자료 확보: 잔고 부족으로 인한 실패 건수를 분석할 수 있다.

4. 실무 적용 가이드

4.1. 예외 분류 전략

실무에서 효과적인 예외 처리를 위해서는 예외를 체계적으로 분류하는 전략이 필요하다.

4.1.1. 시스템 예외 vs 비즈니스 예외

구분   시스템 예외 비즈니스 예외
의미 복구 불가능한 시스템 문제 비즈니스 규칙 위반
예시 DB 연결 실패, 메모리 부족 잔고 부족, 중복 데이터
예외 타입 런타임 예외(RuntimeException) 체크 예외(Exception)
트랜잭션 롤백 커밋 (기본 정책)
처리 방식 로깅 후 상위로 전파 비즈니스 로직에서 처리

4.1.2. 예외 계층 구조 설계

// 시스템 예외 계층
public class SystemException extends RuntimeException {
    // 시스템 레벨의 예외
}

public class InfrastructureException extends SystemException {
    // 인프라 관련 예외 (DB, 네트워크 등)
}

public class DatabaseException extends InfrastructureException {
    // 데이터베이스 관련 예외
}

// 비즈니스 예외 계층
public class BusinessException extends Exception {
    // 비즈니스 레벨의 예외
}

public class ValidationException extends BusinessException {
    // 입력 검증 실패 예외
}

public class PaymentException extends BusinessException {
    // 결제 관련 예외
}

public class NotEnoughMoneyException extends PaymentException {
    // 잔고 부족 예외
}

4.2. 트랜잭션 전략 선택 가이드

4.2.1. 기본 정책 사용 시나리오

적합한 경우:

  • 비즈니스 예외는 체크 예외로, 시스템 예외는 런타임 예외로 명확히 구분된 경우
  • 대부분의 비즈니스 예외가 트랜잭션 커밋을 요구하는 경우
  • 팀 내에 일관된 예외 처리 규칙이 확립된 경우

예시:

@Transactional
public void processOrder(Order order) throws BusinessException {
    // 비즈니스 로직
    if (invalidCondition) {
        throw new BusinessException("비즈니스 규칙 위반");  // 체크 예외 → 커밋
    }

    if (systemError) {
        throw new SystemException("시스템 오류");  // 런타임 예외 → 롤백
    }
}

4.2.2. rollbackFor 옵션 사용 시나리오

적합한 경우:

  • 체크 예외지만 트랜잭션 롤백이 필요한 경우
  • 레거시 코드 통합 시 예외 타입이 일관되지 않은 경우
  • 특정 비즈니스 예외에 대해 다른 트랜잭션 동작이 필요한 경우

예시:

// 특정 비즈니스 예외는 롤백
@Transactional(rollbackFor = CriticalBusinessException.class)
public void criticalOperation() throws CriticalBusinessException {
    // 중요한 작업: 실패 시 롤백 필요
    if (criticalFailure) {
        throw new CriticalBusinessException("중요한 실패");  // 롤백
    }
}

// 모든 예외를 롤백 (엄격한 정책)
@Transactional(rollbackFor = Exception.class)
public void strictOperation() throws Exception {
    // 모든 실패는 롤백
    throw new AnyException();  // 롤백
}

4.2.3. noRollbackFor 옵션 사용 시나리오

적합한 경우:

  • 특정 런타임 예외는 비즈니스적으로 의미 있어 커밋해야 하는 경우
  • 특정 시스템 예외가 복구 가능한 경우
  • 예외를 통한 흐름 제어가 필요한 경우

예시:

// 특정 런타임 예외는 커밋
@Transactional(noRollbackFor = {IllegalArgumentException.class, IllegalStateException.class})
public void flexibleOperation() {
    if (invalidInput) {
        throw new IllegalArgumentException("잘못된 입력");  // 커밋
    }

    if (invalidState) {
        throw new IllegalStateException("잘못된 상태");  // 커밋
    }
}

4.3. 예외 처리 패턴

4.3.1. 예외 복구 패턴

@Service
@Transactional
@Slf4j
public class OrderService {

    public OrderResult processOrder(OrderRequest request) {
        try {
            return tryProcessOrder(request);

        } catch (NotEnoughMoneyException e) {
            // 비즈니스 예외 복구
            log.warn("잔고 부족: {}", request.getUserId());
            return OrderResult.failure("잔고가 부족합니다. 다른 결제 수단을 선택해주세요.");

        } catch (BusinessException e) {
            // 다른 비즈니스 예외 처리
            log.warn("비즈니스 예외: {}", e.getMessage());
            return OrderResult.failure(e.getMessage());

        } catch (Exception e) {
            // 시스템 예외 처리
            log.error("시스템 예외", e);
            throw new SystemException("시스템 오류가 발생했습니다.", e);
        }
    }

    @Transactional
    private OrderResult tryProcessOrder(OrderRequest request) throws BusinessException {
        // 실제 주문 처리 로직
        // 체크 예외를 던질 수 있음
    }
}

4.3.2 예외 전환 패턴

@Service
@Transactional(rollbackFor = Exception.class)  // 모든 예외 롤백
public class PaymentService {

    public void processPayment(PaymentRequest request) {
        try {
            // 외부 시스템 호출
            externalPaymentService.process(request);

        } catch (ExternalSystemException e) {
            // 외부 시스템 예외를 내부 예외로 전환
            log.error("외부 시스템 오류", e);
            throw new PaymentException("결제 시스템 오류", e);

        } catch (TimeoutException e) {
            // 타임아웃 예외 처리
            log.warn("결제 시스템 응답 시간 초과");
            throw new PaymentTimeoutException("결제 시스템 응답이 지연되고 있습니다", e);
        }
    }
}

4.4. 주의사항과 모범 사례

4.4.1. 흔한 실수와 해결책

실수 1: 체크 예외를 무시하는 catch 블록

// ❌ 잘못된 예
@Transactional
public void process() {
    try {
        someOperation();
    } catch (Exception e) {
        // 아무것도 하지 않음 (침묵하는 버그)
    }
}

// ✅ 올바른 예
@Transactional
public void process() {
    try {
        someOperation();
    } catch (BusinessException e) {
        log.warn("비즈니스 예외 발생", e);
        throw e;  // 다시 던져서 트랜잭션 정책 적용
    }
}

실수 2: 트랜잭션 경계 안에서 너무 많은 작업

// ❌ 잘못된 예
@Transactional
public void processLargeBatch() {
    // 수천 건의 데이터 처리
    for (int i = 0; i < 10000; i++) {
        repository.save(createData(i));
    }
    // 트랜잭션이 너무 길어져서 데드락이나 타임아웃 위험
}

// ✅ 올바른 예
public void processLargeBatch() {
    for (int i = 0; i < 10000; i++) {
        processSingleItem(i);  // 각 항목별 트랜잭션
    }
}

@Transactional
public void processSingleItem(int index) {
    repository.save(createData(index));
}

4.4.2 모범 사례 체크리스트

  • 예외 분류: 시스템 예외와 비즈니스 예외를 명확히 구분했는가?
  • 트랜잭션 범위: 트랜잭션이 너무 길지 않은가? (일반적으로 1초 이내 권장)
  • 예외 문서화: 메서드 시그니처에 던지는 예외를 명시했는가?
  • 롤백 테스트: 예외 발생 시 롤백이 정상 동작하는지 테스트했는가?
  • 로그 기록: 예외 발생 시 적절한 로그를 남겼는가?
  • 재시도 전략: 네트워크 오류 등에 대한 재시도 로직을 고려했는가?

5. 정리

5.1. 핵심 개념 요약

  1. 기본 정책: 스프링 트랜잭션 AOP는 예외 타입에 따라 자동으로 커밋/롤백을 결정한다.
    • 언체크 예외(RuntimeException, Error) → 롤백
    • 체크 예외(Exception) → 커밋
    • 정상 종료 → 커밋
  2. 정책의 논리적 근거:
    • 언체크 예외는 대부분 복구 불가능한 시스템 문제를 나타낸다.
    • 체크 예외는 비즈니스 의미가 있어 호출자가 처리해야 할 경우가 많다.
  3. 세밀한 제어 옵션:
    • rollbackFor: 특정 예외를 롤백하도록 지정
    • noRollbackFor: 특정 예외를 롤백하지 않도록 지정
  4. 실전 적용:
    • 비즈니스 예외는 트랜잭션 커밋을 통해 데이터 보존이 중요한 경우가 많다.
    • 시스템 예외는 트랜잭션 롤백을 통해 데이터 일관성을 유지한다.

5.2. 실무 적용 원칙

  1. 일관성 원칙: 팀 내에서 예외 분류와 트랜잭션 전략을 일관되게 적용한다.
  2. 명시성 원칙: @Transactional 애노테이션에 의도가 명확히 드러나도록 옵션을 사용한다.
  3. 단순성 원칙: 기본 정책으로 충분한 경우 불필요한 옵션을 추가하지 않는다.
  4. 테스트 원칙: 다양한 예외 상황에 대한 트랜잭션 동작을 반드시 테스트한다.

5.3. 마무리

트랜잭션과 예외 처리는 엔터프라이즈 애플리케이션 개발의 핵심 주제이다. 단순히 @Transactional을 붙이는 수준을 넘어, 예외의 종류에 따른 트랜잭션 동작 원리를 이해하고, 비즈니스 요구사항에 맞게 적절히 제어할 수 있어야 한다.

이 글에서 다룬 내용을 바탕으로 자신의 프로젝트에서:

  1. 현재의 예외 처리 전략을 평가해보고
  2. 비즈니스 예외와 시스템 예외를 명확히 구분하며
  3. 트랜잭션 동작이 비즈니스 요구사항을 정확히 반영하는지 확인해보기 바란다.

올바른 트랜잭션과 예외 처리 전략은 데이터의 정확성과 시스템의 안정성을 보장하는 기반이 된다. 이 기반 위에 견고한 비즈니스 로직을 구축할 수 있을 것이다.

 

'Spring > DB' 카테고리의 다른 글

[Advanced-3] 트랜잭션 전파(2): 활용  (0) 2026.01.04
[Advanced-2] 트랜잭션 전파(1): 기본  (0) 2026.01.04
[Basic-6] 스프링과 문제 해결 - 예외 처리, 반복  (0) 2026.01.03
[Basic-5] 자바 예외 이해  (0) 2026.01.03
[Basic-4] 스프링과 문제 해결 - 트랜잭션  (0) 2026.01.02
'Spring/DB' 카테고리의 다른 글
  • [Advanced-3] 트랜잭션 전파(2): 활용
  • [Advanced-2] 트랜잭션 전파(1): 기본
  • [Basic-6] 스프링과 문제 해결 - 예외 처리, 반복
  • [Basic-5] 자바 예외 이해
h6bro
h6bro
백엔드 개발자의 기술 블로그
  • h6bro
    Jun's Tech Blog
    h6bro
  • 전체
    오늘
    어제
    • 분류 전체보기 (250) N
      • Java (18)
        • Core (9)
        • Design Pattern (9)
      • Spring (80)
        • Core (24)
        • MVC (6)
        • DB (10)
        • JPA (26)
        • Monitoring (3)
        • Security (11)
        • WebSocket (0)
      • Database (33)
        • Redis (15)
        • MySQL (18)
      • MSA (25) N
        • MSA 기본 (11)
        • MSA 아키텍처 (14) N
      • Kafka (30)
        • Core (18)
        • Connect (12)
      • ElasticSearch (11)
        • Search (11)
        • Logging (0)
      • Test (4)
        • k6 (4)
      • Docker (9)
      • CI&CD (10)
        • GitHub Actions (6)
        • ArgoCD (4)
      • Kubernetes (18)
        • Core (12)
        • Ops (6)
      • Cloud Engineering (4)
        • AWS Infrastructure (3)
        • AWS EKS (1)
        • Terraform (0)
      • Project (8)
        • LinkFolio (1)
        • Secondhand Market (7)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

    • Cloud Engineering 포스팅 정리
  • 인기 글

  • 태그

    ㅈ
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.5
h6bro
[Advanced-1] 예외와 트랜잭션 커밋, 롤백
상단으로

티스토리툴바