- Published on
Spring Boot 3에서 @Transactional 무시되는 7가지
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 장애처럼 보이는 데이터 정합성 문제의 상당수는 “트랜잭션이 당연히 걸렸을 것”이라는 가정에서 시작합니다. Spring Boot 3(Spring Framework 6)에서도 @Transactional은 여전히 강력하지만, 프록시 기반 AOP라는 전제 때문에 특정 조건에서 조용히 무시되거나(정확히는 트랜잭션 경계가 생성되지 않거나) 롤백이 기대와 다르게 동작할 수 있습니다.
이 글에서는 실무에서 가장 자주 만나고, Spring Boot 3로 오면서(또는 Jakarta 전환 이후) 더 헷갈리기 쉬운 7가지 패턴을 재현 코드와 함께 정리합니다. 운영에서 문제가 터졌다면 애플리케이션 로그뿐 아니라 DB/인프라 지표도 같이 봐야 합니다. 예를 들어 DB 커넥션 고갈이나 타임아웃이 겹치면 트랜잭션 문제로 오인하기 쉽습니다. (네트워크 타임아웃 관점은 Python httpx ReadTimeout·ConnectError 재시도 설계도 참고)
1) 같은 클래스 내부 호출(Self-invocation)로 프록시를 우회
@Transactional은 기본적으로 프록시가 메서드 호출을 가로채며 트랜잭션을 시작합니다. 그런데 같은 클래스 내부에서 this.someTxMethod()처럼 호출하면 프록시를 거치지 않습니다. 결과적으로 트랜잭션이 시작되지 않아 “무시된 것처럼” 보입니다.
재현 코드
@Service
public class OrderService {
private final OrderRepository orderRepository;
public OrderService(OrderRepository orderRepository) {
this.orderRepository = orderRepository;
}
public void placeOrder() {
// 같은 클래스 내부 호출 -> 프록시 우회
saveOrderTx();
}
@Transactional
public void saveOrderTx() {
orderRepository.save(new Order(...));
throw new RuntimeException("fail");
}
}
위 코드는 placeOrder()에서 saveOrderTx()를 호출하지만, 실제로는 트랜잭션이 걸리지 않을 수 있습니다(프록시 밖에서 내부 메서드 호출).
해결 방법
- 트랜잭션 메서드를 다른 빈으로 분리해서 호출
- 또는
ApplicationContext로 프록시 빈을 주입받아 그 빈을 통해 호출(권장도 낮음)
@Service
public class OrderFacade {
private final OrderTxService orderTxService;
public OrderFacade(OrderTxService orderTxService) {
this.orderTxService = orderTxService;
}
public void placeOrder() {
orderTxService.saveOrderTx();
}
}
@Service
class OrderTxService {
private final OrderRepository repo;
OrderTxService(OrderRepository repo) { this.repo = repo; }
@Transactional
public void saveOrderTx() {
repo.save(new Order(...));
throw new RuntimeException("fail");
}
}
2) private/final 메서드에 붙여서 AOP 적용이 안 됨
프록시 기반 AOP는 기본적으로 오버라이드 가능한(public/protected) 메서드를 가로채는 방식입니다. 따라서 다음은 트랜잭션이 적용되지 않을 가능성이 큽니다.
private메서드에@Transactionalfinal메서드(또는final클래스 + CGLIB 제약)
재현 코드
@Service
public class PaymentService {
@Transactional
private void payInternal() {
// 트랜잭션이 적용되지 않을 수 있음
}
public void pay() {
payInternal();
}
}
해결 방법
- 트랜잭션 경계는 public 메서드에 선언
- 내부 로직은 private로 두되, 트랜잭션 시작은 public 메서드에서
@Service
public class PaymentService {
@Transactional
public void pay() {
payInternal();
}
private void payInternal() {
// 실제 로직
}
}
3) 예외를 잡아먹어서 롤백이 안 됨(try-catch)
@Transactional이 “무시”된 것처럼 보이는 가장 흔한 이유는 롤백이 안 되기 때문입니다. 트랜잭션은 시작됐지만, 예외를 catch로 삼켜서 정상 종료로 간주되면 커밋됩니다.
재현 코드
@Transactional
public void register() {
userRepository.save(new User(...));
try {
externalCall();
throw new RuntimeException("boom");
} catch (Exception e) {
// 예외를 삼키면 트랜잭션은 정상 종료 -> 커밋될 수 있음
log.warn("ignored", e);
}
}
해결 방법
- 예외를 다시 던지기
- 또는 명시적으로 rollback-only 마킹
@Transactional
public void register() {
userRepository.save(new User(...));
try {
externalCall();
throw new RuntimeException("boom");
} catch (Exception e) {
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
throw e; // 또는 비즈니스 예외로 래핑
}
}
4) 체크 예외(checked exception)는 기본 롤백 대상이 아님
Spring의 기본 규칙은:
RuntimeException/Error발생 시 롤백- Checked Exception(예:
IOException)은 기본적으로 롤백하지 않음
그래서 “예외가 났는데도 데이터가 남는다” → @Transactional이 무시된 것처럼 보입니다.
재현 코드
@Transactional
public void importFile() throws IOException {
repository.save(new Item(...));
throw new IOException("read failed");
}
해결 방법
rollbackFor지정
@Transactional(rollbackFor = IOException.class)
public void importFile() throws IOException {
repository.save(new Item(...));
throw new IOException("read failed");
}
- 또는 체크 예외를 런타임 예외로 변환(도메인 정책에 따라)
5) 전파(Propagation) 설정 때문에 바깥 트랜잭션에 합류/분리됨
@Transactional이 “안 먹는다”는 말은 사실 내가 기대한 경계가 아니다라는 뜻인 경우가 많습니다. 특히 REQUIRES_NEW, NOT_SUPPORTED, SUPPORTS 같은 전파 옵션이 섞이면 롤백/커밋 단위가 달라집니다.
대표 함정: REQUIRES_NEW가 내부에서 커밋해버림
@Service
public class OuterService {
private final InnerService innerService;
private final Repo repo;
public OuterService(InnerService innerService, Repo repo) {
this.innerService = innerService;
this.repo = repo;
}
@Transactional
public void outer() {
repo.save(new Entity("outer"));
innerService.inner(); // 여기서 별도 트랜잭션으로 커밋될 수 있음
throw new RuntimeException("outer fail");
}
}
@Service
class InnerService {
private final Repo repo;
InnerService(Repo repo) { this.repo = repo; }
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void inner() {
repo.save(new Entity("inner"));
}
}
outer()가 실패해도 inner()는 이미 별도 트랜잭션으로 커밋되어 데이터가 남습니다.
해결 방법
- 전파 옵션을 의도에 맞게 통일
- “부분 커밋”이 필요하다면 명확히 설계(보상 트랜잭션/사가)
6) @Async / 다른 스레드로 넘어가며 트랜잭션 컨텍스트가 끊김
Spring 트랜잭션은 기본적으로 ThreadLocal 기반입니다. 즉, 트랜잭션 컨텍스트는 스레드에 묶입니다. @Async, CompletableFuture, 별도 스레드풀로 작업을 넘기면 같은 트랜잭션으로 묶이지 않습니다.
재현 코드
@Service
public class AsyncService {
private final Repo repo;
public AsyncService(Repo repo) {
this.repo = repo;
}
@Transactional
public void doWork() {
repo.save(new Entity("before"));
runAsync();
throw new RuntimeException("fail");
}
@Async
public void runAsync() {
// 별도 스레드: doWork의 트랜잭션과 무관
repo.save(new Entity("async"));
}
}
결과:
doWork()는 롤백될 수 있지만runAsync()는 별도 트랜잭션/auto-commit로 저장되어 남을 수 있음
해결 방법
- 비동기 작업을 트랜잭션 밖으로 분리하고, 이벤트/메시지로 일관성 설계
- 정말 필요하다면 비동기 메서드에도 트랜잭션을 명시(단, 같은 트랜잭션 공유는 아님)
7) 트랜잭션 매니저/데이터소스가 다르거나, 읽기 전용(readOnly) 최적화에 발목
7-1) 여러 DataSource에서 다른 TransactionManager를 타는 경우
멀티 데이터소스 환경에서 @Transactional이 기본 매니저를 사용해 다른 DB에만 적용되거나, 실제로 쓰는 리포지토리의 매니저와 불일치하면 트랜잭션이 기대와 다르게 보입니다.
- 어떤
PlatformTransactionManager가 기본인지 @Transactional(transactionManager = "...")를 지정했는지- JPA 리포지토리가 어떤
EntityManagerFactory에 묶였는지
를 함께 점검해야 합니다.
@Transactional(transactionManager = "orderTxManager")
public void createOrder() {
orderRepo.save(...);
}
7-2) readOnly=true가 쓰기를 막거나 플러시 타이밍을 바꿈
@Transactional(readOnly = true)는 DB에 따라 강제는 아니지만, JPA/Hibernate 쪽에서 flush 전략 최적화가 걸려 쓰기/변경 감지가 기대대로 되지 않는 상황을 만들 수 있습니다.
@Transactional(readOnly = true)
public void updateUserName(Long id, String name) {
User u = userRepo.findById(id).orElseThrow();
u.setName(name); // 변경해도 flush가 안 되거나 예외/무시처럼 보일 수 있음
}
해결은 단순합니다.
- 조회 전용 메서드와 갱신 메서드를 분리
- 갱신 메서드는
readOnly=false(기본값)로 유지
운영에서 “정말 트랜잭션이 시작됐는지” 확인하는 방법
원인 추정을 코드만으로 끝내면 위험합니다. 아래처럼 로그/설정으로 트랜잭션 경계를 눈으로 확인하면 빠르게 좁힐 수 있습니다.
1) 트랜잭션 디버그 로그
application.yml 예시:
logging:
level:
org.springframework.transaction: TRACE
org.springframework.orm.jpa: DEBUG
org.hibernate.SQL: DEBUG
Creating new transaction with name ...Participating in existing transactionInitiating transaction rollback
같은 로그가 실제로 찍히는지 확인합니다.
2) 커넥션/타임아웃 이슈도 함께 배제
트랜잭션이 “무시”된 게 아니라, 커넥션 풀 고갈/DB 응답 지연으로 중간에 실패하면서 애매한 상태가 남는 경우도 많습니다. 컨테이너 환경이라면 OOM/리소스 제한으로 애플리케이션이 재시작되며 미완료 작업이 반복될 수도 있습니다. 이런 경우는 트랜잭션 자체보다 런타임 안정성 점검이 먼저입니다. (리소스 관점은 EKS Pod OOMKilled 반복 원인과 메모리·GC·Limit 튜닝 참고)
체크리스트: @Transactional이 무시되는지 30초 점검
- 트랜잭션 메서드가 public이고 프록시를 통해 호출되는가? (자가호출 아님)
-
private/final에 붙이지 않았는가? - 예외를
catch로 삼키지 않았는가? - 체크 예외라면
rollbackFor가 필요한가? - 전파 옵션(
REQUIRES_NEW등)이 의도와 맞는가? -
@Async/다른 스레드로 넘어가며 경계가 끊기지 않는가? - 올바른
TransactionManager/DataSource를 쓰는가?readOnly는 적절한가?
마무리
Spring Boot 3에서 @Transactional이 “무시”되는 대부분의 사례는 프레임워크 버그가 아니라 프록시 기반 AOP의 호출 경로, 예외/전파 규칙, 스레드 경계에서 발생합니다. 위 7가지를 재현 코드로 하나씩 제거해 나가면, 트랜잭션이 실제로 시작됐는지/왜 롤백이 안 됐는지를 빠르게 특정할 수 있습니다.
다음 단계로는 @DataJpaTest 또는 통합 테스트에서 롤백/커밋 결과를 검증하는 테스트를 추가해, 리팩터링이나 비동기 도입 시에도 트랜잭션 경계가 깨지지 않도록 안전망을 만드는 것을 권장합니다.