beomsic 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() 사용

 

📌 테스트 시나리오

  1. 각 테이블(uuid_v4_table, uuid_v7_table)에 100,000 / 1,000,000 건의 데이터 삽입
  2. 각 단계에서 다음의 쿼리 성능 측정
    • 삽입: 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

https://learn.microsoft.com/ko-kr/dotnet/api/java.util.uuid.randomuuid?view=net-android-35.0&viewFallbackFrom=xamarin-android-sdk-13

https://dev.to/umangsinha12/postgresql-uuid-performance-benchmarking-random-v4-and-time-based-v7-uuids-n9b

https://medium.com/@hoanguyendev/streamline-uuid-v7-generation-in-spring-boot-entities-with-custom-annotations-hibernate-6-5-4ddc018895cf

https://github.com/f4b6a3/uuid-creator

https://medium.com/@nipunasan/how-to-use-idgeneratortype-in-hibernate-6-5-spring-boot-3-3-for-custom-id-generation-403be9b51d6f