멈재

[JPA] 변경 감지와 병합 (JpaRepository.save도 병합이 일어난다?) 본문

JAVA & Spring & JPA

[JPA] 변경 감지와 병합 (JpaRepository.save도 병합이 일어난다?)

멈재 2023. 12. 9. 20:26
728x90

JPA에서 데이터를 변경하는 방법을 소개하고 병합을 통한 변경 시에 주의할 점에 대해서 소개하려고 합니다.

 

일반적으로 Spring Data JPA에서 데이터를 변경하는 방법은 두 가지가 있습니다.

  • 더티채킹에 의한 변경
  • 병합(merge)을 이용한 변경

 

 

그리고 Spring Data Jpa를 이용하여 작성한 코드는 보통 다음과 같은 패턴의 형태로 동작되게 됩니다.

EntityManagerFactory emf = Persistence.createEntityManagerFactory("hello jpa");
EntityManager em = emf.createEntityManager();
EntityTransaction tx = em.getTransaction();

try {
    tx.begin();

    // biz logic...

    tx.commit();
} catch (Exception e) {
    tx.rollback();
    throw e;
} finally {
    em.close();
    emf.close();
}

 

이와 같은 형태는 JDBC로 작성한 방식과 매우 유사한 형태를 띄고 있는데요, 하지만 JPA에는 영속성 컨텍스트라는 특별한 개념이 존재합니다.

 

JPA를 사용하여( 엔티티 매니저를 통해 ) 엔티티를 저장하거나 조회하면 엔티티를 영속성 컨텍스트에 보관하고 관리하게 됩니다.

자바 ORM 표준 JPA 프로그래밍 P93, 엔티티의 생명주기

 

그리고 이 영속성 컨텍스트는 특정 시점이 되면 영속성 컨텍스트에 관리되는 엔티티 객체들 중에 변경이 일어난 객체를 데이터베이스에 반영하는 작업을 수행합니다.

JPA에서는 이처럼 변경이 일어난 객체를 데이터베이스에 반영하는 작업을 플러시(flush)라고 하며, 아래 세 가지 상황에서 동작됩니다.

(참고로 변경의 대상이 되는 시점은 영속성 컨텍스트에 최초로 등록된 시점입니다.)

  • EntityManager.flush를 직접 호출
  • 트랜잭션 커밋 시점에 flush가 자동 호출
  • JPQL 쿼리 실행 직전에 자동 호출

 

변경 대상의 시점으로 인해 발생할 수 있는 문제를 다룬 포스팅도 있으니 참고바랍니다.
[Spring/JPA] JPA flush, 편하게 써도 되는걸까

 

 

 

더티 채킹

더티채킹은 영속성 컨텍스트에서 관리되는 엔티티의 변경 사항을 감지하면 UPDATE 쿼리를 생성하여 업데이트하는 Hibernate가 사용하는 메커니즘입니다.

이때 중요한 것은 변경 사항의 대상은 영속성 컨텍스트에서 관리하는 엔티티에 대해서만 적용된다는 것입니다.

즉, 준영속/비영속 상태의 엔티티는 더티 채킹의 대상에 포함되지 않습니다.

 

Spring Data Jpa와 @Transactional 을 함께 사용하게 되면 다음과 같이 더티 채킹된 엔티티는 변경 감지가 일어납니다.

@Service
@RequiredArgsConstructor
public class ArticleService {

    private final ArticleJpaRepository articleJpaRepository;

    @Transactional
    public void update(final Long articleId, String title) {
        Article article = articleJpaRepository.getArticle(articleId);
        article.changeTitle(title);
    }
}
@ActiveProfiles("test")
@SpringBootTest
class ArticleTest {

    @Autowired
    protected ArticleJpaRepository articleRepository;

    @Autowired
    protected ArticleService articleService;

    private Article article;

    @BeforeEach
    public void setUp() {
        article = articleRepository.save(new Article("더티채킹과 병합", "Spring / JPA", "멈재"));
    }

    @Test
    void updateTest() {
        // given
        final Long id = article.getId();
        final String given = "행복한 JPA";

        // when
        articleService.update(id, given);

        // then
        Article find = articleRepository.getArticleById(id);
        assertThat(given).isEqualTo(find.getTitle());
    }
}

 

이처럼 영속성 컨텍스트에 관리되는 엔티티를 변경하게 하고 나서 save 메서드로 적어주지 않아도 UPDATE 쿼리가 실행된 것을 알 수 있습니다.

 

 

병합(merge)

경우에 따라서 영속성 컨텍스트에서 관리되고 있지는 않지만, 이미 데이터베이스를 거친 엔티티(식별자를 가지고 있는 값)를 변경해야 할 때도 있습니다.

이처럼 영속성 컨텍스트에서 관리되고 있지는 않지만 식별자를 가진 엔티티를 준영속(detach) 엔티티라고 부르고, 이 준영속 엔티티인 상태에서는 더티 채킹의 대상이 되지 않으므로 변경이 이루어지지 않습니다.

 

영속성 컨텍스트에 관리되지 않게끔(트랜잭션 범위를 벗어나게 하기 위해) 호출자에 Transactional을 제거한 updateV2를 만들어 테스트하면 다음과 같은 결과를 확인할 수 있습니다.

@Service
@RequiredArgsConstructor
public class ArticleService {

    private final ArticleJpaRepository articleJpaRepository;

    public void updateV2(final Long articleId, String title) {
        Article detach = articleJpaRepository.getArticleById(articleId); // 준영속 상태
        detach.changeTitle(title);
    }

    @Transactional
    public void update(final Long articleId, String title) {
				...
    }
}
@ActiveProfiles("test")
@SpringBootTest
class ArticleTest {

    @Autowired
    protected ArticleJpaRepository articleRepository;

    @Autowired
    protected ArticleService articleService;

    private Article article;

    @BeforeEach
    public void setUp() {
        article = articleRepository.save(new Article("더티채킹과 병합", "Spring / JPA", "멈재"));
    }

    @Test
    void updateV2Test() {
        // given
        final Long id = article.getId();
        final String given = "행복한 JPA";

        // when
        articleService.updateV2(id, given);

        // then
        Article find = articleRepository.getArticleById(id);
        assertThat(given).isEqualTo(find.getTitle());
    }
}

엔티티가 영속성 컨텍스트에 관리될 때와는 다르게 변경을 시도하더라도 실제로는 반영되지 않은 것을 알 수 있습니다.

 

이처럼 준영속 엔티티를 수정하는 방법은 일반적인 방법과는 다른데, 준영속 엔티티를 수정하는 방법은 두 가지가 있습니다.

  • 변경 감지 기능을 사용하여 변경
  • 병합(merge)을 사용해서 변경

 

변경 감지를 사용하여 변경

변경 감지를 사용하는 것은 트랜잭션 안에서 엔티티를 조회해야 영속성 컨텍스트에 관리가 되므로 Transactional로 감싸주기만 하면 됩니다.

@Transactional
public void updateV2(final Long articleId, String title) {
    Article article = articleJpaRepository.getArticleById(articleId);
    detach.changeTitle(article);
}

 

이때 주의할 점은 Transactional 의 옵션이 readOnly = false로 설정되어야 한다는 것인데요,

만약 readOnly=true 로 설정될 경우 플러시가 발생하지 않아서 변경을 시도해도 반영되지 않습니다.

 

 

병합(merge)을 사용해서 변경

병합은 준영속 상태의 엔티티를 영속 상태로 변경할 때 사용하는 기능으로 엔티티 매니저 merge 기능을 통해 사용할 수 있습니다.

@Service
@RequiredArgsConstructor
public class ArticleService {

    private final EntityManager em;

    @Transactional
    public void updateV3(final Long articleId, String title) {
        Article detach = new Article(articleId, title); // 준영속 상태
        em.merge(detach);
        detach.changeTitle(title);
    }
}
@Test
void updateV3Test() {
    // given
    final Long id = article.getId();
    final String given = "행복한 JPA";

    // when
    articleService.updateV3(id, given);

    // then
    Article find = articleRepository.getArticleById(id);
    assertThat(given).isEqualTo(find.getTitle());
}

 

테스트 결과만 보았을 때에는 변경 감지와 병합은 다른 게 없어 보입니다.

 

그러나 병합은 영속 엔티티의 값을 준영속 엔티티의 값으로 모두 교체하게 됩니다.

이 말은, 준영속 엔티티로 병합을 시도할 때 값이 없으면 NULL로 업데이트될 위험이 존재합니다.

(즉 휴면 에러가 발생할 확률이 높아집니다)

 

따라서 앞선 예시의 준영속 엔티티와 병합 후 엔티티를 조회해 보면 다음의 문제가 발생한 것을 알 수 있습니다.

@Test
void updateV3Test() {
    // given
    final Long id = article.getId();
    final String given = "행복한 JPA";
    System.out.println("before = " + article);

    // when
    articleService.updateV3(id, given);

    // then
    Article find = articleRepository.getArticleById(id);
    System.out.println("after  = " + find);
    assertThat(given).isEqualTo(find.getTitle());
}

 

 

 

사실 이 포스팅을 쓰게 된 건 비단 엔티티 매니저로 병합을 사용하지 말자 를 말하고자 한 것이 아닙니다.

 

엔티티의 모든 변경을 더티 채킹만으로 시도한다면 다음의 문제가 발생하지는 않지만 만약 save 메서드를 명시적으로 쓰는 경우라면 문제가 발생할 수 있습니다.

 

그 이유는 JpaRepository의 구현체인 SimpleJpaRepository 클래스의 save 메서드의 동작 방식 때문입니다.

SimpleJpaRepository 클래스의 save 메서드 내부 구현을 살펴보겠습니다.

@Repository
@Transactional(readOnly = true)
public class SimpleJpaRepository<T, ID> implements JpaRepositoryImplementation<T, ID> {

    private final EntityManager entityManager;

    public SimpleJpaRepository(..., EntityManager entityManager) {
        ...
        this.entityManager = entityManager;
    }

    @Transactional
    @Override
    public <S extends T> S save(S entity) {
        Assert.notNull(entity, "Entity must not be null");

        if (entityInformation.isNew(entity)) {
            entityManager.persist(entity);
            return entity;
        } else {
            return entityManager.merge(entity);
        }
    }
    ...
}

 

SimpleJpaRepository는 엔티티 매니저를 받아 사용하고 있고, save 메서드 선언부에 Transactional 애너테이션이 붙어있는 것을 알 수 있습니다.

 

save 메서드가 실행되면 요청 엔티티가 새로운 엔티티인지 기존 엔티티인지를 판단하여 persist() 또는 merge()의 행위가 실행되게 됩니다.

다시 말해, 준영속 엔티티의 변경을 엔티티 매니저로 병합(merge) 하지 않더라도 JpaRepository.save로 인해 문제가 발생할 수 있음을 뜻합니다.

 

 

준영속 엔티티의 변경을 JpaRepository.save로 실행하면 어떤 결과를 얻게 될까요.

@Service
@RequiredArgsConstructor
public class ArticleService {

    private final ArticleJpaRepository articleJpaRepository;

    @Transactional
    public void updateV4(final Long articleId, String title) {
        Article detach = new Article(articleId, title); // 준영속 상태
        articleJpaRepository.save(detach);
        detach.changeTitle(title);
    }
}
@Test
void updateV4Test() {
    // given
    final Long id = article.getId();
    final String given = "행복한 JPA";
    System.out.println("before = " + article);

    // when
    articleService.updateV4(id, given);

    // then
    Article find = articleRepository.getArticleById(id);
    System.out.println("after  = " + find);
    assertThat(given).isEqualTo(find.getTitle());
}

 

테스트는 통과했지만 엔티티 매니저로 병합(merge)을 시도한 것과 동일한 상태값을 가지는 걸 알 수 있습니다.

 

 

결론

  • 준영속 엔티티를 다룰 때에는 조심하자.
  • 데이터의 변경은 가능하면 변경 감지를 이용하자.
  • 하나의 메서드가 2개의 트랜잭션을 사용하게 된다면 위와 같은 상황들을 유의하자.