기본 키 하나가 스키마 전체에 퍼진다
기본 키(Primary Key)는 테이블 하나만의 문제가 아닙니다. 그 키를 참조하는 모든 외래 키 컬럼이 같은 타입을 갖게 되고, 인덱스·조인·API 주소·로그까지 그 값을 들고 다닙니다. 그래서 기본 키 타입은 나중에 바꾸기 가장 어려운 설계 결정 중 하나입니다.
실무에서 가장 많이 고르는 후보는 세 가지입니다.
- bigint — 데이터베이스가 1, 2, 3… 순서대로 매기는 정수
- UUID v4 — 128비트 중 122비트가 무작위인 식별자
- UUID v7 — 앞부분에 생성 시각(밀리초)을 담아 시간순으로 정렬되는 UUID
2025년 9월 25일 정식 출시된 PostgreSQL 18에 uuidv7() 함수가 들어오면서, 세 번째 선택지를 확장 모듈 없이 쓸 수 있게 됐습니다. 각각의 장단점과 고르는 기준을 정리합니다.
bigint: 작고 빠르지만 번호는 데이터베이스가 매긴다
PostgreSQL에서는 GENERATED ALWAYS AS IDENTITY로 선언합니다. 예전의 serial보다 SQL 표준에 맞는 방식입니다.
CREATE TABLE customers (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
email text NOT NULL UNIQUE
);
장점
- 8바이트로 UUID(16바이트)의 절반입니다. 기본 키 인덱스와 이를 참조하는 외래 키 인덱스가 모두 작아집니다.
- 새 값이 항상 인덱스의 오른쪽 끝에 붙어서 쓰기가 효율적입니다.
- 사람이 읽고 말하기 쉽습니다("주문 1042번").
단점
- 번호를 데이터베이스가 매기므로 저장하기 전에는 ID를 알 수 없습니다. 오프라인 앱이나 여러 서비스가 각자 레코드를 만들어 나중에 합치는 구조에서는 불편합니다.
- 주소에 그대로 노출하면 다음 번호를 추측하기 쉽고, 전체 개수나 증가 속도가 드러납니다.
- 여러 데이터베이스의 데이터를 합치면 번호가 충돌합니다.
UUID v4: 어디서든 만들 수 있지만 인덱스에 흩어진다
CREATE TABLE customers (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(), -- PostgreSQL 18의 uuidv4()와 같은 함수
email text NOT NULL UNIQUE
);
장점
- 애플리케이션·클라이언트·다른 서비스 어디서든 미리 만들 수 있고, 사실상 충돌하지 않습니다.
- 추측할 수 없어서 주소에 노출해도 다른 레코드를 짐작하기 어렵습니다.
단점
- 값이 무작위라서 새 행이 B-tree 인덱스의 아무 위치에나 들어갑니다. 쓰기가 많은 테이블에서는 인덱스 페이지가 자주 쪼개지고, 최근에 쓴 데이터가 메모리 캐시에 모이지 않아 디스크 I/O가 늘어납니다.
- 16바이트라 bigint보다 인덱스가 큽니다. 외래 키가 많을수록 차이가 커집니다.
MySQL(InnoDB)처럼 테이블 자체를 기본 키 순서로 저장하는(클러스터드 인덱스) 엔진에서는 이 무작위 삽입의 영향이 테이블 전체에 미칩니다.
UUID v7: 시간순으로 정렬되는 UUID
UUID v7은 2024년 5월 발표된 RFC 9562에 정의된 형식입니다. 128비트 중 앞의 48비트에 Unix 시각(밀리초)을 넣고, 버전·변형 비트를 뺀 나머지를 무작위 값으로 채웁니다. RFC 9562는 가능하면 UUIDv1·v6 대신 UUIDv7을 쓰라고(SHOULD) 권합니다.
예를 들어 019535d9-3df7-79fb-b466-fa907fa17f9e에서 앞의 16진수 12자리(019535d9-3df7)가 생성 시각이고, 그다음 7이 버전입니다.
앞부분이 시각이므로 나중에 만든 값이 대체로 더 큽니다. 그래서 새 행이 bigint처럼 인덱스의 오른쪽 끝에 모이고, UUID v4의 무작위 삽입 문제가 크게 줄어듭니다. 동시에 어디서든 만들 수 있고 무작위 부분 때문에 추측하기 어렵다는 UUID의 장점은 그대로입니다.
주의할 점
- ID만 보면 생성 시각이 드러납니다. RFC 9562도 보안 고려 사항에서 타임스탬프가 아주 작은 공격 표면이 된다고 적고 있습니다. 가입 시각처럼 드러나면 곤란한 정보라면 외부에 보여 주는 ID를 따로 두세요.
- 크기는 v4와 같은 16바이트입니다.
PostgreSQL 18에서 쓰는 법
PostgreSQL 18부터는 확장 없이 바로 쓸 수 있습니다.
CREATE TABLE orders (
id uuid PRIMARY KEY DEFAULT uuidv7(),
customer_id uuid NOT NULL REFERENCES customers(id),
total_amount numeric(12, 2) NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
-- ID에 담긴 생성 시각과 버전 확인
SELECT id,
uuid_extract_timestamp(id) AS id_created_at,
uuid_extract_version(id) AS version
FROM orders
ORDER BY id DESC
LIMIT 10;
PostgreSQL 18의 UUID 관련 함수는 다음과 같습니다.
| 함수 | 설명 |
|---|---|
uuidv7() | 시간순 UUID v7 생성. uuidv7(interval)로 기록할 시각을 그만큼 옮길 수 있음 |
uuidv4() | gen_random_uuid()와 같은 무작위 v4 생성(버전을 드러낸 별칭) |
uuid_extract_timestamp(uuid) | v1·v7에서 생성 시각 추출(다른 버전은 NULL) |
uuid_extract_version(uuid) | 버전 번호 확인 |
uuid_extract_timestamp()가 있다고 created_at 컬럼을 없애지는 마세요. ID는 애플리케이션에서 미리 만들 수도 있고 시각을 옮겨 만들 수도 있어서, 업무에서 쓰는 생성 시각과 항상 같지는 않습니다.
MySQL에서는
MySQL 8.4 문서 기준으로 UUID()는 버전 1 UUID를 만들고, UUID v7을 만드는 내장 함수는 없습니다(네이티브 지원 요청이 MySQL 버그 트래커에 등록돼 있습니다). 현실적인 방법은 두 가지입니다.
- 애플리케이션에서 UUID v7을 만들어
BINARY(16)컬럼에 저장 UUID()를 쓴다면UUID_TO_BIN(UUID(), 1)로 저장 — 두 번째 인자1은 시간 부분의 순서를 바꿔 값이 순차에 가깝게 들어가도록 합니다
어느 쪽이든 문자열(CHAR(36))로 저장하면 두 배 이상 커지므로 바이너리로 저장하는 편이 좋습니다.
상황별로 고르기
| 상황 | 추천 |
|---|---|
| 데이터베이스 하나, ID를 외부에 보여 주지 않음 | bigint identity |
| 클라이언트·여러 서비스가 ID를 미리 만들어야 함, 데이터 병합 | UUID v7 |
| 주소·API에 ID가 노출되지만 생성 시각은 드러나도 괜찮음 | UUID v7 |
| 생성 시각까지 숨겨야 하는 값(초대 링크, 비밀번호 재설정 토큰 등) | UUID v4 또는 별도 무작위 토큰 |
이미 UUID v4로 운영 중이라면 새 테이블부터 v7을 쓰는 것으로 충분한 경우가 많습니다. 기존 키를 바꾸면 참조하는 모든 외래 키를 함께 바꿔야 하므로 효과와 비용을 먼저 따져 보세요.
ERD를 그릴 때 확인할 것
- 외래 키 타입은 참조하는 기본 키와 같아야 합니다.
uuid와text,bigint와integer가 섞이면 조인할 때마다 형 변환이 일어나거나 제약 조건을 만들 수 없습니다. - 기본 키 전략은 테이블마다 따로 정하기보다 스키마 전체에 한 가지 원칙을 두는 편이 실수를 줄입니다. 예외(외부 공개용 토큰 등)는 별도 컬럼으로 분리하세요.
- 기본 키 인덱스 외에 자주 조회하는 외래 키에도 인덱스를 두세요. 키가 16바이트라면 인덱스 크기도 함께 고려해야 합니다.
키 타입을 먼저 정하고 관계를 그리면 외래 키 컬럼의 타입 불일치를 미리 발견할 수 있습니다. 관련 내용은 외래키와 테이블 관계 완전 정복과 데이터베이스 인덱스 완전 가이드에서 더 자세히 다룹니다.