• Spring Data JPA의 save 메서드는 엔티티의 신규 여부(isNew)에 따라 persist 또는 merge를 선택적으로 실행한다.
  • 이 내부 동작을 정확히 이해하지 못하면 직접 ID를 할당하는 환경에서 merge가 호출되어 불필요한 SELECT 쿼리가 발생하는 성능 저하를 겪을 수 있다.
  • save() 메서드의 내부 코드를 보면 entityInformation.isNew(entity) 호출 결과에 따라 분기 처리 된다.
@Override
@Transactional
public <S extends T> S save(S entity) {
    Assert.notNull(entity, "Entity must not be null");
 
    if (entityInformation.isNew(entity)) {
        entityManager.persist(entity); // 신규 저장
        return entity;
    } else {
        return entityManager.merge(entity); // 수정(병합)
    }
}

@Version 필드가 존재하는 경우

  • @Version 필드가 있고, 타입이 객체(Wrapper class)라면 해당 필드가 null일 때만 새로운 엔티티로 판단한다.
    • primitive 타입인 경우 AbstractEntityInformation의 로직을 따른다.
@Override
public boolean isNew(T entity) {
    if(versionAttribute.isEmpty()
          || versionAttribute.map(Attribute::getJavaType).map(Class::isPrimitive).orElse(false)) {
        return super.isNew(entity);
    }
 
    BeanWrapper wrapper = new DirectFieldAccessFallbackBeanWrapper(entity);
    // Version 값이 null이면 신규 엔티티
    return versionAttribute.map(it -> wrapper.getPropertyValue(it.getName()) == null).orElse(true);
}

@Version 필드가 존재하지 않는 경우

  • @Id 필드를 기준으로 판단한다.
    • 식별자가 객체 타입이면 null 여부, 숫자 타입(Number)이면 0 여부를 확인한다.
public boolean isNew(T entity) {
    Id id = getId(entity);
    Class<ID> idType = getIdType();
 
    if (!idType.isPrimitive()) {
        return id == null; // 객체 타입 ID가 null이면 신규
    }
 
    if (id instanceof Number) {
        return ((Number) id).longValue() == 0L; // 숫자 타입 ID가 0이면 신규
    }
 
    throw new IllegalArgumentException(String.format("Unsupported primitive id type %s", idType));
}

직접 ID를 할당한다면?

  • 키 생성 전략을 사용하지 않고 직접 ID를 할당하는 경우 새로운 entity로 간주되지 않는다.
  • @GeneratedValue를 사용하면 DB 저장 시점에 ID가 생성되므로 save() 호출 시 ID는 null이다.
    • isNew()true
  • Application에서 ID를 직접 할당(@Id만 사용)하는 경우, save()를 호출하기 전에 이미 ID 값이 존재하게 된다.
    • 이로 인해 JPA는 이를 기존 데이터로 오해하여 merge()를 호출하고, DB에 데이터가 있는지 확인하는 불필요한 SELECT 쿼리를 날리게 된다.

Persistable 인터페이스 구현

  • 위와 같은 문제를 해결하기 위해 Persistable 인터페이스 구현을 고려해 볼 수 있다.
  • 직접 ID 할당 방식을 사용할 때는 엔티티에 Persistable 인터페이스를 구현하여 판단 로직을 직접 정의해야 한다.
    • JpaPersistableEntityInformation이 동작
  • 아래 코드와 같이 save() 호출 시 isNew()createdDate의 유무에 따라 결정되므로, ID가 채워져 있어도 안전하게 persist()를 호출할 수 있다.
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Item implements Persistable<String> {
 
    @Id
    private String id;
 
    @CreatedDate
    private LocalDateTime createdDate; // 생성일자를 활용한 신규 여부 판단
 
    public Item(String id) {
        this.id = id;
    }
 
    @Override
    public String getId() {
        return id;
    }
 
    @Override
    public boolean isNew() {
        // 생성일자가 null이면 새로운 엔티티로 판단
        return createdDate == null;
    }
}