Bldev's Blog

[스프링] 트랜잭션 관리

2024. 8. 11.

트랜잭션 관리

트랜잭션 관리의 목적은 데이터의 일관성과 무결성을 보장하는 것이다. 관리 방법은 프로그램 방식 트랜잭션 관리와 선언적 트랜잭션 관리 두 가지로 나뉜다.


트랜잭션 관리를 직접 프로그래밍하는 방식에서는 비즈니스 로직을 수행하는 메서드 내에 트랜잭션 관리 코드(트랜잭션 경계 설정)를 삽입하여 커밋과 롤백을 제어한다. AOP 관점에서 트랜잭션 관리는 일종의 공통 관심사(common aspect)이다. 선언적 트랜잭션 관리는 비즈니스 로직과 트랜잭션 관리 로직을 분리한다.

일련의 작업(쿼리 실행)을 수행하는 메서드 내에서 여러 쿼리 실행을 하나의 트랜잭션으로 실행할지 결정하여 쿼리 실행이 하나라도 실패하면 전체 쿼리 실행이 실패하도록 트랜잭션을 롤백하고 쿼리 실행이 모두 성공하면 트랜잭션을 커밋하도록 한다.


프로그램 방식 트랜잭션 관리

일련의 작업을 수행하는 메서드 내에서 작업이 모두 성공적으로 종료되면 commit() 메서드 실행을 실행하여 트랜잭션을 커밋하고 작업이 하나라도 실패한 경우 rollback() 메서드를 실행하여 트랜잭션을 롤백한다.

JDBC는 기본적으로 하나의 쿼리 실행이 성공하면 곧 바로 트랜잭션을 커밋하므로 원하는 작업에 대해 트랜잭션 관리를 하기 위해 자동 커밋 기능을 설정 해제해야 한다. Connection 객체의 setAutoCommit(false) 메서드를 호출하여 쿼리 실행 완료 시 트랜잭션을 자동으로 커밋하지 않도록 설정할 수 있다.

하나의 데이터 소스에 대해서는 commit() 메서드와 rollback() 메서드로 트랜잭션 관리가 쉽게 가능하지만 여러 데이터 소스에 대한 트랜잭션을 관리하거나 자바 EE 애플리케이션 서버의 내장 트랜잭션 관리 기능을 사용하기 위해서는 JTA(Java Transaction API) 사용이 필요하며 ORM 프레임워크마다 서로 다른 JTA를 사용해야 할 수도 있다.

프로그램 방식의 트랜잭션 관리는 트랜잭션 관리 코드가 특정 데이터 접근 기술에 종속되므로 기술을 변경하려면 관련된 코드를 모두 변경해야 하는 의존성 문제가 있다. 스프링은 데이터 접근 기술에 독립적으로 간편하게 트랜잭션 관리 작업을 할 수 있도록 트랜잭션 관리자 인터페이스(PlatformTransactionManager), 트랜잭션 템플릿 인터페이스(TransactionTemplate), 트랜잭션 선언 기능 등 특정 기술에 의존하지 않는 유용한 트랜잭션 관리 기능을 지원한다.

트랜잭션 관리자는 새로운 트랜잭션의 시작, 트랜잭션 커밋 및 롤백을 통해 트랜잭션을 관리하는 역할을 수행한다. 트랜잭션 템플릿은 트랜잭션 관리자를 통해 트랜잭션을 직접 시작, 커밋, 롤백하는 코드를 반복적으로 작성할 필요 없이 트랜잭션을 적용할 코드 블록을 인자로 전달하여 호출만 하면 트랜잭션을 자동으로 관리해주는 메서드를 제공함으로써 트랜잭션 관리를 보다 간편하게 할 수 있게 도와준다. 트랜잭션 템플릿은 또한 트랜잭션을 적용할 코드의 실행 도중 언체크 예외가 발생하거나 코드 블록 내에서 TransactionStatus의 setRollbackOnly() 메서드를 명시적으로 호출하면 트랜잭션를 롤백한다.

스프링이 제공하는, 트랜잭션 관리 기능의 추상화를 위한 인터페이스를 사용하면 트랜잭션 관리를 편리하게 할 수 있고 트랜잭션 관리 코드를 특정 기술에 독립적으로 작성할 수 있다.


스프링 트랜잭션 관리 인터페이스 (PlatformTransactionManager)

PlatformTransactionManager은 스프링 트랜잭션 관리자를 포괄한 인터페이스이다. 데이터 접근 기술에 독립적인 트랜잭션 관리 기능을 캡슐화하여 트랜잭션 관리를 추상화하는 핵심 인터페이스이다. PlatformTransactionManager 인터페이스는 TransactionDefinition 인터페이스와 TransactionStatus 인터페이스를 사용해 트랜잭션을 생성하고 관리한다. PlatformTransactionManager의 setDataSource() 메서드를 사용하여 트랜잭션을 관리할 데이터 소스를 지정할 수 있다.

스프링은 여러 데이터베이스 접근 구현 방식에 따라 적용 가능한 PlatformTransactionManager 인터페이스의 다양한 구현체들을 제공한다. JDBC로 하나의 데이터 소스에 접근하는 경우에는 DataSourceTransactionManager를 사용한다. 자바 EE 애플리케이션 서버에서 JTA로 트랜잭션을 관리할 경우, 서버에서 트랜잭션을 탐색하기 위해 JtaTransactionManager를 사용한다. 분산 트랜잭션(여러 리소스에 걸친 트랜잭션) 구현 시에도 JtaTransactionManager를 사용한다. ORM 프레임워크로 데이터베이스에 접근할 경우 HibernateTransactionManager나 JpaTransactionManager 등의 해당 프레임워크 트랜잭션 관리자를 사용한다.

PlatformTransactionManager를 사용한 트랜잭션 관리 코드 작성은 다음 순서로 수행한다.

  1. 일련의 작업(쿼리 실행)을 수행하는 메서드가 정의된 클래스(JdbcDaoSupport 상속)에 PlatformTransactionManager 타입 속성 선언 및 DI를 위한 Setter 메서드 정의
  2. 새 트랜잭션 시작 전 TransactionDefinition 타입 트랜잭션 정의 객체 초기화. 구현체로는 DefaultTransactionDefinition을 사용한다.
  3. 트랜잭션 정의 객체를 PlatformTransactionManager 객체의 getTransaction() 메서드의 인자로 전달하고 호출하여 트랜잭션 관리자에게 새 트랜잭션을 시작할 것을 요청: transactionManager.getTransaction(transactionDefinition)
  4. 쿼리가 모두 정상 실행되면 getTransaction() 메서드가 반환하는, 트랜잭션 상태 추적을 위한 TransactionStatus 객체(트랜잭션 상태)를 인자로 넘겨 트랜잭션 관리자에게 알림: transactionManager.commit(transactionStatus)
  5. 스프링 JDBC 템플릿에서 발생한 예외는 모두 DataAccessException 하위 타입이므로 해당 예외가 나면 트랜잭션 관리자가 트랜잭션을 롤백하도록 설정: transactionManager.rollback(transactionStatus)
  6. TransactionManager 구현체 빈 정의 시 적절한 트랜잭션 관리자 구현체 선택 및 데이터 소스 설정: transactionManager.setDataSource(dataSource())
  7. 트랜잭션 관리자를 PlatformTransactionManager로 선언하였으므로 빈 구성 클래스에서 JdbcDaoSupport 구현 객체 빈 정의 시 TransactionManager 구현체 빈을 JdbcDaoSupport 상속 클래스의 setTransactionManager() 메소드의 인자로 전달: JdbcDaoSupport상속객체.setTransactionManager(transactionManager()). setDataSource() 메소드의 인자로 데이터 소스 객체를 전달하는 것도 필요하다.

트랜잭션 템플릿 (TransactionTemplate)

트랜잭션 관리자를 사용하여 트랜잭션을 직접 관리하는 코드를 구현하는 경우 트랜잭션 관리 코드가 중복된다. 스프링이 제공하는 트랜잭션 템플릿(TransactionTemplate)을 사용하면 전체 트랜잭션 관리 프로세스 및 예외 처리를 효과적으로 할 수 있다. 트랜잭션 관리자를 통해 트랜잭션을 직접 실행하고 커밋 및 롤백하는 반복적인 코드 작성이 필요하지 않다.

TransactionTemplate 객체 생성을 위해서는 TransactionManager가 필요하다. 트랜잭션 템플릿은 트랜잭션이 적용될 코드 블록을 캡슐화한 트랜잭션 콜백 객체를 실행한다.

TransactionCallback<T> 인터페이스를 구현한 콜백 클래스에서 코드 블록을 캡슐화한 뒤 TransactionTemplate의 execute() 메서드에 전달한다. 트랜잭션 실행 결과 반환값이 있는 경우 콜백 객체의 반환값은 템플릿의 execute() 메서드가 반환한다. 트랜잭션 실행 결과 반환값이 필요 없는 경우 TransactionCallback<T> 인터페이스를 구현한 프레임워크 내장 객체인 TransactionCallbackWithoutResult 객체를 전달한다.

콜백 객체 실행이 정상적으로 종료되면 트랜잭션이 커밋되고, 콜백 객체를 실행하다가 언체크 예외(예: RuntimeException, DataAccessException)가 발생하거나, 명시적으로 doInTransactionWithoutResult() 메서드의 TransactionStatus 인자의 setRollbackOnly() 메서드를 호출하면 트랜잭션이 롤백된다.

TransactionTemplate을 사용한 트랜잭션 관리 코드 작성은 다음 순서로 수행한다.

  1. 일련의 작업(쿼리 실행)을 수행하는 메서드가 정의된 클래스(JdbcDaoSupport 상속)에 PlatformTransactionManager 타입 속성 선언 및 DI를 위한 Setter 메서드 정의
  2. 새 트랜잭션 시작 전 TransactionTemplate 트랜잭션 템플릿 객체 초기화
  3. TransactionCallback<T> 인터페이스를 구현한 콜백 클래스에서 코드 블록을 캡슐화한 뒤 TransactionTemplate의 execute() 메서드의 인자로 전달
  4. 빈 구성 클래스에서 TransactionTemplate 객체 빈 정의 시 TransactionManager 빈을 TransactionTemplate의 setTransactionManager() 메소드의 인자로 전달: transactionTemplate.setTransactionManager(transactionManager())
  5. 빈 구성 클래스에서 JdbcDaoSupport 구현 객체 빈 정의 시 TransactionTemplate 빈을 JdbcDaoSupport 상속 클래스의 setTransactionManager() 메소드의 인자로 전달: JdbcDaoSupport상속객체.setTransactionTemplate(transactionTemplate()). 트랜잭션 관리자 대신 트랜잭션 템플릿을 주입하며, setDataSource() 메소드로 데이터 소스 객체도 전달해야 한다.

트랜잭션 템플릿 객체는 스레드-안전한 객체이므로 트랜잭션이 적용된 여러 빈에 걸쳐 사용될 수 있다.


선언적 트랜잭션 관리

트랜잭션을 적용할 메서드 또는 클래스에 @Transactional을 추가하여 선언적으로 트랜잭션을 관리할 수 있다. 이렇게 관리되는 트렌잭션을 스프링 관리(spring-managed) 또는 컨테이너 관리(container-managed) 관리 트랜잭션이라고도 한다. 메서드에 @Transactional만 붙이면 트랜잭션이 걸린 메서드로 선언된다. 클래스에 적용하면 해당 클래스의 모든 public 메서드에 트랜잭션이 적용된다. 인터페이스도 클래스 및 메서드 레벨에 @Transactional을 붙일 순 있지만 클래스 기반 프록시(예: CGLIB 프록시)에서는 제대로 작동하지 않을 수 있으므로 권장하지 않는다.

선언적 트랜잭션 관리는 런타임에 적용되기 때문에 애플리케이션 코드는 트랜잭션 관리에 의존하지 않게 된다. 즉, 트랜잭션은 교차(횡단) 관심사가 된다.

@Transactional 어노테이션 기반 선언전 트랜잭션 관리를 위해 빈 구성 클래스에는 @EnableTransactionManegement을 붙여 트랜잭션 관리 활성화 설정을 해야 한다. IoC 컨테이너에 선언된 빈들을 찾아 @Transactional을 붙인 메서드(또는 이 어노테이션을 붙인 클래스의 모든 메서드) 중 public 메서드를 가져와 어드바이스를 적용한다.

어노테이션을 사용한 선언적 트랜잭션 관리의 경우 스프링 AOP가 프록시 기반으로 작동하는 한계(IoC 컨테이너에 선언된 빈의 public 메서드에만 어드바이스를 적용할 수 있다) 때문에 public 메서드에만 적용 가능하다. 따라서 public 메서드 스코프로 트랜잭션 관리가 국한되는 한계를 해결하고 public 외의 메서드나 IoC 컨테이너 외부에서 만든 객체의 메서드에 대해서도 트랜잭션을 관리해야 할 경우에는 AnnotationTransactionAspect라는 AspectJ 애스팩트를 사용해야 한다.


트랜잭션 속성 설정

트랜잭션은 전파(propagation), 격리 수준(isolation level), 타임아웃, 롤백 규칙, 읽기 전용(read-only) 트랜잭션 여부 등의 속성을 설정할 수 있다. 스프링은 트랜잭션의 모든 관련 설정을 TransactionDefinition 인터페이스로 캡슐화한다. TransactionDefinition 인터페이스는 트랜잭션을 관리하는 인터페이스인 PlatformTransactionManager에서 사용된다.

PlatformTransactionManager의 핵심 메서드인 getTransaction()은 TransactionDefinition 인터페이스를 인자로 전달받고 TransactionStatus 인터페이스를 반환한다. TransactionStatus 인터페이스는 트랜잭션 실행을 제어하기 위해 트랜잭션의 상태 정보를 확인하기 위해 사용된다. 이 인터페이스를 통해 트랜잭션 결과를 설정하고 트랜잭션의 완료 여부, 새 트랜잭션인지의 여부를 확인할 수 있다.


트랜잭션 전파 속성 설정

트랜잭션이 적용된 메서드(작업 메서드)를 다른 메서드(요청 메서드)가 호출하면 트랜잭션 전파가 일어난다. 호출한 메서드 역시 기존 트랜잭션 내에서 실행할지, 아니면 트랜잭션을 하나 더 생성해 자신만의 고유한 트랜잭션에서 실행할지 결정이 필요하다. 기존 트랜잭션을 그대로 적용하는 경우(요청 메서드에서 작업 메서드로 트랜잭션이 전달되는 경우) 작업 메서드에서 쿼리 실행이 실패하면 요청 메서드의 트랜잭션이 롤백된다. 기존 트랜잭션을 적용하지 않고 새로운 트랜잭션을 시작하는 경우 작업 메서드에서 쿼리 실행이 실패해도 요청 메서드의 트랜잭션이 롤백되지 않는다.

트랜잭션 전파 방식은 @Transactional의 propagation 트랜잭션 속성에 명시한다.

@Transactional(propagation = Propagation.전파속성)

스프링 org.springframework.transaction.TransactionDefinition 인터페이스에는 일곱 가지 트랜잭션 전파 방식이 정의되어 있다.

기본 전파 방식인 REQUIRED는 진행 중인 트랜잭션이 있으면 현재 메서드를 그 트랜잭션에서 실행하고, 없으면 새 트랜잭션을 시작해서 실행한다. REQUIRES_NEW는 항상 새 트랜잭션을 시작하여 현재 메서드를 실행하고, 진행 중인 트랜잭션이 있으면 잠시 중단시킨다.

SUPPORTS는 진행 중인 트랜잭션이 있으면 그 트랜잭션 내에서 실행하고, 없으면 트랜잭션 없이 실행한다. NOT_SUPPORTED는 트랜잭션 없이 현재 메서드를 실행하고, 진행 중인 트랜잭션이 있으면 잠시 중단시킨다.

MANDATORY는 반드시 트랜잭션 안에서 실행하며 진행 중인 트랜잭션이 없으면 예외를 던진다. 반대로 NEVER는 반드시 트랜잭션 없이 실행하며 진행 중인 트랜잭션이 있으면 예외를 던진다.

NESTED는 진행 중인 트랜잭션이 있으면 현재 메서드를 그 트랜잭션의 중첩 트랜잭션(nested transaction) 안에서 실행한다. 중첩 트랜잭션은 JDBC 3.0의 세이브포인트(savepoint) 기능이 있어야 가능하다. 진행 중인 트랜잭션이 없으면 새 트랜잭션을 시작해서 실행한다. 다른 전파 방식들이 자바 EE의 트랜잭션 전달 방식과 유사한 반면 이 방식은 스프링에서만 가능하다. 장시간 실행되는 업무를 처리하면서 배치 실행 도중 끊어서 커밋하는 경우에 유용하며, 중간에 작업이 잘못되어도 중첩 트랜잭션을 롤백하면 끊어서 실행한 분량의 작업만 소실된다.


모든 트랜잭션 관리자 구현체가 이들 전파 방식을 전부 지원하는 건 아니며 하부 리소스에 따라 달라질 수도 있다. 트랜잭션 관리자가 다양한 전파 방식을 지원한다 해도 데이터베이스가 지원하는 격리 수준에 따라 영향을 받을 수밖에 없다.


트랜잭션 격리 속성 설정

서로 다른 트랜잭션이 동일한 데이터에 접근하는 경우 여러 문제가 발생할 수 있으므로 트랜잭션들은 서로 격리되어야 한다. 하나의 트랜잭션에서 일련의 쿼리를 실행하고 있는 도중에 다른 트랜잭션이 데이터를 수정하는 경우 동일한 쿼리 실행 결과가 서로 다르게 나타나는 문제가 발생하게 된다. 이러한 문제가 발생하면 데이터의 일관성(inconsistency)이 낮아진다.

트랜잭션 격리가 일어나지 않는 경우 발생할 수 있는 문제는 크게 네 가지이며 트랜잭션의 격리는 그 수준에 따라 네 가지 문제 중 일부를 막을 수 있게 해준다.

트랜잭션 격리가 일어나지 않는 경우 발생할 수 있는 네 가지 문제는 다음과 같다.

  1. 오염된 값 읽기 (dirty read): 트랜잭션1이 데이터를 수정하였지만 커밋을 하기 전 상태에서 트랜잭션2가 수정된 데이터를 읽고 작업을 수행하는 도중 트랜잭션1이 롤백되면 트랜잭션2가 읽은 데이터는 일시적인 값으로 더 이상 유효하지 않다.
  2. 재현 불가능한 읽기 (nonrepeatable ead): 트랜잭션1이 하나의 데이터를 두 번 읽을 때, 첫 번째로 데이터를 읽은 후 트랜잭션2가 해당 데이터를 수정하고 커밋을 한 경우 트랜잭션1이 동일한 데이터를 다시 읽으면 이전의 데이터와는 다른 데이터를 얻게 된다.
  3. 허상 읽기 (phantom read): 트랜잭션1이 쿼리(여러 데이터를 조회하는 작업 수행)를 두 번 실행할 때, 첫 번째로 실행 후 트랜잭션2가 데이터를 삽입하거나 삭제하고 커밋한 경우 트랜잭션1이 두 번째로 실행하면 트랜잭션2가 삽입한 데이터가 보이거나 삭제한 데이터가 보이지 않게 된다.
  4. 소실된 수정 (lost update): 트랜잭션1과 트랜잭션2가 동일한 데이터를 읽고 수정하려는 경우 트랜잭션1이 먼저 데이터를 수정하고 커밋을 하기 전에 트랜잭션2가 동일한 데이터를 수정하였고 트랜잭션1이 커밋한 후에 트랜잭션2도 커밋을 하게 되면 트랜잭션1이 수정한 데이터를 트랜잭션2가 덮어쓰게 되어 트랜잭션1이 수정한 데이터가 소실된다.

이러한 문제를 해결하기 위해 트랜잭션을 격리해야 하며 격리 수준에 따라 데이터의 일관성이 달라지게 된다. 격리 수준이 높을수록 데이터의 일관성이 높아진다.

트랜잭션 격리 수준은 다음과 같다(낮은 순서대로).

  1. 커밋되지 않은 데이터 읽기 (read uncommitted): 다른 트랜잭션이 아직 커밋하지 않은(uncommitted) 값을 한 트랜잭션이 읽을 수 있다. 읽기 락은 발생하지 않지만 쓰기 락은 발생 가능하다. 쓰기 락은 트랜잭션 종료까지 유지된다. 이 격리 수준에서는 오염된 값 읽기, 재현 불가한 읽기, 허상 읽기 문제가 발생 가능하다.
  2. 커밋된 데이터 읽기 (read committed): 한 트랜잭션이 다른 트랜잭션이 커밋한(committed) 값만 읽을 수 있다. 읽기 락과 쓰기 락 모두 발생 가능하다. 읽기 락은 작업이 완료되면 해제되지만 쓰기 락은 트랜잭션이 종료될 때까지 유지된다. 이 격리 수준에서는 오염된 값 읽기 문제를 해결할 수 있지만, 재현 불가한 읽기, 허상 읽기 문제는 발생 가능하다.
  3. 재현 가능한 읽기 (repeatable read): 트랜잭션이 데이터를 여러 번 읽어도 동일한 데이터를 읽도록 보장하며 읽기 작업 트랜잭션이 지속되는 동안에 다른 트랜잭션은 해당 데이터의 삽입은 가능하지만 변경, 삭제를 할 수 없다. 읽기 락과 쓰기 락 모두 발생 가능하다. 읽기 락과 쓰기 락 모두 트랜잭션이 종료될 때까지 유지된다. 이 격리 수준에서는 오염된 값 읽기, 재현 불가한 읽기 문제를 해결할 수 있지만, 허상 읽기 문제는 발생 가능하다.
  4. 직렬화 가능한 읽기 (serializable read): 트랜잭션이 테이블을 여러 번 읽어도 정확히 동일한 로우를 읽도록 보장하며 읽기 작업 트랜잭션이 지속되는 동안에는 다른 트랜잭션이 해당 테이블에 삽입, 변경, 삭제를 할 수 없다. 읽기 락과 쓰기 락 모두 발생 가능하다. 이 때 두 락은 로우 레벨에 대한 락이다. 읽기 락과 쓰기 락 모두 트랜잭션이 종료될 때까지 유지된다. 이 격리 수준은 동시성 문제를 모두 해결하지만 성능이 현저히 떨어진다.

재현 가능한 읽기와 직렬화 가능한 읽기 격리 수준의 차이는 트랜잭션이 지속되는 도중 삽입을 할 수 있는지 그렇지 않은지이다.

트랜잭션 격리 수준은 @Transactional의 isolation 트랜잭션 속성에 다음과 같이 명시한다.

@Transactional(isolation = Isolation.격리속성)

스프링 org.springframework.transaction.TransactionDefinition 인터페이스에는 DEFAULT, READ_UNCOMMITTED, READ_COMMITTED, REPEATABLE_READ, SERIALIZABLE 다섯 가지 격리 수준이 정의되어 있다. DEFAULT는 데이터베이스의 기본 격리 수준을 사용하며, 대다수 데이터베이스의 기본 격리 수준은 커밋된 데이터 읽기이다. 나머지 네 가지는 앞의 격리 수준에 차례로 대응한다.


트랜잭션 롤백 속성 설정

기본 롤백 규칙

선언적 트랜잭션에서 롤백 여부는 트랜잭션 메서드를 빠져나오는 예외를 기준으로 결정된다. 스프링의 트랜잭션 인프라는 호출 스택을 따라 전파되는 처리되지 않은 예외를 프록시(트랜잭션 어드바이스)에서 잡아 롤백 여부를 판단한다. 기본 설정에서 메서드가 정상 반환하면 커밋하고, RuntimeException이나 그 하위 타입(언체크 예외), Error나 그 하위 타입이 빠져나오면 롤백한다. RuntimeException이 아닌 Exception 하위 타입(예: IOException), 즉 체크 예외가 빠져나오면 롤백하지 않고 커밋한다.

체크 예외에서 "커밋된다"는 것은 예외가 사라진다는 뜻이 아니다. 예외는 호출자에게 그대로 전달되지만, 그때까지 실행한 DB 변경은 롤백되지 않고 확정된다. 스프링은 기본 설정에서 RuntimeException과 Error만 롤백 대상으로 표시하므로, Exception을 직접 던지는 경우도 RuntimeException이 아니라면 체크 예외로 취급되어 커밋된다.

예외 종류별로 흐름을 비교하면 다음과 같다.

언체크 예외 (RuntimeException, Error)
체크 예외 (기본 설정)
체크 예외 + rollbackFor

체크 예외를 롤백 대상으로 삼으려면 롤백 규칙을 지정한다. 규칙은 예외 타입 또는 클래스 이름 패턴으로 지정하며, 지정한 타입과 같거나 그 하위 타입이면 일치한다.

@Transactional(rollbackFor = Exception.class)                 // 모든 예외에서 롤백
@Transactional(noRollbackFor = DuplicateKeyException.class)   // 이 예외는 롤백하지 않음
@Transactional(rollbackForClassName = "PaymentException")     // 클래스 이름(부분 문자열) 패턴

예외를 던지지 않고 롤백을 지시해야 한다면 TransactionStatus의 setRollbackOnly()를 호출한다. 다만 스프링 트랜잭션 API에 코드가 종속되므로 예외를 던지는 방식을 권장한다.

TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();

논리 트랜잭션과 물리 트랜잭션

전파 속성에 따른 롤백 동작을 이해하려면 두 개념을 구분해야 한다. 물리 트랜잭션은 실제 DB 커넥션에서 시작되고 커밋/롤백되는 트랜잭션이다. 논리 트랜잭션은 @Transactional이 붙은 메서드마다 생성되는 트랜잭션 범위(scope)이다.

REQUIRED에서는 바깥 메서드와 안쪽 메서드가 각각 논리 트랜잭션을 가지지만 모두 하나의 물리 트랜잭션에 매핑된다. 실제 커밋/롤백은 최초로 트랜잭션을 시작한 가장 바깥 논리 트랜잭션만 수행한다. 안쪽 논리 트랜잭션은 정상 종료해도 커밋하지 않고, 기본 설정에서는 예외로 종료하면 물리 트랜잭션에 롤백 전용(rollback-only) 표시만 남긴다. 이 표시는 한 번 남기면 되돌릴 수 없다.

논리 트랜잭션이 필요한 이유

하위 메서드에 @Transactional이 없으면 하위의 쿼리는 상위가 연 커넥션을 그대로 사용하므로 상위 트랜잭션에 포함된다. 이때 하위 호출에는 트랜잭션 프록시가 개입하지 않아 하위만의 트랜잭션 범위가 생기지 않고, 하위가 던진 예외는 상위의 롤백 규칙으로만 판단된다.

하위에도 @Transactional이 있으면 하위 호출이 프록시를 거치고, 스프링은 하위가 선언한 속성을 따로 해석한다. 메서드는 자신이 단독으로 호출될지 다른 트랜잭션 안에서 호출될지 알 수 없다. 단독으로 호출되면 물리 트랜잭션을 시작하고 직접 커밋해야 하고, 다른 트랜잭션 안에서 호출되면 그 트랜잭션에 참여해야 한다. 그래서 스프링은 메서드마다 선언을 담은 논리 범위를 만들고, 그 범위를 물리 트랜잭션에 어떻게 연결할지를 전파 속성으로 정한다.

논리 범위 단위로 결정되는 것은 여러 가지다. 먼저 소유권이 정해진다. 물리 트랜잭션을 새로 시작한 범위만 실제로 커밋/롤백하고, 참여한 범위가 정상 종료해도 물리 커밋은 일어나지 않는다. 전파 속성에 따라 기존 트랜잭션에 참여할지, 중단하고 새로 시작할지, 트랜잭션 없이 실행할지, 예외를 낼지도 이 범위에서 결정된다. REQUIRES_NEW와 NOT_SUPPORTED는 바깥 트랜잭션의 커넥션을 잠시 분리했다가 하위가 끝나면 다시 연결한다.

속성 적용도 범위에 따라 달라진다. isolation, readOnly, timeout은 새 물리 트랜잭션을 시작할 때만 적용되고, 기존 트랜잭션에 참여하는 범위의 설정은 기본적으로 무시된다. 트랜잭션 관리자의 validateExistingTransaction을 true로 두면 불일치하는 참여를 거부한다. 롤백 판단은 그 메서드의 롤백 규칙(rollbackFor, noRollbackFor)으로 이루어진다. 새로 시작한 범위는 바로 롤백하고, 참여한 범위는 롤백 전용 표시를 남긴다. afterCommit 같은 트랜잭션 동기화 콜백은 동기화를 시작한 범위가 끝날 때 실행된다.

참여 범위의 정상 종료는 커밋이 아니다

안쪽 REQUIRED 메서드가 insert 후 정상 반환해도 바깥 메서드가 진행 중이면 그 변경은 커밋되지 않은 상태다. 같은 트랜잭션의 커넥션에서는 보이지만 다른 커넥션에서는 보이지 않는다. 이후 바깥이 롤백되면 안쪽 변경도 함께 롤백된다.

완료 콜백의 실행 시점

TransactionSynchronization의 afterCommit 콜백은 등록된 범위가 속한 트랜잭션 동기화에 묶이고, 그 트랜잭션이 커밋된 뒤에 실행된다. @TransactionalEventListener(phase = AFTER_COMMIT)도 이벤트를 발행하는 시점에 현재 트랜잭션에 동기화를 등록하므로 같은 규칙을 따른다. 따라서 콜백을 등록하거나 이벤트를 발행한 범위에 따라 실행 시점이 달라진다.

REQUIRED로 참여한 하위에서 등록하면 afterCommit은 바깥 트랜잭션이 커밋된 뒤에 실행된다. 바깥이 이후 롤백되면 afterCommit은 실행되지 않고 afterCompletion만 롤백 상태로 실행된다. 반면 REQUIRES_NEW 하위에서 등록하면 하위 트랜잭션이 커밋된 직후, 바깥 메서드로 돌아가기 전에 실행된다. 바깥이 이후 롤백되어도 이미 실행된 뒤이며 하위의 변경도 남는다. REQUIRES_NEW 하위 자체가 롤백되면 그 범위의 afterCommit은 실행되지 않는다.

참여 실패 시 롤백 전용 표시 설정

참여한 범위가 예외로 끝났을 때 물리 트랜잭션 전체에 롤백 전용 표시를 남기는 것은 트랜잭션 관리자의 globalRollbackOnParticipationFailure 설정(기본값 true)에 따른 동작이다. 하위 REQUIRED 메서드가 예외를 던지고 상위가 이를 try-catch로 처리한 뒤 정상 종료하는 경우, 설정에 따라 결과가 다르다.

설정 하위의 동작 호출자가 받는 것 결과
true (기본) 예외를 던짐 UnexpectedRollbackException 전체 롤백
false 예외를 던짐 예외 없음 상위와 하위 변경 모두 커밋
true 또는 false 예외 없이 setRollbackOnly() 호출 UnexpectedRollbackException 전체 롤백

false로 두면 하위의 실패를 상위가 처리하고 롤백 여부를 결정할 수 있다. 다만 이 설정은 예외로 인한 참여 실패에만 적용되며, 명시적인 setRollbackOnly() 호출은 설정과 관계없이 전체 롤백으로 이어진다. 하위의 실패만 되돌리고 상위를 계속 진행하려면 이 설정보다 NESTED(세이브포인트)나 REQUIRES_NEW를 사용하는 편이 의도를 드러내기 쉽다.


전파 속성별 예외 처리와 롤백

아래 예시에서 OuterService.outer()가 상위 메서드, InnerService.inner()가 하위 메서드이다. 두 메서드는 서로 다른 빈에 있어 프록시를 거쳐 호출된다. 하위 메서드의 실패는 외부 결제 호출(paymentGateway.charge())이 PaymentFailedException(RuntimeException)을 던지는 상황으로 가정한다.

@Service
@RequiredArgsConstructor
public class OuterService {
    private final InnerService innerService;
    private final OrderRepository orderRepository;

    @Transactional
    public void outer() {
        orderRepository.save(new Order("outer"));   // (1) 상위 로직
        innerService.inner();                        // (2) 하위 로직
    }
}

@Service
public class InnerService {
    private final LogRepository logRepository;
    private final PaymentGateway paymentGateway;

    @Transactional(propagation = Propagation.REQUIRED)   // 전파 속성을 바꿔가며 비교
    public void inner() {
        logRepository.save(new Log("inner"));   // 하위 로직 (DB 변경)
        paymentGateway.charge();                // 외부 결제 호출. 실패하면 PaymentFailedException 발생
    }
}

롤백 판단은 예외가 어디서 발생했는지가 아니라 프록시를 빠져나온 예외의 타입으로 결정된다.

1) REQUIRED: 하위 예외를 상위가 처리하지 않은 경우

결제 실패로 inner()가 던진 PaymentFailedException이 outer()까지 전파된다. 하위 프록시가 물리 트랜잭션에 롤백 전용 표시를 하고, 상위 프록시도 예외를 받아 롤백을 수행한다. (1)과 (2)의 변경이 모두 롤백된다.

2) REQUIRED: 하위 예외를 상위가 try-catch로 처리한 경우 (주의)
@Transactional
public void outer() {
    orderRepository.save(new Order("outer"));
    try {
        innerService.inner();   // 예외 발생
    } catch (PaymentFailedException e) {
        log.warn("inner 실패, 무시하고 계속 진행", e);
    }
}

상위에서 예외를 삼켰더라도 inner()의 프록시는 이미 물리 트랜잭션에 롤백 전용 표시를 남겼다. outer()가 정상 종료되어 커밋을 시도하면 스프링은 롤백 전용 표시를 확인하고 전체를 롤백한 뒤 UnexpectedRollbackException을 던진다.

(1) 상위 로직과 (2) 하위 로직이 모두 롤백되며, 예외를 잡았다고 상위 로직만 커밋되지는 않는다. 호출자가 커밋이 성공했다고 오해하지 않도록 하려는 의도된 동작이다. 이 예외는 outer()를 호출한 쪽(프록시 바깥)에서 발생하므로 outer() 내부의 catch 블록에서는 잡을 수 없다. 이는 트랜잭션 관리자의 globalRollbackOnParticipationFailure가 기본값 true일 때의 동작이며, false로 두면 롤백 전용 표시가 남지 않아 상위와 하위 변경이 모두 커밋된다.

3) REQUIRES_NEW: 하위를 독립 트랜잭션으로 분리
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void inner() { logRepository.save(new Log("inner")); paymentGateway.charge(); }   // 결제 실패 시 PaymentFailedException

inner()는 기존 트랜잭션을 일시 중단하고 별도의 물리 트랜잭션(별도 커넥션)으로 실행된다. 두 트랜잭션은 서로의 롤백 상태에 영향을 받지 않는다. 하위가 예외를 던지면 하위는 롤백되고, 상위가 그 예외를 try-catch로 처리하면 상위는 UnexpectedRollbackException 없이 커밋된다. 상위가 예외를 처리하지 않고 전파하면 예외가 상위 프록시에 도달하므로 상위도 롤백된다.

하위가 정상적으로 끝난 뒤 상위에서 예외가 나면, 하위는 이미 커밋되었으므로 상위가 롤백되어도 하위 결과는 되돌아가지 않는다. 감사 로그, 실패 이력 저장처럼 상위 결과와 무관하게 남겨야 하는 작업에 적합하다.

주의할 점도 있다. 상위 트랜잭션의 커넥션을 유지한 채 하위가 새 커넥션을 획득하므로 스레드당 커넥션이 2개 필요하다. 동시 요청이 많고 커넥션 풀이 작으면 풀 고갈과 데드락이 발생할 수 있으므로, 풀 크기는 동시 스레드 수보다 최소 1 이상 커야 한다. 상위가 이미 락을 잡은 로우를 하위가 수정하려 하면 하위가 상위의 커밋을 기다리게 되어 자기 자신과 데드락에 빠진다. 또한 하위가 독립 트랜잭션이므로 상위에서 아직 커밋되지 않은 데이터는 하위에서 보이지 않는다.

4) NESTED: 세이브포인트 기반 부분 롤백
@Transactional(propagation = Propagation.NESTED)
public void inner() { logRepository.save(new Log("inner")); paymentGateway.charge(); }   // 결제 실패 시 PaymentFailedException

NESTED는 하나의 물리 트랜잭션 안에서 JDBC 세이브포인트를 만들어 하위를 실행한다. 하위가 실패하면 세이브포인트까지만 롤백하고 상위는 트랜잭션을 계속 진행할 수 있다. 상위가 하위의 예외를 try-catch로 처리하면 하위 변경분만 빠진 채 상위가 커밋되고, 상위로 전파하면 전체가 롤백된다.

REQUIRES_NEW와 달리 하위는 상위 롤백에 종속된다. 하위가 정상적으로 끝났더라도 이후 상위에서 예외가 나면 하위 변경분도 함께 롤백되며, 하위 변경분은 상위가 커밋될 때 비로소 확정된다.

NESTED는 DataSourceTransactionManager(JDBC)에서 지원한다. JpaTransactionManager는 nestedTransactionAllowed 기본값이 false이므로 NestedTransactionNotSupportedException이 발생할 수 있어 별도 설정과 JPA 구현체, JpaDialect 지원 여부 확인이 필요하다. JTA 환경에서는 지원되지 않는다.

5) 그 밖의 전파 속성

SUPPORTS는 상위 트랜잭션이 있으면 REQUIRED와 동일하게 참여하므로 하위 예외 시 롤백 전용 표시를 남기고, 상위 트랜잭션이 없으면 트랜잭션 없이 실행된다. MANDATORY는 상위 트랜잭션에 참여하며 상위 트랜잭션이 없으면 예외를 던진다. NEVER는 상위 트랜잭션이 있으면 예외를 던진다.

NOT_SUPPORTED는 상위 트랜잭션을 중단하고 트랜잭션 없이 실행한다. 하위 예외가 상위에 전파되면 상위는 롤백되지만, 하위의 DB 변경은 즉시 반영되었으므로 되돌릴 수 없다.


상위 메서드의 예외 처리 방식 정리

하위 전파 하위가 예외를 던지고 상위가 catch로 삼킴 하위가 예외를 던지고 상위로 전파
REQUIRED 전체 롤백 + UnexpectedRollbackException 전체 롤백
REQUIRES_NEW 하위만 롤백, 상위 커밋 둘 다 롤백 (상위는 예외를 받았으므로)
NESTED 하위만 롤백, 상위 커밋 전체 롤백

하위의 실패를 상위가 무시하고 계속 진행해야 한다면 REQUIRES_NEW 또는 NESTED를 사용하거나, 하위 트랜잭션 메서드가 예외를 던지지 않도록 설계한다. 하위가 REQUIRED인 채로 예외를 삼키는 구조는 대부분 의도와 다르게 전체가 롤백되므로 피해야 한다.


하위 메서드 내부에서 예외를 처리한 경우

예외를 던진 메서드와 try-catch로 처리하는 메서드가 같다면 트랜잭션 프록시는 예외를 보지 못한다.

@Transactional
public void inner() {
    logRepository.save(new Log("inner"));
    try {
        paymentGateway.charge();
    } catch (PaymentFailedException e) {
        // 프록시 입장에서는 정상 종료 → 커밋 대상
    }
}

프록시를 빠져나온 예외만 롤백 판단 대상이므로, 내부에서 삼킨 예외는 롤백 전용 표시를 남기지 않는다. 단, catch 안에서 예외를 삼켰더라도 이미 실행된 SQL이 DB 제약조건 위반 등으로 실패한 경우에는 문제가 남는다. JPA에서는 영속성 컨텍스트가 불안정해질 수 있고, DB가 해당 트랜잭션을 이후 쿼리를 받지 않는 상태(예: PostgreSQL의 aborted 상태)로 만들 수 있어 이후 쿼리가 실패한다. 로직상 롤백이 필요한데 예외를 삼켜야 한다면 catch 안에서 TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()를 호출한다.

예외가 하위 메서드의 @Transactional 경계를 넘을 때 하위 프록시가 롤백 전용 표시를 남기는지는 하위의 롤백 규칙과 트랜잭션 관리자의 globalRollbackOnParticipationFailure 설정(기본값 true)으로 결정된다. 예를 들어 하위가 @Transactional(noRollbackFor = BusinessException.class)이면 BusinessException이 전파되어도 하위 프록시는 롤백 전용 표시를 남기지 않는다. 다만 상위가 그 예외를 처리하지 않아 상위 경계까지 전파되면 상위의 롤백 규칙으로 다시 판단된다.


체크 예외와 롤백 규칙의 조합

기본 규칙상 체크 예외는 커밋이므로 전파 속성과 조합할 때 주의가 필요하다.

@Transactional
public void outer() throws Exception {
    orderRepository.save(new Order("outer"));
    innerService.inner();          // 결제 실패 시 체크 예외 PaymentCheckedException 발생 (rollbackFor 없음)
}

inner()에 rollbackFor가 없으면 체크 예외에서 하위는 롤백 전용 표시를 남기지 않고, 상위로 전파된 예외도 체크 예외이므로 상위 역시 커밋된다. 하위의 변경분도 함께 커밋된다. 반대로 하위에만 rollbackFor = Exception.class를 지정하면 하위가 롤백 전용 표시를 남기고, 상위에서 체크 예외를 throws로 전파하더라도 상위 규칙(롤백 대상 아님)에 따라 커밋을 시도하다 UnexpectedRollbackException이 발생한다. 따라서 상위, 하위 메서드의 rollbackFor 정책을 일관되게 맞추거나, 예외 체계를 언체크 예외 중심으로 설계하는 것이 안전하다.

체크 예외와 언체크 예외를 구분하지 않고 모두 롤백하려면 rollbackFor = Exception.class를 지정한다. 롤백 규칙은 메서드마다 따로 적용되므로, 바깥 메서드에 지정해도 REQUIRES_NEW 같은 안쪽 트랜잭션 메서드에는 적용되지 않는다. 반복을 줄이려면 클래스 레벨에 지정한다. 클래스 레벨 설정은 메서드 레벨에서 다시 지정하면 덮어쓴다.

@Service
@Transactional(rollbackFor = Exception.class)   // 클래스의 모든 public 메서드에 적용
public class OrderService {

    public void order() throws Exception { ... }

    @Transactional(rollbackFor = Exception.class, noRollbackFor = StockShortageException.class)
    public void reserve() throws Exception { ... }   // 이 예외만 커밋
}

업무상 정상 흐름으로 쓰는 체크 예외까지 롤백될 수 있으므로 해당 예외는 noRollbackFor로 제외한다. rollbackFor = Exception.class를 메타 어노테이션으로 묶은 커스텀 어노테이션을 만들어 프로젝트 전체에 같은 규칙을 적용할 수도 있다.


프록시 기반 동작의 한계

스프링은 기본적으로 프록시 모드로 트랜잭션을 적용한다. 따라서 프록시를 통과하는 외부 호출에만 트랜잭션 어드바이스가 적용된다.

같은 클래스 내부에서 this.inner()를 호출하는 자기 호출(self-invocation)은 프록시를 거치지 않으므로 inner()에 붙은 REQUIRES_NEW, NESTED, rollbackFor 등의 설정이 전혀 적용되지 않는다. 별도 빈으로 분리하는 방식이 가장 단순한 해결책이다. @PostConstruct 등 프록시 초기화 전 코드에서도 트랜잭션이 적용되지 않는다. public이 아닌 메서드나 final 메서드(CGLIB 프록시)는 기본 프록시 모드에서 적용 대상이 아니거나 제대로 동작하지 않을 수 있다. 또한 호출 스레드가 다르면(@Async, 별도 스레드 풀) 트랜잭션이 전파되지 않고 새 컨텍스트에서 실행된다.


정리

트랜잭션을 설계할 때는 먼저 체크 예외도 롤백해야 하는지 확인하고, 그렇다면 rollbackFor를 지정한다. 하위의 실패를 상위가 무시하고 계속 진행해야 한다면 REQUIRED로는 전체가 롤백되므로 REQUIRES_NEW나 NESTED를 검토한다. 하위 결과를 상위와 무관하게 남겨야 한다면 REQUIRES_NEW를 쓰되 커넥션 풀 크기를 확인하고, 하위만 부분 롤백하고 상위는 계속 진행해야 한다면 NESTED를 쓰되 트랜잭션 관리자가 지원하는지 확인한다. 같은 클래스 내부 호출은 프록시를 거치지 않으므로 빈을 분리한다.


참고

https://cloud.google.com/firestore/docs/transaction-data-contention?hl=ko

https://www.ibm.com/docs/en/rational-clearquest/7.1.0?topic=clearquest-optimistic-pessimistic-record-locking