@Transactional이 rollback하지 않는 예외 — 트랜잭션 관리의 함정
@Service
public class TransferService {
@Transactional
public void transfer(Long from, Long to, long amount) {
accountRepository.withdraw(from, amount);
try {
externalService.notify(to); // 외부 API 호출 실패
} catch (Exception e) {
log.warn("알림 실패", e); // 예외를 잡아서 처리함
}
accountRepository.deposit(to, amount);
}
}
이 코드에서 deposit이 실패하면 — withdraw도 롤백되어야 한다. @Transactional이 이것을 보장한다. 하지만 예외를 잡아서(catch) 처리하면 트랜잭션에 어떤 영향을 주는가? 그리고 checked exception은 기본적으로 롤백을 발생시키지 않는다는 것을 아는가?
이 글은 @Transactional의 작동 원리, 전파(propagation), 격리 수준(isolation), 롤백 규칙을 풀어간다. (Spring Framework Reference - Transaction)
@Transactional — 선언적 트랜잭션 관리
// Spring 7 / Java 25 — @Transactional
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class OrderService {
private final OrderRepository orderRepo;
private final PaymentRepository paymentRepo;
public OrderService(OrderRepository orderRepo, PaymentRepository paymentRepo) {
this.orderRepo = orderRepo;
this.paymentRepo = paymentRepo;
}
@Transactional
public void placeOrder(Order order) {
orderRepo.save(order);
paymentRepo.charge(order); // 여기서 실패하면 save도 롤백됨
}
}
@Transactional은 AOP 프록시(장 06)를 통해 트랜잭션 경계를 만든다:
flowchart TD
A["호출자"] -->|"@Transactional 메서드 호출"| P["트랜잭션 프록시"]
P -->|"1. 트랜잭션 시작"| T["begin tx"]
T -->|"2. 비즈니스 메서드 실행"| B["placeOrder()"]
B -->|"성공"| C["commit"]
B -->|"RuntimeException"| R["rollback"]
C --> RES["호출자에게 반환"]
R --> RES
롤백 규칙 — 어떤 예외가 rollback을 발생시키는가
| 예외 타입 | 기본 동작 | 비고 |
|---|---|---|
RuntimeException |
rollback | NullPointerException, IllegalStateException 등 |
Error |
rollback | OutOfMemoryError 등 |
| Checked Exception | 커밋(!) | IOException, SQLException 등 |
checked exception은 기본적으로 트랜잭션을 롤백하지 않는다. 이것은 EJB 시절의 관례를 따른 것 — checked exception은 "비즈니스적으로 예상 가능한 상황"으로 간주하여 커밋을 유지한다. (Spring Framework Reference - Rollback)
왜 checked와 unchecked를 다르게 처리하는가 — 설계 이유: checked exception은 컴파일러가 throws를 강제하는 예외다 — 즉, 개발자가 "이 예외가 발생할 수 있다"는 것을 인지하고 있었다. 인지한 예외는 비즈니스 흐름의 일부일 가능성이 높으므로, 롤백보다는 커밋이 합리적일 수 있다. 반면 RuntimeException은 프로그래밍 오류(NPE, 인덱스 초과)로, 예상치 못한 상황이므로 롤백이 안전하다. 물론 현대에서는 rollbackFor = Exception.class로 모든 예외를 롤백하는 것이 더 직관적이다.
비유: checked exception은 배송 지연이다 — 예상 가능한 상황이므로 주문을 취소(롤백)하지 않고 진행(커밋)할 수 있다. RuntimeException은 창고 화재다 — 예상치 못한 사고이므로 즉시 주문을 취소(롤백)해야 한다.
롤백 규칙 커스터마이징
// Spring 7 / Java 25 — 롤백 규칙 커스터마이징
@Transactional(rollbackFor = IOException.class) // checked exception도 롤백
public void importData(File file) throws IOException { ... }
@Transactional(noRollbackFor = IllegalStateException.class) // 이 예외는 롤백 안 함
public void process(Order order) { ... }
프로덕션에서는
rollbackFor = Exception.class를 쓰는 것이 안전한 경우가 많다 — 모든 예외(checked 포함)가 롤백을 발생시키도록 한다.
전파(Propagation) — 중첩 트랜잭션의 동작
@Transactional 메서드가 다른 @Transactional 메서드를 호출할 때, 트랜잭션을 어떻게 처리할지를 전파가 결정한다. (Spring Framework Reference - Propagation)
| 전파 | 동작 |
|---|---|
REQUIRED (기본) |
기존 트랜잭션이 있으면 참여, 없으면 새로 생성 |
REQUIRES_NEW |
기존 트랜잭션을 일시 정지하고 새 트랜잭션 생성 |
SUPPORTS |
트랜잭션이 있으면 참여, 없으면 트랜잭션 없이 실행 |
MANDATORY |
트랜잭션이 반드시 있어야 함 (없으면 예외) |
NEVER |
트랜잭션이 없어야 함 (있으면 예외) |
NESTED |
중첩 트랜잭션 (savepoint 기반) |
// Spring 7 / Java 25 — 전파 예
@Service
public class AuditService {
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void logTransfer(String detail) {
// 메인 트랜잭션이 롤백되어도 감사 로그는 커밋됨
auditRepo.save(detail);
}
}
@Service
public class TransferService {
@Transactional
public void transfer(Long from, Long to, long amount) {
withdraw(from, amount);
deposit(to, amount);
auditService.logTransfer("이체 완료"); // REQUIRES_NEW → 별도 트랜잭션
// 메인 트랜잭션이 롤백되어도 감사 로그는 살아있음
}
}
격리 수준(Isolation)
// Spring 7 / Java 25 — 격리 수준 지정
@Transactional(isolation = Isolation.READ_COMMITTED)
public Order getOrder(Long id) { ... }
| 수준 | 더티 읽기 | 반복 불가 읽기 | 팬텀 읽기 |
|---|---|---|---|
| READ_UNCOMMITTED | 허용 | 허용 | 허용 |
| READ_COMMITTED | 차단 | 허용 | 허용 |
| REPEATABLE_READ | 차단 | 차단 | 허용 |
| SERIALIZABLE | 차단 | 차단 | 차단 |
격리 수준이 높을수록 일관성은 좋아지지만 동시성은 떨어진다. 대부분의 애플리케이션은
READ_COMMITTED(DB 기본값)로 충분하다.
readOnly — 읽기 전용 트랜잭션
// Spring 7 / Java 25 — readOnly 힌트
@Transactional(readOnly = true)
public Order findById(Long id) {
return orderRepo.findById(id).orElseThrow();
}
readOnly = true는 Hibernate에 flush를 생략하고, DB에 읽기 전용 힌트를 전달한다. 성능이 미세하게 개선된다.
자기 호출 함정 — @Transactional과 AOP
장 06에서 다뤘듯, 같은 클래스 내부에서 @Transactional 메서드를 호출하면 AOP 프록시를 우회하여 트랜잭션이 적용되지 않는다. 이것은 트랜잭션 관리에서 가장 흔한 버그 원인이다.
// Spring 7 / Java 25 — 자기 호출 버그
@Service
public class OrderService {
@Transactional
public void placeOrder(Order order) { ... }
public void bulkProcess(List<Order> orders) {
for (Order order : orders) {
this.placeOrder(order); // this = 프록시 아님 → 트랜잭션 미적용!
}
}
}
실습 — 트랜잭션 없이 vs 있을 때
// Spring 7 / Java 25 — TransactionPatternDemo.java
import java.util.*;
public class TransactionPatternDemo {
static class Account {
long balance;
Account(long balance) { this.balance = balance; }
}
// 트랜잭션 없음: withdraw 후 deposit 실패 → 잔액 불일치
static void transferWithoutTx(Account from, Account to, long amount) {
from.balance -= amount; // 출금 성공
if (to.balance < 0) throw new RuntimeException("수신자 오류");
to.balance += amount; // 입금 실패 → from만 차감됨
}
// 트랜잭션 있음: 실패 시 둘 다 롤백
static void transferWithTx(Account from, Account to, long amount) {
var fromSnapshot = from.balance;
var toSnapshot = to.balance;
try {
from.balance -= amount;
if (to.balance < 0) throw new RuntimeException("수신자 오류");
to.balance += amount;
} catch (RuntimeException e) {
from.balance = fromSnapshot; // 롤백
to.balance = toSnapshot;
System.out.println("롤백됨: " + e.getMessage());
}
}
public static void main(String[] args) {
var a = new Account(10000);
var b = new Account(-1);
transferWithoutTx(a, b, 3000);
System.out.println("TX 없음: A=" + a.balance + ", B=" + b.balance);
a.balance = 10000;
transferWithTx(a, b, 3000);
System.out.println("TX 있음: A=" + a.balance + ", B=" + b.balance);
}
}
java TransactionPatternDemo.java
Exception in thread "main" java.lang.RuntimeException: 수신자 오류
확인할 것: 트랜잭션 없이는 출금만 성공하고 입금이 실패하여 잔액이 불일치한다. 트랜잭션 있으면 실패 시 롤백되어 원래 상태로 복구된다. Spring의 @Transactional이 이 롤백을 자동화한다.
프로그래밍 방식 트랜잭션 — TransactionTemplate
선언적(@Transactional)이 아니라 코드에서 직접 트랜잭션 경계를 제어해야 할 때:
// Spring 7 / Java 25 — TransactionTemplate
import org.springframework.transaction.support.TransactionTemplate;
@Service
public class BatchProcessor {
private final TransactionTemplate txTemplate;
private final OrderRepository repo;
public BatchProcessor(PlatformTransactionManager txManager,
OrderRepository repo) {
this.txTemplate = new TransactionTemplate(txManager);
this.repo = repo;
}
public void processInBatches(List<Order> orders) {
for (List<Order> batch : partition(orders, 100)) {
txTemplate.execute(status -> {
for (Order order : batch) {
repo.save(order);
}
return null; // 정상 반환 → commit
// 예외 발생 → 자동 rollback
});
}
}
}
프로그래밍 방식은 배치 처리에서 "100건마다 커밋" 같은 세밀한 제어가 필요할 때 유용하다. 일반적인 경우
@Transactional로 충분하다.
@Transactional 속성 전체 참조
@Transactional(
propagation = Propagation.REQUIRED, // 전파 (기본: REQUIRED)
isolation = Isolation.DEFAULT, // 격리 수준 (기본: DB 기본값)
timeout = 30, // 초 단위 타임아웃 (기본: 무제한)
readOnly = false, // 읽기 전용 (기본: false)
rollbackFor = Exception.class, // 롤백 대상 예외 (기본: RuntimeException)
noRollbackFor = NoRollbackException.class,// 롤백 제외 예외
label = "order-transaction" // 트랜잭션 라벨 (Spring 5.3+, 모니터링용)
)
public void complexOperation() { ... }
요약 — 이 글의 결론
@Transactional은 AOP 프록시로 트랜잭션 경계를 만든다. 메서드 시작 시 트랜잭션을 시작하고, 정상 종료 시 커밋, 예외 시 롤백한다.RuntimeException과Error는 롤백, checked exception은 커밋(기본값). checked exception을 롤백하려면rollbackFor = Exception.class를 지정한다.- 전파(propagation)가 중첩 트랜잭션의 동작을 결정한다.
REQUIRED(기본)는 기존 트랜잭션에 참여,REQUIRES_NEW는 별도 트랜잭션을 생성한다. - 자기 호출은
@Transactional을 우회한다. 같은 클래스 내부에서this.method()를 호출하면 프록시를 통하지 않으므로 트랜잭션이 적용되지 않는다. readOnly = true로 읽기 전용 트랜잭션을 최적화한다. Hibernate flush 생략, DB 읽기 힌트로 성능 개선.
생각해 볼 문제
REQUIRES_NEW전파에서 부모 트랜잭션이 롤백되면 자식 트랜잭션도 롤백되는가?@Transactional이private메서드에 붙으면 어떻게 되는가? (CGLIB 프록시의 한계)- 트랜잭션 내에서 외부 API 호출(HTTP)을 하면 어떤 위험이 있는가?
NESTED전파와REQUIRES_NEW의 차이는? savepoint는 어떻게 작동하는가?- Spring 7에서 JTA(Java Transaction API) 기반 분산 트랜잭션의 지원 상태는?
참고
- Spring Framework 7 - Transaction - 접근 2026-07-10
- Spring Framework 7 - Declarative Transaction - 접근 2026-07-10
- Spring Framework 7 - @Transactional - 접근 2026-07-10
- Spring Framework 7 - Rollback - 접근 2026-07-10
'Develop Artifacts > Spring' 카테고리의 다른 글
| Spring - 10. data-access (0) | 2026.07.12 |
|---|---|
| Spring - 08. validation exception (0) | 2026.07.12 |
| Spring - 07. spring mvc (0) | 2026.07.12 |
| Spring - 06. aop (0) | 2026.07.12 |
| Spring - 05. bean scope profile (0) | 2026.07.12 |
