Spring Boot에서 UUID v7을 ID로 사용하기 + 사전 정의(Predefined) ID 전략 가이드

신재윤·2025년 9월 16일

springboot3

목록 보기
1/1
post-thumbnail

Spring Boot에서 UUID v7을 ID로 사용하기 + 사전 정의(Predefined) ID 전략 가이드

작성 목적: Spring Boot/Hibernate 환경에서 UUID v7을 기본 키로 사용하는 방법과, __DEFAULT__, __SYSTEM__ 같은 예약 ID를 안전하게 설계·정렬하는 실무 가이드를 제공합니다.


목차

  1. 왜 UUID v7인가
  2. 스택별 적용 방법
  3. 엔티티 필드 타입: UUID vs String
  4. 사전 정의(Predefined) ID 설계 패턴
  5. 정렬 요구: 예약 ID를 항상 맨 위로 두고 싶다면
  6. 실무 체크리스트
  7. 샘플 스니펫 모음
  8. 자주 하는 질문(FAQ)

왜 UUID v7인가

  • 시간 정렬 친화적: 상위 48비트가 Unix epoch 밀리초. 오름차순 정렬 == 생성 시각 순에 가깝습니다.
  • 인덱스 효율: v4(완전 랜덤)보다 B-Tree 인서트/페이지 분할이 덜 발생하는 경향.
  • 글로벌 고유성 + 분산 환경에서 충돌 위험 낮음.

Tip: 대량 쓰기 중심 테이블의 PK/클러스터드 인덱스에서 유리합니다.


스택별 적용 방법

Hibernate 7 (권장)

Hibernate 7부터 v7이 정식 옵션입니다.

import org.hibernate.annotations.UuidGenerator;
import jakarta.persistence.*;

@Entity
public class Order {
  @Id
  @UuidGenerator(style = UuidGenerator.Style.VERSION_7)
  private java.util.UUID id;
  // ...
}

Hibernate 6.x (Spring Boot 3.3 기본)

Hibernate 6.x는 기본 제공이 v4 중심이므로, 라이브러리로 v7을 생성해서 주입합니다. (예: uuid-creator)

Maven

<dependency>
  <groupId>com.github.f4b6a3</groupId>
  <artifactId>uuid-creator</artifactId>
  <version>5.3.6</version> <!-- 최신 버전 확인 -->
</dependency>

엔티티

import com.github.f4b6a3.uuid.UuidCreator;
import jakarta.persistence.*;

@Entity
public class Order {
  @Id
  private java.util.UUID id;

  @PrePersist
  void init() {
    if (id == null) {
      id = UuidCreator.getTimeOrdered(); // = UUID v7
    }
  }
}

DB에서 생성하기(선택)

  • PostgreSQL: pg_uuidv7 확장(또는 사용자 함수)로 DEFAULT uuid_generate_v7() 사용.
  • MySQL/MariaDB: 바이너리(16) 저장 + 애플리케이션에서 v7 생성 주입이 일반적.
-- 예: PostgreSQL
CREATE EXTENSION IF NOT EXISTS pg_uuidv7;

CREATE TABLE orders (
  id uuid PRIMARY KEY DEFAULT uuid_generate_v7(),
  -- ...
);

엔티티 필드 타입: UUID vs String

  • 엔티티/DB 내부: UUID 타입 사용 권장.
    • PostgreSQL: uuid, MySQL/MariaDB: BINARY(16)
    • 저장/인덱스 효율, 타입 안전성, 드라이버 호환성 ↑
  • 외부 노출(REST/JSON): Jackson이 UUID를 자동 문자열 직렬화 → DTO는 String으로 노출하면 충분.
// Controller
@GetMapping("/{id}")
public OrderDto get(@PathVariable java.util.UUID id) { ... }

// DTO
public record OrderDto(String id, String name) {
  public static OrderDto from(Order e) {
    return new OrderDto(e.getId().toString(), e.getName());
  }
}

사전 정의(Predefined) ID 설계 패턴

A. 토큰 → UUID v5(이름 기반) 고정 매핑 (권장)

  • __DEFAULT__, __SYSTEM__ 같은 토큰을 정하고, 고정 네임스페이스와 함께 v5로 변환 → 항상 동일한 UUID.
  • 표준 준수(variant/버전 비트), 도구 호환성 ↑
  • all-1, all-2 같은 패턴값은 비권장(비표준·호환성 위험).

B. Nil UUID 1개만 사용

  • 00000000-0000-0000-0000-000000000000는 RFC 정의 특수값이라 1개 용도에 한해 허용.
  • 여러 개 의미를 Nil 변형으로 늘리는 건 비권장.

C. 의미를 별도 컬럼으로 분리

  • PK는 항상 정상 UUID, “예약 여부/종류”는 is_reserved, reserved_type 등으로 분리.
  • 정렬/가독성/쿼리 최적화에 유리.

정렬 요구: 예약 ID를 항상 맨 위로 두고 싶다면

방법 1: 명시적 정렬(정석)

SELECT *
FROM my_table
ORDER BY
  (id IN (
    '65e71d97-8821-5c05-b3b0-6afba2292c8f', -- DEFAULT
    'd180242f-91e4-5c72-909e-d7b98c3625f5', -- SYSTEM
    'f3138000-cd4a-5869-9340-bffc90048cec', -- DELETED
    '17d8467d-e80e-5a3a-96bc-c837c578530c'  -- UNKNOWN
  )) DESC,
  id ASC;

가장 분명하고 유지보수 친화적인 방법입니다. 컬럼으로 분리했다면 ORDER BY is_reserved DESC, id ASC가 더 깔끔합니다.

방법 2: 예약 ID도 UUID v7로—과거 타임스탬프 고정

  • v7은 상위 48비트가 시간 기반이른 시각일수록 오름차순에서 상단에 옵니다.
  • 토큰별 앵커 타임스탬프(예: 1970-01-01, 1970-01-02, …)를 정해 v7을 결정적으로 생성하면, 예약 ID가 항상 위로 옵니다.
  • 랜덤 영역은 토큰 해시로 결정적 채워 충돌 방지.

주의: 내림차순이면 반대로 맨 아래로 갑니다. 정렬 방향을 고정하세요.


실무 체크리스트

  • 엔티티/DB는 UUID 타입 사용(Postgres uuid, MySQL/MariaDB BINARY(16)).
  • 일반 레코드: UUID v7 사용(Hibernate 7 혹은 라이브러리).
  • 예약 ID: v5(토큰 기반) 또는 v7(과거 시각 고정) 중 하나로 일관성 있게.
  • all-1, all-2패턴값 금지(표준·호환성 위험).
  • 정렬은 명시적 ORDER BY 또는 v7 과거 시각 고정 전략을 선택.
  • 한 번 정한 네임스페이스/앵커/해시 방식은 불변으로 관리(문서화+테스트).
  • 외부 노출은 문자열로, 내부는 UUID 타입으로 유지.

샘플 스니펫 모음

Maven/Gradle 의존성

Maven

<dependency>
  <groupId>com.github.f4b6a3</groupId>
  <artifactId>uuid-creator</artifactId>
  <version>5.3.6</version>
</dependency>

Gradle (Kotlin DSL)

implementation("com.github.f4b6a3:uuid-creator:5.3.6")

엔티티 & DTO 예시

// 엔티티: v7 자동 주입 (Hibernate 6.x + uuid-creator)
@Entity
public class Customer {
  @Id
  private java.util.UUID id;

  @Column(nullable = false)
  private String name;

  @PrePersist
  void init() {
    if (id == null) id = com.github.f4b6a3.uuid.UuidCreator.getTimeOrdered();
  }
}

// DTO: 문자열로 노출
public record CustomerDto(String id, String name) {
  public static CustomerDto from(Customer e) {
    return new CustomerDto(e.getId().toString(), e.getName());
  }
}

v5 예약 ID 유틸(Enum)

import com.github.f4b6a3.uuid.UuidCreator;
import java.util.Map;
import java.util.UUID;
import java.util.stream.Collectors;
import java.util.Arrays;

public enum WellKnownId {
  DEFAULT("__DEFAULT__"),
  SYSTEM("__SYSTEM__"),
  DELETED("__DELETED__"),
  UNKNOWN("__UNKNOWN__");

  private static final UUID NAMESPACE = UUID.fromString("11111111-2222-3333-4444-555555555555");
  private final String token;
  private final UUID uuid;

  WellKnownId(String token) {
    this.token = token;
    this.uuid = UuidCreator.getNameBasedSha1(NAMESPACE, token); // UUID v5
  }

  public UUID uuid() { return uuid; }
  public String token() { return token; }

  private static final Map<UUID, WellKnownId> byUuid =
      Arrays.stream(values()).collect(Collectors.toMap(WellKnownId::uuid, e -> e));

  public static WellKnownId fromUuid(UUID id) { return byUuid.get(id); }
}

v7 예약 ID(과거 타임스탬프 고정) 유틸

라이브러리별 API가 다르므로 컨셉을 보여주는 예시입니다.

import com.github.f4b6a3.uuid.UuidCreator;
import java.nio.ByteBuffer;
import java.security.MessageDigest;
import java.time.Instant;
import java.util.Map;
import java.util.UUID;

public final class ReservedV7Factory {
  private static final Map<String, Instant> ANCHOR = Map.of(
      "__DEFAULT__", Instant.parse("1970-01-01T00:00:00Z"),
      "__SYSTEM__",  Instant.parse("1970-01-02T00:00:00Z"),
      "__DELETED__", Instant.parse("1970-01-03T00:00:00Z"),
      "__UNKNOWN__", Instant.parse("1970-01-04T00:00:00Z")
  );

  public static UUID ofToken(String token) {
    Instant t = ANCHOR.get(token);
    if (t == null) throw new IllegalArgumentException("Unknown token: " + token);
    byte[] rnd = hash128(token);
    // uuid-creator에는 직접 랜덤 바이트를 넣는 v7 빌더가 있으므로 해당 API로 치환하세요.
    // 여기선 개념적으로 표현:
    return buildUuidV7(t, rnd);
  }

  private static byte[] hash128(String s) {
    try {
      MessageDigest md = MessageDigest.getInstance("SHA-256");
      byte[] h = md.digest(s.getBytes(java.nio.charset.StandardCharsets.UTF_8));
      return java.util.Arrays.copyOf(h, 16); // 상위 128비트 사용
    } catch (Exception e) {
      throw new RuntimeException(e);
    }
  }

  private static UUID buildUuidV7(Instant instant, byte[] random16) {
    // 실사용 시 uuid-creator의 v7 빌더 API 사용 권장.
    // 여기서는 v7 비트 배치에 맞춰 조립하는 의사 코드입니다.
    long ms = instant.toEpochMilli();
    long timeHigh = (ms & 0xFFFFFFFFFFFFL); // 상위 48비트

    long most = ((timeHigh & 0xFFFFFFFFFFFFL) << 16);
    // version(4 bits) = 0b0111 for v7
    most |= (0x7L << 12); // 간략화된 표기(의사 코드)

    java.nio.ByteBuffer bb = java.nio.ByteBuffer.wrap(random16).order(java.nio.ByteOrder.BIG_ENDIAN);
    long least = bb.getLong();
    long mostRand = bb.getLong();

    return new UUID(most ^ mostRand, least);
  }
}

정렬 SQL/JPQL

-- 예약 ID 우선 + v7 타임순
SELECT *
FROM my_table
ORDER BY is_reserved DESC, id ASC;
@Query("""
select e from MyEntity e
order by case when e.isReserved = true then 1 else 0 end desc, e.id asc
""")
List<MyEntity> findAllOrdered();

자주 하는 질문(FAQ)

Q1. 예약 ID를 all-1, all-2 같은 패턴 값으로 써도 되나요?
A. 비권장입니다. UUID의 버전/variant 규격을 위반할 수 있어 도구/라이브러리 간 호환 문제가 납니다.

Q2. 예약 ID가 여러 개인데, 항상 위로 오게 할 수 있나요?
A. 두 가지 방법이 있습니다.

  • (정석) is_reserved 컬럼으로 명시적 정렬.
  • (타협) 예약 ID도 v7로 만들되, 아주 이른 타임스탬프를 각 토큰에 고정 배정.

Q3. 엔티티는 UUID, 노출은 String으로?
A. 네. 내부는 타입 안정성과 성능을 위해 UUID, 외부는 문자열로 충분합니다.

Q4. Hibernate 6.x에서 v7을 어떻게?
A. uuid-creator 같은 라이브러리로 v7을 생성해 @PrePersist에서 주입하세요.

Q5. PostgreSQL/마리아DB 권장 매핑은?
A. PostgreSQL: uuid, MySQL/MariaDB: BINARY(16)이 권장입니다.


마무리

  • 일반 레코드: UUID v7로 일관되게.
  • 예약 ID: v5(토큰 기반) 또는 v7(과거 시각 고정) 중 하나로 일관성 있게.
  • 정렬은 명시적 ORDER BY가 가장 안전하며, “ID만으로” 해결하고 싶을 때는 v7+과거 시각 고정 전략을 사용하세요.

필요 시: 현재 프로젝트(부트/하이버네이트/DB 조합)에 맞춘 의존성, 설정, 유틸, 마이그레이션 SQL, 테스트 코드까지 즉시 확장 가능합니다.

profile
잼있게!!

0개의 댓글