UUID vs 순차 ID vs ULID: 기본키를 잘못 고르면 나중에 생기는 일
순차 ID의 정보 노출, UUID의 인덱스 비용, ULID·UUIDv7의 절충을 비교하고 내부 키와 공개 키를 분리하는 구성까지 정리했습니다.
기본키는 나중에 바꾸기 가장 어려운 것
테이블을 만들 때 id INTEGER PRIMARY KEY 한 줄은 고민 없이 넘어가기 쉽습니다. 그런데 이 선택은 외래 키, URL, 로그, 외부 시스템 연동까지 퍼져나가서 나중에 바꾸려면 거의 전면 수정이 됩니다.
선택지는 크게 세 가지입니다. 데이터베이스가 매기는 순차 번호, 무작위 UUID, 그리고 시간 순서를 담은 UUID 계열입니다. 갈리는 지점은 세 가지입니다. 누가 생성하는가, 노출해도 되는가, 정렬되는가.
순차 ID: 작고 빠르지만 정보를 흘린다
데이터베이스가 1, 2, 3 순으로 매기는 방식입니다.
장점이 분명합니다. 8바이트 이하로 작아서 인덱스가 가볍고, 값이 순서대로 들어오니 인덱스 끝에만 추가되어 삽입이 효율적입니다. 사람이 읽고 말하기도 쉽습니다.
문제는 값 자체가 정보라는 것입니다. /orders/1043을 받은 사용자는 주문이 대략 천 건 있다는 걸 압니다. 며칠 뒤 다시 주문해서 1102가 나오면 그 사이 증가량까지 계산됩니다. 경쟁사도 같은 방법을 씁니다.
더 직접적인 위험은 순회입니다. 숫자를 하나씩 바꿔가며 접근할 수 있으므로, 권한 검사가 빠진 엔드포인트가 하나라도 있으면 전체 데이터가 노출됩니다. 이건 ID 방식의 문제라기보다 권한 검사의 문제지만, 순차 ID는 그 실수의 대가를 훨씬 크게 만듭니다.
생성 주체도 제약입니다. 값을 데이터베이스가 정하므로 저장하기 전에는 ID를 모릅니다. 연관된 여러 행을 한 번에 만들어야 할 때 순서가 강제됩니다.
UUID: 어디서든 만들 수 있지만 인덱스가 아프다
128비트 무작위 값입니다. 흔히 쓰는 것은 v4입니다.
장점은 클라이언트나 애플리케이션이 미리 만들 수 있다는 점입니다. 저장 전에 ID가 정해지니 연관 데이터를 한꺼번에 구성할 수 있고, 여러 출처에서 생성해도 충돌하지 않습니다. 오프라인 상태에서 만든 데이터를 나중에 동기화하는 구조라면 사실상 필수입니다.
노출해도 순서나 총량이 드러나지 않는 것도 장점입니다.
대가는 인덱스입니다. 값이 무작위라 새 행이 인덱스 여기저기에 흩어져 들어갑니다. 데이터가 커질수록 삽입 비용이 올라가고, 인덱스가 메모리에 다 올라가지 않는 시점부터 차이가 체감됩니다. 크기도 순차 ID의 두 배 이상이고, 이 값이 모든 외래 키에 복제됩니다.
문자열로 저장하면 상황이 더 나빠집니다. 36자 텍스트가 됩니다. 전용 타입이 있는 데이터베이스라면 반드시 그걸 쓰세요.
ULID·UUIDv7: 앞쪽에 시간을 넣는다
무작위성의 장점은 두고 인덱스 문제를 줄이려는 방식입니다. 값의 앞부분에 생성 시각을 넣고 뒷부분만 무작위로 채웁니다.
앞쪽이 시간순이므로 새로 만든 값은 인덱스 끝에 모입니다. 순차 ID의 삽입 효율에 가까워지면서, 값을 미리 만들 수 있다는 성질은 유지됩니다. 정렬하면 대략 생성 순서가 나오는 것도 실무에서 편리합니다. 별도 정렬 컬럼 없이 최근 항목을 뽑을 수 있습니다.
주의할 점은 생성 시각이 값에 담긴다는 사실입니다. ID만 보면 언제 만들어졌는지 알 수 있습니다. 대부분의 경우 문제가 없지만, 생성 시점 자체를 숨겨야 하는 데이터라면 맞지 않습니다.
ULID와 UUIDv7은 목적이 같고 표현 방식이 다릅니다. ULID는 26자 문자열로 읽기 편하고, UUIDv7은 기존 UUID 타입과 도구를 그대로 쓸 수 있습니다.
내부 키와 외부 키를 나누는 방법
세 방식 중 하나를 고르는 대신, 두 개를 두는 선택지가 있습니다.
CREATE TABLE orders (
id bigserial PRIMARY KEY, -- 내부 조인·외래 키용
public_id text UNIQUE NOT NULL, -- URL·API 노출용
...
);
조인과 외래 키는 작고 순차적인 값으로 처리해 성능을 챙기고, 바깥에 노출되는 것은 추측 불가능한 값으로 씁니다. 컬럼 하나와 인덱스 하나가 늘어나는 대신 양쪽 장점을 가져갑니다.
규모가 커진 서비스에서 흔히 보이는 구성이고, 처음부터 이렇게 시작해도 부담이 크지 않습니다.
정리
| 상황 | 선택 |
|---|---|
| ID가 외부에 노출되지 않는 내부 테이블 | 순차 ID |
| URL·API로 노출되는 자원 | UUID 계열 또는 별도 공개 ID |
| 오프라인 생성·다중 출처 동기화 | UUID 계열 |
| 삽입이 매우 잦은 대형 테이블 | 순차 ID 또는 ULID·UUIDv7 |
| 생성 순서가 자주 필요한 데이터 | ULID·UUIDv7 |
| 생성 시점을 숨겨야 하는 데이터 | UUID v4 |
이미 순차 ID로 만들었는데 바꿔야 하나요?
노출 경로가 있는지부터 보세요. ID가 URL이나 API 응답에 나가고 있고 권한 검사가 모든 경로에 확실히 걸려 있지 않다면 우선순위가 높습니다. 이때 기본키를 통째로 바꾸는 대신 공개용 컬럼을 추가하는 방식이 훨씬 적은 비용으로 끝납니다. 반대로 내부에서만 쓰이는 ID라면 굳이 바꿀 이유가 없습니다.
UUID를 문자열로 저장하면 얼마나 손해인가요?
저장 공간이 두 배 이상이 되고, 비교 연산도 느려집니다. 그리고 이 비용은 해당 테이블만이 아니라 그 ID를 참조하는 모든 외래 키와 인덱스에 붙습니다. 전용 UUID 타입이 있다면 그것을 쓰고, 없는 데이터베이스라면 바이너리로 저장하는 방법을 검토하세요.
하나의 프로젝트에서 방식을 섞어도 되나요?
됩니다. 테이블마다 성격이 다르니 오히려 자연스럽습니다. 다만 규칙을 정해두지 않으면 나중에 어떤 테이블이 무엇을 쓰는지 헷갈립니다. "노출되는 자원은 UUID, 내부 전용은 순차"처럼 기준을 문서에 한 줄 적어두면 충분합니다.
지금 확인해볼 것
지금 만들고 있는 서비스에서 URL에 ID가 드러나는 페이지를 열어보세요. 그 숫자를 하나 바꿔서 접속했을 때 무엇이 나오는지가, 어떤 ID 방식이 필요한지를 알려줍니다.
만든 프로젝트가 배포된 상태라면 Vibeollio에 프로젝트 등록해서 공개해보세요. 실제 방문자가 늘면 URL을 공유하고 수정해보는 일이 생기는데, ID 설계의 허점은 대개 그때 드러납니다.
