Postgres에서 UUID 버전 4 기본 키를 피해야 하는 이유
(andyatkinson.com)- UUID v4는 무작위성이 높아 인덱스 비효율과 과도한 I/O를 유발하며, PostgreSQL에서 기본 키로 사용할 경우 성능 저하가 발생함
- 무작위 삽입으로 인해 페이지 분할(page split) 과 인덱스 단편화가 잦아지고, WAL 로그 크기 증가 및 쓰기 지연이 발생함
- UUID는 16바이트 크기로 bigint보다 두 배의 공간을 차지하며, 캐시 적중률 저하와 메모리 낭비로 이어짐
- 보안 식별자로 오해받지만, RFC 4122에 따르면 UUID는 추측 방지용 보안 수단이 아님
- 새로운 데이터베이스에는 정수형 시퀀스 기반 키를 사용하고, 불가피할 경우 시간순 UUID v7을 사용하는 것이 권장됨
UUID v4의 성능 문제
- PostgreSQL에서 UUID v4 기본 키를 사용한 데이터베이스는 지난 10년간 일관되게 성능 저하와 과도한 I/O를 보임
- UUID v4는 122비트가 무작위로 생성되어 인덱스 정렬이 불가능함
- 삽입 시 순차적 페이지에 저장되지 않아 랜덤 접근이 발생, 업데이트·삭제 시에도 비효율적 탐색이 필요함
-
B-Tree 인덱스는 정렬된 데이터를 전제로 하지만, UUID v4는 순서성이 없어 삽입 효율이 낮음
- 각 삽입이 임의의 페이지에 기록되어 중간 페이지 분할이 자주 발생
- 이로 인해 쓰기 지연(latency) 과 WAL 증가가 발생함
UUID의 구조와 대안
- UUID는 128비트(16바이트) 크기의 식별자이며, PostgreSQL에서는 binary uuid 타입으로 저장됨
- UUID v4는 무작위 비트 기반, UUID v7은 앞 48비트에 타임스탬프를 포함해 인덱스 효율이 높음
- PostgreSQL 18(2025년 예정)에서 UUID v7이 기본 지원 예정
- UUID v7은 시간순 정렬이 가능해 페이지 밀도와 캐시 효율이 개선됨
UUID 선택 이유와 한계
- 다중 클라이언트나 마이크로서비스 환경에서 충돌 없는 식별자 생성이 필요할 때 UUID가 사용됨
- 예: 여러 데이터베이스 인스턴스에서 동시에 ID 생성
- 그러나 RFC 4122는 “UUID는 추측이 어렵다고 가정하지 말라”고 명시, 보안 식별자로 부적합
- 충돌 확률은 2.71×10¹⁸개 생성 시 50%로, 현실적으로 충돌 가능성은 낮지만 성능 비용이 큼
UUID의 공간 및 I/O 비효율
- UUID는 bigint(8바이트) 의 두 배, int(4바이트) 의 네 배 공간을 차지
- 대규모 테이블에서는 저장 공간 증가와 백업·복원 시간 증가로 이어짐
-
인덱스 페이지 밀도 실험 결과
- integer 인덱스: 97.64%
- UUID v4 인덱스: 79.06%
- UUID v7 인덱스: 90.09%
- Cybertec 테스트에서 UUID v4 인덱스 조회 시 8.5백만 개 추가 페이지 접근, 31229% I/O 증가 확인
- 동일 조건에서 bigint 인덱스는 27,332 버퍼 접근, UUID v4는 8,562,960 버퍼 접근
캐시 및 메모리 영향
- UUID는 랜덤 분포로 인해 버퍼 캐시 적중률(cache hit ratio) 이 낮음
- 더 많은 페이지를 캐시에 로드해야 하며, 필요한 페이지가 자주 퇴출(eviction) 됨
- 캐시 효율 저하로 쿼리 지연이 발생하며, 메모리 사용량 증가
- 성능 유지를 위해 정기적인 인덱스 재구성(REINDEX CONCURRENTLY) 또는 pg_repack 사용 권장
성능 완화 방안
- 메모리 확장: 데이터베이스 크기의 4배 RAM 확보 권장 (예: DB 25GB → 메모리 128GB)
- work_mem 조정: 정렬 연산 시 더 많은 메모리 할당으로 성능 개선 가능
-
Rails 환경에서는
implicit_order_column설정을 통해 UUID 대신created_at등 정렬 가능한 필드 사용 - CLUSTER 명령으로 정렬 가능한 필드 기준으로 테이블 재배치 가능하나, 배타적 락 필요
정수형 키와 시퀀스 권장
- 신규 데이터베이스에는 정수형 시퀀스 기반 키 사용 권장
-
integer(4바이트)는 약 20억 개,bigint(8바이트)는 훨씬 더 많은 고유 값 제공
-
- 대부분의 비즈니스 앱은 integer로 충분하며, 대규모 서비스는 bigint 사용이 적합
- UUID v4 대신 UUID v7 또는 sequential_uuids 확장을 사용하는 것이 현실적 대안
요약
- UUID v4는 랜덤성으로 인한 인덱스 비효율, 높은 I/O, 낮은 캐시 효율을 초래
- 보안 식별자로 사용할 수 없으며, 공간 낭비가 큼
- 정수형 시퀀스 키가 대부분의 애플리케이션에 더 적합
- 불가피하게 UUID를 사용해야 한다면 시간순 UUID v7을 선택해야 함
- PostgreSQL에서 gen_random_uuid() 를 기본 키로 사용하는 것은 피해야 함
Hacker News 의견들
-
이건 전형적인 조기 최적화의 예시임
영구 식별자에 데이터를 담는 건 데이터 관리의 금기사항임
노르웨이 주민번호처럼 생년월일을 ID에 넣으면, 나중에 출생일을 잘못 알았던 이민자들이 생기거나, 1월 1일 생이 너무 많아 번호가 모자라는 문제를 겪게 됨
과거 카드 카탈로그 시절엔 조회 비용을 줄이려 데이터와 식별자를 섞는 게 이해되지만, 지금은 강력한 데이터베이스가 있으니 굳이 그럴 필요가 없음- 이 예시는 사실 잘못된 기본값 설정 문제라고 생각함
모르는 생일을 1월 1일로 지정한 게 문제였지, 날짜를 키에 넣은 게 본질적 문제는 아님
만약 00이나 99 같은 비날짜 값을 썼다면 충돌이 없었을 것임
UUID에 타임스탬프를 넣는 건 의미 부여가 아니라 성능 최적화 목적임
시간 순으로 증가하는 키는 B-tree 재작성 비용을 줄여 DB 삽입 성능을 높임 - 이탈리아 주민번호도 성별을 포함하는데, 성전환 수술 후엔 문제가 생김
“영구 식별자엔 데이터를 담지 말라”는 건 일반론일 뿐, 상황에 따라 트레이드오프를 감수할 수도 있음
예를 들어 md5 해시를 UUID로 써서 인덱스를 구성하면 단편화는 생기지만 관리 가능한 수준임 - UUIDv7은 단순히 시간 편향(random bias) 을 가진 생성 방식일 뿐, 실제 정보를 담는 건 아님
랜덤 vs 시간 기반 UUID 선택은 밀리초 단위가 아니라 초 단위의 성능 차이를 만들 수 있음 - 작은 DB에선 조기 최적화지만, 규모가 커지면 오히려 반대의 접근이 필요함
대규모 DB에선 샤딩과 분산이 필수라 UUID가 오토인크리먼트보다 낫게 작동함 - 노르웨이 주민번호 예시에 대해, 실제로 1월 1일 생이 그렇게 많을 수 있는지 의문임
포맷이 DDMMYYXXXXX라면 10만 명까지 커버 가능한데, 정말 그 정도로 몰릴 수 있는지 궁금함
아마 특정 연도에 난민이 대거 유입된 경우 같은 특수 상황일 것 같음
- 이 예시는 사실 잘못된 기본값 설정 문제라고 생각함
-
UUID는 보안 토큰처럼 쓰면 안 됨
추측이 어렵다는 이유만으로 보안 기능으로 쓰는 건 위험함
랜덤 값의 목적은 추측 방지뿐 아니라, 연속된 ID 간의 관계를 숨기는 데 있음 -
DB 종류에 따라 PK 전략이 완전히 달라짐
Postgres에선 랜덤 PK가 비효율적이지만, Cockroach나 Spanner 같은 분산 DB에선 오히려 단조 증가 키가 핫샤드 문제를 일으킴- 분산 DB에서도 완전 랜덤보단 증가 경향이 있는 키가 좋음
UUIDv7은 상위 비트가 정렬 가능하고 하위 비트는 랜덤이라 노드 간 분산성과 로컬 저장 효율을 동시에 얻을 수 있음 - DB 종류보단 DB 구조의 차이로 봐야 함
일반적인 비샤딩 DB에선 랜덤 키가 B-tree 단편화를 유발함 - Google Cloud Bigtable에선 순차 키를 역순(reverse) 으로 써서 자동 분산을 이끌어냄
- 샤딩된 Postgres라면 랜덤 PK가 유리함
하지만 범위 조회(range query)가 많다면 랜덤 키는 불리함
결국 워크로드 특성에 따라 선택해야 함 - 쓰기 중심, 시간 편향이 큰 워크로드라면 Postgres에서도 랜덤 PK가 더 나을 수 있음
- 분산 DB에서도 완전 랜덤보단 증가 경향이 있는 키가 좋음
-
글의 요지는 UUIDv4를 PK로 쓰는 단점을 잘 짚었지만, 제시된 정수 난독화 방식은 실서비스엔 부적합해 보임
소규모 DB라면 UUIDv7이 합리적 절충안임- 나는 UUIDv7 대신 UUIDv4를 선호함
생성 시각이 노출되길 원치 않기 때문임
데이터가 많아 UUIDv4의 랜덤성이 성능 문제를 일으키지 않는 한, v4가 더 안전한 선택임 - Postgres에선 단일 시퀀스를 쓰는 걸 좋아함
약간의 정보 노출은 있지만, 운영상 충분히 모호함 - 단순히 사용자 수를 숨기고 싶다면, 오토인크리먼트 키에 암호화(permutation) 를 적용하면 됨
예를 들어 AES-128로 변환 후 base64로 인코딩하면 YouTube 영상 ID처럼 보이게 만들 수 있음
- 나는 UUIDv7 대신 UUIDv4를 선호함
-
나는 기술 실사 중 많은 회사를 보는데, 빠른 샤딩 가능성이 기업 성장의 핵심임
UUID를 모든 테이블에 두면 샤딩 시 구조 변경 없이 확장 가능함
약간의 공간·시간 손해보다 훨씬 큰 확장성 이점을 줌- UUIDv7은 단조 증가 특성 덕분에 Postgres에서도 성능상 이점이 있음
- 우리도 샤딩 과정에서 UUID가 없어 고생했음
결국 복잡한 데이터 모델이라 UUID 유무보다 마이그레이션 자체가 어려웠음
-
우리 앱은 정수 PK를 암호화해 UUID처럼 보이게 만듦
순차 ID가 노출되면 고객 수 추정이나 딕셔너리 공격이 가능하기 때문임
암호화된 ID는 복호화 실패로 스캐닝 시도를 즉시 탐지할 수 있음- 하지만 키 분실이나 교체 시 복호화 불가 문제가 생길 수 있음
- 키 관리 방식이 궁금함 — 환경 변수로 주입하는지, 코드에 내장하는지, AES-GCM 같은 AEAD 스킴을 쓰는지 등 보안 관리가 중요함
-
“20억 개면 충분하다”는 말은 위험함
모든 DBA는 그런 결정으로 시작된 악몽 같은 사례를 하나쯤 갖고 있음 -
글에서 “랜덤 값은 정렬이 비효율적”이라 했는데, 사실 바이트 순서 정렬은 가능함
다만 랜덤 키는 순차 삽입이 아니므로 B-tree 재균형이 자주 일어나 성능 저하가 생김- UUIDv4는 분산 환경에서 유용하지만, 128비트 공간과 비순차성의 대가를 감수해야 함
- 글쓴이는 이후 B-tree 인덱스 비교 실험을 추가했다고 함
정수 PK는 인덱스가 메모리에 잘 맞고, UUIDv4는 페이지 접근이 많아 지연(latency) 이 커졌다고 함 - 기술적으로 근거가 약하다는 의견도 있었음
- B-tree는 증가 키일수록 삽입 효율이 높고, 랜덤 키는 캐시 친화성이 떨어짐
- 데이터 접근이 생성 시점과 밀접할수록, 시간 순 정렬이 성능상 유리함
-
이 글은 문제보다 해결책이 먼저 나온 조기 최적화로 보임
UUIDv4는 대부분의 경우 충분히 괜찮음
성능 문제는 실제로 발생할 때 고려해야 함- 하지만 한 번 UUIDv4로 시작하면 나중에 int64로 재키(rekey) 하긴 거의 불가능함
- 실제로 성능 문제가 생길 땐 이미 성장 단계라 PK를 바꿀 여유가 없음
-
요약하자면, Postgres에선 UUIDv7이 v4보다 약간 더 나은 성능을 보임
최신 버전에선 플러그인 없이도 UUIDv7 지원이 가능함- 다만 글의 핵심은 “가능하면 시퀀스·정수형 PK를 쓰라”는 권장임
- Postgres 18부터는 내장 uuidv7() 함수가 있지만, 확장 기능이 더 많은지는 아직 불분명함
- 대부분의 사용자는 이제 별도 확장이 필요 없을 것임