JPA Flush

4 분 소요


RECOMMEND POSTS BEFORE THIS

1. What is flush?

영속성 컨텍스트(persistence context)에서 관리하는 엔티티(entity)의 변경 내용을 데이터베이스에 동기화하는 작업

JPA는 영속성 컨텍스트에서 관리 중인 엔티티의 모습을 즉시 데이터베이스 반영하지 않습니다. 1차 캐시로서 기능을 수행하면서 필요하다고 판단되는 시점에 변경 쿼리를 수행합니다. JPA에서 플러쉬(flush)가 작동하는 시점은 다음과 같습니다.

  • 엔티티 매니저(entity manager)의 flush 메소드 호출
  • 엔티티 매니저의 트랜잭션(transaction) 객체의 commit 메소드 호출
  • FlushModeType.AUTO 설정인 경우 JPQL(Java Persistence Query Language) 쿼리(Query) 실행 시점

flush, commit 메소드처럼 명시적인 호출은 이해하기 쉽습니다. 기능을 제대로 파악하지 못해 발생하는 실수를 줄이고자 세 번째 조건에 대한 내용을 좀 더 자세히 알아보겠습니다.

2. FlushModeType Options and JPQL(Java Persistence Query Language)

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

FlushModeType 옵션은 플러쉬 기능이 수행되는 시점을 결정합니다. 다음과 같은 옵션이 있습니다.

  • FlushModeType.AUTO
    • 디폴트 값 입니다.
    • 커밋(commit) 또는 JQPL 쿼리 실행 시 플러쉬 기능을 수행합니다.
  • FlushModeType.COMMIT
    • 커밋 시점에만 flush 기능을 수행합니다.
    • 해당 옵션은 성능 최적화를 위해 필요한 경우에만 사용합니다.

JPQLJPA에서 지원하는 객체 지향적 쿼리 문법을 의미합니다. 테이블을 기준으로 질의문을 작성하는 것이 아닌 엔티티 객체를 기준으로 작성합니다. 때문에 일반적인 SQL과 다른 문법적 차이점들을 지닙니다.

3. Practices

JPQL은 영속성 컨텍스트의 지연 쓰기처럼 최대한 실행을 늦추지 않고 즉시 수행합니다. JPQL 쿼리가 영속성 컨텍스트에 담긴 엔티티들의 모습을 고려하지 않고 데이터베이스를 직접 변경하게 되면 데이터의 최종적인 모습이 순서에 맞지 않게 변경되면서 원치 않은 모습을 갖게 될 수 있습니다. 예를 들면 다음과 같은 상황을 들 수 있습니다.

  1. 먼저 영속성 컨텍스트에 관리 중인 엔티티에 오염이 발생했다.
  2. 이후 JPQL 쿼리가 이를 고려하지 않고 데이터베이스를 업데이트 했다.
  3. 트랜잭션 마지막에 커밋이 발생하면서 영속성 컨텍스트의 오염된 엔티티가 오염 확인(dirty checking)에 의해 업데이트 된다.

순서대로라면 JPQL 업데이트가 데이터베이스에 담긴 데이터의 최종 모습이어야 하지만, 결과는 먼저 발생한 오염된 엔티티의 모습이 반영됩니다. 이런 현상을 방지하기 위해 영속성 컨텍스트에서 관리 중인 엔티티들의 변경을 플러쉬 수행으로 먼저 데이터베이스에 동기화합니다.

다음과 같은 내용을 확인해봤습니다.

  • FlushModeType.AUTO 옵션 사용 시 JPQL 쿼리 사용 전에 플러쉬 되는가?
  • FlushModeType.COMMIT 옵션 사용 시 JPQL 쿼리 사용 전에 플러쉬 되는가?

3.1. FlushModeType.AUTO Option

FlushModeType.AUTO 옵션을 사용할 때 동작을 살펴보겠습니다.

  • 이름이 Junhyun Kang인 사용자를 추가합니다.
    • 해당 사용자의 최초 연락처는 이메일 한 개입니다.
  • 사용자 정보를 변경합니다.
    • 이름은 Junhyun으로 변경합니다.
    • 휴대폰 번호를 추가합니다.
  • JPQL 업데이트 쿼리를 수행합니다.
    • 조회 조건은 이름이 Junhyun인 사용자입니다.
    • 연락처를 다른 휴대폰 번호로 업데이트합니다.
  • JPQL 업데이트 쿼리가 정상적으로 수행되어 변경한 데이터는 1 건으로 예상합니다.
  • clear 메소드로 영속성 컨텍스트를 비우고 재조회합니다.
    • 데이터베이스를 직접 변경했기 때문에 영속성 컨텍스트의 엔티티와 데이터베이스의 데이터 모습이 다릅니다.
    • 영속성 컨텍스트에 해당되는 엔티티가 없어야지 엔티티 매니저는 데이터베이스에 변경된 최신 데이터를 재조회합니다.
    • 조회된 사용자의 연락처는 한 개입니다.
    • 연락처는 JPQL 업데이트에 의해 변경된 휴대폰 번호입니다.
package blog.in.action.flush;

import blog.in.action.entity.Member;
import lombok.extern.log4j.Log4j2;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;

import javax.persistence.*;
import java.util.Arrays;
import java.util.List;
import java.util.function.Consumer;
import java.util.stream.Collectors;

import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.equalTo;

@Log4j2
@DataJpaTest
public class AutoOptionTest {

    @PersistenceUnit
    private EntityManagerFactory factory;

    void transaction(Consumer<EntityManager> consumer) {
        EntityManager entityManager = factory.createEntityManager();
        EntityTransaction transaction = entityManager.getTransaction();
        transaction.begin();
        try {
            consumer.accept(entityManager);
        } catch (Exception ex) {
            throw ex;
        } finally {
            transaction.rollback();
            entityManager.close();
        }
    }

    @Test
    void how_to_work_when_auto_option() {
        transaction(entityManager -> {
            entityManager.setFlushMode(FlushModeType.AUTO);
            Member member = Member.builder()
                    .id("Junhyunny")
                    .name("Junhyun Kang")
                    .contacts(Arrays.stream(new String[]{"kang3966@naver.com"}).collect(Collectors.toList()))
                    .build();
            entityManager.persist(member);
            member.setName("Junhyun");
            member.appendContact("010-1234-1234");


            log.info("===== BEFORE JPQL =====");
            String jpqlQuery = "update Member m set m.contacts = '010-3214-3214' where m.name = 'Junhyun'";
            int resultCnt = entityManager.createQuery(jpqlQuery).executeUpdate();
            log.info("===== AFTER JPQL =====");


            entityManager.clear();
            Member result = entityManager.find(Member.class, "Junhyunny");
            List<String> contacts = result.getContacts();
            assertThat(resultCnt, equalTo(1));
            assertThat(contacts.size(), equalTo(1));
            assertThat(contacts.get(0), equalTo("010-3214-3214"));
        });
    }
}
Test Result

다음과 같은 수행 결과를 얻습니다.

  • JPQL 업데이트 쿼리 수행 전에 영속성 컨텍스트의 엔티티의 모습이 데이터베이스에 반영됩니다.
    • 추가(insert), 변경(update) 쿼리가 수행됩니다.
  • 변경된 이름인 Junhyun에 해당하는 데이터의 연락처를 변경합니다.
2023-01-28 00:42:09.847  INFO 24300 --- [           main] blog.in.action.flush.AutoOptionTest      : ===== BEFORE JPQL =====
Hibernate: insert into tb_member (contacts, name, id) values (?, ?, ?)
Hibernate: update tb_member set contacts=?, name=? where id=?
Hibernate: update tb_member set contacts='010-3214-3214' where name='Junhyun'
2023-01-28 00:42:09.963  INFO 24300 --- [           main] blog.in.action.flush.AutoOptionTest      : ===== AFTER JPQL =====
Hibernate: select member0_.id as id1_0_0_, member0_.contacts as contacts2_0_0_, member0_.name as name3_0_0_ from tb_member member0_ where member0_.id=?

3.2. FlushModeType.COMMIT Option

FlushModeType.COMMIT 옵션을 사용할 때 동작을 살펴보겠습니다.

  • 이름이 Junhyun Kang인 사용자를 추가합니다.
    • 해당 사용자의 최초 연락처는 이메일 한 개입니다.
  • 사용자 정보를 변경합니다.
    • 이름은 Junhyun으로 변경합니다.
    • 휴대폰 번호를 추가합니다.
  • JPQL 업데이트 쿼리를 수행합니다.
    • 조회 조건은 이름이 Junhyun인 사용자입니다.
    • 연락처를 다른 휴대폰 번호로 업데이트합니다.
  • JPQL 업데이트 쿼리가 정상적으로 수행되지 않아 변경한 데이터는 0 건으로 예상합니다.
  • 사용자를 조회합니다.
    • 조회된 사용자의 연락처는 두 개입니다.
    • 초기에 설정한 이메일과 다음에 추가한 휴대폰 번호입니다.
package blog.in.action.flush;

import blog.in.action.entity.Member;
import lombok.extern.log4j.Log4j2;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;

import javax.persistence.*;
import java.util.Arrays;
import java.util.List;
import java.util.function.Consumer;
import java.util.stream.Collectors;

import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.equalTo;

@Log4j2
@DataJpaTest
public class CommitOptionTest {

    @PersistenceUnit
    private EntityManagerFactory factory;

    void transaction(Consumer<EntityManager> consumer) {
        EntityManager entityManager = factory.createEntityManager();
        EntityTransaction transaction = entityManager.getTransaction();
        transaction.begin();
        try {
            consumer.accept(entityManager);
        } catch (Exception ex) {
            throw ex;
        } finally {
            transaction.rollback();
            entityManager.close();
        }
    }

    @Test
    void how_to_work_when_commit_option() {
        transaction(entityManager -> {
            entityManager.setFlushMode(FlushModeType.COMMIT);
            Member member = Member.builder()
                    .id("Junhyunny")
                    .name("Junhyun Kang")
                    .contacts(Arrays.stream(new String[]{"kang3966@naver.com"}).collect(Collectors.toList()))
                    .build();
            entityManager.persist(member);
            member.setName("Junhyun");
            member.appendContact("010-1234-1234");


            log.info("===== BEFORE JPQL =====");
            String jpqlQuery = "update Member m set m.contacts = '010-3214-3214' where m.name = 'Junhyun'";
            int resultCnt = entityManager.createQuery(jpqlQuery).executeUpdate();
            log.info("===== AFTER JPQL =====");


            Member result = entityManager.find(Member.class, "Junhyunny");
            List<String> contacts = result.getContacts();
            assertThat(resultCnt, equalTo(0));
            assertThat(contacts.size(), equalTo(2));
            assertThat(contacts.get(0), equalTo("kang3966@naver.com"));
            assertThat(contacts.get(1), equalTo("010-1234-1234"));
        });
    }
}
Test Result

다음과 같은 수행 결과를 얻습니다.

  • JPQL 업데이트 쿼리 수행 전에 영속성 컨텍스트의 엔티티의 모습이 데이터베이스에 반영되지 않습니다.
    • 추가(insert), 변경(update) 쿼리가 수행되지 않습니다.
  • 변경된 이름인 Junhyun에 해당하는 데이터를 변경하지 못 합니다.
    • JPQL 업데이트 쿼리를 수행하는 시점에 데이터베이스엔 조건에 해당되는 데이터가 없습니다.
2023-01-28 00:53:13.667  INFO 10436 --- [           main] blog.in.action.flush.CommitOptionTest    : ===== BEFORE JPQL =====
Hibernate: update tb_member set contacts='010-3214-3214' where name='Junhyun'
2023-01-28 00:53:13.748  INFO 10436 --- [           main] blog.in.action.flush.CommitOptionTest    : ===== AFTER JPQL =====

CLOSING

개발자가 기술에 대해 이해도가 낮은 경우 의도치 않은 버그를 유발하게 됩니다. 만약 "엔티티 매니저는 JPQL 쿼리 수행 전에 플러쉬를 하지 않으면 업데이트 순서가 꼬인다."는 사실을 몰랐다면 찾기 힘든 버그가 숨어들 가능성이 높습니다. 의도치 않게 발생할 수 있는 문제를 피하기 위해 꼼꼼히 공부하자는 차원에서 정리하였습니다.

TEST CODE REPOSITORY

RECOMMEND NEXT POSTS

REFERENCE

댓글남기기