어떤 기능의 변경에 대한 요청을 받았다. 마침 이전에 존재했던 기능에 테스트 코드가 없었다.
테스트 코드를 먼저 만들었다. 왜냐하면 변경 이후에 잘 동작하는지 안전망이 필요했기 때문이다.
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 이 발생하는 케이스를 만들었다.
하지만 문제없이 데이터는 지워지고 테스트는 성공하였다.
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 최적화를 통한 성능 튜닝을 읽어보면 도움이 될 것이다.
'DB' 카테고리의 다른 글
[DB] ERD 그림 그리기 (0) | 2020.04.27 |
---|---|
[db] file db architecture ?! (0) | 2020.03.04 |
[BI] Tableau 게시시에 통합문서명은 영문으로... (0) | 2017.07.28 |
[Tableau] 날짜 필드 변환하기 (0) | 2017.07.25 |
[BI] Tableau Desktop 10.3.1 Mac OS X용 Step by step (0) | 2017.07.24 |