UUIDv7로 바꿨더니 PostgreSQL 인덱스 I/O가 312배 줄었다 — B-tree 페이지 분할을 500배 줄이는 원리
솔직히 말하면, 저도 한동안 UUID는 그냥 uuid4() 한 번 호출하면 끝나는 문제라고 생각했습니다. 어차피 128비트 고유 값이고, DB에 넣으면 잘 굴러가니까요. 그런데 어느 날 운영 중인 서비스에서 인덱스 크기가 테이블 크기보다 커지는 상황을 마주쳤습니다. VACUUM을 돌려도 차도가 없었고, 원인을 파고들다 보니 UUID 삽입 패턴이 문제라는 걸 알게 됐습니다. 그러면서 자연스럽게 UUIDv7의 존재를 찾게 됐고, 직접 재현해봤더니 결과가 놀라웠습니다.
2024년 5월 IETF는 RFC 9562를 통해 UUIDv7을 공식 표준으로 확정했고, 2025년에는 PostgreSQL 18이 uuidv7() 함수를 네이티브로 탑재했습니다. 생태계가 이렇게 빠르게 움직이는 데는 이유가 있습니다. PostgreSQL 18, NVMe SSD 환경에서 1,000만 건 기준으로 측정했을 때, UUIDv7으로 전환하면 B-tree 인덱스 페이지 분할이 최대 500배 감소하고 인덱스 I/O가 312배 줄어듭니다. 왜 이런 수치가 나오는지, 그리고 오늘 바로 어떻게 적용할 수 있는지 풀어보겠습니다.
핵심 개념
UUID 두 버전, 숫자 하나가 이렇게 다릅니다
UUIDv4와 UUIDv7은 겉보기엔 비슷해 보이지만 내부 구조가 전혀 다릅니다.
UUIDv4 (완전 랜덤):
550e8400-e29b-41d4-a716-446655440000
^^^^^^^^ ^^^^ ^^^^ ^^^^ ^^^^^^^^^^^^
모두 랜덤 (122비트)
UUIDv7 (시간 정렬):
018fbe61-9f6a-7000-a47b-5c7962ab7f22
^^^^^^^^ ^^^^
Unix 타임스탬프(48비트, 밀리초) → 나머지는 랜덤(74비트)UUIDv7의 상위 48비트는 Unix 타임스탬프(밀리초 단위)입니다. 그 덕분에 생성된 순서대로 자연 정렬이 됩니다. 이게 왜 중요하냐면, 데이터베이스의 기본 인덱스 구조인 B-tree가 새 값을 어디에 삽입하느냐가 성능을 결정하기 때문입니다.
B-tree는 랜덤 삽입을 싫어합니다
B-tree 인덱스는 값을 정렬된 상태로 유지합니다. 새 레코드가 들어오면 해당 값이 위치해야 할 노드를 찾아서 끼워 넣습니다. 문제는 그 노드가 이미 꽉 차 있을 때 발생합니다.
페이지 분할(Page Split): B-tree의 리프 노드가 가득 찼을 때, 해당 노드를 반으로 나누어 새 노드를 만드는 작업. 분할 후 두 노드는 각각 절반만 채워진 상태가 됩니다.
UUIDv4는 완전 무작위값이라, 새로 삽입되는 ID가 인덱스의 어느 위치에 들어갈지 예측이 불가능합니다. 이미 닫힌(가득 찬) 노드 한가운데를 계속 비집고 들어가야 하는 상황이 반복됩니다. UUIDv7은 항상 인덱스의 가장 우측 끝에 새 값이 추가됩니다. 노드가 꽉 차면 오른쪽에 새 노드를 하나 더 만들면 그만입니다.
| 항목 | UUIDv4 | UUIDv7 |
|---|---|---|
| 삽입 위치 | 인덱스 전체에 랜덤 분산 | 항상 우측 리프 노드에 순차 추가 |
| 페이지 분할 (100만 건 기준) | 5,000~10,000+ 회 | 10~20 회 |
| 리프 페이지 채움률 | ~79% | ~97% |
| 인덱스 단편화 | 심각 | 최소 |
리프 페이지 채움률 79%는 랜덤 삽입 패턴의 PostgreSQL B-tree 인덱스에서 측정된 평균값으로, Ardent Performance Computing의 UUID 벤치마크 및 복수의 현업 측정 사례에서 일관되게 확인되는 수치입니다.
단편화가 쌓이면 I/O 폭발이 납니다
79%만 채워진 인덱스 리프 페이지가 수백만 개 쌓인다고 생각해보시면 됩니다. 같은 데이터를 읽기 위해 더 많은 페이지를 읽어야 합니다.
버퍼 풀(Buffer Pool): PostgreSQL이 디스크에서 읽은 페이지를 메모리에 캐싱하는 영역입니다. 인덱스 단편화가 심할수록 더 많은 페이지를 버퍼 풀에 올려야 하므로 메모리 효율도 함께 떨어집니다.
랜덤하게 분산된 인덱스 페이지가 많아질수록, 한 번의 조회를 위해 버퍼 풀에 올려야 하는 페이지 수가 폭발적으로 늘어납니다. 바로 이 지점에서 312배라는 수치가 나옵니다.
실전 적용
예시 1: 1,000만 건 벌크 삽입 벤치마크 — 수치로 직접 확인하기
원리 설명보다 수치가 더 설득력 있을 때가 있습니다. 처음 이 결과를 봤을 때 저도 계산을 잘못한 줄 알고 두 번 확인했습니다.
-- 테스트 환경: PostgreSQL 18.0, Apple M2 MacBook Pro, NVMe SSD
-- 테스트 준비
CREATE TABLE test_v4 (id UUID PRIMARY KEY, data TEXT);
CREATE TABLE test_v7 (id UUID PRIMARY KEY, data TEXT);
-- UUIDv4 벌크 삽입 (약 7분 이상 소요)
INSERT INTO test_v4 (id, data)
SELECT gen_random_uuid(), md5(random()::text)
FROM generate_series(1, 10000000);
-- UUIDv7 벌크 삽입 (약 3분 소요 — 약 2.3배 빠름)
INSERT INTO test_v7 (id, data)
SELECT uuidv7(), md5(random()::text)
FROM generate_series(1, 10000000);
-- 인덱스 I/O 비교 (버퍼 히트 수)
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM test_v4 WHERE id = '550e8400-e29b-41d4-a716-446655440000';
-- Buffers: 8,562,960 hit (UUIDv4)
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM test_v7 WHERE id = '018fbe61-9f6a-7000-a47b-5c7962ab7f22';
-- Buffers: 27,332 hit (UUIDv7) — 정수 PK와 유사한 수준8,562,960 ÷ 27,332 ≈ 313. 이게 바로 "312배"의 출처입니다. UUIDv7 쪽의 버퍼 히트 수가 정수 PK와 거의 동일한 수준이라는 점이 더 인상적입니다.
예시 2: PostgreSQL 18에서 UUIDv7 기본 키 설정
PostgreSQL 18부터는 애플리케이션 레이어에서 UUID를 생성할 필요 없이 DB 함수를 바로 사용할 수 있습니다.
-- PostgreSQL 18 이상
CREATE TABLE orders (
id UUID PRIMARY KEY DEFAULT uuidv7(),
user_id UUID NOT NULL,
total_amount NUMERIC(10, 2),
status VARCHAR(20)
);
-- 삽입 시 ID를 따로 지정하지 않아도 됩니다
INSERT INTO orders (user_id, total_amount, status)
VALUES ('018fbe61-9f6a-7000-a47b-5c7962ab7f22', 99.99, 'pending');
-- ID에서 생성 시각을 바로 추출할 수 있습니다
SELECT id, uuid_extract_timestamp(id) AS created_at
FROM orders
ORDER BY id DESC
LIMIT 10;uuid_extract_timestamp()는 PostgreSQL 18에서 새로 추가된 내장 함수입니다. created_at 컬럼을 별도로 관리하던 패턴을 ID 하나로 대체할 수 있습니다. 물론 타임스탬프 컬럼이 있는 쪽이 더 명시적이라 선호하는 팀도 있으니, 팀 컨벤션에 따라 선택해보시면 됩니다.
심화: 운영 중에 인덱스 상태를 확인하고 싶다면
pg_statio_user_indexes뷰를 활용해볼 수 있습니다. 각 인덱스의 버퍼 히트·미스 비율을 실시간으로 볼 수 있어서, UUID 타입 전환 전후 효과를 운영 환경에서도 수치로 확인하는 데 유용합니다.
예시 3: 애플리케이션 레이어에서 UUIDv7 생성 (PostgreSQL 17 이하 또는 MySQL)
PostgreSQL 18 이전 버전이거나 MySQL/MariaDB를 사용 중이라면 애플리케이션에서 직접 생성하는 방식을 활용할 수 있습니다.
// TypeScript — uuid 패키지 v9.0+
import { v7 as uuidv7 } from 'uuid';
// TypeORM과 함께 사용
// 클래스 프로퍼티 초기화 방식이 TypeORM v0.3+에서 가장 단순하게 동작합니다.
// @BeforeInsert()나 @Column({ default: () => ... }) 방식도 동작하지만,
// 이 패턴이 새 엔티티 생성 시점에 ID를 명확하게 부여합니다.
@Entity()
export class Order {
@PrimaryColumn('uuid')
id: string = uuidv7();
@Column()
userId: string;
}Python의 경우, 버전에 따라 사용 방법이 다릅니다.
# Python 3.14+ — 표준 라이브러리에 uuid7() 포함 (외부 패키지 불필요)
import uuid
order_id = uuid.uuid7()# Python 3.13 이하 — 서드파티 라이브러리 사용
# pip install uuid7
from uuid7 import uuid7
order_id = uuid7()// Go — github.com/google/uuid
import "github.com/google/uuid"
orderID, err := uuid.NewV7()
if err != nil {
return err
}장단점 분석
장점
| 항목 | 내용 |
|---|---|
| 인덱스 삽입 성능 | 순차 삽입으로 페이지 분할 최소화, UUIDv4 대비 최대 500배 적은 분할 |
| 저장 공간 | 리프 페이지 채움률 97%(UUIDv4 79%) → 인덱스 크기 약 20% 감소 |
| 읽기 I/O | 1,000만 건 기준 버퍼 히트 수 UUIDv4의 1/312 수준 |
| 시간 정렬 | ID만으로 생성 순서 파악 가능, ORDER BY id가 시간 순 정렬과 동일 |
| WAL 오버헤드 감소 | 순차 삽입으로 WAL 크기 감소, 복제·백업 부담 완화 |
| 디버깅 편의 | ID를 보고 대략적인 생성 시각 추론 가능 |
| 분산 고유성 | 중앙 조정 없이 전 세계 어디서든 충돌 확률 사실상 0 |
WAL (Write-Ahead Log): PostgreSQL이 데이터를 실제 파일에 쓰기 전에 변경 내역을 먼저 기록하는 로그. 랜덤 페이지 갱신이 많을수록 WAL 크기가 커지고, 복제 및 백업 부담이 늘어납니다.
단점 및 주의사항
| 항목 | 내용 | 대응 방안 |
|---|---|---|
| 타이밍 노출 | ID에 생성 시각이 포함되어 정보 유출 가능성 | 사용자 ID, 세션 토큰 등 보안 민감 도메인에는 UUIDv4 유지 |
| 분산 노드 정렬 | 같은 밀리초 내 다수 생성 시 노드 간 글로벌 정렬 미보장 | 단일 노드 내에서는 rand_a 카운터로 단조 증가 보장됨 |
| 레거시 마이그레이션 | 기존 UUIDv4 시스템 전환 시 인덱스 재구성 필요 | REINDEX CONCURRENTLY로 무중단 재구성 가능 |
| .NET 주의사항 | 일부 구현체에서 RFC 9562 정렬 보장 미흡 이슈 보고 | RFC 9562 준수 여부를 직접 검증하거나 UUIDNext 사용 권장 |
분산 노드 정렬 이슈는 실무에서 자주 간과되는 부분이라 조금 더 풀어볼게요. UUIDv7의 rand_a 필드(12비트)는 같은 밀리초 내에 생성되는 UUID들이 단조 증가하도록 카운터 역할을 합니다. 단일 노드 안에서는 이 덕분에 밀리초 이하 단위에서도 순서가 보장됩니다. 그런데 서버 A와 서버 B가 동시에 같은 밀리초에 UUID를 생성한다면, 각 노드의 rand_a 카운터는 독립적으로 동작하기 때문에 두 값 사이의 정렬 순서는 우연에 맡겨집니다. 대부분의 웹 서비스에서는 이 차이가 무시 가능한 수준이지만, 이벤트 순서가 엄밀해야 하는 분산 시스템(감사 로그, 금융 트랜잭션 등)에서는 고려해볼 필요가 있습니다.
실무에서 가장 흔한 실수
-
사용자 ID에도 UUIDv7을 적용하는 경우 — 생성 시각이 노출되어 계정 가입 시점이 외부에 드러날 수 있습니다. 주문 ID·상품 ID처럼 타이밍 노출이 무방한 도메인 엔티티와, 사용자 ID·세션 토큰·인증 코드처럼 보안이 중요한 식별자를 먼저 구분해보시는 것을 권장합니다.
-
마이그레이션 없이 컬럼 타입만 변경하는 경우 — 기존 UUIDv4 값이 남아 있는 상태에서 새 레코드만 UUIDv7으로 생성하면, 인덱스가 혼합 패턴이 되어 성능 이점이 반감됩니다. 전환 시
REINDEX CONCURRENTLY를 통해 인덱스를 재구성하는 과정이 필요합니다.REINDEX CONCURRENTLY는 인덱스를 재구성하는 PostgreSQL 명령어로, 서비스 중단 없이 실행할 수 있다는 점이 장점입니다. -
라이브러리 RFC 준수 여부를 확인하지 않는 경우 — 저도 처음에는 npm 검색 상위 결과에 나온 라이브러리를 그냥 골랐는데, .NET 환경에서 팀원이 정렬이 이상하다는 이슈를 올린 뒤에야 RFC 9562 준수 여부를 확인하는 습관이 생겼습니다. 특히 .NET에서
Guid.NewGuid()기반 UUIDv7 구현체 중 정렬 순서를 스펙대로 보장하지 않는 케이스가 보고된 바 있으니, 라이브러리를 고를 때 한 번쯤 확인해보시는 것을 권장합니다.
마치며
도메인 엔티티 식별자에 한해서, UUIDv7은 분산 고유성을 유지하면서 B-tree 인덱스 성능을 정수 PK 수준으로 끌어올릴 수 있는, 현재 시점에서 가장 현실적인 선택입니다.
지금 바로 시작해볼 수 있는 3단계입니다.
-
현재 프로젝트의 UUID 용도를 분류해보시면 좋습니다 — 주문 ID·상품 ID처럼 생성 시각 노출이 무방한 도메인 엔티티와, 세션 토큰·사용자 ID처럼 보안이 중요한 식별자를 구분하는 것부터 시작해볼 수 있습니다.
-
신규 테이블부터 UUIDv7을 적용해볼 수 있습니다 — PostgreSQL 18이라면
DEFAULT uuidv7()를 그대로 사용하시면 되고, 이전 버전이라면uuidnpm 패키지 v9.0+, Python 3.14+ 표준 라이브러리(이하 버전은uuid7패키지), Gogithub.com/google/uuid등을 통해 애플리케이션 레이어에서 생성하는 방식으로 시작해볼 수 있습니다. -
기존 테이블이 있다면
EXPLAIN (ANALYZE, BUFFERS)로 현재 인덱스 I/O를 먼저 측정해보시는 것을 권장합니다 — 전환 전후의 버퍼 히트 수를 비교하면 성능 개선 효과를 직접 수치로 확인할 수 있습니다.
참고 자료
- RFC 9562: Universally Unique IDentifiers (UUIDs) — IETF 공식 표준 문서
- UUID v7 in PostgreSQL 18 | Better Stack Community
- PostgreSQL UUID: Bulk insert with UUIDv7 vs UUIDv4 - DEV Community
- PostgreSQL UUID Performance: Benchmarking Random (v4) and Time-based (v7) UUIDs - DEV Community
- Why Random UUIDs (v4) Kill Database Performance: A Deep Dive into B-tree Index Issues
- UUID Benchmark War | Ardent Performance Computing
- UUIDv7 Comes to PostgreSQL 18 | Nile
- A Comparative Analysis of Identifier Schemes: UUIDv4, UUIDv7, and ULID for Distributed Systems (arXiv)
- Goodbye to sequential integers, hello UUIDv7! | Buildkite
- UUIDv7 in 33 languages | antonz.org
- UUIDv7 vs UUIDv8 (RFC 9562): production notes — .NET pitfalls