BlueNyang
[Spring Data JPA] Spring Data JPA에서 새로운 Entity를 판단하는 방법
Spring

[Spring Data JPA] Spring Data JPA에서 새로운 Entity를 판단하는 방법

BlueNyangBlueNyang
·
·
약 5분
·
# spring-data-jpa# persist-vs-merge# persistable# entity-lifecycle

Spring Data JPA를 사용하다 보면 자연스럽게 save() 메서드를 호출하여 데이터를 데이터베이스에 저장하게 됩니다. 직관적인 이름 덕분에 단순히 INSERT 쿼리가 실행될 것이라고 기대하기 쉽지만, 실제 내부 동작은 조금 더 복잡합니다. 이 과정을 정확히 이해하지 못하면 예상치 못한 성능 저하나 버그를 마주할 수 있습니다.

이번 글에서는 Spring Data JPA가 주어진 Entity를 어떻게 평가하고 저장하는지, 특히 '새로운 Entity'를 판별하는 기준과 그에 따른 영속성 컨텍스트의 동작을 상세히 살펴보겠습니다.

1. Spring Data JPA의 save() 메서드와 영속성 상태

Spring Data JPA의 CrudRepository가 제공하는 save() 메서드는 단순히 데이터를 삽입(Insert)하는 역할만 하지 않습니다. 파라미터로 넘어온 Entity의 상태를 확인하여 persistmerge 중 하나를 선택하여 실행합니다.

1.1. persist와 merge의 개념

JPA의 영속성 컨텍스트(Persistence Context) 관점에서 두 동작은 명확한 차이를 가집니다.

  • persist: 전달된 Entity가 새롭다고 판단될 때 호출됩니다. 영속성 컨텍스트에 Entity를 새롭게 등록하고, 쓰기 지연 저장소에 INSERT 쿼리를 적재합니다.
  • merge: 전달된 Entity가 이미 존재했던 것(즉, 새롭지 않은 것)이라고 판단될 때 호출됩니다. 영속성 컨텍스트(또는 데이터베이스)에서 기존 Entity를 조회(SELECT)한 뒤, 파라미터로 넘어온 Entity의 값으로 병합(UPDATE)하여 새로운 영속 상태의 Entity를 반환합니다.
분기-순서도
분기-순서도

1.2. save() 동작의 핵심, isNew()

결국 save() 메서드의 핵심은 "이 Entity가 새로운가?" 를 정확히 판단하는 것입니다. 새로운 Entity라면 persist를 통해 효율적으로 INSERT를 수행하지만, 그렇지 않다면 불필요할 수 있는 SELECT 쿼리를 동반하는 merge가 발생하기 때문입니다.

2. 새로운 Entity인지 판단하는 기준

Spring Data JPA는 인터페이스를 통해 Entity의 메타데이터를 분석하고 새로운 객체인지 판별합니다.

2.1. 기본 전략: JpaMetamodelEntityInformation

사용자가 별도의 설정을 하지 않았다면, Spring Data JPA는 기본적으로 JpaMetamodelEntityInformation 클래스를 사용하여 Entity의 상태를 파악합니다. 이 클래스는 JPA 메타모델을 기반으로 Entity의 식별자(ID) 타입과 값을 확인하여 동작합니다.

2.2. JpaEntityInformation.isNew() 내부 구조

해당 로직의 기반이 되는 AbstractEntityInformation (또는 JpaMetamodelEntityInformation의 상위 구조)의 isNew() Java 코드 예시를 살펴보면 그 기준이 매우 명확합니다.

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

    // 식별자 타입이 Primitive(원시) 타입이 아닌 경우 (예: Long, String 등 래퍼 클래스나 객체)
    if (!idType.isPrimitive()) {
        return id == null;
    }

    // 식별자 타입이 Number 타입의 Primitive인 경우 (예: int, long)
    if (id instanceof Number) {
        return ((Number) id).longValue() == 0L;
    }

    throw new IllegalArgumentException(String.format("Unsupported primitive id type %s", idType));
}

위 코드에서 알 수 있듯이 판단 기준은 다음과 같습니다.

  1. 객체 타입(예: Long, String): 식별자 값이 null이면 새로운 Entity로 간주합니다.
  2. 원시 타입(예: long, int): 식별자 값이 0이면 새로운 Entity로 간주합니다.

일반적으로 데이터베이스의 Auto Increment 기능(@GeneratedValue)을 사용하고 식별자를 래퍼 클래스(Long)로 선언한다면, 객체 생성 시점에는 ID가 null이므로 정상적으로 persist가 호출됩니다.

3. 직접 ID를 할당하는 경우의 동작과 문제점

문제는 요구사항에 의해 식별자를 시스템에서 직접 생성하여 할당해야 할 때 발생합니다. (예: UUID 사용, 특정 규칙을 가진 문자열 ID 등)

3.1. 식별자가 이미 존재하는 경우의 save() 동작

직접 ID를 생성해서 Entity 객체에 세팅한 후 save()를 호출한다고 가정해 보겠습니다.

이때 isNew() 메서드가 평가할 때, 식별자(ID) 값은 이미 존재하는 상태(null도 아니고 0도 아님)입니다. 따라서 Spring Data JPA는 이 Entity를 새롭지 않은 객체(기존에 있는 객체) 로 판단하게 됩니다.

3.2. 불필요한 merge 발생과 성능 저하

새로운 Entity가 아니라고 판단되었으므로, save()는 내부적으로 merge()를 호출합니다.

merge()는 데이터베이스에 해당 데이터가 있는지 확인하기 위해 먼저 SELECT 쿼리를 실행합니다. 하지만 데이터베이스에는 아직 이 데이터가 없습니다(우리가 방금 생성했기 때문입니다).

데이터베이스에 없다는 것을 확인한 후, 그제야 INSERT 쿼리를 실행하여 데이터를 저장합니다.

결과적으로 데이터 삽입을 위해 항상 SELECT 쿼리가 한 번 더 실행되는 심각한 성능 낭비가 발생합니다.

4. Persistable 인터페이스를 통한 새로운 Entity 판별 제어

위와 같이 직접 식별자를 할당해야 하는 상황에서 merge로 인한 성능 저하를 막기 위해서는 개발자가 직접 "이 객체는 새로운 객체입니다"라고 Spring Data JPA에게 알려주어야 합니다. 이때 사용하는 것이 Persistable 인터페이스입니다.

4.1. Persistable 인터페이스란?

Persistable은 Spring Data에서 제공하는 인터페이스로, getId()isNew() 메서드를 오버라이딩하여 Entity의 식별자와 신규 여부 판단 로직을 직접 구현할 수 있게 해줍니다. Entity가 이 인터페이스를 구현하고 있다면, Spring Data JPA는 기본 로직을 무시하고 오버라이딩된 isNew()의 반환값을 신뢰합니다.

4.2. Kotlin을 활용한 구현 예시

직접 ID를 할당할 때 가장 권장되는 방식 중 하나는 @CreatedDate를 활용하는 것입니다. 객체가 영속화(저장)될 때 감사(Auditing) 기능에 의해 생성일자가 채워진다는 점을 이용하여, 생성일자의 null 여부로 새로운 객체인지를 판단하는 방식입니다.

kotlin
import org.springframework.data.annotation.CreatedDate
import org.springframework.data.domain.Persistable
import org.springframework.data.jpa.domain.support.AuditingEntityListener
import java.time.LocalDateTime
import jakarta.persistence.Entity
import jakarta.persistence.EntityListeners
import jakarta.persistence.Id

@Entity
@EntityListeners(AuditingEntityListener::class)
class Item(
    @Id
    private val id: String // 직접 할당하는 식별자
) : Persistable<String> {

    @CreatedDate
    var createdDate: LocalDateTime? = null
        private set

    override fun getId(): String = id

    // Entity가 새로운 상태인지 직접 정의
    override fun isNew(): Boolean {
        // createdDate가 null이면 아직 DB에 저장되지 않은 새로운 Entity로 판단
        return createdDate == null
    }
}

위와 같이 Kotlin 코드로 구현하면, 직접 String 타입의 ID를 주입하더라도 createdDatenull인 상태이므로 isNew()true를 반환합니다. 따라서 SELECT 쿼리 없이 즉시 persist가 정상적으로 동작하게 됩니다.

5. 결론

Spring Data JPA의 save() 메서드는 단순히 쿼리를 던지는 것을 넘어 영속성 컨텍스트의 상태를 영리하게 관리하려 시도합니다. 그 중심에는 Entity가 새로운 상태인지 판단하는 isNew() 로직이 있으며, 이는 persistmerge의 분기점이 됩니다.

이번 글의 핵심은 다음과 같이 요약할 수 있습니다.

  • @GeneratedValue를 통한 ID 자동 생성 전략을 사용할 때는 기본 제공되는 JpaMetamodelEntityInformation의 판단 로직만으로도 충분히 최적화된 동작(persist)을 보장받습니다.
  • 하지만 비즈니스 로직상 식별자를 애플리케이션 레벨에서 직접 할당해야 한다면, 반드시 Persistable 인터페이스를 구현하여 isNew() 판별 로직을 커스터마이징해야 불필요한 SELECT 쿼리에 의한 성능 저하(merge)를 예방할 수 있습니다.

Spring Data JPA가 제공하는 추상화의 편리함 뒤에 숨겨진 영속성 컨텍스트의 생명주기를 완벽히 이해하고 제어한다면, 더욱 견고하고 성능이 우수한 애플리케이션을 구축할 수 있을 것입니다. 프로젝트의 Entity 식별자 생성 전략을 다시 한번 점검해 보고, 불필요한 merge가 발생하고 있지는 않은지 확인해 보시기를 권장합니다.

BlueNyang
작성자BlueNyang
라이선스
CC BY NC
BlueNyang

BlueNyang

BlueNyang의 개발 log

카테고리

  • Development
  • Framework
  • Language
  • Dev Tools
  • DevOps & Infra
  • Studies

페이지

© 2026 BlueNyang. All rights reserved.

Made with Nuxt.js and Directus