들어가며: @Transactional을 붙이면 끝이라는 착각
Spring Boot에서 @Transactional은 가장 많이 쓰이면서도 가장 많이 오해되는 어노테이션이다. “메서드에 붙이면 트랜잭션이 걸린다”는 수준에서 멈추면, 전파(propagation) 설정 실수로 데이터 정합성이 깨지거나, 체크 예외에서 롤백이 안 되는 버그를 운영에서 마주하게 된다.
이 글에서는 Spring Framework 6.x / Spring Boot 3.x 공식 문서(Transaction Management)를 근거로, @Transactional의 propagation · isolation · rollback 규칙을 실무 시나리오와 함께 정리한다.
1. @Transactional 동작 원리: 프록시 기반 AOP
1-1. 프록시가 트랜잭션을 건다
Spring 공식 문서에 따르면, @Transactional은 AOP 프록시를 통해 동작한다. 빈(Bean)의 메서드를 호출할 때 프록시가 가로채서 PlatformTransactionManager를 이용해 트랜잭션을 시작·커밋·롤백한다.
이 구조에서 가장 중요한 제약이 하나 있다:
@Service
public class OrderService {
@Transactional
public void createOrder(OrderDto dto) {
// 트랜잭션 적용됨
saveOrder(dto);
sendNotification(dto); // 같은 클래스 내부 호출
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void sendNotification(OrderDto dto) {
// ❌ REQUIRES_NEW가 동작하지 않음!
// 프록시를 거치지 않는 내부 호출(self-invocation)
}
}
같은 클래스 내부에서의 메서드 호출(self-invocation)은 프록시를 거치지 않으므로 @Transactional이 무시된다. Spring 공식 문서에 명시적으로 경고하는 사항이다.
1-2. 해결 방법
| 방법 | 설명 | 권장도 |
|---|---|---|
| 별도 Bean으로 분리 | 트랜잭션 경계가 다른 메서드를 별도 서비스 클래스로 추출 | ✅ 권장 |
self 주입 |
@Lazy로 자기 자신을 주입받아 프록시 경유 호출 |
⚠ 가능하지만 가독성 저하 |
| AspectJ 위빙 | 컴파일/로드타임 위빙으로 프록시 없이 동작 | ⚠ 설정 복잡도 높음 |
2. Propagation: 트랜잭션을 어떻게 전파할 것인가
2-1. 7가지 전파 옵션
| Propagation | 기존 트랜잭션 있을 때 | 기존 트랜잭션 없을 때 | 대표 사용 사례 |
|---|---|---|---|
REQUIRED (기본값) |
기존 트랜잭션에 참여 | 새 트랜잭션 생성 | 일반적인 서비스 메서드 |
REQUIRES_NEW |
기존 트랜잭션 일시 중단, 새 트랜잭션 생성 | 새 트랜잭션 생성 | 감사 로그, 알림 — 메인 트랜잭션과 독립적으로 커밋해야 할 때 |
NESTED |
세이브포인트를 생성하여 중첩 | 새 트랜잭션 생성 | 부분 롤백이 필요한 배치 처리 |
SUPPORTS |
기존 트랜잭션에 참여 | 트랜잭션 없이 실행 | 읽기 전용 메서드 |
NOT_SUPPORTED |
기존 트랜잭션 일시 중단 | 트랜잭션 없이 실행 | 외부 API 호출(트랜잭션 밖에서 실행) |
MANDATORY |
기존 트랜잭션에 참여 | 예외 발생 | 반드시 트랜잭션 안에서만 호출되어야 하는 메서드 |
NEVER |
예외 발생 | 트랜잭션 없이 실행 | 트랜잭션 안에서 호출되면 안 되는 메서드 |
2-2. REQUIRED vs. REQUIRES_NEW: 실무에서 가장 많이 헷갈리는 조합
@Service
public class PaymentService {
@Transactional // REQUIRED (기본값)
public void processPayment(PaymentDto dto) {
deductBalance(dto);
auditService.logAudit(dto); // REQUIRES_NEW
updateOrderStatus(dto);
}
}
@Service
public class AuditService {
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void logAudit(PaymentDto dto) {
// 별도 트랜잭션에서 실행
// processPayment가 롤백되어도 감사 로그는 유지됨
}
}
핵심 차이:
REQUIRED로 참여하면 — 부모가 롤백되면 자식도 함께 롤백REQUIRES_NEW면 — 부모와 독립적으로 커밋/롤백REQUIRES_NEW는 DB 커넥션을 하나 더 사용하므로, 커넥션 풀 고갈에 주의해야 한다
2-3. NESTED: 세이브포인트를 이용한 부분 롤백
@Transactional
public void batchProcess(List<Item> items) {
for (Item item : items) {
try {
itemService.processItem(item); // NESTED
} catch (Exception e) {
// 이 아이템만 롤백, 나머지는 계속 처리
log.warn("Item {} failed: {}", item.getId(), e.getMessage());
}
}
}
주의: NESTED는 JDBC 세이브포인트에 의존하므로 JPA/Hibernate에서는 JpaTransactionManager가 아닌 DataSourceTransactionManager를 사용해야 한다. JPA 환경에서 NESTED를 쓰면 NestedTransactionNotSupportedException이 발생할 수 있다.
3. Isolation: 동시 접근 제어 수준
3-1. 4단계 격리 수준
| Isolation | Dirty Read | Non-Repeatable Read | Phantom Read | MySQL InnoDB 기본값 |
|---|---|---|---|---|
READ_UNCOMMITTED |
발생 | 발생 | 발생 | |
READ_COMMITTED |
방지 | 발생 | 발생 | |
REPEATABLE_READ |
방지 | 방지 | InnoDB: 방지* | ✅ 기본값 |
SERIALIZABLE |
방지 | 방지 | 방지 |
*MySQL InnoDB는 MVCC + Next-Key Lock으로 REPEATABLE_READ에서도 Phantom Read를 대부분 방지한다(MySQL 공식 문서 InnoDB Transaction Model).
3-2. Spring에서 격리 수준 지정
@Transactional(isolation = Isolation.READ_COMMITTED)
public BigDecimal getAccountBalance(Long accountId) {
return accountRepository.findBalanceById(accountId);
}
@Transactional의 isolation 속성은 트랜잭션 시작 시 DB 세션의 격리 수준을 변경한다. 대부분의 경우 DB 기본값(MySQL InnoDB: REPEATABLE_READ)을 그대로 사용하는 것이 권장되며, 특정 메서드에서만 격리 수준을 낮추거나 높일 때 사용한다.
4. Rollback 규칙: 체크 예외는 롤백되지 않는다
4-1. 기본 롤백 규칙
Spring 공식 문서에 따르면 @Transactional의 기본 롤백 규칙은:
RuntimeException(언체크 예외) → 롤백 ✅Error→ 롤백 ✅Exception(체크 예외) → 롤백하지 않음 ❌ (커밋됨)
이것은 실무에서 가장 많은 버그를 만드는 규칙이다. 예를 들어:
@Transactional
public void transferMoney(Long from, Long to, BigDecimal amount)
throws InsufficientBalanceException { // 체크 예외
accountRepository.deduct(from, amount);
accountRepository.add(to, amount);
if (getCurrentBalance(from).compareTo(BigDecimal.ZERO) < 0) {
throw new InsufficientBalanceException("잔액 부족");
// ❌ 체크 예외이므로 롤백되지 않음!
// deduct와 add가 커밋되어 데이터 정합성 깨짐
}
}
4-2. 명시적 롤백 설정
// 방법 1: rollbackFor 지정
@Transactional(rollbackFor = Exception.class)
public void transferMoney(Long from, Long to, BigDecimal amount)
throws InsufficientBalanceException {
// 이제 체크 예외에서도 롤백됨
}
// 방법 2: 특정 예외만 롤백 제외
@Transactional(
rollbackFor = Exception.class,
noRollbackFor = NotificationFailedException.class
)
public void processOrder(OrderDto dto) throws Exception {
// NotificationFailedException 외의 모든 예외에서 롤백
}
4-3. 실무 권장: rollbackFor = Exception.class를 기본으로
체크 예외를 사용하는 프로젝트에서는 rollbackFor = Exception.class를 팀 컨벤션으로 강제하는 것이 안전하다. 또는 커스텀 어노테이션을 만들어 기본값을 재정의할 수 있다:
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Transactional(rollbackFor = Exception.class)
public @interface AppTransactional {
Propagation propagation() default Propagation.REQUIRED;
Isolation isolation() default Isolation.DEFAULT;
}
5. readOnly 속성: 단순 힌트가 아니다
@Transactional(readOnly = true)는 읽기 전용 힌트를 JDBC 드라이버와 ORM에 전달한다.
| 레이어 | readOnly=true 효과 |
|---|---|
| JDBC | Connection.setReadOnly(true) — DB에 따라 레플리카로 라우팅 가능 |
| Hibernate/JPA | FlushMode를 MANUAL로 변경 → dirty checking 비활성화 → 메모리·CPU 절약 |
| MySQL | InnoDB: 읽기 전용 트랜잭션에 대해 내부 최적화 적용 |
@Service
@Transactional(readOnly = true) // 클래스 레벨 기본값
public class ProductQueryService {
public List<ProductDto> searchProducts(String keyword) {
return productRepository.findByNameContaining(keyword)
.stream()
.map(ProductDto::from)
.toList();
}
@Transactional // 쓰기 메서드만 오버라이드
public void updatePrice(Long id, BigDecimal price) {
Product product = productRepository.findById(id).orElseThrow();
product.changePrice(price);
}
}
클래스 레벨에 readOnly = true를 걸고, 쓰기 메서드에만 @Transactional을 오버라이드하는 패턴이 실무에서 가장 깔끔하다.
6. 실전 체크리스트
- self-invocation 확인: 같은 클래스 내부 호출에서
@Transactional이 동작하지 않음을 인지하고, 트랜잭션 경계가 다른 로직은 별도 Bean으로 분리했는가? - rollbackFor 설정: 체크 예외를 사용하는 프로젝트에서
rollbackFor = Exception.class를 명시했는가? - REQUIRES_NEW 커넥션 풀:
REQUIRES_NEW사용 시 중첩 깊이 × 동시 스레드 수가 커넥션 풀 크기를 초과하지 않는가? - readOnly 분리: 읽기 전용 메서드에
readOnly = true를 적용하여 dirty checking 비용을 절약했는가? - 트랜잭션 범위 최소화: 외부 API 호출, 파일 I/O 등 느린 작업을 트랜잭션 밖으로 빼서 DB 커넥션 점유 시간을 줄였는가?
- private 메서드 주의:
@Transactional은public메서드에서만 동작한다(프록시 기반 AOP 제약). Spring 공식 문서에 명시된 사항이다.
7. 흔한 실수와 방지법
| 실수 | 증상 | 방지법 |
|---|---|---|
| 체크 예외에서 롤백 기대 | 데이터 정합성 깨짐(커밋됨) | rollbackFor = Exception.class 명시 또는 커스텀 어노테이션 사용 |
| 같은 클래스 내부 호출 | 전파/격리 설정 무시됨 | 별도 Bean으로 분리, 또는 ApplicationEventPublisher로 이벤트 기반 분리 |
| 트랜잭션 안에서 외부 API 호출 | 응답 지연 시 커넥션 풀 고갈 | 외부 호출을 트랜잭션 밖으로 이동. @TransactionalEventListener(phase = AFTER_COMMIT) 활용 |
| REQUIRES_NEW 남용 | 커넥션 풀 고갈, 데드락 | 정말 독립 커밋이 필요한 경우(감사 로그 등)에만 사용. 커넥션 풀 크기를 중첩 깊이에 맞게 설정 |
정리
@Transactional은 “붙이면 끝”이 아니라, propagation · isolation · rollback · readOnly 네 가지 축을 의식적으로 설계해야 하는 선언이다. 특히 체크 예외 롤백 규칙과 self-invocation 제약은 운영 장애로 이어지기 쉬우므로, 팀 컨벤션과 코드 리뷰에서 반드시 확인해야 한다.