얼마 전, 김영한님의 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는 없는 걸 확인할 수 있네요!
이제 본격적으로, 스프링부트의 테스트 실행 흐름 중, 테스트 실행 후 트랜잭션 롤백을 제어하는 부분 위주로 조금 살펴보려 합니다!
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
@BootstrapWith(SpringBootTestContextBootstrapper.class)
@ExtendWith(SpringExtension.class)
public @interface SpringBootTest {
...
}
가장 먼저, 테스트에 달아주는 SpringBootTest 어노테이션을 찾아 왔습니다! SpringBootTestContextBootstrapper를 활용해 초기화를 진행해 주는 것 같네요!
그리고, @ExtendWith(SpringExtension.class) 어노테이션을 포함되어 있는데, 잠시 살펴보면~
→ 코드 위치
@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를 초기화해서 반환하는 로직이 포함되어 있군요!
→ 코드 위치
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를 보러 가면~~
→ 코드 위치
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;
}
SpringBootTestContextBootstrapper는 DefaultTestContextBootstapper를 상속 받고,
buildTestContext 메서드는 있지만, getTestExecutionListeners가 구현되어 있진 않네요..!
buildTextContext 메서드 역시 상위 객체에서 메서드 호출을 하고 있으니..
DefaultTestContextBootstrapper가 상속받는 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를 가져오고 있는 것 같으니, 해당 로직을 이어서 보러 가겠습니다!
→ 코드 위치
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을 보러 가 보려고 합니다!
→ 코드 위치
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을 호출하네요!
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로 설정되어 있어, 해당 시점에서 트랜잭션이 롤백되게 되는 것 같네요~!
SpringBootTest 어노테이션은 SpringBootTestContextBootstrapper와 SpringExtension을 활용한다.SpringExtension의 beforeAll에서 TestContextManager를 초기화한다.프레임워크의 코드를 볼 때면 뭔가 항상 신기하다는 생각을 하게 되는 것 같습니다 :)