Long id = 0L ?

코틀린을 공부하다 참고하려고 우테코 지원 플랫폼의 코드를 봤다. 그 중 흥미로운 점을 발견했는데, 엔티티를 생성할 때 id0L로 초기화 하는 것이다.

@Entity
class Member(
  ...
  id: Long = 0L
)...

이게 왜 흥미로웠냐면 상수 픽스쳐 사용 주의 포스팅에서 썼듯이 idnull이 아니면 merge()를 시행한다고 생각했기 때문이다. 굳이 0L로 초기화하면 select 비용만 추가로 들 것 같았다.

그래서 자바 스프링에서 직접 간단한 엔티티를 만들고, id를 0L로 지정해 저장해봤다.

@Entity
public class Member {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(length = 20, nullable = false)
    private String name;
    ...
}

@SpringBootTest
public class MemberJPATest {

    @Autowired
    private MemberRepository members;
    
    @DisplayName("Long id = 0L 이라면")
    @Test
    void test() {
        Member member = new Member(0L, "dog");
        members.save(member);
    }
}
Hibernate: 
    select
        member0_.id as id1_0_0_,
        member0_.name as name2_0_0_ 
    from
        member member0_ 
    where
        member0_.id=?
Hibernate: 
    insert 
    into
        member
        (id, name) 
    values
        (default, ?)

실행된 SQL문을 보면, 역시나 select문이 insert문에 선행되었다. persist()대신 merge()가 수행된 것이다.


JPA의 새로운 엔티티 식별법

JPARepository 상속 시 쓰게 되는 구현체인 SimpleRepositorysave를 다시 살펴보자.

@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);
    }
}

참 명료한 언어로 잘 짜여있다. if (entityInformation.isNew(entity))니까 엔티티가 새로운 엔티티라면 persist()를, 아니라면 merge()를 수행한다. 그렇다면 이 isNew()는 어디서 가져오는 걸까?

isNew()를 구현한 몇 개의 클래스에 디버깅을 걸며 찾아보니 AbstractEntityInformation라는 낯선 이름의 클래스에 걸렸다.

public abstract class AbstractEntityInformation<T, ID> implements EntityInformation<T, ID> {
    ...
    public boolean isNew(T entity) {
      
        ID id = getId(entity);
        Class<ID> idType = getIdType();
        
        if (!idType.isPrimitive()) {
            return id == null;
        }
        
        if (id instanceof Number) {
            return ((Number) id).longValue() == 0L;
        }
        
        throw new IllegalArgumentException(String.format("Unsupported primitive id type %s", idType));
    }
}

코드 상 isNew()를 판단하는 조건은 다음과 같다.

  • 엔티티의 id가 원시 타입이 아니라면
    • null일 때 새로운 엔티티다
    • 값이 있을 때 새로운 엔티티가 아니다
  • 엔티티의 id가 원시 타입이고, getIdid가 래퍼 클래스의 인스턴스라면
    • 값이 0일 때 새로운 엔티티다
    • 값이 0이 아니면 새로운 엔티티가 아니다

처음 읽었을 때 id instanceof Number라는 부분이 이상했다. 위에서 원시값이 아닌 경우를 한번 분기처리 했는데, 왜 다시 Number의 인스턴스임을 확인하는 걸까? 애초에 id가 원시값이면 Number의 인스턴스가 될 수 없지 않나? 코드를 읽다 혼란이 와서 Number에 대한 정보도 찾아보고 왔다.

이 의문의 답은 Id id = getId(entity)에 있었다. getId를 하면서 원시타입의 경우 래퍼 클래스로 바꿔준다.

public ID getId(T entity) {
    return (ID) persistentEntity.getIdentifierAccessor(entity).getIdentifier();
}

굳이 이렇게 번거롭게 처리한 이유는 찾아봤지만 사실 아직 잘 모르겠다… 어쨌든 여기의 ID형은 JPARepository를 상속하면서 지정한 형태다. 따라서 엔티티에서 long으로 정의된 id는, getID를 거쳐 자동으로 Long이 된다.

그럼 Long이 아닌 long id0L로 초기화하면 persist()가 호출될까?

@Entity
public class Member {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private long id;

    @Column(length = 20, nullable = false)
    private String name;
    ...
}
Hibernate: 
    insert 
    into
        member
        (id, name) 
    values
        (default, ?)

실행 결과 그런 것을 볼 수 있다.
코틀린 코드는 위 예제처럼 자바에서 id의 자료형을 long으로 하고, 0L로 초기화 한 것과 동일하다.

숫자가 아닌 값을 id로 쓰려면?

줍줍 서비스를 개발하다 슬랙은 식별값으로 문자열을 쓴다는 사실이 기억났다. (물론 내부적으로는 숫자 id를 쓸 수도 있겠으나…) 메시지 같은 경우는 수가 많아서 그런지 UUID를 사용한다. 해당 코드를 본다면 id로 UUID를 사용할 경우 persist가 호출되지 않을 것 같았다.

@Entity
public class Member {

    @Id
    @Column(columnDefinition = "BINARY(16)")
    private UUID id = UUID.randomUUID();

    @Column(length = 20, nullable = false)
    private String name;
    ...
}

@SpringBootTest
public class MemberJPATest {

    @Autowired
    private MemberRepository members;
    
    @DisplayName("UUID id = UUID 랜덤값 이라면")
    @Test
    void test() {
        Member member = new Member("dog");
        members.save(member);
    }
}
Hibernate: 
    select
        member0_.id as id1_0_0_,
        member0_.name as name2_0_0_ 
    from
        member member0_ 
    where
        member0_.id=?
Hibernate: 
    insert 
    into
        member
        (name, id) 
    values
        (?, ?)

역시나 merge가 호출되어 selectinsert가 나갔다. 그럼 숫자가 아닌 값을 id로 사용하려면 성능 저하를 필연적으로 안고 가야 하는 것일까?


Persistable 구현으로 내 맘대로 식별시키기

당연히 그럴 리는 없다. JPA는 엔티티의 isNew()를 개발자 입맛대로 바꿀 수 있도록 Persistable<ID> 인터페이스를 제공한다.

@Entity
public class Member implements Persistable<UUID> {

    @Override
    public boolean isNew() {
        return true;
    }
    ...
}

이제 모든 Member의 인스턴스는 save 될 때 마다 새 엔티티로 인식된다. 실제로 테스트 코드 내에서 save를 두 번 호출하면 PK 중복으로 에러가 난다.

@DisplayName("String id = UUID 랜덤값 이라면")
@Test
void test() {
    Member member = new Member("dog");
    members.save(member);
    members.save(member);
}

org.springframework.dao.DataIntegrityViolationException: could not execute statement; SQL [n/a]; constraint [“PUBLIC.PRIMARY_KEY_8 ON PUBLIC.MEMBER(ID) VALUES ( /* 1 */ CAST(X’c63865c5bb6e4f16a3d44aa75244dc1a’ AS BINARY(16)) )”; SQL statement:

insert into member (name, id) values (?, ?) [23505-214]]; nested exception is org.hibernate.exception.ConstraintViolationException: could not execute statement

다른 조건인 생성 일자isNew()를 판단하도록 수정하자.

@Entity
public class Member implements Persistable<UUID> {

    @CreationTimestamp
    private LocalDateTime createdAt;

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

이러면 엔티티 최초 생성 시 createdAt 값이 생길 것이고, 이 값의 여부로 isNew()를 판단한다. 해당 인터페이스를 구현하면 이제 AbstractEntityInformationisNew()를 완전히 우선해 대체함에 주의해야 한다.
idnull이면 어쩌고… 는 더이상 적용되지 않는다는 뜻이다.

또는, 조금 더 간단한 버전

isNew() 구현을 위해 얘기하지 않았지만, 사실 UUID도 자동 생성 처리가 가능하다.

@Entity
public class Member {

    @Id
    @GeneratedValue(generator = "uuid2")
    @GenericGenerator(name = "uuid2", strategy = "uuid2")
    @Column(columnDefinition = "BINARY(16)")
    private UUID id = UUID.randomUUID();
    ...
}

역시 있을 건 거의 이미 다 있다. 바퀴를 새로 발명하지 말자.


그 외에도 식별에 영향을 주는 방법

@Version

첫번쨰로 @Version이 있다. 이 어노테이션은 isNew()를 판별하는 용도로 붙이는 건 아니다. 여기서 다른 클래스가 또 끼어들게 된다. JpaMetamodelEntityInformation라는 클래스다.

public class JpaMetamodelEntityInformation<T, ID> extends JpaEntityInformationSupport<T, ID> {
    ...
    @Override
    public boolean isNew(T entity) {
      
        if (!versionAttribute.isPresent()
            || versionAttribute.map(Attribute::getJavaType).map(Class::isPrimitive).orElse(false)) {
            return super.isNew(entity);
        }

        BeanWrapper wrapper = new DirectFieldAccessFallbackBeanWrapper(entity);

        return versionAttribute.map(it -> wrapper.getPropertyValue(it.getName()) == null).orElse(true);
    }
}

이 클래스의 if문을 보자. @Version이 붙은 값이 존재하지 않거나, 이 값들이 원시 타입이라면 상위의 isNew()를 호출한다. 이 상위 클래스가 AbstractEntityInformation이다. 앞서 @Version이 없던 경우에도 사실 이 메서드를 한 번 타고 왔던 것이다.

@Version 필드가 있고 원시 타입이 아니라면, 이제 이 값으로 isNew()를 판단하게 된다. 이 때도 역시 idnull이면 어쩌고… 는 더이상 적용되지 않는다. @Version이 붙은 필드 값이 null인가의 여부로 판단한다.

@Entity
public class Member {
    
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Version
    private Integer version;
    ...
}

@SpringBootTest
public class MemberJPATest {
    
    @Autowired
    private MemberRepository members;

    @DisplayName("id 값이 있어도 @Version이 null 이라면")
    @Test
    void test() {
        Member member = new Member(1L, "dog");
        members.save(member);
    }
}
Hibernate: 
    insert 
    into
        member
        (id, name, version) 
    values
        (default, ?, ?)

@Version은 원래 LOCK을 위해 사용된다고 한다. JPA 잠금(Lock) 이해하기 라는 포스팅에서 상세한 설명을 볼 수 있다.

EntityInformation 구현

앞서 isNew()가 있었던 구현체들의 상위 인터페이스인 EntityInformation을 직접 구현해 SimpleJpaRepository에 주입하는 방법이다. 이 방식은 아직 이해를 제대로 못했다. SimpleJpaRepository의 생성자에 어떤 방식으로 주입되는지 제대로 파악하고 나서 더 공부해보려 한다.

public SimpleJpaRepository(JpaEntityInformation<T, ?> entityInformation, EntityManager entityManager) {
  
    Assert.notNull(entityInformation, "JpaEntityInformation must not be null!");
    Assert.notNull(entityManager, "EntityManager must not be null!");
    
    // 이 부분을 자신의 구현체로 대체하라는 의미일까? 어떻게...?  
    this.entityInformation = entityInformation;
    this.em = entityManager;
    this.provider = PersistenceProvider.fromEntityManager(entityManager);
}

참고한 다른 블로그 포스팅인 spring-data-jpa save 동작 원리에 나와있다.


정리

실제 코드를 탐색한 순서대로 포스팅을 했더니 글이 길어졌다. EntityInformation 구현을 제외하고, 알아낸 isNew()결정 흐름을 정리하자면 다음과 같다.

  • 엔티티 클래스에서 Persistable<ID> 를 구현했다면, 엔티티 클래스 내의 isNew() 로 판단
  • JpaMetamodelEntityInformationisNew() 가 호출
    • @Version필드가 있고, 해당 값이 원시 타입이 아니라면
      • 필드가 null이라면 true, 아니라면 false 반환
    • @Version필드가 없거나, 해당 값이 원시 타입이라면
      • AbstractEntityInformationisNew() 가 호출
        • id가 원시 타입이 아니라면
          • 값이 null이라면 true, 아니라면 false 반환
        • id가 숫자값이라면
          • 값이 0이라면 true, 아니라면 false 반환

참으로 복잡한 과정이 숨겨져 있었다.

그 외의 호기심?

코드를 열어보면서 IsNewStrategy라는 인터페이스와 구현체들을 발견했는데, 정작 어디 쓰이는지는 알아내지 못했다. 구현체에는 AbstractEntityInformation와 비슷한 로직이 들어있다.

public interface IsNewStrategy {
  
    /**
    * 새로운 엔티티인지 여부를 반환합니다. 다시 말해, 실제로 저장 됐었는지와는 별개입니다.
	  * @param entity must not be {@literal null}.
	  * @return
	  */

    boolean isNew(Object entity);
}

class PersistentEntityIsNewStrategy implements IsNewStrategy {

    @Override
    public boolean isNew(Object entity) {
      
        Object value = valueLookup.apply(entity);

        if (value == null) {
            return true;
        }

        if (valueType != null && !valueType.isPrimitive()) {
            return false;
        }

        if (value instanceof Number) {
            return ((Number) value).longValue() == 0;
        }

        throw new IllegalArgumentException(
              String.format("Could not determine whether %s is new; Unsupported identifier or version property", entity));
    }
}

이 친구들은 또 언제 쓰이는 것인지 더 알아봐야겠다.


도움받은 곳들