primary key를 UUID로 쓰기로 하면 대개 v4를 씁니다. 그런데 그 선택이 index를 얼마나 부풀리는지 숫자로 본 적은 없었어요. pgEdge의 Shaun Thomas(숀 토머스)가 The Time Traveler’s Primary Key에서 그 숫자를 냈고, PostgreSQL 18 컨테이너로 직접 재현해 봤습니다.

v4가 index를 흩어 놓는 방식

B-tree는 정렬된 구조입니다. 새 키가 들어오면 그 값이 들어갈 자리를 찾아 해당 leaf page에 씁니다.

시퀀스로 만든 키는 항상 오른쪽 끝으로 갑니다. 마지막 page를 채우고, 차면 새 page를 붙입니다. page 하나가 거의 꽉 찬 상태로 남습니다.

UUID v4는 값이 완전히 무작위입니다. 원문 표현대로 “생성된 각 값이 맨 첫 행보다 앞에 정렬될 확률과 맨 마지막 행보다 뒤에 정렬될 확률이 같습니다”. 그래서 삽입이 index 전체에 흩어집니다. 이미 꽉 찬 page 가운데에 값이 들어오면 page split이 일어나고, split된 두 page는 각각 절반씩만 채워집니다. 같은 개수의 키를 담는 데 page가 두 배로 필요해집니다.

flowchart TD
    A[새 키 삽입] --> B{키 순서}
    B -->|시퀀스, v7| C[오른쪽 끝 page]
    C --> D[page 꽉 채워 사용]
    B -->|v4 무작위| E[임의 위치 page]
    E --> F[page split 발생]
    F --> G[두 page 절반씩 채움]

UUID v7은 상위 48비트에 밀리초 단위 Unix 타임스탬프를 최상위 비트부터 담습니다. version 정보 뒤에 12비트를 더해 밀리초 이하 정밀도까지 표현합니다. 남는 약 62비트가 무작위입니다. 결과적으로 나중에 만든 값이 앞서 만든 값보다 크게 정렬됩니다. 시퀀스와 같은 삽입 패턴이 됩니다.

직접 재 봤다

PostgreSQL 18에 uuidv7()uuidv4()가 내장되어 있습니다. 기본 설정 컨테이너로 세 가지 테이블에 100만 행씩 넣었습니다.

create table t_bigint (id bigserial primary key, payload text);
create table t_v4 (id uuid primary key, payload text);
create table t_v7 (id uuid primary key, payload text);

insert into t_bigint (payload) select 'x' from generate_series(1,1000000);
insert into t_v4 (id,payload) select uuidv4(),'x' from generate_series(1,1000000);
insert into t_v7 (id,payload) select uuidv7(),'x' from generate_series(1,1000000);

크기를 뽑았습니다.

 relname  | heap  |  idx
----------+-------+-------
 t_bigint | 42 MB | 21 MB
 t_v4     | 50 MB | 37 MB
 t_v7     | 50 MB | 30 MB

원문이 낸 수치가 v4 index 38MB, v7 30MB였는데 거의 그대로 나왔습니다. index만 놓고 보면 v7이 v4보다 19% 작습니다.

이유를 직접 확인하려면 pgstattuplepgstatindex()를 봅니다.

select 'v4' as k, avg_leaf_density, leaf_fragmentation from pgstatindex('t_v4_pkey')
union all
select 'v7', avg_leaf_density, leaf_fragmentation from pgstatindex('t_v7_pkey')
union all
select 'bigint', avg_leaf_density, leaf_fragmentation from pgstatindex('t_bigint_pkey');
   k    | avg_leaf_density | leaf_fragmentation
--------+------------------+--------------------
 v4     |            72.61 |              49.86
 v7     |            89.98 |                  0
 bigint |            90.01 |                  0

여기가 이 실험에서 가장 선명한 부분입니다. v4의 leaf 밀도가 72.61%이고 단편화가 49.86%입니다. v7은 밀도 89.98%에 단편화 0입니다. bigserial의 90.01%와 사실상 같습니다. v7의 삽입 패턴이 시퀀스와 구분되지 않는다는 뜻입니다.

원문 수치가 71.53%와 49.89%, 89.98%와 0.00%였으니 소수점 자리까지 맞았습니다.

heap은 줄지 않는다

표에서 놓치기 쉬운 칸이 heap입니다. t_bigint가 42MB, UUID 두 개는 50MB로 같습니다. UUID는 128비트, bigint는 64비트니까 행마다 8바이트가 더 붙습니다. v7으로 바꿔도 이 차이는 그대로입니다.

정리하면 v7이 개선하는 것은 index의 공간 효율과 삽입 시 page split이고, 값 자체의 크기는 아닙니다. bigint와 UUID 사이의 선택은 여전히 별개 문제입니다. 원문도 이 부분을 분명히 합니다. 데이터가 단일 노드에 있고, 하나의 primary 인스턴스가 키를 발급하고, 키가 외부로 돌아다니지 않는다면 BIGINT GENERATED ALWAYS AS IDENTITY가 최선이라고 적었습니다. 위 실측에서도 bigint index가 21MB로 v7의 70% 수준입니다.

타임스탬프가 공개된다

v7의 대가는 성능이 아니라 정보입니다. 상위 비트가 생성 시각이라서 값에서 그대로 읽힙니다. PostgreSQL 18은 그 추출 함수를 내장으로 제공합니다.

select id, uuid_extract_timestamp(id) from t_v7 limit 2;
01a03337-728f-7bf2-bdf8-69d062bfa627|2026-08-24 10:01:06.959+00
01a03337-7290-7767-bc60-69fb35dd6c00|2026-08-24 10:01:06.96+00

버전도 확인됩니다.

select uuid_extract_version(id) from t_v7 limit 1;   -- 7

이게 뜻하는 바가 셋입니다. 키를 받은 쪽이 그 레코드의 생성 시각을 밀리초 단위로 압니다. 키 두 개를 비교하면 그 사이 생성 간격을 알 수 있어서 생성 속도가 측정됩니다. 그리고 값이 시간순으로 인접하니 이웃 키를 추측해 보는 것이 무작위 값보다 훨씬 그럴듯해집니다.

주문 번호나 사용자 ID를 URL에 노출하는 구조라면 이 세 가지가 실제 문제가 됩니다. 가입 시각, 하루 주문 건수, 경쟁사 성장 속도가 키에서 읽힙니다. 원문도 생성 시각과 삽입 속도가 비공개여야 하는 경우에는 v4가 여전히 적합하다고 적었습니다.

오른쪽 끝이 경합 지점이 된다

또 하나 짚어 둘 만한 대가가 있습니다. v7의 삽입이 index 오른쪽 끝으로 몰린다는 것은 곧 그 page가 모든 writer의 경합 지점이 된다는 뜻입니다.

샤딩된 환경이나 병렬 쓰기가 많은 구성에서는 이게 실제 병목이 될 수 있습니다. v4는 흩어져서 비효율적인 대신, 쓰기를 고르게 분산합니다. 단일 노드에서 순차 삽입이 주된 패턴이라면 v7이 유리하고, writer가 많고 오른쪽 끝 경합이 이미 보이는 환경이라면 계산이 달라집니다.

어떻게 고를까

세 선택지의 성격을 정리하면 이렇습니다.

index 효율분산 생성생성 시각 비공개크기
bigserial / identity가장 좋음 (21MB)어려움유지8바이트
UUID v4나쁨 (37MB, 단편화 50%)지원유지16바이트
UUID v7좋음 (30MB, 단편화 0)지원노출16바이트

이미 UUID v4를 쓰고 있고 분산 생성이 필요해서 그렇게 한 것이라면, v7으로 옮기는 건 대체로 이득입니다. index가 작아지고 page split이 사라지는 대신 잃는 것은 생성 시각의 비공개성뿐입니다. 그 시각이 민감하지 않다면 바꿀 이유가 충분합니다.

반대로 UUID를 쓰는 이유가 “ID를 추측할 수 없게 하려고"였다면 v7은 그 목적과 충돌합니다. v7의 무작위 비트가 62비트라 값 자체를 맞히기는 여전히 어렵지만, 시간순 인접성 때문에 열거 시도의 탐색 공간이 좁아집니다.

새로 만드는 테이블이고 단일 노드라면 bigint가 답입니다. 위 실측에서 index가 v7의 70%, heap이 84%였고 그게 전부 실제 I/O입니다. UUID를 고르는 이유는 성능이 아니라 분산 생성이나 외부 노출이니까요.

참고