December 6, 20256 minutes
UUIDv7은 우리에게 익숙한 UUIDv4처럼 128비트 고유 식별자인데요.
하지만 형님 격인 v4와 달리 1밀리초(ms) 단위의 시간 순 정렬이 가능하다는 결정적인 차이가 있습니다.
타임스탬프와 난수(Random) 부분을 결합한 덕분에, 분산 환경을 포함한 데이터베이스의 레코드 식별자로 아주 훌륭한 선택지가 될 수 있거든요.
이번 글에서는 UUIDv7의 구조를 간단히 살펴보고, 유명 언어별 구현 코드를 소개해 드리겠습니다.
외부 라이브러리 없이 순수하게 구현한 코드들인데요.
가장 빠르거나 완벽하게 관용적인 코드는 아닐 수 있지만, 간결하고 이해하기 쉬운 예제들입니다.
UUIDv7을 문자열로 표현하면 대략 이런 모습입니다.
0190163d-8694-739b-aea5-966c26f8ad91
└─timestamp─┘ │└─┤ │└───rand_b─────┘
ver │var
rand_a이 128비트 값은 몇 가지 부분으로 나뉘는데요. 각 필드의 역할은 다음과 같습니다.
문자열로 표현할 때 각 심볼은 4비트를 16진수로 인코딩하는데요.
위 예시의 a는 1010이므로, 앞의 두 비트는 고정된 변형값(10)이고 뒤의 두 비트는 랜덤입니다.
따라서 결과적으로 이 위치의 16진수는 8(1000), 9(1001), a(1010), b(1011) 중 하나가 됩니다.
더 자세한 내용은 RFC 9652 문서를 참고하시면 됩니다.
crypto.getRandomValues()로 난수 배열을 초기화하고, Date.now()로 현재 타임스탬프를 가져옵니다.
비트 연산을 통해 배열을 채우고 버전과 변형값을 설정하는 방식입니다.
function uuidv7() {
// 난수 바이트 생성
const value = new Uint8Array(16);
crypto.getRandomValues(value);
// 현재 타임스탬프 (ms)
const timestamp = BigInt(Date.now());
// 타임스탬프 할당
value[0] = Number((timestamp >> 40n) & 0xffn);
value[1] = Number((timestamp >> 32n) & 0xffn);
value[2] = Number((timestamp >> 24n) & 0xffn);
value[3] = Number((timestamp >> 16n) & 0xffn);
value[4] = Number((timestamp >> 8n) & 0xffn);
value[5] = Number(timestamp & 0xffn);
// 버전 및 변형값 설정
value[6] = (value[6] & 0x0f) | 0x70;
value[8] = (value[8] & 0x3f) | 0x80;
return value;
}
const uuidVal = uuidv7();
const uuidStr = Array.from(uuidVal)
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
console.log(uuidStr);타입스크립트(TypeScript) 버전도 거의 동일한데요. 함수 시그니처만 function uuidv7(): Uint8Array로 변경해 주시면 됩니다.
파이썬에서는 os.urandom()과 time.time()을 사용해 구현합니다.
import os
import time
def uuidv7():
# 난수 바이트 생성
value = bytearray(os.urandom(16))
# 현재 타임스탬프 (ms)
timestamp = int(time.time() * 1000)
# 타임스탬프 할당
value[0] = (timestamp >> 40) & 0xFF
value[1] = (timestamp >> 32) & 0xFF
value[2] = (timestamp >> 24) & 0xFF
value[3] = (timestamp >> 16) & 0xFF
value[4] = (timestamp >> 8) & 0xFF
value[5] = timestamp & 0xFF
# 버전 및 변형값 설정
value[6] = (value[6] & 0x0F) | 0x70
value[8] = (value[8] & 0x3F) | 0x80
return value
if __name__ == "__main__":
uuid_val = uuidv7()
print(''.join(f'{byte:02x}' for byte in uuid_val))자바에서는 SecureRandom과 ByteBuffer를 활용하면 깔끔하게 구현할 수 있습니다.
import java.nio.ByteBuffer;
import java.security.SecureRandom;
import java.util.UUID;
public class UUIDv7 {
private static final SecureRandom random = new SecureRandom();
public static UUID randomUUID() {
byte[] value = randomBytes();
ByteBuffer buf = ByteBuffer.wrap(value);
long high = buf.getLong();
long low = buf.getLong();
return new UUID(high, low);
}
public static byte[] randomBytes() {
byte[] value = new byte[16];
random.nextBytes(value);
ByteBuffer timestamp = ByteBuffer.allocate(Long.BYTES);
timestamp.putLong(System.currentTimeMillis());
System.arraycopy(timestamp.array(), 2, value, 0, 6);
value[6] = (byte) ((value[6] & 0x0F) | 0x70);
value[8] = (byte) ((value[8] & 0x3F) | 0x80);
return value;
}
public static void main(String[] args) {
var uuid = UUIDv7.randomUUID();
System.out.println(uuid);
}
}C#은 RandomNumberGenerator와 DateTimeOffset을 사용하여 안전하고 정확한 값을 생성합니다.
using System;
using System.Security.Cryptography;
public class UUIDv7 {
private static readonly RandomNumberGenerator random =
RandomNumberGenerator.Create();
public static byte[] Generate() {
byte[] value = new byte[16];
random.GetBytes(value);
long timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
value[0] = (byte)((timestamp >> 40) & 0xFF);
value[1] = (byte)((timestamp >> 32) & 0xFF);
value[2] = (byte)((timestamp >> 24) & 0xFF);
value[3] = (byte)((timestamp >> 16) & 0xFF);
value[4] = (byte)((timestamp >> 8) & 0xFF);
value[5] = (byte)(timestamp & 0xFF);
value[6] = (byte)((value[6] & 0x0F) | 0x70);
value[8] = (byte)((value[8] & 0x3F) | 0x80);
return value;
}
public static void Main(string[] args) {
byte[] uuidVal = Generate();
foreach (byte b in uuidVal) {
Console.Write("{0:x2}", b);
}
Console.WriteLine();
}
}모던 C++의 random_device와 chrono 라이브러리를 사용해 구현한 예제입니다.
#include <array>
#include <chrono>
#include <cstdint>
#include <cstdio>
#include <random>
std::array<uint8_t, 16> uuidv7() {
std::random_device rd;
std::array<uint8_t, 16> random_bytes;
std::generate(random_bytes.begin(), random_bytes.end(), std::ref(rd));
std::array<uint8_t, 16> value;
std::copy(random_bytes.begin(), random_bytes.end(), value.begin());
auto now = std::chrono::system_clock::now();
auto millis = std::chrono::duration_cast<std::chrono::milliseconds>(
now.time_since_epoch()
).count();
value[0] = (millis >> 40) & 0xFF;
value[1] = (millis >> 32) & 0xFF;
value[2] = (millis >> 24) & 0xFF;
value[3] = (millis >> 16) & 0xFF;
value[4] = (millis >> 8) & 0xFF;
value[5] = millis & 0xFF;
value[6] = (value[6] & 0x0F) | 0x70;
value[8] = (value[8] & 0x3F) | 0x80;
return value;
}
int main() {
auto uuid_val = uuidv7();
for (const auto& byte : uuid_val) {
printf("%02x", byte);
}
printf("\n");
return 0;
}C 언어에서는 getentropy와 timespec_get을 사용하여 저수준에서 직접 바이트를 조작합니다.
#include <stdio.h>
#include <stdlib.h>
#include <stdint.h>
#include <time.h>
#include <unistd.h>
int uuidv7(uint8_t* value) {
int err = getentropy(value, 16);
if (err != EXIT_SUCCESS) {
return EXIT_FAILURE;
}
struct timespec ts;
int ok = timespec_get(&ts, TIME_UTC);
if (ok == 0) {
return EXIT_FAILURE;
}
uint64_t timestamp = (uint64_t)ts.tv_sec * 1000 + ts.tv_nsec / 1000000;
value[0] = (timestamp >> 40) & 0xFF;
value[1] = (timestamp >> 32) & 0xFF;
value[2] = (timestamp >> 24) & 0xFF;
value[3] = (timestamp >> 16) & 0xFF;
value[4] = (timestamp >> 8) & 0xFF;
value[5] = timestamp & 0xFF;
value[6] = (value[6] & 0x0F) | 0x70;
value[8] = (value[8] & 0x3F) | 0x80;
return EXIT_SUCCESS;
}
int main() {
uint8_t uuid_val[16];
uuidv7(uuid_val);
for (size_t i = 0; i < 16; i++) {
printf("%02x", uuid_val[i]);
}
printf("\n");
}Go 언어의 간결함이 돋보이는 구현입니다. crypto/rand 패키지를 사용합니다.
package main
import (
"crypto/rand"
"fmt"
"math/big"
"time"
)
func uuidv7() ([16]byte, error) {
var value [16]byte
_, err := rand.Read(value[:])
if err != nil {
return value, err
}
timestamp := big.NewInt(time.Now().UnixMilli())
timestamp.FillBytes(value[0:6])
value[6] = (value[6] & 0x0F) | 0x70
value[8] = (value[8] & 0x3F) | 0x80
return value, nil
}
func main() {
uuidVal, _ := uuidv7()
fmt.Printf("%x\n", uuidVal)
}러스트는 타입 안전성을 보장하며 바이트 단위 조작을 수행합니다.
use std::error::Error;
use std::time::{SystemTime, UNIX_EPOCH};
fn uuidv7() -> Result<[u8; 16], Box<dyn Error>> {
let mut value = [0u8; 16];
getrandom::getrandom(&mut value)?;
let timestamp = match SystemTime::now().duration_since(UNIX_EPOCH) {
Ok(duration) => duration.as_millis() as u64,
Err(_) => return Err(Box::from("Failed to get system time")),
};
value[0] = (timestamp >> 40) as u8;
value[1] = (timestamp >> 32) as u8;
value[2] = (timestamp >> 24) as u8;
value[3] = (timestamp >> 16) as u8;
value[4] = (timestamp >> 8) as u8;
value[5] = timestamp as u8;
value[6] = (value[6] & 0x0F) | 0x70;
value[8] = (value[8] & 0x3F) | 0x80;
Ok(value)
}
fn main() {
match uuidv7() {
Ok(uuid_val) => {
for byte in &uuid_val {
print!("{:02x}", byte);
}
println!();
}
Err(e) => eprintln!("Error: {}", e),
}
}자바와 호환되지만 코틀린 특유의 문법으로 조금 더 간결하게 표현했습니다.
import java.security.SecureRandom
import java.time.Instant
object UUIDv7 {
private val random = SecureRandom()
fun generate(): ByteArray {
val value = ByteArray(16)
random.nextBytes(value)
val timestamp = Instant.now().toEpochMilli()
value[0] = ((timestamp shr 40) and 0xFF).toByte()
value[1] = ((timestamp shr 32) and 0xFF).toByte()
value[2] = ((timestamp shr 24) and 0xFF).toByte()
value[3] = ((timestamp shr 16) and 0xFF).toByte()
value[4] = ((timestamp shr 8) and 0xFF).toByte()
value[5] = (timestamp and 0xFF).toByte()
value[6] = (value[6].toInt() and 0x0F or 0x70).toByte()
value[8] = (value[8].toInt() and 0x3F or 0x80).toByte()
return value
}
@JvmStatic
fun main(args: Array<String>) {
val uuidVal = generate()
uuidVal.forEach { b -> print("%02x".format(b)) }
println()
}
}기존의 UUID 명세인 RFC 4122가 2005년에 발표되었으니 꽤 오랜 시간이 흘렀는데요.
시간 순으로 정렬되어 데이터베이스 성능까지 고려한 새로운 표준(RFC 9652)의 등장은 정말 반가운 소식입니다.
앞으로 수년간 우리 개발자들의 든든한 도구가 되어줄 것 같네요.