Spring Framework

@Transactional을 붙였는데 왜 롤백이 안 될까? 같은 클래스 호출·예외 처리로 재현하기

Hcode 2026. 10. 8.
반응형

@Transactional이 붙어 있는데 실패한 데이터가 DB에 남았다면, 트랜잭션이 실제로 시작됐는지와 발생한 예외가 롤백 조건에 해당하는지부터 나눠 보세요. 같은 클래스 안에서 부른 메서드, checked exception, 메서드 안에서 잡아 버린 예외는 겉으로 비슷해도 확인할 지점이 다릅니다.

여기서는 메모 한 행을 저장한 뒤 일부러 실패시키고, 호출이 끝난 다음 DB에 남은 행을 셌습니다. Java 25.0.3, Spring Framework 7.0.9, H2 2.4.240에서 여섯 경우를 확인했습니다. Spring Boot 4.1.1의 의존성 관리를 사용했으며, 별도의 Boot 서버 없이 Spring 컨텍스트와 JDBC 트랜잭션 매니저로 실행했습니다.

코미가 검사대를 통과하는 메모와 옆길로 들어가는 메모를 비교하며 호출 경로를 살핀다.
메서드를 어디서 호출했는지에 따라 트랜잭션 적용 여부가 달라집니다.

같은 저장 코드도 호출 방식과 예외에 따라 결과가 달라집니다

각 실험은 빈 테이블에서 시작합니다. 아래의 ‘트랜잭션’은 저장 직전에 확인한 활성 상태이고, ‘남은 행’은 서비스 호출이 반환되거나 예외가 전달된 뒤 별도로 조회한 값입니다.

메모 한 행을 넣은 뒤 실패시킨 결과
호출과 예외 처리 트랜잭션 남은 행
외부 호출 + 런타임 예외 있음 0
같은 객체 내부 호출 없음 1
checked exception, 기본 규칙 있음 1
checked exception + rollbackFor 있음 0
메서드 안에서 직접 잡은 예외 있음 1
다른 빈의 실패를 바깥에서 잡음 있음 0

마지막 경우는 바깥 호출이 UnexpectedRollbackException으로 끝났습니다. ‘catch를 썼으니 커밋된다’는 규칙은 없습니다. 어느 경계를 넘어온 예외를 어디에서 잡았는지까지 봐야 합니다.

같은 클래스 안의 호출은 프록시를 다시 지나지 않습니다

기본 프록시 방식에서 Spring은 빈 바깥에서 들어오는 호출을 가로채 트랜잭션을 적용합니다. 프록시는 실제 객체 앞에서 부가 동작을 수행하는 대리 객체입니다. 다음 코드의 두 메서드는 같은 객체 안에 있습니다.

public void callInsideSameObject() {
 runtimeFailure();
}

@Transactional
public void runtimeFailure() {
 insert();
 throw new IllegalStateException("failed after insert");
}

다른 곳에서 Spring 빈의 runtimeFailure()를 직접 부르면 행이 롤백됐습니다. 반면 트랜잭션이 없는 callInsideSameObject()로 들어오면, 내부 호출에는 runtimeFailure()의 애노테이션이 새로 적용되지 않았습니다. 이 실험에서는 JDBC 자동 커밋으로 저장된 한 행이 남았습니다.

내부 호출이 언제나 트랜잭션 밖이라는 뜻은 아닙니다. 바깥 메서드에서 이미 시작한 트랜잭션이 있다면 내부 작업도 그 범위에서 실행될 수 있습니다. 문제는 내부 메서드에 붙인 별도의 트랜잭션 설정을 프록시가 다시 적용하지 않는다는 점입니다.

작업 전체가 하나로 성공하거나 실패해야 한다면, 외부에서 호출하는 서비스 진입 메서드에 경계를 두는 편이 명확합니다. 별도 책임을 가진 작업이면 다른 Spring 빈으로 나누고 그 빈을 통해 호출하는 방법도 있습니다. 무조건 메서드마다 애노테이션을 추가하기 전에 실제 호출 경로부터 그려 보세요.

예외가 밖으로 나갔어도 기본 롤백 대상인지 확인합니다

이 실험의 기본 규칙은 RuntimeException과 Error가 롤백 대상이고, checked exception은 자동 롤백 대상이 아닙니다. Java의 IOException을 던지자 호출자는 예외를 받았지만 저장된 행은 남았습니다.

@Transactional
public void checkedFailure() throws IOException {
 insert();
 throw new IOException("checked failure");
}

@Transactional(rollbackFor = IOException.class)
public void checkedWithRule() throws IOException {
 insert();
 throw new IOException("checked failure");
}

두 번째 메서드에서는 같은 예외에 rollbackFor를 지정해 행이 0개로 돌아왔습니다. 적용할 예외는 업무상 실패의 의미에 맞춰 정하세요. 모든 예외를 일괄 롤백해야 하는지, 일부 예외 뒤에도 저장을 유지해야 하는지부터 결정해야 합니다.

Spring Framework 6.2부터는 @EnableTransactionManagement(rollbackOn = ALL_EXCEPTIONS)로 기본 롤백 규칙을 전역에서 바꿀 수도 있습니다. 여기서는 그 옵션을 켜지 않았습니다. 프로젝트에 전역 규칙이나 개별 noRollbackFor가 있다면 위 표를 그대로 적용하지 말고 현재 설정을 함께 확인하세요.

catch의 위치가 달라지면 결과도 달라집니다

첫 경우는 트랜잭션 메서드 안에서 직접 던진 예외를 그 자리에서 잡고 정상 반환했습니다. 이 예외는 트랜잭션을 처리하는 프록시 바깥으로 전달되지 않았고, 행이 커밋됐습니다.

@Transactional
public void caughtLocally() {
 insert();
 try {
 throw new IllegalStateException("caught inside the same method");
 } catch (IllegalStateException expected) {
 // 메서드는 정상 반환합니다.
 }
}

이 결과는 같은 메서드 안에서 만든 단순한 예외의 결과입니다. DB 자체가 실패했거나, 트랜잭션을 롤백 전용 상태로 표시한 상황까지 포함하지 않습니다.

다음 경우에는 다른 빈의 트랜잭션 메서드가 먼저 실패하고, 바깥 서비스가 예외를 잡습니다. 두 메서드는 기본 전파 방식인 REQUIRED로 같은 물리 트랜잭션을 사용합니다.

@Transactional
public void callAndCatch() {
 try {
 worker.runtimeFailure(); // 다른 Spring 빈을 통한 호출
 } catch (IllegalStateException expected) {
 // 내부 프록시는 이미 rollback-only로 표시했습니다.
 }
}

안쪽 프록시를 예외가 통과하면서 공유 트랜잭션이 롤백 전용으로 표시됐습니다. 바깥 메서드가 정상 반환하려고 해도 커밋할 수 없어 UnexpectedRollbackException이 발생했고, 저장 행은 0개였습니다. 이 동작은 Spring의 REQUIRED 전파 설명과 일치합니다.

테스트에서도 호출 경계 밖의 DB를 확인합니다

애노테이션이 있다는 사실만 검사하면 실제 저장 결과를 놓칩니다. 이번 테스트 메서드 자체에는 @Transactional을 붙이지 않고, Spring 컨텍스트에서 가져온 서비스 빈을 호출한 뒤 DB를 다시 조회했습니다.

assertThrows(IllegalStateException.class, service::runtimeFailure);
assertTrue(service.wasActive());
assertEquals(0, jdbc.queryForObject(
 "select count(*) from memo", Integer.class));

wasActive()는 저장 직전에 TransactionSynchronizationManager.isActualTransactionActive()로 관찰한 값을 반환합니다. 실험용 insert()는 다음과 같습니다.

private void insert() {
 active = TransactionSynchronizationManager.isActualTransactionActive();
 jdbc.update("insert into memo values (?, ?)", 1, "saved");
}

테스트에서 출력한 결과를 사례별로 모으면 다음과 같습니다.

external-runtime: transaction=true, rows=0
self-invocation: transaction=false, rows=1
checked-default: transaction=true, rows=1
checked-rollbackFor: transaction=true, rows=0
caught-locally: transaction=true, rows=1
outer-catches-inner: transaction=true, rows=0

실제 코드에서는 이 순서로 좁혀 보세요

  1. 호출 대상이 직접 만든 객체인지, Spring에서 가져온 빈인지 확인합니다.
  2. 외부 호출과 같은 객체 내부 호출을 구분하고, 저장 지점의 트랜잭션 활성 상태를 봅니다.
  3. 실제 예외의 종류와 rollbackFor·noRollbackFor·전역 규칙을 대조합니다.
  4. 예외를 잡는 위치와 이미 rollback-only가 된 경계가 있는지 확인합니다.
  5. 호출 경계 바깥에서 DB 결과를 확인하는 테스트를 남깁니다.

이 글은 단일 JDBC 데이터소스와 기본 프록시 방식의 예제입니다. AspectJ, 여러 트랜잭션 매니저, 비동기 작업에는 전제가 달라질 수 있습니다. 이벤트와 연결해 실행 시점을 고르는 문제는 이벤트 리스너의 실행 순서와 트랜잭션에서 이어서 볼 수 있습니다.

실패한 이유를 찾을 때는 애노테이션의 유무보다 어디에서 트랜잭션이 시작됐고, 어떤 예외가 어느 경계를 통과했는지가 더 많은 것을 알려 줍니다.

직접 실행할 전체 예제

새 프로젝트에 아래 표시된 경로대로 두 파일을 저장하고 mvn test를 실행하면 됩니다. 실제 확인 환경은 JDK 25.0.3과 Maven 3.9.9입니다. 테스트는 인메모리 H2 DB에서 실행됩니다.

롤백 6가지 경우: pom.xml과 전체 테스트 펼치기

pom.xml

<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>
  <parent><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-parent</artifactId><version>4.1.1</version><relativePath/></parent>
  <groupId>blog.example</groupId><artifactId>transaction-rollback</artifactId><version>1.0.0</version>
  <properties><java.version>17</java.version></properties>
  <dependencies>
    <dependency><groupId>org.springframework</groupId><artifactId>spring-context</artifactId></dependency>
    <dependency><groupId>org.springframework</groupId><artifactId>spring-jdbc</artifactId></dependency>
    <dependency><groupId>com.h2database</groupId><artifactId>h2</artifactId><scope>test</scope></dependency>
    <dependency><groupId>org.junit.jupiter</groupId><artifactId>junit-jupiter</artifactId><scope>test</scope></dependency>
  </dependencies>
</project>

src/test/java/blog/tx/RollbackTest.java

package blog.tx;

import java.io.IOException;
import java.util.UUID;
import javax.sql.DataSource;
import org.h2.jdbcx.JdbcDataSource;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.UnexpectedRollbackException;
import org.springframework.transaction.annotation.EnableTransactionManagement;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import static org.junit.jupiter.api.Assertions.*;

class RollbackTest {
    AnnotationConfigApplicationContext context;
    JdbcTemplate jdbc;
    SaveService service;

    @BeforeEach void setup() {
        context = new AnnotationConfigApplicationContext(Config.class);
        jdbc = context.getBean(JdbcTemplate.class);
        jdbc.execute("create table memo (id int primary key, message varchar(80))");
        service = context.getBean(SaveService.class);
    }

    @AfterEach void close() { context.close(); }

    int rows() { return jdbc.queryForObject("select count(*) from memo", Integer.class); }

    void result(String name, int count) {
        assertEquals(count, rows());
        System.out.printf("RESULT %s: transaction=%s, rows=%d%n", name, service.wasActive(), rows());
    }

    @Test void externalRuntimeExceptionRollsBack() {
        assertThrows(IllegalStateException.class, service::runtimeFailure);
        assertTrue(service.wasActive());
        result("external-runtime", 0);
    }

    @Test void selfInvocationDoesNotStartTransaction() {
        assertThrows(IllegalStateException.class, service::callInsideSameObject);
        assertFalse(service.wasActive());
        result("self-invocation", 1);
    }

    @Test void checkedExceptionCommitsByDefault() {
        assertThrows(IOException.class, service::checkedFailure);
        assertTrue(service.wasActive());
        result("checked-default", 1);
    }

    @Test void explicitRollbackRuleRollsBackCheckedException() {
        assertThrows(IOException.class, service::checkedWithRule);
        result("checked-rollbackFor", 0);
    }

    @Test void caughtLocalExceptionDoesNotReachInterceptor() {
        service.caughtLocally();
        assertTrue(service.wasActive());
        result("caught-locally", 1);
    }

    @Test void caughtInnerTransactionalFailureStillMarksRollbackOnly() {
        var caller = context.getBean(CallerService.class);
        assertThrows(UnexpectedRollbackException.class, caller::callAndCatch);
        result("outer-catches-inner", 0);
    }

    @Configuration(proxyBeanMethods = false)
    @EnableTransactionManagement
    static class Config {
        @Bean DataSource dataSource() {
            JdbcDataSource ds = new JdbcDataSource();
            ds.setURL("jdbc:h2:mem:" + UUID.randomUUID() + ";DB_CLOSE_DELAY=-1");
            return ds;
        }
        @Bean JdbcTemplate jdbc(DataSource ds) { return new JdbcTemplate(ds); }
        @Bean PlatformTransactionManager transactionManager(DataSource ds) {
            return new DataSourceTransactionManager(ds);
        }
        @Bean SaveService saveService(JdbcTemplate jdbc) { return new SaveService(jdbc); }
        @Bean CallerService callerService(SaveService service) { return new CallerService(service); }
    }

    public static class SaveService {
        private final JdbcTemplate jdbc;
        private boolean active;
        public SaveService(JdbcTemplate jdbc) { this.jdbc = jdbc; }
        public boolean wasActive() { return active; }
        private void insert() {
            active = TransactionSynchronizationManager.isActualTransactionActive();
            jdbc.update("insert into memo values (?, ?)", 1, "saved");
        }
        public void callInsideSameObject() { runtimeFailure(); }
        @Transactional public void runtimeFailure() {
            insert();
            throw new IllegalStateException("failed after insert");
        }
        @Transactional public void checkedFailure() throws IOException {
            insert();
            throw new IOException("checked failure");
        }
        @Transactional(rollbackFor = IOException.class)
        public void checkedWithRule() throws IOException {
            insert();
            throw new IOException("checked failure");
        }
        @Transactional public void caughtLocally() {
            insert();
            try {
                throw new IllegalStateException("caught inside the same method");
            } catch (IllegalStateException expected) {
                // 이 메서드는 정상 반환한다. DB 오류나 내부 트랜잭션 실패가 아니다.
            }
        }
    }

    public static class CallerService {
        private final SaveService worker;
        public CallerService(SaveService worker) { this.worker = worker; }
        @Transactional public void callAndCatch() {
            try {
                worker.runtimeFailure();
            } catch (IllegalStateException expected) {
                // 내부 프록시가 공유 트랜잭션을 이미 rollback-only로 표시했다.
            }
        }
    }
}

참고한 공식 자료

공식 자료와 예제 확인일: 2026년 10월 8일. 실제 프로젝트의 Spring 버전과 트랜잭션 설정을 함께 확인하세요.

반응형

댓글