UUID를 DB Primary Key로 사용하면 실제로 어떤 문제가 생길까?
UUID(Universally Unique Identifier)는 분산 시스템이나 여러 서버 환경에서 충돌 없이 고유한 식별자를 생성할 수 있어 매력적인 선택지처럼 보입니다. 특히 마이크로서비스 아키텍처나 외부 API와 연동하는 경우, 자동 증가 정수 키(AUTO_INCREMENT)보다 UUID가 더 안전하다는 인식도 있습니다.
그런데 실제로 UUID를 Primary Key로 도입한 뒤 성능 문제를 경험하는 경우가 적지 않습니다. 이 글에서는 UUID를 Primary Key로 사용했을 때 어떤 실질적인 문제가 발생하는지, 그 원인은 무엇인지, 그리고 문제를 줄이기 위한 방법은 무엇인지 구체적으로 살펴봅니다.
UUID란 무엇인가
UUID는 128비트 길이의 식별자로, 일반적으로 550e8400-e29b-41d4-a716-446655440000과 같은 형태의 32자리 16진수 문자열로 표현됩니다. 버전에 따라 생성 방식이 다르며, 가장 널리 쓰이는 UUID v4는 무작위(random) 값 기반입니다.
핵심 특성은 중앙 서버 없이도 충돌 가능성이 극히 낮은 고유 식별자를 독립적으로 생성할 수 있다는 점입니다. 이 특성 덕분에 여러 서버나 클라이언트가 동시에 레코드를 생성해도 ID가 겹칠 우려가 거의 없습니다.
왜 Primary Key로 문제가 될까
인덱스 단편화 문제
대부분의 관계형 데이터베이스는 Primary Key를 기준으로 클러스터드 인덱스(Clustered Index)를 구성합니다. MySQL InnoDB가 대표적인 예입니다. 클러스터드 인덱스는 데이터가 키 순서에 따라 물리적으로 정렬된 상태로 저장됩니다.
AUTO_INCREMENT 정수 키를 사용하면 새로운 레코드는 항상 가장 마지막 페이지에 순차적으로 추가됩니다. 반면 UUID v4처럼 무작위로 생성된 값을 키로 사용하면, 새 레코드가 기존 데이터 중간 어딘가에 삽입됩니다. 이 과정에서 B-Tree 인덱스 페이지 분할(page split)이 자주 발생하고, 결과적으로 인덱스 단편화가 심해집니다.
인덱스 단편화가 쌓이면 다음과 같은 문제가 연쇄적으로 나타납니다.
- 디스크 I/O 증가
- 캐시 효율 저하(버퍼 풀 활용도 감소)
- 쓰기 성능 저하
- 전체적인 쿼리 응답 시간 증가
데이터 양이 적을 때는 체감하기 어렵지만, 수백만 건 이상의 데이터가 쌓이면 성능 차이가 눈에 띄게 나타나기 시작합니다.
저장 공간 낭비
UUID를 문자열(VARCHAR 또는 CHAR(36))로 저장하면 36바이트를 차지합니다. 하이픈을 제거해도 32바이트입니다. 반면 4바이트 INT나 8바이트 BIGINT와 비교하면 4~9배 더 많은 공간을 사용합니다.
Primary Key는 단순히 한 컬럼에만 영향을 주지 않습니다. InnoDB 기준으로 세컨더리 인덱스(Secondary Index)는 내부적으로 Primary Key 값을 포함합니다. 테이블에 인덱스가 여러 개 있다면, UUID를 Primary Key로 사용할 때 모든 세컨더리 인덱스의 크기가 덩달아 커집니다. 이는 메모리(버퍼 풀) 사용량 증가로 이어집니다.
가독성과 운영 편의성 저하
숫자 ID와 달리 UUID는 사람이 읽기 어렵습니다. 운영 환경에서 로그를 분석하거나, 특정 레코드를 직접 조회해야 하는 상황에서 550e8400-e29b-41d4-a716-446655440000 같은 값을 다루는 건 번거롭습니다. 실수로 잘못된 ID를 복사하는 오류도 자연스럽게 늘어납니다.
어떤 상황에서 특히 심각한가
모든 환경에서 UUID가 문제가 되는 것은 아닙니다. 다음 조건이 겹칠수록 문제가 두드러집니다.
- 데이터 건수가 많은 경우: 수십만 건 이하에서는 성능 차이가 거의 없습니다. 수백만 건을 넘기면서 인덱스 단편화가 누적됩니다.
- 쓰기 비율이 높은 경우: 읽기 중심 워크로드보다 삽입이 잦은 워크로드에서 페이지 분할 빈도가 높아집니다.
- MySQL InnoDB처럼 클러스터드 인덱스를 사용하는 DB: PostgreSQL은 힙(heap) 기반 저장 방식으로 클러스터드 인덱스 구조가 아니기 때문에 InnoDB에 비해 UUID 삽입 패턴의 영향을 덜 받습니다. 다만 인덱스 크기나 정렬 문제는 PostgreSQL에서도 존재합니다.
- 세컨더리 인덱스가 많은 경우: Primary Key 크기가 모든 인덱스에 영향을 미치므로, 인덱스가 많을수록 공간 및 성능 영향이 커집니다.
문제를 완화하는 방법
UUID v7 또는 ULID 사용
UUID v4의 근본적인 문제는 무작위성입니다. 이를 해결하는 대안으로 UUID v7과 ULID가 있습니다.
UUID v7은 타임스탬프를 앞부분에 포함하도록 설계되어, 생성 순서대로 정렬됩니다. 즉, 새로운 UUID가 항상 기존 값보다 큰 값을 가지게 되어 순차 삽입과 유사한 효과를 냅니다. 인덱스 단편화 문제를 크게 줄일 수 있습니다.
ULID(Universally Unique Lexicographically Sortable Identifier)도 같은 철학을 공유합니다. 48비트 타임스탬프와 80비트 무작위 값으로 구성되어, 문자열로 정렬해도 생성 순서와 일치합니다.
두 방식 모두 UUID의 전역 고유성을 유지하면서 정렬 가능성을 추가했다는 점에서 DB Primary Key로서 훨씬 적합합니다.
바이너리 형식으로 저장
UUID를 문자열이 아닌 BINARY(16)으로 저장하면 16바이트로 줄어듭니다. VARCHAR(36) 대비 절반 이하의 공간을 사용하며, 비교 연산도 빠릅니다. MySQL에서는 UUID_TO_BIN() 함수와 BIN_TO_UUID() 함수를 활용해 변환할 수 있습니다.
-- UUID를 BINARY(16)으로 변환하여 저장
INSERT INTO orders (id, amount) VALUES (UUID_TO_BIN(UUID()), 10000);
-- 조회 시 다시 문자열로 변환
SELECT BIN_TO_UUID(id), amount FROM orders;
MySQL 8.0부터는 UUID_TO_BIN(uuid, 1) 옵션을 사용해 타임스탬프 부분을 앞으로 재배열하여 순차 정렬 효과를 얻을 수도 있습니다.
정수 ID와 UUID를 함께 사용하는 방법
내부 Primary Key는 AUTO_INCREMENT 정수로 유지하고, UUID는 별도 컬럼으로 추가하는 방식도 현실적인 선택입니다. 내부 조인, 외래 키 관계 등에는 정수 ID를 사용해 성능을 확보하고, 외부 노출이 필요한 경우(API 응답, URL 등)에는 UUID 컬럼을 활용합니다.
이 방식은 보안 관점에서도 유리합니다. 정수 ID를 외부에 노출하면 레코드 수를 추측할 수 있고 열거 공격(enumeration attack)에 취약해집니다. UUID를 외부 식별자로 사용하면 이 위험을 줄일 수 있습니다.
정리: UUID Primary Key, 언제 써도 될까
| 상황 | 권장 여부 |
|---|---|
| 소규모 프로젝트, 낮은 쓰기 빈도 | 사용 가능, 큰 문제 없음 |
| 대용량 데이터, 높은 삽입 빈도 (MySQL InnoDB) | 주의 필요, UUID v7 또는 ULID 권장 |
| 분산 시스템, 여러 노드에서 ID 생성 | UUID 계열 필요, 순차형 UUID 권장 |
| 외부 API, URL에 ID 노출 | UUID 적합 (정수 ID와 병행 사용도 고려) |
| PostgreSQL 사용 | InnoDB보다 영향 적음, 그러나 인덱스 크기 여전히 고려 필요 |
UUID를 Primary Key로 쓰는 것 자체가 나쁜 선택은 아닙니다. 문제는 어떤 버전의 UUID를, 어떤 형식으로, 어떤 데이터베이스에 저장하느냐입니다. UUID v4를 문자열로, MySQL InnoDB에 쓰는 조합이 가장 문제가 많습니다. 반대로 UUID v7이나 ULID를 바이너리로 저장하면 많은 단점을 해소할 수 있습니다.
설계 단계에서 데이터 규모, 쓰기 빈도, 사용하는 데이터베이스 엔진을 함께 고려해 Primary Key 전략을 결정하는 것이 중요합니다. UUID의 편리함이 필요하다면, 그 비용을 알고 선택하는 것과 모르고 사용하는 것은 큰 차이가 있습니다.