스프링부트의 테스트는 어떻게 롤백될까?

toto9602·2025년 1월 26일

얼마 전, 김영한님의 JPA 강의를 듣던 중, @Transactional이 테스트 메서드에 달려 있는 경우, 메서드 실행 후 트랜잭션이 롤백된다는 내용을 접한 적이 있었습니다.

강의를 듣고 나서 이 동작이 어떻게 이뤄지는지 조금 궁금해져, 오늘은 관련 내용을 알아보고자 포스팅을 작성합니다!

잘못된 내용에 대한 피드백은 언제나 감사드립니다! (_ _)

참고자료

<인프런 강의> 실전! 스프링 부트와 JPA 활용1 - 웹 애플리케이션 개발
Spring Boot Test에서 Test Configuration 감지하기

테스트

테스트 코드

org.springframework.transaction.annotation.Transactional;

import static org.assertj.core.api.Assertions.assertThat;

@SpringBootTest
class PostRepositoryTest {
    @Autowired
    PostRepository postRepository;

    @Test
    @Transactional
    public void savePost() {
        // given
        Post post = new Post();
        post.setTitle("test");

        // when
        Long savedId = postRepository.save(post);

        // then
        Post foundPost = postRepository.find(savedId);
        assertThat(foundPost.getTitle()).isEqualTo(post.getTitle());
    }
}

테스트 실행 후

테스트는 잘 통과되었고, 별도로 테스트 이후에 DB를 truncate해 주지 않았지만 쌓인 row는 없는 걸 확인할 수 있네요!

코드 살펴보기

이제 본격적으로, 스프링부트의 테스트 실행 흐름 중, 테스트 실행 후 트랜잭션 롤백을 제어하는 부분 위주로 조금 살펴보려 합니다!

0. @SpringBootTest

→ 코드 위치

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
@BootstrapWith(SpringBootTestContextBootstrapper.class)
@ExtendWith(SpringExtension.class)
public @interface SpringBootTest {
	...
}    

가장 먼저, 테스트에 달아주는 SpringBootTest 어노테이션을 찾아 왔습니다! SpringBootTestContextBootstrapper를 활용해 초기화를 진행해 주는 것 같네요!

그리고, @ExtendWith(SpringExtension.class) 어노테이션을 포함되어 있는데, 잠시 살펴보면~

SpringExtension

코드 위치

	@Override
	public void beforeAll(ExtensionContext context) throws Exception {
		TestContextManager testContextManager = getTestContextManager(context);
		registerMethodInvoker(testContextManager, context);
		testContextManager.beforeTestClass();
	}
    
	static TestContextManager getTestContextManager(ExtensionContext context) {
		Assert.notNull(context, "ExtensionContext must not be null");
		Class<?> testClass = context.getRequiredTestClass();
		Store store = getStore(context);
		return store.getOrComputeIfAbsent(testClass, TestContextManager::new, TestContextManager.class);
	}

beforeAll 메서드에, TestContextManager를 초기화해서 반환하는 로직이 포함되어 있군요!

1. TestContextManager

코드 위치

	public TestContextManager(Class<?> testClass) {
		this(BootstrapUtils.resolveTestContextBootstrapper(testClass));
	}
    
    public TestContextManager(TestContextBootstrapper testContextBootstrapper) {
		this.testContext = testContextBootstrapper.buildTestContext();
		this.testContextHolder = ThreadLocal.withInitial(() -> copyTestContext(this.testContext));
		registerTestExecutionListeners(testContextBootstrapper.getTestExecutionListeners());
	}

TestContextManager에서는 크게 TestContext를 생성하고,
Listener를 등록해 주는 2가지 일을 하네요!

@SpringBootTest 어노테이션을 쓰고 있으니, SpringBootTestContextBootstrapper를 보러 가면~~

SpringBootTestContextBootstrapper

코드 위치

public class SpringBootTestContextBootstrapper extends DefaultTestContextBootstrapper {

    public TestContext buildTestContext() {
        TestContext context = super.buildTestContext();
        this.verifyConfiguration(context.getTestClass());
        SpringBootTest.WebEnvironment webEnvironment = this.getWebEnvironment(context.getTestClass());
        if (webEnvironment == WebEnvironment.MOCK && this.deduceWebApplicationType() == WebApplicationType.SERVLET) {
            context.setAttribute("org.springframework.test.context.web.ServletTestExecutionListener.activateListener", true);
        } else if (webEnvironment != null && webEnvironment.isEmbedded()) {
            context.setAttribute("org.springframework.test.context.web.ServletTestExecutionListener.activateListener", false);
        }

        return context;
    }

SpringBootTestContextBootstrapperDefaultTestContextBootstapper를 상속 받고,
buildTestContext 메서드는 있지만, getTestExecutionListeners가 구현되어 있진 않네요..!

buildTextContext 메서드 역시 상위 객체에서 메서드 호출을 하고 있으니..
DefaultTestContextBootstrapper가 상속받는 AbstractTestContextBootstrapper까지 올라가 보면...

AbstractTestContextBootstrapper

코드 위치

    public TestContext buildTestContext() {
        // DefaultTestContext 초기화!
        return new DefaultTestContext(this.getBootstrapContext().getTestClass(), this.buildMergedContextConfiguration(), this.getCacheAwareContextLoaderDelegate());
    }
    
    public final List<TestExecutionListener> getTestExecutionListeners() {
    	   List<TestExecutionListener> listeners = new ArrayList(8);
           ...
           ...
           // 조건에 따라 getDefaultTestExecutionListeners를 호출
           listeners.addAll(this.getDefaultTestExecutionListeners());
    }
    
    protected List<TestExecutionListener> getDefaultTestExecutionListeners() {
        return TestContextSpringFactoriesUtils.loadFactoryImplementations(TestExecutionListener.class);
    }    
  • buildTextContext 메서드에서 DefaultTestContext를 만들어 주고 있네요!

  • getTestExecutionListeners는.. TestContextSpringFactoriesUtils를 호출하여 listeners를 가져오고 있는 것 같으니, 해당 로직을 이어서 보러 가겠습니다!

TestContextSpringFactoryUtils

코드 위치

    public static <T> List<T> loadFactoryImplementations(Class<T> factoryType) {
        SpringFactoriesLoader loader = SpringFactoriesLoader.forDefaultResourceLocation(TestContextSpringFactoriesUtils.class.getClassLoader());
        List<T> implementations = loader.load(factoryType, new TestContextFailureHandler());
        if (logger.isTraceEnabled()) {
            logger.trace("Loaded %s implementations from location [%s]: %s".formatted(factoryType.getSimpleName(), "META-INF/spring.factories", classNames(implementations)));
        }

        return Collections.unmodifiableList(implementations);
    }

→ 이 메서드에 TestExecutionListener 클래스가 들어가는 경우, 총 18개의 Listener가 기본으로 로드되고, 그 중에 TransactionTestExecutionListener도 포함되어 있네요!

Listener가 등록된 부분까지 확인했으니, 이제 Listener을 보러 가 보려고 합니다!

2. TransactionTestExecutionListener

코드 위치

public class TransactionalTestExecutionListener extends AbstractTestExecutionListener {
	// 테스트 실행 이후 실행되는 메서드!
    public void afterTestMethod(TestContext testContext) throws Exception {
        Method testMethod = testContext.getTestMethod();
        Assert.notNull(testMethod, "The test method of the supplied TestContext must not be null");
        TransactionContext txContext = TransactionContextHolder.removeCurrentTransactionContext();
        if (txContext != null) {
            TransactionStatus transactionStatus = txContext.getTransactionStatus();

            try {
                if (transactionStatus != null && !transactionStatus.isCompleted()) {
                    txContext.endTransaction();
                }
            } finally {
                this.runAfterTransactionMethods(testContext);
            }
        }

    }

메서드가 실행되는 시점에는, 아직 completed 값이 false이므로
testContext의 endTransaction을 호출하네요!

TestContext

    void endTransaction() {
        if (logger.isTraceEnabled()) {
            logger.trace(String.format("Ending transaction for test context %s; transaction status [%s]; rollback [%s]", this.testContext, this.transactionStatus, this.flaggedForRollback));
        }

        Assert.state(this.transactionStatus != null, () -> {
            return "Failed to end transaction - transaction does not exist: " + this.testContext;
        });

        try {
            if (this.flaggedForRollback) { // 이 값이 true !
                this.transactionManager.rollback(this.transactionStatus);
            } else {
                this.transactionManager.commit(this.transactionStatus);
            }
        } finally {
            this.transactionStatus = null;
        }

        int transactionsStarted = this.transactionsStarted.get();
        if (logger.isTraceEnabled()) {
            logger.trace("%s transaction (%d) for test context: %s".formatted(this.flaggedForRollback ? "Rolled back" : "Committed", transactionsStarted, this.testContext));
        } else if (logger.isDebugEnabled()) {
            logger.debug("%s transaction (%d) for test class [%s]; test method [%s]".formatted(this.flaggedForRollback ? "Rolled back" : "Committed", transactionsStarted, this.testContext.getTestClass().getName(), this.testContext.getTestMethod().getName()));
        }

    }

테스트가 실행된 DefaultTestContext의 경우, flaggedForRollback 값이 true로 설정되어 있어, 해당 시점에서 트랜잭션이 롤백되게 되는 것 같네요~!

마무리

  1. SpringBootTest 어노테이션은 SpringBootTestContextBootstrapperSpringExtension을 활용한다.
  2. SpringExtension의 beforeAll에서 TestContextManager를 초기화한다.
    • TestContext가 초기화되고,
    • Listener들이 등록된다.
  3. 메서드 실행 이후, TestContext에 포함된 필드에 따라 분기가 이루어져, Listener가 트랜잭션을 롤백한다.

스프링부트에서 테스트가 실행되는 과정 중, 테스트 메서드의 트랜잭션이 롤백되는 로직을 살펴 보았습니다! 로직 자체도 신기했지만, `TestContextSpringFactoriesUtils`의 메서드의 너무 다양한 타입에서 유연하게 사용되고 있어, 그 다형성이 신기하기도 하더라고요.

프레임워크의 코드를 볼 때면 뭔가 항상 신기하다는 생각을 하게 되는 것 같습니다 :)

profile
주니어 백엔드 개발자입니다! 조용한 시간에 읽고 쓰는 것을 좋아합니다 :)

0개의 댓글