- Published on
Spring Boot 3에서 @Transactional이 안 먹는 6가지
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서론
Spring Boot에서 @Transactional은 “메서드 호출 전후로 트랜잭션을 열고/커밋(또는 롤백)한다”는 약속을 해줍니다. 그런데 실무에서는 분명 @Transactional을 붙였는데도 롤백이 안 되거나, 커밋 타이밍이 이상하거나, 아예 트랜잭션이 시작되지 않는 상황을 자주 만납니다.
대부분의 원인은 DB나 JPA가 아니라 Spring AOP 프록시가 트랜잭션 경계를 만드는 방식에서 출발합니다. Spring Boot 3(Sprint Framework 6)에서도 핵심 원리는 동일하며, Jakarta 전환 이후 패키지/설정 차이로 인해 “안 먹는 것처럼 보이는” 케이스가 더 눈에 띄기도 합니다.
이 글에서는 Spring Boot 3 기준으로 @Transactional이 안 먹는 대표적인 6가지를 재현 코드 + 진단 포인트 + 해결책으로 정리합니다.
> 참고: 인증/리다이렉트 문제로 요청이 반복되면 트랜잭션이 끊겨 보일 때도 있습니다. 보안 설정 이슈가 의심되면 Spring Security OAuth2 리다이렉트 루프 끊는 법도 함께 확인해보세요.
1) 같은 클래스 내부 호출(Self-invocation)로 프록시를 우회함
증상
@Transactional을 붙인 메서드를 같은 클래스의 다른 메서드에서 호출했는데 트랜잭션이 시작되지 않음- 예외가 발생해도 롤백이 안 됨
왜 이런가?
Spring의 선언적 트랜잭션은 기본적으로 프록시 기반 AOP입니다. 즉, 외부에서 빈을 호출할 때 프록시가 가로채 트랜잭션을 시작합니다. 하지만 같은 객체 내부에서 this.someTxMethod()처럼 호출하면 프록시를 거치지 않고 실제 메서드를 직접 호출하게 됩니다.
재현 코드
@Service
public class OrderService {
public void placeOrder() {
// 같은 클래스 내부 호출 -> 프록시 우회
saveOrderTx();
}
@Transactional
public void saveOrderTx() {
// DB 저장 후 예외
throw new RuntimeException("boom");
}
}
해결책
- 트랜잭션 메서드를 다른 빈으로 분리
@Service
public class OrderTxService {
@Transactional
public void saveOrderTx() {
throw new RuntimeException("boom");
}
}
@Service
public class OrderService {
private final OrderTxService orderTxService;
public OrderService(OrderTxService orderTxService) {
this.orderTxService = orderTxService;
}
public void placeOrder() {
orderTxService.saveOrderTx();
}
}
- (권장도 낮음)
AopContext.currentProxy()로 프록시를 통해 호출하려면exposeProxy설정이 필요합니다. 구조가 복잡해지고 테스트가 어려워져서 보통은 빈 분리가 가장 깔끔합니다.
2) private, final 메서드/클래스라 프록시 적용이 안 됨
증상
@Transactional을 붙였는데도 로그/디버깅 상 트랜잭션이 시작되지 않음- 특히 Kotlin에서
final이 기본이라 더 자주 발생
왜 이런가?
Spring 프록시는 크게 두 가지 방식이 있습니다.
- JDK 동적 프록시: 인터페이스 기반
- CGLIB 프록시: 클래스를 상속해서 오버라이드
CGLIB는 상속/오버라이드가 필요하기 때문에 final class, final method, private method에는 적용이 제한됩니다. 또한 private 메서드는 외부에서 호출 자체가 어렵고, AOP 포인트컷도 걸기 애매합니다.
재현 코드
@Service
public class PaymentService {
@Transactional
private void payInternal() {
// 프록시 적용 대상이 되기 어려움
}
}
해결책
- 트랜잭션 경계는 보통 **public 메서드(서비스 계층)**에 둡니다.
- Kotlin이라면
kotlin-spring플러그인(all-open)을 적용해@Service,@Transactional대상 클래스를 open 처리하세요.
3) 예외가 발생했는데도 롤백이 안 됨 (체크 예외/잡아먹기)
증상
- 예외가 분명히 발생했는데 DB 변경이 커밋됨
- 또는 예외를 catch한 뒤 다시 던지지 않으면 롤백이 안 됨
왜 이런가?
기본 정책은 다음과 같습니다.
- RuntimeException / Error: 롤백
- Checked Exception: 커밋(롤백 안 함)
또한 예외를 catch로 잡고 정상 흐름으로 끝내면 스프링은 “정상 종료”로 판단해 커밋합니다.
재현 코드 1: 체크 예외
@Transactional
public void updateProfile() throws Exception {
// ...
throw new Exception("checked"); // 기본 설정에선 롤백 안 될 수 있음
}
재현 코드 2: 예외를 삼킴
@Transactional
public void updateProfile() {
try {
// ...
throw new RuntimeException("boom");
} catch (RuntimeException e) {
// 로깅만 하고 끝 -> 트랜잭션은 커밋될 수 있음
}
}
해결책
- 체크 예외도 롤백하려면
rollbackFor지정
@Transactional(rollbackFor = Exception.class)
public void updateProfile() throws Exception {
throw new Exception("checked");
}
- 예외를 잡았다면
- 다시 던지거나
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()로 롤백 마킹
@Transactional
public void updateProfile() {
try {
throw new RuntimeException("boom");
} catch (RuntimeException e) {
org.springframework.transaction.interceptor.TransactionAspectSupport
.currentTransactionStatus()
.setRollbackOnly();
}
}
4) 비동기/다른 스레드로 넘어가면서 트랜잭션 컨텍스트가 끊김
증상
@Async,CompletableFuture, 스레드풀로 넘긴 작업에서 DB 접근 시 트랜잭션이 없는 것처럼 동작- Lazy 로딩 실패, 예상치 못한 커밋/플러시 타이밍
왜 이런가?
Spring 트랜잭션은 기본적으로 ThreadLocal 기반으로 바인딩됩니다. 즉, 트랜잭션은 “현재 스레드”에 귀속됩니다. 다른 스레드로 넘어가면 트랜잭션 컨텍스트가 전달되지 않습니다.
재현 코드
@Service
public class ReportService {
@Async
@Transactional
public void generateAsync() {
// 이 메서드 자체는 별도 스레드에서 새 트랜잭션이 열릴 수 있음
// 하지만 호출자가 기대한 트랜잭션과 동일하지 않음
}
@Transactional
public void requestReport() {
generateAsync();
// 여기 트랜잭션과 generateAsync의 트랜잭션은 분리됨
}
}
해결책
- “같은 트랜잭션으로 묶고 싶다”면 비동기로 분리하지 말아야 합니다.
- 비동기가 필요하면 트랜잭션 경계를 비동기 메서드 내부에서 독립적으로 설계하세요.
- 이벤트 기반 처리라면
@TransactionalEventListener(phase = AFTER_COMMIT)로 커밋 이후 비동기 작업을 트리거하는 방식이 안전합니다.
5) 트랜잭션 전파(propagation)와 읽기 전용(readOnly) 오해
증상
- 내부 메서드에
REQUIRES_NEW를 줬는데 기대대로 분리되지 않음(또는 반대) readOnly=true인데도 업데이트가 되는 것 같거나, 반대로 업데이트가 누락되는 것처럼 보임
포인트
(1) 전파 옵션은 “프록시를 타고 들어갈 때”만 의미가 큼
전파 옵션 역시 AOP로 적용됩니다. 즉, self-invocation이면 전파 옵션도 무력화됩니다.
(2) readOnly=true는 “쓰기 금지”가 아니라 “힌트”에 가깝다
JPA/Hibernate에서 readOnly는 플러시 모드, dirty checking 최적화에 영향을 주지만 DB 레벨에서 강제되는 제약이 아닙니다(구현/설정에 따라 다름). 그래서 “안전장치”로만 믿으면 위험합니다.
예시 코드
@Service
public class AccountService {
@Transactional(readOnly = true)
public Account find(long id) {
return accountRepository.findById(id).orElseThrow();
}
@Transactional
public void deposit(long id, long amount) {
Account a = accountRepository.findById(id).orElseThrow();
a.deposit(amount);
}
}
해결책
- 전파 옵션을 설계할 때는 호출 구조가 프록시를 타는지(빈 분리)부터 확인
readOnly는 성능 최적화로 쓰되, 쓰기 방지는 DB 권한/제약/애플리케이션 로직으로 보완
6) 테스트/설정 환경에서 트랜잭션이 “이미 다른 규칙”으로 동작 중
증상
- 운영에서는 롤백되는데 테스트에서는 항상 롤백(또는 반대)
@SpringBootTest에서 데이터가 남지 않거나, 예상과 다르게 커밋됨
왜 이런가?
스프링 테스트는 기본적으로 다음이 흔합니다.
- 테스트 메서드/클래스에
@Transactional이 붙으면 테스트 종료 시 롤백 @DataJpaTest는 기본적으로 트랜잭션 롤백@Commit,@Rollback(false)로 바뀜
또한 여러 PlatformTransactionManager(예: JPA + JDBC, 멀티 데이터소스)가 존재하면 어떤 매니저가 선택되는지에 따라 “안 먹는 것처럼” 보일 수 있습니다.
재현/확인 코드
@SpringBootTest
@Transactional
class OrderServiceTest {
@Test
void test() {
// 여기서 저장해도 테스트 끝나면 롤백될 가능성이 큼
}
}
해결책
- 테스트에서 커밋을 확인해야 하면 명시적으로
@SpringBootTest
class OrderServiceCommitTest {
@Test
@org.springframework.test.annotation.Commit
void testCommit() {
// 커밋 확인
}
}
- 멀티 트랜잭션 매니저라면
@Transactional(transactionManager = "...")로 명시
진단 체크리스트 (빠르게 원인 좁히기)
아래 순서로 보면 대부분 10분 내로 원인이 좁혀집니다.
- 호출이 다른 빈을 통해서 일어나는가? (self-invocation 여부)
- 대상 메서드가
public인가? (Kotlinfinal여부 포함) - 예외가 RuntimeException으로 밖까지 전파되는가? (catch로 삼키지 않았는가)
- 다른 스레드로 넘어가지는 않는가? (
@Async, Future, 스케줄러) - 테스트/환경에서 기본 롤백 규칙이 덮어쓰고 있지 않은가?
- 로그로 트랜잭션 시작/커밋을 확인했는가?
트랜잭션 로그는 아래처럼 켜면 도움이 됩니다.
# application.properties
logging.level.org.springframework.transaction=TRACE
logging.level.org.springframework.orm.jpa=DEBUG
마무리
Spring Boot 3에서 @Transactional이 안 먹는 문제는 대부분 “트랜잭션이 적용되지 않았다”기보다 프록시를 타지 않았다, 예외 정책을 오해했다, 스레드 경계가 바뀌었다로 귀결됩니다.
특히 1번(self-invocation)과 3번(예외 롤백 규칙)은 재현 빈도가 매우 높습니다. 트랜잭션이 의심될 때는 비즈니스 로직을 고치기 전에 먼저 호출 구조(빈 경계)와 예외 흐름부터 점검하세요.
추가로, 요청이 반복되거나 인증 흐름이 꼬여서 동일 작업이 여러 번 수행되는 경우 트랜잭션 문제가 아닌 “요청 자체가 비정상”일 수 있습니다. 그런 케이스는 Spring Security OAuth2 리다이렉트 루프 끊는 법을 함께 확인하면 원인 분리가 빨라집니다.