JPA 복합키 save()

JUHYUN·2025년 10월 26일
0

문제상황

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()
    → 이미 존재할 수 있는 엔티티로 간주되어 SELECTUPDATE 또는 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()를 자동 관리할 수 있습니다.

profile
행복과 같은 속도를 찾는 개발자

0개의 댓글