들어가기에 앞서
스프링 트랜잭션은 UncheckedException은 롤백하고 CheckedException은 비즈니스 예외로 취급하여 롤백하지 않는다.
스프링 프레임워크의 트랜잭션에 대해 처음 배울 때 들었던 이야기입니다. 최근 실무에서 스프링 트랜잭션의 롤백을 제어하여 비즈니스 요구사항을 구현했습니다. 이 경험을 바탕으로 스프링 트랜잭션의 롤백이 어떻게 동작하는지 직접 프레임워크 코드와 공식 문서를 통해 학습한 내용을 정리해보려고 합니다.
본 게시글에서는 다음 내용을 다룹니다.
- 스프링 트랜잭션의 기본 롤백 정책과 그 이유
rollbackFor,noRollbackFor속성을 통한 롤백 규칙 커스터마이징- 실무에서
noRollbackFor를 활용한 사례
스프링 트랜잭션의 기본 롤백 정책
스프링의 선언적 트랜잭션(@Transactional)의 기본 설정은 RuntimeException(언체크 예외)과 Error에 대해서만 롤백을 수행합니다. CheckedException(체크 예외)이 발생하면 트랜잭션을 롤백하지 않고 커밋합니다.
| 예외 유형 | 기본 동작 |
|---|---|
| RuntimeException 및 하위 클래스 | 롤백 |
| Error | 롤백 |
| CheckedException | 커밋 |
프레임워크 코드로 살펴보기
롤백과 관련한 기본 동작은 DefaultTransactionAttribute 클래스의 rollbackOn() 메서드에서 확인할 수 있습니다.
// DefaultTransactionAttribute.java (Spring Framework)
@Override
public boolean rollbackOn(Throwable ex) {
return (ex instanceof RuntimeException || ex instanceof Error);
}
트랜잭션 실행 중 예외가 발생하면 TransactionAspectSupport의 completeTransactionAfterThrowing() 메서드가 호출됩니다. 이 메서드는 rollbackOn() 결과에 따라 롤백 여부를 결정합니다.
// TransactionAspectSupport.java (Spring Framework)
if (txInfo.transactionAttribute != null &&
txInfo.transactionAttribute.rollbackOn(ex)) {
// 롤백 수행
txInfo.getTransactionManager().rollback(txInfo.getTransactionStatus());
} else {
// 커밋 시도
txInfo.getTransactionManager().commit(txInfo.getTransactionStatus());
}
왜 CheckedException은 롤백하지 않을까?
DefaultTransactionAttribute#rollbackOn() 메서드의 주석을 살펴보면 그 이유를 알 수 있습니다.
The default behavior is as with EJB: rollback on unchecked exception (RuntimeException), assuming an unexpected outcome outside any business rules. Additionally, we also attempt to rollback on Error which is clearly an unexpected outcome as well. By contrast, a checked exception is considered a business exception and therefore a regular expected outcome of the transactional business method, i.e. a kind of alternative return value which still allows for regular completion of resource operations.
정리하면, 이 동작은 EJB의 규칙을 따른 것입니다. 런타임 예외는 비즈니스 규칙 외부의 예상치 못한 결과로 간주하여 롤백합니다. 반면, 체크 예외는 비즈니스 예외로 간주합니다. 즉, 트랜잭션 비즈니스 메서드의 예상된 결과이며 일종의 대안적인 반환값으로 취급하여 롤백하지 않습니다.
스프링은 이 규칙을 기본값으로 채택하면서도, rollbackFor와 noRollbackFor 속성을 통해 더 유연하게 커스터마이징할 수 있도록 설계했습니다.
롤백 규칙 커스터마이징
rollbackFor: 특정 예외에서 롤백하기
체크 예외가 발생했을 때도 롤백이 필요하다면 rollbackFor 속성을 사용합니다.
@Transactional(rollbackFor = BusinessException.class)
public void processOrder() {
// BusinessException(체크 예외)이 발생해도 롤백됨
}
noRollbackFor: 특정 예외에서 롤백하지 않기
런타임 예외가 발생하더라도 롤백을 원하지 않는 경우 noRollbackFor 속성을 사용합니다.
@Transactional(noRollbackFor = RuntimeBusinessException.class)
public void updateStock() {
// RuntimeBusinessException(런타임 예외)이 발생해도 롤백되지 않음
}
규칙 우선순위
rollbackFor와 noRollbackFor가 동시에 지정된 경우 스프링이 지정한 우선순위에 따라 롤백할지, 롤백하지 않을지 결정됩니다. 이 부분은 스프링이 늘 그렇듯이 구체적인 것을 우선으로 따릅니다.
우선순위에 대한 동작은 RuleBasedTransactionAttribute 클래스에서 구현되어 있습니다.
// RuleBasedTransactionAttribute.java (Spring Framework)
@Override
public boolean rollbackOn(Throwable ex) {
RollbackRuleAttribute winner = null;
int deepest = Integer.MAX_VALUE;
// 모든 규칙을 순회하며 가장 구체적인(깊이가 얕은) 규칙을 찾음
for (RollbackRuleAttribute rule : this.rollbackRules) {
int depth = rule.getDepth(ex);
if (depth >= 0 && depth < deepest) {
deepest = depth;
winner = rule;
}
}
// 매칭 규칙이 없으면 기본 동작(RuntimeException, Error만 롤백)
if (winner == null) {
return super.rollbackOn(ex);
}
// NoRollbackRuleAttribute이면 롤백 안 함
return !(winner instanceof NoRollbackRuleAttribute);
}
예를 들어, 다음과 같이 설정하면 다른 모든 예외에 대해서는 롤백되지만, noRollbackFor로 지정한 BusinessException에 대해서는 롤백되지 않습니다.
@Transactional(
rollbackFor = [Exception::class],
noRollbackFor = [BusinessException::class]
)
fun something() {
// ...
}
디버그 모드로 좀 더 자세히 살펴보겠습니다. rollbackRules에 Exception이 RollbackRuleAttribute로, BusinessException이 NoRollbackRuleAttribute로 등록되어 있는 것을 확인할 수 있습니다.

둘 중 더 얕은 depth를 가진(더 구체적인) BusinessException이 winner로 선택되고, winner는 NoRollbackRuleAttribute 클래스의 인스턴스이기 때문에 롤백되지 않습니다.


주의할 점
비즈니스 로직이 여러 계층의 @Transactional 메서드 호출로 이루어지는 경우 주의가 필요합니다. rollbackFor와 noRollbackFor로 지정한 규칙은 각 메서드의 트랜잭션 경계에서 개별적으로 적용됩니다. 따라서 예외를 처음 던지는 메서드에서 noRollbackFor를 설정하더라도, 예외가 호출한 메서드까지 전파되면 호출한 메서드의 설정에 따라 롤백 여부가 다시 결정됩니다. 예를 들어 다음과 같이 로직이 구성되어 있다고 해봅시다.
class ServiceA(
private val serviceB: ServiceB,
) {
@Transactional(
rollbackFor = [Exception::class],
)
fun somethingA() {
serviceB.somethingB()
}
}
class ServiceB {
@Transactional(
rollbackFor = [Exception::class],
noRollbackFor = [BusinessException::class]
)
fun somethingB() {
throw BusinessException()
}
}
이 경우 ServiceB#somethingB()에서 BusinessException을 던졌을 때 변경사항이 커밋되길 기대할 수 있습니다. 그러나 실제로는 호출한 쪽인 ServiceA#somethingA()까지 BusinessException이 전파되고, somethingA()에는 noRollbackFor 설정이 없기 때문에 기대와는 달리 변경사항이 롤백됩니다. somethingA()의 변경사항을 정상적으로 커밋하려면 예외를 잡아서 처리하거나, noRollbackFor 설정을 추가해야 합니다.
class ServiceA(
private val serviceB: ServiceB,
) {
@Transactional(
rollbackFor = [Exception::class],
)
fun somethingA() {
try {
serviceB.somethingB()
} catch (e: BusinessException) {
// 비즈니스 로직...
}
}
}
실무 활용 사례: noRollbackFor로 비즈니스 요구사항 구현하기
문제 상황
해당 기능을 활용해 문제를 해결했던 상황을 빗대어서 설명해보겠습니다. 번들(Bundle) 과 아이템(Item) 이라는 엔티티가 있다고 해봅시다. 하나의 번들은 2개 이상의 아이템으로 구성됩니다. 번들과 관련된 새로운 기능을 오픈하기 위해 작업하던 중, 새로운 정책으로 다음 요구사항이 추가되었습니다.
- (정책) 번들을 구성하고 있는 아이템을 삭제 처리하면 번들도 함께 삭제 처리해야 한다.
단순히 이 정책만 구현하면 간단합니다. 문제는 기존에 존재하는 다른 요구사항과 결합될 때 발생했습니다.
- (기존 요구사항) 번들의 상태가 다운스트림 서비스에 반영 중일 때, 번들을 수정할 수 없다.
- (시나리오) 번들의 상태가 다운스트림 서비스에 반영 중인 상태일 때, 아이템이 삭제 처리되면 어떻게 될까?
이 시나리오에서 단순히 정책을 그대로 구현하면, 기존 요구사항과 충돌하여 상태가 올바르게 반영되지 않는 문제가 발생할 수 있었습니다.
해결 방법
시나리오를 만족하기 위해 주어진 정책을 다음과 같은 유효성으로 풀어냈습니다.
- 번들을 구성하고 있는 모든 아이템은 공개 상태여야 한다.
유효성을 위반하면 BundleHasDeletedItemException이라는 런타임 예외를 던집니다. 해당 예외가 발생했을 때 트랜잭션을 롤백하지 않고, 호출부에서 해당 예외를 잡아서 삭제 처리 로직을 수행하여 정책을 코드에 반영할 수 있도록 만들었습니다.
다음은 시나리오에 대한 간단한 코드 예시입니다.
/**
* 번들 서비스
* @property syncProcessor 번들의 변경사항을 다운스트림 서비스에 반영하는 처리기
*/
class BundleService(
private val validator: BundleValidator,
private val syncProcessor: BundleSyncProcessor,
) {
/**
* 다운스트림 서비스 반영 성공 응답을 번들에 반영한다.
* 만약 번들이 유효성을 위반한 상태인 경우 삭제 처리하고
* 해당 사실을 다운스트림 서비스에도 반영 요청한다.
*/
@Transactional
fun handleSyncSuccess(input: HandleSyncSuccessInput) {
val bundle = getBundle(input.bundleId)
try {
update(bundle)
} catch (e: BundleHasDeletedItemException) {
bundle.changeStatusToDeleted()
update(bundle, isValidate = false)
}
}
private fun update(bundle: Bundle, isValidate: Boolean = true) {
if (isValidate) {
validator.validate(bundle)
}
// 이외의 비즈니스 로직...
// 번들이 변경되었으면 변경사항을 다운스트림 서비스에도 반영한다.
if (bundle.isUpdated()) {
syncProcessor.process(bundle)
}
}
}
/**
* 일대다 관계인 번들과 아이템의 연결을 책임지는 서비스
*/
class BundleItemLinkService {
/**
* 번들을 구성하고 있는 아이템의 유효성을 검사한다.
*/
@Transactional(readOnly = true, noRollbackFor = [BundleHasDeletedItemException::class])
fun validate(bundle: Bundle) {
val items = getItems(bundle)
// 기존 유효성 검사...
items.forEach { item ->
if (item.isStatusDeleted()) {
throw BundleHasDeletedItemException("번들은 삭제된 아이템을 포함할 수 없습니다.")
}
}
}
}
BundleItemLinkService#validate() 메서드에 noRollbackFor 옵션을 추가한 것을 볼 수 있습니다. 이렇게 하면 검증 중에 BundleHasDeletedItemException이 발생하더라도 트랜잭션이 rollbackOnly로 마킹되지 않습니다. 호출부에서 예외를 잡아서 적합한 비즈니스 로직을 처리한 뒤, 트랜잭션은 정상적으로 커밋됩니다.
처음에는 해당 정책을 구현하기 위해 어떤 방법을 사용해야 하나 고민이 좀 있었습니다. 그러나 요구사항을 정리하고 스프링 트랜잭션이 제공하는 기능을 활용하여 빠르게 구현할 수 있었습니다.
끝으로
스프링 트랜잭션의 롤백 정책을 정리하면 다음과 같습니다.
- 기본 정책: RuntimeException과 Error만 롤백, CheckedException은 커밋
- rollbackFor: 지정한 예외를 롤백 대상에 포함
- noRollbackFor: 지정한 예외를 롤백 대상에서 제외
- 규칙 우선순위: 예외 상속 계층에서 가장 구체적인 규칙이 우선 적용
참고
'Spring' 카테고리의 다른 글
| 스프링 트랜잭션 동기화로 메시지 발행 타이밍 제어하기 (0) | 2026.02.17 |
|---|---|
| Jedis, Lettuce, Redisson. 레디스를 위한 세 가지 자바 클라이언트 (1) | 2024.10.30 |
| 스프링 @Retry를 이용한 재시도 처리하기 (0) | 2024.06.12 |
| 스프링 @Async를 이용한 비동기 실행 적용하기 (1) | 2024.06.11 |
| 스프링에서 외부 API 호출을 테스트하는 방법(feat. RestClient) (0) | 2024.05.20 |