Spring Data JPA — isNew()로 “새로운 엔티티”를 구분하는 방식 완전 정리

이동휘·2025년 4월 28일
0

매일매일 블로그

목록 보기
1/49

Spring Data JPA가 save() 호출 한 번으로 INSERT(persist)와 UPDATE(merge)를 자동으로 구분해 주는 비밀은 JpaEntityInformation#isNew()에 숨어 있습니다.
코드를 뜯어보며 동작 방식을 이해하고, 직접 ID를 할당할 때 주의해야 할 점까지 정리했습니다.

1. isNew()가 호출되는 곳

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

    if (entityInformation.isNew(entity)) {
        entityManager.persist(entity);  // INSERT
        return entity;
    } else {
        return entityManager.merge(entity);  // UPDATE
    }
}
  • SimpleJpaRepository#save()는 먼저 entityInformation.isNew(entity)를 호출해 신규 엔티티인지 판단합니다.
  • true → EntityManager.persist() → INSERT
  • false → EntityManager.merge() → SELECT 후 UPDATE
  • 따라서 ‘신규’ 판단이 틀리면 불필요한 SELECT가 발생하거나, 반대로 UPDATE 쿼리가 날아가지 않는 참사가 생깁니다.

2. JpaEntityInformation 계층 구조

  • JpaMetamodelEntityInformation
    • 기본 구현체
    • @Version 필드가 없거나 primitive 타입일 때 AbstractEntityInformation#isNew()를 호출
  • JpaPersistableEntityInformation
    • 엔티티가 Persistable 인터페이스를 구현하면 사용
    • entity.isNew() 값을 그대로 따름

3. @Version 유무·타입에 따른 분기

@Override
public boolean isNew(T entity) {
    if (versionAttribute.isEmpty()
        || versionAttribute.map(Attribute::getJavaType)
                           .map(Class::isPrimitive).orElse(false)) {
        return super.isNew(entity);   // (1)
    }

    // (2) Wrapper 타입(@Version Long 등) → null 여부
    BeanWrapper wrapper = new DirectFieldAccessFallbackBeanWrapper(entity);
    return versionAttribute
             .map(it -> wrapper.getPropertyValue(it.getName()) == null)
             .orElse(true);
}
  1. @Version없다 혹은 primitive(long, int) ➜ AbstractEntityInformation#isNew() 호출
  2. @Version랩퍼 타입(Long, Integer) ➜ 값이 null이면 신규로 간주

4. AbstractEntityInformation#isNew()

public boolean isNew(T entity) {
    ID id = getId(entity);
    Class<ID> idType = getIdType();

    if (!idType.isPrimitive()) {
        return id == null;               // (A)
    }
    if (id instanceof Number) {
        return ((Number) id).longValue() == 0L;  // (B)
    }
    throw new IllegalArgumentException(
        String.format("Unsupported primitive id type %s", idType));
}
  • 비-primitive IDnull 이면 신규 (A)
  • primitive 숫자 ID0 이면 신규 (B)
  • @GeneratedValue 전략이면 메모리상 ID가 null언제나 신규

5. 직접 ID를 세팅할 때 주의!

  • 키 생성 전략 없이 ID를 직접 넣으면 id != nullisNew()falsemerge 호출
    • → 아직 DB에 없어도 SELECT → INSERT 패턴 → 성능 손해
  • 해결: Persistable 인터페이스 구현
@Entity
public class Order implements Persistable<Long> {
    @Id
    private Long id;

    @Override public Long getId() { return id; }
    @Override public boolean isNew() { return id == null; } // or createdAt == null
}

한 줄 요약

ID / @Version / Persistable 세 조합으로 “신규 vs 기존”을 올바르게 판단해야 save() 가 원하는 대로 persist 혹은 merge 를 수행한다.

꼬리물기 질문 & 답변

질문답변 요약
Q1. saveAll() 에서도 같은 로직이 적용될까?내부에서 각 엔티티마다 save() 호출 → isNew() 비용이 배수로 증가. 대용량이면 persist() + chunk flush(예: 1000건) 권장
Q2. 영속성 컨텍스트에 이미 존재하는 엔티티를 persist() 하면?같은 ID 있으면 EntityExistsException. Spring Data는 isNew()로 걸러주지만 순수 JPA 코드에서는 직접 확인 필요
Q3. 복합 키(@EmbeddedId)를 쓰면 isNew() 는?@EmbeddedId 객체 자체의 null 여부로 판단. 내부 필드가 비어 있어도 isNew()false 될 수 있으니 조립 과정 주의 또는 Persistable 사용

읽고 나서 해볼 것

  • 직접 ID 할당 엔티티를 만들어 isNew() 로그 확인
  • 같은 엔티티에 persist() vs merge() 각각 호출 → SQL 차이 분석
  • saveAll() 대용량 배치에서 flush 간격에 따른 메모리·실행시간 비교

0개의 댓글