0+ 스프링/0 + 스프링 ORM(JPA)

[JPA] JPA가 Entity를 판별하는 방법과 save()의 비밀(entityInformation.isNew(entity))

힘들면힘을내는쿼카 2023. 2. 22. 22:29
728x90
반응형

[JPA] JPA가 Entity를 판별하는 방법과 save()의 비밀(entityInformation.isNew(entity))

 

JPA를 사용하여 엔티티를 매핑하면 저도 모르게
@Id @GeneratedValue을 사용하는 모습을 봤습니다.
그런데 @GeneratedValue을 사용하지 않으면 어떻게 될까요? 🤔

이점이 궁금하게 되어 해당 결과를 공유하고자 포스팅 합니다.^^

 

 

SimpleJpaRepository.save()

Spring data jpa에서 제공하는 CrudRepositroy인터페이스의 구현체인 SimpleJpaRepository save() 메소드는 다음과 같이 구현되어 있습니다.

@Repository
@Transactional(readOnly = true)
public class SimpleJpaRepository<T, ID> implements JpaRepositoryImplementation<T, ID> {
    @Transactional
    @Override
    public <S extends T> S save(S entity) {

        Assert.notNull(entity, "Entity must not be null.");

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

여기서 주목해야 할것은 entityInformation.isNew(entity) 입니다.
isNew()의 반환 타입에 따라 persist()를 실행할지 merge()를 실행할지 결정합니다.

persist()merge()의 기능을 까먹으신 분들을 위해 설명하면 다음과 같습니다. 🤗

  • persist()비영속 상태의 객체를 영속 상태로 만듭니다.
  • merge()준영속 상태의 객체를 영속 상태로 만듭니다.

 

isNew()

isNew() 메소드는 새로운 Entity를 판단하기 위해 Id값을 확인 합니다.
아래와 같은 경우에 새로운 Entity라고 판단하고 true를 반환 합니다.
(그외는 flase를 반환 합니다.)

  • Id의 타입이 Object 타입이고 null
  • Id의 타입이 Primitive 타입이고 0
  • Persistable 인터페이스를 구현한 경우

 

Persistable 인터페이스

public interface Persistable<ID> {

    /**
     * Returns the id of the entity.
     *
     * @return the id. Can be {@literal null}.
     */
    @Nullable
    ID getId();

    /**
     * Returns if the {@code Persistable} is new or was persisted already.
     *
     * @return if {@literal true} the object is new.
     */
    boolean isNew();
}

잠깐 여기에 isNew()가 보이나요? 👀
이 메소드가 바로 save()에서 사용된 메소드 입니다.

@GeneratedValue를 사용하면 생성 전략에 따라서 자동으로 Id를 생성합니다.

  • IDENTITY
    • PK 생성 전략을 데이터 베이스에 위임(MySQL)
    • 쓰기 지연 SQL 저장소 사용 불가
  • SEQUENCE
    • 데이터베이스 시퀀스 오브젝트 사용(ORACLE)
    • @SequenceGenerator 필요
  • TABLE
    • 키 생성용 테이블 사용(모든 데이터베이스 사용 가능)
    • @TableGenerator 필요
  • AUTO
    • 방언에 따라 자동 지정(기본값)

결국 save()를 호출하는 시점에 isNew()를 통해
해당 EntityId의 생성 전략을 판단한 뒤
persist()를 수행하거나
merge()를 수행하게 됩니다.

이말이 사실인지 확인해 볼까요?

@GeneratedValue 사용 🙆‍♂️

먼저 @GeneratedValue를 포함한 Entity를 작성합니다.

@Entity
@Setter @Getter
public class TestEntityGeneratedValue {
    @Id
    @GeneratedValue
    private Long id;
    private String name;
}

 

Repository

public interface TestEntityGeneratedValueRepository 
extends JpaRepository<TestEntityGeneratedValue, Long> {
}

 

테스트 코드

@SpringBootTest
public class SaveTestGeneratedValue {
    @Autowired
    TestEntityGeneratedValueRepository repository;
    @Autowired
    EntityManager em;

    @Test
    @Transactional
    @Rollback(value = false) // sql을 확인하기 위함
    void saveTest() {
        TestEntityGeneratedValue entity = new TestEntityGeneratedValue();
        entity.setName("안녕!");

        repository.save(entity);
        System.out.println("id="+entity.getId());
        System.out.println("name="+entity.getName());

        entity.setName("변경감지!");
        System.out.println("id="+entity.getId());
        System.out.println("name="+entity.getName());
    }
}

saveTest()하나의 트랜잭션에서 실행되기 때문에 entity가 영속성 상태를 유지하게 되어 변경감지가 일어나게 됩니다.

 

결과

id=1
name=안녕!
id=1
name=변경감지!
Hibernate: 
    insert 
    into
        test_entity_generated_value
        (name, id) 
    values
        (?, ?)
binding parameter [1] as [VARCHAR] - [안녕!]
binding parameter [2] as [BIGINT] - [1]
Hibernate: 
    update
        test_entity_generated_value 
    set
        name=? 
    where
        id=?
binding parameter [1] as [VARCHAR] - [변경감지!]
binding parameter [2] as [BIGINT] - [1]

트랜잭션이 커밋되기 전까지 쓰기 지연 SQL 저장소에 저장되었다가
커밋 시점에 SQL이 발생하는것을 확인할 수 있습니다. 👍

그렇다면 @GeneratedValue를 사용하지 않으면 어떻게 될까요? 🤔

@GeneratedValue 사용 🙅‍♀️

먼저 @GeneratedValue를 포함하지 않은 Entity를 작성합니다.

@Entity
@Setter @Getter
public class TestEntityOnlyId {
    @Id
    private String id;
    private String name;
}

 

Repository

public interface TestEntityOnlyIdRepository 
extends JpaRepository<TestEntityOnlyId, String> {
}

 

테스트 코드

@SpringBootTest
public class SaveTestOnlyId {
    @Autowired
    TestEntityOnlyIdRepository repository;

    @Test
    @Transactional
    @Rollback(value = false)
    void saveTest() {

        String id = UUID.randomUUID().toString();

        TestEntityOnlyId entity = new TestEntityOnlyId();
        entity.setId(id);
        entity.setName("반가워!");
        // merge 수행
        TestEntityOnlyId newEntity = repository.save(entity);
        System.out.println("id="+entity.getId());
        System.out.println("name="+entity.getName());

        // entity는 영속상태가 아니다.!
        entity.setName("entity는 영속상태 아니라서 변경감지가 안된다.!");
        System.out.println("id="+entity.getId());
        System.out.println("name="+entity.getName());

        // newEntity는 영속상태 입니다.!
        newEntity.setName("newEntity는 변경감지가 된다!");
        System.out.println("id="+newEntity.getId());
        System.out.println("name="+newEntity.getName());
    }
}

save()에서 persist()가 아니고merge()를 수행하는 이유는
앞에서 설명드린 isNew()에서 flase가 반환되기 때문입니다.

merge(entity)가 수행되고 반환되는 객체(newEntity)는 영속상태 입니다.
여기서 주의깊게 보셔야할 것이 entity는 영속상태가 아니라는 것 입니다.

새롭게 반환되는 newEntity가 영속상태 입니다.!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
따라서 entity는 비영속 상태 이기때문에 변경감지가 발생하지 않는 것을 확인할 수 있습니다.
그에 반해 newEntity는 영속 상태 이기 때문에 변경감지가 발생합니다.

 

결과

Hibernate: 
    select
        testentity0_.id as id1_6_0_,
        testentity0_.name as name2_6_0_ 
    from
        test_entity_only_id testentity0_ 
    where
        testentity0_.id=?
binding parameter [1] as [VARCHAR] - [a36419f4-b7ec-493d-afe8-c5cde8c87a5b]

id=1b94dec5-58f3-4881-9e86-ccf121c3f2ae
name=반가워!
id=1b94dec5-58f3-4881-9e86-ccf121c3f2ae
name=entity는 영속상태 아니라서 변경감지가 안된다.!
id=1b94dec5-58f3-4881-9e86-ccf121c3f2ae
name=newEntity는 변경감지가 된다!

Hibernate: 
    insert 
    into
        test_entity_only_id
        (name, id) 
    values
        (?, ?)
binding parameter [1] as [VARCHAR] - [반가워!]
binding parameter [2] as [VARCHAR] - [1b94dec5-58f3-4881-9e86-ccf121c3f2ae]

Hibernate: 
    update
        test_entity_only_id 
    set
        name=? 
    where
        id=?
binding parameter [1] as [VARCHAR] - [newEntity는 변경감지가 된다!]
binding parameter [2] as [VARCHAR] - [1b94dec5-58f3-4881-9e86-ccf121c3f2ae]

@GeneratedValue가 없으면 JPA에서 PK를 관리하지 않고,
Id의 값이 null이 아니라isNew()에서 false를 반환했고,
그 결과 merge() 호출했습니다.
(merge()의 기본 동작은 select -> insert(또는 update) 입니다.)

여기에서는 merge()로 인하여 select -> insert가 발생했음을 확인할 수 있습니다.
추가로 entity는 변경감지가 안되고, newEntity는 변경감지가 되는 것을 확인할 수 있습니다.^^

merge() 주의 사항

merge() 뭐지 뭐지? ㅎㅎㅎ
merge()를 사용할 때 주의사항에 대해서 말씀 드리겠습니다. 🤣

merge()의 기본 동작은 select -> insert(또는 update) 입니다.
여기에서 update는 우리가 알고있는 변경감지 기능이 아닙니다.
변경감지는 변경된 필드만 update가 되는데,
merge()로 발생하는 update SQL은 전체 필드를 갱신합니다…..!

예제를 보여드리겠습니다.

@SpringBootTest
public class MergeUpdateTest {

    @Autowired
    TestEntityOnlyIdRepository repository;

    @Test
    @Rollback(value = false)
    void testMergeUpdate() {
        String id = UUID.randomUUID().toString();

        TestEntityOnlyId testEntity = new TestEntityOnlyId();
        testEntity.setId(id);
        testEntity.setName("테스트");
        testEntity.setAge(10);
        // 트랜잭션 시작
        repository.save(testEntity);
        // 트랜잭션 종료
        System.out.println("testEntity = " + testEntity);

        TestEntityOnlyId testEntity2 = new TestEntityOnlyId();
        testEntity2.setId(id);
        testEntity2.setName("테스트2");
        // 트랜잭션 시작
        repository.save(testEntity2);
        // 트랜잭션 종료
        System.out.println("testEntity2 = " + testEntity2);
    }
}

첫 번째 save()에서 merge()가 실행되고, select -> insert를 수행합니다.
두 번째 save()에서 merge()가 실행되고, select -> update를 수행합니다.
(save() 구현부를 보면 @Trasactional이 있습니다.)

 

Hibernate: 
    select
        testentity0_.id as id1_6_0_,
        testentity0_.age as age2_6_0_,
        testentity0_.name as name3_6_0_ 
    from
        test_entity_only_id testentity0_ 
    where
        testentity0_.id=?
binding parameter [1] as [VARCHAR] - [21999815-5ce5-4937-b25e-cadc52826b99]

Hibernate: 
    insert 
    into
        test_entity_only_id
        (age, name, id) 
    values
        (?, ?, ?)
binding parameter [1] as [INTEGER] - [10]
binding parameter [2] as [VARCHAR] - [테스트]
binding parameter [3] as [VARCHAR] - [21999815-5ce5-4937-b25e-cadc52826b99]
testEntity = TestEntityOnlyId(id=21999815-5ce5-4937-b25e-cadc52826b99, name=테스트, age=10)

Hibernate: 
    select
        testentity0_.id as id1_6_0_,
        testentity0_.age as age2_6_0_,
        testentity0_.name as name3_6_0_ 
    from
        test_entity_only_id testentity0_ 
    where
        testentity0_.id=?
binding parameter [1] as [VARCHAR] - [21999815-5ce5-4937-b25e-cadc52826b99]
extracted value ([age2_6_0_] : [INTEGER]) - [10]
extracted value ([name3_6_0_] : [VARCHAR]) - [테스트]

Hibernate: 
    update
        test_entity_only_id 
    set
        age=?,
        name=? 
    where
        id=?
binding parameter [1] as [INTEGER] - [null]
binding parameter [2] as [VARCHAR] - [테스트2]
binding parameter [3] as [VARCHAR] - [21999815-5ce5-4937-b25e-cadc52826b99]
testEntity2 = TestEntityOnlyId(id=21999815-5ce5-4937-b25e-cadc52826b99, name=테스트2, age=null)

Hibernate: 
    select
        testentity0_.id as id1_6_0_,
        testentity0_.age as age2_6_0_,
        testentity0_.name as name3_6_0_ 
    from
        test_entity_only_id testentity0_ 
    where
        testentity0_.id=?
binding parameter [1] as [VARCHAR] - [834a3d64-7096-47b6-9652-fc86c8fdd8f5]
extracted value ([age2_6_0_] : [INTEGER]) - [null]
extracted value ([name3_6_0_] : [VARCHAR]) - [테스트2]
findEntity=TestEntityOnlyId(id=834a3d64-7096-47b6-9652-fc86c8fdd8f5, name=테스트2, age=null)

insert SQL에서 age = 10 이었습니다…
그런데 merge()를 통해서 생성된 update SQL을 확인하면 age = null인것을 확인할 수 있습니다.
또한 조회한 결과 age = null 인것을 확인할 수 있습니다.
merge()로 발생하는 update SQL은 전체 필드를 갱신하는 것을 확인할 수 있습니다.!

이렇게 의도치 않게 필드 전체를 갱신하면 데이터 정합성에 문제가 생길수 있습니다.

따라서 저장은 persist()를 호출하도록 하고,
갱신은 변경 감지를 사용할 수 있도록 코드를 작성해야 합니다.

우리 회사는 @GeneratedValue를 사용 못해요….

ㅎㅎ고객님 난감하셨죠?🤗
이럴때는 기존 엔티티가 Persistable 인터페이스를 상속받아 isNew()를 직접 구현하여 사용하면 됩니다. 🧑‍💻

@Entity
@Setter @Getter
@ToString
@EntityListeners(AuditingEntityListener.class)
public class TestEntityOnlyId implements Persistable<String> {

    @Id
    private String id;
    private String name;
    private Integer age;
    @CreatedDate
    private LocalDateTime createDate;

    //id가 null이 아니고, createDate가 null이면 true
    @Override
    public boolean isNew() {
        return id != null && createDate == null;
    }
}

Id필드만으로는 isNew()를 올바르게 구현하는데 판단하기 어려울것 같아
Auditing 기술을 사용하여 구현했습니다.
(@EntityListeners(AuditingEntityListener.class), @CreatedDate를 추가했습니다.)

 

@SpringBootTest
@EnableJpaAuditing
public class SaveTestOnlyId {
    @Autowired
    TestEntityOnlyIdRepository repository;

    @Test
    @Rollback(value = false)
    void saveTestAddPersistable() {
        String id = UUID.randomUUID().toString();

        TestEntityOnlyId entity = new TestEntityOnlyId();
        entity.setId(id);
        entity.setName("반가워!");
        entity.setAge(10);
        repository.save(entity);
    }
}
Hibernate: 
    insert 
    into
        test_entity_only_id
        (age, create_date, name, id) 
    values
        (?, ?, ?, ?)
binding parameter [1] as [INTEGER] - [10]
binding parameter [2] as [TIMESTAMP] - [2023-02-22T23:22:43.752286]
binding parameter [3] as [VARCHAR] - [반가워!]
binding parameter [4] as [VARCHAR] - [abaaf71f-0552-49ac-a070-2f7d0033d09c]

persist()가 실행되어 insert SQL만 생성된 것을 확인할수 있습니다. 👍

정리

  • 동일한 트랜잭션 안에서만 EntityManager가 유효합니다.
    • 동일한 트랜잭션 안에서 영속성이 보장됩니다.
  • save()isNew()의 반환값에 따라서 persist() 또는 merge()를 호출합니다.
    • Id의 타입이 Object 타입이고 null -> true
    • Id의 타입이 Primitive 타입이고 0 -> true
    • Persistable 인터페이스를 구현한 경우 -> true
  • merge(entity)새로운 객체(newEntity)를 반환하고 새로운 객체(newEntity)가 영속상태입니다.
  • merge()는 변경 감지와 다르게 전체 필드를 갱신하는 update SQL이 발생합니다.
  • 저장은 persist(), 갱신은 변경 감지를 사용합시다.
  • @GeneratedValue를 사용할 수 없을때는 Persistable을 상속받아 isNew()를 직접 구현합니다.

 

 

참고 https://jaime-note.tistory.com/65

 

스프링 데이터 JPA - 새로운 Entity 판별

) 모든 소스 코드는 여기에서 확인할 수 있습니다. 바로 이전 포스팅에서 JPA가 save()를 호출할 때 새로운 Entity인지 기존 Entity인지 판단해 persist() 또는 merge()를 호출한다는 내용을 다룬 바 있습니

jaime-note.tistory.com

 

 

728x90
반응형