본문 바로가기

DB

JPA: @DataJpaTest 에서 트랜잭션 Rollback 이 안된 이유는?

어떤 기능의 변경에 대한 요청을 받았다. 마침 이전에 존재했던 기능에 테스트 코드가 없었다.

테스트 코드를 먼저 만들었다. 왜냐하면 변경 이후에 잘 동작하는지 안전망이 필요했기 때문이다.

Unique index violation 이 발생

IntelliJ IDEA에서 테스트 묶음(Test Suites) 들을 돌릴 때는 문제가 없었다.

그런데 이상하게 로컬 환경에서 터미널에서 mvnw clean test 명령을 돌리면 테스트 하나가 깨진다.

원인을 찾아보니 DataIntegrityViolationException 가 발생했고, 세부적으로는 Unique index violation 이 발생했다.

org.springframework.dao.DataIntegrityViolationException: could not execute statement; SQL [n/a]; constraint [null]; nested exception is org.hibernate.exception.ConstraintViolationException: could not execute statement
Caused by: org.hibernate.exception.ConstraintViolationException: could not execute statement
Caused by: org.h2.jdbc.JdbcSQLException: Unique index or primary key violation
insert into ...

테스트 앞과 뒤에는 착실하게 트랜잭션이 시작되고 종료되고 있었다.

2021-08-07 19:44:39.244  INFO 2506 --- [           main] o.s.t.c.transaction.TransactionContext   [] : Began transaction (1) for test context [DefaultTestContext@1329eff testClass = MyRepositoryIntegrationSpec, testInstance = com.tistory.namocom.repository.MyRepositoryIntegrationSpec@35399441, testMethod = $spock_feature_0_0@MyRepositoryIntegrationSpec, testException = [null], mergedContextConfiguration = [MergedContextConfiguration@6497b078 testClass = MyRepositoryIntegrationSpec, locations = '{}', classes = '{class com.tistory.namocom.Application}', contextInitializerClasses = '[]', activeProfiles = '{test}', propertySourceLocations = '{}', propertySourceProperties = '{org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTestContextBootstrapper=true}', contextCustomizers = set[[ImportsContextCustomizer@41c2284a key = [org.springframework.boot.autoconfigure.cache.CacheAutoConfiguration, org.springframework.boot.autoconfigure.data.jpa.JpaRepositoriesAutoConfiguration, org.springframework.boot.autoconfigure.flyway.FlywayAutoConfiguration, org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration, org.springframework.boot.autoconfigure.jdbc.DataSourceTransactionManagerAutoConfiguration, org.springframework.boot.autoconfigure.jdbc.JdbcTemplateAutoConfiguration, org.springframework.boot.autoconfigure.liquibase.LiquibaseAutoConfiguration, org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration, org.springframework.boot.autoconfigure.transaction.TransactionAutoConfiguration, org.springframework.boot.test.autoconfigure.jdbc.TestDatabaseAutoConfiguration, org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManagerAutoConfiguration]], org.springframework.boot.test.context.filter.ExcludeFilterContextCustomizer@27c86f2d, org.springframework.boot.test.json.DuplicateJsonObjectContextCustomizerFactory$DuplicateJsonObjectContextCustomizer@79ad8b2f, org.springframework.boot.test.mock.mockito.MockitoContextCustomizer@0, org.springframework.boot.test.autoconfigure.OverrideAutoConfigurationContextCustomizerFactory$DisableAutoConfigurationContextCustomizer@2fd6b6c7, org.springframework.boot.test.autoconfigure.filter.TypeExcludeFiltersContextCustomizer@351584c0, org.springframework.boot.test.autoconfigure.properties.PropertyMappingContextCustomizer@29b05d5a, org.springframework.boot.test.autoconfigure.web.servlet.WebDriverContextCustomizerFactory$Customizer@77167fb7, org.spockframework.spring.mock.SpockContextCustomizer@0], contextLoader = 'org.springframework.boot.test.context.SpringBootContextLoader', parent = [null]], attributes = map[[empty]]]; transaction manager [org.springframework.orm.jpa.JpaTransactionManager@6a7fc4c1]; rollback [true]
... test
2021-08-07 19:44:39.582  INFO 2506 --- [           main] o.s.t.c.transaction.TransactionContext   [] : Rolled back transaction for test: [DefaultTestContext@1329eff testClass = MyRepositoryIntegrationSpec, testInstance = com.tistory.namocom.repository.MyRepositoryIntegrationSpec@35399441, testMethod = $spock_feature_0_0@MyRepositoryIntegrationSpec, testException = [null], mergedContextConfiguration = [MergedContextConfiguration@6497b078 testClass = MyRepositoryIntegrationSpec, locations = '{}', classes = '{class com.tistory.namocom.Application}', contextInitializerClasses = '[]', activeProfiles = '{test}', propertySourceLocations = '{}', propertySourceProperties = '{org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTestContextBootstrapper=true}', contextCustomizers = set[[ImportsContextCustomizer@41c2284a key = [org.springframework.boot.autoconfigure.cache.CacheAutoConfiguration, org.springframework.boot.autoconfigure.data.jpa.JpaRepositoriesAutoConfiguration, org.springframework.boot.autoconfigure.flyway.FlywayAutoConfiguration, org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration, org.springframework.boot.autoconfigure.jdbc.DataSourceTransactionManagerAutoConfiguration, org.springframework.boot.autoconfigure.jdbc.JdbcTemplateAutoConfiguration, org.springframework.boot.autoconfigure.liquibase.LiquibaseAutoConfiguration, org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration, org.springframework.boot.autoconfigure.transaction.TransactionAutoConfiguration, org.springframework.boot.test.autoconfigure.jdbc.TestDatabaseAutoConfiguration, org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManagerAutoConfiguration]], org.springframework.boot.test.context.filter.ExcludeFilterContextCustomizer@27c86f2d, org.springframework.boot.test.json.DuplicateJsonObjectContextCustomizerFactory$DuplicateJsonObjectContextCustomizer@79ad8b2f, org.springframework.boot.test.mock.mockito.MockitoContextCustomizer@0, org.springframework.boot.test.autoconfigure.OverrideAutoConfigurationContextCustomizerFactory$DisableAutoConfigurationContextCustomizer@2fd6b6c7, org.springframework.boot.test.autoconfigure.filter.TypeExcludeFiltersContextCustomizer@351584c0, org.springframework.boot.test.autoconfigure.properties.PropertyMappingContextCustomizer@29b05d5a, org.springframework.boot.test.autoconfigure.web.servlet.WebDriverContextCustomizerFactory$Customizer@77167fb7, org.spockframework.spring.mock.SpockContextCustomizer@0], contextLoader = 'org.springframework.boot.test.context.SpringBootContextLoader', parent = [null]], attributes = map['org.spockframework.spring.SpringMockTestExecutionListener.MOCKED_BEANS_LIST' -> list[[empty]]]]

 

가설과 검증

혹시나 싶어서 테스트 수행전에 데이터 조회를 setup 메서드에 추가해서 확인해보니 데이터가 조회가 되었다.

따라서 이전에 수행한 테스트가 종료되면서 저장된(persist)된 데이터가 다음 테스트에 영향을 준다고 결론을 내릴 수 있었다.

 

처음에는 H2 라는 'In memory DB 가 트랜잭션을 지원 안하나?' 생각도 했지만 http://www.h2database.com/에는 Transaction Isolation 에는 일반적인 트랜잭션 단계를 지원하고 기술되어 있었다.

 

신규로 프로젝트를 만들어서 비슷한 케이스로 만들어보았다.

테스트 격리가 제대로 안될 경우 Primary Key violation 이 발생하는 케이스를 만들었다.

createTest2와 createTest3 는 동일한 PK를 사용하기에 rollback 이 안되면 나중에 수행한 테스트가 실패한다.

하지만 문제없이 데이터는 지워지고 테스트는 성공하였다.

D & C

학교에서 배운 알고리즘 수업에서 실전에 여러번 적용해 본 알고리즘은 분할 정복 알고리즘(Divide-and-conquer algorithm)이었다.

마찬가지로 새로만든 프로젝트에 기존 프로젝트의 설정을 일부분씩 옮겨서 적용을 해보았다.

넣고 빼고를 반복하다 보니 특정 설정이 있으면 트랜잭션 롤백이 잘 안된다는 것을 알 수 있었다.

 

바로...

hibernate.connection.provider_disables_autocommit 였다.

Spring Boot 에서는 spring.jpa.properties 를 통해 아래와 같이 설정이 가능하다.

# properties
spring.jpa.properties.hibernate.connection.provider_disables_autocommit=true
# yaml
spring:
  jpa:
    properties:
      hibernate:
        connection:
          provider_disables_autocommit: true

설정은 아래의 클래스 들을 참고한다.

영향을 주는 이유는?

그렇다면 왜 이 설정이 Transaction Rollback 에 영향을 주는 것일까?

트랜잭션 요청되면 TransactionContext 의 startTransaction() 에 의해 트랙잭션이 시작되고 endTransaction() 에 의해 트랜잭션이 종료된다.

시퀀스 다이어그램으로 표현하면 아래와 같다.

윗 부분이 트랜잭션 시작, 아래가 종료 부분이다.

관심을 가져야 할 부분이 트랜잭션 시작 부분에서는 빨간색 부분이고, 종료 부분에서는 녹색 부분이다.

트랜잭션의 시작: LogicalConnectionManagedImpl

package org.hibernate.resource.jdbc.internal;

public class LogicalConnectionManagedImpl extends AbstractLogicalConnectionImplementor {

	boolean initiallyAutoCommit;
    
	@Override
	public void begin() {
		initiallyAutoCommit = !doConnectionsFromProviderHaveAutoCommitDisabled()
		  && determineInitialAutoCommitMode(getConnectionForTransactionManagement());
		super.begin();
	}
    
	@Override
	protected void afterCompletion() {
		resetConnection( initiallyAutoCommit );
		initiallyAutoCommit = false;

		afterTransaction();
	}

내부에 initiallyAutoCommit 라는 프로퍼티가 있는데 이 값에 따라서 트랜잭션 종료시 commit 이 되는지 여부가 달라지기 때문이다.

 

트랜잭션의 종료: AbstractLogicalConnectionImplementor

package org.hibernate.resource.jdbc.internal;

public abstract class AbstractLogicalConnectionImplementor implements LogicalConnectionImplementor, PhysicalJdbcTransaction {

	@Override
	public void rollback() {
		try {
			getConnectionForTransactionManagement().rollback();
			status = TransactionStatus.ROLLED_BACK;
		}
		catch( SQLException e ) {
			status = TransactionStatus.FAILED_ROLLBACK;
			throw new TransactionException( "Unable to rollback against JDBC Connection", e );
		}

		afterCompletion();
	}
    
	protected void afterCompletion() {
		// by default, nothing to do
	}

	protected void resetConnection(boolean initiallyAutoCommit) {
		try {
			if ( initiallyAutoCommit ) {
				getConnectionForTransactionManagement().setAutoCommit( true );
				status = TransactionStatus.NOT_ACTIVE;
			}
		}
		catch ( Exception e ) {
			log.debug(...);
		}
	}
    
    protected abstract Connection getConnectionForTransactionManagement();

트랜잭션이 rollback 후에 롤백이 완료 후에 수행할 것이 afterCompletion() 메서드가 호출이 된다.

추상 클래스인 AbstractLogicalConnectionImplementor 에는 아무것도 하지 않지만,

LogicalConnectionManagedImpl 구현체에서는 resetConnection 을 수행한다.

트랜잭션의 시작 때 살펴본 LogicalConnectionManagedImpl 의 begin() 아래에 있던 그 메서드이다.

package org.hibernate.resource.jdbc.internal;

public class LogicalConnectionManagedImpl extends AbstractLogicalConnectionImplementor {

	boolean initiallyAutoCommit;

	@Override
	public void begin() { } // omit
    
	@Override
	protected void afterCompletion() {
		resetConnection( initiallyAutoCommit );
		initiallyAutoCommit = false;

		afterTransaction();
	}

begin() 에서 설정된 그 initiallyAutoCommit 값이 resetConnection 에 전달한다.

결론은...

결국은 hibernate.connection.provider_disables_autocommit 의 값이 true 로 설정이 되었으면,

initiallyAutoCommit 는 false 가 되고 rollback 이 완료되고 commit 이 수행되지 않는다.

그렇기에 이전 테스트에서 값이 저장이 되었다면 아직 데이터가 있다고 판단되어 Unique index 이나 primary key violation가 발생할 수 있었다.

provider_disables_autocommit doConnectionsFromProviderHaveAutoCommitDisabled() determineInitialAutoCommitMode() initiallyAutoCommit rollback 후 commit
true true N/A (short-circuit) false 미수행
false false SessionInterface#getAutoCommit() 의 결과 true 수행
false 미수행

See more..

hibernate.connection.provider_disables_autocommit 을 true 로 설정하면 initiallyAutoCommit 의 조건인 !doConnectionsFromProviderHaveAutoCommitDisabled() && determineInitialAutoCommitMode() 중 앞에서 false 가 되므로 뒤의 determineInitialAutoCommitMode() 부분이 항상 수행이 되지 않는다.

로컬 환경이라면 네트워크 연결을 필요로 하지 않지만 별도에 위치하고 있는 DB 서버라면 통신을 별도로 수행해야 하므로 약간의 시간을 소모하게 된다.

더 자세한 내용은 Vlad Mihalcea의 Why you should always use hibernate.connection.provider_disables_autocommit for resource-local JPA transactions 이나  민규 님의 Hibernate setAutoCommit 최적화를 통한 성능 튜닝을 읽어보면 도움이 될 것이다.