UUID v4 / UUID v7
이번 프로젝트에서 UUID를 기본 키(PK)로 사용하기로 결정한 이후 어떤 성능적인 영향이 있을지에 대한 고민을 바탕으로 이 글을 작성하게 되었습니다.
저희 프로젝트에서 사용하는 관계형 데이터베이스(RDB)는 PostgreSQL입니다.
✅ PostgreSQL의 Index는 기본적으로 B-Tree
🐘 PostgreSQL 공식 문서 에 따르면
PostgreSQL은 여러 인덱스 타입을 제공하지만 기본적으로 CREATE INDEX는 B-tree 인덱스를 생성합니다.
B-tree는 대부분의 일반적인 상황에 적합하며 정렬 가능한 타입에 가장 효과적으로 동작합니다.
📌 Hibernate에서 UUID 생성 방식
Spring Data JPA에서 아래와 같이 UUID를 사용하는 경우
@Entity
public class UserEntity {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
}
Hibernate 6 기준 내부적으로 아래 전략을 사용합니다.
// package org.hibernate.id.uuid
public class UuidGenerator implements BeforeExecutionGenerator {
private final UuidValueGenerator generator;
private final UUIDJavaType.ValueTransformer valueTransformer;
@Internal
public UuidGenerator(Class<?> memberType) {
this.generator = StandardRandomStrategy.INSTANCE;
this.valueTransformer = this.determineProperTransformer(memberType);
}
}
// package org.hibernate.id.uuid
public class StandardRandomStrategy implements UUIDGenerationStrategy {
public UUID generateUUID(...) {
return UUID.randomUUID(); // UUID v4
}
}
즉, @GeneratedValue(strategy = GenerationType.UUID)는 기본적으로 UUID v4 (무작위 기반) 를 생성합니다.
- UUID.randomUUID()는 RFC 4122에 따라 생성된 UUID v4 를 생성합니다.
- 무작위 값 기반의 UUID입니다.
🔍 UUID v4 vs UUID v7 성능 비교 실험
🤖 테스트 엔티티
@Entity
@Table(name = "uuid_v4_table")
public class UUIDv4Entity {
@Id
private UUID id = UUID.randomUUID();
private String name;
}
@Entity
@Table(name = "uuid_v7_table")
public class UUIDv7Entity {
@Id
private UUID id;
private String name;
}
- UUIDv4: UUID.randomUUID()
- UUIDv7: UuidCreator.getTimeOrderedEpoch() 사용
📌 테스트 시나리오
- 각 테이블(uuid_v4_table, uuid_v7_table)에 100,000 / 1,000,000 건의 데이터 삽입
- 각 단계에서 다음의 쿼리 성능 측정
- 삽입: INSERT INTO ...
- 삭제: DELETE FROM ... WHERE id = '...'
❗ 조회 측정 하지 않은 이유
- v4든 v7이든 PostgreSQL에서의 WHERE id = '...' 같을 것이라 생각
1. 삽입
// UUID v4
for (int i = 0; i < count; i++) {
v4Repo.save(new UUIDv4Entity(UUID.randomUUID(), "v4-" + i));
}
// UUID v7
for (int i = 0; i < count; i++) {
UUID uuid = UuidCreator.getTimeOrderedEpoch();
v7Repo.save(new UUIDv7Entity(uuid, "v7-" + i));
}
➕ 삽입 성능 결과
데이터 수 | UUID v4 INSERT | UUID v7 INSERT | 차이(ms) | 성능 향상 비율 |
100,000 | 148,057 ms | 144,691 ms | 3,366 | 약 2.3% 빠름 |
1,000,000 | 1,425,861 ms | 1,304,353 ms | 121,508 | 약 8.5% 빠름 |
2. 삭제
- 중간 위치 ID 기준으로 삭제
// SELECT COUNT(*) FROM uuid_v4_table; = 1,000,000
// SELECT COUNT(*) FROM uuid_v7_table; = 1,000,000
SELECT id FROM uuid_v4_table OFFSET 500000 LIMIT 1;
--- 결과: d9952f44-b1f3-421c-81be-b71d8673fe0a
SELECT id FROM uuid_v7_table OFFSET 500000 LIMIT 1;
--- 결과: 0197103a-5c87-709b-964d-2782a208e6eb
EXPLAIN ANALYZE DELETE FROM uuid_v4_table WHERE id = 'd9952f44-b1f3-421c-81be-b71d8673fe0a';
EXPLAIN ANALYZE DELETE FROM uuid_v7_table WHERE id = '0197103a-5c87-709b-964d-2782a208e6eb';
🗑️ 삭제 성능 비교
항목 | UUID v4 | UUID v7 | 차이(ms) | 성능 향상 비율 |
Index Scan Time | 0.124 ms | 0.264 ms | -0.14 | ❌ UUID v7가 느림 - (정렬 인덱스가 더 깊어서 초기 Index Scan은 느릴 수 있음) |
Execution Time | 2.103 ms | 0.882 ms | 1.221 | UUID v7가 약 58.1% 빠름 |
🧾 결론
❗ UUID v4의 문제점
- 무작위 값(랜덤 삽입)
- ⇒ B-Tree 구조에서 노드 재정렬과 Page Split을 유발
- 디스크 I/O 증가, 캐시 효율 저하
- 대량의 삽입 작업 시 성능 저하 가능성
✅ UUID v7의 장점
- 시간 기반 UUID로 값이 점진적으로 증가
- B-Tree 인덱스에서 삽입 정렬이 가능하여 효율적
🌴 UUIDv7 과 B-tree
B-Tree는 키 값이 순차적으로 증가하면 인덱스의 오른쪽 끝에만 데이터를 추가합니다.
⇒ 이렇게 되면 페이지 분할(page split)이나 노드 재정렬이 거의 발생하지 않고 디스크 I/O도 적어집니다.
UUIDv7은 상위 비트에 타임스탬프를 포함하고 하위 비트에 랜덤값을 넣습니다.
- 이 구조 덕분에 UUIDv7 값은 생성 시점 기준으로 항상 증가합니다.
- 즉, 데이터가 생성된 시간 순서대로 값이 커져 새로 생성된 UUIDv7은 항상 B-Tree 인덱스의 "오른쪽 끝"에 삽입됩니다.
- 이로 인해 B-Tree 인덱스는 페이지 분할이나 노드 재정렬이 거의 필요 없어 효율적입니다.
🆚 B-Tree 정렬에 의한 디스크 접근 지역성(Locality) 차이
- UUID v4는 랜덤하게 값이 삽입되므로 B-Tree 인덱스가 조각나고 분산됩니다.
- 삭제할 때 디스크나 버퍼 캐시에서 해당 노드를 찾아가는 비용이 더 큼
- 반면 UUID v7은 값이 시간 순으로 정렬되어 있어 인접한 노드에 배치됩니다.
- 이로 인해 삭제 시 접근 위치가 더 예측 가능하고 디스크/메모리 접근 경로가 최적화됩니다.
📚 Page Split이란?
- PostgreSQL에서 B-Tree 인덱스는 정렬된 페이지(블록)에 키를 저장합니다.
- 각 페이지에는 저장할 수 있는 공간이 제한되어 있고 키는 정렬된 순서로 들어갑니다.
🔨 그런데 삽입하려는 위치의 페이지가 가득 차 있으면?
- 시스템은 기존 페이지를 두 개로 나누고(Split) 키를 재배치해야 합니다.
- 이를 Page Split이라고 부릅니다.
❗ 왜 Page Split이 문제?
- 쓰기 성능 저하 - Split은 단순한 삽입보다 추가 연산이 많습니다 (복사, 재배치….)
- 디스크 I/O 증가 - 더 많은 페이지가 필요해져 캐시 효율도 저하.
- 인덱스 조각화(Index Fragmentation) 발생 → 이후 조회 성능도 영향을 받음
⚠️ UUID v4와 Page Split
- UUID v4는 무작위 값이어서 인덱스의 중간 또는 아무 곳에나 삽입됩니다.
- 결과적으로 특정 페이지가 자주 Split되어 인덱스가 빠르게 커지고 조각화됩니다.
예시
[페이지1: 001, 002, 003]
↑
랜덤 UUID 값 삽입시 → 공간 부족 → Split 발생
✅ UUID v7과 Page Split 감소
- UUID v7은 시간 순서대로 증가하는 값입니다.
- 항상 인덱스의 가장 끝에 삽입되어 중간 페이지를 건드릴 일이 거의 없습니다.
- 대부분의 삽입은 가장 마지막 페이지에 일어나고 이 페이지가 가득 찼을 때만 한 번 Split됩니다.
예시
[페이지1: 001, 002, 003]
↑
다음 UUID v7 → 끝에 삽입 → Split 거의 없음
👍 한 줄 요약
UUID v4는 무작위 삽입으로 인덱스 비효율이 발생하지만 UUID v7은 시간순 정렬 특성으로 쓰기 성능이 더 우수합니다.
🧑🏻💻 적용해보기
UUID v7 유틸리티 클래스
@UtilityClass
public class UuidUtils {
private static final SecureRandom RANDOM = new SecureRandom();
private static final long VERSION_7_MASK = 0x7000L;
private static final long VARIANT_MASK = 0x8000000000000000L;
private static final long RANDOM_B_MASK = 0x3FFFFFFFFFFFFFFFL;
private static final int TIMESTAMP_SHIFT = 16;
private static final int RANDOM_A_BOUND = 4096; // 2^12
public static UUID randomV7() {
long timestamp = getMicrosecondTimestamp();
int randomA = RANDOM.nextInt(RANDOM_A_BOUND);
long randomB = RANDOM.nextLong() & RANDOM_B_MASK;
long mostSigBits = (timestamp << TIMESTAMP_SHIFT) | VERSION_7_MASK | randomA;
long leastSigBits = VARIANT_MASK | randomB;
return new UUID(mostSigBits, leastSigBits);
}
public static long extractTimestamp(UUID uuid) {
return uuid.getMostSignificantBits() >>> TIMESTAMP_SHIFT;
}
private static long getMicrosecondTimestamp() {
long millis = System.currentTimeMillis();
long nanos = System.nanoTime();
long micros = (nanos / 1000) % 1000;
return millis * 1000 + micros;
}
}
Hibernate ID 생성기
public class UuidV7IdGenerator implements IdentifierGenerator {
@Override
public Object generate(SharedSessionContractImplementor session, Object object) {
return UuidUtils.randomV7();
}
}
커스텀 어노테이션
@IdGeneratorType(UuidV7IdGenerator.class)
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.FIELD, ElementType.METHOD})
public @interface GeneratedUuidV7Value {
}
엔티티에 적용
@Entity
public class UserEntity {
@Id
@GeneratedUuidV7Value
@Column(name = "id")
private UUID id;
}
테스트
class UuidUtilsTest {
@Test
public void uuidV7_정상_생성() {
// given
UUID uuid1 = UuidUtils.randomV7();
UUID uuid2 = UuidUtils.randomV7();
long timestamp1 = UuidUtils.extractTimestamp(uuid1);
long timestamp2 = UuidUtils.extractTimestamp(uuid2);
// when & then
assertTrue(uuid1.toString().compareTo(uuid2.toString()) < 0);
assertThat(uuid1.version()).isEqualTo(7);
assertThat(uuid2.version()).isEqualTo(7);
assertThat(timestamp1 <= timestamp2).isTrue();
}
}
✅ 생성된 ID가 UUIDv7인지 확인해보기 ⇒ 링크
⭐ 팀원의 리뷰
이 리뷰를 보고 아래와 같은 생각을 했습니다.
🧐 nonoTime()을 왜 사용했었는지를 생각해보자.
- System.currentTimeMillis()는 밀리초 단위까지만 정확하기 때문에 그 하위 마이크로초 단위 정렬을 위해 nanoTime()의 하위 비트를 활용
⇒ 같은 밀리초 내에서 여러 UUID가 생성될 경우의 충돌을 방지...
⚠️ 하지만, 리뷰처럼 nanoTime()은 절대 시간 정보(정확한 시간 정보)를 얻기 위한 용도로는 부적절
❗ 제안해주신 Instant.now().toEpochMilli() 방식은 코드도 더 간결하고 UTC 기반으로 시간이 명확하게 표현된다는 장점이 있다고 생각.
🎯 개선 목표
1. UUID v7 명세에 부합하는 안정적이고 예측 가능한 시간(정확한 시간 정보) 기반 UUID 생성
- Instant.now().toEpochMilli() 를 사용해 UTC 기반 시간 확보
2. 같은 밀리초 내에서 UUID 순서 보장을 위한 방법 추가
- AtomicInteger와 synchronized 를 통해 같은 밀리초 내 생성 순서 보장
⚙️ UUID v7 구조
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
┌─────────────────────────────────────────────────────────────────┐
│ MSB (Most Significant Bits - 64비트) │
├─────────────────────────────────────────────────────────────────┤
│ unix_ts_ms (48비트) │ver│ random_a (12비트) │
├─────────────────────────────────────────────────────────────────┤
│ LSB (Least Significant Bits - 64 bits) │
├─────────────────────────────────────────────────────────────────┤
│var│ random_b (62비트) │
└─────────────────────────────────────────────────────────────────┘
이 구조를 바탕으로 개선!!
🧑🏻💻 개선해보기
전체 코드
@UtilityClass
public class UUIDUtils {
private static final SecureRandom RANDOM = new SecureRandom();
private static final AtomicInteger CLOCK_SEQUENCE = new AtomicInteger(0);
private static final long TIMESTAMP_MASK = 0xFFFFFFFFFFFFL;
private static final long VERSION_7_MASK = 0x7000L;
private static final long CLOCK_SEQ_MASK = 0xFFF;
private static final long VARIANT_MASK = 0x8000000000000000L;
private static final long RANDOM_B_MASK = 0x3FFFFFFFFFFFFFFFL;
private static final int TIMESTAMP_SHIFT = 16;
private static final int CLOCK_SEQ_RANGE = 4096;
private static final int CLOCK_SEQ_MAX = CLOCK_SEQ_RANGE - 1;
private static volatile long lastTimestampMs = 0;
public static UUID createUUIDv7() {
Instant now = Instant.now();
long nowMs = now.toEpochMilli();
long clockSeq = getClockSequence(nowMs);
long msb = 0;
msb |= (nowMs & TIMESTAMP_MASK) << TIMESTAMP_SHIFT;
msb |= VERSION_7_MASK;
msb |= (clockSeq & CLOCK_SEQ_MASK);
long lsb = 0;
lsb |= VARIANT_MASK;
lsb |= (RANDOM.nextLong() & RANDOM_B_MASK);
return new UUID(msb, lsb);
}
private static long getClockSequence(long currentMs) {
synchronized (CLOCK_SEQUENCE) {
if (currentMs != lastTimestampMs) {
lastTimestampMs = currentMs;
CLOCK_SEQUENCE.set(RANDOM.nextInt(CLOCK_SEQ_RANGE));
} else {
CLOCK_SEQUENCE.compareAndSet(CLOCK_SEQ_MAX, 0);
}
return CLOCK_SEQUENCE.getAndIncrement();
}
}
public static long extractTimestamp(UUID uuid) {
return (uuid.getMostSignificantBits() >>> TIMESTAMP_SHIFT) & TIMESTAMP_MASK;
}
}
1️⃣ MSB (Most Significant Bits)
// 64비트 MSB = [48비트 timestamp][4비트 version][12비트 clock_seq]
long msb = 0;
msb |= (nowMs & TIMESTAMP_MASK) << TIMESTAMP_SHIFT; // 타임스탬프
msb |= VERSION_7_MASK; // 버전 7
msb |= (clockSeq & CLOCK_SEQ_MASK); // 클럭시퀀스
- UUID 전체 128비트 중 상위 64비트
- 시간 순 정렬이 가능한 UUID를 만들기 위해서 사용 ⇒ 이로 인해 DB 인덱스 효율이 더 좋아짐.
- UUIDv7은 MSB에 "시간"이 들어가 있어서 생성된 순서대로 정렬이 가능합니다.
예시
nowMs = 1748520000000L (2025-05-28 00:00:00 UTC)
clockSeq = 123
Step 1: 타임스탬프 시프트 (16비트 왼쪽 시프트)
(1748520000000 & 0xFFFFFFFFFFFFL) << 16
= 0x000196F5E60A000 << 16
= 0x196F5E60A0000000
Step 2: 버전 추가 (7)
0x196F5E60A0000000 | 0x7000 = 0x196F5E60A0007000
Step 3: 클럭시퀀스 추가
0x196F5E60A0007000 | (123 & 0xFFF) = 0x196F5E60A000707B
------------------------ 최종 MSB --------
MSB = 0x196F5E60A000707B
🔍 16비트 왼쪽 시프트
> timestamp 를 MSB 상위 48비트에 배치하기 위해 전체 64비트 중 하위 16비트를 비워주기
> 따라서, << 16
🔍 클럭 시퀀스 추가이유
> 같은 밀리초 안에 UUID를 여러 개 생성할 경우 타임스탬프가 동일합니다.
> 따라서 UUID들이 서로 다른 값을 갖게 하려면 뭔가 추가 식별자가 필요하게 되는데 이를 위해 12비트 클럭 시퀀스를 사용하여 같은 밀리초 안에서도 생성 순서를 보장하도록 했습니다.
> 즉, clockSeq 은 밀리초 단위 충돌 방지 + 생성 순서 보장 용도
2️⃣ LSB (Least Significant Bits)
// 64비트 LSB = [2비트 variant][62비트 random]
long lsb = 0;
lsb |= VARIANT_MASK; // variant 10 을 최상위 2비트에
lsb |= (RANDOM.nextLong() & RANDOM_B_MASK); // 62비트 랜덤값
- UUID의 하위 64비트입니다.
- 상위 2비트는 UUID의 형식을 지정하는 variant 비트입니다.
- 나머지 62비트는 UUID 충돌 방지를 위한 난수
🔍 variant
> variant 는 UUID가 어떤 규격을 따르는지 명시하는 필드입니다.
> UUIDv7은 RFC 4122를 따르는 형식 → 항상 10 이어야 합니다. (참고 링크)
🔍 추가 난수 비트
> 추가로 62비트 난수를 넣어 UUID의 고유성을 확보합니다.
📚 Ref.
https://www.baeldung.com/java-hibernate-uuid-primary-key
https://www.baeldung.com/java-generating-time-based-uuids
https://docs.tosspayments.com/resources/glossary/uuid