JPA Update 과정과 Dirty Checking (feat. @Transaction)

yeahdy_:)·2022년 10월 11일

JPA

목록 보기
1/2

다른 분이 맡았던 프로젝트를 대신 백업하고 있을 때 JPA update 관련 이슈가 발생했다.

이슈를 해결하던 중 JPA의 update시 Dirty Cheking의 과정이 궁금해 졌고, 이슈의 원인이였던 setter를 통한 update와 repository.save()로 update를 했을 때의 차이를 알아보고자 한다.

이슈

  • 통계 데이터를 보여주는 화면에서 특정 데이터의 수치가 기간 조회를 클릭할 때 마다 계속해서 변경됨
  • 전날 데이터를 토대로 보여주는 것이기 때문에 변경이 불가능한 상황
  • 해당 수치를 DB에서 조회했을 때 변경된 데이터로 조회 되었음

원인

  • 컨트롤러에서 entity.set***()을 통해 데이터를 변경하는 로직 발견
  • 실행된 sql 문 로그를 확인 해 보니 UPDATE 쿼리문이 발생되어 JPA 내부에서 더티체킹이 발생

Dirty Checking 과정

  • 영속성 컨텍스트에서 관리하고 있는 객체의 변경이 감지된 경우 최초 저장된 엔티티 스냅샷과 엔티티 하나씩 비교한다.
  • 변경된 부분에 맞게 SQL을 생성한 후 쓰기 지연 SQL 저장소에 보관한다.
  • 이 후 트랜잭션이 커밋될 때 DB에 Flush 해 데이터를 변경한다.

update 시 setter와 save()의 차이

주문상세 엔티티(OrderDetailEntity)를 조회 해 name 필드의 값을 변경하는 테스트코드이다.

검증은 OrderDetailEntity를 모두 조회하여 변경이 이루어진 OrderDetailEntity객체를 가져온 후 변경한 이름과 같은지 확인한다.

결과적으로는 두 경우 모두 update 쿼리문을 통해 name 필드의 값을 변경한다.

1. setter 를 통한 update

@Test
@Transactional
void update_order_detail_test() {
	//given
    String name = "ellie3";
    //when
    System.out.println("##############findById##############");
    OrderDetailEntity orderDetailEntity = orderDetailRepository.findById(1L).orElse(null);
    System.out.println("##############update##############");
    orderDetailEntity.setName(name);
    //then
    OrderDetailEntity changedOrderDetailEntity = orderDetailRepository.findAll().get(0);
    assertThat(changedOrderDetailEntity.getName()).isEqualTo(name);
}

실행된 쿼리

##############findById##############
Hibernate: 
    select
        ode1_0.id,
        ...
    from
        order_detail ode1_0 
    where
        ode1_0.id=?
##############update##############
Hibernate: 
    update
        order_detail 
    set
        name=? 
    where
        id=?
...
  • OrderDetailEntity orderDetailEntity = orderDetailRepository.findById(1L).orElse(null) findById() 를 통해 엔티티는 영속화가 된 상태이며 JPA 내부에서 조회된 엔티티를 그대로 복사 해 스냅샷으로 보관한다.
  • orderDetailEntity.setName(name);
    영속화된 엔티티를 setter 할 경우 트랜잭션 commit 시점에 저장된 스냅샷과 비교 해 변경사항이 있다면 dirty checking이 이루어지고, update 쿼리문이 발생한다.

2. repository.save() 를 통한 update

@Test
void update_order_detail_test() {
	//given
    String name = "ellie";
    //when
    System.out.println("##############findById##############");
    OrderDetailEntity orderDetailEntity = orderDetailRepository.findById(1L).orElse(null);
    orderDetailEntity.setName(name);
    System.out.println("##############save##############");
    orderDetailRepository.save(orderDetailEntity);
    //then
    OrderDetailEntity changedOrderDetailEntity = orderDetailRepository.findAll().get(0);
    assertThat(changedOrderDetailEntity.getName()).isEqualTo(name);
}

실행된 쿼리

예상한 대로 update 쿼리문이 실행되고 name이 변경되어 테스트에 통과했다.

##############findById##############
Hibernate: 
    select
        ode1_0.id,
        ...
    from
        order_detail ode1_0 
    where
        ode1_0.id=?
##############save##############
Hibernate: 
    select
        ode1_0.id,
        ...
    from
        order_detail ode1_0 
    where
        ode1_0.id=?
Hibernate: 
    update
        order_detail 
    set
        name=? 
    where
        id=?
... 생략

그런데 실행된 쿼리문을 보면 UPDATE 쿼리문 전에 SELECT문이 실행되는 것을 알 수 있다.
디버깅을 해보니 orderDetailRepository.save(orderDetailEntity)했을 때 SELECT문과 UPDATE문이 동시에 실행되는데, 왜 SELECT문이 실행되는걸까?

Spring Data JPA의 save() 메소드는 내부적으로 EntityManager의 persist() 또는 merge() 메소드를 호출하는데 이 때, save() 메소드는 먼저 엔티티가 새로운 것인지 아닌지를 판단한다.

판단 기준은 엔티티의 식별자인 PK가 1차 캐시(또는 DB)에 존재하지 않는다면 새로운 엔티티를 생성한다.

  1. 새로운 엔티티라면 EntityManager의 persist() 메소드가 호출되어 새로운 엔티티를 DB에 저장한다.
  2. 새로운 엔티티가 아니라면 EntityManager의 merge() 메소드가 호출된다.
    이때 merge()메소드는 식별자를 가진 엔티티가 DB에 이미 존재하는지 확인하기 위해 SELECT 쿼리를 실행하게 된다. 만약 식별자를 가진 엔티티가 DB에 있다면merge() 메소드는 해당 엔티티를 가져와서 변경 사항을 반영하고, 그 결과를 DB에 UPDATE한다.

따라서, save() 메소드를 호출할 때 SELECT 쿼리가 발생하는 이유는, 이미 존재하는 엔티티의 변경 사항을 반영하기 위해 해당 엔티티를 먼저 데이터베이스에서 조회하기 때문이다.


여기서 의문점은 merge() 가 호출된다는 것은 준영속 상태의 엔티티를 영속화한다는 뜻인데,

그럼 findById로 엔티티를 조회했지만 트랜잭션 범위 내에 없기 때문에 OrderDetailEntity는 준영속 상태인 것이고, 그래서 merge() 를 호출했을 때 SELECT문이 실행되었다는 뜻 아닌가?

그렇다면 @Transaction을 붙이면 트랜잭션 범위 내에서 영속성 컨텍스트에 의해 관리되기 때문에 SELECT 쿼리문이 발생하지 않아야 한다.

2-2. @Transaction과 save() 를 통한 update

@Test
@Transactional
void update_order_detail_test() {
    String name = "ellie2";

    System.out.println("##############findById##############");
    OrderDetailEntity orderDetailEntity = orderDetailRepository.findById(1L).orElse(null);
    orderDetailEntity.setName(name);
    System.out.println("##############save##############");
    orderDetailRepository.save(orderDetailEntity);

    OrderDetailEntity changedOrderDetailEntity = orderDetailRepository.findAll().get(0);
    assertThat(changedOrderDetailEntity.getName()).isEqualTo(name);
}

실행된 쿼리

##############findById##############
Hibernate: 
    select
        ode1_0.id,
        ...
    from
        order_detail ode1_0 
    where
        ode1_0.id=?
##############save##############
Hibernate: 
    update
        order_detail 
    set
        name=? 
    where
        id=?
...

@Transaction 을 붙이니 예상한대로 UPDATE문 전에 SELECT문이 실행되지 않은 것을 알 수 있다.

즉, 트랜잭션 범위 내에서 findById를 통해 조회된 엔티티가 영속화 된 상태이기 때문에 1차캐시에 저장되고, 이후에 save() 메소드를 호출하더라도 1차캐시에서 가져오기 때문에 SELECT문이 실행되지 않는다.


그럼 entity의 setter 를 통해 UPDATE 할 경우 save() 호출은 불필요한 것일까?

findById()를 통해 이미 엔티티는 영속화된 상태이다. 여기서 save()를 하면 JPA는 내부적으로 엔티티 수정을 위해 merge()가 호출되고, 이미 영속된 상태의 엔티티를 또 영속화한다.
따라서 save()를 호출 해 한번 더 영속화할 필요가 없다.

또한 save()를 호출하면 트랜잭션이 끝날 때까지 데이터베이스와 연결되어 있어 불필요한 통신이 발생할 수도 있다.

엔티티의 setter를 통해 변경된 값을 직접 설정하면 JPA는 해당 변경 사항을 추적하고 트랜잭션이 커밋될 때 자동으로 DB에 반영하기 때문에 save()의 호출은 불필요하다.

stackoverflow 참고: transactional-spring-jpa-save-not-necessary


update 시 @Transaction 을 붙이지 않을 경우

엔티티의 setter로 update를 진행할 때 @Transaction을 붙여서 수정을 했는데, 만약 @Transaction을 붙이지 않을 경우 어떻게 될까?

3. @Transaction이 없고, setter를 통한 update

@Test
void update_order_detail_test() {
    String name = "ellie4";
    
    System.out.println("##############findById##############");
    OrderDetailEntity orderDetailEntity = orderDetailRepository.findById(1L).orElse(null);
    System.out.println("##############update##############");
    orderDetailEntity.setName(name);

    OrderDetailEntity changedOrderDetailEntity = orderDetailRepository.findAll().get(0);
    assertThat(changedOrderDetailEntity.getName()).isEqualTo(name);
}

실행된 쿼리

##############findById##############
Hibernate: 
    select
        ode1_0.id,
        ...
    from
        order_detail ode1_0 
    where
        ode1_0.id=?
##############update##############
Hibernate: 
    select
        ode1_0.id,
        ...
    from
        order_detail ode1_0

@Transaction이 없을 경우 UPDATE문이 실행 되지 않고, 다음의 findAll() 의 쿼리문이 실행되었다. 따라서 수정이 이루어 지지 않아 테스트가 실패하는 것을 알 수 있었다.

그렇다면 @Transaction은 어떤 역할을 하는 것일까?

Dirty Checking 시 트랜잭션의 역할

@Transactional을 메소드에 붙일 경우 메소드가 하나의 트랜잭션 내에서 실행된다는 뜻으로 메소드가 시작될 때 새로운 트랜잭션을 시작하고, 메소드가 종료될 때 트랜잭션을 커밋(또는 롤백)한다.

더티체킹은 트랜잭션 내에서 동작하며 작동하는 시점은 트랜잭션의 커밋 시점이다.

따라서 트랜잭션 범위 안에서 저장된 엔티티를 조회 했을 때 영속성 컨텍스트에서 관리되고, 데이터 값을 수정할 경우 JPA 내부에서 변경을 감지하고 update 쿼리문이 발생하는 것이다.

참고: @Transactional을 사용하는 이유에 대하여 (부제. JPA Dirty Checking)


결론

  • UPDATE 쿼리문이 실행함에 따라 Dirty Checking이 발생하고, Transaction과 JPA의 연관관계를 알 수 있었다.
  • 이슈 해결은 엔티티에서 더티체킹이 발생하지 않도록 엔티티가 아닌 DTO를 통해서 데이터를 변경하도록 수정했다. (컨트롤러에서 엔티티의 setter 변경 로직이 왜 있는지는 의문이다. 무분별한 엔티티 setter 사용에 대한 경각심을 얻게 되었다.)
profile
기억하기 위해 기록하고 있습니다. 포스트 중 잘못된 정보가 있다면 코멘트 남겨주세요🐰

0개의 댓글