DDIP 프로젝트를 개발하면서 UUID 기반의 엔티티를 설계하던 중, 팀원으로부터 흥미로운 질문을 받았다.
"@UuidGenerator를 사용할 때 UUID가 저장되는 시점에 하이버네이트가 자동으로 uuid를 자동 생성해서 id를 채워주는 것 같은데, 혹시 그럼 새로운 엔티티 생성 시 새로운 엔티티로 판정되나요?"
이 질문은 단순해 보였지만, 실제로 파헤쳐보니 Spring Data JPA와 Hibernate의 깊은 동작 원리와 관련된 복잡한 문제였다. 이 글에서는 그 과정에서 발견한 UUID 기반 엔티티의 모든 것을 정리해보고자 한다.
개발 과정에서 다음과 같은 코드를 작성했다.
// 분명 새로운 엔티티를 저장하는데...
UUID customId = UUID.randomUUID();
UserEntity user = new UserEntity(customId, "test@example.com", "tester");
userRepository.save(user);
// 예상: INSERT INTO users ...
// 실제: SELECT * FROM users WHERE id = ?
// → org.hibernate.StaleObjectStateException 발생!
왜 이런 일이 발생하는지 이해하기 위해 Spring Data JPA의 내부 동작을 살펴보았다.
Spring Data JPA의 SimpleJpaRepository.save() 메서드를 분석해보니 다음과 같은 구조였다.
// SimpleJpaRepository.save() 의 핵심 로직
@Transactional
public <S extends T> S save(S entity) {
if (entityInformation.isNew(entity)) {
em.persist(entity); // 바로 INSERT
return entity;
} else {
return em.merge(entity); // SELECT 후 INSERT/UPDATE
}
}
기본적으로 JPA는 다음과 같은 방식으로 신규 엔티티를 판정한다.
// JpaMetamodelEntityInformation의 기본 구현
public boolean isNew(T entity) {
return getId(entity) == null; // ID가 null이면 신규 엔티티
}
여기서 핵심 문제가 드러났다. UUID가 언제 생성되느냐에 따라 isNew() 판정 결과가 달라진다는 것이다.
의문을 해결하기 위해 실제 테스트 코드를 작성하여 확인해보았다.
@Entity
@Table(name = "USERS")
public class UserEntity {
@Id
@UuidGenerator // 핵심: UUID 자동 생성
@Column(columnDefinition = "char(36)")
@JdbcTypeCode(SqlTypes.CHAR)
private UUID id;
private String email;
private String nickName;
// 생성자, getter 등...
}


실험 결과: @UuidGenerator는 지연 생성된다! 객체 생성 시점이 아닌 persist() 시점에 UUID가 생성된다.
2-1. 정상 케이스 (ID = null):

2-2. 문제 케이스 (ID ≠ null):

2-1. 정상 케이스 (ID = null)

2-2. 문제 케이스 (ID ≠ null)

실험 결과: org.springframework.orm.ObjectOptimisticLockingFailureException 발생!
원인: merge()가 SELECT 후 데이터가 없어서 동시성 문제로 판단
실험 1의 결과가 모든 것을 명확히 해주었다:
=== 실험 1: UUID 생성 시점 확인 ===
=== 객체 생성 직후 ===
ID: null
ID가 null인가? true
=== persist() 후 ===
ID: bae93284-b534-42ef-bc8d-a9a6260b9ae2
ID가 생성되었는가? true
✅ 결론: @UuidGenerator는 persist() 시점에 지연 생성됨!
이것이 바로 우리 프로젝트에서 별다른 문제가 발생하지 않는 이유다!
"merge()는 SELECT 결과가 없으면 INSERT 하는 거 아닌가?"라는 의문이 들 수 있다. 맞는 말이지만, UUID의 경우는 특별하다.
Hibernate의 내부 판정 로직
1. ID가 있음 → "이건 기존 엔티티야"
2. SELECT 결과 없음 → "어? 누가 삭제했나? 동시성 문제?"
3. 안전 장치 발동 → StaleObjectStateException 발생
Hibernate는 데이터 무결성을 위해 예상치 못한 상황에서는 예외를 발생시킨다. 이는 보수적이지만 안전한 접근 방식이다.
Persistable 인터페이스를 구현하여 isNew() 판정 로직을 직접 제어할 수 있다.
@Entity
public class UserEntity implements Persistable<UUID> {
@Id
@UuidGenerator
private UUID id;
@Transient
private boolean isNew = true; // 핵심: 직접 제어
// ...existing code...
@Override
public boolean isNew() {
return isNew; // ID와 무관하게 개발자가 직접 제어
}
@PrePersist
void markNotNew() {
this.isNew = false; // 저장 후 기존 엔티티로 마킹
}
@PostLoad
void markNotNewOnLoad() {
this.isNew = false; // 조회 시에도 기존 엔티티로 마킹
}
}
| 시나리오 | 쿼리 수 | 예상 동작 | 실제 결과 |
|---|---|---|---|
| ID = null | 1개 (INSERT) | 정상 | 정상 |
| ID ≠ null | 2개 (SELECT + INSERT) | 정상 | 예외 발생 |
| 시나리오 | 쿼리 수 | 예상 동작 | 실제 결과 |
|---|---|---|---|
| ID = null | 1개 (INSERT) | 정상 | 정상 |
| ID ≠ null | 1개 (INSERT) | 정상 | 정상 |
결과: 쿼리 수 50% 감소 + 예외 해결!
1. 테스트 코드
특정 ID로 테스트: 123e4567-e89b-12d3-a456-426614174000
2. 데이터 마이그레이션
기존 시스템 ID: a1b2c3d4-...
3. 외부 시스템 연동
외부 API ID: external-uuid-123
💡 해결책: Persistable 인터페이스 구현 필요!
실험을 통해 가장 중요한 사실을 확인했다. @UuidGenerator는 persist() 시점에 UUID를 생성하므로, 객체 생성 시점에는 ID가 null이다. 이는 Spring Data JPA의 기본 isNew() 로직과 완벽하게 호환된다.
검토 결과, 다음과 같은 이유로 현재 프로젝트에서는 Persistable 인터페이스를 적용하지 않기로 결정했다:
실제 필요성 부족:
현재 구조의 완벽한 동작:
@UuidGenerator의 지연 생성으로 인해 모든 신규 엔티티가 정상적으로 persist() 경로를 탄다복잡성 증가 우려:
@Transient boolean isNew)와 라이프사이클 메서드 필요만약 @UuidGenerator가 즉시 생성 방식이었다면 다음과 같은 문제가 발생했을 것이다:
UserEntity user = UserEntity.create("test@test.com", "tester", "ACTIVE");
만약 이 시점에서 UUID가 생성되었다면: user.getId() != null
→ isNew() = false → merge() 호출 → StaleObjectStateException 발생!
이런 경우라면 Persistable 구현이 필수였을 것이다.
또한 다음과 같은 요구사항이 생긴다면 Persistable 도입을 재검토해야 한다:
UUID 기반 JPA 엔티티 설계 시 반드시 확인해야 할 것:
이번 탐구를 통해 "언제 어떤 기술을 도입할 것인가"에 대한 판단 기준을 명확히 할 수 있었다. 기술적으로 가능하다고 해서 무조건 적용하는 것이 아니라, 실제 요구사항과 복잡성을 균형 있게 고려하는 것이 중요하다는 교훈을 얻었다.