[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()는 내부적으로 persist와 merge를 구분함
// 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
}
createdAt이 null이면 아직 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 설정까지 하면 더 효과적
'TroubleShooting' 카테고리의 다른 글
| [JPA] MultipleBagFetchException (0) | 2026.04.04 |
|---|---|
| [vue] CORS 해결 (1) | 2022.06.19 |
| [Vue] You are using the runtime-only build of Vue where the template compiler is not available. (0) | 2020.09.07 |
| [IntelliJ] Git 관련 작업 시 hangs on/Holding (0) | 2020.06.16 |
| [Java-stream] Map 변환 중 Duplicate key (0) | 2020.06.13 |


