빌드노트
같은 클래스에서 부른 @Transactional 메서드는 왜 롤백되지 않을까
같은 @Transactional 메서드도 프록시를 거쳐 호출했는지에 따라 결과가 달라진다. Spring과 메모리 H2에서 트랜잭션 활성 상태·자동 커밋·예외 후 행 수를 비교하고, 서비스 분리와 TransactionTemplate, 바깥 트랜잭션 중 필요한 경계를 고른다.
외부에서 호출하면 롤백되던 @Transactional 메서드가 같은 클래스 안에서 호출될 때는 예외 뒤에도 행을 남길 수 있다. Spring의 기본 프록시 방식에서는 메서드에 애너테이션이 있는지와 그 호출에 트랜잭션 처리가 적용되는지가 다르다. 호출하는 쪽에도 트랜잭션이 없다면 this.saveThenFail()은 새 트랜잭션을 시작하지 않는다.
먼저 저장 메서드를 부르는 한 줄을 확인한다. 같은 객체의 메서드를 바로 호출하고 있다면, 저장을 맡은 별도 Spring 빈으로 옮겨 호출하거나 TransactionTemplate으로 필요한 범위를 직접 감싸는 선택이 가능하다. 전체 작업을 하나로 롤백해야 한다면 바깥 진입 메서드를 트랜잭션 경계로 삼을 수도 있다.
바깥에서 빈을 호출해도 안쪽 this 호출은 다르다
문제가 생기는 호출 관계는 다음과 같다. Writer는 Spring 빈이고 두 메서드 모두 public이다. insertAndProbe()는 합성 데이터 한 행을 넣고, 그 INSERT에 쓴 JDBC 연결과 트랜잭션 상태를 기록한다.
public void selfCall() {
this.saveThenFail("self-call");
}
@Transactional
public void saveThenFail(String label) {
insertAndProbe(label);
throw new IllegalStateException("planned failure");
}
Spring은 트랜잭션 처리를 붙인 프록시를 빈의 호출자에게 전달한다. 외부에서 writer.saveThenFail(...)을 부르면 프록시가 해당 메서드의 트랜잭션 설정을 적용할 기회를 얻는다. 반면 writer.selfCall()로 들어온 뒤 실행되는 this.saveThenFail(...)은 대상 객체 내부의 직접 호출이다. 안쪽 호출이 프록시로 되돌아가지 않는다. Spring의 프록시 설명 (새 탭에서 열림)이 이 차이를 설명한다.
@Transactional이 메서드 본문 자체를 고치는 것은 아니다. Spring 6.2 트랜잭션 문서 (새 탭에서 열림)는 기본 proxy 모드에서 외부로부터 프록시를 거친 호출을 가로챈다고 명시한다. 이 글도 그 모드를 대상으로 한다.
같은 예외 뒤에 0행과 1행이 남았다
운영 장애 기록 대신 별도의 최소 Spring 프로젝트로 비교했다. Windows 11, OpenJDK 17.0.2, Spring Framework 6.2.19, H2 2.3.232를 사용했다. @EnableTransactionManagement(proxyTargetClass = true)와 DataSourceTransactionManager를 명시했고, 앱 설정이나 컴포넌트 검색 없이 필요한 빈만 등록했다.
각 경우를 시작하기 전에 테이블을 비웠다. 메서드 안에서 한 행을 넣은 뒤 IllegalStateException을 던지고, 호출 경계 밖의 main에서 예외를 잡은 다음 행 수를 다시 읽었다. main에는 트랜잭션이 없다. 아래는 실제 출력 중 비교 부분이다.
writer.cglibProxy=true
external inside active=true autoCommit=false rows=1
external after exception=IllegalStateException rows=0
self-call inside active=false autoCommit=true rows=1
self-call after exception=IllegalStateException rows=1
outer-transaction inside active=true autoCommit=false rows=1
outer-transaction after exception=IllegalStateException rows=0
split-service inside active=true autoCommit=false rows=1
split-service after exception=IllegalStateException rows=0
template inside active=true autoCommit=false rows=1
template after exception=IllegalStateException rows=0
active는 TransactionSynchronizationManager.isActualTransactionActive()의 반환값이다. API 문서 (새 탭에서 열림)에 따르면 현재 스레드에 실제 트랜잭션이 활성화되어 있는지를 나타낸다. autoCommit은 INSERT에 사용한 연결의 getAutoCommit() 값이고, inside의 rows=1은 예외 전에 INSERT가 실제로 실행됐다는 확인이다.
외부 호출은 트랜잭션 안에서 INSERT를 실행했으며 예외가 경계를 빠져나온 뒤 0행이었다. 자기 호출은 Spring이 관리하는 트랜잭션이 없었고 연결의 autoCommit이 true였다. JDBC Connection 문서 (새 탭에서 열림)는 자동 커밋 모드에서 각 SQL 문의 변경을 자동 커밋한다고 설명한다. 이 실행에서는 INSERT가 커밋된 다음 Java 코드가 예외를 던졌기 때문에 한 행이 남았다.
outer-transaction은 자기 호출이면 항상 트랜잭션이 없다는 해석을 막기 위한 비교다. 이 경우에는 외부에서 호출한 outerTransaction()에 @Transactional을 붙였다. 바깥 메서드의 트랜잭션이 이미 시작되어 있으므로 그 안의 this.saveThenFail()에서도 active=true였다. 안쪽 애너테이션이 새로 적용됐다는 뜻은 아니다.
저장 한 건을 묶을지, 바깥 작업 전체를 묶을지 고른다
저장 메서드 자체를 여러 진입점에서 호출해야 한다면 저장 책임을 별도 Spring 빈으로 분리하는 편이 호출 경로를 드러내기 쉽다. 예제의 split-service에서는 ImportService에 주입된 Writer 빈을 통해 다음 메서드를 실행했다.
public void importOne() {
writer.saveThenFail("split-service");
}
중요한 것은 클래스를 하나 더 만드는 데 그치지 않고, 트랜잭션 메서드가 있는 Spring 빈을 주입받아 호출하는 것이다. new Writer(...)로 직접 만든 객체에는 이 예제의 프록시 처리가 없다. 실제 비교에서도 주입된 빈을 부른 경우는 active=true, 예외 후 0행이었다.
전체 가져오기 작업을 함께 롤백해야 한다면 외부에서 들어오는 바깥 메서드에 @Transactional을 둘 수 있다. 다만 한 건씩 독립적으로 처리하려던 작업을 전체 트랜잭션으로 바꾸면 롤백 범위도 넓어진다. 어디까지 함께 성공해야 하는지를 먼저 정해야 한다.
같은 클래스 안에 흐름을 유지하면서 일부 코드의 경계를 직접 표시하고 싶다면 TransactionTemplate을 사용할 수 있다. 쓰기에 사용하는 PlatformTransactionManager를 주입받아 new TransactionTemplate(transactionManager)로 만든다. 아래의 template도 이 방식으로 준비했다.
public void explicitBoundary() {
template.executeWithoutResult(status -> this.saveThenFail("template"));
}
여기서 트랜잭션을 시작한 것은 템플릿이다. 안쪽 자기 호출의 애너테이션에 기대지 않고 콜백을 감쌌으며, 예외가 콜백 밖으로 나간 뒤 0행이었다. 이미 바깥 트랜잭션이 있다면 템플릿의 기본 설정도 그 트랜잭션에 참여한다. Spring의 명시적 트랜잭션 문서 (새 탭에서 열림)는 이런 동기식 흐름에 TransactionTemplate을 안내한다. 코드가 Spring 트랜잭션 API에 직접 의존한다는 점은 선택할 때 함께 고려한다.
메서드를 public으로 바꾸거나 클래스 기반 프록시를 켜는 것만으로 이 자기 호출을 해결할 수는 없다. 이번 실패 조건도 이미 public 메서드와 CGLIB 프록시를 사용했다.
내 코드와 같은 조건인지 확인하려면
자신의 Spring 코드에서는 저장 메서드의 첫 쓰기 직전에 활성 상태를 기록해 본다. org.springframework.transaction.support.TransactionSynchronizationManager를 import하면 다음 두 줄을 넣을 수 있다. 이는 이미 Spring 트랜잭션 설정이 있는 동기식 메서드 안에 넣는 진단 코드다.
System.out.printf("saveThenFail active=%s%n",
TransactionSynchronizationManager.isActualTransactionActive());
테스트용 DB와 합성 입력을 준비하고, 호출자에 바깥 트랜잭션이 없는 상태에서 두 경로를 따로 실행한다. 먼저 다른 빈이나 테스트 코드에서 주입받은 저장 빈의 메서드를 직접 부르고, 다음에는 문제가 난 같은 객체 안의 호출 경로를 실행한다. 비교를 위한 테스트 메서드 자체에 트랜잭션을 붙이면 앞의 outer-transaction처럼 자기 호출에서도 true가 나올 수 있다.
이 글의 저장 메서드에 같은 진단 코드를 넣어 실행했을 때 직접 외부 호출은 saveThenFail active=true, 바깥 트랜잭션 없는 자기 호출은 saveThenFail active=false였다. 서비스 분리와 템플릿으로 감싼 경로에서는 다시 true였다. 이 차이가 나면 호출 지점이 프록시를 거치는지 확인하고, 앞에서 설명한 방식으로 필요한 트랜잭션 경계를 정한다. this.를 생략해 saveThenFail()로 써도 같은 객체 안의 호출이라는 점은 같다.
롤백까지 비교하려면 테스트 데이터 쓰기 뒤에 같은 런타임 예외를 발생시키고, 그 예외가 트랜잭션 경계를 빠져나온 뒤 바깥 트랜잭션 없는 위치에서 행 수를 다시 조회한다. 메서드 안에서 읽은 1행은 아직 롤백 여부를 알려 주지 않는다. 위 실험에서는 안쪽 조회가 모두 1행이었지만, 경계 밖에서는 외부 호출 0행과 자기 호출 1행으로 갈렸다.
자신의 코드에서 외부 호출부터 active=false라면 Spring이 관리하는 빈인지와 트랜잭션 설정이 활성화되어 있는지부터 확인한다. active=true인데도 행이 남는다면 예외가 실제 트랜잭션 경계 밖으로 전달됐는지, 롤백 규칙과 쓰기에 사용하는 트랜잭션 매니저가 맞는지를 살핀다. 이 예제는 기본 규칙에서 롤백되는 런타임 예외를 사용했고, 안쪽에서 예외를 삼키지 않았다.
이번에 확인한 범위는 단일 스레드의 Spring JDBC와 H2다. 체크 예외, JPA의 flush 시점, 여러 DB를 함께 쓰는 경우, 비동기 호출과 AspectJ 모드는 실행하지 않았다. 특히 자기 호출의 결과가 반드시 한 행이라는 뜻은 아니다. 여기서 한 행이 남은 조건은 트랜잭션 없는 JDBC 연결의 자동 커밋이었다.