UUID v1·v4·v7 비교와 DB 기본키 설계

2026-04-13 공개 8분 읽기

요약 (TL;DR)

이렇게 말하면 논쟁이 좀 붙겠지만, 고볼륨 삽입 테이블의 기본키로 v4 UUID를 쓰는 건 발등 찍기에 가깝습니다. 제가 살펴본 한 PostgreSQL 16 워크로드는 events 테이블 기본키를 uuid v4(gen_random_uuid())로 두고 있었는데, 매 INSERT가 B-tree의 무작위 잎(leaf) 페이지에 떨어지며 shared_buffers를 식히고 인덱스 단편화가 한 자릿수 후반대 퍼센트로 누적되고 있었습니다. 컬럼 타입을 v7로 바꾸자 — 같은 16바이트 uuid 타입, 같은 인덱스 — 평균 INSERT 지연이 약 1/3 수준으로 떨어졌고, pg_stat_user_indexes의 단편화 지표도 안정되었습니다. UUID 버전 선택은 “암호학 결정”이 아니라 데이터베이스 설계 결정입니다. UUID는 128비트 값으로, 8-4-4-4-12 16진 표기에 4비트는 버전에, 2–3비트는 variant에 고정 배정됩니다. v1은 시각과 노드 식별자(역사적으론 MAC 주소)를 새겨 넣어 대략 시간순 정렬이 되지만 호스트를 노출합니다. v4는 122비트 난수로 강한 비예측성을 얻는 대신 정렬이 되지 않습니다. v7은 RFC 9562(2024)에서 표준화된 버전으로, 상위 48비트에 Unix 밀리초 타임스탬프를 두고 나머지를 난수로 채워 v4의 안전성과 v1의 인덱스 지역성을 결합합니다. 공개 API ID처럼 “언제 만들었는지 가리는 것”이 진짜로 중요한 곳에서는 v4가 여전히 기본값입니다. 그 외 거의 모든 곳에서는 v7이 더 나은 선택이고, v1은 레거시로 — DB가 받아 주더라도 새 설계의 기본값으로는 고르지 마세요.

배경/개념

UUID는 128비트입니다. 관례적으로는 하이픈으로 묶인 32자리 16진 수로 쓰입니다. xxxxxxxx-xxxx-Mxxx-Nxxx-xxxxxxxxxxxx. M 자리에 버전(1, 4, 7 등)이 들어가고, N의 상위 비트가 variant를 표시합니다. 나머지는 버전마다 의미가 다릅니다.

버전 1은 머신과 시각을 가로지르는 유일성을 목표로 설계됐습니다. 60비트 타임스탬프는 1582-10-15 이래 100나노초 간격을, 클록 시퀀스 필드는 시계 되돌림을, 48비트 노드 ID는 원래 네트워크 MAC 주소를 담았습니다. 프라이버시 측면의 결과는 직설적입니다. 노트북에서 발급된 v1 UUID에는 그 노트북의 MAC이 인코딩되어 있고, uuid CLI 한 줄로 역산됩니다. 현대 라이브러리는 누수를 피하려고 노드 ID를 무작위화하기도 하지만, 여전히 원래 규칙을 따르는 구현도 많습니다.

버전 4는 122비트 난수에 버전·variant용 6비트를 고정한 형태입니다. 강한 RNG(브라우저의 crypto.randomUUID()나 PostgreSQL의 gen_random_uuid())를 가정합니다. 현실적인 규모에서 충돌은 사실상 불가능합니다 — 생일 한계로 약 10억 개를 생성해도 충돌 확률은 1조 분의 1 수준입니다. 단점은 연속해서 발급한 두 v4가 서로 무관하다는 점이고, 그래서 인덱스 삽입 시 페이지가 무작위로 터치되어 캐시 지역성과 쓰기 증폭(WAL·풀 페이지 쓰기) 모두에 나쁩니다.

버전 7은 2024년에 발간된 RFC 9562의 일부로, 이 RFC가 RFC 4122를 폐기하면서 v6·v7·v8을 추가했습니다. v7은 상위에 48비트 Unix 밀리초 타임스탬프를 두고, 버전 태그, 소규모 rand_a, variant 태그, 62비트 rand_b 꼬리를 이어 붙입니다. 실용적인 효과는 같은 밀리초에 발급된 v7이 정렬상 서로 인접하고, 밀리초를 가로질러서는 시간순으로 정렬된다는 것입니다. 난수 꼬리만으로도 같은 밀리초 안에서 유일성을 확보할 엔트로피로 충분합니다. PostgreSQL 18에는 네이티브 uuidv7()이 들어왔고, 그 이전 버전에서도 pg_uuidv7 확장이나 애플리케이션 레벨 라이브러리(Node uuid 9.x, Python uuid6)로 같은 결과를 얻을 수 있습니다.

variant 비트가 중요한 이유는 RFC 9562/4122 계열과 레거시 마이크로소프트·아폴로 UUID를 구분하기 때문입니다. 이 글에서는 RFC variant(위의 N 그룹 첫 16진수가 8, 9, a, b 중 하나)를 가정합니다.

저장 형식은 별개의 문제입니다. PostgreSQL 16의 네이티브 uuid 타입은 값을 16바이트로 저장합니다. MySQL은 보통 BINARY(16) 또는 CHAR(36)을 쓰는데, 후자는 저장공간을 두 배로 늘리고 비교를 문자 단위로 만듭니다. 버전 선택과 저장 형식은 서로 얽힙니다. v7을 바이너리로 정렬하면 저렴하고 정확하며, 16진 문자열로 정렬해도 정확하지만 느리고, v4는 어떻게 저장해도 정렬에 의미가 없습니다.

비교/데이터

속성v1v4v7
생성 입력타임스탬프 + 클록 시퀀스 + 노드 ID122비트 난수48비트 Unix ms + 난수 꼬리
프라이버시노드 ID(흔히 MAC) 노출호스트·시간 정보 없음생성 시각(ms)은 드러남, 호스트는 없음
시간 정렬가능(단 바이트 순서 ≠ 시간 순서, 재배열 필요)불가가능 — 사전식 순서가 시간순과 일치
인덱스 지역성중간나쁨(B-tree 전역에 무작위 삽입)좋음(거의 단조 증가)
대표 용도레거시 시스템, 일부 COM/Windows ID공개 API ID, 세션 토큰, 솔트이벤트 로그, 고볼륨 삽입, 시간 페이지네이션
엔트로피낮음(대부분 시간·노드)높음(약 122비트)꼬리 높음(약 74비트), ms 내 충돌 희박

대략적인 멘탈 모델은 이렇습니다. v4는 예측 불가능성을 극대화하는 대신 인덱스 성능을 희생하고, v7은 대부분의 응용에서 충분한 예측 불가능성을 유지하면서 DB가 좋아하는 “끝에 삽입” 패턴을 복원하며, v1은 역사적 유산이라 존재는 알아둬야 하지만 선택하지는 말아야 하는 쪽에 가깝습니다.

실전 시나리오

시나리오 1 — 추가 중심의 이벤트 로그. 도입부에서 언급한 워크로드가 정확히 이 모양이었습니다. 하루 수백만 행이 들어오고, 보통 “최근 24시간, 시간순”으로 조회되는 테이블은 v7이 직접 이득입니다. 새 행이 기본키 인덱스 끝에 붙으니 핫 페이지가 따뜻하게 유지되고, 시간 범위 질의는 인접 인덱스 세그먼트로 매핑됩니다. v4에서 v7로 컬럼 디폴트만 바꿔도 쿼리 코드는 그대로인데 쓰기 지연과 인덱스 단편화가 줄어드는 경우가 흔합니다.

시나리오 2 — 공개 사용자 대면 ID. /orders/{id} 같은 공유 링크는 다른 사용자 주문을 열거당하지 않도록 예측 불가능해야 합니다. v4가 안전한 기본값입니다. v7의 이점도 원한다면, v7이 밀리초 단위 생성 시각을 드러낸다는 점을 기억하세요. 주문에는 괜찮을 수 있어도, 더 민감한 맥락(분당 결제 건수 같은 비즈니스 신호)에서는 누설이 됩니다. 제가 한 팀에 권한 절충안은 v7을 내부 기본키로 두고, 외부에는 별도의 v4 또는 짧은 무작위 슬러그를 노출하는 이중 ID 패턴이었습니다 — 인덱스 성능을 잃지 않으면서 외부에 시간 정보를 흘리지 않습니다.

시나리오 3 — 멀티 리전·샤드 시스템. v7의 타임스탬프 접두는 서로 다른 리전이 같은 밀리초에 UUID를 발급해도 시간순으로 자연스럽게 섞이도록 해주지만, 같은 밀리초 내부에서 리전 간 순서 보장은 없습니다. 리전 간 더 엄격한 순서가 필요하면 ULID(48비트 타임스탬프 + 80비트 난수를 Crockford Base32로 표현)가 거의 동일한 특성을 더 짧은 26자 텍스트로 제공합니다. 그보다 엄격한 보장이 필요하다면 명시적 머신 ID가 포함된 Snowflake 스타일 ID(트위터 원본·디스코드 변형 모두 64비트)가 있지만, 머신 ID를 할당할 조정(coordination) 을 추가로 짊어져야 합니다.

자주 하는 오해

“UUID는 DB에서 항상 느리다.” 4바이트 int보다는 느리지만, 실제 비용의 상당 부분은 B-tree 인덱스의 무작위 삽입 단편화이고 이건 v7이 거의 제거해 줍니다. 36자 문자열이 아니라 16바이트로 저장하면 인덱스 크기가 절반이 되고 비교가 빨라집니다. “UUID는 느리다” 벤치마크 다수는 사실 “MySQL에서 CHAR(36)로 저장한 v4가 느리다”에 가깝습니다.

“v4만이 안전하다.” v7의 난수 꼬리도 충분히 큰 엔트로피 풀이라, 대부분의 응용에서 — 세션 참조, API ID — 공격자가 열거하기는 비현실적입니다. 문제는 예측 가능성이 아니라 타임스탬프 누설로, v7은 행이 언제 생성됐는지를 드러냅니다. 그 누설이 받아들일 만하다면(대개는 그렇습니다) 외부 ID에도 v7은 합리적인 선택입니다.

“UUID는 문자열로 저장해야 한다.” 문자열 형태는 36자(16진 32자 + 하이픈 4자)지만 바이너리는 16바이트이고, 바이트 정렬이 곧 올바른 정렬이 됩니다. v1의 비단조 바이트 순서나 v7의 바이트 순서가 시간과 맞아야 하는 점을 생각하면 바이너리 저장이 유리합니다. PostgreSQL의 uuid 타입은 이미 16바이트로 저장하므로 따로 바이너리 변환을 고민할 필요가 없습니다.

“v1의 MAC 누설은 아무도 안 본다.” v1에서 MAC을 역산하는 건 공개된 변환이고, 포렌식 도구(uuid -d 등)는 기본적으로 이를 뽑아냅니다. UUID가 URL·지원 티켓·외부에 공유되는 로그에 포함된다면, 이는 실제 정보 노출입니다.

체크리스트 또는 의사결정 플로우

  1. 자주 삽입되는 인덱스의 키로 쓰이는가? v7이 기본. 생성 시각의 비예측성이 진짜로 필요할 때만 v4.
  2. UUID가 사용자·파트너에게 보이는가? 어느 쪽이든 동작합니다. v7의 타임스탬프 누설을 받아들일 만한지만 확인.
  3. Postgres인가? 네이티브 uuid(16바이트) 저장. MySQL에서는 문자열 호환이 정말 필요하지 않는 한 BINARY(16).
  4. 다중 생성기 간 순서 보장이 필요한가? v7만으로는 부족. ULID(같은 시간 접두) 또는 명시적 머신 ID를 포함하는 Snowflake 스타일.
  5. 코드베이스에 v1이 남아 있는가? MAC 누설을 문서화하고, 스키마가 허락하는 시점에 이전 계획을 세우세요.
  6. 클라이언트에서 생성하는가? 암호학적으로 강한 RNG를 호출하는 라이브러리를 사용하세요(최신 브라우저의 v4는 crypto.randomUUID(), v7은 보통 같은 RNG를 감쌉니다).

관련 도구

Patrache Studio의 UUID 생성기는 v4와 v7을 로컬에서 만들어 주므로 생성된 값이 제3자 서비스 로그에 남지 않습니다. UUID는 거의 항상 JSON 페이로드 안에 실려 다니는데, JSON 포매팅·검증·JSON Schema의 차이와 실무 활용에서 그 ID들을 서비스 간 이동 중에도 타입 있게 유지하는 스키마 패턴을 다룹니다. 16바이트 UUID에서 파생된 22자짜리 짧은 ID 같은 간결한 텍스트 표현이 필요할 때는, Base64·URL 인코딩: 왜 필요하고 언제 잘못 쓰는가에서 표준 Base64가 아닌 Base64URL을 골라야 하는 이유를 설명합니다.

참고 자료