
프로젝트 정리 중 문득 그런 생각이 들었다. 지금껏 당연하게 사용해왔던 JPA는 어떻게 해당 데이터가 새로운 데이터인지를 판단하는지 내부 동작 과정이 궁금해졌다. ID를 기준으로 본다고 하면 가능할 것 같긴한데 만약 Auto Increment를 쓰지 않고 사용자가 ID를 직접 지정하는 테이블의 경우는 그걸 어떻게 판단할 수 있을까? 이 호기심을 풀고자 오늘은 JPA가 새로운 엔티티를 어떻게 감지하는지 알아보았다.
우리가 평소에 repository.save(entity)를 호출하면 JPA가 알아서 INSERT 또는 UPDATE를 해준다. 하지만 JPA 입장에서는 이 데이터가 새로운 것인지 아니면 기존 데이터를 수정하는 것인지 판단해야 한다. 그 판단 로직이 바로 isNew() 메서드에 숨어있다.
SimpleJpaRepository의 save() 메서드를 보면 다음과 같다:
@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
}
}
여기서 주목할 점은 persist()와 merge()의 차이다.
persist(): 새로운 데이터라고 확신하고 바로 INSERT 쿼리를 준비한다. DB 조회 없이 바로 저장하기 때문에 효율적이다.
merge(): 일단 해당 ID로 DB를 조회해본다. 있으면 UPDATE, 없으면 INSERT를 수행한다. 새로운 데이터인데도 merge()가 호출되면 불필요한 SELECT 쿼리가 먼저 발생하여 성능이 저하된다.
결국 정확한 isNew() 판단이 성능에 직접적인 영향을 미치는 것이다.
대부분의 경우 우리는 @GeneratedValue를 사용해서 ID를 자동으로 생성한다.
@Entity
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
}
이 경우 JPA는 JpaMetamodelEntityInformation이라는 구현체를 사용하여 신규 엔티티 여부를 판단한다. 판단 로직은 생각보다 단순하다.
1단계: @Version 필드가 있는가?
@Entity
public class Product {
@Id @GeneratedValue
private Long id;
@Version
private Integer version; // 낙관적 락을 위한 버전 관리 필드
}
@Version 어노테이션이 붙은 필드가 있고, 그 타입이 wrapper class(Integer, Long 등)라면 해당 필드가 null인지 확인한다. null이면 새로운 데이터다.
만약 @Version 필드가 primitive 타입(int, long 등)이거나 아예 없다면 다음 단계로 넘어간다.
2단계: @Id 필드 확인
AbstractEntityInformation의 isNew() 메서드가 실행된다:
public boolean isNew(T entity) {
ID id = getId(entity);
Class<ID> idType = getIdType();
if (!idType.isPrimitive()) {
return id == null; // wrapper class면 null 여부 확인
}
if (id instanceof Number) {
return ((Number) id).longValue() == 0L; // primitive면 0인지 확인
}
throw new IllegalArgumentException(String.format("Unsupported primitive id type %s", idType));
}
Member member = new Member();
member.setName("홍길동");
// 이 시점에서 id는 null
memberRepository.save(member);
// 1. isNew() 호출 → id가 null이므로 true 반환
// 2. persist() 실행 → INSERT 쿼리 준비
// 3. DB에 저장되면서 id가 자동으로 1, 2, 3... 순으로 할당됨
자동 키 생성 전략을 사용하면 객체 생성 시점에는 ID가 비어있고, DB에 저장될 때 비로소 ID가 할당된다. 따라서 isNew() 판단이 자연스럽게 정확하다.
그런데 서론에서 던진 질문으로 돌아가보자. Auto Increment를 사용하지 않고 ID를 직접 지정한다면 어떻게 될까?
@Entity
public class Book {
@Id
private String isbn; // ISBN을 직접 할당
private String title;
}
// 사용
Book book = new Book();
book.setIsbn("978-89-123-4567");
book.setTitle("Spring 완벽 가이드");
bookRepository.save(book);
이 상황에서 문제가 발생한다:
isNew()는 false를 반환한다merge()가 실행된다isbn = "978-89-123-4567"인 데이터를 조회한다결과적으로는 INSERT가 되지만, 불필요한 SELECT 쿼리가 먼저 실행되어 비효율적이다.
직접 ID를 할당하는 경우에는 Persistable<T> 인터페이스를 구현해야 한다. 이렇게 하면 JPA가 JpaPersistableEntityInformation이라는 다른 구현체를 사용하게 된다.
@Entity
public class Book implements Persistable<String> {
@Id
private String isbn;
private String title;
@Transient // DB에 저장하지 않는 필드
private boolean isNew = true;
@Override
public String getId() {
return isbn;
}
@Override
public boolean isNew() {
return isNew; // 내가 직접 관리
}
@PostPersist // DB에 저장된 직후
@PostLoad // DB에서 조회한 직후
private void markNotNew() {
this.isNew = false;
}
}
JpaPersistableEntityInformation의 isNew() 메서드는 매우 심플하다:
@Override
public boolean isNew(T entity) {
return entity.isNew(); // 엔티티에게 직접 물어본다
}
더 이상 ID 값이나 Version 필드를 보지 않고, 엔티티 스스로가 "나 신규야!" 또는 "나 기존 데이터야!"라고 알려주는 것이다.
// 1. 새로운 Book 객체 생성
Book book = new Book();
book.setIsbn("978-89-123-4567");
book.setTitle("Spring 완벽 가이드");
// isNew = true (기본값)
// 2. 저장
bookRepository.save(book);
// isNew() 호출 → true 반환
// persist() 실행 → INSERT (SELECT 없이 바로 저장!)
// 3. @PostPersist로 isNew = false로 변경됨
// 4. 다시 저장하면
book.setTitle("Spring 마스터");
bookRepository.save(book);
// isNew() 호출 → false 반환
// merge() 실행 → UPDATE
이제 처음 궁금했던 점들을 정리해보자.
JpaEntityInformation: 엔티티의 메타 정보를 관리하는 인터페이스. "이 엔티티가 새로운가?"를 판단하는 역할을 한다.
JpaMetamodelEntityInformation: 자동 키 생성 전략을 사용할 때의 기본 구현체. @Version이나 @Id 필드를 보고 자동으로 판단한다.
JpaPersistableEntityInformation: Persistable 인터페이스를 구현한 엔티티에서 사용하는 구현체. 엔티티의 isNew() 메서드를 직접 호출한다.
primitive vs wrapper class:
@Entity
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
}
아무 설정 없이 save() 사용 가능. JPA가 알아서 잘 판단한다.
@Entity
public class Book implements Persistable<String> {
@Id
private String isbn;
private String title;
@Transient
private boolean isNew = true;
@Override
public String getId() {
return isbn;
}
@Override
public boolean isNew() {
return isNew;
}
@PostPersist
@PostLoad
private void markNotNew() {
this.isNew = false;
}
}
반드시 Persistable 인터페이스를 구현해야 성능 저하를 막을 수 있다.
@Entity
public class Product {
@Id @GeneratedValue
private Long id;
@Version
private Integer version; // wrapper class 권장
}
@Version 필드가 null이면 신규로 판단한다. primitive 타입보다는 wrapper class를 사용하는 것이 좋다.
처음에는 단순히 "JPA가 어떻게 새로운 데이터를 알아볼까?"라는 호기심에서 시작했는데, 파고들수록 persist와 merge의 차이, 성능 최적화, 그리고 직접 ID를 할당할 때의 주의점까지 알게 되었다.
특히 직접 ID를 할당하는 경우 Persistable 인터페이스를 구현하지 않으면 매번 불필요한 SELECT 쿼리가 발생한다는 점은 실무에서 성능 이슈로 이어질 수 있는 중요한 부분이다. 평소에 @GeneratedValue만 사용하다 보니 몰랐던 부분인데, 이번 기회에 JPA의 내부 동작을 조금 더 깊이 이해하게 된 것 같다.
혹시 UUID나 ISBN처럼 직접 ID를 할당하는 엔티티를 만들 일이 있다면, 꼭 Persistable 인터페이스를 구현하는 것을 잊지 말아야겠다.