개발
home
❄️

Snowflake ID를 Spring Boot + JPA에서 사용하기 (Persistable)

Created
2026/05/11
Tags
SpringBoot
JPA
Kotlin
Algorithm
성능 최적화
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() 하지 않도록 주의