-
UUID v4 / UUID v7🧑🏻💻 프로젝트/motimo 2025. 5. 30. 16:25
이번 프로젝트에서 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
'🧑🏻💻 프로젝트 > motimo' 카테고리의 다른 글
Flyway 이용기 (2) 2025.06.15 CQS / CQRS 패턴 적용기 (1) 2025.05.25