~ / posts / developer

DB 기본키 UUID vs 자동증가 정수, UUIDv7이 인덱스에 유리한 이유

자동증가 정수는 작고 빠르지만 분산 생성과 외부 노출에 약하고, UUID v4는 랜덤이라 B-Tree 인덱스 페이지가 흩어져요. 50만 건 시뮬레이션에서 v4식 랜덤 삽입은 페이지 44% 증가, 작업 페이지 53배였어요. v7이 이를 해결하는 원리와 저장 방식을 정리했어요.

새 테이블을 만들 때마다 "기본키를 BIGINT AUTO_INCREMENT로 할까, UUID로 할까"를 고민하게 돼요. 예전에는 "UUID는 느리니까 쓰지 마라"가 정답처럼 통했는데, 그 말의 정확한 뜻은 "랜덤한 UUID v4를 클러스터드 인덱스 기본키로 쓰면 삽입 위치가 흩어져서 느리다"예요. 결론부터 말하면 단일 DB에서 내부용으로만 쓰는 테이블은 자동증가 정수가 여전히 가장 단순하고 효율적이고, 여러 곳에서 ID를 미리 만들어야 하거나 ID가 외부에 노출된다면 UUID v7을 16바이트 바이너리(또는 네이티브 uuid 타입)로 저장하는 게 좋은 절충이에요. v7은 시간순으로 증가해서 v4의 인덱스 문제를 대부분 피합니다.

이 글에서는 두 방식의 장단점을 실무 기준으로 비교하고, v4가 왜 인덱스에 불리한지를 직접 돌린 B-Tree 시뮬레이션 숫자로 보여 드린 다음, 저장 크기 계산과 MySQL·PostgreSQL에서의 저장 방법까지 정리했어요. UUID 자체의 구조와 버전 차이는 UUID v4와 v7 차이 글을 먼저 보시면 좋아요.

핵심 요약

자동증가 정수는 4~8바이트로 작고 항상 인덱스 끝에 추가돼 빠르지만, DB가 번호를 발급해야 하고 ID로 데이터 규모가 드러나요.

UUID v4는 랜덤이라 새 행이 인덱스 곳곳에 끼어들어 페이지 분할이 잦고, 자주 건드리는 페이지가 크게 늘어요.

UUID v7은 앞부분이 시각이라 자동증가처럼 끝에 쌓여서, 분산 생성의 장점은 유지하면서 인덱스 문제를 대부분 피해요.

UUID는 CHAR(36) 문자열 대신 BINARY(16)이나 네이티브 uuid 타입으로 저장해야 크기가 절반 이하로 줄어요.

자동증가 정수 vs UUID, 장단점 비교

먼저 큰 그림부터 볼게요.

기준자동증가 정수 (BIGINT)UUID v4UUID v7
크기8바이트 (INT는 4)16바이트16바이트
생성 위치DB만 가능어디서나어디서나
삽입 순서항상 증가무작위시간순 증가
인덱스 삽입 성능매우 좋음나쁨 (흩어짐)좋음
외부 노출 시개수·증가 속도 추측 가능정보 없음생성 시각 노출
URL 추측 공격1, 2, 3… 순서대로 시도 가능사실상 불가시각 부분은 추측 가능, 나머지 랜덤
사람이 읽기쉬움 (주문 10234)어려움어려움
데이터 병합번호 충돌충돌 없음충돌 없음

자동증가 정수의 약점은 성능이 아니라 발급 주체가 DB 하나라는 데 있어요. 그래서 이런 상황에서 UUID가 필요해집니다.

UUID v4가 인덱스에 불리한 이유: B-Tree 페이지 분할

MySQL InnoDB는 테이블 데이터 자체를 기본키 순서로 정렬된 B-Tree(클러스터드 인덱스)에 저장해요. PostgreSQL은 테이블(힙)과 인덱스가 분리돼 있지만, 기본키 인덱스는 역시 B-Tree입니다. B-Tree의 리프 페이지(InnoDB 기본 16KB)에는 키가 정렬된 상태로 들어 있어요.

키가 항상 증가하면 새 행은 언제나 맨 오른쪽 리프 페이지에 추가돼요. 그 페이지가 차면 새 페이지를 오른쪽에 하나 붙이면 끝이에요. 메모리에 올려 둬야 하는 "뜨거운" 페이지는 오른쪽 끝 몇 개뿐입니다.

키가 랜덤이면 새 행이 이미 꽉 찬 중간 페이지에 끼어들어야 해요. 자리가 없으면 페이지를 반으로 쪼개고(페이지 분할), 상위 노드를 고치고, 두 페이지를 디스크에 다시 씁니다. 쪼개진 페이지는 반만 차 있는 상태로 남고요. 그리고 다음 삽입은 또 전혀 다른 페이지로 가요.

순차 키는 B-Tree 오른쪽 끝 페이지에만 추가되고, 랜덤 키(UUID v4)는 여러 리프 페이지에 흩어져 삽입되면서 페이지 분할이 생기는 모습을 비교한 그림
순차 키는 끝에만, 랜덤 키는 곳곳에 끼어든다

직접 돌려 본 시뮬레이션 결과

말로만 하면 감이 안 와서, 파이썬으로 단순한 B-Tree 리프 계층을 흉내 내 50만 건을 넣어 봤어요. 리프 하나에 키 100개가 들어가고, 꽉 차면 반으로 나누는 규칙입니다(순차 삽입은 맨 끝에서 새 페이지를 여는 규칙). 한쪽은 0, 1, 2…처럼 증가하는 키, 다른 쪽은 같은 키를 무작위로 섞은 순서로 넣었어요.

50만 건 삽입 시뮬레이션, 순차 키 vs 랜덤 키
순차 키 (자동증가·v7)랜덤 키 (v4)
02,0004,0006,0008,000전체 리프 페이지 수 · 순차 키 (자동증가·v7) 5,000페이지전체 리프 페이지 수 · 랜덤 키 (v4) 7,207페이지전체 리프 페이지 수마지막 1만 건이 건드린 페이지 수 · 순차 키 (자동증가·v7) 100페이지마지막 1만 건이 건드린 페이지 수 · 랜덤 키 (v4) 5,326페이지마지막 1만 건이 건드린 페이지 수

랜덤 키는 페이지가 44% 더 많고, 최근 삽입이 건드리는 페이지는 53배 많아요

단위: 페이지 · 자료: 리프당 100키, 분할 시 반분할 규칙의 단순 B-Tree 리프 시뮬레이션 (파이썬 직접 실행, 50만 건)
표로 보기
구분순차 키 (자동증가·v7)랜덤 키 (v4)
전체 리프 페이지 수5,0007,207
마지막 1만 건이 건드린 페이지 수1005,326

결과가 꽤 극적이에요.

실제 InnoDB는 순차 삽입 시 페이지를 약 15/16까지 채우고 1/16을 여유로 남겨요. MySQL 공식 문서도 순차 삽입이면 페이지가 약 15/16, 랜덤 삽입이면 1/2에서 15/16 사이로 채워진다고 설명합니다. 시뮬레이션의 숫자와 같은 방향이에요.

테이블이 작을 때는 이 차이가 거의 안 느껴져요. 인덱스 전체가 메모리에 들어가면 랜덤 삽입도 메모리 안에서 끝나니까요. 문제는 데이터가 커져서 인덱스가 메모리를 넘는 순간부터 나타나고, 그때는 기본키를 바꾸기가 가장 어려운 시점이라는 게 함정이에요.

UUID v7이 인덱스에 유리한 이유

v7은 앞 48비트가 Unix 밀리초라서, 나중에 만든 값이 항상 더 커요(같은 밀리초 안에서는 카운터나 랜덤으로 순서 결정). 그러니 B-Tree 입장에서는 자동증가 정수와 거의 같은 패턴으로 오른쪽 끝에만 쌓입니다. 위 시뮬레이션의 "순차 키" 쪽에 해당해요.

그러면서도 UUID의 장점은 그대로예요. 애플리케이션 서버 어디서든 DB에 묻지 않고 ID를 만들 수 있고, 여러 서버에서 만든 값을 합쳐도 충돌하지 않아요. 서버마다 시계가 조금씩 어긋나 있으면 밀리초 단위에서 약간 순서가 뒤섞이지만, 대부분 오른쪽 끝 근처 몇 페이지 안에서 일어나는 일이라 성능 영향은 작습니다.

v7의 대가는 하나예요. 앞부분에 생성 시각이 들어 있어서, 외부에 그대로 노출하면 "이 계정이 언제 만들어졌는지"가 드러나요. 가입 시각이 민감한 서비스라면 내부 기본키는 v7, 외부 공개용 ID는 v4로 따로 두는 설계를 고려하세요.

저장 크기 계산: BIGINT 8바이트 vs UUID 16바이트

UUID가 정수보다 크다는 건 맞지만, 얼마나 큰지는 저장 방식에 따라 크게 달라요. 특히 InnoDB는 모든 보조 인덱스의 각 항목에 기본키 값을 복사해 저장하기 때문에, 기본키 크기가 보조 인덱스 수만큼 곱해집니다.

1억 행 기준 기본키 값이 차지하는 공간 (InnoDB, 보조 인덱스 3개 가정)
기본키 인덱스보조 인덱스 3개의 PK 복사본
02.557.51012.515INT · 기본키 인덱스 0.4GBINT · 보조 인덱스 3개의 PK 복사본 1.2GBINTBIGINT · 기본키 인덱스 0.8GBBIGINT · 보조 인덱스 3개의 PK 복사본 2.4GBBIGINTUUID BINARY(16) · 기본키 인덱스 1.6GBUUID BINARY(16) · 보조 인덱스 3개의 PK 복사본 4.8GBUUID BINARY(16)UUID CHAR(36) · 기본키 인덱스 3.6GBUUID CHAR(36) · 보조 인덱스 3개의 PK 복사본 10.8GBUUID CHAR(36)

CHAR(36)은 ASCII 기준 최소 크기이고, 문자셋·행 형식에 따라 더 커질 수 있어요

단위: GB · 자료: 1억 행 × 키 바이트 × (1 + 보조 인덱스 3개)로 직접 계산, 페이지·행 오버헤드 제외한 키 값만의 크기
표로 보기
구분기본키 인덱스보조 인덱스 3개의 PK 복사본
INT0.41.2
BIGINT0.82.4
UUID BINARY(16)1.64.8
UUID CHAR(36)3.610.8

1억 행에 보조 인덱스 3개라면 기본키 값만으로 BIGINT는 3.2GB, UUID를 BINARY(16)으로 저장하면 6.4GB, CHAR(36) 문자열로 저장하면 14.4GB예요. BINARY(16)과 CHAR(36)의 차이(2.25배)가 BIGINT와 BINARY(16)의 차이(2배)보다 큽니다. UUID를 쓰기로 했다면 문자열로 저장하지 않는 게 첫 번째 원칙이에요.

PostgreSQL은 보조 인덱스가 기본키가 아니라 행 위치(TID, 6바이트)를 가리켜서 이 곱셈 효과가 InnoDB보다 작아요. 다만 다른 테이블의 외래키 컬럼과 그 인덱스에는 여전히 16바이트가 들어가니, 참조가 많은 테이블일수록 크기 차이가 쌓입니다.

MySQL·PostgreSQL에서 UUID 저장하는 법

PostgreSQL

네이티브 uuid 타입이 16바이트로 저장하니 그냥 쓰면 돼요. gen_random_uuid()는 v4를 만들고, PostgreSQL 18부터는 uuidv7() 함수가 기본 제공됩니다. 그 이전 버전이라면 애플리케이션에서 v7을 만들어 넣으면 돼요.

CREATE TABLE orders (
  id         uuid PRIMARY KEY DEFAULT uuidv7(),   -- PostgreSQL 18+
  public_id  uuid NOT NULL DEFAULT gen_random_uuid(),  -- 외부 공개용 v4 (선택)
  created_at timestamptz NOT NULL DEFAULT now()
);

MySQL

MySQL은 uuid 전용 타입이 없어서 BINARY(16)을 씁니다. 주의할 점은 MySQL 내장 UUID() 함수가 v1을 만든다는 거예요. v1은 시간의 하위 비트가 앞에 와서 그대로 넣으면 정렬이 안 되는데, UUID_TO_BIN(UUID(), 1)처럼 두 번째 인수에 1을 주면 시간 비트를 재배치해 순차적으로 만들어 줍니다. 꺼낼 때도 BIN_TO_UUID(id, 1)로 같은 플래그를 줘야 해요.

CREATE TABLE orders (
  id BINARY(16) PRIMARY KEY,   -- 앱에서 만든 UUID v7을 16바이트로 저장
  created_at DATETIME(3) NOT NULL
);
INSERT INTO orders VALUES (UUID_TO_BIN('01a0b72d-62fb-7a3f-9c21-5e8b0d4f6a17'), NOW(3));
SELECT BIN_TO_UUID(id) FROM orders;

v7을 애플리케이션에서 만들어 넣는다면 플래그 없이 UUID_TO_BIN(값)으로 그대로 저장하세요. v7은 이미 정렬 가능한 순서라 재배치하면 오히려 순서가 깨집니다.

상황별 추천: 어떤 기본키를 고를까

상황추천
단일 DB, 내부 관리용 테이블, ID 노출 없음BIGINT 자동증가
ID가 URL·API로 노출되는 서비스내부 BIGINT + 외부 공개용 UUID v4 컬럼, 또는 UUID v7 기본키
여러 서버·서비스에서 ID 생성, 샤딩UUID v7
오프라인 생성 후 동기화 (모바일 앱)UUID v7 (또는 v4)
이미 UUID v4 기본키로 운영 중인 대형 테이블새 테이블부터 v7, 기존 테이블은 성능 문제를 확인한 뒤 판단
로그·이벤트·메시지 IDUUID v7 (시간순 정렬이 곧 조회 순서)

"내부 BIGINT + 외부 UUID" 조합은 꽤 실용적이에요. 조인과 외래키는 작은 정수로 하고, 사용자에게 보이는 URL에는 UUID를 써서 추측 공격과 규모 노출을 막는 거죠. 대신 컬럼과 인덱스가 하나 더 생기니, 외부 노출 ID가 정말 필요한 테이블에만 쓰세요.

흔한 실수

자주 묻는 질문

UUID를 기본키로 쓰면 정말 느린가요?

랜덤한 UUID v4를 클러스터드 인덱스 기본키로 쓰고, 테이블이 메모리보다 커지면 느려져요. 삽입이 인덱스 곳곳에 흩어지면서 페이지 분할과 디스크 읽기가 늘기 때문이에요. 시간순으로 증가하는 UUID v7을 쓰면 이 문제를 대부분 피할 수 있습니다.

MySQL에서 UUID는 어떤 타입으로 저장해야 하나요?

BINARY(16)을 쓰세요. CHAR(36)보다 2배 이상 작고 비교도 빨라요. 문자열과의 변환은 UUID_TO_BIN과 BIN_TO_UUID 함수로 하고, MySQL의 UUID() 함수(v1)를 쓸 때만 두 번째 인수 1로 시간 비트를 재배치합니다.

자동증가 ID를 외부에 노출하면 왜 안 좋나요?

번호가 순서대로라 전체 데이터 수와 증가 속도가 드러나고, 권한 검사가 허술하면 번호를 바꿔 가며 다른 사람의 데이터를 조회하는 공격이 쉬워져요. 외부에는 UUID 같은 추측하기 어려운 ID를 보여 주고, 권한 검사는 반드시 서버에서 하세요.

이미 UUID v4 기본키를 쓰고 있는데 바꿔야 하나요?

테이블이 메모리에 충분히 들어가고 삽입 성능에 문제가 없다면 급하게 바꿀 필요는 없어요. 새로 만드는 값만 v7로 바꾸면 이후 삽입은 오른쪽 끝에 쌓이기 시작합니다. 기본키 타입 자체를 바꾸는 마이그레이션은 외래키까지 영향이 커서 신중해야 해요.

PostgreSQL에서도 v4와 v7 차이가 있나요?

있어요. PostgreSQL은 테이블과 인덱스가 분리돼 있어 InnoDB보다 영향이 작지만, 기본키 B-Tree 인덱스에는 같은 원리로 랜덤 삽입의 페이지 분할과 캐시 미스가 생겨요. PostgreSQL 18부터는 uuidv7() 함수를 기본으로 쓸 수 있습니다.

정리

자동증가 정수는 작고 빠르지만 DB 하나가 번호를 발급해야 하고 외부에 노출되면 정보가 새요. UUID v4는 어디서나 만들 수 있지만 랜덤이라 B-Tree 인덱스에 흩어져 들어가고, 시뮬레이션에서 페이지는 44%, 최근 작업 페이지는 53배 늘었어요. UUID v7은 시간순으로 증가해서 두 방식의 장점을 대부분 가져가고, BINARY(16)이나 네이티브 uuid 타입으로 저장하면 크기 부담도 BIGINT의 2배 수준으로 관리됩니다.

UUID 생성기에서 v7을 여러 개 만들어 보면 앞자리가 순서대로 증가하는 걸 바로 확인할 수 있어요. 대량 테스트 데이터가 필요하면 SQL IN 형식이나 JSON 배열로 1,000개까지 한 번에 복사할 수 있습니다.

#UUID 기본키#auto increment vs UUID#UUIDv7 인덱스#기본키 설계#MySQL UUID 성능#PostgreSQL uuid#B-Tree 페이지 분할#BINARY(16)
← 이전 글WCAG 명도 대비 4.5:1 기준과 계산 공식, 통과하는 색 찾는 법다음 글 →타임존 처리 원칙 — KST·UTC 저장과 DB 타입, 날짜 집계 함정