Spring Data JPA를 사용하던 중, 새로운 엔티티를 save()로 저장했을 뿐인데
다음과 같은 SELECT 쿼리가 먼저 실행되는 것을 발견했습니다.
select
v1_0.date_option_id,
v1_0.member_id
from
date_vote v1_0
where
(v1_0.date_option_id, v1_0.member_id) in ((?, ?))
처음에는 단순히 저장(INSERT)만 일어날 것이라 생각했는데,
Hibernate가 왜 굳이 존재 여부를 확인하는 SELECT를 수행하는지 의문이 들었습니다.
해당 엔티티는 @EmbeddedId를 사용하고 id 구성이 fk로 이루어진 복합키 구조였기 때문에, save() 할 엔티티를 생성할 때 id 를 직접 할당해주어야 했습니다. 이로 인해save() 동작 시점에 Hibernate가 persist() 가 아닌 merge()를 선택하면서 이런 쿼리가 발생하고 있었습니다.
즉, “새로운 객체를 저장하려고 했는데 Hibernate가 먼저 SELECT를 날린다”는 문제의 핵심은
Spring Data JPA가 엔티티를 새로운 객체인지, 기존 객체인지 명확히 구분하지 못하는 데 있었습니다.
이 글에서는 이 현상의 근본 원인과 함께,
Persistable 인터페이스와 JPA 생명주기 콜백을 활용하여 이 문제를 해결하는 방법을 정리합니다.
save()의 내부 동작Spring Data JPA의 save()는 내부적으로 다음과 같은 로직으로 동작합니다.
if (entityInformation.isNew(entity)) {
em.persist(entity);
} else {
em.merge(entity);
}
즉, 엔티티가 새 객체인지(existing entity인지) 를 판단하여 persist() 또는 merge() 중 하나를 선택합니다.
이때 isNew()의 기본 판단 기준은 ID의 null 여부입니다.
persist() vs merge()persist()
→ 새 엔티티로 간주되어 INSERT를 수행합니다.
→ ID가 null이면 @GeneratedValue를 이용해 Hibernate가 자동으로 생성합니다.
merge()
→ 이미 존재할 수 있는 엔티티로 간주되어 SELECT 후 UPDATE 또는 INSERT를 수행합니다.
→ 복합키에서는 다음과 같은 쿼리가 발생할 수 있습니다.
select
v1_0.date_option_id,
v1_0.member_id
from
date_vote v1_0
where
(v1_0.date_option_id, v1_0.member_id) in ((?, ?))
이는 Hibernate가 복합키를 하나의 튜플(tuple) 로 인식하여
(col1, col2) IN ((?, ?)) 형태로 존재 여부를 확인하기 때문입니다.
복합키는 @GeneratedValue 전략을 사용할 수 없기 때문에,
save() 또는 persist() 전에 반드시 직접 ID를 설정해야 합니다.
vote.setId(new DateVoteId(optionId, memberId));
그렇지 않으면 Hibernate는 다음과 같은 예외를 발생시킵니다.
org.hibernate.id.IdentifierGenerationException:
ids for this class must be manually assigned before calling save()
Persistable<ID> 인터페이스로 isNew() 로직 커스터마이징Spring Data JPA는 엔티티의 “신규 여부”를 isNew() 메서드를 통해 판단합니다.
기본 구현은 ID의 null 여부만 보고 판단하기 때문에, 복합키나 커스텀 생성 규칙이 있는 경우에는 부정확할 수 있습니다.
이때 Persistable<ID> 인터페이스를 직접 구현하면 isNew() 판별 로직을 완전히 개발자가 제어할 수 있습니다.
public interface Persistable<ID> {
ID getId();
boolean isNew();
}
이를 엔티티에 적용하면, Spring Data JPA는 기본 로직 대신 엔티티가 직접 정의한 isNew()를 사용합니다.
@Entity
public class DateVote implements Persistable<DateVoteId> {
@EmbeddedId
private DateVoteId id;
@Transient
private boolean isNew = true;
@Override
public DateVoteId getId() {
return id;
}
@Override
public boolean isNew() {
return this.isNew;
}
/**
* 엔티티가 DB에 저장되거나 조회된 이후 호출되는 콜백 메서드입니다.
* 이 시점에서 엔티티는 이미 영속 상태이므로, 새로운 객체가 아닙니다.
*/
@PostLoad
@PostPersist
public void markNotNew() {
this.isNew = false;
}
}
@PostLoad, @PostPersist 의 역할이 두 애너테이션은 JPA의 엔티티 생명주기(Lifecycle Callback) 에서 중요한 역할을 합니다.
| 애너테이션 | 호출 시점 | 주요 목적 |
|---|---|---|
@PostLoad | 엔티티가 DB로부터 조회된 직후 | 이미 DB에 존재하는 엔티티임을 표시 (isNew = false) |
@PostPersist | 엔티티가 persist() 되어 INSERT 후 flush된 직후 | 새로 저장된 엔티티가 더 이상 신규가 아님을 표시 (isNew = false) |
즉, markNotNew() 메서드는 엔티티가 “DB에 존재하는 상태임”을 JPA가 인식하는 즉시 호출되어, 내부 플래그를 false로 변경합니다.
이를 통해 save() 메서드가 이후에 다시 호출되더라도 merge()로 안전하게 동작할 수 있습니다.
Persistable 구현 시 장점| 항목 | 내용 |
|---|---|
| 동작 제어 | isNew() 로직을 직접 구현해 신규 여부를 제어할 수 있습니다. |
| 복합키 호환 | 복합키 엔티티에서도 정확한 신규 판별이 가능합니다. |
| 생명주기 관리 | @PostLoad, @PostPersist를 통해 자동 상태 변경이 가능합니다. |
| 일관성 유지 | Hibernate 내부의 식별자 null 여부와 별도로 도메인 기준의 판단이 가능합니다. |
| 유연한 확장성 | 커스텀 생성 규칙이나 외부 시스템 연동 시에도 persist/merge 구분이 명확합니다. |
| 항목 | 내용 |
|---|---|
save() 판단 기준 | isNew() (기본은 ID null 여부) |
persist() 실행 조건 | 새 엔티티로 판단될 때 |
merge() 실행 조건 | 기존 엔티티로 판단될 때 |
| 복합키 자동 생성 | ❌ 불가능 (직접 세팅 필요) |
| ID null + @GeneratedValue 없음 | IdentifierGenerationException 발생 |
| 해결 방법 | Persistable 구현 + @PostLoad, @PostPersist 로 상태 관리 |
JPA에서
save()는 단순히 “저장”을 의미하지 않습니다.
내부적으로persist()와merge()를 구분하며, 복합키 엔티티에서는 식별자 직접 할당이 필수입니다.
Persistable을 구현하고@PostLoad,@PostPersist를 함께 사용하면
엔티티의 생명주기를 정확히 반영하면서isNew()를 자동 관리할 수 있습니다.