ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 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() 사용

     

    📌 테스트 시나리오

    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

    '🧑🏻‍💻 프로젝트 > motimo' 카테고리의 다른 글

    Flyway 이용기  (2) 2025.06.15
    CQS / CQRS 패턴 적용기  (1) 2025.05.25

    댓글

Designed by Tistory.