- Published on
Spring Boot 3에서 @Transactional 먹통 원인 7가지
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버에서 분명 @Transactional을 붙였는데도 데이터가 커밋되거나(롤백이 안 됨), 반대로 기대한 격리/락이 전혀 걸리지 않는 것처럼 보이는 순간이 있습니다. Spring Boot 3는 기본적으로 프록시 기반 AOP로 트랜잭션을 적용하므로, 프록시가 끼어들지 못하는 호출 경로가 생기거나 예외/전파 설정이 의도와 다르면 쉽게 “먹통”처럼 보입니다.
이 글은 Spring Boot 3 기준으로 @Transactional이 적용되지 않는 대표 원인 7가지를 재현 가능한 코드와 함께 정리합니다. 운영 장애로 번지기 쉬운 패턴 위주로 다룹니다.
- 관련 글: 인증/세션 문제로 트랜잭션 경계가 꼬인 것처럼 보일 때는 Spring Security JWT 401 - 키 로테이션과 JWKS 캐시도 함께 점검해보세요.
0. 먼저 확인할 기본 전제 3가지
아래 3가지는 원인 7가지로 들어가기 전에 “진짜로 트랜잭션이 시작됐는지”를 빠르게 가늠하는 체크리스트입니다.
- 트랜잭션 매니저가 존재하는가
- JPA면 보통
JpaTransactionManager가 자동 구성됩니다. - JDBC면
DataSourceTransactionManager가 필요합니다.
- 로그로 트랜잭션 시작/종료가 찍히는가
# application.properties
logging.level.org.springframework.transaction=TRACE
logging.level.org.springframework.orm.jpa=DEBUG
- 실제 DB 커넥션이 같은지(동일 트랜잭션인지)
- 같은 트랜잭션이면 같은 커넥션을 재사용하는 경우가 많습니다.
- JPA에서는
EntityManager의 flush 시점/영속성 컨텍스트 때문에 “커밋된 것처럼 보이는” 착시가 생길 수 있어, SQL 로그와 함께 봐야 합니다.
1) 같은 클래스 내부 호출(self-invocation)로 프록시를 우회함
가장 흔한 원인입니다. Spring의 @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 OrderEntity("A"));
throw new RuntimeException("fail");
}
}
위 코드는 saveOrderTx()에 @Transactional이 있어도 롤백이 안 되는 것처럼 보일 수 있습니다(실제로는 트랜잭션이 시작되지 않았을 가능성이 큼).
해결
- 트랜잭션 메서드를 별도 빈으로 분리해서 “다른 빈 호출”로 만들기
- 또는 트랜잭션이 필요한 메서드를 외부에서 직접 호출되도록 설계
@Service
public class OrderTxService {
private final OrderRepository orderRepository;
public OrderTxService(OrderRepository orderRepository) {
this.orderRepository = orderRepository;
}
@Transactional
public void saveOrderTx() {
orderRepository.save(new OrderEntity("A"));
throw new RuntimeException("fail");
}
}
@Service
public class OrderService {
private final OrderTxService orderTxService;
public OrderService(OrderTxService orderTxService) {
this.orderTxService = orderTxService;
}
public void placeOrder() {
orderTxService.saveOrderTx();
}
}
2) final 클래스/메서드, private 메서드에 붙여서 프록시 적용이 안 됨
프록시 방식은 “가로채기”가 가능해야 합니다.
private메서드: 외부에서 호출될 수 없으니 프록시가 개입할 지점이 없음final메서드/클래스: CGLIB 기반 프록시가 오버라이드할 수 없어 적용이 제한될 수 있음
흔한 실수
@Service
public class PaymentService {
@Transactional
private void payInternal() {
// 트랜잭션 기대하지만 적용되지 않음
}
}
해결
@Transactional은 보통public메서드에 붙이세요.- Kotlin을 쓰면 기본이
final이라 더 자주 터집니다. Kotlin에서는kotlin-spring플러그인(올오픈) 적용이 사실상 필수입니다.
plugins {
kotlin("jvm")
kotlin("plugin.spring")
}
3) 예외 타입 때문에 롤백이 안 됨(Checked Exception)
Spring의 기본 롤백 규칙은 다음과 같습니다.
RuntimeException및Error는 롤백- Checked Exception은 기본적으로 롤백하지 않음
즉, 비즈니스 예외를 Checked Exception으로 던지면 “왜 커밋됐지”가 됩니다.
재현 코드
@Transactional
public void updateProfile() throws Exception {
// ... update
throw new Exception("checked exception");
}
해결
- Checked Exception에도 롤백하려면
rollbackFor를 지정합니다.
@Transactional(rollbackFor = Exception.class)
public void updateProfile() throws Exception {
throw new Exception("checked exception");
}
- 반대로 특정 런타임 예외는 롤백하지 않게 하려면
noRollbackFor를 사용합니다.
@Transactional(noRollbackFor = IllegalArgumentException.class)
public void doSomething() {
throw new IllegalArgumentException("no rollback");
}
4) 전파(propagation) 설정으로 “트랜잭션이 없는 것처럼” 보임
전파 옵션을 잘못 쓰면 트랜잭션 경계가 예상과 달라집니다.
REQUIRES_NEW 남발로 바깥 롤백과 분리됨
바깥 트랜잭션이 롤백되어도, 내부 REQUIRES_NEW는 이미 커밋되어 데이터가 남습니다. 이걸 보고 @Transactional이 먹통이라고 오해하기 쉽습니다.
@Service
public class OuterService {
private final InnerService innerService;
public OuterService(InnerService innerService) {
this.innerService = innerService;
}
@Transactional
public void outer() {
innerService.innerRequiresNew();
throw new RuntimeException("outer failed");
}
}
@Service
public class InnerService {
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void innerRequiresNew() {
// 여기는 별도 트랜잭션으로 커밋됨
}
}
NOT_SUPPORTED나 NEVER로 트랜잭션을 끊어버림
NOT_SUPPORTED: 현재 트랜잭션이 있으면 중단하고 비트랜잭션으로 실행NEVER: 트랜잭션이 있으면 예외
특히 공통 유틸/로깅/감사(Audit) 코드에 이런 옵션이 붙어 있으면 예상치 못한 경계가 생깁니다.
해결
- 전파 옵션은 “기술적 이유”가 명확할 때만 사용하고, 기본은
REQUIRED로 두는 게 안전합니다. - “부분 커밋”이 필요하다면, 왜 필요한지(예: 감사 로그는 반드시 남겨야 함)를 문서화하고 테스트로 고정하세요.
보상 트랜잭션이 필요한 케이스라면 DB 트랜잭션만으로 해결하려다 더 꼬일 수 있습니다. 분산/비동기 플로우라면 Saga 보상트랜잭션 설계 - 중복·순서꼬임 해결를 참고해 설계를 분리하는 편이 낫습니다.
5) 비동기/스레드 경계(@Async, 스케줄러, 이벤트)로 트랜잭션 컨텍스트가 전달되지 않음
Spring 트랜잭션은 기본적으로 스레드 로컬 기반입니다. 즉, 다른 스레드로 넘어가면 같은 트랜잭션이 이어지지 않습니다.
@Async 재현
@Service
public class EmailService {
@Async
public void sendAsync() {
// 여기에는 호출자 트랜잭션이 없음
}
}
@Service
public class UserService {
private final EmailService emailService;
public UserService(EmailService emailService) {
this.emailService = emailService;
}
@Transactional
public void register() {
// DB 저장
emailService.sendAsync();
// 여기서 예외가 나서 롤백돼도, async는 별개로 실행될 수 있음
}
}
이벤트 리스너도 마찬가지
@EventListener는 기본적으로 같은 스레드에서 실행될 수 있지만, 트랜잭션 커밋 전/후 타이밍이 중요합니다.- 커밋 이후에만 실행되어야 하면
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)를 고려하세요.
@Component
public class UserEventHandler {
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onUserCreated(UserCreatedEvent event) {
// 커밋 이후 실행
}
}
해결
- “같은 트랜잭션에서 처리돼야 하는 일”은 비동기로 보내지 말고 동기 흐름에 두기
- 비동기가 필요하면, 트랜잭션 경계 밖에서 메시지 발행(Outbox 패턴 등)으로 일관성을 설계하기
6) 테스트 환경에서 롤백/커밋 동작을 착각함(@SpringBootTest, @DataJpaTest)
테스트에서는 프레임워크가 기본 롤백을 걸어두는 경우가 많습니다.
@DataJpaTest는 기본적으로 테스트 종료 시 롤백@Transactional이 테스트 메서드/클래스에 붙어 있으면 테스트도 트랜잭션으로 감싸짐
이때 “서비스의 @Transactional이 안 먹는다”가 아니라 “테스트 트랜잭션이 바깥에서 감싸고 있어서 전파가 다르게 보이는” 상황이 흔합니다.
재현 포인트
- 테스트에서
REQUIRES_NEW를 호출하면 실제로 커밋이 일어나 DB에 남는지 @Commit혹은@Rollback(false)를 붙였을 때 결과가 달라지는지
@SpringBootTest
@Transactional
class UserServiceTest {
@Test
void testSomething() {
// 이 테스트 자체가 트랜잭션
}
}
해결
- 트랜잭션 경계를 검증하는 테스트는
@Transactional을 테스트에 붙이지 말고, 필요한 경우에만 명시 - 커밋 여부를 확인해야 하면
@Commit을 사용
@Test
@Commit
void shouldCommitForVerification() {
// 커밋 확인용
}
7) 트랜잭션은 시작됐지만 “쓰기 지연/flush 타이밍” 때문에 먹통처럼 보임(JPA)
JPA는 변경 감지와 쓰기 지연으로 인해 SQL이 즉시 나가지 않을 수 있습니다. 이때 다음과 같은 착시가 생깁니다.
- “예외가 났는데도 DB에 반영된 것처럼 보인다”
- “락이 걸렸어야 하는데 안 걸린 것 같다”
실제로는
- 같은 트랜잭션 안에서 영속성 컨텍스트에만 반영됐고, 커밋 시점에 flush되며 SQL이 실행
- 혹은 조회가 1차 캐시에서 나가 DB를 다시 보지 않음
재현 예시: flush 시점 확인
@Transactional
public void updateAndFail(EntityManager em) {
User user = em.find(User.class, 1L);
user.setName("changed");
// 여기서 DB에 SQL이 안 나갈 수 있음
// 강제로 flush하면 여기서 SQL 실행
em.flush();
throw new RuntimeException("fail");
}
위 코드는 flush()로 인해 SQL이 실행되지만, 트랜잭션이 롤백되면 DB 최종 상태는 원복되어야 정상입니다. 다만 로그/모니터링에서 SQL이 찍히는 걸 보고 “커밋됐다”고 오해할 수 있습니다.
해결
- “DB에 실제로 반영됐는지”는 커밋 이후 다른 트랜잭션/다른 커넥션에서 재조회로 확인
- 운영에서 혼선을 줄이려면 SQL 로그뿐 아니라 트랜잭션 커밋/롤백 로그도 함께 보세요.
빠른 진단 체크리스트(운영/로컬 공통)
- 호출 경로가 프록시를 타는가
- 컨트롤러
->서비스->리포지토리처럼 “빈 간 호출”인지 확인(화살표는->처럼 인라인 코드로 표기) - 같은 클래스 내부 호출이면 1번 원인 가능성 큼
- 메서드 시그니처가 프록시 친화적인가
public인지- Kotlin/
final이슈 없는지
예외가 런타임 예외인지,
rollbackFor가 필요한지전파 옵션이 기본(
REQUIRED)인지
REQUIRES_NEW로 부분 커밋 의도가 있는지 재점검
- 스레드 경계를 넘는지
@Async, 스케줄러, 메시지 리스너, 이벤트 리스너
테스트가 바깥에서 트랜잭션을 감싸고 있는지
JPA flush/캐시 착시를 배제했는지
마무리: “먹통”은 대부분 경계 문제다
Spring Boot 3에서 @Transactional이 동작하지 않는 것처럼 보일 때, 실제로는
- 프록시를 우회했거나
- 트랜잭션 전파/예외 규칙을 잘못 이해했거나
- 스레드/이벤트로 경계를 넘었거나
- JPA의 flush/캐시로 관찰이 왜곡된 경우
가 대부분입니다.
장애 상황에서는 트랜잭션 로그를 켜고, “어떤 메서드 호출이 프록시를 탔는지”와 “커밋/롤백이 실제로 언제 일어났는지”를 먼저 확정하세요. 그 다음 위 7가지를 위에서부터 체크하면 원인에 빠르게 수렴합니다.