2026-05-11
개요
Snowflake ID 같은 String 타입의 PK를 Spring Data JPA 엔티티에 적용할 때, save() 호출 시점에 의도치 않은 SELECT 쿼리가 한 번 더 발생하는 문제를 만났습니다.
원인은 JPA가 "이 엔티티가 신규인지(INSERT) 아니면 수정인지(UPDATE) 판단"하기 위해 DB를 한 번 조회하기 때문입니다.
이 글에서는 Persistable<String> 인터페이스를 활용해 이 문제를 우아하게 해결하는 방법을 정리합니다. 예제 도메인은 Article(게시글) 로 잡아서 설명합니다.
본문
왜 Persistable이 필요한가?
Spring Data JPA의 SimpleJpaRepository.save() 구현을 보면 다음과 같습니다.
@Transactional
public <S extends T> S save(S entity) {
if (entityInformation.isNew(entity)) {
em.persist(entity);
return entity;
} else {
return em.merge(entity);
}
}
Java
복사
핵심은 isNew() 메서드입니다. 기본적으로 JPA는 @Id 필드 값이 null이면 신규로 판단합니다.
그런데 Snowflake ID는 ID를 클라이언트(애플리케이션)에서 미리 생성해서 넣어둡니다. 즉, 새로 만든 엔티티도 이미 ID 값을 가지고 있습니다.
val article = Article(
id = SnowflakeIdGenerator.nextId(), // 이미 ID가 채워져 있음
title = "새 글",
)
// JPA는 이 엔티티를 "이미 있는 것"으로 판단
// → save() 시 merge()를 호출 → SELECT 쿼리 발생!
Kotlin
복사
결과적으로 INSERT 한 번에 불필요한 SELECT 쿼리가 추가로 발생합니다. 대량 저장(saveAll) 시에는 이 비용이 누적되어 성능 저하의 원인이 됩니다.
Persistable<ID> 인터페이스로 해결하기
Spring Data가 제공하는 Persistable<ID> 인터페이스를 구현하면, 엔티티가 직접 "나는 신규다 / 아니다"를 알려줄 수 있습니다.
interface Persistable<ID> {
fun getId(): ID?
fun isNew(): Boolean
}
Kotlin
복사
save() 호출 시 JPA는 이제 DB를 조회하지 않고, 엔티티의 isNew() 값만 보고 INSERT/UPDATE를 결정합니다.
상속 계층 설계
공통 로직을 BasePersistableEntity에 정의하고, 모든 엔티티가 이를 상속하도록 합니다.
BasePersistableEntity 구현
import jakarta.persistence.MappedSuperclass
import jakarta.persistence.PostLoad
import jakarta.persistence.PostPersist
import jakarta.persistence.Transient
import org.springframework.data.domain.Persistable
@MappedSuperclass
abstract class BasePersistableEntity : BaseTimeEntity(), Persistable<String> {
@Transient
private var isNew: Boolean = true
@PostPersist
@PostLoad
fun markNotNew() {
this.isNew = false
}
abstract override fun getId(): String
override fun isNew(): Boolean = isNew
}
Kotlin
복사
핵심 포인트는 다음과 같습니다.
•
isNew는 @Transient로 선언하여 DB에 저장하지 않음
•
@PostPersist: INSERT가 끝난 후 isNew = false로 변경
•
@PostLoad: DB에서 조회된 직후에도 isNew = false로 변경
•
기본값은 true이므로, 새로 만든 엔티티는 자동으로 신규 상태
Article 엔티티 구현
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.Id
import jakarta.persistence.Table
@Entity
@Table(name = "article")
class Article(
@Id
@Column(nullable = false, columnDefinition = "varchar(24)")
private val id: String = SnowflakeIdGenerator.nextId(),
@Column(nullable = false)
var title: String,
@Column(nullable = false, columnDefinition = "TEXT")
var content: String,
@Column(nullable = false)
val authorId: String,
) : BasePersistableEntity() {
override fun getId(): String = id
fun update(title: String, content: String) {
this.title = title
this.content = content
}
}
Kotlin
복사
•
id의 기본값으로 SnowflakeIdGenerator.nextId()를 호출 → 객체 생성 시점에 ID 자동 부여
•
getId()를 오버라이드하여 Persistable 계약을 만족
엔티티 생성/수정 흐름
신규 엔티티 생성 시
val article = Article(
title = "오늘의 회고",
content = "...",
authorId = "01h8p2kg3h456",
)
println(article.isNew()) // true (기본값)
articleRepository.save(article)
// → isNew() = true → em.persist() 호출 → INSERT 쿼리만 실행
println(article.isNew()) // false (@PostPersist 콜백 실행됨)
Kotlin
복사
기존 엔티티 수정 시
val article = articleRepository.findByIdOrNull("01h8p2kg3h456")
?: throw ArticleNotFoundException()
// → SELECT 쿼리 → @PostLoad 콜백 → isNew() = false
article.update(title = "수정된 제목", content = "...")
articleRepository.save(article)
// → isNew() = false → em.merge() 호출 → UPDATE 쿼리
Kotlin
복사
Service 레이어 적용
@Service
class ArticleService(
private val articleRepository: ArticleRepository,
) {
@Transactional
fun createArticle(request: CreateArticleRequest): Article {
val article = Article(
title = request.title,
content = request.content,
authorId = request.authorId,
)
// isNew() = true이므로 SELECT 없이 바로 INSERT
return articleRepository.save(article)
}
@Transactional
fun updateArticle(id: String, request: UpdateArticleRequest): Article {
val article = articleRepository.findByIdOrNull(id)
?: throw ArticleNotFoundException(id)
article.update(
title = request.title,
content = request.content,
)
// isNew() = false이므로 UPDATE 쿼리만 실행
return articleRepository.save(article)
}
}
Kotlin
복사
성능 차이: Before vs After
Persistable 적용 전에는 INSERT 한 번을 위해 SELECT가 먼저 발생합니다.
Persistable 적용 후에는 불필요한 SELECT 없이 바로 INSERT가 나갑니다.
특히 대량 저장 시 효과가 큽니다. 1,000건을 한 번에 저장한다면 SELECT 1,000번이 사라집니다.
@Transactional
fun createBatch(requests: List<CreateArticleRequest>): List<Article> {
val articles = requests.map { request ->
Article(
title = request.title,
content = request.content,
authorId = request.authorId,
)
}
// 모든 엔티티의 isNew() = true
// → SELECT 없이 INSERT만 batch로 실행
return articleRepository.saveAll(articles)
}
Kotlin
복사
주의사항
@PostPersist / @PostLoad는 JPA 라이프사이클 콜백이다
두 콜백은 JPA EntityManager가 엔티티를 영속화하거나 DB에서 로드할 때만 실행됩니다.
따라서 JPA를 거치지 않고 객체가 만들어지는 모든 경로에서 isNew는 클래스 기본값(true)으로 남아 INSERT가 잘못 시도될 수 있습니다.
직렬화/역직렬화를 거치는지, 외부 시스템에서 받아온 것인지, 수동으로 생성한 것인지 관계없이, JPA EntityManager를 거치지 않고 들어온 엔티티는 모두 isNew = true인 상태입니다.
대표적으로 다음 상황들이 모두 해당됩니다.
•
Redis 캐시 역직렬화: Spring Data Redis(@RedisHash), RedisTemplate 모두 객체를 JSON 등으로 직렬화해서 저장하므로, 꺼낼 때 새 인스턴스가 만들어짐
•
외부 캐시 라이브러리: Hazelcast, Ehcache, Caffeine 등을 직렬화 옵션으로 쓸 때
•
HTTP API 응답 역직렬화: 외부 서비스에서 받아온 JSON을 Jackson으로 엔티티로 변환
•
메시지 큐: Kafka, RabbitMQ 등에서 이벤트 페이로드를 엔티티로 역직렬화
•
수동 생성: 테스트 코드, mapper, reflection 등으로 직접 생성자를 호출
결과적으로 JPA를 거치지 않고 받은 엔티티를 그대로 save() 하면 다음과 같이 위험합니다.
// ⚠️ 위험한 패턴: JPA를 거치지 않고 들어온 엔티티를 그대로 save()
val article: Article = redisTemplate.opsForValue().get("article:$id")
// 또는 val article: Article = restTemplate.getForObject(...)
// 또는 val article: Article = kafkaRecord.value()
article.update(title = "수정된 제목", content = "...")
articleRepository.save(article)
// → article.isNew() = true (@PostLoad가 호출되지 않았으므로 기본값 그대로)
// → JPA가 신규로 판단 → INSERT 시도 → 기본키 충돌 또는 중복 데이터 발생!
Kotlin
복사
권장 패턴은 두 가지입니다.
•
항상 JPA를 거쳐서 엔티티 획득: 외부에서 받은 것은 ID나 DTO로만 다루고, 실제 엔티티는 findByIdOrNull()로 DB에서 다시 조회 (가장 안전)
•
역직렬화 직후 isNew 강제 세팅: 커스텀 디시리얼라이저나 mapper 레이어에서 외부 객체를 엔티티로 변환할 때 isNew = false를 명시적으로 설정
가장 안전하고 단순한 방법은 첫 번째입니다. 외부 시스템에서 받은 것은 "ID/DTO를 담은 그릇"으로만 취급하고, JPA 엔티티는 항상 Repository를 거쳐서만 얻도록 하면 @PostLoad 콜백이 자동으로 동작합니다.
// ✅ 안전한 패턴: 외부에서는 ID/DTO만, 엔티티는 항상 DB에서
val cachedId: String = redisTemplate.opsForValue().get("article-id:$key")
val article = articleRepository.findByIdOrNull(cachedId)
?: throw ArticleNotFoundException(cachedId)
article.update(title = "수정된 제목", content = "...")
articleRepository.save(article)
// → @PostLoad 콜백으로 isNew=false → UPDATE 쿼리
Kotlin
복사
검증 테스트
@DataJpaTest
class ArticlePersistableTest @Autowired constructor(
private val articleRepository: ArticleRepository,
) {
@Test
fun `신규 엔티티는 isNew=true 이고, 저장 후 false로 바뀐다`() {
// given
val article = Article(
title = "테스트",
content = "내용",
authorId = "01h8p2kg3h456",
)
assertTrue(article.isNew())
// when
articleRepository.save(article)
// then
assertFalse(article.isNew())
}
@Test
fun `조회된 엔티티는 isNew=false 다`() {
// given
val saved = articleRepository.save(
Article(
title = "테스트",
content = "내용",
authorId = "01h8p2kg3h456",
),
)
// when
val found = articleRepository.findByIdOrNull(saved.getId())!!
// then
assertFalse(found.isNew())
}
}
Kotlin
복사
쿼리 로그로도 직접 확인할 수 있습니다.
# application.yml
logging:
level:
org.hibernate.SQL: DEBUG
org.hibernate.orm.jdbc.bind: TRACE
YAML
복사
정리
•
애플리케이션 레벨에서 PK를 생성하는 경우(Snowflake ID, UUID 등)에는 Persistable<ID>를 구현해야 한다
•
@Transient 플래그 + @PostPersist / @PostLoad 콜백으로 신규 여부를 직접 관리
•
불필요한 SELECT 쿼리를 제거하여 INSERT 성능 향상, 특히 batch insert에서 효과가 큼
•
detached 상태에서 직접 save() 하지 않도록 주의

