junyeokk
Blog
Database·2025. 11. 27

UUID를 Primary Key로 사용하기

데이터베이스 테이블을 설계할 때 가장 먼저 결정하는 것 중 하나가 Primary Key의 타입이다. 대부분의 경우 두 가지 선택지가 있다.

sql
-- 방법 1: Auto Increment
CREATE TABLE users (
  id SERIAL PRIMARY KEY,
  name TEXT NOT NULL
);

-- 방법 2: UUID
CREATE TABLE users (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  name TEXT NOT NULL
);

간단한 프로젝트에서는 SERIAL(auto increment)로 충분하다. 그런데 시스템이 커지면서 여러 서버에서 동시에 데이터를 생성하거나, 서버에 접속하지 않고도 ID를 미리 만들어야 하는 상황이 생기면 auto increment의 한계가 드러난다.


Auto Increment의 한계

Auto increment는 단일 데이터베이스 서버에서는 완벽하게 동작한다. 하지만 구조적인 한계가 있다.

분산 환경에서 충돌

서버가 여러 대일 때, 각 서버가 독립적으로 1, 2, 3... 을 발급하면 같은 ID가 중복된다. 이를 해결하려면 별도의 ID 생성 서비스를 두거나, 서버마다 다른 시작값과 증가폭을 설정해야 한다.

sql
-- 서버 A: 1, 3, 5, 7...
ALTER SEQUENCE users_id_seq INCREMENT BY 2 START WITH 1;

-- 서버 B: 2, 4, 6, 8...
ALTER SEQUENCE users_id_seq INCREMENT BY 2 START WITH 2;

서버가 2대일 때는 이렇게 할 수 있지만, 서버가 늘어날 때마다 설정을 바꿔야 하고 관리가 복잡해진다. 결국 ID 생성을 위한 별도 서비스가 필요하게 되는데, 그 서비스 자체가 단일 장애점(SPOF)이 된다.

항상 DB에 물어봐야 한다

auto increment ID는 INSERT를 실행해야만 값을 알 수 있다. 클라이언트에서 새 엔티티를 만들고 관계를 설정한 뒤 한번에 서버로 보내는 식의 오프라인 시나리오에서는 사용할 수 없다. 모바일 앱이 오프라인에서 데이터를 생성하고 나중에 동기화하는 경우가 대표적이다.

비즈니스 정보 노출

현재 ID가 10,432라면 "이 서비스에 약 1만 건의 데이터가 있다"는 정보가 노출된다. 경쟁사가 주문 ID를 통해 일일 주문량을 추측하거나, 공격자가 ID 범위를 순차 탐색하는 것도 가능하다.

text
https://api.example.com/orders/10432  → 존재
https://api.example.com/orders/10433  → 존재
https://api.example.com/orders/10434  → 404

물론 적절한 인증/인가가 있으면 접근 자체는 막을 수 있지만, ID 패턴으로 데이터의 규모나 생성 속도를 유추할 수 있다는 것 자체가 보안 관점에서는 바람직하지 않다.


UUID란

UUID(Universally Unique Identifier)는 128비트(16바이트) 크기의 식별자로, 중앙 서버 없이도 충돌 확률이 극히 낮은 고유 ID를 생성할 수 있다. 표준 형식은 하이픈으로 구분된 32개의 16진수 문자다.

text
550e8400-e29b-41d4-a716-446655440000

UUID에는 여러 버전이 있다. 가장 많이 쓰이는 것은 v4(완전 랜덤)이고, 최근에는 v7(시간 정렬 가능)이 주목받고 있다.

버전생성 방식특징
v1타임스탬프 + MAC 주소시간 포함하지만 정렬 불가 (little-endian)
v4완전 랜덤가장 널리 사용, 정렬 불가
v7타임스탬프(ms) + 랜덤시간순 정렬 가능, 2024년 RFC 9562 표준화

PostgreSQL에서 UUID 사용

PostgreSQL은 uuid 타입을 네이티브로 지원한다. 별도의 확장 없이 바로 사용할 수 있다.

기본 설정

PostgreSQL 13 이상에서는 내장 함수 gen_random_uuid()로 UUIDv4를 생성할 수 있다.

sql
CREATE TABLE users (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  email TEXT NOT NULL UNIQUE,
  created_at TIMESTAMPTZ DEFAULT now()
);

INSERT INTO users (email) VALUES ('j@example.com');
-- id: 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11' (자동 생성)

uuid-ossp 확장을 설치하면 v1, v3, v5 등 다른 버전의 UUID도 생성할 수 있다.

sql
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
SELECT uuid_generate_v1();  -- 타임스탬프 기반
SELECT uuid_generate_v4();  -- 랜덤 (gen_random_uuid()와 동일)

클라이언트에서 ID 생성

UUID의 핵심 장점은 클라이언트에서 ID를 미리 생성할 수 있다는 것이다. INSERT 전에 ID를 알 수 있으므로 관계 설정이 편리하다.

typescript
import { randomUUID } from 'crypto';

// 서버에 물어보지 않고 ID 생성
const userId = randomUUID();
const profileId = randomUUID();

// 관계를 미리 설정한 뒤 한번에 전송
await api.createUserWithProfile({
  user: { id: userId, email: 'j@example.com' },
  profile: { id: profileId, userId, bio: '...' }
});

이 패턴은 오프라인 동기화, 낙관적 업데이트(optimistic update), 배치 삽입 등에서 유용하다.


성능 이야기: PostgreSQL은 괜찮다

UUID를 PK로 쓸 때 가장 많이 나오는 우려가 "인덱스 성능 저하"다. 그런데 이건 데이터베이스마다 사정이 다르다.

MySQL vs PostgreSQL의 구조적 차이

MySQL의 InnoDB는 클러스터드 인덱스(Clustered Index)를 사용한다. 테이블의 실제 데이터가 PK 순서대로 물리적으로 정렬되어 저장된다. UUIDv4처럼 완전 랜덤한 값이 PK면, 새로운 행을 삽입할 때마다 B-tree의 임의의 위치에 데이터를 끼워넣어야 한다. 이 과정에서 페이지 분할(page split)이 빈번하게 발생하고, 이미 캐시에서 밀려난 페이지를 디스크에서 다시 읽어야 하므로 삽입 성능이 크게 떨어진다.

text
클러스터드 인덱스 (MySQL InnoDB):
PK 순서 = 물리 저장 순서

[1] [2] [3] [5] [6] [8] [9]

         여기에 [4] 삽입하려면
         뒤의 데이터를 모두 밀어야 함 → 페이지 분할

PostgreSQL은 힙(Heap) 구조를 사용한다. 데이터는 삽입된 순서대로(또는 빈 공간이 있는 곳에) 저장되고, PK 인덱스는 별도의 B-tree로 관리된다. PK 값이 랜덤이든 순차적이든 데이터 자체의 물리적 배치에는 영향이 없다.

text
힙 구조 (PostgreSQL):
데이터: 삽입 순서대로 저장 (PK 값과 무관)
인덱스: 별도 B-tree에서 PK → 힙 위치 매핑

데이터 힙: [row7] [row2] [row5] [row1] ...
PK 인덱스: 정렬된 B-tree → 각 row의 힙 위치 포인터

따라서 PostgreSQL에서는 UUIDv4를 PK로 써도 삽입 성능에 큰 영향이 없다. MySQL에서 UUID가 느리다는 벤치마크를 보고 PostgreSQL에서도 같을 거라 생각하면 안 된다.

그래도 존재하는 트레이드오프

성능에 큰 문제가 없다고 해서 아무런 비용이 없는 것은 아니다.

인덱스 크기: UUID는 16바이트, BIGINT(bigserial)는 8바이트다. 인덱스 크기가 약 2배가 되므로 메모리에 캐시되는 인덱스 페이지 수가 줄어든다. 행이 수천만~수억 건이 되면 이 차이가 체감될 수 있다.

조인 비용: FK로 UUID를 참조하는 테이블이 많으면 조인 시 비교 연산 비용이 커진다. 16바이트 비교가 8바이트 비교보다 느리다.

캐시 효율: UUIDv4는 완전 랜덤이므로 범위 쿼리(range scan)에서 캐시 효율이 떨어진다. 순차적인 ID는 인접한 행이 같은 인덱스 페이지에 있을 확률이 높지만, 랜덤 UUID는 페이지가 분산된다.

sql
-- 순차 ID: 인접한 행이 같은 페이지에 있을 확률 높음
SELECT * FROM orders WHERE id BETWEEN 10000 AND 10100;

-- 랜덤 UUID: 100개의 행이 100개의 다른 페이지에 흩어져 있을 수 있음
SELECT * FROM orders WHERE created_at BETWEEN '2026-01-01' AND '2026-01-02';
-- (created_at 인덱스 사용 시에는 문제 없음, PK 기반 범위 조회 시 비효율)

UUIDv7: 정렬 가능한 UUID

UUIDv4의 "정렬 불가" 문제를 해결하기 위해 다양한 대안이 등장했다. Snowflake ID(Twitter), ULID, CUID 등이 있었고, 2024년 5월 RFC 9562로 UUIDv7이 공식 표준이 되었다.

구조

UUIDv7은 상위 48비트에 Unix 타임스탬프(밀리초)를 big-endian으로 저장한다.

text
UUIDv7 구조:
|---- 48비트 타임스탬프(ms) ----|-- 4비트 버전 --|---- 랜덤 ----|
 0                             47              51             127

예시:
0192d44e-c200-7b8a-a3f6-9e2b1c4d5e6f
^^^^^^^^ ^^^^
타임스탬프    버전(7)

타임스탬프가 상위 비트에 있으므로 생성 시간순으로 자연스럽게 정렬된다. 같은 밀리초에 생성된 UUID들 사이에서는 랜덤 부분으로 구분된다.

sql
-- UUIDv7은 시간순 정렬이 된다
SELECT id, created_at FROM events ORDER BY id;
-- id 순서 ≈ created_at 순서

PostgreSQL에서 UUIDv7 사용

PostgreSQL 17 기준으로 아직 네이티브 UUIDv7 생성 함수는 없다. 확장이나 애플리케이션 레벨에서 생성해야 한다.

pg_uuidv7 확장 사용:

sql
-- 확장 설치 (pgxn 또는 소스에서)
CREATE EXTENSION pg_uuidv7;

CREATE TABLE events (
  id UUID PRIMARY KEY DEFAULT uuid_generate_v7(),
  payload JSONB
);

애플리케이션에서 생성 (Node.js):

typescript
// uuid 패키지 v10+
import { v7 as uuidv7 } from 'uuid';

const id = uuidv7();
// '0192d44e-c200-7b8a-a3f6-9e2b1c4d5e6f'

SQL 함수로 직접 구현:

sql
CREATE OR REPLACE FUNCTION uuid_generate_v7() RETURNS uuid AS $$
DECLARE
  unix_ts_ms BIGINT;
  buffer BYTEA;
BEGIN
  unix_ts_ms = extract(epoch FROM clock_timestamp()) * 1000;
  buffer = set_byte(
    set_byte(
      overlay(
        uuid_send(gen_random_uuid())
        PLACING substring(int8send(unix_ts_ms) FROM 3)
        FROM 1 FOR 6
      ),
      6, (get_byte(uuid_send(gen_random_uuid()), 6) & 15) | 112  -- version 7
    ),
    8, (get_byte(uuid_send(gen_random_uuid()), 8) & 63) | 128  -- variant 10
  );
  RETURN encode(buffer, 'hex')::uuid;
END
$$ LANGUAGE plpgsql VOLATILE;

UUIDv7의 장점

특성Auto IncrementUUIDv4UUIDv7
전역 고유성
상태 없이 생성
시간순 정렬
인덱스 효율✅ (가장 좋음)❌ (랜덤 분산)✅ (거의 순차적)
가독성
크기4~8바이트16바이트16바이트

UUIDv7은 시간순으로 증가하므로 B-tree 인덱스에서 항상 오른쪽 끝에 삽입된다. 이는 auto increment와 유사한 패턴이라 인덱스 효율이 UUIDv4보다 훨씬 좋다. 페이지 분할도 거의 발생하지 않는다.


실전 가이드: 언제 뭘 쓸까

Auto Increment를 선택하는 경우

  • 단일 서버, 단일 데이터베이스
  • ID가 외부에 노출되어야 하고 가독성이 중요한 경우 (이슈 번호, 주문 번호)
  • 테이블이 매우 크고 조인이 빈번해서 인덱스 크기가 중요한 경우
  • 기존 시스템과의 호환성이 필요한 경우

UUID를 선택하는 경우

  • 분산 시스템, 마이크로서비스
  • 클라이언트에서 ID를 미리 생성해야 하는 경우
  • 오프라인 동기화가 필요한 모바일 앱
  • 여러 데이터소스를 병합하는 경우
  • ID를 통한 데이터 규모 유추를 막고 싶은 경우

하이브리드 패턴

실무에서는 두 가지를 섞어 쓰는 것도 흔하다. 내부적으로는 auto increment PK를 사용하고, 외부 노출용으로는 별도의 UUID 컬럼을 두는 패턴이다.

sql
CREATE TABLE orders (
  id BIGSERIAL PRIMARY KEY,          -- 내부 PK (빠른 조인)
  public_id UUID DEFAULT gen_random_uuid() UNIQUE,  -- 외부 노출
  amount INTEGER NOT NULL,
  created_at TIMESTAMPTZ DEFAULT now()
);

-- 내부 쿼리: 빠른 정수 조인
SELECT o.*, u.name
FROM orders o
JOIN users u ON o.user_id = u.id;

-- API 응답: UUID만 노출
-- GET /api/orders/a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11

이 방식은 내부 성능과 외부 보안을 모두 확보할 수 있지만, 컬럼이 하나 더 생기고 UUID 인덱스도 추가로 관리해야 한다는 단점이 있다.


ORM에서의 UUID

MikroORM

typescript
@Entity()
export class User {
  @PrimaryKey({ type: 'uuid', defaultRaw: 'gen_random_uuid()' })
  id: string = randomUUID();

  @Property()
  email!: string;
}

id 필드에 기본값으로 randomUUID()를 할당하면, em.create()로 엔티티를 만드는 시점에 이미 ID가 존재한다. DB의 gen_random_uuid()는 애플리케이션에서 ID를 지정하지 않았을 때의 폴백이다.

TypeORM

typescript
@Entity()
export class User {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column()
  email: string;
}

Prisma

prisma
model User {
  id    String @id @default(uuid()) @db.Uuid
  email String @unique
}

정리

UUID를 PK로 쓸지 말지는 "성능이 느려지지 않을까?"보다 "이 시스템에 분산 ID 생성이 필요한가?"로 판단하는 게 맞다. PostgreSQL에서는 힙 구조 덕분에 UUID의 삽입 성능 페널티가 거의 없고, UUIDv7을 쓰면 정렬 문제까지 해결된다.

한 줄 요약: PostgreSQL + UUIDv7이 현시점 가장 균형 잡힌 선택이다. 분산 생성, 시간순 정렬, 합리적인 인덱스 성능을 모두 갖추고 있다. 단, 테이블이 수억 건 이상이고 조인이 매우 빈번한 경우에는 인덱스 크기 차이가 유의미할 수 있으므로 벤치마크로 확인하자.


관련 문서