개발일상/TIL

[230907] Spring Boot 테스트의 롤백

sechoi 2023. 9. 8. 17:11

✅ Spring Boot 테스트의 롤백

문제 발생: 일대다 단방향 참조가 불러온 나비효과

JPA에서 일대다 단방향을 지양하라는 것은 널리 알려진 얘기이다. 대표적인 이유는 자식 엔티티를 데이터베이스에 save하는 과정에서 추가 쿼리가 날라가기 때문인데, 자식 엔티티에서 부모의 정보(id)를 가지고 있지 않아 insert 쿼리 이후 fk를 설정하는 update 쿼리가 발생한다.

 

이를 직접 확인하기 위해 테스트를 해보았다.

@DataJpaTest
public class CustomTest {

    @Autowired
    private OrderRepository orderRepository;

    @Test
    void test() {
        Order order = new Order(new OrderItems());

        orderRepository.save(order);
    }
}

Order와 OrderItem이 있다. 데이터베이스에서 OrderItem은 Order의 id를 가지고 있지만, 어플리케이션에서는 일대다 단방향 관계를 가지고 있기 때문에 OrderItem은 Order의 정보를 가지고 있지 않다. 대신 Order는 OrderItem 리스트를 가지고 있다.

 

그리고 테스트를 실행하면...

 

OMG? update 쿼리 없이 insert 쿼리만 발생했다. 

 

그렇다면 일대다 단방향일 때 추가 쿼리가 나가지 않는다는 것인가? 하지만 이미 프로덕션 코드에서 추가 쿼리가 나가는 것을 확인한 상태였다. 그래서 테스트 설정을 살펴봤다.

 

해당 테스트는 @DataJpaTest가 붙어있고, DataJpaTest의 대표적인 특징은 @Transactional을 통해 롤백을 지원하는 것이다. 그래서 롤백을 막고 다시 테스트를 해보았다.

 

@DataJpaTest
public class CustomTest {

    @Autowired
    private OrderRepository orderRepository;

    @Test
    @Rollback(value = false)
    void test() {
        Order order = new Order(new OrderItems());

        orderRepository.save(order);
    }
}

그랬더니 원하는 대로 update 쿼리가 발생했다. 

 

즉, 추가 쿼리의 발생 여부는 롤백에 달려있는 것이다. 그렇다면 테스트가 끝날 때 롤백 여부를 판단해, 롤백을 하지 않으면 추가 쿼리를 생성하고 기존 insert 쿼리와 함께 커밋함을 추측할 수 있다.

 

@DataJpaTest의 테스트에서 롤백은 언제 발생할까?

@DataJpaTest는 @Transactional을 포함한다. 그러면 테스트 메소드마다 트랜잭션이 시작되고, 해당 트랜잭션이 종료될 때 롤백 마크를 를 확인해 롤백을 할 지, 커밋을 할 지 판단한다고 한다.

 

그러면 롤백 마크는 정확히 어디에 있는 걸까? 이를 찾기 위해 @Transactional을 찾아갔다. 하지만 롤백에 관한 구체적인 설정만 있을 뿐, 롤백을 할지 말지 결정하는 속성은 보이지 않았다. 또한 생각해보면, 프로덕션 코드에서 해당 어노테이션을 붙여도 자동으로 롤백이 되지 않는다. 왜 테스트에서만 @Transactional을 붙이면 롤백이 기본 설정이 되는 걸까?

 

테스트에서의 @Transactional과 롤백 설정

 

위 GPT의 답변을 통해 TransactionalTestExecutionListener 클래스를 찾아갔다. 그리고 롤백 설정은 트랜잭션이 시작될 때 이루어질 것이므로 beforeTestMethod 메서드를 살펴보았다.

 

@Override
public void beforeTestMethod(final TestContext testContext) throws Exception {
    
    TransactionContext txContext = TransactionContextHolder.removeCurrentTransactionContext();

    PlatformTransactionManager tm = null;
    TransactionAttribute transactionAttribute = this.attributeSource.getTransactionAttribute(testMethod, testClass);

    if (transactionAttribute != null) {
        transactionAttribute = TestContextTransactionUtils.createDelegatingTransactionAttribute(testContext,
            transactionAttribute);

        tm = getTransactionManager(testContext, transactionAttribute.getQualifier());
    }

    if (tm != null) {
        // 아래를 보세요!!!
        txContext = new TransactionContext(testContext, tm, transactionAttribute, isRollback(testContext));
        runBeforeTransactionMethods(testContext);
        txContext.startTransaction();
        TransactionContextHolder.setCurrentTransactionContext(txContext);
    }
}

 

TransactionContext txContext = TransactionContextHolder.removeCurrentTransactionContext() 에서 현재 트랜잭션이 있다면 지우고 그것을 반환한다. 지금은 테스트 메서드가 실행되기 전이라 따로 트랜잭션 컨텍스트가 없어서 null을 반환한다.

 

그리고 getTransactionManager()를 통해 트랜잭션 매니저를 가져오고, 매니저가 존재하면 아래에서 txContext = new TransactionContext(testContext, tm, transactionAttribute, isRollback(testContext)) 로 새로운 트랜잭션 컨텍스트를 생성한다. 이 때 isRollback() 메서드가 보인다!

 

protected final boolean isRollback(TestContext testContext) throws Exception {
    boolean rollback = isDefaultRollback(testContext);
    Method testMethod = testContext.getTestMethod();
    Rollback rollbackAnnotation = AnnotatedElementUtils.findMergedAnnotation(testMethod, Rollback.class);
    
    if (rollbackAnnotation != null) {
        boolean rollbackOverride = rollbackAnnotation.value();
        rollback = rollbackOverride;
    }
    return rollback;
}

첫 줄에서 isDefaultRollback()으로 기본 설정을 하는 것 같아 한 번 살펴봤다.

protected final boolean isDefaultRollback(TestContext testContext) throws Exception {
    Class<?> testClass = testContext.getTestClass();
    Rollback rollback = TestContextAnnotationUtils.findMergedAnnotation(testClass, Rollback.class);
    boolean rollbackPresent = (rollback != null);

    if (rollbackPresent) {
        boolean defaultRollback = rollback.value();
        return defaultRollback;
    }
    return true;
}

Rollback rollback = TestContextAnnotationUtils.findMergedAnnotation(testClass, Rollback.class) 에서 테스트 '클래스'에 @Rollback이 있으면 찾아온다. 그리고 있다면 해당 어노테이션의 value에 따라 롤백 여부를 결정하고, 없으면 true를 반환한다. 테스트 환경에서 롤백이 기본적으로 설정되어 있는 이유는 바로 이 코드 때문인 것이다!

 

그리고 다시 isRollback()으로 돌아와서, 이번에는 Rollback rollbackAnnotation = AnnotatedElementUtils.findMergedAnnotation(testMethod, Rollback.class) 에서 테스트 '메서드'에 @Rollback이 있는 지 확인한다. 그리고 있다면 마찬가지로 value로 롤백 여부를 결정한다. 테스트 클래스의 @Rollback보다 테스트 메서드의 @Rollback 설정이 우선 순위가 높은 것이다.

 

정리하자면 TransactionalTestExecutionListener의 beforeTestMethod()에서 TransactionContext를 만들고, 이를 가지고 트랜잭션을 시작한다. 이 때 TransactionContext을 만드는 과정에서 isRollback()을 통해 해당 트랜잭션의 롤백 여부를 설정하는데, isDefaultRollback()에서 기본 롤백 설정을 true로 해놓았기 때문에 테스트에서 @Transactinoal이 붙으면 자동으로 롤백을 하는 것이다.

 

트랜잭션 종료 과정에서의 flush

하지만 아직 왜 롤백이 일어났을 때는 추가 쿼리가 발생하지 않았는지 모른다. 그래서 트랜잭션이 종료될 때를 살펴보기로 했다.

위에서 TransactionalTestExecutionListener의 beforeTestMethod()에서 트랜잭션이 시작됨을 확인했다. 그렇다면 afterTestMethod()에서 트랜잭션이 종료될 것이다.

 

@Override
public void afterTestMethod(TestContext testContext) throws Exception {
    Method testMethod = testContext.getTestMethod();

    TransactionContext txContext = TransactionContextHolder.removeCurrentTransactionContext();
    // If there was (or perhaps still is) a transaction...
    if (txContext != null) {
        TransactionStatus transactionStatus = txContext.getTransactionStatus();
        try {
            // If the transaction is still active...
            if (transactionStatus != null && !transactionStatus.isCompleted()) {
                // 여기를 보세요
                txContext.endTransaction();
            }
        }
        finally {
            runAfterTransactionMethods(testContext);
        }
    }
}

afterTestMethod()에서도 beforeTestMethod()와 같이 현재 transaction context를 지우면서 가져온다. 지금은 테스트가 종료될 때이므로 트랜잭션이 존재한다.

 

아래 txContext.endTransaction()을 통해 트랜잭션을 종료한다. 해당 메서드를 자세히 살펴보았다.

void endTransaction() {
    try {
        if (this.flaggedForRollback) {
            this.transactionManager.rollback(this.transactionStatus);
        }
        else {
            this.transactionManager.commit(this.transactionStatus);
        }
    }
    finally {
        this.transactionStatus = null;
    }

    int transactionsStarted = this.transactionsStarted.get();
}

beforeTestMethod()에서 new TransactionContext(testContext, tm, transactionAttribute, isRollback(testContext)) 로 새로운 트랜잭션 컨텍스트를 만들었다. 이 때 생성자를 통해 flaggedForRollback이 isRollback(testContext) 값으로 초기화됐다.

 

flaggedForRollback이 true이면 추가 쿼리가 발생하지 않기 때문에 false일 때, 즉 commit()이 실행될 때를 살펴보자.

@Override
public final void commit(TransactionStatus status) throws TransactionException {
    if (status.isCompleted()) {
        throw new IllegalTransactionStateException(
                "Transaction is already completed - do not call commit or rollback more than once per transaction");
    }

    DefaultTransactionStatus defStatus = (DefaultTransactionStatus) status;
    if (defStatus.isLocalRollbackOnly()) {
        processRollback(defStatus, false);
        return;
    }

    if (!shouldCommitOnGlobalRollbackOnly() && defStatus.isGlobalRollbackOnly()) {
        processRollback(defStatus, true);
        return;
    }

    // 여기!
    processCommit(defStatus);
}

AbstractPlatformTransactionManager 클래스의 commit()이 실행된다. 그리고 아래 processCommit() 내에서 JpaTransactionManager의 doCommit()을 실행한다. 이 안에서 TransactionImpl의 commit()으로 계속 들어가다보면 JdbcCoordinatorImpl의 beforeTransactionCompletion()를 탄다. 느낌이 온다..

 

@Override
public void beforeTransactionCompletion() {
    flushBeforeTransactionCompletion();
    actionQueue.beforeTransactionCompletion();
    try {
        getInterceptor().beforeTransactionCompletion( getTransactionIfAccessible() );
    }
    catch (Throwable t) {
        log.exceptionInBeforeTransactionCompletionInterceptor( t );
    }
    super.beforeTransactionCompletion();
}

계속 이동하면 SessionImple의 beforeTransactionCompletion()이 나오는데 여기서 flushBeforeTrasactionCompletion()이 보인다. 아직까지는 우리가 원하는 업데이트 쿼리가 생성되지 않았다. persistentContext에 엔티티들이 저장되어 있을 뿐이다. flush 과정에서 쿼리가 생성되는 걸까?

 

private void doFlush() {
    pulseTransactionCoordinator();
    checkTransactionNeededForUpdateOperation();

    try {
        FlushEvent event = new FlushEvent( this );
        // 여기!!
        fastSessionServices.eventListenerGroup_FLUSH
                .fireEventOnEachListener( event, FlushEventListener::onFlush );
        delayedAfterCompletion();
    }
    catch ( RuntimeException e ) {
        throw getExceptionConverter().convert( e );
    }
}

SessionImple의 doFlush()이다. 여기서 fastSessionServices.eventListenerGroup_FLUSH .fireEventOnEachListener( event, FlushEventListener::onFlush ) 로 이벤트를 fire하는 순간 업데이트 쿼리가 나간다. 그래서 FlushEventListener::onFlush 를 살펴보았다.

 

public void onFlush(FlushEvent event) throws HibernateException {
    if ( persistenceContext.getNumberOfManagedEntities() > 0
            || persistenceContext.getCollectionEntriesSize() > 0 ) {

        try {
            // 생략
            
            // 여기
            performExecutions( source );
            postFlush( source );
        }
        finally {
            source.getEventListenerManager().flushEnd(
                    event.getNumberOfEntitiesProcessed(),
                    event.getNumberOfCollectionsProcessed()
            );
        }
        
        // 생략
    }
    else if ( source.getActionQueue().hasAnyQueuedActions() ) {
        // execute any queued unloaded-entity deletions
        performExecutions( source );
    }
}

DefaultFlushEventListener의 onFlush()이다. 여기서 performExecutions()로 들어간다.

 

protected void performExecutions(EventSource session) {

    // IMPL NOTE : here we alter the flushing flag of the persistence context to allow
    //		during-flush callbacks more leniency in regards to initializing proxies and
    //		lazy collections during their processing.
    // For more information, see HHH-2763
    final PersistenceContext persistenceContext = session.getPersistenceContextInternal();
    final JdbcCoordinator jdbcCoordinator = session.getJdbcCoordinator();
    try {
        jdbcCoordinator.flushBeginning();
        persistenceContext.setFlushing( true );
        // we need to lock the collection caches before executing entity inserts/updates in order to
        // account for bi-directional associations
        final ActionQueue actionQueue = session.getActionQueue();
        actionQueue.prepareActions();
        actionQueue.executeActions();
    }
    finally {
        persistenceContext.setFlushing( false );
        jdbcCoordinator.flushEnding();
    }
}

actionQueue로 action을 준비한 후 실행한다. 그럼 actionQueue란 무엇이고 현재 뭐가 들어있을까?

Responsible for maintaining the queue of actions related to events. The ActionQueue holds the DML operations queued as part of a session's transactional-write-behind semantics. The DML operations are queued here until a flush forces them to be executed against the database.

세션의 트랜잭션 쓰기 지연(semantics)의 일환으로 대기 중인 DML 작업을 보유하는 곳이라 한다.

actionQueue를 살펴보면 collectionCreations라는 수상한(?) 필드가 보인다. CollectionRecreateAction이 담겨있길래 무엇인지 찾아보았다.

CollectionRecreateAction는 Hibernate의 내부 메커니즘 중 하나로, 엔티티 내에서 컬렉션에 변경 사항이 있을 때 이러한 변경 사항을 추적하고 트랜잭션이 커밋될 때 데이터베이스와 동기화하는 방법을 결정합니다.

현재 OrderItem 엔티티에는 Order의 id가 없으나 테이블에는 있다. 따라서 해당 액션을 통해 업데이트 쿼리를 만들고, 이걸로 데이터베이스와 동기화를 시키지 않나 추측된다.

 

executeActions()에서는 ORDERED_OPERATIONS enum을 반복문으로 돌며 해당 enum에 해당하는 action을 실행한다. 지금은 CollectionRecreateAction 밖에 없으므로 해당 액션을 실행하는 코드를 따라갔다.

 

@Override
public void execute() throws HibernateException {
    // this method is called when a new non-null collection is persisted
    // or when an existing (non-null) collection is moved to a new owner
    final PersistentCollection<?> collection = getCollection();
    preRecreate();
    final SharedSessionContractImplementor session = getSession();
    getPersister().recreate( collection, getKey(), session);
    session.getPersistenceContextInternal().getCollectionEntry( collection ).afterAction( collection );
    evict();
    postRecreate();

    final StatisticsImplementor statistics = session.getFactory().getStatistics();
    if ( statistics.isStatisticsEnabled() ) {
        statistics.recreateCollection( getPersister().getRole() );
    }
}

CollectionRecreateAction을 실행하는 메서드이다. 진짜 끝이 보인다..!

collection에서는 orderItem을 가져온다. 그리고 아래 getPersister().recreate( collection, getKey(), session) 메서드로 들어가보면 OneToManyPersister의 recreate() 메서드로 이동한다.

 

@Override
public void recreate(PersistentCollection<?> collection, Object id, SharedSessionContractImplementor session)
        throws HibernateException {
    getInsertRowsCoordinator().insertRows( collection, id, collection::includeInRecreate, session );
    writeIndex( collection, collection.entries( this ), id, true, session );
}

insertRows()가 보인다! 계속 들어가보면 OneToManyPersister의 buildTableUpdate() 메서드로 이동한다.

private TableUpdate<JdbcMutationOperation> buildTableUpdate(MutatingTableReference tableReference) {
    final TableUpdateBuilderStandard<JdbcMutationOperation> updateBuilder = new TableUpdateBuilderStandard<>( this, tableReference, getFactory() );
    final PluralAttributeMapping attributeMapping = getAttributeMapping();

    attributeMapping.getKeyDescriptor().getKeyPart().forEachSelectable( updateBuilder );

    final CollectionPart indexDescriptor = attributeMapping.getIndexDescriptor();
    if ( indexDescriptor != null ) {
        indexDescriptor.forEachUpdatable( updateBuilder );
    }

    final EntityCollectionPart elementDescriptor = (EntityCollectionPart) attributeMapping.getElementDescriptor();
    final EntityMappingType elementType = elementDescriptor.getAssociatedEntityMappingType();
    assert elementType.containsTableReference( tableReference.getTableName() );
    updateBuilder.addKeyRestrictionsLeniently( elementType.getIdentifierMapping() );
    return (TableUpdate<JdbcMutationOperation>) updateBuilder.buildMutation();
}

 

해당 메서드를 실행하면 다음과 같은 결과가 나온다. OrderItem 테이블을 업데이트하고, 쿼리에서 id와 order_id 부분이 파라미터라는 정보도 가지고 있다. 

 

private JdbcMutationOperation generateInsertRowOperation(MutatingTableReference tableReference) {
    // NOTE : `TableUpdateBuilderStandard` and `TableUpdate` already handle custom-sql
    final TableUpdate<JdbcMutationOperation> tableUpdate = buildTableUpdate( tableReference );
    return tableUpdate.createMutationOperation( null, getFactory() );
}

위 buildTableUpdate() 메서드를 실행했다. 이제 결과인 tableUpdate를 가지고 createMutationOperation() 메서드를 실행한다. 

 

@Override
public O createMutationOperation(ValuesAnalysis valuesAnalysis, SessionFactoryImplementor factory) {
    final SqlAstTranslatorFactory sqlAstTranslatorFactory = factory
            .getJdbcServices()
            .getJdbcEnvironment()
            .getSqlAstTranslatorFactory();
    //noinspection unchecked
    final SqlAstTranslator<JdbcMutationOperation> translator = sqlAstTranslatorFactory.buildModelMutationTranslator(
            (TableMutation<JdbcMutationOperation>) this,
            factory
    );

    //noinspection unchecked
    return (O) translator.translate( null, MutationQueryOptions.INSTANCE );
}

AbstractTableMutation의 createMutationOperation() 이다. SqlTranslator 어쩌구가 보인다. 여기서 실제 sql 쿼리로 translate하는 것 같다. 계속 가본다.

 

@Override
public void visitStandardTableUpdate(TableUpdateStandard tableUpdate) {
    getCurrentClauseStack().push( Clause.UPDATE );
    try {
        applySqlComment( tableUpdate.getMutationComment() );

        sqlBuffer.append( "update " );
        appendSql( tableUpdate.getMutatingTable().getTableName() );
        registerAffectedTable( tableUpdate.getMutatingTable().getTableName() );

        getCurrentClauseStack().push( Clause.SET );
        try {
            sqlBuffer.append( " set" );
            tableUpdate.forEachValueBinding( (columnPosition, columnValueBinding) -> {
                if ( columnPosition == 0 ) {
                    sqlBuffer.append( ' ' );
                }
                else {
                    sqlBuffer.append( ',' );
                }
                sqlBuffer.append( columnValueBinding.getColumnReference().getColumnExpression() );
                sqlBuffer.append( '=' );
                columnValueBinding.getValueExpression().accept( this );
            } );
        }
        finally {
            getCurrentClauseStack().pop();
        }

        getCurrentClauseStack().push( Clause.WHERE );
        try {
            sqlBuffer.append( " where" );
            tableUpdate.forEachKeyBinding( (position, columnValueBinding) -> {
                if ( position == 0 ) {
                    sqlBuffer.append( ' ' );
                }
                else {
                    sqlBuffer.append( " and " );
                }
                sqlBuffer.append( columnValueBinding.getColumnReference().getColumnExpression() );
                sqlBuffer.append( '=' );
                columnValueBinding.getValueExpression().accept( this );
            } );

            if ( tableUpdate.getNumberOfOptimisticLockBindings() > 0 ) {
                tableUpdate.forEachOptimisticLockBinding( (position, columnValueBinding) -> {
                    sqlBuffer.append( " and " );
                    sqlBuffer.append( columnValueBinding.getColumnReference().getColumnExpression() );
                    if ( columnValueBinding.getValueExpression() == null ) {
                        sqlBuffer.append( " is null" );
                    }
                    else {
                        sqlBuffer.append( "=" );
                        columnValueBinding.getValueExpression().accept( this );
                    }
                } );
            }

            if ( tableUpdate.getWhereFragment() != null ) {
                sqlBuffer.append( " and (" ).append( tableUpdate.getWhereFragment() ).append( ")" );
            }
        }
        finally {
            getCurrentClauseStack().pop();
        }
    }
    finally {
        getCurrentClauseStack().pop();
    }
}

AbstractSqlAstTranslator 내 메서드이다. 

해당 메서드를 실행하면 위와 같은 쿼리가 생성된다. 실제 쿼리가 생성되는 과정을 확인했다.

 

결론: 별거 없다(?)

정리하자면 어플리케이션 코드에서 Order에 OrderItem과 일대다 관계를 맺고 있음을 어노테이션을 통해 명시해준다.

@OneToMany(cascade = CascadeType.ALL)
@JoinColumn(name = "order_id")
private final List<OrderItem> orderItems = new ArrayList<>();

그리고 테스트에서 Order와 OrderItem을 저장해준다.

@DataJpaTest
public class CustomTest {

    @Autowired
    private OrderRepository orderRepository;

    @Test
    @Rollback(value = false)
    void test() {
        Order order = new Order(new OrderItems());

        orderRepository.save(order);
    }
}

(현재 둘 다 id 생성 전략을 IDENTITY로 해놓았기 때문에, 테스트 메서드의 트랜잭션이 끝나기 전에 쿼리를 날려 id를 가져온다.)

여기까지는 롤백 여부와 상관없이 일어나는 일이다.

 

그리고 트랜잭션이 종료될 때(TransactionContext.endTransaction()) 해당 트랜잭션의 롤백 유무를 검사한다. 그리고 롤백을 해야하면 바로 롤백을 한다. 롤백을 하지 않는다면 commit을 진행하는데, 이 commit 메서드 안에 flush를 하고 commit하는 과정이 포함되어있다.

 

flush를 할 때 데이터베이스로 전송할 쿼리를 생성하고 날린다. 이 때 생성은 hibernate의 SessionImpl 내 정보를 가지고 일어난다. SessionImpl에는 영속성 컨텍스트 등 다양한 정보를 가지고 있으며, 이 정보로 actionQueue를 만든다.

 

현재 Order의 orderItems인 list 컬렉션 때문에 actionQueue에 CollectionRecreateAction이 추가된다. 그리고 이걸로 OneToManyPersister의 buildTableUpdate()를 실행하여 TableUpdateStandart 객체를 생성하고, 이를 sqlTranslator(지금은 h2 translator를 사용한다)를 사용해 실제 쿼리로 번역한다.

 

즉, 업데이트 쿼리는 hibernate의 SessionImpl 정보를 바탕으로 flush 과정에서 만들어진다.

 

알게된 점

- 테스트에서는 TransactionalTestExecutionListener를 통해 테스트용 트랜잭션 설정을 해준다.

- 테스트에서는 TransactionContext에서 해당 트랜잭션에 대한 롤백 마크를 한다.