PostgreSQL 18의 `uuidv7()`와 `uuid_extract_timestamp()`로 `created_at` 없이 시계열 쿼리 속도를 44% 높이는 법
테이블을 설계할 때 id UUID PRIMARY KEY DEFAULT gen_random_uuid()를 써놓고, 바로 아래에 created_at TIMESTAMPTZ NOT NULL DEFAULT now()를 자동으로 추가해본 경험이 있을 겁니다. 저도 수년간 그렇게 해왔고, 심지어 팀 스키마 템플릿에 아예 박아두기도 했습니다. 그런데 PostgreSQL 18이 uuidv7()를 네이티브 함수로 제공하면서 이 관행을 재검토할 이유가 생겼습니다.
UUIDv7은 상위 48비트에 Unix 에포크 밀리초 타임스탬프를 품고 있어서, ID 값 자체가 이미 생성 시각을 담고 있습니다. 즉 created_at 컬럼이 없어도 id 컬럼 하나로 기본키 조회와 시계열 범위 필터를 동시에 처리할 수 있습니다. 이 글에서는 uuidv7()의 내부 구조부터, 인덱스를 완전히 활용하는 범위 쿼리 패턴, uuid_extract_timestamp()로 생성 시각을 꺼내는 방법, 그리고 직접 실수하면서 배운 함정 세 가지를 함께 공유합니다.
핵심 개념
UUIDv7의 구조 — 타임스탬프가 내장된 ID
참고: PostgreSQL 18은 2025년 9월 정식 출시 예정이며, 지금 당장은
pg_uuidv7확장이나 Neon 같은 PostgreSQL 18 기반 플랫폼에서 미리 경험해볼 수 있습니다.
RFC 9562(2024년 5월 확정)로 공식 표준이 된 UUIDv7은 128비트를 이렇게 나눠 씁니다.
UUIDv7 구조 (128비트)
├── [0-47] 48비트 Unix 에포크 타임스탬프 (밀리초)
├── [48-59] 12비트 서브밀리초 분수 (단조증가 보장)
├── [60-63] 4비트 버전 (0111 = 7)
├── [64-65] 2비트 배리언트
└── [66-127] 62비트 랜덤상위 48비트가 밀리초 타임스탬프이기 때문에 사전순(lexicographic)으로 정렬하면 곧 시간순 정렬이 됩니다. 여기에 PostgreSQL 18은 12비트 서브밀리초 분수를 추가해, 같은 세션에서 동일 밀리초 안에 여러 UUID를 생성해도 순서가 뒤집히지 않는 단조증가(monotonicity) 를 보장합니다.
단조증가(monotonicity) 란 새로 생성된 값이 항상 이전 값보다 크거나 같음을 보장하는 성질입니다. B-tree 인덱스 관점에서는 삽입 위치가 항상 오른쪽 끝에 가까워 페이지 분할(page split)과 단편화를 최소화합니다. UUIDv4는 완전 랜덤이라 삽입 때마다 B-tree 어디든 비집고 들어가야 하는 것과 대조적입니다.
수백만 건 규모 테이블 벤치마크에서 UUIDv4 대비 쿼리 실행 시간이 약 44% 단축되고 버퍼 히트 수도 101에서 3으로 줄었다는 결과가 있습니다. 데이터 규모와 하드웨어에 따라 차이는 있지만, 방향성은 분명합니다.
uuid_extract_timestamp() — ID에서 생성 시각 꺼내기
SELECT
uuidv7() AS new_uuid,
CURRENT_TIMESTAMP AS actual_time,
uuid_extract_timestamp(uuidv7()) AS extracted_time;기존에
uuid_extract_timestamp()는 UUID v1 전용이었습니다. PostgreSQL 18에서 v7까지 지원 범위가 넓어지면서 실용성이 크게 올라갔습니다. v7이 아닌 UUID를 전달하면NULL을 반환합니다.
기존 UUIDv4 데이터와 섞인 환경에서는 uuid_extract_version(id) = 7 조건을 먼저 확인해두는 것이 안전합니다. 여러분도 레거시 테이블에서 uuid_extract_timestamp()를 불러봤다가 NULL 범벅이 된 결과를 마주한 경험, 한 번쯤 있지 않으신가요?
실전 적용
예시 1: created_at 없는 테이블 설계
솔직히 처음 이 패턴을 봤을 때 "컬럼 하나 줄이는 게 뭐가 그리 대수야"라는 생각이 들었습니다. 그런데 실제로 계산해보면, 컬럼 하나가 사라지면서 그 컬럼의 인덱스까지 같이 사라집니다. 쓰기 트랜잭션마다 두 인덱스를 갱신하던 부담이 하나로 줄어드는 셈입니다.
-- 기존 패턴: PK 인덱스 + created_at 인덱스, 두 개 유지 필요
CREATE TABLE events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
payload JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_events_created_at ON events(created_at);
-- PostgreSQL 18 패턴: id 컬럼의 B-tree 인덱스 하나로 충분
CREATE TABLE events (
id UUID PRIMARY KEY DEFAULT uuidv7(),
payload JSONB
);| 항목 | 기존 패턴 | UUIDv7 패턴 |
|---|---|---|
| 컬럼 수 | 3개 (id, payload, created_at) | 2개 (id, payload) |
| 인덱스 수 | 2개 (PK + created_at) | 1개 (PK) |
| 쓰기 오버헤드 | 두 인덱스 갱신 | 한 인덱스 갱신 |
| 시계열 정렬 | ORDER BY created_at |
ORDER BY id |
예시 2: 시계열 범위 쿼리와 인덱스 활용
앞서 만든 테이블에서 시계열 범위 조회를 해보면 바로 함정이 보입니다. 가장 먼저 시도하게 되는 방식이 이렇습니다.
-- 직관적이지만 인덱스를 못 쓰는 방식 (주의)
SELECT id, uuid_extract_timestamp(id) AS ts, payload
FROM events
WHERE uuid_extract_timestamp(id) >= NOW() - INTERVAL '7 days'
ORDER BY id DESC
LIMIT 100;저도 처음엔 이 패턴으로 쿼리를 짰다가 EXPLAIN ANALYZE를 보고 멈칫한 경험이 있습니다. uuid_extract_timestamp(id)를 WHERE 조건에 직접 쓰면 함수 기반 표현식이 되어 인덱스 스캔 대신 Sequential Scan이 발생합니다.
-- EXPLAIN ANALYZE 출력 (인덱스 미사용)
Seq Scan on events (cost=0.00..3541.00 rows=333 width=48)
Filter: (uuid_extract_timestamp(id) >= (now() - '7 days'::interval))인덱스를 완전히 활용하려면 타임스탬프를 UUID 경계값으로 변환하는 헬퍼 함수를 만드는 것을 권장합니다.
이 함수가 하는 일을 한 문장으로 요약하면, 타임스탬프를 해당 밀리초에서 가능한 가장 작은 UUIDv7 값으로 변환하는 것입니다.
CREATE OR REPLACE FUNCTION uuid_from_timestamp(ts TIMESTAMPTZ)
RETURNS UUID AS $$
DECLARE
-- EXTRACT(EPOCH)는 부동소수점을 반환하므로 ::BIGINT로 명시적 캐스팅
ms BIGINT := (EXTRACT(EPOCH FROM ts) * 1000)::BIGINT;
hex TEXT;
BEGIN
hex := lpad(to_hex(ms), 12, '0')
-- 버전 비트 0111(=7) → hex '7', 이후 12비트는 최솟값 '000'
|| '7000'
-- 배리언트 최솟값 10000000 → hex '80', 이후 8비트는 최솟값 '00'
|| '8000'
|| '000000000000';
RETURN (
substring(hex, 1, 8) || '-' ||
substring(hex, 9, 4) || '-' ||
substring(hex, 13, 4) || '-' ||
substring(hex, 17, 4) || '-' ||
substring(hex, 21)
)::UUID;
END;
$$ LANGUAGE plpgsql IMMUTABLE;IMMUTABLE로 선언하면 쿼리 플래너가 상수 폴딩(constant folding)을 적용해 성능 면에서도 유리합니다.
한 가지 주의할 점이 있습니다. 이 함수는 해당 밀리초의 가장 작은 UUIDv7 값을 만들기 때문에, 상한에도 똑같이 쓰면 경계 조건 버그가 생깁니다. uuid_from_timestamp('2025-01-31 23:59:59+00')은 그 밀리초의 최솟값 UUID를 반환하므로, 같은 밀리초에 생성된 UUID 중 랜덤 비트가 0보다 큰 값이 범위에서 빠져버립니다. 상한은 다음 경계를 여는 방식으로 처리하는 것이 안전합니다.
-- id 인덱스를 Index Scan으로 활용하는 안전한 범위 쿼리
SELECT id, payload
FROM events
WHERE id >= uuid_from_timestamp('2025-01-01 00:00:00+00')
AND id < uuid_from_timestamp('2025-02-01 00:00:00+00') -- 다음 달 시작을 상한으로
ORDER BY id;-- EXPLAIN ANALYZE 출력 (인덱스 사용)
Index Scan using events_pkey on events (cost=0.43..8.45 rows=1 width=48)
Index Cond: ((id >= '01942e80-...'::uuid) AND (id < '0194a800-...'::uuid))예시 3: 커서 기반 페이지네이션
앞서 만든 헬퍼 함수를 커서 페이지네이션에도 자연스럽게 활용할 수 있습니다. OFFSET 기반 페이지네이션은 OFFSET 10000처럼 깊어질수록, 인덱스를 타더라도 앞의 모든 행을 세어 건너뛰어야 해서 성능이 선형으로 저하됩니다. UUIDv7은 시간순 단조증가 특성 덕분에 keyset pagination에 자연스럽게 맞습니다.
-- 첫 페이지
SELECT id, uuid_extract_timestamp(id) AS created_at, payload
FROM events
ORDER BY id
LIMIT 20;
-- 이전 페이지 마지막 id를 커서로 사용
-- $1 = 이전 페이지 마지막 id (애플리케이션에서 바인딩 파라미터로 전달)
SELECT id, uuid_extract_timestamp(id) AS created_at, payload
FROM events
WHERE id > $1
ORDER BY id
LIMIT 20;WHERE id > :cursor 조건이 B-tree 인덱스를 그대로 타기 때문에, 수백만 건 테이블에서도 일정한 응답 속도를 기대할 수 있습니다. 한 가지 덧붙이자면, 실제로 이 커서 값을 외부 API 응답에 그대로 노출했다가 UUID에서 생성 시각이 추출된다는 걸 나중에 깨닫고 팀에서 지적받은 경험이 있습니다. 외부에 커서를 노출할 때는 암호화하거나 불투명 토큰으로 감싸는 편이 좋습니다.
장단점 분석
장점
| 항목 | 내용 |
|---|---|
| 컬럼·인덱스 절감 | created_at 컬럼과 인덱스 제거로 스토리지, 쓰기 오버헤드 감소 |
| B-tree 친화적 삽입 | 단조증가 키로 페이지 분할·단편화 최소화 |
| 인덱스 성능 | UUIDv4 대비 쿼리 실행 시간 약 44% 단축 (수백만 건 규모 벤치마크 기준) |
| 정렬·페이지네이션 | ORDER BY id만으로 시간순 정렬, 커서 페이지네이션 최적 |
| 표준화 | RFC 9562 공식 표준으로 크로스-시스템 호환성 확보 |
단점 및 주의사항
| 항목 | 내용 | 대응 방안 |
|---|---|---|
| 타임스탬프 노출 | ID만으로 생성 시각 추출 가능, 외부 API 노출 시 정보 유출 위험 | 외부 응답에는 별도 불투명 토큰 사용 |
| 스토리지 크기 | BIGSERIAL(8바이트) 대비 UUID(16바이트)로 2배 |
실제 서비스 규모에 따라 BIGSERIAL 유지 검토 |
| 함수 기반 필터 | WHERE uuid_extract_timestamp(id) > ...는 인덱스 미사용 |
uuid_from_timestamp() 헬퍼로 경계값 UUID 변환 |
| 세션 간 단조성 | 단조증가는 동일 세션 내에서만 보장 | 밀리초 이하 순서가 중요하면 추가 정렬 기준 고려 |
| 레거시 UUID 불호환 | UUIDv4 등 기존 값에 uuid_extract_timestamp() 적용 불가 (NULL 반환) |
uuid_extract_version(id) = 7 조건으로 필터 |
| VIRTUAL 생성 컬럼 | PostgreSQL 18에서 새로 도입된 VIRTUAL 생성 컬럼은 인덱싱 불가 | 타임스탬프 인덱스가 필요하면 STORED 컬럼으로 생성 |
VIRTUAL vs STORED 생성 컬럼: PostgreSQL 18은 저장 없이 조회 시 계산만 하는 VIRTUAL 생성 컬럼을 새로 지원하지만, 인덱스를 걸 수는 없습니다.
uuid_extract_timestamp(id)를 자주 인덱스로 활용하고 싶다면GENERATED ALWAYS AS (...) STORED방식을 선택하거나, 기존처럼created_at컬럼을 유지하는 편이 맞습니다.
실무에서 가장 흔한 실수
uuid_extract_timestamp()필터를 운영 쿼리에 그대로 사용:WHERE uuid_extract_timestamp(id) >= ...패턴은 Sequential Scan을 유발합니다.EXPLAIN ANALYZE를 실행해보면Seq Scan on events가 그대로 드러납니다.uuid_from_timestamp()헬퍼로 경계값 UUID를 만들어WHERE id >= ... AND id < ...형태로 바꾸는 것을 권장합니다.- 레거시 테이블에 함수를 그냥 붙이기: UUIDv4로 채워진 기존
id컬럼에uuid_extract_timestamp()를 적용하면 기존 데이터는 전부 NULL을 반환합니다. 마이그레이션 없이 함수를 붙이는 것은 신규 레코드에만 유효합니다. 신규 테이블부터 적용하거나 단계적으로 전환하는 접근이 필요합니다. - 다중 노드 환경에서 밀리초 이하 순서를 신뢰하기: 단조증가는 동일 세션 안에서만 보장됩니다. 다중 서버·다중 세션 환경에서 동일 밀리초 내 전역 순서까지 기대하면 예기치 않은 순서 역전을 만날 수 있습니다. 엄격한 전역 순서가 필요한 시스템이라면 이 점을 감안해 설계하는 것이 좋습니다.
마치며
id 컬럼 하나가 기본키이자 타임스탬프 컨테이너이자 시계열 인덱스가 되는 것, UUIDv7이 가져온 가장 실용적인 변화입니다.
지금 바로 시작해볼 수 있는 3단계입니다.
- PostgreSQL 18 환경 준비: Neon 무료 티어나 Docker로
postgres:18beta이미지를 띄워SELECT uuidv7(), uuid_extract_timestamp(uuidv7());한 줄로 동작을 확인해볼 수 있습니다. PostgreSQL 17 이하라면pg_uuidv7확장(CREATE EXTENSION pg_uuidv7;)으로 동일한 함수 시그니처를 미리 사용해볼 수 있습니다. - 헬퍼 함수 추가 및 테이블 설계: 위에서 소개한
uuid_from_timestamp()함수를 마이그레이션 파일에 추가하고, 신규 테이블은DEFAULT uuidv7()+created_at제거 패턴으로 작성해보시면 좋습니다. 기존 테이블 마이그레이션 비용이 크다면 신규 테이블부터 적용해 두 패턴을 병행하는 것이 현실적입니다. - 쿼리 플랜 확인:
EXPLAIN (ANALYZE, BUFFERS)로 기존WHERE created_at >= ...쿼리와WHERE id >= uuid_from_timestamp(...) AND id < uuid_from_timestamp(...)쿼리의 Buffers hit/read 수치를 비교해볼 수 있습니다.Seq Scan과Index Scan의 차이를 숫자로 직접 확인하는 순간, 왜 이 패턴으로 전환해야 하는지 설득력이 생깁니다.
참고 자료
- PostgreSQL 18 공식 문서 — UUID 함수 (9.14)
- PostgreSQL 18 릴리스 노트
- RFC 9562 — Universally Unique IDentifiers (UUIDs)
- UUIDv7 Comes to PostgreSQL 18 — The Nile
- UUID v7 in PostgreSQL 18 — Better Stack Community
- PostgreSQL 18 UUIDv7 Support — Neon
- UUIDv7 in PostgreSQL 18: What You Need to Know — DbVisualizer
- Exploring PostgreSQL 18's new UUIDv7 support — Aiven
- PostgreSQL 18's UUIDv7: Faster and Secure Time-Ordered IDs — Hashrocket
- PostgreSQL UUID Performance: Benchmarking Random (v4) and Time-based (v7) — DEV Community
- Postgres UUIDv7: Performance vs. Privacy
- Postgres UUIDv7 + per-backend monotonicity — brandur.org
- Get Excited About Postgres 18 — Crunchy Data
- uuid_extract_timestamp() — pgPedia