기본키를 UUID로 했더니 느려질 때 — UUIDv4·UUIDv7·BIGSERIAL 비교

기본키를 UUID로 했더니

기본키를 UUID로 했더니 인서트가 느려질 수 있다는 이야기, 실제로 겪어보면 사실일까요? 조건만 맞으면 사실입니다. 완전히 무작위로 생성되는 UUIDv4를 기본키로 쓰면 B-Tree 인덱스에 값이 뒤죽박죽 꽂히면서 페이지 분할과 캐시 미스가 늘어나고, 테이블이 수백만 행을 넘기는 시점부터 그 차이가 체감될 만큼 커집니다. 이 문제는 시간 순서를 담은 UUIDv7이나 애초에 정수를 쓰는 BIGSERIAL로 기본키를 바꾸면 상당 부분 줄어듭니다.

기본키를 UUID로 했더니 벌어지는 일: 인서트 지연의 구조적 원인

UUID는 128비트 길이의 식별자 표준이며, PostgreSQL이나 MySQL 같은 관계형 DB는 기본키에 B-Tree 인덱스를 자동으로 걸어둡니다. BIGSERIAL처럼 값이 1씩 증가하면 새 행은 항상 인덱스 트리의 가장 오른쪽 리프 페이지에 추가되기 때문에, 디스크 입출력이 거의 순차적으로 일어납니다.

반면 UUIDv4는 값이 완전히 무작위라서 새 행이 인덱스 트리의 아무 위치에나 꽂힙니다. 이미 가득 찬 페이지에 값이 끼어들면 페이지 분할이 일어나고, 분할된 페이지는 디스크 여기저기로 흩어집니다. 여기에 더해 UUID 자체가 16바이트라 bigint(8바이트)보다 인덱스 크기가 커지고, 캐시에 올려둘 수 있는 유효 페이지 수가 줄어들면서 캐시 적중률도 함께 떨어집니다.

테이블이 작을 때는 인덱스 전체가 메모리에 다 올라가 있어서 이 차이가 거의 드러나지 않습니다. 하지만 행 수가 늘어나 인덱스가 메모리 밖으로 밀려나는 시점부터 디스크 랜덤 I/O가 누적되면서 인서트 지연이 뚜렷하게 나타납니다.

왜 하필 UUIDv4가 지목받는 걸까요?

UUID에는 버전이 여럽 있는데, 버전마다 비트를 채우는 방식이 다릅니다. 과거에 쓰이던 UUIDv1은 타임스탬프와 네트워크 카드의 MAC 주소를 조합해 만들어서 정렬은 잘 되지만, MAC 주소가 노출되는 보안 문제 때문에 많은 시스템이 꺼리게 되었습니다.

B-Tree 인덱스 구조와 데이터베이스 서버 성능을 나타내는 이미지

그 대안으로 자리잡은 것이 UUIDv4입니다. 122비트를 전부 무작위 값으로 채우기 때문에 추측이나 충돌 위험은 거의 없지만, 정렬 정보를 전혀 담지 않는다는 대가를 치릅니다. 결국 보안 문제를 피하려고 선택한 방식이 인덱스 지역성 문제를 새로 만들어낸 셈입니다. 이 간극을 메우기 위해 나온 것이 시간 정보와 무작위성을 같이 담는 최신 버전입니다.

UUIDv7은 타임스탬프와 무작위성을 함께 담습니다

UUIDv7 구조는 앞쪽 48비트에 밀리초 단위 유닉스 타임스탬프를 넣고, 버전·변형 비트를 제외한 나머지 약 74비트를 무작위 값으로 채웁니다. 타임스탬프가 앞자리를 차지하다 보니 값 전체가 생성 시각 순으로 정렬되고, 새로 들어오는 행은 UUIDv4와 달리 인덱스 트리의 오른쪽 끝 근처에 계속 쌓입니다. 사실상 BIGSERIAL과 비슷한 쓰기 패턴을 가지면서도, 노드마다 중앙 서버 없이 독립적으로 값을 생성할 수 있습니다.

PostgreSQL은 18 버전부터 uuidv7()과 uuidv4() 함수를 핵심 기능으로 기본 제공해서, 별도 확장 설치 없이 바로 사용할 수 있습니다. 아래는 실제로 동작하는 테이블 정의 예시입니다.

-- UUIDv4: 완전 무작위 (PostgreSQL 13+ gen_random_uuid 기본 제공)
CREATE TABLE orders_v4 (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  created_at timestamptz NOT NULL DEFAULT now()
);

-- UUIDv7: 시간 순 정렬 가능 (PostgreSQL 18+ uuidv7 기본 제공)
CREATE TABLE orders_v7 (
  id uuid PRIMARY KEY DEFAULT uuidv7(),
  created_at timestamptz NOT NULL DEFAULT now()
);

-- BIGSERIAL: 순차 정수
CREATE TABLE orders_serial (
  id bigserial PRIMARY KEY,
  created_at timestamptz NOT NULL DEFAULT now()
);

세 테이블 모두 같은 트랜잭션 양을 처리해도, 인덱스 내부에서 값이 꽂히는 위치가 서로 다르다는 점이 체감 성능 차이의 핵심입니다.

저장 크기·정렬성·분산 생성 기준으로 골라보세요

세 방식의 특성을 같은 기준으로 정리하면 다음과 같습니다.

UUID 고유 식별자 코드가 표시된 화면

항목 UUIDv4 UUIDv7 BIGSERIAL
저장 크기 16바이트 16바이트 8바이트
시간 순 정렬 안 됨 (완전 무작위) 됨 (앞 48비트가 타임스탬프) 됨 (1씩 증가)
여러 서버가 동시에 충돌 없이 생성 가능 가능 중앙 시퀀스 필요
B-Tree 인덱스 쓰기 지역성 나쁨 좋음 좋음
생성 순서·대략적 시각 추정 가능성 거의 불가능 가능 (타임스탬프 노출) 가능 (행 번호 그대로 노출)

여러 리전, 여러 서버에서 중앙 조정 없이 키를 만들어야 하면서 외부에 순서나 시각을 드러내고 싶지 않다면 UUIDv7이 균형점입니다. 단일 서버에서만 쓰고 순서 노출이 전혀 문제되지 않는 내부 시스템이라면 BIGSERIAL이 저장 공간도 작고 가장 가볍습니다.

운영 중인 UUIDv4 PK를 지금 UUIDv7로 바꿔도 될까요?

바로 바꾸는 건 권장하기 어렵습니다. 기본키 타입이나 값 체계를 바꾸려면 해당 테이블을 참조하는 모든 외래키, 유니크 제약, 조인 쿼리까지 영향을 받고, 행이 많은 테이블이면 전체 재작성 자체가 긴 잠금을 유발할 수 있습니다. 보통은 새 컬럼을 추가해 백필하고, 일정 기간 두 값을 동시에 쓰면서 애플리케이션을 전환한 뒤, 마지막에 기본키를 교체하는 순서로 진행합니다.

UUIDv7로 바꾼다고 모든 상황이 좋아지는 것도 아닙니다. 타임스탬프가 앞자리에 그대로 드러나기 때문에, 이 값을 공개 URL이나 API 응답에 그대로 노출하면 대략적인 가입·주문 시각이나 전체 처리량을 외부에서 짐작할 수 있는 단점이 있습니다. 또한 해시 기반으로 데이터를 여러 샤드에 분산하는 시스템에서는 값이 시간순으로 몰리는 성격이 오히려 특정 샤드에 쓰기가 집중되는 핫스팟을 만들 수 있어서, 이런 환경에서는 UUIDv4의 무작위성이 더 유리할 수 있습니다.

테이블 크기와 분산 환경 여부를 먼저 따져보세요

기본키를 UUID로 했더니 느려지는 현상은 테이블 크기가 작을 때는 거의 드러나지 않다가, 인덱스가 메모리 캐시 밖으로 밀려날 만큼 데이터가 쌓이면 뚜렷해집니다. 단일 서버에서 대량 쓰기가 몰린다면 UUIDv7이나 BIGSERIAL로 옮기는 쪽이 유리하고, 여러 노드가 독립적으로 키를 만들어야 하면서 순서 노출을 피하고 싶다면 UUIDv7이, 완전히 폐쇄된 내부 시스템이라면 BIGSERIAL이 가장 단순합니다.

실제로 손대기 전에는 EXPLAIN (ANALYZE, BUFFERS)로 현재 인덱스의 버퍼 사용량을 찍어보고, pg_relation_size로 UUID 기본키 인덱스와 bigint 인덱스의 실제 크기 차이를 먼저 확인해보시는 것을 권해드립니다. 수치로 확인한 뒤에 마이그레이션 여부를 결정하면 불필요한 작업을 줄일 수 있습니다.

도커 이미지 빌드가 매번 느려질 때, 캐시 순서부터 확인하세요

Leave a Comment