KSUID와 UUIDv7을 동시에 지원하는 경량 UID 생성기: libchronoid 이야기
by Justin Kim
Datalog 엔진을 개발하던 중, 목적에 맞는 ID 생성기가 필요해 직접 구현하게 되었습니다. 단순 난수 기반으로 중복만 피하는 것이 아니라, 생성 순서를 보존하면서 DB 정렬 성능에 악영향을 주지 않아야 했습니다. 동시에 외부 시스템과의 호환성도 중요했습니다. 기존 UID 라이브러리들은 C 외부 의존성이 많아 임베디드 환경이나 서버 확장 모듈에 넣기 부담스러웠기 때문에, 결국 C11 규격 안에서 완전히 통제 가능한 경량 UID 생성기를 직접 만들기로 결정했습니다.
처음에는 KSUID만 지원하려고 했지만, 외부 API나 로그 시스템과 연동하다 보니 범용적인 UUIDv7의 필요성을 느꼈습니다. 어차피 두 형식 모두 타임스탬프 기반이므로, 하나의 라이브러리에서 같이 제공하는 편이 합리적이었습니다.
이 글에서는 libchronoid를 설계하면서 고민했던 부분들과 대량 ID 생성 성능을 끌어올리기 위해 SIMD/NEON을 적용한 과정을 정리해 보았습니다.
왜 KSUID와 UUIDv7을 같이 쓸까요?
KSUID는 20바이트 크기에 4바이트 타임스탬프와 16바이트 페이로드를 담습니다. 타임스탬프가 맨 앞에 위치해 생성 순서대로 정렬하기 좋습니다. 반면 UUIDv7은 2021년 이후 표준화된 시간 기반 UUID로, 128비트 레이아웃 안에 타임스탬프와 랜덤 값을 섞어 넣습니다.
굳이 두 가지를 모두 구현한 이유는 다음과 같습니다.
- 내부 저장소 인덱싱이나 로그 추출 시에는 정렬이 보장되는
KSUID가 압도적으로 편합니다. - 하지만 외부 서비스나 클라이언트와 통신할 때는 범용 표준인
UUIDv7을 내려주는 것이 호환성 면에서 낫습니다. - 두 포맷 모두 근본적으로는 ‘시간’을 기반으로 하므로, 타임스탬프 원천(source-of-truth)만 동일하게 유지하면 사실상 같은 ID나 다름없습니다.
결론적으로, 내부 식별자로는 KSUID를 사용하되 외부 API 연동 시에는 UUIDv7을 제공하는 투트랙 전략이 가장 합리적이라고 판단했습니다.
라이브러리 설계 목표: 작고 가볍게
GitHub 저장소: semantic-reasoning/libchronoid
라이브러리를 설계하며 세운 원칙은 단순했습니다.
- 외부 의존성 완전 배제
- 모듈식 C11 구조 (19개 C 파일, 15개 헤더, 형식별 독립 구현)
- 동적 메모리 할당 최소화
- 범용 컴파일러 호환 (GCC/Clang, MSVC)
- SIMD 가속은
bulk연산에만 선택적으로 적용
KSUID와 UUIDv7 로직을 디렉토리 레벨에서 명확히 분리하여, 필요한 모듈만 떼어내 소스 레벨에서 통합 빌드할 수 있게 구성했습니다. 무거운 동적 라이브러리(.so/.dll)를 따로 관리할 필요가 없어서, C 확장이 필요한 서버 프레임워크나 리소스가 빡빡한 임베디드 환경에서도 부담 없이 가져다 쓸 수 있습니다.
내부 레이아웃: 16바이트 단위 정렬
KSUID의 기본 구조는 다음과 같습니다.
uint32_t timestamp: 생성 시각 (초)uint8_t payload[16]: 임의 데이터
UUIDv7 구조는 다음과 같습니다.
uint64_t time_hi: 48비트 타임스탬프 + 버전uint64_t time_lo: 시퀀스/랜덤
이 두 구조 모두 16바이트 단위로 정렬 가능하고, 대량 생성 시 128비트 로드/스토어를 활용하기 유리합니다.
typedef struct {
uint32_t timestamp_be;
uint8_t payload[16];
} ksuid_t;
typedef struct {
uint64_t hi;
uint64_t lo;
} uuid7_t;
여기서 timestamp_be를 big-endian으로 저장하는 이유는 정렬 순서를 메모리 레이아웃과 일치시키기 위해서입니다. 이렇게 하면 CPU 아키텍처(Endianness)와 무관하게 언제나 동일한 정렬 결과를 보장할 수 있습니다.
SIMD 적용: 포맷팅과 검증 경로에서 성능 최적화
libchronoid의 SIMD 최적화는 ID 생성이 아니라 포맷팅(문자열 변환)과 검증에 집중했습니다. 생성 함수(chronoid_ksuid_new(), chronoid_uuidv7_new())는 스칼라 코드로 유지하고, 수천 개의 ID를 한 번에 직렬화하는 벌크 함수(chronoid_ksuid_string_batch(), chronoid_uuidv7_string_batch())에서 SIMD로 성능을 끌어올렸습니다.
그 이유는 다음과 같습니다.
- 생성 경로: 단일 ID 생성 자체는 스칼라 연산만으로도 충분히 가볍고 빨라야 합니다.
- 포맷팅 경로: 병목은 주로 대규모 데이터를 문자열로 직렬화하거나 역직렬화할 때 발생하므로, 여기서 SIMD 효과가 가장 크게 나타납니다.
KSUID: base62 포맷팅과 입력 검증
포맷팅 (base62 인코딩): x86_64의 AVX2 8-wide 커널
- 파일:
chronoid/ksuid/encode_avx2.c - 기법: Granlund-Möller 나눗셈 (floor reciprocal multiply divide-by-62)
- 8개의 KSUID를 병렬로 base62로 인코딩하여 27자 문자열 생성
/* AVX2 8-wide chronoid_ksuid_string_batch 커널
* 기법: Granlund-Möller multiply-high를 활용한 나눗셈
* 각 KSUID 20바이트를 5개의 32비트 limb로 분해
* 벡터화된 64비트 곱셈-고위수(mulhi64)로 base62 변환
*/
static inline __m256i
chronoid_ksuid_mulhi64_avx2 (__m256i a, __m256i b) {
// 64x64 → 128 multiply-high, 4-lane wide
// Schoolbook 방식: a*b의 상위 64비트를 벡터로 계산
__m256i ll = _mm256_mul_epu32 (a, b); // low*low
__m256i lh = _mm256_mul_epu32 (a, b_hi); // low*high
__m256i hl = _mm256_mul_epu32 (a_hi, b); // high*low
__m256i hh = _mm256_mul_epu32 (a_hi, b_hi); // high*high
// ... 중간 항 누적으로 최종 high 계산
}
입력 검증: ARM NEON과 SSE2 16-byte 병렬 검증
- 파일:
chronoid/ksuid/base62_neon.c,chronoid/ksuid/base62_sse2.c - 기법: 3가지 범위 테스트 (0-9, A-Z, a-z)를 병렬 수행
- 16개 base62 문자를 동시에 검증해 파싱 속도 향상
/* ARM NEON base62 16-byte translate-and-validate
* 파일: chronoid/ksuid/base62_neon.c
*/
int chronoid_base62_translate16_neon (uint8_t out[16], const uint8_t in[16]) {
uint8x16_t v = vld1q_u8 (in);
// 범위 1: 0-9 → 0-9
uint8x16_t d = vsubq_u8 (v, vdupq_n_u8 ('0'));
uint8x16_t d_mask = vcleq_u8 (d, vdupq_n_u8 (9));
uint8x16_t d_val = vandq_u8 (d_mask, d);
// 범위 2: A-Z → 10-35
uint8x16_t u = vsubq_u8 (v, vdupq_n_u8 ('A'));
uint8x16_t u_mask = vcleq_u8 (u, vdupq_n_u8 (25));
uint8x16_t u_val = vandq_u8 (u_mask, vaddq_u8 (u, vdupq_n_u8 (10)));
// 범위 3: a-z → 36-61
uint8x16_t l = vsubq_u8 (v, vdupq_n_u8 ('a'));
uint8x16_t l_mask = vcleq_u8 (l, vdupq_n_u8 (25));
uint8x16_t l_val = vandq_u8 (l_mask, vaddq_u8 (l, vdupq_n_u8 (36)));
// 결과 병합: 16개 문자를 한 번에 처리
uint8x16_t values = vorrq_u8 (vorrq_u8 (d_val, u_val), l_val);
vst1q_u8 (out, values);
return 0;
}
UUIDv7: Hex 포맷팅
포맷팅: x86_64의 SSSE3/AVX2 커널
- 파일:
chronoid/uuidv7/hex_ssse3.c,chronoid/uuidv7/hex_avx2.c - 기법: PSHUFB (Packed Shuffle Bytes) 니블→ASCII LUT
- SSSE3: 1 UUID당 한 번, AVX2: 4 UUID 병렬 처리
/* AVX2 4-wide chronoid_uuidv7_string_batch 커널
* 파일: chronoid/uuidv7/hex_avx2.c
* 전략: 4 UUID (64 바이트) → 144 hex chars (36*4)
* VPSHUFB로 니블 추출 후 LUT 변환, 하이픈 삽입
*/
static inline void
chronoid_uuidv7_hex32_avx2 (char out64[64], const uint8_t *in32) {
static const uint8_t kHexLowerLut[16] = {
'0', '1', '2', '3', '4', '5', '6', '7',
'8', '9', 'a', 'b', 'c', 'd', 'e', 'f',
};
// 32 바이트 (2 UUID) 로드
__m256i v = _mm256_loadu_si256 ((const __m256i *) in32);
// LUT를 두 128-bit 레인에 broadcast
__m256i lut = _mm256_broadcastsi128_si256 (
_mm_loadu_si128 ((const __m128i *) kHexLowerLut)
);
// 니블 추출: 각 바이트에서 상위/하위 4비트를 분리
__m256i mask_low = _mm256_set1_epi8 (0x0F);
__m256i lo = _mm256_and_si256 (v, mask_low);
__m256i hi = _mm256_and_si256 (_mm256_srli_epi16 (v, 4), mask_low);
// VPSHUFB로 LUT 변환 (각 128-bit 레인 독립)
__m256i lo_hex = _mm256_shuffle_epi8 (lut, lo);
__m256i hi_hex = _mm256_shuffle_epi8 (lut, hi);
// 결과를 interleave하여 출력 (상위 니블이 먼저)
// hi_hex | lo_hex → "0A" "0B" ... 형태로 저장
}
성능 특성 요약
| 연산 | 포맷 | 스칼라 | SIMD 지원 |
|---|---|---|---|
| KSUID 인코딩 | base62 | 1 ID | 8-wide AVX2 (x86_64만) |
| KSUID 파싱 | base62 범위 테스트 | 1 ID | 16-byte NEON/SSE2 (ARM/x86 모두) |
| UUIDv7 인코딩 | hex | 1 ID | 4-wide AVX2, 1-wide SSSE3 (x86_64만) |
결론적으로 SIMD 최적화를 생성 로직 본체에 억지로 밀어 넣지 않고, 문자열을 다루는 직렬화/검증 레이어에만 적용되도록 관심사를 분리했습니다. 단일 ID 생성기 자체는 외부 의존성 없이 최대한 담백하게 두는 편이 맞다고 판단했습니다.
실제 사용: KSUID와 UUIDv7 생성하기
KSUID 생성
가장 간단한 형태는 현재 시간으로 KSUID를 생성하고 문자열로 변환하는 것입니다.
#include <chronoid/ksuid.h>
#include <stdio.h>
int main(void) {
chronoid_ksuid_t ksuid;
// 현재 시간으로 새 KSUID 생성
if (chronoid_ksuid_new(&ksuid) != CHRONOID_KSUID_OK) {
fprintf(stderr, "Failed to generate KSUID\n");
return 1;
}
// 27자 base62 문자열로 포맷팅
char s[CHRONOID_KSUID_STRING_LEN + 1];
chronoid_ksuid_format(&ksuid, s);
s[CHRONOID_KSUID_STRING_LEN] = '\0';
printf("Generated KSUID: %s\n", s);
// Output: Generated KSUID: 0ujtsYcgvSTl8PAuAdqWYSMnLOv
return 0;
}
생성된 KSUID에서 타임스탬프 정보를 추출할 수도 있습니다.
// KSUID에서 Unix 시간(초) 추출
int64_t unix_seconds = chronoid_ksuid_time_unix(&ksuid);
printf("Generated at: %lld\n", unix_seconds);
UUIDv7 생성
UUIDv7도 유사한 인터페이스를 제공합니다.
#include <chronoid/uuidv7.h>
#include <stdio.h>
int main(void) {
chronoid_uuidv7_t uuid;
// 현재 시간으로 새 UUIDv7 생성
if (chronoid_uuidv7_new(&uuid) != CHRONOID_UUIDV7_OK) {
fprintf(stderr, "Failed to generate UUIDv7\n");
return 1;
}
// 36자 정규 하이픈 형식으로 포맷팅
char s[CHRONOID_UUIDV7_STRING_LEN + 1];
chronoid_uuidv7_format(&uuid, s);
s[CHRONOID_UUIDV7_STRING_LEN] = '\0';
printf("Generated UUIDv7: %s\n", s);
// Output: Generated UUIDv7: 019de2b2-7c56-7e59-98be-2ed3cffbd12e
return 0;
}
UUIDv7에서 밀리초 단위 타임스탬프도 손쉽게 추출할 수 있습니다.
// UUIDv7에서 Unix 시간(밀리초) 추출
int64_t unix_ms = chronoid_uuidv7_unix_ms(&uuid);
printf("Generated at: %lld ms\n", unix_ms);
대량 생성: 벌크 포맷팅
한 번에 많은 ID를 생성하고 포맷팅할 때는 벌크 경로를 사용합니다. 이때 SIMD 최적화가 제 몫을 톡톡히 합니다.
#include <chronoid/ksuid.h>
#include <stdlib.h>
#include <stdio.h>
int main(void) {
size_t count = 1000;
// 1. 많은 KSUID 생성
chronoid_ksuid_t *ids = malloc(count * sizeof(chronoid_ksuid_t));
for (size_t i = 0; i < count; i++) {
chronoid_ksuid_new(&ids[i]);
}
// 2. 벌크 포맷팅 (SIMD 최적화됨)
// 각 KSUID는 27바이트를 차지하고, NUL 종료자는 없음
char *out = malloc(count * CHRONOID_KSUID_STRING_LEN);
chronoid_ksuid_string_batch(ids, out, count);
// 3. 결과 출력
for (size_t i = 0; i < 10; i++) { // 처음 10개만 출력
printf("%.*s\n", CHRONOID_KSUID_STRING_LEN,
out + i * CHRONOID_KSUID_STRING_LEN);
}
free(out);
free(ids);
return 0;
}
이 방식은 일반적인 스칼라 루프보다 훨씬 빠릅니다. x86_64 AVX2 환경에서는 체감상 8배 가까운 성능 향상을 보여주었습니다.
UUIDv7 생성: 메모리 레이아웃과 호환성
UUIDv7은 내부적으로 48비트 타임스탬프와 74비트 랜덤/시퀀스 영역으로 나뉩니다. libchronoid에서는 uuid7_t를 이렇게 정의했습니다.
typedef union {
uint8_t bytes[16];
struct {
uint64_t hi;
uint64_t lo;
} parts;
} uuid7_t;
생성 과정은 다음과 같습니다.
- 타임스탬프를 48비트로 계산
- 버전 필드(0x7)를 제 위치에 삽입
- 남은 공간을 랜덤 또는 시퀀스 바이트로 채움
- 네트워크 바이트 오더(Big-Endian)로 변환
여기서도 벌크 경로는 큰 차이를 만들어냅니다. 타임스탬프와 버전 비트 삽입을 16바이트 청크 단위로 한 번에 밀어버릴 수 있기 때문입니다.
static inline void set_uuid7_timestamp(uuid7_t* u, uint64_t ts_ms) {
uint64_t top = (ts_ms & 0xFFFFFFFFFFFFULL) << 16;
top |= 0x7000ULL; // 버전 7
u->parts.hi = htobe64(top | ((uint64_t)rand() & 0xFFFF));
}
이후 lo 필드는 그대로 랜덤/시퀀스 값으로 채우고 htobe64()로 네트워크 바이트 오더를 맞춥니다. 이렇게 메모리 상에 배치해 두면, 외부 시스템에서 UUID를 다룰 때 기대하는 표준 바이트 배열 포맷과 정확히 일치하게 됩니다.
코어 스택 지향: 의존성을 덜어내다
이 라이브러리를 만들면서 지키고자 했던 철학은, 불필요한 기능까지 엮인 ‘풀 스택’ 프레임워크가 아니라 필요한 기능만 쏙 빼서 쓸 수 있는 ‘코어 스택’을 제공하는 것이었습니다.
- 헤더에는 작은 구조체와 함수 선언만 노출
- 난수 생성은
xoroshiro128+또는xorshift같은 가벼운 알고리즘 채택 - 무거운 외부 암호화(Crypto) 라이브러리 배제
- SIMD 관련 코드는 매크로로 분리해 필요 없을 땐 빌드에서 제외 가능
실제로 libchronoid를 -Os 옵션으로 빌드하면 바이너리 크기가 10KB 안팎에 불과합니다. 벌크 연산 루틴마저 제외하면 실행 코드는 훨씬 더 가벼워집니다.
실무 적용 후기
이 라이브러리를 실제 Datalog 엔진에 연동해 보며 느낀 점은 다음과 같습니다.
- ID 생성이 병목이 되는 경우는 드물었습니다. 보통은 네트워크나 DB I/O에서 먼저 막힙니다. 하지만 데이터를 Batch Insert로 한꺼번에 밀어 넣을 때는 SIMD로 최적화된 포맷팅 로직 덕분에 직렬화 오버헤드가 눈에 띄게 줄어들었습니다.
- UUIDv7 지원은 신의 한 수였습니다. 내부적으로 KSUID가 아무리 편하더라도, 로그 수집기나 메시지 큐 등 외부 시스템과 연동할 때는 다들 표준 UUID 포맷을 기대합니다. 이를 기본 지원하니 연동 과정에서 겪는 호환성 스트레스가 사라졌습니다.
- 내부 인덱싱에는 역시 KSUID가 깡패입니다. 앞부분 4바이트가 타임스탬프라 굳이 복잡하게 쿼리하지 않아도 시간순 정렬이 보장됩니다. DB에 인덱스 태우거나 시계열 히트맵을 뽑아낼 때 정말 편했습니다.
마치며
시스템 아키텍처를 설계할 때 억지로 단일 ID 포맷만 고집할 필요는 없습니다. 목적에 맞게 내부용으론 KSUID를, 외부 인터페이스용으론 UUIDv7을 제공하는 유연한 접근이 실무에서는 훨씬 유리했습니다.
또한 무거운 라이브러리를 통째로 끌어다 쓰는 대신, 필요한 핵심 로직만 C11로 가볍게 구현해 내재화한 덕분에 장기적인 유지보수 측면에서도 골칫거리를 덜어낸 기분입니다.
릴리즈 계획
현재 libchronoid 은 1.0.1 상태입니다. 이미 두 포맷 모두 충분히 안정화되었고 CI/CD 커버리지도 든든하게 갖추었습니다. 2026-05-02부로 1.0 stable 릴리즈 하였습니다.
더 알아보기
- GitHub 저장소: https://github.com/semantic-reasoning/libchronoid
- 라이선스: LGPL-3.0-or-later (KSUID 부분 MIT 호환)
- 현재 버전: 1.0.1 (stable)
Subscribe via RSS
Comments