728x90
반응형

[JPA] save()가 INSERT 대신 SELECT를 날리는 경우

환경 : Spring Boot 3.x, JPA, Hibernate, Kotlin 1.9

 


증상

// memberRepository.save(member) 호출 시
// INSERT가 아니라 SELECT 쿼리가 먼저 실행됨
Hibernate: select m1_0.id ... from member m1_0 where m1_0.id=?
Hibernate: insert into member ...

 

1. 원인

Spring Data JPA의 save()는 내부적으로 persistmerge를 구분함

// SimpleJpaRepository 내부 구현
@Transactional
override fun <S : T> save(entity: S): S {
    if (entityInformation.isNew(entity)) {
        em.persist(entity) // INSERT
        return entity
    } else {
        return em.merge(entity) // SELECT + INSERT 또는 UPDATE
    }
}

isNew() 판단 기준:

  • ID가 null이면 새 엔티티 → persist
  • ID가 null이 아니면 기존 엔티티 → merge (SELECT 먼저 실행)

문제는 ID를 직접 할당하는 경우. ID가 이미 세팅되어 있으니 JPA가 기존 엔티티로 판단해서 merge를 호출함

// ID 직접 할당 → isNew() = false → merge → SELECT 발생
@Entity
class Product(
    @Id
    val id: String = UUID.randomUUID().toString(), // 이미 값이 있음
    val name: String
)

 

2. 해결 방법

2.1) @GeneratedValue 사용 (가장 간단)

DB에서 ID를 자동 생성하면 save() 시점에 ID가 null이므로 항상 persist

@Entity
class Member(
    val name: String,
    val email: String,
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    val id: Long = 0
)

 

2.2) Persistable 인터페이스 구현

ID를 직접 할당해야 하는 경우, Persistable을 구현해서 isNew() 판단 로직을 직접 제어

@Entity
class Product(
    @Id
    val id: String = UUID.randomUUID().toString(),
    val name: String,
    @CreatedDate
    var createdAt: LocalDateTime? = null
) : Persistable<String> {

    override fun getId(): String = id

    override fun isNew(): Boolean = createdAt == null
}

createdAtnull이면 아직 DB에 저장 안 된 것 → persist 호출. @CreatedDate는 저장 시점에 자동 세팅되므로 한 번 저장된 엔티티는 isNew() = false

@CreatedDate 사용하려면 @EnableJpaAuditing 필요

@EnableJpaAuditing
@SpringBootApplication
class Application

 

2.3) @Version 필드 활용

@Version 필드가 null이면 새 엔티티로 판단

@Entity
class Product(
    @Id
    val id: String = UUID.randomUUID().toString(),
    val name: String,
    @Version
    val version: Long? = null // null이면 isNew() = true
)

 

3. persist vs merge 동작 비교

// persist — INSERT 1번
em.persist(member)
// SQL: INSERT INTO member (name, email) VALUES (?, ?)

// merge — SELECT 1번 + INSERT 또는 UPDATE 1번
em.merge(member)
// SQL: SELECT ... FROM member WHERE id = ?
//      INSERT INTO member ... (새 엔티티인 경우)
//      UPDATE member SET ... (기존 엔티티인 경우)

merge는 항상 SELECT가 선행됨. 대량 INSERT 시 성능 차이가 큼

3.1) 대량 INSERT 성능 차이

// 나쁜 예 — ID 직접 할당 + saveAll → merge 1000번 = SELECT 1000 + INSERT 1000
val products = (1..1000).map { Product(id = "P-$it", name = "상품$it") }
productRepository.saveAll(products)
// 총 쿼리: 2000번

// 좋은 예 — Persistable 구현 후 saveAll → persist 1000번
// 총 쿼리: 1000번 (SELECT 없음)

※ 대량 INSERT 시에는 Persistable 구현 + spring.jpa.properties.hibernate.jdbc.batch_size=50 설정까지 하면 더 효과적

 

728x90
반응형

+ Recent posts