@Transactional — 어노테이션 하나로 트랜잭션이 관리되는 원리
서비스 메서드에
@Transactional하나만 붙이면 트랜잭션이 관리된다는데, 그 안에서는 도대체 무슨 일이 일어나고 있는 걸까요?
@Transactional은 Spring에서 가장 많이 사용하는 어노테이션 중 하나입니다. 하지만 그 동작 원리를 모르면 예상치 못한 버그를 만들 수 있습니다.
개념 정의
@Transactional 은 선언적 트랜잭션 관리를 제공하는 Spring 어노테이션입니다. 메서드 실행을 트랜잭션으로 감싸서 **정상 완료 시 커밋 **, ** 예외 발생 시 롤백 **을 자동으로 처리합니다.
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository orderRepository;
private final PaymentService paymentService;
@Transactional
public void createOrder(OrderRequest request) {
Order order = Order.create(request);
orderRepository.save(order); // 1. 주문 저장
paymentService.pay(order); // 2. 결제 처리
// 예외 발생 시 1번, 2번 모두 롤백
}
}
프록시 기반 동작 원리
@Transactional은 Spring AOP 프록시 를 통해 동작합니다.
동작 흐름
클라이언트 → 프록시 객체 → 실제 서비스 객체
│
├─ 1. 트랜잭션 시작 (getTransaction)
├─ 2. 실제 메서드 호출
├─ 3-a. 정상 → 커밋 (commit)
└─ 3-b. 예외 → 롤백 (rollback)
Spring이 생성하는 프록시의 동작을 의사 코드로 표현하면 다음과 같습니다.
// Spring이 생성하는 프록시 (개념적 코드)
public class OrderServiceProxy extends OrderService {
private final TransactionManager txManager;
private final OrderService target;
@Override
public void createOrder(OrderRequest request) {
TransactionStatus status = txManager.getTransaction(new DefaultTransactionDefinition());
try {
target.createOrder(request); // 실제 메서드 호출
txManager.commit(status);
} catch (RuntimeException e) {
txManager.rollback(status);
throw e;
}
}
}
프록시 생성 방식
Spring Boot는 기본적으로 CGLIB 프록시 를 사용합니다.
- CGLIB: 클래스를 상속하여 프록시 생성 (Spring Boot 기본)
- JDK Dynamic Proxy: 인터페이스를 구현하여 프록시 생성
PlatformTransactionManager
트랜잭션의 실제 관리는 PlatformTransactionManager가 담당합니다.
public interface PlatformTransactionManager extends TransactionManager {
TransactionStatus getTransaction(TransactionDefinition definition);
void commit(TransactionStatus status);
void rollback(TransactionStatus status);
}
구현체에 따라 다른 트랜잭션 메커니즘을 사용합니다.
| 구현체 | 대상 |
|---|---|
| DataSourceTransactionManager | JDBC |
| JpaTransactionManager | JPA (Hibernate) |
| JtaTransactionManager | JTA (분산 트랜잭션) |
Spring Boot는 JPA를 사용하면 자동으로 JpaTransactionManager를 등록합니다.
rollbackFor — 롤백 규칙
기본 규칙
@Transactional
public void process() {
// RuntimeException → 롤백
// Error → 롤백
// CheckedException → 커밋 (!)
}
공부하다 보니 이 부분에서 자주 실수를 하게 됩니다. 체크 예외는 기본적으로 ** 롤백되지 않습니다 **.
rollbackFor로 변경
@Transactional(rollbackFor = Exception.class)
public void process() throws IOException {
// 이제 IOException(체크 예외)에서도 롤백됨
}
noRollbackFor
@Transactional(noRollbackFor = CustomBusinessException.class)
public void process() {
// CustomBusinessException이 발생해도 커밋
}
readOnly 최적화
@Service
@Transactional(readOnly = true) // 클래스 레벨: 기본 읽기 전용
public class MemberService {
public MemberDto getMember(Long id) {
// readOnly = true 적용
return memberRepository.findById(id)
.map(MemberDto::from)
.orElseThrow();
}
@Transactional // 메서드 레벨: readOnly = false 오버라이드
public void updateMember(Long id, UpdateRequest request) {
Member member = memberRepository.findById(id).orElseThrow();
member.update(request);
}
}
readOnly = true의 효과는 다음과 같습니다.
- **Hibernate 최적화 **: 변경 감지(Dirty Checking)를 위한 스냅샷을 생성하지 않아 메모리와 CPU를 절약합니다.
- ** 플러시 생략 **: 읽기 전용이므로 트랜잭션 종료 시 플러시가 발생하지 않습니다.
- **DB 레플리카 라우팅 **: DataSource 라우팅 설정이 있을 때 읽기 전용 쿼리를 레플리카 DB로 보낼 수 있습니다.
@Transactional 속성 정리
@Transactional(
propagation = Propagation.REQUIRED, // 전파 속성 (기본: REQUIRED)
isolation = Isolation.DEFAULT, // 격리 수준 (기본: DB 기본값)
timeout = -1, // 타임아웃 초 (기본: 없음)
readOnly = false, // 읽기 전용 여부
rollbackFor = {}, // 롤백할 예외 타입
noRollbackFor = {}, // 롤백하지 않을 예외 타입
transactionManager = "transactionManager" // 사용할 TransactionManager
)
적용 우선순위
@Service
@Transactional(readOnly = true, timeout = 10) // 클래스 레벨
public class MemberService {
// readOnly = true, timeout = 10 (클래스 레벨 상속)
public MemberDto getMember(Long id) { ... }
// readOnly = false (메서드 레벨이 우선)
@Transactional
public void createMember(CreateRequest request) { ... }
// readOnly = false, timeout = 30 (메서드 레벨이 우선)
@Transactional(timeout = 30)
public void importMembers(List<CreateRequest> requests) { ... }
}
메서드 레벨의 @Transactional이 클래스 레벨보다 ** 항상 우선 **합니다.
체크 예외와 언체크 예외
// 체크 예외 — 기본적으로 롤백되지 않음
public class InsufficientBalanceException extends Exception { }
// 언체크 예외 — 기본적으로 롤백됨
public class InvalidOrderException extends RuntimeException { }
왜 이런 구분이 있을까요? Spring의 철학은 다음과 같습니다.
- ** 체크 예외 **: 비즈니스 로직의 정상적인 흐름 중 하나로 볼 수 있다 (예: 잔고 부족)
- ** 언체크 예외 **: 프로그래밍 오류나 시스템 장애이므로 트랜잭션을 되돌려야 한다
하지만 실무에서는 rollbackFor = Exception.class를 명시하여 모든 예외에서 롤백하는 것이 안전합니다.
함정 — 이걸 모르면 터진다
1. 같은 클래스 내부 호출은 @Transactional이 무시된다
@Transactional은 프록시를 통해 동작하기 때문에, 같은 클래스 안에서 메서드를 직접 호출하면 프록시를 거치지 않습니다.
@Service
public class OrderService {
public void outer() {
this.inner(); // 프록시를 거치지 않음 → 트랜잭션 안 걸림
}
@Transactional
public void inner() {
orderRepository.save(order);
// 롤백이 필요한 상황에서도 트랜잭션이 없음
}
}
this.inner()호출은 프록시가 아닌 실제 객체의 메서드를 직접 호출하는 것이라 AOP가 개입할 수 없습니다. 별도의 빈으로 분리하거나self-injection패턴을 사용해야 합니다.
2. Checked Exception은 기본적으로 롤백되지 않는다
프로덕션에서 가장 흔하게 만나는 실수입니다. IOException 같은 체크 예외가 터져도 트랜잭션은 정상 커밋됩니다.
@Transactional
public void importFile(MultipartFile file) throws IOException {
orderRepository.save(order);
fileService.store(file); // IOException 발생!
// order는 롤백되지 않고 커밋됨 — 데이터 불일치
}
실무에서는
@Transactional(rollbackFor = Exception.class)를 습관처럼 명시하는 것이 안전합니다. Spring 기본 정책만 믿으면 체크 예외에서 데이터가 꼬입니다.
3. readOnly=true인데 CUD 작업하면 조용히 무시될 수 있다
readOnly = true를 설정하면 Hibernate가 플러시를 생략합니다. 이때 save()를 호출해도 예외 없이 그냥 무시됩니다.
@Transactional(readOnly = true)
public void updateName(Long id, String name) {
Member member = memberRepository.findById(id).orElseThrow();
member.setName(name);
// Dirty Checking이 동작하지 않음
// 예외도 없고, DB에도 반영 안 됨
}
컴파일 에러도, 런타임 에러도 없어서 버그를 발견하기까지 시간이 걸립니다. 로그에도 안 찍히기 때문에 "분명 코드는 맞는데 왜 안 바뀌지?" 상태에 빠지게 됩니다.
주의할 점
1. 클래스 레벨 @Transactional(readOnly = true)에 메서드 레벨 @Transactional을 오버라이드할 때 rollbackFor가 초기화된다
클래스 레벨에 @Transactional(readOnly = true, rollbackFor = Exception.class)를 설정하고, 메서드 레벨에 @Transactional만 붙이면 rollbackFor가 기본값(빈 배열)으로 리셋됩니다. 메서드 레벨에서 오버라이드할 때는 rollbackFor도 명시적으로 다시 지정해야 합니다.
2. @Transactional이 인터페이스에 붙어있으면 JDK Dynamic Proxy에서만 동작한다
인터페이스의 메서드에 @Transactional을 붙이면, CGLIB 프록시를 사용하는 Spring Boot 기본 설정에서는 이 어노테이션이 무시될 수 있습니다. 클래스나 구현체 메서드에 직접 붙이는 것이 안전합니다.
3. 트랜잭션 타임아웃(timeout)을 설정하지 않으면 느린 쿼리가 커넥션을 무한히 점유한다
@Transactional의 timeout 기본값은 -1(무제한)입니다. 느린 쿼리나 외부 API 호출이 트랜잭션 안에 있으면 DB 커넥션이 장시간 점유되어 커넥션 풀이 고갈될 수 있습니다. 운영 환경에서는 적절한 timeout 값을 반드시 설정하세요.
정리
@Transactional은 AOP 프록시 를 통해 트랜잭션 시작/커밋/롤백을 자동 관리합니다.- 기본 롤백 규칙은 RuntimeException과 Error에서만 롤백 이며, 체크 예외는 커밋됩니다.
readOnly = true는 Hibernate의 변경 감지를 건너뛰어 조회 성능을 최적화 합니다.- 메서드 레벨 의 @Transactional이 클래스 레벨보다 우선 적용됩니다.
PlatformTransactionManager가 실제 트랜잭션 관리를 담당하며, JPA 환경에서는JpaTransactionManager가 사용됩니다.