JPA Flush

6 분 소요


⚠️ 해당 포스트는 2021년 8월 22일에 재작성되었습니다.(불필요 코드 제거)

영속성 컨텍스트(Persistence Context)에 존재하는 엔티티(Entity) 변경 내용을 데이터베이스에 동기화하는 작업

JPA에서 flush가 동작하는 시점은 다음과 같습니다.

  • 명시적으로 entityManager.flush() 메소드 호출
  • entityManager.getTransaction().commit() 메소드 호출
  • JPQL 쿼리 수행시 자동으로 수행(FlushModeType.AUTO인 경우에만 적용)

명시적인 호출이나 commit 시점에 동작하는 것은 어렵지 않게 받아들일 수 있습니다. 그런데 JPQL은 무엇이고, FlushModeType.AUTO인 경우에만 적용된다는 말은 어떤 의미인지 모르겠습니다. 관련 내용들을 조금 더 자세히 알아보았습니다.

1. Flush 모드 옵션

JPA flush 기능이 수행되는 시점을 결정하는 옵션

다음과 같은 옵션이 있습니다.

  • FlushModeType.AUTO, 디폴트(default) 값으로 commit 또는 JQPL 쿼리 실행시 flush 기능이 수행됩니다.
  • FlushModeType.COMMIT, commit 시점에만 flush 기능이 수행됩니다.(성능 최적화를 위해 필요한 경우에만 사용)

2. JPQL(Java Persistence Query Language)

JPA에서 사용하는 객체 지향적 쿼리(Query)

JPQL은 테이블을 대상으로 쿼리를 하는 것이 아니라 엔티티 객체를 대상으로 쿼리하기 때문에 일반적인 SQL과 조금 다른 문법 요소를 가집니다. JPQL에 대한 정리 글이 아니니 간단히 개념에 대해서만 정리하고 JPA flush 메소드와 어떤 연관이 있는지 알아보도록 하겠습니다.

JPQL은 영속성 컨텍스트에 의해 관리되고 있는 데이터를 고려하지 않고 동작합니다.(JPA Clear) 그렇기 때문에 JPQL 사용 시 flush 모드 옵션 값에 따라 같은 코드가 서로 다른 결과를 가지게 됩니다. FlushModeType.COMMIT 옵션 사용 시 JPQL 쿼리 수행 전 반드시 flush 메소드를 호출하여 영속성 컨텍스트에 저장된 데이터와 데이터베이스를 동기화할 필요가 있습니다.

3. 테스트 코드

아래와 같은 관점에서 테스트 코드를 작성하였습니다.

  • FlushModeType.AUTO 옵션 사용 시 JPQL 쿼리 사용 전에 flush 기능이 동작되었는가?
  • FlushModeType.COMMIT 옵션 사용 시 JPQL 쿼리 사용 전에 flush 기능이 동작되었는가?

테스트 방법은 아래와 같으며 데이터가 같은 결과를 갖는지 알아보도록 하겠습니다.

  • flush 메소드 내부적으로 동작하였는지 쿼리를 JQPL 쿼리 사용 전, 후에 로그를 남겨 확인합니다.
  • MEMBER 권한을 가진 데이터들을 JPQL_MEMBER 권한으로 변경한 후 JPQL_MEMBER 권한을 가진 멤버가 1명 이상인지 확인합니다.

3.1. FlushModeType.AUTO 옵션 사용 테스트

package blog.in.action.flush;

import static org.junit.jupiter.api.Assertions.assertTrue;
import blog.in.action.entity.Member;
import java.util.ArrayList;
import java.util.List;
import javax.persistence.EntityManager;
import javax.persistence.EntityManagerFactory;
import javax.persistence.FlushModeType;
import javax.persistence.PersistenceUnit;
import javax.persistence.TypedQuery;
import lombok.extern.log4j.Log4j2;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;

@Log4j2
@SpringBootTest
public class AutoOptionTest {

    @PersistenceUnit
    private EntityManagerFactory factory;

    @BeforeEach
    private void beforeEach() {
        EntityManager em = factory.createEntityManager();
        try {
            em.getTransaction().begin();
            Member member = em.find(Member.class, "01012341234");
            if (member == null) {
                member = new Member();
                member.setId("01012341234");
                member.setPassword("1234");
                member.setMemberName("Junhyunny");
                member.setMemberEmail("kang3966@naver.com");
                em.persist(member);
            } else {
                member.setAuthorities(new ArrayList<>());
            }
            em.getTransaction().commit();
        } catch (Exception ex) {
            em.getTransaction().rollback();
            log.error("exception occurs", ex);
        } finally {
            em.close();
        }
    }

    @Test
    public void test() {
        EntityManager em = factory.createEntityManager();
        em.setFlushMode(FlushModeType.AUTO);
        try {
            em.getTransaction().begin();

            Member member = em.find(Member.class, "01012341234");
            List<String> authorities = new ArrayList<>(member.getAuthorities());
            authorities.add("MEMBER");
            member.setAuthorities(authorities);

            log.info("JPQL 쿼리 수행 전입니다.");

            String jpql = "update Member m set m.authorities = 'JQPL_MEMBER' where m.authorities like '%MEMBER%'";
            em.createQuery(jpql).executeUpdate();

            jpql = "select m from Member m where m.authorities like '%JQPL_MEMBER%'";

            TypedQuery<Member> query = em.createQuery(jpql, Member.class);
            List<Member> jpqlMember = query.getResultList();

            log.info("JPQL 쿼리 수행 후입니다.");

            assertTrue(jpqlMember.size() > 0);

            em.getTransaction().commit();
        } catch (Exception ex) {
            em.getTransaction().rollback();
            log.error("exception occurs", ex);
        } finally {
            em.close();
        }
    }
}
FlushModeType.AUTO 옵션 사용 테스트 수행 결과
  • Junit 테스트 결과, 수행 로그, 데이터베이스에 마지막으로 저장된 데이터
  • JPQL 메소드 수행 전 엔티티가 변경되었음을 감지하여 update 쿼리가 수행되는 것을 로그로 확인할 수 있습니다.
  • 권한이 ‘JQPL_MEMBER’인 데이터가 1건 이상인 경우 테스트가 통과됩니다.

Hibernate: select member0_.id as id1_0_0_, member0_.authorities as authorit2_0_0_, member0_.member_email as member_e3_0_0_, member0_.member_name as member_n4_0_0_, member0_.password as password5_0_0_ from tb_member member0_ where member0_.id=?
Hibernate: insert into tb_member (authorities, member_email, member_name, password, id) values (?, ?, ?, ?, ?)
Hibernate: select member0_.id as id1_0_0_, member0_.authorities as authorit2_0_0_, member0_.member_email as member_e3_0_0_, member0_.member_name as member_n4_0_0_, member0_.password as password5_0_0_ from tb_member member0_ where member0_.id=?
2021-08-22 01:53:27.085  INFO 18304 --- [           main] blog.in.action.flush.AutoOptionTest      : JPQL 쿼리 수행 전입니다.
Hibernate: update tb_member set authorities=?, member_email=?, member_name=?, password=? where id=?
Hibernate: update tb_member set authorities='JQPL_MEMBER' where authorities like '%MEMBER%'
Hibernate: select member0_.id as id1_0_, member0_.authorities as authorit2_0_, member0_.member_email as member_e3_0_, member0_.member_name as member_n4_0_, member0_.password as password5_0_ from tb_member member0_ where member0_.authorities like '%JQPL_MEMBER%'
2021-08-22 01:53:27.181  INFO 18304 --- [           main] blog.in.action.flush.AutoOptionTest      : JPQL 쿼리 수행 후입니다.

3.2. FlushModeType.COMMIT 옵션 사용 테스트

package blog.in.action.flush;

import static org.junit.jupiter.api.Assertions.assertFalse;
import blog.in.action.entity.Member;
import java.util.ArrayList;
import java.util.List;
import javax.persistence.EntityManager;
import javax.persistence.EntityManagerFactory;
import javax.persistence.FlushModeType;
import javax.persistence.PersistenceUnit;
import javax.persistence.TypedQuery;
import lombok.extern.log4j.Log4j2;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;

@Log4j2
@SpringBootTest
public class CommitOptionTest {

    @PersistenceUnit
    private EntityManagerFactory factory;

    @BeforeEach
    private void beforeEach() {
        EntityManager em = factory.createEntityManager();
        try {
            em.getTransaction().begin();
            Member member = em.find(Member.class, "01012341234");
            if (member == null) {
                member = new Member();
                member.setId("01012341234");
                member.setPassword("1234");
                member.setMemberName("Junhyunny");
                member.setMemberEmail("kang3966@naver.com");
                em.persist(member);
            } else {
                member.setAuthorities(new ArrayList<>());
            }
            em.getTransaction().commit();
        } catch (Exception ex) {
            em.getTransaction().rollback();
            log.error("exception occurs", ex);
        } finally {
            em.close();
        }
    }

    @Test
    public void test() {
        EntityManager em = factory.createEntityManager();
        em.setFlushMode(FlushModeType.COMMIT);
        try {
            em.getTransaction().begin();

            Member member = em.find(Member.class, "01012341234");
            List<String> authorities = new ArrayList<>(member.getAuthorities());
            authorities.add("MEMBER");
            member.setAuthorities(authorities);

            log.info("JPQL 쿼리 수행 전입니다.");

            String jpql = "update Member m set m.authorities = 'JQPL_MEMBER' where m.authorities like '%MEMBER%'";
            em.createQuery(jpql).executeUpdate();

            jpql = "select m from Member m where m.authorities like '%JQPL_MEMBER%'";
            TypedQuery<Member> query = em.createQuery(jpql, Member.class);
            List<Member> jpqlMember = query.getResultList();

            log.info("JPQL 쿼리 수행 후입니다.");

            assertFalse(jpqlMember.size() > 0);

            em.getTransaction().commit();
        } catch (Exception ex) {
            em.getTransaction().rollback();
            log.error("exception occurs", ex);
        } finally {
            em.close();
        }
    }
}
FlushModeType.COMMIT 옵션 사용 테스트 수행 결과
  • Junit 테스트 결과, 수행 로그, 데이터베이스에 마지막으로 저장된 데이터
  • FlushModeType.AUTO 테스트와는 다르게 JPQL 쿼리 수행 전에 update 쿼리가 수행되지 않음을 알 수 있습니다.
  • 트랜잭션 commit 시점에 엔티티 변경사항에 대한 업데이트가 수행됩니다.
  • 권한이 ‘JQPL_MEMBER’인 데이터가 0건인 경우 테스트가 통과됩니다.
  • 데이터베이스에 저장된 데이터가 FlushModeType.AUTO 테스트 때와 다름을 알 수 있습니다.

Hibernate: select member0_.id as id1_0_0_, member0_.authorities as authorit2_0_0_, member0_.member_email as member_e3_0_0_, member0_.member_name as member_n4_0_0_, member0_.password as password5_0_0_ from tb_member member0_ where member0_.id=?
Hibernate: update tb_member set authorities=?, member_email=?, member_name=?, password=? where id=?
Hibernate: select member0_.id as id1_0_0_, member0_.authorities as authorit2_0_0_, member0_.member_email as member_e3_0_0_, member0_.member_name as member_n4_0_0_, member0_.password as password5_0_0_ from tb_member member0_ where member0_.id=?
2021-08-22 01:56:42.849  INFO 18376 --- [           main] blog.in.action.flush.CommitOptionTest    : JPQL 쿼리 수행 전입니다.
Hibernate: update tb_member set authorities='JQPL_MEMBER' where authorities like '%MEMBER%'
Hibernate: select member0_.id as id1_0_, member0_.authorities as authorit2_0_, member0_.member_email as member_e3_0_, member0_.member_name as member_n4_0_, member0_.password as password5_0_ from tb_member member0_ where member0_.authorities like '%JQPL_MEMBER%'
2021-08-22 01:56:42.932  INFO 18376 --- [           main] blog.in.action.flush.CommitOptionTest    : JPQL 쿼리 수행 후입니다.
Hibernate: update tb_member set authorities=?, member_email=?, member_name=?, password=? where id=?

4. FlushModeType 값에 따른 flush 여부 판단

FlushModeType 옵션 값에 따라 어디에서 flush 여부를 판정하는지 궁금하여 디버깅해보았습니다. 콜 스택(call stack)을 확인하니 호출 위치는 아래와 같았습니다.

  1. org.hibernate.internal.SessionImpl 클래스 - autoFlushIfRequired 메소드
  2. org.hibernate.event.internal.DefaultAutoFlushEventListener.onAutoFlush 메소드
  3. org.hibernate.event.internal.DefaultAutoFlushEventListener.flushMightBeNeeded 메소드
    • 해당 위치에서 FlushMode.Auto 값 이상인 경우에 flsuh 여부가 필요할 것으로 판정합니다.
    • FlushMode.Auto 보다 큰 값을 가지는 경우는 FlushMode.ALWAYS 밖에 없습니다.
Debuging Call Stack

AutoFlush 수행 코드, DefaultAutoFlushEventListener 클래스
    public void onAutoFlush(AutoFlushEvent event) throws HibernateException {
        final EventSource source = event.getSession();
        final SessionEventListenerManager eventListenerManager = source.getEventListenerManager();
        try {
            eventListenerManager.partialFlushStart();

            if ( flushMightBeNeeded( source ) ) {
                // Need to get the number of collection removals before flushing to executions
                // (because flushing to executions can add collection removal actions to the action queue).
                final ActionQueue actionQueue = source.getActionQueue();
                final int oldSize = actionQueue.numberOfCollectionRemovals();
                flushEverythingToExecutions( event );
                if ( flushIsReallyNeeded( event, source ) ) {
                    LOG.trace( "Need to execute flush" );
                    event.setFlushRequired( true );

                    // note: performExecutions() clears all collectionXxxxtion
                    // collections (the collection actions) in the session
                    performExecutions( source );
                    postFlush( source );

                    postPostFlush( source );

                    final StatisticsImplementor statistics = source.getFactory().getStatistics();
                    if ( statistics.isStatisticsEnabled() ) {
                        statistics.flush();
                    }
                }
                else {
                    LOG.trace( "Don't need to execute flush" );
                    event.setFlushRequired( false );
                    actionQueue.clearFromFlushNeededCheck( oldSize );
                }
            }
        }
        finally {
            eventListenerManager.partialFlushEnd(
                    event.getNumberOfEntitiesProcessed(),
                    event.getNumberOfEntitiesProcessed()
            );
        }
    }

    // ...

    private boolean flushMightBeNeeded(final EventSource source) {
        final PersistenceContext persistenceContext = source.getPersistenceContextInternal();
        return !source.getHibernateFlushMode().lessThan( FlushMode.AUTO )
                && source.getDontFlushFromFind() == 0
                && ( persistenceContext.getNumberOfManagedEntities() > 0 ||
                        persistenceContext.getCollectionEntriesSize() > 0 );
    }

CLOSING

개발자가 기술에 대해 이해도가 낮은 경우 의도치 않은 버그를 유발할 수 있습니다. 만약, 'JPQL은 EntityManager가 flush하지 않은 데이터를 확인할 수 없다.'는 사실을 모르고 개발된 어플리케이션은 찾기 어려운 버그를 내포할 가능성이 높습니다. '의도치 않는 문제를 피해가고자 사용하는 기술에 대해 꼼꼼히 공부하자.'라는 취지에서 JPA flush는 별도의 주제로 정리해보았습니다. FlushModeType.COMMIT 옵션을 사용한 테스트에서 JPQL 사용 전 em.flush() 메소드를 호출하면 어떤 결과를 얻을 수 있는지 테스트해보시기 바랍니다.

TEST CODE REPOSITORY

REFERENCE