메서드에 트랜잭션 경계를 선언하는 애노테이션. 내부적으로는 AOP 프록시가 setAutoCommit(false) → commit/rollback을 대신 호출한다.
무엇을 대신해 주나
// 이 한 줄이
@Transactional
void transfer(...) { ... }
// 이걸 대신한다
try (Connection con = ds.getConnection()) {
con.setAutoCommit(false);
try { /* 본문 */ con.commit(); }
catch (Exception e) { con.rollback(); throw e; }
}
가장 많이 데는 함정 — 검사 예외는 롤백되지 않는다
@Transactional
void order() throws IOException {
repo.save(o);
throw new IOException("실패"); // 검사 예외 → 커밋된다!
}
기본 롤백 규칙
RuntimeException · Error → 롤백
Exception (검사 예외) → 커밋 ← 직관과 반대다
@Transactional(rollbackFor = Exception.class) // 모든 예외에 롤백
이 기본값은 EJB 시절의 관례를 물려받은 것이라 논리적 필연이 아니다. 그래서 더 헷갈린다.
프록시라서 생기는 제약
- private · final 메서드 — → 프록시가 오버라이드 못 한다 → 무시된다
- 자기 호출 — → 프록시를 안 거친다 → 무시된다
- 빈이 아닌 객체 — → 프록시 자체가 없다
아무 예외 없이 조용히 무시된다는 점이 위험하다.
예외를 잡아먹으면 롤백되지 않는다
@Transactional
void outer() {
try { inner(); }
catch (Exception e) { log.error("무시", e); } // 삼켰다 → 커밋된다
}
다만 내부 트랜잭션이 rollback-only로 표시한 경우는 다르다.
@Transactional(REQUIRED) 인 inner 가 예외로 실패
- 같은 트랜잭션에 rollback-only 표시가 붙는다
- outer 가 예외를 삼켜도 커밋 시점에
- UnexpectedRollbackException: Transaction silently rolled back
readOnly의 실제 효과
@Transactional(readOnly = true)
- Hibernate 가 스냅샷을 만들지 않는다 → 메모리·변경 감지 비용 절약
- flush 모드가 MANUAL 이 되어 불필요한 flush 가 사라진다
- 일부 환경에서 읽기 전용 복제본으로 라우팅되는 근거가 된다
조회 전용 메서드에 붙이는 습관이 실무 기본값이다.
면접 함정
- ❌ "예외가 나면 무조건 롤백" → 검사 예외는 커밋된다.
- ❌ "트랜잭션 안에서 외부 API를 불러도 된다" → 그동안 커넥션이 묶인다. 밖으로 빼거나 커밋 후로 미룬다.
클래스에 붙일 때의 범위
@Service
@Transactional(readOnly = true) // 클래스 기본값: 읽기 전용
class OrderService {
public Order find(Long id) { ... } // readOnly = true 적용
@Transactional // 메서드에서 재정의 (쓰기)
public void create(...) { ... }
}
"클래스에 readOnly, 쓰기 메서드만 재정의" 가 널리 쓰이는 관용구다. 조회에 실수로 쓰기가 섞이는 것도 막아 준다.
타임아웃을 건다
@Transactional(timeout = 5) // 5초를 넘기면 롤백시킨다
느린 쿼리 하나가 커넥션과 락을 오래 쥐는 것을 막는 안전장치다. 긴 배치에는 반드시 명시한다.
트랜잭션 밖으로 빼야 할 것들
-
외부 API 호출 — 느린 I/O 가 커넥션과 락을 인질로 잡는다
-
메일 · 알림 발송 — 롤백돼도 되돌릴 수 없다 → AFTER_COMMIT 으로
-
파일 업로드 — 시간이 오래 걸린다
-
대용량 계산 — DB 와 무관한 CPU 작업
-
"트랜잭션은 DB 작업만, 짧게" 가 원칙이다