Spring Data JPA isNew 메서드

8 minute read

김영한 : 실전! 스프링 데이터 JPA 강의를 공부한 내용입니다.


Spring Data JPA save()

JPA를 처음 접한건 회사에서 간단한 프로젝트를 맡게 되었을 때 였다.
트래픽이 많지 않은 서비스의 실시간 백업 테이블 배치 프로젝트였다.
큰 부담이 없었기 때문에 JPA 공부를 해보고자 Spring Data JPA를 사용했다.

처음 사용해보고 너무 좋다고 느꼈는데,
얼마 가지 못하고 엄청난 성능 문제를 맛봤다.

결국은 JPA의 대한 지식이 모자라
멘붕을 느끼고 JDBC Template로 프로젝트를 마무리 했다.

멘붕의 원인은 바로 save 메서드였다.
save 메서드의 이름만 보고 단순히 저장을 위한 메서드라고 생각했다.

코드를 먼저 확인해보자.

@Data
@Entity
public class Users {

    @Id
    private String id;

    private String name;

}
public interface UserRepository extends JpaRepository<Users, String> {
}

간단한 Users 엔티티와 Repository이다.
대신 ID 값을 UUID를 넣어야했기 때문에 @GeneratedValue를 사용하지 않았다.

테스트 코드를 작성해보겠다.


@SpringBootTest
class UserRepositoryTest {

    @Autowired
    UserRepository userRepository;

    @Test
    void saveTest() {
        Users user = new Users();
        user.setId(UUID.randomUUID().toString());
        user.setName("LEE");

        Users saveUser = userRepository.save(user);

        assertThat(saveUser.getName()).isEqualTo(user.getName());
    }

}

테스트는 싱겁게 성공해버린다.

근데 문제는 테스트가 아니었다.

2021-07-28 20:34:06.839 DEBUG 1500 --- [           main] org.hibernate.SQL                        : 
    select
        users0_.id as id1_0_0_,
        users0_.name as name2_0_0_ 
    from
        users users0_ 
    where
        users0_.id=?
2021-07-28 20:34:06.845 TRACE 1500 --- [           main] o.h.type.descriptor.sql.BasicBinder      : binding parameter [1] as [VARCHAR] - [00d0d1b7-d780-403c-a4d3-ef7a1bc9ed87]
2021-07-28 20:34:06.872 DEBUG 1500 --- [           main] org.hibernate.SQL                        : 
    insert 
    into
        users
        (name, id) 
    values
        (?, ?)
2021-07-28 20:34:06.874 TRACE 1500 --- [           main] o.h.type.descriptor.sql.BasicBinder      : binding parameter [1] as [VARCHAR] - [LEE]
2021-07-28 20:34:06.875 TRACE 1500 --- [           main] o.h.type.descriptor.sql.BasicBinder      : binding parameter [2] as [VARCHAR] - [00d0d1b7-d780-403c-a4d3-ef7a1bc9ed87]

분명히 save 메서드를 사용했는데 insert 쿼리 전에 select 쿼리가 발생했다.


save() 로직

Spring Data JPA는 JPA를 감싸서
더 편하게 사용할 수 있도록 만든 것이지 JPA와 별개가 아니다.

즉 내부적으론 JPA를 사용한다는 것이다.

save 메서드는 JpaRepository의 구현체인 SimpleJpaRepository에 구현되어있다.

@Transactional
public <S extends T> S save(S entity) {
    Assert.notNull(entity, "Entity must not be null.");
    if (this.entityInformation.isNew(entity)) {
        this.em.persist(entity);
        return entity;
    } else {
        return this.em.merge(entity);
    }
}

save 메서드는 isNew 메서드를 사용한다.
먼저 엔티티의 ID의 유무를 통해
이 엔티티가 이미 DB에 들어간 값인지 아닌지를 판단한다.

만약 ID가 Reference Type라면 null,
Primitive Type이라면 0
이때는 persist를 하고, 반대의 경우라면 merge를 한다.

위 테스트 코드의 경우 엔티티의 ID를 UUID로 직접 지정해준다.

Spring Data JPA는 ID가 null이 아니니 merge의 대상이라고 판단하고,
select 쿼리를 실행한다.

이때 DB에서 조회해온 값을 덮어씌우는게 merge인데,
만약에 DB에서 해당 ID로 조회가 안된다면 다시 persist를 하게된다.


테스트
@SpringBootTest
class UserRepositoryTest {

    @Autowired
    UserRepository userRepository;

    @Test
    void saveTest() {
        String id = UUID.randomUUID().toString();

        Users user = new Users();
        user.setId(id);
        user.setName("LEE");

        Users saveUser = userRepository.save(user);   // insert

        Users nextUser = new Users();
        nextUser.setId(id);
        nextUser.setName("GICHEOL");
        Users saveNextUser = userRepository.save(nextUser); // update

    }

}
2021-07-28 20:50:47.067 DEBUG 1541 --- [           main] org.hibernate.SQL                        : 
    select
        users0_.id as id1_0_0_,
        users0_.name as name2_0_0_ 
    from
        users users0_ 
    where
        users0_.id=?
2021-07-28 20:50:47.073 TRACE 1541 --- [           main] o.h.type.descriptor.sql.BasicBinder      : binding parameter [1] as [VARCHAR] - [f80373e6-9361-4044-97f4-a93a512f1c0f]
2021-07-28 20:50:47.103 DEBUG 1541 --- [           main] org.hibernate.SQL                        : 
    insert 
    into
        users
        (name, id) 
    values
        (?, ?)
2021-07-28 20:50:47.104 TRACE 1541 --- [           main] o.h.type.descriptor.sql.BasicBinder      : binding parameter [1] as [VARCHAR] - [LEE]
2021-07-28 20:50:47.104 TRACE 1541 --- [           main] o.h.type.descriptor.sql.BasicBinder      : binding parameter [2] as [VARCHAR] - [f80373e6-9361-4044-97f4-a93a512f1c0f]
2021-07-28 20:50:47.110 DEBUG 1541 --- [           main] org.hibernate.SQL                        : 
    select
        users0_.id as id1_0_0_,
        users0_.name as name2_0_0_ 
    from
        users users0_ 
    where
        users0_.id=?
2021-07-28 20:50:47.110 TRACE 1541 --- [           main] o.h.type.descriptor.sql.BasicBinder      : binding parameter [1] as [VARCHAR] - [f80373e6-9361-4044-97f4-a93a512f1c0f]
2021-07-28 20:50:47.114 TRACE 1541 --- [           main] o.h.type.descriptor.sql.BasicExtractor   : extracted value ([name2_0_0_] : [VARCHAR]) - [LEE]
2021-07-28 20:50:47.118 DEBUG 1541 --- [           main] org.hibernate.SQL                        : 
    update
        users 
    set
        name=? 
    where
        id=?
2021-07-28 20:50:47.119 TRACE 1541 --- [           main] o.h.type.descriptor.sql.BasicBinder      : binding parameter [1] as [VARCHAR] - [GICHEOL]
2021-07-28 20:50:47.119 TRACE 1541 --- [           main] o.h.type.descriptor.sql.BasicBinder      : binding parameter [2] as [VARCHAR] - [f80373e6-9361-4044-97f4-a93a512f1c0f]

첫번째 save는 엔티티의 ID가 설정되어 있으니
먼저 select 후 DB 조회가 안되는 것을 확인 후 insert를 했고,
두번째 save는 마찬가지로 select를 했는데,
DB에서 조회되어 update 쿼리가 발생한 것이다.

@SpringBootTest
class UserRepositoryTest {

    @Autowired
    UserRepository userRepository;

    @Test
    void saveTest() {
        String id = UUID.randomUUID().toString();
        
        Users user = new Users();
        user.setId(id);
        user.setName("LEE");
        user.setAge(100);

        Users saveUser = userRepository.save(user);

        Users nextUser = new Users();
        nextUser.setId(id);
        nextUser.setName("LEE");
        Users saveNextUser = userRepository.save(nextUser);
    }

}
2021-07-28 20:54:39.008 DEBUG 1554 --- [           main] org.hibernate.SQL                        : 
    select
        users0_.id as id1_0_0_,
        users0_.name as name2_0_0_ 
    from
        users users0_ 
    where
        users0_.id=?
2021-07-28 20:54:39.018 TRACE 1554 --- [           main] o.h.type.descriptor.sql.BasicBinder      : binding parameter [1] as [VARCHAR] - [ee2258d8-d1e7-4b36-bfae-827fa4c099cd]
2021-07-28 20:54:39.058 DEBUG 1554 --- [           main] org.hibernate.SQL                        : 
    insert 
    into
        users
        (name, id) 
    values
        (?, ?)
2021-07-28 20:54:39.059 TRACE 1554 --- [           main] o.h.type.descriptor.sql.BasicBinder      : binding parameter [1] as [VARCHAR] - [LEE]
2021-07-28 20:54:39.059 TRACE 1554 --- [           main] o.h.type.descriptor.sql.BasicBinder      : binding parameter [2] as [VARCHAR] - [ee2258d8-d1e7-4b36-bfae-827fa4c099cd]
2021-07-28 20:54:39.068 DEBUG 1554 --- [           main] org.hibernate.SQL                        : 
    select
        users0_.id as id1_0_0_,
        users0_.name as name2_0_0_ 
    from
        users users0_ 
    where
        users0_.id=?
2021-07-28 20:54:39.069 TRACE 1554 --- [           main] o.h.type.descriptor.sql.BasicBinder      : binding parameter [1] as [VARCHAR] - [ee2258d8-d1e7-4b36-bfae-827fa4c099cd]
2021-07-28 20:54:39.073 TRACE 1554 --- [           main] o.h.type.descriptor.sql.BasicExtractor   : extracted value ([name2_0_0_] : [VARCHAR]) - [LEE]

이번엔 아예 동일한 객체를 save 해보았다.
마지막에 select 쿼리를 날리고 insert, update를 하지 않는다.

대충 썼다가 큰 문제를 접하기 딱 좋은 로직이다..
꼭 JPA에 대해 공부하고 사용하도록 하자.


merge의 문제점
@Data
@Entity
public class Users {

    @Id
    private String id;

    private String name;

    private int age;

}
@SpringBootTest
class UserRepositoryTest {

    @Autowired
    UserRepository userRepository;

    @Test
    void saveTest() {
        String id = UUID.randomUUID().toString();

        Users user = new Users();
        user.setId(id);
        user.setName("LEE");
        user.setAge(26);

        Users saveUser = userRepository.save(user);

        Users nextUser = new Users();
        nextUser.setId(id);
        nextUser.setName("GICHEOL");
        Users saveNextUser = userRepository.save(nextUser);
    }

}

위에서 말했지만 merge는 일반적인 update가 아닌,
데이터 자체를 덮어씌우는 update를 수행한다.

user는 id, name, age를 셋팅해줬지만
nextUser는 age를 세팅하지 않았다.

결과를 확인해보자.

2021-07-28 21:07:04.486 DEBUG 1603 --- [           main] org.hibernate.SQL                        : 
    select
        users0_.id as id1_0_0_,
        users0_.age as age2_0_0_,
        users0_.name as name3_0_0_ 
    from
        users users0_ 
    where
        users0_.id=?
2021-07-28 21:07:04.493 TRACE 1603 --- [           main] o.h.type.descriptor.sql.BasicBinder      : binding parameter [1] as [VARCHAR] - [4e0e9071-aa28-4484-9523-5066d81e0efc]
2021-07-28 21:07:04.522 DEBUG 1603 --- [           main] org.hibernate.SQL                        : 
    insert 
    into
        users
        (age, name, id) 
    values
        (?, ?, ?)
2021-07-28 21:07:04.523 TRACE 1603 --- [           main] o.h.type.descriptor.sql.BasicBinder      : binding parameter [1] as [INTEGER] - [26]
2021-07-28 21:07:04.523 TRACE 1603 --- [           main] o.h.type.descriptor.sql.BasicBinder      : binding parameter [2] as [VARCHAR] - [LEE]
2021-07-28 21:07:04.523 TRACE 1603 --- [           main] o.h.type.descriptor.sql.BasicBinder      : binding parameter [3] as [VARCHAR] - [4e0e9071-aa28-4484-9523-5066d81e0efc]
2021-07-28 21:07:04.530 DEBUG 1603 --- [           main] org.hibernate.SQL                        : 
    select
        users0_.id as id1_0_0_,
        users0_.age as age2_0_0_,
        users0_.name as name3_0_0_ 
    from
        users users0_ 
    where
        users0_.id=?
2021-07-28 21:07:04.531 TRACE 1603 --- [           main] o.h.type.descriptor.sql.BasicBinder      : binding parameter [1] as [VARCHAR] - [4e0e9071-aa28-4484-9523-5066d81e0efc]
2021-07-28 21:07:04.534 TRACE 1603 --- [           main] o.h.type.descriptor.sql.BasicExtractor   : extracted value ([age2_0_0_] : [INTEGER]) - [26]
2021-07-28 21:07:04.535 TRACE 1603 --- [           main] o.h.type.descriptor.sql.BasicExtractor   : extracted value ([name3_0_0_] : [VARCHAR]) - [LEE]
2021-07-28 21:07:04.538 DEBUG 1603 --- [           main] org.hibernate.SQL                        : 
    update
        users 
    set
        age=?,
        name=? 
    where
        id=?
2021-07-28 21:07:04.539 TRACE 1603 --- [           main] o.h.type.descriptor.sql.BasicBinder      : binding parameter [1] as [INTEGER] - [0]
2021-07-28 21:07:04.539 TRACE 1603 --- [           main] o.h.type.descriptor.sql.BasicBinder      : binding parameter [2] as [VARCHAR] - [GICHEOL]
2021-07-28 21:07:04.539 TRACE 1603 --- [           main] o.h.type.descriptor.sql.BasicBinder      : binding parameter [3] as [VARCHAR] - [4e0e9071-aa28-4484-9523-5066d81e0efc]

이제 select, insert, select, update가 나가는 건 이해했다.
근데 마지막 update문의 파라미터를 보면 정말 어이가 없다.

update를 기대했는데 세팅을 하지않은 age 까지 0으로 변경되었다.

한 가지 문제가 더 있다.
다시 한번 SimpleJpaRepository를 보자

@Transactional
public <S extends T> S save(S entity) {
    Assert.notNull(entity, "Entity must not be null.");
    if (this.entityInformation.isNew(entity)) {
        this.em.persist(entity);
        return entity;
    } else {
        return this.em.merge(entity);
    }
}

persist는 엔티티를 리턴하지만, merge는 객체를 새로 만들어서 준다.
즉 persist 이후 엔티티는 영속상태이지만
merge 이후 엔티티는 준영속상태이다.

이런 이유 때문에 실수를 줄이기 위해
save 호출 후 엔티티를 사용할 일이 있다면
persist던 merge던 영속상태의 엔티티를 반환받아서
사용하는 것을 습관화하도록 하자.

@Data
@Entity
public class Users {

    @Id
    private String id;

    private String name;

    private int age;

}

@Data
@Entity
public class Team {

    @Id @GeneratedValue
    private Long id;

    private String name;

}
@Transactional
@Test
void saveTest() {
    Users user = new Users();
    user.setId(UUID.randomUUID().toString());
    user.setName("LEE");
    user.setAge(26);

    Users saveUser = userRepository.save(user);

    assertThat(em.contains(user)).isFalse();
    assertThat(em.contains(saveUser)).isTrue();


    Team team = new Team();
    team.setName("GICHEOL");
    Team saveTeam = teamRepository.save(team);

    assertThat(em.contains(team)).isTrue();
    assertThat(em.contains(saveTeam)).isTrue();

}


이처럼 merge의 문제는 이만저만이 아니다.

그런데 사실 merge의 원래 용도는 update가 아니다.
준영속상태(detached)의 엔티티를 영속상태(persist)로 변경해주는 용도이다.

실제로 update를 해야된다면, Dirty Checking을 사용하도록 하자.


해결방법

다시 돌아가서 엔티티의 ID를 UUID와 같은 값으로 미리 세팅하고
DB에 insert 하는 일은 꽤나 흔히 있는 일이다.

ID가 자동 생성값이 아니기 때문에 JPA는 새로운 엔티티인지 이미 존재하는 엔티티인지 알 수가 없다.
그렇기 때문에 insert 전에 select를 하게 된다.

회원가입을 기능을 구현했는데 검증의 의도가 아니라면
굳이 insert 전에 select를 할 필요가 있을까?

이런 경우 엔티티 클래스에 Persistable를 구현하면된다.
구현해야되는 메서드는 바로 isNew와 getId이다.

@Data
@Entity
@EntityListeners(AuditingEntityListener.class)
public class Users implements Persistable<String> {

    @Id
    private String id;

    private String name;

    private int age;

    @CreatedDate
    private LocalDateTime createDate;


    @Override
    public String getId() {
        return id;
    }

    @Override
    public boolean isNew() {
        return createDate == null;
    }

}

JpaRepository의 구현체인 SimpleJpaRepository는 save 메서드를 구현할 때 isNew 메서드를 통해
해당 엔티티의 ID가 자동 생성 값인지 등을 확인 후
새로운 엔티티인지 이미 존재하는 엔티티인지 검증한다.

이 과정을 직접 구현하는 것이다.

이때 JPA의 Auditing 기능을 사용하면 isNew 메서드를 쉽게 구현할 수 있다.

Auditing은 @CreatedDate, @CreatedBy, @LastModifiedBy, @LastModifiedDate
네 개의 Annotation을 지원한다.

생성자, 생성일, 수정자, 수정일을 persist 시점에 JPA가 직접 넣어주는건데,
그렇기 때문에 생성일, 수정일은 null이 될 수 없다.

isNew 메서드 호출 시점에 @CreatedDate가 아직 null이라면
DB에 insert가 되지 않은 시점이라고 판단하는 것이다.

@EnableJpaAuditing  // 필수
@SpringBootApplication
public class JpaProblemApplication {

    public static void main(String[] args) {
        SpringApplication.run(JpaProblemApplication.class, args);
    }

}

다시 테스트를 돌려보면 insert 쿼리 이전에 select 쿼리가 발생하지 않는다.

@SpringBootTest
class UserRepositoryTest {

    @Autowired
    UserRepository userRepository;

    @Test
    void saveTest() {
        String id = UUID.randomUUID().toString();

        Users user = new Users();
        user.setId(id);
        user.setName("LEE");
        user.setAge(26);

        Users saveUser = userRepository.save(user);
    }

}
2021-07-28 21:46:18.004 DEBUG 1731 --- [           main] org.hibernate.SQL                        : 
    insert 
    into
        users
        (age, create_date, name, id) 
    values
        (?, ?, ?, ?)
2021-07-28 21:46:18.012 TRACE 1731 --- [           main] o.h.type.descriptor.sql.BasicBinder      : binding parameter [1] as [INTEGER] - [26]
2021-07-28 21:46:18.014 TRACE 1731 --- [           main] o.h.type.descriptor.sql.BasicBinder      : binding parameter [2] as [TIMESTAMP] - [2021-07-28T21:46:17.944028]
2021-07-28 21:46:18.022 TRACE 1731 --- [           main] o.h.type.descriptor.sql.BasicBinder      : binding parameter [3] as [VARCHAR] - [LEE]
2021-07-28 21:46:18.022 TRACE 1731 --- [           main] o.h.type.descriptor.sql.BasicBinder      : binding parameter [4] as [VARCHAR] - [068b329b-15b8-4965-9231-dc5f9a6a379b]

결론

얘기가 두서없이 길어졌다.

결국 save 메서드는 persist를 하던, merge를 하던
반환받은 값을 사용해야하고,

update 쿼리가 필요하다면 merge보단 Dirty Checking을,

UUID와 같은 값을 ID로 사용한다면 엔티티 클래스에 Persistable를 구현해야하며
isNew 메서드는 Auditing 기능을 활용하면 쉽게 구현할 수 있다.

Spring Data JPA는 JPA를 모르고 사용하기엔
인지할 수 없는 문제가 많이 발생할 수 있다.

JPA 공부를 열심히 하자.


Tags:

Categories:

Updated:

Leave a comment